Skip to content

Commit dede867

Browse files
authored
feat: reasoning, web search, and thinking event support (#62)
## Summary Adds support for reasoning and web search events in fixture responses across multiple providers: - **OpenAI Responses API**: Optional `reasoning` field on `TextResponse` emits `response.reasoning_summary_text.delta` events before text content. Optional `webSearches` field emits `response.web_search_call` events. - **Anthropic Claude**: `reasoning` field emits `thinking` content blocks before text in `/v1/messages`. - Both HTTP SSE and WebSocket transports supported. - Backward compatible — existing fixtures work unchanged. Closes #60 ## Changes - **`src/types.ts`**: `TextResponse` extended with optional `reasoning?: string` and `webSearches?: string[]` - **`src/responses.ts`**: `buildTextStreamEvents` accepts optional reasoning/webSearches and prepends the appropriate events with correct `output_index` adjustment. Private helpers `buildReasoningStreamEvents` and `buildWebSearchStreamEvents` handle individual event sequences. Non-streaming `buildTextResponse` includes reasoning and web search output items. - **`src/messages.ts`**: Emits `thinking` content blocks when `reasoning` is present (both streaming and non-streaming). Warns when `webSearches` is used (not supported by Claude API). - **`src/ws-responses.ts`**: Passes reasoning/webSearches through to `buildTextStreamEvents` (no duplicated logic) - **`src/fixture-loader.ts`**: Validates `reasoning` (string) and `webSearches` (array of strings). Warns on empty reasoning. - **`src/stream-collapse.ts`**: Extracts reasoning from `response.reasoning_summary_text.delta`, text from `response.output_text.delta`, and web searches from `response.output_item.done` web_search_call events. Claude collapse extracts reasoning from `thinking_delta` events. ## Example ```typescript mock.addFixture({ match: { userMessage: "hello" }, response: { content: "Based on my research, here's what I found.", reasoning: "Let me think about what the user is asking...", webSearches: ["latest greeting conventions 2025"], }, }); ``` ## Test plan - [x] Reasoning events emitted correctly in Responses API SSE - [x] Reasoning deltas reconstruct full text - [x] Web search events emitted before text events - [x] Web search items contain query strings - [x] Combined reasoning + web search + text streaming - [x] Non-streaming responses include reasoning and web search output items - [x] Anthropic thinking blocks (streaming and non-streaming) - [x] Backward compatibility — plain text fixtures unchanged - [x] WebSocket reasoning passthrough - [x] Fixture-loader validation (reasoning type, empty string, webSearches type/elements) - [x] Stream-collapse extraction (OpenAI reasoning/text/web search, Anthropic thinking) - [x] All 1333 tests pass - [x] Prettier, ESLint clean
2 parents b2b7cbe + b5a072a commit dede867

10 files changed

Lines changed: 1113 additions & 46 deletions

src/__tests__/fixture-loader.test.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -844,4 +844,75 @@ describe("validateFixtures", () => {
844844
true,
845845
);
846846
});
847+
848+
it("error: reasoning is not a string", () => {
849+
const fixtures = [makeFixture({ response: { content: "hi", reasoning: 123 } as never })];
850+
const results = validateFixtures(fixtures);
851+
expect(
852+
results.some(
853+
(r) => r.severity === "error" && r.message.includes("reasoning must be a string"),
854+
),
855+
).toBe(true);
856+
});
857+
858+
it("warning: reasoning is empty string", () => {
859+
const fixtures = [makeFixture({ response: { content: "hi", reasoning: "" } })];
860+
const results = validateFixtures(fixtures);
861+
expect(
862+
results.some((r) => r.severity === "warning" && r.message.includes("reasoning is empty")),
863+
).toBe(true);
864+
});
865+
866+
it("error: webSearches is not an array", () => {
867+
const fixtures = [
868+
makeFixture({ response: { content: "hi", webSearches: "not-array" } as never }),
869+
];
870+
const results = validateFixtures(fixtures);
871+
expect(
872+
results.some(
873+
(r) => r.severity === "error" && r.message.includes("webSearches must be an array"),
874+
),
875+
).toBe(true);
876+
});
877+
878+
it("error: webSearches element is not a string", () => {
879+
const fixtures = [
880+
makeFixture({ response: { content: "hi", webSearches: ["valid", 42] } as never }),
881+
];
882+
const results = validateFixtures(fixtures);
883+
expect(
884+
results.some(
885+
(r) => r.severity === "error" && r.message.includes("webSearches[1] is not a string"),
886+
),
887+
).toBe(true);
888+
});
889+
890+
it("accepts valid reasoning and webSearches", () => {
891+
const fixtures = [
892+
makeFixture({
893+
response: { content: "hi", reasoning: "thinking...", webSearches: ["query1", "query2"] },
894+
}),
895+
];
896+
expect(validateFixtures(fixtures)).toEqual([]);
897+
});
898+
899+
it("warning: webSearches is empty array", () => {
900+
const fixtures = [makeFixture({ response: { content: "hi", webSearches: [] } })];
901+
const results = validateFixtures(fixtures);
902+
expect(
903+
results.some(
904+
(r) => r.severity === "warning" && r.message.includes("webSearches is empty array"),
905+
),
906+
).toBe(true);
907+
});
908+
909+
it("warning: webSearches element is empty string", () => {
910+
const fixtures = [makeFixture({ response: { content: "hi", webSearches: ["valid", ""] } })];
911+
const results = validateFixtures(fixtures);
912+
expect(
913+
results.some(
914+
(r) => r.severity === "warning" && r.message.includes("webSearches[1] is empty string"),
915+
),
916+
).toBe(true);
917+
});
847918
});

0 commit comments

Comments
 (0)