From 23a7179142ea6a3e3ef31fc07c693368ce96f9aa Mon Sep 17 00:00:00 2001 From: FreshlyBrewedCode Date: Sat, 19 Sep 2026 10:59:58 +0000 Subject: [PATCH 1/2] refactor(runtime): move chunk interpretation into the agent adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR 0012 §2: AgentAdapter now yields AgentAdapterYield items (opaque chunk plus optional AgentSignal). The normalized signal union has three members — sessionId, structuredOutput, runError — each documented against the AgentStepFinished field it populates. The opencode adapter extracts signals via extractOpencodeSignal(), the only place in src/runtime/ that string-matches vendor event names (opencode.session-id, structured-output.complete). The runtime consumes signals directly via a switch on _tag and never matches vendor strings. The corpus replay adapter reuses extractOpencodeSignal() to supply signals from recorded opencode traces. Existing corpus files replay without modification. The slow fake adapter wraps raw chunks in { chunk } items. The adapter contract remains plain async (AsyncIterable), authorable without Effect (ADR 0012 §5). --- src/replay/adapter.ts | 22 +++++++--- src/runtime/agent-adapter.ts | 31 +++++++++++++- src/runtime/agent-step.ts | 62 ++++++++++++++-------------- src/runtime/agent-step.usage.test.ts | 7 +++- src/runtime/opencode-adapter.ts | 59 ++++++++++++++++++++++++-- 5 files changed, 139 insertions(+), 42 deletions(-) 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..5af341a 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,29 @@ 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; +} + export interface AgentAdapter { - stream(options: AgentAdapterOptions): AsyncIterable; + stream(options: AgentAdapterOptions): AsyncIterable; } diff --git a/src/runtime/agent-step.ts b/src/runtime/agent-step.ts index 5d0ca65..92d9d4f 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. @@ -106,12 +111,7 @@ export function buildAgentStepEffect(options: AgentStepEffectOptions): AgentStep (cause) => new AgentStepChunkError({ cause }), ); - const partial: AgentStepPartial = { - chunkCount: 0, - finalText: "", - sessionId: undefined, - usage: undefined, - }; + const partial: AgentStepPartial = { chunkCount: 0, finalText: "", sessionId: undefined, usage: undefined }; let currentMessageBuffer: string | undefined; let structuredOutput: unknown; let runError: string | undefined; @@ -120,28 +120,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 +161,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; }), ); @@ -181,10 +181,10 @@ export function buildAgentStepEffect(options: AgentStepEffectOptions): AgentStep chunkCount: partial.chunkCount, finalText: partial.finalText, structuredOutput, - sessionId: partial.sessionId, - usage: partial.usage, - runError, - durationMs: Date.now() - startedAt, + sessionId: partial.sessionId, + usage: partial.usage, + runError, + durationMs: Date.now() - startedAt, })); return { effect, abortController, partial }; 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.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 }; + } }, }; From 8bf6c4950f3f3ee1d5d8176a61d03ff97b8c4b0c Mon Sep 17 00:00:00 2001 From: FreshlyBrewedCode Date: Sat, 19 Sep 2026 11:00:03 +0000 Subject: [PATCH 2/2] test(runtime): verify signal extraction and consumption - agent-step.test.ts: signal-based adapter yields are consumed correctly (sessionId, structuredOutput, runError signals; onChunk receives raw chunk; chunks without signals pass through) - opencode-adapter.test.ts: extractOpencodeSignal() handles all cases (vendor CUSTOM chunks, RUN_ERROR, non-matching chunks, malformed values) - adapter.test.ts: corpus replay yields AgentAdapterYield items with signals extracted from recorded opencode chunks --- src/replay/adapter.test.ts | 24 +++++ src/runtime/agent-step.test.ts | 137 +++++++++++++++++++++++++++ src/runtime/agent-step.ts | 15 ++- src/runtime/opencode-adapter.test.ts | 64 +++++++++++++ 4 files changed, 235 insertions(+), 5 deletions(-) create mode 100644 src/runtime/agent-step.test.ts create mode 100644 src/runtime/opencode-adapter.test.ts 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/runtime/agent-step.test.ts b/src/runtime/agent-step.test.ts new file mode 100644 index 0000000..a33f438 --- /dev/null +++ b/src/runtime/agent-step.test.ts @@ -0,0 +1,137 @@ +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( + { type: "CUSTOM", name: "opencode.session-id", value: { sessionId: "ses_abc" } }, + { _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: "structured-output.complete", value: { object: outputObject } }, + { _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([ + makeYield( + { type: "RUN_ERROR", message: "something broke" }, + { _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 92d9d4f..e597aba 100644 --- a/src/runtime/agent-step.ts +++ b/src/runtime/agent-step.ts @@ -111,7 +111,12 @@ export function buildAgentStepEffect(options: AgentStepEffectOptions): AgentStep (cause) => new AgentStepChunkError({ cause }), ); - const partial: AgentStepPartial = { chunkCount: 0, finalText: "", sessionId: undefined, usage: undefined }; + const partial: AgentStepPartial = { + chunkCount: 0, + finalText: "", + sessionId: undefined, + usage: undefined, + }; let currentMessageBuffer: string | undefined; let structuredOutput: unknown; let runError: string | undefined; @@ -181,10 +186,10 @@ export function buildAgentStepEffect(options: AgentStepEffectOptions): AgentStep chunkCount: partial.chunkCount, finalText: partial.finalText, structuredOutput, - sessionId: partial.sessionId, - usage: partial.usage, - runError, - durationMs: Date.now() - startedAt, + sessionId: partial.sessionId, + usage: partial.usage, + runError, + durationMs: Date.now() - startedAt, })); return { effect, abortController, partial }; diff --git a/src/runtime/opencode-adapter.test.ts b/src/runtime/opencode-adapter.test.ts new file mode 100644 index 0000000..e129dbd --- /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 undefined 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(); + }); +});