diff --git a/open-sse/executors/copilot-m365-connection.ts b/open-sse/executors/copilot-m365-connection.ts new file mode 100644 index 0000000000..b3c2a1507d --- /dev/null +++ b/open-sse/executors/copilot-m365-connection.ts @@ -0,0 +1,116 @@ +/** + * Microsoft 365 Copilot (individual / Substrate BizChat) connection helpers. + * + * Pure URL / credential / prompt builders for the #4042 individual M365 path. + * Kept transport-free (no BaseExecutor import — only a type import) so they can + * be unit-tested without the executor's heavy runtime dependency chain. The + * access_token rides in the WS query string per the protocol, so any logging of + * the URL MUST go through redactWsUrl(). + */ + +import { randomUUID, randomBytes } from "node:crypto"; +import type { ProviderCredentials } from "./base.ts"; + +type JsonRecord = Record; + +/** Individual-tier defaults observed in @skyzea1's #4042 capture. */ +export const M365_INDIVIDUAL_DEFAULTS = { + host: "substrate.office.com", + source: "officeweb", + product: "Office", + agentHost: "Bizchat.FullScreen", + licenseType: "Starter", + agent: "web", + scenario: "OfficeWebPaidConsumerCopilot", +} as const; + +export interface M365ConnectionParams { + host: string; + chathubPath: string; // "@" + accessToken: string; +} + +/** A new 32-hex chat session id (== XRoutingParameterSessionKey == clientrequestid). */ +export function newChatSessionId(): string { + return randomBytes(16).toString("hex"); +} + +/** + * Read the pasted credential bits. The individual access_token is opaque (JWE), + * so it is consumed verbatim. The Chathub path (`user@tenant`) is pasted + * alongside it because it is not derivable from the opaque token. + */ +export function resolveConnectionParams( + credentials: ProviderCredentials | undefined +): M365ConnectionParams | { error: string } { + const psd = (credentials?.providerSpecificData ?? {}) as JsonRecord; + const accessToken = + (typeof credentials?.apiKey === "string" && credentials.apiKey) || + (typeof psd.accessToken === "string" && psd.accessToken) || + (typeof psd.access_token === "string" && psd.access_token) || + ""; + if (!accessToken) { + return { error: "Missing M365 Copilot access_token. Paste it as the provider credential." }; + } + const chathubPath = + (typeof psd.chathubPath === "string" && psd.chathubPath) || + (typeof psd.userTenant === "string" && psd.userTenant) || + ""; + if (!chathubPath || !chathubPath.includes("@")) { + return { + error: + "Missing M365 Chathub path. Paste the '@' segment from the WebSocket URL.", + }; + } + const host = (typeof psd.host === "string" && psd.host) || M365_INDIVIDUAL_DEFAULTS.host; + return { host, chathubPath, accessToken }; +} + +/** + * Build the BizChat WebSocket URL. The access_token rides in the query string + * (per the protocol), so callers must never log the returned URL verbatim — use + * redactWsUrl() for any logging. + */ +export function buildWsUrl(params: M365ConnectionParams): string { + const sessionKey = newChatSessionId(); + const query = new URLSearchParams({ + chatsessionid: sessionKey, + XRoutingParameterSessionKey: sessionKey, + clientrequestid: sessionKey, + "X-SessionId": randomUUID(), + ConversationId: randomUUID(), + access_token: params.accessToken, + source: M365_INDIVIDUAL_DEFAULTS.source, + product: M365_INDIVIDUAL_DEFAULTS.product, + agentHost: M365_INDIVIDUAL_DEFAULTS.agentHost, + licenseType: M365_INDIVIDUAL_DEFAULTS.licenseType, + isEdu: "false", + agent: M365_INDIVIDUAL_DEFAULTS.agent, + scenario: M365_INDIVIDUAL_DEFAULTS.scenario, + }); + return `wss://${params.host}/m365Copilot/Chathub/${params.chathubPath}?${query.toString()}`; +} + +/** Strip the access_token from a WS URL so it is safe to log. */ +export function redactWsUrl(wsUrl: string): string { + return wsUrl.replace(/access_token=[^&]*/i, "access_token=REDACTED"); +} + +/** Flatten OpenAI messages into a single prompt (system instructions prepended). */ +export function buildPrompt(body: JsonRecord | undefined): string { + const messages = (body?.messages as Array) || []; + const systemMsgs = messages.filter((m) => m.role === "system"); + const userMsg = messages.filter((m) => m.role === "user").pop(); + const userText = + typeof userMsg?.content === "string" ? userMsg.content : JSON.stringify(userMsg?.content ?? ""); + let prompt = ""; + if (systemMsgs.length > 0) { + const sysText = systemMsgs + .map((m) => (typeof m.content === "string" ? m.content : "")) + .filter(Boolean) + .join("\n"); + if (sysText) prompt += `[System Instructions]\n${sysText}\n\n`; + } + prompt += userText; + return prompt; +} diff --git a/open-sse/executors/copilot-m365-frames.ts b/open-sse/executors/copilot-m365-frames.ts new file mode 100644 index 0000000000..e236792dc2 --- /dev/null +++ b/open-sse/executors/copilot-m365-frames.ts @@ -0,0 +1,201 @@ +/** + * Microsoft 365 Copilot (BizChat / Substrate) SignalR-over-WebSocket framing. + * + * Pure, transport-free helpers that translate between the OpenAI chat shape and + * the Substrate BizChat SignalR JSON protocol observed on the individual M365 + * path (`m365.cloud.microsoft/chat` → `wss://substrate.office.com/m365Copilot/ + * Chathub/...`). Keeping these pure lets us unit-test the wire format against the + * real frame captures contributed in #4042 without opening a live socket — the + * live round-trip is the separate Rule #18 validation gate for the executor. + * + * Protocol (from @skyzea1's #4042 capture): + * - JSON messages terminated with the SignalR record separator `\x1e`. + * - Handshake: → {"protocol":"json","version":1} ← {} → {"type":6} + * - Send: type:4 invocation to target "chat" with arguments[0] = { message, ... } + * - Stream: type:1 target:"update" deltas (bot text at arguments[0].messages[].text, + * accumulated — NOT incremental) → isLastUpdate:true → type:2 final → type:3 completion. + */ + +/** SignalR record separator (0x1e) terminating every JSON frame. */ +export const RECORD_SEPARATOR = String.fromCharCode(0x1e); + +/** SignalR handshake request — the first frame the client must send. */ +export const HANDSHAKE_REQUEST = { protocol: "json", version: 1 } as const; + +/** SignalR keepalive ping frame. */ +export const KEEPALIVE_PING = { type: 6 } as const; + +/** Allowed message types observed in the individual M365 send frame. */ +export const ALLOWED_MESSAGE_TYPES = [ + "Chat", + "Suggestion", + "InternalSearchQuery", + "Disengaged", + "InternalLoaderMessage", + "Progress", + "GeneratedCode", + "RenderCardRequest", + "AdsQuery", + "SemanticSerp", + "GenerateContentQuery", +] as const; + +/** Append the record separator to a JSON-serializable frame. */ +export function encodeFrame(obj: unknown): string { + return JSON.stringify(obj) + RECORD_SEPARATOR; +} + +/** Serialized handshake request frame. */ +export function handshakeFrame(): string { + return encodeFrame(HANDSHAKE_REQUEST); +} + +/** Serialized keepalive ping frame. */ +export function keepaliveFrame(): string { + return encodeFrame(KEEPALIVE_PING); +} + +/** + * Split a raw socket buffer into complete `\x1e`-terminated frames, returning any + * trailing partial frame as `rest` so it can be prepended to the next chunk. + */ +export function splitFrames(buffer: string): { frames: string[]; rest: string } { + const parts = buffer.split(RECORD_SEPARATOR); + // The last element is either "" (buffer ended on a separator) or a partial frame. + const rest = parts.pop() ?? ""; + const frames = parts.filter((p) => p.length > 0); + return { frames, rest }; +} + +/** Safely JSON.parse a single frame body; returns null on malformed input. */ +export function parseFrame(frame: string): Record | null { + const trimmed = frame.trim(); + if (!trimmed) return null; + try { + const parsed = JSON.parse(trimmed); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? (parsed as Record) + : null; + } catch { + return null; + } +} + +/** + * A SignalR handshake response is `{}` on success, or `{ error: "..." }` on + * failure. Returns the error string, or null when the handshake succeeded. + */ +export function handshakeError(frame: Record | null): string | null { + if (!frame) return null; + const err = frame.error; + return typeof err === "string" && err.length > 0 ? err : null; +} + +export interface ChatInvocationOptions { + text: string; + /** Per-connection trace id (hex), reused as clientCorrelationId/traceId. */ + traceId: string; + /** Per-session id (GUID). */ + sessionId: string; + /** Whether this is the first turn of the conversation. */ + isStartOfSession?: boolean; + /** Tier-specific option flags; left empty by default (tuned during live validation). */ + optionsSets?: string[]; + tone?: string; +} + +/** + * Build the `type:4` chat invocation frame body (not yet `\x1e`-terminated). + * Mirrors the argument shape captured on the individual M365 path in #4042. + */ +export function buildChatInvocation(opts: ChatInvocationOptions): Record { + return { + type: 4, + target: "chat", + invocationId: "0", + arguments: [ + { + source: "officeweb", + clientCorrelationId: opts.traceId, + sessionId: opts.sessionId, + optionsSets: opts.optionsSets ?? [], + streamingMode: "ConciseWithPadding", + spokenTextMode: "None", + options: {}, + extraExtensionParameters: {}, + allowedMessageTypes: [...ALLOWED_MESSAGE_TYPES], + sliceIds: [], + threadLevelGptId: {}, + traceId: opts.traceId, + isStartOfSession: opts.isStartOfSession ?? true, + clientInfo: {}, + message: { + author: "user", + inputMethod: "Keyboard", + text: opts.text, + messageType: "Chat", + }, + plugins: [], + isSbsSupported: false, + tone: opts.tone ?? "", + renderReferencesBehindEOS: true, + disconnectBehavior: "", + }, + ], + }; +} + +/** True when the frame is a SignalR invocation/streamItem (`type:1`) update. */ +export function isUpdateFrame(frame: Record | null): boolean { + return !!frame && frame.type === 1 && frame.target === "update"; +} + +/** True when the frame is the SignalR completion (`type:3`) for the chat invocation. */ +export function isCompletionFrame(frame: Record | null): boolean { + return !!frame && frame.type === 3; +} + +/** True when an update frame is flagged as the last update of the turn. */ +export function isLastUpdate(frame: Record | null): boolean { + if (!isUpdateFrame(frame)) return false; + const args = (frame as Record).arguments; + const first = Array.isArray(args) ? (args[0] as Record | undefined) : undefined; + return first?.isLastUpdate === true; +} + +/** + * Extract the accumulated bot text from a `type:1` update frame, reading the last + * bot-authored message's `.text`. Returns null when the frame carries no bot text + * (Progress/Suggestion/ReferencesListComplete updates, throttling-only frames, etc.). + */ +export function extractBotText(frame: Record | null): string | null { + if (!isUpdateFrame(frame)) return null; + const args = (frame as Record).arguments; + const first = Array.isArray(args) ? (args[0] as Record | undefined) : undefined; + const messages = first?.messages; + if (!Array.isArray(messages)) return null; + // Prefer the last bot-authored message with non-empty text. + for (let i = messages.length - 1; i >= 0; i--) { + const m = messages[i] as Record | undefined; + if (!m) continue; + const author = m.author; + const text = m.text; + if ((author === "bot" || author === undefined) && typeof text === "string" && text.length > 0) { + return text; + } + } + return null; +} + +/** + * BizChat update frames carry the FULL accumulated answer each time, not an + * incremental delta. Given the previously-emitted text and the new accumulated + * text, return the new suffix to stream. When the new text does not extend the + * previous (a replace/rewrite), the whole new text is returned so nothing is lost. + */ +export function incrementalDelta(previous: string, next: string): string { + if (!next) return ""; + if (next === previous) return ""; + if (next.startsWith(previous)) return next.slice(previous.length); + return next; +} diff --git a/tests/unit/m365-bizchat-frames-4042.test.ts b/tests/unit/m365-bizchat-frames-4042.test.ts new file mode 100644 index 0000000000..b67323148f --- /dev/null +++ b/tests/unit/m365-bizchat-frames-4042.test.ts @@ -0,0 +1,215 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +// #4042 — Microsoft 365 Copilot (individual / Substrate BizChat) frame mapping. +// Fixtures below are taken from @skyzea1's real, sanitized capture of an +// `m365.cloud.microsoft/chat` round-trip (test prompt → one-word "pong" reply). +// These pin the wire format so the executor's encode/decode cannot drift; the +// live socket round-trip is the separate Rule #18 validation gate. + +import { + RECORD_SEPARATOR, + encodeFrame, + handshakeFrame, + keepaliveFrame, + splitFrames, + parseFrame, + handshakeError, + buildChatInvocation, + isUpdateFrame, + isCompletionFrame, + isLastUpdate, + extractBotText, + incrementalDelta, +} from "../../open-sse/executors/copilot-m365-frames.ts"; + +// ── Real captured frames (#4042) ────────────────────────────────────────── + +const HANDSHAKE_ACK = {}; // SignalR success ack +const PROGRESS_UPDATE = { + type: 1, + target: "update", + arguments: [{ messages: [{ messageType: "Progress", author: "bot" }] }], +}; +const BOT_UPDATE = { + type: 1, + target: "update", + arguments: [ + { + messages: [ + { + text: "pong", + author: "bot", + responseIdentifier: "Default", + messageId: "00000000-0000-0000-0000-000000000001", + requestId: "trace-id", + adaptiveCards: [ + { type: "AdaptiveCard", version: "1.0", body: [{ type: "TextBlock", text: "pong" }] }, + ], + sourceAttributions: [], + contentOrigin: "DeepLeo", + }, + ], + nonce: "nonce", + requestId: "trace-id", + }, + ], +}; +const FINAL_UPDATE = { + type: 1, + target: "update", + arguments: [ + { + messages: [ + { text: "pong", author: "bot", sourceAttributions: [], references: {}, contentOrigin: "DeepLeo" }, + ], + isLastUpdate: true, + requestId: "trace-id", + }, + ], +}; +const FINAL_ITEM = { type: 2, invocationId: "0", item: { messages: [], requestId: "trace-id" } }; +const COMPLETION = { type: 3, invocationId: "0" }; + +// ── Framing ──────────────────────────────────────────────────────────────── + +test("RECORD_SEPARATOR is the SignalR 0x1e control char", () => { + assert.equal(RECORD_SEPARATOR, String.fromCharCode(0x1e)); + assert.equal(RECORD_SEPARATOR.charCodeAt(0), 0x1e); +}); + +test("handshakeFrame / keepaliveFrame emit the exact SignalR bytes", () => { + assert.equal(handshakeFrame(), `{"protocol":"json","version":1}` + RECORD_SEPARATOR); + assert.equal(keepaliveFrame(), `{"type":6}` + RECORD_SEPARATOR); +}); + +test("encodeFrame appends the record separator", () => { + assert.equal(encodeFrame({ a: 1 }), `{"a":1}` + RECORD_SEPARATOR); +}); + +test("splitFrames separates complete frames and keeps the trailing partial", () => { + const buffer = encodeFrame(HANDSHAKE_ACK) + encodeFrame(BOT_UPDATE) + `{"type":1,"par`; + const { frames, rest } = splitFrames(buffer); + assert.equal(frames.length, 2); + assert.deepEqual(parseFrame(frames[0]), {}); + assert.equal(isUpdateFrame(parseFrame(frames[1])), true); + assert.equal(rest, `{"type":1,"par`); +}); + +test("splitFrames returns empty rest when the buffer ends on a separator", () => { + const { frames, rest } = splitFrames(encodeFrame(COMPLETION)); + assert.equal(frames.length, 1); + assert.equal(rest, ""); +}); + +// ── Handshake ──────────────────────────────────────────────────────────── + +test("handshakeError is null on the {} ack and surfaces an error string", () => { + assert.equal(handshakeError(parseFrame(encodeFrame(HANDSHAKE_ACK).slice(0, -1))), null); + assert.equal(handshakeError({ error: "bad handshake" }), "bad handshake"); +}); + +// ── Send (type:4) ────────────────────────────────────────────────────────── + +test("buildChatInvocation produces a type:4 chat invocation carrying the user text", () => { + const frame = buildChatInvocation({ + text: "protocol capture test. Reply with one word: pong.", + traceId: "trace-id", + sessionId: "session-id", + isStartOfSession: true, + }); + assert.equal(frame.type, 4); + assert.equal(frame.target, "chat"); + assert.equal(frame.invocationId, "0"); + const arg = (frame.arguments as Array>)[0]; + assert.equal(arg.source, "officeweb"); + assert.equal(arg.streamingMode, "ConciseWithPadding"); + assert.equal(arg.traceId, "trace-id"); + assert.equal(arg.clientCorrelationId, "trace-id"); + assert.equal(arg.sessionId, "session-id"); + assert.equal(arg.isStartOfSession, true); + assert.ok(Array.isArray(arg.allowedMessageTypes)); + assert.ok((arg.allowedMessageTypes as string[]).includes("Chat")); + const message = arg.message as Record; + assert.equal(message.author, "user"); + assert.equal(message.inputMethod, "Keyboard"); + assert.equal(message.messageType, "Chat"); + assert.equal(message.text, "protocol capture test. Reply with one word: pong."); +}); + +test("buildChatInvocation serializes/round-trips through the framing", () => { + const frame = buildChatInvocation({ text: "hi", traceId: "t", sessionId: "s" }); + const wire = encodeFrame(frame); + assert.ok(wire.endsWith(RECORD_SEPARATOR)); + const { frames } = splitFrames(wire); + assert.deepEqual(parseFrame(frames[0]), frame); +}); + +// ── Response decode (type:1/2/3) ───────────────────────────────────────── + +test("isUpdateFrame / isCompletionFrame classify the captured frames", () => { + assert.equal(isUpdateFrame(BOT_UPDATE), true); + assert.equal(isUpdateFrame(FINAL_UPDATE), true); + assert.equal(isUpdateFrame(COMPLETION), false); + assert.equal(isUpdateFrame(FINAL_ITEM), false); + assert.equal(isCompletionFrame(COMPLETION), true); + assert.equal(isCompletionFrame(BOT_UPDATE), false); + assert.equal(isCompletionFrame(FINAL_ITEM), false); // type:2 is the final item, not completion +}); + +test("isLastUpdate only fires on the isLastUpdate:true update", () => { + assert.equal(isLastUpdate(BOT_UPDATE), false); + assert.equal(isLastUpdate(FINAL_UPDATE), true); + assert.equal(isLastUpdate(COMPLETION), false); +}); + +test("extractBotText reads the bot answer and ignores Progress frames", () => { + assert.equal(extractBotText(BOT_UPDATE), "pong"); + assert.equal(extractBotText(FINAL_UPDATE), "pong"); + assert.equal(extractBotText(PROGRESS_UPDATE), null); + assert.equal(extractBotText(COMPLETION), null); +}); + +// ── Accumulated → incremental delta ───────────────────────────────────── + +test("incrementalDelta emits only the new suffix of accumulated text", () => { + assert.equal(incrementalDelta("", "pong"), "pong"); + assert.equal(incrementalDelta("pong", "pong"), ""); + assert.equal(incrementalDelta("po", "pong"), "ng"); + assert.equal(incrementalDelta("", ""), ""); +}); + +test("incrementalDelta falls back to the full text on a non-extending replace", () => { + assert.equal(incrementalDelta("abc", "xyz"), "xyz"); +}); + +// ── End-to-end decode of the captured stream ───────────────────────────── + +test("decoding the captured frame sequence reconstructs the bot answer once", () => { + const wire = + encodeFrame(HANDSHAKE_ACK) + + encodeFrame(PROGRESS_UPDATE) + + encodeFrame(BOT_UPDATE) + + encodeFrame(FINAL_UPDATE) + + encodeFrame(FINAL_ITEM) + + encodeFrame(COMPLETION); + + const { frames } = splitFrames(wire); + let emitted = ""; + let prev = ""; + let completed = false; + for (const raw of frames) { + const frame = parseFrame(raw); + if (isUpdateFrame(frame)) { + const text = extractBotText(frame); + if (text != null) { + emitted += incrementalDelta(prev, text); + prev = text; + } + } else if (isCompletionFrame(frame)) { + completed = true; + } + } + assert.equal(emitted, "pong"); + assert.equal(completed, true); +}); diff --git a/tests/unit/m365-connection-4042.test.ts b/tests/unit/m365-connection-4042.test.ts new file mode 100644 index 0000000000..af4890de62 --- /dev/null +++ b/tests/unit/m365-connection-4042.test.ts @@ -0,0 +1,109 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +// #4042 — M365 Copilot (individual) connection helpers: credential resolution, +// WS URL building, token redaction, and prompt flattening. Pure functions, no +// live socket — the round-trip is the separate Rule #18 validation gate. + +import { + M365_INDIVIDUAL_DEFAULTS, + newChatSessionId, + resolveConnectionParams, + buildWsUrl, + redactWsUrl, + buildPrompt, +} from "../../open-sse/executors/copilot-m365-connection.ts"; + +// ── Credential resolution ──────────────────────────────────────────────── + +test("resolveConnectionParams errors when the access_token is missing", () => { + const r = resolveConnectionParams(undefined); + assert.ok("error" in r); + assert.match((r as { error: string }).error, /access_token/i); +}); + +test("resolveConnectionParams errors when the Chathub path is missing", () => { + const r = resolveConnectionParams({ apiKey: "tok" }); + assert.ok("error" in r); + assert.match((r as { error: string }).error, /Chathub path/i); +}); + +test("resolveConnectionParams reads token from apiKey and path from providerSpecificData", () => { + const r = resolveConnectionParams({ + apiKey: "opaque-jwe-token", + providerSpecificData: { chathubPath: "user-oid@tenant-id" }, + }); + assert.ok(!("error" in r)); + const p = r as { host: string; chathubPath: string; accessToken: string }; + assert.equal(p.accessToken, "opaque-jwe-token"); + assert.equal(p.chathubPath, "user-oid@tenant-id"); + assert.equal(p.host, M365_INDIVIDUAL_DEFAULTS.host); +}); + +test("resolveConnectionParams accepts access_token in providerSpecificData and a custom host", () => { + const r = resolveConnectionParams({ + providerSpecificData: { + access_token: "tok2", + userTenant: "u@t", + host: "substrate.svc.cloud.microsoft", + }, + }); + assert.ok(!("error" in r)); + const p = r as { host: string; chathubPath: string; accessToken: string }; + assert.equal(p.accessToken, "tok2"); + assert.equal(p.chathubPath, "u@t"); + assert.equal(p.host, "substrate.svc.cloud.microsoft"); +}); + +// ── WS URL building ────────────────────────────────────────────────────── + +test("buildWsUrl targets the substrate Chathub with the individual-tier query", () => { + const url = buildWsUrl({ host: "substrate.office.com", chathubPath: "u@t", accessToken: "TOK" }); + assert.ok(url.startsWith("wss://substrate.office.com/m365Copilot/Chathub/u@t?")); + const qs = new URLSearchParams(url.split("?")[1]); + assert.equal(qs.get("licenseType"), "Starter"); + assert.equal(qs.get("agent"), "web"); + assert.equal(qs.get("scenario"), "OfficeWebPaidConsumerCopilot"); + assert.equal(qs.get("source"), "officeweb"); + assert.equal(qs.get("access_token"), "TOK"); + // chatsessionid == XRoutingParameterSessionKey == clientrequestid (same value) + const sid = qs.get("chatsessionid"); + assert.ok(sid && /^[0-9a-f]{32}$/.test(sid)); + assert.equal(qs.get("XRoutingParameterSessionKey"), sid); + assert.equal(qs.get("clientrequestid"), sid); +}); + +test("redactWsUrl strips the access_token so the URL is safe to log", () => { + const url = buildWsUrl({ host: "substrate.office.com", chathubPath: "u@t", accessToken: "SECRET" }); + const redacted = redactWsUrl(url); + assert.ok(!redacted.includes("SECRET"), "token must not survive redaction"); + assert.match(redacted, /access_token=REDACTED/); +}); + +test("newChatSessionId is 32 lowercase hex chars", () => { + const id = newChatSessionId(); + assert.match(id, /^[0-9a-f]{32}$/); + assert.notEqual(newChatSessionId(), id); +}); + +// ── Prompt flattening ──────────────────────────────────────────────────── + +test("buildPrompt returns the last user message", () => { + const prompt = buildPrompt({ + messages: [ + { role: "user", content: "first" }, + { role: "user", content: "second" }, + ], + }); + assert.equal(prompt, "second"); +}); + +test("buildPrompt prepends system instructions", () => { + const prompt = buildPrompt({ + messages: [ + { role: "system", content: "Be terse." }, + { role: "user", content: "hi" }, + ], + }); + assert.match(prompt, /\[System Instructions\]\nBe terse\.\n\nhi$/); +});