@kuralle-syrinx/kuralle 4.4.0 → 4.5.0

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.
@@ -0,0 +1,100 @@
1
+ import type { Reasoner, ReasonerTurn, ReasoningPart } from "@kuralle-syrinx/core";
2
+ export interface KuralleStreamPart {
3
+ readonly [key: string]: unknown;
4
+ readonly type: string;
5
+ readonly delta?: string;
6
+ readonly toolName?: string;
7
+ readonly args?: unknown;
8
+ readonly toolCallId?: string;
9
+ readonly result?: unknown;
10
+ readonly error?: string;
11
+ readonly waitingFor?: string;
12
+ readonly nodeId?: string;
13
+ readonly options?: unknown;
14
+ readonly prompt?: string;
15
+ readonly sessionId?: string;
16
+ readonly userFacingMessage?: string;
17
+ readonly payload?: unknown;
18
+ }
19
+ export interface KuralleMessageLike {
20
+ readonly role: string;
21
+ content: unknown;
22
+ }
23
+ export interface KuralleStoredSession {
24
+ readonly id: string;
25
+ messages: KuralleMessageLike[];
26
+ /**
27
+ * Durable-run bookkeeping the bridge reads for flow-resume. Optional and unknown-typed
28
+ * because kuralle owns its shape.
29
+ *
30
+ * This was a `[key: string]: unknown` catch-all, which made the interface unsatisfiable
31
+ * by any concrete type: a real `Session` has no index signature, so it could never be
32
+ * assigned here. Nothing caught that, because nothing type-checked the bridge against
33
+ * the real Runtime — see `real-runtime.compile-check.ts`. Only this one key is read.
34
+ */
35
+ durableRuns?: unknown;
36
+ /** Working-memory blob the bridge inspects on resume. Kuralle owns its shape. */
37
+ workingMemory?: unknown;
38
+ }
39
+ export interface KuralleSessionStoreLike {
40
+ get(id: string): Promise<KuralleStoredSession | null>;
41
+ save(session: KuralleStoredSession): Promise<void>;
42
+ }
43
+ export interface KuralleTurnHandle {
44
+ readonly events: AsyncIterable<KuralleStreamPart>;
45
+ then?: PromiseLike<unknown>["then"];
46
+ }
47
+ export interface KuralleRunOptions {
48
+ readonly input?: string;
49
+ readonly sessionId?: string;
50
+ readonly userId?: string;
51
+ readonly agentId?: string;
52
+ readonly abortSignal?: AbortSignal;
53
+ /**
54
+ * Prior turns seeded into an empty kuralle session (G4 resume-by-seed).
55
+ *
56
+ * Deliberately narrower than it looks: the real `Runtime.run` takes `ModelMessage[]`,
57
+ * whose `role` is a union, so a `string` role here would be too wide to assign — and a
58
+ * mutable array, so `ReadonlyArray` would not assign either. The producer
59
+ * (`buildHistoryDeltaSeed`) already filters to user/assistant, so this only makes the
60
+ * declaration honest about what the code was doing.
61
+ *
62
+ * `real-runtime.compile-check.ts` pins this against the actual Runtime type.
63
+ */
64
+ historyDelta?: Array<{
65
+ role: "user" | "assistant";
66
+ content: string;
67
+ }>;
68
+ }
69
+ /**
70
+ * The slice of kuralle's `Runtime` this bridge calls, as a loose structural shape.
71
+ *
72
+ * Deliberately hand-written rather than `Pick<Runtime, ...>`. Deriving was tried and is
73
+ * strictly worse here: it drags in `TurnHandle`, `HarnessStreamPart`, `Session` and
74
+ * `RunOptions` wholesale, so every test fake and adapter would have to construct
75
+ * full-fidelity kuralle objects to call a bridge whose whole point is loose coupling.
76
+ *
77
+ * The drift that freedom costs is contained by `real-runtime.compile-check.ts`, which
78
+ * asserts the REAL `Runtime` still satisfies this shape. That check is what was missing —
79
+ * not stricter types. It is the same pattern `cf-agents` uses for the agents SDK.
80
+ */
81
+ export interface KuralleRuntimeLike {
82
+ run(opts: KuralleRunOptions): KuralleTurnHandle;
83
+ getSession?(sessionId: string): Promise<KuralleStoredSession | null>;
84
+ getSessionStore?(): KuralleSessionStoreLike;
85
+ }
86
+ export interface FromKuralleRuntimeOptions {
87
+ readonly sessionId: string;
88
+ readonly userId?: string;
89
+ readonly agentId?: string;
90
+ }
91
+ export declare function fromKuralleRuntime(runtime: KuralleRuntimeLike, opts: FromKuralleRuntimeOptions): Reasoner;
92
+ export declare function buildKuralleTurnRunOptions(runtime: KuralleRuntimeLike, params: FromKuralleRuntimeOptions & {
93
+ readonly userText: string;
94
+ readonly signal?: AbortSignal;
95
+ }): Promise<KuralleRunOptions>;
96
+ export declare function runKuralleTurn(runtime: KuralleRuntimeLike, runOpts: KuralleRunOptions): KuralleTurnHandle;
97
+ export declare function awaitKuralleTurn(handle: KuralleTurnHandle): Promise<void>;
98
+ export declare function streamFromKuralle(runtime: KuralleRuntimeLike, turn: ReasonerTurn, opts: FromKuralleRuntimeOptions): AsyncGenerator<ReasoningPart>;
99
+ export declare function reconcileSpokenPrefix(runtime: KuralleRuntimeLike, sessionId: string, spokenPrefix: string): Promise<void>;
100
+ export declare function rewriteLastAssistant(messages: ReadonlyArray<KuralleMessageLike>, spokenPrefix: string): KuralleMessageLike[];
@@ -0,0 +1,252 @@
1
+ // SPDX-License-Identifier: MIT
2
+ import { categorizeLlmError, isRecoverable } from "@kuralle-syrinx/core";
3
+ const DURABLE_RUNS_KEY = "durableRuns";
4
+ export function fromKuralleRuntime(runtime, opts) {
5
+ return { stream: (turn) => streamFromKuralle(runtime, turn, opts) };
6
+ }
7
+ async function buildKuralleRunOptions(runtime, turn, opts) {
8
+ const base = {
9
+ sessionId: opts.sessionId,
10
+ userId: opts.userId,
11
+ agentId: opts.agentId,
12
+ abortSignal: turn.signal,
13
+ };
14
+ if (!turn.userText)
15
+ return base;
16
+ if (!runtime.getSession) {
17
+ return { ...base, input: turn.userText };
18
+ }
19
+ const session = await runtime.getSession(opts.sessionId);
20
+ const runState = readActiveRunState(session, opts.sessionId);
21
+ if (runState?.activeFlow) {
22
+ const appended = await appendFlowResumeUserMessage(runtime, opts.sessionId, turn.userText);
23
+ if (appended)
24
+ return base;
25
+ }
26
+ // G4 resume-by-seed: the BRIDGE owns history (reasoner seam §4.5). When the kuralle
27
+ // session is empty (fresh isolate / post-eviction) and the turn carries prior context,
28
+ // seed it once via historyDelta — kuralle appends it to the (empty) session, so the
29
+ // runtime resumes with the same context instead of restarting amnesiac. Never seeded
30
+ // into a non-empty session (that would double-apply history).
31
+ return { ...base, input: turn.userText, ...seedHistoryDelta(session, turn.messages) };
32
+ }
33
+ function seedHistoryDelta(session, messages) {
34
+ if (session && session.messages.length > 0)
35
+ return {};
36
+ const delta = messages
37
+ .filter((message) => message.role === "user" || message.role === "assistant")
38
+ .map((message) => ({ role: message.role, content: message.content }));
39
+ return delta.length > 0 ? { historyDelta: delta } : {};
40
+ }
41
+ export async function buildKuralleTurnRunOptions(runtime, params) {
42
+ const turn = {
43
+ userText: params.userText,
44
+ messages: [],
45
+ signal: params.signal ?? new AbortController().signal,
46
+ };
47
+ return buildKuralleRunOptions(runtime, turn, params);
48
+ }
49
+ export function runKuralleTurn(runtime, runOpts) {
50
+ return runtime.run(runOpts);
51
+ }
52
+ export async function awaitKuralleTurn(handle) {
53
+ await new Promise((resolve, reject) => {
54
+ if (typeof handle.then === "function") {
55
+ handle.then(() => resolve(), reject);
56
+ return;
57
+ }
58
+ resolve();
59
+ });
60
+ }
61
+ function readActiveRunState(session, sessionId) {
62
+ if (!session)
63
+ return undefined;
64
+ const runs = session[DURABLE_RUNS_KEY];
65
+ if (!runs || typeof runs !== "object" || Array.isArray(runs))
66
+ return undefined;
67
+ const persisted = runs[sessionId];
68
+ if (!persisted || typeof persisted !== "object")
69
+ return undefined;
70
+ const runState = persisted.runState;
71
+ return runState;
72
+ }
73
+ async function appendFlowResumeUserMessage(runtime, sessionId, userText) {
74
+ const store = runtime.getSessionStore?.();
75
+ if (!store)
76
+ return false;
77
+ const session = await store.get(sessionId);
78
+ if (!session)
79
+ return false;
80
+ const userMessage = { role: "user", content: userText };
81
+ session.messages = [...session.messages, userMessage];
82
+ const wm = session.workingMemory;
83
+ if (wm && typeof wm === "object" && !Array.isArray(wm)) {
84
+ delete wm["__v2_pendingUserInput"];
85
+ }
86
+ const runs = session[DURABLE_RUNS_KEY];
87
+ if (runs && typeof runs === "object" && !Array.isArray(runs)) {
88
+ const persisted = runs[sessionId];
89
+ if (persisted && typeof persisted === "object") {
90
+ const runState = persisted.runState;
91
+ if (runState) {
92
+ runState.messages = [...(runState.messages ?? []), userMessage];
93
+ }
94
+ }
95
+ }
96
+ await store.save(session);
97
+ return true;
98
+ }
99
+ export async function* streamFromKuralle(runtime, turn, opts) {
100
+ const runOpts = await buildKuralleRunOptions(runtime, turn, opts);
101
+ const handle = runtime.run(runOpts);
102
+ let acc = "";
103
+ let aborted = false;
104
+ try {
105
+ for await (const part of handle.events) {
106
+ if (turn.signal.aborted) {
107
+ aborted = true;
108
+ break;
109
+ }
110
+ switch (part.type) {
111
+ case "text-delta": {
112
+ const t = String(part.delta ?? "");
113
+ acc += t;
114
+ yield { type: "text-delta", text: t };
115
+ if (turn.signal.aborted) {
116
+ aborted = true;
117
+ }
118
+ break;
119
+ }
120
+ case "tool-call":
121
+ yield {
122
+ type: "tool-call",
123
+ toolId: String(part.toolCallId ?? ""),
124
+ toolName: String(part.toolName ?? ""),
125
+ args: toRecord(part.args),
126
+ };
127
+ break;
128
+ case "tool-result":
129
+ yield {
130
+ type: "tool-result",
131
+ toolId: String(part.toolCallId ?? ""),
132
+ toolName: String(part.toolName ?? ""),
133
+ result: stringifyResult(part.result),
134
+ };
135
+ break;
136
+ case "error":
137
+ yield toErrorPart(new Error(String(part.error ?? "Kuralle error")));
138
+ return;
139
+ case "paused":
140
+ yield {
141
+ type: "suspended",
142
+ runId: opts.sessionId,
143
+ prompt: typeof part.waitingFor === "string" ? part.waitingFor : undefined,
144
+ payload: part,
145
+ };
146
+ return;
147
+ case "interactive":
148
+ yield {
149
+ type: "suspended",
150
+ runId: opts.sessionId,
151
+ prompt: typeof part.prompt === "string" ? part.prompt : undefined,
152
+ payload: part,
153
+ };
154
+ return;
155
+ case "safety-blocked":
156
+ yield {
157
+ type: "blocked",
158
+ userFacingMessage: String(part.userFacingMessage ?? "This request cannot be completed."),
159
+ payload: part,
160
+ };
161
+ return;
162
+ case "done":
163
+ yield { type: "finish", reason: "stop", text: acc };
164
+ return;
165
+ default:
166
+ yield { type: "control", name: part.type, payload: part };
167
+ break;
168
+ }
169
+ if (aborted)
170
+ break;
171
+ }
172
+ }
173
+ finally {
174
+ await awaitKuralleTurn(handle).catch(() => undefined);
175
+ if (aborted && acc.length > 0) {
176
+ await reconcileSpokenPrefix(runtime, opts.sessionId, acc);
177
+ }
178
+ }
179
+ if (aborted)
180
+ return;
181
+ yield toErrorPart(new Error("Kuralle stream ended without a done part"));
182
+ }
183
+ export async function reconcileSpokenPrefix(runtime, sessionId, spokenPrefix) {
184
+ const store = runtime.getSessionStore?.();
185
+ if (!store)
186
+ return;
187
+ const session = await store.get(sessionId);
188
+ if (!session)
189
+ return;
190
+ let changed = false;
191
+ const topLevel = rewriteLastAssistant(session.messages, spokenPrefix);
192
+ if (topLevel !== session.messages) {
193
+ session.messages = topLevel;
194
+ changed = true;
195
+ }
196
+ const runs = session[DURABLE_RUNS_KEY];
197
+ if (runs && typeof runs === "object" && !Array.isArray(runs)) {
198
+ for (const persisted of Object.values(runs)) {
199
+ if (!persisted || typeof persisted !== "object")
200
+ continue;
201
+ const runState = persisted.runState;
202
+ if (!runState?.messages)
203
+ continue;
204
+ const rewritten = rewriteLastAssistant(runState.messages, spokenPrefix);
205
+ if (rewritten !== runState.messages) {
206
+ runState.messages = rewritten;
207
+ changed = true;
208
+ }
209
+ }
210
+ }
211
+ if (changed)
212
+ await store.save(session);
213
+ }
214
+ export function rewriteLastAssistant(messages, spokenPrefix) {
215
+ if (messages.length === 0)
216
+ return [...messages];
217
+ const last = messages[messages.length - 1];
218
+ if (!last || last.role !== "assistant")
219
+ return [...messages];
220
+ const full = assistantText(last);
221
+ if (spokenPrefix.length >= full.length)
222
+ return [...messages];
223
+ return [...messages.slice(0, -1), { role: "assistant", content: spokenPrefix }];
224
+ }
225
+ function assistantText(message) {
226
+ if (typeof message.content === "string")
227
+ return message.content;
228
+ if (Array.isArray(message.content)) {
229
+ return message.content
230
+ .map((part) => {
231
+ if (typeof part === "string")
232
+ return part;
233
+ if (part && typeof part === "object" && "text" in part)
234
+ return String(part.text);
235
+ return "";
236
+ })
237
+ .join("");
238
+ }
239
+ return String(message.content ?? "");
240
+ }
241
+ function toErrorPart(error) {
242
+ const cause = error instanceof Error ? error : new Error(String(error));
243
+ return { type: "error", cause, recoverable: isRecoverable(categorizeLlmError(cause)) };
244
+ }
245
+ function stringifyResult(r) {
246
+ return typeof r === "string" ? r : JSON.stringify(r);
247
+ }
248
+ function toRecord(value) {
249
+ if (typeof value !== "object" || value === null || Array.isArray(value))
250
+ return {};
251
+ return value;
252
+ }
@@ -0,0 +1 @@
1
+ export { fromKuralleRuntime, runKuralleTurn, awaitKuralleTurn, buildKuralleTurnRunOptions, reconcileSpokenPrefix, rewriteLastAssistant, type KuralleRuntimeLike, type KuralleRunOptions, type KuralleStreamPart, type KuralleSessionStoreLike, type KuralleStoredSession, type KuralleMessageLike, type FromKuralleRuntimeOptions, } from "./from-kuralle.js";
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ // SPDX-License-Identifier: MIT
2
+ export { fromKuralleRuntime, runKuralleTurn, awaitKuralleTurn, buildKuralleTurnRunOptions, reconcileSpokenPrefix, rewriteLastAssistant, } from "./from-kuralle.js";
@@ -0,0 +1 @@
1
+ export declare const reasonerFromRealRuntime: import("@kuralle-syrinx/core").Reasoner;
@@ -0,0 +1,20 @@
1
+ // SPDX-License-Identifier: MIT
2
+ //
3
+ // Compile-only proof that the REAL `@kuralle-agents/core` Runtime satisfies the
4
+ // structural `KuralleRuntimeLike` this bridge consumes. Type-checked by
5
+ // `tsc --noEmit`; never executed.
6
+ //
7
+ // Why this file exists: the bridge deliberately types against a structural interface
8
+ // rather than importing kuralle's types, so a consumer can supply any runtime-shaped
9
+ // object. The cost of that freedom is that nothing was verifying the real Runtime
10
+ // still fits — the package pinned `^0.8.5` in devDeps while npm shipped 0.13.0, five
11
+ // minors and a package-folder rename later, and no check would have failed. If kuralle
12
+ // changes `run` / `getSession` / `getSessionStore`, this file stops compiling.
13
+ //
14
+ // Mirrors the same pattern as `packages/cf-agents/src/real-agent.compile-check.ts`.
15
+ import { fromKuralleRuntime } from "./from-kuralle.js";
16
+ const asBridgeRuntime = realRuntime;
17
+ // And the real thing composes into the seam the voice pipeline consumes.
18
+ export const reasonerFromRealRuntime = fromKuralleRuntime(asBridgeRuntime, {
19
+ sessionId: "compile-check-session",
20
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kuralle-syrinx/kuralle",
3
- "version": "4.4.0",
3
+ "version": "4.5.0",
4
4
  "private": false,
5
5
  "description": "kuralle-agents bridge for Syrinx — run a kuralle runtime as the voice pipeline's reasoner",
6
6
  "keywords": [
@@ -21,21 +21,28 @@
21
21
  "url": "https://github.com/kuralle/syrinx/issues"
22
22
  },
23
23
  "type": "module",
24
- "main": "./src/index.ts",
25
- "types": "./src/index.ts",
24
+ "main": "./dist/index.js",
25
+ "types": "./dist/index.d.ts",
26
26
  "dependencies": {
27
- "@kuralle-syrinx/core": "4.4.0"
27
+ "@kuralle-syrinx/core": "4.5.0"
28
28
  },
29
29
  "peerDependencies": {
30
30
  "@kuralle-agents/core": ">=0.13.0 <1"
31
31
  },
32
32
  "devDependencies": {
33
33
  "@kuralle-agents/core": "^0.13.0",
34
+ "@types/node": "^22.0.0",
34
35
  "typescript": "^5.7.0",
35
36
  "vitest": "^3.2.6"
36
37
  },
38
+ "files": [
39
+ "dist",
40
+ "src",
41
+ "README.md"
42
+ ],
37
43
  "scripts": {
38
44
  "typecheck": "tsc --noEmit",
39
- "test": "vitest run"
45
+ "test": "vitest run",
46
+ "build": "tsc -p tsconfig.build.json"
40
47
  }
41
48
  }
@@ -0,0 +1,421 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ import { describe, expect, it } from "vitest";
4
+ import type { Reasoner, ReasonerTurn, ReasoningPart } from "@kuralle-syrinx/core";
5
+ import {
6
+ buildKuralleTurnRunOptions,
7
+ fromKuralleRuntime,
8
+ reconcileSpokenPrefix,
9
+ rewriteLastAssistant,
10
+ type KuralleMessageLike,
11
+ type KuralleRunOptions,
12
+ type KuralleRuntimeLike,
13
+ type KuralleSessionStoreLike,
14
+ type KuralleStoredSession,
15
+ type KuralleStreamPart,
16
+ } from "./from-kuralle.js";
17
+
18
+ function baseTurn(): ReasonerTurn {
19
+ return {
20
+ userText: "Hi",
21
+ messages: [{ role: "system", content: "test" }],
22
+ signal: new AbortController().signal,
23
+ };
24
+ }
25
+
26
+ function textDelta(delta: string): KuralleStreamPart {
27
+ return { type: "text-delta", delta };
28
+ }
29
+
30
+ function toolCall(toolCallId: string, toolName: string, args: Record<string, unknown>): KuralleStreamPart {
31
+ return { type: "tool-call", toolCallId, toolName, args };
32
+ }
33
+
34
+ function toolResult(toolCallId: string, toolName: string, result: unknown): KuralleStreamPart {
35
+ return { type: "tool-result", toolCallId, toolName, result };
36
+ }
37
+
38
+ function errorPart(error: string): KuralleStreamPart {
39
+ return { type: "error", error };
40
+ }
41
+
42
+ function done(sessionId?: string): KuralleStreamPart {
43
+ return { type: "done", sessionId };
44
+ }
45
+
46
+ function safetyBlocked(userFacingMessage: string): KuralleStreamPart {
47
+ return { type: "safety-blocked", userFacingMessage };
48
+ }
49
+
50
+ async function* partsToEvents(parts: KuralleStreamPart[]): AsyncIterable<KuralleStreamPart> {
51
+ for (const p of parts) yield p;
52
+ }
53
+
54
+ function fakeRuntime(
55
+ parts: KuralleStreamPart[],
56
+ spy?: {
57
+ runOpts?: {
58
+ input?: string;
59
+ sessionId?: string;
60
+ userId?: string;
61
+ agentId?: string;
62
+ abortSignal?: AbortSignal;
63
+ };
64
+ },
65
+ ): KuralleRuntimeLike {
66
+ return {
67
+ run(opts) {
68
+ if (spy) spy.runOpts = opts;
69
+ return { events: partsToEvents(parts) };
70
+ },
71
+ };
72
+ }
73
+
74
+ async function collectParts(reasoner: Reasoner, turn: ReasonerTurn): Promise<ReasoningPart[]> {
75
+ const collected: ReasoningPart[] = [];
76
+ for await (const part of reasoner.stream(turn)) {
77
+ collected.push(part);
78
+ }
79
+ return collected;
80
+ }
81
+
82
+ describe("fromKuralleRuntime G4 resume-by-seed (historyDelta)", () => {
83
+ const turnWithContext = (): ReasonerTurn => ({
84
+ userText: "Second question",
85
+ messages: [
86
+ { role: "system", content: "sys" },
87
+ { role: "user", content: "First question" },
88
+ { role: "assistant", content: "First answer" },
89
+ ],
90
+ signal: new AbortController().signal,
91
+ });
92
+
93
+ it("seeds prior turn.messages into an EMPTY kuralle session (fresh isolate resume)", async () => {
94
+ const spy: { runOpts?: KuralleRunOptions } = {};
95
+ const runtime: KuralleRuntimeLike = {
96
+ run(opts) {
97
+ spy.runOpts = opts;
98
+ return { events: partsToEvents([textDelta("ok"), done()]) };
99
+ },
100
+ getSession: async () => null,
101
+ };
102
+ const reasoner = fromKuralleRuntime(runtime, { sessionId: "sess-1" });
103
+
104
+ await collectParts(reasoner, turnWithContext());
105
+
106
+ expect(spy.runOpts?.input).toBe("Second question");
107
+ expect(spy.runOpts?.historyDelta).toEqual([
108
+ { role: "user", content: "First question" },
109
+ { role: "assistant", content: "First answer" },
110
+ ]);
111
+ });
112
+
113
+ it("does NOT seed into a non-empty session (no double-applied history, R6)", async () => {
114
+ const spy: { runOpts?: KuralleRunOptions } = {};
115
+ const runtime: KuralleRuntimeLike = {
116
+ run(opts) {
117
+ spy.runOpts = opts;
118
+ return { events: partsToEvents([textDelta("ok"), done()]) };
119
+ },
120
+ getSession: async () => ({
121
+ id: "sess-1",
122
+ messages: [{ role: "user", content: "already there" }],
123
+ }),
124
+ };
125
+ const reasoner = fromKuralleRuntime(runtime, { sessionId: "sess-1" });
126
+
127
+ await collectParts(reasoner, turnWithContext());
128
+
129
+ expect(spy.runOpts?.input).toBe("Second question");
130
+ expect(spy.runOpts?.historyDelta).toBeUndefined();
131
+ });
132
+ });
133
+
134
+ describe("fromKuralleRuntime", () => {
135
+ it("maps happy path: text deltas and done to finish:stop", async () => {
136
+ const reasoner = fromKuralleRuntime(
137
+ fakeRuntime([textDelta("Hi"), textDelta(" there"), done("sess-1")]),
138
+ { sessionId: "sess-1" },
139
+ );
140
+
141
+ const parts = await collectParts(reasoner, baseTurn());
142
+
143
+ expect(parts).toEqual([
144
+ { type: "text-delta", text: "Hi" },
145
+ { type: "text-delta", text: " there" },
146
+ { type: "finish", reason: "stop", text: "Hi there" },
147
+ ]);
148
+ });
149
+
150
+ it("speaks a kuralle per-tool interim: it arrives as text-delta before the tool result", async () => {
151
+ // Kuralle's `Tool.interim` / `interimAfterMs` is the tool-level equivalent of a spoken
152
+ // preamble — "Let me pull up your record" while a slow tool runs. It is NOT a distinct
153
+ // stream part: `ToolExecutor` fires an `onInterim` callback, and kuralle's `Runtime`
154
+ // converts it to `text-start` / `text-delta` / `text-end` on the run stream
155
+ // (Runtime.js — `onInterim: (message) => { emit({type:'text-delta', delta: message}) }`).
156
+ //
157
+ // So it reaches Syrinx as an ordinary text-delta and flows llm.delta -> bufferTtsText ->
158
+ // tts.text -> spoken audio, with no bridge change needed. This test pins that path so a
159
+ // future refactor of the text-delta case cannot silently mute every tool interim.
160
+ const reasoner = fromKuralleRuntime(
161
+ fakeRuntime([
162
+ toolCall("call-1", "studentRelationsLookup", { requestType: "late_add" }),
163
+ // The interim, as the runtime actually emits it — bare text-delta, mid-tool.
164
+ textDelta("Let me pull up your registration record."),
165
+ toolResult("call-1", "studentRelationsLookup", { addDeadline: "2027-02-05" }),
166
+ textDelta("Your late add deadline was February 5th."),
167
+ done("sess-interim"),
168
+ ]),
169
+ { sessionId: "sess-interim" },
170
+ );
171
+
172
+ const parts = await collectParts(reasoner, baseTurn());
173
+
174
+ const firstSpoken = parts.find((p) => p.type === "text-delta");
175
+ expect(firstSpoken).toEqual({
176
+ type: "text-delta",
177
+ text: "Let me pull up your registration record.",
178
+ });
179
+ // And it precedes the tool result, so the user hears it while the tool is still running.
180
+ const interimIdx = parts.findIndex((p) => p.type === "text-delta");
181
+ const resultIdx = parts.findIndex((p) => p.type === "tool-result");
182
+ expect(interimIdx).toBeLessThan(resultIdx);
183
+ });
184
+
185
+ it("maps tool-call and tool-result with toolId from toolCallId", async () => {
186
+ const reasoner = fromKuralleRuntime(
187
+ fakeRuntime([
188
+ toolCall("tc-1", "lookup", { id: "123" }),
189
+ toolResult("tc-1", "lookup", { found: true }),
190
+ done(),
191
+ ]),
192
+ { sessionId: "sess-1" },
193
+ );
194
+
195
+ const parts = await collectParts(reasoner, baseTurn());
196
+
197
+ expect(parts).toEqual([
198
+ {
199
+ type: "tool-call",
200
+ toolId: "tc-1",
201
+ toolName: "lookup",
202
+ args: { id: "123" },
203
+ },
204
+ {
205
+ type: "tool-result",
206
+ toolId: "tc-1",
207
+ toolName: "lookup",
208
+ result: JSON.stringify({ found: true }),
209
+ },
210
+ { type: "finish", reason: "stop", text: "" },
211
+ ]);
212
+ });
213
+
214
+ it("maps error part to terminal error", async () => {
215
+ const reasoner = fromKuralleRuntime(fakeRuntime([errorPart("boom")]), { sessionId: "sess-1" });
216
+
217
+ const parts = await collectParts(reasoner, baseTurn());
218
+
219
+ expect(parts).toHaveLength(1);
220
+ expect(parts[0]?.type).toBe("error");
221
+ if (parts[0]?.type === "error") {
222
+ expect(parts[0].cause.message).toBe("boom");
223
+ expect(parts[0].recoverable).toBe(false);
224
+ }
225
+ });
226
+
227
+ it("maps safety-blocked to a terminal blocked part without the missing-done error", async () => {
228
+ const reasoner = fromKuralleRuntime(
229
+ fakeRuntime([safetyBlocked("I cannot help with that request.")]),
230
+ { sessionId: "sess-safety" },
231
+ );
232
+
233
+ const parts = await collectParts(reasoner, baseTurn());
234
+
235
+ expect(parts).toEqual([
236
+ {
237
+ type: "blocked",
238
+ userFacingMessage: "I cannot help with that request.",
239
+ payload: { type: "safety-blocked", userFacingMessage: "I cannot help with that request." },
240
+ },
241
+ ]);
242
+ expect(parts.some((part) => part.type === "error")).toBe(false);
243
+ });
244
+
245
+ it("preserves orchestration parts as opaque control parts", async () => {
246
+ const handoff = { type: "handoff", targetAgent: "billing", reason: "account question" } satisfies KuralleStreamPart;
247
+ const outcome = { type: "conversation-outcome", outcome: { taskSuccess: true, satisfaction: false } } satisfies KuralleStreamPart;
248
+ const reasoner = fromKuralleRuntime(fakeRuntime([handoff, outcome, done()]), { sessionId: "sess-control" });
249
+
250
+ const parts = await collectParts(reasoner, baseTurn());
251
+
252
+ expect(parts).toEqual([
253
+ { type: "control", name: "handoff", payload: handoff },
254
+ { type: "control", name: "conversation-outcome", payload: outcome },
255
+ { type: "finish", reason: "stop", text: "" },
256
+ ]);
257
+ });
258
+
259
+ it("yields terminal error when stream ends without done", async () => {
260
+ const reasoner = fromKuralleRuntime(fakeRuntime([textDelta("partial")]), { sessionId: "sess-1" });
261
+
262
+ const parts = await collectParts(reasoner, baseTurn());
263
+
264
+ expect(parts).toHaveLength(2);
265
+ expect(parts[0]).toEqual({ type: "text-delta", text: "partial" });
266
+ expect(parts[1]?.type).toBe("error");
267
+ if (parts[1]?.type === "error") {
268
+ expect(parts[1].cause.message).toBe("Kuralle stream ended without a done part");
269
+ expect(parts[1].recoverable).toBe(false);
270
+ }
271
+ });
272
+
273
+ it("returns without yielding when turn.signal is already aborted", async () => {
274
+ const controller = new AbortController();
275
+ controller.abort();
276
+ const turn: ReasonerTurn = { ...baseTurn(), signal: controller.signal };
277
+
278
+ const reasoner = fromKuralleRuntime(
279
+ fakeRuntime([textDelta("should not appear"), done()]),
280
+ { sessionId: "sess-1" },
281
+ );
282
+
283
+ const parts = await collectParts(reasoner, turn);
284
+
285
+ expect(parts).toEqual([]);
286
+ });
287
+
288
+ it("passes only userText to run, not turn.messages", async () => {
289
+ const spy: { runOpts?: { input?: string; sessionId?: string } } = {};
290
+ const reasoner = fromKuralleRuntime(fakeRuntime([done()], spy), { sessionId: "sess-42" });
291
+
292
+ const turn: ReasonerTurn = {
293
+ userText: "What is my name?",
294
+ messages: [
295
+ { role: "system", content: "ignored" },
296
+ { role: "user", content: "also ignored" },
297
+ ],
298
+ signal: new AbortController().signal,
299
+ };
300
+ await collectParts(reasoner, turn);
301
+
302
+ expect(spy.runOpts?.input).toBe("What is my name?");
303
+ expect(spy.runOpts?.sessionId).toBe("sess-42");
304
+ });
305
+
306
+ it("flow resume pre-appends user message and omits input", async () => {
307
+ const sessionId = "sess-flow";
308
+ const store = createMockSessionStore(sessionId, [{ role: "assistant", content: "What is your name?" }]);
309
+ const session = (await store.get(sessionId))!;
310
+ session.durableRuns = {
311
+ [sessionId]: {
312
+ runState: {
313
+ activeFlow: "book-advisor-appointment",
314
+ messages: [{ role: "assistant", content: "What is your name?" }],
315
+ },
316
+ steps: [],
317
+ },
318
+ };
319
+ await store.save(session);
320
+
321
+ const runtime: KuralleRuntimeLike = {
322
+ run: () => ({ events: partsToEvents([done()]) }),
323
+ getSession: (id) => store.get(id),
324
+ getSessionStore: () => store,
325
+ };
326
+
327
+ const opts = await buildKuralleTurnRunOptions(runtime, {
328
+ sessionId,
329
+ userText: "Priya, CS masters, Friday",
330
+ });
331
+
332
+ expect(opts.input).toBeUndefined();
333
+ expect(opts.historyDelta).toBeUndefined();
334
+ const saved = await store.get(sessionId);
335
+ expect(saved?.messages.at(-1)).toEqual({ role: "user", content: "Priya, CS masters, Friday" });
336
+ const runMessages = (
337
+ saved?.durableRuns as Record<string, { runState: { messages: KuralleMessageLike[] } }>
338
+ )[sessionId]?.runState.messages;
339
+ expect(runMessages?.at(-1)).toEqual({ role: "user", content: "Priya, CS masters, Friday" });
340
+ });
341
+
342
+ it("rewriteLastAssistant truncates only when spoken prefix is shorter", () => {
343
+ const messages: KuralleMessageLike[] = [
344
+ { role: "user", content: "Hi" },
345
+ { role: "assistant", content: "Hello there friend" },
346
+ ];
347
+ expect(rewriteLastAssistant(messages, "Hello")).toEqual([
348
+ { role: "user", content: "Hi" },
349
+ { role: "assistant", content: "Hello" },
350
+ ]);
351
+ expect(rewriteLastAssistant(messages, "Hello there friend")).toStrictEqual(messages);
352
+ });
353
+
354
+ it("after barge-in abort, reconciles persisted assistant message to spoken prefix", async () => {
355
+ const fullText = "Hello there friend";
356
+ const spokenPrefix = "Hello";
357
+ const sessionId = "sess-barge";
358
+ const store = createMockSessionStore(sessionId, []);
359
+
360
+ await reconcileSpokenPrefix(
361
+ { run: () => ({ events: partsToEvents([]) }), getSessionStore: () => store },
362
+ sessionId,
363
+ spokenPrefix,
364
+ );
365
+ let session = await store.get(sessionId);
366
+ expect(session?.messages).toEqual([]);
367
+
368
+ session = (await store.get(sessionId)) ?? { id: sessionId, messages: [] };
369
+ session.messages = [{ role: "assistant", content: fullText }];
370
+ await store.save(session);
371
+
372
+ await reconcileSpokenPrefix(
373
+ { run: () => ({ events: partsToEvents([]) }), getSessionStore: () => store },
374
+ sessionId,
375
+ spokenPrefix,
376
+ );
377
+ session = await store.get(sessionId);
378
+ expect(session?.messages.at(-1)).toEqual({ role: "assistant", content: spokenPrefix });
379
+ });
380
+
381
+ it("reconcileSpokenPrefix rewrites durable run messages", async () => {
382
+ const sessionId = "sess-durable";
383
+ const store = createMockSessionStore(sessionId, []);
384
+ const session = (await store.get(sessionId))!;
385
+ session.messages = [{ role: "assistant", content: "full reply text" }];
386
+ session.durableRuns = {
387
+ [sessionId]: {
388
+ runState: { messages: [{ role: "assistant", content: "full reply text" }] },
389
+ steps: [],
390
+ },
391
+ };
392
+ await store.save(session);
393
+
394
+ await reconcileSpokenPrefix(
395
+ { run: () => ({ events: partsToEvents([]) }), getSessionStore: () => store },
396
+ sessionId,
397
+ "full",
398
+ );
399
+
400
+ const saved = await store.get(sessionId);
401
+ expect(saved?.messages.at(-1)).toEqual({ role: "assistant", content: "full" });
402
+ const runState = (saved?.durableRuns as Record<string, { runState: { messages: KuralleMessageLike[] } }>)[sessionId]
403
+ ?.runState;
404
+ expect(runState?.messages.at(-1)).toEqual({ role: "assistant", content: "full" });
405
+ });
406
+ });
407
+
408
+ function createMockSessionStore(sessionId: string, messages: KuralleMessageLike[]): KuralleSessionStoreLike {
409
+ const sessions = new Map<string, KuralleStoredSession>([
410
+ [sessionId, { id: sessionId, messages: [...messages] }],
411
+ ]);
412
+ return {
413
+ async get(id) {
414
+ const session = sessions.get(id);
415
+ return session ? structuredClone(session) : null;
416
+ },
417
+ async save(session) {
418
+ sessions.set(session.id, structuredClone(session));
419
+ },
420
+ };
421
+ }