@neutrome/lilsdk 0.6.1 → 0.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -18,8 +18,10 @@ small — SDK contracts only — and each subpath owns one concern:
18
18
  `OutputSink`, and `createExecutionEvent`
19
19
  - `@neutrome/lilsdk/loops`: `retry`, `fallback`, and `createGoalExecutor`
20
20
  - `@neutrome/lilsdk/tools`: `createToolsExecutor` tool-call loop
21
- - `@neutrome/lilsdk/managed`: `createCapabilitiesExecutor` and
22
- `createAttachmentToTextExecutor`
21
+ - `@neutrome/lilsdk/managed`: `createCapabilitiesExecutor`,
22
+ `createAttachmentToTextExecutor`, and
23
+ `createCollapsedReasoner(reasoner, summarizer, options)` for safe reasoning
24
+ updates
23
25
  - `@neutrome/lilsdk/stream`: `observeExecutionStream`, `streamStage`, and
24
26
  `writeThinking`
25
27
  - `@neutrome/lilsdk/primitives`: structural program helpers — `walk`, `map`,
@@ -1,102 +1,113 @@
1
- ## API Report File for "neutrome"
2
-
3
- > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
4
-
5
- ```ts
6
-
7
- import type { Program } from '@neutrome/lil-engine';
8
- import { ProgramAttachment } from '@neutrome/lil-engine';
9
-
10
- // @public
11
- export type AttachmentReaderRule = {
12
- mimeTypes: readonly string[];
13
- executor: ExecutorInput;
14
- };
15
-
16
- // @public
17
- export type AttachmentToTextOptions = {
18
- systemPrompt?: string;
19
- cacheKey?: (attachment: ProgramAttachment, ctx: ExecutorContext) => string;
20
- };
21
-
22
- // @public
23
- type Cache_2 = {
24
- get(key: string): Promise<string | null>;
25
- put(key: string, value: string, options?: CachePutOptions): Promise<void>;
26
- delete(key: string): Promise<void>;
27
- };
28
-
29
- // @public
30
- type CachePutOptions = {
31
- expiration?: number;
32
- expirationTtl?: number;
33
- };
34
-
35
- // @public
36
- export type CapabilitiesExecutorOptions = {
37
- enabledIterations?: number;
38
- cacheKey?: (ctx: ExecutorContext) => string;
39
- skipPrefixes?: readonly string[];
40
- };
41
-
42
- // Warning: (ae-forgotten-export) The symbol "Executor" needs to be exported by the entry point index.d.ts
43
- //
44
- // @public
45
- export function createAttachmentToTextExecutor(inner: ExecutorInput, rules: readonly AttachmentReaderRule[], options?: AttachmentToTextOptions): Executor;
46
-
47
- // @public
48
- export function createCapabilitiesExecutor(inner: ExecutorInput, options?: CapabilitiesExecutorOptions): Executor;
49
-
50
- // @public
51
- type ExecutionEvent = {
52
- kind: string;
53
- requestId: string;
54
- executionId: string;
55
- timestamp: string;
56
- parentExecutionId?: string;
57
- target?: {
58
- kind: "provider" | "executor";
59
- id: string;
60
- };
61
- errorKind?: string;
62
- data?: Record<string, unknown>;
63
- };
64
-
65
- // @public
66
- type Executor = {
67
- execute(request: Program, ctx: ExecutorContext): Promise<Program>;
68
- stream(request: Program, ctx: ExecutorContext): AsyncIterable<Program>;
69
- };
70
-
71
- // Warning: (ae-forgotten-export) The symbol "RuntimeContext" needs to be exported by the entry point index.d.ts
72
- //
73
- // @public
74
- type ExecutorContext = RuntimeContext & {
75
- requestId: string;
76
- executionId: string;
77
- parentExecutionId?: string;
78
- };
79
-
80
- // @public
81
- type ExecutorInput = string | Executor;
82
-
83
- // @public
84
- type RuntimeContext = {
85
- cache: Cache_2;
86
- invoke(executor: ExecutorInput, request: Program): Promise<Program>;
87
- invokeStream(executor: ExecutorInput, request: Program): AsyncIterable<Program>;
88
- observe(event: ExecutionEvent): void;
89
- signal: AbortSignal;
90
- };
91
-
92
- // Warnings were encountered during analysis:
93
- //
94
- // .reference-build/types/neutrome-lilsdk/src/managed/attachment-to-text.d.ts:8:5 - (ae-forgotten-export) The symbol "ExecutorInput" needs to be exported by the entry point index.d.ts
95
- // .reference-build/types/neutrome-lilsdk/src/managed/attachment-to-text.d.ts:15:5 - (ae-forgotten-export) The symbol "ExecutorContext" needs to be exported by the entry point index.d.ts
96
- // .reference-build/types/neutrome-lilsdk/src/types.d.ts:42:5 - (ae-forgotten-export) The symbol "CachePutOptions" needs to be exported by the entry point index.d.ts
97
- // .reference-build/types/neutrome-lilsdk/src/types.d.ts:55:5 - (ae-forgotten-export) The symbol "Cache_2" needs to be exported by the entry point index.d.ts
98
- // .reference-build/types/neutrome-lilsdk/src/types.d.ts:77:5 - (ae-forgotten-export) The symbol "ExecutionEvent" needs to be exported by the entry point index.d.ts
99
-
100
- // (No @packageDocumentation comment for this package)
101
-
102
- ```
1
+ ## API Report File for "neutrome"
2
+
3
+ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
4
+
5
+ ```ts
6
+
7
+ import type { Program } from '@neutrome/lil-engine';
8
+ import { ProgramAttachment } from '@neutrome/lil-engine';
9
+
10
+ // @public
11
+ export type AttachmentReaderRule = {
12
+ mimeTypes: readonly string[];
13
+ executor: ExecutorInput;
14
+ };
15
+
16
+ // @public
17
+ export type AttachmentToTextOptions = {
18
+ systemPrompt?: string;
19
+ cacheKey?: (attachment: ProgramAttachment, ctx: ExecutorContext) => string;
20
+ };
21
+
22
+ // @public
23
+ type Cache_2 = {
24
+ get(key: string): Promise<string | null>;
25
+ put(key: string, value: string, options?: CachePutOptions): Promise<void>;
26
+ delete(key: string): Promise<void>;
27
+ };
28
+
29
+ // @public
30
+ type CachePutOptions = {
31
+ expiration?: number;
32
+ expirationTtl?: number;
33
+ };
34
+
35
+ // @public
36
+ export type CapabilitiesExecutorOptions = {
37
+ enabledIterations?: number;
38
+ cacheKey?: (ctx: ExecutorContext) => string;
39
+ skipPrefixes?: readonly string[];
40
+ };
41
+
42
+ // @public
43
+ export type CollapsedReasonerOptions = {
44
+ prompt?: string;
45
+ forcedPrompt?: string;
46
+ interval?: number;
47
+ forcedInterval?: number;
48
+ };
49
+
50
+ // Warning: (ae-forgotten-export) The symbol "Executor" needs to be exported by the entry point index.d.ts
51
+ //
52
+ // @public
53
+ export function createAttachmentToTextExecutor(inner: ExecutorInput, rules: readonly AttachmentReaderRule[], options?: AttachmentToTextOptions): Executor;
54
+
55
+ // @public
56
+ export function createCapabilitiesExecutor(inner: ExecutorInput, options?: CapabilitiesExecutorOptions): Executor;
57
+
58
+ // @public
59
+ export function createCollapsedReasoner(reasoner: ExecutorInput, summarizer: ExecutorInput, options?: CollapsedReasonerOptions): Executor;
60
+
61
+ // @public
62
+ type ExecutionEvent = {
63
+ kind: string;
64
+ requestId: string;
65
+ executionId: string;
66
+ timestamp: string;
67
+ parentExecutionId?: string;
68
+ target?: {
69
+ kind: "provider" | "executor";
70
+ id: string;
71
+ };
72
+ errorKind?: string;
73
+ data?: Record<string, unknown>;
74
+ };
75
+
76
+ // @public
77
+ type Executor = {
78
+ execute(request: Program, ctx: ExecutorContext): Promise<Program>;
79
+ stream(request: Program, ctx: ExecutorContext): AsyncIterable<Program>;
80
+ };
81
+
82
+ // Warning: (ae-forgotten-export) The symbol "RuntimeContext" needs to be exported by the entry point index.d.ts
83
+ //
84
+ // @public
85
+ type ExecutorContext = RuntimeContext & {
86
+ requestId: string;
87
+ executionId: string;
88
+ parentExecutionId?: string;
89
+ };
90
+
91
+ // @public
92
+ type ExecutorInput = string | Executor;
93
+
94
+ // @public
95
+ type RuntimeContext = {
96
+ cache: Cache_2;
97
+ invoke(executor: ExecutorInput, request: Program): Promise<Program>;
98
+ invokeStream(executor: ExecutorInput, request: Program): AsyncIterable<Program>;
99
+ observe(event: ExecutionEvent): void;
100
+ signal: AbortSignal;
101
+ };
102
+
103
+ // Warnings were encountered during analysis:
104
+ //
105
+ // .reference-build/types/neutrome-lilsdk/src/managed/attachment-to-text.d.ts:8:5 - (ae-forgotten-export) The symbol "ExecutorInput" needs to be exported by the entry point index.d.ts
106
+ // .reference-build/types/neutrome-lilsdk/src/managed/attachment-to-text.d.ts:15:5 - (ae-forgotten-export) The symbol "ExecutorContext" needs to be exported by the entry point index.d.ts
107
+ // .reference-build/types/neutrome-lilsdk/src/types.d.ts:42:5 - (ae-forgotten-export) The symbol "CachePutOptions" needs to be exported by the entry point index.d.ts
108
+ // .reference-build/types/neutrome-lilsdk/src/types.d.ts:55:5 - (ae-forgotten-export) The symbol "Cache_2" needs to be exported by the entry point index.d.ts
109
+ // .reference-build/types/neutrome-lilsdk/src/types.d.ts:77:5 - (ae-forgotten-export) The symbol "ExecutionEvent" needs to be exported by the entry point index.d.ts
110
+
111
+ // (No @packageDocumentation comment for this package)
112
+
113
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@neutrome/lilsdk",
3
- "version": "0.6.1",
3
+ "version": "0.6.3",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./src/index.ts",
@@ -0,0 +1,237 @@
1
+ import {
2
+ appendInstructions,
3
+ appendUserMessage,
4
+ createProgram,
5
+ extractContentText,
6
+ extractThinkingText,
7
+ findThinkingBlocks,
8
+ Opcode,
9
+ prependSystemPrompt,
10
+ removeInstructions,
11
+ type Program,
12
+ } from "@neutrome/lil-engine";
13
+ import type { Executor, ExecutorContext, ExecutorInput } from "../types.ts";
14
+
15
+ const DEFAULT_INTERVAL = 2_000;
16
+ const DEFAULT_FORCED_INTERVAL = 8_000;
17
+
18
+ const DEFAULT_PROMPT =
19
+ "Summarize the reasoning below as a concise user-facing progress update. Return an empty response when there is no useful update.";
20
+ const DEFAULT_FORCED_PROMPT =
21
+ "Give a concise user-facing progress update based only on the reasoning below. Return an empty response when no update is useful.";
22
+
23
+ /** Options accepted by {@link createCollapsedReasoner}. */
24
+ export type CollapsedReasonerOptions = {
25
+ /** Prompt used for periodic progress updates. */
26
+ prompt?: string;
27
+ /** Prompt used when an update must be attempted. */
28
+ forcedPrompt?: string;
29
+ /** Milliseconds between ordinary progress updates. Defaults to 2 seconds. */
30
+ interval?: number;
31
+ /** Milliseconds between forced updates. Defaults to 8 seconds. */
32
+ forcedInterval?: number;
33
+ };
34
+
35
+ /**
36
+ * Hides a reasoner's raw chain of thought and replaces it with concise,
37
+ * user-facing progress updates generated by a separate summarizer.
38
+ *
39
+ * @param reasoner - Executor or model id that produces the original response.
40
+ * @param summarizer - Executor or model id that summarizes unexposed reasoning.
41
+ * @param options - Prompts and wall-clock update cadence.
42
+ */
43
+ export function createCollapsedReasoner(
44
+ reasoner: ExecutorInput,
45
+ summarizer: ExecutorInput,
46
+ options: CollapsedReasonerOptions = {},
47
+ ): Executor {
48
+ const settings = parseOptions(options);
49
+
50
+ return {
51
+ async execute(request, ctx) {
52
+ const response = await ctx.invoke(reasoner, request);
53
+ const reasoning = extractThinkingText(response);
54
+ const concealed = withoutThinking(response);
55
+ if (!reasoning) return concealed;
56
+
57
+ const summary = await summarize(ctx, summarizer, settings.forcedPrompt, reasoning);
58
+ return summary ? appendAssistantThinking(concealed, summary) : concealed;
59
+ },
60
+
61
+ async *stream(request, ctx) {
62
+ const source = ctx.invokeStream(reasoner, request)[Symbol.asyncIterator]();
63
+ let raw = "";
64
+ let startedAt: number | undefined;
65
+ let normalAt = 0;
66
+ let forcedAt = 0;
67
+ let summaries: Promise<void> = Promise.resolve();
68
+ const ready: string[] = [];
69
+ let summaryError: unknown;
70
+ let wake: (() => void) | undefined;
71
+ let wakePromise = nextWake();
72
+ const terminal: Program[] = [];
73
+
74
+ const notify = () => {
75
+ wake?.();
76
+ wakePromise = nextWake();
77
+ };
78
+ const enqueue = (prompt: string, force: boolean) => {
79
+ if (!raw) return;
80
+ const interval = raw;
81
+ raw = "";
82
+ summaries = summaries.then(async () => {
83
+ const summary = await summarize(ctx, summarizer, prompt, interval);
84
+ if (summary) ready.push(summary);
85
+ });
86
+ void summaries.then(notify, (error: unknown) => {
87
+ summaryError = error;
88
+ notify();
89
+ });
90
+ if (force) normalAt = Date.now();
91
+ };
92
+
93
+ let next = source.next();
94
+ try {
95
+ for (;;) {
96
+ if (summaryError) throw summaryError;
97
+ while (ready.length > 0) yield thinkingChunk(ready.shift()!);
98
+
99
+ const now = Date.now();
100
+ if (startedAt !== undefined) {
101
+ if (now >= forcedAt) {
102
+ enqueue(settings.forcedPrompt, true);
103
+ forcedAt = now + settings.forcedInterval;
104
+ normalAt = now + settings.interval;
105
+ continue;
106
+ }
107
+ if (now >= normalAt) {
108
+ enqueue(settings.prompt, false);
109
+ normalAt = now + settings.interval;
110
+ continue;
111
+ }
112
+ }
113
+
114
+ const due = startedAt === undefined
115
+ ? undefined
116
+ : Math.max(0, Math.min(normalAt, forcedAt) - Date.now());
117
+ const event = await Promise.race([
118
+ next.then((result) => ({ type: "source" as const, result })),
119
+ wakePromise.then(() => ({ type: "summary" as const })),
120
+ ...(due === undefined ? [] : [delay(due).then(() => ({ type: "timer" as const }))]),
121
+ ]);
122
+ if (event.type !== "source") continue;
123
+ next = source.next();
124
+ if (event.result.done) break;
125
+
126
+ const visible = splitStreamChunk(event.result.value, terminal, (text) => {
127
+ raw += text;
128
+ if (startedAt === undefined) {
129
+ const timestamp = Date.now();
130
+ startedAt = timestamp;
131
+ normalAt = timestamp + settings.interval;
132
+ forcedAt = timestamp + settings.forcedInterval;
133
+ }
134
+ });
135
+ if (visible.code.length > 0) yield visible;
136
+ }
137
+
138
+ enqueue(settings.forcedPrompt, true);
139
+ await summaries;
140
+ if (summaryError) throw summaryError;
141
+ while (ready.length > 0) yield thinkingChunk(ready.shift()!);
142
+ yield* terminal;
143
+ } finally {
144
+ await source.return?.();
145
+ }
146
+
147
+ function nextWake(): Promise<void> {
148
+ return new Promise((resolve) => {
149
+ wake = resolve;
150
+ });
151
+ }
152
+ },
153
+ };
154
+ }
155
+
156
+ function parseOptions(options: CollapsedReasonerOptions) {
157
+ const interval = positiveDuration(options.interval ?? DEFAULT_INTERVAL, "interval");
158
+ const forcedInterval = positiveDuration(
159
+ options.forcedInterval ?? DEFAULT_FORCED_INTERVAL,
160
+ "forcedInterval",
161
+ );
162
+ if (forcedInterval < interval) {
163
+ throw new RangeError("forcedInterval must be greater than or equal to interval");
164
+ }
165
+ return {
166
+ interval,
167
+ forcedInterval,
168
+ prompt: options.prompt ?? DEFAULT_PROMPT,
169
+ forcedPrompt: options.forcedPrompt ?? DEFAULT_FORCED_PROMPT,
170
+ };
171
+ }
172
+
173
+ function positiveDuration(value: number, name: string): number {
174
+ if (!Number.isFinite(value) || value <= 0) {
175
+ throw new RangeError(`${name} must be a positive duration in milliseconds`);
176
+ }
177
+ return value;
178
+ }
179
+
180
+ async function summarize(
181
+ ctx: ExecutorContext,
182
+ summarizer: ExecutorInput,
183
+ prompt: string,
184
+ reasoning: string,
185
+ ): Promise<string> {
186
+ const request = prependSystemPrompt(appendUserMessage(createProgram(), reasoning), prompt);
187
+ return extractContentText(await ctx.invoke(summarizer, request)).trim();
188
+ }
189
+
190
+ function withoutThinking(program: Program): Program {
191
+ const indices = findThinkingBlocks(program).flatMap((block) =>
192
+ Array.from({ length: block.end - block.start + 1 }, (_, offset) => block.start + offset),
193
+ );
194
+ return removeInstructions(program, indices);
195
+ }
196
+
197
+ function appendAssistantThinking(program: Program, text: string): Program {
198
+ return appendInstructions(program, [
199
+ { opcode: Opcode.MSG_START, value: { kind: "none" } },
200
+ { opcode: Opcode.ROLE_AST, value: { kind: "none" } },
201
+ { opcode: Opcode.THINK_START, value: { kind: "none" } },
202
+ { opcode: Opcode.THINK_CHUNK, value: { kind: "string", value: text } },
203
+ { opcode: Opcode.THINK_END, value: { kind: "none" } },
204
+ { opcode: Opcode.MSG_END, value: { kind: "none" } },
205
+ ]);
206
+ }
207
+
208
+ function splitStreamChunk(
209
+ chunk: Program,
210
+ terminal: Program[],
211
+ onThinking: (text: string) => void,
212
+ ): Program {
213
+ const visible = [] as Program["code"];
214
+ const ending = [] as Program["code"];
215
+ for (const instruction of chunk.code) {
216
+ if (instruction.opcode === Opcode.STREAM_THINK_DELTA && instruction.value.kind === "string") {
217
+ onThinking(instruction.value.value);
218
+ } else if (instruction.opcode === Opcode.RESP_DONE || instruction.opcode === Opcode.STREAM_END) {
219
+ ending.push(instruction);
220
+ } else {
221
+ visible.push(instruction);
222
+ }
223
+ }
224
+ if (ending.length > 0) terminal.push({ code: ending, buffers: chunk.buffers });
225
+ return { code: visible, buffers: chunk.buffers };
226
+ }
227
+
228
+ function thinkingChunk(text: string): Program {
229
+ return {
230
+ code: [{ opcode: Opcode.STREAM_THINK_DELTA, value: { kind: "string", value: text } }],
231
+ buffers: [],
232
+ };
233
+ }
234
+
235
+ function delay(milliseconds: number): Promise<void> {
236
+ return new Promise((resolve) => setTimeout(resolve, milliseconds));
237
+ }
@@ -1,5 +1,7 @@
1
1
  export { createCapabilitiesExecutor } from "./capabilities.ts";
2
2
  export type { CapabilitiesExecutorOptions } from "./capabilities.ts";
3
+ export { createCollapsedReasoner } from "./collapsed-reasoner.ts";
4
+ export type { CollapsedReasonerOptions } from "./collapsed-reasoner.ts";
3
5
  export { createAttachmentToTextExecutor } from "./attachment-to-text.ts";
4
6
  export type {
5
7
  AttachmentReaderRule,
@@ -0,0 +1,190 @@
1
+ import {
2
+ appendAssistantMessage,
3
+ appendInstructions,
4
+ createProgram,
5
+ delta,
6
+ extractContentText,
7
+ extractThinkingText,
8
+ Opcode,
9
+ ProgramView,
10
+ type Instruction,
11
+ type Program,
12
+ } from "@neutrome/lil-engine";
13
+ import { describe, expect, it, vi } from "vitest";
14
+ import { createCollapsedReasoner } from "../src/managed/collapsed-reasoner.ts";
15
+ import type { Executor, ExecutorContext } from "../src/types.ts";
16
+
17
+ describe("createCollapsedReasoner", () => {
18
+ it("replaces completed raw reasoning with a forced summary", async () => {
19
+ let summaryRequest: Program | undefined;
20
+ const executor = createCollapsedReasoner(
21
+ fromExecute(async () => withThinking("answer", "private reasoning")),
22
+ fromExecute(async (request) => {
23
+ summaryRequest = request;
24
+ return appendAssistantMessage(createProgram(), "Working through it.");
25
+ }),
26
+ );
27
+
28
+ const result = await executor.execute(createProgram(), context());
29
+
30
+ expect(extractContentText(result)).toBe("answer");
31
+ expect(extractThinkingText(result)).toBe("Working through it.");
32
+ expect(new ProgramView(summaryRequest!).messages.at(-1)?.text).toBe("private reasoning");
33
+ });
34
+
35
+ it("suppresses raw stream deltas, preserves visible chunks, and flushes before the end", async () => {
36
+ const executor = createCollapsedReasoner(
37
+ streaming(async function* () {
38
+ yield delta.start();
39
+ yield delta.thinking("private reasoning");
40
+ yield delta.text("answer");
41
+ yield delta.end();
42
+ }),
43
+ fromExecute(async () => appendAssistantMessage(createProgram(), "Checking details.")),
44
+ );
45
+
46
+ const chunks: Program[] = [];
47
+ for await (const chunk of executor.stream(createProgram(), context())) chunks.push(chunk);
48
+
49
+ expect(extractStreamThinking(chunks)).toEqual(["Checking details."]);
50
+ expect(chunks.map((chunk) => extractStreamText(chunk)).join("")).toBe("answer");
51
+ expect(chunks.at(-1)?.code.map((item) => item.opcode)).toEqual([
52
+ Opcode.RESP_DONE,
53
+ Opcode.STREAM_END,
54
+ ]);
55
+ });
56
+
57
+ it("uses elapsed time for periodic summaries and gives a forced tick priority", async () => {
58
+ vi.useFakeTimers();
59
+ let release: (() => void) | undefined;
60
+ const executor = createCollapsedReasoner(
61
+ streaming(async function* () {
62
+ yield delta.thinking("first");
63
+ await new Promise<void>((resolve) => {
64
+ release = resolve;
65
+ });
66
+ yield delta.end();
67
+ }),
68
+ fromExecute(async (request) =>
69
+ appendAssistantMessage(createProgram(), `summary:${extractContentText(request)}`),
70
+ ),
71
+ { interval: 10, forcedInterval: 20 },
72
+ );
73
+ const iterator = executor.stream(createProgram(), context())[Symbol.asyncIterator]();
74
+
75
+ const pendingSummary = iterator.next();
76
+ await vi.advanceTimersByTimeAsync(20);
77
+ const summary = await pendingSummary;
78
+ release?.();
79
+ const end = await iterator.next();
80
+ vi.useRealTimers();
81
+
82
+ expect(extractStreamThinking([summary.value!])).toEqual([expect.stringContaining("first")]);
83
+ expect(end.value?.code.some((item: Instruction) => item.opcode === Opcode.STREAM_END)).toBe(true);
84
+ });
85
+
86
+ it("validates its timing options and never emits blank summaries", async () => {
87
+ expect(() => createCollapsedReasoner("reasoner", "summarizer", { interval: 0 })).toThrow(RangeError);
88
+ expect(() =>
89
+ createCollapsedReasoner("reasoner", "summarizer", { interval: 10, forcedInterval: 9 }),
90
+ ).toThrow(RangeError);
91
+
92
+ const executor = createCollapsedReasoner(
93
+ streaming(async function* () {
94
+ yield delta.thinking("private");
95
+ yield delta.end();
96
+ }),
97
+ fromExecute(async () => appendAssistantMessage(createProgram(), " ")),
98
+ );
99
+ const chunks: Program[] = [];
100
+ for await (const chunk of executor.stream(createProgram(), context())) chunks.push(chunk);
101
+ expect(extractStreamThinking(chunks)).toEqual([]);
102
+ });
103
+
104
+ it("propagates source and summarizer failures without exposing reasoning", async () => {
105
+ const sourceFailure = new Error("source failed");
106
+ const brokenSource = createCollapsedReasoner(
107
+ streaming(async function* () {
108
+ throw sourceFailure;
109
+ }),
110
+ "summarizer",
111
+ );
112
+ await expect(collect(brokenSource.stream(createProgram(), context()))).rejects.toBe(sourceFailure);
113
+
114
+ const summaryFailure = new Error("summary failed");
115
+ const brokenSummary = createCollapsedReasoner(
116
+ streaming(async function* () {
117
+ yield delta.thinking("private");
118
+ yield delta.end();
119
+ }),
120
+ fromExecute(async () => {
121
+ throw summaryFailure;
122
+ }),
123
+ );
124
+ await expect(collect(brokenSummary.stream(createProgram(), context()))).rejects.toBe(summaryFailure);
125
+ });
126
+ });
127
+
128
+ function withThinking(text: string, thinking: string): Program {
129
+ return appendInstructions(appendAssistantMessage(createProgram(), text), [
130
+ { opcode: Opcode.MSG_START, value: { kind: "none" } },
131
+ { opcode: Opcode.ROLE_AST, value: { kind: "none" } },
132
+ { opcode: Opcode.THINK_START, value: { kind: "none" } },
133
+ { opcode: Opcode.THINK_CHUNK, value: { kind: "string", value: thinking } },
134
+ { opcode: Opcode.THINK_END, value: { kind: "none" } },
135
+ { opcode: Opcode.MSG_END, value: { kind: "none" } },
136
+ ]);
137
+ }
138
+
139
+ function context(): ExecutorContext {
140
+ const ctx: ExecutorContext = {
141
+ cache: { get: async () => null, put: async () => {}, delete: async () => {} },
142
+ requestId: "request",
143
+ executionId: "execution",
144
+ invoke(executor, request) {
145
+ return typeof executor === "string"
146
+ ? Promise.resolve(request)
147
+ : executor.execute(request, ctx);
148
+ },
149
+ async *invokeStream(executor, request) {
150
+ if (typeof executor !== "string") yield* executor.stream(request, ctx);
151
+ },
152
+ observe() {},
153
+ signal: new AbortController().signal,
154
+ };
155
+ return ctx;
156
+ }
157
+
158
+ function fromExecute(execute: Executor["execute"]): Executor {
159
+ return { execute, async *stream() {} };
160
+ }
161
+
162
+ function streaming(stream: Executor["stream"]): Executor {
163
+ return { async execute() { return createProgram(); }, stream };
164
+ }
165
+
166
+ function extractStreamText(chunk: Program): string {
167
+ let text = "";
168
+ for (const item of chunk.code) {
169
+ if (item.opcode === Opcode.STREAM_DELTA && item.value.kind === "string") text += item.value.value;
170
+ }
171
+ return text;
172
+ }
173
+
174
+ function extractStreamThinking(chunks: Program[]): string[] {
175
+ const thinking: string[] = [];
176
+ for (const chunk of chunks) {
177
+ for (const item of chunk.code) {
178
+ if (item.opcode === Opcode.STREAM_THINK_DELTA && item.value.kind === "string") {
179
+ thinking.push(item.value.value);
180
+ }
181
+ }
182
+ }
183
+ return thinking;
184
+ }
185
+
186
+ async function collect(stream: AsyncIterable<Program>): Promise<Program[]> {
187
+ const chunks: Program[] = [];
188
+ for await (const chunk of stream) chunks.push(chunk);
189
+ return chunks;
190
+ }