chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,426 @@
|
||||
// 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user