Skip to content

Commit b8fc958

Browse files
authored
Merge branch 'main' into test/ws-conformance
2 parents ecdd4fd + b625bfb commit b8fc958

24 files changed

Lines changed: 1463 additions & 98 deletions

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,17 @@
11
# @copilotkit/llmock
22

3+
## 1.3.0
4+
5+
### Minor Changes
6+
7+
- Mid-stream interruption: `truncateAfterChunks` and `disconnectAfterMs` fixture fields to simulate abrupt server disconnects
8+
- AbortSignal-based cancellation primitives (`createInterruptionSignal`, signal-aware `delay()`)
9+
- Backward-compatible `writeSSEStream` overload with `StreamOptions` returning completion status
10+
- Interruption support across all HTTP SSE and WebSocket streaming paths
11+
- `destroy()` method on `WebSocketConnection` for abrupt disconnect simulation
12+
- Journal records `interrupted` and `interruptReason` on interrupted streams
13+
- LLMock convenience API extended with interruption options (`truncateAfterChunks`, `disconnectAfterMs`)
14+
315
## 1.2.0
416

517
### Minor Changes

README.md

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -622,11 +622,6 @@ Areas where llmock could grow, and explicit non-goals for the current scope.
622622
- **WebSocket compression**: `permessage-deflate` is not supported.
623623
- **Session persistence**: Realtime and Gemini Live sessions exist only for the lifetime of a single WebSocket connection. There is no cross-connection session resumption.
624624

625-
### Streaming
626-
627-
- **Mid-stream interruption**: No way to simulate a server disconnecting partway through a stream (e.g. `truncateAfterChunks`, `disconnectAfterMs`).
628-
- **Abort/cancellation signaling**: Streaming functions do not accept an `AbortSignal` for client-side cancellation.
629-
630625
### Fixtures
631626

632627
- **Request metadata in predicates**: Predicate functions receive only the `ChatCompletionRequest`, not HTTP headers, method, or URL.
@@ -636,7 +631,7 @@ Areas where llmock could grow, and explicit non-goals for the current scope.
636631

637632
### Testing
638633

639-
- **E2E SDK tests**: The test suite uses raw HTTP and WebSocket frames, not real OpenAI/Anthropic/Gemini client SDKs.
634+
- **Live API conformance**: The `api-conformance` tests validate response format structure but do not run against real LLM APIs. A subset of tests that hit actual OpenAI/Anthropic/Gemini endpoints (gated behind API keys) would catch format drift as providers evolve their APIs.
640635
- **Token counts**: Usage fields are always zero across all providers.
641636
- **Vision/image content**: Image content parts are not handled by any provider.
642637

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@copilotkit/llmock",
3-
"version": "1.2.0",
3+
"version": "1.3.0",
44
"description": "Deterministic mock LLM server for testing (OpenAI, Anthropic, Gemini)",
55
"license": "MIT",
66
"packageManager": "pnpm@10.28.2",

src/__tests__/fixture-loader.test.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,71 @@ describe("loadFixtureFile", () => {
114114
expect(fixtures[0].chunkSize).toBeUndefined();
115115
});
116116

