|
| 1 | +import type { StreamFn } from "@earendil-works/pi-agent-core"; |
1 | 2 | import type { Context, Model } from "@earendil-works/pi-ai"; |
2 | 3 | import { createAssistantMessageEventStream } from "@earendil-works/pi-ai"; |
3 | 4 | import { |
@@ -30,6 +31,10 @@ type ReplayToolCall = { |
30 | 31 | }; |
31 | 32 |
|
32 | 33 | type RegisteredProvider = Awaited<ReturnType<typeof registerSingleProviderPlugin>>; |
| 34 | +type FakeStream = { |
| 35 | + result: () => Promise<unknown>; |
| 36 | + [Symbol.asyncIterator]: () => AsyncIterator<unknown>; |
| 37 | +}; |
33 | 38 |
|
34 | 39 | const emptyUsage = { |
35 | 40 | input: 0, |
@@ -141,6 +146,29 @@ function createPayloadCapturingStream(capture: PayloadCapture, model: OpenAIComp |
141 | 146 | }; |
142 | 147 | } |
143 | 148 |
|
| 149 | +function createFakeStream(params: { events: unknown[]; resultMessage: unknown }): FakeStream { |
| 150 | + return { |
| 151 | + async result() { |
| 152 | + return params.resultMessage; |
| 153 | + }, |
| 154 | + [Symbol.asyncIterator]() { |
| 155 | + return (async function* () { |
| 156 | + for (const event of params.events) { |
| 157 | + yield event; |
| 158 | + } |
| 159 | + })(); |
| 160 | + }, |
| 161 | + }; |
| 162 | +} |
| 163 | + |
| 164 | +function createResultStreamFn(params: { events?: unknown[]; resultMessage: unknown }): StreamFn { |
| 165 | + return () => |
| 166 | + createFakeStream({ |
| 167 | + events: params.events ?? [], |
| 168 | + resultMessage: params.resultMessage, |
| 169 | + }) as ReturnType<StreamFn>; |
| 170 | +} |
| 171 | + |
144 | 172 | function requireThinkingWrapper( |
145 | 173 | wrapper: ReturnType<typeof createMiMoThinkingWrapper>, |
146 | 174 | label: string, |
@@ -312,4 +340,99 @@ describe("xiaomi provider plugin", () => { |
312 | 340 | "reasoning_content", |
313 | 341 | ); |
314 | 342 | }); |
| 343 | + |
| 344 | + it.each(["mimo-v2-pro", "mimo-v2-omni"] as const)( |
| 345 | + "promotes reasoning-only terminal output to visible text for %s", |
| 346 | + async (modelId) => { |
| 347 | + const model = mimoReasoningModel(modelId); |
| 348 | + const wrapped = requireThinkingWrapper( |
| 349 | + createMiMoThinkingWrapper( |
| 350 | + createResultStreamFn({ |
| 351 | + events: [ |
| 352 | + { |
| 353 | + type: "message_end", |
| 354 | + message: { |
| 355 | + role: "assistant", |
| 356 | + content: [{ type: "thinking", thinking: "MiMo final answer" }], |
| 357 | + stopReason: "stop", |
| 358 | + }, |
| 359 | + }, |
| 360 | + ], |
| 361 | + resultMessage: { |
| 362 | + role: "assistant", |
| 363 | + content: [{ type: "thinking", thinking: "MiMo final answer" }], |
| 364 | + stopReason: "stop", |
| 365 | + }, |
| 366 | + }), |
| 367 | + "high", |
| 368 | + ), |
| 369 | + modelId, |
| 370 | + ); |
| 371 | + |
| 372 | + const stream = (await wrapped(model, { messages: [] } as Context, {})) as FakeStream; |
| 373 | + const events: unknown[] = []; |
| 374 | + for await (const event of stream) { |
| 375 | + events.push(event); |
| 376 | + } |
| 377 | + |
| 378 | + expect(events).toEqual([ |
| 379 | + { |
| 380 | + type: "message_end", |
| 381 | + message: { |
| 382 | + role: "assistant", |
| 383 | + content: [{ type: "text", text: "MiMo final answer" }], |
| 384 | + stopReason: "stop", |
| 385 | + }, |
| 386 | + }, |
| 387 | + ]); |
| 388 | + await expect(stream.result()).resolves.toEqual({ |
| 389 | + role: "assistant", |
| 390 | + content: [{ type: "text", text: "MiMo final answer" }], |
| 391 | + stopReason: "stop", |
| 392 | + }); |
| 393 | + }, |
| 394 | + ); |
| 395 | + |
| 396 | + it("does not promote reasoning when the MiMo assistant turn also has text or tool calls", async () => { |
| 397 | + const model = mimoReasoningModel("mimo-v2-pro"); |
| 398 | + const textMessage = { |
| 399 | + role: "assistant", |
| 400 | + content: [ |
| 401 | + { type: "thinking", thinking: "internal" }, |
| 402 | + { type: "text", text: "already visible" }, |
| 403 | + ], |
| 404 | + stopReason: "stop", |
| 405 | + }; |
| 406 | + const toolMessage = { |
| 407 | + role: "assistant", |
| 408 | + content: [{ type: "thinking", thinking: "call reasoning" }, readToolCall], |
| 409 | + stopReason: "toolUse", |
| 410 | + }; |
| 411 | + |
| 412 | + for (const resultMessage of [textMessage, toolMessage]) { |
| 413 | + const wrapped = requireThinkingWrapper( |
| 414 | + createMiMoThinkingWrapper(createResultStreamFn({ resultMessage }), "high"), |
| 415 | + "mixed-content", |
| 416 | + ); |
| 417 | + const stream = (await wrapped(model, { messages: [] } as Context, {})) as FakeStream; |
| 418 | + |
| 419 | + await expect(stream.result()).resolves.toEqual(resultMessage); |
| 420 | + } |
| 421 | + }); |
| 422 | + |
| 423 | + it("does not promote reasoning-only output for newer MiMo replay models", async () => { |
| 424 | + const model = mimoReasoningModel("mimo-v2.5-pro"); |
| 425 | + const resultMessage = { |
| 426 | + role: "assistant", |
| 427 | + content: [{ type: "thinking", thinking: "actual reasoning" }], |
| 428 | + stopReason: "stop", |
| 429 | + }; |
| 430 | + const wrapped = requireThinkingWrapper( |
| 431 | + createMiMoThinkingWrapper(createResultStreamFn({ resultMessage }), "high"), |
| 432 | + "mimo-v2.5-pro", |
| 433 | + ); |
| 434 | + const stream = (await wrapped(model, { messages: [] } as Context, {})) as FakeStream; |
| 435 | + |
| 436 | + await expect(stream.result()).resolves.toEqual(resultMessage); |
| 437 | + }); |
315 | 438 | }); |
0 commit comments