Files
2026-07-13 13:32:57 +08:00

427 lines
15 KiB
TypeScript

// Import the test harness FIRST — installs the resource catalog so the
// chat task functions below register correctly.
import { mockChatAgent } from "../src/v3/test/index.js";
import { describe, expect, it, vi } from "vitest";
import { chat } from "../src/v3/ai.js";
import { simulateReadableStream, streamText, tool } from "ai";
import { MockLanguageModelV3 } from "ai/test";
import type { LanguageModelV3StreamPart } from "@ai-sdk/provider";
import type { ModelMessage, UIMessage } from "ai";
import { z } from "zod";
// ── Helpers ────────────────────────────────────────────────────────────
function textStream(text: string): ReadableStream<LanguageModelV3StreamPart> {
return simulateReadableStream({
chunks: [
{ type: "text-start", id: "t1" },
{ type: "text-delta", id: "t1", delta: text },
{ type: "text-end", id: "t1" },
{
type: "finish",
finishReason: { unified: "stop", raw: "stop" },
usage: {
inputTokens: { total: 10, noCache: 10, cacheRead: undefined, cacheWrite: undefined },
outputTokens: { total: 10, text: 10, reasoning: undefined },
},
},
],
});
}
type Capture = {
skipped?: boolean;
isFinal?: boolean;
handover?: { isFinal: boolean } | null;
uiMessages?: Array<{ id: string; role: string; partTypes: string[] }>;
};
function snapshot(uiMessages: UIMessage[]): Capture["uiMessages"] {
return uiMessages.map((m) => ({
id: m.id,
role: m.role,
partTypes: (m.parts ?? []).map((p) => p.type),
}));
}
// A pure-text partial (the warm step-1 response, isFinal: true).
const PURE_TEXT_PARTIAL: ModelMessage[] = [
{ role: "assistant", content: [{ type: "text", text: "Hi there, hope you're well." }] },
];
// A tool-call partial reshaped server-side into the approval round (isFinal: false).
const TOOL_CALL_PARTIAL: ModelMessage[] = [
{
role: "assistant",
content: [
{ type: "text", text: "let me check the weather" },
{ type: "tool-call", toolCallId: "tc-1", toolName: "weather", input: { city: "tokyo" } },
{
type: "tool-approval-request",
approvalId: "handover-approval-1",
toolCallId: "tc-1",
} as never,
],
},
{
role: "tool",
content: [
{
type: "tool-approval-response",
approvalId: "handover-approval-1",
approved: true,
} as never,
],
},
];
function weatherToolWithExecute(execute: (input: { city: string }) => Promise<unknown>) {
return tool({
description: "Look up weather",
inputSchema: z.object({ city: z.string() }),
execute: execute as never,
});
}
// ── chat.customAgent + headStart handover ───────────────────────────────
describe("chat.customAgent + headStart handover", () => {
it("consumeHandover skip → clean exit, no turn-complete", async () => {
const capture: Capture = {};
const runAfter = vi.fn();
const agent = chat.customAgent({
id: "custom.handover.skip",
run: async (payload) => {
const conversation = new chat.MessageAccumulator();
const { isFinal, skipped } = await conversation.consumeHandover({ payload });
capture.skipped = skipped;
capture.isFinal = isFinal;
if (skipped) return;
runAfter();
await chat.writeTurnComplete();
},
});
const harness = mockChatAgent(agent, { chatId: "t-skip", mode: "handover-prepare" });
try {
await harness.sendHandoverSkip();
await new Promise((r) => setTimeout(r, 20));
expect(capture.skipped).toBe(true);
expect(capture.isFinal).toBe(false);
expect(runAfter).not.toHaveBeenCalled();
expect(harness.allChunks).toHaveLength(0);
} finally {
await harness.close();
}
});
it("consumeHandover isFinal: true (pure text) → partial spliced, no streamText", async () => {
const capture: Capture = {};
const runAfter = vi.fn();
const agent = chat.customAgent({
id: "custom.handover.final",
run: async (payload) => {
const conversation = new chat.MessageAccumulator();
const { isFinal, skipped } = await conversation.consumeHandover({ payload });
capture.skipped = skipped;
capture.isFinal = isFinal;
capture.uiMessages = snapshot(conversation.uiMessages);
if (skipped) return;
if (isFinal) {
await chat.writeTurnComplete();
return;
}
runAfter();
},
});
const harness = mockChatAgent(agent, { chatId: "t-final", mode: "handover-prepare" });
try {
await harness.sendHandover({
partialAssistantMessage: PURE_TEXT_PARTIAL,
messageId: "asst-msg-1",
isFinal: true,
});
await new Promise((r) => setTimeout(r, 20));
expect(capture.skipped).toBe(false);
expect(capture.isFinal).toBe(true);
// The warm step-1 partial is in the accumulator under its messageId.
expect(capture.uiMessages).toEqual([
{ id: "asst-msg-1", role: "assistant", partTypes: ["text"] },
]);
// isFinal means no streamText.
expect(runAfter).not.toHaveBeenCalled();
} finally {
await harness.close();
}
});
it("consumeHandover isFinal: false (tool call) → streamText runs the handed-over tool round", async () => {
const capture: Capture = {};
const toolExecute = vi.fn(async ({ city }: { city: string }) => ({ city, temp: 22 }));
const agent = chat.customAgent({
id: "custom.handover.toolcall",
run: async (payload) => {
const conversation = new chat.MessageAccumulator();
const { isFinal, skipped } = await conversation.consumeHandover({ payload });
capture.skipped = skipped;
capture.isFinal = isFinal;
if (skipped) return;
if (isFinal) {
await chat.writeTurnComplete();
return;
}
const result = streamText({
model: new MockLanguageModelV3({
doStream: async () => ({ stream: textStream("the weather in tokyo is 22°C") }),
}),
messages: conversation.modelMessages,
tools: { weather: weatherToolWithExecute(toolExecute) },
});
await chat.pipeAndCapture(result);
await chat.writeTurnComplete();
},
});
const harness = mockChatAgent(agent, { chatId: "t-tool", mode: "handover-prepare" });
try {
await harness.sendHandover({
partialAssistantMessage: TOOL_CALL_PARTIAL,
messageId: "asst-msg-2",
isFinal: false,
});
// Handover consumed as non-final, and the handed-over tool round resumed:
// the agent-side `execute` ran on the pending tool-call from step 1
// (schema-only-on-warm-handler pattern). The full step-2-text-through-handover
// path is verified end-to-end by the ai-chat-e2e smoke test (T29).
expect(capture.isFinal).toBe(false);
expect(toolExecute).toHaveBeenCalledWith(
expect.objectContaining({ city: "tokyo" }),
expect.anything()
);
} finally {
await harness.close();
}
});
it("addResponse replaces the spliced partial in place when the resume reuses its id", async () => {
// On a non-final handover resume the pipe threads originalMessages, so the
// captured response carries the SAME id as the spliced partial. addResponse
// must replace it, not append a duplicate (else the persisted accumulator
// ends up with two assistant messages — caught live by T29, not the mock pipe).
const capture: Capture = {};
const agent = chat.customAgent({
id: "custom.handover.addresponse-dedup",
run: async (payload) => {
const conversation = new chat.MessageAccumulator();
await conversation.consumeHandover({ payload });
// Simulate the merged step-2 response reusing the partial's id.
await conversation.addResponse({
id: "asst-msg-2",
role: "assistant",
parts: [
{ type: "text", text: "the weather in tokyo is 22°C" },
{
type: "tool-weather",
toolCallId: "tc-1",
state: "output-available",
input: { city: "tokyo" },
output: { city: "tokyo", temp: 22 },
} as never,
],
});
capture.uiMessages = snapshot(conversation.uiMessages);
await chat.writeTurnComplete();
},
});
const harness = mockChatAgent(agent, { chatId: "t-addresp-dedup", mode: "handover-prepare" });
try {
await harness.sendHandover({
partialAssistantMessage: TOOL_CALL_PARTIAL,
messageId: "asst-msg-2",
isFinal: false,
});
await new Promise((r) => setTimeout(r, 20));
// Exactly one assistant message under the handover id — replaced, not doubled.
expect(capture.uiMessages!.filter((m) => m.id === "asst-msg-2")).toHaveLength(1);
expect(capture.uiMessages!.filter((m) => m.role === "assistant")).toHaveLength(1);
// And it carries the merged step-2 content (text + resolved tool output).
expect(capture.uiMessages!.at(-1)?.partTypes).toEqual(["text", "tool-weather"]);
} finally {
await harness.close();
}
});
it("seeds payload.headStartMessages before splicing the partial", async () => {
const capture: Capture = {};
const prior: UIMessage[] = [
{ id: "u-1", role: "user", parts: [{ type: "text", text: "hello" }] },
];
const agent = chat.customAgent({
id: "custom.handover.seed",
run: async (payload) => {
const conversation = new chat.MessageAccumulator();
await conversation.consumeHandover({ payload });
capture.uiMessages = snapshot(conversation.uiMessages);
await chat.writeTurnComplete();
},
});
const harness = mockChatAgent(agent, {
chatId: "t-seed",
mode: "handover-prepare",
headStartMessages: prior,
});
try {
await harness.sendHandover({
partialAssistantMessage: PURE_TEXT_PARTIAL,
messageId: "asst-msg-3",
isFinal: true,
});
await new Promise((r) => setTimeout(r, 20));
// Prior history first, then the warm partial.
expect(capture.uiMessages).toEqual([
{ id: "u-1", role: "user", partTypes: ["text"] },
{ id: "asst-msg-3", role: "assistant", partTypes: ["text"] },
]);
} finally {
await harness.close();
}
});
it("dedups the partial when headStartMessages already carries its messageId", async () => {
const capture: Capture = {};
const prior: UIMessage[] = [
{ id: "u-1", role: "user", parts: [{ type: "text", text: "hello" }] },
// Already-persisted partial under the same id the handover uses.
{
id: "asst-dup",
role: "assistant",
parts: [{ type: "text", text: "Hi there, hope you're well." }],
},
];
const agent = chat.customAgent({
id: "custom.handover.dedup",
run: async (payload) => {
const conversation = new chat.MessageAccumulator();
await conversation.consumeHandover({ payload });
capture.uiMessages = snapshot(conversation.uiMessages);
await chat.writeTurnComplete();
},
});
const harness = mockChatAgent(agent, {
chatId: "t-dedup",
mode: "handover-prepare",
headStartMessages: prior,
});
try {
await harness.sendHandover({
partialAssistantMessage: PURE_TEXT_PARTIAL,
messageId: "asst-dup",
isFinal: true,
});
await new Promise((r) => setTimeout(r, 20));
// Not doubled — still just the two seeded messages.
expect(capture.uiMessages).toHaveLength(2);
expect(capture.uiMessages!.filter((m) => m.id === "asst-dup")).toHaveLength(1);
} finally {
await harness.close();
}
});
});
// ── chat.createSession + headStart handover ──────────────────────────────
describe("chat.createSession + headStart handover", () => {
it("turn.handover.isFinal: true → complete() with no source finalizes the partial", async () => {
const capture: Capture = {};
const runAfter = vi.fn();
const agent = chat.customAgent({
id: "session.handover.final",
run: async (payload) => {
const session = chat.createSession(payload, { signal: new AbortController().signal });
for await (const turn of session) {
capture.handover = turn.handover;
capture.uiMessages = snapshot(turn.uiMessages);
if (turn.handover?.isFinal) {
await turn.complete();
return;
}
runAfter();
return;
}
},
});
const harness = mockChatAgent(agent, { chatId: "s-final", mode: "handover-prepare" });
try {
await harness.sendHandover({
partialAssistantMessage: PURE_TEXT_PARTIAL,
messageId: "asst-msg-4",
isFinal: true,
});
await new Promise((r) => setTimeout(r, 20));
expect(capture.handover).toEqual({ isFinal: true });
expect(capture.uiMessages).toEqual([
{ id: "asst-msg-4", role: "assistant", partTypes: ["text"] },
]);
expect(runAfter).not.toHaveBeenCalled();
} finally {
await harness.close();
}
});
it("turn.handover.isFinal: false → streamText runs the handed-over tool round", async () => {
const capture: Capture = {};
const toolExecute = vi.fn(async ({ city }: { city: string }) => ({ city, temp: 22 }));
const agent = chat.customAgent({
id: "session.handover.toolcall",
run: async (payload) => {
const session = chat.createSession(payload, { signal: new AbortController().signal });
for await (const turn of session) {
capture.handover = turn.handover;
const result = streamText({
model: new MockLanguageModelV3({
doStream: async () => ({ stream: textStream("the weather in tokyo is 22°C") }),
}),
messages: turn.messages,
tools: { weather: weatherToolWithExecute(toolExecute) },
abortSignal: turn.signal,
});
await turn.complete(result);
return;
}
},
});
const harness = mockChatAgent(agent, { chatId: "s-tool", mode: "handover-prepare" });
try {
await harness.sendHandover({
partialAssistantMessage: TOOL_CALL_PARTIAL,
messageId: "asst-msg-5",
isFinal: false,
});
// Surfaced as a non-final handover turn, and the handed-over tool round
// resumed (agent-side execute ran). Full step-2-text path covered by T29.
expect(capture.handover).toEqual({ isFinal: false });
expect(toolExecute).toHaveBeenCalledWith(
expect.objectContaining({ city: "tokyo" }),
expect.anything()
);
} finally {
await harness.close();
}
});
});