|
1 | 1 | import { describe, expect, it } from "bun:test"; |
| 2 | +import type { AdapterRegistry } from "@intx/inference"; |
2 | 3 | import { createBuiltinRegistry } from "@intx/inference/providers"; |
3 | 4 | import type { ConversationTurn, LastCycleSource } from "@intx/types/runtime"; |
4 | | -import { sanitizeReplayTurns, withReplaySanitizer } from "./replay-sanitizer.js"; |
| 5 | +import { createGrokResponsesAdapter } from "./grok-responses-adapter.js"; |
| 6 | +import { createOpenAICompatibleAdapter } from "./openai-compatible-adapter.js"; |
| 7 | +import { |
| 8 | + sanitizeReplayTurns, |
| 9 | + THINKING_ONLY_OMITTED, |
| 10 | + withReplaySanitizer, |
| 11 | +} from "./replay-sanitizer.js"; |
5 | 12 |
|
6 | 13 | const GROK_SIGNATURE = "grok-opaque-signature-blob"; |
7 | 14 |
|
@@ -33,6 +40,59 @@ function resolveSanitized(source: LastCycleSource) { |
33 | 40 | return withReplaySanitizer(createBuiltinRegistry()).resolve(source); |
34 | 41 | } |
35 | 42 |
|
| 43 | +function corbitsRegistry(): AdapterRegistry { |
| 44 | + const builtin = createBuiltinRegistry(); |
| 45 | + return { |
| 46 | + has: (provider) => |
| 47 | + provider === "openai-compatible" || provider === "grok-responses" || builtin.has(provider), |
| 48 | + resolve(source, quirks) { |
| 49 | + if (source.provider === "openai-compatible") { |
| 50 | + return createOpenAICompatibleAdapter(source); |
| 51 | + } |
| 52 | + if (source.provider === "grok-responses") { |
| 53 | + return createGrokResponsesAdapter(source); |
| 54 | + } |
| 55 | + return builtin.resolve(source, quirks); |
| 56 | + }, |
| 57 | + }; |
| 58 | +} |
| 59 | + |
| 60 | +function thinkingOnlyHistory(): ConversationTurn[] { |
| 61 | + return [ |
| 62 | + { |
| 63 | + role: "user", |
| 64 | + content: [{ type: "text", text: "hello" }], |
| 65 | + timestamp: 1, |
| 66 | + }, |
| 67 | + { |
| 68 | + role: "assistant", |
| 69 | + model: "grok-4", |
| 70 | + content: [{ type: "thinking", thinking: "pondering" }], |
| 71 | + timestamp: 2, |
| 72 | + }, |
| 73 | + { |
| 74 | + role: "user", |
| 75 | + content: [{ type: "text", text: "continue" }], |
| 76 | + timestamp: 3, |
| 77 | + }, |
| 78 | + ]; |
| 79 | +} |
| 80 | + |
| 81 | +const USER_ONLY: ConversationTurn[] = [ |
| 82 | + { |
| 83 | + role: "user", |
| 84 | + content: [{ type: "text", text: "hello" }], |
| 85 | + timestamp: 1, |
| 86 | + }, |
| 87 | +]; |
| 88 | + |
| 89 | +const THINKING_ONLY_TAIL: ConversationTurn = { |
| 90 | + role: "assistant", |
| 91 | + model: "grok-4", |
| 92 | + content: [{ type: "thinking", thinking: "pondering" }], |
| 93 | + timestamp: 2, |
| 94 | +}; |
| 95 | + |
36 | 96 | describe("sanitizeReplayTurns", () => { |
37 | 97 | it("strips foreign thinking blocks and signatures", () => { |
38 | 98 | const turns = sanitizeReplayTurns(grokThinkingHistory(), "claude-opus-4"); |
@@ -101,6 +161,68 @@ describe("sanitizeReplayTurns", () => { |
101 | 161 | expect(results).toHaveLength(1); |
102 | 162 | expect(results[0]).toMatchObject({ callId: "call_1", isError: true }); |
103 | 163 | }); |
| 164 | + |
| 165 | + it("replaces a thinking-only assistant between users with a marker and keeps roles", () => { |
| 166 | + const turns = sanitizeReplayTurns(thinkingOnlyHistory(), "claude-opus-4"); |
| 167 | + expect(turns.map((t) => t.role)).toEqual(["user", "assistant", "user"]); |
| 168 | + expect(turns[1]?.content).toEqual([{ type: "text", text: THINKING_ONLY_OMITTED }]); |
| 169 | + }); |
| 170 | + |
| 171 | + it("replaces empty and leftover-only assistant turns with the same marker", () => { |
| 172 | + const empty = sanitizeReplayTurns( |
| 173 | + [ |
| 174 | + { |
| 175 | + role: "assistant", |
| 176 | + model: "grok-4", |
| 177 | + content: [], |
| 178 | + timestamp: 1, |
| 179 | + }, |
| 180 | + ], |
| 181 | + "claude-opus-4", |
| 182 | + ); |
| 183 | + expect(empty[0]?.content).toEqual([{ type: "text", text: THINKING_ONLY_OMITTED }]); |
| 184 | + |
| 185 | + const leftovers = sanitizeReplayTurns( |
| 186 | + [ |
| 187 | + { |
| 188 | + role: "assistant", |
| 189 | + model: "claude-opus-4", |
| 190 | + content: [ |
| 191 | + { type: "thinking", thinking: "pondering" }, |
| 192 | + { type: "redacted_thinking", data: "opaque" }, |
| 193 | + { type: "citation", citedText: "quote", source: {} }, |
| 194 | + ], |
| 195 | + timestamp: 1, |
| 196 | + }, |
| 197 | + ], |
| 198 | + "gemini-2.5-pro", |
| 199 | + ); |
| 200 | + expect(leftovers[0]?.content).toEqual([{ type: "text", text: THINKING_ONLY_OMITTED }]); |
| 201 | + expect(JSON.stringify(leftovers)).not.toContain("pondering"); |
| 202 | + expect(JSON.stringify(leftovers)).not.toContain("opaque"); |
| 203 | + }); |
| 204 | + |
| 205 | + it("leaves assistant turns with text and/or tool_call unchanged", () => { |
| 206 | + const withText = sanitizeReplayTurns(grokThinkingHistory(), "grok-4"); |
| 207 | + expect(withText[1]?.content).toEqual([ |
| 208 | + { type: "thinking", thinking: "pondering", signature: GROK_SIGNATURE }, |
| 209 | + { type: "text", text: "answer", signature: GROK_SIGNATURE }, |
| 210 | + ]); |
| 211 | + |
| 212 | + const withTool = [ |
| 213 | + { |
| 214 | + role: "assistant" as const, |
| 215 | + model: "grok-4", |
| 216 | + content: [ |
| 217 | + { type: "thinking" as const, thinking: "need a tool" }, |
| 218 | + { type: "tool_call" as const, id: "call_1", name: "ls", arguments: {} }, |
| 219 | + ], |
| 220 | + timestamp: 1, |
| 221 | + }, |
| 222 | + ]; |
| 223 | + const kept = sanitizeReplayTurns(withTool, "grok-4"); |
| 224 | + expect(kept[0]?.content).toEqual(withTool[0]?.content); |
| 225 | + }); |
104 | 226 | }); |
105 | 227 |
|
106 | 228 | describe("withReplaySanitizer", () => { |
@@ -186,4 +308,53 @@ describe("withReplaySanitizer", () => { |
186 | 308 | expect(request.body).toContain("tool_result"); |
187 | 309 | expect(request.body).toContain("call_1"); |
188 | 310 | }); |
| 311 | + |
| 312 | + it("changes buildRequest bodies after a thinking-only turn for builtin and Corbits adapters", () => { |
| 313 | + const sanitized = withReplaySanitizer(corbitsRegistry()); |
| 314 | + const cases: LastCycleSource[] = [ |
| 315 | + { sourceId: "s1", provider: "anthropic", model: "claude-opus-4" }, |
| 316 | + { sourceId: "s1", provider: "google-genai", model: "gemini-2.5-pro" }, |
| 317 | + { sourceId: "s1", provider: "openai", model: "gpt-5" }, |
| 318 | + { sourceId: "s1", provider: "openai-compatible", model: "kimi-k2" }, |
| 319 | + { sourceId: "s1", provider: "grok-responses", model: "grok-4.5" }, |
| 320 | + ]; |
| 321 | + for (const source of cases) { |
| 322 | + const adapter = sanitized.resolve(source); |
| 323 | + const without = adapter.buildRequest(USER_ONLY, source.model, {}); |
| 324 | + const withThinking = adapter.buildRequest( |
| 325 | + [...USER_ONLY, THINKING_ONLY_TAIL], |
| 326 | + source.model, |
| 327 | + {}, |
| 328 | + ); |
| 329 | + expect(withThinking.body).not.toEqual(without.body); |
| 330 | + expect(withThinking.body).toContain(THINKING_ONLY_OMITTED); |
| 331 | + } |
| 332 | + }); |
| 333 | + |
| 334 | + it("builds requests for thinking-only and leftover-only assistant turns", () => { |
| 335 | + const adapter = resolveSanitized({ |
| 336 | + sourceId: "s1", |
| 337 | + provider: "anthropic", |
| 338 | + model: "claude-opus-4", |
| 339 | + }); |
| 340 | + const leftoverHistory: ConversationTurn[] = [ |
| 341 | + { |
| 342 | + role: "user", |
| 343 | + content: [{ type: "text", text: "hello" }], |
| 344 | + timestamp: 1, |
| 345 | + }, |
| 346 | + { |
| 347 | + role: "assistant", |
| 348 | + model: "grok-4", |
| 349 | + content: [ |
| 350 | + { type: "thinking", thinking: "pondering" }, |
| 351 | + { type: "redacted_thinking", data: "opaque" }, |
| 352 | + { type: "citation", citedText: "quote", source: {} }, |
| 353 | + ], |
| 354 | + timestamp: 2, |
| 355 | + }, |
| 356 | + ]; |
| 357 | + expect(() => adapter.buildRequest(thinkingOnlyHistory(), "claude-opus-4", {})).not.toThrow(); |
| 358 | + expect(() => adapter.buildRequest(leftoverHistory, "claude-opus-4", {})).not.toThrow(); |
| 359 | + }); |
189 | 360 | }); |
0 commit comments