diff --git a/AGENTS.md b/AGENTS.md index 1b70c7347b..6e2c3834af 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,3 +43,10 @@ Prefer the narrowest test layer that proves the behavior. This follows standard - Use `apps/vscode-e2e` only when the behavior depends on the real VS Code extension host, VS Code workspace APIs, extension activation, webview/extension messaging, file watcher behavior, or a complete user workflow. - Keep e2e tests focused on high-value smoke coverage across boundaries. Avoid placing detailed protocol, parsing, storage, retry, or edge-case assertions in e2e when they can be covered reliably at a lower layer. - When fixing a regression, add the regression test at the lowest layer that would have failed for the bug. Add an e2e test only if lower-level tests cannot represent the failure mode. + +## Maintaining this file + +Keep this file for knowledge useful to almost every future agent session in this project. +Do not repeat what the codebase already shows; point to the authoritative file or command instead. +Prefer rewriting or pruning existing entries over appending new ones. +When updating this file, preserve this bar for all agents and keep entries concise. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000000..47dc3e3d86 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/packages/zoo-protocol/eslint-suppressions.json b/packages/zoo-protocol/eslint-suppressions.json new file mode 100644 index 0000000000..7a73a41bfd --- /dev/null +++ b/packages/zoo-protocol/eslint-suppressions.json @@ -0,0 +1,2 @@ +{ +} \ No newline at end of file diff --git a/packages/zoo-protocol/eslint.config.mjs b/packages/zoo-protocol/eslint.config.mjs new file mode 100644 index 0000000000..694bf73664 --- /dev/null +++ b/packages/zoo-protocol/eslint.config.mjs @@ -0,0 +1,4 @@ +import { config } from "@roo-code/config-eslint/base" + +/** @type {import("eslint").Linter.Config} */ +export default [...config] diff --git a/packages/zoo-protocol/package.json b/packages/zoo-protocol/package.json new file mode 100644 index 0000000000..e0d7871fe9 --- /dev/null +++ b/packages/zoo-protocol/package.json @@ -0,0 +1,24 @@ +{ + "name": "@roo-code/zoo-protocol", + "description": "Private versioned contracts shared by the Zoo CLI client and host.", + "private": true, + "type": "module", + "exports": "./src/index.ts", + "types": "./src/index.ts", + "scripts": { + "lint": "eslint src --ext=ts --max-warnings=0", + "check-types": "tsc --noEmit", + "test": "vitest run", + "build": "tsc", + "clean": "rimraf dist .turbo" + }, + "dependencies": { + "zod": "3.25.76" + }, + "devDependencies": { + "@roo-code/config-eslint": "workspace:^", + "@roo-code/config-typescript": "workspace:^", + "@types/node": "22.20.1", + "vitest": "4.1.9" + } +} diff --git a/packages/zoo-protocol/src/__tests__/contracts.test.ts b/packages/zoo-protocol/src/__tests__/contracts.test.ts new file mode 100644 index 0000000000..c8d1da1bae --- /dev/null +++ b/packages/zoo-protocol/src/__tests__/contracts.test.ts @@ -0,0 +1,2543 @@ +import { + EXIT_CODES, + ZOO_HOST_PROTOCOL_VERSION, + assertAuthoritativeRootResult, + createHostEventStreamParser, + compareSemanticTraces, + exitContextSchema, + exitCodeFor, + hostCommandSchema, + hostEventSchema, + hostHelloSchema, + negotiateProtocol, + parentHelloSchema, + parityScenarios, + redactText, + redactValue, + runDeterministicFakeProvider, + validateCommandLifecycle, + validateMonotonicSequence, + validateNegotiatedStreamSession, + validateParentHello, + validateStreamLifecycle as validateStreamLifecycleContract, + zooRunResultSchema, + zooStreamEventSchema, + zooStreamSchema, +} from "../index.js" + +const timestamp = "2026-08-05T12:00:00.000Z" +const startCommand = hostCommandSchema.parse({ + v: 1, + id: "start", + type: "task.start", + workspace: "/workspace", + prompt: "Start", +}) + +function validateStreamLifecycle( + events: Parameters[0], + commands: Parameters[1] = [], + commandEvents: Parameters[2] = [], + scope?: Parameters[3], +) { + const lastHostSeq = commandEvents.reduce((maximum, event) => Math.max(maximum, event.seq), 0) + const eventEnvelopes = events.map((event, index) => + hostEventSchema.parse({ + v: 1, + seq: lastHostSeq + index + 1, + hostId: event.hostId, + type: "event", + event, + }), + ) + return validateStreamLifecycleContract(events, commands, [...commandEvents, ...eventEnvelopes], scope) +} + +const initEvent = zooStreamEventSchema.parse({ + v: 1, + seq: 1, + timestamp, + hostId: "host", + type: "system.init", + protocol: "zoo-stream", + hostProtocolVersion: 1, + capabilities: ["task:start"], + clientVersion: "1.0.0", + hostVersion: "1.0.0", +}) +if (initEvent.type !== "system.init") throw new Error("Expected system.init fixture") + +function taskEvent(seq: number, type: string, fields: Record = {}) { + return zooStreamEventSchema.parse({ + v: 1, + seq, + timestamp, + hostId: "host", + type, + rootTaskId: "root", + taskId: "root", + ...(type === "task.created" ? { requestId: "start" } : {}), + ...fields, + }) +} + +function resultEvent(seq: number, result: Record = {}, event: Record = {}) { + const outcome = result.outcome ?? "completed" + const parsed = taskEvent(seq, "task.result", { + requestId: "start", + result: { + schemaVersion: 1, + protocol: "zoo-run-result", + success: outcome === "completed", + outcome, + rootTaskId: "root", + workspace: "/workspace", + resumable: false, + elapsedMs: 10, + ...result, + }, + ...event, + }) + if (parsed.type !== "task.result") throw new Error("Expected task.result fixture") + return parsed +} + +function startDone(commandId = "start", rootTaskId = "root", startSeq = 1) { + return [ + hostEventSchema.parse({ v: 1, seq: startSeq, hostId: "host", type: "command.ack", commandId }), + hostEventSchema.parse({ + v: 1, + seq: startSeq + 1, + hostId: "host", + type: "command.done", + commandId, + data: { commandType: "task.start", task: { rootTaskId, taskId: rootTaskId } }, + }), + ] +} + +function resumeDone(commandId = "resume", taskId = "root", startSeq = 1) { + return [ + hostEventSchema.parse({ v: 1, seq: startSeq, hostId: "host", type: "command.ack", commandId }), + hostEventSchema.parse({ + v: 1, + seq: startSeq + 1, + hostId: "host", + type: "command.done", + commandId, + data: { commandType: "task.resume", task: { rootTaskId: "root", taskId } }, + }), + ] +} + +function cancellationDone(commandId = "cancel", hostId = "host", startSeq = 1) { + return [ + hostEventSchema.parse({ v: 1, seq: startSeq, hostId, type: "command.ack", commandId }), + hostEventSchema.parse({ + v: 1, + seq: startSeq + 1, + hostId, + type: "command.done", + commandId, + data: { commandType: "task.cancel", rootTaskId: "root" }, + }), + ] +} + +function cancellationError(commandId = "cancel", startSeq = 1) { + return [ + hostEventSchema.parse({ v: 1, seq: startSeq, hostId: "host", type: "command.ack", commandId }), + hostEventSchema.parse({ + v: 1, + seq: startSeq + 1, + hostId: "host", + type: "command.error", + commandId, + error: { code: "cancel_failed", message: "Task already completed" }, + }), + ] +} + +function askResponseDone(commandId = "respond", hostId = "host", startSeq = 1) { + return [ + hostEventSchema.parse({ v: 1, seq: startSeq, hostId, type: "command.ack", commandId }), + hostEventSchema.parse({ + v: 1, + seq: startSeq + 1, + hostId, + type: "command.done", + commandId, + data: { commandType: "ask.respond", taskId: "root", askId: "ask" }, + }), + ] +} + +function askResponseError(commandId = "respond", startSeq = 1) { + return [ + hostEventSchema.parse({ v: 1, seq: startSeq, hostId: "host", type: "command.ack", commandId }), + hostEventSchema.parse({ + v: 1, + seq: startSeq + 1, + hostId: "host", + type: "command.error", + commandId, + error: { code: "task_failed", message: "Response was not accepted" }, + }), + ] +} + +describe("strict host contracts", () => { + it("accepts a valid start and rejects unknown fields", () => { + const command = { + v: ZOO_HOST_PROTOCOL_VERSION, + id: "command-1", + type: "task.start", + workspace: "/workspace", + prompt: "Fix the test", + overrides: { approval: "safe" }, + } + expect(hostCommandSchema.parse(command)).toEqual(command) + expect(hostCommandSchema.safeParse({ ...command, unexpected: true }).success).toBe(false) + expect(hostCommandSchema.safeParse({ ...command, overrides: { reasoningEffort: "max" } }).success).toBe(true) + expect(hostCommandSchema.safeParse({ ...command, overrides: { reasoningEffort: "disabled" } }).success).toBe( + true, + ) + const formattedPrompt = hostCommandSchema.parse({ ...command, prompt: " formatted prompt\n" }) + expect(formattedPrompt.type === "task.start" && formattedPrompt.prompt).toBe(" formatted prompt\n") + expect(hostCommandSchema.safeParse({ ...command, prompt: " \n\t" }).success).toBe(false) + }) + + it("enforces input and approval payload invariants", () => { + expect(hostCommandSchema.safeParse({ v: 1, id: "1", type: "task.input", taskId: "task" }).success).toBe(false) + expect( + hostCommandSchema.safeParse({ v: 1, id: "1", type: "task.input", taskId: "task", text: " \n" }).success, + ).toBe(false) + expect( + hostCommandSchema.safeParse({ + v: 1, + id: "1", + type: "ask.respond", + taskId: "task", + askId: "ask", + response: "message", + }).success, + ).toBe(false) + expect( + hostCommandSchema.safeParse({ + v: 1, + id: "1", + type: "ask.respond", + taskId: "task", + askId: "ask", + response: "message", + text: " \t", + }).success, + ).toBe(false) + }) + + it("negotiates versions and required capabilities", () => { + const hello = hostHelloSchema.parse({ + type: "hello", + hostId: "host-1", + supportedVersions: [1], + capabilities: { 1: ["task:start", "host:shutdown", "future:additive-capability"] }, + buildVersion: "1.0.0", + }) + expect(negotiateProtocol(hello, [1], ["task:start"])).toEqual({ ok: true, version: 1 }) + expect(negotiateProtocol(hello, [2], ["task:start"])).toMatchObject({ ok: false }) + expect(negotiateProtocol(hello, [1], ["task:resume"])).toMatchObject({ ok: false }) + const multiVersionHello = hostHelloSchema.parse({ + ...hello, + supportedVersions: [1, 2], + capabilities: { 1: ["task:start"], 2: ["task:start", "task:resume"] }, + }) + expect(negotiateProtocol(multiVersionHello, [1], ["task:resume"])).toMatchObject({ ok: false }) + expect(negotiateProtocol(multiVersionHello, [2, 1], ["task:resume"])).toMatchObject({ ok: false }) + const lowerVersionCapabilities = hostHelloSchema.parse({ + ...hello, + supportedVersions: [1, 2], + capabilities: { 1: ["task:start", "task:resume"], 2: ["task:start"] }, + }) + expect(negotiateProtocol(lowerVersionCapabilities, [2, 1], ["task:resume"])).toEqual({ ok: true, version: 1 }) + }) + + it("binds parent selection to the host advertisement", () => { + const host = hostHelloSchema.parse({ + type: "hello", + hostId: "host-1", + supportedVersions: [1, 2], + capabilities: { 1: ["task:start"], 2: ["task:start", "task:resume"] }, + buildVersion: "1.0.0", + }) + const selected = parentHelloSchema.parse({ + type: "hello.select", + version: 2, + clientVersion: "1.0.0", + requiredCapabilities: ["task:resume"], + }) + expect(validateParentHello(host, selected)).toMatchObject({ ok: false }) + expect(validateParentHello(host, { ...selected, version: 3 })).toMatchObject({ ok: false }) + expect(validateParentHello(host, { ...selected, version: 1 })).toMatchObject({ ok: false }) + expect(validateParentHello(host, { ...selected, version: 1, requiredCapabilities: ["task:start"] })).toEqual({ + ok: true, + version: 1, + }) + }) + + it("requires contiguous host sequence numbers", () => { + expect(validateMonotonicSequence(8, 9)).toEqual({ ok: true }) + expect(validateMonotonicSequence(8, 10)).toEqual({ ok: false, expected: 9 }) + expect(validateMonotonicSequence(Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER)).toMatchObject({ ok: false }) + expect( + hostEventSchema.safeParse({ + v: 1, + seq: Number.MAX_SAFE_INTEGER + 1, + hostId: "host", + type: "command.ack", + commandId: "cmd", + }).success, + ).toBe(false) + }) + + it("binds stream initialization to the negotiated session", () => { + const host = hostHelloSchema.parse({ + type: "hello", + hostId: "host", + supportedVersions: [1], + capabilities: { 1: ["task:start", "future:additive-capability"] }, + buildVersion: "1.0.0", + }) + const parent = parentHelloSchema.parse({ + type: "hello.select", + version: 1, + clientVersion: "1.0.0", + requiredCapabilities: ["task:start"], + }) + expect( + validateNegotiatedStreamSession(host, parent, [ + { ...initEvent, capabilities: ["task:start", "future:additive-capability"] }, + ]), + ).toEqual({ ok: true }) + expect(validateNegotiatedStreamSession(host, parent, [{ ...initEvent, hostId: "other" }])).toMatchObject({ + ok: false, + }) + expect(validateNegotiatedStreamSession(host, parent, [{ ...initEvent, capabilities: [] }])).toMatchObject({ + ok: false, + }) + }) + + it("pins host identity and sequence in the streaming parser", () => { + const parser = createHostEventStreamParser({ hostId: "host" }) + expect(() => + createHostEventStreamParser({ hostId: "negotiated-host" }).push({ + v: 1, + seq: 1, + hostId: "other", + type: "host.heartbeat", + monotonicMs: 1, + }), + ).toThrow("cannot span multiple hosts") + parser.push({ v: 1, seq: 4, hostId: "host", type: "host.heartbeat", monotonicMs: 1 }) + expect(() => parser.push({ v: 1, seq: 6, hostId: "host", type: "host.heartbeat", monotonicMs: 2 })).toThrow( + "Expected host sequence 5", + ) + const otherHost = createHostEventStreamParser({ hostId: "host" }) + otherHost.push({ v: 1, seq: 1, hostId: "host", type: "host.heartbeat", monotonicMs: 1 }) + expect(() => otherHost.push({ v: 1, seq: 2, hostId: "other", type: "host.heartbeat", monotonicMs: 2 })).toThrow( + "cannot span multiple hosts", + ) + expect( + hostEventSchema.safeParse({ v: 1, seq: 1, hostId: "host", type: "host.heartbeat", monotonicMs: Infinity }) + .success, + ).toBe(false) + }) + + it("rejects oversized terminal output before stream parsing", () => { + const parser = createHostEventStreamParser({ hostId: "host", maxInputBytes: 4 }) + expect(() => + parser.push({ + v: 1, + seq: 1, + hostId: "host", + type: "event", + event: { + v: 1, + seq: 1, + timestamp, + hostId: "host", + rootTaskId: "root", + taskId: "root", + type: "terminal.output", + toolCallId: "terminal", + stream: "stdout", + delta: "12345", + }, + }), + ).toThrow("input limit") + const escaped = createHostEventStreamParser({ hostId: "host", maxInputBytes: 100 }) + expect(() => + escaped.push({ + v: 1, + seq: 1, + hostId: "host", + type: "host.heartbeat", + monotonicMs: 1, + padding: "\u0000".repeat(30), + }), + ).toThrow("input limit") + }) + + it("does not mutate parser state for an invalid nested event", () => { + const parser = createHostEventStreamParser({ hostId: "host" }) + const invalid = { + v: 1, + seq: 1, + hostId: "host", + type: "event", + event: { + v: 1, + seq: 1, + timestamp, + hostId: "other", + rootTaskId: "root", + taskId: "root", + type: "terminal.output", + toolCallId: "terminal", + stream: "stdout", + delta: "safe\n", + }, + } + expect(() => parser.push(invalid)).toThrow("hostId must match") + expect(parser.push({ ...invalid, event: { ...invalid.event, hostId: "host" } })).toMatchObject([ + { seq: 1, event: { delta: "safe\n" } }, + ]) + expect(parser.flush()).toEqual([]) + }) + + it("models one ACK and terminal command response independently", () => { + const command = hostCommandSchema.parse({ v: 1, id: "cmd", type: "host.shutdown" }) + const events = [ + hostEventSchema.parse({ v: 1, seq: 1, hostId: "host", type: "command.ack", commandId: "cmd" }), + hostEventSchema.parse({ + v: 1, + seq: 2, + hostId: "host", + type: "command.done", + commandId: "cmd", + data: { commandType: "host.shutdown" }, + }), + ] + expect(validateCommandLifecycle([command], events, "host")).toEqual({ ok: true }) + expect(validateCommandLifecycle([command], [...events, events[1]!], "host")).toMatchObject({ ok: false }) + expect(validateCommandLifecycle([command], [events[1]!, events[0]!], "host")).toMatchObject({ ok: false }) + expect(validateCommandLifecycle([command], [events[0]!, { ...events[1]!, seq: 3 }], "host")).toMatchObject({ + ok: false, + }) + }) + + it("correlates terminal responses with commands and hosts", () => { + const command = hostCommandSchema.parse({ + v: 1, + id: "cmd", + type: "ask.respond", + taskId: "task", + askId: "ask", + response: "approve", + }) + const acknowledgement = hostEventSchema.parse({ + v: 1, + seq: 1, + hostId: "host-a", + type: "command.ack", + commandId: "cmd", + }) + const completion = hostEventSchema.parse({ + v: 1, + seq: 2, + hostId: "host-a", + type: "command.done", + commandId: "cmd", + data: { commandType: "ask.respond", taskId: "task", askId: "ask" }, + }) + const mismatchedIdentity = hostEventSchema.parse({ + v: 1, + seq: 2, + hostId: "host-a", + type: "command.done", + commandId: "cmd", + data: { commandType: "ask.respond", taskId: "task", askId: "other" }, + }) + const mismatchedType = hostEventSchema.parse({ + v: 1, + seq: 2, + hostId: "host-a", + type: "command.done", + commandId: "cmd", + data: { commandType: "host.shutdown" }, + }) + expect(validateCommandLifecycle([command], [acknowledgement, completion], "host-a")).toEqual({ ok: true }) + expect(validateCommandLifecycle([command], [acknowledgement, mismatchedIdentity], "host-a")).toMatchObject({ + ok: false, + }) + expect(validateCommandLifecycle([command], [acknowledgement, mismatchedType], "host-a")).toMatchObject({ + ok: false, + }) + expect( + validateCommandLifecycle([command], [acknowledgement, { ...completion, hostId: "host-b" }], "host-a"), + ).toMatchObject({ + ok: false, + }) + }) + + it("rejects missing and mismatched command completion payloads", () => { + const done = { v: 1, seq: 1, hostId: "host", type: "command.done", commandId: "cmd" } + expect(hostEventSchema.safeParse(done).success).toBe(false) + expect( + hostEventSchema.safeParse({ + ...done, + data: { commandType: "task.start", task: { rootTaskId: "root" } }, + }).success, + ).toBe(false) + const start = hostCommandSchema.parse({ + v: 1, + id: "cmd", + type: "task.start", + workspace: "/workspace", + prompt: "start", + }) + const acknowledgement = hostEventSchema.parse({ + v: 1, + seq: 1, + hostId: "host", + type: "command.ack", + commandId: "cmd", + }) + const childCompletion = hostEventSchema.parse({ + ...done, + seq: 2, + data: { commandType: "task.start", task: { rootTaskId: "root", taskId: "child" } }, + }) + expect(validateCommandLifecycle([start], [acknowledgement, childCompletion], "host")).toMatchObject({ + ok: false, + }) + }) + + it("does not reuse root identities across successful starts", () => { + const commands = ["first", "second"].map((id) => + hostCommandSchema.parse({ v: 1, id, type: "task.start", workspace: "/workspace", prompt: id }), + ) + const events = commands.flatMap((command, index) => [ + hostEventSchema.parse({ + v: 1, + seq: index * 2 + 1, + hostId: "host", + type: "command.ack", + commandId: command.id, + }), + hostEventSchema.parse({ + v: 1, + seq: index * 2 + 2, + hostId: "host", + type: "command.done", + commandId: command.id, + data: { commandType: "task.start", task: { rootTaskId: "root", taskId: "root" } }, + }), + ]) + expect(validateCommandLifecycle(commands, events, "host")).toMatchObject({ ok: false }) + }) + + it("redacts command errors before they cross the host boundary", () => { + const parsed = hostEventSchema.parse({ + v: 1, + seq: 1, + hostId: "host", + type: "command.error", + commandId: "command", + error: { code: "provider_failed", message: "password=hunter2", phase: "token=secret" }, + }) + expect(parsed.type === "command.error" && parsed.error.message).toBe("[REDACTED]") + expect(parsed.type === "command.error" && parsed.error.phase).toBe("[REDACTED]") + }) + + it("statefully redacts normalized terminal output at the host boundary", () => { + const parser = createHostEventStreamParser({ hostId: "host" }) + const envelope = (seq: number, delta: string) => ({ + v: 1, + seq, + hostId: "host", + type: "event", + event: { + v: 1, + seq, + timestamp, + hostId: "host", + rootTaskId: "root", + taskId: "root", + type: "terminal.output", + toolCallId: "terminal", + stream: "stdout", + delta, + }, + }) + const events = [ + ...parser.push(envelope(1, "Build succeeded\n")), + ...parser.push(envelope(2, "API_TOKEN=")), + ...parser.push(envelope(3, "abcdefgh")), + ...parser.flush(), + ] + expect( + events + .map((event) => + event.type === "event" && event.event.type === "terminal.output" ? event.event.delta : "", + ) + .join(""), + ).toBe("Build succeeded\n[REDACTED]") + + const interleavedParser = createHostEventStreamParser({ hostId: "host" }) + const interleaved = [ + ...interleavedParser.push(envelope(1, "API_TOKEN=")), + ...interleavedParser.push({ v: 1, seq: 2, hostId: "host", type: "host.heartbeat", monotonicMs: 1 }), + ...interleavedParser.push(envelope(3, "abcdefgh")), + ...interleavedParser.flush(), + ] + expect(interleaved.map((event) => event.seq)).toEqual([1, 2, 3]) + expect( + interleaved + .filter((event) => event.type === "event" && event.event.type === "terminal.output") + .map((event) => + event.type === "event" && event.event.type === "terminal.output" ? event.event.delta : "", + ) + .join(""), + ).toBe("[REDACTED]") + }) + + it("releases blocked envelopes on deadline and byte pressure", () => { + let now = 0 + const terminalEnvelope = (seq: number, delta: string) => ({ + v: 1, + seq, + hostId: "host", + type: "event", + event: { + v: 1, + seq, + timestamp, + hostId: "host", + rootTaskId: "root", + taskId: "root", + type: "terminal.output", + toolCallId: "terminal", + stream: "stdout", + delta, + }, + }) + const deadlineParser = createHostEventStreamParser({ hostId: "host", maxPendingMs: 10, now: () => now }) + expect(deadlineParser.push(terminalEnvelope(1, "unterminated"))).toEqual([]) + now = 10 + expect(deadlineParser.tick()).toMatchObject([{ seq: 1, event: { delta: "[REDACTED]" } }]) + + const byteParser = createHostEventStreamParser({ hostId: "host", maxQueuedBytes: 1 }) + expect(byteParser.push(terminalEnvelope(1, "unterminated"))).toMatchObject([ + { seq: 1, event: { delta: "[REDACTED]" } }, + ]) + + const scopedParser = createHostEventStreamParser({ hostId: "host", maxPendingMs: 10, now: () => now }) + now = 0 + expect(scopedParser.push(terminalEnvelope(1, "unterminated"))).toEqual([]) + now = 10 + expect(scopedParser.tick()).toMatchObject([{ seq: 1, event: { delta: "[REDACTED]" } }]) + expect( + scopedParser.push({ + ...terminalEnvelope(2, "harmless\n"), + event: { ...terminalEnvelope(2, "harmless\n").event, toolCallId: "other-terminal" }, + }), + ).toMatchObject([{ seq: 2, event: { delta: "harmless\n" } }]) + + now = 0 + const multipleParser = createHostEventStreamParser({ hostId: "host", maxPendingMs: 10, now: () => now }) + expect(multipleParser.push(terminalEnvelope(1, "first"))).toEqual([]) + expect( + multipleParser.push({ + ...terminalEnvelope(2, "second"), + event: { ...terminalEnvelope(2, "second").event, toolCallId: "other-terminal" }, + }), + ).toEqual([]) + now = 10 + expect(multipleParser.tick()).toMatchObject([ + { seq: 1, event: { delta: "[REDACTED]" } }, + { seq: 2, event: { delta: "[REDACTED]" } }, + ]) + }) + + it("preserves host envelopes across concurrent root streams", () => { + const parser = createHostEventStreamParser({ hostId: "host" }) + const envelope = (hostSeq: number, rootTaskId: string, delta: string) => ({ + v: 1, + seq: hostSeq, + hostId: "host", + type: "event", + event: { + v: 1, + seq: 1, + timestamp, + hostId: "host", + rootTaskId, + taskId: rootTaskId, + type: "terminal.output", + toolCallId: "terminal", + stream: "stdout", + delta, + }, + }) + const events = [ + ...parser.push(envelope(1, "root-a", "first\n")), + ...parser.push(envelope(2, "root-b", "second\n")), + ...parser.flush(), + ] + expect( + events.map((event) => [ + event.seq, + event.type === "event" && "rootTaskId" in event.event ? event.event.rootTaskId : undefined, + ]), + ).toEqual([ + [1, "root-a"], + [2, "root-b"], + ]) + }) + + it("binds history completion data to its requested workspace", () => { + const command = hostCommandSchema.parse({ + v: 1, + id: "history", + type: "history.list", + workspace: "/workspace", + }) + const acknowledgement = hostEventSchema.parse({ + v: 1, + seq: 1, + hostId: "host", + type: "command.ack", + commandId: "history", + }) + const completion = hostEventSchema.parse({ + v: 1, + seq: 2, + hostId: "host", + type: "command.done", + commandId: "history", + data: { commandType: "history.list", workspace: "/workspace", tasks: [] }, + }) + const mismatchedCompletion = hostEventSchema.parse({ + v: 1, + seq: 2, + hostId: "host", + type: "command.done", + commandId: "history", + data: { commandType: "history.list", workspace: "/other", tasks: [] }, + }) + expect(validateCommandLifecycle([command], [acknowledgement, completion], "host")).toEqual({ ok: true }) + expect(validateCommandLifecycle([command], [acknowledgement, mismatchedCompletion], "host")).toMatchObject({ + ok: false, + }) + }) +}) + +describe("public automation contracts", () => { + it("validates one-object results and semantic success", () => { + const result = { + schemaVersion: 1, + protocol: "zoo-run-result", + success: true, + outcome: "completed", + rootTaskId: "root", + workspace: "/workspace", + resumable: false, + content: "Finished", + elapsedMs: 25, + } + expect(zooRunResultSchema.parse(result)).toEqual(result) + expect(zooRunResultSchema.safeParse({ ...result, success: false }).success).toBe(false) + expect( + zooRunResultSchema.safeParse({ + ...result, + error: { code: "task_failed", message: "contradiction" }, + }).success, + ).toBe(false) + expect(zooRunResultSchema.safeParse({ ...result, resumable: true }).success).toBe(false) + expect( + zooRunResultSchema.safeParse({ + ...result, + success: false, + outcome: "needs_input", + resumable: true, + }).success, + ).toBe(true) + expect( + zooRunResultSchema.safeParse({ + ...result, + success: false, + outcome: "needs_input", + resumable: false, + }).success, + ).toBe(false) + expect( + zooRunResultSchema.safeParse({ + ...result, + success: false, + outcome: "needs_input", + error: { code: "provider_failed", message: "contradiction" }, + }).success, + ).toBe(false) + expect( + zooRunResultSchema.safeParse({ + ...result, + success: false, + outcome: "failed", + error: { code: "task_timed_out", message: "contradiction" }, + }).success, + ).toBe(false) + }) + + it("validates strict, ordered stream records", () => { + const event = { + v: 1, + seq: 1, + timestamp, + hostId: "host", + type: "message.upsert", + rootTaskId: "root", + taskId: "root", + messageId: "message-1", + role: "assistant", + content: "hello", + complete: false, + } + expect(zooStreamEventSchema.parse(event)).toEqual(event) + expect(zooStreamEventSchema.safeParse({ ...event, seq: 0 }).success).toBe(false) + expect(zooStreamEventSchema.safeParse({ ...event, seq: Number.MAX_SAFE_INTEGER + 1 }).success).toBe(false) + expect(zooStreamEventSchema.safeParse({ ...event, rawSecret: "no" }).success).toBe(false) + expect(zooStreamEventSchema.safeParse({ ...event, taskId: undefined }).success).toBe(false) + const tool = { + v: 1, + seq: 1, + timestamp, + hostId: "host", + type: "tool.started", + rootTaskId: "root", + taskId: "root", + toolCallId: "tool", + name: "read", + arguments: { nested: [null, true, 1, "value"] }, + } + expect(zooStreamEventSchema.safeParse(tool).success).toBe(true) + for (const invalid of [Infinity, BigInt(1), undefined, () => undefined]) { + expect(zooStreamEventSchema.safeParse({ ...tool, arguments: { invalid } }).success).toBe(false) + } + expect( + zooStreamEventSchema.safeParse({ ...initEvent, capabilities: ["task:start", "future:additive"] }).success, + ).toBe(true) + }) + + it("requires init, contiguous sequence, and a settled authoritative root", () => { + const created = taskEvent(2, "task.created") + const started = taskEvent(3, "task.started") + const completed = taskEvent(4, "task.lifecycle", { state: "completed" }) + const result = resultEvent(5) + expect( + validateStreamLifecycle([initEvent, created, started, completed, result], [startCommand], startDone()), + ).toEqual({ + ok: true, + }) + const history = hostCommandSchema.parse({ + v: 1, + id: "history", + type: "history.list", + workspace: "/workspace", + }) + expect( + validateStreamLifecycle( + [initEvent, created, started, completed, result], + [startCommand, history], + startDone(), + ), + ).toMatchObject({ ok: false }) + expect( + validateStreamLifecycle( + [initEvent, created, started, completed, result], + [startCommand], + [ + ...startDone(), + hostEventSchema.parse({ v: 1, seq: 3, hostId: "host", type: "command.ack", commandId: "unknown" }), + ], + ), + ).toMatchObject({ ok: false }) + expect( + validateStreamLifecycle( + [initEvent, created, started, completed, resultEvent(5, { workspace: "/other" })], + [startCommand], + ), + ).toMatchObject({ ok: false }) + expect( + validateStreamLifecycle( + [initEvent, created, started, completed, resultEvent(5, {}, { requestId: "other" })], + [startCommand], + ), + ).toMatchObject({ ok: false }) + expect(validateStreamLifecycle([initEvent, resultEvent(2)])).toMatchObject({ ok: false }) + expect(validateStreamLifecycle([initEvent, created, resultEvent(3)])).toMatchObject({ ok: false }) + expect(validateStreamLifecycle([initEvent, created, started, completed, result], [startCommand])).toMatchObject( + { + ok: false, + }, + ) + expect( + validateStreamLifecycle( + [initEvent, created, started, completed, result], + [startCommand], + startDone("start", "other-root"), + ), + ).toMatchObject({ ok: false }) + expect( + validateStreamLifecycle([initEvent, created, completed, { ...result, hostId: "other-host" }]), + ).toMatchObject({ + ok: false, + }) + expect(validateStreamLifecycle([{ ...initEvent, seq: 2 }, created, completed, result])).toMatchObject({ + ok: false, + }) + expect(validateStreamLifecycle([initEvent])).toMatchObject({ ok: false }) + expect(validateStreamLifecycle([initEvent, created, completed, { ...result, taskId: "child" }])).toMatchObject({ + ok: false, + }) + expect( + validateStreamLifecycle([initEvent, created, taskEvent(3, "task.lifecycle", { state: "failed" }), result]), + ).toMatchObject({ ok: false }) + expect(validateStreamLifecycle([initEvent, { ...initEvent, seq: 2 }, resultEvent(3)])).toMatchObject({ + ok: false, + }) + }) + + it("scopes interleaved host commands and requires ACK before public effects", () => { + const stream = [ + initEvent, + taskEvent(2, "task.created"), + taskEvent(3, "task.started"), + taskEvent(4, "task.lifecycle", { state: "completed" }), + resultEvent(5), + ] + const otherStart = hostCommandSchema.parse({ + v: 1, + id: "other-start", + type: "task.start", + workspace: "/other", + prompt: "Other", + }) + const interleaved = [ + hostEventSchema.parse({ v: 1, seq: 1, hostId: "host", type: "command.ack", commandId: "start" }), + hostEventSchema.parse({ v: 1, seq: 2, hostId: "host", type: "command.ack", commandId: "other-start" }), + hostEventSchema.parse({ + v: 1, + seq: 3, + hostId: "host", + type: "command.done", + commandId: "other-start", + data: { commandType: "task.start", task: { rootTaskId: "other-root", taskId: "other-root" } }, + }), + hostEventSchema.parse({ + v: 1, + seq: 4, + hostId: "host", + type: "command.done", + commandId: "start", + data: { commandType: "task.start", task: { rootTaskId: "root", taskId: "root" } }, + }), + ] + expect( + validateStreamLifecycle(stream, [startCommand, otherStart], interleaved, { + initiatingCommandId: "start", + commandIds: ["start"], + }), + ).toEqual({ ok: true }) + const unrelatedResponse = hostCommandSchema.parse({ + v: 1, + id: "other-response", + type: "ask.respond", + taskId: "other-root", + askId: "other", + response: "approve", + }) + const unrelatedResponseEvents = [ + hostEventSchema.parse({ v: 1, seq: 5, hostId: "host", type: "command.ack", commandId: "other-response" }), + hostEventSchema.parse({ + v: 1, + seq: 6, + hostId: "host", + type: "command.done", + commandId: "other-response", + data: { commandType: "ask.respond", taskId: "other-root", askId: "other" }, + }), + ] + expect( + validateStreamLifecycle( + stream, + [startCommand, otherStart, unrelatedResponse], + [...interleaved, ...unrelatedResponseEvents], + { + initiatingCommandId: "start", + commandIds: ["start"], + }, + ), + ).toEqual({ ok: true }) + + const eventEnvelopes = stream.map((event, index) => + hostEventSchema.parse({ v: 1, seq: index + 1, hostId: "host", type: "event", event }), + ) + const lateAckWindow = [ + eventEnvelopes[0]!, + eventEnvelopes[1]!, + ...startDone("start", "root", 3), + ...eventEnvelopes.slice(2).map((event, index) => ({ ...event, seq: index + 5 })), + ] + expect(validateStreamLifecycleContract(stream, [startCommand], lateAckWindow)).toMatchObject({ ok: false }) + const mismatchedEnvelope = eventEnvelopes.map((event, index) => + index === 1 && event.type === "event" + ? { ...event, seq: event.seq + 2, event: { ...event.event, requestId: "different-request" } } + : { ...event, seq: event.seq + 2 }, + ) + expect( + validateStreamLifecycleContract(stream, [startCommand], [...startDone(), ...mismatchedEnvelope]), + ).toMatchObject({ + ok: false, + }) + }) + + it("validates task-tree settlement and approval command causation", () => { + const rootCreated = taskEvent(2, "task.created") + const rootStarted = taskEvent(3, "task.started") + const childCreated = taskEvent(4, "task.created", { taskId: "child", parentTaskId: "root" }) + const delegated = taskEvent(5, "task.delegated", { + taskId: "child", + parentTaskId: "root", + childTaskId: "child", + }) + const childStarted = taskEvent(6, "task.started", { taskId: "child" }) + const childCompleted = taskEvent(7, "task.lifecycle", { taskId: "child", state: "completed" }) + const rootCompleted = taskEvent(8, "task.lifecycle", { state: "completed" }) + expect( + validateStreamLifecycle( + [ + initEvent, + rootCreated, + rootStarted, + childCreated, + delegated, + childStarted, + childCompleted, + rootCompleted, + resultEvent(9), + ], + [startCommand], + startDone(), + ), + ).toEqual({ ok: true }) + + expect( + validateStreamLifecycle( + [ + initEvent, + rootCreated, + rootStarted, + childCreated, + delegated, + childStarted, + { ...rootCompleted, seq: 7 }, + { ...childCompleted, seq: 8 }, + resultEvent(9), + ], + [startCommand], + startDone(), + ), + ).toMatchObject({ ok: false }) + expect( + validateStreamLifecycle([initEvent, rootCreated, childCreated, delegated, rootCompleted, resultEvent(6)]), + ).toMatchObject({ ok: false }) + expect( + validateStreamLifecycle([ + initEvent, + rootCreated, + childCreated, + taskEvent(4, "task.lifecycle", { taskId: "child", state: "completed" }), + taskEvent(5, "task.lifecycle", { state: "completed" }), + resultEvent(6), + ]), + ).toMatchObject({ ok: false }) + const mismatchedDelegation = taskEvent(4, "task.delegated", { + taskId: "root", + parentTaskId: "root", + childTaskId: "child", + }) + expect( + validateStreamLifecycle([ + initEvent, + rootCreated, + childCreated, + mismatchedDelegation, + rootCompleted, + resultEvent(6), + ]), + ).toMatchObject({ ok: false }) + + const required = taskEvent(4, "ask.required", { + askId: "ask", + category: "tool", + subject: "Run command", + }) + const waitingForApproval = taskEvent(5, "task.lifecycle", { state: "waiting" }) + const resolved = taskEvent(6, "ask.resolved", { + requestId: "respond", + askId: "ask", + decision: "approve", + source: "user", + }) + const response = hostCommandSchema.parse({ + v: 1, + id: "respond", + type: "ask.respond", + taskId: "root", + askId: "ask", + response: "approve", + }) + const runningAfterApproval = taskEvent(7, "task.lifecycle", { state: "running", requestId: "respond" }) + const completed = taskEvent(8, "task.lifecycle", { state: "completed" }) + expect( + validateStreamLifecycle( + [ + initEvent, + rootCreated, + rootStarted, + required, + waitingForApproval, + resolved, + runningAfterApproval, + completed, + resultEvent(9), + ], + [startCommand, response], + [...startDone(), ...askResponseDone("respond", "host", 3)], + ), + ).toEqual({ + ok: true, + }) + expect( + validateStreamLifecycle( + [initEvent, rootCreated, rootStarted, required, resolved, completed, resultEvent(7)], + [startCommand, response], + [...startDone(), ...askResponseDone("respond", "host", 3)], + ), + ).toMatchObject({ ok: false }) + const mismatchedResolution = zooStreamEventSchema.parse({ ...resolved, decision: "reject" }) + expect( + validateStreamLifecycle( + [initEvent, rootCreated, required, mismatchedResolution, completed, resultEvent(6)], + [response], + ), + ).toMatchObject({ ok: false }) + const deniedApproval = zooStreamEventSchema.parse({ ...resolved, source: "deny" }) + expect( + validateStreamLifecycle([initEvent, rootCreated, required, deniedApproval, completed, resultEvent(6)]), + ).toMatchObject({ ok: false }) + const policyOverride = zooStreamEventSchema.parse({ ...resolved, source: "policy", decision: "reject" }) + expect( + validateStreamLifecycle( + [initEvent, rootCreated, rootStarted, required, policyOverride, completed, resultEvent(7)], + [startCommand, response], + [...startDone(), ...askResponseDone("respond", "host", 3)], + ), + ).toMatchObject({ ok: false }) + const policyReportedResponse = zooStreamEventSchema.parse({ ...resolved, source: "policy" }) + expect( + validateStreamLifecycle( + [ + initEvent, + rootCreated, + rootStarted, + required, + waitingForApproval, + policyReportedResponse, + runningAfterApproval, + completed, + resultEvent(9), + ], + [startCommand, response], + [...startDone(), ...askResponseDone("respond", "host", 3)], + ), + ).toEqual({ ok: true }) + expect( + validateStreamLifecycle( + [initEvent, rootCreated, rootStarted, required, resolved, completed, resultEvent(7)], + [startCommand, response], + ), + ).toMatchObject({ ok: false }) + expect( + validateStreamLifecycle( + [initEvent, rootCreated, rootStarted, required, resolved, completed, resultEvent(7)], + [startCommand, response], + askResponseDone().slice(1), + ), + ).toMatchObject({ ok: false }) + expect( + validateStreamLifecycle( + [initEvent, rootCreated, rootStarted, required, resolved, completed, resultEvent(7)], + [startCommand, response], + askResponseDone("respond", "other"), + ), + ).toMatchObject({ ok: false }) + const waiting = taskEvent(5, "task.lifecycle", { state: "waiting" }) + const needsInput = resultEvent(6, { outcome: "needs_input", resumable: true }) + expect( + validateStreamLifecycle( + [initEvent, rootCreated, rootStarted, required, waiting, needsInput], + [startCommand, response], + [...startDone(), ...askResponseError("respond", 3)], + ), + ).toEqual({ ok: true }) + expect( + validateStreamLifecycle( + [initEvent, rootCreated, rootStarted, required, waiting, needsInput], + [startCommand, response], + ), + ).toMatchObject({ ok: false }) + expect( + validateStreamLifecycle( + [initEvent, rootCreated, rootStarted, required, resolved, completed, resultEvent(7)], + [startCommand, { ...response, id: startCommand.id }], + askResponseDone(startCommand.id), + ), + ).toMatchObject({ ok: false }) + }) + + it("correlates cancellation and settles operation lifecycles", () => { + const created = taskEvent(2, "task.created") + const started = taskEvent(3, "task.started") + const toolStarted = taskEvent(4, "tool.started", { toolCallId: "tool", name: "read" }) + const toolCompleted = taskEvent(5, "tool.completed", { toolCallId: "tool", name: "read" }) + const terminalStarted = taskEvent(6, "terminal.status", { toolCallId: "terminal", state: "running" }) + const terminalExited = taskEvent(7, "terminal.status", { toolCallId: "terminal", state: "exited", exitCode: 0 }) + const mcpStarted = taskEvent(8, "mcp.started", { operationId: "mcp", server: "test", operation: "read" }) + const mcpCompleted = taskEvent(9, "mcp.completed", { operationId: "mcp", server: "test", operation: "read" }) + const interrupted = taskEvent(10, "task.lifecycle", { state: "interrupted", cause: "cancelled" }) + const cancelled = resultEvent(11, { outcome: "cancelled", cancellationReason: "user" }, { requestId: "cancel" }) + const command = hostCommandSchema.parse({ + v: 1, + id: "cancel", + type: "task.cancel", + rootTaskId: "root", + reason: "user", + }) + if (command.type !== "task.cancel") throw new Error("Expected task.cancel fixture") + const stream = [ + initEvent, + created, + started, + toolStarted, + toolCompleted, + terminalStarted, + terminalExited, + mcpStarted, + mcpCompleted, + interrupted, + cancelled, + ] + expect( + validateStreamLifecycle( + stream, + [startCommand, command], + [...startDone(), ...cancellationDone("cancel", "host", 3)], + ), + ).toEqual({ ok: true }) + const completedDespiteCancellation = [ + initEvent, + created, + started, + taskEvent(4, "task.lifecycle", { state: "completed" }), + resultEvent(5), + ] + expect( + validateStreamLifecycle( + completedDespiteCancellation, + [startCommand, command], + [...startDone(), ...cancellationDone("cancel", "host", 3)], + { initiatingCommandId: "start", commandIds: ["start"] }, + ), + ).toMatchObject({ ok: false }) + expect( + validateStreamLifecycle( + stream.map((event) => + event.type === "task.lifecycle" && event.state === "interrupted" + ? { ...event, cause: "timed_out" as const } + : event, + ), + [startCommand, command], + [...startDone(), ...cancellationDone("cancel", "host", 3)], + ), + ).toMatchObject({ ok: false }) + expect( + validateStreamLifecycle(stream, [startCommand, command], cancellationDone("cancel", "other")), + ).toMatchObject({ ok: false }) + expect(validateStreamLifecycle(stream)).toMatchObject({ ok: false }) + expect(validateStreamLifecycle(stream, [{ ...command, reason: "signal" }])).toMatchObject({ ok: false }) + expect( + zooStreamEventSchema.safeParse({ + ...terminalStarted, + exitCode: 0, + }).success, + ).toBe(false) + expect(zooStreamEventSchema.safeParse({ ...terminalExited, exitCode: undefined }).success).toBe(false) + expect( + validateStreamLifecycle([ + initEvent, + created, + toolStarted, + taskEvent(4, "tool.completed", { toolCallId: "tool", name: "write" }), + taskEvent(5, "task.lifecycle", { state: "completed" }), + resultEvent(6), + ]), + ).toMatchObject({ ok: false }) + expect( + validateStreamLifecycle([ + initEvent, + created, + mcpStarted, + taskEvent(4, "mcp.completed", { operationId: "mcp", server: "other", operation: "read" }), + taskEvent(5, "task.lifecycle", { state: "completed" }), + resultEvent(6), + ]), + ).toMatchObject({ ok: false }) + expect( + validateStreamLifecycle([ + initEvent, + created, + toolCompleted, + taskEvent(4, "task.lifecycle", { state: "completed" }), + resultEvent(5), + ]), + ).toMatchObject({ ok: false }) + expect( + validateStreamLifecycle([ + initEvent, + created, + toolStarted, + taskEvent(4, "task.lifecycle", { state: "completed" }), + resultEvent(5), + ]), + ).toMatchObject({ ok: false }) + expect( + validateStreamLifecycle([ + initEvent, + created, + taskEvent(3, "terminal.status", { toolCallId: "terminal", state: "exited", exitCode: 0 }), + taskEvent(4, "task.lifecycle", { state: "completed" }), + resultEvent(5), + ]), + ).toMatchObject({ ok: false }) + expect( + validateStreamLifecycle([ + initEvent, + created, + mcpCompleted, + taskEvent(4, "task.lifecycle", { state: "completed" }), + resultEvent(5), + ]), + ).toMatchObject({ ok: false }) + }) + + it("abandons pending asks for terminal interruption or failure", () => { + const created = taskEvent(2, "task.created") + const started = taskEvent(3, "task.started") + const required = taskEvent(4, "ask.required", { askId: "ask", category: "tool", subject: "Run" }) + const waiting = taskEvent(5, "task.lifecycle", { state: "waiting" }) + const abandoned = taskEvent(6, "ask.abandoned", { askId: "ask", reason: "cancelled" }) + if (abandoned.type !== "ask.abandoned") throw new Error("Expected ask.abandoned fixture") + const interrupted = taskEvent(7, "task.lifecycle", { state: "interrupted", cause: "cancelled" }) + const cancelled = resultEvent(8, { outcome: "cancelled", cancellationReason: "user" }, { requestId: "cancel" }) + const command = hostCommandSchema.parse({ + v: 1, + id: "cancel", + type: "task.cancel", + rootTaskId: "root", + reason: "user", + }) + expect( + validateStreamLifecycle( + [initEvent, created, started, required, waiting, abandoned, interrupted, cancelled], + [startCommand, command], + [...startDone(), ...cancellationDone("cancel", "host", 3)], + ), + ).toEqual({ ok: true }) + expect( + validateStreamLifecycle( + [initEvent, created, required, { ...abandoned, reason: "timed_out" }, interrupted, cancelled], + [command], + ), + ).toMatchObject({ ok: false }) + const failedAbandonment = taskEvent(6, "ask.abandoned", { askId: "ask", reason: "failed" }) + expect( + validateStreamLifecycle( + [ + initEvent, + created, + started, + required, + waiting, + failedAbandonment, + taskEvent(7, "task.lifecycle", { state: "failed", cause: "failed" }), + resultEvent(8, { + outcome: "failed", + error: { code: "provider_failed", message: "failed" }, + }), + ], + [startCommand], + startDone(), + ), + ).toEqual({ ok: true }) + + const childFailureThenCancellation = [ + initEvent, + created, + started, + taskEvent(4, "task.created", { taskId: "child", parentTaskId: "root" }), + taskEvent(5, "task.delegated", { taskId: "child", parentTaskId: "root", childTaskId: "child" }), + taskEvent(6, "task.started", { taskId: "child" }), + taskEvent(7, "ask.required", { + taskId: "child", + askId: "child-ask", + category: "tool", + subject: "Run", + }), + taskEvent(8, "task.lifecycle", { taskId: "child", state: "waiting" }), + taskEvent(9, "ask.abandoned", { taskId: "child", askId: "child-ask", reason: "failed" }), + taskEvent(10, "task.lifecycle", { taskId: "child", state: "failed", cause: "failed" }), + taskEvent(11, "task.lifecycle", { state: "interrupted", cause: "cancelled" }), + resultEvent(12, { outcome: "cancelled", cancellationReason: "user" }, { requestId: "cancel" }), + ] + expect( + validateStreamLifecycle( + childFailureThenCancellation, + [startCommand, command], + [...startDone(), ...cancellationDone("cancel", "host", 3)], + ), + ).toEqual({ ok: true }) + }) + + it("requires currentTaskId to belong to the authoritative tree", () => { + expect( + validateStreamLifecycle([ + initEvent, + taskEvent(2, "task.created"), + taskEvent(3, "task.lifecycle", { state: "completed" }), + resultEvent(4, { currentTaskId: "ghost" }), + ]), + ).toMatchObject({ ok: false }) + }) + + it("requires a cause for waiting tasks to return to running", () => { + const created = taskEvent(2, "task.created") + const started = taskEvent(3, "task.started") + const required = taskEvent(4, "ask.required", { askId: "ask", category: "tool", subject: "Run" }) + const waiting = taskEvent(5, "task.lifecycle", { state: "waiting" }) + const running = taskEvent(6, "task.lifecycle", { state: "running" }) + const completed = taskEvent(7, "task.lifecycle", { state: "completed" }) + expect( + validateStreamLifecycle( + [initEvent, created, started, required, waiting, running, completed, resultEvent(8)], + [startCommand], + ), + ).toMatchObject({ ok: false }) + + const response = hostCommandSchema.parse({ + v: 1, + id: "respond", + type: "ask.respond", + taskId: "root", + askId: "ask", + response: "approve", + }) + const resolved = taskEvent(6, "ask.resolved", { + requestId: "respond", + askId: "ask", + decision: "approve", + source: "user", + }) + expect( + validateStreamLifecycle( + [ + initEvent, + created, + started, + required, + waiting, + resolved, + { ...running, seq: 7, requestId: "respond" }, + { ...completed, seq: 8 }, + resultEvent(9), + ], + [startCommand, response], + [...startDone(), ...askResponseDone("respond", "host", 3)], + ), + ).toEqual({ ok: true }) + + const rejection = hostCommandSchema.parse({ ...response, id: "reject", response: "reject" }) + const rejected = taskEvent(6, "ask.resolved", { + requestId: "reject", + askId: "ask", + decision: "reject", + source: "user", + }) + expect( + validateStreamLifecycle( + [ + initEvent, + created, + started, + required, + waiting, + rejected, + { ...running, seq: 7, requestId: "reject" }, + { ...completed, seq: 8 }, + resultEvent(9), + ], + [startCommand, rejection], + [...startDone(), ...askResponseDone("reject", "host", 3)], + ), + ).toEqual({ ok: true }) + + const secondRequired = taskEvent(5, "ask.required", { askId: "other", category: "tool", subject: "Other" }) + expect( + validateStreamLifecycle( + [ + initEvent, + created, + started, + required, + secondRequired, + { ...waiting, seq: 6 }, + { ...resolved, seq: 7 }, + { ...running, seq: 8, requestId: "respond" }, + resultEvent(9, { outcome: "needs_input", resumable: true }), + ], + [startCommand, response], + [...startDone(), ...askResponseDone("respond", "host", 3)], + ), + ).toMatchObject({ ok: false }) + }) + + it("consumes each task input resume cause once", () => { + const input = hostCommandSchema.parse({ + v: 1, + id: "input", + type: "task.input", + taskId: "root", + text: "continue", + }) + const inputEvents = [ + hostEventSchema.parse({ v: 1, seq: 3, hostId: "host", type: "command.ack", commandId: "input" }), + hostEventSchema.parse({ + v: 1, + seq: 4, + hostId: "host", + type: "command.done", + commandId: "input", + data: { commandType: "task.input", taskId: "root" }, + }), + ] + const stream = [ + initEvent, + taskEvent(2, "task.created"), + taskEvent(3, "task.started"), + taskEvent(4, "task.lifecycle", { state: "waiting" }), + taskEvent(5, "task.lifecycle", { state: "running", requestId: "input" }), + taskEvent(6, "task.lifecycle", { state: "waiting" }), + taskEvent(7, "task.lifecycle", { state: "running", requestId: "input" }), + taskEvent(8, "task.lifecycle", { state: "completed" }), + resultEvent(9), + ] + expect(validateStreamLifecycle(stream, [startCommand, input], [...startDone(), ...inputEvents])).toMatchObject({ + ok: false, + }) + const messageEffect = [ + initEvent, + taskEvent(2, "task.created"), + taskEvent(3, "task.started"), + taskEvent(4, "message.upsert", { + requestId: "input", + messageId: "input-message", + role: "user", + content: "continue", + complete: true, + }), + taskEvent(5, "task.lifecycle", { state: "completed" }), + resultEvent(6), + ] + expect(validateStreamLifecycle(messageEffect, [startCommand, input], [...startDone(), ...inputEvents])).toEqual( + { + ok: true, + }, + ) + const ghostInput = hostCommandSchema.parse({ + v: 1, + id: "ghost-input", + type: "task.input", + taskId: "ghost", + text: "continue", + }) + const ghostInputEvents = [ + hostEventSchema.parse({ v: 1, seq: 3, hostId: "host", type: "command.ack", commandId: "ghost-input" }), + hostEventSchema.parse({ + v: 1, + seq: 4, + hostId: "host", + type: "command.done", + commandId: "ghost-input", + data: { commandType: "task.input", taskId: "ghost" }, + }), + ] + expect( + validateStreamLifecycle( + messageEffect + .filter((event) => event.type !== "message.upsert") + .map((event, index) => ({ ...event, seq: index + 1 })), + [startCommand, ghostInput], + [...startDone(), ...ghostInputEvents], + { + initiatingCommandId: "start", + commandIds: ["start", "ghost-input"], + }, + ), + ).toMatchObject({ ok: false }) + }) + + it("reconstructs resume streams from a matching command", () => { + const command = hostCommandSchema.parse({ + v: 1, + id: "resume", + type: "task.resume", + rootTaskId: "root", + taskId: "root", + }) + const predecessor = taskEvent(3, "task.lifecycle", { state: "interrupted" }) + const resumed = taskEvent(4, "task.resumed", { requestId: "resume", previousState: "interrupted" }) + const started = taskEvent(5, "task.started") + const completed = taskEvent(6, "task.lifecycle", { state: "completed" }) + const stream = [ + initEvent, + taskEvent(2, "task.created"), + predecessor, + resumed, + started, + completed, + resultEvent(7, {}, { requestId: "resume" }), + ] + expect(validateStreamLifecycle(stream, [command], resumeDone())).toEqual({ ok: true }) + expect(validateStreamLifecycle(stream)).toMatchObject({ ok: false }) + expect( + validateStreamLifecycle( + [...stream.slice(0, 3), { ...resumed, seq: 4 }, { ...completed, seq: 5 }, resultEvent(6)], + [command], + ), + ).toMatchObject({ ok: false }) + expect(zooStreamEventSchema.safeParse({ ...resumed, previousState: "completed" }).success).toBe(false) + }) + + it("allows a resumed run to be cancelled by a distinct command", () => { + const resume = hostCommandSchema.parse({ + v: 1, + id: "resume", + type: "task.resume", + rootTaskId: "root", + taskId: "root", + }) + const cancel = hostCommandSchema.parse({ + v: 1, + id: "cancel", + type: "task.cancel", + rootTaskId: "root", + reason: "user", + }) + const stream = [ + initEvent, + taskEvent(2, "task.created"), + taskEvent(3, "task.lifecycle", { state: "interrupted" }), + taskEvent(4, "task.resumed", { requestId: "resume", previousState: "interrupted" }), + taskEvent(5, "task.started"), + taskEvent(6, "task.lifecycle", { state: "interrupted", cause: "cancelled" }), + resultEvent(7, { outcome: "cancelled", cancellationReason: "user" }, { requestId: "cancel" }), + ] + expect( + validateStreamLifecycle( + stream, + [resume, cancel], + [...resumeDone(), ...cancellationDone("cancel", "host", 3)], + ), + ).toEqual({ ok: true }) + }) + + it("requires accepted cancellations to own cancelled results", () => { + const cancel = hostCommandSchema.parse({ + v: 1, + id: "cancel", + type: "task.cancel", + rootTaskId: "root", + reason: "user", + }) + const completed = [ + initEvent, + taskEvent(2, "task.created"), + taskEvent(3, "task.started"), + taskEvent(4, "task.lifecycle", { state: "completed" }), + resultEvent(5), + ] + expect(validateStreamLifecycle(completed, [startCommand, cancel], cancellationDone())).toMatchObject({ + ok: false, + }) + expect( + validateStreamLifecycle( + completed, + [startCommand, cancel], + [...startDone(), ...cancellationError("cancel", 3)], + ), + ).toEqual({ ok: true }) + expect(validateStreamLifecycle(completed, [startCommand, cancel])).toMatchObject({ ok: false }) + }) + + it("resumes a correlated descendant from its reconstructed predecessor", () => { + const command = hostCommandSchema.parse({ + v: 1, + id: "resume-child", + type: "task.resume", + rootTaskId: "root", + taskId: "child", + }) + const stream = [ + initEvent, + taskEvent(2, "task.created"), + taskEvent(3, "task.created", { taskId: "child", parentTaskId: "root" }), + taskEvent(4, "task.delegated", { taskId: "child", parentTaskId: "root", childTaskId: "child" }), + taskEvent(5, "task.lifecycle", { taskId: "child", state: "waiting" }), + taskEvent(6, "task.resumed", { + taskId: "child", + requestId: "resume-child", + previousState: "waiting", + }), + taskEvent(7, "task.started", { taskId: "child" }), + taskEvent(8, "task.lifecycle", { taskId: "child", state: "completed" }), + taskEvent(9, "task.started"), + taskEvent(10, "task.lifecycle", { state: "completed" }), + resultEvent(11, {}, { requestId: "resume-child" }), + ] + expect(validateStreamLifecycle(stream, [command], resumeDone("resume-child", "child"))).toEqual({ ok: true }) + expect( + validateStreamLifecycle( + stream.map((event) => + event.type === "task.resumed" ? { ...event, previousState: "interrupted" as const } : event, + ), + [command], + ), + ).toMatchObject({ ok: false }) + }) + + it("keeps operation identities separate for delimiter-bearing IDs", () => { + const stream = [ + initEvent, + taskEvent(2, "task.created"), + taskEvent(3, "task.started"), + taskEvent(4, "task.created", { taskId: "root\u0000x", parentTaskId: "root" }), + taskEvent(5, "task.delegated", { + taskId: "root\u0000x", + parentTaskId: "root", + childTaskId: "root\u0000x", + }), + taskEvent(6, "task.started", { taskId: "root\u0000x" }), + taskEvent(7, "tool.started", { toolCallId: "x\u0000y", name: "read" }), + taskEvent(8, "tool.started", { taskId: "root\u0000x", toolCallId: "y", name: "read" }), + taskEvent(9, "tool.completed", { toolCallId: "x\u0000y", name: "read" }), + taskEvent(10, "tool.completed", { taskId: "root\u0000x", toolCallId: "y", name: "read" }), + taskEvent(11, "task.lifecycle", { taskId: "root\u0000x", state: "completed" }), + taskEvent(12, "task.lifecycle", { state: "completed" }), + resultEvent(13), + ] + expect(validateStreamLifecycle(stream, [startCommand], startDone())).toEqual({ ok: true }) + }) + + it("keeps pending asks on waiting tasks", () => { + const childCreated = taskEvent(3, "task.created", { taskId: "child", parentTaskId: "root" }) + const delegated = taskEvent(4, "task.delegated", { + taskId: "child", + parentTaskId: "root", + childTaskId: "child", + }) + const required = taskEvent(5, "ask.required", { + taskId: "child", + askId: "ask", + category: "tool", + subject: "Run", + }) + const childCompleted = taskEvent(6, "task.lifecycle", { taskId: "child", state: "completed" }) + const rootWaiting = taskEvent(7, "task.lifecycle", { state: "waiting" }) + expect( + validateStreamLifecycle([ + initEvent, + taskEvent(2, "task.created"), + childCreated, + delegated, + required, + childCompleted, + rootWaiting, + resultEvent(8, { outcome: "needs_input", resumable: true }), + ]), + ).toMatchObject({ ok: false }) + expect( + validateStreamLifecycle( + [ + initEvent, + taskEvent(2, "task.created"), + taskEvent(3, "task.started"), + taskEvent(4, "task.lifecycle", { state: "waiting" }), + resultEvent(5, { outcome: "needs_input", resumable: true }), + ], + [startCommand], + startDone(), + ), + ).toMatchObject({ ok: false }) + }) + + it("enforces operation, ask, message, and parent execution state", () => { + const created = taskEvent(2, "task.created") + const started = taskEvent(3, "task.started") + const required = taskEvent(4, "ask.required", { askId: "ask", category: "tool", subject: "Run" }) + expect( + validateStreamLifecycle( + [ + initEvent, + created, + started, + required, + taskEvent(5, "tool.started", { toolCallId: "tool", name: "shell" }), + taskEvent(6, "task.lifecycle", { state: "completed" }), + resultEvent(7), + ], + [startCommand], + startDone(), + ), + ).toMatchObject({ ok: false }) + + const response = hostCommandSchema.parse({ + v: 1, + id: "respond", + type: "ask.respond", + taskId: "root", + askId: "ask", + response: "approve", + }) + const resolved = taskEvent(5, "ask.resolved", { + requestId: "respond", + askId: "ask", + decision: "approve", + source: "user", + }) + expect( + validateStreamLifecycle( + [ + initEvent, + created, + started, + required, + resolved, + taskEvent(6, "ask.required", { askId: "ask", category: "tool", subject: "Again" }), + taskEvent(7, "task.lifecycle", { state: "completed" }), + resultEvent(8), + ], + [startCommand, response], + askResponseDone(), + ), + ).toMatchObject({ ok: false }) + + const message = taskEvent(4, "message.upsert", { + messageId: "message", + role: "assistant", + content: "done", + complete: true, + }) + expect( + validateStreamLifecycle( + [ + initEvent, + created, + started, + message, + taskEvent(5, "message.upsert", { + messageId: "message", + role: "user", + content: "changed", + complete: false, + }), + taskEvent(6, "task.lifecycle", { state: "completed" }), + resultEvent(7), + ], + [startCommand], + ), + ).toMatchObject({ ok: false }) + + expect( + validateStreamLifecycle( + [ + initEvent, + created, + taskEvent(3, "task.created", { taskId: "child", parentTaskId: "root" }), + taskEvent(4, "task.delegated", { taskId: "child", parentTaskId: "root", childTaskId: "child" }), + taskEvent(5, "task.started", { taskId: "child" }), + taskEvent(6, "task.lifecycle", { taskId: "child", state: "completed" }), + taskEvent(7, "task.started"), + taskEvent(8, "task.lifecycle", { state: "completed" }), + resultEvent(9), + ], + [startCommand], + ), + ).toMatchObject({ ok: false }) + }) + + it("requires completed streams to settle partial messages", () => { + const partial = taskEvent(4, "message.upsert", { + messageId: "message", + role: "assistant", + content: "partial", + complete: false, + }) + const stream = [ + initEvent, + taskEvent(2, "task.created"), + taskEvent(3, "task.started"), + partial, + taskEvent(5, "task.lifecycle", { state: "completed" }), + resultEvent(6), + ] + expect(validateStreamLifecycle(stream, [startCommand])).toMatchObject({ ok: false }) + expect( + validateStreamLifecycle( + [ + ...stream.slice(0, -2), + taskEvent(5, "task.lifecycle", { state: "failed" }), + resultEvent(6, { + outcome: "failed", + error: { code: "task_failed", message: "failed" }, + }), + ], + [startCommand], + startDone(), + ), + ).toEqual({ ok: true }) + }) + + it("maps every terminal outcome deterministically", () => { + expect(exitCodeFor({ outcome: "completed" })).toBe(EXIT_CODES.completed) + expect(exitCodeFor({ outcome: "needs_input" })).toBe(EXIT_CODES.needsInput) + expect(exitCodeFor({ outcome: "cancelled" })).toBe(EXIT_CODES.cancelled) + expect(exitCodeFor({ outcome: "timed_out" })).toBe(EXIT_CODES.timedOut) + expect(exitCodeFor({ outcome: "failed", errorCode: "invalid_mode" })).toBe(EXIT_CODES.usage) + expect(exitCodeFor({ outcome: "failed", errorCode: "provider_failed" })).toBe(EXIT_CODES.providerFailure) + expect(exitCodeFor({ outcome: "failed", errorCode: "host_crashed" })).toBe(EXIT_CODES.runtimeFailure) + expect(exitCodeFor({ outcome: "cancelled", signal: "SIGINT" })).toBe(EXIT_CODES.sigint) + expect(exitCodeFor({ outcome: "cancelled", signal: "SIGTERM" })).toBe(EXIT_CODES.sigterm) + expect(exitContextSchema.safeParse({ outcome: "cancelled", errorCode: "invalid_mode" }).success).toBe(false) + expect(exitContextSchema.safeParse({ outcome: "completed", signal: "SIGINT" }).success).toBe(false) + expect(exitContextSchema.safeParse({ outcome: "failed", errorCode: "task_timed_out" }).success).toBe(false) + }) +}) + +describe("redaction contracts", () => { + it("redacts secret-shaped keys and text before buffering", () => { + const input = { + provider: "openrouter", + apiKey: "sk-secret-value", + nested: { authorization: "Bearer abcdefgh", command: "API_TOKEN=abcdefgh run" }, + } + expect(redactValue(input)).toEqual({ + provider: "openrouter", + apiKey: "[REDACTED]", + nested: { authorization: "[REDACTED]", command: "[REDACTED] run" }, + }) + expect(redactText("Authorization: Bearer abcdefgh")).not.toContain("abcdefgh") + expect(redactText("Authorization: abc123\nCookie: session=abc")).not.toMatch(/abc123|session=abc/) + expect(redactText('{"password":"hunter2"}')).toBe('{"password":"[REDACTED]"}') + expect(redactText('{"password": hunter2}')).toBe('{"password": [REDACTED]}') + expect(redactText("{'api_key': abc123}")).toBe("{'api_key': [REDACTED]}") + expect(redactText('{"client_secret":"secret-value","access_token":"token-value"}')).toBe( + '{"client_secret":"[REDACTED]","access_token":"[REDACTED]"}', + ) + expect(redactText("--api-key abc123 run")).toBe("[REDACTED] run") + expect(redactText("API Key: abc123")).toBe("[REDACTED]") + expect(redactText("Private Key: abc123")).toBe("[REDACTED]") + expect(redactText('{"auth.token":"secret"}')).toBe('{"auth.token":"[REDACTED]"}') + expect(redactText("https://alice:hunter2@example.com/path")).toBe("https://[REDACTED]@example.com/path") + expect(redactText("https://alice:p@ss@example.com/path")).toBe("https://[REDACTED]@example.com/path") + expect(redactText("--password abc,def run")).toBe("[REDACTED] run") + expect(redactText('API_TOKEN="abc def" run')).toBe("[REDACTED] run") + expect(redactText('{"access token":"hunter2","client secret":"secret-value"}')).toBe( + '{"access token":"[REDACTED]","client secret":"[REDACTED]"}', + ) + expect(redactValue({ max_tokens: 4096, tokenCount: 12, tokenizer: "bpe", accessToken: "secret" })).toEqual({ + max_tokens: 4096, + tokenCount: 12, + tokenizer: "bpe", + accessToken: "[REDACTED]", + }) + expect(redactValue({ databasePassword: "pw", signingSecret: "sig", secretAccessKey: "key" })).toEqual({ + databasePassword: "[REDACTED]", + signingSecret: "[REDACTED]", + secretAccessKey: "[REDACTED]", + }) + expect(redactText("https://opaque-token@example.com/path")).toBe("https://[REDACTED]@example.com/path") + expect(redactText("postgres://alice:hunter2@db/prod")).toBe("postgres://[REDACTED]@db/prod") + expect(redactText("redis://:secret@cache/0")).toBe("redis://[REDACTED]@cache/0") + expect(redactText("API_TOKEN=abc,def")).toBe("[REDACTED]") + expect(redactText("OPENAI_API_KEY: value")).toBe("[REDACTED]") + expect(redactText("SENTRY_AUTH_TOKEN: value")).toBe("[REDACTED]") + expect(redactText("openaiApiKey: value")).toBe("[REDACTED]") + expect(redactValue({ credentials: "value", passphrase: "value", passwd: "value", pwd: "value" })).toEqual({ + credentials: "[REDACTED]", + passphrase: "[REDACTED]", + passwd: "[REDACTED]", + pwd: "[REDACTED]", + }) + expect(redactText('{"max_tokens":4096} tokenizer=bpe --max-tokens 4096')).toBe( + '{"max_tokens":4096} tokenizer=bpe --max-tokens 4096', + ) + expect( + redactValue({ "set-cookie": "session=abc", setCookie: "session=def", proxyAuthorization: "Basic abc" }), + ).toEqual({ + "set-cookie": "[REDACTED]", + setCookie: "[REDACTED]", + proxyAuthorization: "[REDACTED]", + }) + expect(redactText("password: abc,def")).toBe("[REDACTED]") + expect(redactText('{"password": abc,def}')).toBe('{"password": [REDACTED]}') + expect(redactText("accessTokenValue=hunter2 cookieJar=session123")).not.toMatch(/hunter2|session123/) + expect(redactText(`\u001b]0;password=hunter2\u0007safe`)).toBe("[REDACTED]") + expect(redactText('{"api\\u005fkey":"hunter2"}')).toBe("[REDACTED]") + expect(redactText("API_\u001b[31mTOKEN=abcdefgh")).toBe("[REDACTED]") + expect(redactText('{"literal":"\\u0061"}')).toBe('{"literal":"\\u0061"}') + expect(redactText("safe\u001b[31m text")).toBe("safe\u001b[31m text") + expect(redactText("github_pat_1234567890abcdef")).toBe("[REDACTED]") + expect(redactValue({ sessionCookie: "abc", cookieJar: "def", privateKeyPem: "ghi" })).toEqual({ + sessionCookie: "[REDACTED]", + cookieJar: "[REDACTED]", + privateKeyPem: "[REDACTED]", + }) + expect(redactValue({ dbpassword: "pw", appsecret: "secret" })).toEqual({ + dbpassword: "[REDACTED]", + appsecret: "[REDACTED]", + }) + expect(redactText('{"my token":"[REDACTED]hunter2"}')).toBe('{"my token":"[REDACTED]"}') + expect(redactText("pass\\u001b[31mword=hunter2")).toBe("[REDACTED]") + }) + + it("redacts public event and result payloads during parsing", () => { + const message = taskEvent(1, "message.upsert", { + messageId: "message", + role: "assistant", + content: "Authorization: Bearer abcdefgh", + complete: true, + }) + expect(message.type === "message.upsert" && message.content).toBe("[REDACTED]") + const result = zooRunResultSchema.parse({ + schemaVersion: 1, + protocol: "zoo-run-result", + success: true, + outcome: "completed", + rootTaskId: "root", + workspace: "/workspace", + resumable: false, + content: "password=hunter2", + elapsedMs: 1, + }) + expect(result.content).toBe("[REDACTED]") + expect(zooStreamEventSchema.safeParse({ ...message, taskId: Symbol("secret") }).success).toBe(false) + const structuralIdentity = taskEvent(2, "message.upsert", { + taskId: "password=hunter2", + messageId: "token=identity", + role: "assistant", + content: "safe", + complete: true, + }) + if (structuralIdentity.type !== "message.upsert") throw new Error("Expected message.upsert fixture") + expect(structuralIdentity.taskId).toBe("password=hunter2") + expect(structuralIdentity.messageId).toBe("token=identity") + const terminalOutput = taskEvent(3, "terminal.output", { + toolCallId: "terminal", + stream: "stdout", + delta: "abcdefgh", + }) + expect(terminalOutput.type === "terminal.output" && terminalOutput.delta).toBe("[REDACTED]") + const terminalPrefix = taskEvent(4, "terminal.output", { + toolCallId: "terminal", + stream: "stdout", + delta: "API_TOKEN=", + }) + expect(terminalPrefix.type === "terminal.output" && terminalPrefix.delta).toBe("[REDACTED]") + const failed = zooRunResultSchema.parse({ + schemaVersion: 1, + protocol: "zoo-run-result", + success: false, + outcome: "failed", + rootTaskId: "root", + workspace: "/workspace", + resumable: false, + error: { code: "provider_failed", message: "safe", phase: "password=hunter2" }, + elapsedMs: 1, + }) + expect(failed.error?.phase).toBe("[REDACTED]") + }) + + it("buffers terminal output across delta boundaries without destroying harmless output", () => { + const terminal = { + v: 1, + timestamp, + hostId: "host", + rootTaskId: "root", + taskId: "root", + type: "terminal.output", + toolCallId: "terminal", + stream: "stdout", + } as const + const output = zooStreamSchema.parse([ + { ...terminal, seq: 1, delta: "Build succeeded\n" }, + { ...terminal, seq: 2, delta: "API_TOKEN=" }, + { ...terminal, seq: 3, delta: "abcdefgh" }, + ]) + expect(output.map((event) => (event.type === "terminal.output" ? event.delta : "")).join("")).toBe( + "Build succeeded\n[REDACTED]", + ) + const mixed = zooStreamSchema.parse([ + { ...terminal, seq: 1, delta: "Build succeeded\nAPI_TOKEN=" }, + { ...terminal, seq: 2, delta: "abcdefgh\n" }, + ]) + expect(mixed.map((event) => (event.type === "terminal.output" ? event.delta : "")).join("")).toBe( + "Build succeeded\n[REDACTED]\n", + ) + const empty = zooStreamSchema.parse([{ ...terminal, seq: 1, delta: "" }]) + expect(empty).toMatchObject([{ type: "terminal.output", delta: "" }]) + }) + + it("buffers multiline secrets and fails closed when bounded memory is exceeded", () => { + const terminal = { + v: 1, + timestamp, + hostId: "host", + rootTaskId: "root", + taskId: "root", + type: "terminal.output", + toolCallId: "terminal", + stream: "stdout", + } as const + const output = zooStreamSchema.parse([ + { ...terminal, seq: 1, delta: "-----BEGIN PRIVATE KEY-----\n" }, + { ...terminal, seq: 2, delta: "super-secret-body\n" }, + { ...terminal, seq: 3, delta: "-----END PRIVATE KEY-----\n" }, + ]) + expect(output.map((event) => (event.type === "terminal.output" ? event.delta : "")).join("")).toBe( + "[REDACTED]\n", + ) + const repeated = zooStreamSchema.parse([ + { + ...terminal, + seq: 1, + delta: "-----BEGIN PRIVATE KEY-----\nfirst\n-----END PRIVATE KEY-----\n-----BEGIN PRIVATE KEY-----\nsecond", + }, + ]) + expect(repeated.map((event) => (event.type === "terminal.output" ? event.delta : "")).join("")).toBe( + "[REDACTED]", + ) + const pgp = zooStreamSchema.parse([ + { ...terminal, seq: 1, delta: "-----BEGIN PGP PRIVATE KEY BLOCK-----\n" }, + { ...terminal, seq: 2, delta: "private-body\n" }, + { ...terminal, seq: 3, delta: "-----END PGP PRIVATE KEY BLOCK-----\n" }, + ]) + expect(pgp.map((event) => (event.type === "terminal.output" ? event.delta : "")).join("")).toBe("[REDACTED]\n") + + const parser = createHostEventStreamParser({ hostId: "host", maxPendingBytes: 4 }) + const overflow = parser.push({ + v: 1, + seq: 1, + hostId: "host", + type: "event", + event: { ...terminal, seq: 1, delta: "secret" }, + }) + expect( + overflow[0]?.type === "event" && overflow[0].event.type === "terminal.output" && overflow[0].event.delta, + ).toBe("[REDACTED]") + const cappedParser = createHostEventStreamParser({ hostId: "host", maxPendingStreams: 1 }) + const pending = (seq: number, toolCallId: string) => ({ + v: 1, + seq, + hostId: "host", + type: "event", + event: { ...terminal, seq, toolCallId, delta: "unterminated" }, + }) + const capped = [ + ...cappedParser.push(pending(1, "first")), + ...cappedParser.push(pending(2, "second")), + ...cappedParser.push(pending(3, "third")), + ...cappedParser.flush(), + ] + expect( + capped.every( + (event) => + event.type === "event" && + event.event.type === "terminal.output" && + event.event.delta === "[REDACTED]", + ), + ).toBe(true) + let now = 0 + const deadlineParser = createHostEventStreamParser({ hostId: "host", maxPendingMs: 10, now: () => now }) + expect(deadlineParser.push(pending(1, "deadline"))).toEqual([]) + now = 10 + const released = deadlineParser.push({ + v: 1, + seq: 2, + hostId: "host", + type: "host.heartbeat", + monotonicMs: 1, + }) + expect(released.map((event) => event.seq)).toEqual([1, 2]) + expect( + released[0]?.type === "event" && released[0].event.type === "terminal.output" && released[0].event.delta, + ).toBe("[REDACTED]") + + const unterminated = zooStreamSchema.parse([ + { ...terminal, seq: 1, delta: "-----BEGIN PRIVATE KEY-----\n" }, + { ...terminal, seq: 2, delta: "super-secret-body" }, + ]) + expect(unterminated.map((event) => (event.type === "terminal.output" ? event.delta : "")).join("")).toBe( + "[REDACTED]", + ) + const unterminatedQuoted = zooStreamSchema.parse([{ ...terminal, seq: 1, delta: '{"api_key":"hunter2' }]) + expect(unterminatedQuoted[0]?.type === "terminal.output" && unterminatedQuoted[0].delta).toBe("[REDACTED]") + const escapedUnterminatedQuoted = zooStreamSchema.parse([ + { ...terminal, seq: 1, delta: '{"api\\u005fkey":"hunter2' }, + ]) + expect(escapedUnterminatedQuoted[0]?.type === "terminal.output" && escapedUnterminatedQuoted[0].delta).toBe( + "[REDACTED]", + ) + const multilineQuoted = zooStreamSchema.parse([ + { ...terminal, seq: 1, delta: 'password="first\n' }, + { ...terminal, seq: 2, delta: 'second"\n' }, + { ...terminal, seq: 3, delta: "harmless\n" }, + ]) + expect(multilineQuoted.map((event) => (event.type === "terminal.output" ? event.delta : "")).join("")).toBe( + "[REDACTED][REDACTED]harmless\n", + ) + const multilineCookie = zooStreamSchema.parse([ + { ...terminal, seq: 1, delta: 'cookie="first\n' }, + { ...terminal, seq: 2, delta: 'second"\n' }, + { ...terminal, seq: 3, delta: "harmless\n" }, + ]) + expect(multilineCookie.map((event) => (event.type === "terminal.output" ? event.delta : "")).join("")).toBe( + "[REDACTED][REDACTED]harmless\n", + ) + const multilineAssignment = zooStreamSchema.parse([ + { ...terminal, seq: 1, delta: "API_TOKEN=\n" }, + { ...terminal, seq: 2, delta: "hunter2\n" }, + { ...terminal, seq: 3, delta: "harmless\n" }, + ]) + expect(multilineAssignment.map((event) => (event.type === "terminal.output" ? event.delta : "")).join("")).toBe( + "[REDACTED][REDACTED]harmless\n", + ) + const ansiPem = zooStreamSchema.parse([ + { ...terminal, seq: 1, delta: "-----BEGIN\u001b[31m PRIVATE KEY-----\n" }, + { ...terminal, seq: 2, delta: "private-body\n" }, + ]) + expect(ansiPem.map((event) => (event.type === "terminal.output" ? event.delta : "")).join("")).toBe( + "[REDACTED]", + ) + + const interleaved = zooStreamSchema.parse([ + { ...terminal, seq: 1, delta: "API_TOKEN=" }, + { ...terminal, seq: 2, stream: "stderr", delta: "harmless\n" }, + { ...terminal, seq: 3, delta: "abcdefgh\n" }, + ]) + expect(interleaved.map((event) => (event.type === "terminal.output" ? event.delta : "")).join("")).toBe( + "[REDACTED][REDACTED]", + ) + const crossStream = zooStreamSchema.parse([ + { ...terminal, seq: 1, delta: "API_TOKEN=" }, + { ...terminal, seq: 2, stream: "stderr", delta: "hunter2\n" }, + ]) + expect(crossStream.map((event) => (event.type === "terminal.output" ? event.delta : "")).join("")).toBe( + "[REDACTED]", + ) + expect(crossStream.map((event) => (event.type === "terminal.output" ? event.stream : ""))).toEqual([ + "stdout", + "stderr", + ]) + const crossStreamQuote = zooStreamSchema.parse([ + { ...terminal, seq: 1, delta: 'password="first\n' }, + { ...terminal, seq: 2, stream: "stderr", delta: 'diagnostic "\n' }, + { ...terminal, seq: 3, delta: "hunter2\n" }, + ]) + expect(crossStreamQuote.map((event) => (event.type === "terminal.output" ? event.delta : "")).join("")).toBe( + "[REDACTED][REDACTED][REDACTED]", + ) + }) + + it("preserves prototype-like keys as redacted record data", () => { + const input = JSON.parse('{"__proto__":{"polluted":true},"safe":"value"}') as Record + const output = redactValue(input) + expect(output).toEqual(input) + expect(Object.prototype.hasOwnProperty.call(output, "__proto__")).toBe(true) + expect(({} as Record).polluted).toBeUndefined() + }) + + it("handles cycles without throwing", () => { + const input: Record = {} + input.self = input + expect(redactValue(input)).toEqual({ self: "[CIRCULAR]" }) + }) + + it("preserves repeated non-cyclic references", () => { + const shared = { value: "safe" } + expect(redactValue({ left: shared, right: shared })).toEqual({ + left: { value: "safe" }, + right: { value: "safe" }, + }) + }) + + it("canonicalizes terminal controls and credential value suffixes", () => { + expect(redactText(`API_\u001b]0;title\u0007TOKEN=hunter2`)).toBe("[REDACTED]") + expect(redactText(`API_\u009dtitle\u009cTOKEN=hunter2`)).toBe("[REDACTED]") + expect(redactText("passX\bword=hunter2")).toBe("[REDACTED]") + expect(redactText("passX\u001b[1Dword=hunter2")).toBe("[REDACTED]") + expect(redactText("word=hunter2\rpass")).toBe("[REDACTED]") + expect(redactValue({ apikey: "one", apitoken: "two", authtoken: "three", accesstoken: "four" })).toEqual({ + apikey: "[REDACTED]", + apitoken: "[REDACTED]", + authtoken: "[REDACTED]", + accesstoken: "[REDACTED]", + }) + expect(redactValue({ accessTokenValue: "hunter2", apiKeyValue: "secret", maxTokenValue: 10 })).toEqual({ + accessTokenValue: "[REDACTED]", + apiKeyValue: "[REDACTED]", + maxTokenValue: 10, + }) + }) +}) + +describe("deterministic parity oracle", () => { + it.each(parityScenarios)("accepts the $id golden semantic trace", (scenario) => { + expect(compareSemanticTraces(scenario.expected, runDeterministicFakeProvider(scenario))).toEqual({ ok: true }) + }) + + it("includes the prompt in fake-provider semantics", () => { + const scenario = { ...parityScenarios[0]!, prompt: "Changed prompt" } + expect( + compareSemanticTraces(parityScenarios[0]!.expected, runDeterministicFakeProvider(scenario)), + ).toMatchObject({ + ok: false, + }) + }) + + it("includes tool identity and arguments in fake-provider semantics", () => { + const trace = runDeterministicFakeProvider(parityScenarios[1]!) + expect(trace.find((entry) => entry.type === "tool.started")).toMatchObject({ + toolName: "read_file", + toolArguments: { path: "README.md" }, + }) + }) + + it("detects child completion incorrectly settling the root", () => { + const trace = [ + { type: "task.created", taskId: "root" }, + { type: "task.result", taskId: "child", outcome: "completed" as const }, + ] + expect(assertAuthoritativeRootResult(trace, "root")).toBe(false) + expect(assertAuthoritativeRootResult(parityScenarios[2]!.expected, "root")).toBe(true) + expect( + assertAuthoritativeRootResult([{ type: "task.result", taskId: "root", rootTaskId: "root" }], "root"), + ).toBe(false) + expect( + assertAuthoritativeRootResult( + [{ type: "task.result", taskId: "root", rootTaskId: "root", outcome: "failed" }], + "root", + ), + ).toBe(false) + expect( + assertAuthoritativeRootResult( + [ + { + type: "task.result", + taskId: "root", + rootTaskId: "root", + outcome: "cancelled", + cancellationReason: "invalid" as "user", + }, + ], + "root", + ), + ).toBe(false) + expect( + assertAuthoritativeRootResult( + [{ type: "task.result", taskId: "root", rootTaskId: "root", outcome: "timed_out" }], + "root", + ), + ).toBe(true) + expect( + assertAuthoritativeRootResult( + [ + { + type: "task.result", + taskId: "root", + rootTaskId: "root", + outcome: "completed", + resumable: true, + }, + ], + "root", + ), + ).toBe(false) + expect( + assertAuthoritativeRootResult( + [{ type: "task.result", taskId: "root", rootTaskId: "root", outcome: "needs_input" }], + "root", + ), + ).toBe(false) + }) + + it("reports semantic drift without timestamps", () => { + const expected = parityScenarios[0]!.expected + const result = compareSemanticTraces(expected, expected.slice(0, -1)) + expect(result).toMatchObject({ ok: false }) + }) + + it("models timeout separately and rejects trailing terminal turns", () => { + const timeout = runDeterministicFakeProvider({ + id: "timeout", + prompt: "Timeout", + providerTurns: ["timeout:task_timed_out"], + expected: [], + }) + expect(timeout.at(-1)).toMatchObject({ outcome: "timed_out", errorCode: "task_timed_out" }) + expect(timeout.find((entry) => entry.type === "task.lifecycle")).toMatchObject({ + state: "interrupted", + cause: "timed_out", + }) + expect( + assertAuthoritativeRootResult( + [{ type: "task.result", taskId: "root", rootTaskId: "root", outcome: "timed_out" }], + "root", + ), + ).toBe(true) + const colonArgument = runDeterministicFakeProvider({ + id: "colon-argument", + prompt: "Read URL", + providerTurns: ["tool:read_file:call:https://example.com/a:b"], + expected: [], + }) + expect(colonArgument.find((entry) => entry.type === "tool.started")?.toolArguments).toEqual({ + path: "https://example.com/a:b", + }) + expect(() => + runDeterministicFakeProvider({ + id: "malformed-tool", + prompt: "Read", + providerTurns: ["tool:read_file:call"], + expected: [], + }), + ).toThrow("Invalid tool fixture") + expect(() => + runDeterministicFakeProvider({ + id: "invalid-failure", + prompt: "Fail", + providerTurns: ["fail:task_timed_out"], + expected: [], + }), + ).toThrow() + expect(() => + runDeterministicFakeProvider({ + id: "trailing", + prompt: "Cancel", + providerTurns: ["cancel:cancel-1:user", "trailing"], + expected: [], + }), + ).toThrow() + }) + + it("rejects unresolved fake-provider state and events after the authoritative result", () => { + for (const providerTurns of [ + ["delegate:child"], + ["ask:ask-1"], + ["delegate:child", "fail:provider_failed"], + ["ask:ask-1", "cancel:cancel-1:user"], + ["delegate:child", "timeout:task_timed_out"], + ]) { + expect(() => + runDeterministicFakeProvider({ id: "unresolved", prompt: "Unresolved", providerTurns, expected: [] }), + ).toThrow() + } + expect( + assertAuthoritativeRootResult( + [ + { type: "task.result", taskId: "root", rootTaskId: "root", outcome: "completed" }, + { type: "message.upsert", taskId: "root", content: "late" }, + ], + "root", + ), + ).toBe(false) + expect(() => + runDeterministicFakeProvider({ + id: "ghost", + prompt: "Ghost", + providerTurns: ["ghost:done"], + expected: [], + }), + ).toThrow() + expect(() => + runDeterministicFakeProvider({ + id: "duplicate", + prompt: "Duplicate", + providerTurns: ["delegate:child", "delegate:child"], + expected: [], + }), + ).toThrow() + for (const providerTurns of [ + ["delegate:root"], + ["tool:read_file:call-1:a", "tool:read_file:call-1:b"], + ["ask:ask-1", "approve:ask-1:user:request-1", "ask:ask-1"], + ["ask:ask-1", "approve:ask-1:user:request-1", "ask:ask-2", "approve:ask-2:user:request-1"], + ]) { + expect(() => + runDeterministicFakeProvider({ id: "reused", prompt: "Reuse", providerTurns, expected: [] }), + ).toThrow() + } + }) + + it("rejects extra approval and cancellation fixture fields", () => { + for (const providerTurns of [ + ["ask:ask-1", "approve:ask-1:user:request-1:extra"], + ["cancel:request-1:user:extra"], + ]) { + expect(() => + runDeterministicFakeProvider({ + id: "extra-fields", + prompt: "Reject extras", + providerTurns, + expected: [], + }), + ).toThrow() + } + }) + + it("ignores object property insertion order without ignoring event order", () => { + const expected = [{ type: "message.upsert", taskId: "root", content: "hello" }] + const reordered = [{ content: "hello", taskId: "root", type: "message.upsert" }] + expect(compareSemanticTraces(expected, reordered)).toEqual({ ok: true }) + expect(compareSemanticTraces(expected, [...reordered, ...reordered])).toMatchObject({ ok: false }) + }) +}) diff --git a/packages/zoo-protocol/src/command-lifecycle.ts b/packages/zoo-protocol/src/command-lifecycle.ts new file mode 100644 index 0000000000..e3c81abf3f --- /dev/null +++ b/packages/zoo-protocol/src/command-lifecycle.ts @@ -0,0 +1,98 @@ +import type { HostCommand } from "./host-commands.js" +import type { HostEvent } from "./host-events.js" + +export function validateCommandLifecycle( + commands: readonly HostCommand[], + events: readonly HostEvent[], + hostId: string, +): { ok: true } | { ok: false; commandId: string; message: string } { + const commandById = new Map() + const startedRoots = new Set() + for (const command of commands) { + if (commandById.has(command.id)) { + return { ok: false, commandId: command.id, message: "Command IDs must be unique" } + } + commandById.set(command.id, command) + } + + for (const [index, event] of events.entries()) { + if (event.hostId !== hostId) { + const commandId = "commandId" in event ? event.commandId : commands[0]?.id ?? "unknown" + return { ok: false, commandId, message: "Command lifecycle cannot span multiple hosts" } + } + if ( + (event.type === "command.ack" || event.type === "command.done" || event.type === "command.error") && + !commandById.has(event.commandId) + ) { + return { ok: false, commandId: event.commandId, message: "Response references an unknown command" } + } + if (index > 0) { + const expected = events[index - 1]!.seq + 1 + if (event.seq !== expected) { + const commandId = "commandId" in event ? event.commandId : commands[0]?.id ?? "unknown" + return { ok: false, commandId, message: `Expected host sequence ${expected}` } + } + } + } + + for (const command of commands) { + const commandId = command.id + const commandEvents = events.filter( + (event) => + (event.type === "command.ack" || event.type === "command.done" || event.type === "command.error") && + event.commandId === commandId, + ) + const acknowledgements = commandEvents.filter((event) => event.type === "command.ack") + const terminals = commandEvents.filter((event) => event.type === "command.done" || event.type === "command.error") + if (acknowledgements.length !== 1) { + return { ok: false, commandId, message: `Expected one ACK, received ${acknowledgements.length}` } + } + if (terminals.length !== 1) { + return { ok: false, commandId, message: `Expected one DONE or ERROR, received ${terminals.length}` } + } + if (acknowledgements[0]!.seq >= terminals[0]!.seq) { + return { ok: false, commandId, message: "ACK must precede DONE or ERROR" } + } + const terminal = terminals[0]! + if (terminal.type === "command.done") { + const data = terminal.data + const matches = (() => { + switch (command.type) { + case "task.start": + return data.commandType === command.type && data.task.taskId === data.task.rootTaskId + case "task.resume": + return ( + data.commandType === command.type && + data.task.taskId === command.taskId && + data.task.rootTaskId === command.rootTaskId + ) + case "task.input": + return data.commandType === command.type && data.taskId === command.taskId + case "ask.respond": + return data.commandType === command.type && data.taskId === command.taskId && data.askId === command.askId + case "task.cancel": + return data.commandType === command.type && data.rootTaskId === command.rootTaskId + case "history.list": + return ( + data.commandType === command.type && + data.workspace === command.workspace && + data.tasks.every((task) => task.workspace === command.workspace) + ) + case "host.snapshot": + case "host.shutdown": + return data.commandType === command.type + } + })() + if (!matches) { + return { ok: false, commandId, message: "DONE payload does not match the originating command" } + } + if (command.type === "task.start" && data.commandType === "task.start") { + if (startedRoots.has(data.task.rootTaskId)) { + return { ok: false, commandId, message: "Successful task starts must return unique root task IDs" } + } + startedRoots.add(data.task.rootTaskId) + } + } + } + return { ok: true } +} diff --git a/packages/zoo-protocol/src/host-commands.ts b/packages/zoo-protocol/src/host-commands.ts new file mode 100644 index 0000000000..3cbd4e7680 --- /dev/null +++ b/packages/zoo-protocol/src/host-commands.ts @@ -0,0 +1,116 @@ +import { z } from "zod" + +import { ZOO_HOST_PROTOCOL_VERSION } from "./version.js" + +export const approvalModeSchema = z.enum(["interactive", "safe", "auto"]) +export type ApprovalMode = z.infer + +export const reasoningEffortSchema = z.enum([ + "disabled", + "none", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max", +]) + +export const runOverridesSchema = z + .object({ + provider: z.string().min(1).optional(), + profile: z.string().min(1).optional(), + model: z.string().min(1).optional(), + mode: z.string().min(1).optional(), + reasoningEffort: reasoningEffortSchema.optional(), + approval: approvalModeSchema.optional(), + }) + .strict() + .refine(({ profile, provider }) => !(profile && provider), { + message: "profile and provider are mutually exclusive", + }) + +export type RunOverrides = z.infer + +const commandBaseSchema = z + .object({ + v: z.literal(ZOO_HOST_PROTOCOL_VERSION), + id: z.string().min(1), + }) + .strict() + +const taskStartCommandSchema = commandBaseSchema + .extend({ + type: z.literal("task.start"), + workspace: z.string().min(1), + prompt: z.string().refine((prompt) => prompt.trim().length > 0, "Prompt cannot be blank"), + overrides: runOverridesSchema.optional(), + }) + .strict() + +const taskResumeCommandSchema = commandBaseSchema + .extend({ + type: z.literal("task.resume"), + taskId: z.string().min(1), + rootTaskId: z.string().min(1), + overrides: runOverridesSchema.optional(), + }) + .strict() + +const taskInputCommandSchema = commandBaseSchema + .extend({ + type: z.literal("task.input"), + taskId: z.string().min(1), + text: z.string().refine((text) => text.trim().length > 0, "Input cannot be blank").optional(), + images: z.array(z.string().min(1)).min(1).optional(), + }) + .strict() + +const askRespondCommandSchema = commandBaseSchema + .extend({ + type: z.literal("ask.respond"), + taskId: z.string().min(1), + askId: z.string().min(1), + response: z.enum(["approve", "reject", "message"]), + text: z.string().refine((text) => text.trim().length > 0, "Response cannot be blank").optional(), + }) + .strict() + +const taskCancelCommandSchema = commandBaseSchema + .extend({ + type: z.literal("task.cancel"), + rootTaskId: z.string().min(1), + reason: z.enum(["user", "signal", "timeout"]), + }) + .strict() + +const historyListCommandSchema = commandBaseSchema + .extend({ type: z.literal("history.list"), workspace: z.string().min(1) }) + .strict() +const hostSnapshotCommandSchema = commandBaseSchema.extend({ type: z.literal("host.snapshot") }).strict() +const hostShutdownCommandSchema = commandBaseSchema.extend({ type: z.literal("host.shutdown") }).strict() + +const hostCommandDiscriminatedSchema = z.discriminatedUnion("type", [ + taskStartCommandSchema, + taskResumeCommandSchema, + taskInputCommandSchema, + askRespondCommandSchema, + taskCancelCommandSchema, + historyListCommandSchema, + hostSnapshotCommandSchema, + hostShutdownCommandSchema, +]) + +export const hostCommandSchema = hostCommandDiscriminatedSchema.superRefine((command, context) => { + if (command.type === "task.input" && command.text === undefined && command.images === undefined) { + context.addIssue({ code: z.ZodIssueCode.custom, message: "task.input requires text or images" }) + } + if (command.type === "ask.respond" && (command.response === "message") !== (command.text !== undefined)) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: "message responses require text and other responses forbid it", + }) + } +}) + +export type HostCommand = z.infer diff --git a/packages/zoo-protocol/src/host-events.ts b/packages/zoo-protocol/src/host-events.ts new file mode 100644 index 0000000000..0c54faf4d5 --- /dev/null +++ b/packages/zoo-protocol/src/host-events.ts @@ -0,0 +1,316 @@ +import { z } from "zod" + +import { zooErrorSchema } from "./outcomes.js" +import { + createZooStreamRedactor, + rawZooStreamEventSchema, + zooStreamEventSchema, + type RawZooStreamEvent, +} from "./public-events.js" +import { redactText } from "./redaction.js" +import { ZOO_HOST_PROTOCOL_VERSION } from "./version.js" + +const base = { + v: z.literal(ZOO_HOST_PROTOCOL_VERSION), + seq: z.number().int().safe().positive(), + hostId: z.string().min(1), +} + +const strictObject = (shape: T) => z.object(shape).strict() + +const taskReferenceSchema = strictObject({ + rootTaskId: z.string().min(1), + taskId: z.string().min(1), +}) + +const taskSummarySchema = strictObject({ + rootTaskId: z.string().min(1), + currentTaskId: z.string().min(1), + workspace: z.string().min(1), + state: z.enum(["running", "waiting", "interrupted", "completed", "failed"]), +}) + +export const commandDoneDataSchema = z.discriminatedUnion("commandType", [ + strictObject({ commandType: z.literal("task.start"), task: taskReferenceSchema }), + strictObject({ commandType: z.literal("task.resume"), task: taskReferenceSchema }), + strictObject({ commandType: z.literal("task.input"), taskId: z.string().min(1) }), + strictObject({ commandType: z.literal("ask.respond"), taskId: z.string().min(1), askId: z.string().min(1) }), + strictObject({ commandType: z.literal("task.cancel"), rootTaskId: z.string().min(1) }), + strictObject({ + commandType: z.literal("history.list"), + workspace: z.string().min(1), + tasks: z.array(taskSummarySchema), + }), + strictObject({ + commandType: z.literal("host.snapshot"), + lastSeq: z.number().int().safe().nonnegative(), + activeRootTaskId: z.string().min(1).optional(), + }), + strictObject({ commandType: z.literal("host.shutdown") }), +]) + +const commandAckSchema = strictObject({ ...base, type: z.literal("command.ack"), commandId: z.string().min(1) }) +const commandDoneSchema = strictObject({ + ...base, + type: z.literal("command.done"), + commandId: z.string().min(1), + data: commandDoneDataSchema, +}) +const commandErrorSchema = strictObject({ + ...base, + type: z.literal("command.error"), + commandId: z.string().min(1), + error: zooErrorSchema, +}) +const heartbeatSchema = strictObject({ + ...base, + type: z.literal("host.heartbeat"), + monotonicMs: z.number().finite().nonnegative(), +}) +const snapshotSchema = strictObject({ + ...base, + type: z.literal("host.snapshot"), + lastSeq: z.number().int().safe().nonnegative(), + activeRootTaskId: z.string().min(1).optional(), +}) +const normalizedEventSchema = strictObject({ ...base, type: z.literal("event"), event: zooStreamEventSchema }) +const rawNormalizedEventSchema = strictObject({ ...base, type: z.literal("event"), event: rawZooStreamEventSchema }) + +const hostEventDiscriminatedSchema = z.discriminatedUnion("type", [ + commandAckSchema, + commandDoneSchema, + commandErrorSchema, + heartbeatSchema, + snapshotSchema, + normalizedEventSchema, +]) + +const rawHostEventDiscriminatedSchema = z.discriminatedUnion("type", [ + commandAckSchema, + commandDoneSchema, + commandErrorSchema, + heartbeatSchema, + snapshotSchema, + rawNormalizedEventSchema, +]) + +export const hostEventSchema = hostEventDiscriminatedSchema + .superRefine((event, context) => { + if (event.type === "event" && event.event.hostId !== event.hostId) { + context.addIssue({ code: z.ZodIssueCode.custom, message: "Normalized event hostId must match its host envelope" }) + } + }) + .transform((event) => + event.type === "command.error" + ? { + ...event, + error: { + ...event.error, + message: redactText(event.error.message), + phase: event.error.phase === undefined ? undefined : redactText(event.error.phase), + }, + } + : event, + ) + +export type HostEvent = z.infer + +export type HostEventStreamParser = { + push: (event: unknown) => HostEvent[] + tick: () => HostEvent[] + flush: () => HostEvent[] +} + +export function createHostEventStreamParser( + options: { + hostId: string + maxPendingBytes?: number + maxPendingEvents?: number + maxPendingStreams?: number + maxQueuedEvents?: number + maxQueuedBytes?: number + maxInputBytes?: number + maxPendingMs?: number + now?: () => number + }, +): HostEventStreamParser { + const redactor = createZooStreamRedactor(options) + const maxQueuedEvents = options.maxQueuedEvents ?? 512 + const maxQueuedBytes = options.maxQueuedBytes ?? 1024 * 1024 + const maxInputBytes = options.maxInputBytes ?? 1024 * 1024 + const maxPendingMs = options.maxPendingMs ?? 1_000 + const now = options.now ?? Date.now + type QueueEntry = { + envelope?: z.infer + output?: HostEvent + enqueuedAt: number + bytes: number + } + const queue: QueueEntry[] = [] + let queuedBytes = 0 + const envelopes = new Map() + const pinnedHostId = options.hostId + let lastSeq: number | undefined + const encoder = new TextEncoder() + const inputSize = (value: unknown): number => { + const seen = new WeakSet() + const stack: Array<{ value: unknown; depth: number }> = [{ value, depth: 0 }] + let bytes = 0 + while (stack.length > 0) { + const current = stack.pop()! + if (current.depth > 64) return maxInputBytes + 1 + if (current.value === null || typeof current.value !== "object") { + let serialized: string | undefined + try { + serialized = JSON.stringify(current.value) + } catch { + return maxInputBytes + 1 + } + if (serialized === undefined) return maxInputBytes + 1 + bytes += encoder.encode(serialized).byteLength + } else { + if (seen.has(current.value)) return maxInputBytes + 1 + seen.add(current.value) + if (Array.isArray(current.value)) { + bytes += 2 + Math.max(0, current.value.length - 1) + for (let index = current.value.length - 1; index >= 0; index -= 1) { + stack.push({ value: current.value[index], depth: current.depth + 1 }) + } + } else { + const entries = Object.entries(current.value) + bytes += 2 + Math.max(0, entries.length - 1) + for (let index = entries.length - 1; index >= 0; index -= 1) { + const [key, entry] = entries[index]! + bytes += encoder.encode(JSON.stringify(key)).byteLength + 1 + stack.push({ value: entry, depth: current.depth + 1 }) + } + } + } + if (bytes > maxInputBytes) return bytes + } + return bytes + } + const eventKey = (event: RawZooStreamEvent) => + JSON.stringify([ + event.hostId, + event.seq, + event.timestamp, + event.type, + event.requestId, + "rootTaskId" in event ? event.rootTaskId : undefined, + "taskId" in event ? event.taskId : undefined, + "messageId" in event ? event.messageId : undefined, + "askId" in event ? event.askId : undefined, + "toolCallId" in event ? event.toolCallId : undefined, + "operationId" in event ? event.operationId : undefined, + "stream" in event ? event.stream : undefined, + ]) + const assign = (events: ReturnType) => { + for (const event of events) { + const key = eventKey(event) + const entries = envelopes.get(key) + const entry = entries?.shift() + if (entry === undefined) throw new Error("Missing host envelope for buffered Zoo stream event") + if (entries?.length === 0) envelopes.delete(key) + const envelope = entry.envelope + if (envelope === undefined) { + throw new Error("Missing host envelope for buffered Zoo stream event") + } + entry.output = { ...envelope, event } + } + } + const drain = (): HostEvent[] => { + const ready: HostEvent[] = [] + while (queue[0]?.output !== undefined) { + const entry = queue.shift()! + queuedBytes -= entry.bytes + ready.push(entry.output!) + } + return ready + } + const releaseBlockedQueue = (): HostEvent[] => { + const ready: HostEvent[] = [] + while (true) { + ready.push(...drain()) + const oldest = queue[0] + if ( + oldest === undefined || + oldest.output !== undefined || + (queue.length < maxQueuedEvents && + queuedBytes < maxQueuedBytes && + now() - oldest.enqueuedAt < maxPendingMs) + ) { + return ready + } + assign(redactor.failClosed(oldest.envelope?.event)) + } + } + const sanitizeNonEvent = (event: z.infer): HostEvent => + event.type === "command.error" + ? { + ...event, + error: { + ...event.error, + message: redactText(event.error.message), + phase: event.error.phase === undefined ? undefined : redactText(event.error.phase), + }, + } + : event as HostEvent + + return { + push(input) { + if (inputSize(input) > maxInputBytes) throw new Error("Host event exceeds the input limit") + const event = rawHostEventDiscriminatedSchema.parse(input) + if (event.hostId !== pinnedHostId) { + throw new Error("Host event stream cannot span multiple hosts") + } + if (lastSeq !== undefined && !validateMonotonicSequence(lastSeq, event.seq).ok) { + throw new Error(`Expected host sequence ${lastSeq + 1}`) + } + if (event.type === "event" && event.event.hostId !== event.hostId) { + throw new Error("Normalized event hostId must match its host envelope") + } + let bytes: number + try { + bytes = encoder.encode(JSON.stringify(event)).byteLength + } catch { + bytes = maxQueuedBytes + } + lastSeq = event.seq + const released = releaseBlockedQueue() + const entry: QueueEntry = { enqueuedAt: now(), bytes } + queue.push(entry) + queuedBytes += bytes + if (event.type !== "event") { + entry.output = sanitizeNonEvent(event) + return [...released, ...releaseBlockedQueue()] + } + entry.envelope = event + const key = eventKey(event.event) + const entries = envelopes.get(key) ?? [] + entries.push(entry) + envelopes.set(key, entries) + assign(redactor.push(event.event)) + return [...released, ...releaseBlockedQueue()] + }, + tick: releaseBlockedQueue, + flush() { + assign(redactor.flush()) + const output = drain() + if (queue.length > 0 || envelopes.size > 0) throw new Error("Host event stream contains unflushed events") + return output + }, + } +} + +export function validateMonotonicSequence( + previous: number, + next: number, +): { ok: true } | { ok: false; expected: number } { + const expected = previous + 1 + return Number.isSafeInteger(previous) && Number.isSafeInteger(next) && next === expected + ? { ok: true } + : { ok: false, expected } +} + +export { validateCommandLifecycle } from "./command-lifecycle.js" diff --git a/packages/zoo-protocol/src/index.ts b/packages/zoo-protocol/src/index.ts new file mode 100644 index 0000000000..ca3718595f --- /dev/null +++ b/packages/zoo-protocol/src/index.ts @@ -0,0 +1,17 @@ +export * from "./host-commands.js" +export * from "./host-events.js" +export * from "./outcomes.js" +export * from "./parity.js" +export { + changedFileSchema, + usageSchema, + validateNegotiatedStreamSession, + validateStreamLifecycle, + zooRunResultSchema, + zooStreamEventSchema, + zooStreamSchema, + type ZooRunResult, + type ZooStreamEvent, +} from "./public-events.js" +export * from "./redaction.js" +export * from "./version.js" diff --git a/packages/zoo-protocol/src/outcomes.ts b/packages/zoo-protocol/src/outcomes.ts new file mode 100644 index 0000000000..89a9d5ae60 --- /dev/null +++ b/packages/zoo-protocol/src/outcomes.ts @@ -0,0 +1,106 @@ +import { z } from "zod" + +export const zooOutcomeSchema = z.enum(["completed", "needs_input", "cancelled", "timed_out", "failed"]) +export type ZooOutcome = z.infer + +export const zooErrorCodes = [ + "invalid_usage", + "invalid_workspace", + "invalid_provider", + "invalid_profile", + "invalid_model", + "invalid_mode", + "invalid_session", + "credentials_missing", + "permission_required", + "permission_denied", + "outside_workspace", + "protected_path", + "provider_failed", + "task_failed", + "host_start_failed", + "host_crashed", + "protocol_incompatible", + "protocol_gap", + "cancel_failed", + "cleanup_timed_out", + "task_timed_out", + "output_closed", +] as const + +export const zooErrorCodeSchema = z.enum(zooErrorCodes) +export type ZooErrorCode = z.infer + +export const failedErrorCodeSchema = z.enum( + zooErrorCodes.filter((code) => code !== "task_timed_out" && code !== "cleanup_timed_out") as [ + Exclude, + ...Exclude[], + ], +) + +export const zooErrorKindSchema = z.enum(["configuration", "provider", "runtime"]) +export type ZooErrorKind = z.infer + +export const zooErrorSchema = z + .object({ + code: zooErrorCodeSchema, + message: z.string().min(1), + kind: zooErrorKindSchema.optional(), + phase: z.string().min(1).optional(), + }) + .strict() + +export type ZooError = z.infer + +export const EXIT_CODES = { + completed: 0, + usage: 2, + needsInput: 3, + cancelled: 4, + providerFailure: 10, + runtimeFailure: 70, + timedOut: 124, + sigint: 130, + sigterm: 143, +} as const + +const usageErrors = new Set([ + "invalid_usage", + "invalid_workspace", + "invalid_provider", + "invalid_profile", + "invalid_model", + "invalid_mode", + "invalid_session", + "credentials_missing", +]) + +export const exitContextSchema = z.discriminatedUnion("outcome", [ + z.object({ outcome: z.literal("completed") }).strict(), + z.object({ outcome: z.literal("needs_input") }).strict(), + z.object({ outcome: z.literal("cancelled"), signal: z.enum(["SIGINT", "SIGTERM"]).optional() }).strict(), + z + .object({ + outcome: z.literal("timed_out"), + errorCode: z.enum(["task_timed_out", "cleanup_timed_out"]).optional(), + }) + .strict(), + z.object({ outcome: z.literal("failed"), errorCode: failedErrorCodeSchema }).strict(), +]) + +export type ExitContext = z.infer + +export function exitCodeFor(context: ExitContext): number { + const { outcome } = exitContextSchema.parse(context) + const errorCode = "errorCode" in context ? context.errorCode : undefined + const signal = "signal" in context ? context.signal : undefined + if (signal === "SIGINT") return EXIT_CODES.sigint + if (signal === "SIGTERM") return EXIT_CODES.sigterm + if (errorCode && usageErrors.has(errorCode)) return EXIT_CODES.usage + if (outcome === "completed") return EXIT_CODES.completed + if (outcome === "needs_input") return EXIT_CODES.needsInput + if (outcome === "cancelled") return EXIT_CODES.cancelled + if (outcome === "timed_out") return EXIT_CODES.timedOut + if (errorCode === "provider_failed") return EXIT_CODES.providerFailure + return EXIT_CODES.runtimeFailure +} diff --git a/packages/zoo-protocol/src/parity.ts b/packages/zoo-protocol/src/parity.ts new file mode 100644 index 0000000000..c40054cfb3 --- /dev/null +++ b/packages/zoo-protocol/src/parity.ts @@ -0,0 +1,391 @@ +import { + failedErrorCodeSchema, + type ZooErrorCode, + type ZooOutcome, + zooErrorCodeSchema, + zooOutcomeSchema, +} from "./outcomes.js" + +export type SemanticTraceEntry = { + type: string + taskId?: string + rootTaskId?: string + parentTaskId?: string + toolCallId?: string + toolName?: string + toolArguments?: Record + state?: "running" | "waiting" | "interrupted" | "completed" | "failed" + cause?: "cancelled" | "timed_out" | "failed" + askId?: string + decision?: "approve" | "reject" | "needs_input" + source?: "policy" | "user" | "auto" | "deny" + requestId?: string + cancellationReason?: "user" | "signal" | "timeout" + content?: string + prompt?: string + outcome?: ZooOutcome + errorCode?: ZooErrorCode + resumable?: boolean +} + +export type ParityScenario = { + id: string + prompt: string + providerTurns: readonly string[] + expected: readonly SemanticTraceEntry[] +} + +export const parityScenarios: readonly ParityScenario[] = [ + { + id: "text-completion", + prompt: "Reply with the fixture greeting.", + providerTurns: ["Hello from Zoo."], + expected: [ + { type: "task.created", rootTaskId: "root", taskId: "root", prompt: "Reply with the fixture greeting." }, + { type: "task.started", rootTaskId: "root", taskId: "root" }, + { type: "message.upsert", rootTaskId: "root", taskId: "root", content: "Hello from Zoo." }, + { type: "task.lifecycle", rootTaskId: "root", taskId: "root", state: "completed" }, + { type: "task.result", rootTaskId: "root", taskId: "root", outcome: "completed" }, + ], + }, + { + id: "tool-pairing", + prompt: "Read README.md and report its title.", + providerTurns: ["tool:read_file:call-1:README.md", "Zoo Code"], + expected: [ + { + type: "task.created", + rootTaskId: "root", + taskId: "root", + prompt: "Read README.md and report its title.", + }, + { type: "task.started", rootTaskId: "root", taskId: "root" }, + { + type: "tool.started", + rootTaskId: "root", + taskId: "root", + toolCallId: "call-1", + toolName: "read_file", + toolArguments: { path: "README.md" }, + }, + { + type: "tool.completed", + rootTaskId: "root", + taskId: "root", + toolCallId: "call-1", + toolName: "read_file", + toolArguments: { path: "README.md" }, + }, + { type: "message.upsert", rootTaskId: "root", taskId: "root", content: "Zoo Code" }, + { type: "task.lifecycle", rootTaskId: "root", taskId: "root", state: "completed" }, + { type: "task.result", rootTaskId: "root", taskId: "root", outcome: "completed" }, + ], + }, + { + id: "delegation-root-authority", + prompt: "Delegate once, then finish the root task.", + providerTurns: ["delegate:child", "child:done", "root:accepted"], + expected: [ + { + type: "task.created", + rootTaskId: "root", + taskId: "root", + prompt: "Delegate once, then finish the root task.", + }, + { type: "task.started", rootTaskId: "root", taskId: "root" }, + { type: "task.created", rootTaskId: "root", taskId: "child", parentTaskId: "root" }, + { type: "task.delegated", rootTaskId: "root", taskId: "child", parentTaskId: "root" }, + { type: "task.started", rootTaskId: "root", taskId: "child" }, + { type: "task.lifecycle", rootTaskId: "root", taskId: "child", state: "completed" }, + { type: "message.upsert", rootTaskId: "root", taskId: "root", content: "root:accepted" }, + { type: "task.lifecycle", rootTaskId: "root", taskId: "root", state: "completed" }, + { type: "task.result", rootTaskId: "root", taskId: "root", outcome: "completed" }, + ], + }, + { + id: "approval-causation", + prompt: "Request approval.", + providerTurns: ["ask:ask-1", "approve:ask-1:user:respond-1"], + expected: [ + { type: "task.created", rootTaskId: "root", taskId: "root", prompt: "Request approval." }, + { type: "task.started", rootTaskId: "root", taskId: "root" }, + { type: "ask.required", rootTaskId: "root", taskId: "root", askId: "ask-1" }, + { type: "task.lifecycle", rootTaskId: "root", taskId: "root", state: "waiting" }, + { + type: "ask.resolved", + rootTaskId: "root", + taskId: "root", + askId: "ask-1", + decision: "approve", + source: "user", + requestId: "respond-1", + }, + { type: "task.lifecycle", rootTaskId: "root", taskId: "root", state: "running" }, + { type: "task.lifecycle", rootTaskId: "root", taskId: "root", state: "completed" }, + { type: "task.result", rootTaskId: "root", taskId: "root", outcome: "completed" }, + ], + }, + { + id: "cancelled", + prompt: "Cancel deterministically.", + providerTurns: ["cancel:cancel-1:user"], + expected: [ + { type: "task.created", rootTaskId: "root", taskId: "root", prompt: "Cancel deterministically." }, + { type: "task.started", rootTaskId: "root", taskId: "root" }, + { type: "task.lifecycle", rootTaskId: "root", taskId: "root", state: "interrupted", cause: "cancelled" }, + { + type: "task.result", + rootTaskId: "root", + taskId: "root", + outcome: "cancelled", + requestId: "cancel-1", + cancellationReason: "user", + }, + ], + }, + { + id: "provider-failure", + prompt: "Fail deterministically.", + providerTurns: ["fail:provider_failed"], + expected: [ + { type: "task.created", rootTaskId: "root", taskId: "root", prompt: "Fail deterministically." }, + { type: "task.started", rootTaskId: "root", taskId: "root" }, + { type: "task.lifecycle", rootTaskId: "root", taskId: "root", state: "failed" }, + { + type: "task.result", + rootTaskId: "root", + taskId: "root", + outcome: "failed", + errorCode: "provider_failed", + }, + ], + }, + { + id: "needs-input", + prompt: "Wait for deterministic input.", + providerTurns: ["ask:ask-1", "needs_input"], + expected: [ + { type: "task.created", rootTaskId: "root", taskId: "root", prompt: "Wait for deterministic input." }, + { type: "task.started", rootTaskId: "root", taskId: "root" }, + { type: "ask.required", rootTaskId: "root", taskId: "root", askId: "ask-1" }, + { type: "task.lifecycle", rootTaskId: "root", taskId: "root", state: "waiting" }, + { type: "task.result", rootTaskId: "root", taskId: "root", outcome: "needs_input", resumable: true }, + ], + }, +] + +export function compareSemanticTraces( + expected: readonly SemanticTraceEntry[], + actual: readonly SemanticTraceEntry[], +): { ok: true } | { ok: false; difference: string } { + const canonicalize = (value: unknown): unknown => { + if (Array.isArray(value)) return value.map(canonicalize) + if (value === null || typeof value !== "object") return value + return Object.fromEntries( + Object.entries(value) + .filter(([, entry]) => entry !== undefined) + .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)) + .map(([key, entry]) => [key, canonicalize(entry)]), + ) + } + const expectedJson = JSON.stringify(canonicalize(expected)) + const actualJson = JSON.stringify(canonicalize(actual)) + return expectedJson === actualJson + ? { ok: true } + : { ok: false, difference: `Expected ${expectedJson}\nReceived ${actualJson}` } +} + +export function runDeterministicFakeProvider(scenario: ParityScenario): readonly SemanticTraceEntry[] { + if (scenario.prompt.trim().length === 0) throw new Error("Fake-provider scenarios require a prompt") + + const trace: SemanticTraceEntry[] = [ + { type: "task.created", rootTaskId: "root", taskId: "root", prompt: scenario.prompt }, + { type: "task.started", rootTaskId: "root", taskId: "root" }, + ] + let result: SemanticTraceEntry = { type: "task.result", rootTaskId: "root", taskId: "root", outcome: "completed" } + let terminalReached = false + const activeChildren = new Set() + const usedTaskIds = new Set(["root"]) + const pendingAsks = new Set() + const usedAskIds = new Set() + const usedToolCallIds = new Set() + const usedRequestIds = new Set() + const requireSettledState = () => { + if (activeChildren.size > 0 || pendingAsks.size > 0) { + throw new Error("Fake-provider terminal outcomes require settled descendants and asks") + } + } + for (const turn of scenario.providerTurns) { + if (terminalReached) throw new Error("Fake-provider terminal directives must be the final turn") + if (turn.startsWith("tool:")) { + const separator1 = turn.indexOf(":") + const separator2 = turn.indexOf(":", separator1 + 1) + const separator3 = turn.indexOf(":", separator2 + 1) + if (separator2 < 0 || separator3 < 0) throw new Error(`Invalid tool fixture: ${turn}`) + const operation = turn.slice(separator1 + 1, separator2) + const toolCallId = turn.slice(separator2 + 1, separator3) + const argument = turn.slice(separator3 + 1) + if (operation !== "read_file" || !toolCallId || !argument || usedToolCallIds.has(toolCallId)) { + throw new Error(`Invalid tool fixture: ${turn}`) + } + usedToolCallIds.add(toolCallId) + const tool = { + rootTaskId: "root", + taskId: "root", + toolCallId, + toolName: operation, + toolArguments: { path: argument }, + } + trace.push({ type: "tool.started", ...tool }) + trace.push({ type: "tool.completed", ...tool }) + continue + } + if (turn.startsWith("delegate:")) { + const taskId = turn.slice("delegate:".length) + if (!taskId || usedTaskIds.has(taskId)) throw new Error(`Invalid delegation fixture: ${turn}`) + trace.push({ type: "task.created", rootTaskId: "root", taskId, parentTaskId: "root" }) + trace.push({ type: "task.delegated", rootTaskId: "root", taskId, parentTaskId: "root" }) + trace.push({ type: "task.started", rootTaskId: "root", taskId }) + activeChildren.add(taskId) + usedTaskIds.add(taskId) + continue + } + if (turn.endsWith(":done")) { + const taskId = turn.slice(0, -":done".length) + if (!taskId || !activeChildren.has(taskId)) throw new Error(`Invalid completion fixture: ${turn}`) + trace.push({ type: "task.lifecycle", rootTaskId: "root", taskId, state: "completed" }) + activeChildren.delete(taskId) + continue + } + if (turn.startsWith("ask:")) { + const askId = turn.slice(4) + if (!askId || usedAskIds.has(askId)) throw new Error(`Invalid ask fixture: ${turn}`) + const wasUnblocked = pendingAsks.size === 0 + pendingAsks.add(askId) + usedAskIds.add(askId) + trace.push({ type: "ask.required", rootTaskId: "root", taskId: "root", askId }) + if (wasUnblocked) trace.push({ type: "task.lifecycle", rootTaskId: "root", taskId: "root", state: "waiting" }) + continue + } + if (turn.startsWith("approve:")) { + const fields = turn.split(":") + const [, askId, source, requestId] = fields + if (!askId || source !== "user" || !requestId || usedRequestIds.has(requestId)) { + throw new Error(`Invalid approval fixture: ${turn}`) + } + if (fields.length !== 4) throw new Error(`Invalid approval fixture: ${turn}`) + if (!pendingAsks.delete(askId)) throw new Error(`Approval references unknown ask: ${turn}`) + usedRequestIds.add(requestId) + trace.push({ + type: "ask.resolved", + rootTaskId: "root", + taskId: "root", + askId, + decision: "approve", + source, + requestId, + }) + if (pendingAsks.size === 0) { + trace.push({ type: "task.lifecycle", rootTaskId: "root", taskId: "root", state: "running" }) + } + continue + } + if (turn.startsWith("cancel:")) { + requireSettledState() + const fields = turn.split(":") + const [, requestId, cancellationReason] = fields + if ( + fields.length !== 3 || + !requestId || + usedRequestIds.has(requestId) || + !["user", "signal", "timeout"].includes(cancellationReason ?? "") + ) + throw new Error(`Invalid cancellation fixture: ${turn}`) + usedRequestIds.add(requestId) + trace.push({ type: "task.lifecycle", rootTaskId: "root", taskId: "root", state: "interrupted", cause: "cancelled" }) + result = { + type: "task.result", + rootTaskId: "root", + taskId: "root", + outcome: "cancelled", + requestId, + cancellationReason: cancellationReason as "user" | "signal" | "timeout", + } + terminalReached = true + continue + } + if (turn.startsWith("fail:")) { + requireSettledState() + const errorCode = failedErrorCodeSchema.parse(turn.slice(5)) + trace.push({ type: "task.lifecycle", rootTaskId: "root", taskId: "root", state: "failed" }) + result = { type: "task.result", rootTaskId: "root", taskId: "root", outcome: "failed", errorCode } + terminalReached = true + continue + } + if (turn.startsWith("timeout:")) { + requireSettledState() + const errorCode = zooErrorCodeSchema.parse(turn.slice(8)) + if (errorCode !== "task_timed_out" && errorCode !== "cleanup_timed_out") { + throw new Error(`Invalid timeout fixture: ${turn}`) + } + trace.push({ type: "task.lifecycle", rootTaskId: "root", taskId: "root", state: "interrupted", cause: "timed_out" }) + result = { type: "task.result", rootTaskId: "root", taskId: "root", outcome: "timed_out", errorCode } + terminalReached = true + continue + } + if (turn === "needs_input") { + if (activeChildren.size > 0 || pendingAsks.size === 0) { + throw new Error("needs_input requires a pending ask and settled descendants") + } + result = { + type: "task.result", + rootTaskId: "root", + taskId: "root", + outcome: "needs_input", + resumable: true, + } + terminalReached = true + continue + } + trace.push({ type: "message.upsert", rootTaskId: "root", taskId: "root", content: turn }) + } + if (result.outcome === "completed") { + requireSettledState() + trace.push({ type: "task.lifecycle", rootTaskId: "root", taskId: "root", state: "completed" }) + } + trace.push(result) + return trace +} + +export function assertAuthoritativeRootResult(trace: readonly SemanticTraceEntry[], rootTaskId: string): boolean { + const results = trace.filter((entry) => entry.type === "task.result") + if (results.length !== 1) return false + const result = results[0]! + if (trace.at(-1) !== result) return false + if ( + result.taskId !== rootTaskId || + result.rootTaskId !== rootTaskId || + !zooOutcomeSchema.safeParse(result.outcome).success || + (result.outcome === "needs_input" && result.resumable !== true) || + (result.resumable === true && !["needs_input", "cancelled", "timed_out"].includes(result.outcome ?? "")) + ) { + return false + } + if (result.outcome === "failed") { + return failedErrorCodeSchema.safeParse(result.errorCode).success && result.cancellationReason === undefined + } + if (result.outcome === "timed_out") { + return ( + (result.errorCode === undefined || ["task_timed_out", "cleanup_timed_out"].includes(result.errorCode)) && + result.cancellationReason === undefined + ) + } + if (result.outcome === "cancelled") { + return ( + result.errorCode === undefined && + result.cancellationReason !== undefined && + ["user", "signal", "timeout"].includes(result.cancellationReason) + ) + } + return result.errorCode === undefined && result.cancellationReason === undefined +} diff --git a/packages/zoo-protocol/src/public-events.ts b/packages/zoo-protocol/src/public-events.ts new file mode 100644 index 0000000000..9e98123ceb --- /dev/null +++ b/packages/zoo-protocol/src/public-events.ts @@ -0,0 +1,1353 @@ +import { isDeepStrictEqual } from "node:util" + +import { z } from "zod" + +import { validateCommandLifecycle } from "./command-lifecycle.js" +import type { HostCommand } from "./host-commands.js" +import type { HostEvent } from "./host-events.js" +import { failedErrorCodeSchema, zooErrorSchema, zooOutcomeSchema } from "./outcomes.js" +import { + canonicalizeRedactionText, + isSensitiveKey, + requiresFailClosedRedaction, + REDACTED, + redactValue, + type JsonValue, +} from "./redaction.js" +import { + ZOO_HOST_PROTOCOL_VERSION, + ZOO_PUBLIC_SCHEMA_VERSION, + validateParentHello, + type HostHello, + type ParentHello, +} from "./version.js" + +const strictObject = (shape: T) => z.object(shape).strict() + +export const usageSchema = strictObject({ + inputTokens: z.number().int().safe().nonnegative().optional(), + outputTokens: z.number().int().safe().nonnegative().optional(), + cacheReads: z.number().int().safe().nonnegative().optional(), + cacheWrites: z.number().int().safe().nonnegative().optional(), +}) + +export const changedFileSchema = strictObject({ path: z.string().min(1), status: z.string().min(1) }) + +const rawZooRunResultSchema = strictObject({ + schemaVersion: z.literal(ZOO_PUBLIC_SCHEMA_VERSION), + protocol: z.literal("zoo-run-result"), + success: z.boolean(), + outcome: zooOutcomeSchema, + rootTaskId: z.string().min(1), + currentTaskId: z.string().min(1).optional(), + workspace: z.string().min(1), + resumable: z.boolean(), + content: z.string().optional(), + error: zooErrorSchema.optional(), + usage: usageSchema.optional(), + cost: z.number().finite().nonnegative().optional(), + elapsedMs: z.number().int().safe().nonnegative(), + changedFiles: z.array(changedFileSchema).optional(), + cancellationReason: z.enum(["user", "signal", "timeout"]).optional(), +}).superRefine((result, context) => { + if (result.success !== (result.outcome === "completed")) { + context.addIssue({ code: z.ZodIssueCode.custom, message: "success must match completed outcome" }) + } + if (result.outcome === "failed" && result.error === undefined) { + context.addIssue({ code: z.ZodIssueCode.custom, message: "failed results require an error" }) + } + if ( + result.outcome === "failed" && + result.error !== undefined && + !failedErrorCodeSchema.safeParse(result.error.code).success + ) { + context.addIssue({ code: z.ZodIssueCode.custom, message: "failed results require a non-timeout error code" }) + } + if ( + result.outcome === "timed_out" && + result.error !== undefined && + !["task_timed_out", "cleanup_timed_out"].includes(result.error.code) + ) { + context.addIssue({ code: z.ZodIssueCode.custom, message: "timed_out results require a timeout error code" }) + } + if (!["failed", "timed_out"].includes(result.outcome) && result.error !== undefined) { + context.addIssue({ code: z.ZodIssueCode.custom, message: `${result.outcome} results cannot include an error` }) + } + if ((result.outcome === "cancelled") !== (result.cancellationReason !== undefined)) { + context.addIssue({ code: z.ZodIssueCode.custom, message: "cancelled results require a cancellation reason" }) + } + if (result.resumable && !["needs_input", "cancelled", "timed_out"].includes(result.outcome)) { + context.addIssue({ code: z.ZodIssueCode.custom, message: `${result.outcome} results cannot be resumed` }) + } + if (result.outcome === "needs_input" && !result.resumable) { + context.addIssue({ code: z.ZodIssueCode.custom, message: "needs_input results must be resumable" }) + } +}) + +const redactError = (error: T): T => ({ + ...error, + message: String(redactValue(error.message)), + ...(error.phase === undefined ? {} : { phase: String(redactValue(error.phase)) }), +}) +const redactRecord = (value: Record): Record => redactValue(value) + +export const zooRunResultSchema = rawZooRunResultSchema.transform((result) => ({ + ...result, + content: result.content === undefined ? undefined : String(redactValue(result.content)), + error: result.error === undefined ? undefined : redactError(result.error), +})) + +export type ZooRunResult = z.infer + +const eventBase = { + v: z.literal(ZOO_PUBLIC_SCHEMA_VERSION), + seq: z.number().int().safe().positive(), + timestamp: z.string().datetime({ offset: true }), + hostId: z.string().min(1), + requestId: z.string().min(1).optional(), +} + +const event = (type: Type, shape: T) => + strictObject({ ...eventBase, type: z.literal(type), ...shape }) + +const taskEvent = (type: Type, shape: T) => + strictObject({ + ...eventBase, + type: z.literal(type), + rootTaskId: z.string().min(1), + taskId: z.string().min(1), + ...shape, + }) + +const systemInitEventSchema = event("system.init", { + protocol: z.literal("zoo-stream"), + hostProtocolVersion: z.literal(ZOO_HOST_PROTOCOL_VERSION), + capabilities: z.array(z.string().min(1)), + clientVersion: z.string().min(1), + hostVersion: z.string().min(1), +}) +const systemWarningEventSchema = event("system.warning", { code: z.string().min(1), message: z.string().min(1) }) +const taskCreatedEventSchema = taskEvent("task.created", { parentTaskId: z.string().min(1).optional() }) +const taskStartedEventSchema = taskEvent("task.started", {}) +const taskLifecycleEventSchema = taskEvent("task.lifecycle", { + state: z.enum(["running", "waiting", "interrupted", "completed", "failed"]), + cause: z.enum(["cancelled", "timed_out", "failed"]).optional(), +}) +const taskResumedEventSchema = taskEvent("task.resumed", { + previousState: z.enum(["waiting", "interrupted"]), +}) +const taskDelegatedEventSchema = taskEvent("task.delegated", { + parentTaskId: z.string().min(1), + childTaskId: z.string().min(1), +}) +const messageUpsertEventSchema = taskEvent("message.upsert", { + messageId: z.string().min(1), + role: z.enum(["assistant", "user", "reasoning"]), + content: z.string(), + complete: z.boolean(), +}) +const askRequiredEventSchema = taskEvent("ask.required", { + askId: z.string().min(1), + category: z.string().min(1), + subject: z.string().min(1), +}) +const askResolvedEventSchema = taskEvent("ask.resolved", { + askId: z.string().min(1), + decision: z.enum(["approve", "reject", "needs_input"]), + source: z.enum(["policy", "user", "auto", "deny"]), +}) +const askAbandonedEventSchema = taskEvent("ask.abandoned", { + askId: z.string().min(1), + reason: z.enum(["cancelled", "timed_out", "failed"]), +}) +const jsonValueSchema: z.ZodType = z.lazy(() => + z.union([ + z.null(), + z.boolean(), + z.number().finite(), + z.string(), + z.array(jsonValueSchema), + z.record(jsonValueSchema), + ]), +) +const toolEventState = { + toolCallId: z.string().min(1), + name: z.string().min(1), + arguments: z.record(jsonValueSchema).optional(), + output: z.string().optional(), +} +const toolStartedEventSchema = taskEvent("tool.started", toolEventState) +const toolUpdatedEventSchema = taskEvent("tool.updated", toolEventState) +const toolCompletedEventSchema = taskEvent("tool.completed", toolEventState) +const toolFailedEventSchema = taskEvent("tool.failed", { ...toolEventState, error: zooErrorSchema }) +const terminalOutputEventSchema = taskEvent("terminal.output", { + toolCallId: z.string().min(1), + stream: z.enum(["stdout", "stderr"]), + delta: z.string(), +}) +const terminalStatusEventSchema = taskEvent("terminal.status", { + toolCallId: z.string().min(1), + state: z.enum(["running", "background", "exited", "killed"]), + exitCode: z.number().int().safe().nullable().optional(), +}) +const mcpEventState = { + operationId: z.string().min(1), + server: z.string().min(1), + operation: z.string().min(1), + output: z.string().optional(), +} +const mcpStartedEventSchema = taskEvent("mcp.started", mcpEventState) +const mcpCompletedEventSchema = taskEvent("mcp.completed", mcpEventState) +const mcpFailedEventSchema = taskEvent("mcp.failed", { ...mcpEventState, error: zooErrorSchema }) +const usageUpdatedEventSchema = taskEvent("usage.updated", { + usage: usageSchema, + cost: z.number().finite().nonnegative().optional(), +}) +const taskResultEventSchema = taskEvent("task.result", { result: zooRunResultSchema }) + +export const rawZooStreamEventSchema = z + .discriminatedUnion("type", [ + systemInitEventSchema, + systemWarningEventSchema, + taskCreatedEventSchema, + taskStartedEventSchema, + taskLifecycleEventSchema, + taskResumedEventSchema, + taskDelegatedEventSchema, + messageUpsertEventSchema, + askRequiredEventSchema, + askResolvedEventSchema, + askAbandonedEventSchema, + toolStartedEventSchema, + toolUpdatedEventSchema, + toolCompletedEventSchema, + toolFailedEventSchema, + terminalOutputEventSchema, + terminalStatusEventSchema, + mcpStartedEventSchema, + mcpCompletedEventSchema, + mcpFailedEventSchema, + usageUpdatedEventSchema, + taskResultEventSchema, + ]) + .superRefine((streamEvent, context) => { + if (streamEvent.type !== "terminal.status") return + const terminal = streamEvent.state === "exited" || streamEvent.state === "killed" + if (terminal === (streamEvent.exitCode === undefined)) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: terminal + ? "Terminal states require an exit code or null" + : "Nonterminal states cannot include an exit code", + }) + } + }) + +const redactStreamEvent = (streamEvent: z.infer) => { + switch (streamEvent.type) { + case "system.warning": + return { ...streamEvent, message: String(redactValue(streamEvent.message)) } + case "message.upsert": + return { ...streamEvent, content: String(redactValue(streamEvent.content)) } + case "ask.required": + return { ...streamEvent, subject: String(redactValue(streamEvent.subject)) } + case "tool.started": + case "tool.updated": + case "tool.completed": + return { + ...streamEvent, + arguments: streamEvent.arguments === undefined ? undefined : redactRecord(streamEvent.arguments), + output: streamEvent.output === undefined ? undefined : String(redactValue(streamEvent.output)), + } + case "tool.failed": + return { + ...streamEvent, + arguments: streamEvent.arguments === undefined ? undefined : redactRecord(streamEvent.arguments), + output: streamEvent.output === undefined ? undefined : String(redactValue(streamEvent.output)), + error: redactError(streamEvent.error), + } + case "terminal.output": + return { ...streamEvent, delta: streamEvent.delta.length === 0 ? "" : REDACTED } + case "mcp.started": + case "mcp.completed": + return { + ...streamEvent, + output: streamEvent.output === undefined ? undefined : String(redactValue(streamEvent.output)), + } + case "mcp.failed": + return { + ...streamEvent, + output: streamEvent.output === undefined ? undefined : String(redactValue(streamEvent.output)), + error: redactError(streamEvent.error), + } + default: + return streamEvent + } +} + +export const zooStreamEventSchema = rawZooStreamEventSchema.transform(redactStreamEvent) + +export type ZooStreamEvent = z.infer +export type RawZooStreamEvent = z.infer + +const streamEventKey = (event: RawZooStreamEvent) => + JSON.stringify([ + event.hostId, + event.seq, + event.timestamp, + event.type, + event.requestId, + "rootTaskId" in event ? event.rootTaskId : undefined, + "taskId" in event ? event.taskId : undefined, + "messageId" in event ? event.messageId : undefined, + "askId" in event ? event.askId : undefined, + "toolCallId" in event ? event.toolCallId : undefined, + "operationId" in event ? event.operationId : undefined, + "stream" in event ? event.stream : undefined, + ]) + +export type ZooStreamRedactor = { + push: (event: RawZooStreamEvent) => ZooStreamEvent[] + flush: () => ZooStreamEvent[] + failClosed: (event?: RawZooStreamEvent) => ZooStreamEvent[] +} + +export function createZooStreamRedactor( + options: { maxPendingBytes?: number; maxPendingEvents?: number; maxPendingStreams?: number } = {}, +): ZooStreamRedactor { + const maxPendingBytes = options.maxPendingBytes ?? 64 * 1024 + const maxPendingEvents = options.maxPendingEvents ?? 256 + const maxPendingStreams = options.maxPendingStreams ?? 256 + type PendingOutput = { + events: Array> + text: string + textByStream: Partial["stream"], string>> + pem: boolean + overflowed: boolean + secretQuotes: Partial["stream"], '"' | "'">> + secretValueContinuations: Partial["stream"], true>> + } + const pendingOutputs = new Map() + let failClosedAll = false + const outputKey = (event: z.infer) => + JSON.stringify([event.hostId, event.rootTaskId, event.taskId, event.toolCallId]) + const hasUnmatchedPem = (text: string): boolean => { + const openLabels: string[] = [] + for (const match of text.matchAll(/-----(BEGIN|END) ((?:[A-Z ]*PRIVATE KEY|PGP PRIVATE KEY BLOCK))-----/g)) { + const [, boundary, label] = match + if (boundary === "BEGIN" && label !== undefined) openLabels.push(label) + else if (boundary === "END" && label !== undefined && openLabels.at(-1) === label) openLabels.pop() + } + return openLabels.length > 0 + } + const incompleteSecretQuote = (text: string): '"' | "'" | undefined => { + const match = text.match(/(?:^|[,{;\s])["']?([A-Za-z0-9_. -]+)["']?\s*[:=]\s*(["'])(?:\\.|[^\\])*$/i) + if (match === null || !isSensitiveKey(match[1] ?? "") || (match[2] !== '"' && match[2] !== "'")) + return undefined + const opening = /[:=]\s*(["'])/.exec(match[0]) + if (opening === null) return undefined + const value = match[0].slice(opening.index + opening[0].length) + return new RegExp(`(?:^|[^\\\\])${match[2]}`).test(value) ? undefined : match[2] + } + const closesSecretQuote = (text: string, quote: '"' | "'"): boolean => + new RegExp(`(?:^|[^\\\\])${quote}`).test(text) + const incompleteSecretValue = (text: string): boolean => { + const line = + text + .replace(/\r?\n$/, "") + .split(/\r?\n/) + .at(-1) ?? "" + const match = line.match(/(?:^|[,{;\s])["']?([A-Za-z0-9_. -]+)["']?\s*[:=]\s*$/i) + return match !== null && isSensitiveKey(match[1] ?? "") + } + + const emit = (key: string, replacement?: string): ZooStreamEvent[] => { + const pending = pendingOutputs.get(key) + if (pending === undefined) return [] + if (pending.events.length === 0) { + pendingOutputs.delete(key) + return [] + } + const [first, ...rest] = pending.events + const continuedSecret = Object.keys(pending.secretQuotes).length > 0 + const continuedValue = Object.keys(pending.secretValueContinuations).length > 0 + const secretQuotes = { ...pending.secretQuotes } + const secretValueContinuations = { ...pending.secretValueContinuations } + for (const [stream, text] of Object.entries(pending.textByStream) as Array< + [z.infer["stream"], string] + >) { + const streamText = canonicalizeRedactionText(text) + const activeQuote = secretQuotes[stream] + if (activeQuote !== undefined) { + if (closesSecretQuote(streamText, activeQuote)) delete secretQuotes[stream] + } else { + const quote = incompleteSecretQuote(streamText) + if (quote !== undefined) secretQuotes[stream] = quote + } + if (secretValueContinuations[stream] === true) { + if (!incompleteSecretValue(streamText)) delete secretValueContinuations[stream] + } else if (incompleteSecretValue(streamText)) { + secretValueContinuations[stream] = true + } + } + const unterminatedSecret = + pending.pem || + Object.keys(secretQuotes).length > 0 || + Object.keys(secretValueContinuations).length > 0 || + requiresFailClosedRedaction(pending.text) + const delta = replacement ?? (unterminatedSecret ? REDACTED : String(redactValue(pending.text))) + pendingOutputs.delete(key) + if (Object.keys(secretQuotes).length > 0 || Object.keys(secretValueContinuations).length > 0) { + pendingOutputs.set(key, { + events: [], + text: "", + textByStream: {}, + pem: false, + overflowed: false, + secretQuotes, + secretValueContinuations, + }) + } + return first === undefined + ? [] + : [ + { ...first, delta: continuedSecret || continuedValue ? REDACTED : delta }, + ...rest.map((event) => ({ ...event, delta: "" })), + ] + } + const emitOperation = (event: z.infer): ZooStreamEvent[] => + [...pendingOutputs.keys()] + .filter((key) => { + const [hostId, rootTaskId, taskId, toolCallId] = JSON.parse(key) as string[] + return ( + hostId === event.hostId && + rootTaskId === event.rootTaskId && + taskId === event.taskId && + toolCallId === event.toolCallId + ) + }) + .flatMap((key) => emit(key)) + + return { + push(event) { + if (event.type !== "terminal.output") { + const finalized = + event.type === "terminal.status" && (event.state === "exited" || event.state === "killed") + ? emitOperation(event) + : [] + return [...finalized, redactStreamEvent(event)] + } + if (event.delta.length === 0) return [{ ...event, delta: "" }] + const key = outputKey(event) + if (failClosedAll) return [{ ...event, delta: REDACTED }] + let pending = pendingOutputs.get(key) + if (pending === undefined) { + if (pendingOutputs.size >= maxPendingStreams) { + failClosedAll = true + const buffered = [...pendingOutputs.keys()].flatMap((pendingKey) => emit(pendingKey, REDACTED)) + return [...buffered, { ...event, delta: event.delta.length === 0 ? "" : REDACTED }] + } + pending = { + events: [], + text: "", + textByStream: {}, + pem: false, + overflowed: false, + secretQuotes: {}, + secretValueContinuations: {}, + } + pendingOutputs.set(key, pending) + } + if (pending.overflowed) return [{ ...event, delta: event.delta.length === 0 ? "" : REDACTED }] + pending.events.push(event) + pending.text += event.delta + pending.textByStream[event.stream] = (pending.textByStream[event.stream] ?? "") + event.delta + pending.pem = hasUnmatchedPem(canonicalizeRedactionText(pending.text)) + if ( + new TextEncoder().encode(pending.text).byteLength > maxPendingBytes || + pending.events.length > maxPendingEvents + ) { + pending.overflowed = true + const redacted = emit(key, REDACTED) + pendingOutputs.set(key, { + events: [], + text: "", + textByStream: {}, + pem: false, + overflowed: true, + secretQuotes: {}, + secretValueContinuations: {}, + }) + return redacted + } + if (pending.pem) return [] + const boundary = pending.text.lastIndexOf("\n") + const allEventsEndAtBoundary = boundary === pending.text.length - 1 + return allEventsEndAtBoundary ? emit(key) : [] + }, + flush: () => [...pendingOutputs.keys()].flatMap((key) => emit(key)), + failClosed: (event) => { + if (event === undefined) failClosedAll = true + const keys = event?.type === "terminal.output" ? [outputKey(event)] : [...pendingOutputs.keys()] + return keys.flatMap((key) => { + const redacted = emit(key, REDACTED) + pendingOutputs.set(key, { + events: [], + text: "", + textByStream: {}, + pem: false, + overflowed: true, + secretQuotes: {}, + secretValueContinuations: {}, + }) + return redacted + }) + }, + } +} + +export const zooStreamSchema = z.array(rawZooStreamEventSchema).transform((events): ZooStreamEvent[] => { + const redactor = createZooStreamRedactor() + const positions = new Map() + for (const [index, event] of events.entries()) { + const key = streamEventKey(event) + const indices = positions.get(key) ?? [] + indices.push(index) + positions.set(key, indices) + } + return [...events.flatMap((streamEvent) => redactor.push(streamEvent)), ...redactor.flush()] + .map((event) => ({ event, index: positions.get(streamEventKey(event))?.shift() ?? Number.MAX_SAFE_INTEGER })) + .sort((left, right) => left.index - right.index) + .map(({ event }) => event) +}) + +export function validateNegotiatedStreamSession( + host: HostHello, + parent: ParentHello, + events: readonly ZooStreamEvent[], +): { ok: true } | { ok: false; code: "protocol_incompatible"; message: string } { + const negotiation = validateParentHello(host, parent) + if (!negotiation.ok) return negotiation + const init = events[0] + const advertised = host.capabilities[String(parent.version)] ?? [] + if ( + init?.type !== "system.init" || + init.hostId !== host.hostId || + init.hostProtocolVersion !== parent.version || + init.clientVersion !== parent.clientVersion || + init.hostVersion !== host.buildVersion || + parent.requiredCapabilities.some((capability) => !init.capabilities.includes(capability)) || + init.capabilities.some((capability) => !advertised.includes(capability)) + ) { + return { + ok: false, + code: "protocol_incompatible", + message: "system.init does not match the negotiated host session", + } + } + return { ok: true } +} + +export function validateStreamLifecycle( + events: readonly ZooStreamEvent[], + commands: readonly HostCommand[] = [], + commandEvents: readonly HostEvent[] = [], + lifecycleScope?: { initiatingCommandId: string; commandIds?: readonly string[] }, +): { ok: true } | { ok: false; code: "protocol_gap" | "task_failed"; message: string } { + if (events[0]?.type !== "system.init") { + return { ok: false, code: "task_failed", message: "Stream must start with system.init" } + } + if (events[0].seq !== 1 || events.slice(1).some((streamEvent) => streamEvent.type === "system.init")) { + return { ok: false, code: "protocol_gap", message: "Stream must contain one sequence-1 system.init" } + } + const hostId = events[0].hostId + if (events.some((streamEvent) => streamEvent.hostId !== hostId)) { + return { ok: false, code: "protocol_gap", message: "Stream cannot span multiple hosts" } + } + if (commandEvents.some((event) => event.hostId !== hostId)) { + return { ok: false, code: "protocol_gap", message: "Command lifecycle cannot span multiple hosts" } + } + for (let index = 1; index < commandEvents.length; index += 1) { + const expected = commandEvents[index - 1]!.seq + 1 + if (commandEvents[index]!.seq !== expected) { + return { ok: false, code: "protocol_gap", message: `Expected host sequence ${expected}` } + } + } + for (let index = 1; index < events.length; index += 1) { + const expected = events[index - 1]!.seq + 1 + if (events[index]!.seq !== expected) { + return { ok: false, code: "protocol_gap", message: `Expected sequence ${expected}` } + } + } + const commandLifecycle = validateCommandLifecycle(commands, commandEvents, hostId) + if (!commandLifecycle.ok) { + return { ok: false, code: "protocol_gap", message: commandLifecycle.message } + } + const eventEnvelopes = commandEvents.filter((event) => event.type === "event") + if ( + eventEnvelopes.length !== events.length || + events.some((streamEvent, index) => !isDeepStrictEqual(eventEnvelopes[index]?.event, streamEvent)) + ) { + return { + ok: false, + code: "protocol_gap", + message: "Public events must exactly match their ordered host envelopes", + } + } + const results = events.filter((event) => event.type === "task.result") + if (results.length !== 1 || events.at(-1)?.type !== "task.result") { + return { ok: false, code: "task_failed", message: "Accepted stream must end with exactly one task.result" } + } + const resultEvent = results[0]! + const rootTaskId = resultEvent.result.rootTaskId + if (resultEvent.rootTaskId !== rootTaskId || resultEvent.taskId !== rootTaskId) { + return { ok: false, code: "task_failed", message: "task.result must identify the authoritative root task" } + } + const resumedEvents = events.filter((streamEvent) => streamEvent.type === "task.resumed") + const initiatingCandidates = commands.filter( + (command) => command.type === "task.start" || command.type === "task.resume", + ) + const initiatingCommand = lifecycleScope + ? initiatingCandidates.find((command) => command.id === lifecycleScope.initiatingCommandId) + : initiatingCandidates.length === 1 + ? initiatingCandidates[0] + : undefined + const treeTaskIds = new Set( + events + .filter((event): event is ZooStreamEvent & { type: "task.created" } => event.type === "task.created") + .map((event) => event.taskId), + ) + const explicitlyScopedCommandIds = + lifecycleScope?.commandIds === undefined ? undefined : new Set(lifecycleScope.commandIds) + const targetsRunTree = (command: HostCommand): boolean => { + if (command.id === initiatingCommand?.id) return true + if (command.type === "task.resume" || command.type === "task.cancel") return command.rootTaskId === rootTaskId + if (command.type === "task.input" || command.type === "ask.respond") return treeTaskIds.has(command.taskId) + return false + } + const runCommands = + explicitlyScopedCommandIds === undefined + ? commands + : commands.filter((command) => explicitlyScopedCommandIds.has(command.id) || targetsRunTree(command)) + const startCommands = initiatingCommand?.type === "task.start" ? [initiatingCommand] : [] + const resumeCommands = initiatingCommand?.type === "task.resume" ? [initiatingCommand] : [] + if (initiatingCommand === undefined) { + return { ok: false, code: "protocol_gap", message: "Run scope must identify exactly one initiating command" } + } + if (new Set(commands.map((command) => command.id)).size !== commands.length) { + return { ok: false, code: "protocol_gap", message: "Command IDs must be globally unique" } + } + if (resumedEvents.length === 0) { + const start = startCommands[0] + if ( + startCommands.length !== 1 || + resumeCommands.length !== 0 || + start === undefined || + resultEvent.result.workspace !== start.workspace || + (resultEvent.result.outcome !== "cancelled" && resultEvent.requestId !== start.id) + ) { + return { + ok: false, + code: "task_failed", + message: "Fresh streams must match exactly one task.start command", + } + } + } else if (resumedEvents.length !== 1 || startCommands.length !== 0 || resumeCommands.length !== 1) { + return { ok: false, code: "task_failed", message: "Resume streams must match exactly one task.resume command" } + } + + const pendingAsks = new Map>() + const settledAsks = new Map>() + const consumedResponseCommands = new Set() + const consumedInputCommands = new Set() + const abandonedAsks = new Map>() + const taskTerminalCauses = new Map() + const taskStates = new Map() + const endedStates = new Set(["completed", "failed"]) + const settledStates = new Set(["interrupted", "completed", "failed"]) + const taskParents = new Map() + const createdTasks = new Set() + const delegatedTasks = new Set() + const resumedTasks = new Set() + const startedTasks = new Set() + type MessageState = { role: "assistant" | "user" | "reasoning"; complete: boolean } + type ToolState = { state: "active" | "terminal"; name: string } + type McpState = { state: "active" | "terminal"; server: string; operation: string } + const toolStates = new Map>() + const terminalOperationStates = new Map>() + const mcpStates = new Map>() + const messageStates = new Map>() + const approvalResumeCauses = new Map() + const scope = (map: Map>, taskId: string): Map => { + const existing = map.get(taskId) + if (existing !== undefined) return existing + const created = new Map() + map.set(taskId, created) + return created + } + const askScope = (taskId: string): Set => { + const existing = pendingAsks.get(taskId) + if (existing !== undefined) return existing + const created = new Set() + pendingAsks.set(taskId, created) + return created + } + const setScope = (map: Map>, taskId: string): Set => { + const existing = map.get(taskId) + if (existing !== undefined) return existing + const created = new Set() + map.set(taskId, created) + return created + } + const values = (map: Map>): T[] => + [...map.values()].flatMap((entries) => [...entries.values()]) + const isDescendantOf = (taskId: string, parentTaskId: string): boolean => { + let current = taskParents.get(taskId) + while (current !== undefined && current !== null) { + if (current === parentTaskId) return true + current = taskParents.get(current) + } + return false + } + const hasPendingAskInAncestry = (taskId: string): boolean => { + let current: string | null | undefined = taskId + while (current !== undefined && current !== null) { + if ((pendingAsks.get(current)?.size ?? 0) > 0) return true + current = taskParents.get(current) + } + return false + } + const hostSequenceFor = (streamEvent: ZooStreamEvent): number | undefined => { + const index = events.indexOf(streamEvent) + return index < 0 ? undefined : eventEnvelopes[index]?.seq + } + const causalTerminal = (commandId: string, effect?: ZooStreamEvent): HostEvent | undefined => { + const lifecycle = commandEvents.filter( + (event) => + (event.type === "command.ack" || event.type === "command.done" || event.type === "command.error") && + event.commandId === commandId, + ) + const acknowledgements = lifecycle.filter((event) => event.type === "command.ack") + const terminals = lifecycle.filter((event) => event.type === "command.done" || event.type === "command.error") + if ( + acknowledgements.length !== 1 || + terminals.length !== 1 || + acknowledgements[0]!.seq >= terminals[0]!.seq || + (effect !== undefined && (hostSequenceFor(effect) ?? 0) <= acknowledgements[0]!.seq) + ) { + return undefined + } + return terminals[0] + } + const inputResumeCause = (streamEvent: ZooStreamEvent & { taskId: string }): boolean => { + const { taskId, requestId } = streamEvent + if (requestId === undefined || consumedInputCommands.has(requestId)) return false + const input = runCommands.find( + (command) => command.type === "task.input" && command.id === requestId && command.taskId === taskId, + ) + const terminal = causalTerminal(requestId, streamEvent) + const valid = + input !== undefined && + terminal?.type === "command.done" && + terminal.data.commandType === "task.input" && + terminal.data.taskId === taskId + if (valid) consumedInputCommands.add(requestId) + return valid + } + const approvalResumeCause = (taskId: string, requestId: string | undefined): boolean => { + if (!approvalResumeCauses.has(taskId) || approvalResumeCauses.get(taskId) !== requestId) return false + approvalResumeCauses.delete(taskId) + return true + } + for (const response of runCommands.filter((command) => command.type === "ask.respond")) { + if (causalTerminal(response.id) === undefined) { + return { + ok: false, + code: "protocol_gap", + message: "Every ask response requires ACK and one terminal response", + } + } + } + const initiatingTerminal = initiatingCommand === undefined ? undefined : causalTerminal(initiatingCommand.id) + if (initiatingTerminal?.type !== "command.done") { + return { ok: false, code: "protocol_gap", message: "Task stream requires a successful initiating command" } + } + if ( + initiatingCommand?.type === "task.start" && + (initiatingTerminal.data.commandType !== "task.start" || + initiatingTerminal.data.task.rootTaskId !== rootTaskId || + initiatingTerminal.data.task.taskId !== rootTaskId) + ) { + return { ok: false, code: "task_failed", message: "task.start completion does not match the stream root" } + } + if ( + initiatingCommand?.type === "task.resume" && + (initiatingTerminal.data.commandType !== "task.resume" || + initiatingTerminal.data.task.rootTaskId !== rootTaskId || + initiatingTerminal.data.task.taskId !== initiatingCommand.taskId) + ) { + return { ok: false, code: "task_failed", message: "task.resume completion does not match the resumed task" } + } + for (const streamEvent of events) { + if ("rootTaskId" in streamEvent && streamEvent.rootTaskId !== rootTaskId) { + return { + ok: false, + code: "task_failed", + message: "All task events must identify the authoritative root task", + } + } + if (!("taskId" in streamEvent) || streamEvent.type === "task.result") continue + + if (streamEvent.type === "task.created") { + const parentTaskId = streamEvent.parentTaskId ?? null + if ( + (streamEvent.taskId === rootTaskId) !== (parentTaskId === null) || + (parentTaskId !== null && !taskParents.has(parentTaskId)) || + (parentTaskId !== null && settledStates.has(taskStates.get(parentTaskId) ?? "")) || + createdTasks.has(streamEvent.taskId) + ) { + return { + ok: false, + code: "task_failed", + message: `Invalid creation edge for task ${streamEvent.taskId}`, + } + } + if (taskParents.has(streamEvent.taskId) && taskParents.get(streamEvent.taskId) !== parentTaskId) { + return { ok: false, code: "task_failed", message: `Conflicting parent for task ${streamEvent.taskId}` } + } + taskParents.set(streamEvent.taskId, parentTaskId) + createdTasks.add(streamEvent.taskId) + if ( + streamEvent.taskId === rootTaskId && + resumedEvents.length === 0 && + streamEvent.requestId !== startCommands[0]?.id + ) { + return { ok: false, code: "task_failed", message: "Root creation must match its task.start request" } + } + if (streamEvent.taskId === rootTaskId && causalTerminal(initiatingCommand.id, streamEvent) === undefined) { + return { ok: false, code: "protocol_gap", message: "Task creation must follow its command ACK" } + } + } else if (streamEvent.type === "task.delegated") { + const reconstructingResumeTree = resumedEvents.length === 1 && resumedTasks.size === 0 + if ( + streamEvent.taskId !== streamEvent.childTaskId || + streamEvent.childTaskId === rootTaskId || + streamEvent.childTaskId === streamEvent.parentTaskId || + !createdTasks.has(streamEvent.parentTaskId) || + !createdTasks.has(streamEvent.childTaskId) || + settledStates.has(taskStates.get(streamEvent.parentTaskId) ?? "") || + (taskStates.get(streamEvent.parentTaskId) !== "running" && !reconstructingResumeTree) || + delegatedTasks.has(streamEvent.childTaskId) + ) { + return { + ok: false, + code: "task_failed", + message: `Invalid delegation edge for task ${streamEvent.childTaskId}`, + } + } + if ( + taskParents.has(streamEvent.childTaskId) && + taskParents.get(streamEvent.childTaskId) !== streamEvent.parentTaskId + ) { + return { + ok: false, + code: "task_failed", + message: `Conflicting parent for task ${streamEvent.childTaskId}`, + } + } + taskParents.set(streamEvent.childTaskId, streamEvent.parentTaskId) + delegatedTasks.add(streamEvent.childTaskId) + } else if (!taskParents.has(streamEvent.taskId)) { + return { ok: false, code: "task_failed", message: `Event references unknown task ${streamEvent.taskId}` } + } + if ( + streamEvent.taskId !== rootTaskId && + streamEvent.type !== "task.created" && + streamEvent.type !== "task.delegated" && + !delegatedTasks.has(streamEvent.taskId) + ) { + return { + ok: false, + code: "task_failed", + message: `Task ${streamEvent.taskId} emitted an event before delegation`, + } + } + + const previousState = taskStates.get(streamEvent.taskId) + if ( + previousState === "running" && + (pendingAsks.get(streamEvent.taskId)?.size ?? 0) > 0 && + !(streamEvent.type === "task.lifecycle" && streamEvent.state === "waiting") + ) { + return { ok: false, code: "task_failed", message: "A pending approval must transition its task to waiting" } + } + if ( + approvalResumeCauses.has(streamEvent.taskId) && + !(streamEvent.type === "task.lifecycle" && streamEvent.state === "running") + ) { + approvalResumeCauses.delete(streamEvent.taskId) + } + if ( + (previousState !== undefined && endedStates.has(previousState)) || + (previousState === "interrupted" && streamEvent.type !== "task.resumed") + ) { + return { + ok: false, + code: "task_failed", + message: `Task ${streamEvent.taskId} emitted an event after termination`, + } + } + if (streamEvent.type === "task.started") { + const parentTaskId = taskParents.get(streamEvent.taskId) + const reconstructingResumedDescendant = + resumedEvents.length === 1 && + resumeCommands[0]?.taskId === streamEvent.taskId && + resumedTasks.has(streamEvent.taskId) + if ( + startedTasks.has(streamEvent.taskId) || + (previousState !== undefined && previousState !== "running") || + (resumeCommands[0]?.taskId === streamEvent.taskId && !resumedTasks.has(streamEvent.taskId)) || + (parentTaskId !== null && + parentTaskId !== undefined && + taskStates.get(parentTaskId) !== "running" && + !reconstructingResumedDescendant) + ) { + return { + ok: false, + code: "task_failed", + message: `Invalid start transition for task ${streamEvent.taskId}`, + } + } + startedTasks.add(streamEvent.taskId) + taskStates.set(streamEvent.taskId, "running") + } + if (streamEvent.type === "task.lifecycle") { + const resume = resumeCommands[0] + const reconstructingPredecessor = + !startedTasks.has(streamEvent.taskId) && + resume?.taskId === streamEvent.taskId && + !resumedTasks.has(streamEvent.taskId) && + (streamEvent.state === "waiting" || streamEvent.state === "interrupted") + if (!startedTasks.has(streamEvent.taskId) && !reconstructingPredecessor) { + return { + ok: false, + code: "task_failed", + message: "Task lifecycle requires an ordered task.started event", + } + } + const transitionAllowed = + reconstructingPredecessor || + (previousState === "running" && + ["waiting", "interrupted", "completed", "failed"].includes(streamEvent.state)) || + (previousState === "waiting" && + (["interrupted", "failed"].includes(streamEvent.state) || + (streamEvent.state === "running" && + !hasPendingAskInAncestry(streamEvent.taskId) && + (approvalResumeCause(streamEvent.taskId, streamEvent.requestId) || + inputResumeCause(streamEvent))))) + if (!transitionAllowed) { + return { + ok: false, + code: "task_failed", + message: `Invalid lifecycle transition for task ${streamEvent.taskId}`, + } + } + if ( + (streamEvent.state === "running" || + streamEvent.state === "waiting" || + streamEvent.state === "completed") && + streamEvent.cause !== undefined + ) { + return { ok: false, code: "task_failed", message: "Lifecycle cause contradicts task state" } + } + if (streamEvent.state === "failed" && streamEvent.cause !== undefined && streamEvent.cause !== "failed") { + return { ok: false, code: "task_failed", message: "Lifecycle cause contradicts task state" } + } + if (streamEvent.state === "interrupted" && streamEvent.cause === "failed") { + return { ok: false, code: "task_failed", message: "Lifecycle cause contradicts task state" } + } + if ((pendingAsks.get(streamEvent.taskId)?.size ?? 0) > 0 && settledStates.has(streamEvent.state)) { + return { ok: false, code: "task_failed", message: "A task with a pending ask cannot terminate" } + } + if ( + settledStates.has(streamEvent.state) && + [...createdTasks].some( + (taskId) => + isDescendantOf(taskId, streamEvent.taskId) && !settledStates.has(taskStates.get(taskId) ?? ""), + ) + ) { + return { ok: false, code: "task_failed", message: "A task cannot terminate before its descendants" } + } + taskStates.set(streamEvent.taskId, streamEvent.state) + if (streamEvent.cause !== undefined) taskTerminalCauses.set(streamEvent.taskId, streamEvent.cause) + } + if (streamEvent.type === "task.resumed") { + const resume = resumeCommands[0] + if ( + resume === undefined || + resume.id !== streamEvent.requestId || + resume.taskId !== streamEvent.taskId || + resume.rootTaskId !== streamEvent.rootTaskId || + (resultEvent.result.outcome !== "cancelled" && resultEvent.requestId !== resume.id) || + resumedTasks.size > 0 || + previousState !== streamEvent.previousState + ) { + return { + ok: false, + code: "task_failed", + message: "task.resumed must match reconstructed persisted state", + } + } + if (causalTerminal(resume!.id, streamEvent) === undefined) { + return { ok: false, code: "protocol_gap", message: "Task resume must follow its command ACK" } + } + resumedTasks.add(streamEvent.taskId) + taskStates.set(streamEvent.taskId, "running") + } + if ( + [ + "message.upsert", + "ask.required", + "ask.resolved", + "ask.abandoned", + "tool.started", + "tool.updated", + "tool.completed", + "tool.failed", + "terminal.output", + "terminal.status", + "mcp.started", + "mcp.completed", + "mcp.failed", + "usage.updated", + ].includes(streamEvent.type) && + !startedTasks.has(streamEvent.taskId) + ) { + return { ok: false, code: "task_failed", message: "Task operation requires an ordered task.started event" } + } + if ( + [ + "tool.started", + "tool.updated", + "tool.completed", + "tool.failed", + "terminal.output", + "terminal.status", + "mcp.started", + "mcp.completed", + "mcp.failed", + ].includes(streamEvent.type) && + (taskStates.get(streamEvent.taskId) !== "running" || hasPendingAskInAncestry(streamEvent.taskId)) + ) { + return { ok: false, code: "task_failed", message: "Task operations require an unblocked running task" } + } + if (streamEvent.type === "message.upsert") { + const matchingInput = runCommands.find( + (command) => command.type === "task.input" && command.id === streamEvent.requestId, + ) + if (streamEvent.role === "user" && matchingInput !== undefined && !inputResumeCause(streamEvent)) { + return { + ok: false, + code: "task_failed", + message: "Task input must cause exactly one matching user message", + } + } + const messages = scope(messageStates, streamEvent.taskId) + const previous = messages.get(streamEvent.messageId) + if (previous?.complete === true || (previous !== undefined && previous.role !== streamEvent.role)) { + return { + ok: false, + code: "task_failed", + message: `Invalid update for message ${streamEvent.messageId}`, + } + } + messages.set(streamEvent.messageId, { role: streamEvent.role, complete: streamEvent.complete }) + } + if (streamEvent.type === "ask.required") { + const asks = askScope(streamEvent.taskId) + if ( + taskStates.get(streamEvent.taskId) !== "running" || + asks.has(streamEvent.askId) || + settledAsks.get(streamEvent.taskId)?.has(streamEvent.askId) === true + ) { + return { ok: false, code: "task_failed", message: `Ask ${streamEvent.askId} was already used` } + } + asks.add(streamEvent.askId) + } + if (streamEvent.type === "ask.resolved") { + if (taskStates.get(streamEvent.taskId) !== "waiting") { + return { ok: false, code: "task_failed", message: "Ask resolution requires a waiting task" } + } + if (!pendingAsks.get(streamEvent.taskId)?.delete(streamEvent.askId)) { + return { ok: false, code: "task_failed", message: `Ask ${streamEvent.askId} was not pending` } + } + if ( + (streamEvent.source === "deny" && streamEvent.decision !== "reject") || + (streamEvent.source === "auto" && streamEvent.decision !== "approve") + ) { + return { ok: false, code: "task_failed", message: "Ask decision contradicts its resolution source" } + } + const responseCommands = runCommands.filter( + (command) => + command.type === "ask.respond" && + command.taskId === streamEvent.taskId && + command.askId === streamEvent.askId, + ) + if (streamEvent.source === "user" || responseCommands.length > 0) { + const response = responseCommands.find( + (command) => !consumedResponseCommands.has(command.id) && command.id === streamEvent.requestId, + ) + const expectedDecision = + response?.type === "ask.respond" + ? { approve: "approve", reject: "reject", message: "needs_input" }[response.response] + : undefined + const completion = response === undefined ? undefined : causalTerminal(response.id, streamEvent) + if ( + expectedDecision !== streamEvent.decision || + completion?.type !== "command.done" || + completion.data.commandType !== "ask.respond" || + completion.data.taskId !== streamEvent.taskId || + completion.data.askId !== streamEvent.askId + ) { + return { + ok: false, + code: "task_failed", + message: "Ask resolution requires its successful response command", + } + } + if (response !== undefined) consumedResponseCommands.add(response.id) + } + setScope(settledAsks, streamEvent.taskId).add(streamEvent.askId) + approvalResumeCauses.set(streamEvent.taskId, streamEvent.requestId) + } + if (streamEvent.type === "ask.abandoned") { + if (!pendingAsks.get(streamEvent.taskId)?.delete(streamEvent.askId)) { + return { ok: false, code: "task_failed", message: `Ask ${streamEvent.askId} was not pending` } + } + scope(abandonedAsks, streamEvent.taskId).set(streamEvent.askId, streamEvent.reason) + setScope(settledAsks, streamEvent.taskId).add(streamEvent.askId) + } + + if (streamEvent.type === "tool.started") { + const tools = scope(toolStates, streamEvent.taskId) + if (tools.has(streamEvent.toolCallId)) + return { ok: false, code: "task_failed", message: "Tool operation started twice" } + tools.set(streamEvent.toolCallId, { state: "active", name: streamEvent.name }) + } else if ( + streamEvent.type === "tool.updated" || + streamEvent.type === "tool.completed" || + streamEvent.type === "tool.failed" + ) { + const tools = scope(toolStates, streamEvent.taskId) + const tool = tools.get(streamEvent.toolCallId) + if (tool?.state !== "active" || tool.name !== streamEvent.name) { + return { ok: false, code: "task_failed", message: "Tool event requires an active operation" } + } + if (streamEvent.type === "tool.completed" || streamEvent.type === "tool.failed") + tools.set(streamEvent.toolCallId, { ...tool, state: "terminal" }) + } + + if (streamEvent.type === "terminal.status") { + const operations = scope(terminalOperationStates, streamEvent.taskId) + const state = operations.get(streamEvent.toolCallId) + if (streamEvent.state === "running") { + if (state !== undefined) + return { ok: false, code: "task_failed", message: "Terminal operation started twice" } + operations.set(streamEvent.toolCallId, "active") + } else if (state !== "active") { + return { ok: false, code: "task_failed", message: "Terminal status requires an active operation" } + } else if (streamEvent.state === "exited" || streamEvent.state === "killed") { + operations.set(streamEvent.toolCallId, "terminal") + } + } else if ( + streamEvent.type === "terminal.output" && + terminalOperationStates.get(streamEvent.taskId)?.get(streamEvent.toolCallId) !== "active" + ) { + return { ok: false, code: "task_failed", message: "Terminal output requires an active operation" } + } + + if (streamEvent.type === "mcp.started") { + const operations = scope(mcpStates, streamEvent.taskId) + if (operations.has(streamEvent.operationId)) + return { ok: false, code: "task_failed", message: "MCP operation started twice" } + operations.set(streamEvent.operationId, { + state: "active", + server: streamEvent.server, + operation: streamEvent.operation, + }) + } else if (streamEvent.type === "mcp.completed" || streamEvent.type === "mcp.failed") { + const operations = scope(mcpStates, streamEvent.taskId) + const operation = operations.get(streamEvent.operationId) + if ( + operation?.state !== "active" || + operation.server !== streamEvent.server || + operation.operation !== streamEvent.operation + ) { + return { ok: false, code: "task_failed", message: "MCP result requires an active operation" } + } + operations.set(streamEvent.operationId, { ...operation, state: "terminal" }) + } + } + const pendingAskCount = [...pendingAsks.values()].reduce((count, asks) => count + asks.size, 0) + if (pendingAskCount > 0 && resultEvent.result.outcome !== "needs_input") { + return { ok: false, code: "task_failed", message: "Terminal stream contains unresolved asks" } + } + if (resultEvent.result.outcome === "needs_input" && pendingAskCount === 0) { + return { ok: false, code: "task_failed", message: "needs_input requires an unresolved actionable ask" } + } + if ( + resultEvent.result.outcome === "needs_input" && + [...pendingAsks].some(([taskId, asks]) => asks.size > 0 && taskStates.get(taskId) !== "waiting") + ) { + return { ok: false, code: "task_failed", message: "Pending asks must belong to waiting tasks" } + } + if (resumeCommands.length !== resumedTasks.size) { + return { ok: false, code: "task_failed", message: "Every resume command must reconstruct one resumed task" } + } + if ( + [...abandonedAsks].some(([taskId, asks]) => + [...asks.values()].some((reason) => taskTerminalCauses.get(taskId) !== reason), + ) + ) { + return { ok: false, code: "task_failed", message: "Ask abandonment contradicts its task lifecycle" } + } + const expectedState = { + completed: "completed", + needs_input: "waiting", + cancelled: "interrupted", + timed_out: "interrupted", + failed: "failed", + } as const + if ( + !createdTasks.has(rootTaskId) || + [...taskParents.keys()].some((taskId) => !createdTasks.has(taskId)) || + [...createdTasks].some((taskId) => taskId !== rootTaskId && !delegatedTasks.has(taskId)) + ) { + return { + ok: false, + code: "task_failed", + message: "Every task in the authoritative tree must be created and every descendant delegated", + } + } + if (resultEvent.result.currentTaskId !== undefined && !createdTasks.has(resultEvent.result.currentTaskId)) { + return { + ok: false, + code: "task_failed", + message: "currentTaskId must identify a task in the authoritative tree", + } + } + const rootState = taskStates.get(rootTaskId) + if (rootState !== expectedState[resultEvent.result.outcome]) { + return { ok: false, code: "task_failed", message: "Root lifecycle state contradicts task.result" } + } + const expectedInterruptedCause = + resultEvent.result.outcome === "cancelled" + ? "cancelled" + : resultEvent.result.outcome === "timed_out" + ? "timed_out" + : undefined + if (expectedInterruptedCause !== undefined && taskTerminalCauses.get(rootTaskId) !== expectedInterruptedCause) { + return { ok: false, code: "task_failed", message: "Root lifecycle cause contradicts task.result" } + } + const allowedDescendantStates = { + completed: new Set(["completed"]), + needs_input: new Set(["waiting", "interrupted", "completed", "failed"]), + cancelled: new Set(["interrupted", "completed", "failed"]), + timed_out: new Set(["interrupted", "completed", "failed"]), + failed: new Set(["interrupted", "completed", "failed"]), + }[resultEvent.result.outcome] + if ( + [...createdTasks].some( + (taskId) => taskId !== rootTaskId && !allowedDescendantStates.has(taskStates.get(taskId) ?? "running"), + ) + ) { + return { + ok: false, + code: "task_failed", + message: "Every descendant task must reach a compatible settled state", + } + } + if ( + [...values(toolStates), ...values(mcpStates)].some((operation) => operation.state === "active") || + values(terminalOperationStates).includes("active") + ) { + return { ok: false, code: "task_failed", message: "Terminal stream contains active operations" } + } + if (resultEvent.result.outcome === "completed" && values(messageStates).some((message) => !message.complete)) { + return { ok: false, code: "task_failed", message: "Completed streams cannot contain partial messages" } + } + const unconsumedResponses = runCommands.some((command) => { + if (command.type !== "ask.respond" || consumedResponseCommands.has(command.id)) return false + return causalTerminal(command.id)?.type === "command.done" + }) + if (unconsumedResponses) { + return { ok: false, code: "task_failed", message: "Every ask response command must settle its matching ask" } + } + const invalidInputs = runCommands.some((command) => { + if (command.type !== "task.input") return false + const terminal = causalTerminal(command.id) + if (terminal?.type !== "command.done") return false + return ( + !createdTasks.has(command.taskId) || + terminal.data.commandType !== "task.input" || + terminal.data.taskId !== command.taskId || + !consumedInputCommands.has(command.id) + ) + }) + if (invalidInputs) { + return { + ok: false, + code: "task_failed", + message: "Every successful task input must have one matching tree effect", + } + } + const cancelCommands = runCommands.filter( + (command) => command.type === "task.cancel" && command.rootTaskId === rootTaskId, + ) + const cancellationTerminals = cancelCommands.map((command) => causalTerminal(command.id)) + if (cancellationTerminals.some((terminal) => terminal === undefined)) { + return { + ok: false, + code: "task_failed", + message: "Every cancellation command requires ACK and one terminal response", + } + } + if ( + cancellationTerminals.some( + (terminal) => + terminal?.type === "command.done" && + (terminal.data.commandType !== "task.cancel" || terminal.data.rootTaskId !== rootTaskId), + ) + ) { + return { ok: false, code: "task_failed", message: "Cancellation completion does not match its command" } + } + const acceptedCancellations = cancelCommands.filter( + (_command, index) => cancellationTerminals[index]?.type === "command.done", + ) + if (acceptedCancellations.length > 0 && resultEvent.result.outcome !== "cancelled") { + return { ok: false, code: "task_failed", message: "An accepted cancellation must interrupt the result" } + } + if (acceptedCancellations.length > 1) { + return { ok: false, code: "task_failed", message: "A task can accept only one cancellation command" } + } + if (resultEvent.result.outcome === "cancelled") { + const cancellation = acceptedCancellations.find( + (command) => + command.type === "task.cancel" && + command.id === resultEvent.requestId && + command.rootTaskId === rootTaskId && + command.reason === resultEvent.result.cancellationReason, + ) + if (cancellation === undefined) { + return { + ok: false, + code: "task_failed", + message: "Cancelled result does not match its cancellation command", + } + } + if (causalTerminal(cancellation.id, resultEvent) === undefined) { + return { ok: false, code: "protocol_gap", message: "Cancelled result must follow its command ACK" } + } + } + return { ok: true } +} diff --git a/packages/zoo-protocol/src/redaction.ts b/packages/zoo-protocol/src/redaction.ts new file mode 100644 index 0000000000..f225db8c3d --- /dev/null +++ b/packages/zoo-protocol/src/redaction.ts @@ -0,0 +1,199 @@ +const REDACTED = "[REDACTED]" as const +const sensitiveKeyName = String.raw`(?:[A-Za-z0-9_.-]*(?:password|secret|passphrase|passwd|pwd)[A-Za-z0-9_.-]*|[A-Za-z0-9_.-]*(?:api[-_. ]?(?:key|token)|access[-_. ]?token|auth[-_. ]?token|bearer[-_. ]?token|client[-_. ]?secret|id[-_. ]?token|private[-_. ]?key|refresh[-_. ]?token|session[-_. ]?token)|authorization|cookie|credentials?|token)` +const secretValue = String.raw`(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|[^\s;}]+)` +const cliSecretValue = String.raw`(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|[^\s;}]+)` +const doubleQuotedSecret = new RegExp(`("${sensitiveKeyName}"\\s*:\\s*)"(?:\\\\.|[^"\\\\])*"`, "gi") +const singleQuotedSecret = new RegExp(`('${sensitiveKeyName}'\\s*:\\s*)'(?:\\\\.|[^'\\\\])*'`, "gi") +const quotedUnquotedSecret = new RegExp( + `((?:"${sensitiveKeyName}"|'${sensitiveKeyName}')\\s*:\\s*)(?!["'])[^\\r\\n;}]+`, + "gi", +) +const quotedAssignment = /((["'])([^"']+)\2\s*[:=]\s*)("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|[^\r\n;}]+)/g +const bareColonAssignment = /(\b([A-Za-z][A-Za-z0-9_.-]*)\s*:\s*)("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|[^\r\n;}]+)/g +const bareEqualsAssignment = /(\b([A-Za-z][A-Za-z0-9_.-]*)\s*=\s*)("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|[^\s;}]+)/g +const terminalControl = new RegExp( + `(?:${String.fromCharCode(27)}\\][^${String.fromCharCode(7)}${String.fromCharCode(27)}]*(?:${String.fromCharCode(7)}|${String.fromCharCode(27)}\\\\)|${String.fromCharCode(27)}[PX^_][\\s\\S]*?${String.fromCharCode(27)}\\\\|${String.fromCharCode(27)}\\[[0-?]*[ -/]*[@-~]|[\\u0090\\u0098\\u009d\\u009e\\u009f][\\s\\S]*?\\u009c|\\u009b[0-?]*[ -/]*[@-~]|${String.fromCharCode(27)}[@-_])`, + "g", +) +const unsafeTerminalEditing = new RegExp( + `(?:${String.fromCharCode(27)}\\[[0-?]*[ -/]*[A-HJKSTfsu]|${String.fromCharCode(155)}[0-?]*[ -/]*[A-HJKSTfsu])`, + "i", +) +const secretPatterns: ReadonlyArray = [ + /\b(?:Authorization|Proxy-Authorization|Cookie|Set-Cookie)\s*:\s*[^\r\n]+/gi, + new RegExp(`--${sensitiveKeyName}(?:\\s*=\\s*|\\s+)${cliSecretValue}`, "gi"), + new RegExp(`(? + containsSensitiveAssignment(control), + ) +} + +function containsSensitiveAssignment(value: string): boolean { + let candidateStart = 0 + for (let index = 0; index < value.length; index += 1) { + const character = value[index]! + if (character === ":" || character === "=") { + const candidate = value.slice(candidateStart, index).trim() + if (candidate.length > 0 && isSensitiveKey(candidate)) return true + candidateStart = index + 1 + continue + } + const code = character.charCodeAt(0) + const allowed = + (code >= 65 && code <= 90) || + (code >= 97 && code <= 122) || + (code >= 48 && code <= 57) || + character === "_" || + character === "." || + character === "-" || + character === " " + if (!allowed) candidateStart = index + 1 + } + return false +} + +function redactAssignments(value: string): string { + const isRedactedValue = (entry: string) => { + const unquoted = + (entry.startsWith('"') && entry.endsWith('"')) || (entry.startsWith("'") && entry.endsWith("'")) + ? entry.slice(1, -1) + : entry + return unquoted === REDACTED + } + const redactValueText = (prefix: string, entry: string) => { + const quote = + entry.startsWith('"') && entry.endsWith('"') ? '"' : entry.startsWith("'") && entry.endsWith("'") ? "'" : "" + return `${prefix}${quote}${REDACTED}${quote}` + } + return value + .replace(quotedAssignment, (match, prefix: string, _quote: string, key: string, value: string) => + isSensitiveKey(key) && !isRedactedValue(value) ? redactValueText(prefix, value) : match, + ) + .replace(bareColonAssignment, (match, prefix: string, key: string, value: string) => + isSensitiveKey(key) && !isRedactedValue(value) ? redactValueText(prefix, value) : match, + ) + .replace(bareEqualsAssignment, (match, prefix: string, key: string, value: string) => + isSensitiveKey(key) && !isRedactedValue(value) ? redactValueText(prefix, value) : match, + ) +} + +export function canonicalizeRedactionText(value: string): string { + const withoutTerminalControls = value + .replace(/\\u([0-9a-f]{4})/gi, (_match, code: string) => String.fromCharCode(Number.parseInt(code, 16))) + .replace(terminalControl, "") + const rendered: string[] = [] + for (const character of withoutTerminalControls) { + if (character === "\b") { + if (rendered.at(-1) !== "\n") rendered.pop() + } else { + rendered.push(character) + } + } + return rendered.join("") +} + +export function redactText(value: string): string { + if (requiresFailClosedRedaction(value)) return REDACTED + const canonical = canonicalizeRedactionText(value) + const structured = canonical + .replace(/\b[A-Za-z][A-Za-z0-9+.-]*:\/\/[^\s/?#]+/g, (authority) => { + const schemeEnd = authority.indexOf("//") + 2 + const credentialsEnd = authority.lastIndexOf("@") + if (credentialsEnd < schemeEnd) return authority + return `${authority.slice(0, schemeEnd)}${REDACTED}@${authority.slice(credentialsEnd + 1)}` + }) + .replace(doubleQuotedSecret, `$1"${REDACTED}"`) + .replace(singleQuotedSecret, `$1'${REDACTED}'`) + .replace(quotedUnquotedSecret, `$1${REDACTED}`) + const assignments = redactAssignments(structured) + const redacted = secretPatterns.reduce((text, pattern) => text.replace(pattern, REDACTED), assignments) + if (canonical !== value) return redacted === canonical ? value : REDACTED + return redacted +} + +export function redactValue(value: Record): Record +export function redactValue(value: JsonValue[]): JsonValue[] +export function redactValue(value: JsonValue): JsonValue +export function redactValue(value: unknown, seen?: WeakSet): RedactedValue +export function redactValue(value: unknown, seen = new WeakSet()): RedactedValue { + if (value === null || typeof value === "boolean" || typeof value === "number") return value + if (value === undefined) return undefined + if (typeof value === "string") return redactText(value) + if (typeof value !== "object") return undefined + if (seen.has(value)) return "[CIRCULAR]" + seen.add(value) + + let result: RedactedValue + if (Array.isArray(value)) { + result = value.map((entry) => redactValue(entry, seen)) + } else { + const entries: Record = {} + for (const [key, entry] of Object.entries(value)) { + Object.defineProperty(entries, key, { + value: isSensitiveKey(key) ? REDACTED : redactValue(entry, seen), + enumerable: true, + configurable: true, + writable: true, + }) + } + result = entries + } + seen.delete(value) + return result +} + +export { REDACTED } diff --git a/packages/zoo-protocol/src/version.ts b/packages/zoo-protocol/src/version.ts new file mode 100644 index 0000000000..914726ce7c --- /dev/null +++ b/packages/zoo-protocol/src/version.ts @@ -0,0 +1,101 @@ +import { z } from "zod" + +export const ZOO_HOST_PROTOCOL_VERSION = 1 as const +export const ZOO_PUBLIC_SCHEMA_VERSION = 1 as const + +export const zooCapabilities = [ + "task:start", + "task:resume", + "task:input", + "task:cancel", + "ask:respond", + "history:list", + "host:snapshot", + "host:shutdown", + "checkpoint:unavailable", +] as const + +export const zooCapabilitySchema = z.enum(zooCapabilities) +export type ZooCapability = z.infer + +export const hostHelloSchema = z + .object({ + type: z.literal("hello"), + hostId: z.string().min(1), + supportedVersions: z.array(z.number().int().positive()).nonempty(), + capabilities: z.record(z.string().regex(/^[1-9]\d*$/), z.array(z.string().min(1))), + buildVersion: z.string().min(1), + }) + .strict() + .superRefine((hello, context) => { + const advertisedVersions = Object.keys(hello.capabilities).map(Number) + if ( + advertisedVersions.some((version) => !hello.supportedVersions.includes(version)) || + hello.supportedVersions.some((version) => !(String(version) in hello.capabilities)) + ) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: "Capabilities must be advertised for exactly the supported protocol versions", + }) + } + }) + +export type HostHello = z.infer + +export const parentHelloSchema = z + .object({ + type: z.literal("hello.select"), + version: z.number().int().positive(), + clientVersion: z.string().min(1), + requiredCapabilities: z.array(zooCapabilitySchema), + }) + .strict() + +export type ParentHello = z.infer + +export type NegotiationResult = + | { ok: true; version: number } + | { ok: false; code: "protocol_incompatible"; message: string } + +export function negotiateProtocol( + host: HostHello, + supportedVersions: readonly number[], + requiredCapabilities: readonly ZooCapability[], +): NegotiationResult { + const version = [...supportedVersions] + .sort((left, right) => right - left) + .find( + (candidate) => + candidate === ZOO_HOST_PROTOCOL_VERSION && + host.supportedVersions.includes(candidate) && + requiredCapabilities.every((capability) => host.capabilities[String(candidate)]?.includes(capability)), + ) + if (version === undefined) { + return { + ok: false, + code: "protocol_incompatible", + message: "No mutually supported host protocol version provides all required capabilities", + } + } + + return { ok: true, version } +} + +export function validateParentHello(host: HostHello, parent: ParentHello): NegotiationResult { + if (parent.version !== ZOO_HOST_PROTOCOL_VERSION) { + return { ok: false, code: "protocol_incompatible", message: "Selected protocol version has no installed codec" } + } + if (!host.supportedVersions.includes(parent.version)) { + return { ok: false, code: "protocol_incompatible", message: "Parent selected an unadvertised protocol version" } + } + const capabilities = host.capabilities[String(parent.version)] ?? [] + const missing = parent.requiredCapabilities.filter((capability) => !capabilities.includes(capability)) + if (missing.length > 0) { + return { + ok: false, + code: "protocol_incompatible", + message: `Selected protocol version is missing required capabilities: ${missing.join(", ")}`, + } + } + return { ok: true, version: parent.version } +} diff --git a/packages/zoo-protocol/tsconfig.json b/packages/zoo-protocol/tsconfig.json new file mode 100644 index 0000000000..45cb8699a3 --- /dev/null +++ b/packages/zoo-protocol/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@roo-code/config-typescript/base.json", + "compilerOptions": { + "outDir": "dist" + }, + "include": ["src"], + "exclude": ["node_modules"] +} diff --git a/packages/zoo-protocol/vitest.config.ts b/packages/zoo-protocol/vitest.config.ts new file mode 100644 index 0000000000..ea7ddf23e3 --- /dev/null +++ b/packages/zoo-protocol/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "vitest/config" + +export default defineConfig({ + test: { + globals: true, + environment: "node", + include: ["src/**/*.test.ts"], + watch: false, + }, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7c3dd070ac..82da4164ca 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -419,6 +419,25 @@ importers: specifier: 4.1.9 version: 4.1.9(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@26.1.0)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + packages/zoo-protocol: + dependencies: + zod: + specifier: 3.25.76 + version: 3.25.76 + devDependencies: + '@roo-code/config-eslint': + specifier: workspace:^ + version: link:../config-eslint + '@roo-code/config-typescript': + specifier: workspace:^ + version: link:../config-typescript + '@types/node': + specifier: 22.20.1 + version: 22.20.1 + vitest: + specifier: 4.1.9 + version: 4.1.9(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@26.1.0)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + src: dependencies: '@anthropic-ai/sdk':