diff --git a/src/replay/adapter.test.ts b/src/replay/adapter.test.ts index 7f4a053..460a2a5 100644 --- a/src/replay/adapter.test.ts +++ b/src/replay/adapter.test.ts @@ -1,6 +1,7 @@ import { Effect } from "effect"; import { describe, expect, test } from "bun:test"; import { buildAgentStepEffect } from "../runtime/agent-step"; +import type { AgentAdapterYield } from "../runtime/agent-adapter"; import { createCorpusReplayAdapter, createSlowFakeAdapter, loadCorpusBlocks } from "./adapter"; const FULL_ROUND_TRIP_CORPUS = `${import.meta.dir}/../../test/corpus/run-1789308170212.ndjson`; @@ -65,6 +66,29 @@ describe("createCorpusReplayAdapter", () => { expect(outcome.finalText.length).toBeGreaterThan(0); expect(outcome.runError).toBeUndefined(); }); + + test("yields AgentAdapterYield items with signals extracted from opencode chunks", async () => { + const adapter = createCorpusReplayAdapter(FULL_ROUND_TRIP_CORPUS); + const stream = adapter.stream({ + threadId: "t", + dir: "/tmp", + model: "m", + prompt: "p", + abortController: new AbortController(), + }); + + const yields: AgentAdapterYield[] = []; + for await (const y of stream) { + yields.push(y as AgentAdapterYield); + } + + expect(yields.length).toBe(39); + const sessionIdYield = yields.find((y) => y.signal?._tag === "sessionId"); + expect(sessionIdYield).toBeDefined(); + expect(sessionIdYield?.signal?.value).toBe("ses_f64ec04acffeJ0tjsHSkjAEqZF"); + const textYields = yields.filter((y) => y.signal === undefined); + expect(textYields.length).toBeGreaterThan(0); + }); }); describe("createSlowFakeAdapter", () => { diff --git a/src/replay/adapter.ts b/src/replay/adapter.ts index c3810a4..6f5a85f 100644 --- a/src/replay/adapter.ts +++ b/src/replay/adapter.ts @@ -3,6 +3,12 @@ * adapter a corpus replayer") — the runtime code path is identical whether * chunks come from here or from live opencode; only this module swaps. * + * ADR 0012 §2: adapters yield `AgentAdapterYield` items (opaque chunk + + * optional signal). The corpus replay adapter extracts signals from recorded + * opencode chunks using `extractOpencodeSignal`, so existing corpus traces + * replay without modification. A second adapter could supply signals without + * imitating opencode chunk shapes at all. + * * `createCorpusReplayAdapter` replays a recorded NDJSON trace * (`test/corpus/*.ndjson`, one `{step, chunk}` line each). The traces were * captured one workflow step at a time, so consecutive lines sharing a @@ -13,7 +19,12 @@ */ import { readFileSync } from "node:fs"; -import type { AgentAdapter, AgentAdapterOptions } from "../runtime/agent-adapter"; +import type { + AgentAdapter, + AgentAdapterOptions, + AgentAdapterYield, +} from "../runtime/agent-adapter"; +import { extractOpencodeSignal } from "../runtime/opencode-adapter"; export interface CorpusStepBlock { readonly step: string; @@ -54,7 +65,7 @@ export function createCorpusReplayAdapter(path: string): AgentAdapter { let cursor = 0; return { - stream(_options: AgentAdapterOptions): AsyncIterable { + stream(_options: AgentAdapterOptions): AsyncIterable { const index = cursor; cursor += 1; const block = blocks[index]; @@ -66,7 +77,8 @@ export function createCorpusReplayAdapter(path: string): AgentAdapter { return { async *[Symbol.asyncIterator]() { for (const chunk of block.chunks) { - yield chunk; + const signal = extractOpencodeSignal(chunk); + yield signal !== undefined ? { chunk, signal } : { chunk }; } }, }; @@ -83,12 +95,12 @@ export function createCorpusReplayAdapter(path: string): AgentAdapter { */ export function createSlowFakeAdapter(chunks: ReadonlyArray, delayMs = 20): AgentAdapter { return { - stream(_options: AgentAdapterOptions): AsyncIterable { + stream(_options: AgentAdapterOptions): AsyncIterable { return { async *[Symbol.asyncIterator]() { for (const chunk of chunks) { await new Promise((resolve) => setTimeout(resolve, delayMs)); - yield chunk; + yield { chunk }; } }, }; diff --git a/src/runtime/agent-adapter.ts b/src/runtime/agent-adapter.ts index 76bf8de..93fdb37 100644 --- a/src/runtime/agent-adapter.ts +++ b/src/runtime/agent-adapter.ts @@ -3,6 +3,12 @@ * actually produces a chunk stream. Live opencode for real runs, corpus * replay for `bun test` (STATUS.md phase 1: "make the fake adapter a corpus * replayer") — same runtime code path either way, only this function swaps. + * + * ADR 0012 §2: the adapter interprets its own chunk stream and yields the + * opaque chunk together with a normalized signal drawn from a small closed + * union. The runtime consumes signals directly; it never string-matches a + * vendor event name. A member exists in the union only when Factory has a + * field for it (`AgentStepFinished.sessionId`, `.output`, `.error`). */ export interface AgentAdapterOptions { @@ -15,6 +21,35 @@ export interface AgentAdapterOptions { readonly abortController: AbortController; } +/** + * The normalized signal union (ADR 0012 §2). Each member populates one + * field on `AgentStepFinished`: + * - `sessionId` → `AgentStepFinished.sessionId` + * - `structuredOutput` → `AgentStepFinished.output` (via `resolveOutput`) + * - `runError` → `AgentStepFinished.error` + */ +export type AgentSignal = + | { readonly _tag: "sessionId"; readonly value: string } + | { readonly _tag: "structuredOutput"; readonly value: unknown } + | { readonly _tag: "runError"; readonly value: string }; + +/** + * One item yielded by an adapter: the opaque AG-UI chunk (unchanged) plus + * an optional signal. The chunk is forwarded verbatim to `AgentChunk` events + * and to the SPA's `StreamProcessor`. The signal is consumed by the runtime + * to populate `AgentStepFinished` fields. + */ +export interface AgentAdapterYield { + readonly chunk: unknown; + readonly signal?: AgentSignal; +} + +/** + * Adapters own all chunk interpretation, including errors: the runtime no + * longer detects `RUN_ERROR` chunks itself, so an adapter must emit a + * `runError` signal for every chunk that reports a run failure, or the step + * will finish without `AgentStepFinished.error`. + */ export interface AgentAdapter { - stream(options: AgentAdapterOptions): AsyncIterable; + stream(options: AgentAdapterOptions): AsyncIterable; } diff --git a/src/runtime/agent-step.test.ts b/src/runtime/agent-step.test.ts new file mode 100644 index 0000000..f2f878f --- /dev/null +++ b/src/runtime/agent-step.test.ts @@ -0,0 +1,139 @@ +import { Effect } from "effect"; +import { describe, expect, test } from "bun:test"; +import type { AgentAdapterYield, AgentSignal } from "./agent-adapter"; +import { buildAgentStepEffect } from "./agent-step"; + +function makeYield(chunk: unknown, signal?: AgentSignal): AgentAdapterYield { + return signal !== undefined ? { chunk, signal } : { chunk }; +} + +function signalAdapter(yields: ReadonlyArray) { + return { + stream() { + return { + async *[Symbol.asyncIterator]() { + for (const y of yields) yield y; + }, + }; + }, + }; +} + +describe("buildAgentStepEffect signal extraction", () => { + test("extracts sessionId from a sessionId signal", async () => { + const adapter = signalAdapter([ + makeYield({ type: "TEXT_MESSAGE_START" }), + makeYield({ type: "TEXT_MESSAGE_CONTENT", delta: "hello" }), + makeYield({ type: "TEXT_MESSAGE_END" }), + makeYield( + // Neutral chunk: the runtime must read the signal, not the chunk shape. + { type: "CUSTOM", name: "anything" }, + { _tag: "sessionId", value: "ses_abc" }, + ), + ]); + + const handle = buildAgentStepEffect({ + threadId: "t", + dir: "/tmp", + model: "m", + prompt: "p", + adapter, + onChunk: () => {}, + }); + + const outcome = await Effect.runPromise(handle.effect); + expect(outcome.sessionId).toBe("ses_abc"); + expect(outcome.finalText).toBe("hello"); + expect(outcome.chunkCount).toBe(4); + }); + + test("extracts structuredOutput from a structuredOutput signal", async () => { + const outputObject = { title: "test", body: "content" }; + const adapter = signalAdapter([ + makeYield({ type: "TEXT_MESSAGE_START" }), + makeYield({ type: "TEXT_MESSAGE_CONTENT", delta: "done" }), + makeYield({ type: "TEXT_MESSAGE_END" }), + makeYield( + { type: "CUSTOM", name: "anything" }, + { _tag: "structuredOutput", value: outputObject }, + ), + ]); + + const handle = buildAgentStepEffect({ + threadId: "t", + dir: "/tmp", + model: "m", + prompt: "p", + adapter, + onChunk: () => {}, + }); + + const outcome = await Effect.runPromise(handle.effect); + expect(outcome.structuredOutput).toEqual(outputObject); + }); + + test("extracts runError from a runError signal", async () => { + const adapter = signalAdapter([ + // No RUN_ERROR chunk: the error comes from the signal alone. + makeYield( + { type: "CUSTOM", name: "anything" }, + { _tag: "runError", value: "something broke" }, + ), + ]); + + const handle = buildAgentStepEffect({ + threadId: "t", + dir: "/tmp", + model: "m", + prompt: "p", + adapter, + onChunk: () => {}, + }); + + const outcome = await Effect.runPromise(handle.effect); + expect(outcome.runError).toBe("something broke"); + }); + + test("onChunk receives the raw opaque chunk, not the signal wrapper", async () => { + const rawChunk = { type: "TEXT_MESSAGE_START" }; + const adapter = signalAdapter([makeYield(rawChunk)]); + + const chunks: Array = []; + const handle = buildAgentStepEffect({ + threadId: "t", + dir: "/tmp", + model: "m", + prompt: "p", + adapter, + onChunk: (chunk) => chunks.push(chunk), + }); + + await Effect.runPromise(handle.effect); + expect(chunks).toHaveLength(1); + expect(chunks[0]).toBe(rawChunk); + }); + + test("yields without signals pass through without error", async () => { + const adapter = signalAdapter([ + makeYield({ type: "TEXT_MESSAGE_START" }), + makeYield({ type: "TEXT_MESSAGE_CONTENT", delta: "no signals here" }), + makeYield({ type: "TEXT_MESSAGE_END" }), + ]); + + const handle = buildAgentStepEffect({ + threadId: "t", + dir: "/tmp", + model: "m", + prompt: "p", + adapter, + onChunk: () => {}, + }); + + const outcome = await Effect.runPromise(handle.effect); + expect(outcome.chunkCount).toBe(3); + expect(outcome.finalText).toBe("no signals here"); + expect(outcome.sessionId).toBeUndefined(); + expect(outcome.structuredOutput).toBeUndefined(); + expect(outcome.runError).toBeUndefined(); + }); +}); diff --git a/src/runtime/agent-step.ts b/src/runtime/agent-step.ts index 5d0ca65..e597aba 100644 --- a/src/runtime/agent-step.ts +++ b/src/runtime/agent-step.ts @@ -5,6 +5,11 @@ * §5, D17): the boundary wiring is unchanged, only the chunk source and the * bookkeeping surface (now `ctx.agent`'s granular result, ADR 0002 §2) moved. * + * ADR 0012 §2: the runtime consumes `AgentSignal`s from the adapter and never + * string-matches vendor event names. AG-UI standard types (`TEXT_MESSAGE_*`) + * are still interpreted here for `finalText` accumulation — these are part + * of the open AG-UI protocol, not vendor-specific. + * * WHY THE EXPLICIT `abortController.abort()` IS NEEDED (0a-1/0a-2 findings): * closing the IO stream does not terminate the opencode process; only an * explicit abort does, and even that is indirect — `abort()` fires the @@ -18,7 +23,7 @@ import { Effect, Schema, Stream } from "effect"; import type { AgentStepUsage } from "../events"; -import type { AgentAdapter } from "./agent-adapter"; +import type { AgentAdapter, AgentAdapterYield } from "./agent-adapter"; /** * Pull the four token counts out of a `RUN_FINISHED.usage` object. @@ -120,28 +125,33 @@ export function buildAgentStepEffect(options: AgentStepEffectOptions): AgentStep // Plain closure mutation (not a `Ref`) is fine: this Effect never runs // concurrently with itself, and the callback always runs on the same // single-threaded event loop turn (mirrors the spike's finding exactly). - const processed = Stream.mapEffect(rawStream, (chunk) => + const processed = Stream.mapEffect(rawStream, (yieldItem: AgentAdapterYield) => Effect.sync(() => { partial.chunkCount += 1; - options.onChunk(chunk); + options.onChunk(yieldItem.chunk); + + if (yieldItem.signal !== undefined) { + switch (yieldItem.signal._tag) { + case "sessionId": + partial.sessionId = yieldItem.signal.value; + break; + case "structuredOutput": + structuredOutput = yieldItem.signal.value; + break; + case "runError": + runError = yieldItem.signal.value; + break; + } + } - const record = chunk as { + const record = yieldItem.chunk as { type?: unknown; - name?: unknown; - value?: unknown; delta?: unknown; - message?: unknown; usage?: unknown; }; - if (record.type === "CUSTOM" && typeof record.name === "string") { - if (record.name === "structured-output.complete") { - const value = record.value as { object?: unknown } | undefined; - structuredOutput = value?.object; - } else if (record.name === "opencode.session-id") { - const value = record.value as { sessionId?: unknown } | undefined; - if (typeof value?.sessionId === "string") partial.sessionId = value.sessionId; - } + if (record.type === "RUN_FINISHED") { + partial.usage = readUsage(record.usage); } if (record.type === "TEXT_MESSAGE_START") { @@ -156,14 +166,9 @@ export function buildAgentStepEffect(options: AgentStepEffectOptions): AgentStep partial.finalText = currentMessageBuffer; } currentMessageBuffer = undefined; - } else if (record.type === "RUN_FINISHED") { - partial.usage = readUsage(record.usage); - } else if (record.type === "RUN_ERROR") { - const message = record.message; - runError = typeof message === "string" ? message : JSON.stringify(chunk); } - return chunk; + return yieldItem; }), ); diff --git a/src/runtime/agent-step.usage.test.ts b/src/runtime/agent-step.usage.test.ts index 590bf49..00e65b4 100644 --- a/src/runtime/agent-step.usage.test.ts +++ b/src/runtime/agent-step.usage.test.ts @@ -15,6 +15,7 @@ import { describe, expect, test } from "bun:test"; import { Effect } from "effect"; import { agentStepContextTokens } from "../events"; +import type { AgentAdapterYield } from "./agent-adapter"; import { loadCorpusBlocks } from "../replay/adapter"; import { buildAgentStepEffect } from "./agent-step"; @@ -27,8 +28,10 @@ async function runBlock(chunks: ReadonlyArray) { model: "model", prompt: "prompt", adapter: { - async *stream() { - yield* chunks; + async *stream(): AsyncGenerator { + for (const chunk of chunks) { + yield { chunk }; + } }, }, onChunk: () => {}, diff --git a/src/runtime/opencode-adapter.test.ts b/src/runtime/opencode-adapter.test.ts new file mode 100644 index 0000000..75020ab --- /dev/null +++ b/src/runtime/opencode-adapter.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, test } from "bun:test"; +import { extractOpencodeSignal } from "./opencode-adapter"; + +describe("extractOpencodeSignal", () => { + test("returns sessionId signal for opencode.session-id CUSTOM chunk", () => { + const chunk = { + type: "CUSTOM", + name: "opencode.session-id", + value: { sessionId: "ses_abc123" }, + }; + expect(extractOpencodeSignal(chunk)).toEqual({ _tag: "sessionId", value: "ses_abc123" }); + }); + + test("returns structuredOutput signal for structured-output.complete CUSTOM chunk", () => { + const obj = { title: "t", body: "b" }; + const chunk = { + type: "CUSTOM", + name: "structured-output.complete", + value: { object: obj }, + }; + expect(extractOpencodeSignal(chunk)).toEqual({ _tag: "structuredOutput", value: obj }); + }); + + test("returns runError signal for RUN_ERROR chunk", () => { + const chunk = { type: "RUN_ERROR", message: "oops" }; + expect(extractOpencodeSignal(chunk)).toEqual({ _tag: "runError", value: "oops" }); + }); + + test("returns runError with the stringified chunk for RUN_ERROR with non-string message", () => { + const chunk = { type: "RUN_ERROR", message: 42 }; + expect(extractOpencodeSignal(chunk)).toEqual({ + _tag: "runError", + value: JSON.stringify(chunk), + }); + }); + + test("returns undefined for a regular TEXT_MESSAGE chunk", () => { + expect(extractOpencodeSignal({ type: "TEXT_MESSAGE_START" })).toBeUndefined(); + expect(extractOpencodeSignal({ type: "TEXT_MESSAGE_CONTENT", delta: "hi" })).toBeUndefined(); + expect(extractOpencodeSignal({ type: "TEXT_MESSAGE_END" })).toBeUndefined(); + }); + + test("returns undefined for CUSTOM chunks with other names", () => { + expect( + extractOpencodeSignal({ type: "CUSTOM", name: "sandbox.file", value: {} }), + ).toBeUndefined(); + }); + + test("returns undefined for structured-output.complete without value.object", () => { + expect( + extractOpencodeSignal({ type: "CUSTOM", name: "structured-output.complete", value: {} }), + ).toBeUndefined(); + }); + + test("returns undefined for opencode.session-id without string sessionId", () => { + expect( + extractOpencodeSignal({ + type: "CUSTOM", + name: "opencode.session-id", + value: { sessionId: 42 }, + }), + ).toBeUndefined(); + }); +}); diff --git a/src/runtime/opencode-adapter.ts b/src/runtime/opencode-adapter.ts index 878c24c..edefb3f 100644 --- a/src/runtime/opencode-adapter.ts +++ b/src/runtime/opencode-adapter.ts @@ -3,6 +3,11 @@ * `opencodeText`, exactly the plumbing proven in `src/spike/lib/effect-agent-step.ts` * (ADR 0001 §5). D15: `localProcessSandbox({dir})` implements D8, paired with * `defineWorkspace({source:{type:"none"}, setup:[]})` to get `lifecycle.reuse:"thread"`. + * + * ADR 0012 §2: this adapter interprets its own chunk stream. Vendor-specific + * event names (`opencode.session-id`, `structured-output.complete`) are + * recognized here and emitted as normalized `AgentSignal`s. The runtime + * consumes signals and never matches these strings. */ import { chat } from "@tanstack/ai"; @@ -10,7 +15,51 @@ import { opencodeText } from "@tanstack/ai-opencode"; import { defineSandbox, defineWorkspace, withSandbox } from "@tanstack/ai-sandbox"; import { localProcessSandbox } from "@tanstack/ai-sandbox-local-process"; import net from "node:net"; -import type { AgentAdapter, AgentAdapterOptions } from "./agent-adapter"; +import type { + AgentAdapter, + AgentAdapterOptions, + AgentAdapterYield, + AgentSignal, +} from "./agent-adapter"; + +/** + * Inspect one raw AG-UI chunk for opencode-specific signals. Returns the + * matching `AgentSignal` when the chunk carries a recognized vendor event, + * or `undefined` when it does not. This is the only place in the runtime + * layer that string-matches opencode event names. + */ +export function extractOpencodeSignal(chunk: unknown): AgentSignal | undefined { + const record = chunk as { + type?: unknown; + name?: unknown; + value?: unknown; + message?: unknown; + }; + + if (record.type === "CUSTOM" && typeof record.name === "string") { + if (record.name === "opencode.session-id") { + const value = record.value as { sessionId?: unknown } | undefined; + if (typeof value?.sessionId === "string") { + return { _tag: "sessionId", value: value.sessionId }; + } + } else if (record.name === "structured-output.complete") { + const value = record.value as { object?: unknown } | undefined; + if (value?.object !== undefined) { + return { _tag: "structuredOutput", value: value.object }; + } + } + } + + if (record.type === "RUN_ERROR") { + const message = record.message; + return { + _tag: "runError", + value: typeof message === "string" ? message : JSON.stringify(chunk), + }; + } + + return undefined; +} /** * The adapter boots `opencode serve --port=…` inside the run's sandbox on a @@ -37,7 +86,7 @@ async function freePort(): Promise { } export const opencodeAdapter: AgentAdapter = { - async *stream(options: AgentAdapterOptions): AsyncIterable { + async *stream(options: AgentAdapterOptions): AsyncIterable { const sandboxDefinition = defineSandbox({ id: "factory-run", provider: localProcessSandbox({ dir: options.dir }), @@ -68,6 +117,10 @@ export const opencodeAdapter: AgentAdapter = { stream: true, } as unknown as Parameters[0]) as AsyncIterable) : (chat(baseChatOptions) as unknown as AsyncIterable); - yield* stream; + + for await (const chunk of stream) { + const signal = extractOpencodeSignal(chunk); + yield signal !== undefined ? { chunk, signal } : { chunk }; + } }, };