Compare commits
2 Commits
fix/share-
...
feat/198-i
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b146fd24d | ||
|
|
f789be9c89 |
@@ -1175,6 +1175,8 @@
|
||||
"{{name}} is typing…": "{{name}} is typing…",
|
||||
"Send": "Send",
|
||||
"Send when the agent finishes": "Send when the agent finishes",
|
||||
"Send now": "Send now",
|
||||
"Interrupt and send now": "Interrupt and send now",
|
||||
"Queue message": "Queue message",
|
||||
"Remove queued message": "Remove queued message",
|
||||
"Stop": "Stop",
|
||||
|
||||
@@ -715,6 +715,8 @@
|
||||
"No chats yet.": "Чатов пока нет.",
|
||||
"Send": "Отправить",
|
||||
"Send when the agent finishes": "Отправить, когда агент закончит",
|
||||
"Send now": "Отправить сейчас",
|
||||
"Interrupt and send now": "Прервать и отправить сейчас",
|
||||
"Queue message": "Поставить в очередь",
|
||||
"Remove queued message": "Убрать из очереди",
|
||||
"Something went wrong": "Что-то пошло не так",
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { generateId } from "ai";
|
||||
import { ActionIcon, Box, Group, Stack, Text } from "@mantine/core";
|
||||
import { IconClockHour4, IconX } from "@tabler/icons-react";
|
||||
import { ActionIcon, Box, Group, Stack, Text, Tooltip } from "@mantine/core";
|
||||
import {
|
||||
IconClockHour4,
|
||||
IconPlayerPlayFilled,
|
||||
IconX,
|
||||
} from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useChat, type UIMessage } from "@ai-sdk/react";
|
||||
import { DefaultChatTransport } from "ai";
|
||||
@@ -24,6 +28,7 @@ import { liveTurnTokens } from "@/features/ai-chat/utils/count-stream-tokens.ts"
|
||||
import {
|
||||
dequeue,
|
||||
enqueueMessage,
|
||||
promoteToHead,
|
||||
removeQueuedById,
|
||||
type QueuedMessage,
|
||||
} from "@/features/ai-chat/utils/queue-helpers.ts";
|
||||
@@ -177,9 +182,12 @@ export default function ChatThread({
|
||||
// LOCAL state so it is scoped to this conversation: it is cleared when the user
|
||||
// deliberately switches chat / starts a new chat (the parent remounts this via
|
||||
// `key`), but it SURVIVES in-place new-chat id adoption (no remount), so a
|
||||
// message queued during a brand-new chat's first turn is not lost. On Stop or
|
||||
// error the queue is intentionally preserved (onFinish does not fire then) so
|
||||
// the user decides what to do with the pending messages.
|
||||
// message queued during a brand-new chat's first turn is not lost. On a normal
|
||||
// Stop / disconnect / error the queue is intentionally preserved (onFinish DOES
|
||||
// fire on those — see the abort/disconnect/error branches below — but it leaves
|
||||
// the queue intact) so the user decides what to do with the pending messages.
|
||||
// The one exception is a deliberate "Send now" (which itself calls stop()): its
|
||||
// abort branch in onFinish flushes the message it promoted to the head.
|
||||
const [queued, setQueued] = useState<QueuedMessage[]>([]);
|
||||
// Mirror the queue in a ref so the `onFinish` flush always reads the latest
|
||||
// queue without a stale closure; `setQueue` updates BOTH the ref and the state.
|
||||
@@ -193,6 +201,14 @@ export default function ChatThread({
|
||||
// helper can call the current instance from the stable `onFinish` callback.
|
||||
const sendMessageRef = useRef<((m: { text: string }) => void) | null>(null);
|
||||
|
||||
// Set by "Send now" so the abort WE trigger flushes the promoted head (the
|
||||
// normal abort path keeps the queue intact instead).
|
||||
const flushOnAbortRef = useRef(false);
|
||||
// Tags the very next send as an intentional user interrupt, so the server can
|
||||
// note in the agent's context that the previous turn was cut short. One-shot:
|
||||
// read-and-cleared by prepareSendMessagesRequest.
|
||||
const interruptNextSendRef = useRef(false);
|
||||
|
||||
// FIFO dequeue + send the next queued message (no-op when the queue is empty).
|
||||
const flushNext = useCallback(() => {
|
||||
const { head, rest } = dequeue(queuedRef.current);
|
||||
@@ -224,17 +240,24 @@ export default function ChatThread({
|
||||
// when null) and tell the agent which page "this page" refers to. Both
|
||||
// are read live from refs so changing chats/pages does NOT recreate the
|
||||
// transport. `openPage` is null on a non-page route.
|
||||
prepareSendMessagesRequest: ({ messages, body }) => ({
|
||||
body: {
|
||||
...body,
|
||||
chatId: chatIdRef.current,
|
||||
openPage: openPageRef.current,
|
||||
// Honoured by the server only when creating a new chat; null =>
|
||||
// universal assistant.
|
||||
roleId: roleIdRef.current,
|
||||
messages,
|
||||
},
|
||||
}),
|
||||
prepareSendMessagesRequest: ({ messages, body }) => {
|
||||
// One-shot interrupt flag: consumed here so only the send triggered by
|
||||
// "Send now" carries it; every normal send leaves it false.
|
||||
const interrupted = interruptNextSendRef.current;
|
||||
interruptNextSendRef.current = false;
|
||||
return {
|
||||
body: {
|
||||
...body,
|
||||
chatId: chatIdRef.current,
|
||||
openPage: openPageRef.current,
|
||||
// Honoured by the server only when creating a new chat; null =>
|
||||
// universal assistant.
|
||||
roleId: roleIdRef.current,
|
||||
interrupted,
|
||||
messages,
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
@@ -259,6 +282,16 @@ export default function ChatThread({
|
||||
// message metadata) so the parent adopts the REAL created chat id for a new
|
||||
// chat — see adopt-chat-id.ts for the full #137 design.
|
||||
onTurnFinished(extractServerChatId(message));
|
||||
// Read-and-clear: only the immediately-following terminal outcome may consume it.
|
||||
const intentionalInterrupt = flushOnAbortRef.current;
|
||||
flushOnAbortRef.current = false;
|
||||
if (intentionalInterrupt && isAbort) {
|
||||
// "Send now": flush the promoted head even though the turn was aborted, and
|
||||
// suppress the neutral "stopped" marker (this was a deliberate interrupt).
|
||||
setStopNotice(null);
|
||||
flushNext();
|
||||
return;
|
||||
}
|
||||
// Show a neutral "stopped" marker for an aborted turn; the red error banner
|
||||
// (via `error`) already covers isError, and a clean finish clears any marker.
|
||||
if (isError) setStopNotice(null);
|
||||
@@ -286,6 +319,13 @@ export default function ChatThread({
|
||||
// Keep the flush helper pointed at the latest sendMessage instance.
|
||||
sendMessageRef.current = sendMessage;
|
||||
|
||||
// Mirror the live turn status in a ref so event handlers (sendNow) branch on the
|
||||
// CURRENT status rather than a value captured in a stale render closure — a turn
|
||||
// can finish between render and click, and arming the interrupt refs against a
|
||||
// no-op stop() would leave them set to leak into a later, unrelated Stop.
|
||||
const statusRef = useRef(status);
|
||||
statusRef.current = status;
|
||||
|
||||
// EARLY chat-id adoption (#174): the server streams the authoritative chat id
|
||||
// on the assistant message metadata at the `start` chunk (message.metadata.
|
||||
// chatId — see adopt-chat-id.ts / chatStreamMetadata). Forward it to the parent
|
||||
@@ -317,9 +357,47 @@ export default function ChatThread({
|
||||
|
||||
const isStreaming = status === "submitted" || status === "streaming";
|
||||
|
||||
// Clear the stopped marker as soon as a new turn begins streaming.
|
||||
// "Send now" on a queued message: interrupt the current turn and immediately
|
||||
// send THIS message. Any other queued messages stay queued and flush normally
|
||||
// after the new turn finishes.
|
||||
const sendNow = useCallback(
|
||||
(id: string) => {
|
||||
// Branch on the LIVE status (statusRef), not the closure-captured isStreaming:
|
||||
// the turn may have finished between render and click, in which case stop()
|
||||
// is a no-op and arming the interrupt refs would strand them for a later turn.
|
||||
const liveStreaming =
|
||||
statusRef.current === "submitted" || statusRef.current === "streaming";
|
||||
if (liveStreaming) {
|
||||
// Promote the chosen message to the head so the existing onFinish→flushNext
|
||||
// sends exactly it, then interrupt: the abort triggers onFinish below.
|
||||
setQueue(promoteToHead(queuedRef.current, id));
|
||||
flushOnAbortRef.current = true;
|
||||
interruptNextSendRef.current = true;
|
||||
stop();
|
||||
} else {
|
||||
// Not streaming: nothing to interrupt — just send it now (no interrupt note).
|
||||
const msg = queuedRef.current.find((m) => m.id === id);
|
||||
if (!msg) return;
|
||||
setQueue(removeQueuedById(queuedRef.current, id));
|
||||
sendMessageRef.current?.({ text: msg.text });
|
||||
}
|
||||
},
|
||||
[setQueue, stop],
|
||||
);
|
||||
|
||||
// Clear the stopped marker as soon as a new turn begins streaming, and drop any
|
||||
// stale "Send now" interrupt flags. In the legit interrupt path both refs are
|
||||
// already consumed synchronously (onFinish + prepareSendMessagesRequest) before
|
||||
// this effect runs, so clearing here is a no-op for it; its purpose is to defuse
|
||||
// the race where a flag was armed but the expected abort never fired (the turn
|
||||
// finished cleanly in the same tick as the click), so it cannot leak into an
|
||||
// unrelated later turn.
|
||||
useEffect(() => {
|
||||
if (isStreaming) setStopNotice(null);
|
||||
if (isStreaming) {
|
||||
setStopNotice(null);
|
||||
flushOnAbortRef.current = false;
|
||||
interruptNextSendRef.current = false;
|
||||
}
|
||||
}, [isStreaming]);
|
||||
|
||||
// Classify the turn error into a heading + detail so the banner names the cause
|
||||
@@ -458,6 +536,17 @@ export default function ChatThread({
|
||||
<Text size="xs" lineClamp={2} className={classes.queuedText}>
|
||||
{m.text}
|
||||
</Text>
|
||||
<Tooltip label={t("Interrupt and send now")} withArrow>
|
||||
<ActionIcon
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
color="blue"
|
||||
onClick={() => sendNow(m.id)}
|
||||
aria-label={t("Send now")}
|
||||
>
|
||||
<IconPlayerPlayFilled size={12} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<ActionIcon
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
enqueueMessage,
|
||||
dequeue,
|
||||
removeQueuedById,
|
||||
promoteToHead,
|
||||
type QueuedMessage,
|
||||
} from "./queue-helpers";
|
||||
|
||||
@@ -89,6 +90,47 @@ describe("removeQueuedById", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("promoteToHead", () => {
|
||||
it("moves a middle item to the front and preserves the order of the rest", () => {
|
||||
const queue: QueuedMessage[] = [
|
||||
{ id: "a", text: "first" },
|
||||
{ id: "b", text: "second" },
|
||||
{ id: "c", text: "third" },
|
||||
];
|
||||
const next = promoteToHead(queue, "b");
|
||||
expect(next).toEqual([
|
||||
{ id: "b", text: "second" },
|
||||
{ id: "a", text: "first" },
|
||||
{ id: "c", text: "third" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns an equivalent array when the id is absent", () => {
|
||||
const queue: QueuedMessage[] = [
|
||||
{ id: "a", text: "first" },
|
||||
{ id: "b", text: "second" },
|
||||
];
|
||||
expect(promoteToHead(queue, "missing")).toEqual([
|
||||
{ id: "a", text: "first" },
|
||||
{ id: "b", text: "second" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not mutate the input queue", () => {
|
||||
const queue: QueuedMessage[] = [
|
||||
{ id: "a", text: "first" },
|
||||
{ id: "b", text: "second" },
|
||||
{ id: "c", text: "third" },
|
||||
];
|
||||
promoteToHead(queue, "c");
|
||||
expect(queue).toEqual([
|
||||
{ id: "a", text: "first" },
|
||||
{ id: "b", text: "second" },
|
||||
{ id: "c", text: "third" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("FIFO order", () => {
|
||||
it("preserves order across enqueue -> dequeue", () => {
|
||||
let queue: QueuedMessage[] = [];
|
||||
|
||||
@@ -32,3 +32,14 @@ export function removeQueuedById(
|
||||
): QueuedMessage[] {
|
||||
return queue.filter((m) => m.id !== id);
|
||||
}
|
||||
|
||||
/** Move the queued message with the given id to the FRONT (returns a new array).
|
||||
* Returns the input array unchanged (by identity) when the id is absent. Pure. */
|
||||
export function promoteToHead(
|
||||
queue: QueuedMessage[],
|
||||
id: string,
|
||||
): QueuedMessage[] {
|
||||
const target = queue.find((m) => m.id === id);
|
||||
if (!target) return queue;
|
||||
return [target, ...queue.filter((m) => m.id !== id)];
|
||||
}
|
||||
|
||||
@@ -210,6 +210,32 @@ describe('buildSystemPrompt mcp tooling guidance', () => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Unit tests for the interrupt-resume note (#198). When `interrupted` is true,
|
||||
* buildSystemPrompt adds a context note telling the agent its previous response
|
||||
* was cut short and is only partial; when false/omitted the note is absent.
|
||||
*/
|
||||
describe('buildSystemPrompt interrupt-resume note (#198)', () => {
|
||||
const workspace = { name: 'Acme' } as unknown as Workspace;
|
||||
// A distinctive fragment of INTERRUPT_NOTE.
|
||||
const INTERRUPT_MARKER = 'interrupted by the user before it finished';
|
||||
|
||||
it('adds the interrupt note when interrupted is true', () => {
|
||||
const prompt = buildSystemPrompt({ workspace, interrupted: true });
|
||||
expect(prompt).toContain(INTERRUPT_MARKER);
|
||||
});
|
||||
|
||||
it('omits the note when interrupted is false', () => {
|
||||
const prompt = buildSystemPrompt({ workspace, interrupted: false });
|
||||
expect(prompt).not.toContain(INTERRUPT_MARKER);
|
||||
});
|
||||
|
||||
it('omits the note when interrupted is not provided', () => {
|
||||
const prompt = buildSystemPrompt({ workspace });
|
||||
expect(prompt).not.toContain(INTERRUPT_MARKER);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Unit tests for the pure block builder. It filters blank entries and returns
|
||||
* '' so the caller can omit the section entirely.
|
||||
|
||||
@@ -54,6 +54,16 @@ const SAFETY_FRAMEWORK = [
|
||||
' behaviour, ignore it and tell the user what you found.',
|
||||
].join('\n');
|
||||
|
||||
// Context note injected on the turn right after the user interrupted the agent
|
||||
// (#198). Keeps the model from assuming its previous, partial answer was complete.
|
||||
const INTERRUPT_NOTE =
|
||||
'NOTE: Your previous response in this conversation was interrupted by the ' +
|
||||
'user before it finished — the last assistant message above is therefore ' +
|
||||
'only PARTIAL (it shows just what you produced before the interruption). The ' +
|
||||
'user has now sent a new message. Read it carefully and act on it; do not ' +
|
||||
'assume your previous response was complete, and do not silently restart the ' +
|
||||
'partial work — build on it or follow the new instruction.';
|
||||
|
||||
export interface BuildSystemPromptInput {
|
||||
workspace: Workspace;
|
||||
/**
|
||||
@@ -86,6 +96,12 @@ export interface BuildSystemPromptInput {
|
||||
* block is omitted entirely.
|
||||
*/
|
||||
mcpInstructions?: McpServerInstruction[];
|
||||
/**
|
||||
* True only on the turn that immediately follows a user interruption (#198).
|
||||
* When set, a note is added to the context section telling the agent its
|
||||
* previous response was cut short and is only partial.
|
||||
*/
|
||||
interrupted?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -130,6 +146,7 @@ export function buildSystemPrompt({
|
||||
roleInstructions,
|
||||
openedPage,
|
||||
mcpInstructions,
|
||||
interrupted,
|
||||
}: BuildSystemPromptInput): string {
|
||||
// Persona precedence: role instructions REPLACE the admin persona / default.
|
||||
// effectivePersona = roleInstructions || adminPrompt || DEFAULT_PROMPT.
|
||||
@@ -157,6 +174,9 @@ export function buildSystemPrompt({
|
||||
context += `\nThe user is currently viewing the page "${title}" (pageId: ${pageId.trim()}). When they refer to "this page", "the current page", or similar, operate on that pageId — use the read/write page tools with it.`;
|
||||
}
|
||||
|
||||
// Interrupt-resume note (#198): only on the turn right after a user interrupt.
|
||||
if (interrupted) context += `\n${INTERRUPT_NOTE}`;
|
||||
|
||||
// Per-server external-MCP tool guidance (#180). Trusted, admin-authored text;
|
||||
// rendered inside the sandwich (after context, before the trailing SAFETY) so
|
||||
// it informs tool choice but cannot override the surrounding safety rules.
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
flushAssistant,
|
||||
chatStreamMetadata,
|
||||
accumulateStepUsage,
|
||||
shouldInjectInterruptNote,
|
||||
MAX_AGENT_STEPS,
|
||||
FINAL_STEP_INSTRUCTION,
|
||||
} from './ai-chat.service';
|
||||
@@ -492,6 +493,70 @@ describe('accumulateStepUsage', () => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* shouldInjectInterruptNote (#198): the pure gate behind the interrupt-resume
|
||||
* note. It returns true ONLY when the client flagged the send as a "Send now"
|
||||
* interrupt AND the previous turn (history[len-2]) really ended unfinished —
|
||||
* an assistant row with status 'aborted' or (abort/resend race) 'streaming'.
|
||||
* Every other shape gates it off.
|
||||
*/
|
||||
describe('shouldInjectInterruptNote (#198)', () => {
|
||||
it('returns true for flag + assistant + aborted', () => {
|
||||
expect(
|
||||
shouldInjectInterruptNote(true, { role: 'assistant', status: 'aborted' }),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for flag + assistant + streaming (abort persistence in flight)", () => {
|
||||
expect(
|
||||
shouldInjectInterruptNote(true, {
|
||||
role: 'assistant',
|
||||
status: 'streaming',
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when the client did not flag an interrupt', () => {
|
||||
expect(
|
||||
shouldInjectInterruptNote(false, {
|
||||
role: 'assistant',
|
||||
status: 'aborted',
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldInjectInterruptNote(undefined, {
|
||||
role: 'assistant',
|
||||
status: 'aborted',
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when the previous turn is not an assistant row', () => {
|
||||
expect(
|
||||
shouldInjectInterruptNote(true, { role: 'user', status: 'aborted' }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for a settled assistant status (completed/error/null)', () => {
|
||||
expect(
|
||||
shouldInjectInterruptNote(true, {
|
||||
role: 'assistant',
|
||||
status: 'completed',
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldInjectInterruptNote(true, { role: 'assistant', status: 'error' }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldInjectInterruptNote(true, { role: 'assistant', status: null }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when there is no previous turn (undefined)', () => {
|
||||
expect(shouldInjectInterruptNote(true, undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Contract test for the #180 wiring in AiChatService.handle: the external MCP
|
||||
* toolset must be built BEFORE the system prompt, and its per-server guidance
|
||||
|
||||
@@ -93,6 +93,10 @@ export interface AiChatStreamBody {
|
||||
// is attacker-controllable but harmless: the agent reads/writes via its
|
||||
// CASL-enforced page tools, which 403 on a page the user cannot access.
|
||||
openPage?: { id?: string; title?: string } | null;
|
||||
// Set by the client's "Send now" (interrupt + resend) path. When true AND the
|
||||
// preceding assistant turn really ended unfinished, the system prompt gets a
|
||||
// note that the previous response was interrupted (see ai-chat.prompt.ts).
|
||||
interrupted?: boolean;
|
||||
// useChat sends the full UIMessage list; the last one is the new user turn.
|
||||
messages?: UIMessage[];
|
||||
}
|
||||
@@ -333,6 +337,16 @@ export class AiChatService implements OnModuleInit {
|
||||
// convertToModelMessages is async in ai@6.0.134 (returns Promise<ModelMessage[]>).
|
||||
const messages = await convertToModelMessages(uiMessages);
|
||||
|
||||
// Interrupt-resume note (#198): only when the client flagged this send as an
|
||||
// interrupt AND the turn right before the just-inserted user message really
|
||||
// ended unfinished. history is oldest→newest; the tail is the user row we just
|
||||
// inserted, so history[len-2] is the previous turn. Accept 'aborted' and also
|
||||
// 'streaming' (the abort persistence can still be in flight — abort/resend race).
|
||||
const interrupted = shouldInjectInterruptNote(
|
||||
body.interrupted,
|
||||
history[history.length - 2],
|
||||
);
|
||||
|
||||
// The model is resolved by the controller before hijack (clean 503 path).
|
||||
// Here we only need the admin-configured system prompt.
|
||||
const resolved = await this.aiSettings.resolve(workspace.id);
|
||||
@@ -404,6 +418,8 @@ export class AiChatService implements OnModuleInit {
|
||||
openedPage: openPageContext,
|
||||
// Guidance only for servers that connected and yielded ≥1 callable tool.
|
||||
mcpInstructions: external.instructions,
|
||||
// #198: add the interrupt-resume note when the previous turn was cut short.
|
||||
interrupted,
|
||||
});
|
||||
|
||||
// Pass the resolved chatId so the write tools can mint provenance tokens
|
||||
@@ -1145,6 +1161,26 @@ export interface AssistantFlush {
|
||||
status: 'streaming' | 'completed' | 'error' | 'aborted';
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure decision (#198): does this turn need the interrupt-resume note in its
|
||||
* system prompt? True only when the client flagged the send as a "Send now"
|
||||
* interrupt AND the turn right before the just-inserted user message really
|
||||
* ended unfinished (status 'aborted', or 'streaming' when the abort persistence
|
||||
* is still in flight — the abort/resend race). A user/role mismatch, a settled
|
||||
* status (completed/error/null), or a missing previous turn all gate it off.
|
||||
* Extracted so the gating is unit-testable without seaming the streaming path.
|
||||
*/
|
||||
export function shouldInjectInterruptNote(
|
||||
bodyInterrupted: boolean | undefined,
|
||||
prevTurn: { role?: string; status?: string | null } | undefined,
|
||||
): boolean {
|
||||
return (
|
||||
bodyInterrupted === true &&
|
||||
prevTurn?.role === 'assistant' &&
|
||||
(prevTurn.status === 'aborted' || prevTurn.status === 'streaming')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure decision for the terminal finalize (#183): given whether the upfront
|
||||
* assistant row exists (`assistantId`), choose whether the terminal payload is
|
||||
|
||||
Reference in New Issue
Block a user