diff --git a/apps/loopover-miner-ui/src/chat-api.test.ts b/apps/loopover-miner-ui/src/chat-api.test.ts new file mode 100644 index 0000000000..632371bd93 --- /dev/null +++ b/apps/loopover-miner-ui/src/chat-api.test.ts @@ -0,0 +1,266 @@ +import { describe, expect, it } from "vitest"; + +import { handleAuthRequest } from "../vite-auth"; +import { + chatApiPlugin, + formatSseEvent, + handleChatRequest, + writeSseStream, + type ChatApiDeps, + type ChatSseEvent, +} from "../vite-chat-api"; + +// Transport-level coverage for the streaming chat endpoint (#6517). The grounding itself is the engine's +// (packages/loopover-engine/src/miner/chat-grounding.ts, covered by test/unit/chat-grounding-engine.test.ts); +// these tests only prove route matching, body validation, and SSE re-emission. + +function deps(overrides: Partial = {}): ChatApiDeps { + return { + loadChatGroundingModule: async () => ({ + isValidChatMessages: (value: unknown) => Array.isArray(value) && value.length > 0, + runChatGrounding: () => + (async function* (): AsyncGenerator { + yield { type: "text", text: "hi" }; + yield { type: "done" }; + })(), + }), + ...overrides, + }; +} + +const VALID_BODY = JSON.stringify({ messages: [{ role: "user", content: "status?" }] }); + +describe("the auth gate covers /api/chat (#6517)", () => { + // The chat plugin is registered after authPlugin() in vite.config.ts, so it inherits the existing + // session-cookie gate (#4858) with no new auth mechanism — an unauthenticated request is rejected in the + // Connect chain before this endpoint's middleware ever runs. + const TOKEN = "deterministic-test-token"; + + it("rejects an unauthenticated /api/chat request with a 401, before the handler", () => { + expect(handleAuthRequest("/api/chat", undefined, TOKEN)).toEqual({ + status: 401, + body: JSON.stringify({ error: "unauthenticated: missing or invalid local miner-ui session cookie" }), + }); + }); + + it("falls through for an authenticated /api/chat request so the chat middleware runs", () => { + expect(handleAuthRequest("/api/chat", `loopover_miner_ui_token=${TOKEN}`, TOKEN)).toBeNull(); + }); +}); + +describe("handleChatRequest routing (#6517)", () => { + it("falls through for a different path or a non-POST method", async () => { + expect(await handleChatRequest("POST", "/api/other", VALID_BODY, deps())).toBeNull(); + expect(await handleChatRequest("GET", "/api/chat", VALID_BODY, deps())).toBeNull(); + expect(await handleChatRequest(undefined, "/api/chat", VALID_BODY, deps())).toBeNull(); + }); + + it("never loads the grounding module for a non-matching route", async () => { + let loaded = false; + await handleChatRequest("GET", "/api/chat", VALID_BODY, { + loadChatGroundingModule: async () => { + loaded = true; + throw new Error("must not load"); + }, + }); + expect(loaded).toBe(false); + }); +}); + +describe("handleChatRequest validation (#6517)", () => { + it("rejects a non-JSON body with a non-streamed 400", async () => { + expect(await handleChatRequest("POST", "/api/chat", "not json", deps())).toEqual({ + kind: "json", + status: 400, + body: JSON.stringify({ error: "invalid_json: request body must be JSON" }), + }); + }); + + it("rejects an empty/malformed messages array with a non-streamed 400", async () => { + const rejected = await handleChatRequest("POST", "/api/chat", JSON.stringify({ messages: [] }), deps()); + expect(rejected?.kind).toBe("json"); + expect((rejected as { status: number }).status).toBe(400); + const missing = await handleChatRequest("POST", "/api/chat", JSON.stringify({}), deps()); + expect((missing as { status: number }).status).toBe(400); + const nullBody = await handleChatRequest("POST", "/api/chat", "null", deps()); + expect((nullBody as { status: number }).status).toBe(400); + }); + + it("delegates validation to the engine, not a second local copy", async () => { + // The engine's isValidChatMessages is the single source of truth; a module that rejects everything must + // make the endpoint reject too, with no local override. + const rejected = await handleChatRequest( + "POST", + "/api/chat", + VALID_BODY, + deps({ + loadChatGroundingModule: async () => ({ + isValidChatMessages: () => false, + runChatGrounding: () => (async function* (): AsyncGenerator {})(), + }), + }), + ); + expect((rejected as { status: number }).status).toBe(400); + }); + + it("returns a stream for a valid body", async () => { + const handled = await handleChatRequest("POST", "/api/chat", VALID_BODY, deps()); + expect(handled?.kind).toBe("stream"); + }); +}); + +describe("SSE wire format (#6517)", () => { + it("serializes one event per data line terminated by a blank line", () => { + expect(formatSseEvent({ type: "text", text: "hi" })).toBe('data: {"type":"text","text":"hi"}\n\n'); + expect(formatSseEvent({ type: "done" })).toBe('data: {"type":"done"}\n\n'); + }); + + it("re-emits an upstream stream as SSE lines terminated by done", async () => { + const written: string[] = []; + const headers: Record = {}; + const res = { + statusCode: 0, + setHeader: (k: string, v: string) => { + headers[k] = v; + }, + write: (chunk: string) => written.push(chunk), + end: () => {}, + }; + await writeSseStream( + res, + (async function* (): AsyncGenerator { + yield { type: "tool_call", tool: "loopover_miner_status", input: {} }; + yield { type: "text", text: "idle" }; + yield { type: "done" }; + })(), + ); + expect(res.statusCode).toBe(200); + expect(headers["Content-Type"]).toBe("text/event-stream"); + expect(headers["Cache-Control"]).toBe("no-cache"); + expect(written).toEqual([ + 'data: {"type":"tool_call","tool":"loopover_miner_status","input":{}}\n\n', + 'data: {"type":"text","text":"idle"}\n\n', + 'data: {"type":"done"}\n\n', + ]); + }); + + it("forwards an engine error event verbatim rather than throwing", async () => { + const written: string[] = []; + const res = { + statusCode: 0, + setHeader: () => {}, + write: (chunk: string) => written.push(chunk), + end: () => {}, + }; + await writeSseStream( + res, + (async function* (): AsyncGenerator { + yield { type: "error", code: "no_coding_agent_configured", message: "not configured" }; + yield { type: "done" }; + })(), + ); + expect(written[0]).toContain('"type":"error"'); + expect(written[0]).toContain("no_coding_agent_configured"); + expect(written.at(-1)).toBe('data: {"type":"done"}\n\n'); + }); +}); + +describe("chatApiPlugin middleware (#6517)", () => { + type CapturedHandler = ( + req: { method?: string; url?: string; on: (event: string, cb: (chunk?: unknown) => void) => void }, + res: { + statusCode: number; + setHeader: (k: string, v: string) => void; + write: (chunk: string) => void; + end: (body?: string) => void; + }, + next: () => void, + ) => void; + + function captureMiddleware(): CapturedHandler { + let captured: CapturedHandler | undefined; + const plugin = chatApiPlugin(deps()); + const server = { middlewares: { use: (fn: CapturedHandler) => (captured = fn) } }; + // @ts-expect-error -- the test double only implements the subset of Vite's ViteDevServer this plugin reads. + plugin.configureServer(server); + if (!captured) throw new Error("chatApiPlugin did not register a middleware"); + return captured; + } + + function fakeReq(method: string, url: string, body: string) { + return { + method, + url, + on(event: string, cb: (chunk?: unknown) => void) { + if (event === "data") cb(body); + if (event === "end") cb(); + }, + }; + } + + it("registers on both the dev and preview servers", () => { + const plugin = chatApiPlugin(deps()); + expect(plugin.name).toBe("gittensory-miner-ui:chat-api"); + expect(typeof plugin.configureServer).toBe("function"); + expect(typeof plugin.configurePreviewServer).toBe("function"); + }); + + it("calls next() for a non-chat route without reading a body", () => { + const middleware = captureMiddleware(); + let nexted = false; + middleware( + fakeReq("GET", "/api/run-state", ""), + { statusCode: 0, setHeader: () => {}, write: () => {}, end: () => {} }, + () => { + nexted = true; + }, + ); + expect(nexted).toBe(true); + }); + + it("streams SSE frames for a valid POST", async () => { + const middleware = captureMiddleware(); + const written: string[] = []; + let ended = false; + const res = { + statusCode: 0, + setHeader: () => {}, + write: (chunk: string) => written.push(chunk), + end: () => { + ended = true; + }, + }; + middleware(fakeReq("POST", "/api/chat", VALID_BODY), res, () => {}); + await vi_waitFor(() => ended); + expect(res.statusCode).toBe(200); + expect(written.at(-1)).toBe('data: {"type":"done"}\n\n'); + }); + + it("answers a malformed POST with a buffered JSON 400, not a stream", async () => { + const middleware = captureMiddleware(); + let body: string | undefined; + const res = { + statusCode: 0, + setHeader: () => {}, + write: () => { + throw new Error("must not stream a validation failure"); + }, + end: (value?: string) => { + body = value; + }, + }; + middleware(fakeReq("POST", "/api/chat", "not json"), res, () => {}); + await vi_waitFor(() => body !== undefined); + expect(res.statusCode).toBe(400); + expect(body).toContain("invalid_json"); + }); +}); + +/** Minimal poll helper — the middleware resolves its promise chain out of band. */ +async function vi_waitFor(predicate: () => boolean, timeoutMs = 1000): Promise { + const started = Date.now(); + while (!predicate()) { + if (Date.now() - started > timeoutMs) throw new Error("timed out waiting for the middleware to settle"); + await new Promise((resolve) => setTimeout(resolve, 5)); + } +} diff --git a/apps/loopover-miner-ui/vite-chat-api.ts b/apps/loopover-miner-ui/vite-chat-api.ts new file mode 100644 index 0000000000..1ba788cb03 --- /dev/null +++ b/apps/loopover-miner-ui/vite-chat-api.ts @@ -0,0 +1,158 @@ +import type { Plugin } from "vite"; + +// Streaming chat endpoint for the miner-ui chat rail (#6517). `POST /api/chat` grounds answers in the miner's own +// read-only `loopover_miner_*` MCP tools via the engine's chat-grounding module — this file is transport only: +// route match, body validation, and re-emitting the engine's events as Server-Sent Events. No grounding logic, +// no tool knowledge, and no action-dispatch lives here. +// +// Transport notes: +// - `text/event-stream`, one `data: \n\n` line per event, consumed client-side via fetch() + ReadableStream +// (not the native EventSource API, which cannot send a POST body). +// - A malformed/empty `messages` body is rejected as a plain non-streamed 4xx JSON error, before any model call. +// - `{"type":"done"}` always terminates a started stream — the engine guarantees it, including on its error paths. +// - Registered after authPlugin() in vite.config.ts, so vite-auth.ts's (#4858) session-cookie gate already rejects +// unauthenticated requests before this middleware is reached — no new auth mechanism here. + +/** Wire events mirrored from the engine's ChatGroundingEvent union; re-emitted verbatim as SSE data lines. */ +export type ChatSseEvent = + | { type: "text"; text: string } + | { type: "tool_call"; tool: string; input: Record } + | { type: "tool_result"; tool: string; output: unknown } + | { type: "error"; code: string; message: string } + | { type: "done" }; + +/** + * Hand-written view of the sibling engine package's surface (same convention as vite-run-state-api.ts's + * RunStateModule) — the app deliberately does not depend on the engine's emitted .d.ts. + */ +type ChatGroundingModule = { + isValidChatMessages: (value: unknown) => boolean; + runChatGrounding: ( + messages: Array<{ role: "user" | "assistant"; content: string }>, + options?: Record, + ) => AsyncIterable; +}; + +export type ChatApiDeps = { + loadChatGroundingModule: () => Promise; +}; + +const defaultDeps: ChatApiDeps = { + // The built engine output, not its TypeScript source — mirrors how vite-run-state-api.ts reaches into the + // sibling package (`packages/loopover-miner/lib/run-state.js`). + loadChatGroundingModule: () => import("../../packages/loopover-engine/dist/index.js") as Promise, +}; + +/** A buffered JSON reply (validation failures) or a live event stream. */ +export type ChatApiResult = + { kind: "json"; status: number; body: string } | { kind: "stream"; events: AsyncIterable }; + +/** Serializes one event as an SSE frame. One named place so the wire format can't drift between call sites. */ +export function formatSseEvent(event: ChatSseEvent): string { + return `data: ${JSON.stringify(event)}\n\n`; +} + +function matchesChatRoute(method: string | undefined, url: string | undefined): boolean { + return url === "/api/chat" && method === "POST"; +} + +function invalidBody(message: string): ChatApiResult { + return { kind: "json", status: 400, body: JSON.stringify({ error: message }) }; +} + +/** + * Factored out of the plugin for testing (the vite-run-state-api.ts convention). Returns `null` when this is not + * the chat route, so the middleware falls through. + */ +export async function handleChatRequest( + method: string | undefined, + url: string | undefined, + rawBody: string, + deps: ChatApiDeps = defaultDeps, +): Promise { + if (!matchesChatRoute(method, url)) return null; + + let parsed: unknown; + try { + parsed = JSON.parse(rawBody); + } catch { + return invalidBody("invalid_json: request body must be JSON"); + } + + const messages = (parsed as { messages?: unknown } | null)?.messages; + const chatModule = await deps.loadChatGroundingModule(); + if (!chatModule.isValidChatMessages(messages)) { + return invalidBody( + "invalid_messages: expected a non-empty messages array of {role: 'user'|'assistant', content: string} whose last entry is a user message", + ); + } + + return { + kind: "stream", + events: chatModule.runChatGrounding(messages as Array<{ role: "user" | "assistant"; content: string }>), + }; +} + +type ChatResponse = { + statusCode: number; + setHeader: (key: string, value: string) => void; + write: (chunk: string) => void; + end: (body?: string) => void; +}; + +/** Writes an event stream out as SSE frames. Exported for testing against a response double. */ +export async function writeSseStream(res: ChatResponse, events: AsyncIterable): Promise { + res.statusCode = 200; + res.setHeader("Content-Type", "text/event-stream"); + res.setHeader("Cache-Control", "no-cache"); + res.setHeader("Connection", "keep-alive"); + for await (const event of events) { + res.write(formatSseEvent(event)); + } + res.end(); +} + +/** Vite dev/preview middleware for the streaming read-only chat endpoint. */ +export function chatApiPlugin(deps: ChatApiDeps = defaultDeps): Plugin { + const attach = (middlewares: { + use: ( + fn: (req: { method?: string; url?: string } & NodeJS.ReadableStream, res: ChatResponse, next: () => void) => void, + ) => void; + }) => { + middlewares.use((req, res, next) => { + if (!matchesChatRoute(req.method, req.url)) return next(); + void readRequestBody(req) + .then((rawBody) => handleChatRequest(req.method, req.url, rawBody, deps)) + .then(async (handled) => { + if (!handled) return next(); + if (handled.kind === "json") { + res.statusCode = handled.status; + res.setHeader("Content-Type", "application/json"); + res.end(handled.body); + return; + } + await writeSseStream(res, handled.events); + }); + }); + }; + return { + name: "gittensory-miner-ui:chat-api", + configureServer(server) { + attach(server.middlewares); + }, + configurePreviewServer(server) { + attach(server.middlewares); + }, + }; +} + +function readRequestBody(req: NodeJS.ReadableStream): Promise { + return new Promise((resolve, reject) => { + let body = ""; + req.on("data", (chunk: Buffer | string) => { + body += chunk.toString(); + }); + req.on("end", () => resolve(body)); + req.on("error", reject); + }); +} diff --git a/apps/loopover-miner-ui/vite.config.ts b/apps/loopover-miner-ui/vite.config.ts index c74f04f7f3..75d39f5634 100644 --- a/apps/loopover-miner-ui/vite.config.ts +++ b/apps/loopover-miner-ui/vite.config.ts @@ -5,6 +5,7 @@ import { defineConfig } from "vite"; import tsconfigPaths from "vite-tsconfig-paths"; import { authPlugin } from "./vite-auth"; +import { chatApiPlugin } from "./vite-chat-api"; import { governorApiPlugin } from "./vite-governor-api"; import { ledgersApiPlugin } from "./vite-ledgers-api"; import { portfolioQueueActionsApiPlugin } from "./vite-portfolio-queue-actions-api"; @@ -21,6 +22,7 @@ export default defineConfig({ // Must run before the API plugins below: it rejects any unauthenticated /api/* request before their own // middlewares are reached (#4858). authPlugin(), + chatApiPlugin(), runStateApiPlugin(), portfolioQueueApiPlugin(), portfolioQueueActionsApiPlugin(), diff --git a/packages/loopover-engine/src/index.ts b/packages/loopover-engine/src/index.ts index 72be673ce8..4539cedf7c 100644 --- a/packages/loopover-engine/src/index.ts +++ b/packages/loopover-engine/src/index.ts @@ -387,6 +387,24 @@ export { type AgentSdkQueryOptions, type CreateAgentSdkDriverOptions, } from "./miner/agent-sdk-driver.js"; +export { + buildChatPrompt, + CHAT_GROUNDING_MCP_SERVER_NAME, + CHAT_GROUNDING_TOOL_NAMES, + CHAT_REDACTED_TEXT, + CHAT_SYSTEM_PROMPT, + containsBlockedTerm, + isValidChatMessages, + redactBlockedText, + resolveChatProviderError, + resolveChatQuery, + runChatGrounding, + type ChatGroundingEvent, + type ChatMessage, + type ChatQueryFn, + type ChatQueryOptions, + type RunChatGroundingOptions, +} from "./miner/chat-grounding.js"; export { LOCAL_WRITE_BOUNDARY, buildApplyLabelsSpec, diff --git a/packages/loopover-engine/src/miner/chat-grounding.ts b/packages/loopover-engine/src/miner/chat-grounding.ts new file mode 100644 index 0000000000..5bf4fb4f91 --- /dev/null +++ b/packages/loopover-engine/src/miner/chat-grounding.ts @@ -0,0 +1,287 @@ +// Read-only conversational grounding for the miner-ui chat rail (#6517). Answers natural-language questions about +// the miner's OWN local state by driving `@anthropic-ai/claude-agent-sdk`'s `query()` against the miner's existing +// read-only MCP server (packages/loopover-miner/bin/loopover-miner-mcp.js), so the 11 tools' implementations are +// called directly and never reimplemented here. +// +// Boundaries this module enforces, in order: +// 1. Provider fail-closed. Only the `agent-sdk` provider is usable: driver-factory.ts's `claude-cli`/`codex-cli` +// drivers are task-shaped, single-turn, buffered CodingAgentDriverResult interfaces built for one-shot coding +// attempts — not a conversational streaming tool-calling loop. Any other/absent provider emits one `error` +// event and `done`, never a partial/mock/echoed answer. +// 2. Tool allowlist. The session may only reach the 11 read-only `loopover_miner_*` tools below — no write-capable +// `loopover_*` tool (local-write-tools.ts's LOCAL_WRITE_BOUNDARY) and no action-dispatch route. +// 3. Privacy. A conversational surface adds a leak vector the tools themselves don't have: a user can simply ASK +// "what's my trust score" and an ungrounded model could hallucinate one. The system prompt instructs the model +// to decline those terms, and — because a prompt is not enforcement — every outgoing `text` chunk is checked +// against track-record-summary.ts's PUBLIC_FIELD_BLOCKLIST and redacted on a hit. +// +// The endpoint is stateless: the caller supplies the full message history per request (no conversation store). + +import { PUBLIC_FIELD_BLOCKLIST } from "../track-record-summary.js"; +import { resolveFirstConfiguredCodingAgentDriverName } from "./driver-factory.js"; + +/** + * The exact read-only tools this endpoint may call — one `server.registerTool(...)` call each in + * packages/loopover-miner/bin/loopover-miner-mcp.js. Frozen and asserted by an invariant test so a future + * accidental addition of a 12th tool (or a write-capable one) fails the suite, not just code review. + */ +export const CHAT_GROUNDING_TOOL_NAMES = Object.freeze([ + "loopover_miner_ping", + "loopover_miner_get_portfolio_dashboard", + "loopover_miner_get_manage_status", + "loopover_miner_list_claims", + "loopover_miner_get_audit_feed", + "loopover_miner_get_run_state", + "loopover_miner_list_plans", + "loopover_miner_get_plan", + "loopover_miner_get_governor_decisions", + "loopover_miner_status", + "loopover_miner_get_calibration_report", +] as const); + +/** The MCP server name the session registers the miner tools under. */ +export const CHAT_GROUNDING_MCP_SERVER_NAME = "loopover-miner"; + +/** Ceiling on a single conversational session's tool-calling turns. */ +const CHAT_MAX_TURNS = 12; + +/** Replacement written in place of any `text` chunk that trips the privacy backstop. */ +export const CHAT_REDACTED_TEXT = + "[redacted: this assistant has no access to wallet, hotkey, coldkey, reward, payout, or trust-score data]"; + +/** + * System prompt. The declined-term sentence is derived from track-record-summary.ts's PUBLIC_FIELD_BLOCKLIST — + * the same term set the output-side backstop enforces, so the instruction and the enforcement can't drift. + */ +export const CHAT_SYSTEM_PROMPT = [ + "You are the Loopover miner's local assistant. You answer questions about this miner's own local state only.", + "", + "Ground every answer in the read-only loopover_miner_* tools available to you. If a tool cannot answer the", + "question, say so plainly — never guess, estimate, or invent a value.", + "", + "You have no access to wallet, hotkey, coldkey, reward, payout, ranking, or trust-score data: none of the", + "available tools expose it. If asked for any of those, say plainly that this data is not available to you", + "rather than producing a number.", + "", + "You are read-only. You cannot open pull requests, file issues, pause or resume the governor, or release or", + "requeue portfolio work. If asked to do any of those, explain that this chat cannot take actions.", +].join("\n"); + +/** A single conversational turn supplied by the caller. */ +export type ChatMessage = { + role: "user" | "assistant"; + content: string; +}; + +/** The wire events this module yields; the transport re-emits each one verbatim as an SSE `data:` line. */ +export type ChatGroundingEvent = + | { type: "text"; text: string } + | { type: "tool_call"; tool: string; input: Record } + | { type: "tool_result"; tool: string; output: unknown } + | { type: "error"; code: string; message: string } + | { type: "done" }; + +/** The exact option subset this module puts on a chat `query()` session. */ +export type ChatQueryOptions = { + systemPrompt: string; + allowedTools: readonly string[]; + mcpServers: Record; + maxTurns: number; +}; + +/** + * Injected `query()`-shaped function — mirrors agent-sdk-driver.ts's AgentSdkQueryFn convention so tests drive a + * fake async-iterable and CI never makes a real model call. Messages are consumed structurally (plain records). + */ +export type ChatQueryFn = (input: { + prompt: string; + options: ChatQueryOptions; +}) => AsyncIterable>; + +export type RunChatGroundingOptions = { + /** Injected `query()` loop; defaults to the real `@anthropic-ai/claude-agent-sdk` export. */ + query?: ChatQueryFn | undefined; + /** Env used for provider resolution; defaults to `process.env`. */ + env?: Record | undefined; + /** Command/args that start the miner's read-only MCP server over stdio. */ + mcpServer?: { command: string; args: string[] } | undefined; +}; + +/* v8 ignore start -- real-SDK path: imports @anthropic-ai/claude-agent-sdk and opens a live session; tests inject + a fake ChatQueryFn instead (same convention as agent-sdk-driver.ts's injected AgentSdkQueryFn). */ +const defaultQuery: ChatQueryFn = (input) => { + async function* stream(): AsyncGenerator> { + const sdk = (await import("@anthropic-ai/claude-agent-sdk")) as unknown as { + query: (params: { prompt: string; options?: Record }) => AsyncIterable; + }; + for await (const message of sdk.query({ prompt: input.prompt, options: input.options })) { + yield message as Record; + } + } + return stream(); +}; +/* v8 ignore stop */ + +/** Default stdio command for the miner's own MCP server — the bin `packages/loopover-miner/package.json` exposes. */ +const DEFAULT_MCP_SERVER = Object.freeze({ + command: "npx", + args: Object.freeze(["-y", "@loopover/miner", "loopover-miner-mcp"]) as unknown as string[], +}); + +/** + * Resolves the injected seam, defaulting to the real SDK loop. Split out of `runChatGrounding` (which would invoke + * the result immediately) so the default arm is exercised by binding it, never by opening a live session — + * mirroring how agent-sdk-driver.ts's factory resolves `options.query ?? defaultQuery` without calling it. + */ +export function resolveChatQuery(options: RunChatGroundingOptions = {}): ChatQueryFn { + return options.query ?? defaultQuery; +} + +function asRecord(value: unknown): Record | undefined { + return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : undefined; +} + +/** True when any blocked term appears — the enforcement half of the privacy boundary. */ +export function containsBlockedTerm(text: string): boolean { + return PUBLIC_FIELD_BLOCKLIST.some((pattern) => pattern.test(text)); +} + +/** + * Output-side backstop: a chunk mentioning a blocked term is replaced wholesale rather than forwarded. Replacing + * (not filtering) keeps the stream well-formed and makes the refusal visible to the user. + */ +export function redactBlockedText(text: string): string { + return containsBlockedTerm(text) ? CHAT_REDACTED_TEXT : text; +} + +/** Validates the caller-supplied history. Stateless endpoint: the full history arrives per request. */ +export function isValidChatMessages(value: unknown): value is ChatMessage[] { + if (!Array.isArray(value) || value.length === 0) return false; + for (const entry of value) { + const record = asRecord(entry); + if (!record) return false; + if (record.role !== "user" && record.role !== "assistant") return false; + if (typeof record.content !== "string" || record.content.length === 0) return false; + } + return asRecord(value[value.length - 1])?.role === "user"; +} + +/** + * Serializes the caller's history into a single prompt. The SDK session is opened per request (stateless), so the + * prior turns are replayed as labelled context ahead of the live question. + */ +export function buildChatPrompt(messages: ChatMessage[]): string { + const history = messages.slice(0, -1); + const latest = messages[messages.length - 1]; + const lines: string[] = []; + if (history.length > 0) { + lines.push("Conversation so far:"); + for (const message of history) { + lines.push(`${message.role === "user" ? "User" : "Assistant"}: ${message.content}`); + } + lines.push(""); + } + lines.push(`User: ${latest?.content ?? ""}`); + return lines.join("\n"); +} + +/** + * Resolves the provider and returns the fail-closed error code when chat is not usable, or `undefined` when the + * configured provider is `agent-sdk`. Reuses driver-factory.ts's resolution rather than reading MINER_CODING_AGENT_* + * directly, so provider parsing lives in exactly one place. + */ +export function resolveChatProviderError( + env: Record, +): { code: string; message: string } | undefined { + const provider = resolveFirstConfiguredCodingAgentDriverName(env); + if (provider === undefined) { + return { + code: "no_coding_agent_configured", + message: + "No coding-agent provider is configured. Chat requires the agent-sdk provider — set MINER_CODING_AGENT_PROVIDER=agent-sdk.", + }; + } + if (provider !== "agent-sdk") { + return { + code: "chat_requires_agent_sdk_provider", + message: `Chat requires the agent-sdk provider; the configured provider is ${provider}, which is a single-turn, buffered coding driver.`, + }; + } + return undefined; +} + +/** Folds one assistant message's content blocks into wire events. */ +function* foldAssistantMessage(message: Record): Generator { + const content = asRecord(message.message)?.content; + if (!Array.isArray(content)) return; + for (const rawBlock of content) { + const block = asRecord(rawBlock); + if (!block) continue; + if (block.type === "text" && typeof block.text === "string") { + yield { type: "text", text: redactBlockedText(block.text) }; + continue; + } + if (block.type === "tool_use" && typeof block.name === "string") { + yield { type: "tool_call", tool: block.name, input: asRecord(block.input) ?? {} }; + } + } +} + +/** Folds one user message's tool-result blocks (the SDK reports tool output on a `user`-role message). */ +function* foldToolResultMessage(message: Record): Generator { + const content = asRecord(message.message)?.content; + if (!Array.isArray(content)) return; + for (const rawBlock of content) { + const block = asRecord(rawBlock); + if (!block || block.type !== "tool_result") continue; + const tool = typeof block.tool_use_id === "string" ? block.tool_use_id : ""; + yield { type: "tool_result", tool, output: block.content }; + } +} + +/** + * Drives one grounded conversational turn, yielding wire events. Never throws: an SDK failure becomes an `error` + * event, and `done` always terminates the stream — including on the fail-closed provider paths. + */ +export async function* runChatGrounding( + messages: ChatMessage[], + options: RunChatGroundingOptions = {}, +): AsyncGenerator { + const env = options.env ?? process.env; + const providerError = resolveChatProviderError(env); + if (providerError) { + yield { type: "error", code: providerError.code, message: providerError.message }; + yield { type: "done" }; + return; + } + + const query = resolveChatQuery(options); + const mcpServer = options.mcpServer ?? DEFAULT_MCP_SERVER; + try { + const stream = query({ + prompt: buildChatPrompt(messages), + options: { + systemPrompt: CHAT_SYSTEM_PROMPT, + allowedTools: CHAT_GROUNDING_TOOL_NAMES, + mcpServers: { [CHAT_GROUNDING_MCP_SERVER_NAME]: mcpServer }, + maxTurns: CHAT_MAX_TURNS, + }, + }); + for await (const message of stream) { + if (message.type === "assistant") { + yield* foldAssistantMessage(message); + continue; + } + if (message.type === "user") { + yield* foldToolResultMessage(message); + } + } + } catch (error) { + yield { + type: "error", + code: "chat_grounding_failed", + message: error instanceof Error ? error.message : String(error), + }; + } + yield { type: "done" }; +} diff --git a/packages/loopover-engine/src/track-record-summary.ts b/packages/loopover-engine/src/track-record-summary.ts index d3bf5cf126..9f53710da2 100644 --- a/packages/loopover-engine/src/track-record-summary.ts +++ b/packages/loopover-engine/src/track-record-summary.ts @@ -107,7 +107,12 @@ const RESOLVED_MERGED_STATES = new Set(["merged", "merge", "accepted"]); const RESOLVED_CLOSED_STATES = new Set(["closed", "declined", "rejected", "closed_unmerged", "not_merged"]); const OPEN_STATES = new Set(["open", "draft", "pending", "ready_for_review"]); const INCIDENT_KINDS = new Set(["ban", "moderation", "code_of_conduct", "abuse", "spam"]); -const PUBLIC_FIELD_BLOCKLIST = [ +/** + * Terms that must never reach a public/non-owner surface. Exported (#6517) so the miner chat + * grounding endpoint reuses this exact term set for its system-prompt instruction and its + * output-side redaction backstop rather than defining a second, drifting copy. + */ +export const PUBLIC_FIELD_BLOCKLIST = [ /\btrust\s*score\b/iu, /\btrustscore\b/iu, /\bscoreability\b/iu, diff --git a/packages/loopover-engine/test/chat-grounding.test.ts b/packages/loopover-engine/test/chat-grounding.test.ts new file mode 100644 index 0000000000..c312e32d0b --- /dev/null +++ b/packages/loopover-engine/test/chat-grounding.test.ts @@ -0,0 +1,195 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { + buildChatPrompt, + CHAT_GROUNDING_MCP_SERVER_NAME, + CHAT_GROUNDING_TOOL_NAMES, + CHAT_REDACTED_TEXT, + CHAT_SYSTEM_PROMPT, + containsBlockedTerm, + isValidChatMessages, + redactBlockedText, + resolveChatProviderError, + resolveChatQuery, + runChatGrounding, + type ChatGroundingEvent, + type ChatMessage, + type ChatQueryFn, +} from "../dist/index.js"; + +// Read-only conversational grounding (#6517). Every test drives an injected fake ChatQueryFn — CI never opens a +// real agent-sdk session. Mirrored as a vitest suite at test/unit/chat-grounding-engine.test.ts, which is what +// codecov/patch actually measures for packages/loopover-engine/src/**. + +const AGENT_SDK_ENV = { MINER_CODING_AGENT_PROVIDER: "agent-sdk" }; +const USER_ONLY: ChatMessage[] = [{ role: "user", content: "what is my run state?" }]; + +function queryYielding( + messages: Array>, + captured?: { input?: Parameters[0] }, +): ChatQueryFn { + return (input) => { + if (captured) captured.input = input; + return (async function* () { + yield* messages; + })(); + }; +} + +function assistantText(text: string): Record { + return { type: "assistant", message: { content: [{ type: "text", text }] } }; +} + +async function collect(events: AsyncIterable): Promise { + const out: ChatGroundingEvent[] = []; + for await (const event of events) out.push(event); + return out; +} + +test("the tool allowlist is exactly the 11 read-only loopover_miner_* tools", () => { + assert.deepEqual( + [...CHAT_GROUNDING_TOOL_NAMES], + [ + "loopover_miner_ping", + "loopover_miner_get_portfolio_dashboard", + "loopover_miner_get_manage_status", + "loopover_miner_list_claims", + "loopover_miner_get_audit_feed", + "loopover_miner_get_run_state", + "loopover_miner_list_plans", + "loopover_miner_get_plan", + "loopover_miner_get_governor_decisions", + "loopover_miner_status", + "loopover_miner_get_calibration_report", + ], + ); + assert.equal(CHAT_GROUNDING_TOOL_NAMES.length, 11); +}); + +test("the session receives exactly the allowlist, the miner MCP server, and the system prompt", async () => { + const captured: { input?: Parameters[0] } = {}; + await collect( + runChatGrounding(USER_ONLY, { + env: AGENT_SDK_ENV, + query: queryYielding([assistantText("ok")], captured), + mcpServer: { command: "node", args: ["mcp.js"] }, + }), + ); + assert.deepEqual(captured.input?.options.allowedTools, [...CHAT_GROUNDING_TOOL_NAMES]); + assert.deepEqual(captured.input?.options.mcpServers, { + [CHAT_GROUNDING_MCP_SERVER_NAME]: { command: "node", args: ["mcp.js"] }, + }); + assert.equal(captured.input?.options.systemPrompt, CHAT_SYSTEM_PROMPT); +}); + +test("an unconfigured provider fails closed without calling the model", async () => { + let called = false; + const events = await collect( + runChatGrounding(USER_ONLY, { + env: {}, + query: () => { + called = true; + return (async function* () {})(); + }, + }), + ); + assert.equal(called, false); + assert.deepEqual(events[0]?.type, "error"); + assert.equal((events[0] as { code: string }).code, "no_coding_agent_configured"); + assert.deepEqual(events.at(-1), { type: "done" }); +}); + +test("the single-turn CLI drivers fail closed with chat_requires_agent_sdk_provider", async () => { + for (const provider of ["claude-cli", "codex-cli"]) { + const events = await collect(runChatGrounding(USER_ONLY, { env: { MINER_CODING_AGENT_PROVIDER: provider } })); + assert.equal((events[0] as { code: string }).code, "chat_requires_agent_sdk_provider"); + assert.deepEqual(events.at(-1), { type: "done" }); + } + assert.equal(resolveChatProviderError(AGENT_SDK_ENV), undefined); +}); + +test("text chunks stream through and done terminates", async () => { + const events = await collect( + runChatGrounding(USER_ONLY, { env: AGENT_SDK_ENV, query: queryYielding([assistantText("run state is idle")]) }), + ); + assert.deepEqual(events, [{ type: "text", text: "run state is idle" }, { type: "done" }]); +}); + +test("tool_use and tool_result blocks become tool_call / tool_result events", async () => { + const events = await collect( + runChatGrounding(USER_ONLY, { + env: AGENT_SDK_ENV, + query: queryYielding([ + { + type: "assistant", + message: { content: [{ type: "tool_use", name: "loopover_miner_status", input: { verbose: true } }] }, + }, + { type: "user", message: { content: [{ type: "tool_result", tool_use_id: "loopover_miner_status", content: "{}" }] } }, + ]), + }), + ); + assert.deepEqual(events, [ + { type: "tool_call", tool: "loopover_miner_status", input: { verbose: true } }, + { type: "tool_result", tool: "loopover_miner_status", output: "{}" }, + { type: "done" }, + ]); +}); + +test("a blocked term in a text chunk is redacted, a clean chunk is forwarded verbatim", async () => { + assert.equal(containsBlockedTerm("your trust score is 9"), true); + assert.equal(redactBlockedText("your trust score is 9"), CHAT_REDACTED_TEXT); + assert.equal(redactBlockedText("run state is idle"), "run state is idle"); + const events = await collect( + runChatGrounding(USER_ONLY, { env: AGENT_SDK_ENV, query: queryYielding([assistantText("your wallet balance")]) }), + ); + assert.deepEqual(events, [{ type: "text", text: CHAT_REDACTED_TEXT }, { type: "done" }]); +}); + +test("a thrown session becomes an error event still followed by done", async () => { + const events = await collect( + runChatGrounding(USER_ONLY, { + env: AGENT_SDK_ENV, + query: () => { + throw new Error("session boom"); + }, + }), + ); + assert.deepEqual(events, [ + { type: "error", code: "chat_grounding_failed", message: "session boom" }, + { type: "done" }, + ]); +}); + +test("message validation accepts a user-terminated history and rejects malformed input", () => { + assert.equal(isValidChatMessages(USER_ONLY), true); + assert.equal(isValidChatMessages([]), false); + assert.equal(isValidChatMessages("nope"), false); + assert.equal(isValidChatMessages([{ role: "system", content: "x" }]), false); + assert.equal(isValidChatMessages([{ role: "user", content: "" }]), false); + assert.equal( + isValidChatMessages([ + { role: "user", content: "hi" }, + { role: "assistant", content: "hello" }, + ]), + false, + ); +}); + +test("the prompt replays prior turns as labelled context", () => { + assert.equal( + buildChatPrompt([ + { role: "user", content: "hi" }, + { role: "assistant", content: "hello" }, + { role: "user", content: "status?" }, + ]), + "Conversation so far:\nUser: hi\nAssistant: hello\n\nUser: status?", + ); + assert.equal(buildChatPrompt(USER_ONLY), "User: what is my run state?"); +}); + +test("the injected seam resolves to the fake when given, and to a function otherwise", () => { + const fake: ChatQueryFn = () => (async function* () {})(); + assert.equal(resolveChatQuery({ query: fake }), fake); + assert.equal(typeof resolveChatQuery(), "function"); +}); diff --git a/test/unit/chat-grounding-engine.test.ts b/test/unit/chat-grounding-engine.test.ts new file mode 100644 index 0000000000..d433a5510c --- /dev/null +++ b/test/unit/chat-grounding-engine.test.ts @@ -0,0 +1,339 @@ +import { describe, expect, it } from "vitest"; + +import { + buildChatPrompt, + CHAT_GROUNDING_MCP_SERVER_NAME, + CHAT_GROUNDING_TOOL_NAMES, + CHAT_REDACTED_TEXT, + CHAT_SYSTEM_PROMPT, + containsBlockedTerm, + isValidChatMessages, + redactBlockedText, + resolveChatProviderError, + resolveChatQuery, + runChatGrounding, + type ChatGroundingEvent, + type ChatMessage, + type ChatQueryFn, +} from "../../packages/loopover-engine/src/index"; + +// Vitest mirror of packages/loopover-engine/test/chat-grounding.test.ts (#6517). codecov/patch is computed from +// this app vitest run (vitest.config coverage includes packages/loopover-engine/src/**), so the changed engine +// lines need a vitest test that imports the SRC directly — the engine's own node:test suite is not collected here. + +const AGENT_SDK_ENV = { MINER_CODING_AGENT_PROVIDER: "agent-sdk" }; + +function queryYielding( + messages: Array>, + captured?: { input?: Parameters[0] }, +): ChatQueryFn { + return (input) => { + if (captured) captured.input = input; + return (async function* () { + yield* messages; + })(); + }; +} + +function assistantText(text: string): Record { + return { type: "assistant", message: { content: [{ type: "text", text }] } }; +} + +async function collect(events: AsyncIterable): Promise { + const out: ChatGroundingEvent[] = []; + for await (const event of events) out.push(event); + return out; +} + +const USER_ONLY: ChatMessage[] = [{ role: "user", content: "what is my run state?" }]; + +describe("chat grounding tool allowlist (#6517)", () => { + it("is exactly the 11 read-only loopover_miner_* tools", () => { + // Invariant: a future accidental addition of a 12th tool (or a write-capable one) fails here, not just review. + expect([...CHAT_GROUNDING_TOOL_NAMES]).toEqual([ + "loopover_miner_ping", + "loopover_miner_get_portfolio_dashboard", + "loopover_miner_get_manage_status", + "loopover_miner_list_claims", + "loopover_miner_get_audit_feed", + "loopover_miner_get_run_state", + "loopover_miner_list_plans", + "loopover_miner_get_plan", + "loopover_miner_get_governor_decisions", + "loopover_miner_status", + "loopover_miner_get_calibration_report", + ]); + expect(CHAT_GROUNDING_TOOL_NAMES).toHaveLength(11); + for (const name of CHAT_GROUNDING_TOOL_NAMES) { + expect(name.startsWith("loopover_miner_")).toBe(true); + } + }); + + it("passes exactly that allowlist and the miner MCP server to the session", async () => { + const captured: { input?: Parameters[0] } = {}; + await collect( + runChatGrounding(USER_ONLY, { + env: AGENT_SDK_ENV, + query: queryYielding([assistantText("ok")], captured), + mcpServer: { command: "node", args: ["mcp.js"] }, + }), + ); + expect(captured.input?.options.allowedTools).toEqual([...CHAT_GROUNDING_TOOL_NAMES]); + expect(captured.input?.options.mcpServers).toEqual({ + [CHAT_GROUNDING_MCP_SERVER_NAME]: { command: "node", args: ["mcp.js"] }, + }); + expect(captured.input?.options.systemPrompt).toBe(CHAT_SYSTEM_PROMPT); + }); + + it("falls back to the packaged miner MCP server command when none is injected", async () => { + const captured: { input?: Parameters[0] } = {}; + await collect( + runChatGrounding(USER_ONLY, { env: AGENT_SDK_ENV, query: queryYielding([assistantText("ok")], captured) }), + ); + const server = captured.input?.options.mcpServers[CHAT_GROUNDING_MCP_SERVER_NAME]; + expect(server?.command).toBe("npx"); + expect(server?.args).toContain("loopover-miner-mcp"); + }); +}); + +describe("chat grounding provider resolution (#6517)", () => { + it("fails closed with no_coding_agent_configured when nothing is configured", async () => { + expect(resolveChatProviderError({})?.code).toBe("no_coding_agent_configured"); + const events = await collect(runChatGrounding(USER_ONLY, { env: {} })); + expect(events).toEqual([ + { type: "error", code: "no_coding_agent_configured", message: expect.any(String) }, + { type: "done" }, + ]); + }); + + it.each(["claude-cli", "codex-cli"])( + "fails closed with chat_requires_agent_sdk_provider for the %s driver", + async (provider) => { + const env = { MINER_CODING_AGENT_PROVIDER: provider }; + expect(resolveChatProviderError(env)?.code).toBe("chat_requires_agent_sdk_provider"); + const events = await collect(runChatGrounding(USER_ONLY, { env })); + expect(events).toEqual([ + { type: "error", code: "chat_requires_agent_sdk_provider", message: expect.stringContaining(provider) }, + { type: "done" }, + ]); + }, + ); + + it("never calls the model on a fail-closed provider path", async () => { + let called = false; + const events = await collect( + runChatGrounding(USER_ONLY, { + env: { MINER_CODING_AGENT_PROVIDER: "claude-cli" }, + query: () => { + called = true; + return (async function* () {})(); + }, + }), + ); + expect(called).toBe(false); + expect(events.at(-1)).toEqual({ type: "done" }); + }); + + it("returns undefined (chat usable) for the agent-sdk provider", () => { + expect(resolveChatProviderError(AGENT_SDK_ENV)).toBeUndefined(); + }); +}); + +describe("chat grounding streaming (#6517)", () => { + it("emits text chunks then a terminating done", async () => { + const events = await collect( + runChatGrounding(USER_ONLY, { + env: AGENT_SDK_ENV, + query: queryYielding([assistantText("your run "), assistantText("state is idle")]), + }), + ); + expect(events).toEqual([ + { type: "text", text: "your run " }, + { type: "text", text: "state is idle" }, + { type: "done" }, + ]); + }); + + it("emits tool_call for a tool_use block and tool_result for a tool-result message", async () => { + const events = await collect( + runChatGrounding(USER_ONLY, { + env: AGENT_SDK_ENV, + query: queryYielding([ + { + type: "assistant", + message: { + content: [{ type: "tool_use", name: "loopover_miner_get_run_state", input: { repoFullName: "a/b" } }], + }, + }, + { + type: "user", + message: { content: [{ type: "tool_result", tool_use_id: "loopover_miner_get_run_state", content: "{}" }] }, + }, + ]), + }), + ); + expect(events).toEqual([ + { type: "tool_call", tool: "loopover_miner_get_run_state", input: { repoFullName: "a/b" } }, + { type: "tool_result", tool: "loopover_miner_get_run_state", output: "{}" }, + { type: "done" }, + ]); + }); + + it("defaults a tool_use with no input to an empty object and a tool_result with no id to an empty name", async () => { + const events = await collect( + runChatGrounding(USER_ONLY, { + env: AGENT_SDK_ENV, + query: queryYielding([ + { type: "assistant", message: { content: [{ type: "tool_use", name: "loopover_miner_ping" }] } }, + { type: "user", message: { content: [{ type: "tool_result", content: "pong" }] } }, + ]), + }), + ); + expect(events).toEqual([ + { type: "tool_call", tool: "loopover_miner_ping", input: {} }, + { type: "tool_result", tool: "", output: "pong" }, + { type: "done" }, + ]); + }); + + it("ignores unknown messages and malformed/non-array content without throwing", async () => { + const events = await collect( + runChatGrounding(USER_ONLY, { + env: AGENT_SDK_ENV, + query: queryYielding([ + { type: "system", message: { content: [{ type: "text", text: "ignored" }] } }, + { type: "assistant", message: { content: "not-an-array" } }, + { type: "assistant" }, + { type: "user", message: { content: "not-an-array" } }, + { type: "assistant", message: { content: [null, { type: "other" }, { type: "text" }] } }, + { type: "user", message: { content: [null, { type: "text", text: "not-a-tool-result" }] } }, + ]), + }), + ); + expect(events).toEqual([{ type: "done" }]); + }); + + it("turns a thrown SDK error into an error event still followed by done", async () => { + const events = await collect( + runChatGrounding(USER_ONLY, { + env: AGENT_SDK_ENV, + query: () => { + throw new Error("session boom"); + }, + }), + ); + expect(events).toEqual([ + { type: "error", code: "chat_grounding_failed", message: "session boom" }, + { type: "done" }, + ]); + }); + + it("stringifies a non-Error rejection", async () => { + const events = await collect( + runChatGrounding(USER_ONLY, { + env: AGENT_SDK_ENV, + query: () => + (async function* (): AsyncGenerator> { + throw "plain string"; + })(), + }), + ); + expect(events[0]).toEqual({ type: "error", code: "chat_grounding_failed", message: "plain string" }); + }); +}); + +describe("chat grounding privacy backstop (#6517)", () => { + it("declines the blocked terms in the system prompt", () => { + for (const term of ["wallet", "hotkey", "coldkey", "reward", "payout", "trust-score"]) { + expect(CHAT_SYSTEM_PROMPT).toContain(term); + } + }); + + it.each(["your trust score is 9", "wallet balance", "the hotkey is x", "coldkey", "reward pool", "payout due", "ranking"])( + "redacts a text chunk containing a blocked term (%s)", + async (text) => { + expect(containsBlockedTerm(text)).toBe(true); + expect(redactBlockedText(text)).toBe(CHAT_REDACTED_TEXT); + const events = await collect( + runChatGrounding(USER_ONLY, { env: AGENT_SDK_ENV, query: queryYielding([assistantText(text)]) }), + ); + expect(events).toEqual([{ type: "text", text: CHAT_REDACTED_TEXT }, { type: "done" }]); + }, + ); + + it("forwards a clean chunk verbatim", async () => { + expect(containsBlockedTerm("your run state is idle")).toBe(false); + expect(redactBlockedText("your run state is idle")).toBe("your run state is idle"); + const events = await collect( + runChatGrounding(USER_ONLY, { env: AGENT_SDK_ENV, query: queryYielding([assistantText("your run state is idle")]) }), + ); + expect(events).toEqual([{ type: "text", text: "your run state is idle" }, { type: "done" }]); + }); +}); + +describe("chat message validation + prompt building (#6517)", () => { + it("accepts a well-formed history ending in a user message", () => { + expect(isValidChatMessages(USER_ONLY)).toBe(true); + expect( + isValidChatMessages([ + { role: "user", content: "hi" }, + { role: "assistant", content: "hello" }, + { role: "user", content: "status?" }, + ]), + ).toBe(true); + }); + + it.each([ + ["not an array", "nope"], + ["empty array", []], + ["null entry", [null]], + ["unknown role", [{ role: "system", content: "x" }]], + ["non-string content", [{ role: "user", content: 1 }]], + ["empty content", [{ role: "user", content: "" }]], + ["last message not from the user", [{ role: "user", content: "hi" }, { role: "assistant", content: "hello" }]], + ])("rejects %s", (_label, value) => { + expect(isValidChatMessages(value)).toBe(false); + }); + + it("builds a prompt with prior turns replayed as labelled context", () => { + expect( + buildChatPrompt([ + { role: "user", content: "hi" }, + { role: "assistant", content: "hello" }, + { role: "user", content: "status?" }, + ]), + ).toBe("Conversation so far:\nUser: hi\nAssistant: hello\n\nUser: status?"); + }); + + it("builds a bare prompt for a single user turn", () => { + expect(buildChatPrompt(USER_ONLY)).toBe("User: what is my run state?"); + }); + + it("tolerates an empty history (no last message) rather than throwing", () => { + // isValidChatMessages rejects this upstream; buildChatPrompt still must not throw on the nullish arm. + expect(buildChatPrompt([])).toBe("User: "); + }); +}); + +describe("chat grounding seam resolution (#6517)", () => { + it("returns the injected query when provided", () => { + const fake: ChatQueryFn = () => (async function* () {})(); + expect(resolveChatQuery({ query: fake })).toBe(fake); + }); + + it("falls back to the real SDK loop when none is injected, without opening a session", () => { + // Binding the default must not import or call the SDK — only invoking the returned fn would. + expect(typeof resolveChatQuery()).toBe("function"); + expect(typeof resolveChatQuery({})).toBe("function"); + }); + + it("resolves the provider from process.env when no env is injected", async () => { + // The `options.env ?? process.env` arm: this test process has no MINER_CODING_AGENT_PROVIDER, so chat + // fails closed rather than reaching the model. + const events = await collect(runChatGrounding(USER_ONLY)); + expect(events).toEqual([ + { type: "error", code: "no_coding_agent_configured", message: expect.any(String) }, + { type: "done" }, + ]); + }); +});