diff --git a/src/index.ts b/src/index.ts index aed6a9d..112edc4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,7 +15,7 @@ import { execFileSync } from "node:child_process"; import { randomUUID } from "node:crypto"; -import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, type FSWatcher, mkdirSync, readdirSync, readFileSync, rmSync, watch, writeFileSync } from "node:fs"; import { join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; @@ -47,6 +47,8 @@ const PLAN_MODE_BLOCKED_TOOLS = ["edit", "write"]; const SUBTASK_LINE = /^\s+(?:\d+\.|[-*])\s*\[([ xX/-])\]\s*(.*)$/; type GoalStatus = "open" | "active" | "done" | "cancelled"; const CHAR_TO_STATUS: Record = { " ": "open", "/": "active", x: "done", "-": "cancelled" }; +const STATUS_TO_CHAR: Record = { open: " ", active: "/", done: "x", cancelled: "-" }; +const goalKey = (subject: string) => subject.trim().toLowerCase(); function scanGoals(plan: string): Array<{ status: GoalStatus; subject: string; line: number }> { const goals: Array<{ status: GoalStatus; subject: string; line: number }> = []; @@ -92,6 +94,8 @@ interface PlanState { approvalId: string | null; planVersion: number | null; latestDirection: string; + signedOffGoals: string[]; + previousPlan: string | null; } export default function piGoalsExtension(pi: ExtensionAPI): void { @@ -113,6 +117,8 @@ export default function piGoalsExtension(pi: ExtensionAPI): void { approvalId: null, planVersion: null, latestDirection: "", + signedOffGoals: [], + previousPlan: null, }; let modelError: string | null = null; let readyAttempt: object | undefined; @@ -137,6 +143,31 @@ export default function piGoalsExtension(pi: ExtensionAPI): void { pi.appendEntry(STATE, state); } + // Only CompleteGoal adds sign-off; direct edits remain claims for supervisor judgment. + function refreshSignoffs(ctx: ExtensionContext): void { + if (state.phase !== "working") return; + const goals = scanGoals(readPlan(ctx)); + const signedOffGoals = state.signedOffGoals.filter(subject => { + const matches = goals.filter(goal => goalKey(goal.subject) === subject); + return matches.length === 1 && matches[0].status === "done"; + }); + if (signedOffGoals.length !== state.signedOffGoals.length) { + state = { ...state, signedOffGoals }; + persist(); + } + } + + function planReview(plan: string): string { + const goals = scanGoals(plan); + const previous = scanGoals(state.previousPlan ?? ""); + const changes = goals.flatMap(goal => { + const old = previous.find(prior => goalKey(prior.subject) === goalKey(goal.subject)); + return old?.status === goal.status ? [] : [`${goal.subject}: ${old ? `[${STATUS_TO_CHAR[old.status]}]` : "not previously observed"} -> [${STATUS_TO_CHAR[goal.status]}]${goal.status === "done" ? state.signedOffGoals.includes(goalKey(goal.subject)) ? "; CompleteGoal sign-off recorded" : "; manual completion claim, no CompleteGoal sign-off recorded" : ""}`]; + }); + const claims = goals.filter(goal => goal.status === "done" && !state.signedOffGoals.includes(goalKey(goal.subject))); + return `Claims awaiting supervisor judgment: ${claims.map(goal => goal.subject).join(", ") || "none"}\nGoal-state changes:\n${changes.join("\n") || "none"}\nPlan diff since the previous published view:\n${planDiff(state.previousPlan ?? "", plan)}\nManual edits are allowed. Inspect changes and steer a correction when warranted; a checkbox is not sign-off.`; + } + function pauseReason(): string | null { if (!state.phase) return null; if (modelError) return `${modelError} Select /model, then run /goals reconnect.`; @@ -214,22 +245,31 @@ export default function piGoalsExtension(pi: ExtensionAPI): void { let workerTurns = 0; let viewGeneration = 0; let viewTimer: ReturnType | undefined; + let planWatcher: FSWatcher | undefined; + let planEditTimer: ReturnType | undefined; - async function publishWorkerView(ctx: ExtensionContext, reason: "ready" | "settled" | "turns" | "interval" | "started"): Promise { + async function publishWorkerView(ctx: ExtensionContext, reason: "ready" | "settled" | "turns" | "interval" | "started" | "plan"): Promise { if (state.phase !== "working" || modelError || !intercom.bound) return; const generation = ++viewGeneration; const binding = state.approvalId; const background = reason === "started" ? { quiet: false, description: "agent starting; background state not queried" } : await backgroundState(pi); if (!intercom.bound || modelError || generation !== viewGeneration || binding !== state.approvalId || state.phase !== "working") return; + refreshSignoffs(ctx); + const plan = readPlan(ctx); const entries = ctx.sessionManager.getBranch(); const view = workerView(entries, reason, reason !== "started" && ctx.isIdle(), { sourceSession: ctx.sessionManager.getSessionFile()!, latestDirection: state.latestDirection, model: ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "not selected", since: intercom.acknowledgedEntry, background: background.description, + planReview: `Plan: ${planRel(ctx)}\n${planReview(plan)}`, }); intercom.view(view, reason, entries.at(-1)?.id, background.quiet); - const goals = scanGoals(readPlan(ctx)); - if (goals.length > 0 && goals.every((goal) => goal.status === "done" || goal.status === "cancelled")) { + if (reason !== "started" && intercom.connected && state.previousPlan !== plan) { + state = { ...state, previousPlan: plan }; + persist(); + } + const goals = scanGoals(plan); + if (goals.length > 0 && goals.every((goal) => (goal.status === "done" && state.signedOffGoals.includes(goalKey(goal.subject))) || goal.status === "cancelled")) { stopWorkerTimers(); state = { ...state, phase: null }; models.leave(); @@ -239,12 +279,37 @@ export default function piGoalsExtension(pi: ExtensionAPI): void { } function startWorkerTimers(ctx: ExtensionContext): void { + if (!planWatcher) { + const activePath = planPath(ctx); + try { + // Watch the containing directory so atomic replacement does not lose the file watch. + planWatcher = watch(join(ctx.cwd, PLAN_DIR), (_event, filename) => { + if (intercom.ended || state.phase !== "working") return; + if (filename && join(ctx.cwd, PLAN_DIR, filename.toString()) !== activePath) return; + if (planEditTimer) clearTimeout(planEditTimer); + planEditTimer = setTimeout(() => { + planEditTimer = undefined; + if (intercom.ended || state.phase !== "working" || planPath(ctx) !== activePath) return; + updateWidget(ctx); + // Working edits coalesce into the existing settled view; idle edits wake review now. + if (ctx.isIdle() && readPlan(ctx) !== state.previousPlan) { + void publishWorkerView(ctx, "plan").catch(error => { if (!intercom.ended) ctx.ui.notify(`Plan review failed: ${String(error)}`, "error"); }); + } + }, 150); + }); + planWatcher.on("error", error => { if (!intercom.ended) ctx.ui.notify(`Plan watch failed: ${error.message}`, "error"); }); + } catch (error) { ctx.ui.notify(`Could not watch active plan: ${String(error)}`, "warning"); } + } if (!viewTimer) viewTimer = setInterval(() => { void publishWorkerView(ctx, "interval").catch(error => { if (!intercom.ended) ctx.ui.notify(`Worker view failed: ${String(error)}`, "error"); }); }, 60 * 60_000); } function stopWorkerTimers(): void { + planWatcher?.close(); + planWatcher = undefined; + if (planEditTimer) clearTimeout(planEditTimer); + planEditTimer = undefined; if (viewTimer) clearInterval(viewTimer); viewTimer = undefined; } @@ -265,6 +330,7 @@ export default function piGoalsExtension(pi: ExtensionAPI): void { } function updateWidget(ctx: ExtensionContext): void { + refreshSignoffs(ctx); const paused = pauseReason(); if (paused) { ctx.ui.setStatus(STATUS_KEY, ctx.ui.theme.fg("warning", "goals paused")); @@ -282,16 +348,19 @@ export default function piGoalsExtension(pi: ExtensionAPI): void { ctx.ui.setWidget(WIDGET_KEY, undefined); return; } - const done = goals.filter((g) => g.status === "done").length; - const liveGoals = goals.filter((g) => g.status === "active" || g.status === "open"); - const stateLabel = liveGoals.length > 0 ? " · supervised" : " · complete"; + const isSignedOff = (subject: string) => state.signedOffGoals.includes(goalKey(subject)); + const done = goals.filter(g => g.status === "done" && isSignedOff(g.subject)).length; + const claimed = goals.filter(g => g.status === "done" && !isSignedOff(g.subject)); + const liveGoals = goals.filter(g => g.status === "active" || g.status === "open"); + const stateLabel = claimed.length ? ` · ${claimed.length} claimed, awaiting review` : liveGoals.length > 0 ? " · supervised" : " · complete"; ctx.ui.setStatus(STATUS_KEY, ctx.ui.theme.fg("accent", `◷ ${done}/${goals.length} goals${stateLabel}`)); const mark: Record = { done: "✔", active: "▸", open: "◻", cancelled: "✗" }; // Only live goals get lines so finished work never pushes current work off screen. The active // goal also shows its open subtasks: this file is the task list, so the widget is the task list. // No path line: the session id makes it too long to be useful in the widget. const plan = readPlan(ctx); - const lines: string[] = liveGoals.length === 0 ? ["✔ complete"] : []; + const lines: string[] = claimed.map(g => `? claimed complete; awaiting supervisor review: ${g.subject}`); + if (liveGoals.length === 0 && claimed.length === 0) lines.push("✔ complete"); for (const g of liveGoals) { lines.push(`${mark[g.status]} ${g.status === "active" ? "supervising… " : ""}${g.subject}`); if (g.status === "active") lines.push(...openSubtasks(plan, g.line).slice(0, 3).map((s) => ctx.ui.theme.fg("muted", ` ◦ ${s}`))); @@ -375,7 +444,7 @@ export default function piGoalsExtension(pi: ExtensionAPI): void { return; } await restoreModel("planning", ctx); - state = { ...state, phase: "planning", supervisorPaneId: null, approvalId: null, planVersion: nextVersion(ctx), latestDirection: arg }; + state = { ...state, phase: "planning", supervisorPaneId: null, approvalId: null, planVersion: nextVersion(ctx), latestDirection: arg, signedOffGoals: [], previousPlan: null }; planningContextPending = true; resyncReason = null; writePlan(ctx, ""); @@ -567,6 +636,8 @@ export default function piGoalsExtension(pi: ExtensionAPI): void { approvalId: last?.data?.approvalId ?? null, planVersion: last?.data?.planVersion ?? null, latestDirection: last?.data?.latestDirection ?? "", + signedOffGoals: last?.data?.signedOffGoals ?? [], + previousPlan: last?.data?.previousPlan ?? null, }; modelError = state.phase ? "Role model restoration is pending." : null; planningContextPending = state.phase === "planning"; @@ -633,6 +704,8 @@ export default function piGoalsExtension(pi: ExtensionAPI): void { const ticked = tickGoal(plan, params.goal); if (!ticked) return result(`No unique exact goal line matched "${params.goal}" in ${planRel(ctx)}.`, true); writePlan(ctx, appendLog(ticked, `${stamp()} mechanically signed off "${params.goal}" after matching supervisor approval`)); + state = { ...state, signedOffGoals: [...state.signedOffGoals.filter(goal => goal !== goalKey(params.goal)), goalKey(params.goal)] }; + persist(); updateWidget(ctx); return result(`Sign-off accepted. Goal ticked [x] in ${planRel(ctx)}.`); }, @@ -641,6 +714,19 @@ export default function piGoalsExtension(pi: ExtensionAPI): void { // --- helpers (module scope) -------------------------------------------------------------------- +// A compact changed span, not a second plan parser. Worker views bound its serialized size. +function planDiff(before: string, after: string): string { + if (before === after) return "none"; + const old = before.split("\n"); + const next = after.split("\n"); + let start = 0; + while (start < old.length && start < next.length && old[start] === next[start]) start++; + let oldEnd = old.length; + let nextEnd = next.length; + while (oldEnd > start && nextEnd > start && old[oldEnd - 1] === next[nextEnd - 1]) { oldEnd--; nextEnd--; } + return [`@@ from line ${start + 1} @@`, ...old.slice(start, oldEnd).map(line => `- ${line}`), ...next.slice(start, nextEnd).map(line => `+ ${line}`)].join("\n"); +} + function result(text: string, isError = false) { return { content: [{ type: "text" as const, text }], details: {}, isError }; } diff --git a/src/worker-view.ts b/src/worker-view.ts index d670359..8d6b38b 100644 --- a/src/worker-view.ts +++ b/src/worker-view.ts @@ -53,9 +53,10 @@ export interface ViewContext { model: string; since?: string; background: string; + planReview?: string; } -export function workerView(entries: SessionEntry[], reason: "ready" | "settled" | "turns" | "interval" | "started", idle: boolean, context: ViewContext): string { +export function workerView(entries: SessionEntry[], reason: "ready" | "settled" | "turns" | "interval" | "started" | "plan", idle: boolean, context: ViewContext): string { const compactAt = entries.map(entry => entry.type).lastIndexOf("compaction"); const since = context.since ? entries.findIndex(entry => entry.id === context.since) : -1; const from = since >= compactAt ? since + 1 : compactAt + 1; @@ -64,5 +65,5 @@ export function workerView(entries: SessionEntry[], reason: "ready" | "settled" const summary = since < compactAt ? entries[compactAt]?.summary : undefined; const outstanding = outstandingTools(entries.slice(compactAt + 1)); const state = reason === "ready" ? "is ready to begin" : idle ? "stopped" : "is still working"; - return `The worker ${state}.\n\nreview trigger: ${reason}\nsource session: ${bounded(context.sourceSession, 800)}\nworker model: ${bounded(context.model, 300)}\nlatest human direction:\n${bounded(context.latestDirection || "not recorded", 1800)}\ntool calls with no result: ${bounded(outstanding.join(", ") || "none", 500)}\ntracked background work: ${bounded(context.background, 800)}\n\n${summary ? `compaction summary (worker account, not independent evidence):\n${bounded(summary, 2500)}\n\n` : ""}new worker transcript${since === -1 ? " (initial or reset view)" : " since the last acknowledged view"}:\n${bounded(recent || "No new messages.", 7000, true)}`; + return `The worker ${state}.\n\nreview trigger: ${reason}\nsource session: ${bounded(context.sourceSession, 800)}\nworker model: ${bounded(context.model, 300)}\nlatest human direction:\n${bounded(context.latestDirection || "not recorded", 1800)}\ntool calls with no result: ${bounded(outstanding.join(", ") || "none", 500)}\ntracked background work: ${bounded(context.background, 800)}\n\n${context.planReview ? `Plan review:\n${bounded(context.planReview, 1800)}\n\n` : ""}${summary ? `compaction summary (worker account, not independent evidence):\n${bounded(summary, 2500)}\n\n` : ""}new worker transcript${since === -1 ? " (initial or reset view)" : " since the last acknowledged view"}:\n${bounded(recent || "No new messages.", 5500, true)}`; } diff --git a/test/goals-flow.test.ts b/test/goals-flow.test.ts index b6ac07b..d116b19 100644 --- a/test/goals-flow.test.ts +++ b/test/goals-flow.test.ts @@ -1,5 +1,5 @@ import { execFileSync } from "node:child_process"; -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; @@ -90,7 +90,7 @@ afterEach(async () => { }); describe("/goals flow", () => { - it("reports actual idle state, invalidates stopped views on start, and stops completed plans", async () => { + it("reports idleness and keeps manual completion claims supervised without reverting edits", async () => { vi.useFakeTimers({ toFake: ["setInterval", "clearInterval"] }); const flow = setup(["Ready"]); try { @@ -100,31 +100,96 @@ describe("/goals flow", () => { const views = () => flow.transport.sent.filter(message => message.kind === "view"); await vi.advanceTimersByTimeAsync(60 * 60_000); expect(views().at(-1)?.text).toMatch(/^The worker stopped\./); + writeFileSync(path, readFileSync(path, "utf8").replace("[ ] goal:", "[/] goal:")); flow.ctx.isIdle.mockReturnValue(false); await flow.hooks.get("agent_start")({}, flow.ctx); expect(views().at(-1)?.text).toMatch(/^The worker is still working\./); await flow.hooks.get("agent_settled")({}, flow.ctx); expect(views().at(-1)?.text).toMatch(/^The worker is still working\./); flow.ctx.isIdle.mockReturnValue(true); - writeFileSync(path, readFileSync(path, "utf8").replace("[ ] goal:", "[x] goal:")); + writeFileSync(path, readFileSync(path, "utf8").replace("[/] goal:", "[x] goal:")); await flow.hooks.get("agent_settled")({}, flow.ctx); + await flow.hooks.get("turn_end")({}, flow.ctx); const count = views().length; - expect(flow.entries.at(-1)?.data).toMatchObject({ phase: null }); + expect(flow.entries.at(-1)?.data).toMatchObject({ phase: "working", signedOffGoals: [] }); + expect(views().at(-1)?.text).toContain("make the file: [/] -> [x]; manual completion claim, no CompleteGoal sign-off recorded"); + expect(readFileSync(path, "utf8")).toContain("[x] goal:"); + expect(flow.ctx.ui.setStatus).toHaveBeenLastCalledWith("pi-goals", expect.stringContaining("0/1 goals · 1 claimed, awaiting review")); + expect(flow.ctx.ui.setWidget).toHaveBeenLastCalledWith("pi-goals-widget", [expect.stringContaining("claimed complete; awaiting supervisor review")]); const binding = (flow.entries.at(-1)?.data as any).approvalId; - const messageCount = flow.messages.length; - flow.transport.receive({ binding, role: "supervisor", kind: "steer", id: "late-completed", text: "Obsolete instruction." }); - await flow.commands.get("goals").handler("restart", flow.ctx); - await flow.commands.get("goals").handler("reconnect", flow.ctx); - expect(flow.messages).toHaveLength(messageCount); - expect(openSupervisorPane).toHaveBeenCalledTimes(1); + flow.transport.receive({ binding, role: "supervisor", kind: "steer", id: "review-claim", text: "Reopen the goal; verify the missing output first." }); + expect(flow.messages.at(-1)?.content).toBe("[supervisor] Reopen the goal; verify the missing output first."); + await flow.hooks.get("session_start")({}, flow.ctx); + expect(flow.entries.at(-1)?.data).toMatchObject({ phase: "working", signedOffGoals: [] }); await vi.advanceTimersByTimeAsync(60 * 60_000); - expect(views()).toHaveLength(count); + expect(views().length).toBeGreaterThan(count); } finally { await flow.hooks.get("session_shutdown")(); vi.useRealTimers(); rmSync(flow.cwd, { recursive: true, force: true }); } }); + it("wakes review for an external non-checkbox plan edit, including atomic replacement", async () => { + const flow = setup(["Ready"]); + try { + await flow.commands.get("goals").handler("make the file", flow.ctx); + const path = approvedPlan(flow.cwd); + await flow.hooks.get("agent_settled")({}, flow.ctx); + const before = readFileSync(path, "utf8"); + writeFileSync(`${path}.tmp`, before.replace("output exists", "output contains exact required bytes")); + renameSync(`${path}.tmp`, path); + await vi.waitFor(() => { + const view = flow.transport.sent.filter(message => message.kind === "view").at(-1); + expect(view?.reason).toBe("plan"); + expect(view?.text).toContain("- - discriminator: output exists"); + expect(view?.text).toContain("+ - discriminator: output contains exact required bytes"); + }); + const count = flow.transport.sent.length; + await flow.hooks.get("session_shutdown")(); + writeFileSync(path, before); + await new Promise(resolve => setTimeout(resolve, 250)); + expect(flow.transport.sent).toHaveLength(count); + } finally { await flow.hooks.get("session_shutdown")(); rmSync(flow.cwd, { recursive: true, force: true }); } + }); + + it("coalesces active-worker plan edits into its settled review", async () => { + const flow = setup(["Ready"]); + try { + await flow.commands.get("goals").handler("make the file", flow.ctx); + const path = approvedPlan(flow.cwd); + await flow.hooks.get("agent_settled")({}, flow.ctx); + flow.ctx.isIdle.mockReturnValue(false); + const before = readFileSync(path, "utf8"); + writeFileSync(path, before.replace("output exists", "intermediate discriminator")); + writeFileSync(path, before.replace("output exists", "final discriminator")); + await new Promise(resolve => setTimeout(resolve, 250)); + expect(flow.transport.sent.filter(message => message.kind === "view" && message.reason === "plan")).toHaveLength(0); + flow.ctx.isIdle.mockReturnValue(true); + await flow.hooks.get("agent_settled")({}, flow.ctx); + const view = flow.transport.sent.filter(message => message.kind === "view").at(-1); + expect(view?.text).toContain("- - discriminator: output exists"); + expect(view?.text).toContain("+ - discriminator: final discriminator"); + expect(view?.text).not.toContain("intermediate discriminator"); + } finally { await flow.hooks.get("session_shutdown")(); rmSync(flow.cwd, { recursive: true, force: true }); } + }); + + it("restores sign-off markers but clears one when a goal is reopened", async () => { + const flow = setup([]); + try { + const path = writePlan(flow.cwd, "1. [x] goal: first\n2. [ ] goal: second\n"); + flow.entries.push({ type: "custom", customType: "pi-goals-state", data: { phase: "working", approvalId: "binding", planVersion: 1, signedOffGoals: ["first"], previousPlan: readFileSync(path, "utf8") } }); + await flow.hooks.get("session_start")({}, flow.ctx); + expect(flow.ctx.ui.setStatus).toHaveBeenLastCalledWith("pi-goals", expect.stringContaining("1/2 goals")); + writeFileSync(path, "1. [/] goal: first\n2. [ ] goal: second\n"); + await flow.hooks.get("turn_end")({}, flow.ctx); + expect(flow.entries.at(-1)?.data).toMatchObject({ signedOffGoals: [] }); + writeFileSync(path, "1. [x] goal: first\n2. [ ] goal: second\n"); + await flow.hooks.get("agent_settled")({}, flow.ctx); + await flow.hooks.get("turn_end")({}, flow.ctx); + expect(flow.ctx.ui.setStatus).toHaveBeenLastCalledWith("pi-goals", expect.stringContaining("0/2 goals · 1 claimed, awaiting review")); + } finally { await flow.hooks.get("session_shutdown")(); rmSync(flow.cwd, { recursive: true, force: true }); } + }); + it("preserves drafts, records the interview, and keeps planning read-only", async () => { const flow = setup(["Refine"], ["Keep two columns."]); try { @@ -247,7 +312,9 @@ describe("/goals flow", () => { expect(readFileSync(planPath, "utf8")).toContain("1. [x] goal: make the file"); expect(readFileSync(planPath, "utf8")).toContain("1. [ ] goal: make the file"); await flow.hooks.get("agent_settled")({}, flow.ctx); - expect(flow.entries.at(-1)?.data).toMatchObject({ phase: null }); + expect(flow.entries.at(-1)?.data).toMatchObject({ phase: null, signedOffGoals: [goal] }); + expect(flow.ctx.ui.setWidget).toHaveBeenLastCalledWith("pi-goals-widget", ["✔ complete"]); + await flow.hooks.get("session_start")({}, flow.ctx); expect(flow.ctx.ui.setWidget).toHaveBeenLastCalledWith("pi-goals-widget", ["✔ complete"]); } finally { rmSync(flow.cwd, { recursive: true, force: true }); diff --git a/test/worker-view.test.ts b/test/worker-view.test.ts index 4151230..562fc72 100644 --- a/test/worker-view.test.ts +++ b/test/worker-view.test.ts @@ -30,7 +30,7 @@ it("bounds serialized Unicode and quoted logs while marking omissions", () => { const view = workerView([ { id: "compact", type: "compaction", summary: '"\\🧪'.repeat(20_000) }, entry("new", '"\\🧪'.repeat(20_000)), - ], "interval", true, { ...context, latestDirection: "Remote only. ".repeat(3000) }); + ], "interval", true, { ...context, latestDirection: "Remote only. ".repeat(3000), planReview: '"\\🧪'.repeat(20_000) }); expect(Buffer.byteLength(JSON.stringify({ binding: "binding", role: "worker", kind: "view", id: "id", text: view }))).toBeLessThan(16_000); expect(view).toContain("[truncated; inspect source session]"); expect(view).toContain(context.sourceSession);