feat(sdk): chat.headStart handover for customAgent and createSession (#3963)
## Summary
`chat.headStart` (the warm step-1 fast path) previously handed its
response over only to `chat.agent`. This extends handover to the other
two backends: `chat.customAgent` consumes it with
`conversation.consumeHandover({ payload })` on turn 0, and
`chat.createSession` surfaces it as `turn.handover` (call
`turn.complete()` with no source to finalize a pure-text handover). The
low-level `chat.waitForHandover()` and `accumulator.applyHandover()` are
exported for hand-rolled loops.
It also adds `triggerConfig` to `chat.headStart()` and
`chat.openSession()`, so the auto-triggered handover-prepare run
inherits tags, queue, machine, and the other session run options the
same way `chat.createStartSessionAction()` does. The `chat:{chatId}` tag
is prepended automatically. Because the session is created once on the
first head-start turn (idempotent on the chat id), this is the only
place those options can be set for a head-start chat's lifetime.
## Fix: tool-call resume
When the warm step-1 hands over a pending tool call (rather than pure
text), the agent loop resumes that tool round. For it to merge cleanly
the pipe threads the spliced partial as `originalMessages`, so the
resumed tool-output chunk attaches to the handed-over tool-call instead
of throwing `No tool invocation found`. `MessageAccumulator.addResponse`
now also dedups by id (replace-in-place), so the persisted history
doesn't carry a duplicate assistant message when the resumed response
reuses the partial's id.
Incorporates the `triggerConfig` work from
[#3933](https://github.com/triggerdotdev/trigger.dev/pull/3933) by
@saasjesus, with `createStartSessionAction` extended to also forward
`maxDuration`, `region`, and `lockToVersion` so the two session entry
points stay consistent.
Verified end-to-end against a local environment: handover (pure-text and
tool-call) on both new backends, a `chat.agent` regression pass, and
`triggerConfig` tags and queue landing on the run.
---------
Co-authored-by: saasjesus <armin@chatarmin.com>
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
---
|
||||
"@trigger.dev/sdk": patch
|
||||
---
|
||||
|
||||
`chat.headStart` now works with the `chat.customAgent` and `chat.createSession` backends, not only `chat.agent`. The warm step-1 response hands over to your loop the same way it does for a managed agent.
|
||||
|
||||
In a `chat.customAgent` loop, consume the handover on turn 0:
|
||||
|
||||
```ts
|
||||
const conversation = new chat.MessageAccumulator();
|
||||
const { isFinal, skipped } = await conversation.consumeHandover({ payload });
|
||||
if (skipped) return; // warm handler aborted, so exit without a turn
|
||||
if (isFinal) {
|
||||
await chat.writeTurnComplete(); // step 1 is the response, no streamText
|
||||
} else {
|
||||
const result = streamText({ model, messages: conversation.modelMessages, tools });
|
||||
// Pass originalMessages so the handed-over tool round merges into the
|
||||
// step-1 assistant instead of starting a new message.
|
||||
const response = await chat.pipeAndCapture(result, {
|
||||
originalMessages: conversation.uiMessages,
|
||||
});
|
||||
if (response) await conversation.addResponse(response);
|
||||
}
|
||||
```
|
||||
|
||||
With `chat.createSession`, the iterator surfaces it as `turn.handover`; call `turn.complete()` with no argument on a final handover. The lower-level `chat.waitForHandover()` and `accumulator.applyHandover()` are also exported for hand-rolled loops.
|
||||
@@ -0,0 +1,15 @@
|
||||
---
|
||||
"@trigger.dev/sdk": patch
|
||||
---
|
||||
|
||||
Add `triggerConfig` support to `chat.headStart()` and `chat.openSession()`, so the auto-triggered handover-prepare run inherits tags, queue, machine, and other session trigger options the same way `chat.createStartSessionAction()` does. The `chat:{chatId}` tag is prepended automatically.
|
||||
|
||||
```ts
|
||||
export const POST = chat.headStart({
|
||||
agentId: "my-agent",
|
||||
triggerConfig: { tags: ["org:acme"], queue: "chat" },
|
||||
run: async ({ chat }) => streamText({ ...chat.toStreamTextOptions(), model }),
|
||||
});
|
||||
```
|
||||
|
||||
Because the session is created once on the first head-start turn and is idempotent on the chat id, this is the only place to set those options for a head-start chat's lifetime. `chat.createStartSessionAction()` now also forwards `maxDuration`, `region`, and `lockToVersion` so both session entry points stay consistent.
|
||||
@@ -1761,9 +1761,10 @@ const stopInput: RealtimeDefinedInputStream<{ stop: true; message?: string }> =
|
||||
* with pending tool calls (`"handover"` — agent picks up from tool
|
||||
* execution), or it finished pure-text (`"handover-skip"` — agent
|
||||
* exits cleanly without making an LLM call).
|
||||
* @internal
|
||||
*
|
||||
* Returned by `chat.waitForHandover()` for custom-agent loops.
|
||||
*/
|
||||
type HandoverSignal =
|
||||
export type HandoverSignal =
|
||||
| {
|
||||
kind: "handover";
|
||||
partialAssistantMessage: ModelMessage[];
|
||||
@@ -1808,6 +1809,42 @@ const handoverInput = {
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Wait for a `chat.headStart` handover signal inside a custom-agent loop or
|
||||
* `chat.createSession`. Returns:
|
||||
* - `null` — this run is not a `handover-prepare` boot, or the wait idled out /
|
||||
* the warm handler crashed before signaling. Treat as "no handover".
|
||||
* - `{ kind: "handover-skip" }` — the warm handler aborted; exit without a turn.
|
||||
* - `{ kind: "handover", partialAssistantMessage, messageId?, isFinal }` — splice
|
||||
* the partial (`chat.MessageAccumulator.applyHandover`) and, when `isFinal` is
|
||||
* false, fall through to `streamText` to run the handed-over tool round.
|
||||
*
|
||||
* For the common case prefer `accumulator.consumeHandover()`, which also seeds
|
||||
* `payload.headStartMessages` and applies the partial for you.
|
||||
*
|
||||
* Must be called at turn 0 before any `chat.messages.waitWithIdleTimeout` —
|
||||
* that facade consumes and discards non-message chunks, which would swallow the
|
||||
* handover signal.
|
||||
*/
|
||||
async function waitForHandover(options: {
|
||||
/** The run's wire payload (only `trigger` / `idleTimeoutInSeconds` are read). */
|
||||
payload: { trigger?: string; idleTimeoutInSeconds?: number };
|
||||
idleTimeoutInSeconds?: number;
|
||||
timeout?: string;
|
||||
spanName?: string;
|
||||
}): Promise<HandoverSignal | null> {
|
||||
if (options.payload.trigger !== "handover-prepare") return null;
|
||||
const result = await handoverInput.waitWithIdleTimeout({
|
||||
idleTimeoutInSeconds:
|
||||
options.idleTimeoutInSeconds ?? options.payload.idleTimeoutInSeconds ?? 60,
|
||||
timeout: options.timeout,
|
||||
spanName: options.spanName ?? "waiting for handover signal",
|
||||
});
|
||||
// Non-ok = idle timeout or the warm handler crashed without signaling.
|
||||
if (!result.ok) return null;
|
||||
return result.output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-turn deferred promises. Registered via `chat.defer()`, awaited
|
||||
* before `onTurnComplete` fires. Reset each turn.
|
||||
@@ -1918,6 +1955,31 @@ function synthesizeHandoverUIMessage(
|
||||
} as UIMessage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Splice a head-start handover partial into an accumulating message pair
|
||||
* (model + UI). Dedups by `messageId` against the UI chain (so a hydrated
|
||||
* history that already persisted the partial isn't doubled), then pushes the
|
||||
* partial into `modelMessages` and the synthesized UIMessage into `uiMessages`.
|
||||
* Shared by the `chat.agent` turn-0 splice and `ChatMessageAccumulator.applyHandover`.
|
||||
* @internal
|
||||
*/
|
||||
function spliceHandoverPartial(
|
||||
modelMessages: ModelMessage[],
|
||||
uiMessages: UIMessage[],
|
||||
signal: { partialAssistantMessage: ModelMessage[]; messageId?: string }
|
||||
): void {
|
||||
if (!signal.partialAssistantMessage || signal.partialAssistantMessage.length === 0) {
|
||||
return;
|
||||
}
|
||||
// Skip if the hydrated chain already persisted the partial under this id.
|
||||
const alreadyInChain =
|
||||
signal.messageId !== undefined && uiMessages.some((m) => m.id === signal.messageId);
|
||||
if (alreadyInChain) return;
|
||||
modelMessages.push(...signal.partialAssistantMessage);
|
||||
const partialUI = synthesizeHandoverUIMessage(signal.partialAssistantMessage, signal.messageId);
|
||||
if (partialUI) uiMessages.push(partialUI);
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-turn background context queue. Messages added via `chat.backgroundWork.inject()`
|
||||
* are drained at the next `prepareStep` boundary and appended to the model messages.
|
||||
@@ -6798,22 +6860,10 @@ function chatAgent<
|
||||
// `UIMessageStreamError: No tool invocation found`.
|
||||
const pendingHandoverPartial = locals.get(chatHandoverPartialKey);
|
||||
if (pendingHandoverPartial && pendingHandoverPartial.length > 0) {
|
||||
const handoverMessageId = locals.get(chatHandoverMessageIdKey);
|
||||
// Skip if the hydrated chain already persisted the
|
||||
// partial under the handover messageId.
|
||||
const alreadyInChain =
|
||||
handoverMessageId !== undefined &&
|
||||
accumulatedUIMessages.some((m) => m.id === handoverMessageId);
|
||||
if (!alreadyInChain) {
|
||||
accumulatedMessages.push(...pendingHandoverPartial);
|
||||
const partialUI = synthesizeHandoverUIMessage(
|
||||
pendingHandoverPartial,
|
||||
handoverMessageId
|
||||
);
|
||||
if (partialUI) {
|
||||
accumulatedUIMessages.push(partialUI as TUIMessage);
|
||||
}
|
||||
}
|
||||
spliceHandoverPartial(accumulatedMessages, accumulatedUIMessages, {
|
||||
partialAssistantMessage: pendingHandoverPartial,
|
||||
messageId: locals.get(chatHandoverMessageIdKey),
|
||||
});
|
||||
locals.set(chatHandoverPartialKey, []); // consume once
|
||||
}
|
||||
}
|
||||
@@ -8748,7 +8798,7 @@ async function chatWriteTurnComplete(options?: { publicAccessToken?: string }):
|
||||
*/
|
||||
async function pipeChatAndCapture(
|
||||
source: UIMessageStreamable,
|
||||
options?: { signal?: AbortSignal; spanName?: string }
|
||||
options?: { signal?: AbortSignal; spanName?: string; originalMessages?: UIMessage[] }
|
||||
): Promise<UIMessage | undefined> {
|
||||
let captured: UIMessage | undefined;
|
||||
let resolveOnFinish: () => void;
|
||||
@@ -8759,6 +8809,10 @@ async function pipeChatAndCapture(
|
||||
const resolvedOptions = resolveUIMessageStreamOptions();
|
||||
const uiStream = source.toUIMessageStream({
|
||||
...resolvedOptions,
|
||||
// Thread the prior chain (incl. a spliced handover partial) so a resumed
|
||||
// tool round's tool-output chunks merge into the originating tool-call
|
||||
// instead of throwing "No tool invocation found".
|
||||
...(options?.originalMessages ? { originalMessages: options.originalMessages } : {}),
|
||||
// Stamp a server-generated id on the start chunk, same as chat.agent's
|
||||
// pipe. Without it the AI SDK regenerates the assistant id when a
|
||||
// prepareStep injection (steering) starts a new step mid-stream, and
|
||||
@@ -8844,10 +8898,76 @@ class ChatMessageAccumulator {
|
||||
this.modelMessages = await toModelMessages(uiMessages);
|
||||
}
|
||||
|
||||
/**
|
||||
* Splice a `chat.headStart` handover partial into the accumulator (the warm
|
||||
* step-1 response). Dedups by `messageId` so a seeded/hydrated history that
|
||||
* already carries the partial isn't doubled. Seed any prior history first
|
||||
* (e.g. `setMessages(payload.headStartMessages)`). Low-level — see
|
||||
* `consumeHandover` for the wait+seed+apply convenience.
|
||||
*/
|
||||
applyHandover(signal: { partialAssistantMessage: ModelMessage[]; messageId?: string }): void {
|
||||
spliceHandoverPartial(this.modelMessages, this.uiMessages, signal);
|
||||
}
|
||||
|
||||
/**
|
||||
* One-call `chat.headStart` handover for a custom-agent loop: waits for the
|
||||
* handover signal, seeds prior history from `payload.headStartMessages`,
|
||||
* applies the warm step-1 partial, and reports what to do next.
|
||||
*
|
||||
* Returns `{ isFinal, skipped }`:
|
||||
* - `skipped: true` — not a `handover-prepare` run, the wait idled out, or the
|
||||
* warm handler aborted. Exit the run without a turn.
|
||||
* - `isFinal: true` — step 1 IS the response (pure text). Write turn-complete
|
||||
* and continue; do not call `streamText`.
|
||||
* - `isFinal: false` — fall through to `streamText`, which runs the pending
|
||||
* tool round handed over from step 1.
|
||||
*/
|
||||
async consumeHandover(options: {
|
||||
payload: {
|
||||
trigger?: string;
|
||||
idleTimeoutInSeconds?: number;
|
||||
headStartMessages?: UIMessage[];
|
||||
};
|
||||
idleTimeoutInSeconds?: number;
|
||||
timeout?: string;
|
||||
}): Promise<{ isFinal: boolean; skipped: boolean }> {
|
||||
const signal = await waitForHandover({
|
||||
payload: options.payload,
|
||||
idleTimeoutInSeconds: options.idleTimeoutInSeconds,
|
||||
timeout: options.timeout,
|
||||
});
|
||||
if (!signal || signal.kind === "handover-skip") {
|
||||
return { isFinal: false, skipped: true };
|
||||
}
|
||||
if (options.payload.headStartMessages && options.payload.headStartMessages.length > 0) {
|
||||
await this.setMessages(options.payload.headStartMessages);
|
||||
}
|
||||
this.applyHandover(signal);
|
||||
return { isFinal: signal.isFinal, skipped: false };
|
||||
}
|
||||
|
||||
async addResponse(response: UIMessage): Promise<void> {
|
||||
if (!response.id) {
|
||||
response = { ...response, id: generateMessageId() };
|
||||
}
|
||||
// Tool-approval and handover-resume continuations reuse the trailing
|
||||
// assistant's ID (via originalMessages on the pipe), so the captured
|
||||
// response can carry the same ID as a message already in the chain
|
||||
// (e.g. a spliced handover partial). Replace in place instead of pushing
|
||||
// a duplicate, mirroring the chat.agent accumulator.
|
||||
const existingIdx = this.uiMessages.findIndex((m) => m.id === response.id);
|
||||
if (existingIdx !== -1) {
|
||||
this.uiMessages[existingIdx] = response;
|
||||
try {
|
||||
// Reconvert all model messages since we replaced rather than appended.
|
||||
this.modelMessages = await toModelMessages(
|
||||
this.uiMessages.map((m) => stripProviderMetadata(m))
|
||||
);
|
||||
} catch {
|
||||
// Conversion failed — leave the existing model messages in place
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.uiMessages.push(response);
|
||||
try {
|
||||
const msgs = await toModelMessages([stripProviderMetadata(response)]);
|
||||
@@ -9040,6 +9160,13 @@ export type ChatTurn = {
|
||||
previousTurnUsage?: LanguageModelUsage;
|
||||
/** Cumulative token usage across all completed turns so far. */
|
||||
totalUsage: LanguageModelUsage;
|
||||
/**
|
||||
* Set on the first turn of a `chat.headStart` handover; `null` otherwise.
|
||||
* When `isFinal` is true the warm step-1 IS the response — call
|
||||
* `turn.complete()` with no argument (don't call `streamText`). When false,
|
||||
* call `streamText` as usual; it runs the handed-over tool round.
|
||||
*/
|
||||
handover: { isFinal: boolean } | null;
|
||||
|
||||
/**
|
||||
* Replace accumulated messages (for compaction). Takes UIMessages and
|
||||
@@ -9051,8 +9178,11 @@ export type ChatTurn = {
|
||||
/**
|
||||
* Easy path: pipe stream, capture response, accumulate it,
|
||||
* clean up aborted parts if stopped, and write turn-complete chunk.
|
||||
*
|
||||
* Call with no argument on a head-start final turn (`turn.handover?.isFinal`)
|
||||
* — the warm step-1 partial is already the response, so there's nothing to pipe.
|
||||
*/
|
||||
complete(source: UIMessageStreamable): Promise<UIMessage | undefined>;
|
||||
complete(source?: UIMessageStreamable): Promise<UIMessage | undefined>;
|
||||
|
||||
/**
|
||||
* Manual path: just write turn-complete chunk.
|
||||
@@ -9152,6 +9282,31 @@ function createChatSession(
|
||||
}
|
||||
turn++;
|
||||
|
||||
// Head-start handover: the server triggered this run with
|
||||
// `trigger: "handover-prepare"` and signals the warm step-1 partial on
|
||||
// `session.in`. Wait for it BEFORE any `messagesInput.waitWithIdleTimeout`
|
||||
// (that facade consumes-and-discards non-message chunks and would swallow
|
||||
// the signal). Turn-0 only — continuation boots never carry this trigger.
|
||||
let handoverThisTurn: { isFinal: boolean } | null = null;
|
||||
let pendingHandoverSignal: HandoverSignal | null = null;
|
||||
if (turn === 0 && currentPayload.trigger === "handover-prepare") {
|
||||
const signal = await waitForHandover({
|
||||
payload: currentPayload,
|
||||
idleTimeoutInSeconds:
|
||||
sessionIdleTimeoutOpt ?? currentPayload.idleTimeoutInSeconds ?? idleTimeoutInSeconds,
|
||||
timeout,
|
||||
});
|
||||
if (!signal || signal.kind === "handover-skip" || runSignal.aborted) {
|
||||
stop.cleanup();
|
||||
return { done: true, value: undefined };
|
||||
}
|
||||
pendingHandoverSignal = signal;
|
||||
handoverThisTurn = { isFinal: signal.isFinal };
|
||||
// Rewrite to a normal first-turn message turn so the rest of the loop
|
||||
// (steering setup, addIncoming, turnObj) runs unchanged.
|
||||
currentPayload = { ...currentPayload, trigger: "submit-message", message: undefined };
|
||||
}
|
||||
|
||||
// First turn: wait when the boot payload carries no message.
|
||||
// Preload boots wait for the first real message; continuation
|
||||
// boots (fresh run via `ensureRunForSession` / end-and-continue)
|
||||
@@ -9274,6 +9429,17 @@ function createChatSession(
|
||||
turn
|
||||
);
|
||||
|
||||
// Apply the head-start handover AFTER addIncoming — turn-0 addIncoming
|
||||
// replaces accumulator state, which would wipe a pre-applied splice.
|
||||
// Seed prior history first, then splice the warm step-1 partial.
|
||||
if (pendingHandoverSignal) {
|
||||
const priorHistory = currentPayload.headStartMessages as UIMessage[] | undefined;
|
||||
if (priorHistory && priorHistory.length > 0) {
|
||||
await accumulator.setMessages(priorHistory);
|
||||
}
|
||||
accumulator.applyHandover(pendingHandoverSignal);
|
||||
}
|
||||
|
||||
// chat.requestUpgrade() called before this turn — signal transport and exit
|
||||
if (locals.get(chatUpgradeRequestedKey)) {
|
||||
await writeUpgradeRequiredChunk();
|
||||
@@ -9302,15 +9468,44 @@ function createChatSession(
|
||||
continuation: currentPayload.continuation ?? false,
|
||||
previousTurnUsage,
|
||||
totalUsage: cumulativeUsage,
|
||||
handover: handoverThisTurn,
|
||||
|
||||
async setMessages(uiMessages: UIMessage[]) {
|
||||
await accumulator.setMessages(uiMessages);
|
||||
},
|
||||
|
||||
async complete(source: UIMessageStreamable) {
|
||||
async complete(source?: UIMessageStreamable) {
|
||||
// Head-start final turn: the warm step-1 partial is already spliced
|
||||
// into the accumulator and IS the response — nothing to pipe. Only
|
||||
// valid on a final handover; a missing source on any other turn is a
|
||||
// mistake (it would silently finalize without an assistant response).
|
||||
if (!source) {
|
||||
if (!handoverThisTurn?.isFinal) {
|
||||
throw new Error(
|
||||
"turn.complete() requires a stream source unless turn.handover.isFinal is true"
|
||||
);
|
||||
}
|
||||
const response = accumulator.uiMessages.at(-1);
|
||||
if (!response || response.role !== "assistant") {
|
||||
throw new Error(
|
||||
"turn.complete() could not find the spliced handover response"
|
||||
);
|
||||
}
|
||||
sessionMsgSub.off();
|
||||
await chatWriteTurnComplete();
|
||||
return response;
|
||||
}
|
||||
let response: UIMessage | undefined;
|
||||
try {
|
||||
response = await pipeChatAndCapture(source, { signal: combinedSignal });
|
||||
response = await pipeChatAndCapture(source, {
|
||||
signal: combinedSignal,
|
||||
// On a non-final handover turn, thread the spliced partial so a
|
||||
// resumed tool round's tool-output chunks merge into the
|
||||
// handed-over tool-call. Gated on the handover turn only — a
|
||||
// normal turn must not pass originalMessages (it would merge the
|
||||
// fresh response into the prior assistant message).
|
||||
...(handoverThisTurn ? { originalMessages: accumulator.uiMessages } : {}),
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
if (runSignal.aborted) {
|
||||
@@ -9899,8 +10094,8 @@ function createChatStartSessionAction<TChat extends AnyTask = AnyTask>(
|
||||
// run-list filter by chat works without the customer having to wire it
|
||||
// up. Mirrors the browser-mediated `TriggerChatTransport.doStart` path.
|
||||
const userTags = params.triggerConfig?.tags ?? options?.triggerConfig?.tags ?? [];
|
||||
// Platform cap is 10 tags per run; the auto chat tag takes one slot.
|
||||
const tags = [`chat:${params.chatId}`, ...userTags].slice(0, 10);
|
||||
// SessionTriggerConfig.tags allows at most 5; the auto chat tag takes one slot.
|
||||
const tags = [`chat:${params.chatId}`, ...userTags].slice(0, 5);
|
||||
|
||||
const clientDataMetadata =
|
||||
params.clientData !== undefined ? { metadata: params.clientData } : {};
|
||||
@@ -9928,6 +10123,22 @@ function createChatStartSessionAction<TChat extends AnyTask = AnyTask>(
|
||||
params.triggerConfig?.maxAttempts ?? options?.triggerConfig?.maxAttempts!,
|
||||
}
|
||||
: {}),
|
||||
...(options?.triggerConfig?.maxDuration !== undefined ||
|
||||
params.triggerConfig?.maxDuration !== undefined
|
||||
? {
|
||||
maxDuration:
|
||||
params.triggerConfig?.maxDuration ?? options?.triggerConfig?.maxDuration!,
|
||||
}
|
||||
: {}),
|
||||
...(options?.triggerConfig?.region || params.triggerConfig?.region
|
||||
? { region: params.triggerConfig?.region ?? options?.triggerConfig?.region }
|
||||
: {}),
|
||||
...(options?.triggerConfig?.lockToVersion || params.triggerConfig?.lockToVersion
|
||||
? {
|
||||
lockToVersion:
|
||||
params.triggerConfig?.lockToVersion ?? options?.triggerConfig?.lockToVersion,
|
||||
}
|
||||
: {}),
|
||||
...(options?.triggerConfig?.idleTimeoutInSeconds !== undefined ||
|
||||
params.triggerConfig?.idleTimeoutInSeconds !== undefined
|
||||
? {
|
||||
@@ -10137,6 +10348,13 @@ export const chat = {
|
||||
MessageAccumulator: ChatMessageAccumulator,
|
||||
/** Create a chat session (async iterator). See {@link createChatSession}. */
|
||||
createSession: createChatSession,
|
||||
/**
|
||||
* Wait for a `chat.headStart` handover signal inside a `chat.customAgent`
|
||||
* loop (turn 0). See {@link waitForHandover}. For most loops prefer the
|
||||
* `chat.MessageAccumulator.consumeHandover()` convenience, which also seeds
|
||||
* `payload.headStartMessages` and applies the partial.
|
||||
*/
|
||||
waitForHandover,
|
||||
/**
|
||||
* Store and retrieve a resolved prompt for the current run.
|
||||
*
|
||||
|
||||
@@ -216,6 +216,67 @@ describe("chat.headStart (route handler)", () => {
|
||||
expect(body.triggerConfig.basePayload.idleTimeoutInSeconds).toBe(60);
|
||||
});
|
||||
|
||||
it("merges triggerConfig tags and queue into createSession", async () => {
|
||||
const requests: CapturedRequest[] = [];
|
||||
global.fetch = vi.fn().mockImplementation(async (url: string | URL, init?: RequestInit) => {
|
||||
const urlStr = typeof url === "string" ? url : url.toString();
|
||||
requests.push({ url: urlStr, init });
|
||||
if (urlStr.endsWith("/api/v1/sessions") || urlStr.endsWith("/api/v1/sessions/")) {
|
||||
return createSessionResponse("chat-1");
|
||||
}
|
||||
if (urlStr.includes("/realtime/v1/sessions/") && urlStr.endsWith("/in/append")) {
|
||||
return appendOkResponse();
|
||||
}
|
||||
if (/\/realtime\/v1\/sessions\/[^/]+\/out$/.test(urlStr)) {
|
||||
return new Response(new ReadableStream({ start(c) { c.close(); } }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
});
|
||||
}
|
||||
throw new Error(`Unexpected URL: ${urlStr}`);
|
||||
});
|
||||
|
||||
const handler = chat.headStart({
|
||||
agentId: "test-agent",
|
||||
triggerConfig: {
|
||||
tags: ["org:acme", "agentic-run:xyz"],
|
||||
queue: "my-queue",
|
||||
},
|
||||
run: async ({ chat: chatHelper }) => {
|
||||
return streamText({
|
||||
...chatHelper.toStreamTextOptions(),
|
||||
model: new MockLanguageModelV3({
|
||||
doStream: async () => ({ stream: textStream("hi back") }),
|
||||
}),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
await withApiContext(() =>
|
||||
handler(
|
||||
makeRequest({
|
||||
chatId: "chat-1",
|
||||
trigger: "submit-message",
|
||||
headStartMessages: [{ id: "m1", role: "user", parts: [{ type: "text", text: "hi" }] }],
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
const sessionCreate = requests.find((r) =>
|
||||
r.url.endsWith("/api/v1/sessions") || r.url.endsWith("/api/v1/sessions/")
|
||||
);
|
||||
expect(sessionCreate).toBeDefined();
|
||||
const body = JSON.parse(sessionCreate!.init!.body as string);
|
||||
expect(body.triggerConfig.tags).toEqual([
|
||||
"chat:chat-1",
|
||||
"org:acme",
|
||||
"agentic-run:xyz",
|
||||
]);
|
||||
expect(body.triggerConfig.queue).toBe("my-queue");
|
||||
expect(body.triggerConfig.basePayload.trigger).toBe("handover-prepare");
|
||||
expect(body.triggerConfig.basePayload.chatId).toBe("chat-1");
|
||||
});
|
||||
|
||||
it("dispatches handover with isFinal=true on pure-text finishReason", async () => {
|
||||
const requests: CapturedRequest[] = [];
|
||||
global.fetch = vi.fn().mockImplementation(async (url: string | URL, init?: RequestInit) => {
|
||||
|
||||
@@ -59,6 +59,7 @@ import {
|
||||
SessionStreamInstance,
|
||||
TRIGGER_CONTROL_SUBTYPE,
|
||||
apiClientManager,
|
||||
type SessionTriggerConfig,
|
||||
} from "@trigger.dev/core/v3";
|
||||
// Runtime VALUES via the ESM/CJS shim so the CJS build can `require` ESM-only
|
||||
// `ai@7` (see ../imports/ai-runtime.ts).
|
||||
@@ -195,6 +196,12 @@ export type HeadStartHandlerOptions<TTools extends Record<string, Tool>> = {
|
||||
* exiting. Defaults to 60.
|
||||
*/
|
||||
idleTimeoutInSeconds?: number;
|
||||
/**
|
||||
* Run options for the auto-triggered `handover-prepare` session run —
|
||||
* tags, queue, machine, etc. Mirrors `chat.createStartSessionAction`.
|
||||
* The `chat:{chatId}` tag is prepended automatically.
|
||||
*/
|
||||
triggerConfig?: Partial<SessionTriggerConfig>;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -220,6 +227,7 @@ export const chat = {
|
||||
req,
|
||||
agentId: opts.agentId,
|
||||
idleTimeoutInSeconds: opts.idleTimeoutInSeconds,
|
||||
triggerConfig: opts.triggerConfig,
|
||||
});
|
||||
|
||||
const helper: HeadStartChatHelper<TTools> = {
|
||||
@@ -249,6 +257,7 @@ export const chat = {
|
||||
req: Request;
|
||||
agentId: string;
|
||||
idleTimeoutInSeconds?: number;
|
||||
triggerConfig?: Partial<SessionTriggerConfig>;
|
||||
}): Promise<HeadStartSession> {
|
||||
return openHandoverSession(opts).then((s) => s.handle);
|
||||
},
|
||||
@@ -304,6 +313,7 @@ async function openHandoverSession(opts: {
|
||||
req: Request;
|
||||
agentId: string;
|
||||
idleTimeoutInSeconds?: number;
|
||||
triggerConfig?: Partial<SessionTriggerConfig>;
|
||||
}): Promise<InternalSession> {
|
||||
const wirePayload = (await opts.req.json()) as ChatTaskWirePayload;
|
||||
const chatId = wirePayload.chatId;
|
||||
@@ -323,7 +333,39 @@ async function openHandoverSession(opts: {
|
||||
const modelMessages = await convertToModelMessages(uiMessages);
|
||||
|
||||
const apiClient = resolveApiClient();
|
||||
const idleTimeoutInSeconds = opts.idleTimeoutInSeconds ?? 60;
|
||||
// Top-level `idleTimeoutInSeconds` wins over the one in `triggerConfig`.
|
||||
const idleTimeoutInSeconds =
|
||||
opts.idleTimeoutInSeconds ?? opts.triggerConfig?.idleTimeoutInSeconds ?? 60;
|
||||
|
||||
// Merge the customer's trigger options. `handover-prepare` and `chatId` in
|
||||
// `basePayload` are ours and can't be overridden; the `chat:{chatId}` tag is
|
||||
// prepended (SessionTriggerConfig.tags caps at 5).
|
||||
const userTags = opts.triggerConfig?.tags ?? [];
|
||||
const tags = [`chat:${chatId}`, ...userTags].slice(0, 5);
|
||||
|
||||
const triggerConfig: SessionTriggerConfig = {
|
||||
basePayload: {
|
||||
...(opts.triggerConfig?.basePayload ?? {}),
|
||||
...wirePayload,
|
||||
chatId,
|
||||
trigger: "handover-prepare",
|
||||
idleTimeoutInSeconds,
|
||||
},
|
||||
...(opts.triggerConfig?.machine ? { machine: opts.triggerConfig.machine } : {}),
|
||||
...(opts.triggerConfig?.queue ? { queue: opts.triggerConfig.queue } : {}),
|
||||
tags,
|
||||
...(opts.triggerConfig?.maxAttempts !== undefined
|
||||
? { maxAttempts: opts.triggerConfig.maxAttempts }
|
||||
: {}),
|
||||
...(opts.triggerConfig?.maxDuration !== undefined
|
||||
? { maxDuration: opts.triggerConfig.maxDuration }
|
||||
: {}),
|
||||
...(opts.triggerConfig?.region ? { region: opts.triggerConfig.region } : {}),
|
||||
...(opts.triggerConfig?.lockToVersion
|
||||
? { lockToVersion: opts.triggerConfig.lockToVersion }
|
||||
: {}),
|
||||
idleTimeoutInSeconds,
|
||||
};
|
||||
|
||||
// Create the session and trigger the chat.agent's `handover-prepare`
|
||||
// run atomically. `createSession` is idempotent on `(env, externalId
|
||||
@@ -342,15 +384,7 @@ async function openHandoverSession(opts: {
|
||||
type: "chat.agent",
|
||||
externalId: chatId,
|
||||
taskIdentifier: opts.agentId,
|
||||
triggerConfig: {
|
||||
basePayload: {
|
||||
...wirePayload,
|
||||
chatId,
|
||||
trigger: "handover-prepare",
|
||||
idleTimeoutInSeconds,
|
||||
},
|
||||
idleTimeoutInSeconds,
|
||||
},
|
||||
triggerConfig,
|
||||
});
|
||||
const sessionPublicAccessToken = created.publicAccessToken;
|
||||
|
||||
|
||||
@@ -96,6 +96,42 @@ describe("chat.createStartSessionAction — runtime", () => {
|
||||
expect(lastStartBody?.triggerConfig.basePayload).not.toHaveProperty("metadata");
|
||||
});
|
||||
|
||||
it("prepends chat:{chatId} to triggerConfig.tags and caps at 5", async () => {
|
||||
installStartFixture();
|
||||
|
||||
const start = chat.createStartSessionAction("fake-chat", {
|
||||
triggerConfig: {
|
||||
tags: ["org:acme", "a", "b", "c", "d", "e"],
|
||||
},
|
||||
});
|
||||
await start({ chatId: "chat-tags" });
|
||||
|
||||
expect(lastStartBody?.triggerConfig.tags).toEqual([
|
||||
"chat:chat-tags",
|
||||
"org:acme",
|
||||
"a",
|
||||
"b",
|
||||
"c",
|
||||
]);
|
||||
});
|
||||
|
||||
it("forwards maxDuration, region, and lockToVersion from triggerConfig", async () => {
|
||||
installStartFixture();
|
||||
|
||||
const start = chat.createStartSessionAction("fake-chat", {
|
||||
triggerConfig: {
|
||||
maxDuration: 120,
|
||||
region: "us-east-1",
|
||||
lockToVersion: "20260101.1",
|
||||
},
|
||||
});
|
||||
await start({ chatId: "chat-parity" });
|
||||
|
||||
expect(lastStartBody?.triggerConfig.maxDuration).toBe(120);
|
||||
expect(lastStartBody?.triggerConfig.region).toBe("us-east-1");
|
||||
expect(lastStartBody?.triggerConfig.lockToVersion).toBe("20260101.1");
|
||||
});
|
||||
|
||||
it("keeps session-level metadata distinct from per-turn clientData", async () => {
|
||||
installStartFixture();
|
||||
|
||||
|
||||
@@ -0,0 +1,414 @@
|
||||
// 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