feat(chat): add compaction option, pendingMessages steering, and usePendingMessages hook
This commit is contained in:
@@ -600,6 +600,102 @@ export type ChatTaskCompactionOptions = {
|
||||
/** @internal */
|
||||
const chatTaskCompactionKey = locals.create<ChatTaskCompactionOptions>("chat.taskCompaction");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pending messages — mid-execution message injection via prepareStep
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Event passed to `shouldInject` and `prepareMessages` callbacks.
|
||||
*/
|
||||
export type PendingMessagesBatchEvent = {
|
||||
/** All pending UI messages that arrived during streaming (batch). */
|
||||
messages: UIMessage[];
|
||||
/** Current model messages in the conversation. */
|
||||
modelMessages: ModelMessage[];
|
||||
/** Completed steps so far. */
|
||||
steps: CompactionStep[];
|
||||
/** Current step number (0-indexed). */
|
||||
stepNumber: number;
|
||||
/** Chat session ID. */
|
||||
chatId: string;
|
||||
/** Current turn number (0-indexed). */
|
||||
turn: number;
|
||||
/** Custom data from the frontend. */
|
||||
clientData?: unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* Event passed to `onReceived` callback (per-message, as they arrive).
|
||||
*/
|
||||
export type PendingMessageReceivedEvent = {
|
||||
/** The UI message that arrived during streaming. */
|
||||
message: UIMessage;
|
||||
/** Chat session ID. */
|
||||
chatId: string;
|
||||
/** Current turn number (0-indexed). */
|
||||
turn: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Event passed to `onInjected` callback (batch, after injection).
|
||||
*/
|
||||
export type PendingMessagesInjectedEvent = {
|
||||
/** All UI messages that were injected. */
|
||||
messages: UIMessage[];
|
||||
/** The model messages that were injected. */
|
||||
injectedModelMessages: ModelMessage[];
|
||||
/** Chat session ID. */
|
||||
chatId: string;
|
||||
/** Current turn number (0-indexed). */
|
||||
turn: number;
|
||||
/** Step number where injection occurred. */
|
||||
stepNumber: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Options for the `pendingMessages` field on `chat.task()`, `chat.createSession()`,
|
||||
* or `ChatMessageAccumulator`.
|
||||
*
|
||||
* Configures how messages that arrive during streaming are handled. When
|
||||
* `shouldInject` is provided and returns `true`, the full batch of pending
|
||||
* messages is injected between tool-call steps via `prepareStep`.
|
||||
* Otherwise, messages queue for the next turn.
|
||||
*/
|
||||
export type PendingMessagesOptions = {
|
||||
/**
|
||||
* Decide whether to inject pending messages between tool-call steps.
|
||||
* Called once per step boundary with the full batch of pending messages.
|
||||
* If absent, no injection happens — messages only queue for the next turn.
|
||||
*/
|
||||
shouldInject?: (event: PendingMessagesBatchEvent) => boolean | Promise<boolean>;
|
||||
/**
|
||||
* Transform the batch of pending messages before injection.
|
||||
* Return the model messages to inject.
|
||||
* Default: convert each UI message via `convertToModelMessages`.
|
||||
*/
|
||||
prepare?: (event: PendingMessagesBatchEvent) => ModelMessage[] | Promise<ModelMessage[]>;
|
||||
/** Called when a message arrives during streaming (per-message). */
|
||||
onReceived?: (event: PendingMessageReceivedEvent) => void | Promise<void>;
|
||||
/** Called after a batch of messages is injected via `prepareStep`. */
|
||||
onInjected?: (event: PendingMessagesInjectedEvent) => void | Promise<void>;
|
||||
};
|
||||
|
||||
/**
|
||||
* The data part type used to signal that pending messages were injected
|
||||
* between tool-call steps. The frontend can match on this to render
|
||||
* injection points inline in the assistant response.
|
||||
*/
|
||||
export const PENDING_MESSAGE_INJECTED_TYPE = "data-pending-message-injected" as const;
|
||||
|
||||
/** @internal */
|
||||
type SteeringQueueEntry = { uiMessage: UIMessage; modelMessages: ModelMessage[] };
|
||||
/** @internal */
|
||||
const chatPendingMessagesKey = locals.create<PendingMessagesOptions>("chat.pendingMessages");
|
||||
/** @internal */
|
||||
const chatSteeringQueueKey = locals.create<SteeringQueueEntry[]>("chat.steeringQueue");
|
||||
/** @internal — IDs of messages that were successfully injected via prepareStep */
|
||||
const chatInjectedMessageIdsKey = locals.create<Set<string>>("chat.injectedMessageIds");
|
||||
|
||||
/**
|
||||
* Event passed to the `prepareMessages` hook.
|
||||
*/
|
||||
@@ -1009,6 +1105,129 @@ function chatCompactionStep(options: CompactionOptions): (args: { messages: Mode
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Steering queue drain — shared by toStreamTextOptions, session, accumulator
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Drain the steering queue as a batch. Calls `shouldInject` once with all
|
||||
* pending messages. If it returns true, calls `prepareMessages` once to
|
||||
* transform the batch, then clears the queue.
|
||||
* Returns the model messages to inject (empty if none).
|
||||
* @internal
|
||||
*/
|
||||
async function drainSteeringQueue(
|
||||
config: PendingMessagesOptions,
|
||||
messages: ModelMessage[],
|
||||
steps: CompactionStep[],
|
||||
queueOverride?: SteeringQueueEntry[],
|
||||
): Promise<ModelMessage[]> {
|
||||
const queue = queueOverride ?? locals.get(chatSteeringQueueKey);
|
||||
if (!queue || queue.length === 0) return [];
|
||||
|
||||
const ctx = locals.get(chatTurnContextKey);
|
||||
const stepNumber = steps.length - 1;
|
||||
const uiMessages = queue.map((e) => e.uiMessage);
|
||||
|
||||
const batchEvent: PendingMessagesBatchEvent = {
|
||||
messages: uiMessages,
|
||||
modelMessages: messages,
|
||||
steps,
|
||||
stepNumber,
|
||||
chatId: ctx?.chatId ?? "",
|
||||
turn: ctx?.turn ?? 0,
|
||||
clientData: ctx?.clientData,
|
||||
};
|
||||
|
||||
// Call shouldInject once for the whole batch
|
||||
const shouldInject = config.shouldInject
|
||||
? await config.shouldInject(batchEvent)
|
||||
: false;
|
||||
|
||||
if (!shouldInject) return [];
|
||||
|
||||
// Extract message texts for span attributes
|
||||
const messageTexts = uiMessages.map((m) =>
|
||||
(m.parts ?? []).filter((p: any) => p.type === "text").map((p: any) => p.text).join("") || ""
|
||||
);
|
||||
const previewText = messageTexts.length === 1
|
||||
? messageTexts[0]!.slice(0, 80)
|
||||
: `${queue.length} messages`;
|
||||
|
||||
return tracer.startActiveSpan(
|
||||
"pending message injected",
|
||||
async () => {
|
||||
// Transform the batch — default: concatenate all pre-converted model messages
|
||||
const injected = config.prepare
|
||||
? await config.prepare(batchEvent)
|
||||
: queue.flatMap((e) => e.modelMessages);
|
||||
|
||||
// Clear the queue and record injected IDs
|
||||
queue.length = 0;
|
||||
const injectedIds = locals.get(chatInjectedMessageIdsKey);
|
||||
if (injectedIds) {
|
||||
for (const m of uiMessages) injectedIds.add(m.id);
|
||||
}
|
||||
|
||||
// Write injection confirmation chunk to the stream so the frontend
|
||||
// knows which messages were injected and where in the response.
|
||||
if (injected.length > 0) {
|
||||
try {
|
||||
const { waitUntilComplete } = streams.writer(CHAT_STREAM_KEY, {
|
||||
collapsed: true,
|
||||
execute: ({ write }) => {
|
||||
write({
|
||||
type: PENDING_MESSAGE_INJECTED_TYPE,
|
||||
id: generateMessageId(),
|
||||
data: {
|
||||
messageIds: uiMessages.map((m) => m.id),
|
||||
messages: uiMessages.map((m, idx) => ({
|
||||
id: m.id,
|
||||
text: messageTexts[idx] ?? "",
|
||||
})),
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
await waitUntilComplete();
|
||||
} catch { /* non-fatal — stream write failed */ }
|
||||
}
|
||||
|
||||
// Fire onInjected callback
|
||||
if (config.onInjected && injected.length > 0) {
|
||||
try {
|
||||
await config.onInjected({
|
||||
messages: uiMessages,
|
||||
injectedModelMessages: injected,
|
||||
chatId: ctx?.chatId ?? "",
|
||||
turn: ctx?.turn ?? 0,
|
||||
stepNumber,
|
||||
});
|
||||
} catch { /* non-fatal */ }
|
||||
}
|
||||
|
||||
return injected;
|
||||
},
|
||||
{
|
||||
attributes: {
|
||||
[SemanticInternalAttributes.STYLE_ICON]: "tabler-message-forward",
|
||||
"pending.message_count": uiMessages.length,
|
||||
"pending.step_number": stepNumber,
|
||||
"pending.messages": messageTexts,
|
||||
...(ctx?.chatId ? { "pending.chat_id": ctx.chatId } : {}),
|
||||
...(ctx?.turn != null ? { "pending.turn": ctx.turn } : {}),
|
||||
...accessoryAttributes({
|
||||
items: [
|
||||
{ text: `${uiMessages.length} message${uiMessages.length === 1 ? "" : "s"}`, variant: "normal" },
|
||||
{ text: `between steps ${stepNumber} and ${stepNumber + 1}`, variant: "normal" },
|
||||
],
|
||||
style: "codepath",
|
||||
}),
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// chat.isCompactionSafe — check if it's safe to compact messages
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1143,29 +1362,50 @@ function toStreamTextOptions(options?: ToStreamTextOptionsOptions): Record<strin
|
||||
const telemetry = prompt.toAISDKTelemetry(options?.telemetry);
|
||||
Object.assign(result, telemetry);
|
||||
|
||||
// Auto-inject prepareStep when task-level compaction is configured.
|
||||
// We build a custom prepareStep instead of using chatCompactionStep so we
|
||||
// can pass the enriched SummarizeEvent to taskCompaction.summarize.
|
||||
// Auto-inject prepareStep when compaction or pendingMessages is configured.
|
||||
const taskCompaction = locals.get(chatTaskCompactionKey);
|
||||
if (taskCompaction) {
|
||||
const taskPendingMessages = locals.get(chatPendingMessagesKey);
|
||||
|
||||
if (taskCompaction || taskPendingMessages) {
|
||||
result.prepareStep = async ({ messages, steps }: { messages: ModelMessage[]; steps: CompactionStep[] }) => {
|
||||
const compactResult = await chatCompact(messages, steps, {
|
||||
shouldCompact: taskCompaction.shouldCompact,
|
||||
summarize: (msgs) => {
|
||||
const ctx = locals.get(chatTurnContextKey);
|
||||
const lastStep = steps.at(-1);
|
||||
return taskCompaction.summarize({
|
||||
messages: msgs,
|
||||
usage: lastStep?.usage,
|
||||
source: "inner",
|
||||
stepNumber: steps.length - 1,
|
||||
chatId: ctx?.chatId,
|
||||
turn: ctx?.turn,
|
||||
clientData: ctx?.clientData,
|
||||
});
|
||||
},
|
||||
});
|
||||
return compactResult.type === "skipped" ? undefined : compactResult;
|
||||
let resultMessages: ModelMessage[] | undefined;
|
||||
|
||||
// 1. Compaction
|
||||
if (taskCompaction) {
|
||||
const compactResult = await chatCompact(messages, steps, {
|
||||
shouldCompact: taskCompaction.shouldCompact,
|
||||
summarize: (msgs) => {
|
||||
const ctx = locals.get(chatTurnContextKey);
|
||||
const lastStep = steps.at(-1);
|
||||
return taskCompaction.summarize({
|
||||
messages: msgs,
|
||||
usage: lastStep?.usage,
|
||||
source: "inner",
|
||||
stepNumber: steps.length - 1,
|
||||
chatId: ctx?.chatId,
|
||||
turn: ctx?.turn,
|
||||
clientData: ctx?.clientData,
|
||||
});
|
||||
},
|
||||
});
|
||||
if (compactResult.type !== "skipped") {
|
||||
resultMessages = compactResult.messages;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Pending message injection (steering)
|
||||
if (taskPendingMessages) {
|
||||
const injected = await drainSteeringQueue(
|
||||
taskPendingMessages,
|
||||
resultMessages ?? messages,
|
||||
steps,
|
||||
);
|
||||
if (injected.length > 0) {
|
||||
resultMessages = [...(resultMessages ?? messages), ...injected];
|
||||
}
|
||||
}
|
||||
|
||||
return resultMessages ? { messages: resultMessages } : undefined;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1590,7 +1830,23 @@ export type ChatTaskOptions<
|
||||
compaction?: ChatTaskCompactionOptions;
|
||||
|
||||
/**
|
||||
* Called after the stream closes for this turn. Use this to persist the
|
||||
* Configure how messages that arrive during streaming are handled.
|
||||
*
|
||||
* By default, messages queue for the next turn. When `shouldInject` is provided
|
||||
* and returns `true`, messages are injected between tool-call steps via
|
||||
* `prepareStep` — allowing users to steer the agent mid-execution.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* pendingMessages: {
|
||||
* shouldInject: ({ steps }) => steps.length > 0,
|
||||
* onReceived: ({ message }) => logger.info("Steering message received"),
|
||||
* },
|
||||
* ```
|
||||
*/
|
||||
pendingMessages?: PendingMessagesOptions;
|
||||
|
||||
/**
|
||||
* conversation to your database after each assistant response.
|
||||
*
|
||||
* @example
|
||||
@@ -1756,6 +2012,7 @@ function chatTask<
|
||||
onBeforeTurnComplete,
|
||||
onCompacted,
|
||||
compaction,
|
||||
pendingMessages: pendingMessagesConfig,
|
||||
prepareMessages,
|
||||
onTurnComplete,
|
||||
maxTurns = 100,
|
||||
@@ -1799,6 +2056,10 @@ function chatTask<
|
||||
locals.set(chatTaskCompactionKey, compaction);
|
||||
}
|
||||
|
||||
if (pendingMessagesConfig) {
|
||||
locals.set(chatPendingMessagesKey, pendingMessagesConfig);
|
||||
}
|
||||
|
||||
let currentWirePayload = payload;
|
||||
const continuation = payload.continuation ?? false;
|
||||
const previousRunId = payload.previousRunId;
|
||||
@@ -1948,6 +2209,8 @@ function chatTask<
|
||||
locals.set(chatPipeCountKey, 0);
|
||||
locals.set(chatDeferKey, new Set());
|
||||
locals.set(chatCompactionStateKey, undefined);
|
||||
locals.set(chatSteeringQueueKey, []);
|
||||
locals.set(chatInjectedMessageIdsKey, new Set());
|
||||
|
||||
// Store chat context for auto-detection by ai.tool subtasks
|
||||
locals.set(chatTurnContextKey, {
|
||||
@@ -1969,7 +2232,39 @@ function chatTask<
|
||||
|
||||
// Buffer messages that arrive during streaming
|
||||
const pendingMessages: ChatTaskWirePayload[] = [];
|
||||
const msgSub = messagesInput.on((msg) => {
|
||||
const pmConfig = locals.get(chatPendingMessagesKey);
|
||||
const msgSub = messagesInput.on(async (msg) => {
|
||||
// If pendingMessages is configured, route to the steering queue
|
||||
// instead of the wire buffer. The frontend handles re-sending
|
||||
// non-injected messages via sendMessage on turn complete.
|
||||
if (pmConfig) {
|
||||
const lastUIMessage = msg.messages?.[msg.messages.length - 1];
|
||||
if (lastUIMessage) {
|
||||
if (pmConfig.onReceived) {
|
||||
try {
|
||||
await pmConfig.onReceived({
|
||||
message: lastUIMessage,
|
||||
chatId: currentWirePayload.chatId,
|
||||
turn,
|
||||
});
|
||||
} catch { /* non-fatal */ }
|
||||
}
|
||||
|
||||
try {
|
||||
const queue = locals.get(chatSteeringQueueKey) ?? [];
|
||||
// Deduplicate by message ID — guards against double-sends
|
||||
if (lastUIMessage.id && queue.some((e) => e.uiMessage.id === lastUIMessage.id)) {
|
||||
return;
|
||||
}
|
||||
const modelMsgs = await toModelMessages([lastUIMessage]);
|
||||
queue.push({ uiMessage: lastUIMessage, modelMessages: modelMsgs });
|
||||
locals.set(chatSteeringQueueKey, queue);
|
||||
} catch { /* conversion failed — skip steering queue */ }
|
||||
}
|
||||
return; // Don't add to wire buffer — frontend handles non-injected case
|
||||
}
|
||||
|
||||
// No pendingMessages config — standard wire buffer for next turn
|
||||
pendingMessages.push(msg);
|
||||
});
|
||||
|
||||
@@ -2508,7 +2803,8 @@ function chatTask<
|
||||
);
|
||||
}
|
||||
|
||||
// If messages arrived during streaming, use the first one immediately
|
||||
// If messages arrived during streaming (without pendingMessages config),
|
||||
// use the first one immediately as the next turn.
|
||||
if (pendingMessages.length > 0) {
|
||||
currentWirePayload = pendingMessages[0]!;
|
||||
return "continue";
|
||||
@@ -2909,9 +3205,12 @@ class ChatMessageAccumulator {
|
||||
modelMessages: ModelMessage[] = [];
|
||||
uiMessages: UIMessage[] = [];
|
||||
private _compaction?: ChatTaskCompactionOptions;
|
||||
private _pendingMessages?: PendingMessagesOptions;
|
||||
private _steeringQueue: SteeringQueueEntry[] = [];
|
||||
|
||||
constructor(options?: { compaction?: ChatTaskCompactionOptions }) {
|
||||
constructor(options?: { compaction?: ChatTaskCompactionOptions; pendingMessages?: PendingMessagesOptions }) {
|
||||
this._compaction = options?.compaction;
|
||||
this._pendingMessages = options?.pendingMessages;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2965,19 +3264,73 @@ class ChatMessageAccumulator {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a `prepareStep` function for inner-loop compaction.
|
||||
* Only available when `compaction` was provided to the constructor.
|
||||
* Pass the result to `streamText({ prepareStep: conversation.prepareStep() })`.
|
||||
* Queue a message for injection via `prepareStep`. Call from a
|
||||
* `messagesInput.on()` listener when a message arrives during streaming.
|
||||
*/
|
||||
steer(message: UIMessage, modelMessages?: ModelMessage[]): void {
|
||||
if (modelMessages) {
|
||||
this._steeringQueue.push({ uiMessage: message, modelMessages });
|
||||
} else {
|
||||
// Defer conversion — will be done in prepareStep if needed
|
||||
this._steeringQueue.push({ uiMessage: message, modelMessages: [] });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue a message for injection, converting to model messages automatically.
|
||||
*/
|
||||
async steerAsync(message: UIMessage): Promise<void> {
|
||||
const modelMsgs = await toModelMessages([message]);
|
||||
this._steeringQueue.push({ uiMessage: message, modelMessages: modelMsgs });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get and clear unconsumed steering messages.
|
||||
*/
|
||||
drainSteering(): UIMessage[] {
|
||||
const result = this._steeringQueue.map((e) => e.uiMessage);
|
||||
this._steeringQueue = [];
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a `prepareStep` function that handles both compaction and
|
||||
* pending message injection. Pass to `streamText({ prepareStep: conversation.prepareStep() })`.
|
||||
*/
|
||||
prepareStep(): ((args: { messages: ModelMessage[]; steps: CompactionStep[] }) => Promise<{ messages: ModelMessage[] } | undefined>) | undefined {
|
||||
if (!this._compaction) return undefined;
|
||||
if (!this._compaction && !this._pendingMessages) return undefined;
|
||||
const comp = this._compaction;
|
||||
const pm = this._pendingMessages;
|
||||
const queue = this._steeringQueue;
|
||||
|
||||
return async ({ messages, steps }) => {
|
||||
const result = await chatCompact(messages, steps, {
|
||||
shouldCompact: comp.shouldCompact,
|
||||
summarize: (msgs) => comp.summarize({ messages: msgs, source: "inner" }),
|
||||
});
|
||||
return result.type === "skipped" ? undefined : result;
|
||||
let resultMessages: ModelMessage[] | undefined;
|
||||
|
||||
// 1. Compaction
|
||||
if (comp) {
|
||||
const result = await chatCompact(messages, steps, {
|
||||
shouldCompact: comp.shouldCompact,
|
||||
summarize: (msgs) => comp.summarize({ messages: msgs, source: "inner" }),
|
||||
});
|
||||
if (result.type !== "skipped") {
|
||||
resultMessages = result.messages;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Pending message injection
|
||||
if (pm && queue.length > 0) {
|
||||
const injected = await drainSteeringQueue(
|
||||
pm,
|
||||
resultMessages ?? messages,
|
||||
steps,
|
||||
queue,
|
||||
);
|
||||
if (injected.length > 0) {
|
||||
resultMessages = [...(resultMessages ?? messages), ...injected];
|
||||
}
|
||||
}
|
||||
|
||||
return resultMessages ? { messages: resultMessages } : undefined;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3058,6 +3411,8 @@ export type ChatSessionOptions = {
|
||||
maxTurns?: number;
|
||||
/** Automatic context compaction — same options as `chat.task({ compaction })`. */
|
||||
compaction?: ChatTaskCompactionOptions;
|
||||
/** Configure mid-execution message injection — same options as `chat.task({ pendingMessages })`. */
|
||||
pendingMessages?: PendingMessagesOptions;
|
||||
};
|
||||
|
||||
export type ChatTurn = {
|
||||
@@ -3108,6 +3463,13 @@ export type ChatTurn = {
|
||||
* Use with `chat.pipeAndCapture` when you need control between pipe and done.
|
||||
*/
|
||||
addResponse(response: UIMessage): Promise<void>;
|
||||
|
||||
/**
|
||||
* Returns a `prepareStep` function that handles both compaction and
|
||||
* pending message injection. Pass to `streamText({ prepareStep: turn.prepareStep() })`.
|
||||
* Only needed when not using `chat.toStreamTextOptions()` (which auto-injects it).
|
||||
*/
|
||||
prepareStep(): ((args: { messages: ModelMessage[]; steps: CompactionStep[] }) => Promise<{ messages: ModelMessage[] } | undefined>) | undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -3151,6 +3513,7 @@ function createChatSession(
|
||||
timeout = "1h",
|
||||
maxTurns = 100,
|
||||
compaction: sessionCompaction,
|
||||
pendingMessages: sessionPendingMessages,
|
||||
} = options;
|
||||
|
||||
return {
|
||||
@@ -3203,6 +3566,45 @@ function createChatSession(
|
||||
// Reset stop signal for this turn
|
||||
stop.reset();
|
||||
|
||||
// Set up steering queue and pending messages config in locals
|
||||
// so toStreamTextOptions() auto-injects prepareStep for steering
|
||||
const turnSteeringQueue: SteeringQueueEntry[] = [];
|
||||
locals.set(chatSteeringQueueKey, turnSteeringQueue);
|
||||
if (sessionPendingMessages) {
|
||||
locals.set(chatPendingMessagesKey, sessionPendingMessages);
|
||||
}
|
||||
locals.set(chatTurnContextKey, {
|
||||
chatId: currentPayload.chatId,
|
||||
turn,
|
||||
continuation: currentPayload.continuation ?? false,
|
||||
clientData: currentPayload.metadata,
|
||||
});
|
||||
|
||||
// Listen for messages during streaming (steering + next-turn buffer)
|
||||
const sessionPendingWire: ChatTaskWirePayload[] = [];
|
||||
const sessionMsgSub = messagesInput.on(async (msg) => {
|
||||
sessionPendingWire.push(msg);
|
||||
|
||||
if (sessionPendingMessages) {
|
||||
const lastUIMessage = msg.messages?.[msg.messages.length - 1];
|
||||
if (lastUIMessage) {
|
||||
if (sessionPendingMessages.onReceived) {
|
||||
try {
|
||||
await sessionPendingMessages.onReceived({
|
||||
message: lastUIMessage,
|
||||
chatId: currentPayload.chatId,
|
||||
turn,
|
||||
});
|
||||
} catch { /* non-fatal */ }
|
||||
}
|
||||
try {
|
||||
const modelMsgs = await toModelMessages([lastUIMessage]);
|
||||
turnSteeringQueue.push({ uiMessage: lastUIMessage, modelMessages: modelMsgs });
|
||||
} catch { /* non-fatal */ }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Accumulate messages
|
||||
const messages = await accumulator.addIncoming(
|
||||
currentPayload.messages,
|
||||
@@ -3237,6 +3639,7 @@ function createChatSession(
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
if (runSignal.aborted) {
|
||||
// Full cancel — don't accumulate
|
||||
sessionMsgSub.off();
|
||||
await chatWriteTurnComplete();
|
||||
return undefined;
|
||||
}
|
||||
@@ -3310,6 +3713,7 @@ function createChatSession(
|
||||
}
|
||||
}
|
||||
|
||||
sessionMsgSub.off();
|
||||
await chatWriteTurnComplete();
|
||||
return response;
|
||||
},
|
||||
@@ -3319,8 +3723,43 @@ function createChatSession(
|
||||
},
|
||||
|
||||
async done() {
|
||||
sessionMsgSub.off();
|
||||
await chatWriteTurnComplete();
|
||||
},
|
||||
|
||||
prepareStep() {
|
||||
const hasCompaction = !!sessionCompaction;
|
||||
const hasPending = !!sessionPendingMessages;
|
||||
if (!hasCompaction && !hasPending) return undefined;
|
||||
|
||||
return async ({ messages: stepMsgs, steps }: { messages: ModelMessage[]; steps: CompactionStep[] }) => {
|
||||
let resultMessages: ModelMessage[] | undefined;
|
||||
|
||||
if (sessionCompaction) {
|
||||
const compactResult = await chatCompact(stepMsgs, steps, {
|
||||
shouldCompact: sessionCompaction.shouldCompact,
|
||||
summarize: (msgs) => sessionCompaction.summarize({ messages: msgs, source: "inner" }),
|
||||
});
|
||||
if (compactResult.type !== "skipped") {
|
||||
resultMessages = compactResult.messages;
|
||||
}
|
||||
}
|
||||
|
||||
if (sessionPendingMessages) {
|
||||
const injected = await drainSteeringQueue(
|
||||
sessionPendingMessages,
|
||||
resultMessages ?? stepMsgs,
|
||||
steps,
|
||||
turnSteeringQueue,
|
||||
);
|
||||
if (injected.length > 0) {
|
||||
resultMessages = [...(resultMessages ?? stepMsgs), ...injected];
|
||||
}
|
||||
}
|
||||
|
||||
return resultMessages ? { messages: resultMessages } : undefined;
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
return { done: false, value: turnObj };
|
||||
|
||||
@@ -23,13 +23,14 @@
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
TriggerChatTransport,
|
||||
type TriggerChatTransportOptions,
|
||||
} from "./chat.js";
|
||||
import type { AnyTask, TaskIdentifier } from "@trigger.dev/core/v3";
|
||||
import type { InferChatClientData } from "./ai.js";
|
||||
import { PENDING_MESSAGE_INJECTED_TYPE, type InferChatClientData } from "./ai.js";
|
||||
import type { UIMessage, ChatRequestOptions } from "ai";
|
||||
|
||||
/**
|
||||
* Options for `useTriggerChatTransport`, with a type-safe `task` field.
|
||||
@@ -93,3 +94,252 @@ export function useTriggerChatTransport<TTask extends AnyTask = AnyTask>(
|
||||
|
||||
return ref.current;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// usePendingMessages — manage steering messages during streaming
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** A pending message tracked by `usePendingMessages`. */
|
||||
export type PendingMessage = {
|
||||
id: string;
|
||||
text: string;
|
||||
/** How this message is being handled. */
|
||||
mode: "steering" | "queued";
|
||||
/** Whether the backend confirmed this message was injected mid-response. */
|
||||
injected: boolean;
|
||||
};
|
||||
|
||||
/** Options for `usePendingMessages`. */
|
||||
export type UsePendingMessagesOptions = {
|
||||
/** The chat transport instance. */
|
||||
transport: TriggerChatTransport;
|
||||
/** The chat session ID. */
|
||||
chatId: string;
|
||||
/** The current useChat status. */
|
||||
status: string;
|
||||
/** The current messages from useChat. */
|
||||
messages: UIMessage[];
|
||||
/** The setMessages function from useChat. */
|
||||
setMessages: (fn: UIMessage[] | ((prev: UIMessage[]) => UIMessage[])) => void;
|
||||
/** The sendMessage function from useChat. */
|
||||
sendMessage: (message: { text: string }, options?: ChatRequestOptions) => void;
|
||||
/** Metadata to include when sending (e.g. `{ model }` for model selection). */
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
/** A message embedded in an injection point data part. */
|
||||
export type InjectedMessage = {
|
||||
id: string;
|
||||
text: string;
|
||||
};
|
||||
|
||||
/** Return value of `usePendingMessages`. */
|
||||
export type UsePendingMessagesReturn = {
|
||||
/** Current pending messages with their mode and injection status. */
|
||||
pending: PendingMessage[];
|
||||
/** Send a steering message during streaming, or a normal message when ready. */
|
||||
steer: (text: string) => void;
|
||||
/** Queue a message for the next turn (sent after current response finishes). */
|
||||
queue: (text: string) => void;
|
||||
/** Promote a queued message to a steering message (sends via input stream immediately). */
|
||||
promoteToSteering: (id: string) => void;
|
||||
/** Check if an assistant message part is an injection point. */
|
||||
isInjectionPoint: (part: unknown) => boolean;
|
||||
/** Get the injected message IDs from an injection point part. */
|
||||
getInjectedMessageIds: (part: unknown) => string[];
|
||||
/** Get the injected messages (id + text) from an injection point part. Self-contained — works after turn complete. */
|
||||
getInjectedMessages: (part: unknown) => InjectedMessage[];
|
||||
};
|
||||
|
||||
/**
|
||||
* React hook for managing pending messages (steering) during streaming.
|
||||
*
|
||||
* Handles:
|
||||
* - Sending messages via input stream during streaming (bypassing useChat)
|
||||
* - Tracking which messages were injected mid-response vs queued for next turn
|
||||
* - Inserting injected messages into the conversation on turn complete
|
||||
* - Auto-sending non-injected messages as the next turn
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const pending = usePendingMessages({
|
||||
* transport, chatId, status, messages, setMessages, sendMessage,
|
||||
* metadata: { model },
|
||||
* });
|
||||
*
|
||||
* // In the form:
|
||||
* <form onSubmit={(e) => {
|
||||
* e.preventDefault();
|
||||
* pending.send(input);
|
||||
* setInput("");
|
||||
* }}>
|
||||
*
|
||||
* // Render pending messages:
|
||||
* {pending.pending.map(msg => (
|
||||
* <div key={msg.id}>{msg.text} — {msg.injected ? "Injected" : "Pending"}</div>
|
||||
* ))}
|
||||
*
|
||||
* // Render injection points inline in assistant messages:
|
||||
* {msg.parts.map((part, i) =>
|
||||
* pending.isInjectionPoint(part)
|
||||
* ? <InjectionMarker key={i} ids={pending.getInjectedMessageIds(part)} />
|
||||
* : <Part key={i} part={part} />
|
||||
* )}
|
||||
* ```
|
||||
*/
|
||||
export function usePendingMessages(options: UsePendingMessagesOptions): UsePendingMessagesReturn {
|
||||
const { transport, chatId, status, messages, setMessages, sendMessage, metadata } = options;
|
||||
|
||||
// Internal state: track messages with their mode
|
||||
type InternalMessage = UIMessage & { _mode: "steering" | "queued" };
|
||||
const [pendingMsgs, setPendingMsgs] = useState<InternalMessage[]>([]);
|
||||
const injectedIdsRef = useRef<Set<string>>(new Set());
|
||||
const prevStatusRef = useRef(status);
|
||||
|
||||
// Watch for injection confirmation chunks in streaming messages
|
||||
useEffect(() => {
|
||||
if (status !== "streaming") return;
|
||||
let newlyInjected = false;
|
||||
for (const msg of messages) {
|
||||
if (msg.role !== "assistant") continue;
|
||||
for (const part of msg.parts ?? []) {
|
||||
if ((part as any).type === PENDING_MESSAGE_INJECTED_TYPE) {
|
||||
const messageIds = (part as any).data?.messageIds;
|
||||
if (Array.isArray(messageIds)) {
|
||||
for (const id of messageIds) {
|
||||
if (!injectedIdsRef.current.has(id)) {
|
||||
injectedIdsRef.current.add(id);
|
||||
newlyInjected = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Remove injected steering messages from the pending overlay immediately
|
||||
if (newlyInjected) {
|
||||
setPendingMsgs((prev) => prev.filter((m) => !injectedIdsRef.current.has(m.id)));
|
||||
}
|
||||
}, [status, messages]);
|
||||
|
||||
// Handle turn completion
|
||||
useEffect(() => {
|
||||
const turnCompleted = prevStatusRef.current === "streaming" && status === "ready";
|
||||
prevStatusRef.current = status;
|
||||
if (!turnCompleted) return;
|
||||
|
||||
// Auto-send non-injected messages as the next turn.
|
||||
// This includes queued messages AND steering messages that weren't
|
||||
// injected (arrived too late, no prepareStep boundary, etc.).
|
||||
// Note: steering messages were also sent via sendPendingMessage to
|
||||
// the backend's wire buffer, so the backend may already have them.
|
||||
// Calling sendMessage here ensures useChat subscribes to the response.
|
||||
const toSend = pendingMsgs.filter(
|
||||
(m) => !injectedIdsRef.current.has(m.id)
|
||||
);
|
||||
|
||||
// Clean up
|
||||
setPendingMsgs([]);
|
||||
injectedIdsRef.current.clear();
|
||||
promotedIdsRef.current.clear();
|
||||
|
||||
// Auto-send as next turn
|
||||
if (toSend.length > 0) {
|
||||
const text = toSend.map((m) => (m.parts?.[0] as any)?.text ?? "").join("\n");
|
||||
sendMessage({ text }, metadata ? { metadata } : undefined);
|
||||
}
|
||||
}, [status, pendingMsgs, sendMessage, metadata, messages]);
|
||||
|
||||
// Send a steering message (injected mid-response via prepareStep)
|
||||
const steer = useCallback(
|
||||
(text: string) => {
|
||||
if (status === "streaming") {
|
||||
const msg: InternalMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
role: "user",
|
||||
parts: [{ type: "text", text }],
|
||||
_mode: "steering",
|
||||
};
|
||||
transport.sendPendingMessage(chatId, msg, metadata);
|
||||
setPendingMsgs((prev) => [...prev, msg]);
|
||||
} else {
|
||||
// Not streaming — just send normally
|
||||
sendMessage({ text }, metadata ? { metadata } : undefined);
|
||||
}
|
||||
},
|
||||
[status, transport, chatId, sendMessage, metadata]
|
||||
);
|
||||
|
||||
// Queue a message for the next turn (no injection attempt)
|
||||
const queue = useCallback(
|
||||
(text: string) => {
|
||||
if (status === "streaming") {
|
||||
const msg: InternalMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
role: "user",
|
||||
parts: [{ type: "text", text }],
|
||||
_mode: "queued",
|
||||
};
|
||||
setPendingMsgs((prev) => [...prev, msg]);
|
||||
} else {
|
||||
sendMessage({ text }, metadata ? { metadata } : undefined);
|
||||
}
|
||||
},
|
||||
[status, sendMessage, metadata]
|
||||
);
|
||||
|
||||
// Promote a queued message to steering (send via input stream immediately)
|
||||
const promotedIdsRef = useRef<Set<string>>(new Set());
|
||||
const promoteToSteering = useCallback(
|
||||
(id: string) => {
|
||||
// Guard against double-click — ref check is synchronous
|
||||
if (promotedIdsRef.current.has(id)) {
|
||||
console.log("[usePendingMessages] promote blocked — already promoted:", id);
|
||||
return;
|
||||
}
|
||||
console.log("[usePendingMessages] promoting:", id);
|
||||
promotedIdsRef.current.add(id);
|
||||
|
||||
setPendingMsgs((prev) => {
|
||||
const msg = prev.find((m) => m.id === id);
|
||||
if (!msg || msg._mode !== "queued") return prev;
|
||||
transport.sendPendingMessage(chatId, msg, metadata);
|
||||
return prev.map((m) => m.id === id ? { ...m, _mode: "steering" as const } : m);
|
||||
});
|
||||
},
|
||||
[transport, chatId, metadata]
|
||||
);
|
||||
|
||||
const isInjectionPoint = useCallback(
|
||||
(part: unknown): boolean =>
|
||||
typeof part === "object" && part !== null && (part as any).type === PENDING_MESSAGE_INJECTED_TYPE,
|
||||
[]
|
||||
);
|
||||
|
||||
const getInjectedMessageIds = useCallback(
|
||||
(part: unknown): string[] => {
|
||||
if (!isInjectionPoint(part)) return [];
|
||||
const ids = (part as any).data?.messageIds;
|
||||
return Array.isArray(ids) ? ids : [];
|
||||
},
|
||||
[isInjectionPoint]
|
||||
);
|
||||
|
||||
const getInjectedMessages = useCallback(
|
||||
(part: unknown): InjectedMessage[] => {
|
||||
if (!isInjectionPoint(part)) return [];
|
||||
const msgs = (part as any).data?.messages;
|
||||
return Array.isArray(msgs) ? msgs : [];
|
||||
},
|
||||
[isInjectionPoint]
|
||||
);
|
||||
|
||||
const pending: PendingMessage[] = pendingMsgs.map((m) => ({
|
||||
id: m.id,
|
||||
text: (m.parts?.[0] as any)?.text ?? "",
|
||||
mode: m._mode,
|
||||
injected: injectedIdsRef.current.has(m.id),
|
||||
}));
|
||||
|
||||
return { pending, steer, queue, promoteToSteering, isInjectionPoint, getInjectedMessageIds, getInjectedMessages };
|
||||
}
|
||||
|
||||
@@ -384,6 +384,51 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Send a message to the running task via input stream without disrupting
|
||||
* the current streaming response. Use this to send steering/pending messages
|
||||
* while the agent is actively streaming.
|
||||
*
|
||||
* Unlike `sendMessage()` from useChat, this does NOT:
|
||||
* - Add the message to useChat's local message state
|
||||
* - Cancel the active stream subscription
|
||||
* - Start a new response stream
|
||||
*
|
||||
* The message is delivered to the task's `messagesInput.on()` listener
|
||||
* and can be injected between tool-call steps via the `pendingMessages`
|
||||
* configuration.
|
||||
*
|
||||
* @returns `true` if the message was sent, `false` if there's no active session.
|
||||
*/
|
||||
sendPendingMessage = async (
|
||||
chatId: string,
|
||||
message: UIMessage,
|
||||
metadata?: Record<string, unknown>,
|
||||
): Promise<boolean> => {
|
||||
const session = this.sessions.get(chatId);
|
||||
if (!session?.runId) return false;
|
||||
|
||||
const mergedMetadata =
|
||||
this.defaultMetadata || metadata
|
||||
? { ...(this.defaultMetadata ?? {}), ...(metadata ?? {}) }
|
||||
: undefined;
|
||||
|
||||
const payload = {
|
||||
messages: [message],
|
||||
chatId,
|
||||
trigger: "submit-message" as const,
|
||||
metadata: mergedMetadata,
|
||||
};
|
||||
|
||||
try {
|
||||
const apiClient = new ApiClient(this.baseURL, session.publicAccessToken);
|
||||
await apiClient.sendInputStream(session.runId, CHAT_MESSAGES_STREAM_ID, payload);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
reconnectToStream = async (
|
||||
options: {
|
||||
chatId: string;
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { UIMessage } from "ai";
|
||||
import { useChat } from "@ai-sdk/react";
|
||||
import type { TriggerChatTransport } from "@trigger.dev/sdk/chat";
|
||||
import type { CompactionChunkData } from "@trigger.dev/sdk/ai";
|
||||
import { usePendingMessages } from "@trigger.dev/sdk/chat/react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Streamdown } from "streamdown";
|
||||
import { MODEL_OPTIONS } from "@/lib/models";
|
||||
@@ -271,7 +272,7 @@ export function Chat({
|
||||
const turnCounter = useRef(0);
|
||||
const [ttfbHistory, setTtfbHistory] = useState<TtfbEntry[]>([]);
|
||||
|
||||
const { messages, sendMessage, stop, status, error } = useChat({
|
||||
const { messages, setMessages, sendMessage, stop, status, error } = useChat({
|
||||
id: chatId,
|
||||
messages: initialMessages,
|
||||
transport,
|
||||
@@ -305,31 +306,96 @@ export function Chat({
|
||||
}
|
||||
}, [status, messages]);
|
||||
|
||||
// Pending message to send after the current turn completes
|
||||
const [pendingMessage, setPendingMessage] = useState<string | null>(null);
|
||||
// Pending messages — handles steering messages during streaming
|
||||
const pending = usePendingMessages({
|
||||
transport,
|
||||
chatId,
|
||||
status,
|
||||
messages,
|
||||
setMessages,
|
||||
sendMessage,
|
||||
metadata: { model },
|
||||
});
|
||||
|
||||
// Handle turn completion: persist messages and auto-send pending message
|
||||
// Expose test helpers for automated testing via Chrome DevTools.
|
||||
// All actions go through refs so closures always call the latest version.
|
||||
const stateRef = useRef({ status, messages, pending: pending.pending });
|
||||
stateRef.current = { status, messages, pending: pending.pending };
|
||||
|
||||
const actionsRef = useRef({
|
||||
steer: pending.steer,
|
||||
queue: pending.queue,
|
||||
promote: pending.promoteToSteering,
|
||||
send: (text: string) => {
|
||||
turnCounter.current++;
|
||||
sendTimestamp.current = Date.now();
|
||||
sendMessage({ text }, { metadata: { model } });
|
||||
},
|
||||
stop,
|
||||
});
|
||||
actionsRef.current = {
|
||||
steer: pending.steer,
|
||||
queue: pending.queue,
|
||||
promote: pending.promoteToSteering,
|
||||
send: (text: string) => {
|
||||
turnCounter.current++;
|
||||
sendTimestamp.current = Date.now();
|
||||
sendMessage({ text }, { metadata: { model } });
|
||||
},
|
||||
stop,
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
(window as any).__chat = {
|
||||
get status() { return stateRef.current.status; },
|
||||
get messages() { return stateRef.current.messages; },
|
||||
get pending() { return stateRef.current.pending; },
|
||||
get runId() { return transport.getSession(chatId)?.runId ?? session?.runId ?? null; },
|
||||
chatId,
|
||||
steer: (text: string) => actionsRef.current.steer(text),
|
||||
queue: (text: string) => actionsRef.current.queue(text),
|
||||
promote: (id: string) => actionsRef.current.promote(id),
|
||||
send: (text: string) => actionsRef.current.send(text),
|
||||
stop: () => actionsRef.current.stop(),
|
||||
// Wait for a tool call to appear, then steer
|
||||
steerOnToolCall: (text: string) => new Promise<void>((resolve) => {
|
||||
const check = setInterval(() => {
|
||||
const { messages: msgs } = stateRef.current;
|
||||
const lastMsg = msgs[msgs.length - 1];
|
||||
const hasTool = lastMsg?.role === "assistant" && lastMsg.parts?.some(
|
||||
(p: any) => p.type?.startsWith("tool-") || p.type === "dynamic-tool"
|
||||
);
|
||||
if (hasTool) {
|
||||
clearInterval(check);
|
||||
console.log("[__chat] steerOnToolCall: tool detected, steering now. status:", stateRef.current.status);
|
||||
actionsRef.current.steer(text);
|
||||
resolve();
|
||||
}
|
||||
}, 200);
|
||||
}),
|
||||
// Wait for status to become a value
|
||||
waitForStatus: (target: string) => new Promise<void>((resolve) => {
|
||||
const check = setInterval(() => {
|
||||
if (stateRef.current.status === target) { clearInterval(check); resolve(); }
|
||||
}, 100);
|
||||
}),
|
||||
steerAfterDelay: (text: string, ms: number) => new Promise<void>((r) => setTimeout(() => { actionsRef.current.steer(text); r(); }, ms)),
|
||||
queueAfterDelay: (text: string, ms: number) => new Promise<void>((r) => setTimeout(() => { actionsRef.current.queue(text); r(); }, ms)),
|
||||
};
|
||||
return () => { delete (window as any).__chat; };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [chatId]);
|
||||
|
||||
// Persist messages when a turn completes
|
||||
const prevStatus = useRef(status);
|
||||
useEffect(() => {
|
||||
const turnCompleted = prevStatus.current === "streaming" && status === "ready";
|
||||
prevStatus.current = status;
|
||||
|
||||
if (!turnCompleted) return;
|
||||
|
||||
// Persist messages when a turn completes
|
||||
if (messages.length > 0) {
|
||||
onMessagesChange?.(chatId, messages);
|
||||
}
|
||||
|
||||
// Auto-send the pending message
|
||||
if (pendingMessage) {
|
||||
const text = pendingMessage;
|
||||
setPendingMessage(null);
|
||||
turnCounter.current++;
|
||||
sendTimestamp.current = Date.now();
|
||||
sendMessage({ text }, { metadata: { model } });
|
||||
}
|
||||
}, [status, messages, chatId, onMessagesChange, sendMessage, pendingMessage, model]);
|
||||
}, [status, messages, chatId, onMessagesChange]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-white">
|
||||
@@ -443,6 +509,25 @@ export function Chat({
|
||||
return <ToolInvocation key={i} part={part} />;
|
||||
}
|
||||
|
||||
if (pending.isInjectionPoint(part)) {
|
||||
const injectedMsgs = pending.getInjectedMessages(part);
|
||||
if (injectedMsgs.length === 0) return null;
|
||||
return (
|
||||
<div key={i} className="my-2 flex justify-end">
|
||||
<div className="max-w-[60%]">
|
||||
{injectedMsgs.map((m) => (
|
||||
<div key={m.id} className="rounded-lg bg-purple-100 px-3 py-1.5 text-sm text-purple-800">
|
||||
{m.text}
|
||||
</div>
|
||||
))}
|
||||
<div className="mt-0.5 text-right text-[10px] text-purple-400">
|
||||
injected mid-response
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (part.type.startsWith("data-")) {
|
||||
return (
|
||||
<div
|
||||
@@ -472,18 +557,31 @@ export function Chat({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pendingMessage && (
|
||||
<div className="flex justify-end">
|
||||
{pending.pending.map((msg) => (
|
||||
<div key={msg.id} className="flex justify-end">
|
||||
<div className="max-w-[80%]">
|
||||
<div className="rounded-lg bg-blue-600 px-4 py-2 text-sm text-white opacity-60">
|
||||
{pendingMessage}
|
||||
<div className={`rounded-lg px-4 py-2 text-sm text-white opacity-75 ${
|
||||
msg.mode === "steering" ? "bg-purple-600" : "bg-gray-500"
|
||||
}`}>
|
||||
{msg.text}
|
||||
</div>
|
||||
<div className="mt-1 text-right text-[10px] text-gray-400">
|
||||
Queued — will send when current response finishes
|
||||
<div className="mt-1 flex items-center justify-end gap-2">
|
||||
<span className="text-[10px] text-gray-400">
|
||||
{msg.mode === "steering" ? "Steering — waiting for injection point" : "Queued for next turn"}
|
||||
</span>
|
||||
{msg.mode === "queued" && status === "streaming" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => pending.promoteToSteering(msg.id)}
|
||||
className="text-[10px] text-purple-500 hover:text-purple-700 underline"
|
||||
>
|
||||
Steer instead
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
@@ -507,13 +605,11 @@ export function Chat({
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (!input.trim()) return;
|
||||
if (status === "streaming") {
|
||||
setPendingMessage(input);
|
||||
} else {
|
||||
if (status !== "streaming") {
|
||||
turnCounter.current++;
|
||||
sendTimestamp.current = Date.now();
|
||||
sendMessage({ text: input }, { metadata: { model } });
|
||||
}
|
||||
pending.steer(input);
|
||||
setInput("");
|
||||
}}
|
||||
className="shrink-0 border-t border-gray-200 bg-white p-4"
|
||||
@@ -528,11 +624,25 @@ export function Chat({
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!input.trim() || !!pendingMessage}
|
||||
disabled={!input.trim()}
|
||||
className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{status === "streaming" ? "Queue" : "Send"}
|
||||
Send
|
||||
</button>
|
||||
{status === "streaming" && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={!input.trim()}
|
||||
onClick={() => {
|
||||
if (!input.trim()) return;
|
||||
pending.queue(input);
|
||||
setInput("");
|
||||
}}
|
||||
className="rounded-lg bg-gray-500 px-4 py-2 text-sm font-medium text-white hover:bg-gray-600 disabled:opacity-50"
|
||||
>
|
||||
Queue
|
||||
</button>
|
||||
)}
|
||||
{status === "streaming" && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -280,6 +280,15 @@ export const aiChat = chat.task({
|
||||
];
|
||||
},
|
||||
},
|
||||
pendingMessages: {
|
||||
// Inject user messages between tool-call steps so the agent can adjust
|
||||
shouldInject: ({ steps }) => steps.length > 0,
|
||||
prepare: ({ messages }) => messages.length === 1
|
||||
? [{ role: "user" as const, content: (messages[0]!.parts?.[0] as any)?.text ?? "" }]
|
||||
: [{ role: "user" as const, content: `The user sent ${messages.length} messages while you were working:\n\n${messages.map((m, i) => `${i + 1}. ${(m.parts?.[0] as any)?.text ?? ""}`).join("\n")}` }],
|
||||
// onReceived/onInjected are optional — the SDK automatically writes
|
||||
// a data-pending-message-injected chunk when injection happens.
|
||||
},
|
||||
prepareMessages: ({ messages, reason }) => {
|
||||
// Add Anthropic cache breaks to the last message for prompt caching.
|
||||
// Applied everywhere — run(), compaction rebuilds, compaction results.
|
||||
@@ -585,6 +594,14 @@ export const aiChatRaw = task({
|
||||
},
|
||||
],
|
||||
},
|
||||
pendingMessages: {
|
||||
// Inject with a prefix so the LLM knows these are mid-execution corrections
|
||||
shouldInject: () => true,
|
||||
prepare: ({ messages }) => [{
|
||||
role: "user" as const,
|
||||
content: [{ type: "text" as const, text: `[User sent ${messages.length} message(s) while you were working]:\n${messages.map(m => (m.parts?.[0] as any)?.text ?? "").join("\n")}` }],
|
||||
}],
|
||||
},
|
||||
});
|
||||
|
||||
for (let turn = 0; turn < 100; turn++) {
|
||||
@@ -623,6 +640,12 @@ export const aiChatRaw = task({
|
||||
});
|
||||
}
|
||||
|
||||
// Listen for steering messages during streaming
|
||||
const steeringSub = chat.messages.on(async (msg) => {
|
||||
const lastMsg = msg.messages?.[msg.messages.length - 1];
|
||||
if (lastMsg) await conversation.steerAsync(lastMsg);
|
||||
});
|
||||
|
||||
const result = streamText({
|
||||
...chat.toStreamTextOptions({ registry }),
|
||||
...(modelOverride ? { model: getModel(modelOverride) } : {}),
|
||||
@@ -655,6 +678,8 @@ export const aiChatRaw = task({
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
} finally {
|
||||
steeringSub.off();
|
||||
}
|
||||
|
||||
if (response) {
|
||||
@@ -744,6 +769,10 @@ export const aiChatSession = task({
|
||||
...uiMessages.slice(-4),
|
||||
],
|
||||
},
|
||||
pendingMessages: {
|
||||
// Always inject in the session variant
|
||||
shouldInject: () => true,
|
||||
},
|
||||
});
|
||||
|
||||
for await (const turn of session) {
|
||||
|
||||
Reference in New Issue
Block a user