Show manual completion claims and wake supervision on plan edits

This commit is contained in:
wassname
2026-09-09 08:07:45 +08:00
parent 2b61440c73
commit 5567c9d5c2
4 changed files with 178 additions and 24 deletions
+95 -9
View File
@@ -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<string, GoalStatus> = { " ": "open", "/": "active", x: "done", "-": "cancelled" };
const STATUS_TO_CHAR: Record<GoalStatus, string> = { 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<PlanState>(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<typeof setInterval> | undefined;
let planWatcher: FSWatcher | undefined;
let planEditTimer: ReturnType<typeof setTimeout> | undefined;
async function publishWorkerView(ctx: ExtensionContext, reason: "ready" | "settled" | "turns" | "interval" | "started"): Promise<void> {
async function publishWorkerView(ctx: ExtensionContext, reason: "ready" | "settled" | "turns" | "interval" | "started" | "plan"): Promise<void> {
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<GoalStatus, string> = { 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 };
}
+3 -2
View File
@@ -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)}`;
}
+79 -12
View File
@@ -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 });
+1 -1
View File
@@ -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);