117+
it("passes through truncateAfterChunks when set", () => {
118+
const filePath = writeJson(tmpDir, "truncate.json", {
119+
fixtures: [
120+
{
121+
match: { userMessage: "truncate me" },
122+
response: { content: "partial" },
123+
truncateAfterChunks: 3,
124+
},
125+
],
126+
});
127+
128+
const fixtures = loadFixtureFile(filePath);
129+
expect(fixtures).toHaveLength(1);
130+
expect(fixtures[0].truncateAfterChunks).toBe(3);
131+
});
132+
133+
it("passes through disconnectAfterMs when set", () => {
134+
const filePath = writeJson(tmpDir, "disconnect.json", {
135+
fixtures: [
136+
{
137+
match: { userMessage: "disconnect me" },
138+
response: { content: "partial" },
139+
disconnectAfterMs: 500,
140+
},
141+
],
142+
});
143+
144+
const fixtures = loadFixtureFile(filePath);
145+
expect(fixtures).toHaveLength(1);
146+
expect(fixtures[0].disconnectAfterMs).toBe(500);
147+
});
148+
149+
it("passes through both truncateAfterChunks and disconnectAfterMs together", () => {
150+
const filePath = writeJson(tmpDir, "both-interruptions.json", {
151+
fixtures: [
152+
{
153+
match: { userMessage: "both" },
154+
response: { content: "partial" },
155+
truncateAfterChunks: 5,
156+
disconnectAfterMs: 1000,
157+
},
158+
],
159+
});
160+
161+
const fixtures = loadFixtureFile(filePath);
162+
expect(fixtures).toHaveLength(1);
163+
expect(fixtures[0].truncateAfterChunks).toBe(5);
164+
expect(fixtures[0].disconnectAfterMs).toBe(1000);
165+
});
166+
167+
it("omits truncateAfterChunks and disconnectAfterMs when not present in JSON", () => {
168+
const filePath = writeJson(tmpDir, "no-interruptions.json", {
169+
fixtures: [
170+
{
171+
match: { userMessage: "plain" },
172+
response: { content: "complete" },
173+
},
174+
],
175+
});
176+
177+
const fixtures = loadFixtureFile(filePath);
178+
expect(fixtures[0].truncateAfterChunks).toBeUndefined();
179+
expect(fixtures[0].disconnectAfterMs).toBeUndefined();
180+
});
181+
117182
it("warns and returns empty array for invalid JSON", () => {
118183
const filePath = join(tmpDir, "bad.json");
119184
writeFileSync(filePath, "{ not valid json", "utf-8");

src/__tests__/interruption.test.ts

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import { describe, it, expect, vi, afterEach } from "vitest";
2+
import { createInterruptionSignal } from "../interruption.js";
3+
import type { Fixture } from "../types.js";
4+
5+
function makeFixture(overrides?: Partial<Fixture>): Fixture {
6+
return {
7+
match: { userMessage: "test" },
8+
response: { content: "hello" },
9+
...overrides,
10+
};
11+
}
12+
13+
afterEach(() => {
14+
vi.useRealTimers();
15+
});
16+
17+
describe("createInterruptionSignal", () => {
18+
it("returns null when no interruption fields are set", () => {
19+
const result = createInterruptionSignal(makeFixture());
20+
expect(result).toBeNull();
21+
});
22+
23+
it("returns null when both fields are undefined", () => {
24+
const result = createInterruptionSignal(
25+
makeFixture({ truncateAfterChunks: undefined, disconnectAfterMs: undefined }),
26+
);
27+
expect(result).toBeNull();
28+
});
29+
30+
it("truncateAfterChunks: aborts after N ticks", () => {
31+
const ctrl = createInterruptionSignal(makeFixture({ truncateAfterChunks: 3 }));
32+
expect(ctrl).not.toBeNull();
33+
expect(ctrl!.signal.aborted).toBe(false);
34+
35+
ctrl!.tick();
36+
expect(ctrl!.signal.aborted).toBe(false);
37+
ctrl!.tick();
38+
expect(ctrl!.signal.aborted).toBe(false);
39+
ctrl!.tick();
40+
expect(ctrl!.signal.aborted).toBe(true);
41+
expect(ctrl!.reason()).toBe("truncateAfterChunks");
42+
43+
ctrl!.cleanup();
44+
});
45+
46+
it("truncateAfterChunks: extra ticks after abort are no-ops", () => {
47+
const ctrl = createInterruptionSignal(makeFixture({ truncateAfterChunks: 1 }));
48+
ctrl!.tick();
49+
expect(ctrl!.signal.aborted).toBe(true);
50+
// Should not throw
51+
ctrl!.tick();
52+
ctrl!.tick();
53+
expect(ctrl!.reason()).toBe("truncateAfterChunks");
54+
ctrl!.cleanup();
55+
});
56+
57+
it("disconnectAfterMs: aborts after timeout", async () => {
58+
vi.useFakeTimers();
59+
const ctrl = createInterruptionSignal(makeFixture({ disconnectAfterMs: 100 }));
60+
expect(ctrl).not.toBeNull();
61+
expect(ctrl!.signal.aborted).toBe(false);
62+
63+
vi.advanceTimersByTime(99);
64+
expect(ctrl!.signal.aborted).toBe(false);
65+
66+
vi.advanceTimersByTime(1);
67+
expect(ctrl!.signal.aborted).toBe(true);
68+
expect(ctrl!.reason()).toBe("disconnectAfterMs");
69+
70+
ctrl!.cleanup();
71+
});
72+
73+
it("both set: truncateAfterChunks fires first wins", () => {
74+
vi.useFakeTimers();
75+
const ctrl = createInterruptionSignal(
76+
makeFixture({ truncateAfterChunks: 2, disconnectAfterMs: 10000 }),
77+
);
78+
79+
ctrl!.tick();
80+
ctrl!.tick();
81+
expect(ctrl!.signal.aborted).toBe(true);
82+
expect(ctrl!.reason()).toBe("truncateAfterChunks");
83+
84+
ctrl!.cleanup();
85+
});
86+
87+
it("both set: disconnectAfterMs fires first wins", () => {
88+
vi.useFakeTimers();
89+
const ctrl = createInterruptionSignal(
90+
makeFixture({ truncateAfterChunks: 100, disconnectAfterMs: 50 }),
91+
);
92+
93+
ctrl!.tick(); // 1 of 100
94+
expect(ctrl!.signal.aborted).toBe(false);
95+
96+
vi.advanceTimersByTime(50);
97+
expect(ctrl!.signal.aborted).toBe(true);
98+
expect(ctrl!.reason()).toBe("disconnectAfterMs");
99+
100+
ctrl!.cleanup();
101+
});
102+
103+
it("cleanup clears the timer", () => {
104+
vi.useFakeTimers();
105+
const ctrl = createInterruptionSignal(makeFixture({ disconnectAfterMs: 100 }));
106+
107+
ctrl!.cleanup();
108+
109+
vi.advanceTimersByTime(200);
110+
expect(ctrl!.signal.aborted).toBe(false);
111+
expect(ctrl!.reason()).toBeUndefined();
112+
});
113+
114+
it("reason returns undefined before abort", () => {
115+
const ctrl = createInterruptionSignal(makeFixture({ truncateAfterChunks: 5 }));
116+
expect(ctrl!.reason()).toBeUndefined();
117+
ctrl!.cleanup();
118+
});
119+
120+
it("truncateAfterChunks: 0 aborts immediately on first tick", () => {
121+
const ctrl = createInterruptionSignal(makeFixture({ truncateAfterChunks: 0 }));
122+
expect(ctrl).not.toBeNull();
123+
expect(ctrl!.signal.aborted).toBe(false);
124+
125+
ctrl!.tick();
126+
expect(ctrl!.signal.aborted).toBe(true);
127+
expect(ctrl!.reason()).toBe("truncateAfterChunks");
128+
129+
ctrl!.cleanup();
130+
});
131+
132+
it("disconnectAfterMs: 0 aborts promptly", async () => {
133+
const ctrl = createInterruptionSignal(makeFixture({ disconnectAfterMs: 0 }));
134+
expect(ctrl).not.toBeNull();
135+
136+
await new Promise((r) => setTimeout(r, 10));
137+
expect(ctrl!.signal.aborted).toBe(true);
138+
expect(ctrl!.reason()).toBe("disconnectAfterMs");
139+
140+
ctrl!.cleanup();
141+
});
142+
});

0 commit comments

Comments
 (0)