@alexkroman1/aai 1.4.5 → 1.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.
- package/.turbo/turbo-build.log +9 -9
- package/CHANGELOG.md +13 -0
- package/dist/assemblyai-C969QGi4.js +35 -0
- package/dist/cartesia-BfQPOQ7Y.js +37 -0
- package/dist/host/_pipeline-test-fakes.d.ts +3 -1
- package/dist/host/providers/stt/deepgram.d.ts +28 -0
- package/dist/host/providers/tts/cartesia.d.ts +1 -1
- package/dist/host/providers/tts/rime.d.ts +44 -0
- package/dist/host/runtime-barrel.d.ts +4 -2
- package/dist/host/runtime-barrel.js +1432 -1208
- package/dist/host/runtime.d.ts +2 -2
- package/dist/host/s2s.d.ts +16 -16
- package/dist/host/session-core.d.ts +37 -0
- package/dist/host/transports/pipeline-transport.d.ts +48 -0
- package/dist/host/transports/s2s-transport.d.ts +19 -0
- package/dist/host/transports/types.d.ts +45 -0
- package/dist/host/ws-handler.d.ts +14 -10
- package/dist/sdk/protocol.d.ts +6 -5
- package/dist/sdk/providers/llm-barrel.js +1 -1
- package/dist/sdk/providers/stt/deepgram.d.ts +35 -0
- package/dist/sdk/providers/stt-barrel.d.ts +1 -0
- package/dist/sdk/providers/stt-barrel.js +2 -2
- package/dist/sdk/providers/tts/cartesia.d.ts +12 -4
- package/dist/sdk/providers/tts/rime.d.ts +42 -0
- package/dist/sdk/providers/tts-barrel.d.ts +1 -0
- package/dist/sdk/providers/tts-barrel.js +2 -2
- package/host/_pipeline-test-fakes.ts +6 -3
- package/host/_test-utils.ts +209 -128
- package/host/cleanup.test.ts +25 -298
- package/host/integration/pipeline-reference.integration.test.ts +30 -35
- package/host/providers/resolve.ts +10 -2
- package/host/providers/stt/deepgram.test.ts +229 -0
- package/host/providers/stt/deepgram.ts +172 -0
- package/host/providers/tts/cartesia.ts +7 -3
- package/host/providers/tts/rime.test.ts +251 -0
- package/host/providers/tts/rime.ts +322 -0
- package/host/runtime-barrel.ts +4 -2
- package/host/runtime.test.ts +13 -46
- package/host/runtime.ts +131 -23
- package/host/s2s.test.ts +122 -131
- package/host/s2s.ts +44 -52
- package/host/session-core.test.ts +257 -0
- package/host/session-core.ts +262 -0
- package/host/transports/pipeline-transport.test.ts +651 -0
- package/host/transports/pipeline-transport.ts +532 -0
- package/host/{fixture-replay.test.ts → transports/s2s-transport-fixtures.test.ts} +76 -106
- package/host/transports/s2s-transport.test.ts +56 -0
- package/host/transports/s2s-transport.ts +116 -0
- package/host/transports/types.test.ts +22 -0
- package/host/transports/types.ts +51 -0
- package/host/ws-handler.test.ts +324 -242
- package/host/ws-handler.ts +56 -59
- package/package.json +2 -1
- package/sdk/__snapshots__/exports.test.ts.snap +3 -3
- package/sdk/protocol-compat.test.ts +8 -0
- package/sdk/protocol.ts +6 -5
- package/sdk/providers/stt/deepgram.ts +43 -0
- package/sdk/providers/stt-barrel.ts +2 -0
- package/sdk/providers/tts/cartesia.ts +15 -5
- package/sdk/providers/tts/rime.ts +52 -0
- package/sdk/providers/tts-barrel.ts +2 -0
- package/dist/assemblyai-Cxg9eobY.js +0 -18
- package/dist/cartesia-DwDk2tEu.js +0 -10
- package/dist/host/pipeline-session-ctx.d.ts +0 -24
- package/dist/host/pipeline-session.d.ts +0 -52
- package/dist/host/session-ctx.d.ts +0 -73
- package/dist/host/session.d.ts +0 -62
- package/host/pipeline-session-ctx.test.ts +0 -31
- package/host/pipeline-session-ctx.ts +0 -36
- package/host/pipeline-session.test.ts +0 -672
- package/host/pipeline-session.ts +0 -533
- package/host/s2s-fixtures.test.ts +0 -237
- package/host/session-ctx.test.ts +0 -387
- package/host/session-ctx.ts +0 -134
- package/host/session-fixture-replay.test.ts +0 -128
- package/host/session.test.ts +0 -634
- package/host/session.ts +0 -412
- /package/dist/{anthropic-BrUCPKUc.js → anthropic-CcLZygAr.js} +0 -0
|
@@ -1,237 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test, vi } from "vitest";
|
|
2
|
-
import { loadFixture, silentLogger } from "./_test-utils.ts";
|
|
3
|
-
import type { S2sWebSocket } from "./s2s.ts";
|
|
4
|
-
import { connectS2s } from "./s2s.ts";
|
|
5
|
-
|
|
6
|
-
/** EventTarget-based WebSocket stub (standard API, no `.on()` adapter needed). */
|
|
7
|
-
function createWebSocketStub() {
|
|
8
|
-
const target = new EventTarget();
|
|
9
|
-
return Object.assign(target, {
|
|
10
|
-
readyState: 0,
|
|
11
|
-
send: vi.fn(),
|
|
12
|
-
close: vi.fn(),
|
|
13
|
-
addEventListener: target.addEventListener.bind(target) as S2sWebSocket["addEventListener"],
|
|
14
|
-
/** Simulate a server-side event for testing. */
|
|
15
|
-
emit(event: string, ...args: unknown[]) {
|
|
16
|
-
const builders: Record<string, () => Event> = {
|
|
17
|
-
open: () => new Event("open"),
|
|
18
|
-
message: () => new MessageEvent("message", { data: args[0] }),
|
|
19
|
-
close: () => {
|
|
20
|
-
const ev = new Event("close");
|
|
21
|
-
if (typeof args[0] === "number") Object.assign(ev, { code: args[0] });
|
|
22
|
-
if (typeof args[1] === "string") Object.assign(ev, { reason: args[1] });
|
|
23
|
-
return ev;
|
|
24
|
-
},
|
|
25
|
-
error: () => {
|
|
26
|
-
const msg = args[0] instanceof Error ? args[0].message : String(args[0]);
|
|
27
|
-
const ev = new Event("error");
|
|
28
|
-
Object.defineProperty(ev, "message", { value: msg });
|
|
29
|
-
return ev;
|
|
30
|
-
},
|
|
31
|
-
};
|
|
32
|
-
const build = builders[event];
|
|
33
|
-
if (build) target.dispatchEvent(build());
|
|
34
|
-
},
|
|
35
|
-
});
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
const s2sConfig = { wssUrl: "wss://fake", inputSampleRate: 16_000, outputSampleRate: 16_000 };
|
|
39
|
-
|
|
40
|
-
function createTestS2s() {
|
|
41
|
-
const raw = createWebSocketStub();
|
|
42
|
-
const createWebSocket = () => {
|
|
43
|
-
setTimeout(() => {
|
|
44
|
-
raw.readyState = 1;
|
|
45
|
-
raw.emit("open");
|
|
46
|
-
}, 0);
|
|
47
|
-
return raw;
|
|
48
|
-
};
|
|
49
|
-
return { raw, createWebSocket, logger: { ...silentLogger } };
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
async function setupHandle() {
|
|
53
|
-
const { raw, createWebSocket, logger } = createTestS2s();
|
|
54
|
-
const handle = await connectS2s({
|
|
55
|
-
apiKey: "test-key",
|
|
56
|
-
config: s2sConfig,
|
|
57
|
-
createWebSocket,
|
|
58
|
-
logger,
|
|
59
|
-
});
|
|
60
|
-
return { raw, handle, logger };
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
// ─── Fixture-based tests (real API responses from Kokoro TTS audio) ─────
|
|
64
|
-
|
|
65
|
-
describe("real API fixtures", () => {
|
|
66
|
-
/**
|
|
67
|
-
* Replay all fixture messages through the S2S handle and collect events.
|
|
68
|
-
* Events are collected from both the 'ready'/'replyStarted'/'sessionExpired' special
|
|
69
|
-
* events and the unified 'event' emitter, tagged with their source type.
|
|
70
|
-
*/
|
|
71
|
-
async function replayFixture(fixtureName: string) {
|
|
72
|
-
const { raw, handle } = await setupHandle();
|
|
73
|
-
const events: { type: string; payload: unknown }[] = [];
|
|
74
|
-
|
|
75
|
-
// Special events that are NOT in the 'event' emitter
|
|
76
|
-
handle.on("ready", (p) => events.push({ type: "ready", payload: p }));
|
|
77
|
-
handle.on("replyStarted", (p) => events.push({ type: "replyStarted", payload: p }));
|
|
78
|
-
handle.on("sessionExpired", () => events.push({ type: "sessionExpired", payload: undefined }));
|
|
79
|
-
handle.on("audio", (p) => events.push({ type: "audio", payload: p }));
|
|
80
|
-
handle.on("error", (p) => events.push({ type: "error", payload: p }));
|
|
81
|
-
|
|
82
|
-
// All protocol-shaped events via the unified 'event' emitter
|
|
83
|
-
handle.on("event", (event) => events.push({ type: event.type, payload: event }));
|
|
84
|
-
|
|
85
|
-
const fixtures = loadFixture<Record<string, unknown>[]>(fixtureName);
|
|
86
|
-
for (const msg of fixtures) {
|
|
87
|
-
raw.emit("message", Buffer.from(JSON.stringify(msg)));
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
return { events, fixtures, raw, handle };
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
// ── Session lifecycle ──────────────────────────────────────────────────
|
|
94
|
-
|
|
95
|
-
test("parses real session.ready messages with extra fields (timestamp, config)", async () => {
|
|
96
|
-
const { events } = await replayFixture("session-ready.json");
|
|
97
|
-
|
|
98
|
-
const readyEvents = events.filter((e) => e.type === "ready");
|
|
99
|
-
expect(readyEvents.length).toBeGreaterThan(0);
|
|
100
|
-
expect((readyEvents[0]?.payload as { sessionId: string }).sessionId).toMatch(/^sess_/);
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
test("parses real session.updated messages — they are now silently dropped", async () => {
|
|
104
|
-
const { events } = await replayFixture("session-updated.json");
|
|
105
|
-
|
|
106
|
-
// session.updated is no longer dispatched — it is dropped in s2s.ts
|
|
107
|
-
const updatedEvents = events.filter((e) => e.type === "session.updated");
|
|
108
|
-
expect(updatedEvents.length).toBe(0);
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
// ── Greeting session ───────────────────────────────────────────────────
|
|
112
|
-
|
|
113
|
-
test("greeting session produces correct event sequence", async () => {
|
|
114
|
-
const { events } = await replayFixture("greeting-session-sequence.json");
|
|
115
|
-
|
|
116
|
-
const types = events.map((e) => e.type);
|
|
117
|
-
// session.updated is dropped, so first non-audio event is 'ready'
|
|
118
|
-
expect(types[0]).toBe("ready");
|
|
119
|
-
expect(types[1]).toBe("replyStarted");
|
|
120
|
-
expect(types.filter((t) => t === "agent_transcript").length).toBeGreaterThan(0);
|
|
121
|
-
expect(types).toContain("agent_transcript");
|
|
122
|
-
expect(types.at(-1)).toBe("reply_done");
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
// ── Reply lifecycle ────────────────────────────────────────────────────
|
|
126
|
-
|
|
127
|
-
test("real transcript.agent has _interrupted field", async () => {
|
|
128
|
-
const { events } = await replayFixture("reply-lifecycle.json");
|
|
129
|
-
|
|
130
|
-
const transcripts = events.filter((e) => e.type === "agent_transcript");
|
|
131
|
-
expect(transcripts.length).toBe(1);
|
|
132
|
-
const payload = transcripts[0]?.payload as {
|
|
133
|
-
type: string;
|
|
134
|
-
text: string;
|
|
135
|
-
_interrupted: boolean;
|
|
136
|
-
};
|
|
137
|
-
expect(payload._interrupted).toBe(false);
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
// ── Audio ──────────────────────────────────────────────────────────────
|
|
141
|
-
|
|
142
|
-
test("real reply.audio messages decode to Uint8Array", async () => {
|
|
143
|
-
const { events } = await replayFixture("reply-audio-samples.json");
|
|
144
|
-
|
|
145
|
-
const audioEvents = events.filter((e) => e.type === "audio");
|
|
146
|
-
expect(audioEvents.length).toBeGreaterThan(0);
|
|
147
|
-
for (const e of audioEvents) {
|
|
148
|
-
expect((e.payload as { audio: Uint8Array }).audio).toBeInstanceOf(Uint8Array);
|
|
149
|
-
}
|
|
150
|
-
});
|
|
151
|
-
|
|
152
|
-
// ── User speech recognition (from Kokoro TTS audio) ────────────────────
|
|
153
|
-
|
|
154
|
-
test("user speech events from real STT (Kokoro-generated audio)", async () => {
|
|
155
|
-
const { events } = await replayFixture("user-speech-recognition.json");
|
|
156
|
-
|
|
157
|
-
const types = events.map((e) => e.type);
|
|
158
|
-
expect(types).toContain("speech_started");
|
|
159
|
-
expect(types).toContain("speech_stopped");
|
|
160
|
-
expect(types).toContain("user_transcript");
|
|
161
|
-
|
|
162
|
-
// Verify the STT correctly transcribed the Kokoro audio
|
|
163
|
-
const transcripts = events.filter((e) => e.type === "user_transcript");
|
|
164
|
-
const texts = transcripts.map((e) => (e.payload as { text: string }).text);
|
|
165
|
-
expect(texts.some((t) => t.toLowerCase().includes("space"))).toBe(true);
|
|
166
|
-
expect(texts.some((t) => t.toLowerCase().includes("weather"))).toBe(true);
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
// ── Simple question flow ───────────────────────────────────────────────
|
|
170
|
-
|
|
171
|
-
test("simple question: greeting → user speech → agent response", async () => {
|
|
172
|
-
const { events } = await replayFixture("simple-question-sequence.json");
|
|
173
|
-
|
|
174
|
-
const types = events.map((e) => e.type);
|
|
175
|
-
|
|
176
|
-
// Session setup: session.updated is dropped; first events are ready + replyStarted
|
|
177
|
-
expect(types[0]).toBe("ready");
|
|
178
|
-
expect(types[1]).toBe("replyStarted");
|
|
179
|
-
|
|
180
|
-
// Greeting reply
|
|
181
|
-
expect(types).toContain("replyStarted");
|
|
182
|
-
|
|
183
|
-
// User speech recognition
|
|
184
|
-
expect(types).toContain("speech_started");
|
|
185
|
-
expect(types).toContain("user_transcript");
|
|
186
|
-
|
|
187
|
-
// Agent response
|
|
188
|
-
const agentTranscripts = events.filter((e) => e.type === "agent_transcript");
|
|
189
|
-
expect(agentTranscripts.length).toBe(2); // greeting + answer
|
|
190
|
-
|
|
191
|
-
// Two complete reply cycles (greeting + answer)
|
|
192
|
-
expect(types.filter((t) => t === "reply_done").length).toBe(2);
|
|
193
|
-
});
|
|
194
|
-
|
|
195
|
-
// ── Tool call flow ─────────────────────────────────────────────────────
|
|
196
|
-
|
|
197
|
-
test("tool call: user asks weather → tool_call event dispatched with parsed args", async () => {
|
|
198
|
-
const { events } = await replayFixture("tool-calls.json");
|
|
199
|
-
|
|
200
|
-
const toolCallEvents = events.filter((e) => e.type === "tool_call");
|
|
201
|
-
expect(toolCallEvents.length).toBe(1);
|
|
202
|
-
const tc = toolCallEvents[0]?.payload as {
|
|
203
|
-
toolCallId: string;
|
|
204
|
-
toolName: string;
|
|
205
|
-
args: Record<string, unknown>;
|
|
206
|
-
};
|
|
207
|
-
expect(tc.toolName).toBe("get_weather");
|
|
208
|
-
expect(tc.args.city).toBe("San Francisco");
|
|
209
|
-
expect(tc.toolCallId).toMatch(/^chatcmpl-tool-/);
|
|
210
|
-
});
|
|
211
|
-
|
|
212
|
-
test("tool call sequence: greeting → user speech → tool call → agent response", async () => {
|
|
213
|
-
const { events } = await replayFixture("tool-call-sequence.json");
|
|
214
|
-
|
|
215
|
-
const types = events.map((e) => e.type);
|
|
216
|
-
|
|
217
|
-
// Session setup: session.updated dropped; first events are ready + replyStarted
|
|
218
|
-
expect(types[0]).toBe("ready");
|
|
219
|
-
expect(types[1]).toBe("replyStarted");
|
|
220
|
-
|
|
221
|
-
// User speech was recognized
|
|
222
|
-
expect(types).toContain("user_transcript");
|
|
223
|
-
const userTx = events.find((e) => e.type === "user_transcript");
|
|
224
|
-
expect((userTx?.payload as { text: string }).text.toLowerCase()).toContain("weather");
|
|
225
|
-
|
|
226
|
-
// Tool was called
|
|
227
|
-
expect(types).toContain("tool_call");
|
|
228
|
-
const toolCall = events.find((e) => e.type === "tool_call");
|
|
229
|
-
expect((toolCall?.payload as { toolName: string }).toolName).toBe("get_weather");
|
|
230
|
-
|
|
231
|
-
// Agent responded after tool result
|
|
232
|
-
const agentTxs = events.filter((e) => e.type === "agent_transcript");
|
|
233
|
-
expect(agentTxs.length).toBe(2); // greeting + tool response
|
|
234
|
-
const toolResponse = agentTxs.at(-1)?.payload as { text: string };
|
|
235
|
-
expect(toolResponse.text.toLowerCase()).toContain("san francisco");
|
|
236
|
-
});
|
|
237
|
-
});
|
package/host/session-ctx.test.ts
DELETED
|
@@ -1,387 +0,0 @@
|
|
|
1
|
-
// Copyright 2025 the AAI authors. MIT license.
|
|
2
|
-
|
|
3
|
-
import { describe, expect, it, vi } from "vitest";
|
|
4
|
-
import { DEFAULT_MAX_HISTORY } from "../sdk/constants.ts";
|
|
5
|
-
import type { Message } from "../sdk/types.ts";
|
|
6
|
-
import { toolError } from "../sdk/utils.ts";
|
|
7
|
-
import { flush, makeClient, makeConfig, silentLogger } from "./_test-utils.ts";
|
|
8
|
-
import { buildCtx } from "./session-ctx.ts";
|
|
9
|
-
|
|
10
|
-
function makeBuildCtxOpts(overrides?: Record<string, unknown>) {
|
|
11
|
-
return {
|
|
12
|
-
id: "session-1",
|
|
13
|
-
agent: "test-agent",
|
|
14
|
-
client: makeClient(),
|
|
15
|
-
agentConfig: makeConfig({ maxSteps: 3 }),
|
|
16
|
-
executeTool: vi.fn(async () => "ok"),
|
|
17
|
-
log: silentLogger,
|
|
18
|
-
...overrides,
|
|
19
|
-
};
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
describe("buildCtx", () => {
|
|
23
|
-
it("returns ctx with the correct session id", () => {
|
|
24
|
-
const ctx = buildCtx(makeBuildCtxOpts({ id: "my-session" }));
|
|
25
|
-
expect(ctx.id).toBe("my-session");
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
it("returns ctx with the correct agent name", () => {
|
|
29
|
-
const ctx = buildCtx(makeBuildCtxOpts({ agent: "my-agent" }));
|
|
30
|
-
expect(ctx.agent).toBe("my-agent");
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
it("initializes with empty conversation messages", () => {
|
|
34
|
-
const ctx = buildCtx(makeBuildCtxOpts());
|
|
35
|
-
expect(ctx.conversationMessages).toEqual([]);
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
it("initializes with null s2s handle", () => {
|
|
39
|
-
const ctx = buildCtx(makeBuildCtxOpts());
|
|
40
|
-
expect(ctx.s2s).toBeNull();
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
it("initializes with null turnPromise", () => {
|
|
44
|
-
const ctx = buildCtx(makeBuildCtxOpts());
|
|
45
|
-
expect(ctx.turnPromise).toBeNull();
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
it("initializes reply state with empty pendingTools, zero toolCallCount, and null replyId", () => {
|
|
49
|
-
const ctx = buildCtx(makeBuildCtxOpts());
|
|
50
|
-
expect(ctx.reply).toEqual({
|
|
51
|
-
pendingTools: [],
|
|
52
|
-
toolCallCount: 0,
|
|
53
|
-
currentReplyId: null,
|
|
54
|
-
});
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
it("defaults maxHistory to DEFAULT_MAX_HISTORY when not provided", () => {
|
|
58
|
-
const ctx = buildCtx(makeBuildCtxOpts());
|
|
59
|
-
expect(ctx.maxHistory).toBe(DEFAULT_MAX_HISTORY);
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
it("uses custom maxHistory when provided", () => {
|
|
63
|
-
const ctx = buildCtx(makeBuildCtxOpts({ maxHistory: 50 }));
|
|
64
|
-
expect(ctx.maxHistory).toBe(50);
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
it("passes through the agentConfig, executeTool, and log dependencies", () => {
|
|
68
|
-
const config = makeConfig({ maxSteps: 7 });
|
|
69
|
-
const executeTool = vi.fn(async () => "done");
|
|
70
|
-
const ctx = buildCtx(makeBuildCtxOpts({ agentConfig: config, executeTool }));
|
|
71
|
-
expect(ctx.agentConfig).toBe(config);
|
|
72
|
-
expect(ctx.executeTool).toBe(executeTool);
|
|
73
|
-
expect(ctx.log).toBe(silentLogger);
|
|
74
|
-
});
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
describe("consumeToolCallStep", () => {
|
|
78
|
-
it("returns null (success) when tool call is within maxSteps", () => {
|
|
79
|
-
const ctx = buildCtx(makeBuildCtxOpts());
|
|
80
|
-
ctx.beginReply("reply-1");
|
|
81
|
-
const result = ctx.consumeToolCallStep("my-tool", "reply-1");
|
|
82
|
-
expect(result).toBeNull();
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
it("increments toolCallCount on each call", () => {
|
|
86
|
-
const ctx = buildCtx(makeBuildCtxOpts());
|
|
87
|
-
ctx.beginReply("reply-1");
|
|
88
|
-
|
|
89
|
-
ctx.consumeToolCallStep("tool-a", "reply-1");
|
|
90
|
-
expect(ctx.reply.toolCallCount).toBe(1);
|
|
91
|
-
|
|
92
|
-
ctx.consumeToolCallStep("tool-b", "reply-1");
|
|
93
|
-
expect(ctx.reply.toolCallCount).toBe(2);
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
it("allows exactly maxSteps tool calls", () => {
|
|
97
|
-
const ctx = buildCtx(makeBuildCtxOpts({ agentConfig: makeConfig({ maxSteps: 2 }) }));
|
|
98
|
-
ctx.beginReply("reply-1");
|
|
99
|
-
|
|
100
|
-
expect(ctx.consumeToolCallStep("tool-1", "reply-1")).toBeNull();
|
|
101
|
-
expect(ctx.consumeToolCallStep("tool-2", "reply-1")).toBeNull();
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
it("rejects when tool call count exceeds maxSteps", () => {
|
|
105
|
-
const ctx = buildCtx(makeBuildCtxOpts({ agentConfig: makeConfig({ maxSteps: 2 }) }));
|
|
106
|
-
ctx.beginReply("reply-1");
|
|
107
|
-
|
|
108
|
-
ctx.consumeToolCallStep("tool-1", "reply-1");
|
|
109
|
-
ctx.consumeToolCallStep("tool-2", "reply-1");
|
|
110
|
-
const result = ctx.consumeToolCallStep("tool-3", "reply-1");
|
|
111
|
-
expect(result).toBe(toolError("Maximum tool steps reached. Please respond to the user now."));
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
it("logs when maxSteps is exceeded", () => {
|
|
115
|
-
const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
|
|
116
|
-
const ctx = buildCtx(makeBuildCtxOpts({ agentConfig: makeConfig({ maxSteps: 1 }), log }));
|
|
117
|
-
ctx.beginReply("reply-1");
|
|
118
|
-
|
|
119
|
-
ctx.consumeToolCallStep("tool-1", "reply-1"); // ok
|
|
120
|
-
ctx.consumeToolCallStep("tool-2", "reply-1"); // exceeds
|
|
121
|
-
|
|
122
|
-
expect(log.info).toHaveBeenCalledWith("maxSteps exceeded, refusing tool call", {
|
|
123
|
-
toolCallCount: 2,
|
|
124
|
-
maxSteps: 1,
|
|
125
|
-
});
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
it("rejects with stale replyId (mismatched)", () => {
|
|
129
|
-
const ctx = buildCtx(makeBuildCtxOpts());
|
|
130
|
-
ctx.beginReply("reply-1");
|
|
131
|
-
|
|
132
|
-
const result = ctx.consumeToolCallStep("my-tool", "stale-reply");
|
|
133
|
-
expect(result).toBe(toolError("Reply was interrupted. Discarding stale tool call."));
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
it("rejects when replyId is null", () => {
|
|
137
|
-
const ctx = buildCtx(makeBuildCtxOpts());
|
|
138
|
-
ctx.beginReply("reply-1");
|
|
139
|
-
|
|
140
|
-
const result = ctx.consumeToolCallStep("my-tool", null);
|
|
141
|
-
expect(result).toBe(toolError("Reply was interrupted. Discarding stale tool call."));
|
|
142
|
-
});
|
|
143
|
-
|
|
144
|
-
it("rejects when no reply has been started (currentReplyId is null)", () => {
|
|
145
|
-
const ctx = buildCtx(makeBuildCtxOpts());
|
|
146
|
-
// No beginReply — currentReplyId stays null
|
|
147
|
-
const result = ctx.consumeToolCallStep("my-tool", "some-reply");
|
|
148
|
-
expect(result).toBe(toolError("Reply was interrupted. Discarding stale tool call."));
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
it("allows unlimited tool calls when maxSteps is undefined", () => {
|
|
152
|
-
const ctx = buildCtx(makeBuildCtxOpts({ agentConfig: makeConfig() }));
|
|
153
|
-
ctx.beginReply("reply-1");
|
|
154
|
-
|
|
155
|
-
// makeConfig() without maxSteps leaves it undefined
|
|
156
|
-
for (let i = 0; i < 100; i++) {
|
|
157
|
-
expect(ctx.consumeToolCallStep(`tool-${i}`, "reply-1")).toBeNull();
|
|
158
|
-
}
|
|
159
|
-
});
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
describe("pushMessages", () => {
|
|
163
|
-
it("appends messages to conversationMessages", () => {
|
|
164
|
-
const ctx = buildCtx(makeBuildCtxOpts());
|
|
165
|
-
const msg1: Message = { role: "user", content: "hello" };
|
|
166
|
-
const msg2: Message = { role: "assistant", content: "hi" };
|
|
167
|
-
|
|
168
|
-
ctx.pushMessages(msg1);
|
|
169
|
-
expect(ctx.conversationMessages).toEqual([msg1]);
|
|
170
|
-
|
|
171
|
-
ctx.pushMessages(msg2);
|
|
172
|
-
expect(ctx.conversationMessages).toEqual([msg1, msg2]);
|
|
173
|
-
});
|
|
174
|
-
|
|
175
|
-
it("accepts multiple messages at once", () => {
|
|
176
|
-
const ctx = buildCtx(makeBuildCtxOpts());
|
|
177
|
-
const msg1: Message = { role: "user", content: "a" };
|
|
178
|
-
const msg2: Message = { role: "assistant", content: "b" };
|
|
179
|
-
const msg3: Message = { role: "tool", content: "c" };
|
|
180
|
-
|
|
181
|
-
ctx.pushMessages(msg1, msg2, msg3);
|
|
182
|
-
expect(ctx.conversationMessages).toEqual([msg1, msg2, msg3]);
|
|
183
|
-
});
|
|
184
|
-
|
|
185
|
-
it("trims to maxHistory keeping the most recent messages", () => {
|
|
186
|
-
const ctx = buildCtx(makeBuildCtxOpts({ maxHistory: 3 }));
|
|
187
|
-
|
|
188
|
-
ctx.pushMessages(
|
|
189
|
-
{ role: "user", content: "1" },
|
|
190
|
-
{ role: "assistant", content: "2" },
|
|
191
|
-
{ role: "user", content: "3" },
|
|
192
|
-
);
|
|
193
|
-
expect(ctx.conversationMessages).toHaveLength(3);
|
|
194
|
-
|
|
195
|
-
ctx.pushMessages({ role: "assistant", content: "4" });
|
|
196
|
-
expect(ctx.conversationMessages).toHaveLength(3);
|
|
197
|
-
expect(ctx.conversationMessages.map((m) => m.content)).toEqual(["2", "3", "4"]);
|
|
198
|
-
});
|
|
199
|
-
|
|
200
|
-
it("trims correctly when pushing multiple messages that exceed maxHistory", () => {
|
|
201
|
-
const ctx = buildCtx(makeBuildCtxOpts({ maxHistory: 2 }));
|
|
202
|
-
|
|
203
|
-
ctx.pushMessages(
|
|
204
|
-
{ role: "user", content: "a" },
|
|
205
|
-
{ role: "assistant", content: "b" },
|
|
206
|
-
{ role: "user", content: "c" },
|
|
207
|
-
{ role: "assistant", content: "d" },
|
|
208
|
-
);
|
|
209
|
-
|
|
210
|
-
expect(ctx.conversationMessages).toHaveLength(2);
|
|
211
|
-
expect(ctx.conversationMessages.map((m) => m.content)).toEqual(["c", "d"]);
|
|
212
|
-
});
|
|
213
|
-
|
|
214
|
-
it("does not trim when maxHistory is 0 (disabled)", () => {
|
|
215
|
-
const ctx = buildCtx(makeBuildCtxOpts({ maxHistory: 0 }));
|
|
216
|
-
|
|
217
|
-
for (let i = 0; i < 300; i++) {
|
|
218
|
-
ctx.pushMessages({ role: "user", content: `msg-${i}` });
|
|
219
|
-
}
|
|
220
|
-
expect(ctx.conversationMessages).toHaveLength(300);
|
|
221
|
-
});
|
|
222
|
-
});
|
|
223
|
-
|
|
224
|
-
describe("cancelReply", () => {
|
|
225
|
-
it("resets pendingTools and toolCallCount", () => {
|
|
226
|
-
const ctx = buildCtx(makeBuildCtxOpts());
|
|
227
|
-
ctx.beginReply("reply-1");
|
|
228
|
-
ctx.consumeToolCallStep("tool-1", "reply-1");
|
|
229
|
-
ctx.reply.pendingTools.push({ callId: "c1", result: "r1" });
|
|
230
|
-
|
|
231
|
-
expect(ctx.reply.toolCallCount).toBe(1);
|
|
232
|
-
expect(ctx.reply.pendingTools).toHaveLength(1);
|
|
233
|
-
|
|
234
|
-
ctx.cancelReply();
|
|
235
|
-
|
|
236
|
-
expect(ctx.reply.toolCallCount).toBe(0);
|
|
237
|
-
expect(ctx.reply.pendingTools).toEqual([]);
|
|
238
|
-
expect(ctx.reply.currentReplyId).toBeNull();
|
|
239
|
-
});
|
|
240
|
-
|
|
241
|
-
it("allows a new reply to start fresh after cancel", () => {
|
|
242
|
-
const ctx = buildCtx(makeBuildCtxOpts({ agentConfig: makeConfig({ maxSteps: 1 }) }));
|
|
243
|
-
ctx.beginReply("reply-1");
|
|
244
|
-
ctx.consumeToolCallStep("tool-1", "reply-1"); // uses the single step
|
|
245
|
-
|
|
246
|
-
ctx.cancelReply();
|
|
247
|
-
ctx.beginReply("reply-2");
|
|
248
|
-
|
|
249
|
-
// Should succeed because toolCallCount was reset
|
|
250
|
-
const result = ctx.consumeToolCallStep("tool-1", "reply-2");
|
|
251
|
-
expect(result).toBeNull();
|
|
252
|
-
});
|
|
253
|
-
});
|
|
254
|
-
|
|
255
|
-
describe("beginReply", () => {
|
|
256
|
-
it("resets reply state with the given replyId", () => {
|
|
257
|
-
const ctx = buildCtx(makeBuildCtxOpts());
|
|
258
|
-
ctx.beginReply("reply-1");
|
|
259
|
-
|
|
260
|
-
expect(ctx.reply.currentReplyId).toBe("reply-1");
|
|
261
|
-
expect(ctx.reply.pendingTools).toEqual([]);
|
|
262
|
-
expect(ctx.reply.toolCallCount).toBe(0);
|
|
263
|
-
});
|
|
264
|
-
|
|
265
|
-
it("clears turnPromise", () => {
|
|
266
|
-
const ctx = buildCtx(makeBuildCtxOpts());
|
|
267
|
-
ctx.chainTurn(Promise.resolve());
|
|
268
|
-
expect(ctx.turnPromise).not.toBeNull();
|
|
269
|
-
|
|
270
|
-
ctx.beginReply("reply-1");
|
|
271
|
-
expect(ctx.turnPromise).toBeNull();
|
|
272
|
-
});
|
|
273
|
-
|
|
274
|
-
it("resets toolCallCount from a previous reply", () => {
|
|
275
|
-
const ctx = buildCtx(makeBuildCtxOpts({ agentConfig: makeConfig({ maxSteps: 2 }) }));
|
|
276
|
-
ctx.beginReply("reply-1");
|
|
277
|
-
ctx.consumeToolCallStep("tool-a", "reply-1");
|
|
278
|
-
ctx.consumeToolCallStep("tool-b", "reply-1");
|
|
279
|
-
expect(ctx.reply.toolCallCount).toBe(2);
|
|
280
|
-
|
|
281
|
-
ctx.beginReply("reply-2");
|
|
282
|
-
expect(ctx.reply.toolCallCount).toBe(0);
|
|
283
|
-
|
|
284
|
-
// Can now use maxSteps again
|
|
285
|
-
expect(ctx.consumeToolCallStep("tool-a", "reply-2")).toBeNull();
|
|
286
|
-
expect(ctx.consumeToolCallStep("tool-b", "reply-2")).toBeNull();
|
|
287
|
-
});
|
|
288
|
-
|
|
289
|
-
it("invalidates tool calls from the previous reply", () => {
|
|
290
|
-
const ctx = buildCtx(makeBuildCtxOpts());
|
|
291
|
-
ctx.beginReply("reply-1");
|
|
292
|
-
ctx.beginReply("reply-2");
|
|
293
|
-
|
|
294
|
-
// Tool call using old replyId should be rejected
|
|
295
|
-
const result = ctx.consumeToolCallStep("my-tool", "reply-1");
|
|
296
|
-
expect(result).toBe(toolError("Reply was interrupted. Discarding stale tool call."));
|
|
297
|
-
});
|
|
298
|
-
});
|
|
299
|
-
|
|
300
|
-
describe("chainTurn", () => {
|
|
301
|
-
it("sets turnPromise on first call", () => {
|
|
302
|
-
const ctx = buildCtx(makeBuildCtxOpts());
|
|
303
|
-
expect(ctx.turnPromise).toBeNull();
|
|
304
|
-
|
|
305
|
-
ctx.chainTurn(Promise.resolve());
|
|
306
|
-
expect(ctx.turnPromise).not.toBeNull();
|
|
307
|
-
});
|
|
308
|
-
|
|
309
|
-
it("chains promises sequentially", async () => {
|
|
310
|
-
const ctx = buildCtx(makeBuildCtxOpts());
|
|
311
|
-
const order: number[] = [];
|
|
312
|
-
|
|
313
|
-
ctx.chainTurn(
|
|
314
|
-
new Promise<void>((resolve) => {
|
|
315
|
-
queueMicrotask(() => {
|
|
316
|
-
order.push(1);
|
|
317
|
-
resolve();
|
|
318
|
-
});
|
|
319
|
-
}),
|
|
320
|
-
);
|
|
321
|
-
|
|
322
|
-
ctx.chainTurn(
|
|
323
|
-
new Promise<void>((resolve) => {
|
|
324
|
-
queueMicrotask(() => {
|
|
325
|
-
order.push(2);
|
|
326
|
-
resolve();
|
|
327
|
-
});
|
|
328
|
-
}),
|
|
329
|
-
);
|
|
330
|
-
|
|
331
|
-
await ctx.turnPromise;
|
|
332
|
-
await flush();
|
|
333
|
-
expect(order).toEqual([1, 2]);
|
|
334
|
-
});
|
|
335
|
-
|
|
336
|
-
it("continues the chain even if a prior turn rejects", async () => {
|
|
337
|
-
const ctx = buildCtx(makeBuildCtxOpts());
|
|
338
|
-
const order: string[] = [];
|
|
339
|
-
|
|
340
|
-
ctx.chainTurn(
|
|
341
|
-
new Promise<void>((_, reject) => {
|
|
342
|
-
queueMicrotask(() => {
|
|
343
|
-
order.push("fail");
|
|
344
|
-
reject(new Error("boom"));
|
|
345
|
-
});
|
|
346
|
-
}),
|
|
347
|
-
);
|
|
348
|
-
|
|
349
|
-
ctx.chainTurn(
|
|
350
|
-
new Promise<void>((resolve) => {
|
|
351
|
-
queueMicrotask(() => {
|
|
352
|
-
order.push("success");
|
|
353
|
-
resolve();
|
|
354
|
-
});
|
|
355
|
-
}),
|
|
356
|
-
);
|
|
357
|
-
|
|
358
|
-
// The chain uses .then() which means rejection propagates.
|
|
359
|
-
// We need to catch the final promise to avoid unhandled rejection.
|
|
360
|
-
try {
|
|
361
|
-
await ctx.turnPromise;
|
|
362
|
-
} catch {
|
|
363
|
-
// expected
|
|
364
|
-
}
|
|
365
|
-
await flush();
|
|
366
|
-
|
|
367
|
-
expect(order).toContain("fail");
|
|
368
|
-
});
|
|
369
|
-
|
|
370
|
-
it("allows awaiting turnPromise to wait for all chained turns", async () => {
|
|
371
|
-
const ctx = buildCtx(makeBuildCtxOpts());
|
|
372
|
-
let completed = false;
|
|
373
|
-
|
|
374
|
-
ctx.chainTurn(
|
|
375
|
-
new Promise<void>((resolve) => {
|
|
376
|
-
setTimeout(() => {
|
|
377
|
-
completed = true;
|
|
378
|
-
resolve();
|
|
379
|
-
}, 10);
|
|
380
|
-
}),
|
|
381
|
-
);
|
|
382
|
-
|
|
383
|
-
expect(completed).toBe(false);
|
|
384
|
-
await ctx.turnPromise;
|
|
385
|
-
expect(completed).toBe(true);
|
|
386
|
-
});
|
|
387
|
-
});
|