From 63fadb074c3db11cd4abb3927e8e0c5bd27ea5bf Mon Sep 17 00:00:00 2001 From: Wilson Nguyen Date: Tue, 7 Jul 2026 18:26:13 -0700 Subject: [PATCH] Split long-pole runtime test files to unlock file-level parallelism bun runs test files in parallel but tests within a single file serially, so a large file becomes a serial critical path that caps the suite's wall-clock time on multi-core runners regardless of core count. The runtime suite was bounded by chat-task.test.ts (154 tests) at ~23s on a 16-core runner. Split the eight files over ~4s (chat-task, http, tunnel, jobs, email-watchers, chat, chat-needs-input, approval-mode) into balanced parts, preserving every test and each describe's own setup hooks. Give each part a per-file-unique temp root via import.meta.file so parts running in separate worker processes no longer race on a shared /tmp directory. Co-Authored-By: Claude Fable 5 --- .../src/execution/approval-mode.part1.test.ts | 550 ++ ...de.test.ts => approval-mode.part2.test.ts} | 453 - .../execution/chat-needs-input.part1.test.ts | 465 + ...test.ts => chat-needs-input.part2.test.ts} | 306 +- .../src/execution/chat-task.part1.test.ts | 2402 +++++ .../src/execution/chat-task.part2.test.ts | 1328 +++ .../src/execution/chat-task.part3.test.ts | 1307 +++ .../src/execution/chat-task.part4.test.ts | 1394 +++ .../src/execution/chat-task.part5.test.ts | 1388 +++ .../runtime/src/execution/chat-task.test.ts | 6643 -------------- .../{chat.test.ts => chat.part1.test.ts} | 322 - .../runtime/src/execution/chat.part2.test.ts | 443 + packages/runtime/src/http.part1.test.ts | 2328 +++++ packages/runtime/src/http.part2.test.ts | 2660 ++++++ packages/runtime/src/http.part3.test.ts | 2261 +++++ packages/runtime/src/http.part4.test.ts | 1466 +++ packages/runtime/src/http.test.ts | 7944 ----------------- .../src/integrations/tunnel.part1.test.ts | 1777 ++++ .../{tunnel.test.ts => tunnel.part2.test.ts} | 1540 +--- .../src/{jobs.test.ts => jobs.part1.test.ts} | 1896 +--- packages/runtime/src/jobs.part2.test.ts | 1974 ++++ .../src/state/email-watchers.part1.test.ts | 664 ++ ...s.test.ts => email-watchers.part2.test.ts} | 599 -- 23 files changed, 22411 insertions(+), 19699 deletions(-) create mode 100644 packages/runtime/src/execution/approval-mode.part1.test.ts rename packages/runtime/src/execution/{approval-mode.test.ts => approval-mode.part2.test.ts} (55%) create mode 100644 packages/runtime/src/execution/chat-needs-input.part1.test.ts rename packages/runtime/src/execution/{chat-needs-input.test.ts => chat-needs-input.part2.test.ts} (59%) create mode 100644 packages/runtime/src/execution/chat-task.part1.test.ts create mode 100644 packages/runtime/src/execution/chat-task.part2.test.ts create mode 100644 packages/runtime/src/execution/chat-task.part3.test.ts create mode 100644 packages/runtime/src/execution/chat-task.part4.test.ts create mode 100644 packages/runtime/src/execution/chat-task.part5.test.ts delete mode 100644 packages/runtime/src/execution/chat-task.test.ts rename packages/runtime/src/execution/{chat.test.ts => chat.part1.test.ts} (74%) create mode 100644 packages/runtime/src/execution/chat.part2.test.ts create mode 100644 packages/runtime/src/http.part1.test.ts create mode 100644 packages/runtime/src/http.part2.test.ts create mode 100644 packages/runtime/src/http.part3.test.ts create mode 100644 packages/runtime/src/http.part4.test.ts delete mode 100644 packages/runtime/src/http.test.ts create mode 100644 packages/runtime/src/integrations/tunnel.part1.test.ts rename packages/runtime/src/integrations/{tunnel.test.ts => tunnel.part2.test.ts} (61%) rename packages/runtime/src/{jobs.test.ts => jobs.part1.test.ts} (54%) create mode 100644 packages/runtime/src/jobs.part2.test.ts create mode 100644 packages/runtime/src/state/email-watchers.part1.test.ts rename packages/runtime/src/state/{email-watchers.test.ts => email-watchers.part2.test.ts} (53%) diff --git a/packages/runtime/src/execution/approval-mode.part1.test.ts b/packages/runtime/src/execution/approval-mode.part1.test.ts new file mode 100644 index 00000000..eb248130 --- /dev/null +++ b/packages/runtime/src/execution/approval-mode.part1.test.ts @@ -0,0 +1,550 @@ +// End-to-end tests for `approvalMode` across the chat-task dispatcher +// and the legacy imperative path. +// +// The matrix covers `{strict, auto, yolo}` x the five approval-eligible +// tools (`file_write`, `file_patch`, `terminal_exec` safe + dangerous, +// `code_exec`, `browser_upload_file`). We use the echo provider with +// stubbed tool-calling responses to drive the chat-task loop end-to-end +// without a real LLM, then verify both the task outcome and the audit +// trail (authorization.requested -> authorization.approved -> ) carries +// the expected `autoApprovedReason`. + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + clearEchoToolCallingResponses, + setEchoToolCallingResponse, + normalizeProvider +} from "../provider"; +import { submitTask, resolveAuthorization, decideApproval } from "../agent"; +import { readState, mutateState, createAuthorization } from "../state"; +import type { RuntimeConfig, Task } from "../types"; + +function buildConfig(workspaceRoot: string, instance: string, opts: Partial = {}): RuntimeConfig { + return { + instance, + port: 7338, + token: "test", + provider: { name: "echo", model: "" }, + workspaceRoot, + stateRoot: process.env.GINI_STATE_ROOT ?? "/tmp/gini-approval-mode-test", + logRoot: process.env.GINI_LOG_ROOT ?? "/tmp/gini-approval-mode-test-logs", + ...opts + }; +} + +// Poll granularity is deliberately tight (5ms): the echo-provider dispatch +// settles in ~25ms for a single-turn pause and ~30-140ms for a two-turn +// completion, so a coarse 20ms tick spends most of its budget in dead wait +// between the task flipping terminal and this loop observing it. 5ms keeps +// the observation responsive without changing the 5000ms deadline — the +// task genuinely completes fast, we just stop over-sleeping past it. +const POLL_TICK_MS = 5; + +async function waitForTerminal(config: RuntimeConfig, taskId: string, timeoutMs = 5000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const state = readState(config.instance); + const task = state.tasks.find((t) => t.id === taskId); + if (task && (task.status === "completed" || task.status === "failed" || task.status === "cancelled" || task.status === "waiting_approval")) { + return task; + } + await Bun.sleep(POLL_TICK_MS); + } + throw new Error(`Task ${taskId} did not reach terminal state within ${timeoutMs}ms`); +} + +// Variant that waits specifically for a non-waiting_approval terminal +// state. The imperative auto-resolve path briefly flips the task to +// `waiting_approval` inside requestShell/requestFileWrite before +// `resolveAuthorization` runs and flips it to `completed`; callers that +// expect the final state need to poll past the intermediate one. +async function waitForFinalTerminal(config: RuntimeConfig, taskId: string, timeoutMs = 5000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const state = readState(config.instance); + const task = state.tasks.find((t) => t.id === taskId); + if (task && (task.status === "completed" || task.status === "failed" || task.status === "cancelled")) { + return task; + } + await Bun.sleep(POLL_TICK_MS); + } + throw new Error(`Task ${taskId} did not reach a final terminal state within ${timeoutMs}ms`); +} + +describe("approvalMode dispatch matrix", () => { + let root: string; + let prevState: string | undefined; + let prevLog: string | undefined; + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "gini-approval-mode-")); + prevState = process.env.GINI_STATE_ROOT; + prevLog = process.env.GINI_LOG_ROOT; + process.env.GINI_STATE_ROOT = root; + process.env.GINI_LOG_ROOT = `${root}-logs`; + clearEchoToolCallingResponses(); + }); + + afterEach(() => { + if (prevState === undefined) delete process.env.GINI_STATE_ROOT; + else process.env.GINI_STATE_ROOT = prevState; + if (prevLog === undefined) delete process.env.GINI_LOG_ROOT; + else process.env.GINI_LOG_ROOT = prevLog; + rmSync(root, { recursive: true, force: true }); + clearEchoToolCallingResponses(); + }); + + // ---------------- strict ---------------- + + describe("strict mode", () => { + test("file_write pauses for approval", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-approval-mode-ws-")); + const config = buildConfig(workspaceRoot, "strict-fw", { approvalMode: "strict" }); + const provider = normalizeProvider(config.provider); + + setEchoToolCallingResponse({ + provider, + text: "", + toolCalls: [ + { id: "call_w", type: "function", function: { name: "file_write", arguments: JSON.stringify({ path: "x.txt", content: "x" }) } } + ], + finishReason: "tool_calls" + }); + + const task = await submitTask(config, "write x.txt", { mode: "chat" }); + const paused = await waitForTerminal(config, task.id); + expect(paused.status).toBe("waiting_approval"); + expect(existsSync(join(workspaceRoot, "x.txt"))).toBe(false); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + test("file_patch pauses for approval", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-approval-mode-ws-")); + writeFileSync(join(workspaceRoot, "p.txt"), "old"); + const config = buildConfig(workspaceRoot, "strict-fp", { approvalMode: "strict" }); + const provider = normalizeProvider(config.provider); + + setEchoToolCallingResponse({ + provider, + text: "", + toolCalls: [ + { id: "call_p", type: "function", function: { name: "file_patch", arguments: JSON.stringify({ path: "p.txt", oldText: "old", newText: "new" }) } } + ], + finishReason: "tool_calls" + }); + + const task = await submitTask(config, "patch p.txt", { mode: "chat" }); + const paused = await waitForTerminal(config, task.id); + expect(paused.status).toBe("waiting_approval"); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + test("terminal_exec pauses for approval even on safe commands", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-approval-mode-ws-")); + const config = buildConfig(workspaceRoot, "strict-term", { approvalMode: "strict" }); + const provider = normalizeProvider(config.provider); + + setEchoToolCallingResponse({ + provider, + text: "", + toolCalls: [ + { id: "call_t", type: "function", function: { name: "terminal_exec", arguments: JSON.stringify({ command: "echo hi" }) } } + ], + finishReason: "tool_calls" + }); + + const task = await submitTask(config, "run echo", { mode: "chat" }); + const paused = await waitForTerminal(config, task.id); + expect(paused.status).toBe("waiting_approval"); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + test("code_exec pauses for approval", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-approval-mode-ws-")); + const config = buildConfig(workspaceRoot, "strict-code", { approvalMode: "strict" }); + const provider = normalizeProvider(config.provider); + + setEchoToolCallingResponse({ + provider, + text: "", + toolCalls: [ + { id: "call_c", type: "function", function: { name: "code_exec", arguments: JSON.stringify({ language: "js", code: "console.log(1)" }) } } + ], + finishReason: "tool_calls" + }); + + const task = await submitTask(config, "run code", { mode: "chat" }); + const paused = await waitForTerminal(config, task.id); + expect(paused.status).toBe("waiting_approval"); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + test("browser_upload_file pauses for approval", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-approval-mode-ws-")); + writeFileSync(join(workspaceRoot, "u.txt"), "u"); + const config = buildConfig(workspaceRoot, "strict-upload", { approvalMode: "strict" }); + const provider = normalizeProvider(config.provider); + + // browser_upload_file is a deferred tool, so the model must load it + // before calling it; calling it directly would (correctly) be nudged by + // the loop's deferred-tool gate. Load it on the first turn, then call it. + setEchoToolCallingResponse({ + provider, + text: "", + toolCalls: [ + { id: "call_load", type: "function", function: { name: "load_tools", arguments: JSON.stringify({ names: ["browser_upload_file"] }) } } + ], + finishReason: "tool_calls" + }); + setEchoToolCallingResponse({ + provider, + text: "", + toolCalls: [ + { id: "call_u", type: "function", function: { name: "browser_upload_file", arguments: JSON.stringify({ ref: "stub-ref", path: "u.txt" }) } } + ], + finishReason: "tool_calls" + }); + + const task = await submitTask(config, "upload", { mode: "chat" }); + const paused = await waitForTerminal(config, task.id); + expect(paused.status).toBe("waiting_approval"); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + test("browser_download pauses for approval", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-approval-mode-ws-")); + const config = buildConfig(workspaceRoot, "strict-download", { approvalMode: "strict" }); + const provider = normalizeProvider(config.provider); + + // Same deferred-tool dance as browser_upload_file: load it first. + setEchoToolCallingResponse({ + provider, + text: "", + toolCalls: [ + { id: "call_load", type: "function", function: { name: "load_tools", arguments: JSON.stringify({ names: ["browser_download"] }) } } + ], + finishReason: "tool_calls" + }); + setEchoToolCallingResponse({ + provider, + text: "", + toolCalls: [ + { id: "call_d", type: "function", function: { name: "browser_download", arguments: JSON.stringify({ ref: "@e1" }) } } + ], + finishReason: "tool_calls" + }); + + const task = await submitTask(config, "download", { mode: "chat" }); + const paused = await waitForTerminal(config, task.id); + expect(paused.status).toBe("waiting_approval"); + + const state = readState(config.instance); + const approval = state.authorizations.find((a) => a.taskId === task.id); + expect(approval?.action).toBe("browser.download"); + expect(approval?.payload.ref).toBe("@e1"); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + }); + // ---------------- auto ---------------- + + describe("auto mode", () => { + test("file_write auto-approves with approval-mode-auto reason", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-approval-mode-ws-")); + const config = buildConfig(workspaceRoot, "auto-fw", { approvalMode: "auto" }); + const provider = normalizeProvider(config.provider); + + setEchoToolCallingResponse({ + provider, + text: "", + toolCalls: [ + { id: "call_w", type: "function", function: { name: "file_write", arguments: JSON.stringify({ path: "auto.txt", content: "auto" }) } } + ], + finishReason: "tool_calls" + }); + setEchoToolCallingResponse({ provider, text: "done", toolCalls: [], finishReason: "stop" }); + + const task = await submitTask(config, "write auto.txt", { mode: "chat" }); + const finished = await waitForTerminal(config, task.id); + expect(finished.status).toBe("completed"); + expect(await Bun.file(join(workspaceRoot, "auto.txt")).text()).toBe("auto"); + + const state = readState(config.instance); + const approvals = state.authorizations.filter((a) => a.taskId === task.id); + expect(approvals).toHaveLength(1); + expect(approvals[0]?.status).toBe("approved"); + const writeAudits = state.audit.filter((a) => a.action === "file.write" && a.taskId === task.id); + expect(writeAudits[0]?.evidence?.autoApprovedReason).toBe("approval-mode-auto"); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + test("file_patch auto-approves with approval-mode-auto reason", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-approval-mode-ws-")); + writeFileSync(join(workspaceRoot, "p.txt"), "old"); + const config = buildConfig(workspaceRoot, "auto-fp", { approvalMode: "auto" }); + const provider = normalizeProvider(config.provider); + + setEchoToolCallingResponse({ + provider, + text: "", + toolCalls: [ + { id: "call_p", type: "function", function: { name: "file_patch", arguments: JSON.stringify({ path: "p.txt", oldText: "old", newText: "new" }) } } + ], + finishReason: "tool_calls" + }); + setEchoToolCallingResponse({ provider, text: "done", toolCalls: [], finishReason: "stop" }); + + const task = await submitTask(config, "patch p.txt", { mode: "chat" }); + const finished = await waitForTerminal(config, task.id); + expect(finished.status).toBe("completed"); + expect(await Bun.file(join(workspaceRoot, "p.txt")).text()).toBe("new"); + + const state = readState(config.instance); + const patchAudits = state.audit.filter((a) => a.action === "file.patch" && a.taskId === task.id); + expect(patchAudits[0]?.evidence?.autoApprovedReason).toBe("approval-mode-auto"); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + test("terminal_exec auto-approves safe commands with approval-mode-auto reason", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-approval-mode-ws-")); + const config = buildConfig(workspaceRoot, "auto-term-safe", { approvalMode: "auto" }); + const provider = normalizeProvider(config.provider); + + setEchoToolCallingResponse({ + provider, + text: "", + toolCalls: [ + { id: "call_t", type: "function", function: { name: "terminal_exec", arguments: JSON.stringify({ command: "echo safe" }) } } + ], + finishReason: "tool_calls" + }); + setEchoToolCallingResponse({ provider, text: "ran it", toolCalls: [], finishReason: "stop" }); + + const task = await submitTask(config, "run echo", { mode: "chat" }); + const finished = await waitForTerminal(config, task.id); + expect(finished.status).toBe("completed"); + + const state = readState(config.instance); + const execAudits = state.audit.filter((a) => a.action === "terminal.exec" && a.taskId === task.id); + expect(execAudits[0]?.evidence?.autoApprovedReason).toBe("approval-mode-auto"); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + test("terminal_exec gates dangerous commands (rm -rf /)", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-approval-mode-ws-")); + const config = buildConfig(workspaceRoot, "auto-term-danger", { approvalMode: "auto" }); + const provider = normalizeProvider(config.provider); + + setEchoToolCallingResponse({ + provider, + text: "", + toolCalls: [ + { id: "call_t", type: "function", function: { name: "terminal_exec", arguments: JSON.stringify({ command: "rm -rf /" }) } } + ], + finishReason: "tool_calls" + }); + + const task = await submitTask(config, "delete world", { mode: "chat" }); + const paused = await waitForTerminal(config, task.id); + expect(paused.status).toBe("waiting_approval"); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + test("terminal_exec gates sudo", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-approval-mode-ws-")); + const config = buildConfig(workspaceRoot, "auto-term-sudo", { approvalMode: "auto" }); + const provider = normalizeProvider(config.provider); + + setEchoToolCallingResponse({ + provider, + text: "", + toolCalls: [ + { id: "call_t", type: "function", function: { name: "terminal_exec", arguments: JSON.stringify({ command: "sudo apt update" }) } } + ], + finishReason: "tool_calls" + }); + + const task = await submitTask(config, "elevate", { mode: "chat" }); + const paused = await waitForTerminal(config, task.id); + expect(paused.status).toBe("waiting_approval"); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + test("autoApproveCommands allowlist short-circuits the dangerous-pattern blocklist", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-approval-mode-ws-")); + // Operator explicitly allows a `sudo` command — the allowlist must + // win over the built-in `sudo ` block. The command is a + // non-interactive no-op (`sudo -n true`) rather than a real package + // command so the auto-approved exec runs instantly and offline; the + // gate-vs-allowlist decision is identical regardless of the payload. + const config = buildConfig(workspaceRoot, "auto-allowlist-wins", { + approvalMode: "auto", + autoApproveCommands: ["sudo -n true"] + }); + const provider = normalizeProvider(config.provider); + + setEchoToolCallingResponse({ + provider, + text: "", + toolCalls: [ + { id: "call_t", type: "function", function: { name: "terminal_exec", arguments: JSON.stringify({ command: "sudo -n true" }) } } + ], + finishReason: "tool_calls" + }); + setEchoToolCallingResponse({ provider, text: "ok", toolCalls: [], finishReason: "stop" }); + + const task = await submitTask(config, "update", { mode: "chat" }); + const finished = await waitForTerminal(config, task.id); + expect(finished.status).toBe("completed"); + + const state = readState(config.instance); + // Allowlist fast-path bypasses approval-row creation entirely. + expect(state.authorizations.filter((a) => a.taskId === task.id)).toHaveLength(0); + const execAudits = state.audit.filter((a) => a.action === "terminal.exec" && a.taskId === task.id); + expect(execAudits[0]?.evidence?.autoApprovedReason).toBe("sudo -n true"); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + test("code_exec auto-approves under auto mode (safe snippet)", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-approval-mode-ws-")); + const config = buildConfig(workspaceRoot, "auto-code", { approvalMode: "auto" }); + const provider = normalizeProvider(config.provider); + + setEchoToolCallingResponse({ + provider, + text: "", + toolCalls: [ + { id: "call_c", type: "function", function: { name: "code_exec", arguments: JSON.stringify({ language: "js", code: "console.log(1+1)" }) } } + ], + finishReason: "tool_calls" + }); + setEchoToolCallingResponse({ provider, text: "ran", toolCalls: [], finishReason: "stop" }); + + const task = await submitTask(config, "run code", { mode: "chat" }); + const finished = await waitForTerminal(config, task.id); + expect(finished.status).toBe("completed"); + + const state = readState(config.instance); + const approvals = state.authorizations.filter((a) => a.taskId === task.id); + expect(approvals).toHaveLength(1); + expect(approvals[0]?.status).toBe("approved"); + const execAudits = state.audit.filter((a) => a.action === "terminal.exec" && a.taskId === task.id); + expect(execAudits[0]?.evidence?.autoApprovedReason).toBe("approval-mode-auto"); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + test("code_exec gates argv-style dangerous source (Bun.spawn sudo)", async () => { + // Argv-style payload is invisible to a substring check against + // the wrapper alone (the wrapper contains "sudo" without the + // trailing space). The policy seam must check the raw source + // too and gate. + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-approval-mode-ws-")); + const config = buildConfig(workspaceRoot, "auto-code-argv-sudo", { approvalMode: "auto" }); + const provider = normalizeProvider(config.provider); + + setEchoToolCallingResponse({ + provider, + text: "", + toolCalls: [ + { id: "call_c", type: "function", function: { name: "code_exec", arguments: JSON.stringify({ language: "js", code: `Bun.spawn(["sudo", "apt", "update"])` }) } } + ], + finishReason: "tool_calls" + }); + + const task = await submitTask(config, "spawn sudo", { mode: "chat" }); + const paused = await waitForTerminal(config, task.id); + expect(paused.status).toBe("waiting_approval"); + + // The approval row's reason should carry the matched-pattern id, + // not the generic per-action copy. + const state = readState(config.instance); + const approvals = state.authorizations.filter((a) => a.taskId === task.id); + expect(approvals[0]?.reason).toContain("dangerous-pattern:"); + expect(approvals[0]?.reason).toContain("sudo"); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + test("code_exec gates argv-style dangerous source (python subprocess sudo)", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-approval-mode-ws-")); + const config = buildConfig(workspaceRoot, "auto-code-argv-py", { approvalMode: "auto" }); + const provider = normalizeProvider(config.provider); + + setEchoToolCallingResponse({ + provider, + text: "", + toolCalls: [ + { id: "call_c", type: "function", function: { name: "code_exec", arguments: JSON.stringify({ language: "python", code: `import subprocess\nsubprocess.run(["sudo", "apt", "update"])` }) } } + ], + finishReason: "tool_calls" + }); + + const task = await submitTask(config, "subprocess sudo", { mode: "chat" }); + const paused = await waitForTerminal(config, task.id); + expect(paused.status).toBe("waiting_approval"); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + test("approval row reason carries the matched dangerous-pattern id", async () => { + // Pin Fix 4 directly on the dispatch-level surface: the policy + // decision must flow into the persisted approval row's reason + // field so the operator sees WHY they're being asked rather + // than the generic per-action copy. + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-approval-mode-ws-")); + const config = buildConfig(workspaceRoot, "auto-reason-on-row", { approvalMode: "auto" }); + const provider = normalizeProvider(config.provider); + + setEchoToolCallingResponse({ + provider, + text: "", + toolCalls: [ + { id: "call_t", type: "function", function: { name: "terminal_exec", arguments: JSON.stringify({ command: "rm -rf /" }) } } + ], + finishReason: "tool_calls" + }); + + const task = await submitTask(config, "wipe", { mode: "chat" }); + const paused = await waitForTerminal(config, task.id); + expect(paused.status).toBe("waiting_approval"); + + const state = readState(config.instance); + const approvals = state.authorizations.filter((a) => a.taskId === task.id); + expect(approvals[0]?.reason).toContain("dangerous-pattern:"); + expect(approvals[0]?.reason).toContain("rm-rf-dangerous-target"); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + test("browser_upload_file auto-approves under auto mode", async () => { + // Use resolveApprovalPolicy directly here rather than running + // the full chat-task loop: the actual setInputFiles call would + // spin up a real playwright browser (no live session exists in + // unit tests). The policy decision is what this case actually + // pins — the per-action dispatcher is wired up identically to + // file.write / file.patch above. + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-approval-mode-ws-")); + const config = buildConfig(workspaceRoot, "auto-upload", { approvalMode: "auto" }); + const { resolveApprovalPolicy } = await import("./policy"); + const decision = resolveApprovalPolicy(config, "browser.upload_file"); + expect(decision).toEqual({ mode: "auto", reason: "approval-mode-auto" }); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + }); +}); diff --git a/packages/runtime/src/execution/approval-mode.test.ts b/packages/runtime/src/execution/approval-mode.part2.test.ts similarity index 55% rename from packages/runtime/src/execution/approval-mode.test.ts rename to packages/runtime/src/execution/approval-mode.part2.test.ts index 77aab540..bda5c320 100644 --- a/packages/runtime/src/execution/approval-mode.test.ts +++ b/packages/runtime/src/execution/approval-mode.part2.test.ts @@ -97,458 +97,6 @@ describe("approvalMode dispatch matrix", () => { clearEchoToolCallingResponses(); }); - // ---------------- strict ---------------- - - describe("strict mode", () => { - test("file_write pauses for approval", async () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-approval-mode-ws-")); - const config = buildConfig(workspaceRoot, "strict-fw", { approvalMode: "strict" }); - const provider = normalizeProvider(config.provider); - - setEchoToolCallingResponse({ - provider, - text: "", - toolCalls: [ - { id: "call_w", type: "function", function: { name: "file_write", arguments: JSON.stringify({ path: "x.txt", content: "x" }) } } - ], - finishReason: "tool_calls" - }); - - const task = await submitTask(config, "write x.txt", { mode: "chat" }); - const paused = await waitForTerminal(config, task.id); - expect(paused.status).toBe("waiting_approval"); - expect(existsSync(join(workspaceRoot, "x.txt"))).toBe(false); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - test("file_patch pauses for approval", async () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-approval-mode-ws-")); - writeFileSync(join(workspaceRoot, "p.txt"), "old"); - const config = buildConfig(workspaceRoot, "strict-fp", { approvalMode: "strict" }); - const provider = normalizeProvider(config.provider); - - setEchoToolCallingResponse({ - provider, - text: "", - toolCalls: [ - { id: "call_p", type: "function", function: { name: "file_patch", arguments: JSON.stringify({ path: "p.txt", oldText: "old", newText: "new" }) } } - ], - finishReason: "tool_calls" - }); - - const task = await submitTask(config, "patch p.txt", { mode: "chat" }); - const paused = await waitForTerminal(config, task.id); - expect(paused.status).toBe("waiting_approval"); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - test("terminal_exec pauses for approval even on safe commands", async () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-approval-mode-ws-")); - const config = buildConfig(workspaceRoot, "strict-term", { approvalMode: "strict" }); - const provider = normalizeProvider(config.provider); - - setEchoToolCallingResponse({ - provider, - text: "", - toolCalls: [ - { id: "call_t", type: "function", function: { name: "terminal_exec", arguments: JSON.stringify({ command: "echo hi" }) } } - ], - finishReason: "tool_calls" - }); - - const task = await submitTask(config, "run echo", { mode: "chat" }); - const paused = await waitForTerminal(config, task.id); - expect(paused.status).toBe("waiting_approval"); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - test("code_exec pauses for approval", async () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-approval-mode-ws-")); - const config = buildConfig(workspaceRoot, "strict-code", { approvalMode: "strict" }); - const provider = normalizeProvider(config.provider); - - setEchoToolCallingResponse({ - provider, - text: "", - toolCalls: [ - { id: "call_c", type: "function", function: { name: "code_exec", arguments: JSON.stringify({ language: "js", code: "console.log(1)" }) } } - ], - finishReason: "tool_calls" - }); - - const task = await submitTask(config, "run code", { mode: "chat" }); - const paused = await waitForTerminal(config, task.id); - expect(paused.status).toBe("waiting_approval"); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - test("browser_upload_file pauses for approval", async () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-approval-mode-ws-")); - writeFileSync(join(workspaceRoot, "u.txt"), "u"); - const config = buildConfig(workspaceRoot, "strict-upload", { approvalMode: "strict" }); - const provider = normalizeProvider(config.provider); - - // browser_upload_file is a deferred tool, so the model must load it - // before calling it; calling it directly would (correctly) be nudged by - // the loop's deferred-tool gate. Load it on the first turn, then call it. - setEchoToolCallingResponse({ - provider, - text: "", - toolCalls: [ - { id: "call_load", type: "function", function: { name: "load_tools", arguments: JSON.stringify({ names: ["browser_upload_file"] }) } } - ], - finishReason: "tool_calls" - }); - setEchoToolCallingResponse({ - provider, - text: "", - toolCalls: [ - { id: "call_u", type: "function", function: { name: "browser_upload_file", arguments: JSON.stringify({ ref: "stub-ref", path: "u.txt" }) } } - ], - finishReason: "tool_calls" - }); - - const task = await submitTask(config, "upload", { mode: "chat" }); - const paused = await waitForTerminal(config, task.id); - expect(paused.status).toBe("waiting_approval"); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - test("browser_download pauses for approval", async () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-approval-mode-ws-")); - const config = buildConfig(workspaceRoot, "strict-download", { approvalMode: "strict" }); - const provider = normalizeProvider(config.provider); - - // Same deferred-tool dance as browser_upload_file: load it first. - setEchoToolCallingResponse({ - provider, - text: "", - toolCalls: [ - { id: "call_load", type: "function", function: { name: "load_tools", arguments: JSON.stringify({ names: ["browser_download"] }) } } - ], - finishReason: "tool_calls" - }); - setEchoToolCallingResponse({ - provider, - text: "", - toolCalls: [ - { id: "call_d", type: "function", function: { name: "browser_download", arguments: JSON.stringify({ ref: "@e1" }) } } - ], - finishReason: "tool_calls" - }); - - const task = await submitTask(config, "download", { mode: "chat" }); - const paused = await waitForTerminal(config, task.id); - expect(paused.status).toBe("waiting_approval"); - - const state = readState(config.instance); - const approval = state.authorizations.find((a) => a.taskId === task.id); - expect(approval?.action).toBe("browser.download"); - expect(approval?.payload.ref).toBe("@e1"); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - }); - - // ---------------- auto ---------------- - - describe("auto mode", () => { - test("file_write auto-approves with approval-mode-auto reason", async () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-approval-mode-ws-")); - const config = buildConfig(workspaceRoot, "auto-fw", { approvalMode: "auto" }); - const provider = normalizeProvider(config.provider); - - setEchoToolCallingResponse({ - provider, - text: "", - toolCalls: [ - { id: "call_w", type: "function", function: { name: "file_write", arguments: JSON.stringify({ path: "auto.txt", content: "auto" }) } } - ], - finishReason: "tool_calls" - }); - setEchoToolCallingResponse({ provider, text: "done", toolCalls: [], finishReason: "stop" }); - - const task = await submitTask(config, "write auto.txt", { mode: "chat" }); - const finished = await waitForTerminal(config, task.id); - expect(finished.status).toBe("completed"); - expect(await Bun.file(join(workspaceRoot, "auto.txt")).text()).toBe("auto"); - - const state = readState(config.instance); - const approvals = state.authorizations.filter((a) => a.taskId === task.id); - expect(approvals).toHaveLength(1); - expect(approvals[0]?.status).toBe("approved"); - const writeAudits = state.audit.filter((a) => a.action === "file.write" && a.taskId === task.id); - expect(writeAudits[0]?.evidence?.autoApprovedReason).toBe("approval-mode-auto"); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - test("file_patch auto-approves with approval-mode-auto reason", async () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-approval-mode-ws-")); - writeFileSync(join(workspaceRoot, "p.txt"), "old"); - const config = buildConfig(workspaceRoot, "auto-fp", { approvalMode: "auto" }); - const provider = normalizeProvider(config.provider); - - setEchoToolCallingResponse({ - provider, - text: "", - toolCalls: [ - { id: "call_p", type: "function", function: { name: "file_patch", arguments: JSON.stringify({ path: "p.txt", oldText: "old", newText: "new" }) } } - ], - finishReason: "tool_calls" - }); - setEchoToolCallingResponse({ provider, text: "done", toolCalls: [], finishReason: "stop" }); - - const task = await submitTask(config, "patch p.txt", { mode: "chat" }); - const finished = await waitForTerminal(config, task.id); - expect(finished.status).toBe("completed"); - expect(await Bun.file(join(workspaceRoot, "p.txt")).text()).toBe("new"); - - const state = readState(config.instance); - const patchAudits = state.audit.filter((a) => a.action === "file.patch" && a.taskId === task.id); - expect(patchAudits[0]?.evidence?.autoApprovedReason).toBe("approval-mode-auto"); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - test("terminal_exec auto-approves safe commands with approval-mode-auto reason", async () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-approval-mode-ws-")); - const config = buildConfig(workspaceRoot, "auto-term-safe", { approvalMode: "auto" }); - const provider = normalizeProvider(config.provider); - - setEchoToolCallingResponse({ - provider, - text: "", - toolCalls: [ - { id: "call_t", type: "function", function: { name: "terminal_exec", arguments: JSON.stringify({ command: "echo safe" }) } } - ], - finishReason: "tool_calls" - }); - setEchoToolCallingResponse({ provider, text: "ran it", toolCalls: [], finishReason: "stop" }); - - const task = await submitTask(config, "run echo", { mode: "chat" }); - const finished = await waitForTerminal(config, task.id); - expect(finished.status).toBe("completed"); - - const state = readState(config.instance); - const execAudits = state.audit.filter((a) => a.action === "terminal.exec" && a.taskId === task.id); - expect(execAudits[0]?.evidence?.autoApprovedReason).toBe("approval-mode-auto"); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - test("terminal_exec gates dangerous commands (rm -rf /)", async () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-approval-mode-ws-")); - const config = buildConfig(workspaceRoot, "auto-term-danger", { approvalMode: "auto" }); - const provider = normalizeProvider(config.provider); - - setEchoToolCallingResponse({ - provider, - text: "", - toolCalls: [ - { id: "call_t", type: "function", function: { name: "terminal_exec", arguments: JSON.stringify({ command: "rm -rf /" }) } } - ], - finishReason: "tool_calls" - }); - - const task = await submitTask(config, "delete world", { mode: "chat" }); - const paused = await waitForTerminal(config, task.id); - expect(paused.status).toBe("waiting_approval"); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - test("terminal_exec gates sudo", async () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-approval-mode-ws-")); - const config = buildConfig(workspaceRoot, "auto-term-sudo", { approvalMode: "auto" }); - const provider = normalizeProvider(config.provider); - - setEchoToolCallingResponse({ - provider, - text: "", - toolCalls: [ - { id: "call_t", type: "function", function: { name: "terminal_exec", arguments: JSON.stringify({ command: "sudo apt update" }) } } - ], - finishReason: "tool_calls" - }); - - const task = await submitTask(config, "elevate", { mode: "chat" }); - const paused = await waitForTerminal(config, task.id); - expect(paused.status).toBe("waiting_approval"); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - test("autoApproveCommands allowlist short-circuits the dangerous-pattern blocklist", async () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-approval-mode-ws-")); - // Operator explicitly allows a `sudo` command — the allowlist must - // win over the built-in `sudo ` block. The command is a - // non-interactive no-op (`sudo -n true`) rather than a real package - // command so the auto-approved exec runs instantly and offline; the - // gate-vs-allowlist decision is identical regardless of the payload. - const config = buildConfig(workspaceRoot, "auto-allowlist-wins", { - approvalMode: "auto", - autoApproveCommands: ["sudo -n true"] - }); - const provider = normalizeProvider(config.provider); - - setEchoToolCallingResponse({ - provider, - text: "", - toolCalls: [ - { id: "call_t", type: "function", function: { name: "terminal_exec", arguments: JSON.stringify({ command: "sudo -n true" }) } } - ], - finishReason: "tool_calls" - }); - setEchoToolCallingResponse({ provider, text: "ok", toolCalls: [], finishReason: "stop" }); - - const task = await submitTask(config, "update", { mode: "chat" }); - const finished = await waitForTerminal(config, task.id); - expect(finished.status).toBe("completed"); - - const state = readState(config.instance); - // Allowlist fast-path bypasses approval-row creation entirely. - expect(state.authorizations.filter((a) => a.taskId === task.id)).toHaveLength(0); - const execAudits = state.audit.filter((a) => a.action === "terminal.exec" && a.taskId === task.id); - expect(execAudits[0]?.evidence?.autoApprovedReason).toBe("sudo -n true"); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - test("code_exec auto-approves under auto mode (safe snippet)", async () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-approval-mode-ws-")); - const config = buildConfig(workspaceRoot, "auto-code", { approvalMode: "auto" }); - const provider = normalizeProvider(config.provider); - - setEchoToolCallingResponse({ - provider, - text: "", - toolCalls: [ - { id: "call_c", type: "function", function: { name: "code_exec", arguments: JSON.stringify({ language: "js", code: "console.log(1+1)" }) } } - ], - finishReason: "tool_calls" - }); - setEchoToolCallingResponse({ provider, text: "ran", toolCalls: [], finishReason: "stop" }); - - const task = await submitTask(config, "run code", { mode: "chat" }); - const finished = await waitForTerminal(config, task.id); - expect(finished.status).toBe("completed"); - - const state = readState(config.instance); - const approvals = state.authorizations.filter((a) => a.taskId === task.id); - expect(approvals).toHaveLength(1); - expect(approvals[0]?.status).toBe("approved"); - const execAudits = state.audit.filter((a) => a.action === "terminal.exec" && a.taskId === task.id); - expect(execAudits[0]?.evidence?.autoApprovedReason).toBe("approval-mode-auto"); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - test("code_exec gates argv-style dangerous source (Bun.spawn sudo)", async () => { - // Argv-style payload is invisible to a substring check against - // the wrapper alone (the wrapper contains "sudo" without the - // trailing space). The policy seam must check the raw source - // too and gate. - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-approval-mode-ws-")); - const config = buildConfig(workspaceRoot, "auto-code-argv-sudo", { approvalMode: "auto" }); - const provider = normalizeProvider(config.provider); - - setEchoToolCallingResponse({ - provider, - text: "", - toolCalls: [ - { id: "call_c", type: "function", function: { name: "code_exec", arguments: JSON.stringify({ language: "js", code: `Bun.spawn(["sudo", "apt", "update"])` }) } } - ], - finishReason: "tool_calls" - }); - - const task = await submitTask(config, "spawn sudo", { mode: "chat" }); - const paused = await waitForTerminal(config, task.id); - expect(paused.status).toBe("waiting_approval"); - - // The approval row's reason should carry the matched-pattern id, - // not the generic per-action copy. - const state = readState(config.instance); - const approvals = state.authorizations.filter((a) => a.taskId === task.id); - expect(approvals[0]?.reason).toContain("dangerous-pattern:"); - expect(approvals[0]?.reason).toContain("sudo"); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - test("code_exec gates argv-style dangerous source (python subprocess sudo)", async () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-approval-mode-ws-")); - const config = buildConfig(workspaceRoot, "auto-code-argv-py", { approvalMode: "auto" }); - const provider = normalizeProvider(config.provider); - - setEchoToolCallingResponse({ - provider, - text: "", - toolCalls: [ - { id: "call_c", type: "function", function: { name: "code_exec", arguments: JSON.stringify({ language: "python", code: `import subprocess\nsubprocess.run(["sudo", "apt", "update"])` }) } } - ], - finishReason: "tool_calls" - }); - - const task = await submitTask(config, "subprocess sudo", { mode: "chat" }); - const paused = await waitForTerminal(config, task.id); - expect(paused.status).toBe("waiting_approval"); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - test("approval row reason carries the matched dangerous-pattern id", async () => { - // Pin Fix 4 directly on the dispatch-level surface: the policy - // decision must flow into the persisted approval row's reason - // field so the operator sees WHY they're being asked rather - // than the generic per-action copy. - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-approval-mode-ws-")); - const config = buildConfig(workspaceRoot, "auto-reason-on-row", { approvalMode: "auto" }); - const provider = normalizeProvider(config.provider); - - setEchoToolCallingResponse({ - provider, - text: "", - toolCalls: [ - { id: "call_t", type: "function", function: { name: "terminal_exec", arguments: JSON.stringify({ command: "rm -rf /" }) } } - ], - finishReason: "tool_calls" - }); - - const task = await submitTask(config, "wipe", { mode: "chat" }); - const paused = await waitForTerminal(config, task.id); - expect(paused.status).toBe("waiting_approval"); - - const state = readState(config.instance); - const approvals = state.authorizations.filter((a) => a.taskId === task.id); - expect(approvals[0]?.reason).toContain("dangerous-pattern:"); - expect(approvals[0]?.reason).toContain("rm-rf-dangerous-target"); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - test("browser_upload_file auto-approves under auto mode", async () => { - // Use resolveApprovalPolicy directly here rather than running - // the full chat-task loop: the actual setInputFiles call would - // spin up a real playwright browser (no live session exists in - // unit tests). The policy decision is what this case actually - // pins — the per-action dispatcher is wired up identically to - // file.write / file.patch above. - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-approval-mode-ws-")); - const config = buildConfig(workspaceRoot, "auto-upload", { approvalMode: "auto" }); - const { resolveApprovalPolicy } = await import("./policy"); - const decision = resolveApprovalPolicy(config, "browser.upload_file"); - expect(decision).toEqual({ mode: "auto", reason: "approval-mode-auto" }); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - }); - // ---------------- yolo ---------------- describe("yolo mode", () => { @@ -685,7 +233,6 @@ describe("approvalMode dispatch matrix", () => { rmSync(workspaceRoot, { recursive: true, force: true }); }); }); - // ---------------- back-compat: legacy dangerouslyAutoApprove alias ---------------- test("legacy dangerouslyAutoApprove: true behaves like approvalMode: yolo", async () => { diff --git a/packages/runtime/src/execution/chat-needs-input.part1.test.ts b/packages/runtime/src/execution/chat-needs-input.part1.test.ts new file mode 100644 index 00000000..ffbf3fc5 --- /dev/null +++ b/packages/runtime/src/execution/chat-needs-input.part1.test.ts @@ -0,0 +1,465 @@ +// Phase-3 unified-task-model runtime surface: the `needs_input` status. +// +// An all-chat.choice park (ask_user) is reclassified from waiting_approval to +// needs_input, with the question payload stamped on Task.needsInput. A plain +// message post into the parked session answers the question and resumes the +// SAME task (zero client changes for CLI/bridges); the card's /complete +// endpoint keeps working; a turn that touches an approval gate keeps +// waiting_approval; the park survives a gateway restart; a second resolve of +// an already-answered question fails gracefully; and GINI_NEEDS_INPUT_STATUS=0 +// parks as waiting_approval while the internal machinery keeps working. +// +// Uses the echo provider with stubbed tool-calling responses so the parked +// task is a REAL chat-task loop pause (toolCallState snapshot included) and +// the resume paths run end to end — same harness as http-chat-choice.test.ts. + +import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test"; +import { rmSync } from "node:fs"; +import { + clearEchoStructuredResponses, + clearEchoToolCallingResponses, + getEchoToolCallingCalls, + normalizeProvider, + setEchoStructuredResponse, + setEchoToolCallingResponse +} from "../provider"; +import { createHandler } from "../http"; +import { ApprovalRaceLostError, reconcileInFlightTasks, resolveSetupRequest } from "../agent"; +import { persistConnectOutcome } from "./safe-resume"; +import { resumeChatTask } from "./chat-task"; +import { dispatchChatMessageToTopic, submitChatMessage as submitChatMessageRaw } from "./chat"; +import { settleSubmittedChatMessage } from "./chat-test-support"; +import { createChatSession, createTopic, listChatBlocks, mutateState, readState, sessionHasInFlightChatTask } from "../state"; +import type { RuntimeConfig, Task } from "../types"; + +// Narrow the submit union to the run-now branch for the answer-path asserts +// (a queued result where a run-now is expected is a test-setup bug). An +// echo-first accepted result (kind:"agent" intake) settles to the dispatched +// outcome first; answer-path results pass through synchronously. +async function submitChatMessage( + config: RuntimeConfig, + sessionId: string, + input: Record +): Promise<{ runId?: string; taskId: string; status: Task["status"] }> { + const result = await submitChatMessageRaw(config, sessionId, input); + const settled = await settleSubmittedChatMessage( + config, + sessionId, + result, + String(input.content ?? "") + ); + if ("queued" in settled) throw new Error("expected run-now submission, got queued"); + return settled; +} + +const ROOT = `/tmp/gini-chat-needs-input-tests-${import.meta.file}`; + +beforeAll(() => { + process.env.GINI_STATE_ROOT = ROOT; + process.env.GINI_LOG_ROOT = `${ROOT}-logs`; + // Shrink the resume race-window poll (production default 1000/100) so the + // in-process harness resolves within a couple of mutateState boundaries. + process.env.GINI_RESUME_WAIT_BUDGET_MS = "40"; + process.env.GINI_RESUME_WAIT_TICK_MS = "5"; +}); + +afterEach(() => { + clearEchoToolCallingResponses(); + clearEchoStructuredResponses(); + delete process.env.GINI_NEEDS_INPUT_STATUS; +}); + +afterAll(() => { + delete process.env.GINI_RESUME_WAIT_BUDGET_MS; + delete process.env.GINI_RESUME_WAIT_TICK_MS; + rmSync(ROOT, { recursive: true, force: true }); + rmSync(`${ROOT}-logs`, { recursive: true, force: true }); +}); + +function buildConfig(instance: string): RuntimeConfig { + return { + instance, + port: 7346, + token: "test-token", + provider: { name: "echo", model: "" }, + workspaceRoot: "/tmp", + stateRoot: `${ROOT}/instances/${instance}`, + logRoot: `${ROOT}-logs/${instance}`, + approvalMode: "strict" + }; +} + +async function waitForTask( + config: RuntimeConfig, + taskId: string, + status: Task["status"], + timeoutMs = 5000 +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const task = readState(config.instance).tasks.find((t) => t.id === taskId); + if (task?.status === status) return task; + // Tight poll: the status flips on the in-process microtask/timer queue, so a + // small interval just notices the already-settled transition sooner (each + // wait sheds ~half a poll interval of dead wall-clock). The 5s deadline still + // bounds a genuine hang identically. Notably the ->completed resumes settle in + // <1 tick, so a 20ms interval was pure floor there. + await Bun.sleep(2); + } + throw new Error(`Task ${taskId} did not reach ${status} within ${timeoutMs}ms`); +} + +const QUESTION = "How should I search the web?"; +const OPTIONS = [ + { label: "Set up Brave + Exa", description: "Best coverage" }, + { label: "Set up Brave only" }, + { label: "Neither — use web_fetch" } +]; + +function stubAskUser(config: RuntimeConfig): void { + setEchoToolCallingResponse({ + provider: normalizeProvider(config.provider), + text: "", + toolCalls: [ + { + id: "call_choice", + type: "function", + function: { + name: "ask_user", + arguments: JSON.stringify({ question: QUESTION, options: OPTIONS }) + } + } + ], + finishReason: "tool_calls" + }); +} + +function stubAnswer(config: RuntimeConfig, text: string): void { + setEchoToolCallingResponse({ + provider: normalizeProvider(config.provider), + text, + toolCalls: [], + finishReason: "stop" + }); +} + +// Drive a real chat turn that parks on ask_user. Returns the parked task id, +// session id, and the pending chat.choice setup-request id. +async function parkOnAskUser( + config: RuntimeConfig, + parkedStatus: Task["status"] = "needs_input" +): Promise<{ taskId: string; sessionId: string; setupId: string }> { + stubAskUser(config); + const session = await mutateState(config.instance, (state) => createChatSession(state, "needs-input session")); + const submitted = await submitChatMessage(config, session.id, { content: "find me fresh results" }); + await waitForTask(config, submitted.taskId, parkedStatus); + const setup = readState(config.instance).setupRequests.find( + (s) => s.taskId === submitted.taskId && s.action === "chat.choice" + ); + if (!setup) throw new Error("chat.choice setup request not minted"); + return { taskId: submitted.taskId, sessionId: session.id, setupId: setup.id }; +} + +describe("needs_input park", () => { + test("an all-chat.choice park stamps status needs_input + the question payload", async () => { + const instance = "needs-input-park"; + rmSync(`${ROOT}/instances/${instance}`, { recursive: true, force: true }); + const config = buildConfig(instance); + const { taskId, sessionId, setupId } = await parkOnAskUser(config); + + const state = readState(config.instance); + const task = state.tasks.find((t) => t.id === taskId); + expect(task?.status).toBe("needs_input"); + expect(task?.currentStep).toBe("Waiting for your answer"); + expect(task?.needsInput?.question).toBe(QUESTION); + expect(task?.needsInput?.options).toEqual(OPTIONS.map((o) => o.label)); + expect(task?.needsInput?.setupRequestId).toBe(setupId); + // blockId points at the setup_requested card so clients can deep-link. + const setupBlock = listChatBlocks(config.instance, sessionId).find((b) => b.kind === "setup_requested"); + expect(task?.needsInput?.blockId).toBe(setupBlock!.id); + // The paused snapshot is intact — this is the same park, reclassified. + expect(task?.toolCallState?.pending?.length).toBe(1); + // needs_input counts as in-flight, so a concurrent turn can't start. + expect(sessionHasInFlightChatTask(state, sessionId)).toBe(true); + }); + + test("a turn that touches an approval gate parks as waiting_approval (no needsInput stamp)", async () => { + const instance = "needs-input-mixed"; + rmSync(`${ROOT}/instances/${instance}`, { recursive: true, force: true }); + const config = buildConfig(instance); + // The model asks for a gated file_write AND ask_user in the same turn. + // The Authorization gate pends first, so the park is NOT all-chat.choice + // and keeps waiting_approval exactly as before. + setEchoToolCallingResponse({ + provider: normalizeProvider(config.provider), + text: "", + toolCalls: [ + { + id: "call_write", + type: "function", + function: { name: "file_write", arguments: JSON.stringify({ path: "out.txt", content: "x" }) } + }, + { + id: "call_choice", + type: "function", + function: { + name: "ask_user", + arguments: JSON.stringify({ question: QUESTION, options: OPTIONS }) + } + } + ], + finishReason: "tool_calls" + }); + const session = await mutateState(config.instance, (state) => createChatSession(state, "mixed session")); + const submitted = await submitChatMessage(config, session.id, { content: "write it out" }); + const parked = await waitForTask(config, submitted.taskId, "waiting_approval"); + expect(parked.needsInput).toBeUndefined(); + expect(parked.currentStep).toBe("Waiting for approval"); + const state = readState(config.instance); + expect(state.authorizations.some((a) => a.taskId === submitted.taskId && a.status === "pending")).toBe(true); + }); + + test("GINI_NEEDS_INPUT_STATUS=0 parks as waiting_approval but still stamps needsInput", async () => { + const instance = "needs-input-hatch"; + rmSync(`${ROOT}/instances/${instance}`, { recursive: true, force: true }); + const config = buildConfig(instance); + process.env.GINI_NEEDS_INPUT_STATUS = "0"; + const { taskId, setupId } = await parkOnAskUser(config, "waiting_approval"); + + const task = readState(config.instance).tasks.find((t) => t.id === taskId); + expect(task?.status).toBe("waiting_approval"); + // The internal machinery still runs — only the exposed status differs. + expect(task?.needsInput?.setupRequestId).toBe(setupId); + + // The message-answer path keys on the stamp, so it works under the hatch. + stubAnswer(config, "Got it — Brave only."); + const answered = await submitChatMessage(config, task!.chatSessionId!, { content: "brave only please" }); + expect(answered.taskId).toBe(taskId); + const finished = await waitForTask(config, taskId, "completed"); + expect(finished.summary).toBe("Got it — Brave only."); + }); +}); + +describe("needs_input answer paths", () => { + test("a plain message post answers the question and resumes the SAME task", async () => { + const instance = "needs-input-answer"; + rmSync(`${ROOT}/instances/${instance}`, { recursive: true, force: true }); + const config = buildConfig(instance); + const { taskId, sessionId, setupId } = await parkOnAskUser(config); + + stubAnswer(config, "Got it, using DuckDuckGo."); + const answered = await submitChatMessage(config, sessionId, { content: "just use duckduckgo" }); + // Same task, same run — no new turn was minted. + expect(answered.taskId).toBe(taskId); + expect(readState(config.instance).tasks.filter((t) => t.chatSessionId === sessionId).length).toBe(1); + + const finished = await waitForTask(config, taskId, "completed"); + expect(finished.summary).toBe("Got it, using DuckDuckGo."); + // The park stamp is consumed by the resume. + expect(finished.needsInput).toBeUndefined(); + + const state = readState(config.instance); + const setup = state.setupRequests.find((s) => s.id === setupId); + expect(setup?.status).toBe("completed"); + expect(setup?.connectOutcome).toEqual({ ok: true, message: "You answered: just use duckduckgo" }); + + const blocks = listChatBlocks(config.instance, sessionId); + // The user's message renders in the thread, and the answer rode the + // ask_user tool result into the loop. + expect(blocks.some((b) => b.kind === "user_text" && b.text === "just use duckduckgo")).toBe(true); + expect( + blocks.some((b) => b.kind === "tool_result" && b.preview.includes('User answered: "just use duckduckgo"')) + ).toBe(true); + }); + + test("an option-less ask_user is rejected in place — no park, no chat.choice SetupRequest", async () => { + // ask_user is options-only: an open-ended ask belongs in the model's + // plain message text, so the dispatcher returns a graceful steering + // error instead of minting an empty choice card. The turn keeps running + // on the error result and finishes normally — it never parks. + const instance = "needs-input-optionless"; + rmSync(`${ROOT}/instances/${instance}`, { recursive: true, force: true }); + const config = buildConfig(instance); + setEchoToolCallingResponse({ + provider: normalizeProvider(config.provider), + text: "", + toolCalls: [ + { + id: "call_open", + type: "function", + function: { name: "ask_user", arguments: JSON.stringify({ question: "What should the reply say?" }) } + } + ], + finishReason: "tool_calls" + }); + // The model recovers by asking in prose — the steered path. + stubAnswer(config, "What should the reply say?"); + const session = await mutateState(config.instance, (state) => createChatSession(state, "open question session")); + const submitted = await submitChatMessage(config, session.id, { content: "reply to dana for me" }); + const finished = await waitForTask(config, submitted.taskId, "completed"); + expect(finished.needsInput).toBeUndefined(); + + const state = readState(config.instance); + expect(state.setupRequests.filter((s) => s.taskId === submitted.taskId)).toEqual([]); + const blocks = listChatBlocks(config.instance, session.id); + // The error text steers the model to plain message text. + expect( + blocks.some((b) => b.kind === "tool_result" && b.preview.includes("ask_user only presents choices")) + ).toBe(true); + expect(blocks.some((b) => b.kind === "setup_requested")).toBe(false); + }); + + test("a Chat message dispatched into a parked Topic answers and resumes the SAME task", async () => { + const instance = "needs-input-topic-dispatch"; + rmSync(`${ROOT}/instances/${instance}`, { recursive: true, force: true }); + const config = buildConfig(instance); + + // Park a TOPIC on ask_user (a kind:"topic" submit is never routed and + // runs in place). + stubAskUser(config); + const { chatId, topicId } = await mutateState(config.instance, (state) => { + const chat = createChatSession(state, "Messages", undefined, undefined, undefined, "agent"); + const topic = createTopic(state, { title: "Search setup", parentChatSessionId: chat.id }); + return { chatId: chat.id, topicId: topic.id }; + }); + const parked = await submitChatMessage(config, topicId, { content: "find me fresh results" }); + await waitForTask(config, parked.taskId, "needs_input"); + + // A Chat-routed message lands through dispatchChatMessageToTopic — the + // needs-input pre-check consumes it as the answer BEFORE the queue + // decision, resuming the SAME task instead of queueing behind the park. + stubAnswer(config, "Got it — Brave only."); + const liveSession = readState(config.instance).chatSessions.find((s) => s.id === chatId)!; + const dispatched = await dispatchChatMessageToTopic(config, chatId, topicId, { + content: "brave only please", + images: [], + audio: undefined, + liveSession, + clientSurface: undefined + }); + if ("queued" in dispatched) throw new Error("expected the answer path to run now, got queued"); + expect(dispatched.taskId).toBe(parked.taskId); + + const finished = await waitForTask(config, parked.taskId, "completed"); + expect(finished.summary).toBe("Got it — Brave only."); + // The message resumed the parked turn — it never minted a second task. + expect(readState(config.instance).tasks.filter((t) => t.chatSessionId === topicId).length).toBe(1); + }); + + test("a parked root-Chat question consumes the message as its answer before intake routing", async () => { + // The answer carve-out runs BEFORE routeChatMessage: a message posted + // while the root Chat's own live run is parked on ask_user answers and + // resumes that SAME task. Routing it into a Topic would strand the + // parked question forever. + const instance = "needs-input-router"; + rmSync(`${ROOT}/instances/${instance}`, { recursive: true, force: true }); + const config = buildConfig(instance); + + // Park the ROOT Chat (kind:"agent" — the only kind the router runs on). + stubAskUser(config); + const chatId = await mutateState(config.instance, (state) => + createChatSession(state, "Messages", undefined, undefined, undefined, "agent").id + ); + const submitted = await submitChatMessage(config, chatId, { content: "find me fresh results" }); + await waitForTask(config, submitted.taskId, "needs_input"); + + // The router would classify the answer as a new topic; the parked + // question must consume it before the router is ever consulted. + setEchoStructuredResponse("chat-route", { decision: "new_topic", title: "Should Not Mint" }); + stubAnswer(config, "Got it — Brave only."); + const answered = await submitChatMessage(config, chatId, { content: "brave only please" }); + expect(answered.taskId).toBe(submitted.taskId); + + const finished = await waitForTask(config, submitted.taskId, "completed"); + expect(finished.summary).toBe("Got it — Brave only."); + const state = readState(config.instance); + // No topic was minted and no second task started in the Chat. + expect(state.chatSessions.filter((s) => s.kind === "topic").length).toBe(0); + expect(state.tasks.filter((t) => t.chatSessionId === chatId).length).toBe(1); + }); + + test("POST /api/setup-requests/:id/complete still answers the card", async () => { + const instance = "needs-input-complete"; + rmSync(`${ROOT}/instances/${instance}`, { recursive: true, force: true }); + const config = buildConfig(instance); + const handler = createHandler(config); + const { taskId, setupId } = await parkOnAskUser(config); + + stubAnswer(config, "Great — setting up Brave and Exa now."); + const response = await handler( + new Request(`http://127.0.0.1:${config.port}/api/setup-requests/${setupId}/complete`, { + method: "POST", + headers: { "content-type": "application/json", authorization: `Bearer ${config.token}` }, + body: JSON.stringify({ choice: { label: "Set up Brave + Exa" } }) + }) + ); + expect(response.status).toBe(200); + const finished = await waitForTask(config, taskId, "completed"); + expect(finished.summary).toBe("Great — setting up Brave and Exa now."); + expect(finished.needsInput).toBeUndefined(); + }); + + test("double answer: the second resolve of an answered question fails gracefully", async () => { + const instance = "needs-input-race"; + rmSync(`${ROOT}/instances/${instance}`, { recursive: true, force: true }); + const config = buildConfig(instance); + const handler = createHandler(config); + const { taskId, sessionId, setupId } = await parkOnAskUser(config); + + stubAnswer(config, "Answered once."); + const answered = await submitChatMessage(config, sessionId, { content: "first answer wins" }); + expect(answered.taskId).toBe(taskId); + + // A near-simultaneous answer from the thread card loses the claim with a + // typed already-resolved error, never a double resume. + await expect( + resolveSetupRequest(config, setupId, "complete", { actor: "user", resumeChatTask: false }) + ).rejects.toThrow(ApprovalRaceLostError); + // The HTTP surface maps the same race to 410 via its pending pre-check. + const response = await handler( + new Request(`http://127.0.0.1:${config.port}/api/setup-requests/${setupId}/complete`, { + method: "POST", + headers: { "content-type": "application/json", authorization: `Bearer ${config.token}` }, + body: JSON.stringify({ choice: { label: "Set up Brave only" } }) + }) + ); + expect(response.status).toBe(410); + + const finished = await waitForTask(config, taskId, "completed"); + expect(finished.summary).toBe("Answered once."); + // Exactly one answer reached the loop. + const answers = listChatBlocks(config.instance, sessionId).filter( + (b) => b.kind === "tool_result" && b.preview.includes("User answered:") + ); + expect(answers.length).toBe(1); + }); + + test("a needs_input park survives a restart-reconcile and stays answerable", async () => { + const instance = "needs-input-restart"; + rmSync(`${ROOT}/instances/${instance}`, { recursive: true, force: true }); + const config = buildConfig(instance); + const { taskId, sessionId, setupId } = await parkOnAskUser(config); + + // Boot-time reconcile claims only running/queued orphans — a parked + // question is durable state waiting on the user, exactly like + // waiting_approval, so a restart must leave it untouched. + const reconciled = await reconcileInFlightTasks(config, { + cutoffIso: new Date(Date.now() + 60_000).toISOString(), + dispatch: async () => { + throw new Error("a parked question must not be re-dispatched"); + } + }); + expect(reconciled.resumed).toEqual([]); + expect(reconciled.failed).toEqual([]); + expect(readState(config.instance).tasks.find((t) => t.id === taskId)?.status).toBe("needs_input"); + + // The durable toolCallState still resumes after the "restart": a message + // post answers the surviving question and completes the same task. + stubAnswer(config, "Resumed after restart."); + const answered = await submitChatMessage(config, sessionId, { content: "resume it" }); + expect(answered.taskId).toBe(taskId); + const finished = await waitForTask(config, taskId, "completed"); + expect(finished.summary).toBe("Resumed after restart."); + expect(readState(config.instance).setupRequests.find((s) => s.id === setupId)?.status).toBe("completed"); + }); +}); diff --git a/packages/runtime/src/execution/chat-needs-input.test.ts b/packages/runtime/src/execution/chat-needs-input.part2.test.ts similarity index 59% rename from packages/runtime/src/execution/chat-needs-input.test.ts rename to packages/runtime/src/execution/chat-needs-input.part2.test.ts index 86dd7483..98746156 100644 --- a/packages/runtime/src/execution/chat-needs-input.test.ts +++ b/packages/runtime/src/execution/chat-needs-input.part2.test.ts @@ -52,7 +52,7 @@ async function submitChatMessage( return settled; } -const ROOT = "/tmp/gini-chat-needs-input-tests"; +const ROOT = `/tmp/gini-chat-needs-input-tests-${import.meta.file}`; beforeAll(() => { process.env.GINI_STATE_ROOT = ROOT; @@ -160,310 +160,6 @@ async function parkOnAskUser( return { taskId: submitted.taskId, sessionId: session.id, setupId: setup.id }; } -describe("needs_input park", () => { - test("an all-chat.choice park stamps status needs_input + the question payload", async () => { - const instance = "needs-input-park"; - rmSync(`${ROOT}/instances/${instance}`, { recursive: true, force: true }); - const config = buildConfig(instance); - const { taskId, sessionId, setupId } = await parkOnAskUser(config); - - const state = readState(config.instance); - const task = state.tasks.find((t) => t.id === taskId); - expect(task?.status).toBe("needs_input"); - expect(task?.currentStep).toBe("Waiting for your answer"); - expect(task?.needsInput?.question).toBe(QUESTION); - expect(task?.needsInput?.options).toEqual(OPTIONS.map((o) => o.label)); - expect(task?.needsInput?.setupRequestId).toBe(setupId); - // blockId points at the setup_requested card so clients can deep-link. - const setupBlock = listChatBlocks(config.instance, sessionId).find((b) => b.kind === "setup_requested"); - expect(task?.needsInput?.blockId).toBe(setupBlock!.id); - // The paused snapshot is intact — this is the same park, reclassified. - expect(task?.toolCallState?.pending?.length).toBe(1); - // needs_input counts as in-flight, so a concurrent turn can't start. - expect(sessionHasInFlightChatTask(state, sessionId)).toBe(true); - }); - - test("a turn that touches an approval gate parks as waiting_approval (no needsInput stamp)", async () => { - const instance = "needs-input-mixed"; - rmSync(`${ROOT}/instances/${instance}`, { recursive: true, force: true }); - const config = buildConfig(instance); - // The model asks for a gated file_write AND ask_user in the same turn. - // The Authorization gate pends first, so the park is NOT all-chat.choice - // and keeps waiting_approval exactly as before. - setEchoToolCallingResponse({ - provider: normalizeProvider(config.provider), - text: "", - toolCalls: [ - { - id: "call_write", - type: "function", - function: { name: "file_write", arguments: JSON.stringify({ path: "out.txt", content: "x" }) } - }, - { - id: "call_choice", - type: "function", - function: { - name: "ask_user", - arguments: JSON.stringify({ question: QUESTION, options: OPTIONS }) - } - } - ], - finishReason: "tool_calls" - }); - const session = await mutateState(config.instance, (state) => createChatSession(state, "mixed session")); - const submitted = await submitChatMessage(config, session.id, { content: "write it out" }); - const parked = await waitForTask(config, submitted.taskId, "waiting_approval"); - expect(parked.needsInput).toBeUndefined(); - expect(parked.currentStep).toBe("Waiting for approval"); - const state = readState(config.instance); - expect(state.authorizations.some((a) => a.taskId === submitted.taskId && a.status === "pending")).toBe(true); - }); - - test("GINI_NEEDS_INPUT_STATUS=0 parks as waiting_approval but still stamps needsInput", async () => { - const instance = "needs-input-hatch"; - rmSync(`${ROOT}/instances/${instance}`, { recursive: true, force: true }); - const config = buildConfig(instance); - process.env.GINI_NEEDS_INPUT_STATUS = "0"; - const { taskId, setupId } = await parkOnAskUser(config, "waiting_approval"); - - const task = readState(config.instance).tasks.find((t) => t.id === taskId); - expect(task?.status).toBe("waiting_approval"); - // The internal machinery still runs — only the exposed status differs. - expect(task?.needsInput?.setupRequestId).toBe(setupId); - - // The message-answer path keys on the stamp, so it works under the hatch. - stubAnswer(config, "Got it — Brave only."); - const answered = await submitChatMessage(config, task!.chatSessionId!, { content: "brave only please" }); - expect(answered.taskId).toBe(taskId); - const finished = await waitForTask(config, taskId, "completed"); - expect(finished.summary).toBe("Got it — Brave only."); - }); -}); - -describe("needs_input answer paths", () => { - test("a plain message post answers the question and resumes the SAME task", async () => { - const instance = "needs-input-answer"; - rmSync(`${ROOT}/instances/${instance}`, { recursive: true, force: true }); - const config = buildConfig(instance); - const { taskId, sessionId, setupId } = await parkOnAskUser(config); - - stubAnswer(config, "Got it, using DuckDuckGo."); - const answered = await submitChatMessage(config, sessionId, { content: "just use duckduckgo" }); - // Same task, same run — no new turn was minted. - expect(answered.taskId).toBe(taskId); - expect(readState(config.instance).tasks.filter((t) => t.chatSessionId === sessionId).length).toBe(1); - - const finished = await waitForTask(config, taskId, "completed"); - expect(finished.summary).toBe("Got it, using DuckDuckGo."); - // The park stamp is consumed by the resume. - expect(finished.needsInput).toBeUndefined(); - - const state = readState(config.instance); - const setup = state.setupRequests.find((s) => s.id === setupId); - expect(setup?.status).toBe("completed"); - expect(setup?.connectOutcome).toEqual({ ok: true, message: "You answered: just use duckduckgo" }); - - const blocks = listChatBlocks(config.instance, sessionId); - // The user's message renders in the thread, and the answer rode the - // ask_user tool result into the loop. - expect(blocks.some((b) => b.kind === "user_text" && b.text === "just use duckduckgo")).toBe(true); - expect( - blocks.some((b) => b.kind === "tool_result" && b.preview.includes('User answered: "just use duckduckgo"')) - ).toBe(true); - }); - - test("an option-less ask_user is rejected in place — no park, no chat.choice SetupRequest", async () => { - // ask_user is options-only: an open-ended ask belongs in the model's - // plain message text, so the dispatcher returns a graceful steering - // error instead of minting an empty choice card. The turn keeps running - // on the error result and finishes normally — it never parks. - const instance = "needs-input-optionless"; - rmSync(`${ROOT}/instances/${instance}`, { recursive: true, force: true }); - const config = buildConfig(instance); - setEchoToolCallingResponse({ - provider: normalizeProvider(config.provider), - text: "", - toolCalls: [ - { - id: "call_open", - type: "function", - function: { name: "ask_user", arguments: JSON.stringify({ question: "What should the reply say?" }) } - } - ], - finishReason: "tool_calls" - }); - // The model recovers by asking in prose — the steered path. - stubAnswer(config, "What should the reply say?"); - const session = await mutateState(config.instance, (state) => createChatSession(state, "open question session")); - const submitted = await submitChatMessage(config, session.id, { content: "reply to dana for me" }); - const finished = await waitForTask(config, submitted.taskId, "completed"); - expect(finished.needsInput).toBeUndefined(); - - const state = readState(config.instance); - expect(state.setupRequests.filter((s) => s.taskId === submitted.taskId)).toEqual([]); - const blocks = listChatBlocks(config.instance, session.id); - // The error text steers the model to plain message text. - expect( - blocks.some((b) => b.kind === "tool_result" && b.preview.includes("ask_user only presents choices")) - ).toBe(true); - expect(blocks.some((b) => b.kind === "setup_requested")).toBe(false); - }); - - test("a Chat message dispatched into a parked Topic answers and resumes the SAME task", async () => { - const instance = "needs-input-topic-dispatch"; - rmSync(`${ROOT}/instances/${instance}`, { recursive: true, force: true }); - const config = buildConfig(instance); - - // Park a TOPIC on ask_user (a kind:"topic" submit is never routed and - // runs in place). - stubAskUser(config); - const { chatId, topicId } = await mutateState(config.instance, (state) => { - const chat = createChatSession(state, "Messages", undefined, undefined, undefined, "agent"); - const topic = createTopic(state, { title: "Search setup", parentChatSessionId: chat.id }); - return { chatId: chat.id, topicId: topic.id }; - }); - const parked = await submitChatMessage(config, topicId, { content: "find me fresh results" }); - await waitForTask(config, parked.taskId, "needs_input"); - - // A Chat-routed message lands through dispatchChatMessageToTopic — the - // needs-input pre-check consumes it as the answer BEFORE the queue - // decision, resuming the SAME task instead of queueing behind the park. - stubAnswer(config, "Got it — Brave only."); - const liveSession = readState(config.instance).chatSessions.find((s) => s.id === chatId)!; - const dispatched = await dispatchChatMessageToTopic(config, chatId, topicId, { - content: "brave only please", - images: [], - audio: undefined, - liveSession, - clientSurface: undefined - }); - if ("queued" in dispatched) throw new Error("expected the answer path to run now, got queued"); - expect(dispatched.taskId).toBe(parked.taskId); - - const finished = await waitForTask(config, parked.taskId, "completed"); - expect(finished.summary).toBe("Got it — Brave only."); - // The message resumed the parked turn — it never minted a second task. - expect(readState(config.instance).tasks.filter((t) => t.chatSessionId === topicId).length).toBe(1); - }); - - test("a parked root-Chat question consumes the message as its answer before intake routing", async () => { - // The answer carve-out runs BEFORE routeChatMessage: a message posted - // while the root Chat's own live run is parked on ask_user answers and - // resumes that SAME task. Routing it into a Topic would strand the - // parked question forever. - const instance = "needs-input-router"; - rmSync(`${ROOT}/instances/${instance}`, { recursive: true, force: true }); - const config = buildConfig(instance); - - // Park the ROOT Chat (kind:"agent" — the only kind the router runs on). - stubAskUser(config); - const chatId = await mutateState(config.instance, (state) => - createChatSession(state, "Messages", undefined, undefined, undefined, "agent").id - ); - const submitted = await submitChatMessage(config, chatId, { content: "find me fresh results" }); - await waitForTask(config, submitted.taskId, "needs_input"); - - // The router would classify the answer as a new topic; the parked - // question must consume it before the router is ever consulted. - setEchoStructuredResponse("chat-route", { decision: "new_topic", title: "Should Not Mint" }); - stubAnswer(config, "Got it — Brave only."); - const answered = await submitChatMessage(config, chatId, { content: "brave only please" }); - expect(answered.taskId).toBe(submitted.taskId); - - const finished = await waitForTask(config, submitted.taskId, "completed"); - expect(finished.summary).toBe("Got it — Brave only."); - const state = readState(config.instance); - // No topic was minted and no second task started in the Chat. - expect(state.chatSessions.filter((s) => s.kind === "topic").length).toBe(0); - expect(state.tasks.filter((t) => t.chatSessionId === chatId).length).toBe(1); - }); - - test("POST /api/setup-requests/:id/complete still answers the card", async () => { - const instance = "needs-input-complete"; - rmSync(`${ROOT}/instances/${instance}`, { recursive: true, force: true }); - const config = buildConfig(instance); - const handler = createHandler(config); - const { taskId, setupId } = await parkOnAskUser(config); - - stubAnswer(config, "Great — setting up Brave and Exa now."); - const response = await handler( - new Request(`http://127.0.0.1:${config.port}/api/setup-requests/${setupId}/complete`, { - method: "POST", - headers: { "content-type": "application/json", authorization: `Bearer ${config.token}` }, - body: JSON.stringify({ choice: { label: "Set up Brave + Exa" } }) - }) - ); - expect(response.status).toBe(200); - const finished = await waitForTask(config, taskId, "completed"); - expect(finished.summary).toBe("Great — setting up Brave and Exa now."); - expect(finished.needsInput).toBeUndefined(); - }); - - test("double answer: the second resolve of an answered question fails gracefully", async () => { - const instance = "needs-input-race"; - rmSync(`${ROOT}/instances/${instance}`, { recursive: true, force: true }); - const config = buildConfig(instance); - const handler = createHandler(config); - const { taskId, sessionId, setupId } = await parkOnAskUser(config); - - stubAnswer(config, "Answered once."); - const answered = await submitChatMessage(config, sessionId, { content: "first answer wins" }); - expect(answered.taskId).toBe(taskId); - - // A near-simultaneous answer from the thread card loses the claim with a - // typed already-resolved error, never a double resume. - await expect( - resolveSetupRequest(config, setupId, "complete", { actor: "user", resumeChatTask: false }) - ).rejects.toThrow(ApprovalRaceLostError); - // The HTTP surface maps the same race to 410 via its pending pre-check. - const response = await handler( - new Request(`http://127.0.0.1:${config.port}/api/setup-requests/${setupId}/complete`, { - method: "POST", - headers: { "content-type": "application/json", authorization: `Bearer ${config.token}` }, - body: JSON.stringify({ choice: { label: "Set up Brave only" } }) - }) - ); - expect(response.status).toBe(410); - - const finished = await waitForTask(config, taskId, "completed"); - expect(finished.summary).toBe("Answered once."); - // Exactly one answer reached the loop. - const answers = listChatBlocks(config.instance, sessionId).filter( - (b) => b.kind === "tool_result" && b.preview.includes("User answered:") - ); - expect(answers.length).toBe(1); - }); - - test("a needs_input park survives a restart-reconcile and stays answerable", async () => { - const instance = "needs-input-restart"; - rmSync(`${ROOT}/instances/${instance}`, { recursive: true, force: true }); - const config = buildConfig(instance); - const { taskId, sessionId, setupId } = await parkOnAskUser(config); - - // Boot-time reconcile claims only running/queued orphans — a parked - // question is durable state waiting on the user, exactly like - // waiting_approval, so a restart must leave it untouched. - const reconciled = await reconcileInFlightTasks(config, { - cutoffIso: new Date(Date.now() + 60_000).toISOString(), - dispatch: async () => { - throw new Error("a parked question must not be re-dispatched"); - } - }); - expect(reconciled.resumed).toEqual([]); - expect(reconciled.failed).toEqual([]); - expect(readState(config.instance).tasks.find((t) => t.id === taskId)?.status).toBe("needs_input"); - - // The durable toolCallState still resumes after the "restart": a message - // post answers the surviving question and completes the same task. - stubAnswer(config, "Resumed after restart."); - const answered = await submitChatMessage(config, sessionId, { content: "resume it" }); - expect(answered.taskId).toBe(taskId); - const finished = await waitForTask(config, taskId, "completed"); - expect(finished.summary).toBe("Resumed after restart."); - expect(readState(config.instance).setupRequests.find((s) => s.id === setupId)?.status).toBe("completed"); - }); -}); - // Simulate the crash window that wedges a park: the answer's persist // completed (gate atomically claimed + human-readable outcome recorded — // exactly what answerNeedsInputForMessage and the /complete handler do diff --git a/packages/runtime/src/execution/chat-task.part1.test.ts b/packages/runtime/src/execution/chat-task.part1.test.ts new file mode 100644 index 00000000..0b5030d2 --- /dev/null +++ b/packages/runtime/src/execution/chat-task.part1.test.ts @@ -0,0 +1,2402 @@ +// End-to-end tests for the chat-task agent loop. +// +// We use the echo provider with stubbed tool-calling responses so the loop +// is fully deterministic. The test covers: +// - one tool call → result fed back → final answer +// - approval-gated tool call → task pauses with toolCallState +// - resume after approval → task completes +// +// HOME is pointed at a unique mkdtemp dir per test (same pattern as +// src/state/google-accounts.test.ts): the loop reads the machine-global +// Google account registry (buildConnectedAccountsBlock(readGoogleAccounts()) +// in the system prompt; isSkillActive via credentialExternallySatisfied), so +// a developer machine with registered accounts would otherwise shift the +// system-prompt size and skill activity these tests depend on. + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, join } from "node:path"; +import { + clearEchoAuxTextResponses, + clearEchoToolCallingResponses, + getEchoAuxTextRequests, + getEchoToolCallingCalls, + getEchoToolCallingToolNames, + setEchoAuxTextFailure, + setEchoAuxTextResponse, + setEchoToolCallingFailure, + setEchoToolCallingResponse, + normalizeProvider, + type MessageContentPart, + type ToolCallingMessage +} from "../provider"; +import { submitTask, decideApproval, resolveSetupRequest } from "../agent"; +import { + bankIdForAgent, + createChatMessage, + createChatSession, + createEmptyState, + createRun, + createSubagentRecord, + deleteChatSession, + ensureAgentBank, + ensureDefaultBank, + insertMemoryUnit, + listChatBlocks, + mutateState, + now, + readState, + recordProviderAuthFailure +} from "../state"; +import { echoEmbed } from "../embeddings"; +import { storeUpload } from "../state/uploads"; +import { resolveDefaultPriorContextTokenBudget } from "../provider-capabilities"; +import type { AgentIdentity, GoogleAccount, JobRecord, RuntimeConfig, RuntimeState, SkillRecord, Task, ToolsetRecord } from "../types"; +import { createSkillFromInput, setSkillStatus } from "../capabilities/skills"; +import { + __setBaseToolCatalogForTests, + buildAgentIdentity, + buildConnectedAccountsBlock, + buildEnabledSkillsBlock, + buildInactiveSkillsBlock, + buildMcpServersBlock, + buildSkillScriptsBlock, + compactionMiddleSpan, + elideOldToolResultsToBudget, + IN_TURN_COMPACTION_NOTE_PREFIX, + initialNavStallState, + nextNavStallState, + promptTokensFromUsage, + renderMessagesForCompaction +} from "./chat-task"; +import type { ToolCatalogTool } from "./tool-catalog"; +import type { EffectiveContext } from "./effective-context"; +import { __resetDefaultGiniInstructionsCacheForTest } from "../system-prompt"; + +// Compaction geometry in this file is calibrated against a FIXED instructions +// size (alongside the pinned tool catalog). The real default INSTRUCTIONS.md +// changes over time, which would otherwise shift the system-prompt slice and +// move the compaction crossing point opaquely. Pin a frozen fixture (byte-for- +// byte the default as of this calibration) so edits to the shipped default +// never perturb these tests. +const COMPACTION_INSTRUCTIONS_FIXTURE = join( + import.meta.dir, + "..", + "runtime", + "__fixtures__", + "instructions-compaction-geometry.md" +); + +// These tests submit on idle sessions, which always run immediately. They also +// don't seed a "chat-route" stub, so the router coerces to a chat-direct +// decision and the turn runs in the submitted session. Narrow the submit union +// to the chat-direct run-now branch so the existing `.taskId` reads stay typed +// (a queued or topic-dispatched result here is a test-setup bug). See ADR +// chat-message-queue.md. +async function submitChatMessage( + config: RuntimeConfig, + sessionId: string, + input: Record +): Promise<{ sessionId: string; runId?: string; taskId: string; status: Task["status"] }> { + const { submitChatMessage: submitChatMessageRaw } = await import("./chat"); + const { settleSubmittedChatMessage } = await import("./chat-test-support"); + const result = await submitChatMessageRaw(config, sessionId, input); + const settled = await settleSubmittedChatMessage( + config, + sessionId, + result, + String(input.content ?? "") + ); + if ("queued" in settled) throw new Error("expected run-now submission, got queued"); + if ("topicId" in settled) throw new Error("expected chat-direct submission, got topic dispatch"); + return settled; +} + +let scratchHome: string; +let prevHome: string | undefined; +let prevEmbedding: string | undefined; + +beforeEach(() => { + scratchHome = mkdtempSync(join(tmpdir(), "gini-chat-task-home-")); + prevHome = process.env.HOME; + process.env.HOME = scratchHome; + // Pin embeddings to echo: the local provider is unavailable under bun + // test, and the fallback chain otherwise picks the openai provider on + // machines with ~/.codex/auth.json (resolved via os.homedir(), which + // ignores the HOME override above) — turning every memory embed in the + // loop into a real network call. + prevEmbedding = process.env.GINI_EMBEDDING_PROVIDER; + process.env.GINI_EMBEDDING_PROVIDER = "echo"; + // Pin instructions to the frozen geometry fixture so the compaction tests' + // system-prompt slice is independent of edits to the shipped default. + __resetDefaultGiniInstructionsCacheForTest(COMPACTION_INSTRUCTIONS_FIXTURE); +}); + +afterEach(() => { + if (prevHome === undefined) delete process.env.HOME; + else process.env.HOME = prevHome; + if (prevEmbedding === undefined) delete process.env.GINI_EMBEDDING_PROVIDER; + else process.env.GINI_EMBEDDING_PROVIDER = prevEmbedding; + __resetDefaultGiniInstructionsCacheForTest(); + rmSync(scratchHome, { recursive: true, force: true }); +}); + +function buildConfig(workspaceRoot: string, instance: string, opts: Partial = {}): RuntimeConfig { + return { + instance, + port: 7338, + token: "test", + provider: { name: "echo", model: "" }, + workspaceRoot, + stateRoot: process.env.GINI_STATE_ROOT ?? "/tmp/gini-chat-task-test", + logRoot: process.env.GINI_LOG_ROOT ?? "/tmp/gini-chat-task-test-logs", + // These tests predate the approvalMode flip and pin the + // approval-gated loop behavior. Force "strict" so the chat-task + // loop continues to exercise the pause+resume path here; the new + // default-auto matrix lives in approval-mode.test.ts. + approvalMode: "strict", + ...opts + }; +} + +// Seed an enabled skill with an arbitrary (large) body. read_skill returns +// the body verbatim, which makes it the simplest way to drive big tool +// results through the loop without touching the filesystem caps. +async function seedBulkSkill(config: RuntimeConfig, name: string, body: string): Promise { + const skill = await createSkillFromInput(config, { name, description: `Bulk ${name}` }); + await mutateState(config.instance, (state) => { + const item = state.skills.find((s) => s.id === skill.id)!; + item.body = body; + }); + await setSkillStatus(config, skill.id, "enabled"); +} + +// Fixed, test-owned tool catalog for the in-turn-compaction geometry tests. +// Those tests size skill bodies against the always-on `toolSchemaTokens` +// floor; growing any always-on tool description (as create_job's did) would +// otherwise shift that floor and move the compaction crossing point opaquely. +// Installing this constant via __setBaseToolCatalogForTests pins the floor so +// no live-catalog change can perturb the geometry. +// +// Shape: `read_skill` (the only tool the compaction scripts dispatch — the +// loop must run to completion with it) plus uniform filler tools whose count +// is chosen so the serialized provider catalog tokenizes to a known floor. +// Measured floor: toolSchemaTokens = ceil(JSON.stringify(toProviderTools)/4) +// = 12,207 tokens (read_skill ≈ 90 tokens + 47 filler tools ≈ 258 tokens +// each). The high-water mark is floor(32,000 × 0.85) = 27,200 under the echo +// provider (no usage → promptTokenEstimateGap 0), so the message budget +// before the mark is 27,200 − 12,207 ≈ 14,993 tokens. Each in-turn exchange +// is one ~8,600-char read_skill result (≈ 2,150 tokens) plus its assistant +// tool_call row; with the protected head + system prompt, six accumulated +// results cross the mark before the 7th call. The filler count is the ONE +// knob that sets the floor; bodies below are derived from it. +const FIXED_COMPACTION_CATALOG_FILLER = 47; +const FIXED_COMPACTION_CATALOG: ToolCatalogTool[] = [ + { + toolset: "skills", + type: "function", + function: { + name: "read_skill", + description: "Return the full body of an enabled skill by name.", + parameters: { + type: "object", + properties: { name: { type: "string", description: "Skill name." } }, + required: ["name"] + } + } + }, + ...Array.from({ length: FIXED_COMPACTION_CATALOG_FILLER }, (_unused, i) => ({ + toolset: "core", + type: "function" as const, + function: { + name: `fixed_floor_tool_${i}`, + description: "X".repeat(900), + parameters: { type: "object", properties: {}, required: [] as string[] } + } + })) +]; + +// Poll cadence for the terminal-state waiters. The task settles on a detached +// microtask/timer, so the wait resolves at the NEXT poll after it lands — a +// tight interval keeps the wall-clock cost near the real settle time instead of +// rounding every wait up to a coarse tick. Kept as a named constant so the +// cadence is tunable in one place (and small enough that 100+ sequential waits +// don't dominate the suite). +const TERMINAL_POLL_MS = 2; + +async function waitForTerminal(config: RuntimeConfig, taskId: string, timeoutMs = 5000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const state = readState(config.instance); + const task = state.tasks.find((t) => t.id === taskId); + if (task && (task.status === "completed" || task.status === "failed" || task.status === "cancelled" || task.status === "waiting_approval" || task.status === "needs_input")) { + return task; + } + await Bun.sleep(TERMINAL_POLL_MS); + } + throw new Error(`Task ${taskId} did not reach terminal state within ${timeoutMs}ms`); +} + +// Post-approve wait: decideApproval("approve") returns once the decision is +// durable while the side effect + resume run detached, so the task is still +// parked at waiting_approval when the call returns — waitForTerminal's +// parked-inclusive accept set would return the pre-approve park immediately. +// Poll for a genuinely final status instead. +async function waitForFinalTerminal(config: RuntimeConfig, taskId: string, timeoutMs = 5000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const state = readState(config.instance); + const task = state.tasks.find((t) => t.id === taskId); + if (task && (task.status === "completed" || task.status === "failed" || task.status === "cancelled")) { + return task; + } + await Bun.sleep(TERMINAL_POLL_MS); + } + throw new Error(`Task ${taskId} did not reach a final terminal state within ${timeoutMs}ms`); +} + +describe("chat-task loop", () => { + let root: string; + let prevState: string | undefined; + let prevLog: string | undefined; + let prevTransientBackoff: string | undefined; + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "gini-chat-task-")); + prevState = process.env.GINI_STATE_ROOT; + prevLog = process.env.GINI_LOG_ROOT; + prevTransientBackoff = process.env.GINI_TRANSIENT_RETRY_BASE_MS; + process.env.GINI_STATE_ROOT = root; + process.env.GINI_LOG_ROOT = `${root}-logs`; + // Run the transient-retry backoff at 0ms so these tests never burn real + // wall-clock sleep (the production curve is verified separately by the + // trace-message assertions); keeps the suite fast and deterministic. + process.env.GINI_TRANSIENT_RETRY_BASE_MS = "0"; + clearEchoToolCallingResponses(); + clearEchoAuxTextResponses(); + }); + + afterEach(() => { + if (prevState === undefined) delete process.env.GINI_STATE_ROOT; + else process.env.GINI_STATE_ROOT = prevState; + if (prevLog === undefined) delete process.env.GINI_LOG_ROOT; + else process.env.GINI_LOG_ROOT = prevLog; + if (prevTransientBackoff === undefined) delete process.env.GINI_TRANSIENT_RETRY_BASE_MS; + else process.env.GINI_TRANSIENT_RETRY_BASE_MS = prevTransientBackoff; + // Clear any fixed-catalog override a compaction test installed so the + // rest of the suite sees the live buildToolCatalog. + __setBaseToolCatalogForTests(null); + rmSync(root, { recursive: true, force: true }); + clearEchoToolCallingResponses(); + clearEchoAuxTextResponses(); + }); + + test("dispatches a low-risk tool call then completes with a final answer", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + const fixturePath = join(workspaceRoot, "hello.md"); + writeFileSync(fixturePath, "Hello, world!"); + const config = buildConfig(workspaceRoot, "chat-task-sync"); + const provider = normalizeProvider(config.provider); + + // First model turn: ask to read the file. + setEchoToolCallingResponse({ + provider, + text: "", + toolCalls: [ + { id: "call_1", type: "function", function: { name: "file_read", arguments: JSON.stringify({ path: "hello.md" }) } } + ], + finishReason: "tool_calls" + }); + // Second model turn: respond with the file contents. + setEchoToolCallingResponse({ + provider, + text: "The file says: Hello, world!", + toolCalls: [], + finishReason: "stop" + }); + + const task = await submitTask(config, "what does hello.md say?", { mode: "chat" }); + const finished = await waitForTerminal(config, task.id); + + expect(finished.status).toBe("completed"); + expect(finished.summary).toBe("The file says: Hello, world!"); + // Audit trail should include the file.read. + const state = readState(config.instance); + const reads = state.audit.filter((a) => a.action === "file.read" && a.taskId === task.id); + expect(reads).toHaveLength(1); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + test("read_skill rejects disabled skills with a recoverable error", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + const config = buildConfig(workspaceRoot, "chat-task-readskill-disabled"); + const provider = normalizeProvider(config.provider); + + // Create a disabled skill. The agent loop should see an + // error tool result and recover. + const skill = await createSkillFromInput(config, { + name: "disabled-skill", + description: "Currently disabled." + }); + await setSkillStatus(config, skill.id, "disabled"); + await mutateState(config.instance, (state) => { + const item = state.skills.find((s) => s.id === skill.id)!; + item.body = "Some disabled content."; + }); + + setEchoToolCallingResponse({ + provider, + text: "", + toolCalls: [ + { id: "call_disabled", type: "function", function: { name: "read_skill", arguments: JSON.stringify({ name: "disabled-skill" }) } } + ], + finishReason: "tool_calls" + }); + setEchoToolCallingResponse({ + provider, + text: "Got it — that skill is disabled.", + toolCalls: [], + finishReason: "stop" + }); + + const task = await submitTask(config, "use the disabled skill", { mode: "chat" }); + const finished = await waitForTerminal(config, task.id); + + expect(finished.status).toBe("completed"); + expect(finished.summary).toBe("Got it — that skill is disabled."); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + // One-pending-per-turn regression. When the LLM emits multiple + // tool calls in a single assistant turn and the first one returns + // a pending approval, all subsequent dispatches MUST be deferred + // so their side effects don't race the user's approval decision. + // The chat-task loop skips remaining calls and synthesizes a + // "skipped" tool_result for message-history symmetry; the LLM + // re-evaluates from the new state on the next turn after the + // approval resolves. + test("terminal_exec without pty sees no TTY and stays on the legacy spawn path", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + const config = buildConfig(workspaceRoot, "chat-task-no-pty"); + const provider = normalizeProvider(config.provider); + + const command = "tty -s && echo PTY-OK || echo NO-PTY"; + setEchoToolCallingResponse({ + provider, + text: "", + toolCalls: [ + { id: "call_nopty", type: "function", function: { name: "terminal_exec", arguments: JSON.stringify({ command }) } } + ], + finishReason: "tool_calls" + }); + setEchoToolCallingResponse({ + provider, + text: "Ran without TTY.", + toolCalls: [], + finishReason: "stop" + }); + + const task = await submitTask(config, "run without pty", { mode: "chat" }); + const paused = await waitForTerminal(config, task.id); + expect(paused.status).toBe("waiting_approval"); + + const stateBefore = readState(config.instance); + const approval = stateBefore.authorizations.find((a) => a.id === paused.approvalIds[0]!)!; + expect(approval.payload.pty).toBe(false); + + await decideApproval(config, approval.id, "approve"); + const finished = await waitForFinalTerminal(config, task.id); + expect(finished.status).toBe("completed"); + + const stateAfter = readState(config.instance); + const auditEntry = stateAfter.audit.find((a) => a.action === "terminal.exec" && a.taskId === task.id)!; + const evidence = auditEntry.evidence as Record; + expect(evidence.pty).toBe(false); + expect(String(evidence.stdout)).toContain("NO-PTY"); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + // Auto-approve allowlist (Fix 2). When the user has added a matching + // pattern to RuntimeConfig.autoApproveCommands, terminal_exec should + // execute synchronously and write a high-risk audit row with + // evidence.autoApproved=true plus the matched pattern. No approval row + // should be created — the loop must continue without pausing. + test("a successful provider call clears the persistent needs-reauth record", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + const config = buildConfig(workspaceRoot, "chat-task-reauth-clear"); + const provider = normalizeProvider(config.provider); + + // A prior turn recorded an echo auth failure (issue #233). + await mutateState(config.instance, (state) => { + recordProviderAuthFailure(state, { provider: "echo", detail: "token expired", taskId: "task_prior" }); + }); + expect(readState(config.instance).providerAuthFailures?.echo).toBeDefined(); + + setEchoToolCallingResponse({ provider, text: "All good again.", toolCalls: [], finishReason: "stop" }); + const task = await submitTask(config, "say hi", { mode: "chat" }); + const finished = await waitForTerminal(config, task.id); + expect(finished.status).toBe("completed"); + + // The successful call dropped the record and audited the clear. + const state = readState(config.instance); + expect(state.providerAuthFailures?.echo).toBeUndefined(); + const cleared = state.audit.find((a) => a.action === "provider.auth.cleared" && a.target === "echo"); + expect(cleared).toBeDefined(); + expect(cleared?.evidence).toMatchObject({ provider: "echo", reason: "provider call succeeded" }); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + test("does NOT trip the navigation loop-breaker on distinct-URL research", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + const config = buildConfig(workspaceRoot, "chat-task-nav-distinct"); + config.agent = { maxIterations: 9 }; + const provider = normalizeProvider(config.provider); + // Pin the tool-catalog floor so the many-iteration geometry is decoupled + // from live always-on catalog size (cleared in afterEach) — this test is + // about the nav guard, not the context window. Dispatch keys on the call + // name, so browser_navigate still hits the SSRF gate as before. + __setBaseToolCatalogForTests(FIXED_COMPACTION_CATALOG); + + // Nine navigations to nine DISTINCT loopback URLs. Each is SSRF-blocked + // pre-flight (deterministic, no browser), but the nav guard counts the + // call regardless of result — and since every URL is new, the count never + // climbs. The loop exhausts the iteration cap instead. + for (let i = 1; i <= 9; i++) { + setEchoToolCallingResponse({ + provider, + text: "", + toolCalls: [ + { + id: `call_nav_${i}`, + type: "function", + function: { name: "browser_navigate", arguments: JSON.stringify({ url: `http://127.0.0.1:${i}/` }) } + } + ], + finishReason: "tool_calls" + }); + } + // Tool-less summary turn the cap-exhaustion exit consumes. + setEchoToolCallingResponse({ + provider, + text: "I checked nine different pages but ran out of steps before finishing.", + toolCalls: [], + finishReason: "stop" + }); + + const task = await submitTask(config, "research across pages", { mode: "chat" }); + const finished = await waitForTerminal(config, task.id, 10000); + + expect(finished.status).toBe("completed"); + // Reached the iteration cap — NOT the loop-breaker. + expect(finished.currentStep).toBe("Completed (iteration cap reached: 9)"); + + const { readTrace } = await import("../state"); + const traces = readTrace(config.instance, task.id); + // No navigation loop-breaker warning fired. + const navBreaker = traces.find( + (t) => t.type === "warning" && /navigations to recently-visited URLs.*loop-breaker/.test(t.message) + ); + expect(navBreaker).toBeUndefined(); + // The cap warning is what stopped us. + const capWarning = traces.find((t) => t.type === "warning" && /Iteration cap \(9\)/.test(t.message)); + expect(capWarning).toBeDefined(); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + // The navigation loop-breaker's protection MUST NOT regress: a model that + // oscillates between a small set of URLs (the degenerate reload/ping-pong + // pattern behind the original context-overflow incident) still trips it at + // the existing threshold. Oscillating between TWO URLs keeps the exact-match + // and action-only guards from firing (arguments alternate every turn), so + // only the navigation guard can catch it — isolating the guard under test. + test("accumulates cost across iterations including the cap-exhaustion summary turn", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + const fixturePath = join(workspaceRoot, "hello.md"); + writeFileSync(fixturePath, "Hello, world!"); + const config = buildConfig(workspaceRoot, "chat-task-cost-accum"); + config.agent = { maxIterations: 2 }; + const provider = normalizeProvider(config.provider); + + // Two tool-call iterations, each reporting 10 in / 5 out / 15 total. + for (let i = 0; i < 2; i++) { + setEchoToolCallingResponse({ + provider, + text: "", + toolCalls: [ + { + id: `call_acc_${i}`, + type: "function", + function: { name: "file_read", arguments: JSON.stringify({ path: "hello.md" }) } + } + ], + finishReason: "tool_calls", + cost: { provider: "echo", model: "gini-echo-v0", inputTokens: 10, outputTokens: 5, totalTokens: 15 } + }); + } + // Cap-exhaustion summary turn — adds another 4 in / 6 out / 10 total. + setEchoToolCallingResponse({ + provider, + text: "Cap reached.", + toolCalls: [], + finishReason: "stop", + cost: { provider: "echo", model: "gini-echo-v0", inputTokens: 4, outputTokens: 6, totalTokens: 10 } + }); + + const task = await submitTask(config, "loop a bit", { mode: "chat" }); + const finished = await waitForTerminal(config, task.id, 10000); + + expect(finished.status).toBe("completed"); + expect(finished.summary).toBe("Cap reached."); + // 10 + 10 + 4 = 24 input tokens, 5 + 5 + 6 = 16 output tokens, 15 + 15 + 10 = 40 total. + expect(finished.cost?.inputTokens).toBe(24); + expect(finished.cost?.outputTokens).toBe(16); + expect(finished.cost?.totalTokens).toBe(40); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + // Fix 4 (warning de-duplication): the invalid-config warning should be + // emitted at most once per task even when runLoop is re-entered after + // approval pauses. We force a pause via a gated tool call, approve it, + // then assert the trace contains exactly one matching warning. + test("subagent path preserves the subagent prompt and still appends the bound-jobs block", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + const config = buildConfig(workspaceRoot, "chat-task-bound-job-subagent"); + const provider = normalizeProvider(config.provider); + + const SUBAGENT_PROMPT = "You are a narrow research subagent. Stay terse."; + const { runId, subagentId, jobId } = await mutateState(config.instance, (state) => { + const session = createChatSession(state, "Research thread"); + const run = createRun(state, { + kind: "conversation_turn", + title: "Research turn", + input: "kick off", + conversationId: session.id + }); + const subagent = createSubagentRecord(state, { + name: "researcher", + prompt: "research subagent", + toolsets: ["file"], + systemPrompt: SUBAGENT_PROMPT + }); + const at = now(); + const job: JobRecord = { + id: "job-research", + instance: state.instance, + name: "Weekly research digest", + prompt: "Summarize this week's top three industry stories.", + intervalSeconds: 604800, + status: "active", + deliveryTargets: [], + context: [], + retryLimit: 0, + timeoutSeconds: 600, + chatSessionId: session.id, + createdAt: at, + updatedAt: at, + nextRunAt: at, + runCount: 0, + missedRuns: 0, + taskIds: [], + runIds: [] + }; + state.jobs.push(job); + return { runId: run.id, subagentId: subagent.id, jobId: job.id }; + }); + + setEchoToolCallingResponse({ + provider, + text: "Done.", + toolCalls: [], + finishReason: "stop" + }); + + const task = await submitTask(config, "go", { mode: "chat", runId, subagentId }); + const finished = await waitForTerminal(config, task.id); + expect(finished.status).toBe("completed"); + + const calls = getEchoToolCallingCalls(); + expect(calls.length).toBeGreaterThan(0); + const system = calls[0]!.find((m) => m.role === "system"); + const content = String(system?.content ?? ""); + // Subagent prompt preserved verbatim at the top of system context. + expect(content.startsWith(SUBAGENT_PROMPT)).toBe(true); + // Scheduled-jobs context block still appended after the subagent prompt. + expect(content).toContain("Scheduled jobs delivering into this chat:"); + expect(content).toContain(jobId); + expect(content).toContain("Weekly research digest"); + expect(content).toContain("every 604800s"); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + // [SILENT] sentinel suppression at the chat-block layer. A scheduled + // job (or fan-out subagent worker) that has nothing to report responds + // with exactly "[SILENT]". The legacy message layer drops the + // ChatMessageRecord, but the UI renders chat blocks — so a completed + // turn whose final text is exactly "[SILENT]" must NOT leave a visible + // assistant_text block behind. + test("subagent path skips runtime identity injection", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + const config = buildConfig(workspaceRoot, "chat-task-identity-subagent"); + const provider = normalizeProvider(config.provider); + + const SUBAGENT_PROMPT = "You are a narrow research subagent. Stay terse."; + const { runId, subagentId } = await mutateState(config.instance, (state) => { + const session = createChatSession(state, "Identity subagent probe"); + const run = createRun(state, { + kind: "conversation_turn", + title: "Subagent turn", + input: "kick off", + conversationId: session.id + }); + const subagent = createSubagentRecord(state, { + name: "researcher", + prompt: "research subagent", + toolsets: ["file"], + systemPrompt: SUBAGENT_PROMPT + }); + return { runId: run.id, subagentId: subagent.id }; + }); + + setEchoToolCallingResponse({ + provider, + text: "Done.", + toolCalls: [], + finishReason: "stop" + }); + + const task = await submitTask(config, "go", { mode: "chat", runId, subagentId }); + const finished = await waitForTerminal(config, task.id); + expect(finished.status).toBe("completed"); + + const calls = getEchoToolCallingCalls(); + expect(calls.length).toBeGreaterThan(0); + const turn = calls[0]!; + const system = turn.find((m) => m.role === "system"); + const content = String(system?.content ?? ""); + expect(content.startsWith(SUBAGENT_PROMPT)).toBe(true); + expect(content).not.toContain("Your runtime identity:"); + expect(content).not.toContain("Runtime identity changes since last turn:"); + // Subagents keep their single override prompt + the real user message: + // no ephemeral identity/memory tail is injected on the subagent path. + expect(turn.filter((m) => m.role === "user").length).toBe(1); + for (const m of turn) { + const text = String(m.content ?? ""); + expect(text).not.toContain("Your runtime identity:"); + expect(text).not.toContain("Runtime identity changes since last turn:"); + } + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + // Client-surface injection: the surface of the message that started THIS + // turn rides in the ephemeral role:"user" tail (never the byte-stable + // system prefix), and is omitted entirely when unknown. See ADR + // client-surface-context.md. + test("deferred snapshot write skips when the chat session was deleted before the model returned", async () => { + // Race: snapshot decision is made up-front in runChatTask, but the + // write is deferred to runLoop after the first model call. If the + // chat session is deleted during that window, the deferred write + // must not recreate an orphan snapshot keyed on a now-deleted + // session id. We deterministically simulate the race by deleting + // the session before the task ever runs -- the run still exists, + // so runChatTask resolves the conversationId, but the deferred + // write's session-existence check inside mutateState catches the + // deletion and skips the write. + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + const config = buildConfig(workspaceRoot, "chat-task-identity-orphan-guard"); + const provider = normalizeProvider(config.provider); + + const { runId, sessionId } = await mutateState(config.instance, (state) => { + const session = createChatSession(state, "Soon-to-be-deleted"); + const run = createRun(state, { + kind: "conversation_turn", + title: "Orphan probe", + input: "go", + conversationId: session.id + }); + return { runId: run.id, sessionId: session.id }; + }); + + // Delete the chat session before the task runs. runChatTask will + // still build identity from the run.conversationId, but the + // deferred write must observe that the session is gone. + await mutateState(config.instance, (state) => { + deleteChatSession(state, sessionId); + }); + + setEchoToolCallingResponse({ + provider, + text: "Done.", + toolCalls: [], + finishReason: "stop" + }); + + const task = await submitTask(config, "anything", { mode: "chat", runId }); + await waitForTerminal(config, task.id); + + expect(readState(config.instance).identitySnapshots?.[sessionId]).toBeUndefined(); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + test("emits typed blocks for a successful tool-calling turn", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + const fixturePath = join(workspaceRoot, "hello.md"); + writeFileSync(fixturePath, "Hello, world!"); + const config = buildConfig(workspaceRoot, "chat-task-blocks-success"); + const provider = normalizeProvider(config.provider); + + // Set up the session BEFORE submitting the task so chatSessionId + // is bound to the task. submitTask threads chatSessionId through + // to createTask which the emission resolver reads. + const session = await mutateState(config.instance, (state) => + createChatSession(state, "block-test", undefined, "agent_x") + ); + + setEchoToolCallingResponse({ + provider, + text: "Sure, reading it now.", + toolCalls: [ + { id: "call_1", type: "function", function: { name: "file_read", arguments: JSON.stringify({ path: "hello.md" }) } } + ], + finishReason: "tool_calls" + }); + setEchoToolCallingResponse({ + provider, + text: "The file says: Hello, world!", + toolCalls: [], + finishReason: "stop" + }); + + const submitted = await submitChatMessage(config, session.id, { content: "what does hello.md say?" }); + const finished = await waitForTerminal(config, submitted.taskId); + expect(finished.status).toBe("completed"); + + const { listChatBlocks } = await import("../state"); + const blocks = listChatBlocks(config.instance, session.id); + // Expected sequence in ordinal order: + // user_text → phase("Thinking") → assistant_text("Sure, reading it now.") + // → phase("Working: file_read") → tool_call(file_read, ok) + // → tool_result(call_1) → phase("Thinking") → assistant_text(final) + // → phase("Completed") + const kinds = blocks.map((b) => b.kind); + expect(kinds[0]).toBe("user_text"); + expect(kinds).toContain("tool_call"); + expect(kinds).toContain("tool_result"); + expect(kinds[kinds.length - 1]).toBe("phase"); + + const user = blocks.find((b) => b.kind === "user_text"); + expect(user?.kind === "user_text" && user.text).toBe("what does hello.md say?"); + + const toolCall = blocks.find((b) => b.kind === "tool_call"); + if (toolCall?.kind === "tool_call") { + expect(toolCall.toolName).toBe("file_read"); + expect(toolCall.displayLabel).toBe("Read file"); + expect(toolCall.argsPreview).toBe("hello.md"); + expect(toolCall.argsFull).toEqual({ path: "hello.md" }); + expect(toolCall.status).toBe("ok"); + expect(toolCall.callId).toBe("call_1"); + } else { + throw new Error("missing tool_call block"); + } + + const toolResult = blocks.find((b) => b.kind === "tool_result"); + if (toolResult?.kind === "tool_result") { + expect(toolResult.callId).toBe("call_1"); + } else { + throw new Error("missing tool_result block"); + } + + // Final assistant_text is the model's reply (after the tool result + // turn) — settled with streaming:false. + const assistantTexts = blocks.filter((b): b is typeof blocks[0] & { kind: "assistant_text" } => + b.kind === "assistant_text" + ); + expect(assistantTexts.length).toBeGreaterThan(0); + const finalAssistant = assistantTexts[assistantTexts.length - 1]!; + expect(finalAssistant.streaming).toBe(false); + expect(finalAssistant.text).toContain("Hello, world!"); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + test("connector.request surfaces the reason as an assistant bubble above the setup card", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + const config = buildConfig(workspaceRoot, "chat-task-blocks-connector"); + const provider = normalizeProvider(config.provider); + + const session = await mutateState(config.instance, (state) => + createChatSession(state, "block-connector", undefined, "agent_z") + ); + + const reason = "Brave Search would help me find a fresh answer here. Want to connect it?"; + setEchoToolCallingResponse({ + provider, + text: "", + toolCalls: [ + { id: "call_c", type: "function", function: { name: "request_connector", arguments: JSON.stringify({ provider: "brave-search", reason }) } } + ], + finishReason: "tool_calls" + }); + + const submitted = await submitChatMessage(config, session.id, { content: "search the web for the best cafe" }); + const paused = await waitForTerminal(config, submitted.taskId); + expect(paused.status).toBe("waiting_approval"); + + const { listChatBlocks } = await import("../state"); + const blocks = listChatBlocks(config.instance, session.id); + + // The minimal setup card carries no inline reason; the reason is its + // own assistant bubble, ordered above the card. + const setupIdx = blocks.findIndex((b) => b.kind === "setup_requested"); + expect(setupIdx).toBeGreaterThanOrEqual(0); + const setup = blocks[setupIdx]; + if (setup?.kind === "setup_requested") { + expect(setup.action).toBe("connector.request"); + } else { + throw new Error("missing setup_requested block"); + } + const reasonIdx = blocks.findIndex((b) => b.kind === "assistant_text" && b.text === reason); + expect(reasonIdx).toBeGreaterThanOrEqual(0); + expect(reasonIdx).toBeLessThan(setupIdx); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + test("confirmation.request cancel resumes the chat loop with {confirmed:false}", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + const config = buildConfig(workspaceRoot, "chat-task-blocks-confirm-cancel"); + const provider = normalizeProvider(config.provider); + + const session = await mutateState(config.instance, (state) => + createChatSession(state, "block-confirm-cancel", undefined, "agent_c") + ); + + setEchoToolCallingResponse({ + provider, + text: "", + toolCalls: [ + { + id: "call_c", + type: "function", + function: { + name: "request_confirmation", + arguments: JSON.stringify({ summary: "Send the reply to Dana" }) + } + } + ], + finishReason: "tool_calls" + }); + setEchoToolCallingResponse({ + provider, + text: "Okay, I won't send it. What should I change?", + toolCalls: [], + finishReason: "stop" + }); + + const submitted = await submitChatMessage(config, session.id, { content: "reply to Dana" }); + const paused = await waitForTerminal(config, submitted.taskId); + expect(paused.status).toBe("waiting_approval"); + + const setup = readState(config.instance).setupRequests.find((s) => s.taskId === submitted.taskId); + expect(setup?.action).toBe("confirmation.request"); + + await resolveSetupRequest(config, setup!.id, "cancel", { actor: "user" }); + + let finished = readState(config.instance).tasks.find((t) => t.id === submitted.taskId); + const deadline = Date.now() + 5000; + while (finished?.status !== "completed" && Date.now() < deadline) { + await Bun.sleep(TERMINAL_POLL_MS); + finished = readState(config.instance).tasks.find((t) => t.id === submitted.taskId); + } + // Cancel must resume the loop, NOT fail the task. + expect(finished?.status).toBe("completed"); + expect(finished?.summary).toBe("Okay, I won't send it. What should I change?"); + + const { listChatBlocks } = await import("../state"); + const blocks = listChatBlocks(config.instance, session.id); + // The model receives an unambiguous boolean from the cancel. + expect(blocks.some((b) => b.kind === "tool_result" && b.preview.includes('"confirmed":false'))).toBe(true); + expect(readState(config.instance).setupRequests.find((s) => s.id === setup!.id)?.status).toBe("cancelled"); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + test("a structured tool result from a prior turn is replayed on the next turn", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + const config = buildConfig(workspaceRoot, "chat-task-transcript-id"); + const provider = normalizeProvider(config.provider); + const fixturePath = join(workspaceRoot, "issue.json"); + // The "create issue" stand-in: a tool the loop dispatches synchronously + // whose result carries an id the agent must remember next turn. + const issueResult = JSON.stringify({ ok: true, issueId: "ISSUE-4242" }); + writeFileSync(fixturePath, issueResult); + + const { createChat, syncChatTaskResult } = await import("./chat"); + const session = await createChat(config, { title: "Issue thread" }); + + // Turn 1: read the file (returns the issue id), then a final answer. + setEchoToolCallingResponse({ + provider, + text: "", + toolCalls: [ + { id: "call_issue", type: "function", function: { name: "file_read", arguments: JSON.stringify({ path: "issue.json" }) } } + ], + finishReason: "tool_calls" + }); + setEchoToolCallingResponse({ + provider, + text: "Created issue ISSUE-4242.", + toolCalls: [], + finishReason: "stop" + }); + + const first = await submitChatMessage(config, session.id, { content: "create an issue" }); + await waitForTerminal(config, first.taskId); + await syncChatTaskResult(config, session.id, first.taskId); + + // Turn 2: the model just answers — we only care about the transcript it + // was handed. + setEchoToolCallingResponse({ + provider, + text: "Editing ISSUE-4242 as requested.", + toolCalls: [], + finishReason: "stop" + }); + const second = await submitChatMessage(config, session.id, { content: "now edit that issue" }); + await waitForTerminal(config, second.taskId); + + // Locate turn 2's provider call: the one whose user message is the + // second prompt. + const calls = getEchoToolCallingCalls(); + const turn2 = calls.find((messages) => + messages.some((m) => m.role === "user" && m.content === "now edit that issue") + ); + expect(turn2).toBeDefined(); + + // The replayed transcript carries the assistant tool_calls message AND + // the paired role:"tool" result with the issue id. + const assistantToolCalls = turn2!.find( + (m) => m.role === "assistant" && Array.isArray(m.tool_calls) && m.tool_calls.length > 0 + ); + expect(assistantToolCalls).toBeDefined(); + expect(assistantToolCalls!.tool_calls![0]!.id).toBe("call_issue"); + + const toolResult = turn2!.find((m) => m.role === "tool" && m.tool_call_id === "call_issue"); + expect(toolResult).toBeDefined(); + expect(String(toolResult!.content)).toContain("ISSUE-4242"); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + test("advertises deferred tools on demand and loads one via load_tools, feeding back a callable confirmation", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + const config = buildConfig(workspaceRoot, "chat-task-deferred-load"); + const provider = normalizeProvider(config.provider); + + // Turn 1: load the browser_snapshot schema via load_tools. (We stop at the + // load round-trip rather than driving a real browser — the load branch is + // what this test pins; the browser dispatch itself is exercised live.) + setEchoToolCallingResponse({ + provider, + text: "", + toolCalls: [ + { id: "call_load", type: "function", function: { name: "load_tools", arguments: JSON.stringify({ names: ["browser_snapshot"] }) } } + ], + finishReason: "tool_calls" + }); + // Turn 2: final answer (no browser call, to keep the test hermetic). + setEchoToolCallingResponse({ + provider, + text: "Browser tools are ready.", + toolCalls: [], + finishReason: "stop" + }); + + const task = await submitTask(config, "get ready to browse", { mode: "chat" }); + const finished = await waitForTerminal(config, task.id, 10000); + expect(finished.status).toBe("completed"); + expect(finished.summary).toBe("Browser tools are ready."); + // loadedTools is cleared on terminal completion. + expect(finished.loadedTools).toBeUndefined(); + + const calls = getEchoToolCallingCalls(); + expect(calls.length).toBe(2); + // First model call: system prompt advertises the on-demand index with + // browser_snapshot by name. + const firstSystem = String(calls[0]!.find((m) => m.role === "system")?.content ?? ""); + expect(firstSystem).toContain("Tools available on demand"); + expect(firstSystem).toContain("browser_snapshot"); + + // After the load, the second model call's message history carries the + // load_tools tool result confirming the tool is now callable. + const secondTurn = calls[1]!; + const loadResult = secondTurn.find( + (m) => m.role === "tool" && typeof m.content === "string" && m.content.includes("callable directly") + ); + expect(loadResult).toBeDefined(); + const envelope = JSON.parse(String((loadResult as { content: string }).content)) as { + ok: boolean; + loaded: string[]; + }; + expect(envelope.ok).toBe(true); + expect(envelope.loaded).toContain("browser_snapshot"); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + test("browser_navigate seeds the deferred browser cluster live on the next turn and persists it on loadedTools", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + // strict so the turn-2 file_write gates, snapshotting loadedTools mid-task. + const config = buildConfig(workspaceRoot, "chat-task-navigate-seeds"); + const provider = normalizeProvider(config.provider); + + // Turn 1: navigate WITHOUT any load_tools call. + setEchoToolCallingResponse({ + provider, + text: "", + toolCalls: [ + { id: "call_nav", type: "function", function: { name: "browser_navigate", arguments: JSON.stringify({ url: "http://127.0.0.1:9/" }) } } + ], + finishReason: "tool_calls" + }); + // Turn 2: file_write (core, strict → pauses) so the paused task row + // exposes the persisted loadedTools. + setEchoToolCallingResponse({ + provider, + text: "", + toolCalls: [ + { id: "call_w", type: "function", function: { name: "file_write", arguments: JSON.stringify({ path: "notes.txt", content: "seeded" }) } } + ], + finishReason: "tool_calls" + }); + // Turn 3 (resumed): final answer. + setEchoToolCallingResponse({ + provider, + text: "Browsing session ready.", + toolCalls: [], + finishReason: "stop" + }); + + const task = await submitTask(config, "open the page", { mode: "chat" }); + const paused = await waitForTerminal(config, task.id, 10000); + expect(paused.status).toBe("waiting_approval"); + // The whole browser cluster persisted onto the task after the navigate. + expect(paused.loadedTools).toContain("browser_snapshot"); + expect(paused.loadedTools).toContain("browser_click"); + expect(paused.loadedTools).toContain("browser_type"); + + await decideApproval(config, paused.approvalIds[0]!, "approve"); + const finished = await waitForFinalTerminal(config, task.id, 10000); + expect(finished.status).toBe("completed"); + expect(finished.summary).toBe("Browsing session ready."); + // Cleared on terminal completion. + expect(finished.loadedTools).toBeUndefined(); + + // Provider tools array per call: deferred browser tools absent on the + // navigate turn, live on the next turn, and still live on the resumed turn + // (re-seeded from task.loadedTools). + const toolNames = getEchoToolCallingToolNames(); + expect(toolNames.length).toBe(3); + expect(toolNames[0]).toContain("browser_navigate"); + expect(toolNames[0]).not.toContain("browser_snapshot"); + expect(toolNames[1]).toContain("browser_snapshot"); + expect(toolNames[1]).toContain("browser_click"); + expect(toolNames[2]).toContain("browser_snapshot"); + + // The seed leaves a trace entry so "why is this tool live?" is answerable. + const { readTrace } = await import("../state"); + const seedTrace = readTrace(config.instance, task.id).find( + (t) => t.message === "Deferred browser tools seeded by browser_navigate" + ); + expect(seedTrace).toBeDefined(); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + // create_agent approve/resume regression: the direct self tool routes + // through the unchanged self.config approval branch. Approving the gate + // must run the create_agent handler (agent row lands) and resume the loop. + test("normal chat turn persists its final answer and the next turn replays it", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + const config = buildConfig(workspaceRoot, "chat-task-normal-answer-history"); + const provider = normalizeProvider(config.provider); + + const sessionId = await mutateState(config.instance, (state) => { + const session = createChatSession(state, "General chat"); + return session.id; + }); + + setEchoToolCallingResponse({ + provider, + text: "Section 413 has better sightlines than Cat2.", + toolCalls: [], + finishReason: "stop" + }); + + const task = await submitTask(config, "is 413 better than Cat2?", { mode: "chat", chatSessionId: sessionId }); + const finished = await waitForTerminal(config, task.id); + expect(finished.status).toBe("completed"); + + // Exactly one durable assistant answer row (not a transcript/approval row). + const answerRows = readState(config.instance).chatMessages.filter( + (m) => m.sessionId === sessionId && m.role === "assistant" && m.kind !== "tool_transcript" && m.kind !== "approval_reason" + ); + expect(answerRows.length).toBe(1); + expect(answerRows[0]!.content).toBe("Section 413 has better sightlines than Cat2."); + expect(answerRows[0]!.taskId).toBe(task.id); + expect(answerRows[0]!.kind).toBeUndefined(); + + clearEchoToolCallingResponses(); + setEchoToolCallingResponse({ + provider, + text: "Noted.", + toolCalls: [], + finishReason: "stop" + }); + + // Turn 2 in the same session: the provider messages must replay the + // prior turn's answer via priorChatMessages. + const followUp = await submitTask(config, "ok thanks", { mode: "chat", chatSessionId: sessionId }); + const finishedFollowUp = await waitForTerminal(config, followUp.id); + expect(finishedFollowUp.status).toBe("completed"); + + const calls = getEchoToolCallingCalls(); + const lastTurn = calls[calls.length - 1]!; + const replayed = lastTurn.map((m) => (typeof m.content === "string" ? m.content : "")).join("\n"); + expect(replayed).toContain("Section 413 has better sightlines than Cat2."); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + // The interrupt-context marker persisted on cancel must reach the model: the + // turn AFTER a cancelled one replays "[Request interrupted by user]" via + // priorChatMessages, so the model knows the prior response was stopped and + // doesn't blindly re-attempt it. Mirrors Claude Code's interrupt injection. + test("parent-delegated subagent with no chat session persists nothing", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + const config = buildConfig(workspaceRoot, "chat-task-delegated-subagent"); + const provider = normalizeProvider(config.provider); + + const subagentId = await mutateState(config.instance, (state) => { + const subagent = createSubagentRecord(state, { + name: "researcher", + prompt: "research subagent", + toolsets: ["file"], + systemPrompt: "You are a research subagent." + }); + return subagent.id; + }); + + setEchoToolCallingResponse({ + provider, + text: "Research complete: the answer is 42.", + toolCalls: [], + finishReason: "stop" + }); + + const task = await submitTask(config, "research the question", { mode: "chat", subagentId }); + const finished = await waitForTerminal(config, task.id); + expect(finished.status).toBe("completed"); + + const messages = readState(config.instance).chatMessages.filter((m) => m.taskId === task.id); + expect(messages.length).toBe(0); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + // [SILENT] from a session-bound subagent persists NO summary chatMessage — + // the suppression that holds for blocks/the legacy layer must also gate the + // finalize persistence so a "nothing to report" watch run leaves no row. + test("persistent context overflow exits gracefully with a partial result", async () => { + const OVERFLOW_MESSAGE = "prompt is too long: 250000 tokens > 200000 maximum"; + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + const config = buildConfig(workspaceRoot, "chat-task-overflow-exhaust"); + const provider = normalizeProvider(config.provider); + + // Two small tool turns first so the partial exit has prior work behind it. + for (let i = 0; i < 2; i++) { + writeFileSync(join(workspaceRoot, `note${i}.md`), `note-${i} content`); + setEchoToolCallingResponse({ + provider, + text: "", + toolCalls: [ + { id: `call_x${i}`, type: "function", function: { name: "file_read", arguments: JSON.stringify({ path: `note${i}.md` }) } } + ], + finishReason: "tool_calls" + }); + } + // Every attempt of iteration 3's model call overflows (3 total attempts). + setEchoToolCallingFailure(OVERFLOW_MESSAGE); + setEchoToolCallingFailure(OVERFLOW_MESSAGE); + setEchoToolCallingFailure(OVERFLOW_MESSAGE); + + const task = await submitTask(config, "read the notes", { mode: "chat" }); + const finished = await waitForTerminal(config, task.id, 10000); + + expect(finished.status).toBe("completed"); + expect(finished.currentStep).toBe("Completed (stopped: context window exhausted)"); + expect(finished.summary).toContain("This is a partial result."); + expect(finished.error).toBeUndefined(); + + // 2 tool turns + 3 failed attempts, and no summary call after exhaustion. + expect(getEchoToolCallingCalls().length).toBe(5); + + const { readTrace } = await import("../state"); + const traces = readTrace(config.instance, task.id); + expect( + traces.some((t) => t.type === "warning" && /overflow persisted after 3 attempts/.test(t.message)) + ).toBe(true); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + // A provider can stream text before EVERY overflow failure. When the + // attempts exhaust, the exit must settle the failed attempt's in-flight + // assistant block (streaming:false) and drain queued flushes so the + // discarded partial text can't land on the completed task. + test("a stream that fails with overflow does not leak its partial text into the retry", async () => { + const OVERFLOW_MESSAGE = "Error code 400: context_length_exceeded"; + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + const config = buildConfig(workspaceRoot, "chat-task-overflow-stream-reset"); + const provider = normalizeProvider(config.provider); + const session = await mutateState(config.instance, (state) => + createChatSession(state, "stream-reset") + ); + + writeFileSync(join(workspaceRoot, "note.md"), "note content"); + // Attempt 1 streams partial text, then fails with overflow. The retry + // narrates cleanly and calls a tool (so its narration settles as a + // block); the final iteration completes the task. + setEchoToolCallingFailure(OVERFLOW_MESSAGE, { streamTextBeforeFailure: "LEAKED-PARTIAL " }); + setEchoToolCallingResponse({ + provider, + text: "Clean narration.", + toolCalls: [ + { id: "call_s1", type: "function", function: { name: "file_read", arguments: JSON.stringify({ path: "note.md" }) } } + ], + finishReason: "tool_calls" + }); + setEchoToolCallingResponse({ provider, text: "Done.", toolCalls: [], finishReason: "stop" }); + + const submitted = await submitChatMessage(config, session.id, { content: "read the note" }); + const finished = await waitForTerminal(config, submitted.taskId, 10000); + + expect(finished.status).toBe("completed"); + expect(finished.summary).toBe("Done."); + + // No settled block carries the failed attempt's partial stream. + const { listChatBlocks } = await import("../state"); + const blocks = listChatBlocks(config.instance, session.id); + const assistantTexts = blocks.filter((b): b is typeof blocks[0] & { kind: "assistant_text" } => + b.kind === "assistant_text" + ); + expect(assistantTexts.length).toBeGreaterThan(0); + for (const block of assistantTexts) { + expect(block.text).not.toContain("LEAKED-PARTIAL"); + } + expect(assistantTexts.some((b) => b.text === "Clean narration.")).toBe(true); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + // In-turn compaction happy path. Token geometry under the echo provider + // (32k window, high-water 27,200): the always-on tool schemas + system + // prompt occupy ~15.4k tokens, so six ~2.2k-token read_skill results cross + // the high-water mark before the 7th call — and pruning can't help (all + // six results sit inside the elision layer's protected-recent window). The + // loop must summarize the middle exchanges via ONE aux call, splice in the + // marked summary message, protect the head and the recent tail, and keep + // going to completion. The schema floor is pinned to FIXED_COMPACTION_CATALOG + // (installed below) so the crossing geometry is decoupled from live + // always-on catalog growth; toolsets are also disabled, and read_skill is in + // the fixed catalog so the calls still dispatch. + test("in-turn compaction respects the per-turn cap and proceeds without a third summary", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + const config = buildConfig(workspaceRoot, "chat-task-compaction-cap"); + const provider = normalizeProvider(config.provider); + await mutateState(config.instance, (state) => { + for (const toolset of state.toolsets) toolset.status = "disabled"; + }); + // Pin the tool-catalog floor so the crossing geometry is decoupled from + // live always-on catalog size (cleared in afterEach). + __setBaseToolCatalogForTests(FIXED_COMPACTION_CATALOG); + + const queueRead = (i: number): void => { + setEchoToolCallingResponse({ + provider, + text: "", + toolCalls: [ + { id: `call_c${i}`, type: "function", function: { name: "read_skill", arguments: JSON.stringify({ name: `bulk-skill-${i}` }) } } + ], + finishReason: "tool_calls" + }); + }; + // Twelve ~2.2k-token reads. The high-water mark trips after every sixth + // accumulated full result, so compactions land at iterations 7 and 10 — + // three iterations apart, wide enough that the refill guard stays quiet + // — and the third trigger at iteration 13 hits the cap. + for (let i = 0; i < 12; i++) { + await seedBulkSkill(config, `bulk-skill-${i}`, `BODY-${i} ${"x".repeat(8_372)}`); + queueRead(i); + } + setEchoAuxTextResponse({ text: "SUMMARY-ONE" }); + setEchoAuxTextResponse({ text: "SUMMARY-TWO" }); + setEchoToolCallingResponse({ + provider, + text: "Finished after two compactions.", + toolCalls: [], + finishReason: "stop" + }); + + const task = await submitTask(config, "review every bulk skill", { mode: "chat" }); + const finished = await waitForTerminal(config, task.id, 10000); + + expect(finished.status).toBe("completed"); + expect(finished.summary).toBe("Finished after two compactions."); + // Exactly two aux summaries — the third trigger hit the cap and the + // loop proceeded to the final call instead of summarizing again. + expect(getEchoAuxTextRequests().length).toBe(2); + expect(getEchoToolCallingCalls().length).toBe(13); + + const { readTrace } = await import("../state"); + const traces = readTrace(config.instance, task.id); + const compactions = traces.filter( + (t) => t.type === "warning" && /In-turn compaction replaced/.test(t.message) + ); + expect(compactions.length).toBe(2); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + // No aux model → compaction is impossible. Cheap pruning already failed + // to bring the projection under the mark, so the loop must exit + // gracefully with a partial result instead of failing the task. + test("a persistent transient fault gives up after the retry cap and fails the task", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + const config = buildConfig(workspaceRoot, "chat-task-transient-cap"); + + // 1 initial attempt + 2 retries = 3 total, all failing transiently. + setEchoToolCallingFailure("connection reset by peer"); + setEchoToolCallingFailure("connection reset by peer"); + setEchoToolCallingFailure("connection reset by peer"); + + const task = await submitTask(config, "say hi", { mode: "chat" }); + const finished = await waitForTerminal(config, task.id, 10000); + + expect(finished.status).toBe("failed"); + expect(finished.error).toContain("connection reset"); + // Exactly three provider calls: initial + 2 retries, then it gives up. + expect(getEchoToolCallingCalls().length).toBe(3); + + const { readTrace } = await import("../state"); + const traces = readTrace(config.instance, task.id); + const retries = traces.filter( + (t) => t.type === "warning" && /Transient model-call fault; retrying/.test(t.message) + ); + // Two retry warnings (one per extra attempt); the third failure exhausts + // the budget and falls through to the hard-throw. Backoff base is 0ms for + // tests, so both retries render "after 0ms" (the production base * 2^n curve + // is a plain constant; only the attempt counter and cap matter here). + expect(retries.length).toBe(2); + expect(retries[1]!.message).toContain("after 0ms"); + expect(retries[1]!.message).toContain("attempt 2 of 2"); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + // A transient failure that streamed partial text before erroring must have + // that partial trimmed from partialSummary before the retry (mirrors the + // overflow path's discard). Exercises the surfacedTextLen > 0 reset branch. + test("without provider usage the trim path stays on the chars/4 estimate and never engages", async () => { + const ELISION_MARKER = + "[Earlier tool result elided to fit the context window. Re-run the tool if you still need its output.]"; + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + const config = buildConfig(workspaceRoot, "chat-task-no-usage-trim"); + const provider = normalizeProvider(config.provider); + await mutateState(config.instance, (state) => { + for (const toolset of state.toolsets) toolset.status = "disabled"; + }); + // Pin the tool-catalog floor so the no-trim geometry is decoupled from + // live always-on catalog size (cleared in afterEach). + __setBaseToolCatalogForTests(FIXED_COMPACTION_CATALOG); + + // Twelve modest reads (~880 tokens each). With echo reporting no usage the + // calibration gap stays 0, so the only trim trigger is the chars/4 live + // budget — and the accumulated transcript stays well under it (the budget + // is 32,000 − 1,600 reserve − ~12,739 floor [12,207 pinned catalog + the + // system-prompt slice] ≈ 17,661 tokens), so no + // elision and no proactive compaction ever engages. + for (let i = 0; i < 12; i++) { + await seedBulkSkill(config, `chunk-skill-${i}`, `chunk-${i} ${"x".repeat(3_396)}`); + setEchoToolCallingResponse({ + provider, + text: "", + toolCalls: [ + { id: `call_n${i}`, type: "function", function: { name: "read_skill", arguments: JSON.stringify({ name: `chunk-skill-${i}` }) } } + ], + finishReason: "tool_calls" + }); + } + setEchoToolCallingResponse({ + provider, + text: "Done reading.", + toolCalls: [], + finishReason: "stop" + }); + + const task = await submitTask(config, "read all the chunks", { mode: "chat" }); + const finished = await waitForTerminal(config, task.id, 10000); + expect(finished.status).toBe("completed"); + + const calls = getEchoToolCallingCalls(); + expect(calls.length).toBe(13); + for (const call of calls) { + expect(call.some((m) => m.content === ELISION_MARKER)).toBe(false); + } + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); +}); + +describe("nextNavStallState", () => { + // Convenience: thread a sequence of single-call iterations through the state + // and return the running count after each one. + function counts(steps: { name: string; url?: string }[]): number[] { + let state = initialNavStallState(); + const out: number[] = []; + for (const s of steps) { + state = nextNavStallState(state, [s]); + out.push(state.count); + } + return out; + } + + test("repeated navigation to the SAME url climbs; page-actions reset", () => { + expect( + counts([ + { name: "browser_navigate", url: "https://a.com" }, // 0: first visit is progress + { name: "browser_navigate", url: "https://a.com" }, // 1: repeat + { name: "browser_navigate", url: "https://a.com" }, // 2: repeat + { name: "browser_click" }, // 0: page-action resets + { name: "browser_navigate", url: "https://a.com" } // 0: still in window but reset wins on the click line; new line is a repeat + ]) + ).toEqual([0, 1, 2, 0, 1]); + }); + + test("navigating to a NEW url is progress and resets the count", () => { + // Reload the same page twice (climb), then move to a fresh URL (reset). + expect( + counts([ + { name: "browser_navigate", url: "https://a.com" }, // 0 + { name: "browser_navigate", url: "https://a.com" }, // 1 + { name: "browser_navigate", url: "https://b.com" }, // 0: new URL + { name: "browser_navigate", url: "https://c.com" } // 0: new URL + ]) + ).toEqual([0, 1, 0, 0]); + }); + + test("a browser_console data extraction resets the count (the research pattern)", () => { + // navigate -> console-extract -> navigate across DISTINCT pages never climbs: + // a fresh URL is already progress, and the console reset reinforces it. + expect( + counts([ + { name: "browser_navigate", url: "https://a.com" }, // 0 + { name: "browser_console" }, // 0: progress + { name: "browser_navigate", url: "https://b.com" }, // 0: new URL + { name: "browser_console" }, // 0 + { name: "browser_navigate", url: "https://c.com" } // 0: new URL + ]) + ).toEqual([0, 0, 0, 0, 0]); + // A console extraction resets even a climbing reload count (the model pulled + // data off the page — genuine progress), though the URL stays in the window. + expect( + counts([ + { name: "browser_navigate", url: "https://a.com" }, // 0 + { name: "browser_navigate", url: "https://a.com" }, // 1: repeat + { name: "browser_navigate", url: "https://a.com" }, // 2: repeat + { name: "browser_console" }, // 0: progress resets + { name: "browser_navigate", url: "https://a.com" } // 1: still in window, repeat again + ]) + ).toEqual([0, 1, 2, 0, 1]); + }); + + test("browser_snapshot is NEUTRAL — re-snapshotting the same page does NOT reset", () => { + // The degenerate overflow incident: navigate then re-snapshot the same URL + // repeatedly. Snapshot must not reset the stall, so the reload count climbs. + expect( + counts([ + { name: "browser_navigate", url: "https://a.com" }, // 0 + { name: "browser_snapshot" }, // 0 (neutral, count unchanged) + { name: "browser_navigate", url: "https://a.com" }, // 1: repeat URL + { name: "browser_snapshot" }, // 1 (neutral) + { name: "browser_navigate", url: "https://a.com" } // 2: repeat URL + ]) + ).toEqual([0, 0, 1, 1, 2]); + }); + + test("multiple tools in one iteration apply in order (last write wins on reset)", () => { + // navigate (repeat) then click in the same turn nets a reset. + let s = nextNavStallState({ count: 4, recentUrls: ["https://a.com"] }, [ + { name: "browser_navigate", url: "https://a.com" }, + { name: "browser_click" } + ]); + expect(s.count).toBe(0); + // click then navigate-to-known ends at 1 (reset, then a repeat). + s = nextNavStallState({ count: 4, recentUrls: ["https://a.com"] }, [ + { name: "browser_click" }, + { name: "browser_navigate", url: "https://a.com" } + ]); + expect(s.count).toBe(1); + }); + + test("oscillation between two URLs climbs to the threshold (the case guard 2 misses)", () => { + // Alternating navigate targets keep the ACTION signature flipping so the + // action-only guard resets every turn — but both URLs stay in the recent + // window, so each navigation is a repeat and the stall climbs monotonically. + let state = initialNavStallState(); + // Seed both URLs into the window first (two distinct first-visits). + state = nextNavStallState(state, [{ name: "browser_navigate", url: "https://a.com" }]); + state = nextNavStallState(state, [{ name: "browser_navigate", url: "https://b.com" }]); + for (let i = 0; i < 8; i++) { + const url = i % 2 === 0 ? "https://a.com" : "https://b.com"; + state = nextNavStallState(state, [{ name: "browser_navigate", url }]); + } + expect(state.count).toBeGreaterThanOrEqual(8); + }); + + test("a long run of DISTINCT urls never trips (the legitimate-research case)", () => { + let state = initialNavStallState(); + for (let i = 0; i < 12; i++) { + state = nextNavStallState(state, [{ name: "browser_navigate", url: `https://site.com/page-${i}` }]); + } + expect(state.count).toBe(0); + }); + + test("repeated browser_back oscillation climbs (back has no url of its own)", () => { + let state = initialNavStallState(); + const out: number[] = []; + for (let i = 0; i < 10; i++) { + state = nextNavStallState(state, [{ name: "browser_back" }]); + out.push(state.count); + } + // First back is the first visit of the back-sentinel (0), then it repeats. + expect(out[0]).toBe(0); + expect(out[8]).toBeGreaterThanOrEqual(8); + }); +}); + +// In-loop tool-result elision. A pure function over a messages array + budget, +// so a direct unit test is deterministic — no need to force a real provider +// overflow through the loop. +describe("elideOldToolResultsToBudget", () => { + const ELISION_MARKER = + "[Earlier tool result elided to fit the context window. Re-run the tool if you still need its output.]"; + + function bigToolResult(id: string, fill: string): ToolCallingMessage { + return { role: "tool", tool_call_id: id, content: fill.repeat(400) }; + } + + test("no-op when already within budget", () => { + const messages: ToolCallingMessage[] = [ + { role: "user", content: "hi" }, + { role: "tool", tool_call_id: "call_0", content: "small result" } + ]; + const before = JSON.stringify(messages); + expect(elideOldToolResultsToBudget(messages, 1_000_000)).toBe(0); + expect(JSON.stringify(messages)).toBe(before); + }); + + test("shrinks oldest tool results first while protecting the most-recent six", () => { + // Ten oversized tool results plus interleaving assistant rows. With a tiny + // budget, the elidable set is everything but the most-recent six; the + // helper walks oldest→newest until it fits. + const messages: ToolCallingMessage[] = [{ role: "user", content: "go" }]; + for (let i = 0; i < 10; i++) { + messages.push({ role: "assistant", content: null, tool_calls: [] }); + messages.push(bigToolResult(`call_${i}`, `X${i}-`)); + } + + const elided = elideOldToolResultsToBudget(messages, 100); + expect(elided).toBeGreaterThan(0); + + const toolMessages = messages.filter((m) => m.role === "tool"); + // The four oldest tool results (10 total − 6 protected) are elided… + for (let i = 0; i < 4; i++) { + expect(toolMessages[i]!.content).toBe(ELISION_MARKER); + // role + tool_call_id stay intact so codex call/output pairing survives. + expect(toolMessages[i]!.tool_call_id).toBe(`call_${i}`); + } + // …and the most-recent six are never touched. + for (let i = 4; i < 10; i++) { + expect(toolMessages[i]!.content).not.toBe(ELISION_MARKER); + } + }); + + test("never drops a message — only shrinks content", () => { + const messages: ToolCallingMessage[] = [{ role: "user", content: "go" }]; + for (let i = 0; i < 10; i++) { + messages.push(bigToolResult(`call_${i}`, `Y${i}-`)); + } + const lengthBefore = messages.length; + elideOldToolResultsToBudget(messages, 50); + expect(messages.length).toBe(lengthBefore); + }); + + test("leaves small tool results and non-tool messages alone", () => { + // A short tool result (≤ 200 chars) isn't worth shrinking; assistant/user + // rows are never elidable regardless of size. + const messages: ToolCallingMessage[] = [ + { role: "assistant", content: "Z".repeat(5000), tool_calls: [] }, + { role: "tool", tool_call_id: "call_small", content: "tiny" }, + bigToolResult("call_big", "W-") + ]; + elideOldToolResultsToBudget(messages, 10); + expect(messages[0]!.content).not.toBe(ELISION_MARKER); + expect(messages[1]!.content).toBe("tiny"); + }); +}); + +// Group-aligned middle-span selection for in-turn compaction. Pure over a +// messages array, so the pairing/protection rules are pinned directly. +describe("compactionMiddleSpan", () => { + const asst = (id: string, calls = 1): ToolCallingMessage => ({ + role: "assistant", + content: null, + tool_calls: Array.from({ length: calls }, (_, i) => ({ + id: `${id}_${i}`, + type: "function" as const, + function: { name: "t", arguments: "{}" } + })) + }); + const tool = (id: string): ToolCallingMessage => ({ role: "tool", tool_call_id: id, content: "r" }); + + test("protects the head (initial messages + first exchange) and the recent-tail exchanges", () => { + const messages: ToolCallingMessage[] = [ + { role: "system", content: "s" }, + { role: "user", content: "u" }, + asst("a"), tool("a_0"), + asst("b"), tool("b_0"), + asst("c"), tool("c_0"), + asst("d"), tool("d_0"), + asst("e"), tool("e_0") + ]; + // initialCount=2 (system+user). Head also covers exchange a; tail keeps + // exchanges d and e; middle = exchanges b and c (indices 4..8). + expect(compactionMiddleSpan(messages, 2, 2)).toEqual({ start: 4, end: 8 }); + }); + + test("keeps multi-result exchanges whole (group-aligned boundaries)", () => { + const messages: ToolCallingMessage[] = [ + { role: "user", content: "u" }, + asst("a", 2), tool("a_0"), tool("a_1"), + asst("b", 2), tool("b_0"), tool("b_1"), + asst("c"), tool("c_0"), + asst("d"), tool("d_0") + ]; + // Middle = exchange b only (indices 4..7) — both of its tool results + // travel with their assistant row. + expect(compactionMiddleSpan(messages, 1, 2)).toEqual({ start: 4, end: 7 }); + }); + + test("returns undefined when everything is protected", () => { + const messages: ToolCallingMessage[] = [ + { role: "user", content: "u" }, + asst("a"), tool("a_0"), + asst("b"), tool("b_0"), + asst("c"), tool("c_0") + ]; + expect(compactionMiddleSpan(messages, 1, 2)).toBeUndefined(); + expect(compactionMiddleSpan([], 0, 2)).toBeUndefined(); + }); +}); + +describe("renderMessagesForCompaction", () => { + test("renders roles, tool-call signatures, and content", () => { + const rendered = renderMessagesForCompaction([ + { + role: "assistant", + content: "checking", + tool_calls: [{ id: "x", type: "function", function: { name: "file_read", arguments: '{"path":"a.md"}' } }] + }, + { role: "tool", tool_call_id: "x", content: "file body" } + ]); + expect(rendered).toContain('assistant -> file_read({"path":"a.md"}): checking'); + expect(rendered).toContain("tool: file body"); + }); + + test("caps oversized messages and the total input", () => { + const big = "z".repeat(10_000); + const rendered = renderMessagesForCompaction([{ role: "tool", tool_call_id: "x", content: big }]); + expect(rendered.length).toBeLessThan(5_000); + expect(rendered).toContain("[truncated]"); + + const many = Array.from({ length: 30 }, (_, i): ToolCallingMessage => ({ + role: "tool", + tool_call_id: `t${i}`, + content: "y".repeat(9_000) + })); + const total = renderMessagesForCompaction(many); + expect(total.length).toBeLessThan(70_000); + expect(total).toContain("[remaining messages omitted from summary input]"); + }); +}); + +// Provider usage records carry the real prompt size under different keys per +// provider family; the extractor must accept both and reject junk. +describe("promptTokensFromUsage", () => { + test("reads input_tokens (anthropic/bedrock) and prompt_tokens (openai-compatible)", () => { + expect(promptTokensFromUsage({ input_tokens: 1234 })).toBe(1234); + expect(promptTokensFromUsage({ prompt_tokens: 567 })).toBe(567); + // input_tokens wins when both are present (normalized Converse usage). + expect(promptTokensFromUsage({ input_tokens: 10, prompt_tokens: 20 })).toBe(10); + }); + + test("rejects missing, non-numeric, and negative counts", () => { + expect(promptTokensFromUsage(undefined)).toBeUndefined(); + expect(promptTokensFromUsage({})).toBeUndefined(); + expect(promptTokensFromUsage({ prompt_tokens: "9" })).toBeUndefined(); + expect(promptTokensFromUsage({ input_tokens: Number.NaN })).toBeUndefined(); + expect(promptTokensFromUsage({ input_tokens: -5 })).toBeUndefined(); + }); +}); + +describe("buildAgentIdentity", () => { + function makeToolset(name: string, status: ToolsetRecord["status"] = "enabled"): ToolsetRecord { + const at = "2026-05-19T00:00:00.000Z"; + return { + id: `toolset_${name}`, + instance: "test-instance", + name, + description: "", + status, + toolNames: [], + scopes: ["task"], + createdAt: at, + updatedAt: at + }; + } + + function makeState(toolsets: ToolsetRecord[]): RuntimeState { + // Build on top of the canonical empty-state seed so this fixture + // automatically inherits any new top-level RuntimeState field + // without per-test churn. Override only the slices the + // buildAgentIdentity tests actually exercise. + const state = createEmptyState("test-instance"); + state.toolsets = toolsets; + state.agents = [{ + id: "agent_x", + instance: "test-instance", + name: "alpha", + status: "active", + toolsets: [], + messagingTargets: [], + createdAt: "2026-05-19T00:00:00.000Z", + updatedAt: "2026-05-19T00:00:00.000Z" + }]; + state.activeAgentId = "agent_x"; + return state; + } + + const baseConfig: RuntimeConfig = { + instance: "test-instance", + port: 9999, + token: "test", + provider: { name: "echo", model: "test-model" }, + workspaceRoot: "/tmp/ws", + stateRoot: "/tmp/state", + logRoot: "/tmp/logs" + }; + + test("renders the actual enabled toolset names when the agent imposes no filter", () => { + const state = makeState([ + makeToolset("file"), + makeToolset("terminal"), + makeToolset("memory"), + makeToolset("disabled-thing", "disabled") + ]); + const effective: EffectiveContext = { + agentId: "agent_x", + memoryNamespace: "agent_x", + provider: { name: "echo", model: "test-model" }, + providerSource: "agent", + warnings: [] + // no toolsetFilter — unrestricted + }; + const identity: AgentIdentity = buildAgentIdentity(baseConfig, state, effective); + // Disabled toolsets must NOT appear; enabled toolsets must be sorted + // so the rendered identity is stable across runs. + expect(identity.toolsets).toEqual(["file", "memory", "terminal"]); + }); + + test("renders the filter set when the agent declares a whitelist", () => { + const state = makeState([makeToolset("file"), makeToolset("terminal"), makeToolset("memory")]); + const effective: EffectiveContext = { + agentId: "agent_x", + memoryNamespace: "agent_x", + provider: { name: "echo", model: "test-model" }, + providerSource: "agent", + toolsetFilter: new Set(["terminal", "file"]), + warnings: [] + }; + const identity = buildAgentIdentity(baseConfig, state, effective); + expect(identity.toolsets).toEqual(["file", "terminal"]); + }); + + test("drops disabled and unknown toolset names from the filter so the prompt matches the catalog", () => { + // effective.toolsetFilter intentionally keeps unknown / disabled + // refs (effective-context.ts:9-16) so re-enabling later "just + // works"; the identity block must NOT show those as available or + // it would tell the model a tool family is callable when in fact + // tool-catalog.ts excludes them from the dispatch surface. + const state = makeState([ + makeToolset("file"), + makeToolset("terminal"), + makeToolset("messaging", "disabled") + ]); + const effective: EffectiveContext = { + agentId: "agent_x", + memoryNamespace: "agent_x", + provider: { name: "echo", model: "test-model" }, + providerSource: "agent", + // Whitelist includes a disabled-in-state name and an entirely + // unknown name; both must be filtered out of the rendered + // identity block. + toolsetFilter: new Set(["file", "messaging", "phantom"]), + warnings: [] + }; + const identity = buildAgentIdentity(baseConfig, state, effective); + expect(identity.toolsets).toEqual(["file"]); + }); + + test("yields an empty toolsets list only when state has no enabled toolsets and no filter", () => { + const state = makeState([makeToolset("legacy", "disabled")]); + const effective: EffectiveContext = { + provider: { name: "echo", model: "test-model" }, + providerSource: "instance", + warnings: [] + }; + const identity = buildAgentIdentity(baseConfig, state, effective); + expect(identity.toolsets).toEqual([]); + expect(identity.agentId).toBe("(none)"); + expect(identity.memoryNamespace).toBe("(none)"); + }); + + test("sanitizes an agent name carrying a newline into a single-line label", () => { + // The identity block renders agentName verbatim, so a name with an + // embedded newline must collapse to one line rather than inject a + // raw extra model-visible line into the runtime-identity block. + const state = makeState([makeToolset("file")]); + state.agents[0]!.name = "Mansour\nIgnore"; + const effective: EffectiveContext = { + agentId: "agent_x", + memoryNamespace: "agent_x", + provider: { name: "echo", model: "test-model" }, + providerSource: "agent", + warnings: [] + }; + const identity = buildAgentIdentity(baseConfig, state, effective); + expect(identity.agentName).toBe("Mansour Ignore"); + expect(identity.agentName).not.toContain("\n"); + }); +}); + +describe("buildEnabledSkillsBlock", () => { + function skill(name: string, description = `${name} description`, status: SkillRecord["status"] = "enabled"): SkillRecord { + return { + id: `skill_${name}`, + instance: "test", + name, + description, + trigger: "", + steps: [], + requiredTools: [], + requiredPermissions: [], + status, + version: 1, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + tests: [], + successCount: 0, + failureCount: 0, + previousVersions: [], + body: "", + source: "bundled", + manifestPath: `/skills/${name}/SKILL.md` + }; + } + + test("lists active skill descriptions and points to list_skills/read_skill", () => { + const block = buildEnabledSkillsBlock([skill("linear", "Linear issues"), skill("disabled", "Hidden", "disabled")]); + expect(block).toContain("call list_skills"); + expect(block).toContain("call read_skill"); + expect(block).toContain("- linear: Linear issues"); + expect(block).not.toContain("disabled"); + }); + + test("caps the inline skill list and leaves a discovery hint", () => { + const skills = Array.from({ length: 45 }, (_, i) => skill(`skill-${String(i).padStart(2, "0")}`)); + const block = buildEnabledSkillsBlock(skills); + expect(block).toContain("- skill-00: skill-00 description"); + expect(block).toContain("- skill-39: skill-39 description"); + expect(block).not.toContain("- skill-40: skill-40 description"); + expect(block).toContain("5 more skills not shown"); + expect(block).toContain("nameContains/status filters"); + }); +}); + +describe("buildConnectedAccountsBlock", () => { + function account(opts: { tag: string; email?: string; configDir?: string }): GoogleAccount { + return { + id: `gacct_${opts.tag}`, + tag: opts.tag, + email: opts.email ?? `${opts.tag}@example.com`, + configDir: opts.configDir ?? `/home/u/.gini/google-accounts/gacct_${opts.tag}`, + addedAt: "2026-01-01T00:00:00.000Z" + }; + } + + test("returns empty string when no accounts are connected", () => { + expect(buildConnectedAccountsBlock([])).toBe(""); + }); + + test("renders a single account's tag, email, and config dir plus the prefix guidance", () => { + const block = buildConnectedAccountsBlock([ + account({ tag: "personal", email: "me@gmail.com", configDir: "/home/u/.config/gws" }) + ]); + expect(block).toContain("Connected Google accounts"); + expect(block).toContain("personal"); + expect(block).toContain("me@gmail.com"); + expect(block).toContain("/home/u/.config/gws"); + expect(block).toContain("GOOGLE_WORKSPACE_CLI_CONFIG_DIR"); + expect(block).toContain("use it"); + }); + + test("surfaces both accounts, aggregate-on-read, and ask-on-write guidance when 2+ are connected", () => { + const block = buildConnectedAccountsBlock([ + account({ tag: "personal" }), + account({ tag: "work" }) + ]); + expect(block).toContain("personal"); + expect(block).toContain("work"); + // Unscoped reads fan out across every account instead of picking one. + expect(block).toContain("EVERY connected account"); + // Writes still ask when no account is named. + expect(block).toContain("ASK which account first"); + }); + + test("shows the sign-in-pending placeholder for an account with no email yet", () => { + const block = buildConnectedAccountsBlock([account({ tag: "school", email: "" })]); + expect(block).toContain("school"); + expect(block).toContain("(sign-in pending)"); + }); +}); + +describe("buildInactiveSkillsBlock", () => { + // Minimal SkillRecord factory. Only the fields the block builder + // reads (name, description, status, requiredCredentials, source) carry + // meaningful values; the rest are stubbed so the type checks. Skills now + // declare credentials BY NAME; the block maps each name to its provider + // (LINEAR_API_KEY → linear, google-workspace-oauth → google-oauth-desktop). + function makeSkill(opts: { + name: string; + description?: string; + requiredCredentials?: string[]; + status?: SkillRecord["status"]; + source?: SkillRecord["source"]; + }): SkillRecord { + const at = "2026-05-19T00:00:00.000Z"; + return { + id: `skill_${opts.name}`, + instance: "test-instance", + name: opts.name, + description: opts.description ?? "(no description)", + trigger: "", + steps: [], + requiredTools: [], + requiredPermissions: [], + status: opts.status ?? "enabled", + version: 1, + createdAt: at, + updatedAt: at, + tests: [], + successCount: 0, + failureCount: 0, + previousVersions: [], + body: "", + requiredCredentials: opts.requiredCredentials, + source: opts.source + }; + } + + test("routes the google-workspace-oauth credential to request_connector with the provider id", () => { + // google-workspace-oauth maps to google-oauth-desktop. In hosted this + // provider declares no setup skill (the credential is baked into the guest + // at provisioning), so the block emits the bare request_connector shortcut + // for the provider id — no read_skill / setup-skill detour. + const skill = makeSkill({ + name: "google-calendar", + description: "Google Calendar", + requiredCredentials: ["google-workspace-oauth"] + }); + const block = buildInactiveSkillsBlock([skill]); + expect(block).toContain("google-oauth-desktop"); + expect(block).toContain("call `request_connector` with provider id `google-oauth-desktop`"); + // No setup skill is declared, so the block must not tell the model to + // read_skill first. + expect(block).not.toMatch(/read_skill/); + }); + + test("collapses multiple skills sharing one credential into a single line", () => { + // All Google Workspace product skills share one credential — the block + // should emit ONE provider line, not one per skill. + const skills = [ + makeSkill({ name: "google-calendar", requiredCredentials: ["google-workspace-oauth"] }), + makeSkill({ name: "google-gmail", requiredCredentials: ["google-workspace-oauth"] }), + makeSkill({ name: "google-drive", requiredCredentials: ["google-workspace-oauth"] }) + ]; + const block = buildInactiveSkillsBlock(skills); + const providerLines = block.split("\n").filter((line) => line.includes("google-oauth-desktop")); + expect(providerLines).toHaveLength(1); + expect(providerLines[0]).toContain("google-calendar"); + expect(providerLines[0]).toContain("google-gmail"); + expect(providerLines[0]).toContain("google-drive"); + expect(providerLines[0]).toContain("call `request_connector` with provider id `google-oauth-desktop`"); + }); + + test("falls back to request_connector guidance for providers without a setup skill", () => { + // LINEAR_API_KEY → linear, which does not declare setupSkill, so the + // block must emit the default request_connector instruction. + const skill = makeSkill({ + name: "needs-linear", + description: "Test skill that needs Linear.", + requiredCredentials: ["LINEAR_API_KEY"] + }); + const block = buildInactiveSkillsBlock([skill]); + expect(block).toContain("linear"); + expect(block).toContain("call `request_connector` with provider id `linear`"); + // Must NOT mention read_skill — no setup skill is declared. + expect(block).not.toMatch(/read_skill/); + }); + + test("instructs a templateless request_connector for a credential with no registered provider", () => { + // SOME_SERVICE_API_KEY maps to no provider module, so providerForCredential + // falls back to the name itself. The block must NOT emit the bare + // provider-id shortcut (there is no provider to connect) — it must tell the + // model to call request_connector with the {name, type, skillId} shape so + // the user can enter the secret in chat. The name is UPPER_SNAKE so the + // inferred type is api-key. + const skill = makeSkill({ + name: "needs-some-service", + description: "Test skill that needs an unmapped credential.", + requiredCredentials: ["SOME_SERVICE_API_KEY"] + }); + const block = buildInactiveSkillsBlock([skill]); + expect(block).toContain("SOME_SERVICE_API_KEY"); + expect(block).toContain('name: "SOME_SERVICE_API_KEY"'); + expect(block).toContain('type: "api-key"'); + expect(block).toContain(`skillId: "${skill.id}"`); + // No provider-id shortcut and no read_skill dead-end for a name with no + // registered provider. + expect(block).not.toContain("call `request_connector` with provider id `SOME_SERVICE_API_KEY`"); + expect(block).not.toMatch(/read_skill/); + expect(block).not.toContain("will be rejected"); + }); + + // Minimal RuntimeState carrying only the connectors the block reads. + function stateWithConnectors(connectors: RuntimeState["connectors"]): RuntimeState { + return { + version: 1, + instance: "test", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + tasks: [], authorizations: [], setupRequests: [], audit: [], skills: [], jobs: [], + connectors, improvements: [], skillOutcomes: [], learningFindings: [], + promotions: [], snapshots: [], tools: [], toolsets: [], subagents: [], + mcpServers: [], messagingBridges: [], importReports: [], agents: [], + activeAgentId: undefined, relays: [], notifications: [], emailWatchers: [], events: [], + jobRuns: [], chatSessions: [], chatMessages: [], messagingMessages: [], + runs: [], planSteps: [], usageLedger: [] + }; + } + + test("a disabled generic connector sharing the credential name still yields the api-key templateless line by NAME", () => { + // Regression: a disabled/unhealthy "generic" connector row sharing the + // credential name must NOT masquerade as the owning provider. The earlier + // code returned the row's provider ("generic"), grouped under that key, and + // emitted a bogus `{name:"generic", type:"oauth2"}` line. The line must name + // the actual credential and be api-key (templateless is api-key only). + const skill = makeSkill({ + name: "needs-some-service", + requiredCredentials: ["SOME_SERVICE_API_KEY"] + }); + const state = stateWithConnectors([ + { + id: "id_generic_row", + instance: "test", + name: "SOME_SERVICE_API_KEY", + provider: "generic", + status: "disabled", + scopes: [], + secretRefs: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + health: "unknown", + source: "user" + } + ]); + const block = buildInactiveSkillsBlock([skill], state); + expect(block).toContain('name: "SOME_SERVICE_API_KEY"'); + expect(block).toContain('type: "api-key"'); + // Never the bogus generic/oauth2 line. + expect(block).not.toContain('name: "generic"'); + expect(block).not.toContain('type: "oauth2"'); + }); + + test("returns an empty string when no inactive-with-credential skills are present", () => { + expect(buildInactiveSkillsBlock([])).toBe(""); + // Skills with no requiredCredentials are filtered out before the + // grouping step. + const skill = makeSkill({ name: "no-cred", requiredCredentials: [] }); + expect(buildInactiveSkillsBlock([skill])).toBe(""); + }); + + test("opens with the dual-path intro so the model knows both routing options", () => { + const skill = makeSkill({ + name: "needs-linear", + requiredCredentials: ["LINEAR_API_KEY"] + }); + const block = buildInactiveSkillsBlock([skill]); + expect(block).toMatch(/^Skills below need an external connector\./); + // Both request_connector routing options are advertised: a registered + // provider id, and the templateless api-key {name, type:"api-key", skillId} + // shape for a credential with no registered provider. + expect(block).toContain("request_connector"); + expect(block).toContain("provider id"); + expect(block).toContain('{name, type:"api-key", skillId}'); + }); + + test("emits no browser-shortcut directive for the Google Workspace credential", () => { + // On hosted, the Google account is connected at sign-in through the host + // and google-oauth-desktop declares no setup skill, so the Google credential + // routes through the bare request_connector line — with no read_skill detour + // and no "ONLY correct path" browser-shortcut directive. + const skill = makeSkill({ + name: "google-calendar", + requiredCredentials: ["google-workspace-oauth"] + }); + const block = buildInactiveSkillsBlock([skill]); + expect(block).toContain("google-oauth-desktop"); + expect(block).not.toContain("ONLY correct path"); + expect(block).not.toContain("browser_navigate"); + expect(block).not.toMatch(/read_skill/); + }); + + test("skips the no-browser-shortcut directive when no provider declares a setup skill", () => { + // request_connector is the only path advertised when no setup skill is + // declared, so the browser-shortcut directive is unnecessary noise. + const skill = makeSkill({ + name: "needs-linear", + requiredCredentials: ["LINEAR_API_KEY"] + }); + const block = buildInactiveSkillsBlock([skill]); + expect(block).not.toContain("ONLY correct path"); + expect(block).not.toContain("browser_navigate"); + }); +}); + +describe("buildMcpServersBlock", () => { + function stateWith(servers: RuntimeState["mcpServers"]): RuntimeState { + return { + version: 1, + instance: "test", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + tasks: [], authorizations: [], setupRequests: [], audit: [], skills: [], jobs: [], + connectors: [], improvements: [], skillOutcomes: [], learningFindings: [], + promotions: [], snapshots: [], tools: [], toolsets: [], subagents: [], + mcpServers: servers, messagingBridges: [], importReports: [], agents: [], + activeAgentId: undefined, relays: [], notifications: [], emailWatchers: [], events: [], + jobRuns: [], chatSessions: [], chatMessages: [], messagingMessages: [], + runs: [], planSteps: [], usageLedger: [] + }; + } + + function server(name: string, tools: Array<{ name: string }>): RuntimeState["mcpServers"][number] { + return { + id: `mcp_${name}`, + instance: "test", + name, + command: "", + args: [], + envKeys: [], + status: "configured", + exposedTools: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + transport: "http", + url: "https://example.test/mcp", + tools + }; + } + + test("returns empty string when no servers are configured", () => { + expect(buildMcpServersBlock(stateWith([]))).toBe(""); + }); + + test("lists tool names per server so the model has the full inventory", () => { + // The inventory line is what lets the model reach for a tool the skill + // never documented. Skills should not have to be re-edited every time + // an MCP server adds a tool. + const state = stateWith([ + server("linear", [ + { name: "list_issues" }, + { name: "save_issue" }, + { name: "list_initiatives" }, + { name: "extract_images" } + ]) + ]); + const block = buildMcpServersBlock(state); + expect(block).toContain("- linear (4 tools)"); + expect(block).toContain("tools: extract_images, list_initiatives, list_issues, save_issue"); + }); + + test("includes the default-yes posture instruction", () => { + // Without this, the model treats the skill's documented tools as + // exhaustive and refuses tasks for tools that actually exist on the + // server's inventory list. + const state = stateWith([server("linear", [{ name: "list_issues" }])]); + const block = buildMcpServersBlock(state); + expect(block).toContain("Do not refuse"); + expect(block).toContain("validation error on bad args"); + }); + + test("omits the per-server inventory line when a server has no cached tools yet", () => { + // Health probe hasn't populated tools yet — show the server but skip + // the inventory line so we don't lie about emptiness. (The default-yes + // posture sentence below still mentions the word `tools:`, so we + // assert on the indented inventory line specifically.) + const state = stateWith([server("linear", [])]); + const block = buildMcpServersBlock(state); + expect(block).toContain("- linear"); + expect(block).not.toMatch(/^ {2}tools:/m); + }); + + test("alphabetizes both servers and their tool name lists for determinism", () => { + // Toolset hashes and prompt-cache stability depend on stable ordering + // across boots even when the order tools were registered varies. + const state = stateWith([ + server("zenith", [{ name: "z_one" }, { name: "a_two" }]), + server("acme", [{ name: "c_one" }, { name: "a_two" }]) + ]); + const block = buildMcpServersBlock(state); + const acmeIdx = block.indexOf("- acme"); + const zenithIdx = block.indexOf("- zenith"); + expect(acmeIdx).toBeGreaterThanOrEqual(0); + expect(zenithIdx).toBeGreaterThan(acmeIdx); + expect(block).toContain("tools: a_two, c_one"); + expect(block).toContain("tools: a_two, z_one"); + }); +}); + +describe("buildSkillScriptsBlock", () => { + // listEnabledSkillScripts statSyncs the real scripts/ dir under each + // skill's manifestPath, so the seeded skills need real files on disk. + let dir: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "gini-skill-scripts-block-")); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + function seedSkill( + state: RuntimeState, + name: string, + scripts: string[], + opts: { status?: SkillRecord["status"] } = {} + ): void { + const skillDir = join(dir, name); + const scriptsDir = join(skillDir, "scripts"); + mkdirSync(scriptsDir, { recursive: true }); + for (const script of scripts) { + writeFileSync(join(scriptsDir, script), "console.log('{}')"); + } + state.skills.push({ + id: `skill_${name}`, + instance: state.instance, + name, + description: "", + trigger: "", + steps: [], + requiredTools: [], + requiredPermissions: [], + status: opts.status ?? "enabled", + version: 1, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + tests: [], + successCount: 0, + failureCount: 0, + previousVersions: [], + body: "", + source: "bundled", + manifestPath: join(skillDir, "SKILL.md") + }); + } + + test("returns empty string when no visible skill ships scripts", () => { + const state = createEmptyState("test"); + seedSkill(state, "no-scripts", []); + expect(buildSkillScriptsBlock(state, new Set(["no-scripts"]))).toBe(""); + }); + + test("lists each visible skill's scripts, alphabetized by skill and script", () => { + const state = createEmptyState("test"); + seedSkill(state, "bbb", ["alpha.sh"]); + seedSkill(state, "aaa", ["two.ts", "one.ts"]); + const block = buildSkillScriptsBlock(state, new Set(["aaa", "bbb"])); + expect(block).toBe( + [ + "Skill scripts (invoke with skill_run, never re-implement in terminal_exec; call list_skills/read_skill for omitted skills):", + "- aaa: one, two", + "- bbb: alpha" + ].join("\n") + ); + }); + + test("caps the inline skill script list and leaves a discovery hint", () => { + const state = createEmptyState("test"); + const names = new Set(); + for (let i = 0; i < 45; i++) { + const name = `skill-${String(i).padStart(2, "0")}`; + names.add(name); + seedSkill(state, name, ["run.ts"]); + } + const block = buildSkillScriptsBlock(state, names); + expect(block).toContain("- skill-00: run"); + expect(block).toContain("- skill-39: run"); + expect(block).not.toContain("- skill-40: run"); + expect(block).toContain("5 more skill script entries not shown"); + expect(block).toContain("call list_skills"); + }); + + test("omits skills that are enabled but not visible (inactive connector)", () => { + const state = createEmptyState("test"); + seedSkill(state, "visible", ["go.ts"]); + seedSkill(state, "hidden", ["go.ts"]); + const block = buildSkillScriptsBlock(state, new Set(["visible"])); + expect(block).toContain("- visible: go"); + expect(block).not.toContain("hidden"); + }); +}); diff --git a/packages/runtime/src/execution/chat-task.part2.test.ts b/packages/runtime/src/execution/chat-task.part2.test.ts new file mode 100644 index 00000000..a5e68c18 --- /dev/null +++ b/packages/runtime/src/execution/chat-task.part2.test.ts @@ -0,0 +1,1328 @@ +// End-to-end tests for the chat-task agent loop. +// +// We use the echo provider with stubbed tool-calling responses so the loop +// is fully deterministic. The test covers: +// - one tool call → result fed back → final answer +// - approval-gated tool call → task pauses with toolCallState +// - resume after approval → task completes +// +// HOME is pointed at a unique mkdtemp dir per test (same pattern as +// src/state/google-accounts.test.ts): the loop reads the machine-global +// Google account registry (buildConnectedAccountsBlock(readGoogleAccounts()) +// in the system prompt; isSkillActive via credentialExternallySatisfied), so +// a developer machine with registered accounts would otherwise shift the +// system-prompt size and skill activity these tests depend on. + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, join } from "node:path"; +import { + clearEchoAuxTextResponses, + clearEchoToolCallingResponses, + getEchoAuxTextRequests, + getEchoToolCallingCalls, + getEchoToolCallingToolNames, + setEchoAuxTextFailure, + setEchoAuxTextResponse, + setEchoToolCallingFailure, + setEchoToolCallingResponse, + normalizeProvider, + type MessageContentPart, + type ToolCallingMessage +} from "../provider"; +import { submitTask, decideApproval, resolveSetupRequest } from "../agent"; +import { + bankIdForAgent, + createChatMessage, + createChatSession, + createEmptyState, + createRun, + createSubagentRecord, + deleteChatSession, + ensureAgentBank, + ensureDefaultBank, + insertMemoryUnit, + listChatBlocks, + mutateState, + now, + readState, + recordProviderAuthFailure +} from "../state"; +import { echoEmbed } from "../embeddings"; +import { storeUpload } from "../state/uploads"; +import { resolveDefaultPriorContextTokenBudget } from "../provider-capabilities"; +import type { AgentIdentity, GoogleAccount, JobRecord, RuntimeConfig, RuntimeState, SkillRecord, Task, ToolsetRecord } from "../types"; +import { createSkillFromInput, setSkillStatus } from "../capabilities/skills"; +import { + __setBaseToolCatalogForTests, + buildAgentIdentity, + buildConnectedAccountsBlock, + buildEnabledSkillsBlock, + buildInactiveSkillsBlock, + buildMcpServersBlock, + buildSkillScriptsBlock, + compactionMiddleSpan, + elideOldToolResultsToBudget, + IN_TURN_COMPACTION_NOTE_PREFIX, + initialNavStallState, + nextNavStallState, + promptTokensFromUsage, + renderMessagesForCompaction +} from "./chat-task"; +import type { ToolCatalogTool } from "./tool-catalog"; +import type { EffectiveContext } from "./effective-context"; +import { __resetDefaultGiniInstructionsCacheForTest } from "../system-prompt"; + +// Compaction geometry in this file is calibrated against a FIXED instructions +// size (alongside the pinned tool catalog). The real default INSTRUCTIONS.md +// changes over time, which would otherwise shift the system-prompt slice and +// move the compaction crossing point opaquely. Pin a frozen fixture (byte-for- +// byte the default as of this calibration) so edits to the shipped default +// never perturb these tests. +const COMPACTION_INSTRUCTIONS_FIXTURE = join( + import.meta.dir, + "..", + "runtime", + "__fixtures__", + "instructions-compaction-geometry.md" +); + +// These tests submit on idle sessions, which always run immediately. They also +// don't seed a "chat-route" stub, so the router coerces to a chat-direct +// decision and the turn runs in the submitted session. Narrow the submit union +// to the chat-direct run-now branch so the existing `.taskId` reads stay typed +// (a queued or topic-dispatched result here is a test-setup bug). See ADR +// chat-message-queue.md. +async function submitChatMessage( + config: RuntimeConfig, + sessionId: string, + input: Record +): Promise<{ sessionId: string; runId?: string; taskId: string; status: Task["status"] }> { + const { submitChatMessage: submitChatMessageRaw } = await import("./chat"); + const { settleSubmittedChatMessage } = await import("./chat-test-support"); + const result = await submitChatMessageRaw(config, sessionId, input); + const settled = await settleSubmittedChatMessage( + config, + sessionId, + result, + String(input.content ?? "") + ); + if ("queued" in settled) throw new Error("expected run-now submission, got queued"); + if ("topicId" in settled) throw new Error("expected chat-direct submission, got topic dispatch"); + return settled; +} + +let scratchHome: string; +let prevHome: string | undefined; +let prevEmbedding: string | undefined; + +beforeEach(() => { + scratchHome = mkdtempSync(join(tmpdir(), "gini-chat-task-home-")); + prevHome = process.env.HOME; + process.env.HOME = scratchHome; + // Pin embeddings to echo: the local provider is unavailable under bun + // test, and the fallback chain otherwise picks the openai provider on + // machines with ~/.codex/auth.json (resolved via os.homedir(), which + // ignores the HOME override above) — turning every memory embed in the + // loop into a real network call. + prevEmbedding = process.env.GINI_EMBEDDING_PROVIDER; + process.env.GINI_EMBEDDING_PROVIDER = "echo"; + // Pin instructions to the frozen geometry fixture so the compaction tests' + // system-prompt slice is independent of edits to the shipped default. + __resetDefaultGiniInstructionsCacheForTest(COMPACTION_INSTRUCTIONS_FIXTURE); +}); + +afterEach(() => { + if (prevHome === undefined) delete process.env.HOME; + else process.env.HOME = prevHome; + if (prevEmbedding === undefined) delete process.env.GINI_EMBEDDING_PROVIDER; + else process.env.GINI_EMBEDDING_PROVIDER = prevEmbedding; + __resetDefaultGiniInstructionsCacheForTest(); + rmSync(scratchHome, { recursive: true, force: true }); +}); + +function buildConfig(workspaceRoot: string, instance: string, opts: Partial = {}): RuntimeConfig { + return { + instance, + port: 7338, + token: "test", + provider: { name: "echo", model: "" }, + workspaceRoot, + stateRoot: process.env.GINI_STATE_ROOT ?? "/tmp/gini-chat-task-test", + logRoot: process.env.GINI_LOG_ROOT ?? "/tmp/gini-chat-task-test-logs", + // These tests predate the approvalMode flip and pin the + // approval-gated loop behavior. Force "strict" so the chat-task + // loop continues to exercise the pause+resume path here; the new + // default-auto matrix lives in approval-mode.test.ts. + approvalMode: "strict", + ...opts + }; +} + +// Seed an enabled skill with an arbitrary (large) body. read_skill returns +// the body verbatim, which makes it the simplest way to drive big tool +// results through the loop without touching the filesystem caps. +async function seedBulkSkill(config: RuntimeConfig, name: string, body: string): Promise { + const skill = await createSkillFromInput(config, { name, description: `Bulk ${name}` }); + await mutateState(config.instance, (state) => { + const item = state.skills.find((s) => s.id === skill.id)!; + item.body = body; + }); + await setSkillStatus(config, skill.id, "enabled"); +} + +// Fixed, test-owned tool catalog for the in-turn-compaction geometry tests. +// Those tests size skill bodies against the always-on `toolSchemaTokens` +// floor; growing any always-on tool description (as create_job's did) would +// otherwise shift that floor and move the compaction crossing point opaquely. +// Installing this constant via __setBaseToolCatalogForTests pins the floor so +// no live-catalog change can perturb the geometry. +// +// Shape: `read_skill` (the only tool the compaction scripts dispatch — the +// loop must run to completion with it) plus uniform filler tools whose count +// is chosen so the serialized provider catalog tokenizes to a known floor. +// Measured floor: toolSchemaTokens = ceil(JSON.stringify(toProviderTools)/4) +// = 12,207 tokens (read_skill ≈ 90 tokens + 47 filler tools ≈ 258 tokens +// each). The high-water mark is floor(32,000 × 0.85) = 27,200 under the echo +// provider (no usage → promptTokenEstimateGap 0), so the message budget +// before the mark is 27,200 − 12,207 ≈ 14,993 tokens. Each in-turn exchange +// is one ~8,600-char read_skill result (≈ 2,150 tokens) plus its assistant +// tool_call row; with the protected head + system prompt, six accumulated +// results cross the mark before the 7th call. The filler count is the ONE +// knob that sets the floor; bodies below are derived from it. +const FIXED_COMPACTION_CATALOG_FILLER = 47; +const FIXED_COMPACTION_CATALOG: ToolCatalogTool[] = [ + { + toolset: "skills", + type: "function", + function: { + name: "read_skill", + description: "Return the full body of an enabled skill by name.", + parameters: { + type: "object", + properties: { name: { type: "string", description: "Skill name." } }, + required: ["name"] + } + } + }, + ...Array.from({ length: FIXED_COMPACTION_CATALOG_FILLER }, (_unused, i) => ({ + toolset: "core", + type: "function" as const, + function: { + name: `fixed_floor_tool_${i}`, + description: "X".repeat(900), + parameters: { type: "object", properties: {}, required: [] as string[] } + } + })) +]; + +// Poll cadence for the terminal-state waiters. The task settles on a detached +// microtask/timer, so the wait resolves at the NEXT poll after it lands — a +// tight interval keeps the wall-clock cost near the real settle time instead of +// rounding every wait up to a coarse tick. Kept as a named constant so the +// cadence is tunable in one place (and small enough that 100+ sequential waits +// don't dominate the suite). +const TERMINAL_POLL_MS = 2; + +async function waitForTerminal(config: RuntimeConfig, taskId: string, timeoutMs = 5000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const state = readState(config.instance); + const task = state.tasks.find((t) => t.id === taskId); + if (task && (task.status === "completed" || task.status === "failed" || task.status === "cancelled" || task.status === "waiting_approval" || task.status === "needs_input")) { + return task; + } + await Bun.sleep(TERMINAL_POLL_MS); + } + throw new Error(`Task ${taskId} did not reach terminal state within ${timeoutMs}ms`); +} + +// Post-approve wait: decideApproval("approve") returns once the decision is +// durable while the side effect + resume run detached, so the task is still +// parked at waiting_approval when the call returns — waitForTerminal's +// parked-inclusive accept set would return the pre-approve park immediately. +// Poll for a genuinely final status instead. +async function waitForFinalTerminal(config: RuntimeConfig, taskId: string, timeoutMs = 5000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const state = readState(config.instance); + const task = state.tasks.find((t) => t.id === taskId); + if (task && (task.status === "completed" || task.status === "failed" || task.status === "cancelled")) { + return task; + } + await Bun.sleep(TERMINAL_POLL_MS); + } + throw new Error(`Task ${taskId} did not reach a final terminal state within ${timeoutMs}ms`); +} + +describe("chat-task loop", () => { + let root: string; + let prevState: string | undefined; + let prevLog: string | undefined; + let prevTransientBackoff: string | undefined; + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "gini-chat-task-")); + prevState = process.env.GINI_STATE_ROOT; + prevLog = process.env.GINI_LOG_ROOT; + prevTransientBackoff = process.env.GINI_TRANSIENT_RETRY_BASE_MS; + process.env.GINI_STATE_ROOT = root; + process.env.GINI_LOG_ROOT = `${root}-logs`; + // Run the transient-retry backoff at 0ms so these tests never burn real + // wall-clock sleep (the production curve is verified separately by the + // trace-message assertions); keeps the suite fast and deterministic. + process.env.GINI_TRANSIENT_RETRY_BASE_MS = "0"; + clearEchoToolCallingResponses(); + clearEchoAuxTextResponses(); + }); + + afterEach(() => { + if (prevState === undefined) delete process.env.GINI_STATE_ROOT; + else process.env.GINI_STATE_ROOT = prevState; + if (prevLog === undefined) delete process.env.GINI_LOG_ROOT; + else process.env.GINI_LOG_ROOT = prevLog; + if (prevTransientBackoff === undefined) delete process.env.GINI_TRANSIENT_RETRY_BASE_MS; + else process.env.GINI_TRANSIENT_RETRY_BASE_MS = prevTransientBackoff; + // Clear any fixed-catalog override a compaction test installed so the + // rest of the suite sees the live buildToolCatalog. + __setBaseToolCatalogForTests(null); + rmSync(root, { recursive: true, force: true }); + clearEchoToolCallingResponses(); + clearEchoAuxTextResponses(); + }); + + test("approval-gated tool call pauses the task and resumes after approval", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + const config = buildConfig(workspaceRoot, "chat-task-gated"); + const provider = normalizeProvider(config.provider); + + // First model turn: request a file write. + setEchoToolCallingResponse({ + provider, + text: "", + toolCalls: [ + { id: "call_w", type: "function", function: { name: "file_write", arguments: JSON.stringify({ path: "out.txt", content: "from-agent" }) } } + ], + finishReason: "tool_calls" + }); + // Second model turn (after approval resumes): final reply. + setEchoToolCallingResponse({ + provider, + text: "Wrote the file as requested.", + toolCalls: [], + finishReason: "stop" + }); + + const task = await submitTask(config, "please create out.txt", { mode: "chat" }); + const paused = await waitForTerminal(config, task.id); + expect(paused.status).toBe("waiting_approval"); + expect(paused.toolCallState).toBeDefined(); + expect(paused.toolCallState?.pending.length).toBe(1); + expect(paused.toolCallState?.pending[0]?.toolName).toBe("file_write"); + expect(paused.approvalIds.length).toBe(1); + + const approvalId = paused.approvalIds[0]!; + await decideApproval(config, approvalId, "approve"); + const finished = await waitForFinalTerminal(config, task.id); + + expect(finished.status).toBe("completed"); + expect(finished.summary).toBe("Wrote the file as requested."); + // The file should have been written. + const written = await Bun.file(join(workspaceRoot, "out.txt")).text(); + expect(written).toBe("from-agent"); + // The toolCallState should be cleared on completion. + expect(finished.toolCallState).toBeUndefined(); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + test("pending approval halts the rest of the turn — later calls are skipped, not dispatched", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + const config = buildConfig(workspaceRoot, "chat-task-pending-halt"); + const provider = normalizeProvider(config.provider); + + setEchoToolCallingResponse({ + provider, + text: "", + toolCalls: [ + { id: "call_w1", type: "function", function: { name: "file_write", arguments: JSON.stringify({ path: "a.txt", content: "AAA" }) } }, + { id: "call_w2", type: "function", function: { name: "file_write", arguments: JSON.stringify({ path: "b.txt", content: "BBB" }) } } + ], + finishReason: "tool_calls" + }); + + const task = await submitTask(config, "write a and b", { mode: "chat" }); + const paused = await waitForTerminal(config, task.id); + expect(paused.status).toBe("waiting_approval"); + expect(paused.toolCallState).toBeDefined(); + // Only the first call goes pending. The second was skipped (its + // dispatch never ran) — message history carries a synthetic + // skipped tool_result so the LLM sees both tool_calls paired. + expect(paused.toolCallState?.pending.length).toBe(1); + expect(paused.approvalIds.length).toBe(1); + + // Deny the lone pending approval. The task fails. The second + // file_write never ran in the first place, so its file must not + // exist on disk either. + const [firstApprovalId] = paused.approvalIds as [string]; + await decideApproval(config, firstApprovalId, "deny"); + // Deny resumes detached; poll for the final failed state rather than a + // fixed sleep so the wait tracks the real settle time. + const failedTask = await waitForFinalTerminal(config, task.id); + + expect(failedTask.status).toBe("failed"); + expect(failedTask.toolCallState).toBeUndefined(); + expect(existsSync(join(workspaceRoot, "a.txt"))).toBe(false); + expect(existsSync(join(workspaceRoot, "b.txt"))).toBe(false); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + test("terminal_exec auto-approves and runs synchronously when the command matches the allowlist", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + const config = buildConfig(workspaceRoot, "chat-task-auto-approve"); + config.autoApproveCommands = ["echo *"]; + const provider = normalizeProvider(config.provider); + + setEchoToolCallingResponse({ + provider, + text: "", + toolCalls: [ + { id: "call_auto", type: "function", function: { name: "terminal_exec", arguments: JSON.stringify({ command: "echo hello" }) } } + ], + finishReason: "tool_calls" + }); + setEchoToolCallingResponse({ + provider, + text: "Said hello.", + toolCalls: [], + finishReason: "stop" + }); + + const task = await submitTask(config, "say hello", { mode: "chat" }); + const finished = await waitForTerminal(config, task.id); + + expect(finished.status).toBe("completed"); + expect(finished.summary).toBe("Said hello."); + + const state = readState(config.instance); + // No approval row should have been created. + const approvalsForTask = state.authorizations.filter((a) => a.taskId === task.id); + expect(approvalsForTask).toHaveLength(0); + + // The audit row should exist and be flagged as auto-approved. + const audit = state.audit.find((a) => a.action === "terminal.exec" && a.taskId === task.id)!; + expect(audit).toBeDefined(); + expect(audit.risk).toBe("high"); + const evidence = audit.evidence as Record; + expect(evidence.autoApproved).toBe(true); + expect(evidence.autoApprovedReason).toBe("echo *"); + expect(evidence.exitCode).toBe(0); + expect(String(evidence.stdout)).toContain("hello"); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + test("a healthy turn with no needs-reauth record writes no clear audit (no state churn)", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + const config = buildConfig(workspaceRoot, "chat-task-reauth-nochurn"); + const provider = normalizeProvider(config.provider); + + setEchoToolCallingResponse({ provider, text: "Nothing to clear.", toolCalls: [], finishReason: "stop" }); + const task = await submitTask(config, "say hi", { mode: "chat" }); + const finished = await waitForTerminal(config, task.id); + expect(finished.status).toBe("completed"); + + // The clear seam fires only when a record exists — a healthy instance + // sees neither a record nor a provider.auth.cleared audit row. + const state = readState(config.instance); + expect(state.providerAuthFailures?.echo).toBeUndefined(); + expect(state.audit.some((a) => a.action === "provider.auth.cleared")).toBe(false); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + test("STILL trips the navigation loop-breaker on oscillating-URL reload loops", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + const config = buildConfig(workspaceRoot, "chat-task-nav-oscillate"); + const provider = normalizeProvider(config.provider); + // Pinned floor for the same reason as the distinct-URL test above. + __setBaseToolCatalogForTests(FIXED_COMPACTION_CATALOG); + + // Two distinct first-visits seed the recent-URL window (count stays 0), + // then alternating between them is a repeat every turn so the count climbs + // to MAX_NAVIGATION_WITHOUT_ACTION (8): 2 seed + 8 repeats = 10 navigations. + const urls = ["http://127.0.0.1:1/", "http://127.0.0.1:2/"]; + for (let i = 0; i < 10; i++) { + setEchoToolCallingResponse({ + provider, + text: "", + toolCalls: [ + { + id: `call_osc_${i}`, + type: "function", + function: { name: "browser_navigate", arguments: JSON.stringify({ url: urls[i % 2] }) } + } + ], + finishReason: "tool_calls" + }); + } + // Tool-less summary turn the loop-breaker exit consumes. + setEchoToolCallingResponse({ + provider, + text: "I kept bouncing between the same two pages without getting anywhere.", + toolCalls: [], + finishReason: "stop" + }); + + const task = await submitTask(config, "open these pages", { mode: "chat" }); + const finished = await waitForTerminal(config, task.id, 10000); + + expect(finished.status).toBe("completed"); + expect(finished.currentStep).toBe("Completed (stopped: tool loop made no progress)"); + + const { readTrace } = await import("../state"); + const traces = readTrace(config.instance, task.id); + const navBreaker = traces.find( + (t) => t.type === "warning" && /navigations to recently-visited URLs.*loop-breaker/.test(t.message) + ); + expect(navBreaker).toBeDefined(); + // Stopped well before the iteration cap: 10 navigation turns + 1 summary turn. + const calls = getEchoToolCallingCalls(); + expect(calls.length).toBe(11); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + // Invalid `agent.maxIterations` values must fall back to the built-in + // default and emit a warning trace. We only verify the warning trace + // here — proving the fallback value matches the default would require running + // a full-length loop, which is wasteful; the resolver is small enough + // that the warning's presence is sufficient evidence. + test("invalid agent.maxIterations warning is emitted at most once across approval resumes", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + const config = buildConfig(workspaceRoot, "chat-task-warn-once"); + (config as unknown as { agent: { maxIterations: number } }).agent = { maxIterations: 0 }; + const provider = normalizeProvider(config.provider); + + // First model turn: request a file write (gated → pauses the task). + setEchoToolCallingResponse({ + provider, + text: "", + toolCalls: [ + { id: "call_wo", type: "function", function: { name: "file_write", arguments: JSON.stringify({ path: "out.txt", content: "x" }) } } + ], + finishReason: "tool_calls" + }); + // Resume turn: final answer. + setEchoToolCallingResponse({ + provider, + text: "Done.", + toolCalls: [], + finishReason: "stop" + }); + + const task = await submitTask(config, "write and finish", { mode: "chat" }); + const paused = await waitForTerminal(config, task.id); + expect(paused.status).toBe("waiting_approval"); + await decideApproval(config, paused.approvalIds[0]!, "approve"); + const finished = await waitForFinalTerminal(config, task.id); + expect(finished.status).toBe("completed"); + + const { readTrace } = await import("../state"); + const traces = readTrace(config.instance, task.id); + const warnings = traces.filter( + (t) => t.type === "warning" && /agent\.maxIterations/i.test(String(t.data?.reason ?? "")) + ); + expect(warnings.length).toBe(1); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + // Also accept the same invalid-string case (e.g. "abc") to confirm the + // resolver's typeof guard rejects non-numbers, not just non-positive + // integers. + test("suppresses the assistant_text block when the final turn text is exactly [SILENT]", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + const config = buildConfig(workspaceRoot, "chat-task-silent-suppress"); + const provider = normalizeProvider(config.provider); + + const sessionId = await mutateState(config.instance, (state) => { + const session = createChatSession(state, "Inbox triage"); + const run = createRun(state, { + kind: "conversation_turn", + title: "Watcher turn", + input: "kick off", + conversationId: session.id + }); + return { runId: run.id, sessionId: session.id }; + }); + + setEchoToolCallingResponse({ + provider, + text: "[SILENT]", + toolCalls: [], + finishReason: "stop" + }); + + const task = await submitTask(config, "anything new?", { mode: "chat", runId: sessionId.runId, chatSessionId: sessionId.sessionId }); + const finished = await waitForTerminal(config, task.id); + expect(finished.status).toBe("completed"); + + const blocks = listChatBlocks(config.instance, sessionId.sessionId); + expect(blocks.some((b) => b.kind === "assistant_text")).toBe(false); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + test("injects the current message's client-surface line into the ephemeral tail, per turn", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + const config = buildConfig(workspaceRoot, "chat-task-surface"); + const provider = normalizeProvider(config.provider); + const { createChat } = await import("./chat"); + const session = await createChat(config, { title: "Surface probe" }); + + // Turn 1 from the web, turn 2 from the phone — the same session can + // alternate surfaces, so each turn must carry only its OWN surface. + setEchoToolCallingResponse({ provider, text: "ok", toolCalls: [], finishReason: "stop" }); + const first = await submitChatMessage(config, session.id, { content: "desktop turn", client: "web" }); + expect((await waitForTerminal(config, first.taskId)).status).toBe("completed"); + + setEchoToolCallingResponse({ provider, text: "ok", toolCalls: [], finishReason: "stop" }); + const second = await submitChatMessage(config, session.id, { content: "phone turn", client: "mobile" }); + expect((await waitForTerminal(config, second.taskId)).status).toBe("completed"); + + const calls = getEchoToolCallingCalls(); + const turn1 = calls[0]!; + const turn2 = calls[calls.length - 1]!; + + // Turn 1: the web line rides in the tail immediately before the user + // message, and the system prefix stays surface-free. + const userIdx1 = turn1.findIndex((m) => m.role === "user" && m.content === "desktop turn"); + expect(userIdx1).toBeGreaterThan(0); + const tail1 = String(turn1[userIdx1 - 1]!.content ?? ""); + expect(tail1).toContain("The user is messaging from the web app"); + expect(String(turn1.find((m) => m.role === "system")?.content ?? "")).not.toContain("The user is messaging"); + + // Turn 2: only the mobile line — the prior turn's web line must not + // replay into this turn's context. + const userIdx2 = turn2.findIndex((m) => m.role === "user" && m.content === "phone turn"); + expect(userIdx2).toBeGreaterThan(0); + const tail2 = String(turn2[userIdx2 - 1]!.content ?? ""); + expect(tail2).toContain("The user is messaging from the mobile app"); + expect(tail2).toContain("a browser handoff can't reach them"); + for (const m of turn2) { + expect(String(m.content ?? "")).not.toContain("The user is messaging from the web app"); + } + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + test("message 0 is a byte-stable prefix across two turns in the same session", async () => { + // Headline cache contract: with no identity/skill/job/connector change + // between turns, the system message (message 0) must be byte-identical + // turn-to-turn so automatic provider prefix caching stays warm. Message 0 + // is stable regardless of whether recall returns anything, because the + // per-turn-varying content (emitted identity, recalled memory) now lives + // in the ephemeral role:"user" tail rather than in message 0. See ADR + // stable-system-prefix.md. + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + const config = buildConfig(workspaceRoot, "chat-task-stable-prefix"); + const provider = normalizeProvider(config.provider); + + const sessionId = await mutateState(config.instance, (state) => { + const session = createChatSession(state, "Stable prefix"); + return session.id; + }); + + const firstRunId = await mutateState(config.instance, (state) => { + const run = createRun(state, { + kind: "conversation_turn", + title: "Turn 1", + input: "first", + conversationId: sessionId + }); + return run.id; + }); + setEchoToolCallingResponse({ provider, text: "One.", toolCalls: [], finishReason: "stop" }); + const firstTask = await submitTask(config, "first question", { mode: "chat", runId: firstRunId }); + await waitForTerminal(config, firstTask.id); + + const secondRunId = await mutateState(config.instance, (state) => { + const run = createRun(state, { + kind: "conversation_turn", + title: "Turn 2", + input: "second", + conversationId: sessionId + }); + return run.id; + }); + setEchoToolCallingResponse({ provider, text: "Two.", toolCalls: [], finishReason: "stop" }); + const secondTask = await submitTask(config, "second question", { mode: "chat", runId: secondRunId }); + await waitForTerminal(config, secondTask.id); + + const calls = getEchoToolCallingCalls(); + expect(calls.length).toBe(2); + expect(calls[0]![0]!.role).toBe("system"); + expect(calls[1]![0]!.role).toBe("system"); + // Byte equality of message 0 across the two turns is the whole point. + expect(calls[0]![0]!.content).toBe(calls[1]![0]!.content); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + test("surfaces native web searches as display-only Web search chips", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + const config = buildConfig(workspaceRoot, "chat-task-web-search-chip"); + const provider = normalizeProvider(config.provider); + + const session = await mutateState(config.instance, (state) => + createChatSession(state, "block-websearch", undefined, "agent_ws") + ); + + // A hosted native-web-search turn: the provider ran the searches itself and + // returns their headlines in `webSearches` alongside a cited final answer + // (no dispatchable tool calls). The loop must surface each search as a chip. + setEchoToolCallingResponse({ + provider, + text: "The top story is X. ([hn](https://news.ycombinator.com/))", + toolCalls: [], + finishReason: "stop", + webSearches: ["hacker news top story", "https://news.ycombinator.com/"] + }); + + const submitted = await submitChatMessage(config, session.id, { content: "what's the top HN story?" }); + const finished = await waitForTerminal(config, submitted.taskId); + expect(finished.status).toBe("completed"); + + const { listChatBlocks } = await import("../state"); + const blocks = listChatBlocks(config.instance, session.id); + + const webSearchChips = blocks.filter( + (b): b is typeof blocks[0] & { kind: "tool_call" } => + b.kind === "tool_call" && b.toolName === "web_search" + ); + expect(webSearchChips.length).toBe(2); + // Display-only: "Web search" label, the query/url as the inline preview, + // settled straight to ok (the search already ran on the provider). + expect(webSearchChips.map((c) => c.displayLabel)).toEqual(["Web search", "Web search"]); + expect(webSearchChips.map((c) => c.argsPreview)).toEqual([ + "hacker news top story", + "https://news.ycombinator.com/" + ]); + expect(webSearchChips.every((c) => c.status === "ok")).toBe(true); + // Distinct call ids so the two searches render as separate rows. + expect(new Set(webSearchChips.map((c) => c.callId)).size).toBe(2); + // Nothing was dispatched, so the chips carry no paired tool_result. + expect(blocks.filter((b) => b.kind === "tool_result")).toHaveLength(0); + + // Ordering is load-bearing, not cosmetic: both chips must land BEFORE the + // assistant's answer block. A chip trailing the answer makes group-exchanges + // fold the answer out of its standalone bubble (finalAnswerIdx = -1). The + // provider announces each search mid-stream (before text) for exactly this. + // listChatBlocks is ordinal-ordered, so index position is the settled order. + const kinds = blocks.map((b) => b.kind); + const lastAnswerIdx = kinds.lastIndexOf("assistant_text"); + const chipIdxs = blocks + .map((b, i) => (b.kind === "tool_call" && b.toolName === "web_search" ? i : -1)) + .filter((i) => i >= 0); + expect(lastAnswerIdx).toBeGreaterThanOrEqual(0); + expect(Math.max(...chipIdxs)).toBeLessThan(lastAnswerIdx); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + test("connector.request cancel resumes the chat loop with a fallback result", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + const config = buildConfig(workspaceRoot, "chat-task-blocks-connector-cancel"); + const provider = normalizeProvider(config.provider); + + const session = await mutateState(config.instance, (state) => + createChatSession(state, "block-connector-cancel", undefined, "agent_z") + ); + + const reason = "I need Brave Search access to answer with current weather."; + setEchoToolCallingResponse({ + provider, + text: "", + toolCalls: [ + { id: "call_c", type: "function", function: { name: "request_connector", arguments: JSON.stringify({ provider: "brave-search", reason }) } } + ], + finishReason: "tool_calls" + }); + setEchoToolCallingResponse({ + provider, + text: "I can't look up current weather without Brave Search access. Connect Brave Search or provide the weather details.", + toolCalls: [], + finishReason: "stop" + }); + + const submitted = await submitChatMessage(config, session.id, { content: "what is the weather in sf today" }); + const paused = await waitForTerminal(config, submitted.taskId); + expect(paused.status).toBe("waiting_approval"); + + const setup = readState(config.instance).setupRequests.find((s) => s.taskId === submitted.taskId); + expect(setup?.action).toBe("connector.request"); + + await resolveSetupRequest(config, setup!.id, "cancel", { actor: "user" }); + + let finished = readState(config.instance).tasks.find((t) => t.id === submitted.taskId); + const deadline = Date.now() + 5000; + while (finished?.status !== "completed" && Date.now() < deadline) { + await Bun.sleep(TERMINAL_POLL_MS); + finished = readState(config.instance).tasks.find((t) => t.id === submitted.taskId); + } + expect(finished?.status).toBe("completed"); + expect(finished?.summary).toBe( + "I can't look up current weather without Brave Search access. Connect Brave Search or provide the weather details." + ); + + const { listChatBlocks } = await import("../state"); + const blocks = listChatBlocks(config.instance, session.id); + let lastPhase = blocks[blocks.length - 1]; + for (let i = blocks.length - 1; i >= 0; i--) { + if (blocks[i]?.kind === "phase") { + lastPhase = blocks[i]; + break; + } + } + expect(lastPhase?.kind).toBe("phase"); + if (lastPhase?.kind === "phase") { + expect(lastPhase.label).toBe("Completed"); + expect(lastPhase.taskId).toBe(submitted.taskId); + } + const requestConnectorCall = blocks.find( + (b) => b.kind === "tool_call" && b.toolName === "request_connector" + ); + expect(requestConnectorCall?.kind).toBe("tool_call"); + if (requestConnectorCall?.kind === "tool_call") { + expect(requestConnectorCall.status).toBe("ok"); + } + expect(blocks.some((b) => b.kind === "tool_result" && b.preview.includes("User canceled connector setup for brave-search"))).toBe(true); + expect(blocks.some((b) => b.kind === "assistant_text" && b.text === finished?.summary)).toBe(true); + expect(readState(config.instance).setupRequests.find((s) => s.id === setup!.id)?.status).toBe("cancelled"); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + test("emits parallel tool_calls with distinct callIds and ordinals", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + writeFileSync(join(workspaceRoot, "a.md"), "alpha"); + writeFileSync(join(workspaceRoot, "b.md"), "beta"); + const config = buildConfig(workspaceRoot, "chat-task-blocks-parallel"); + const provider = normalizeProvider(config.provider); + + const session = await mutateState(config.instance, (state) => + createChatSession(state, "block-parallel", undefined, "agent_p") + ); + + setEchoToolCallingResponse({ + provider, + text: "", + toolCalls: [ + { id: "call_a", type: "function", function: { name: "file_read", arguments: JSON.stringify({ path: "a.md" }) } }, + { id: "call_b", type: "function", function: { name: "file_read", arguments: JSON.stringify({ path: "b.md" }) } } + ], + finishReason: "tool_calls" + }); + setEchoToolCallingResponse({ + provider, + text: "Read both.", + toolCalls: [], + finishReason: "stop" + }); + + const submitted = await submitChatMessage(config, session.id, { content: "read both" }); + const finished = await waitForTerminal(config, submitted.taskId); + expect(finished.status).toBe("completed"); + + const { listChatBlocks } = await import("../state"); + const blocks = listChatBlocks(config.instance, session.id); + const toolCalls = blocks.filter((b): b is typeof blocks[0] & { kind: "tool_call" } => b.kind === "tool_call"); + expect(toolCalls).toHaveLength(2); + expect(toolCalls.map((c) => c.callId).sort()).toEqual(["call_a", "call_b"]); + expect(toolCalls.map((c) => c.ordinal)).toEqual( + toolCalls.map((c) => c.ordinal).slice().sort((x, y) => x - y) + ); + expect(toolCalls.every((c) => c.status === "ok")).toBe(true); + + const toolResults = blocks.filter((b) => b.kind === "tool_result"); + expect(toolResults).toHaveLength(2); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + test("two turns reusing the same tool_call_id each replay with their own result", async () => { + // The text-backstop path synthesizes call ids from name:args:index, so + // the same tool called with the same args on two turns yields an + // identical tool_call_id. Pairing must stay local to each assistant row: + // turn 1's call gets turn 1's result, turn 2's call gets turn 2's, even + // though both rows share the id. + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + const config = buildConfig(workspaceRoot, "chat-task-transcript-dupid"); + const provider = normalizeProvider(config.provider); + const fixturePath = join(workspaceRoot, "state.txt"); + + const { createChat, syncChatTaskResult } = await import("./chat"); + const session = await createChat(config, { title: "Dup-id thread" }); + + // Turn 1: read the file (content "FIRST"), reusing the colliding id. + writeFileSync(fixturePath, "FIRST"); + setEchoToolCallingResponse({ + provider, + text: "", + toolCalls: [ + { id: "call_dup", type: "function", function: { name: "file_read", arguments: JSON.stringify({ path: "state.txt" }) } } + ], + finishReason: "tool_calls" + }); + setEchoToolCallingResponse({ + provider, + text: "Read FIRST.", + toolCalls: [], + finishReason: "stop" + }); + const first = await submitChatMessage(config, session.id, { content: "read it once" }); + await waitForTerminal(config, first.taskId); + await syncChatTaskResult(config, session.id, first.taskId); + + // Turn 2: read the same file (now "SECOND") with the SAME tool_call_id. + writeFileSync(fixturePath, "SECOND"); + setEchoToolCallingResponse({ + provider, + text: "", + toolCalls: [ + { id: "call_dup", type: "function", function: { name: "file_read", arguments: JSON.stringify({ path: "state.txt" }) } } + ], + finishReason: "tool_calls" + }); + setEchoToolCallingResponse({ + provider, + text: "Read SECOND.", + toolCalls: [], + finishReason: "stop" + }); + const second = await submitChatMessage(config, session.id, { content: "read it again" }); + await waitForTerminal(config, second.taskId); + await syncChatTaskResult(config, session.id, second.taskId); + + // Turn 3: plain answer — we only inspect the transcript it was handed. + setEchoToolCallingResponse({ + provider, + text: "Done.", + toolCalls: [], + finishReason: "stop" + }); + const third = await submitChatMessage(config, session.id, { content: "what changed?" }); + await waitForTerminal(config, third.taskId); + + const calls = getEchoToolCallingCalls(); + const turn3 = calls.find((messages) => + messages.some((m) => m.role === "user" && m.content === "what changed?") + ); + expect(turn3).toBeDefined(); + + // Both assistant tool_calls rows replay, each immediately followed by its + // OWN paired result — turn 1 with "FIRST", turn 2 with "SECOND". + const toolResults: string[] = []; + for (let i = 0; i < turn3!.length; i++) { + const m = turn3![i]!; + if (m.role === "assistant" && Array.isArray(m.tool_calls) && m.tool_calls.some((c) => c.id === "call_dup")) { + const next = turn3![i + 1]; + expect(next?.role).toBe("tool"); + expect(next?.tool_call_id).toBe("call_dup"); + toolResults.push(String(next?.content ?? "")); + } + } + expect(toolResults.length).toBe(2); + expect(toolResults[0]).toContain("FIRST"); + expect(toolResults[0]).not.toContain("SECOND"); + expect(toolResults[1]).toContain("SECOND"); + expect(toolResults[1]).not.toContain("FIRST"); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + test("the dispatcher's deferred guard nudges toward load_tools and does not over-trigger", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + const config = buildConfig(workspaceRoot, "chat-task-deferred-nudge"); + + const { dispatchToolCall } = await import("./tool-dispatch"); + const { isDeferredToolName } = await import("./tool-catalog"); + const { createTask, upsertTask } = await import("../state"); + const taskRow = createTask(config.instance, "nudge probe"); + await mutateState(config.instance, (state) => upsertTask(state, taskRow)); + + // A genuinely unknown (non-deferred) tool still throws Unknown tool — + // the guard is scoped to deferred names and does not over-trigger. + expect(isDeferredToolName("totally_made_up_tool")).toBe(false); + let threw = false; + try { + await dispatchToolCall(config, taskRow.id, "totally_made_up_tool", "call_x", "{}"); + } catch { + threw = true; + } + expect(threw).toBe(true); + + // browser_snapshot is a known deferred tool. The guard helper classifies + // it as deferred so a not-yet-loaded reference reaching the dispatcher's + // default case would return the recoverable load_tools nudge rather than + // throwing Unknown tool. + expect(isDeferredToolName("browser_snapshot")).toBe(true); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + // The riskiest invariant: a deferred tool the model loaded must stay live + // across an approval pause/resume. We load two deferred self tools + // (set_provider + get_self), call set_provider (strict → gates), assert the + // paused task carries loadedTools, then approve and — on the RESUMED turn — + // call get_self directly. get_self only dispatches successfully if it is + // still in providerTools after the resume, which proves runLoop re-seeds + // loadedToolNames from task.loadedTools (a merely-completing final-text turn + // would not prove the loaded schema survived). + test("create_agent direct tool gates, then approval lands the agent and resumes", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + const config = buildConfig(workspaceRoot, "chat-task-create-agent-resume"); + const provider = normalizeProvider(config.provider); + + // Turn 1: load create_agent. + setEchoToolCallingResponse({ + provider, + text: "", + toolCalls: [ + { id: "call_load", type: "function", function: { name: "load_tools", arguments: JSON.stringify({ names: ["create_agent"] }) } } + ], + finishReason: "tool_calls" + }); + // Turn 2: call create_agent directly. Strict → gates. + setEchoToolCallingResponse({ + provider, + text: "", + toolCalls: [ + { id: "call_ca", type: "function", function: { name: "create_agent", arguments: JSON.stringify({ name: "E2E2" }) } } + ], + finishReason: "tool_calls" + }); + // Turn 3: final answer after approval. + setEchoToolCallingResponse({ + provider, + text: "Agent created.", + toolCalls: [], + finishReason: "stop" + }); + + const task = await submitTask(config, "create an agent called E2E2", { mode: "chat" }); + const paused = await waitForTerminal(config, task.id, 10000); + expect(paused.status).toBe("waiting_approval"); + + const stateBefore = readState(config.instance); + const approval = stateBefore.authorizations.find((a) => a.id === paused.approvalIds[0]!)!; + expect(approval.action).toBe("self.config"); + expect(approval.payload.opName).toBe("create_agent"); + // No agent yet — the side effect is deferred to approval time. + expect(stateBefore.agents.some((a) => a.name === "E2E2")).toBe(false); + + await decideApproval(config, approval.id, "approve"); + const finished = await waitForFinalTerminal(config, task.id, 10000); + expect(finished.status).toBe("completed"); + expect(finished.summary).toBe("Agent created."); + + // The handler ran on approval — the agent row now exists. + const stateAfter = readState(config.instance); + expect(stateAfter.agents.some((a) => a.name === "E2E2")).toBe(true); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + // Issue #397: an inline-handled tool (load_tools here; the deferred-not-loaded + // nudge shares the branch) must PERSIST its tool result to + // the durable transcript, paired with the assistant tool_use row. Otherwise a + // later turn (or any rebuild) replays the assistant tool_use with no result, + // and a tool-pairing-strict provider (Bedrock Converse, Anthropic Messages) + // 400s the whole request. Pin: after a load_tools turn, the channel's durable + // chatMessages carry both the assistant call AND a role:"tool" result for it. + test("a turn cancelled mid-stream replays the interrupt marker to the model on the next turn", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + // Per-instance runtime state persists across reruns in the same worker, so + // derive the instance from the unique mkdtemp basename — otherwise a rerun + // replays chatMessages accumulated by the prior run and the assertion races. + const config = buildConfig(workspaceRoot, `chat-task-interrupt-marker-replay-${basename(workspaceRoot)}`); + const provider = normalizeProvider(config.provider); + const { cancelTask } = await import("../agent"); + + const sessionId = await mutateState(config.instance, (state) => { + const session = createChatSession(state, "General chat"); + return session.id; + }); + + // Turn 1: a held, tool-less model call so the cancel lands mid-stream. + setEchoToolCallingResponse( + { provider, text: "drafting a long answer...", toolCalls: [], finishReason: "stop" }, + undefined, + { delayMs: 3000 } + ); + const task = await submitTask(config, "write me a long essay", { mode: "chat", chatSessionId: sessionId }); + // Wait until it's genuinely running (model call in flight), then cancel. + const deadline = Date.now() + 5000; + while (Date.now() < deadline) { + if (readState(config.instance).tasks.find((t) => t.id === task.id)?.status === "running") break; + await Bun.sleep(10); + } + await cancelTask(config, task.id); + const cancelled = await waitForTerminal(config, task.id); + expect(cancelled.status).toBe("cancelled"); + + // Turn 2 in the same session: the replayed provider messages must include + // the interrupt marker (a user-role row priorChatMessages replays). + clearEchoToolCallingResponses(); + setEchoToolCallingResponse({ provider, text: "Sure.", toolCalls: [], finishReason: "stop" }); + const followUp = await submitTask(config, "actually, just summarize", { mode: "chat", chatSessionId: sessionId }); + const finishedFollowUp = await waitForTerminal(config, followUp.id); + expect(finishedFollowUp.status).toBe("completed"); + + const calls = getEchoToolCallingCalls(); + const lastTurn = calls[calls.length - 1]!; + const replayed = lastTurn.map((m) => (typeof m.content === "string" ? m.content : "")).join("\n"); + expect(replayed).toContain("[Request interrupted by user]"); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + // The iteration-cap/loop-stall summary exit completes the task with a real + // user-facing summary, so it must land the same durable assistant answer + // row as the no-tool-calls path — otherwise a turn that ends on the cap + // leaves the session with no answer for the next turn to replay. + test("session-bound subagent with a [SILENT] final persists no summary chatMessage", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + const config = buildConfig(workspaceRoot, "chat-task-fanout-silent"); + const provider = normalizeProvider(config.provider); + + const { sessionId, subagentId } = await mutateState(config.instance, (state) => { + const session = createChatSession(state, "Email: quiet@example.com"); + const subagent = createSubagentRecord(state, { + name: "email-watch", + prompt: "watch worker", + toolsets: ["file"], + systemPrompt: "You are an email watch worker." + }); + return { sessionId: session.id, subagentId: subagent.id }; + }); + + setEchoToolCallingResponse({ + provider, + text: "[SILENT]", + toolCalls: [], + finishReason: "stop" + }); + + const worker = await submitTask(config, "anything to reply to?", { mode: "chat", chatSessionId: sessionId, subagentId }); + const finished = await waitForTerminal(config, worker.id); + expect(finished.status).toBe("completed"); + + const summaryRows = readState(config.instance).chatMessages.filter( + (m) => m.sessionId === sessionId && m.role === "assistant" && m.kind !== "tool_transcript" && m.kind !== "approval_reason" + ); + expect(summaryRows.length).toBe(0); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + // Provider-reported prompt tokens drive the in-turn trim trigger. The + // chars/4 estimate here stays under every threshold, so without + // calibration no elision would ever fire. A stubbed call reporting a real + // prompt size near the window (29.6k of the echo provider's 32k) must + // tighten the NEXT iteration's budgets so the oldest unprotected tool + // results shrink before the following call — via pruning alone, with no + // summarization (aux) involvement. Toolsets are disabled to pin the tool + // schemas to the always-on floor (file_read still dispatches; the catalog + // only shapes what the provider sees). + test("overflow exhaustion settles the failed attempt's stream", async () => { + const OVERFLOW_MESSAGE = "prompt is too long: 250000 tokens > 200000 maximum"; + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + const config = buildConfig(workspaceRoot, "chat-task-overflow-stream-exhaust"); + const session = await mutateState(config.instance, (state) => + createChatSession(state, "stream-exhaust") + ); + + // Every attempt streams partial text before throwing the overflow. + setEchoToolCallingFailure(OVERFLOW_MESSAGE, { streamTextBeforeFailure: "DISCARDED-PARTIAL " }); + setEchoToolCallingFailure(OVERFLOW_MESSAGE, { streamTextBeforeFailure: "DISCARDED-PARTIAL " }); + setEchoToolCallingFailure(OVERFLOW_MESSAGE, { streamTextBeforeFailure: "DISCARDED-PARTIAL " }); + + const submitted = await submitChatMessage(config, session.id, { content: "go" }); + const finished = await waitForTerminal(config, submitted.taskId, 10000); + + expect(finished.status).toBe("completed"); + expect(finished.summary).toContain("This is a partial result."); + // The discarded stream never resurrects into the partial summary. + expect(finished.partialSummary ?? "").toBe(""); + + // No block on the completed task is left streaming. + const { listChatBlocks } = await import("../state"); + const blocks = listChatBlocks(config.instance, session.id); + const streamingBlocks = blocks.filter((b) => b.kind === "assistant_text" && b.streaming); + expect(streamingBlocks.length).toBe(0); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + // The partial-result exit surfaces only THIS turn's narration. The packed + // prior context inside workingMessages carries earlier turns' assistant + // answers, so a transcript re-scan would resurrect one of those as this + // turn's partial result; and the explanatory note must reach the chat + // exactly once (as a system note), never duplicated into an + // assistant_text block. + test("in-turn compaction summarizes the middle, protects head and tail, and continues", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + const config = buildConfig(workspaceRoot, "chat-task-compaction"); + const provider = normalizeProvider(config.provider); + await mutateState(config.instance, (state) => { + for (const toolset of state.toolsets) toolset.status = "disabled"; + }); + // Pin the tool-catalog floor so the crossing geometry is decoupled from + // live always-on catalog size (cleared in afterEach). + __setBaseToolCatalogForTests(FIXED_COMPACTION_CATALOG); + + for (let i = 0; i < 7; i++) { + await seedBulkSkill(config, `bulk-skill-${i}`, `BODY-${i} ${"x".repeat(8_372)}`); + setEchoToolCallingResponse({ + provider, + text: "", + toolCalls: [ + { id: `call_s${i}`, type: "function", function: { name: "read_skill", arguments: JSON.stringify({ name: `bulk-skill-${i}` }) } } + ], + finishReason: "tool_calls" + }); + } + setEchoAuxTextResponse({ text: "SUMMARY-OF-MIDDLE" }); + setEchoToolCallingResponse({ + provider, + text: "All skills reviewed.", + toolCalls: [], + finishReason: "stop" + }); + + const task = await submitTask(config, "review every bulk skill", { mode: "chat" }); + const finished = await waitForTerminal(config, task.id, 10000); + expect(finished.status).toBe("completed"); + expect(finished.summary).toBe("All skills reviewed."); + + // Exactly one aux summarization call, fed ONLY the middle exchanges — + // at compaction time (before the 7th call) six exchanges exist: BODY-0 + // is the protected head exchange, BODY-4/BODY-5 the protected tail, so + // the middle is BODY-1..BODY-3. BODY-6 is read after the compaction. + const auxRequests = getEchoAuxTextRequests(); + expect(auxRequests.length).toBe(1); + for (const middle of ["BODY-1", "BODY-2", "BODY-3"]) { + expect(auxRequests[0]!.user).toContain(middle); + } + for (const protectedBody of ["BODY-0", "BODY-4", "BODY-5", "BODY-6"]) { + expect(auxRequests[0]!.user).not.toContain(protectedBody); + } + + // The final call carries the marked synthetic summary… + const calls = getEchoToolCallingCalls(); + expect(calls.length).toBe(8); + const finalCall = calls[7]!; + const summaryMessage = finalCall.find( + (m) => + m.role === "user" && + typeof m.content === "string" && + m.content.startsWith(IN_TURN_COMPACTION_NOTE_PREFIX) + ); + expect(summaryMessage).toBeDefined(); + expect(String(summaryMessage!.content)).toContain("SUMMARY-OF-MIDDLE"); + // …the middle tool results are gone… + const toolContents = finalCall.filter((m) => m.role === "tool").map((m) => String(m.content)); + for (const middle of ["BODY-1", "BODY-2", "BODY-3"]) { + expect(toolContents.some((c) => c.includes(middle))).toBe(false); + } + // …the recent tail (and the post-compaction read) stay verbatim… + expect(toolContents.some((c) => c.includes("BODY-4"))).toBe(true); + expect(toolContents.some((c) => c.includes("BODY-5"))).toBe(true); + expect(toolContents.some((c) => c.includes("BODY-6"))).toBe(true); + // …and the head is intact: system prompt, the original ask, and the + // first in-turn exchange. + expect(finalCall[0]!.role).toBe("system"); + expect( + finalCall.some((m) => m.role === "user" && String(m.content).includes("review every bulk skill")) + ).toBe(true); + expect( + finalCall.some((m) => m.role === "assistant" && (m.tool_calls ?? []).some((c) => c.id === "call_s0")) + ).toBe(true); + expect(toolContents.some((c) => c.includes("BODY-0"))).toBe(true); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + // Anti-thrash: when the only summarizable middle is tiny while the + // protected head/tail carries the bulk, compaction reclaims almost + // nothing — the loop must bail to the graceful partial exit instead of + // grinding through pointless aux calls. + test("in-turn compaction falls back to a graceful partial exit when the aux model is unavailable", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + const config = buildConfig(workspaceRoot, "chat-task-compaction-aux-fail"); + const provider = normalizeProvider(config.provider); + await mutateState(config.instance, (state) => { + for (const toolset of state.toolsets) toolset.status = "disabled"; + }); + // Pin the tool-catalog floor so the crossing geometry is decoupled from + // live always-on catalog size (cleared in afterEach). + __setBaseToolCatalogForTests(FIXED_COMPACTION_CATALOG); + + for (let i = 0; i < 7; i++) { + await seedBulkSkill(config, `bulk-skill-${i}`, `BODY-${i} ${"x".repeat(8_372)}`); + setEchoToolCallingResponse({ + provider, + text: "", + toolCalls: [ + { id: `call_f${i}`, type: "function", function: { name: "read_skill", arguments: JSON.stringify({ name: `bulk-skill-${i}` }) } } + ], + finishReason: "tool_calls" + }); + } + setEchoAuxTextFailure("aux model unavailable"); + + const task = await submitTask(config, "review every bulk skill", { mode: "chat" }); + const finished = await waitForTerminal(config, task.id, 10000); + + expect(finished.status).toBe("completed"); + expect(finished.currentStep).toBe("Completed (stopped: context window exhausted)"); + expect(finished.summary).toContain("no summarization model was available"); + expect(finished.error).toBeUndefined(); + // The trigger fired before the 7th call, the aux call failed, and no + // further model call ran. + expect(getEchoToolCallingCalls().length).toBe(6); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + // Non-overflow provider errors keep their existing contract: the task + // fails with the raw error, no compact-and-retry. + test("a transient fault that streamed partial text trims it before the successful retry", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + const config = buildConfig(workspaceRoot, "chat-task-transient-partial-trim"); + const provider = normalizeProvider(config.provider); + + // The failed attempt streams a partial chunk, then errors transiently. + setEchoToolCallingFailure("The operation timed out.", { streamTextBeforeFailure: "DISCARDED-PARTIAL " }); + setEchoToolCallingResponse({ provider, text: "Clean retry answer.", toolCalls: [], finishReason: "stop" }); + + const task = await submitTask(config, "say hi", { mode: "chat" }); + const finished = await waitForTerminal(config, task.id, 10000); + + expect(finished.status).toBe("completed"); + // The discarded partial must NOT leak into the final summary. + expect(finished.summary).toBe("Clean retry answer."); + expect(finished.summary).not.toContain("DISCARDED-PARTIAL"); + expect(getEchoToolCallingCalls().length).toBe(2); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + // A cancel that lands during the transient backoff wait must bail to the + // cancelled path, never sneak in the retry. Exercises abortableBackoffSleep's + // abort branch and the catch-and-bailOnTurnAbort handling. +}); \ No newline at end of file diff --git a/packages/runtime/src/execution/chat-task.part3.test.ts b/packages/runtime/src/execution/chat-task.part3.test.ts new file mode 100644 index 00000000..50dfa756 --- /dev/null +++ b/packages/runtime/src/execution/chat-task.part3.test.ts @@ -0,0 +1,1307 @@ +// End-to-end tests for the chat-task agent loop. +// +// We use the echo provider with stubbed tool-calling responses so the loop +// is fully deterministic. The test covers: +// - one tool call → result fed back → final answer +// - approval-gated tool call → task pauses with toolCallState +// - resume after approval → task completes +// +// HOME is pointed at a unique mkdtemp dir per test (same pattern as +// src/state/google-accounts.test.ts): the loop reads the machine-global +// Google account registry (buildConnectedAccountsBlock(readGoogleAccounts()) +// in the system prompt; isSkillActive via credentialExternallySatisfied), so +// a developer machine with registered accounts would otherwise shift the +// system-prompt size and skill activity these tests depend on. + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, join } from "node:path"; +import { + clearEchoAuxTextResponses, + clearEchoToolCallingResponses, + getEchoAuxTextRequests, + getEchoToolCallingCalls, + getEchoToolCallingToolNames, + setEchoAuxTextFailure, + setEchoAuxTextResponse, + setEchoToolCallingFailure, + setEchoToolCallingResponse, + normalizeProvider, + type MessageContentPart, + type ToolCallingMessage +} from "../provider"; +import { submitTask, decideApproval, resolveSetupRequest } from "../agent"; +import { + bankIdForAgent, + createChatMessage, + createChatSession, + createEmptyState, + createRun, + createSubagentRecord, + deleteChatSession, + ensureAgentBank, + ensureDefaultBank, + insertMemoryUnit, + listChatBlocks, + mutateState, + now, + readState, + recordProviderAuthFailure +} from "../state"; +import { echoEmbed } from "../embeddings"; +import { storeUpload } from "../state/uploads"; +import { resolveDefaultPriorContextTokenBudget } from "../provider-capabilities"; +import type { AgentIdentity, GoogleAccount, JobRecord, RuntimeConfig, RuntimeState, SkillRecord, Task, ToolsetRecord } from "../types"; +import { createSkillFromInput, setSkillStatus } from "../capabilities/skills"; +import { + __setBaseToolCatalogForTests, + buildAgentIdentity, + buildConnectedAccountsBlock, + buildEnabledSkillsBlock, + buildInactiveSkillsBlock, + buildMcpServersBlock, + buildSkillScriptsBlock, + compactionMiddleSpan, + elideOldToolResultsToBudget, + IN_TURN_COMPACTION_NOTE_PREFIX, + initialNavStallState, + nextNavStallState, + promptTokensFromUsage, + renderMessagesForCompaction +} from "./chat-task"; +import type { ToolCatalogTool } from "./tool-catalog"; +import type { EffectiveContext } from "./effective-context"; +import { __resetDefaultGiniInstructionsCacheForTest } from "../system-prompt"; + +// Compaction geometry in this file is calibrated against a FIXED instructions +// size (alongside the pinned tool catalog). The real default INSTRUCTIONS.md +// changes over time, which would otherwise shift the system-prompt slice and +// move the compaction crossing point opaquely. Pin a frozen fixture (byte-for- +// byte the default as of this calibration) so edits to the shipped default +// never perturb these tests. +const COMPACTION_INSTRUCTIONS_FIXTURE = join( + import.meta.dir, + "..", + "runtime", + "__fixtures__", + "instructions-compaction-geometry.md" +); + +// These tests submit on idle sessions, which always run immediately. They also +// don't seed a "chat-route" stub, so the router coerces to a chat-direct +// decision and the turn runs in the submitted session. Narrow the submit union +// to the chat-direct run-now branch so the existing `.taskId` reads stay typed +// (a queued or topic-dispatched result here is a test-setup bug). See ADR +// chat-message-queue.md. +async function submitChatMessage( + config: RuntimeConfig, + sessionId: string, + input: Record +): Promise<{ sessionId: string; runId?: string; taskId: string; status: Task["status"] }> { + const { submitChatMessage: submitChatMessageRaw } = await import("./chat"); + const { settleSubmittedChatMessage } = await import("./chat-test-support"); + const result = await submitChatMessageRaw(config, sessionId, input); + const settled = await settleSubmittedChatMessage( + config, + sessionId, + result, + String(input.content ?? "") + ); + if ("queued" in settled) throw new Error("expected run-now submission, got queued"); + if ("topicId" in settled) throw new Error("expected chat-direct submission, got topic dispatch"); + return settled; +} + +let scratchHome: string; +let prevHome: string | undefined; +let prevEmbedding: string | undefined; + +beforeEach(() => { + scratchHome = mkdtempSync(join(tmpdir(), "gini-chat-task-home-")); + prevHome = process.env.HOME; + process.env.HOME = scratchHome; + // Pin embeddings to echo: the local provider is unavailable under bun + // test, and the fallback chain otherwise picks the openai provider on + // machines with ~/.codex/auth.json (resolved via os.homedir(), which + // ignores the HOME override above) — turning every memory embed in the + // loop into a real network call. + prevEmbedding = process.env.GINI_EMBEDDING_PROVIDER; + process.env.GINI_EMBEDDING_PROVIDER = "echo"; + // Pin instructions to the frozen geometry fixture so the compaction tests' + // system-prompt slice is independent of edits to the shipped default. + __resetDefaultGiniInstructionsCacheForTest(COMPACTION_INSTRUCTIONS_FIXTURE); +}); + +afterEach(() => { + if (prevHome === undefined) delete process.env.HOME; + else process.env.HOME = prevHome; + if (prevEmbedding === undefined) delete process.env.GINI_EMBEDDING_PROVIDER; + else process.env.GINI_EMBEDDING_PROVIDER = prevEmbedding; + __resetDefaultGiniInstructionsCacheForTest(); + rmSync(scratchHome, { recursive: true, force: true }); +}); + +function buildConfig(workspaceRoot: string, instance: string, opts: Partial = {}): RuntimeConfig { + return { + instance, + port: 7338, + token: "test", + provider: { name: "echo", model: "" }, + workspaceRoot, + stateRoot: process.env.GINI_STATE_ROOT ?? "/tmp/gini-chat-task-test", + logRoot: process.env.GINI_LOG_ROOT ?? "/tmp/gini-chat-task-test-logs", + // These tests predate the approvalMode flip and pin the + // approval-gated loop behavior. Force "strict" so the chat-task + // loop continues to exercise the pause+resume path here; the new + // default-auto matrix lives in approval-mode.test.ts. + approvalMode: "strict", + ...opts + }; +} + +// Seed an enabled skill with an arbitrary (large) body. read_skill returns +// the body verbatim, which makes it the simplest way to drive big tool +// results through the loop without touching the filesystem caps. +async function seedBulkSkill(config: RuntimeConfig, name: string, body: string): Promise { + const skill = await createSkillFromInput(config, { name, description: `Bulk ${name}` }); + await mutateState(config.instance, (state) => { + const item = state.skills.find((s) => s.id === skill.id)!; + item.body = body; + }); + await setSkillStatus(config, skill.id, "enabled"); +} + +// Fixed, test-owned tool catalog for the in-turn-compaction geometry tests. +// Those tests size skill bodies against the always-on `toolSchemaTokens` +// floor; growing any always-on tool description (as create_job's did) would +// otherwise shift that floor and move the compaction crossing point opaquely. +// Installing this constant via __setBaseToolCatalogForTests pins the floor so +// no live-catalog change can perturb the geometry. +// +// Shape: `read_skill` (the only tool the compaction scripts dispatch — the +// loop must run to completion with it) plus uniform filler tools whose count +// is chosen so the serialized provider catalog tokenizes to a known floor. +// Measured floor: toolSchemaTokens = ceil(JSON.stringify(toProviderTools)/4) +// = 12,207 tokens (read_skill ≈ 90 tokens + 47 filler tools ≈ 258 tokens +// each). The high-water mark is floor(32,000 × 0.85) = 27,200 under the echo +// provider (no usage → promptTokenEstimateGap 0), so the message budget +// before the mark is 27,200 − 12,207 ≈ 14,993 tokens. Each in-turn exchange +// is one ~8,600-char read_skill result (≈ 2,150 tokens) plus its assistant +// tool_call row; with the protected head + system prompt, six accumulated +// results cross the mark before the 7th call. The filler count is the ONE +// knob that sets the floor; bodies below are derived from it. +const FIXED_COMPACTION_CATALOG_FILLER = 47; +const FIXED_COMPACTION_CATALOG: ToolCatalogTool[] = [ + { + toolset: "skills", + type: "function", + function: { + name: "read_skill", + description: "Return the full body of an enabled skill by name.", + parameters: { + type: "object", + properties: { name: { type: "string", description: "Skill name." } }, + required: ["name"] + } + } + }, + ...Array.from({ length: FIXED_COMPACTION_CATALOG_FILLER }, (_unused, i) => ({ + toolset: "core", + type: "function" as const, + function: { + name: `fixed_floor_tool_${i}`, + description: "X".repeat(900), + parameters: { type: "object", properties: {}, required: [] as string[] } + } + })) +]; + +// Poll cadence for the terminal-state waiters. The task settles on a detached +// microtask/timer, so the wait resolves at the NEXT poll after it lands — a +// tight interval keeps the wall-clock cost near the real settle time instead of +// rounding every wait up to a coarse tick. Kept as a named constant so the +// cadence is tunable in one place (and small enough that 100+ sequential waits +// don't dominate the suite). +const TERMINAL_POLL_MS = 2; + +async function waitForTerminal(config: RuntimeConfig, taskId: string, timeoutMs = 5000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const state = readState(config.instance); + const task = state.tasks.find((t) => t.id === taskId); + if (task && (task.status === "completed" || task.status === "failed" || task.status === "cancelled" || task.status === "waiting_approval" || task.status === "needs_input")) { + return task; + } + await Bun.sleep(TERMINAL_POLL_MS); + } + throw new Error(`Task ${taskId} did not reach terminal state within ${timeoutMs}ms`); +} + +// Post-approve wait: decideApproval("approve") returns once the decision is +// durable while the side effect + resume run detached, so the task is still +// parked at waiting_approval when the call returns — waitForTerminal's +// parked-inclusive accept set would return the pre-approve park immediately. +// Poll for a genuinely final status instead. +async function waitForFinalTerminal(config: RuntimeConfig, taskId: string, timeoutMs = 5000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const state = readState(config.instance); + const task = state.tasks.find((t) => t.id === taskId); + if (task && (task.status === "completed" || task.status === "failed" || task.status === "cancelled")) { + return task; + } + await Bun.sleep(TERMINAL_POLL_MS); + } + throw new Error(`Task ${taskId} did not reach a final terminal state within ${timeoutMs}ms`); +} + +describe("chat-task loop", () => { + let root: string; + let prevState: string | undefined; + let prevLog: string | undefined; + let prevTransientBackoff: string | undefined; + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "gini-chat-task-")); + prevState = process.env.GINI_STATE_ROOT; + prevLog = process.env.GINI_LOG_ROOT; + prevTransientBackoff = process.env.GINI_TRANSIENT_RETRY_BASE_MS; + process.env.GINI_STATE_ROOT = root; + process.env.GINI_LOG_ROOT = `${root}-logs`; + // Run the transient-retry backoff at 0ms so these tests never burn real + // wall-clock sleep (the production curve is verified separately by the + // trace-message assertions); keeps the suite fast and deterministic. + process.env.GINI_TRANSIENT_RETRY_BASE_MS = "0"; + clearEchoToolCallingResponses(); + clearEchoAuxTextResponses(); + }); + + afterEach(() => { + if (prevState === undefined) delete process.env.GINI_STATE_ROOT; + else process.env.GINI_STATE_ROOT = prevState; + if (prevLog === undefined) delete process.env.GINI_LOG_ROOT; + else process.env.GINI_LOG_ROOT = prevLog; + if (prevTransientBackoff === undefined) delete process.env.GINI_TRANSIENT_RETRY_BASE_MS; + else process.env.GINI_TRANSIENT_RETRY_BASE_MS = prevTransientBackoff; + // Clear any fixed-catalog override a compaction test installed so the + // rest of the suite sees the live buildToolCatalog. + __setBaseToolCatalogForTests(null); + rmSync(root, { recursive: true, force: true }); + clearEchoToolCallingResponses(); + clearEchoAuxTextResponses(); + }); + + test("falls back to a final answer when the model emits no tool calls", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + const config = buildConfig(workspaceRoot, "chat-task-direct"); + const provider = normalizeProvider(config.provider); + + setEchoToolCallingResponse({ + provider, + text: "Sure, here's a direct answer.", + toolCalls: [], + finishReason: "stop" + }); + + const task = await submitTask(config, "say hi", { mode: "chat" }); + const finished = await waitForTerminal(config, task.id); + expect(finished.status).toBe("completed"); + expect(finished.summary).toBe("Sure, here's a direct answer."); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + test("resumeChatTask is a no-op for a task that has already failed", async () => { + // Standalone test of resumeChatTask's terminal-task guard. We construct + // a task in the failed state and call resumeChatTask directly; it must + // return without flipping the task back to running and without + // re-entering the loop. + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + const config = buildConfig(workspaceRoot, "chat-task-resume-failed"); + const provider = normalizeProvider(config.provider); + + setEchoToolCallingResponse({ + provider, + text: "", + toolCalls: [ + { id: "call_x", type: "function", function: { name: "file_write", arguments: JSON.stringify({ path: "x.txt", content: "X" }) } } + ], + finishReason: "tool_calls" + }); + + const task = await submitTask(config, "write x", { mode: "chat" }); + const paused = await waitForTerminal(config, task.id); + expect(paused.status).toBe("waiting_approval"); + const approvalId = paused.approvalIds[0]!; + + // Deny — fails the task and clears the snapshot. Deny resumes detached, so + // poll for the failed state instead of a fixed sleep. + await decideApproval(config, approvalId, "deny"); + const failedBefore = await waitForFinalTerminal(config, task.id); + expect(failedBefore.status).toBe("failed"); + + // Now call resumeChatTask directly. Must no-op. + const { resumeChatTask } = await import("../execution/chat-task"); + const result = await resumeChatTask(config, task.id, "call_x", "should-not-resume"); + expect(result.status).toBe("failed"); + + // Status / partialSummary unchanged after the no-op resume. + const after = readState(config.instance).tasks.find((t) => t.id === task.id)!; + expect(after.status).toBe("failed"); + expect(after.toolCallState).toBeUndefined(); + expect(existsSync(join(workspaceRoot, "x.txt"))).toBe(false); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + test("terminal_exec falls through to the approval gate when no allowlist pattern matches", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + const config = buildConfig(workspaceRoot, "chat-task-auto-approve-miss"); + config.autoApproveCommands = ["memo *"]; + const provider = normalizeProvider(config.provider); + + setEchoToolCallingResponse({ + provider, + text: "", + toolCalls: [ + { id: "call_miss", type: "function", function: { name: "terminal_exec", arguments: JSON.stringify({ command: "rm -rf /tmp/x" }) } } + ], + finishReason: "tool_calls" + }); + + const task = await submitTask(config, "rm something", { mode: "chat" }); + const paused = await waitForTerminal(config, task.id); + expect(paused.status).toBe("waiting_approval"); + expect(paused.approvalIds.length).toBe(1); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + test("an auth failure on the iteration-cap summary call persists the needs-reauth record", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + writeFileSync(join(workspaceRoot, "hello0.md"), "Hello (0)"); + writeFileSync(join(workspaceRoot, "hello1.md"), "Hello (1)"); + // The summary-failure path settles the task itself (it does not route + // through failTask), so it must write the persistent record on its own. + // Drive it with a REAL provider transport (openai + stubbed fetch): two + // streamed tool-call turns reach the cap, then the tool-less summary call + // gets a 401 whose body names a key fragment that must be redacted. + const config = buildConfig(workspaceRoot, "chat-task-reauth-summary", { + provider: { name: "openai", model: "gpt-test" }, + agent: { maxIterations: 2 } + }); + + const prevKey = process.env.OPENAI_API_KEY; + const prevEmbed = process.env.GINI_EMBEDDING_PROVIDER; + const originalFetch = globalThis.fetch; + process.env.OPENAI_API_KEY = "sk-test-summary"; + // Pin embeddings to the in-process echo provider so memory recall never + // routes through the stubbed fetch. + process.env.GINI_EMBEDDING_PROVIDER = "echo"; + + const sseToolCall = (i: number): Response => { + const events = [ + { choices: [{ index: 0, delta: { tool_calls: [{ index: 0, id: `call_s${i}`, type: "function", function: { name: "file_read", arguments: "" } }] } }] }, + { choices: [{ index: 0, delta: { tool_calls: [{ index: 0, function: { arguments: JSON.stringify({ path: `hello${i}.md` }) } }] } }] }, + { choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }] } + ]; + const stream = new ReadableStream({ + start(controller) { + const enc = new TextEncoder(); + for (const e of events) controller.enqueue(enc.encode(`data: ${JSON.stringify(e)}\n\n`)); + controller.enqueue(enc.encode("data: [DONE]\n\n")); + controller.close(); + } + }); + return new Response(stream, { status: 200, headers: { "content-type": "text/event-stream" } }); + }; + let calls = 0; + globalThis.fetch = ((() => { + calls += 1; + if (calls <= 2) return Promise.resolve(sseToolCall(calls - 1)); + // The tool-less summary call is non-streaming; reject it like a dead + // credential would. + return Promise.resolve(new Response( + JSON.stringify({ error: { message: "Incorrect API key provided: sk-livefail123456" } }), + { status: 401, headers: { "content-type": "application/json" } } + )); + }) as unknown) as typeof fetch; + + try { + const task = await submitTask(config, "loop forever", { mode: "chat" }); + const finished = await waitForTerminal(config, task.id, 10000); + + expect(finished.status).toBe("failed"); + expect(finished.authErrorProvider).toBe("openai"); + expect(finished.error).toBe("Incorrect API key provided: sk-***"); + + const state = readState(config.instance); + expect(state.providerAuthFailures?.openai).toMatchObject({ + provider: "openai", + detail: "Incorrect API key provided: sk-***", + taskId: task.id + }); + // The raw key fragment never lands in state.json. + expect(JSON.stringify(state.providerAuthFailures)).not.toContain("sk-livefail123456"); + expect( + state.audit.find((a) => a.action === "provider.auth.needs_reauth" && a.target === "openai") + ).toBeDefined(); + } finally { + globalThis.fetch = originalFetch; + if (prevKey === undefined) delete process.env.OPENAI_API_KEY; + else process.env.OPENAI_API_KEY = prevKey; + if (prevEmbed === undefined) delete process.env.GINI_EMBEDDING_PROVIDER; + else process.env.GINI_EMBEDDING_PROVIDER = prevEmbed; + rmSync(workspaceRoot, { recursive: true, force: true }); + } + }); + + // Loop-breaker (identical-repeat). A model that emits the IDENTICAL tool + // call and gets the IDENTICAL result several iterations in a row is stuck; + // the loop must stop at MAX_IDENTICAL_TOOL_REPEATS (3) — well before the + // iteration cap — and route to the same graceful tool-less summary exit. + // We drive a cold browser_connect (no page open → deterministic ok:false + // guard refusal every turn) to reproduce the real stuck-loop scenario. + test("invalid agent.maxIterations falls back to the default and emits a warning trace", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + const config = buildConfig(workspaceRoot, "chat-task-cap-invalid"); + // Invalid: 0 is non-positive. Loose-typed cast so the test can simulate + // a config.json that was hand-edited with a bad value. + (config as unknown as { agent: { maxIterations: number } }).agent = { maxIterations: 0 }; + const provider = normalizeProvider(config.provider); + + setEchoToolCallingResponse({ + provider, + text: "Direct answer.", + toolCalls: [], + finishReason: "stop" + }); + + const task = await submitTask(config, "say something", { mode: "chat" }); + const finished = await waitForTerminal(config, task.id); + + expect(finished.status).toBe("completed"); + expect(finished.summary).toBe("Direct answer."); + + const { readTrace } = await import("../state"); + const traces = readTrace(config.instance, task.id); + const warning = traces.find( + (t) => t.type === "warning" && /agent\.maxIterations/i.test(String(t.data?.reason ?? "")) + ); + expect(warning).toBeDefined(); + expect((warning?.data as Record | undefined)?.defaultCap).toBe(200); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + test("non-numeric agent.maxIterations also falls back with a warning", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + const config = buildConfig(workspaceRoot, "chat-task-cap-invalid-string"); + (config as unknown as { agent: { maxIterations: string } }).agent = { maxIterations: "abc" }; + const provider = normalizeProvider(config.provider); + + setEchoToolCallingResponse({ + provider, + text: "Hello.", + toolCalls: [], + finishReason: "stop" + }); + + const task = await submitTask(config, "say hi again", { mode: "chat" }); + const finished = await waitForTerminal(config, task.id); + + expect(finished.status).toBe("completed"); + + const { readTrace } = await import("../state"); + const traces = readTrace(config.instance, task.id); + const warning = traces.find( + (t) => t.type === "warning" && /agent\.maxIterations/i.test(String(t.data?.reason ?? "")) + ); + expect(warning).toBeDefined(); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + // Bound-jobs system block: when the chat session backing the current task + // has one or more JobRecords whose `chatSessionId` matches, the chat-task + // loop must surface them in the system prompt. The model uses that block + // to short-circuit list_jobs and call update_job / delete_job directly. + test("does NOT suppress when the final text merely contains [SILENT]", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + const config = buildConfig(workspaceRoot, "chat-task-silent-contains"); + const provider = normalizeProvider(config.provider); + + const sessionId = await mutateState(config.instance, (state) => { + const session = createChatSession(state, "Inbox triage"); + const run = createRun(state, { + kind: "conversation_turn", + title: "Watcher turn", + input: "kick off", + conversationId: session.id + }); + return { runId: run.id, sessionId: session.id }; + }); + + setEchoToolCallingResponse({ + provider, + text: "[SILENT] but here's an update", + toolCalls: [], + finishReason: "stop" + }); + + const task = await submitTask(config, "anything new?", { mode: "chat", runId: sessionId.runId, chatSessionId: sessionId.sessionId }); + const finished = await waitForTerminal(config, task.id); + expect(finished.status).toBe("completed"); + + const blocks = listChatBlocks(config.instance, sessionId.sessionId); + const assistantText = blocks.filter((b) => b.kind === "assistant_text"); + expect(assistantText).toHaveLength(1); + expect(assistantText[0]).toMatchObject({ text: "[SILENT] but here's an update" }); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + test("derives the surface line from a bridge session without a client field", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + const config = buildConfig(workspaceRoot, "chat-task-surface-bridge"); + const provider = normalizeProvider(config.provider); + const sessionId = await mutateState(config.instance, (state) => { + const session = createChatSession(state, "Telegram probe", { + kind: "telegram", + bridgeId: "bridge_1", + chatId: 42, + target: "42" + }); + return session.id; + }); + + setEchoToolCallingResponse({ provider, text: "ok", toolCalls: [], finishReason: "stop" }); + const submitted = await submitChatMessage(config, sessionId, { content: "bridge turn" }); + expect((await waitForTerminal(config, submitted.taskId)).status).toBe("completed"); + + const turn = getEchoToolCallingCalls()[0]!; + const userIdx = turn.findIndex((m) => m.role === "user" && m.content === "bridge turn"); + expect(userIdx).toBeGreaterThan(0); + const tail = String(turn[userIdx - 1]!.content ?? ""); + expect(tail).toContain("The user is messaging from Telegram"); + expect(tail).toContain("a browser handoff can't reach them"); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + test("delivers emitted identity then recalled memory in a role:user tail before the user message", async () => { + // Both per-turn blocks must still reach the model: the emitted identity + // (turn 1 of a session) and the recalled-memory block, in that order, + // inside a single role:"user" message placed immediately before the real + // user message. Recall is driven through the real pipeline with the echo + // embedder and the reranker pinned to `none` so it returns the seeded + // unit deterministically and offline. + const prevEmbed = process.env.GINI_EMBEDDING_PROVIDER; + const prevReranker = process.env.GINI_RERANKER_PROVIDER; + process.env.GINI_EMBEDDING_PROVIDER = "echo"; + process.env.GINI_RERANKER_PROVIDER = "none"; + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + const config = buildConfig(workspaceRoot, "chat-task-tail-delivery"); + const provider = normalizeProvider(config.provider); + const MEMORY_TEXT = "the user keeps bees on a rooftop in Lisbon"; + + try { + // Active agent so resolveEffectiveContext yields a memory namespace and + // recall runs; seed one matching unit into that agent's bank. + const agentId = "agent_tail"; + const sessionId = await mutateState(config.instance, (state) => { + state.agents.push({ + id: agentId, + instance: state.instance, + name: "tail", + providerName: "echo", + model: "gini-echo-v0", + toolsets: [], + messagingTargets: [], + status: "active", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z" + }); + state.activeAgentId = agentId; + const session = createChatSession(state, "Tail delivery"); + return session.id; + }); + ensureDefaultBank(config.instance); + ensureAgentBank(config.instance, agentId); + insertMemoryUnit(config.instance, { + bankId: bankIdForAgent(agentId), + agentId, + text: MEMORY_TEXT, + embedding: echoEmbed(MEMORY_TEXT), + embeddingModel: "echo-embed-v0", + network: "world" + }); + + const runId = await mutateState(config.instance, (state) => { + const run = createRun(state, { + kind: "conversation_turn", + title: "Tail turn", + input: "intro", + conversationId: sessionId + }); + return run.id; + }); + + setEchoToolCallingResponse({ provider, text: "Noted.", toolCalls: [], finishReason: "stop" }); + const task = await submitTask(config, MEMORY_TEXT, { mode: "chat", runId }); + const finished = await waitForTerminal(config, task.id); + expect(finished.status).toBe("completed"); + + const calls = getEchoToolCallingCalls(); + expect(calls.length).toBeGreaterThan(0); + const turn = calls[0]!; + // The stable system prefix carries neither block anymore. + const systemContent = String(turn.find((m) => m.role === "system")?.content ?? ""); + expect(systemContent).not.toContain("Your runtime identity:"); + expect(systemContent).not.toContain("Long-term memory of prior conversations"); + // The tail is the role:"user" message immediately before the real input. + const userIdx = turn.findIndex((m) => m.role === "user" && m.content === MEMORY_TEXT); + expect(userIdx).toBeGreaterThan(0); + const tail = turn[userIdx - 1]!; + expect(tail.role).toBe("user"); + const tailContent = String(tail.content ?? ""); + const identityIdx = tailContent.indexOf("Your runtime identity:"); + const memoryIdx = tailContent.indexOf("Long-term memory of prior conversations"); + expect(identityIdx).toBeGreaterThanOrEqual(0); + expect(memoryIdx).toBeGreaterThanOrEqual(0); + expect(tailContent).toContain(MEMORY_TEXT); + // Identity before memory, mirroring the old system-prompt order. + expect(identityIdx).toBeLessThan(memoryIdx); + } finally { + if (prevEmbed === undefined) delete process.env.GINI_EMBEDDING_PROVIDER; + else process.env.GINI_EMBEDDING_PROVIDER = prevEmbed; + if (prevReranker === undefined) delete process.env.GINI_RERANKER_PROVIDER; + else process.env.GINI_RERANKER_PROVIDER = prevReranker; + rmSync(workspaceRoot, { recursive: true, force: true }); + } + }); + + test("emits authorization_requested with the action field for gated tools", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + const config = buildConfig(workspaceRoot, "chat-task-blocks-approval"); + const provider = normalizeProvider(config.provider); + + const session = await mutateState(config.instance, (state) => + createChatSession(state, "block-approval", undefined, "agent_y") + ); + + setEchoToolCallingResponse({ + provider, + text: "", + toolCalls: [ + { id: "call_w", type: "function", function: { name: "file_write", arguments: JSON.stringify({ path: "out.txt", content: "hi" }) } } + ], + finishReason: "tool_calls" + }); + setEchoToolCallingResponse({ + provider, + text: "Wrote it.", + toolCalls: [], + finishReason: "stop" + }); + + const submitted = await submitChatMessage(config, session.id, { content: "write out.txt" }); + const paused = await waitForTerminal(config, submitted.taskId); + expect(paused.status).toBe("waiting_approval"); + + const { listChatBlocks } = await import("../state"); + let blocks = listChatBlocks(config.instance, session.id); + const approval = blocks.find((b) => b.kind === "authorization_requested"); + if (approval?.kind === "authorization_requested") { + expect(approval.authorizationId).toBe(paused.approvalIds[0]); + expect(approval.action).toBe("file.write"); + expect(approval.risk).toBeDefined(); + } else { + throw new Error("missing authorization_requested block"); + } + + // Resume by approving; the tool_call flips ok and a tool_result lands. + await decideApproval(config, paused.approvalIds[0]!, "approve"); + const finished = await waitForFinalTerminal(config, submitted.taskId); + expect(finished.status).toBe("completed"); + + blocks = listChatBlocks(config.instance, session.id); + const toolCall = blocks.find((b) => b.kind === "tool_call"); + if (toolCall?.kind === "tool_call") { + expect(toolCall.status).toBe("ok"); + } else { + throw new Error("missing tool_call block"); + } + const toolResult = blocks.find((b) => b.kind === "tool_result"); + expect(toolResult).toBeDefined(); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + test("ask_user pauses the turn with a chat.choice setup card and no reason bubble", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + const config = buildConfig(workspaceRoot, "chat-task-blocks-choice"); + const provider = normalizeProvider(config.provider); + + const session = await mutateState(config.instance, (state) => + createChatSession(state, "block-choice", undefined, "agent_q") + ); + + const question = "How should I search the web?"; + setEchoToolCallingResponse({ + provider, + text: "", + toolCalls: [ + { + id: "call_q", + type: "function", + function: { + name: "ask_user", + arguments: JSON.stringify({ + question, + options: [ + { label: "Set up Brave only" }, + { label: "Neither — use web_fetch", description: "No setup needed" } + ] + }) + } + } + ], + finishReason: "tool_calls" + }); + + const submitted = await submitChatMessage(config, session.id, { content: "find the best cafe" }); + const paused = await waitForTerminal(config, submitted.taskId); + // An all-chat.choice park is reclassified as needs_input. + expect(paused.status).toBe("needs_input"); + + const setup = readState(config.instance).setupRequests.find((s) => s.taskId === submitted.taskId); + expect(setup?.action).toBe("chat.choice"); + expect(setup?.payload.question).toBe(question); + + const { listChatBlocks } = await import("../state"); + const blocks = listChatBlocks(config.instance, session.id); + const setupBlock = blocks.find((b) => b.kind === "setup_requested"); + if (setupBlock?.kind === "setup_requested") { + expect(setupBlock.action).toBe("chat.choice"); + // The summary IS the question — that's what transcripts/sessions show. + expect(setupBlock.summary).toBe(question); + } else { + throw new Error("missing setup_requested block"); + } + // Unlike connector.request, no assistant bubble accompanies the card — + // the question lives in the card itself. + expect(blocks.some((b) => b.kind === "assistant_text")).toBe(false); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + test("subagent tasks (no chat session) skip block emission entirely", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + const config = buildConfig(workspaceRoot, "chat-task-blocks-subagent"); + const provider = normalizeProvider(config.provider); + + setEchoToolCallingResponse({ + provider, + text: "Subagent done.", + toolCalls: [], + finishReason: "stop" + }); + + // Submit a task without a chatSessionId — equivalent to a subagent + // child or a CLI imperative task. The loop should run to + // completion but no chat_blocks rows should land. + const task = await submitTask(config, "subagent prompt", { mode: "chat" }); + const finished = await waitForTerminal(config, task.id); + expect(finished.status).toBe("completed"); + + // No session => no rows in chat_blocks for this task. We can't + // grep by taskId alone (no helper exposed) so just confirm we + // wrote zero rows by asking the DB directly via getMemoryDb. + const { getMemoryDb } = await import("../state"); + const db = getMemoryDb(config.instance); + const count = db + .query<{ c: number }, [string]>("SELECT COUNT(*) AS c FROM chat_blocks WHERE task_id = ?") + .get(task.id)?.c ?? 0; + expect(count).toBe(0); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + test("a read_skill body from a prior turn persists into the next turn's transcript", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + const config = buildConfig(workspaceRoot, "chat-task-transcript-skill"); + const provider = normalizeProvider(config.provider); + + const skill = await createSkillFromInput(config, { + name: "apple-notes", + description: "Apple Notes via memo CLI." + }); + const skillBody = "# Apple Notes\n\nUse `memo notes -a` to add a note."; + await mutateState(config.instance, (state) => { + const item = state.skills.find((s) => s.id === skill.id)!; + item.body = skillBody; + }); + await setSkillStatus(config, skill.id, "enabled"); + + const { createChat, syncChatTaskResult } = await import("./chat"); + const session = await createChat(config, { title: "Skill thread" }); + + // Turn 1: read the skill, then answer. + setEchoToolCallingResponse({ + provider, + text: "", + toolCalls: [ + { id: "call_skill", type: "function", function: { name: "read_skill", arguments: JSON.stringify({ name: "apple-notes" }) } } + ], + finishReason: "tool_calls" + }); + setEchoToolCallingResponse({ + provider, + text: "I now know how to use Apple Notes.", + toolCalls: [], + finishReason: "stop" + }); + + const first = await submitChatMessage(config, session.id, { content: "how do I add an apple note?" }); + await waitForTerminal(config, first.taskId); + await syncChatTaskResult(config, session.id, first.taskId); + + // Turn 2: a follow-up. The skill body must be in the replayed transcript + // so the model need not re-read it (Claude Code skill behavior). + setEchoToolCallingResponse({ + provider, + text: "Adding your note now.", + toolCalls: [], + finishReason: "stop" + }); + const second = await submitChatMessage(config, session.id, { content: "add a note that says hi" }); + await waitForTerminal(config, second.taskId); + + const calls = getEchoToolCallingCalls(); + const turn2 = calls.find((messages) => + messages.some((m) => m.role === "user" && m.content === "add a note that says hi") + ); + expect(turn2).toBeDefined(); + const skillToolResult = turn2!.find((m) => m.role === "tool" && m.tool_call_id === "call_skill"); + expect(skillToolResult).toBeDefined(); + expect(String(skillToolResult!.content)).toContain("memo notes -a"); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + test("loaded deferred tool set persists across an approval pause/resume and dispatches after resume", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + // strict so set_provider gates instead of auto-resolving. + const config = buildConfig(workspaceRoot, "chat-task-deferred-resume"); + const provider = normalizeProvider(config.provider); + + // Turn 1: load set_provider AND get_self. + setEchoToolCallingResponse({ + provider, + text: "", + toolCalls: [ + { id: "call_load", type: "function", function: { name: "load_tools", arguments: JSON.stringify({ names: ["set_provider", "get_self"] }) } } + ], + finishReason: "tool_calls" + }); + // Turn 2: call set_provider directly (top-level args). Strict → pauses. + setEchoToolCallingResponse({ + provider, + text: "", + toolCalls: [ + { id: "call_sp", type: "function", function: { name: "set_provider", arguments: JSON.stringify({ provider: "echo" }) } } + ], + finishReason: "tool_calls" + }); + // Turn 3 (the RESUMED turn): call the previously-loaded get_self directly. + // It's a query op → resolves sync (no second gate) and writes a self.get + // audit row, proving the loaded schema survived the resume. + setEchoToolCallingResponse({ + provider, + text: "", + toolCalls: [ + { id: "call_gs", type: "function", function: { name: "get_self", arguments: "{}" } } + ], + finishReason: "tool_calls" + }); + // Turn 4: final answer. + setEchoToolCallingResponse({ + provider, + text: "Provider set.", + toolCalls: [], + finishReason: "stop" + }); + + const task = await submitTask(config, "switch to echo", { mode: "chat" }); + const paused = await waitForTerminal(config, task.id, 10000); + expect(paused.status).toBe("waiting_approval"); + // The loaded set survived onto the task across the pause snapshot. + expect(paused.loadedTools).toContain("set_provider"); + expect(paused.loadedTools).toContain("get_self"); + expect(paused.toolCallState).toBeDefined(); + expect(paused.toolCallState?.pending[0]?.toolName).toBe("set_provider"); + expect(paused.approvalIds.length).toBe(1); + + // Approve → resume. runLoop re-seeds loadedToolNames from task.loadedTools + // so get_self is live again on the resumed iteration. + await decideApproval(config, paused.approvalIds[0]!, "approve"); + const finished = await waitForFinalTerminal(config, task.id, 10000); + expect(finished.status).toBe("completed"); + expect(finished.summary).toBe("Provider set."); + // get_self dispatched on the resumed turn — proving the loaded deferred + // tool was in providerTools post-resume (not nudged as unloaded). + const state = readState(config.instance); + const reads = state.audit.filter((a) => a.action === "self.get" && a.taskId === task.id); + expect(reads).toHaveLength(1); + // Cleared on terminal completion. + expect(finished.loadedTools).toBeUndefined(); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + // Loop gate, never-loaded case: the model emits a deferred tool it never + // loaded. The chat-task loop gate (NOT the dispatcher's default-case + // backstop, which browser_snapshot never reaches — it has its own dispatch + // case) must block execution and feed back a "not loaded yet" nudge, and the + // loop must continue to a final answer. We assert NO browser.snapshot audit + // row exists (proving the thunk never ran). + test("inline load_tools persists a paired tool_result row in the durable transcript", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + // This turn opens memory.db (recall + auto-retain), and memory-db.ts caches + // SQLite handles by instance NAME across the process. Derive the instance + // from the unique mkdtemp basename so a rerun in the same worker can't reuse + // a cached handle pointing at this run's already-removed state dir. + const config = buildConfig(workspaceRoot, `chat-task-inline-persist-${basename(workspaceRoot)}`); + const provider = normalizeProvider(config.provider); + + const sessionId = await mutateState(config.instance, (state) => + createChatSession(state, "Inline persist").id + ); + + // Turn 1: an inline load_tools call. Turn 2: a tool-less final answer. + setEchoToolCallingResponse({ + provider, + text: "", + toolCalls: [ + { id: "call_inline_load", type: "function", function: { name: "load_tools", arguments: JSON.stringify({ names: ["browser_snapshot"] }) } } + ], + finishReason: "tool_calls" + }); + setEchoToolCallingResponse({ provider, text: "Ready.", toolCalls: [], finishReason: "stop" }); + + const task = await submitTask(config, "load the browser tools", { mode: "chat", chatSessionId: sessionId }); + const finished = await waitForTerminal(config, task.id, 10000); + expect(finished.status).toBe("completed"); + + const durable = readState(config.instance).chatMessages.filter( + (m) => m.sessionId === sessionId && m.kind === "tool_transcript" + ); + // The assistant tool_use row was persisted... + const assistantCall = durable.find( + (m) => m.role === "assistant" && (m.toolCalls ?? []).some((c) => c.id === "call_inline_load") + ); + expect(assistantCall).toBeDefined(); + // ...AND its inline result is now a paired durable row (the fix). + const pairedResult = durable.find((m) => m.role === "tool" && m.toolCallId === "call_inline_load"); + expect(pairedResult).toBeDefined(); + expect(String(pairedResult?.content)).toContain("callable directly"); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + // Fan-out watch worker history. A session-bound subagent (chatSessionId set, + // no run.conversationId — exactly how dispatchFanOut spawns a concern-channel + // worker) must land its turn in the channel's durable chatMessages so a later + // turn in the same channel replays the draft instead of seeing empty history. + test("iteration-cap summary exit persists the durable answer row", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + const config = buildConfig(workspaceRoot, "chat-task-cap-answer-row"); + config.agent = { maxIterations: 3 }; + const provider = normalizeProvider(config.provider); + + const sessionId = await mutateState(config.instance, (state) => { + const session = createChatSession(state, "Cap chat"); + return session.id; + }); + + // Three distinct tool-call turns consume the cap without tripping the + // identical-repeat loop-breaker, then the tool-less summary turn fires. + for (let i = 0; i < 3; i++) { + writeFileSync(join(workspaceRoot, `cap${i}.md`), `cap content (${i})`); + setEchoToolCallingResponse({ + provider, + text: "", + toolCalls: [ + { + id: `call_cap_${i}`, + type: "function", + function: { name: "file_read", arguments: JSON.stringify({ path: `cap${i}.md` }) } + } + ], + finishReason: "tool_calls" + }); + } + setEchoToolCallingResponse({ + provider, + text: "Cap summary: I read three files but could not finish.", + toolCalls: [], + finishReason: "stop" + }); + + const task = await submitTask(config, "loop forever", { mode: "chat", chatSessionId: sessionId }); + const finished = await waitForTerminal(config, task.id, 10000); + expect(finished.status).toBe("completed"); + expect(finished.currentStep).toBe("Completed (iteration cap reached: 3)"); + + const answerRows = readState(config.instance).chatMessages.filter( + (m) => m.sessionId === sessionId && m.role === "assistant" && m.kind !== "tool_transcript" && m.kind !== "approval_reason" + ); + expect(answerRows.length).toBe(1); + expect(answerRows[0]!.content).toBe("Cap summary: I read three files but could not finish."); + expect(answerRows[0]!.taskId).toBe(task.id); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + // The context-exhaustion partial-result exit also completes with a real + // user-facing summary and must persist the same durable answer row. + test("inflated provider-reported prompt tokens engage the trim path on the next iteration", async () => { + const ELISION_MARKER = + "[Earlier tool result elided to fit the context window. Re-run the tool if you still need its output.]"; + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + const config = buildConfig(workspaceRoot, "chat-task-usage-trim"); + const provider = normalizeProvider(config.provider); + await mutateState(config.instance, (state) => { + for (const toolset of state.toolsets) toolset.status = "disabled"; + }); + + // Twelve tool-call turns reading DISTINCT files (so no loop-breaker + // trips), each result ~3k chars — elidable (>200 chars) but the total + // stays under every estimate-driven threshold. Only the LAST response + // reports usage; the resulting calibration gap forces the pre-call trim + // ahead of the 13th call. The per-read filler is sized so the accumulated + // transcript sits below the chars/4 high-water mark given the always-on + // tool-schema floor and system-prompt slice. + for (let i = 0; i < 12; i++) { + writeFileSync(join(workspaceRoot, `chunk${i}.md`), `chunk-${i} `.repeat(325)); + setEchoToolCallingResponse({ + provider, + text: "", + toolCalls: [ + { id: `call_u${i}`, type: "function", function: { name: "file_read", arguments: JSON.stringify({ path: `chunk${i}.md` }) } } + ], + finishReason: "tool_calls", + ...(i === 11 ? { usage: { prompt_tokens: 29_600 } } : {}) + }); + } + setEchoToolCallingResponse({ + provider, + text: "Done reading.", + toolCalls: [], + finishReason: "stop" + }); + + const task = await submitTask(config, "read all the chunks", { mode: "chat" }); + const finished = await waitForTerminal(config, task.id, 10000); + expect(finished.status).toBe("completed"); + expect(finished.summary).toBe("Done reading."); + + // The 13th provider call (after the usage report) must see the oldest + // tool results elided — oldest-first, with the recent tail untouched — + // and the trim must be pure pruning (no aux summarization). + const calls = getEchoToolCallingCalls(); + expect(calls.length).toBe(13); + const finalToolMessages = calls[12]!.filter((m) => m.role === "tool"); + expect(finalToolMessages.length).toBe(12); + const markerCount = finalToolMessages.filter((m) => m.content === ELISION_MARKER).length; + expect(markerCount).toBeGreaterThanOrEqual(2); + for (let i = 0; i < finalToolMessages.length; i++) { + if (i < markerCount) expect(finalToolMessages[i]!.content).toBe(ELISION_MARKER); + else expect(finalToolMessages[i]!.content).toContain(`chunk-${i}`); + } + expect(getEchoAuxTextRequests().length).toBe(0); + // No call BEFORE the usage report saw any elision. + for (let c = 0; c < 12; c++) { + expect(calls[c]!.some((m) => m.content === ELISION_MARKER)).toBe(false); + } + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + // Reactive overflow recovery: a provider that rejects the prompt as too + // long gets a compacted transcript on retry (bounded attempts). Two + // overflow failures followed by a success must complete the task with the + // retried call's answer — and the retried call must carry elided results. + test("partial exit never resurrects a prior turn's answer and emits the note once", async () => { + const OVERFLOW_MESSAGE = "prompt is too long: 250000 tokens > 200000 maximum"; + const PARTIAL_NOTE = + "Stopped early: the conversation no longer fits the model's context window even after compaction. This is a partial result."; + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + const config = buildConfig(workspaceRoot, "chat-task-partial-prior-turn"); + const provider = normalizeProvider(config.provider); + const session = await mutateState(config.instance, (state) => + createChatSession(state, "partial-exit") + ); + + // Turn 1 completes normally with a distinctive answer. + setEchoToolCallingResponse({ provider, text: "PRIOR-TURN-ANSWER", toolCalls: [], finishReason: "stop" }); + const first = await submitChatMessage(config, session.id, { content: "first question" }); + const firstDone = await waitForTerminal(config, first.taskId, 10000); + expect(firstDone.summary).toBe("PRIOR-TURN-ANSWER"); + + // Turn 2: a narration-less tool turn, then persistent overflow. + writeFileSync(join(workspaceRoot, "note.md"), "note content"); + setEchoToolCallingResponse({ + provider, + text: "", + toolCalls: [ + { id: "call_p1", type: "function", function: { name: "file_read", arguments: JSON.stringify({ path: "note.md" }) } } + ], + finishReason: "tool_calls" + }); + setEchoToolCallingFailure(OVERFLOW_MESSAGE); + setEchoToolCallingFailure(OVERFLOW_MESSAGE); + setEchoToolCallingFailure(OVERFLOW_MESSAGE); + const second = await submitChatMessage(config, session.id, { content: "second question" }); + const finished = await waitForTerminal(config, second.taskId, 10000); + + expect(finished.status).toBe("completed"); + // Note-only: no narration happened this turn — the prior turn's answer + // must not be presented as this turn's partial result. + expect(finished.summary).toBe(PARTIAL_NOTE); + + // The note reaches the chat exactly once, as a system note. + const { listChatBlocks } = await import("../state"); + const blocks = listChatBlocks(config.instance, session.id); + const noteBlocks = blocks.filter((b) => JSON.stringify(b).includes("Stopped early")); + expect(noteBlocks.length).toBe(1); + expect(noteBlocks[0]!.kind).toBe("system_note"); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + // When the turn DID narrate before exhausting the window, the partial + // exit surfaces that narration (cleaned) with the note appended. + test("in-turn compaction bails gracefully when the savings are too small", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + const config = buildConfig(workspaceRoot, "chat-task-compaction-savings"); + const provider = normalizeProvider(config.provider); + await mutateState(config.instance, (state) => { + for (const toolset of state.toolsets) toolset.status = "disabled"; + }); + // Pin the tool-catalog floor so the crossing geometry is decoupled from + // live always-on catalog size (cleared in afterEach). + __setBaseToolCatalogForTests(FIXED_COMPACTION_CATALOG); + + // Exchange sizes: big, tiny, big, big. The middle span (everything + // between the protected first exchange and the protected last two) is + // ONLY the tiny exchange, so the summary cannot reclaim anything. + const bodies = [`BODY-0 ${"x".repeat(19_000)}`, "tiny note", `BODY-2 ${"x".repeat(19_000)}`, `BODY-3 ${"x".repeat(19_000)}`]; + for (let i = 0; i < bodies.length; i++) { + await seedBulkSkill(config, `bulk-skill-${i}`, bodies[i]!); + setEchoToolCallingResponse({ + provider, + text: "", + toolCalls: [ + { id: `call_v${i}`, type: "function", function: { name: "read_skill", arguments: JSON.stringify({ name: `bulk-skill-${i}` }) } } + ], + finishReason: "tool_calls" + }); + } + setEchoAuxTextResponse({ text: "TINY-SUMMARY" }); + + const task = await submitTask(config, "review the bulk skills", { mode: "chat" }); + const finished = await waitForTerminal(config, task.id, 10000); + + expect(finished.status).toBe("completed"); + expect(finished.currentStep).toBe("Completed (stopped: context window exhausted)"); + expect(finished.summary).toContain("could not reclaim enough"); + // One compaction was attempted (the tiny middle), then the bail fired — + // no further model calls. + expect(getEchoAuxTextRequests().length).toBe(1); + expect(getEchoToolCallingCalls().length).toBe(4); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + // The savings bail only applies while the projection is still ABOVE the + // high-water mark. A compaction that reclaims little in absolute terms + // but gets the next call back under the mark is a success — the turn + // must proceed, not exit one call short of finishing. + test("non-overflow provider errors still fail the task without retrying", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + const config = buildConfig(workspaceRoot, "chat-task-provider-error"); + + setEchoToolCallingFailure("upstream exploded (500)"); + + const task = await submitTask(config, "say hi", { mode: "chat" }); + const finished = await waitForTerminal(config, task.id, 10000); + + expect(finished.status).toBe("failed"); + expect(finished.error).toContain("upstream exploded"); + // Exactly one provider call — no retry on a non-overflow failure. + expect(getEchoToolCallingCalls().length).toBe(1); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + // A transient fault (dropped connection / OS timeout) is retried with bounded + // backoff: the conversation is valid, so the next attempt may simply succeed. + test("a cancel during the transient backoff wait bails to cancelled, not a retry", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + const config = buildConfig(workspaceRoot, `chat-task-transient-cancel-${basename(workspaceRoot)}`); + const provider = normalizeProvider(config.provider); + const { cancelTask } = await import("../agent"); + + // This is the ONE transient test that needs a real backoff window to cancel + // inside. Override the (otherwise-0ms) base to a long, fixed value so the + // cancel deterministically lands DURING the wait regardless of CI load — the + // test never actually waits it out (the cancel aborts the sleep at once). + // The retry warning is written synchronously right before the sleep starts, + // so once we observe it there is a full 60s window to cancel: no race. + const prevBase = process.env.GINI_TRANSIENT_RETRY_BASE_MS; + process.env.GINI_TRANSIENT_RETRY_BASE_MS = "60000"; + try { + // One transient failure arms the backoff; the success stub after it must + // NEVER run because we cancel during that backoff window. + setEchoToolCallingFailure("The operation timed out."); + setEchoToolCallingResponse({ provider, text: "SHOULD-NOT-APPEAR", toolCalls: [], finishReason: "stop" }); + + const task = await submitTask(config, "say hi", { mode: "chat" }); + // The transient-retry warning is appended right before the backoff sleep — + // poll for it, then cancel inside the (60s) backoff window. + const { readTrace } = await import("../state"); + const deadline = Date.now() + 5000; + let sawRetryWarning = false; + while (Date.now() < deadline) { + sawRetryWarning = readTrace(config.instance, task.id).some( + (t) => t.type === "warning" && /Transient model-call fault; retrying/.test(t.message) + ); + if (sawRetryWarning) break; + await Bun.sleep(5); + } + // Assert the retry actually armed BEFORE we cancel — otherwise a regression + // that never enters the backoff would let the cancel land on a non-backoff + // task and still satisfy the assertions below, silently masking the break. + expect(sawRetryWarning).toBe(true); + await cancelTask(config, task.id); + const cancelled = await waitForTerminal(config, task.id, 10000); + + expect(cancelled.status).toBe("cancelled"); + // The retry's success stub must not have run: only the failed first call. + expect(getEchoToolCallingCalls().length).toBe(1); + expect(cancelled.summary ?? "").not.toContain("SHOULD-NOT-APPEAR"); + } finally { + if (prevBase === undefined) delete process.env.GINI_TRANSIENT_RETRY_BASE_MS; + else process.env.GINI_TRANSIENT_RETRY_BASE_MS = prevBase; + } + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + // A context overflow is NOT a transient fault — it must take the compact-and- + // retry path on its own budget, never the transient-retry path. (The transient + // markers and the overflow markers are disjoint by construction.) +}); \ No newline at end of file diff --git a/packages/runtime/src/execution/chat-task.part4.test.ts b/packages/runtime/src/execution/chat-task.part4.test.ts new file mode 100644 index 00000000..67e67722 --- /dev/null +++ b/packages/runtime/src/execution/chat-task.part4.test.ts @@ -0,0 +1,1394 @@ +// End-to-end tests for the chat-task agent loop. +// +// We use the echo provider with stubbed tool-calling responses so the loop +// is fully deterministic. The test covers: +// - one tool call → result fed back → final answer +// - approval-gated tool call → task pauses with toolCallState +// - resume after approval → task completes +// +// HOME is pointed at a unique mkdtemp dir per test (same pattern as +// src/state/google-accounts.test.ts): the loop reads the machine-global +// Google account registry (buildConnectedAccountsBlock(readGoogleAccounts()) +// in the system prompt; isSkillActive via credentialExternallySatisfied), so +// a developer machine with registered accounts would otherwise shift the +// system-prompt size and skill activity these tests depend on. + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, join } from "node:path"; +import { + clearEchoAuxTextResponses, + clearEchoToolCallingResponses, + getEchoAuxTextRequests, + getEchoToolCallingCalls, + getEchoToolCallingToolNames, + setEchoAuxTextFailure, + setEchoAuxTextResponse, + setEchoToolCallingFailure, + setEchoToolCallingResponse, + normalizeProvider, + type MessageContentPart, + type ToolCallingMessage +} from "../provider"; +import { submitTask, decideApproval, resolveSetupRequest } from "../agent"; +import { + bankIdForAgent, + createChatMessage, + createChatSession, + createEmptyState, + createRun, + createSubagentRecord, + deleteChatSession, + ensureAgentBank, + ensureDefaultBank, + insertMemoryUnit, + listChatBlocks, + mutateState, + now, + readState, + recordProviderAuthFailure +} from "../state"; +import { echoEmbed } from "../embeddings"; +import { storeUpload } from "../state/uploads"; +import { resolveDefaultPriorContextTokenBudget } from "../provider-capabilities"; +import type { AgentIdentity, GoogleAccount, JobRecord, RuntimeConfig, RuntimeState, SkillRecord, Task, ToolsetRecord } from "../types"; +import { createSkillFromInput, setSkillStatus } from "../capabilities/skills"; +import { + __setBaseToolCatalogForTests, + buildAgentIdentity, + buildConnectedAccountsBlock, + buildEnabledSkillsBlock, + buildInactiveSkillsBlock, + buildMcpServersBlock, + buildSkillScriptsBlock, + compactionMiddleSpan, + elideOldToolResultsToBudget, + IN_TURN_COMPACTION_NOTE_PREFIX, + initialNavStallState, + nextNavStallState, + promptTokensFromUsage, + renderMessagesForCompaction +} from "./chat-task"; +import type { ToolCatalogTool } from "./tool-catalog"; +import type { EffectiveContext } from "./effective-context"; +import { __resetDefaultGiniInstructionsCacheForTest } from "../system-prompt"; + +// Compaction geometry in this file is calibrated against a FIXED instructions +// size (alongside the pinned tool catalog). The real default INSTRUCTIONS.md +// changes over time, which would otherwise shift the system-prompt slice and +// move the compaction crossing point opaquely. Pin a frozen fixture (byte-for- +// byte the default as of this calibration) so edits to the shipped default +// never perturb these tests. +const COMPACTION_INSTRUCTIONS_FIXTURE = join( + import.meta.dir, + "..", + "runtime", + "__fixtures__", + "instructions-compaction-geometry.md" +); + +// These tests submit on idle sessions, which always run immediately. They also +// don't seed a "chat-route" stub, so the router coerces to a chat-direct +// decision and the turn runs in the submitted session. Narrow the submit union +// to the chat-direct run-now branch so the existing `.taskId` reads stay typed +// (a queued or topic-dispatched result here is a test-setup bug). See ADR +// chat-message-queue.md. +async function submitChatMessage( + config: RuntimeConfig, + sessionId: string, + input: Record +): Promise<{ sessionId: string; runId?: string; taskId: string; status: Task["status"] }> { + const { submitChatMessage: submitChatMessageRaw } = await import("./chat"); + const { settleSubmittedChatMessage } = await import("./chat-test-support"); + const result = await submitChatMessageRaw(config, sessionId, input); + const settled = await settleSubmittedChatMessage( + config, + sessionId, + result, + String(input.content ?? "") + ); + if ("queued" in settled) throw new Error("expected run-now submission, got queued"); + if ("topicId" in settled) throw new Error("expected chat-direct submission, got topic dispatch"); + return settled; +} + +let scratchHome: string; +let prevHome: string | undefined; +let prevEmbedding: string | undefined; + +beforeEach(() => { + scratchHome = mkdtempSync(join(tmpdir(), "gini-chat-task-home-")); + prevHome = process.env.HOME; + process.env.HOME = scratchHome; + // Pin embeddings to echo: the local provider is unavailable under bun + // test, and the fallback chain otherwise picks the openai provider on + // machines with ~/.codex/auth.json (resolved via os.homedir(), which + // ignores the HOME override above) — turning every memory embed in the + // loop into a real network call. + prevEmbedding = process.env.GINI_EMBEDDING_PROVIDER; + process.env.GINI_EMBEDDING_PROVIDER = "echo"; + // Pin instructions to the frozen geometry fixture so the compaction tests' + // system-prompt slice is independent of edits to the shipped default. + __resetDefaultGiniInstructionsCacheForTest(COMPACTION_INSTRUCTIONS_FIXTURE); +}); + +afterEach(() => { + if (prevHome === undefined) delete process.env.HOME; + else process.env.HOME = prevHome; + if (prevEmbedding === undefined) delete process.env.GINI_EMBEDDING_PROVIDER; + else process.env.GINI_EMBEDDING_PROVIDER = prevEmbedding; + __resetDefaultGiniInstructionsCacheForTest(); + rmSync(scratchHome, { recursive: true, force: true }); +}); + +function buildConfig(workspaceRoot: string, instance: string, opts: Partial = {}): RuntimeConfig { + return { + instance, + port: 7338, + token: "test", + provider: { name: "echo", model: "" }, + workspaceRoot, + stateRoot: process.env.GINI_STATE_ROOT ?? "/tmp/gini-chat-task-test", + logRoot: process.env.GINI_LOG_ROOT ?? "/tmp/gini-chat-task-test-logs", + // These tests predate the approvalMode flip and pin the + // approval-gated loop behavior. Force "strict" so the chat-task + // loop continues to exercise the pause+resume path here; the new + // default-auto matrix lives in approval-mode.test.ts. + approvalMode: "strict", + ...opts + }; +} + +// Seed an enabled skill with an arbitrary (large) body. read_skill returns +// the body verbatim, which makes it the simplest way to drive big tool +// results through the loop without touching the filesystem caps. +async function seedBulkSkill(config: RuntimeConfig, name: string, body: string): Promise { + const skill = await createSkillFromInput(config, { name, description: `Bulk ${name}` }); + await mutateState(config.instance, (state) => { + const item = state.skills.find((s) => s.id === skill.id)!; + item.body = body; + }); + await setSkillStatus(config, skill.id, "enabled"); +} + +// Fixed, test-owned tool catalog for the in-turn-compaction geometry tests. +// Those tests size skill bodies against the always-on `toolSchemaTokens` +// floor; growing any always-on tool description (as create_job's did) would +// otherwise shift that floor and move the compaction crossing point opaquely. +// Installing this constant via __setBaseToolCatalogForTests pins the floor so +// no live-catalog change can perturb the geometry. +// +// Shape: `read_skill` (the only tool the compaction scripts dispatch — the +// loop must run to completion with it) plus uniform filler tools whose count +// is chosen so the serialized provider catalog tokenizes to a known floor. +// Measured floor: toolSchemaTokens = ceil(JSON.stringify(toProviderTools)/4) +// = 12,207 tokens (read_skill ≈ 90 tokens + 47 filler tools ≈ 258 tokens +// each). The high-water mark is floor(32,000 × 0.85) = 27,200 under the echo +// provider (no usage → promptTokenEstimateGap 0), so the message budget +// before the mark is 27,200 − 12,207 ≈ 14,993 tokens. Each in-turn exchange +// is one ~8,600-char read_skill result (≈ 2,150 tokens) plus its assistant +// tool_call row; with the protected head + system prompt, six accumulated +// results cross the mark before the 7th call. The filler count is the ONE +// knob that sets the floor; bodies below are derived from it. +const FIXED_COMPACTION_CATALOG_FILLER = 47; +const FIXED_COMPACTION_CATALOG: ToolCatalogTool[] = [ + { + toolset: "skills", + type: "function", + function: { + name: "read_skill", + description: "Return the full body of an enabled skill by name.", + parameters: { + type: "object", + properties: { name: { type: "string", description: "Skill name." } }, + required: ["name"] + } + } + }, + ...Array.from({ length: FIXED_COMPACTION_CATALOG_FILLER }, (_unused, i) => ({ + toolset: "core", + type: "function" as const, + function: { + name: `fixed_floor_tool_${i}`, + description: "X".repeat(900), + parameters: { type: "object", properties: {}, required: [] as string[] } + } + })) +]; + +// Poll cadence for the terminal-state waiters. The task settles on a detached +// microtask/timer, so the wait resolves at the NEXT poll after it lands — a +// tight interval keeps the wall-clock cost near the real settle time instead of +// rounding every wait up to a coarse tick. Kept as a named constant so the +// cadence is tunable in one place (and small enough that 100+ sequential waits +// don't dominate the suite). +const TERMINAL_POLL_MS = 2; + +async function waitForTerminal(config: RuntimeConfig, taskId: string, timeoutMs = 5000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const state = readState(config.instance); + const task = state.tasks.find((t) => t.id === taskId); + if (task && (task.status === "completed" || task.status === "failed" || task.status === "cancelled" || task.status === "waiting_approval" || task.status === "needs_input")) { + return task; + } + await Bun.sleep(TERMINAL_POLL_MS); + } + throw new Error(`Task ${taskId} did not reach terminal state within ${timeoutMs}ms`); +} + +// Post-approve wait: decideApproval("approve") returns once the decision is +// durable while the side effect + resume run detached, so the task is still +// parked at waiting_approval when the call returns — waitForTerminal's +// parked-inclusive accept set would return the pre-approve park immediately. +// Poll for a genuinely final status instead. +async function waitForFinalTerminal(config: RuntimeConfig, taskId: string, timeoutMs = 5000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const state = readState(config.instance); + const task = state.tasks.find((t) => t.id === taskId); + if (task && (task.status === "completed" || task.status === "failed" || task.status === "cancelled")) { + return task; + } + await Bun.sleep(TERMINAL_POLL_MS); + } + throw new Error(`Task ${taskId} did not reach a final terminal state within ${timeoutMs}ms`); +} + +describe("chat-task loop", () => { + let root: string; + let prevState: string | undefined; + let prevLog: string | undefined; + let prevTransientBackoff: string | undefined; + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "gini-chat-task-")); + prevState = process.env.GINI_STATE_ROOT; + prevLog = process.env.GINI_LOG_ROOT; + prevTransientBackoff = process.env.GINI_TRANSIENT_RETRY_BASE_MS; + process.env.GINI_STATE_ROOT = root; + process.env.GINI_LOG_ROOT = `${root}-logs`; + // Run the transient-retry backoff at 0ms so these tests never burn real + // wall-clock sleep (the production curve is verified separately by the + // trace-message assertions); keeps the suite fast and deterministic. + process.env.GINI_TRANSIENT_RETRY_BASE_MS = "0"; + clearEchoToolCallingResponses(); + clearEchoAuxTextResponses(); + }); + + afterEach(() => { + if (prevState === undefined) delete process.env.GINI_STATE_ROOT; + else process.env.GINI_STATE_ROOT = prevState; + if (prevLog === undefined) delete process.env.GINI_LOG_ROOT; + else process.env.GINI_LOG_ROOT = prevLog; + if (prevTransientBackoff === undefined) delete process.env.GINI_TRANSIENT_RETRY_BASE_MS; + else process.env.GINI_TRANSIENT_RETRY_BASE_MS = prevTransientBackoff; + // Clear any fixed-catalog override a compaction test installed so the + // rest of the suite sees the live buildToolCatalog. + __setBaseToolCatalogForTests(null); + rmSync(root, { recursive: true, force: true }); + clearEchoToolCallingResponses(); + clearEchoAuxTextResponses(); + }); + + test("read_skill returns the full body of an enabled skill", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + const config = buildConfig(workspaceRoot, "chat-task-readskill"); + const provider = normalizeProvider(config.provider); + + // Pre-create an enabled skill with a non-empty body — simulates a + // post-loadSkillsFromDisk state without exercising the loader here. + const skill = await createSkillFromInput(config, { + name: "apple-notes", + description: "Apple Notes via memo CLI." + }); + await mutateState(config.instance, (state) => { + const item = state.skills.find((s) => s.id === skill.id)!; + item.body = "# Apple Notes\n\nUse `memo notes -a` to add a note."; + }); + await setSkillStatus(config, skill.id, "enabled"); + + setEchoToolCallingResponse({ + provider, + text: "", + toolCalls: [ + { id: "call_skill", type: "function", function: { name: "read_skill", arguments: JSON.stringify({ name: "apple-notes" }) } } + ], + finishReason: "tool_calls" + }); + setEchoToolCallingResponse({ + provider, + text: "I now know how to use Apple Notes.", + toolCalls: [], + finishReason: "stop" + }); + + const task = await submitTask(config, "how do I add an apple note?", { mode: "chat" }); + const finished = await waitForTerminal(config, task.id); + + expect(finished.status).toBe("completed"); + expect(finished.summary).toBe("I now know how to use Apple Notes."); + // Audit trail captures the skill read. + const state = readState(config.instance); + const reads = state.audit.filter((a) => a.action === "skill.read" && a.taskId === task.id); + expect(reads).toHaveLength(1); + expect(reads[0]?.evidence?.name).toBe("apple-notes"); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + test("image attachment on a non-vision model proceeds and steers an in-band refusal", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + const config = buildConfig(workspaceRoot, "chat-task-image-no-vision"); + + // The echo provider resolves to vision:false. A PNG header is enough since + // buildAttachmentContent degrades the image to a text note without reading + // the bytes — it gates on mime alone, never emitting an image_url part. + const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x01]); + const upload = storeUpload(config.instance, png, "image/png", "pic.png"); + + const task = await submitTask(config, "see this", { + mode: "chat", + images: [{ id: upload.id, mimeType: "image/png", size: upload.size }] + }); + const finished = await waitForTerminal(config, task.id); + + // The turn is no longer hard-rejected: it runs to completion so the refusal + // is a normal, replayable assistant turn. + expect(finished.status).toBe("completed"); + + // The model saw the image degraded to a text note plus the steering + // directive, and never received an image_url part it would 400 on. + const turn = getEchoToolCallingCalls()[0]!; + const userMessage = turn.find((m) => m.role === "user" && Array.isArray(m.content))!; + const userParts = userMessage.content as MessageContentPart[]; + expect(userParts.every((p) => p.type !== "image_url")).toBe(true); + const userText = userParts + .filter((p): p is { type: "text"; text: string } => p.type === "text") + .map((p) => p.text) + .join("\n"); + expect(userText).toContain("You cannot see the image(s) above"); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + // PTY support: terminal_exec accepts an opt-in `pty: true` arg that wraps + // the command under a pseudo-terminal so interactive CLIs (vim, memo, + // claude-code) don't see "stdin is not a tty" and exit immediately. We + // verify the round-trip end-to-end: + // - the model emits terminal_exec with pty=true + // - the approval payload captures pty=true + // - executeApprovedAction spawns under a TTY (verified by `tty -s`) + // - the audit evidence carries pty=true so the user can see it + test("invalid tool args are reported back as tool errors so the model can recover", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + const config = buildConfig(workspaceRoot, "chat-task-baddargs"); + const provider = normalizeProvider(config.provider); + + setEchoToolCallingResponse({ + provider, + text: "", + toolCalls: [ + { id: "call_bad", type: "function", function: { name: "file_read", arguments: '{"oops":true}' } } + ], + finishReason: "tool_calls" + }); + setEchoToolCallingResponse({ + provider, + text: "Sorry, I goofed.", + toolCalls: [], + finishReason: "stop" + }); + + const task = await submitTask(config, "read something", { mode: "chat" }); + const finished = await waitForTerminal(config, task.id); + expect(finished.status).toBe("completed"); + expect(finished.summary).toBe("Sorry, I goofed."); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + // Iteration cap (graceful exhaustion). When the chat-task loop hits the + // configurable iteration cap, it must NOT fail. Instead it makes one + // final tool-less model call asking for a summary and completes with + // that text. A warning trace should record the cap hit. + test("stops at the identical-repeat loop-breaker and completes via a tool-less summary", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + const config = buildConfig(workspaceRoot, "chat-task-loop-breaker"); + const provider = normalizeProvider(config.provider); + + // Three identical iterations: same cold browser_connect args, same guard + // refusal each time. The third pass trips the loop-breaker (runLength 3). + for (let i = 0; i < 3; i++) { + setEchoToolCallingResponse({ + provider, + text: "", + toolCalls: [ + { + id: `call_repeat_${i}`, + type: "function", + function: { + name: "browser_connect", + arguments: JSON.stringify({ reason: "Sign in to Example", url: "https://example.com/login" }) + } + } + ], + finishReason: "tool_calls" + }); + } + // Tool-less summary turn — what the loop-breaker exit should consume. + setEchoToolCallingResponse({ + provider, + text: "That sign-in path keeps refusing. Try connecting the service from settings, then ask again.", + toolCalls: [], + finishReason: "stop" + }); + + const task = await submitTask(config, "sign in to example", { mode: "chat" }); + const finished = await waitForTerminal(config, task.id, 10000); + + expect(finished.status).toBe("completed"); + expect(finished.summary).toBe( + "That sign-in path keeps refusing. Try connecting the service from settings, then ask again." + ); + expect(finished.currentStep).toBe("Completed (stopped: tool loop made no progress)"); + expect(finished.error).toBeUndefined(); + + // Exactly four model calls: three repeated tool turns + one tool-less + // summary — proving we stopped at the loop-breaker, not the iteration cap. + const calls = getEchoToolCallingCalls(); + expect(calls.length).toBe(4); + // The final summary turn is the tool-less exit: its last message is the + // repeat-specific summary instruction asking for a final answer. + const summaryTurn = calls[3]!; + const lastMessage = summaryTurn[summaryTurn.length - 1]!; + expect(lastMessage.role).toBe("user"); + expect(String(lastMessage.content)).toContain("repeated the same tool call"); + + // Trace records the loop-breaker stop. + const { readTrace } = await import("../state"); + const traces = readTrace(config.instance, task.id); + const breaker = traces.find( + (t) => t.type === "warning" && /identical tool call\(s\) and result\(s\) \(loop-breaker\)/.test(t.message) + ); + expect(breaker).toBeDefined(); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + // Action-only loop-breaker. A model that emits the IDENTICAL tool call + // (same name + arguments) but gets a DIFFERENT result every iteration — + // the real browser_navigate case, where each live-page snapshot jitters — + // slips past the exact-match guard, so the coarser action-only guard must + // catch it at MAX_SAME_ACTION_REPEATS (6) instead of running to the iteration cap. + // We drive get_current_time, whose result (a timestamp) differs each call. + test("invalid agent.priorContextTokens falls back to the provider-derived default", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + const config = buildConfig(workspaceRoot, "chat-task-prior-context-invalid"); + (config as unknown as { agent: { priorContextTokens: number } }).agent = { priorContextTokens: 0 }; + const provider = normalizeProvider(config.provider); + const expectedDefault = resolveDefaultPriorContextTokenBudget(provider); + + setEchoToolCallingResponse({ + provider, + text: "Direct answer.", + toolCalls: [], + finishReason: "stop" + }); + + const task = await submitTask(config, "say something", { mode: "chat" }); + const finished = await waitForTerminal(config, task.id); + + expect(finished.status).toBe("completed"); + expect(finished.summary).toBe("Direct answer."); + + const { readTrace } = await import("../state"); + const traces = readTrace(config.instance, task.id); + const warning = traces.find( + (t) => t.type === "warning" && /agent\.priorContextTokens/i.test(String(t.data?.reason ?? "")) + ); + expect(warning).toBeDefined(); + expect((warning?.data as Record | undefined)?.defaultBudget).toBe(expectedDefault); + + const contextTrace = traces.find( + (t) => t.type === "model" && t.message === "chat-task system context built" + ); + const contextData = contextTrace?.data as Record | undefined; + expect(contextData?.priorContextTokenDefault).toBe(expectedDefault); + expect(contextData?.priorContextTokenRequested).toBe(expectedDefault); + expect(typeof contextData?.priorContextTokenAvailable).toBe("number"); + expect(contextData?.priorContextTokenBudget as number).toBeLessThanOrEqual(expectedDefault); + expect(contextData?.priorContextTokenBudget as number) + .toBeLessThanOrEqual(contextData?.priorContextTokenAvailable as number); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + test("appends a Bound scheduled jobs block when a job is bound to the session", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + const config = buildConfig(workspaceRoot, "chat-task-bound-job"); + const provider = normalizeProvider(config.provider); + + // Stand up a chat session, a job pointing at it, and a run that lives in + // the same conversation. The submitted task carries that runId so the + // chat-task loop resolves the session id via run.conversationId and + // finds the bound job during system-prompt assembly. + const { runId, jobId } = await mutateState(config.instance, (state) => { + const session = createChatSession(state, "Daily standup"); + const run = createRun(state, { + kind: "conversation_turn", + title: "Standup turn", + input: "kick off", + conversationId: session.id + }); + const at = now(); + const job: JobRecord = { + id: "job-standup", + instance: state.instance, + name: "Daily standup", + prompt: "Ask the team what they did yesterday and what they will do today.", + intervalSeconds: undefined, + status: "active", + deliveryTargets: [], + context: [], + retryLimit: 0, + timeoutSeconds: 600, + chatSessionId: session.id, + cronExpression: "0 9 * * *", + cronTimezone: "America/Los_Angeles", + createdAt: at, + updatedAt: at, + nextRunAt: at, + runCount: 0, + missedRuns: 0, + taskIds: [], + runIds: [] + }; + state.jobs.push(job); + return { runId: run.id, jobId: job.id }; + }); + + setEchoToolCallingResponse({ + provider, + text: "Ready.", + toolCalls: [], + finishReason: "stop" + }); + + const task = await submitTask(config, "remind me what this job does", { mode: "chat", runId }); + const finished = await waitForTerminal(config, task.id); + expect(finished.status).toBe("completed"); + + const calls = getEchoToolCallingCalls(); + expect(calls.length).toBeGreaterThan(0); + const system = calls[0]!.find((m) => m.role === "system"); + expect(system).toBeDefined(); + const content = String(system?.content ?? ""); + expect(content).toContain("Scheduled jobs delivering into this chat:"); + expect(content).toContain(jobId); + expect(content).toContain("Daily standup"); + expect(content).toContain("cron `0 9 * * *`"); + expect(content).toContain("America/Los_Angeles"); + expect(content).toContain("Ask the team what they did yesterday"); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + test("writes a normal assistant_text block when the final text is real content", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + const config = buildConfig(workspaceRoot, "chat-task-silent-normal"); + const provider = normalizeProvider(config.provider); + + const sessionId = await mutateState(config.instance, (state) => { + const session = createChatSession(state, "Inbox triage"); + const run = createRun(state, { + kind: "conversation_turn", + title: "Watcher turn", + input: "kick off", + conversationId: session.id + }); + return { runId: run.id, sessionId: session.id }; + }); + + setEchoToolCallingResponse({ + provider, + text: "You have one new invoice from Acme.", + toolCalls: [], + finishReason: "stop" + }); + + const task = await submitTask(config, "anything new?", { mode: "chat", runId: sessionId.runId, chatSessionId: sessionId.sessionId }); + const finished = await waitForTerminal(config, task.id); + expect(finished.status).toBe("completed"); + + const blocks = listChatBlocks(config.instance, sessionId.sessionId); + const assistantText = blocks.filter((b) => b.kind === "assistant_text"); + expect(assistantText).toHaveLength(1); + expect(assistantText[0]).toMatchObject({ text: "You have one new invoice from Acme." }); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + test("omits the surface line entirely when the surface is unknown", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + const config = buildConfig(workspaceRoot, "chat-task-surface-unknown"); + const provider = normalizeProvider(config.provider); + const { createChat } = await import("./chat"); + const session = await createChat(config, { title: "Unknown surface probe" }); + + setEchoToolCallingResponse({ provider, text: "ok", toolCalls: [], finishReason: "stop" }); + const submitted = await submitChatMessage(config, session.id, { content: "untagged turn" }); + expect((await waitForTerminal(config, submitted.taskId)).status).toBe("completed"); + + // No claim anywhere in the turn — not a hedged "unknown surface" line. + const turn = getEchoToolCallingCalls()[0]!; + for (const m of turn) { + expect(String(m.content ?? "")).not.toContain("The user is messaging"); + } + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + test("does not replay a prior turn's ephemeral tail on the next turn", async () => { + // The tail is built live and never persisted, so the next turn's prior + // transcript (priorChatMessages reads only durable chatMessages) must not + // contain the previous tail's identity/memory text. Turn 1 emits identity + // into its tail; turn 2's outgoing messages must carry none of it as + // replayed history. Recall is isolated (no active agent), so the only + // tail content under test is the turn-1 identity block. + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + const config = buildConfig(workspaceRoot, "chat-task-no-double-inject"); + const provider = normalizeProvider(config.provider); + + const sessionId = await mutateState(config.instance, (state) => { + const session = createChatSession(state, "No double inject"); + return session.id; + }); + + const firstRunId = await mutateState(config.instance, (state) => { + const run = createRun(state, { + kind: "conversation_turn", + title: "Turn 1", + input: "first", + conversationId: sessionId + }); + return run.id; + }); + setEchoToolCallingResponse({ provider, text: "One.", toolCalls: [], finishReason: "stop" }); + const firstTask = await submitTask(config, "first question", { mode: "chat", runId: firstRunId }); + await waitForTerminal(config, firstTask.id); + + const secondRunId = await mutateState(config.instance, (state) => { + const run = createRun(state, { + kind: "conversation_turn", + title: "Turn 2", + input: "second", + conversationId: sessionId + }); + return run.id; + }); + setEchoToolCallingResponse({ provider, text: "Two.", toolCalls: [], finishReason: "stop" }); + const secondTask = await submitTask(config, "second question", { mode: "chat", runId: secondRunId }); + await waitForTerminal(config, secondTask.id); + + const calls = getEchoToolCallingCalls(); + expect(calls.length).toBe(2); + // Turn 1 emitted identity into its tail. + const firstTurnText = calls[0]!.map((m) => String(m.content ?? "")).join("\n"); + expect(firstTurnText).toContain("Your runtime identity:"); + // Turn 2's messages, EXCLUDING its own freshly-built tail, must not carry + // the prior turn's identity as replayed history. (The quiet second turn + // emits no identity of its own, so any occurrence would be a stale replay.) + const secondTurn = calls[1]!; + const userIdx = secondTurn.findIndex((m) => m.role === "user" && m.content === "second question"); + const historyAndPrefix = secondTurn.filter((_, i) => i !== userIdx - 1); + const historyText = historyAndPrefix.map((m) => String(m.content ?? "")).join("\n"); + expect(historyText).not.toContain("Your runtime identity:"); + // Durable transcript rows likewise never include the tail. + const stored = readState(config.instance).chatMessages.filter((m) => m.sessionId === sessionId); + for (const m of stored) { + expect(String(m.content ?? "")).not.toContain("Your runtime identity:"); + } + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + test("approving a gated tool emits a Working phase before the side effect runs", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + const config = buildConfig(workspaceRoot, "chat-task-blocks-approval-phase"); + const provider = normalizeProvider(config.provider); + + const session = await mutateState(config.instance, (state) => + createChatSession(state, "block-approval-phase", undefined, "agent_y2") + ); + + setEchoToolCallingResponse({ + provider, + text: "", + toolCalls: [ + { id: "call_wp", type: "function", function: { name: "file_write", arguments: JSON.stringify({ path: "out2.txt", content: "hi" }) } } + ], + finishReason: "tool_calls" + }); + setEchoToolCallingResponse({ + provider, + text: "Wrote it.", + toolCalls: [], + finishReason: "stop" + }); + + const submitted = await submitChatMessage(config, session.id, { content: "write out2.txt" }); + const paused = await waitForTerminal(config, submitted.taskId); + expect(paused.status).toBe("waiting_approval"); + + const { listChatBlocks } = await import("../state"); + const gate = listChatBlocks(config.instance, session.id).find( + (b) => b.kind === "authorization_requested" + ); + if (!gate) throw new Error("missing authorization_requested block"); + + await decideApproval(config, paused.approvalIds[0]!, "approve"); + const finished = await waitForFinalTerminal(config, submitted.taskId); + expect(finished.status).toBe("completed"); + + // The approval flip itself lands a non-terminal phase NEWER than the + // gate block. Without it, the backwards activity scan (thread lists, + // panel composer) keeps reporting waiting_approval for the entire + // side-effect execution window — the approved action can run for its + // full timeout before the resumed loop writes anything else. + const blocks = listChatBlocks(config.instance, session.id); + const working = blocks.find((b) => b.kind === "phase" && b.label === "Working: file.write"); + if (working?.kind !== "phase") throw new Error("missing approval Working phase block"); + expect(working.ordinal).toBeGreaterThan(gate.ordinal); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + test("chat.choice cancel (Skip) resumes the chat loop with the skip fallback", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + const config = buildConfig(workspaceRoot, "chat-task-blocks-choice-cancel"); + const provider = normalizeProvider(config.provider); + + const session = await mutateState(config.instance, (state) => + createChatSession(state, "block-choice-cancel", undefined, "agent_q") + ); + + setEchoToolCallingResponse({ + provider, + text: "", + toolCalls: [ + { + id: "call_q", + type: "function", + function: { + name: "ask_user", + arguments: JSON.stringify({ + question: "Which format do you want?", + options: [{ label: "Markdown" }, { label: "Plain text" }] + }) + } + } + ], + finishReason: "tool_calls" + }); + setEchoToolCallingResponse({ + provider, + text: "I'll go with Markdown since it reads best in chat.", + toolCalls: [], + finishReason: "stop" + }); + + const submitted = await submitChatMessage(config, session.id, { content: "export my notes" }); + const paused = await waitForTerminal(config, submitted.taskId); + expect(paused.status).toBe("needs_input"); + + const setup = readState(config.instance).setupRequests.find((s) => s.taskId === submitted.taskId); + expect(setup?.action).toBe("chat.choice"); + + await resolveSetupRequest(config, setup!.id, "cancel", { actor: "user" }); + + let finished = readState(config.instance).tasks.find((t) => t.id === submitted.taskId); + const deadline = Date.now() + 5000; + while (finished?.status !== "completed" && Date.now() < deadline) { + await Bun.sleep(TERMINAL_POLL_MS); + finished = readState(config.instance).tasks.find((t) => t.id === submitted.taskId); + } + // Skip must resume the loop, NOT fail the task. + expect(finished?.status).toBe("completed"); + expect(finished?.summary).toBe("I'll go with Markdown since it reads best in chat."); + + const { listChatBlocks } = await import("../state"); + const blocks = listChatBlocks(config.instance, session.id); + const askUserCall = blocks.find((b) => b.kind === "tool_call" && b.toolName === "ask_user"); + expect(askUserCall?.kind).toBe("tool_call"); + if (askUserCall?.kind === "tool_call") { + expect(askUserCall.status).toBe("ok"); + } + expect(blocks.some((b) => b.kind === "tool_result" && b.preview.includes("User skipped the question"))).toBe(true); + expect(readState(config.instance).setupRequests.find((s) => s.id === setup!.id)?.status).toBe("cancelled"); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + test("cancellation flips streaming assistant_text to settled and emits cancellation block", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + const config = buildConfig(workspaceRoot, "chat-task-blocks-cancel"); + const provider = normalizeProvider(config.provider); + + const session = await mutateState(config.instance, (state) => + createChatSession(state, "block-cancel", undefined, "agent_c") + ); + + // Single response that finishes immediately — the test exercises + // post-completion cancelTask emission rather than a true + // mid-stream cancel (which requires provider-stream injection + // outside the echo provider's contract). The chat-block invariant + // we test here: cancelTask emits system_note + Cancelled phase + // even after the task has already settled. + setEchoToolCallingResponse({ + provider, + text: "Hi.", + toolCalls: [], + finishReason: "stop" + }); + + const submitted = await submitChatMessage(config, session.id, { content: "say hi" }); + const finished = await waitForTerminal(config, submitted.taskId); + expect(finished.status).toBe("completed"); + + // Now cancel the (already-completed) task. cancelTask is idempotent + // for terminal tasks — it returns the row as-is — but the + // chat-block emission still happens unconditionally in the current + // implementation. We verify the invariant differently: by re- + // running through a fresh task that we cancel BEFORE waiting for + // it to settle. + const { cancelTask, submitTask } = await import("../agent"); + + setEchoToolCallingResponse({ + provider, + text: "second response", + toolCalls: [], + finishReason: "stop" + }); + + // Manually create a queued task tied to the same chat session, + // then cancel before runChatTask gets a chance to flip to + // running. The terminal status guard at the top of runChatTask + // detects the cancellation and bails out cleanly. + const cancelTarget = await submitTask(config, "will-be-cancelled", { + mode: "chat", + chatSessionId: session.id + }); + await cancelTask(config, cancelTarget.id); + + const { listChatBlocks } = await import("../state"); + const blocks = listChatBlocks(config.instance, session.id); + const sysNotes = blocks.filter((b) => b.kind === "system_note"); + expect(sysNotes.some((n) => n.kind === "system_note" && n.text === "Cancelled")).toBe(true); + const phases = blocks.filter((b) => b.kind === "phase"); + expect(phases.some((p) => p.kind === "phase" && p.label === "Cancelled")).toBe(true); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + test("the gated approval path persists and replays its tool result, keeping pairing valid", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + const config = buildConfig(workspaceRoot, "chat-task-transcript-gated"); + const provider = normalizeProvider(config.provider); + + const { createChat, syncChatTaskResult } = await import("./chat"); + const session = await createChat(config, { title: "Gated thread" }); + + // Turn 1: request a file write (approval-gated), then answer after resume. + setEchoToolCallingResponse({ + provider, + text: "", + toolCalls: [ + { id: "call_gated", type: "function", function: { name: "file_write", arguments: JSON.stringify({ path: "out.txt", content: "from-agent" }) } } + ], + finishReason: "tool_calls" + }); + setEchoToolCallingResponse({ + provider, + text: "Wrote the file as requested.", + toolCalls: [], + finishReason: "stop" + }); + + const first = await submitChatMessage(config, session.id, { content: "create out.txt" }); + const paused = await waitForTerminal(config, first.taskId); + expect(paused.status).toBe("waiting_approval"); + const approvalId = paused.approvalIds[0]!; + await decideApproval(config, approvalId, "approve"); + const finished = await waitForFinalTerminal(config, first.taskId); + expect(finished.status).toBe("completed"); + await syncChatTaskResult(config, session.id, first.taskId); + + // Turn 2: a follow-up. The gated tool's result must be replayed, and the + // assistant tool_calls row must be immediately followed by its paired + // role:"tool" result (provider ordering invariant). + setEchoToolCallingResponse({ + provider, + text: "Done.", + toolCalls: [], + finishReason: "stop" + }); + const second = await submitChatMessage(config, session.id, { content: "thanks" }); + await waitForTerminal(config, second.taskId); + + const calls = getEchoToolCallingCalls(); + const turn2 = calls.find((messages) => + messages.some((m) => m.role === "user" && m.content === "thanks") + ); + expect(turn2).toBeDefined(); + + const assistantIdx = turn2!.findIndex( + (m) => m.role === "assistant" && Array.isArray(m.tool_calls) && m.tool_calls!.some((c) => c.id === "call_gated") + ); + expect(assistantIdx).toBeGreaterThan(-1); + // The matching tool result must immediately follow its assistant message. + const nextMsg = turn2![assistantIdx + 1]; + expect(nextMsg?.role).toBe("tool"); + expect(nextMsg?.tool_call_id).toBe("call_gated"); + + // No orphan tool results: every role:"tool" message in the replay points + // at an assistant tool_call id that appears earlier in the same array. + const emittedCallIds = new Set(); + for (const m of turn2!) { + if (m.role === "assistant" && Array.isArray(m.tool_calls)) { + for (const c of m.tool_calls) emittedCallIds.add(c.id); + } + if (m.role === "tool") { + expect(typeof m.tool_call_id).toBe("string"); + expect(emittedCallIds.has(m.tool_call_id!)).toBe(true); + } + } + + // The durable transcript rows exist in state.chatMessages but are + // excluded from the human-facing JSON view. + const { getChatSession } = await import("./chat"); + const stored = readState(config.instance).chatMessages.filter( + (m) => m.sessionId === session.id && m.kind === "tool_transcript" + ); + expect(stored.length).toBeGreaterThan(0); + const view = getChatSession(config, session.id); + expect(view.messages.some((m) => m.kind === "tool_transcript")).toBe(false); + + // The full-state runtime views (/api/state, /api/mobile/bootstrap) must + // also drop transcript rows — they carry tool-call args and raw tool + // results (skill bodies, file contents) that have no place in a public + // state poll. + const { publicState, mobileBootstrap } = await import("../runtime/views"); + expect(publicState(config).chatMessages.some((m) => m.kind === "tool_transcript")).toBe(false); + expect(mobileBootstrap(config).chatMessages.some((m) => m.kind === "tool_transcript")).toBe(false); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + test("loop gate blocks a deferred tool the model never loaded and nudges toward load_tools", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + const config = buildConfig(workspaceRoot, "chat-task-deferred-unloaded"); + const provider = normalizeProvider(config.provider); + + // Turn 1: jump straight to browser_snapshot without loading it first. + setEchoToolCallingResponse({ + provider, + text: "", + toolCalls: [ + { id: "call_snap", type: "function", function: { name: "browser_snapshot", arguments: "{}" } } + ], + finishReason: "tool_calls" + }); + // Turn 2: model recovers with a final answer. + setEchoToolCallingResponse({ + provider, + text: "Understood — I'll load the browser tools first next time.", + toolCalls: [], + finishReason: "stop" + }); + + const task = await submitTask(config, "snapshot the page", { mode: "chat" }); + const finished = await waitForTerminal(config, task.id, 10000); + expect(finished.status).toBe("completed"); + expect(finished.summary).toBe("Understood — I'll load the browser tools first next time."); + + const state = readState(config.instance); + // The browser thunk never ran: no browser.snapshot audit row. + const snapAudits = state.audit.filter((a) => a.action === "browser.snapshot" && a.taskId === task.id); + expect(snapAudits).toHaveLength(0); + + // The second model turn's history carries the "not loaded yet" nudge as the + // tool result for the unloaded browser_snapshot call. + const calls = getEchoToolCallingCalls(); + expect(calls.length).toBe(2); + const nudge = calls[1]!.find( + (m) => m.role === "tool" && typeof m.content === "string" && m.content.includes("not loaded yet") + ); + expect(nudge).toBeDefined(); + const envelope = JSON.parse(String((nudge as { content: string }).content)) as { ok: boolean; error: string }; + expect(envelope.ok).toBe(false); + expect(envelope.error).toContain("browser_snapshot"); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + // Loop gate, same-batch case: the model emits load_tools AND the tool it just + // loaded in ONE tool_calls array. The provider generated browser_snapshot + // without ever having its schema (it wasn't in the tools array that turn), so + // the loaded set as it stood at turn start does NOT contain it — the gate + // must block browser_snapshot this turn while load_tools still succeeds. A + // subsequent turn can then call browser_snapshot for real (now loadable). + test("session-bound subagent persists its transcript + final text and a later turn replays them", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + writeFileSync(join(workspaceRoot, "thread.md"), "From: shelden@berkeley.edu\nSubject: meeting"); + const config = buildConfig(workspaceRoot, "chat-task-fanout-history"); + const provider = normalizeProvider(config.provider); + + const { sessionId, subagentId } = await mutateState(config.instance, (state) => { + const session = createChatSession(state, "Email: shelden@berkeley.edu"); + const subagent = createSubagentRecord(state, { + name: "email-watch", + prompt: "watch worker", + toolsets: ["file"], + systemPrompt: "You are an email watch worker." + }); + return { sessionId: session.id, subagentId: subagent.id }; + }); + + // Turn 1: the worker reads the thread (a tool call → tool_transcript rows) + // then proposes a draft (the turn-ending final text). No runId, so the run/ + // conversationId path is dead — the chatSessionId fallback must carry it. + setEchoToolCallingResponse({ + provider, + text: "Reading the thread.", + toolCalls: [{ id: "c1", type: "function", function: { name: "file_read", arguments: JSON.stringify({ path: "thread.md" }) } }], + finishReason: "tool_calls" + }); + setEchoToolCallingResponse({ + provider, + text: "PROPOSED REPLY: Thanks, I can meet Tuesday at 2pm.", + toolCalls: [], + finishReason: "stop" + }); + + const worker = await submitTask(config, "Draft a reply to the latest email.", { mode: "chat", chatSessionId: sessionId, subagentId }); + const finishedWorker = await waitForTerminal(config, worker.id); + expect(finishedWorker.status).toBe("completed"); + + const afterTurn1 = readState(config.instance).chatMessages.filter((m) => m.sessionId === sessionId); + // Transcript rows stamped into the channel (fix #1). + expect(afterTurn1.some((m) => m.kind === "tool_transcript")).toBe(true); + // Exactly one durable assistant summary row carrying the draft (fix #2). + const draftRows = afterTurn1.filter( + (m) => m.role === "assistant" && m.kind !== "tool_transcript" && m.kind !== "approval_reason" + ); + expect(draftRows.length).toBe(1); + expect(draftRows[0]!.content).toContain("PROPOSED REPLY"); + expect(draftRows[0]!.taskId).toBe(worker.id); + + clearEchoToolCallingResponses(); + setEchoToolCallingResponse({ + provider, + text: "Sent.", + toolCalls: [], + finishReason: "stop" + }); + + // Turn 2: a follow-up "send" in the same channel. Its system/messages must + // replay the prior worker draft via priorChatMessages. + const followUp = await submitTask(config, "send", { mode: "chat", chatSessionId: sessionId }); + const finishedFollowUp = await waitForTerminal(config, followUp.id); + expect(finishedFollowUp.status).toBe("completed"); + + const calls = getEchoToolCallingCalls(); + const lastTurn = calls[calls.length - 1]!; + const replayed = lastTurn.map((m) => (typeof m.content === "string" ? m.content : "")).join("\n"); + expect(replayed).toContain("PROPOSED REPLY: Thanks, I can meet Tuesday at 2pm."); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + // No double-write for a normal turn. Finalize persists the durable assistant + // summary row for every completed chat task; syncChatTaskResult (mobile /sync, + // messaging pollers) must short-circuit to that existing row instead of + // adding a second one. Here we model the normal path: a run-bound chat task + // (run.conversationId === session) with NO subagentId. Finalize writes the + // row, then syncChatTaskResult returns it, yielding exactly one assistant + // summary message. + test("context-exhaustion partial-result exit persists the durable answer row", async () => { + const OVERFLOW_MESSAGE = "prompt is too long: 250000 tokens > 200000 maximum"; + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + const config = buildConfig(workspaceRoot, "chat-task-overflow-answer-row"); + const session = await mutateState(config.instance, (state) => + createChatSession(state, "Overflow chat") + ); + + // Every attempt of the turn's model call overflows (3 total attempts). + setEchoToolCallingFailure(OVERFLOW_MESSAGE); + setEchoToolCallingFailure(OVERFLOW_MESSAGE); + setEchoToolCallingFailure(OVERFLOW_MESSAGE); + + const submitted = await submitChatMessage(config, session.id, { content: "go" }); + const finished = await waitForTerminal(config, submitted.taskId, 10000); + expect(finished.status).toBe("completed"); + expect(finished.currentStep).toBe("Completed (stopped: context window exhausted)"); + + const answerRows = readState(config.instance).chatMessages.filter( + (m) => m.sessionId === session.id && m.role === "assistant" && m.kind !== "tool_transcript" && m.kind !== "approval_reason" + ); + expect(answerRows.length).toBe(1); + expect(answerRows[0]!.content).toContain("This is a partial result."); + expect(answerRows[0]!.taskId).toBe(submitted.taskId); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + // A thread-reply turn stamps threadId/parentBlockId on the task; the + // persisted answer row must carry both (sync's short-circuit means it can + // never be backfilled later) and the run must link to the answer + // (assistantMessageId), symmetric with the userMessageId set at submit. + test("compacts and retries when the provider reports a context overflow, then completes", async () => { + const ELISION_MARKER = + "[Earlier tool result elided to fit the context window. Re-run the tool if you still need its output.]"; + const OVERFLOW_MESSAGE = + "This model's maximum context length is 32000 tokens. However, your messages resulted in 99999 tokens."; + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + const config = buildConfig(workspaceRoot, "chat-task-overflow-retry"); + const provider = normalizeProvider(config.provider); + await mutateState(config.instance, (state) => { + for (const toolset of state.toolsets) toolset.status = "disabled"; + }); + // Pin the tool-catalog floor so the crossing geometry is decoupled from + // live always-on catalog size (cleared in afterEach). + __setBaseToolCatalogForTests(FIXED_COMPACTION_CATALOG); + + // Seven mid-size tool results (distinct skills so no loop-breaker trips) + // give the overflow compaction passes something to shrink, while the + // estimated total stays under the proactive high-water mark (27,200 + // tokens against the ~12,487-token floor: the 12,207 pinned catalog plus + // the system-prompt slice) — the proactive + // compaction path never fires, so the overflow is driven purely by the + // stubbed provider failures. + for (let i = 0; i < 7; i++) { + await seedBulkSkill(config, `bulk-skill-${i}`, `BODY-${i} ${"x".repeat(4_800)}`); + setEchoToolCallingResponse({ + provider, + text: "", + toolCalls: [ + { id: `call_o${i}`, type: "function", function: { name: "read_skill", arguments: JSON.stringify({ name: `bulk-skill-${i}` }) } } + ], + finishReason: "tool_calls" + }); + } + // Iteration 8's model call: two overflow rejections, then success. + setEchoToolCallingFailure(OVERFLOW_MESSAGE); + setEchoToolCallingFailure(OVERFLOW_MESSAGE); + setEchoToolCallingResponse({ + provider, + text: "Recovered after compaction.", + toolCalls: [], + finishReason: "stop" + }); + + const task = await submitTask(config, "read all the bulk files", { mode: "chat" }); + const finished = await waitForTerminal(config, task.id, 10000); + expect(finished.status).toBe("completed"); + expect(finished.summary).toBe("Recovered after compaction."); + expect(finished.error).toBeUndefined(); + + // 7 tool turns + 2 failed attempts + 1 successful retry = 10 calls. + const calls = getEchoToolCallingCalls(); + expect(calls.length).toBe(10); + // The successful retry saw a harder-compacted transcript than the first + // failed attempt (the proactive pre-call elision may already have shrunk + // the oldest results; the overflow passes must shrink strictly more, + // including into the protected-recent window on the final retry). + const elidedInFirstAttempt = calls[7]!.filter((m) => m.content === ELISION_MARKER).length; + const elidedInRetry = calls[9]!.filter((m) => m.content === ELISION_MARKER).length; + expect(elidedInRetry).toBeGreaterThan(elidedInFirstAttempt); + expect(elidedInRetry).toBeGreaterThanOrEqual(2); + + // The compact-and-retry warnings landed in the trace. + const { readTrace } = await import("../state"); + const traces = readTrace(config.instance, task.id); + const retries = traces.filter( + (t) => t.type === "warning" && /rejected the prompt as too long/.test(t.message) + ); + expect(retries.length).toBe(2); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + // The calibration gap after an overflow retry must compare the retry's + // reported usage against the COMPACTED payload it actually sent. Against + // the stale pre-elision estimate the gap would clamp toward 0 and the + // next iteration's budget would loosen right back into the overflow. + // Geometry: ten ~1.2k-token reads, then a retry whose reported usage sits + // between the stale and recomputed estimates — only the recomputed base + // yields a gap big enough to force elision before the final call. + test("partial exit surfaces the current turn's narration with the note appended", async () => { + const OVERFLOW_MESSAGE = "prompt is too long: 250000 tokens > 200000 maximum"; + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + const config = buildConfig(workspaceRoot, "chat-task-partial-narration"); + const provider = normalizeProvider(config.provider); + + writeFileSync(join(workspaceRoot, "note.md"), "note content"); + setEchoToolCallingResponse({ + provider, + text: "STEP-NARRATION before reading.", + toolCalls: [ + { id: "call_n1", type: "function", function: { name: "file_read", arguments: JSON.stringify({ path: "note.md" }) } } + ], + finishReason: "tool_calls" + }); + setEchoToolCallingFailure(OVERFLOW_MESSAGE); + setEchoToolCallingFailure(OVERFLOW_MESSAGE); + setEchoToolCallingFailure(OVERFLOW_MESSAGE); + + const task = await submitTask(config, "read the note", { mode: "chat" }); + const finished = await waitForTerminal(config, task.id, 10000); + + expect(finished.status).toBe("completed"); + expect(finished.summary).toBe( + "STEP-NARRATION before reading.\n\n" + + "Stopped early: the conversation no longer fits the model's context window even after compaction. This is a partial result." + ); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + // Narration from a NON-streaming provider never opens an assistant_text + // block (the tool-call path only finalizes a streamed one), so the partial + // exit must emit it one-shot — otherwise task.summary carries narration + // the chat timeline never shows. + test("in-turn compaction proceeds when small savings still get under the high-water mark", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + const config = buildConfig(workspaceRoot, "chat-task-compaction-small-win"); + const provider = normalizeProvider(config.provider); + await mutateState(config.instance, (state) => { + for (const toolset of state.toolsets) toolset.status = "disabled"; + }); + // Pin the tool-catalog floor so the crossing geometry is decoupled from + // live always-on catalog size (cleared in afterEach). + __setBaseToolCatalogForTests(FIXED_COMPACTION_CATALOG); + + // Geometry: the projection crosses the high-water mark (27,200 tokens + // under the echo provider's 32k window) with a small middle span — so + // the compaction reclaims under 10% of the projection (the savings-bail + // threshold) but enough to dip back under the mark. Exchange 0 is the + // protected head, exchanges 1–2 the summarizable middle, exchanges 3–4 + // the protected tail. The tail sizes leave a few hundred tokens of + // post-compaction headroom under the mark; the always-on tool schemas + // count toward the projection, so growing them erodes this headroom. + const bodies = [ + `BODY-0 ${"x".repeat(8_000)}`, + `BODY-1 ${"x".repeat(4_000)}`, + `BODY-2 ${"x".repeat(4_000)}`, + `BODY-3 ${"x".repeat(19_000)}`, + `BODY-4 ${"x".repeat(14_000)}` + ]; + for (let i = 0; i < bodies.length; i++) { + await seedBulkSkill(config, `bulk-skill-${i}`, bodies[i]!); + setEchoToolCallingResponse({ + provider, + text: "", + toolCalls: [ + { id: `call_w${i}`, type: "function", function: { name: "read_skill", arguments: JSON.stringify({ name: `bulk-skill-${i}` }) } } + ], + finishReason: "tool_calls" + }); + } + setEchoAuxTextResponse({ text: "MIDDLE-SUMMARY" }); + setEchoToolCallingResponse({ + provider, + text: "Finished within budget.", + toolCalls: [], + finishReason: "stop" + }); + + const task = await submitTask(config, "review the bulk skills", { mode: "chat" }); + const finished = await waitForTerminal(config, task.id, 10000); + + expect(finished.status).toBe("completed"); + expect(finished.summary).toBe("Finished within budget."); + // One compaction, then the loop proceeded to the final call. + expect(getEchoAuxTextRequests().length).toBe(1); + expect(getEchoToolCallingCalls().length).toBe(6); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + // Anti-thrash: a compaction whose reclaimed space refills within two + // iterations (the model keeps pulling huge results) must stop and exit + // with a partial result rather than compact again. + test("transient model fault (operation timed out) retries with backoff then completes", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + const config = buildConfig(workspaceRoot, "chat-task-transient-retry"); + const provider = normalizeProvider(config.provider); + + // First attempt: a transient OS timeout. Second attempt: success. + setEchoToolCallingFailure("The operation timed out."); + setEchoToolCallingResponse({ provider, text: "Recovered after a flaky connection.", toolCalls: [], finishReason: "stop" }); + + const task = await submitTask(config, "say hi", { mode: "chat" }); + const finished = await waitForTerminal(config, task.id, 10000); + + expect(finished.status).toBe("completed"); + expect(finished.summary).toBe("Recovered after a flaky connection."); + expect(finished.error).toBeUndefined(); + // Exactly two provider calls: the failed attempt + the successful retry. + expect(getEchoToolCallingCalls().length).toBe(2); + + const { readTrace } = await import("../state"); + const traces = readTrace(config.instance, task.id); + const retries = traces.filter( + (t) => t.type === "warning" && /Transient model-call fault; retrying/.test(t.message) + ); + expect(retries.length).toBe(1); + // Backoff base is overridden to 0ms for tests (GINI_TRANSIENT_RETRY_BASE_MS), + // so the curve renders 0 * 2^0 = 0ms here; the production 500ms base is a + // plain constant exercised by the live path. + expect(retries[0]!.message).toContain("after 0ms"); + expect(retries[0]!.message).toContain("attempt 1 of 2"); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + // The streaming idle/stall timeout (provider.ts StreamIdleTimeoutError, whose + // message carries the "stream idle timeout" marker) is also transient and must + // be retried — this pins the reconciliation between the reader's thrown shape + // and the retry classifier. + test("a context overflow takes the overflow path, not the transient-retry path", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + const config = buildConfig(workspaceRoot, "chat-task-overflow-not-transient"); + const provider = normalizeProvider(config.provider); + + const OVERFLOW_MESSAGE = + "This model's maximum context length is 32000 tokens. However, your messages resulted in 99999 tokens."; + setEchoToolCallingFailure(OVERFLOW_MESSAGE); + setEchoToolCallingResponse({ provider, text: "Recovered via overflow path.", toolCalls: [], finishReason: "stop" }); + + const task = await submitTask(config, "say hi", { mode: "chat" }); + const finished = await waitForTerminal(config, task.id, 10000); + + expect(finished.status).toBe("completed"); + expect(finished.summary).toBe("Recovered via overflow path."); + + const { readTrace } = await import("../state"); + const traces = readTrace(config.instance, task.id); + // The overflow warning fired; the transient-retry warning did NOT. + expect(traces.some((t) => t.type === "warning" && /rejected the prompt as too long/.test(t.message))).toBe(true); + expect(traces.some((t) => t.type === "warning" && /Transient model-call fault/.test(t.message))).toBe(false); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + // Regression: transient retries must NOT consume the context-overflow + // compaction budget. The two counters are independent (transientAttempts vs + // attempt), so two transient faults followed by an overflow must still leave + // the overflow path its full compaction budget — the overflow must COMPACT + // and recover, not exit early with a no-compaction partial result. (Before the + // attempt-- fix, the transient `continue` advanced `attempt` to 3, so the + // first overflow hit `attempt >= MAX_CONTEXT_OVERFLOW_ATTEMPTS` and bailed.) +}); \ No newline at end of file diff --git a/packages/runtime/src/execution/chat-task.part5.test.ts b/packages/runtime/src/execution/chat-task.part5.test.ts new file mode 100644 index 00000000..d4bf829c --- /dev/null +++ b/packages/runtime/src/execution/chat-task.part5.test.ts @@ -0,0 +1,1388 @@ +// End-to-end tests for the chat-task agent loop. +// +// We use the echo provider with stubbed tool-calling responses so the loop +// is fully deterministic. The test covers: +// - one tool call → result fed back → final answer +// - approval-gated tool call → task pauses with toolCallState +// - resume after approval → task completes +// +// HOME is pointed at a unique mkdtemp dir per test (same pattern as +// src/state/google-accounts.test.ts): the loop reads the machine-global +// Google account registry (buildConnectedAccountsBlock(readGoogleAccounts()) +// in the system prompt; isSkillActive via credentialExternallySatisfied), so +// a developer machine with registered accounts would otherwise shift the +// system-prompt size and skill activity these tests depend on. + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, join } from "node:path"; +import { + clearEchoAuxTextResponses, + clearEchoToolCallingResponses, + getEchoAuxTextRequests, + getEchoToolCallingCalls, + getEchoToolCallingToolNames, + setEchoAuxTextFailure, + setEchoAuxTextResponse, + setEchoToolCallingFailure, + setEchoToolCallingResponse, + normalizeProvider, + type MessageContentPart, + type ToolCallingMessage +} from "../provider"; +import { submitTask, decideApproval, resolveSetupRequest } from "../agent"; +import { + bankIdForAgent, + createChatMessage, + createChatSession, + createEmptyState, + createRun, + createSubagentRecord, + deleteChatSession, + ensureAgentBank, + ensureDefaultBank, + insertMemoryUnit, + listChatBlocks, + mutateState, + now, + readState, + recordProviderAuthFailure +} from "../state"; +import { echoEmbed } from "../embeddings"; +import { storeUpload } from "../state/uploads"; +import { resolveDefaultPriorContextTokenBudget } from "../provider-capabilities"; +import type { AgentIdentity, GoogleAccount, JobRecord, RuntimeConfig, RuntimeState, SkillRecord, Task, ToolsetRecord } from "../types"; +import { createSkillFromInput, setSkillStatus } from "../capabilities/skills"; +import { + __setBaseToolCatalogForTests, + buildAgentIdentity, + buildConnectedAccountsBlock, + buildEnabledSkillsBlock, + buildInactiveSkillsBlock, + buildMcpServersBlock, + buildSkillScriptsBlock, + compactionMiddleSpan, + elideOldToolResultsToBudget, + IN_TURN_COMPACTION_NOTE_PREFIX, + initialNavStallState, + nextNavStallState, + promptTokensFromUsage, + renderMessagesForCompaction +} from "./chat-task"; +import type { ToolCatalogTool } from "./tool-catalog"; +import type { EffectiveContext } from "./effective-context"; +import { __resetDefaultGiniInstructionsCacheForTest } from "../system-prompt"; + +// Compaction geometry in this file is calibrated against a FIXED instructions +// size (alongside the pinned tool catalog). The real default INSTRUCTIONS.md +// changes over time, which would otherwise shift the system-prompt slice and +// move the compaction crossing point opaquely. Pin a frozen fixture (byte-for- +// byte the default as of this calibration) so edits to the shipped default +// never perturb these tests. +const COMPACTION_INSTRUCTIONS_FIXTURE = join( + import.meta.dir, + "..", + "runtime", + "__fixtures__", + "instructions-compaction-geometry.md" +); + +// These tests submit on idle sessions, which always run immediately. They also +// don't seed a "chat-route" stub, so the router coerces to a chat-direct +// decision and the turn runs in the submitted session. Narrow the submit union +// to the chat-direct run-now branch so the existing `.taskId` reads stay typed +// (a queued or topic-dispatched result here is a test-setup bug). See ADR +// chat-message-queue.md. +async function submitChatMessage( + config: RuntimeConfig, + sessionId: string, + input: Record +): Promise<{ sessionId: string; runId?: string; taskId: string; status: Task["status"] }> { + const { submitChatMessage: submitChatMessageRaw } = await import("./chat"); + const { settleSubmittedChatMessage } = await import("./chat-test-support"); + const result = await submitChatMessageRaw(config, sessionId, input); + const settled = await settleSubmittedChatMessage( + config, + sessionId, + result, + String(input.content ?? "") + ); + if ("queued" in settled) throw new Error("expected run-now submission, got queued"); + if ("topicId" in settled) throw new Error("expected chat-direct submission, got topic dispatch"); + return settled; +} + +let scratchHome: string; +let prevHome: string | undefined; +let prevEmbedding: string | undefined; + +beforeEach(() => { + scratchHome = mkdtempSync(join(tmpdir(), "gini-chat-task-home-")); + prevHome = process.env.HOME; + process.env.HOME = scratchHome; + // Pin embeddings to echo: the local provider is unavailable under bun + // test, and the fallback chain otherwise picks the openai provider on + // machines with ~/.codex/auth.json (resolved via os.homedir(), which + // ignores the HOME override above) — turning every memory embed in the + // loop into a real network call. + prevEmbedding = process.env.GINI_EMBEDDING_PROVIDER; + process.env.GINI_EMBEDDING_PROVIDER = "echo"; + // Pin instructions to the frozen geometry fixture so the compaction tests' + // system-prompt slice is independent of edits to the shipped default. + __resetDefaultGiniInstructionsCacheForTest(COMPACTION_INSTRUCTIONS_FIXTURE); +}); + +afterEach(() => { + if (prevHome === undefined) delete process.env.HOME; + else process.env.HOME = prevHome; + if (prevEmbedding === undefined) delete process.env.GINI_EMBEDDING_PROVIDER; + else process.env.GINI_EMBEDDING_PROVIDER = prevEmbedding; + __resetDefaultGiniInstructionsCacheForTest(); + rmSync(scratchHome, { recursive: true, force: true }); +}); + +function buildConfig(workspaceRoot: string, instance: string, opts: Partial = {}): RuntimeConfig { + return { + instance, + port: 7338, + token: "test", + provider: { name: "echo", model: "" }, + workspaceRoot, + stateRoot: process.env.GINI_STATE_ROOT ?? "/tmp/gini-chat-task-test", + logRoot: process.env.GINI_LOG_ROOT ?? "/tmp/gini-chat-task-test-logs", + // These tests predate the approvalMode flip and pin the + // approval-gated loop behavior. Force "strict" so the chat-task + // loop continues to exercise the pause+resume path here; the new + // default-auto matrix lives in approval-mode.test.ts. + approvalMode: "strict", + ...opts + }; +} + +// Seed an enabled skill with an arbitrary (large) body. read_skill returns +// the body verbatim, which makes it the simplest way to drive big tool +// results through the loop without touching the filesystem caps. +async function seedBulkSkill(config: RuntimeConfig, name: string, body: string): Promise { + const skill = await createSkillFromInput(config, { name, description: `Bulk ${name}` }); + await mutateState(config.instance, (state) => { + const item = state.skills.find((s) => s.id === skill.id)!; + item.body = body; + }); + await setSkillStatus(config, skill.id, "enabled"); +} + +// Fixed, test-owned tool catalog for the in-turn-compaction geometry tests. +// Those tests size skill bodies against the always-on `toolSchemaTokens` +// floor; growing any always-on tool description (as create_job's did) would +// otherwise shift that floor and move the compaction crossing point opaquely. +// Installing this constant via __setBaseToolCatalogForTests pins the floor so +// no live-catalog change can perturb the geometry. +// +// Shape: `read_skill` (the only tool the compaction scripts dispatch — the +// loop must run to completion with it) plus uniform filler tools whose count +// is chosen so the serialized provider catalog tokenizes to a known floor. +// Measured floor: toolSchemaTokens = ceil(JSON.stringify(toProviderTools)/4) +// = 12,207 tokens (read_skill ≈ 90 tokens + 47 filler tools ≈ 258 tokens +// each). The high-water mark is floor(32,000 × 0.85) = 27,200 under the echo +// provider (no usage → promptTokenEstimateGap 0), so the message budget +// before the mark is 27,200 − 12,207 ≈ 14,993 tokens. Each in-turn exchange +// is one ~8,600-char read_skill result (≈ 2,150 tokens) plus its assistant +// tool_call row; with the protected head + system prompt, six accumulated +// results cross the mark before the 7th call. The filler count is the ONE +// knob that sets the floor; bodies below are derived from it. +const FIXED_COMPACTION_CATALOG_FILLER = 47; +const FIXED_COMPACTION_CATALOG: ToolCatalogTool[] = [ + { + toolset: "skills", + type: "function", + function: { + name: "read_skill", + description: "Return the full body of an enabled skill by name.", + parameters: { + type: "object", + properties: { name: { type: "string", description: "Skill name." } }, + required: ["name"] + } + } + }, + ...Array.from({ length: FIXED_COMPACTION_CATALOG_FILLER }, (_unused, i) => ({ + toolset: "core", + type: "function" as const, + function: { + name: `fixed_floor_tool_${i}`, + description: "X".repeat(900), + parameters: { type: "object", properties: {}, required: [] as string[] } + } + })) +]; + +// Poll cadence for the terminal-state waiters. The task settles on a detached +// microtask/timer, so the wait resolves at the NEXT poll after it lands — a +// tight interval keeps the wall-clock cost near the real settle time instead of +// rounding every wait up to a coarse tick. Kept as a named constant so the +// cadence is tunable in one place (and small enough that 100+ sequential waits +// don't dominate the suite). +const TERMINAL_POLL_MS = 2; + +async function waitForTerminal(config: RuntimeConfig, taskId: string, timeoutMs = 5000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const state = readState(config.instance); + const task = state.tasks.find((t) => t.id === taskId); + if (task && (task.status === "completed" || task.status === "failed" || task.status === "cancelled" || task.status === "waiting_approval" || task.status === "needs_input")) { + return task; + } + await Bun.sleep(TERMINAL_POLL_MS); + } + throw new Error(`Task ${taskId} did not reach terminal state within ${timeoutMs}ms`); +} + +// Post-approve wait: decideApproval("approve") returns once the decision is +// durable while the side effect + resume run detached, so the task is still +// parked at waiting_approval when the call returns — waitForTerminal's +// parked-inclusive accept set would return the pre-approve park immediately. +// Poll for a genuinely final status instead. +async function waitForFinalTerminal(config: RuntimeConfig, taskId: string, timeoutMs = 5000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const state = readState(config.instance); + const task = state.tasks.find((t) => t.id === taskId); + if (task && (task.status === "completed" || task.status === "failed" || task.status === "cancelled")) { + return task; + } + await Bun.sleep(TERMINAL_POLL_MS); + } + throw new Error(`Task ${taskId} did not reach a final terminal state within ${timeoutMs}ms`); +} + +describe("chat-task loop", () => { + let root: string; + let prevState: string | undefined; + let prevLog: string | undefined; + let prevTransientBackoff: string | undefined; + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "gini-chat-task-")); + prevState = process.env.GINI_STATE_ROOT; + prevLog = process.env.GINI_LOG_ROOT; + prevTransientBackoff = process.env.GINI_TRANSIENT_RETRY_BASE_MS; + process.env.GINI_STATE_ROOT = root; + process.env.GINI_LOG_ROOT = `${root}-logs`; + // Run the transient-retry backoff at 0ms so these tests never burn real + // wall-clock sleep (the production curve is verified separately by the + // trace-message assertions); keeps the suite fast and deterministic. + process.env.GINI_TRANSIENT_RETRY_BASE_MS = "0"; + clearEchoToolCallingResponses(); + clearEchoAuxTextResponses(); + }); + + afterEach(() => { + if (prevState === undefined) delete process.env.GINI_STATE_ROOT; + else process.env.GINI_STATE_ROOT = prevState; + if (prevLog === undefined) delete process.env.GINI_LOG_ROOT; + else process.env.GINI_LOG_ROOT = prevLog; + if (prevTransientBackoff === undefined) delete process.env.GINI_TRANSIENT_RETRY_BASE_MS; + else process.env.GINI_TRANSIENT_RETRY_BASE_MS = prevTransientBackoff; + // Clear any fixed-catalog override a compaction test installed so the + // rest of the suite sees the live buildToolCatalog. + __setBaseToolCatalogForTests(null); + rmSync(root, { recursive: true, force: true }); + clearEchoToolCallingResponses(); + clearEchoAuxTextResponses(); + }); + + test("read_skill chooses an enabled same-name user skill when the bundled row is disabled", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + const config = buildConfig(workspaceRoot, "chat-task-readskill-same-name"); + const provider = normalizeProvider(config.provider); + + const bundled = await createSkillFromInput(config, { + name: "same-name", + description: "Bundled disabled skill." + }); + const user = await createSkillFromInput(config, { + name: "same-name", + description: "User enabled skill." + }); + await mutateState(config.instance, (state) => { + const bundledRow = state.skills.find((s) => s.id === bundled.id)!; + bundledRow.source = "bundled"; + bundledRow.status = "disabled"; + bundledRow.body = "disabled bundled body"; + const userRow = state.skills.find((s) => s.id === user.id)!; + userRow.source = "user"; + userRow.status = "enabled"; + userRow.body = "enabled user body"; + }); + + setEchoToolCallingResponse({ + provider, + text: "", + toolCalls: [ + { id: "call_same_name", type: "function", function: { name: "read_skill", arguments: JSON.stringify({ name: "same-name" }) } } + ], + finishReason: "tool_calls" + }); + setEchoToolCallingResponse({ + provider, + text: "Loaded the enabled skill.", + toolCalls: [], + finishReason: "stop" + }); + + const task = await submitTask(config, "use the same-name skill", { mode: "chat" }); + const finished = await waitForTerminal(config, task.id); + + expect(finished.status).toBe("completed"); + const state = readState(config.instance); + const reads = state.audit.filter((a) => a.action === "skill.read" && a.taskId === task.id); + expect(reads).toHaveLength(1); + expect(reads[0]?.target).toBe(user.id); + expect(reads[0]?.evidence?.bytes).toBe("enabled user body".length); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + test("terminal_exec with pty=true runs the command under a real TTY", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + const config = buildConfig(workspaceRoot, "chat-task-pty"); + const provider = normalizeProvider(config.provider); + + // `tty -s` exits 0 when stdin is a terminal, 1 otherwise. Print the + // result so we can verify both the exit code and the side channel. + const command = "tty -s && echo PTY-OK || echo NO-PTY"; + setEchoToolCallingResponse({ + provider, + text: "", + toolCalls: [ + { id: "call_pty", type: "function", function: { name: "terminal_exec", arguments: JSON.stringify({ command, pty: true }) } } + ], + finishReason: "tool_calls" + }); + setEchoToolCallingResponse({ + provider, + text: "Ran the command under a TTY.", + toolCalls: [], + finishReason: "stop" + }); + + const task = await submitTask(config, "run with pty", { mode: "chat" }); + const paused = await waitForTerminal(config, task.id); + expect(paused.status).toBe("waiting_approval"); + expect(paused.approvalIds.length).toBe(1); + + // Approval payload should carry pty=true so the executor knows to wrap. + const stateBefore = readState(config.instance); + const approval = stateBefore.authorizations.find((a) => a.id === paused.approvalIds[0]!)!; + expect(approval.payload.pty).toBe(true); + + await decideApproval(config, approval.id, "approve"); + const finished = await waitForFinalTerminal(config, task.id); + expect(finished.status).toBe("completed"); + + const stateAfter = readState(config.instance); + const auditEntry = stateAfter.audit.find((a) => a.action === "terminal.exec" && a.taskId === task.id)!; + expect(auditEntry).toBeDefined(); + const evidence = auditEntry.evidence as Record; + expect(evidence.pty).toBe(true); + expect(evidence.exitCode).toBe(0); + // The wrapped command saw a TTY, so the `tty -s` branch ran. + expect(String(evidence.stdout)).toContain("PTY-OK"); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + test("hits the configurable iteration cap and completes with a tool-less summary", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + const config = buildConfig(workspaceRoot, "chat-task-cap-graceful"); + config.agent = { maxIterations: 3 }; + const provider = normalizeProvider(config.provider); + + // Three iterations of tool calls — one per loop pass — each reading a + // DISTINCT file so the per-iteration signatures differ and the + // identical-repeat loop-breaker does not pre-empt the genuine cap path. + // The loop guard is `iterations < cap` so cap=3 means three model turns + // are consumed before exhaustion. + for (let i = 0; i < 3; i++) { + writeFileSync(join(workspaceRoot, `hello${i}.md`), `Hello, world! (${i})`); + setEchoToolCallingResponse({ + provider, + text: "", + toolCalls: [ + { + id: `call_loop_${i}`, + type: "function", + function: { name: "file_read", arguments: JSON.stringify({ path: `hello${i}.md` }) } + } + ], + finishReason: "tool_calls" + }); + } + // Tool-less summary turn — what the exhaustion path should consume. + setEchoToolCallingResponse({ + provider, + text: "Cap reached. I read three files but never produced a final answer.", + toolCalls: [], + finishReason: "stop" + }); + + const task = await submitTask(config, "loop forever", { mode: "chat" }); + const finished = await waitForTerminal(config, task.id, 10000); + + expect(finished.status).toBe("completed"); + expect(finished.summary).toBe( + "Cap reached. I read three files but never produced a final answer." + ); + expect(finished.currentStep).toBe("Completed (iteration cap reached: 3)"); + expect(finished.error).toBeUndefined(); + + // Trace should contain a warning event flagging the cap hit. + const { readTrace } = await import("../state"); + const traces = readTrace(config.instance, task.id); + const warning = traces.find((t) => t.type === "warning" && /Iteration cap \(3\)/.test(t.message)); + expect(warning).toBeDefined(); + expect((warning?.data as Record | undefined)?.iterations).toBe(3); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + test("stops at the action-only loop-breaker when results jitter but the action repeats", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + const config = buildConfig(workspaceRoot, "chat-task-action-loop-breaker"); + const provider = normalizeProvider(config.provider); + + // Six identical get_current_time calls: same name + args every turn, but + // the clock advances so each result differs. The exact-match guard never + // fires; the action-only guard trips on the sixth pass (runLength 6). + for (let i = 0; i < 6; i++) { + setEchoToolCallingResponse({ + provider, + text: "", + toolCalls: [ + { + id: `call_clock_${i}`, + type: "function", + function: { name: "get_current_time", arguments: JSON.stringify({}) } + } + ], + finishReason: "tool_calls" + }); + } + // Tool-less summary turn — what the loop-breaker exit should consume. + setEchoToolCallingResponse({ + provider, + text: "I kept checking the time without making progress on your request.", + toolCalls: [], + finishReason: "stop" + }); + + const task = await submitTask(config, "what time is it", { mode: "chat" }); + const finished = await waitForTerminal(config, task.id, 10000); + + expect(finished.status).toBe("completed"); + expect(finished.summary).toBe( + "I kept checking the time without making progress on your request." + ); + expect(finished.currentStep).toBe("Completed (stopped: tool loop made no progress)"); + expect(finished.error).toBeUndefined(); + + // Exactly seven model calls: six repeated tool turns + one tool-less + // summary — proving the action-only guard stopped us at 6, not the iteration cap. + const calls = getEchoToolCallingCalls(); + expect(calls.length).toBe(7); + + // Trace records the action-only loop-breaker stop (not the exact-match one). + const { readTrace } = await import("../state"); + const traces = readTrace(config.instance, task.id); + const breaker = traces.find( + (t) => + t.type === "warning" && + /repeating the same tool call\(s\) with identical arguments \(loop-breaker\)/.test(t.message) + ); + expect(breaker).toBeDefined(); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + // Navigation loop-breaker MUST NOT false-positive on legitimate sequential + // research. A model that navigates across many DISTINCT URLs (each blocked + // here by the loopback SSRF gate, so no Chromium launches) is making progress, + // not looping — the per-URL recent-window guard must let it run to the + // iteration cap rather than tripping the navigation loop-breaker. (Loopback + // URLs all yield the same generic block message, so distinct ports differ + // only in arguments — exactly the trace signature the false-positive showed: + // climbing nav count with identicalRunLength 1.) + test("oversized agent.priorContextTokens override is clamped to available provider context", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + const config = buildConfig(workspaceRoot, "chat-task-prior-context-clamp"); + config.agent = { priorContextTokens: 1_000_000 }; + const provider = normalizeProvider(config.provider); + + setEchoToolCallingResponse({ + provider, + text: "Direct answer.", + toolCalls: [], + finishReason: "stop" + }); + + const task = await submitTask(config, "say something", { mode: "chat" }); + const finished = await waitForTerminal(config, task.id); + + expect(finished.status).toBe("completed"); + + const { readTrace } = await import("../state"); + const traces = readTrace(config.instance, task.id); + const warning = traces.find( + (t) => t.type === "warning" && /exceeds available provider context/i.test(t.message) + ); + expect(warning).toBeDefined(); + + const contextTrace = traces.find( + (t) => t.type === "model" && t.message === "chat-task system context built" + ); + const contextData = contextTrace?.data as Record | undefined; + expect(contextData?.priorContextTokenRequested).toBe(1_000_000); + expect(contextData?.priorContextTokenBudget as number) + .toBeLessThanOrEqual(contextData?.priorContextTokenAvailable as number); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + // Fix 2 (cost accumulation): each iteration's cost must add to the + // running total instead of overwriting. Tested across a 3-iteration + // run where the stub provider returns small but nonzero usage on + // every turn — including the final tool-less summary turn the cap + // exhaustion path emits. + test("omits the Bound scheduled jobs block when no job is bound to the session", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + const config = buildConfig(workspaceRoot, "chat-task-no-bound-job"); + const provider = normalizeProvider(config.provider); + + setEchoToolCallingResponse({ + provider, + text: "Hi.", + toolCalls: [], + finishReason: "stop" + }); + + // No runId / no chat session bound — the system context should not + // carry the block header and should not pick up stray trailing + // whitespace from a `${...}\n\n${empty}` template. + const task = await submitTask(config, "say hi", { mode: "chat" }); + const finished = await waitForTerminal(config, task.id); + expect(finished.status).toBe("completed"); + + const calls = getEchoToolCallingCalls(); + expect(calls.length).toBeGreaterThan(0); + const system = calls[0]!.find((m) => m.role === "system"); + expect(system).toBeDefined(); + const content = String(system?.content ?? ""); + expect(content).not.toContain("Scheduled jobs delivering into this chat:"); + // No trailing blank lines from optional sections being concatenated. + expect(content).toBe(content.trimEnd()); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + test("emits the full runtime identity block on the first turn of a chat session", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + const config = buildConfig(workspaceRoot, "chat-task-identity-first"); + const provider = normalizeProvider(config.provider); + + const { runId } = await mutateState(config.instance, (state) => { + const session = createChatSession(state, "Identity probe"); + const run = createRun(state, { + kind: "conversation_turn", + title: "Identity turn", + input: "intro", + conversationId: session.id + }); + return { runId: run.id }; + }); + + setEchoToolCallingResponse({ + provider, + text: "Identity acknowledged.", + toolCalls: [], + finishReason: "stop" + }); + + const task = await submitTask(config, "what's your setup?", { mode: "chat", runId }); + const finished = await waitForTerminal(config, task.id); + expect(finished.status).toBe("completed"); + + const calls = getEchoToolCallingCalls(); + expect(calls.length).toBeGreaterThan(0); + // The emitted identity now rides in the ephemeral role:"user" tail + // placed immediately before the real user message — NOT in the + // byte-stable system prefix. See ADR stable-system-prefix.md. + const turn = calls[0]!; + const system = turn.find((m) => m.role === "system"); + const systemContent = String(system?.content ?? ""); + expect(systemContent).not.toContain("Your runtime identity:"); + const userIdx = turn.findIndex((m) => m.role === "user" && m.content === "what's your setup?"); + expect(userIdx).toBeGreaterThan(0); + const tail = turn[userIdx - 1]!; + expect(tail.role).toBe("user"); + const tailContent = String(tail.content ?? ""); + expect(tailContent).toContain("Your runtime identity:"); + expect(tailContent).toContain(`- instance: ${config.instance}`); + expect(tailContent).toContain(`- runtime port: ${config.port}`); + expect(tailContent).toContain("- provider: echo/"); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + test("omits the identity block on a follow-up turn when nothing changed", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + const config = buildConfig(workspaceRoot, "chat-task-identity-followup"); + const provider = normalizeProvider(config.provider); + + const sessionId = await mutateState(config.instance, (state) => { + const session = createChatSession(state, "Identity follow-up"); + return session.id; + }); + + // Two distinct runs in the same conversation — same session id, two + // separate user turns. The second turn must not re-emit the identity + // block because nothing changed under the K=10 refresh threshold. + const firstRunId = await mutateState(config.instance, (state) => { + const run = createRun(state, { + kind: "conversation_turn", + title: "Turn 1", + input: "first", + conversationId: sessionId + }); + return run.id; + }); + + setEchoToolCallingResponse({ + provider, + text: "First.", + toolCalls: [], + finishReason: "stop" + }); + + const firstTask = await submitTask(config, "first question", { mode: "chat", runId: firstRunId }); + await waitForTerminal(config, firstTask.id); + + const secondRunId = await mutateState(config.instance, (state) => { + const run = createRun(state, { + kind: "conversation_turn", + title: "Turn 2", + input: "second", + conversationId: sessionId + }); + return run.id; + }); + + setEchoToolCallingResponse({ + provider, + text: "Second.", + toolCalls: [], + finishReason: "stop" + }); + + const secondTask = await submitTask(config, "second question", { mode: "chat", runId: secondRunId }); + await waitForTerminal(config, secondTask.id); + + const calls = getEchoToolCallingCalls(); + expect(calls.length).toBe(2); + // Identity now rides in the ephemeral role:"user" tail, so check every + // message of each turn — not just the (now identity-free) system prefix. + const firstTurnText = calls[0]!.map((m) => String(m.content ?? "")).join("\n"); + const secondTurnText = calls[1]!.map((m) => String(m.content ?? "")).join("\n"); + // First turn emits the full identity (in the tail); the system prefix + // itself never carries it anymore. + expect(firstTurnText).toContain("Your runtime identity:"); + expect(String(calls[0]!.find((m) => m.role === "system")?.content ?? "")).not.toContain("Your runtime identity:"); + // Quiet follow-up turn: no identity anywhere — neither system nor tail + // (and with nothing recalled, no tail message is injected at all). + expect(secondTurnText).not.toContain("Your runtime identity:"); + expect(secondTurnText).not.toContain("Runtime identity changes since last turn:"); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + test("packs prior chat history for the provider without deleting stored history", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + const config = buildConfig(workspaceRoot, "chat-task-prior-context-pack", { + agent: { priorContextTokens: 80 } + }); + const provider = normalizeProvider(config.provider); + const sessionId = await mutateState(config.instance, (state) => { + const session = createChatSession(state, "Packed history"); + createChatMessage(state, { + sessionId: session.id, + role: "user", + content: `old secret should not be replayed ${"x".repeat(500)}` + }); + createChatMessage(state, { + sessionId: session.id, + role: "assistant", + content: `old answer should not be replayed ${"y".repeat(500)}` + }); + createChatMessage(state, { + sessionId: session.id, + role: "user", + content: "recent anchor question" + }); + createChatMessage(state, { + sessionId: session.id, + role: "assistant", + content: "recent anchor answer" + }); + return session.id; + }); + + const runId = await mutateState(config.instance, (state) => { + const run = createRun(state, { + kind: "conversation_turn", + title: "Packed turn", + input: "current", + conversationId: sessionId + }); + return run.id; + }); + + setEchoToolCallingResponse({ provider, text: "Done.", toolCalls: [], finishReason: "stop" }); + const task = await submitTask(config, "current question", { mode: "chat", runId }); + const finished = await waitForTerminal(config, task.id); + expect(finished.status).toBe("completed"); + + const firstCall = getEchoToolCallingCalls()[0]!; + const providerText = firstCall.map((m) => String(m.content ?? "")).join("\n"); + expect(providerText).toContain("Earlier chat history is outside the current model context."); + expect(providerText).toContain("recent anchor question"); + expect(providerText).toContain("recent anchor answer"); + expect(providerText).not.toContain("old secret should not be replayed"); + expect(providerText).not.toContain("old answer should not be replayed"); + + const storedText = readState(config.instance).chatMessages.map((m) => m.content).join("\n"); + expect(storedText).toContain("old secret should not be replayed"); + expect(storedText).toContain("old answer should not be replayed"); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + // ChatBlock protocol pin (ADR chat-block-protocol.md). The loop must + // emit a typed stream of blocks per chat session: user_text, phase, + // assistant_text, tool_call, tool_result, approval_requested, + // system_note. Tests run the loop against the echo provider with + // pre-loaded responses and assert the block list shape. + test("completing a setup request lands a Working phase after the gate", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + const config = buildConfig(workspaceRoot, "chat-task-blocks-setup-phase"); + const provider = normalizeProvider(config.provider); + + const session = await mutateState(config.instance, (state) => + createChatSession(state, "block-setup-phase", undefined, "agent_z2") + ); + + setEchoToolCallingResponse({ + provider, + text: "", + toolCalls: [ + { + id: "call_sp", + type: "function", + function: { + name: "request_connector", + arguments: JSON.stringify({ provider: "brave-search", reason: "Need web search." }) + } + } + ], + finishReason: "tool_calls" + }); + + const submitted = await submitChatMessage(config, session.id, { content: "search the web" }); + const paused = await waitForTerminal(config, submitted.taskId); + expect(paused.status).toBe("waiting_approval"); + + const { listChatBlocks } = await import("../state"); + const gate = listChatBlocks(config.instance, session.id).find((b) => b.kind === "setup_requested"); + if (gate?.kind !== "setup_requested") throw new Error("missing setup_requested block"); + + // The /complete handlers claim the row with resumeChatTask:false and run + // their (potentially slow) side effects afterwards; connector.request is + // mapped to emit a Working phase on complete, covering that window so the + // activity scan stops reading a resolved gate as waiting_approval. + const { resolveSetupRequest } = await import("../agent"); + await resolveSetupRequest(config, gate.setupRequestId, "complete", { + actor: "user", + resumeChatTask: false + }); + + const blocks = listChatBlocks(config.instance, session.id); + const working = blocks.find((b) => b.kind === "phase" && b.label === "Working: connector.request"); + if (working?.kind !== "phase") throw new Error("missing setup-complete Working phase block"); + expect(working.ordinal).toBeGreaterThan(gate.ordinal); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + test("request_confirmation pauses the turn with a confirmation.request setup card and no reason bubble", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + const config = buildConfig(workspaceRoot, "chat-task-blocks-confirm"); + const provider = normalizeProvider(config.provider); + + const session = await mutateState(config.instance, (state) => + createChatSession(state, "block-confirm", undefined, "agent_c") + ); + + const summary = "Send this reply to Dana in the project thread"; + setEchoToolCallingResponse({ + provider, + text: "", + toolCalls: [ + { + id: "call_c", + type: "function", + function: { + name: "request_confirmation", + arguments: JSON.stringify({ summary, details: "Hi Dana — ship it.", confirmLabel: "Send" }) + } + } + ], + finishReason: "tool_calls" + }); + + const submitted = await submitChatMessage(config, session.id, { content: "reply to Dana that it's good" }); + const paused = await waitForTerminal(config, submitted.taskId); + expect(paused.status).toBe("waiting_approval"); + + const setup = readState(config.instance).setupRequests.find((s) => s.taskId === submitted.taskId); + expect(setup?.action).toBe("confirmation.request"); + expect(setup?.payload.summary).toBe(summary); + expect(setup?.payload.confirmLabel).toBe("Send"); + + const { listChatBlocks } = await import("../state"); + const blocks = listChatBlocks(config.instance, session.id); + const setupBlock = blocks.find((b) => b.kind === "setup_requested"); + if (setupBlock?.kind === "setup_requested") { + expect(setupBlock.action).toBe("confirmation.request"); + // The summary IS the block summary — that's what transcripts show. + expect(setupBlock.summary).toBe(summary); + } else { + throw new Error("missing setup_requested block"); + } + // Like chat.choice, no assistant bubble accompanies the card — the summary + // lives in the card itself. + expect(blocks.some((b) => b.kind === "assistant_text")).toBe(false); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + test("deleteChatSession cascades and removes all chat blocks", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + const config = buildConfig(workspaceRoot, "chat-task-blocks-cascade"); + const provider = normalizeProvider(config.provider); + + const session = await mutateState(config.instance, (state) => + createChatSession(state, "block-cascade", undefined, "agent_d") + ); + + setEchoToolCallingResponse({ + provider, + text: "Hello back.", + toolCalls: [], + finishReason: "stop" + }); + + const submitted = await submitChatMessage(config, session.id, { content: "hi" }); + await waitForTerminal(config, submitted.taskId); + + const { listChatBlocks } = await import("../state"); + expect(listChatBlocks(config.instance, session.id).length).toBeGreaterThan(0); + + await mutateState(config.instance, (state) => deleteChatSession(state, session.id)); + expect(listChatBlocks(config.instance, session.id)).toHaveLength(0); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + // Tool-calling transcript persistence + replay (ADR + // agent-loop-tool-calling.md). A prior turn's assistant tool_calls and its + // paired role:"tool" results are persisted durably (kind:"tool_transcript") + // and replayed next turn so the model sees the structured results of its + // own earlier actions instead of re-deriving them. + test("a gated tool that persists an approval_reason row still replays its call+result next turn", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + const config = buildConfig(workspaceRoot, "chat-task-transcript-gated-reason"); + const provider = normalizeProvider(config.provider); + + const { createChat, syncChatTaskResult } = await import("./chat"); + const session = await createChat(config, { title: "Connector thread" }); + + // Turn 1: request_connector (approval-gated) for a provider with no + // setupSkill, so it goes straight to a pending setup request. Unlike + // file_write, this path persists a plain assistant kind:"approval_reason" + // row BETWEEN the assistant tool_calls row and the on-resume tool result, + // which is exactly what the turn-window pairing must span. + setEchoToolCallingResponse({ + provider, + text: "", + toolCalls: [ + { id: "call_conn", type: "function", function: { name: "request_connector", arguments: JSON.stringify({ provider: "linear", reason: "list my open issues" }) } } + ], + finishReason: "tool_calls" + }); + setEchoToolCallingResponse({ + provider, + text: "Connected to Linear.", + toolCalls: [], + finishReason: "stop" + }); + + const first = await submitChatMessage(config, session.id, { content: "connect linear" }); + const paused = await waitForTerminal(config, first.taskId); + expect(paused.status).toBe("waiting_approval"); + + // An approval_reason row was persisted during dispatch — this is the + // interleaved non-tool row that the old window logic stopped at. + const reasonRows = readState(config.instance).chatMessages.filter( + (m) => m.sessionId === session.id && m.kind === "approval_reason" + ); + expect(reasonRows.length).toBeGreaterThan(0); + + // Resolve the setup request the way the /complete handler does: the + // synthesized toolResult is fed back via resumeChatTask, which persists + // the gated tool result row. + const setup = readState(config.instance).setupRequests.find((s) => s.taskId === first.taskId); + expect(setup).toBeDefined(); + await resolveSetupRequest(config, setup!.id, "complete", { + actor: "user", + toolResult: "Connected to Linear. Proceed with the original request." + }); + const finished = await waitForTerminal(config, first.taskId); + expect(finished.status).toBe("completed"); + await syncChatTaskResult(config, session.id, first.taskId); + + // Turn 2: a follow-up. The gated tool's call+result must survive replay + // even though an approval_reason row sits between them in the transcript. + setEchoToolCallingResponse({ + provider, + text: "Done.", + toolCalls: [], + finishReason: "stop" + }); + const second = await submitChatMessage(config, session.id, { content: "thanks" }); + await waitForTerminal(config, second.taskId); + + const calls = getEchoToolCallingCalls(); + const turn2 = calls.find((messages) => + messages.some((m) => m.role === "user" && m.content === "thanks") + ); + expect(turn2).toBeDefined(); + + const assistantIdx = turn2!.findIndex( + (m) => m.role === "assistant" && Array.isArray(m.tool_calls) && m.tool_calls!.some((c) => c.id === "call_conn") + ); + expect(assistantIdx).toBeGreaterThan(-1); + // The matching tool result must immediately follow its assistant message. + const nextMsg = turn2![assistantIdx + 1]; + expect(nextMsg?.role).toBe("tool"); + expect(nextMsg?.tool_call_id).toBe("call_conn"); + expect(String(nextMsg?.content)).toContain("Connected to Linear"); + + // No orphan tool results: every role:"tool" message in the replay points + // at an assistant tool_call id that appears earlier in the same array. + const emittedCallIds = new Set(); + for (const m of turn2!) { + if (m.role === "assistant" && Array.isArray(m.tool_calls)) { + for (const c of m.tool_calls) emittedCallIds.add(c.id); + } + if (m.role === "tool") { + expect(typeof m.tool_call_id).toBe("string"); + expect(emittedCallIds.has(m.tool_call_id!)).toBe(true); + } + } + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + test("loop gate blocks a same-batch load+call but lets the tool through on the next turn", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + const config = buildConfig(workspaceRoot, "chat-task-deferred-samebatch"); + const provider = normalizeProvider(config.provider); + + // Turn 1: load_tools(browser_snapshot) AND browser_snapshot(...) together. + setEchoToolCallingResponse({ + provider, + text: "", + toolCalls: [ + { id: "call_load", type: "function", function: { name: "load_tools", arguments: JSON.stringify({ names: ["browser_snapshot"] }) } }, + { id: "call_snap", type: "function", function: { name: "browser_snapshot", arguments: "{}" } } + ], + finishReason: "tool_calls" + }); + // Turn 2: model recovers with a final answer (we stop before a real browser + // call to keep the test hermetic; the point is that the same-batch call was + // gated while load_tools succeeded). + setEchoToolCallingResponse({ + provider, + text: "Browser tools loaded; ready to snapshot.", + toolCalls: [], + finishReason: "stop" + }); + + const task = await submitTask(config, "snapshot the page", { mode: "chat" }); + const finished = await waitForTerminal(config, task.id, 10000); + expect(finished.status).toBe("completed"); + + const state = readState(config.instance); + // browser_snapshot was NOT executed this turn — no browser.snapshot audit. + const snapAudits = state.audit.filter((a) => a.action === "browser.snapshot" && a.taskId === task.id); + expect(snapAudits).toHaveLength(0); + + const calls = getEchoToolCallingCalls(); + expect(calls.length).toBe(2); + const secondTurn = calls[1]!; + // load_tools succeeded: its tool result confirms callability. + const loadResult = secondTurn.find( + (m) => m.role === "tool" && typeof m.content === "string" && m.content.includes("callable directly") + ); + expect(loadResult).toBeDefined(); + // browser_snapshot got the "not loaded yet" nudge this turn. + const snapResult = secondTurn.find( + (m) => m.role === "tool" && typeof m.content === "string" && m.content.includes("not loaded yet") + ); + expect(snapResult).toBeDefined(); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + // browser_navigate seeds the deferred browser cluster: a navigation + // establishes a browsing session whose snapshot is full of actionable @eN + // refs, so the interaction tools (snapshot, click, type, …) must be live on + // the NEXT provider call without a load_tools round-trip per tool — and the + // seeded set must persist on task.loadedTools so a pause/resume keeps it. + // The navigate here targets a loopback URL (SSRF-blocked pre-flight, no + // Chromium) — seeding is unconditional on the navigate outcome. + test("normal chat turn persists exactly one assistant summary (no double-write)", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + const config = buildConfig(workspaceRoot, "chat-task-no-double-write"); + const provider = normalizeProvider(config.provider); + + const sessionId = await mutateState(config.instance, (state) => { + const session = createChatSession(state, "General chat"); + const run = createRun(state, { + kind: "conversation_turn", + title: "Turn", + input: "kick off", + conversationId: session.id + }); + return { runId: run.id, sessionId: session.id }; + }); + + setEchoToolCallingResponse({ + provider, + text: "Here is your answer.", + toolCalls: [], + finishReason: "stop" + }); + + const task = await submitTask(config, "what is 2+2?", { mode: "chat", runId: sessionId.runId, chatSessionId: sessionId.sessionId }); + const finished = await waitForTerminal(config, task.id); + expect(finished.status).toBe("completed"); + + // Finalize wrote the summary row for the normal turn. + const beforeSync = readState(config.instance).chatMessages.filter( + (m) => m.sessionId === sessionId.sessionId && m.role === "assistant" && m.kind !== "tool_transcript" && m.kind !== "approval_reason" + ); + expect(beforeSync.length).toBe(1); + expect(beforeSync[0]!.content).toBe("Here is your answer."); + + const { syncChatTaskResult } = await import("./chat"); + const synced = await syncChatTaskResult(config, sessionId.sessionId, task.id); + expect(synced?.content).toBe("Here is your answer."); + + const afterSync = readState(config.instance).chatMessages.filter( + (m) => m.sessionId === sessionId.sessionId && m.role === "assistant" && m.kind !== "tool_transcript" && m.kind !== "approval_reason" + ); + expect(afterSync.length).toBe(1); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + // Normal chat-turn answer durability. A plain chat turn (no subagentId, no + // jobId) must land its final answer in durable chatMessages at completion — + // no client /sync callback required — so the next turn in the same session + // replays the answer via priorChatMessages instead of seeing the prior + // question unanswered. + test("thread-reply turn's persisted answer row carries parentBlockId and links the run", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + const config = buildConfig(workspaceRoot, "chat-task-thread-answer-row"); + const provider = normalizeProvider(config.provider); + + const { sessionId, runId } = await mutateState(config.instance, (state) => { + const session = createChatSession(state, "Threaded chat"); + const run = createRun(state, { + kind: "conversation_turn", + title: "Thread turn", + input: "follow up", + conversationId: session.id + }); + return { sessionId: session.id, runId: run.id }; + }); + + setEchoToolCallingResponse({ + provider, + text: "Threaded answer.", + toolCalls: [], + finishReason: "stop" + }); + + const task = await submitTask(config, "follow up", { + mode: "chat", + runId, + chatSessionId: sessionId, + threadId: "thread_t1", + parentBlockId: "blk_parent" + }); + const finished = await waitForTerminal(config, task.id); + expect(finished.status).toBe("completed"); + + const answerRows = readState(config.instance).chatMessages.filter( + (m) => m.sessionId === sessionId && m.role === "assistant" && m.kind !== "tool_transcript" && m.kind !== "approval_reason" + ); + expect(answerRows.length).toBe(1); + expect(answerRows[0]!.content).toBe("Threaded answer."); + expect(answerRows[0]!.threadId).toBe("thread_t1"); + expect(answerRows[0]!.parentBlockId).toBe("blk_parent"); + + const run = readState(config.instance).runs.find((r) => r.id === runId); + expect(run?.assistantMessageId).toBe(answerRows[0]!.id); + expect(run?.updatedAt).toBe(answerRows[0]!.createdAt); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + // A parent-delegated subagent with NO chatSessionId resolves no session, so + // neither the transcript nor the final text is persisted — its result flows + // back to the parent as a tool result, not into any channel's history. + test("overflow retry recalibrates the token estimate to the compacted payload", async () => { + const ELISION_MARKER = + "[Earlier tool result elided to fit the context window. Re-run the tool if you still need its output.]"; + const OVERFLOW_MESSAGE = "prompt is too long: 33000 tokens > 32000 maximum"; + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + const config = buildConfig(workspaceRoot, "chat-task-overflow-recalibrate"); + const provider = normalizeProvider(config.provider); + await mutateState(config.instance, (state) => { + for (const toolset of state.toolsets) toolset.status = "disabled"; + }); + + for (let i = 0; i < 11; i++) { + await seedBulkSkill(config, `bulk-skill-${i}`, `BODY-${i} ${"x".repeat(4_550)}`); + } + for (let i = 0; i < 10; i++) { + setEchoToolCallingResponse({ + provider, + text: "", + toolCalls: [ + { id: `call_g${i}`, type: "function", function: { name: "read_skill", arguments: JSON.stringify({ name: `bulk-skill-${i}` }) } } + ], + finishReason: "tool_calls" + }); + } + // Iteration 11: one overflow rejection, then a successful retry that + // reports usage and keeps working (one more read). + setEchoToolCallingFailure(OVERFLOW_MESSAGE); + setEchoToolCallingResponse({ + provider, + text: "", + toolCalls: [ + { id: "call_g10", type: "function", function: { name: "read_skill", arguments: JSON.stringify({ name: "bulk-skill-10" }) } } + ], + finishReason: "tool_calls", + usage: { prompt_tokens: 26_000 } + }); + setEchoToolCallingResponse({ + provider, + text: "Recalibrated.", + toolCalls: [], + finishReason: "stop" + }); + + const task = await submitTask(config, "read all the bulk skills", { mode: "chat" }); + const finished = await waitForTerminal(config, task.id, 10000); + expect(finished.status).toBe("completed"); + expect(finished.summary).toBe("Recalibrated."); + + // The final call must see MORE elision than the retry left behind — + // driven purely by the recalibrated gap (no overflow fired after the + // retry). + const calls = getEchoToolCallingCalls(); + expect(calls.length).toBe(13); + const markersInRetry = calls[11]!.filter((m) => m.content === ELISION_MARKER).length; + const markersInFinal = calls[12]!.filter((m) => m.content === ELISION_MARKER).length; + expect(markersInFinal).toBeGreaterThan(markersInRetry); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + // Overflow that persists through every retry must exit gracefully with a + // partial result — completed, not failed — without making the tool-less + // summary call (which would itself overflow). + test("partial exit emits non-streaming narration as a block alongside the note", async () => { + const OVERFLOW_MESSAGE = "prompt is too long: 250000 tokens > 200000 maximum"; + const PARTIAL_NOTE = + "Stopped early: the conversation no longer fits the model's context window even after compaction. This is a partial result."; + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + const config = buildConfig(workspaceRoot, "chat-task-partial-nonstream"); + const provider = normalizeProvider(config.provider); + const session = await mutateState(config.instance, (state) => + createChatSession(state, "partial-nonstream") + ); + + writeFileSync(join(workspaceRoot, "note.md"), "note content"); + // Whole-string response (no deltas) narrating before a tool call, then + // persistent overflow. + setEchoToolCallingResponse( + { + provider, + text: "ONE-SHOT NARRATION before reading.", + toolCalls: [ + { id: "call_ns1", type: "function", function: { name: "file_read", arguments: JSON.stringify({ path: "note.md" }) } } + ], + finishReason: "tool_calls" + }, + undefined, + { nonStreaming: true } + ); + setEchoToolCallingFailure(OVERFLOW_MESSAGE); + setEchoToolCallingFailure(OVERFLOW_MESSAGE); + setEchoToolCallingFailure(OVERFLOW_MESSAGE); + + const submitted = await submitChatMessage(config, session.id, { content: "read the note" }); + const finished = await waitForTerminal(config, submitted.taskId, 10000); + + expect(finished.status).toBe("completed"); + expect(finished.summary).toBe(`ONE-SHOT NARRATION before reading.\n\n${PARTIAL_NOTE}`); + + // The narration reaches the timeline as a settled block; the note stays + // a system note (never folded into the block). + const { listChatBlocks } = await import("../state"); + const blocks = listChatBlocks(config.instance, session.id); + const narrationBlocks = blocks.filter( + (b) => b.kind === "assistant_text" && b.text === "ONE-SHOT NARRATION before reading." + ); + expect(narrationBlocks.length).toBe(1); + expect(narrationBlocks[0]!.kind === "assistant_text" && narrationBlocks[0]!.streaming).toBe(false); + const noteBlocks = blocks.filter((b) => JSON.stringify(b).includes("Stopped early")); + expect(noteBlocks.length).toBe(1); + expect(noteBlocks[0]!.kind).toBe("system_note"); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + // A provider can stream part of a response BEFORE throwing the overflow. + // The retry must start from a clean stream: without a per-attempt reset + // the failed attempt's text accretes onto the retry's in the route buffer + // and the in-flight assistant block. + test("in-turn compaction bails gracefully when the window refills immediately", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + const config = buildConfig(workspaceRoot, "chat-task-compaction-refill"); + const provider = normalizeProvider(config.provider); + await mutateState(config.instance, (state) => { + for (const toolset of state.toolsets) toolset.status = "disabled"; + }); + // Pin the tool-catalog floor so the crossing geometry is decoupled from + // live always-on catalog size (cleared in afterEach). + __setBaseToolCatalogForTests(FIXED_COMPACTION_CATALOG); + + // Same geometry as the happy path (compaction fires before call 7) plus + // a 7th huge read that immediately refills the reclaimed space. + for (let i = 0; i < 7; i++) { + const chars = i === 6 ? 36_000 : 8_600; + await seedBulkSkill(config, `bulk-skill-${i}`, `BODY-${i} ${"x".repeat(chars)}`); + setEchoToolCallingResponse({ + provider, + text: "", + toolCalls: [ + { id: `call_r${i}`, type: "function", function: { name: "read_skill", arguments: JSON.stringify({ name: `bulk-skill-${i}` }) } } + ], + finishReason: "tool_calls" + }); + } + setEchoAuxTextResponse({ text: "REFILL-SUMMARY" }); + + const task = await submitTask(config, "review every bulk skill", { mode: "chat" }); + const finished = await waitForTerminal(config, task.id, 10000); + + expect(finished.status).toBe("completed"); + expect(finished.currentStep).toBe("Completed (stopped: context window exhausted)"); + expect(finished.summary).toContain("refilled immediately"); + // Exactly one compaction, then the refill bail before an 8th call. + expect(getEchoAuxTextRequests().length).toBe(1); + expect(getEchoToolCallingCalls().length).toBe(7); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + // The per-turn compaction cap: after two compactions (spaced widely enough + // that the refill guard stays quiet), a third trigger must NOT summarize + // again — the loop proceeds (the reactive overflow retry is the backstop) + // and completes normally. + test("streaming idle-timeout fault retries then completes", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + const config = buildConfig(workspaceRoot, "chat-task-idle-retry"); + const provider = normalizeProvider(config.provider); + + // The exact message provider.ts's StreamIdleTimeoutError produces. + setEchoToolCallingFailure("Model stream idle timeout: no data for 120000ms"); + setEchoToolCallingResponse({ provider, text: "Back after the stall.", toolCalls: [], finishReason: "stop" }); + + const task = await submitTask(config, "say hi", { mode: "chat" }); + const finished = await waitForTerminal(config, task.id, 10000); + + expect(finished.status).toBe("completed"); + expect(finished.summary).toBe("Back after the stall."); + expect(getEchoToolCallingCalls().length).toBe(2); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + // The transient budget is bounded: after MAX_TRANSIENT_RETRIES (2) extra + // attempts the task fails with the raw fault, just as it would today. + test("transient retries do not steal the context-overflow compaction budget", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); + const config = buildConfig(workspaceRoot, "chat-task-transient-then-overflow"); + const provider = normalizeProvider(config.provider); + + const OVERFLOW_MESSAGE = + "This model's maximum context length is 32000 tokens. However, your messages resulted in 99999 tokens."; + // Two transient faults exhaust the transient budget, then a context overflow + // arrives, then a clean success once compaction shrinks the transcript. + setEchoToolCallingFailure("The operation timed out."); + setEchoToolCallingFailure("The operation timed out."); + setEchoToolCallingFailure(OVERFLOW_MESSAGE); + setEchoToolCallingResponse({ provider, text: "Recovered after transient faults + a compaction.", toolCalls: [], finishReason: "stop" }); + + const task = await submitTask(config, "say hi", { mode: "chat" }); + const finished = await waitForTerminal(config, task.id, 10000); + + // The task recovered via compaction — it did NOT exit early with the + // no-compaction partial-result message. + expect(finished.status).toBe("completed"); + expect(finished.summary).toBe("Recovered after transient faults + a compaction."); + expect(finished.summary).not.toContain("no longer fits the model's context window"); + + const { readTrace } = await import("../state"); + const traces = readTrace(config.instance, task.id); + // Both transient retries fired AND the overflow compaction still ran. + const transientRetries = traces.filter( + (t) => t.type === "warning" && /Transient model-call fault; retrying/.test(t.message) + ); + expect(transientRetries.length).toBe(2); + expect(traces.some((t) => t.type === "warning" && /rejected the prompt as too long/.test(t.message))).toBe(true); + // 4 provider calls total: 2 transient fails + 1 overflow + 1 successful retry. + expect(getEchoToolCallingCalls().length).toBe(4); + + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + // Fallback: a provider that reports no usage (the echo default) keeps the + // plain chars/4 behavior — the identical transcript never trims. +}); \ No newline at end of file diff --git a/packages/runtime/src/execution/chat-task.test.ts b/packages/runtime/src/execution/chat-task.test.ts deleted file mode 100644 index eb5dbd5d..00000000 --- a/packages/runtime/src/execution/chat-task.test.ts +++ /dev/null @@ -1,6643 +0,0 @@ -// End-to-end tests for the chat-task agent loop. -// -// We use the echo provider with stubbed tool-calling responses so the loop -// is fully deterministic. The test covers: -// - one tool call → result fed back → final answer -// - approval-gated tool call → task pauses with toolCallState -// - resume after approval → task completes -// -// HOME is pointed at a unique mkdtemp dir per test (same pattern as -// src/state/google-accounts.test.ts): the loop reads the machine-global -// Google account registry (buildConnectedAccountsBlock(readGoogleAccounts()) -// in the system prompt; isSkillActive via credentialExternallySatisfied), so -// a developer machine with registered accounts would otherwise shift the -// system-prompt size and skill activity these tests depend on. - -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { basename, join } from "node:path"; -import { - clearEchoAuxTextResponses, - clearEchoToolCallingResponses, - getEchoAuxTextRequests, - getEchoToolCallingCalls, - getEchoToolCallingToolNames, - setEchoAuxTextFailure, - setEchoAuxTextResponse, - setEchoToolCallingFailure, - setEchoToolCallingResponse, - normalizeProvider, - type MessageContentPart, - type ToolCallingMessage -} from "../provider"; -import { submitTask, decideApproval, resolveSetupRequest } from "../agent"; -import { - bankIdForAgent, - createChatMessage, - createChatSession, - createEmptyState, - createRun, - createSubagentRecord, - deleteChatSession, - ensureAgentBank, - ensureDefaultBank, - insertMemoryUnit, - listChatBlocks, - mutateState, - now, - readState, - recordProviderAuthFailure -} from "../state"; -import { echoEmbed } from "../embeddings"; -import { storeUpload } from "../state/uploads"; -import { resolveDefaultPriorContextTokenBudget } from "../provider-capabilities"; -import type { AgentIdentity, GoogleAccount, JobRecord, RuntimeConfig, RuntimeState, SkillRecord, Task, ToolsetRecord } from "../types"; -import { createSkillFromInput, setSkillStatus } from "../capabilities/skills"; -import { - __setBaseToolCatalogForTests, - buildAgentIdentity, - buildConnectedAccountsBlock, - buildEnabledSkillsBlock, - buildInactiveSkillsBlock, - buildMcpServersBlock, - buildSkillScriptsBlock, - compactionMiddleSpan, - elideOldToolResultsToBudget, - IN_TURN_COMPACTION_NOTE_PREFIX, - initialNavStallState, - nextNavStallState, - promptTokensFromUsage, - renderMessagesForCompaction -} from "./chat-task"; -import type { ToolCatalogTool } from "./tool-catalog"; -import type { EffectiveContext } from "./effective-context"; -import { __resetDefaultGiniInstructionsCacheForTest } from "../system-prompt"; - -// Compaction geometry in this file is calibrated against a FIXED instructions -// size (alongside the pinned tool catalog). The real default INSTRUCTIONS.md -// changes over time, which would otherwise shift the system-prompt slice and -// move the compaction crossing point opaquely. Pin a frozen fixture (byte-for- -// byte the default as of this calibration) so edits to the shipped default -// never perturb these tests. -const COMPACTION_INSTRUCTIONS_FIXTURE = join( - import.meta.dir, - "..", - "runtime", - "__fixtures__", - "instructions-compaction-geometry.md" -); - -// These tests submit on idle sessions, which always run immediately. They also -// don't seed a "chat-route" stub, so the router coerces to a chat-direct -// decision and the turn runs in the submitted session. Narrow the submit union -// to the chat-direct run-now branch so the existing `.taskId` reads stay typed -// (a queued or topic-dispatched result here is a test-setup bug). See ADR -// chat-message-queue.md. -async function submitChatMessage( - config: RuntimeConfig, - sessionId: string, - input: Record -): Promise<{ sessionId: string; runId?: string; taskId: string; status: Task["status"] }> { - const { submitChatMessage: submitChatMessageRaw } = await import("./chat"); - const { settleSubmittedChatMessage } = await import("./chat-test-support"); - const result = await submitChatMessageRaw(config, sessionId, input); - const settled = await settleSubmittedChatMessage( - config, - sessionId, - result, - String(input.content ?? "") - ); - if ("queued" in settled) throw new Error("expected run-now submission, got queued"); - if ("topicId" in settled) throw new Error("expected chat-direct submission, got topic dispatch"); - return settled; -} - -let scratchHome: string; -let prevHome: string | undefined; -let prevEmbedding: string | undefined; - -beforeEach(() => { - scratchHome = mkdtempSync(join(tmpdir(), "gini-chat-task-home-")); - prevHome = process.env.HOME; - process.env.HOME = scratchHome; - // Pin embeddings to echo: the local provider is unavailable under bun - // test, and the fallback chain otherwise picks the openai provider on - // machines with ~/.codex/auth.json (resolved via os.homedir(), which - // ignores the HOME override above) — turning every memory embed in the - // loop into a real network call. - prevEmbedding = process.env.GINI_EMBEDDING_PROVIDER; - process.env.GINI_EMBEDDING_PROVIDER = "echo"; - // Pin instructions to the frozen geometry fixture so the compaction tests' - // system-prompt slice is independent of edits to the shipped default. - __resetDefaultGiniInstructionsCacheForTest(COMPACTION_INSTRUCTIONS_FIXTURE); -}); - -afterEach(() => { - if (prevHome === undefined) delete process.env.HOME; - else process.env.HOME = prevHome; - if (prevEmbedding === undefined) delete process.env.GINI_EMBEDDING_PROVIDER; - else process.env.GINI_EMBEDDING_PROVIDER = prevEmbedding; - __resetDefaultGiniInstructionsCacheForTest(); - rmSync(scratchHome, { recursive: true, force: true }); -}); - -function buildConfig(workspaceRoot: string, instance: string, opts: Partial = {}): RuntimeConfig { - return { - instance, - port: 7338, - token: "test", - provider: { name: "echo", model: "" }, - workspaceRoot, - stateRoot: process.env.GINI_STATE_ROOT ?? "/tmp/gini-chat-task-test", - logRoot: process.env.GINI_LOG_ROOT ?? "/tmp/gini-chat-task-test-logs", - // These tests predate the approvalMode flip and pin the - // approval-gated loop behavior. Force "strict" so the chat-task - // loop continues to exercise the pause+resume path here; the new - // default-auto matrix lives in approval-mode.test.ts. - approvalMode: "strict", - ...opts - }; -} - -// Seed an enabled skill with an arbitrary (large) body. read_skill returns -// the body verbatim, which makes it the simplest way to drive big tool -// results through the loop without touching the filesystem caps. -async function seedBulkSkill(config: RuntimeConfig, name: string, body: string): Promise { - const skill = await createSkillFromInput(config, { name, description: `Bulk ${name}` }); - await mutateState(config.instance, (state) => { - const item = state.skills.find((s) => s.id === skill.id)!; - item.body = body; - }); - await setSkillStatus(config, skill.id, "enabled"); -} - -// Fixed, test-owned tool catalog for the in-turn-compaction geometry tests. -// Those tests size skill bodies against the always-on `toolSchemaTokens` -// floor; growing any always-on tool description (as create_job's did) would -// otherwise shift that floor and move the compaction crossing point opaquely. -// Installing this constant via __setBaseToolCatalogForTests pins the floor so -// no live-catalog change can perturb the geometry. -// -// Shape: `read_skill` (the only tool the compaction scripts dispatch — the -// loop must run to completion with it) plus uniform filler tools whose count -// is chosen so the serialized provider catalog tokenizes to a known floor. -// Measured floor: toolSchemaTokens = ceil(JSON.stringify(toProviderTools)/4) -// = 12,207 tokens (read_skill ≈ 90 tokens + 47 filler tools ≈ 258 tokens -// each). The high-water mark is floor(32,000 × 0.85) = 27,200 under the echo -// provider (no usage → promptTokenEstimateGap 0), so the message budget -// before the mark is 27,200 − 12,207 ≈ 14,993 tokens. Each in-turn exchange -// is one ~8,600-char read_skill result (≈ 2,150 tokens) plus its assistant -// tool_call row; with the protected head + system prompt, six accumulated -// results cross the mark before the 7th call. The filler count is the ONE -// knob that sets the floor; bodies below are derived from it. -const FIXED_COMPACTION_CATALOG_FILLER = 47; -const FIXED_COMPACTION_CATALOG: ToolCatalogTool[] = [ - { - toolset: "skills", - type: "function", - function: { - name: "read_skill", - description: "Return the full body of an enabled skill by name.", - parameters: { - type: "object", - properties: { name: { type: "string", description: "Skill name." } }, - required: ["name"] - } - } - }, - ...Array.from({ length: FIXED_COMPACTION_CATALOG_FILLER }, (_unused, i) => ({ - toolset: "core", - type: "function" as const, - function: { - name: `fixed_floor_tool_${i}`, - description: "X".repeat(900), - parameters: { type: "object", properties: {}, required: [] as string[] } - } - })) -]; - -// Poll cadence for the terminal-state waiters. The task settles on a detached -// microtask/timer, so the wait resolves at the NEXT poll after it lands — a -// tight interval keeps the wall-clock cost near the real settle time instead of -// rounding every wait up to a coarse tick. Kept as a named constant so the -// cadence is tunable in one place (and small enough that 100+ sequential waits -// don't dominate the suite). -const TERMINAL_POLL_MS = 2; - -async function waitForTerminal(config: RuntimeConfig, taskId: string, timeoutMs = 5000): Promise { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - const state = readState(config.instance); - const task = state.tasks.find((t) => t.id === taskId); - if (task && (task.status === "completed" || task.status === "failed" || task.status === "cancelled" || task.status === "waiting_approval" || task.status === "needs_input")) { - return task; - } - await Bun.sleep(TERMINAL_POLL_MS); - } - throw new Error(`Task ${taskId} did not reach terminal state within ${timeoutMs}ms`); -} - -// Post-approve wait: decideApproval("approve") returns once the decision is -// durable while the side effect + resume run detached, so the task is still -// parked at waiting_approval when the call returns — waitForTerminal's -// parked-inclusive accept set would return the pre-approve park immediately. -// Poll for a genuinely final status instead. -async function waitForFinalTerminal(config: RuntimeConfig, taskId: string, timeoutMs = 5000): Promise { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - const state = readState(config.instance); - const task = state.tasks.find((t) => t.id === taskId); - if (task && (task.status === "completed" || task.status === "failed" || task.status === "cancelled")) { - return task; - } - await Bun.sleep(TERMINAL_POLL_MS); - } - throw new Error(`Task ${taskId} did not reach a final terminal state within ${timeoutMs}ms`); -} - -describe("chat-task loop", () => { - let root: string; - let prevState: string | undefined; - let prevLog: string | undefined; - let prevTransientBackoff: string | undefined; - - beforeEach(() => { - root = mkdtempSync(join(tmpdir(), "gini-chat-task-")); - prevState = process.env.GINI_STATE_ROOT; - prevLog = process.env.GINI_LOG_ROOT; - prevTransientBackoff = process.env.GINI_TRANSIENT_RETRY_BASE_MS; - process.env.GINI_STATE_ROOT = root; - process.env.GINI_LOG_ROOT = `${root}-logs`; - // Run the transient-retry backoff at 0ms so these tests never burn real - // wall-clock sleep (the production curve is verified separately by the - // trace-message assertions); keeps the suite fast and deterministic. - process.env.GINI_TRANSIENT_RETRY_BASE_MS = "0"; - clearEchoToolCallingResponses(); - clearEchoAuxTextResponses(); - }); - - afterEach(() => { - if (prevState === undefined) delete process.env.GINI_STATE_ROOT; - else process.env.GINI_STATE_ROOT = prevState; - if (prevLog === undefined) delete process.env.GINI_LOG_ROOT; - else process.env.GINI_LOG_ROOT = prevLog; - if (prevTransientBackoff === undefined) delete process.env.GINI_TRANSIENT_RETRY_BASE_MS; - else process.env.GINI_TRANSIENT_RETRY_BASE_MS = prevTransientBackoff; - // Clear any fixed-catalog override a compaction test installed so the - // rest of the suite sees the live buildToolCatalog. - __setBaseToolCatalogForTests(null); - rmSync(root, { recursive: true, force: true }); - clearEchoToolCallingResponses(); - clearEchoAuxTextResponses(); - }); - - test("dispatches a low-risk tool call then completes with a final answer", async () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - const fixturePath = join(workspaceRoot, "hello.md"); - writeFileSync(fixturePath, "Hello, world!"); - const config = buildConfig(workspaceRoot, "chat-task-sync"); - const provider = normalizeProvider(config.provider); - - // First model turn: ask to read the file. - setEchoToolCallingResponse({ - provider, - text: "", - toolCalls: [ - { id: "call_1", type: "function", function: { name: "file_read", arguments: JSON.stringify({ path: "hello.md" }) } } - ], - finishReason: "tool_calls" - }); - // Second model turn: respond with the file contents. - setEchoToolCallingResponse({ - provider, - text: "The file says: Hello, world!", - toolCalls: [], - finishReason: "stop" - }); - - const task = await submitTask(config, "what does hello.md say?", { mode: "chat" }); - const finished = await waitForTerminal(config, task.id); - - expect(finished.status).toBe("completed"); - expect(finished.summary).toBe("The file says: Hello, world!"); - // Audit trail should include the file.read. - const state = readState(config.instance); - const reads = state.audit.filter((a) => a.action === "file.read" && a.taskId === task.id); - expect(reads).toHaveLength(1); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - test("approval-gated tool call pauses the task and resumes after approval", async () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - const config = buildConfig(workspaceRoot, "chat-task-gated"); - const provider = normalizeProvider(config.provider); - - // First model turn: request a file write. - setEchoToolCallingResponse({ - provider, - text: "", - toolCalls: [ - { id: "call_w", type: "function", function: { name: "file_write", arguments: JSON.stringify({ path: "out.txt", content: "from-agent" }) } } - ], - finishReason: "tool_calls" - }); - // Second model turn (after approval resumes): final reply. - setEchoToolCallingResponse({ - provider, - text: "Wrote the file as requested.", - toolCalls: [], - finishReason: "stop" - }); - - const task = await submitTask(config, "please create out.txt", { mode: "chat" }); - const paused = await waitForTerminal(config, task.id); - expect(paused.status).toBe("waiting_approval"); - expect(paused.toolCallState).toBeDefined(); - expect(paused.toolCallState?.pending.length).toBe(1); - expect(paused.toolCallState?.pending[0]?.toolName).toBe("file_write"); - expect(paused.approvalIds.length).toBe(1); - - const approvalId = paused.approvalIds[0]!; - await decideApproval(config, approvalId, "approve"); - const finished = await waitForFinalTerminal(config, task.id); - - expect(finished.status).toBe("completed"); - expect(finished.summary).toBe("Wrote the file as requested."); - // The file should have been written. - const written = await Bun.file(join(workspaceRoot, "out.txt")).text(); - expect(written).toBe("from-agent"); - // The toolCallState should be cleared on completion. - expect(finished.toolCallState).toBeUndefined(); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - test("falls back to a final answer when the model emits no tool calls", async () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - const config = buildConfig(workspaceRoot, "chat-task-direct"); - const provider = normalizeProvider(config.provider); - - setEchoToolCallingResponse({ - provider, - text: "Sure, here's a direct answer.", - toolCalls: [], - finishReason: "stop" - }); - - const task = await submitTask(config, "say hi", { mode: "chat" }); - const finished = await waitForTerminal(config, task.id); - expect(finished.status).toBe("completed"); - expect(finished.summary).toBe("Sure, here's a direct answer."); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - test("read_skill returns the full body of an enabled skill", async () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - const config = buildConfig(workspaceRoot, "chat-task-readskill"); - const provider = normalizeProvider(config.provider); - - // Pre-create an enabled skill with a non-empty body — simulates a - // post-loadSkillsFromDisk state without exercising the loader here. - const skill = await createSkillFromInput(config, { - name: "apple-notes", - description: "Apple Notes via memo CLI." - }); - await mutateState(config.instance, (state) => { - const item = state.skills.find((s) => s.id === skill.id)!; - item.body = "# Apple Notes\n\nUse `memo notes -a` to add a note."; - }); - await setSkillStatus(config, skill.id, "enabled"); - - setEchoToolCallingResponse({ - provider, - text: "", - toolCalls: [ - { id: "call_skill", type: "function", function: { name: "read_skill", arguments: JSON.stringify({ name: "apple-notes" }) } } - ], - finishReason: "tool_calls" - }); - setEchoToolCallingResponse({ - provider, - text: "I now know how to use Apple Notes.", - toolCalls: [], - finishReason: "stop" - }); - - const task = await submitTask(config, "how do I add an apple note?", { mode: "chat" }); - const finished = await waitForTerminal(config, task.id); - - expect(finished.status).toBe("completed"); - expect(finished.summary).toBe("I now know how to use Apple Notes."); - // Audit trail captures the skill read. - const state = readState(config.instance); - const reads = state.audit.filter((a) => a.action === "skill.read" && a.taskId === task.id); - expect(reads).toHaveLength(1); - expect(reads[0]?.evidence?.name).toBe("apple-notes"); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - test("read_skill chooses an enabled same-name user skill when the bundled row is disabled", async () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - const config = buildConfig(workspaceRoot, "chat-task-readskill-same-name"); - const provider = normalizeProvider(config.provider); - - const bundled = await createSkillFromInput(config, { - name: "same-name", - description: "Bundled disabled skill." - }); - const user = await createSkillFromInput(config, { - name: "same-name", - description: "User enabled skill." - }); - await mutateState(config.instance, (state) => { - const bundledRow = state.skills.find((s) => s.id === bundled.id)!; - bundledRow.source = "bundled"; - bundledRow.status = "disabled"; - bundledRow.body = "disabled bundled body"; - const userRow = state.skills.find((s) => s.id === user.id)!; - userRow.source = "user"; - userRow.status = "enabled"; - userRow.body = "enabled user body"; - }); - - setEchoToolCallingResponse({ - provider, - text: "", - toolCalls: [ - { id: "call_same_name", type: "function", function: { name: "read_skill", arguments: JSON.stringify({ name: "same-name" }) } } - ], - finishReason: "tool_calls" - }); - setEchoToolCallingResponse({ - provider, - text: "Loaded the enabled skill.", - toolCalls: [], - finishReason: "stop" - }); - - const task = await submitTask(config, "use the same-name skill", { mode: "chat" }); - const finished = await waitForTerminal(config, task.id); - - expect(finished.status).toBe("completed"); - const state = readState(config.instance); - const reads = state.audit.filter((a) => a.action === "skill.read" && a.taskId === task.id); - expect(reads).toHaveLength(1); - expect(reads[0]?.target).toBe(user.id); - expect(reads[0]?.evidence?.bytes).toBe("enabled user body".length); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - test("read_skill rejects disabled skills with a recoverable error", async () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - const config = buildConfig(workspaceRoot, "chat-task-readskill-disabled"); - const provider = normalizeProvider(config.provider); - - // Create a disabled skill. The agent loop should see an - // error tool result and recover. - const skill = await createSkillFromInput(config, { - name: "disabled-skill", - description: "Currently disabled." - }); - await setSkillStatus(config, skill.id, "disabled"); - await mutateState(config.instance, (state) => { - const item = state.skills.find((s) => s.id === skill.id)!; - item.body = "Some disabled content."; - }); - - setEchoToolCallingResponse({ - provider, - text: "", - toolCalls: [ - { id: "call_disabled", type: "function", function: { name: "read_skill", arguments: JSON.stringify({ name: "disabled-skill" }) } } - ], - finishReason: "tool_calls" - }); - setEchoToolCallingResponse({ - provider, - text: "Got it — that skill is disabled.", - toolCalls: [], - finishReason: "stop" - }); - - const task = await submitTask(config, "use the disabled skill", { mode: "chat" }); - const finished = await waitForTerminal(config, task.id); - - expect(finished.status).toBe("completed"); - expect(finished.summary).toBe("Got it — that skill is disabled."); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - // One-pending-per-turn regression. When the LLM emits multiple - // tool calls in a single assistant turn and the first one returns - // a pending approval, all subsequent dispatches MUST be deferred - // so their side effects don't race the user's approval decision. - // The chat-task loop skips remaining calls and synthesizes a - // "skipped" tool_result for message-history symmetry; the LLM - // re-evaluates from the new state on the next turn after the - // approval resolves. - test("pending approval halts the rest of the turn — later calls are skipped, not dispatched", async () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - const config = buildConfig(workspaceRoot, "chat-task-pending-halt"); - const provider = normalizeProvider(config.provider); - - setEchoToolCallingResponse({ - provider, - text: "", - toolCalls: [ - { id: "call_w1", type: "function", function: { name: "file_write", arguments: JSON.stringify({ path: "a.txt", content: "AAA" }) } }, - { id: "call_w2", type: "function", function: { name: "file_write", arguments: JSON.stringify({ path: "b.txt", content: "BBB" }) } } - ], - finishReason: "tool_calls" - }); - - const task = await submitTask(config, "write a and b", { mode: "chat" }); - const paused = await waitForTerminal(config, task.id); - expect(paused.status).toBe("waiting_approval"); - expect(paused.toolCallState).toBeDefined(); - // Only the first call goes pending. The second was skipped (its - // dispatch never ran) — message history carries a synthetic - // skipped tool_result so the LLM sees both tool_calls paired. - expect(paused.toolCallState?.pending.length).toBe(1); - expect(paused.approvalIds.length).toBe(1); - - // Deny the lone pending approval. The task fails. The second - // file_write never ran in the first place, so its file must not - // exist on disk either. - const [firstApprovalId] = paused.approvalIds as [string]; - await decideApproval(config, firstApprovalId, "deny"); - // Deny resumes detached; poll for the final failed state rather than a - // fixed sleep so the wait tracks the real settle time. - const failedTask = await waitForFinalTerminal(config, task.id); - - expect(failedTask.status).toBe("failed"); - expect(failedTask.toolCallState).toBeUndefined(); - expect(existsSync(join(workspaceRoot, "a.txt"))).toBe(false); - expect(existsSync(join(workspaceRoot, "b.txt"))).toBe(false); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - test("resumeChatTask is a no-op for a task that has already failed", async () => { - // Standalone test of resumeChatTask's terminal-task guard. We construct - // a task in the failed state and call resumeChatTask directly; it must - // return without flipping the task back to running and without - // re-entering the loop. - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - const config = buildConfig(workspaceRoot, "chat-task-resume-failed"); - const provider = normalizeProvider(config.provider); - - setEchoToolCallingResponse({ - provider, - text: "", - toolCalls: [ - { id: "call_x", type: "function", function: { name: "file_write", arguments: JSON.stringify({ path: "x.txt", content: "X" }) } } - ], - finishReason: "tool_calls" - }); - - const task = await submitTask(config, "write x", { mode: "chat" }); - const paused = await waitForTerminal(config, task.id); - expect(paused.status).toBe("waiting_approval"); - const approvalId = paused.approvalIds[0]!; - - // Deny — fails the task and clears the snapshot. Deny resumes detached, so - // poll for the failed state instead of a fixed sleep. - await decideApproval(config, approvalId, "deny"); - const failedBefore = await waitForFinalTerminal(config, task.id); - expect(failedBefore.status).toBe("failed"); - - // Now call resumeChatTask directly. Must no-op. - const { resumeChatTask } = await import("../execution/chat-task"); - const result = await resumeChatTask(config, task.id, "call_x", "should-not-resume"); - expect(result.status).toBe("failed"); - - // Status / partialSummary unchanged after the no-op resume. - const after = readState(config.instance).tasks.find((t) => t.id === task.id)!; - expect(after.status).toBe("failed"); - expect(after.toolCallState).toBeUndefined(); - expect(existsSync(join(workspaceRoot, "x.txt"))).toBe(false); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - test("image attachment on a non-vision model proceeds and steers an in-band refusal", async () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - const config = buildConfig(workspaceRoot, "chat-task-image-no-vision"); - - // The echo provider resolves to vision:false. A PNG header is enough since - // buildAttachmentContent degrades the image to a text note without reading - // the bytes — it gates on mime alone, never emitting an image_url part. - const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x01]); - const upload = storeUpload(config.instance, png, "image/png", "pic.png"); - - const task = await submitTask(config, "see this", { - mode: "chat", - images: [{ id: upload.id, mimeType: "image/png", size: upload.size }] - }); - const finished = await waitForTerminal(config, task.id); - - // The turn is no longer hard-rejected: it runs to completion so the refusal - // is a normal, replayable assistant turn. - expect(finished.status).toBe("completed"); - - // The model saw the image degraded to a text note plus the steering - // directive, and never received an image_url part it would 400 on. - const turn = getEchoToolCallingCalls()[0]!; - const userMessage = turn.find((m) => m.role === "user" && Array.isArray(m.content))!; - const userParts = userMessage.content as MessageContentPart[]; - expect(userParts.every((p) => p.type !== "image_url")).toBe(true); - const userText = userParts - .filter((p): p is { type: "text"; text: string } => p.type === "text") - .map((p) => p.text) - .join("\n"); - expect(userText).toContain("You cannot see the image(s) above"); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - // PTY support: terminal_exec accepts an opt-in `pty: true` arg that wraps - // the command under a pseudo-terminal so interactive CLIs (vim, memo, - // claude-code) don't see "stdin is not a tty" and exit immediately. We - // verify the round-trip end-to-end: - // - the model emits terminal_exec with pty=true - // - the approval payload captures pty=true - // - executeApprovedAction spawns under a TTY (verified by `tty -s`) - // - the audit evidence carries pty=true so the user can see it - test("terminal_exec with pty=true runs the command under a real TTY", async () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - const config = buildConfig(workspaceRoot, "chat-task-pty"); - const provider = normalizeProvider(config.provider); - - // `tty -s` exits 0 when stdin is a terminal, 1 otherwise. Print the - // result so we can verify both the exit code and the side channel. - const command = "tty -s && echo PTY-OK || echo NO-PTY"; - setEchoToolCallingResponse({ - provider, - text: "", - toolCalls: [ - { id: "call_pty", type: "function", function: { name: "terminal_exec", arguments: JSON.stringify({ command, pty: true }) } } - ], - finishReason: "tool_calls" - }); - setEchoToolCallingResponse({ - provider, - text: "Ran the command under a TTY.", - toolCalls: [], - finishReason: "stop" - }); - - const task = await submitTask(config, "run with pty", { mode: "chat" }); - const paused = await waitForTerminal(config, task.id); - expect(paused.status).toBe("waiting_approval"); - expect(paused.approvalIds.length).toBe(1); - - // Approval payload should carry pty=true so the executor knows to wrap. - const stateBefore = readState(config.instance); - const approval = stateBefore.authorizations.find((a) => a.id === paused.approvalIds[0]!)!; - expect(approval.payload.pty).toBe(true); - - await decideApproval(config, approval.id, "approve"); - const finished = await waitForFinalTerminal(config, task.id); - expect(finished.status).toBe("completed"); - - const stateAfter = readState(config.instance); - const auditEntry = stateAfter.audit.find((a) => a.action === "terminal.exec" && a.taskId === task.id)!; - expect(auditEntry).toBeDefined(); - const evidence = auditEntry.evidence as Record; - expect(evidence.pty).toBe(true); - expect(evidence.exitCode).toBe(0); - // The wrapped command saw a TTY, so the `tty -s` branch ran. - expect(String(evidence.stdout)).toContain("PTY-OK"); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - test("terminal_exec without pty sees no TTY and stays on the legacy spawn path", async () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - const config = buildConfig(workspaceRoot, "chat-task-no-pty"); - const provider = normalizeProvider(config.provider); - - const command = "tty -s && echo PTY-OK || echo NO-PTY"; - setEchoToolCallingResponse({ - provider, - text: "", - toolCalls: [ - { id: "call_nopty", type: "function", function: { name: "terminal_exec", arguments: JSON.stringify({ command }) } } - ], - finishReason: "tool_calls" - }); - setEchoToolCallingResponse({ - provider, - text: "Ran without TTY.", - toolCalls: [], - finishReason: "stop" - }); - - const task = await submitTask(config, "run without pty", { mode: "chat" }); - const paused = await waitForTerminal(config, task.id); - expect(paused.status).toBe("waiting_approval"); - - const stateBefore = readState(config.instance); - const approval = stateBefore.authorizations.find((a) => a.id === paused.approvalIds[0]!)!; - expect(approval.payload.pty).toBe(false); - - await decideApproval(config, approval.id, "approve"); - const finished = await waitForFinalTerminal(config, task.id); - expect(finished.status).toBe("completed"); - - const stateAfter = readState(config.instance); - const auditEntry = stateAfter.audit.find((a) => a.action === "terminal.exec" && a.taskId === task.id)!; - const evidence = auditEntry.evidence as Record; - expect(evidence.pty).toBe(false); - expect(String(evidence.stdout)).toContain("NO-PTY"); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - // Auto-approve allowlist (Fix 2). When the user has added a matching - // pattern to RuntimeConfig.autoApproveCommands, terminal_exec should - // execute synchronously and write a high-risk audit row with - // evidence.autoApproved=true plus the matched pattern. No approval row - // should be created — the loop must continue without pausing. - test("terminal_exec auto-approves and runs synchronously when the command matches the allowlist", async () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - const config = buildConfig(workspaceRoot, "chat-task-auto-approve"); - config.autoApproveCommands = ["echo *"]; - const provider = normalizeProvider(config.provider); - - setEchoToolCallingResponse({ - provider, - text: "", - toolCalls: [ - { id: "call_auto", type: "function", function: { name: "terminal_exec", arguments: JSON.stringify({ command: "echo hello" }) } } - ], - finishReason: "tool_calls" - }); - setEchoToolCallingResponse({ - provider, - text: "Said hello.", - toolCalls: [], - finishReason: "stop" - }); - - const task = await submitTask(config, "say hello", { mode: "chat" }); - const finished = await waitForTerminal(config, task.id); - - expect(finished.status).toBe("completed"); - expect(finished.summary).toBe("Said hello."); - - const state = readState(config.instance); - // No approval row should have been created. - const approvalsForTask = state.authorizations.filter((a) => a.taskId === task.id); - expect(approvalsForTask).toHaveLength(0); - - // The audit row should exist and be flagged as auto-approved. - const audit = state.audit.find((a) => a.action === "terminal.exec" && a.taskId === task.id)!; - expect(audit).toBeDefined(); - expect(audit.risk).toBe("high"); - const evidence = audit.evidence as Record; - expect(evidence.autoApproved).toBe(true); - expect(evidence.autoApprovedReason).toBe("echo *"); - expect(evidence.exitCode).toBe(0); - expect(String(evidence.stdout)).toContain("hello"); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - test("terminal_exec falls through to the approval gate when no allowlist pattern matches", async () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - const config = buildConfig(workspaceRoot, "chat-task-auto-approve-miss"); - config.autoApproveCommands = ["memo *"]; - const provider = normalizeProvider(config.provider); - - setEchoToolCallingResponse({ - provider, - text: "", - toolCalls: [ - { id: "call_miss", type: "function", function: { name: "terminal_exec", arguments: JSON.stringify({ command: "rm -rf /tmp/x" }) } } - ], - finishReason: "tool_calls" - }); - - const task = await submitTask(config, "rm something", { mode: "chat" }); - const paused = await waitForTerminal(config, task.id); - expect(paused.status).toBe("waiting_approval"); - expect(paused.approvalIds.length).toBe(1); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - test("invalid tool args are reported back as tool errors so the model can recover", async () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - const config = buildConfig(workspaceRoot, "chat-task-baddargs"); - const provider = normalizeProvider(config.provider); - - setEchoToolCallingResponse({ - provider, - text: "", - toolCalls: [ - { id: "call_bad", type: "function", function: { name: "file_read", arguments: '{"oops":true}' } } - ], - finishReason: "tool_calls" - }); - setEchoToolCallingResponse({ - provider, - text: "Sorry, I goofed.", - toolCalls: [], - finishReason: "stop" - }); - - const task = await submitTask(config, "read something", { mode: "chat" }); - const finished = await waitForTerminal(config, task.id); - expect(finished.status).toBe("completed"); - expect(finished.summary).toBe("Sorry, I goofed."); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - // Iteration cap (graceful exhaustion). When the chat-task loop hits the - // configurable iteration cap, it must NOT fail. Instead it makes one - // final tool-less model call asking for a summary and completes with - // that text. A warning trace should record the cap hit. - test("hits the configurable iteration cap and completes with a tool-less summary", async () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - const config = buildConfig(workspaceRoot, "chat-task-cap-graceful"); - config.agent = { maxIterations: 3 }; - const provider = normalizeProvider(config.provider); - - // Three iterations of tool calls — one per loop pass — each reading a - // DISTINCT file so the per-iteration signatures differ and the - // identical-repeat loop-breaker does not pre-empt the genuine cap path. - // The loop guard is `iterations < cap` so cap=3 means three model turns - // are consumed before exhaustion. - for (let i = 0; i < 3; i++) { - writeFileSync(join(workspaceRoot, `hello${i}.md`), `Hello, world! (${i})`); - setEchoToolCallingResponse({ - provider, - text: "", - toolCalls: [ - { - id: `call_loop_${i}`, - type: "function", - function: { name: "file_read", arguments: JSON.stringify({ path: `hello${i}.md` }) } - } - ], - finishReason: "tool_calls" - }); - } - // Tool-less summary turn — what the exhaustion path should consume. - setEchoToolCallingResponse({ - provider, - text: "Cap reached. I read three files but never produced a final answer.", - toolCalls: [], - finishReason: "stop" - }); - - const task = await submitTask(config, "loop forever", { mode: "chat" }); - const finished = await waitForTerminal(config, task.id, 10000); - - expect(finished.status).toBe("completed"); - expect(finished.summary).toBe( - "Cap reached. I read three files but never produced a final answer." - ); - expect(finished.currentStep).toBe("Completed (iteration cap reached: 3)"); - expect(finished.error).toBeUndefined(); - - // Trace should contain a warning event flagging the cap hit. - const { readTrace } = await import("../state"); - const traces = readTrace(config.instance, task.id); - const warning = traces.find((t) => t.type === "warning" && /Iteration cap \(3\)/.test(t.message)); - expect(warning).toBeDefined(); - expect((warning?.data as Record | undefined)?.iterations).toBe(3); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - test("a successful provider call clears the persistent needs-reauth record", async () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - const config = buildConfig(workspaceRoot, "chat-task-reauth-clear"); - const provider = normalizeProvider(config.provider); - - // A prior turn recorded an echo auth failure (issue #233). - await mutateState(config.instance, (state) => { - recordProviderAuthFailure(state, { provider: "echo", detail: "token expired", taskId: "task_prior" }); - }); - expect(readState(config.instance).providerAuthFailures?.echo).toBeDefined(); - - setEchoToolCallingResponse({ provider, text: "All good again.", toolCalls: [], finishReason: "stop" }); - const task = await submitTask(config, "say hi", { mode: "chat" }); - const finished = await waitForTerminal(config, task.id); - expect(finished.status).toBe("completed"); - - // The successful call dropped the record and audited the clear. - const state = readState(config.instance); - expect(state.providerAuthFailures?.echo).toBeUndefined(); - const cleared = state.audit.find((a) => a.action === "provider.auth.cleared" && a.target === "echo"); - expect(cleared).toBeDefined(); - expect(cleared?.evidence).toMatchObject({ provider: "echo", reason: "provider call succeeded" }); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - test("a healthy turn with no needs-reauth record writes no clear audit (no state churn)", async () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - const config = buildConfig(workspaceRoot, "chat-task-reauth-nochurn"); - const provider = normalizeProvider(config.provider); - - setEchoToolCallingResponse({ provider, text: "Nothing to clear.", toolCalls: [], finishReason: "stop" }); - const task = await submitTask(config, "say hi", { mode: "chat" }); - const finished = await waitForTerminal(config, task.id); - expect(finished.status).toBe("completed"); - - // The clear seam fires only when a record exists — a healthy instance - // sees neither a record nor a provider.auth.cleared audit row. - const state = readState(config.instance); - expect(state.providerAuthFailures?.echo).toBeUndefined(); - expect(state.audit.some((a) => a.action === "provider.auth.cleared")).toBe(false); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - test("an auth failure on the iteration-cap summary call persists the needs-reauth record", async () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - writeFileSync(join(workspaceRoot, "hello0.md"), "Hello (0)"); - writeFileSync(join(workspaceRoot, "hello1.md"), "Hello (1)"); - // The summary-failure path settles the task itself (it does not route - // through failTask), so it must write the persistent record on its own. - // Drive it with a REAL provider transport (openai + stubbed fetch): two - // streamed tool-call turns reach the cap, then the tool-less summary call - // gets a 401 whose body names a key fragment that must be redacted. - const config = buildConfig(workspaceRoot, "chat-task-reauth-summary", { - provider: { name: "openai", model: "gpt-test" }, - agent: { maxIterations: 2 } - }); - - const prevKey = process.env.OPENAI_API_KEY; - const prevEmbed = process.env.GINI_EMBEDDING_PROVIDER; - const originalFetch = globalThis.fetch; - process.env.OPENAI_API_KEY = "sk-test-summary"; - // Pin embeddings to the in-process echo provider so memory recall never - // routes through the stubbed fetch. - process.env.GINI_EMBEDDING_PROVIDER = "echo"; - - const sseToolCall = (i: number): Response => { - const events = [ - { choices: [{ index: 0, delta: { tool_calls: [{ index: 0, id: `call_s${i}`, type: "function", function: { name: "file_read", arguments: "" } }] } }] }, - { choices: [{ index: 0, delta: { tool_calls: [{ index: 0, function: { arguments: JSON.stringify({ path: `hello${i}.md` }) } }] } }] }, - { choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }] } - ]; - const stream = new ReadableStream({ - start(controller) { - const enc = new TextEncoder(); - for (const e of events) controller.enqueue(enc.encode(`data: ${JSON.stringify(e)}\n\n`)); - controller.enqueue(enc.encode("data: [DONE]\n\n")); - controller.close(); - } - }); - return new Response(stream, { status: 200, headers: { "content-type": "text/event-stream" } }); - }; - let calls = 0; - globalThis.fetch = ((() => { - calls += 1; - if (calls <= 2) return Promise.resolve(sseToolCall(calls - 1)); - // The tool-less summary call is non-streaming; reject it like a dead - // credential would. - return Promise.resolve(new Response( - JSON.stringify({ error: { message: "Incorrect API key provided: sk-livefail123456" } }), - { status: 401, headers: { "content-type": "application/json" } } - )); - }) as unknown) as typeof fetch; - - try { - const task = await submitTask(config, "loop forever", { mode: "chat" }); - const finished = await waitForTerminal(config, task.id, 10000); - - expect(finished.status).toBe("failed"); - expect(finished.authErrorProvider).toBe("openai"); - expect(finished.error).toBe("Incorrect API key provided: sk-***"); - - const state = readState(config.instance); - expect(state.providerAuthFailures?.openai).toMatchObject({ - provider: "openai", - detail: "Incorrect API key provided: sk-***", - taskId: task.id - }); - // The raw key fragment never lands in state.json. - expect(JSON.stringify(state.providerAuthFailures)).not.toContain("sk-livefail123456"); - expect( - state.audit.find((a) => a.action === "provider.auth.needs_reauth" && a.target === "openai") - ).toBeDefined(); - } finally { - globalThis.fetch = originalFetch; - if (prevKey === undefined) delete process.env.OPENAI_API_KEY; - else process.env.OPENAI_API_KEY = prevKey; - if (prevEmbed === undefined) delete process.env.GINI_EMBEDDING_PROVIDER; - else process.env.GINI_EMBEDDING_PROVIDER = prevEmbed; - rmSync(workspaceRoot, { recursive: true, force: true }); - } - }); - - // Loop-breaker (identical-repeat). A model that emits the IDENTICAL tool - // call and gets the IDENTICAL result several iterations in a row is stuck; - // the loop must stop at MAX_IDENTICAL_TOOL_REPEATS (3) — well before the - // iteration cap — and route to the same graceful tool-less summary exit. - // We drive a cold browser_connect (no page open → deterministic ok:false - // guard refusal every turn) to reproduce the real stuck-loop scenario. - test("stops at the identical-repeat loop-breaker and completes via a tool-less summary", async () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - const config = buildConfig(workspaceRoot, "chat-task-loop-breaker"); - const provider = normalizeProvider(config.provider); - - // Three identical iterations: same cold browser_connect args, same guard - // refusal each time. The third pass trips the loop-breaker (runLength 3). - for (let i = 0; i < 3; i++) { - setEchoToolCallingResponse({ - provider, - text: "", - toolCalls: [ - { - id: `call_repeat_${i}`, - type: "function", - function: { - name: "browser_connect", - arguments: JSON.stringify({ reason: "Sign in to Example", url: "https://example.com/login" }) - } - } - ], - finishReason: "tool_calls" - }); - } - // Tool-less summary turn — what the loop-breaker exit should consume. - setEchoToolCallingResponse({ - provider, - text: "That sign-in path keeps refusing. Try connecting the service from settings, then ask again.", - toolCalls: [], - finishReason: "stop" - }); - - const task = await submitTask(config, "sign in to example", { mode: "chat" }); - const finished = await waitForTerminal(config, task.id, 10000); - - expect(finished.status).toBe("completed"); - expect(finished.summary).toBe( - "That sign-in path keeps refusing. Try connecting the service from settings, then ask again." - ); - expect(finished.currentStep).toBe("Completed (stopped: tool loop made no progress)"); - expect(finished.error).toBeUndefined(); - - // Exactly four model calls: three repeated tool turns + one tool-less - // summary — proving we stopped at the loop-breaker, not the iteration cap. - const calls = getEchoToolCallingCalls(); - expect(calls.length).toBe(4); - // The final summary turn is the tool-less exit: its last message is the - // repeat-specific summary instruction asking for a final answer. - const summaryTurn = calls[3]!; - const lastMessage = summaryTurn[summaryTurn.length - 1]!; - expect(lastMessage.role).toBe("user"); - expect(String(lastMessage.content)).toContain("repeated the same tool call"); - - // Trace records the loop-breaker stop. - const { readTrace } = await import("../state"); - const traces = readTrace(config.instance, task.id); - const breaker = traces.find( - (t) => t.type === "warning" && /identical tool call\(s\) and result\(s\) \(loop-breaker\)/.test(t.message) - ); - expect(breaker).toBeDefined(); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - // Action-only loop-breaker. A model that emits the IDENTICAL tool call - // (same name + arguments) but gets a DIFFERENT result every iteration — - // the real browser_navigate case, where each live-page snapshot jitters — - // slips past the exact-match guard, so the coarser action-only guard must - // catch it at MAX_SAME_ACTION_REPEATS (6) instead of running to the iteration cap. - // We drive get_current_time, whose result (a timestamp) differs each call. - test("stops at the action-only loop-breaker when results jitter but the action repeats", async () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - const config = buildConfig(workspaceRoot, "chat-task-action-loop-breaker"); - const provider = normalizeProvider(config.provider); - - // Six identical get_current_time calls: same name + args every turn, but - // the clock advances so each result differs. The exact-match guard never - // fires; the action-only guard trips on the sixth pass (runLength 6). - for (let i = 0; i < 6; i++) { - setEchoToolCallingResponse({ - provider, - text: "", - toolCalls: [ - { - id: `call_clock_${i}`, - type: "function", - function: { name: "get_current_time", arguments: JSON.stringify({}) } - } - ], - finishReason: "tool_calls" - }); - } - // Tool-less summary turn — what the loop-breaker exit should consume. - setEchoToolCallingResponse({ - provider, - text: "I kept checking the time without making progress on your request.", - toolCalls: [], - finishReason: "stop" - }); - - const task = await submitTask(config, "what time is it", { mode: "chat" }); - const finished = await waitForTerminal(config, task.id, 10000); - - expect(finished.status).toBe("completed"); - expect(finished.summary).toBe( - "I kept checking the time without making progress on your request." - ); - expect(finished.currentStep).toBe("Completed (stopped: tool loop made no progress)"); - expect(finished.error).toBeUndefined(); - - // Exactly seven model calls: six repeated tool turns + one tool-less - // summary — proving the action-only guard stopped us at 6, not the iteration cap. - const calls = getEchoToolCallingCalls(); - expect(calls.length).toBe(7); - - // Trace records the action-only loop-breaker stop (not the exact-match one). - const { readTrace } = await import("../state"); - const traces = readTrace(config.instance, task.id); - const breaker = traces.find( - (t) => - t.type === "warning" && - /repeating the same tool call\(s\) with identical arguments \(loop-breaker\)/.test(t.message) - ); - expect(breaker).toBeDefined(); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - // Navigation loop-breaker MUST NOT false-positive on legitimate sequential - // research. A model that navigates across many DISTINCT URLs (each blocked - // here by the loopback SSRF gate, so no Chromium launches) is making progress, - // not looping — the per-URL recent-window guard must let it run to the - // iteration cap rather than tripping the navigation loop-breaker. (Loopback - // URLs all yield the same generic block message, so distinct ports differ - // only in arguments — exactly the trace signature the false-positive showed: - // climbing nav count with identicalRunLength 1.) - test("does NOT trip the navigation loop-breaker on distinct-URL research", async () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - const config = buildConfig(workspaceRoot, "chat-task-nav-distinct"); - config.agent = { maxIterations: 9 }; - const provider = normalizeProvider(config.provider); - // Pin the tool-catalog floor so the many-iteration geometry is decoupled - // from live always-on catalog size (cleared in afterEach) — this test is - // about the nav guard, not the context window. Dispatch keys on the call - // name, so browser_navigate still hits the SSRF gate as before. - __setBaseToolCatalogForTests(FIXED_COMPACTION_CATALOG); - - // Nine navigations to nine DISTINCT loopback URLs. Each is SSRF-blocked - // pre-flight (deterministic, no browser), but the nav guard counts the - // call regardless of result — and since every URL is new, the count never - // climbs. The loop exhausts the iteration cap instead. - for (let i = 1; i <= 9; i++) { - setEchoToolCallingResponse({ - provider, - text: "", - toolCalls: [ - { - id: `call_nav_${i}`, - type: "function", - function: { name: "browser_navigate", arguments: JSON.stringify({ url: `http://127.0.0.1:${i}/` }) } - } - ], - finishReason: "tool_calls" - }); - } - // Tool-less summary turn the cap-exhaustion exit consumes. - setEchoToolCallingResponse({ - provider, - text: "I checked nine different pages but ran out of steps before finishing.", - toolCalls: [], - finishReason: "stop" - }); - - const task = await submitTask(config, "research across pages", { mode: "chat" }); - const finished = await waitForTerminal(config, task.id, 10000); - - expect(finished.status).toBe("completed"); - // Reached the iteration cap — NOT the loop-breaker. - expect(finished.currentStep).toBe("Completed (iteration cap reached: 9)"); - - const { readTrace } = await import("../state"); - const traces = readTrace(config.instance, task.id); - // No navigation loop-breaker warning fired. - const navBreaker = traces.find( - (t) => t.type === "warning" && /navigations to recently-visited URLs.*loop-breaker/.test(t.message) - ); - expect(navBreaker).toBeUndefined(); - // The cap warning is what stopped us. - const capWarning = traces.find((t) => t.type === "warning" && /Iteration cap \(9\)/.test(t.message)); - expect(capWarning).toBeDefined(); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - // The navigation loop-breaker's protection MUST NOT regress: a model that - // oscillates between a small set of URLs (the degenerate reload/ping-pong - // pattern behind the original context-overflow incident) still trips it at - // the existing threshold. Oscillating between TWO URLs keeps the exact-match - // and action-only guards from firing (arguments alternate every turn), so - // only the navigation guard can catch it — isolating the guard under test. - test("STILL trips the navigation loop-breaker on oscillating-URL reload loops", async () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - const config = buildConfig(workspaceRoot, "chat-task-nav-oscillate"); - const provider = normalizeProvider(config.provider); - // Pinned floor for the same reason as the distinct-URL test above. - __setBaseToolCatalogForTests(FIXED_COMPACTION_CATALOG); - - // Two distinct first-visits seed the recent-URL window (count stays 0), - // then alternating between them is a repeat every turn so the count climbs - // to MAX_NAVIGATION_WITHOUT_ACTION (8): 2 seed + 8 repeats = 10 navigations. - const urls = ["http://127.0.0.1:1/", "http://127.0.0.1:2/"]; - for (let i = 0; i < 10; i++) { - setEchoToolCallingResponse({ - provider, - text: "", - toolCalls: [ - { - id: `call_osc_${i}`, - type: "function", - function: { name: "browser_navigate", arguments: JSON.stringify({ url: urls[i % 2] }) } - } - ], - finishReason: "tool_calls" - }); - } - // Tool-less summary turn the loop-breaker exit consumes. - setEchoToolCallingResponse({ - provider, - text: "I kept bouncing between the same two pages without getting anywhere.", - toolCalls: [], - finishReason: "stop" - }); - - const task = await submitTask(config, "open these pages", { mode: "chat" }); - const finished = await waitForTerminal(config, task.id, 10000); - - expect(finished.status).toBe("completed"); - expect(finished.currentStep).toBe("Completed (stopped: tool loop made no progress)"); - - const { readTrace } = await import("../state"); - const traces = readTrace(config.instance, task.id); - const navBreaker = traces.find( - (t) => t.type === "warning" && /navigations to recently-visited URLs.*loop-breaker/.test(t.message) - ); - expect(navBreaker).toBeDefined(); - // Stopped well before the iteration cap: 10 navigation turns + 1 summary turn. - const calls = getEchoToolCallingCalls(); - expect(calls.length).toBe(11); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - // Invalid `agent.maxIterations` values must fall back to the built-in - // default and emit a warning trace. We only verify the warning trace - // here — proving the fallback value matches the default would require running - // a full-length loop, which is wasteful; the resolver is small enough - // that the warning's presence is sufficient evidence. - test("invalid agent.maxIterations falls back to the default and emits a warning trace", async () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - const config = buildConfig(workspaceRoot, "chat-task-cap-invalid"); - // Invalid: 0 is non-positive. Loose-typed cast so the test can simulate - // a config.json that was hand-edited with a bad value. - (config as unknown as { agent: { maxIterations: number } }).agent = { maxIterations: 0 }; - const provider = normalizeProvider(config.provider); - - setEchoToolCallingResponse({ - provider, - text: "Direct answer.", - toolCalls: [], - finishReason: "stop" - }); - - const task = await submitTask(config, "say something", { mode: "chat" }); - const finished = await waitForTerminal(config, task.id); - - expect(finished.status).toBe("completed"); - expect(finished.summary).toBe("Direct answer."); - - const { readTrace } = await import("../state"); - const traces = readTrace(config.instance, task.id); - const warning = traces.find( - (t) => t.type === "warning" && /agent\.maxIterations/i.test(String(t.data?.reason ?? "")) - ); - expect(warning).toBeDefined(); - expect((warning?.data as Record | undefined)?.defaultCap).toBe(200); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - test("invalid agent.priorContextTokens falls back to the provider-derived default", async () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - const config = buildConfig(workspaceRoot, "chat-task-prior-context-invalid"); - (config as unknown as { agent: { priorContextTokens: number } }).agent = { priorContextTokens: 0 }; - const provider = normalizeProvider(config.provider); - const expectedDefault = resolveDefaultPriorContextTokenBudget(provider); - - setEchoToolCallingResponse({ - provider, - text: "Direct answer.", - toolCalls: [], - finishReason: "stop" - }); - - const task = await submitTask(config, "say something", { mode: "chat" }); - const finished = await waitForTerminal(config, task.id); - - expect(finished.status).toBe("completed"); - expect(finished.summary).toBe("Direct answer."); - - const { readTrace } = await import("../state"); - const traces = readTrace(config.instance, task.id); - const warning = traces.find( - (t) => t.type === "warning" && /agent\.priorContextTokens/i.test(String(t.data?.reason ?? "")) - ); - expect(warning).toBeDefined(); - expect((warning?.data as Record | undefined)?.defaultBudget).toBe(expectedDefault); - - const contextTrace = traces.find( - (t) => t.type === "model" && t.message === "chat-task system context built" - ); - const contextData = contextTrace?.data as Record | undefined; - expect(contextData?.priorContextTokenDefault).toBe(expectedDefault); - expect(contextData?.priorContextTokenRequested).toBe(expectedDefault); - expect(typeof contextData?.priorContextTokenAvailable).toBe("number"); - expect(contextData?.priorContextTokenBudget as number).toBeLessThanOrEqual(expectedDefault); - expect(contextData?.priorContextTokenBudget as number) - .toBeLessThanOrEqual(contextData?.priorContextTokenAvailable as number); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - test("oversized agent.priorContextTokens override is clamped to available provider context", async () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - const config = buildConfig(workspaceRoot, "chat-task-prior-context-clamp"); - config.agent = { priorContextTokens: 1_000_000 }; - const provider = normalizeProvider(config.provider); - - setEchoToolCallingResponse({ - provider, - text: "Direct answer.", - toolCalls: [], - finishReason: "stop" - }); - - const task = await submitTask(config, "say something", { mode: "chat" }); - const finished = await waitForTerminal(config, task.id); - - expect(finished.status).toBe("completed"); - - const { readTrace } = await import("../state"); - const traces = readTrace(config.instance, task.id); - const warning = traces.find( - (t) => t.type === "warning" && /exceeds available provider context/i.test(t.message) - ); - expect(warning).toBeDefined(); - - const contextTrace = traces.find( - (t) => t.type === "model" && t.message === "chat-task system context built" - ); - const contextData = contextTrace?.data as Record | undefined; - expect(contextData?.priorContextTokenRequested).toBe(1_000_000); - expect(contextData?.priorContextTokenBudget as number) - .toBeLessThanOrEqual(contextData?.priorContextTokenAvailable as number); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - // Fix 2 (cost accumulation): each iteration's cost must add to the - // running total instead of overwriting. Tested across a 3-iteration - // run where the stub provider returns small but nonzero usage on - // every turn — including the final tool-less summary turn the cap - // exhaustion path emits. - test("accumulates cost across iterations including the cap-exhaustion summary turn", async () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - const fixturePath = join(workspaceRoot, "hello.md"); - writeFileSync(fixturePath, "Hello, world!"); - const config = buildConfig(workspaceRoot, "chat-task-cost-accum"); - config.agent = { maxIterations: 2 }; - const provider = normalizeProvider(config.provider); - - // Two tool-call iterations, each reporting 10 in / 5 out / 15 total. - for (let i = 0; i < 2; i++) { - setEchoToolCallingResponse({ - provider, - text: "", - toolCalls: [ - { - id: `call_acc_${i}`, - type: "function", - function: { name: "file_read", arguments: JSON.stringify({ path: "hello.md" }) } - } - ], - finishReason: "tool_calls", - cost: { provider: "echo", model: "gini-echo-v0", inputTokens: 10, outputTokens: 5, totalTokens: 15 } - }); - } - // Cap-exhaustion summary turn — adds another 4 in / 6 out / 10 total. - setEchoToolCallingResponse({ - provider, - text: "Cap reached.", - toolCalls: [], - finishReason: "stop", - cost: { provider: "echo", model: "gini-echo-v0", inputTokens: 4, outputTokens: 6, totalTokens: 10 } - }); - - const task = await submitTask(config, "loop a bit", { mode: "chat" }); - const finished = await waitForTerminal(config, task.id, 10000); - - expect(finished.status).toBe("completed"); - expect(finished.summary).toBe("Cap reached."); - // 10 + 10 + 4 = 24 input tokens, 5 + 5 + 6 = 16 output tokens, 15 + 15 + 10 = 40 total. - expect(finished.cost?.inputTokens).toBe(24); - expect(finished.cost?.outputTokens).toBe(16); - expect(finished.cost?.totalTokens).toBe(40); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - // Fix 4 (warning de-duplication): the invalid-config warning should be - // emitted at most once per task even when runLoop is re-entered after - // approval pauses. We force a pause via a gated tool call, approve it, - // then assert the trace contains exactly one matching warning. - test("invalid agent.maxIterations warning is emitted at most once across approval resumes", async () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - const config = buildConfig(workspaceRoot, "chat-task-warn-once"); - (config as unknown as { agent: { maxIterations: number } }).agent = { maxIterations: 0 }; - const provider = normalizeProvider(config.provider); - - // First model turn: request a file write (gated → pauses the task). - setEchoToolCallingResponse({ - provider, - text: "", - toolCalls: [ - { id: "call_wo", type: "function", function: { name: "file_write", arguments: JSON.stringify({ path: "out.txt", content: "x" }) } } - ], - finishReason: "tool_calls" - }); - // Resume turn: final answer. - setEchoToolCallingResponse({ - provider, - text: "Done.", - toolCalls: [], - finishReason: "stop" - }); - - const task = await submitTask(config, "write and finish", { mode: "chat" }); - const paused = await waitForTerminal(config, task.id); - expect(paused.status).toBe("waiting_approval"); - await decideApproval(config, paused.approvalIds[0]!, "approve"); - const finished = await waitForFinalTerminal(config, task.id); - expect(finished.status).toBe("completed"); - - const { readTrace } = await import("../state"); - const traces = readTrace(config.instance, task.id); - const warnings = traces.filter( - (t) => t.type === "warning" && /agent\.maxIterations/i.test(String(t.data?.reason ?? "")) - ); - expect(warnings.length).toBe(1); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - // Also accept the same invalid-string case (e.g. "abc") to confirm the - // resolver's typeof guard rejects non-numbers, not just non-positive - // integers. - test("non-numeric agent.maxIterations also falls back with a warning", async () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - const config = buildConfig(workspaceRoot, "chat-task-cap-invalid-string"); - (config as unknown as { agent: { maxIterations: string } }).agent = { maxIterations: "abc" }; - const provider = normalizeProvider(config.provider); - - setEchoToolCallingResponse({ - provider, - text: "Hello.", - toolCalls: [], - finishReason: "stop" - }); - - const task = await submitTask(config, "say hi again", { mode: "chat" }); - const finished = await waitForTerminal(config, task.id); - - expect(finished.status).toBe("completed"); - - const { readTrace } = await import("../state"); - const traces = readTrace(config.instance, task.id); - const warning = traces.find( - (t) => t.type === "warning" && /agent\.maxIterations/i.test(String(t.data?.reason ?? "")) - ); - expect(warning).toBeDefined(); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - // Bound-jobs system block: when the chat session backing the current task - // has one or more JobRecords whose `chatSessionId` matches, the chat-task - // loop must surface them in the system prompt. The model uses that block - // to short-circuit list_jobs and call update_job / delete_job directly. - test("appends a Bound scheduled jobs block when a job is bound to the session", async () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - const config = buildConfig(workspaceRoot, "chat-task-bound-job"); - const provider = normalizeProvider(config.provider); - - // Stand up a chat session, a job pointing at it, and a run that lives in - // the same conversation. The submitted task carries that runId so the - // chat-task loop resolves the session id via run.conversationId and - // finds the bound job during system-prompt assembly. - const { runId, jobId } = await mutateState(config.instance, (state) => { - const session = createChatSession(state, "Daily standup"); - const run = createRun(state, { - kind: "conversation_turn", - title: "Standup turn", - input: "kick off", - conversationId: session.id - }); - const at = now(); - const job: JobRecord = { - id: "job-standup", - instance: state.instance, - name: "Daily standup", - prompt: "Ask the team what they did yesterday and what they will do today.", - intervalSeconds: undefined, - status: "active", - deliveryTargets: [], - context: [], - retryLimit: 0, - timeoutSeconds: 600, - chatSessionId: session.id, - cronExpression: "0 9 * * *", - cronTimezone: "America/Los_Angeles", - createdAt: at, - updatedAt: at, - nextRunAt: at, - runCount: 0, - missedRuns: 0, - taskIds: [], - runIds: [] - }; - state.jobs.push(job); - return { runId: run.id, jobId: job.id }; - }); - - setEchoToolCallingResponse({ - provider, - text: "Ready.", - toolCalls: [], - finishReason: "stop" - }); - - const task = await submitTask(config, "remind me what this job does", { mode: "chat", runId }); - const finished = await waitForTerminal(config, task.id); - expect(finished.status).toBe("completed"); - - const calls = getEchoToolCallingCalls(); - expect(calls.length).toBeGreaterThan(0); - const system = calls[0]!.find((m) => m.role === "system"); - expect(system).toBeDefined(); - const content = String(system?.content ?? ""); - expect(content).toContain("Scheduled jobs delivering into this chat:"); - expect(content).toContain(jobId); - expect(content).toContain("Daily standup"); - expect(content).toContain("cron `0 9 * * *`"); - expect(content).toContain("America/Los_Angeles"); - expect(content).toContain("Ask the team what they did yesterday"); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - test("omits the Bound scheduled jobs block when no job is bound to the session", async () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - const config = buildConfig(workspaceRoot, "chat-task-no-bound-job"); - const provider = normalizeProvider(config.provider); - - setEchoToolCallingResponse({ - provider, - text: "Hi.", - toolCalls: [], - finishReason: "stop" - }); - - // No runId / no chat session bound — the system context should not - // carry the block header and should not pick up stray trailing - // whitespace from a `${...}\n\n${empty}` template. - const task = await submitTask(config, "say hi", { mode: "chat" }); - const finished = await waitForTerminal(config, task.id); - expect(finished.status).toBe("completed"); - - const calls = getEchoToolCallingCalls(); - expect(calls.length).toBeGreaterThan(0); - const system = calls[0]!.find((m) => m.role === "system"); - expect(system).toBeDefined(); - const content = String(system?.content ?? ""); - expect(content).not.toContain("Scheduled jobs delivering into this chat:"); - // No trailing blank lines from optional sections being concatenated. - expect(content).toBe(content.trimEnd()); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - test("subagent path preserves the subagent prompt and still appends the bound-jobs block", async () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - const config = buildConfig(workspaceRoot, "chat-task-bound-job-subagent"); - const provider = normalizeProvider(config.provider); - - const SUBAGENT_PROMPT = "You are a narrow research subagent. Stay terse."; - const { runId, subagentId, jobId } = await mutateState(config.instance, (state) => { - const session = createChatSession(state, "Research thread"); - const run = createRun(state, { - kind: "conversation_turn", - title: "Research turn", - input: "kick off", - conversationId: session.id - }); - const subagent = createSubagentRecord(state, { - name: "researcher", - prompt: "research subagent", - toolsets: ["file"], - systemPrompt: SUBAGENT_PROMPT - }); - const at = now(); - const job: JobRecord = { - id: "job-research", - instance: state.instance, - name: "Weekly research digest", - prompt: "Summarize this week's top three industry stories.", - intervalSeconds: 604800, - status: "active", - deliveryTargets: [], - context: [], - retryLimit: 0, - timeoutSeconds: 600, - chatSessionId: session.id, - createdAt: at, - updatedAt: at, - nextRunAt: at, - runCount: 0, - missedRuns: 0, - taskIds: [], - runIds: [] - }; - state.jobs.push(job); - return { runId: run.id, subagentId: subagent.id, jobId: job.id }; - }); - - setEchoToolCallingResponse({ - provider, - text: "Done.", - toolCalls: [], - finishReason: "stop" - }); - - const task = await submitTask(config, "go", { mode: "chat", runId, subagentId }); - const finished = await waitForTerminal(config, task.id); - expect(finished.status).toBe("completed"); - - const calls = getEchoToolCallingCalls(); - expect(calls.length).toBeGreaterThan(0); - const system = calls[0]!.find((m) => m.role === "system"); - const content = String(system?.content ?? ""); - // Subagent prompt preserved verbatim at the top of system context. - expect(content.startsWith(SUBAGENT_PROMPT)).toBe(true); - // Scheduled-jobs context block still appended after the subagent prompt. - expect(content).toContain("Scheduled jobs delivering into this chat:"); - expect(content).toContain(jobId); - expect(content).toContain("Weekly research digest"); - expect(content).toContain("every 604800s"); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - // [SILENT] sentinel suppression at the chat-block layer. A scheduled - // job (or fan-out subagent worker) that has nothing to report responds - // with exactly "[SILENT]". The legacy message layer drops the - // ChatMessageRecord, but the UI renders chat blocks — so a completed - // turn whose final text is exactly "[SILENT]" must NOT leave a visible - // assistant_text block behind. - test("suppresses the assistant_text block when the final turn text is exactly [SILENT]", async () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - const config = buildConfig(workspaceRoot, "chat-task-silent-suppress"); - const provider = normalizeProvider(config.provider); - - const sessionId = await mutateState(config.instance, (state) => { - const session = createChatSession(state, "Inbox triage"); - const run = createRun(state, { - kind: "conversation_turn", - title: "Watcher turn", - input: "kick off", - conversationId: session.id - }); - return { runId: run.id, sessionId: session.id }; - }); - - setEchoToolCallingResponse({ - provider, - text: "[SILENT]", - toolCalls: [], - finishReason: "stop" - }); - - const task = await submitTask(config, "anything new?", { mode: "chat", runId: sessionId.runId, chatSessionId: sessionId.sessionId }); - const finished = await waitForTerminal(config, task.id); - expect(finished.status).toBe("completed"); - - const blocks = listChatBlocks(config.instance, sessionId.sessionId); - expect(blocks.some((b) => b.kind === "assistant_text")).toBe(false); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - test("does NOT suppress when the final text merely contains [SILENT]", async () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - const config = buildConfig(workspaceRoot, "chat-task-silent-contains"); - const provider = normalizeProvider(config.provider); - - const sessionId = await mutateState(config.instance, (state) => { - const session = createChatSession(state, "Inbox triage"); - const run = createRun(state, { - kind: "conversation_turn", - title: "Watcher turn", - input: "kick off", - conversationId: session.id - }); - return { runId: run.id, sessionId: session.id }; - }); - - setEchoToolCallingResponse({ - provider, - text: "[SILENT] but here's an update", - toolCalls: [], - finishReason: "stop" - }); - - const task = await submitTask(config, "anything new?", { mode: "chat", runId: sessionId.runId, chatSessionId: sessionId.sessionId }); - const finished = await waitForTerminal(config, task.id); - expect(finished.status).toBe("completed"); - - const blocks = listChatBlocks(config.instance, sessionId.sessionId); - const assistantText = blocks.filter((b) => b.kind === "assistant_text"); - expect(assistantText).toHaveLength(1); - expect(assistantText[0]).toMatchObject({ text: "[SILENT] but here's an update" }); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - test("writes a normal assistant_text block when the final text is real content", async () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - const config = buildConfig(workspaceRoot, "chat-task-silent-normal"); - const provider = normalizeProvider(config.provider); - - const sessionId = await mutateState(config.instance, (state) => { - const session = createChatSession(state, "Inbox triage"); - const run = createRun(state, { - kind: "conversation_turn", - title: "Watcher turn", - input: "kick off", - conversationId: session.id - }); - return { runId: run.id, sessionId: session.id }; - }); - - setEchoToolCallingResponse({ - provider, - text: "You have one new invoice from Acme.", - toolCalls: [], - finishReason: "stop" - }); - - const task = await submitTask(config, "anything new?", { mode: "chat", runId: sessionId.runId, chatSessionId: sessionId.sessionId }); - const finished = await waitForTerminal(config, task.id); - expect(finished.status).toBe("completed"); - - const blocks = listChatBlocks(config.instance, sessionId.sessionId); - const assistantText = blocks.filter((b) => b.kind === "assistant_text"); - expect(assistantText).toHaveLength(1); - expect(assistantText[0]).toMatchObject({ text: "You have one new invoice from Acme." }); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - test("emits the full runtime identity block on the first turn of a chat session", async () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - const config = buildConfig(workspaceRoot, "chat-task-identity-first"); - const provider = normalizeProvider(config.provider); - - const { runId } = await mutateState(config.instance, (state) => { - const session = createChatSession(state, "Identity probe"); - const run = createRun(state, { - kind: "conversation_turn", - title: "Identity turn", - input: "intro", - conversationId: session.id - }); - return { runId: run.id }; - }); - - setEchoToolCallingResponse({ - provider, - text: "Identity acknowledged.", - toolCalls: [], - finishReason: "stop" - }); - - const task = await submitTask(config, "what's your setup?", { mode: "chat", runId }); - const finished = await waitForTerminal(config, task.id); - expect(finished.status).toBe("completed"); - - const calls = getEchoToolCallingCalls(); - expect(calls.length).toBeGreaterThan(0); - // The emitted identity now rides in the ephemeral role:"user" tail - // placed immediately before the real user message — NOT in the - // byte-stable system prefix. See ADR stable-system-prefix.md. - const turn = calls[0]!; - const system = turn.find((m) => m.role === "system"); - const systemContent = String(system?.content ?? ""); - expect(systemContent).not.toContain("Your runtime identity:"); - const userIdx = turn.findIndex((m) => m.role === "user" && m.content === "what's your setup?"); - expect(userIdx).toBeGreaterThan(0); - const tail = turn[userIdx - 1]!; - expect(tail.role).toBe("user"); - const tailContent = String(tail.content ?? ""); - expect(tailContent).toContain("Your runtime identity:"); - expect(tailContent).toContain(`- instance: ${config.instance}`); - expect(tailContent).toContain(`- runtime port: ${config.port}`); - expect(tailContent).toContain("- provider: echo/"); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - test("subagent path skips runtime identity injection", async () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - const config = buildConfig(workspaceRoot, "chat-task-identity-subagent"); - const provider = normalizeProvider(config.provider); - - const SUBAGENT_PROMPT = "You are a narrow research subagent. Stay terse."; - const { runId, subagentId } = await mutateState(config.instance, (state) => { - const session = createChatSession(state, "Identity subagent probe"); - const run = createRun(state, { - kind: "conversation_turn", - title: "Subagent turn", - input: "kick off", - conversationId: session.id - }); - const subagent = createSubagentRecord(state, { - name: "researcher", - prompt: "research subagent", - toolsets: ["file"], - systemPrompt: SUBAGENT_PROMPT - }); - return { runId: run.id, subagentId: subagent.id }; - }); - - setEchoToolCallingResponse({ - provider, - text: "Done.", - toolCalls: [], - finishReason: "stop" - }); - - const task = await submitTask(config, "go", { mode: "chat", runId, subagentId }); - const finished = await waitForTerminal(config, task.id); - expect(finished.status).toBe("completed"); - - const calls = getEchoToolCallingCalls(); - expect(calls.length).toBeGreaterThan(0); - const turn = calls[0]!; - const system = turn.find((m) => m.role === "system"); - const content = String(system?.content ?? ""); - expect(content.startsWith(SUBAGENT_PROMPT)).toBe(true); - expect(content).not.toContain("Your runtime identity:"); - expect(content).not.toContain("Runtime identity changes since last turn:"); - // Subagents keep their single override prompt + the real user message: - // no ephemeral identity/memory tail is injected on the subagent path. - expect(turn.filter((m) => m.role === "user").length).toBe(1); - for (const m of turn) { - const text = String(m.content ?? ""); - expect(text).not.toContain("Your runtime identity:"); - expect(text).not.toContain("Runtime identity changes since last turn:"); - } - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - // Client-surface injection: the surface of the message that started THIS - // turn rides in the ephemeral role:"user" tail (never the byte-stable - // system prefix), and is omitted entirely when unknown. See ADR - // client-surface-context.md. - test("injects the current message's client-surface line into the ephemeral tail, per turn", async () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - const config = buildConfig(workspaceRoot, "chat-task-surface"); - const provider = normalizeProvider(config.provider); - const { createChat } = await import("./chat"); - const session = await createChat(config, { title: "Surface probe" }); - - // Turn 1 from the web, turn 2 from the phone — the same session can - // alternate surfaces, so each turn must carry only its OWN surface. - setEchoToolCallingResponse({ provider, text: "ok", toolCalls: [], finishReason: "stop" }); - const first = await submitChatMessage(config, session.id, { content: "desktop turn", client: "web" }); - expect((await waitForTerminal(config, first.taskId)).status).toBe("completed"); - - setEchoToolCallingResponse({ provider, text: "ok", toolCalls: [], finishReason: "stop" }); - const second = await submitChatMessage(config, session.id, { content: "phone turn", client: "mobile" }); - expect((await waitForTerminal(config, second.taskId)).status).toBe("completed"); - - const calls = getEchoToolCallingCalls(); - const turn1 = calls[0]!; - const turn2 = calls[calls.length - 1]!; - - // Turn 1: the web line rides in the tail immediately before the user - // message, and the system prefix stays surface-free. - const userIdx1 = turn1.findIndex((m) => m.role === "user" && m.content === "desktop turn"); - expect(userIdx1).toBeGreaterThan(0); - const tail1 = String(turn1[userIdx1 - 1]!.content ?? ""); - expect(tail1).toContain("The user is messaging from the web app"); - expect(String(turn1.find((m) => m.role === "system")?.content ?? "")).not.toContain("The user is messaging"); - - // Turn 2: only the mobile line — the prior turn's web line must not - // replay into this turn's context. - const userIdx2 = turn2.findIndex((m) => m.role === "user" && m.content === "phone turn"); - expect(userIdx2).toBeGreaterThan(0); - const tail2 = String(turn2[userIdx2 - 1]!.content ?? ""); - expect(tail2).toContain("The user is messaging from the mobile app"); - expect(tail2).toContain("a browser handoff can't reach them"); - for (const m of turn2) { - expect(String(m.content ?? "")).not.toContain("The user is messaging from the web app"); - } - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - test("derives the surface line from a bridge session without a client field", async () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - const config = buildConfig(workspaceRoot, "chat-task-surface-bridge"); - const provider = normalizeProvider(config.provider); - const sessionId = await mutateState(config.instance, (state) => { - const session = createChatSession(state, "Telegram probe", { - kind: "telegram", - bridgeId: "bridge_1", - chatId: 42, - target: "42" - }); - return session.id; - }); - - setEchoToolCallingResponse({ provider, text: "ok", toolCalls: [], finishReason: "stop" }); - const submitted = await submitChatMessage(config, sessionId, { content: "bridge turn" }); - expect((await waitForTerminal(config, submitted.taskId)).status).toBe("completed"); - - const turn = getEchoToolCallingCalls()[0]!; - const userIdx = turn.findIndex((m) => m.role === "user" && m.content === "bridge turn"); - expect(userIdx).toBeGreaterThan(0); - const tail = String(turn[userIdx - 1]!.content ?? ""); - expect(tail).toContain("The user is messaging from Telegram"); - expect(tail).toContain("a browser handoff can't reach them"); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - test("omits the surface line entirely when the surface is unknown", async () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - const config = buildConfig(workspaceRoot, "chat-task-surface-unknown"); - const provider = normalizeProvider(config.provider); - const { createChat } = await import("./chat"); - const session = await createChat(config, { title: "Unknown surface probe" }); - - setEchoToolCallingResponse({ provider, text: "ok", toolCalls: [], finishReason: "stop" }); - const submitted = await submitChatMessage(config, session.id, { content: "untagged turn" }); - expect((await waitForTerminal(config, submitted.taskId)).status).toBe("completed"); - - // No claim anywhere in the turn — not a hedged "unknown surface" line. - const turn = getEchoToolCallingCalls()[0]!; - for (const m of turn) { - expect(String(m.content ?? "")).not.toContain("The user is messaging"); - } - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - test("omits the identity block on a follow-up turn when nothing changed", async () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - const config = buildConfig(workspaceRoot, "chat-task-identity-followup"); - const provider = normalizeProvider(config.provider); - - const sessionId = await mutateState(config.instance, (state) => { - const session = createChatSession(state, "Identity follow-up"); - return session.id; - }); - - // Two distinct runs in the same conversation — same session id, two - // separate user turns. The second turn must not re-emit the identity - // block because nothing changed under the K=10 refresh threshold. - const firstRunId = await mutateState(config.instance, (state) => { - const run = createRun(state, { - kind: "conversation_turn", - title: "Turn 1", - input: "first", - conversationId: sessionId - }); - return run.id; - }); - - setEchoToolCallingResponse({ - provider, - text: "First.", - toolCalls: [], - finishReason: "stop" - }); - - const firstTask = await submitTask(config, "first question", { mode: "chat", runId: firstRunId }); - await waitForTerminal(config, firstTask.id); - - const secondRunId = await mutateState(config.instance, (state) => { - const run = createRun(state, { - kind: "conversation_turn", - title: "Turn 2", - input: "second", - conversationId: sessionId - }); - return run.id; - }); - - setEchoToolCallingResponse({ - provider, - text: "Second.", - toolCalls: [], - finishReason: "stop" - }); - - const secondTask = await submitTask(config, "second question", { mode: "chat", runId: secondRunId }); - await waitForTerminal(config, secondTask.id); - - const calls = getEchoToolCallingCalls(); - expect(calls.length).toBe(2); - // Identity now rides in the ephemeral role:"user" tail, so check every - // message of each turn — not just the (now identity-free) system prefix. - const firstTurnText = calls[0]!.map((m) => String(m.content ?? "")).join("\n"); - const secondTurnText = calls[1]!.map((m) => String(m.content ?? "")).join("\n"); - // First turn emits the full identity (in the tail); the system prefix - // itself never carries it anymore. - expect(firstTurnText).toContain("Your runtime identity:"); - expect(String(calls[0]!.find((m) => m.role === "system")?.content ?? "")).not.toContain("Your runtime identity:"); - // Quiet follow-up turn: no identity anywhere — neither system nor tail - // (and with nothing recalled, no tail message is injected at all). - expect(secondTurnText).not.toContain("Your runtime identity:"); - expect(secondTurnText).not.toContain("Runtime identity changes since last turn:"); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - test("deferred snapshot write skips when the chat session was deleted before the model returned", async () => { - // Race: snapshot decision is made up-front in runChatTask, but the - // write is deferred to runLoop after the first model call. If the - // chat session is deleted during that window, the deferred write - // must not recreate an orphan snapshot keyed on a now-deleted - // session id. We deterministically simulate the race by deleting - // the session before the task ever runs -- the run still exists, - // so runChatTask resolves the conversationId, but the deferred - // write's session-existence check inside mutateState catches the - // deletion and skips the write. - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - const config = buildConfig(workspaceRoot, "chat-task-identity-orphan-guard"); - const provider = normalizeProvider(config.provider); - - const { runId, sessionId } = await mutateState(config.instance, (state) => { - const session = createChatSession(state, "Soon-to-be-deleted"); - const run = createRun(state, { - kind: "conversation_turn", - title: "Orphan probe", - input: "go", - conversationId: session.id - }); - return { runId: run.id, sessionId: session.id }; - }); - - // Delete the chat session before the task runs. runChatTask will - // still build identity from the run.conversationId, but the - // deferred write must observe that the session is gone. - await mutateState(config.instance, (state) => { - deleteChatSession(state, sessionId); - }); - - setEchoToolCallingResponse({ - provider, - text: "Done.", - toolCalls: [], - finishReason: "stop" - }); - - const task = await submitTask(config, "anything", { mode: "chat", runId }); - await waitForTerminal(config, task.id); - - expect(readState(config.instance).identitySnapshots?.[sessionId]).toBeUndefined(); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - test("message 0 is a byte-stable prefix across two turns in the same session", async () => { - // Headline cache contract: with no identity/skill/job/connector change - // between turns, the system message (message 0) must be byte-identical - // turn-to-turn so automatic provider prefix caching stays warm. Message 0 - // is stable regardless of whether recall returns anything, because the - // per-turn-varying content (emitted identity, recalled memory) now lives - // in the ephemeral role:"user" tail rather than in message 0. See ADR - // stable-system-prefix.md. - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - const config = buildConfig(workspaceRoot, "chat-task-stable-prefix"); - const provider = normalizeProvider(config.provider); - - const sessionId = await mutateState(config.instance, (state) => { - const session = createChatSession(state, "Stable prefix"); - return session.id; - }); - - const firstRunId = await mutateState(config.instance, (state) => { - const run = createRun(state, { - kind: "conversation_turn", - title: "Turn 1", - input: "first", - conversationId: sessionId - }); - return run.id; - }); - setEchoToolCallingResponse({ provider, text: "One.", toolCalls: [], finishReason: "stop" }); - const firstTask = await submitTask(config, "first question", { mode: "chat", runId: firstRunId }); - await waitForTerminal(config, firstTask.id); - - const secondRunId = await mutateState(config.instance, (state) => { - const run = createRun(state, { - kind: "conversation_turn", - title: "Turn 2", - input: "second", - conversationId: sessionId - }); - return run.id; - }); - setEchoToolCallingResponse({ provider, text: "Two.", toolCalls: [], finishReason: "stop" }); - const secondTask = await submitTask(config, "second question", { mode: "chat", runId: secondRunId }); - await waitForTerminal(config, secondTask.id); - - const calls = getEchoToolCallingCalls(); - expect(calls.length).toBe(2); - expect(calls[0]![0]!.role).toBe("system"); - expect(calls[1]![0]!.role).toBe("system"); - // Byte equality of message 0 across the two turns is the whole point. - expect(calls[0]![0]!.content).toBe(calls[1]![0]!.content); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - test("delivers emitted identity then recalled memory in a role:user tail before the user message", async () => { - // Both per-turn blocks must still reach the model: the emitted identity - // (turn 1 of a session) and the recalled-memory block, in that order, - // inside a single role:"user" message placed immediately before the real - // user message. Recall is driven through the real pipeline with the echo - // embedder and the reranker pinned to `none` so it returns the seeded - // unit deterministically and offline. - const prevEmbed = process.env.GINI_EMBEDDING_PROVIDER; - const prevReranker = process.env.GINI_RERANKER_PROVIDER; - process.env.GINI_EMBEDDING_PROVIDER = "echo"; - process.env.GINI_RERANKER_PROVIDER = "none"; - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - const config = buildConfig(workspaceRoot, "chat-task-tail-delivery"); - const provider = normalizeProvider(config.provider); - const MEMORY_TEXT = "the user keeps bees on a rooftop in Lisbon"; - - try { - // Active agent so resolveEffectiveContext yields a memory namespace and - // recall runs; seed one matching unit into that agent's bank. - const agentId = "agent_tail"; - const sessionId = await mutateState(config.instance, (state) => { - state.agents.push({ - id: agentId, - instance: state.instance, - name: "tail", - providerName: "echo", - model: "gini-echo-v0", - toolsets: [], - messagingTargets: [], - status: "active", - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z" - }); - state.activeAgentId = agentId; - const session = createChatSession(state, "Tail delivery"); - return session.id; - }); - ensureDefaultBank(config.instance); - ensureAgentBank(config.instance, agentId); - insertMemoryUnit(config.instance, { - bankId: bankIdForAgent(agentId), - agentId, - text: MEMORY_TEXT, - embedding: echoEmbed(MEMORY_TEXT), - embeddingModel: "echo-embed-v0", - network: "world" - }); - - const runId = await mutateState(config.instance, (state) => { - const run = createRun(state, { - kind: "conversation_turn", - title: "Tail turn", - input: "intro", - conversationId: sessionId - }); - return run.id; - }); - - setEchoToolCallingResponse({ provider, text: "Noted.", toolCalls: [], finishReason: "stop" }); - const task = await submitTask(config, MEMORY_TEXT, { mode: "chat", runId }); - const finished = await waitForTerminal(config, task.id); - expect(finished.status).toBe("completed"); - - const calls = getEchoToolCallingCalls(); - expect(calls.length).toBeGreaterThan(0); - const turn = calls[0]!; - // The stable system prefix carries neither block anymore. - const systemContent = String(turn.find((m) => m.role === "system")?.content ?? ""); - expect(systemContent).not.toContain("Your runtime identity:"); - expect(systemContent).not.toContain("Long-term memory of prior conversations"); - // The tail is the role:"user" message immediately before the real input. - const userIdx = turn.findIndex((m) => m.role === "user" && m.content === MEMORY_TEXT); - expect(userIdx).toBeGreaterThan(0); - const tail = turn[userIdx - 1]!; - expect(tail.role).toBe("user"); - const tailContent = String(tail.content ?? ""); - const identityIdx = tailContent.indexOf("Your runtime identity:"); - const memoryIdx = tailContent.indexOf("Long-term memory of prior conversations"); - expect(identityIdx).toBeGreaterThanOrEqual(0); - expect(memoryIdx).toBeGreaterThanOrEqual(0); - expect(tailContent).toContain(MEMORY_TEXT); - // Identity before memory, mirroring the old system-prompt order. - expect(identityIdx).toBeLessThan(memoryIdx); - } finally { - if (prevEmbed === undefined) delete process.env.GINI_EMBEDDING_PROVIDER; - else process.env.GINI_EMBEDDING_PROVIDER = prevEmbed; - if (prevReranker === undefined) delete process.env.GINI_RERANKER_PROVIDER; - else process.env.GINI_RERANKER_PROVIDER = prevReranker; - rmSync(workspaceRoot, { recursive: true, force: true }); - } - }); - - test("does not replay a prior turn's ephemeral tail on the next turn", async () => { - // The tail is built live and never persisted, so the next turn's prior - // transcript (priorChatMessages reads only durable chatMessages) must not - // contain the previous tail's identity/memory text. Turn 1 emits identity - // into its tail; turn 2's outgoing messages must carry none of it as - // replayed history. Recall is isolated (no active agent), so the only - // tail content under test is the turn-1 identity block. - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - const config = buildConfig(workspaceRoot, "chat-task-no-double-inject"); - const provider = normalizeProvider(config.provider); - - const sessionId = await mutateState(config.instance, (state) => { - const session = createChatSession(state, "No double inject"); - return session.id; - }); - - const firstRunId = await mutateState(config.instance, (state) => { - const run = createRun(state, { - kind: "conversation_turn", - title: "Turn 1", - input: "first", - conversationId: sessionId - }); - return run.id; - }); - setEchoToolCallingResponse({ provider, text: "One.", toolCalls: [], finishReason: "stop" }); - const firstTask = await submitTask(config, "first question", { mode: "chat", runId: firstRunId }); - await waitForTerminal(config, firstTask.id); - - const secondRunId = await mutateState(config.instance, (state) => { - const run = createRun(state, { - kind: "conversation_turn", - title: "Turn 2", - input: "second", - conversationId: sessionId - }); - return run.id; - }); - setEchoToolCallingResponse({ provider, text: "Two.", toolCalls: [], finishReason: "stop" }); - const secondTask = await submitTask(config, "second question", { mode: "chat", runId: secondRunId }); - await waitForTerminal(config, secondTask.id); - - const calls = getEchoToolCallingCalls(); - expect(calls.length).toBe(2); - // Turn 1 emitted identity into its tail. - const firstTurnText = calls[0]!.map((m) => String(m.content ?? "")).join("\n"); - expect(firstTurnText).toContain("Your runtime identity:"); - // Turn 2's messages, EXCLUDING its own freshly-built tail, must not carry - // the prior turn's identity as replayed history. (The quiet second turn - // emits no identity of its own, so any occurrence would be a stale replay.) - const secondTurn = calls[1]!; - const userIdx = secondTurn.findIndex((m) => m.role === "user" && m.content === "second question"); - const historyAndPrefix = secondTurn.filter((_, i) => i !== userIdx - 1); - const historyText = historyAndPrefix.map((m) => String(m.content ?? "")).join("\n"); - expect(historyText).not.toContain("Your runtime identity:"); - // Durable transcript rows likewise never include the tail. - const stored = readState(config.instance).chatMessages.filter((m) => m.sessionId === sessionId); - for (const m of stored) { - expect(String(m.content ?? "")).not.toContain("Your runtime identity:"); - } - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - test("packs prior chat history for the provider without deleting stored history", async () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - const config = buildConfig(workspaceRoot, "chat-task-prior-context-pack", { - agent: { priorContextTokens: 80 } - }); - const provider = normalizeProvider(config.provider); - const sessionId = await mutateState(config.instance, (state) => { - const session = createChatSession(state, "Packed history"); - createChatMessage(state, { - sessionId: session.id, - role: "user", - content: `old secret should not be replayed ${"x".repeat(500)}` - }); - createChatMessage(state, { - sessionId: session.id, - role: "assistant", - content: `old answer should not be replayed ${"y".repeat(500)}` - }); - createChatMessage(state, { - sessionId: session.id, - role: "user", - content: "recent anchor question" - }); - createChatMessage(state, { - sessionId: session.id, - role: "assistant", - content: "recent anchor answer" - }); - return session.id; - }); - - const runId = await mutateState(config.instance, (state) => { - const run = createRun(state, { - kind: "conversation_turn", - title: "Packed turn", - input: "current", - conversationId: sessionId - }); - return run.id; - }); - - setEchoToolCallingResponse({ provider, text: "Done.", toolCalls: [], finishReason: "stop" }); - const task = await submitTask(config, "current question", { mode: "chat", runId }); - const finished = await waitForTerminal(config, task.id); - expect(finished.status).toBe("completed"); - - const firstCall = getEchoToolCallingCalls()[0]!; - const providerText = firstCall.map((m) => String(m.content ?? "")).join("\n"); - expect(providerText).toContain("Earlier chat history is outside the current model context."); - expect(providerText).toContain("recent anchor question"); - expect(providerText).toContain("recent anchor answer"); - expect(providerText).not.toContain("old secret should not be replayed"); - expect(providerText).not.toContain("old answer should not be replayed"); - - const storedText = readState(config.instance).chatMessages.map((m) => m.content).join("\n"); - expect(storedText).toContain("old secret should not be replayed"); - expect(storedText).toContain("old answer should not be replayed"); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - // ChatBlock protocol pin (ADR chat-block-protocol.md). The loop must - // emit a typed stream of blocks per chat session: user_text, phase, - // assistant_text, tool_call, tool_result, approval_requested, - // system_note. Tests run the loop against the echo provider with - // pre-loaded responses and assert the block list shape. - test("emits typed blocks for a successful tool-calling turn", async () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - const fixturePath = join(workspaceRoot, "hello.md"); - writeFileSync(fixturePath, "Hello, world!"); - const config = buildConfig(workspaceRoot, "chat-task-blocks-success"); - const provider = normalizeProvider(config.provider); - - // Set up the session BEFORE submitting the task so chatSessionId - // is bound to the task. submitTask threads chatSessionId through - // to createTask which the emission resolver reads. - const session = await mutateState(config.instance, (state) => - createChatSession(state, "block-test", undefined, "agent_x") - ); - - setEchoToolCallingResponse({ - provider, - text: "Sure, reading it now.", - toolCalls: [ - { id: "call_1", type: "function", function: { name: "file_read", arguments: JSON.stringify({ path: "hello.md" }) } } - ], - finishReason: "tool_calls" - }); - setEchoToolCallingResponse({ - provider, - text: "The file says: Hello, world!", - toolCalls: [], - finishReason: "stop" - }); - - const submitted = await submitChatMessage(config, session.id, { content: "what does hello.md say?" }); - const finished = await waitForTerminal(config, submitted.taskId); - expect(finished.status).toBe("completed"); - - const { listChatBlocks } = await import("../state"); - const blocks = listChatBlocks(config.instance, session.id); - // Expected sequence in ordinal order: - // user_text → phase("Thinking") → assistant_text("Sure, reading it now.") - // → phase("Working: file_read") → tool_call(file_read, ok) - // → tool_result(call_1) → phase("Thinking") → assistant_text(final) - // → phase("Completed") - const kinds = blocks.map((b) => b.kind); - expect(kinds[0]).toBe("user_text"); - expect(kinds).toContain("tool_call"); - expect(kinds).toContain("tool_result"); - expect(kinds[kinds.length - 1]).toBe("phase"); - - const user = blocks.find((b) => b.kind === "user_text"); - expect(user?.kind === "user_text" && user.text).toBe("what does hello.md say?"); - - const toolCall = blocks.find((b) => b.kind === "tool_call"); - if (toolCall?.kind === "tool_call") { - expect(toolCall.toolName).toBe("file_read"); - expect(toolCall.displayLabel).toBe("Read file"); - expect(toolCall.argsPreview).toBe("hello.md"); - expect(toolCall.argsFull).toEqual({ path: "hello.md" }); - expect(toolCall.status).toBe("ok"); - expect(toolCall.callId).toBe("call_1"); - } else { - throw new Error("missing tool_call block"); - } - - const toolResult = blocks.find((b) => b.kind === "tool_result"); - if (toolResult?.kind === "tool_result") { - expect(toolResult.callId).toBe("call_1"); - } else { - throw new Error("missing tool_result block"); - } - - // Final assistant_text is the model's reply (after the tool result - // turn) — settled with streaming:false. - const assistantTexts = blocks.filter((b): b is typeof blocks[0] & { kind: "assistant_text" } => - b.kind === "assistant_text" - ); - expect(assistantTexts.length).toBeGreaterThan(0); - const finalAssistant = assistantTexts[assistantTexts.length - 1]!; - expect(finalAssistant.streaming).toBe(false); - expect(finalAssistant.text).toContain("Hello, world!"); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - test("surfaces native web searches as display-only Web search chips", async () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - const config = buildConfig(workspaceRoot, "chat-task-web-search-chip"); - const provider = normalizeProvider(config.provider); - - const session = await mutateState(config.instance, (state) => - createChatSession(state, "block-websearch", undefined, "agent_ws") - ); - - // A hosted native-web-search turn: the provider ran the searches itself and - // returns their headlines in `webSearches` alongside a cited final answer - // (no dispatchable tool calls). The loop must surface each search as a chip. - setEchoToolCallingResponse({ - provider, - text: "The top story is X. ([hn](https://news.ycombinator.com/))", - toolCalls: [], - finishReason: "stop", - webSearches: ["hacker news top story", "https://news.ycombinator.com/"] - }); - - const submitted = await submitChatMessage(config, session.id, { content: "what's the top HN story?" }); - const finished = await waitForTerminal(config, submitted.taskId); - expect(finished.status).toBe("completed"); - - const { listChatBlocks } = await import("../state"); - const blocks = listChatBlocks(config.instance, session.id); - - const webSearchChips = blocks.filter( - (b): b is typeof blocks[0] & { kind: "tool_call" } => - b.kind === "tool_call" && b.toolName === "web_search" - ); - expect(webSearchChips.length).toBe(2); - // Display-only: "Web search" label, the query/url as the inline preview, - // settled straight to ok (the search already ran on the provider). - expect(webSearchChips.map((c) => c.displayLabel)).toEqual(["Web search", "Web search"]); - expect(webSearchChips.map((c) => c.argsPreview)).toEqual([ - "hacker news top story", - "https://news.ycombinator.com/" - ]); - expect(webSearchChips.every((c) => c.status === "ok")).toBe(true); - // Distinct call ids so the two searches render as separate rows. - expect(new Set(webSearchChips.map((c) => c.callId)).size).toBe(2); - // Nothing was dispatched, so the chips carry no paired tool_result. - expect(blocks.filter((b) => b.kind === "tool_result")).toHaveLength(0); - - // Ordering is load-bearing, not cosmetic: both chips must land BEFORE the - // assistant's answer block. A chip trailing the answer makes group-exchanges - // fold the answer out of its standalone bubble (finalAnswerIdx = -1). The - // provider announces each search mid-stream (before text) for exactly this. - // listChatBlocks is ordinal-ordered, so index position is the settled order. - const kinds = blocks.map((b) => b.kind); - const lastAnswerIdx = kinds.lastIndexOf("assistant_text"); - const chipIdxs = blocks - .map((b, i) => (b.kind === "tool_call" && b.toolName === "web_search" ? i : -1)) - .filter((i) => i >= 0); - expect(lastAnswerIdx).toBeGreaterThanOrEqual(0); - expect(Math.max(...chipIdxs)).toBeLessThan(lastAnswerIdx); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - test("emits authorization_requested with the action field for gated tools", async () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - const config = buildConfig(workspaceRoot, "chat-task-blocks-approval"); - const provider = normalizeProvider(config.provider); - - const session = await mutateState(config.instance, (state) => - createChatSession(state, "block-approval", undefined, "agent_y") - ); - - setEchoToolCallingResponse({ - provider, - text: "", - toolCalls: [ - { id: "call_w", type: "function", function: { name: "file_write", arguments: JSON.stringify({ path: "out.txt", content: "hi" }) } } - ], - finishReason: "tool_calls" - }); - setEchoToolCallingResponse({ - provider, - text: "Wrote it.", - toolCalls: [], - finishReason: "stop" - }); - - const submitted = await submitChatMessage(config, session.id, { content: "write out.txt" }); - const paused = await waitForTerminal(config, submitted.taskId); - expect(paused.status).toBe("waiting_approval"); - - const { listChatBlocks } = await import("../state"); - let blocks = listChatBlocks(config.instance, session.id); - const approval = blocks.find((b) => b.kind === "authorization_requested"); - if (approval?.kind === "authorization_requested") { - expect(approval.authorizationId).toBe(paused.approvalIds[0]); - expect(approval.action).toBe("file.write"); - expect(approval.risk).toBeDefined(); - } else { - throw new Error("missing authorization_requested block"); - } - - // Resume by approving; the tool_call flips ok and a tool_result lands. - await decideApproval(config, paused.approvalIds[0]!, "approve"); - const finished = await waitForFinalTerminal(config, submitted.taskId); - expect(finished.status).toBe("completed"); - - blocks = listChatBlocks(config.instance, session.id); - const toolCall = blocks.find((b) => b.kind === "tool_call"); - if (toolCall?.kind === "tool_call") { - expect(toolCall.status).toBe("ok"); - } else { - throw new Error("missing tool_call block"); - } - const toolResult = blocks.find((b) => b.kind === "tool_result"); - expect(toolResult).toBeDefined(); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - test("approving a gated tool emits a Working phase before the side effect runs", async () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - const config = buildConfig(workspaceRoot, "chat-task-blocks-approval-phase"); - const provider = normalizeProvider(config.provider); - - const session = await mutateState(config.instance, (state) => - createChatSession(state, "block-approval-phase", undefined, "agent_y2") - ); - - setEchoToolCallingResponse({ - provider, - text: "", - toolCalls: [ - { id: "call_wp", type: "function", function: { name: "file_write", arguments: JSON.stringify({ path: "out2.txt", content: "hi" }) } } - ], - finishReason: "tool_calls" - }); - setEchoToolCallingResponse({ - provider, - text: "Wrote it.", - toolCalls: [], - finishReason: "stop" - }); - - const submitted = await submitChatMessage(config, session.id, { content: "write out2.txt" }); - const paused = await waitForTerminal(config, submitted.taskId); - expect(paused.status).toBe("waiting_approval"); - - const { listChatBlocks } = await import("../state"); - const gate = listChatBlocks(config.instance, session.id).find( - (b) => b.kind === "authorization_requested" - ); - if (!gate) throw new Error("missing authorization_requested block"); - - await decideApproval(config, paused.approvalIds[0]!, "approve"); - const finished = await waitForFinalTerminal(config, submitted.taskId); - expect(finished.status).toBe("completed"); - - // The approval flip itself lands a non-terminal phase NEWER than the - // gate block. Without it, the backwards activity scan (thread lists, - // panel composer) keeps reporting waiting_approval for the entire - // side-effect execution window — the approved action can run for its - // full timeout before the resumed loop writes anything else. - const blocks = listChatBlocks(config.instance, session.id); - const working = blocks.find((b) => b.kind === "phase" && b.label === "Working: file.write"); - if (working?.kind !== "phase") throw new Error("missing approval Working phase block"); - expect(working.ordinal).toBeGreaterThan(gate.ordinal); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - test("completing a setup request lands a Working phase after the gate", async () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - const config = buildConfig(workspaceRoot, "chat-task-blocks-setup-phase"); - const provider = normalizeProvider(config.provider); - - const session = await mutateState(config.instance, (state) => - createChatSession(state, "block-setup-phase", undefined, "agent_z2") - ); - - setEchoToolCallingResponse({ - provider, - text: "", - toolCalls: [ - { - id: "call_sp", - type: "function", - function: { - name: "request_connector", - arguments: JSON.stringify({ provider: "brave-search", reason: "Need web search." }) - } - } - ], - finishReason: "tool_calls" - }); - - const submitted = await submitChatMessage(config, session.id, { content: "search the web" }); - const paused = await waitForTerminal(config, submitted.taskId); - expect(paused.status).toBe("waiting_approval"); - - const { listChatBlocks } = await import("../state"); - const gate = listChatBlocks(config.instance, session.id).find((b) => b.kind === "setup_requested"); - if (gate?.kind !== "setup_requested") throw new Error("missing setup_requested block"); - - // The /complete handlers claim the row with resumeChatTask:false and run - // their (potentially slow) side effects afterwards; connector.request is - // mapped to emit a Working phase on complete, covering that window so the - // activity scan stops reading a resolved gate as waiting_approval. - const { resolveSetupRequest } = await import("../agent"); - await resolveSetupRequest(config, gate.setupRequestId, "complete", { - actor: "user", - resumeChatTask: false - }); - - const blocks = listChatBlocks(config.instance, session.id); - const working = blocks.find((b) => b.kind === "phase" && b.label === "Working: connector.request"); - if (working?.kind !== "phase") throw new Error("missing setup-complete Working phase block"); - expect(working.ordinal).toBeGreaterThan(gate.ordinal); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - test("connector.request surfaces the reason as an assistant bubble above the setup card", async () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - const config = buildConfig(workspaceRoot, "chat-task-blocks-connector"); - const provider = normalizeProvider(config.provider); - - const session = await mutateState(config.instance, (state) => - createChatSession(state, "block-connector", undefined, "agent_z") - ); - - const reason = "Brave Search would help me find a fresh answer here. Want to connect it?"; - setEchoToolCallingResponse({ - provider, - text: "", - toolCalls: [ - { id: "call_c", type: "function", function: { name: "request_connector", arguments: JSON.stringify({ provider: "brave-search", reason }) } } - ], - finishReason: "tool_calls" - }); - - const submitted = await submitChatMessage(config, session.id, { content: "search the web for the best cafe" }); - const paused = await waitForTerminal(config, submitted.taskId); - expect(paused.status).toBe("waiting_approval"); - - const { listChatBlocks } = await import("../state"); - const blocks = listChatBlocks(config.instance, session.id); - - // The minimal setup card carries no inline reason; the reason is its - // own assistant bubble, ordered above the card. - const setupIdx = blocks.findIndex((b) => b.kind === "setup_requested"); - expect(setupIdx).toBeGreaterThanOrEqual(0); - const setup = blocks[setupIdx]; - if (setup?.kind === "setup_requested") { - expect(setup.action).toBe("connector.request"); - } else { - throw new Error("missing setup_requested block"); - } - const reasonIdx = blocks.findIndex((b) => b.kind === "assistant_text" && b.text === reason); - expect(reasonIdx).toBeGreaterThanOrEqual(0); - expect(reasonIdx).toBeLessThan(setupIdx); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - test("connector.request cancel resumes the chat loop with a fallback result", async () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - const config = buildConfig(workspaceRoot, "chat-task-blocks-connector-cancel"); - const provider = normalizeProvider(config.provider); - - const session = await mutateState(config.instance, (state) => - createChatSession(state, "block-connector-cancel", undefined, "agent_z") - ); - - const reason = "I need Brave Search access to answer with current weather."; - setEchoToolCallingResponse({ - provider, - text: "", - toolCalls: [ - { id: "call_c", type: "function", function: { name: "request_connector", arguments: JSON.stringify({ provider: "brave-search", reason }) } } - ], - finishReason: "tool_calls" - }); - setEchoToolCallingResponse({ - provider, - text: "I can't look up current weather without Brave Search access. Connect Brave Search or provide the weather details.", - toolCalls: [], - finishReason: "stop" - }); - - const submitted = await submitChatMessage(config, session.id, { content: "what is the weather in sf today" }); - const paused = await waitForTerminal(config, submitted.taskId); - expect(paused.status).toBe("waiting_approval"); - - const setup = readState(config.instance).setupRequests.find((s) => s.taskId === submitted.taskId); - expect(setup?.action).toBe("connector.request"); - - await resolveSetupRequest(config, setup!.id, "cancel", { actor: "user" }); - - let finished = readState(config.instance).tasks.find((t) => t.id === submitted.taskId); - const deadline = Date.now() + 5000; - while (finished?.status !== "completed" && Date.now() < deadline) { - await Bun.sleep(TERMINAL_POLL_MS); - finished = readState(config.instance).tasks.find((t) => t.id === submitted.taskId); - } - expect(finished?.status).toBe("completed"); - expect(finished?.summary).toBe( - "I can't look up current weather without Brave Search access. Connect Brave Search or provide the weather details." - ); - - const { listChatBlocks } = await import("../state"); - const blocks = listChatBlocks(config.instance, session.id); - let lastPhase = blocks[blocks.length - 1]; - for (let i = blocks.length - 1; i >= 0; i--) { - if (blocks[i]?.kind === "phase") { - lastPhase = blocks[i]; - break; - } - } - expect(lastPhase?.kind).toBe("phase"); - if (lastPhase?.kind === "phase") { - expect(lastPhase.label).toBe("Completed"); - expect(lastPhase.taskId).toBe(submitted.taskId); - } - const requestConnectorCall = blocks.find( - (b) => b.kind === "tool_call" && b.toolName === "request_connector" - ); - expect(requestConnectorCall?.kind).toBe("tool_call"); - if (requestConnectorCall?.kind === "tool_call") { - expect(requestConnectorCall.status).toBe("ok"); - } - expect(blocks.some((b) => b.kind === "tool_result" && b.preview.includes("User canceled connector setup for brave-search"))).toBe(true); - expect(blocks.some((b) => b.kind === "assistant_text" && b.text === finished?.summary)).toBe(true); - expect(readState(config.instance).setupRequests.find((s) => s.id === setup!.id)?.status).toBe("cancelled"); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - test("ask_user pauses the turn with a chat.choice setup card and no reason bubble", async () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - const config = buildConfig(workspaceRoot, "chat-task-blocks-choice"); - const provider = normalizeProvider(config.provider); - - const session = await mutateState(config.instance, (state) => - createChatSession(state, "block-choice", undefined, "agent_q") - ); - - const question = "How should I search the web?"; - setEchoToolCallingResponse({ - provider, - text: "", - toolCalls: [ - { - id: "call_q", - type: "function", - function: { - name: "ask_user", - arguments: JSON.stringify({ - question, - options: [ - { label: "Set up Brave only" }, - { label: "Neither — use web_fetch", description: "No setup needed" } - ] - }) - } - } - ], - finishReason: "tool_calls" - }); - - const submitted = await submitChatMessage(config, session.id, { content: "find the best cafe" }); - const paused = await waitForTerminal(config, submitted.taskId); - // An all-chat.choice park is reclassified as needs_input. - expect(paused.status).toBe("needs_input"); - - const setup = readState(config.instance).setupRequests.find((s) => s.taskId === submitted.taskId); - expect(setup?.action).toBe("chat.choice"); - expect(setup?.payload.question).toBe(question); - - const { listChatBlocks } = await import("../state"); - const blocks = listChatBlocks(config.instance, session.id); - const setupBlock = blocks.find((b) => b.kind === "setup_requested"); - if (setupBlock?.kind === "setup_requested") { - expect(setupBlock.action).toBe("chat.choice"); - // The summary IS the question — that's what transcripts/sessions show. - expect(setupBlock.summary).toBe(question); - } else { - throw new Error("missing setup_requested block"); - } - // Unlike connector.request, no assistant bubble accompanies the card — - // the question lives in the card itself. - expect(blocks.some((b) => b.kind === "assistant_text")).toBe(false); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - test("chat.choice cancel (Skip) resumes the chat loop with the skip fallback", async () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - const config = buildConfig(workspaceRoot, "chat-task-blocks-choice-cancel"); - const provider = normalizeProvider(config.provider); - - const session = await mutateState(config.instance, (state) => - createChatSession(state, "block-choice-cancel", undefined, "agent_q") - ); - - setEchoToolCallingResponse({ - provider, - text: "", - toolCalls: [ - { - id: "call_q", - type: "function", - function: { - name: "ask_user", - arguments: JSON.stringify({ - question: "Which format do you want?", - options: [{ label: "Markdown" }, { label: "Plain text" }] - }) - } - } - ], - finishReason: "tool_calls" - }); - setEchoToolCallingResponse({ - provider, - text: "I'll go with Markdown since it reads best in chat.", - toolCalls: [], - finishReason: "stop" - }); - - const submitted = await submitChatMessage(config, session.id, { content: "export my notes" }); - const paused = await waitForTerminal(config, submitted.taskId); - expect(paused.status).toBe("needs_input"); - - const setup = readState(config.instance).setupRequests.find((s) => s.taskId === submitted.taskId); - expect(setup?.action).toBe("chat.choice"); - - await resolveSetupRequest(config, setup!.id, "cancel", { actor: "user" }); - - let finished = readState(config.instance).tasks.find((t) => t.id === submitted.taskId); - const deadline = Date.now() + 5000; - while (finished?.status !== "completed" && Date.now() < deadline) { - await Bun.sleep(TERMINAL_POLL_MS); - finished = readState(config.instance).tasks.find((t) => t.id === submitted.taskId); - } - // Skip must resume the loop, NOT fail the task. - expect(finished?.status).toBe("completed"); - expect(finished?.summary).toBe("I'll go with Markdown since it reads best in chat."); - - const { listChatBlocks } = await import("../state"); - const blocks = listChatBlocks(config.instance, session.id); - const askUserCall = blocks.find((b) => b.kind === "tool_call" && b.toolName === "ask_user"); - expect(askUserCall?.kind).toBe("tool_call"); - if (askUserCall?.kind === "tool_call") { - expect(askUserCall.status).toBe("ok"); - } - expect(blocks.some((b) => b.kind === "tool_result" && b.preview.includes("User skipped the question"))).toBe(true); - expect(readState(config.instance).setupRequests.find((s) => s.id === setup!.id)?.status).toBe("cancelled"); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - test("request_confirmation pauses the turn with a confirmation.request setup card and no reason bubble", async () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - const config = buildConfig(workspaceRoot, "chat-task-blocks-confirm"); - const provider = normalizeProvider(config.provider); - - const session = await mutateState(config.instance, (state) => - createChatSession(state, "block-confirm", undefined, "agent_c") - ); - - const summary = "Send this reply to Dana in the project thread"; - setEchoToolCallingResponse({ - provider, - text: "", - toolCalls: [ - { - id: "call_c", - type: "function", - function: { - name: "request_confirmation", - arguments: JSON.stringify({ summary, details: "Hi Dana — ship it.", confirmLabel: "Send" }) - } - } - ], - finishReason: "tool_calls" - }); - - const submitted = await submitChatMessage(config, session.id, { content: "reply to Dana that it's good" }); - const paused = await waitForTerminal(config, submitted.taskId); - expect(paused.status).toBe("waiting_approval"); - - const setup = readState(config.instance).setupRequests.find((s) => s.taskId === submitted.taskId); - expect(setup?.action).toBe("confirmation.request"); - expect(setup?.payload.summary).toBe(summary); - expect(setup?.payload.confirmLabel).toBe("Send"); - - const { listChatBlocks } = await import("../state"); - const blocks = listChatBlocks(config.instance, session.id); - const setupBlock = blocks.find((b) => b.kind === "setup_requested"); - if (setupBlock?.kind === "setup_requested") { - expect(setupBlock.action).toBe("confirmation.request"); - // The summary IS the block summary — that's what transcripts show. - expect(setupBlock.summary).toBe(summary); - } else { - throw new Error("missing setup_requested block"); - } - // Like chat.choice, no assistant bubble accompanies the card — the summary - // lives in the card itself. - expect(blocks.some((b) => b.kind === "assistant_text")).toBe(false); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - test("confirmation.request cancel resumes the chat loop with {confirmed:false}", async () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - const config = buildConfig(workspaceRoot, "chat-task-blocks-confirm-cancel"); - const provider = normalizeProvider(config.provider); - - const session = await mutateState(config.instance, (state) => - createChatSession(state, "block-confirm-cancel", undefined, "agent_c") - ); - - setEchoToolCallingResponse({ - provider, - text: "", - toolCalls: [ - { - id: "call_c", - type: "function", - function: { - name: "request_confirmation", - arguments: JSON.stringify({ summary: "Send the reply to Dana" }) - } - } - ], - finishReason: "tool_calls" - }); - setEchoToolCallingResponse({ - provider, - text: "Okay, I won't send it. What should I change?", - toolCalls: [], - finishReason: "stop" - }); - - const submitted = await submitChatMessage(config, session.id, { content: "reply to Dana" }); - const paused = await waitForTerminal(config, submitted.taskId); - expect(paused.status).toBe("waiting_approval"); - - const setup = readState(config.instance).setupRequests.find((s) => s.taskId === submitted.taskId); - expect(setup?.action).toBe("confirmation.request"); - - await resolveSetupRequest(config, setup!.id, "cancel", { actor: "user" }); - - let finished = readState(config.instance).tasks.find((t) => t.id === submitted.taskId); - const deadline = Date.now() + 5000; - while (finished?.status !== "completed" && Date.now() < deadline) { - await Bun.sleep(TERMINAL_POLL_MS); - finished = readState(config.instance).tasks.find((t) => t.id === submitted.taskId); - } - // Cancel must resume the loop, NOT fail the task. - expect(finished?.status).toBe("completed"); - expect(finished?.summary).toBe("Okay, I won't send it. What should I change?"); - - const { listChatBlocks } = await import("../state"); - const blocks = listChatBlocks(config.instance, session.id); - // The model receives an unambiguous boolean from the cancel. - expect(blocks.some((b) => b.kind === "tool_result" && b.preview.includes('"confirmed":false'))).toBe(true); - expect(readState(config.instance).setupRequests.find((s) => s.id === setup!.id)?.status).toBe("cancelled"); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - test("emits parallel tool_calls with distinct callIds and ordinals", async () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - writeFileSync(join(workspaceRoot, "a.md"), "alpha"); - writeFileSync(join(workspaceRoot, "b.md"), "beta"); - const config = buildConfig(workspaceRoot, "chat-task-blocks-parallel"); - const provider = normalizeProvider(config.provider); - - const session = await mutateState(config.instance, (state) => - createChatSession(state, "block-parallel", undefined, "agent_p") - ); - - setEchoToolCallingResponse({ - provider, - text: "", - toolCalls: [ - { id: "call_a", type: "function", function: { name: "file_read", arguments: JSON.stringify({ path: "a.md" }) } }, - { id: "call_b", type: "function", function: { name: "file_read", arguments: JSON.stringify({ path: "b.md" }) } } - ], - finishReason: "tool_calls" - }); - setEchoToolCallingResponse({ - provider, - text: "Read both.", - toolCalls: [], - finishReason: "stop" - }); - - const submitted = await submitChatMessage(config, session.id, { content: "read both" }); - const finished = await waitForTerminal(config, submitted.taskId); - expect(finished.status).toBe("completed"); - - const { listChatBlocks } = await import("../state"); - const blocks = listChatBlocks(config.instance, session.id); - const toolCalls = blocks.filter((b): b is typeof blocks[0] & { kind: "tool_call" } => b.kind === "tool_call"); - expect(toolCalls).toHaveLength(2); - expect(toolCalls.map((c) => c.callId).sort()).toEqual(["call_a", "call_b"]); - expect(toolCalls.map((c) => c.ordinal)).toEqual( - toolCalls.map((c) => c.ordinal).slice().sort((x, y) => x - y) - ); - expect(toolCalls.every((c) => c.status === "ok")).toBe(true); - - const toolResults = blocks.filter((b) => b.kind === "tool_result"); - expect(toolResults).toHaveLength(2); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - test("subagent tasks (no chat session) skip block emission entirely", async () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - const config = buildConfig(workspaceRoot, "chat-task-blocks-subagent"); - const provider = normalizeProvider(config.provider); - - setEchoToolCallingResponse({ - provider, - text: "Subagent done.", - toolCalls: [], - finishReason: "stop" - }); - - // Submit a task without a chatSessionId — equivalent to a subagent - // child or a CLI imperative task. The loop should run to - // completion but no chat_blocks rows should land. - const task = await submitTask(config, "subagent prompt", { mode: "chat" }); - const finished = await waitForTerminal(config, task.id); - expect(finished.status).toBe("completed"); - - // No session => no rows in chat_blocks for this task. We can't - // grep by taskId alone (no helper exposed) so just confirm we - // wrote zero rows by asking the DB directly via getMemoryDb. - const { getMemoryDb } = await import("../state"); - const db = getMemoryDb(config.instance); - const count = db - .query<{ c: number }, [string]>("SELECT COUNT(*) AS c FROM chat_blocks WHERE task_id = ?") - .get(task.id)?.c ?? 0; - expect(count).toBe(0); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - test("cancellation flips streaming assistant_text to settled and emits cancellation block", async () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - const config = buildConfig(workspaceRoot, "chat-task-blocks-cancel"); - const provider = normalizeProvider(config.provider); - - const session = await mutateState(config.instance, (state) => - createChatSession(state, "block-cancel", undefined, "agent_c") - ); - - // Single response that finishes immediately — the test exercises - // post-completion cancelTask emission rather than a true - // mid-stream cancel (which requires provider-stream injection - // outside the echo provider's contract). The chat-block invariant - // we test here: cancelTask emits system_note + Cancelled phase - // even after the task has already settled. - setEchoToolCallingResponse({ - provider, - text: "Hi.", - toolCalls: [], - finishReason: "stop" - }); - - const submitted = await submitChatMessage(config, session.id, { content: "say hi" }); - const finished = await waitForTerminal(config, submitted.taskId); - expect(finished.status).toBe("completed"); - - // Now cancel the (already-completed) task. cancelTask is idempotent - // for terminal tasks — it returns the row as-is — but the - // chat-block emission still happens unconditionally in the current - // implementation. We verify the invariant differently: by re- - // running through a fresh task that we cancel BEFORE waiting for - // it to settle. - const { cancelTask, submitTask } = await import("../agent"); - - setEchoToolCallingResponse({ - provider, - text: "second response", - toolCalls: [], - finishReason: "stop" - }); - - // Manually create a queued task tied to the same chat session, - // then cancel before runChatTask gets a chance to flip to - // running. The terminal status guard at the top of runChatTask - // detects the cancellation and bails out cleanly. - const cancelTarget = await submitTask(config, "will-be-cancelled", { - mode: "chat", - chatSessionId: session.id - }); - await cancelTask(config, cancelTarget.id); - - const { listChatBlocks } = await import("../state"); - const blocks = listChatBlocks(config.instance, session.id); - const sysNotes = blocks.filter((b) => b.kind === "system_note"); - expect(sysNotes.some((n) => n.kind === "system_note" && n.text === "Cancelled")).toBe(true); - const phases = blocks.filter((b) => b.kind === "phase"); - expect(phases.some((p) => p.kind === "phase" && p.label === "Cancelled")).toBe(true); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - test("deleteChatSession cascades and removes all chat blocks", async () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - const config = buildConfig(workspaceRoot, "chat-task-blocks-cascade"); - const provider = normalizeProvider(config.provider); - - const session = await mutateState(config.instance, (state) => - createChatSession(state, "block-cascade", undefined, "agent_d") - ); - - setEchoToolCallingResponse({ - provider, - text: "Hello back.", - toolCalls: [], - finishReason: "stop" - }); - - const submitted = await submitChatMessage(config, session.id, { content: "hi" }); - await waitForTerminal(config, submitted.taskId); - - const { listChatBlocks } = await import("../state"); - expect(listChatBlocks(config.instance, session.id).length).toBeGreaterThan(0); - - await mutateState(config.instance, (state) => deleteChatSession(state, session.id)); - expect(listChatBlocks(config.instance, session.id)).toHaveLength(0); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - // Tool-calling transcript persistence + replay (ADR - // agent-loop-tool-calling.md). A prior turn's assistant tool_calls and its - // paired role:"tool" results are persisted durably (kind:"tool_transcript") - // and replayed next turn so the model sees the structured results of its - // own earlier actions instead of re-deriving them. - test("a structured tool result from a prior turn is replayed on the next turn", async () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - const config = buildConfig(workspaceRoot, "chat-task-transcript-id"); - const provider = normalizeProvider(config.provider); - const fixturePath = join(workspaceRoot, "issue.json"); - // The "create issue" stand-in: a tool the loop dispatches synchronously - // whose result carries an id the agent must remember next turn. - const issueResult = JSON.stringify({ ok: true, issueId: "ISSUE-4242" }); - writeFileSync(fixturePath, issueResult); - - const { createChat, syncChatTaskResult } = await import("./chat"); - const session = await createChat(config, { title: "Issue thread" }); - - // Turn 1: read the file (returns the issue id), then a final answer. - setEchoToolCallingResponse({ - provider, - text: "", - toolCalls: [ - { id: "call_issue", type: "function", function: { name: "file_read", arguments: JSON.stringify({ path: "issue.json" }) } } - ], - finishReason: "tool_calls" - }); - setEchoToolCallingResponse({ - provider, - text: "Created issue ISSUE-4242.", - toolCalls: [], - finishReason: "stop" - }); - - const first = await submitChatMessage(config, session.id, { content: "create an issue" }); - await waitForTerminal(config, first.taskId); - await syncChatTaskResult(config, session.id, first.taskId); - - // Turn 2: the model just answers — we only care about the transcript it - // was handed. - setEchoToolCallingResponse({ - provider, - text: "Editing ISSUE-4242 as requested.", - toolCalls: [], - finishReason: "stop" - }); - const second = await submitChatMessage(config, session.id, { content: "now edit that issue" }); - await waitForTerminal(config, second.taskId); - - // Locate turn 2's provider call: the one whose user message is the - // second prompt. - const calls = getEchoToolCallingCalls(); - const turn2 = calls.find((messages) => - messages.some((m) => m.role === "user" && m.content === "now edit that issue") - ); - expect(turn2).toBeDefined(); - - // The replayed transcript carries the assistant tool_calls message AND - // the paired role:"tool" result with the issue id. - const assistantToolCalls = turn2!.find( - (m) => m.role === "assistant" && Array.isArray(m.tool_calls) && m.tool_calls.length > 0 - ); - expect(assistantToolCalls).toBeDefined(); - expect(assistantToolCalls!.tool_calls![0]!.id).toBe("call_issue"); - - const toolResult = turn2!.find((m) => m.role === "tool" && m.tool_call_id === "call_issue"); - expect(toolResult).toBeDefined(); - expect(String(toolResult!.content)).toContain("ISSUE-4242"); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - test("two turns reusing the same tool_call_id each replay with their own result", async () => { - // The text-backstop path synthesizes call ids from name:args:index, so - // the same tool called with the same args on two turns yields an - // identical tool_call_id. Pairing must stay local to each assistant row: - // turn 1's call gets turn 1's result, turn 2's call gets turn 2's, even - // though both rows share the id. - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - const config = buildConfig(workspaceRoot, "chat-task-transcript-dupid"); - const provider = normalizeProvider(config.provider); - const fixturePath = join(workspaceRoot, "state.txt"); - - const { createChat, syncChatTaskResult } = await import("./chat"); - const session = await createChat(config, { title: "Dup-id thread" }); - - // Turn 1: read the file (content "FIRST"), reusing the colliding id. - writeFileSync(fixturePath, "FIRST"); - setEchoToolCallingResponse({ - provider, - text: "", - toolCalls: [ - { id: "call_dup", type: "function", function: { name: "file_read", arguments: JSON.stringify({ path: "state.txt" }) } } - ], - finishReason: "tool_calls" - }); - setEchoToolCallingResponse({ - provider, - text: "Read FIRST.", - toolCalls: [], - finishReason: "stop" - }); - const first = await submitChatMessage(config, session.id, { content: "read it once" }); - await waitForTerminal(config, first.taskId); - await syncChatTaskResult(config, session.id, first.taskId); - - // Turn 2: read the same file (now "SECOND") with the SAME tool_call_id. - writeFileSync(fixturePath, "SECOND"); - setEchoToolCallingResponse({ - provider, - text: "", - toolCalls: [ - { id: "call_dup", type: "function", function: { name: "file_read", arguments: JSON.stringify({ path: "state.txt" }) } } - ], - finishReason: "tool_calls" - }); - setEchoToolCallingResponse({ - provider, - text: "Read SECOND.", - toolCalls: [], - finishReason: "stop" - }); - const second = await submitChatMessage(config, session.id, { content: "read it again" }); - await waitForTerminal(config, second.taskId); - await syncChatTaskResult(config, session.id, second.taskId); - - // Turn 3: plain answer — we only inspect the transcript it was handed. - setEchoToolCallingResponse({ - provider, - text: "Done.", - toolCalls: [], - finishReason: "stop" - }); - const third = await submitChatMessage(config, session.id, { content: "what changed?" }); - await waitForTerminal(config, third.taskId); - - const calls = getEchoToolCallingCalls(); - const turn3 = calls.find((messages) => - messages.some((m) => m.role === "user" && m.content === "what changed?") - ); - expect(turn3).toBeDefined(); - - // Both assistant tool_calls rows replay, each immediately followed by its - // OWN paired result — turn 1 with "FIRST", turn 2 with "SECOND". - const toolResults: string[] = []; - for (let i = 0; i < turn3!.length; i++) { - const m = turn3![i]!; - if (m.role === "assistant" && Array.isArray(m.tool_calls) && m.tool_calls.some((c) => c.id === "call_dup")) { - const next = turn3![i + 1]; - expect(next?.role).toBe("tool"); - expect(next?.tool_call_id).toBe("call_dup"); - toolResults.push(String(next?.content ?? "")); - } - } - expect(toolResults.length).toBe(2); - expect(toolResults[0]).toContain("FIRST"); - expect(toolResults[0]).not.toContain("SECOND"); - expect(toolResults[1]).toContain("SECOND"); - expect(toolResults[1]).not.toContain("FIRST"); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - test("a read_skill body from a prior turn persists into the next turn's transcript", async () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - const config = buildConfig(workspaceRoot, "chat-task-transcript-skill"); - const provider = normalizeProvider(config.provider); - - const skill = await createSkillFromInput(config, { - name: "apple-notes", - description: "Apple Notes via memo CLI." - }); - const skillBody = "# Apple Notes\n\nUse `memo notes -a` to add a note."; - await mutateState(config.instance, (state) => { - const item = state.skills.find((s) => s.id === skill.id)!; - item.body = skillBody; - }); - await setSkillStatus(config, skill.id, "enabled"); - - const { createChat, syncChatTaskResult } = await import("./chat"); - const session = await createChat(config, { title: "Skill thread" }); - - // Turn 1: read the skill, then answer. - setEchoToolCallingResponse({ - provider, - text: "", - toolCalls: [ - { id: "call_skill", type: "function", function: { name: "read_skill", arguments: JSON.stringify({ name: "apple-notes" }) } } - ], - finishReason: "tool_calls" - }); - setEchoToolCallingResponse({ - provider, - text: "I now know how to use Apple Notes.", - toolCalls: [], - finishReason: "stop" - }); - - const first = await submitChatMessage(config, session.id, { content: "how do I add an apple note?" }); - await waitForTerminal(config, first.taskId); - await syncChatTaskResult(config, session.id, first.taskId); - - // Turn 2: a follow-up. The skill body must be in the replayed transcript - // so the model need not re-read it (Claude Code skill behavior). - setEchoToolCallingResponse({ - provider, - text: "Adding your note now.", - toolCalls: [], - finishReason: "stop" - }); - const second = await submitChatMessage(config, session.id, { content: "add a note that says hi" }); - await waitForTerminal(config, second.taskId); - - const calls = getEchoToolCallingCalls(); - const turn2 = calls.find((messages) => - messages.some((m) => m.role === "user" && m.content === "add a note that says hi") - ); - expect(turn2).toBeDefined(); - const skillToolResult = turn2!.find((m) => m.role === "tool" && m.tool_call_id === "call_skill"); - expect(skillToolResult).toBeDefined(); - expect(String(skillToolResult!.content)).toContain("memo notes -a"); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - test("the gated approval path persists and replays its tool result, keeping pairing valid", async () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - const config = buildConfig(workspaceRoot, "chat-task-transcript-gated"); - const provider = normalizeProvider(config.provider); - - const { createChat, syncChatTaskResult } = await import("./chat"); - const session = await createChat(config, { title: "Gated thread" }); - - // Turn 1: request a file write (approval-gated), then answer after resume. - setEchoToolCallingResponse({ - provider, - text: "", - toolCalls: [ - { id: "call_gated", type: "function", function: { name: "file_write", arguments: JSON.stringify({ path: "out.txt", content: "from-agent" }) } } - ], - finishReason: "tool_calls" - }); - setEchoToolCallingResponse({ - provider, - text: "Wrote the file as requested.", - toolCalls: [], - finishReason: "stop" - }); - - const first = await submitChatMessage(config, session.id, { content: "create out.txt" }); - const paused = await waitForTerminal(config, first.taskId); - expect(paused.status).toBe("waiting_approval"); - const approvalId = paused.approvalIds[0]!; - await decideApproval(config, approvalId, "approve"); - const finished = await waitForFinalTerminal(config, first.taskId); - expect(finished.status).toBe("completed"); - await syncChatTaskResult(config, session.id, first.taskId); - - // Turn 2: a follow-up. The gated tool's result must be replayed, and the - // assistant tool_calls row must be immediately followed by its paired - // role:"tool" result (provider ordering invariant). - setEchoToolCallingResponse({ - provider, - text: "Done.", - toolCalls: [], - finishReason: "stop" - }); - const second = await submitChatMessage(config, session.id, { content: "thanks" }); - await waitForTerminal(config, second.taskId); - - const calls = getEchoToolCallingCalls(); - const turn2 = calls.find((messages) => - messages.some((m) => m.role === "user" && m.content === "thanks") - ); - expect(turn2).toBeDefined(); - - const assistantIdx = turn2!.findIndex( - (m) => m.role === "assistant" && Array.isArray(m.tool_calls) && m.tool_calls!.some((c) => c.id === "call_gated") - ); - expect(assistantIdx).toBeGreaterThan(-1); - // The matching tool result must immediately follow its assistant message. - const nextMsg = turn2![assistantIdx + 1]; - expect(nextMsg?.role).toBe("tool"); - expect(nextMsg?.tool_call_id).toBe("call_gated"); - - // No orphan tool results: every role:"tool" message in the replay points - // at an assistant tool_call id that appears earlier in the same array. - const emittedCallIds = new Set(); - for (const m of turn2!) { - if (m.role === "assistant" && Array.isArray(m.tool_calls)) { - for (const c of m.tool_calls) emittedCallIds.add(c.id); - } - if (m.role === "tool") { - expect(typeof m.tool_call_id).toBe("string"); - expect(emittedCallIds.has(m.tool_call_id!)).toBe(true); - } - } - - // The durable transcript rows exist in state.chatMessages but are - // excluded from the human-facing JSON view. - const { getChatSession } = await import("./chat"); - const stored = readState(config.instance).chatMessages.filter( - (m) => m.sessionId === session.id && m.kind === "tool_transcript" - ); - expect(stored.length).toBeGreaterThan(0); - const view = getChatSession(config, session.id); - expect(view.messages.some((m) => m.kind === "tool_transcript")).toBe(false); - - // The full-state runtime views (/api/state, /api/mobile/bootstrap) must - // also drop transcript rows — they carry tool-call args and raw tool - // results (skill bodies, file contents) that have no place in a public - // state poll. - const { publicState, mobileBootstrap } = await import("../runtime/views"); - expect(publicState(config).chatMessages.some((m) => m.kind === "tool_transcript")).toBe(false); - expect(mobileBootstrap(config).chatMessages.some((m) => m.kind === "tool_transcript")).toBe(false); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - test("a gated tool that persists an approval_reason row still replays its call+result next turn", async () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - const config = buildConfig(workspaceRoot, "chat-task-transcript-gated-reason"); - const provider = normalizeProvider(config.provider); - - const { createChat, syncChatTaskResult } = await import("./chat"); - const session = await createChat(config, { title: "Connector thread" }); - - // Turn 1: request_connector (approval-gated) for a provider with no - // setupSkill, so it goes straight to a pending setup request. Unlike - // file_write, this path persists a plain assistant kind:"approval_reason" - // row BETWEEN the assistant tool_calls row and the on-resume tool result, - // which is exactly what the turn-window pairing must span. - setEchoToolCallingResponse({ - provider, - text: "", - toolCalls: [ - { id: "call_conn", type: "function", function: { name: "request_connector", arguments: JSON.stringify({ provider: "linear", reason: "list my open issues" }) } } - ], - finishReason: "tool_calls" - }); - setEchoToolCallingResponse({ - provider, - text: "Connected to Linear.", - toolCalls: [], - finishReason: "stop" - }); - - const first = await submitChatMessage(config, session.id, { content: "connect linear" }); - const paused = await waitForTerminal(config, first.taskId); - expect(paused.status).toBe("waiting_approval"); - - // An approval_reason row was persisted during dispatch — this is the - // interleaved non-tool row that the old window logic stopped at. - const reasonRows = readState(config.instance).chatMessages.filter( - (m) => m.sessionId === session.id && m.kind === "approval_reason" - ); - expect(reasonRows.length).toBeGreaterThan(0); - - // Resolve the setup request the way the /complete handler does: the - // synthesized toolResult is fed back via resumeChatTask, which persists - // the gated tool result row. - const setup = readState(config.instance).setupRequests.find((s) => s.taskId === first.taskId); - expect(setup).toBeDefined(); - await resolveSetupRequest(config, setup!.id, "complete", { - actor: "user", - toolResult: "Connected to Linear. Proceed with the original request." - }); - const finished = await waitForTerminal(config, first.taskId); - expect(finished.status).toBe("completed"); - await syncChatTaskResult(config, session.id, first.taskId); - - // Turn 2: a follow-up. The gated tool's call+result must survive replay - // even though an approval_reason row sits between them in the transcript. - setEchoToolCallingResponse({ - provider, - text: "Done.", - toolCalls: [], - finishReason: "stop" - }); - const second = await submitChatMessage(config, session.id, { content: "thanks" }); - await waitForTerminal(config, second.taskId); - - const calls = getEchoToolCallingCalls(); - const turn2 = calls.find((messages) => - messages.some((m) => m.role === "user" && m.content === "thanks") - ); - expect(turn2).toBeDefined(); - - const assistantIdx = turn2!.findIndex( - (m) => m.role === "assistant" && Array.isArray(m.tool_calls) && m.tool_calls!.some((c) => c.id === "call_conn") - ); - expect(assistantIdx).toBeGreaterThan(-1); - // The matching tool result must immediately follow its assistant message. - const nextMsg = turn2![assistantIdx + 1]; - expect(nextMsg?.role).toBe("tool"); - expect(nextMsg?.tool_call_id).toBe("call_conn"); - expect(String(nextMsg?.content)).toContain("Connected to Linear"); - - // No orphan tool results: every role:"tool" message in the replay points - // at an assistant tool_call id that appears earlier in the same array. - const emittedCallIds = new Set(); - for (const m of turn2!) { - if (m.role === "assistant" && Array.isArray(m.tool_calls)) { - for (const c of m.tool_calls) emittedCallIds.add(c.id); - } - if (m.role === "tool") { - expect(typeof m.tool_call_id).toBe("string"); - expect(emittedCallIds.has(m.tool_call_id!)).toBe(true); - } - } - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - test("advertises deferred tools on demand and loads one via load_tools, feeding back a callable confirmation", async () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - const config = buildConfig(workspaceRoot, "chat-task-deferred-load"); - const provider = normalizeProvider(config.provider); - - // Turn 1: load the browser_snapshot schema via load_tools. (We stop at the - // load round-trip rather than driving a real browser — the load branch is - // what this test pins; the browser dispatch itself is exercised live.) - setEchoToolCallingResponse({ - provider, - text: "", - toolCalls: [ - { id: "call_load", type: "function", function: { name: "load_tools", arguments: JSON.stringify({ names: ["browser_snapshot"] }) } } - ], - finishReason: "tool_calls" - }); - // Turn 2: final answer (no browser call, to keep the test hermetic). - setEchoToolCallingResponse({ - provider, - text: "Browser tools are ready.", - toolCalls: [], - finishReason: "stop" - }); - - const task = await submitTask(config, "get ready to browse", { mode: "chat" }); - const finished = await waitForTerminal(config, task.id, 10000); - expect(finished.status).toBe("completed"); - expect(finished.summary).toBe("Browser tools are ready."); - // loadedTools is cleared on terminal completion. - expect(finished.loadedTools).toBeUndefined(); - - const calls = getEchoToolCallingCalls(); - expect(calls.length).toBe(2); - // First model call: system prompt advertises the on-demand index with - // browser_snapshot by name. - const firstSystem = String(calls[0]!.find((m) => m.role === "system")?.content ?? ""); - expect(firstSystem).toContain("Tools available on demand"); - expect(firstSystem).toContain("browser_snapshot"); - - // After the load, the second model call's message history carries the - // load_tools tool result confirming the tool is now callable. - const secondTurn = calls[1]!; - const loadResult = secondTurn.find( - (m) => m.role === "tool" && typeof m.content === "string" && m.content.includes("callable directly") - ); - expect(loadResult).toBeDefined(); - const envelope = JSON.parse(String((loadResult as { content: string }).content)) as { - ok: boolean; - loaded: string[]; - }; - expect(envelope.ok).toBe(true); - expect(envelope.loaded).toContain("browser_snapshot"); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - test("the dispatcher's deferred guard nudges toward load_tools and does not over-trigger", async () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - const config = buildConfig(workspaceRoot, "chat-task-deferred-nudge"); - - const { dispatchToolCall } = await import("./tool-dispatch"); - const { isDeferredToolName } = await import("./tool-catalog"); - const { createTask, upsertTask } = await import("../state"); - const taskRow = createTask(config.instance, "nudge probe"); - await mutateState(config.instance, (state) => upsertTask(state, taskRow)); - - // A genuinely unknown (non-deferred) tool still throws Unknown tool — - // the guard is scoped to deferred names and does not over-trigger. - expect(isDeferredToolName("totally_made_up_tool")).toBe(false); - let threw = false; - try { - await dispatchToolCall(config, taskRow.id, "totally_made_up_tool", "call_x", "{}"); - } catch { - threw = true; - } - expect(threw).toBe(true); - - // browser_snapshot is a known deferred tool. The guard helper classifies - // it as deferred so a not-yet-loaded reference reaching the dispatcher's - // default case would return the recoverable load_tools nudge rather than - // throwing Unknown tool. - expect(isDeferredToolName("browser_snapshot")).toBe(true); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - // The riskiest invariant: a deferred tool the model loaded must stay live - // across an approval pause/resume. We load two deferred self tools - // (set_provider + get_self), call set_provider (strict → gates), assert the - // paused task carries loadedTools, then approve and — on the RESUMED turn — - // call get_self directly. get_self only dispatches successfully if it is - // still in providerTools after the resume, which proves runLoop re-seeds - // loadedToolNames from task.loadedTools (a merely-completing final-text turn - // would not prove the loaded schema survived). - test("loaded deferred tool set persists across an approval pause/resume and dispatches after resume", async () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - // strict so set_provider gates instead of auto-resolving. - const config = buildConfig(workspaceRoot, "chat-task-deferred-resume"); - const provider = normalizeProvider(config.provider); - - // Turn 1: load set_provider AND get_self. - setEchoToolCallingResponse({ - provider, - text: "", - toolCalls: [ - { id: "call_load", type: "function", function: { name: "load_tools", arguments: JSON.stringify({ names: ["set_provider", "get_self"] }) } } - ], - finishReason: "tool_calls" - }); - // Turn 2: call set_provider directly (top-level args). Strict → pauses. - setEchoToolCallingResponse({ - provider, - text: "", - toolCalls: [ - { id: "call_sp", type: "function", function: { name: "set_provider", arguments: JSON.stringify({ provider: "echo" }) } } - ], - finishReason: "tool_calls" - }); - // Turn 3 (the RESUMED turn): call the previously-loaded get_self directly. - // It's a query op → resolves sync (no second gate) and writes a self.get - // audit row, proving the loaded schema survived the resume. - setEchoToolCallingResponse({ - provider, - text: "", - toolCalls: [ - { id: "call_gs", type: "function", function: { name: "get_self", arguments: "{}" } } - ], - finishReason: "tool_calls" - }); - // Turn 4: final answer. - setEchoToolCallingResponse({ - provider, - text: "Provider set.", - toolCalls: [], - finishReason: "stop" - }); - - const task = await submitTask(config, "switch to echo", { mode: "chat" }); - const paused = await waitForTerminal(config, task.id, 10000); - expect(paused.status).toBe("waiting_approval"); - // The loaded set survived onto the task across the pause snapshot. - expect(paused.loadedTools).toContain("set_provider"); - expect(paused.loadedTools).toContain("get_self"); - expect(paused.toolCallState).toBeDefined(); - expect(paused.toolCallState?.pending[0]?.toolName).toBe("set_provider"); - expect(paused.approvalIds.length).toBe(1); - - // Approve → resume. runLoop re-seeds loadedToolNames from task.loadedTools - // so get_self is live again on the resumed iteration. - await decideApproval(config, paused.approvalIds[0]!, "approve"); - const finished = await waitForFinalTerminal(config, task.id, 10000); - expect(finished.status).toBe("completed"); - expect(finished.summary).toBe("Provider set."); - // get_self dispatched on the resumed turn — proving the loaded deferred - // tool was in providerTools post-resume (not nudged as unloaded). - const state = readState(config.instance); - const reads = state.audit.filter((a) => a.action === "self.get" && a.taskId === task.id); - expect(reads).toHaveLength(1); - // Cleared on terminal completion. - expect(finished.loadedTools).toBeUndefined(); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - // Loop gate, never-loaded case: the model emits a deferred tool it never - // loaded. The chat-task loop gate (NOT the dispatcher's default-case - // backstop, which browser_snapshot never reaches — it has its own dispatch - // case) must block execution and feed back a "not loaded yet" nudge, and the - // loop must continue to a final answer. We assert NO browser.snapshot audit - // row exists (proving the thunk never ran). - test("loop gate blocks a deferred tool the model never loaded and nudges toward load_tools", async () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - const config = buildConfig(workspaceRoot, "chat-task-deferred-unloaded"); - const provider = normalizeProvider(config.provider); - - // Turn 1: jump straight to browser_snapshot without loading it first. - setEchoToolCallingResponse({ - provider, - text: "", - toolCalls: [ - { id: "call_snap", type: "function", function: { name: "browser_snapshot", arguments: "{}" } } - ], - finishReason: "tool_calls" - }); - // Turn 2: model recovers with a final answer. - setEchoToolCallingResponse({ - provider, - text: "Understood — I'll load the browser tools first next time.", - toolCalls: [], - finishReason: "stop" - }); - - const task = await submitTask(config, "snapshot the page", { mode: "chat" }); - const finished = await waitForTerminal(config, task.id, 10000); - expect(finished.status).toBe("completed"); - expect(finished.summary).toBe("Understood — I'll load the browser tools first next time."); - - const state = readState(config.instance); - // The browser thunk never ran: no browser.snapshot audit row. - const snapAudits = state.audit.filter((a) => a.action === "browser.snapshot" && a.taskId === task.id); - expect(snapAudits).toHaveLength(0); - - // The second model turn's history carries the "not loaded yet" nudge as the - // tool result for the unloaded browser_snapshot call. - const calls = getEchoToolCallingCalls(); - expect(calls.length).toBe(2); - const nudge = calls[1]!.find( - (m) => m.role === "tool" && typeof m.content === "string" && m.content.includes("not loaded yet") - ); - expect(nudge).toBeDefined(); - const envelope = JSON.parse(String((nudge as { content: string }).content)) as { ok: boolean; error: string }; - expect(envelope.ok).toBe(false); - expect(envelope.error).toContain("browser_snapshot"); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - // Loop gate, same-batch case: the model emits load_tools AND the tool it just - // loaded in ONE tool_calls array. The provider generated browser_snapshot - // without ever having its schema (it wasn't in the tools array that turn), so - // the loaded set as it stood at turn start does NOT contain it — the gate - // must block browser_snapshot this turn while load_tools still succeeds. A - // subsequent turn can then call browser_snapshot for real (now loadable). - test("loop gate blocks a same-batch load+call but lets the tool through on the next turn", async () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - const config = buildConfig(workspaceRoot, "chat-task-deferred-samebatch"); - const provider = normalizeProvider(config.provider); - - // Turn 1: load_tools(browser_snapshot) AND browser_snapshot(...) together. - setEchoToolCallingResponse({ - provider, - text: "", - toolCalls: [ - { id: "call_load", type: "function", function: { name: "load_tools", arguments: JSON.stringify({ names: ["browser_snapshot"] }) } }, - { id: "call_snap", type: "function", function: { name: "browser_snapshot", arguments: "{}" } } - ], - finishReason: "tool_calls" - }); - // Turn 2: model recovers with a final answer (we stop before a real browser - // call to keep the test hermetic; the point is that the same-batch call was - // gated while load_tools succeeded). - setEchoToolCallingResponse({ - provider, - text: "Browser tools loaded; ready to snapshot.", - toolCalls: [], - finishReason: "stop" - }); - - const task = await submitTask(config, "snapshot the page", { mode: "chat" }); - const finished = await waitForTerminal(config, task.id, 10000); - expect(finished.status).toBe("completed"); - - const state = readState(config.instance); - // browser_snapshot was NOT executed this turn — no browser.snapshot audit. - const snapAudits = state.audit.filter((a) => a.action === "browser.snapshot" && a.taskId === task.id); - expect(snapAudits).toHaveLength(0); - - const calls = getEchoToolCallingCalls(); - expect(calls.length).toBe(2); - const secondTurn = calls[1]!; - // load_tools succeeded: its tool result confirms callability. - const loadResult = secondTurn.find( - (m) => m.role === "tool" && typeof m.content === "string" && m.content.includes("callable directly") - ); - expect(loadResult).toBeDefined(); - // browser_snapshot got the "not loaded yet" nudge this turn. - const snapResult = secondTurn.find( - (m) => m.role === "tool" && typeof m.content === "string" && m.content.includes("not loaded yet") - ); - expect(snapResult).toBeDefined(); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - // browser_navigate seeds the deferred browser cluster: a navigation - // establishes a browsing session whose snapshot is full of actionable @eN - // refs, so the interaction tools (snapshot, click, type, …) must be live on - // the NEXT provider call without a load_tools round-trip per tool — and the - // seeded set must persist on task.loadedTools so a pause/resume keeps it. - // The navigate here targets a loopback URL (SSRF-blocked pre-flight, no - // Chromium) — seeding is unconditional on the navigate outcome. - test("browser_navigate seeds the deferred browser cluster live on the next turn and persists it on loadedTools", async () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - // strict so the turn-2 file_write gates, snapshotting loadedTools mid-task. - const config = buildConfig(workspaceRoot, "chat-task-navigate-seeds"); - const provider = normalizeProvider(config.provider); - - // Turn 1: navigate WITHOUT any load_tools call. - setEchoToolCallingResponse({ - provider, - text: "", - toolCalls: [ - { id: "call_nav", type: "function", function: { name: "browser_navigate", arguments: JSON.stringify({ url: "http://127.0.0.1:9/" }) } } - ], - finishReason: "tool_calls" - }); - // Turn 2: file_write (core, strict → pauses) so the paused task row - // exposes the persisted loadedTools. - setEchoToolCallingResponse({ - provider, - text: "", - toolCalls: [ - { id: "call_w", type: "function", function: { name: "file_write", arguments: JSON.stringify({ path: "notes.txt", content: "seeded" }) } } - ], - finishReason: "tool_calls" - }); - // Turn 3 (resumed): final answer. - setEchoToolCallingResponse({ - provider, - text: "Browsing session ready.", - toolCalls: [], - finishReason: "stop" - }); - - const task = await submitTask(config, "open the page", { mode: "chat" }); - const paused = await waitForTerminal(config, task.id, 10000); - expect(paused.status).toBe("waiting_approval"); - // The whole browser cluster persisted onto the task after the navigate. - expect(paused.loadedTools).toContain("browser_snapshot"); - expect(paused.loadedTools).toContain("browser_click"); - expect(paused.loadedTools).toContain("browser_type"); - - await decideApproval(config, paused.approvalIds[0]!, "approve"); - const finished = await waitForFinalTerminal(config, task.id, 10000); - expect(finished.status).toBe("completed"); - expect(finished.summary).toBe("Browsing session ready."); - // Cleared on terminal completion. - expect(finished.loadedTools).toBeUndefined(); - - // Provider tools array per call: deferred browser tools absent on the - // navigate turn, live on the next turn, and still live on the resumed turn - // (re-seeded from task.loadedTools). - const toolNames = getEchoToolCallingToolNames(); - expect(toolNames.length).toBe(3); - expect(toolNames[0]).toContain("browser_navigate"); - expect(toolNames[0]).not.toContain("browser_snapshot"); - expect(toolNames[1]).toContain("browser_snapshot"); - expect(toolNames[1]).toContain("browser_click"); - expect(toolNames[2]).toContain("browser_snapshot"); - - // The seed leaves a trace entry so "why is this tool live?" is answerable. - const { readTrace } = await import("../state"); - const seedTrace = readTrace(config.instance, task.id).find( - (t) => t.message === "Deferred browser tools seeded by browser_navigate" - ); - expect(seedTrace).toBeDefined(); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - // create_agent approve/resume regression: the direct self tool routes - // through the unchanged self.config approval branch. Approving the gate - // must run the create_agent handler (agent row lands) and resume the loop. - test("create_agent direct tool gates, then approval lands the agent and resumes", async () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - const config = buildConfig(workspaceRoot, "chat-task-create-agent-resume"); - const provider = normalizeProvider(config.provider); - - // Turn 1: load create_agent. - setEchoToolCallingResponse({ - provider, - text: "", - toolCalls: [ - { id: "call_load", type: "function", function: { name: "load_tools", arguments: JSON.stringify({ names: ["create_agent"] }) } } - ], - finishReason: "tool_calls" - }); - // Turn 2: call create_agent directly. Strict → gates. - setEchoToolCallingResponse({ - provider, - text: "", - toolCalls: [ - { id: "call_ca", type: "function", function: { name: "create_agent", arguments: JSON.stringify({ name: "E2E2" }) } } - ], - finishReason: "tool_calls" - }); - // Turn 3: final answer after approval. - setEchoToolCallingResponse({ - provider, - text: "Agent created.", - toolCalls: [], - finishReason: "stop" - }); - - const task = await submitTask(config, "create an agent called E2E2", { mode: "chat" }); - const paused = await waitForTerminal(config, task.id, 10000); - expect(paused.status).toBe("waiting_approval"); - - const stateBefore = readState(config.instance); - const approval = stateBefore.authorizations.find((a) => a.id === paused.approvalIds[0]!)!; - expect(approval.action).toBe("self.config"); - expect(approval.payload.opName).toBe("create_agent"); - // No agent yet — the side effect is deferred to approval time. - expect(stateBefore.agents.some((a) => a.name === "E2E2")).toBe(false); - - await decideApproval(config, approval.id, "approve"); - const finished = await waitForFinalTerminal(config, task.id, 10000); - expect(finished.status).toBe("completed"); - expect(finished.summary).toBe("Agent created."); - - // The handler ran on approval — the agent row now exists. - const stateAfter = readState(config.instance); - expect(stateAfter.agents.some((a) => a.name === "E2E2")).toBe(true); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - // Issue #397: an inline-handled tool (load_tools here; the deferred-not-loaded - // nudge shares the branch) must PERSIST its tool result to - // the durable transcript, paired with the assistant tool_use row. Otherwise a - // later turn (or any rebuild) replays the assistant tool_use with no result, - // and a tool-pairing-strict provider (Bedrock Converse, Anthropic Messages) - // 400s the whole request. Pin: after a load_tools turn, the channel's durable - // chatMessages carry both the assistant call AND a role:"tool" result for it. - test("inline load_tools persists a paired tool_result row in the durable transcript", async () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - // This turn opens memory.db (recall + auto-retain), and memory-db.ts caches - // SQLite handles by instance NAME across the process. Derive the instance - // from the unique mkdtemp basename so a rerun in the same worker can't reuse - // a cached handle pointing at this run's already-removed state dir. - const config = buildConfig(workspaceRoot, `chat-task-inline-persist-${basename(workspaceRoot)}`); - const provider = normalizeProvider(config.provider); - - const sessionId = await mutateState(config.instance, (state) => - createChatSession(state, "Inline persist").id - ); - - // Turn 1: an inline load_tools call. Turn 2: a tool-less final answer. - setEchoToolCallingResponse({ - provider, - text: "", - toolCalls: [ - { id: "call_inline_load", type: "function", function: { name: "load_tools", arguments: JSON.stringify({ names: ["browser_snapshot"] }) } } - ], - finishReason: "tool_calls" - }); - setEchoToolCallingResponse({ provider, text: "Ready.", toolCalls: [], finishReason: "stop" }); - - const task = await submitTask(config, "load the browser tools", { mode: "chat", chatSessionId: sessionId }); - const finished = await waitForTerminal(config, task.id, 10000); - expect(finished.status).toBe("completed"); - - const durable = readState(config.instance).chatMessages.filter( - (m) => m.sessionId === sessionId && m.kind === "tool_transcript" - ); - // The assistant tool_use row was persisted... - const assistantCall = durable.find( - (m) => m.role === "assistant" && (m.toolCalls ?? []).some((c) => c.id === "call_inline_load") - ); - expect(assistantCall).toBeDefined(); - // ...AND its inline result is now a paired durable row (the fix). - const pairedResult = durable.find((m) => m.role === "tool" && m.toolCallId === "call_inline_load"); - expect(pairedResult).toBeDefined(); - expect(String(pairedResult?.content)).toContain("callable directly"); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - // Fan-out watch worker history. A session-bound subagent (chatSessionId set, - // no run.conversationId — exactly how dispatchFanOut spawns a concern-channel - // worker) must land its turn in the channel's durable chatMessages so a later - // turn in the same channel replays the draft instead of seeing empty history. - test("session-bound subagent persists its transcript + final text and a later turn replays them", async () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - writeFileSync(join(workspaceRoot, "thread.md"), "From: shelden@berkeley.edu\nSubject: meeting"); - const config = buildConfig(workspaceRoot, "chat-task-fanout-history"); - const provider = normalizeProvider(config.provider); - - const { sessionId, subagentId } = await mutateState(config.instance, (state) => { - const session = createChatSession(state, "Email: shelden@berkeley.edu"); - const subagent = createSubagentRecord(state, { - name: "email-watch", - prompt: "watch worker", - toolsets: ["file"], - systemPrompt: "You are an email watch worker." - }); - return { sessionId: session.id, subagentId: subagent.id }; - }); - - // Turn 1: the worker reads the thread (a tool call → tool_transcript rows) - // then proposes a draft (the turn-ending final text). No runId, so the run/ - // conversationId path is dead — the chatSessionId fallback must carry it. - setEchoToolCallingResponse({ - provider, - text: "Reading the thread.", - toolCalls: [{ id: "c1", type: "function", function: { name: "file_read", arguments: JSON.stringify({ path: "thread.md" }) } }], - finishReason: "tool_calls" - }); - setEchoToolCallingResponse({ - provider, - text: "PROPOSED REPLY: Thanks, I can meet Tuesday at 2pm.", - toolCalls: [], - finishReason: "stop" - }); - - const worker = await submitTask(config, "Draft a reply to the latest email.", { mode: "chat", chatSessionId: sessionId, subagentId }); - const finishedWorker = await waitForTerminal(config, worker.id); - expect(finishedWorker.status).toBe("completed"); - - const afterTurn1 = readState(config.instance).chatMessages.filter((m) => m.sessionId === sessionId); - // Transcript rows stamped into the channel (fix #1). - expect(afterTurn1.some((m) => m.kind === "tool_transcript")).toBe(true); - // Exactly one durable assistant summary row carrying the draft (fix #2). - const draftRows = afterTurn1.filter( - (m) => m.role === "assistant" && m.kind !== "tool_transcript" && m.kind !== "approval_reason" - ); - expect(draftRows.length).toBe(1); - expect(draftRows[0]!.content).toContain("PROPOSED REPLY"); - expect(draftRows[0]!.taskId).toBe(worker.id); - - clearEchoToolCallingResponses(); - setEchoToolCallingResponse({ - provider, - text: "Sent.", - toolCalls: [], - finishReason: "stop" - }); - - // Turn 2: a follow-up "send" in the same channel. Its system/messages must - // replay the prior worker draft via priorChatMessages. - const followUp = await submitTask(config, "send", { mode: "chat", chatSessionId: sessionId }); - const finishedFollowUp = await waitForTerminal(config, followUp.id); - expect(finishedFollowUp.status).toBe("completed"); - - const calls = getEchoToolCallingCalls(); - const lastTurn = calls[calls.length - 1]!; - const replayed = lastTurn.map((m) => (typeof m.content === "string" ? m.content : "")).join("\n"); - expect(replayed).toContain("PROPOSED REPLY: Thanks, I can meet Tuesday at 2pm."); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - // No double-write for a normal turn. Finalize persists the durable assistant - // summary row for every completed chat task; syncChatTaskResult (mobile /sync, - // messaging pollers) must short-circuit to that existing row instead of - // adding a second one. Here we model the normal path: a run-bound chat task - // (run.conversationId === session) with NO subagentId. Finalize writes the - // row, then syncChatTaskResult returns it, yielding exactly one assistant - // summary message. - test("normal chat turn persists exactly one assistant summary (no double-write)", async () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - const config = buildConfig(workspaceRoot, "chat-task-no-double-write"); - const provider = normalizeProvider(config.provider); - - const sessionId = await mutateState(config.instance, (state) => { - const session = createChatSession(state, "General chat"); - const run = createRun(state, { - kind: "conversation_turn", - title: "Turn", - input: "kick off", - conversationId: session.id - }); - return { runId: run.id, sessionId: session.id }; - }); - - setEchoToolCallingResponse({ - provider, - text: "Here is your answer.", - toolCalls: [], - finishReason: "stop" - }); - - const task = await submitTask(config, "what is 2+2?", { mode: "chat", runId: sessionId.runId, chatSessionId: sessionId.sessionId }); - const finished = await waitForTerminal(config, task.id); - expect(finished.status).toBe("completed"); - - // Finalize wrote the summary row for the normal turn. - const beforeSync = readState(config.instance).chatMessages.filter( - (m) => m.sessionId === sessionId.sessionId && m.role === "assistant" && m.kind !== "tool_transcript" && m.kind !== "approval_reason" - ); - expect(beforeSync.length).toBe(1); - expect(beforeSync[0]!.content).toBe("Here is your answer."); - - const { syncChatTaskResult } = await import("./chat"); - const synced = await syncChatTaskResult(config, sessionId.sessionId, task.id); - expect(synced?.content).toBe("Here is your answer."); - - const afterSync = readState(config.instance).chatMessages.filter( - (m) => m.sessionId === sessionId.sessionId && m.role === "assistant" && m.kind !== "tool_transcript" && m.kind !== "approval_reason" - ); - expect(afterSync.length).toBe(1); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - // Normal chat-turn answer durability. A plain chat turn (no subagentId, no - // jobId) must land its final answer in durable chatMessages at completion — - // no client /sync callback required — so the next turn in the same session - // replays the answer via priorChatMessages instead of seeing the prior - // question unanswered. - test("normal chat turn persists its final answer and the next turn replays it", async () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - const config = buildConfig(workspaceRoot, "chat-task-normal-answer-history"); - const provider = normalizeProvider(config.provider); - - const sessionId = await mutateState(config.instance, (state) => { - const session = createChatSession(state, "General chat"); - return session.id; - }); - - setEchoToolCallingResponse({ - provider, - text: "Section 413 has better sightlines than Cat2.", - toolCalls: [], - finishReason: "stop" - }); - - const task = await submitTask(config, "is 413 better than Cat2?", { mode: "chat", chatSessionId: sessionId }); - const finished = await waitForTerminal(config, task.id); - expect(finished.status).toBe("completed"); - - // Exactly one durable assistant answer row (not a transcript/approval row). - const answerRows = readState(config.instance).chatMessages.filter( - (m) => m.sessionId === sessionId && m.role === "assistant" && m.kind !== "tool_transcript" && m.kind !== "approval_reason" - ); - expect(answerRows.length).toBe(1); - expect(answerRows[0]!.content).toBe("Section 413 has better sightlines than Cat2."); - expect(answerRows[0]!.taskId).toBe(task.id); - expect(answerRows[0]!.kind).toBeUndefined(); - - clearEchoToolCallingResponses(); - setEchoToolCallingResponse({ - provider, - text: "Noted.", - toolCalls: [], - finishReason: "stop" - }); - - // Turn 2 in the same session: the provider messages must replay the - // prior turn's answer via priorChatMessages. - const followUp = await submitTask(config, "ok thanks", { mode: "chat", chatSessionId: sessionId }); - const finishedFollowUp = await waitForTerminal(config, followUp.id); - expect(finishedFollowUp.status).toBe("completed"); - - const calls = getEchoToolCallingCalls(); - const lastTurn = calls[calls.length - 1]!; - const replayed = lastTurn.map((m) => (typeof m.content === "string" ? m.content : "")).join("\n"); - expect(replayed).toContain("Section 413 has better sightlines than Cat2."); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - // The interrupt-context marker persisted on cancel must reach the model: the - // turn AFTER a cancelled one replays "[Request interrupted by user]" via - // priorChatMessages, so the model knows the prior response was stopped and - // doesn't blindly re-attempt it. Mirrors Claude Code's interrupt injection. - test("a turn cancelled mid-stream replays the interrupt marker to the model on the next turn", async () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - // Per-instance runtime state persists across reruns in the same worker, so - // derive the instance from the unique mkdtemp basename — otherwise a rerun - // replays chatMessages accumulated by the prior run and the assertion races. - const config = buildConfig(workspaceRoot, `chat-task-interrupt-marker-replay-${basename(workspaceRoot)}`); - const provider = normalizeProvider(config.provider); - const { cancelTask } = await import("../agent"); - - const sessionId = await mutateState(config.instance, (state) => { - const session = createChatSession(state, "General chat"); - return session.id; - }); - - // Turn 1: a held, tool-less model call so the cancel lands mid-stream. - setEchoToolCallingResponse( - { provider, text: "drafting a long answer...", toolCalls: [], finishReason: "stop" }, - undefined, - { delayMs: 3000 } - ); - const task = await submitTask(config, "write me a long essay", { mode: "chat", chatSessionId: sessionId }); - // Wait until it's genuinely running (model call in flight), then cancel. - const deadline = Date.now() + 5000; - while (Date.now() < deadline) { - if (readState(config.instance).tasks.find((t) => t.id === task.id)?.status === "running") break; - await Bun.sleep(10); - } - await cancelTask(config, task.id); - const cancelled = await waitForTerminal(config, task.id); - expect(cancelled.status).toBe("cancelled"); - - // Turn 2 in the same session: the replayed provider messages must include - // the interrupt marker (a user-role row priorChatMessages replays). - clearEchoToolCallingResponses(); - setEchoToolCallingResponse({ provider, text: "Sure.", toolCalls: [], finishReason: "stop" }); - const followUp = await submitTask(config, "actually, just summarize", { mode: "chat", chatSessionId: sessionId }); - const finishedFollowUp = await waitForTerminal(config, followUp.id); - expect(finishedFollowUp.status).toBe("completed"); - - const calls = getEchoToolCallingCalls(); - const lastTurn = calls[calls.length - 1]!; - const replayed = lastTurn.map((m) => (typeof m.content === "string" ? m.content : "")).join("\n"); - expect(replayed).toContain("[Request interrupted by user]"); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - // The iteration-cap/loop-stall summary exit completes the task with a real - // user-facing summary, so it must land the same durable assistant answer - // row as the no-tool-calls path — otherwise a turn that ends on the cap - // leaves the session with no answer for the next turn to replay. - test("iteration-cap summary exit persists the durable answer row", async () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - const config = buildConfig(workspaceRoot, "chat-task-cap-answer-row"); - config.agent = { maxIterations: 3 }; - const provider = normalizeProvider(config.provider); - - const sessionId = await mutateState(config.instance, (state) => { - const session = createChatSession(state, "Cap chat"); - return session.id; - }); - - // Three distinct tool-call turns consume the cap without tripping the - // identical-repeat loop-breaker, then the tool-less summary turn fires. - for (let i = 0; i < 3; i++) { - writeFileSync(join(workspaceRoot, `cap${i}.md`), `cap content (${i})`); - setEchoToolCallingResponse({ - provider, - text: "", - toolCalls: [ - { - id: `call_cap_${i}`, - type: "function", - function: { name: "file_read", arguments: JSON.stringify({ path: `cap${i}.md` }) } - } - ], - finishReason: "tool_calls" - }); - } - setEchoToolCallingResponse({ - provider, - text: "Cap summary: I read three files but could not finish.", - toolCalls: [], - finishReason: "stop" - }); - - const task = await submitTask(config, "loop forever", { mode: "chat", chatSessionId: sessionId }); - const finished = await waitForTerminal(config, task.id, 10000); - expect(finished.status).toBe("completed"); - expect(finished.currentStep).toBe("Completed (iteration cap reached: 3)"); - - const answerRows = readState(config.instance).chatMessages.filter( - (m) => m.sessionId === sessionId && m.role === "assistant" && m.kind !== "tool_transcript" && m.kind !== "approval_reason" - ); - expect(answerRows.length).toBe(1); - expect(answerRows[0]!.content).toBe("Cap summary: I read three files but could not finish."); - expect(answerRows[0]!.taskId).toBe(task.id); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - // The context-exhaustion partial-result exit also completes with a real - // user-facing summary and must persist the same durable answer row. - test("context-exhaustion partial-result exit persists the durable answer row", async () => { - const OVERFLOW_MESSAGE = "prompt is too long: 250000 tokens > 200000 maximum"; - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - const config = buildConfig(workspaceRoot, "chat-task-overflow-answer-row"); - const session = await mutateState(config.instance, (state) => - createChatSession(state, "Overflow chat") - ); - - // Every attempt of the turn's model call overflows (3 total attempts). - setEchoToolCallingFailure(OVERFLOW_MESSAGE); - setEchoToolCallingFailure(OVERFLOW_MESSAGE); - setEchoToolCallingFailure(OVERFLOW_MESSAGE); - - const submitted = await submitChatMessage(config, session.id, { content: "go" }); - const finished = await waitForTerminal(config, submitted.taskId, 10000); - expect(finished.status).toBe("completed"); - expect(finished.currentStep).toBe("Completed (stopped: context window exhausted)"); - - const answerRows = readState(config.instance).chatMessages.filter( - (m) => m.sessionId === session.id && m.role === "assistant" && m.kind !== "tool_transcript" && m.kind !== "approval_reason" - ); - expect(answerRows.length).toBe(1); - expect(answerRows[0]!.content).toContain("This is a partial result."); - expect(answerRows[0]!.taskId).toBe(submitted.taskId); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - // A thread-reply turn stamps threadId/parentBlockId on the task; the - // persisted answer row must carry both (sync's short-circuit means it can - // never be backfilled later) and the run must link to the answer - // (assistantMessageId), symmetric with the userMessageId set at submit. - test("thread-reply turn's persisted answer row carries parentBlockId and links the run", async () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - const config = buildConfig(workspaceRoot, "chat-task-thread-answer-row"); - const provider = normalizeProvider(config.provider); - - const { sessionId, runId } = await mutateState(config.instance, (state) => { - const session = createChatSession(state, "Threaded chat"); - const run = createRun(state, { - kind: "conversation_turn", - title: "Thread turn", - input: "follow up", - conversationId: session.id - }); - return { sessionId: session.id, runId: run.id }; - }); - - setEchoToolCallingResponse({ - provider, - text: "Threaded answer.", - toolCalls: [], - finishReason: "stop" - }); - - const task = await submitTask(config, "follow up", { - mode: "chat", - runId, - chatSessionId: sessionId, - threadId: "thread_t1", - parentBlockId: "blk_parent" - }); - const finished = await waitForTerminal(config, task.id); - expect(finished.status).toBe("completed"); - - const answerRows = readState(config.instance).chatMessages.filter( - (m) => m.sessionId === sessionId && m.role === "assistant" && m.kind !== "tool_transcript" && m.kind !== "approval_reason" - ); - expect(answerRows.length).toBe(1); - expect(answerRows[0]!.content).toBe("Threaded answer."); - expect(answerRows[0]!.threadId).toBe("thread_t1"); - expect(answerRows[0]!.parentBlockId).toBe("blk_parent"); - - const run = readState(config.instance).runs.find((r) => r.id === runId); - expect(run?.assistantMessageId).toBe(answerRows[0]!.id); - expect(run?.updatedAt).toBe(answerRows[0]!.createdAt); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - // A parent-delegated subagent with NO chatSessionId resolves no session, so - // neither the transcript nor the final text is persisted — its result flows - // back to the parent as a tool result, not into any channel's history. - test("parent-delegated subagent with no chat session persists nothing", async () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - const config = buildConfig(workspaceRoot, "chat-task-delegated-subagent"); - const provider = normalizeProvider(config.provider); - - const subagentId = await mutateState(config.instance, (state) => { - const subagent = createSubagentRecord(state, { - name: "researcher", - prompt: "research subagent", - toolsets: ["file"], - systemPrompt: "You are a research subagent." - }); - return subagent.id; - }); - - setEchoToolCallingResponse({ - provider, - text: "Research complete: the answer is 42.", - toolCalls: [], - finishReason: "stop" - }); - - const task = await submitTask(config, "research the question", { mode: "chat", subagentId }); - const finished = await waitForTerminal(config, task.id); - expect(finished.status).toBe("completed"); - - const messages = readState(config.instance).chatMessages.filter((m) => m.taskId === task.id); - expect(messages.length).toBe(0); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - // [SILENT] from a session-bound subagent persists NO summary chatMessage — - // the suppression that holds for blocks/the legacy layer must also gate the - // finalize persistence so a "nothing to report" watch run leaves no row. - test("session-bound subagent with a [SILENT] final persists no summary chatMessage", async () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - const config = buildConfig(workspaceRoot, "chat-task-fanout-silent"); - const provider = normalizeProvider(config.provider); - - const { sessionId, subagentId } = await mutateState(config.instance, (state) => { - const session = createChatSession(state, "Email: quiet@example.com"); - const subagent = createSubagentRecord(state, { - name: "email-watch", - prompt: "watch worker", - toolsets: ["file"], - systemPrompt: "You are an email watch worker." - }); - return { sessionId: session.id, subagentId: subagent.id }; - }); - - setEchoToolCallingResponse({ - provider, - text: "[SILENT]", - toolCalls: [], - finishReason: "stop" - }); - - const worker = await submitTask(config, "anything to reply to?", { mode: "chat", chatSessionId: sessionId, subagentId }); - const finished = await waitForTerminal(config, worker.id); - expect(finished.status).toBe("completed"); - - const summaryRows = readState(config.instance).chatMessages.filter( - (m) => m.sessionId === sessionId && m.role === "assistant" && m.kind !== "tool_transcript" && m.kind !== "approval_reason" - ); - expect(summaryRows.length).toBe(0); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - // Provider-reported prompt tokens drive the in-turn trim trigger. The - // chars/4 estimate here stays under every threshold, so without - // calibration no elision would ever fire. A stubbed call reporting a real - // prompt size near the window (29.6k of the echo provider's 32k) must - // tighten the NEXT iteration's budgets so the oldest unprotected tool - // results shrink before the following call — via pruning alone, with no - // summarization (aux) involvement. Toolsets are disabled to pin the tool - // schemas to the always-on floor (file_read still dispatches; the catalog - // only shapes what the provider sees). - test("inflated provider-reported prompt tokens engage the trim path on the next iteration", async () => { - const ELISION_MARKER = - "[Earlier tool result elided to fit the context window. Re-run the tool if you still need its output.]"; - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - const config = buildConfig(workspaceRoot, "chat-task-usage-trim"); - const provider = normalizeProvider(config.provider); - await mutateState(config.instance, (state) => { - for (const toolset of state.toolsets) toolset.status = "disabled"; - }); - - // Twelve tool-call turns reading DISTINCT files (so no loop-breaker - // trips), each result ~3k chars — elidable (>200 chars) but the total - // stays under every estimate-driven threshold. Only the LAST response - // reports usage; the resulting calibration gap forces the pre-call trim - // ahead of the 13th call. The per-read filler is sized so the accumulated - // transcript sits below the chars/4 high-water mark given the always-on - // tool-schema floor and system-prompt slice. - for (let i = 0; i < 12; i++) { - writeFileSync(join(workspaceRoot, `chunk${i}.md`), `chunk-${i} `.repeat(325)); - setEchoToolCallingResponse({ - provider, - text: "", - toolCalls: [ - { id: `call_u${i}`, type: "function", function: { name: "file_read", arguments: JSON.stringify({ path: `chunk${i}.md` }) } } - ], - finishReason: "tool_calls", - ...(i === 11 ? { usage: { prompt_tokens: 29_600 } } : {}) - }); - } - setEchoToolCallingResponse({ - provider, - text: "Done reading.", - toolCalls: [], - finishReason: "stop" - }); - - const task = await submitTask(config, "read all the chunks", { mode: "chat" }); - const finished = await waitForTerminal(config, task.id, 10000); - expect(finished.status).toBe("completed"); - expect(finished.summary).toBe("Done reading."); - - // The 13th provider call (after the usage report) must see the oldest - // tool results elided — oldest-first, with the recent tail untouched — - // and the trim must be pure pruning (no aux summarization). - const calls = getEchoToolCallingCalls(); - expect(calls.length).toBe(13); - const finalToolMessages = calls[12]!.filter((m) => m.role === "tool"); - expect(finalToolMessages.length).toBe(12); - const markerCount = finalToolMessages.filter((m) => m.content === ELISION_MARKER).length; - expect(markerCount).toBeGreaterThanOrEqual(2); - for (let i = 0; i < finalToolMessages.length; i++) { - if (i < markerCount) expect(finalToolMessages[i]!.content).toBe(ELISION_MARKER); - else expect(finalToolMessages[i]!.content).toContain(`chunk-${i}`); - } - expect(getEchoAuxTextRequests().length).toBe(0); - // No call BEFORE the usage report saw any elision. - for (let c = 0; c < 12; c++) { - expect(calls[c]!.some((m) => m.content === ELISION_MARKER)).toBe(false); - } - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - // Reactive overflow recovery: a provider that rejects the prompt as too - // long gets a compacted transcript on retry (bounded attempts). Two - // overflow failures followed by a success must complete the task with the - // retried call's answer — and the retried call must carry elided results. - test("compacts and retries when the provider reports a context overflow, then completes", async () => { - const ELISION_MARKER = - "[Earlier tool result elided to fit the context window. Re-run the tool if you still need its output.]"; - const OVERFLOW_MESSAGE = - "This model's maximum context length is 32000 tokens. However, your messages resulted in 99999 tokens."; - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - const config = buildConfig(workspaceRoot, "chat-task-overflow-retry"); - const provider = normalizeProvider(config.provider); - await mutateState(config.instance, (state) => { - for (const toolset of state.toolsets) toolset.status = "disabled"; - }); - // Pin the tool-catalog floor so the crossing geometry is decoupled from - // live always-on catalog size (cleared in afterEach). - __setBaseToolCatalogForTests(FIXED_COMPACTION_CATALOG); - - // Seven mid-size tool results (distinct skills so no loop-breaker trips) - // give the overflow compaction passes something to shrink, while the - // estimated total stays under the proactive high-water mark (27,200 - // tokens against the ~12,487-token floor: the 12,207 pinned catalog plus - // the system-prompt slice) — the proactive - // compaction path never fires, so the overflow is driven purely by the - // stubbed provider failures. - for (let i = 0; i < 7; i++) { - await seedBulkSkill(config, `bulk-skill-${i}`, `BODY-${i} ${"x".repeat(4_800)}`); - setEchoToolCallingResponse({ - provider, - text: "", - toolCalls: [ - { id: `call_o${i}`, type: "function", function: { name: "read_skill", arguments: JSON.stringify({ name: `bulk-skill-${i}` }) } } - ], - finishReason: "tool_calls" - }); - } - // Iteration 8's model call: two overflow rejections, then success. - setEchoToolCallingFailure(OVERFLOW_MESSAGE); - setEchoToolCallingFailure(OVERFLOW_MESSAGE); - setEchoToolCallingResponse({ - provider, - text: "Recovered after compaction.", - toolCalls: [], - finishReason: "stop" - }); - - const task = await submitTask(config, "read all the bulk files", { mode: "chat" }); - const finished = await waitForTerminal(config, task.id, 10000); - expect(finished.status).toBe("completed"); - expect(finished.summary).toBe("Recovered after compaction."); - expect(finished.error).toBeUndefined(); - - // 7 tool turns + 2 failed attempts + 1 successful retry = 10 calls. - const calls = getEchoToolCallingCalls(); - expect(calls.length).toBe(10); - // The successful retry saw a harder-compacted transcript than the first - // failed attempt (the proactive pre-call elision may already have shrunk - // the oldest results; the overflow passes must shrink strictly more, - // including into the protected-recent window on the final retry). - const elidedInFirstAttempt = calls[7]!.filter((m) => m.content === ELISION_MARKER).length; - const elidedInRetry = calls[9]!.filter((m) => m.content === ELISION_MARKER).length; - expect(elidedInRetry).toBeGreaterThan(elidedInFirstAttempt); - expect(elidedInRetry).toBeGreaterThanOrEqual(2); - - // The compact-and-retry warnings landed in the trace. - const { readTrace } = await import("../state"); - const traces = readTrace(config.instance, task.id); - const retries = traces.filter( - (t) => t.type === "warning" && /rejected the prompt as too long/.test(t.message) - ); - expect(retries.length).toBe(2); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - // The calibration gap after an overflow retry must compare the retry's - // reported usage against the COMPACTED payload it actually sent. Against - // the stale pre-elision estimate the gap would clamp toward 0 and the - // next iteration's budget would loosen right back into the overflow. - // Geometry: ten ~1.2k-token reads, then a retry whose reported usage sits - // between the stale and recomputed estimates — only the recomputed base - // yields a gap big enough to force elision before the final call. - test("overflow retry recalibrates the token estimate to the compacted payload", async () => { - const ELISION_MARKER = - "[Earlier tool result elided to fit the context window. Re-run the tool if you still need its output.]"; - const OVERFLOW_MESSAGE = "prompt is too long: 33000 tokens > 32000 maximum"; - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - const config = buildConfig(workspaceRoot, "chat-task-overflow-recalibrate"); - const provider = normalizeProvider(config.provider); - await mutateState(config.instance, (state) => { - for (const toolset of state.toolsets) toolset.status = "disabled"; - }); - - for (let i = 0; i < 11; i++) { - await seedBulkSkill(config, `bulk-skill-${i}`, `BODY-${i} ${"x".repeat(4_550)}`); - } - for (let i = 0; i < 10; i++) { - setEchoToolCallingResponse({ - provider, - text: "", - toolCalls: [ - { id: `call_g${i}`, type: "function", function: { name: "read_skill", arguments: JSON.stringify({ name: `bulk-skill-${i}` }) } } - ], - finishReason: "tool_calls" - }); - } - // Iteration 11: one overflow rejection, then a successful retry that - // reports usage and keeps working (one more read). - setEchoToolCallingFailure(OVERFLOW_MESSAGE); - setEchoToolCallingResponse({ - provider, - text: "", - toolCalls: [ - { id: "call_g10", type: "function", function: { name: "read_skill", arguments: JSON.stringify({ name: "bulk-skill-10" }) } } - ], - finishReason: "tool_calls", - usage: { prompt_tokens: 26_000 } - }); - setEchoToolCallingResponse({ - provider, - text: "Recalibrated.", - toolCalls: [], - finishReason: "stop" - }); - - const task = await submitTask(config, "read all the bulk skills", { mode: "chat" }); - const finished = await waitForTerminal(config, task.id, 10000); - expect(finished.status).toBe("completed"); - expect(finished.summary).toBe("Recalibrated."); - - // The final call must see MORE elision than the retry left behind — - // driven purely by the recalibrated gap (no overflow fired after the - // retry). - const calls = getEchoToolCallingCalls(); - expect(calls.length).toBe(13); - const markersInRetry = calls[11]!.filter((m) => m.content === ELISION_MARKER).length; - const markersInFinal = calls[12]!.filter((m) => m.content === ELISION_MARKER).length; - expect(markersInFinal).toBeGreaterThan(markersInRetry); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - // Overflow that persists through every retry must exit gracefully with a - // partial result — completed, not failed — without making the tool-less - // summary call (which would itself overflow). - test("persistent context overflow exits gracefully with a partial result", async () => { - const OVERFLOW_MESSAGE = "prompt is too long: 250000 tokens > 200000 maximum"; - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - const config = buildConfig(workspaceRoot, "chat-task-overflow-exhaust"); - const provider = normalizeProvider(config.provider); - - // Two small tool turns first so the partial exit has prior work behind it. - for (let i = 0; i < 2; i++) { - writeFileSync(join(workspaceRoot, `note${i}.md`), `note-${i} content`); - setEchoToolCallingResponse({ - provider, - text: "", - toolCalls: [ - { id: `call_x${i}`, type: "function", function: { name: "file_read", arguments: JSON.stringify({ path: `note${i}.md` }) } } - ], - finishReason: "tool_calls" - }); - } - // Every attempt of iteration 3's model call overflows (3 total attempts). - setEchoToolCallingFailure(OVERFLOW_MESSAGE); - setEchoToolCallingFailure(OVERFLOW_MESSAGE); - setEchoToolCallingFailure(OVERFLOW_MESSAGE); - - const task = await submitTask(config, "read the notes", { mode: "chat" }); - const finished = await waitForTerminal(config, task.id, 10000); - - expect(finished.status).toBe("completed"); - expect(finished.currentStep).toBe("Completed (stopped: context window exhausted)"); - expect(finished.summary).toContain("This is a partial result."); - expect(finished.error).toBeUndefined(); - - // 2 tool turns + 3 failed attempts, and no summary call after exhaustion. - expect(getEchoToolCallingCalls().length).toBe(5); - - const { readTrace } = await import("../state"); - const traces = readTrace(config.instance, task.id); - expect( - traces.some((t) => t.type === "warning" && /overflow persisted after 3 attempts/.test(t.message)) - ).toBe(true); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - // A provider can stream text before EVERY overflow failure. When the - // attempts exhaust, the exit must settle the failed attempt's in-flight - // assistant block (streaming:false) and drain queued flushes so the - // discarded partial text can't land on the completed task. - test("overflow exhaustion settles the failed attempt's stream", async () => { - const OVERFLOW_MESSAGE = "prompt is too long: 250000 tokens > 200000 maximum"; - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - const config = buildConfig(workspaceRoot, "chat-task-overflow-stream-exhaust"); - const session = await mutateState(config.instance, (state) => - createChatSession(state, "stream-exhaust") - ); - - // Every attempt streams partial text before throwing the overflow. - setEchoToolCallingFailure(OVERFLOW_MESSAGE, { streamTextBeforeFailure: "DISCARDED-PARTIAL " }); - setEchoToolCallingFailure(OVERFLOW_MESSAGE, { streamTextBeforeFailure: "DISCARDED-PARTIAL " }); - setEchoToolCallingFailure(OVERFLOW_MESSAGE, { streamTextBeforeFailure: "DISCARDED-PARTIAL " }); - - const submitted = await submitChatMessage(config, session.id, { content: "go" }); - const finished = await waitForTerminal(config, submitted.taskId, 10000); - - expect(finished.status).toBe("completed"); - expect(finished.summary).toContain("This is a partial result."); - // The discarded stream never resurrects into the partial summary. - expect(finished.partialSummary ?? "").toBe(""); - - // No block on the completed task is left streaming. - const { listChatBlocks } = await import("../state"); - const blocks = listChatBlocks(config.instance, session.id); - const streamingBlocks = blocks.filter((b) => b.kind === "assistant_text" && b.streaming); - expect(streamingBlocks.length).toBe(0); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - // The partial-result exit surfaces only THIS turn's narration. The packed - // prior context inside workingMessages carries earlier turns' assistant - // answers, so a transcript re-scan would resurrect one of those as this - // turn's partial result; and the explanatory note must reach the chat - // exactly once (as a system note), never duplicated into an - // assistant_text block. - test("partial exit never resurrects a prior turn's answer and emits the note once", async () => { - const OVERFLOW_MESSAGE = "prompt is too long: 250000 tokens > 200000 maximum"; - const PARTIAL_NOTE = - "Stopped early: the conversation no longer fits the model's context window even after compaction. This is a partial result."; - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - const config = buildConfig(workspaceRoot, "chat-task-partial-prior-turn"); - const provider = normalizeProvider(config.provider); - const session = await mutateState(config.instance, (state) => - createChatSession(state, "partial-exit") - ); - - // Turn 1 completes normally with a distinctive answer. - setEchoToolCallingResponse({ provider, text: "PRIOR-TURN-ANSWER", toolCalls: [], finishReason: "stop" }); - const first = await submitChatMessage(config, session.id, { content: "first question" }); - const firstDone = await waitForTerminal(config, first.taskId, 10000); - expect(firstDone.summary).toBe("PRIOR-TURN-ANSWER"); - - // Turn 2: a narration-less tool turn, then persistent overflow. - writeFileSync(join(workspaceRoot, "note.md"), "note content"); - setEchoToolCallingResponse({ - provider, - text: "", - toolCalls: [ - { id: "call_p1", type: "function", function: { name: "file_read", arguments: JSON.stringify({ path: "note.md" }) } } - ], - finishReason: "tool_calls" - }); - setEchoToolCallingFailure(OVERFLOW_MESSAGE); - setEchoToolCallingFailure(OVERFLOW_MESSAGE); - setEchoToolCallingFailure(OVERFLOW_MESSAGE); - const second = await submitChatMessage(config, session.id, { content: "second question" }); - const finished = await waitForTerminal(config, second.taskId, 10000); - - expect(finished.status).toBe("completed"); - // Note-only: no narration happened this turn — the prior turn's answer - // must not be presented as this turn's partial result. - expect(finished.summary).toBe(PARTIAL_NOTE); - - // The note reaches the chat exactly once, as a system note. - const { listChatBlocks } = await import("../state"); - const blocks = listChatBlocks(config.instance, session.id); - const noteBlocks = blocks.filter((b) => JSON.stringify(b).includes("Stopped early")); - expect(noteBlocks.length).toBe(1); - expect(noteBlocks[0]!.kind).toBe("system_note"); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - // When the turn DID narrate before exhausting the window, the partial - // exit surfaces that narration (cleaned) with the note appended. - test("partial exit surfaces the current turn's narration with the note appended", async () => { - const OVERFLOW_MESSAGE = "prompt is too long: 250000 tokens > 200000 maximum"; - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - const config = buildConfig(workspaceRoot, "chat-task-partial-narration"); - const provider = normalizeProvider(config.provider); - - writeFileSync(join(workspaceRoot, "note.md"), "note content"); - setEchoToolCallingResponse({ - provider, - text: "STEP-NARRATION before reading.", - toolCalls: [ - { id: "call_n1", type: "function", function: { name: "file_read", arguments: JSON.stringify({ path: "note.md" }) } } - ], - finishReason: "tool_calls" - }); - setEchoToolCallingFailure(OVERFLOW_MESSAGE); - setEchoToolCallingFailure(OVERFLOW_MESSAGE); - setEchoToolCallingFailure(OVERFLOW_MESSAGE); - - const task = await submitTask(config, "read the note", { mode: "chat" }); - const finished = await waitForTerminal(config, task.id, 10000); - - expect(finished.status).toBe("completed"); - expect(finished.summary).toBe( - "STEP-NARRATION before reading.\n\n" + - "Stopped early: the conversation no longer fits the model's context window even after compaction. This is a partial result." - ); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - // Narration from a NON-streaming provider never opens an assistant_text - // block (the tool-call path only finalizes a streamed one), so the partial - // exit must emit it one-shot — otherwise task.summary carries narration - // the chat timeline never shows. - test("partial exit emits non-streaming narration as a block alongside the note", async () => { - const OVERFLOW_MESSAGE = "prompt is too long: 250000 tokens > 200000 maximum"; - const PARTIAL_NOTE = - "Stopped early: the conversation no longer fits the model's context window even after compaction. This is a partial result."; - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - const config = buildConfig(workspaceRoot, "chat-task-partial-nonstream"); - const provider = normalizeProvider(config.provider); - const session = await mutateState(config.instance, (state) => - createChatSession(state, "partial-nonstream") - ); - - writeFileSync(join(workspaceRoot, "note.md"), "note content"); - // Whole-string response (no deltas) narrating before a tool call, then - // persistent overflow. - setEchoToolCallingResponse( - { - provider, - text: "ONE-SHOT NARRATION before reading.", - toolCalls: [ - { id: "call_ns1", type: "function", function: { name: "file_read", arguments: JSON.stringify({ path: "note.md" }) } } - ], - finishReason: "tool_calls" - }, - undefined, - { nonStreaming: true } - ); - setEchoToolCallingFailure(OVERFLOW_MESSAGE); - setEchoToolCallingFailure(OVERFLOW_MESSAGE); - setEchoToolCallingFailure(OVERFLOW_MESSAGE); - - const submitted = await submitChatMessage(config, session.id, { content: "read the note" }); - const finished = await waitForTerminal(config, submitted.taskId, 10000); - - expect(finished.status).toBe("completed"); - expect(finished.summary).toBe(`ONE-SHOT NARRATION before reading.\n\n${PARTIAL_NOTE}`); - - // The narration reaches the timeline as a settled block; the note stays - // a system note (never folded into the block). - const { listChatBlocks } = await import("../state"); - const blocks = listChatBlocks(config.instance, session.id); - const narrationBlocks = blocks.filter( - (b) => b.kind === "assistant_text" && b.text === "ONE-SHOT NARRATION before reading." - ); - expect(narrationBlocks.length).toBe(1); - expect(narrationBlocks[0]!.kind === "assistant_text" && narrationBlocks[0]!.streaming).toBe(false); - const noteBlocks = blocks.filter((b) => JSON.stringify(b).includes("Stopped early")); - expect(noteBlocks.length).toBe(1); - expect(noteBlocks[0]!.kind).toBe("system_note"); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - // A provider can stream part of a response BEFORE throwing the overflow. - // The retry must start from a clean stream: without a per-attempt reset - // the failed attempt's text accretes onto the retry's in the route buffer - // and the in-flight assistant block. - test("a stream that fails with overflow does not leak its partial text into the retry", async () => { - const OVERFLOW_MESSAGE = "Error code 400: context_length_exceeded"; - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - const config = buildConfig(workspaceRoot, "chat-task-overflow-stream-reset"); - const provider = normalizeProvider(config.provider); - const session = await mutateState(config.instance, (state) => - createChatSession(state, "stream-reset") - ); - - writeFileSync(join(workspaceRoot, "note.md"), "note content"); - // Attempt 1 streams partial text, then fails with overflow. The retry - // narrates cleanly and calls a tool (so its narration settles as a - // block); the final iteration completes the task. - setEchoToolCallingFailure(OVERFLOW_MESSAGE, { streamTextBeforeFailure: "LEAKED-PARTIAL " }); - setEchoToolCallingResponse({ - provider, - text: "Clean narration.", - toolCalls: [ - { id: "call_s1", type: "function", function: { name: "file_read", arguments: JSON.stringify({ path: "note.md" }) } } - ], - finishReason: "tool_calls" - }); - setEchoToolCallingResponse({ provider, text: "Done.", toolCalls: [], finishReason: "stop" }); - - const submitted = await submitChatMessage(config, session.id, { content: "read the note" }); - const finished = await waitForTerminal(config, submitted.taskId, 10000); - - expect(finished.status).toBe("completed"); - expect(finished.summary).toBe("Done."); - - // No settled block carries the failed attempt's partial stream. - const { listChatBlocks } = await import("../state"); - const blocks = listChatBlocks(config.instance, session.id); - const assistantTexts = blocks.filter((b): b is typeof blocks[0] & { kind: "assistant_text" } => - b.kind === "assistant_text" - ); - expect(assistantTexts.length).toBeGreaterThan(0); - for (const block of assistantTexts) { - expect(block.text).not.toContain("LEAKED-PARTIAL"); - } - expect(assistantTexts.some((b) => b.text === "Clean narration.")).toBe(true); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - // In-turn compaction happy path. Token geometry under the echo provider - // (32k window, high-water 27,200): the always-on tool schemas + system - // prompt occupy ~15.4k tokens, so six ~2.2k-token read_skill results cross - // the high-water mark before the 7th call — and pruning can't help (all - // six results sit inside the elision layer's protected-recent window). The - // loop must summarize the middle exchanges via ONE aux call, splice in the - // marked summary message, protect the head and the recent tail, and keep - // going to completion. The schema floor is pinned to FIXED_COMPACTION_CATALOG - // (installed below) so the crossing geometry is decoupled from live - // always-on catalog growth; toolsets are also disabled, and read_skill is in - // the fixed catalog so the calls still dispatch. - test("in-turn compaction summarizes the middle, protects head and tail, and continues", async () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - const config = buildConfig(workspaceRoot, "chat-task-compaction"); - const provider = normalizeProvider(config.provider); - await mutateState(config.instance, (state) => { - for (const toolset of state.toolsets) toolset.status = "disabled"; - }); - // Pin the tool-catalog floor so the crossing geometry is decoupled from - // live always-on catalog size (cleared in afterEach). - __setBaseToolCatalogForTests(FIXED_COMPACTION_CATALOG); - - for (let i = 0; i < 7; i++) { - await seedBulkSkill(config, `bulk-skill-${i}`, `BODY-${i} ${"x".repeat(8_372)}`); - setEchoToolCallingResponse({ - provider, - text: "", - toolCalls: [ - { id: `call_s${i}`, type: "function", function: { name: "read_skill", arguments: JSON.stringify({ name: `bulk-skill-${i}` }) } } - ], - finishReason: "tool_calls" - }); - } - setEchoAuxTextResponse({ text: "SUMMARY-OF-MIDDLE" }); - setEchoToolCallingResponse({ - provider, - text: "All skills reviewed.", - toolCalls: [], - finishReason: "stop" - }); - - const task = await submitTask(config, "review every bulk skill", { mode: "chat" }); - const finished = await waitForTerminal(config, task.id, 10000); - expect(finished.status).toBe("completed"); - expect(finished.summary).toBe("All skills reviewed."); - - // Exactly one aux summarization call, fed ONLY the middle exchanges — - // at compaction time (before the 7th call) six exchanges exist: BODY-0 - // is the protected head exchange, BODY-4/BODY-5 the protected tail, so - // the middle is BODY-1..BODY-3. BODY-6 is read after the compaction. - const auxRequests = getEchoAuxTextRequests(); - expect(auxRequests.length).toBe(1); - for (const middle of ["BODY-1", "BODY-2", "BODY-3"]) { - expect(auxRequests[0]!.user).toContain(middle); - } - for (const protectedBody of ["BODY-0", "BODY-4", "BODY-5", "BODY-6"]) { - expect(auxRequests[0]!.user).not.toContain(protectedBody); - } - - // The final call carries the marked synthetic summary… - const calls = getEchoToolCallingCalls(); - expect(calls.length).toBe(8); - const finalCall = calls[7]!; - const summaryMessage = finalCall.find( - (m) => - m.role === "user" && - typeof m.content === "string" && - m.content.startsWith(IN_TURN_COMPACTION_NOTE_PREFIX) - ); - expect(summaryMessage).toBeDefined(); - expect(String(summaryMessage!.content)).toContain("SUMMARY-OF-MIDDLE"); - // …the middle tool results are gone… - const toolContents = finalCall.filter((m) => m.role === "tool").map((m) => String(m.content)); - for (const middle of ["BODY-1", "BODY-2", "BODY-3"]) { - expect(toolContents.some((c) => c.includes(middle))).toBe(false); - } - // …the recent tail (and the post-compaction read) stay verbatim… - expect(toolContents.some((c) => c.includes("BODY-4"))).toBe(true); - expect(toolContents.some((c) => c.includes("BODY-5"))).toBe(true); - expect(toolContents.some((c) => c.includes("BODY-6"))).toBe(true); - // …and the head is intact: system prompt, the original ask, and the - // first in-turn exchange. - expect(finalCall[0]!.role).toBe("system"); - expect( - finalCall.some((m) => m.role === "user" && String(m.content).includes("review every bulk skill")) - ).toBe(true); - expect( - finalCall.some((m) => m.role === "assistant" && (m.tool_calls ?? []).some((c) => c.id === "call_s0")) - ).toBe(true); - expect(toolContents.some((c) => c.includes("BODY-0"))).toBe(true); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - // Anti-thrash: when the only summarizable middle is tiny while the - // protected head/tail carries the bulk, compaction reclaims almost - // nothing — the loop must bail to the graceful partial exit instead of - // grinding through pointless aux calls. - test("in-turn compaction bails gracefully when the savings are too small", async () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - const config = buildConfig(workspaceRoot, "chat-task-compaction-savings"); - const provider = normalizeProvider(config.provider); - await mutateState(config.instance, (state) => { - for (const toolset of state.toolsets) toolset.status = "disabled"; - }); - // Pin the tool-catalog floor so the crossing geometry is decoupled from - // live always-on catalog size (cleared in afterEach). - __setBaseToolCatalogForTests(FIXED_COMPACTION_CATALOG); - - // Exchange sizes: big, tiny, big, big. The middle span (everything - // between the protected first exchange and the protected last two) is - // ONLY the tiny exchange, so the summary cannot reclaim anything. - const bodies = [`BODY-0 ${"x".repeat(19_000)}`, "tiny note", `BODY-2 ${"x".repeat(19_000)}`, `BODY-3 ${"x".repeat(19_000)}`]; - for (let i = 0; i < bodies.length; i++) { - await seedBulkSkill(config, `bulk-skill-${i}`, bodies[i]!); - setEchoToolCallingResponse({ - provider, - text: "", - toolCalls: [ - { id: `call_v${i}`, type: "function", function: { name: "read_skill", arguments: JSON.stringify({ name: `bulk-skill-${i}` }) } } - ], - finishReason: "tool_calls" - }); - } - setEchoAuxTextResponse({ text: "TINY-SUMMARY" }); - - const task = await submitTask(config, "review the bulk skills", { mode: "chat" }); - const finished = await waitForTerminal(config, task.id, 10000); - - expect(finished.status).toBe("completed"); - expect(finished.currentStep).toBe("Completed (stopped: context window exhausted)"); - expect(finished.summary).toContain("could not reclaim enough"); - // One compaction was attempted (the tiny middle), then the bail fired — - // no further model calls. - expect(getEchoAuxTextRequests().length).toBe(1); - expect(getEchoToolCallingCalls().length).toBe(4); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - // The savings bail only applies while the projection is still ABOVE the - // high-water mark. A compaction that reclaims little in absolute terms - // but gets the next call back under the mark is a success — the turn - // must proceed, not exit one call short of finishing. - test("in-turn compaction proceeds when small savings still get under the high-water mark", async () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - const config = buildConfig(workspaceRoot, "chat-task-compaction-small-win"); - const provider = normalizeProvider(config.provider); - await mutateState(config.instance, (state) => { - for (const toolset of state.toolsets) toolset.status = "disabled"; - }); - // Pin the tool-catalog floor so the crossing geometry is decoupled from - // live always-on catalog size (cleared in afterEach). - __setBaseToolCatalogForTests(FIXED_COMPACTION_CATALOG); - - // Geometry: the projection crosses the high-water mark (27,200 tokens - // under the echo provider's 32k window) with a small middle span — so - // the compaction reclaims under 10% of the projection (the savings-bail - // threshold) but enough to dip back under the mark. Exchange 0 is the - // protected head, exchanges 1–2 the summarizable middle, exchanges 3–4 - // the protected tail. The tail sizes leave a few hundred tokens of - // post-compaction headroom under the mark; the always-on tool schemas - // count toward the projection, so growing them erodes this headroom. - const bodies = [ - `BODY-0 ${"x".repeat(8_000)}`, - `BODY-1 ${"x".repeat(4_000)}`, - `BODY-2 ${"x".repeat(4_000)}`, - `BODY-3 ${"x".repeat(19_000)}`, - `BODY-4 ${"x".repeat(14_000)}` - ]; - for (let i = 0; i < bodies.length; i++) { - await seedBulkSkill(config, `bulk-skill-${i}`, bodies[i]!); - setEchoToolCallingResponse({ - provider, - text: "", - toolCalls: [ - { id: `call_w${i}`, type: "function", function: { name: "read_skill", arguments: JSON.stringify({ name: `bulk-skill-${i}` }) } } - ], - finishReason: "tool_calls" - }); - } - setEchoAuxTextResponse({ text: "MIDDLE-SUMMARY" }); - setEchoToolCallingResponse({ - provider, - text: "Finished within budget.", - toolCalls: [], - finishReason: "stop" - }); - - const task = await submitTask(config, "review the bulk skills", { mode: "chat" }); - const finished = await waitForTerminal(config, task.id, 10000); - - expect(finished.status).toBe("completed"); - expect(finished.summary).toBe("Finished within budget."); - // One compaction, then the loop proceeded to the final call. - expect(getEchoAuxTextRequests().length).toBe(1); - expect(getEchoToolCallingCalls().length).toBe(6); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - // Anti-thrash: a compaction whose reclaimed space refills within two - // iterations (the model keeps pulling huge results) must stop and exit - // with a partial result rather than compact again. - test("in-turn compaction bails gracefully when the window refills immediately", async () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - const config = buildConfig(workspaceRoot, "chat-task-compaction-refill"); - const provider = normalizeProvider(config.provider); - await mutateState(config.instance, (state) => { - for (const toolset of state.toolsets) toolset.status = "disabled"; - }); - // Pin the tool-catalog floor so the crossing geometry is decoupled from - // live always-on catalog size (cleared in afterEach). - __setBaseToolCatalogForTests(FIXED_COMPACTION_CATALOG); - - // Same geometry as the happy path (compaction fires before call 7) plus - // a 7th huge read that immediately refills the reclaimed space. - for (let i = 0; i < 7; i++) { - const chars = i === 6 ? 36_000 : 8_600; - await seedBulkSkill(config, `bulk-skill-${i}`, `BODY-${i} ${"x".repeat(chars)}`); - setEchoToolCallingResponse({ - provider, - text: "", - toolCalls: [ - { id: `call_r${i}`, type: "function", function: { name: "read_skill", arguments: JSON.stringify({ name: `bulk-skill-${i}` }) } } - ], - finishReason: "tool_calls" - }); - } - setEchoAuxTextResponse({ text: "REFILL-SUMMARY" }); - - const task = await submitTask(config, "review every bulk skill", { mode: "chat" }); - const finished = await waitForTerminal(config, task.id, 10000); - - expect(finished.status).toBe("completed"); - expect(finished.currentStep).toBe("Completed (stopped: context window exhausted)"); - expect(finished.summary).toContain("refilled immediately"); - // Exactly one compaction, then the refill bail before an 8th call. - expect(getEchoAuxTextRequests().length).toBe(1); - expect(getEchoToolCallingCalls().length).toBe(7); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - // The per-turn compaction cap: after two compactions (spaced widely enough - // that the refill guard stays quiet), a third trigger must NOT summarize - // again — the loop proceeds (the reactive overflow retry is the backstop) - // and completes normally. - test("in-turn compaction respects the per-turn cap and proceeds without a third summary", async () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - const config = buildConfig(workspaceRoot, "chat-task-compaction-cap"); - const provider = normalizeProvider(config.provider); - await mutateState(config.instance, (state) => { - for (const toolset of state.toolsets) toolset.status = "disabled"; - }); - // Pin the tool-catalog floor so the crossing geometry is decoupled from - // live always-on catalog size (cleared in afterEach). - __setBaseToolCatalogForTests(FIXED_COMPACTION_CATALOG); - - const queueRead = (i: number): void => { - setEchoToolCallingResponse({ - provider, - text: "", - toolCalls: [ - { id: `call_c${i}`, type: "function", function: { name: "read_skill", arguments: JSON.stringify({ name: `bulk-skill-${i}` }) } } - ], - finishReason: "tool_calls" - }); - }; - // Twelve ~2.2k-token reads. The high-water mark trips after every sixth - // accumulated full result, so compactions land at iterations 7 and 10 — - // three iterations apart, wide enough that the refill guard stays quiet - // — and the third trigger at iteration 13 hits the cap. - for (let i = 0; i < 12; i++) { - await seedBulkSkill(config, `bulk-skill-${i}`, `BODY-${i} ${"x".repeat(8_372)}`); - queueRead(i); - } - setEchoAuxTextResponse({ text: "SUMMARY-ONE" }); - setEchoAuxTextResponse({ text: "SUMMARY-TWO" }); - setEchoToolCallingResponse({ - provider, - text: "Finished after two compactions.", - toolCalls: [], - finishReason: "stop" - }); - - const task = await submitTask(config, "review every bulk skill", { mode: "chat" }); - const finished = await waitForTerminal(config, task.id, 10000); - - expect(finished.status).toBe("completed"); - expect(finished.summary).toBe("Finished after two compactions."); - // Exactly two aux summaries — the third trigger hit the cap and the - // loop proceeded to the final call instead of summarizing again. - expect(getEchoAuxTextRequests().length).toBe(2); - expect(getEchoToolCallingCalls().length).toBe(13); - - const { readTrace } = await import("../state"); - const traces = readTrace(config.instance, task.id); - const compactions = traces.filter( - (t) => t.type === "warning" && /In-turn compaction replaced/.test(t.message) - ); - expect(compactions.length).toBe(2); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - // No aux model → compaction is impossible. Cheap pruning already failed - // to bring the projection under the mark, so the loop must exit - // gracefully with a partial result instead of failing the task. - test("in-turn compaction falls back to a graceful partial exit when the aux model is unavailable", async () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - const config = buildConfig(workspaceRoot, "chat-task-compaction-aux-fail"); - const provider = normalizeProvider(config.provider); - await mutateState(config.instance, (state) => { - for (const toolset of state.toolsets) toolset.status = "disabled"; - }); - // Pin the tool-catalog floor so the crossing geometry is decoupled from - // live always-on catalog size (cleared in afterEach). - __setBaseToolCatalogForTests(FIXED_COMPACTION_CATALOG); - - for (let i = 0; i < 7; i++) { - await seedBulkSkill(config, `bulk-skill-${i}`, `BODY-${i} ${"x".repeat(8_372)}`); - setEchoToolCallingResponse({ - provider, - text: "", - toolCalls: [ - { id: `call_f${i}`, type: "function", function: { name: "read_skill", arguments: JSON.stringify({ name: `bulk-skill-${i}` }) } } - ], - finishReason: "tool_calls" - }); - } - setEchoAuxTextFailure("aux model unavailable"); - - const task = await submitTask(config, "review every bulk skill", { mode: "chat" }); - const finished = await waitForTerminal(config, task.id, 10000); - - expect(finished.status).toBe("completed"); - expect(finished.currentStep).toBe("Completed (stopped: context window exhausted)"); - expect(finished.summary).toContain("no summarization model was available"); - expect(finished.error).toBeUndefined(); - // The trigger fired before the 7th call, the aux call failed, and no - // further model call ran. - expect(getEchoToolCallingCalls().length).toBe(6); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - // Non-overflow provider errors keep their existing contract: the task - // fails with the raw error, no compact-and-retry. - test("non-overflow provider errors still fail the task without retrying", async () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - const config = buildConfig(workspaceRoot, "chat-task-provider-error"); - - setEchoToolCallingFailure("upstream exploded (500)"); - - const task = await submitTask(config, "say hi", { mode: "chat" }); - const finished = await waitForTerminal(config, task.id, 10000); - - expect(finished.status).toBe("failed"); - expect(finished.error).toContain("upstream exploded"); - // Exactly one provider call — no retry on a non-overflow failure. - expect(getEchoToolCallingCalls().length).toBe(1); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - // A transient fault (dropped connection / OS timeout) is retried with bounded - // backoff: the conversation is valid, so the next attempt may simply succeed. - test("transient model fault (operation timed out) retries with backoff then completes", async () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - const config = buildConfig(workspaceRoot, "chat-task-transient-retry"); - const provider = normalizeProvider(config.provider); - - // First attempt: a transient OS timeout. Second attempt: success. - setEchoToolCallingFailure("The operation timed out."); - setEchoToolCallingResponse({ provider, text: "Recovered after a flaky connection.", toolCalls: [], finishReason: "stop" }); - - const task = await submitTask(config, "say hi", { mode: "chat" }); - const finished = await waitForTerminal(config, task.id, 10000); - - expect(finished.status).toBe("completed"); - expect(finished.summary).toBe("Recovered after a flaky connection."); - expect(finished.error).toBeUndefined(); - // Exactly two provider calls: the failed attempt + the successful retry. - expect(getEchoToolCallingCalls().length).toBe(2); - - const { readTrace } = await import("../state"); - const traces = readTrace(config.instance, task.id); - const retries = traces.filter( - (t) => t.type === "warning" && /Transient model-call fault; retrying/.test(t.message) - ); - expect(retries.length).toBe(1); - // Backoff base is overridden to 0ms for tests (GINI_TRANSIENT_RETRY_BASE_MS), - // so the curve renders 0 * 2^0 = 0ms here; the production 500ms base is a - // plain constant exercised by the live path. - expect(retries[0]!.message).toContain("after 0ms"); - expect(retries[0]!.message).toContain("attempt 1 of 2"); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - // The streaming idle/stall timeout (provider.ts StreamIdleTimeoutError, whose - // message carries the "stream idle timeout" marker) is also transient and must - // be retried — this pins the reconciliation between the reader's thrown shape - // and the retry classifier. - test("streaming idle-timeout fault retries then completes", async () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - const config = buildConfig(workspaceRoot, "chat-task-idle-retry"); - const provider = normalizeProvider(config.provider); - - // The exact message provider.ts's StreamIdleTimeoutError produces. - setEchoToolCallingFailure("Model stream idle timeout: no data for 120000ms"); - setEchoToolCallingResponse({ provider, text: "Back after the stall.", toolCalls: [], finishReason: "stop" }); - - const task = await submitTask(config, "say hi", { mode: "chat" }); - const finished = await waitForTerminal(config, task.id, 10000); - - expect(finished.status).toBe("completed"); - expect(finished.summary).toBe("Back after the stall."); - expect(getEchoToolCallingCalls().length).toBe(2); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - // The transient budget is bounded: after MAX_TRANSIENT_RETRIES (2) extra - // attempts the task fails with the raw fault, just as it would today. - test("a persistent transient fault gives up after the retry cap and fails the task", async () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - const config = buildConfig(workspaceRoot, "chat-task-transient-cap"); - - // 1 initial attempt + 2 retries = 3 total, all failing transiently. - setEchoToolCallingFailure("connection reset by peer"); - setEchoToolCallingFailure("connection reset by peer"); - setEchoToolCallingFailure("connection reset by peer"); - - const task = await submitTask(config, "say hi", { mode: "chat" }); - const finished = await waitForTerminal(config, task.id, 10000); - - expect(finished.status).toBe("failed"); - expect(finished.error).toContain("connection reset"); - // Exactly three provider calls: initial + 2 retries, then it gives up. - expect(getEchoToolCallingCalls().length).toBe(3); - - const { readTrace } = await import("../state"); - const traces = readTrace(config.instance, task.id); - const retries = traces.filter( - (t) => t.type === "warning" && /Transient model-call fault; retrying/.test(t.message) - ); - // Two retry warnings (one per extra attempt); the third failure exhausts - // the budget and falls through to the hard-throw. Backoff base is 0ms for - // tests, so both retries render "after 0ms" (the production base * 2^n curve - // is a plain constant; only the attempt counter and cap matter here). - expect(retries.length).toBe(2); - expect(retries[1]!.message).toContain("after 0ms"); - expect(retries[1]!.message).toContain("attempt 2 of 2"); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - // A transient failure that streamed partial text before erroring must have - // that partial trimmed from partialSummary before the retry (mirrors the - // overflow path's discard). Exercises the surfacedTextLen > 0 reset branch. - test("a transient fault that streamed partial text trims it before the successful retry", async () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - const config = buildConfig(workspaceRoot, "chat-task-transient-partial-trim"); - const provider = normalizeProvider(config.provider); - - // The failed attempt streams a partial chunk, then errors transiently. - setEchoToolCallingFailure("The operation timed out.", { streamTextBeforeFailure: "DISCARDED-PARTIAL " }); - setEchoToolCallingResponse({ provider, text: "Clean retry answer.", toolCalls: [], finishReason: "stop" }); - - const task = await submitTask(config, "say hi", { mode: "chat" }); - const finished = await waitForTerminal(config, task.id, 10000); - - expect(finished.status).toBe("completed"); - // The discarded partial must NOT leak into the final summary. - expect(finished.summary).toBe("Clean retry answer."); - expect(finished.summary).not.toContain("DISCARDED-PARTIAL"); - expect(getEchoToolCallingCalls().length).toBe(2); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - // A cancel that lands during the transient backoff wait must bail to the - // cancelled path, never sneak in the retry. Exercises abortableBackoffSleep's - // abort branch and the catch-and-bailOnTurnAbort handling. - test("a cancel during the transient backoff wait bails to cancelled, not a retry", async () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - const config = buildConfig(workspaceRoot, `chat-task-transient-cancel-${basename(workspaceRoot)}`); - const provider = normalizeProvider(config.provider); - const { cancelTask } = await import("../agent"); - - // This is the ONE transient test that needs a real backoff window to cancel - // inside. Override the (otherwise-0ms) base to a long, fixed value so the - // cancel deterministically lands DURING the wait regardless of CI load — the - // test never actually waits it out (the cancel aborts the sleep at once). - // The retry warning is written synchronously right before the sleep starts, - // so once we observe it there is a full 60s window to cancel: no race. - const prevBase = process.env.GINI_TRANSIENT_RETRY_BASE_MS; - process.env.GINI_TRANSIENT_RETRY_BASE_MS = "60000"; - try { - // One transient failure arms the backoff; the success stub after it must - // NEVER run because we cancel during that backoff window. - setEchoToolCallingFailure("The operation timed out."); - setEchoToolCallingResponse({ provider, text: "SHOULD-NOT-APPEAR", toolCalls: [], finishReason: "stop" }); - - const task = await submitTask(config, "say hi", { mode: "chat" }); - // The transient-retry warning is appended right before the backoff sleep — - // poll for it, then cancel inside the (60s) backoff window. - const { readTrace } = await import("../state"); - const deadline = Date.now() + 5000; - let sawRetryWarning = false; - while (Date.now() < deadline) { - sawRetryWarning = readTrace(config.instance, task.id).some( - (t) => t.type === "warning" && /Transient model-call fault; retrying/.test(t.message) - ); - if (sawRetryWarning) break; - await Bun.sleep(5); - } - // Assert the retry actually armed BEFORE we cancel — otherwise a regression - // that never enters the backoff would let the cancel land on a non-backoff - // task and still satisfy the assertions below, silently masking the break. - expect(sawRetryWarning).toBe(true); - await cancelTask(config, task.id); - const cancelled = await waitForTerminal(config, task.id, 10000); - - expect(cancelled.status).toBe("cancelled"); - // The retry's success stub must not have run: only the failed first call. - expect(getEchoToolCallingCalls().length).toBe(1); - expect(cancelled.summary ?? "").not.toContain("SHOULD-NOT-APPEAR"); - } finally { - if (prevBase === undefined) delete process.env.GINI_TRANSIENT_RETRY_BASE_MS; - else process.env.GINI_TRANSIENT_RETRY_BASE_MS = prevBase; - } - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - // A context overflow is NOT a transient fault — it must take the compact-and- - // retry path on its own budget, never the transient-retry path. (The transient - // markers and the overflow markers are disjoint by construction.) - test("a context overflow takes the overflow path, not the transient-retry path", async () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - const config = buildConfig(workspaceRoot, "chat-task-overflow-not-transient"); - const provider = normalizeProvider(config.provider); - - const OVERFLOW_MESSAGE = - "This model's maximum context length is 32000 tokens. However, your messages resulted in 99999 tokens."; - setEchoToolCallingFailure(OVERFLOW_MESSAGE); - setEchoToolCallingResponse({ provider, text: "Recovered via overflow path.", toolCalls: [], finishReason: "stop" }); - - const task = await submitTask(config, "say hi", { mode: "chat" }); - const finished = await waitForTerminal(config, task.id, 10000); - - expect(finished.status).toBe("completed"); - expect(finished.summary).toBe("Recovered via overflow path."); - - const { readTrace } = await import("../state"); - const traces = readTrace(config.instance, task.id); - // The overflow warning fired; the transient-retry warning did NOT. - expect(traces.some((t) => t.type === "warning" && /rejected the prompt as too long/.test(t.message))).toBe(true); - expect(traces.some((t) => t.type === "warning" && /Transient model-call fault/.test(t.message))).toBe(false); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - // Regression: transient retries must NOT consume the context-overflow - // compaction budget. The two counters are independent (transientAttempts vs - // attempt), so two transient faults followed by an overflow must still leave - // the overflow path its full compaction budget — the overflow must COMPACT - // and recover, not exit early with a no-compaction partial result. (Before the - // attempt-- fix, the transient `continue` advanced `attempt` to 3, so the - // first overflow hit `attempt >= MAX_CONTEXT_OVERFLOW_ATTEMPTS` and bailed.) - test("transient retries do not steal the context-overflow compaction budget", async () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - const config = buildConfig(workspaceRoot, "chat-task-transient-then-overflow"); - const provider = normalizeProvider(config.provider); - - const OVERFLOW_MESSAGE = - "This model's maximum context length is 32000 tokens. However, your messages resulted in 99999 tokens."; - // Two transient faults exhaust the transient budget, then a context overflow - // arrives, then a clean success once compaction shrinks the transcript. - setEchoToolCallingFailure("The operation timed out."); - setEchoToolCallingFailure("The operation timed out."); - setEchoToolCallingFailure(OVERFLOW_MESSAGE); - setEchoToolCallingResponse({ provider, text: "Recovered after transient faults + a compaction.", toolCalls: [], finishReason: "stop" }); - - const task = await submitTask(config, "say hi", { mode: "chat" }); - const finished = await waitForTerminal(config, task.id, 10000); - - // The task recovered via compaction — it did NOT exit early with the - // no-compaction partial-result message. - expect(finished.status).toBe("completed"); - expect(finished.summary).toBe("Recovered after transient faults + a compaction."); - expect(finished.summary).not.toContain("no longer fits the model's context window"); - - const { readTrace } = await import("../state"); - const traces = readTrace(config.instance, task.id); - // Both transient retries fired AND the overflow compaction still ran. - const transientRetries = traces.filter( - (t) => t.type === "warning" && /Transient model-call fault; retrying/.test(t.message) - ); - expect(transientRetries.length).toBe(2); - expect(traces.some((t) => t.type === "warning" && /rejected the prompt as too long/.test(t.message))).toBe(true); - // 4 provider calls total: 2 transient fails + 1 overflow + 1 successful retry. - expect(getEchoToolCallingCalls().length).toBe(4); - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - // Fallback: a provider that reports no usage (the echo default) keeps the - // plain chars/4 behavior — the identical transcript never trims. - test("without provider usage the trim path stays on the chars/4 estimate and never engages", async () => { - const ELISION_MARKER = - "[Earlier tool result elided to fit the context window. Re-run the tool if you still need its output.]"; - const workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-ws-")); - const config = buildConfig(workspaceRoot, "chat-task-no-usage-trim"); - const provider = normalizeProvider(config.provider); - await mutateState(config.instance, (state) => { - for (const toolset of state.toolsets) toolset.status = "disabled"; - }); - // Pin the tool-catalog floor so the no-trim geometry is decoupled from - // live always-on catalog size (cleared in afterEach). - __setBaseToolCatalogForTests(FIXED_COMPACTION_CATALOG); - - // Twelve modest reads (~880 tokens each). With echo reporting no usage the - // calibration gap stays 0, so the only trim trigger is the chars/4 live - // budget — and the accumulated transcript stays well under it (the budget - // is 32,000 − 1,600 reserve − ~12,739 floor [12,207 pinned catalog + the - // system-prompt slice] ≈ 17,661 tokens), so no - // elision and no proactive compaction ever engages. - for (let i = 0; i < 12; i++) { - await seedBulkSkill(config, `chunk-skill-${i}`, `chunk-${i} ${"x".repeat(3_396)}`); - setEchoToolCallingResponse({ - provider, - text: "", - toolCalls: [ - { id: `call_n${i}`, type: "function", function: { name: "read_skill", arguments: JSON.stringify({ name: `chunk-skill-${i}` }) } } - ], - finishReason: "tool_calls" - }); - } - setEchoToolCallingResponse({ - provider, - text: "Done reading.", - toolCalls: [], - finishReason: "stop" - }); - - const task = await submitTask(config, "read all the chunks", { mode: "chat" }); - const finished = await waitForTerminal(config, task.id, 10000); - expect(finished.status).toBe("completed"); - - const calls = getEchoToolCallingCalls(); - expect(calls.length).toBe(13); - for (const call of calls) { - expect(call.some((m) => m.content === ELISION_MARKER)).toBe(false); - } - - rmSync(workspaceRoot, { recursive: true, force: true }); - }); -}); - -// Navigation-without-progress counter (loop-breaker guard 3). Driving the real -// browser tool through the loop would launch Chromium — heavy and flaky in a -// unit test — so the counter step is a pure helper tested directly. The model's -// emitted (tool name, navigation URL) pairs per iteration are all it needs. -describe("nextNavStallState", () => { - // Convenience: thread a sequence of single-call iterations through the state - // and return the running count after each one. - function counts(steps: { name: string; url?: string }[]): number[] { - let state = initialNavStallState(); - const out: number[] = []; - for (const s of steps) { - state = nextNavStallState(state, [s]); - out.push(state.count); - } - return out; - } - - test("repeated navigation to the SAME url climbs; page-actions reset", () => { - expect( - counts([ - { name: "browser_navigate", url: "https://a.com" }, // 0: first visit is progress - { name: "browser_navigate", url: "https://a.com" }, // 1: repeat - { name: "browser_navigate", url: "https://a.com" }, // 2: repeat - { name: "browser_click" }, // 0: page-action resets - { name: "browser_navigate", url: "https://a.com" } // 0: still in window but reset wins on the click line; new line is a repeat - ]) - ).toEqual([0, 1, 2, 0, 1]); - }); - - test("navigating to a NEW url is progress and resets the count", () => { - // Reload the same page twice (climb), then move to a fresh URL (reset). - expect( - counts([ - { name: "browser_navigate", url: "https://a.com" }, // 0 - { name: "browser_navigate", url: "https://a.com" }, // 1 - { name: "browser_navigate", url: "https://b.com" }, // 0: new URL - { name: "browser_navigate", url: "https://c.com" } // 0: new URL - ]) - ).toEqual([0, 1, 0, 0]); - }); - - test("a browser_console data extraction resets the count (the research pattern)", () => { - // navigate -> console-extract -> navigate across DISTINCT pages never climbs: - // a fresh URL is already progress, and the console reset reinforces it. - expect( - counts([ - { name: "browser_navigate", url: "https://a.com" }, // 0 - { name: "browser_console" }, // 0: progress - { name: "browser_navigate", url: "https://b.com" }, // 0: new URL - { name: "browser_console" }, // 0 - { name: "browser_navigate", url: "https://c.com" } // 0: new URL - ]) - ).toEqual([0, 0, 0, 0, 0]); - // A console extraction resets even a climbing reload count (the model pulled - // data off the page — genuine progress), though the URL stays in the window. - expect( - counts([ - { name: "browser_navigate", url: "https://a.com" }, // 0 - { name: "browser_navigate", url: "https://a.com" }, // 1: repeat - { name: "browser_navigate", url: "https://a.com" }, // 2: repeat - { name: "browser_console" }, // 0: progress resets - { name: "browser_navigate", url: "https://a.com" } // 1: still in window, repeat again - ]) - ).toEqual([0, 1, 2, 0, 1]); - }); - - test("browser_snapshot is NEUTRAL — re-snapshotting the same page does NOT reset", () => { - // The degenerate overflow incident: navigate then re-snapshot the same URL - // repeatedly. Snapshot must not reset the stall, so the reload count climbs. - expect( - counts([ - { name: "browser_navigate", url: "https://a.com" }, // 0 - { name: "browser_snapshot" }, // 0 (neutral, count unchanged) - { name: "browser_navigate", url: "https://a.com" }, // 1: repeat URL - { name: "browser_snapshot" }, // 1 (neutral) - { name: "browser_navigate", url: "https://a.com" } // 2: repeat URL - ]) - ).toEqual([0, 0, 1, 1, 2]); - }); - - test("multiple tools in one iteration apply in order (last write wins on reset)", () => { - // navigate (repeat) then click in the same turn nets a reset. - let s = nextNavStallState({ count: 4, recentUrls: ["https://a.com"] }, [ - { name: "browser_navigate", url: "https://a.com" }, - { name: "browser_click" } - ]); - expect(s.count).toBe(0); - // click then navigate-to-known ends at 1 (reset, then a repeat). - s = nextNavStallState({ count: 4, recentUrls: ["https://a.com"] }, [ - { name: "browser_click" }, - { name: "browser_navigate", url: "https://a.com" } - ]); - expect(s.count).toBe(1); - }); - - test("oscillation between two URLs climbs to the threshold (the case guard 2 misses)", () => { - // Alternating navigate targets keep the ACTION signature flipping so the - // action-only guard resets every turn — but both URLs stay in the recent - // window, so each navigation is a repeat and the stall climbs monotonically. - let state = initialNavStallState(); - // Seed both URLs into the window first (two distinct first-visits). - state = nextNavStallState(state, [{ name: "browser_navigate", url: "https://a.com" }]); - state = nextNavStallState(state, [{ name: "browser_navigate", url: "https://b.com" }]); - for (let i = 0; i < 8; i++) { - const url = i % 2 === 0 ? "https://a.com" : "https://b.com"; - state = nextNavStallState(state, [{ name: "browser_navigate", url }]); - } - expect(state.count).toBeGreaterThanOrEqual(8); - }); - - test("a long run of DISTINCT urls never trips (the legitimate-research case)", () => { - let state = initialNavStallState(); - for (let i = 0; i < 12; i++) { - state = nextNavStallState(state, [{ name: "browser_navigate", url: `https://site.com/page-${i}` }]); - } - expect(state.count).toBe(0); - }); - - test("repeated browser_back oscillation climbs (back has no url of its own)", () => { - let state = initialNavStallState(); - const out: number[] = []; - for (let i = 0; i < 10; i++) { - state = nextNavStallState(state, [{ name: "browser_back" }]); - out.push(state.count); - } - // First back is the first visit of the back-sentinel (0), then it repeats. - expect(out[0]).toBe(0); - expect(out[8]).toBeGreaterThanOrEqual(8); - }); -}); - -// In-loop tool-result elision. A pure function over a messages array + budget, -// so a direct unit test is deterministic — no need to force a real provider -// overflow through the loop. -describe("elideOldToolResultsToBudget", () => { - const ELISION_MARKER = - "[Earlier tool result elided to fit the context window. Re-run the tool if you still need its output.]"; - - function bigToolResult(id: string, fill: string): ToolCallingMessage { - return { role: "tool", tool_call_id: id, content: fill.repeat(400) }; - } - - test("no-op when already within budget", () => { - const messages: ToolCallingMessage[] = [ - { role: "user", content: "hi" }, - { role: "tool", tool_call_id: "call_0", content: "small result" } - ]; - const before = JSON.stringify(messages); - expect(elideOldToolResultsToBudget(messages, 1_000_000)).toBe(0); - expect(JSON.stringify(messages)).toBe(before); - }); - - test("shrinks oldest tool results first while protecting the most-recent six", () => { - // Ten oversized tool results plus interleaving assistant rows. With a tiny - // budget, the elidable set is everything but the most-recent six; the - // helper walks oldest→newest until it fits. - const messages: ToolCallingMessage[] = [{ role: "user", content: "go" }]; - for (let i = 0; i < 10; i++) { - messages.push({ role: "assistant", content: null, tool_calls: [] }); - messages.push(bigToolResult(`call_${i}`, `X${i}-`)); - } - - const elided = elideOldToolResultsToBudget(messages, 100); - expect(elided).toBeGreaterThan(0); - - const toolMessages = messages.filter((m) => m.role === "tool"); - // The four oldest tool results (10 total − 6 protected) are elided… - for (let i = 0; i < 4; i++) { - expect(toolMessages[i]!.content).toBe(ELISION_MARKER); - // role + tool_call_id stay intact so codex call/output pairing survives. - expect(toolMessages[i]!.tool_call_id).toBe(`call_${i}`); - } - // …and the most-recent six are never touched. - for (let i = 4; i < 10; i++) { - expect(toolMessages[i]!.content).not.toBe(ELISION_MARKER); - } - }); - - test("never drops a message — only shrinks content", () => { - const messages: ToolCallingMessage[] = [{ role: "user", content: "go" }]; - for (let i = 0; i < 10; i++) { - messages.push(bigToolResult(`call_${i}`, `Y${i}-`)); - } - const lengthBefore = messages.length; - elideOldToolResultsToBudget(messages, 50); - expect(messages.length).toBe(lengthBefore); - }); - - test("leaves small tool results and non-tool messages alone", () => { - // A short tool result (≤ 200 chars) isn't worth shrinking; assistant/user - // rows are never elidable regardless of size. - const messages: ToolCallingMessage[] = [ - { role: "assistant", content: "Z".repeat(5000), tool_calls: [] }, - { role: "tool", tool_call_id: "call_small", content: "tiny" }, - bigToolResult("call_big", "W-") - ]; - elideOldToolResultsToBudget(messages, 10); - expect(messages[0]!.content).not.toBe(ELISION_MARKER); - expect(messages[1]!.content).toBe("tiny"); - }); -}); - -// Group-aligned middle-span selection for in-turn compaction. Pure over a -// messages array, so the pairing/protection rules are pinned directly. -describe("compactionMiddleSpan", () => { - const asst = (id: string, calls = 1): ToolCallingMessage => ({ - role: "assistant", - content: null, - tool_calls: Array.from({ length: calls }, (_, i) => ({ - id: `${id}_${i}`, - type: "function" as const, - function: { name: "t", arguments: "{}" } - })) - }); - const tool = (id: string): ToolCallingMessage => ({ role: "tool", tool_call_id: id, content: "r" }); - - test("protects the head (initial messages + first exchange) and the recent-tail exchanges", () => { - const messages: ToolCallingMessage[] = [ - { role: "system", content: "s" }, - { role: "user", content: "u" }, - asst("a"), tool("a_0"), - asst("b"), tool("b_0"), - asst("c"), tool("c_0"), - asst("d"), tool("d_0"), - asst("e"), tool("e_0") - ]; - // initialCount=2 (system+user). Head also covers exchange a; tail keeps - // exchanges d and e; middle = exchanges b and c (indices 4..8). - expect(compactionMiddleSpan(messages, 2, 2)).toEqual({ start: 4, end: 8 }); - }); - - test("keeps multi-result exchanges whole (group-aligned boundaries)", () => { - const messages: ToolCallingMessage[] = [ - { role: "user", content: "u" }, - asst("a", 2), tool("a_0"), tool("a_1"), - asst("b", 2), tool("b_0"), tool("b_1"), - asst("c"), tool("c_0"), - asst("d"), tool("d_0") - ]; - // Middle = exchange b only (indices 4..7) — both of its tool results - // travel with their assistant row. - expect(compactionMiddleSpan(messages, 1, 2)).toEqual({ start: 4, end: 7 }); - }); - - test("returns undefined when everything is protected", () => { - const messages: ToolCallingMessage[] = [ - { role: "user", content: "u" }, - asst("a"), tool("a_0"), - asst("b"), tool("b_0"), - asst("c"), tool("c_0") - ]; - expect(compactionMiddleSpan(messages, 1, 2)).toBeUndefined(); - expect(compactionMiddleSpan([], 0, 2)).toBeUndefined(); - }); -}); - -describe("renderMessagesForCompaction", () => { - test("renders roles, tool-call signatures, and content", () => { - const rendered = renderMessagesForCompaction([ - { - role: "assistant", - content: "checking", - tool_calls: [{ id: "x", type: "function", function: { name: "file_read", arguments: '{"path":"a.md"}' } }] - }, - { role: "tool", tool_call_id: "x", content: "file body" } - ]); - expect(rendered).toContain('assistant -> file_read({"path":"a.md"}): checking'); - expect(rendered).toContain("tool: file body"); - }); - - test("caps oversized messages and the total input", () => { - const big = "z".repeat(10_000); - const rendered = renderMessagesForCompaction([{ role: "tool", tool_call_id: "x", content: big }]); - expect(rendered.length).toBeLessThan(5_000); - expect(rendered).toContain("[truncated]"); - - const many = Array.from({ length: 30 }, (_, i): ToolCallingMessage => ({ - role: "tool", - tool_call_id: `t${i}`, - content: "y".repeat(9_000) - })); - const total = renderMessagesForCompaction(many); - expect(total.length).toBeLessThan(70_000); - expect(total).toContain("[remaining messages omitted from summary input]"); - }); -}); - -// Provider usage records carry the real prompt size under different keys per -// provider family; the extractor must accept both and reject junk. -describe("promptTokensFromUsage", () => { - test("reads input_tokens (anthropic/bedrock) and prompt_tokens (openai-compatible)", () => { - expect(promptTokensFromUsage({ input_tokens: 1234 })).toBe(1234); - expect(promptTokensFromUsage({ prompt_tokens: 567 })).toBe(567); - // input_tokens wins when both are present (normalized Converse usage). - expect(promptTokensFromUsage({ input_tokens: 10, prompt_tokens: 20 })).toBe(10); - }); - - test("rejects missing, non-numeric, and negative counts", () => { - expect(promptTokensFromUsage(undefined)).toBeUndefined(); - expect(promptTokensFromUsage({})).toBeUndefined(); - expect(promptTokensFromUsage({ prompt_tokens: "9" })).toBeUndefined(); - expect(promptTokensFromUsage({ input_tokens: Number.NaN })).toBeUndefined(); - expect(promptTokensFromUsage({ input_tokens: -5 })).toBeUndefined(); - }); -}); - -describe("buildAgentIdentity", () => { - function makeToolset(name: string, status: ToolsetRecord["status"] = "enabled"): ToolsetRecord { - const at = "2026-05-19T00:00:00.000Z"; - return { - id: `toolset_${name}`, - instance: "test-instance", - name, - description: "", - status, - toolNames: [], - scopes: ["task"], - createdAt: at, - updatedAt: at - }; - } - - function makeState(toolsets: ToolsetRecord[]): RuntimeState { - // Build on top of the canonical empty-state seed so this fixture - // automatically inherits any new top-level RuntimeState field - // without per-test churn. Override only the slices the - // buildAgentIdentity tests actually exercise. - const state = createEmptyState("test-instance"); - state.toolsets = toolsets; - state.agents = [{ - id: "agent_x", - instance: "test-instance", - name: "alpha", - status: "active", - toolsets: [], - messagingTargets: [], - createdAt: "2026-05-19T00:00:00.000Z", - updatedAt: "2026-05-19T00:00:00.000Z" - }]; - state.activeAgentId = "agent_x"; - return state; - } - - const baseConfig: RuntimeConfig = { - instance: "test-instance", - port: 9999, - token: "test", - provider: { name: "echo", model: "test-model" }, - workspaceRoot: "/tmp/ws", - stateRoot: "/tmp/state", - logRoot: "/tmp/logs" - }; - - test("renders the actual enabled toolset names when the agent imposes no filter", () => { - const state = makeState([ - makeToolset("file"), - makeToolset("terminal"), - makeToolset("memory"), - makeToolset("disabled-thing", "disabled") - ]); - const effective: EffectiveContext = { - agentId: "agent_x", - memoryNamespace: "agent_x", - provider: { name: "echo", model: "test-model" }, - providerSource: "agent", - warnings: [] - // no toolsetFilter — unrestricted - }; - const identity: AgentIdentity = buildAgentIdentity(baseConfig, state, effective); - // Disabled toolsets must NOT appear; enabled toolsets must be sorted - // so the rendered identity is stable across runs. - expect(identity.toolsets).toEqual(["file", "memory", "terminal"]); - }); - - test("renders the filter set when the agent declares a whitelist", () => { - const state = makeState([makeToolset("file"), makeToolset("terminal"), makeToolset("memory")]); - const effective: EffectiveContext = { - agentId: "agent_x", - memoryNamespace: "agent_x", - provider: { name: "echo", model: "test-model" }, - providerSource: "agent", - toolsetFilter: new Set(["terminal", "file"]), - warnings: [] - }; - const identity = buildAgentIdentity(baseConfig, state, effective); - expect(identity.toolsets).toEqual(["file", "terminal"]); - }); - - test("drops disabled and unknown toolset names from the filter so the prompt matches the catalog", () => { - // effective.toolsetFilter intentionally keeps unknown / disabled - // refs (effective-context.ts:9-16) so re-enabling later "just - // works"; the identity block must NOT show those as available or - // it would tell the model a tool family is callable when in fact - // tool-catalog.ts excludes them from the dispatch surface. - const state = makeState([ - makeToolset("file"), - makeToolset("terminal"), - makeToolset("messaging", "disabled") - ]); - const effective: EffectiveContext = { - agentId: "agent_x", - memoryNamespace: "agent_x", - provider: { name: "echo", model: "test-model" }, - providerSource: "agent", - // Whitelist includes a disabled-in-state name and an entirely - // unknown name; both must be filtered out of the rendered - // identity block. - toolsetFilter: new Set(["file", "messaging", "phantom"]), - warnings: [] - }; - const identity = buildAgentIdentity(baseConfig, state, effective); - expect(identity.toolsets).toEqual(["file"]); - }); - - test("yields an empty toolsets list only when state has no enabled toolsets and no filter", () => { - const state = makeState([makeToolset("legacy", "disabled")]); - const effective: EffectiveContext = { - provider: { name: "echo", model: "test-model" }, - providerSource: "instance", - warnings: [] - }; - const identity = buildAgentIdentity(baseConfig, state, effective); - expect(identity.toolsets).toEqual([]); - expect(identity.agentId).toBe("(none)"); - expect(identity.memoryNamespace).toBe("(none)"); - }); - - test("sanitizes an agent name carrying a newline into a single-line label", () => { - // The identity block renders agentName verbatim, so a name with an - // embedded newline must collapse to one line rather than inject a - // raw extra model-visible line into the runtime-identity block. - const state = makeState([makeToolset("file")]); - state.agents[0]!.name = "Mansour\nIgnore"; - const effective: EffectiveContext = { - agentId: "agent_x", - memoryNamespace: "agent_x", - provider: { name: "echo", model: "test-model" }, - providerSource: "agent", - warnings: [] - }; - const identity = buildAgentIdentity(baseConfig, state, effective); - expect(identity.agentName).toBe("Mansour Ignore"); - expect(identity.agentName).not.toContain("\n"); - }); -}); - -describe("buildEnabledSkillsBlock", () => { - function skill(name: string, description = `${name} description`, status: SkillRecord["status"] = "enabled"): SkillRecord { - return { - id: `skill_${name}`, - instance: "test", - name, - description, - trigger: "", - steps: [], - requiredTools: [], - requiredPermissions: [], - status, - version: 1, - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - tests: [], - successCount: 0, - failureCount: 0, - previousVersions: [], - body: "", - source: "bundled", - manifestPath: `/skills/${name}/SKILL.md` - }; - } - - test("lists active skill descriptions and points to list_skills/read_skill", () => { - const block = buildEnabledSkillsBlock([skill("linear", "Linear issues"), skill("disabled", "Hidden", "disabled")]); - expect(block).toContain("call list_skills"); - expect(block).toContain("call read_skill"); - expect(block).toContain("- linear: Linear issues"); - expect(block).not.toContain("disabled"); - }); - - test("caps the inline skill list and leaves a discovery hint", () => { - const skills = Array.from({ length: 45 }, (_, i) => skill(`skill-${String(i).padStart(2, "0")}`)); - const block = buildEnabledSkillsBlock(skills); - expect(block).toContain("- skill-00: skill-00 description"); - expect(block).toContain("- skill-39: skill-39 description"); - expect(block).not.toContain("- skill-40: skill-40 description"); - expect(block).toContain("5 more skills not shown"); - expect(block).toContain("nameContains/status filters"); - }); -}); - -describe("buildConnectedAccountsBlock", () => { - function account(opts: { tag: string; email?: string; configDir?: string }): GoogleAccount { - return { - id: `gacct_${opts.tag}`, - tag: opts.tag, - email: opts.email ?? `${opts.tag}@example.com`, - configDir: opts.configDir ?? `/home/u/.gini/google-accounts/gacct_${opts.tag}`, - addedAt: "2026-01-01T00:00:00.000Z" - }; - } - - test("returns empty string when no accounts are connected", () => { - expect(buildConnectedAccountsBlock([])).toBe(""); - }); - - test("renders a single account's tag, email, and config dir plus the prefix guidance", () => { - const block = buildConnectedAccountsBlock([ - account({ tag: "personal", email: "me@gmail.com", configDir: "/home/u/.config/gws" }) - ]); - expect(block).toContain("Connected Google accounts"); - expect(block).toContain("personal"); - expect(block).toContain("me@gmail.com"); - expect(block).toContain("/home/u/.config/gws"); - expect(block).toContain("GOOGLE_WORKSPACE_CLI_CONFIG_DIR"); - expect(block).toContain("use it"); - }); - - test("surfaces both accounts, aggregate-on-read, and ask-on-write guidance when 2+ are connected", () => { - const block = buildConnectedAccountsBlock([ - account({ tag: "personal" }), - account({ tag: "work" }) - ]); - expect(block).toContain("personal"); - expect(block).toContain("work"); - // Unscoped reads fan out across every account instead of picking one. - expect(block).toContain("EVERY connected account"); - // Writes still ask when no account is named. - expect(block).toContain("ASK which account first"); - }); - - test("shows the sign-in-pending placeholder for an account with no email yet", () => { - const block = buildConnectedAccountsBlock([account({ tag: "school", email: "" })]); - expect(block).toContain("school"); - expect(block).toContain("(sign-in pending)"); - }); -}); - -describe("buildInactiveSkillsBlock", () => { - // Minimal SkillRecord factory. Only the fields the block builder - // reads (name, description, status, requiredCredentials, source) carry - // meaningful values; the rest are stubbed so the type checks. Skills now - // declare credentials BY NAME; the block maps each name to its provider - // (LINEAR_API_KEY → linear, google-workspace-oauth → google-oauth-desktop). - function makeSkill(opts: { - name: string; - description?: string; - requiredCredentials?: string[]; - status?: SkillRecord["status"]; - source?: SkillRecord["source"]; - }): SkillRecord { - const at = "2026-05-19T00:00:00.000Z"; - return { - id: `skill_${opts.name}`, - instance: "test-instance", - name: opts.name, - description: opts.description ?? "(no description)", - trigger: "", - steps: [], - requiredTools: [], - requiredPermissions: [], - status: opts.status ?? "enabled", - version: 1, - createdAt: at, - updatedAt: at, - tests: [], - successCount: 0, - failureCount: 0, - previousVersions: [], - body: "", - requiredCredentials: opts.requiredCredentials, - source: opts.source - }; - } - - test("routes the google-workspace-oauth credential to request_connector with the provider id", () => { - // google-workspace-oauth maps to google-oauth-desktop. In hosted this - // provider declares no setup skill (the credential is baked into the guest - // at provisioning), so the block emits the bare request_connector shortcut - // for the provider id — no read_skill / setup-skill detour. - const skill = makeSkill({ - name: "google-calendar", - description: "Google Calendar", - requiredCredentials: ["google-workspace-oauth"] - }); - const block = buildInactiveSkillsBlock([skill]); - expect(block).toContain("google-oauth-desktop"); - expect(block).toContain("call `request_connector` with provider id `google-oauth-desktop`"); - // No setup skill is declared, so the block must not tell the model to - // read_skill first. - expect(block).not.toMatch(/read_skill/); - }); - - test("collapses multiple skills sharing one credential into a single line", () => { - // All Google Workspace product skills share one credential — the block - // should emit ONE provider line, not one per skill. - const skills = [ - makeSkill({ name: "google-calendar", requiredCredentials: ["google-workspace-oauth"] }), - makeSkill({ name: "google-gmail", requiredCredentials: ["google-workspace-oauth"] }), - makeSkill({ name: "google-drive", requiredCredentials: ["google-workspace-oauth"] }) - ]; - const block = buildInactiveSkillsBlock(skills); - const providerLines = block.split("\n").filter((line) => line.includes("google-oauth-desktop")); - expect(providerLines).toHaveLength(1); - expect(providerLines[0]).toContain("google-calendar"); - expect(providerLines[0]).toContain("google-gmail"); - expect(providerLines[0]).toContain("google-drive"); - expect(providerLines[0]).toContain("call `request_connector` with provider id `google-oauth-desktop`"); - }); - - test("falls back to request_connector guidance for providers without a setup skill", () => { - // LINEAR_API_KEY → linear, which does not declare setupSkill, so the - // block must emit the default request_connector instruction. - const skill = makeSkill({ - name: "needs-linear", - description: "Test skill that needs Linear.", - requiredCredentials: ["LINEAR_API_KEY"] - }); - const block = buildInactiveSkillsBlock([skill]); - expect(block).toContain("linear"); - expect(block).toContain("call `request_connector` with provider id `linear`"); - // Must NOT mention read_skill — no setup skill is declared. - expect(block).not.toMatch(/read_skill/); - }); - - test("instructs a templateless request_connector for a credential with no registered provider", () => { - // SOME_SERVICE_API_KEY maps to no provider module, so providerForCredential - // falls back to the name itself. The block must NOT emit the bare - // provider-id shortcut (there is no provider to connect) — it must tell the - // model to call request_connector with the {name, type, skillId} shape so - // the user can enter the secret in chat. The name is UPPER_SNAKE so the - // inferred type is api-key. - const skill = makeSkill({ - name: "needs-some-service", - description: "Test skill that needs an unmapped credential.", - requiredCredentials: ["SOME_SERVICE_API_KEY"] - }); - const block = buildInactiveSkillsBlock([skill]); - expect(block).toContain("SOME_SERVICE_API_KEY"); - expect(block).toContain('name: "SOME_SERVICE_API_KEY"'); - expect(block).toContain('type: "api-key"'); - expect(block).toContain(`skillId: "${skill.id}"`); - // No provider-id shortcut and no read_skill dead-end for a name with no - // registered provider. - expect(block).not.toContain("call `request_connector` with provider id `SOME_SERVICE_API_KEY`"); - expect(block).not.toMatch(/read_skill/); - expect(block).not.toContain("will be rejected"); - }); - - // Minimal RuntimeState carrying only the connectors the block reads. - function stateWithConnectors(connectors: RuntimeState["connectors"]): RuntimeState { - return { - version: 1, - instance: "test", - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - tasks: [], authorizations: [], setupRequests: [], audit: [], skills: [], jobs: [], - connectors, improvements: [], skillOutcomes: [], learningFindings: [], - promotions: [], snapshots: [], tools: [], toolsets: [], subagents: [], - mcpServers: [], messagingBridges: [], importReports: [], agents: [], - activeAgentId: undefined, relays: [], notifications: [], emailWatchers: [], events: [], - jobRuns: [], chatSessions: [], chatMessages: [], messagingMessages: [], - runs: [], planSteps: [], usageLedger: [] - }; - } - - test("a disabled generic connector sharing the credential name still yields the api-key templateless line by NAME", () => { - // Regression: a disabled/unhealthy "generic" connector row sharing the - // credential name must NOT masquerade as the owning provider. The earlier - // code returned the row's provider ("generic"), grouped under that key, and - // emitted a bogus `{name:"generic", type:"oauth2"}` line. The line must name - // the actual credential and be api-key (templateless is api-key only). - const skill = makeSkill({ - name: "needs-some-service", - requiredCredentials: ["SOME_SERVICE_API_KEY"] - }); - const state = stateWithConnectors([ - { - id: "id_generic_row", - instance: "test", - name: "SOME_SERVICE_API_KEY", - provider: "generic", - status: "disabled", - scopes: [], - secretRefs: [], - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - health: "unknown", - source: "user" - } - ]); - const block = buildInactiveSkillsBlock([skill], state); - expect(block).toContain('name: "SOME_SERVICE_API_KEY"'); - expect(block).toContain('type: "api-key"'); - // Never the bogus generic/oauth2 line. - expect(block).not.toContain('name: "generic"'); - expect(block).not.toContain('type: "oauth2"'); - }); - - test("returns an empty string when no inactive-with-credential skills are present", () => { - expect(buildInactiveSkillsBlock([])).toBe(""); - // Skills with no requiredCredentials are filtered out before the - // grouping step. - const skill = makeSkill({ name: "no-cred", requiredCredentials: [] }); - expect(buildInactiveSkillsBlock([skill])).toBe(""); - }); - - test("opens with the dual-path intro so the model knows both routing options", () => { - const skill = makeSkill({ - name: "needs-linear", - requiredCredentials: ["LINEAR_API_KEY"] - }); - const block = buildInactiveSkillsBlock([skill]); - expect(block).toMatch(/^Skills below need an external connector\./); - // Both request_connector routing options are advertised: a registered - // provider id, and the templateless api-key {name, type:"api-key", skillId} - // shape for a credential with no registered provider. - expect(block).toContain("request_connector"); - expect(block).toContain("provider id"); - expect(block).toContain('{name, type:"api-key", skillId}'); - }); - - test("emits no browser-shortcut directive for the Google Workspace credential", () => { - // On hosted, the Google account is connected at sign-in through the host - // and google-oauth-desktop declares no setup skill, so the Google credential - // routes through the bare request_connector line — with no read_skill detour - // and no "ONLY correct path" browser-shortcut directive. - const skill = makeSkill({ - name: "google-calendar", - requiredCredentials: ["google-workspace-oauth"] - }); - const block = buildInactiveSkillsBlock([skill]); - expect(block).toContain("google-oauth-desktop"); - expect(block).not.toContain("ONLY correct path"); - expect(block).not.toContain("browser_navigate"); - expect(block).not.toMatch(/read_skill/); - }); - - test("skips the no-browser-shortcut directive when no provider declares a setup skill", () => { - // request_connector is the only path advertised when no setup skill is - // declared, so the browser-shortcut directive is unnecessary noise. - const skill = makeSkill({ - name: "needs-linear", - requiredCredentials: ["LINEAR_API_KEY"] - }); - const block = buildInactiveSkillsBlock([skill]); - expect(block).not.toContain("ONLY correct path"); - expect(block).not.toContain("browser_navigate"); - }); -}); - -describe("buildMcpServersBlock", () => { - function stateWith(servers: RuntimeState["mcpServers"]): RuntimeState { - return { - version: 1, - instance: "test", - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - tasks: [], authorizations: [], setupRequests: [], audit: [], skills: [], jobs: [], - connectors: [], improvements: [], skillOutcomes: [], learningFindings: [], - promotions: [], snapshots: [], tools: [], toolsets: [], subagents: [], - mcpServers: servers, messagingBridges: [], importReports: [], agents: [], - activeAgentId: undefined, relays: [], notifications: [], emailWatchers: [], events: [], - jobRuns: [], chatSessions: [], chatMessages: [], messagingMessages: [], - runs: [], planSteps: [], usageLedger: [] - }; - } - - function server(name: string, tools: Array<{ name: string }>): RuntimeState["mcpServers"][number] { - return { - id: `mcp_${name}`, - instance: "test", - name, - command: "", - args: [], - envKeys: [], - status: "configured", - exposedTools: [], - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - transport: "http", - url: "https://example.test/mcp", - tools - }; - } - - test("returns empty string when no servers are configured", () => { - expect(buildMcpServersBlock(stateWith([]))).toBe(""); - }); - - test("lists tool names per server so the model has the full inventory", () => { - // The inventory line is what lets the model reach for a tool the skill - // never documented. Skills should not have to be re-edited every time - // an MCP server adds a tool. - const state = stateWith([ - server("linear", [ - { name: "list_issues" }, - { name: "save_issue" }, - { name: "list_initiatives" }, - { name: "extract_images" } - ]) - ]); - const block = buildMcpServersBlock(state); - expect(block).toContain("- linear (4 tools)"); - expect(block).toContain("tools: extract_images, list_initiatives, list_issues, save_issue"); - }); - - test("includes the default-yes posture instruction", () => { - // Without this, the model treats the skill's documented tools as - // exhaustive and refuses tasks for tools that actually exist on the - // server's inventory list. - const state = stateWith([server("linear", [{ name: "list_issues" }])]); - const block = buildMcpServersBlock(state); - expect(block).toContain("Do not refuse"); - expect(block).toContain("validation error on bad args"); - }); - - test("omits the per-server inventory line when a server has no cached tools yet", () => { - // Health probe hasn't populated tools yet — show the server but skip - // the inventory line so we don't lie about emptiness. (The default-yes - // posture sentence below still mentions the word `tools:`, so we - // assert on the indented inventory line specifically.) - const state = stateWith([server("linear", [])]); - const block = buildMcpServersBlock(state); - expect(block).toContain("- linear"); - expect(block).not.toMatch(/^ {2}tools:/m); - }); - - test("alphabetizes both servers and their tool name lists for determinism", () => { - // Toolset hashes and prompt-cache stability depend on stable ordering - // across boots even when the order tools were registered varies. - const state = stateWith([ - server("zenith", [{ name: "z_one" }, { name: "a_two" }]), - server("acme", [{ name: "c_one" }, { name: "a_two" }]) - ]); - const block = buildMcpServersBlock(state); - const acmeIdx = block.indexOf("- acme"); - const zenithIdx = block.indexOf("- zenith"); - expect(acmeIdx).toBeGreaterThanOrEqual(0); - expect(zenithIdx).toBeGreaterThan(acmeIdx); - expect(block).toContain("tools: a_two, c_one"); - expect(block).toContain("tools: a_two, z_one"); - }); -}); - -describe("buildSkillScriptsBlock", () => { - // listEnabledSkillScripts statSyncs the real scripts/ dir under each - // skill's manifestPath, so the seeded skills need real files on disk. - let dir: string; - - beforeEach(() => { - dir = mkdtempSync(join(tmpdir(), "gini-skill-scripts-block-")); - }); - - afterEach(() => { - rmSync(dir, { recursive: true, force: true }); - }); - - function seedSkill( - state: RuntimeState, - name: string, - scripts: string[], - opts: { status?: SkillRecord["status"] } = {} - ): void { - const skillDir = join(dir, name); - const scriptsDir = join(skillDir, "scripts"); - mkdirSync(scriptsDir, { recursive: true }); - for (const script of scripts) { - writeFileSync(join(scriptsDir, script), "console.log('{}')"); - } - state.skills.push({ - id: `skill_${name}`, - instance: state.instance, - name, - description: "", - trigger: "", - steps: [], - requiredTools: [], - requiredPermissions: [], - status: opts.status ?? "enabled", - version: 1, - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - tests: [], - successCount: 0, - failureCount: 0, - previousVersions: [], - body: "", - source: "bundled", - manifestPath: join(skillDir, "SKILL.md") - }); - } - - test("returns empty string when no visible skill ships scripts", () => { - const state = createEmptyState("test"); - seedSkill(state, "no-scripts", []); - expect(buildSkillScriptsBlock(state, new Set(["no-scripts"]))).toBe(""); - }); - - test("lists each visible skill's scripts, alphabetized by skill and script", () => { - const state = createEmptyState("test"); - seedSkill(state, "bbb", ["alpha.sh"]); - seedSkill(state, "aaa", ["two.ts", "one.ts"]); - const block = buildSkillScriptsBlock(state, new Set(["aaa", "bbb"])); - expect(block).toBe( - [ - "Skill scripts (invoke with skill_run, never re-implement in terminal_exec; call list_skills/read_skill for omitted skills):", - "- aaa: one, two", - "- bbb: alpha" - ].join("\n") - ); - }); - - test("caps the inline skill script list and leaves a discovery hint", () => { - const state = createEmptyState("test"); - const names = new Set(); - for (let i = 0; i < 45; i++) { - const name = `skill-${String(i).padStart(2, "0")}`; - names.add(name); - seedSkill(state, name, ["run.ts"]); - } - const block = buildSkillScriptsBlock(state, names); - expect(block).toContain("- skill-00: run"); - expect(block).toContain("- skill-39: run"); - expect(block).not.toContain("- skill-40: run"); - expect(block).toContain("5 more skill script entries not shown"); - expect(block).toContain("call list_skills"); - }); - - test("omits skills that are enabled but not visible (inactive connector)", () => { - const state = createEmptyState("test"); - seedSkill(state, "visible", ["go.ts"]); - seedSkill(state, "hidden", ["go.ts"]); - const block = buildSkillScriptsBlock(state, new Set(["visible"])); - expect(block).toContain("- visible: go"); - expect(block).not.toContain("hidden"); - }); -}); diff --git a/packages/runtime/src/execution/chat.test.ts b/packages/runtime/src/execution/chat.part1.test.ts similarity index 74% rename from packages/runtime/src/execution/chat.test.ts rename to packages/runtime/src/execution/chat.part1.test.ts index df9794ae..28d3a3a0 100644 --- a/packages/runtime/src/execution/chat.test.ts +++ b/packages/runtime/src/execution/chat.part1.test.ts @@ -901,325 +901,3 @@ describe("chat session waiting-approval placeholder", () => { expect(stateNow.chatSessions.find((item) => item.id === session.id)?.title).toBe("Untitled chat"); }); }); - -// listChatSessions enriches each session with `pendingApprovalCount` so the -// sidebar can render an "awaiting approval" indicator without a second -// round-trip. The count joins state.authorizations and state.setupRequests -// (both pending) against the session's taskIds. -describe("chat list pendingApprovalCount enrichment", () => { - let root: string; - let workspaceRoot: string; - let prevState: string | undefined; - let prevLog: string | undefined; - - beforeEach(() => { - root = mkdtempSync(join(tmpdir(), "gini-chat-list-")); - workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-list-ws-")); - prevState = process.env.GINI_STATE_ROOT; - prevLog = process.env.GINI_LOG_ROOT; - process.env.GINI_STATE_ROOT = root; - process.env.GINI_LOG_ROOT = `${root}-logs`; - }); - - afterEach(() => { - if (prevState === undefined) delete process.env.GINI_STATE_ROOT; - else process.env.GINI_STATE_ROOT = prevState; - if (prevLog === undefined) delete process.env.GINI_LOG_ROOT; - else process.env.GINI_LOG_ROOT = prevLog; - rmSync(root, { recursive: true, force: true }); - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - async function seedTask(config: RuntimeConfig, sessionId: string, taskId: string): Promise { - await mutateState(config.instance, (state) => { - const task: Task = { - id: taskId, - title: taskId, - input: "", - status: "running", - instance: state.instance, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - tracePath: "", - auditIds: [], - approvalIds: [], - skillIds: [], - chatSessionId: sessionId - }; - state.tasks.push(task); - const sessionRecord = state.chatSessions.find((s) => s.id === sessionId); - if (sessionRecord) sessionRecord.taskIds.push(taskId); - }); - } - - async function seedAuthorization( - config: RuntimeConfig, - overrides: Partial & Pick - ): Promise { - await mutateState(config.instance, (state) => { - const at = new Date().toISOString(); - const authorization: Authorization = { - instance: state.instance, - createdAt: at, - updatedAt: at, - action: "file.write", - target: "out.txt", - risk: "medium", - reason: "test authorization", - payload: {}, - ...overrides - }; - state.authorizations.push(authorization); - }); - } - - async function seedSetupRequest( - config: RuntimeConfig, - overrides: Partial & Pick - ): Promise { - await mutateState(config.instance, (state) => { - const at = new Date().toISOString(); - const setupRequest: SetupRequest = { - instance: state.instance, - createdAt: at, - updatedAt: at, - action: "browser.connect", - target: "https://example.com", - reason: "test setup", - payload: {}, - ...overrides - }; - state.setupRequests.push(setupRequest); - }); - } - - test("returns 0 when the session has no approvals", async () => { - const config = buildConfig(workspaceRoot, "chat-list-no-approvals"); - const session = await createChat(config, { title: "plain" }); - - const rows = listChatSessions(config); - const row = rows.find((s) => s.id === session.id); - expect(row?.pendingApprovalCount).toBe(0); - }); - - test("counts a pending Authorization linked to one of the session's tasks", async () => { - const config = buildConfig(workspaceRoot, "chat-list-auth-pending"); - const session = await createChat(config, { title: "needs auth" }); - await seedTask(config, session.id, "task_auth_1"); - await seedAuthorization(config, { id: "authz_1", taskId: "task_auth_1", status: "pending" }); - - const row = listChatSessions(config).find((s) => s.id === session.id); - expect(row?.pendingApprovalCount).toBe(1); - }); - - test("counts a pending SetupRequest linked to one of the session's tasks", async () => { - const config = buildConfig(workspaceRoot, "chat-list-setup-pending"); - const session = await createChat(config, { title: "needs setup" }); - await seedTask(config, session.id, "task_setup_1"); - await seedSetupRequest(config, { id: "setup_1", taskId: "task_setup_1", status: "pending" }); - - const row = listChatSessions(config).find((s) => s.id === session.id); - expect(row?.pendingApprovalCount).toBe(1); - }); - - test("sums pending Authorizations and SetupRequests on the same session", async () => { - const config = buildConfig(workspaceRoot, "chat-list-both-pending"); - const session = await createChat(config, { title: "needs both" }); - await seedTask(config, session.id, "task_both_1"); - await seedTask(config, session.id, "task_both_2"); - await seedAuthorization(config, { id: "authz_a", taskId: "task_both_1", status: "pending" }); - await seedAuthorization(config, { id: "authz_b", taskId: "task_both_2", status: "pending" }); - await seedSetupRequest(config, { id: "setup_a", taskId: "task_both_1", status: "pending" }); - - const row = listChatSessions(config).find((s) => s.id === session.id); - expect(row?.pendingApprovalCount).toBe(3); - }); - - test("ignores resolved Authorizations and SetupRequests", async () => { - const config = buildConfig(workspaceRoot, "chat-list-resolved"); - const session = await createChat(config, { title: "resolved" }); - await seedTask(config, session.id, "task_resolved_1"); - await seedAuthorization(config, { id: "authz_approved", taskId: "task_resolved_1", status: "approved" }); - await seedAuthorization(config, { id: "authz_denied", taskId: "task_resolved_1", status: "denied" }); - await seedSetupRequest(config, { id: "setup_completed", taskId: "task_resolved_1", status: "completed" }); - await seedSetupRequest(config, { id: "setup_cancelled", taskId: "task_resolved_1", status: "cancelled" }); - - const row = listChatSessions(config).find((s) => s.id === session.id); - expect(row?.pendingApprovalCount).toBe(0); - }); - - test("ignores approvals whose taskId is not in the session's taskIds", async () => { - const config = buildConfig(workspaceRoot, "chat-list-other-session"); - const target = await createChat(config, { title: "target" }); - const other = await createChat(config, { title: "other" }); - await seedTask(config, other.id, "task_other_1"); - await seedAuthorization(config, { id: "authz_other", taskId: "task_other_1", status: "pending" }); - await seedSetupRequest(config, { id: "setup_other", taskId: "task_other_1", status: "pending" }); - - const rows = listChatSessions(config); - expect(rows.find((s) => s.id === target.id)?.pendingApprovalCount).toBe(0); - expect(rows.find((s) => s.id === other.id)?.pendingApprovalCount).toBe(2); - }); - - test("truncates lastMessagePreview when the latest block exceeds the cap", async () => { - // Sibling-branch coverage for the existing preview-truncation ternary - // — exercises the long-text path that runs alongside the new - // pendingApprovalCount enrichment in the same map(). - const config = buildConfig(workspaceRoot, "chat-list-long-preview"); - const session = await createChat(config, { title: "long preview" }); - const longText = "x".repeat(300); - insertChatBlock(config.instance, { - kind: "user_text", - sessionId: session.id, - text: longText, - agentId: null - }); - - const row = listChatSessions(config).find((s) => s.id === session.id); - expect(row?.lastMessagePreview).toBeTruthy(); - expect(row?.lastMessagePreview?.endsWith("…")).toBe(true); - expect(row?.lastMessagePreview?.length).toBeLessThan(longText.length); - }); - - test("ignores approvals with no taskId", async () => { - const config = buildConfig(workspaceRoot, "chat-list-no-task-link"); - const session = await createChat(config, { title: "untargeted" }); - await mutateState(config.instance, (state) => { - const at = new Date().toISOString(); - state.authorizations.push({ - id: "authz_no_task", - instance: state.instance, - status: "pending", - createdAt: at, - updatedAt: at, - action: "file.write", - target: "out.txt", - risk: "low", - reason: "no task linkage", - payload: {} - }); - state.setupRequests.push({ - id: "setup_no_task", - instance: state.instance, - status: "pending", - createdAt: at, - updatedAt: at, - action: "browser.connect", - target: "https://example.com", - reason: "no task linkage", - payload: {} - }); - }); - - const row = listChatSessions(config).find((s) => s.id === session.id); - expect(row?.pendingApprovalCount).toBe(0); - }); -}); - -// Client-surface resolution on message submit. UI clients tag each POST with -// a `client` body field; bridge sessions derive the surface from -// `source.kind`; anything else resolves to unknown (no clientSurface on the -// task) — never a submit error. See ADR client-surface-context.md. -describe("chat message client surface", () => { - let root: string; - let workspaceRoot: string; - let prevState: string | undefined; - let prevLog: string | undefined; - - beforeEach(() => { - root = mkdtempSync(join(tmpdir(), "gini-chat-surface-")); - workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-surface-ws-")); - prevState = process.env.GINI_STATE_ROOT; - prevLog = process.env.GINI_LOG_ROOT; - process.env.GINI_STATE_ROOT = root; - process.env.GINI_LOG_ROOT = `${root}-logs`; - clearEchoToolCallingResponses(); - }); - - afterEach(() => { - if (prevState === undefined) delete process.env.GINI_STATE_ROOT; - else process.env.GINI_STATE_ROOT = prevState; - if (prevLog === undefined) delete process.env.GINI_LOG_ROOT; - else process.env.GINI_LOG_ROOT = prevLog; - rmSync(root, { recursive: true, force: true }); - rmSync(workspaceRoot, { recursive: true, force: true }); - clearEchoToolCallingResponses(); - }); - - function stubTurn(config: RuntimeConfig): void { - setEchoToolCallingResponse({ - provider: normalizeProvider(config.provider), - text: "ok", - toolCalls: [], - finishReason: "stop" - }); - } - - function taskSurface(config: RuntimeConfig, taskId: string): string | undefined { - return readState(config.instance).tasks.find((t) => t.id === taskId)?.clientSurface; - } - - test("stores each valid client value on the spawned task", async () => { - const config = buildConfig(workspaceRoot, "chat-surface-valid"); - for (const client of ["web", "mobile", "cli"] as const) { - stubTurn(config); - const session = await createChat(config, { title: `surface-${client}` }); - const submitted = await submitChatMessage(config, session.id, { content: "hi", client }); - expect(taskSurface(config, submitted.taskId)).toBe(client); - } - }); - - test("treats an unrecognized client value as unknown without rejecting the message", async () => { - const config = buildConfig(workspaceRoot, "chat-surface-invalid"); - stubTurn(config); - const session = await createChat(config, { title: "surface-invalid" }); - const submitted = await submitChatMessage(config, session.id, { content: "hi", client: "smartwatch" }); - expect(taskSurface(config, submitted.taskId)).toBeUndefined(); - }); - - test("treats an absent client field as unknown", async () => { - const config = buildConfig(workspaceRoot, "chat-surface-absent"); - stubTurn(config); - const session = await createChat(config, { title: "surface-absent" }); - const submitted = await submitChatMessage(config, session.id, { content: "hi" }); - expect(taskSurface(config, submitted.taskId)).toBeUndefined(); - }); - - test("stamps the client surface on a thread reply's spawned task", async () => { - const config = buildConfig(workspaceRoot, "chat-surface-thread"); - stubTurn(config); - const session = await createChat(config, { title: "surface-thread" }); - // Root the new thread on a main-chat block — the message being - // branched from. - const parent = insertChatBlock(config.instance, { - kind: "user_text", - sessionId: session.id, - text: "original message", - agentId: null - }); - const submitted = await submitThreadReply(config, session.id, "thread_surface", { - content: "reply from my phone", - client: "mobile", - parentBlockId: parent.id - }); - if ("queued" in submitted) throw new Error("idle session should run the thread reply now"); - expect(taskSurface(config, submitted.taskId)).toBe("mobile"); - }); - - test("derives the surface from a bridge session source without a client field", async () => { - const config = buildConfig(workspaceRoot, "chat-surface-bridge"); - const sources = [ - { kind: "telegram" as const, bridgeId: "b1", chatId: 7, target: "7" }, - { kind: "discord" as const, bridgeId: "b2", channelId: "c1", target: "c1" }, - { kind: "openclaw" as const, openclawSessionId: "s1", openclawAgentId: "a1" } - ]; - for (const source of sources) { - stubTurn(config); - const session = await mutateState(config.instance, (state) => - createChatSession(state, `bridge-${source.kind}`, source) - ); - const submitted = await submitChatMessage(config, session.id, { content: "hi" }); - expect(taskSurface(config, submitted.taskId)).toBe(source.kind); - } - }); -}); diff --git a/packages/runtime/src/execution/chat.part2.test.ts b/packages/runtime/src/execution/chat.part2.test.ts new file mode 100644 index 00000000..d2ce821a --- /dev/null +++ b/packages/runtime/src/execution/chat.part2.test.ts @@ -0,0 +1,443 @@ +// Integration tests for the chat session view, focused on the +// waiting-approval placeholder behavior. +// +// Before the fix, syncChatTaskResult accepted waiting_approval as a sync +// trigger and persisted a real ChatMessageRecord with content like +// "Waiting for approval". A short-circuit then prevented updates, so the +// placeholder text never refreshed once the approval was granted and the +// task completed. +// +// After the fix: +// - syncChatTaskResult only writes a real assistant message for +// completed / failed / cancelled. +// - waiting_approval is rendered as a synthetic (ephemeral) assistant +// message synthesized in getChatSession; once the task transitions to +// completed and the real synced message lands, the synthetic one +// disappears and the UI shows the final summary. + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + ProviderAuthError, + clearEchoStructuredResponses, + clearEchoToolCallingResponses, + setEchoStructuredResponse, + setEchoToolCallingResponse, + normalizeProvider +} from "../provider"; +import { decideApproval, failTask } from "../agent"; +import { __setTransformersLoaderForTests } from "../stt"; +import { createChatMessage, createChatSession, insertChatBlock, listChatBlocks, mutateState, readState } from "../state"; +import { storeUpload } from "../state/uploads"; +import { createScheduledJob } from "../jobs"; +import { + getChatSession, + listChatSessions, + submitChatMessage as submitChatMessageRaw, + submitThreadReply, + syncChatTaskResult, + createChat, + deleteChat +} from "./chat"; +import { settleSubmittedChatMessage, type SettledDispatch } from "./chat-test-support"; +import type { Authorization, RuntimeConfig, SetupRequest, Task } from "../types"; + +// Most tests here submit on idle sessions, which always run immediately. +// Settle the echo-first ack into the dispatched turn and narrow to the +// run-now branch so the existing `.taskId` reads stay typed (a queued result +// here is a test-setup bug). See ADR chat-message-queue.md. +async function submitChatMessage( + ...args: Parameters +): Promise> { + const result = await submitChatMessageRaw(...args); + const settled = await settleSubmittedChatMessage( + args[0], + args[1], + result, + String(args[2].content ?? "") + ); + if ("queued" in settled) throw new Error("expected run-now submission, got queued"); + return settled; +} + +// Minimal valid 16 kHz mono 16-bit PCM WAV so decodeWav succeeds and the +// stubbed transcriber actually runs (a malformed buffer would fail in the +// decoder before the provider is reached). +function makeWav(samples: number[]): Uint8Array { + const dataLength = samples.length * 2; + const buffer = new ArrayBuffer(44 + dataLength); + const view = new DataView(buffer); + const writeTag = (offset: number, tag: string) => { + for (let i = 0; i < tag.length; i++) view.setUint8(offset + i, tag.charCodeAt(i)); + }; + writeTag(0, "RIFF"); + view.setUint32(4, 36 + dataLength, true); + writeTag(8, "WAVE"); + writeTag(12, "fmt "); + view.setUint32(16, 16, true); + view.setUint16(20, 1, true); + view.setUint16(22, 1, true); + view.setUint32(24, 16000, true); + view.setUint32(28, 16000 * 2, true); + view.setUint16(32, 2, true); + view.setUint16(34, 16, true); + writeTag(36, "data"); + view.setUint32(40, dataLength, true); + for (let i = 0; i < samples.length; i++) view.setInt16(44 + i * 2, samples[i]!, true); + return new Uint8Array(buffer); +} + +function buildConfig(workspaceRoot: string, instance: string): RuntimeConfig { + return { + instance, + port: 7340, + token: "test", + provider: { name: "echo", model: "" }, + workspaceRoot, + stateRoot: process.env.GINI_STATE_ROOT ?? "/tmp/gini-chat-test", + logRoot: process.env.GINI_LOG_ROOT ?? "/tmp/gini-chat-test-logs", + // Pin the gated path so these waiting-approval placeholder tests + // remain meaningful under the new default-auto approval policy. + approvalMode: "strict" + }; +} + +async function waitForStatus( + config: RuntimeConfig, + taskId: string, + match: (task: Task) => boolean, + timeoutMs = 5000 +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const state = readState(config.instance); + const task = state.tasks.find((t) => t.id === taskId); + if (task && match(task)) return task; + await Bun.sleep(20); + } + throw new Error(`Task ${taskId} did not reach the expected state within ${timeoutMs}ms`); +} + +// listChatSessions enriches each session with `pendingApprovalCount` so the +// sidebar can render an "awaiting approval" indicator without a second +// round-trip. The count joins state.authorizations and state.setupRequests +// (both pending) against the session's taskIds. +describe("chat list pendingApprovalCount enrichment", () => { + let root: string; + let workspaceRoot: string; + let prevState: string | undefined; + let prevLog: string | undefined; + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "gini-chat-list-")); + workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-list-ws-")); + prevState = process.env.GINI_STATE_ROOT; + prevLog = process.env.GINI_LOG_ROOT; + process.env.GINI_STATE_ROOT = root; + process.env.GINI_LOG_ROOT = `${root}-logs`; + }); + + afterEach(() => { + if (prevState === undefined) delete process.env.GINI_STATE_ROOT; + else process.env.GINI_STATE_ROOT = prevState; + if (prevLog === undefined) delete process.env.GINI_LOG_ROOT; + else process.env.GINI_LOG_ROOT = prevLog; + rmSync(root, { recursive: true, force: true }); + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + async function seedTask(config: RuntimeConfig, sessionId: string, taskId: string): Promise { + await mutateState(config.instance, (state) => { + const task: Task = { + id: taskId, + title: taskId, + input: "", + status: "running", + instance: state.instance, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + tracePath: "", + auditIds: [], + approvalIds: [], + skillIds: [], + chatSessionId: sessionId + }; + state.tasks.push(task); + const sessionRecord = state.chatSessions.find((s) => s.id === sessionId); + if (sessionRecord) sessionRecord.taskIds.push(taskId); + }); + } + + async function seedAuthorization( + config: RuntimeConfig, + overrides: Partial & Pick + ): Promise { + await mutateState(config.instance, (state) => { + const at = new Date().toISOString(); + const authorization: Authorization = { + instance: state.instance, + createdAt: at, + updatedAt: at, + action: "file.write", + target: "out.txt", + risk: "medium", + reason: "test authorization", + payload: {}, + ...overrides + }; + state.authorizations.push(authorization); + }); + } + + async function seedSetupRequest( + config: RuntimeConfig, + overrides: Partial & Pick + ): Promise { + await mutateState(config.instance, (state) => { + const at = new Date().toISOString(); + const setupRequest: SetupRequest = { + instance: state.instance, + createdAt: at, + updatedAt: at, + action: "browser.connect", + target: "https://example.com", + reason: "test setup", + payload: {}, + ...overrides + }; + state.setupRequests.push(setupRequest); + }); + } + + test("returns 0 when the session has no approvals", async () => { + const config = buildConfig(workspaceRoot, "chat-list-no-approvals"); + const session = await createChat(config, { title: "plain" }); + + const rows = listChatSessions(config); + const row = rows.find((s) => s.id === session.id); + expect(row?.pendingApprovalCount).toBe(0); + }); + + test("counts a pending Authorization linked to one of the session's tasks", async () => { + const config = buildConfig(workspaceRoot, "chat-list-auth-pending"); + const session = await createChat(config, { title: "needs auth" }); + await seedTask(config, session.id, "task_auth_1"); + await seedAuthorization(config, { id: "authz_1", taskId: "task_auth_1", status: "pending" }); + + const row = listChatSessions(config).find((s) => s.id === session.id); + expect(row?.pendingApprovalCount).toBe(1); + }); + + test("counts a pending SetupRequest linked to one of the session's tasks", async () => { + const config = buildConfig(workspaceRoot, "chat-list-setup-pending"); + const session = await createChat(config, { title: "needs setup" }); + await seedTask(config, session.id, "task_setup_1"); + await seedSetupRequest(config, { id: "setup_1", taskId: "task_setup_1", status: "pending" }); + + const row = listChatSessions(config).find((s) => s.id === session.id); + expect(row?.pendingApprovalCount).toBe(1); + }); + + test("sums pending Authorizations and SetupRequests on the same session", async () => { + const config = buildConfig(workspaceRoot, "chat-list-both-pending"); + const session = await createChat(config, { title: "needs both" }); + await seedTask(config, session.id, "task_both_1"); + await seedTask(config, session.id, "task_both_2"); + await seedAuthorization(config, { id: "authz_a", taskId: "task_both_1", status: "pending" }); + await seedAuthorization(config, { id: "authz_b", taskId: "task_both_2", status: "pending" }); + await seedSetupRequest(config, { id: "setup_a", taskId: "task_both_1", status: "pending" }); + + const row = listChatSessions(config).find((s) => s.id === session.id); + expect(row?.pendingApprovalCount).toBe(3); + }); + + test("ignores resolved Authorizations and SetupRequests", async () => { + const config = buildConfig(workspaceRoot, "chat-list-resolved"); + const session = await createChat(config, { title: "resolved" }); + await seedTask(config, session.id, "task_resolved_1"); + await seedAuthorization(config, { id: "authz_approved", taskId: "task_resolved_1", status: "approved" }); + await seedAuthorization(config, { id: "authz_denied", taskId: "task_resolved_1", status: "denied" }); + await seedSetupRequest(config, { id: "setup_completed", taskId: "task_resolved_1", status: "completed" }); + await seedSetupRequest(config, { id: "setup_cancelled", taskId: "task_resolved_1", status: "cancelled" }); + + const row = listChatSessions(config).find((s) => s.id === session.id); + expect(row?.pendingApprovalCount).toBe(0); + }); + + test("ignores approvals whose taskId is not in the session's taskIds", async () => { + const config = buildConfig(workspaceRoot, "chat-list-other-session"); + const target = await createChat(config, { title: "target" }); + const other = await createChat(config, { title: "other" }); + await seedTask(config, other.id, "task_other_1"); + await seedAuthorization(config, { id: "authz_other", taskId: "task_other_1", status: "pending" }); + await seedSetupRequest(config, { id: "setup_other", taskId: "task_other_1", status: "pending" }); + + const rows = listChatSessions(config); + expect(rows.find((s) => s.id === target.id)?.pendingApprovalCount).toBe(0); + expect(rows.find((s) => s.id === other.id)?.pendingApprovalCount).toBe(2); + }); + + test("truncates lastMessagePreview when the latest block exceeds the cap", async () => { + // Sibling-branch coverage for the existing preview-truncation ternary + // — exercises the long-text path that runs alongside the new + // pendingApprovalCount enrichment in the same map(). + const config = buildConfig(workspaceRoot, "chat-list-long-preview"); + const session = await createChat(config, { title: "long preview" }); + const longText = "x".repeat(300); + insertChatBlock(config.instance, { + kind: "user_text", + sessionId: session.id, + text: longText, + agentId: null + }); + + const row = listChatSessions(config).find((s) => s.id === session.id); + expect(row?.lastMessagePreview).toBeTruthy(); + expect(row?.lastMessagePreview?.endsWith("…")).toBe(true); + expect(row?.lastMessagePreview?.length).toBeLessThan(longText.length); + }); + + test("ignores approvals with no taskId", async () => { + const config = buildConfig(workspaceRoot, "chat-list-no-task-link"); + const session = await createChat(config, { title: "untargeted" }); + await mutateState(config.instance, (state) => { + const at = new Date().toISOString(); + state.authorizations.push({ + id: "authz_no_task", + instance: state.instance, + status: "pending", + createdAt: at, + updatedAt: at, + action: "file.write", + target: "out.txt", + risk: "low", + reason: "no task linkage", + payload: {} + }); + state.setupRequests.push({ + id: "setup_no_task", + instance: state.instance, + status: "pending", + createdAt: at, + updatedAt: at, + action: "browser.connect", + target: "https://example.com", + reason: "no task linkage", + payload: {} + }); + }); + + const row = listChatSessions(config).find((s) => s.id === session.id); + expect(row?.pendingApprovalCount).toBe(0); + }); +}); + +// Client-surface resolution on message submit. UI clients tag each POST with +// a `client` body field; bridge sessions derive the surface from +// `source.kind`; anything else resolves to unknown (no clientSurface on the +// task) — never a submit error. See ADR client-surface-context.md. +describe("chat message client surface", () => { + let root: string; + let workspaceRoot: string; + let prevState: string | undefined; + let prevLog: string | undefined; + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "gini-chat-surface-")); + workspaceRoot = mkdtempSync(join(tmpdir(), "gini-chat-surface-ws-")); + prevState = process.env.GINI_STATE_ROOT; + prevLog = process.env.GINI_LOG_ROOT; + process.env.GINI_STATE_ROOT = root; + process.env.GINI_LOG_ROOT = `${root}-logs`; + clearEchoToolCallingResponses(); + }); + + afterEach(() => { + if (prevState === undefined) delete process.env.GINI_STATE_ROOT; + else process.env.GINI_STATE_ROOT = prevState; + if (prevLog === undefined) delete process.env.GINI_LOG_ROOT; + else process.env.GINI_LOG_ROOT = prevLog; + rmSync(root, { recursive: true, force: true }); + rmSync(workspaceRoot, { recursive: true, force: true }); + clearEchoToolCallingResponses(); + }); + + function stubTurn(config: RuntimeConfig): void { + setEchoToolCallingResponse({ + provider: normalizeProvider(config.provider), + text: "ok", + toolCalls: [], + finishReason: "stop" + }); + } + + function taskSurface(config: RuntimeConfig, taskId: string): string | undefined { + return readState(config.instance).tasks.find((t) => t.id === taskId)?.clientSurface; + } + + test("stores each valid client value on the spawned task", async () => { + const config = buildConfig(workspaceRoot, "chat-surface-valid"); + for (const client of ["web", "mobile", "cli"] as const) { + stubTurn(config); + const session = await createChat(config, { title: `surface-${client}` }); + const submitted = await submitChatMessage(config, session.id, { content: "hi", client }); + expect(taskSurface(config, submitted.taskId)).toBe(client); + } + }); + + test("treats an unrecognized client value as unknown without rejecting the message", async () => { + const config = buildConfig(workspaceRoot, "chat-surface-invalid"); + stubTurn(config); + const session = await createChat(config, { title: "surface-invalid" }); + const submitted = await submitChatMessage(config, session.id, { content: "hi", client: "smartwatch" }); + expect(taskSurface(config, submitted.taskId)).toBeUndefined(); + }); + + test("treats an absent client field as unknown", async () => { + const config = buildConfig(workspaceRoot, "chat-surface-absent"); + stubTurn(config); + const session = await createChat(config, { title: "surface-absent" }); + const submitted = await submitChatMessage(config, session.id, { content: "hi" }); + expect(taskSurface(config, submitted.taskId)).toBeUndefined(); + }); + + test("stamps the client surface on a thread reply's spawned task", async () => { + const config = buildConfig(workspaceRoot, "chat-surface-thread"); + stubTurn(config); + const session = await createChat(config, { title: "surface-thread" }); + // Root the new thread on a main-chat block — the message being + // branched from. + const parent = insertChatBlock(config.instance, { + kind: "user_text", + sessionId: session.id, + text: "original message", + agentId: null + }); + const submitted = await submitThreadReply(config, session.id, "thread_surface", { + content: "reply from my phone", + client: "mobile", + parentBlockId: parent.id + }); + if ("queued" in submitted) throw new Error("idle session should run the thread reply now"); + expect(taskSurface(config, submitted.taskId)).toBe("mobile"); + }); + + test("derives the surface from a bridge session source without a client field", async () => { + const config = buildConfig(workspaceRoot, "chat-surface-bridge"); + const sources = [ + { kind: "telegram" as const, bridgeId: "b1", chatId: 7, target: "7" }, + { kind: "discord" as const, bridgeId: "b2", channelId: "c1", target: "c1" }, + { kind: "openclaw" as const, openclawSessionId: "s1", openclawAgentId: "a1" } + ]; + for (const source of sources) { + stubTurn(config); + const session = await mutateState(config.instance, (state) => + createChatSession(state, `bridge-${source.kind}`, source) + ); + const submitted = await submitChatMessage(config, session.id, { content: "hi" }); + expect(taskSurface(config, submitted.taskId)).toBe(source.kind); + } + }); +}); diff --git a/packages/runtime/src/http.part1.test.ts b/packages/runtime/src/http.part1.test.ts new file mode 100644 index 00000000..ba2c2525 --- /dev/null +++ b/packages/runtime/src/http.part1.test.ts @@ -0,0 +1,2328 @@ +import { describe, expect, test } from "bun:test"; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import "./hooks/builtins"; // the email-watch routes provision a backing job, which validates isKnownHook("skill-script") +import { createHandler, resolveInlineUpload } from "./http"; +import { logDir, uploadsDir, webPortPath } from "./paths"; +import { clearWebTargetCache } from "./web-target"; +import { dirname, join } from "node:path"; +import { addAudit, appendEvent, insertChatBlock, isPlausibleMime, mutateState, readState, readTrace, recordProviderAuthFailure, sanitizeFilename, storeUpload, uploadStat } from "./state"; +import { getOrCreateAgentChat } from "./execution/chat"; +import { createScheduledJob } from "./jobs"; +import { removeMemoryDb } from "./state/memory-db"; +import { listProviders } from "./integrations/connectors/registry"; +import { resetGoogleLoginWebState } from "./integrations/connectors/google-login-web"; +import { awaitTunnelSettled, setTunnelDeps, type TunnelChild } from "./integrations/tunnel"; +import type { RuntimeConfig } from "./types"; +import type { LoginHandle, RelayDefaults, Session, Store, TunnelOptions } from "gini-relay"; + +// Stub a provider's host-environment `detect()` so the connector-detection +// endpoint test stays deterministic AND fast regardless of what's installed +// on the developer's PATH. The production `detect()` for claude-code / codex +// shells out via spawnSync (`which`, `claude auth status`), which on a machine +// with those CLIs installed dominates this test's wall time (the unstubbed +// detect endpoint test measured 1.524641s). Mirrors the same in-place +// swap-and-restore helper used by src/jobs/connector-detection.test.ts. The +// registry is a process-wide singleton, so the returned restore fn MUST run in +// a finally to avoid leaking the stub into sibling tests. +function stubProviderDetect( + providerId: string, + value: { detected: boolean; suggestedName?: string; message?: string } +): () => void { + const provider = listProviders().find((p) => p.id === providerId); + if (!provider) throw new Error(`Provider not registered: ${providerId}`); + const previous = provider.detect; + provider.detect = async () => value; + return () => { + provider.detect = previous; + }; +} + +// Companion to stubProviderDetect. After connector auto-detection creates a +// record for a provider that exposes a `probe()`, runConnectorDetection runs +// an initial checkConnector → provider.probe, which for claude-code shells out +// to `claude auth status` again. Stub the probe so the detection endpoint +// test never touches a real subprocess. Same swap-and-restore discipline. +function stubProviderProbe(providerId: string, value: { ok: boolean; message: string }): () => void { + const provider = listProviders().find((p) => p.id === providerId); + if (!provider) throw new Error(`Provider not registered: ${providerId}`); + const previous = provider.probe; + provider.probe = async () => value; + return () => { + provider.probe = previous; + }; +} + +describe("runtime api", () => { + test("applies approved improvement proposals and audits the decision", async () => { + const config = testConfig("improvement-approve"); + const handler = createHandler(config); + + const proposal = await call(handler, config, "/api/improvements", { + method: "POST", + body: JSON.stringify({ + kind: "skill", + title: "review-traces", + rationale: "Trace evidence shows repeated review steps.", + payload: { name: "review-traces", steps: ["Inspect trace", "Summarize evidence"] } + }) + }); + + const applied = await call(handler, config, `/api/improvements/${proposal.id}/approve`, { method: "POST" }); + const state = readState(config.instance); + + expect(applied.status).toBe("applied"); + expect(applied.appliedTargetId).toBeString(); + expect(state.skills.some((skill) => skill.id === applied.appliedTargetId)).toBe(true); + expect(state.audit.some((event) => event.action === "improvement.applied")).toBe(true); + }); + + + test("rejected improvement proposals do not mutate target stores", async () => { + const config = testConfig("improvement-reject"); + const handler = createHandler(config); + + const proposal = await call(handler, config, "/api/improvements", { + method: "POST", + body: JSON.stringify({ + kind: "skill", + title: "Remember review preference", + payload: { name: "review-pref", description: "Prefer evidence-backed reviews.", trigger: "review", steps: ["Cite evidence"] } + }) + }); + + const rejected = await call(handler, config, `/api/improvements/${proposal.id}/reject`, { method: "POST" }); + const state = readState(config.instance); + + expect(rejected.status).toBe("rejected"); + expect(state.skills.some((skill) => skill.name === "review-pref")).toBe(false); + expect(state.audit.some((event) => event.action === "improvement.rejected")).toBe(true); + }); + + + test("only the owner bearer authorizes the mobile contracts (owner-token-only auth)", async () => { + const config = testConfig("owner-only-mobile"); + const handler = createHandler(config); + + // A legacy device-shaped bearer must be refused — the pairing subsystem is + // gone and bearer === config.token is the whole check (ADR owner-token-auth.md). + const refused = await rawCall(handler, config, "/api/mobile/bootstrap", {}, "gini_device_00000000-0000-0000-0000-000000000000"); + expect(refused.status).toBe(401); + + const mobile = await callWithToken(handler, config, config.token, "/api/mobile/bootstrap"); + expect(mobile.instance).toBe(config.instance); + }); + + + test("records promotion proposals without applying upgrades", async () => { + const config = testConfig("promotion"); + const handler = createHandler(config); + + const proposal = await call(handler, config, "/api/promotions", { + method: "POST", + body: JSON.stringify({ + candidateRef: "commit-abc", + evidencePath: "/tmp/evidence.json", + summary: "Candidate passed sandbox smoke.", + rollbackPlan: "Restore snapshot snap_abc." + }) + }); + const rejected = await call(handler, config, `/api/promotions/${proposal.id}/reject`, { method: "POST" }); + + expect(rejected.status).toBe("rejected"); + expect(rejected.candidateRef).toBe("commit-abc"); + expect(readState(config.instance).audit.some((event) => event.action === "promotion.rejected")).toBe(true); + }); + + + test("supports Hermes-parity control records for search, toolsets, subagents, MCP, messaging, and imports", async () => { + const config = testConfig("hermes-parity"); + const handler = createHandler(config); + + const task = await call(handler, config, "/api/tasks", { + method: "POST", + body: JSON.stringify({ input: "remember Hermes parity should be searchable" }) + }); + await waitForTask(handler, config, task.id); + + const search = await call(handler, config, "/api/search?q=Hermes"); + const toolsets = await call(handler, config, "/api/toolsets"); + const disabled = await call(handler, config, "/api/toolsets/messaging/disable", { method: "POST" }); + const subagent = await call(handler, config, "/api/subagents", { + method: "POST", + body: JSON.stringify({ name: "reviewer", prompt: "review Hermes parity", parentTaskId: task.id, toolsets: ["memory"] }) + }); + await waitForTask(handler, config, subagent.taskId); + const mcp = await call(handler, config, "/api/mcp", { + method: "POST", + body: JSON.stringify({ name: "demo-mcp", command: "echo", args: ["ok"], exposedTools: ["demo.echo"] }) + }); + const bridge = await call(handler, config, "/api/messaging", { + method: "POST", + body: JSON.stringify({ name: "demo-bridge", kind: "demo", deliveryTargets: ["local"] }) + }); + const report = await call(handler, config, "/api/imports/inspect", { + method: "POST", + body: JSON.stringify({ source: "hermes", path: process.cwd() }) + }); + + expect(search.length).toBeGreaterThan(0); + expect(toolsets.toolsets.some((item: { name: string }) => item.name === "session_search")).toBe(true); + expect(disabled.status).toBe("disabled"); + expect(subagent.taskId).toBeString(); + expect(mcp.status).toBe("configured"); + expect(bridge.status).toBe("configured"); + expect(report.status).toBe("completed"); + }); + + + test("executes low-risk file tool tasks with trace and audit evidence", async () => { + const config = testConfig("file-tools"); + config.workspaceRoot = process.cwd(); + const handler = createHandler(config); + + const read = await call(handler, config, "/api/tasks", { + method: "POST", + body: JSON.stringify({ input: "read README.md" }) + }); + const readDetail = await waitForTask(handler, config, read.id); + const list = await call(handler, config, "/api/tasks", { + method: "POST", + body: JSON.stringify({ input: "list packages/runtime/src" }) + }); + const listDetail = await waitForTask(handler, config, list.id); + const find = await call(handler, config, "/api/tasks", { + method: "POST", + body: JSON.stringify({ input: "find Gini in README.md" }) + }); + const findDetail = await waitForTask(handler, config, find.id); + const state = readState(config.instance); + + expect(readDetail.task.status).toBe("completed"); + expect(listDetail.task.summary).toContain("src/agent.ts"); + expect(findDetail.task.summary).toContain("README.md"); + expect(state.audit.some((event) => event.action === "file.read")).toBe(true); + expect(state.audit.some((event) => event.action === "file.list")).toBe(true); + expect(state.audit.some((event) => event.action === "file.search")).toBe(true); + }); + + + test("supports agent config equivalents and Hermes parity reporting", async () => { + const config = testConfig("agents-parity"); + const handler = createHandler(config); + + const created = await call(handler, config, "/api/agents", { + method: "POST", + body: JSON.stringify({ name: "research", toolsets: ["file", "web", "session_search"] }) + }); + const active = await call(handler, config, `/api/agents/${created.id}/use`, { method: "POST" }); + const agents = await call(handler, config, "/api/agents"); + const parity = await call(handler, config, "/api/parity/hermes"); + + expect(active.status).toBe("active"); + expect(agents.activeAgentId).toBe(created.id); + expect(parity.ok).toBe(true); + expect(parity.checks.some((item: { id: string; status: string }) => item.id === "agents" && item.status === "pass")).toBe(true); + }); + + + test("DELETE /api/agents/:id removes the agent and cascades cleanup", async () => { + const config = testConfig("agents-delete"); + const handler = createHandler(config); + + const created = await call(handler, config, "/api/agents", { + method: "POST", + body: JSON.stringify({ name: "scratch" }) + }); + const deleted = await call(handler, config, `/api/agents/${created.id}`, { method: "DELETE" }); + + expect(deleted.ok).toBe(true); + expect(deleted.id).toBe(created.id); + expect(deleted.bankDeleted).toBe(true); + + const after = await call(handler, config, "/api/agents"); + expect(after.agents.find((agent: { id: string }) => agent.id === created.id)).toBeUndefined(); + + // Idempotent: a second delete on the same id returns 404, not 500. + const followUp = await rawCall(handler, config, `/api/agents/${created.id}`, { method: "DELETE" }, config.token); + expect(followUp.status).toBe(404); + }); + + + test("DELETE /api/agents/:id rejects the default agent with 400", async () => { + const config = testConfig("agents-delete-default"); + const handler = createHandler(config); + const response = await rawCall(handler, config, "/api/agents/agent_default", { method: "DELETE" }, config.token); + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.error).toContain("Cannot delete the default agent"); + }); + + + test("DELETE /api/agents/:id rejects the active agent with 400", async () => { + const config = testConfig("agents-delete-active"); + const handler = createHandler(config); + + const created = await call(handler, config, "/api/agents", { + method: "POST", + body: JSON.stringify({ name: "active" }) + }); + await call(handler, config, `/api/agents/${created.id}/use`, { method: "POST" }); + + const response = await rawCall(handler, config, `/api/agents/${created.id}`, { method: "DELETE" }, config.token); + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.error).toContain("Cannot delete the active agent"); + }); + + + test("POST /api/agents/:id/archive then /unarchive round-trips archivedAt", async () => { + const config = testConfig("agents-archive-roundtrip"); + const handler = createHandler(config); + + const created = await call(handler, config, "/api/agents", { + method: "POST", + body: JSON.stringify({ name: "scratch" }) + }); + const archived = await call(handler, config, `/api/agents/${created.id}/archive`, { method: "POST" }); + expect(typeof archived.archivedAt).toBe("string"); + + const restored = await call(handler, config, `/api/agents/${created.id}/unarchive`, { method: "POST" }); + expect(restored.archivedAt).toBeUndefined(); + }); + + + test("POST /api/agents/:id/archive rejects the default agent with 400", async () => { + const config = testConfig("agents-archive-default"); + const handler = createHandler(config); + const response = await rawCall(handler, config, "/api/agents/agent_default/archive", { method: "POST" }, config.token); + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.error).toContain("Cannot archive the default agent"); + }); + + + test("POST /api/agents/:id/archive archives the active agent and hands active to the default", async () => { + const config = testConfig("agents-archive-active"); + const handler = createHandler(config); + + const created = await call(handler, config, "/api/agents", { + method: "POST", + body: JSON.stringify({ name: "active" }) + }); + await call(handler, config, `/api/agents/${created.id}/use`, { method: "POST" }); + + const archived = await call(handler, config, `/api/agents/${created.id}/archive`, { method: "POST" }); + expect(typeof archived.archivedAt).toBe("string"); + + // Active selection reassigns to the always-present default agent. + const agents = await call(handler, config, "/api/agents"); + expect(agents.activeAgentId).toBe("agent_default"); + expect(agents.defaultAgentId).toBe("agent_default"); + }); + + + test("POST /api/agents/:id/use rejects an archived agent with 400", async () => { + const config = testConfig("agents-use-archived"); + const handler = createHandler(config); + + const created = await call(handler, config, "/api/agents", { + method: "POST", + body: JSON.stringify({ name: "scratch" }) + }); + await call(handler, config, `/api/agents/${created.id}/archive`, { method: "POST" }); + + const response = await rawCall(handler, config, `/api/agents/${created.id}/use`, { method: "POST" }, config.token); + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.error).toContain("Cannot use an archived agent"); + }); + + + test("PATCH /api/agents/:id renames the agent", async () => { + const config = testConfig("agents-rename"); + const handler = createHandler(config); + + const created = await call(handler, config, "/api/agents", { + method: "POST", + body: JSON.stringify({ name: "Mansour" }) + }); + const renamed = await call(handler, config, `/api/agents/${created.id}`, { + method: "PATCH", + body: JSON.stringify({ name: "Bob" }) + }); + expect(renamed.id).toBe(created.id); + expect(renamed.name).toBe("Bob"); + + const after = await call(handler, config, "/api/agents"); + expect(after.agents.find((agent: { id: string }) => agent.id === created.id)?.name).toBe("Bob"); + }); + + + test("PATCH /api/agents/:id returns 404 for an unknown agent", async () => { + const config = testConfig("agents-rename-missing"); + const handler = createHandler(config); + const response = await rawCall( + handler, + config, + "/api/agents/agent_does_not_exist", + { method: "PATCH", body: JSON.stringify({ name: "Bob" }) }, + config.token + ); + expect(response.status).toBe(404); + }); + + + test("PATCH /api/agents/:id returns 400 for an empty name", async () => { + // A missing / blank name is user input, not a server fault — it must + // map to 400, never the catch-all 500. + const config = testConfig("agents-rename-empty"); + const handler = createHandler(config); + const created = await call(handler, config, "/api/agents", { + method: "POST", + body: JSON.stringify({ name: "Mansour" }) + }); + const response = await rawCall( + handler, + config, + `/api/agents/${created.id}`, + { method: "PATCH", body: JSON.stringify({}) }, + config.token + ); + expect(response.status).toBe(400); + }); + + + test("POST /api/agents/:id/provider sets the agent's provider and /status reflects it", async () => { + const config = testConfig("agents-set-provider"); + const handler = createHandler(config); + // Configure the pinned provider so the resolved provider dispatches verbatim; + // an unconfigured pin would transiently fall back to any other configured + // provider (e.g. an ambient codex auth.json on the dev machine), which is a + // separate path covered by the dispatch-fallback tests. + const prevOpenai = process.env.OPENAI_API_KEY; + process.env.OPENAI_API_KEY = "sk-test-agent-provider"; + try { + const created = await call(handler, config, "/api/agents", { + method: "POST", + body: JSON.stringify({ name: "research" }) + }); + await call(handler, config, `/api/agents/${created.id}/use`, { method: "POST" }); + + const updated = await call(handler, config, `/api/agents/${created.id}/provider`, { + method: "POST", + body: JSON.stringify({ providerName: "openai", model: "gpt-4o" }) + }); + expect(updated.providerName).toBe("openai"); + expect(updated.model).toBe("gpt-4o"); + + // The override drives inference: the active-agent block resolves the + // agent's provider, not the instance default (echo in this config). + const status = await call(handler, config, "/api/status"); + expect(status.activeAgent.resolvedProvider.name).toBe("openai"); + expect(status.activeAgent.resolvedProvider.model).toBe("gpt-4o"); + expect(status.activeAgent.providerSource).toBe("agent"); + } finally { + if (prevOpenai === undefined) delete process.env.OPENAI_API_KEY; + else process.env.OPENAI_API_KEY = prevOpenai; + } + }); + + + test("POST /api/agents/:id/provider with blank fields clears the override", async () => { + const config = testConfig("agents-clear-provider"); + const handler = createHandler(config); + const created = await call(handler, config, "/api/agents", { + method: "POST", + body: JSON.stringify({ name: "research", providerName: "openai", model: "gpt-4o" }) + }); + await call(handler, config, `/api/agents/${created.id}/use`, { method: "POST" }); + + const cleared = await call(handler, config, `/api/agents/${created.id}/provider`, { + method: "POST", + body: JSON.stringify({ providerName: "", model: "" }) + }); + expect(cleared.providerName).toBeUndefined(); + expect(cleared.model).toBeUndefined(); + + // With no agent override, the active-agent block falls back to the + // instance provider (echo) and reports the instance source. + const status = await call(handler, config, "/api/status"); + expect(status.activeAgent.providerSource).toBe("instance"); + expect(status.activeAgent.resolvedProvider.name).toBe("echo"); + }); + + + test("POST /api/agents/:id/provider returns 404 for an unknown agent", async () => { + const config = testConfig("agents-set-provider-missing"); + const handler = createHandler(config); + const response = await rawCall( + handler, + config, + "/api/agents/agent_does_not_exist/provider", + { method: "POST", body: JSON.stringify({ providerName: "openai", model: "gpt-4o" }) }, + config.token + ); + expect(response.status).toBe(404); + }); + + + test("POST /api/agents/:id/provider returns 400 for a lone providerName", async () => { + const config = testConfig("agents-set-provider-partial"); + const handler = createHandler(config); + const created = await call(handler, config, "/api/agents", { + method: "POST", + body: JSON.stringify({ name: "research" }) + }); + const response = await rawCall( + handler, + config, + `/api/agents/${created.id}/provider`, + { method: "POST", body: JSON.stringify({ providerName: "openai" }) }, + config.token + ); + expect(response.status).toBe(400); + }); + + + test("POST /api/agents/:id/provider returns 400 for an unknown provider", async () => { + const config = testConfig("agents-set-provider-unknown"); + const handler = createHandler(config); + const created = await call(handler, config, "/api/agents", { + method: "POST", + body: JSON.stringify({ name: "research" }) + }); + const response = await rawCall( + handler, + config, + `/api/agents/${created.id}/provider`, + { method: "POST", body: JSON.stringify({ providerName: "bogus", model: "x" }) }, + config.token + ); + expect(response.status).toBe(400); + }); + + + test("supports relay degraded health and notification delivery records", async () => { + const config = testConfig("relay-notifications"); + const handler = createHandler(config); + + const relay = await call(handler, config, "/api/relays", { + method: "POST", + body: JSON.stringify({ name: "local", endpoint: "local://test", mode: "local-only" }) + }); + const health = await call(handler, config, `/api/relays/${relay.id}/health`, { method: "POST" }); + const notification = await call(handler, config, "/api/notifications", { + method: "POST", + body: JSON.stringify({ kind: "runtime", target: "local", title: "Runtime check", body: "Relay test" }) + }); + const sent = await call(handler, config, "/api/notifications/send", { method: "POST" }); + + expect(health.status).toBe("degraded"); + expect(notification.status).toBe("queued"); + expect(sent.some((item: { id: string; status: string }) => item.id === notification.id && item.status === "sent")).toBe(true); + }); + + + test("tunnel routes return the full TunnelState across the select/connect/disconnect flow", async () => { + const config = testConfig("tunnel-routes"); + const handler = createHandler(config); + + // Inject fake gini-relay seams so the connect flow exercises the + // connecting -> connected transition without OAuth, the host browser, or a + // spawned frpc child. Restored in the finally. + const session: Session = { token: "gsk_x", subdomain: "subroute", account: "u@test" }; + const relay: RelayDefaults = { + relayUrl: "https://relay.test", frpsAddr: "relay.test", frpsPort: 7000, + relayDomain: "relay.test", tlsServerName: "relay.test", frpToken: "t", + caFile: "/tmp/ca", loopbackPorts: [8765], bandwidth: "1220KB" + }; + const child: TunnelChild = { + start: () => Promise.resolve(), + stop: () => Promise.resolve(0), + exited: Promise.withResolvers().promise + }; + const store: Store = { home: "/tmp/h", deviceId: () => "d1", readSession: () => session, writeSession: () => {}, clearSession: () => {} }; + const handle: LoginHandle = { + url: "https://relay.test/consent", redirectUri: "http://127.0.0.1:8765/cb", + waitForSession: () => Promise.resolve(session), cancel: () => {} + }; + // Inert manual drivers so nothing in this flow can shell out to a + // host-installed tailscale/ngrok/cloudflared (or flip their catalog rows). + const inertDriver = (requires: string) => ({ + detect: () => Promise.resolve({ enabled: false, requires }), + connect: () => Promise.reject(new Error("manual driver must not run")) + }); + setTunnelDeps({ + loginUrl: () => Promise.resolve(handle), + buildTunnel: (_opts: TunnelOptions) => child, + createStore: () => store, + resolveDefaults: () => relay, + openBrowser: () => {}, + resolveLocalPort: () => 4321, + probeLocalPort: () => Promise.resolve(true), + drivers: { + tailscale: inertDriver("Tailscale network"), + ngrok: inertDriver("ngrok account"), + cloudflare: inertDriver("cloudflared CLI") + } + }); + + try { + // GET on a fresh instance: catalog present, nothing selected, idle. + const initial = await call(handler, config, "/api/tunnel"); + expect(initial.status).toBe("idle"); + expect(initial.selectedProvider).toBeNull(); + expect(initial.providers.map((p: { id: string }) => p.id)).toEqual([ + "gini-relay", + "tailscale", + "ngrok", + "cloudflare" + ]); + + // select saves the choice without connecting. + const selected = await call(handler, config, "/api/tunnel/select", { + method: "POST", + body: JSON.stringify({ provider: "gini-relay" }) + }); + expect(selected.selectedProvider).toBe("gini-relay"); + expect(selected.status).toBe("idle"); + + // connect (no body provider) uses the saved selection; the route returns + // "connecting" immediately while the background handshake runs. + const connecting = await call(handler, config, "/api/tunnel/connect", { + method: "POST", + body: JSON.stringify({}) + }); + expect(connecting.status).toBe("connecting"); + expect(connecting.url).toBeUndefined(); + + // Let the background flow settle, then GET reflects connected + url. + await awaitTunnelSettled(config.instance); + const connected = await call(handler, config, "/api/tunnel"); + expect(connected.status).toBe("connected"); + expect(connected.url).toBe("https://subroute.relay.test"); + + // cancel returns to idle keeping the selection. + const cancelled = await call(handler, config, "/api/tunnel/cancel", { method: "POST" }); + expect(cancelled.status).toBe("idle"); + expect(cancelled.selectedProvider).toBe("gini-relay"); + + // connect with an explicit provider in the body overrides selection. + const reconnecting = await call(handler, config, "/api/tunnel/connect", { + method: "POST", + body: JSON.stringify({ provider: "gini-relay" }) + }); + expect(reconnecting.status).toBe("connecting"); + await awaitTunnelSettled(config.instance); + expect((await call(handler, config, "/api/tunnel")).status).toBe("connected"); + + // disconnect tears down, keeps the selection. + const disconnected = await call(handler, config, "/api/tunnel/disconnect", { method: "POST" }); + expect(disconnected.status).toBe("idle"); + expect(disconnected.selectedProvider).toBe("gini-relay"); + } finally { + setTunnelDeps(); + } + }); + + + test("GET /api/tunnel?detect=1 re-probes driver availability and flips catalog rows", async () => { + const config = testConfig("tunnel-detect"); + const handler = createHandler(config); + const inert = (requires: string) => ({ + detect: () => Promise.resolve({ enabled: false, requires }), + connect: () => Promise.reject(new Error("unused")) + }); + setTunnelDeps({ + drivers: { + tailscale: { detect: () => Promise.resolve({ enabled: true }), connect: () => Promise.reject(new Error("unused")) }, + ngrok: inert("ngrok account"), + cloudflare: inert("cloudflared CLI") + } + }); + try { + // A plain GET never spawns detection: the catalog stays default-disabled. + const plain = await call(handler, config, "/api/tunnel"); + const plainRow = plain.providers.find((p: { id: string }) => p.id === "tailscale"); + expect(plainRow.enabled).toBe(false); + // detect=1 probes the drivers and the row flips. + const detected = await call(handler, config, "/api/tunnel?detect=1"); + const row = detected.providers.find((p: { id: string }) => p.id === "tailscale"); + expect(row.enabled).toBe(true); + expect(row.requires).toBeUndefined(); + } finally { + setTunnelDeps(); + } + }); + + + test("POST /api/tunnel/select rejects a disabled provider with a 400", async () => { + const config = testConfig("tunnel-reject"); + const handler = createHandler(config); + // The select path re-probes a disabled provider's prerequisite before + // rejecting — pin detection to disabled so the rejection (and this test) + // never depends on which CLIs the host machine happens to have. + setTunnelDeps({ + drivers: { + tailscale: { detect: () => Promise.resolve({ enabled: false, requires: "Tailscale network" }), connect: () => Promise.reject(new Error("unused")) }, + ngrok: { detect: () => Promise.resolve({ enabled: false, requires: "ngrok account" }), connect: () => Promise.reject(new Error("unused")) }, + cloudflare: { detect: () => Promise.resolve({ enabled: false, requires: "cloudflared CLI" }), connect: () => Promise.reject(new Error("unused")) } + } + }); + try { + const response = await rawCall(handler, config, "/api/tunnel/select", { + method: "POST", + body: JSON.stringify({ provider: "ngrok" }) + }, config.token); + expect(response.status).toBe(400); + const value = await response.json(); + expect(value.error).toContain("not available"); + // The machine-readable code rides along so clients can branch on the + // failure kind (the web UI opens the provider's guide on this code). + expect(value.code).toBe("provider_unavailable"); + } finally { + setTunnelDeps(); + } + }); + + + test("supports V1 skill governance and job run history workflows", async () => { + const config = testConfig("v1-skill-job"); + const handler = createHandler(config); + + const skill = await call(handler, config, "/api/skills", { + method: "POST", + body: JSON.stringify({ name: "triage", steps: ["Read trace"], tests: ["has name"] }) + }); + const enabled = await call(handler, config, `/api/skills/${skill.id}/enable`, { method: "POST" }); + const tested = await call(handler, config, `/api/skills/${skill.id}/test`, { method: "POST" }); + const updated = await call(handler, config, `/api/skills/${skill.id}`, { + method: "PATCH", + body: JSON.stringify({ description: "Updated skill", steps: ["Read trace", "Summarize"] }) + }); + const rolledBack = await call(handler, config, `/api/skills/${skill.id}/rollback`, { method: "POST" }); + + const job = await call(handler, config, "/api/jobs", { + method: "POST", + body: JSON.stringify({ name: "script", intervalSeconds: 60, script: "echo script-ok", deliveryTargets: ["local"], timeoutSeconds: 5 }) + }); + const run = await call(handler, config, `/api/jobs/${job.id}/run`, { method: "POST" }); + const runs = await call(handler, config, `/api/jobs/${job.id}/runs`); + const replay = await call(handler, config, `/api/job-runs/${runs[0].id}/replay`, { method: "POST" }); + const events = await call(handler, config, "/api/events"); + + expect(enabled.status).toBe("enabled"); + expect(tested.ok).toBe(true); + expect(updated.version).toBe(2); + expect(rolledBack.version).toBe(3); + expect(run.exitCode).toBe(0); + expect(runs[0].summary).toContain("script-ok"); + expect(replay.exitCode).toBe(0); + expect(events.some((event: { action: string }) => event.action === "job.run.completed")).toBe(true); + }); + + + test("PATCH /api/jobs/:id round-trips costBudget alongside other editable fields", async () => { + const config = testConfig("v1-job-cost-budget"); + const handler = createHandler(config); + + const job = await call(handler, config, "/api/jobs", { + method: "POST", + body: JSON.stringify({ name: "cost-job", intervalSeconds: 120, prompt: "noop" }) + }); + expect(job.costBudget).toBeUndefined(); + + const patched = await call(handler, config, `/api/jobs/${job.id}`, { + method: "PATCH", + body: JSON.stringify({ costBudget: 2.5, retryLimit: 4, timeoutSeconds: 45 }) + }); + expect(patched.costBudget).toBe(2.5); + expect(patched.retryLimit).toBe(4); + expect(patched.timeoutSeconds).toBe(45); + + const refetched = (await call(handler, config, "/api/jobs")).find((item: { id: string }) => item.id === job.id); + expect(refetched.costBudget).toBe(2.5); + + const cleared = await call(handler, config, `/api/jobs/${job.id}`, { + method: "PATCH", + body: JSON.stringify({ costBudget: null }) + }); + expect(cleared.costBudget).toBeUndefined(); + }); + + + test("probes and invokes configured MCP command records", async () => { + const config = testConfig("v1-mcp"); + const handler = createHandler(config); + + const server = await call(handler, config, "/api/mcp", { + method: "POST", + body: JSON.stringify({ name: "echo-mcp", command: "echo", args: ["ok"], exposedTools: ["echo.tool"] }) + }); + const health = await call(handler, config, `/api/mcp/${server.id}/health`, { method: "POST" }); + const invoked = await call(handler, config, `/api/mcp/${server.id}/invoke`, { + method: "POST", + body: JSON.stringify({ toolName: "echo.tool", input: { value: 1 } }) + }); + + expect(health.status).toBe("configured"); + expect(health.message).toContain("completed"); + expect(invoked.ok).toBe(true); + expect(invoked.stdout).toContain("ok"); + }); + + + test("exposes recorded runtime events as an SSE stream", async () => { + const config = testConfig("events-stream"); + const handler = createHandler(config); + + await call(handler, config, "/api/improvements", { + method: "POST", + body: JSON.stringify({ kind: "skill", title: "event-test", payload: { name: "event-test" } }) + }); + const response = await rawCall(handler, config, "/api/events/stream", {}, config.token); + const reader = response.body?.getReader(); + const chunk = await reader?.read(); + await reader?.cancel(); + const text = new TextDecoder().decode(chunk?.value); + + expect(response.headers.get("content-type")).toContain("text/event-stream"); + expect(text).toContain("data:"); + expect(text).toContain("event_"); + }); + + + test("SSE stream honors Last-Event-ID for reconnect dedup", async () => { + // Regression for the Round 2 reconnect storm: every reconnect was + // re-replaying the entire event log, which compounded into thousands of + // events/sec on the client when the EventSource thrashed. With dedup, a + // reconnect that includes the most-recent id should yield zero historical + // events on first read. + const config = testConfig("events-stream-dedup"); + const handler = createHandler(config); + + await call(handler, config, "/api/improvements", { + method: "POST", + body: JSON.stringify({ kind: "skill", title: "first", payload: { name: "first" } }) + }); + // Read the full event log once to discover the most-recent id. + const events = await call(handler, config, "/api/events"); + expect(events.length).toBeGreaterThan(0); + const lastEventId = events[events.length - 1].id; + + const response = await rawCall( + handler, + config, + "/api/events/stream", + { headers: { "last-event-id": lastEventId } }, + config.token + ); + // First read should yield no historical events (everything up to and + // including lastEventId is suppressed). The TextDecoder.decode of an empty + // chunk is "". + const reader = response.body?.getReader(); + // Race the read against a short timeout; the heartbeat doesn't fire for + // 1s, so an immediate read should observe an empty buffer. The timeout + // value only needs to lose to a real event (there are none queued) and + // win against the 1s heartbeat — 30ms is as conclusive as 200ms here and + // doesn't burn the wall when the suite runs the whole describe block. + const winner = await Promise.race([ + reader?.read(), + new Promise((resolve) => setTimeout(() => resolve({ value: undefined, done: false }), 30)) + ]) as { value?: Uint8Array; done?: boolean }; + await reader?.cancel(); + const text = winner?.value ? new TextDecoder().decode(winner.value) : ""; + expect(text).toBe(""); + }); + + + test("SSE Last-Event-ID older than retained buffer still delivers retained events", async () => { + // Regression for R3-G1: when the client's Last-Event-ID has rolled out of + // the 1000-event ring buffer (long disconnect or burst), we must NOT + // silently pre-seed every retained event into `seen` — that would deliver + // nothing on reconnect and the client would never recover. Instead, treat + // the unknown id as "best effort" and ship the entire retained window. + const config = testConfig("events-stream-rollover"); + const handler = createHandler(config); + + // Generate more events than the ring buffer holds (1000), so a fabricated + // earlier id is guaranteed not to be retained. + await mutateState(config.instance, (state) => { + for (let i = 0; i < 1100; i += 1) { + appendEvent( + state, + { + kind: "runtime", + action: "noop", + target: `target-${i}`, + risk: "low", + summary: `event ${i}` + }, + { system: true } + ); + } + }); + + // Construct a stale Last-Event-ID that mimics the ID format but isn't in + // the buffer. (The buffer holds the last 1000; this id is intentionally + // synthetic and won't match any retained event.) + const staleId = "event_rolled_out_of_buffer"; + + const response = await rawCall( + handler, + config, + "/api/events/stream", + { headers: { "last-event-id": staleId } }, + config.token + ); + const reader = response.body?.getReader(); + const winner = (await Promise.race([ + reader?.read(), + new Promise((resolve) => setTimeout(() => resolve({ value: undefined, done: false }), 200)) + ])) as { value?: Uint8Array; done?: boolean }; + await reader?.cancel(); + const text = winner?.value ? new TextDecoder().decode(winner.value) : ""; + + // Should have received the retained window, not silence. + expect(text).toContain("data:"); + expect(text).toContain("event_"); + }); + + + test("supports local chat sessions backed by task execution and retry contracts", async () => { + const config = testConfig("chat"); + const handler = createHandler(config); + + const session = await call(handler, config, "/api/chat", { + method: "POST", + body: JSON.stringify({ title: "Hermes-style chat" }) + }); + const submitted = await call(handler, config, `/api/chat/${session.id}/messages`, { + method: "POST", + body: JSON.stringify({ content: "remember chat history works" }) + }); + await waitForTask(handler, config, submitted.taskId); + const assistant = await call(handler, config, `/api/chat/${session.id}/tasks/${submitted.taskId}/sync`, { method: "POST" }); + const retry = await call(handler, config, `/api/tasks/${submitted.taskId}/retry`, { method: "POST" }); + const detail = await call(handler, config, `/api/chat/${session.id}`); + + expect(assistant.role).toBe("assistant"); + expect(retry.input).toContain("remember chat history works"); + expect(detail.messages).toHaveLength(2); + expect(detail.taskIds).toContain(submitted.taskId); + }); + + + test("chat message POST accepts an optional client surface field", async () => { + const config = testConfig("chat-client-surface"); + const handler = createHandler(config); + + const session = await call(handler, config, "/api/chat", { + method: "POST", + body: JSON.stringify({ title: "surface chat" }) + }); + // A valid `client` value lands on the spawned task; an unrecognized one + // resolves to unknown without rejecting the message (older clients must + // keep working). See ADR client-surface-context.md. + const tagged = await call(handler, config, `/api/chat/${session.id}/messages`, { + method: "POST", + body: JSON.stringify({ content: "hello from my phone", client: "mobile" }) + }); + const untagged = await call(handler, config, `/api/chat/${session.id}/messages`, { + method: "POST", + body: JSON.stringify({ content: "hello from somewhere", client: "fridge" }) + }); + const tasks = readState(config.instance).tasks; + expect(tasks.find((t) => t.id === tagged.taskId)?.clientSurface).toBe("mobile"); + expect(tasks.find((t) => t.id === untagged.taskId)?.clientSurface).toBeUndefined(); + }); + + + test("queues a chat message posted during an in-flight turn and DELETE drops it", async () => { + // While a session has a non-terminal chat task, a new POST enqueues onto + // the session instead of starting a concurrent task; the queued item is + // removable via DELETE /api/chat/:id/pending/:pendingId. See ADR + // chat-message-queue.md. + const config = testConfig("chat-queue-http"); + const handler = createHandler(config); + + const session = await call(handler, config, "/api/chat", { + method: "POST", + body: JSON.stringify({ title: "queue chat" }) + }); + // Seed a non-terminal task on the session so the next submit reads as busy + // (without depending on a still-running agent loop). + await mutateState(config.instance, (state) => { + const at = new Date().toISOString(); + state.tasks.push({ + id: "task_busy", + title: "busy", + input: "busy", + status: "running", + instance: state.instance, + createdAt: at, + updatedAt: at, + tracePath: "", + auditIds: [], + approvalIds: [], + skillIds: [], + chatSessionId: session.id + }); + const record = state.chatSessions.find((s) => s.id === session.id); + if (record) record.taskIds.push("task_busy"); + }); + + const queued = await call(handler, config, `/api/chat/${session.id}/messages`, { + method: "POST", + body: JSON.stringify({ content: "while you were out" }) + }); + expect(queued.queued).toBe(true); + expect(queued.pendingId).toBeString(); + expect(queued.taskId).toBeUndefined(); + + let pending = readState(config.instance).chatSessions.find((s) => s.id === session.id)?.pendingMessages ?? []; + expect(pending.map((p: { content: string }) => p.content)).toEqual(["while you were out"]); + + // Unknown pending id → 404. + const missing = await rawCall( + handler, + config, + `/api/chat/${session.id}/pending/pending_nope`, + { method: "DELETE" }, + config.token + ); + expect(missing.status).toBe(404); + + // Removing the real pending id clears the queue and reports removed:true. + const removed = await call(handler, config, `/api/chat/${session.id}/pending/${queued.pendingId}`, { + method: "DELETE" + }); + expect(removed.removed).toBe(true); + pending = readState(config.instance).chatSessions.find((s) => s.id === session.id)?.pendingMessages ?? []; + expect(pending).toHaveLength(0); + }); + + + test("approval-gated file patch produces a diff approval", async () => { + // Memory CRUD via `/api/memory` was removed alongside the + // state.memories consolidation. See ADR + // runtime-identity-files.md. + const config = testConfig("memory-patch"); + config.workspaceRoot = process.cwd(); + const handler = createHandler(config); + + const task = await call(handler, config, "/api/tasks", { + method: "POST", + body: JSON.stringify({ input: "patch README.md :: Gini => Gini" }) + }); + const detail = await waitForTask(handler, config, task.id); + const approval = readState(config.instance).authorizations.find((item) => item.taskId === task.id); + + expect(detail.task.status).toBe("waiting_approval"); + expect(approval?.action).toBe("file.patch"); + expect(String(approval?.payload.diff)).toContain("--- before"); + }); + + + test("routes messaging bridge input to tasks and records outbound delivery", async () => { + const config = testConfig("messaging-routing"); + const handler = createHandler(config); + + const bridge = await call(handler, config, "/api/messaging", { + method: "POST", + body: JSON.stringify({ name: "local-messages", kind: "demo", deliveryTargets: ["local"] }) + }); + const inbound = await call(handler, config, `/api/messaging/${bridge.id}/receive`, { + method: "POST", + body: JSON.stringify({ text: "remember message bridge works", target: "local" }) + }); + await waitForTask(handler, config, inbound.taskId); + const outbound = await call(handler, config, `/api/messaging/${bridge.id}/send`, { + method: "POST", + body: JSON.stringify({ text: "Task is visible in Gini", target: "local" }) + }); + const messages = await call(handler, config, `/api/messaging/${bridge.id}/messages`); + + expect(inbound.direction).toBe("inbound"); + expect(inbound.status).toBe("received"); + expect(outbound.status).toBe("sent"); + expect(messages).toHaveLength(2); + }); + + + test("rejects send to a target outside the active agent's messagingTargets filter", async () => { + const config = testConfig("messaging-agent-filter"); + const handler = createHandler(config); + + // Bridge advertises two targets so the per-call `target` selector has + // something to disagree with the agent filter about. + const bridge = await call(handler, config, "/api/messaging", { + method: "POST", + body: JSON.stringify({ name: "multi", kind: "demo", deliveryTargets: ["local", "slack"] }) + }); + const agent = await call(handler, config, "/api/agents", { + method: "POST", + body: JSON.stringify({ name: "local-only", toolsets: ["file"], messagingTargets: ["local"] }) + }); + await call(handler, config, `/api/agents/${agent.id}/use`, { method: "POST" }); + + // local target is permitted → succeeds. + const allowed = await call(handler, config, `/api/messaging/${bridge.id}/send`, { + method: "POST", + body: JSON.stringify({ text: "ok", target: "local" }) + }); + expect(allowed.status).toBe("sent"); + + // slack is outside the agent filter → server returns 400 with a typed + // error message that names both target and agent. + const rejected = await rawCall(handler, config, `/api/messaging/${bridge.id}/send`, { + method: "POST", + body: JSON.stringify({ text: "nope", target: "slack" }) + }, config.token); + expect(rejected.ok).toBe(false); + const errorBody = await rejected.json(); + expect(String(errorBody.error)).toContain("not permitted by active agent"); + expect(String(errorBody.error)).toContain("slack"); + }); + + + test("GET / returns the runtime banner when the web server is not running", async () => { + const config = testConfig("root-pointer"); + const handler = createHandler(config); + + const response = await handler(new Request(`http://127.0.0.1:${config.port}/`)); + const value = (await response.json()) as { name?: string; instance?: string; message?: string }; + + expect(response.headers.get("content-type") ?? "").toContain("application/json"); + expect(value.name).toBe("gini-runtime"); + expect(value.instance).toBe(config.instance); + expect(String(value.message)).toContain("Next.js"); + }); + + // The web-down banner is reachable over the relay on bootstrap paths (exempt + // from the session gate) during the web child's post-restart startup window. + // A non-loopback (relay) caller must get only the bare name/message — never the + // instance, port, or web-URL hint — so the banner can't leak deployment details. + + test("the web-down banner withholds deployment details from a relay caller", async () => { + const config = testConfig("banner-relay-redaction"); + const handler = createHandler(config); + + const relayHost = "sub.gini-relay.lilaclabs.ai"; + const response = await handler( + new Request(`https://${relayHost}/favicon.ico`, { headers: { host: relayHost } }) + ); + expect(response.status).toBe(200); + const value = (await response.json()) as { + name?: string; + instance?: unknown; + port?: unknown; + ui_url_hint?: unknown; + }; + expect(value.name).toBe("gini-runtime"); + expect(value.instance).toBeUndefined(); + expect(value.port).toBeUndefined(); + expect(value.ui_url_hint).toBeUndefined(); + }); + + // The web reverse proxy: non-/api traffic and the /api/runtime/* BFF + // namespace route to the Next.js server, while native /api/* stays + // bearer-gated. With no web server running in tests, the proxy falls back + // to the runtime banner — which is exactly what proves the routing: a path + // that reached the bearer gate would 401, not return the banner. + + test("/api/runtime/* bypasses the gateway bearer gate and reaches the web proxy", async () => { + const config = testConfig("bff-carveout"); + const handler = createHandler(config); + + // No Authorization header. A native /api/* path is gated → 401. + const native = await handler(new Request(`http://127.0.0.1:${config.port}/api/status`)); + expect(native.status).toBe(401); + + // The BFF namespace is carved out of the gate; with web down it falls + // through to the proxy. An API-shaped path gets a 502 (not the 401 it + // would get if it had hit the bearer gate, and not a 200 banner a caller + // could mistake for success). + const bff = await handler(new Request(`http://127.0.0.1:${config.port}/api/runtime/status`)); + expect(bff.status).toBe(502); + const body = (await bff.json()) as { error?: string }; + expect(String(body.error)).toContain("Web UI not running"); + }); + + + test("non-/api paths proxy to the web server (banner fallback when web is down)", async () => { + const config = testConfig("web-proxy-fallback"); + const handler = createHandler(config); + + const page = await handler(new Request(`http://127.0.0.1:${config.port}/some/app/route`)); + expect(page.status).toBe(200); + const body = (await page.json()) as { name?: string }; + expect(body.name).toBe("gini-runtime"); + }); + + // The gateway is the single trust front: every web-bound request is validated + // before proxying so the inner web child stays relay-agnostic. An untrusted + // (non-loopback, non-relay, non-allowlisted) Host is refused here. + + test("web-bound requests from an untrusted Host are refused before proxying", async () => { + const config = testConfig("web-proxy-gate"); + const handler = createHandler(config); + // Page/asset path → 404 (don't confirm the host exists). + const page = await handler(new Request("http://evil.example/some/app/route", { headers: { host: "evil.example" } })); + expect(page.status).toBe(404); + // /api/runtime/* BFF namespace → 403 so a programmatic caller sees the refusal. + const bff = await handler(new Request("http://evil.example/api/runtime/status", { headers: { host: "evil.example" } })); + expect(bff.status).toBe(403); + }); + + // After validating the real Host/Origin, the gateway presents the inner web + // child a loopback Host AND Origin so the child needs no relay awareness. + + test("proxyWeb rewrites Host and Origin to loopback before forwarding to the web child", async () => { + const config = testConfig("web-proxy-rewrite"); + const handler = createHandler(config); + const captured: { host: string | null; origin: string | null } = { host: null, origin: null }; + const upstream = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch(req) { + const u = new URL(req.url); + if (u.pathname === "/api/runtime/__healthz") { + return Response.json({ ok: true, service: "gini-web", instance: config.instance }); + } + captured.host = req.headers.get("host"); + captured.origin = req.headers.get("origin"); + return new Response("ok"); + } + }); + try { + mkdirSync(dirname(webPortPath(config.instance)), { recursive: true }); + writeFileSync(webPortPath(config.instance), String(upstream.port)); + clearWebTargetCache(config.instance); + // Loopback Host passes the gate; the original Origin points at the gateway + // port, so a correct rewrite makes the child see the loopback web port. + await handler(new Request(`http://127.0.0.1:${config.port}/some/app/route`, { + headers: { origin: `http://127.0.0.1:${config.port}` } + })); + expect(captured.host).toBe(`127.0.0.1:${upstream.port}`); + expect(captured.origin).toBe(`http://127.0.0.1:${upstream.port}`); + } finally { + await upstream.stop(true); + clearWebTargetCache(config.instance); + } + }); + + // The web child builds redirects from the loopback Host the gateway forwarded, + // so an absolute Location points at the loopback web port — which would send a + // remote tunnel browser to its own 127.0.0.1. The gateway rewrites it to a + // relative path so the browser resolves it against the origin it used. + + test("proxyWeb rewrites an absolute loopback redirect Location to a relative path", async () => { + const config = testConfig("web-proxy-redirect"); + const handler = createHandler(config); + const upstream = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch(req) { + const u = new URL(req.url); + if (u.pathname === "/api/runtime/__healthz") { + return Response.json({ ok: true, service: "gini-web", instance: config.instance }); + } + // Emulate the setup gate building an absolute redirect from its (gateway- + // rewritten, loopback) Host. + return new Response(null, { status: 307, headers: { location: `http://${req.headers.get("host")}/setup` } }); + } + }); + try { + mkdirSync(dirname(webPortPath(config.instance)), { recursive: true }); + writeFileSync(webPortPath(config.instance), String(upstream.port)); + clearWebTargetCache(config.instance); + const res = await handler(new Request(`http://127.0.0.1:${config.port}/chat`, { + headers: { origin: `http://127.0.0.1:${config.port}` } + })); + expect(res.status).toBe(307); + expect(res.headers.get("location")).toBe("/setup"); + } finally { + await upstream.stop(true); + clearWebTargetCache(config.instance); + } + }); + + // The gateway forwards app cookies to the inner web child untouched (it owns + // no cookies of its own under owner-token-only auth). + + test("proxyWeb passes application cookies through to the inner web child", async () => { + const config = testConfig("web-proxy-cookie-pass"); + const handler = createHandler(config); + const captured: { cookie: string | null } = { cookie: null }; + const upstream = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch(req) { + const u = new URL(req.url); + if (u.pathname === "/api/runtime/__healthz") { + return Response.json({ ok: true, service: "gini-web", instance: config.instance }); + } + captured.cookie = req.headers.get("cookie"); + return new Response("ok"); + } + }); + try { + mkdirSync(dirname(webPortPath(config.instance)), { recursive: true }); + writeFileSync(webPortPath(config.instance), String(upstream.port)); + clearWebTargetCache(config.instance); + await handler(new Request(`http://127.0.0.1:${config.port}/some/app/route`, { + headers: { + origin: `http://127.0.0.1:${config.port}`, + cookie: "theme=dark; locale=en" + } + })); + expect(captured.cookie).toBe("theme=dark; locale=en"); + } finally { + await upstream.stop(true); + clearWebTargetCache(config.instance); + } + }); + + + test("preserves full terminal stdout in a trace artifact when audit evidence is truncated", async () => { + // Master plan §6.2 requires that "outputs are truncated intelligently + // with full logs stored." The audit `evidence` field caps stdout/stderr + // at 4KB for at-a-glance inline reading, but the full text must remain + // retrievable. agent.executeApprovedAction writes a sibling artifact + // under the task's trace directory and references it from both the + // audit evidence and the trace record. + const config = testConfig("terminal-output-preservation"); + config.workspaceRoot = "/tmp"; + const handler = createHandler(config); + + // Generate >4KB of stdout to force the inline excerpt to truncate. + const command = "yes abcdefghij | head -n 500"; + const submitted = await call(handler, config, "/api/tasks", { + method: "POST", + body: JSON.stringify({ input: `shell ${command}` }) + }); + const detail = await waitForTask(handler, config, submitted.id); + expect(detail.task.status).toBe("waiting_approval"); + + const approval = readState(config.instance).authorizations.find((item) => item.taskId === submitted.id); + expect(approval).toBeDefined(); + await call(handler, config, `/api/authorizations/${approval!.id}/approve`, { method: "POST" }); + + const finalDetail = await waitForTask(handler, config, submitted.id, ["completed", "failed"]); + expect(finalDetail.task.status).toBe("completed"); + + const auditEntry = readState(config.instance).audit.find( + (event) => event.action === "terminal.exec" && event.taskId === submitted.id + ); + expect(auditEntry).toBeDefined(); + const evidence = auditEntry!.evidence as Record; + + // Inline excerpt is truncated at 4000 bytes for display, but the audit + // carries metadata that signals truncation and points at the artifact. + expect(typeof evidence.stdout).toBe("string"); + expect((evidence.stdout as string).length).toBeLessThanOrEqual(4000); + expect(evidence.stdoutTruncated).toBe(true); + expect(typeof evidence.stdoutBytes).toBe("number"); + expect(evidence.stdoutBytes as number).toBeGreaterThan(4000); + expect(typeof evidence.artifactPath).toBe("string"); + expect(typeof evidence.artifactRelPath).toBe("string"); + expect(String(evidence.artifactRelPath)).toContain(`traces/${submitted.id}/terminal-`); + + // The artifact file actually exists and contains the full output. + const artifactPath = String(evidence.artifactPath); + expect(existsSync(artifactPath)).toBe(true); + const body = readFileSync(artifactPath, "utf8"); + expect(body).toContain("--- stdout"); + expect(body).toContain("--- stderr"); + expect(body.length).toBeGreaterThan(4000); + + // The trace record for the executed command also references the artifact + // so the Tasks timeline UI can surface a "View full output" affordance. + const trace = readTrace(config.instance, submitted.id); + const toolRecord = trace.find( + (record) => record.type === "tool" && record.message === "Command executed" + ); + expect(toolRecord).toBeDefined(); + const data = toolRecord!.data as Record; + expect(data.stdoutTruncated).toBe(true); + expect(typeof data.artifactRelPath).toBe("string"); + }); + + + test("reports V1 readiness from runtime evidence", async () => { + const config = testConfig("readiness"); + const handler = createHandler(config); + + await call(handler, config, "/api/improvements", { + method: "POST", + body: JSON.stringify({ kind: "skill", title: "readiness", payload: { name: "readiness" } }) + }); + const readiness = await call(handler, config, "/api/readiness/v1"); + + expect(readiness.ok).toBe(true); + expect(readiness.checks.some((item: { id: string; status: string }) => item.id === "future_app_contracts" && item.status === "pass")).toBe(true); + }); + + + test("models chat work as conversation runs with plan steps and compatibility tasks", async () => { + const config = testConfig("conversation-runs"); + const handler = createHandler(config); + + const session = await call(handler, config, "/api/chat", { + method: "POST", + body: JSON.stringify({ title: "Planful chat" }) + }); + const submitted = await call(handler, config, `/api/chat/${session.id}/messages`, { + method: "POST", + body: JSON.stringify({ content: "remember conversation runs are the execution layer" }) + }); + await waitForTask(handler, config, submitted.taskId); + await call(handler, config, `/api/chat/${session.id}/tasks/${submitted.taskId}/sync`, { method: "POST" }); + + const run = await call(handler, config, `/api/runs/${submitted.runId}`); + const chat = await call(handler, config, `/api/chat/${session.id}`); + const runs = await call(handler, config, "/api/runs"); + + expect(run.kind).toBe("conversation_turn"); + expect(run.status).toBe("completed"); + expect(run.task.id).toBe(submitted.taskId); + expect(run.planSteps.length).toBeGreaterThanOrEqual(2); + expect(chat.runIds).toContain(submitted.runId); + expect(chat.messages.some((message: { role: string; runId?: string }) => message.role === "assistant" && message.runId === submitted.runId)).toBe(true); + expect(runs.some((item: { id: string }) => item.id === submitted.runId)).toBe(true); + }); + + + test("GET /api/providers/catalog carries the persistent per-provider auth status", async () => { + const config = testConfig("providers-catalog-auth"); + const handler = createHandler(config); + + // No failure records: every row reads ok with no reauth payload. + const clean = await call(handler, config, "/api/providers/catalog"); + expect(clean.every((row: { authStatus?: string; reauth?: unknown }) => row.authStatus === "ok" && row.reauth === undefined)).toBe(true); + + // Record a codex auth failure (what failTask persists on a + // ProviderAuthError, issue #233) and re-read the catalog. + await mutateState(config.instance, (state) => { + recordProviderAuthFailure(state, { + provider: "codex", + detail: "Provided authentication token is expired.", + taskId: "task_catalog" + }); + }); + const flagged = await call(handler, config, "/api/providers/catalog"); + const codex = flagged.find((row: { name: string }) => row.name === "codex"); + expect(codex.authStatus).toBe("needs_reauth"); + expect(codex.reauth).toMatchObject({ + detail: "Provided authentication token is expired.", + reauthKind: "docs", + reauthUrl: "https://gini.lilaclabs.ai/docs/providers/codex#re-authentication" + }); + expect(typeof codex.reauth.at).toBe("string"); + // Unaffected rows stay ok. + const openai = flagged.find((row: { name: string }) => row.name === "openai"); + expect(openai.authStatus).toBe("ok"); + expect(openai.reauth).toBeUndefined(); + }); + + + test("connector CRUD round-trips through /api/connectors without persisting plaintext secrets", async () => { + const config = testConfig("connector-crud"); + const handler = createHandler(config); + + const created = await call(handler, config, "/api/connectors", { + method: "POST", + body: JSON.stringify({ provider: "linear", name: "primary linear", scopes: ["read"], secrets: { token: "lin_secret_abc" } }) + }); + + expect(created.provider).toBe("linear"); + expect(created.secretRefs).toHaveLength(1); + expect(created.secretRefs[0].purpose).toBe("token"); + // User-source default — only the auto-detection job emits "auto". + expect(created.source).toBe("user"); + const raw = readFileSync(`${config.stateRoot}/state.json`, "utf8"); + expect(raw).not.toContain("lin_secret_abc"); + + const listed = await call(handler, config, "/api/connectors"); + expect(listed.some((item: { id: string }) => item.id === created.id)).toBe(true); + + await call(handler, config, `/api/connectors/${created.id}`, { + method: "PATCH", + body: JSON.stringify({ secrets: { token: "lin_secret_xyz" } }) + }); + const auditAfterRotate = readState(config.instance).audit; + expect(auditAfterRotate.some((event) => event.action === "connector.rotate")).toBe(true); + + await call(handler, config, `/api/connectors/${created.id}`, { method: "DELETE" }); + const after = await call(handler, config, "/api/connectors"); + expect(after.some((item: { id: string }) => item.id === created.id)).toBe(false); + expect(existsSync(`${config.stateRoot}/secrets/${created.id}.token.json`)).toBe(false); + }); + + + test("deleting an auto-source connector tombstones the record with status=disabled", async () => { + const config = testConfig("connector-tombstone"); + const handler = createHandler(config); + // Seed an auto-source connector directly on state (detection runs on + // the live gateway; this test boots a one-shot handler so we inject + // the record by hand). + await mutateState(config.instance, (state) => { + const at = new Date().toISOString(); + state.connectors.push({ + id: "id_auto_test", + instance: state.instance, + name: "auto-codex", + provider: "codex", + status: "configured", + scopes: [], + secretRefs: [], + createdAt: at, + updatedAt: at, + health: "healthy", + source: "auto" + }); + }); + const result = await call(handler, config, "/api/connectors/id_auto_test", { method: "DELETE" }); + expect(result.tombstoned).toBe(true); + const state = readState(config.instance); + const record = state.connectors.find((c) => c.id === "id_auto_test"); + expect(record?.status).toBe("disabled"); + expect(state.audit.some((event) => event.action === "connector.disable")).toBe(true); + }); + + + test("POST /api/connectors/detect runs the detection job and is idempotent", async () => { + const config = testConfig("connector-detect-endpoint"); + const handler = createHandler(config); + // Stub the only two providers with a host-shelling detect() so the run + // is deterministic and never spawns `which` / `claude auth status`. + // claude-code detects positive → the first endpoint call creates an + // auto-source connector; codex detects negative. The second call must + // then skip claude-code with reason "exists", exercising the full + // create-then-skip idempotency contract through the HTTP route. + const restoreClaude = stubProviderDetect("claude-code", { + detected: true, + suggestedName: "Claude Code", + message: "stub" + }); + const restoreClaudeProbe = stubProviderProbe("claude-code", { ok: true, message: "stub" }); + const restoreCodex = stubProviderDetect("codex", { detected: false }); + try { + const first = await call(handler, config, "/api/connectors/detect", { method: "POST" }); + expect(first).toHaveProperty("considered"); + expect(first).toHaveProperty("created"); + expect((first.created as Array<{ provider: string }>).map((c) => c.provider)).toContain("claude-code"); + // The second call should not create any new records — the detection + // logic is idempotent at the registry+state level. + const second = await call(handler, config, "/api/connectors/detect", { method: "POST" }); + const createdProviders = (second.created as Array<{ provider: string }>).map((c) => c.provider); + expect(createdProviders).toEqual([]); + expect((second.skipped as Array<{ provider: string; reason: string }>).find((s) => s.provider === "claude-code")?.reason).toBe("exists"); + } finally { + restoreClaude(); + restoreClaudeProbe(); + restoreCodex(); + } + }); + + + test("GET /api/connectors/providers returns the registry", async () => { + const config = testConfig("providers-list"); + const handler = createHandler(config); + const providers = await call(handler, config, "/api/connectors/providers"); + expect(Array.isArray(providers)).toBe(true); + const ids = providers.map((p: { id: string }) => p.id); + expect(ids).toContain("demo"); + expect(ids).toContain("linear"); + expect(ids).toContain("generic"); + expect(ids).toContain("claude-code"); + expect(ids).toContain("codex"); + // Credential templates: linear (single env binding) → api-key prefill + // with the MCP URL + server name; google-oauth-desktop (two bindings) → + // oauth2 envMap. + const linear = providers.find((p: { id: string }) => p.id === "linear"); + expect(linear.credentialTemplate).toEqual({ + type: "api-key", + name: "LINEAR_API_KEY", + mcpUrl: "https://mcp.linear.app/mcp", + mcpName: "linear" + }); + const gws = providers.find((p: { id: string }) => p.id === "google-oauth-desktop"); + expect(gws.credentialTemplate.type).toBe("oauth2"); + expect(gws.credentialTemplate.envMap).toEqual({ + client_id: "GOOGLE_WORKSPACE_CLI_CLIENT_ID", + client_secret: "GOOGLE_WORKSPACE_CLI_CLIENT_SECRET" + }); + // Presence-only providers (no secret spec) carry no template. + const demo = providers.find((p: { id: string }) => p.id === "demo"); + expect(demo.credentialTemplate).toBeUndefined(); + }); + + + test("POST /api/connectors threads a typed api-key credential through createConnector", async () => { + const config = testConfig("connector-typed-create"); + const handler = createHandler(config); + const created = await call(handler, config, "/api/connectors", { + method: "POST", + body: JSON.stringify({ + provider: "generic", + name: "MY_SERVICE_KEY", + type: "api-key", + secrets: { MY_SERVICE_KEY: "lin_secret_typed" }, + metadata: { mcp: { url: "https://mcp.example.com/mcp", headerName: "Authorization", scheme: "Bearer" } } + }) + }); + expect(created.type).toBe("api-key"); + expect(created.name).toBe("MY_SERVICE_KEY"); + expect(created.metadata.mcp.url).toBe("https://mcp.example.com/mcp"); + expect(created.secretRefs).toHaveLength(1); + expect(created.secretRefs[0].purpose).toBe("MY_SERVICE_KEY"); + const raw = readFileSync(`${config.stateRoot}/state.json`, "utf8"); + expect(raw).not.toContain("lin_secret_typed"); + }); + + + test("POST /api/setup-requests//complete creates a connector and resolves the setup request on probe success", async () => { + const config = testConfig("setup-requests-complete-happy"); + const handler = createHandler(config); + // Stage a connector.request setup-request row directly. Demo provider has + // no probe, so checkConnector falls back to presence-only => healthy + // without any network mocking. + const { createSetupRequest } = await import("./state"); + const approval = await mutateState(config.instance, (state) => + createSetupRequest(state, { + action: "connector.request", + target: "demo", + reason: "test connect", + payload: { + provider: "demo", + providerLabel: "Demo", + providerDescription: "Demo provider", + reason: "test connect", + fields: [], + toolCallId: "call_demo_1" + } + }) + ); + + const response = await call(handler, config, `/api/setup-requests/${approval.id}/complete`, { + method: "POST", + body: JSON.stringify({ secrets: {}, scopes: [] }) + }); + expect(response.ok).toBe(true); + expect(response.connector.provider).toBe("demo"); + expect(response.connector.health).toBe("healthy"); + + const state = readState(config.instance); + const resolved = state.setupRequests.find((a) => a.id === approval.id); + expect(resolved?.status).toBe("completed"); + expect(state.connectors.some((c) => c.provider === "demo" && c.health === "healthy")).toBe(true); + }); + + + test("POST /api/setup-requests//complete grants the connector and enables the skill for skill.grant_connector", async () => { + const config = testConfig("setup-complete-skill-grant"); + const handler = createHandler(config); + const { createSetupRequest, createSkill } = await import("./state"); + const skill = await mutateState(config.instance, (state) => + createSkill(state, { + name: "needs-linear", + description: "", + trigger: "", + steps: [], + requiredTools: [], + requiredPermissions: [], + status: "disabled", + source: "user", + requiredConnectors: [{ provider: "linear" }] + }) + ); + const approval = await mutateState(config.instance, (state) => + createSetupRequest(state, { + action: "skill.grant_connector", + target: "Linear", + reason: "Skill needs-linear requests access to your Linear credential.", + payload: { + skillId: skill.id, + skillName: skill.name, + credentialName: "LINEAR_API_KEY", + credentialLabel: "Linear", + toolCallId: "call_grant_1" + } + }) + ); + + const response = await call(handler, config, `/api/setup-requests/${approval.id}/complete`, { + method: "POST", + body: JSON.stringify({}) + }); + expect(response.ok).toBe(true); + + const state = readState(config.instance); + const resolved = state.setupRequests.find((a) => a.id === approval.id); + expect(resolved?.status).toBe("completed"); + const updated = state.skills.find((s) => s.id === skill.id); + expect(updated?.status).toBe("enabled"); + expect(updated?.grantedConnectors).toEqual(["LINEAR_API_KEY"]); + expect(state.audit.some((a) => a.action === "skill.connector.granted")).toBe(true); + }); + + + test("POST /api/setup-requests//complete: a templateless connector.request creates a typed api-key, grants + enables the skill, and records no secret", async () => { + const config = testConfig("setup-complete-templateless"); + const handler = createHandler(config); + const { createSetupRequest, createSkill } = await import("./state"); + const skill = await mutateState(config.instance, (state) => + createSkill(state, { + name: "needs-some-service", + description: "", + trigger: "", + steps: [], + requiredTools: [], + requiredPermissions: [], + status: "disabled", + source: "user", + requiredCredentials: ["SOME_SERVICE_API_KEY"] + }) + ); + // Templateless payload: no `provider`, carries credentialType/Name/Label + + // skillId (exactly what requestConnectorTool mints). + const approval = await mutateState(config.instance, (state) => + createSetupRequest(state, { + action: "connector.request", + target: "SOME_SERVICE_API_KEY", + reason: "Enter your Some Service API key", + payload: { + credentialName: "SOME_SERVICE_API_KEY", + credentialType: "api-key", + credentialLabel: "Some Service", + skillId: skill.id, + reason: "Enter your Some Service API key", + toolCallId: "call_tl_complete" + } + }) + ); + + const secretValue = "sk-some-service-super-secret"; + const response = await call(handler, config, `/api/setup-requests/${approval.id}/complete`, { + method: "POST", + body: JSON.stringify({ secrets: { SOME_SERVICE_API_KEY: secretValue } }) + }); + expect(response.ok).toBe(true); + expect(response.connector.health).toBe("healthy"); + + const state = readState(config.instance); + // A TYPED api-key record landed under the requested name. + const connector = state.connectors.find((c) => c.name === "SOME_SERVICE_API_KEY"); + expect(connector).toBeDefined(); + expect(connector?.type).toBe("api-key"); + // The requesting skill was granted the credential and enabled (its only + // required credential is now granted). + const updated = state.skills.find((s) => s.id === skill.id); + expect(updated?.grantedConnectors).toEqual(["SOME_SERVICE_API_KEY"]); + expect(updated?.status).toBe("enabled"); + // The setup request resolved. + expect(state.setupRequests.find((a) => a.id === approval.id)?.status).toBe("completed"); + // The audit row for connector.request carries the credential name but NO + // secret value — the secret stays server-side. + const requestAudit = state.audit.find((a) => a.action === "connector.request"); + expect(requestAudit).toBeDefined(); + expect((requestAudit?.evidence as Record)?.credentialName).toBe("SOME_SERVICE_API_KEY"); + expect(JSON.stringify(state.audit)).not.toContain(secretValue); + }); + + + test("POST /api/setup-requests//complete: a known-provider connector.request with skillId grants + enables the skill", async () => { + const config = testConfig("setup-complete-known-grant"); + const handler = createHandler(config); + const { createSetupRequest, createSkill } = await import("./state"); + // demo provider has no probe (presence-only healthy) and no credential + // template, so its record stays untyped — to exercise the grant we point + // the skill's requiredCredentials at the connector name the demo create + // lands ("Demo") and assert the grant is recorded. firstUngrantedCredential + // only blocks on TYPED credentials, so an untyped demo connector enables. + const skill = await mutateState(config.instance, (state) => + createSkill(state, { + name: "needs-demo", + description: "", + trigger: "", + steps: [], + requiredTools: [], + requiredPermissions: [], + status: "disabled", + source: "user", + requiredCredentials: ["Demo"] + }) + ); + const approval = await mutateState(config.instance, (state) => + createSetupRequest(state, { + action: "connector.request", + target: "demo", + reason: "connect demo", + payload: { + provider: "demo", + providerLabel: "Demo", + providerDescription: "Demo provider", + fields: [], + skillId: skill.id, + reason: "connect demo", + toolCallId: "call_known_complete" + } + }) + ); + + const response = await call(handler, config, `/api/setup-requests/${approval.id}/complete`, { + method: "POST", + body: JSON.stringify({ secrets: {}, scopes: [] }) + }); + expect(response.ok).toBe(true); + expect(response.connector.provider).toBe("demo"); + + const state = readState(config.instance); + const updated = state.skills.find((s) => s.id === skill.id); + expect(updated?.grantedConnectors).toEqual(["Demo"]); + expect(updated?.status).toBe("enabled"); + }); + + + test("POST /api/setup-requests//complete: a known-provider (linear) connector.request creates a TYPED LINEAR_API_KEY, grants + enables the requesting skill", async () => { + // Real template-path regression: a connector.request for {provider:"linear", + // skillId} must land a TYPED LINEAR_API_KEY record (stamped from the + // module's credentialTemplate), and because the requesting skill declares + // LINEAR_API_KEY, completing the card grants it and enables the skill — no + // second consent card. The demo provider can't prove this (it's untyped / + // presence-only); linear has both a credentialTemplate and a live probe, so + // we stub a healthy viewer query. + const config = testConfig("setup-complete-linear-typed-grant"); + const handler = createHandler(config); + const { createSetupRequest, createSkill } = await import("./state"); + const skill = await mutateState(config.instance, (state) => + createSkill(state, { + name: "needs-linear-typed", + description: "", + trigger: "", + steps: [], + requiredTools: [], + requiredPermissions: [], + status: "disabled", + source: "user", + requiredCredentials: ["LINEAR_API_KEY"] + }) + ); + const approval = await mutateState(config.instance, (state) => + createSetupRequest(state, { + action: "connector.request", + target: "linear", + reason: "connect linear", + payload: { + provider: "linear", + providerLabel: "Linear", + providerDescription: "Linear", + fields: [], + skillId: skill.id, + reason: "connect linear", + toolCallId: "call_linear_typed" + } + }) + ); + + // Stub the Linear GraphQL probe with a healthy viewer so checkConnector + // flips the typed record to healthy without a live network call. + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => + new Response(JSON.stringify({ data: { viewer: { id: "u1", name: "Tester", email: "t@e.co" } } }), { + status: 200, + headers: { "content-type": "application/json" } + })) as unknown as typeof fetch; + let response: { ok: boolean; connector?: { provider?: string } }; + try { + response = await call(handler, config, `/api/setup-requests/${approval.id}/complete`, { + method: "POST", + body: JSON.stringify({ secrets: { token: "lin_api_realish" } }) + }); + } finally { + globalThis.fetch = originalFetch; + } + expect(response.ok).toBe(true); + + const state = readState(config.instance); + // The Linear template stamps a TYPED api-key record named LINEAR_API_KEY. + const connector = state.connectors.find((c) => c.provider === "linear"); + expect(connector?.type).toBe("api-key"); + expect(connector?.name).toBe("LINEAR_API_KEY"); + expect(connector?.health).toBe("healthy"); + // The requesting skill (declares LINEAR_API_KEY) was granted + enabled. + const updated = state.skills.find((s) => s.id === skill.id); + expect(updated?.grantedConnectors).toEqual(["LINEAR_API_KEY"]); + expect(updated?.status).toBe("enabled"); + // No secret value leaked into the audit log. + expect(JSON.stringify(state.audit)).not.toContain("lin_api_realish"); + }); + + + test("POST /api/setup-requests//complete: skillId for a skill that does NOT declare the credential creates the connector but does NOT grant or enable", async () => { + // Auto-grant trust guard: the model supplies skillId, so /complete must + // verify the named skill actually declares connector.name before granting. + // A skill that does not declare the credential gets the connector created + // (so the credential exists) but is neither granted the credential nor + // enabled — "a skill only gets credentials it declared + the user granted". + const config = testConfig("setup-complete-undeclared-no-grant"); + const handler = createHandler(config); + const { createSetupRequest, createSkill } = await import("./state"); + const skill = await mutateState(config.instance, (state) => + createSkill(state, { + name: "wants-other-cred", + description: "", + trigger: "", + steps: [], + requiredTools: [], + requiredPermissions: [], + status: "disabled", + source: "user", + // Declares a DIFFERENT credential than the one being requested. + requiredCredentials: ["OTHER_API_KEY"] + }) + ); + const approval = await mutateState(config.instance, (state) => + createSetupRequest(state, { + action: "connector.request", + target: "SOME_SERVICE_API_KEY", + reason: "Enter your Some Service API key", + payload: { + credentialName: "SOME_SERVICE_API_KEY", + credentialType: "api-key", + credentialLabel: "Some Service", + skillId: skill.id, + reason: "Enter your Some Service API key", + toolCallId: "call_undeclared" + } + }) + ); + + const response = await call(handler, config, `/api/setup-requests/${approval.id}/complete`, { + method: "POST", + body: JSON.stringify({ secrets: { SOME_SERVICE_API_KEY: "sk-secret" } }) + }); + expect(response.ok).toBe(true); + + const state = readState(config.instance); + // The connector was created (the credential now exists). + expect(state.connectors.some((c) => c.name === "SOME_SERVICE_API_KEY")).toBe(true); + // But the skill — which never declared SOME_SERVICE_API_KEY — was NOT + // granted it and stays disabled. + const updated = state.skills.find((s) => s.id === skill.id); + expect(updated?.grantedConnectors ?? []).not.toContain("SOME_SERVICE_API_KEY"); + expect(updated?.status).toBe("disabled"); + }); + + + test("POST /api/setup-requests//complete: a multi-credential skill grants the requested credential but stays DISABLED while another required credential has no connector", async () => { + // Enable-when-fully-satisfied: a skill that requires two credentials and + // only just got the first must NOT be enabled while the second has no + // connector row at all. firstUngrantedCredential alone misses this (it + // skips required creds with no connector); isSkillActive catches it. + const config = testConfig("setup-complete-partial-multi-disabled"); + const handler = createHandler(config); + const { createSetupRequest, createSkill } = await import("./state"); + const skill = await mutateState(config.instance, (state) => + createSkill(state, { + name: "needs-two-creds", + description: "", + trigger: "", + steps: [], + requiredTools: [], + requiredPermissions: [], + status: "disabled", + source: "user", + // SECOND_API_KEY has no connector yet — it'll be requested separately. + requiredCredentials: ["FIRST_API_KEY", "SECOND_API_KEY"] + }) + ); + const approval = await mutateState(config.instance, (state) => + createSetupRequest(state, { + action: "connector.request", + target: "FIRST_API_KEY", + reason: "Enter your first API key", + payload: { + credentialName: "FIRST_API_KEY", + credentialType: "api-key", + credentialLabel: "First", + skillId: skill.id, + reason: "Enter your first API key", + toolCallId: "call_first" + } + }) + ); + + const response = await call(handler, config, `/api/setup-requests/${approval.id}/complete`, { + method: "POST", + body: JSON.stringify({ secrets: { FIRST_API_KEY: "sk-first" } }) + }); + expect(response.ok).toBe(true); + + const state = readState(config.instance); + const updated = state.skills.find((s) => s.id === skill.id); + // The requested credential was granted (the human entered it for this skill). + expect(updated?.grantedConnectors).toEqual(["FIRST_API_KEY"]); + // But the skill stays disabled — SECOND_API_KEY still has no connector. + expect(updated?.status).toBe("disabled"); + }); + + + test("POST /api/setup-requests//complete on a multi-provider skill grants one provider, stays disabled, and mints the next grant card", async () => { + const config = testConfig("setup-complete-skill-grant-multi"); + const handler = createHandler(config); + const { createSetupRequest, createSkill } = await import("./state"); + await seedTypedCredential(config, "LINEAR_API_KEY", "linear"); + await seedTypedCredential(config, "GENERIC_KEY", "generic"); + const skill = await mutateState(config.instance, (state) => + createSkill(state, { + name: "needs-two", + description: "", + trigger: "", + steps: [], + requiredTools: [], + requiredPermissions: [], + status: "disabled", + source: "user", + requiredCredentials: ["LINEAR_API_KEY", "GENERIC_KEY"] + }) + ); + const approval = await mutateState(config.instance, (state) => + createSetupRequest(state, { + action: "skill.grant_connector", + target: "Linear", + reason: "Skill needs-two requests access to your Linear credential.", + payload: { + skillId: skill.id, + skillName: skill.name, + credentialName: "LINEAR_API_KEY", + credentialLabel: "Linear", + toolCallId: "call_grant_multi" + } + }) + ); + + const response = await call(handler, config, `/api/setup-requests/${approval.id}/complete`, { + method: "POST", + body: JSON.stringify({}) + }); + expect(response.ok).toBe(true); + + const state = readState(config.instance); + const resolved = state.setupRequests.find((a) => a.id === approval.id); + expect(resolved?.status).toBe("completed"); + const updated = state.skills.find((s) => s.id === skill.id); + // Only the first credential is granted; the skill stays disabled until the + // remaining credential is granted too. + expect(updated?.grantedConnectors).toEqual(["LINEAR_API_KEY"]); + expect(updated?.status).toBe("disabled"); + // A new pending grant card was minted for the remaining credential. + const next = state.setupRequests.find( + (s) => s.status === "pending" && s.action === "skill.grant_connector" && s.payload.credentialName === "GENERIC_KEY" + ); + expect(next).toBeDefined(); + expect(next?.payload.skillId).toBe(skill.id); + }); + + + test("POST /api/setup-requests//complete: a double-complete of one grant request resolves once and mints exactly one next card", async () => { + const config = testConfig("setup-complete-skill-grant-double"); + const handler = createHandler(config); + const { createSetupRequest, createSkill } = await import("./state"); + await seedTypedCredential(config, "LINEAR_API_KEY", "linear"); + await seedTypedCredential(config, "GENERIC_KEY", "generic"); + const skill = await mutateState(config.instance, (state) => + createSkill(state, { + name: "needs-two-double", + description: "", + trigger: "", + steps: [], + requiredTools: [], + requiredPermissions: [], + status: "disabled", + source: "user", + requiredCredentials: ["LINEAR_API_KEY", "GENERIC_KEY"] + }) + ); + const approval = await mutateState(config.instance, (state) => + createSetupRequest(state, { + action: "skill.grant_connector", + target: "Linear", + reason: "Skill needs-two-double requests access to your Linear credential.", + payload: { + skillId: skill.id, + skillName: skill.name, + credentialName: "LINEAR_API_KEY", + credentialLabel: "Linear", + toolCallId: "call_grant_double" + } + }) + ); + + // Fire two completes of the SAME request. The mutateState lock serializes + // the atomic claim, so exactly one wins; the loser hits the already- + // resolved guard and mints nothing. No extra pending grant row. + const [a, b] = await Promise.all([ + rawCall(handler, config, `/api/setup-requests/${approval.id}/complete`, { + method: "POST", + body: JSON.stringify({}) + }, config.token), + rawCall(handler, config, `/api/setup-requests/${approval.id}/complete`, { + method: "POST", + body: JSON.stringify({}) + }, config.token) + ]); + const oks = [a.ok, b.ok]; + expect(oks.filter(Boolean).length).toBe(1); + + const state = readState(config.instance); + const resolved = state.setupRequests.find((s) => s.id === approval.id); + expect(resolved?.status).toBe("completed"); + // Exactly one next card for the remaining credential — no duplicate from + // the losing racer. + const next = state.setupRequests.filter( + (s) => s.status === "pending" && s.action === "skill.grant_connector" && s.payload.credentialName === "GENERIC_KEY" + ); + expect(next.length).toBe(1); + // No stray pending grant rows beyond that single next card. + const pendingGrants = state.setupRequests.filter( + (s) => s.status === "pending" && s.action === "skill.grant_connector" + ); + expect(pendingGrants.length).toBe(1); + }); + + + test("POST /api/setup-requests//complete: a double-complete of the FINAL grant request enables once and writes exactly one skill.enabled audit and one grant", async () => { + const config = testConfig("setup-complete-skill-grant-final-double"); + const handler = createHandler(config); + const { createSetupRequest, createSkill, createTask, upsertTask } = await import("./state"); + // A single-provider skill so completing the one grant card is the FINAL + // step (no next card): the winner records the grant, enables the skill, + // and resumes the task. A losing racer must produce ZERO side effects — + // no duplicate grant, no duplicate skill.enabled audit, no second resume. + const skill = await mutateState(config.instance, (state) => + createSkill(state, { + name: "needs-linear-final-double", + description: "", + trigger: "", + steps: [], + requiredTools: [], + requiredPermissions: [], + status: "disabled", + source: "user", + requiredConnectors: [{ provider: "linear" }] + }) + ); + // Seed a terminal task so the resume branch is exercised but bails fast + // (resumeChatTask no-ops on a completed task) instead of polling for a + // waiting_approval flip that never comes. + const taskId = await mutateState(config.instance, (state) => { + const task = createTask(state.instance, "grant final double"); + task.status = "completed"; + upsertTask(state, task); + return task.id; + }); + const approval = await mutateState(config.instance, (state) => + createSetupRequest(state, { + taskId, + action: "skill.grant_connector", + target: "Linear", + reason: "Skill needs-linear-final-double requests access to your Linear credential.", + payload: { + skillId: skill.id, + skillName: skill.name, + credentialName: "LINEAR_API_KEY", + credentialLabel: "Linear", + toolCallId: "call_grant_final_double" + } + }) + ); + + const [a, b] = await Promise.all([ + rawCall(handler, config, `/api/setup-requests/${approval.id}/complete`, { + method: "POST", + body: JSON.stringify({}) + }, config.token), + rawCall(handler, config, `/api/setup-requests/${approval.id}/complete`, { + method: "POST", + body: JSON.stringify({}) + }, config.token) + ]); + expect([a.ok, b.ok].filter(Boolean).length).toBe(1); + + const state = readState(config.instance); + const resolved = state.setupRequests.find((s) => s.id === approval.id); + expect(resolved?.status).toBe("completed"); + const updated = state.skills.find((s) => s.id === skill.id); + expect(updated?.status).toBe("enabled"); + // Exactly one grant — the loser double-granted nothing. + expect(updated?.grantedConnectors).toEqual(["LINEAR_API_KEY"]); + // Exactly ONE skill.enabled audit row (the loser produced no second one). + expect(state.audit.filter((a) => a.action === "skill.enabled").length).toBe(1); + // Exactly ONE grant audit row. + expect(state.audit.filter((a) => a.action === "skill.connector.granted").length).toBe(1); + // No extra pending grant rows from the losing racer. + expect( + state.setupRequests.filter((s) => s.status === "pending" && s.action === "skill.grant_connector").length + ).toBe(0); + }); + +}); + +async function call(handler: ReturnType, config: RuntimeConfig, path: string, init: RequestInit = {}) { + return callWithToken(handler, config, config.token, path, init); +} + +async function callWithToken(handler: ReturnType, config: RuntimeConfig, token: string, path: string, init: RequestInit = {}) { + const response = await rawCall(handler, config, path, init, token); + const value = await response.json(); + if (!response.ok) throw new Error(value.error ?? `HTTP ${response.status}`); + return value; +} + +async function rawCall(handler: ReturnType, config: RuntimeConfig, path: string, init: RequestInit = {}, token?: string) { + const response = await handler(new Request(`http://127.0.0.1:${config.port}${path}`, { + ...init, + headers: { "content-type": "application/json", ...(token ? { authorization: `Bearer ${token}` } : {}), ...(init.headers ?? {}) } + })); + return response; +} + +// Opens a chat SSE stream, pumps frames until the initial `chat_session` +// frame lands (which only happens inside the stream's start() — i.e. AFTER +// presence registration), runs the caller's `whileOpen` assertions, then +// ALWAYS cancels the reader in a finally so a thrown assertion can't leak +// the stream (its keepalive interval and presence entry would otherwise +// survive the test). Returns after the reader is cancelled. +async function withChatStream( + handler: ReturnType, + config: RuntimeConfig, + sessionId: string, + init: RequestInit, + whileOpen: () => void +): Promise { + const response = await rawCall(handler, config, `/api/chat/${sessionId}/stream`, init, config.token); + expect(response.status).toBe(200); + const reader = response.body?.getReader(); + expect(reader).toBeDefined(); + if (!reader) return; + try { + const decoder = new TextDecoder(); + let buffer = ""; + const deadline = Date.now() + 500; + while (Date.now() < deadline) { + const { done, value } = await Promise.race([ + reader.read(), + new Promise<{ done: boolean; value: undefined }>((resolve) => + setTimeout(() => resolve({ done: false, value: undefined }), 50) + ) + ]); + if (done) break; + if (value) buffer += decoder.decode(value); + if (buffer.includes("event: chat_session")) break; + } + expect(buffer).toContain("event: chat_session"); + whileOpen(); + } finally { + await reader.cancel(); + } + // cancel() runs the stream's cleanup synchronously in-process; give the + // microtask queue one turn to settle before the caller asserts the + // post-close registry state. + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +function testConfig(instance: string): RuntimeConfig { + const root = `/tmp/gini-http-tests-${import.meta.file}`; + process.env.GINI_STATE_ROOT = root; + process.env.GINI_LOG_ROOT = `${root}-logs`; + // Drop the cached SQLite handle for this instance before nuking the + // directory. Without this, a prior test that opened the per-instance + // memory DB leaves an open `bun:sqlite` handle pointing at the now- + // unlinked file. The next call to getMemoryDb returns that cached + // handle (the cache key is the instance name) and any write fails + // because the inode is gone. removeMemoryDb closes the cached handle + // AND unlinks the file + WAL/SHM siblings in one shot. + removeMemoryDb(instance); + // resumeChatTask polls for the loop's flip to waiting_approval before + // staging a tool result. In-process the flip lands within a couple of + // mutateState boundaries, and several fill_secret / approval tests seed a + // task that never reaches waiting_approval at all — so the production + // 1000ms/100ms budget is pure dead wall here (the fill_secret leak test + // measured 1079.00ms in isolation, nearly all of it this poll). Shrink the + // budget via the server-side env knob the production code reads (default + // preserved at 1000/100); the race still resolves well within 40ms over 5ms + // ticks in-process. + process.env.GINI_RESUME_WAIT_BUDGET_MS = "40"; + process.env.GINI_RESUME_WAIT_TICK_MS = "5"; + rmSync(`${root}/instances/${instance}`, { recursive: true, force: true }); + return { + instance, + port: 7337, + token: "test-token", + provider: { name: "echo", model: "gini-echo-v0" }, + workspaceRoot: "/tmp", + stateRoot: `${root}/instances/${instance}`, + logRoot: `${root}-logs/${instance}`, + // These tests predate the approval-mode flip and rely on the + // gated path. Force "strict" to keep them honest; new defaults + // are exercised in approval-mode.test.ts. + approvalMode: "strict" + }; +} + +// Write a log file under the test config's instance log dir so the /api/logs +// route reads it. testConfig nukes the instance state dir and points +// GINI_LOG_ROOT at a sibling tree, so this lands in a clean per-instance dir. +function seedLogFile(config: RuntimeConfig, filename: string, body: string): void { + const dir = logDir(config.instance); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, filename), body); +} + +// Seed a typed api-key credential so the per-(skill, credential) consent gate +// (firstUngrantedCredential) treats it as carrying a secret that needs consent. +async function seedTypedCredential(config: RuntimeConfig, name: string, provider: string) { + const at = new Date().toISOString(); + await mutateState(config.instance, (state) => { + state.connectors.push({ + id: `id_${name}`, + instance: state.instance, + name, + provider, + type: "api-key", + status: "configured", + scopes: [], + secretRefs: [{ purpose: name, path: `/tmp/${name}.json` }], + createdAt: at, + updatedAt: at, + health: "healthy", + source: "user" + }); + }); +} + +async function waitForTask( + handler: ReturnType, + config: RuntimeConfig, + taskId: string, + // The approve endpoint returns at decision durability while the side + // effect + resume run detached, so a post-approve wait must exclude + // the still-parked waiting_approval status or it returns the + // pre-approve park immediately. + statuses: string[] = ["completed", "failed", "waiting_approval"] +) { + // Phase 5 added auto-recall + auto-retain to runTask. The retain side is + // fire-and-forget so it can't block, but the inline recall + a few extra + // mutateState audits push runTask completion past the original 500ms + // budget on slower hosts. A 200-iteration / 10ms loop = 2s ceiling is + // still well under any reasonable test timeout. + for (let attempt = 0; attempt < 200; attempt += 1) { + const detail = await call(handler, config, `/api/tasks/${taskId}`); + if (statuses.includes(detail.task.status)) return detail; + await Bun.sleep(10); + } + throw new Error(`Task did not settle: ${taskId}`); +} diff --git a/packages/runtime/src/http.part2.test.ts b/packages/runtime/src/http.part2.test.ts new file mode 100644 index 00000000..beb1a4e4 --- /dev/null +++ b/packages/runtime/src/http.part2.test.ts @@ -0,0 +1,2660 @@ +import { describe, expect, test } from "bun:test"; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import "./hooks/builtins"; // the email-watch routes provision a backing job, which validates isKnownHook("skill-script") +import { createHandler, resolveInlineUpload } from "./http"; +import { logDir, uploadsDir, webPortPath } from "./paths"; +import { clearWebTargetCache } from "./web-target"; +import { dirname, join } from "node:path"; +import { addAudit, appendEvent, insertChatBlock, isPlausibleMime, mutateState, readState, readTrace, recordProviderAuthFailure, sanitizeFilename, storeUpload, uploadStat } from "./state"; +import { getOrCreateAgentChat } from "./execution/chat"; +import { createScheduledJob } from "./jobs"; +import { removeMemoryDb } from "./state/memory-db"; +import { listProviders } from "./integrations/connectors/registry"; +import { resetGoogleLoginWebState } from "./integrations/connectors/google-login-web"; +import { awaitTunnelSettled, setTunnelDeps, type TunnelChild } from "./integrations/tunnel"; +import type { RuntimeConfig } from "./types"; +import type { LoginHandle, RelayDefaults, Session, Store, TunnelOptions } from "gini-relay"; + +// Stub a provider's host-environment `detect()` so the connector-detection +// endpoint test stays deterministic AND fast regardless of what's installed +// on the developer's PATH. The production `detect()` for claude-code / codex +// shells out via spawnSync (`which`, `claude auth status`), which on a machine +// with those CLIs installed dominates this test's wall time (the unstubbed +// detect endpoint test measured 1.524641s). Mirrors the same in-place +// swap-and-restore helper used by src/jobs/connector-detection.test.ts. The +// registry is a process-wide singleton, so the returned restore fn MUST run in +// a finally to avoid leaking the stub into sibling tests. +function stubProviderDetect( + providerId: string, + value: { detected: boolean; suggestedName?: string; message?: string } +): () => void { + const provider = listProviders().find((p) => p.id === providerId); + if (!provider) throw new Error(`Provider not registered: ${providerId}`); + const previous = provider.detect; + provider.detect = async () => value; + return () => { + provider.detect = previous; + }; +} + +// Companion to stubProviderDetect. After connector auto-detection creates a +// record for a provider that exposes a `probe()`, runConnectorDetection runs +// an initial checkConnector → provider.probe, which for claude-code shells out +// to `claude auth status` again. Stub the probe so the detection endpoint +// test never touches a real subprocess. Same swap-and-restore discipline. +function stubProviderProbe(providerId: string, value: { ok: boolean; message: string }): () => void { + const provider = listProviders().find((p) => p.id === providerId); + if (!provider) throw new Error(`Provider not registered: ${providerId}`); + const previous = provider.probe; + provider.probe = async () => value; + return () => { + provider.probe = previous; + }; +} + +describe("runtime api", () => { + test("POST /api/setup-requests//complete vs cancel on the same grant card: Cancel prevents the grant+enable", async () => { + const config = testConfig("setup-complete-skill-grant-cancel-race"); + const handler = createHandler(config); + const { createSetupRequest, createSkill, createTask, upsertTask } = await import("./state"); + const skill = await mutateState(config.instance, (state) => + createSkill(state, { + name: "needs-linear-cancel-race", + description: "", + trigger: "", + steps: [], + requiredTools: [], + requiredPermissions: [], + status: "disabled", + source: "user", + requiredConnectors: [{ provider: "linear" }] + }) + ); + const taskId = await mutateState(config.instance, (state) => { + const task = createTask(state.instance, "grant cancel race"); + task.status = "completed"; + upsertTask(state, task); + return task.id; + }); + const approval = await mutateState(config.instance, (state) => + createSetupRequest(state, { + taskId, + action: "skill.grant_connector", + target: "Linear", + reason: "Skill needs-linear-cancel-race requests access to your Linear credential.", + payload: { + skillId: skill.id, + skillName: skill.name, + credentialName: "LINEAR_API_KEY", + credentialLabel: "Linear", + toolCallId: "call_grant_cancel_race" + } + }) + ); + + // Race a complete against a cancel on the SAME card. The per-instance + // mutateState lock serializes the two pending→terminal transitions, so + // exactly one wins. The consent gate must be honored: whichever side wins, + // a grant+enable happens ONLY if complete won — a winning cancel leaves the + // skill disabled and ungranted. + const [completeRes, cancelRes] = await Promise.all([ + rawCall(handler, config, `/api/setup-requests/${approval.id}/complete`, { + method: "POST", + body: JSON.stringify({}) + }, config.token), + rawCall(handler, config, `/api/setup-requests/${approval.id}/cancel`, { + method: "POST", + body: JSON.stringify({}) + }, config.token) + ]); + + const state = readState(config.instance); + const resolved = state.setupRequests.find((s) => s.id === approval.id); + const updated = state.skills.find((s) => s.id === skill.id); + const granted = state.audit.some((a) => a.action === "skill.connector.granted"); + const enabled = state.audit.some((a) => a.action === "skill.enabled"); + + if (resolved?.status === "cancelled") { + // Cancel won — the consent gate is honored: NO grant, NO enable. + expect(completeRes.ok).toBe(false); + expect(updated?.status).toBe("disabled"); + expect(updated?.grantedConnectors ?? []).toEqual([]); + expect(granted).toBe(false); + expect(enabled).toBe(false); + } else { + // Complete won — cancel is a no-op against the now-completed row, and + // the skill is granted+enabled. + expect(resolved?.status).toBe("completed"); + expect(cancelRes.ok).toBe(false); + expect(updated?.status).toBe("enabled"); + expect(updated?.grantedConnectors).toEqual(["LINEAR_API_KEY"]); + expect(granted).toBe(true); + expect(enabled).toBe(true); + } + }); + + + test("POST /api/setup-requests//complete returns ok:false, claims the request, and cleans up the connector on probe failure", async () => { + const config = testConfig("setup-requests-complete-probe-fail"); + const handler = createHandler(config); + const { createSetupRequest } = await import("./state"); + const approval = await mutateState(config.instance, (state) => + createSetupRequest(state, { + action: "connector.request", + target: "linear", + reason: "fetch issues", + payload: { + provider: "linear", + providerLabel: "Linear", + providerDescription: "Linear", + reason: "fetch issues", + fields: [], + toolCallId: "call_linear_fail" + } + }) + ); + // Stub the Linear GraphQL probe with a 401 so the probe fails. + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => new Response("{\"errors\":[{\"message\":\"Unauthorized\"}]}", { + status: 401, + headers: { "content-type": "application/json" } + })) as unknown as typeof fetch; + try { + const response = await call(handler, config, `/api/setup-requests/${approval.id}/complete`, { + method: "POST", + body: JSON.stringify({ secrets: { token: "not-a-real-token" } }) + }); + expect(response.ok).toBe(false); + expect(response.message).toBeString(); + } finally { + globalThis.fetch = originalFetch; + } + // The row was claimed BEFORE the create (claim-first race safety), so a + // probe failure cannot bounce it back to pending — it stays completed + // with a persisted failure outcome, and the orphaned unhealthy connector + // is cleaned up so it never lingers as a half-configured record. + const state = readState(config.instance); + const after = state.setupRequests.find((a) => a.id === approval.id); + expect(after?.status).toBe("completed"); + expect(after?.connectOutcome?.ok).toBe(false); + expect(state.connectors.some((c) => c.provider === "linear")).toBe(false); + }); + + + test("POST /api/setup-requests//complete: an UNEXPECTED post-claim throw resumes the task instead of stranding it", async () => { + // Strand-the-task regression: after the winning claim, createConnector / + // grant / enable / resume can still throw (here: a duplicate credential + // name). The route's catch-all would return 500 while the setup row sits + // `completed` and the task stays `waiting_approval` — orphaned. The fix + // wraps the whole post-claim block: any throw persists a failure outcome + // and resumes the task. We seed a genuine waiting_approval task with a + // resumable toolCallState (one pending request_connector call) so the + // resume re-enters the echo loop and the task settles terminally. + const config = testConfig("setup-complete-postclaim-throw"); + const handler = createHandler(config); + const { createSetupRequest, createTask, upsertTask } = await import("./state"); + + // Pre-seed a connector under the requested name so createConnector throws + // on instance-wide name uniqueness AFTER the claim. + await seedTypedCredential(config, "DUP_API_KEY", "generic"); + + const toolCallId = "call_postclaim_throw"; + const taskId = await mutateState(config.instance, (state) => { + const task = createTask(state.instance, "needs dup key"); + task.status = "waiting_approval"; + task.toolCallState = { + messages: [ + { role: "system", content: "you are gini" }, + { role: "user", content: "connect dup" }, + { role: "assistant", content: "", tool_calls: [{ id: toolCallId, type: "function", function: { name: "request_connector", arguments: "{}" } }] } + ], + toolsHash: "test", + pending: [{ toolCallId, toolName: "request_connector", approvalId: "" }], + iterations: 1 + }; + upsertTask(state, task); + return task.id; + }); + + const approval = await mutateState(config.instance, (state) => { + const a = createSetupRequest(state, { + taskId, + action: "connector.request", + target: "DUP_API_KEY", + reason: "Enter your Dup API key", + payload: { + credentialName: "DUP_API_KEY", + credentialType: "api-key", + credentialLabel: "Dup", + reason: "Enter your Dup API key", + toolCallId + } + }); + // Bind the approval to the task so the pending entry resolves on resume. + const item = state.tasks.find((t) => t.id === taskId)!; + item.toolCallState!.pending[0]!.approvalId = a.id; + item.approvalIds.push(a.id); + return a; + }); + + const raw = await rawCall(handler, config, `/api/setup-requests/${approval.id}/complete`, { + method: "POST", + body: JSON.stringify({ secrets: { DUP_API_KEY: "dup-secret" } }) + }, config.token); + const response = await raw.json(); + // The route returned a structured failure body (the outcome + resume ran; + // it is NOT the bare catch-all 500 that bypasses both). + expect(response.ok).toBe(false); + expect(response.message).toBeString(); + + const settled = await waitForTask(handler, config, taskId); + // The task RESUMED — it is no longer stranded at waiting_approval. + expect(settled.task.status).not.toBe("waiting_approval"); + + const state = readState(config.instance); + const resolved = state.setupRequests.find((a) => a.id === approval.id); + // The setup row is claimed (resolved) with a persisted failure outcome. + expect(resolved?.status).toBe("completed"); + expect(resolved?.connectOutcome?.ok).toBe(false); + // No duplicate connector was created — only the pre-seeded one remains. + expect(state.connectors.filter((c) => c.name === "DUP_API_KEY").length).toBe(1); + }); + + + test("POST /api/setup-requests//complete: a double-submit of a connector.request resolves once with no extra mutations", async () => { + // Claim-first race safety for connector.request: two concurrent completes + // of the same card — the mutateState lock serializes the atomic claim, so + // exactly one wins and creates exactly one connector; the loser produces + // zero side effects. + const config = testConfig("setup-complete-connector-double"); + const handler = createHandler(config); + const { createSetupRequest, createSkill } = await import("./state"); + const skill = await mutateState(config.instance, (state) => + createSkill(state, { + name: "needs-race-key", + description: "", + trigger: "", + steps: [], + requiredTools: [], + requiredPermissions: [], + status: "disabled", + source: "user", + requiredCredentials: ["RACE_API_KEY"] + }) + ); + const approval = await mutateState(config.instance, (state) => + createSetupRequest(state, { + action: "connector.request", + target: "RACE_API_KEY", + reason: "Enter your Race API key", + payload: { + credentialName: "RACE_API_KEY", + credentialType: "api-key", + credentialLabel: "Race", + skillId: skill.id, + reason: "Enter your Race API key", + toolCallId: "call_race" + } + }) + ); + + const [a, b] = await Promise.all([ + rawCall(handler, config, `/api/setup-requests/${approval.id}/complete`, { + method: "POST", + body: JSON.stringify({ secrets: { RACE_API_KEY: "race-secret" } }) + }, config.token), + rawCall(handler, config, `/api/setup-requests/${approval.id}/complete`, { + method: "POST", + body: JSON.stringify({ secrets: { RACE_API_KEY: "race-secret" } }) + }, config.token) + ]); + // Exactly one winner. + expect([a.ok, b.ok].filter(Boolean).length).toBe(1); + + const state = readState(config.instance); + const resolved = state.setupRequests.find((s) => s.id === approval.id); + expect(resolved?.status).toBe("completed"); + // Exactly one connector created — the loser created nothing. + expect(state.connectors.filter((c) => c.name === "RACE_API_KEY").length).toBe(1); + // The skill was granted the credential exactly once and enabled. + const updated = state.skills.find((s) => s.id === skill.id); + expect(updated?.grantedConnectors).toEqual(["RACE_API_KEY"]); + expect(updated?.status).toBe("enabled"); + // Exactly one grant audit row from the single winner. + expect(state.audit.filter((au) => au.action === "skill.connector.granted").length).toBe(1); + }); + + + test("POST /api/setup-requests//complete 404s for an authorization id", async () => { + const config = testConfig("setup-requests-complete-wrong-collection"); + const handler = createHandler(config); + const { createAuthorization } = await import("./state"); + const approval = await mutateState(config.instance, (state) => + createAuthorization(state, { + action: "file.write", + target: "/tmp/x", + risk: "high", + reason: "stub", + payload: { path: "/tmp/x", content: "hi" } + }) + ); + const response = await rawCall( + handler, + config, + `/api/setup-requests/${approval.id}/complete`, + { method: "POST", body: JSON.stringify({ secrets: {} }) }, + config.token + ); + // Authorization ids never appear in the setupRequests collection, so + // /complete returns 404 — the two endpoint families are independent. + expect(response.status).toBe(404); + }); + + + test("POST /api/setup-requests//complete creates a messaging bridge and resolves the setup request", async () => { + // Happy-path pin for the chat-side Add Telegram flow. The card's + // Submit button POSTs the name + bot token under `secrets`; the + // gateway routes them into addMessagingBridge (the same code path + // the CLI and the settings page already call) and resolves the + // setup request so the chat-task loop can resume. + const config = testConfig("setup-complete-bridge-happy"); + const handler = createHandler(config); + const { createSetupRequest } = await import("./state"); + const setup = await mutateState(config.instance, (state) => + createSetupRequest(state, { + action: "messaging.add_bridge", + target: "telegram", + reason: "Add a Telegram bridge", + payload: { kind: "telegram", suggestedName: "chat-test-bridge", toolCallId: "call_bridge_happy" } + }) + ); + + const response = await call(handler, config, `/api/setup-requests/${setup.id}/complete`, { + method: "POST", + body: JSON.stringify({ secrets: { name: "chat-test-bridge", botToken: "1234:ABCDEFGHIJKLMNOPQR" } }) + }); + expect(response.ok).toBe(true); + expect(response.bridge?.name).toBe("chat-test-bridge"); + expect(response.bridge?.kind).toBe("telegram"); + + const state = readState(config.instance); + const resolved = state.setupRequests.find((s) => s.id === setup.id); + expect(resolved?.status).toBe("completed"); + const bridge = state.messagingBridges.find((b) => b.name === "chat-test-bridge"); + expect(bridge).toBeDefined(); + expect(bridge?.kind).toBe("telegram"); + + // Audit-row traceability pin: the chat-card create writes a + // dedicated audit row with the originating setup-request id AND the + // resulting bridge.id so operators can reconstruct + // "setup X via chat-card → bridge Y" from the activity feed. + // Without this row the chat path is indistinguishable from the + // CLI / settings dialog in the audit log (both write the same + // generic messaging.configured row via createMessagingBridgeRecord). + const chatAddRow = state.audit.find( + (e) => e.action === "messaging.add_bridge" && e.approvalId === setup.id + ); + expect(chatAddRow).toBeDefined(); + expect(chatAddRow?.target).toBe(bridge?.id); + expect((chatAddRow?.evidence as { kind?: string } | undefined)?.kind).toBe("telegram"); + expect((chatAddRow?.evidence as { bridgeName?: string } | undefined)?.bridgeName).toBe("chat-test-bridge"); + + // Durable outcome pin: the /complete handler writes + // setup.connectOutcome so a post-reload render of the resolved + // card reads the truthful past-tense summary. Without this, the + // React component's sticky state evaporates on reload and the + // card would fall back to "Bridge added." even when the side + // effect actually failed. + expect(resolved?.connectOutcome?.ok).toBe(true); + expect(resolved?.connectOutcome?.message).toContain("chat-test-bridge"); + }); + + + test("POST /api/setup-requests//complete refuses messaging.add_bridge that was already cancelled, and creates no bridge", async () => { + // Race-safety pin: the messaging.add_bridge branch must resolve the + // setup request BEFORE addMessagingBridge so a concurrent /cancel + // (or cancel cascade) cannot leave an orphan bridge + encrypted + // secret on disk after the user has already abandoned the prompt. + // Mirrors the resolve-first contract in + // src/execution/browser-fill-secrets.ts. We simulate the race by + // pre-cancelling the setup request and then hitting /complete — the + // handler must short-circuit at the "already !pending" guard, + // return 410, and never touch addMessagingBridge. + const config = testConfig("setup-complete-bridge-cancel-race"); + const handler = createHandler(config); + const { createSetupRequest } = await import("./state"); + const setup = await mutateState(config.instance, (state) => + createSetupRequest(state, { + action: "messaging.add_bridge", + target: "telegram", + reason: "Add a Telegram bridge", + payload: { kind: "telegram", suggestedName: "race-bridge", toolCallId: "call_bridge_race" } + }) + ); + // Pre-cancel the setup request as if a concurrent operator had + // clicked Cancel between the user's typing and the Submit landing + // on the server. + await call(handler, config, `/api/setup-requests/${setup.id}/cancel`, { method: "POST" }); + expect(readState(config.instance).setupRequests.find((s) => s.id === setup.id)?.status).toBe("cancelled"); + const beforeBridges = readState(config.instance).messagingBridges.length; + + const response = await rawCall( + handler, + config, + `/api/setup-requests/${setup.id}/complete`, + { + method: "POST", + body: JSON.stringify({ secrets: { name: "race-bridge", botToken: "1234:ABCDEFGHIJKL" } }) + }, + config.token + ); + // 410 Gone — the resolution-before-creation contract is upheld by + // the outer "already !pending" guard. The load-bearing invariant + // is the absence of any bridge / orphan secret on the other side. + expect(response.status).toBe(410); + + const after = readState(config.instance); + expect(after.messagingBridges.length).toBe(beforeBridges); + expect(after.setupRequests.find((s) => s.id === setup.id)?.status).toBe("cancelled"); + }); + + + test("POST /api/setup-requests//complete rejects malformed messaging.add_bridge tokens BEFORE resolving the setup request", async () => { + // Token-format pre-check: addMessagingBridge runs + // assertHeaderSafeToken internally, and the chat card disappears + // once the setup request flips out of pending state. Without + // pre-resolve token validation, a malformed token would burn the + // request and the user could not retype from the same card. + // The bounded module calls assertHeaderSafeToken BEFORE resolving; + // this test pins that ordering by submitting a token with a control + // character and asserting the request stays pending. + const config = testConfig("setup-complete-bridge-bad-token-stays-pending"); + const handler = createHandler(config); + const { createSetupRequest } = await import("./state"); + const setup = await mutateState(config.instance, (state) => + createSetupRequest(state, { + action: "messaging.add_bridge", + target: "telegram", + reason: "Add a Telegram bridge", + payload: { kind: "telegram", suggestedName: "bad-token", toolCallId: "call_bridge_bad_token" } + }) + ); + const response = await call(handler, config, `/api/setup-requests/${setup.id}/complete`, { + method: "POST", + // Control character in the token — assertHeaderSafeToken + // refuses any byte outside printable ASCII [\x21-\x7E]. + body: JSON.stringify({ secrets: { name: "bad-token", botToken: "1234:abc\ndef" } }) + }); + expect(response.ok).toBe(false); + expect(typeof response.message).toBe("string"); + + const after = readState(config.instance); + expect(after.setupRequests.find((s) => s.id === setup.id)?.status).toBe("pending"); + expect(after.messagingBridges.length).toBe(0); + }); + + + test("POST /api/setup-requests//complete returns ok:false when messaging.add_bridge is missing a name or token", async () => { + // The chat card disables Submit until both inputs are non-empty, + // but a CLI/API caller could POST a partial body. The gateway + // mirrors the same readiness gate as the card so a partial + // submission can't silently create a half-configured bridge. + const config = testConfig("setup-complete-bridge-missing"); + const handler = createHandler(config); + const { createSetupRequest } = await import("./state"); + const setup = await mutateState(config.instance, (state) => + createSetupRequest(state, { + action: "messaging.add_bridge", + target: "telegram", + reason: "Add a Telegram bridge", + payload: { kind: "telegram", suggestedName: "missing-fields", toolCallId: "call_bridge_missing" } + }) + ); + const missingToken = await call(handler, config, `/api/setup-requests/${setup.id}/complete`, { + method: "POST", + body: JSON.stringify({ secrets: { name: "missing-fields" } }) + }); + expect(missingToken.ok).toBe(false); + expect(missingToken.message).toContain("Bot token"); + + const missingName = await call(handler, config, `/api/setup-requests/${setup.id}/complete`, { + method: "POST", + body: JSON.stringify({ secrets: { botToken: "1234:ABCDEFGHIJ" } }) + }); + expect(missingName.ok).toBe(false); + expect(missingName.message).toContain("name"); + + // Both rejections must leave the setup request pending — otherwise + // the chat card would flip out of pending state and the user + // couldn't retry. + const after = readState(config.instance).setupRequests.find((s) => s.id === setup.id); + expect(after?.status).toBe("pending"); + }); + + + test("screencast frames + input endpoints 404 for an unknown setup request", async () => { + const config = testConfig("screencast-404"); + const handler = createHandler(config); + const frames = await rawCall(handler, config, "/api/browser/screencast/nope/frames", {}, config.token); + expect(frames.status).toBe(404); + const input = await rawCall(handler, config, "/api/browser/screencast/nope/input", { + method: "POST", + body: JSON.stringify({ kind: "move", x: 1, y: 1 }) + }, config.token); + expect(input.status).toBe(404); + }); + + + test("screencast frames returns 409 when no spawned browser is running", async () => { + // A real browser.connect setup exists, but there's no live spawned Chrome + // in this unit-test context, so the bridge fails to start and the endpoint + // surfaces 409 rather than opening an empty stream. + const config = testConfig("screencast-no-browser"); + const handler = createHandler(config); + const { createSetupRequest } = await import("./state"); + const setup = await mutateState(config.instance, (state) => + createSetupRequest(state, { + action: "browser.connect", + target: "https://example.com", + reason: "Sign in to continue", + payload: { toolCallId: "call_sc", signInStarted: true, screencast: true } + }) + ); + const frames = await rawCall(handler, config, `/api/browser/screencast/${setup.id}/frames`, {}, config.token); + expect(frames.status).toBe(409); + const input = await rawCall(handler, config, `/api/browser/screencast/${setup.id}/input`, { + method: "POST", + body: JSON.stringify({ kind: "move", x: 1, y: 1 }) + }, config.token); + expect(input.status).toBe(409); + }); + + + test("open-browser 409s with the no-URL message when no browser is live and no page URL is recorded", async () => { + // A pending browser.connect with no live spawned Chrome AND no recorded url + // can't relaunch anything to screencast, so it 409s with the distinct + // no-URL message rather than attempting a blind navigate. + const config = testConfig("open-browser-no-url-no-browser"); + const handler = createHandler(config); + const { createSetupRequest } = await import("./state"); + const setup = await mutateState(config.instance, (state) => + createSetupRequest(state, { + action: "browser.connect", + target: "Sign in", + reason: "Sign in", + payload: { toolCallId: "call_no_url" } + }) + ); + const res = await rawCall(handler, config, `/api/setup-requests/${setup.id}/open-browser`, { method: "POST" }, config.token); + expect(res.status).toBe(409); + const body = await res.json(); + expect(body.error).toContain("no page URL is recorded"); + // The row stays pending so a later retry (once a browser is live) works. + expect(readState(config.instance).setupRequests.find((s) => s.id === setup.id)?.status).toBe("pending"); + }); + + + test("open-browser attempts a headless relaunch+navigate when a URL is recorded but no browser is live", async () => { + // Restart/crash/disconnect drops the in-process spawned handle while the + // durable card survives. With a recorded url, open-browser relaunches the + // headless Chrome and navigates to it (the spawn-only replacement for the + // removed managed relaunch) instead of stranding the user. We STUB the spawn + // launcher to throw so the relaunch fails deterministically (no dependence + // on ambient "is real Chrome installed?"), proving the recovery path was + // taken and surfaces a 409 with the row left pending for a later real retry. + const config = testConfig("open-browser-relaunch-attempt"); + const handler = createHandler(config); + const { createSetupRequest } = await import("./state"); + const browserMod = await import("./tools/browser"); + const browserTest = browserMod.__test; + browserMod.setBrowserInstance(config.instance); + browserTest.setSpawnChromeForTest(async () => { + throw new Error("stubbed: no Chrome in this unit test"); + }); + try { + const setup = await mutateState(config.instance, (state) => + createSetupRequest(state, { + action: "browser.connect", + target: "Sign in", + reason: "Sign in", + payload: { toolCallId: "call_relaunch", url: "https://example.com/login" } + }) + ); + const res = await rawCall(handler, config, `/api/setup-requests/${setup.id}/open-browser`, { method: "POST" }, config.token); + expect(res.status).toBe(409); + const body = await res.json(); + // Distinct from the no-URL message: this 409 came from the relaunch attempt. + expect(body.error).not.toContain("no page URL is recorded"); + expect(readState(config.instance).setupRequests.find((s) => s.id === setup.id)?.status).toBe("pending"); + } finally { + browserTest.setSpawnChromeForTest(null); + browserTest.uninstallFakeBrowserForTest(); + browserTest.resetBrowserInstanceForTest(); + } + }); + + + test("open-browser refuses (no relaunch/navigate) when the user's Chrome is attached over CDP", async () => { + // A stale Connect card racing a later cdp attach: the screencast streams the + // SPAWNED Chrome, but the user is on cdp. /open-browser must NOT relaunch + + // navigate the user's external Chrome to the recorded URL — it refuses with + // the cdp-specific 409 BEFORE any navigation. (The dispatch guard stops NEW + // cards on cdp; this covers an already-minted card hitting /open-browser.) + const config = testConfig("open-browser-cdp-refuse"); + const handler = createHandler(config); + const { createSetupRequest, now } = await import("./state"); + const setup = await mutateState(config.instance, (state) => { + // Active cdp transport + a stale card carrying a recorded URL. + state.browser = { mode: "cdp", cdpUrl: "ws://127.0.0.1:9222/devtools/browser/abc", startedAt: now() }; + return createSetupRequest(state, { + action: "browser.connect", + target: "Sign in", + reason: "Sign in", + payload: { toolCallId: "call_cdp_stale", url: "https://example.com/login" } + }); + }); + const res = await rawCall(handler, config, `/api/setup-requests/${setup.id}/open-browser`, { method: "POST" }, config.token); + expect(res.status).toBe(409); + const body = await res.json(); + expect(String(body.error)).toContain("attached over CDP"); + // The card was NOT stamped as a live screencast (no navigation happened). + const after = readState(config.instance).setupRequests.find((s) => s.id === setup.id); + expect(after?.status).toBe("pending"); + expect(after?.payload.signInStarted).toBeUndefined(); + expect(after?.payload.screencast).toBeUndefined(); + }); + + + test("frames reconnect on an already-stamped card relaunches the browser when none is live", async () => { + // After a gateway/Chrome restart, an already-stamped sign-in card (screencast + // + signInStarted, still pending) skips /open-browser — the modal reconnects + // straight to /frames. So /frames must itself attempt the headless relaunch + // when no browser is live and a URL is recorded, or the user is stuck on a + // permanent 409. In this unit context there's no real Chrome, so the relaunch + // fails fast and 409s — but via the relaunch path (distinct from the no-URL + // message), proving the reconnect recovery is wired through /frames. + const config = testConfig("frames-reconnect-relaunch"); + const handler = createHandler(config); + const { createSetupRequest } = await import("./state"); + const setup = await mutateState(config.instance, (state) => + createSetupRequest(state, { + action: "browser.connect", + target: "Sign in", + reason: "Sign in", + // Already stamped (the pre-restart open-browser ran); URL recorded. + payload: { toolCallId: "call_frames_relaunch", url: "https://example.com/login", signInStarted: true, screencast: true } + }) + ); + const res = await rawCall(handler, config, `/api/browser/screencast/${setup.id}/frames`, {}, config.token); + expect(res.status).toBe(409); + const body = await res.json(); + expect(body.error).not.toContain("no page URL is recorded"); + // Row stays pending so a real retry (with a live browser) can recover. + expect(readState(config.instance).setupRequests.find((s) => s.id === setup.id)?.status).toBe("pending"); + }); + + + test("frames reconnect on a stamped card with NO recorded URL 409s with the no-URL message", async () => { + // The relaunch needs a target page; without a recorded URL there's nothing + // to navigate to, so /frames 409s with the distinct no-URL message rather + // than blindly relaunching. + const config = testConfig("frames-reconnect-no-url"); + const handler = createHandler(config); + const { createSetupRequest } = await import("./state"); + const setup = await mutateState(config.instance, (state) => + createSetupRequest(state, { + action: "browser.connect", + target: "Sign in", + reason: "Sign in", + payload: { toolCallId: "call_frames_no_url", signInStarted: true, screencast: true } + }) + ); + const res = await rawCall(handler, config, `/api/browser/screencast/${setup.id}/frames`, {}, config.token); + expect(res.status).toBe(409); + const body = await res.json(); + expect(body.error).toContain("no page URL is recorded"); + }); + + + test("screencast endpoints reject a setup that isn't an active sign-in (lifecycle gate)", async () => { + // A browser.connect setup that is NOT pending, OR not stamped screencast + + // signInStarted, must be refused so a stale EventSource reconnect after + // complete/cancel can't rebuild a live drive channel. + const config = testConfig("screencast-lifecycle"); + const handler = createHandler(config); + const { createSetupRequest } = await import("./state"); + // Pending, but never went through open-browser (no screencast/signInStarted). + const notStarted = await mutateState(config.instance, (state) => + createSetupRequest(state, { + action: "browser.connect", + target: "https://example.com", + reason: "Sign in", + payload: { toolCallId: "call_ls" } + }) + ); + const r1 = await rawCall(handler, config, `/api/browser/screencast/${notStarted.id}/frames`, {}, config.token); + expect(r1.status).toBe(404); + // Stamped screencast but cancelled — no longer pending. + const cancelled = await mutateState(config.instance, (state) => { + const s = createSetupRequest(state, { + action: "browser.connect", + target: "https://example.com", + reason: "Sign in", + payload: { toolCallId: "call_ls2", signInStarted: true, screencast: true } + }); + const row = state.setupRequests.find((x) => x.id === s.id); + if (row) row.status = "cancelled"; + return s; + }); + const r2 = await rawCall(handler, config, `/api/browser/screencast/${cancelled.id}/input`, { + method: "POST", + body: JSON.stringify({ kind: "move", x: 1, y: 1 }) + }, config.token); + expect(r2.status).toBe(404); + }); + + + test("open-browser refuses a row cancelled before the stamp mutation (in-mutation status recheck)", async () => { + // /open-browser checks pending on a pre-read, then stamps screencast + + // signInStarted inside mutateState. A /cancel that commits in that window + // must not get a live sign-in stamped on top: the in-mutation status + // recheck returns 410 and leaves the cancelled row untouched. + const config = testConfig("open-browser-raced-cancel"); + const handler = createHandler(config); + const { createSetupRequest } = await import("./state"); + const { __test: browserTest } = await import("./tools/browser"); + // A live spawned handle so getScreencastPort is non-null (we get past the + // 409 no-browser gate and reach the stamp mutation). + browserTest.installFakeSpawnedHandleForTest(9333, { + close: async () => undefined, + pages: () => [], + browser: () => ({ isConnected: () => true }) + }); + try { + const setup = await mutateState(config.instance, (state) => + createSetupRequest(state, { + action: "browser.connect", + target: "https://example.com", + reason: "Sign in to continue", + payload: { toolCallId: "call_race" } + }) + ); + // Cancel the row first — this is the racer that wins. + await mutateState(config.instance, (state) => { + const row = state.setupRequests.find((s) => s.id === setup.id); + if (row) row.status = "cancelled"; + }); + const res = await rawCall(handler, config, `/api/setup-requests/${setup.id}/open-browser`, { method: "POST" }, config.token); + // The pre-read pending gate already 410s here; the in-mutation recheck is + // the backstop for a cancel that lands AFTER that gate. Either way the + // contract is: no live sign-in stamped on a terminal row. + expect(res.status).toBe(410); + const after = readState(config.instance).setupRequests.find((s) => s.id === setup.id); + expect(after?.status).toBe("cancelled"); + expect(after?.payload.signInStarted).toBeUndefined(); + expect(after?.payload.screencast).toBeUndefined(); + } finally { + browserTest.uninstallFakeBrowserForTest(); + } + }); + + + test("browser.connect /complete claims the row BEFORE writing the audit row (no duplicate on double-submit)", async () => { + // The non-screencast fallback must resolve (claim pending->completed) before + // writing the rich browser.connect audit row. A second /complete loses the + // claim (ApprovalRaceLostError) and writes ZERO side effects, so exactly one + // browser.connect audit row exists no matter how many completes race. + const config = testConfig("browser-connect-complete-claim-first"); + const handler = createHandler(config); + const { createSetupRequest } = await import("./state"); + const setup = await mutateState(config.instance, (state) => + createSetupRequest(state, { + action: "browser.connect", + target: "Sign in to the store", + reason: "Sign in to the store", + // No screencast marker → the non-screencast fallback path. + payload: { toolCallId: "call_complete", reason: "Sign in to the store" } + }) + ); + const first = await rawCall(handler, config, `/api/setup-requests/${setup.id}/complete`, { method: "POST" }, config.token); + expect(first.status).toBe(200); + // Second complete on the now-terminal row loses the claim. + const second = await rawCall(handler, config, `/api/setup-requests/${setup.id}/complete`, { method: "POST" }, config.token); + expect(second.status).toBeGreaterThanOrEqual(400); + const browserConnectAudits = readState(config.instance).audit.filter((a) => a.action === "browser.connect"); + expect(browserConnectAudits).toHaveLength(1); + }); + + + test("open-browser is idempotent: a second open on an already-stamped row writes no duplicate audit", async () => { + // /open-browser keeps the row pending, so two opens (double-click / retry) + // both pass the pending check. The in-mutation already-stamped guard makes + // the second a no-op so exactly one stage:open-browser audit row exists. + const config = testConfig("open-browser-idempotent"); + const handler = createHandler(config); + const { createSetupRequest } = await import("./state"); + const { __test: browserTest } = await import("./tools/browser"); + browserTest.installFakeSpawnedHandleForTest(9333, { + close: async () => undefined, + pages: () => [], + browser: () => ({ isConnected: () => true }) + }); + try { + const setup = await mutateState(config.instance, (state) => + createSetupRequest(state, { + action: "browser.connect", + target: "https://example.com", + reason: "Sign in to continue", + payload: { toolCallId: "call_idem", url: "https://example.com/login" } + }) + ); + const first = await rawCall(handler, config, `/api/setup-requests/${setup.id}/open-browser`, { method: "POST" }, config.token); + expect(first.status).toBe(200); + const second = await rawCall(handler, config, `/api/setup-requests/${setup.id}/open-browser`, { method: "POST" }, config.token); + // The second open succeeds (idempotent) but writes no extra audit/trace. + expect(second.status).toBe(200); + const openAudits = readState(config.instance).audit.filter( + (a) => a.action === "browser.connect" && (a.evidence as Record | undefined)?.stage === "open-browser" + ); + expect(openAudits).toHaveLength(1); + } finally { + browserTest.uninstallFakeBrowserForTest(); + } + }); + + + test("screencast frames stream a frame and input dispatches with a live bridge", async () => { + // HTTP success path: install a fake bridge (no real Chrome) so the SSE + // envelope + input dispatch wiring is exercised end to end at the gateway. + const config = testConfig("screencast-success"); + const handler = createHandler(config); + const { createSetupRequest } = await import("./state"); + const sc = await import("./execution/browser-screencast"); + const setup = await mutateState(config.instance, (state) => + createSetupRequest(state, { + action: "browser.connect", + target: "https://example.com", + reason: "Sign in", + payload: { toolCallId: "call_ok", signInStarted: true, screencast: true } + }) + ); + // A minimal fake bridge: replays one frame to each subscriber and records + // dispatched input. Installed as the live activeBridge so the no-factory + // getOrStartBridge inside http.ts reuses it. + const dispatched: unknown[] = []; + const fakeBridge = { + isClosed: () => false, + subscribe( + onFrame: (f: { data: string; meta: Record }) => void, + _onClose?: () => void, + onUrl?: (url: string) => void + ) { + onUrl?.("https://signin.example.com/login"); + onFrame({ data: "QUJD", meta: { deviceWidth: 800 } }); + return () => undefined; + }, + dispatchInput: async (m: { kind?: string }) => { + dispatched.push(m); + // Mirror the real bridge: selection-causing kinds return a selection. + return m.kind === "copy" ? { selection: "picked text" } : {}; + }, + start: async () => undefined, + stop: async () => undefined + }; + sc.__setActiveBridgeForTest(fakeBridge as never, setup.id); + try { + const framesRes = await handler( + new Request(`http://127.0.0.1:${config.port}/api/browser/screencast/${setup.id}/frames`, { + headers: { authorization: `Bearer ${config.token}` } + }) + ); + expect(framesRes.status).toBe(200); + expect(framesRes.headers.get("content-type")).toContain("text/event-stream"); + const reader = framesRes.body!.getReader(); + // onUrl and onFrame enqueue separate chunks; read both. + const decoder = new TextDecoder(); + const first = decoder.decode((await reader.read()).value); + const second = decoder.decode((await reader.read()).value); + const text = first + second; + // The gateway-sourced origin is streamed as an `event: url` so the modal + // can show the operator which site they're signing into. + expect(text).toContain("event: url"); + expect(text).toContain("https://signin.example.com/login"); + expect(text).toContain("event: frame"); + expect(text).toContain("QUJD"); + await reader.cancel(); + + const inputRes = await call(handler, config, `/api/browser/screencast/${setup.id}/input`, { + method: "POST", + body: JSON.stringify({ kind: "click", x: 10, y: 20, clickCount: 1 }) + }); + expect(inputRes.ok).toBe(true); + expect(dispatched).toEqual([{ kind: "click", x: 10, y: 20, clickCount: 1 }]); + + // A copy relays the remote selection back in the response body so the + // modal can serve it to the operator's clipboard. + const copyRes = await call(handler, config, `/api/browser/screencast/${setup.id}/input`, { + method: "POST", + body: JSON.stringify({ kind: "copy" }) + }); + expect(copyRes.ok).toBe(true); + expect(copyRes.selection).toBe("picked text"); + } finally { + sc.__resetActiveBridgeForTest(); + } + }); + + + test("the frames SSE stream closes when the bridge dies (no dangling keepalives)", async () => { + const config = testConfig("screencast-frames-close-on-death"); + const handler = createHandler(config); + const { createSetupRequest } = await import("./state"); + const sc = await import("./execution/browser-screencast"); + const setup = await mutateState(config.instance, (state) => + createSetupRequest(state, { + action: "browser.connect", + target: "https://example.com", + reason: "Sign in", + payload: { toolCallId: "call_close", signInStarted: true, screencast: true } + }) + ); + // A fake bridge that captures the onClose callback so the test can fire it, + // simulating the CDP socket dropping. + let fireClose: (() => void) | undefined; + const fakeBridge = { + isClosed: () => false, + subscribe(onFrame: (f: { data: string; meta: Record }) => void, onClose?: () => void) { + onFrame({ data: "QUJD", meta: { deviceWidth: 800 } }); + fireClose = onClose; + return () => undefined; + }, + dispatchInput: async () => undefined, + start: async () => undefined, + stop: async () => undefined + }; + sc.__setActiveBridgeForTest(fakeBridge as never, setup.id); + try { + const framesRes = await handler( + new Request(`http://127.0.0.1:${config.port}/api/browser/screencast/${setup.id}/frames`, { + headers: { authorization: `Bearer ${config.token}` } + }) + ); + expect(framesRes.status).toBe(200); + const reader = framesRes.body!.getReader(); + await reader.read(); // first frame + expect(fireClose).toBeDefined(); + fireClose!(); // bridge dies → route should close the stream + const next = await reader.read(); + expect(next.done).toBe(true); // stream ended, not dangling on keepalives + } finally { + sc.__resetActiveBridgeForTest(); + } + }); + + + test("completing a screencast browser.connect resolves the setup without a managed relaunch", async () => { + // The screencast path keeps the agent on its headless Chrome the whole + // time, so /complete just stops the bridge (a no-op here, no live bridge) + // and resolves the setup — no connectBrowser relaunch. + const config = testConfig("screencast-complete"); + const handler = createHandler(config); + const { createSetupRequest } = await import("./state"); + const setup = await mutateState(config.instance, (state) => + createSetupRequest(state, { + action: "browser.connect", + target: "https://example.com", + reason: "Sign in", + payload: { toolCallId: "call_sc3", signInStarted: true, screencast: true } + }) + ); + const res = await call(handler, config, `/api/setup-requests/${setup.id}/complete`, { + method: "POST", + body: JSON.stringify({}) + }); + expect(res.ok).toBe(true); + expect(readState(config.instance).setupRequests.find((s) => s.id === setup.id)?.status).toBe("completed"); + }); + + + test("cancelling a screencast browser.connect stops the bridge and cancels the setup", async () => { + const config = testConfig("screencast-cancel"); + const handler = createHandler(config); + const { createSetupRequest } = await import("./state"); + const setup = await mutateState(config.instance, (state) => + createSetupRequest(state, { + action: "browser.connect", + target: "https://example.com", + reason: "Sign in", + payload: { toolCallId: "call_sc4", signInStarted: true, screencast: true } + }) + ); + await call(handler, config, `/api/setup-requests/${setup.id}/cancel`, { method: "POST" }); + expect(readState(config.instance).setupRequests.find((s) => s.id === setup.id)?.status).toBe("cancelled"); + }); + + + test("cancel stops the owner's bridge unconditionally, even if the row isn't yet stamped screencast", async () => { + // /cancel must not gate the bridge teardown on a pre-claim screencast read: + // payload.screencast can flip true (via /open-browser) in the window between + // that read and the atomic claim. Install a live bridge owned by the setup + // on a row that is NOT yet stamped screencast, cancel it, and assert the + // bridge is torn down anyway — the unconditional owner-scoped stop. + const config = testConfig("screencast-cancel-unstamped"); + const handler = createHandler(config); + const { createSetupRequest } = await import("./state"); + const sc = await import("./execution/browser-screencast"); + const setup = await mutateState(config.instance, (state) => + createSetupRequest(state, { + action: "browser.connect", + target: "https://example.com", + reason: "Sign in", + // Deliberately NOT stamped screencast/signInStarted yet. + payload: { toolCallId: "call_sc_unstamped" } + }) + ); + let stopped = false; + const fakeBridge = { + isClosed: () => false, + stop: async () => { + stopped = true; + } + } as unknown as import("./execution/browser-screencast").ScreencastBridge; + sc.__setActiveBridgeForTest(fakeBridge, setup.id); + try { + await call(handler, config, `/api/setup-requests/${setup.id}/cancel`, { method: "POST" }); + expect(readState(config.instance).setupRequests.find((s) => s.id === setup.id)?.status).toBe("cancelled"); + expect(stopped).toBe(true); + } finally { + await sc.stopActiveBridge(); + } + }); + + + test("a frames request after a screencast complete is rejected (no bridge recreation)", async () => { + // /complete marks the setup terminal BEFORE stopping the bridge, so a + // frames/input request racing the completion sees status!=="pending" and + // 404s instead of recreating an orphaned bridge in the teardown gap. + const config = testConfig("screencast-complete-then-frames"); + const handler = createHandler(config); + const { createSetupRequest } = await import("./state"); + const setup = await mutateState(config.instance, (state) => + createSetupRequest(state, { + action: "browser.connect", + target: "https://example.com", + reason: "Sign in", + payload: { toolCallId: "call_sc_race", signInStarted: true, screencast: true } + }) + ); + const done = await call(handler, config, `/api/setup-requests/${setup.id}/complete`, { + method: "POST", + body: JSON.stringify({}) + }); + expect(done.ok).toBe(true); + const framesRes = await rawCall( + handler, + config, + `/api/browser/screencast/${setup.id}/frames`, + { method: "GET" }, + config.token + ); + expect(framesRes.status).toBe(404); + }); + + + test("screencast input rejects a malformed JSON body with 400", async () => { + const config = testConfig("screencast-bad-body"); + const handler = createHandler(config); + const { createSetupRequest } = await import("./state"); + const setup = await mutateState(config.instance, (state) => + createSetupRequest(state, { + action: "browser.connect", + target: "https://example.com", + reason: "Sign in", + payload: { toolCallId: "call_sc2", signInStarted: true, screencast: true } + }) + ); + const res = await rawCall(handler, config, `/api/browser/screencast/${setup.id}/input`, { + method: "POST", + body: "{not json" + }, config.token); + expect(res.status).toBe(400); + }); + + + test("POST /api/setup-requests//complete refuses a code-less messaging.approve_pairing approve and keeps the request pending", async () => { + // allowChat's pending-row presence check is gated on `expectedCode` + // being defined (the legacy CLI's "operator knows what they're + // doing" trust model). If a chat-card pairing payload arrives + // without verificationCode (group chat: groups intentionally never + // mint a code, or a stale request whose pending row was cleared and + // recreated), a no-code allowChat call would bypass the pending-row + // check and enroll a chat that is no longer pending. Pin that + // messaging-pairing-connect refuses the approve branch up-front when + // verificationCode is missing. + const config = testConfig("setup-complete-pairing-codeless-refuses"); + const handler = createHandler(config); + const { createSetupRequest } = await import("./state"); + const setup = await mutateState(config.instance, (state) => + createSetupRequest(state, { + action: "messaging.approve_pairing", + target: "bridge_codeless:7", + reason: "Confirm pairing", + // Deliberately omit verificationCode — the chat card normally + // carries one for private chats, but a stale or group-chat + // payload would not. + payload: { bridgeId: "bridge_codeless", chatId: 7, toolCallId: "call_codeless" } + }) + ); + + const response = await call(handler, config, `/api/setup-requests/${setup.id}/complete`, { + method: "POST", + body: JSON.stringify({}) + }); + expect(response.ok).toBe(false); + expect(response.message).toContain("code-less"); + const after = readState(config.instance); + expect(after.setupRequests.find((s) => s.id === setup.id)?.status).toBe("pending"); + }); + + + test("POST /api/setup-requests//complete removes a messaging bridge through the setup-request flow", async () => { + // Happy path for the chat-side Remove bridge card. The /complete + // handler delegates to runMessagingRemoveConnect, which resolves + // the setup request atomically then calls removeMessagingBridge. + const config = testConfig("setup-complete-remove-bridge-happy"); + const handler = createHandler(config); + + // Create a real bridge via the existing endpoint so its + // encrypted secret + state record exist before we try to remove it. + const created = await call(handler, config, `/api/messaging`, { + method: "POST", + body: JSON.stringify({ name: "remove-me", kind: "telegram", botToken: "1234:ABCDEFGHIJKLMNOPQR" }) + }); + expect(created.id).toBeString(); + + const { createSetupRequest } = await import("./state"); + const setup = await mutateState(config.instance, (state) => + createSetupRequest(state, { + action: "messaging.remove_bridge", + target: created.id, + reason: "Remove bridge", + payload: { bridgeId: created.id, bridgeName: "remove-me", kind: "telegram", toolCallId: "call_remove" } + }) + ); + + const response = await call(handler, config, `/api/setup-requests/${setup.id}/complete`, { + method: "POST", + body: JSON.stringify({}) + }); + expect(response.ok).toBe(true); + expect(response.removed).toBe(true); + expect(response.bridgeId).toBe(created.id); + + const after = readState(config.instance); + expect(after.setupRequests.find((s) => s.id === setup.id)?.status).toBe("completed"); + expect(after.messagingBridges.find((b) => b.id === created.id)).toBeUndefined(); + + // Chat-card lineage audit row pin: the chat-card remove path + // writes a dedicated audit row carrying the setup-request id + + // bridgeId, so a chat-card remove is distinguishable from a CLI / + // settings remove in the activity feed. + const chatRemoveRow = after.audit.find( + (e) => e.action === "messaging.remove_bridge" && e.approvalId === setup.id + ); + expect(chatRemoveRow).toBeDefined(); + expect(chatRemoveRow?.target).toBe(created.id); + expect((chatRemoveRow?.evidence as { bridgeName?: string } | undefined)?.bridgeName).toBe("remove-me"); + }); + + + test("POST /api/setup-requests//complete refuses partial browser.fill_secret submissions", async () => { + // fillReady in BlockSetupRequested.tsx only disables the web + // Submit button; CLI / mobile / direct API clients can still POST a + // partial body. The gateway must enforce that every declared slot + // has a non-empty value before any DOM fill happens — otherwise + // /complete would resolve with some slots silently unfilled and the + // agent would be told (in agent.ts:runApprovedAction) that every + // declared slot was filled. + const config = testConfig("complete-rejects-partial-fill-secret"); + const handler = createHandler(config); + const { createSetupRequest } = await import("./state"); + const taskId = await mutateState(config.instance, (state) => { + const { createTask, upsertTask } = require("./state") as typeof import("./state"); + const task = createTask(state.instance, "partial-test"); + upsertTask(state, task); + return task.id; + }); + // Seed approvedUrl so the origin guard's "no live page" refusal + // doesn't fire before the missing-slot check; this test is about + // partial submission, not origin binding. + const approval = await mutateState(config.instance, (state) => + createSetupRequest(state, { + taskId, + action: "browser.fill_secret", + target: "https://example.com", + reason: "Sign in to the test site", + payload: { + slots: [ + { name: "username", locator: "@e1", label: "Username", kind: "text" }, + { name: "password", locator: "@e2", label: "Password", kind: "password" } + ], + reason: "Sign in", + toolCallId: "call_fill", + // Origin only — sanitizeUrlForAuditTarget strips pathname. + approvedUrl: "https://example.com" + } + }) + ); + const { __test: browserTest } = await import("./tools/browser"); + browserTest.installFakeSessionWithPageForTest(taskId, { + // The live URL can be on any path within the approved origin; + // the equality check is on origin only after the SEC-C fix. + url: () => "https://example.com/login", + close: () => Promise.resolve() + } as Partial); + const response = await rawCall( + handler, + config, + `/api/setup-requests/${approval.id}/complete`, + { + method: "POST", + body: JSON.stringify({ secrets: { username: "tomsmith" } }) + }, + config.token + ); + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.ok).toBe(false); + expect(body.message).toContain("password"); + expect(body.message).toContain("Missing"); + const after = readState(config.instance).setupRequests.find((a) => a.id === approval.id); + expect(after?.status).toBe("pending"); + }); + + + test("POST /api/setup-requests//complete: submitted fill_secret values never appear in state.json, trace JSONL, or runtime.jsonl", async () => { + // End-to-end absence pin for the ADR's secret-handling guarantee: + // submitted credential values must flow request-scope only and + // never reach any persisted artifact. Without this test the only + // protection is manual code review of every audit/trace/log write + // touching the fill_secret path. Distinct marker strings let us + // grep the raw bytes after the request — partial matches would + // catch even an attempt to serialize a wrapper object containing + // the value. + const config = testConfig("fill-secret-no-state-leak"); + const handler = createHandler(config); + const { createTask, upsertTask, createSetupRequest } = await import("./state"); + const taskId = await mutateState(config.instance, (state) => { + const task = createTask(state.instance, "fill secret leak guard"); + upsertTask(state, task); + return task.id; + }); + // Seed approvedUrl on the payload AND install a matching fake + // session so the origin guard passes and the fill loop actually + // runs. The fills will error per-slot because the fake page's + // .locator() returns nothing useful — what we care about is that + // the audit row is written with redacted: true and the markers + // never reach state/trace/log even when the runtime tries to + // record what happened. + const approval = await mutateState(config.instance, (state) => + createSetupRequest(state, { + taskId, + action: "browser.fill_secret", + target: "https://example.com", + reason: "Sign in to the test site", + payload: { + slots: [ + { name: "username", locator: "@e1", label: "Username", kind: "text" }, + { name: "password", locator: "@e2", label: "Password", kind: "password" } + ], + reason: "Sign in", + toolCallId: "call_fill", + approvedUrl: "https://example.com" + } + }) + ); + const { __test: browserTest } = await import("./tools/browser"); + browserTest.installFakeSessionWithPageForTest(taskId, { + url: () => "https://example.com/login", + // Fake locator that no-ops on fill; the audit-row write still + // happens regardless of whether the fill succeeded. Cast as + // Partial since the fake only implements what + // browserFillByLocator touches. + locator: ((_sel: string) => ({ + fill: async () => { throw new Error("fake session, no real DOM"); } + })) as unknown as import("playwright-core").Page["locator"], + close: () => Promise.resolve() + } as Partial); + const USERNAME_MARKER = "tomsmith-LEAK-MARKER-zzzzz"; + const PASSWORD_MARKER = "SuperSecretPassword-LEAK-MARKER-zzzzz"; + await rawCall( + handler, + config, + `/api/setup-requests/${approval.id}/complete`, + { + method: "POST", + body: JSON.stringify({ secrets: { username: USERNAME_MARKER, password: PASSWORD_MARKER } }) + }, + config.token + ); + // No browser session exists, so browserFillByLocator returns + // errors for both slots and the audit row evidence carries + // `errors[]` (no values) + filledSlots = []. The approval is + // still resolved atomically before the fill loop so the deny + // race is closed; the agent gets a partial-fill error result + // via resumeChatTask. + + // Raw state.json bytes must not contain either marker. + const stateJsonPath = `${config.stateRoot}/state.json`; + const rawState = readFileSync(stateJsonPath, "utf8"); + expect(rawState).not.toContain(USERNAME_MARKER); + expect(rawState).not.toContain(PASSWORD_MARKER); + + // Trace JSONL (per-task) must not contain either marker. The + // file may not exist if no trace events fired for this task — + // an empty file is fine, the test only fails on a leak. + const traceJsonlPath = `${config.stateRoot}/traces/${taskId}.jsonl`; + if (existsSync(traceJsonlPath)) { + const rawTrace = readFileSync(traceJsonlPath, "utf8"); + expect(rawTrace).not.toContain(USERNAME_MARKER); + expect(rawTrace).not.toContain(PASSWORD_MARKER); + } + + // runtime.jsonl is the cross-task log file — also greppable. + const runtimeLogPath = `${config.logRoot}/runtime.jsonl`; + if (existsSync(runtimeLogPath)) { + const rawLog = readFileSync(runtimeLogPath, "utf8"); + expect(rawLog).not.toContain(USERNAME_MARKER); + expect(rawLog).not.toContain(PASSWORD_MARKER); + } + + // The audit row itself: defense-in-depth. Both `evidence` (would + // be undefined after redaction) and `target` must not contain + // the markers. `target` is preserved across redaction; this pin + // catches a future regression that would forget to sanitize URL + // query strings or stuff secrets into the target field. + const auditRows = readState(config.instance).audit.filter( + (a) => a.action === "browser.fill_secret" && a.approvalId === approval.id + ); + expect(auditRows.length).toBe(1); + const row = auditRows[0]!; + expect(row.redacted).toBe(true); + expect(row.evidence).toBeUndefined(); + expect(row.target ?? "").not.toContain(USERNAME_MARKER); + expect(row.target ?? "").not.toContain(PASSWORD_MARKER); + }); + + + test("POST /api/setup-requests//complete refuses fill_secret when page navigated away from approved origin", async () => { + // The approval.target encodes the origin the user consented + // to fill into (protocol+host+port; pathname is stripped by + // sanitizeUrlForAuditTarget). If the page has navigated to a + // different origin (agent action, user click, JS redirect, + // phishing redirect) between approval creation and Submit, + // the live URL no longer matches and we refuse with 409 so a + // fresh approval is required for the new destination. In + // this test the browser session was never opened so + // peekCurrentBrowserUrl returns undefined, + // which the handler treats as "no live page to fill" — same + // refusal path. + const config = testConfig("complete-fill-secret-origin-mismatch"); + const handler = createHandler(config); + const { createTask, upsertTask, createSetupRequest } = await import("./state"); + const taskId = await mutateState(config.instance, (state) => { + const task = createTask(state.instance, "test"); + upsertTask(state, task); + return task.id; + }); + const approval = await mutateState(config.instance, (state) => + createSetupRequest(state, { + taskId, + action: "browser.fill_secret", + target: "https://example.com", + reason: "Sign in", + payload: { + slots: [ + { name: "username", locator: "@e1", label: "Username", kind: "text" }, + { name: "password", locator: "@e2", label: "Password", kind: "password" } + ], + reason: "Sign in", + toolCallId: "call_fill", + // The /connect origin guard reads from the structural + // approvedUrl on payload — peer approval actions carry + // their contract fields under payload too. Stored as + // origin only (no pathname) since reset/magic-link + // URLs can carry tokens in the path. + approvedUrl: "https://example.com" + } + }) + ); + const response = await rawCall( + handler, + config, + `/api/setup-requests/${approval.id}/complete`, + { + method: "POST", + body: JSON.stringify({ secrets: { username: "tomsmith", password: "SuperSecretPassword!" } }) + }, + config.token + ); + expect(response.status).toBe(409); + const body = await response.json(); + expect(body.ok).toBe(false); + // The browser session was never opened, so peekCurrentBrowserUrl + // returns undefined and the /complete handler takes the + // "session expired" branch (distinct from the "page navigated" + // branch where a live session exists but its URL differs from + // approvedUrl). Without that split the operator would see + // "page navigated" after a 5-minute walk-away — misleading. + expect(body.message).toContain("Browser session expired"); + expect(body.message).toContain("https://example.com"); + // Approval stayed pending — no resolveApproval call ran. + const after = readState(config.instance).setupRequests.find((a) => a.id === approval.id); + expect(after?.status).toBe("pending"); + }); + + + test("POST /api/setup-requests//complete refuses sub-floor password-kind slot values", async () => { + // The snapshot post-redactor uses literal substring replacement; + // single-character (and other very short) values would shred + // structural tokens like [@e1] in snapshot text. The 4-char + // floor in src/tools/browser.ts:recordFilledSecret keeps the + // redactor safe. For a password-kind slot a sub-floor value is + // both a near-certain typo AND an un-redactable leak risk, so + // /connect refuses it (the registry-skip-for-short-values would + // otherwise let the value escape via a later unredacted tool + // result). Non-password slots take the opposite path — see the + // short-PII test below. + const config = testConfig("complete-fill-secret-too-short"); + const handler = createHandler(config); + const { createTask, upsertTask, createSetupRequest } = await import("./state"); + const taskId = await mutateState(config.instance, (state) => { + const task = createTask(state.instance, "short value test"); + upsertTask(state, task); + return task.id; + }); + const approval = await mutateState(config.instance, (state) => + createSetupRequest(state, { + taskId, + action: "browser.fill_secret", + target: "https://example.com", + reason: "Sign in", + payload: { + slots: [ + { name: "pin", locator: "@e1", label: "PIN", kind: "password" } + ], + reason: "Sign in", + toolCallId: "call_fill", + approvedUrl: "https://example.com" + } + }) + ); + const { __test: browserTest } = await import("./tools/browser"); + browserTest.installFakeSessionWithPageForTest(taskId, { + url: () => "https://example.com", + close: () => Promise.resolve() + } as Partial); + const response = await rawCall( + handler, + config, + `/api/setup-requests/${approval.id}/complete`, + { + method: "POST", + body: JSON.stringify({ secrets: { pin: "12" } }) + }, + config.token + ); + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.ok).toBe(false); + expect(body.message).toContain("too short"); + expect(body.message).toContain("pin"); + const after = readState(config.instance).setupRequests.find((a) => a.id === approval.id); + expect(after?.status).toBe("pending"); + }); + + + test("POST /api/setup-requests//complete accepts a sub-floor non-password (PII) slot value", async () => { + // fill_secret also collects identity/PII fields — a real call + // asks for date of birth + last name. Short last names ("Shi", + // "Ng", "Li") are valid and must fill. The redaction floor is a + // redactor-safety constraint, not an input-validation gate, so a + // text-kind slot below the floor is accepted and filled (it is + // simply not redaction-registered, which is fine for a non- + // credential). Pin the boundary so the floor never silently + // re-broadens to block PII again. + const config = testConfig("complete-fill-secret-short-pii"); + const handler = createHandler(config); + const { createTask, upsertTask, createSetupRequest } = await import("./state"); + const taskId = await mutateState(config.instance, (state) => { + const task = createTask(state.instance, "short PII test"); + upsertTask(state, task); + return task.id; + }); + const approval = await mutateState(config.instance, (state) => + createSetupRequest(state, { + taskId, + action: "browser.fill_secret", + target: "https://example.com", + reason: "Look up account", + payload: { + slots: [ + { name: "lastname", locator: "@e43", label: "Last Name", kind: "text" } + ], + reason: "Look up account", + toolCallId: "call_fill", + approvedUrl: "https://example.com" + } + }) + ); + const filled: Array<{ locator: string; value: string }> = []; + const { __test: browserTest } = await import("./tools/browser"); + browserTest.installFakeSessionWithPageForTest(taskId, { + url: () => "https://example.com", + close: () => Promise.resolve(), + // browserFillByLocator resolves an @-ref to a literal + // [data-gini-ref] selector, then calls page.locator(sel).fill(). + locator: (selector: string) => ({ + fill: (value: string) => { + filled.push({ locator: selector, value }); + return Promise.resolve(); + }, + evaluate: () => Promise.resolve() + }) + } as unknown as Partial); + const response = await rawCall( + handler, + config, + `/api/setup-requests/${approval.id}/complete`, + { + method: "POST", + body: JSON.stringify({ secrets: { lastname: "Shi" } }) + }, + config.token + ); + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.ok).toBe(true); + expect(body.filledSlots).toEqual(["lastname"]); + expect(filled).toEqual([{ locator: '[data-gini-ref="e43"]', value: "Shi" }]); + const after = readState(config.instance).setupRequests.find((a) => a.id === approval.id); + expect(after?.status).toBe("completed"); + }); + + + test("POST /api/setup-requests//complete: distinct 409 when live session exists but page navigated to a different origin", async () => { + // Pin the OTHER 409 branch: a live session whose current URL no + // longer matches the approved origin. This is the genuine + // page-navigated case (agent click, JS redirect, phishing + // redirect), distinct from the session-expired idle-sweep case + // covered by the previous test. + const config = testConfig("complete-fill-secret-real-navigation"); + const handler = createHandler(config); + const { createTask, upsertTask, createSetupRequest } = await import("./state"); + const taskId = await mutateState(config.instance, (state) => { + const task = createTask(state.instance, "real navigation test"); + upsertTask(state, task); + return task.id; + }); + const approval = await mutateState(config.instance, (state) => + createSetupRequest(state, { + taskId, + action: "browser.fill_secret", + target: "https://example.com", + reason: "Sign in", + payload: { + slots: [ + { name: "username", locator: "@e1", label: "Username", kind: "text" }, + { name: "password", locator: "@e2", label: "Password", kind: "password" } + ], + reason: "Sign in", + toolCallId: "call_fill", + approvedUrl: "https://example.com" + } + }) + ); + const { __test: browserTest } = await import("./tools/browser"); + // Live session exists but the page URL is on a different origin + // than what the approval captured — should take the "page + // navigated" branch, NOT the "session expired" branch. + browserTest.installFakeSessionWithPageForTest(taskId, { + url: () => "https://evil.example.org/phishing", + close: () => Promise.resolve() + } as Partial); + const response = await rawCall( + handler, + config, + `/api/setup-requests/${approval.id}/complete`, + { + method: "POST", + body: JSON.stringify({ secrets: { username: "tomsmith", password: "SuperSecretPassword!" } }) + }, + config.token + ); + expect(response.status).toBe(409); + const body = await response.json(); + expect(body.ok).toBe(false); + expect(body.message).toContain("Page navigated"); + expect(body.message).toContain("https://example.com"); + expect(body.message).toContain("https://evil.example.org"); + const after = readState(config.instance).setupRequests.find((a) => a.id === approval.id); + expect(after?.status).toBe("pending"); + }); + + // Default transport (issue #420): /api/browser/connect with NO cdpUrl is a + // no-op acknowledgement — the spawned Chrome launches lazily on the first + // browser tool call, not at connect time, and carries no record. + + test("browser connect with no cdpUrl returns the stable disconnected status", async () => { + const config = testConfig("browser-connect-empty-body"); + const handler = createHandler(config); + const response = await rawCall( + handler, + config, + "/api/browser/connect", + { method: "POST", body: JSON.stringify({}) }, + config.token + ); + expect(response.status).toBe(201); + const body = await response.json(); + expect(body.connected).toBe(false); + expect(readState(config.instance).browser ?? null).toBeNull(); + }); + + // A cdpUrl with an unsupported protocol is user-input error → 400, and no + // record is written. (Validation happens before any probe/attach.) + + test("browser connect rejects an unsupported cdpUrl protocol with 400", async () => { + const config = testConfig("browser-connect-bad-protocol"); + const handler = createHandler(config); + const response = await rawCall( + handler, + config, + "/api/browser/connect", + { method: "POST", body: JSON.stringify({ cdpUrl: "file:///etc/passwd" }) }, + config.token + ); + expect(response.status).toBe(400); + const body = await response.json(); + expect(String(body.error ?? body.message)).toContain("Unsupported cdpUrl protocol"); + expect(readState(config.instance).browser ?? null).toBeNull(); + }); + + // An unreachable (but well-formed) loopback CDP endpoint surfaces as 400 with + // a clear "Could not reach CDP endpoint" message, and writes no record. The + // server-side env knobs shrink the probe deadline so the test doesn't burn + // the full 15s budget (they are NOT plumbed from the POST body). + + test("browser connect surfaces an unreachable cdp endpoint as 400", async () => { + const config = testConfig("browser-connect-unreachable-cdp"); + const handler = createHandler(config); + const prevTimeout = process.env.GINI_CDP_PROBE_TIMEOUT_MS; + const prevInterval = process.env.GINI_CDP_PROBE_INTERVAL_MS; + process.env.GINI_CDP_PROBE_TIMEOUT_MS = "40"; + process.env.GINI_CDP_PROBE_INTERVAL_MS = "10"; + try { + const response = await rawCall( + handler, + config, + "/api/browser/connect", + // Port 1 is reserved and never listening, so the probe always fails. + { method: "POST", body: JSON.stringify({ cdpUrl: "ws://127.0.0.1:1/devtools/browser/abc" }) }, + config.token + ); + expect(response.status).toBe(400); + const body = await response.json(); + expect(String(body.error ?? body.message)).toContain("Could not reach CDP endpoint"); + expect(readState(config.instance).browser ?? null).toBeNull(); + } finally { + if (prevTimeout === undefined) delete process.env.GINI_CDP_PROBE_TIMEOUT_MS; + else process.env.GINI_CDP_PROBE_TIMEOUT_MS = prevTimeout; + if (prevInterval === undefined) delete process.env.GINI_CDP_PROBE_INTERVAL_MS; + else process.env.GINI_CDP_PROBE_INTERVAL_MS = prevInterval; + } + }); + + + test("PATCH /api/settings/auto-approve rejects out-of-union approvalMode with 400", async () => { + // An invalid value previously mapped to undefined and the PATCH + // silently no-op'd while returning 200 — the client thought it + // succeeded. Mirror job-level strict validation at the HTTP + // boundary so misconfigured clients get a loud failure. + const config = testConfig("settings-bad-approval-mode"); + const handler = createHandler(config); + const response = await rawCall( + handler, + config, + "/api/settings/auto-approve", + { + method: "PATCH", + body: JSON.stringify({ approvalMode: "bogus" }) + }, + config.token + ); + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.error).toMatch(/approvalMode must be one of/); + expect(body.validValues).toEqual(["strict", "auto", "yolo"]); + // Original value on the config object must not have changed. + expect(config.approvalMode).toBe("strict"); + }); + + + test("POST /api/browser/wipe-profile is no longer routed", async () => { + const config = testConfig("browser-wipe-removed"); + const handler = createHandler(config); + const response = await rawCall( + handler, + config, + "/api/browser/wipe-profile", + { method: "POST" }, + config.token + ); + expect(response.status).toBe(404); + }); + + + test("GET /api/browser reports the stable disconnected status", async () => { + const config = testConfig("browser-status"); + const handler = createHandler(config); + const response = await rawCall(handler, config, "/api/browser", {}, config.token); + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.connected).toBe(false); + }); + + + test("stamps the active agent on records and filters listings by agentId", async () => { + const config = testConfig("records-agentid"); + const handler = createHandler(config); + + // Two agents — submit a task under each so we have heterogeneous rows. + const initial = await call(handler, config, "/api/agents"); + const defaultAgentId = initial.activeAgentId as string; + const second = await call(handler, config, "/api/agents", { + method: "POST", + body: JSON.stringify({ name: "scout" }) + }); + + // Task under the default agent. We use `read README.md` so runTask + // dispatches a real low-risk file tool and the task lands in a terminal + // state before the test ends — avoids a background failTask firing + // after the test's state file has been cleaned up by the next test. + config.workspaceRoot = process.cwd(); + const defaultTask = await call(handler, config, "/api/tasks", { + method: "POST", + body: JSON.stringify({ input: "read README.md" }) + }); + expect(defaultTask.agentId).toBe(defaultAgentId); + await waitForTask(handler, config, defaultTask.id); + + await call(handler, config, `/api/agents/${second.id}/use`, { method: "POST" }); + const scoutTask = await call(handler, config, "/api/tasks", { + method: "POST", + body: JSON.stringify({ input: "read README.md" }) + }); + expect(scoutTask.agentId).toBe(second.id); + await waitForTask(handler, config, scoutTask.id); + + // Unfiltered listing includes both rows. + const all = await call(handler, config, "/api/tasks"); + expect(all.some((task: { id: string }) => task.id === defaultTask.id)).toBe(true); + expect(all.some((task: { id: string }) => task.id === scoutTask.id)).toBe(true); + + // Filtered listing returns only the matching agent's rows. + const scoutOnly = await call(handler, config, `/api/tasks?agentId=${encodeURIComponent(second.id)}`); + expect(scoutOnly.every((task: { agentId?: string }) => task.agentId === second.id)).toBe(true); + expect(scoutOnly.some((task: { id: string }) => task.id === scoutTask.id)).toBe(true); + expect(scoutOnly.some((task: { id: string }) => task.id === defaultTask.id)).toBe(false); + + // Empty string is treated as "no filter" — preserves legacy behavior. + const empty = await call(handler, config, "/api/tasks?agentId="); + expect(empty.length).toBe(all.length); + }); + + + test("stamps the active agent on chat sessions and filters by agentId", async () => { + const config = testConfig("records-agentid-chat"); + const handler = createHandler(config); + const initial = await call(handler, config, "/api/agents"); + const defaultAgentId = initial.activeAgentId as string; + const second = await call(handler, config, "/api/agents", { + method: "POST", + body: JSON.stringify({ name: "scout" }) + }); + const sessionA = await call(handler, config, "/api/chat", { + method: "POST", + body: JSON.stringify({ title: "under default" }) + }); + expect(sessionA.agentId).toBe(defaultAgentId); + await call(handler, config, `/api/agents/${second.id}/use`, { method: "POST" }); + const sessionB = await call(handler, config, "/api/chat", { + method: "POST", + body: JSON.stringify({ title: "under scout" }) + }); + expect(sessionB.agentId).toBe(second.id); + const scopedDefault = await call(handler, config, `/api/chat?agentId=${encodeURIComponent(defaultAgentId)}`); + expect(scopedDefault.some((s: { id: string }) => s.id === sessionA.id)).toBe(true); + expect(scopedDefault.some((s: { id: string }) => s.id === sessionB.id)).toBe(false); + const scopedScout = await call(handler, config, `/api/chat?agentId=${encodeURIComponent(second.id)}`); + expect(scopedScout.some((s: { id: string }) => s.id === sessionB.id)).toBe(true); + expect(scopedScout.some((s: { id: string }) => s.id === sessionA.id)).toBe(false); + }); + + + test("stamps the active agent on jobs and filters job listings", async () => { + const config = testConfig("records-agentid-jobs"); + const handler = createHandler(config); + const initial = await call(handler, config, "/api/agents"); + const defaultAgentId = initial.activeAgentId as string; + const second = await call(handler, config, "/api/agents", { + method: "POST", + body: JSON.stringify({ name: "scout" }) + }); + const jobA = await call(handler, config, "/api/jobs", { + method: "POST", + body: JSON.stringify({ name: "default-job", prompt: "hello", intervalSeconds: 3600 }) + }); + expect(jobA.agentId).toBe(defaultAgentId); + await call(handler, config, `/api/agents/${second.id}/use`, { method: "POST" }); + const jobB = await call(handler, config, "/api/jobs", { + method: "POST", + body: JSON.stringify({ name: "scout-job", prompt: "hi", intervalSeconds: 3600 }) + }); + expect(jobB.agentId).toBe(second.id); + const scoped = await call(handler, config, `/api/jobs?agentId=${encodeURIComponent(defaultAgentId)}`); + expect(scoped.every((j: { agentId?: string }) => j.agentId === defaultAgentId)).toBe(true); + expect(scoped.some((j: { id: string }) => j.id === jobA.id)).toBe(true); + expect(scoped.some((j: { id: string }) => j.id === jobB.id)).toBe(false); + }); + + + test("stamps the active agent on subagents and filters by agentId", async () => { + const config = testConfig("records-agentid-subagents"); + const handler = createHandler(config); + const initial = await call(handler, config, "/api/agents"); + const defaultAgentId = initial.activeAgentId as string; + const second = await call(handler, config, "/api/agents", { + method: "POST", + body: JSON.stringify({ name: "scout" }) + }); + const subA = await call(handler, config, "/api/subagents", { + method: "POST", + body: JSON.stringify({ name: "child-default", prompt: "report" }) + }); + expect(subA.agentId).toBe(defaultAgentId); + await call(handler, config, `/api/agents/${second.id}/use`, { method: "POST" }); + const subB = await call(handler, config, "/api/subagents", { + method: "POST", + body: JSON.stringify({ name: "child-scout", prompt: "report" }) + }); + expect(subB.agentId).toBe(second.id); + const scoped = await call(handler, config, `/api/subagents?agentId=${encodeURIComponent(second.id)}`); + expect(scoped.every((s: { agentId?: string }) => s.agentId === second.id)).toBe(true); + expect(scoped.some((s: { id: string }) => s.id === subB.id)).toBe(true); + expect(scoped.some((s: { id: string }) => s.id === subA.id)).toBe(false); + }); + + + test("subagent inherits the parent task's agent even when the active agent switched", async () => { + const config = testConfig("records-agentid-subagent-inherit"); + const handler = createHandler(config); + const initial = await call(handler, config, "/api/agents"); + const defaultAgentId = initial.activeAgentId as string; + const second = await call(handler, config, "/api/agents", { + method: "POST", + body: JSON.stringify({ name: "scout" }) + }); + // Submit a parent task under the default agent so the resulting parent + // task carries agentId=default. + const parentTask = await call(handler, config, "/api/tasks", { + method: "POST", + body: JSON.stringify({ input: "echo parent" }) + }); + await waitForTask(handler, config, parentTask.id); + // Switch the active agent *before* spawning the subagent. The child + // should still inherit the parent's agent id (default), not the active + // agent (scout). Regression test for the inheritance bug where + // spawnSubagent read `resolveEffectiveContext(...).agentId` directly. + await call(handler, config, `/api/agents/${second.id}/use`, { method: "POST" }); + const child = await call(handler, config, "/api/subagents", { + method: "POST", + body: JSON.stringify({ + name: "child", + prompt: "echo child", + parentTaskId: parentTask.id + }) + }); + expect(child.agentId).toBe(defaultAgentId); + // The child task spawned by the subagent path should also carry the + // parent's agent — not the active agent at the moment of spawn. + await waitForTask(handler, config, child.taskId); + const childDetail = await call(handler, config, `/api/tasks/${child.taskId}`); + expect(childDetail.task.agentId).toBe(defaultAgentId); + }); + + + test("approvals inherit agentId from the originating task and filter by agentId", async () => { + const config = testConfig("records-agentid-approvals"); + // The patch flow writes through workspaceRoot; point at the repo so the + // pre-image read in `patch README.md ::` succeeds. + config.workspaceRoot = process.cwd(); + const handler = createHandler(config); + const initial = await call(handler, config, "/api/agents"); + const defaultAgentId = initial.activeAgentId as string; + const second = await call(handler, config, "/api/agents", { + method: "POST", + body: JSON.stringify({ name: "scout" }) + }); + // Submit a patch task under the default agent — the agent loop blocks + // on file.patch until approval, and createApproval inherits agentId + // from the originating task. + const task = await call(handler, config, "/api/tasks", { + method: "POST", + body: JSON.stringify({ input: "patch README.md :: Gini => Gini" }) + }); + await waitForTask(handler, config, task.id); + // Switch the active agent *before* asserting. The approval was already + // created under the originating task, so it must carry the default + // agent's id regardless of whoever is active now. + await call(handler, config, `/api/agents/${second.id}/use`, { method: "POST" }); + const approvals = readState(config.instance).authorizations.filter((a) => a.taskId === task.id); + expect(approvals.length).toBeGreaterThan(0); + expect(approvals.every((a) => a.agentId === defaultAgentId)).toBe(true); + const scopedDefault = await call(handler, config, `/api/authorizations?agentId=${encodeURIComponent(defaultAgentId)}`); + expect(scopedDefault.every((a: { agentId?: string }) => a.agentId === defaultAgentId)).toBe(true); + expect(scopedDefault.some((a: { taskId?: string }) => a.taskId === task.id)).toBe(true); + const scopedScout = await call(handler, config, `/api/authorizations?agentId=${encodeURIComponent(second.id)}`); + expect(scopedScout.some((a: { taskId?: string }) => a.taskId === task.id)).toBe(false); + }); + + + test("stamps the active agent on events and audit and filters listings", async () => { + const config = testConfig("records-agentid-events"); + const handler = createHandler(config); + const initial = await call(handler, config, "/api/agents"); + const defaultAgentId = initial.activeAgentId as string; + const second = await call(handler, config, "/api/agents", { + method: "POST", + body: JSON.stringify({ name: "scout" }) + }); + // The agent.activated audit/event for the second agent should be tagged + // with its id — the runtime stamps the active agent via inferAgentId. + await call(handler, config, `/api/agents/${second.id}/use`, { method: "POST" }); + const events = await call(handler, config, `/api/events?agentId=${encodeURIComponent(second.id)}`); + expect(events.every((e: { agentId?: string }) => e.agentId === second.id)).toBe(true); + expect(events.some((e: { action: string }) => e.action === "agent.activated")).toBe(true); + const defaultEvents = await call(handler, config, `/api/events?agentId=${encodeURIComponent(defaultAgentId)}`); + expect(defaultEvents.every((e: { agentId?: string }) => e.agentId === defaultAgentId)).toBe(true); + const audit = await call(handler, config, `/api/audit?agentId=${encodeURIComponent(second.id)}`); + expect(audit.every((a: { agentId?: string }) => a.agentId === second.id)).toBe(true); + }); + + + test("migrateRecordAgentIds is idempotent across repeated reads", async () => { + const config = testConfig("records-agentid-idempotent"); + const handler = createHandler(config); + // Seed an unstamped task to force a backfill on the first read. + await mutateState(config.instance, (state) => { + state.tasks.unshift({ + id: "task_legacy_one", + title: "legacy", + input: "legacy task", + status: "completed", + instance: state.instance, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + tracePath: "", + auditIds: [], + approvalIds: [], + skillIds: [] + }); + }); + // Trigger reads to run normalizeState multiple times. + await call(handler, config, "/api/tasks"); + await call(handler, config, "/api/tasks"); + await call(handler, config, "/api/tasks"); + const audit = await call(handler, config, "/api/audit"); + const backfills = audit.filter((row: { action: string }) => row.action === "records.agentid.backfill"); + // Exactly one backfill row should exist regardless of how many reads. + expect(backfills.length).toBe(1); + }); + + + test("scheduled job fired after agent switch attributes the task to the originating agent", async () => { + const config = testConfig("records-agentid-job-fire"); + const handler = createHandler(config); + config.workspaceRoot = process.cwd(); + const initial = await call(handler, config, "/api/agents"); + const defaultAgentId = initial.activeAgentId as string; + const second = await call(handler, config, "/api/agents", { + method: "POST", + body: JSON.stringify({ name: "scout" }) + }); + // Create the job under the default agent. Use a read tool so the + // spawned task can settle into a terminal state inside the test window + // — keeps the runtime from logging a "Task not found" against a stale + // state file after the next test cleans up. + const job = await call(handler, config, "/api/jobs", { + method: "POST", + body: JSON.stringify({ name: "owner-test", prompt: "read README.md", intervalSeconds: 3600 }) + }); + expect(job.agentId).toBe(defaultAgentId); + // Switch the active agent *before* the job fires. + await call(handler, config, `/api/agents/${second.id}/use`, { method: "POST" }); + // Fire the job manually (the dispatch path is shared with the scheduler). + const fired = await call(handler, config, `/api/jobs/${job.id}/run`, { method: "POST" }); + expect(fired.taskId).toBeString(); + // Wait for the resulting task to settle so its async tail doesn't + // outlive the test and trip a "Task not found" failure on a later + // test's state-file cleanup. + await waitForTask(handler, config, fired.taskId); + const detail = await call(handler, config, `/api/tasks/${fired.taskId}`); + expect(detail.task.agentId).toBe(defaultAgentId); + expect(detail.task.jobId).toBe(job.id); + }); + + + test("POST /api/jobs ignores agentId in the request body", async () => { + // Regression: the public input bag previously honored a caller-supplied + // `agentId`, letting a malicious or buggy client attribute new jobs to + // any agent. Now the HTTP path strips it and the runtime falls back to + // the active agent. + const config = testConfig("records-agentid-job-untrusted-input"); + const handler = createHandler(config); + const initial = await call(handler, config, "/api/agents"); + const defaultAgentId = initial.activeAgentId as string; + const second = await call(handler, config, "/api/agents", { + method: "POST", + body: JSON.stringify({ name: "scout" }) + }); + // While the active agent is still the default, post a job whose body + // tries to spoof attribution to the scout agent. + const job = await call(handler, config, "/api/jobs", { + method: "POST", + body: JSON.stringify({ + name: "spoof-job", + prompt: "hello", + intervalSeconds: 3600, + agentId: second.id + }) + }); + expect(job.agentId).toBe(defaultAgentId); + expect(job.agentId).not.toBe(second.id); + }); + + + test("job lifecycle audits carry the originating job's agent across a switch", async () => { + // Regression: addAudit's inferAgentId previously had no jobId fallback + // and the lifecycle audit writes in src/jobs/index.ts didn't pass + // agentId, so a paused/updated/removed audit after an agent switch + // misattributed the row to the new active agent. + const config = testConfig("records-agentid-job-audits"); + const handler = createHandler(config); + const initial = await call(handler, config, "/api/agents"); + const defaultAgentId = initial.activeAgentId as string; + const second = await call(handler, config, "/api/agents", { + method: "POST", + body: JSON.stringify({ name: "scout" }) + }); + const job = await call(handler, config, "/api/jobs", { + method: "POST", + body: JSON.stringify({ name: "audit-job", prompt: "hello", intervalSeconds: 3600 }) + }); + expect(job.agentId).toBe(defaultAgentId); + // Switch the active agent *before* exercising the lifecycle transitions. + await call(handler, config, `/api/agents/${second.id}/use`, { method: "POST" }); + await call(handler, config, `/api/jobs/${job.id}/pause`, { method: "POST" }); + await call(handler, config, `/api/jobs/${job.id}/resume`, { method: "POST" }); + await call(handler, config, `/api/jobs/${job.id}`, { + method: "PATCH", + body: JSON.stringify({ intervalSeconds: 7200 }) + }); + await call(handler, config, `/api/jobs/${job.id}`, { method: "DELETE" }); + const state = readState(config.instance); + const targeted = state.audit.filter((a) => a.target === job.id); + const lifecycle = targeted.filter((a) => + a.action === "job.paused" + || a.action === "job.active" + || a.action === "job.updated" + || a.action === "job.removed" + ); + expect(lifecycle.length).toBeGreaterThanOrEqual(4); + expect(lifecycle.every((a) => a.agentId === defaultAgentId)).toBe(true); + }); + + + test("chat message under a session keeps the session's agent across all emitted events", async () => { + // Regression: createRun and createPlanStep previously emitted events + // without an agentId. With the session bound to agent A, sending a + // message after switching the active agent to B would mis-stamp the + // run/step events to B even though the task itself inherits A. + const config = testConfig("records-agentid-chat-message"); + config.workspaceRoot = process.cwd(); + const handler = createHandler(config); + const initial = await call(handler, config, "/api/agents"); + const defaultAgentId = initial.activeAgentId as string; + const second = await call(handler, config, "/api/agents", { + method: "POST", + body: JSON.stringify({ name: "scout" }) + }); + const session = await call(handler, config, "/api/chat", { + method: "POST", + body: JSON.stringify({ title: "owned by default" }) + }); + expect(session.agentId).toBe(defaultAgentId); + await call(handler, config, `/api/agents/${second.id}/use`, { method: "POST" }); + const submitted = await call(handler, config, `/api/chat/${session.id}/messages`, { + method: "POST", + body: JSON.stringify({ content: "read README.md" }) + }); + await waitForTask(handler, config, submitted.taskId); + const task = (await call(handler, config, `/api/tasks/${submitted.taskId}`)).task; + expect(task.agentId).toBe(defaultAgentId); + // Every event for this run should carry the original session's agent — + // not the now-active scout agent. + const state = readState(config.instance); + const runEvents = state.events.filter((e) => e.runId === submitted.runId); + expect(runEvents.length).toBeGreaterThan(0); + expect(runEvents.every((e) => e.agentId === defaultAgentId)).toBe(true); + // The run.created and run.step.created events should specifically be + // present and tagged. + expect(runEvents.some((e) => e.action === "run.created")).toBe(true); + expect(runEvents.some((e) => e.action === "run.step.created")).toBe(true); + }); + + + describe("identity-files routes", () => { + test("GET /api/identity-files returns INSTRUCTIONS.md, USER.md, and SOULs with budget metadata", async () => { + const config = testConfig("identity-show"); + const handler = createHandler(config); + // Seed USER.md so the budget snapshot is meaningful. + const { writeUserProfile, scaffoldInstanceIdentityFiles } = await import("./runtime/identity-files"); + scaffoldInstanceIdentityFiles(config.instance); + writeUserProfile(config.instance, "## Identity\n- Name: TestUser", "approved"); + const dump = await call(handler, config, "/api/identity-files"); + expect(dump.instance).toBe(config.instance); + expect(dump.userProfile.content).toContain("Name: TestUser"); + expect(dump.userProfile.cap).toBe(1500); + expect(dump.userProfile.budget.used).toBeGreaterThan(0); + expect(dump.userProfile.budget.overCap).toBe(false); + // INSTRUCTIONS.md is materialized by scaffold; the route returns + // its content trimmed. + expect(dump.instructions.content).toMatch(/You are a personal agent running on the gini-agent framework\./); + }); + + test("GET /api/identity-files/history?kind=user returns snapshots newest-first", async () => { + const config = testConfig("identity-history"); + const handler = createHandler(config); + const { writeUserProfile } = await import("./runtime/identity-files"); + writeUserProfile(config.instance, "v1", "approved"); + writeUserProfile(config.instance, "v2", "approved"); + writeUserProfile(config.instance, "v3", "approved"); + const out = await call(handler, config, "/api/identity-files/history?kind=user"); + expect(out.kind).toBe("user"); + // Three writes → two snapshots in history (first write has nothing + // to roll back to). + expect(out.entries.length).toBe(2); + // Each entry carries a path-safe name and a positive size. + for (const entry of out.entries) { + expect(entry.name).toMatch(/\.md$/); + expect(entry.sizeBytes).toBeGreaterThan(0); + } + }); + + test("POST /api/identity-files/rollback restores from a snapshot and emits an audit row", async () => { + const config = testConfig("identity-rollback"); + const handler = createHandler(config); + const { writeUserProfile, listUserProfileHistory, userProfilePath } = await import("./runtime/identity-files"); + writeUserProfile(config.instance, "v1 body", "approved"); + writeUserProfile(config.instance, "v2 body", "approved"); + writeUserProfile(config.instance, "v3 body", "approved"); + const history = listUserProfileHistory(config.instance); + const v1Snap = history.find((e) => readFileSync(e.path, "utf8") === "v1 body"); + expect(v1Snap).toBeDefined(); + const result = await call(handler, config, "/api/identity-files/rollback", { + method: "POST", + body: JSON.stringify({ kind: "user", snapshot: v1Snap!.name }) + }); + expect(result.ok).toBe(true); + expect(result.restoredBytes).toBe(Buffer.byteLength("v1 body", "utf8")); + // The active USER.md now holds the rolled-back body. + expect(readFileSync(userProfilePath(config.instance), "utf8")).toBe("v1 body"); + // Audit row recorded the rollback. + const state = readState(config.instance); + const audit = state.audit.find((a) => a.action === "identity.user_profile.rollback"); + expect(audit).toBeDefined(); + // Pre-rollback snapshot was created so the rollback is itself + // reversible. + expect(result.preRestoreSnapshot).not.toBeNull(); + }); + + test("POST /api/identity-files/rollback rejects an unknown snapshot name with reason='no snapshot'", async () => { + const config = testConfig("identity-rollback-unknown"); + const handler = createHandler(config); + const { writeUserProfile } = await import("./runtime/identity-files"); + writeUserProfile(config.instance, "v1", "approved"); + const result = await call(handler, config, "/api/identity-files/rollback", { + method: "POST", + body: JSON.stringify({ kind: "user", snapshot: "2099-01-01T00-00-00.000Z.md" }) + }); + expect(result.ok).toBe(false); + expect(result.reason).toBe("no snapshot"); + }); + }); + + describe("push preview endpoint", () => { + test("GET /api/push/preview returns the latest assistant reply for a completed message", async () => { + const config = testConfig("push-preview-message"); + const handler = createHandler(config); + const session = await call(handler, config, "/api/chat", { + method: "POST", + body: JSON.stringify({ title: "Morning briefing" }) + }); + const submitted = await call(handler, config, `/api/chat/${session.id}/messages`, { + method: "POST", + body: JSON.stringify({ content: "what's up" }) + }); + await waitForTask(handler, config, submitted.taskId); + + const preview = await call( + handler, + config, + `/api/push/preview?sessionId=${session.id}&event=message_completed` + ); + expect(preview.title).toBe("Morning briefing"); + // The echo provider replies with the user's text; the body must be + // the actual reply, NOT a generic "Tap to read" string. + expect(typeof preview.body).toBe("string"); + expect(preview.body.length).toBeGreaterThan(0); + expect(preview.body).not.toBe("Tap to read"); + }); + + test("GET /api/push/preview 404s when the session has no assistant message yet", async () => { + const config = testConfig("push-preview-empty"); + const handler = createHandler(config); + const session = await call(handler, config, "/api/chat", { + method: "POST", + body: JSON.stringify({ title: "Empty chat" }) + }); + const res = await rawCall( + handler, + config, + `/api/push/preview?sessionId=${session.id}&event=message_completed`, + {}, + config.token + ); + expect(res.status).toBe(404); + }); + + test("GET /api/push/preview surfaces a pending authorization's risk + summary", async () => { + const config = testConfig("push-preview-approval"); + const handler = createHandler(config); + const session = await call(handler, config, "/api/chat", { + method: "POST", + body: JSON.stringify({ title: "Deploy bot" }) + }); + const { createAuthorization } = await import("./state"); + const approval = await mutateState(config.instance, (state) => + createAuthorization(state, { + action: "terminal.exec", + target: "rm -rf build", + risk: "high", + reason: "Clear the stale build cache", + payload: {} + }) + ); + + const preview = await call( + handler, + config, + `/api/push/preview?sessionId=${session.id}&event=authorization_requested&approvalId=${approval.id}` + ); + expect(preview.title).toBe("Approve in Deploy bot?"); + expect(preview.body).toBe("[high] Clear the stale build cache"); + }); + + test("GET /api/push/preview surfaces a pending setup request's ask", async () => { + const config = testConfig("push-preview-setup"); + const handler = createHandler(config); + const session = await call(handler, config, "/api/chat", { + method: "POST", + body: JSON.stringify({ title: "Email watch" }) + }); + const { createSetupRequest } = await import("./state"); + const setup = await mutateState(config.instance, (state) => + createSetupRequest(state, { + action: "browser.connect", + target: "https://example.com/login", + reason: "Sign in to your email provider", + payload: {} + }) + ); + + const preview = await call( + handler, + config, + `/api/push/preview?sessionId=${session.id}&event=setup_requested&approvalId=${setup.id}` + ); + expect(preview.title).toBe("Finish a step in Email watch"); + expect(preview.body).toBe("Sign in to your email provider"); + }); + + test("GET /api/push/preview validates inputs and auth", async () => { + const config = testConfig("push-preview-validation"); + const handler = createHandler(config); + const session = await call(handler, config, "/api/chat", { + method: "POST", + body: JSON.stringify({ title: "Validation chat" }) + }); + + // Unauthenticated. + const noAuth = await rawCall( + handler, + config, + `/api/push/preview?sessionId=${session.id}&event=message_completed` + ); + expect(noAuth.status).toBe(401); + + // Missing sessionId. + const noSession = await rawCall( + handler, + config, + `/api/push/preview?event=message_completed`, + {}, + config.token + ); + expect(noSession.status).toBe(400); + + // Unknown event. + const badEvent = await rawCall( + handler, + config, + `/api/push/preview?sessionId=${session.id}&event=bogus`, + {}, + config.token + ); + expect(badEvent.status).toBe(400); + + // Unknown session. + const badSession = await rawCall( + handler, + config, + `/api/push/preview?sessionId=chat_nope&event=message_completed`, + {}, + config.token + ); + expect(badSession.status).toBe(404); + }); + + test("POST /api/push/unwatch (no sessionId) clears the whole device bucket", async () => { + const config = testConfig("push-unwatch"); + const handler = createHandler(config); + // Register the device so requireDeviceToken accepts the header. + await call(handler, config, "/api/push/devices", { + method: "POST", + body: JSON.stringify({ token: "tok_unwatch", platform: "ios", bundleId: "ai.lilaclabs.gini.mobile" }) + }); + const { addSseSubscription, isDeviceWatching } = await import("./state"); + const { __resetSseSubscriptionsForTests } = await import("./state/sse-subscriptions"); + try { + // Seed two watched sessions for this device (as if it had opened + // two chats), plus one for a different device that must survive. + addSseSubscription(config.instance, "tok_unwatch", "chat_a"); + addSseSubscription(config.instance, "tok_unwatch", "chat_b"); + addSseSubscription(config.instance, "tok_other", "chat_a"); + expect(isDeviceWatching(config.instance, "tok_unwatch", "chat_a")).toBe(true); + + // No sessionId → background beacon → clear everything for the device. + const res = await call(handler, config, "/api/push/unwatch", { + method: "POST", + headers: { "x-device-token": "tok_unwatch" } + }); + expect(res.ok).toBe(true); + expect(res.cleared).toBe(2); + expect(isDeviceWatching(config.instance, "tok_unwatch", "chat_a")).toBe(false); + expect(isDeviceWatching(config.instance, "tok_unwatch", "chat_b")).toBe(false); + // The other device is untouched. + expect(isDeviceWatching(config.instance, "tok_other", "chat_a")).toBe(true); + } finally { + // Don't leak seeded entries into the process-wide registry. + __resetSseSubscriptionsForTests(); + } + }); + + test("POST /api/push/unwatch?sessionId clears only that session", async () => { + const config = testConfig("push-unwatch-session"); + const handler = createHandler(config); + await call(handler, config, "/api/push/devices", { + method: "POST", + body: JSON.stringify({ token: "tok_nav", platform: "ios", bundleId: "ai.lilaclabs.gini.mobile" }) + }); + const { addSseSubscription, isDeviceWatching } = await import("./state"); + const { __resetSseSubscriptionsForTests } = await import("./state/sse-subscriptions"); + try { + // Device watches two chats; navigating away from chat_a must leave + // chat_b watched (the just-opened chat mustn't be race-cleared). + addSseSubscription(config.instance, "tok_nav", "chat_a"); + addSseSubscription(config.instance, "tok_nav", "chat_b"); + + const res = await call(handler, config, "/api/push/unwatch?sessionId=chat_a", { + method: "POST", + headers: { "x-device-token": "tok_nav" } + }); + expect(res.ok).toBe(true); + expect(res.cleared).toBe(1); + expect(isDeviceWatching(config.instance, "tok_nav", "chat_a")).toBe(false); + expect(isDeviceWatching(config.instance, "tok_nav", "chat_b")).toBe(true); + } finally { + __resetSseSubscriptionsForTests(); + } + }); + + test("POST /api/push/unwatch?sessionId&streamId clears only that stream, not a sibling on the same session", async () => { + const config = testConfig("push-unwatch-stream"); + const handler = createHandler(config); + await call(handler, config, "/api/push/devices", { + method: "POST", + body: JSON.stringify({ token: "tok_stream", platform: "ios", bundleId: "ai.lilaclabs.gini.mobile" }) + }); + const { addSseSubscription, isDeviceWatching } = await import("./state"); + const { __resetSseSubscriptionsForTests } = await import("./state/sse-subscriptions"); + try { + // Thread View (card over the main chat) and the main chat both open + // a stream on the SAME session, each with its own streamId. Tearing + // down the thread must leave the main chat's watch intact. + addSseSubscription(config.instance, "tok_stream", "chat_a", "stream_main"); + addSseSubscription(config.instance, "tok_stream", "chat_a", "stream_thread"); + + const res = await call( + handler, + config, + "/api/push/unwatch?sessionId=chat_a&streamId=stream_thread", + { method: "POST", headers: { "x-device-token": "tok_stream" } } + ); + expect(res.ok).toBe(true); + expect(res.cleared).toBe(1); + // The main chat's stream is still registered → session still watched. + expect(isDeviceWatching(config.instance, "tok_stream", "chat_a")).toBe(true); + } finally { + __resetSseSubscriptionsForTests(); + } + }); + + test("POST /api/push/unwatch requires auth + a registered device token", async () => { + const config = testConfig("push-unwatch-auth"); + const handler = createHandler(config); + // Unauthenticated. + const noAuth = await rawCall(handler, config, "/api/push/unwatch", { method: "POST" }); + expect(noAuth.status).toBe(401); + // Authenticated but no X-Device-Token header. + const noDevice = await rawCall(handler, config, "/api/push/unwatch", { method: "POST" }, config.token); + expect(noDevice.status).toBe(400); + // Authenticated with an unregistered device token. + const badDevice = await rawCall( + handler, + config, + "/api/push/unwatch", + { method: "POST", headers: { "x-device-token": "tok_ghost" } }, + config.token + ); + expect(badDevice.status).toBe(403); + }); + }); +}); + +async function call(handler: ReturnType, config: RuntimeConfig, path: string, init: RequestInit = {}) { + return callWithToken(handler, config, config.token, path, init); +} + +async function callWithToken(handler: ReturnType, config: RuntimeConfig, token: string, path: string, init: RequestInit = {}) { + const response = await rawCall(handler, config, path, init, token); + const value = await response.json(); + if (!response.ok) throw new Error(value.error ?? `HTTP ${response.status}`); + return value; +} + +async function rawCall(handler: ReturnType, config: RuntimeConfig, path: string, init: RequestInit = {}, token?: string) { + const response = await handler(new Request(`http://127.0.0.1:${config.port}${path}`, { + ...init, + headers: { "content-type": "application/json", ...(token ? { authorization: `Bearer ${token}` } : {}), ...(init.headers ?? {}) } + })); + return response; +} + +// Opens a chat SSE stream, pumps frames until the initial `chat_session` +// frame lands (which only happens inside the stream's start() — i.e. AFTER +// presence registration), runs the caller's `whileOpen` assertions, then +// ALWAYS cancels the reader in a finally so a thrown assertion can't leak +// the stream (its keepalive interval and presence entry would otherwise +// survive the test). Returns after the reader is cancelled. +async function withChatStream( + handler: ReturnType, + config: RuntimeConfig, + sessionId: string, + init: RequestInit, + whileOpen: () => void +): Promise { + const response = await rawCall(handler, config, `/api/chat/${sessionId}/stream`, init, config.token); + expect(response.status).toBe(200); + const reader = response.body?.getReader(); + expect(reader).toBeDefined(); + if (!reader) return; + try { + const decoder = new TextDecoder(); + let buffer = ""; + const deadline = Date.now() + 500; + while (Date.now() < deadline) { + const { done, value } = await Promise.race([ + reader.read(), + new Promise<{ done: boolean; value: undefined }>((resolve) => + setTimeout(() => resolve({ done: false, value: undefined }), 50) + ) + ]); + if (done) break; + if (value) buffer += decoder.decode(value); + if (buffer.includes("event: chat_session")) break; + } + expect(buffer).toContain("event: chat_session"); + whileOpen(); + } finally { + await reader.cancel(); + } + // cancel() runs the stream's cleanup synchronously in-process; give the + // microtask queue one turn to settle before the caller asserts the + // post-close registry state. + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +function testConfig(instance: string): RuntimeConfig { + const root = `/tmp/gini-http-tests-${import.meta.file}`; + process.env.GINI_STATE_ROOT = root; + process.env.GINI_LOG_ROOT = `${root}-logs`; + // Drop the cached SQLite handle for this instance before nuking the + // directory. Without this, a prior test that opened the per-instance + // memory DB leaves an open `bun:sqlite` handle pointing at the now- + // unlinked file. The next call to getMemoryDb returns that cached + // handle (the cache key is the instance name) and any write fails + // because the inode is gone. removeMemoryDb closes the cached handle + // AND unlinks the file + WAL/SHM siblings in one shot. + removeMemoryDb(instance); + // resumeChatTask polls for the loop's flip to waiting_approval before + // staging a tool result. In-process the flip lands within a couple of + // mutateState boundaries, and several fill_secret / approval tests seed a + // task that never reaches waiting_approval at all — so the production + // 1000ms/100ms budget is pure dead wall here (the fill_secret leak test + // measured 1079.00ms in isolation, nearly all of it this poll). Shrink the + // budget via the server-side env knob the production code reads (default + // preserved at 1000/100); the race still resolves well within 40ms over 5ms + // ticks in-process. + process.env.GINI_RESUME_WAIT_BUDGET_MS = "40"; + process.env.GINI_RESUME_WAIT_TICK_MS = "5"; + rmSync(`${root}/instances/${instance}`, { recursive: true, force: true }); + return { + instance, + port: 7337, + token: "test-token", + provider: { name: "echo", model: "gini-echo-v0" }, + workspaceRoot: "/tmp", + stateRoot: `${root}/instances/${instance}`, + logRoot: `${root}-logs/${instance}`, + // These tests predate the approval-mode flip and rely on the + // gated path. Force "strict" to keep them honest; new defaults + // are exercised in approval-mode.test.ts. + approvalMode: "strict" + }; +} + +// Write a log file under the test config's instance log dir so the /api/logs +// route reads it. testConfig nukes the instance state dir and points +// GINI_LOG_ROOT at a sibling tree, so this lands in a clean per-instance dir. +function seedLogFile(config: RuntimeConfig, filename: string, body: string): void { + const dir = logDir(config.instance); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, filename), body); +} + +// Seed a typed api-key credential so the per-(skill, credential) consent gate +// (firstUngrantedCredential) treats it as carrying a secret that needs consent. +async function seedTypedCredential(config: RuntimeConfig, name: string, provider: string) { + const at = new Date().toISOString(); + await mutateState(config.instance, (state) => { + state.connectors.push({ + id: `id_${name}`, + instance: state.instance, + name, + provider, + type: "api-key", + status: "configured", + scopes: [], + secretRefs: [{ purpose: name, path: `/tmp/${name}.json` }], + createdAt: at, + updatedAt: at, + health: "healthy", + source: "user" + }); + }); +} + +async function waitForTask( + handler: ReturnType, + config: RuntimeConfig, + taskId: string, + // The approve endpoint returns at decision durability while the side + // effect + resume run detached, so a post-approve wait must exclude + // the still-parked waiting_approval status or it returns the + // pre-approve park immediately. + statuses: string[] = ["completed", "failed", "waiting_approval"] +) { + // Phase 5 added auto-recall + auto-retain to runTask. The retain side is + // fire-and-forget so it can't block, but the inline recall + a few extra + // mutateState audits push runTask completion past the original 500ms + // budget on slower hosts. A 200-iteration / 10ms loop = 2s ceiling is + // still well under any reasonable test timeout. + for (let attempt = 0; attempt < 200; attempt += 1) { + const detail = await call(handler, config, `/api/tasks/${taskId}`); + if (statuses.includes(detail.task.status)) return detail; + await Bun.sleep(10); + } + throw new Error(`Task did not settle: ${taskId}`); +} diff --git a/packages/runtime/src/http.part3.test.ts b/packages/runtime/src/http.part3.test.ts new file mode 100644 index 00000000..b4f25a1a --- /dev/null +++ b/packages/runtime/src/http.part3.test.ts @@ -0,0 +1,2261 @@ +import { describe, expect, test } from "bun:test"; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import "./hooks/builtins"; // the email-watch routes provision a backing job, which validates isKnownHook("skill-script") +import { createHandler, resolveInlineUpload } from "./http"; +import { logDir, uploadsDir, webPortPath } from "./paths"; +import { clearWebTargetCache } from "./web-target"; +import { dirname, join } from "node:path"; +import { addAudit, appendEvent, insertChatBlock, isPlausibleMime, mutateState, readState, readTrace, recordProviderAuthFailure, sanitizeFilename, storeUpload, uploadStat } from "./state"; +import { getOrCreateAgentChat } from "./execution/chat"; +import { createScheduledJob } from "./jobs"; +import { removeMemoryDb } from "./state/memory-db"; +import { listProviders } from "./integrations/connectors/registry"; +import { resetGoogleLoginWebState } from "./integrations/connectors/google-login-web"; +import { awaitTunnelSettled, setTunnelDeps, type TunnelChild } from "./integrations/tunnel"; +import type { RuntimeConfig } from "./types"; +import type { LoginHandle, RelayDefaults, Session, Store, TunnelOptions } from "gini-relay"; + +// Stub a provider's host-environment `detect()` so the connector-detection +// endpoint test stays deterministic AND fast regardless of what's installed +// on the developer's PATH. The production `detect()` for claude-code / codex +// shells out via spawnSync (`which`, `claude auth status`), which on a machine +// with those CLIs installed dominates this test's wall time (the unstubbed +// detect endpoint test measured 1.524641s). Mirrors the same in-place +// swap-and-restore helper used by src/jobs/connector-detection.test.ts. The +// registry is a process-wide singleton, so the returned restore fn MUST run in +// a finally to avoid leaking the stub into sibling tests. +function stubProviderDetect( + providerId: string, + value: { detected: boolean; suggestedName?: string; message?: string } +): () => void { + const provider = listProviders().find((p) => p.id === providerId); + if (!provider) throw new Error(`Provider not registered: ${providerId}`); + const previous = provider.detect; + provider.detect = async () => value; + return () => { + provider.detect = previous; + }; +} + +// Companion to stubProviderDetect. After connector auto-detection creates a +// record for a provider that exposes a `probe()`, runConnectorDetection runs +// an initial checkConnector → provider.probe, which for claude-code shells out +// to `claude auth status` again. Stub the probe so the detection endpoint +// test never touches a real subprocess. Same swap-and-restore discipline. +function stubProviderProbe(providerId: string, value: { ok: boolean; message: string }): () => void { + const provider = listProviders().find((p) => p.id === providerId); + if (!provider) throw new Error(`Provider not registered: ${providerId}`); + const previous = provider.probe; + provider.probe = async () => value; + return () => { + provider.probe = previous; + }; +} + +describe("runtime api", () => { + test("chat session lifecycle events carry the session's agent", async () => { + // Regression: createChatSession / deleteChatSession / renameChatSession + // emitted lifecycle events without an agentId. A session created / + // renamed / deleted while a different agent was active would attribute + // the event to the active agent rather than the session's owner. + const config = testConfig("records-agentid-chat-lifecycle"); + const handler = createHandler(config); + const initial = await call(handler, config, "/api/agents"); + const defaultAgentId = initial.activeAgentId as string; + const second = await call(handler, config, "/api/agents", { + method: "POST", + body: JSON.stringify({ name: "scout" }) + }); + // Create the session under default. + const session = await call(handler, config, "/api/chat", { + method: "POST", + body: JSON.stringify({ title: "lifecycle" }) + }); + expect(session.agentId).toBe(defaultAgentId); + // Switch active to scout, then rename and delete the default-owned + // session — both events should still carry the default's id. + await call(handler, config, `/api/agents/${second.id}/use`, { method: "POST" }); + await call(handler, config, `/api/chat/${session.id}`, { + method: "PATCH", + body: JSON.stringify({ title: "renamed" }) + }); + await call(handler, config, `/api/chat/${session.id}`, { method: "DELETE" }); + const state = readState(config.instance); + const targeted = state.events.filter((e) => e.target === session.id); + const created = targeted.find((e) => e.action === "chat.session.created"); + const renamed = targeted.find((e) => e.action === "chat.session.renamed"); + const deleted = targeted.find((e) => e.action === "chat.session.deleted"); + expect(created?.agentId).toBe(defaultAgentId); + expect(renamed?.agentId).toBe(defaultAgentId); + expect(deleted?.agentId).toBe(defaultAgentId); + }); + + + test("deleting a chat session also clears the per-conversation identity snapshot", async () => { + // Identity snapshots are keyed on conversationId (the chat session id); + // without the cleanup in deleteChatSession each deleted chat leaks one + // IdentitySnapshotRecord into state forever. + const config = testConfig("records-identity-snapshot-cleanup"); + const handler = createHandler(config); + const session = await call(handler, config, "/api/chat", { + method: "POST", + body: JSON.stringify({ title: "snapshot-cleanup" }) + }); + await mutateState(config.instance, (state) => { + if (!state.identitySnapshots) state.identitySnapshots = {}; + state.identitySnapshots[session.id] = { + identity: { + instance: config.instance, + runtimePort: config.port, + agentName: "default", + agentId: "agent_x", + provider: "echo/test", + toolsets: ["file"], + memoryNamespace: "agent_x" + }, + lastFullTurn: 1 + }; + }); + expect(readState(config.instance).identitySnapshots?.[session.id]).toBeDefined(); + await call(handler, config, `/api/chat/${session.id}`, { method: "DELETE" }); + expect(readState(config.instance).identitySnapshots?.[session.id]).toBeUndefined(); + }); + + + test("addAudit infers agentId from jobId when neither agentId nor taskId is provided", async () => { + // Regression: inferAgentId's jobId fallback only fires when the caller + // threads `jobId` (or appendEvent's persisted `jobId`) through. This + // test pins that an audit row created with just jobId resolves to the + // owning job's agent — without it the row would fall back to + // state.activeAgentId after a switch. + const config = testConfig("records-agentid-job-fallback"); + const handler = createHandler(config); + const initial = await call(handler, config, "/api/agents"); + const defaultAgentId = initial.activeAgentId as string; + const second = await call(handler, config, "/api/agents", { + method: "POST", + body: JSON.stringify({ name: "scout" }) + }); + const job = await call(handler, config, "/api/jobs", { + method: "POST", + body: JSON.stringify({ name: "fallback-job", prompt: "hi", intervalSeconds: 3600 }) + }); + // Switch the active agent before emitting the audit. + await call(handler, config, `/api/agents/${second.id}/use`, { method: "POST" }); + await mutateState(config.instance, (state) => { + addAudit( + state, + { + actor: "runtime", + action: "test.job.fallback", + target: job.id, + risk: "low", + evidence: { jobId: job.id } + }, + { jobId: job.id } + ); + }); + const state = readState(config.instance); + const audit = state.audit.find((a) => a.action === "test.job.fallback"); + expect(audit?.agentId).toBe(defaultAgentId); + const paired = state.events.find((e) => e.action === "test.job.fallback"); + expect(paired?.agentId).toBe(defaultAgentId); + }); + + + test("migrateRecordAgentIds re-stamps rows pointing at a deleted agent", async () => { + // Regression: the migration's predicate previously only re-stamped + // rows where agentId was missing. A row carrying the id of a deleted + // agent stayed stranded under an unselectable bucket. Now stale ids + // are treated the same as missing. + const config = testConfig("records-agentid-stale"); + const handler = createHandler(config); + const initial = await call(handler, config, "/api/agents"); + const defaultAgentId = initial.activeAgentId as string; + // Seed records pointing at a ghost agent that doesn't exist in + // state.agents. The next read triggers normalizeState -> + // migrateRecordAgentIds and should re-stamp them with the first + // existing agent (the default). + await mutateState(config.instance, (state) => { + const at = new Date().toISOString(); + state.tasks.unshift({ + id: "task_ghost", + title: "ghost", + input: "ghost task", + status: "completed", + instance: state.instance, + agentId: "agent_ghost", + createdAt: at, + updatedAt: at, + tracePath: "", + auditIds: [], + approvalIds: [], + skillIds: [] + }); + }); + // Trigger the migration via a fresh read. + await call(handler, config, "/api/tasks"); + const stamped = readState(config.instance); + const ghostTask = stamped.tasks.find((t) => t.id === "task_ghost"); + expect(ghostTask?.agentId).toBe(defaultAgentId); + // Re-reading should be idempotent — no further backfill row beyond + // what the first migration produced. + await call(handler, config, "/api/tasks"); + await call(handler, config, "/api/tasks"); + const audit = await call(handler, config, "/api/audit"); + const backfills = audit.filter((row: { action: string }) => row.action === "records.agentid.backfill"); + expect(backfills.length).toBe(1); + }); + + + test("AgentContext resolves each source-id branch deterministically", async () => { + // Pin the AgentContext contract: each branch in the union resolves to + // the agent its source record carries, never to state.activeAgentId. + // We exercise every branch from a single test instance so the + // resolution matrix lives in one place. + const config = testConfig("agent-context-branches"); + const handler = createHandler(config); + const initial = await call(handler, config, "/api/agents"); + const defaultAgentId = initial.activeAgentId as string; + const second = await call(handler, config, "/api/agents", { + method: "POST", + body: JSON.stringify({ name: "scout" }) + }); + // Seed a task, job, session, and memory under the default agent. + const session = await call(handler, config, "/api/chat", { + method: "POST", + body: JSON.stringify({ title: "branch-test" }) + }); + const job = await call(handler, config, "/api/jobs", { + method: "POST", + body: JSON.stringify({ name: "branch-job", prompt: "hi", intervalSeconds: 3600 }) + }); + await mutateState(config.instance, (state) => { + state.tasks.unshift({ + id: "task_branch", + title: "branch", + input: "branch task", + status: "completed", + instance: state.instance, + agentId: defaultAgentId, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + tracePath: "", + auditIds: [], + approvalIds: [], + skillIds: [] + }); + }); + // Switch the active agent so any silent fallback would attribute to + // scout rather than the source record's owner. + await call(handler, config, `/api/agents/${second.id}/use`, { method: "POST" }); + await mutateState(config.instance, (state) => { + // explicit agentId branch + addAudit( + state, + { actor: "runtime", action: "test.branch.agentId", target: "explicit", risk: "low" }, + { agentId: defaultAgentId } + ); + // taskId branch + addAudit( + state, + { actor: "runtime", action: "test.branch.taskId", target: "from-task", risk: "low" }, + { taskId: "task_branch" } + ); + // jobId branch + addAudit( + state, + { actor: "runtime", action: "test.branch.jobId", target: "from-job", risk: "low" }, + { jobId: job.id } + ); + // sessionId branch + addAudit( + state, + { actor: "runtime", action: "test.branch.sessionId", target: "from-session", risk: "low" }, + { sessionId: session.id } + ); + // system: true branch + addAudit( + state, + { actor: "runtime", action: "test.branch.system", target: "system", risk: "low" }, + { system: true } + ); + }); + const audit = readState(config.instance).audit; + expect(audit.find((a) => a.action === "test.branch.agentId")?.agentId).toBe(defaultAgentId); + expect(audit.find((a) => a.action === "test.branch.taskId")?.agentId).toBe(defaultAgentId); + expect(audit.find((a) => a.action === "test.branch.jobId")?.agentId).toBe(defaultAgentId); + expect(audit.find((a) => a.action === "test.branch.sessionId")?.agentId).toBe(defaultAgentId); + expect(audit.find((a) => a.action === "test.branch.system")?.agentId).toBeUndefined(); + }); + + + test("AgentContext is required at the type level for every emitter", () => { + // Pin the type-level invariant: calling appendEvent or addAudit + // without an AgentContext is a compile error, not a silent + // active-agent fallback. The `@ts-expect-error` directives below + // force tsc to confirm the missing third argument is rejected. + // If these comments stop catching an error, someone reintroduced a + // two-argument overload and the whole point of this refactor is + // undone. + // + // We never actually invoke these — the type check is the assertion. + // Wrapping in a `false &&` keeps tsc inspecting the call signature + // while keeping the runtime tree-shake-eligible. + if (false as boolean) { + const state = readState("agent-context-typecheck"); + // @ts-expect-error appendEvent requires an AgentContext as the third argument. + appendEvent(state, { kind: "runtime", action: "no-context", target: "x", risk: "low", summary: "x" }); + // @ts-expect-error addAudit requires an AgentContext as the third argument. + addAudit(state, { actor: "runtime", action: "no-context", target: "x", risk: "low" }); + } + expect(true).toBe(true); + }); + + + test("AgentContext returns undefined when the source record was deleted", async () => { + // The contract says: if a sourceId is provided but the record doesn't + // exist (deleted, race), resolveAgentId returns undefined. It must NOT + // silently fall back to the active agent. + const config = testConfig("agent-context-missing-source"); + const handler = createHandler(config); + await mutateState(config.instance, (state) => { + addAudit( + state, + { actor: "runtime", action: "test.missing.task", target: "x", risk: "low" }, + { taskId: "task_does_not_exist" } + ); + addAudit( + state, + { actor: "runtime", action: "test.missing.job", target: "x", risk: "low" }, + { jobId: "job_does_not_exist" } + ); + addAudit( + state, + { actor: "runtime", action: "test.missing.session", target: "x", risk: "low" }, + { sessionId: "chat_does_not_exist" } + ); + }); + const audit = readState(config.instance).audit; + expect(audit.find((a) => a.action === "test.missing.task")?.agentId).toBeUndefined(); + expect(audit.find((a) => a.action === "test.missing.job")?.agentId).toBeUndefined(); + expect(audit.find((a) => a.action === "test.missing.session")?.agentId).toBeUndefined(); + }); + + + test("POST /api/messaging/:id/allow with a malformed chatId returns 400 (not 500)", async () => { + // parseChatIdStrict throws "Invalid input: chatId must be ..." so + // statusFromErrorMessage maps it to 400. Without the prefix, a + // caller who PUTs `null` or `""` would see "internal error" 500. + const config = testConfig("messaging-allow-bad-chatid"); + const handler = createHandler(config); + const { addMessagingBridge } = await import("./integrations/messaging"); + const bridge = await addMessagingBridge(config, { + name: "tg", + kind: "telegram", + deliveryTargets: ["1"], + botToken: "TOK" + }); + const badPayloads: Array = [null, "", "123abc", "abc", 1.5]; + for (const chatId of badPayloads) { + const response = await rawCall( + handler, + config, + `/api/messaging/${bridge.id}/allow`, + { method: "POST", body: JSON.stringify({ chatId }) }, + config.token + ); + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.error).toMatch(/chatId must be a finite integer/); + } + }); + + + test("POST /api/messaging/:id/allow with a mismatched verification code returns 409 (not 500)", async () => { + // allowChat throws "Verification code mismatch — ..." when the + // operator's UI snapshot lost a race against a fresher DM that + // rotated the pending code. The HTTP layer must map that to 409 + // Conflict (stale-view), not the previous catch-all 500. + const config = testConfig("messaging-allow-code-mismatch"); + const handler = createHandler(config); + const { addMessagingBridge, recordDeniedChatAttempt } = await import("./integrations/messaging"); + const bridge = await addMessagingBridge(config, { + name: "tg", + kind: "telegram", + deliveryTargets: ["1"], + botToken: "TOK" + }); + await recordDeniedChatAttempt(config, bridge.id, { chatId: 42, chatType: "private" }); + const response = await rawCall( + handler, + config, + `/api/messaging/${bridge.id}/allow`, + { + method: "POST", + body: JSON.stringify({ chatId: 42, expectedCode: "ZZ-ZZ-ZZ" }) + }, + config.token + ); + expect(response.status).toBe(409); + const body = await response.json(); + expect(body.error).toMatch(/Verification code mismatch/); + }); + + + test("POST /api/messaging/:id/allow with an expired verification code returns 409 (not 500)", async () => { + // allowChat throws "Verification code for chat ${chatId} has expired + // ..." when the pending code aged past its TTL between page load and + // click. Same 409 contract as the mismatch case so the UI can + // distinguish stale-view conflicts from generic server errors. + const { mutateState } = await import("./state/store"); + const config = testConfig("messaging-allow-code-expired"); + const handler = createHandler(config); + const { addMessagingBridge, recordDeniedChatAttempt } = await import("./integrations/messaging"); + const bridge = await addMessagingBridge(config, { + name: "tg", + kind: "telegram", + deliveryTargets: ["1"], + botToken: "TOK" + }); + const pending = await recordDeniedChatAttempt(config, bridge.id, { + chatId: 99, + chatType: "private" + }); + expect(pending?.verificationCode).toBeTruthy(); + await mutateState(config.instance, (state) => { + const live = state.messagingBridges.find((b) => b.id === bridge.id); + if (!live) return; + const meta = { ...(live.metadata ?? {}) }; + const list = Array.isArray(meta.recentDeniedChats) ? [...meta.recentDeniedChats] : []; + const idx = list.findIndex((entry: { chatId?: number }) => entry?.chatId === 99); + if (idx < 0) return; + list[idx] = { + ...list[idx], + verificationCodeExpiresAt: new Date(Date.now() - 60_000).toISOString() + }; + meta.recentDeniedChats = list; + live.metadata = meta; + }); + const response = await rawCall( + handler, + config, + `/api/messaging/${bridge.id}/allow`, + { + method: "POST", + body: JSON.stringify({ chatId: 99, expectedCode: pending!.verificationCode }) + }, + config.token + ); + expect(response.status).toBe(409); + const body = await response.json(); + expect(body.error).toMatch(/has expired/); + }); + + // ChatBlock protocol endpoints (ADR chat-block-protocol.md). The + // routes are smoke-tested here; deeper assertions on per-block + // shape live in src/state/chat-blocks.test.ts and + // src/execution/chat-task.test.ts. + + test("GET /api/chat/:id/blocks returns ordered ChatBlock list and 404 for missing sessions", async () => { + const config = testConfig("chat-blocks-list"); + const handler = createHandler(config); + const session = await call(handler, config, "/api/chat", { + method: "POST", + body: JSON.stringify({ title: "blocks endpoint smoke" }) + }); + const submitted = await call(handler, config, `/api/chat/${session.id}/messages`, { + method: "POST", + body: JSON.stringify({ content: "please reply" }) + }); + await waitForTask(handler, config, submitted.taskId); + + const blocks = await call(handler, config, `/api/chat/${session.id}/blocks`); + expect(Array.isArray(blocks)).toBe(true); + expect(blocks.length).toBeGreaterThan(0); + expect(blocks[0].kind).toBe("user_text"); + expect(blocks[0].text).toBe("please reply"); + // Ordinals monotonically increase. + const ordinals = blocks.map((b: { ordinal: number }) => b.ordinal); + for (let i = 1; i < ordinals.length; i += 1) { + expect(ordinals[i]).toBeGreaterThan(ordinals[i - 1]!); + } + + const missing = await rawCall( + handler, + config, + `/api/chat/chat_does_not_exist/blocks`, + {}, + config.token + ); + expect(missing.status).toBe(404); + }); + + + test("DELETE /api/chat/:id cascades chat blocks (subsequent /blocks returns 404)", async () => { + const config = testConfig("chat-blocks-cascade"); + const handler = createHandler(config); + const session = await call(handler, config, "/api/chat", { + method: "POST", + body: JSON.stringify({ title: "cascade smoke" }) + }); + await call(handler, config, `/api/chat/${session.id}/messages`, { + method: "POST", + body: JSON.stringify({ content: "blocks should disappear after delete" }) + }); + // Wait for at least the user_text block to land. We don't need the + // assistant turn to finish for this assertion. + let blocks: unknown[] = []; + for (let i = 0; i < 50; i += 1) { + const result = await call(handler, config, `/api/chat/${session.id}/blocks`); + if (Array.isArray(result) && result.length > 0) { + blocks = result; + break; + } + await Bun.sleep(20); + } + expect(blocks.length).toBeGreaterThan(0); + + await call(handler, config, `/api/chat/${session.id}`, { method: "DELETE" }); + + const afterDelete = await rawCall( + handler, + config, + `/api/chat/${session.id}/blocks`, + {}, + config.token + ); + expect(afterDelete.status).toBe(404); + }); + + + test("GET /api/chat/:id/stream returns SSE with chat_block frames", async () => { + const config = testConfig("chat-blocks-stream"); + const handler = createHandler(config); + const session = await call(handler, config, "/api/chat", { + method: "POST", + body: JSON.stringify({ title: "stream smoke" }) + }); + // Pre-publish some blocks so the initial backfill carries data. + const submitted = await call(handler, config, `/api/chat/${session.id}/messages`, { + method: "POST", + body: JSON.stringify({ content: "stream this" }) + }); + await waitForTask(handler, config, submitted.taskId); + + const response = await rawCall( + handler, + config, + `/api/chat/${session.id}/stream`, + {}, + config.token + ); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toContain("text/event-stream"); + // Read just the first frame to confirm the SSE shape; if we + // consumed the whole body we'd block on the keepalive interval. + const reader = response.body?.getReader(); + expect(reader).toBeDefined(); + const decoder = new TextDecoder(); + let buffer = ""; + if (reader) { + // Pump up to ~500ms collecting frames so the backfill arrives. + const deadline = Date.now() + 500; + while (Date.now() < deadline) { + const { done, value } = await Promise.race([ + reader.read(), + new Promise<{ done: boolean; value: undefined }>((resolve) => + setTimeout(() => resolve({ done: false, value: undefined }), 50) + ) + ]); + if (done) break; + if (value) buffer += decoder.decode(value); + if (buffer.includes("user_text")) break; + } + await reader.cancel(); + } + expect(buffer).toContain("event: chat_block"); + expect(buffer).toContain("user_text"); + expect(buffer).toContain("stream this"); + }); + + + test("GET /api/chat/:id/stream emits id frames as :", async () => { + // Pins the SSE wire contract: each chat_block frame's `id:` line + // carries `:`. The mobile/browser client + // round-trips that string as Last-Event-ID on reconnect, and the + // gateway parses the `:` suffix to detect in-place updates on + // the cursor row (see listChatBlocksAfter). A regression that + // strips the suffix would silently break resume semantics for the + // streaming assistant_text case, so we pin the format at the HTTP + // boundary. + const config = testConfig("chat-blocks-stream-id-format"); + const handler = createHandler(config); + const session = await call(handler, config, "/api/chat", { + method: "POST", + body: JSON.stringify({ title: "stream id format" }) + }); + const submitted = await call(handler, config, `/api/chat/${session.id}/messages`, { + method: "POST", + body: JSON.stringify({ content: "id format check" }) + }); + await waitForTask(handler, config, submitted.taskId); + + const response = await rawCall( + handler, + config, + `/api/chat/${session.id}/stream`, + {}, + config.token + ); + expect(response.status).toBe(200); + const reader = response.body?.getReader(); + expect(reader).toBeDefined(); + const decoder = new TextDecoder(); + let buffer = ""; + if (reader) { + const deadline = Date.now() + 500; + while (Date.now() < deadline) { + const { done, value } = await Promise.race([ + reader.read(), + new Promise<{ done: boolean; value: undefined }>((resolve) => + setTimeout(() => resolve({ done: false, value: undefined }), 50) + ) + ]); + if (done) break; + if (value) buffer += decoder.decode(value); + if (buffer.includes("event: chat_block")) break; + } + await reader.cancel(); + } + // Frame shape: `id: :\nevent: chat_block\n...`. + // Block ids are `block_` (no `:`); ISO timestamps look like + // `YYYY-MM-DDTHH:MM:SS.sssZ`. The whole line must match this pattern. + const idLineMatch = buffer.match( + /^id: ([A-Za-z0-9_-]+):(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z)$/m + ); + expect(idLineMatch).not.toBeNull(); + // Sanity: the captured block id portion does not itself contain `:`, + // so splitting on the first colon in listChatBlocksAfter is safe. + expect(idLineMatch?.[1]).not.toContain(":"); + }); + + + test("GET /api/chat/:id/stream emits chat_session frame on initial connect", async () => { + // The mobile client reads the chat-detail header title from the + // session record this frame carries. Without an initial emit, + // there's a window where the header would render "Chat" / the + // first-message fallback even though the gateway already knows + // the canonical title (e.g. on reconnect to an already-renamed + // session). + const config = testConfig("chat-stream-session-initial"); + const handler = createHandler(config); + const session = await call(handler, config, "/api/chat", { + method: "POST", + body: JSON.stringify({ title: "before-rename" }) + }); + await call(handler, config, `/api/chat/${session.id}`, { + method: "PATCH", + body: JSON.stringify({ title: "Renamed in the lobby" }) + }); + + const response = await rawCall( + handler, + config, + `/api/chat/${session.id}/stream`, + {}, + config.token + ); + expect(response.status).toBe(200); + const reader = response.body?.getReader(); + expect(reader).toBeDefined(); + const decoder = new TextDecoder(); + let buffer = ""; + if (reader) { + const deadline = Date.now() + 500; + while (Date.now() < deadline) { + const { done, value } = await Promise.race([ + reader.read(), + new Promise<{ done: boolean; value: undefined }>((resolve) => + setTimeout(() => resolve({ done: false, value: undefined }), 50) + ) + ]); + if (done) break; + if (value) buffer += decoder.decode(value); + if (buffer.includes("event: chat_session")) break; + } + await reader.cancel(); + } + expect(buffer).toContain("event: chat_session"); + expect(buffer).toContain("Renamed in the lobby"); + }); + + + test("GET /api/chat/:id/stream pushes chat_session frame on rename", async () => { + // The auto-rename path (chat-task → autoRenameChatAfterTurn) fires + // after task completion and the mobile chat-detail header must + // pick up the new title without polling. Stand-in for the auto + // case by hitting /rename explicitly — both paths route through + // renameChat → publishChatSession. + const config = testConfig("chat-stream-session-rename"); + const handler = createHandler(config); + const session = await call(handler, config, "/api/chat", { + method: "POST", + body: JSON.stringify({ title: "" }) + }); + + const response = await rawCall( + handler, + config, + `/api/chat/${session.id}/stream`, + {}, + config.token + ); + expect(response.status).toBe(200); + const reader = response.body?.getReader(); + expect(reader).toBeDefined(); + const decoder = new TextDecoder(); + + // Drain the initial chat_session frame (the one emitted on connect) + // so the assertion below targets the rename-driven frame. + let buffer = ""; + if (reader) { + const initialDeadline = Date.now() + 500; + while (Date.now() < initialDeadline) { + const { done, value } = await Promise.race([ + reader.read(), + new Promise<{ done: boolean; value: undefined }>((resolve) => + setTimeout(() => resolve({ done: false, value: undefined }), 50) + ) + ]); + if (done) break; + if (value) buffer += decoder.decode(value); + if (buffer.includes("event: chat_session")) break; + } + // Clear the buffer so we can detect the second emit independently. + buffer = ""; + + // Trigger the publish from another concurrent caller, then drain. + await call(handler, config, `/api/chat/${session.id}`, { + method: "PATCH", + body: JSON.stringify({ title: "Streamed rename" }) + }); + + const deadline = Date.now() + 1000; + while (Date.now() < deadline) { + const { done, value } = await Promise.race([ + reader.read(), + new Promise<{ done: boolean; value: undefined }>((resolve) => + setTimeout(() => resolve({ done: false, value: undefined }), 50) + ) + ]); + if (done) break; + if (value) buffer += decoder.decode(value); + if (buffer.includes("Streamed rename")) break; + } + await reader.cancel(); + } + expect(buffer).toContain("event: chat_session"); + expect(buffer).toContain("Streamed rename"); + }); + + + test("GET /api/chat/:id/stream returns 404 for unknown sessions", async () => { + const config = testConfig("chat-blocks-stream-404"); + const handler = createHandler(config); + const response = await rawCall( + handler, + config, + `/api/chat/chat_unknown/stream`, + {}, + config.token + ); + expect(response.status).toBe(404); + }); + + + test("a tokenless web stream marks the session web-watched until it closes", async () => { + // A web client (no X-Device-Token) opening a chat stream registers on + // the pushless registry so the dispatcher can downgrade the phone's + // completion alert to a silent badge refresh while the human reads on + // the web. Closing the stream clears the entry — so a send-then-close + // leaves the phone eligible for its normal alert. + const config = testConfig("chat-stream-web-presence"); + const handler = createHandler(config); + const { isSessionWebWatched, isDeviceWatching } = await import("./state"); + const session = await call(handler, config, "/api/chat", { + method: "POST", + body: JSON.stringify({ title: "web presence" }) + }); + + expect(isSessionWebWatched(config.instance, session.id)).toBe(false); + + // No X-Device-Token header → treated as a web/CLI client. + await withChatStream(handler, config, session.id, {}, () => { + // Web presence is recorded; the device registry is untouched (no + // token to key on). + expect(isSessionWebWatched(config.instance, session.id)).toBe(true); + expect(isDeviceWatching(config.instance, config.token, session.id)).toBe(false); + }); + + // Stream closed → presence cleared (the send-then-close path). + expect(isSessionWebWatched(config.instance, session.id)).toBe(false); + }); + + + test("a stream with a valid X-Device-Token registers per-device watch (not pushless)", async () => { + // The mobile path: a registered device opens the stream with its + // valid token, which lands in the per-device registry so the + // dispatcher skips a redundant push to THIS device. It must NOT land + // in the pushless registry (that's web-only). + const config = testConfig("chat-stream-device-watch"); + const handler = createHandler(config); + const { isSessionWebWatched, isDeviceWatching } = await import("./state"); + const session = await call(handler, config, "/api/chat", { + method: "POST", + body: JSON.stringify({ title: "device watch" }) + }); + // Register the device so deviceTokenFromRequest resolves it for the + // caller's credential. + const deviceToken = "valid_device_token_wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww"; + await call(handler, config, "/api/push/devices", { + method: "POST", + body: JSON.stringify({ token: deviceToken, platform: "ios", bundleId: "ai.lilaclabs.gini.mobile" }) + }); + + await withChatStream( + handler, + config, + session.id, + { headers: { "x-device-token": deviceToken } }, + () => { + // Device watch recorded; pushless registry untouched. + expect(isDeviceWatching(config.instance, deviceToken, session.id)).toBe(true); + expect(isSessionWebWatched(config.instance, session.id)).toBe(false); + } + ); + expect(isDeviceWatching(config.instance, deviceToken, session.id)).toBe(false); + }); + + + test("a stream with a present-but-invalid X-Device-Token registers NO presence (not web, not device)", async () => { + // Tri-state guard: deviceTokenFromRequest returns null for BOTH an + // absent header AND a present-but-unregistered/mismatched token. Only + // a truly-absent header is a web/CLI client. A real iPhone whose + // persisted token went stale (rotated server-side, or re-paired) primes + // that stale token onto the SSE handshake on cold launch — it must NOT + // be misclassified as a web client, or it would silence its OWN + // completion alerts for the session. With an invalid token present we + // register nothing: no pushless downgrade, no per-device skip. + const config = testConfig("chat-stream-stale-token"); + const handler = createHandler(config); + const { isSessionWebWatched, isDeviceWatching } = await import("./state"); + const session = await call(handler, config, "/api/chat", { + method: "POST", + body: JSON.stringify({ title: "stale token" }) + }); + + const staleToken = "stale_unregistered_token_zzzzzzzzzzzzzzzzzzzzzzzz"; + await withChatStream( + handler, + config, + session.id, + { headers: { "x-device-token": staleToken } }, + () => { + // Neither registry recorded this stream — a stale-token phone keeps + // its normal alert. + expect(isSessionWebWatched(config.instance, session.id)).toBe(false); + expect(isDeviceWatching(config.instance, staleToken, session.id)).toBe(false); + } + ); + expect(isSessionWebWatched(config.instance, session.id)).toBe(false); + }); + + + test("an unauthenticated stream request 401s and creates no pushless presence", async () => { + // The pushless registration is a notification side effect, so it must + // sit behind the bearer gate: a request with no Authorization header + // 401s before reaching the stream factory, leaving the registry empty. + // Pins that the side effect can't be triggered by an unauthenticated + // caller who merely knows a session id. + const config = testConfig("chat-stream-unauth-no-presence"); + const handler = createHandler(config); + const { isSessionWebWatched } = await import("./state"); + const session = await call(handler, config, "/api/chat", { + method: "POST", + body: JSON.stringify({ title: "unauth presence" }) + }); + + // No token argument → no Authorization header. + const response = await rawCall(handler, config, `/api/chat/${session.id}/stream`, {}); + expect(response.status).toBe(401); + expect(isSessionWebWatched(config.instance, session.id)).toBe(false); + }); + + + test("POST /api/messaging/:id/reject-pending with a malformed chatId returns 400 (not 500)", async () => { + // Same parseChatIdStrict guard as /allow — pin it here so the new + // route doesn't regress to 500 on bad input as the surface grows. + const config = testConfig("messaging-reject-pending-bad-chatid"); + const handler = createHandler(config); + const { addMessagingBridge } = await import("./integrations/messaging"); + const bridge = await addMessagingBridge(config, { + name: "tg", + kind: "telegram", + deliveryTargets: ["1"], + botToken: "TOK" + }); + const badPayloads: Array = [null, "", "123abc", "abc", 1.5]; + for (const chatId of badPayloads) { + const response = await rawCall( + handler, + config, + `/api/messaging/${bridge.id}/reject-pending`, + { method: "POST", body: JSON.stringify({ chatId }) }, + config.token + ); + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.error).toMatch(/chatId must be a finite integer/); + } + }); + + + test("rejects /api/embedding/reembed payloads that pass both allBanks and bankId", async () => { + // The CLI throws when both --all-banks and --bank are supplied + // (src/cli/commands/embedding.ts). The HTTP API has to mirror + // that contract: silently ignoring bankId when allBanks=true + // would let a caller think they were reembedding a single bank + // and instead trigger a full-instance reembed — a destructive, + // irreversible operation against every bank in the instance. + const config = testConfig("embedding-reembed-conflict"); + const handler = createHandler(config); + const response = await rawCall( + handler, + config, + "/api/embedding/reembed", + { + method: "POST", + body: JSON.stringify({ allBanks: true, bankId: "bank_default" }) + }, + config.token + ); + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.error).toMatch(/mutually exclusive/); + }); + + describe("push device endpoints", () => { + test("POST /api/push/devices upserts a token scoped to the owner credential", async () => { + const config = testConfig("push-devices-upsert"); + const handler = createHandler(config); + + const ownerReg = await call(handler, config, "/api/push/devices", { + method: "POST", + body: JSON.stringify({ token: "tok_owner", platform: "ios", bundleId: "ai.lilaclabs.gini.mobile" }) + }); + // Owner-token-only auth: every caller resolves to the "owner" credential. + expect(ownerReg.ok).toBe(true); + expect(ownerReg.device.credentialId).toBe("owner"); + expect(ownerReg.device.token).toBe("tok_owner"); + + // A second install registers its own token under the same credential. + const secondReg = await call(handler, config, "/api/push/devices", { + method: "POST", + body: JSON.stringify({ token: "tok_phone", platform: "ios", bundleId: "ai.lilaclabs.gini.mobile" }) + }); + expect(secondReg.ok).toBe(true); + expect(secondReg.device.credentialId).toBe("owner"); + + // Re-register the same token — idempotent rebind (bundle id updates). + const rebind = await call(handler, config, "/api/push/devices", { + method: "POST", + body: JSON.stringify({ token: "tok_owner", platform: "ios", bundleId: "ai.lilaclabs.gini.mobile.dev" }) + }); + expect(rebind.device.bundleId).toBe("ai.lilaclabs.gini.mobile.dev"); + expect(rebind.device.credentialId).toBe("owner"); + }); + + test("POST /api/push/devices validates inputs", async () => { + const config = testConfig("push-devices-validate"); + const handler = createHandler(config); + + const missingToken = await rawCall(handler, config, "/api/push/devices", { + method: "POST", + body: JSON.stringify({ platform: "ios", bundleId: "ai.lilaclabs.gini.mobile" }) + }, config.token); + expect(missingToken.status).toBe(400); + + const wrongPlatform = await rawCall(handler, config, "/api/push/devices", { + method: "POST", + body: JSON.stringify({ token: "tok", platform: "android", bundleId: "ai.lilaclabs.gini.mobile" }) + }, config.token); + expect(wrongPlatform.status).toBe(400); + + const missingBundle = await rawCall(handler, config, "/api/push/devices", { + method: "POST", + body: JSON.stringify({ token: "tok", platform: "ios" }) + }, config.token); + expect(missingBundle.status).toBe(400); + }); + + test("DELETE /api/push/devices/:token removes the owner's token; a missing token is 404", async () => { + const config = testConfig("push-devices-delete"); + const handler = createHandler(config); + + await call(handler, config, "/api/push/devices", { + method: "POST", + body: JSON.stringify({ token: "tok_phone", platform: "ios", bundleId: "ai.lilaclabs.gini.mobile" }) + }); + + const ownDelete = await call(handler, config, "/api/push/devices/tok_phone", { method: "DELETE" }); + expect(ownDelete.ok).toBe(true); + + // Second delete of the same token: 404. + const repeatDelete = await rawCall(handler, config, "/api/push/devices/tok_phone", { method: "DELETE" }, config.token); + expect(repeatDelete.status).toBe(404); + + // A token that never existed: 404 as well. + const missingDelete = await rawCall(handler, config, "/api/push/devices/tok_never", { method: "DELETE" }, config.token); + expect(missingDelete.status).toBe(404); + }); + + test("POST /api/push/devices → 200 row written with origin='loopback'", async () => { + // Push devices register over loopback from the local web UI; the + // row is tagged origin='loopback' (the only origin). + const config = testConfig("push-devices-loopback"); + mkdirSync(config.stateRoot, { recursive: true }); + const handler = createHandler(config); + + const res = await rawCall( + handler, + config, + "/api/push/devices", + { + method: "POST", + body: JSON.stringify({ token: "tok_loop", platform: "ios", bundleId: "ai.lilaclabs.gini.mobile" }) + }, + config.token + ); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.device.origin).toBe("loopback"); + }); + + test("POST /api/chat/:id/read records the cursor and GET /api/badge surfaces the unread total", async () => { + const config = testConfig("chat-read-badge"); + const handler = createHandler(config); + + // Register a device first — read/badge now key per device, not + // per credential, so the mobile client identifies itself via + // X-Device-Token on every call. + await call(handler, config, "/api/push/devices", { + method: "POST", + body: JSON.stringify({ token: "tok_owner_device", platform: "ios", bundleId: "ai.lilaclabs.gini.mobile" }) + }); + const deviceHeader = { "x-device-token": "tok_owner_device" }; + + const session = await call(handler, config, "/api/chat", { + method: "POST", + body: JSON.stringify({ title: "read state" }) + }); + // Plant two visible blocks via the persistence layer — the read + // endpoint validates the block id, but the unread aggregate is + // what we're measuring here. + const { insertChatBlock } = await import("./state"); + const b1 = insertChatBlock(config.instance, { + kind: "user_text", + sessionId: session.id, + text: "hi" + }); + insertChatBlock(config.instance, { + kind: "user_text", + sessionId: session.id, + text: "follow up" + }); + + // Fresh device: no read state yet, both blocks unread. + const before = await call(handler, config, "/api/badge", { headers: deviceHeader }); + expect(before.unread).toBe(2); + + const marked = await call(handler, config, `/api/chat/${session.id}/read`, { + method: "POST", + headers: deviceHeader, + body: JSON.stringify({ lastReadBlockId: b1.id }) + }); + expect(marked.ok).toBe(true); + expect(marked.readState.lastReadBlockId).toBe(b1.id); + + const after = await call(handler, config, "/api/badge", { headers: deviceHeader }); + expect(after.unread).toBe(1); + }); + + test("GET /api/badge and /api/unread exclude archived sessions", async () => { + // Regression: an archived session (e.g. a deleted recurring-job + // channel) is hidden from every client by the `!archivedAt` filter, + // so the user can never open it to clear its read-state. The badge + // must not count its blocks — otherwise it pins at a number that + // can never be drained. + const config = testConfig("chat-badge-archived"); + const handler = createHandler(config); + + await call(handler, config, "/api/push/devices", { + method: "POST", + body: JSON.stringify({ token: "tok_owner_device", platform: "ios", bundleId: "ai.lilaclabs.gini.mobile" }) + }); + const deviceHeader = { "x-device-token": "tok_owner_device" }; + + const live = await call(handler, config, "/api/chat", { + method: "POST", + body: JSON.stringify({ title: "live chat" }) + }); + const stale = await call(handler, config, "/api/chat", { + method: "POST", + body: JSON.stringify({ title: "old job channel" }) + }); + + const { insertChatBlock } = await import("./state"); + insertChatBlock(config.instance, { kind: "assistant_text", sessionId: live.id, text: "reachable", streaming: false }); + insertChatBlock(config.instance, { kind: "assistant_text", sessionId: stale.id, text: "stuck 1", streaming: false }); + insertChatBlock(config.instance, { kind: "assistant_text", sessionId: stale.id, text: "stuck 2", streaming: false }); + + // Both sessions visible → all three blocks count. + const before = await call(handler, config, "/api/badge", { headers: deviceHeader }); + expect(before.unread).toBe(3); + + // Archive the stale session — it now drops out of the badge. + await mutateState(config.instance, (state) => { + const session = state.chatSessions.find((s) => s.id === stale.id); + if (session) session.archivedAt = new Date().toISOString(); + }); + + const afterBadge = await call(handler, config, "/api/badge", { headers: deviceHeader }); + expect(afterBadge.unread).toBe(1); + + const afterUnread = await call(handler, config, "/api/unread", { headers: deviceHeader }); + expect(afterUnread.counts[live.id]).toBe(1); + expect(afterUnread.counts[stale.id]).toBeUndefined(); + }); + + test("GET /api/badge and /api/unread exclude sessions of archived agents", async () => { + // Second unreachable vector: archiving an agent stamps `archivedAt` + // on the AGENT, not its chat session. Both clients show archived + // agents in a Restore-only group with no way to open the chat, so + // the session's blocks can never be marked read. The badge must + // exclude them just like a self-archived session. + const config = testConfig("chat-badge-archived-agent"); + const handler = createHandler(config); + + await call(handler, config, "/api/push/devices", { + method: "POST", + body: JSON.stringify({ token: "tok_owner_device", platform: "ios", bundleId: "ai.lilaclabs.gini.mobile" }) + }); + const deviceHeader = { "x-device-token": "tok_owner_device" }; + + const agent = await call(handler, config, "/api/agents", { + method: "POST", + body: JSON.stringify({ name: "scratch agent" }) + }); + const agentChat = await getOrCreateAgentChat(config.instance, agent.id); + + const { insertChatBlock } = await import("./state"); + insertChatBlock(config.instance, { + kind: "assistant_text", + sessionId: agentChat.id, + text: "agent reply 1", + streaming: false, + agentId: agent.id + }); + insertChatBlock(config.instance, { + kind: "assistant_text", + sessionId: agentChat.id, + text: "agent reply 2", + streaming: false, + agentId: agent.id + }); + + // Agent active → its chat is reachable, both blocks count. + const before = await call(handler, config, "/api/badge", { headers: deviceHeader }); + expect(before.unread).toBe(2); + + // Archive the agent (the session keeps no archivedAt of its own). + await call(handler, config, `/api/agents/${agent.id}/archive`, { method: "POST" }); + const sessionStillUnarchived = readState(config.instance).chatSessions.find((s) => s.id === agentChat.id); + expect(sessionStillUnarchived?.archivedAt).toBeUndefined(); + + // Badge drops to zero — the only session belongs to an archived agent. + const afterBadge = await call(handler, config, "/api/badge", { headers: deviceHeader }); + expect(afterBadge.unread).toBe(0); + const afterUnread = await call(handler, config, "/api/unread", { headers: deviceHeader }); + expect(afterUnread.counts[agentChat.id]).toBeUndefined(); + + // Restoring the agent makes the chat reachable again → blocks recount. + await call(handler, config, `/api/agents/${agent.id}/unarchive`, { method: "POST" }); + const afterRestore = await call(handler, config, "/api/badge", { headers: deviceHeader }); + expect(afterRestore.unread).toBe(2); + }); + + test("GET /api/badge still counts a job channel owned by an archived agent", async () => { + // Over-exclusion guard: a job CHANNEL (kind:"channel" / origin:"job") + // stays on the channel rail even when its owning agent is archived — + // the client filters key on kind/origin + !archivedAt, never on agent + // state. So unless the channel is archived in its own right it is still + // reachable and openable, and its blocks must keep counting. Only the + // agent's CANONICAL chat vanishes with the agent. + const config = testConfig("chat-badge-archived-agent-channel"); + const handler = createHandler(config); + + await call(handler, config, "/api/push/devices", { + method: "POST", + body: JSON.stringify({ token: "tok_owner_device", platform: "ios", bundleId: "ai.lilaclabs.gini.mobile" }) + }); + const deviceHeader = { "x-device-token": "tok_owner_device" }; + + const agent = await call(handler, config, "/api/agents", { + method: "POST", + body: JSON.stringify({ name: "channel owner" }) + }); + // A real recurring job with its own dedicated channel (kind:"channel", + // origin:"job"), owned by the agent. Going through createScheduledJob + // means the job references the channel, so the orphan-channel sweep in + // normalizeState leaves it live (a bare hand-built channel would be + // archived as orphaned on the next state load). + const job = await createScheduledJob( + config, + { + name: "news-watch", + prompt: "summarize headlines", + intervalSeconds: 600, + createDedicatedSession: { title: "news-watch" } + }, + { originatingAgentId: agent.id } + ); + const channelId = job.chatSessionId!; + const channelSession = readState(config.instance).chatSessions.find((s) => s.id === channelId); + expect(channelSession?.kind).toBe("channel"); + expect(channelSession?.agentId).toBe(agent.id); + + const { insertChatBlock } = await import("./state"); + insertChatBlock(config.instance, { + kind: "assistant_text", + sessionId: channelId, + text: "digest 1", + streaming: false, + agentId: agent.id + }); + + const before = await call(handler, config, "/api/badge", { headers: deviceHeader }); + expect(before.unread).toBe(1); + + // Archive the agent. The channel is still rendered on the rail, so it + // must keep counting (unlike the agent's canonical chat). + await call(handler, config, `/api/agents/${agent.id}/archive`, { method: "POST" }); + const sessionStillLive = readState(config.instance).chatSessions.find((s) => s.id === channelId); + expect(sessionStillLive?.archivedAt).toBeUndefined(); + + const afterBadge = await call(handler, config, "/api/badge", { headers: deviceHeader }); + expect(afterBadge.unread).toBe(1); + const afterUnread = await call(handler, config, "/api/unread", { headers: deviceHeader }); + expect(afterUnread.counts[channelId]).toBe(1); + + // But if the channel itself is archived, it drops out (vector 1). + await mutateState(config.instance, (state) => { + const s = state.chatSessions.find((x) => x.id === channelId); + if (s) s.archivedAt = new Date().toISOString(); + }); + const afterChannelArchive = await call(handler, config, "/api/badge", { headers: deviceHeader }); + expect(afterChannelArchive.unread).toBe(0); + }); + + test("POST /api/chat/:id/read rejects bad input and cross-session ids", async () => { + const config = testConfig("chat-read-validate"); + const handler = createHandler(config); + await call(handler, config, "/api/push/devices", { + method: "POST", + body: JSON.stringify({ token: "tok_owner_device", platform: "ios", bundleId: "ai.lilaclabs.gini.mobile" }) + }); + const deviceHeader = { "x-device-token": "tok_owner_device" }; + const sessionA = await call(handler, config, "/api/chat", { + method: "POST", + body: JSON.stringify({ title: "A" }) + }); + const sessionB = await call(handler, config, "/api/chat", { + method: "POST", + body: JSON.stringify({ title: "B" }) + }); + const { insertChatBlock } = await import("./state"); + const bA = insertChatBlock(config.instance, { + kind: "user_text", + sessionId: sessionA.id, + text: "in A" + }); + + // Missing lastReadBlockId. + const missing = await rawCall( + handler, + config, + `/api/chat/${sessionA.id}/read`, + { method: "POST", headers: deviceHeader, body: JSON.stringify({}) }, + config.token + ); + expect(missing.status).toBe(400); + + // Block belongs to A — POSTing it on B's cursor is rejected. + const cross = await rawCall( + handler, + config, + `/api/chat/${sessionB.id}/read`, + { method: "POST", headers: deviceHeader, body: JSON.stringify({ lastReadBlockId: bA.id }) }, + config.token + ); + expect(cross.status).toBe(400); + + // Unknown session: 404. + const noSession = await rawCall( + handler, + config, + "/api/chat/chat_nonexistent/read", + { method: "POST", headers: deviceHeader, body: JSON.stringify({ lastReadBlockId: bA.id }) }, + config.token + ); + expect(noSession.status).toBe(404); + + // Missing X-Device-Token: 400 (mobile-only endpoint). + const missingDevice = await rawCall( + handler, + config, + `/api/chat/${sessionA.id}/read`, + { method: "POST", body: JSON.stringify({ lastReadBlockId: bA.id }) }, + config.token + ); + expect(missingDevice.status).toBe(400); + + // Foreign device token (not registered to this credential): 403. + const foreignDevice = await rawCall( + handler, + config, + `/api/chat/${sessionA.id}/read`, + { + method: "POST", + headers: { "x-device-token": "tok_someone_else" }, + body: JSON.stringify({ lastReadBlockId: bA.id }) + }, + config.token + ); + expect(foreignDevice.status).toBe(403); + }); + + test("read state is scoped per device, not per credential", async () => { + // Two iPhones owned by the same human (both register under the + // "owner" credential). iPhone A reading the chat must NOT clear + // iPhone B's badge — that's the load-bearing per-device guarantee. + const config = testConfig("chat-read-device"); + const handler = createHandler(config); + await call(handler, config, "/api/push/devices", { + method: "POST", + body: JSON.stringify({ token: "tok_iphone_a", platform: "ios", bundleId: "ai.lilaclabs.gini.mobile" }) + }); + await call(handler, config, "/api/push/devices", { + method: "POST", + body: JSON.stringify({ token: "tok_iphone_b", platform: "ios", bundleId: "ai.lilaclabs.gini.mobile" }) + }); + + const session = await call(handler, config, "/api/chat", { + method: "POST", + body: JSON.stringify({ title: "shared" }) + }); + const { insertChatBlock } = await import("./state"); + const block = insertChatBlock(config.instance, { + kind: "user_text", + sessionId: session.id, + text: "hello" + }); + + // iPhone A marks read; its badge drops to 0. iPhone B's badge + // is still 1 because read state is per-device. + await call(handler, config, `/api/chat/${session.id}/read`, { + method: "POST", + headers: { "x-device-token": "tok_iphone_a" }, + body: JSON.stringify({ lastReadBlockId: block.id }) + }); + const badgeA = await call(handler, config, "/api/badge", { + headers: { "x-device-token": "tok_iphone_a" } + }); + const badgeB = await call(handler, config, "/api/badge", { + headers: { "x-device-token": "tok_iphone_b" } + }); + expect(badgeA.unread).toBe(0); + expect(badgeB.unread).toBe(1); + }); + + test("DELETE /api/chat/:id/read marks just the latest assistant turn unread", async () => { + const config = testConfig("chat-unread-swipe"); + const handler = createHandler(config); + await call(handler, config, "/api/push/devices", { + method: "POST", + body: JSON.stringify({ token: "tok_iphone_a", platform: "ios", bundleId: "ai.lilaclabs.gini.mobile" }) + }); + await call(handler, config, "/api/push/devices", { + method: "POST", + body: JSON.stringify({ token: "tok_iphone_b", platform: "ios", bundleId: "ai.lilaclabs.gini.mobile" }) + }); + const headerA = { "x-device-token": "tok_iphone_a" }; + const headerB = { "x-device-token": "tok_iphone_b" }; + const session = await call(handler, config, "/api/chat", { + method: "POST", + body: JSON.stringify({ title: "swipe" }) + }); + // Realistic chat: a few user messages culminating in an assistant + // reply. After Mark Unread the badge should show 1 (just the + // assistant turn), not 4 (every visible block). + const { insertChatBlock } = await import("./state"); + insertChatBlock(config.instance, { kind: "user_text", sessionId: session.id, text: "hi" }); + insertChatBlock(config.instance, { kind: "user_text", sessionId: session.id, text: "still hi" }); + insertChatBlock(config.instance, { kind: "user_text", sessionId: session.id, text: "ok last one" }); + const assistant = insertChatBlock(config.instance, { + kind: "assistant_text", + sessionId: session.id, + text: "hello back", + streaming: false + }); + + // Both devices catch up first so the baseline badge is 0. + await call(handler, config, `/api/chat/${session.id}/read`, { + method: "POST", headers: headerA, body: JSON.stringify({ lastReadBlockId: assistant.id }) + }); + await call(handler, config, `/api/chat/${session.id}/read`, { + method: "POST", headers: headerB, body: JSON.stringify({ lastReadBlockId: assistant.id }) + }); + expect((await call(handler, config, "/api/badge", { headers: headerA })).unread).toBe(0); + + // iPhone A swipes "Mark unread". The badge surfaces just the + // latest assistant turn (1), not the full session. + const cleared = await call(handler, config, `/api/chat/${session.id}/read`, { + method: "DELETE", headers: headerA + }); + expect(cleared.ok).toBe(true); + expect((await call(handler, config, "/api/badge", { headers: headerA })).unread).toBe(1); + // iPhone B is unaffected (still caught up). + expect((await call(handler, config, "/api/badge", { headers: headerB })).unread).toBe(0); + + // Idempotent — replaying lands on the same cursor; still 1. + const second = await call(handler, config, `/api/chat/${session.id}/read`, { + method: "DELETE", headers: headerA + }); + expect(second.ok).toBe(true); + expect((await call(handler, config, "/api/badge", { headers: headerA })).unread).toBe(1); + + // Unknown session: 404. + const noSession = await rawCall( + handler, + config, + "/api/chat/chat_nonexistent/read", + { method: "DELETE", headers: headerA }, + config.token + ); + expect(noSession.status).toBe(404); + + // Missing X-Device-Token: 400. + const noDevice = await rawCall( + handler, + config, + `/api/chat/${session.id}/read`, + { method: "DELETE" }, + config.token + ); + expect(noDevice.status).toBe(400); + }); + + test("GET /api/unread returns per-session unread counts scoped to the device", async () => { + const config = testConfig("chat-unread-counts"); + const handler = createHandler(config); + await call(handler, config, "/api/push/devices", { + method: "POST", + body: JSON.stringify({ token: "tok_iphone_a", platform: "ios", bundleId: "ai.lilaclabs.gini.mobile" }) + }); + await call(handler, config, "/api/push/devices", { + method: "POST", + body: JSON.stringify({ token: "tok_iphone_b", platform: "ios", bundleId: "ai.lilaclabs.gini.mobile" }) + }); + const headerA = { "x-device-token": "tok_iphone_a" }; + const headerB = { "x-device-token": "tok_iphone_b" }; + const sessionA = await call(handler, config, "/api/chat", { + method: "POST", + body: JSON.stringify({ title: "A" }) + }); + const sessionB = await call(handler, config, "/api/chat", { + method: "POST", + body: JSON.stringify({ title: "B" }) + }); + const { insertChatBlock } = await import("./state"); + insertChatBlock(config.instance, { kind: "user_text", sessionId: sessionA.id, text: "1" }); + const a2 = insertChatBlock(config.instance, { + kind: "user_text", + sessionId: sessionA.id, + text: "2" + }); + insertChatBlock(config.instance, { kind: "user_text", sessionId: sessionB.id, text: "3" }); + + // Fresh device A — both sessions show their full count. + const initial = await call(handler, config, "/api/unread", { headers: headerA }); + expect(initial.counts[sessionA.id]).toBe(2); + expect(initial.counts[sessionB.id]).toBe(1); + + // A catches up on session A — it drops out of the map for A. + await call(handler, config, `/api/chat/${sessionA.id}/read`, { + method: "POST", + headers: headerA, + body: JSON.stringify({ lastReadBlockId: a2.id }) + }); + const after = await call(handler, config, "/api/unread", { headers: headerA }); + expect(after.counts[sessionA.id]).toBeUndefined(); + expect(after.counts[sessionB.id]).toBe(1); + + // Device B is unaffected. + const bView = await call(handler, config, "/api/unread", { headers: headerB }); + expect(bView.counts[sessionA.id]).toBe(2); + expect(bView.counts[sessionB.id]).toBe(1); + + // Missing X-Device-Token: 400. + const noDevice = await rawCall( + handler, + config, + "/api/unread", + {}, + config.token + ); + expect(noDevice.status).toBe(400); + + // Unauth: 401. + const noAuth = await rawCall(handler, config, "/api/unread"); + expect(noAuth.status).toBe(401); + }); + + test("read + badge endpoints require authentication", async () => { + const config = testConfig("chat-read-auth"); + const handler = createHandler(config); + const read = await rawCall(handler, config, "/api/chat/chat_x/read", { + method: "POST", + body: JSON.stringify({ lastReadBlockId: "block_x" }) + }); + expect(read.status).toBe(401); + const badge = await rawCall(handler, config, "/api/badge"); + expect(badge.status).toBe(401); + }); + + test("push device endpoints require authentication", async () => { + const config = testConfig("push-devices-auth"); + const handler = createHandler(config); + + const post = await rawCall(handler, config, "/api/push/devices", { + method: "POST", + body: JSON.stringify({ token: "tok", platform: "ios", bundleId: "ai.lilaclabs.gini.mobile" }) + }); + expect(post.status).toBe(401); + + const del = await rawCall(handler, config, "/api/push/devices/tok", { method: "DELETE" }); + expect(del.status).toBe(401); + }); + }); + + describe("cors", () => { + // Save/restore the env override so individual cases don't leak. + function withEnv(value: string | undefined, fn: () => Promise): Promise { + const prior = process.env.GINI_CORS_ORIGINS; + if (value === undefined) delete process.env.GINI_CORS_ORIGINS; + else process.env.GINI_CORS_ORIGINS = value; + return fn().finally(() => { + if (prior === undefined) delete process.env.GINI_CORS_ORIGINS; + else process.env.GINI_CORS_ORIGINS = prior; + }); + } + + test("preflight from an allowed origin returns 204 with CORS headers", async () => { + await withEnv(undefined, async () => { + const config = testConfig("cors-preflight-allowed"); + const handler = createHandler(config); + const response = await handler(new Request(`http://127.0.0.1:${config.port}/api/status`, { + method: "OPTIONS", + headers: { + origin: "http://localhost:8090", + "access-control-request-method": "GET", + "access-control-request-headers": "authorization" + } + })); + expect(response.status).toBe(204); + expect(response.headers.get("access-control-allow-origin")).toBe("http://localhost:8090"); + expect(response.headers.get("access-control-allow-credentials")).toBe("true"); + expect(response.headers.get("vary")).toBe("Origin"); + expect(response.headers.get("access-control-allow-methods")).toContain("GET"); + expect(response.headers.get("access-control-allow-methods")).toContain("POST"); + expect(response.headers.get("access-control-allow-headers") ?? "").toContain("Authorization"); + expect(response.headers.get("access-control-allow-headers") ?? "").toContain("X-Device-Token"); + expect(response.headers.get("access-control-allow-headers") ?? "").toContain("Last-Event-ID"); + expect(response.headers.get("access-control-max-age")).toBe("600"); + }); + }); + + test("preflight from a disallowed origin returns 204 without allow-origin", async () => { + await withEnv(undefined, async () => { + const config = testConfig("cors-preflight-disallowed"); + const handler = createHandler(config); + const response = await handler(new Request(`http://127.0.0.1:${config.port}/api/status`, { + method: "OPTIONS", + headers: { + origin: "http://evil.example.com", + "access-control-request-method": "GET" + } + })); + expect(response.status).toBe(204); + expect(response.headers.get("access-control-allow-origin")).toBeNull(); + // The protocol-level headers still go out — they describe what + // the server *would* accept; the browser rejects because of the + // missing allow-origin. + expect(response.headers.get("access-control-allow-methods")).toContain("GET"); + }); + }); + + test("normal GET from an allowed origin gets CORS headers", async () => { + await withEnv(undefined, async () => { + const config = testConfig("cors-get-allowed"); + const handler = createHandler(config); + const response = await handler(new Request(`http://127.0.0.1:${config.port}/api/status`, { + headers: { + origin: "http://localhost:3045", + authorization: `Bearer ${config.token}` + } + })); + expect(response.status).toBe(200); + expect(response.headers.get("access-control-allow-origin")).toBe("http://localhost:3045"); + expect(response.headers.get("access-control-allow-credentials")).toBe("true"); + expect(response.headers.get("vary")).toBe("Origin"); + expect(response.headers.get("access-control-expose-headers")).toBe("Last-Event-ID"); + }); + }); + + test("non-browser caller without Origin gets no CORS headers", async () => { + await withEnv(undefined, async () => { + const config = testConfig("cors-no-origin"); + const handler = createHandler(config); + const response = await handler(new Request(`http://127.0.0.1:${config.port}/api/status`, { + headers: { authorization: `Bearer ${config.token}` } + })); + expect(response.status).toBe(200); + expect(response.headers.get("access-control-allow-origin")).toBeNull(); + expect(response.headers.get("vary")).toBeNull(); + }); + }); + + test("401 responses still carry CORS headers so the browser sees the status", async () => { + await withEnv(undefined, async () => { + const config = testConfig("cors-401"); + const handler = createHandler(config); + const response = await handler(new Request(`http://127.0.0.1:${config.port}/api/status`, { + headers: { origin: "http://localhost:8090" } // no Authorization + })); + expect(response.status).toBe(401); + expect(response.headers.get("access-control-allow-origin")).toBe("http://localhost:8090"); + }); + }); + + test("GINI_CORS_ORIGINS env var overrides the default allowlist", async () => { + await withEnv("https://example.com", async () => { + const config = testConfig("cors-custom-env"); + const handler = createHandler(config); + + const allowed = await handler(new Request(`http://127.0.0.1:${config.port}/api/status`, { + headers: { + origin: "https://example.com", + authorization: `Bearer ${config.token}` + } + })); + expect(allowed.headers.get("access-control-allow-origin")).toBe("https://example.com"); + + // The defaults (localhost:8090, etc) should NOT be honored when + // the env var is set — it's a full override, not an additive list. + const denied = await handler(new Request(`http://127.0.0.1:${config.port}/api/status`, { + headers: { + origin: "http://localhost:8090", + authorization: `Bearer ${config.token}` + } + })); + expect(denied.headers.get("access-control-allow-origin")).toBeNull(); + }); + }); + }); +}); + +describe("GET /api/docs", () => { + test("returns the requested doc section markdown and title", async () => { + const config = testConfig("docs-section"); + const handler = createHandler(config); + + const doc = await call(handler, config, "/api/docs/providers/codex?section=re-authentication"); + expect(doc.title).toBe("Codex"); + expect(doc.anchor).toBe("re-authentication"); + expect(doc.markdown).toContain("## Re-authentication"); + }); + + test("requires authentication", async () => { + const config = testConfig("docs-unauth"); + const handler = createHandler(config); + + const response = await rawCall(handler, config, "/api/docs/providers/codex"); + expect(response.status).toBe(401); + }); + + test("rejects a traversal path with 400", async () => { + const config = testConfig("docs-traversal"); + const handler = createHandler(config); + + // Percent-encode the slashes so the WHATWG URL parser doesn't collapse the + // `..` segments away — a literal `/api/docs/../package` normalizes to + // `/api/package` and 404s before this route matches. Encoded, the route + // matches and resolveDocPath's confinement check rejects the escaping path. + const response = await rawCall(handler, config, "/api/docs/..%2F..%2Fpackage", {}, config.token); + expect(response.status).toBe(400); + }); +}); + +describe("agent-chat and thread endpoints", () => { + test("GET /api/agents/:id/chat returns a stable single session across calls", async () => { + const config = testConfig("agent-chat-resolve"); + const handler = createHandler(config); + + const agent = await call(handler, config, "/api/agents", { + method: "POST", + body: JSON.stringify({ name: "Nova" }) + }); + + const first = await call(handler, config, `/api/agents/${agent.id}/chat`); + const second = await call(handler, config, `/api/agents/${agent.id}/chat`); + + expect(first.id).toBeString(); + expect(first.kind).toBe("agent"); + expect(first.agentId).toBe(agent.id); + expect(second.id).toBe(first.id); + }); + + test("GET /api/chat/:id/threads lists threads and 404s on a missing session", async () => { + const config = testConfig("thread-list"); + const handler = createHandler(config); + + const agent = await call(handler, config, "/api/agents", { + method: "POST", + body: JSON.stringify({ name: "Sage" }) + }); + const session = await call(handler, config, `/api/agents/${agent.id}/chat`); + + // Root the thread off a main-chat assistant block, then add an agent + // reply inside the thread. + const root = insertChatBlock(config.instance, { + kind: "assistant_text", + sessionId: session.id, + text: "Here is the research plan.", + streaming: false, + agentId: agent.id + }); + insertChatBlock(config.instance, { + kind: "assistant_text", + sessionId: session.id, + text: "Step one is done.", + streaming: false, + agentId: agent.id, + threadId: "thread_one", + parentBlockId: root.id + }); + + const threads = await call(handler, config, `/api/chat/${session.id}/threads`); + expect(threads).toHaveLength(1); + expect(threads[0].threadId).toBe("thread_one"); + expect(threads[0].parentBlockId).toBe(root.id); + expect(threads[0].lastReplyAuthor).toBe("agent"); + expect(threads[0].rootPreview).toContain("research plan"); + + const missing = await rawCall(handler, config, "/api/chat/chat_nope/threads", {}, config.token); + expect(missing.status).toBe(404); + }); + + test("GET /api/chat/:id/threads/:tid/blocks returns the thread's blocks and 404s on a missing session", async () => { + const config = testConfig("thread-blocks"); + const handler = createHandler(config); + + const agent = await call(handler, config, "/api/agents", { + method: "POST", + body: JSON.stringify({ name: "Scout" }) + }); + const session = await call(handler, config, `/api/agents/${agent.id}/chat`); + + const root = insertChatBlock(config.instance, { + kind: "assistant_text", + sessionId: session.id, + text: "Parent message", + streaming: false, + agentId: agent.id + }); + const reply = insertChatBlock(config.instance, { + kind: "assistant_text", + sessionId: session.id, + text: "Threaded reply", + streaming: false, + agentId: agent.id, + threadId: "thread_blocks", + parentBlockId: root.id + }); + // A main-chat block that must NOT leak into the thread fetch. + insertChatBlock(config.instance, { + kind: "assistant_text", + sessionId: session.id, + text: "Main chat only", + streaming: false, + agentId: agent.id + }); + + const blocks = await call(handler, config, `/api/chat/${session.id}/threads/thread_blocks/blocks`); + expect(blocks).toHaveLength(1); + expect(blocks[0].id).toBe(reply.id); + expect(blocks[0].threadId).toBe("thread_blocks"); + + const missing = await rawCall(handler, config, "/api/chat/chat_nope/threads/thread_blocks/blocks", {}, config.token); + expect(missing.status).toBe(404); + }); + + test("POST /api/chat/:id/threads/:tid/messages tags the block + task and mirrors to main with alsoToMain", async () => { + const config = testConfig("thread-reply"); + const handler = createHandler(config); + + const agent = await call(handler, config, "/api/agents", { + method: "POST", + body: JSON.stringify({ name: "Echo" }) + }); + const session = await call(handler, config, `/api/agents/${agent.id}/chat`); + + const root = insertChatBlock(config.instance, { + kind: "assistant_text", + sessionId: session.id, + text: "Original answer", + streaming: false, + agentId: agent.id + }); + insertChatBlock(config.instance, { + kind: "assistant_text", + sessionId: session.id, + text: "First thread reply", + streaming: false, + agentId: agent.id, + threadId: "thread_reply", + parentBlockId: root.id + }); + + const submitted = await call(handler, config, `/api/chat/${session.id}/threads/thread_reply/messages`, { + method: "POST", + body: JSON.stringify({ content: "follow up in the thread", alsoToMain: true }) + }); + + expect(submitted.threadId).toBe("thread_reply"); + expect(submitted.taskId).toBeString(); + + // The spawned task carries the thread membership so the whole response + // threads (resolveEmitContext reads these off the task). + const task = readState(config.instance).tasks.find((t) => t.id === submitted.taskId); + expect(task?.threadId).toBe("thread_reply"); + expect(task?.parentBlockId).toBe(root.id); + + // The user reply lands as a thread-tagged user_text block... + const threadBlocks = await call(handler, config, `/api/chat/${session.id}/threads/thread_reply/blocks`); + const threadUser = threadBlocks.find( + (b: { kind: string; text?: string }) => b.kind === "user_text" && b.text === "follow up in the thread" + ); + expect(threadUser.threadId).toBe("thread_reply"); + expect(threadUser.parentBlockId).toBe(root.id); + + // ...and alsoToMain mirrors it as an un-threaded main-chat user_text block. + const allBlocks = await call(handler, config, `/api/chat/${session.id}/blocks`); + const mainMirror = allBlocks.filter( + (b: { kind: string; text?: string; threadId?: string }) => + b.kind === "user_text" && b.text === "follow up in the thread" && b.threadId === undefined + ); + expect(mainMirror).toHaveLength(1); + }); + + test("POST /api/chat/:id/threads/:tid/messages 404s when the thread does not exist", async () => { + const config = testConfig("thread-reply-missing"); + const handler = createHandler(config); + + const agent = await call(handler, config, "/api/agents", { + method: "POST", + body: JSON.stringify({ name: "Vega" }) + }); + const session = await call(handler, config, `/api/agents/${agent.id}/chat`); + + const response = await rawCall(handler, config, `/api/chat/${session.id}/threads/thread_absent/messages`, { + method: "POST", + body: JSON.stringify({ content: "no thread here" }) + }, config.token); + expect(response.status).toBe(404); + const value = await response.json(); + expect(String(value.error)).toContain("Thread not found"); + }); + + test("POST /api/chat/:id/threads/:tid/messages 404s with Chat session not found on a bad session", async () => { + const config = testConfig("thread-reply-bad-session"); + const handler = createHandler(config); + + const response = await rawCall(handler, config, "/api/chat/chat_nope/threads/thread_one/messages", { + method: "POST", + body: JSON.stringify({ content: "no session here" }) + }, config.token); + expect(response.status).toBe(404); + const value = await response.json(); + expect(String(value.error)).toContain("Chat session not found"); + }); + + test("POST /api/chat/:id/threads/:tid/messages creates a new thread off a main-chat parent block", async () => { + const config = testConfig("thread-reply-create"); + const handler = createHandler(config); + + const agent = await call(handler, config, "/api/agents", { + method: "POST", + body: JSON.stringify({ name: "Orion" }) + }); + const session = await call(handler, config, `/api/agents/${agent.id}/chat`); + + // A main-chat assistant block the user branches a brand-new thread from. + const parent = insertChatBlock(config.instance, { + kind: "assistant_text", + sessionId: session.id, + text: "Here is my plan.", + streaming: false, + agentId: agent.id + }); + + // No prior blocks under thread_fresh — the parentBlockId in the body is + // what brings the thread into existence. + const submitted = await call(handler, config, `/api/chat/${session.id}/threads/thread_fresh/messages`, { + method: "POST", + body: JSON.stringify({ content: "kick off the thread", parentBlockId: parent.id }) + }); + + expect(submitted.threadId).toBe("thread_fresh"); + expect(submitted.taskId).toBeString(); + + // The spawned task carries the new thread's membership. + const task = readState(config.instance).tasks.find((t) => t.id === submitted.taskId); + expect(task?.threadId).toBe("thread_fresh"); + expect(task?.parentBlockId).toBe(parent.id); + + // The user reply lands as a thread-tagged user_text block rooted at the + // parent, so the thread now exists and renders in the panel. + const threadBlocks = await call(handler, config, `/api/chat/${session.id}/threads/thread_fresh/blocks`); + const threadUser = threadBlocks.find( + (b: { kind: string; text?: string }) => b.kind === "user_text" && b.text === "kick off the thread" + ); + expect(threadUser.threadId).toBe("thread_fresh"); + expect(threadUser.parentBlockId).toBe(parent.id); + }); + + test("POST /api/chat/:id/threads/:tid/messages 404s when starting a new thread without a parent block", async () => { + const config = testConfig("thread-reply-create-noparent"); + const handler = createHandler(config); + + const agent = await call(handler, config, "/api/agents", { + method: "POST", + body: JSON.stringify({ name: "Lyra" }) + }); + const session = await call(handler, config, `/api/agents/${agent.id}/chat`); + + const response = await rawCall(handler, config, `/api/chat/${session.id}/threads/thread_new/messages`, { + method: "POST", + body: JSON.stringify({ content: "no parent supplied" }) + }, config.token); + expect(response.status).toBe(404); + const value = await response.json(); + expect(String(value.error)).toContain("Thread not found"); + }); + + test("GET /api/threads aggregates across agent sessions with agentName, newest first", async () => { + const config = testConfig("threads-inbox"); + const handler = createHandler(config); + + const nova = await call(handler, config, "/api/agents", { + method: "POST", + body: JSON.stringify({ name: "Nova" }) + }); + const sage = await call(handler, config, "/api/agents", { + method: "POST", + body: JSON.stringify({ name: "Sage" }) + }); + const novaChat = await getOrCreateAgentChat(config.instance, nova.id); + const sageChat = await getOrCreateAgentChat(config.instance, sage.id); + + // Nova's thread (older last reply). + const novaRoot = insertChatBlock(config.instance, { + kind: "assistant_text", + sessionId: novaChat.id, + text: "Nova parent", + streaming: false, + agentId: nova.id + }); + insertChatBlock(config.instance, { + kind: "assistant_text", + sessionId: novaChat.id, + text: "Nova thread reply", + streaming: false, + agentId: nova.id, + threadId: "thread_nova", + parentBlockId: novaRoot.id + }); + + // Sage's thread (newer last reply — must sort first). + await Bun.sleep(2); + const sageRoot = insertChatBlock(config.instance, { + kind: "assistant_text", + sessionId: sageChat.id, + text: "Sage parent", + streaming: false, + agentId: sage.id + }); + insertChatBlock(config.instance, { + kind: "user_text", + sessionId: sageChat.id, + text: "Sage thread reply", + agentId: sage.id, + threadId: "thread_sage", + parentBlockId: sageRoot.id + }); + + const inbox = await call(handler, config, "/api/threads"); + expect(inbox).toHaveLength(2); + // Newest last reply first. + expect(inbox[0].threadId).toBe("thread_sage"); + expect(inbox[0].agentName).toBe("Sage"); + expect(inbox[0].lastReplyAuthor).toBe("user"); + expect(inbox[1].threadId).toBe("thread_nova"); + expect(inbox[1].agentName).toBe("Nova"); + expect(inbox[1].lastReplyAuthor).toBe("agent"); + + // filter=unread is accepted but returns the full list (client filters). + const all = await call(handler, config, "/api/threads?filter=unread"); + expect(all).toHaveLength(2); + }); + + test("GET /api/logs requires the bearer", async () => { + const config = testConfig("logs-auth"); + const handler = createHandler(config); + const response = await rawCall(handler, config, "/api/logs"); + expect(response.status).toBe(401); + }); + + test("GET /api/logs returns parsed runtime entries by default", async () => { + const config = testConfig("logs-runtime"); + const handler = createHandler(config); + seedLogFile(config, "runtime.jsonl", + `${JSON.stringify({ at: "2026-06-07T00:00:00.000Z", message: "boot", data: { token: "sk-secret-1" } })}\n` + + `${JSON.stringify({ at: "2026-06-07T00:00:01.000Z", message: "ready" })}\n` + ); + const tail = await call(handler, config, "/api/logs"); + expect(tail.stream).toBe("runtime"); + expect(tail.redacted).toBe(false); + expect(tail.entries).toHaveLength(2); + // Raw mode keeps the data payload untouched. + expect(tail.entries[0].data).toEqual({ token: "sk-secret-1" }); + expect(tail.lines).toBeUndefined(); + }); + + test("GET /api/logs honors the stream param and returns raw lines", async () => { + const config = testConfig("logs-stream"); + const handler = createHandler(config); + seedLogFile(config, "web.log", "web line 1\nweb line 2\n"); + const tail = await call(handler, config, "/api/logs?stream=web"); + expect(tail.stream).toBe("web"); + expect(tail.lines).toEqual(["web line 1", "web line 2"]); + expect(tail.entries).toBeUndefined(); + }); + + test("GET /api/logs with redact=true drops data and scrubs secrets", async () => { + const config = testConfig("logs-redact"); + const handler = createHandler(config); + seedLogFile(config, "runtime.jsonl", + `${JSON.stringify({ at: "2026-06-07T00:00:00.000Z", message: "auth Bearer sk-leak-123", data: { token: "sk-leak-123" } })}\n` + ); + const tail = await call(handler, config, "/api/logs?redact=true"); + expect(tail.redacted).toBe(true); + expect(tail.entries).toHaveLength(1); + expect(tail.entries[0].data).toBeUndefined(); + expect(tail.entries[0].message).not.toContain("sk-leak-123"); + expect(tail.entries[0].message).toContain("[redacted]"); + }); + + test("GET /api/logs rejects an unknown stream with 400", async () => { + const config = testConfig("logs-unknown"); + const handler = createHandler(config); + const response = await rawCall(handler, config, "/api/logs?stream=audit", {}, config.token); + expect(response.status).toBe(400); + }); + + test("GET /api/logs clamps the limit to the most recent lines", async () => { + const config = testConfig("logs-limit"); + const handler = createHandler(config); + const body = Array.from({ length: 6 }, (_, i) => JSON.stringify({ message: `m${i}` })).join("\n") + "\n"; + seedLogFile(config, "runtime.jsonl", body); + const tail = await call(handler, config, "/api/logs?limit=2"); + expect(tail.truncated).toBe(true); + expect(tail.entries.map((e: { message: string }) => e.message)).toEqual(["m4", "m5"]); + }); +}); + +async function call(handler: ReturnType, config: RuntimeConfig, path: string, init: RequestInit = {}) { + return callWithToken(handler, config, config.token, path, init); +} + +async function callWithToken(handler: ReturnType, config: RuntimeConfig, token: string, path: string, init: RequestInit = {}) { + const response = await rawCall(handler, config, path, init, token); + const value = await response.json(); + if (!response.ok) throw new Error(value.error ?? `HTTP ${response.status}`); + return value; +} + +async function rawCall(handler: ReturnType, config: RuntimeConfig, path: string, init: RequestInit = {}, token?: string) { + const response = await handler(new Request(`http://127.0.0.1:${config.port}${path}`, { + ...init, + headers: { "content-type": "application/json", ...(token ? { authorization: `Bearer ${token}` } : {}), ...(init.headers ?? {}) } + })); + return response; +} + +// Opens a chat SSE stream, pumps frames until the initial `chat_session` +// frame lands (which only happens inside the stream's start() — i.e. AFTER +// presence registration), runs the caller's `whileOpen` assertions, then +// ALWAYS cancels the reader in a finally so a thrown assertion can't leak +// the stream (its keepalive interval and presence entry would otherwise +// survive the test). Returns after the reader is cancelled. +async function withChatStream( + handler: ReturnType, + config: RuntimeConfig, + sessionId: string, + init: RequestInit, + whileOpen: () => void +): Promise { + const response = await rawCall(handler, config, `/api/chat/${sessionId}/stream`, init, config.token); + expect(response.status).toBe(200); + const reader = response.body?.getReader(); + expect(reader).toBeDefined(); + if (!reader) return; + try { + const decoder = new TextDecoder(); + let buffer = ""; + const deadline = Date.now() + 500; + while (Date.now() < deadline) { + const { done, value } = await Promise.race([ + reader.read(), + new Promise<{ done: boolean; value: undefined }>((resolve) => + setTimeout(() => resolve({ done: false, value: undefined }), 50) + ) + ]); + if (done) break; + if (value) buffer += decoder.decode(value); + if (buffer.includes("event: chat_session")) break; + } + expect(buffer).toContain("event: chat_session"); + whileOpen(); + } finally { + await reader.cancel(); + } + // cancel() runs the stream's cleanup synchronously in-process; give the + // microtask queue one turn to settle before the caller asserts the + // post-close registry state. + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +function testConfig(instance: string): RuntimeConfig { + const root = `/tmp/gini-http-tests-${import.meta.file}`; + process.env.GINI_STATE_ROOT = root; + process.env.GINI_LOG_ROOT = `${root}-logs`; + // Drop the cached SQLite handle for this instance before nuking the + // directory. Without this, a prior test that opened the per-instance + // memory DB leaves an open `bun:sqlite` handle pointing at the now- + // unlinked file. The next call to getMemoryDb returns that cached + // handle (the cache key is the instance name) and any write fails + // because the inode is gone. removeMemoryDb closes the cached handle + // AND unlinks the file + WAL/SHM siblings in one shot. + removeMemoryDb(instance); + // resumeChatTask polls for the loop's flip to waiting_approval before + // staging a tool result. In-process the flip lands within a couple of + // mutateState boundaries, and several fill_secret / approval tests seed a + // task that never reaches waiting_approval at all — so the production + // 1000ms/100ms budget is pure dead wall here (the fill_secret leak test + // measured 1079.00ms in isolation, nearly all of it this poll). Shrink the + // budget via the server-side env knob the production code reads (default + // preserved at 1000/100); the race still resolves well within 40ms over 5ms + // ticks in-process. + process.env.GINI_RESUME_WAIT_BUDGET_MS = "40"; + process.env.GINI_RESUME_WAIT_TICK_MS = "5"; + rmSync(`${root}/instances/${instance}`, { recursive: true, force: true }); + return { + instance, + port: 7337, + token: "test-token", + provider: { name: "echo", model: "gini-echo-v0" }, + workspaceRoot: "/tmp", + stateRoot: `${root}/instances/${instance}`, + logRoot: `${root}-logs/${instance}`, + // These tests predate the approval-mode flip and rely on the + // gated path. Force "strict" to keep them honest; new defaults + // are exercised in approval-mode.test.ts. + approvalMode: "strict" + }; +} + +// Write a log file under the test config's instance log dir so the /api/logs +// route reads it. testConfig nukes the instance state dir and points +// GINI_LOG_ROOT at a sibling tree, so this lands in a clean per-instance dir. +function seedLogFile(config: RuntimeConfig, filename: string, body: string): void { + const dir = logDir(config.instance); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, filename), body); +} + +// Seed a typed api-key credential so the per-(skill, credential) consent gate +// (firstUngrantedCredential) treats it as carrying a secret that needs consent. +async function seedTypedCredential(config: RuntimeConfig, name: string, provider: string) { + const at = new Date().toISOString(); + await mutateState(config.instance, (state) => { + state.connectors.push({ + id: `id_${name}`, + instance: state.instance, + name, + provider, + type: "api-key", + status: "configured", + scopes: [], + secretRefs: [{ purpose: name, path: `/tmp/${name}.json` }], + createdAt: at, + updatedAt: at, + health: "healthy", + source: "user" + }); + }); +} + +async function waitForTask( + handler: ReturnType, + config: RuntimeConfig, + taskId: string, + // The approve endpoint returns at decision durability while the side + // effect + resume run detached, so a post-approve wait must exclude + // the still-parked waiting_approval status or it returns the + // pre-approve park immediately. + statuses: string[] = ["completed", "failed", "waiting_approval"] +) { + // Phase 5 added auto-recall + auto-retain to runTask. The retain side is + // fire-and-forget so it can't block, but the inline recall + a few extra + // mutateState audits push runTask completion past the original 500ms + // budget on slower hosts. A 200-iteration / 10ms loop = 2s ceiling is + // still well under any reasonable test timeout. + for (let attempt = 0; attempt < 200; attempt += 1) { + const detail = await call(handler, config, `/api/tasks/${taskId}`); + if (statuses.includes(detail.task.status)) return detail; + await Bun.sleep(10); + } + throw new Error(`Task did not settle: ${taskId}`); +} diff --git a/packages/runtime/src/http.part4.test.ts b/packages/runtime/src/http.part4.test.ts new file mode 100644 index 00000000..e9b9d737 --- /dev/null +++ b/packages/runtime/src/http.part4.test.ts @@ -0,0 +1,1466 @@ +import { describe, expect, test } from "bun:test"; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import "./hooks/builtins"; // the email-watch routes provision a backing job, which validates isKnownHook("skill-script") +import { createHandler, resolveInlineUpload } from "./http"; +import { logDir, uploadsDir, webPortPath } from "./paths"; +import { clearWebTargetCache } from "./web-target"; +import { dirname, join } from "node:path"; +import { addAudit, appendEvent, insertChatBlock, isPlausibleMime, mutateState, readState, readTrace, recordProviderAuthFailure, sanitizeFilename, storeUpload, uploadStat } from "./state"; +import { getOrCreateAgentChat } from "./execution/chat"; +import { createScheduledJob } from "./jobs"; +import { removeMemoryDb } from "./state/memory-db"; +import { listProviders } from "./integrations/connectors/registry"; +import { resetGoogleLoginWebState } from "./integrations/connectors/google-login-web"; +import { awaitTunnelSettled, setTunnelDeps, type TunnelChild } from "./integrations/tunnel"; +import type { RuntimeConfig } from "./types"; +import type { LoginHandle, RelayDefaults, Session, Store, TunnelOptions } from "gini-relay"; + +// Stub a provider's host-environment `detect()` so the connector-detection +// endpoint test stays deterministic AND fast regardless of what's installed +// on the developer's PATH. The production `detect()` for claude-code / codex +// shells out via spawnSync (`which`, `claude auth status`), which on a machine +// with those CLIs installed dominates this test's wall time (the unstubbed +// detect endpoint test measured 1.524641s). Mirrors the same in-place +// swap-and-restore helper used by src/jobs/connector-detection.test.ts. The +// registry is a process-wide singleton, so the returned restore fn MUST run in +// a finally to avoid leaking the stub into sibling tests. +function stubProviderDetect( + providerId: string, + value: { detected: boolean; suggestedName?: string; message?: string } +): () => void { + const provider = listProviders().find((p) => p.id === providerId); + if (!provider) throw new Error(`Provider not registered: ${providerId}`); + const previous = provider.detect; + provider.detect = async () => value; + return () => { + provider.detect = previous; + }; +} + +// Companion to stubProviderDetect. After connector auto-detection creates a +// record for a provider that exposes a `probe()`, runConnectorDetection runs +// an initial checkConnector → provider.probe, which for claude-code shells out +// to `claude auth status` again. Stub the probe so the detection endpoint +// test never touches a real subprocess. Same swap-and-restore discipline. +function stubProviderProbe(providerId: string, value: { ok: boolean; message: string }): () => void { + const provider = listProviders().find((p) => p.id === providerId); + if (!provider) throw new Error(`Provider not registered: ${providerId}`); + const previous = provider.probe; + provider.probe = async () => value; + return () => { + provider.probe = previous; + }; +} + +describe("GET /api/files", () => { + test("returns content, absolute path, and name for an existing text file", async () => { + const config = testConfig("files-read-ok"); + const workspace = `/tmp/gini-files-test-${Date.now()}`; + mkdirSync(workspace, { recursive: true }); + config.workspaceRoot = workspace; + writeFileSync(`${workspace}/note.md`, "# Hello\n"); + const handler = createHandler(config); + + const file = await call(handler, config, "/api/files?path=note.md"); + expect(file.name).toBe("note.md"); + expect(file.absolutePath).toBe(`${workspace}/note.md`); + expect(file.content).toBe("# Hello\n"); + expect(file.binary).toBe(false); + expect(file.truncated).toBe(false); + + rmSync(workspace, { recursive: true, force: true }); + }); + + test("rejects a path that escapes the workspace with 400", async () => { + const config = testConfig("files-escape-400"); + const workspace = `/tmp/gini-files-test-${Date.now()}-escape`; + mkdirSync(workspace, { recursive: true }); + config.workspaceRoot = workspace; + const handler = createHandler(config); + + const response = await rawCall(handler, config, "/api/files?path=../outside.txt", {}, config.token); + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.error).toContain("outside workspace"); + + rmSync(workspace, { recursive: true, force: true }); + }); + + test("returns 404 for a non-existent file", async () => { + const config = testConfig("files-missing-404"); + const workspace = `/tmp/gini-files-test-${Date.now()}-missing`; + mkdirSync(workspace, { recursive: true }); + config.workspaceRoot = workspace; + const handler = createHandler(config); + + const response = await rawCall(handler, config, "/api/files?path=nope.txt", {}, config.token); + expect(response.status).toBe(404); + const body = await response.json(); + expect(body.error).toBe("File not found"); + + rmSync(workspace, { recursive: true, force: true }); + }); + + test("reports a binary file without returning its content", async () => { + const config = testConfig("files-binary"); + const workspace = `/tmp/gini-files-test-${Date.now()}-binary`; + mkdirSync(workspace, { recursive: true }); + config.workspaceRoot = workspace; + writeFileSync(`${workspace}/blob.bin`, Buffer.from([0x89, 0x50, 0x00, 0x01])); + const handler = createHandler(config); + + const file = await call(handler, config, "/api/files?path=blob.bin"); + expect(file.binary).toBe(true); + expect(file.content).toBe(null); + expect(file.bytes).toBe(4); + + rmSync(workspace, { recursive: true, force: true }); + }); + + test("returns 400 for a directory", async () => { + const config = testConfig("files-directory"); + const workspace = `/tmp/gini-files-test-${Date.now()}-directory`; + mkdirSync(workspace, { recursive: true }); + config.workspaceRoot = workspace; + mkdirSync(`${workspace}/sub`); + const handler = createHandler(config); + + const response = await rawCall(handler, config, "/api/files?path=sub", {}, config.token); + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.error).toBe("Not a file"); + + rmSync(workspace, { recursive: true, force: true }); + }); + + test("truncates a file larger than the read cap", async () => { + const config = testConfig("files-truncate"); + const workspace = `/tmp/gini-files-test-${Date.now()}-truncate`; + mkdirSync(workspace, { recursive: true }); + config.workspaceRoot = workspace; + writeFileSync(`${workspace}/big.txt`, "a".repeat(600 * 1024)); + const handler = createHandler(config); + + const file = await call(handler, config, "/api/files?path=big.txt"); + expect(file.truncated).toBe(true); + expect(file.content.length).toBe(512 * 1024); + expect(file.bytes).toBe(600 * 1024); + + rmSync(workspace, { recursive: true, force: true }); + }); + + test("raw=1 streams the file as a download attachment", async () => { + const config = testConfig("files-raw"); + const workspace = `/tmp/gini-files-test-${Date.now()}-raw`; + mkdirSync(workspace, { recursive: true }); + config.workspaceRoot = workspace; + writeFileSync(`${workspace}/note.md`, "# Hello\nworld\n"); + const handler = createHandler(config); + + const response = await rawCall(handler, config, "/api/files?path=note.md&raw=1", {}, config.token); + expect(response.status).toBe(200); + const disposition = response.headers.get("content-disposition") ?? ""; + expect(disposition).toContain("attachment"); + expect(disposition).toContain("note.md"); + expect(await response.text()).toBe("# Hello\nworld\n"); + + rmSync(workspace, { recursive: true, force: true }); + }); + + test("raw=1 download for a non-ASCII filename returns 200 with both header forms", async () => { + const config = testConfig("files-raw-unicode"); + const workspace = `/tmp/gini-files-test-${Date.now()}-raw-unicode`; + mkdirSync(workspace, { recursive: true }); + config.workspaceRoot = workspace; + writeFileSync(`${workspace}/café.md`, "# Hello\n"); + const handler = createHandler(config); + + const response = await rawCall(handler, config, `/api/files?path=${encodeURIComponent("café.md")}&raw=1`, {}, config.token); + expect(response.status).toBe(200); + const disposition = response.headers.get("content-disposition") ?? ""; + expect(disposition).toContain(`filename="caf_.md"`); + expect(disposition).toContain("filename*=UTF-8''"); + + rmSync(workspace, { recursive: true, force: true }); + }); + + test("inline=1 serves a PDF inline with the application/pdf content-type", async () => { + const config = testConfig("files-inline-pdf"); + const workspace = `/tmp/gini-files-test-${Date.now()}-inline-pdf`; + mkdirSync(workspace, { recursive: true }); + config.workspaceRoot = workspace; + writeFileSync(`${workspace}/doc.pdf`, Buffer.from("%PDF-1.4\n")); + const handler = createHandler(config); + + const response = await rawCall(handler, config, "/api/files?path=doc.pdf&raw=1&inline=1", {}, config.token); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("application/pdf"); + expect(response.headers.get("content-disposition")).toBe("inline"); + + rmSync(workspace, { recursive: true, force: true }); + }); + + test("inline=1 serves a PNG inline with the image/png content-type", async () => { + const config = testConfig("files-inline-png"); + const workspace = `/tmp/gini-files-test-${Date.now()}-inline-png`; + mkdirSync(workspace, { recursive: true }); + config.workspaceRoot = workspace; + writeFileSync(`${workspace}/pic.png`, Buffer.from([0x89, 0x50, 0x4e, 0x47])); + const handler = createHandler(config); + + const response = await rawCall(handler, config, "/api/files?path=pic.png&raw=1&inline=1", {}, config.token); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("image/png"); + expect(response.headers.get("content-disposition")).toBe("inline"); + + rmSync(workspace, { recursive: true, force: true }); + }); + + test("inline=1 never serves an SVG inline — falls back to attachment download", async () => { + const config = testConfig("files-inline-svg"); + const workspace = `/tmp/gini-files-test-${Date.now()}-inline-svg`; + mkdirSync(workspace, { recursive: true }); + config.workspaceRoot = workspace; + writeFileSync(`${workspace}/evil.svg`, ""); + const handler = createHandler(config); + + const response = await rawCall(handler, config, "/api/files?path=evil.svg&raw=1&inline=1", {}, config.token); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("application/octet-stream"); + expect(response.headers.get("content-disposition") ?? "").toContain("attachment"); + + rmSync(workspace, { recursive: true, force: true }); + }); + + test("inline=1 never serves HTML inline — falls back to attachment download", async () => { + const config = testConfig("files-inline-html"); + const workspace = `/tmp/gini-files-test-${Date.now()}-inline-html`; + mkdirSync(workspace, { recursive: true }); + config.workspaceRoot = workspace; + writeFileSync(`${workspace}/evil.html`, ""); + const handler = createHandler(config); + + const response = await rawCall(handler, config, "/api/files?path=evil.html&raw=1&inline=1", {}, config.token); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("application/octet-stream"); + expect(response.headers.get("content-disposition") ?? "").toContain("attachment"); + + rmSync(workspace, { recursive: true, force: true }); + }); + + // The upload gate accepts any plausible MIME, not just images/audio. These + // build the multipart request directly (not via call/rawCall, which pin + // content-type: application/json) so FormData sets its own multipart + // boundary header. + test("POST /api/uploads accepts an application/pdf file and serves it back", async () => { + const config = testConfig("uploads-pdf"); + const handler = createHandler(config); + const form = new FormData(); + form.set("file", new File([new Uint8Array([0x25, 0x50, 0x44, 0x46])], "report.pdf", { type: "application/pdf" })); + const response = await handler(new Request(`http://127.0.0.1:${config.port}/api/uploads`, { + method: "POST", + headers: { authorization: `Bearer ${config.token}` }, + body: form + })); + expect(response.status).toBe(201); + const ref = await response.json(); + expect(ref.mimeType).toBe("application/pdf"); + + const fetched = await rawCall(handler, config, `/api/uploads/${ref.id}`, {}, config.token); + expect(fetched.status).toBe(200); + expect(fetched.headers.get("content-type")).toBe("application/pdf"); + // Arbitrary MIME is accepted, so served uploads are forced to download and + // never sniffed — a text/html or SVG upload can't execute on the app origin. + expect(fetched.headers.get("content-disposition")).toBe("attachment"); + expect(fetched.headers.get("x-content-type-options")).toBe("nosniff"); + // The full-body response advertises range support so a media client knows + // it may stream via byte ranges (iOS AVPlayer requires this to play remote + // audio at all). + expect(fetched.headers.get("accept-ranges")).toBe("bytes"); + }); + + // `?inline=1` opts a safe-allowlisted upload into content-disposition: inline + // so a file/PDF chip can open it as a preview in a browser tab instead of + // forcing a download. These pin the per-mime allowlist decisions (the SSRF / + // top-level-script surface lives in the unsafe branches). + test("GET /api/uploads?inline=1 serves a PDF inline with its real type", async () => { + const config = testConfig("uploads-inline-pdf"); + const handler = createHandler(config); + const ref = storeUpload(config.instance, new Uint8Array([0x25, 0x50, 0x44, 0x46]), "application/pdf", "report.pdf"); + const res = await rawCall(handler, config, `/api/uploads/${ref.id}?inline=1`, {}, config.token); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toBe("application/pdf"); + expect(res.headers.get("content-disposition")).toBe("inline"); + expect(res.headers.get("x-content-type-options")).toBe("nosniff"); + }); + + test("GET /api/uploads?inline=1 serves a PNG inline with its real type", async () => { + const config = testConfig("uploads-inline-png"); + const handler = createHandler(config); + const ref = storeUpload(config.instance, new Uint8Array([0x89, 0x50, 0x4e, 0x47]), "image/png", "shot.png"); + const res = await rawCall(handler, config, `/api/uploads/${ref.id}?inline=1`, {}, config.token); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toBe("image/png"); + expect(res.headers.get("content-disposition")).toBe("inline"); + }); + + test("GET /api/uploads?inline=1 coerces a markdown upload to text/plain inline", async () => { + const config = testConfig("uploads-inline-md"); + const handler = createHandler(config); + const ref = storeUpload(config.instance, new TextEncoder().encode("# Title\n"), "text/markdown", "notes.md"); + const res = await rawCall(handler, config, `/api/uploads/${ref.id}?inline=1`, {}, config.token); + expect(res.status).toBe(200); + // Coerced to text/plain so the browser shows raw text rather than + // interpreting/executing the payload as a document. + expect(res.headers.get("content-type")).toBe("text/plain; charset=utf-8"); + expect(res.headers.get("content-disposition")).toBe("inline"); + }); + + test("GET /api/uploads?inline=1 never serves an SVG inline — keeps attachment", async () => { + const config = testConfig("uploads-inline-svg"); + const handler = createHandler(config); + const ref = storeUpload(config.instance, new TextEncoder().encode(""), "image/svg+xml", "x.svg"); + const res = await rawCall(handler, config, `/api/uploads/${ref.id}?inline=1`, {}, config.token); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toBe("image/svg+xml"); + expect(res.headers.get("content-disposition")).toBe("attachment"); + }); + + test("GET /api/uploads?inline=1 never serves HTML inline — keeps attachment", async () => { + const config = testConfig("uploads-inline-html"); + const handler = createHandler(config); + const ref = storeUpload(config.instance, new TextEncoder().encode("

x

"), "text/html", "x.html"); + const res = await rawCall(handler, config, `/api/uploads/${ref.id}?inline=1`, {}, config.token); + expect(res.status).toBe(200); + expect(res.headers.get("content-disposition")).toBe("attachment"); + }); + + test("resolveInlineUpload returns null without the inline param and gates by mime", () => { + // No inline param → always download, regardless of a safe mime. + expect(resolveInlineUpload(null, "application/pdf")).toBeNull(); + expect(resolveInlineUpload("", "application/pdf")).toBeNull(); + // Safe direct image/pdf → its own type. + expect(resolveInlineUpload("1", "application/pdf")).toEqual({ contentType: "application/pdf" }); + expect(resolveInlineUpload("1", "image/jpeg")).toEqual({ contentType: "image/jpeg" }); + // Text-like → coerced to text/plain. + expect(resolveInlineUpload("1", "text/csv")).toEqual({ contentType: "text/plain; charset=utf-8" }); + expect(resolveInlineUpload("1", "application/json")).toEqual({ contentType: "text/plain; charset=utf-8" }); + // Unsafe / unknown → null (download). + expect(resolveInlineUpload("1", "image/svg+xml")).toBeNull(); + expect(resolveInlineUpload("1", "text/html")).toBeNull(); + expect(resolveInlineUpload("1", "application/octet-stream")).toBeNull(); + }); + + // A mobile in-app browser can't send the bearer header or the gateway cookie, + // so the app mints a short-lived SIGNED url (POST /api/uploads/:id/sign) and + // opens that. These pin: the mint is bearer-gated, the signed url authorizes a + // header-less GET, and the signature is scoped + expiring. + test("POST /api/uploads/:id/sign mints a signed path that authorizes a header-less GET", async () => { + const config = testConfig("uploads-sign-ok"); + const handler = createHandler(config); + const ref = storeUpload(config.instance, new Uint8Array([0x25, 0x50, 0x44, 0x46]), "application/pdf", "report.pdf"); + + const minted = await rawCall(handler, config, `/api/uploads/${ref.id}/sign`, { method: "POST" }, config.token); + expect(minted.status).toBe(200); + const body = await minted.json(); + expect(body.path).toContain(`/api/uploads/${ref.id}?inline=1`); + expect(body.path).toMatch(/[?&]exp=\d+/); + expect(body.path).toMatch(/[?&]sig=[0-9a-f]{64}/); + expect(typeof body.exp).toBe("number"); + + // The signed path authorizes a GET with NO Authorization header. + const got = await rawCall(handler, config, body.path, {}); + expect(got.status).toBe(200); + expect(got.headers.get("content-type")).toBe("application/pdf"); + expect(got.headers.get("content-disposition")).toBe("inline"); + }); + + test("the mint endpoint itself still requires a bearer", async () => { + const config = testConfig("uploads-sign-gated"); + const handler = createHandler(config); + const ref = storeUpload(config.instance, new Uint8Array([1, 2, 3]), "application/pdf", "x.pdf"); + const res = await rawCall(handler, config, `/api/uploads/${ref.id}/sign`, { method: "POST" }); // no token + expect(res.status).toBe(401); + }); + + test("signing an unknown upload id returns 404 (no signature for absent bytes)", async () => { + const config = testConfig("uploads-sign-404"); + const handler = createHandler(config); + const res = await rawCall(handler, config, `/api/uploads/does-not-exist/sign`, { method: "POST" }, config.token); + expect(res.status).toBe(404); + }); + + test("an unsigned, unauthenticated upload GET is rejected 401", async () => { + const config = testConfig("uploads-sign-unsigned"); + const handler = createHandler(config); + const ref = storeUpload(config.instance, new Uint8Array([1, 2, 3]), "application/pdf", "x.pdf"); + const res = await rawCall(handler, config, `/api/uploads/${ref.id}?inline=1`, {}); // no token, no sig + expect(res.status).toBe(401); + }); + + test("a tampered upload id on a signed url fails (signature is scoped to one id)", async () => { + const config = testConfig("uploads-sign-scope"); + const handler = createHandler(config); + const a = storeUpload(config.instance, new Uint8Array([1, 2, 3]), "application/pdf", "a.pdf"); + const b = storeUpload(config.instance, new Uint8Array([4, 5, 6]), "application/pdf", "b.pdf"); + const minted = await rawCall(handler, config, `/api/uploads/${a.id}/sign`, { method: "POST" }, config.token); + const { path } = await minted.json(); + // Swap a's id for b's id but keep a's signature → must fail. + const sig = new URL(`http://x${path}`).searchParams.get("sig"); + const exp = new URL(`http://x${path}`).searchParams.get("exp"); + const forged = `/api/uploads/${b.id}?inline=1&exp=${exp}&sig=${sig}`; + const res = await rawCall(handler, config, forged, {}); + expect(res.status).toBe(401); + }); + + test("an expired signature is rejected 401", async () => { + const config = testConfig("uploads-sign-expired"); + const handler = createHandler(config); + const ref = storeUpload(config.instance, new Uint8Array([1, 2, 3]), "application/pdf", "x.pdf"); + // Mint with the minimum TTL clamp (30s), then forge an already-past exp by + // re-signing with a past timestamp using the same module the gateway uses. + const pastExp = Math.floor(Date.now() / 1000) - 10; + const { signUploadParams } = await import("./lib/upload-signing"); + const { sig } = signUploadParams(config.token, ref.id, pastExp); + const res = await rawCall(handler, config, `/api/uploads/${ref.id}?inline=1&exp=${pastExp}&sig=${sig}`, {}); + expect(res.status).toBe(401); + }); + + test("a caller-supplied ?ttl= is honored and clamped to the [30,600]s ceiling", async () => { + const config = testConfig("uploads-sign-ttl"); + const handler = createHandler(config); + const ref = storeUpload(config.instance, new Uint8Array([0x25, 0x50, 0x44, 0x46]), "application/pdf", "x.pdf"); + + // An in-range ttl rides through unchanged; an over-max one clamps to 600. + // The exp the mint returns is now + clampedTtl, so assert exp lands within a + // small window of that target (a couple seconds for clock drift across the call). + const now = Math.floor(Date.now() / 1000); + const inRange = await rawCall(handler, config, `/api/uploads/${ref.id}/sign?ttl=120`, { method: "POST" }, config.token); + expect(inRange.status).toBe(200); + expect((await inRange.json()).exp - now).toBeLessThanOrEqual(122); + + const clamped = await rawCall(handler, config, `/api/uploads/${ref.id}/sign?ttl=99999`, { method: "POST" }, config.token); + expect(clamped.status).toBe(200); + const clampedExp = (await clamped.json()).exp - Math.floor(Date.now() / 1000); + expect(clampedExp).toBeGreaterThan(595); + expect(clampedExp).toBeLessThanOrEqual(600); + }); + + test("a signed HEAD is also authorized (in-app browser preflight)", async () => { + const config = testConfig("uploads-sign-head"); + const handler = createHandler(config); + const ref = storeUpload(config.instance, new Uint8Array([0x25, 0x50, 0x44, 0x46]), "application/pdf", "x.pdf"); + const minted = await rawCall(handler, config, `/api/uploads/${ref.id}/sign`, { method: "POST" }, config.token); + const { path } = await minted.json(); + const res = await rawCall(handler, config, path, { method: "HEAD" }); + expect(res.status).toBe(200); + }); + + // iOS AVPlayer won't start a remote audio AVURLAsset unless the server honors + // Range requests (206 + Content-Range). These pin the range semantics of the + // upload GET so a regression can't silently break voice-message playback. + test("GET /api/uploads honors a bounded Range with 206 + Content-Range", async () => { + const config = testConfig("uploads-range-bounded"); + const handler = createHandler(config); + const bytes = new Uint8Array(Array.from({ length: 100 }, (_, i) => i)); + const ref = storeUpload(config.instance, bytes, "audio/wav"); + + const res = await rawCall(handler, config, `/api/uploads/${ref.id}`, { + headers: { range: "bytes=0-9" } + }, config.token); + expect(res.status).toBe(206); + expect(res.headers.get("content-range")).toBe("bytes 0-9/100"); + expect(res.headers.get("content-length")).toBe("10"); + expect(res.headers.get("accept-ranges")).toBe("bytes"); + const body = new Uint8Array(await res.arrayBuffer()); + expect(Array.from(body)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]); + }); + + test("GET /api/uploads serves a mid-file Range slice with exact bytes", async () => { + const config = testConfig("uploads-range-mid"); + const handler = createHandler(config); + const bytes = new Uint8Array(Array.from({ length: 100 }, (_, i) => i)); + const ref = storeUpload(config.instance, bytes, "audio/wav"); + + const res = await rawCall(handler, config, `/api/uploads/${ref.id}`, { + headers: { range: "bytes=10-19" } + }, config.token); + expect(res.status).toBe(206); + expect(res.headers.get("content-range")).toBe("bytes 10-19/100"); + const body = new Uint8Array(await res.arrayBuffer()); + expect(Array.from(body)).toEqual([10, 11, 12, 13, 14, 15, 16, 17, 18, 19]); + }); + + test("GET /api/uploads clamps an over-long Range end to the last byte", async () => { + const config = testConfig("uploads-range-clamp"); + const handler = createHandler(config); + const bytes = new Uint8Array(Array.from({ length: 50 }, (_, i) => i)); + const ref = storeUpload(config.instance, bytes, "audio/wav"); + + const res = await rawCall(handler, config, `/api/uploads/${ref.id}`, { + headers: { range: "bytes=40-999" } + }, config.token); + expect(res.status).toBe(206); + expect(res.headers.get("content-range")).toBe("bytes 40-49/50"); + expect(res.headers.get("content-length")).toBe("10"); + }); + + test("GET /api/uploads serves an open-ended Range to EOF", async () => { + const config = testConfig("uploads-range-open"); + const handler = createHandler(config); + const bytes = new Uint8Array(Array.from({ length: 50 }, (_, i) => i)); + const ref = storeUpload(config.instance, bytes, "audio/wav"); + + const res = await rawCall(handler, config, `/api/uploads/${ref.id}`, { + headers: { range: "bytes=48-" } + }, config.token); + expect(res.status).toBe(206); + expect(res.headers.get("content-range")).toBe("bytes 48-49/50"); + const body = new Uint8Array(await res.arrayBuffer()); + expect(Array.from(body)).toEqual([48, 49]); + }); + + test("GET /api/uploads serves a suffix Range (last N bytes)", async () => { + const config = testConfig("uploads-range-suffix"); + const handler = createHandler(config); + const bytes = new Uint8Array(Array.from({ length: 50 }, (_, i) => i)); + const ref = storeUpload(config.instance, bytes, "audio/wav"); + + const res = await rawCall(handler, config, `/api/uploads/${ref.id}`, { + headers: { range: "bytes=-5" } + }, config.token); + expect(res.status).toBe(206); + expect(res.headers.get("content-range")).toBe("bytes 45-49/50"); + const body = new Uint8Array(await res.arrayBuffer()); + expect(Array.from(body)).toEqual([45, 46, 47, 48, 49]); + }); + + test("GET /api/uploads returns 416 for a Range starting past EOF", async () => { + const config = testConfig("uploads-range-416"); + const handler = createHandler(config); + const bytes = new Uint8Array(Array.from({ length: 50 }, (_, i) => i)); + const ref = storeUpload(config.instance, bytes, "audio/wav"); + + const res = await rawCall(handler, config, `/api/uploads/${ref.id}`, { + headers: { range: "bytes=999-" } + }, config.token); + expect(res.status).toBe(416); + expect(res.headers.get("content-range")).toBe("bytes */50"); + }); + + test("GET /api/uploads ignores a malformed Range and serves the full 200 body", async () => { + const config = testConfig("uploads-range-malformed"); + const handler = createHandler(config); + const bytes = new Uint8Array(Array.from({ length: 50 }, (_, i) => i)); + const ref = storeUpload(config.instance, bytes, "audio/wav"); + + const res = await rawCall(handler, config, `/api/uploads/${ref.id}`, { + headers: { range: "lines=1-2" } + }, config.token); + expect(res.status).toBe(200); + expect(res.headers.get("content-length")).toBe("50"); + expect(res.headers.get("content-range")).toBeNull(); + expect(res.headers.get("accept-ranges")).toBe("bytes"); + }); + + test("GET /api/uploads treats a reversed Range (start>end) as malformed → full 200", async () => { + const config = testConfig("uploads-range-reversed"); + const handler = createHandler(config); + const bytes = new Uint8Array(Array.from({ length: 50 }, (_, i) => i)); + const ref = storeUpload(config.instance, bytes, "audio/wav"); + + const res = await rawCall(handler, config, `/api/uploads/${ref.id}`, { + headers: { range: "bytes=20-10" } + }, config.token); + expect(res.status).toBe(200); + expect(res.headers.get("content-length")).toBe("50"); + }); + + test("GET /api/uploads treats an empty suffix Range (bytes=-) as malformed → full 200", async () => { + const config = testConfig("uploads-range-emptysuffix"); + const handler = createHandler(config); + const bytes = new Uint8Array(Array.from({ length: 50 }, (_, i) => i)); + const ref = storeUpload(config.instance, bytes, "audio/wav"); + + const res = await rawCall(handler, config, `/api/uploads/${ref.id}`, { + headers: { range: "bytes=-" } + }, config.token); + expect(res.status).toBe(200); + expect(res.headers.get("content-length")).toBe("50"); + }); + + test("GET /api/uploads returns 416 for a zero-length suffix Range (bytes=-0)", async () => { + const config = testConfig("uploads-range-zerosuffix"); + const handler = createHandler(config); + const bytes = new Uint8Array(Array.from({ length: 50 }, (_, i) => i)); + const ref = storeUpload(config.instance, bytes, "audio/wav"); + + const res = await rawCall(handler, config, `/api/uploads/${ref.id}`, { + headers: { range: "bytes=-0" } + }, config.token); + expect(res.status).toBe(416); + expect(res.headers.get("content-range")).toBe("bytes */50"); + }); + + test("GET /api/uploads returns 416 for any Range against a zero-byte upload", async () => { + const config = testConfig("uploads-range-empty-file"); + const handler = createHandler(config); + // storeUpload rejects an empty body, so write the manifest+bytes directly to + // exercise the total===0 branch (a 0-byte stored file can't satisfy a range). + const ref = storeUpload(config.instance, new Uint8Array([1]), "audio/wav"); + writeFileSync(join(uploadsDir(config.instance), `${ref.id}.wav`), new Uint8Array([])); + + const res = await rawCall(handler, config, `/api/uploads/${ref.id}`, { + headers: { range: "bytes=0-10" } + }, config.token); + expect(res.status).toBe(416); + expect(res.headers.get("content-range")).toBe("bytes */0"); + }); + + test("HEAD /api/uploads advertises accept-ranges and the content length", async () => { + const config = testConfig("uploads-head-range"); + const handler = createHandler(config); + const bytes = new Uint8Array(Array.from({ length: 42 }, (_, i) => i)); + const ref = storeUpload(config.instance, bytes, "audio/wav"); + + const res = await rawCall(handler, config, `/api/uploads/${ref.id}`, { + method: "HEAD" + }, config.token); + expect(res.status).toBe(200); + expect(res.headers.get("accept-ranges")).toBe("bytes"); + expect(res.headers.get("content-length")).toBe("42"); + }); + + test("POST /api/uploads accepts a text/csv file", async () => { + const config = testConfig("uploads-csv"); + const handler = createHandler(config); + const form = new FormData(); + form.set("file", new File(["a,b\n1,2\n"], "data.csv", { type: "text/csv" })); + const response = await handler(new Request(`http://127.0.0.1:${config.port}/api/uploads`, { + method: "POST", + headers: { authorization: `Bearer ${config.token}` }, + body: form + })); + expect(response.status).toBe(201); + const ref = await response.json(); + expect(ref.mimeType).toBe("text/csv"); + }); + + // The 415 gate fires on a structurally-invalid mime (no slash / whitespace). + // It can't be reached through a real request: Bun's server-side + // request.formData() normalizes an invalid part Content-Type (e.g. + // "notamime") to application/octet-stream, and its File/FormData encoder + // sniffs the part mime from the filename extension — either way the part + // arrives plausible, so the predicate is exercised directly to pin the + // 415-triggering condition. + test("isPlausibleMime rejects structurally-invalid mimes (the 415 gate)", () => { + expect(isPlausibleMime("notamime")).toBe(false); + expect(isPlausibleMime("text/csv")).toBe(true); + expect(isPlausibleMime("application/pdf")).toBe(true); + }); + + test("POST /api/uploads rejects an empty file with 400", async () => { + const config = testConfig("uploads-empty"); + const handler = createHandler(config); + const form = new FormData(); + form.set("file", new File([], "empty.csv", { type: "text/csv" })); + const response = await handler(new Request(`http://127.0.0.1:${config.port}/api/uploads`, { + method: "POST", + headers: { authorization: `Bearer ${config.token}` }, + body: form + })); + expect(response.status).toBe(400); + }); + + test("POST /api/uploads rejects a file over the size cap with 413", async () => { + process.env.GINI_MAX_UPLOAD_BYTES = "10"; + try { + const config = testConfig("uploads-toolarge"); + const handler = createHandler(config); + const form = new FormData(); + form.set("file", new File(["this body is well over ten bytes"], "big.csv", { type: "text/csv" })); + const response = await handler(new Request(`http://127.0.0.1:${config.port}/api/uploads`, { + method: "POST", + headers: { authorization: `Bearer ${config.token}` }, + body: form + })); + expect(response.status).toBe(413); + } finally { + delete process.env.GINI_MAX_UPLOAD_BYTES; + } + }); + + test("storeUpload sanitizes a filename with embedded newline/control chars to a single line", () => { + const config = testConfig("uploads-filename"); + const ref = storeUpload(config.instance, new Uint8Array([1, 2, 3]), "text/csv", "a\nb\t\rc.csv"); + const stat = uploadStat(config.instance, ref.id); + expect(stat?.filename).toBe("a b c.csv"); + expect(stat?.filename).not.toContain("\n"); + }); + + // The exported sanitizeFilename is also applied at the model-facing render + // in buildAttachmentContent, covering manifests written outside storeUpload. + test("sanitizeFilename strips control chars, collapses whitespace, and caps length", () => { + expect(sanitizeFilename("a\nb\tc.csv")).toBe("a b c.csv"); + expect(sanitizeFilename("x".repeat(300)).length).toBe(255); + }); +}); + +describe("email watcher routes", () => { + test("GET /api/email/watchers is scoped to the active agent", async () => { + const config = testConfig("http-email-list-scope"); + const handler = createHandler(config); + const initial = await call(handler, config, "/api/agents"); + const defaultAgentId = initial.activeAgentId as string; + const second = await call(handler, config, "/api/agents", { + method: "POST", + body: JSON.stringify({ name: "scout" }) + }); + + // A watcher created under the default agent. + const underDefault = await call(handler, config, "/api/email/watchers", { + method: "POST", + body: JSON.stringify({ sender: "alice@example.com" }) + }); + // Switch to scout and create another watcher under it. + await call(handler, config, `/api/agents/${second.id}/use`, { method: "POST" }); + const underScout = await call(handler, config, "/api/email/watchers", { + method: "POST", + body: JSON.stringify({ sender: "bob@example.com" }) + }); + + // GET now reflects the active agent (scout) — only its watcher. + const scoutList = await call(handler, config, "/api/email/watchers"); + expect(scoutList.map((w: { id: string }) => w.id)).toEqual([(underScout as { id: string }).id]); + + // Switch back: GET returns only the default agent's watcher. + await call(handler, config, `/api/agents/${defaultAgentId}/use`, { method: "POST" }); + const defaultList = await call(handler, config, "/api/email/watchers"); + expect(defaultList.map((w: { id: string }) => w.id)).toEqual([(underDefault as { id: string }).id]); + }); + + test("PATCH /api/email/watchers/:id toggles enabled and tears down / recreates the shared job", async () => { + const config = testConfig("http-email-patch"); + const handler = createHandler(config); + const created = await call(handler, config, "/api/email/watchers", { + method: "POST", + body: JSON.stringify({ sender: "alice@example.com" }) + }); + const id = (created as { id: string }).id; + const jobId = (created as { jobId: string }).jobId; + expect(jobId).toBeString(); + // The shared email-watch job is active and watches this sole watcher. + const sharedJob = () => + readState(config.instance).jobs.find( + (j) => (j.preRunHook?.config as { skill?: string })?.skill === "gmail-watch" + ); + expect(sharedJob()?.id).toBe(jobId); + expect(sharedJob()?.status).toBe("active"); + + // Disabling the sole watcher tears the shared job down (nothing to poll). + const disabled = await call(handler, config, `/api/email/watchers/${id}`, { + method: "PATCH", + body: JSON.stringify({ enabled: false }) + }); + expect((disabled as { enabled: boolean }).enabled).toBe(false); + expect(sharedJob()).toBeUndefined(); + + // Re-enabling recreates the shared job and re-stamps the watcher's jobId. + const enabled = await call(handler, config, `/api/email/watchers/${id}`, { + method: "PATCH", + body: JSON.stringify({ enabled: true }) + }); + expect((enabled as { enabled: boolean }).enabled).toBe(true); + expect(sharedJob()).toBeDefined(); + expect((enabled as { jobId: string }).jobId).toBe(sharedJob()!.id); + }); + + test("PATCH /api/email/watchers/:id rejects a non-boolean enabled with 400", async () => { + const config = testConfig("http-email-patch-bad"); + const handler = createHandler(config); + const created = await call(handler, config, "/api/email/watchers", { + method: "POST", + body: JSON.stringify({ sender: "bob@example.com" }) + }); + const id = (created as { id: string }).id; + const response = await rawCall( + handler, + config, + `/api/email/watchers/${id}`, + { method: "PATCH", body: JSON.stringify({ enabled: "yes" }) }, + config.token + ); + expect(response.status).toBe(400); + }); + + test("PATCH /api/email/watchers/:id returns 404 for an unknown watcher", async () => { + const config = testConfig("http-email-patch-404"); + const handler = createHandler(config); + const response = await rawCall( + handler, + config, + "/api/email/watchers/nope", + { method: "PATCH", body: JSON.stringify({ enabled: false }) }, + config.token + ); + expect(response.status).toBe(404); + }); + + test("PATCH /api/email/watchers/:id clears the objective with an explicit null", async () => { + const config = testConfig("http-email-patch-clear"); + const handler = createHandler(config); + const created = await call(handler, config, "/api/email/watchers", { + method: "POST", + body: JSON.stringify({ sender: "bob@example.com", objective: "Get a refund" }) + }); + const id = (created as { id: string }).id; + expect((created as { objective?: string }).objective).toBe("Get a refund"); + // Explicit null clears (distinct from omitted = unchanged, "" = 400). + const cleared = await call(handler, config, `/api/email/watchers/${id}`, { + method: "PATCH", + body: JSON.stringify({ objective: null }) + }); + expect((cleared as { objective?: string }).objective).toBeUndefined(); + // An empty-string objective is still rejected. + const empty = await rawCall( + handler, + config, + `/api/email/watchers/${id}`, + { method: "PATCH", body: JSON.stringify({ objective: "" }) }, + config.token + ); + expect(empty.status).toBe(400); + }); + + test("POST /api/google/accounts rejects a configDir outside the allowed roots", async () => { + const config = testConfig("http-google-accounts-configdir"); + const handler = createHandler(config); + // A relative path is rejected. + const relative = await rawCall( + handler, + config, + "/api/google/accounts", + { method: "POST", body: JSON.stringify({ tag: "x", configDir: "relative/gws" }) }, + config.token + ); + expect(relative.status).toBe(400); + expect((await relative.json()).error).toContain("configDir must be"); + // An absolute but unrelated path is rejected (defense-in-depth — never + // reaches registerAccount / a real gws spawn). + const arbitrary = await rawCall( + handler, + config, + "/api/google/accounts", + { method: "POST", body: JSON.stringify({ tag: "x", configDir: "/etc/passwd" }) }, + config.token + ); + expect(arbitrary.status).toBe(400); + expect((await arbitrary.json()).error).toContain("configDir must be"); + }); + + test("POST /api/google/accounts/provision requires all three credential fields", async () => { + const config = testConfig("http-google-provision-400"); + const handler = createHandler(config); + for (const body of [ + {}, + { clientId: "id", clientSecret: "secret" }, + { clientId: "id", refreshToken: "token" }, + { clientId: "id", clientSecret: "secret", refreshToken: " " } + ]) { + const response = await rawCall( + handler, + config, + "/api/google/accounts/provision", + { method: "POST", body: JSON.stringify(body) }, + config.token + ); + expect(response.status).toBe(400); + expect((await response.json()).error).toContain("clientId, clientSecret, and refreshToken"); + } + }); + + test("POST /api/google/accounts/provision registers a trusted account (201)", async () => { + const config = testConfig("http-google-provision-201"); + const handler = createHandler(config); + // The registry is HOME-keyed (machine-global), so point HOME at a scratch + // dir for the duration of this test. + const prevHome = process.env.HOME; + const scratchHome = join(`/tmp/gini-http-tests-${import.meta.file}`, `provision-home-${process.pid}-${Date.now()}`); + mkdirSync(scratchHome, { recursive: true }); + process.env.HOME = scratchHome; + try { + const response = await rawCall( + handler, + config, + "/api/google/accounts/provision", + { + method: "POST", + body: JSON.stringify({ + clientId: "edge-client", + clientSecret: "edge-secret", + refreshToken: "1//refresh", + email: "ada@example.com", + principal: "sub-ada" + }) + }, + config.token + ); + expect(response.status).toBe(201); + const account = await response.json(); + expect(account.tag).toBe("ada"); + expect(account.provisioned).toBe(true); + expect(existsSync(join(account.configDir, "credentials.json"))).toBe(true); + } finally { + if (prevHome === undefined) delete process.env.HOME; + else process.env.HOME = prevHome; + rmSync(scratchHome, { recursive: true, force: true }); + } + }); + + test("POST /api/google/accounts/provision with primary:true heals the hosted baked credential in place", async () => { + const config = testConfig("http-google-provision-primary"); + const handler = createHandler(config); + const prevHome = process.env.HOME; + const prevHosted = process.env.GINI_HOSTED; + const prevCred = process.env.GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE; + const scratchHome = join(`/tmp/gini-http-tests-${import.meta.file}`, `provision-primary-${process.pid}-${Date.now()}`); + const credentialsFile = join(scratchHome, ".config", "gws-hosted", "credentials.json"); + mkdirSync(dirname(credentialsFile), { recursive: true }); + writeFileSync(credentialsFile, "{}"); + process.env.HOME = scratchHome; + process.env.GINI_HOSTED = "1"; + process.env.GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE = credentialsFile; + try { + const { ensureHostedPrimaryAccount } = await import("./integrations/connectors/google-accounts"); + const { readGoogleAccounts } = await import("./state/google-accounts"); + const primary = await ensureHostedPrimaryAccount(); + expect(primary?.tag).toBe("primary"); + + const response = await rawCall( + handler, + config, + "/api/google/accounts/provision", + { + method: "POST", + body: JSON.stringify({ + clientId: "edge-client", + clientSecret: "edge-secret", + refreshToken: "1//healed", + email: "wilson@example.com", + principal: "sub-wilson", + primary: true + }) + }, + config.token + ); + expect(response.status).toBe(201); + const account = await response.json(); + // Landed on the baked primary row — no duplicate minted. + expect(account.id).toBe(primary!.id); + expect(account.configDir).toBe(dirname(credentialsFile)); + expect(readGoogleAccounts()).toHaveLength(1); + // The fresh token overwrote the baked file gws actually reads. + expect(JSON.parse(readFileSync(credentialsFile, "utf8")).refresh_token).toBe("1//healed"); + } finally { + if (prevHome === undefined) delete process.env.HOME; + else process.env.HOME = prevHome; + if (prevHosted === undefined) delete process.env.GINI_HOSTED; + else process.env.GINI_HOSTED = prevHosted; + if (prevCred === undefined) delete process.env.GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE; + else process.env.GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE = prevCred; + rmSync(scratchHome, { recursive: true, force: true }); + } + }); + + test("POST /api/google/accounts/provision with makePrimary:true persists the account as the primary", async () => { + const config = testConfig("http-google-provision-make-primary"); + const handler = createHandler(config); + const prevHome = process.env.HOME; + const scratchHome = join(`/tmp/gini-http-tests-${import.meta.file}`, `provision-make-primary-${process.pid}-${Date.now()}`); + mkdirSync(scratchHome, { recursive: true }); + process.env.HOME = scratchHome; + try { + const { readPrimaryGoogleAccountId } = await import("./state/google-accounts"); + const provision = (body: Record) => + rawCall( + handler, + config, + "/api/google/accounts/provision", + { method: "POST", body: JSON.stringify(body) }, + config.token + ); + // An add (no flag) never touches the primary. + const added = await provision({ + clientId: "edge-client", + clientSecret: "edge-secret", + refreshToken: "1//refresh-a", + email: "ada@example.com", + principal: "sub-ada" + }); + expect(added.status).toBe(201); + expect(readPrimaryGoogleAccountId()).toBeUndefined(); + // A sign-in-intent provision flips the persisted primary to its account. + const signedIn = await provision({ + clientId: "edge-client", + clientSecret: "edge-secret", + refreshToken: "1//refresh-b", + email: "bob@example.com", + principal: "sub-bob", + makePrimary: true + }); + expect(signedIn.status).toBe(201); + const account = await signedIn.json(); + expect(readPrimaryGoogleAccountId()).toBe(account.id); + } finally { + if (prevHome === undefined) delete process.env.HOME; + else process.env.HOME = prevHome; + rmSync(scratchHome, { recursive: true, force: true }); + } + }); + + test("GET /api/google/auth-mode reflects the hosted marker", async () => { + const config = testConfig("http-google-auth-mode"); + const handler = createHandler(config); + const prevHosted = process.env.GINI_HOSTED; + delete process.env.GINI_HOSTED; + try { + expect(await call(handler, config, "/api/google/auth-mode")).toEqual({ mode: "loopback" }); + process.env.GINI_HOSTED = "1"; + expect(await call(handler, config, "/api/google/auth-mode")).toEqual({ mode: "edge" }); + } finally { + if (prevHosted === undefined) delete process.env.GINI_HOSTED; + else process.env.GINI_HOSTED = prevHosted; + } + }); + + test("POST /api/google/accounts no longer requires a tag (configDir validation still applies)", async () => { + const config = testConfig("http-google-accounts-no-tag"); + const handler = createHandler(config); + // A tag-less body reaches the configDir gate (previously a missing tag was + // its own 400) — the register path derives the tag from the live session. + const response = await rawCall( + handler, + config, + "/api/google/accounts", + { method: "POST", body: JSON.stringify({ configDir: "relative/gws" }) }, + config.token + ); + expect(response.status).toBe(400); + expect((await response.json()).error).toContain("configDir must be"); + }); + + test("GET /api/google/login/start 302s to Google consent with PKCE and the browser-facing redirect_uri", async () => { + const config = testConfig("http-google-login-start"); + const handler = createHandler(config); + const prevHosted = process.env.GINI_HOSTED; + delete process.env.GINI_HOSTED; + resetGoogleLoginWebState(); + try { + const origin = encodeURIComponent("http://127.0.0.1:3059"); + const response = await rawCall( + handler, + config, + `/api/google/login/start?returnTo=%2Fonboarding&origin=${origin}`, + {}, + config.token + ); + expect(response.status).toBe(302); + const location = new URL(response.headers.get("location") ?? ""); + expect(location.host).toBe("accounts.google.com"); + expect(location.searchParams.get("redirect_uri")).toBe( + "http://127.0.0.1:3059/api/runtime/google/login/callback" + ); + expect(location.searchParams.get("code_challenge_method")).toBe("S256"); + expect(location.searchParams.get("state")).toBeTruthy(); + } finally { + if (prevHosted === undefined) delete process.env.GINI_HOSTED; + else process.env.GINI_HOSTED = prevHosted; + resetGoogleLoginWebState(); + } + }); + + test("GET /api/google/login/start 400s on a non-loopback origin and in edge auth mode", async () => { + const config = testConfig("http-google-login-start-400"); + const handler = createHandler(config); + const prevHosted = process.env.GINI_HOSTED; + delete process.env.GINI_HOSTED; + resetGoogleLoginWebState(); + try { + const lan = await rawCall( + handler, + config, + `/api/google/login/start?origin=${encodeURIComponent("http://192.168.1.20:3000")}`, + {}, + config.token + ); + expect(lan.status).toBe(400); + expect((await lan.json()).error).toContain("loopback"); + process.env.GINI_HOSTED = "1"; + const edge = await rawCall( + handler, + config, + `/api/google/login/start?origin=${encodeURIComponent("http://127.0.0.1:3059")}`, + {}, + config.token + ); + expect(edge.status).toBe(400); + expect((await edge.json()).error).toContain("edge sign-in flow"); + } finally { + if (prevHosted === undefined) delete process.env.GINI_HOSTED; + else process.env.GINI_HOSTED = prevHosted; + resetGoogleLoginWebState(); + } + }); + + test("GET /api/google/login/callback with an unknown state 302s back with googleAddError=1 (never an error page)", async () => { + const config = testConfig("http-google-login-callback"); + const handler = createHandler(config); + resetGoogleLoginWebState(); + const response = await rawCall( + handler, + config, + "/api/google/login/callback?code=bogus&state=bogus", + {}, + config.token + ); + expect(response.status).toBe(302); + expect(response.headers.get("location")).toBe("/onboarding?step=accounts&googleAddError=1"); + }); +}); + +describe("response compression", () => { + // Seed enough audit rows that /api/state's JSON body clears the 1 KB + // threshold, so the compressible-path assertions exercise real compression + // rather than the too-small skip. + async function seedBulkyState(config: RuntimeConfig): Promise { + // Awaited by every caller: mutateState is async (it chains on the + // per-instance write lock), so without awaiting it the subsequent + // readState in the request under test can race the write and read state + // before the bulky audit rows land — intermittently dropping the body + // under the 1 KB threshold and skipping compression. + await mutateState(config.instance, (state) => { + for (let i = 0; i < 200; i += 1) { + addAudit(state, { + actor: "runtime", + action: "test.bulk", + target: `row-${i}`, + risk: "low", + evidence: { note: "padding to push the state payload past the gzip threshold" } + }, { system: true }); + } + }); + } + + test("gzips a large JSON response when the client accepts only gzip", async () => { + const config = testConfig("gzip-json"); + const handler = createHandler(config); + await seedBulkyState(config); + const response = await rawCall( + handler, + config, + "/api/state", + { headers: { "accept-encoding": "gzip" } }, + config.token + ); + expect(response.status).toBe(200); + expect(response.headers.get("content-encoding")).toBe("gzip"); + expect(response.headers.get("vary") ?? "").toContain("Accept-Encoding"); + // The body is real gzip and decodes back to valid JSON state. + const compressed = new Uint8Array(await response.arrayBuffer()); + const decoded = JSON.parse(new TextDecoder().decode(Bun.gunzipSync(compressed))); + expect(decoded.instance).toBe(config.instance); + // The compressed transfer is materially smaller than the raw JSON. + const rawLen = new TextEncoder().encode(JSON.stringify(decoded)).length; + expect(compressed.byteLength).toBeLessThan(rawLen); + }); + + test("prefers brotli when the client accepts both br and gzip", async () => { + const config = testConfig("br-json"); + const handler = createHandler(config); + await seedBulkyState(config); + const response = await rawCall( + handler, + config, + "/api/state", + { headers: { "accept-encoding": "gzip, br" } }, + config.token + ); + expect(response.status).toBe(200); + expect(response.headers.get("content-encoding")).toBe("br"); + expect(response.headers.get("vary") ?? "").toContain("Accept-Encoding"); + // The body is real brotli and decodes back to valid JSON state. + const { brotliDecompressSync } = await import("node:zlib"); + const compressed = new Uint8Array(await response.arrayBuffer()); + const decoded = JSON.parse(new TextDecoder().decode(brotliDecompressSync(compressed))); + expect(decoded.instance).toBe(config.instance); + const rawLen = new TextEncoder().encode(JSON.stringify(decoded)).length; + expect(compressed.byteLength).toBeLessThan(rawLen); + }); + + test("falls back to gzip when br is explicitly opted out (br;q=0)", async () => { + const config = testConfig("br-optout"); + const handler = createHandler(config); + await seedBulkyState(config); + const response = await rawCall( + handler, + config, + "/api/state", + { headers: { "accept-encoding": "br;q=0, gzip" } }, + config.token + ); + expect(response.headers.get("content-encoding")).toBe("gzip"); + }); + + test("does not compress when the client omits Accept-Encoding", async () => { + const config = testConfig("gzip-absent"); + const handler = createHandler(config); + await seedBulkyState(config); + const response = await rawCall(handler, config, "/api/state", {}, config.token); + expect(response.status).toBe(200); + expect(response.headers.get("content-encoding")).toBeNull(); + const body = await response.json(); + expect(body.instance).toBe(config.instance); + }); + + test("honors gzip;q=0 as an explicit opt-out", async () => { + const config = testConfig("gzip-q0"); + const handler = createHandler(config); + await seedBulkyState(config); + const response = await rawCall( + handler, + config, + "/api/state", + { headers: { "accept-encoding": "gzip;q=0" } }, + config.token + ); + expect(response.headers.get("content-encoding")).toBeNull(); + }); + + test("leaves a small JSON response uncompressed (below threshold)", async () => { + const config = testConfig("gzip-small"); + const handler = createHandler(config); + const response = await rawCall( + handler, + config, + "/api/tasks?agentId=does_not_exist", + { headers: { "accept-encoding": "gzip" } }, + config.token + ); + expect(response.status).toBe(200); + expect(response.headers.get("content-encoding")).toBeNull(); + // Body is still the intact (empty) JSON array. + expect(await response.json()).toEqual([]); + }); + + test("never compresses an SSE event stream", async () => { + const config = testConfig("gzip-sse"); + const handler = createHandler(config); + const response = await rawCall( + handler, + config, + "/api/events/stream", + { headers: { "accept-encoding": "gzip" } }, + config.token + ); + try { + expect(response.headers.get("content-type") ?? "").toContain("text/event-stream"); + expect(response.headers.get("content-encoding")).toBeNull(); + } finally { + await response.body?.cancel(); + } + }); + + test("never compresses a web-proxy path (only the native /api surface is compressed)", async () => { + // /api/runtime/* is a web-proxy path (isWebProxyPath). Those responses are + // reachable unauthenticated via the pairing bootstrap and are gzip'd by the + // web child itself, so the gateway must NOT brotli/gzip them — that would + // let an unauthenticated client burn CPU on compression before any session + // check. Unauthenticated relay-less loopback returns 401 here, but the + // point is the absence of a gateway-applied Content-Encoding regardless. + const config = testConfig("gzip-webproxy"); + const handler = createHandler(config); + const response = await rawCall( + handler, + config, + "/api/runtime/status", + { headers: { "accept-encoding": "gzip, br" } } + ); + expect(response.headers.get("content-encoding")).toBeNull(); + }); + + test("never compresses a 206 byte-range response (preserves Content-Range semantics)", async () => { + // A compressible upload (text/csv) over the 1 KB threshold, fetched with a + // Range. The 206 carries Content-Range computed on the UNENCODED bytes, so + // adding Content-Encoding would corrupt the range contract (RFC 7233). + const config = testConfig("gzip-range"); + const handler = createHandler(config); + const bytes = new TextEncoder().encode("col_a,col_b,col_c\n" + "1,2,3\n".repeat(500)); + const ref = storeUpload(config.instance, bytes, "text/csv"); + const res = await rawCall( + handler, + config, + `/api/uploads/${ref.id}`, + { headers: { range: "bytes=0-1999", "accept-encoding": "br, gzip" } }, + config.token + ); + expect(res.status).toBe(206); + expect(res.headers.get("content-range")).toContain("bytes 0-1999/"); + expect(res.headers.get("content-encoding")).toBeNull(); + }); +}); + +async function call(handler: ReturnType, config: RuntimeConfig, path: string, init: RequestInit = {}) { + return callWithToken(handler, config, config.token, path, init); +} + +async function callWithToken(handler: ReturnType, config: RuntimeConfig, token: string, path: string, init: RequestInit = {}) { + const response = await rawCall(handler, config, path, init, token); + const value = await response.json(); + if (!response.ok) throw new Error(value.error ?? `HTTP ${response.status}`); + return value; +} + +async function rawCall(handler: ReturnType, config: RuntimeConfig, path: string, init: RequestInit = {}, token?: string) { + const response = await handler(new Request(`http://127.0.0.1:${config.port}${path}`, { + ...init, + headers: { "content-type": "application/json", ...(token ? { authorization: `Bearer ${token}` } : {}), ...(init.headers ?? {}) } + })); + return response; +} + +// Opens a chat SSE stream, pumps frames until the initial `chat_session` +// frame lands (which only happens inside the stream's start() — i.e. AFTER +// presence registration), runs the caller's `whileOpen` assertions, then +// ALWAYS cancels the reader in a finally so a thrown assertion can't leak +// the stream (its keepalive interval and presence entry would otherwise +// survive the test). Returns after the reader is cancelled. +async function withChatStream( + handler: ReturnType, + config: RuntimeConfig, + sessionId: string, + init: RequestInit, + whileOpen: () => void +): Promise { + const response = await rawCall(handler, config, `/api/chat/${sessionId}/stream`, init, config.token); + expect(response.status).toBe(200); + const reader = response.body?.getReader(); + expect(reader).toBeDefined(); + if (!reader) return; + try { + const decoder = new TextDecoder(); + let buffer = ""; + const deadline = Date.now() + 500; + while (Date.now() < deadline) { + const { done, value } = await Promise.race([ + reader.read(), + new Promise<{ done: boolean; value: undefined }>((resolve) => + setTimeout(() => resolve({ done: false, value: undefined }), 50) + ) + ]); + if (done) break; + if (value) buffer += decoder.decode(value); + if (buffer.includes("event: chat_session")) break; + } + expect(buffer).toContain("event: chat_session"); + whileOpen(); + } finally { + await reader.cancel(); + } + // cancel() runs the stream's cleanup synchronously in-process; give the + // microtask queue one turn to settle before the caller asserts the + // post-close registry state. + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +function testConfig(instance: string): RuntimeConfig { + const root = `/tmp/gini-http-tests-${import.meta.file}`; + process.env.GINI_STATE_ROOT = root; + process.env.GINI_LOG_ROOT = `${root}-logs`; + // Drop the cached SQLite handle for this instance before nuking the + // directory. Without this, a prior test that opened the per-instance + // memory DB leaves an open `bun:sqlite` handle pointing at the now- + // unlinked file. The next call to getMemoryDb returns that cached + // handle (the cache key is the instance name) and any write fails + // because the inode is gone. removeMemoryDb closes the cached handle + // AND unlinks the file + WAL/SHM siblings in one shot. + removeMemoryDb(instance); + // resumeChatTask polls for the loop's flip to waiting_approval before + // staging a tool result. In-process the flip lands within a couple of + // mutateState boundaries, and several fill_secret / approval tests seed a + // task that never reaches waiting_approval at all — so the production + // 1000ms/100ms budget is pure dead wall here (the fill_secret leak test + // measured 1079.00ms in isolation, nearly all of it this poll). Shrink the + // budget via the server-side env knob the production code reads (default + // preserved at 1000/100); the race still resolves well within 40ms over 5ms + // ticks in-process. + process.env.GINI_RESUME_WAIT_BUDGET_MS = "40"; + process.env.GINI_RESUME_WAIT_TICK_MS = "5"; + rmSync(`${root}/instances/${instance}`, { recursive: true, force: true }); + return { + instance, + port: 7337, + token: "test-token", + provider: { name: "echo", model: "gini-echo-v0" }, + workspaceRoot: "/tmp", + stateRoot: `${root}/instances/${instance}`, + logRoot: `${root}-logs/${instance}`, + // These tests predate the approval-mode flip and rely on the + // gated path. Force "strict" to keep them honest; new defaults + // are exercised in approval-mode.test.ts. + approvalMode: "strict" + }; +} + +// Write a log file under the test config's instance log dir so the /api/logs +// route reads it. testConfig nukes the instance state dir and points +// GINI_LOG_ROOT at a sibling tree, so this lands in a clean per-instance dir. +function seedLogFile(config: RuntimeConfig, filename: string, body: string): void { + const dir = logDir(config.instance); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, filename), body); +} + +// Seed a typed api-key credential so the per-(skill, credential) consent gate +// (firstUngrantedCredential) treats it as carrying a secret that needs consent. +async function seedTypedCredential(config: RuntimeConfig, name: string, provider: string) { + const at = new Date().toISOString(); + await mutateState(config.instance, (state) => { + state.connectors.push({ + id: `id_${name}`, + instance: state.instance, + name, + provider, + type: "api-key", + status: "configured", + scopes: [], + secretRefs: [{ purpose: name, path: `/tmp/${name}.json` }], + createdAt: at, + updatedAt: at, + health: "healthy", + source: "user" + }); + }); +} + +async function waitForTask( + handler: ReturnType, + config: RuntimeConfig, + taskId: string, + // The approve endpoint returns at decision durability while the side + // effect + resume run detached, so a post-approve wait must exclude + // the still-parked waiting_approval status or it returns the + // pre-approve park immediately. + statuses: string[] = ["completed", "failed", "waiting_approval"] +) { + // Phase 5 added auto-recall + auto-retain to runTask. The retain side is + // fire-and-forget so it can't block, but the inline recall + a few extra + // mutateState audits push runTask completion past the original 500ms + // budget on slower hosts. A 200-iteration / 10ms loop = 2s ceiling is + // still well under any reasonable test timeout. + for (let attempt = 0; attempt < 200; attempt += 1) { + const detail = await call(handler, config, `/api/tasks/${taskId}`); + if (statuses.includes(detail.task.status)) return detail; + await Bun.sleep(10); + } + throw new Error(`Task did not settle: ${taskId}`); +} diff --git a/packages/runtime/src/http.test.ts b/packages/runtime/src/http.test.ts deleted file mode 100644 index 1b38996e..00000000 --- a/packages/runtime/src/http.test.ts +++ /dev/null @@ -1,7944 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import "./hooks/builtins"; // the email-watch routes provision a backing job, which validates isKnownHook("skill-script") -import { createHandler, resolveInlineUpload } from "./http"; -import { logDir, uploadsDir, webPortPath } from "./paths"; -import { clearWebTargetCache } from "./web-target"; -import { dirname, join } from "node:path"; -import { addAudit, appendEvent, insertChatBlock, isPlausibleMime, mutateState, readState, readTrace, recordProviderAuthFailure, sanitizeFilename, storeUpload, uploadStat } from "./state"; -import { getOrCreateAgentChat } from "./execution/chat"; -import { createScheduledJob } from "./jobs"; -import { removeMemoryDb } from "./state/memory-db"; -import { listProviders } from "./integrations/connectors/registry"; -import { resetGoogleLoginWebState } from "./integrations/connectors/google-login-web"; -import { awaitTunnelSettled, setTunnelDeps, type TunnelChild } from "./integrations/tunnel"; -import type { RuntimeConfig } from "./types"; -import type { LoginHandle, RelayDefaults, Session, Store, TunnelOptions } from "gini-relay"; - -// Stub a provider's host-environment `detect()` so the connector-detection -// endpoint test stays deterministic AND fast regardless of what's installed -// on the developer's PATH. The production `detect()` for claude-code / codex -// shells out via spawnSync (`which`, `claude auth status`), which on a machine -// with those CLIs installed dominates this test's wall time (the unstubbed -// detect endpoint test measured 1.524641s). Mirrors the same in-place -// swap-and-restore helper used by src/jobs/connector-detection.test.ts. The -// registry is a process-wide singleton, so the returned restore fn MUST run in -// a finally to avoid leaking the stub into sibling tests. -function stubProviderDetect( - providerId: string, - value: { detected: boolean; suggestedName?: string; message?: string } -): () => void { - const provider = listProviders().find((p) => p.id === providerId); - if (!provider) throw new Error(`Provider not registered: ${providerId}`); - const previous = provider.detect; - provider.detect = async () => value; - return () => { - provider.detect = previous; - }; -} - -// Companion to stubProviderDetect. After connector auto-detection creates a -// record for a provider that exposes a `probe()`, runConnectorDetection runs -// an initial checkConnector → provider.probe, which for claude-code shells out -// to `claude auth status` again. Stub the probe so the detection endpoint -// test never touches a real subprocess. Same swap-and-restore discipline. -function stubProviderProbe(providerId: string, value: { ok: boolean; message: string }): () => void { - const provider = listProviders().find((p) => p.id === providerId); - if (!provider) throw new Error(`Provider not registered: ${providerId}`); - const previous = provider.probe; - provider.probe = async () => value; - return () => { - provider.probe = previous; - }; -} - -describe("runtime api", () => { - test("applies approved improvement proposals and audits the decision", async () => { - const config = testConfig("improvement-approve"); - const handler = createHandler(config); - - const proposal = await call(handler, config, "/api/improvements", { - method: "POST", - body: JSON.stringify({ - kind: "skill", - title: "review-traces", - rationale: "Trace evidence shows repeated review steps.", - payload: { name: "review-traces", steps: ["Inspect trace", "Summarize evidence"] } - }) - }); - - const applied = await call(handler, config, `/api/improvements/${proposal.id}/approve`, { method: "POST" }); - const state = readState(config.instance); - - expect(applied.status).toBe("applied"); - expect(applied.appliedTargetId).toBeString(); - expect(state.skills.some((skill) => skill.id === applied.appliedTargetId)).toBe(true); - expect(state.audit.some((event) => event.action === "improvement.applied")).toBe(true); - }); - - test("rejected improvement proposals do not mutate target stores", async () => { - const config = testConfig("improvement-reject"); - const handler = createHandler(config); - - const proposal = await call(handler, config, "/api/improvements", { - method: "POST", - body: JSON.stringify({ - kind: "skill", - title: "Remember review preference", - payload: { name: "review-pref", description: "Prefer evidence-backed reviews.", trigger: "review", steps: ["Cite evidence"] } - }) - }); - - const rejected = await call(handler, config, `/api/improvements/${proposal.id}/reject`, { method: "POST" }); - const state = readState(config.instance); - - expect(rejected.status).toBe("rejected"); - expect(state.skills.some((skill) => skill.name === "review-pref")).toBe(false); - expect(state.audit.some((event) => event.action === "improvement.rejected")).toBe(true); - }); - - test("only the owner bearer authorizes the mobile contracts (owner-token-only auth)", async () => { - const config = testConfig("owner-only-mobile"); - const handler = createHandler(config); - - // A legacy device-shaped bearer must be refused — the pairing subsystem is - // gone and bearer === config.token is the whole check (ADR owner-token-auth.md). - const refused = await rawCall(handler, config, "/api/mobile/bootstrap", {}, "gini_device_00000000-0000-0000-0000-000000000000"); - expect(refused.status).toBe(401); - - const mobile = await callWithToken(handler, config, config.token, "/api/mobile/bootstrap"); - expect(mobile.instance).toBe(config.instance); - }); - - test("records promotion proposals without applying upgrades", async () => { - const config = testConfig("promotion"); - const handler = createHandler(config); - - const proposal = await call(handler, config, "/api/promotions", { - method: "POST", - body: JSON.stringify({ - candidateRef: "commit-abc", - evidencePath: "/tmp/evidence.json", - summary: "Candidate passed sandbox smoke.", - rollbackPlan: "Restore snapshot snap_abc." - }) - }); - const rejected = await call(handler, config, `/api/promotions/${proposal.id}/reject`, { method: "POST" }); - - expect(rejected.status).toBe("rejected"); - expect(rejected.candidateRef).toBe("commit-abc"); - expect(readState(config.instance).audit.some((event) => event.action === "promotion.rejected")).toBe(true); - }); - - test("supports Hermes-parity control records for search, toolsets, subagents, MCP, messaging, and imports", async () => { - const config = testConfig("hermes-parity"); - const handler = createHandler(config); - - const task = await call(handler, config, "/api/tasks", { - method: "POST", - body: JSON.stringify({ input: "remember Hermes parity should be searchable" }) - }); - await waitForTask(handler, config, task.id); - - const search = await call(handler, config, "/api/search?q=Hermes"); - const toolsets = await call(handler, config, "/api/toolsets"); - const disabled = await call(handler, config, "/api/toolsets/messaging/disable", { method: "POST" }); - const subagent = await call(handler, config, "/api/subagents", { - method: "POST", - body: JSON.stringify({ name: "reviewer", prompt: "review Hermes parity", parentTaskId: task.id, toolsets: ["memory"] }) - }); - await waitForTask(handler, config, subagent.taskId); - const mcp = await call(handler, config, "/api/mcp", { - method: "POST", - body: JSON.stringify({ name: "demo-mcp", command: "echo", args: ["ok"], exposedTools: ["demo.echo"] }) - }); - const bridge = await call(handler, config, "/api/messaging", { - method: "POST", - body: JSON.stringify({ name: "demo-bridge", kind: "demo", deliveryTargets: ["local"] }) - }); - const report = await call(handler, config, "/api/imports/inspect", { - method: "POST", - body: JSON.stringify({ source: "hermes", path: process.cwd() }) - }); - - expect(search.length).toBeGreaterThan(0); - expect(toolsets.toolsets.some((item: { name: string }) => item.name === "session_search")).toBe(true); - expect(disabled.status).toBe("disabled"); - expect(subagent.taskId).toBeString(); - expect(mcp.status).toBe("configured"); - expect(bridge.status).toBe("configured"); - expect(report.status).toBe("completed"); - }); - - test("executes low-risk file tool tasks with trace and audit evidence", async () => { - const config = testConfig("file-tools"); - config.workspaceRoot = process.cwd(); - const handler = createHandler(config); - - const read = await call(handler, config, "/api/tasks", { - method: "POST", - body: JSON.stringify({ input: "read README.md" }) - }); - const readDetail = await waitForTask(handler, config, read.id); - const list = await call(handler, config, "/api/tasks", { - method: "POST", - body: JSON.stringify({ input: "list packages/runtime/src" }) - }); - const listDetail = await waitForTask(handler, config, list.id); - const find = await call(handler, config, "/api/tasks", { - method: "POST", - body: JSON.stringify({ input: "find Gini in README.md" }) - }); - const findDetail = await waitForTask(handler, config, find.id); - const state = readState(config.instance); - - expect(readDetail.task.status).toBe("completed"); - expect(listDetail.task.summary).toContain("src/agent.ts"); - expect(findDetail.task.summary).toContain("README.md"); - expect(state.audit.some((event) => event.action === "file.read")).toBe(true); - expect(state.audit.some((event) => event.action === "file.list")).toBe(true); - expect(state.audit.some((event) => event.action === "file.search")).toBe(true); - }); - - test("supports agent config equivalents and Hermes parity reporting", async () => { - const config = testConfig("agents-parity"); - const handler = createHandler(config); - - const created = await call(handler, config, "/api/agents", { - method: "POST", - body: JSON.stringify({ name: "research", toolsets: ["file", "web", "session_search"] }) - }); - const active = await call(handler, config, `/api/agents/${created.id}/use`, { method: "POST" }); - const agents = await call(handler, config, "/api/agents"); - const parity = await call(handler, config, "/api/parity/hermes"); - - expect(active.status).toBe("active"); - expect(agents.activeAgentId).toBe(created.id); - expect(parity.ok).toBe(true); - expect(parity.checks.some((item: { id: string; status: string }) => item.id === "agents" && item.status === "pass")).toBe(true); - }); - - test("DELETE /api/agents/:id removes the agent and cascades cleanup", async () => { - const config = testConfig("agents-delete"); - const handler = createHandler(config); - - const created = await call(handler, config, "/api/agents", { - method: "POST", - body: JSON.stringify({ name: "scratch" }) - }); - const deleted = await call(handler, config, `/api/agents/${created.id}`, { method: "DELETE" }); - - expect(deleted.ok).toBe(true); - expect(deleted.id).toBe(created.id); - expect(deleted.bankDeleted).toBe(true); - - const after = await call(handler, config, "/api/agents"); - expect(after.agents.find((agent: { id: string }) => agent.id === created.id)).toBeUndefined(); - - // Idempotent: a second delete on the same id returns 404, not 500. - const followUp = await rawCall(handler, config, `/api/agents/${created.id}`, { method: "DELETE" }, config.token); - expect(followUp.status).toBe(404); - }); - - test("DELETE /api/agents/:id rejects the default agent with 400", async () => { - const config = testConfig("agents-delete-default"); - const handler = createHandler(config); - const response = await rawCall(handler, config, "/api/agents/agent_default", { method: "DELETE" }, config.token); - expect(response.status).toBe(400); - const body = await response.json(); - expect(body.error).toContain("Cannot delete the default agent"); - }); - - test("DELETE /api/agents/:id rejects the active agent with 400", async () => { - const config = testConfig("agents-delete-active"); - const handler = createHandler(config); - - const created = await call(handler, config, "/api/agents", { - method: "POST", - body: JSON.stringify({ name: "active" }) - }); - await call(handler, config, `/api/agents/${created.id}/use`, { method: "POST" }); - - const response = await rawCall(handler, config, `/api/agents/${created.id}`, { method: "DELETE" }, config.token); - expect(response.status).toBe(400); - const body = await response.json(); - expect(body.error).toContain("Cannot delete the active agent"); - }); - - test("POST /api/agents/:id/archive then /unarchive round-trips archivedAt", async () => { - const config = testConfig("agents-archive-roundtrip"); - const handler = createHandler(config); - - const created = await call(handler, config, "/api/agents", { - method: "POST", - body: JSON.stringify({ name: "scratch" }) - }); - const archived = await call(handler, config, `/api/agents/${created.id}/archive`, { method: "POST" }); - expect(typeof archived.archivedAt).toBe("string"); - - const restored = await call(handler, config, `/api/agents/${created.id}/unarchive`, { method: "POST" }); - expect(restored.archivedAt).toBeUndefined(); - }); - - test("POST /api/agents/:id/archive rejects the default agent with 400", async () => { - const config = testConfig("agents-archive-default"); - const handler = createHandler(config); - const response = await rawCall(handler, config, "/api/agents/agent_default/archive", { method: "POST" }, config.token); - expect(response.status).toBe(400); - const body = await response.json(); - expect(body.error).toContain("Cannot archive the default agent"); - }); - - test("POST /api/agents/:id/archive archives the active agent and hands active to the default", async () => { - const config = testConfig("agents-archive-active"); - const handler = createHandler(config); - - const created = await call(handler, config, "/api/agents", { - method: "POST", - body: JSON.stringify({ name: "active" }) - }); - await call(handler, config, `/api/agents/${created.id}/use`, { method: "POST" }); - - const archived = await call(handler, config, `/api/agents/${created.id}/archive`, { method: "POST" }); - expect(typeof archived.archivedAt).toBe("string"); - - // Active selection reassigns to the always-present default agent. - const agents = await call(handler, config, "/api/agents"); - expect(agents.activeAgentId).toBe("agent_default"); - expect(agents.defaultAgentId).toBe("agent_default"); - }); - - test("POST /api/agents/:id/use rejects an archived agent with 400", async () => { - const config = testConfig("agents-use-archived"); - const handler = createHandler(config); - - const created = await call(handler, config, "/api/agents", { - method: "POST", - body: JSON.stringify({ name: "scratch" }) - }); - await call(handler, config, `/api/agents/${created.id}/archive`, { method: "POST" }); - - const response = await rawCall(handler, config, `/api/agents/${created.id}/use`, { method: "POST" }, config.token); - expect(response.status).toBe(400); - const body = await response.json(); - expect(body.error).toContain("Cannot use an archived agent"); - }); - - test("PATCH /api/agents/:id renames the agent", async () => { - const config = testConfig("agents-rename"); - const handler = createHandler(config); - - const created = await call(handler, config, "/api/agents", { - method: "POST", - body: JSON.stringify({ name: "Mansour" }) - }); - const renamed = await call(handler, config, `/api/agents/${created.id}`, { - method: "PATCH", - body: JSON.stringify({ name: "Bob" }) - }); - expect(renamed.id).toBe(created.id); - expect(renamed.name).toBe("Bob"); - - const after = await call(handler, config, "/api/agents"); - expect(after.agents.find((agent: { id: string }) => agent.id === created.id)?.name).toBe("Bob"); - }); - - test("PATCH /api/agents/:id returns 404 for an unknown agent", async () => { - const config = testConfig("agents-rename-missing"); - const handler = createHandler(config); - const response = await rawCall( - handler, - config, - "/api/agents/agent_does_not_exist", - { method: "PATCH", body: JSON.stringify({ name: "Bob" }) }, - config.token - ); - expect(response.status).toBe(404); - }); - - test("PATCH /api/agents/:id returns 400 for an empty name", async () => { - // A missing / blank name is user input, not a server fault — it must - // map to 400, never the catch-all 500. - const config = testConfig("agents-rename-empty"); - const handler = createHandler(config); - const created = await call(handler, config, "/api/agents", { - method: "POST", - body: JSON.stringify({ name: "Mansour" }) - }); - const response = await rawCall( - handler, - config, - `/api/agents/${created.id}`, - { method: "PATCH", body: JSON.stringify({}) }, - config.token - ); - expect(response.status).toBe(400); - }); - - test("POST /api/agents/:id/provider sets the agent's provider and /status reflects it", async () => { - const config = testConfig("agents-set-provider"); - const handler = createHandler(config); - // Configure the pinned provider so the resolved provider dispatches verbatim; - // an unconfigured pin would transiently fall back to any other configured - // provider (e.g. an ambient codex auth.json on the dev machine), which is a - // separate path covered by the dispatch-fallback tests. - const prevOpenai = process.env.OPENAI_API_KEY; - process.env.OPENAI_API_KEY = "sk-test-agent-provider"; - try { - const created = await call(handler, config, "/api/agents", { - method: "POST", - body: JSON.stringify({ name: "research" }) - }); - await call(handler, config, `/api/agents/${created.id}/use`, { method: "POST" }); - - const updated = await call(handler, config, `/api/agents/${created.id}/provider`, { - method: "POST", - body: JSON.stringify({ providerName: "openai", model: "gpt-4o" }) - }); - expect(updated.providerName).toBe("openai"); - expect(updated.model).toBe("gpt-4o"); - - // The override drives inference: the active-agent block resolves the - // agent's provider, not the instance default (echo in this config). - const status = await call(handler, config, "/api/status"); - expect(status.activeAgent.resolvedProvider.name).toBe("openai"); - expect(status.activeAgent.resolvedProvider.model).toBe("gpt-4o"); - expect(status.activeAgent.providerSource).toBe("agent"); - } finally { - if (prevOpenai === undefined) delete process.env.OPENAI_API_KEY; - else process.env.OPENAI_API_KEY = prevOpenai; - } - }); - - test("POST /api/agents/:id/provider with blank fields clears the override", async () => { - const config = testConfig("agents-clear-provider"); - const handler = createHandler(config); - const created = await call(handler, config, "/api/agents", { - method: "POST", - body: JSON.stringify({ name: "research", providerName: "openai", model: "gpt-4o" }) - }); - await call(handler, config, `/api/agents/${created.id}/use`, { method: "POST" }); - - const cleared = await call(handler, config, `/api/agents/${created.id}/provider`, { - method: "POST", - body: JSON.stringify({ providerName: "", model: "" }) - }); - expect(cleared.providerName).toBeUndefined(); - expect(cleared.model).toBeUndefined(); - - // With no agent override, the active-agent block falls back to the - // instance provider (echo) and reports the instance source. - const status = await call(handler, config, "/api/status"); - expect(status.activeAgent.providerSource).toBe("instance"); - expect(status.activeAgent.resolvedProvider.name).toBe("echo"); - }); - - test("POST /api/agents/:id/provider returns 404 for an unknown agent", async () => { - const config = testConfig("agents-set-provider-missing"); - const handler = createHandler(config); - const response = await rawCall( - handler, - config, - "/api/agents/agent_does_not_exist/provider", - { method: "POST", body: JSON.stringify({ providerName: "openai", model: "gpt-4o" }) }, - config.token - ); - expect(response.status).toBe(404); - }); - - test("POST /api/agents/:id/provider returns 400 for a lone providerName", async () => { - const config = testConfig("agents-set-provider-partial"); - const handler = createHandler(config); - const created = await call(handler, config, "/api/agents", { - method: "POST", - body: JSON.stringify({ name: "research" }) - }); - const response = await rawCall( - handler, - config, - `/api/agents/${created.id}/provider`, - { method: "POST", body: JSON.stringify({ providerName: "openai" }) }, - config.token - ); - expect(response.status).toBe(400); - }); - - test("POST /api/agents/:id/provider returns 400 for an unknown provider", async () => { - const config = testConfig("agents-set-provider-unknown"); - const handler = createHandler(config); - const created = await call(handler, config, "/api/agents", { - method: "POST", - body: JSON.stringify({ name: "research" }) - }); - const response = await rawCall( - handler, - config, - `/api/agents/${created.id}/provider`, - { method: "POST", body: JSON.stringify({ providerName: "bogus", model: "x" }) }, - config.token - ); - expect(response.status).toBe(400); - }); - - test("supports relay degraded health and notification delivery records", async () => { - const config = testConfig("relay-notifications"); - const handler = createHandler(config); - - const relay = await call(handler, config, "/api/relays", { - method: "POST", - body: JSON.stringify({ name: "local", endpoint: "local://test", mode: "local-only" }) - }); - const health = await call(handler, config, `/api/relays/${relay.id}/health`, { method: "POST" }); - const notification = await call(handler, config, "/api/notifications", { - method: "POST", - body: JSON.stringify({ kind: "runtime", target: "local", title: "Runtime check", body: "Relay test" }) - }); - const sent = await call(handler, config, "/api/notifications/send", { method: "POST" }); - - expect(health.status).toBe("degraded"); - expect(notification.status).toBe("queued"); - expect(sent.some((item: { id: string; status: string }) => item.id === notification.id && item.status === "sent")).toBe(true); - }); - - test("tunnel routes return the full TunnelState across the select/connect/disconnect flow", async () => { - const config = testConfig("tunnel-routes"); - const handler = createHandler(config); - - // Inject fake gini-relay seams so the connect flow exercises the - // connecting -> connected transition without OAuth, the host browser, or a - // spawned frpc child. Restored in the finally. - const session: Session = { token: "gsk_x", subdomain: "subroute", account: "u@test" }; - const relay: RelayDefaults = { - relayUrl: "https://relay.test", frpsAddr: "relay.test", frpsPort: 7000, - relayDomain: "relay.test", tlsServerName: "relay.test", frpToken: "t", - caFile: "/tmp/ca", loopbackPorts: [8765], bandwidth: "1220KB" - }; - const child: TunnelChild = { - start: () => Promise.resolve(), - stop: () => Promise.resolve(0), - exited: Promise.withResolvers().promise - }; - const store: Store = { home: "/tmp/h", deviceId: () => "d1", readSession: () => session, writeSession: () => {}, clearSession: () => {} }; - const handle: LoginHandle = { - url: "https://relay.test/consent", redirectUri: "http://127.0.0.1:8765/cb", - waitForSession: () => Promise.resolve(session), cancel: () => {} - }; - // Inert manual drivers so nothing in this flow can shell out to a - // host-installed tailscale/ngrok/cloudflared (or flip their catalog rows). - const inertDriver = (requires: string) => ({ - detect: () => Promise.resolve({ enabled: false, requires }), - connect: () => Promise.reject(new Error("manual driver must not run")) - }); - setTunnelDeps({ - loginUrl: () => Promise.resolve(handle), - buildTunnel: (_opts: TunnelOptions) => child, - createStore: () => store, - resolveDefaults: () => relay, - openBrowser: () => {}, - resolveLocalPort: () => 4321, - probeLocalPort: () => Promise.resolve(true), - drivers: { - tailscale: inertDriver("Tailscale network"), - ngrok: inertDriver("ngrok account"), - cloudflare: inertDriver("cloudflared CLI") - } - }); - - try { - // GET on a fresh instance: catalog present, nothing selected, idle. - const initial = await call(handler, config, "/api/tunnel"); - expect(initial.status).toBe("idle"); - expect(initial.selectedProvider).toBeNull(); - expect(initial.providers.map((p: { id: string }) => p.id)).toEqual([ - "gini-relay", - "tailscale", - "ngrok", - "cloudflare" - ]); - - // select saves the choice without connecting. - const selected = await call(handler, config, "/api/tunnel/select", { - method: "POST", - body: JSON.stringify({ provider: "gini-relay" }) - }); - expect(selected.selectedProvider).toBe("gini-relay"); - expect(selected.status).toBe("idle"); - - // connect (no body provider) uses the saved selection; the route returns - // "connecting" immediately while the background handshake runs. - const connecting = await call(handler, config, "/api/tunnel/connect", { - method: "POST", - body: JSON.stringify({}) - }); - expect(connecting.status).toBe("connecting"); - expect(connecting.url).toBeUndefined(); - - // Let the background flow settle, then GET reflects connected + url. - await awaitTunnelSettled(config.instance); - const connected = await call(handler, config, "/api/tunnel"); - expect(connected.status).toBe("connected"); - expect(connected.url).toBe("https://subroute.relay.test"); - - // cancel returns to idle keeping the selection. - const cancelled = await call(handler, config, "/api/tunnel/cancel", { method: "POST" }); - expect(cancelled.status).toBe("idle"); - expect(cancelled.selectedProvider).toBe("gini-relay"); - - // connect with an explicit provider in the body overrides selection. - const reconnecting = await call(handler, config, "/api/tunnel/connect", { - method: "POST", - body: JSON.stringify({ provider: "gini-relay" }) - }); - expect(reconnecting.status).toBe("connecting"); - await awaitTunnelSettled(config.instance); - expect((await call(handler, config, "/api/tunnel")).status).toBe("connected"); - - // disconnect tears down, keeps the selection. - const disconnected = await call(handler, config, "/api/tunnel/disconnect", { method: "POST" }); - expect(disconnected.status).toBe("idle"); - expect(disconnected.selectedProvider).toBe("gini-relay"); - } finally { - setTunnelDeps(); - } - }); - - test("GET /api/tunnel?detect=1 re-probes driver availability and flips catalog rows", async () => { - const config = testConfig("tunnel-detect"); - const handler = createHandler(config); - const inert = (requires: string) => ({ - detect: () => Promise.resolve({ enabled: false, requires }), - connect: () => Promise.reject(new Error("unused")) - }); - setTunnelDeps({ - drivers: { - tailscale: { detect: () => Promise.resolve({ enabled: true }), connect: () => Promise.reject(new Error("unused")) }, - ngrok: inert("ngrok account"), - cloudflare: inert("cloudflared CLI") - } - }); - try { - // A plain GET never spawns detection: the catalog stays default-disabled. - const plain = await call(handler, config, "/api/tunnel"); - const plainRow = plain.providers.find((p: { id: string }) => p.id === "tailscale"); - expect(plainRow.enabled).toBe(false); - // detect=1 probes the drivers and the row flips. - const detected = await call(handler, config, "/api/tunnel?detect=1"); - const row = detected.providers.find((p: { id: string }) => p.id === "tailscale"); - expect(row.enabled).toBe(true); - expect(row.requires).toBeUndefined(); - } finally { - setTunnelDeps(); - } - }); - - test("POST /api/tunnel/select rejects a disabled provider with a 400", async () => { - const config = testConfig("tunnel-reject"); - const handler = createHandler(config); - // The select path re-probes a disabled provider's prerequisite before - // rejecting — pin detection to disabled so the rejection (and this test) - // never depends on which CLIs the host machine happens to have. - setTunnelDeps({ - drivers: { - tailscale: { detect: () => Promise.resolve({ enabled: false, requires: "Tailscale network" }), connect: () => Promise.reject(new Error("unused")) }, - ngrok: { detect: () => Promise.resolve({ enabled: false, requires: "ngrok account" }), connect: () => Promise.reject(new Error("unused")) }, - cloudflare: { detect: () => Promise.resolve({ enabled: false, requires: "cloudflared CLI" }), connect: () => Promise.reject(new Error("unused")) } - } - }); - try { - const response = await rawCall(handler, config, "/api/tunnel/select", { - method: "POST", - body: JSON.stringify({ provider: "ngrok" }) - }, config.token); - expect(response.status).toBe(400); - const value = await response.json(); - expect(value.error).toContain("not available"); - // The machine-readable code rides along so clients can branch on the - // failure kind (the web UI opens the provider's guide on this code). - expect(value.code).toBe("provider_unavailable"); - } finally { - setTunnelDeps(); - } - }); - - test("supports V1 skill governance and job run history workflows", async () => { - const config = testConfig("v1-skill-job"); - const handler = createHandler(config); - - const skill = await call(handler, config, "/api/skills", { - method: "POST", - body: JSON.stringify({ name: "triage", steps: ["Read trace"], tests: ["has name"] }) - }); - const enabled = await call(handler, config, `/api/skills/${skill.id}/enable`, { method: "POST" }); - const tested = await call(handler, config, `/api/skills/${skill.id}/test`, { method: "POST" }); - const updated = await call(handler, config, `/api/skills/${skill.id}`, { - method: "PATCH", - body: JSON.stringify({ description: "Updated skill", steps: ["Read trace", "Summarize"] }) - }); - const rolledBack = await call(handler, config, `/api/skills/${skill.id}/rollback`, { method: "POST" }); - - const job = await call(handler, config, "/api/jobs", { - method: "POST", - body: JSON.stringify({ name: "script", intervalSeconds: 60, script: "echo script-ok", deliveryTargets: ["local"], timeoutSeconds: 5 }) - }); - const run = await call(handler, config, `/api/jobs/${job.id}/run`, { method: "POST" }); - const runs = await call(handler, config, `/api/jobs/${job.id}/runs`); - const replay = await call(handler, config, `/api/job-runs/${runs[0].id}/replay`, { method: "POST" }); - const events = await call(handler, config, "/api/events"); - - expect(enabled.status).toBe("enabled"); - expect(tested.ok).toBe(true); - expect(updated.version).toBe(2); - expect(rolledBack.version).toBe(3); - expect(run.exitCode).toBe(0); - expect(runs[0].summary).toContain("script-ok"); - expect(replay.exitCode).toBe(0); - expect(events.some((event: { action: string }) => event.action === "job.run.completed")).toBe(true); - }); - - test("PATCH /api/jobs/:id round-trips costBudget alongside other editable fields", async () => { - const config = testConfig("v1-job-cost-budget"); - const handler = createHandler(config); - - const job = await call(handler, config, "/api/jobs", { - method: "POST", - body: JSON.stringify({ name: "cost-job", intervalSeconds: 120, prompt: "noop" }) - }); - expect(job.costBudget).toBeUndefined(); - - const patched = await call(handler, config, `/api/jobs/${job.id}`, { - method: "PATCH", - body: JSON.stringify({ costBudget: 2.5, retryLimit: 4, timeoutSeconds: 45 }) - }); - expect(patched.costBudget).toBe(2.5); - expect(patched.retryLimit).toBe(4); - expect(patched.timeoutSeconds).toBe(45); - - const refetched = (await call(handler, config, "/api/jobs")).find((item: { id: string }) => item.id === job.id); - expect(refetched.costBudget).toBe(2.5); - - const cleared = await call(handler, config, `/api/jobs/${job.id}`, { - method: "PATCH", - body: JSON.stringify({ costBudget: null }) - }); - expect(cleared.costBudget).toBeUndefined(); - }); - - test("probes and invokes configured MCP command records", async () => { - const config = testConfig("v1-mcp"); - const handler = createHandler(config); - - const server = await call(handler, config, "/api/mcp", { - method: "POST", - body: JSON.stringify({ name: "echo-mcp", command: "echo", args: ["ok"], exposedTools: ["echo.tool"] }) - }); - const health = await call(handler, config, `/api/mcp/${server.id}/health`, { method: "POST" }); - const invoked = await call(handler, config, `/api/mcp/${server.id}/invoke`, { - method: "POST", - body: JSON.stringify({ toolName: "echo.tool", input: { value: 1 } }) - }); - - expect(health.status).toBe("configured"); - expect(health.message).toContain("completed"); - expect(invoked.ok).toBe(true); - expect(invoked.stdout).toContain("ok"); - }); - - test("exposes recorded runtime events as an SSE stream", async () => { - const config = testConfig("events-stream"); - const handler = createHandler(config); - - await call(handler, config, "/api/improvements", { - method: "POST", - body: JSON.stringify({ kind: "skill", title: "event-test", payload: { name: "event-test" } }) - }); - const response = await rawCall(handler, config, "/api/events/stream", {}, config.token); - const reader = response.body?.getReader(); - const chunk = await reader?.read(); - await reader?.cancel(); - const text = new TextDecoder().decode(chunk?.value); - - expect(response.headers.get("content-type")).toContain("text/event-stream"); - expect(text).toContain("data:"); - expect(text).toContain("event_"); - }); - - test("SSE stream honors Last-Event-ID for reconnect dedup", async () => { - // Regression for the Round 2 reconnect storm: every reconnect was - // re-replaying the entire event log, which compounded into thousands of - // events/sec on the client when the EventSource thrashed. With dedup, a - // reconnect that includes the most-recent id should yield zero historical - // events on first read. - const config = testConfig("events-stream-dedup"); - const handler = createHandler(config); - - await call(handler, config, "/api/improvements", { - method: "POST", - body: JSON.stringify({ kind: "skill", title: "first", payload: { name: "first" } }) - }); - // Read the full event log once to discover the most-recent id. - const events = await call(handler, config, "/api/events"); - expect(events.length).toBeGreaterThan(0); - const lastEventId = events[events.length - 1].id; - - const response = await rawCall( - handler, - config, - "/api/events/stream", - { headers: { "last-event-id": lastEventId } }, - config.token - ); - // First read should yield no historical events (everything up to and - // including lastEventId is suppressed). The TextDecoder.decode of an empty - // chunk is "". - const reader = response.body?.getReader(); - // Race the read against a short timeout; the heartbeat doesn't fire for - // 1s, so an immediate read should observe an empty buffer. The timeout - // value only needs to lose to a real event (there are none queued) and - // win against the 1s heartbeat — 30ms is as conclusive as 200ms here and - // doesn't burn the wall when the suite runs the whole describe block. - const winner = await Promise.race([ - reader?.read(), - new Promise((resolve) => setTimeout(() => resolve({ value: undefined, done: false }), 30)) - ]) as { value?: Uint8Array; done?: boolean }; - await reader?.cancel(); - const text = winner?.value ? new TextDecoder().decode(winner.value) : ""; - expect(text).toBe(""); - }); - - test("SSE Last-Event-ID older than retained buffer still delivers retained events", async () => { - // Regression for R3-G1: when the client's Last-Event-ID has rolled out of - // the 1000-event ring buffer (long disconnect or burst), we must NOT - // silently pre-seed every retained event into `seen` — that would deliver - // nothing on reconnect and the client would never recover. Instead, treat - // the unknown id as "best effort" and ship the entire retained window. - const config = testConfig("events-stream-rollover"); - const handler = createHandler(config); - - // Generate more events than the ring buffer holds (1000), so a fabricated - // earlier id is guaranteed not to be retained. - await mutateState(config.instance, (state) => { - for (let i = 0; i < 1100; i += 1) { - appendEvent( - state, - { - kind: "runtime", - action: "noop", - target: `target-${i}`, - risk: "low", - summary: `event ${i}` - }, - { system: true } - ); - } - }); - - // Construct a stale Last-Event-ID that mimics the ID format but isn't in - // the buffer. (The buffer holds the last 1000; this id is intentionally - // synthetic and won't match any retained event.) - const staleId = "event_rolled_out_of_buffer"; - - const response = await rawCall( - handler, - config, - "/api/events/stream", - { headers: { "last-event-id": staleId } }, - config.token - ); - const reader = response.body?.getReader(); - const winner = (await Promise.race([ - reader?.read(), - new Promise((resolve) => setTimeout(() => resolve({ value: undefined, done: false }), 200)) - ])) as { value?: Uint8Array; done?: boolean }; - await reader?.cancel(); - const text = winner?.value ? new TextDecoder().decode(winner.value) : ""; - - // Should have received the retained window, not silence. - expect(text).toContain("data:"); - expect(text).toContain("event_"); - }); - - test("supports local chat sessions backed by task execution and retry contracts", async () => { - const config = testConfig("chat"); - const handler = createHandler(config); - - const session = await call(handler, config, "/api/chat", { - method: "POST", - body: JSON.stringify({ title: "Hermes-style chat" }) - }); - const submitted = await call(handler, config, `/api/chat/${session.id}/messages`, { - method: "POST", - body: JSON.stringify({ content: "remember chat history works" }) - }); - await waitForTask(handler, config, submitted.taskId); - const assistant = await call(handler, config, `/api/chat/${session.id}/tasks/${submitted.taskId}/sync`, { method: "POST" }); - const retry = await call(handler, config, `/api/tasks/${submitted.taskId}/retry`, { method: "POST" }); - const detail = await call(handler, config, `/api/chat/${session.id}`); - - expect(assistant.role).toBe("assistant"); - expect(retry.input).toContain("remember chat history works"); - expect(detail.messages).toHaveLength(2); - expect(detail.taskIds).toContain(submitted.taskId); - }); - - test("chat message POST accepts an optional client surface field", async () => { - const config = testConfig("chat-client-surface"); - const handler = createHandler(config); - - const session = await call(handler, config, "/api/chat", { - method: "POST", - body: JSON.stringify({ title: "surface chat" }) - }); - // A valid `client` value lands on the spawned task; an unrecognized one - // resolves to unknown without rejecting the message (older clients must - // keep working). See ADR client-surface-context.md. - const tagged = await call(handler, config, `/api/chat/${session.id}/messages`, { - method: "POST", - body: JSON.stringify({ content: "hello from my phone", client: "mobile" }) - }); - const untagged = await call(handler, config, `/api/chat/${session.id}/messages`, { - method: "POST", - body: JSON.stringify({ content: "hello from somewhere", client: "fridge" }) - }); - const tasks = readState(config.instance).tasks; - expect(tasks.find((t) => t.id === tagged.taskId)?.clientSurface).toBe("mobile"); - expect(tasks.find((t) => t.id === untagged.taskId)?.clientSurface).toBeUndefined(); - }); - - test("queues a chat message posted during an in-flight turn and DELETE drops it", async () => { - // While a session has a non-terminal chat task, a new POST enqueues onto - // the session instead of starting a concurrent task; the queued item is - // removable via DELETE /api/chat/:id/pending/:pendingId. See ADR - // chat-message-queue.md. - const config = testConfig("chat-queue-http"); - const handler = createHandler(config); - - const session = await call(handler, config, "/api/chat", { - method: "POST", - body: JSON.stringify({ title: "queue chat" }) - }); - // Seed a non-terminal task on the session so the next submit reads as busy - // (without depending on a still-running agent loop). - await mutateState(config.instance, (state) => { - const at = new Date().toISOString(); - state.tasks.push({ - id: "task_busy", - title: "busy", - input: "busy", - status: "running", - instance: state.instance, - createdAt: at, - updatedAt: at, - tracePath: "", - auditIds: [], - approvalIds: [], - skillIds: [], - chatSessionId: session.id - }); - const record = state.chatSessions.find((s) => s.id === session.id); - if (record) record.taskIds.push("task_busy"); - }); - - const queued = await call(handler, config, `/api/chat/${session.id}/messages`, { - method: "POST", - body: JSON.stringify({ content: "while you were out" }) - }); - expect(queued.queued).toBe(true); - expect(queued.pendingId).toBeString(); - expect(queued.taskId).toBeUndefined(); - - let pending = readState(config.instance).chatSessions.find((s) => s.id === session.id)?.pendingMessages ?? []; - expect(pending.map((p: { content: string }) => p.content)).toEqual(["while you were out"]); - - // Unknown pending id → 404. - const missing = await rawCall( - handler, - config, - `/api/chat/${session.id}/pending/pending_nope`, - { method: "DELETE" }, - config.token - ); - expect(missing.status).toBe(404); - - // Removing the real pending id clears the queue and reports removed:true. - const removed = await call(handler, config, `/api/chat/${session.id}/pending/${queued.pendingId}`, { - method: "DELETE" - }); - expect(removed.removed).toBe(true); - pending = readState(config.instance).chatSessions.find((s) => s.id === session.id)?.pendingMessages ?? []; - expect(pending).toHaveLength(0); - }); - - test("approval-gated file patch produces a diff approval", async () => { - // Memory CRUD via `/api/memory` was removed alongside the - // state.memories consolidation. See ADR - // runtime-identity-files.md. - const config = testConfig("memory-patch"); - config.workspaceRoot = process.cwd(); - const handler = createHandler(config); - - const task = await call(handler, config, "/api/tasks", { - method: "POST", - body: JSON.stringify({ input: "patch README.md :: Gini => Gini" }) - }); - const detail = await waitForTask(handler, config, task.id); - const approval = readState(config.instance).authorizations.find((item) => item.taskId === task.id); - - expect(detail.task.status).toBe("waiting_approval"); - expect(approval?.action).toBe("file.patch"); - expect(String(approval?.payload.diff)).toContain("--- before"); - }); - - test("routes messaging bridge input to tasks and records outbound delivery", async () => { - const config = testConfig("messaging-routing"); - const handler = createHandler(config); - - const bridge = await call(handler, config, "/api/messaging", { - method: "POST", - body: JSON.stringify({ name: "local-messages", kind: "demo", deliveryTargets: ["local"] }) - }); - const inbound = await call(handler, config, `/api/messaging/${bridge.id}/receive`, { - method: "POST", - body: JSON.stringify({ text: "remember message bridge works", target: "local" }) - }); - await waitForTask(handler, config, inbound.taskId); - const outbound = await call(handler, config, `/api/messaging/${bridge.id}/send`, { - method: "POST", - body: JSON.stringify({ text: "Task is visible in Gini", target: "local" }) - }); - const messages = await call(handler, config, `/api/messaging/${bridge.id}/messages`); - - expect(inbound.direction).toBe("inbound"); - expect(inbound.status).toBe("received"); - expect(outbound.status).toBe("sent"); - expect(messages).toHaveLength(2); - }); - - test("rejects send to a target outside the active agent's messagingTargets filter", async () => { - const config = testConfig("messaging-agent-filter"); - const handler = createHandler(config); - - // Bridge advertises two targets so the per-call `target` selector has - // something to disagree with the agent filter about. - const bridge = await call(handler, config, "/api/messaging", { - method: "POST", - body: JSON.stringify({ name: "multi", kind: "demo", deliveryTargets: ["local", "slack"] }) - }); - const agent = await call(handler, config, "/api/agents", { - method: "POST", - body: JSON.stringify({ name: "local-only", toolsets: ["file"], messagingTargets: ["local"] }) - }); - await call(handler, config, `/api/agents/${agent.id}/use`, { method: "POST" }); - - // local target is permitted → succeeds. - const allowed = await call(handler, config, `/api/messaging/${bridge.id}/send`, { - method: "POST", - body: JSON.stringify({ text: "ok", target: "local" }) - }); - expect(allowed.status).toBe("sent"); - - // slack is outside the agent filter → server returns 400 with a typed - // error message that names both target and agent. - const rejected = await rawCall(handler, config, `/api/messaging/${bridge.id}/send`, { - method: "POST", - body: JSON.stringify({ text: "nope", target: "slack" }) - }, config.token); - expect(rejected.ok).toBe(false); - const errorBody = await rejected.json(); - expect(String(errorBody.error)).toContain("not permitted by active agent"); - expect(String(errorBody.error)).toContain("slack"); - }); - - test("GET / returns the runtime banner when the web server is not running", async () => { - const config = testConfig("root-pointer"); - const handler = createHandler(config); - - const response = await handler(new Request(`http://127.0.0.1:${config.port}/`)); - const value = (await response.json()) as { name?: string; instance?: string; message?: string }; - - expect(response.headers.get("content-type") ?? "").toContain("application/json"); - expect(value.name).toBe("gini-runtime"); - expect(value.instance).toBe(config.instance); - expect(String(value.message)).toContain("Next.js"); - }); - - // The web-down banner is reachable over the relay on bootstrap paths (exempt - // from the session gate) during the web child's post-restart startup window. - // A non-loopback (relay) caller must get only the bare name/message — never the - // instance, port, or web-URL hint — so the banner can't leak deployment details. - test("the web-down banner withholds deployment details from a relay caller", async () => { - const config = testConfig("banner-relay-redaction"); - const handler = createHandler(config); - - const relayHost = "sub.gini-relay.lilaclabs.ai"; - const response = await handler( - new Request(`https://${relayHost}/favicon.ico`, { headers: { host: relayHost } }) - ); - expect(response.status).toBe(200); - const value = (await response.json()) as { - name?: string; - instance?: unknown; - port?: unknown; - ui_url_hint?: unknown; - }; - expect(value.name).toBe("gini-runtime"); - expect(value.instance).toBeUndefined(); - expect(value.port).toBeUndefined(); - expect(value.ui_url_hint).toBeUndefined(); - }); - - // The web reverse proxy: non-/api traffic and the /api/runtime/* BFF - // namespace route to the Next.js server, while native /api/* stays - // bearer-gated. With no web server running in tests, the proxy falls back - // to the runtime banner — which is exactly what proves the routing: a path - // that reached the bearer gate would 401, not return the banner. - test("/api/runtime/* bypasses the gateway bearer gate and reaches the web proxy", async () => { - const config = testConfig("bff-carveout"); - const handler = createHandler(config); - - // No Authorization header. A native /api/* path is gated → 401. - const native = await handler(new Request(`http://127.0.0.1:${config.port}/api/status`)); - expect(native.status).toBe(401); - - // The BFF namespace is carved out of the gate; with web down it falls - // through to the proxy. An API-shaped path gets a 502 (not the 401 it - // would get if it had hit the bearer gate, and not a 200 banner a caller - // could mistake for success). - const bff = await handler(new Request(`http://127.0.0.1:${config.port}/api/runtime/status`)); - expect(bff.status).toBe(502); - const body = (await bff.json()) as { error?: string }; - expect(String(body.error)).toContain("Web UI not running"); - }); - - test("non-/api paths proxy to the web server (banner fallback when web is down)", async () => { - const config = testConfig("web-proxy-fallback"); - const handler = createHandler(config); - - const page = await handler(new Request(`http://127.0.0.1:${config.port}/some/app/route`)); - expect(page.status).toBe(200); - const body = (await page.json()) as { name?: string }; - expect(body.name).toBe("gini-runtime"); - }); - - // The gateway is the single trust front: every web-bound request is validated - // before proxying so the inner web child stays relay-agnostic. An untrusted - // (non-loopback, non-relay, non-allowlisted) Host is refused here. - test("web-bound requests from an untrusted Host are refused before proxying", async () => { - const config = testConfig("web-proxy-gate"); - const handler = createHandler(config); - // Page/asset path → 404 (don't confirm the host exists). - const page = await handler(new Request("http://evil.example/some/app/route", { headers: { host: "evil.example" } })); - expect(page.status).toBe(404); - // /api/runtime/* BFF namespace → 403 so a programmatic caller sees the refusal. - const bff = await handler(new Request("http://evil.example/api/runtime/status", { headers: { host: "evil.example" } })); - expect(bff.status).toBe(403); - }); - - // After validating the real Host/Origin, the gateway presents the inner web - // child a loopback Host AND Origin so the child needs no relay awareness. - test("proxyWeb rewrites Host and Origin to loopback before forwarding to the web child", async () => { - const config = testConfig("web-proxy-rewrite"); - const handler = createHandler(config); - const captured: { host: string | null; origin: string | null } = { host: null, origin: null }; - const upstream = Bun.serve({ - port: 0, - hostname: "127.0.0.1", - fetch(req) { - const u = new URL(req.url); - if (u.pathname === "/api/runtime/__healthz") { - return Response.json({ ok: true, service: "gini-web", instance: config.instance }); - } - captured.host = req.headers.get("host"); - captured.origin = req.headers.get("origin"); - return new Response("ok"); - } - }); - try { - mkdirSync(dirname(webPortPath(config.instance)), { recursive: true }); - writeFileSync(webPortPath(config.instance), String(upstream.port)); - clearWebTargetCache(config.instance); - // Loopback Host passes the gate; the original Origin points at the gateway - // port, so a correct rewrite makes the child see the loopback web port. - await handler(new Request(`http://127.0.0.1:${config.port}/some/app/route`, { - headers: { origin: `http://127.0.0.1:${config.port}` } - })); - expect(captured.host).toBe(`127.0.0.1:${upstream.port}`); - expect(captured.origin).toBe(`http://127.0.0.1:${upstream.port}`); - } finally { - await upstream.stop(true); - clearWebTargetCache(config.instance); - } - }); - - // The web child builds redirects from the loopback Host the gateway forwarded, - // so an absolute Location points at the loopback web port — which would send a - // remote tunnel browser to its own 127.0.0.1. The gateway rewrites it to a - // relative path so the browser resolves it against the origin it used. - test("proxyWeb rewrites an absolute loopback redirect Location to a relative path", async () => { - const config = testConfig("web-proxy-redirect"); - const handler = createHandler(config); - const upstream = Bun.serve({ - port: 0, - hostname: "127.0.0.1", - fetch(req) { - const u = new URL(req.url); - if (u.pathname === "/api/runtime/__healthz") { - return Response.json({ ok: true, service: "gini-web", instance: config.instance }); - } - // Emulate the setup gate building an absolute redirect from its (gateway- - // rewritten, loopback) Host. - return new Response(null, { status: 307, headers: { location: `http://${req.headers.get("host")}/setup` } }); - } - }); - try { - mkdirSync(dirname(webPortPath(config.instance)), { recursive: true }); - writeFileSync(webPortPath(config.instance), String(upstream.port)); - clearWebTargetCache(config.instance); - const res = await handler(new Request(`http://127.0.0.1:${config.port}/chat`, { - headers: { origin: `http://127.0.0.1:${config.port}` } - })); - expect(res.status).toBe(307); - expect(res.headers.get("location")).toBe("/setup"); - } finally { - await upstream.stop(true); - clearWebTargetCache(config.instance); - } - }); - - // The gateway forwards app cookies to the inner web child untouched (it owns - // no cookies of its own under owner-token-only auth). - test("proxyWeb passes application cookies through to the inner web child", async () => { - const config = testConfig("web-proxy-cookie-pass"); - const handler = createHandler(config); - const captured: { cookie: string | null } = { cookie: null }; - const upstream = Bun.serve({ - port: 0, - hostname: "127.0.0.1", - fetch(req) { - const u = new URL(req.url); - if (u.pathname === "/api/runtime/__healthz") { - return Response.json({ ok: true, service: "gini-web", instance: config.instance }); - } - captured.cookie = req.headers.get("cookie"); - return new Response("ok"); - } - }); - try { - mkdirSync(dirname(webPortPath(config.instance)), { recursive: true }); - writeFileSync(webPortPath(config.instance), String(upstream.port)); - clearWebTargetCache(config.instance); - await handler(new Request(`http://127.0.0.1:${config.port}/some/app/route`, { - headers: { - origin: `http://127.0.0.1:${config.port}`, - cookie: "theme=dark; locale=en" - } - })); - expect(captured.cookie).toBe("theme=dark; locale=en"); - } finally { - await upstream.stop(true); - clearWebTargetCache(config.instance); - } - }); - - test("preserves full terminal stdout in a trace artifact when audit evidence is truncated", async () => { - // Master plan §6.2 requires that "outputs are truncated intelligently - // with full logs stored." The audit `evidence` field caps stdout/stderr - // at 4KB for at-a-glance inline reading, but the full text must remain - // retrievable. agent.executeApprovedAction writes a sibling artifact - // under the task's trace directory and references it from both the - // audit evidence and the trace record. - const config = testConfig("terminal-output-preservation"); - config.workspaceRoot = "/tmp"; - const handler = createHandler(config); - - // Generate >4KB of stdout to force the inline excerpt to truncate. - const command = "yes abcdefghij | head -n 500"; - const submitted = await call(handler, config, "/api/tasks", { - method: "POST", - body: JSON.stringify({ input: `shell ${command}` }) - }); - const detail = await waitForTask(handler, config, submitted.id); - expect(detail.task.status).toBe("waiting_approval"); - - const approval = readState(config.instance).authorizations.find((item) => item.taskId === submitted.id); - expect(approval).toBeDefined(); - await call(handler, config, `/api/authorizations/${approval!.id}/approve`, { method: "POST" }); - - const finalDetail = await waitForTask(handler, config, submitted.id, ["completed", "failed"]); - expect(finalDetail.task.status).toBe("completed"); - - const auditEntry = readState(config.instance).audit.find( - (event) => event.action === "terminal.exec" && event.taskId === submitted.id - ); - expect(auditEntry).toBeDefined(); - const evidence = auditEntry!.evidence as Record; - - // Inline excerpt is truncated at 4000 bytes for display, but the audit - // carries metadata that signals truncation and points at the artifact. - expect(typeof evidence.stdout).toBe("string"); - expect((evidence.stdout as string).length).toBeLessThanOrEqual(4000); - expect(evidence.stdoutTruncated).toBe(true); - expect(typeof evidence.stdoutBytes).toBe("number"); - expect(evidence.stdoutBytes as number).toBeGreaterThan(4000); - expect(typeof evidence.artifactPath).toBe("string"); - expect(typeof evidence.artifactRelPath).toBe("string"); - expect(String(evidence.artifactRelPath)).toContain(`traces/${submitted.id}/terminal-`); - - // The artifact file actually exists and contains the full output. - const artifactPath = String(evidence.artifactPath); - expect(existsSync(artifactPath)).toBe(true); - const body = readFileSync(artifactPath, "utf8"); - expect(body).toContain("--- stdout"); - expect(body).toContain("--- stderr"); - expect(body.length).toBeGreaterThan(4000); - - // The trace record for the executed command also references the artifact - // so the Tasks timeline UI can surface a "View full output" affordance. - const trace = readTrace(config.instance, submitted.id); - const toolRecord = trace.find( - (record) => record.type === "tool" && record.message === "Command executed" - ); - expect(toolRecord).toBeDefined(); - const data = toolRecord!.data as Record; - expect(data.stdoutTruncated).toBe(true); - expect(typeof data.artifactRelPath).toBe("string"); - }); - - test("reports V1 readiness from runtime evidence", async () => { - const config = testConfig("readiness"); - const handler = createHandler(config); - - await call(handler, config, "/api/improvements", { - method: "POST", - body: JSON.stringify({ kind: "skill", title: "readiness", payload: { name: "readiness" } }) - }); - const readiness = await call(handler, config, "/api/readiness/v1"); - - expect(readiness.ok).toBe(true); - expect(readiness.checks.some((item: { id: string; status: string }) => item.id === "future_app_contracts" && item.status === "pass")).toBe(true); - }); - - test("models chat work as conversation runs with plan steps and compatibility tasks", async () => { - const config = testConfig("conversation-runs"); - const handler = createHandler(config); - - const session = await call(handler, config, "/api/chat", { - method: "POST", - body: JSON.stringify({ title: "Planful chat" }) - }); - const submitted = await call(handler, config, `/api/chat/${session.id}/messages`, { - method: "POST", - body: JSON.stringify({ content: "remember conversation runs are the execution layer" }) - }); - await waitForTask(handler, config, submitted.taskId); - await call(handler, config, `/api/chat/${session.id}/tasks/${submitted.taskId}/sync`, { method: "POST" }); - - const run = await call(handler, config, `/api/runs/${submitted.runId}`); - const chat = await call(handler, config, `/api/chat/${session.id}`); - const runs = await call(handler, config, "/api/runs"); - - expect(run.kind).toBe("conversation_turn"); - expect(run.status).toBe("completed"); - expect(run.task.id).toBe(submitted.taskId); - expect(run.planSteps.length).toBeGreaterThanOrEqual(2); - expect(chat.runIds).toContain(submitted.runId); - expect(chat.messages.some((message: { role: string; runId?: string }) => message.role === "assistant" && message.runId === submitted.runId)).toBe(true); - expect(runs.some((item: { id: string }) => item.id === submitted.runId)).toBe(true); - }); - - test("GET /api/providers/catalog carries the persistent per-provider auth status", async () => { - const config = testConfig("providers-catalog-auth"); - const handler = createHandler(config); - - // No failure records: every row reads ok with no reauth payload. - const clean = await call(handler, config, "/api/providers/catalog"); - expect(clean.every((row: { authStatus?: string; reauth?: unknown }) => row.authStatus === "ok" && row.reauth === undefined)).toBe(true); - - // Record a codex auth failure (what failTask persists on a - // ProviderAuthError, issue #233) and re-read the catalog. - await mutateState(config.instance, (state) => { - recordProviderAuthFailure(state, { - provider: "codex", - detail: "Provided authentication token is expired.", - taskId: "task_catalog" - }); - }); - const flagged = await call(handler, config, "/api/providers/catalog"); - const codex = flagged.find((row: { name: string }) => row.name === "codex"); - expect(codex.authStatus).toBe("needs_reauth"); - expect(codex.reauth).toMatchObject({ - detail: "Provided authentication token is expired.", - reauthKind: "docs", - reauthUrl: "https://gini.lilaclabs.ai/docs/providers/codex#re-authentication" - }); - expect(typeof codex.reauth.at).toBe("string"); - // Unaffected rows stay ok. - const openai = flagged.find((row: { name: string }) => row.name === "openai"); - expect(openai.authStatus).toBe("ok"); - expect(openai.reauth).toBeUndefined(); - }); - - test("connector CRUD round-trips through /api/connectors without persisting plaintext secrets", async () => { - const config = testConfig("connector-crud"); - const handler = createHandler(config); - - const created = await call(handler, config, "/api/connectors", { - method: "POST", - body: JSON.stringify({ provider: "linear", name: "primary linear", scopes: ["read"], secrets: { token: "lin_secret_abc" } }) - }); - - expect(created.provider).toBe("linear"); - expect(created.secretRefs).toHaveLength(1); - expect(created.secretRefs[0].purpose).toBe("token"); - // User-source default — only the auto-detection job emits "auto". - expect(created.source).toBe("user"); - const raw = readFileSync(`${config.stateRoot}/state.json`, "utf8"); - expect(raw).not.toContain("lin_secret_abc"); - - const listed = await call(handler, config, "/api/connectors"); - expect(listed.some((item: { id: string }) => item.id === created.id)).toBe(true); - - await call(handler, config, `/api/connectors/${created.id}`, { - method: "PATCH", - body: JSON.stringify({ secrets: { token: "lin_secret_xyz" } }) - }); - const auditAfterRotate = readState(config.instance).audit; - expect(auditAfterRotate.some((event) => event.action === "connector.rotate")).toBe(true); - - await call(handler, config, `/api/connectors/${created.id}`, { method: "DELETE" }); - const after = await call(handler, config, "/api/connectors"); - expect(after.some((item: { id: string }) => item.id === created.id)).toBe(false); - expect(existsSync(`${config.stateRoot}/secrets/${created.id}.token.json`)).toBe(false); - }); - - test("deleting an auto-source connector tombstones the record with status=disabled", async () => { - const config = testConfig("connector-tombstone"); - const handler = createHandler(config); - // Seed an auto-source connector directly on state (detection runs on - // the live gateway; this test boots a one-shot handler so we inject - // the record by hand). - await mutateState(config.instance, (state) => { - const at = new Date().toISOString(); - state.connectors.push({ - id: "id_auto_test", - instance: state.instance, - name: "auto-codex", - provider: "codex", - status: "configured", - scopes: [], - secretRefs: [], - createdAt: at, - updatedAt: at, - health: "healthy", - source: "auto" - }); - }); - const result = await call(handler, config, "/api/connectors/id_auto_test", { method: "DELETE" }); - expect(result.tombstoned).toBe(true); - const state = readState(config.instance); - const record = state.connectors.find((c) => c.id === "id_auto_test"); - expect(record?.status).toBe("disabled"); - expect(state.audit.some((event) => event.action === "connector.disable")).toBe(true); - }); - - test("POST /api/connectors/detect runs the detection job and is idempotent", async () => { - const config = testConfig("connector-detect-endpoint"); - const handler = createHandler(config); - // Stub the only two providers with a host-shelling detect() so the run - // is deterministic and never spawns `which` / `claude auth status`. - // claude-code detects positive → the first endpoint call creates an - // auto-source connector; codex detects negative. The second call must - // then skip claude-code with reason "exists", exercising the full - // create-then-skip idempotency contract through the HTTP route. - const restoreClaude = stubProviderDetect("claude-code", { - detected: true, - suggestedName: "Claude Code", - message: "stub" - }); - const restoreClaudeProbe = stubProviderProbe("claude-code", { ok: true, message: "stub" }); - const restoreCodex = stubProviderDetect("codex", { detected: false }); - try { - const first = await call(handler, config, "/api/connectors/detect", { method: "POST" }); - expect(first).toHaveProperty("considered"); - expect(first).toHaveProperty("created"); - expect((first.created as Array<{ provider: string }>).map((c) => c.provider)).toContain("claude-code"); - // The second call should not create any new records — the detection - // logic is idempotent at the registry+state level. - const second = await call(handler, config, "/api/connectors/detect", { method: "POST" }); - const createdProviders = (second.created as Array<{ provider: string }>).map((c) => c.provider); - expect(createdProviders).toEqual([]); - expect((second.skipped as Array<{ provider: string; reason: string }>).find((s) => s.provider === "claude-code")?.reason).toBe("exists"); - } finally { - restoreClaude(); - restoreClaudeProbe(); - restoreCodex(); - } - }); - - test("GET /api/connectors/providers returns the registry", async () => { - const config = testConfig("providers-list"); - const handler = createHandler(config); - const providers = await call(handler, config, "/api/connectors/providers"); - expect(Array.isArray(providers)).toBe(true); - const ids = providers.map((p: { id: string }) => p.id); - expect(ids).toContain("demo"); - expect(ids).toContain("linear"); - expect(ids).toContain("generic"); - expect(ids).toContain("claude-code"); - expect(ids).toContain("codex"); - // Credential templates: linear (single env binding) → api-key prefill - // with the MCP URL + server name; google-oauth-desktop (two bindings) → - // oauth2 envMap. - const linear = providers.find((p: { id: string }) => p.id === "linear"); - expect(linear.credentialTemplate).toEqual({ - type: "api-key", - name: "LINEAR_API_KEY", - mcpUrl: "https://mcp.linear.app/mcp", - mcpName: "linear" - }); - const gws = providers.find((p: { id: string }) => p.id === "google-oauth-desktop"); - expect(gws.credentialTemplate.type).toBe("oauth2"); - expect(gws.credentialTemplate.envMap).toEqual({ - client_id: "GOOGLE_WORKSPACE_CLI_CLIENT_ID", - client_secret: "GOOGLE_WORKSPACE_CLI_CLIENT_SECRET" - }); - // Presence-only providers (no secret spec) carry no template. - const demo = providers.find((p: { id: string }) => p.id === "demo"); - expect(demo.credentialTemplate).toBeUndefined(); - }); - - test("POST /api/connectors threads a typed api-key credential through createConnector", async () => { - const config = testConfig("connector-typed-create"); - const handler = createHandler(config); - const created = await call(handler, config, "/api/connectors", { - method: "POST", - body: JSON.stringify({ - provider: "generic", - name: "MY_SERVICE_KEY", - type: "api-key", - secrets: { MY_SERVICE_KEY: "lin_secret_typed" }, - metadata: { mcp: { url: "https://mcp.example.com/mcp", headerName: "Authorization", scheme: "Bearer" } } - }) - }); - expect(created.type).toBe("api-key"); - expect(created.name).toBe("MY_SERVICE_KEY"); - expect(created.metadata.mcp.url).toBe("https://mcp.example.com/mcp"); - expect(created.secretRefs).toHaveLength(1); - expect(created.secretRefs[0].purpose).toBe("MY_SERVICE_KEY"); - const raw = readFileSync(`${config.stateRoot}/state.json`, "utf8"); - expect(raw).not.toContain("lin_secret_typed"); - }); - - test("POST /api/setup-requests//complete creates a connector and resolves the setup request on probe success", async () => { - const config = testConfig("setup-requests-complete-happy"); - const handler = createHandler(config); - // Stage a connector.request setup-request row directly. Demo provider has - // no probe, so checkConnector falls back to presence-only => healthy - // without any network mocking. - const { createSetupRequest } = await import("./state"); - const approval = await mutateState(config.instance, (state) => - createSetupRequest(state, { - action: "connector.request", - target: "demo", - reason: "test connect", - payload: { - provider: "demo", - providerLabel: "Demo", - providerDescription: "Demo provider", - reason: "test connect", - fields: [], - toolCallId: "call_demo_1" - } - }) - ); - - const response = await call(handler, config, `/api/setup-requests/${approval.id}/complete`, { - method: "POST", - body: JSON.stringify({ secrets: {}, scopes: [] }) - }); - expect(response.ok).toBe(true); - expect(response.connector.provider).toBe("demo"); - expect(response.connector.health).toBe("healthy"); - - const state = readState(config.instance); - const resolved = state.setupRequests.find((a) => a.id === approval.id); - expect(resolved?.status).toBe("completed"); - expect(state.connectors.some((c) => c.provider === "demo" && c.health === "healthy")).toBe(true); - }); - - test("POST /api/setup-requests//complete grants the connector and enables the skill for skill.grant_connector", async () => { - const config = testConfig("setup-complete-skill-grant"); - const handler = createHandler(config); - const { createSetupRequest, createSkill } = await import("./state"); - const skill = await mutateState(config.instance, (state) => - createSkill(state, { - name: "needs-linear", - description: "", - trigger: "", - steps: [], - requiredTools: [], - requiredPermissions: [], - status: "disabled", - source: "user", - requiredConnectors: [{ provider: "linear" }] - }) - ); - const approval = await mutateState(config.instance, (state) => - createSetupRequest(state, { - action: "skill.grant_connector", - target: "Linear", - reason: "Skill needs-linear requests access to your Linear credential.", - payload: { - skillId: skill.id, - skillName: skill.name, - credentialName: "LINEAR_API_KEY", - credentialLabel: "Linear", - toolCallId: "call_grant_1" - } - }) - ); - - const response = await call(handler, config, `/api/setup-requests/${approval.id}/complete`, { - method: "POST", - body: JSON.stringify({}) - }); - expect(response.ok).toBe(true); - - const state = readState(config.instance); - const resolved = state.setupRequests.find((a) => a.id === approval.id); - expect(resolved?.status).toBe("completed"); - const updated = state.skills.find((s) => s.id === skill.id); - expect(updated?.status).toBe("enabled"); - expect(updated?.grantedConnectors).toEqual(["LINEAR_API_KEY"]); - expect(state.audit.some((a) => a.action === "skill.connector.granted")).toBe(true); - }); - - test("POST /api/setup-requests//complete: a templateless connector.request creates a typed api-key, grants + enables the skill, and records no secret", async () => { - const config = testConfig("setup-complete-templateless"); - const handler = createHandler(config); - const { createSetupRequest, createSkill } = await import("./state"); - const skill = await mutateState(config.instance, (state) => - createSkill(state, { - name: "needs-some-service", - description: "", - trigger: "", - steps: [], - requiredTools: [], - requiredPermissions: [], - status: "disabled", - source: "user", - requiredCredentials: ["SOME_SERVICE_API_KEY"] - }) - ); - // Templateless payload: no `provider`, carries credentialType/Name/Label + - // skillId (exactly what requestConnectorTool mints). - const approval = await mutateState(config.instance, (state) => - createSetupRequest(state, { - action: "connector.request", - target: "SOME_SERVICE_API_KEY", - reason: "Enter your Some Service API key", - payload: { - credentialName: "SOME_SERVICE_API_KEY", - credentialType: "api-key", - credentialLabel: "Some Service", - skillId: skill.id, - reason: "Enter your Some Service API key", - toolCallId: "call_tl_complete" - } - }) - ); - - const secretValue = "sk-some-service-super-secret"; - const response = await call(handler, config, `/api/setup-requests/${approval.id}/complete`, { - method: "POST", - body: JSON.stringify({ secrets: { SOME_SERVICE_API_KEY: secretValue } }) - }); - expect(response.ok).toBe(true); - expect(response.connector.health).toBe("healthy"); - - const state = readState(config.instance); - // A TYPED api-key record landed under the requested name. - const connector = state.connectors.find((c) => c.name === "SOME_SERVICE_API_KEY"); - expect(connector).toBeDefined(); - expect(connector?.type).toBe("api-key"); - // The requesting skill was granted the credential and enabled (its only - // required credential is now granted). - const updated = state.skills.find((s) => s.id === skill.id); - expect(updated?.grantedConnectors).toEqual(["SOME_SERVICE_API_KEY"]); - expect(updated?.status).toBe("enabled"); - // The setup request resolved. - expect(state.setupRequests.find((a) => a.id === approval.id)?.status).toBe("completed"); - // The audit row for connector.request carries the credential name but NO - // secret value — the secret stays server-side. - const requestAudit = state.audit.find((a) => a.action === "connector.request"); - expect(requestAudit).toBeDefined(); - expect((requestAudit?.evidence as Record)?.credentialName).toBe("SOME_SERVICE_API_KEY"); - expect(JSON.stringify(state.audit)).not.toContain(secretValue); - }); - - test("POST /api/setup-requests//complete: a known-provider connector.request with skillId grants + enables the skill", async () => { - const config = testConfig("setup-complete-known-grant"); - const handler = createHandler(config); - const { createSetupRequest, createSkill } = await import("./state"); - // demo provider has no probe (presence-only healthy) and no credential - // template, so its record stays untyped — to exercise the grant we point - // the skill's requiredCredentials at the connector name the demo create - // lands ("Demo") and assert the grant is recorded. firstUngrantedCredential - // only blocks on TYPED credentials, so an untyped demo connector enables. - const skill = await mutateState(config.instance, (state) => - createSkill(state, { - name: "needs-demo", - description: "", - trigger: "", - steps: [], - requiredTools: [], - requiredPermissions: [], - status: "disabled", - source: "user", - requiredCredentials: ["Demo"] - }) - ); - const approval = await mutateState(config.instance, (state) => - createSetupRequest(state, { - action: "connector.request", - target: "demo", - reason: "connect demo", - payload: { - provider: "demo", - providerLabel: "Demo", - providerDescription: "Demo provider", - fields: [], - skillId: skill.id, - reason: "connect demo", - toolCallId: "call_known_complete" - } - }) - ); - - const response = await call(handler, config, `/api/setup-requests/${approval.id}/complete`, { - method: "POST", - body: JSON.stringify({ secrets: {}, scopes: [] }) - }); - expect(response.ok).toBe(true); - expect(response.connector.provider).toBe("demo"); - - const state = readState(config.instance); - const updated = state.skills.find((s) => s.id === skill.id); - expect(updated?.grantedConnectors).toEqual(["Demo"]); - expect(updated?.status).toBe("enabled"); - }); - - test("POST /api/setup-requests//complete: a known-provider (linear) connector.request creates a TYPED LINEAR_API_KEY, grants + enables the requesting skill", async () => { - // Real template-path regression: a connector.request for {provider:"linear", - // skillId} must land a TYPED LINEAR_API_KEY record (stamped from the - // module's credentialTemplate), and because the requesting skill declares - // LINEAR_API_KEY, completing the card grants it and enables the skill — no - // second consent card. The demo provider can't prove this (it's untyped / - // presence-only); linear has both a credentialTemplate and a live probe, so - // we stub a healthy viewer query. - const config = testConfig("setup-complete-linear-typed-grant"); - const handler = createHandler(config); - const { createSetupRequest, createSkill } = await import("./state"); - const skill = await mutateState(config.instance, (state) => - createSkill(state, { - name: "needs-linear-typed", - description: "", - trigger: "", - steps: [], - requiredTools: [], - requiredPermissions: [], - status: "disabled", - source: "user", - requiredCredentials: ["LINEAR_API_KEY"] - }) - ); - const approval = await mutateState(config.instance, (state) => - createSetupRequest(state, { - action: "connector.request", - target: "linear", - reason: "connect linear", - payload: { - provider: "linear", - providerLabel: "Linear", - providerDescription: "Linear", - fields: [], - skillId: skill.id, - reason: "connect linear", - toolCallId: "call_linear_typed" - } - }) - ); - - // Stub the Linear GraphQL probe with a healthy viewer so checkConnector - // flips the typed record to healthy without a live network call. - const originalFetch = globalThis.fetch; - globalThis.fetch = (async () => - new Response(JSON.stringify({ data: { viewer: { id: "u1", name: "Tester", email: "t@e.co" } } }), { - status: 200, - headers: { "content-type": "application/json" } - })) as unknown as typeof fetch; - let response: { ok: boolean; connector?: { provider?: string } }; - try { - response = await call(handler, config, `/api/setup-requests/${approval.id}/complete`, { - method: "POST", - body: JSON.stringify({ secrets: { token: "lin_api_realish" } }) - }); - } finally { - globalThis.fetch = originalFetch; - } - expect(response.ok).toBe(true); - - const state = readState(config.instance); - // The Linear template stamps a TYPED api-key record named LINEAR_API_KEY. - const connector = state.connectors.find((c) => c.provider === "linear"); - expect(connector?.type).toBe("api-key"); - expect(connector?.name).toBe("LINEAR_API_KEY"); - expect(connector?.health).toBe("healthy"); - // The requesting skill (declares LINEAR_API_KEY) was granted + enabled. - const updated = state.skills.find((s) => s.id === skill.id); - expect(updated?.grantedConnectors).toEqual(["LINEAR_API_KEY"]); - expect(updated?.status).toBe("enabled"); - // No secret value leaked into the audit log. - expect(JSON.stringify(state.audit)).not.toContain("lin_api_realish"); - }); - - test("POST /api/setup-requests//complete: skillId for a skill that does NOT declare the credential creates the connector but does NOT grant or enable", async () => { - // Auto-grant trust guard: the model supplies skillId, so /complete must - // verify the named skill actually declares connector.name before granting. - // A skill that does not declare the credential gets the connector created - // (so the credential exists) but is neither granted the credential nor - // enabled — "a skill only gets credentials it declared + the user granted". - const config = testConfig("setup-complete-undeclared-no-grant"); - const handler = createHandler(config); - const { createSetupRequest, createSkill } = await import("./state"); - const skill = await mutateState(config.instance, (state) => - createSkill(state, { - name: "wants-other-cred", - description: "", - trigger: "", - steps: [], - requiredTools: [], - requiredPermissions: [], - status: "disabled", - source: "user", - // Declares a DIFFERENT credential than the one being requested. - requiredCredentials: ["OTHER_API_KEY"] - }) - ); - const approval = await mutateState(config.instance, (state) => - createSetupRequest(state, { - action: "connector.request", - target: "SOME_SERVICE_API_KEY", - reason: "Enter your Some Service API key", - payload: { - credentialName: "SOME_SERVICE_API_KEY", - credentialType: "api-key", - credentialLabel: "Some Service", - skillId: skill.id, - reason: "Enter your Some Service API key", - toolCallId: "call_undeclared" - } - }) - ); - - const response = await call(handler, config, `/api/setup-requests/${approval.id}/complete`, { - method: "POST", - body: JSON.stringify({ secrets: { SOME_SERVICE_API_KEY: "sk-secret" } }) - }); - expect(response.ok).toBe(true); - - const state = readState(config.instance); - // The connector was created (the credential now exists). - expect(state.connectors.some((c) => c.name === "SOME_SERVICE_API_KEY")).toBe(true); - // But the skill — which never declared SOME_SERVICE_API_KEY — was NOT - // granted it and stays disabled. - const updated = state.skills.find((s) => s.id === skill.id); - expect(updated?.grantedConnectors ?? []).not.toContain("SOME_SERVICE_API_KEY"); - expect(updated?.status).toBe("disabled"); - }); - - test("POST /api/setup-requests//complete: a multi-credential skill grants the requested credential but stays DISABLED while another required credential has no connector", async () => { - // Enable-when-fully-satisfied: a skill that requires two credentials and - // only just got the first must NOT be enabled while the second has no - // connector row at all. firstUngrantedCredential alone misses this (it - // skips required creds with no connector); isSkillActive catches it. - const config = testConfig("setup-complete-partial-multi-disabled"); - const handler = createHandler(config); - const { createSetupRequest, createSkill } = await import("./state"); - const skill = await mutateState(config.instance, (state) => - createSkill(state, { - name: "needs-two-creds", - description: "", - trigger: "", - steps: [], - requiredTools: [], - requiredPermissions: [], - status: "disabled", - source: "user", - // SECOND_API_KEY has no connector yet — it'll be requested separately. - requiredCredentials: ["FIRST_API_KEY", "SECOND_API_KEY"] - }) - ); - const approval = await mutateState(config.instance, (state) => - createSetupRequest(state, { - action: "connector.request", - target: "FIRST_API_KEY", - reason: "Enter your first API key", - payload: { - credentialName: "FIRST_API_KEY", - credentialType: "api-key", - credentialLabel: "First", - skillId: skill.id, - reason: "Enter your first API key", - toolCallId: "call_first" - } - }) - ); - - const response = await call(handler, config, `/api/setup-requests/${approval.id}/complete`, { - method: "POST", - body: JSON.stringify({ secrets: { FIRST_API_KEY: "sk-first" } }) - }); - expect(response.ok).toBe(true); - - const state = readState(config.instance); - const updated = state.skills.find((s) => s.id === skill.id); - // The requested credential was granted (the human entered it for this skill). - expect(updated?.grantedConnectors).toEqual(["FIRST_API_KEY"]); - // But the skill stays disabled — SECOND_API_KEY still has no connector. - expect(updated?.status).toBe("disabled"); - }); - - test("POST /api/setup-requests//complete on a multi-provider skill grants one provider, stays disabled, and mints the next grant card", async () => { - const config = testConfig("setup-complete-skill-grant-multi"); - const handler = createHandler(config); - const { createSetupRequest, createSkill } = await import("./state"); - await seedTypedCredential(config, "LINEAR_API_KEY", "linear"); - await seedTypedCredential(config, "GENERIC_KEY", "generic"); - const skill = await mutateState(config.instance, (state) => - createSkill(state, { - name: "needs-two", - description: "", - trigger: "", - steps: [], - requiredTools: [], - requiredPermissions: [], - status: "disabled", - source: "user", - requiredCredentials: ["LINEAR_API_KEY", "GENERIC_KEY"] - }) - ); - const approval = await mutateState(config.instance, (state) => - createSetupRequest(state, { - action: "skill.grant_connector", - target: "Linear", - reason: "Skill needs-two requests access to your Linear credential.", - payload: { - skillId: skill.id, - skillName: skill.name, - credentialName: "LINEAR_API_KEY", - credentialLabel: "Linear", - toolCallId: "call_grant_multi" - } - }) - ); - - const response = await call(handler, config, `/api/setup-requests/${approval.id}/complete`, { - method: "POST", - body: JSON.stringify({}) - }); - expect(response.ok).toBe(true); - - const state = readState(config.instance); - const resolved = state.setupRequests.find((a) => a.id === approval.id); - expect(resolved?.status).toBe("completed"); - const updated = state.skills.find((s) => s.id === skill.id); - // Only the first credential is granted; the skill stays disabled until the - // remaining credential is granted too. - expect(updated?.grantedConnectors).toEqual(["LINEAR_API_KEY"]); - expect(updated?.status).toBe("disabled"); - // A new pending grant card was minted for the remaining credential. - const next = state.setupRequests.find( - (s) => s.status === "pending" && s.action === "skill.grant_connector" && s.payload.credentialName === "GENERIC_KEY" - ); - expect(next).toBeDefined(); - expect(next?.payload.skillId).toBe(skill.id); - }); - - test("POST /api/setup-requests//complete: a double-complete of one grant request resolves once and mints exactly one next card", async () => { - const config = testConfig("setup-complete-skill-grant-double"); - const handler = createHandler(config); - const { createSetupRequest, createSkill } = await import("./state"); - await seedTypedCredential(config, "LINEAR_API_KEY", "linear"); - await seedTypedCredential(config, "GENERIC_KEY", "generic"); - const skill = await mutateState(config.instance, (state) => - createSkill(state, { - name: "needs-two-double", - description: "", - trigger: "", - steps: [], - requiredTools: [], - requiredPermissions: [], - status: "disabled", - source: "user", - requiredCredentials: ["LINEAR_API_KEY", "GENERIC_KEY"] - }) - ); - const approval = await mutateState(config.instance, (state) => - createSetupRequest(state, { - action: "skill.grant_connector", - target: "Linear", - reason: "Skill needs-two-double requests access to your Linear credential.", - payload: { - skillId: skill.id, - skillName: skill.name, - credentialName: "LINEAR_API_KEY", - credentialLabel: "Linear", - toolCallId: "call_grant_double" - } - }) - ); - - // Fire two completes of the SAME request. The mutateState lock serializes - // the atomic claim, so exactly one wins; the loser hits the already- - // resolved guard and mints nothing. No extra pending grant row. - const [a, b] = await Promise.all([ - rawCall(handler, config, `/api/setup-requests/${approval.id}/complete`, { - method: "POST", - body: JSON.stringify({}) - }, config.token), - rawCall(handler, config, `/api/setup-requests/${approval.id}/complete`, { - method: "POST", - body: JSON.stringify({}) - }, config.token) - ]); - const oks = [a.ok, b.ok]; - expect(oks.filter(Boolean).length).toBe(1); - - const state = readState(config.instance); - const resolved = state.setupRequests.find((s) => s.id === approval.id); - expect(resolved?.status).toBe("completed"); - // Exactly one next card for the remaining credential — no duplicate from - // the losing racer. - const next = state.setupRequests.filter( - (s) => s.status === "pending" && s.action === "skill.grant_connector" && s.payload.credentialName === "GENERIC_KEY" - ); - expect(next.length).toBe(1); - // No stray pending grant rows beyond that single next card. - const pendingGrants = state.setupRequests.filter( - (s) => s.status === "pending" && s.action === "skill.grant_connector" - ); - expect(pendingGrants.length).toBe(1); - }); - - test("POST /api/setup-requests//complete: a double-complete of the FINAL grant request enables once and writes exactly one skill.enabled audit and one grant", async () => { - const config = testConfig("setup-complete-skill-grant-final-double"); - const handler = createHandler(config); - const { createSetupRequest, createSkill, createTask, upsertTask } = await import("./state"); - // A single-provider skill so completing the one grant card is the FINAL - // step (no next card): the winner records the grant, enables the skill, - // and resumes the task. A losing racer must produce ZERO side effects — - // no duplicate grant, no duplicate skill.enabled audit, no second resume. - const skill = await mutateState(config.instance, (state) => - createSkill(state, { - name: "needs-linear-final-double", - description: "", - trigger: "", - steps: [], - requiredTools: [], - requiredPermissions: [], - status: "disabled", - source: "user", - requiredConnectors: [{ provider: "linear" }] - }) - ); - // Seed a terminal task so the resume branch is exercised but bails fast - // (resumeChatTask no-ops on a completed task) instead of polling for a - // waiting_approval flip that never comes. - const taskId = await mutateState(config.instance, (state) => { - const task = createTask(state.instance, "grant final double"); - task.status = "completed"; - upsertTask(state, task); - return task.id; - }); - const approval = await mutateState(config.instance, (state) => - createSetupRequest(state, { - taskId, - action: "skill.grant_connector", - target: "Linear", - reason: "Skill needs-linear-final-double requests access to your Linear credential.", - payload: { - skillId: skill.id, - skillName: skill.name, - credentialName: "LINEAR_API_KEY", - credentialLabel: "Linear", - toolCallId: "call_grant_final_double" - } - }) - ); - - const [a, b] = await Promise.all([ - rawCall(handler, config, `/api/setup-requests/${approval.id}/complete`, { - method: "POST", - body: JSON.stringify({}) - }, config.token), - rawCall(handler, config, `/api/setup-requests/${approval.id}/complete`, { - method: "POST", - body: JSON.stringify({}) - }, config.token) - ]); - expect([a.ok, b.ok].filter(Boolean).length).toBe(1); - - const state = readState(config.instance); - const resolved = state.setupRequests.find((s) => s.id === approval.id); - expect(resolved?.status).toBe("completed"); - const updated = state.skills.find((s) => s.id === skill.id); - expect(updated?.status).toBe("enabled"); - // Exactly one grant — the loser double-granted nothing. - expect(updated?.grantedConnectors).toEqual(["LINEAR_API_KEY"]); - // Exactly ONE skill.enabled audit row (the loser produced no second one). - expect(state.audit.filter((a) => a.action === "skill.enabled").length).toBe(1); - // Exactly ONE grant audit row. - expect(state.audit.filter((a) => a.action === "skill.connector.granted").length).toBe(1); - // No extra pending grant rows from the losing racer. - expect( - state.setupRequests.filter((s) => s.status === "pending" && s.action === "skill.grant_connector").length - ).toBe(0); - }); - - test("POST /api/setup-requests//complete vs cancel on the same grant card: Cancel prevents the grant+enable", async () => { - const config = testConfig("setup-complete-skill-grant-cancel-race"); - const handler = createHandler(config); - const { createSetupRequest, createSkill, createTask, upsertTask } = await import("./state"); - const skill = await mutateState(config.instance, (state) => - createSkill(state, { - name: "needs-linear-cancel-race", - description: "", - trigger: "", - steps: [], - requiredTools: [], - requiredPermissions: [], - status: "disabled", - source: "user", - requiredConnectors: [{ provider: "linear" }] - }) - ); - const taskId = await mutateState(config.instance, (state) => { - const task = createTask(state.instance, "grant cancel race"); - task.status = "completed"; - upsertTask(state, task); - return task.id; - }); - const approval = await mutateState(config.instance, (state) => - createSetupRequest(state, { - taskId, - action: "skill.grant_connector", - target: "Linear", - reason: "Skill needs-linear-cancel-race requests access to your Linear credential.", - payload: { - skillId: skill.id, - skillName: skill.name, - credentialName: "LINEAR_API_KEY", - credentialLabel: "Linear", - toolCallId: "call_grant_cancel_race" - } - }) - ); - - // Race a complete against a cancel on the SAME card. The per-instance - // mutateState lock serializes the two pending→terminal transitions, so - // exactly one wins. The consent gate must be honored: whichever side wins, - // a grant+enable happens ONLY if complete won — a winning cancel leaves the - // skill disabled and ungranted. - const [completeRes, cancelRes] = await Promise.all([ - rawCall(handler, config, `/api/setup-requests/${approval.id}/complete`, { - method: "POST", - body: JSON.stringify({}) - }, config.token), - rawCall(handler, config, `/api/setup-requests/${approval.id}/cancel`, { - method: "POST", - body: JSON.stringify({}) - }, config.token) - ]); - - const state = readState(config.instance); - const resolved = state.setupRequests.find((s) => s.id === approval.id); - const updated = state.skills.find((s) => s.id === skill.id); - const granted = state.audit.some((a) => a.action === "skill.connector.granted"); - const enabled = state.audit.some((a) => a.action === "skill.enabled"); - - if (resolved?.status === "cancelled") { - // Cancel won — the consent gate is honored: NO grant, NO enable. - expect(completeRes.ok).toBe(false); - expect(updated?.status).toBe("disabled"); - expect(updated?.grantedConnectors ?? []).toEqual([]); - expect(granted).toBe(false); - expect(enabled).toBe(false); - } else { - // Complete won — cancel is a no-op against the now-completed row, and - // the skill is granted+enabled. - expect(resolved?.status).toBe("completed"); - expect(cancelRes.ok).toBe(false); - expect(updated?.status).toBe("enabled"); - expect(updated?.grantedConnectors).toEqual(["LINEAR_API_KEY"]); - expect(granted).toBe(true); - expect(enabled).toBe(true); - } - }); - - test("POST /api/setup-requests//complete returns ok:false, claims the request, and cleans up the connector on probe failure", async () => { - const config = testConfig("setup-requests-complete-probe-fail"); - const handler = createHandler(config); - const { createSetupRequest } = await import("./state"); - const approval = await mutateState(config.instance, (state) => - createSetupRequest(state, { - action: "connector.request", - target: "linear", - reason: "fetch issues", - payload: { - provider: "linear", - providerLabel: "Linear", - providerDescription: "Linear", - reason: "fetch issues", - fields: [], - toolCallId: "call_linear_fail" - } - }) - ); - // Stub the Linear GraphQL probe with a 401 so the probe fails. - const originalFetch = globalThis.fetch; - globalThis.fetch = (async () => new Response("{\"errors\":[{\"message\":\"Unauthorized\"}]}", { - status: 401, - headers: { "content-type": "application/json" } - })) as unknown as typeof fetch; - try { - const response = await call(handler, config, `/api/setup-requests/${approval.id}/complete`, { - method: "POST", - body: JSON.stringify({ secrets: { token: "not-a-real-token" } }) - }); - expect(response.ok).toBe(false); - expect(response.message).toBeString(); - } finally { - globalThis.fetch = originalFetch; - } - // The row was claimed BEFORE the create (claim-first race safety), so a - // probe failure cannot bounce it back to pending — it stays completed - // with a persisted failure outcome, and the orphaned unhealthy connector - // is cleaned up so it never lingers as a half-configured record. - const state = readState(config.instance); - const after = state.setupRequests.find((a) => a.id === approval.id); - expect(after?.status).toBe("completed"); - expect(after?.connectOutcome?.ok).toBe(false); - expect(state.connectors.some((c) => c.provider === "linear")).toBe(false); - }); - - test("POST /api/setup-requests//complete: an UNEXPECTED post-claim throw resumes the task instead of stranding it", async () => { - // Strand-the-task regression: after the winning claim, createConnector / - // grant / enable / resume can still throw (here: a duplicate credential - // name). The route's catch-all would return 500 while the setup row sits - // `completed` and the task stays `waiting_approval` — orphaned. The fix - // wraps the whole post-claim block: any throw persists a failure outcome - // and resumes the task. We seed a genuine waiting_approval task with a - // resumable toolCallState (one pending request_connector call) so the - // resume re-enters the echo loop and the task settles terminally. - const config = testConfig("setup-complete-postclaim-throw"); - const handler = createHandler(config); - const { createSetupRequest, createTask, upsertTask } = await import("./state"); - - // Pre-seed a connector under the requested name so createConnector throws - // on instance-wide name uniqueness AFTER the claim. - await seedTypedCredential(config, "DUP_API_KEY", "generic"); - - const toolCallId = "call_postclaim_throw"; - const taskId = await mutateState(config.instance, (state) => { - const task = createTask(state.instance, "needs dup key"); - task.status = "waiting_approval"; - task.toolCallState = { - messages: [ - { role: "system", content: "you are gini" }, - { role: "user", content: "connect dup" }, - { role: "assistant", content: "", tool_calls: [{ id: toolCallId, type: "function", function: { name: "request_connector", arguments: "{}" } }] } - ], - toolsHash: "test", - pending: [{ toolCallId, toolName: "request_connector", approvalId: "" }], - iterations: 1 - }; - upsertTask(state, task); - return task.id; - }); - - const approval = await mutateState(config.instance, (state) => { - const a = createSetupRequest(state, { - taskId, - action: "connector.request", - target: "DUP_API_KEY", - reason: "Enter your Dup API key", - payload: { - credentialName: "DUP_API_KEY", - credentialType: "api-key", - credentialLabel: "Dup", - reason: "Enter your Dup API key", - toolCallId - } - }); - // Bind the approval to the task so the pending entry resolves on resume. - const item = state.tasks.find((t) => t.id === taskId)!; - item.toolCallState!.pending[0]!.approvalId = a.id; - item.approvalIds.push(a.id); - return a; - }); - - const raw = await rawCall(handler, config, `/api/setup-requests/${approval.id}/complete`, { - method: "POST", - body: JSON.stringify({ secrets: { DUP_API_KEY: "dup-secret" } }) - }, config.token); - const response = await raw.json(); - // The route returned a structured failure body (the outcome + resume ran; - // it is NOT the bare catch-all 500 that bypasses both). - expect(response.ok).toBe(false); - expect(response.message).toBeString(); - - const settled = await waitForTask(handler, config, taskId); - // The task RESUMED — it is no longer stranded at waiting_approval. - expect(settled.task.status).not.toBe("waiting_approval"); - - const state = readState(config.instance); - const resolved = state.setupRequests.find((a) => a.id === approval.id); - // The setup row is claimed (resolved) with a persisted failure outcome. - expect(resolved?.status).toBe("completed"); - expect(resolved?.connectOutcome?.ok).toBe(false); - // No duplicate connector was created — only the pre-seeded one remains. - expect(state.connectors.filter((c) => c.name === "DUP_API_KEY").length).toBe(1); - }); - - test("POST /api/setup-requests//complete: a double-submit of a connector.request resolves once with no extra mutations", async () => { - // Claim-first race safety for connector.request: two concurrent completes - // of the same card — the mutateState lock serializes the atomic claim, so - // exactly one wins and creates exactly one connector; the loser produces - // zero side effects. - const config = testConfig("setup-complete-connector-double"); - const handler = createHandler(config); - const { createSetupRequest, createSkill } = await import("./state"); - const skill = await mutateState(config.instance, (state) => - createSkill(state, { - name: "needs-race-key", - description: "", - trigger: "", - steps: [], - requiredTools: [], - requiredPermissions: [], - status: "disabled", - source: "user", - requiredCredentials: ["RACE_API_KEY"] - }) - ); - const approval = await mutateState(config.instance, (state) => - createSetupRequest(state, { - action: "connector.request", - target: "RACE_API_KEY", - reason: "Enter your Race API key", - payload: { - credentialName: "RACE_API_KEY", - credentialType: "api-key", - credentialLabel: "Race", - skillId: skill.id, - reason: "Enter your Race API key", - toolCallId: "call_race" - } - }) - ); - - const [a, b] = await Promise.all([ - rawCall(handler, config, `/api/setup-requests/${approval.id}/complete`, { - method: "POST", - body: JSON.stringify({ secrets: { RACE_API_KEY: "race-secret" } }) - }, config.token), - rawCall(handler, config, `/api/setup-requests/${approval.id}/complete`, { - method: "POST", - body: JSON.stringify({ secrets: { RACE_API_KEY: "race-secret" } }) - }, config.token) - ]); - // Exactly one winner. - expect([a.ok, b.ok].filter(Boolean).length).toBe(1); - - const state = readState(config.instance); - const resolved = state.setupRequests.find((s) => s.id === approval.id); - expect(resolved?.status).toBe("completed"); - // Exactly one connector created — the loser created nothing. - expect(state.connectors.filter((c) => c.name === "RACE_API_KEY").length).toBe(1); - // The skill was granted the credential exactly once and enabled. - const updated = state.skills.find((s) => s.id === skill.id); - expect(updated?.grantedConnectors).toEqual(["RACE_API_KEY"]); - expect(updated?.status).toBe("enabled"); - // Exactly one grant audit row from the single winner. - expect(state.audit.filter((au) => au.action === "skill.connector.granted").length).toBe(1); - }); - - test("POST /api/setup-requests//complete 404s for an authorization id", async () => { - const config = testConfig("setup-requests-complete-wrong-collection"); - const handler = createHandler(config); - const { createAuthorization } = await import("./state"); - const approval = await mutateState(config.instance, (state) => - createAuthorization(state, { - action: "file.write", - target: "/tmp/x", - risk: "high", - reason: "stub", - payload: { path: "/tmp/x", content: "hi" } - }) - ); - const response = await rawCall( - handler, - config, - `/api/setup-requests/${approval.id}/complete`, - { method: "POST", body: JSON.stringify({ secrets: {} }) }, - config.token - ); - // Authorization ids never appear in the setupRequests collection, so - // /complete returns 404 — the two endpoint families are independent. - expect(response.status).toBe(404); - }); - - test("POST /api/setup-requests//complete creates a messaging bridge and resolves the setup request", async () => { - // Happy-path pin for the chat-side Add Telegram flow. The card's - // Submit button POSTs the name + bot token under `secrets`; the - // gateway routes them into addMessagingBridge (the same code path - // the CLI and the settings page already call) and resolves the - // setup request so the chat-task loop can resume. - const config = testConfig("setup-complete-bridge-happy"); - const handler = createHandler(config); - const { createSetupRequest } = await import("./state"); - const setup = await mutateState(config.instance, (state) => - createSetupRequest(state, { - action: "messaging.add_bridge", - target: "telegram", - reason: "Add a Telegram bridge", - payload: { kind: "telegram", suggestedName: "chat-test-bridge", toolCallId: "call_bridge_happy" } - }) - ); - - const response = await call(handler, config, `/api/setup-requests/${setup.id}/complete`, { - method: "POST", - body: JSON.stringify({ secrets: { name: "chat-test-bridge", botToken: "1234:ABCDEFGHIJKLMNOPQR" } }) - }); - expect(response.ok).toBe(true); - expect(response.bridge?.name).toBe("chat-test-bridge"); - expect(response.bridge?.kind).toBe("telegram"); - - const state = readState(config.instance); - const resolved = state.setupRequests.find((s) => s.id === setup.id); - expect(resolved?.status).toBe("completed"); - const bridge = state.messagingBridges.find((b) => b.name === "chat-test-bridge"); - expect(bridge).toBeDefined(); - expect(bridge?.kind).toBe("telegram"); - - // Audit-row traceability pin: the chat-card create writes a - // dedicated audit row with the originating setup-request id AND the - // resulting bridge.id so operators can reconstruct - // "setup X via chat-card → bridge Y" from the activity feed. - // Without this row the chat path is indistinguishable from the - // CLI / settings dialog in the audit log (both write the same - // generic messaging.configured row via createMessagingBridgeRecord). - const chatAddRow = state.audit.find( - (e) => e.action === "messaging.add_bridge" && e.approvalId === setup.id - ); - expect(chatAddRow).toBeDefined(); - expect(chatAddRow?.target).toBe(bridge?.id); - expect((chatAddRow?.evidence as { kind?: string } | undefined)?.kind).toBe("telegram"); - expect((chatAddRow?.evidence as { bridgeName?: string } | undefined)?.bridgeName).toBe("chat-test-bridge"); - - // Durable outcome pin: the /complete handler writes - // setup.connectOutcome so a post-reload render of the resolved - // card reads the truthful past-tense summary. Without this, the - // React component's sticky state evaporates on reload and the - // card would fall back to "Bridge added." even when the side - // effect actually failed. - expect(resolved?.connectOutcome?.ok).toBe(true); - expect(resolved?.connectOutcome?.message).toContain("chat-test-bridge"); - }); - - test("POST /api/setup-requests//complete refuses messaging.add_bridge that was already cancelled, and creates no bridge", async () => { - // Race-safety pin: the messaging.add_bridge branch must resolve the - // setup request BEFORE addMessagingBridge so a concurrent /cancel - // (or cancel cascade) cannot leave an orphan bridge + encrypted - // secret on disk after the user has already abandoned the prompt. - // Mirrors the resolve-first contract in - // src/execution/browser-fill-secrets.ts. We simulate the race by - // pre-cancelling the setup request and then hitting /complete — the - // handler must short-circuit at the "already !pending" guard, - // return 410, and never touch addMessagingBridge. - const config = testConfig("setup-complete-bridge-cancel-race"); - const handler = createHandler(config); - const { createSetupRequest } = await import("./state"); - const setup = await mutateState(config.instance, (state) => - createSetupRequest(state, { - action: "messaging.add_bridge", - target: "telegram", - reason: "Add a Telegram bridge", - payload: { kind: "telegram", suggestedName: "race-bridge", toolCallId: "call_bridge_race" } - }) - ); - // Pre-cancel the setup request as if a concurrent operator had - // clicked Cancel between the user's typing and the Submit landing - // on the server. - await call(handler, config, `/api/setup-requests/${setup.id}/cancel`, { method: "POST" }); - expect(readState(config.instance).setupRequests.find((s) => s.id === setup.id)?.status).toBe("cancelled"); - const beforeBridges = readState(config.instance).messagingBridges.length; - - const response = await rawCall( - handler, - config, - `/api/setup-requests/${setup.id}/complete`, - { - method: "POST", - body: JSON.stringify({ secrets: { name: "race-bridge", botToken: "1234:ABCDEFGHIJKL" } }) - }, - config.token - ); - // 410 Gone — the resolution-before-creation contract is upheld by - // the outer "already !pending" guard. The load-bearing invariant - // is the absence of any bridge / orphan secret on the other side. - expect(response.status).toBe(410); - - const after = readState(config.instance); - expect(after.messagingBridges.length).toBe(beforeBridges); - expect(after.setupRequests.find((s) => s.id === setup.id)?.status).toBe("cancelled"); - }); - - test("POST /api/setup-requests//complete rejects malformed messaging.add_bridge tokens BEFORE resolving the setup request", async () => { - // Token-format pre-check: addMessagingBridge runs - // assertHeaderSafeToken internally, and the chat card disappears - // once the setup request flips out of pending state. Without - // pre-resolve token validation, a malformed token would burn the - // request and the user could not retype from the same card. - // The bounded module calls assertHeaderSafeToken BEFORE resolving; - // this test pins that ordering by submitting a token with a control - // character and asserting the request stays pending. - const config = testConfig("setup-complete-bridge-bad-token-stays-pending"); - const handler = createHandler(config); - const { createSetupRequest } = await import("./state"); - const setup = await mutateState(config.instance, (state) => - createSetupRequest(state, { - action: "messaging.add_bridge", - target: "telegram", - reason: "Add a Telegram bridge", - payload: { kind: "telegram", suggestedName: "bad-token", toolCallId: "call_bridge_bad_token" } - }) - ); - const response = await call(handler, config, `/api/setup-requests/${setup.id}/complete`, { - method: "POST", - // Control character in the token — assertHeaderSafeToken - // refuses any byte outside printable ASCII [\x21-\x7E]. - body: JSON.stringify({ secrets: { name: "bad-token", botToken: "1234:abc\ndef" } }) - }); - expect(response.ok).toBe(false); - expect(typeof response.message).toBe("string"); - - const after = readState(config.instance); - expect(after.setupRequests.find((s) => s.id === setup.id)?.status).toBe("pending"); - expect(after.messagingBridges.length).toBe(0); - }); - - test("POST /api/setup-requests//complete returns ok:false when messaging.add_bridge is missing a name or token", async () => { - // The chat card disables Submit until both inputs are non-empty, - // but a CLI/API caller could POST a partial body. The gateway - // mirrors the same readiness gate as the card so a partial - // submission can't silently create a half-configured bridge. - const config = testConfig("setup-complete-bridge-missing"); - const handler = createHandler(config); - const { createSetupRequest } = await import("./state"); - const setup = await mutateState(config.instance, (state) => - createSetupRequest(state, { - action: "messaging.add_bridge", - target: "telegram", - reason: "Add a Telegram bridge", - payload: { kind: "telegram", suggestedName: "missing-fields", toolCallId: "call_bridge_missing" } - }) - ); - const missingToken = await call(handler, config, `/api/setup-requests/${setup.id}/complete`, { - method: "POST", - body: JSON.stringify({ secrets: { name: "missing-fields" } }) - }); - expect(missingToken.ok).toBe(false); - expect(missingToken.message).toContain("Bot token"); - - const missingName = await call(handler, config, `/api/setup-requests/${setup.id}/complete`, { - method: "POST", - body: JSON.stringify({ secrets: { botToken: "1234:ABCDEFGHIJ" } }) - }); - expect(missingName.ok).toBe(false); - expect(missingName.message).toContain("name"); - - // Both rejections must leave the setup request pending — otherwise - // the chat card would flip out of pending state and the user - // couldn't retry. - const after = readState(config.instance).setupRequests.find((s) => s.id === setup.id); - expect(after?.status).toBe("pending"); - }); - - test("screencast frames + input endpoints 404 for an unknown setup request", async () => { - const config = testConfig("screencast-404"); - const handler = createHandler(config); - const frames = await rawCall(handler, config, "/api/browser/screencast/nope/frames", {}, config.token); - expect(frames.status).toBe(404); - const input = await rawCall(handler, config, "/api/browser/screencast/nope/input", { - method: "POST", - body: JSON.stringify({ kind: "move", x: 1, y: 1 }) - }, config.token); - expect(input.status).toBe(404); - }); - - test("screencast frames returns 409 when no spawned browser is running", async () => { - // A real browser.connect setup exists, but there's no live spawned Chrome - // in this unit-test context, so the bridge fails to start and the endpoint - // surfaces 409 rather than opening an empty stream. - const config = testConfig("screencast-no-browser"); - const handler = createHandler(config); - const { createSetupRequest } = await import("./state"); - const setup = await mutateState(config.instance, (state) => - createSetupRequest(state, { - action: "browser.connect", - target: "https://example.com", - reason: "Sign in to continue", - payload: { toolCallId: "call_sc", signInStarted: true, screencast: true } - }) - ); - const frames = await rawCall(handler, config, `/api/browser/screencast/${setup.id}/frames`, {}, config.token); - expect(frames.status).toBe(409); - const input = await rawCall(handler, config, `/api/browser/screencast/${setup.id}/input`, { - method: "POST", - body: JSON.stringify({ kind: "move", x: 1, y: 1 }) - }, config.token); - expect(input.status).toBe(409); - }); - - test("open-browser 409s with the no-URL message when no browser is live and no page URL is recorded", async () => { - // A pending browser.connect with no live spawned Chrome AND no recorded url - // can't relaunch anything to screencast, so it 409s with the distinct - // no-URL message rather than attempting a blind navigate. - const config = testConfig("open-browser-no-url-no-browser"); - const handler = createHandler(config); - const { createSetupRequest } = await import("./state"); - const setup = await mutateState(config.instance, (state) => - createSetupRequest(state, { - action: "browser.connect", - target: "Sign in", - reason: "Sign in", - payload: { toolCallId: "call_no_url" } - }) - ); - const res = await rawCall(handler, config, `/api/setup-requests/${setup.id}/open-browser`, { method: "POST" }, config.token); - expect(res.status).toBe(409); - const body = await res.json(); - expect(body.error).toContain("no page URL is recorded"); - // The row stays pending so a later retry (once a browser is live) works. - expect(readState(config.instance).setupRequests.find((s) => s.id === setup.id)?.status).toBe("pending"); - }); - - test("open-browser attempts a headless relaunch+navigate when a URL is recorded but no browser is live", async () => { - // Restart/crash/disconnect drops the in-process spawned handle while the - // durable card survives. With a recorded url, open-browser relaunches the - // headless Chrome and navigates to it (the spawn-only replacement for the - // removed managed relaunch) instead of stranding the user. We STUB the spawn - // launcher to throw so the relaunch fails deterministically (no dependence - // on ambient "is real Chrome installed?"), proving the recovery path was - // taken and surfaces a 409 with the row left pending for a later real retry. - const config = testConfig("open-browser-relaunch-attempt"); - const handler = createHandler(config); - const { createSetupRequest } = await import("./state"); - const browserMod = await import("./tools/browser"); - const browserTest = browserMod.__test; - browserMod.setBrowserInstance(config.instance); - browserTest.setSpawnChromeForTest(async () => { - throw new Error("stubbed: no Chrome in this unit test"); - }); - try { - const setup = await mutateState(config.instance, (state) => - createSetupRequest(state, { - action: "browser.connect", - target: "Sign in", - reason: "Sign in", - payload: { toolCallId: "call_relaunch", url: "https://example.com/login" } - }) - ); - const res = await rawCall(handler, config, `/api/setup-requests/${setup.id}/open-browser`, { method: "POST" }, config.token); - expect(res.status).toBe(409); - const body = await res.json(); - // Distinct from the no-URL message: this 409 came from the relaunch attempt. - expect(body.error).not.toContain("no page URL is recorded"); - expect(readState(config.instance).setupRequests.find((s) => s.id === setup.id)?.status).toBe("pending"); - } finally { - browserTest.setSpawnChromeForTest(null); - browserTest.uninstallFakeBrowserForTest(); - browserTest.resetBrowserInstanceForTest(); - } - }); - - test("open-browser refuses (no relaunch/navigate) when the user's Chrome is attached over CDP", async () => { - // A stale Connect card racing a later cdp attach: the screencast streams the - // SPAWNED Chrome, but the user is on cdp. /open-browser must NOT relaunch + - // navigate the user's external Chrome to the recorded URL — it refuses with - // the cdp-specific 409 BEFORE any navigation. (The dispatch guard stops NEW - // cards on cdp; this covers an already-minted card hitting /open-browser.) - const config = testConfig("open-browser-cdp-refuse"); - const handler = createHandler(config); - const { createSetupRequest, now } = await import("./state"); - const setup = await mutateState(config.instance, (state) => { - // Active cdp transport + a stale card carrying a recorded URL. - state.browser = { mode: "cdp", cdpUrl: "ws://127.0.0.1:9222/devtools/browser/abc", startedAt: now() }; - return createSetupRequest(state, { - action: "browser.connect", - target: "Sign in", - reason: "Sign in", - payload: { toolCallId: "call_cdp_stale", url: "https://example.com/login" } - }); - }); - const res = await rawCall(handler, config, `/api/setup-requests/${setup.id}/open-browser`, { method: "POST" }, config.token); - expect(res.status).toBe(409); - const body = await res.json(); - expect(String(body.error)).toContain("attached over CDP"); - // The card was NOT stamped as a live screencast (no navigation happened). - const after = readState(config.instance).setupRequests.find((s) => s.id === setup.id); - expect(after?.status).toBe("pending"); - expect(after?.payload.signInStarted).toBeUndefined(); - expect(after?.payload.screencast).toBeUndefined(); - }); - - test("frames reconnect on an already-stamped card relaunches the browser when none is live", async () => { - // After a gateway/Chrome restart, an already-stamped sign-in card (screencast - // + signInStarted, still pending) skips /open-browser — the modal reconnects - // straight to /frames. So /frames must itself attempt the headless relaunch - // when no browser is live and a URL is recorded, or the user is stuck on a - // permanent 409. In this unit context there's no real Chrome, so the relaunch - // fails fast and 409s — but via the relaunch path (distinct from the no-URL - // message), proving the reconnect recovery is wired through /frames. - const config = testConfig("frames-reconnect-relaunch"); - const handler = createHandler(config); - const { createSetupRequest } = await import("./state"); - const setup = await mutateState(config.instance, (state) => - createSetupRequest(state, { - action: "browser.connect", - target: "Sign in", - reason: "Sign in", - // Already stamped (the pre-restart open-browser ran); URL recorded. - payload: { toolCallId: "call_frames_relaunch", url: "https://example.com/login", signInStarted: true, screencast: true } - }) - ); - const res = await rawCall(handler, config, `/api/browser/screencast/${setup.id}/frames`, {}, config.token); - expect(res.status).toBe(409); - const body = await res.json(); - expect(body.error).not.toContain("no page URL is recorded"); - // Row stays pending so a real retry (with a live browser) can recover. - expect(readState(config.instance).setupRequests.find((s) => s.id === setup.id)?.status).toBe("pending"); - }); - - test("frames reconnect on a stamped card with NO recorded URL 409s with the no-URL message", async () => { - // The relaunch needs a target page; without a recorded URL there's nothing - // to navigate to, so /frames 409s with the distinct no-URL message rather - // than blindly relaunching. - const config = testConfig("frames-reconnect-no-url"); - const handler = createHandler(config); - const { createSetupRequest } = await import("./state"); - const setup = await mutateState(config.instance, (state) => - createSetupRequest(state, { - action: "browser.connect", - target: "Sign in", - reason: "Sign in", - payload: { toolCallId: "call_frames_no_url", signInStarted: true, screencast: true } - }) - ); - const res = await rawCall(handler, config, `/api/browser/screencast/${setup.id}/frames`, {}, config.token); - expect(res.status).toBe(409); - const body = await res.json(); - expect(body.error).toContain("no page URL is recorded"); - }); - - test("screencast endpoints reject a setup that isn't an active sign-in (lifecycle gate)", async () => { - // A browser.connect setup that is NOT pending, OR not stamped screencast + - // signInStarted, must be refused so a stale EventSource reconnect after - // complete/cancel can't rebuild a live drive channel. - const config = testConfig("screencast-lifecycle"); - const handler = createHandler(config); - const { createSetupRequest } = await import("./state"); - // Pending, but never went through open-browser (no screencast/signInStarted). - const notStarted = await mutateState(config.instance, (state) => - createSetupRequest(state, { - action: "browser.connect", - target: "https://example.com", - reason: "Sign in", - payload: { toolCallId: "call_ls" } - }) - ); - const r1 = await rawCall(handler, config, `/api/browser/screencast/${notStarted.id}/frames`, {}, config.token); - expect(r1.status).toBe(404); - // Stamped screencast but cancelled — no longer pending. - const cancelled = await mutateState(config.instance, (state) => { - const s = createSetupRequest(state, { - action: "browser.connect", - target: "https://example.com", - reason: "Sign in", - payload: { toolCallId: "call_ls2", signInStarted: true, screencast: true } - }); - const row = state.setupRequests.find((x) => x.id === s.id); - if (row) row.status = "cancelled"; - return s; - }); - const r2 = await rawCall(handler, config, `/api/browser/screencast/${cancelled.id}/input`, { - method: "POST", - body: JSON.stringify({ kind: "move", x: 1, y: 1 }) - }, config.token); - expect(r2.status).toBe(404); - }); - - test("open-browser refuses a row cancelled before the stamp mutation (in-mutation status recheck)", async () => { - // /open-browser checks pending on a pre-read, then stamps screencast + - // signInStarted inside mutateState. A /cancel that commits in that window - // must not get a live sign-in stamped on top: the in-mutation status - // recheck returns 410 and leaves the cancelled row untouched. - const config = testConfig("open-browser-raced-cancel"); - const handler = createHandler(config); - const { createSetupRequest } = await import("./state"); - const { __test: browserTest } = await import("./tools/browser"); - // A live spawned handle so getScreencastPort is non-null (we get past the - // 409 no-browser gate and reach the stamp mutation). - browserTest.installFakeSpawnedHandleForTest(9333, { - close: async () => undefined, - pages: () => [], - browser: () => ({ isConnected: () => true }) - }); - try { - const setup = await mutateState(config.instance, (state) => - createSetupRequest(state, { - action: "browser.connect", - target: "https://example.com", - reason: "Sign in to continue", - payload: { toolCallId: "call_race" } - }) - ); - // Cancel the row first — this is the racer that wins. - await mutateState(config.instance, (state) => { - const row = state.setupRequests.find((s) => s.id === setup.id); - if (row) row.status = "cancelled"; - }); - const res = await rawCall(handler, config, `/api/setup-requests/${setup.id}/open-browser`, { method: "POST" }, config.token); - // The pre-read pending gate already 410s here; the in-mutation recheck is - // the backstop for a cancel that lands AFTER that gate. Either way the - // contract is: no live sign-in stamped on a terminal row. - expect(res.status).toBe(410); - const after = readState(config.instance).setupRequests.find((s) => s.id === setup.id); - expect(after?.status).toBe("cancelled"); - expect(after?.payload.signInStarted).toBeUndefined(); - expect(after?.payload.screencast).toBeUndefined(); - } finally { - browserTest.uninstallFakeBrowserForTest(); - } - }); - - test("browser.connect /complete claims the row BEFORE writing the audit row (no duplicate on double-submit)", async () => { - // The non-screencast fallback must resolve (claim pending->completed) before - // writing the rich browser.connect audit row. A second /complete loses the - // claim (ApprovalRaceLostError) and writes ZERO side effects, so exactly one - // browser.connect audit row exists no matter how many completes race. - const config = testConfig("browser-connect-complete-claim-first"); - const handler = createHandler(config); - const { createSetupRequest } = await import("./state"); - const setup = await mutateState(config.instance, (state) => - createSetupRequest(state, { - action: "browser.connect", - target: "Sign in to the store", - reason: "Sign in to the store", - // No screencast marker → the non-screencast fallback path. - payload: { toolCallId: "call_complete", reason: "Sign in to the store" } - }) - ); - const first = await rawCall(handler, config, `/api/setup-requests/${setup.id}/complete`, { method: "POST" }, config.token); - expect(first.status).toBe(200); - // Second complete on the now-terminal row loses the claim. - const second = await rawCall(handler, config, `/api/setup-requests/${setup.id}/complete`, { method: "POST" }, config.token); - expect(second.status).toBeGreaterThanOrEqual(400); - const browserConnectAudits = readState(config.instance).audit.filter((a) => a.action === "browser.connect"); - expect(browserConnectAudits).toHaveLength(1); - }); - - test("open-browser is idempotent: a second open on an already-stamped row writes no duplicate audit", async () => { - // /open-browser keeps the row pending, so two opens (double-click / retry) - // both pass the pending check. The in-mutation already-stamped guard makes - // the second a no-op so exactly one stage:open-browser audit row exists. - const config = testConfig("open-browser-idempotent"); - const handler = createHandler(config); - const { createSetupRequest } = await import("./state"); - const { __test: browserTest } = await import("./tools/browser"); - browserTest.installFakeSpawnedHandleForTest(9333, { - close: async () => undefined, - pages: () => [], - browser: () => ({ isConnected: () => true }) - }); - try { - const setup = await mutateState(config.instance, (state) => - createSetupRequest(state, { - action: "browser.connect", - target: "https://example.com", - reason: "Sign in to continue", - payload: { toolCallId: "call_idem", url: "https://example.com/login" } - }) - ); - const first = await rawCall(handler, config, `/api/setup-requests/${setup.id}/open-browser`, { method: "POST" }, config.token); - expect(first.status).toBe(200); - const second = await rawCall(handler, config, `/api/setup-requests/${setup.id}/open-browser`, { method: "POST" }, config.token); - // The second open succeeds (idempotent) but writes no extra audit/trace. - expect(second.status).toBe(200); - const openAudits = readState(config.instance).audit.filter( - (a) => a.action === "browser.connect" && (a.evidence as Record | undefined)?.stage === "open-browser" - ); - expect(openAudits).toHaveLength(1); - } finally { - browserTest.uninstallFakeBrowserForTest(); - } - }); - - test("screencast frames stream a frame and input dispatches with a live bridge", async () => { - // HTTP success path: install a fake bridge (no real Chrome) so the SSE - // envelope + input dispatch wiring is exercised end to end at the gateway. - const config = testConfig("screencast-success"); - const handler = createHandler(config); - const { createSetupRequest } = await import("./state"); - const sc = await import("./execution/browser-screencast"); - const setup = await mutateState(config.instance, (state) => - createSetupRequest(state, { - action: "browser.connect", - target: "https://example.com", - reason: "Sign in", - payload: { toolCallId: "call_ok", signInStarted: true, screencast: true } - }) - ); - // A minimal fake bridge: replays one frame to each subscriber and records - // dispatched input. Installed as the live activeBridge so the no-factory - // getOrStartBridge inside http.ts reuses it. - const dispatched: unknown[] = []; - const fakeBridge = { - isClosed: () => false, - subscribe( - onFrame: (f: { data: string; meta: Record }) => void, - _onClose?: () => void, - onUrl?: (url: string) => void - ) { - onUrl?.("https://signin.example.com/login"); - onFrame({ data: "QUJD", meta: { deviceWidth: 800 } }); - return () => undefined; - }, - dispatchInput: async (m: { kind?: string }) => { - dispatched.push(m); - // Mirror the real bridge: selection-causing kinds return a selection. - return m.kind === "copy" ? { selection: "picked text" } : {}; - }, - start: async () => undefined, - stop: async () => undefined - }; - sc.__setActiveBridgeForTest(fakeBridge as never, setup.id); - try { - const framesRes = await handler( - new Request(`http://127.0.0.1:${config.port}/api/browser/screencast/${setup.id}/frames`, { - headers: { authorization: `Bearer ${config.token}` } - }) - ); - expect(framesRes.status).toBe(200); - expect(framesRes.headers.get("content-type")).toContain("text/event-stream"); - const reader = framesRes.body!.getReader(); - // onUrl and onFrame enqueue separate chunks; read both. - const decoder = new TextDecoder(); - const first = decoder.decode((await reader.read()).value); - const second = decoder.decode((await reader.read()).value); - const text = first + second; - // The gateway-sourced origin is streamed as an `event: url` so the modal - // can show the operator which site they're signing into. - expect(text).toContain("event: url"); - expect(text).toContain("https://signin.example.com/login"); - expect(text).toContain("event: frame"); - expect(text).toContain("QUJD"); - await reader.cancel(); - - const inputRes = await call(handler, config, `/api/browser/screencast/${setup.id}/input`, { - method: "POST", - body: JSON.stringify({ kind: "click", x: 10, y: 20, clickCount: 1 }) - }); - expect(inputRes.ok).toBe(true); - expect(dispatched).toEqual([{ kind: "click", x: 10, y: 20, clickCount: 1 }]); - - // A copy relays the remote selection back in the response body so the - // modal can serve it to the operator's clipboard. - const copyRes = await call(handler, config, `/api/browser/screencast/${setup.id}/input`, { - method: "POST", - body: JSON.stringify({ kind: "copy" }) - }); - expect(copyRes.ok).toBe(true); - expect(copyRes.selection).toBe("picked text"); - } finally { - sc.__resetActiveBridgeForTest(); - } - }); - - test("the frames SSE stream closes when the bridge dies (no dangling keepalives)", async () => { - const config = testConfig("screencast-frames-close-on-death"); - const handler = createHandler(config); - const { createSetupRequest } = await import("./state"); - const sc = await import("./execution/browser-screencast"); - const setup = await mutateState(config.instance, (state) => - createSetupRequest(state, { - action: "browser.connect", - target: "https://example.com", - reason: "Sign in", - payload: { toolCallId: "call_close", signInStarted: true, screencast: true } - }) - ); - // A fake bridge that captures the onClose callback so the test can fire it, - // simulating the CDP socket dropping. - let fireClose: (() => void) | undefined; - const fakeBridge = { - isClosed: () => false, - subscribe(onFrame: (f: { data: string; meta: Record }) => void, onClose?: () => void) { - onFrame({ data: "QUJD", meta: { deviceWidth: 800 } }); - fireClose = onClose; - return () => undefined; - }, - dispatchInput: async () => undefined, - start: async () => undefined, - stop: async () => undefined - }; - sc.__setActiveBridgeForTest(fakeBridge as never, setup.id); - try { - const framesRes = await handler( - new Request(`http://127.0.0.1:${config.port}/api/browser/screencast/${setup.id}/frames`, { - headers: { authorization: `Bearer ${config.token}` } - }) - ); - expect(framesRes.status).toBe(200); - const reader = framesRes.body!.getReader(); - await reader.read(); // first frame - expect(fireClose).toBeDefined(); - fireClose!(); // bridge dies → route should close the stream - const next = await reader.read(); - expect(next.done).toBe(true); // stream ended, not dangling on keepalives - } finally { - sc.__resetActiveBridgeForTest(); - } - }); - - test("completing a screencast browser.connect resolves the setup without a managed relaunch", async () => { - // The screencast path keeps the agent on its headless Chrome the whole - // time, so /complete just stops the bridge (a no-op here, no live bridge) - // and resolves the setup — no connectBrowser relaunch. - const config = testConfig("screencast-complete"); - const handler = createHandler(config); - const { createSetupRequest } = await import("./state"); - const setup = await mutateState(config.instance, (state) => - createSetupRequest(state, { - action: "browser.connect", - target: "https://example.com", - reason: "Sign in", - payload: { toolCallId: "call_sc3", signInStarted: true, screencast: true } - }) - ); - const res = await call(handler, config, `/api/setup-requests/${setup.id}/complete`, { - method: "POST", - body: JSON.stringify({}) - }); - expect(res.ok).toBe(true); - expect(readState(config.instance).setupRequests.find((s) => s.id === setup.id)?.status).toBe("completed"); - }); - - test("cancelling a screencast browser.connect stops the bridge and cancels the setup", async () => { - const config = testConfig("screencast-cancel"); - const handler = createHandler(config); - const { createSetupRequest } = await import("./state"); - const setup = await mutateState(config.instance, (state) => - createSetupRequest(state, { - action: "browser.connect", - target: "https://example.com", - reason: "Sign in", - payload: { toolCallId: "call_sc4", signInStarted: true, screencast: true } - }) - ); - await call(handler, config, `/api/setup-requests/${setup.id}/cancel`, { method: "POST" }); - expect(readState(config.instance).setupRequests.find((s) => s.id === setup.id)?.status).toBe("cancelled"); - }); - - test("cancel stops the owner's bridge unconditionally, even if the row isn't yet stamped screencast", async () => { - // /cancel must not gate the bridge teardown on a pre-claim screencast read: - // payload.screencast can flip true (via /open-browser) in the window between - // that read and the atomic claim. Install a live bridge owned by the setup - // on a row that is NOT yet stamped screencast, cancel it, and assert the - // bridge is torn down anyway — the unconditional owner-scoped stop. - const config = testConfig("screencast-cancel-unstamped"); - const handler = createHandler(config); - const { createSetupRequest } = await import("./state"); - const sc = await import("./execution/browser-screencast"); - const setup = await mutateState(config.instance, (state) => - createSetupRequest(state, { - action: "browser.connect", - target: "https://example.com", - reason: "Sign in", - // Deliberately NOT stamped screencast/signInStarted yet. - payload: { toolCallId: "call_sc_unstamped" } - }) - ); - let stopped = false; - const fakeBridge = { - isClosed: () => false, - stop: async () => { - stopped = true; - } - } as unknown as import("./execution/browser-screencast").ScreencastBridge; - sc.__setActiveBridgeForTest(fakeBridge, setup.id); - try { - await call(handler, config, `/api/setup-requests/${setup.id}/cancel`, { method: "POST" }); - expect(readState(config.instance).setupRequests.find((s) => s.id === setup.id)?.status).toBe("cancelled"); - expect(stopped).toBe(true); - } finally { - await sc.stopActiveBridge(); - } - }); - - test("a frames request after a screencast complete is rejected (no bridge recreation)", async () => { - // /complete marks the setup terminal BEFORE stopping the bridge, so a - // frames/input request racing the completion sees status!=="pending" and - // 404s instead of recreating an orphaned bridge in the teardown gap. - const config = testConfig("screencast-complete-then-frames"); - const handler = createHandler(config); - const { createSetupRequest } = await import("./state"); - const setup = await mutateState(config.instance, (state) => - createSetupRequest(state, { - action: "browser.connect", - target: "https://example.com", - reason: "Sign in", - payload: { toolCallId: "call_sc_race", signInStarted: true, screencast: true } - }) - ); - const done = await call(handler, config, `/api/setup-requests/${setup.id}/complete`, { - method: "POST", - body: JSON.stringify({}) - }); - expect(done.ok).toBe(true); - const framesRes = await rawCall( - handler, - config, - `/api/browser/screencast/${setup.id}/frames`, - { method: "GET" }, - config.token - ); - expect(framesRes.status).toBe(404); - }); - - test("screencast input rejects a malformed JSON body with 400", async () => { - const config = testConfig("screencast-bad-body"); - const handler = createHandler(config); - const { createSetupRequest } = await import("./state"); - const setup = await mutateState(config.instance, (state) => - createSetupRequest(state, { - action: "browser.connect", - target: "https://example.com", - reason: "Sign in", - payload: { toolCallId: "call_sc2", signInStarted: true, screencast: true } - }) - ); - const res = await rawCall(handler, config, `/api/browser/screencast/${setup.id}/input`, { - method: "POST", - body: "{not json" - }, config.token); - expect(res.status).toBe(400); - }); - - test("POST /api/setup-requests//complete refuses a code-less messaging.approve_pairing approve and keeps the request pending", async () => { - // allowChat's pending-row presence check is gated on `expectedCode` - // being defined (the legacy CLI's "operator knows what they're - // doing" trust model). If a chat-card pairing payload arrives - // without verificationCode (group chat: groups intentionally never - // mint a code, or a stale request whose pending row was cleared and - // recreated), a no-code allowChat call would bypass the pending-row - // check and enroll a chat that is no longer pending. Pin that - // messaging-pairing-connect refuses the approve branch up-front when - // verificationCode is missing. - const config = testConfig("setup-complete-pairing-codeless-refuses"); - const handler = createHandler(config); - const { createSetupRequest } = await import("./state"); - const setup = await mutateState(config.instance, (state) => - createSetupRequest(state, { - action: "messaging.approve_pairing", - target: "bridge_codeless:7", - reason: "Confirm pairing", - // Deliberately omit verificationCode — the chat card normally - // carries one for private chats, but a stale or group-chat - // payload would not. - payload: { bridgeId: "bridge_codeless", chatId: 7, toolCallId: "call_codeless" } - }) - ); - - const response = await call(handler, config, `/api/setup-requests/${setup.id}/complete`, { - method: "POST", - body: JSON.stringify({}) - }); - expect(response.ok).toBe(false); - expect(response.message).toContain("code-less"); - const after = readState(config.instance); - expect(after.setupRequests.find((s) => s.id === setup.id)?.status).toBe("pending"); - }); - - test("POST /api/setup-requests//complete removes a messaging bridge through the setup-request flow", async () => { - // Happy path for the chat-side Remove bridge card. The /complete - // handler delegates to runMessagingRemoveConnect, which resolves - // the setup request atomically then calls removeMessagingBridge. - const config = testConfig("setup-complete-remove-bridge-happy"); - const handler = createHandler(config); - - // Create a real bridge via the existing endpoint so its - // encrypted secret + state record exist before we try to remove it. - const created = await call(handler, config, `/api/messaging`, { - method: "POST", - body: JSON.stringify({ name: "remove-me", kind: "telegram", botToken: "1234:ABCDEFGHIJKLMNOPQR" }) - }); - expect(created.id).toBeString(); - - const { createSetupRequest } = await import("./state"); - const setup = await mutateState(config.instance, (state) => - createSetupRequest(state, { - action: "messaging.remove_bridge", - target: created.id, - reason: "Remove bridge", - payload: { bridgeId: created.id, bridgeName: "remove-me", kind: "telegram", toolCallId: "call_remove" } - }) - ); - - const response = await call(handler, config, `/api/setup-requests/${setup.id}/complete`, { - method: "POST", - body: JSON.stringify({}) - }); - expect(response.ok).toBe(true); - expect(response.removed).toBe(true); - expect(response.bridgeId).toBe(created.id); - - const after = readState(config.instance); - expect(after.setupRequests.find((s) => s.id === setup.id)?.status).toBe("completed"); - expect(after.messagingBridges.find((b) => b.id === created.id)).toBeUndefined(); - - // Chat-card lineage audit row pin: the chat-card remove path - // writes a dedicated audit row carrying the setup-request id + - // bridgeId, so a chat-card remove is distinguishable from a CLI / - // settings remove in the activity feed. - const chatRemoveRow = after.audit.find( - (e) => e.action === "messaging.remove_bridge" && e.approvalId === setup.id - ); - expect(chatRemoveRow).toBeDefined(); - expect(chatRemoveRow?.target).toBe(created.id); - expect((chatRemoveRow?.evidence as { bridgeName?: string } | undefined)?.bridgeName).toBe("remove-me"); - }); - - test("POST /api/setup-requests//complete refuses partial browser.fill_secret submissions", async () => { - // fillReady in BlockSetupRequested.tsx only disables the web - // Submit button; CLI / mobile / direct API clients can still POST a - // partial body. The gateway must enforce that every declared slot - // has a non-empty value before any DOM fill happens — otherwise - // /complete would resolve with some slots silently unfilled and the - // agent would be told (in agent.ts:runApprovedAction) that every - // declared slot was filled. - const config = testConfig("complete-rejects-partial-fill-secret"); - const handler = createHandler(config); - const { createSetupRequest } = await import("./state"); - const taskId = await mutateState(config.instance, (state) => { - const { createTask, upsertTask } = require("./state") as typeof import("./state"); - const task = createTask(state.instance, "partial-test"); - upsertTask(state, task); - return task.id; - }); - // Seed approvedUrl so the origin guard's "no live page" refusal - // doesn't fire before the missing-slot check; this test is about - // partial submission, not origin binding. - const approval = await mutateState(config.instance, (state) => - createSetupRequest(state, { - taskId, - action: "browser.fill_secret", - target: "https://example.com", - reason: "Sign in to the test site", - payload: { - slots: [ - { name: "username", locator: "@e1", label: "Username", kind: "text" }, - { name: "password", locator: "@e2", label: "Password", kind: "password" } - ], - reason: "Sign in", - toolCallId: "call_fill", - // Origin only — sanitizeUrlForAuditTarget strips pathname. - approvedUrl: "https://example.com" - } - }) - ); - const { __test: browserTest } = await import("./tools/browser"); - browserTest.installFakeSessionWithPageForTest(taskId, { - // The live URL can be on any path within the approved origin; - // the equality check is on origin only after the SEC-C fix. - url: () => "https://example.com/login", - close: () => Promise.resolve() - } as Partial); - const response = await rawCall( - handler, - config, - `/api/setup-requests/${approval.id}/complete`, - { - method: "POST", - body: JSON.stringify({ secrets: { username: "tomsmith" } }) - }, - config.token - ); - expect(response.status).toBe(400); - const body = await response.json(); - expect(body.ok).toBe(false); - expect(body.message).toContain("password"); - expect(body.message).toContain("Missing"); - const after = readState(config.instance).setupRequests.find((a) => a.id === approval.id); - expect(after?.status).toBe("pending"); - }); - - test("POST /api/setup-requests//complete: submitted fill_secret values never appear in state.json, trace JSONL, or runtime.jsonl", async () => { - // End-to-end absence pin for the ADR's secret-handling guarantee: - // submitted credential values must flow request-scope only and - // never reach any persisted artifact. Without this test the only - // protection is manual code review of every audit/trace/log write - // touching the fill_secret path. Distinct marker strings let us - // grep the raw bytes after the request — partial matches would - // catch even an attempt to serialize a wrapper object containing - // the value. - const config = testConfig("fill-secret-no-state-leak"); - const handler = createHandler(config); - const { createTask, upsertTask, createSetupRequest } = await import("./state"); - const taskId = await mutateState(config.instance, (state) => { - const task = createTask(state.instance, "fill secret leak guard"); - upsertTask(state, task); - return task.id; - }); - // Seed approvedUrl on the payload AND install a matching fake - // session so the origin guard passes and the fill loop actually - // runs. The fills will error per-slot because the fake page's - // .locator() returns nothing useful — what we care about is that - // the audit row is written with redacted: true and the markers - // never reach state/trace/log even when the runtime tries to - // record what happened. - const approval = await mutateState(config.instance, (state) => - createSetupRequest(state, { - taskId, - action: "browser.fill_secret", - target: "https://example.com", - reason: "Sign in to the test site", - payload: { - slots: [ - { name: "username", locator: "@e1", label: "Username", kind: "text" }, - { name: "password", locator: "@e2", label: "Password", kind: "password" } - ], - reason: "Sign in", - toolCallId: "call_fill", - approvedUrl: "https://example.com" - } - }) - ); - const { __test: browserTest } = await import("./tools/browser"); - browserTest.installFakeSessionWithPageForTest(taskId, { - url: () => "https://example.com/login", - // Fake locator that no-ops on fill; the audit-row write still - // happens regardless of whether the fill succeeded. Cast as - // Partial since the fake only implements what - // browserFillByLocator touches. - locator: ((_sel: string) => ({ - fill: async () => { throw new Error("fake session, no real DOM"); } - })) as unknown as import("playwright-core").Page["locator"], - close: () => Promise.resolve() - } as Partial); - const USERNAME_MARKER = "tomsmith-LEAK-MARKER-zzzzz"; - const PASSWORD_MARKER = "SuperSecretPassword-LEAK-MARKER-zzzzz"; - await rawCall( - handler, - config, - `/api/setup-requests/${approval.id}/complete`, - { - method: "POST", - body: JSON.stringify({ secrets: { username: USERNAME_MARKER, password: PASSWORD_MARKER } }) - }, - config.token - ); - // No browser session exists, so browserFillByLocator returns - // errors for both slots and the audit row evidence carries - // `errors[]` (no values) + filledSlots = []. The approval is - // still resolved atomically before the fill loop so the deny - // race is closed; the agent gets a partial-fill error result - // via resumeChatTask. - - // Raw state.json bytes must not contain either marker. - const stateJsonPath = `${config.stateRoot}/state.json`; - const rawState = readFileSync(stateJsonPath, "utf8"); - expect(rawState).not.toContain(USERNAME_MARKER); - expect(rawState).not.toContain(PASSWORD_MARKER); - - // Trace JSONL (per-task) must not contain either marker. The - // file may not exist if no trace events fired for this task — - // an empty file is fine, the test only fails on a leak. - const traceJsonlPath = `${config.stateRoot}/traces/${taskId}.jsonl`; - if (existsSync(traceJsonlPath)) { - const rawTrace = readFileSync(traceJsonlPath, "utf8"); - expect(rawTrace).not.toContain(USERNAME_MARKER); - expect(rawTrace).not.toContain(PASSWORD_MARKER); - } - - // runtime.jsonl is the cross-task log file — also greppable. - const runtimeLogPath = `${config.logRoot}/runtime.jsonl`; - if (existsSync(runtimeLogPath)) { - const rawLog = readFileSync(runtimeLogPath, "utf8"); - expect(rawLog).not.toContain(USERNAME_MARKER); - expect(rawLog).not.toContain(PASSWORD_MARKER); - } - - // The audit row itself: defense-in-depth. Both `evidence` (would - // be undefined after redaction) and `target` must not contain - // the markers. `target` is preserved across redaction; this pin - // catches a future regression that would forget to sanitize URL - // query strings or stuff secrets into the target field. - const auditRows = readState(config.instance).audit.filter( - (a) => a.action === "browser.fill_secret" && a.approvalId === approval.id - ); - expect(auditRows.length).toBe(1); - const row = auditRows[0]!; - expect(row.redacted).toBe(true); - expect(row.evidence).toBeUndefined(); - expect(row.target ?? "").not.toContain(USERNAME_MARKER); - expect(row.target ?? "").not.toContain(PASSWORD_MARKER); - }); - - test("POST /api/setup-requests//complete refuses fill_secret when page navigated away from approved origin", async () => { - // The approval.target encodes the origin the user consented - // to fill into (protocol+host+port; pathname is stripped by - // sanitizeUrlForAuditTarget). If the page has navigated to a - // different origin (agent action, user click, JS redirect, - // phishing redirect) between approval creation and Submit, - // the live URL no longer matches and we refuse with 409 so a - // fresh approval is required for the new destination. In - // this test the browser session was never opened so - // peekCurrentBrowserUrl returns undefined, - // which the handler treats as "no live page to fill" — same - // refusal path. - const config = testConfig("complete-fill-secret-origin-mismatch"); - const handler = createHandler(config); - const { createTask, upsertTask, createSetupRequest } = await import("./state"); - const taskId = await mutateState(config.instance, (state) => { - const task = createTask(state.instance, "test"); - upsertTask(state, task); - return task.id; - }); - const approval = await mutateState(config.instance, (state) => - createSetupRequest(state, { - taskId, - action: "browser.fill_secret", - target: "https://example.com", - reason: "Sign in", - payload: { - slots: [ - { name: "username", locator: "@e1", label: "Username", kind: "text" }, - { name: "password", locator: "@e2", label: "Password", kind: "password" } - ], - reason: "Sign in", - toolCallId: "call_fill", - // The /connect origin guard reads from the structural - // approvedUrl on payload — peer approval actions carry - // their contract fields under payload too. Stored as - // origin only (no pathname) since reset/magic-link - // URLs can carry tokens in the path. - approvedUrl: "https://example.com" - } - }) - ); - const response = await rawCall( - handler, - config, - `/api/setup-requests/${approval.id}/complete`, - { - method: "POST", - body: JSON.stringify({ secrets: { username: "tomsmith", password: "SuperSecretPassword!" } }) - }, - config.token - ); - expect(response.status).toBe(409); - const body = await response.json(); - expect(body.ok).toBe(false); - // The browser session was never opened, so peekCurrentBrowserUrl - // returns undefined and the /complete handler takes the - // "session expired" branch (distinct from the "page navigated" - // branch where a live session exists but its URL differs from - // approvedUrl). Without that split the operator would see - // "page navigated" after a 5-minute walk-away — misleading. - expect(body.message).toContain("Browser session expired"); - expect(body.message).toContain("https://example.com"); - // Approval stayed pending — no resolveApproval call ran. - const after = readState(config.instance).setupRequests.find((a) => a.id === approval.id); - expect(after?.status).toBe("pending"); - }); - - test("POST /api/setup-requests//complete refuses sub-floor password-kind slot values", async () => { - // The snapshot post-redactor uses literal substring replacement; - // single-character (and other very short) values would shred - // structural tokens like [@e1] in snapshot text. The 4-char - // floor in src/tools/browser.ts:recordFilledSecret keeps the - // redactor safe. For a password-kind slot a sub-floor value is - // both a near-certain typo AND an un-redactable leak risk, so - // /connect refuses it (the registry-skip-for-short-values would - // otherwise let the value escape via a later unredacted tool - // result). Non-password slots take the opposite path — see the - // short-PII test below. - const config = testConfig("complete-fill-secret-too-short"); - const handler = createHandler(config); - const { createTask, upsertTask, createSetupRequest } = await import("./state"); - const taskId = await mutateState(config.instance, (state) => { - const task = createTask(state.instance, "short value test"); - upsertTask(state, task); - return task.id; - }); - const approval = await mutateState(config.instance, (state) => - createSetupRequest(state, { - taskId, - action: "browser.fill_secret", - target: "https://example.com", - reason: "Sign in", - payload: { - slots: [ - { name: "pin", locator: "@e1", label: "PIN", kind: "password" } - ], - reason: "Sign in", - toolCallId: "call_fill", - approvedUrl: "https://example.com" - } - }) - ); - const { __test: browserTest } = await import("./tools/browser"); - browserTest.installFakeSessionWithPageForTest(taskId, { - url: () => "https://example.com", - close: () => Promise.resolve() - } as Partial); - const response = await rawCall( - handler, - config, - `/api/setup-requests/${approval.id}/complete`, - { - method: "POST", - body: JSON.stringify({ secrets: { pin: "12" } }) - }, - config.token - ); - expect(response.status).toBe(400); - const body = await response.json(); - expect(body.ok).toBe(false); - expect(body.message).toContain("too short"); - expect(body.message).toContain("pin"); - const after = readState(config.instance).setupRequests.find((a) => a.id === approval.id); - expect(after?.status).toBe("pending"); - }); - - test("POST /api/setup-requests//complete accepts a sub-floor non-password (PII) slot value", async () => { - // fill_secret also collects identity/PII fields — a real call - // asks for date of birth + last name. Short last names ("Shi", - // "Ng", "Li") are valid and must fill. The redaction floor is a - // redactor-safety constraint, not an input-validation gate, so a - // text-kind slot below the floor is accepted and filled (it is - // simply not redaction-registered, which is fine for a non- - // credential). Pin the boundary so the floor never silently - // re-broadens to block PII again. - const config = testConfig("complete-fill-secret-short-pii"); - const handler = createHandler(config); - const { createTask, upsertTask, createSetupRequest } = await import("./state"); - const taskId = await mutateState(config.instance, (state) => { - const task = createTask(state.instance, "short PII test"); - upsertTask(state, task); - return task.id; - }); - const approval = await mutateState(config.instance, (state) => - createSetupRequest(state, { - taskId, - action: "browser.fill_secret", - target: "https://example.com", - reason: "Look up account", - payload: { - slots: [ - { name: "lastname", locator: "@e43", label: "Last Name", kind: "text" } - ], - reason: "Look up account", - toolCallId: "call_fill", - approvedUrl: "https://example.com" - } - }) - ); - const filled: Array<{ locator: string; value: string }> = []; - const { __test: browserTest } = await import("./tools/browser"); - browserTest.installFakeSessionWithPageForTest(taskId, { - url: () => "https://example.com", - close: () => Promise.resolve(), - // browserFillByLocator resolves an @-ref to a literal - // [data-gini-ref] selector, then calls page.locator(sel).fill(). - locator: (selector: string) => ({ - fill: (value: string) => { - filled.push({ locator: selector, value }); - return Promise.resolve(); - }, - evaluate: () => Promise.resolve() - }) - } as unknown as Partial); - const response = await rawCall( - handler, - config, - `/api/setup-requests/${approval.id}/complete`, - { - method: "POST", - body: JSON.stringify({ secrets: { lastname: "Shi" } }) - }, - config.token - ); - expect(response.status).toBe(200); - const body = await response.json(); - expect(body.ok).toBe(true); - expect(body.filledSlots).toEqual(["lastname"]); - expect(filled).toEqual([{ locator: '[data-gini-ref="e43"]', value: "Shi" }]); - const after = readState(config.instance).setupRequests.find((a) => a.id === approval.id); - expect(after?.status).toBe("completed"); - }); - - test("POST /api/setup-requests//complete: distinct 409 when live session exists but page navigated to a different origin", async () => { - // Pin the OTHER 409 branch: a live session whose current URL no - // longer matches the approved origin. This is the genuine - // page-navigated case (agent click, JS redirect, phishing - // redirect), distinct from the session-expired idle-sweep case - // covered by the previous test. - const config = testConfig("complete-fill-secret-real-navigation"); - const handler = createHandler(config); - const { createTask, upsertTask, createSetupRequest } = await import("./state"); - const taskId = await mutateState(config.instance, (state) => { - const task = createTask(state.instance, "real navigation test"); - upsertTask(state, task); - return task.id; - }); - const approval = await mutateState(config.instance, (state) => - createSetupRequest(state, { - taskId, - action: "browser.fill_secret", - target: "https://example.com", - reason: "Sign in", - payload: { - slots: [ - { name: "username", locator: "@e1", label: "Username", kind: "text" }, - { name: "password", locator: "@e2", label: "Password", kind: "password" } - ], - reason: "Sign in", - toolCallId: "call_fill", - approvedUrl: "https://example.com" - } - }) - ); - const { __test: browserTest } = await import("./tools/browser"); - // Live session exists but the page URL is on a different origin - // than what the approval captured — should take the "page - // navigated" branch, NOT the "session expired" branch. - browserTest.installFakeSessionWithPageForTest(taskId, { - url: () => "https://evil.example.org/phishing", - close: () => Promise.resolve() - } as Partial); - const response = await rawCall( - handler, - config, - `/api/setup-requests/${approval.id}/complete`, - { - method: "POST", - body: JSON.stringify({ secrets: { username: "tomsmith", password: "SuperSecretPassword!" } }) - }, - config.token - ); - expect(response.status).toBe(409); - const body = await response.json(); - expect(body.ok).toBe(false); - expect(body.message).toContain("Page navigated"); - expect(body.message).toContain("https://example.com"); - expect(body.message).toContain("https://evil.example.org"); - const after = readState(config.instance).setupRequests.find((a) => a.id === approval.id); - expect(after?.status).toBe("pending"); - }); - - // Default transport (issue #420): /api/browser/connect with NO cdpUrl is a - // no-op acknowledgement — the spawned Chrome launches lazily on the first - // browser tool call, not at connect time, and carries no record. - test("browser connect with no cdpUrl returns the stable disconnected status", async () => { - const config = testConfig("browser-connect-empty-body"); - const handler = createHandler(config); - const response = await rawCall( - handler, - config, - "/api/browser/connect", - { method: "POST", body: JSON.stringify({}) }, - config.token - ); - expect(response.status).toBe(201); - const body = await response.json(); - expect(body.connected).toBe(false); - expect(readState(config.instance).browser ?? null).toBeNull(); - }); - - // A cdpUrl with an unsupported protocol is user-input error → 400, and no - // record is written. (Validation happens before any probe/attach.) - test("browser connect rejects an unsupported cdpUrl protocol with 400", async () => { - const config = testConfig("browser-connect-bad-protocol"); - const handler = createHandler(config); - const response = await rawCall( - handler, - config, - "/api/browser/connect", - { method: "POST", body: JSON.stringify({ cdpUrl: "file:///etc/passwd" }) }, - config.token - ); - expect(response.status).toBe(400); - const body = await response.json(); - expect(String(body.error ?? body.message)).toContain("Unsupported cdpUrl protocol"); - expect(readState(config.instance).browser ?? null).toBeNull(); - }); - - // An unreachable (but well-formed) loopback CDP endpoint surfaces as 400 with - // a clear "Could not reach CDP endpoint" message, and writes no record. The - // server-side env knobs shrink the probe deadline so the test doesn't burn - // the full 15s budget (they are NOT plumbed from the POST body). - test("browser connect surfaces an unreachable cdp endpoint as 400", async () => { - const config = testConfig("browser-connect-unreachable-cdp"); - const handler = createHandler(config); - const prevTimeout = process.env.GINI_CDP_PROBE_TIMEOUT_MS; - const prevInterval = process.env.GINI_CDP_PROBE_INTERVAL_MS; - process.env.GINI_CDP_PROBE_TIMEOUT_MS = "40"; - process.env.GINI_CDP_PROBE_INTERVAL_MS = "10"; - try { - const response = await rawCall( - handler, - config, - "/api/browser/connect", - // Port 1 is reserved and never listening, so the probe always fails. - { method: "POST", body: JSON.stringify({ cdpUrl: "ws://127.0.0.1:1/devtools/browser/abc" }) }, - config.token - ); - expect(response.status).toBe(400); - const body = await response.json(); - expect(String(body.error ?? body.message)).toContain("Could not reach CDP endpoint"); - expect(readState(config.instance).browser ?? null).toBeNull(); - } finally { - if (prevTimeout === undefined) delete process.env.GINI_CDP_PROBE_TIMEOUT_MS; - else process.env.GINI_CDP_PROBE_TIMEOUT_MS = prevTimeout; - if (prevInterval === undefined) delete process.env.GINI_CDP_PROBE_INTERVAL_MS; - else process.env.GINI_CDP_PROBE_INTERVAL_MS = prevInterval; - } - }); - - test("PATCH /api/settings/auto-approve rejects out-of-union approvalMode with 400", async () => { - // An invalid value previously mapped to undefined and the PATCH - // silently no-op'd while returning 200 — the client thought it - // succeeded. Mirror job-level strict validation at the HTTP - // boundary so misconfigured clients get a loud failure. - const config = testConfig("settings-bad-approval-mode"); - const handler = createHandler(config); - const response = await rawCall( - handler, - config, - "/api/settings/auto-approve", - { - method: "PATCH", - body: JSON.stringify({ approvalMode: "bogus" }) - }, - config.token - ); - expect(response.status).toBe(400); - const body = await response.json(); - expect(body.error).toMatch(/approvalMode must be one of/); - expect(body.validValues).toEqual(["strict", "auto", "yolo"]); - // Original value on the config object must not have changed. - expect(config.approvalMode).toBe("strict"); - }); - - test("POST /api/browser/wipe-profile is no longer routed", async () => { - const config = testConfig("browser-wipe-removed"); - const handler = createHandler(config); - const response = await rawCall( - handler, - config, - "/api/browser/wipe-profile", - { method: "POST" }, - config.token - ); - expect(response.status).toBe(404); - }); - - test("GET /api/browser reports the stable disconnected status", async () => { - const config = testConfig("browser-status"); - const handler = createHandler(config); - const response = await rawCall(handler, config, "/api/browser", {}, config.token); - expect(response.status).toBe(200); - const body = await response.json(); - expect(body.connected).toBe(false); - }); - - test("stamps the active agent on records and filters listings by agentId", async () => { - const config = testConfig("records-agentid"); - const handler = createHandler(config); - - // Two agents — submit a task under each so we have heterogeneous rows. - const initial = await call(handler, config, "/api/agents"); - const defaultAgentId = initial.activeAgentId as string; - const second = await call(handler, config, "/api/agents", { - method: "POST", - body: JSON.stringify({ name: "scout" }) - }); - - // Task under the default agent. We use `read README.md` so runTask - // dispatches a real low-risk file tool and the task lands in a terminal - // state before the test ends — avoids a background failTask firing - // after the test's state file has been cleaned up by the next test. - config.workspaceRoot = process.cwd(); - const defaultTask = await call(handler, config, "/api/tasks", { - method: "POST", - body: JSON.stringify({ input: "read README.md" }) - }); - expect(defaultTask.agentId).toBe(defaultAgentId); - await waitForTask(handler, config, defaultTask.id); - - await call(handler, config, `/api/agents/${second.id}/use`, { method: "POST" }); - const scoutTask = await call(handler, config, "/api/tasks", { - method: "POST", - body: JSON.stringify({ input: "read README.md" }) - }); - expect(scoutTask.agentId).toBe(second.id); - await waitForTask(handler, config, scoutTask.id); - - // Unfiltered listing includes both rows. - const all = await call(handler, config, "/api/tasks"); - expect(all.some((task: { id: string }) => task.id === defaultTask.id)).toBe(true); - expect(all.some((task: { id: string }) => task.id === scoutTask.id)).toBe(true); - - // Filtered listing returns only the matching agent's rows. - const scoutOnly = await call(handler, config, `/api/tasks?agentId=${encodeURIComponent(second.id)}`); - expect(scoutOnly.every((task: { agentId?: string }) => task.agentId === second.id)).toBe(true); - expect(scoutOnly.some((task: { id: string }) => task.id === scoutTask.id)).toBe(true); - expect(scoutOnly.some((task: { id: string }) => task.id === defaultTask.id)).toBe(false); - - // Empty string is treated as "no filter" — preserves legacy behavior. - const empty = await call(handler, config, "/api/tasks?agentId="); - expect(empty.length).toBe(all.length); - }); - - test("stamps the active agent on chat sessions and filters by agentId", async () => { - const config = testConfig("records-agentid-chat"); - const handler = createHandler(config); - const initial = await call(handler, config, "/api/agents"); - const defaultAgentId = initial.activeAgentId as string; - const second = await call(handler, config, "/api/agents", { - method: "POST", - body: JSON.stringify({ name: "scout" }) - }); - const sessionA = await call(handler, config, "/api/chat", { - method: "POST", - body: JSON.stringify({ title: "under default" }) - }); - expect(sessionA.agentId).toBe(defaultAgentId); - await call(handler, config, `/api/agents/${second.id}/use`, { method: "POST" }); - const sessionB = await call(handler, config, "/api/chat", { - method: "POST", - body: JSON.stringify({ title: "under scout" }) - }); - expect(sessionB.agentId).toBe(second.id); - const scopedDefault = await call(handler, config, `/api/chat?agentId=${encodeURIComponent(defaultAgentId)}`); - expect(scopedDefault.some((s: { id: string }) => s.id === sessionA.id)).toBe(true); - expect(scopedDefault.some((s: { id: string }) => s.id === sessionB.id)).toBe(false); - const scopedScout = await call(handler, config, `/api/chat?agentId=${encodeURIComponent(second.id)}`); - expect(scopedScout.some((s: { id: string }) => s.id === sessionB.id)).toBe(true); - expect(scopedScout.some((s: { id: string }) => s.id === sessionA.id)).toBe(false); - }); - - test("stamps the active agent on jobs and filters job listings", async () => { - const config = testConfig("records-agentid-jobs"); - const handler = createHandler(config); - const initial = await call(handler, config, "/api/agents"); - const defaultAgentId = initial.activeAgentId as string; - const second = await call(handler, config, "/api/agents", { - method: "POST", - body: JSON.stringify({ name: "scout" }) - }); - const jobA = await call(handler, config, "/api/jobs", { - method: "POST", - body: JSON.stringify({ name: "default-job", prompt: "hello", intervalSeconds: 3600 }) - }); - expect(jobA.agentId).toBe(defaultAgentId); - await call(handler, config, `/api/agents/${second.id}/use`, { method: "POST" }); - const jobB = await call(handler, config, "/api/jobs", { - method: "POST", - body: JSON.stringify({ name: "scout-job", prompt: "hi", intervalSeconds: 3600 }) - }); - expect(jobB.agentId).toBe(second.id); - const scoped = await call(handler, config, `/api/jobs?agentId=${encodeURIComponent(defaultAgentId)}`); - expect(scoped.every((j: { agentId?: string }) => j.agentId === defaultAgentId)).toBe(true); - expect(scoped.some((j: { id: string }) => j.id === jobA.id)).toBe(true); - expect(scoped.some((j: { id: string }) => j.id === jobB.id)).toBe(false); - }); - - test("stamps the active agent on subagents and filters by agentId", async () => { - const config = testConfig("records-agentid-subagents"); - const handler = createHandler(config); - const initial = await call(handler, config, "/api/agents"); - const defaultAgentId = initial.activeAgentId as string; - const second = await call(handler, config, "/api/agents", { - method: "POST", - body: JSON.stringify({ name: "scout" }) - }); - const subA = await call(handler, config, "/api/subagents", { - method: "POST", - body: JSON.stringify({ name: "child-default", prompt: "report" }) - }); - expect(subA.agentId).toBe(defaultAgentId); - await call(handler, config, `/api/agents/${second.id}/use`, { method: "POST" }); - const subB = await call(handler, config, "/api/subagents", { - method: "POST", - body: JSON.stringify({ name: "child-scout", prompt: "report" }) - }); - expect(subB.agentId).toBe(second.id); - const scoped = await call(handler, config, `/api/subagents?agentId=${encodeURIComponent(second.id)}`); - expect(scoped.every((s: { agentId?: string }) => s.agentId === second.id)).toBe(true); - expect(scoped.some((s: { id: string }) => s.id === subB.id)).toBe(true); - expect(scoped.some((s: { id: string }) => s.id === subA.id)).toBe(false); - }); - - test("subagent inherits the parent task's agent even when the active agent switched", async () => { - const config = testConfig("records-agentid-subagent-inherit"); - const handler = createHandler(config); - const initial = await call(handler, config, "/api/agents"); - const defaultAgentId = initial.activeAgentId as string; - const second = await call(handler, config, "/api/agents", { - method: "POST", - body: JSON.stringify({ name: "scout" }) - }); - // Submit a parent task under the default agent so the resulting parent - // task carries agentId=default. - const parentTask = await call(handler, config, "/api/tasks", { - method: "POST", - body: JSON.stringify({ input: "echo parent" }) - }); - await waitForTask(handler, config, parentTask.id); - // Switch the active agent *before* spawning the subagent. The child - // should still inherit the parent's agent id (default), not the active - // agent (scout). Regression test for the inheritance bug where - // spawnSubagent read `resolveEffectiveContext(...).agentId` directly. - await call(handler, config, `/api/agents/${second.id}/use`, { method: "POST" }); - const child = await call(handler, config, "/api/subagents", { - method: "POST", - body: JSON.stringify({ - name: "child", - prompt: "echo child", - parentTaskId: parentTask.id - }) - }); - expect(child.agentId).toBe(defaultAgentId); - // The child task spawned by the subagent path should also carry the - // parent's agent — not the active agent at the moment of spawn. - await waitForTask(handler, config, child.taskId); - const childDetail = await call(handler, config, `/api/tasks/${child.taskId}`); - expect(childDetail.task.agentId).toBe(defaultAgentId); - }); - - test("approvals inherit agentId from the originating task and filter by agentId", async () => { - const config = testConfig("records-agentid-approvals"); - // The patch flow writes through workspaceRoot; point at the repo so the - // pre-image read in `patch README.md ::` succeeds. - config.workspaceRoot = process.cwd(); - const handler = createHandler(config); - const initial = await call(handler, config, "/api/agents"); - const defaultAgentId = initial.activeAgentId as string; - const second = await call(handler, config, "/api/agents", { - method: "POST", - body: JSON.stringify({ name: "scout" }) - }); - // Submit a patch task under the default agent — the agent loop blocks - // on file.patch until approval, and createApproval inherits agentId - // from the originating task. - const task = await call(handler, config, "/api/tasks", { - method: "POST", - body: JSON.stringify({ input: "patch README.md :: Gini => Gini" }) - }); - await waitForTask(handler, config, task.id); - // Switch the active agent *before* asserting. The approval was already - // created under the originating task, so it must carry the default - // agent's id regardless of whoever is active now. - await call(handler, config, `/api/agents/${second.id}/use`, { method: "POST" }); - const approvals = readState(config.instance).authorizations.filter((a) => a.taskId === task.id); - expect(approvals.length).toBeGreaterThan(0); - expect(approvals.every((a) => a.agentId === defaultAgentId)).toBe(true); - const scopedDefault = await call(handler, config, `/api/authorizations?agentId=${encodeURIComponent(defaultAgentId)}`); - expect(scopedDefault.every((a: { agentId?: string }) => a.agentId === defaultAgentId)).toBe(true); - expect(scopedDefault.some((a: { taskId?: string }) => a.taskId === task.id)).toBe(true); - const scopedScout = await call(handler, config, `/api/authorizations?agentId=${encodeURIComponent(second.id)}`); - expect(scopedScout.some((a: { taskId?: string }) => a.taskId === task.id)).toBe(false); - }); - - test("stamps the active agent on events and audit and filters listings", async () => { - const config = testConfig("records-agentid-events"); - const handler = createHandler(config); - const initial = await call(handler, config, "/api/agents"); - const defaultAgentId = initial.activeAgentId as string; - const second = await call(handler, config, "/api/agents", { - method: "POST", - body: JSON.stringify({ name: "scout" }) - }); - // The agent.activated audit/event for the second agent should be tagged - // with its id — the runtime stamps the active agent via inferAgentId. - await call(handler, config, `/api/agents/${second.id}/use`, { method: "POST" }); - const events = await call(handler, config, `/api/events?agentId=${encodeURIComponent(second.id)}`); - expect(events.every((e: { agentId?: string }) => e.agentId === second.id)).toBe(true); - expect(events.some((e: { action: string }) => e.action === "agent.activated")).toBe(true); - const defaultEvents = await call(handler, config, `/api/events?agentId=${encodeURIComponent(defaultAgentId)}`); - expect(defaultEvents.every((e: { agentId?: string }) => e.agentId === defaultAgentId)).toBe(true); - const audit = await call(handler, config, `/api/audit?agentId=${encodeURIComponent(second.id)}`); - expect(audit.every((a: { agentId?: string }) => a.agentId === second.id)).toBe(true); - }); - - test("migrateRecordAgentIds is idempotent across repeated reads", async () => { - const config = testConfig("records-agentid-idempotent"); - const handler = createHandler(config); - // Seed an unstamped task to force a backfill on the first read. - await mutateState(config.instance, (state) => { - state.tasks.unshift({ - id: "task_legacy_one", - title: "legacy", - input: "legacy task", - status: "completed", - instance: state.instance, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - tracePath: "", - auditIds: [], - approvalIds: [], - skillIds: [] - }); - }); - // Trigger reads to run normalizeState multiple times. - await call(handler, config, "/api/tasks"); - await call(handler, config, "/api/tasks"); - await call(handler, config, "/api/tasks"); - const audit = await call(handler, config, "/api/audit"); - const backfills = audit.filter((row: { action: string }) => row.action === "records.agentid.backfill"); - // Exactly one backfill row should exist regardless of how many reads. - expect(backfills.length).toBe(1); - }); - - test("scheduled job fired after agent switch attributes the task to the originating agent", async () => { - const config = testConfig("records-agentid-job-fire"); - const handler = createHandler(config); - config.workspaceRoot = process.cwd(); - const initial = await call(handler, config, "/api/agents"); - const defaultAgentId = initial.activeAgentId as string; - const second = await call(handler, config, "/api/agents", { - method: "POST", - body: JSON.stringify({ name: "scout" }) - }); - // Create the job under the default agent. Use a read tool so the - // spawned task can settle into a terminal state inside the test window - // — keeps the runtime from logging a "Task not found" against a stale - // state file after the next test cleans up. - const job = await call(handler, config, "/api/jobs", { - method: "POST", - body: JSON.stringify({ name: "owner-test", prompt: "read README.md", intervalSeconds: 3600 }) - }); - expect(job.agentId).toBe(defaultAgentId); - // Switch the active agent *before* the job fires. - await call(handler, config, `/api/agents/${second.id}/use`, { method: "POST" }); - // Fire the job manually (the dispatch path is shared with the scheduler). - const fired = await call(handler, config, `/api/jobs/${job.id}/run`, { method: "POST" }); - expect(fired.taskId).toBeString(); - // Wait for the resulting task to settle so its async tail doesn't - // outlive the test and trip a "Task not found" failure on a later - // test's state-file cleanup. - await waitForTask(handler, config, fired.taskId); - const detail = await call(handler, config, `/api/tasks/${fired.taskId}`); - expect(detail.task.agentId).toBe(defaultAgentId); - expect(detail.task.jobId).toBe(job.id); - }); - - test("POST /api/jobs ignores agentId in the request body", async () => { - // Regression: the public input bag previously honored a caller-supplied - // `agentId`, letting a malicious or buggy client attribute new jobs to - // any agent. Now the HTTP path strips it and the runtime falls back to - // the active agent. - const config = testConfig("records-agentid-job-untrusted-input"); - const handler = createHandler(config); - const initial = await call(handler, config, "/api/agents"); - const defaultAgentId = initial.activeAgentId as string; - const second = await call(handler, config, "/api/agents", { - method: "POST", - body: JSON.stringify({ name: "scout" }) - }); - // While the active agent is still the default, post a job whose body - // tries to spoof attribution to the scout agent. - const job = await call(handler, config, "/api/jobs", { - method: "POST", - body: JSON.stringify({ - name: "spoof-job", - prompt: "hello", - intervalSeconds: 3600, - agentId: second.id - }) - }); - expect(job.agentId).toBe(defaultAgentId); - expect(job.agentId).not.toBe(second.id); - }); - - test("job lifecycle audits carry the originating job's agent across a switch", async () => { - // Regression: addAudit's inferAgentId previously had no jobId fallback - // and the lifecycle audit writes in src/jobs/index.ts didn't pass - // agentId, so a paused/updated/removed audit after an agent switch - // misattributed the row to the new active agent. - const config = testConfig("records-agentid-job-audits"); - const handler = createHandler(config); - const initial = await call(handler, config, "/api/agents"); - const defaultAgentId = initial.activeAgentId as string; - const second = await call(handler, config, "/api/agents", { - method: "POST", - body: JSON.stringify({ name: "scout" }) - }); - const job = await call(handler, config, "/api/jobs", { - method: "POST", - body: JSON.stringify({ name: "audit-job", prompt: "hello", intervalSeconds: 3600 }) - }); - expect(job.agentId).toBe(defaultAgentId); - // Switch the active agent *before* exercising the lifecycle transitions. - await call(handler, config, `/api/agents/${second.id}/use`, { method: "POST" }); - await call(handler, config, `/api/jobs/${job.id}/pause`, { method: "POST" }); - await call(handler, config, `/api/jobs/${job.id}/resume`, { method: "POST" }); - await call(handler, config, `/api/jobs/${job.id}`, { - method: "PATCH", - body: JSON.stringify({ intervalSeconds: 7200 }) - }); - await call(handler, config, `/api/jobs/${job.id}`, { method: "DELETE" }); - const state = readState(config.instance); - const targeted = state.audit.filter((a) => a.target === job.id); - const lifecycle = targeted.filter((a) => - a.action === "job.paused" - || a.action === "job.active" - || a.action === "job.updated" - || a.action === "job.removed" - ); - expect(lifecycle.length).toBeGreaterThanOrEqual(4); - expect(lifecycle.every((a) => a.agentId === defaultAgentId)).toBe(true); - }); - - test("chat message under a session keeps the session's agent across all emitted events", async () => { - // Regression: createRun and createPlanStep previously emitted events - // without an agentId. With the session bound to agent A, sending a - // message after switching the active agent to B would mis-stamp the - // run/step events to B even though the task itself inherits A. - const config = testConfig("records-agentid-chat-message"); - config.workspaceRoot = process.cwd(); - const handler = createHandler(config); - const initial = await call(handler, config, "/api/agents"); - const defaultAgentId = initial.activeAgentId as string; - const second = await call(handler, config, "/api/agents", { - method: "POST", - body: JSON.stringify({ name: "scout" }) - }); - const session = await call(handler, config, "/api/chat", { - method: "POST", - body: JSON.stringify({ title: "owned by default" }) - }); - expect(session.agentId).toBe(defaultAgentId); - await call(handler, config, `/api/agents/${second.id}/use`, { method: "POST" }); - const submitted = await call(handler, config, `/api/chat/${session.id}/messages`, { - method: "POST", - body: JSON.stringify({ content: "read README.md" }) - }); - await waitForTask(handler, config, submitted.taskId); - const task = (await call(handler, config, `/api/tasks/${submitted.taskId}`)).task; - expect(task.agentId).toBe(defaultAgentId); - // Every event for this run should carry the original session's agent — - // not the now-active scout agent. - const state = readState(config.instance); - const runEvents = state.events.filter((e) => e.runId === submitted.runId); - expect(runEvents.length).toBeGreaterThan(0); - expect(runEvents.every((e) => e.agentId === defaultAgentId)).toBe(true); - // The run.created and run.step.created events should specifically be - // present and tagged. - expect(runEvents.some((e) => e.action === "run.created")).toBe(true); - expect(runEvents.some((e) => e.action === "run.step.created")).toBe(true); - }); - - test("chat session lifecycle events carry the session's agent", async () => { - // Regression: createChatSession / deleteChatSession / renameChatSession - // emitted lifecycle events without an agentId. A session created / - // renamed / deleted while a different agent was active would attribute - // the event to the active agent rather than the session's owner. - const config = testConfig("records-agentid-chat-lifecycle"); - const handler = createHandler(config); - const initial = await call(handler, config, "/api/agents"); - const defaultAgentId = initial.activeAgentId as string; - const second = await call(handler, config, "/api/agents", { - method: "POST", - body: JSON.stringify({ name: "scout" }) - }); - // Create the session under default. - const session = await call(handler, config, "/api/chat", { - method: "POST", - body: JSON.stringify({ title: "lifecycle" }) - }); - expect(session.agentId).toBe(defaultAgentId); - // Switch active to scout, then rename and delete the default-owned - // session — both events should still carry the default's id. - await call(handler, config, `/api/agents/${second.id}/use`, { method: "POST" }); - await call(handler, config, `/api/chat/${session.id}`, { - method: "PATCH", - body: JSON.stringify({ title: "renamed" }) - }); - await call(handler, config, `/api/chat/${session.id}`, { method: "DELETE" }); - const state = readState(config.instance); - const targeted = state.events.filter((e) => e.target === session.id); - const created = targeted.find((e) => e.action === "chat.session.created"); - const renamed = targeted.find((e) => e.action === "chat.session.renamed"); - const deleted = targeted.find((e) => e.action === "chat.session.deleted"); - expect(created?.agentId).toBe(defaultAgentId); - expect(renamed?.agentId).toBe(defaultAgentId); - expect(deleted?.agentId).toBe(defaultAgentId); - }); - - test("deleting a chat session also clears the per-conversation identity snapshot", async () => { - // Identity snapshots are keyed on conversationId (the chat session id); - // without the cleanup in deleteChatSession each deleted chat leaks one - // IdentitySnapshotRecord into state forever. - const config = testConfig("records-identity-snapshot-cleanup"); - const handler = createHandler(config); - const session = await call(handler, config, "/api/chat", { - method: "POST", - body: JSON.stringify({ title: "snapshot-cleanup" }) - }); - await mutateState(config.instance, (state) => { - if (!state.identitySnapshots) state.identitySnapshots = {}; - state.identitySnapshots[session.id] = { - identity: { - instance: config.instance, - runtimePort: config.port, - agentName: "default", - agentId: "agent_x", - provider: "echo/test", - toolsets: ["file"], - memoryNamespace: "agent_x" - }, - lastFullTurn: 1 - }; - }); - expect(readState(config.instance).identitySnapshots?.[session.id]).toBeDefined(); - await call(handler, config, `/api/chat/${session.id}`, { method: "DELETE" }); - expect(readState(config.instance).identitySnapshots?.[session.id]).toBeUndefined(); - }); - - test("addAudit infers agentId from jobId when neither agentId nor taskId is provided", async () => { - // Regression: inferAgentId's jobId fallback only fires when the caller - // threads `jobId` (or appendEvent's persisted `jobId`) through. This - // test pins that an audit row created with just jobId resolves to the - // owning job's agent — without it the row would fall back to - // state.activeAgentId after a switch. - const config = testConfig("records-agentid-job-fallback"); - const handler = createHandler(config); - const initial = await call(handler, config, "/api/agents"); - const defaultAgentId = initial.activeAgentId as string; - const second = await call(handler, config, "/api/agents", { - method: "POST", - body: JSON.stringify({ name: "scout" }) - }); - const job = await call(handler, config, "/api/jobs", { - method: "POST", - body: JSON.stringify({ name: "fallback-job", prompt: "hi", intervalSeconds: 3600 }) - }); - // Switch the active agent before emitting the audit. - await call(handler, config, `/api/agents/${second.id}/use`, { method: "POST" }); - await mutateState(config.instance, (state) => { - addAudit( - state, - { - actor: "runtime", - action: "test.job.fallback", - target: job.id, - risk: "low", - evidence: { jobId: job.id } - }, - { jobId: job.id } - ); - }); - const state = readState(config.instance); - const audit = state.audit.find((a) => a.action === "test.job.fallback"); - expect(audit?.agentId).toBe(defaultAgentId); - const paired = state.events.find((e) => e.action === "test.job.fallback"); - expect(paired?.agentId).toBe(defaultAgentId); - }); - - test("migrateRecordAgentIds re-stamps rows pointing at a deleted agent", async () => { - // Regression: the migration's predicate previously only re-stamped - // rows where agentId was missing. A row carrying the id of a deleted - // agent stayed stranded under an unselectable bucket. Now stale ids - // are treated the same as missing. - const config = testConfig("records-agentid-stale"); - const handler = createHandler(config); - const initial = await call(handler, config, "/api/agents"); - const defaultAgentId = initial.activeAgentId as string; - // Seed records pointing at a ghost agent that doesn't exist in - // state.agents. The next read triggers normalizeState -> - // migrateRecordAgentIds and should re-stamp them with the first - // existing agent (the default). - await mutateState(config.instance, (state) => { - const at = new Date().toISOString(); - state.tasks.unshift({ - id: "task_ghost", - title: "ghost", - input: "ghost task", - status: "completed", - instance: state.instance, - agentId: "agent_ghost", - createdAt: at, - updatedAt: at, - tracePath: "", - auditIds: [], - approvalIds: [], - skillIds: [] - }); - }); - // Trigger the migration via a fresh read. - await call(handler, config, "/api/tasks"); - const stamped = readState(config.instance); - const ghostTask = stamped.tasks.find((t) => t.id === "task_ghost"); - expect(ghostTask?.agentId).toBe(defaultAgentId); - // Re-reading should be idempotent — no further backfill row beyond - // what the first migration produced. - await call(handler, config, "/api/tasks"); - await call(handler, config, "/api/tasks"); - const audit = await call(handler, config, "/api/audit"); - const backfills = audit.filter((row: { action: string }) => row.action === "records.agentid.backfill"); - expect(backfills.length).toBe(1); - }); - - test("AgentContext resolves each source-id branch deterministically", async () => { - // Pin the AgentContext contract: each branch in the union resolves to - // the agent its source record carries, never to state.activeAgentId. - // We exercise every branch from a single test instance so the - // resolution matrix lives in one place. - const config = testConfig("agent-context-branches"); - const handler = createHandler(config); - const initial = await call(handler, config, "/api/agents"); - const defaultAgentId = initial.activeAgentId as string; - const second = await call(handler, config, "/api/agents", { - method: "POST", - body: JSON.stringify({ name: "scout" }) - }); - // Seed a task, job, session, and memory under the default agent. - const session = await call(handler, config, "/api/chat", { - method: "POST", - body: JSON.stringify({ title: "branch-test" }) - }); - const job = await call(handler, config, "/api/jobs", { - method: "POST", - body: JSON.stringify({ name: "branch-job", prompt: "hi", intervalSeconds: 3600 }) - }); - await mutateState(config.instance, (state) => { - state.tasks.unshift({ - id: "task_branch", - title: "branch", - input: "branch task", - status: "completed", - instance: state.instance, - agentId: defaultAgentId, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - tracePath: "", - auditIds: [], - approvalIds: [], - skillIds: [] - }); - }); - // Switch the active agent so any silent fallback would attribute to - // scout rather than the source record's owner. - await call(handler, config, `/api/agents/${second.id}/use`, { method: "POST" }); - await mutateState(config.instance, (state) => { - // explicit agentId branch - addAudit( - state, - { actor: "runtime", action: "test.branch.agentId", target: "explicit", risk: "low" }, - { agentId: defaultAgentId } - ); - // taskId branch - addAudit( - state, - { actor: "runtime", action: "test.branch.taskId", target: "from-task", risk: "low" }, - { taskId: "task_branch" } - ); - // jobId branch - addAudit( - state, - { actor: "runtime", action: "test.branch.jobId", target: "from-job", risk: "low" }, - { jobId: job.id } - ); - // sessionId branch - addAudit( - state, - { actor: "runtime", action: "test.branch.sessionId", target: "from-session", risk: "low" }, - { sessionId: session.id } - ); - // system: true branch - addAudit( - state, - { actor: "runtime", action: "test.branch.system", target: "system", risk: "low" }, - { system: true } - ); - }); - const audit = readState(config.instance).audit; - expect(audit.find((a) => a.action === "test.branch.agentId")?.agentId).toBe(defaultAgentId); - expect(audit.find((a) => a.action === "test.branch.taskId")?.agentId).toBe(defaultAgentId); - expect(audit.find((a) => a.action === "test.branch.jobId")?.agentId).toBe(defaultAgentId); - expect(audit.find((a) => a.action === "test.branch.sessionId")?.agentId).toBe(defaultAgentId); - expect(audit.find((a) => a.action === "test.branch.system")?.agentId).toBeUndefined(); - }); - - test("AgentContext is required at the type level for every emitter", () => { - // Pin the type-level invariant: calling appendEvent or addAudit - // without an AgentContext is a compile error, not a silent - // active-agent fallback. The `@ts-expect-error` directives below - // force tsc to confirm the missing third argument is rejected. - // If these comments stop catching an error, someone reintroduced a - // two-argument overload and the whole point of this refactor is - // undone. - // - // We never actually invoke these — the type check is the assertion. - // Wrapping in a `false &&` keeps tsc inspecting the call signature - // while keeping the runtime tree-shake-eligible. - if (false as boolean) { - const state = readState("agent-context-typecheck"); - // @ts-expect-error appendEvent requires an AgentContext as the third argument. - appendEvent(state, { kind: "runtime", action: "no-context", target: "x", risk: "low", summary: "x" }); - // @ts-expect-error addAudit requires an AgentContext as the third argument. - addAudit(state, { actor: "runtime", action: "no-context", target: "x", risk: "low" }); - } - expect(true).toBe(true); - }); - - test("AgentContext returns undefined when the source record was deleted", async () => { - // The contract says: if a sourceId is provided but the record doesn't - // exist (deleted, race), resolveAgentId returns undefined. It must NOT - // silently fall back to the active agent. - const config = testConfig("agent-context-missing-source"); - const handler = createHandler(config); - await mutateState(config.instance, (state) => { - addAudit( - state, - { actor: "runtime", action: "test.missing.task", target: "x", risk: "low" }, - { taskId: "task_does_not_exist" } - ); - addAudit( - state, - { actor: "runtime", action: "test.missing.job", target: "x", risk: "low" }, - { jobId: "job_does_not_exist" } - ); - addAudit( - state, - { actor: "runtime", action: "test.missing.session", target: "x", risk: "low" }, - { sessionId: "chat_does_not_exist" } - ); - }); - const audit = readState(config.instance).audit; - expect(audit.find((a) => a.action === "test.missing.task")?.agentId).toBeUndefined(); - expect(audit.find((a) => a.action === "test.missing.job")?.agentId).toBeUndefined(); - expect(audit.find((a) => a.action === "test.missing.session")?.agentId).toBeUndefined(); - }); - - test("POST /api/messaging/:id/allow with a malformed chatId returns 400 (not 500)", async () => { - // parseChatIdStrict throws "Invalid input: chatId must be ..." so - // statusFromErrorMessage maps it to 400. Without the prefix, a - // caller who PUTs `null` or `""` would see "internal error" 500. - const config = testConfig("messaging-allow-bad-chatid"); - const handler = createHandler(config); - const { addMessagingBridge } = await import("./integrations/messaging"); - const bridge = await addMessagingBridge(config, { - name: "tg", - kind: "telegram", - deliveryTargets: ["1"], - botToken: "TOK" - }); - const badPayloads: Array = [null, "", "123abc", "abc", 1.5]; - for (const chatId of badPayloads) { - const response = await rawCall( - handler, - config, - `/api/messaging/${bridge.id}/allow`, - { method: "POST", body: JSON.stringify({ chatId }) }, - config.token - ); - expect(response.status).toBe(400); - const body = await response.json(); - expect(body.error).toMatch(/chatId must be a finite integer/); - } - }); - - test("POST /api/messaging/:id/allow with a mismatched verification code returns 409 (not 500)", async () => { - // allowChat throws "Verification code mismatch — ..." when the - // operator's UI snapshot lost a race against a fresher DM that - // rotated the pending code. The HTTP layer must map that to 409 - // Conflict (stale-view), not the previous catch-all 500. - const config = testConfig("messaging-allow-code-mismatch"); - const handler = createHandler(config); - const { addMessagingBridge, recordDeniedChatAttempt } = await import("./integrations/messaging"); - const bridge = await addMessagingBridge(config, { - name: "tg", - kind: "telegram", - deliveryTargets: ["1"], - botToken: "TOK" - }); - await recordDeniedChatAttempt(config, bridge.id, { chatId: 42, chatType: "private" }); - const response = await rawCall( - handler, - config, - `/api/messaging/${bridge.id}/allow`, - { - method: "POST", - body: JSON.stringify({ chatId: 42, expectedCode: "ZZ-ZZ-ZZ" }) - }, - config.token - ); - expect(response.status).toBe(409); - const body = await response.json(); - expect(body.error).toMatch(/Verification code mismatch/); - }); - - test("POST /api/messaging/:id/allow with an expired verification code returns 409 (not 500)", async () => { - // allowChat throws "Verification code for chat ${chatId} has expired - // ..." when the pending code aged past its TTL between page load and - // click. Same 409 contract as the mismatch case so the UI can - // distinguish stale-view conflicts from generic server errors. - const { mutateState } = await import("./state/store"); - const config = testConfig("messaging-allow-code-expired"); - const handler = createHandler(config); - const { addMessagingBridge, recordDeniedChatAttempt } = await import("./integrations/messaging"); - const bridge = await addMessagingBridge(config, { - name: "tg", - kind: "telegram", - deliveryTargets: ["1"], - botToken: "TOK" - }); - const pending = await recordDeniedChatAttempt(config, bridge.id, { - chatId: 99, - chatType: "private" - }); - expect(pending?.verificationCode).toBeTruthy(); - await mutateState(config.instance, (state) => { - const live = state.messagingBridges.find((b) => b.id === bridge.id); - if (!live) return; - const meta = { ...(live.metadata ?? {}) }; - const list = Array.isArray(meta.recentDeniedChats) ? [...meta.recentDeniedChats] : []; - const idx = list.findIndex((entry: { chatId?: number }) => entry?.chatId === 99); - if (idx < 0) return; - list[idx] = { - ...list[idx], - verificationCodeExpiresAt: new Date(Date.now() - 60_000).toISOString() - }; - meta.recentDeniedChats = list; - live.metadata = meta; - }); - const response = await rawCall( - handler, - config, - `/api/messaging/${bridge.id}/allow`, - { - method: "POST", - body: JSON.stringify({ chatId: 99, expectedCode: pending!.verificationCode }) - }, - config.token - ); - expect(response.status).toBe(409); - const body = await response.json(); - expect(body.error).toMatch(/has expired/); - }); - - // ChatBlock protocol endpoints (ADR chat-block-protocol.md). The - // routes are smoke-tested here; deeper assertions on per-block - // shape live in src/state/chat-blocks.test.ts and - // src/execution/chat-task.test.ts. - test("GET /api/chat/:id/blocks returns ordered ChatBlock list and 404 for missing sessions", async () => { - const config = testConfig("chat-blocks-list"); - const handler = createHandler(config); - const session = await call(handler, config, "/api/chat", { - method: "POST", - body: JSON.stringify({ title: "blocks endpoint smoke" }) - }); - const submitted = await call(handler, config, `/api/chat/${session.id}/messages`, { - method: "POST", - body: JSON.stringify({ content: "please reply" }) - }); - await waitForTask(handler, config, submitted.taskId); - - const blocks = await call(handler, config, `/api/chat/${session.id}/blocks`); - expect(Array.isArray(blocks)).toBe(true); - expect(blocks.length).toBeGreaterThan(0); - expect(blocks[0].kind).toBe("user_text"); - expect(blocks[0].text).toBe("please reply"); - // Ordinals monotonically increase. - const ordinals = blocks.map((b: { ordinal: number }) => b.ordinal); - for (let i = 1; i < ordinals.length; i += 1) { - expect(ordinals[i]).toBeGreaterThan(ordinals[i - 1]!); - } - - const missing = await rawCall( - handler, - config, - `/api/chat/chat_does_not_exist/blocks`, - {}, - config.token - ); - expect(missing.status).toBe(404); - }); - - test("DELETE /api/chat/:id cascades chat blocks (subsequent /blocks returns 404)", async () => { - const config = testConfig("chat-blocks-cascade"); - const handler = createHandler(config); - const session = await call(handler, config, "/api/chat", { - method: "POST", - body: JSON.stringify({ title: "cascade smoke" }) - }); - await call(handler, config, `/api/chat/${session.id}/messages`, { - method: "POST", - body: JSON.stringify({ content: "blocks should disappear after delete" }) - }); - // Wait for at least the user_text block to land. We don't need the - // assistant turn to finish for this assertion. - let blocks: unknown[] = []; - for (let i = 0; i < 50; i += 1) { - const result = await call(handler, config, `/api/chat/${session.id}/blocks`); - if (Array.isArray(result) && result.length > 0) { - blocks = result; - break; - } - await Bun.sleep(20); - } - expect(blocks.length).toBeGreaterThan(0); - - await call(handler, config, `/api/chat/${session.id}`, { method: "DELETE" }); - - const afterDelete = await rawCall( - handler, - config, - `/api/chat/${session.id}/blocks`, - {}, - config.token - ); - expect(afterDelete.status).toBe(404); - }); - - test("GET /api/chat/:id/stream returns SSE with chat_block frames", async () => { - const config = testConfig("chat-blocks-stream"); - const handler = createHandler(config); - const session = await call(handler, config, "/api/chat", { - method: "POST", - body: JSON.stringify({ title: "stream smoke" }) - }); - // Pre-publish some blocks so the initial backfill carries data. - const submitted = await call(handler, config, `/api/chat/${session.id}/messages`, { - method: "POST", - body: JSON.stringify({ content: "stream this" }) - }); - await waitForTask(handler, config, submitted.taskId); - - const response = await rawCall( - handler, - config, - `/api/chat/${session.id}/stream`, - {}, - config.token - ); - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toContain("text/event-stream"); - // Read just the first frame to confirm the SSE shape; if we - // consumed the whole body we'd block on the keepalive interval. - const reader = response.body?.getReader(); - expect(reader).toBeDefined(); - const decoder = new TextDecoder(); - let buffer = ""; - if (reader) { - // Pump up to ~500ms collecting frames so the backfill arrives. - const deadline = Date.now() + 500; - while (Date.now() < deadline) { - const { done, value } = await Promise.race([ - reader.read(), - new Promise<{ done: boolean; value: undefined }>((resolve) => - setTimeout(() => resolve({ done: false, value: undefined }), 50) - ) - ]); - if (done) break; - if (value) buffer += decoder.decode(value); - if (buffer.includes("user_text")) break; - } - await reader.cancel(); - } - expect(buffer).toContain("event: chat_block"); - expect(buffer).toContain("user_text"); - expect(buffer).toContain("stream this"); - }); - - test("GET /api/chat/:id/stream emits id frames as :", async () => { - // Pins the SSE wire contract: each chat_block frame's `id:` line - // carries `:`. The mobile/browser client - // round-trips that string as Last-Event-ID on reconnect, and the - // gateway parses the `:` suffix to detect in-place updates on - // the cursor row (see listChatBlocksAfter). A regression that - // strips the suffix would silently break resume semantics for the - // streaming assistant_text case, so we pin the format at the HTTP - // boundary. - const config = testConfig("chat-blocks-stream-id-format"); - const handler = createHandler(config); - const session = await call(handler, config, "/api/chat", { - method: "POST", - body: JSON.stringify({ title: "stream id format" }) - }); - const submitted = await call(handler, config, `/api/chat/${session.id}/messages`, { - method: "POST", - body: JSON.stringify({ content: "id format check" }) - }); - await waitForTask(handler, config, submitted.taskId); - - const response = await rawCall( - handler, - config, - `/api/chat/${session.id}/stream`, - {}, - config.token - ); - expect(response.status).toBe(200); - const reader = response.body?.getReader(); - expect(reader).toBeDefined(); - const decoder = new TextDecoder(); - let buffer = ""; - if (reader) { - const deadline = Date.now() + 500; - while (Date.now() < deadline) { - const { done, value } = await Promise.race([ - reader.read(), - new Promise<{ done: boolean; value: undefined }>((resolve) => - setTimeout(() => resolve({ done: false, value: undefined }), 50) - ) - ]); - if (done) break; - if (value) buffer += decoder.decode(value); - if (buffer.includes("event: chat_block")) break; - } - await reader.cancel(); - } - // Frame shape: `id: :\nevent: chat_block\n...`. - // Block ids are `block_` (no `:`); ISO timestamps look like - // `YYYY-MM-DDTHH:MM:SS.sssZ`. The whole line must match this pattern. - const idLineMatch = buffer.match( - /^id: ([A-Za-z0-9_-]+):(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z)$/m - ); - expect(idLineMatch).not.toBeNull(); - // Sanity: the captured block id portion does not itself contain `:`, - // so splitting on the first colon in listChatBlocksAfter is safe. - expect(idLineMatch?.[1]).not.toContain(":"); - }); - - test("GET /api/chat/:id/stream emits chat_session frame on initial connect", async () => { - // The mobile client reads the chat-detail header title from the - // session record this frame carries. Without an initial emit, - // there's a window where the header would render "Chat" / the - // first-message fallback even though the gateway already knows - // the canonical title (e.g. on reconnect to an already-renamed - // session). - const config = testConfig("chat-stream-session-initial"); - const handler = createHandler(config); - const session = await call(handler, config, "/api/chat", { - method: "POST", - body: JSON.stringify({ title: "before-rename" }) - }); - await call(handler, config, `/api/chat/${session.id}`, { - method: "PATCH", - body: JSON.stringify({ title: "Renamed in the lobby" }) - }); - - const response = await rawCall( - handler, - config, - `/api/chat/${session.id}/stream`, - {}, - config.token - ); - expect(response.status).toBe(200); - const reader = response.body?.getReader(); - expect(reader).toBeDefined(); - const decoder = new TextDecoder(); - let buffer = ""; - if (reader) { - const deadline = Date.now() + 500; - while (Date.now() < deadline) { - const { done, value } = await Promise.race([ - reader.read(), - new Promise<{ done: boolean; value: undefined }>((resolve) => - setTimeout(() => resolve({ done: false, value: undefined }), 50) - ) - ]); - if (done) break; - if (value) buffer += decoder.decode(value); - if (buffer.includes("event: chat_session")) break; - } - await reader.cancel(); - } - expect(buffer).toContain("event: chat_session"); - expect(buffer).toContain("Renamed in the lobby"); - }); - - test("GET /api/chat/:id/stream pushes chat_session frame on rename", async () => { - // The auto-rename path (chat-task → autoRenameChatAfterTurn) fires - // after task completion and the mobile chat-detail header must - // pick up the new title without polling. Stand-in for the auto - // case by hitting /rename explicitly — both paths route through - // renameChat → publishChatSession. - const config = testConfig("chat-stream-session-rename"); - const handler = createHandler(config); - const session = await call(handler, config, "/api/chat", { - method: "POST", - body: JSON.stringify({ title: "" }) - }); - - const response = await rawCall( - handler, - config, - `/api/chat/${session.id}/stream`, - {}, - config.token - ); - expect(response.status).toBe(200); - const reader = response.body?.getReader(); - expect(reader).toBeDefined(); - const decoder = new TextDecoder(); - - // Drain the initial chat_session frame (the one emitted on connect) - // so the assertion below targets the rename-driven frame. - let buffer = ""; - if (reader) { - const initialDeadline = Date.now() + 500; - while (Date.now() < initialDeadline) { - const { done, value } = await Promise.race([ - reader.read(), - new Promise<{ done: boolean; value: undefined }>((resolve) => - setTimeout(() => resolve({ done: false, value: undefined }), 50) - ) - ]); - if (done) break; - if (value) buffer += decoder.decode(value); - if (buffer.includes("event: chat_session")) break; - } - // Clear the buffer so we can detect the second emit independently. - buffer = ""; - - // Trigger the publish from another concurrent caller, then drain. - await call(handler, config, `/api/chat/${session.id}`, { - method: "PATCH", - body: JSON.stringify({ title: "Streamed rename" }) - }); - - const deadline = Date.now() + 1000; - while (Date.now() < deadline) { - const { done, value } = await Promise.race([ - reader.read(), - new Promise<{ done: boolean; value: undefined }>((resolve) => - setTimeout(() => resolve({ done: false, value: undefined }), 50) - ) - ]); - if (done) break; - if (value) buffer += decoder.decode(value); - if (buffer.includes("Streamed rename")) break; - } - await reader.cancel(); - } - expect(buffer).toContain("event: chat_session"); - expect(buffer).toContain("Streamed rename"); - }); - - test("GET /api/chat/:id/stream returns 404 for unknown sessions", async () => { - const config = testConfig("chat-blocks-stream-404"); - const handler = createHandler(config); - const response = await rawCall( - handler, - config, - `/api/chat/chat_unknown/stream`, - {}, - config.token - ); - expect(response.status).toBe(404); - }); - - test("a tokenless web stream marks the session web-watched until it closes", async () => { - // A web client (no X-Device-Token) opening a chat stream registers on - // the pushless registry so the dispatcher can downgrade the phone's - // completion alert to a silent badge refresh while the human reads on - // the web. Closing the stream clears the entry — so a send-then-close - // leaves the phone eligible for its normal alert. - const config = testConfig("chat-stream-web-presence"); - const handler = createHandler(config); - const { isSessionWebWatched, isDeviceWatching } = await import("./state"); - const session = await call(handler, config, "/api/chat", { - method: "POST", - body: JSON.stringify({ title: "web presence" }) - }); - - expect(isSessionWebWatched(config.instance, session.id)).toBe(false); - - // No X-Device-Token header → treated as a web/CLI client. - await withChatStream(handler, config, session.id, {}, () => { - // Web presence is recorded; the device registry is untouched (no - // token to key on). - expect(isSessionWebWatched(config.instance, session.id)).toBe(true); - expect(isDeviceWatching(config.instance, config.token, session.id)).toBe(false); - }); - - // Stream closed → presence cleared (the send-then-close path). - expect(isSessionWebWatched(config.instance, session.id)).toBe(false); - }); - - test("a stream with a valid X-Device-Token registers per-device watch (not pushless)", async () => { - // The mobile path: a registered device opens the stream with its - // valid token, which lands in the per-device registry so the - // dispatcher skips a redundant push to THIS device. It must NOT land - // in the pushless registry (that's web-only). - const config = testConfig("chat-stream-device-watch"); - const handler = createHandler(config); - const { isSessionWebWatched, isDeviceWatching } = await import("./state"); - const session = await call(handler, config, "/api/chat", { - method: "POST", - body: JSON.stringify({ title: "device watch" }) - }); - // Register the device so deviceTokenFromRequest resolves it for the - // caller's credential. - const deviceToken = "valid_device_token_wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww"; - await call(handler, config, "/api/push/devices", { - method: "POST", - body: JSON.stringify({ token: deviceToken, platform: "ios", bundleId: "ai.lilaclabs.gini.mobile" }) - }); - - await withChatStream( - handler, - config, - session.id, - { headers: { "x-device-token": deviceToken } }, - () => { - // Device watch recorded; pushless registry untouched. - expect(isDeviceWatching(config.instance, deviceToken, session.id)).toBe(true); - expect(isSessionWebWatched(config.instance, session.id)).toBe(false); - } - ); - expect(isDeviceWatching(config.instance, deviceToken, session.id)).toBe(false); - }); - - test("a stream with a present-but-invalid X-Device-Token registers NO presence (not web, not device)", async () => { - // Tri-state guard: deviceTokenFromRequest returns null for BOTH an - // absent header AND a present-but-unregistered/mismatched token. Only - // a truly-absent header is a web/CLI client. A real iPhone whose - // persisted token went stale (rotated server-side, or re-paired) primes - // that stale token onto the SSE handshake on cold launch — it must NOT - // be misclassified as a web client, or it would silence its OWN - // completion alerts for the session. With an invalid token present we - // register nothing: no pushless downgrade, no per-device skip. - const config = testConfig("chat-stream-stale-token"); - const handler = createHandler(config); - const { isSessionWebWatched, isDeviceWatching } = await import("./state"); - const session = await call(handler, config, "/api/chat", { - method: "POST", - body: JSON.stringify({ title: "stale token" }) - }); - - const staleToken = "stale_unregistered_token_zzzzzzzzzzzzzzzzzzzzzzzz"; - await withChatStream( - handler, - config, - session.id, - { headers: { "x-device-token": staleToken } }, - () => { - // Neither registry recorded this stream — a stale-token phone keeps - // its normal alert. - expect(isSessionWebWatched(config.instance, session.id)).toBe(false); - expect(isDeviceWatching(config.instance, staleToken, session.id)).toBe(false); - } - ); - expect(isSessionWebWatched(config.instance, session.id)).toBe(false); - }); - - test("an unauthenticated stream request 401s and creates no pushless presence", async () => { - // The pushless registration is a notification side effect, so it must - // sit behind the bearer gate: a request with no Authorization header - // 401s before reaching the stream factory, leaving the registry empty. - // Pins that the side effect can't be triggered by an unauthenticated - // caller who merely knows a session id. - const config = testConfig("chat-stream-unauth-no-presence"); - const handler = createHandler(config); - const { isSessionWebWatched } = await import("./state"); - const session = await call(handler, config, "/api/chat", { - method: "POST", - body: JSON.stringify({ title: "unauth presence" }) - }); - - // No token argument → no Authorization header. - const response = await rawCall(handler, config, `/api/chat/${session.id}/stream`, {}); - expect(response.status).toBe(401); - expect(isSessionWebWatched(config.instance, session.id)).toBe(false); - }); - - test("POST /api/messaging/:id/reject-pending with a malformed chatId returns 400 (not 500)", async () => { - // Same parseChatIdStrict guard as /allow — pin it here so the new - // route doesn't regress to 500 on bad input as the surface grows. - const config = testConfig("messaging-reject-pending-bad-chatid"); - const handler = createHandler(config); - const { addMessagingBridge } = await import("./integrations/messaging"); - const bridge = await addMessagingBridge(config, { - name: "tg", - kind: "telegram", - deliveryTargets: ["1"], - botToken: "TOK" - }); - const badPayloads: Array = [null, "", "123abc", "abc", 1.5]; - for (const chatId of badPayloads) { - const response = await rawCall( - handler, - config, - `/api/messaging/${bridge.id}/reject-pending`, - { method: "POST", body: JSON.stringify({ chatId }) }, - config.token - ); - expect(response.status).toBe(400); - const body = await response.json(); - expect(body.error).toMatch(/chatId must be a finite integer/); - } - }); - - test("rejects /api/embedding/reembed payloads that pass both allBanks and bankId", async () => { - // The CLI throws when both --all-banks and --bank are supplied - // (src/cli/commands/embedding.ts). The HTTP API has to mirror - // that contract: silently ignoring bankId when allBanks=true - // would let a caller think they were reembedding a single bank - // and instead trigger a full-instance reembed — a destructive, - // irreversible operation against every bank in the instance. - const config = testConfig("embedding-reembed-conflict"); - const handler = createHandler(config); - const response = await rawCall( - handler, - config, - "/api/embedding/reembed", - { - method: "POST", - body: JSON.stringify({ allBanks: true, bankId: "bank_default" }) - }, - config.token - ); - expect(response.status).toBe(400); - const body = await response.json(); - expect(body.error).toMatch(/mutually exclusive/); - }); - - describe("identity-files routes", () => { - test("GET /api/identity-files returns INSTRUCTIONS.md, USER.md, and SOULs with budget metadata", async () => { - const config = testConfig("identity-show"); - const handler = createHandler(config); - // Seed USER.md so the budget snapshot is meaningful. - const { writeUserProfile, scaffoldInstanceIdentityFiles } = await import("./runtime/identity-files"); - scaffoldInstanceIdentityFiles(config.instance); - writeUserProfile(config.instance, "## Identity\n- Name: TestUser", "approved"); - const dump = await call(handler, config, "/api/identity-files"); - expect(dump.instance).toBe(config.instance); - expect(dump.userProfile.content).toContain("Name: TestUser"); - expect(dump.userProfile.cap).toBe(1500); - expect(dump.userProfile.budget.used).toBeGreaterThan(0); - expect(dump.userProfile.budget.overCap).toBe(false); - // INSTRUCTIONS.md is materialized by scaffold; the route returns - // its content trimmed. - expect(dump.instructions.content).toMatch(/You are a personal agent running on the gini-agent framework\./); - }); - - test("GET /api/identity-files/history?kind=user returns snapshots newest-first", async () => { - const config = testConfig("identity-history"); - const handler = createHandler(config); - const { writeUserProfile } = await import("./runtime/identity-files"); - writeUserProfile(config.instance, "v1", "approved"); - writeUserProfile(config.instance, "v2", "approved"); - writeUserProfile(config.instance, "v3", "approved"); - const out = await call(handler, config, "/api/identity-files/history?kind=user"); - expect(out.kind).toBe("user"); - // Three writes → two snapshots in history (first write has nothing - // to roll back to). - expect(out.entries.length).toBe(2); - // Each entry carries a path-safe name and a positive size. - for (const entry of out.entries) { - expect(entry.name).toMatch(/\.md$/); - expect(entry.sizeBytes).toBeGreaterThan(0); - } - }); - - test("POST /api/identity-files/rollback restores from a snapshot and emits an audit row", async () => { - const config = testConfig("identity-rollback"); - const handler = createHandler(config); - const { writeUserProfile, listUserProfileHistory, userProfilePath } = await import("./runtime/identity-files"); - writeUserProfile(config.instance, "v1 body", "approved"); - writeUserProfile(config.instance, "v2 body", "approved"); - writeUserProfile(config.instance, "v3 body", "approved"); - const history = listUserProfileHistory(config.instance); - const v1Snap = history.find((e) => readFileSync(e.path, "utf8") === "v1 body"); - expect(v1Snap).toBeDefined(); - const result = await call(handler, config, "/api/identity-files/rollback", { - method: "POST", - body: JSON.stringify({ kind: "user", snapshot: v1Snap!.name }) - }); - expect(result.ok).toBe(true); - expect(result.restoredBytes).toBe(Buffer.byteLength("v1 body", "utf8")); - // The active USER.md now holds the rolled-back body. - expect(readFileSync(userProfilePath(config.instance), "utf8")).toBe("v1 body"); - // Audit row recorded the rollback. - const state = readState(config.instance); - const audit = state.audit.find((a) => a.action === "identity.user_profile.rollback"); - expect(audit).toBeDefined(); - // Pre-rollback snapshot was created so the rollback is itself - // reversible. - expect(result.preRestoreSnapshot).not.toBeNull(); - }); - - test("POST /api/identity-files/rollback rejects an unknown snapshot name with reason='no snapshot'", async () => { - const config = testConfig("identity-rollback-unknown"); - const handler = createHandler(config); - const { writeUserProfile } = await import("./runtime/identity-files"); - writeUserProfile(config.instance, "v1", "approved"); - const result = await call(handler, config, "/api/identity-files/rollback", { - method: "POST", - body: JSON.stringify({ kind: "user", snapshot: "2099-01-01T00-00-00.000Z.md" }) - }); - expect(result.ok).toBe(false); - expect(result.reason).toBe("no snapshot"); - }); - }); - - describe("push device endpoints", () => { - test("POST /api/push/devices upserts a token scoped to the owner credential", async () => { - const config = testConfig("push-devices-upsert"); - const handler = createHandler(config); - - const ownerReg = await call(handler, config, "/api/push/devices", { - method: "POST", - body: JSON.stringify({ token: "tok_owner", platform: "ios", bundleId: "ai.lilaclabs.gini.mobile" }) - }); - // Owner-token-only auth: every caller resolves to the "owner" credential. - expect(ownerReg.ok).toBe(true); - expect(ownerReg.device.credentialId).toBe("owner"); - expect(ownerReg.device.token).toBe("tok_owner"); - - // A second install registers its own token under the same credential. - const secondReg = await call(handler, config, "/api/push/devices", { - method: "POST", - body: JSON.stringify({ token: "tok_phone", platform: "ios", bundleId: "ai.lilaclabs.gini.mobile" }) - }); - expect(secondReg.ok).toBe(true); - expect(secondReg.device.credentialId).toBe("owner"); - - // Re-register the same token — idempotent rebind (bundle id updates). - const rebind = await call(handler, config, "/api/push/devices", { - method: "POST", - body: JSON.stringify({ token: "tok_owner", platform: "ios", bundleId: "ai.lilaclabs.gini.mobile.dev" }) - }); - expect(rebind.device.bundleId).toBe("ai.lilaclabs.gini.mobile.dev"); - expect(rebind.device.credentialId).toBe("owner"); - }); - - test("POST /api/push/devices validates inputs", async () => { - const config = testConfig("push-devices-validate"); - const handler = createHandler(config); - - const missingToken = await rawCall(handler, config, "/api/push/devices", { - method: "POST", - body: JSON.stringify({ platform: "ios", bundleId: "ai.lilaclabs.gini.mobile" }) - }, config.token); - expect(missingToken.status).toBe(400); - - const wrongPlatform = await rawCall(handler, config, "/api/push/devices", { - method: "POST", - body: JSON.stringify({ token: "tok", platform: "android", bundleId: "ai.lilaclabs.gini.mobile" }) - }, config.token); - expect(wrongPlatform.status).toBe(400); - - const missingBundle = await rawCall(handler, config, "/api/push/devices", { - method: "POST", - body: JSON.stringify({ token: "tok", platform: "ios" }) - }, config.token); - expect(missingBundle.status).toBe(400); - }); - - test("DELETE /api/push/devices/:token removes the owner's token; a missing token is 404", async () => { - const config = testConfig("push-devices-delete"); - const handler = createHandler(config); - - await call(handler, config, "/api/push/devices", { - method: "POST", - body: JSON.stringify({ token: "tok_phone", platform: "ios", bundleId: "ai.lilaclabs.gini.mobile" }) - }); - - const ownDelete = await call(handler, config, "/api/push/devices/tok_phone", { method: "DELETE" }); - expect(ownDelete.ok).toBe(true); - - // Second delete of the same token: 404. - const repeatDelete = await rawCall(handler, config, "/api/push/devices/tok_phone", { method: "DELETE" }, config.token); - expect(repeatDelete.status).toBe(404); - - // A token that never existed: 404 as well. - const missingDelete = await rawCall(handler, config, "/api/push/devices/tok_never", { method: "DELETE" }, config.token); - expect(missingDelete.status).toBe(404); - }); - - test("POST /api/push/devices → 200 row written with origin='loopback'", async () => { - // Push devices register over loopback from the local web UI; the - // row is tagged origin='loopback' (the only origin). - const config = testConfig("push-devices-loopback"); - mkdirSync(config.stateRoot, { recursive: true }); - const handler = createHandler(config); - - const res = await rawCall( - handler, - config, - "/api/push/devices", - { - method: "POST", - body: JSON.stringify({ token: "tok_loop", platform: "ios", bundleId: "ai.lilaclabs.gini.mobile" }) - }, - config.token - ); - expect(res.status).toBe(200); - const body = await res.json(); - expect(body.device.origin).toBe("loopback"); - }); - - test("POST /api/chat/:id/read records the cursor and GET /api/badge surfaces the unread total", async () => { - const config = testConfig("chat-read-badge"); - const handler = createHandler(config); - - // Register a device first — read/badge now key per device, not - // per credential, so the mobile client identifies itself via - // X-Device-Token on every call. - await call(handler, config, "/api/push/devices", { - method: "POST", - body: JSON.stringify({ token: "tok_owner_device", platform: "ios", bundleId: "ai.lilaclabs.gini.mobile" }) - }); - const deviceHeader = { "x-device-token": "tok_owner_device" }; - - const session = await call(handler, config, "/api/chat", { - method: "POST", - body: JSON.stringify({ title: "read state" }) - }); - // Plant two visible blocks via the persistence layer — the read - // endpoint validates the block id, but the unread aggregate is - // what we're measuring here. - const { insertChatBlock } = await import("./state"); - const b1 = insertChatBlock(config.instance, { - kind: "user_text", - sessionId: session.id, - text: "hi" - }); - insertChatBlock(config.instance, { - kind: "user_text", - sessionId: session.id, - text: "follow up" - }); - - // Fresh device: no read state yet, both blocks unread. - const before = await call(handler, config, "/api/badge", { headers: deviceHeader }); - expect(before.unread).toBe(2); - - const marked = await call(handler, config, `/api/chat/${session.id}/read`, { - method: "POST", - headers: deviceHeader, - body: JSON.stringify({ lastReadBlockId: b1.id }) - }); - expect(marked.ok).toBe(true); - expect(marked.readState.lastReadBlockId).toBe(b1.id); - - const after = await call(handler, config, "/api/badge", { headers: deviceHeader }); - expect(after.unread).toBe(1); - }); - - test("GET /api/badge and /api/unread exclude archived sessions", async () => { - // Regression: an archived session (e.g. a deleted recurring-job - // channel) is hidden from every client by the `!archivedAt` filter, - // so the user can never open it to clear its read-state. The badge - // must not count its blocks — otherwise it pins at a number that - // can never be drained. - const config = testConfig("chat-badge-archived"); - const handler = createHandler(config); - - await call(handler, config, "/api/push/devices", { - method: "POST", - body: JSON.stringify({ token: "tok_owner_device", platform: "ios", bundleId: "ai.lilaclabs.gini.mobile" }) - }); - const deviceHeader = { "x-device-token": "tok_owner_device" }; - - const live = await call(handler, config, "/api/chat", { - method: "POST", - body: JSON.stringify({ title: "live chat" }) - }); - const stale = await call(handler, config, "/api/chat", { - method: "POST", - body: JSON.stringify({ title: "old job channel" }) - }); - - const { insertChatBlock } = await import("./state"); - insertChatBlock(config.instance, { kind: "assistant_text", sessionId: live.id, text: "reachable", streaming: false }); - insertChatBlock(config.instance, { kind: "assistant_text", sessionId: stale.id, text: "stuck 1", streaming: false }); - insertChatBlock(config.instance, { kind: "assistant_text", sessionId: stale.id, text: "stuck 2", streaming: false }); - - // Both sessions visible → all three blocks count. - const before = await call(handler, config, "/api/badge", { headers: deviceHeader }); - expect(before.unread).toBe(3); - - // Archive the stale session — it now drops out of the badge. - await mutateState(config.instance, (state) => { - const session = state.chatSessions.find((s) => s.id === stale.id); - if (session) session.archivedAt = new Date().toISOString(); - }); - - const afterBadge = await call(handler, config, "/api/badge", { headers: deviceHeader }); - expect(afterBadge.unread).toBe(1); - - const afterUnread = await call(handler, config, "/api/unread", { headers: deviceHeader }); - expect(afterUnread.counts[live.id]).toBe(1); - expect(afterUnread.counts[stale.id]).toBeUndefined(); - }); - - test("GET /api/badge and /api/unread exclude sessions of archived agents", async () => { - // Second unreachable vector: archiving an agent stamps `archivedAt` - // on the AGENT, not its chat session. Both clients show archived - // agents in a Restore-only group with no way to open the chat, so - // the session's blocks can never be marked read. The badge must - // exclude them just like a self-archived session. - const config = testConfig("chat-badge-archived-agent"); - const handler = createHandler(config); - - await call(handler, config, "/api/push/devices", { - method: "POST", - body: JSON.stringify({ token: "tok_owner_device", platform: "ios", bundleId: "ai.lilaclabs.gini.mobile" }) - }); - const deviceHeader = { "x-device-token": "tok_owner_device" }; - - const agent = await call(handler, config, "/api/agents", { - method: "POST", - body: JSON.stringify({ name: "scratch agent" }) - }); - const agentChat = await getOrCreateAgentChat(config.instance, agent.id); - - const { insertChatBlock } = await import("./state"); - insertChatBlock(config.instance, { - kind: "assistant_text", - sessionId: agentChat.id, - text: "agent reply 1", - streaming: false, - agentId: agent.id - }); - insertChatBlock(config.instance, { - kind: "assistant_text", - sessionId: agentChat.id, - text: "agent reply 2", - streaming: false, - agentId: agent.id - }); - - // Agent active → its chat is reachable, both blocks count. - const before = await call(handler, config, "/api/badge", { headers: deviceHeader }); - expect(before.unread).toBe(2); - - // Archive the agent (the session keeps no archivedAt of its own). - await call(handler, config, `/api/agents/${agent.id}/archive`, { method: "POST" }); - const sessionStillUnarchived = readState(config.instance).chatSessions.find((s) => s.id === agentChat.id); - expect(sessionStillUnarchived?.archivedAt).toBeUndefined(); - - // Badge drops to zero — the only session belongs to an archived agent. - const afterBadge = await call(handler, config, "/api/badge", { headers: deviceHeader }); - expect(afterBadge.unread).toBe(0); - const afterUnread = await call(handler, config, "/api/unread", { headers: deviceHeader }); - expect(afterUnread.counts[agentChat.id]).toBeUndefined(); - - // Restoring the agent makes the chat reachable again → blocks recount. - await call(handler, config, `/api/agents/${agent.id}/unarchive`, { method: "POST" }); - const afterRestore = await call(handler, config, "/api/badge", { headers: deviceHeader }); - expect(afterRestore.unread).toBe(2); - }); - - test("GET /api/badge still counts a job channel owned by an archived agent", async () => { - // Over-exclusion guard: a job CHANNEL (kind:"channel" / origin:"job") - // stays on the channel rail even when its owning agent is archived — - // the client filters key on kind/origin + !archivedAt, never on agent - // state. So unless the channel is archived in its own right it is still - // reachable and openable, and its blocks must keep counting. Only the - // agent's CANONICAL chat vanishes with the agent. - const config = testConfig("chat-badge-archived-agent-channel"); - const handler = createHandler(config); - - await call(handler, config, "/api/push/devices", { - method: "POST", - body: JSON.stringify({ token: "tok_owner_device", platform: "ios", bundleId: "ai.lilaclabs.gini.mobile" }) - }); - const deviceHeader = { "x-device-token": "tok_owner_device" }; - - const agent = await call(handler, config, "/api/agents", { - method: "POST", - body: JSON.stringify({ name: "channel owner" }) - }); - // A real recurring job with its own dedicated channel (kind:"channel", - // origin:"job"), owned by the agent. Going through createScheduledJob - // means the job references the channel, so the orphan-channel sweep in - // normalizeState leaves it live (a bare hand-built channel would be - // archived as orphaned on the next state load). - const job = await createScheduledJob( - config, - { - name: "news-watch", - prompt: "summarize headlines", - intervalSeconds: 600, - createDedicatedSession: { title: "news-watch" } - }, - { originatingAgentId: agent.id } - ); - const channelId = job.chatSessionId!; - const channelSession = readState(config.instance).chatSessions.find((s) => s.id === channelId); - expect(channelSession?.kind).toBe("channel"); - expect(channelSession?.agentId).toBe(agent.id); - - const { insertChatBlock } = await import("./state"); - insertChatBlock(config.instance, { - kind: "assistant_text", - sessionId: channelId, - text: "digest 1", - streaming: false, - agentId: agent.id - }); - - const before = await call(handler, config, "/api/badge", { headers: deviceHeader }); - expect(before.unread).toBe(1); - - // Archive the agent. The channel is still rendered on the rail, so it - // must keep counting (unlike the agent's canonical chat). - await call(handler, config, `/api/agents/${agent.id}/archive`, { method: "POST" }); - const sessionStillLive = readState(config.instance).chatSessions.find((s) => s.id === channelId); - expect(sessionStillLive?.archivedAt).toBeUndefined(); - - const afterBadge = await call(handler, config, "/api/badge", { headers: deviceHeader }); - expect(afterBadge.unread).toBe(1); - const afterUnread = await call(handler, config, "/api/unread", { headers: deviceHeader }); - expect(afterUnread.counts[channelId]).toBe(1); - - // But if the channel itself is archived, it drops out (vector 1). - await mutateState(config.instance, (state) => { - const s = state.chatSessions.find((x) => x.id === channelId); - if (s) s.archivedAt = new Date().toISOString(); - }); - const afterChannelArchive = await call(handler, config, "/api/badge", { headers: deviceHeader }); - expect(afterChannelArchive.unread).toBe(0); - }); - - test("POST /api/chat/:id/read rejects bad input and cross-session ids", async () => { - const config = testConfig("chat-read-validate"); - const handler = createHandler(config); - await call(handler, config, "/api/push/devices", { - method: "POST", - body: JSON.stringify({ token: "tok_owner_device", platform: "ios", bundleId: "ai.lilaclabs.gini.mobile" }) - }); - const deviceHeader = { "x-device-token": "tok_owner_device" }; - const sessionA = await call(handler, config, "/api/chat", { - method: "POST", - body: JSON.stringify({ title: "A" }) - }); - const sessionB = await call(handler, config, "/api/chat", { - method: "POST", - body: JSON.stringify({ title: "B" }) - }); - const { insertChatBlock } = await import("./state"); - const bA = insertChatBlock(config.instance, { - kind: "user_text", - sessionId: sessionA.id, - text: "in A" - }); - - // Missing lastReadBlockId. - const missing = await rawCall( - handler, - config, - `/api/chat/${sessionA.id}/read`, - { method: "POST", headers: deviceHeader, body: JSON.stringify({}) }, - config.token - ); - expect(missing.status).toBe(400); - - // Block belongs to A — POSTing it on B's cursor is rejected. - const cross = await rawCall( - handler, - config, - `/api/chat/${sessionB.id}/read`, - { method: "POST", headers: deviceHeader, body: JSON.stringify({ lastReadBlockId: bA.id }) }, - config.token - ); - expect(cross.status).toBe(400); - - // Unknown session: 404. - const noSession = await rawCall( - handler, - config, - "/api/chat/chat_nonexistent/read", - { method: "POST", headers: deviceHeader, body: JSON.stringify({ lastReadBlockId: bA.id }) }, - config.token - ); - expect(noSession.status).toBe(404); - - // Missing X-Device-Token: 400 (mobile-only endpoint). - const missingDevice = await rawCall( - handler, - config, - `/api/chat/${sessionA.id}/read`, - { method: "POST", body: JSON.stringify({ lastReadBlockId: bA.id }) }, - config.token - ); - expect(missingDevice.status).toBe(400); - - // Foreign device token (not registered to this credential): 403. - const foreignDevice = await rawCall( - handler, - config, - `/api/chat/${sessionA.id}/read`, - { - method: "POST", - headers: { "x-device-token": "tok_someone_else" }, - body: JSON.stringify({ lastReadBlockId: bA.id }) - }, - config.token - ); - expect(foreignDevice.status).toBe(403); - }); - - test("read state is scoped per device, not per credential", async () => { - // Two iPhones owned by the same human (both register under the - // "owner" credential). iPhone A reading the chat must NOT clear - // iPhone B's badge — that's the load-bearing per-device guarantee. - const config = testConfig("chat-read-device"); - const handler = createHandler(config); - await call(handler, config, "/api/push/devices", { - method: "POST", - body: JSON.stringify({ token: "tok_iphone_a", platform: "ios", bundleId: "ai.lilaclabs.gini.mobile" }) - }); - await call(handler, config, "/api/push/devices", { - method: "POST", - body: JSON.stringify({ token: "tok_iphone_b", platform: "ios", bundleId: "ai.lilaclabs.gini.mobile" }) - }); - - const session = await call(handler, config, "/api/chat", { - method: "POST", - body: JSON.stringify({ title: "shared" }) - }); - const { insertChatBlock } = await import("./state"); - const block = insertChatBlock(config.instance, { - kind: "user_text", - sessionId: session.id, - text: "hello" - }); - - // iPhone A marks read; its badge drops to 0. iPhone B's badge - // is still 1 because read state is per-device. - await call(handler, config, `/api/chat/${session.id}/read`, { - method: "POST", - headers: { "x-device-token": "tok_iphone_a" }, - body: JSON.stringify({ lastReadBlockId: block.id }) - }); - const badgeA = await call(handler, config, "/api/badge", { - headers: { "x-device-token": "tok_iphone_a" } - }); - const badgeB = await call(handler, config, "/api/badge", { - headers: { "x-device-token": "tok_iphone_b" } - }); - expect(badgeA.unread).toBe(0); - expect(badgeB.unread).toBe(1); - }); - - test("DELETE /api/chat/:id/read marks just the latest assistant turn unread", async () => { - const config = testConfig("chat-unread-swipe"); - const handler = createHandler(config); - await call(handler, config, "/api/push/devices", { - method: "POST", - body: JSON.stringify({ token: "tok_iphone_a", platform: "ios", bundleId: "ai.lilaclabs.gini.mobile" }) - }); - await call(handler, config, "/api/push/devices", { - method: "POST", - body: JSON.stringify({ token: "tok_iphone_b", platform: "ios", bundleId: "ai.lilaclabs.gini.mobile" }) - }); - const headerA = { "x-device-token": "tok_iphone_a" }; - const headerB = { "x-device-token": "tok_iphone_b" }; - const session = await call(handler, config, "/api/chat", { - method: "POST", - body: JSON.stringify({ title: "swipe" }) - }); - // Realistic chat: a few user messages culminating in an assistant - // reply. After Mark Unread the badge should show 1 (just the - // assistant turn), not 4 (every visible block). - const { insertChatBlock } = await import("./state"); - insertChatBlock(config.instance, { kind: "user_text", sessionId: session.id, text: "hi" }); - insertChatBlock(config.instance, { kind: "user_text", sessionId: session.id, text: "still hi" }); - insertChatBlock(config.instance, { kind: "user_text", sessionId: session.id, text: "ok last one" }); - const assistant = insertChatBlock(config.instance, { - kind: "assistant_text", - sessionId: session.id, - text: "hello back", - streaming: false - }); - - // Both devices catch up first so the baseline badge is 0. - await call(handler, config, `/api/chat/${session.id}/read`, { - method: "POST", headers: headerA, body: JSON.stringify({ lastReadBlockId: assistant.id }) - }); - await call(handler, config, `/api/chat/${session.id}/read`, { - method: "POST", headers: headerB, body: JSON.stringify({ lastReadBlockId: assistant.id }) - }); - expect((await call(handler, config, "/api/badge", { headers: headerA })).unread).toBe(0); - - // iPhone A swipes "Mark unread". The badge surfaces just the - // latest assistant turn (1), not the full session. - const cleared = await call(handler, config, `/api/chat/${session.id}/read`, { - method: "DELETE", headers: headerA - }); - expect(cleared.ok).toBe(true); - expect((await call(handler, config, "/api/badge", { headers: headerA })).unread).toBe(1); - // iPhone B is unaffected (still caught up). - expect((await call(handler, config, "/api/badge", { headers: headerB })).unread).toBe(0); - - // Idempotent — replaying lands on the same cursor; still 1. - const second = await call(handler, config, `/api/chat/${session.id}/read`, { - method: "DELETE", headers: headerA - }); - expect(second.ok).toBe(true); - expect((await call(handler, config, "/api/badge", { headers: headerA })).unread).toBe(1); - - // Unknown session: 404. - const noSession = await rawCall( - handler, - config, - "/api/chat/chat_nonexistent/read", - { method: "DELETE", headers: headerA }, - config.token - ); - expect(noSession.status).toBe(404); - - // Missing X-Device-Token: 400. - const noDevice = await rawCall( - handler, - config, - `/api/chat/${session.id}/read`, - { method: "DELETE" }, - config.token - ); - expect(noDevice.status).toBe(400); - }); - - test("GET /api/unread returns per-session unread counts scoped to the device", async () => { - const config = testConfig("chat-unread-counts"); - const handler = createHandler(config); - await call(handler, config, "/api/push/devices", { - method: "POST", - body: JSON.stringify({ token: "tok_iphone_a", platform: "ios", bundleId: "ai.lilaclabs.gini.mobile" }) - }); - await call(handler, config, "/api/push/devices", { - method: "POST", - body: JSON.stringify({ token: "tok_iphone_b", platform: "ios", bundleId: "ai.lilaclabs.gini.mobile" }) - }); - const headerA = { "x-device-token": "tok_iphone_a" }; - const headerB = { "x-device-token": "tok_iphone_b" }; - const sessionA = await call(handler, config, "/api/chat", { - method: "POST", - body: JSON.stringify({ title: "A" }) - }); - const sessionB = await call(handler, config, "/api/chat", { - method: "POST", - body: JSON.stringify({ title: "B" }) - }); - const { insertChatBlock } = await import("./state"); - insertChatBlock(config.instance, { kind: "user_text", sessionId: sessionA.id, text: "1" }); - const a2 = insertChatBlock(config.instance, { - kind: "user_text", - sessionId: sessionA.id, - text: "2" - }); - insertChatBlock(config.instance, { kind: "user_text", sessionId: sessionB.id, text: "3" }); - - // Fresh device A — both sessions show their full count. - const initial = await call(handler, config, "/api/unread", { headers: headerA }); - expect(initial.counts[sessionA.id]).toBe(2); - expect(initial.counts[sessionB.id]).toBe(1); - - // A catches up on session A — it drops out of the map for A. - await call(handler, config, `/api/chat/${sessionA.id}/read`, { - method: "POST", - headers: headerA, - body: JSON.stringify({ lastReadBlockId: a2.id }) - }); - const after = await call(handler, config, "/api/unread", { headers: headerA }); - expect(after.counts[sessionA.id]).toBeUndefined(); - expect(after.counts[sessionB.id]).toBe(1); - - // Device B is unaffected. - const bView = await call(handler, config, "/api/unread", { headers: headerB }); - expect(bView.counts[sessionA.id]).toBe(2); - expect(bView.counts[sessionB.id]).toBe(1); - - // Missing X-Device-Token: 400. - const noDevice = await rawCall( - handler, - config, - "/api/unread", - {}, - config.token - ); - expect(noDevice.status).toBe(400); - - // Unauth: 401. - const noAuth = await rawCall(handler, config, "/api/unread"); - expect(noAuth.status).toBe(401); - }); - - test("read + badge endpoints require authentication", async () => { - const config = testConfig("chat-read-auth"); - const handler = createHandler(config); - const read = await rawCall(handler, config, "/api/chat/chat_x/read", { - method: "POST", - body: JSON.stringify({ lastReadBlockId: "block_x" }) - }); - expect(read.status).toBe(401); - const badge = await rawCall(handler, config, "/api/badge"); - expect(badge.status).toBe(401); - }); - - test("push device endpoints require authentication", async () => { - const config = testConfig("push-devices-auth"); - const handler = createHandler(config); - - const post = await rawCall(handler, config, "/api/push/devices", { - method: "POST", - body: JSON.stringify({ token: "tok", platform: "ios", bundleId: "ai.lilaclabs.gini.mobile" }) - }); - expect(post.status).toBe(401); - - const del = await rawCall(handler, config, "/api/push/devices/tok", { method: "DELETE" }); - expect(del.status).toBe(401); - }); - }); - - describe("push preview endpoint", () => { - test("GET /api/push/preview returns the latest assistant reply for a completed message", async () => { - const config = testConfig("push-preview-message"); - const handler = createHandler(config); - const session = await call(handler, config, "/api/chat", { - method: "POST", - body: JSON.stringify({ title: "Morning briefing" }) - }); - const submitted = await call(handler, config, `/api/chat/${session.id}/messages`, { - method: "POST", - body: JSON.stringify({ content: "what's up" }) - }); - await waitForTask(handler, config, submitted.taskId); - - const preview = await call( - handler, - config, - `/api/push/preview?sessionId=${session.id}&event=message_completed` - ); - expect(preview.title).toBe("Morning briefing"); - // The echo provider replies with the user's text; the body must be - // the actual reply, NOT a generic "Tap to read" string. - expect(typeof preview.body).toBe("string"); - expect(preview.body.length).toBeGreaterThan(0); - expect(preview.body).not.toBe("Tap to read"); - }); - - test("GET /api/push/preview 404s when the session has no assistant message yet", async () => { - const config = testConfig("push-preview-empty"); - const handler = createHandler(config); - const session = await call(handler, config, "/api/chat", { - method: "POST", - body: JSON.stringify({ title: "Empty chat" }) - }); - const res = await rawCall( - handler, - config, - `/api/push/preview?sessionId=${session.id}&event=message_completed`, - {}, - config.token - ); - expect(res.status).toBe(404); - }); - - test("GET /api/push/preview surfaces a pending authorization's risk + summary", async () => { - const config = testConfig("push-preview-approval"); - const handler = createHandler(config); - const session = await call(handler, config, "/api/chat", { - method: "POST", - body: JSON.stringify({ title: "Deploy bot" }) - }); - const { createAuthorization } = await import("./state"); - const approval = await mutateState(config.instance, (state) => - createAuthorization(state, { - action: "terminal.exec", - target: "rm -rf build", - risk: "high", - reason: "Clear the stale build cache", - payload: {} - }) - ); - - const preview = await call( - handler, - config, - `/api/push/preview?sessionId=${session.id}&event=authorization_requested&approvalId=${approval.id}` - ); - expect(preview.title).toBe("Approve in Deploy bot?"); - expect(preview.body).toBe("[high] Clear the stale build cache"); - }); - - test("GET /api/push/preview surfaces a pending setup request's ask", async () => { - const config = testConfig("push-preview-setup"); - const handler = createHandler(config); - const session = await call(handler, config, "/api/chat", { - method: "POST", - body: JSON.stringify({ title: "Email watch" }) - }); - const { createSetupRequest } = await import("./state"); - const setup = await mutateState(config.instance, (state) => - createSetupRequest(state, { - action: "browser.connect", - target: "https://example.com/login", - reason: "Sign in to your email provider", - payload: {} - }) - ); - - const preview = await call( - handler, - config, - `/api/push/preview?sessionId=${session.id}&event=setup_requested&approvalId=${setup.id}` - ); - expect(preview.title).toBe("Finish a step in Email watch"); - expect(preview.body).toBe("Sign in to your email provider"); - }); - - test("GET /api/push/preview validates inputs and auth", async () => { - const config = testConfig("push-preview-validation"); - const handler = createHandler(config); - const session = await call(handler, config, "/api/chat", { - method: "POST", - body: JSON.stringify({ title: "Validation chat" }) - }); - - // Unauthenticated. - const noAuth = await rawCall( - handler, - config, - `/api/push/preview?sessionId=${session.id}&event=message_completed` - ); - expect(noAuth.status).toBe(401); - - // Missing sessionId. - const noSession = await rawCall( - handler, - config, - `/api/push/preview?event=message_completed`, - {}, - config.token - ); - expect(noSession.status).toBe(400); - - // Unknown event. - const badEvent = await rawCall( - handler, - config, - `/api/push/preview?sessionId=${session.id}&event=bogus`, - {}, - config.token - ); - expect(badEvent.status).toBe(400); - - // Unknown session. - const badSession = await rawCall( - handler, - config, - `/api/push/preview?sessionId=chat_nope&event=message_completed`, - {}, - config.token - ); - expect(badSession.status).toBe(404); - }); - - test("POST /api/push/unwatch (no sessionId) clears the whole device bucket", async () => { - const config = testConfig("push-unwatch"); - const handler = createHandler(config); - // Register the device so requireDeviceToken accepts the header. - await call(handler, config, "/api/push/devices", { - method: "POST", - body: JSON.stringify({ token: "tok_unwatch", platform: "ios", bundleId: "ai.lilaclabs.gini.mobile" }) - }); - const { addSseSubscription, isDeviceWatching } = await import("./state"); - const { __resetSseSubscriptionsForTests } = await import("./state/sse-subscriptions"); - try { - // Seed two watched sessions for this device (as if it had opened - // two chats), plus one for a different device that must survive. - addSseSubscription(config.instance, "tok_unwatch", "chat_a"); - addSseSubscription(config.instance, "tok_unwatch", "chat_b"); - addSseSubscription(config.instance, "tok_other", "chat_a"); - expect(isDeviceWatching(config.instance, "tok_unwatch", "chat_a")).toBe(true); - - // No sessionId → background beacon → clear everything for the device. - const res = await call(handler, config, "/api/push/unwatch", { - method: "POST", - headers: { "x-device-token": "tok_unwatch" } - }); - expect(res.ok).toBe(true); - expect(res.cleared).toBe(2); - expect(isDeviceWatching(config.instance, "tok_unwatch", "chat_a")).toBe(false); - expect(isDeviceWatching(config.instance, "tok_unwatch", "chat_b")).toBe(false); - // The other device is untouched. - expect(isDeviceWatching(config.instance, "tok_other", "chat_a")).toBe(true); - } finally { - // Don't leak seeded entries into the process-wide registry. - __resetSseSubscriptionsForTests(); - } - }); - - test("POST /api/push/unwatch?sessionId clears only that session", async () => { - const config = testConfig("push-unwatch-session"); - const handler = createHandler(config); - await call(handler, config, "/api/push/devices", { - method: "POST", - body: JSON.stringify({ token: "tok_nav", platform: "ios", bundleId: "ai.lilaclabs.gini.mobile" }) - }); - const { addSseSubscription, isDeviceWatching } = await import("./state"); - const { __resetSseSubscriptionsForTests } = await import("./state/sse-subscriptions"); - try { - // Device watches two chats; navigating away from chat_a must leave - // chat_b watched (the just-opened chat mustn't be race-cleared). - addSseSubscription(config.instance, "tok_nav", "chat_a"); - addSseSubscription(config.instance, "tok_nav", "chat_b"); - - const res = await call(handler, config, "/api/push/unwatch?sessionId=chat_a", { - method: "POST", - headers: { "x-device-token": "tok_nav" } - }); - expect(res.ok).toBe(true); - expect(res.cleared).toBe(1); - expect(isDeviceWatching(config.instance, "tok_nav", "chat_a")).toBe(false); - expect(isDeviceWatching(config.instance, "tok_nav", "chat_b")).toBe(true); - } finally { - __resetSseSubscriptionsForTests(); - } - }); - - test("POST /api/push/unwatch?sessionId&streamId clears only that stream, not a sibling on the same session", async () => { - const config = testConfig("push-unwatch-stream"); - const handler = createHandler(config); - await call(handler, config, "/api/push/devices", { - method: "POST", - body: JSON.stringify({ token: "tok_stream", platform: "ios", bundleId: "ai.lilaclabs.gini.mobile" }) - }); - const { addSseSubscription, isDeviceWatching } = await import("./state"); - const { __resetSseSubscriptionsForTests } = await import("./state/sse-subscriptions"); - try { - // Thread View (card over the main chat) and the main chat both open - // a stream on the SAME session, each with its own streamId. Tearing - // down the thread must leave the main chat's watch intact. - addSseSubscription(config.instance, "tok_stream", "chat_a", "stream_main"); - addSseSubscription(config.instance, "tok_stream", "chat_a", "stream_thread"); - - const res = await call( - handler, - config, - "/api/push/unwatch?sessionId=chat_a&streamId=stream_thread", - { method: "POST", headers: { "x-device-token": "tok_stream" } } - ); - expect(res.ok).toBe(true); - expect(res.cleared).toBe(1); - // The main chat's stream is still registered → session still watched. - expect(isDeviceWatching(config.instance, "tok_stream", "chat_a")).toBe(true); - } finally { - __resetSseSubscriptionsForTests(); - } - }); - - test("POST /api/push/unwatch requires auth + a registered device token", async () => { - const config = testConfig("push-unwatch-auth"); - const handler = createHandler(config); - // Unauthenticated. - const noAuth = await rawCall(handler, config, "/api/push/unwatch", { method: "POST" }); - expect(noAuth.status).toBe(401); - // Authenticated but no X-Device-Token header. - const noDevice = await rawCall(handler, config, "/api/push/unwatch", { method: "POST" }, config.token); - expect(noDevice.status).toBe(400); - // Authenticated with an unregistered device token. - const badDevice = await rawCall( - handler, - config, - "/api/push/unwatch", - { method: "POST", headers: { "x-device-token": "tok_ghost" } }, - config.token - ); - expect(badDevice.status).toBe(403); - }); - }); - - describe("cors", () => { - // Save/restore the env override so individual cases don't leak. - function withEnv(value: string | undefined, fn: () => Promise): Promise { - const prior = process.env.GINI_CORS_ORIGINS; - if (value === undefined) delete process.env.GINI_CORS_ORIGINS; - else process.env.GINI_CORS_ORIGINS = value; - return fn().finally(() => { - if (prior === undefined) delete process.env.GINI_CORS_ORIGINS; - else process.env.GINI_CORS_ORIGINS = prior; - }); - } - - test("preflight from an allowed origin returns 204 with CORS headers", async () => { - await withEnv(undefined, async () => { - const config = testConfig("cors-preflight-allowed"); - const handler = createHandler(config); - const response = await handler(new Request(`http://127.0.0.1:${config.port}/api/status`, { - method: "OPTIONS", - headers: { - origin: "http://localhost:8090", - "access-control-request-method": "GET", - "access-control-request-headers": "authorization" - } - })); - expect(response.status).toBe(204); - expect(response.headers.get("access-control-allow-origin")).toBe("http://localhost:8090"); - expect(response.headers.get("access-control-allow-credentials")).toBe("true"); - expect(response.headers.get("vary")).toBe("Origin"); - expect(response.headers.get("access-control-allow-methods")).toContain("GET"); - expect(response.headers.get("access-control-allow-methods")).toContain("POST"); - expect(response.headers.get("access-control-allow-headers") ?? "").toContain("Authorization"); - expect(response.headers.get("access-control-allow-headers") ?? "").toContain("X-Device-Token"); - expect(response.headers.get("access-control-allow-headers") ?? "").toContain("Last-Event-ID"); - expect(response.headers.get("access-control-max-age")).toBe("600"); - }); - }); - - test("preflight from a disallowed origin returns 204 without allow-origin", async () => { - await withEnv(undefined, async () => { - const config = testConfig("cors-preflight-disallowed"); - const handler = createHandler(config); - const response = await handler(new Request(`http://127.0.0.1:${config.port}/api/status`, { - method: "OPTIONS", - headers: { - origin: "http://evil.example.com", - "access-control-request-method": "GET" - } - })); - expect(response.status).toBe(204); - expect(response.headers.get("access-control-allow-origin")).toBeNull(); - // The protocol-level headers still go out — they describe what - // the server *would* accept; the browser rejects because of the - // missing allow-origin. - expect(response.headers.get("access-control-allow-methods")).toContain("GET"); - }); - }); - - test("normal GET from an allowed origin gets CORS headers", async () => { - await withEnv(undefined, async () => { - const config = testConfig("cors-get-allowed"); - const handler = createHandler(config); - const response = await handler(new Request(`http://127.0.0.1:${config.port}/api/status`, { - headers: { - origin: "http://localhost:3045", - authorization: `Bearer ${config.token}` - } - })); - expect(response.status).toBe(200); - expect(response.headers.get("access-control-allow-origin")).toBe("http://localhost:3045"); - expect(response.headers.get("access-control-allow-credentials")).toBe("true"); - expect(response.headers.get("vary")).toBe("Origin"); - expect(response.headers.get("access-control-expose-headers")).toBe("Last-Event-ID"); - }); - }); - - test("non-browser caller without Origin gets no CORS headers", async () => { - await withEnv(undefined, async () => { - const config = testConfig("cors-no-origin"); - const handler = createHandler(config); - const response = await handler(new Request(`http://127.0.0.1:${config.port}/api/status`, { - headers: { authorization: `Bearer ${config.token}` } - })); - expect(response.status).toBe(200); - expect(response.headers.get("access-control-allow-origin")).toBeNull(); - expect(response.headers.get("vary")).toBeNull(); - }); - }); - - test("401 responses still carry CORS headers so the browser sees the status", async () => { - await withEnv(undefined, async () => { - const config = testConfig("cors-401"); - const handler = createHandler(config); - const response = await handler(new Request(`http://127.0.0.1:${config.port}/api/status`, { - headers: { origin: "http://localhost:8090" } // no Authorization - })); - expect(response.status).toBe(401); - expect(response.headers.get("access-control-allow-origin")).toBe("http://localhost:8090"); - }); - }); - - test("GINI_CORS_ORIGINS env var overrides the default allowlist", async () => { - await withEnv("https://example.com", async () => { - const config = testConfig("cors-custom-env"); - const handler = createHandler(config); - - const allowed = await handler(new Request(`http://127.0.0.1:${config.port}/api/status`, { - headers: { - origin: "https://example.com", - authorization: `Bearer ${config.token}` - } - })); - expect(allowed.headers.get("access-control-allow-origin")).toBe("https://example.com"); - - // The defaults (localhost:8090, etc) should NOT be honored when - // the env var is set — it's a full override, not an additive list. - const denied = await handler(new Request(`http://127.0.0.1:${config.port}/api/status`, { - headers: { - origin: "http://localhost:8090", - authorization: `Bearer ${config.token}` - } - })); - expect(denied.headers.get("access-control-allow-origin")).toBeNull(); - }); - }); - }); -}); - -describe("GET /api/files", () => { - test("returns content, absolute path, and name for an existing text file", async () => { - const config = testConfig("files-read-ok"); - const workspace = `/tmp/gini-files-test-${Date.now()}`; - mkdirSync(workspace, { recursive: true }); - config.workspaceRoot = workspace; - writeFileSync(`${workspace}/note.md`, "# Hello\n"); - const handler = createHandler(config); - - const file = await call(handler, config, "/api/files?path=note.md"); - expect(file.name).toBe("note.md"); - expect(file.absolutePath).toBe(`${workspace}/note.md`); - expect(file.content).toBe("# Hello\n"); - expect(file.binary).toBe(false); - expect(file.truncated).toBe(false); - - rmSync(workspace, { recursive: true, force: true }); - }); - - test("rejects a path that escapes the workspace with 400", async () => { - const config = testConfig("files-escape-400"); - const workspace = `/tmp/gini-files-test-${Date.now()}-escape`; - mkdirSync(workspace, { recursive: true }); - config.workspaceRoot = workspace; - const handler = createHandler(config); - - const response = await rawCall(handler, config, "/api/files?path=../outside.txt", {}, config.token); - expect(response.status).toBe(400); - const body = await response.json(); - expect(body.error).toContain("outside workspace"); - - rmSync(workspace, { recursive: true, force: true }); - }); - - test("returns 404 for a non-existent file", async () => { - const config = testConfig("files-missing-404"); - const workspace = `/tmp/gini-files-test-${Date.now()}-missing`; - mkdirSync(workspace, { recursive: true }); - config.workspaceRoot = workspace; - const handler = createHandler(config); - - const response = await rawCall(handler, config, "/api/files?path=nope.txt", {}, config.token); - expect(response.status).toBe(404); - const body = await response.json(); - expect(body.error).toBe("File not found"); - - rmSync(workspace, { recursive: true, force: true }); - }); - - test("reports a binary file without returning its content", async () => { - const config = testConfig("files-binary"); - const workspace = `/tmp/gini-files-test-${Date.now()}-binary`; - mkdirSync(workspace, { recursive: true }); - config.workspaceRoot = workspace; - writeFileSync(`${workspace}/blob.bin`, Buffer.from([0x89, 0x50, 0x00, 0x01])); - const handler = createHandler(config); - - const file = await call(handler, config, "/api/files?path=blob.bin"); - expect(file.binary).toBe(true); - expect(file.content).toBe(null); - expect(file.bytes).toBe(4); - - rmSync(workspace, { recursive: true, force: true }); - }); - - test("returns 400 for a directory", async () => { - const config = testConfig("files-directory"); - const workspace = `/tmp/gini-files-test-${Date.now()}-directory`; - mkdirSync(workspace, { recursive: true }); - config.workspaceRoot = workspace; - mkdirSync(`${workspace}/sub`); - const handler = createHandler(config); - - const response = await rawCall(handler, config, "/api/files?path=sub", {}, config.token); - expect(response.status).toBe(400); - const body = await response.json(); - expect(body.error).toBe("Not a file"); - - rmSync(workspace, { recursive: true, force: true }); - }); - - test("truncates a file larger than the read cap", async () => { - const config = testConfig("files-truncate"); - const workspace = `/tmp/gini-files-test-${Date.now()}-truncate`; - mkdirSync(workspace, { recursive: true }); - config.workspaceRoot = workspace; - writeFileSync(`${workspace}/big.txt`, "a".repeat(600 * 1024)); - const handler = createHandler(config); - - const file = await call(handler, config, "/api/files?path=big.txt"); - expect(file.truncated).toBe(true); - expect(file.content.length).toBe(512 * 1024); - expect(file.bytes).toBe(600 * 1024); - - rmSync(workspace, { recursive: true, force: true }); - }); - - test("raw=1 streams the file as a download attachment", async () => { - const config = testConfig("files-raw"); - const workspace = `/tmp/gini-files-test-${Date.now()}-raw`; - mkdirSync(workspace, { recursive: true }); - config.workspaceRoot = workspace; - writeFileSync(`${workspace}/note.md`, "# Hello\nworld\n"); - const handler = createHandler(config); - - const response = await rawCall(handler, config, "/api/files?path=note.md&raw=1", {}, config.token); - expect(response.status).toBe(200); - const disposition = response.headers.get("content-disposition") ?? ""; - expect(disposition).toContain("attachment"); - expect(disposition).toContain("note.md"); - expect(await response.text()).toBe("# Hello\nworld\n"); - - rmSync(workspace, { recursive: true, force: true }); - }); - - test("raw=1 download for a non-ASCII filename returns 200 with both header forms", async () => { - const config = testConfig("files-raw-unicode"); - const workspace = `/tmp/gini-files-test-${Date.now()}-raw-unicode`; - mkdirSync(workspace, { recursive: true }); - config.workspaceRoot = workspace; - writeFileSync(`${workspace}/café.md`, "# Hello\n"); - const handler = createHandler(config); - - const response = await rawCall(handler, config, `/api/files?path=${encodeURIComponent("café.md")}&raw=1`, {}, config.token); - expect(response.status).toBe(200); - const disposition = response.headers.get("content-disposition") ?? ""; - expect(disposition).toContain(`filename="caf_.md"`); - expect(disposition).toContain("filename*=UTF-8''"); - - rmSync(workspace, { recursive: true, force: true }); - }); - - test("inline=1 serves a PDF inline with the application/pdf content-type", async () => { - const config = testConfig("files-inline-pdf"); - const workspace = `/tmp/gini-files-test-${Date.now()}-inline-pdf`; - mkdirSync(workspace, { recursive: true }); - config.workspaceRoot = workspace; - writeFileSync(`${workspace}/doc.pdf`, Buffer.from("%PDF-1.4\n")); - const handler = createHandler(config); - - const response = await rawCall(handler, config, "/api/files?path=doc.pdf&raw=1&inline=1", {}, config.token); - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toBe("application/pdf"); - expect(response.headers.get("content-disposition")).toBe("inline"); - - rmSync(workspace, { recursive: true, force: true }); - }); - - test("inline=1 serves a PNG inline with the image/png content-type", async () => { - const config = testConfig("files-inline-png"); - const workspace = `/tmp/gini-files-test-${Date.now()}-inline-png`; - mkdirSync(workspace, { recursive: true }); - config.workspaceRoot = workspace; - writeFileSync(`${workspace}/pic.png`, Buffer.from([0x89, 0x50, 0x4e, 0x47])); - const handler = createHandler(config); - - const response = await rawCall(handler, config, "/api/files?path=pic.png&raw=1&inline=1", {}, config.token); - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toBe("image/png"); - expect(response.headers.get("content-disposition")).toBe("inline"); - - rmSync(workspace, { recursive: true, force: true }); - }); - - test("inline=1 never serves an SVG inline — falls back to attachment download", async () => { - const config = testConfig("files-inline-svg"); - const workspace = `/tmp/gini-files-test-${Date.now()}-inline-svg`; - mkdirSync(workspace, { recursive: true }); - config.workspaceRoot = workspace; - writeFileSync(`${workspace}/evil.svg`, ""); - const handler = createHandler(config); - - const response = await rawCall(handler, config, "/api/files?path=evil.svg&raw=1&inline=1", {}, config.token); - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toBe("application/octet-stream"); - expect(response.headers.get("content-disposition") ?? "").toContain("attachment"); - - rmSync(workspace, { recursive: true, force: true }); - }); - - test("inline=1 never serves HTML inline — falls back to attachment download", async () => { - const config = testConfig("files-inline-html"); - const workspace = `/tmp/gini-files-test-${Date.now()}-inline-html`; - mkdirSync(workspace, { recursive: true }); - config.workspaceRoot = workspace; - writeFileSync(`${workspace}/evil.html`, ""); - const handler = createHandler(config); - - const response = await rawCall(handler, config, "/api/files?path=evil.html&raw=1&inline=1", {}, config.token); - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toBe("application/octet-stream"); - expect(response.headers.get("content-disposition") ?? "").toContain("attachment"); - - rmSync(workspace, { recursive: true, force: true }); - }); - - // The upload gate accepts any plausible MIME, not just images/audio. These - // build the multipart request directly (not via call/rawCall, which pin - // content-type: application/json) so FormData sets its own multipart - // boundary header. - test("POST /api/uploads accepts an application/pdf file and serves it back", async () => { - const config = testConfig("uploads-pdf"); - const handler = createHandler(config); - const form = new FormData(); - form.set("file", new File([new Uint8Array([0x25, 0x50, 0x44, 0x46])], "report.pdf", { type: "application/pdf" })); - const response = await handler(new Request(`http://127.0.0.1:${config.port}/api/uploads`, { - method: "POST", - headers: { authorization: `Bearer ${config.token}` }, - body: form - })); - expect(response.status).toBe(201); - const ref = await response.json(); - expect(ref.mimeType).toBe("application/pdf"); - - const fetched = await rawCall(handler, config, `/api/uploads/${ref.id}`, {}, config.token); - expect(fetched.status).toBe(200); - expect(fetched.headers.get("content-type")).toBe("application/pdf"); - // Arbitrary MIME is accepted, so served uploads are forced to download and - // never sniffed — a text/html or SVG upload can't execute on the app origin. - expect(fetched.headers.get("content-disposition")).toBe("attachment"); - expect(fetched.headers.get("x-content-type-options")).toBe("nosniff"); - // The full-body response advertises range support so a media client knows - // it may stream via byte ranges (iOS AVPlayer requires this to play remote - // audio at all). - expect(fetched.headers.get("accept-ranges")).toBe("bytes"); - }); - - // `?inline=1` opts a safe-allowlisted upload into content-disposition: inline - // so a file/PDF chip can open it as a preview in a browser tab instead of - // forcing a download. These pin the per-mime allowlist decisions (the SSRF / - // top-level-script surface lives in the unsafe branches). - test("GET /api/uploads?inline=1 serves a PDF inline with its real type", async () => { - const config = testConfig("uploads-inline-pdf"); - const handler = createHandler(config); - const ref = storeUpload(config.instance, new Uint8Array([0x25, 0x50, 0x44, 0x46]), "application/pdf", "report.pdf"); - const res = await rawCall(handler, config, `/api/uploads/${ref.id}?inline=1`, {}, config.token); - expect(res.status).toBe(200); - expect(res.headers.get("content-type")).toBe("application/pdf"); - expect(res.headers.get("content-disposition")).toBe("inline"); - expect(res.headers.get("x-content-type-options")).toBe("nosniff"); - }); - - test("GET /api/uploads?inline=1 serves a PNG inline with its real type", async () => { - const config = testConfig("uploads-inline-png"); - const handler = createHandler(config); - const ref = storeUpload(config.instance, new Uint8Array([0x89, 0x50, 0x4e, 0x47]), "image/png", "shot.png"); - const res = await rawCall(handler, config, `/api/uploads/${ref.id}?inline=1`, {}, config.token); - expect(res.status).toBe(200); - expect(res.headers.get("content-type")).toBe("image/png"); - expect(res.headers.get("content-disposition")).toBe("inline"); - }); - - test("GET /api/uploads?inline=1 coerces a markdown upload to text/plain inline", async () => { - const config = testConfig("uploads-inline-md"); - const handler = createHandler(config); - const ref = storeUpload(config.instance, new TextEncoder().encode("# Title\n"), "text/markdown", "notes.md"); - const res = await rawCall(handler, config, `/api/uploads/${ref.id}?inline=1`, {}, config.token); - expect(res.status).toBe(200); - // Coerced to text/plain so the browser shows raw text rather than - // interpreting/executing the payload as a document. - expect(res.headers.get("content-type")).toBe("text/plain; charset=utf-8"); - expect(res.headers.get("content-disposition")).toBe("inline"); - }); - - test("GET /api/uploads?inline=1 never serves an SVG inline — keeps attachment", async () => { - const config = testConfig("uploads-inline-svg"); - const handler = createHandler(config); - const ref = storeUpload(config.instance, new TextEncoder().encode(""), "image/svg+xml", "x.svg"); - const res = await rawCall(handler, config, `/api/uploads/${ref.id}?inline=1`, {}, config.token); - expect(res.status).toBe(200); - expect(res.headers.get("content-type")).toBe("image/svg+xml"); - expect(res.headers.get("content-disposition")).toBe("attachment"); - }); - - test("GET /api/uploads?inline=1 never serves HTML inline — keeps attachment", async () => { - const config = testConfig("uploads-inline-html"); - const handler = createHandler(config); - const ref = storeUpload(config.instance, new TextEncoder().encode("

x

"), "text/html", "x.html"); - const res = await rawCall(handler, config, `/api/uploads/${ref.id}?inline=1`, {}, config.token); - expect(res.status).toBe(200); - expect(res.headers.get("content-disposition")).toBe("attachment"); - }); - - test("resolveInlineUpload returns null without the inline param and gates by mime", () => { - // No inline param → always download, regardless of a safe mime. - expect(resolveInlineUpload(null, "application/pdf")).toBeNull(); - expect(resolveInlineUpload("", "application/pdf")).toBeNull(); - // Safe direct image/pdf → its own type. - expect(resolveInlineUpload("1", "application/pdf")).toEqual({ contentType: "application/pdf" }); - expect(resolveInlineUpload("1", "image/jpeg")).toEqual({ contentType: "image/jpeg" }); - // Text-like → coerced to text/plain. - expect(resolveInlineUpload("1", "text/csv")).toEqual({ contentType: "text/plain; charset=utf-8" }); - expect(resolveInlineUpload("1", "application/json")).toEqual({ contentType: "text/plain; charset=utf-8" }); - // Unsafe / unknown → null (download). - expect(resolveInlineUpload("1", "image/svg+xml")).toBeNull(); - expect(resolveInlineUpload("1", "text/html")).toBeNull(); - expect(resolveInlineUpload("1", "application/octet-stream")).toBeNull(); - }); - - // A mobile in-app browser can't send the bearer header or the gateway cookie, - // so the app mints a short-lived SIGNED url (POST /api/uploads/:id/sign) and - // opens that. These pin: the mint is bearer-gated, the signed url authorizes a - // header-less GET, and the signature is scoped + expiring. - test("POST /api/uploads/:id/sign mints a signed path that authorizes a header-less GET", async () => { - const config = testConfig("uploads-sign-ok"); - const handler = createHandler(config); - const ref = storeUpload(config.instance, new Uint8Array([0x25, 0x50, 0x44, 0x46]), "application/pdf", "report.pdf"); - - const minted = await rawCall(handler, config, `/api/uploads/${ref.id}/sign`, { method: "POST" }, config.token); - expect(minted.status).toBe(200); - const body = await minted.json(); - expect(body.path).toContain(`/api/uploads/${ref.id}?inline=1`); - expect(body.path).toMatch(/[?&]exp=\d+/); - expect(body.path).toMatch(/[?&]sig=[0-9a-f]{64}/); - expect(typeof body.exp).toBe("number"); - - // The signed path authorizes a GET with NO Authorization header. - const got = await rawCall(handler, config, body.path, {}); - expect(got.status).toBe(200); - expect(got.headers.get("content-type")).toBe("application/pdf"); - expect(got.headers.get("content-disposition")).toBe("inline"); - }); - - test("the mint endpoint itself still requires a bearer", async () => { - const config = testConfig("uploads-sign-gated"); - const handler = createHandler(config); - const ref = storeUpload(config.instance, new Uint8Array([1, 2, 3]), "application/pdf", "x.pdf"); - const res = await rawCall(handler, config, `/api/uploads/${ref.id}/sign`, { method: "POST" }); // no token - expect(res.status).toBe(401); - }); - - test("signing an unknown upload id returns 404 (no signature for absent bytes)", async () => { - const config = testConfig("uploads-sign-404"); - const handler = createHandler(config); - const res = await rawCall(handler, config, `/api/uploads/does-not-exist/sign`, { method: "POST" }, config.token); - expect(res.status).toBe(404); - }); - - test("an unsigned, unauthenticated upload GET is rejected 401", async () => { - const config = testConfig("uploads-sign-unsigned"); - const handler = createHandler(config); - const ref = storeUpload(config.instance, new Uint8Array([1, 2, 3]), "application/pdf", "x.pdf"); - const res = await rawCall(handler, config, `/api/uploads/${ref.id}?inline=1`, {}); // no token, no sig - expect(res.status).toBe(401); - }); - - test("a tampered upload id on a signed url fails (signature is scoped to one id)", async () => { - const config = testConfig("uploads-sign-scope"); - const handler = createHandler(config); - const a = storeUpload(config.instance, new Uint8Array([1, 2, 3]), "application/pdf", "a.pdf"); - const b = storeUpload(config.instance, new Uint8Array([4, 5, 6]), "application/pdf", "b.pdf"); - const minted = await rawCall(handler, config, `/api/uploads/${a.id}/sign`, { method: "POST" }, config.token); - const { path } = await minted.json(); - // Swap a's id for b's id but keep a's signature → must fail. - const sig = new URL(`http://x${path}`).searchParams.get("sig"); - const exp = new URL(`http://x${path}`).searchParams.get("exp"); - const forged = `/api/uploads/${b.id}?inline=1&exp=${exp}&sig=${sig}`; - const res = await rawCall(handler, config, forged, {}); - expect(res.status).toBe(401); - }); - - test("an expired signature is rejected 401", async () => { - const config = testConfig("uploads-sign-expired"); - const handler = createHandler(config); - const ref = storeUpload(config.instance, new Uint8Array([1, 2, 3]), "application/pdf", "x.pdf"); - // Mint with the minimum TTL clamp (30s), then forge an already-past exp by - // re-signing with a past timestamp using the same module the gateway uses. - const pastExp = Math.floor(Date.now() / 1000) - 10; - const { signUploadParams } = await import("./lib/upload-signing"); - const { sig } = signUploadParams(config.token, ref.id, pastExp); - const res = await rawCall(handler, config, `/api/uploads/${ref.id}?inline=1&exp=${pastExp}&sig=${sig}`, {}); - expect(res.status).toBe(401); - }); - - test("a caller-supplied ?ttl= is honored and clamped to the [30,600]s ceiling", async () => { - const config = testConfig("uploads-sign-ttl"); - const handler = createHandler(config); - const ref = storeUpload(config.instance, new Uint8Array([0x25, 0x50, 0x44, 0x46]), "application/pdf", "x.pdf"); - - // An in-range ttl rides through unchanged; an over-max one clamps to 600. - // The exp the mint returns is now + clampedTtl, so assert exp lands within a - // small window of that target (a couple seconds for clock drift across the call). - const now = Math.floor(Date.now() / 1000); - const inRange = await rawCall(handler, config, `/api/uploads/${ref.id}/sign?ttl=120`, { method: "POST" }, config.token); - expect(inRange.status).toBe(200); - expect((await inRange.json()).exp - now).toBeLessThanOrEqual(122); - - const clamped = await rawCall(handler, config, `/api/uploads/${ref.id}/sign?ttl=99999`, { method: "POST" }, config.token); - expect(clamped.status).toBe(200); - const clampedExp = (await clamped.json()).exp - Math.floor(Date.now() / 1000); - expect(clampedExp).toBeGreaterThan(595); - expect(clampedExp).toBeLessThanOrEqual(600); - }); - - test("a signed HEAD is also authorized (in-app browser preflight)", async () => { - const config = testConfig("uploads-sign-head"); - const handler = createHandler(config); - const ref = storeUpload(config.instance, new Uint8Array([0x25, 0x50, 0x44, 0x46]), "application/pdf", "x.pdf"); - const minted = await rawCall(handler, config, `/api/uploads/${ref.id}/sign`, { method: "POST" }, config.token); - const { path } = await minted.json(); - const res = await rawCall(handler, config, path, { method: "HEAD" }); - expect(res.status).toBe(200); - }); - - // iOS AVPlayer won't start a remote audio AVURLAsset unless the server honors - // Range requests (206 + Content-Range). These pin the range semantics of the - // upload GET so a regression can't silently break voice-message playback. - test("GET /api/uploads honors a bounded Range with 206 + Content-Range", async () => { - const config = testConfig("uploads-range-bounded"); - const handler = createHandler(config); - const bytes = new Uint8Array(Array.from({ length: 100 }, (_, i) => i)); - const ref = storeUpload(config.instance, bytes, "audio/wav"); - - const res = await rawCall(handler, config, `/api/uploads/${ref.id}`, { - headers: { range: "bytes=0-9" } - }, config.token); - expect(res.status).toBe(206); - expect(res.headers.get("content-range")).toBe("bytes 0-9/100"); - expect(res.headers.get("content-length")).toBe("10"); - expect(res.headers.get("accept-ranges")).toBe("bytes"); - const body = new Uint8Array(await res.arrayBuffer()); - expect(Array.from(body)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]); - }); - - test("GET /api/uploads serves a mid-file Range slice with exact bytes", async () => { - const config = testConfig("uploads-range-mid"); - const handler = createHandler(config); - const bytes = new Uint8Array(Array.from({ length: 100 }, (_, i) => i)); - const ref = storeUpload(config.instance, bytes, "audio/wav"); - - const res = await rawCall(handler, config, `/api/uploads/${ref.id}`, { - headers: { range: "bytes=10-19" } - }, config.token); - expect(res.status).toBe(206); - expect(res.headers.get("content-range")).toBe("bytes 10-19/100"); - const body = new Uint8Array(await res.arrayBuffer()); - expect(Array.from(body)).toEqual([10, 11, 12, 13, 14, 15, 16, 17, 18, 19]); - }); - - test("GET /api/uploads clamps an over-long Range end to the last byte", async () => { - const config = testConfig("uploads-range-clamp"); - const handler = createHandler(config); - const bytes = new Uint8Array(Array.from({ length: 50 }, (_, i) => i)); - const ref = storeUpload(config.instance, bytes, "audio/wav"); - - const res = await rawCall(handler, config, `/api/uploads/${ref.id}`, { - headers: { range: "bytes=40-999" } - }, config.token); - expect(res.status).toBe(206); - expect(res.headers.get("content-range")).toBe("bytes 40-49/50"); - expect(res.headers.get("content-length")).toBe("10"); - }); - - test("GET /api/uploads serves an open-ended Range to EOF", async () => { - const config = testConfig("uploads-range-open"); - const handler = createHandler(config); - const bytes = new Uint8Array(Array.from({ length: 50 }, (_, i) => i)); - const ref = storeUpload(config.instance, bytes, "audio/wav"); - - const res = await rawCall(handler, config, `/api/uploads/${ref.id}`, { - headers: { range: "bytes=48-" } - }, config.token); - expect(res.status).toBe(206); - expect(res.headers.get("content-range")).toBe("bytes 48-49/50"); - const body = new Uint8Array(await res.arrayBuffer()); - expect(Array.from(body)).toEqual([48, 49]); - }); - - test("GET /api/uploads serves a suffix Range (last N bytes)", async () => { - const config = testConfig("uploads-range-suffix"); - const handler = createHandler(config); - const bytes = new Uint8Array(Array.from({ length: 50 }, (_, i) => i)); - const ref = storeUpload(config.instance, bytes, "audio/wav"); - - const res = await rawCall(handler, config, `/api/uploads/${ref.id}`, { - headers: { range: "bytes=-5" } - }, config.token); - expect(res.status).toBe(206); - expect(res.headers.get("content-range")).toBe("bytes 45-49/50"); - const body = new Uint8Array(await res.arrayBuffer()); - expect(Array.from(body)).toEqual([45, 46, 47, 48, 49]); - }); - - test("GET /api/uploads returns 416 for a Range starting past EOF", async () => { - const config = testConfig("uploads-range-416"); - const handler = createHandler(config); - const bytes = new Uint8Array(Array.from({ length: 50 }, (_, i) => i)); - const ref = storeUpload(config.instance, bytes, "audio/wav"); - - const res = await rawCall(handler, config, `/api/uploads/${ref.id}`, { - headers: { range: "bytes=999-" } - }, config.token); - expect(res.status).toBe(416); - expect(res.headers.get("content-range")).toBe("bytes */50"); - }); - - test("GET /api/uploads ignores a malformed Range and serves the full 200 body", async () => { - const config = testConfig("uploads-range-malformed"); - const handler = createHandler(config); - const bytes = new Uint8Array(Array.from({ length: 50 }, (_, i) => i)); - const ref = storeUpload(config.instance, bytes, "audio/wav"); - - const res = await rawCall(handler, config, `/api/uploads/${ref.id}`, { - headers: { range: "lines=1-2" } - }, config.token); - expect(res.status).toBe(200); - expect(res.headers.get("content-length")).toBe("50"); - expect(res.headers.get("content-range")).toBeNull(); - expect(res.headers.get("accept-ranges")).toBe("bytes"); - }); - - test("GET /api/uploads treats a reversed Range (start>end) as malformed → full 200", async () => { - const config = testConfig("uploads-range-reversed"); - const handler = createHandler(config); - const bytes = new Uint8Array(Array.from({ length: 50 }, (_, i) => i)); - const ref = storeUpload(config.instance, bytes, "audio/wav"); - - const res = await rawCall(handler, config, `/api/uploads/${ref.id}`, { - headers: { range: "bytes=20-10" } - }, config.token); - expect(res.status).toBe(200); - expect(res.headers.get("content-length")).toBe("50"); - }); - - test("GET /api/uploads treats an empty suffix Range (bytes=-) as malformed → full 200", async () => { - const config = testConfig("uploads-range-emptysuffix"); - const handler = createHandler(config); - const bytes = new Uint8Array(Array.from({ length: 50 }, (_, i) => i)); - const ref = storeUpload(config.instance, bytes, "audio/wav"); - - const res = await rawCall(handler, config, `/api/uploads/${ref.id}`, { - headers: { range: "bytes=-" } - }, config.token); - expect(res.status).toBe(200); - expect(res.headers.get("content-length")).toBe("50"); - }); - - test("GET /api/uploads returns 416 for a zero-length suffix Range (bytes=-0)", async () => { - const config = testConfig("uploads-range-zerosuffix"); - const handler = createHandler(config); - const bytes = new Uint8Array(Array.from({ length: 50 }, (_, i) => i)); - const ref = storeUpload(config.instance, bytes, "audio/wav"); - - const res = await rawCall(handler, config, `/api/uploads/${ref.id}`, { - headers: { range: "bytes=-0" } - }, config.token); - expect(res.status).toBe(416); - expect(res.headers.get("content-range")).toBe("bytes */50"); - }); - - test("GET /api/uploads returns 416 for any Range against a zero-byte upload", async () => { - const config = testConfig("uploads-range-empty-file"); - const handler = createHandler(config); - // storeUpload rejects an empty body, so write the manifest+bytes directly to - // exercise the total===0 branch (a 0-byte stored file can't satisfy a range). - const ref = storeUpload(config.instance, new Uint8Array([1]), "audio/wav"); - writeFileSync(join(uploadsDir(config.instance), `${ref.id}.wav`), new Uint8Array([])); - - const res = await rawCall(handler, config, `/api/uploads/${ref.id}`, { - headers: { range: "bytes=0-10" } - }, config.token); - expect(res.status).toBe(416); - expect(res.headers.get("content-range")).toBe("bytes */0"); - }); - - test("HEAD /api/uploads advertises accept-ranges and the content length", async () => { - const config = testConfig("uploads-head-range"); - const handler = createHandler(config); - const bytes = new Uint8Array(Array.from({ length: 42 }, (_, i) => i)); - const ref = storeUpload(config.instance, bytes, "audio/wav"); - - const res = await rawCall(handler, config, `/api/uploads/${ref.id}`, { - method: "HEAD" - }, config.token); - expect(res.status).toBe(200); - expect(res.headers.get("accept-ranges")).toBe("bytes"); - expect(res.headers.get("content-length")).toBe("42"); - }); - - test("POST /api/uploads accepts a text/csv file", async () => { - const config = testConfig("uploads-csv"); - const handler = createHandler(config); - const form = new FormData(); - form.set("file", new File(["a,b\n1,2\n"], "data.csv", { type: "text/csv" })); - const response = await handler(new Request(`http://127.0.0.1:${config.port}/api/uploads`, { - method: "POST", - headers: { authorization: `Bearer ${config.token}` }, - body: form - })); - expect(response.status).toBe(201); - const ref = await response.json(); - expect(ref.mimeType).toBe("text/csv"); - }); - - // The 415 gate fires on a structurally-invalid mime (no slash / whitespace). - // It can't be reached through a real request: Bun's server-side - // request.formData() normalizes an invalid part Content-Type (e.g. - // "notamime") to application/octet-stream, and its File/FormData encoder - // sniffs the part mime from the filename extension — either way the part - // arrives plausible, so the predicate is exercised directly to pin the - // 415-triggering condition. - test("isPlausibleMime rejects structurally-invalid mimes (the 415 gate)", () => { - expect(isPlausibleMime("notamime")).toBe(false); - expect(isPlausibleMime("text/csv")).toBe(true); - expect(isPlausibleMime("application/pdf")).toBe(true); - }); - - test("POST /api/uploads rejects an empty file with 400", async () => { - const config = testConfig("uploads-empty"); - const handler = createHandler(config); - const form = new FormData(); - form.set("file", new File([], "empty.csv", { type: "text/csv" })); - const response = await handler(new Request(`http://127.0.0.1:${config.port}/api/uploads`, { - method: "POST", - headers: { authorization: `Bearer ${config.token}` }, - body: form - })); - expect(response.status).toBe(400); - }); - - test("POST /api/uploads rejects a file over the size cap with 413", async () => { - process.env.GINI_MAX_UPLOAD_BYTES = "10"; - try { - const config = testConfig("uploads-toolarge"); - const handler = createHandler(config); - const form = new FormData(); - form.set("file", new File(["this body is well over ten bytes"], "big.csv", { type: "text/csv" })); - const response = await handler(new Request(`http://127.0.0.1:${config.port}/api/uploads`, { - method: "POST", - headers: { authorization: `Bearer ${config.token}` }, - body: form - })); - expect(response.status).toBe(413); - } finally { - delete process.env.GINI_MAX_UPLOAD_BYTES; - } - }); - - test("storeUpload sanitizes a filename with embedded newline/control chars to a single line", () => { - const config = testConfig("uploads-filename"); - const ref = storeUpload(config.instance, new Uint8Array([1, 2, 3]), "text/csv", "a\nb\t\rc.csv"); - const stat = uploadStat(config.instance, ref.id); - expect(stat?.filename).toBe("a b c.csv"); - expect(stat?.filename).not.toContain("\n"); - }); - - // The exported sanitizeFilename is also applied at the model-facing render - // in buildAttachmentContent, covering manifests written outside storeUpload. - test("sanitizeFilename strips control chars, collapses whitespace, and caps length", () => { - expect(sanitizeFilename("a\nb\tc.csv")).toBe("a b c.csv"); - expect(sanitizeFilename("x".repeat(300)).length).toBe(255); - }); -}); - -describe("GET /api/docs", () => { - test("returns the requested doc section markdown and title", async () => { - const config = testConfig("docs-section"); - const handler = createHandler(config); - - const doc = await call(handler, config, "/api/docs/providers/codex?section=re-authentication"); - expect(doc.title).toBe("Codex"); - expect(doc.anchor).toBe("re-authentication"); - expect(doc.markdown).toContain("## Re-authentication"); - }); - - test("requires authentication", async () => { - const config = testConfig("docs-unauth"); - const handler = createHandler(config); - - const response = await rawCall(handler, config, "/api/docs/providers/codex"); - expect(response.status).toBe(401); - }); - - test("rejects a traversal path with 400", async () => { - const config = testConfig("docs-traversal"); - const handler = createHandler(config); - - // Percent-encode the slashes so the WHATWG URL parser doesn't collapse the - // `..` segments away — a literal `/api/docs/../package` normalizes to - // `/api/package` and 404s before this route matches. Encoded, the route - // matches and resolveDocPath's confinement check rejects the escaping path. - const response = await rawCall(handler, config, "/api/docs/..%2F..%2Fpackage", {}, config.token); - expect(response.status).toBe(400); - }); -}); - -describe("agent-chat and thread endpoints", () => { - test("GET /api/agents/:id/chat returns a stable single session across calls", async () => { - const config = testConfig("agent-chat-resolve"); - const handler = createHandler(config); - - const agent = await call(handler, config, "/api/agents", { - method: "POST", - body: JSON.stringify({ name: "Nova" }) - }); - - const first = await call(handler, config, `/api/agents/${agent.id}/chat`); - const second = await call(handler, config, `/api/agents/${agent.id}/chat`); - - expect(first.id).toBeString(); - expect(first.kind).toBe("agent"); - expect(first.agentId).toBe(agent.id); - expect(second.id).toBe(first.id); - }); - - test("GET /api/chat/:id/threads lists threads and 404s on a missing session", async () => { - const config = testConfig("thread-list"); - const handler = createHandler(config); - - const agent = await call(handler, config, "/api/agents", { - method: "POST", - body: JSON.stringify({ name: "Sage" }) - }); - const session = await call(handler, config, `/api/agents/${agent.id}/chat`); - - // Root the thread off a main-chat assistant block, then add an agent - // reply inside the thread. - const root = insertChatBlock(config.instance, { - kind: "assistant_text", - sessionId: session.id, - text: "Here is the research plan.", - streaming: false, - agentId: agent.id - }); - insertChatBlock(config.instance, { - kind: "assistant_text", - sessionId: session.id, - text: "Step one is done.", - streaming: false, - agentId: agent.id, - threadId: "thread_one", - parentBlockId: root.id - }); - - const threads = await call(handler, config, `/api/chat/${session.id}/threads`); - expect(threads).toHaveLength(1); - expect(threads[0].threadId).toBe("thread_one"); - expect(threads[0].parentBlockId).toBe(root.id); - expect(threads[0].lastReplyAuthor).toBe("agent"); - expect(threads[0].rootPreview).toContain("research plan"); - - const missing = await rawCall(handler, config, "/api/chat/chat_nope/threads", {}, config.token); - expect(missing.status).toBe(404); - }); - - test("GET /api/chat/:id/threads/:tid/blocks returns the thread's blocks and 404s on a missing session", async () => { - const config = testConfig("thread-blocks"); - const handler = createHandler(config); - - const agent = await call(handler, config, "/api/agents", { - method: "POST", - body: JSON.stringify({ name: "Scout" }) - }); - const session = await call(handler, config, `/api/agents/${agent.id}/chat`); - - const root = insertChatBlock(config.instance, { - kind: "assistant_text", - sessionId: session.id, - text: "Parent message", - streaming: false, - agentId: agent.id - }); - const reply = insertChatBlock(config.instance, { - kind: "assistant_text", - sessionId: session.id, - text: "Threaded reply", - streaming: false, - agentId: agent.id, - threadId: "thread_blocks", - parentBlockId: root.id - }); - // A main-chat block that must NOT leak into the thread fetch. - insertChatBlock(config.instance, { - kind: "assistant_text", - sessionId: session.id, - text: "Main chat only", - streaming: false, - agentId: agent.id - }); - - const blocks = await call(handler, config, `/api/chat/${session.id}/threads/thread_blocks/blocks`); - expect(blocks).toHaveLength(1); - expect(blocks[0].id).toBe(reply.id); - expect(blocks[0].threadId).toBe("thread_blocks"); - - const missing = await rawCall(handler, config, "/api/chat/chat_nope/threads/thread_blocks/blocks", {}, config.token); - expect(missing.status).toBe(404); - }); - - test("POST /api/chat/:id/threads/:tid/messages tags the block + task and mirrors to main with alsoToMain", async () => { - const config = testConfig("thread-reply"); - const handler = createHandler(config); - - const agent = await call(handler, config, "/api/agents", { - method: "POST", - body: JSON.stringify({ name: "Echo" }) - }); - const session = await call(handler, config, `/api/agents/${agent.id}/chat`); - - const root = insertChatBlock(config.instance, { - kind: "assistant_text", - sessionId: session.id, - text: "Original answer", - streaming: false, - agentId: agent.id - }); - insertChatBlock(config.instance, { - kind: "assistant_text", - sessionId: session.id, - text: "First thread reply", - streaming: false, - agentId: agent.id, - threadId: "thread_reply", - parentBlockId: root.id - }); - - const submitted = await call(handler, config, `/api/chat/${session.id}/threads/thread_reply/messages`, { - method: "POST", - body: JSON.stringify({ content: "follow up in the thread", alsoToMain: true }) - }); - - expect(submitted.threadId).toBe("thread_reply"); - expect(submitted.taskId).toBeString(); - - // The spawned task carries the thread membership so the whole response - // threads (resolveEmitContext reads these off the task). - const task = readState(config.instance).tasks.find((t) => t.id === submitted.taskId); - expect(task?.threadId).toBe("thread_reply"); - expect(task?.parentBlockId).toBe(root.id); - - // The user reply lands as a thread-tagged user_text block... - const threadBlocks = await call(handler, config, `/api/chat/${session.id}/threads/thread_reply/blocks`); - const threadUser = threadBlocks.find( - (b: { kind: string; text?: string }) => b.kind === "user_text" && b.text === "follow up in the thread" - ); - expect(threadUser.threadId).toBe("thread_reply"); - expect(threadUser.parentBlockId).toBe(root.id); - - // ...and alsoToMain mirrors it as an un-threaded main-chat user_text block. - const allBlocks = await call(handler, config, `/api/chat/${session.id}/blocks`); - const mainMirror = allBlocks.filter( - (b: { kind: string; text?: string; threadId?: string }) => - b.kind === "user_text" && b.text === "follow up in the thread" && b.threadId === undefined - ); - expect(mainMirror).toHaveLength(1); - }); - - test("POST /api/chat/:id/threads/:tid/messages 404s when the thread does not exist", async () => { - const config = testConfig("thread-reply-missing"); - const handler = createHandler(config); - - const agent = await call(handler, config, "/api/agents", { - method: "POST", - body: JSON.stringify({ name: "Vega" }) - }); - const session = await call(handler, config, `/api/agents/${agent.id}/chat`); - - const response = await rawCall(handler, config, `/api/chat/${session.id}/threads/thread_absent/messages`, { - method: "POST", - body: JSON.stringify({ content: "no thread here" }) - }, config.token); - expect(response.status).toBe(404); - const value = await response.json(); - expect(String(value.error)).toContain("Thread not found"); - }); - - test("POST /api/chat/:id/threads/:tid/messages 404s with Chat session not found on a bad session", async () => { - const config = testConfig("thread-reply-bad-session"); - const handler = createHandler(config); - - const response = await rawCall(handler, config, "/api/chat/chat_nope/threads/thread_one/messages", { - method: "POST", - body: JSON.stringify({ content: "no session here" }) - }, config.token); - expect(response.status).toBe(404); - const value = await response.json(); - expect(String(value.error)).toContain("Chat session not found"); - }); - - test("POST /api/chat/:id/threads/:tid/messages creates a new thread off a main-chat parent block", async () => { - const config = testConfig("thread-reply-create"); - const handler = createHandler(config); - - const agent = await call(handler, config, "/api/agents", { - method: "POST", - body: JSON.stringify({ name: "Orion" }) - }); - const session = await call(handler, config, `/api/agents/${agent.id}/chat`); - - // A main-chat assistant block the user branches a brand-new thread from. - const parent = insertChatBlock(config.instance, { - kind: "assistant_text", - sessionId: session.id, - text: "Here is my plan.", - streaming: false, - agentId: agent.id - }); - - // No prior blocks under thread_fresh — the parentBlockId in the body is - // what brings the thread into existence. - const submitted = await call(handler, config, `/api/chat/${session.id}/threads/thread_fresh/messages`, { - method: "POST", - body: JSON.stringify({ content: "kick off the thread", parentBlockId: parent.id }) - }); - - expect(submitted.threadId).toBe("thread_fresh"); - expect(submitted.taskId).toBeString(); - - // The spawned task carries the new thread's membership. - const task = readState(config.instance).tasks.find((t) => t.id === submitted.taskId); - expect(task?.threadId).toBe("thread_fresh"); - expect(task?.parentBlockId).toBe(parent.id); - - // The user reply lands as a thread-tagged user_text block rooted at the - // parent, so the thread now exists and renders in the panel. - const threadBlocks = await call(handler, config, `/api/chat/${session.id}/threads/thread_fresh/blocks`); - const threadUser = threadBlocks.find( - (b: { kind: string; text?: string }) => b.kind === "user_text" && b.text === "kick off the thread" - ); - expect(threadUser.threadId).toBe("thread_fresh"); - expect(threadUser.parentBlockId).toBe(parent.id); - }); - - test("POST /api/chat/:id/threads/:tid/messages 404s when starting a new thread without a parent block", async () => { - const config = testConfig("thread-reply-create-noparent"); - const handler = createHandler(config); - - const agent = await call(handler, config, "/api/agents", { - method: "POST", - body: JSON.stringify({ name: "Lyra" }) - }); - const session = await call(handler, config, `/api/agents/${agent.id}/chat`); - - const response = await rawCall(handler, config, `/api/chat/${session.id}/threads/thread_new/messages`, { - method: "POST", - body: JSON.stringify({ content: "no parent supplied" }) - }, config.token); - expect(response.status).toBe(404); - const value = await response.json(); - expect(String(value.error)).toContain("Thread not found"); - }); - - test("GET /api/threads aggregates across agent sessions with agentName, newest first", async () => { - const config = testConfig("threads-inbox"); - const handler = createHandler(config); - - const nova = await call(handler, config, "/api/agents", { - method: "POST", - body: JSON.stringify({ name: "Nova" }) - }); - const sage = await call(handler, config, "/api/agents", { - method: "POST", - body: JSON.stringify({ name: "Sage" }) - }); - const novaChat = await getOrCreateAgentChat(config.instance, nova.id); - const sageChat = await getOrCreateAgentChat(config.instance, sage.id); - - // Nova's thread (older last reply). - const novaRoot = insertChatBlock(config.instance, { - kind: "assistant_text", - sessionId: novaChat.id, - text: "Nova parent", - streaming: false, - agentId: nova.id - }); - insertChatBlock(config.instance, { - kind: "assistant_text", - sessionId: novaChat.id, - text: "Nova thread reply", - streaming: false, - agentId: nova.id, - threadId: "thread_nova", - parentBlockId: novaRoot.id - }); - - // Sage's thread (newer last reply — must sort first). - await Bun.sleep(2); - const sageRoot = insertChatBlock(config.instance, { - kind: "assistant_text", - sessionId: sageChat.id, - text: "Sage parent", - streaming: false, - agentId: sage.id - }); - insertChatBlock(config.instance, { - kind: "user_text", - sessionId: sageChat.id, - text: "Sage thread reply", - agentId: sage.id, - threadId: "thread_sage", - parentBlockId: sageRoot.id - }); - - const inbox = await call(handler, config, "/api/threads"); - expect(inbox).toHaveLength(2); - // Newest last reply first. - expect(inbox[0].threadId).toBe("thread_sage"); - expect(inbox[0].agentName).toBe("Sage"); - expect(inbox[0].lastReplyAuthor).toBe("user"); - expect(inbox[1].threadId).toBe("thread_nova"); - expect(inbox[1].agentName).toBe("Nova"); - expect(inbox[1].lastReplyAuthor).toBe("agent"); - - // filter=unread is accepted but returns the full list (client filters). - const all = await call(handler, config, "/api/threads?filter=unread"); - expect(all).toHaveLength(2); - }); - - test("GET /api/logs requires the bearer", async () => { - const config = testConfig("logs-auth"); - const handler = createHandler(config); - const response = await rawCall(handler, config, "/api/logs"); - expect(response.status).toBe(401); - }); - - test("GET /api/logs returns parsed runtime entries by default", async () => { - const config = testConfig("logs-runtime"); - const handler = createHandler(config); - seedLogFile(config, "runtime.jsonl", - `${JSON.stringify({ at: "2026-06-07T00:00:00.000Z", message: "boot", data: { token: "sk-secret-1" } })}\n` + - `${JSON.stringify({ at: "2026-06-07T00:00:01.000Z", message: "ready" })}\n` - ); - const tail = await call(handler, config, "/api/logs"); - expect(tail.stream).toBe("runtime"); - expect(tail.redacted).toBe(false); - expect(tail.entries).toHaveLength(2); - // Raw mode keeps the data payload untouched. - expect(tail.entries[0].data).toEqual({ token: "sk-secret-1" }); - expect(tail.lines).toBeUndefined(); - }); - - test("GET /api/logs honors the stream param and returns raw lines", async () => { - const config = testConfig("logs-stream"); - const handler = createHandler(config); - seedLogFile(config, "web.log", "web line 1\nweb line 2\n"); - const tail = await call(handler, config, "/api/logs?stream=web"); - expect(tail.stream).toBe("web"); - expect(tail.lines).toEqual(["web line 1", "web line 2"]); - expect(tail.entries).toBeUndefined(); - }); - - test("GET /api/logs with redact=true drops data and scrubs secrets", async () => { - const config = testConfig("logs-redact"); - const handler = createHandler(config); - seedLogFile(config, "runtime.jsonl", - `${JSON.stringify({ at: "2026-06-07T00:00:00.000Z", message: "auth Bearer sk-leak-123", data: { token: "sk-leak-123" } })}\n` - ); - const tail = await call(handler, config, "/api/logs?redact=true"); - expect(tail.redacted).toBe(true); - expect(tail.entries).toHaveLength(1); - expect(tail.entries[0].data).toBeUndefined(); - expect(tail.entries[0].message).not.toContain("sk-leak-123"); - expect(tail.entries[0].message).toContain("[redacted]"); - }); - - test("GET /api/logs rejects an unknown stream with 400", async () => { - const config = testConfig("logs-unknown"); - const handler = createHandler(config); - const response = await rawCall(handler, config, "/api/logs?stream=audit", {}, config.token); - expect(response.status).toBe(400); - }); - - test("GET /api/logs clamps the limit to the most recent lines", async () => { - const config = testConfig("logs-limit"); - const handler = createHandler(config); - const body = Array.from({ length: 6 }, (_, i) => JSON.stringify({ message: `m${i}` })).join("\n") + "\n"; - seedLogFile(config, "runtime.jsonl", body); - const tail = await call(handler, config, "/api/logs?limit=2"); - expect(tail.truncated).toBe(true); - expect(tail.entries.map((e: { message: string }) => e.message)).toEqual(["m4", "m5"]); - }); -}); - -describe("email watcher routes", () => { - test("GET /api/email/watchers is scoped to the active agent", async () => { - const config = testConfig("http-email-list-scope"); - const handler = createHandler(config); - const initial = await call(handler, config, "/api/agents"); - const defaultAgentId = initial.activeAgentId as string; - const second = await call(handler, config, "/api/agents", { - method: "POST", - body: JSON.stringify({ name: "scout" }) - }); - - // A watcher created under the default agent. - const underDefault = await call(handler, config, "/api/email/watchers", { - method: "POST", - body: JSON.stringify({ sender: "alice@example.com" }) - }); - // Switch to scout and create another watcher under it. - await call(handler, config, `/api/agents/${second.id}/use`, { method: "POST" }); - const underScout = await call(handler, config, "/api/email/watchers", { - method: "POST", - body: JSON.stringify({ sender: "bob@example.com" }) - }); - - // GET now reflects the active agent (scout) — only its watcher. - const scoutList = await call(handler, config, "/api/email/watchers"); - expect(scoutList.map((w: { id: string }) => w.id)).toEqual([(underScout as { id: string }).id]); - - // Switch back: GET returns only the default agent's watcher. - await call(handler, config, `/api/agents/${defaultAgentId}/use`, { method: "POST" }); - const defaultList = await call(handler, config, "/api/email/watchers"); - expect(defaultList.map((w: { id: string }) => w.id)).toEqual([(underDefault as { id: string }).id]); - }); - - test("PATCH /api/email/watchers/:id toggles enabled and tears down / recreates the shared job", async () => { - const config = testConfig("http-email-patch"); - const handler = createHandler(config); - const created = await call(handler, config, "/api/email/watchers", { - method: "POST", - body: JSON.stringify({ sender: "alice@example.com" }) - }); - const id = (created as { id: string }).id; - const jobId = (created as { jobId: string }).jobId; - expect(jobId).toBeString(); - // The shared email-watch job is active and watches this sole watcher. - const sharedJob = () => - readState(config.instance).jobs.find( - (j) => (j.preRunHook?.config as { skill?: string })?.skill === "gmail-watch" - ); - expect(sharedJob()?.id).toBe(jobId); - expect(sharedJob()?.status).toBe("active"); - - // Disabling the sole watcher tears the shared job down (nothing to poll). - const disabled = await call(handler, config, `/api/email/watchers/${id}`, { - method: "PATCH", - body: JSON.stringify({ enabled: false }) - }); - expect((disabled as { enabled: boolean }).enabled).toBe(false); - expect(sharedJob()).toBeUndefined(); - - // Re-enabling recreates the shared job and re-stamps the watcher's jobId. - const enabled = await call(handler, config, `/api/email/watchers/${id}`, { - method: "PATCH", - body: JSON.stringify({ enabled: true }) - }); - expect((enabled as { enabled: boolean }).enabled).toBe(true); - expect(sharedJob()).toBeDefined(); - expect((enabled as { jobId: string }).jobId).toBe(sharedJob()!.id); - }); - - test("PATCH /api/email/watchers/:id rejects a non-boolean enabled with 400", async () => { - const config = testConfig("http-email-patch-bad"); - const handler = createHandler(config); - const created = await call(handler, config, "/api/email/watchers", { - method: "POST", - body: JSON.stringify({ sender: "bob@example.com" }) - }); - const id = (created as { id: string }).id; - const response = await rawCall( - handler, - config, - `/api/email/watchers/${id}`, - { method: "PATCH", body: JSON.stringify({ enabled: "yes" }) }, - config.token - ); - expect(response.status).toBe(400); - }); - - test("PATCH /api/email/watchers/:id returns 404 for an unknown watcher", async () => { - const config = testConfig("http-email-patch-404"); - const handler = createHandler(config); - const response = await rawCall( - handler, - config, - "/api/email/watchers/nope", - { method: "PATCH", body: JSON.stringify({ enabled: false }) }, - config.token - ); - expect(response.status).toBe(404); - }); - - test("PATCH /api/email/watchers/:id clears the objective with an explicit null", async () => { - const config = testConfig("http-email-patch-clear"); - const handler = createHandler(config); - const created = await call(handler, config, "/api/email/watchers", { - method: "POST", - body: JSON.stringify({ sender: "bob@example.com", objective: "Get a refund" }) - }); - const id = (created as { id: string }).id; - expect((created as { objective?: string }).objective).toBe("Get a refund"); - // Explicit null clears (distinct from omitted = unchanged, "" = 400). - const cleared = await call(handler, config, `/api/email/watchers/${id}`, { - method: "PATCH", - body: JSON.stringify({ objective: null }) - }); - expect((cleared as { objective?: string }).objective).toBeUndefined(); - // An empty-string objective is still rejected. - const empty = await rawCall( - handler, - config, - `/api/email/watchers/${id}`, - { method: "PATCH", body: JSON.stringify({ objective: "" }) }, - config.token - ); - expect(empty.status).toBe(400); - }); - - test("POST /api/google/accounts rejects a configDir outside the allowed roots", async () => { - const config = testConfig("http-google-accounts-configdir"); - const handler = createHandler(config); - // A relative path is rejected. - const relative = await rawCall( - handler, - config, - "/api/google/accounts", - { method: "POST", body: JSON.stringify({ tag: "x", configDir: "relative/gws" }) }, - config.token - ); - expect(relative.status).toBe(400); - expect((await relative.json()).error).toContain("configDir must be"); - // An absolute but unrelated path is rejected (defense-in-depth — never - // reaches registerAccount / a real gws spawn). - const arbitrary = await rawCall( - handler, - config, - "/api/google/accounts", - { method: "POST", body: JSON.stringify({ tag: "x", configDir: "/etc/passwd" }) }, - config.token - ); - expect(arbitrary.status).toBe(400); - expect((await arbitrary.json()).error).toContain("configDir must be"); - }); - - test("POST /api/google/accounts/provision requires all three credential fields", async () => { - const config = testConfig("http-google-provision-400"); - const handler = createHandler(config); - for (const body of [ - {}, - { clientId: "id", clientSecret: "secret" }, - { clientId: "id", refreshToken: "token" }, - { clientId: "id", clientSecret: "secret", refreshToken: " " } - ]) { - const response = await rawCall( - handler, - config, - "/api/google/accounts/provision", - { method: "POST", body: JSON.stringify(body) }, - config.token - ); - expect(response.status).toBe(400); - expect((await response.json()).error).toContain("clientId, clientSecret, and refreshToken"); - } - }); - - test("POST /api/google/accounts/provision registers a trusted account (201)", async () => { - const config = testConfig("http-google-provision-201"); - const handler = createHandler(config); - // The registry is HOME-keyed (machine-global), so point HOME at a scratch - // dir for the duration of this test. - const prevHome = process.env.HOME; - const scratchHome = join("/tmp/gini-http-tests", `provision-home-${process.pid}-${Date.now()}`); - mkdirSync(scratchHome, { recursive: true }); - process.env.HOME = scratchHome; - try { - const response = await rawCall( - handler, - config, - "/api/google/accounts/provision", - { - method: "POST", - body: JSON.stringify({ - clientId: "edge-client", - clientSecret: "edge-secret", - refreshToken: "1//refresh", - email: "ada@example.com", - principal: "sub-ada" - }) - }, - config.token - ); - expect(response.status).toBe(201); - const account = await response.json(); - expect(account.tag).toBe("ada"); - expect(account.provisioned).toBe(true); - expect(existsSync(join(account.configDir, "credentials.json"))).toBe(true); - } finally { - if (prevHome === undefined) delete process.env.HOME; - else process.env.HOME = prevHome; - rmSync(scratchHome, { recursive: true, force: true }); - } - }); - - test("POST /api/google/accounts/provision with primary:true heals the hosted baked credential in place", async () => { - const config = testConfig("http-google-provision-primary"); - const handler = createHandler(config); - const prevHome = process.env.HOME; - const prevHosted = process.env.GINI_HOSTED; - const prevCred = process.env.GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE; - const scratchHome = join("/tmp/gini-http-tests", `provision-primary-${process.pid}-${Date.now()}`); - const credentialsFile = join(scratchHome, ".config", "gws-hosted", "credentials.json"); - mkdirSync(dirname(credentialsFile), { recursive: true }); - writeFileSync(credentialsFile, "{}"); - process.env.HOME = scratchHome; - process.env.GINI_HOSTED = "1"; - process.env.GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE = credentialsFile; - try { - const { ensureHostedPrimaryAccount } = await import("./integrations/connectors/google-accounts"); - const { readGoogleAccounts } = await import("./state/google-accounts"); - const primary = await ensureHostedPrimaryAccount(); - expect(primary?.tag).toBe("primary"); - - const response = await rawCall( - handler, - config, - "/api/google/accounts/provision", - { - method: "POST", - body: JSON.stringify({ - clientId: "edge-client", - clientSecret: "edge-secret", - refreshToken: "1//healed", - email: "wilson@example.com", - principal: "sub-wilson", - primary: true - }) - }, - config.token - ); - expect(response.status).toBe(201); - const account = await response.json(); - // Landed on the baked primary row — no duplicate minted. - expect(account.id).toBe(primary!.id); - expect(account.configDir).toBe(dirname(credentialsFile)); - expect(readGoogleAccounts()).toHaveLength(1); - // The fresh token overwrote the baked file gws actually reads. - expect(JSON.parse(readFileSync(credentialsFile, "utf8")).refresh_token).toBe("1//healed"); - } finally { - if (prevHome === undefined) delete process.env.HOME; - else process.env.HOME = prevHome; - if (prevHosted === undefined) delete process.env.GINI_HOSTED; - else process.env.GINI_HOSTED = prevHosted; - if (prevCred === undefined) delete process.env.GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE; - else process.env.GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE = prevCred; - rmSync(scratchHome, { recursive: true, force: true }); - } - }); - - test("POST /api/google/accounts/provision with makePrimary:true persists the account as the primary", async () => { - const config = testConfig("http-google-provision-make-primary"); - const handler = createHandler(config); - const prevHome = process.env.HOME; - const scratchHome = join("/tmp/gini-http-tests", `provision-make-primary-${process.pid}-${Date.now()}`); - mkdirSync(scratchHome, { recursive: true }); - process.env.HOME = scratchHome; - try { - const { readPrimaryGoogleAccountId } = await import("./state/google-accounts"); - const provision = (body: Record) => - rawCall( - handler, - config, - "/api/google/accounts/provision", - { method: "POST", body: JSON.stringify(body) }, - config.token - ); - // An add (no flag) never touches the primary. - const added = await provision({ - clientId: "edge-client", - clientSecret: "edge-secret", - refreshToken: "1//refresh-a", - email: "ada@example.com", - principal: "sub-ada" - }); - expect(added.status).toBe(201); - expect(readPrimaryGoogleAccountId()).toBeUndefined(); - // A sign-in-intent provision flips the persisted primary to its account. - const signedIn = await provision({ - clientId: "edge-client", - clientSecret: "edge-secret", - refreshToken: "1//refresh-b", - email: "bob@example.com", - principal: "sub-bob", - makePrimary: true - }); - expect(signedIn.status).toBe(201); - const account = await signedIn.json(); - expect(readPrimaryGoogleAccountId()).toBe(account.id); - } finally { - if (prevHome === undefined) delete process.env.HOME; - else process.env.HOME = prevHome; - rmSync(scratchHome, { recursive: true, force: true }); - } - }); - - test("GET /api/google/auth-mode reflects the hosted marker", async () => { - const config = testConfig("http-google-auth-mode"); - const handler = createHandler(config); - const prevHosted = process.env.GINI_HOSTED; - delete process.env.GINI_HOSTED; - try { - expect(await call(handler, config, "/api/google/auth-mode")).toEqual({ mode: "loopback" }); - process.env.GINI_HOSTED = "1"; - expect(await call(handler, config, "/api/google/auth-mode")).toEqual({ mode: "edge" }); - } finally { - if (prevHosted === undefined) delete process.env.GINI_HOSTED; - else process.env.GINI_HOSTED = prevHosted; - } - }); - - test("POST /api/google/accounts no longer requires a tag (configDir validation still applies)", async () => { - const config = testConfig("http-google-accounts-no-tag"); - const handler = createHandler(config); - // A tag-less body reaches the configDir gate (previously a missing tag was - // its own 400) — the register path derives the tag from the live session. - const response = await rawCall( - handler, - config, - "/api/google/accounts", - { method: "POST", body: JSON.stringify({ configDir: "relative/gws" }) }, - config.token - ); - expect(response.status).toBe(400); - expect((await response.json()).error).toContain("configDir must be"); - }); - - test("GET /api/google/login/start 302s to Google consent with PKCE and the browser-facing redirect_uri", async () => { - const config = testConfig("http-google-login-start"); - const handler = createHandler(config); - const prevHosted = process.env.GINI_HOSTED; - delete process.env.GINI_HOSTED; - resetGoogleLoginWebState(); - try { - const origin = encodeURIComponent("http://127.0.0.1:3059"); - const response = await rawCall( - handler, - config, - `/api/google/login/start?returnTo=%2Fonboarding&origin=${origin}`, - {}, - config.token - ); - expect(response.status).toBe(302); - const location = new URL(response.headers.get("location") ?? ""); - expect(location.host).toBe("accounts.google.com"); - expect(location.searchParams.get("redirect_uri")).toBe( - "http://127.0.0.1:3059/api/runtime/google/login/callback" - ); - expect(location.searchParams.get("code_challenge_method")).toBe("S256"); - expect(location.searchParams.get("state")).toBeTruthy(); - } finally { - if (prevHosted === undefined) delete process.env.GINI_HOSTED; - else process.env.GINI_HOSTED = prevHosted; - resetGoogleLoginWebState(); - } - }); - - test("GET /api/google/login/start 400s on a non-loopback origin and in edge auth mode", async () => { - const config = testConfig("http-google-login-start-400"); - const handler = createHandler(config); - const prevHosted = process.env.GINI_HOSTED; - delete process.env.GINI_HOSTED; - resetGoogleLoginWebState(); - try { - const lan = await rawCall( - handler, - config, - `/api/google/login/start?origin=${encodeURIComponent("http://192.168.1.20:3000")}`, - {}, - config.token - ); - expect(lan.status).toBe(400); - expect((await lan.json()).error).toContain("loopback"); - process.env.GINI_HOSTED = "1"; - const edge = await rawCall( - handler, - config, - `/api/google/login/start?origin=${encodeURIComponent("http://127.0.0.1:3059")}`, - {}, - config.token - ); - expect(edge.status).toBe(400); - expect((await edge.json()).error).toContain("edge sign-in flow"); - } finally { - if (prevHosted === undefined) delete process.env.GINI_HOSTED; - else process.env.GINI_HOSTED = prevHosted; - resetGoogleLoginWebState(); - } - }); - - test("GET /api/google/login/callback with an unknown state 302s back with googleAddError=1 (never an error page)", async () => { - const config = testConfig("http-google-login-callback"); - const handler = createHandler(config); - resetGoogleLoginWebState(); - const response = await rawCall( - handler, - config, - "/api/google/login/callback?code=bogus&state=bogus", - {}, - config.token - ); - expect(response.status).toBe(302); - expect(response.headers.get("location")).toBe("/onboarding?step=accounts&googleAddError=1"); - }); -}); - -describe("response compression", () => { - // Seed enough audit rows that /api/state's JSON body clears the 1 KB - // threshold, so the compressible-path assertions exercise real compression - // rather than the too-small skip. - async function seedBulkyState(config: RuntimeConfig): Promise { - // Awaited by every caller: mutateState is async (it chains on the - // per-instance write lock), so without awaiting it the subsequent - // readState in the request under test can race the write and read state - // before the bulky audit rows land — intermittently dropping the body - // under the 1 KB threshold and skipping compression. - await mutateState(config.instance, (state) => { - for (let i = 0; i < 200; i += 1) { - addAudit(state, { - actor: "runtime", - action: "test.bulk", - target: `row-${i}`, - risk: "low", - evidence: { note: "padding to push the state payload past the gzip threshold" } - }, { system: true }); - } - }); - } - - test("gzips a large JSON response when the client accepts only gzip", async () => { - const config = testConfig("gzip-json"); - const handler = createHandler(config); - await seedBulkyState(config); - const response = await rawCall( - handler, - config, - "/api/state", - { headers: { "accept-encoding": "gzip" } }, - config.token - ); - expect(response.status).toBe(200); - expect(response.headers.get("content-encoding")).toBe("gzip"); - expect(response.headers.get("vary") ?? "").toContain("Accept-Encoding"); - // The body is real gzip and decodes back to valid JSON state. - const compressed = new Uint8Array(await response.arrayBuffer()); - const decoded = JSON.parse(new TextDecoder().decode(Bun.gunzipSync(compressed))); - expect(decoded.instance).toBe(config.instance); - // The compressed transfer is materially smaller than the raw JSON. - const rawLen = new TextEncoder().encode(JSON.stringify(decoded)).length; - expect(compressed.byteLength).toBeLessThan(rawLen); - }); - - test("prefers brotli when the client accepts both br and gzip", async () => { - const config = testConfig("br-json"); - const handler = createHandler(config); - await seedBulkyState(config); - const response = await rawCall( - handler, - config, - "/api/state", - { headers: { "accept-encoding": "gzip, br" } }, - config.token - ); - expect(response.status).toBe(200); - expect(response.headers.get("content-encoding")).toBe("br"); - expect(response.headers.get("vary") ?? "").toContain("Accept-Encoding"); - // The body is real brotli and decodes back to valid JSON state. - const { brotliDecompressSync } = await import("node:zlib"); - const compressed = new Uint8Array(await response.arrayBuffer()); - const decoded = JSON.parse(new TextDecoder().decode(brotliDecompressSync(compressed))); - expect(decoded.instance).toBe(config.instance); - const rawLen = new TextEncoder().encode(JSON.stringify(decoded)).length; - expect(compressed.byteLength).toBeLessThan(rawLen); - }); - - test("falls back to gzip when br is explicitly opted out (br;q=0)", async () => { - const config = testConfig("br-optout"); - const handler = createHandler(config); - await seedBulkyState(config); - const response = await rawCall( - handler, - config, - "/api/state", - { headers: { "accept-encoding": "br;q=0, gzip" } }, - config.token - ); - expect(response.headers.get("content-encoding")).toBe("gzip"); - }); - - test("does not compress when the client omits Accept-Encoding", async () => { - const config = testConfig("gzip-absent"); - const handler = createHandler(config); - await seedBulkyState(config); - const response = await rawCall(handler, config, "/api/state", {}, config.token); - expect(response.status).toBe(200); - expect(response.headers.get("content-encoding")).toBeNull(); - const body = await response.json(); - expect(body.instance).toBe(config.instance); - }); - - test("honors gzip;q=0 as an explicit opt-out", async () => { - const config = testConfig("gzip-q0"); - const handler = createHandler(config); - await seedBulkyState(config); - const response = await rawCall( - handler, - config, - "/api/state", - { headers: { "accept-encoding": "gzip;q=0" } }, - config.token - ); - expect(response.headers.get("content-encoding")).toBeNull(); - }); - - test("leaves a small JSON response uncompressed (below threshold)", async () => { - const config = testConfig("gzip-small"); - const handler = createHandler(config); - const response = await rawCall( - handler, - config, - "/api/tasks?agentId=does_not_exist", - { headers: { "accept-encoding": "gzip" } }, - config.token - ); - expect(response.status).toBe(200); - expect(response.headers.get("content-encoding")).toBeNull(); - // Body is still the intact (empty) JSON array. - expect(await response.json()).toEqual([]); - }); - - test("never compresses an SSE event stream", async () => { - const config = testConfig("gzip-sse"); - const handler = createHandler(config); - const response = await rawCall( - handler, - config, - "/api/events/stream", - { headers: { "accept-encoding": "gzip" } }, - config.token - ); - try { - expect(response.headers.get("content-type") ?? "").toContain("text/event-stream"); - expect(response.headers.get("content-encoding")).toBeNull(); - } finally { - await response.body?.cancel(); - } - }); - - test("never compresses a web-proxy path (only the native /api surface is compressed)", async () => { - // /api/runtime/* is a web-proxy path (isWebProxyPath). Those responses are - // reachable unauthenticated via the pairing bootstrap and are gzip'd by the - // web child itself, so the gateway must NOT brotli/gzip them — that would - // let an unauthenticated client burn CPU on compression before any session - // check. Unauthenticated relay-less loopback returns 401 here, but the - // point is the absence of a gateway-applied Content-Encoding regardless. - const config = testConfig("gzip-webproxy"); - const handler = createHandler(config); - const response = await rawCall( - handler, - config, - "/api/runtime/status", - { headers: { "accept-encoding": "gzip, br" } } - ); - expect(response.headers.get("content-encoding")).toBeNull(); - }); - - test("never compresses a 206 byte-range response (preserves Content-Range semantics)", async () => { - // A compressible upload (text/csv) over the 1 KB threshold, fetched with a - // Range. The 206 carries Content-Range computed on the UNENCODED bytes, so - // adding Content-Encoding would corrupt the range contract (RFC 7233). - const config = testConfig("gzip-range"); - const handler = createHandler(config); - const bytes = new TextEncoder().encode("col_a,col_b,col_c\n" + "1,2,3\n".repeat(500)); - const ref = storeUpload(config.instance, bytes, "text/csv"); - const res = await rawCall( - handler, - config, - `/api/uploads/${ref.id}`, - { headers: { range: "bytes=0-1999", "accept-encoding": "br, gzip" } }, - config.token - ); - expect(res.status).toBe(206); - expect(res.headers.get("content-range")).toContain("bytes 0-1999/"); - expect(res.headers.get("content-encoding")).toBeNull(); - }); -}); - -async function call(handler: ReturnType, config: RuntimeConfig, path: string, init: RequestInit = {}) { - return callWithToken(handler, config, config.token, path, init); -} - -async function callWithToken(handler: ReturnType, config: RuntimeConfig, token: string, path: string, init: RequestInit = {}) { - const response = await rawCall(handler, config, path, init, token); - const value = await response.json(); - if (!response.ok) throw new Error(value.error ?? `HTTP ${response.status}`); - return value; -} - -async function rawCall(handler: ReturnType, config: RuntimeConfig, path: string, init: RequestInit = {}, token?: string) { - const response = await handler(new Request(`http://127.0.0.1:${config.port}${path}`, { - ...init, - headers: { "content-type": "application/json", ...(token ? { authorization: `Bearer ${token}` } : {}), ...(init.headers ?? {}) } - })); - return response; -} - -// Opens a chat SSE stream, pumps frames until the initial `chat_session` -// frame lands (which only happens inside the stream's start() — i.e. AFTER -// presence registration), runs the caller's `whileOpen` assertions, then -// ALWAYS cancels the reader in a finally so a thrown assertion can't leak -// the stream (its keepalive interval and presence entry would otherwise -// survive the test). Returns after the reader is cancelled. -async function withChatStream( - handler: ReturnType, - config: RuntimeConfig, - sessionId: string, - init: RequestInit, - whileOpen: () => void -): Promise { - const response = await rawCall(handler, config, `/api/chat/${sessionId}/stream`, init, config.token); - expect(response.status).toBe(200); - const reader = response.body?.getReader(); - expect(reader).toBeDefined(); - if (!reader) return; - try { - const decoder = new TextDecoder(); - let buffer = ""; - const deadline = Date.now() + 500; - while (Date.now() < deadline) { - const { done, value } = await Promise.race([ - reader.read(), - new Promise<{ done: boolean; value: undefined }>((resolve) => - setTimeout(() => resolve({ done: false, value: undefined }), 50) - ) - ]); - if (done) break; - if (value) buffer += decoder.decode(value); - if (buffer.includes("event: chat_session")) break; - } - expect(buffer).toContain("event: chat_session"); - whileOpen(); - } finally { - await reader.cancel(); - } - // cancel() runs the stream's cleanup synchronously in-process; give the - // microtask queue one turn to settle before the caller asserts the - // post-close registry state. - await new Promise((resolve) => setTimeout(resolve, 0)); -} - -function testConfig(instance: string): RuntimeConfig { - const root = "/tmp/gini-http-tests"; - process.env.GINI_STATE_ROOT = root; - process.env.GINI_LOG_ROOT = `${root}-logs`; - // Drop the cached SQLite handle for this instance before nuking the - // directory. Without this, a prior test that opened the per-instance - // memory DB leaves an open `bun:sqlite` handle pointing at the now- - // unlinked file. The next call to getMemoryDb returns that cached - // handle (the cache key is the instance name) and any write fails - // because the inode is gone. removeMemoryDb closes the cached handle - // AND unlinks the file + WAL/SHM siblings in one shot. - removeMemoryDb(instance); - // resumeChatTask polls for the loop's flip to waiting_approval before - // staging a tool result. In-process the flip lands within a couple of - // mutateState boundaries, and several fill_secret / approval tests seed a - // task that never reaches waiting_approval at all — so the production - // 1000ms/100ms budget is pure dead wall here (the fill_secret leak test - // measured 1079.00ms in isolation, nearly all of it this poll). Shrink the - // budget via the server-side env knob the production code reads (default - // preserved at 1000/100); the race still resolves well within 40ms over 5ms - // ticks in-process. - process.env.GINI_RESUME_WAIT_BUDGET_MS = "40"; - process.env.GINI_RESUME_WAIT_TICK_MS = "5"; - rmSync(`${root}/instances/${instance}`, { recursive: true, force: true }); - return { - instance, - port: 7337, - token: "test-token", - provider: { name: "echo", model: "gini-echo-v0" }, - workspaceRoot: "/tmp", - stateRoot: `${root}/instances/${instance}`, - logRoot: `${root}-logs/${instance}`, - // These tests predate the approval-mode flip and rely on the - // gated path. Force "strict" to keep them honest; new defaults - // are exercised in approval-mode.test.ts. - approvalMode: "strict" - }; -} - -// Write a log file under the test config's instance log dir so the /api/logs -// route reads it. testConfig nukes the instance state dir and points -// GINI_LOG_ROOT at a sibling tree, so this lands in a clean per-instance dir. -function seedLogFile(config: RuntimeConfig, filename: string, body: string): void { - const dir = logDir(config.instance); - mkdirSync(dir, { recursive: true }); - writeFileSync(join(dir, filename), body); -} - -// Seed a typed api-key credential so the per-(skill, credential) consent gate -// (firstUngrantedCredential) treats it as carrying a secret that needs consent. -async function seedTypedCredential(config: RuntimeConfig, name: string, provider: string) { - const at = new Date().toISOString(); - await mutateState(config.instance, (state) => { - state.connectors.push({ - id: `id_${name}`, - instance: state.instance, - name, - provider, - type: "api-key", - status: "configured", - scopes: [], - secretRefs: [{ purpose: name, path: `/tmp/${name}.json` }], - createdAt: at, - updatedAt: at, - health: "healthy", - source: "user" - }); - }); -} - -async function waitForTask( - handler: ReturnType, - config: RuntimeConfig, - taskId: string, - // The approve endpoint returns at decision durability while the side - // effect + resume run detached, so a post-approve wait must exclude - // the still-parked waiting_approval status or it returns the - // pre-approve park immediately. - statuses: string[] = ["completed", "failed", "waiting_approval"] -) { - // Phase 5 added auto-recall + auto-retain to runTask. The retain side is - // fire-and-forget so it can't block, but the inline recall + a few extra - // mutateState audits push runTask completion past the original 500ms - // budget on slower hosts. A 200-iteration / 10ms loop = 2s ceiling is - // still well under any reasonable test timeout. - for (let attempt = 0; attempt < 200; attempt += 1) { - const detail = await call(handler, config, `/api/tasks/${taskId}`); - if (statuses.includes(detail.task.status)) return detail; - await Bun.sleep(10); - } - throw new Error(`Task did not settle: ${taskId}`); -} diff --git a/packages/runtime/src/integrations/tunnel.part1.test.ts b/packages/runtime/src/integrations/tunnel.part1.test.ts new file mode 100644 index 00000000..9cf74464 --- /dev/null +++ b/packages/runtime/src/integrations/tunnel.part1.test.ts @@ -0,0 +1,1777 @@ +// Unit tests for the tunnel connectivity behavior module (ADR +// tunnel-connectivity.md). Covers the full TunnelState contract returned by +// every function, the provider catalog, selection validation, the real +// gini-relay connect flow (connecting -> connected / error), cancel, +// disconnect, and the startup reconcile + resume (a previously connected tunnel +// comes back automatically by reusing the stored session) — at 100% line + +// function coverage. +// +// Every gini-relay seam (login primitive, tunnel builder, store, defaults, +// browser opener, local-port resolver) is injected via `setTunnelDeps`, so +// these tests never hit the network, OAuth, the host browser, or a spawned +// frpc child. + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + awaitTunnelSettled, + cancelTunnel, + connectTunnel, + defaultLogout, + defaultOpenBrowser, + defaultPersistWorkspaceGrant, + defaultReadCloudflareConfig, + defaultRunCommand, + defaultTunnelProcSpawn, + disconnectTunnel, + getTunnel, + makeDefaultDeps, + makeDefaultDrivers, + parseCloudflareConfig, + PROVIDER_UNAVAILABLE, + reconcileTunnelOnStartup, + refreshProviderDetection, + selectProvider, + setTunnelDeps, + spawnUrlChild, + stopAllTunnels, + type ManualDriver, + type SpawnedTunnelProc, + type TunnelChild, + type TunnelDeps +} from "./tunnel"; +import { mutateState, readState } from "../state"; +import { addGoogleAccount } from "../state/google-accounts"; +import { isRuntimeTunnelHost } from "../lib/origin-trust"; +import type { RuntimeConfig, TunnelProviderId } from "../types"; +import type { LoginHandle, RelayDefaults, Session, Store, TunnelOptions } from "gini-relay"; + +const ROOT = `/tmp/gini-tunnel-integration-tests-${import.meta.file}`; + +function testConfig(instance: string): RuntimeConfig { + process.env.GINI_STATE_ROOT = ROOT; + process.env.GINI_LOG_ROOT = `${ROOT}-logs`; + rmSync(`${ROOT}/instances/${instance}`, { recursive: true, force: true }); + return { + instance, + port: 7337, + token: "test-token", + provider: { name: "echo", model: "gini-echo-v0" }, + workspaceRoot: join(ROOT, instance, "workspace"), + stateRoot: ROOT, + logRoot: `${ROOT}-logs` + }; +} + +// The relay defaults the fake login + tunnel builder echo back. relayDomain +// drives the public url the connected record records. +const RELAY: RelayDefaults = { + relayUrl: "https://relay.test", + frpsAddr: "relay.test", + frpsPort: 7000, + relayDomain: "relay.test", + tlsServerName: "relay.test", + frpToken: "tok", + caFile: "/tmp/ca.crt", + loopbackPorts: [8765, 8766, 8767], + bandwidth: "1220KB" +}; + +const SESSION: Session = { token: "gsk_abc", subdomain: "subdom7", account: "user@test" }; + +// A controllable fake frpc child: `start()` resolves (or rejects), `stop()` +// records the call, `exited` never settles unless stopped. +function fakeChild(opts: { startRejects?: Error } = {}): TunnelChild & { stopped: boolean } { + const exited = Promise.withResolvers(); + return { + stopped: false, + start() { + if (opts.startRejects) return Promise.reject(opts.startRejects); + return Promise.resolve(this); + }, + stop() { + this.stopped = true; + exited.resolve(0); + return Promise.resolve(0); + }, + exited: exited.promise + }; +} + +// A child whose `exited` can be resolved on demand to simulate frpc dying on its +// own (crash / relay drop) WITHOUT a stop() call. +function crashableChild(): TunnelChild & { stopped: boolean; crash: (code: number) => void } { + const exited = Promise.withResolvers(); + return { + stopped: false, + start() { + return Promise.resolve(this); + }, + stop() { + this.stopped = true; + exited.resolve(0); + return Promise.resolve(0); + }, + exited: exited.promise, + crash(code: number) { + exited.resolve(code); + } + }; +} + +// A child whose stop() REJECTS (best-effort teardown must swallow it). start() +// is gateable; exited never settles on its own. +function rejectStopChild( + gated = false +): TunnelChild & { releaseStart: () => void; stopCalls: number } { + const startGate = Promise.withResolvers(); + if (!gated) startGate.resolve(); + const child = { + stopCalls: 0, + start() { + return startGate.promise.then(() => child); + }, + stop() { + child.stopCalls += 1; + return Promise.reject(new Error("stop failed")); + }, + exited: Promise.withResolvers().promise, + releaseStart() { + startGate.resolve(); + } + }; + return child; +} + +// Poll the persisted tunnel status until it reaches `want` (the child-exit +// watcher writes asynchronously). Bounded at 600 * 5ms = 3000ms (under the +// 10000ms per-test cap) so a genuinely stuck state fails fast without flaking +// under CI load. +async function waitForStatus(c: RuntimeConfig, want: string): Promise { + for (let i = 0; i < 600; i += 1) { + if (getTunnel(c).status === want) return; + await Bun.sleep(5); + } + throw new Error(`tunnel status never reached "${want}" (stuck at "${getTunnel(c).status}")`); +} + +// A fake store: deviceId is stable; session read/write are no-ops we don't +// assert on (the login primitive is faked, so it never actually persists). +function fakeStore(): Store { + return { + home: "/tmp/gini-relay-fake", + deviceId: () => "device-1", + readSession: () => SESSION, + writeSession: () => {}, + clearSession: () => {} + }; +} + +// A fake store with NO persisted session, forcing the OAuth login path. +function fakeStoreNoSession(): Store { + return { + home: "/tmp/gini-relay-fake", + deviceId: () => "device-1", + readSession: () => null, + writeSession: () => {}, + clearSession: () => {} + }; +} + +// Builds a fake LoginHandle whose waitForSession resolves with SESSION by +// default. Overrides let a test make the login hang or reject. +function fakeLoginHandle(over: Partial = {}): LoginHandle { + return { + url: "https://relay.test/consent?x=1", + redirectUri: "http://127.0.0.1:8765/cb", + waitForSession: () => Promise.resolve(SESSION), + cancel: () => {}, + ...over + }; +} + +// Assemble an injectable deps set. Each seam is overridable per test. The +// base set fixes resolveLocalPort to a sentinel; the port-resolution tests +// use `depsNoPort` to exercise the REAL default resolver. +function deps(over: Partial = {}): Partial { + return { ...depsNoPort(), resolveLocalPort: () => 4321, ...over }; +} + +// Inert manual drivers: detection reports the default-disabled availability +// and connect/disconnect must never run. Every deps set includes these so no +// test can fall through to the REAL drivers and shell out to a host-installed +// tailscale/ngrok/cloudflared (whose presence would also flip catalog rows). +function fakeDrivers(over: Partial = {}): TunnelDeps["drivers"] { + const inert = (requires: string): ManualDriver => ({ + detect: () => Promise.resolve({ enabled: false, requires }), + connect: () => Promise.reject(new Error("manual driver connect must not run in this test")) + }); + return { + tailscale: inert("Tailscale network"), + ngrok: inert("ngrok account"), + cloudflare: inert("cloudflared CLI"), + ...over + }; +} + +// The same seams MINUS resolveLocalPort, so the module's default +// (env override -> gateway port config.port) runs. +function depsNoPort(over: Partial = {}): Partial { + const opened: string[] = []; + return { + loginUrl: () => Promise.resolve(fakeLoginHandle()), + buildTunnel: (_opts: TunnelOptions) => fakeChild(), + createStore: () => fakeStore(), + resolveDefaults: () => RELAY, + openBrowser: (url: string) => { + opened.push(url); + }, + probeLocalPort: () => Promise.resolve(true), + logout: () => Promise.resolve(), + persistWorkspaceGrant: () => Promise.resolve(), + drivers: fakeDrivers(), + ...over + }; +} + +// Deps whose store has NO session, so connect must run the browser login flow. +function depsLogin(over: Partial = {}): Partial { + return { ...deps(), createStore: () => fakeStoreNoSession(), ...over }; +} + +describe("tunnel integration", () => { + let config: RuntimeConfig; + // Snapshot every env knob the tunnel module reads and CLEAR it for each test, so + // a value left set in the ambient environment (or leaked by a prior test) can't + // change a test's outcome — e.g. an ambient GINI_TUNNEL_RESUME_WAIT_MS=0 would + // make the override poll time out immediately. Tests that need a specific value + // set it themselves; afterEach restores the original. + const TUNNEL_ENV_KEYS = [ + "GINI_TUNNEL_PORT", + "GINI_TUNNEL_RESUME_WAIT_MS", + "GINI_TUNNEL_RESUME_POLL_MS", + "GINI_TUNNEL_RECONNECT_MAX_ATTEMPTS", + "GINI_TUNNEL_RECONNECT_BASE_MS", + "GINI_TUNNEL_RECONNECT_MAX_MS", + "GINI_RELAY_PROVISIONED" + ] as const; + let prevEnv: Record = {}; + + beforeEach(() => { + config = testConfig(`t-${Math.random().toString(36).slice(2)}`); + prevEnv = {}; + for (const key of TUNNEL_ENV_KEYS) { + prevEnv[key] = process.env[key]; + delete process.env[key]; + } + setTunnelDeps(deps()); + }); + + afterEach(() => { + setTunnelDeps(); // restore the real gini-relay seams + // Restore env even if a test failed mid-assertion, so state never leaks. + for (const key of TUNNEL_ENV_KEYS) { + if (prevEnv[key] === undefined) delete process.env[key]; + else process.env[key] = prevEnv[key]; + } + rmSync(`${ROOT}/instances/${config.instance}`, { recursive: true, force: true }); + }); + + // GET on a fresh instance: the catalog is present, nothing selected, idle. + test("getTunnel on a fresh instance returns catalog + idle/null", () => { + const state = getTunnel(config); + expect(state.selectedProvider).toBeNull(); + expect(state.status).toBe("idle"); + expect(state.url).toBeUndefined(); + expect(state.message).toBeUndefined(); + expect(state.providers.map((p) => p.id)).toEqual([ + "gini-relay", + "tailscale", + "ngrok", + "cloudflare" + ]); + }); + + // The catalog values must match the agreed contract exactly: only + // gini-relay enabled (until detection finds a manual CLI); the rest disabled + // with a `requires` reason. Setup guidance lives in docs/remote-access/.md, + // not in the catalog. + test("provider catalog matches the agreed shape", () => { + const byId = Object.fromEntries(getTunnel(config).providers.map((p) => [p.id, p])); + expect(byId["gini-relay"]).toEqual({ id: "gini-relay", name: "Gini Relay", enabled: true }); + expect(byId.tailscale).toEqual({ + id: "tailscale", + name: "Tailscale", + enabled: false, + requires: "Tailscale network" + }); + expect(byId.ngrok).toEqual({ + id: "ngrok", + name: "ngrok", + enabled: false, + requires: "ngrok account" + }); + expect(byId.cloudflare).toEqual({ + id: "cloudflare", + name: "Cloudflare", + enabled: false, + requires: "cloudflared CLI" + }); + }); + + // select saves the choice without connecting; status stays idle. + test("selectProvider saves selection, stays idle, audits", async () => { + const state = await selectProvider(config, "gini-relay"); + expect(state.selectedProvider).toBe("gini-relay"); + expect(state.status).toBe("idle"); + expect(state.url).toBeUndefined(); + const persisted = readState(config.instance); + expect(persisted.tunnel?.selectedProvider).toBe("gini-relay"); + expect(persisted.audit.some((a) => a.action === "tunnel.select")).toBe(true); + }); + + // Unknown provider -> reject, no state mutation. + test("selectProvider rejects an unknown provider", async () => { + await expect(selectProvider(config, "bogus")).rejects.toThrow("Unknown tunnel provider: bogus"); + expect(readState(config.instance).tunnel).toBeNull(); + }); + + // Disabled provider -> reject with the requires hint. + test("selectProvider rejects a disabled provider with the requires hint", async () => { + await expect(selectProvider(config, "ngrok")).rejects.toThrow("not available (requires ngrok account)"); + expect(readState(config.instance).tunnel).toBeNull(); + }); + + // Re-selecting the provider you're already connected to is a no-op: the live + // tunnel stays up (the edit panel must not disconnect on a redundant click). + test("re-selecting the connected provider is a no-op and keeps the tunnel live", async () => { + const child = fakeChild(); + setTunnelDeps(deps({ buildTunnel: () => child })); + await selectProvider(config, "gini-relay"); + await connectTunnel(config); + await awaitTunnelSettled(config.instance); + expect(getTunnel(config).status).toBe("connected"); + const reselected = await selectProvider(config, "gini-relay"); + expect(reselected.status).toBe("connected"); + expect(reselected.url).toBe("https://subdom7.relay.test"); + expect(child.stopped).toBe(false); + }); + + // connect using the saved selection: returns "connecting" immediately, then + // the background flow settles to "connected" with the public url and opens + // the consent URL in the host browser. + test("connectTunnel reuses a stored session (no browser) and settles connected", async () => { + const opened: string[] = []; + let loginCalls = 0; + setTunnelDeps(deps({ + openBrowser: (url) => opened.push(url), + loginUrl: () => { + loginCalls += 1; + return Promise.resolve(fakeLoginHandle()); + } + })); + await selectProvider(config, "gini-relay"); + const connecting = await connectTunnel(config); + expect(connecting.status).toBe("connecting"); + expect(connecting.url).toBeUndefined(); + await awaitTunnelSettled(config.instance); + const settled = getTunnel(config); + expect(settled.status).toBe("connected"); + expect(settled.url).toBe("https://subdom7.relay.test"); + expect(settled.selectedProvider).toBe("gini-relay"); + // The stored session was reused: no browser opened, no re-login. + expect(opened).toEqual([]); + expect(loginCalls).toBe(0); + const persisted = readState(config.instance); + expect(persisted.tunnel?.subdomain).toBe("subdom7"); + expect(persisted.audit.some((a) => a.action === "tunnel.connect")).toBe(true); + expect(persisted.audit.some((a) => a.action === "tunnel.connected")).toBe(true); + }); + + // No stored session -> connect runs the browser OAuth login flow. + test("connectTunnel logs in via the browser when there is no stored session", async () => { + const opened: string[] = []; + setTunnelDeps(depsLogin({ openBrowser: (url) => opened.push(url) })); + await connectTunnel(config, "gini-relay"); + await awaitTunnelSettled(config.instance); + const settled = getTunnel(config); + expect(settled.status).toBe("connected"); + expect(settled.url).toBe("https://subdom7.relay.test"); + expect(opened).toEqual(["https://relay.test/consent?x=1"]); + }); + + // A fresh login with GINI_RELAY_PROVISIONED set requests the Workspace + // services, so the relay can capture the grant in one consent. + test("connectTunnel requests Workspace services on a fresh login when provisioned", async () => { + process.env.GINI_RELAY_PROVISIONED = "true"; // restored in afterEach + let seen: string[] | undefined = ["sentinel"]; + setTunnelDeps( + depsLogin({ + loginUrl: (deps) => { + seen = deps.services; + return Promise.resolve(fakeLoginHandle()); + } + }) + ); + await connectTunnel(config, "gini-relay"); + await awaitTunnelSettled(config.instance); + expect(getTunnel(config).status).toBe("connected"); + expect(seen).toEqual(["calendar", "gmail", "drive", "sheets"]); + }); + + // Without the flag, a fresh login is identity-only: no services field, so the + // relay request is byte-for-byte the prior behavior. + test("connectTunnel omits services on a fresh login when not provisioned", async () => { + let seen: string[] | undefined = ["sentinel"]; + setTunnelDeps( + depsLogin({ + loginUrl: (deps) => { + seen = deps.services; + return Promise.resolve(fakeLoginHandle()); + } + }) + ); + await connectTunnel(config, "gini-relay"); + await awaitTunnelSettled(config.instance); + expect(getTunnel(config).status).toBe("connected"); + expect(seen).toBeUndefined(); + }); + + // A fresh login whose session carries a Workspace refresh token persists the + // grant after the tunnel connects (so gws can use it), passing the token through. + test("connectTunnel persists the Workspace grant when the login returns a refresh token", async () => { + let persisted: string | undefined; + const handle = fakeLoginHandle({ + waitForSession: () => Promise.resolve({ ...SESSION, refreshToken: "rt-from-relay" }) + }); + setTunnelDeps( + depsLogin({ + loginUrl: () => Promise.resolve(handle), + persistWorkspaceGrant: (rt) => { + persisted = rt; + return Promise.resolve(); + } + }) + ); + await connectTunnel(config, "gini-relay"); + await awaitTunnelSettled(config.instance); + expect(getTunnel(config).status).toBe("connected"); + expect(persisted).toBe("rt-from-relay"); + }); + + // An identity-only login (no refresh token) never calls the persist seam. + test("connectTunnel does not persist a grant when the session has no refresh token", async () => { + let called = false; + setTunnelDeps( + depsLogin({ + persistWorkspaceGrant: () => { + called = true; + return Promise.resolve(); + } + }) + ); + await connectTunnel(config, "gini-relay"); + await awaitTunnelSettled(config.instance); + expect(getTunnel(config).status).toBe("connected"); + expect(called).toBe(false); + }); + + // A RESUMED session (read from the store, not a fresh consent) that carries a + // refresh token still calls the persist seam — persistence is idempotent, so + // running it on every connect heals a grant that a prior fresh login failed to + // land, without duplicating the account (the dedup lives in + // defaultPersistWorkspaceGrant, exercised in its own unit tests below). + test("connectTunnel persists the grant when reusing a stored session with a refresh token", async () => { + let persisted: string | undefined; + const storedWithToken: Store = { + home: "/tmp/gini-relay-fake", + deviceId: () => "device-1", + readSession: () => ({ ...SESSION, refreshToken: "rt-stored" }), + writeSession: () => {}, + clearSession: () => {} + }; + setTunnelDeps( + deps({ + createStore: () => storedWithToken, + persistWorkspaceGrant: (rt) => { + persisted = rt; + return Promise.resolve(); + } + }) + ); + await connectTunnel(config, "gini-relay"); + await awaitTunnelSettled(config.instance); + expect(getTunnel(config).status).toBe("connected"); + expect(persisted).toBe("rt-stored"); + }); + + // Persisting the grant is best-effort: a failure must NOT downgrade an + // already-connected tunnel. + test("connectTunnel stays connected when persisting the Workspace grant fails", async () => { + const handle = fakeLoginHandle({ + waitForSession: () => Promise.resolve({ ...SESSION, refreshToken: "rt-boom" }) + }); + setTunnelDeps( + depsLogin({ + loginUrl: () => Promise.resolve(handle), + persistWorkspaceGrant: () => Promise.reject(new Error("gws not installed")) + }) + ); + await connectTunnel(config, "gini-relay"); + await awaitTunnelSettled(config.instance); + expect(getTunnel(config).status).toBe("connected"); + }); + + // A stored session the relay rejects (revoked) -> connect self-heals by + // falling back to a fresh login and retrying the tunnel build. + test("connectTunnel falls back to login when the stored session is rejected", async () => { + let build = 0; + const opened: string[] = []; + setTunnelDeps(deps({ + openBrowser: (url) => opened.push(url), + buildTunnel: () => fakeChild(build++ === 0 ? { startRejects: new Error("relay: session rejected") } : {}) + })); + await connectTunnel(config, "gini-relay"); + await awaitTunnelSettled(config.instance); + expect(getTunnel(config).status).toBe("connected"); + expect(opened).toEqual(["https://relay.test/consent?x=1"]); + expect(build).toBe(2); + }); + + // connect with an explicit provider arg overrides the saved selection. + test("connectTunnel honors an explicit provider override", async () => { + const state = await connectTunnel(config, "gini-relay"); + expect(state.status).toBe("connecting"); + expect(state.selectedProvider).toBe("gini-relay"); + await awaitTunnelSettled(config.instance); + expect(getTunnel(config).status).toBe("connected"); + }); + + // connect with no selection and no arg -> reject. + test("connectTunnel with no provider selected rejects", async () => { + await expect(connectTunnel(config)).rejects.toThrow("No tunnel provider selected."); + }); + + // connect with an unknown explicit provider -> reject. + test("connectTunnel rejects an unknown explicit provider", async () => { + await expect(connectTunnel(config, "bogus")).rejects.toThrow("Unknown tunnel provider: bogus"); + }); + + // connect with a disabled explicit provider -> reject. + test("connectTunnel rejects a disabled explicit provider", async () => { + await expect(connectTunnel(config, "tailscale")).rejects.toThrow("not available (requires Tailscale network)"); + }); + + // A login failure flips the record to "error" with the message; no url. + test("connectTunnel records error when login fails", async () => { + setTunnelDeps(depsLogin({ loginUrl: () => Promise.reject(new Error("relay returned no login url")) })); + await connectTunnel(config, "gini-relay"); + await awaitTunnelSettled(config.instance); + const state = getTunnel(config); + expect(state.status).toBe("error"); + expect(state.message).toBe("relay returned no login url"); + expect(state.url).toBeUndefined(); + expect(readState(config.instance).audit.some((a) => a.action === "tunnel.error")).toBe(true); + }); + + // A frpc start failure (after a good login) also flips to "error". + test("connectTunnel records error when frpc start fails", async () => { + setTunnelDeps(deps({ buildTunnel: () => fakeChild({ startRejects: new Error("frpc: boom") }) })); + await connectTunnel(config, "gini-relay"); + await awaitTunnelSettled(config.instance); + const state = getTunnel(config); + expect(state.status).toBe("error"); + expect(state.message).toBe("frpc: boom"); + }); + + // A non-Error rejection is stringified into the message (String(error) path). + test("connectTunnel stringifies a non-Error rejection", async () => { + setTunnelDeps(depsLogin({ loginUrl: () => Promise.reject("plain string failure") })); + await connectTunnel(config, "gini-relay"); + await awaitTunnelSettled(config.instance); + expect(getTunnel(config).message).toBe("plain string failure"); + }); + + // Cancelling mid-login: the pending login is aborted, the record is idle, + // and the background flow that loses the race must NOT clobber idle with + // an error (the registry entry was cleared by the cancel). + test("cancelTunnel during a pending login aborts and stays idle", async () => { + const { promise: hang } = Promise.withResolvers(); + let cancelled = false; + const handle = fakeLoginHandle({ + waitForSession: () => hang, // never resolves on its own + cancel: () => { + cancelled = true; + } + }); + setTunnelDeps(depsLogin({ loginUrl: () => Promise.resolve(handle) })); + await selectProvider(config, "gini-relay"); + await connectTunnel(config); + // Let runConnect reach waitForSession (login handle stored). + await Promise.resolve(); + const state = await cancelTunnel(config); + expect(state.status).toBe("idle"); + expect(state.selectedProvider).toBe("gini-relay"); + expect(cancelled).toBe(true); + expect(readState(config.instance).audit.some((a) => a.action === "tunnel.cancel")).toBe(true); + // The background flow won't settle (the login hangs); the record stays idle. + expect(getTunnel(config).status).toBe("idle"); + }); + + // cancel returns to idle, keeps the selection, clears url/message. + test("cancelTunnel returns idle keeping the selection", async () => { + await selectProvider(config, "gini-relay"); + await connectTunnel(config); + await awaitTunnelSettled(config.instance); + const state = await cancelTunnel(config); + expect(state.status).toBe("idle"); + expect(state.selectedProvider).toBe("gini-relay"); + expect(state.url).toBeUndefined(); + }); + + // cancel with nothing selected still works (selection stays null) and the + // teardown no-ops cleanly (no supervisor registered). + test("cancelTunnel with no selection yields idle/null", async () => { + const state = await cancelTunnel(config); + expect(state.status).toBe("idle"); + expect(state.selectedProvider).toBeNull(); + }); + + // disconnect tears down a live tunnel (stops the frpc child), keeps selection. + test("disconnectTunnel stops the child and returns idle keeping the selection", async () => { + const child = fakeChild(); + setTunnelDeps(deps({ buildTunnel: () => child })); + await selectProvider(config, "gini-relay"); + await connectTunnel(config); + await awaitTunnelSettled(config.instance); + expect(getTunnel(config).status).toBe("connected"); + const state = await disconnectTunnel(config); + expect(state.status).toBe("idle"); + expect(state.selectedProvider).toBe("gini-relay"); + expect(state.url).toBeUndefined(); + expect(child.stopped).toBe(true); + expect(readState(config.instance).audit.some((a) => a.action === "tunnel.disconnect")).toBe(true); + }); + + // disconnect with nothing selected -> idle/null target audited as "none". + test("disconnectTunnel with no selection yields idle/null", async () => { + const state = await disconnectTunnel(config); + expect(state.status).toBe("idle"); + expect(state.selectedProvider).toBeNull(); + const audit = readState(config.instance).audit.find((a) => a.action === "tunnel.disconnect"); + expect(audit?.target).toBe("none"); + }); + + // A second connect tears down the prior in-flight login before starting a + // fresh one (covers the teardown-with-pending-login branch in connect). + test("connectTunnel tears down a prior pending login before reconnecting", async () => { + const { promise: hang } = Promise.withResolvers(); + let firstCancelled = false; + const first = fakeLoginHandle({ waitForSession: () => hang, cancel: () => { firstCancelled = true; } }); + let call = 0; + setTunnelDeps(depsLogin({ + loginUrl: () => Promise.resolve(call++ === 0 ? first : fakeLoginHandle()) + })); + await connectTunnel(config, "gini-relay"); + await Promise.resolve(); + await connectTunnel(config, "gini-relay"); + await awaitTunnelSettled(config.instance); + expect(firstCancelled).toBe(true); + expect(getTunnel(config).status).toBe("connected"); + }); + + // A teardown whose child.stop() rejects is swallowed (best-effort path). + test("disconnectTunnel swallows a child.stop() rejection", async () => { + const child: TunnelChild = { + start: () => Promise.resolve(), + stop: () => Promise.reject(new Error("already gone")), + exited: Promise.withResolvers().promise + }; + setTunnelDeps(deps({ buildTunnel: () => child })); + await connectTunnel(config, "gini-relay"); + await awaitTunnelSettled(config.instance); + const state = await disconnectTunnel(config); + expect(state.status).toBe("idle"); + }); + + // A login.cancel() that throws during teardown is swallowed. + test("cancelTunnel swallows a login.cancel() throw", async () => { + const { promise: hang } = Promise.withResolvers(); + const handle = fakeLoginHandle({ + waitForSession: () => hang, + cancel: () => { throw new Error("cancel blew up"); } + }); + setTunnelDeps(depsLogin({ loginUrl: () => Promise.resolve(handle) })); + await connectTunnel(config, "gini-relay"); + await Promise.resolve(); + const state = await cancelTunnel(config); + expect(state.status).toBe("idle"); + }); + + // The default resolveLocalPort path: env override wins. + test("connectTunnel uses GINI_TUNNEL_PORT override for the local port", async () => { + process.env.GINI_TUNNEL_PORT = "9911"; // restored in afterEach + let seenPort = -1; + setTunnelDeps(depsNoPort({ + buildTunnel: (opts) => { + seenPort = opts.port; + return fakeChild(); + } + })); + await connectTunnel(config, "gini-relay"); + await awaitTunnelSettled(config.instance); + expect(seenPort).toBe(9911); + }); + + // The default resolveLocalPort path: with no env override, the tunnel exposes + // the instance's GATEWAY port (config.port). The gateway fronts both the + // native /api/* surface and the reverse-proxied web app, so one relay URL + // serves the API and the UI. + test("connectTunnel exposes the gateway port (config.port) for the local port", async () => { + delete process.env.GINI_TUNNEL_PORT; // restored in afterEach + let seenPort = -1; + setTunnelDeps(depsNoPort({ + buildTunnel: (opts) => { + seenPort = opts.port; + return fakeChild(); + } + })); + await connectTunnel(config, "gini-relay"); + await awaitTunnelSettled(config.instance); + expect(seenPort).toBe(config.port); + }); + + // disconnect fully logs out: it invokes the logout seam (revoke + clear) so a + // later connect must log in again. + test("disconnectTunnel logs out so a later connect must re-login", async () => { + const child = fakeChild(); + let logoutCalls = 0; + setTunnelDeps(deps({ + buildTunnel: () => child, + logout: () => { + logoutCalls += 1; + return Promise.resolve(); + } + })); + await selectProvider(config, "gini-relay"); + await connectTunnel(config); + await awaitTunnelSettled(config.instance); + const state = await disconnectTunnel(config); + expect(state.status).toBe("idle"); + expect(state.selectedProvider).toBe("gini-relay"); + expect(child.stopped).toBe(true); + expect(logoutCalls).toBe(1); + }); + + // disconnect must settle to idle even when logout throws (best-effort). + test("disconnectTunnel settles idle even when logout throws", async () => { + setTunnelDeps(deps({ logout: () => Promise.reject(new Error("revoke failed")) })); + await selectProvider(config, "gini-relay"); + const state = await disconnectTunnel(config); + expect(state.status).toBe("idle"); + }); + + // defaultLogout: no stored session -> nothing to clear. + test("defaultLogout no-ops when there is no stored session", async () => { + let unlinked = 0; + await defaultLogout( + config.instance, + () => fakeStoreNoSession(), + () => { + unlinked += 1; + } + ); + expect(unlinked).toBe(0); + }); + + // defaultLogout: deletes the local session file so the next connect re-logs-in. + test("defaultLogout deletes the local session file", async () => { + let unlinkedPath = ""; + await defaultLogout( + config.instance, + () => fakeStore(), + (p) => { + unlinkedPath = p; + } + ); + expect(unlinkedPath).toContain("session.json"); + }); + + // defaultLogout: an unlink failure (file already gone) is swallowed. + test("defaultLogout swallows an unlink failure", async () => { + await defaultLogout( + config.instance, + () => fakeStore(), + () => { + throw new Error("gone"); + } + ); + }); + + // The local-port readiness guard: if nothing serves the resolved local + // port, connect must NOT advertise a connected tunnel — it records "error". + test("connectTunnel errors when the local web port isn't serving", async () => { + setTunnelDeps(deps({ probeLocalPort: () => Promise.resolve(false) })); + await connectTunnel(config, "gini-relay"); + await awaitTunnelSettled(config.instance); + const state = getTunnel(config); + expect(state.status).toBe("error"); + expect(state.message).toBe( + "Gini's web UI isn't responding on port 4321 — start it, then reconnect." + ); + expect(state.url).toBeUndefined(); + }); + + // The default host-browser opener shells out to `open ` via Bun.spawn. + // Driven with a stubbed spawn so no real browser launches. + test("defaultOpenBrowser spawns `open` with the url", () => { + const calls: string[][] = []; + const fakeSpawn = ((cmd: string[]) => { + calls.push(cmd); + return {} as ReturnType; + }) as typeof Bun.spawn; + defaultOpenBrowser("https://relay.test/consent", fakeSpawn); + expect(calls).toEqual([["open", "https://relay.test/consent"]]); + }); + + // The connect-aborted branch: the login rejects AFTER a cancel has torn + // down the supervisor, so runConnect must log "aborted" and NOT clobber the + // idle record the cancel wrote with a spurious error. + test("connectTunnel aborts cleanly when teardown wins the race against a login failure", async () => { + const { promise: gate, resolve: openGate } = Promise.withResolvers(); + // Login that only rejects once the test releases the gate — by then the + // cancel below has already deleted this instance's supervisor entry. + const settled = (async () => { + await gate; + throw new Error("relay error getting login URL"); + })(); + setTunnelDeps(depsLogin({ loginUrl: () => settled.then(() => fakeLoginHandle()) })); + await selectProvider(config, "gini-relay"); + await connectTunnel(config); + // Tear down before the login settles. + await cancelTunnel(config); + expect(getTunnel(config).status).toBe("idle"); + // Release the login failure; the background flow takes the aborted path. + // The supervisor entry was deleted by the cancel, so awaitTunnelSettled + // can't track it — flush the rejection + several microtasks instead. + openGate(); + await settled.catch(() => {}); + for (let i = 0; i < 5; i += 1) await Promise.resolve(); + // The cancel's idle record stands — no error clobber. + expect(getTunnel(config).status).toBe("idle"); + }); + + // A connect cancelled DURING the port probe must bail before the login — + // never opening an OAuth browser tab for a connect the user abandoned. + test("connect cancelled during the port probe never opens a browser", async () => { + const probeGate = Promise.withResolvers(); + const opened: string[] = []; + setTunnelDeps( + depsLogin({ probeLocalPort: () => probeGate.promise, openBrowser: (url) => opened.push(url) }) + ); + await selectProvider(config, "gini-relay"); + await connectTunnel(config); + // runConnect is parked on the probe await; cancel before it resolves. + await cancelTunnel(config); + expect(getTunnel(config).status).toBe("idle"); + probeGate.resolve(true); + for (let i = 0; i < 5; i += 1) await Promise.resolve(); + expect(opened).toEqual([]); + expect(getTunnel(config).status).toBe("idle"); + }); + + // The reuse path (stored session) is guarded too: a connect cancelled during + // the probe must never spawn frpc. + test("connect cancelled during the probe never spawns frpc (stored session)", async () => { + const probeGate = Promise.withResolvers(); + let built = 0; + setTunnelDeps( + deps({ + probeLocalPort: () => probeGate.promise, + buildTunnel: () => { + built += 1; + return fakeChild(); + } + }) + ); + await selectProvider(config, "gini-relay"); + await connectTunnel(config); + await cancelTunnel(config); + expect(getTunnel(config).status).toBe("idle"); + probeGate.resolve(true); + for (let i = 0; i < 5; i += 1) await Promise.resolve(); + expect(built).toBe(0); + expect(getTunnel(config).status).toBe("idle"); + }); + + // A connect cancelled DURING loginUrl (after the probe) must cancel the + // freshly-minted login handle and bail before opening a browser. + test("connect cancelled during loginUrl cancels the handle and never opens a browser", async () => { + const loginGate = Promise.withResolvers(); + let cancelled = false; + const opened: string[] = []; + setTunnelDeps( + depsLogin({ + loginUrl: () => loginGate.promise.then(() => fakeLoginHandle({ cancel: () => { cancelled = true; } })), + openBrowser: (url) => opened.push(url) + }) + ); + await selectProvider(config, "gini-relay"); + await connectTunnel(config); + // Let runConnect pass the probe + first guard and park at loginUrl. + for (let i = 0; i < 5; i += 1) await Promise.resolve(); + await cancelTunnel(config); + expect(getTunnel(config).status).toBe("idle"); + loginGate.resolve(); + for (let i = 0; i < 10; i += 1) await Promise.resolve(); + expect(cancelled).toBe(true); + expect(opened).toEqual([]); + expect(getTunnel(config).status).toBe("idle"); + }); + + // If a login fails AFTER loginUrl bound its loopback (e.g. waitForSession + // rejects), the error path must cancel the handle so the loopback isn't leaked. + test("a login failing after loginUrl cancels the handle (no loopback leak)", async () => { + let cancelled = false; + setTunnelDeps( + depsLogin({ + loginUrl: () => + Promise.resolve( + fakeLoginHandle({ + waitForSession: () => Promise.reject(new Error("relay session exchange failed")), + cancel: () => { + cancelled = true; + } + }) + ) + }) + ); + await selectProvider(config, "gini-relay"); + await connectTunnel(config); + await awaitTunnelSettled(config.instance); + expect(getTunnel(config).status).toBe("error"); + expect(cancelled).toBe(true); + }); + + // The real gini-relay seam wrappers delegate to the library without any + // network I/O: createStore (instance-scoped relayHome under the temp ROOT), + // resolveDefaults (pure), and buildTunnel (pure construction — frpc isn't + // spawned until start()). loginUrl is the live network primitive, so it's + // only referenced, never invoked here. + test("makeDefaultDeps wires the real gini-relay primitives", async () => { + const real = makeDefaultDeps(); + const relay = real.resolveDefaults(); + expect(typeof relay.relayUrl).toBe("string"); + const store = real.createStore(config); + expect(typeof store.deviceId()).toBe("string"); + const child = real.buildTunnel({ + session: SESSION, + deviceId: store.deviceId(), + port: 4321, + defaults: relay + }); + expect(typeof child.start).toBe("function"); + expect(typeof real.loginUrl).toBe("function"); + expect(real.resolveLocalPort(config)).toBeGreaterThan(0); + expect(typeof real.probeLocalPort).toBe("function"); + expect(typeof (await real.probeLocalPort(config, 1))).toBe("boolean"); + expect(typeof real.logout).toBe("function"); + await real.logout(config); + expect(typeof real.persistWorkspaceGrant).toBe("function"); + }); + + // defaultPersistWorkspaceGrant writes the gws authorized_user credential into a + // minted config dir and registers it; the register seam is faked so no real gws + // or disk-registry is touched. Signature: + // (refreshToken, principal, register, makeConfigDir, mkdir, writeFile, findProvisioned). + test("defaultPersistWorkspaceGrant writes the credential and registers the account", async () => { + const dir = mkdtempSync(join(tmpdir(), "gini-ws-grant-")); + const writes: Array<{ path: string; body: string }> = []; + const registered: Array<{ tag: string; configDir: string; trusted?: boolean; principal?: string }> = []; + await defaultPersistWorkspaceGrant( + "rt-live", + "sub-123", + async (input) => { + registered.push(input); + return { id: "gacct_x", tag: input.tag, email: "wilson@lilaclabs.ai", configDir: input.configDir, addedAt: "t" }; + }, + () => dir, + () => {}, // dir already exists (mkdtemp) + (path, body) => writes.push({ path, body }), + () => null // no existing provisioned account → mint the dir under test + ); + expect(writes).toHaveLength(1); + expect(writes[0]!.path).toBe(join(dir, "credentials.json")); + const cred = JSON.parse(writes[0]!.body) as Record; + expect(cred.type).toBe("authorized_user"); + expect(cred.refresh_token).toBe("rt-live"); + expect(cred.client_id).toMatch(/\.apps\.googleusercontent\.com$/); + // trusted:true so registration skips the live gws probe; the principal is + // threaded so distinct identities keep separate dirs. + expect(registered).toEqual([{ tag: "workspace", configDir: dir, trusted: true, principal: "sub-123" }]); + rmSync(dir, { recursive: true, force: true }); + }); + + // Exercise the REAL default mkdir/writeFile/makeConfigDir/findProvisioned arrows + // (HOME pointed at an empty temp dir so the registry read is empty and + // configDirForAccount lands somewhere disposable); only the register seam is + // faked so no gws subprocess runs. + test("defaultPersistWorkspaceGrant uses real fs defaults to write the credential", async () => { + const home = mkdtempSync(join(tmpdir(), "gini-ws-home-")); + const prevHome = process.env.HOME; + process.env.HOME = home; + let writtenDir = ""; + try { + await defaultPersistWorkspaceGrant("rt-realfs", undefined, async (input) => { + writtenDir = input.configDir; + return { id: "gacct_z", tag: input.tag, email: "", configDir: input.configDir, addedAt: "t" }; + }); + // The default makeConfigDir lands under ~/.gini/google-accounts/, the + // default mkdir created it, and the default writeFile dropped credentials.json. + expect(writtenDir).toContain(join(home, ".gini", "google-accounts")); + const cred = JSON.parse(readFileSync(join(writtenDir, "credentials.json"), "utf8")) as Record; + expect(cred.refresh_token).toBe("rt-realfs"); + expect(cred.type).toBe("authorized_user"); + } finally { + if (prevHome === undefined) delete process.env.HOME; + else process.env.HOME = prevHome; + rmSync(home, { recursive: true, force: true }); + } + }); + + // A tag collision (a DIFFERENT account already holds PROVISIONED_TAG) must not + // throw: the first register rejects, the retry uses a unique tag and succeeds. + test("defaultPersistWorkspaceGrant retries with a unique tag on collision", async () => { + const dir = mkdtempSync(join(tmpdir(), "gini-ws-grant-")); + const tags: string[] = []; + await defaultPersistWorkspaceGrant( + "rt-2", + undefined, + async (input) => { + tags.push(input.tag); + if (tags.length === 1) throw new Error('tagged "workspace" already exists'); + return { id: "gacct_y", tag: input.tag, email: "", configDir: input.configDir, addedAt: "t" }; + }, + () => dir, + () => {}, + () => {}, + () => null // no existing provisioned account → mint the dir under test + ); + expect(tags).toHaveLength(2); + expect(tags[0]).toBe("workspace"); + expect(tags[1]).toMatch(/^workspace-gacct_/); + rmSync(dir, { recursive: true, force: true }); + }); + + // The retry after a tag collision must ALSO be trusted — otherwise the second + // attempt would re-introduce the live gws probe the fix is removing. + test("defaultPersistWorkspaceGrant marks both the first attempt and the collision retry trusted", async () => { + const dir = mkdtempSync(join(tmpdir(), "gini-ws-grant-")); + const trustedFlags: Array = []; + await defaultPersistWorkspaceGrant( + "rt-3", + undefined, + async (input) => { + trustedFlags.push(input.trusted); + if (trustedFlags.length === 1) throw new Error('tagged "workspace" already exists'); + return { id: "gacct_w", tag: input.tag, email: "", configDir: input.configDir, addedAt: "t" }; + }, + () => dir, + () => {}, + () => {}, + () => null + ); + expect(trustedFlags).toEqual([true, true]); + rmSync(dir, { recursive: true, force: true }); + }); + + // Idempotency: when a provisioned account already exists, re-persisting reuses + // its config dir (rewriting the refreshed credential in place) instead of + // minting a new one — so a reconnect never accumulates duplicate accounts — AND + // keeps the user's current tag instead of forcing it back to PROVISIONED_TAG. + test("defaultPersistWorkspaceGrant reuses the existing provisioned dir and tag on re-persist", async () => { + const existingDir = mkdtempSync(join(tmpdir(), "gini-ws-existing-")); + const registered: Array<{ tag: string; configDir: string; trusted?: boolean; principal?: string }> = []; + let minted = false; + const writes: Array<{ path: string; body: string }> = []; + await defaultPersistWorkspaceGrant( + "rt-refreshed", + "sub-abc", + async (input) => { + registered.push(input); + return { id: "gacct_x", tag: input.tag, email: "", configDir: input.configDir, addedAt: "t" }; + }, + () => { + minted = true; // makeConfigDir must NOT be consulted when one exists + return "/tmp/should-not-be-used"; + }, + () => {}, + (path, body) => writes.push({ path, body }), + // The user retagged this provisioned account to "work"; reuse must keep it. + () => ({ configDir: existingDir, tag: "work" }) + ); + expect(minted).toBe(false); + expect(registered).toEqual([{ tag: "work", configDir: existingDir, trusted: true, principal: "sub-abc" }]); + // The refreshed credential is rewritten into the SAME dir. + expect(writes).toHaveLength(1); + expect(writes[0]!.path).toBe(join(existingDir, "credentials.json")); + const cred = JSON.parse(writes[0]!.body) as Record; + expect(cred.refresh_token).toBe("rt-refreshed"); + rmSync(existingDir, { recursive: true, force: true }); + }); + + // The default findProvisioned seam, against a registry that ALREADY holds a + // provisioned account for THIS principal, returns its dir+tag so re-persist + // reuses it (exercises the real readGoogleAccounts filter+match). HOME is + // isolated so the registry is the one we seed, not the machine's. + test("defaultPersistWorkspaceGrant default seam reuses the provisioned account for the same principal", async () => { + const home = mkdtempSync(join(tmpdir(), "gini-ws-seeded-")); + const prevHome = process.env.HOME; + process.env.HOME = home; + const seededDir = join(home, ".gini", "google-accounts", "gacct_seeded1"); + const registered: Array<{ tag: string; configDir: string; trusted?: boolean; principal?: string }> = []; + let minted = false; + try { + addGoogleAccount({ id: "gacct_seeded1", tag: "work", email: "x@y.com", configDir: seededDir, addedAt: "t", provisioned: true, principal: "sub-seed" }); + await defaultPersistWorkspaceGrant( + "rt-reuse", + "sub-seed", + async (input) => { + registered.push(input); + return { id: "gacct_seeded1", tag: input.tag, email: "x@y.com", configDir: input.configDir, addedAt: "t" }; + }, + () => { + minted = true; + return "/tmp/should-not-be-used"; + }, + () => {}, + () => {} + // findProvisioned left at its default → matches principal → seededDir + tag "work" + ); + expect(minted).toBe(false); + expect(registered).toEqual([{ tag: "work", configDir: seededDir, trusted: true, principal: "sub-seed" }]); + } finally { + if (prevHome === undefined) delete process.env.HOME; + else process.env.HOME = prevHome; + rmSync(home, { recursive: true, force: true }); + } + }); + + // A DIFFERENT principal must NOT reuse another identity's provisioned dir — it + // mints its own, so one identity's credential never clobbers the other's. + test("defaultPersistWorkspaceGrant mints a separate dir for a different principal", async () => { + const home = mkdtempSync(join(tmpdir(), "gini-ws-principal-")); + const prevHome = process.env.HOME; + process.env.HOME = home; + const otherDir = join(home, ".gini", "google-accounts", "gacct_other"); + const freshDir = mkdtempSync(join(tmpdir(), "gini-ws-mine-")); + const registeredDirs: string[] = []; + let minted = false; + try { + // Account already provisioned for principal A. + addGoogleAccount({ id: "gacct_other", tag: "workspace", email: "a@y.com", configDir: otherDir, addedAt: "t", provisioned: true, principal: "sub-A" }); + // We connect as principal B. + await defaultPersistWorkspaceGrant( + "rt-B", + "sub-B", + async (input) => { + registeredDirs.push(input.configDir); + return { id: "gacct_n", tag: input.tag, email: "", configDir: input.configDir, addedAt: "t" }; + }, + () => { + minted = true; + return freshDir; + }, + () => {}, + () => {} + // default findProvisioned → no provisioned account for sub-B → null → mint freshDir + ); + expect(minted).toBe(true); + expect(registeredDirs).toEqual([freshDir]); // principal A's dir is untouched + } finally { + if (prevHome === undefined) delete process.env.HOME; + else process.env.HOME = prevHome; + rmSync(home, { recursive: true, force: true }); + rmSync(freshDir, { recursive: true, force: true }); + } + }); + + // Back-compat: a provisioned account recorded BEFORE principals were tracked + // has no `principal`. A connect that itself carries no principal must still + // reuse that unkeyed account (not mint a duplicate) — the default seam's + // no-principal arm. + test("defaultPersistWorkspaceGrant default seam reuses an unkeyed provisioned account when no principal is known", async () => { + const home = mkdtempSync(join(tmpdir(), "gini-ws-nokey-")); + const prevHome = process.env.HOME; + process.env.HOME = home; + const legacyDir = join(home, ".gini", "google-accounts", "gacct_legacy"); + const registered: Array<{ tag: string; configDir: string; trusted?: boolean; principal?: string }> = []; + let minted = false; + try { + // Provisioned, but no principal recorded (pre-principal row). + addGoogleAccount({ id: "gacct_legacy", tag: "workspace", email: "l@y.com", configDir: legacyDir, addedAt: "t", provisioned: true }); + await defaultPersistWorkspaceGrant( + "rt-legacy", + undefined, // connect carries no principal + async (input) => { + registered.push(input); + return { id: "gacct_legacy", tag: input.tag, email: "l@y.com", configDir: input.configDir, addedAt: "t" }; + }, + () => { + minted = true; + return "/tmp/should-not-be-used"; + }, + () => {}, + () => {} + // default findProvisioned → no-principal arm → legacyDir + tag "workspace" + ); + expect(minted).toBe(false); + expect(registered).toEqual([{ tag: "workspace", configDir: legacyDir, trusted: true, principal: undefined }]); + } finally { + if (prevHome === undefined) delete process.env.HOME; + else process.env.HOME = prevHome; + rmSync(home, { recursive: true, force: true }); + } + }); + + // The default findProvisioned seam reads the live registry: with no provisioned + // account present it returns null, so a fresh dir is minted. HOME points at an + // empty temp dir so the registry read is genuinely empty. + test("defaultPersistWorkspaceGrant mints a fresh dir when no provisioned account exists", async () => { + const home = mkdtempSync(join(tmpdir(), "gini-ws-emptyhome-")); + const prevHome = process.env.HOME; + process.env.HOME = home; + const dir = mkdtempSync(join(tmpdir(), "gini-ws-fresh-")); + let minted = false; + const registeredDirs: string[] = []; + try { + await defaultPersistWorkspaceGrant( + "rt-fresh", + undefined, + async (input) => { + registeredDirs.push(input.configDir); + return { id: "gacct_n", tag: input.tag, email: "", configDir: input.configDir, addedAt: "t" }; + }, + () => { + minted = true; + return dir; + }, + () => {}, + () => {} + // findProvisioned left at its default → reads the (empty) registry → null + ); + expect(minted).toBe(true); + expect(registeredDirs).toEqual([dir]); + } finally { + if (prevHome === undefined) delete process.env.HOME; + else process.env.HOME = prevHome; + rmSync(home, { recursive: true, force: true }); + rmSync(dir, { recursive: true, force: true }); + } + }); + + // awaitTunnelSettled resolves immediately when no connect is in flight. + test("awaitTunnelSettled resolves when nothing is connecting", async () => { + await expect(awaitTunnelSettled(config.instance)).resolves.toBeUndefined(); + }); + + // Seed a persisted "connected" tunnel record (as if the runtime was exposing a + // tunnel before this restart). The link is long-lasting, so boot should resume. + async function seedConnectedRecord(c: RuntimeConfig, provider: TunnelProviderId = "gini-relay"): Promise { + await mutateState(c.instance, (s) => { + s.tunnel = { + instance: c.instance, + selectedProvider: provider, + status: "connected", + url: "https://subdom7.relay.test", + subdomain: "subdom7", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z" + }; + }); + } + + // Startup reconcile RESUMES a tunnel that was connected before the restart by + // reusing the stored relay session (no browser login) — the link is meant to be + // long-lasting / 24-7, so it comes back automatically. It first flips to + // "connecting" so the first GET never reads a stale "connected". + test("reconcileTunnelOnStartup resumes a previously connected tunnel by reusing the session", async () => { + const child = fakeChild(); + let loginCalls = 0; + const opened: string[] = []; + setTunnelDeps( + deps({ + buildTunnel: () => child, + loginUrl: () => { + loginCalls += 1; + return Promise.resolve(fakeLoginHandle()); + }, + openBrowser: (url) => opened.push(url) + }) + ); + await seedConnectedRecord(config); + const immediate = await reconcileTunnelOnStartup(config); + expect(immediate.status).toBe("connecting"); + expect(immediate.selectedProvider).toBe("gini-relay"); + await awaitTunnelSettled(config.instance); + const settled = getTunnel(config); + expect(settled.status).toBe("connected"); + // Same deviceId-keyed subdomain — the link is stable across the restart. + expect(settled.url).toBe("https://subdom7.relay.test"); + // Headless resume: the stored session was reused, no browser/login. + expect(loginCalls).toBe(0); + expect(opened).toEqual([]); + expect(readState(config.instance).audit.some((a) => a.action === "tunnel.reconcile")).toBe(true); + }); + + // Regression (relay update "stuck on restarting"): a client connected through + // the relay has exactly ONE channel to the instance — the public URL the frpc + // child fronts. A restart kills that child, so the resume must bring it back. + // The web child the gateway reverse-proxies may still be (re)compiling at that + // instant, but the tunnel only fronts the GATEWAY port — so once this process + // owns that port (gatewayReady), reachability is restored WITHOUT blocking the + // rebuild on the web child. If the resume waited on web-readiness the remote + // browser would stay blind for the whole restart window and its update gate + // could never poll the restart to completion. + test("reconcileTunnelOnStartup resume restores reachability without waiting for the web child", async () => { + const ready = Promise.withResolvers(); + let built = 0; + let probed = 0; + setTunnelDeps( + deps({ + // The tunnel fronts this process's own gateway port. + resolveLocalPort: (c) => c.port, + // The web child is still down at resume time (mid-recompile after the + // restart). The tunnel must come back anyway — it fronts the gateway, + // not the web child, so this must never be consulted on the resume. + probeLocalPort: () => { + probed += 1; + return Promise.resolve(false); + }, + buildTunnel: () => { + built += 1; + return fakeChild(); + } + }) + ); + await seedConnectedRecord(config); + await reconcileTunnelOnStartup(config, { gatewayReady: ready.promise }); + ready.resolve(); + await awaitTunnelSettled(config.instance); + const settled = getTunnel(config); + expect(settled.status).toBe("connected"); + // Same deviceId-keyed subdomain — the link is stable across the restart. + expect(settled.url).toBe("https://subdom7.relay.test"); + expect(built).toBe(1); + // gatewayReady (the gateway-bind proof) sufficed — the web child was never + // probed, so a slow recompile can't keep the remote client blind. + expect(probed).toBe(0); + }); + + // Security: reconcileTunnelOnStartup runs before Bun.serve binds config.port, + // so the resume must NOT expose the relay URL until THIS process owns the port — + // otherwise the stable public URL could forward to a stale/foreign listener + // still holding it. The frpc rebuild waits on `gatewayReady`; until it resolves + // the record stays "connecting" and no tunnel is built. + test("reconcileTunnelOnStartup resume waits for the gateway port before exposing the tunnel", async () => { + const ready = Promise.withResolvers(); + let built = 0; + setTunnelDeps( + deps({ + resolveLocalPort: (c) => c.port, + buildTunnel: () => { + built += 1; + return fakeChild(); + } + }) + ); + await seedConnectedRecord(config); + await reconcileTunnelOnStartup(config, { gatewayReady: ready.promise }); + // The resume parked on gatewayReady (runConnect's first await): the status is + // "connecting" and no frpc child has been built yet. + expect(getTunnel(config).status).toBe("connecting"); + expect(built).toBe(0); + // The gateway binds → the resume proceeds and the tunnel comes up. + ready.resolve(); + await awaitTunnelSettled(config.instance); + expect(getTunnel(config).status).toBe("connected"); + expect(built).toBe(1); + }); + + // If a cancel/disconnect lands while the resume is parked on gatewayReady, it + // bails without building the tunnel and without clobbering the cancel's idle. + test("reconcileTunnelOnStartup resume bails if cancelled while waiting for the gateway port", async () => { + const ready = Promise.withResolvers(); + let built = 0; + setTunnelDeps( + deps({ + resolveLocalPort: (c) => c.port, + buildTunnel: () => { + built += 1; + return fakeChild(); + } + }) + ); + await seedConnectedRecord(config); + await reconcileTunnelOnStartup(config, { gatewayReady: ready.promise }); + const settled = awaitTunnelSettled(config.instance); + const cancelled = await cancelTunnel(config); + expect(cancelled.status).toBe("idle"); + // The port binds, but the resume was superseded — it must not expose anything. + ready.resolve(); + await settled; + expect(built).toBe(0); + expect(getTunnel(config).status).toBe("idle"); + }); + + // Security (GINI_TUNNEL_PORT override): the tunnel can be pointed at a port this + // process does NOT bind (resolveLocalPort ≠ config.port). gatewayReady proves + // only config.port, so it can't vouch for the override — the resume must instead + // verify that port's identity (a bounded, cancellable poll, since the override + // target may still be coming up after a restart) before exposing the stable + // public URL. It polls the local port and connects once it answers. + test("reconcileTunnelOnStartup resume polls an overridden tunnel port, then connects", async () => { + // Fast poll; the wait budget stays at its 60s default (beforeEach cleared any + // ambient GINI_TUNNEL_RESUME_WAIT_MS), so the three probes land well inside it. + process.env.GINI_TUNNEL_RESUME_POLL_MS = "1"; + const ready = Promise.withResolvers(); + ready.resolve(); + let probes = 0; + // resolveLocalPort returns 4321 (the deps() sentinel) ≠ config.port. + setTunnelDeps(deps({ probeLocalPort: () => Promise.resolve(++probes >= 3) })); + await seedConnectedRecord(config); + // gatewayReady is resolved but ignored for the override — verification is by + // probe, not the gateway bind. + await reconcileTunnelOnStartup(config, { gatewayReady: ready.promise }); + await awaitTunnelSettled(config.instance); + expect(getTunnel(config).status).toBe("connected"); + expect(probes).toBeGreaterThanOrEqual(3); + }); + + // If the overridden port never verifies within the budget, the resume settles + // idle and builds nothing — it must NOT forward the relay URL to an unverified + // (possibly foreign) listener, even though gatewayReady has resolved. + test("reconcileTunnelOnStartup resume settles idle when an overridden port never verifies", async () => { + process.env.GINI_TUNNEL_RESUME_WAIT_MS = "0"; // give up after the first failed probe + const ready = Promise.withResolvers(); + ready.resolve(); + let built = 0; + let probed = 0; + setTunnelDeps( + deps({ + probeLocalPort: () => { + probed += 1; + return Promise.resolve(false); + }, + buildTunnel: () => { + built += 1; + return fakeChild(); + } + }) + ); + await seedConnectedRecord(config); + await reconcileTunnelOnStartup(config, { gatewayReady: ready.promise }); + await awaitTunnelSettled(config.instance); + expect(probed).toBeGreaterThanOrEqual(1); + expect(built).toBe(0); + expect(getTunnel(config).status).toBe("idle"); + }); + + // A resume cancelled while polling an overridden port bails without clobbering + // the idle the cancel wrote (covers the supervisor-superseded guard in the poll). + test("reconcileTunnelOnStartup resume bails when cancelled while polling an overridden port", async () => { + process.env.GINI_TUNNEL_RESUME_POLL_MS = "5"; + setTunnelDeps(deps({ probeLocalPort: () => Promise.resolve(false) })); + await seedConnectedRecord(config); + await reconcileTunnelOnStartup(config); + // Capture the resume's settled promise BEFORE cancel tears down the + // supervisor, then await it: the poll wakes, sees it was superseded, and + // bails without clobbering the idle the cancel wrote. + const settled = awaitTunnelSettled(config.instance); + const cancelled = await cancelTunnel(config); + expect(cancelled.status).toBe("idle"); + await settled; + expect(getTunnel(config).status).toBe("idle"); + }); + + // Resume is non-interactive: with no stored session it must NOT open a browser + // or mint a login on a headless restart — it settles idle for a manual reconnect. + test("reconcileTunnelOnStartup resume settles idle (no login) when there is no stored session", async () => { + let loginCalls = 0; + const opened: string[] = []; + setTunnelDeps( + depsLogin({ + loginUrl: () => { + loginCalls += 1; + return Promise.resolve(fakeLoginHandle()); + }, + openBrowser: (url) => opened.push(url) + }) + ); + await seedConnectedRecord(config); + await reconcileTunnelOnStartup(config); + await awaitTunnelSettled(config.instance); + const settled = getTunnel(config); + expect(settled.status).toBe("idle"); + expect(settled.selectedProvider).toBe("gini-relay"); + expect(loginCalls).toBe(0); + expect(opened).toEqual([]); + }); + + // A resume cancelled mid-rebuild (the frpc start is in flight) bails without + // clobbering the idle the cancel wrote, and stops the child it spawned so it + // isn't orphaned (covers the post-start supervisor-superseded guard). + test("reconcileTunnelOnStartup resume bails when cancelled mid-rebuild", async () => { + const startGate = Promise.withResolvers(); + const startEntered = Promise.withResolvers(); + let stopped = 0; + const child: TunnelChild = { + start: () => { + startEntered.resolve(); + return startGate.promise.then(() => child); + }, + stop: () => { + stopped += 1; + return Promise.resolve(0); + }, + exited: Promise.withResolvers().promise + }; + setTunnelDeps(deps({ buildTunnel: () => child })); + await seedConnectedRecord(config); + await reconcileTunnelOnStartup(config); + // Wait until the resume has actually entered frpc start (deterministic — no + // reliance on microtask ordering) so the cancel below lands mid-rebuild. + await startEntered.promise; + // Capture the settled promise BEFORE cancel tears the supervisor down so the + // await is deterministic. + const settled = awaitTunnelSettled(config.instance); + const cancelled = await cancelTunnel(config); + expect(cancelled.status).toBe("idle"); + // Release the gated start: the resume sees it was superseded, stops the + // child it spawned, and bails without clobbering the cancel's idle. + startGate.resolve(); + await settled; + expect(getTunnel(config).status).toBe("idle"); + expect(stopped).toBeGreaterThanOrEqual(1); + }); + + // A "connected" record for a provider that is no longer enabled does NOT resume + // (covers the disabled-provider guard); it just resets to idle. + test("reconcileTunnelOnStartup does not resume a connected record for a disabled provider", async () => { + await seedConnectedRecord(config, "tailscale"); + const state = await reconcileTunnelOnStartup(config); + expect(state.status).toBe("idle"); + expect(state.selectedProvider).toBe("tailscale"); + }); + + // Startup reconcile also resets a stale "connecting" record. + test("reconcileTunnelOnStartup resets a stale connecting record to idle", async () => { + await mutateState(config.instance, (s) => { + s.tunnel = { + instance: config.instance, + selectedProvider: "gini-relay", + status: "connecting", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z" + }; + }); + const state = await reconcileTunnelOnStartup(config); + expect(state.status).toBe("idle"); + }); + + // Startup reconcile leaves an idle record untouched (no audit row, no write). + test("reconcileTunnelOnStartup leaves an idle record untouched", async () => { + await selectProvider(config, "gini-relay"); // writes an idle record + const before = readState(config.instance).tunnel?.updatedAt; + const state = await reconcileTunnelOnStartup(config); + expect(state.status).toBe("idle"); + expect(state.selectedProvider).toBe("gini-relay"); + expect(readState(config.instance).tunnel?.updatedAt).toBe(before); + expect(readState(config.instance).audit.some((a) => a.action === "tunnel.reconcile")).toBe(false); + }); + + // Startup reconcile on a fresh instance (null record) is a clean no-op. + test("reconcileTunnelOnStartup on a fresh instance returns idle/null", async () => { + const state = await reconcileTunnelOnStartup(config); + expect(state.status).toBe("idle"); + expect(state.selectedProvider).toBeNull(); + }); + + // Startup reconcile leaves an error record untouched (it's not stale-live). + test("reconcileTunnelOnStartup leaves an error record untouched", async () => { + await mutateState(config.instance, (s) => { + s.tunnel = { + instance: config.instance, + selectedProvider: "gini-relay", + status: "error", + message: "boom", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z" + }; + }); + const state = await reconcileTunnelOnStartup(config); + expect(state.status).toBe("error"); + expect(state.message).toBe("boom"); + }); + + // A persisted error record surfaces its message and no url via GET. + test("getTunnel surfaces a persisted error message and hides url", async () => { + await mutateState(config.instance, (s) => { + s.tunnel = { + instance: config.instance, + selectedProvider: "gini-relay", + status: "error", + message: "login failed", + url: "https://should-not-show", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z" + }; + }); + const state = getTunnel(config); + expect(state.status).toBe("error"); + expect(state.message).toBe("login failed"); + expect(state.url).toBeUndefined(); + }); + + // Metadata wins over the spread: a rebuilt record takes the authoritative + // instance and a fresh updatedAt, while preserving the original createdAt — + // even when the prior record carried stale instance/timestamps. + test("createTunnelRecord refreshes instance/updatedAt and preserves createdAt", async () => { + await mutateState(config.instance, (s) => { + s.tunnel = { + instance: "STALE-INSTANCE", + selectedProvider: "gini-relay", + status: "idle", + createdAt: "2000-01-01T00:00:00.000Z", + updatedAt: "2000-01-01T00:00:00.000Z" + }; + }); + await selectProvider(config, "gini-relay"); + const rec = readState(config.instance).tunnel; + expect(rec?.instance).toBe(config.instance); + expect(rec?.createdAt).toBe("2000-01-01T00:00:00.000Z"); + expect(rec?.updatedAt).not.toBe("2000-01-01T00:00:00.000Z"); + }); + + // Shutdown teardown: stopAllTunnels stops the live frpc child so it isn't left + // running past runtime exit. + test("stopAllTunnels stops a live tunnel child", async () => { + const child = fakeChild(); + setTunnelDeps(deps({ buildTunnel: () => child })); + await connectTunnel(config, "gini-relay"); + await awaitTunnelSettled(config.instance); + expect(getTunnel(config).status).toBe("connected"); + await stopAllTunnels(); + expect(child.stopped).toBe(true); + // Idempotent: a second call with nothing live still resolves. + await stopAllTunnels(); + }); + + // A connect that is superseded mid-handshake by a newer connect must abort + // (stop the child it spawned) and never overwrite the winner's "connected". + test("a superseded connect aborts without clobbering the winning connect", async () => { + // childA's stop() rejects so this also exercises the abort path's best-effort + // stop().catch (the older run stopping the child it spawned). + const childA = rejectStopChild(true); + const childB = fakeChild(); + let built = 0; + setTunnelDeps(deps({ buildTunnel: () => (built++ === 0 ? childA : childB) })); + await selectProvider(config, "gini-relay"); + + // Connect A parks at childA.start() (gated); flush so it builds + registers + // childA before the superseding connect tears it down. + await connectTunnel(config, "gini-relay"); + for (let i = 0; i < 10; i += 1) await Promise.resolve(); + expect(built).toBe(1); + + // Connect B supersedes A and connects. + await connectTunnel(config, "gini-relay"); + await awaitTunnelSettled(config.instance); + expect(getTunnel(config).status).toBe("connected"); + + // Release A: it sees it's no longer current and aborts (no clobber). + childA.releaseStart(); + for (let i = 0; i < 10; i += 1) await Promise.resolve(); + expect(getTunnel(config).status).toBe("connected"); + expect(childA.stopCalls).toBeGreaterThanOrEqual(1); + }); + + // stopAllTunnels is best-effort: a child whose stop() rejects must be swallowed. + test("stopAllTunnels swallows a child.stop() rejection", async () => { + setTunnelDeps(deps({ buildTunnel: () => rejectStopChild() })); + await connectTunnel(config, "gini-relay"); + await awaitTunnelSettled(config.instance); + expect(getTunnel(config).status).toBe("connected"); + await stopAllTunnels(); + }); + + // With auto-reconnect disabled, a live tunnel whose frpc child exits on its + // own (crash/relay drop) flips "connected" -> "error" so the UI stops + // advertising a dead tunnel. (The default behavior — auto-reconnect — is + // covered in the reconnect tests below.) + test("a tunnel child exiting on its own flips connected to error (reconnect disabled)", async () => { + process.env.GINI_TUNNEL_RECONNECT_MAX_ATTEMPTS = "0"; // restored in afterEach + const child = crashableChild(); + setTunnelDeps(deps({ buildTunnel: () => child })); + await connectTunnel(config, "gini-relay"); + await awaitTunnelSettled(config.instance); + expect(getTunnel(config).status).toBe("connected"); + + child.crash(7); + await waitForStatus(config, "error"); + const state = getTunnel(config); + expect(state.message).toContain("exited (code 7)"); + expect(state.url).toBeUndefined(); + }); + + // The exit watcher must NOT fire after an intentional disconnect: disconnect + // stops the child (resolving its exited), but the entry is gone, so the idle + // record stands instead of being clobbered with a spurious "error". + test("a child exiting via disconnect does not overwrite the idle record", async () => { + const child = crashableChild(); + setTunnelDeps(deps({ buildTunnel: () => child })); + await connectTunnel(config, "gini-relay"); + await awaitTunnelSettled(config.instance); + expect(getTunnel(config).status).toBe("connected"); + + await disconnectTunnel(config); + expect(getTunnel(config).status).toBe("idle"); + for (let i = 0; i < 10; i += 1) await Promise.resolve(); + expect(getTunnel(config).status).toBe("idle"); + }); + + // A connect that starts while disconnect is awaiting logout must NOT be + // clobbered back to idle: disconnect's idle write skips when a newer connect + // has claimed the instance. + test("disconnect does not clobber a connect that starts during the logout await", async () => { + const logoutGate = Promise.withResolvers(); + const childB = fakeChild(); + let built = 0; + setTunnelDeps( + deps({ + logout: () => logoutGate.promise, + buildTunnel: () => (built++ === 0 ? fakeChild() : childB) + }) + ); + // Get to connected (childA). + await connectTunnel(config, "gini-relay"); + await awaitTunnelSettled(config.instance); + expect(getTunnel(config).status).toBe("connected"); + + // Begin disconnect; it tears down then parks on the gated logout. + const disconnecting = disconnectTunnel(config); + for (let i = 0; i < 5; i += 1) await Promise.resolve(); + + // A new connect claims the instance while disconnect is mid-logout. + await connectTunnel(config, "gini-relay"); + await awaitTunnelSettled(config.instance); + expect(getTunnel(config).status).toBe("connected"); + + // Release logout; disconnect must leave the new connect's record intact. + logoutGate.resolve(); + await disconnecting; + expect(getTunnel(config).status).toBe("connected"); + }); +}); diff --git a/packages/runtime/src/integrations/tunnel.test.ts b/packages/runtime/src/integrations/tunnel.part2.test.ts similarity index 61% rename from packages/runtime/src/integrations/tunnel.test.ts rename to packages/runtime/src/integrations/tunnel.part2.test.ts index d2e30b0f..ee150b27 100644 --- a/packages/runtime/src/integrations/tunnel.test.ts +++ b/packages/runtime/src/integrations/tunnel.part2.test.ts @@ -48,7 +48,7 @@ import { isRuntimeTunnelHost } from "../lib/origin-trust"; import type { RuntimeConfig, TunnelProviderId } from "../types"; import type { LoginHandle, RelayDefaults, Session, Store, TunnelOptions } from "gini-relay"; -const ROOT = "/tmp/gini-tunnel-integration-tests"; +const ROOT = `/tmp/gini-tunnel-integration-tests-${import.meta.file}`; function testConfig(instance: string): RuntimeConfig { process.env.GINI_STATE_ROOT = ROOT; @@ -241,1541 +241,6 @@ function depsLogin(over: Partial = {}): Partial { return { ...deps(), createStore: () => fakeStoreNoSession(), ...over }; } -describe("tunnel integration", () => { - let config: RuntimeConfig; - // Snapshot every env knob the tunnel module reads and CLEAR it for each test, so - // a value left set in the ambient environment (or leaked by a prior test) can't - // change a test's outcome — e.g. an ambient GINI_TUNNEL_RESUME_WAIT_MS=0 would - // make the override poll time out immediately. Tests that need a specific value - // set it themselves; afterEach restores the original. - const TUNNEL_ENV_KEYS = [ - "GINI_TUNNEL_PORT", - "GINI_TUNNEL_RESUME_WAIT_MS", - "GINI_TUNNEL_RESUME_POLL_MS", - "GINI_TUNNEL_RECONNECT_MAX_ATTEMPTS", - "GINI_TUNNEL_RECONNECT_BASE_MS", - "GINI_TUNNEL_RECONNECT_MAX_MS", - "GINI_RELAY_PROVISIONED" - ] as const; - let prevEnv: Record = {}; - - beforeEach(() => { - config = testConfig(`t-${Math.random().toString(36).slice(2)}`); - prevEnv = {}; - for (const key of TUNNEL_ENV_KEYS) { - prevEnv[key] = process.env[key]; - delete process.env[key]; - } - setTunnelDeps(deps()); - }); - - afterEach(() => { - setTunnelDeps(); // restore the real gini-relay seams - // Restore env even if a test failed mid-assertion, so state never leaks. - for (const key of TUNNEL_ENV_KEYS) { - if (prevEnv[key] === undefined) delete process.env[key]; - else process.env[key] = prevEnv[key]; - } - rmSync(`${ROOT}/instances/${config.instance}`, { recursive: true, force: true }); - }); - - // GET on a fresh instance: the catalog is present, nothing selected, idle. - test("getTunnel on a fresh instance returns catalog + idle/null", () => { - const state = getTunnel(config); - expect(state.selectedProvider).toBeNull(); - expect(state.status).toBe("idle"); - expect(state.url).toBeUndefined(); - expect(state.message).toBeUndefined(); - expect(state.providers.map((p) => p.id)).toEqual([ - "gini-relay", - "tailscale", - "ngrok", - "cloudflare" - ]); - }); - - // The catalog values must match the agreed contract exactly: only - // gini-relay enabled (until detection finds a manual CLI); the rest disabled - // with a `requires` reason. Setup guidance lives in docs/remote-access/.md, - // not in the catalog. - test("provider catalog matches the agreed shape", () => { - const byId = Object.fromEntries(getTunnel(config).providers.map((p) => [p.id, p])); - expect(byId["gini-relay"]).toEqual({ id: "gini-relay", name: "Gini Relay", enabled: true }); - expect(byId.tailscale).toEqual({ - id: "tailscale", - name: "Tailscale", - enabled: false, - requires: "Tailscale network" - }); - expect(byId.ngrok).toEqual({ - id: "ngrok", - name: "ngrok", - enabled: false, - requires: "ngrok account" - }); - expect(byId.cloudflare).toEqual({ - id: "cloudflare", - name: "Cloudflare", - enabled: false, - requires: "cloudflared CLI" - }); - }); - - // select saves the choice without connecting; status stays idle. - test("selectProvider saves selection, stays idle, audits", async () => { - const state = await selectProvider(config, "gini-relay"); - expect(state.selectedProvider).toBe("gini-relay"); - expect(state.status).toBe("idle"); - expect(state.url).toBeUndefined(); - const persisted = readState(config.instance); - expect(persisted.tunnel?.selectedProvider).toBe("gini-relay"); - expect(persisted.audit.some((a) => a.action === "tunnel.select")).toBe(true); - }); - - // Unknown provider -> reject, no state mutation. - test("selectProvider rejects an unknown provider", async () => { - await expect(selectProvider(config, "bogus")).rejects.toThrow("Unknown tunnel provider: bogus"); - expect(readState(config.instance).tunnel).toBeNull(); - }); - - // Disabled provider -> reject with the requires hint. - test("selectProvider rejects a disabled provider with the requires hint", async () => { - await expect(selectProvider(config, "ngrok")).rejects.toThrow("not available (requires ngrok account)"); - expect(readState(config.instance).tunnel).toBeNull(); - }); - - // Re-selecting the provider you're already connected to is a no-op: the live - // tunnel stays up (the edit panel must not disconnect on a redundant click). - test("re-selecting the connected provider is a no-op and keeps the tunnel live", async () => { - const child = fakeChild(); - setTunnelDeps(deps({ buildTunnel: () => child })); - await selectProvider(config, "gini-relay"); - await connectTunnel(config); - await awaitTunnelSettled(config.instance); - expect(getTunnel(config).status).toBe("connected"); - const reselected = await selectProvider(config, "gini-relay"); - expect(reselected.status).toBe("connected"); - expect(reselected.url).toBe("https://subdom7.relay.test"); - expect(child.stopped).toBe(false); - }); - - // connect using the saved selection: returns "connecting" immediately, then - // the background flow settles to "connected" with the public url and opens - // the consent URL in the host browser. - test("connectTunnel reuses a stored session (no browser) and settles connected", async () => { - const opened: string[] = []; - let loginCalls = 0; - setTunnelDeps(deps({ - openBrowser: (url) => opened.push(url), - loginUrl: () => { - loginCalls += 1; - return Promise.resolve(fakeLoginHandle()); - } - })); - await selectProvider(config, "gini-relay"); - const connecting = await connectTunnel(config); - expect(connecting.status).toBe("connecting"); - expect(connecting.url).toBeUndefined(); - await awaitTunnelSettled(config.instance); - const settled = getTunnel(config); - expect(settled.status).toBe("connected"); - expect(settled.url).toBe("https://subdom7.relay.test"); - expect(settled.selectedProvider).toBe("gini-relay"); - // The stored session was reused: no browser opened, no re-login. - expect(opened).toEqual([]); - expect(loginCalls).toBe(0); - const persisted = readState(config.instance); - expect(persisted.tunnel?.subdomain).toBe("subdom7"); - expect(persisted.audit.some((a) => a.action === "tunnel.connect")).toBe(true); - expect(persisted.audit.some((a) => a.action === "tunnel.connected")).toBe(true); - }); - - // No stored session -> connect runs the browser OAuth login flow. - test("connectTunnel logs in via the browser when there is no stored session", async () => { - const opened: string[] = []; - setTunnelDeps(depsLogin({ openBrowser: (url) => opened.push(url) })); - await connectTunnel(config, "gini-relay"); - await awaitTunnelSettled(config.instance); - const settled = getTunnel(config); - expect(settled.status).toBe("connected"); - expect(settled.url).toBe("https://subdom7.relay.test"); - expect(opened).toEqual(["https://relay.test/consent?x=1"]); - }); - - // A fresh login with GINI_RELAY_PROVISIONED set requests the Workspace - // services, so the relay can capture the grant in one consent. - test("connectTunnel requests Workspace services on a fresh login when provisioned", async () => { - process.env.GINI_RELAY_PROVISIONED = "true"; // restored in afterEach - let seen: string[] | undefined = ["sentinel"]; - setTunnelDeps( - depsLogin({ - loginUrl: (deps) => { - seen = deps.services; - return Promise.resolve(fakeLoginHandle()); - } - }) - ); - await connectTunnel(config, "gini-relay"); - await awaitTunnelSettled(config.instance); - expect(getTunnel(config).status).toBe("connected"); - expect(seen).toEqual(["calendar", "gmail", "drive", "sheets"]); - }); - - // Without the flag, a fresh login is identity-only: no services field, so the - // relay request is byte-for-byte the prior behavior. - test("connectTunnel omits services on a fresh login when not provisioned", async () => { - let seen: string[] | undefined = ["sentinel"]; - setTunnelDeps( - depsLogin({ - loginUrl: (deps) => { - seen = deps.services; - return Promise.resolve(fakeLoginHandle()); - } - }) - ); - await connectTunnel(config, "gini-relay"); - await awaitTunnelSettled(config.instance); - expect(getTunnel(config).status).toBe("connected"); - expect(seen).toBeUndefined(); - }); - - // A fresh login whose session carries a Workspace refresh token persists the - // grant after the tunnel connects (so gws can use it), passing the token through. - test("connectTunnel persists the Workspace grant when the login returns a refresh token", async () => { - let persisted: string | undefined; - const handle = fakeLoginHandle({ - waitForSession: () => Promise.resolve({ ...SESSION, refreshToken: "rt-from-relay" }) - }); - setTunnelDeps( - depsLogin({ - loginUrl: () => Promise.resolve(handle), - persistWorkspaceGrant: (rt) => { - persisted = rt; - return Promise.resolve(); - } - }) - ); - await connectTunnel(config, "gini-relay"); - await awaitTunnelSettled(config.instance); - expect(getTunnel(config).status).toBe("connected"); - expect(persisted).toBe("rt-from-relay"); - }); - - // An identity-only login (no refresh token) never calls the persist seam. - test("connectTunnel does not persist a grant when the session has no refresh token", async () => { - let called = false; - setTunnelDeps( - depsLogin({ - persistWorkspaceGrant: () => { - called = true; - return Promise.resolve(); - } - }) - ); - await connectTunnel(config, "gini-relay"); - await awaitTunnelSettled(config.instance); - expect(getTunnel(config).status).toBe("connected"); - expect(called).toBe(false); - }); - - // A RESUMED session (read from the store, not a fresh consent) that carries a - // refresh token still calls the persist seam — persistence is idempotent, so - // running it on every connect heals a grant that a prior fresh login failed to - // land, without duplicating the account (the dedup lives in - // defaultPersistWorkspaceGrant, exercised in its own unit tests below). - test("connectTunnel persists the grant when reusing a stored session with a refresh token", async () => { - let persisted: string | undefined; - const storedWithToken: Store = { - home: "/tmp/gini-relay-fake", - deviceId: () => "device-1", - readSession: () => ({ ...SESSION, refreshToken: "rt-stored" }), - writeSession: () => {}, - clearSession: () => {} - }; - setTunnelDeps( - deps({ - createStore: () => storedWithToken, - persistWorkspaceGrant: (rt) => { - persisted = rt; - return Promise.resolve(); - } - }) - ); - await connectTunnel(config, "gini-relay"); - await awaitTunnelSettled(config.instance); - expect(getTunnel(config).status).toBe("connected"); - expect(persisted).toBe("rt-stored"); - }); - - // Persisting the grant is best-effort: a failure must NOT downgrade an - // already-connected tunnel. - test("connectTunnel stays connected when persisting the Workspace grant fails", async () => { - const handle = fakeLoginHandle({ - waitForSession: () => Promise.resolve({ ...SESSION, refreshToken: "rt-boom" }) - }); - setTunnelDeps( - depsLogin({ - loginUrl: () => Promise.resolve(handle), - persistWorkspaceGrant: () => Promise.reject(new Error("gws not installed")) - }) - ); - await connectTunnel(config, "gini-relay"); - await awaitTunnelSettled(config.instance); - expect(getTunnel(config).status).toBe("connected"); - }); - - // A stored session the relay rejects (revoked) -> connect self-heals by - // falling back to a fresh login and retrying the tunnel build. - test("connectTunnel falls back to login when the stored session is rejected", async () => { - let build = 0; - const opened: string[] = []; - setTunnelDeps(deps({ - openBrowser: (url) => opened.push(url), - buildTunnel: () => fakeChild(build++ === 0 ? { startRejects: new Error("relay: session rejected") } : {}) - })); - await connectTunnel(config, "gini-relay"); - await awaitTunnelSettled(config.instance); - expect(getTunnel(config).status).toBe("connected"); - expect(opened).toEqual(["https://relay.test/consent?x=1"]); - expect(build).toBe(2); - }); - - // connect with an explicit provider arg overrides the saved selection. - test("connectTunnel honors an explicit provider override", async () => { - const state = await connectTunnel(config, "gini-relay"); - expect(state.status).toBe("connecting"); - expect(state.selectedProvider).toBe("gini-relay"); - await awaitTunnelSettled(config.instance); - expect(getTunnel(config).status).toBe("connected"); - }); - - // connect with no selection and no arg -> reject. - test("connectTunnel with no provider selected rejects", async () => { - await expect(connectTunnel(config)).rejects.toThrow("No tunnel provider selected."); - }); - - // connect with an unknown explicit provider -> reject. - test("connectTunnel rejects an unknown explicit provider", async () => { - await expect(connectTunnel(config, "bogus")).rejects.toThrow("Unknown tunnel provider: bogus"); - }); - - // connect with a disabled explicit provider -> reject. - test("connectTunnel rejects a disabled explicit provider", async () => { - await expect(connectTunnel(config, "tailscale")).rejects.toThrow("not available (requires Tailscale network)"); - }); - - // A login failure flips the record to "error" with the message; no url. - test("connectTunnel records error when login fails", async () => { - setTunnelDeps(depsLogin({ loginUrl: () => Promise.reject(new Error("relay returned no login url")) })); - await connectTunnel(config, "gini-relay"); - await awaitTunnelSettled(config.instance); - const state = getTunnel(config); - expect(state.status).toBe("error"); - expect(state.message).toBe("relay returned no login url"); - expect(state.url).toBeUndefined(); - expect(readState(config.instance).audit.some((a) => a.action === "tunnel.error")).toBe(true); - }); - - // A frpc start failure (after a good login) also flips to "error". - test("connectTunnel records error when frpc start fails", async () => { - setTunnelDeps(deps({ buildTunnel: () => fakeChild({ startRejects: new Error("frpc: boom") }) })); - await connectTunnel(config, "gini-relay"); - await awaitTunnelSettled(config.instance); - const state = getTunnel(config); - expect(state.status).toBe("error"); - expect(state.message).toBe("frpc: boom"); - }); - - // A non-Error rejection is stringified into the message (String(error) path). - test("connectTunnel stringifies a non-Error rejection", async () => { - setTunnelDeps(depsLogin({ loginUrl: () => Promise.reject("plain string failure") })); - await connectTunnel(config, "gini-relay"); - await awaitTunnelSettled(config.instance); - expect(getTunnel(config).message).toBe("plain string failure"); - }); - - // Cancelling mid-login: the pending login is aborted, the record is idle, - // and the background flow that loses the race must NOT clobber idle with - // an error (the registry entry was cleared by the cancel). - test("cancelTunnel during a pending login aborts and stays idle", async () => { - const { promise: hang } = Promise.withResolvers(); - let cancelled = false; - const handle = fakeLoginHandle({ - waitForSession: () => hang, // never resolves on its own - cancel: () => { - cancelled = true; - } - }); - setTunnelDeps(depsLogin({ loginUrl: () => Promise.resolve(handle) })); - await selectProvider(config, "gini-relay"); - await connectTunnel(config); - // Let runConnect reach waitForSession (login handle stored). - await Promise.resolve(); - const state = await cancelTunnel(config); - expect(state.status).toBe("idle"); - expect(state.selectedProvider).toBe("gini-relay"); - expect(cancelled).toBe(true); - expect(readState(config.instance).audit.some((a) => a.action === "tunnel.cancel")).toBe(true); - // The background flow won't settle (the login hangs); the record stays idle. - expect(getTunnel(config).status).toBe("idle"); - }); - - // cancel returns to idle, keeps the selection, clears url/message. - test("cancelTunnel returns idle keeping the selection", async () => { - await selectProvider(config, "gini-relay"); - await connectTunnel(config); - await awaitTunnelSettled(config.instance); - const state = await cancelTunnel(config); - expect(state.status).toBe("idle"); - expect(state.selectedProvider).toBe("gini-relay"); - expect(state.url).toBeUndefined(); - }); - - // cancel with nothing selected still works (selection stays null) and the - // teardown no-ops cleanly (no supervisor registered). - test("cancelTunnel with no selection yields idle/null", async () => { - const state = await cancelTunnel(config); - expect(state.status).toBe("idle"); - expect(state.selectedProvider).toBeNull(); - }); - - // disconnect tears down a live tunnel (stops the frpc child), keeps selection. - test("disconnectTunnel stops the child and returns idle keeping the selection", async () => { - const child = fakeChild(); - setTunnelDeps(deps({ buildTunnel: () => child })); - await selectProvider(config, "gini-relay"); - await connectTunnel(config); - await awaitTunnelSettled(config.instance); - expect(getTunnel(config).status).toBe("connected"); - const state = await disconnectTunnel(config); - expect(state.status).toBe("idle"); - expect(state.selectedProvider).toBe("gini-relay"); - expect(state.url).toBeUndefined(); - expect(child.stopped).toBe(true); - expect(readState(config.instance).audit.some((a) => a.action === "tunnel.disconnect")).toBe(true); - }); - - // disconnect with nothing selected -> idle/null target audited as "none". - test("disconnectTunnel with no selection yields idle/null", async () => { - const state = await disconnectTunnel(config); - expect(state.status).toBe("idle"); - expect(state.selectedProvider).toBeNull(); - const audit = readState(config.instance).audit.find((a) => a.action === "tunnel.disconnect"); - expect(audit?.target).toBe("none"); - }); - - // A second connect tears down the prior in-flight login before starting a - // fresh one (covers the teardown-with-pending-login branch in connect). - test("connectTunnel tears down a prior pending login before reconnecting", async () => { - const { promise: hang } = Promise.withResolvers(); - let firstCancelled = false; - const first = fakeLoginHandle({ waitForSession: () => hang, cancel: () => { firstCancelled = true; } }); - let call = 0; - setTunnelDeps(depsLogin({ - loginUrl: () => Promise.resolve(call++ === 0 ? first : fakeLoginHandle()) - })); - await connectTunnel(config, "gini-relay"); - await Promise.resolve(); - await connectTunnel(config, "gini-relay"); - await awaitTunnelSettled(config.instance); - expect(firstCancelled).toBe(true); - expect(getTunnel(config).status).toBe("connected"); - }); - - // A teardown whose child.stop() rejects is swallowed (best-effort path). - test("disconnectTunnel swallows a child.stop() rejection", async () => { - const child: TunnelChild = { - start: () => Promise.resolve(), - stop: () => Promise.reject(new Error("already gone")), - exited: Promise.withResolvers().promise - }; - setTunnelDeps(deps({ buildTunnel: () => child })); - await connectTunnel(config, "gini-relay"); - await awaitTunnelSettled(config.instance); - const state = await disconnectTunnel(config); - expect(state.status).toBe("idle"); - }); - - // A login.cancel() that throws during teardown is swallowed. - test("cancelTunnel swallows a login.cancel() throw", async () => { - const { promise: hang } = Promise.withResolvers(); - const handle = fakeLoginHandle({ - waitForSession: () => hang, - cancel: () => { throw new Error("cancel blew up"); } - }); - setTunnelDeps(depsLogin({ loginUrl: () => Promise.resolve(handle) })); - await connectTunnel(config, "gini-relay"); - await Promise.resolve(); - const state = await cancelTunnel(config); - expect(state.status).toBe("idle"); - }); - - // The default resolveLocalPort path: env override wins. - test("connectTunnel uses GINI_TUNNEL_PORT override for the local port", async () => { - process.env.GINI_TUNNEL_PORT = "9911"; // restored in afterEach - let seenPort = -1; - setTunnelDeps(depsNoPort({ - buildTunnel: (opts) => { - seenPort = opts.port; - return fakeChild(); - } - })); - await connectTunnel(config, "gini-relay"); - await awaitTunnelSettled(config.instance); - expect(seenPort).toBe(9911); - }); - - // The default resolveLocalPort path: with no env override, the tunnel exposes - // the instance's GATEWAY port (config.port). The gateway fronts both the - // native /api/* surface and the reverse-proxied web app, so one relay URL - // serves the API and the UI. - test("connectTunnel exposes the gateway port (config.port) for the local port", async () => { - delete process.env.GINI_TUNNEL_PORT; // restored in afterEach - let seenPort = -1; - setTunnelDeps(depsNoPort({ - buildTunnel: (opts) => { - seenPort = opts.port; - return fakeChild(); - } - })); - await connectTunnel(config, "gini-relay"); - await awaitTunnelSettled(config.instance); - expect(seenPort).toBe(config.port); - }); - - // disconnect fully logs out: it invokes the logout seam (revoke + clear) so a - // later connect must log in again. - test("disconnectTunnel logs out so a later connect must re-login", async () => { - const child = fakeChild(); - let logoutCalls = 0; - setTunnelDeps(deps({ - buildTunnel: () => child, - logout: () => { - logoutCalls += 1; - return Promise.resolve(); - } - })); - await selectProvider(config, "gini-relay"); - await connectTunnel(config); - await awaitTunnelSettled(config.instance); - const state = await disconnectTunnel(config); - expect(state.status).toBe("idle"); - expect(state.selectedProvider).toBe("gini-relay"); - expect(child.stopped).toBe(true); - expect(logoutCalls).toBe(1); - }); - - // disconnect must settle to idle even when logout throws (best-effort). - test("disconnectTunnel settles idle even when logout throws", async () => { - setTunnelDeps(deps({ logout: () => Promise.reject(new Error("revoke failed")) })); - await selectProvider(config, "gini-relay"); - const state = await disconnectTunnel(config); - expect(state.status).toBe("idle"); - }); - - // defaultLogout: no stored session -> nothing to clear. - test("defaultLogout no-ops when there is no stored session", async () => { - let unlinked = 0; - await defaultLogout( - config.instance, - () => fakeStoreNoSession(), - () => { - unlinked += 1; - } - ); - expect(unlinked).toBe(0); - }); - - // defaultLogout: deletes the local session file so the next connect re-logs-in. - test("defaultLogout deletes the local session file", async () => { - let unlinkedPath = ""; - await defaultLogout( - config.instance, - () => fakeStore(), - (p) => { - unlinkedPath = p; - } - ); - expect(unlinkedPath).toContain("session.json"); - }); - - // defaultLogout: an unlink failure (file already gone) is swallowed. - test("defaultLogout swallows an unlink failure", async () => { - await defaultLogout( - config.instance, - () => fakeStore(), - () => { - throw new Error("gone"); - } - ); - }); - - // The local-port readiness guard: if nothing serves the resolved local - // port, connect must NOT advertise a connected tunnel — it records "error". - test("connectTunnel errors when the local web port isn't serving", async () => { - setTunnelDeps(deps({ probeLocalPort: () => Promise.resolve(false) })); - await connectTunnel(config, "gini-relay"); - await awaitTunnelSettled(config.instance); - const state = getTunnel(config); - expect(state.status).toBe("error"); - expect(state.message).toBe( - "Gini's web UI isn't responding on port 4321 — start it, then reconnect." - ); - expect(state.url).toBeUndefined(); - }); - - // The default host-browser opener shells out to `open ` via Bun.spawn. - // Driven with a stubbed spawn so no real browser launches. - test("defaultOpenBrowser spawns `open` with the url", () => { - const calls: string[][] = []; - const fakeSpawn = ((cmd: string[]) => { - calls.push(cmd); - return {} as ReturnType; - }) as typeof Bun.spawn; - defaultOpenBrowser("https://relay.test/consent", fakeSpawn); - expect(calls).toEqual([["open", "https://relay.test/consent"]]); - }); - - // The connect-aborted branch: the login rejects AFTER a cancel has torn - // down the supervisor, so runConnect must log "aborted" and NOT clobber the - // idle record the cancel wrote with a spurious error. - test("connectTunnel aborts cleanly when teardown wins the race against a login failure", async () => { - const { promise: gate, resolve: openGate } = Promise.withResolvers(); - // Login that only rejects once the test releases the gate — by then the - // cancel below has already deleted this instance's supervisor entry. - const settled = (async () => { - await gate; - throw new Error("relay error getting login URL"); - })(); - setTunnelDeps(depsLogin({ loginUrl: () => settled.then(() => fakeLoginHandle()) })); - await selectProvider(config, "gini-relay"); - await connectTunnel(config); - // Tear down before the login settles. - await cancelTunnel(config); - expect(getTunnel(config).status).toBe("idle"); - // Release the login failure; the background flow takes the aborted path. - // The supervisor entry was deleted by the cancel, so awaitTunnelSettled - // can't track it — flush the rejection + several microtasks instead. - openGate(); - await settled.catch(() => {}); - for (let i = 0; i < 5; i += 1) await Promise.resolve(); - // The cancel's idle record stands — no error clobber. - expect(getTunnel(config).status).toBe("idle"); - }); - - // A connect cancelled DURING the port probe must bail before the login — - // never opening an OAuth browser tab for a connect the user abandoned. - test("connect cancelled during the port probe never opens a browser", async () => { - const probeGate = Promise.withResolvers(); - const opened: string[] = []; - setTunnelDeps( - depsLogin({ probeLocalPort: () => probeGate.promise, openBrowser: (url) => opened.push(url) }) - ); - await selectProvider(config, "gini-relay"); - await connectTunnel(config); - // runConnect is parked on the probe await; cancel before it resolves. - await cancelTunnel(config); - expect(getTunnel(config).status).toBe("idle"); - probeGate.resolve(true); - for (let i = 0; i < 5; i += 1) await Promise.resolve(); - expect(opened).toEqual([]); - expect(getTunnel(config).status).toBe("idle"); - }); - - // The reuse path (stored session) is guarded too: a connect cancelled during - // the probe must never spawn frpc. - test("connect cancelled during the probe never spawns frpc (stored session)", async () => { - const probeGate = Promise.withResolvers(); - let built = 0; - setTunnelDeps( - deps({ - probeLocalPort: () => probeGate.promise, - buildTunnel: () => { - built += 1; - return fakeChild(); - } - }) - ); - await selectProvider(config, "gini-relay"); - await connectTunnel(config); - await cancelTunnel(config); - expect(getTunnel(config).status).toBe("idle"); - probeGate.resolve(true); - for (let i = 0; i < 5; i += 1) await Promise.resolve(); - expect(built).toBe(0); - expect(getTunnel(config).status).toBe("idle"); - }); - - // A connect cancelled DURING loginUrl (after the probe) must cancel the - // freshly-minted login handle and bail before opening a browser. - test("connect cancelled during loginUrl cancels the handle and never opens a browser", async () => { - const loginGate = Promise.withResolvers(); - let cancelled = false; - const opened: string[] = []; - setTunnelDeps( - depsLogin({ - loginUrl: () => loginGate.promise.then(() => fakeLoginHandle({ cancel: () => { cancelled = true; } })), - openBrowser: (url) => opened.push(url) - }) - ); - await selectProvider(config, "gini-relay"); - await connectTunnel(config); - // Let runConnect pass the probe + first guard and park at loginUrl. - for (let i = 0; i < 5; i += 1) await Promise.resolve(); - await cancelTunnel(config); - expect(getTunnel(config).status).toBe("idle"); - loginGate.resolve(); - for (let i = 0; i < 10; i += 1) await Promise.resolve(); - expect(cancelled).toBe(true); - expect(opened).toEqual([]); - expect(getTunnel(config).status).toBe("idle"); - }); - - // If a login fails AFTER loginUrl bound its loopback (e.g. waitForSession - // rejects), the error path must cancel the handle so the loopback isn't leaked. - test("a login failing after loginUrl cancels the handle (no loopback leak)", async () => { - let cancelled = false; - setTunnelDeps( - depsLogin({ - loginUrl: () => - Promise.resolve( - fakeLoginHandle({ - waitForSession: () => Promise.reject(new Error("relay session exchange failed")), - cancel: () => { - cancelled = true; - } - }) - ) - }) - ); - await selectProvider(config, "gini-relay"); - await connectTunnel(config); - await awaitTunnelSettled(config.instance); - expect(getTunnel(config).status).toBe("error"); - expect(cancelled).toBe(true); - }); - - // The real gini-relay seam wrappers delegate to the library without any - // network I/O: createStore (instance-scoped relayHome under the temp ROOT), - // resolveDefaults (pure), and buildTunnel (pure construction — frpc isn't - // spawned until start()). loginUrl is the live network primitive, so it's - // only referenced, never invoked here. - test("makeDefaultDeps wires the real gini-relay primitives", async () => { - const real = makeDefaultDeps(); - const relay = real.resolveDefaults(); - expect(typeof relay.relayUrl).toBe("string"); - const store = real.createStore(config); - expect(typeof store.deviceId()).toBe("string"); - const child = real.buildTunnel({ - session: SESSION, - deviceId: store.deviceId(), - port: 4321, - defaults: relay - }); - expect(typeof child.start).toBe("function"); - expect(typeof real.loginUrl).toBe("function"); - expect(real.resolveLocalPort(config)).toBeGreaterThan(0); - expect(typeof real.probeLocalPort).toBe("function"); - expect(typeof (await real.probeLocalPort(config, 1))).toBe("boolean"); - expect(typeof real.logout).toBe("function"); - await real.logout(config); - expect(typeof real.persistWorkspaceGrant).toBe("function"); - }); - - // defaultPersistWorkspaceGrant writes the gws authorized_user credential into a - // minted config dir and registers it; the register seam is faked so no real gws - // or disk-registry is touched. Signature: - // (refreshToken, principal, register, makeConfigDir, mkdir, writeFile, findProvisioned). - test("defaultPersistWorkspaceGrant writes the credential and registers the account", async () => { - const dir = mkdtempSync(join(tmpdir(), "gini-ws-grant-")); - const writes: Array<{ path: string; body: string }> = []; - const registered: Array<{ tag: string; configDir: string; trusted?: boolean; principal?: string }> = []; - await defaultPersistWorkspaceGrant( - "rt-live", - "sub-123", - async (input) => { - registered.push(input); - return { id: "gacct_x", tag: input.tag, email: "wilson@lilaclabs.ai", configDir: input.configDir, addedAt: "t" }; - }, - () => dir, - () => {}, // dir already exists (mkdtemp) - (path, body) => writes.push({ path, body }), - () => null // no existing provisioned account → mint the dir under test - ); - expect(writes).toHaveLength(1); - expect(writes[0]!.path).toBe(join(dir, "credentials.json")); - const cred = JSON.parse(writes[0]!.body) as Record; - expect(cred.type).toBe("authorized_user"); - expect(cred.refresh_token).toBe("rt-live"); - expect(cred.client_id).toMatch(/\.apps\.googleusercontent\.com$/); - // trusted:true so registration skips the live gws probe; the principal is - // threaded so distinct identities keep separate dirs. - expect(registered).toEqual([{ tag: "workspace", configDir: dir, trusted: true, principal: "sub-123" }]); - rmSync(dir, { recursive: true, force: true }); - }); - - // Exercise the REAL default mkdir/writeFile/makeConfigDir/findProvisioned arrows - // (HOME pointed at an empty temp dir so the registry read is empty and - // configDirForAccount lands somewhere disposable); only the register seam is - // faked so no gws subprocess runs. - test("defaultPersistWorkspaceGrant uses real fs defaults to write the credential", async () => { - const home = mkdtempSync(join(tmpdir(), "gini-ws-home-")); - const prevHome = process.env.HOME; - process.env.HOME = home; - let writtenDir = ""; - try { - await defaultPersistWorkspaceGrant("rt-realfs", undefined, async (input) => { - writtenDir = input.configDir; - return { id: "gacct_z", tag: input.tag, email: "", configDir: input.configDir, addedAt: "t" }; - }); - // The default makeConfigDir lands under ~/.gini/google-accounts/, the - // default mkdir created it, and the default writeFile dropped credentials.json. - expect(writtenDir).toContain(join(home, ".gini", "google-accounts")); - const cred = JSON.parse(readFileSync(join(writtenDir, "credentials.json"), "utf8")) as Record; - expect(cred.refresh_token).toBe("rt-realfs"); - expect(cred.type).toBe("authorized_user"); - } finally { - if (prevHome === undefined) delete process.env.HOME; - else process.env.HOME = prevHome; - rmSync(home, { recursive: true, force: true }); - } - }); - - // A tag collision (a DIFFERENT account already holds PROVISIONED_TAG) must not - // throw: the first register rejects, the retry uses a unique tag and succeeds. - test("defaultPersistWorkspaceGrant retries with a unique tag on collision", async () => { - const dir = mkdtempSync(join(tmpdir(), "gini-ws-grant-")); - const tags: string[] = []; - await defaultPersistWorkspaceGrant( - "rt-2", - undefined, - async (input) => { - tags.push(input.tag); - if (tags.length === 1) throw new Error('tagged "workspace" already exists'); - return { id: "gacct_y", tag: input.tag, email: "", configDir: input.configDir, addedAt: "t" }; - }, - () => dir, - () => {}, - () => {}, - () => null // no existing provisioned account → mint the dir under test - ); - expect(tags).toHaveLength(2); - expect(tags[0]).toBe("workspace"); - expect(tags[1]).toMatch(/^workspace-gacct_/); - rmSync(dir, { recursive: true, force: true }); - }); - - // The retry after a tag collision must ALSO be trusted — otherwise the second - // attempt would re-introduce the live gws probe the fix is removing. - test("defaultPersistWorkspaceGrant marks both the first attempt and the collision retry trusted", async () => { - const dir = mkdtempSync(join(tmpdir(), "gini-ws-grant-")); - const trustedFlags: Array = []; - await defaultPersistWorkspaceGrant( - "rt-3", - undefined, - async (input) => { - trustedFlags.push(input.trusted); - if (trustedFlags.length === 1) throw new Error('tagged "workspace" already exists'); - return { id: "gacct_w", tag: input.tag, email: "", configDir: input.configDir, addedAt: "t" }; - }, - () => dir, - () => {}, - () => {}, - () => null - ); - expect(trustedFlags).toEqual([true, true]); - rmSync(dir, { recursive: true, force: true }); - }); - - // Idempotency: when a provisioned account already exists, re-persisting reuses - // its config dir (rewriting the refreshed credential in place) instead of - // minting a new one — so a reconnect never accumulates duplicate accounts — AND - // keeps the user's current tag instead of forcing it back to PROVISIONED_TAG. - test("defaultPersistWorkspaceGrant reuses the existing provisioned dir and tag on re-persist", async () => { - const existingDir = mkdtempSync(join(tmpdir(), "gini-ws-existing-")); - const registered: Array<{ tag: string; configDir: string; trusted?: boolean; principal?: string }> = []; - let minted = false; - const writes: Array<{ path: string; body: string }> = []; - await defaultPersistWorkspaceGrant( - "rt-refreshed", - "sub-abc", - async (input) => { - registered.push(input); - return { id: "gacct_x", tag: input.tag, email: "", configDir: input.configDir, addedAt: "t" }; - }, - () => { - minted = true; // makeConfigDir must NOT be consulted when one exists - return "/tmp/should-not-be-used"; - }, - () => {}, - (path, body) => writes.push({ path, body }), - // The user retagged this provisioned account to "work"; reuse must keep it. - () => ({ configDir: existingDir, tag: "work" }) - ); - expect(minted).toBe(false); - expect(registered).toEqual([{ tag: "work", configDir: existingDir, trusted: true, principal: "sub-abc" }]); - // The refreshed credential is rewritten into the SAME dir. - expect(writes).toHaveLength(1); - expect(writes[0]!.path).toBe(join(existingDir, "credentials.json")); - const cred = JSON.parse(writes[0]!.body) as Record; - expect(cred.refresh_token).toBe("rt-refreshed"); - rmSync(existingDir, { recursive: true, force: true }); - }); - - // The default findProvisioned seam, against a registry that ALREADY holds a - // provisioned account for THIS principal, returns its dir+tag so re-persist - // reuses it (exercises the real readGoogleAccounts filter+match). HOME is - // isolated so the registry is the one we seed, not the machine's. - test("defaultPersistWorkspaceGrant default seam reuses the provisioned account for the same principal", async () => { - const home = mkdtempSync(join(tmpdir(), "gini-ws-seeded-")); - const prevHome = process.env.HOME; - process.env.HOME = home; - const seededDir = join(home, ".gini", "google-accounts", "gacct_seeded1"); - const registered: Array<{ tag: string; configDir: string; trusted?: boolean; principal?: string }> = []; - let minted = false; - try { - addGoogleAccount({ id: "gacct_seeded1", tag: "work", email: "x@y.com", configDir: seededDir, addedAt: "t", provisioned: true, principal: "sub-seed" }); - await defaultPersistWorkspaceGrant( - "rt-reuse", - "sub-seed", - async (input) => { - registered.push(input); - return { id: "gacct_seeded1", tag: input.tag, email: "x@y.com", configDir: input.configDir, addedAt: "t" }; - }, - () => { - minted = true; - return "/tmp/should-not-be-used"; - }, - () => {}, - () => {} - // findProvisioned left at its default → matches principal → seededDir + tag "work" - ); - expect(minted).toBe(false); - expect(registered).toEqual([{ tag: "work", configDir: seededDir, trusted: true, principal: "sub-seed" }]); - } finally { - if (prevHome === undefined) delete process.env.HOME; - else process.env.HOME = prevHome; - rmSync(home, { recursive: true, force: true }); - } - }); - - // A DIFFERENT principal must NOT reuse another identity's provisioned dir — it - // mints its own, so one identity's credential never clobbers the other's. - test("defaultPersistWorkspaceGrant mints a separate dir for a different principal", async () => { - const home = mkdtempSync(join(tmpdir(), "gini-ws-principal-")); - const prevHome = process.env.HOME; - process.env.HOME = home; - const otherDir = join(home, ".gini", "google-accounts", "gacct_other"); - const freshDir = mkdtempSync(join(tmpdir(), "gini-ws-mine-")); - const registeredDirs: string[] = []; - let minted = false; - try { - // Account already provisioned for principal A. - addGoogleAccount({ id: "gacct_other", tag: "workspace", email: "a@y.com", configDir: otherDir, addedAt: "t", provisioned: true, principal: "sub-A" }); - // We connect as principal B. - await defaultPersistWorkspaceGrant( - "rt-B", - "sub-B", - async (input) => { - registeredDirs.push(input.configDir); - return { id: "gacct_n", tag: input.tag, email: "", configDir: input.configDir, addedAt: "t" }; - }, - () => { - minted = true; - return freshDir; - }, - () => {}, - () => {} - // default findProvisioned → no provisioned account for sub-B → null → mint freshDir - ); - expect(minted).toBe(true); - expect(registeredDirs).toEqual([freshDir]); // principal A's dir is untouched - } finally { - if (prevHome === undefined) delete process.env.HOME; - else process.env.HOME = prevHome; - rmSync(home, { recursive: true, force: true }); - rmSync(freshDir, { recursive: true, force: true }); - } - }); - - // Back-compat: a provisioned account recorded BEFORE principals were tracked - // has no `principal`. A connect that itself carries no principal must still - // reuse that unkeyed account (not mint a duplicate) — the default seam's - // no-principal arm. - test("defaultPersistWorkspaceGrant default seam reuses an unkeyed provisioned account when no principal is known", async () => { - const home = mkdtempSync(join(tmpdir(), "gini-ws-nokey-")); - const prevHome = process.env.HOME; - process.env.HOME = home; - const legacyDir = join(home, ".gini", "google-accounts", "gacct_legacy"); - const registered: Array<{ tag: string; configDir: string; trusted?: boolean; principal?: string }> = []; - let minted = false; - try { - // Provisioned, but no principal recorded (pre-principal row). - addGoogleAccount({ id: "gacct_legacy", tag: "workspace", email: "l@y.com", configDir: legacyDir, addedAt: "t", provisioned: true }); - await defaultPersistWorkspaceGrant( - "rt-legacy", - undefined, // connect carries no principal - async (input) => { - registered.push(input); - return { id: "gacct_legacy", tag: input.tag, email: "l@y.com", configDir: input.configDir, addedAt: "t" }; - }, - () => { - minted = true; - return "/tmp/should-not-be-used"; - }, - () => {}, - () => {} - // default findProvisioned → no-principal arm → legacyDir + tag "workspace" - ); - expect(minted).toBe(false); - expect(registered).toEqual([{ tag: "workspace", configDir: legacyDir, trusted: true, principal: undefined }]); - } finally { - if (prevHome === undefined) delete process.env.HOME; - else process.env.HOME = prevHome; - rmSync(home, { recursive: true, force: true }); - } - }); - - // The default findProvisioned seam reads the live registry: with no provisioned - // account present it returns null, so a fresh dir is minted. HOME points at an - // empty temp dir so the registry read is genuinely empty. - test("defaultPersistWorkspaceGrant mints a fresh dir when no provisioned account exists", async () => { - const home = mkdtempSync(join(tmpdir(), "gini-ws-emptyhome-")); - const prevHome = process.env.HOME; - process.env.HOME = home; - const dir = mkdtempSync(join(tmpdir(), "gini-ws-fresh-")); - let minted = false; - const registeredDirs: string[] = []; - try { - await defaultPersistWorkspaceGrant( - "rt-fresh", - undefined, - async (input) => { - registeredDirs.push(input.configDir); - return { id: "gacct_n", tag: input.tag, email: "", configDir: input.configDir, addedAt: "t" }; - }, - () => { - minted = true; - return dir; - }, - () => {}, - () => {} - // findProvisioned left at its default → reads the (empty) registry → null - ); - expect(minted).toBe(true); - expect(registeredDirs).toEqual([dir]); - } finally { - if (prevHome === undefined) delete process.env.HOME; - else process.env.HOME = prevHome; - rmSync(home, { recursive: true, force: true }); - rmSync(dir, { recursive: true, force: true }); - } - }); - - // awaitTunnelSettled resolves immediately when no connect is in flight. - test("awaitTunnelSettled resolves when nothing is connecting", async () => { - await expect(awaitTunnelSettled(config.instance)).resolves.toBeUndefined(); - }); - - // Seed a persisted "connected" tunnel record (as if the runtime was exposing a - // tunnel before this restart). The link is long-lasting, so boot should resume. - async function seedConnectedRecord(c: RuntimeConfig, provider: TunnelProviderId = "gini-relay"): Promise { - await mutateState(c.instance, (s) => { - s.tunnel = { - instance: c.instance, - selectedProvider: provider, - status: "connected", - url: "https://subdom7.relay.test", - subdomain: "subdom7", - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z" - }; - }); - } - - // Startup reconcile RESUMES a tunnel that was connected before the restart by - // reusing the stored relay session (no browser login) — the link is meant to be - // long-lasting / 24-7, so it comes back automatically. It first flips to - // "connecting" so the first GET never reads a stale "connected". - test("reconcileTunnelOnStartup resumes a previously connected tunnel by reusing the session", async () => { - const child = fakeChild(); - let loginCalls = 0; - const opened: string[] = []; - setTunnelDeps( - deps({ - buildTunnel: () => child, - loginUrl: () => { - loginCalls += 1; - return Promise.resolve(fakeLoginHandle()); - }, - openBrowser: (url) => opened.push(url) - }) - ); - await seedConnectedRecord(config); - const immediate = await reconcileTunnelOnStartup(config); - expect(immediate.status).toBe("connecting"); - expect(immediate.selectedProvider).toBe("gini-relay"); - await awaitTunnelSettled(config.instance); - const settled = getTunnel(config); - expect(settled.status).toBe("connected"); - // Same deviceId-keyed subdomain — the link is stable across the restart. - expect(settled.url).toBe("https://subdom7.relay.test"); - // Headless resume: the stored session was reused, no browser/login. - expect(loginCalls).toBe(0); - expect(opened).toEqual([]); - expect(readState(config.instance).audit.some((a) => a.action === "tunnel.reconcile")).toBe(true); - }); - - // Regression (relay update "stuck on restarting"): a client connected through - // the relay has exactly ONE channel to the instance — the public URL the frpc - // child fronts. A restart kills that child, so the resume must bring it back. - // The web child the gateway reverse-proxies may still be (re)compiling at that - // instant, but the tunnel only fronts the GATEWAY port — so once this process - // owns that port (gatewayReady), reachability is restored WITHOUT blocking the - // rebuild on the web child. If the resume waited on web-readiness the remote - // browser would stay blind for the whole restart window and its update gate - // could never poll the restart to completion. - test("reconcileTunnelOnStartup resume restores reachability without waiting for the web child", async () => { - const ready = Promise.withResolvers(); - let built = 0; - let probed = 0; - setTunnelDeps( - deps({ - // The tunnel fronts this process's own gateway port. - resolveLocalPort: (c) => c.port, - // The web child is still down at resume time (mid-recompile after the - // restart). The tunnel must come back anyway — it fronts the gateway, - // not the web child, so this must never be consulted on the resume. - probeLocalPort: () => { - probed += 1; - return Promise.resolve(false); - }, - buildTunnel: () => { - built += 1; - return fakeChild(); - } - }) - ); - await seedConnectedRecord(config); - await reconcileTunnelOnStartup(config, { gatewayReady: ready.promise }); - ready.resolve(); - await awaitTunnelSettled(config.instance); - const settled = getTunnel(config); - expect(settled.status).toBe("connected"); - // Same deviceId-keyed subdomain — the link is stable across the restart. - expect(settled.url).toBe("https://subdom7.relay.test"); - expect(built).toBe(1); - // gatewayReady (the gateway-bind proof) sufficed — the web child was never - // probed, so a slow recompile can't keep the remote client blind. - expect(probed).toBe(0); - }); - - // Security: reconcileTunnelOnStartup runs before Bun.serve binds config.port, - // so the resume must NOT expose the relay URL until THIS process owns the port — - // otherwise the stable public URL could forward to a stale/foreign listener - // still holding it. The frpc rebuild waits on `gatewayReady`; until it resolves - // the record stays "connecting" and no tunnel is built. - test("reconcileTunnelOnStartup resume waits for the gateway port before exposing the tunnel", async () => { - const ready = Promise.withResolvers(); - let built = 0; - setTunnelDeps( - deps({ - resolveLocalPort: (c) => c.port, - buildTunnel: () => { - built += 1; - return fakeChild(); - } - }) - ); - await seedConnectedRecord(config); - await reconcileTunnelOnStartup(config, { gatewayReady: ready.promise }); - // The resume parked on gatewayReady (runConnect's first await): the status is - // "connecting" and no frpc child has been built yet. - expect(getTunnel(config).status).toBe("connecting"); - expect(built).toBe(0); - // The gateway binds → the resume proceeds and the tunnel comes up. - ready.resolve(); - await awaitTunnelSettled(config.instance); - expect(getTunnel(config).status).toBe("connected"); - expect(built).toBe(1); - }); - - // If a cancel/disconnect lands while the resume is parked on gatewayReady, it - // bails without building the tunnel and without clobbering the cancel's idle. - test("reconcileTunnelOnStartup resume bails if cancelled while waiting for the gateway port", async () => { - const ready = Promise.withResolvers(); - let built = 0; - setTunnelDeps( - deps({ - resolveLocalPort: (c) => c.port, - buildTunnel: () => { - built += 1; - return fakeChild(); - } - }) - ); - await seedConnectedRecord(config); - await reconcileTunnelOnStartup(config, { gatewayReady: ready.promise }); - const settled = awaitTunnelSettled(config.instance); - const cancelled = await cancelTunnel(config); - expect(cancelled.status).toBe("idle"); - // The port binds, but the resume was superseded — it must not expose anything. - ready.resolve(); - await settled; - expect(built).toBe(0); - expect(getTunnel(config).status).toBe("idle"); - }); - - // Security (GINI_TUNNEL_PORT override): the tunnel can be pointed at a port this - // process does NOT bind (resolveLocalPort ≠ config.port). gatewayReady proves - // only config.port, so it can't vouch for the override — the resume must instead - // verify that port's identity (a bounded, cancellable poll, since the override - // target may still be coming up after a restart) before exposing the stable - // public URL. It polls the local port and connects once it answers. - test("reconcileTunnelOnStartup resume polls an overridden tunnel port, then connects", async () => { - // Fast poll; the wait budget stays at its 60s default (beforeEach cleared any - // ambient GINI_TUNNEL_RESUME_WAIT_MS), so the three probes land well inside it. - process.env.GINI_TUNNEL_RESUME_POLL_MS = "1"; - const ready = Promise.withResolvers(); - ready.resolve(); - let probes = 0; - // resolveLocalPort returns 4321 (the deps() sentinel) ≠ config.port. - setTunnelDeps(deps({ probeLocalPort: () => Promise.resolve(++probes >= 3) })); - await seedConnectedRecord(config); - // gatewayReady is resolved but ignored for the override — verification is by - // probe, not the gateway bind. - await reconcileTunnelOnStartup(config, { gatewayReady: ready.promise }); - await awaitTunnelSettled(config.instance); - expect(getTunnel(config).status).toBe("connected"); - expect(probes).toBeGreaterThanOrEqual(3); - }); - - // If the overridden port never verifies within the budget, the resume settles - // idle and builds nothing — it must NOT forward the relay URL to an unverified - // (possibly foreign) listener, even though gatewayReady has resolved. - test("reconcileTunnelOnStartup resume settles idle when an overridden port never verifies", async () => { - process.env.GINI_TUNNEL_RESUME_WAIT_MS = "0"; // give up after the first failed probe - const ready = Promise.withResolvers(); - ready.resolve(); - let built = 0; - let probed = 0; - setTunnelDeps( - deps({ - probeLocalPort: () => { - probed += 1; - return Promise.resolve(false); - }, - buildTunnel: () => { - built += 1; - return fakeChild(); - } - }) - ); - await seedConnectedRecord(config); - await reconcileTunnelOnStartup(config, { gatewayReady: ready.promise }); - await awaitTunnelSettled(config.instance); - expect(probed).toBeGreaterThanOrEqual(1); - expect(built).toBe(0); - expect(getTunnel(config).status).toBe("idle"); - }); - - // A resume cancelled while polling an overridden port bails without clobbering - // the idle the cancel wrote (covers the supervisor-superseded guard in the poll). - test("reconcileTunnelOnStartup resume bails when cancelled while polling an overridden port", async () => { - process.env.GINI_TUNNEL_RESUME_POLL_MS = "5"; - setTunnelDeps(deps({ probeLocalPort: () => Promise.resolve(false) })); - await seedConnectedRecord(config); - await reconcileTunnelOnStartup(config); - // Capture the resume's settled promise BEFORE cancel tears down the - // supervisor, then await it: the poll wakes, sees it was superseded, and - // bails without clobbering the idle the cancel wrote. - const settled = awaitTunnelSettled(config.instance); - const cancelled = await cancelTunnel(config); - expect(cancelled.status).toBe("idle"); - await settled; - expect(getTunnel(config).status).toBe("idle"); - }); - - // Resume is non-interactive: with no stored session it must NOT open a browser - // or mint a login on a headless restart — it settles idle for a manual reconnect. - test("reconcileTunnelOnStartup resume settles idle (no login) when there is no stored session", async () => { - let loginCalls = 0; - const opened: string[] = []; - setTunnelDeps( - depsLogin({ - loginUrl: () => { - loginCalls += 1; - return Promise.resolve(fakeLoginHandle()); - }, - openBrowser: (url) => opened.push(url) - }) - ); - await seedConnectedRecord(config); - await reconcileTunnelOnStartup(config); - await awaitTunnelSettled(config.instance); - const settled = getTunnel(config); - expect(settled.status).toBe("idle"); - expect(settled.selectedProvider).toBe("gini-relay"); - expect(loginCalls).toBe(0); - expect(opened).toEqual([]); - }); - - // A resume cancelled mid-rebuild (the frpc start is in flight) bails without - // clobbering the idle the cancel wrote, and stops the child it spawned so it - // isn't orphaned (covers the post-start supervisor-superseded guard). - test("reconcileTunnelOnStartup resume bails when cancelled mid-rebuild", async () => { - const startGate = Promise.withResolvers(); - const startEntered = Promise.withResolvers(); - let stopped = 0; - const child: TunnelChild = { - start: () => { - startEntered.resolve(); - return startGate.promise.then(() => child); - }, - stop: () => { - stopped += 1; - return Promise.resolve(0); - }, - exited: Promise.withResolvers().promise - }; - setTunnelDeps(deps({ buildTunnel: () => child })); - await seedConnectedRecord(config); - await reconcileTunnelOnStartup(config); - // Wait until the resume has actually entered frpc start (deterministic — no - // reliance on microtask ordering) so the cancel below lands mid-rebuild. - await startEntered.promise; - // Capture the settled promise BEFORE cancel tears the supervisor down so the - // await is deterministic. - const settled = awaitTunnelSettled(config.instance); - const cancelled = await cancelTunnel(config); - expect(cancelled.status).toBe("idle"); - // Release the gated start: the resume sees it was superseded, stops the - // child it spawned, and bails without clobbering the cancel's idle. - startGate.resolve(); - await settled; - expect(getTunnel(config).status).toBe("idle"); - expect(stopped).toBeGreaterThanOrEqual(1); - }); - - // A "connected" record for a provider that is no longer enabled does NOT resume - // (covers the disabled-provider guard); it just resets to idle. - test("reconcileTunnelOnStartup does not resume a connected record for a disabled provider", async () => { - await seedConnectedRecord(config, "tailscale"); - const state = await reconcileTunnelOnStartup(config); - expect(state.status).toBe("idle"); - expect(state.selectedProvider).toBe("tailscale"); - }); - - // Startup reconcile also resets a stale "connecting" record. - test("reconcileTunnelOnStartup resets a stale connecting record to idle", async () => { - await mutateState(config.instance, (s) => { - s.tunnel = { - instance: config.instance, - selectedProvider: "gini-relay", - status: "connecting", - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z" - }; - }); - const state = await reconcileTunnelOnStartup(config); - expect(state.status).toBe("idle"); - }); - - // Startup reconcile leaves an idle record untouched (no audit row, no write). - test("reconcileTunnelOnStartup leaves an idle record untouched", async () => { - await selectProvider(config, "gini-relay"); // writes an idle record - const before = readState(config.instance).tunnel?.updatedAt; - const state = await reconcileTunnelOnStartup(config); - expect(state.status).toBe("idle"); - expect(state.selectedProvider).toBe("gini-relay"); - expect(readState(config.instance).tunnel?.updatedAt).toBe(before); - expect(readState(config.instance).audit.some((a) => a.action === "tunnel.reconcile")).toBe(false); - }); - - // Startup reconcile on a fresh instance (null record) is a clean no-op. - test("reconcileTunnelOnStartup on a fresh instance returns idle/null", async () => { - const state = await reconcileTunnelOnStartup(config); - expect(state.status).toBe("idle"); - expect(state.selectedProvider).toBeNull(); - }); - - // Startup reconcile leaves an error record untouched (it's not stale-live). - test("reconcileTunnelOnStartup leaves an error record untouched", async () => { - await mutateState(config.instance, (s) => { - s.tunnel = { - instance: config.instance, - selectedProvider: "gini-relay", - status: "error", - message: "boom", - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z" - }; - }); - const state = await reconcileTunnelOnStartup(config); - expect(state.status).toBe("error"); - expect(state.message).toBe("boom"); - }); - - // A persisted error record surfaces its message and no url via GET. - test("getTunnel surfaces a persisted error message and hides url", async () => { - await mutateState(config.instance, (s) => { - s.tunnel = { - instance: config.instance, - selectedProvider: "gini-relay", - status: "error", - message: "login failed", - url: "https://should-not-show", - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z" - }; - }); - const state = getTunnel(config); - expect(state.status).toBe("error"); - expect(state.message).toBe("login failed"); - expect(state.url).toBeUndefined(); - }); - - // Metadata wins over the spread: a rebuilt record takes the authoritative - // instance and a fresh updatedAt, while preserving the original createdAt — - // even when the prior record carried stale instance/timestamps. - test("createTunnelRecord refreshes instance/updatedAt and preserves createdAt", async () => { - await mutateState(config.instance, (s) => { - s.tunnel = { - instance: "STALE-INSTANCE", - selectedProvider: "gini-relay", - status: "idle", - createdAt: "2000-01-01T00:00:00.000Z", - updatedAt: "2000-01-01T00:00:00.000Z" - }; - }); - await selectProvider(config, "gini-relay"); - const rec = readState(config.instance).tunnel; - expect(rec?.instance).toBe(config.instance); - expect(rec?.createdAt).toBe("2000-01-01T00:00:00.000Z"); - expect(rec?.updatedAt).not.toBe("2000-01-01T00:00:00.000Z"); - }); - - // Shutdown teardown: stopAllTunnels stops the live frpc child so it isn't left - // running past runtime exit. - test("stopAllTunnels stops a live tunnel child", async () => { - const child = fakeChild(); - setTunnelDeps(deps({ buildTunnel: () => child })); - await connectTunnel(config, "gini-relay"); - await awaitTunnelSettled(config.instance); - expect(getTunnel(config).status).toBe("connected"); - await stopAllTunnels(); - expect(child.stopped).toBe(true); - // Idempotent: a second call with nothing live still resolves. - await stopAllTunnels(); - }); - - // A connect that is superseded mid-handshake by a newer connect must abort - // (stop the child it spawned) and never overwrite the winner's "connected". - test("a superseded connect aborts without clobbering the winning connect", async () => { - // childA's stop() rejects so this also exercises the abort path's best-effort - // stop().catch (the older run stopping the child it spawned). - const childA = rejectStopChild(true); - const childB = fakeChild(); - let built = 0; - setTunnelDeps(deps({ buildTunnel: () => (built++ === 0 ? childA : childB) })); - await selectProvider(config, "gini-relay"); - - // Connect A parks at childA.start() (gated); flush so it builds + registers - // childA before the superseding connect tears it down. - await connectTunnel(config, "gini-relay"); - for (let i = 0; i < 10; i += 1) await Promise.resolve(); - expect(built).toBe(1); - - // Connect B supersedes A and connects. - await connectTunnel(config, "gini-relay"); - await awaitTunnelSettled(config.instance); - expect(getTunnel(config).status).toBe("connected"); - - // Release A: it sees it's no longer current and aborts (no clobber). - childA.releaseStart(); - for (let i = 0; i < 10; i += 1) await Promise.resolve(); - expect(getTunnel(config).status).toBe("connected"); - expect(childA.stopCalls).toBeGreaterThanOrEqual(1); - }); - - // stopAllTunnels is best-effort: a child whose stop() rejects must be swallowed. - test("stopAllTunnels swallows a child.stop() rejection", async () => { - setTunnelDeps(deps({ buildTunnel: () => rejectStopChild() })); - await connectTunnel(config, "gini-relay"); - await awaitTunnelSettled(config.instance); - expect(getTunnel(config).status).toBe("connected"); - await stopAllTunnels(); - }); - - // With auto-reconnect disabled, a live tunnel whose frpc child exits on its - // own (crash/relay drop) flips "connected" -> "error" so the UI stops - // advertising a dead tunnel. (The default behavior — auto-reconnect — is - // covered in the reconnect tests below.) - test("a tunnel child exiting on its own flips connected to error (reconnect disabled)", async () => { - process.env.GINI_TUNNEL_RECONNECT_MAX_ATTEMPTS = "0"; // restored in afterEach - const child = crashableChild(); - setTunnelDeps(deps({ buildTunnel: () => child })); - await connectTunnel(config, "gini-relay"); - await awaitTunnelSettled(config.instance); - expect(getTunnel(config).status).toBe("connected"); - - child.crash(7); - await waitForStatus(config, "error"); - const state = getTunnel(config); - expect(state.message).toContain("exited (code 7)"); - expect(state.url).toBeUndefined(); - }); - - // The exit watcher must NOT fire after an intentional disconnect: disconnect - // stops the child (resolving its exited), but the entry is gone, so the idle - // record stands instead of being clobbered with a spurious "error". - test("a child exiting via disconnect does not overwrite the idle record", async () => { - const child = crashableChild(); - setTunnelDeps(deps({ buildTunnel: () => child })); - await connectTunnel(config, "gini-relay"); - await awaitTunnelSettled(config.instance); - expect(getTunnel(config).status).toBe("connected"); - - await disconnectTunnel(config); - expect(getTunnel(config).status).toBe("idle"); - for (let i = 0; i < 10; i += 1) await Promise.resolve(); - expect(getTunnel(config).status).toBe("idle"); - }); - - // A connect that starts while disconnect is awaiting logout must NOT be - // clobbered back to idle: disconnect's idle write skips when a newer connect - // has claimed the instance. - test("disconnect does not clobber a connect that starts during the logout await", async () => { - const logoutGate = Promise.withResolvers(); - const childB = fakeChild(); - let built = 0; - setTunnelDeps( - deps({ - logout: () => logoutGate.promise, - buildTunnel: () => (built++ === 0 ? fakeChild() : childB) - }) - ); - // Get to connected (childA). - await connectTunnel(config, "gini-relay"); - await awaitTunnelSettled(config.instance); - expect(getTunnel(config).status).toBe("connected"); - - // Begin disconnect; it tears down then parks on the gated logout. - const disconnecting = disconnectTunnel(config); - for (let i = 0; i < 5; i += 1) await Promise.resolve(); - - // A new connect claims the instance while disconnect is mid-logout. - await connectTunnel(config, "gini-relay"); - await awaitTunnelSettled(config.instance); - expect(getTunnel(config).status).toBe("connected"); - - // Release logout; disconnect must leave the new connect's record intact. - logoutGate.resolve(); - await disconnecting; - expect(getTunnel(config).status).toBe("connected"); - }); -}); - // --------------------------------------------------------------------------- // Manual drivers: detection-driven catalog, the manual connect/disconnect/ // resume flows, and the runtime-tunnel origin trust they publish. @@ -2716,7 +1181,6 @@ describe("manual tunnel drivers", () => { } }); }); - // --------------------------------------------------------------------------- // spawnUrlChild: the URL-scanning process wrapper the ngrok/cloudflared // drivers ride on. @@ -2934,7 +1398,6 @@ describe("spawnUrlChild", () => { } }); }); - // --------------------------------------------------------------------------- // makeDefaultDrivers: the real CLI adapters, exercised through injected // run/spawn seams (no tailscale/ngrok/cloudflared binaries involved). @@ -3333,7 +1796,6 @@ describe("makeDefaultDrivers", () => { } }); }); - // --------------------------------------------------------------------------- // Auto-reconnect after an unexpected child exit, and the relay readiness // timeout. frp's own loop recovers a transient drop without exiting; these diff --git a/packages/runtime/src/jobs.test.ts b/packages/runtime/src/jobs.part1.test.ts similarity index 54% rename from packages/runtime/src/jobs.test.ts rename to packages/runtime/src/jobs.part1.test.ts index b28ca837..233588e7 100644 --- a/packages/runtime/src/jobs.test.ts +++ b/packages/runtime/src/jobs.part1.test.ts @@ -23,6 +23,7 @@ import { dispatchToolCall } from "./execution/tool-dispatch"; import { syncChatTaskResult } from "./execution/chat"; import type { RuntimeConfig } from "./types"; + describe("cron lifecycle", () => { test("scheduler skips paused jobs even when they're due", async () => { const config = testConfig("jobs-paused"); @@ -785,1899 +786,6 @@ describe("cron lifecycle", () => { // No job should have been persisted. expect(readState(config.instance).jobs).toHaveLength(0); }); - - test("create_job dispatch rejects non-string entries in autoApproveCommands", async () => { - const config = testConfig("jobs-create-tool-validate-2"); - const taskId = await mutateState(config.instance, (state) => { - const task = createTask(state.instance, "test", undefined, undefined, undefined, undefined); - upsertTask(state, task); - return task.id; - }); - - await expect( - dispatchToolCall( - config, - taskId, - "create_job", - "call_bad_entry", - JSON.stringify({ name: "bad", intervalSeconds: 60, prompt: "x", autoApproveCommands: ["ok", 7] }) - ) - ).rejects.toThrow(/autoApproveCommands entries must be strings/); - expect(readState(config.instance).jobs).toHaveLength(0); - }); - - test("create_job dispatch accepts approvalMode and persists it", async () => { - const config = testConfig("jobs-create-tool-approval-mode"); - const taskId = await mutateState(config.instance, (state) => { - const task = createTask(state.instance, "test", undefined, undefined, undefined, undefined); - upsertTask(state, task); - return task.id; - }); - - await dispatchToolCall( - config, - taskId, - "create_job", - "call_mode", - JSON.stringify({ - name: "mode-job", - intervalSeconds: 60, - prompt: "x", - approvalMode: "yolo" - }) - ); - - const jobs = readState(config.instance).jobs; - expect(jobs).toHaveLength(1); - expect(jobs[0]?.approvalMode).toBe("yolo"); - const audit = readState(config.instance).audit.find( - (event) => event.action === "job.created" && event.target === jobs[0]!.id - ); - expect(audit?.evidence?.approvalMode).toBe("yolo"); - }); - - test("create_job dispatch rejects invalid approvalMode value", async () => { - const config = testConfig("jobs-create-tool-bad-mode"); - const taskId = await mutateState(config.instance, (state) => { - const task = createTask(state.instance, "test", undefined, undefined, undefined, undefined); - upsertTask(state, task); - return task.id; - }); - - await expect( - dispatchToolCall( - config, - taskId, - "create_job", - "call_bad_mode", - JSON.stringify({ name: "bad", intervalSeconds: 60, prompt: "x", approvalMode: "loose" }) - ) - ).rejects.toThrow(/approvalMode must be one of/); - expect(readState(config.instance).jobs).toHaveLength(0); - }); - - test("create_job dispatch accepts both approvalMode and legacy dangerouslyAutoApprove (alias)", async () => { - // Both fields are accepted on the same payload. approvalMode is - // the canonical signal; the legacy flag is preserved on the - // JobRecord as a deprecated alias. - const config = testConfig("jobs-create-tool-both-fields"); - const taskId = await mutateState(config.instance, (state) => { - const task = createTask(state.instance, "test", undefined, undefined, undefined, undefined); - upsertTask(state, task); - return task.id; - }); - - await dispatchToolCall( - config, - taskId, - "create_job", - "call_both", - JSON.stringify({ - name: "both-fields", - intervalSeconds: 60, - prompt: "x", - approvalMode: "yolo", - dangerouslyAutoApprove: true - }) - ); - - const jobs = readState(config.instance).jobs; - expect(jobs).toHaveLength(1); - expect(jobs[0]?.approvalMode).toBe("yolo"); - expect(jobs[0]?.dangerouslyAutoApprove).toBe(true); - }); - - test("create_job dispatch accepts and persists preRunHook", async () => { - // The tool passes preRunHook through to createScheduledJob unvalidated — - // the same seam the HTTP /api/jobs route uses — so the persisted - // JobRecord must carry the validated hook verbatim, and the audit row - // must pin the handler id. - const config = testConfig("jobs-create-tool-prerunhook"); - const taskId = await mutateState(config.instance, (state) => { - const task = createTask(state.instance, "test", undefined, undefined, undefined, undefined); - upsertTask(state, task); - return task.id; - }); - - const result = await dispatchToolCall( - config, - taskId, - "create_job", - "call_create_job_hook", - JSON.stringify({ - name: "call-watch c-123", - intervalSeconds: 30, - prompt: "report the call result", - timeoutSeconds: 120, - preRunHook: { - handlerId: "skill-script", - config: { skill: "phone-call", script: "call-watch", callId: "c-123" }, - timeoutMs: 25000 - } - }) - ); - expect(result.kind).toBe("sync"); - - const jobs = readState(config.instance).jobs; - expect(jobs).toHaveLength(1); - expect(jobs[0]?.preRunHook).toEqual({ - handlerId: "skill-script", - config: { skill: "phone-call", script: "call-watch", callId: "c-123" }, - timeoutMs: 25000 - }); - const audit = readState(config.instance).audit.find( - (event) => event.action === "job.created" && event.target === jobs[0]!.id - ); - expect(audit?.evidence?.preRunHookHandlerId).toBe("skill-script"); - }); - - test("create_job dispatch rejects an unknown preRunHook handlerId", async () => { - // The registry gate in createScheduledJob is the security boundary: an - // unregistered handlerId must be rejected at create time and persist - // nothing. - const config = testConfig("jobs-create-tool-prerunhook-bad"); - const taskId = await mutateState(config.instance, (state) => { - const task = createTask(state.instance, "test", undefined, undefined, undefined, undefined); - upsertTask(state, task); - return task.id; - }); - - await expect( - dispatchToolCall( - config, - taskId, - "create_job", - "call_create_job_hook_bad", - JSON.stringify({ - name: "bad-hook", - intervalSeconds: 60, - prompt: "x", - preRunHook: { handlerId: "not-a-handler", config: {} } - }) - ) - ).rejects.toThrow(/not a known hook handler/); - expect(readState(config.instance).jobs).toHaveLength(0); - }); - - test("create_job dispatch rejects a null preRunHook config", async () => { - // `typeof null === "object"`, so a bare typeof check would let - // config: null through to a hook that can never resolve its payload. - const config = testConfig("jobs-create-tool-prerunhook-null-config"); - const taskId = await mutateState(config.instance, (state) => { - const task = createTask(state.instance, "test", undefined, undefined, undefined, undefined); - upsertTask(state, task); - return task.id; - }); - - await expect( - dispatchToolCall( - config, - taskId, - "create_job", - "call_create_job_hook_null_config", - JSON.stringify({ - name: "null-hook-config", - intervalSeconds: 60, - prompt: "x", - preRunHook: { handlerId: "skill-script", config: null } - }) - ) - ).rejects.toThrow(/preRunHook\.config must be an object/); - expect(readState(config.instance).jobs).toHaveLength(0); - }); - - test("create_job dispatch persists cronExpression + cronTimezone", async () => { - // Happy-path cron creation through the tool dispatch surface. The - // agent should be able to schedule a wall-clock job by name + - // expression + tz, and the resulting JobRecord must carry both fields - // verbatim (plus intervalSeconds=0 as the "not interval-driven" - // sentinel). - const config = testConfig("jobs-create-tool-cron"); - const taskId = await mutateState(config.instance, (state) => { - const task = createTask(state.instance, "test", undefined, undefined, undefined, undefined); - upsertTask(state, task); - return task.id; - }); - - const result = await dispatchToolCall( - config, - taskId, - "create_job", - "call_create_job_cron", - JSON.stringify({ - name: "daily-9am", - prompt: "morning report", - cronExpression: "0 9 * * *", - cronTimezone: "America/Los_Angeles" - }) - ); - expect(result.kind).toBe("sync"); - - const jobs = readState(config.instance).jobs; - expect(jobs).toHaveLength(1); - expect(jobs[0]?.cronExpression).toBe("0 9 * * *"); - expect(jobs[0]?.cronTimezone).toBe("America/Los_Angeles"); - // Cron-driven jobs carry no intervalSeconds (field is optional). - expect(jobs[0]?.intervalSeconds).toBeUndefined(); - - // Audit + return-message both reflect the cron cadence so a reviewer - // and the agent's follow-up reply describe the schedule correctly. - const audit = readState(config.instance).audit.find( - (event) => event.action === "job.created" && event.target === jobs[0]!.id - ); - expect(audit?.evidence?.cronExpression).toBe("0 9 * * *"); - expect(audit?.evidence?.cronTimezone).toBe("America/Los_Angeles"); - if (result.kind === "sync") { - expect(result.result).toContain("cron"); - expect(result.result).toContain("America/Los_Angeles"); - } - }); - - test("create_job dispatch rejects both intervalSeconds and cronExpression set", async () => { - const config = testConfig("jobs-create-tool-mutex"); - const taskId = await mutateState(config.instance, (state) => { - const task = createTask(state.instance, "test", undefined, undefined, undefined, undefined); - upsertTask(state, task); - return task.id; - }); - - await expect( - dispatchToolCall( - config, - taskId, - "create_job", - "call_both", - JSON.stringify({ - name: "both", - prompt: "x", - intervalSeconds: 60, - cronExpression: "0 9 * * *" - }) - ) - ).rejects.toThrow(/mutually exclusive/); - expect(readState(config.instance).jobs).toHaveLength(0); - }); - - test("create_job dispatch rejects when neither intervalSeconds nor cronExpression is set", async () => { - const config = testConfig("jobs-create-tool-neither"); - const taskId = await mutateState(config.instance, (state) => { - const task = createTask(state.instance, "test", undefined, undefined, undefined, undefined); - upsertTask(state, task); - return task.id; - }); - - await expect( - dispatchToolCall( - config, - taskId, - "create_job", - "call_neither", - JSON.stringify({ name: "neither", prompt: "x" }) - ) - ).rejects.toThrow(/requires either intervalSeconds or cronExpression/); - expect(readState(config.instance).jobs).toHaveLength(0); - }); - - test("create_job dispatch rejects malformed cronExpression", async () => { - const config = testConfig("jobs-create-tool-bad-cron"); - const taskId = await mutateState(config.instance, (state) => { - const task = createTask(state.instance, "test", undefined, undefined, undefined, undefined); - upsertTask(state, task); - return task.id; - }); - - await expect( - dispatchToolCall( - config, - taskId, - "create_job", - "call_bad_cron", - JSON.stringify({ name: "bad", prompt: "x", cronExpression: "foo bar baz qux quux" }) - ) - ).rejects.toThrow(/Invalid input: cronExpression/); - expect(readState(config.instance).jobs).toHaveLength(0); - }); - - test("create_job dispatch rejects non-integer timeoutSeconds", async () => { - const config = testConfig("jobs-create-tool-validate-3"); - const taskId = await mutateState(config.instance, (state) => { - const task = createTask(state.instance, "test", undefined, undefined, undefined, undefined); - upsertTask(state, task); - return task.id; - }); - - await expect( - dispatchToolCall( - config, - taskId, - "create_job", - "call_bad_timeout", - JSON.stringify({ name: "bad", intervalSeconds: 60, prompt: "x", timeoutSeconds: -5 }) - ) - ).rejects.toThrow(/timeoutSeconds must be a positive integer/); - expect(readState(config.instance).jobs).toHaveLength(0); - }); - - test("list_jobs dispatch returns a compact summary of all jobs", async () => { - const config = testConfig("jobs-list-tool"); - const handler = createHandler(config); - // Two jobs of mixed schedule shape so we can confirm both cron and - // interval drivers surface correctly in the summary. - await call(handler, config, "/api/jobs", { - method: "POST", - body: JSON.stringify({ name: "alpha-reminder", script: "true", intervalSeconds: 60 }) - }); - await call(handler, config, "/api/jobs", { - method: "POST", - body: JSON.stringify({ - name: "beta-daily", - script: "true", - cronExpression: "0 9 * * *", - cronTimezone: "America/Los_Angeles" - }) - }); - - const taskId = await mutateState(config.instance, (state) => { - const task = createTask(state.instance, "test", undefined, undefined, undefined, undefined); - upsertTask(state, task); - return task.id; - }); - - const result = await dispatchToolCall( - config, - taskId, - "list_jobs", - "call_list_1", - JSON.stringify({}) - ); - expect(result.kind).toBe("sync"); - if (result.kind !== "sync") throw new Error("expected sync result"); - const parsed = JSON.parse(result.result) as { count: number; jobs: Array> }; - expect(parsed.count).toBe(2); - const names = new Set(parsed.jobs.map((j) => j.name)); - expect(names.has("alpha-reminder")).toBe(true); - expect(names.has("beta-daily")).toBe(true); - const cronEntry = parsed.jobs.find((j) => j.name === "beta-daily"); - expect(cronEntry?.cronExpression).toBe("0 9 * * *"); - expect(cronEntry?.cronTimezone).toBe("America/Los_Angeles"); - - // The listing call writes an audit row so the log records when the - // agent pulled the job inventory. - const audit = readState(config.instance).audit.find( - (event) => event.action === "job.listed" - ); - expect(audit).toBeDefined(); - expect(audit?.evidence?.total).toBe(2); - expect(audit?.evidence?.returned).toBe(2); - }); - - test("list_jobs dispatch filters by nameContains (case-insensitive)", async () => { - const config = testConfig("jobs-list-tool-filter"); - const handler = createHandler(config); - await call(handler, config, "/api/jobs", { - method: "POST", - body: JSON.stringify({ name: "Daily Report", script: "true", intervalSeconds: 60 }) - }); - await call(handler, config, "/api/jobs", { - method: "POST", - body: JSON.stringify({ name: "cake-reminder", script: "true", intervalSeconds: 60 }) - }); - - const taskId = await mutateState(config.instance, (state) => { - const task = createTask(state.instance, "test", undefined, undefined, undefined, undefined); - upsertTask(state, task); - return task.id; - }); - - const result = await dispatchToolCall( - config, - taskId, - "list_jobs", - "call_list_filter", - JSON.stringify({ nameContains: "DAILY" }) - ); - if (result.kind !== "sync") throw new Error("expected sync result"); - const parsed = JSON.parse(result.result) as { count: number; jobs: Array> }; - expect(parsed.count).toBe(1); - expect(parsed.jobs[0]?.name).toBe("Daily Report"); - }); - - test("list_jobs dispatch rejects non-string nameContains", async () => { - const config = testConfig("jobs-list-tool-bad-filter"); - const taskId = await mutateState(config.instance, (state) => { - const task = createTask(state.instance, "test", undefined, undefined, undefined, undefined); - upsertTask(state, task); - return task.id; - }); - await expect( - dispatchToolCall( - config, - taskId, - "list_jobs", - "call_bad_filter", - JSON.stringify({ nameContains: 7 }) - ) - ).rejects.toThrow(/nameContains must be a string/); - }); - - test("list_jobs dispatch truncates long prompts to ~200 chars", async () => { - const config = testConfig("jobs-list-tool-truncate"); - const handler = createHandler(config); - const longPrompt = "x".repeat(500); - await call(handler, config, "/api/jobs", { - method: "POST", - body: JSON.stringify({ name: "long", prompt: longPrompt, intervalSeconds: 60 }) - }); - - const taskId = await mutateState(config.instance, (state) => { - const task = createTask(state.instance, "test", undefined, undefined, undefined, undefined); - upsertTask(state, task); - return task.id; - }); - const result = await dispatchToolCall( - config, - taskId, - "list_jobs", - "call_truncate", - JSON.stringify({}) - ); - if (result.kind !== "sync") throw new Error("expected sync result"); - const parsed = JSON.parse(result.result) as { jobs: Array<{ prompt: string }> }; - // Truncated form is 200 chars + ellipsis marker. - expect(parsed.jobs[0]?.prompt.length).toBeLessThan(longPrompt.length); - expect(parsed.jobs[0]?.prompt.endsWith("…")).toBe(true); - }); - - test("list_jobs dispatch returns verbatim prompts when fullPrompt is true", async () => { - // The agent needs the unstruncated prompt when it intends to edit it - // (append, search-and-replace), since update_job's prompt field is - // REPLACE-only. With `fullPrompt: true` the handler returns the - // entire stored prompt unchanged. - const config = testConfig("jobs-list-tool-full-prompt"); - const handler = createHandler(config); - const longPrompt = "y".repeat(300); - await call(handler, config, "/api/jobs", { - method: "POST", - body: JSON.stringify({ name: "verbatim", prompt: longPrompt, intervalSeconds: 60 }) - }); - - const taskId = await mutateState(config.instance, (state) => { - const task = createTask(state.instance, "test", undefined, undefined, undefined, undefined); - upsertTask(state, task); - return task.id; - }); - - const verbatim = await dispatchToolCall( - config, - taskId, - "list_jobs", - "call_full", - JSON.stringify({ fullPrompt: true }) - ); - if (verbatim.kind !== "sync") throw new Error("expected sync result"); - const verbatimParsed = JSON.parse(verbatim.result) as { jobs: Array<{ prompt: string }> }; - expect(verbatimParsed.jobs[0]?.prompt.length).toBe(longPrompt.length); - expect(verbatimParsed.jobs[0]?.prompt).toBe(longPrompt); - expect(verbatimParsed.jobs[0]?.prompt.endsWith("…")).toBe(false); - - // Same job, without the flag, falls back to the 200-char truncation - // so a long prompt doesn't blow up the tool-result context. - const truncated = await dispatchToolCall( - config, - taskId, - "list_jobs", - "call_trunc", - JSON.stringify({}) - ); - if (truncated.kind !== "sync") throw new Error("expected sync result"); - const truncatedParsed = JSON.parse(truncated.result) as { jobs: Array<{ prompt: string }> }; - expect(truncatedParsed.jobs[0]?.prompt.length).toBeLessThan(longPrompt.length); - expect(truncatedParsed.jobs[0]?.prompt.endsWith("…")).toBe(true); - }); - - test("list_jobs dispatch rejects non-boolean fullPrompt", async () => { - const config = testConfig("jobs-list-tool-full-prompt-bad"); - const taskId = await mutateState(config.instance, (state) => { - const task = createTask(state.instance, "test", undefined, undefined, undefined, undefined); - upsertTask(state, task); - return task.id; - }); - await expect( - dispatchToolCall( - config, - taskId, - "list_jobs", - "call_full_bad", - JSON.stringify({ fullPrompt: "yes" }) - ) - ).rejects.toThrow(/fullPrompt must be a boolean/); - }); - - test("update_job dispatch patches schedule and writes job.updated audit", async () => { - const config = testConfig("jobs-update-tool"); - const handler = createHandler(config); - const job = await call(handler, config, "/api/jobs", { - method: "POST", - body: JSON.stringify({ name: "to-update", script: "true", intervalSeconds: 60 }) - }); - - const taskId = await mutateState(config.instance, (state) => { - const task = createTask(state.instance, "test", undefined, undefined, undefined, undefined); - upsertTask(state, task); - return task.id; - }); - - const result = await dispatchToolCall( - config, - taskId, - "update_job", - "call_update_1", - JSON.stringify({ - jobId: job.id, - cronExpression: "0 23 * * *", - cronTimezone: "America/Los_Angeles", - intervalSeconds: null, - name: "renamed" - }) - ); - expect(result.kind).toBe("sync"); - const after = readState(config.instance).jobs.find((j) => j.id === job.id); - expect(after?.cronExpression).toBe("0 23 * * *"); - expect(after?.cronTimezone).toBe("America/Los_Angeles"); - expect(after?.intervalSeconds).toBeUndefined(); - expect(after?.name).toBe("renamed"); - - const audit = readState(config.instance).audit.find( - (event) => event.action === "job.updated" && event.target === job.id && event.actor === "agent" - ); - expect(audit).toBeDefined(); - expect(audit?.evidence?.jobId).toBe(job.id); - expect(audit?.evidence?.appliedFields).toContain("cronExpression"); - expect(audit?.evidence?.appliedFields).toContain("name"); - // The audit row pins the prior schedule shape so the change is - // reconstructable from the log alone. - expect((audit?.evidence?.previousSchedule as Record)?.intervalSeconds).toBe(60); - }); - - test("update_job dispatch can pause a running job", async () => { - const config = testConfig("jobs-update-tool-pause"); - const handler = createHandler(config); - const job = await call(handler, config, "/api/jobs", { - method: "POST", - body: JSON.stringify({ name: "to-pause", script: "true", intervalSeconds: 60 }) - }); - - const taskId = await mutateState(config.instance, (state) => { - const task = createTask(state.instance, "test", undefined, undefined, undefined, undefined); - upsertTask(state, task); - return task.id; - }); - const result = await dispatchToolCall( - config, - taskId, - "update_job", - "call_pause", - JSON.stringify({ jobId: job.id, status: "paused" }) - ); - expect(result.kind).toBe("sync"); - const after = readState(config.instance).jobs.find((j) => j.id === job.id); - expect(after?.status).toBe("paused"); - const audit = readState(config.instance).audit.find( - (event) => event.action === "job.updated" && event.target === job.id && event.actor === "agent" - ); - expect(audit?.evidence?.appliedFields).toContain("status"); - // The return string must not claim a next-fire moment for a paused - // job — the scheduler skips it while paused, so "next fires at ..." - // would be a lie. - if (result.kind === "sync") { - expect(result.result).not.toContain("next fires at"); - expect(result.result).toContain("will not fire until resumed"); - } - }); - - test("update_job dispatch rejects missing jobId", async () => { - const config = testConfig("jobs-update-tool-bad-1"); - const taskId = await mutateState(config.instance, (state) => { - const task = createTask(state.instance, "test", undefined, undefined, undefined, undefined); - upsertTask(state, task); - return task.id; - }); - await expect( - dispatchToolCall( - config, - taskId, - "update_job", - "call_bad_no_id", - JSON.stringify({ name: "x" }) - ) - ).rejects.toThrow(/jobId/); - }); - - test("update_job dispatch rejects empty patch", async () => { - const config = testConfig("jobs-update-tool-empty"); - const handler = createHandler(config); - const job = await call(handler, config, "/api/jobs", { - method: "POST", - body: JSON.stringify({ name: "empty-patch", script: "true", intervalSeconds: 60 }) - }); - const taskId = await mutateState(config.instance, (state) => { - const task = createTask(state.instance, "test", undefined, undefined, undefined, undefined); - upsertTask(state, task); - return task.id; - }); - await expect( - dispatchToolCall( - config, - taskId, - "update_job", - "call_empty_patch", - JSON.stringify({ jobId: job.id }) - ) - ).rejects.toThrow(/at least one field/); - }); - - test("update_job dispatch rejects unknown jobId", async () => { - const config = testConfig("jobs-update-tool-missing"); - const taskId = await mutateState(config.instance, (state) => { - const task = createTask(state.instance, "test", undefined, undefined, undefined, undefined); - upsertTask(state, task); - return task.id; - }); - await expect( - dispatchToolCall( - config, - taskId, - "update_job", - "call_unknown", - JSON.stringify({ jobId: "job_does_not_exist", name: "x" }) - ) - ).rejects.toThrow(/Job not found/); - }); - - test("update_job dispatch applies autoApproveCommands and dangerouslyAutoApprove onto the JobRecord", async () => { - const config = testConfig("jobs-update-tool-auto-approve"); - const handler = createHandler(config); - const job = await call(handler, config, "/api/jobs", { - method: "POST", - body: JSON.stringify({ name: "approve-me", script: "true", intervalSeconds: 60 }) - }); - - const taskId = await mutateState(config.instance, (state) => { - const task = createTask(state.instance, "test", undefined, undefined, undefined, undefined); - upsertTask(state, task); - return task.id; - }); - - const result = await dispatchToolCall( - config, - taskId, - "update_job", - "call_auto_approve", - JSON.stringify({ - jobId: job.id, - autoApproveCommands: ["ls", "git status"], - dangerouslyAutoApprove: true - }) - ); - expect(result.kind).toBe("sync"); - const after = readState(config.instance).jobs.find((j) => j.id === job.id); - expect(after?.dangerouslyAutoApprove).toBe(true); - expect(after?.autoApproveCommands).toEqual(["ls", "git status"]); - - // Clearing via empty array drops the override entirely. - const cleared = await dispatchToolCall( - config, - taskId, - "update_job", - "call_auto_approve_clear", - JSON.stringify({ - jobId: job.id, - autoApproveCommands: [], - dangerouslyAutoApprove: false - }) - ); - expect(cleared.kind).toBe("sync"); - const afterClear = readState(config.instance).jobs.find((j) => j.id === job.id); - expect(afterClear?.dangerouslyAutoApprove).toBe(false); - expect(afterClear?.autoApproveCommands).toBeUndefined(); - }); - - test("update_job dispatch rejects null prompt", async () => { - // `prompt: null` is not a valid clear signal — JobRecord.prompt is - // string-typed. Throw `Invalid input` so the agent's follow-up - // can't misreport a phantom prompt change. - const config = testConfig("jobs-update-tool-null-prompt"); - const handler = createHandler(config); - const job = await call(handler, config, "/api/jobs", { - method: "POST", - body: JSON.stringify({ name: "null-prompt", script: "true", intervalSeconds: 60 }) - }); - const taskId = await mutateState(config.instance, (state) => { - const task = createTask(state.instance, "test", undefined, undefined, undefined, undefined); - upsertTask(state, task); - return task.id; - }); - await expect( - dispatchToolCall( - config, - taskId, - "update_job", - "call_null_prompt", - JSON.stringify({ jobId: job.id, prompt: null }) - ) - ).rejects.toThrow(/Invalid input: prompt must be a non-empty string/); - }); - - test("update_job dispatch rejects non-string name", async () => { - const config = testConfig("jobs-update-tool-numeric-name"); - const handler = createHandler(config); - const job = await call(handler, config, "/api/jobs", { - method: "POST", - body: JSON.stringify({ name: "to-rename", script: "true", intervalSeconds: 60 }) - }); - const taskId = await mutateState(config.instance, (state) => { - const task = createTask(state.instance, "test", undefined, undefined, undefined, undefined); - upsertTask(state, task); - return task.id; - }); - await expect( - dispatchToolCall( - config, - taskId, - "update_job", - "call_numeric_name", - JSON.stringify({ jobId: job.id, name: 123 }) - ) - ).rejects.toThrow(/Invalid input: name must be a non-empty string/); - }); - - test("update_job dispatch rejects empty-string name", async () => { - const config = testConfig("jobs-update-tool-empty-name"); - const handler = createHandler(config); - const job = await call(handler, config, "/api/jobs", { - method: "POST", - body: JSON.stringify({ name: "to-keep", script: "true", intervalSeconds: 60 }) - }); - const taskId = await mutateState(config.instance, (state) => { - const task = createTask(state.instance, "test", undefined, undefined, undefined, undefined); - upsertTask(state, task); - return task.id; - }); - await expect( - dispatchToolCall( - config, - taskId, - "update_job", - "call_empty_name", - JSON.stringify({ jobId: job.id, name: "" }) - ) - ).rejects.toThrow(/Invalid input: name must be a non-empty string/); - }); - - test("update_job dispatch rejects invalid status value", async () => { - const config = testConfig("jobs-update-tool-bad-status"); - const handler = createHandler(config); - const job = await call(handler, config, "/api/jobs", { - method: "POST", - body: JSON.stringify({ name: "bad-status", script: "true", intervalSeconds: 60 }) - }); - const taskId = await mutateState(config.instance, (state) => { - const task = createTask(state.instance, "test", undefined, undefined, undefined, undefined); - upsertTask(state, task); - return task.id; - }); - await expect( - dispatchToolCall( - config, - taskId, - "update_job", - "call_bad_status", - JSON.stringify({ jobId: job.id, status: "failed" }) - ) - ).rejects.toThrow(/status must be 'active' or 'paused'/); - }); - - test("create_job dispatch persists deliveryTargets as the resolved bridge id", async () => { - const config = testConfig("jobs-create-tool-delivery"); - const { addMessagingBridge } = await import("./integrations/messaging"); - const bridge = await addMessagingBridge(config, { - name: "disc", - kind: "discord", - deliveryTargets: ["chan-1"], - botToken: "TOK" - }); - const taskId = await mutateState(config.instance, (state) => { - const task = createTask(state.instance, "test", undefined, undefined, undefined, undefined); - upsertTask(state, task); - return task.id; - }); - - await dispatchToolCall( - config, - taskId, - "create_job", - "call_delivery", - JSON.stringify({ name: "briefing", intervalSeconds: 60, prompt: "x", deliveryTargets: ["disc"] }) - ); - - // The entry is stored as the bridge id, not the name the caller - // typed — names and kinds are not unique, and bridge ordering - // shifts as records are added, so the id pins the user's choice. - const jobs = readState(config.instance).jobs; - expect(jobs).toHaveLength(1); - expect(jobs[0]?.deliveryTargets).toEqual([bridge.id]); - const audit = readState(config.instance).audit.find( - (event) => event.action === "job.created" && event.target === jobs[0]!.id - ); - expect(audit?.evidence?.deliveryTargets).toEqual([bridge.id]); - }); - - test("create_job dispatch rejects an unknown deliveryTargets entry, listing dispatchable bridge names", async () => { - const config = testConfig("jobs-create-tool-delivery-bad"); - const { addMessagingBridge } = await import("./integrations/messaging"); - await addMessagingBridge(config, { - name: "disc", - kind: "discord", - deliveryTargets: ["chan-1"], - botToken: "TOK" - }); - const taskId = await mutateState(config.instance, (state) => { - const task = createTask(state.instance, "test", undefined, undefined, undefined, undefined); - upsertTask(state, task); - return task.id; - }); - - // The error names the dispatchable bridges so the agent can relay a - // fixable message ("did you mean 'disc'?") instead of a dead end. - await expect( - dispatchToolCall( - config, - taskId, - "create_job", - "call_delivery_bad", - JSON.stringify({ name: "briefing", intervalSeconds: 60, prompt: "x", deliveryTargets: ["whatsapp"] }) - ) - ).rejects.toThrow(/no dispatchable messaging bridge matches 'whatsapp'. Dispatchable bridges: disc/); - expect(readState(config.instance).jobs).toHaveLength(0); - }); - - test("create_job dispatch rejects a non-dispatchable (demo) bridge as a deliveryTargets entry", async () => { - const config = testConfig("jobs-create-tool-delivery-demo"); - const { addMessagingBridge } = await import("./integrations/messaging"); - // Demo bridges are common (CLI default kind) but the finalizer can - // only send to telegram/discord — accepting one here would validate - // a target that fails on every fire. - await addMessagingBridge(config, { name: "demo-bridge", kind: "demo" }); - await addMessagingBridge(config, { - name: "disc", - kind: "discord", - deliveryTargets: ["chan-1"], - botToken: "TOK" - }); - const taskId = await mutateState(config.instance, (state) => { - const task = createTask(state.instance, "test", undefined, undefined, undefined, undefined); - upsertTask(state, task); - return task.id; - }); - - await expect( - dispatchToolCall( - config, - taskId, - "create_job", - "call_delivery_demo", - JSON.stringify({ name: "briefing", intervalSeconds: 60, prompt: "x", deliveryTargets: ["demo-bridge"] }) - ) - ).rejects.toThrow(/no dispatchable messaging bridge matches 'demo-bridge'. Dispatchable bridges: disc/); - expect(readState(config.instance).jobs).toHaveLength(0); - }); - - test("create_job dispatch rejects an ambiguous deliveryTargets entry, listing the candidates", async () => { - const config = testConfig("jobs-create-tool-delivery-ambiguous"); - const { addMessagingBridge } = await import("./integrations/messaging"); - const a = await addMessagingBridge(config, { - name: "disc-a", - kind: "discord", - deliveryTargets: ["chan-1"], - botToken: "TOK" - }); - const b = await addMessagingBridge(config, { - name: "disc-b", - kind: "discord", - deliveryTargets: ["chan-2"], - botToken: "TOK" - }); - const taskId = await mutateState(config.instance, (state) => { - const task = createTask(state.instance, "test", undefined, undefined, undefined, undefined); - upsertTask(state, task); - return task.id; - }); - - // "discord" matches both bridges by kind — first-match would - // silently pick whichever record happens to sort first, so the - // entry is rejected with both candidates named. - let message = ""; - try { - await dispatchToolCall( - config, - taskId, - "create_job", - "call_delivery_ambiguous", - JSON.stringify({ name: "briefing", intervalSeconds: 60, prompt: "x", deliveryTargets: ["discord"] }) - ); - } catch (error) { - message = error instanceof Error ? error.message : String(error); - } - expect(message).toContain("ambiguous"); - expect(message).toContain(`disc-a (${a.id})`); - expect(message).toContain(`disc-b (${b.id})`); - expect(readState(config.instance).jobs).toHaveLength(0); - }); - - test("update_job dispatch sets deliveryTargets and clears them with []", async () => { - const config = testConfig("jobs-update-tool-delivery"); - const handler = createHandler(config); - const { addMessagingBridge } = await import("./integrations/messaging"); - const bridge = await addMessagingBridge(config, { - name: "disc", - kind: "discord", - deliveryTargets: ["chan-1"], - botToken: "TOK" - }); - const job = await call(handler, config, "/api/jobs", { - method: "POST", - body: JSON.stringify({ name: "to-route", script: "true", intervalSeconds: 60 }) - }); - const taskId = await mutateState(config.instance, (state) => { - const task = createTask(state.instance, "test", undefined, undefined, undefined, undefined); - upsertTask(state, task); - return task.id; - }); - - await dispatchToolCall( - config, - taskId, - "update_job", - "call_set_delivery", - JSON.stringify({ jobId: job.id, deliveryTargets: ["disc"] }) - ); - expect(readState(config.instance).jobs.find((j) => j.id === job.id)?.deliveryTargets).toEqual([bridge.id]); - - // An unknown entry is rejected with the dispatchable names and the - // previously-set targets stay untouched. - await expect( - dispatchToolCall( - config, - taskId, - "update_job", - "call_bad_delivery", - JSON.stringify({ jobId: job.id, deliveryTargets: ["slackk"] }) - ) - ).rejects.toThrow(/no dispatchable messaging bridge matches 'slackk'. Dispatchable bridges: disc/); - expect(readState(config.instance).jobs.find((j) => j.id === job.id)?.deliveryTargets).toEqual([bridge.id]); - - // Empty array is the documented "clear" signal. - await dispatchToolCall( - config, - taskId, - "update_job", - "call_clear_delivery", - JSON.stringify({ jobId: job.id, deliveryTargets: [] }) - ); - expect(readState(config.instance).jobs.find((j) => j.id === job.id)?.deliveryTargets).toEqual([]); - }); - - test("delete_job dispatch removes the job and writes job.deleted audit", async () => { - const config = testConfig("jobs-delete-tool"); - const handler = createHandler(config); - const job = await call(handler, config, "/api/jobs", { - method: "POST", - body: JSON.stringify({ - name: "to-delete", - script: "true", - cronExpression: "0 9 * * *", - cronTimezone: "UTC" - }) - }); - - const taskId = await mutateState(config.instance, (state) => { - const task = createTask(state.instance, "test", undefined, undefined, undefined, undefined); - upsertTask(state, task); - return task.id; - }); - - const result = await dispatchToolCall( - config, - taskId, - "delete_job", - "call_delete_1", - JSON.stringify({ jobId: job.id }) - ); - expect(result.kind).toBe("sync"); - if (result.kind === "sync") { - expect(result.result).toContain(job.id); - expect(result.result).toContain("to-delete"); - } - - expect(readState(config.instance).jobs.find((j) => j.id === job.id)).toBeUndefined(); - const audit = readState(config.instance).audit.find( - (event) => event.action === "job.deleted" && event.target === job.id && event.actor === "agent" - ); - expect(audit).toBeDefined(); - expect(audit?.evidence?.jobId).toBe(job.id); - expect(audit?.evidence?.name).toBe("to-delete"); - // The audit row pins the prior schedule shape so the deleted job is - // reconstructable from the log alone. - const prev = audit?.evidence?.previousSchedule as Record | undefined; - expect(prev?.cronExpression).toBe("0 9 * * *"); - expect(prev?.cronTimezone).toBe("UTC"); - }); - - test("delete_job dispatch rejects missing jobId", async () => { - const config = testConfig("jobs-delete-tool-bad-1"); - const taskId = await mutateState(config.instance, (state) => { - const task = createTask(state.instance, "test", undefined, undefined, undefined, undefined); - upsertTask(state, task); - return task.id; - }); - await expect( - dispatchToolCall( - config, - taskId, - "delete_job", - "call_delete_bad", - JSON.stringify({}) - ) - ).rejects.toThrow(/jobId/); - }); - - test("delete_job dispatch rejects unknown jobId", async () => { - const config = testConfig("jobs-delete-tool-missing"); - const taskId = await mutateState(config.instance, (state) => { - const task = createTask(state.instance, "test", undefined, undefined, undefined, undefined); - upsertTask(state, task); - return task.id; - }); - await expect( - dispatchToolCall( - config, - taskId, - "delete_job", - "call_delete_unknown", - JSON.stringify({ jobId: "job_nope" }) - ) - ).rejects.toThrow(/Job not found/); - }); - - test("update_job dispatch refuses to mutate when parent task is terminal", async () => { - // Defense-in-depth: when the parent task has gone terminal between - // the chat-task per-tool guard and dispatch, update_job must skip - // the mutation entirely so a cancelled task can't leak a patched - // job past the cancellation. Pre-check returns an Error string; the - // JobRecord stays untouched. - const config = testConfig("jobs-update-tool-terminal"); - const handler = createHandler(config); - const job = await call(handler, config, "/api/jobs", { - method: "POST", - body: JSON.stringify({ name: "to-keep", script: "true", intervalSeconds: 60 }) - }); - - const taskId = await mutateState(config.instance, (state) => { - const task = createTask(state.instance, "test", undefined, undefined, undefined, undefined); - task.status = "cancelled"; - upsertTask(state, task); - return task.id; - }); - - const result = await dispatchToolCall( - config, - taskId, - "update_job", - "call_update_terminal", - JSON.stringify({ jobId: job.id, name: "should-not-apply" }) - ); - expect(result.kind).toBe("sync"); - if (result.kind === "sync") { - expect(result.result).toMatch(/Error: update_job skipped/); - } - // JobRecord is unchanged — name still "to-keep". - const after = readState(config.instance).jobs.find((j) => j.id === job.id); - expect(after?.name).toBe("to-keep"); - }); - - test("updateJob oneShot patch applies when parent task is completed", async () => { - // Consistency invariant: a `completed` parent task may still manage - // jobs (schedule a follow-up, flip a finished one-shot back to - // recurring, etc.). The shared job mutators in src/jobs/index.ts - // (createScheduledJob, updateJob, updateJobStatus, removeJob) - // permit `completed` and refuse only on `cancelled`/`failed`. The - // oneShot patch rides inside updateJob's single mutateState, so it is - // covered by the exact same predicate as every sibling field — no - // partial application (name landed, oneShot refused) is possible. - // - // The dispatcher's lock-free entry-level pre-check is stricter - // (it short-circuits all terminal statuses to avoid touching the - // lock for the common case), so this test verifies the invariant - // at the authoritative serialization point — the shared mutator — - // which is where the asymmetry would actually leak through under - // a race (parent transitions to `completed` between the pre-check - // and the serialized re-check). - const config = testConfig("jobs-update-tool-oneshot-completed-parent"); - const handler = createHandler(config); - const job = await call(handler, config, "/api/jobs", { - method: "POST", - body: JSON.stringify({ name: "flip-oneshot", prompt: "ping", intervalSeconds: 60 }) - }); - - const taskId = await mutateState(config.instance, (state) => { - const task = createTask(state.instance, "test", undefined, undefined, undefined, undefined); - task.status = "completed"; - upsertTask(state, task); - return task.id; - }); - - // The shared mutator path (updateJob) accepts a `completed` - // parent — this is what permits a completed task's final action - // to be a legitimate job patch. - await updateJob(config, job.id, { name: "renamed-from-completed-parent" }, taskId); - const afterName = readState(config.instance).jobs.find((j) => j.id === job.id); - expect(afterName?.name).toBe("renamed-from-completed-parent"); - - // The oneShot patch goes through the same production path (updateJob's - // dedicated parameter) and must apply under the same predicate. - await updateJob(config, job.id, {}, taskId, true); - const after = readState(config.instance).jobs.find((j) => j.id === job.id); - expect(after?.oneShot).toBe(true); - }); - - test("delete_job dispatch refuses to mutate when parent task is terminal", async () => { - // Same defense-in-depth as update_job: a cancelled parent task must - // not be able to delete a JobRecord through the agent tool path. - const config = testConfig("jobs-delete-tool-terminal"); - const handler = createHandler(config); - const job = await call(handler, config, "/api/jobs", { - method: "POST", - body: JSON.stringify({ name: "to-keep", script: "true", intervalSeconds: 60 }) - }); - - const taskId = await mutateState(config.instance, (state) => { - const task = createTask(state.instance, "test", undefined, undefined, undefined, undefined); - task.status = "cancelled"; - upsertTask(state, task); - return task.id; - }); - - const result = await dispatchToolCall( - config, - taskId, - "delete_job", - "call_delete_terminal", - JSON.stringify({ jobId: job.id }) - ); - expect(result.kind).toBe("sync"); - if (result.kind === "sync") { - expect(result.result).toMatch(/Error: delete_job skipped/); - } - // JobRecord is still present. - const after = readState(config.instance).jobs.find((j) => j.id === job.id); - expect(after).toBeDefined(); - }); - - test("run_job dispatch rejects missing jobId", async () => { - const config = testConfig("jobs-run-tool-bad-1"); - const taskId = await mutateState(config.instance, (state) => { - const task = createTask(state.instance, "test", undefined, undefined, undefined, undefined); - upsertTask(state, task); - return task.id; - }); - await expect( - dispatchToolCall( - config, - taskId, - "run_job", - "call_run_bad", - JSON.stringify({}) - ) - ).rejects.toThrow(/jobId/); - }); - - test("run_job dispatch rejects unknown jobId", async () => { - const config = testConfig("jobs-run-tool-missing"); - const taskId = await mutateState(config.instance, (state) => { - const task = createTask(state.instance, "test", undefined, undefined, undefined, undefined); - upsertTask(state, task); - return task.id; - }); - await expect( - dispatchToolCall( - config, - taskId, - "run_job", - "call_run_unknown", - JSON.stringify({ jobId: "job_nope" }) - ) - ).rejects.toThrow(/Job not found/); - }); - - test("run_job dispatch refuses to mutate when parent task is terminal", async () => { - // Same defense-in-depth as update_job / delete_job: a cancelled - // parent task must not be able to fire a fresh job run through the - // agent tool path. The tool handler does a lock-free pre-check and - // `runJobNow` re-checks inside its serialized `mutateState` block; this - // test exercises the pre-check path. - const config = testConfig("jobs-run-tool-terminal"); - const handler = createHandler(config); - const job = await call(handler, config, "/api/jobs", { - method: "POST", - body: JSON.stringify({ name: "to-fire", script: "true", intervalSeconds: 60 }) - }); - - const taskId = await mutateState(config.instance, (state) => { - const task = createTask(state.instance, "test", undefined, undefined, undefined, undefined); - task.status = "cancelled"; - upsertTask(state, task); - return task.id; - }); - - const result = await dispatchToolCall( - config, - taskId, - "run_job", - "call_run_terminal", - JSON.stringify({ jobId: job.id }) - ); - expect(result.kind).toBe("sync"); - if (result.kind === "sync") { - expect(result.result).toMatch(/Error: run_job skipped/); - } - // No new JobRunRecord was created. - const runs = readState(config.instance).jobRuns.filter((r) => r.jobId === job.id); - expect(runs).toHaveLength(0); - }); - - test("run_job dispatch fires a prompt job, spawns a task, and writes job.run.manual audit", async () => { - const config = testConfig("jobs-run-tool-happy"); - const handler = createHandler(config); - const job = await call(handler, config, "/api/jobs", { - method: "POST", - body: JSON.stringify({ - name: "manual-fire", - prompt: "ping", - intervalSeconds: 3600 - }) - }); - - const taskId = await mutateState(config.instance, (state) => { - const task = createTask(state.instance, "test", undefined, undefined, undefined, undefined); - upsertTask(state, task); - return task.id; - }); - - const result = await dispatchToolCall( - config, - taskId, - "run_job", - "call_run_happy", - JSON.stringify({ jobId: job.id }) - ); - expect(result.kind).toBe("sync"); - if (result.kind === "sync") { - expect(result.result).toContain(job.id); - expect(result.result).toContain("manual-fire"); - expect(result.result).toMatch(/run /); - expect(result.result).toMatch(/task /); - } - - // A new JobRunRecord exists with a spawned task linked. - const runs = readState(config.instance).jobRuns.filter((r) => r.jobId === job.id); - expect(runs).toHaveLength(1); - expect(runs[0]?.trigger).toBe("manual"); - expect(runs[0]?.taskId).toBeDefined(); - - // The audit row uses action "job.run.manual" and points at the - // spawned task + new run id. - const audit = readState(config.instance).audit.find( - (event) => event.action === "job.run.manual" && event.target === job.id && event.actor === "agent" - ); - expect(audit).toBeDefined(); - expect(audit?.evidence?.jobId).toBe(job.id); - expect(audit?.evidence?.runId).toBe(runs[0]?.id); - expect(audit?.evidence?.spawnedTaskId).toBe(runs[0]?.taskId); - }); - - test("run_job dispatch reports script-job success with exit 0", async () => { - // Script-backed jobs execute synchronously inside `runJobNow`, so by - // the time the tool returns the run is already complete. The handler - // must report the exit code (not "Triggered ...") and the audit row - // must pin exitCode for postmortems. - const config = testConfig("jobs-run-tool-script-ok"); - const handler = createHandler(config); - const job = await call(handler, config, "/api/jobs", { - method: "POST", - body: JSON.stringify({ name: "script-ok", script: "true", intervalSeconds: 3600 }) - }); - - const taskId = await mutateState(config.instance, (state) => { - const task = createTask(state.instance, "test", undefined, undefined, undefined, undefined); - upsertTask(state, task); - return task.id; - }); - - const result = await dispatchToolCall( - config, - taskId, - "run_job", - "call_run_script_ok", - JSON.stringify({ jobId: job.id }) - ); - expect(result.kind).toBe("sync"); - if (result.kind === "sync") { - expect(result.result).toContain(job.id); - expect(result.result).toContain("script-ok"); - expect(result.result).toMatch(/completed/); - expect(result.result).toMatch(/exit 0/); - } - - const runs = readState(config.instance).jobRuns.filter((r) => r.jobId === job.id); - expect(runs).toHaveLength(1); - expect(runs[0]?.trigger).toBe("manual"); - // Script jobs don't spawn a task. - expect(runs[0]?.taskId).toBeUndefined(); - - const audit = readState(config.instance).audit.find( - (event) => event.action === "job.run.manual" && event.target === job.id && event.actor === "agent" - ); - expect(audit).toBeDefined(); - expect(audit?.evidence?.jobId).toBe(job.id); - expect(audit?.evidence?.runId).toBe(runs[0]?.id); - expect(audit?.evidence?.exitCode).toBe(0); - }); - - test("run_job dispatch reports script-job failure with non-zero exit", async () => { - // Failure path: tool return string must say "failed", surface the - // non-zero exit, and the audit row must capture exitCode so - // postmortems don't have to cross-reference the JobRun record. - const config = testConfig("jobs-run-tool-script-fail"); - const handler = createHandler(config); - const job = await call(handler, config, "/api/jobs", { - method: "POST", - body: JSON.stringify({ name: "script-fail", script: "exit 1", intervalSeconds: 3600 }) - }); - - const taskId = await mutateState(config.instance, (state) => { - const task = createTask(state.instance, "test", undefined, undefined, undefined, undefined); - upsertTask(state, task); - return task.id; - }); - - const result = await dispatchToolCall( - config, - taskId, - "run_job", - "call_run_script_fail", - JSON.stringify({ jobId: job.id }) - ); - expect(result.kind).toBe("sync"); - if (result.kind === "sync") { - expect(result.result).toContain(job.id); - expect(result.result).toContain("script-fail"); - expect(result.result).toMatch(/failed/); - expect(result.result).toMatch(/exit 1/); - } - - const runs = readState(config.instance).jobRuns.filter((r) => r.jobId === job.id); - expect(runs).toHaveLength(1); - expect(runs[0]?.trigger).toBe("manual"); - expect(runs[0]?.status).toBe("failed"); - - const audit = readState(config.instance).audit.find( - (event) => event.action === "job.run.manual" && event.target === job.id && event.actor === "agent" - ); - expect(audit).toBeDefined(); - expect(audit?.evidence?.jobId).toBe(job.id); - expect(audit?.evidence?.runId).toBe(runs[0]?.id); - expect(audit?.evidence?.exitCode).toBe(1); - }); - - test("scheduled prompt job with chatSessionId delivers an assistant chat message", async () => { - // End-to-end test: create a job linked to a chat session, force its - // nextRunAt into the past, let runDueJobs claim + dispatch it, wait - // for the task to settle, then assert that finalizeJobRunFromTask - // produced a ChatMessageRecord with role="assistant" in that session. - const config = testConfig("jobs-chat-delivery"); - const handler = createHandler(config); - - const session = await call(handler, config, "/api/chat", { - method: "POST", - body: JSON.stringify({ title: "delivery test" }) - }); - - const job = await call(handler, config, "/api/jobs", { - method: "POST", - body: JSON.stringify({ - name: "delivery-job", - prompt: "echo ping", - intervalSeconds: 60, - chatSessionId: session.id, - oneShot: true - }) - }); - expect(job.chatSessionId).toBe(session.id); - expect(job.oneShot).toBe(true); - - // Force the job due so runDueJobs claims it on the next tick. - await mutateState(config.instance, (state) => { - const item = state.jobs.find((candidate) => candidate.id === job.id); - if (!item) throw new Error("setup: job missing"); - item.nextRunAt = new Date(Date.now() - 1_000).toISOString(); - }); - - await runDueJobs(config); - - // Diagnostic: confirm runDueJobs claimed the job and spawned a task. - const afterClaim = readState(config.instance); - const claimedRun = afterClaim.jobRuns.find((run) => run.jobId === job.id); - expect(claimedRun).toBeDefined(); - expect(claimedRun?.taskId).toBeString(); - const spawnedTask = afterClaim.tasks.find((t) => t.id === claimedRun?.taskId); - expect(spawnedTask?.mode).toBe("chat"); - - // Wait for the spawned task to settle, then for the assistant message - // to appear in the session (finalize is async). - await waitFor(() => { - const state = readState(config.instance); - const jobRun = state.jobRuns.find((run) => run.jobId === job.id); - return jobRun?.status === "completed" || jobRun?.status === "failed"; - }, 5_000); - - await waitFor(() => { - const state = readState(config.instance); - return state.chatMessages.some( - (m) => m.sessionId === session.id && m.role === "assistant" - ); - }, 5_000); - - const stateAfter = readState(config.instance); - const assistantMessages = stateAfter.chatMessages.filter( - (m) => m.sessionId === session.id && m.role === "assistant" - ); - expect(assistantMessages.length).toBeGreaterThanOrEqual(1); - - // The job should be paused now because oneShot=true. - const finalJob = stateAfter.jobs.find((candidate) => candidate.id === job.id); - expect(finalJob?.status).toBe("paused"); - - // Audit row for the one-shot completion. - const audit = stateAfter.audit.find( - (event) => event.action === "job.oneshot.completed" && event.target === job.id - ); - expect(audit).toBeDefined(); - }); - - test("a forwardToChat job materializes its answer in its Topic AND forwards a tagged block into the owning agent's Chat", async () => { - // End-to-end: a job that runs in its own Topic and forwards each fire's - // final answer into the owning agent's Chat (ADR - // chat-topics-tasks-subagents.md, "Jobs → Topics"). - const { getOrCreateAgentChat } = await import("./execution/chat"); - const { listChatBlocks, createChatSession } = await import("./state"); - const config = testConfig("jobs-forward-to-chat"); - - // Resolve the owning agent + its Chat, then mint a dedicated Topic for the - // job and a forwardToChat job bound to it. - const agentId = readState(config.instance).agents[0]!.id; - const chat = await getOrCreateAgentChat(config.instance, agentId); - const topicId = await mutateState(config.instance, (state) => { - const topic = createChatSession(state, "ping-watch", undefined, agentId, "job", "channel"); - return topic.id; - }); - const job = await createScheduledJob(config, { - name: "ping-watch", - prompt: "echo ping", - intervalSeconds: 60, - chatSessionId: topicId, - forwardToChat: true, - oneShot: true - }, { originatingAgentId: agentId }); - expect(job.forwardToChat).toBe(true); - - await mutateState(config.instance, (state) => { - const item = state.jobs.find((candidate) => candidate.id === job.id); - if (!item) throw new Error("setup: job missing"); - item.nextRunAt = new Date(Date.now() - 1_000).toISOString(); - }); - - await runDueJobs(config); - - await waitFor(() => { - const jobRun = readState(config.instance).jobRuns.find((run) => run.jobId === job.id); - return jobRun?.status === "completed" || jobRun?.status === "failed"; - }, 5_000); - - // The replay-authoritative answer landed in the Topic. - await waitFor( - () => readState(config.instance).chatMessages.some((m) => m.sessionId === topicId && m.role === "assistant"), - 5_000 - ); - - // …and a forwarded render-only block landed in the owning agent's Chat, - // tagged with the Topic for the deep-link chip. - await waitFor(() => { - return listChatBlocks(config.instance, chat.id).some( - (b) => b.kind === "assistant_text" && b.forwardedFromTopicId === topicId - ); - }, 5_000); - - const forwarded = listChatBlocks(config.instance, chat.id).find( - (b) => b.kind === "assistant_text" && b.forwardedFromTopicId === topicId - ); - expect(forwarded).toBeDefined(); - if (forwarded?.kind === "assistant_text") { - expect(forwarded.forwardedFromTopicTitle).toBe("ping-watch"); - expect(forwarded.text.trim().length).toBeGreaterThan(0); - } - }); - - test("a channel-only job forwards NOTHING into the owning agent's Chat", async () => { - const { getOrCreateAgentChat } = await import("./execution/chat"); - const { listChatBlocks, createChatSession } = await import("./state"); - const config = testConfig("jobs-channel-only-no-forward"); - - const agentId = readState(config.instance).agents[0]!.id; - const chat = await getOrCreateAgentChat(config.instance, agentId); - const topicId = await mutateState(config.instance, (state) => { - const topic = createChatSession(state, "report", undefined, agentId, "job", "channel"); - return topic.id; - }); - // No forwardToChat: the answer stays in the Topic only. - const job = await createScheduledJob(config, { - name: "report", - prompt: "echo ping", - intervalSeconds: 60, - chatSessionId: topicId, - oneShot: true - }, { originatingAgentId: agentId }); - expect(job.forwardToChat ?? false).toBe(false); - - await mutateState(config.instance, (state) => { - const item = state.jobs.find((candidate) => candidate.id === job.id); - if (!item) throw new Error("setup: job missing"); - item.nextRunAt = new Date(Date.now() - 1_000).toISOString(); - }); - - await runDueJobs(config); - - // The answer materializes in the Topic. - await waitFor(() => { - const jobRun = readState(config.instance).jobRuns.find((run) => run.jobId === job.id); - return jobRun?.status === "completed" || jobRun?.status === "failed"; - }, 5_000); - await waitFor( - () => readState(config.instance).chatMessages.some((m) => m.sessionId === topicId && m.role === "assistant"), - 5_000 - ); - - // Nothing forwarded into the agent's Chat. - expect(listChatBlocks(config.instance, chat.id).filter((b) => b.kind === "assistant_text")).toHaveLength(0); - }); - - test("syncChatTaskResult suppresses delivery when the task summary is [SILENT]", async () => { - // The cron-execution hint instructs the LLM to emit "[SILENT]" when a - // scheduled run has nothing new to report. syncChatTaskResult must - // recognize the sentinel, skip creating an assistant ChatMessageRecord, - // and audit the suppression. - const config = testConfig("jobs-silent-suppress"); - - const sessionId = "session_silent_test"; - const taskId = await mutateState(config.instance, (state) => { - state.chatSessions.unshift({ - id: sessionId, - instance: state.instance, - title: "Silent test", - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - messageIds: [], - taskIds: [], - runIds: [] - }); - const task = createTask(state.instance, "watch for change", undefined, undefined, undefined, undefined); - task.status = "completed"; - task.summary = "[SILENT]"; - task.updatedAt = new Date().toISOString(); - upsertTask(state, task); - return task.id; - }); - - const result = await syncChatTaskResult(config, sessionId, taskId); - expect(result).toBeNull(); - - const stateAfter = readState(config.instance); - const assistantMessages = stateAfter.chatMessages.filter( - (m) => m.sessionId === sessionId && m.role === "assistant" - ); - expect(assistantMessages).toHaveLength(0); - - const audit = stateAfter.audit.find( - (event) => event.action === "chat.message.suppressed_silent" && event.target === sessionId - ); - expect(audit).toBeDefined(); - expect(audit?.taskId).toBe(taskId); - }); - - test("syncChatTaskResult delivers when summary contains [SILENT] alongside other text", async () => { - // The sentinel must match exactly. A summary like "[SILENT] but also..." - // or a lowercase variant should NOT be suppressed — otherwise a reminder - // that happens to mention the word could be silently dropped. - const config = testConfig("jobs-silent-not-exact"); - - const sessionId = "session_silent_strict"; - const taskId = await mutateState(config.instance, (state) => { - state.chatSessions.unshift({ - id: sessionId, - instance: state.instance, - title: "Strict", - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - messageIds: [], - taskIds: [], - runIds: [] - }); - const task = createTask(state.instance, "watch", undefined, undefined, undefined, undefined); - task.status = "completed"; - task.summary = "[SILENT] with extra"; - task.updatedAt = new Date().toISOString(); - upsertTask(state, task); - return task.id; - }); - - const result = await syncChatTaskResult(config, sessionId, taskId); - expect(result).not.toBeNull(); - - const stateAfter = readState(config.instance); - const assistantMessages = stateAfter.chatMessages.filter( - (m) => m.sessionId === sessionId && m.role === "assistant" - ); - expect(assistantMessages).toHaveLength(1); - expect(assistantMessages[0]?.content).toBe("[SILENT] with extra"); - }); - - test("dispatchJobReplyToBridge suppresses ONLY exact '[SILENT]'; any prefix-only match still dispatches", async () => { - // Mirror invariant of the syncChatTaskResult test above, but for - // the bridge dispatch path. Earlier code used `startsWith` here - // which would have silently dropped a legitimate reply like - // "[SILENT] but here's an update" while syncChatTaskResult - // (correctly) delivered it to chat — meaning a scheduled job - // would land in chat UI but never reach Telegram/Discord. - const config = testConfig("jobs-silent-dispatch-strict"); - const { addMessagingBridge, setMessagingDeps, resetMessagingDeps } = await import("./integrations/messaging"); - const { findOrCreateDiscordChatSession } = await import("./state"); - const { finalizeJobRunFromTask } = await import("./jobs/finalize"); - const sendCalls: Array<{ channelId: string; content: string }> = []; - setMessagingDeps({ - discordClientFactory: () => ({ - async getMe() { - return { id: "100", username: "Gini", discriminator: "0000", bot: true }; - }, - async sendMessage(channelId, content) { - sendCalls.push({ channelId, content }); - return { id: "reply", channel_id: channelId, content, timestamp: "", author: { id: "100", username: "Gini", bot: true } }; - }, - async triggerTypingIndicator() { - return true as const; - }, - async fetchChannelMessages() { - return []; - } - }) - }); - - try { - const bridge = await addMessagingBridge(config, { - name: "disc", - kind: "discord", - deliveryTargets: ["chan-1"], - botToken: "TOK" - }); - const sessionId = await mutateState(config.instance, (state) => { - const session = findOrCreateDiscordChatSession(state, bridge.id, "chan-1"); - return session.id; - }); - - // "[SILENT] but here's an update" — must NOT be suppressed. - const taskA = await mutateState(config.instance, (state) => { - const t = createTask(state.instance, "scheduled", undefined, undefined, undefined, undefined); - t.status = "completed"; - t.summary = "[SILENT] but here's an update"; - t.jobId = "job_x"; - upsertTask(state, t); - const session = state.chatSessions.find((s) => s.id === sessionId)!; - session.taskIds.push(t.id); - state.jobs.push({ - id: "job_x", - instance: state.instance, - name: "x", - status: "active", - prompt: "p", - deliveryTargets: [], - context: [], - retryLimit: 0, - timeoutSeconds: 600, - chatSessionId: sessionId, - runIds: [], - taskIds: [], - runCount: 0, - missedRuns: 0, - nextRunAt: new Date().toISOString(), - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString() - }); - state.jobRuns.push({ - id: "run_x", - instance: state.instance, - jobId: "job_x", - status: "running", - taskId: t.id, - attempt: 1, - trigger: "schedule", - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString() - }); - return t.id; - }); - const taskAObj = readState(config.instance).tasks.find((t) => t.id === taskA)!; - await finalizeJobRunFromTask(config, taskAObj); - expect(sendCalls.length).toBe(1); - expect(sendCalls[0]?.content).toContain("but here's an update"); - - // Exact "[SILENT]" — must be suppressed. - sendCalls.length = 0; - const taskB = await mutateState(config.instance, (state) => { - const t = createTask(state.instance, "scheduled-silent", undefined, undefined, undefined, undefined); - t.status = "completed"; - t.summary = "[SILENT]"; - t.jobId = "job_x"; - upsertTask(state, t); - const session = state.chatSessions.find((s) => s.id === sessionId)!; - session.taskIds.push(t.id); - state.jobRuns.push({ - id: "run_y", - instance: state.instance, - jobId: "job_x", - status: "running", - taskId: t.id, - attempt: 1, - trigger: "schedule", - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString() - }); - return t.id; - }); - const taskBObj = readState(config.instance).tasks.find((t) => t.id === taskB)!; - await finalizeJobRunFromTask(config, taskBObj); - expect(sendCalls.length).toBe(0); - } finally { - resetMessagingDeps(); - } - }); - - test("dispatchJobReplyToBridge ignores tool_transcript rows so a [SILENT] tool-using job stays suppressed", async () => { - // A tool-using turn persists assistant rows tagged kind:"tool_transcript" - // (model-facing replay narration) before the terminal summary. The bridge - // dispatch picks the newest assistant row for the task; if it considered - // the transcript row it would mirror that narration to Telegram/Discord - // even though the terminal summary is "[SILENT]" — bypassing suppression. - const config = testConfig("jobs-silent-tool-transcript"); - const { addMessagingBridge, setMessagingDeps, resetMessagingDeps } = await import("./integrations/messaging"); - const { findOrCreateDiscordChatSession } = await import("./state"); - const { finalizeJobRunFromTask } = await import("./jobs/finalize"); - const sendCalls: Array<{ channelId: string; content: string }> = []; - setMessagingDeps({ - discordClientFactory: () => ({ - async getMe() { - return { id: "100", username: "Gini", discriminator: "0000", bot: true }; - }, - async sendMessage(channelId, content) { - sendCalls.push({ channelId, content }); - return { id: "reply", channel_id: channelId, content, timestamp: "", author: { id: "100", username: "Gini", bot: true } }; - }, - async triggerTypingIndicator() { - return true as const; - }, - async fetchChannelMessages() { - return []; - } - }) - }); - - try { - const bridge = await addMessagingBridge(config, { - name: "disc", - kind: "discord", - deliveryTargets: ["chan-1"], - botToken: "TOK" - }); - const sessionId = await mutateState(config.instance, (state) => { - const session = findOrCreateDiscordChatSession(state, bridge.id, "chan-1"); - return session.id; - }); - - const taskId = await mutateState(config.instance, (state) => { - const t = createTask(state.instance, "scheduled-tool", undefined, undefined, undefined, undefined); - t.status = "completed"; - t.summary = "[SILENT]"; - t.jobId = "job_tool"; - upsertTask(state, t); - const session = state.chatSessions.find((s) => s.id === sessionId)!; - session.taskIds.push(t.id); - // Seed a model-facing transcript assistant row with non-empty - // narration for the same task — this must never be mirrored. - createChatMessage(state, { - sessionId, - role: "assistant", - content: "Let me check the calendar before replying.", - taskId: t.id, - runId: t.runId, - kind: "tool_transcript" - }); - state.jobs.push({ - id: "job_tool", - instance: state.instance, - name: "x", - status: "active", - prompt: "p", - deliveryTargets: [], - context: [], - retryLimit: 0, - timeoutSeconds: 600, - chatSessionId: sessionId, - runIds: [], - taskIds: [], - runCount: 0, - missedRuns: 0, - nextRunAt: new Date().toISOString(), - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString() - }); - state.jobRuns.push({ - id: "run_tool", - instance: state.instance, - jobId: "job_tool", - status: "running", - taskId: t.id, - attempt: 1, - trigger: "schedule", - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString() - }); - return t.id; - }); - - const taskObj = readState(config.instance).tasks.find((t) => t.id === taskId)!; - await finalizeJobRunFromTask(config, taskObj); - expect(sendCalls.length).toBe(0); - } finally { - resetMessagingDeps(); - } - }); }); // update_job deliverTo: rebinding a job's delivery after creation @@ -3802,7 +1910,7 @@ async function rawCall(handler: ReturnType, config: Runtim } function testConfig(instance: string): RuntimeConfig { - const root = "/tmp/gini-jobs-tests"; + const root = `/tmp/gini-jobs-tests-${import.meta.file}`; process.env.GINI_STATE_ROOT = root; process.env.GINI_LOG_ROOT = `${root}-logs`; rmSync(`${root}/instances/${instance}`, { recursive: true, force: true }); diff --git a/packages/runtime/src/jobs.part2.test.ts b/packages/runtime/src/jobs.part2.test.ts new file mode 100644 index 00000000..2a83fcb9 --- /dev/null +++ b/packages/runtime/src/jobs.part2.test.ts @@ -0,0 +1,1974 @@ +// Cron lifecycle tests. Pairs with src/http.test.ts (kept untouched) — same +// helpers, separate file to keep concerns siloed. +// +// What these cover (Plan B from the cron-hardening context): +// - paused jobs are not picked up by the scheduler tick +// - drift-free nextRunAt advance + missedRuns increment +// - overlap protection: a second scheduled run is skipped while the first +// is still in-flight +// - prompt-job runs finalize asynchronously when the spawned task settles +// - manual run does not implicitly resume a paused job +// - removeJob cascade-deletes the JobRunRecords +// - replay against a removed job returns 404 +// - intervalSeconds validation surfaces 400 + +import { describe, expect, test } from "bun:test"; +import { readFileSync, rmSync } from "node:fs"; +import "./hooks/builtins"; // populates the registry so create_job's preRunHook resolves isKnownHook("skill-script") +import { createHandler } from "./http"; +import { removeJob, runDueJobs, runJobNow } from "./jobs"; +import { advanceCronNextRunAt, createScheduledJob, rebindJobDelivery, updateJob } from "./jobs/index"; +import { createChatMessage, createTask, mutateState, readState, upsertTask } from "./state"; +import { dispatchToolCall } from "./execution/tool-dispatch"; +import { syncChatTaskResult } from "./execution/chat"; +import type { RuntimeConfig } from "./types"; + + +describe("cron lifecycle", () => { + test("create_job dispatch rejects non-string entries in autoApproveCommands", async () => { + const config = testConfig("jobs-create-tool-validate-2"); + const taskId = await mutateState(config.instance, (state) => { + const task = createTask(state.instance, "test", undefined, undefined, undefined, undefined); + upsertTask(state, task); + return task.id; + }); + + await expect( + dispatchToolCall( + config, + taskId, + "create_job", + "call_bad_entry", + JSON.stringify({ name: "bad", intervalSeconds: 60, prompt: "x", autoApproveCommands: ["ok", 7] }) + ) + ).rejects.toThrow(/autoApproveCommands entries must be strings/); + expect(readState(config.instance).jobs).toHaveLength(0); + }); + + test("create_job dispatch accepts approvalMode and persists it", async () => { + const config = testConfig("jobs-create-tool-approval-mode"); + const taskId = await mutateState(config.instance, (state) => { + const task = createTask(state.instance, "test", undefined, undefined, undefined, undefined); + upsertTask(state, task); + return task.id; + }); + + await dispatchToolCall( + config, + taskId, + "create_job", + "call_mode", + JSON.stringify({ + name: "mode-job", + intervalSeconds: 60, + prompt: "x", + approvalMode: "yolo" + }) + ); + + const jobs = readState(config.instance).jobs; + expect(jobs).toHaveLength(1); + expect(jobs[0]?.approvalMode).toBe("yolo"); + const audit = readState(config.instance).audit.find( + (event) => event.action === "job.created" && event.target === jobs[0]!.id + ); + expect(audit?.evidence?.approvalMode).toBe("yolo"); + }); + + test("create_job dispatch rejects invalid approvalMode value", async () => { + const config = testConfig("jobs-create-tool-bad-mode"); + const taskId = await mutateState(config.instance, (state) => { + const task = createTask(state.instance, "test", undefined, undefined, undefined, undefined); + upsertTask(state, task); + return task.id; + }); + + await expect( + dispatchToolCall( + config, + taskId, + "create_job", + "call_bad_mode", + JSON.stringify({ name: "bad", intervalSeconds: 60, prompt: "x", approvalMode: "loose" }) + ) + ).rejects.toThrow(/approvalMode must be one of/); + expect(readState(config.instance).jobs).toHaveLength(0); + }); + + test("create_job dispatch accepts both approvalMode and legacy dangerouslyAutoApprove (alias)", async () => { + // Both fields are accepted on the same payload. approvalMode is + // the canonical signal; the legacy flag is preserved on the + // JobRecord as a deprecated alias. + const config = testConfig("jobs-create-tool-both-fields"); + const taskId = await mutateState(config.instance, (state) => { + const task = createTask(state.instance, "test", undefined, undefined, undefined, undefined); + upsertTask(state, task); + return task.id; + }); + + await dispatchToolCall( + config, + taskId, + "create_job", + "call_both", + JSON.stringify({ + name: "both-fields", + intervalSeconds: 60, + prompt: "x", + approvalMode: "yolo", + dangerouslyAutoApprove: true + }) + ); + + const jobs = readState(config.instance).jobs; + expect(jobs).toHaveLength(1); + expect(jobs[0]?.approvalMode).toBe("yolo"); + expect(jobs[0]?.dangerouslyAutoApprove).toBe(true); + }); + + test("create_job dispatch accepts and persists preRunHook", async () => { + // The tool passes preRunHook through to createScheduledJob unvalidated — + // the same seam the HTTP /api/jobs route uses — so the persisted + // JobRecord must carry the validated hook verbatim, and the audit row + // must pin the handler id. + const config = testConfig("jobs-create-tool-prerunhook"); + const taskId = await mutateState(config.instance, (state) => { + const task = createTask(state.instance, "test", undefined, undefined, undefined, undefined); + upsertTask(state, task); + return task.id; + }); + + const result = await dispatchToolCall( + config, + taskId, + "create_job", + "call_create_job_hook", + JSON.stringify({ + name: "call-watch c-123", + intervalSeconds: 30, + prompt: "report the call result", + timeoutSeconds: 120, + preRunHook: { + handlerId: "skill-script", + config: { skill: "phone-call", script: "call-watch", callId: "c-123" }, + timeoutMs: 25000 + } + }) + ); + expect(result.kind).toBe("sync"); + + const jobs = readState(config.instance).jobs; + expect(jobs).toHaveLength(1); + expect(jobs[0]?.preRunHook).toEqual({ + handlerId: "skill-script", + config: { skill: "phone-call", script: "call-watch", callId: "c-123" }, + timeoutMs: 25000 + }); + const audit = readState(config.instance).audit.find( + (event) => event.action === "job.created" && event.target === jobs[0]!.id + ); + expect(audit?.evidence?.preRunHookHandlerId).toBe("skill-script"); + }); + + test("create_job dispatch rejects an unknown preRunHook handlerId", async () => { + // The registry gate in createScheduledJob is the security boundary: an + // unregistered handlerId must be rejected at create time and persist + // nothing. + const config = testConfig("jobs-create-tool-prerunhook-bad"); + const taskId = await mutateState(config.instance, (state) => { + const task = createTask(state.instance, "test", undefined, undefined, undefined, undefined); + upsertTask(state, task); + return task.id; + }); + + await expect( + dispatchToolCall( + config, + taskId, + "create_job", + "call_create_job_hook_bad", + JSON.stringify({ + name: "bad-hook", + intervalSeconds: 60, + prompt: "x", + preRunHook: { handlerId: "not-a-handler", config: {} } + }) + ) + ).rejects.toThrow(/not a known hook handler/); + expect(readState(config.instance).jobs).toHaveLength(0); + }); + + test("create_job dispatch rejects a null preRunHook config", async () => { + // `typeof null === "object"`, so a bare typeof check would let + // config: null through to a hook that can never resolve its payload. + const config = testConfig("jobs-create-tool-prerunhook-null-config"); + const taskId = await mutateState(config.instance, (state) => { + const task = createTask(state.instance, "test", undefined, undefined, undefined, undefined); + upsertTask(state, task); + return task.id; + }); + + await expect( + dispatchToolCall( + config, + taskId, + "create_job", + "call_create_job_hook_null_config", + JSON.stringify({ + name: "null-hook-config", + intervalSeconds: 60, + prompt: "x", + preRunHook: { handlerId: "skill-script", config: null } + }) + ) + ).rejects.toThrow(/preRunHook\.config must be an object/); + expect(readState(config.instance).jobs).toHaveLength(0); + }); + + test("create_job dispatch persists cronExpression + cronTimezone", async () => { + // Happy-path cron creation through the tool dispatch surface. The + // agent should be able to schedule a wall-clock job by name + + // expression + tz, and the resulting JobRecord must carry both fields + // verbatim (plus intervalSeconds=0 as the "not interval-driven" + // sentinel). + const config = testConfig("jobs-create-tool-cron"); + const taskId = await mutateState(config.instance, (state) => { + const task = createTask(state.instance, "test", undefined, undefined, undefined, undefined); + upsertTask(state, task); + return task.id; + }); + + const result = await dispatchToolCall( + config, + taskId, + "create_job", + "call_create_job_cron", + JSON.stringify({ + name: "daily-9am", + prompt: "morning report", + cronExpression: "0 9 * * *", + cronTimezone: "America/Los_Angeles" + }) + ); + expect(result.kind).toBe("sync"); + + const jobs = readState(config.instance).jobs; + expect(jobs).toHaveLength(1); + expect(jobs[0]?.cronExpression).toBe("0 9 * * *"); + expect(jobs[0]?.cronTimezone).toBe("America/Los_Angeles"); + // Cron-driven jobs carry no intervalSeconds (field is optional). + expect(jobs[0]?.intervalSeconds).toBeUndefined(); + + // Audit + return-message both reflect the cron cadence so a reviewer + // and the agent's follow-up reply describe the schedule correctly. + const audit = readState(config.instance).audit.find( + (event) => event.action === "job.created" && event.target === jobs[0]!.id + ); + expect(audit?.evidence?.cronExpression).toBe("0 9 * * *"); + expect(audit?.evidence?.cronTimezone).toBe("America/Los_Angeles"); + if (result.kind === "sync") { + expect(result.result).toContain("cron"); + expect(result.result).toContain("America/Los_Angeles"); + } + }); + + test("create_job dispatch rejects both intervalSeconds and cronExpression set", async () => { + const config = testConfig("jobs-create-tool-mutex"); + const taskId = await mutateState(config.instance, (state) => { + const task = createTask(state.instance, "test", undefined, undefined, undefined, undefined); + upsertTask(state, task); + return task.id; + }); + + await expect( + dispatchToolCall( + config, + taskId, + "create_job", + "call_both", + JSON.stringify({ + name: "both", + prompt: "x", + intervalSeconds: 60, + cronExpression: "0 9 * * *" + }) + ) + ).rejects.toThrow(/mutually exclusive/); + expect(readState(config.instance).jobs).toHaveLength(0); + }); + + test("create_job dispatch rejects when neither intervalSeconds nor cronExpression is set", async () => { + const config = testConfig("jobs-create-tool-neither"); + const taskId = await mutateState(config.instance, (state) => { + const task = createTask(state.instance, "test", undefined, undefined, undefined, undefined); + upsertTask(state, task); + return task.id; + }); + + await expect( + dispatchToolCall( + config, + taskId, + "create_job", + "call_neither", + JSON.stringify({ name: "neither", prompt: "x" }) + ) + ).rejects.toThrow(/requires either intervalSeconds or cronExpression/); + expect(readState(config.instance).jobs).toHaveLength(0); + }); + + test("create_job dispatch rejects malformed cronExpression", async () => { + const config = testConfig("jobs-create-tool-bad-cron"); + const taskId = await mutateState(config.instance, (state) => { + const task = createTask(state.instance, "test", undefined, undefined, undefined, undefined); + upsertTask(state, task); + return task.id; + }); + + await expect( + dispatchToolCall( + config, + taskId, + "create_job", + "call_bad_cron", + JSON.stringify({ name: "bad", prompt: "x", cronExpression: "foo bar baz qux quux" }) + ) + ).rejects.toThrow(/Invalid input: cronExpression/); + expect(readState(config.instance).jobs).toHaveLength(0); + }); + + test("create_job dispatch rejects non-integer timeoutSeconds", async () => { + const config = testConfig("jobs-create-tool-validate-3"); + const taskId = await mutateState(config.instance, (state) => { + const task = createTask(state.instance, "test", undefined, undefined, undefined, undefined); + upsertTask(state, task); + return task.id; + }); + + await expect( + dispatchToolCall( + config, + taskId, + "create_job", + "call_bad_timeout", + JSON.stringify({ name: "bad", intervalSeconds: 60, prompt: "x", timeoutSeconds: -5 }) + ) + ).rejects.toThrow(/timeoutSeconds must be a positive integer/); + expect(readState(config.instance).jobs).toHaveLength(0); + }); + + test("list_jobs dispatch returns a compact summary of all jobs", async () => { + const config = testConfig("jobs-list-tool"); + const handler = createHandler(config); + // Two jobs of mixed schedule shape so we can confirm both cron and + // interval drivers surface correctly in the summary. + await call(handler, config, "/api/jobs", { + method: "POST", + body: JSON.stringify({ name: "alpha-reminder", script: "true", intervalSeconds: 60 }) + }); + await call(handler, config, "/api/jobs", { + method: "POST", + body: JSON.stringify({ + name: "beta-daily", + script: "true", + cronExpression: "0 9 * * *", + cronTimezone: "America/Los_Angeles" + }) + }); + + const taskId = await mutateState(config.instance, (state) => { + const task = createTask(state.instance, "test", undefined, undefined, undefined, undefined); + upsertTask(state, task); + return task.id; + }); + + const result = await dispatchToolCall( + config, + taskId, + "list_jobs", + "call_list_1", + JSON.stringify({}) + ); + expect(result.kind).toBe("sync"); + if (result.kind !== "sync") throw new Error("expected sync result"); + const parsed = JSON.parse(result.result) as { count: number; jobs: Array> }; + expect(parsed.count).toBe(2); + const names = new Set(parsed.jobs.map((j) => j.name)); + expect(names.has("alpha-reminder")).toBe(true); + expect(names.has("beta-daily")).toBe(true); + const cronEntry = parsed.jobs.find((j) => j.name === "beta-daily"); + expect(cronEntry?.cronExpression).toBe("0 9 * * *"); + expect(cronEntry?.cronTimezone).toBe("America/Los_Angeles"); + + // The listing call writes an audit row so the log records when the + // agent pulled the job inventory. + const audit = readState(config.instance).audit.find( + (event) => event.action === "job.listed" + ); + expect(audit).toBeDefined(); + expect(audit?.evidence?.total).toBe(2); + expect(audit?.evidence?.returned).toBe(2); + }); + + test("list_jobs dispatch filters by nameContains (case-insensitive)", async () => { + const config = testConfig("jobs-list-tool-filter"); + const handler = createHandler(config); + await call(handler, config, "/api/jobs", { + method: "POST", + body: JSON.stringify({ name: "Daily Report", script: "true", intervalSeconds: 60 }) + }); + await call(handler, config, "/api/jobs", { + method: "POST", + body: JSON.stringify({ name: "cake-reminder", script: "true", intervalSeconds: 60 }) + }); + + const taskId = await mutateState(config.instance, (state) => { + const task = createTask(state.instance, "test", undefined, undefined, undefined, undefined); + upsertTask(state, task); + return task.id; + }); + + const result = await dispatchToolCall( + config, + taskId, + "list_jobs", + "call_list_filter", + JSON.stringify({ nameContains: "DAILY" }) + ); + if (result.kind !== "sync") throw new Error("expected sync result"); + const parsed = JSON.parse(result.result) as { count: number; jobs: Array> }; + expect(parsed.count).toBe(1); + expect(parsed.jobs[0]?.name).toBe("Daily Report"); + }); + + test("list_jobs dispatch rejects non-string nameContains", async () => { + const config = testConfig("jobs-list-tool-bad-filter"); + const taskId = await mutateState(config.instance, (state) => { + const task = createTask(state.instance, "test", undefined, undefined, undefined, undefined); + upsertTask(state, task); + return task.id; + }); + await expect( + dispatchToolCall( + config, + taskId, + "list_jobs", + "call_bad_filter", + JSON.stringify({ nameContains: 7 }) + ) + ).rejects.toThrow(/nameContains must be a string/); + }); + + test("list_jobs dispatch truncates long prompts to ~200 chars", async () => { + const config = testConfig("jobs-list-tool-truncate"); + const handler = createHandler(config); + const longPrompt = "x".repeat(500); + await call(handler, config, "/api/jobs", { + method: "POST", + body: JSON.stringify({ name: "long", prompt: longPrompt, intervalSeconds: 60 }) + }); + + const taskId = await mutateState(config.instance, (state) => { + const task = createTask(state.instance, "test", undefined, undefined, undefined, undefined); + upsertTask(state, task); + return task.id; + }); + const result = await dispatchToolCall( + config, + taskId, + "list_jobs", + "call_truncate", + JSON.stringify({}) + ); + if (result.kind !== "sync") throw new Error("expected sync result"); + const parsed = JSON.parse(result.result) as { jobs: Array<{ prompt: string }> }; + // Truncated form is 200 chars + ellipsis marker. + expect(parsed.jobs[0]?.prompt.length).toBeLessThan(longPrompt.length); + expect(parsed.jobs[0]?.prompt.endsWith("…")).toBe(true); + }); + + test("list_jobs dispatch returns verbatim prompts when fullPrompt is true", async () => { + // The agent needs the unstruncated prompt when it intends to edit it + // (append, search-and-replace), since update_job's prompt field is + // REPLACE-only. With `fullPrompt: true` the handler returns the + // entire stored prompt unchanged. + const config = testConfig("jobs-list-tool-full-prompt"); + const handler = createHandler(config); + const longPrompt = "y".repeat(300); + await call(handler, config, "/api/jobs", { + method: "POST", + body: JSON.stringify({ name: "verbatim", prompt: longPrompt, intervalSeconds: 60 }) + }); + + const taskId = await mutateState(config.instance, (state) => { + const task = createTask(state.instance, "test", undefined, undefined, undefined, undefined); + upsertTask(state, task); + return task.id; + }); + + const verbatim = await dispatchToolCall( + config, + taskId, + "list_jobs", + "call_full", + JSON.stringify({ fullPrompt: true }) + ); + if (verbatim.kind !== "sync") throw new Error("expected sync result"); + const verbatimParsed = JSON.parse(verbatim.result) as { jobs: Array<{ prompt: string }> }; + expect(verbatimParsed.jobs[0]?.prompt.length).toBe(longPrompt.length); + expect(verbatimParsed.jobs[0]?.prompt).toBe(longPrompt); + expect(verbatimParsed.jobs[0]?.prompt.endsWith("…")).toBe(false); + + // Same job, without the flag, falls back to the 200-char truncation + // so a long prompt doesn't blow up the tool-result context. + const truncated = await dispatchToolCall( + config, + taskId, + "list_jobs", + "call_trunc", + JSON.stringify({}) + ); + if (truncated.kind !== "sync") throw new Error("expected sync result"); + const truncatedParsed = JSON.parse(truncated.result) as { jobs: Array<{ prompt: string }> }; + expect(truncatedParsed.jobs[0]?.prompt.length).toBeLessThan(longPrompt.length); + expect(truncatedParsed.jobs[0]?.prompt.endsWith("…")).toBe(true); + }); + + test("list_jobs dispatch rejects non-boolean fullPrompt", async () => { + const config = testConfig("jobs-list-tool-full-prompt-bad"); + const taskId = await mutateState(config.instance, (state) => { + const task = createTask(state.instance, "test", undefined, undefined, undefined, undefined); + upsertTask(state, task); + return task.id; + }); + await expect( + dispatchToolCall( + config, + taskId, + "list_jobs", + "call_full_bad", + JSON.stringify({ fullPrompt: "yes" }) + ) + ).rejects.toThrow(/fullPrompt must be a boolean/); + }); + + test("update_job dispatch patches schedule and writes job.updated audit", async () => { + const config = testConfig("jobs-update-tool"); + const handler = createHandler(config); + const job = await call(handler, config, "/api/jobs", { + method: "POST", + body: JSON.stringify({ name: "to-update", script: "true", intervalSeconds: 60 }) + }); + + const taskId = await mutateState(config.instance, (state) => { + const task = createTask(state.instance, "test", undefined, undefined, undefined, undefined); + upsertTask(state, task); + return task.id; + }); + + const result = await dispatchToolCall( + config, + taskId, + "update_job", + "call_update_1", + JSON.stringify({ + jobId: job.id, + cronExpression: "0 23 * * *", + cronTimezone: "America/Los_Angeles", + intervalSeconds: null, + name: "renamed" + }) + ); + expect(result.kind).toBe("sync"); + const after = readState(config.instance).jobs.find((j) => j.id === job.id); + expect(after?.cronExpression).toBe("0 23 * * *"); + expect(after?.cronTimezone).toBe("America/Los_Angeles"); + expect(after?.intervalSeconds).toBeUndefined(); + expect(after?.name).toBe("renamed"); + + const audit = readState(config.instance).audit.find( + (event) => event.action === "job.updated" && event.target === job.id && event.actor === "agent" + ); + expect(audit).toBeDefined(); + expect(audit?.evidence?.jobId).toBe(job.id); + expect(audit?.evidence?.appliedFields).toContain("cronExpression"); + expect(audit?.evidence?.appliedFields).toContain("name"); + // The audit row pins the prior schedule shape so the change is + // reconstructable from the log alone. + expect((audit?.evidence?.previousSchedule as Record)?.intervalSeconds).toBe(60); + }); + + test("update_job dispatch can pause a running job", async () => { + const config = testConfig("jobs-update-tool-pause"); + const handler = createHandler(config); + const job = await call(handler, config, "/api/jobs", { + method: "POST", + body: JSON.stringify({ name: "to-pause", script: "true", intervalSeconds: 60 }) + }); + + const taskId = await mutateState(config.instance, (state) => { + const task = createTask(state.instance, "test", undefined, undefined, undefined, undefined); + upsertTask(state, task); + return task.id; + }); + const result = await dispatchToolCall( + config, + taskId, + "update_job", + "call_pause", + JSON.stringify({ jobId: job.id, status: "paused" }) + ); + expect(result.kind).toBe("sync"); + const after = readState(config.instance).jobs.find((j) => j.id === job.id); + expect(after?.status).toBe("paused"); + const audit = readState(config.instance).audit.find( + (event) => event.action === "job.updated" && event.target === job.id && event.actor === "agent" + ); + expect(audit?.evidence?.appliedFields).toContain("status"); + // The return string must not claim a next-fire moment for a paused + // job — the scheduler skips it while paused, so "next fires at ..." + // would be a lie. + if (result.kind === "sync") { + expect(result.result).not.toContain("next fires at"); + expect(result.result).toContain("will not fire until resumed"); + } + }); + + test("update_job dispatch rejects missing jobId", async () => { + const config = testConfig("jobs-update-tool-bad-1"); + const taskId = await mutateState(config.instance, (state) => { + const task = createTask(state.instance, "test", undefined, undefined, undefined, undefined); + upsertTask(state, task); + return task.id; + }); + await expect( + dispatchToolCall( + config, + taskId, + "update_job", + "call_bad_no_id", + JSON.stringify({ name: "x" }) + ) + ).rejects.toThrow(/jobId/); + }); + + test("update_job dispatch rejects empty patch", async () => { + const config = testConfig("jobs-update-tool-empty"); + const handler = createHandler(config); + const job = await call(handler, config, "/api/jobs", { + method: "POST", + body: JSON.stringify({ name: "empty-patch", script: "true", intervalSeconds: 60 }) + }); + const taskId = await mutateState(config.instance, (state) => { + const task = createTask(state.instance, "test", undefined, undefined, undefined, undefined); + upsertTask(state, task); + return task.id; + }); + await expect( + dispatchToolCall( + config, + taskId, + "update_job", + "call_empty_patch", + JSON.stringify({ jobId: job.id }) + ) + ).rejects.toThrow(/at least one field/); + }); + + test("update_job dispatch rejects unknown jobId", async () => { + const config = testConfig("jobs-update-tool-missing"); + const taskId = await mutateState(config.instance, (state) => { + const task = createTask(state.instance, "test", undefined, undefined, undefined, undefined); + upsertTask(state, task); + return task.id; + }); + await expect( + dispatchToolCall( + config, + taskId, + "update_job", + "call_unknown", + JSON.stringify({ jobId: "job_does_not_exist", name: "x" }) + ) + ).rejects.toThrow(/Job not found/); + }); + + test("update_job dispatch applies autoApproveCommands and dangerouslyAutoApprove onto the JobRecord", async () => { + const config = testConfig("jobs-update-tool-auto-approve"); + const handler = createHandler(config); + const job = await call(handler, config, "/api/jobs", { + method: "POST", + body: JSON.stringify({ name: "approve-me", script: "true", intervalSeconds: 60 }) + }); + + const taskId = await mutateState(config.instance, (state) => { + const task = createTask(state.instance, "test", undefined, undefined, undefined, undefined); + upsertTask(state, task); + return task.id; + }); + + const result = await dispatchToolCall( + config, + taskId, + "update_job", + "call_auto_approve", + JSON.stringify({ + jobId: job.id, + autoApproveCommands: ["ls", "git status"], + dangerouslyAutoApprove: true + }) + ); + expect(result.kind).toBe("sync"); + const after = readState(config.instance).jobs.find((j) => j.id === job.id); + expect(after?.dangerouslyAutoApprove).toBe(true); + expect(after?.autoApproveCommands).toEqual(["ls", "git status"]); + + // Clearing via empty array drops the override entirely. + const cleared = await dispatchToolCall( + config, + taskId, + "update_job", + "call_auto_approve_clear", + JSON.stringify({ + jobId: job.id, + autoApproveCommands: [], + dangerouslyAutoApprove: false + }) + ); + expect(cleared.kind).toBe("sync"); + const afterClear = readState(config.instance).jobs.find((j) => j.id === job.id); + expect(afterClear?.dangerouslyAutoApprove).toBe(false); + expect(afterClear?.autoApproveCommands).toBeUndefined(); + }); + + test("update_job dispatch rejects null prompt", async () => { + // `prompt: null` is not a valid clear signal — JobRecord.prompt is + // string-typed. Throw `Invalid input` so the agent's follow-up + // can't misreport a phantom prompt change. + const config = testConfig("jobs-update-tool-null-prompt"); + const handler = createHandler(config); + const job = await call(handler, config, "/api/jobs", { + method: "POST", + body: JSON.stringify({ name: "null-prompt", script: "true", intervalSeconds: 60 }) + }); + const taskId = await mutateState(config.instance, (state) => { + const task = createTask(state.instance, "test", undefined, undefined, undefined, undefined); + upsertTask(state, task); + return task.id; + }); + await expect( + dispatchToolCall( + config, + taskId, + "update_job", + "call_null_prompt", + JSON.stringify({ jobId: job.id, prompt: null }) + ) + ).rejects.toThrow(/Invalid input: prompt must be a non-empty string/); + }); + + test("update_job dispatch rejects non-string name", async () => { + const config = testConfig("jobs-update-tool-numeric-name"); + const handler = createHandler(config); + const job = await call(handler, config, "/api/jobs", { + method: "POST", + body: JSON.stringify({ name: "to-rename", script: "true", intervalSeconds: 60 }) + }); + const taskId = await mutateState(config.instance, (state) => { + const task = createTask(state.instance, "test", undefined, undefined, undefined, undefined); + upsertTask(state, task); + return task.id; + }); + await expect( + dispatchToolCall( + config, + taskId, + "update_job", + "call_numeric_name", + JSON.stringify({ jobId: job.id, name: 123 }) + ) + ).rejects.toThrow(/Invalid input: name must be a non-empty string/); + }); + + test("update_job dispatch rejects empty-string name", async () => { + const config = testConfig("jobs-update-tool-empty-name"); + const handler = createHandler(config); + const job = await call(handler, config, "/api/jobs", { + method: "POST", + body: JSON.stringify({ name: "to-keep", script: "true", intervalSeconds: 60 }) + }); + const taskId = await mutateState(config.instance, (state) => { + const task = createTask(state.instance, "test", undefined, undefined, undefined, undefined); + upsertTask(state, task); + return task.id; + }); + await expect( + dispatchToolCall( + config, + taskId, + "update_job", + "call_empty_name", + JSON.stringify({ jobId: job.id, name: "" }) + ) + ).rejects.toThrow(/Invalid input: name must be a non-empty string/); + }); + + test("update_job dispatch rejects invalid status value", async () => { + const config = testConfig("jobs-update-tool-bad-status"); + const handler = createHandler(config); + const job = await call(handler, config, "/api/jobs", { + method: "POST", + body: JSON.stringify({ name: "bad-status", script: "true", intervalSeconds: 60 }) + }); + const taskId = await mutateState(config.instance, (state) => { + const task = createTask(state.instance, "test", undefined, undefined, undefined, undefined); + upsertTask(state, task); + return task.id; + }); + await expect( + dispatchToolCall( + config, + taskId, + "update_job", + "call_bad_status", + JSON.stringify({ jobId: job.id, status: "failed" }) + ) + ).rejects.toThrow(/status must be 'active' or 'paused'/); + }); + + test("create_job dispatch persists deliveryTargets as the resolved bridge id", async () => { + const config = testConfig("jobs-create-tool-delivery"); + const { addMessagingBridge } = await import("./integrations/messaging"); + const bridge = await addMessagingBridge(config, { + name: "disc", + kind: "discord", + deliveryTargets: ["chan-1"], + botToken: "TOK" + }); + const taskId = await mutateState(config.instance, (state) => { + const task = createTask(state.instance, "test", undefined, undefined, undefined, undefined); + upsertTask(state, task); + return task.id; + }); + + await dispatchToolCall( + config, + taskId, + "create_job", + "call_delivery", + JSON.stringify({ name: "briefing", intervalSeconds: 60, prompt: "x", deliveryTargets: ["disc"] }) + ); + + // The entry is stored as the bridge id, not the name the caller + // typed — names and kinds are not unique, and bridge ordering + // shifts as records are added, so the id pins the user's choice. + const jobs = readState(config.instance).jobs; + expect(jobs).toHaveLength(1); + expect(jobs[0]?.deliveryTargets).toEqual([bridge.id]); + const audit = readState(config.instance).audit.find( + (event) => event.action === "job.created" && event.target === jobs[0]!.id + ); + expect(audit?.evidence?.deliveryTargets).toEqual([bridge.id]); + }); + + test("create_job dispatch rejects an unknown deliveryTargets entry, listing dispatchable bridge names", async () => { + const config = testConfig("jobs-create-tool-delivery-bad"); + const { addMessagingBridge } = await import("./integrations/messaging"); + await addMessagingBridge(config, { + name: "disc", + kind: "discord", + deliveryTargets: ["chan-1"], + botToken: "TOK" + }); + const taskId = await mutateState(config.instance, (state) => { + const task = createTask(state.instance, "test", undefined, undefined, undefined, undefined); + upsertTask(state, task); + return task.id; + }); + + // The error names the dispatchable bridges so the agent can relay a + // fixable message ("did you mean 'disc'?") instead of a dead end. + await expect( + dispatchToolCall( + config, + taskId, + "create_job", + "call_delivery_bad", + JSON.stringify({ name: "briefing", intervalSeconds: 60, prompt: "x", deliveryTargets: ["whatsapp"] }) + ) + ).rejects.toThrow(/no dispatchable messaging bridge matches 'whatsapp'. Dispatchable bridges: disc/); + expect(readState(config.instance).jobs).toHaveLength(0); + }); + + test("create_job dispatch rejects a non-dispatchable (demo) bridge as a deliveryTargets entry", async () => { + const config = testConfig("jobs-create-tool-delivery-demo"); + const { addMessagingBridge } = await import("./integrations/messaging"); + // Demo bridges are common (CLI default kind) but the finalizer can + // only send to telegram/discord — accepting one here would validate + // a target that fails on every fire. + await addMessagingBridge(config, { name: "demo-bridge", kind: "demo" }); + await addMessagingBridge(config, { + name: "disc", + kind: "discord", + deliveryTargets: ["chan-1"], + botToken: "TOK" + }); + const taskId = await mutateState(config.instance, (state) => { + const task = createTask(state.instance, "test", undefined, undefined, undefined, undefined); + upsertTask(state, task); + return task.id; + }); + + await expect( + dispatchToolCall( + config, + taskId, + "create_job", + "call_delivery_demo", + JSON.stringify({ name: "briefing", intervalSeconds: 60, prompt: "x", deliveryTargets: ["demo-bridge"] }) + ) + ).rejects.toThrow(/no dispatchable messaging bridge matches 'demo-bridge'. Dispatchable bridges: disc/); + expect(readState(config.instance).jobs).toHaveLength(0); + }); + + test("create_job dispatch rejects an ambiguous deliveryTargets entry, listing the candidates", async () => { + const config = testConfig("jobs-create-tool-delivery-ambiguous"); + const { addMessagingBridge } = await import("./integrations/messaging"); + const a = await addMessagingBridge(config, { + name: "disc-a", + kind: "discord", + deliveryTargets: ["chan-1"], + botToken: "TOK" + }); + const b = await addMessagingBridge(config, { + name: "disc-b", + kind: "discord", + deliveryTargets: ["chan-2"], + botToken: "TOK" + }); + const taskId = await mutateState(config.instance, (state) => { + const task = createTask(state.instance, "test", undefined, undefined, undefined, undefined); + upsertTask(state, task); + return task.id; + }); + + // "discord" matches both bridges by kind — first-match would + // silently pick whichever record happens to sort first, so the + // entry is rejected with both candidates named. + let message = ""; + try { + await dispatchToolCall( + config, + taskId, + "create_job", + "call_delivery_ambiguous", + JSON.stringify({ name: "briefing", intervalSeconds: 60, prompt: "x", deliveryTargets: ["discord"] }) + ); + } catch (error) { + message = error instanceof Error ? error.message : String(error); + } + expect(message).toContain("ambiguous"); + expect(message).toContain(`disc-a (${a.id})`); + expect(message).toContain(`disc-b (${b.id})`); + expect(readState(config.instance).jobs).toHaveLength(0); + }); + + test("update_job dispatch sets deliveryTargets and clears them with []", async () => { + const config = testConfig("jobs-update-tool-delivery"); + const handler = createHandler(config); + const { addMessagingBridge } = await import("./integrations/messaging"); + const bridge = await addMessagingBridge(config, { + name: "disc", + kind: "discord", + deliveryTargets: ["chan-1"], + botToken: "TOK" + }); + const job = await call(handler, config, "/api/jobs", { + method: "POST", + body: JSON.stringify({ name: "to-route", script: "true", intervalSeconds: 60 }) + }); + const taskId = await mutateState(config.instance, (state) => { + const task = createTask(state.instance, "test", undefined, undefined, undefined, undefined); + upsertTask(state, task); + return task.id; + }); + + await dispatchToolCall( + config, + taskId, + "update_job", + "call_set_delivery", + JSON.stringify({ jobId: job.id, deliveryTargets: ["disc"] }) + ); + expect(readState(config.instance).jobs.find((j) => j.id === job.id)?.deliveryTargets).toEqual([bridge.id]); + + // An unknown entry is rejected with the dispatchable names and the + // previously-set targets stay untouched. + await expect( + dispatchToolCall( + config, + taskId, + "update_job", + "call_bad_delivery", + JSON.stringify({ jobId: job.id, deliveryTargets: ["slackk"] }) + ) + ).rejects.toThrow(/no dispatchable messaging bridge matches 'slackk'. Dispatchable bridges: disc/); + expect(readState(config.instance).jobs.find((j) => j.id === job.id)?.deliveryTargets).toEqual([bridge.id]); + + // Empty array is the documented "clear" signal. + await dispatchToolCall( + config, + taskId, + "update_job", + "call_clear_delivery", + JSON.stringify({ jobId: job.id, deliveryTargets: [] }) + ); + expect(readState(config.instance).jobs.find((j) => j.id === job.id)?.deliveryTargets).toEqual([]); + }); + + test("delete_job dispatch removes the job and writes job.deleted audit", async () => { + const config = testConfig("jobs-delete-tool"); + const handler = createHandler(config); + const job = await call(handler, config, "/api/jobs", { + method: "POST", + body: JSON.stringify({ + name: "to-delete", + script: "true", + cronExpression: "0 9 * * *", + cronTimezone: "UTC" + }) + }); + + const taskId = await mutateState(config.instance, (state) => { + const task = createTask(state.instance, "test", undefined, undefined, undefined, undefined); + upsertTask(state, task); + return task.id; + }); + + const result = await dispatchToolCall( + config, + taskId, + "delete_job", + "call_delete_1", + JSON.stringify({ jobId: job.id }) + ); + expect(result.kind).toBe("sync"); + if (result.kind === "sync") { + expect(result.result).toContain(job.id); + expect(result.result).toContain("to-delete"); + } + + expect(readState(config.instance).jobs.find((j) => j.id === job.id)).toBeUndefined(); + const audit = readState(config.instance).audit.find( + (event) => event.action === "job.deleted" && event.target === job.id && event.actor === "agent" + ); + expect(audit).toBeDefined(); + expect(audit?.evidence?.jobId).toBe(job.id); + expect(audit?.evidence?.name).toBe("to-delete"); + // The audit row pins the prior schedule shape so the deleted job is + // reconstructable from the log alone. + const prev = audit?.evidence?.previousSchedule as Record | undefined; + expect(prev?.cronExpression).toBe("0 9 * * *"); + expect(prev?.cronTimezone).toBe("UTC"); + }); + + test("delete_job dispatch rejects missing jobId", async () => { + const config = testConfig("jobs-delete-tool-bad-1"); + const taskId = await mutateState(config.instance, (state) => { + const task = createTask(state.instance, "test", undefined, undefined, undefined, undefined); + upsertTask(state, task); + return task.id; + }); + await expect( + dispatchToolCall( + config, + taskId, + "delete_job", + "call_delete_bad", + JSON.stringify({}) + ) + ).rejects.toThrow(/jobId/); + }); + + test("delete_job dispatch rejects unknown jobId", async () => { + const config = testConfig("jobs-delete-tool-missing"); + const taskId = await mutateState(config.instance, (state) => { + const task = createTask(state.instance, "test", undefined, undefined, undefined, undefined); + upsertTask(state, task); + return task.id; + }); + await expect( + dispatchToolCall( + config, + taskId, + "delete_job", + "call_delete_unknown", + JSON.stringify({ jobId: "job_nope" }) + ) + ).rejects.toThrow(/Job not found/); + }); + + test("update_job dispatch refuses to mutate when parent task is terminal", async () => { + // Defense-in-depth: when the parent task has gone terminal between + // the chat-task per-tool guard and dispatch, update_job must skip + // the mutation entirely so a cancelled task can't leak a patched + // job past the cancellation. Pre-check returns an Error string; the + // JobRecord stays untouched. + const config = testConfig("jobs-update-tool-terminal"); + const handler = createHandler(config); + const job = await call(handler, config, "/api/jobs", { + method: "POST", + body: JSON.stringify({ name: "to-keep", script: "true", intervalSeconds: 60 }) + }); + + const taskId = await mutateState(config.instance, (state) => { + const task = createTask(state.instance, "test", undefined, undefined, undefined, undefined); + task.status = "cancelled"; + upsertTask(state, task); + return task.id; + }); + + const result = await dispatchToolCall( + config, + taskId, + "update_job", + "call_update_terminal", + JSON.stringify({ jobId: job.id, name: "should-not-apply" }) + ); + expect(result.kind).toBe("sync"); + if (result.kind === "sync") { + expect(result.result).toMatch(/Error: update_job skipped/); + } + // JobRecord is unchanged — name still "to-keep". + const after = readState(config.instance).jobs.find((j) => j.id === job.id); + expect(after?.name).toBe("to-keep"); + }); + + test("updateJob oneShot patch applies when parent task is completed", async () => { + // Consistency invariant: a `completed` parent task may still manage + // jobs (schedule a follow-up, flip a finished one-shot back to + // recurring, etc.). The shared job mutators in src/jobs/index.ts + // (createScheduledJob, updateJob, updateJobStatus, removeJob) + // permit `completed` and refuse only on `cancelled`/`failed`. The + // oneShot patch rides inside updateJob's single mutateState, so it is + // covered by the exact same predicate as every sibling field — no + // partial application (name landed, oneShot refused) is possible. + // + // The dispatcher's lock-free entry-level pre-check is stricter + // (it short-circuits all terminal statuses to avoid touching the + // lock for the common case), so this test verifies the invariant + // at the authoritative serialization point — the shared mutator — + // which is where the asymmetry would actually leak through under + // a race (parent transitions to `completed` between the pre-check + // and the serialized re-check). + const config = testConfig("jobs-update-tool-oneshot-completed-parent"); + const handler = createHandler(config); + const job = await call(handler, config, "/api/jobs", { + method: "POST", + body: JSON.stringify({ name: "flip-oneshot", prompt: "ping", intervalSeconds: 60 }) + }); + + const taskId = await mutateState(config.instance, (state) => { + const task = createTask(state.instance, "test", undefined, undefined, undefined, undefined); + task.status = "completed"; + upsertTask(state, task); + return task.id; + }); + + // The shared mutator path (updateJob) accepts a `completed` + // parent — this is what permits a completed task's final action + // to be a legitimate job patch. + await updateJob(config, job.id, { name: "renamed-from-completed-parent" }, taskId); + const afterName = readState(config.instance).jobs.find((j) => j.id === job.id); + expect(afterName?.name).toBe("renamed-from-completed-parent"); + + // The oneShot patch goes through the same production path (updateJob's + // dedicated parameter) and must apply under the same predicate. + await updateJob(config, job.id, {}, taskId, true); + const after = readState(config.instance).jobs.find((j) => j.id === job.id); + expect(after?.oneShot).toBe(true); + }); + + test("delete_job dispatch refuses to mutate when parent task is terminal", async () => { + // Same defense-in-depth as update_job: a cancelled parent task must + // not be able to delete a JobRecord through the agent tool path. + const config = testConfig("jobs-delete-tool-terminal"); + const handler = createHandler(config); + const job = await call(handler, config, "/api/jobs", { + method: "POST", + body: JSON.stringify({ name: "to-keep", script: "true", intervalSeconds: 60 }) + }); + + const taskId = await mutateState(config.instance, (state) => { + const task = createTask(state.instance, "test", undefined, undefined, undefined, undefined); + task.status = "cancelled"; + upsertTask(state, task); + return task.id; + }); + + const result = await dispatchToolCall( + config, + taskId, + "delete_job", + "call_delete_terminal", + JSON.stringify({ jobId: job.id }) + ); + expect(result.kind).toBe("sync"); + if (result.kind === "sync") { + expect(result.result).toMatch(/Error: delete_job skipped/); + } + // JobRecord is still present. + const after = readState(config.instance).jobs.find((j) => j.id === job.id); + expect(after).toBeDefined(); + }); + + test("run_job dispatch rejects missing jobId", async () => { + const config = testConfig("jobs-run-tool-bad-1"); + const taskId = await mutateState(config.instance, (state) => { + const task = createTask(state.instance, "test", undefined, undefined, undefined, undefined); + upsertTask(state, task); + return task.id; + }); + await expect( + dispatchToolCall( + config, + taskId, + "run_job", + "call_run_bad", + JSON.stringify({}) + ) + ).rejects.toThrow(/jobId/); + }); + + test("run_job dispatch rejects unknown jobId", async () => { + const config = testConfig("jobs-run-tool-missing"); + const taskId = await mutateState(config.instance, (state) => { + const task = createTask(state.instance, "test", undefined, undefined, undefined, undefined); + upsertTask(state, task); + return task.id; + }); + await expect( + dispatchToolCall( + config, + taskId, + "run_job", + "call_run_unknown", + JSON.stringify({ jobId: "job_nope" }) + ) + ).rejects.toThrow(/Job not found/); + }); + + test("run_job dispatch refuses to mutate when parent task is terminal", async () => { + // Same defense-in-depth as update_job / delete_job: a cancelled + // parent task must not be able to fire a fresh job run through the + // agent tool path. The tool handler does a lock-free pre-check and + // `runJobNow` re-checks inside its serialized `mutateState` block; this + // test exercises the pre-check path. + const config = testConfig("jobs-run-tool-terminal"); + const handler = createHandler(config); + const job = await call(handler, config, "/api/jobs", { + method: "POST", + body: JSON.stringify({ name: "to-fire", script: "true", intervalSeconds: 60 }) + }); + + const taskId = await mutateState(config.instance, (state) => { + const task = createTask(state.instance, "test", undefined, undefined, undefined, undefined); + task.status = "cancelled"; + upsertTask(state, task); + return task.id; + }); + + const result = await dispatchToolCall( + config, + taskId, + "run_job", + "call_run_terminal", + JSON.stringify({ jobId: job.id }) + ); + expect(result.kind).toBe("sync"); + if (result.kind === "sync") { + expect(result.result).toMatch(/Error: run_job skipped/); + } + // No new JobRunRecord was created. + const runs = readState(config.instance).jobRuns.filter((r) => r.jobId === job.id); + expect(runs).toHaveLength(0); + }); + + test("run_job dispatch fires a prompt job, spawns a task, and writes job.run.manual audit", async () => { + const config = testConfig("jobs-run-tool-happy"); + const handler = createHandler(config); + const job = await call(handler, config, "/api/jobs", { + method: "POST", + body: JSON.stringify({ + name: "manual-fire", + prompt: "ping", + intervalSeconds: 3600 + }) + }); + + const taskId = await mutateState(config.instance, (state) => { + const task = createTask(state.instance, "test", undefined, undefined, undefined, undefined); + upsertTask(state, task); + return task.id; + }); + + const result = await dispatchToolCall( + config, + taskId, + "run_job", + "call_run_happy", + JSON.stringify({ jobId: job.id }) + ); + expect(result.kind).toBe("sync"); + if (result.kind === "sync") { + expect(result.result).toContain(job.id); + expect(result.result).toContain("manual-fire"); + expect(result.result).toMatch(/run /); + expect(result.result).toMatch(/task /); + } + + // A new JobRunRecord exists with a spawned task linked. + const runs = readState(config.instance).jobRuns.filter((r) => r.jobId === job.id); + expect(runs).toHaveLength(1); + expect(runs[0]?.trigger).toBe("manual"); + expect(runs[0]?.taskId).toBeDefined(); + + // The audit row uses action "job.run.manual" and points at the + // spawned task + new run id. + const audit = readState(config.instance).audit.find( + (event) => event.action === "job.run.manual" && event.target === job.id && event.actor === "agent" + ); + expect(audit).toBeDefined(); + expect(audit?.evidence?.jobId).toBe(job.id); + expect(audit?.evidence?.runId).toBe(runs[0]?.id); + expect(audit?.evidence?.spawnedTaskId).toBe(runs[0]?.taskId); + }); + + test("run_job dispatch reports script-job success with exit 0", async () => { + // Script-backed jobs execute synchronously inside `runJobNow`, so by + // the time the tool returns the run is already complete. The handler + // must report the exit code (not "Triggered ...") and the audit row + // must pin exitCode for postmortems. + const config = testConfig("jobs-run-tool-script-ok"); + const handler = createHandler(config); + const job = await call(handler, config, "/api/jobs", { + method: "POST", + body: JSON.stringify({ name: "script-ok", script: "true", intervalSeconds: 3600 }) + }); + + const taskId = await mutateState(config.instance, (state) => { + const task = createTask(state.instance, "test", undefined, undefined, undefined, undefined); + upsertTask(state, task); + return task.id; + }); + + const result = await dispatchToolCall( + config, + taskId, + "run_job", + "call_run_script_ok", + JSON.stringify({ jobId: job.id }) + ); + expect(result.kind).toBe("sync"); + if (result.kind === "sync") { + expect(result.result).toContain(job.id); + expect(result.result).toContain("script-ok"); + expect(result.result).toMatch(/completed/); + expect(result.result).toMatch(/exit 0/); + } + + const runs = readState(config.instance).jobRuns.filter((r) => r.jobId === job.id); + expect(runs).toHaveLength(1); + expect(runs[0]?.trigger).toBe("manual"); + // Script jobs don't spawn a task. + expect(runs[0]?.taskId).toBeUndefined(); + + const audit = readState(config.instance).audit.find( + (event) => event.action === "job.run.manual" && event.target === job.id && event.actor === "agent" + ); + expect(audit).toBeDefined(); + expect(audit?.evidence?.jobId).toBe(job.id); + expect(audit?.evidence?.runId).toBe(runs[0]?.id); + expect(audit?.evidence?.exitCode).toBe(0); + }); + + test("run_job dispatch reports script-job failure with non-zero exit", async () => { + // Failure path: tool return string must say "failed", surface the + // non-zero exit, and the audit row must capture exitCode so + // postmortems don't have to cross-reference the JobRun record. + const config = testConfig("jobs-run-tool-script-fail"); + const handler = createHandler(config); + const job = await call(handler, config, "/api/jobs", { + method: "POST", + body: JSON.stringify({ name: "script-fail", script: "exit 1", intervalSeconds: 3600 }) + }); + + const taskId = await mutateState(config.instance, (state) => { + const task = createTask(state.instance, "test", undefined, undefined, undefined, undefined); + upsertTask(state, task); + return task.id; + }); + + const result = await dispatchToolCall( + config, + taskId, + "run_job", + "call_run_script_fail", + JSON.stringify({ jobId: job.id }) + ); + expect(result.kind).toBe("sync"); + if (result.kind === "sync") { + expect(result.result).toContain(job.id); + expect(result.result).toContain("script-fail"); + expect(result.result).toMatch(/failed/); + expect(result.result).toMatch(/exit 1/); + } + + const runs = readState(config.instance).jobRuns.filter((r) => r.jobId === job.id); + expect(runs).toHaveLength(1); + expect(runs[0]?.trigger).toBe("manual"); + expect(runs[0]?.status).toBe("failed"); + + const audit = readState(config.instance).audit.find( + (event) => event.action === "job.run.manual" && event.target === job.id && event.actor === "agent" + ); + expect(audit).toBeDefined(); + expect(audit?.evidence?.jobId).toBe(job.id); + expect(audit?.evidence?.runId).toBe(runs[0]?.id); + expect(audit?.evidence?.exitCode).toBe(1); + }); + + test("scheduled prompt job with chatSessionId delivers an assistant chat message", async () => { + // End-to-end test: create a job linked to a chat session, force its + // nextRunAt into the past, let runDueJobs claim + dispatch it, wait + // for the task to settle, then assert that finalizeJobRunFromTask + // produced a ChatMessageRecord with role="assistant" in that session. + const config = testConfig("jobs-chat-delivery"); + const handler = createHandler(config); + + const session = await call(handler, config, "/api/chat", { + method: "POST", + body: JSON.stringify({ title: "delivery test" }) + }); + + const job = await call(handler, config, "/api/jobs", { + method: "POST", + body: JSON.stringify({ + name: "delivery-job", + prompt: "echo ping", + intervalSeconds: 60, + chatSessionId: session.id, + oneShot: true + }) + }); + expect(job.chatSessionId).toBe(session.id); + expect(job.oneShot).toBe(true); + + // Force the job due so runDueJobs claims it on the next tick. + await mutateState(config.instance, (state) => { + const item = state.jobs.find((candidate) => candidate.id === job.id); + if (!item) throw new Error("setup: job missing"); + item.nextRunAt = new Date(Date.now() - 1_000).toISOString(); + }); + + await runDueJobs(config); + + // Diagnostic: confirm runDueJobs claimed the job and spawned a task. + const afterClaim = readState(config.instance); + const claimedRun = afterClaim.jobRuns.find((run) => run.jobId === job.id); + expect(claimedRun).toBeDefined(); + expect(claimedRun?.taskId).toBeString(); + const spawnedTask = afterClaim.tasks.find((t) => t.id === claimedRun?.taskId); + expect(spawnedTask?.mode).toBe("chat"); + + // Wait for the spawned task to settle, then for the assistant message + // to appear in the session (finalize is async). + await waitFor(() => { + const state = readState(config.instance); + const jobRun = state.jobRuns.find((run) => run.jobId === job.id); + return jobRun?.status === "completed" || jobRun?.status === "failed"; + }, 5_000); + + await waitFor(() => { + const state = readState(config.instance); + return state.chatMessages.some( + (m) => m.sessionId === session.id && m.role === "assistant" + ); + }, 5_000); + + const stateAfter = readState(config.instance); + const assistantMessages = stateAfter.chatMessages.filter( + (m) => m.sessionId === session.id && m.role === "assistant" + ); + expect(assistantMessages.length).toBeGreaterThanOrEqual(1); + + // The job should be paused now because oneShot=true. + const finalJob = stateAfter.jobs.find((candidate) => candidate.id === job.id); + expect(finalJob?.status).toBe("paused"); + + // Audit row for the one-shot completion. + const audit = stateAfter.audit.find( + (event) => event.action === "job.oneshot.completed" && event.target === job.id + ); + expect(audit).toBeDefined(); + }); + + test("a forwardToChat job materializes its answer in its Topic AND forwards a tagged block into the owning agent's Chat", async () => { + // End-to-end: a job that runs in its own Topic and forwards each fire's + // final answer into the owning agent's Chat (ADR + // chat-topics-tasks-subagents.md, "Jobs → Topics"). + const { getOrCreateAgentChat } = await import("./execution/chat"); + const { listChatBlocks, createChatSession } = await import("./state"); + const config = testConfig("jobs-forward-to-chat"); + + // Resolve the owning agent + its Chat, then mint a dedicated Topic for the + // job and a forwardToChat job bound to it. + const agentId = readState(config.instance).agents[0]!.id; + const chat = await getOrCreateAgentChat(config.instance, agentId); + const topicId = await mutateState(config.instance, (state) => { + const topic = createChatSession(state, "ping-watch", undefined, agentId, "job", "channel"); + return topic.id; + }); + const job = await createScheduledJob(config, { + name: "ping-watch", + prompt: "echo ping", + intervalSeconds: 60, + chatSessionId: topicId, + forwardToChat: true, + oneShot: true + }, { originatingAgentId: agentId }); + expect(job.forwardToChat).toBe(true); + + await mutateState(config.instance, (state) => { + const item = state.jobs.find((candidate) => candidate.id === job.id); + if (!item) throw new Error("setup: job missing"); + item.nextRunAt = new Date(Date.now() - 1_000).toISOString(); + }); + + await runDueJobs(config); + + await waitFor(() => { + const jobRun = readState(config.instance).jobRuns.find((run) => run.jobId === job.id); + return jobRun?.status === "completed" || jobRun?.status === "failed"; + }, 5_000); + + // The replay-authoritative answer landed in the Topic. + await waitFor( + () => readState(config.instance).chatMessages.some((m) => m.sessionId === topicId && m.role === "assistant"), + 5_000 + ); + + // …and a forwarded render-only block landed in the owning agent's Chat, + // tagged with the Topic for the deep-link chip. + await waitFor(() => { + return listChatBlocks(config.instance, chat.id).some( + (b) => b.kind === "assistant_text" && b.forwardedFromTopicId === topicId + ); + }, 5_000); + + const forwarded = listChatBlocks(config.instance, chat.id).find( + (b) => b.kind === "assistant_text" && b.forwardedFromTopicId === topicId + ); + expect(forwarded).toBeDefined(); + if (forwarded?.kind === "assistant_text") { + expect(forwarded.forwardedFromTopicTitle).toBe("ping-watch"); + expect(forwarded.text.trim().length).toBeGreaterThan(0); + } + }); + + test("a channel-only job forwards NOTHING into the owning agent's Chat", async () => { + const { getOrCreateAgentChat } = await import("./execution/chat"); + const { listChatBlocks, createChatSession } = await import("./state"); + const config = testConfig("jobs-channel-only-no-forward"); + + const agentId = readState(config.instance).agents[0]!.id; + const chat = await getOrCreateAgentChat(config.instance, agentId); + const topicId = await mutateState(config.instance, (state) => { + const topic = createChatSession(state, "report", undefined, agentId, "job", "channel"); + return topic.id; + }); + // No forwardToChat: the answer stays in the Topic only. + const job = await createScheduledJob(config, { + name: "report", + prompt: "echo ping", + intervalSeconds: 60, + chatSessionId: topicId, + oneShot: true + }, { originatingAgentId: agentId }); + expect(job.forwardToChat ?? false).toBe(false); + + await mutateState(config.instance, (state) => { + const item = state.jobs.find((candidate) => candidate.id === job.id); + if (!item) throw new Error("setup: job missing"); + item.nextRunAt = new Date(Date.now() - 1_000).toISOString(); + }); + + await runDueJobs(config); + + // The answer materializes in the Topic. + await waitFor(() => { + const jobRun = readState(config.instance).jobRuns.find((run) => run.jobId === job.id); + return jobRun?.status === "completed" || jobRun?.status === "failed"; + }, 5_000); + await waitFor( + () => readState(config.instance).chatMessages.some((m) => m.sessionId === topicId && m.role === "assistant"), + 5_000 + ); + + // Nothing forwarded into the agent's Chat. + expect(listChatBlocks(config.instance, chat.id).filter((b) => b.kind === "assistant_text")).toHaveLength(0); + }); + + test("syncChatTaskResult suppresses delivery when the task summary is [SILENT]", async () => { + // The cron-execution hint instructs the LLM to emit "[SILENT]" when a + // scheduled run has nothing new to report. syncChatTaskResult must + // recognize the sentinel, skip creating an assistant ChatMessageRecord, + // and audit the suppression. + const config = testConfig("jobs-silent-suppress"); + + const sessionId = "session_silent_test"; + const taskId = await mutateState(config.instance, (state) => { + state.chatSessions.unshift({ + id: sessionId, + instance: state.instance, + title: "Silent test", + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + messageIds: [], + taskIds: [], + runIds: [] + }); + const task = createTask(state.instance, "watch for change", undefined, undefined, undefined, undefined); + task.status = "completed"; + task.summary = "[SILENT]"; + task.updatedAt = new Date().toISOString(); + upsertTask(state, task); + return task.id; + }); + + const result = await syncChatTaskResult(config, sessionId, taskId); + expect(result).toBeNull(); + + const stateAfter = readState(config.instance); + const assistantMessages = stateAfter.chatMessages.filter( + (m) => m.sessionId === sessionId && m.role === "assistant" + ); + expect(assistantMessages).toHaveLength(0); + + const audit = stateAfter.audit.find( + (event) => event.action === "chat.message.suppressed_silent" && event.target === sessionId + ); + expect(audit).toBeDefined(); + expect(audit?.taskId).toBe(taskId); + }); + + test("syncChatTaskResult delivers when summary contains [SILENT] alongside other text", async () => { + // The sentinel must match exactly. A summary like "[SILENT] but also..." + // or a lowercase variant should NOT be suppressed — otherwise a reminder + // that happens to mention the word could be silently dropped. + const config = testConfig("jobs-silent-not-exact"); + + const sessionId = "session_silent_strict"; + const taskId = await mutateState(config.instance, (state) => { + state.chatSessions.unshift({ + id: sessionId, + instance: state.instance, + title: "Strict", + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + messageIds: [], + taskIds: [], + runIds: [] + }); + const task = createTask(state.instance, "watch", undefined, undefined, undefined, undefined); + task.status = "completed"; + task.summary = "[SILENT] with extra"; + task.updatedAt = new Date().toISOString(); + upsertTask(state, task); + return task.id; + }); + + const result = await syncChatTaskResult(config, sessionId, taskId); + expect(result).not.toBeNull(); + + const stateAfter = readState(config.instance); + const assistantMessages = stateAfter.chatMessages.filter( + (m) => m.sessionId === sessionId && m.role === "assistant" + ); + expect(assistantMessages).toHaveLength(1); + expect(assistantMessages[0]?.content).toBe("[SILENT] with extra"); + }); + + test("dispatchJobReplyToBridge suppresses ONLY exact '[SILENT]'; any prefix-only match still dispatches", async () => { + // Mirror invariant of the syncChatTaskResult test above, but for + // the bridge dispatch path. Earlier code used `startsWith` here + // which would have silently dropped a legitimate reply like + // "[SILENT] but here's an update" while syncChatTaskResult + // (correctly) delivered it to chat — meaning a scheduled job + // would land in chat UI but never reach Telegram/Discord. + const config = testConfig("jobs-silent-dispatch-strict"); + const { addMessagingBridge, setMessagingDeps, resetMessagingDeps } = await import("./integrations/messaging"); + const { findOrCreateDiscordChatSession } = await import("./state"); + const { finalizeJobRunFromTask } = await import("./jobs/finalize"); + const sendCalls: Array<{ channelId: string; content: string }> = []; + setMessagingDeps({ + discordClientFactory: () => ({ + async getMe() { + return { id: "100", username: "Gini", discriminator: "0000", bot: true }; + }, + async sendMessage(channelId, content) { + sendCalls.push({ channelId, content }); + return { id: "reply", channel_id: channelId, content, timestamp: "", author: { id: "100", username: "Gini", bot: true } }; + }, + async triggerTypingIndicator() { + return true as const; + }, + async fetchChannelMessages() { + return []; + } + }) + }); + + try { + const bridge = await addMessagingBridge(config, { + name: "disc", + kind: "discord", + deliveryTargets: ["chan-1"], + botToken: "TOK" + }); + const sessionId = await mutateState(config.instance, (state) => { + const session = findOrCreateDiscordChatSession(state, bridge.id, "chan-1"); + return session.id; + }); + + // "[SILENT] but here's an update" — must NOT be suppressed. + const taskA = await mutateState(config.instance, (state) => { + const t = createTask(state.instance, "scheduled", undefined, undefined, undefined, undefined); + t.status = "completed"; + t.summary = "[SILENT] but here's an update"; + t.jobId = "job_x"; + upsertTask(state, t); + const session = state.chatSessions.find((s) => s.id === sessionId)!; + session.taskIds.push(t.id); + state.jobs.push({ + id: "job_x", + instance: state.instance, + name: "x", + status: "active", + prompt: "p", + deliveryTargets: [], + context: [], + retryLimit: 0, + timeoutSeconds: 600, + chatSessionId: sessionId, + runIds: [], + taskIds: [], + runCount: 0, + missedRuns: 0, + nextRunAt: new Date().toISOString(), + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString() + }); + state.jobRuns.push({ + id: "run_x", + instance: state.instance, + jobId: "job_x", + status: "running", + taskId: t.id, + attempt: 1, + trigger: "schedule", + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString() + }); + return t.id; + }); + const taskAObj = readState(config.instance).tasks.find((t) => t.id === taskA)!; + await finalizeJobRunFromTask(config, taskAObj); + expect(sendCalls.length).toBe(1); + expect(sendCalls[0]?.content).toContain("but here's an update"); + + // Exact "[SILENT]" — must be suppressed. + sendCalls.length = 0; + const taskB = await mutateState(config.instance, (state) => { + const t = createTask(state.instance, "scheduled-silent", undefined, undefined, undefined, undefined); + t.status = "completed"; + t.summary = "[SILENT]"; + t.jobId = "job_x"; + upsertTask(state, t); + const session = state.chatSessions.find((s) => s.id === sessionId)!; + session.taskIds.push(t.id); + state.jobRuns.push({ + id: "run_y", + instance: state.instance, + jobId: "job_x", + status: "running", + taskId: t.id, + attempt: 1, + trigger: "schedule", + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString() + }); + return t.id; + }); + const taskBObj = readState(config.instance).tasks.find((t) => t.id === taskB)!; + await finalizeJobRunFromTask(config, taskBObj); + expect(sendCalls.length).toBe(0); + } finally { + resetMessagingDeps(); + } + }); + + test("dispatchJobReplyToBridge ignores tool_transcript rows so a [SILENT] tool-using job stays suppressed", async () => { + // A tool-using turn persists assistant rows tagged kind:"tool_transcript" + // (model-facing replay narration) before the terminal summary. The bridge + // dispatch picks the newest assistant row for the task; if it considered + // the transcript row it would mirror that narration to Telegram/Discord + // even though the terminal summary is "[SILENT]" — bypassing suppression. + const config = testConfig("jobs-silent-tool-transcript"); + const { addMessagingBridge, setMessagingDeps, resetMessagingDeps } = await import("./integrations/messaging"); + const { findOrCreateDiscordChatSession } = await import("./state"); + const { finalizeJobRunFromTask } = await import("./jobs/finalize"); + const sendCalls: Array<{ channelId: string; content: string }> = []; + setMessagingDeps({ + discordClientFactory: () => ({ + async getMe() { + return { id: "100", username: "Gini", discriminator: "0000", bot: true }; + }, + async sendMessage(channelId, content) { + sendCalls.push({ channelId, content }); + return { id: "reply", channel_id: channelId, content, timestamp: "", author: { id: "100", username: "Gini", bot: true } }; + }, + async triggerTypingIndicator() { + return true as const; + }, + async fetchChannelMessages() { + return []; + } + }) + }); + + try { + const bridge = await addMessagingBridge(config, { + name: "disc", + kind: "discord", + deliveryTargets: ["chan-1"], + botToken: "TOK" + }); + const sessionId = await mutateState(config.instance, (state) => { + const session = findOrCreateDiscordChatSession(state, bridge.id, "chan-1"); + return session.id; + }); + + const taskId = await mutateState(config.instance, (state) => { + const t = createTask(state.instance, "scheduled-tool", undefined, undefined, undefined, undefined); + t.status = "completed"; + t.summary = "[SILENT]"; + t.jobId = "job_tool"; + upsertTask(state, t); + const session = state.chatSessions.find((s) => s.id === sessionId)!; + session.taskIds.push(t.id); + // Seed a model-facing transcript assistant row with non-empty + // narration for the same task — this must never be mirrored. + createChatMessage(state, { + sessionId, + role: "assistant", + content: "Let me check the calendar before replying.", + taskId: t.id, + runId: t.runId, + kind: "tool_transcript" + }); + state.jobs.push({ + id: "job_tool", + instance: state.instance, + name: "x", + status: "active", + prompt: "p", + deliveryTargets: [], + context: [], + retryLimit: 0, + timeoutSeconds: 600, + chatSessionId: sessionId, + runIds: [], + taskIds: [], + runCount: 0, + missedRuns: 0, + nextRunAt: new Date().toISOString(), + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString() + }); + state.jobRuns.push({ + id: "run_tool", + instance: state.instance, + jobId: "job_tool", + status: "running", + taskId: t.id, + attempt: 1, + trigger: "schedule", + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString() + }); + return t.id; + }); + + const taskObj = readState(config.instance).tasks.find((t) => t.id === taskId)!; + await finalizeJobRunFromTask(config, taskObj); + expect(sendCalls.length).toBe(0); + } finally { + resetMessagingDeps(); + } + }); +}); + +async function call(handler: ReturnType, config: RuntimeConfig, path: string, init: RequestInit = {}) { + return callWithToken(handler, config, config.token, path, init); +} + +async function callWithToken(handler: ReturnType, config: RuntimeConfig, token: string, path: string, init: RequestInit = {}) { + const response = await rawCall(handler, config, path, init, token); + const value = await response.json(); + if (!response.ok) throw new Error(value.error ?? `HTTP ${response.status}`); + return value; +} + +async function rawCall(handler: ReturnType, config: RuntimeConfig, path: string, init: RequestInit = {}, token?: string) { + const auth = token ?? config.token; + const response = await handler(new Request(`http://127.0.0.1:${config.port}${path}`, { + ...init, + headers: { "content-type": "application/json", authorization: `Bearer ${auth}`, ...(init.headers ?? {}) } + })); + return response; +} + +function testConfig(instance: string): RuntimeConfig { + const root = `/tmp/gini-jobs-tests-${import.meta.file}`; + process.env.GINI_STATE_ROOT = root; + process.env.GINI_LOG_ROOT = `${root}-logs`; + rmSync(`${root}/instances/${instance}`, { recursive: true, force: true }); + return { + instance, + port: 7338, + token: "test-token", + provider: { name: "echo", model: "gini-echo-v0" }, + workspaceRoot: "/tmp", + stateRoot: `${root}/instances/${instance}`, + logRoot: `${root}-logs/${instance}` + }; +} + +async function waitForTask(handler: ReturnType, config: RuntimeConfig, taskId: string) { + for (let attempt = 0; attempt < 200; attempt += 1) { + const detail = await call(handler, config, `/api/tasks/${taskId}`); + if (["completed", "failed", "waiting_approval", "cancelled"].includes(detail.task.status)) return detail; + await Bun.sleep(10); + } + throw new Error(`Task did not settle: ${taskId}`); +} + +async function waitFor(predicate: () => boolean, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (predicate()) return; + await Bun.sleep(10); + } + if (!predicate()) throw new Error("waitFor: predicate never became true"); +} diff --git a/packages/runtime/src/state/email-watchers.part1.test.ts b/packages/runtime/src/state/email-watchers.part1.test.ts new file mode 100644 index 00000000..ce3ce91a --- /dev/null +++ b/packages/runtime/src/state/email-watchers.part1.test.ts @@ -0,0 +1,664 @@ +// CRUD + query-building tests for email watchers (ADR email-watch.md). + +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { RuntimeConfig } from "../types"; +import "../hooks/builtins"; // populates the registry so createScheduledJob resolves isKnownHook("skill-script") +import { createScheduledJob } from "../jobs"; +import { + addEmailWatcher, + backfillEmailWatcherJobs, + buildWatcherQuery, + closeAllMemoryDbs, + createAgentRecord, + createChatSession, + getEmailWatcher, + listEmailWatchers, + mutateState, + readState, + removeEmailWatcher, + renameChatSession, + setEmailTriageEnabled, + setEmailWatcherEnabled, + setEmailWatcherObjective, + clearEmailWatcherObjective, + updateEmailWatcher, + validateObjective, + validateThreadId +} from "."; +import { accountSelectionHint, resolveWatchAccount } from "./email-watchers"; + +const ROOT = mkdtempSync(join(tmpdir(), "gini-email-watchers-test-")); + +// Isolate the machine-global google-accounts registry (resolved under +// process.env.HOME) so account→configDir resolution sees a CONTROLLED registry, +// not the developer's real signed-in accounts — otherwise the watch-shape +// assertions would non-deterministically pick up a live account's configDir. +const PRIOR_HOME = process.env.HOME; + +beforeAll(() => { + process.env.GINI_STATE_ROOT = ROOT; + process.env.GINI_LOG_ROOT = `${ROOT}-logs`; + process.env.HOME = ROOT; +}); + +afterAll(() => { + closeAllMemoryDbs(); + if (PRIOR_HOME === undefined) delete process.env.HOME; + else process.env.HOME = PRIOR_HOME; + rmSync(ROOT, { recursive: true, force: true }); + rmSync(`${ROOT}-logs`, { recursive: true, force: true }); +}); + +function buildConfig(instance: string): RuntimeConfig { + return { + instance, + port: 0, + token: "test", + provider: { name: "echo", model: "" }, + workspaceRoot: ROOT, + stateRoot: ROOT, + logRoot: `${ROOT}-logs` + }; +} + +describe("buildWatcherQuery", () => { + test("raw query wins over sender", () => { + expect(buildWatcherQuery({ sender: "a@x.com", query: "subject:urgent" })).toBe("subject:urgent"); + }); + test("sender builds from: — no is:unread (read-elsewhere race)", () => { + expect(buildWatcherQuery({ sender: "a@x.com" })).toBe("from:a@x.com"); + }); + test("no sender/query falls back to in:inbox, never an empty q", () => { + expect(buildWatcherQuery({})).toBe("in:inbox"); + }); + test("threadId builds a thread: label and wins over sender", () => { + expect(buildWatcherQuery({ threadId: "t-123", sender: "a@x.com" })).toBe("thread:t-123"); + }); +}); + +describe("resolveWatchAccount", () => { + const A = { email: "sheldenshi@gmail.com", configDir: "/dir/gacct_a", signedIn: true }; + const B = { email: "work@lilaclabs.ai", configDir: "/dir/gacct_b", signedIn: true }; + + test("no registered accounts => default gws (no configDir, no warning)", () => { + expect(resolveWatchAccount(undefined, [])).toEqual({}); + expect(resolveWatchAccount("sheldenshi@gmail.com", [])).toEqual({}); + }); + + test("unset accountEmail binds to the single registered+signed-in account", () => { + expect(resolveWatchAccount(undefined, [A])).toEqual({ configDir: A.configDir, account: A.email }); + }); + + test("unset accountEmail with a registered-but-signed-out account stays on default gws", () => { + expect(resolveWatchAccount(undefined, [{ ...A, signedIn: false }])).toEqual({}); + }); + + test("unset accountEmail is ambiguous across multiple signed-in accounts => default gws", () => { + expect(resolveWatchAccount(undefined, [A, B])).toEqual({}); + }); + + test("set accountEmail resolves to the matching account's configDir (case-insensitive)", () => { + expect(resolveWatchAccount("SHELDENSHI@gmail.com", [A, B])).toEqual({ + configDir: A.configDir, + account: A.email + }); + }); + + test("set accountEmail not registered => no configDir + a visible warning", () => { + const r = resolveWatchAccount("ghost@nowhere.com", [A]); + expect(r.configDir).toBeUndefined(); + expect(r.account).toBeUndefined(); + expect(r.warning).toContain("ghost@nowhere.com"); + expect(r.warning).toContain("not a registered"); + }); +}); + +describe("accountSelectionHint", () => { + const A = { email: "sheldenshi@gmail.com", configDir: "/dir/gacct_a", signedIn: true }; + const B = { email: "work@lilaclabs.ai", configDir: "/dir/gacct_b", signedIn: true }; + + test("no account + 2 signed-in accounts => ask the user, listing both", () => { + const hint = accountSelectionHint(undefined, [A, B]); + expect(hint).toBeDefined(); + expect(hint).toContain("sheldenshi@gmail.com"); + expect(hint).toContain("work@lilaclabs.ai"); + expect(hint).toContain("ask_user"); + }); + + test("no account + exactly one signed-in account => no hint (auto-defaults)", () => { + expect(accountSelectionHint(undefined, [A])).toBeUndefined(); + // A signed-out sibling doesn't count toward the ambiguity. + expect(accountSelectionHint(undefined, [A, { ...B, signedIn: false }])).toBeUndefined(); + }); + + test("no account + zero accounts => no hint (single-account back-compat)", () => { + expect(accountSelectionHint(undefined, [])).toBeUndefined(); + }); + + test("an explicit account => no hint even with multiple connected", () => { + expect(accountSelectionHint("work@lilaclabs.ai", [A, B])).toBeUndefined(); + }); +}); + +describe("watcher CRUD", () => { + test("add creates an enabled watcher with a dedicated chat session", async () => { + const config = buildConfig("ew-add"); + const watcher = await addEmailWatcher(config, { sender: "alice@x.com" }); + expect(watcher.enabled).toBe(true); + expect(watcher.status).toBe("ok"); + expect(watcher.query).toBe("from:alice@x.com"); + expect(watcher.chatSessionId).toBeDefined(); + // The dedicated chat session exists. + const state = readState(config.instance); + expect(state.chatSessions.some((s) => s.id === watcher.chatSessionId)).toBe(true); + expect(state.emailWatchers).toHaveLength(1); + }); + + test("list + get reflect the created watcher", async () => { + const config = buildConfig("ew-list"); + const watcher = await addEmailWatcher(config, { query: "subject:invoice is:unread" }); + expect(listEmailWatchers(config).map((w) => w.id)).toContain(watcher.id); + expect(getEmailWatcher(config, watcher.id)?.query).toBe("subject:invoice is:unread"); + }); + + test("update patches fields and bumps updatedAt", async () => { + const config = buildConfig("ew-update"); + const watcher = await addEmailWatcher(config, { sender: "bob@x.com" }); + const updated = await updateEmailWatcher(config, watcher.id, { + query: "from:bob@x.com newer_than:1d", + status: "needs_auth" + }); + expect(updated?.query).toBe("from:bob@x.com newer_than:1d"); + expect(updated?.status).toBe("needs_auth"); + }); + + test("update on a missing watcher returns undefined", async () => { + const config = buildConfig("ew-update-missing"); + expect(await updateEmailWatcher(config, "nope", { status: "ok" })).toBeUndefined(); + }); + + test("remove deletes the watcher", async () => { + const config = buildConfig("ew-remove"); + const watcher = await addEmailWatcher(config, { sender: "carol@x.com" }); + await removeEmailWatcher(config, watcher.id); + expect(getEmailWatcher(config, watcher.id)).toBeUndefined(); + expect(listEmailWatchers(config)).toHaveLength(0); + }); + + test("remove on a missing watcher throws", async () => { + const config = buildConfig("ew-remove-missing"); + await expect(removeEmailWatcher(config, "nope")).rejects.toThrow("Email watcher not found"); + }); + + test("list is agent-scoped when an agentId is given; unscoped returns all", async () => { + const config = buildConfig("ew-list-scope"); + // Real registered agents — the store re-stamps any watcher whose agentId + // doesn't point at an existing agent back to the default, so synthetic ids + // wouldn't survive a state reload. + const { agentA, agentB } = await mutateState(config.instance, (state) => ({ + agentA: createAgentRecord(state, { name: "agent-a", toolsets: [], messagingTargets: [] }).id, + agentB: createAgentRecord(state, { name: "agent-b", toolsets: [], messagingTargets: [] }).id + })); + const a = await addEmailWatcher(config, { sender: "a@x.com", agentId: agentA }); + const b = await addEmailWatcher(config, { sender: "b@x.com", agentId: agentB }); + // Each agent sees ONLY its own watcher. + expect(listEmailWatchers(config, agentA).map((w) => w.id)).toEqual([a.id]); + expect(listEmailWatchers(config, agentB).map((w) => w.id)).toEqual([b.id]); + // A different (unrelated) agent sees none of the above. + expect(listEmailWatchers(config, "agent-none")).toHaveLength(0); + // No agentId (back-compat for internal callers) returns all. + expect(listEmailWatchers(config).map((w) => w.id).sort()).toEqual([a.id, b.id].sort()); + }); +}); + +describe("shared backing job lifecycle", () => { + // Find the agent's ONE shared email-watch job by its stable marker. + function sharedJob(config: ReturnType) { + return readState(config.instance).jobs.find( + (j) => j.preRunHook?.handlerId === "skill-script" && + (j.preRunHook.config as { skill?: string }).skill === "gmail-watch" + ); + } + // The per-watcher watch entries only — the constant triage watch + // (routeKey/watcherId "triage", a broad in:inbox watch provisioned alongside + // the shared job) is filtered out so these assertions pin the per-watcher list. + function watches(config: ReturnType) { + const job = sharedJob(config); + const all = (job?.preRunHook?.config as { watches?: { watcherId: string; routeKey?: string; query: string; sender?: string }[] }).watches ?? []; + return all.filter((w) => w.watcherId !== "triage"); + } + + test("first add provisions ONE shared job + session and stamps jobId", async () => { + const config = buildConfig("ew-job-add"); + const watcher = await addEmailWatcher(config, { sender: "dave@x.com" }); + expect(watcher.jobId).toBeString(); + expect(watcher.chatSessionId).toBeString(); + const job = sharedJob(config); + expect(job).toBeDefined(); + expect(job?.id).toBe(watcher.jobId!); + expect(job?.name).toBe("Email watch"); + expect(job?.preRunHook?.handlerId).toBe("skill-script"); + const hookConfig = job?.preRunHook?.config as { skill?: string; script?: string }; + expect(hookConfig.skill).toBe("gmail-watch"); + expect(hookConfig.script).toBe("detect"); + expect(job?.chatSessionId).toBe(watcher.chatSessionId); + expect(job?.intervalSeconds).toBe(60); + // The shared job's watch list carries this enabled watcher, including the + // explicitly watched sender (the detection script's heuristic bypass key). + expect(watches(config)).toEqual([{ watcherId: watcher.id, routeKey: watcher.id, query: watcher.query, sender: "dave@x.com" }]); + }); + + test("a sender add stores the sender; a raw-query add does not", async () => { + const config = buildConfig("ew-job-sender-field"); + const bySender = await addEmailWatcher(config, { sender: "noreply@ups.com" }); + expect(bySender.sender).toBe("noreply@ups.com"); + // A raw query wins and makes this a raw-query watch — no single sender. + const byQuery = await addEmailWatcher(config, { sender: "x@y.com", query: "subject:urgent" }); + expect(byQuery.sender).toBeUndefined(); + const list = watches(config); + expect(list.find((w) => w.watcherId === bySender.id)).toMatchObject({ sender: "noreply@ups.com" }); + expect((list.find((w) => w.watcherId === byQuery.id) as { sender?: string }).sender).toBeUndefined(); + }); + + test("the shared job's playbook pins thread reading, objective, needs-input, and follow-up rules", async () => { + const config = buildConfig("ew-job-playbook"); + await addEmailWatcher(config, { sender: "alice@x.com" }); + const prompt = sharedJob(config)!.prompt; + // The matched email's body is provided in the item — draft from it directly. + expect(prompt).toContain("matched email's `body` is INCLUDED in its match item — draft your reply directly from it"); + // The thread is optional prior context, fetched by exact id, never via search. + expect(prompt).toContain("the FULL Gmail THREAD for prior context"); + expect(prompt).toContain("NEVER search by subject, sender, or keywords to locate it"); + // A failed thread fetch must NOT bail — draft from the provided body anyway. + expect(prompt).toContain("DRAFT FROM THE PROVIDED BODY anyway"); + // The id/threadId/from are safe identifiers; only the content is untrusted. + expect(prompt).toContain("SAFE structured identifiers"); + expect(prompt).toContain("Using the id to fetch is not 'following' the email"); + // Objective awareness: authoritative standing instructions per watch. + expect(prompt).toContain("accompanied by an Objective"); + expect(prompt).toContain("authoritative for what the reply should achieve"); + // Needs-input rule: only when the body+objective genuinely lack a fact, not + // because a fetch failed. + expect(prompt).toContain("⏸ Needs your input"); + expect(prompt).toContain("never merely because a thread fetch failed"); + expect(prompt).toContain("[PLACEHOLDER:"); + // Follow-up nudges draft a polite follow-up as a normal proposed reply. + expect(prompt).toContain("gone silent on a watched thread"); + // The standing safety rules survive the rewrite. + expect(prompt).toContain("UNTRUSTED quoted data"); + expect(prompt).toContain("Do NOT send it."); + expect(prompt).toContain("[SILENT]"); + // The deliverable is an `email-draft` CARD: the worker SAVES a real threaded + // Gmail draft via `+reply … --draft` and emits its DraftId/Account so the + // card's Send button works — so a watch-drafted reply renders as a sendable + // draft card like an interactive Gmail draft, and the calendar preview is + // reserved for a specific-time meeting. + expect(prompt).toContain("an `email-draft` card, never plain prose"); + expect(prompt).toContain("```email-draft"); + expect(prompt).toContain("save the reply as a threaded Gmail draft"); + expect(prompt).toContain("--draft --format json"); + expect(prompt).toContain("a `DraftId:` line"); + expect(prompt).toContain("never omit them"); + expect(prompt).not.toContain("OMIT any `DraftId`/`Account` lines"); + expect(prompt).toContain("meeting at a SPECIFIC date and time"); + expect(prompt).not.toContain("compose a PROPOSED reply and post it in this chat"); + }); + + test("a second add reuses the SAME shared job + session and appends to watches", async () => { + const config = buildConfig("ew-job-share"); + const w1 = await addEmailWatcher(config, { sender: "alice@x.com" }); + const w2 = await addEmailWatcher(config, { sender: "bob@x.com" }); + // ONE shared job + ONE shared session for both senders. + expect(w2.jobId).toBe(w1.jobId); + expect(w2.chatSessionId).toBe(w1.chatSessionId); + const jobs = readState(config.instance).jobs.filter( + (j) => (j.preRunHook?.config as { skill?: string })?.skill === "gmail-watch" + ); + expect(jobs).toHaveLength(1); + // ONE shared session bound to the job (each concern also has its OWN channel, + // which is why we target the job-bound session, not every "Email watch" title). + expect(jobs[0]!.chatSessionId).toBe(w1.chatSessionId); + // Both watches are listed. + const list = watches(config); + expect(new Set(list.map((w) => w.watcherId))).toEqual(new Set([w1.id, w2.id])); + // Each concern got its OWN per-concern channel, and the shared job routes each + // bucket into that channel. + expect(w1.channelId).toBeString(); + expect(w2.channelId).toBeString(); + expect(w1.channelId).not.toBe(w2.channelId); + const routes = jobs[0]!.routes ?? {}; + expect(routes[w1.id]?.chatSessionId).toBe(w1.channelId!); + expect(routes[w2.id]?.chatSessionId).toBe(w2.channelId!); + }); + + test("a duplicate add (same thread or sender) returns the existing watcher, no new channel/route", async () => { + const config = buildConfig("ew-dedup"); + // Sender watch: a second add with the same sender returns the same watcher. + const s1 = await addEmailWatcher(config, { sender: "alice@x.com" }); + const s2 = await addEmailWatcher(config, { sender: "alice@x.com" }); + expect(s2.id).toBe(s1.id); + // Thread watch: a second add with the same thread returns the same watcher. + const t1 = await addEmailWatcher(config, { threadId: "t-dup" }); + const t2 = await addEmailWatcher(config, { threadId: "t-dup" }); + expect(t2.id).toBe(t1.id); + + const state = readState(config.instance); + // Exactly two distinct watchers (one sender, one thread) — no duplicates. + expect(state.emailWatchers).toHaveLength(2); + // One channel per distinct concern (plus the shared session); triage is opt-in, + // so no triage channel exists here. The duplicate adds minted no extra channels. + const channels = state.chatSessions.filter((s) => s.feature === "email-watch" && s.kind === "channel"); + expect(channels.some((s) => s.title === "Inbox triage")).toBe(false); + // shared session + 2 concern channels = 3. + expect(channels).toHaveLength(3); + const jobs = state.jobs.filter((j) => (j.preRunHook?.config as { skill?: string })?.skill === "gmail-watch"); + const routes = jobs[0]!.routes ?? {}; + // Routes for exactly the two watchers — no triage route (opt-in), no dup keys. + expect(Object.keys(routes).sort()).toEqual([s1.id, t1.id].sort()); + }); + + test("removing one of several rebuilds watches but keeps the shared job + session", async () => { + const config = buildConfig("ew-job-remove-one"); + const w1 = await addEmailWatcher(config, { sender: "alice@x.com" }); + const w2 = await addEmailWatcher(config, { sender: "bob@x.com" }); + const jobId = w1.jobId!; + await removeEmailWatcher(config, w1.id); + const state = readState(config.instance); + // Shared job + session survive (w2 still watching); watch list rebuilt to w2. + expect(state.jobs.find((j) => j.id === jobId)).toBeDefined(); + expect(state.chatSessions.find((s) => s.id === w1.chatSessionId)).toBeDefined(); + expect(watches(config).map((w) => w.watcherId)).toEqual([w2.id]); + }); + + test("removing the LAST watcher tears down the shared job + session", async () => { + const config = buildConfig("ew-job-remove-last"); + const watcher = await addEmailWatcher(config, { sender: "erin@x.com" }); + const jobId = watcher.jobId!; + const sessionId = watcher.chatSessionId!; + await removeEmailWatcher(config, watcher.id); + const state = readState(config.instance); + expect(state.emailWatchers.find((w) => w.id === watcher.id)).toBeUndefined(); + expect(state.jobs.find((j) => j.id === jobId)).toBeUndefined(); + expect(state.chatSessions.find((s) => s.id === sessionId)).toBeUndefined(); + }); + + test("backfill on legacy watchers (no shared job) provisions ONE and wires them", async () => { + const config = buildConfig("ew-job-backfill-legacy"); + const w1 = await addEmailWatcher(config, { sender: "heidi@x.com" }); + const w2 = await addEmailWatcher(config, { sender: "ivan@x.com" }); + // Model legacy pre-consolidation state: no shared job, dangling jobIds. + await mutateState(config.instance, (state) => { + state.jobs = state.jobs.filter( + (j) => (j.preRunHook?.config as { skill?: string })?.skill !== "gmail-watch" + ); + for (const w of state.emailWatchers) w.jobId = "stale-job-id"; + }); + const provisioned = await backfillEmailWatcherJobs(config); + // ONE shared job provisioned for the agent (not one per watcher). + expect(provisioned).toBe(1); + const jobs = readState(config.instance).jobs.filter( + (j) => (j.preRunHook?.config as { skill?: string })?.skill === "gmail-watch" + ); + expect(jobs).toHaveLength(1); + // Both watchers re-stamped to the shared job; the watch list carries both. + expect(getEmailWatcher(config, w1.id)?.jobId).toBe(jobs[0]!.id); + expect(getEmailWatcher(config, w2.id)?.jobId).toBe(jobs[0]!.id); + expect(new Set(watches(config).map((w) => w.watcherId))).toEqual(new Set([w1.id, w2.id])); + }); + + test("concurrent adds provision EXACTLY one shared job + one session", async () => { + const config = buildConfig("ew-job-concurrent-add"); + // Two adds from independent entrypoints racing the same find-then-create: + // without the per-agent provisioning lock both observe "no shared job" and + // create a duplicate job + session. + const [w1, w2] = await Promise.all([ + addEmailWatcher(config, { sender: "mallory@x.com" }), + addEmailWatcher(config, { sender: "trent@x.com" }) + ]); + const jobs = readState(config.instance).jobs.filter( + (j) => (j.preRunHook?.config as { skill?: string })?.skill === "gmail-watch" + ); + expect(jobs).toHaveLength(1); + // Both watchers point at the one shared job + its bound shared session (each + // also has its own per-concern channel, so we target the job-bound session). + expect(w1.jobId).toBe(jobs[0]!.id); + expect(w2.jobId).toBe(jobs[0]!.id); + expect(w1.chatSessionId).toBe(jobs[0]!.chatSessionId); + expect(w2.chatSessionId).toBe(jobs[0]!.chatSessionId); + // Both senders are in the shared watch list. + expect(new Set(watches(config).map((w) => w.watcherId))).toEqual(new Set([w1.id, w2.id])); + }); + + test("startup backfill racing an incoming add yields ONE shared job + session", async () => { + const config = buildConfig("ew-job-backfill-vs-add"); + // Seed a legacy watcher with no shared job (pre-consolidation), the state the + // un-awaited startup backfill reconciles. A fresh add fires concurrently from + // a different entrypoint — both must converge on the one shared job. + const legacy = await addEmailWatcher(config, { sender: "peggy@x.com" }); + await mutateState(config.instance, (state) => { + state.jobs = state.jobs.filter( + (j) => (j.preRunHook?.config as { skill?: string })?.skill !== "gmail-watch" + ); + // Drop the now-orphaned shared session too so the only "Email watch" + // session counted below is the one provisioning recreates. + state.chatSessions = state.chatSessions.filter((s) => s.title !== "Email watch"); + for (const w of state.emailWatchers) { + w.jobId = "stale-job-id"; + w.chatSessionId = "stale-session-id"; + } + }); + const [, added] = await Promise.all([ + backfillEmailWatcherJobs(config), + addEmailWatcher(config, { sender: "victor@x.com" }) + ]); + const jobs = readState(config.instance).jobs.filter( + (j) => (j.preRunHook?.config as { skill?: string })?.skill === "gmail-watch" + ); + expect(jobs).toHaveLength(1); + // Exactly one shared session — the one bound to the surviving shared job. + expect(jobs[0]!.chatSessionId).toBeString(); + expect( + readState(config.instance).chatSessions.filter((s) => s.id === jobs[0]!.chatSessionId) + ).toHaveLength(1); + // Both the legacy and the freshly-added watcher end up on the one shared job. + expect(getEmailWatcher(config, legacy.id)?.jobId).toBe(jobs[0]!.id); + expect(getEmailWatcher(config, added.id)?.jobId).toBe(jobs[0]!.id); + }); + + test("backfill is idempotent: an existing shared job is reconciled, not duplicated", async () => { + const config = buildConfig("ew-job-backfill-idempotent"); + await addEmailWatcher(config, { sender: "grace@x.com" }); + const before = readState(config.instance).jobs.filter( + (j) => (j.preRunHook?.config as { skill?: string })?.skill === "gmail-watch" + ); + expect(before).toHaveLength(1); + const provisioned = await backfillEmailWatcherJobs(config); + // Existing shared job reconciled — no new provision, no duplicate. + expect(provisioned).toBe(0); + const after = readState(config.instance).jobs.filter( + (j) => (j.preRunHook?.config as { skill?: string })?.skill === "gmail-watch" + ); + expect(after).toHaveLength(1); + expect(after[0]!.id).toBe(before[0]!.id); + }); + + test("backfill leaves an agent with only disabled watchers alone", async () => { + const config = buildConfig("ew-job-backfill-disabled"); + const watcher = await addEmailWatcher(config, { sender: "jane@x.com" }); + // Disabling the only watcher tears the shared job down. + await setEmailWatcherEnabled(config, watcher.id, false); + expect( + readState(config.instance).jobs.filter((j) => (j.preRunHook?.config as { skill?: string })?.skill === "gmail-watch") + ).toHaveLength(0); + const provisioned = await backfillEmailWatcherJobs(config); + // No enabled watchers => no shared job provisioned. + expect(provisioned).toBe(0); + expect( + readState(config.instance).jobs.filter((j) => (j.preRunHook?.config as { skill?: string })?.skill === "gmail-watch") + ).toHaveLength(0); + }); + + test("disable drops a watcher from the shared watch list; enable re-adds it", async () => { + const config = buildConfig("ew-job-toggle"); + const w1 = await addEmailWatcher(config, { sender: "judy@x.com" }); + const w2 = await addEmailWatcher(config, { sender: "kyle@x.com" }); + // Disable w1: the shared job + session stay (w2 enabled), w1 leaves the list. + await setEmailWatcherEnabled(config, w1.id, false); + expect(getEmailWatcher(config, w1.id)?.enabled).toBe(false); + expect(watches(config).map((w) => w.watcherId)).toEqual([w2.id]); + expect(sharedJob(config)).toBeDefined(); + // Re-enable w1: it returns to the watch list. + await setEmailWatcherEnabled(config, w1.id, true); + expect(getEmailWatcher(config, w1.id)?.enabled).toBe(true); + expect(new Set(watches(config).map((w) => w.watcherId))).toEqual(new Set([w1.id, w2.id])); + }); + + test("disabling the last enabled watcher tears the shared job down; enable recreates it", async () => { + const config = buildConfig("ew-job-toggle-last"); + const watcher = await addEmailWatcher(config, { sender: "leo@x.com" }); + await setEmailWatcherEnabled(config, watcher.id, false); + // Shared job + session gone, the disabled record's jobId cleared. + expect(sharedJob(config)).toBeUndefined(); + expect(getEmailWatcher(config, watcher.id)?.jobId).toBeUndefined(); + // Re-enable recreates the shared job + session and re-stamps the record. + const reenabled = await setEmailWatcherEnabled(config, watcher.id, true); + expect(sharedJob(config)).toBeDefined(); + expect(reenabled?.jobId).toBe(sharedJob(config)!.id); + expect(watches(config).map((w) => w.watcherId)).toEqual([watcher.id]); + }); + + test("backfill heals EXACT legacy auto-built query shapes only", async () => { + const config = buildConfig("ew-job-heal-queries"); + const bySender = await addEmailWatcher(config, { sender: "alice@x.com" }); + const catchAll = await addEmailWatcher(config, { query: "placeholder" }); + const rawWithUnread = await addEmailWatcher(config, { query: "subject:invoice is:unread" }); + // Model legacy records: the retired auto-built shapes plus a raw query that + // merely CONTAINS is:unread (must never be rewritten). + await mutateState(config.instance, (state) => { + state.emailWatchers.find((w) => w.id === bySender.id)!.query = "from:alice@x.com is:unread"; + state.emailWatchers.find((w) => w.id === catchAll.id)!.query = "is:unread"; + }); + await backfillEmailWatcherJobs(config); + // Exact legacy shapes rewritten... + expect(getEmailWatcher(config, bySender.id)?.query).toBe("from:alice@x.com"); + expect(getEmailWatcher(config, catchAll.id)?.query).toBe("in:inbox"); + // ...and the user-supplied raw query untouched. + expect(getEmailWatcher(config, rawWithUnread.id)?.query).toBe("subject:invoice is:unread"); + // The shared job's watch list carries the healed queries. + const queries = new Set(watches(config).map((w) => w.query)); + expect(queries).toEqual(new Set(["from:alice@x.com", "in:inbox", "subject:invoice is:unread"])); + }); + + test("heal runs once: a later user-created from:X is:unread survives the next backfill", async () => { + const config = buildConfig("ew-job-heal-once"); + // First backfill stamps the run-once marker (no legacy data to rewrite). + await backfillEmailWatcherJobs(config); + expect(readState(config.instance).emailWatcherQueryHealedAt).toBeDefined(); + // The user now deliberately creates a raw query that is byte-identical to + // the retired auto-built shape. A second backfill must NOT rewrite it. + const raw = await addEmailWatcher(config, { query: "from:x@y.com is:unread" }); + await backfillEmailWatcherJobs(config); + expect(getEmailWatcher(config, raw.id)?.query).toBe("from:x@y.com is:unread"); + }); + + test("backfill self-heals adopted titles + orphan jobs/sessions from old->new transitions", async () => { + const config = buildConfig("ew-job-backfill-heal"); + // Real consolidated state: one shared job + session, two watchers on it. + const w1 = await addEmailWatcher(config, { sender: "alice@x.com" }); + const w2 = await addEmailWatcher(config, { sender: "bob@x.com" }); + const sharedJobId = w1.jobId!; + const sharedSessionId = w1.chatSessionId!; + expect(w2.jobId).toBe(sharedJobId); + // Opt this agent into whole-inbox triage so the heal must keep the live + // triage channel (triage is opt-in; a plain sender watch provisions none). + await setEmailTriageEnabled(config, readState(config.instance).activeAgentId, true); + + // An ORPHAN duplicate gmail-watch job (watches:[]) with its own session — the + // residue of a pre-atomicity-fix race. The session carries the email-watch + // feature marker the way every real shared session does. + const orphanSession = await mutateState(config.instance, (state) => { + const created = createChatSession(state, "Email watch: stale@x.com", undefined, undefined, "job", "channel"); + created.feature = "email-watch"; + return created; + }); + const orphanJob = await createScheduledJob(config, { + name: "Email watch", + prompt: "stale", + intervalSeconds: 60, + chatSessionId: orphanSession.id, + preRunHook: { handlerId: "skill-script", config: { skill: "gmail-watch", script: "detect", watches: [] } } + }); + expect(orphanJob.id).not.toBe(sharedJobId); + + // The shared session AND job were ADOPTED from old per-sender code and never + // renamed (both keep the "Email watch: " label the sidebar renders), + // plus a truly-orphan (marker-carrying) "Email watch: " channel + // referenced by nothing (its job was already removed out-of-band). A DECOY + // channel is titled like an email-watch channel but carries NO feature + // marker — it must survive (proves cleanup is identity-based, not by title). + const { trulyOrphanChannel, decoyChannel } = await mutateState(config.instance, (state) => { + renameChatSession(state, sharedSessionId, "Email watch: alice@x.com"); + const sharedJobRecord = state.jobs.find((j) => j.id === sharedJobId); + if (sharedJobRecord) sharedJobRecord.name = "Email watch: alice@x.com"; + const orphan = createChatSession(state, "Email watch: bob@x.com", undefined, undefined, "job", "channel"); + orphan.feature = "email-watch"; + const decoy = createChatSession(state, "Email watch: decoy@x.com", undefined, undefined, "job", "channel"); + return { trulyOrphanChannel: orphan, decoyChannel: decoy }; + }); + + await backfillEmailWatcherJobs(config); + + const state = readState(config.instance); + // Exactly ONE gmail-watch job: the shared one; the orphan duplicate is gone. + const gmailJobs = state.jobs.filter( + (j) => (j.preRunHook?.config as { skill?: string })?.skill === "gmail-watch" + ); + expect(gmailJobs).toHaveLength(1); + expect(gmailJobs[0]!.id).toBe(sharedJobId); + // The adopted job's name was renamed to the canonical "Email watch" so the + // sidebar (which renders job.name) no longer shows "Email watch: ". + expect(gmailJobs[0]!.name).toBe("Email watch"); + // The SHARED session (job-bound) was adopted, renamed to the canonical title, + // and marker-backfilled — distinct from the two per-concern channels, which are + // referenced by the live watchers and so survive the identity sweep. + const sharedSession = state.chatSessions.find((s) => s.id === sharedSessionId); + expect(sharedSession?.title).toBe("Email watch"); + expect(sharedSession?.feature).toBe("email-watch"); + const concernChannelIds = new Set( + [w1, w2].map((w) => getEmailWatcher(config, w.id)?.channelId).filter((id): id is string => Boolean(id)) + ); + expect(concernChannelIds.size).toBe(2); + // The MARKED email-watch sessions are exactly the shared session + the two + // live concern channels + the triage channel; the orphan job's session + the + // truly-orphan channel were swept by identity. + const triageChannelId = state.chatSessions.find( + (s) => s.kind === "channel" && s.feature === "email-watch" && s.title === "Inbox triage" + )?.id; + expect(triageChannelId).toBeString(); + const emailSessions = state.chatSessions.filter((s) => s.feature === "email-watch"); + expect(new Set(emailSessions.map((s) => s.id))).toEqual(new Set([sharedSessionId, triageChannelId!, ...concernChannelIds])); + expect(state.chatSessions.some((s) => s.id === orphanSession.id)).toBe(false); + expect(state.chatSessions.some((s) => s.id === trulyOrphanChannel.id)).toBe(false); + // The decoy — titled like an email-watch channel but WITHOUT the marker — is + // NOT swept: cleanup matches by identity (feature marker), not by title. + expect(state.chatSessions.some((s) => s.id === decoyChannel.id)).toBe(true); + // Watchers still point at the shared job + session. + expect(getEmailWatcher(config, w1.id)?.jobId).toBe(sharedJobId); + expect(getEmailWatcher(config, w2.id)?.jobId).toBe(sharedJobId); + expect(getEmailWatcher(config, w1.id)?.chatSessionId).toBe(sharedSessionId); + + // A second run is a no-op (idempotent): nothing left to heal. + const jobsBefore = state.jobs.length; + const sessionsBefore = state.chatSessions.length; + await backfillEmailWatcherJobs(config); + const after = readState(config.instance); + expect(after.jobs).toHaveLength(jobsBefore); + expect(after.chatSessions).toHaveLength(sessionsBefore); + // Shared session + the two live per-concern channels + the triage channel + // survive; nothing else is created or swept on the idempotent second pass. + expect(after.chatSessions.filter((s) => s.feature === "email-watch")).toHaveLength(4); + }); +}); diff --git a/packages/runtime/src/state/email-watchers.test.ts b/packages/runtime/src/state/email-watchers.part2.test.ts similarity index 53% rename from packages/runtime/src/state/email-watchers.test.ts rename to packages/runtime/src/state/email-watchers.part2.test.ts index 004c15f1..98d09d2e 100644 --- a/packages/runtime/src/state/email-watchers.test.ts +++ b/packages/runtime/src/state/email-watchers.part2.test.ts @@ -64,156 +64,6 @@ function buildConfig(instance: string): RuntimeConfig { }; } -describe("buildWatcherQuery", () => { - test("raw query wins over sender", () => { - expect(buildWatcherQuery({ sender: "a@x.com", query: "subject:urgent" })).toBe("subject:urgent"); - }); - test("sender builds from: — no is:unread (read-elsewhere race)", () => { - expect(buildWatcherQuery({ sender: "a@x.com" })).toBe("from:a@x.com"); - }); - test("no sender/query falls back to in:inbox, never an empty q", () => { - expect(buildWatcherQuery({})).toBe("in:inbox"); - }); - test("threadId builds a thread: label and wins over sender", () => { - expect(buildWatcherQuery({ threadId: "t-123", sender: "a@x.com" })).toBe("thread:t-123"); - }); -}); - -describe("resolveWatchAccount", () => { - const A = { email: "sheldenshi@gmail.com", configDir: "/dir/gacct_a", signedIn: true }; - const B = { email: "work@lilaclabs.ai", configDir: "/dir/gacct_b", signedIn: true }; - - test("no registered accounts => default gws (no configDir, no warning)", () => { - expect(resolveWatchAccount(undefined, [])).toEqual({}); - expect(resolveWatchAccount("sheldenshi@gmail.com", [])).toEqual({}); - }); - - test("unset accountEmail binds to the single registered+signed-in account", () => { - expect(resolveWatchAccount(undefined, [A])).toEqual({ configDir: A.configDir, account: A.email }); - }); - - test("unset accountEmail with a registered-but-signed-out account stays on default gws", () => { - expect(resolveWatchAccount(undefined, [{ ...A, signedIn: false }])).toEqual({}); - }); - - test("unset accountEmail is ambiguous across multiple signed-in accounts => default gws", () => { - expect(resolveWatchAccount(undefined, [A, B])).toEqual({}); - }); - - test("set accountEmail resolves to the matching account's configDir (case-insensitive)", () => { - expect(resolveWatchAccount("SHELDENSHI@gmail.com", [A, B])).toEqual({ - configDir: A.configDir, - account: A.email - }); - }); - - test("set accountEmail not registered => no configDir + a visible warning", () => { - const r = resolveWatchAccount("ghost@nowhere.com", [A]); - expect(r.configDir).toBeUndefined(); - expect(r.account).toBeUndefined(); - expect(r.warning).toContain("ghost@nowhere.com"); - expect(r.warning).toContain("not a registered"); - }); -}); - -describe("accountSelectionHint", () => { - const A = { email: "sheldenshi@gmail.com", configDir: "/dir/gacct_a", signedIn: true }; - const B = { email: "work@lilaclabs.ai", configDir: "/dir/gacct_b", signedIn: true }; - - test("no account + 2 signed-in accounts => ask the user, listing both", () => { - const hint = accountSelectionHint(undefined, [A, B]); - expect(hint).toBeDefined(); - expect(hint).toContain("sheldenshi@gmail.com"); - expect(hint).toContain("work@lilaclabs.ai"); - expect(hint).toContain("ask_user"); - }); - - test("no account + exactly one signed-in account => no hint (auto-defaults)", () => { - expect(accountSelectionHint(undefined, [A])).toBeUndefined(); - // A signed-out sibling doesn't count toward the ambiguity. - expect(accountSelectionHint(undefined, [A, { ...B, signedIn: false }])).toBeUndefined(); - }); - - test("no account + zero accounts => no hint (single-account back-compat)", () => { - expect(accountSelectionHint(undefined, [])).toBeUndefined(); - }); - - test("an explicit account => no hint even with multiple connected", () => { - expect(accountSelectionHint("work@lilaclabs.ai", [A, B])).toBeUndefined(); - }); -}); - -describe("watcher CRUD", () => { - test("add creates an enabled watcher with a dedicated chat session", async () => { - const config = buildConfig("ew-add"); - const watcher = await addEmailWatcher(config, { sender: "alice@x.com" }); - expect(watcher.enabled).toBe(true); - expect(watcher.status).toBe("ok"); - expect(watcher.query).toBe("from:alice@x.com"); - expect(watcher.chatSessionId).toBeDefined(); - // The dedicated chat session exists. - const state = readState(config.instance); - expect(state.chatSessions.some((s) => s.id === watcher.chatSessionId)).toBe(true); - expect(state.emailWatchers).toHaveLength(1); - }); - - test("list + get reflect the created watcher", async () => { - const config = buildConfig("ew-list"); - const watcher = await addEmailWatcher(config, { query: "subject:invoice is:unread" }); - expect(listEmailWatchers(config).map((w) => w.id)).toContain(watcher.id); - expect(getEmailWatcher(config, watcher.id)?.query).toBe("subject:invoice is:unread"); - }); - - test("update patches fields and bumps updatedAt", async () => { - const config = buildConfig("ew-update"); - const watcher = await addEmailWatcher(config, { sender: "bob@x.com" }); - const updated = await updateEmailWatcher(config, watcher.id, { - query: "from:bob@x.com newer_than:1d", - status: "needs_auth" - }); - expect(updated?.query).toBe("from:bob@x.com newer_than:1d"); - expect(updated?.status).toBe("needs_auth"); - }); - - test("update on a missing watcher returns undefined", async () => { - const config = buildConfig("ew-update-missing"); - expect(await updateEmailWatcher(config, "nope", { status: "ok" })).toBeUndefined(); - }); - - test("remove deletes the watcher", async () => { - const config = buildConfig("ew-remove"); - const watcher = await addEmailWatcher(config, { sender: "carol@x.com" }); - await removeEmailWatcher(config, watcher.id); - expect(getEmailWatcher(config, watcher.id)).toBeUndefined(); - expect(listEmailWatchers(config)).toHaveLength(0); - }); - - test("remove on a missing watcher throws", async () => { - const config = buildConfig("ew-remove-missing"); - await expect(removeEmailWatcher(config, "nope")).rejects.toThrow("Email watcher not found"); - }); - - test("list is agent-scoped when an agentId is given; unscoped returns all", async () => { - const config = buildConfig("ew-list-scope"); - // Real registered agents — the store re-stamps any watcher whose agentId - // doesn't point at an existing agent back to the default, so synthetic ids - // wouldn't survive a state reload. - const { agentA, agentB } = await mutateState(config.instance, (state) => ({ - agentA: createAgentRecord(state, { name: "agent-a", toolsets: [], messagingTargets: [] }).id, - agentB: createAgentRecord(state, { name: "agent-b", toolsets: [], messagingTargets: [] }).id - })); - const a = await addEmailWatcher(config, { sender: "a@x.com", agentId: agentA }); - const b = await addEmailWatcher(config, { sender: "b@x.com", agentId: agentB }); - // Each agent sees ONLY its own watcher. - expect(listEmailWatchers(config, agentA).map((w) => w.id)).toEqual([a.id]); - expect(listEmailWatchers(config, agentB).map((w) => w.id)).toEqual([b.id]); - // A different (unrelated) agent sees none of the above. - expect(listEmailWatchers(config, "agent-none")).toHaveLength(0); - // No agentId (back-compat for internal callers) returns all. - expect(listEmailWatchers(config).map((w) => w.id).sort()).toEqual([a.id, b.id].sort()); - }); -}); - describe("thread-keyed watchers", () => { test("add with threadId stores the id, a thread: label, and no sender", async () => { const config = buildConfig("ew-thread-add"); @@ -330,455 +180,6 @@ describe("watcher objective", () => { }); }); -describe("shared backing job lifecycle", () => { - // Find the agent's ONE shared email-watch job by its stable marker. - function sharedJob(config: ReturnType) { - return readState(config.instance).jobs.find( - (j) => j.preRunHook?.handlerId === "skill-script" && - (j.preRunHook.config as { skill?: string }).skill === "gmail-watch" - ); - } - // The per-watcher watch entries only — the constant triage watch - // (routeKey/watcherId "triage", a broad in:inbox watch provisioned alongside - // the shared job) is filtered out so these assertions pin the per-watcher list. - function watches(config: ReturnType) { - const job = sharedJob(config); - const all = (job?.preRunHook?.config as { watches?: { watcherId: string; routeKey?: string; query: string; sender?: string }[] }).watches ?? []; - return all.filter((w) => w.watcherId !== "triage"); - } - - test("first add provisions ONE shared job + session and stamps jobId", async () => { - const config = buildConfig("ew-job-add"); - const watcher = await addEmailWatcher(config, { sender: "dave@x.com" }); - expect(watcher.jobId).toBeString(); - expect(watcher.chatSessionId).toBeString(); - const job = sharedJob(config); - expect(job).toBeDefined(); - expect(job?.id).toBe(watcher.jobId!); - expect(job?.name).toBe("Email watch"); - expect(job?.preRunHook?.handlerId).toBe("skill-script"); - const hookConfig = job?.preRunHook?.config as { skill?: string; script?: string }; - expect(hookConfig.skill).toBe("gmail-watch"); - expect(hookConfig.script).toBe("detect"); - expect(job?.chatSessionId).toBe(watcher.chatSessionId); - expect(job?.intervalSeconds).toBe(60); - // The shared job's watch list carries this enabled watcher, including the - // explicitly watched sender (the detection script's heuristic bypass key). - expect(watches(config)).toEqual([{ watcherId: watcher.id, routeKey: watcher.id, query: watcher.query, sender: "dave@x.com" }]); - }); - - test("a sender add stores the sender; a raw-query add does not", async () => { - const config = buildConfig("ew-job-sender-field"); - const bySender = await addEmailWatcher(config, { sender: "noreply@ups.com" }); - expect(bySender.sender).toBe("noreply@ups.com"); - // A raw query wins and makes this a raw-query watch — no single sender. - const byQuery = await addEmailWatcher(config, { sender: "x@y.com", query: "subject:urgent" }); - expect(byQuery.sender).toBeUndefined(); - const list = watches(config); - expect(list.find((w) => w.watcherId === bySender.id)).toMatchObject({ sender: "noreply@ups.com" }); - expect((list.find((w) => w.watcherId === byQuery.id) as { sender?: string }).sender).toBeUndefined(); - }); - - test("the shared job's playbook pins thread reading, objective, needs-input, and follow-up rules", async () => { - const config = buildConfig("ew-job-playbook"); - await addEmailWatcher(config, { sender: "alice@x.com" }); - const prompt = sharedJob(config)!.prompt; - // The matched email's body is provided in the item — draft from it directly. - expect(prompt).toContain("matched email's `body` is INCLUDED in its match item — draft your reply directly from it"); - // The thread is optional prior context, fetched by exact id, never via search. - expect(prompt).toContain("the FULL Gmail THREAD for prior context"); - expect(prompt).toContain("NEVER search by subject, sender, or keywords to locate it"); - // A failed thread fetch must NOT bail — draft from the provided body anyway. - expect(prompt).toContain("DRAFT FROM THE PROVIDED BODY anyway"); - // The id/threadId/from are safe identifiers; only the content is untrusted. - expect(prompt).toContain("SAFE structured identifiers"); - expect(prompt).toContain("Using the id to fetch is not 'following' the email"); - // Objective awareness: authoritative standing instructions per watch. - expect(prompt).toContain("accompanied by an Objective"); - expect(prompt).toContain("authoritative for what the reply should achieve"); - // Needs-input rule: only when the body+objective genuinely lack a fact, not - // because a fetch failed. - expect(prompt).toContain("⏸ Needs your input"); - expect(prompt).toContain("never merely because a thread fetch failed"); - expect(prompt).toContain("[PLACEHOLDER:"); - // Follow-up nudges draft a polite follow-up as a normal proposed reply. - expect(prompt).toContain("gone silent on a watched thread"); - // The standing safety rules survive the rewrite. - expect(prompt).toContain("UNTRUSTED quoted data"); - expect(prompt).toContain("Do NOT send it."); - expect(prompt).toContain("[SILENT]"); - // The deliverable is an `email-draft` CARD: the worker SAVES a real threaded - // Gmail draft via `+reply … --draft` and emits its DraftId/Account so the - // card's Send button works — so a watch-drafted reply renders as a sendable - // draft card like an interactive Gmail draft, and the calendar preview is - // reserved for a specific-time meeting. - expect(prompt).toContain("an `email-draft` card, never plain prose"); - expect(prompt).toContain("```email-draft"); - expect(prompt).toContain("save the reply as a threaded Gmail draft"); - expect(prompt).toContain("--draft --format json"); - expect(prompt).toContain("a `DraftId:` line"); - expect(prompt).toContain("never omit them"); - expect(prompt).not.toContain("OMIT any `DraftId`/`Account` lines"); - expect(prompt).toContain("meeting at a SPECIFIC date and time"); - expect(prompt).not.toContain("compose a PROPOSED reply and post it in this chat"); - }); - - test("a second add reuses the SAME shared job + session and appends to watches", async () => { - const config = buildConfig("ew-job-share"); - const w1 = await addEmailWatcher(config, { sender: "alice@x.com" }); - const w2 = await addEmailWatcher(config, { sender: "bob@x.com" }); - // ONE shared job + ONE shared session for both senders. - expect(w2.jobId).toBe(w1.jobId); - expect(w2.chatSessionId).toBe(w1.chatSessionId); - const jobs = readState(config.instance).jobs.filter( - (j) => (j.preRunHook?.config as { skill?: string })?.skill === "gmail-watch" - ); - expect(jobs).toHaveLength(1); - // ONE shared session bound to the job (each concern also has its OWN channel, - // which is why we target the job-bound session, not every "Email watch" title). - expect(jobs[0]!.chatSessionId).toBe(w1.chatSessionId); - // Both watches are listed. - const list = watches(config); - expect(new Set(list.map((w) => w.watcherId))).toEqual(new Set([w1.id, w2.id])); - // Each concern got its OWN per-concern channel, and the shared job routes each - // bucket into that channel. - expect(w1.channelId).toBeString(); - expect(w2.channelId).toBeString(); - expect(w1.channelId).not.toBe(w2.channelId); - const routes = jobs[0]!.routes ?? {}; - expect(routes[w1.id]?.chatSessionId).toBe(w1.channelId!); - expect(routes[w2.id]?.chatSessionId).toBe(w2.channelId!); - }); - - test("a duplicate add (same thread or sender) returns the existing watcher, no new channel/route", async () => { - const config = buildConfig("ew-dedup"); - // Sender watch: a second add with the same sender returns the same watcher. - const s1 = await addEmailWatcher(config, { sender: "alice@x.com" }); - const s2 = await addEmailWatcher(config, { sender: "alice@x.com" }); - expect(s2.id).toBe(s1.id); - // Thread watch: a second add with the same thread returns the same watcher. - const t1 = await addEmailWatcher(config, { threadId: "t-dup" }); - const t2 = await addEmailWatcher(config, { threadId: "t-dup" }); - expect(t2.id).toBe(t1.id); - - const state = readState(config.instance); - // Exactly two distinct watchers (one sender, one thread) — no duplicates. - expect(state.emailWatchers).toHaveLength(2); - // One channel per distinct concern (plus the shared session); triage is opt-in, - // so no triage channel exists here. The duplicate adds minted no extra channels. - const channels = state.chatSessions.filter((s) => s.feature === "email-watch" && s.kind === "channel"); - expect(channels.some((s) => s.title === "Inbox triage")).toBe(false); - // shared session + 2 concern channels = 3. - expect(channels).toHaveLength(3); - const jobs = state.jobs.filter((j) => (j.preRunHook?.config as { skill?: string })?.skill === "gmail-watch"); - const routes = jobs[0]!.routes ?? {}; - // Routes for exactly the two watchers — no triage route (opt-in), no dup keys. - expect(Object.keys(routes).sort()).toEqual([s1.id, t1.id].sort()); - }); - - test("removing one of several rebuilds watches but keeps the shared job + session", async () => { - const config = buildConfig("ew-job-remove-one"); - const w1 = await addEmailWatcher(config, { sender: "alice@x.com" }); - const w2 = await addEmailWatcher(config, { sender: "bob@x.com" }); - const jobId = w1.jobId!; - await removeEmailWatcher(config, w1.id); - const state = readState(config.instance); - // Shared job + session survive (w2 still watching); watch list rebuilt to w2. - expect(state.jobs.find((j) => j.id === jobId)).toBeDefined(); - expect(state.chatSessions.find((s) => s.id === w1.chatSessionId)).toBeDefined(); - expect(watches(config).map((w) => w.watcherId)).toEqual([w2.id]); - }); - - test("removing the LAST watcher tears down the shared job + session", async () => { - const config = buildConfig("ew-job-remove-last"); - const watcher = await addEmailWatcher(config, { sender: "erin@x.com" }); - const jobId = watcher.jobId!; - const sessionId = watcher.chatSessionId!; - await removeEmailWatcher(config, watcher.id); - const state = readState(config.instance); - expect(state.emailWatchers.find((w) => w.id === watcher.id)).toBeUndefined(); - expect(state.jobs.find((j) => j.id === jobId)).toBeUndefined(); - expect(state.chatSessions.find((s) => s.id === sessionId)).toBeUndefined(); - }); - - test("backfill on legacy watchers (no shared job) provisions ONE and wires them", async () => { - const config = buildConfig("ew-job-backfill-legacy"); - const w1 = await addEmailWatcher(config, { sender: "heidi@x.com" }); - const w2 = await addEmailWatcher(config, { sender: "ivan@x.com" }); - // Model legacy pre-consolidation state: no shared job, dangling jobIds. - await mutateState(config.instance, (state) => { - state.jobs = state.jobs.filter( - (j) => (j.preRunHook?.config as { skill?: string })?.skill !== "gmail-watch" - ); - for (const w of state.emailWatchers) w.jobId = "stale-job-id"; - }); - const provisioned = await backfillEmailWatcherJobs(config); - // ONE shared job provisioned for the agent (not one per watcher). - expect(provisioned).toBe(1); - const jobs = readState(config.instance).jobs.filter( - (j) => (j.preRunHook?.config as { skill?: string })?.skill === "gmail-watch" - ); - expect(jobs).toHaveLength(1); - // Both watchers re-stamped to the shared job; the watch list carries both. - expect(getEmailWatcher(config, w1.id)?.jobId).toBe(jobs[0]!.id); - expect(getEmailWatcher(config, w2.id)?.jobId).toBe(jobs[0]!.id); - expect(new Set(watches(config).map((w) => w.watcherId))).toEqual(new Set([w1.id, w2.id])); - }); - - test("concurrent adds provision EXACTLY one shared job + one session", async () => { - const config = buildConfig("ew-job-concurrent-add"); - // Two adds from independent entrypoints racing the same find-then-create: - // without the per-agent provisioning lock both observe "no shared job" and - // create a duplicate job + session. - const [w1, w2] = await Promise.all([ - addEmailWatcher(config, { sender: "mallory@x.com" }), - addEmailWatcher(config, { sender: "trent@x.com" }) - ]); - const jobs = readState(config.instance).jobs.filter( - (j) => (j.preRunHook?.config as { skill?: string })?.skill === "gmail-watch" - ); - expect(jobs).toHaveLength(1); - // Both watchers point at the one shared job + its bound shared session (each - // also has its own per-concern channel, so we target the job-bound session). - expect(w1.jobId).toBe(jobs[0]!.id); - expect(w2.jobId).toBe(jobs[0]!.id); - expect(w1.chatSessionId).toBe(jobs[0]!.chatSessionId); - expect(w2.chatSessionId).toBe(jobs[0]!.chatSessionId); - // Both senders are in the shared watch list. - expect(new Set(watches(config).map((w) => w.watcherId))).toEqual(new Set([w1.id, w2.id])); - }); - - test("startup backfill racing an incoming add yields ONE shared job + session", async () => { - const config = buildConfig("ew-job-backfill-vs-add"); - // Seed a legacy watcher with no shared job (pre-consolidation), the state the - // un-awaited startup backfill reconciles. A fresh add fires concurrently from - // a different entrypoint — both must converge on the one shared job. - const legacy = await addEmailWatcher(config, { sender: "peggy@x.com" }); - await mutateState(config.instance, (state) => { - state.jobs = state.jobs.filter( - (j) => (j.preRunHook?.config as { skill?: string })?.skill !== "gmail-watch" - ); - // Drop the now-orphaned shared session too so the only "Email watch" - // session counted below is the one provisioning recreates. - state.chatSessions = state.chatSessions.filter((s) => s.title !== "Email watch"); - for (const w of state.emailWatchers) { - w.jobId = "stale-job-id"; - w.chatSessionId = "stale-session-id"; - } - }); - const [, added] = await Promise.all([ - backfillEmailWatcherJobs(config), - addEmailWatcher(config, { sender: "victor@x.com" }) - ]); - const jobs = readState(config.instance).jobs.filter( - (j) => (j.preRunHook?.config as { skill?: string })?.skill === "gmail-watch" - ); - expect(jobs).toHaveLength(1); - // Exactly one shared session — the one bound to the surviving shared job. - expect(jobs[0]!.chatSessionId).toBeString(); - expect( - readState(config.instance).chatSessions.filter((s) => s.id === jobs[0]!.chatSessionId) - ).toHaveLength(1); - // Both the legacy and the freshly-added watcher end up on the one shared job. - expect(getEmailWatcher(config, legacy.id)?.jobId).toBe(jobs[0]!.id); - expect(getEmailWatcher(config, added.id)?.jobId).toBe(jobs[0]!.id); - }); - - test("backfill is idempotent: an existing shared job is reconciled, not duplicated", async () => { - const config = buildConfig("ew-job-backfill-idempotent"); - await addEmailWatcher(config, { sender: "grace@x.com" }); - const before = readState(config.instance).jobs.filter( - (j) => (j.preRunHook?.config as { skill?: string })?.skill === "gmail-watch" - ); - expect(before).toHaveLength(1); - const provisioned = await backfillEmailWatcherJobs(config); - // Existing shared job reconciled — no new provision, no duplicate. - expect(provisioned).toBe(0); - const after = readState(config.instance).jobs.filter( - (j) => (j.preRunHook?.config as { skill?: string })?.skill === "gmail-watch" - ); - expect(after).toHaveLength(1); - expect(after[0]!.id).toBe(before[0]!.id); - }); - - test("backfill leaves an agent with only disabled watchers alone", async () => { - const config = buildConfig("ew-job-backfill-disabled"); - const watcher = await addEmailWatcher(config, { sender: "jane@x.com" }); - // Disabling the only watcher tears the shared job down. - await setEmailWatcherEnabled(config, watcher.id, false); - expect( - readState(config.instance).jobs.filter((j) => (j.preRunHook?.config as { skill?: string })?.skill === "gmail-watch") - ).toHaveLength(0); - const provisioned = await backfillEmailWatcherJobs(config); - // No enabled watchers => no shared job provisioned. - expect(provisioned).toBe(0); - expect( - readState(config.instance).jobs.filter((j) => (j.preRunHook?.config as { skill?: string })?.skill === "gmail-watch") - ).toHaveLength(0); - }); - - test("disable drops a watcher from the shared watch list; enable re-adds it", async () => { - const config = buildConfig("ew-job-toggle"); - const w1 = await addEmailWatcher(config, { sender: "judy@x.com" }); - const w2 = await addEmailWatcher(config, { sender: "kyle@x.com" }); - // Disable w1: the shared job + session stay (w2 enabled), w1 leaves the list. - await setEmailWatcherEnabled(config, w1.id, false); - expect(getEmailWatcher(config, w1.id)?.enabled).toBe(false); - expect(watches(config).map((w) => w.watcherId)).toEqual([w2.id]); - expect(sharedJob(config)).toBeDefined(); - // Re-enable w1: it returns to the watch list. - await setEmailWatcherEnabled(config, w1.id, true); - expect(getEmailWatcher(config, w1.id)?.enabled).toBe(true); - expect(new Set(watches(config).map((w) => w.watcherId))).toEqual(new Set([w1.id, w2.id])); - }); - - test("disabling the last enabled watcher tears the shared job down; enable recreates it", async () => { - const config = buildConfig("ew-job-toggle-last"); - const watcher = await addEmailWatcher(config, { sender: "leo@x.com" }); - await setEmailWatcherEnabled(config, watcher.id, false); - // Shared job + session gone, the disabled record's jobId cleared. - expect(sharedJob(config)).toBeUndefined(); - expect(getEmailWatcher(config, watcher.id)?.jobId).toBeUndefined(); - // Re-enable recreates the shared job + session and re-stamps the record. - const reenabled = await setEmailWatcherEnabled(config, watcher.id, true); - expect(sharedJob(config)).toBeDefined(); - expect(reenabled?.jobId).toBe(sharedJob(config)!.id); - expect(watches(config).map((w) => w.watcherId)).toEqual([watcher.id]); - }); - - test("backfill heals EXACT legacy auto-built query shapes only", async () => { - const config = buildConfig("ew-job-heal-queries"); - const bySender = await addEmailWatcher(config, { sender: "alice@x.com" }); - const catchAll = await addEmailWatcher(config, { query: "placeholder" }); - const rawWithUnread = await addEmailWatcher(config, { query: "subject:invoice is:unread" }); - // Model legacy records: the retired auto-built shapes plus a raw query that - // merely CONTAINS is:unread (must never be rewritten). - await mutateState(config.instance, (state) => { - state.emailWatchers.find((w) => w.id === bySender.id)!.query = "from:alice@x.com is:unread"; - state.emailWatchers.find((w) => w.id === catchAll.id)!.query = "is:unread"; - }); - await backfillEmailWatcherJobs(config); - // Exact legacy shapes rewritten... - expect(getEmailWatcher(config, bySender.id)?.query).toBe("from:alice@x.com"); - expect(getEmailWatcher(config, catchAll.id)?.query).toBe("in:inbox"); - // ...and the user-supplied raw query untouched. - expect(getEmailWatcher(config, rawWithUnread.id)?.query).toBe("subject:invoice is:unread"); - // The shared job's watch list carries the healed queries. - const queries = new Set(watches(config).map((w) => w.query)); - expect(queries).toEqual(new Set(["from:alice@x.com", "in:inbox", "subject:invoice is:unread"])); - }); - - test("heal runs once: a later user-created from:X is:unread survives the next backfill", async () => { - const config = buildConfig("ew-job-heal-once"); - // First backfill stamps the run-once marker (no legacy data to rewrite). - await backfillEmailWatcherJobs(config); - expect(readState(config.instance).emailWatcherQueryHealedAt).toBeDefined(); - // The user now deliberately creates a raw query that is byte-identical to - // the retired auto-built shape. A second backfill must NOT rewrite it. - const raw = await addEmailWatcher(config, { query: "from:x@y.com is:unread" }); - await backfillEmailWatcherJobs(config); - expect(getEmailWatcher(config, raw.id)?.query).toBe("from:x@y.com is:unread"); - }); - - test("backfill self-heals adopted titles + orphan jobs/sessions from old->new transitions", async () => { - const config = buildConfig("ew-job-backfill-heal"); - // Real consolidated state: one shared job + session, two watchers on it. - const w1 = await addEmailWatcher(config, { sender: "alice@x.com" }); - const w2 = await addEmailWatcher(config, { sender: "bob@x.com" }); - const sharedJobId = w1.jobId!; - const sharedSessionId = w1.chatSessionId!; - expect(w2.jobId).toBe(sharedJobId); - // Opt this agent into whole-inbox triage so the heal must keep the live - // triage channel (triage is opt-in; a plain sender watch provisions none). - await setEmailTriageEnabled(config, readState(config.instance).activeAgentId, true); - - // An ORPHAN duplicate gmail-watch job (watches:[]) with its own session — the - // residue of a pre-atomicity-fix race. The session carries the email-watch - // feature marker the way every real shared session does. - const orphanSession = await mutateState(config.instance, (state) => { - const created = createChatSession(state, "Email watch: stale@x.com", undefined, undefined, "job", "channel"); - created.feature = "email-watch"; - return created; - }); - const orphanJob = await createScheduledJob(config, { - name: "Email watch", - prompt: "stale", - intervalSeconds: 60, - chatSessionId: orphanSession.id, - preRunHook: { handlerId: "skill-script", config: { skill: "gmail-watch", script: "detect", watches: [] } } - }); - expect(orphanJob.id).not.toBe(sharedJobId); - - // The shared session AND job were ADOPTED from old per-sender code and never - // renamed (both keep the "Email watch: " label the sidebar renders), - // plus a truly-orphan (marker-carrying) "Email watch: " channel - // referenced by nothing (its job was already removed out-of-band). A DECOY - // channel is titled like an email-watch channel but carries NO feature - // marker — it must survive (proves cleanup is identity-based, not by title). - const { trulyOrphanChannel, decoyChannel } = await mutateState(config.instance, (state) => { - renameChatSession(state, sharedSessionId, "Email watch: alice@x.com"); - const sharedJobRecord = state.jobs.find((j) => j.id === sharedJobId); - if (sharedJobRecord) sharedJobRecord.name = "Email watch: alice@x.com"; - const orphan = createChatSession(state, "Email watch: bob@x.com", undefined, undefined, "job", "channel"); - orphan.feature = "email-watch"; - const decoy = createChatSession(state, "Email watch: decoy@x.com", undefined, undefined, "job", "channel"); - return { trulyOrphanChannel: orphan, decoyChannel: decoy }; - }); - - await backfillEmailWatcherJobs(config); - - const state = readState(config.instance); - // Exactly ONE gmail-watch job: the shared one; the orphan duplicate is gone. - const gmailJobs = state.jobs.filter( - (j) => (j.preRunHook?.config as { skill?: string })?.skill === "gmail-watch" - ); - expect(gmailJobs).toHaveLength(1); - expect(gmailJobs[0]!.id).toBe(sharedJobId); - // The adopted job's name was renamed to the canonical "Email watch" so the - // sidebar (which renders job.name) no longer shows "Email watch: ". - expect(gmailJobs[0]!.name).toBe("Email watch"); - // The SHARED session (job-bound) was adopted, renamed to the canonical title, - // and marker-backfilled — distinct from the two per-concern channels, which are - // referenced by the live watchers and so survive the identity sweep. - const sharedSession = state.chatSessions.find((s) => s.id === sharedSessionId); - expect(sharedSession?.title).toBe("Email watch"); - expect(sharedSession?.feature).toBe("email-watch"); - const concernChannelIds = new Set( - [w1, w2].map((w) => getEmailWatcher(config, w.id)?.channelId).filter((id): id is string => Boolean(id)) - ); - expect(concernChannelIds.size).toBe(2); - // The MARKED email-watch sessions are exactly the shared session + the two - // live concern channels + the triage channel; the orphan job's session + the - // truly-orphan channel were swept by identity. - const triageChannelId = state.chatSessions.find( - (s) => s.kind === "channel" && s.feature === "email-watch" && s.title === "Inbox triage" - )?.id; - expect(triageChannelId).toBeString(); - const emailSessions = state.chatSessions.filter((s) => s.feature === "email-watch"); - expect(new Set(emailSessions.map((s) => s.id))).toEqual(new Set([sharedSessionId, triageChannelId!, ...concernChannelIds])); - expect(state.chatSessions.some((s) => s.id === orphanSession.id)).toBe(false); - expect(state.chatSessions.some((s) => s.id === trulyOrphanChannel.id)).toBe(false); - // The decoy — titled like an email-watch channel but WITHOUT the marker — is - // NOT swept: cleanup matches by identity (feature marker), not by title. - expect(state.chatSessions.some((s) => s.id === decoyChannel.id)).toBe(true); - // Watchers still point at the shared job + session. - expect(getEmailWatcher(config, w1.id)?.jobId).toBe(sharedJobId); - expect(getEmailWatcher(config, w2.id)?.jobId).toBe(sharedJobId); - expect(getEmailWatcher(config, w1.id)?.chatSessionId).toBe(sharedSessionId); - - // A second run is a no-op (idempotent): nothing left to heal. - const jobsBefore = state.jobs.length; - const sessionsBefore = state.chatSessions.length; - await backfillEmailWatcherJobs(config); - const after = readState(config.instance); - expect(after.jobs).toHaveLength(jobsBefore); - expect(after.chatSessions).toHaveLength(sessionsBefore); - // Shared session + the two live per-concern channels + the triage channel - // survive; nothing else is created or swept on the idempotent second pass. - expect(after.chatSessions.filter((s) => s.feature === "email-watch")).toHaveLength(4); - }); -}); - describe("derived watcher health (per-watcher byWatcher state)", () => { // Write the detection script's per-watch health blob onto the shared job's // hookState.byWatcher the way a tick would, then assert the email read path