diff --git a/.changeset/calm-gateway-readback.md b/.changeset/calm-gateway-readback.md new file mode 100644 index 0000000..8e7e838 --- /dev/null +++ b/.changeset/calm-gateway-readback.md @@ -0,0 +1,7 @@ +--- +"grok-bot-cli": patch +--- + +Bound gateway requests and response-body reads with a deadline, redact error +details, reject redirects, and label ambiguous sends without automatic retries. +Render nested message content in plain-text transcripts. diff --git a/.changeset/stdin-normalized-transcripts.md b/.changeset/stdin-normalized-transcripts.md new file mode 100644 index 0000000..4d11901 --- /dev/null +++ b/.changeset/stdin-normalized-transcripts.md @@ -0,0 +1,11 @@ +--- +"grok-bot-cli": minor +--- + +Add `send --stdin` so a message can be passed on standard input instead +of the process argument list, with strict UTF-8, non-empty, no-NUL, no +surrounding whitespace and 64 KiB validation. Add a `--normalized` transcript +contract for `thread`/`chat` JSON output that returns only the target identity +and messages with `id`, an explicit `user`/`assistant`/`unknown` role, and text. +The normalizer fails closed on malformed or conflicting evidence rather than +guessing, and keeps normalized text separate from display stringification. diff --git a/README.md b/README.md index cb0df33..a16a507 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,9 @@ gbot groups create --name Launch --member Researcher --member Writer --descripti gbot groups update Launch --title "Launch room" --hidden off gbot send Researcher "Summarize the launch status." gbot send Launch "Share your updates." +printf %s 'Exact UTF-8 message' | gbot send Researcher --stdin gbot thread Researcher +gbot --json thread Researcher --normalized --limit 50 gbot groups delete Launch gbot bots delete Researcher gbot bots delete Writer @@ -37,6 +39,36 @@ gbot bots delete Writer Run `gbot --help` for every command. +Use `send --stdin` when the message must not appear in the process +argument list. Standard input is preserved exactly and must be valid UTF-8, +non-empty, free of NUL bytes and surrounding whitespace, and no larger than +64 KiB. Do not combine `--stdin` with a positional message. In particular, use +`printf %s` rather than `echo` when an extra trailing newline is not intended. + +For integrations that need a stable transcript shape, combine `--normalized` +with `--json thread` or `--json chat`. It returns only the target identity and +messages with `id`, explicit `user`/`assistant`/`unknown` role, and text. Without +`--normalized`, JSON output remains the original gateway response. + +## Gateway failures and readback + +Gateway requests have a 15-second deadline that includes reading the response body. +Requests are not retried automatically, and redirects are rejected. Errors report +the method and status without including server response bodies or credentials. + +A send timeout, network failure, HTTP 408 or 5xx, or an invalid/empty success +response can leave delivery unknown. Do not resend automatically: read the target +thread and verify the original message in the Grok Bot app first. A new CLI send +uses a new client nonce, so invoking it again is not a deduplicated retry. + +The gateway module accepts an optional positive `timeoutMs` in the final options +argument of `ensureSandbox` and `gatewayCall`. The CLI uses the 15-second default. + +Plain-text transcript output handles both direct content and nested +`message.content` / `message.text`. Use `--json` when the full structured result is +needed. These integrations depend on the signed-in app and its internal gateway; +revalidate reads and one controlled send after app or service changes. + ## License MIT diff --git a/src/cli.js b/src/cli.js index 58fe5be..445ed16 100755 --- a/src/cli.js +++ b/src/cli.js @@ -3,6 +3,12 @@ import { AVATAR_COLORS, AVATAR_SHAPES, MAX_GROUP_MEMBERS, StoreError, defaultCan import { hasGatewayAuth } from "./gateway.js"; import { openBackend } from "./commands.js"; import { inspectGrokBotGatewaySession } from "./app-session.js"; +import { realpathSync } from "node:fs"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { entryText, normalizeTranscript } from "./transcript.js"; + +const MAX_STDIN_MESSAGE_BYTES = 64 * 1024; function print(value) { if (typeof value === "string") process.stdout.write(value + "\n"); @@ -45,14 +51,15 @@ function usage() { " groups set --member ID [--member ...]", " groups delete ", " send ", - " thread [--limit N] [--root MESSAGE_ID]", + " send --stdin", + " thread [--limit N] [--root MESSAGE_ID] [--json --normalized]", " chat alias for thread", "", "Max group members: " + MAX_GROUP_MEMBERS, "--description / --instructions is the UI Instructions field (same key).", "Avatar shapes: " + AVATAR_SHAPES.join(" "), "Avatar colors: " + AVATAR_COLORS.join(" "), - "Flags: --gateway --files --dir DIR --json", + "Flags: --gateway --files --dir DIR --json --stdin --normalized", "Auth: GROK_BOT_GATEWAY_URL + GROK_BOT_GATEWAY_TOKEN, or the Grok Bot app session, or CURSOR_ACCESS_TOKEN", "File fallback: GROK_BOT_AGENTS_DIR", ].join("\n"); @@ -159,8 +166,8 @@ function summarize(rec) { }; } -function done(json, rec, text) { - print(json ? summarize(rec) : text); +function done(json, rec, text, printImpl = print) { + printImpl(json ? summarize(rec) : text); } function formatRecord(rec, all) { @@ -184,23 +191,6 @@ function formatRecord(rec, all) { return kind + " " + rec.name + title + "\n " + rec.id + desc + avatar + settingsLine + extra; } -function entryText(e) { - if (!e || typeof e !== "object") return ""; - const direct = e.text || e.prompt || e.message || e.preview; - if (typeof direct === "string" && direct) return direct; - const content = e.content; - if (typeof content === "string") return content; - if (Array.isArray(content)) { - return content.map((part) => { - if (typeof part === "string") return part; - if (part && typeof part === "object") return part.text || part.content || ""; - return ""; - }).filter(Boolean).join("\n"); - } - if (content && typeof content === "object") return content.text || JSON.stringify(content); - return ""; -} - function formatTranscript(out) { const rec = out.target; const payload = out.transcript || out.thread || {}; @@ -219,24 +209,69 @@ function formatTranscript(out) { return lines.join("\n"); } -async function main(argv) { +async function readStdinMessage(stdin) { + const chunks = []; + let byteLength = 0; + for await (const chunk of stdin) { + const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + byteLength += bytes.length; + if (byteLength > MAX_STDIN_MESSAGE_BYTES) { + throw new StoreError("stdin message must be at most 64 KiB."); + } + chunks.push(bytes); + } + if (byteLength === 0) throw new StoreError("stdin message must not be empty."); + + let message; + try { + message = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(Buffer.concat(chunks)); + } catch { + throw new StoreError("stdin message must be valid UTF-8."); + } + if (message.includes("\0")) throw new StoreError("stdin message must not contain a NUL byte."); + if (message.trim() !== message) { + throw new StoreError("stdin message must not have surrounding whitespace."); + } + return message; +} + +export async function main(argv, options = {}) { + const openBackendImpl = options.openBackendImpl ?? openBackend; + const stdin = options.stdin ?? process.stdin; + const printImpl = options.printImpl ?? print; const args = argv.slice(2); if (args.length === 0 || args[0] === "-h" || args[0] === "--help") { - print(usage()); + printImpl(usage()); return; } const json = hasFlag(args, "--json"); const gateway = hasFlag(args, "--gateway"); const filesMode = hasFlag(args, "--files"); + const stdinMode = hasFlag(args, "--stdin"); + const normalized = hasFlag(args, "--normalized"); const rootFlag = takeFlag(args, "--dir"); const cmd = args[0]; const sub = args[1]; const rest = args.slice(2); if (!cmd) { - print(usage()); + printImpl(usage()); return; } + if (stdinMode && cmd !== "send") throw new StoreError("--stdin is only valid with send."); + if (normalized && cmd !== "thread" && cmd !== "chat") { + throw new StoreError("--normalized is only valid with thread or chat."); + } + if (normalized && !json) throw new StoreError("--normalized requires --json."); + if (stdinMode && rest.length > 0) { + throw new StoreError("--stdin cannot be combined with a positional message."); + } + + let stdinMessage; + if (stdinMode) { + if (!sub) throw new StoreError("gbot send --stdin"); + stdinMessage = await readStdinMessage(stdin); + } if (cmd === "doctor") { const candidates = defaultCandidateRoots(); @@ -251,36 +286,36 @@ async function main(argv) { const gatewayAuthPresent = hasGatewayAuth(); const grokBotAppSession = inspectGrokBotGatewaySession(); const payload = { resolved, found, candidates, gatewayAuthPresent, grokBotAppSession, note }; - if (json) print(payload); + if (json) printImpl(payload); else { - print("resolved: " + (resolved ?? "(none)")); - print("gateway auth: " + (gatewayAuthPresent ? "present" : "no")); - if (grokBotAppSession.usable) print("Grok Bot app session: usable"); - else if (grokBotAppSession.present) print("Grok Bot app session: present but unusable: " + grokBotAppSession.error); - else print("Grok Bot app session: not found"); - print("found:"); - print(found.length ? found.map((p) => " " + p).join("\n") : " (none)"); - print("candidates:"); - for (const c of candidates) print(" " + c); - print(note); + printImpl("resolved: " + (resolved ?? "(none)")); + printImpl("gateway auth: " + (gatewayAuthPresent ? "present" : "no")); + if (grokBotAppSession.usable) printImpl("Grok Bot app session: usable"); + else if (grokBotAppSession.present) printImpl("Grok Bot app session: present but unusable: " + grokBotAppSession.error); + else printImpl("Grok Bot app session: not found"); + printImpl("found:"); + printImpl(found.length ? found.map((p) => " " + p).join("\n") : " (none)"); + printImpl("candidates:"); + for (const c of candidates) printImpl(" " + c); + printImpl(note); } return; } - const backend = await openBackend({ root: rootFlag, gateway, files: filesMode }); + const backend = await openBackendImpl({ root: rootFlag, gateway, files: filesMode }); if (cmd === "bots" && sub === "list") { const rows = (await backend.list()).filter((r) => !r.isGroup); - if (json) print(rows.map(summarize)); - else if (rows.length === 0) print("No bots."); - else print(rows.map((r) => formatRecord(r, rows)).join("\n\n")); + if (json) printImpl(rows.map(summarize)); + else if (rows.length === 0) printImpl("No bots."); + else printImpl(rows.map((r) => formatRecord(r, rows)).join("\n\n")); return; } if (cmd === "bots" && sub === "create") { const fields = takeCreateFields(rest); const rec = await backend.createAgent(fields); - done(json, rec, "Created bot " + rec.name + " (" + rec.id + ")"); + done(json, rec, "Created bot " + rec.name + " (" + rec.id + ")", printImpl); return; } @@ -288,7 +323,7 @@ async function main(argv) { const ref = rest.shift(); if (!ref || ref.startsWith("-")) throw new StoreError("gbot bots update [--name NAME] ..."); const rec = await backend.updateAgent(ref, takeUpdatePatch(rest)); - done(json, rec, "Updated " + (rec.isGroup ? "group" : "bot") + " " + rec.name + " (" + rec.id + ")"); + done(json, rec, "Updated " + (rec.isGroup ? "group" : "bot") + " " + rec.name + " (" + rec.id + ")", printImpl); return; } @@ -297,21 +332,21 @@ async function main(argv) { if (!ref) throw new StoreError("gbot bots " + sub + " "); if (sub === "get") { const rec = await backend.resolve(ref); - if (json) print(summarize(rec)); - else print(formatRecord(rec, await backend.list())); + if (json) printImpl(summarize(rec)); + else printImpl(formatRecord(rec, await backend.list())); return; } const rec = await backend.deleteAgent(ref); - done(json, rec, "Deleted " + (rec.isGroup ? "group" : "bot") + " " + rec.name + " (" + rec.id + ")"); + done(json, rec, "Deleted " + (rec.isGroup ? "group" : "bot") + " " + rec.name + " (" + rec.id + ")", printImpl); return; } if (cmd === "groups" && sub === "list") { const all = await backend.list(); const rows = all.filter((r) => r.isGroup); - if (json) print(rows.map(summarize)); - else if (rows.length === 0) print("No groups."); - else print(rows.map((r) => formatRecord(r, all)).join("\n\n")); + if (json) printImpl(rows.map(summarize)); + else if (rows.length === 0) printImpl("No groups."); + else printImpl(rows.map((r) => formatRecord(r, all)).join("\n\n")); return; } @@ -321,7 +356,7 @@ async function main(argv) { const rec = await backend.resolve(ref); if (!rec.isGroup) throw new StoreError('"' + rec.name + '" is a bot, not a group. Use bots delete.'); const deleted = await backend.deleteAgent(ref); - done(json, deleted, "Deleted group " + deleted.name + " (" + deleted.id + ")"); + done(json, deleted, "Deleted group " + deleted.name + " (" + deleted.id + ")", printImpl); return; } @@ -329,7 +364,7 @@ async function main(argv) { const fields = takeCreateFields(rest); const members = takeRepeating(rest, "--member"); const rec = await backend.createGroup({ ...fields, memberIds: members }); - done(json, rec, "Created group " + rec.name + " (" + rec.id + ") with " + rec.memberIds.length + " members"); + done(json, rec, "Created group " + rec.name + " (" + rec.id + ") with " + rec.memberIds.length + " members", printImpl); return; } @@ -339,7 +374,7 @@ async function main(argv) { const current = await backend.resolve(ref); if (!current.isGroup) throw new StoreError('"' + current.name + '" is a bot, not a group. Use bots update.'); const rec = await backend.updateAgent(ref, takeUpdatePatch(rest)); - done(json, rec, "Updated group " + rec.name + " (" + rec.id + ")"); + done(json, rec, "Updated group " + rec.name + " (" + rec.id + ")", printImpl); return; } @@ -348,8 +383,8 @@ async function main(argv) { if (!ref) throw new StoreError("gbot groups " + sub + " "); const rec = await backend.resolve(ref); if (!rec.isGroup) throw new StoreError('"' + rec.name + '" is a bot, not a group.'); - if (json) print(summarize(rec)); - else print(formatRecord(rec, await backend.list())); + if (json) printImpl(summarize(rec)); + else printImpl(formatRecord(rec, await backend.list())); return; } @@ -361,7 +396,7 @@ async function main(argv) { ? await backend.addGroupMember(group, bot) : await backend.removeGroupMember(group, bot); const verb = sub === "add" ? "Added to " : "Removed from "; - done(json, rec, verb + rec.name + ". Members: " + rec.memberIds.length); + done(json, rec, verb + rec.name + ". Members: " + rec.memberIds.length, printImpl); return; } @@ -370,17 +405,17 @@ async function main(argv) { const members = takeRepeating(rest, "--member"); if (!group) throw new StoreError("gbot groups set --member ID [--member ...]"); const rec = await backend.setGroupMembers(group, members); - done(json, rec, "Updated " + rec.name + ". Members: " + rec.memberIds.length); + done(json, rec, "Updated " + rec.name + ". Members: " + rec.memberIds.length, printImpl); return; } if (cmd === "send") { const ref = sub; - const message = rest.join(" ").trim(); + const message = stdinMode ? stdinMessage : rest.join(" ").trim(); if (!ref || !message) throw new StoreError("gbot send "); const out = await backend.send(ref, message); - if (json) print({ id: out.target.id, name: out.target.name, kind: out.target.isGroup ? "group" : "bot", result: out.result }); - else print("Sent to " + (out.target.isGroup ? "group" : "bot") + " " + out.target.name + " (" + out.target.id + ")"); + if (json) printImpl({ id: out.target.id, name: out.target.name, kind: out.target.isGroup ? "group" : "bot", result: out.result }); + else printImpl("Sent to " + (out.target.isGroup ? "group" : "bot") + " " + out.target.name + " (" + out.target.id + ")"); return; } @@ -391,12 +426,22 @@ async function main(argv) { const rootId = takeFlag(rest, "--root"); const limit = limitRaw ? Number(limitRaw) : 40; const out = rootId ? await backend.thread(ref, rootId) : await backend.transcript(ref, limit); - if (json) print(out); - else print(formatTranscript(out)); + if (normalized) printImpl(normalizeTranscript(out)); + else if (json) printImpl(out); + else printImpl(formatTranscript(out)); return; } throw new StoreError(usage()); } -main(process.argv).catch(fail); +function isMainModule() { + if (!process.argv[1]) return false; + try { + return realpathSync(resolve(process.argv[1])) === fileURLToPath(import.meta.url); + } catch { + return false; + } +} + +if (isMainModule()) main(process.argv).catch(fail); diff --git a/src/gateway.js b/src/gateway.js index bd0eae4..02b9fb6 100644 --- a/src/gateway.js +++ b/src/gateway.js @@ -4,14 +4,18 @@ import { hasGrokBotGatewaySession, loadGrokBotGatewaySession } from "./app-sessi import { AVATAR_COLORS, AVATAR_SHAPES } from "./store.js"; export class GatewayError extends Error { - constructor(message, { status, method } = {}) { + constructor(message, { status, method, code, effect } = {}) { super(message); this.name = "GatewayError"; this.status = status; this.method = method; + this.code = code; + this.effect = effect; } } +export const DEFAULT_GATEWAY_TIMEOUT_MS = 15_000; + function backendBase() { return ( process.env.SAND_BACKEND_URL || @@ -65,11 +69,113 @@ export function hasGatewayAuth() { async function readJson(res) { const text = await res.text(); - if (!text) return {}; + if (!text) return { data: {}, invalidJson: false, emptyBody: true }; try { - return JSON.parse(text); + return { data: JSON.parse(text), invalidJson: false, emptyBody: false }; } catch { - return { raw: text }; + return { data: undefined, invalidJson: true, emptyBody: false }; + } +} + +function checkedTimeoutMs(value) { + const timeoutMs = value ?? DEFAULT_GATEWAY_TIMEOUT_MS; + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > 2_147_483_647) { + throw new GatewayError("Gateway timeout must be a positive finite number of milliseconds.", { + code: "INVALID_GATEWAY_TIMEOUT", + }); + } + return timeoutMs; +} + +function timeoutError(method, timeoutMs) { + const sendTimedOut = method === "sendPrompt"; + return new GatewayError( + sendTimedOut + ? method + " timed out after " + timeoutMs + "ms; delivery is unknown. Do not resend automatically." + : method + " timed out after " + timeoutMs + "ms.", + { + method, + code: "GATEWAY_TIMEOUT", + ...(sendTimedOut ? { effect: "unknown" } : {}), + }, + ); +} + +function unknownEffectDetails(method) { + return method === "sendPrompt" ? { effect: "unknown" } : {}; +} + +function requestFailureError(method) { + const sendFailed = method === "sendPrompt"; + return new GatewayError( + sendFailed + ? method + " request failed; delivery is unknown. Do not resend automatically." + : method + " request failed.", + { + method, + code: "GATEWAY_REQUEST_FAILED", + ...unknownEffectDetails(method), + }, + ); +} + +function invalidResponseError(method) { + const sendFailed = method === "sendPrompt"; + return new GatewayError( + sendFailed + ? method + " returned an invalid response; delivery is unknown. Do not resend automatically." + : method + " returned an invalid response.", + { + method, + code: "GATEWAY_INVALID_RESPONSE", + ...unknownEffectDetails(method), + }, + ); +} + +function httpError(method, status) { + const ambiguousSend = method === "sendPrompt" && (status === 408 || status >= 500); + return new GatewayError( + ambiguousSend + ? method + " failed with HTTP " + status + "; delivery is unknown. Do not resend automatically." + : method + " failed with HTTP " + status + ".", + { + status, + method, + ...(ambiguousSend ? { effect: "unknown" } : {}), + }, + ); +} + +async function requestJson(method, url, init, options = {}) { + const timeoutMs = checkedTimeoutMs(options.timeoutMs); + const fetchImpl = options.fetchImpl || globalThis.fetch; + const controller = new AbortController(); + const deadlineError = timeoutError(method, timeoutMs); + let timer; + const deadline = new Promise((_, reject) => { + timer = setTimeout(() => { + controller.abort(); + reject(deadlineError); + }, timeoutMs); + }); + + try { + const res = await Promise.race([ + fetchImpl(url, { ...init, redirect: "error", signal: controller.signal }), + deadline, + ]); + if (!res.ok) { + controller.abort(); + return { res, data: undefined, invalidJson: false, emptyBody: true }; + } + const parsed = await Promise.race([readJson(res), deadline]); + return { res, ...parsed }; + } catch (error) { + if (error === deadlineError || controller.signal.aborted) throw deadlineError; + throw requestFailureError(method); + } finally { + clearTimeout(timer); } } @@ -81,18 +187,17 @@ function pick(obj, ...keys) { return undefined; } -export async function ensureSandbox(accessToken) { +export async function ensureSandbox(accessToken, options = {}) { const url = backendBase() + "/aiserver.v1.GrokBotService/EnsureSandBox"; - const res = await fetch(url, { + const { res, data: body, invalidJson } = await requestJson("EnsureSandBox", url, { method: "POST", headers: ensureSandboxHeaders(accessToken), body: "{}", - }); - const body = await readJson(res); + }, options); if (!res.ok) { - const detail = body.message || body.error || body.raw || res.statusText; - throw new GatewayError("EnsureSandBox failed: " + res.status + " " + detail, { status: res.status, method: "EnsureSandBox" }); + throw httpError("EnsureSandBox", res.status); } + if (invalidJson) throw invalidResponseError("EnsureSandBox"); const gatewayUrl = pick(body, "gatewayUrl", "gateway_url"); const gatewayToken = pick(body, "gatewayToken", "gateway_token"); if (!gatewayUrl || !gatewayToken) { @@ -113,21 +218,29 @@ export async function connectGateway() { return ensureSandbox(token); } -export async function gatewayCall(session, method, body = {}) { +export async function gatewayCall(session, method, body = {}, options = {}) { const url = session.gatewayUrl + "/api/" + method; - const res = await fetch(url, { + const { res, data, invalidJson, emptyBody } = await requestJson(method, url, { method: "POST", headers: requestHeaders(session), body: JSON.stringify(body), - }); - const data = await readJson(res); + }, options); if (!res.ok) { - const detail = data.message || data.error || data.raw || res.statusText; - throw new GatewayError(method + " failed: " + res.status + " " + String(detail).slice(0, 300), { status: res.status, method }); + throw httpError(method, res.status); } + if (invalidJson || (method === "sendPrompt" && !isAffirmativeSendAck(data, emptyBody))) throw invalidResponseError(method); return data; } +function isAffirmativeSendAck(data, emptyBody) { + if (emptyBody || !data || typeof data !== "object" || Array.isArray(data)) return false; + if (Object.keys(data).length === 0) return false; + if ("ok" in data && data.ok !== true) return false; + if ("success" in data && data.success !== true) return false; + if ("error" in data) return false; + return true; +} + function asRecord(agent) { if (!agent) return null; const id = agent.id || agent.agentId; diff --git a/src/transcript.js b/src/transcript.js new file mode 100644 index 0000000..4f45a3f --- /dev/null +++ b/src/transcript.js @@ -0,0 +1,160 @@ +function contentText(value, { stringifyObject = false } = {}) { + if (typeof value === "string") return value; + if (Array.isArray(value)) { + return value.map((part) => contentText(part)).filter(Boolean).join("\n"); + } + if (!value || typeof value !== "object") return ""; + if (typeof value.text === "string" && value.text) return value.text; + if (value.content != null) { + const nested = contentText(value.content, { stringifyObject }); + if (nested) return nested; + } + return stringifyObject ? JSON.stringify(value) : ""; +} + +export function entryText(entry) { + if (!entry || typeof entry !== "object") return ""; + for (const direct of [entry.text, entry.prompt, entry.message, entry.preview]) { + const text = contentText(direct); + if (text) return text; + } + return contentText(entry.content, { stringifyObject: true }); +} + +function transcriptEntries(payload) { + if (Array.isArray(payload)) return payload; + if (!payload || typeof payload !== "object") { + throw new Error("Invalid transcript container."); + } + for (const key of ["entries", "messages", "items"]) { + if (!(key in payload)) continue; + if (!Array.isArray(payload[key])) throw new Error("Invalid transcript container."); + return payload[key]; + } + throw new Error("Invalid transcript container."); +} + +function explicitRole(entry) { + const nested = entry.message && typeof entry.message === "object" && !Array.isArray(entry.message) + ? entry.message + : null; + // Strict role carriers: unsupported values veto role inference. + const strictCandidates = [nested?.type, nested?.role, entry.role, entry.sender]; + // Loose carriers: only contribute user/assistant, never veto. + const looseCandidates = [entry.kind, entry.type]; + const roles = new Set(); + let hasUnsupportedRole = false; + for (const candidate of strictCandidates) { + if (candidate === "user" || candidate === "assistant") { + roles.add(candidate); + } else if (candidate != null && candidate !== "") { + hasUnsupportedRole = true; + } + } + for (const candidate of looseCandidates) { + if (candidate === "user" || candidate === "assistant") roles.add(candidate); + } + if (hasUnsupportedRole && roles.size > 0) return "unknown"; + return roles.size === 1 ? roles.values().next().value : "unknown"; +} + +function isNonblankString(value) { + return typeof value === "string" && value.trim().length > 0; +} + +const TEXT_CARRIERS = ["text", "prompt", "message", "preview", "content"]; + +function evidenceText(value) { + if (typeof value === "string") return value; + if (Array.isArray(value)) { + for (const part of value) { + if (part != null && typeof part !== "string" && typeof part !== "object") { + throw new Error("Malformed transcript text evidence."); + } + } + return value.map((part) => evidenceText(part)).filter(Boolean).join("\n"); + } + if (!value || typeof value !== "object") return ""; + + const presentKeys = TEXT_CARRIERS + .filter((key) => Object.prototype.hasOwnProperty.call(value, key)); + const STRICT_TEXT_KEYS = ["text", "prompt", "preview"]; + for (const key of presentKeys) { + const v = value[key]; + if (v != null && typeof v !== "string" && typeof v !== "object") { + throw new Error("Malformed transcript text evidence."); + } + if (v != null && typeof v === "object" && !Array.isArray(v) && STRICT_TEXT_KEYS.includes(key)) { + const hasTextKeys = TEXT_CARRIERS.some((k) => Object.prototype.hasOwnProperty.call(v, k)); + if (!hasTextKeys) throw new Error("Malformed transcript text evidence."); + } + } + const candidates = presentKeys + .map((key) => evidenceText(value[key])) + .filter(Boolean); + if (new Set(candidates).size > 1) { + throw new Error("Conflicting transcript text evidence."); + } + return candidates[0] ?? ""; +} + +function normalizedEntryText(entry) { + return evidenceText(entry); +} + +function normalizedMessageId(entry) { + const candidates = []; + for (const key of ["id", "messageId"]) { + if (!Object.prototype.hasOwnProperty.call(entry, key)) continue; + const value = entry[key]; + if (value !== null && !isNonblankString(value)) { + throw new Error("Invalid transcript message id."); + } + if (value !== null) candidates.push(value); + } + if (new Set(candidates).size > 1) { + throw new Error("Invalid transcript message id."); + } + return candidates[0] ?? null; +} + +export function normalizeTranscript(out) { + if (!out || typeof out !== "object" || Array.isArray(out)) { + throw new Error("Invalid transcript response."); + } + const target = out.target; + if ( + !target + || typeof target !== "object" + || Array.isArray(target) + || !isNonblankString(target.id) + || !isNonblankString(target.name) + || typeof target.isGroup !== "boolean" + ) { + throw new Error("Invalid transcript target."); + } + + const hasTranscript = Object.prototype.hasOwnProperty.call(out, "transcript"); + const hasThread = Object.prototype.hasOwnProperty.call(out, "thread"); + const payload = hasTranscript ? out.transcript : hasThread ? out.thread : undefined; + const entries = transcriptEntries(payload); + const messages = entries.map((entry) => { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) { + throw new Error("Invalid transcript entry."); + } + return { + id: normalizedMessageId(entry), + role: explicitRole(entry), + text: normalizedEntryText(entry), + }; + }); + + return { + target: { + id: target.id, + name: target.name, + kind: target.isGroup ? "group" : "bot", + }, + messages, + }; +} diff --git a/test/gateway.test.js b/test/gateway.test.js index 591b293..4ca0d7c 100644 --- a/test/gateway.test.js +++ b/test/gateway.test.js @@ -6,6 +6,25 @@ import { parseGatewayHeaders, requestHeaders, } from "../src/headers.js"; +import { + DEFAULT_GATEWAY_TIMEOUT_MS, + ensureSandbox, + gatewayCall, +} from "../src/gateway.js"; + +function response({ ok = true, status = 200, body = "{}", statusText = "" } = {}) { + return { + ok, + status, + statusText, + text: async () => body, + }; +} + +const session = { + gatewayUrl: "https://gateway.invalid", + gatewayToken: "test-token", +}; test("parses env JSON headers", () => { const headers = parseGatewayHeaders('{"X-Anyrun-Network-Token":"abc","empty":""}'); @@ -45,3 +64,432 @@ test("empty env JSON is a no-op", () => { assert.deepEqual(parseGatewayHeaders(""), {}); assert.deepEqual(parseGatewayHeaders(undefined), {}); }); + +test("gateway requests use a finite default deadline", () => { + assert.equal(DEFAULT_GATEWAY_TIMEOUT_MS, 15_000); +}); + +test("gateway timeout aborts one fetch without retrying", async () => { + let calls = 0; + let signal; + const fetchImpl = async (url, init) => { + calls += 1; + signal = init.signal; + return new Promise(() => {}); + }; + + await assert.rejects( + gatewayCall(session, "listAgents", {}, { timeoutMs: 10, fetchImpl }), + (error) => { + assert.equal(error.name, "GatewayError"); + assert.equal(error.code, "GATEWAY_TIMEOUT"); + assert.equal(error.method, "listAgents"); + assert.match(error.message, /^listAgents timed out after 10ms\.$/); + return true; + }, + ); + assert.equal(calls, 1); + assert.equal(signal.aborted, true); +}); + +test("gateway deadline also bounds response body reads", async () => { + let calls = 0; + let signal; + const fetchImpl = async (url, init) => { + calls += 1; + signal = init.signal; + return { + ...response(), + text: async () => new Promise(() => {}), + }; + }; + + await assert.rejects( + gatewayCall(session, "getAgentThread", {}, { timeoutMs: 10, fetchImpl }), + (error) => error.code === "GATEWAY_TIMEOUT" && error.method === "getAgentThread", + ); + assert.equal(calls, 1); + assert.equal(signal.aborted, true); +}); + +test("send timeout reports unknown effect and is never retried", async () => { + let calls = 0; + const fetchImpl = async () => { + calls += 1; + return new Promise(() => {}); + }; + + await assert.rejects( + gatewayCall(session, "sendPrompt", { prompt: "hello" }, { timeoutMs: 10, fetchImpl }), + (error) => { + assert.equal(error.code, "GATEWAY_TIMEOUT"); + assert.equal(error.effect, "unknown"); + assert.equal(error.message, "sendPrompt timed out after 10ms; delivery is unknown. Do not resend automatically."); + return true; + }, + ); + assert.equal(calls, 1); +}); + +test("ensureSandbox has the same controllable deadline", async () => { + let calls = 0; + const fetchImpl = async () => { + calls += 1; + return new Promise(() => {}); + }; + + await assert.rejects( + ensureSandbox("access-token", { timeoutMs: 10, fetchImpl }), + (error) => error.code === "GATEWAY_TIMEOUT" && error.method === "EnsureSandBox", + ); + assert.equal(calls, 1); +}); + +test("invalid deadlines fail before fetch", async () => { + for (const timeoutMs of [0, -1, Infinity, NaN]) { + let calls = 0; + await assert.rejects( + gatewayCall(session, "listAgents", {}, { + timeoutMs, + fetchImpl: async () => { + calls += 1; + return response(); + }, + }), + (error) => error.code === "INVALID_GATEWAY_TIMEOUT", + ); + assert.equal(calls, 0); + } +}); + +test("HTTP errors do not expose response body or status text", async () => { + const secret = "raw-private-message token-123"; + await assert.rejects( + gatewayCall(session, "getAgentThread", {}, { + fetchImpl: async () => response({ + ok: false, + status: 403, + statusText: "Bearer status-secret", + body: JSON.stringify({ error: secret }), + }), + }), + (error) => { + assert.equal(error.message, "getAgentThread failed with HTTP 403."); + assert.equal(error.status, 403); + assert.doesNotMatch(error.message, /private|token|Bearer|status-secret/); + return true; + }, + ); +}); + +test("EnsureSandBox HTTP errors do not expose response details", async () => { + await assert.rejects( + ensureSandbox("access-token", { + fetchImpl: async () => response({ + ok: false, + status: 401, + statusText: "Bearer status-secret", + body: '{"error":"raw token=secret"}', + }), + }), + (error) => { + assert.equal(error.message, "EnsureSandBox failed with HTTP 401."); + assert.equal(error.status, 401); + assert.doesNotMatch(error.message, /raw|token|Bearer|secret/); + return true; + }, + ); +}); + +test("network errors do not expose socket details or URLs", async () => { + const secret = "connect ECONNREFUSED https://gateway.invalid/?token=secret"; + await assert.rejects( + gatewayCall(session, "listAgents", {}, { + fetchImpl: async () => { throw new Error(secret); }, + }), + (error) => { + assert.equal(error.message, "listAgents request failed."); + assert.equal(error.code, "GATEWAY_REQUEST_FAILED"); + assert.doesNotMatch(error.message, /ECONNREFUSED|gateway|token|secret/); + return true; + }, + ); +}); + +test("send network errors report unknown effect without leaking details", async () => { + await assert.rejects( + gatewayCall(session, "sendPrompt", { prompt: "hello" }, { + fetchImpl: async () => { throw new Error("socket failed with token=secret"); }, + }), + (error) => { + assert.equal(error.code, "GATEWAY_REQUEST_FAILED"); + assert.equal(error.effect, "unknown"); + assert.equal(error.message, "sendPrompt request failed; delivery is unknown. Do not resend automatically."); + assert.doesNotMatch(error.message, /socket|token|secret/); + return true; + }, + ); +}); + +test("successful non-JSON responses fail safely without exposing body", async () => { + const raw = "private transcript and token=secret"; + await assert.rejects( + gatewayCall(session, "getAgentThread", {}, { + fetchImpl: async () => response({ body: raw }), + }), + (error) => { + assert.equal(error.code, "GATEWAY_INVALID_RESPONSE"); + assert.equal(error.message, "getAgentThread returned an invalid response."); + assert.doesNotMatch(error.message, /private|token|secret/); + return true; + }, + ); +}); + +test("send non-JSON success reports unknown effect", async () => { + await assert.rejects( + gatewayCall(session, "sendPrompt", { prompt: "hello" }, { + fetchImpl: async () => response({ body: "not-json" }), + }), + (error) => { + assert.equal(error.code, "GATEWAY_INVALID_RESPONSE"); + assert.equal(error.effect, "unknown"); + assert.equal(error.message, "sendPrompt returned an invalid response; delivery is unknown. Do not resend automatically."); + return true; + }, + ); +}); + +test("ambiguous send HTTP failures report unknown effect without retry", async () => { + for (const status of [408, 500, 503]) { + let calls = 0; + await assert.rejects( + gatewayCall(session, "sendPrompt", { prompt: "hello" }, { + fetchImpl: async () => { + calls += 1; + return response({ + ok: false, + status, + body: '{"error":"private token=secret"}', + }); + }, + }), + (error) => { + assert.equal(error.effect, "unknown"); + assert.equal(error.message, "sendPrompt failed with HTTP " + status + "; delivery is unknown. Do not resend automatically."); + assert.doesNotMatch(error.message, /private|token|secret/); + return true; + }, + ); + assert.equal(calls, 1); + } +}); + +test("empty send success reports unknown effect", async () => { + await assert.rejects( + gatewayCall(session, "sendPrompt", { prompt: "hello" }, { + fetchImpl: async () => response({ body: "" }), + }), + (error) => { + assert.equal(error.code, "GATEWAY_INVALID_RESPONSE"); + assert.equal(error.effect, "unknown"); + assert.equal(error.message, "sendPrompt returned an invalid response; delivery is unknown. Do not resend automatically."); + return true; + }, + ); +}); + +test("empty successful bodies preserve the existing empty object result", async () => { + assert.deepEqual(await gatewayCall(session, "deleteAgent", {}, { + fetchImpl: async () => response({ body: "" }), + }), {}); +}); + +test("gateway fetch rejects redirects while preserving a normal HTTPS request", async () => { + let observed; + const data = await gatewayCall(session, "listAgents", {}, { + fetchImpl: async (url, init) => { + observed = { url, redirect: init.redirect, method: init.method }; + return response({ body: '{"agents":[]}' }); + }, + }); + + assert.deepEqual(data, { agents: [] }); + assert.deepEqual(observed, { + url: "https://gateway.invalid/api/listAgents", + redirect: "error", + method: "POST", + }); +}); + +test("HTTP error with stalled body reports HTTP status, not timeout", async () => { + await assert.rejects( + gatewayCall(session, "sendPrompt", { prompt: "hello" }, { + timeoutMs: 50, + fetchImpl: async () => ({ + ok: false, + status: 400, + statusText: "", + text: async () => new Promise(() => {}), + }), + }), + (error) => { + assert.equal(error.message, "sendPrompt failed with HTTP 400."); + assert.equal(error.status, 400); + assert.equal(error.effect, undefined); + assert.notEqual(error.code, "GATEWAY_TIMEOUT"); + return true; + }, + ); +}); + +test("5xx error with stalled body reports HTTP status with unknown effect, not timeout", async () => { + await assert.rejects( + gatewayCall(session, "sendPrompt", { prompt: "hello" }, { + timeoutMs: 50, + fetchImpl: async () => ({ + ok: false, + status: 503, + statusText: "", + text: async () => new Promise(() => {}), + }), + }), + (error) => { + assert.equal(error.message, "sendPrompt failed with HTTP 503; delivery is unknown. Do not resend automatically."); + assert.equal(error.status, 503); + assert.equal(error.effect, "unknown"); + assert.notEqual(error.code, "GATEWAY_TIMEOUT"); + return true; + }, + ); +}); + +test("null JSON send response reports unknown effect", async () => { + await assert.rejects( + gatewayCall(session, "sendPrompt", { prompt: "hello" }, { + fetchImpl: async () => response({ body: "null" }), + }), + (error) => { + assert.equal(error.code, "GATEWAY_INVALID_RESPONSE"); + assert.equal(error.effect, "unknown"); + assert.equal(error.message, "sendPrompt returned an invalid response; delivery is unknown. Do not resend automatically."); + return true; + }, + ); +}); + +test("null JSON for non-send methods returns null data as-is", async () => { + const result = await gatewayCall(session, "deleteAgent", {}, { + fetchImpl: async () => response({ body: "null" }), + }); + assert.equal(result, null); +}); + +test("false JSON send response reports unknown effect", async () => { + await assert.rejects( + gatewayCall(session, "sendPrompt", { prompt: "hello" }, { + fetchImpl: async () => response({ body: "false" }), + }), + (error) => { + assert.equal(error.code, "GATEWAY_INVALID_RESPONSE"); + assert.equal(error.effect, "unknown"); + return true; + }, + ); +}); + +test("false JSON for non-send methods returns false data as-is", async () => { + const result = await gatewayCall(session, "deleteAgent", {}, { + fetchImpl: async () => response({ body: "false" }), + }); + assert.equal(result, false); +}); + +test("HTTP error aborts controller to release the socket", async () => { + let signal; + await assert.rejects( + gatewayCall(session, "listAgents", {}, { + fetchImpl: async (url, init) => { + signal = init.signal; + return response({ ok: false, status: 500 }); + }, + }), + (error) => error.status === 500, + ); + assert.equal(signal.aborted, true); +}); + +test("non-object send responses (0, empty string) report unknown effect", async () => { + for (const body of ["0", '""']) { + await assert.rejects( + gatewayCall(session, "sendPrompt", { prompt: "hello" }, { + fetchImpl: async () => response({ body }), + }), + (error) => { + assert.equal(error.code, "GATEWAY_INVALID_RESPONSE"); + assert.equal(error.effect, "unknown"); + return true; + }, + ); + } +}); + +test("non-object values for non-send methods pass through as-is", async () => { + for (const [body, expected] of [["0", 0], ['""', ""]]) { + const result = await gatewayCall(session, "deleteAgent", {}, { + fetchImpl: async () => response({ body }), + }); + assert.equal(result, expected); + } +}); + +test("array send response reports unknown effect", async () => { + await assert.rejects( + gatewayCall(session, "sendPrompt", { prompt: "hello" }, { + fetchImpl: async () => response({ body: "[]" }), + }), + (error) => { + assert.equal(error.code, "GATEWAY_INVALID_RESPONSE"); + assert.equal(error.effect, "unknown"); + return true; + }, + ); +}); + +test("empty-object send response reports unknown effect", async () => { + await assert.rejects( + gatewayCall(session, "sendPrompt", { prompt: "hello" }, { + fetchImpl: async () => response({ body: "{}" }), + }), + (error) => { + assert.equal(error.code, "GATEWAY_INVALID_RESPONSE"); + assert.equal(error.effect, "unknown"); + return true; + }, + ); +}); + +test("negative send acknowledgements (ok:false, error field, wrong types) report unknown effect", async () => { + for (const body of [ + '{"ok":false}', '{"success":false}', '{"error":"rejected"}', + '{"ok":"false"}', '{"success":[]}', '{"ok":0}', + ]) { + await assert.rejects( + gatewayCall(session, "sendPrompt", { prompt: "hello" }, { + fetchImpl: async () => response({ body }), + }), + (error) => { + assert.equal(error.code, "GATEWAY_INVALID_RESPONSE"); + assert.equal(error.effect, "unknown"); + return true; + }, + ); + } +}); + +test("affirmative send acknowledgement with ok:true is accepted", async () => { + const result = await gatewayCall(session, "sendPrompt", { prompt: "hello" }, { + fetchImpl: async () => response({ body: '{"ok":true,"id":"msg-1"}' }), + }); + assert.deepEqual(result, { ok: true, id: "msg-1" }); +}); diff --git a/test/send-stdin.test.js b/test/send-stdin.test.js new file mode 100644 index 0000000..959a5a4 --- /dev/null +++ b/test/send-stdin.test.js @@ -0,0 +1,170 @@ +import assert from "node:assert/strict"; +import { Readable } from "node:stream"; +import test from "node:test"; + +import { main } from "../src/cli.js"; + +function stdinFrom(...chunks) { + return Readable.from(chunks); +} + +function harness(chunks = []) { + const sent = []; + let backendOpens = 0; + const output = []; + const backend = { + send: async (ref, message) => { + sent.push({ ref, message }); + return { + target: { id: "bot-1", name: ref, isGroup: false }, + result: { ok: true }, + }; + }, + transcript: async (ref) => ({ + target: { id: "bot-1", name: ref, isGroup: false }, + transcript: { entries: [{ id: "m1", message: { type: "assistant", content: "完成" } }] }, + extraRawField: true, + }), + }; + + return { + sent, + output, + get backendOpens() { return backendOpens; }, + options: { + stdin: stdinFrom(...chunks), + openBackendImpl: async () => { + backendOpens += 1; + return backend; + }, + printImpl: (value) => output.push(value), + }, + }; +} + +test("send --stdin preserves exact UTF-8 text and treats flag-looking lines as text", async () => { + const h = harness([Buffer.from("第一行\n--json\n--files\n最後一行", "utf8")]); + + await main(["node", "gbot", "send", "Researcher", "--stdin"], h.options); + + assert.equal(h.backendOpens, 1); + assert.deepEqual(h.sent, [{ + ref: "Researcher", + message: "第一行\n--json\n--files\n最後一行", + }]); +}); + +test("send --stdin supports the 64 KiB byte boundary", async () => { + const h = harness([Buffer.alloc(64 * 1024, 0x61)]); + + await main(["node", "gbot", "send", "Researcher", "--stdin"], h.options); + + assert.equal(h.sent[0].message.length, 64 * 1024); +}); + +test("positional send behavior remains unchanged", async () => { + const h = harness(); + + await main( + ["node", "gbot", "send", "Researcher", "existing", "positional", "message"], + h.options, + ); + + assert.deepEqual(h.sent, [{ ref: "Researcher", message: "existing positional message" }]); +}); + +for (const [name, chunks, pattern] of [ + ["empty input", [], /stdin message must not be empty/i], + ["invalid UTF-8", [Buffer.from([0xc3, 0x28])], /valid UTF-8/i], + ["NUL byte", [Buffer.from("hello\0world")], /NUL/i], + ["leading UTF-8 BOM", [Buffer.from([0xef, 0xbb, 0xbf, ...Buffer.from("hello")])], /surrounding whitespace/i], + ["leading whitespace", [Buffer.from(" message")], /surrounding whitespace/i], + ["trailing whitespace", [Buffer.from("message\n")], /surrounding whitespace/i], + ["oversized input", [Buffer.alloc(64 * 1024 + 1, 0x61)], /64 KiB/i], +]) { + test(`send --stdin rejects ${name} before opening a backend`, async () => { + const h = harness(chunks); + + await assert.rejects( + main(["node", "gbot", "send", "Researcher", "--stdin"], h.options), + pattern, + ); + + assert.equal(h.backendOpens, 0); + assert.deepEqual(h.sent, []); + }); +} + +test("send --stdin rejects a positional message before opening a backend", async () => { + const h = harness([Buffer.from("stdin message")]); + + await assert.rejects( + main(["node", "gbot", "send", "Researcher", "positional", "--stdin"], h.options), + /cannot be combined/i, + ); + + assert.equal(h.backendOpens, 0); +}); + +test("--stdin is rejected for non-send commands before opening a backend", async () => { + const h = harness([Buffer.from("ignored")]); + + await assert.rejects( + main(["node", "gbot", "bots", "list", "--stdin"], h.options), + /only valid with send/i, + ); + + assert.equal(h.backendOpens, 0); +}); + +test("thread --normalized requires --json before opening a backend", async () => { + const h = harness(); + await assert.rejects( + main(["node", "gbot", "thread", "Researcher", "--normalized"], h.options), + /requires --json/i, + ); + assert.equal(h.backendOpens, 0); +}); + +test("--normalized is rejected for non-thread commands before opening a backend", async () => { + const h = harness(); + await assert.rejects( + main(["node", "gbot", "--json", "bots", "list", "--normalized"], h.options), + /only valid with thread or chat/i, + ); + assert.equal(h.backendOpens, 0); +}); + +test("thread --json --normalized emits the stable normalized schema", async () => { + const h = harness(); + await main( + ["node", "gbot", "--json", "thread", "Researcher", "--normalized"], + h.options, + ); + assert.deepEqual(h.output, [{ + target: { id: "bot-1", name: "Researcher", kind: "bot" }, + messages: [{ id: "m1", role: "assistant", text: "完成" }], + }]); +}); + +test("thread raw JSON remains unchanged without --normalized", async () => { + const h = harness(); + await main(["node", "gbot", "--json", "thread", "Researcher"], h.options); + assert.equal(h.output[0].extraRawField, true); + assert.ok(h.output[0].transcript); +}); + +test("malformed normalized transcript emits no partial stdout", async () => { + const h = harness(); + h.options.openBackendImpl = async () => ({ + transcript: async () => ({ + target: { id: "bot-1", name: "Researcher", isGroup: false }, + transcript: { entries: "bad" }, + }), + }); + await assert.rejects( + main(["node", "gbot", "--json", "chat", "Researcher", "--normalized"], h.options), + /invalid transcript container/i, + ); + assert.deepEqual(h.output, []); +}); diff --git a/test/transcript.test.js b/test/transcript.test.js new file mode 100644 index 0000000..86beb8f --- /dev/null +++ b/test/transcript.test.js @@ -0,0 +1,322 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { entryText, normalizeTranscript } from "../src/transcript.js"; + +test("reads actual nested send-message and user entry content", () => { + assert.equal(entryText({ + kind: "send-message", + message: { type: "assistant", content: "nested assistant text" }, + }), "nested assistant text"); + assert.equal(entryText({ + kind: "user", + message: { type: "user", content: "nested user text" }, + }), "nested user text"); +}); + +test("reads nested message text and content parts", () => { + assert.equal(entryText({ message: { text: "nested text" } }), "nested text"); + assert.equal(entryText({ + message: { + content: ["first", { text: "second" }, { content: "third" }], + }, + }), "first\nsecond\nthird"); +}); + +test("preserves existing direct and content formats", () => { + assert.equal(entryText({ text: "direct" }), "direct"); + assert.equal(entryText({ prompt: "prompt" }), "prompt"); + assert.equal(entryText({ message: "message" }), "message"); + assert.equal(entryText({ preview: "preview" }), "preview"); + assert.equal(entryText({ content: "content" }), "content"); + assert.equal(entryText({ content: ["one", { text: "two" }] }), "one\ntwo"); + assert.equal(entryText({ content: { text: "object text" } }), "object text"); + assert.equal(entryText({ content: { type: "image" } }), '{"type":"image"}'); +}); + +test("normalizes actual nested transcript schema with exact text", () => { + const normalized = normalizeTranscript({ + target: { id: "bot-1", name: "研究員", isGroup: false }, + transcript: { + entries: [ + { + id: "message-1", + kind: "send-message", + message: { type: "assistant", content: "第一行\n第二行" }, + }, + { + messageId: "message-2", + role: "user", + message: { content: "請繼續" }, + }, + ], + }, + }); + + assert.deepEqual(normalized, { + target: { id: "bot-1", name: "研究員", kind: "bot" }, + messages: [ + { id: "message-1", role: "assistant", text: "第一行\n第二行" }, + { id: "message-2", role: "user", text: "請繼續" }, + ], + }); +}); + +test("normalizer only accepts explicit user and assistant roles", () => { + const normalized = normalizeTranscript({ + target: { id: "group-1", name: "Launch", isGroup: true }, + thread: { + messages: [ + { id: "1", kind: "send-message", text: "looks sent by user" }, + { id: "2", type: "assistant", text: "answer" }, + { id: "3", message: { role: "user", text: "question" } }, + { id: "4", role: "system", text: "system text" }, + ], + }, + }); + + assert.deepEqual(normalized, { + target: { id: "group-1", name: "Launch", kind: "group" }, + messages: [ + { id: "1", role: "unknown", text: "looks sent by user" }, + { id: "2", role: "assistant", text: "answer" }, + { id: "3", role: "user", text: "question" }, + { id: "4", role: "unknown", text: "system text" }, + ], + }); +}); + +test("normalizer marks conflicting explicit roles unknown", () => { + const normalized = normalizeTranscript({ + target: { id: "bot-1", name: "Bot", isGroup: false }, + transcript: { + entries: [{ + id: "1", + role: "assistant", + message: { type: "user", content: "conflicting echo" }, + }], + }, + }); + + assert.equal(normalized.messages[0].role, "unknown"); +}); + +test("normalizer recognizes explicit sender role", () => { + const normalized = normalizeTranscript({ + target: { id: "bot-1", name: "Bot", isGroup: false }, + transcript: { + entries: [ + { id: "1", sender: "user", text: "hello from sender" }, + { id: "2", sender: "assistant", text: "reply from sender" }, + ], + }, + }); + assert.equal(normalized.messages[0].role, "user"); + assert.equal(normalized.messages[1].role, "assistant"); +}); + +test("normalizer marks conflicting sender and role as unknown", () => { + const normalized = normalizeTranscript({ + target: { id: "bot-1", name: "Bot", isGroup: false }, + transcript: { + entries: [{ id: "1", role: "assistant", sender: "user", text: "conflict" }], + }, + }); + assert.equal(normalized.messages[0].role, "unknown"); +}); + +test("unsupported explicit role vetoes inference from nested carrier", () => { + const normalized = normalizeTranscript({ + target: { id: "bot-1", name: "Bot", isGroup: false }, + transcript: { + entries: [{ id: "1", role: "system", message: { type: "assistant", content: "system says hi" } }], + }, + }); + assert.equal(normalized.messages[0].role, "unknown"); +}); + +test("loose carrier kind does not veto nested role inference", () => { + const normalized = normalizeTranscript({ + target: { id: "bot-1", name: "Bot", isGroup: false }, + transcript: { + entries: [{ id: "1", kind: "send-message", message: { type: "assistant", content: "reply" } }], + }, + }); + assert.equal(normalized.messages[0].role, "assistant"); +}); + +test("malformed non-string strict role carrier vetoes inference", () => { + for (const role of [42, true, {}]) { + const normalized = normalizeTranscript({ + target: { id: "bot-1", name: "Bot", isGroup: false }, + transcript: { + entries: [{ id: "1", role, message: { type: "assistant", content: "hello" } }], + }, + }); + assert.equal(normalized.messages[0].role, "unknown", + `role: ${JSON.stringify(role)} should veto assistant inference`); + } +}); + +test("normalizer rejects conflicting text evidence without changing display formatting", () => { + const target = { id: "bot-1", name: "Bot", isGroup: false }; + const conflicting = { id: "1", text: "first", preview: "second" }; + + assert.equal(entryText(conflicting), "first"); + assert.throws( + () => normalizeTranscript({ target, transcript: { entries: [conflicting] } }), + /conflicting transcript text/i, + ); +}); + +test("normalizer rejects conflicting nested message text and content", () => { + const target = { id: "bot-1", name: "Bot", isGroup: false }; + assert.throws( + () => normalizeTranscript({ + target, + transcript: { + entries: [{ id: "1", message: { type: "text", text: "first", content: "second" } }], + }, + }), + /conflicting transcript text/i, + ); +}); + +test("normalizer rejects malformed non-string text carriers", () => { + const target = { id: "bot-1", name: "Bot", isGroup: false }; + for (const text of [42, true]) { + assert.throws( + () => normalizeTranscript({ + target, + transcript: { entries: [{ id: "1", text, content: "fallback" }] }, + }), + /malformed transcript text/i, + `text: ${JSON.stringify(text)} should be rejected`, + ); + } +}); + +test("normalizer rejects malformed primitives inside text arrays", () => { + const target = { id: "bot-1", name: "Bot", isGroup: false }; + assert.throws( + () => normalizeTranscript({ + target, + transcript: { entries: [{ id: "1", content: ["hello", 42] }] }, + }), + /malformed transcript text/i, + ); +}); + +test("normalizer rejects opaque object in strict text carrier", () => { + const target = { id: "bot-1", name: "Bot", isGroup: false }; + assert.throws( + () => normalizeTranscript({ + target, + transcript: { entries: [{ id: "1", text: { unexpected: true }, content: "fallback" }] }, + }), + /malformed transcript text/i, + ); +}); + +test("matching text carriers normalize once and content arrays remain intact", () => { + const target = { id: "bot-1", name: "Bot", isGroup: false }; + const normalized = normalizeTranscript({ + target, + transcript: { + entries: [ + { id: "1", text: "same", preview: "same" }, + { id: "2", message: { content: ["first", { text: "second" }] } }, + ], + }, + }); + assert.equal(normalized.messages[0].text, "same"); + assert.equal(normalized.messages[1].text, "first\nsecond"); +}); + +test("send-message with nested type text stays unknown", () => { + const normalized = normalizeTranscript({ + target: { id: "bot-1", name: "Bot", isGroup: false }, + transcript: { + entries: [{ id: "1", kind: "send-message", message: { type: "text", content: "hello" } }], + }, + }); + assert.equal(normalized.messages[0].role, "unknown"); +}); + +test("normalized evidence never synthesizes JSON from non-text content", () => { + const normalized = normalizeTranscript({ + target: { id: "bot-1", name: "Bot", isGroup: false }, + transcript: { entries: [{ id: "new", role: "user", content: { type: "image" } }] }, + }); + assert.equal(normalized.messages[0].text, ""); + assert.equal(entryText({ content: { type: "image" } }), '{"type":"image"}'); +}); + +test("normalizer supports items and direct array containers", () => { + const target = { id: "bot-1", name: "Bot", isGroup: false }; + assert.equal(normalizeTranscript({ target, transcript: { items: [] } }).messages.length, 0); + assert.equal(normalizeTranscript({ target, transcript: [] }).messages.length, 0); +}); + +test("normalizer fails closed on malformed containers and entries", () => { + const target = { id: "bot-1", name: "Bot", isGroup: false }; + assert.throws( + () => normalizeTranscript({ target, transcript: { entries: "not-an-array" } }), + /invalid transcript container/i, + ); + assert.throws( + () => normalizeTranscript({ target, transcript: { entries: [null] } }), + /invalid transcript entry/i, + ); +}); + +test("normalizer fails closed on malformed target and message identities", () => { + const transcript = { entries: [] }; + for (const target of [ + { id: "", name: "Bot", isGroup: false }, + { id: "bot-1", name: " ", isGroup: false }, + { id: 7, name: "Bot", isGroup: false }, + { id: "bot-1", name: {}, isGroup: false }, + { id: "bot-1", name: "Bot", isGroup: "false" }, + ]) { + assert.throws(() => normalizeTranscript({ target, transcript }), /invalid transcript target/i); + } + + const target = { id: "bot-1", name: "Bot", isGroup: false }; + for (const id of ["", " ", 42, {}]) { + assert.throws( + () => normalizeTranscript({ target, transcript: { entries: [{ id, text: "message" }] } }), + /invalid transcript message id/i, + ); + } + assert.deepEqual( + normalizeTranscript({ target, transcript: { entries: [{ text: "no id" }] } }).messages[0], + { id: null, role: "unknown", text: "no id" }, + ); +}); + +test("normalizer rejects conflicting or malformed ID carriers", () => { + const target = { id: "bot-1", name: "Bot", isGroup: false }; + for (const entry of [ + { id: "first", messageId: "second", text: "message" }, + { id: "first", messageId: {}, text: "message" }, + { id: {}, messageId: "second", text: "message" }, + ]) { + assert.throws( + () => normalizeTranscript({ target, transcript: { entries: [entry] } }), + /invalid transcript message id/i, + ); + } + + const messages = normalizeTranscript({ + target, + transcript: { + entries: [ + { id: "same", messageId: "same", text: "one" }, + { id: null, messageId: "fallback", text: "two" }, + ], + }, + }).messages; + assert.equal(messages[0].id, "same"); + assert.equal(messages[1].id, "fallback"); +});