@kuralle-syrinx/kuralle 4.2.0 → 4.3.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.
- package/package.json +5 -5
- package/src/from-kuralle.ts +51 -3
- package/src/real-runtime.compile-check.ts +57 -0
- package/src/from-kuralle.test.ts +0 -350
- package/tsconfig.json +0 -13
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kuralle-syrinx/kuralle",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.3.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": [
|
|
@@ -24,15 +24,15 @@
|
|
|
24
24
|
"main": "./src/index.ts",
|
|
25
25
|
"types": "./src/index.ts",
|
|
26
26
|
"dependencies": {
|
|
27
|
-
"@kuralle-syrinx/core": "4.
|
|
27
|
+
"@kuralle-syrinx/core": "4.3.0"
|
|
28
28
|
},
|
|
29
29
|
"peerDependencies": {
|
|
30
|
-
"@kuralle-agents/core": ">=0.
|
|
30
|
+
"@kuralle-agents/core": ">=0.13.0 <1"
|
|
31
31
|
},
|
|
32
32
|
"devDependencies": {
|
|
33
|
-
"@kuralle-agents/core": "^0.
|
|
33
|
+
"@kuralle-agents/core": "^0.13.0",
|
|
34
34
|
"typescript": "^5.7.0",
|
|
35
|
-
"vitest": "^2.
|
|
35
|
+
"vitest": "^3.2.6"
|
|
36
36
|
},
|
|
37
37
|
"scripts": {
|
|
38
38
|
"typecheck": "tsc --noEmit",
|
package/src/from-kuralle.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type { Reasoner, ReasonerTurn, ReasoningPart } from "@kuralle-syrinx/core
|
|
|
4
4
|
import { categorizeLlmError, isRecoverable } from "@kuralle-syrinx/core";
|
|
5
5
|
|
|
6
6
|
export interface KuralleStreamPart {
|
|
7
|
+
readonly [key: string]: unknown;
|
|
7
8
|
readonly type: string;
|
|
8
9
|
readonly delta?: string;
|
|
9
10
|
readonly toolName?: string;
|
|
@@ -16,6 +17,8 @@ export interface KuralleStreamPart {
|
|
|
16
17
|
readonly options?: unknown;
|
|
17
18
|
readonly prompt?: string;
|
|
18
19
|
readonly sessionId?: string;
|
|
20
|
+
readonly userFacingMessage?: string;
|
|
21
|
+
readonly payload?: unknown;
|
|
19
22
|
}
|
|
20
23
|
|
|
21
24
|
export interface KuralleMessageLike {
|
|
@@ -26,7 +29,18 @@ export interface KuralleMessageLike {
|
|
|
26
29
|
export interface KuralleStoredSession {
|
|
27
30
|
readonly id: string;
|
|
28
31
|
messages: KuralleMessageLike[];
|
|
29
|
-
|
|
32
|
+
/**
|
|
33
|
+
* Durable-run bookkeeping the bridge reads for flow-resume. Optional and unknown-typed
|
|
34
|
+
* because kuralle owns its shape.
|
|
35
|
+
*
|
|
36
|
+
* This was a `[key: string]: unknown` catch-all, which made the interface unsatisfiable
|
|
37
|
+
* by any concrete type: a real `Session` has no index signature, so it could never be
|
|
38
|
+
* assigned here. Nothing caught that, because nothing type-checked the bridge against
|
|
39
|
+
* the real Runtime — see `real-runtime.compile-check.ts`. Only this one key is read.
|
|
40
|
+
*/
|
|
41
|
+
durableRuns?: unknown;
|
|
42
|
+
/** Working-memory blob the bridge inspects on resume. Kuralle owns its shape. */
|
|
43
|
+
workingMemory?: unknown;
|
|
30
44
|
}
|
|
31
45
|
|
|
32
46
|
export interface KuralleSessionStoreLike {
|
|
@@ -45,9 +59,32 @@ export interface KuralleRunOptions {
|
|
|
45
59
|
readonly userId?: string;
|
|
46
60
|
readonly agentId?: string;
|
|
47
61
|
readonly abortSignal?: AbortSignal;
|
|
48
|
-
|
|
62
|
+
/**
|
|
63
|
+
* Prior turns seeded into an empty kuralle session (G4 resume-by-seed).
|
|
64
|
+
*
|
|
65
|
+
* Deliberately narrower than it looks: the real `Runtime.run` takes `ModelMessage[]`,
|
|
66
|
+
* whose `role` is a union, so a `string` role here would be too wide to assign — and a
|
|
67
|
+
* mutable array, so `ReadonlyArray` would not assign either. The producer
|
|
68
|
+
* (`buildHistoryDeltaSeed`) already filters to user/assistant, so this only makes the
|
|
69
|
+
* declaration honest about what the code was doing.
|
|
70
|
+
*
|
|
71
|
+
* `real-runtime.compile-check.ts` pins this against the actual Runtime type.
|
|
72
|
+
*/
|
|
73
|
+
historyDelta?: Array<{ role: "user" | "assistant"; content: string }>;
|
|
49
74
|
}
|
|
50
75
|
|
|
76
|
+
/**
|
|
77
|
+
* The slice of kuralle's `Runtime` this bridge calls, as a loose structural shape.
|
|
78
|
+
*
|
|
79
|
+
* Deliberately hand-written rather than `Pick<Runtime, ...>`. Deriving was tried and is
|
|
80
|
+
* strictly worse here: it drags in `TurnHandle`, `HarnessStreamPart`, `Session` and
|
|
81
|
+
* `RunOptions` wholesale, so every test fake and adapter would have to construct
|
|
82
|
+
* full-fidelity kuralle objects to call a bridge whose whole point is loose coupling.
|
|
83
|
+
*
|
|
84
|
+
* The drift that freedom costs is contained by `real-runtime.compile-check.ts`, which
|
|
85
|
+
* asserts the REAL `Runtime` still satisfies this shape. That check is what was missing —
|
|
86
|
+
* not stricter types. It is the same pattern `cf-agents` uses for the agents SDK.
|
|
87
|
+
*/
|
|
51
88
|
export interface KuralleRuntimeLike {
|
|
52
89
|
run(opts: KuralleRunOptions): KuralleTurnHandle;
|
|
53
90
|
getSession?(sessionId: string): Promise<KuralleStoredSession | null>;
|
|
@@ -101,7 +138,10 @@ function seedHistoryDelta(
|
|
|
101
138
|
): Pick<KuralleRunOptions, "historyDelta"> | Record<string, never> {
|
|
102
139
|
if (session && session.messages.length > 0) return {};
|
|
103
140
|
const delta = messages
|
|
104
|
-
.filter(
|
|
141
|
+
.filter(
|
|
142
|
+
(message): message is typeof message & { role: "user" | "assistant" } =>
|
|
143
|
+
message.role === "user" || message.role === "assistant",
|
|
144
|
+
)
|
|
105
145
|
.map((message) => ({ role: message.role, content: message.content }));
|
|
106
146
|
return delta.length > 0 ? { historyDelta: delta } : {};
|
|
107
147
|
}
|
|
@@ -243,10 +283,18 @@ export async function* streamFromKuralle(
|
|
|
243
283
|
payload: part,
|
|
244
284
|
};
|
|
245
285
|
return;
|
|
286
|
+
case "safety-blocked":
|
|
287
|
+
yield {
|
|
288
|
+
type: "blocked",
|
|
289
|
+
userFacingMessage: String(part.userFacingMessage ?? "This request cannot be completed."),
|
|
290
|
+
payload: part,
|
|
291
|
+
};
|
|
292
|
+
return;
|
|
246
293
|
case "done":
|
|
247
294
|
yield { type: "finish", reason: "stop", text: acc };
|
|
248
295
|
return;
|
|
249
296
|
default:
|
|
297
|
+
yield { type: "control", name: part.type, payload: part };
|
|
250
298
|
break;
|
|
251
299
|
}
|
|
252
300
|
if (aborted) break;
|
|
@@ -0,0 +1,57 @@
|
|
|
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
|
+
|
|
16
|
+
import type { HarnessStreamPart, Runtime } from "@kuralle-agents/core";
|
|
17
|
+
import { fromKuralleRuntime, type KuralleRuntimeLike } from "./from-kuralle.js";
|
|
18
|
+
|
|
19
|
+
// The three surfaces the bridge actually calls. If the real Runtime drifts from any
|
|
20
|
+
// of them, this assignment fails to type-check.
|
|
21
|
+
declare const realRuntime: Runtime;
|
|
22
|
+
const asBridgeRuntime: KuralleRuntimeLike = realRuntime;
|
|
23
|
+
|
|
24
|
+
// And the real thing composes into the seam the voice pipeline consumes.
|
|
25
|
+
export const reasonerFromRealRuntime = fromKuralleRuntime(asBridgeRuntime, {
|
|
26
|
+
sessionId: "compile-check-session",
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// Part-type literals
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
//
|
|
33
|
+
// `KuralleStreamPart.type` is `string`, so `streamFromKuralle`'s switch is matching
|
|
34
|
+
// bare string literals with nothing pinning them to reality. The shape check above
|
|
35
|
+
// would stay green if kuralle renamed `"done"` to `"finish"` — and the bridge treats a
|
|
36
|
+
// missing `done` as "stream ended without a done part", i.e. EVERY turn would fail with
|
|
37
|
+
// a terminal error while the type system reported no problem.
|
|
38
|
+
//
|
|
39
|
+
// kuralle exports `HarnessStreamPart` as a discriminated union of literal types, so the
|
|
40
|
+
// switch cases can be pinned at compile time. Keep this list in sync with the `case`
|
|
41
|
+
// arms in `streamFromKuralle`; if upstream renames or drops one, this stops compiling.
|
|
42
|
+
|
|
43
|
+
/** Exactly the part types handled by explicit `streamFromKuralle` case arms. */
|
|
44
|
+
type BridgedPartType =
|
|
45
|
+
| "text-delta"
|
|
46
|
+
| "tool-call"
|
|
47
|
+
| "tool-result"
|
|
48
|
+
| "error"
|
|
49
|
+
| "paused"
|
|
50
|
+
| "interactive"
|
|
51
|
+
| "safety-blocked"
|
|
52
|
+
| "done";
|
|
53
|
+
|
|
54
|
+
type _AssertTrue<T extends true> = T;
|
|
55
|
+
type _BridgedPartTypesStillExistUpstream = _AssertTrue<
|
|
56
|
+
BridgedPartType extends HarnessStreamPart["type"] ? true : false
|
|
57
|
+
>;
|
package/src/from-kuralle.test.ts
DELETED
|
@@ -1,350 +0,0 @@
|
|
|
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
|
-
async function* partsToEvents(parts: KuralleStreamPart[]): AsyncIterable<KuralleStreamPart> {
|
|
47
|
-
for (const p of parts) yield p;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
function fakeRuntime(
|
|
51
|
-
parts: KuralleStreamPart[],
|
|
52
|
-
spy?: {
|
|
53
|
-
runOpts?: {
|
|
54
|
-
input?: string;
|
|
55
|
-
sessionId?: string;
|
|
56
|
-
userId?: string;
|
|
57
|
-
agentId?: string;
|
|
58
|
-
abortSignal?: AbortSignal;
|
|
59
|
-
};
|
|
60
|
-
},
|
|
61
|
-
): KuralleRuntimeLike {
|
|
62
|
-
return {
|
|
63
|
-
run(opts) {
|
|
64
|
-
if (spy) spy.runOpts = opts;
|
|
65
|
-
return { events: partsToEvents(parts) };
|
|
66
|
-
},
|
|
67
|
-
};
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
async function collectParts(reasoner: Reasoner, turn: ReasonerTurn): Promise<ReasoningPart[]> {
|
|
71
|
-
const collected: ReasoningPart[] = [];
|
|
72
|
-
for await (const part of reasoner.stream(turn)) {
|
|
73
|
-
collected.push(part);
|
|
74
|
-
}
|
|
75
|
-
return collected;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
describe("fromKuralleRuntime G4 resume-by-seed (historyDelta)", () => {
|
|
79
|
-
const turnWithContext = (): ReasonerTurn => ({
|
|
80
|
-
userText: "Second question",
|
|
81
|
-
messages: [
|
|
82
|
-
{ role: "system", content: "sys" },
|
|
83
|
-
{ role: "user", content: "First question" },
|
|
84
|
-
{ role: "assistant", content: "First answer" },
|
|
85
|
-
],
|
|
86
|
-
signal: new AbortController().signal,
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
it("seeds prior turn.messages into an EMPTY kuralle session (fresh isolate resume)", async () => {
|
|
90
|
-
const spy: { runOpts?: KuralleRunOptions } = {};
|
|
91
|
-
const runtime: KuralleRuntimeLike = {
|
|
92
|
-
run(opts) {
|
|
93
|
-
spy.runOpts = opts;
|
|
94
|
-
return { events: partsToEvents([textDelta("ok"), done()]) };
|
|
95
|
-
},
|
|
96
|
-
getSession: async () => null,
|
|
97
|
-
};
|
|
98
|
-
const reasoner = fromKuralleRuntime(runtime, { sessionId: "sess-1" });
|
|
99
|
-
|
|
100
|
-
await collectParts(reasoner, turnWithContext());
|
|
101
|
-
|
|
102
|
-
expect(spy.runOpts?.input).toBe("Second question");
|
|
103
|
-
expect(spy.runOpts?.historyDelta).toEqual([
|
|
104
|
-
{ role: "user", content: "First question" },
|
|
105
|
-
{ role: "assistant", content: "First answer" },
|
|
106
|
-
]);
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
it("does NOT seed into a non-empty session (no double-applied history, R6)", async () => {
|
|
110
|
-
const spy: { runOpts?: KuralleRunOptions } = {};
|
|
111
|
-
const runtime: KuralleRuntimeLike = {
|
|
112
|
-
run(opts) {
|
|
113
|
-
spy.runOpts = opts;
|
|
114
|
-
return { events: partsToEvents([textDelta("ok"), done()]) };
|
|
115
|
-
},
|
|
116
|
-
getSession: async () => ({
|
|
117
|
-
id: "sess-1",
|
|
118
|
-
messages: [{ role: "user", content: "already there" }],
|
|
119
|
-
}),
|
|
120
|
-
};
|
|
121
|
-
const reasoner = fromKuralleRuntime(runtime, { sessionId: "sess-1" });
|
|
122
|
-
|
|
123
|
-
await collectParts(reasoner, turnWithContext());
|
|
124
|
-
|
|
125
|
-
expect(spy.runOpts?.input).toBe("Second question");
|
|
126
|
-
expect(spy.runOpts?.historyDelta).toBeUndefined();
|
|
127
|
-
});
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
describe("fromKuralleRuntime", () => {
|
|
131
|
-
it("maps happy path: text deltas and done to finish:stop", async () => {
|
|
132
|
-
const reasoner = fromKuralleRuntime(
|
|
133
|
-
fakeRuntime([textDelta("Hi"), textDelta(" there"), done("sess-1")]),
|
|
134
|
-
{ sessionId: "sess-1" },
|
|
135
|
-
);
|
|
136
|
-
|
|
137
|
-
const parts = await collectParts(reasoner, baseTurn());
|
|
138
|
-
|
|
139
|
-
expect(parts).toEqual([
|
|
140
|
-
{ type: "text-delta", text: "Hi" },
|
|
141
|
-
{ type: "text-delta", text: " there" },
|
|
142
|
-
{ type: "finish", reason: "stop", text: "Hi there" },
|
|
143
|
-
]);
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
it("maps tool-call and tool-result with toolId from toolCallId", async () => {
|
|
147
|
-
const reasoner = fromKuralleRuntime(
|
|
148
|
-
fakeRuntime([
|
|
149
|
-
toolCall("tc-1", "lookup", { id: "123" }),
|
|
150
|
-
toolResult("tc-1", "lookup", { found: true }),
|
|
151
|
-
done(),
|
|
152
|
-
]),
|
|
153
|
-
{ sessionId: "sess-1" },
|
|
154
|
-
);
|
|
155
|
-
|
|
156
|
-
const parts = await collectParts(reasoner, baseTurn());
|
|
157
|
-
|
|
158
|
-
expect(parts).toEqual([
|
|
159
|
-
{
|
|
160
|
-
type: "tool-call",
|
|
161
|
-
toolId: "tc-1",
|
|
162
|
-
toolName: "lookup",
|
|
163
|
-
args: { id: "123" },
|
|
164
|
-
},
|
|
165
|
-
{
|
|
166
|
-
type: "tool-result",
|
|
167
|
-
toolId: "tc-1",
|
|
168
|
-
toolName: "lookup",
|
|
169
|
-
result: JSON.stringify({ found: true }),
|
|
170
|
-
},
|
|
171
|
-
{ type: "finish", reason: "stop", text: "" },
|
|
172
|
-
]);
|
|
173
|
-
});
|
|
174
|
-
|
|
175
|
-
it("maps error part to terminal error", async () => {
|
|
176
|
-
const reasoner = fromKuralleRuntime(fakeRuntime([errorPart("boom")]), { sessionId: "sess-1" });
|
|
177
|
-
|
|
178
|
-
const parts = await collectParts(reasoner, baseTurn());
|
|
179
|
-
|
|
180
|
-
expect(parts).toHaveLength(1);
|
|
181
|
-
expect(parts[0]?.type).toBe("error");
|
|
182
|
-
if (parts[0]?.type === "error") {
|
|
183
|
-
expect(parts[0].cause.message).toBe("boom");
|
|
184
|
-
expect(parts[0].recoverable).toBe(false);
|
|
185
|
-
}
|
|
186
|
-
});
|
|
187
|
-
|
|
188
|
-
it("yields terminal error when stream ends without done", async () => {
|
|
189
|
-
const reasoner = fromKuralleRuntime(fakeRuntime([textDelta("partial")]), { sessionId: "sess-1" });
|
|
190
|
-
|
|
191
|
-
const parts = await collectParts(reasoner, baseTurn());
|
|
192
|
-
|
|
193
|
-
expect(parts).toHaveLength(2);
|
|
194
|
-
expect(parts[0]).toEqual({ type: "text-delta", text: "partial" });
|
|
195
|
-
expect(parts[1]?.type).toBe("error");
|
|
196
|
-
if (parts[1]?.type === "error") {
|
|
197
|
-
expect(parts[1].cause.message).toBe("Kuralle stream ended without a done part");
|
|
198
|
-
expect(parts[1].recoverable).toBe(false);
|
|
199
|
-
}
|
|
200
|
-
});
|
|
201
|
-
|
|
202
|
-
it("returns without yielding when turn.signal is already aborted", async () => {
|
|
203
|
-
const controller = new AbortController();
|
|
204
|
-
controller.abort();
|
|
205
|
-
const turn: ReasonerTurn = { ...baseTurn(), signal: controller.signal };
|
|
206
|
-
|
|
207
|
-
const reasoner = fromKuralleRuntime(
|
|
208
|
-
fakeRuntime([textDelta("should not appear"), done()]),
|
|
209
|
-
{ sessionId: "sess-1" },
|
|
210
|
-
);
|
|
211
|
-
|
|
212
|
-
const parts = await collectParts(reasoner, turn);
|
|
213
|
-
|
|
214
|
-
expect(parts).toEqual([]);
|
|
215
|
-
});
|
|
216
|
-
|
|
217
|
-
it("passes only userText to run, not turn.messages", async () => {
|
|
218
|
-
const spy: { runOpts?: { input?: string; sessionId?: string } } = {};
|
|
219
|
-
const reasoner = fromKuralleRuntime(fakeRuntime([done()], spy), { sessionId: "sess-42" });
|
|
220
|
-
|
|
221
|
-
const turn: ReasonerTurn = {
|
|
222
|
-
userText: "What is my name?",
|
|
223
|
-
messages: [
|
|
224
|
-
{ role: "system", content: "ignored" },
|
|
225
|
-
{ role: "user", content: "also ignored" },
|
|
226
|
-
],
|
|
227
|
-
signal: new AbortController().signal,
|
|
228
|
-
};
|
|
229
|
-
await collectParts(reasoner, turn);
|
|
230
|
-
|
|
231
|
-
expect(spy.runOpts?.input).toBe("What is my name?");
|
|
232
|
-
expect(spy.runOpts?.sessionId).toBe("sess-42");
|
|
233
|
-
});
|
|
234
|
-
|
|
235
|
-
it("flow resume pre-appends user message and omits input", async () => {
|
|
236
|
-
const sessionId = "sess-flow";
|
|
237
|
-
const store = createMockSessionStore(sessionId, [{ role: "assistant", content: "What is your name?" }]);
|
|
238
|
-
const session = (await store.get(sessionId))!;
|
|
239
|
-
session.durableRuns = {
|
|
240
|
-
[sessionId]: {
|
|
241
|
-
runState: {
|
|
242
|
-
activeFlow: "book-advisor-appointment",
|
|
243
|
-
messages: [{ role: "assistant", content: "What is your name?" }],
|
|
244
|
-
},
|
|
245
|
-
steps: [],
|
|
246
|
-
},
|
|
247
|
-
};
|
|
248
|
-
await store.save(session);
|
|
249
|
-
|
|
250
|
-
const runtime: KuralleRuntimeLike = {
|
|
251
|
-
run: () => ({ events: partsToEvents([done()]) }),
|
|
252
|
-
getSession: (id) => store.get(id),
|
|
253
|
-
getSessionStore: () => store,
|
|
254
|
-
};
|
|
255
|
-
|
|
256
|
-
const opts = await buildKuralleTurnRunOptions(runtime, {
|
|
257
|
-
sessionId,
|
|
258
|
-
userText: "Priya, CS masters, Friday",
|
|
259
|
-
});
|
|
260
|
-
|
|
261
|
-
expect(opts.input).toBeUndefined();
|
|
262
|
-
expect(opts.historyDelta).toBeUndefined();
|
|
263
|
-
const saved = await store.get(sessionId);
|
|
264
|
-
expect(saved?.messages.at(-1)).toEqual({ role: "user", content: "Priya, CS masters, Friday" });
|
|
265
|
-
const runMessages = (
|
|
266
|
-
saved?.durableRuns as Record<string, { runState: { messages: KuralleMessageLike[] } }>
|
|
267
|
-
)[sessionId]?.runState.messages;
|
|
268
|
-
expect(runMessages?.at(-1)).toEqual({ role: "user", content: "Priya, CS masters, Friday" });
|
|
269
|
-
});
|
|
270
|
-
|
|
271
|
-
it("rewriteLastAssistant truncates only when spoken prefix is shorter", () => {
|
|
272
|
-
const messages: KuralleMessageLike[] = [
|
|
273
|
-
{ role: "user", content: "Hi" },
|
|
274
|
-
{ role: "assistant", content: "Hello there friend" },
|
|
275
|
-
];
|
|
276
|
-
expect(rewriteLastAssistant(messages, "Hello")).toEqual([
|
|
277
|
-
{ role: "user", content: "Hi" },
|
|
278
|
-
{ role: "assistant", content: "Hello" },
|
|
279
|
-
]);
|
|
280
|
-
expect(rewriteLastAssistant(messages, "Hello there friend")).toStrictEqual(messages);
|
|
281
|
-
});
|
|
282
|
-
|
|
283
|
-
it("after barge-in abort, reconciles persisted assistant message to spoken prefix", async () => {
|
|
284
|
-
const fullText = "Hello there friend";
|
|
285
|
-
const spokenPrefix = "Hello";
|
|
286
|
-
const sessionId = "sess-barge";
|
|
287
|
-
const store = createMockSessionStore(sessionId, []);
|
|
288
|
-
|
|
289
|
-
await reconcileSpokenPrefix(
|
|
290
|
-
{ run: () => ({ events: partsToEvents([]) }), getSessionStore: () => store },
|
|
291
|
-
sessionId,
|
|
292
|
-
spokenPrefix,
|
|
293
|
-
);
|
|
294
|
-
let session = await store.get(sessionId);
|
|
295
|
-
expect(session?.messages).toEqual([]);
|
|
296
|
-
|
|
297
|
-
session = (await store.get(sessionId)) ?? { id: sessionId, messages: [] };
|
|
298
|
-
session.messages = [{ role: "assistant", content: fullText }];
|
|
299
|
-
await store.save(session);
|
|
300
|
-
|
|
301
|
-
await reconcileSpokenPrefix(
|
|
302
|
-
{ run: () => ({ events: partsToEvents([]) }), getSessionStore: () => store },
|
|
303
|
-
sessionId,
|
|
304
|
-
spokenPrefix,
|
|
305
|
-
);
|
|
306
|
-
session = await store.get(sessionId);
|
|
307
|
-
expect(session?.messages.at(-1)).toEqual({ role: "assistant", content: spokenPrefix });
|
|
308
|
-
});
|
|
309
|
-
|
|
310
|
-
it("reconcileSpokenPrefix rewrites durable run messages", async () => {
|
|
311
|
-
const sessionId = "sess-durable";
|
|
312
|
-
const store = createMockSessionStore(sessionId, []);
|
|
313
|
-
const session = (await store.get(sessionId))!;
|
|
314
|
-
session.messages = [{ role: "assistant", content: "full reply text" }];
|
|
315
|
-
session.durableRuns = {
|
|
316
|
-
[sessionId]: {
|
|
317
|
-
runState: { messages: [{ role: "assistant", content: "full reply text" }] },
|
|
318
|
-
steps: [],
|
|
319
|
-
},
|
|
320
|
-
};
|
|
321
|
-
await store.save(session);
|
|
322
|
-
|
|
323
|
-
await reconcileSpokenPrefix(
|
|
324
|
-
{ run: () => ({ events: partsToEvents([]) }), getSessionStore: () => store },
|
|
325
|
-
sessionId,
|
|
326
|
-
"full",
|
|
327
|
-
);
|
|
328
|
-
|
|
329
|
-
const saved = await store.get(sessionId);
|
|
330
|
-
expect(saved?.messages.at(-1)).toEqual({ role: "assistant", content: "full" });
|
|
331
|
-
const runState = (saved?.durableRuns as Record<string, { runState: { messages: KuralleMessageLike[] } }>)[sessionId]
|
|
332
|
-
?.runState;
|
|
333
|
-
expect(runState?.messages.at(-1)).toEqual({ role: "assistant", content: "full" });
|
|
334
|
-
});
|
|
335
|
-
});
|
|
336
|
-
|
|
337
|
-
function createMockSessionStore(sessionId: string, messages: KuralleMessageLike[]): KuralleSessionStoreLike {
|
|
338
|
-
const sessions = new Map<string, KuralleStoredSession>([
|
|
339
|
-
[sessionId, { id: sessionId, messages: [...messages] }],
|
|
340
|
-
]);
|
|
341
|
-
return {
|
|
342
|
-
async get(id) {
|
|
343
|
-
const session = sessions.get(id);
|
|
344
|
-
return session ? structuredClone(session) : null;
|
|
345
|
-
},
|
|
346
|
-
async save(session) {
|
|
347
|
-
sessions.set(session.id, structuredClone(session));
|
|
348
|
-
},
|
|
349
|
-
};
|
|
350
|
-
}
|
package/tsconfig.json
DELETED
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"compilerOptions": {
|
|
3
|
-
"target": "ES2022",
|
|
4
|
-
"module": "NodeNext",
|
|
5
|
-
"moduleResolution": "NodeNext",
|
|
6
|
-
"strict": true,
|
|
7
|
-
"esModuleInterop": true,
|
|
8
|
-
"skipLibCheck": true,
|
|
9
|
-
"types": ["vitest/globals"],
|
|
10
|
-
"noEmit": true
|
|
11
|
-
},
|
|
12
|
-
"include": ["src/**/*.ts"]
|
|
13
|
-
}
|