diff --git a/plugins/web-ui/src/composer.ts b/plugins/web-ui/src/composer.ts index 1e0e1a63c..da6cb58f7 100644 --- a/plugins/web-ui/src/composer.ts +++ b/plugins/web-ui/src/composer.ts @@ -1078,6 +1078,10 @@ export function createComposerSurface(ctx: ConvCtx): ComposerSurface { } function onComposerKeydown(e: KeyboardEvent, agent: Agent): void { + // During IME composition (Japanese/Chinese/Korean), Enter confirms the + // conversion — it must never send. Safari reports composition Enter with + // keyCode 229 and may fire after compositionend, so check both. + if (e.isComposing || e.keyCode === 229) return; const slash = currentSlashMenu(); if (slash.open) { if (e.key === "Escape") { diff --git a/plugins/web-ui/src/sessions.ts b/plugins/web-ui/src/sessions.ts index 738e164f0..dafb53675 100644 --- a/plugins/web-ui/src/sessions.ts +++ b/plugins/web-ui/src/sessions.ts @@ -971,6 +971,7 @@ function renameInput(menuKey: string, ariaLabel: string, commit: () => Promise { + if (e.isComposing || e.keyCode === 229) return; if (e.key === "Enter") { e.preventDefault(); void commit(); diff --git a/scripts/dev/twin-instance.ts b/scripts/dev/twin-instance.ts index 631f20ee3..c7fbce872 100644 --- a/scripts/dev/twin-instance.ts +++ b/scripts/dev/twin-instance.ts @@ -1,10 +1,11 @@ +import { fileURLToPath } from "node:url"; import { spawn, spawnSync } from "node:child_process"; import { existsSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { provisionTwinEnvironment, teardownTwinEnvironment } from "../../test/live-slack/arga-provision.ts"; import { readEnvFile } from "./lib/util.ts"; -const root = new URL("../..", import.meta.url).pathname.replace(/\/$/, ""); +const root = fileURLToPath(new URL("../..", import.meta.url)).replace(/\/$/, ""); const dir = join(root, ".twin-instance"); const envFile = join(dir, "env"); const pumpPid = join(dir, "pump.pid"); diff --git a/scripts/memory-bench.ts b/scripts/memory-bench.ts index c268d9ee1..cb6fe3cc4 100644 --- a/scripts/memory-bench.ts +++ b/scripts/memory-bench.ts @@ -1,3 +1,4 @@ +import { fileURLToPath } from "node:url"; import { mkdtempSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -37,7 +38,7 @@ for (const k of kinds) { } } -const fixtureDir = new URL("../test/memory-bench/conversations/", import.meta.url).pathname; +const fixtureDir = fileURLToPath(new URL("../test/memory-bench/conversations/", import.meta.url)); const conversations = readdirSync(fixtureDir) .filter((f) => f.endsWith(".json")) .sort() diff --git a/src/api/app-turn.ts b/src/api/app-turn.ts index a038be37b..2fee1b6d8 100644 --- a/src/api/app-turn.ts +++ b/src/api/app-turn.ts @@ -315,11 +315,22 @@ export function createTurnMethods( return req.async ? { status: "queued", runId: live.id, steered: true } : drive(live.id); if (decision === "unscreened") injectedText = `${unscreenedNotice("mid-turn message")}\n${steerText}`; } + // A mid-run message can carry files. They can't be materialized into + // the live turn's inbox, but the run must hear about them — name + // them in the steer (with the message ts so the agent can pull each + // via the surface-file API), and never report a captionless file as + // steered while silently dropping it. + const fileNames = (req.attachments ?? []).map((a) => + a.sourceId + ? `${a.name} (fetch via surface-file, ts ${origin.kind === "human" ? (origin.messageTs ?? origin.entryTs) : origin.entryTs})` + : a.name, + ); const wake: Wake = { situation: origin.kind === "ambient" ? "ambientUpdate" : "addressed", ts: String(req.clientSentAt ?? Date.now()), text: injectedText, halt: origin.kind === "human" && isHalt(req.text), + ...(fileNames.length ? { fileNames } : {}), }; const route = routeWake(wake, true, resolveTurnOrigin(live.request).kind === "ambient"); if (route.kind === "steer" || route.kind === "drop") { diff --git a/src/memory/strategies/scratch-promote.ts b/src/memory/strategies/scratch-promote.ts index e5113b28d..1d44b8d0a 100644 --- a/src/memory/strategies/scratch-promote.ts +++ b/src/memory/strategies/scratch-promote.ts @@ -183,7 +183,13 @@ export function createScratchPromote(deps: ScratchPromoteDeps): { strategy: Memo const now = Date.now(); const window = await readLogWindow(scopeId, now, LOG_RETENTION_DAYS); if (window.length && deps.harness.oneShot) { - const longTerm = stripMarker(await base.read(scopeId)); + // Promotion is a read → model round-trip → write. A save that lands + // during the round-trip must not be silently reverted by the write, + // so the write is compare-and-set against the revision we read; on a + // lost race we skip — the next promotion pass will pick everything up. + const head = base.readHead ? await base.readHead(scopeId) : null; + const raw = head ? head.content : await base.read(scopeId); + const longTerm = stripMarker(raw); const scratch = window.map(({ date, body }) => `## ${date}\n${body}`).join("\n\n"); const out = ( (await deps.harness.oneShot( @@ -191,7 +197,13 @@ export function createScratchPromote(deps: ScratchPromoteDeps): { strategy: Memo `Current notebook:\n${longTerm || "(empty)"}\n\nScratch log:\n${scratch}`, )) ?? "" ).trim(); - if (out && !/^none$/i.test(out)) await base.replace(scopeId, out); + if (out && !/^none$/i.test(out)) { + if (head && base.replaceIfRevision) { + await base.replaceIfRevision(scopeId, out, head.revision); + } else { + await base.replace(scopeId, out); + } + } } const cutoff = dateStr(now - LOG_RETENTION_DAYS * 86_400_000); for (const abs of await workspace.list(scopeId)) { diff --git a/src/persistence/durable-map.ts b/src/persistence/durable-map.ts index 94a2d63c2..da3d0a917 100644 --- a/src/persistence/durable-map.ts +++ b/src/persistence/durable-map.ts @@ -14,6 +14,39 @@ export interface DurableMap { take(id: string): Promise; } +/** + * Serialize for a Postgres jsonb column. jsonb rejects two things a JS string + * happily carries: NUL (\u0000) and unpaired surrogate halves — and qm's own + * truncation helpers can manufacture the latter by slicing mid-emoji. The + * memory map accepts those values, so production diverged from every + * in-memory test. Sanitize at the serialization boundary: drop NULs and + * replace lone surrogates with U+FFFD, recursively, only when a string + * actually needs it. + */ +const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? = {}; + for (const [k, v] of Object.entries(value)) out[jsonbSafeString(k)] = jsonbSafe(v); + return out; + } + return value; +} + +export function jsonbStringify(value: unknown): string { + return JSON.stringify(jsonbSafe(value)); +} + function applyPatch(value: T, patch: Partial): T { const next = { ...value } as Record; for (const [k, v] of Object.entries(patch)) { @@ -153,7 +186,7 @@ export function createPostgresMap(pg: PgPool, table: string): DurableMap { client.query( `INSERT INTO ${table} (id, json) VALUES ($1, $2) ON CONFLICT (id) DO UPDATE SET json = EXCLUDED.json`, - [id, JSON.stringify(value)], + [id, jsonbStringify(value)], ), ); }, @@ -163,7 +196,7 @@ export function createPostgresMap(pg: PgPool, table: string): DurableMap { `INSERT INTO ${table} (id, json) VALUES ($1, $2) ON CONFLICT (id) DO UPDATE SET json = ${table}.json RETURNING json`, - [id, JSON.stringify(value)], + [id, jsonbStringify(value)], ), ); return res.rows[0]!.json as T; @@ -173,7 +206,7 @@ export function createPostgresMap(pg: PgPool, table: string): DurableMap { client.query( `INSERT INTO ${table} (id, json) VALUES ($1, $2) ON CONFLICT (id) DO NOTHING`, - [id, JSON.stringify(value)], + [id, jsonbStringify(value)], ), ); return (inserted.rowCount ?? 0) > 0; @@ -186,7 +219,7 @@ export function createPostgresMap(pg: PgPool, table: string): DurableMap { client.query(`UPDATE ${table} SET json = (json - $2::text[]) || $3::jsonb WHERE id = $1 RETURNING json`, [ id, removeKeys, - JSON.stringify(set), + jsonbStringify(set), ]), ); return res.rows.length ? (res.rows[0]!.json as T) : null; @@ -196,7 +229,7 @@ export function createPostgresMap(pg: PgPool, table: string): DurableMap { const current = await client.query(`SELECT json FROM ${table} WHERE id = $1 FOR UPDATE`, [id]); if (!current.rows[0]) return null; const next = fn(current.rows[0].json as T); - await client.query(`UPDATE ${table} SET json = $2 WHERE id = $1`, [id, JSON.stringify(next)]); + await client.query(`UPDATE ${table} SET json = $2 WHERE id = $1`, [id, jsonbStringify(next)]); return next; }); }, diff --git a/src/sandbox/exec-process-session.ts b/src/sandbox/exec-process-session.ts index 5057c3b71..1627dde13 100644 --- a/src/sandbox/exec-process-session.ts +++ b/src/sandbox/exec-process-session.ts @@ -50,7 +50,7 @@ function parseStatus(raw: string): ProcessState { export function redactCommand(command: string, env?: Record): string { return createSecretValueMasker(env)(command) .replace(/(--?(?:token|password|secret|client[-_]?secret|api[-_]?key)[ =])\S+/gi, "$1") - .replace(/(--with-token\b)/gi, "$1") + .replace(/(?:printf|echo)(?:\s+(?:-\w+|"[^"]*"|'[^']*'|\S+))+(\s*\|[^|]*--with-token\b)/gi, "echo $1") .replace( /(export\s+\w*(?:PASS|PASSWORD|SECRET|TOKEN|KEY|IDENTIFIER|CREDENTIAL|PROXY_USER)\w*=')[^']*'/gi, "$1'", diff --git a/src/security/secret-masking.ts b/src/security/secret-masking.ts index f2b84fe4c..d433bd0a7 100644 --- a/src/security/secret-masking.ts +++ b/src/security/secret-masking.ts @@ -21,6 +21,7 @@ export function createSecretValueMasker(env: Record | undefined) const uri = encodeURIComponent(value); if (uri !== value) variants.push({ needle: uri, label: key }); variants.push({ needle: Buffer.from(value, "utf8").toString("base64").replace(/=+$/, ""), label: key }); + variants.push({ needle: Buffer.from(value, "utf8").toString("base64url"), label: key }); } if (!variants.length) return (text) => text; variants.sort((a, b) => b.needle.length - a.needle.length); diff --git a/src/slack/mrkdwn.ts b/src/slack/mrkdwn.ts index fa27bb055..155ba5977 100644 --- a/src/slack/mrkdwn.ts +++ b/src/slack/mrkdwn.ts @@ -1,3 +1,4 @@ +import { safeChunks } from "./safe-cut.ts"; export function decodeSlackEntities(text: string): string { return text.replace(/</g, "<").replace(/>/g, ">").replace(/&/g, "&"); } @@ -173,9 +174,5 @@ function reformatTables(text: string, keep: (s: string) => string): string { } export function slackSectionBlocks(text: string): Array> { - const blocks: Array> = []; - for (let offset = 0; offset < text.length; offset += 2_900) { - blocks.push({ type: "section", text: { type: "mrkdwn", text: text.slice(offset, offset + 2_900) } }); - } - return blocks; + return safeChunks(text, 2_900).map((chunk) => ({ type: "section", text: { type: "mrkdwn", text: chunk } })); } diff --git a/src/slack/safe-cut.ts b/src/slack/safe-cut.ts new file mode 100644 index 000000000..6f160a9a7 --- /dev/null +++ b/src/slack/safe-cut.ts @@ -0,0 +1,56 @@ +/** + * Cut points that never split what a reader (or Slack's parser) treats as one + * unit: a surrogate pair (emoji), a `` entity, or an inline code / + * bold / strike span. Backing off never goes past `floor` characters (a + * pathological single entity longer than the whole budget is cut anyway — + * better a broken link than an empty message). + */ +const FLOOR_FRACTION = 0.25; + +function isLowSurrogate(code: number): boolean { + return code >= 0xdc00 && code <= 0xdfff; +} + +/** The largest index <= max that is safe to cut at. */ +export function safeCutIndex(text: string, max: number): number { + if (text.length <= max) return text.length; + let cut = max; + // never split a surrogate pair + if (isLowSurrogate(text.charCodeAt(cut))) cut--; + const floor = Math.max(1, Math.floor(max * FLOOR_FRACTION)); + // never split a <...> entity (links, mentions): back off to before the "<" + const lastOpen = text.lastIndexOf("<", cut - 1); + if (lastOpen >= 0) { + const lastClose = text.lastIndexOf(">", cut - 1); + if (lastOpen > lastClose && lastOpen >= floor) cut = lastOpen; + } + // never leave an unbalanced formatting run open right at the cut + for (const marker of ["`", "*", "~"]) { + let count = 0; + for (let i = 0; i < cut; i++) if (text[i] === marker) count++; + if (count % 2 === 1) { + const back = text.lastIndexOf(marker, cut - 1); + if (back >= floor) cut = Math.min(cut, back); + } + } + if (isLowSurrogate(text.charCodeAt(cut))) cut--; + return Math.max(cut, 1); +} + +/** Split text into chunks of at most `max`, cutting only at safe points. */ +export function safeChunks(text: string, max: number): string[] { + const chunks: string[] = []; + let rest = text; + while (rest.length > 0) { + const cut = safeCutIndex(rest, max); + chunks.push(rest.slice(0, cut)); + rest = rest.slice(cut); + } + return chunks.length ? chunks : [""]; +} + +/** Truncate to at most `max` characters (plus ellipsis) at a safe point. */ +export function safeClip(text: string, max: number): string { + if (text.length <= max) return text; + return `${text.slice(0, safeCutIndex(text, max))}…`; +} diff --git a/src/slack/util.ts b/src/slack/util.ts index 9677d804f..bef61aebd 100644 --- a/src/slack/util.ts +++ b/src/slack/util.ts @@ -1,10 +1,13 @@ +import { safeCutIndex, safeClip } from "./safe-cut.ts"; + export function inlineCode(text: string): string { - const safe = text.replace(/`/g, "'").slice(0, 2000); - return `\`${safe}${text.length > safe.length ? "..." : ""}\``; + const cleaned = text.replace(/`/g, "'"); + const safe = cleaned.slice(0, safeCutIndex(cleaned, 2000)); + return `\`${safe}${cleaned.length > safe.length ? "..." : ""}\``; } export function clip(text: string, max: number): string { - return text.length > max ? `${text.slice(0, max)}…` : text; + return safeClip(text, max); } export function sleep(ms: number): Promise { diff --git a/src/wake/wake.ts b/src/wake/wake.ts index 5a5d14041..28f99d840 100644 --- a/src/wake/wake.ts +++ b/src/wake/wake.ts @@ -12,6 +12,8 @@ export interface Wake { text?: string; isSelf?: boolean; halt?: boolean; + /** Names of files attached to the message — a file with no caption is still a message. */ + fileNames?: string[]; } export type WakeRoute = @@ -26,6 +28,7 @@ export function routeWake(wake: Wake, runIsLive: boolean, liveRunGated = false): if (wake.halt) return { kind: "steer", signal: "abort" }; if (liveRunGated && wake.situation === "addressed") return { kind: "engage" }; const text = wake.text?.trim(); - if (!text) return { kind: "drop", reason: "empty-mid-turn" }; - return { kind: "steer", signal: "steer", text }; + const files = wake.fileNames?.length ? `[files attached mid-run: ${wake.fileNames.join(", ")}]` : ""; + if (!text && !files) return { kind: "drop", reason: "empty-mid-turn" }; + return { kind: "steer", signal: "steer", text: [text, files].filter(Boolean).join("\n") }; } diff --git a/test/durable-map-jsonb.test.ts b/test/durable-map-jsonb.test.ts new file mode 100644 index 000000000..dac230c80 --- /dev/null +++ b/test/durable-map-jsonb.test.ts @@ -0,0 +1,25 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { jsonbStringify } from "../src/persistence/durable-map.ts"; + +test("NUL characters are dropped for jsonb", () => { + const out = jsonbStringify({ name: "a\u0000b" }); + assert.equal(out, '{"name":"ab"}'); +}); + +test("a lone high surrogate (emoji cut in half by slice) becomes U+FFFD", () => { + const cut = "prefix 😀".slice(0, 8); // strands the high surrogate + const out = JSON.parse(jsonbStringify({ title: cut })) as { title: string }; + assert.ok(!/[\uD800-\uDBFF](?![\uDC00-\uDFFF])/.test(out.title), "no stranded surrogate survives"); + assert.ok(out.title.includes("\uFFFD")); +}); + +test("well-formed strings, keys, arrays, and nesting pass through untouched", () => { + const value = { a: "hello 😀", list: ["x", { deep: "ok" }], n: 3, b: true, z: null }; + assert.equal(jsonbStringify(value), JSON.stringify(value)); +}); + +test("keys are sanitized too", () => { + const out = jsonbStringify({ ["k\u0000ey"]: 1 }); + assert.equal(out, '{"key":1}'); +}); diff --git a/test/exec-process-session.test.ts b/test/exec-process-session.test.ts index d378d36b0..8605ad27e 100644 --- a/test/exec-process-session.test.ts +++ b/test/exec-process-session.test.ts @@ -148,6 +148,20 @@ test("redactCommand strips secret-bearing flags", () => { assert.match(redactCommand("svc --client-secret=shh"), //); }); +test("redactCommand hides a token piped into --with-token", () => { + for (const cmd of [ + "echo ghp_secretvalue12345 | gh auth login --with-token", + 'echo "ghp_secretvalue12345" | gh auth login --with-token', + "printf ghp_secretvalue12345 | gh auth login --with-token", + "echo -n ghp_secretvalue12345 | gh auth login --hostname x.test --with-token", + ]) { + const out = redactCommand(cmd); + assert.ok(!out.includes("ghp_secretvalue12345"), `leaked in: ${out}`); + assert.match(out, //); + assert.match(out, /--with-token/); + } +}); + test("redactCommand masks known injected env values the pattern layer misses", () => { const env = { GITHUB_TOKEN: "ghp_secretvalue12345" }; for (const cmd of [ diff --git a/test/memory-bench.test.ts b/test/memory-bench.test.ts index e674873e8..e837a85c3 100644 --- a/test/memory-bench.test.ts +++ b/test/memory-bench.test.ts @@ -1,3 +1,4 @@ +import { fileURLToPath } from "node:url"; import { test } from "node:test"; import assert from "node:assert/strict"; import { mkdtempSync, readdirSync, readFileSync } from "node:fs"; @@ -19,7 +20,7 @@ import { type BenchResult, } from "../src/memory/bench.ts"; -const FIXTURE_DIR = new URL("./memory-bench/conversations/", import.meta.url).pathname; +const FIXTURE_DIR = fileURLToPath(new URL("./memory-bench/conversations/", import.meta.url)); test("every bench fixture parses and has at least two turns", () => { const files = readdirSync(FIXTURE_DIR).filter((f) => f.endsWith(".json")); diff --git a/test/memory-strategy-scratch-promote.test.ts b/test/memory-strategy-scratch-promote.test.ts index c886ff373..4b5cc936f 100644 --- a/test/memory-strategy-scratch-promote.test.ts +++ b/test/memory-strategy-scratch-promote.test.ts @@ -173,3 +173,18 @@ test("strategy wiring: scratch-promote parses, wraps the store, and ships prompt "per-turn gets the consolidating store — captures by any path trigger the after-N check", ); }); + +test("a save landing during promotion is not reverted by the promote write", async () => { + const { base, strategy, memory } = fresh({ + oneShot: async () => { + // a user edit lands while the model call is in flight + await base.replace(SCOPE, "# Memory\n\n- (2026-06-10) the newer edit"); + return "# Memory\n\n- (2026-06-10) promoted fact"; + }, + }); + await withNow(TODAY, () => memory.capture(SCOPE, ["something recent"], TODAY)); + await withNow(TODAY, () => strategy.maintain!(SCOPE)); + const after = await base.read(SCOPE); + assert.match(after, /the newer edit/, "the mid-flight edit survives"); + assert.doesNotMatch(after, /promoted fact/, "the stale promotion is dropped, not applied"); +}); diff --git a/test/safe-cut.test.ts b/test/safe-cut.test.ts new file mode 100644 index 000000000..3c8013411 --- /dev/null +++ b/test/safe-cut.test.ts @@ -0,0 +1,68 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { safeChunks, safeClip, safeCutIndex } from "../src/slack/safe-cut.ts"; +import { slackSectionBlocks } from "../src/slack/mrkdwn.ts"; + +const wellFormed = (s: string) => Buffer.from(s, "utf8").toString("utf8") === s; + +test("an emoji straddling the boundary is never split into lone surrogates", () => { + const text = "a".repeat(9) + "😀" + "b".repeat(10); + for (let max = 8; max <= 12; max++) { + for (const chunk of safeChunks(text, max)) { + assert.ok(wellFormed(chunk), `max=${max} produced a mangled chunk`); + } + } +}); + +test("a entity that fits the budget is not bisected", () => { + const text = "see for more, plus trailing text to split"; + const chunks = safeChunks(text, 50); + for (const c of chunks) { + const opens = (c.match(//g) ?? []).length; + assert.equal(opens, closes, `entity split across chunks: ${JSON.stringify(c)}`); + } + assert.equal(chunks.join(""), text); +}); + +test("a bold run that fits the budget is not left dangling open at the cut", () => { + const text = "start *bold text* trailing words follow here"; + for (const c of safeChunks(text, 14)) { + assert.equal((c.match(/\*/g) ?? []).length % 2, 0, `unbalanced bold in ${JSON.stringify(c)}`); + } +}); + +test("chunks always reassemble to the original text", () => { + const text = "🎉".repeat(50) + " " + "*bold*".repeat(30); + assert.equal(safeChunks(text, 37).join(""), text); + assert.equal(safeChunks(text, 7).join(""), text); +}); + +test("a pathological single entity longer than the budget still cuts", () => { + const text = "<" + "x".repeat(100); + const chunks = safeChunks(text, 10); + assert.ok(chunks.length > 1); + assert.equal(chunks.join(""), text); +}); + +test("safeClip appends an ellipsis and never mangles", () => { + const text = "abc😀def"; + for (let max = 1; max <= 8; max++) { + const out = safeClip(text, max); + assert.ok(wellFormed(out)); + } + assert.equal(safeClip("short", 10), "short"); +}); + +test("slackSectionBlocks uses safe cuts", () => { + const text = "x".repeat(2_899) + "😀" + "y".repeat(100); + for (const b of slackSectionBlocks(text)) { + const t = (b as { text: { text: string } }).text.text; + assert.ok(wellFormed(t)); + assert.ok(t.length <= 2_900); + } +}); + +test("safeCutIndex returns full length for short text", () => { + assert.equal(safeCutIndex("abc", 10), 3); +}); diff --git a/test/secret-masking.test.ts b/test/secret-masking.test.ts index 835002704..bc6b5bcac 100644 --- a/test/secret-masking.test.ts +++ b/test/secret-masking.test.ts @@ -54,3 +54,13 @@ test("empty or absent env is a passthrough", () => { assert.equal(createSecretValueMasker(undefined)("echo hi"), "echo hi"); assert.equal(createSecretValueMasker({})("echo hi"), "echo hi"); }); + +test("a JWT-shaped (base64url) form of a secret is masked", () => { + const value = "secret+value/with=chars"; + const mask = createSecretValueMasker({ VAULT_PASS: value }); + const b64url = Buffer.from(value, "utf8").toString("base64url"); + assert.equal( + mask(`curl -H "authorization: Bearer ${b64url}"`), + 'curl -H "authorization: Bearer "', + ); +}); diff --git a/test/wake-route.test.ts b/test/wake-route.test.ts index f07a0bdf3..adcb6219d 100644 --- a/test/wake-route.test.ts +++ b/test/wake-route.test.ts @@ -63,3 +63,22 @@ test("isHalt matches a bare stop (any case, optional . or !) and nothing wordier for (const s of ["stop", "STOP", "Stop.", "stop!", " stop "]) assert.ok(isHalt(s), s); for (const s of ["stop the build", "please stop", "stopwatch", ""]) assert.ok(!isHalt(s), s); }); + +test("a captionless file mid-run steers (names the file) instead of dropping", () => { + const route = routeWake({ situation: "engagedUpdate", ts: "1", fileNames: ["screenshot.png"] }, true); + assert.equal(route.kind, "steer"); + assert.match((route as { text?: string }).text ?? "", /screenshot\.png/); +}); + +test("text plus files steers with both", () => { + const route = routeWake({ situation: "engagedUpdate", ts: "1", text: "here you go", fileNames: ["a.pdf"] }, true); + assert.equal(route.kind, "steer"); + const text = (route as { text?: string }).text ?? ""; + assert.match(text, /here you go/); + assert.match(text, /a\.pdf/); +}); + +test("no text and no files still drops", () => { + const route = routeWake({ situation: "engagedUpdate", ts: "1" }, true); + assert.equal(route.kind, "drop"); +});