From da135e933489cb4febb327174babf35ebae9d156 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 30 Mar 2026 15:49:50 -0400 Subject: [PATCH] fix: persist forensics report context across follow-up turns (#2941) (#3261) The forensics prompt was sent as a one-shot message via sendMessage() with triggerTurn: true, causing context loss on follow-up turns. Now writes an active-forensics.json marker to .gsd/runtime/ so that buildBeforeAgentStartResult() can re-inject the forensics prompt on subsequent turns, mirroring how guided task execution context works. Co-authored-by: Claude Opus 4.6 --- .../gsd/bootstrap/system-context.ts | 61 +++++++-- src/resources/extensions/gsd/forensics.ts | 39 ++++++ .../tests/forensics-context-persist.test.ts | 129 ++++++++++++++++++ 3 files changed, 218 insertions(+), 11 deletions(-) create mode 100644 src/resources/extensions/gsd/tests/forensics-context-persist.test.ts diff --git a/src/resources/extensions/gsd/bootstrap/system-context.ts b/src/resources/extensions/gsd/bootstrap/system-context.ts index 0a8255fdc..d2cded710 100644 --- a/src/resources/extensions/gsd/bootstrap/system-context.ts +++ b/src/resources/extensions/gsd/bootstrap/system-context.ts @@ -1,4 +1,4 @@ -import { existsSync, readFileSync } from "node:fs"; +import { existsSync, readFileSync, unlinkSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; @@ -6,6 +6,7 @@ import type { ExtensionContext } from "@gsd/pi-coding-agent"; import { debugTime } from "../debug-logger.js"; import { loadPrompt } from "../prompt-loader.js"; +import { readForensicsMarker } from "../forensics.js"; import { resolveAllSkillReferences, renderPreferencesForSystemPrompt, loadEffectiveGSDPreferences } from "../preferences.js"; import { resolveGsdRootFile, resolveSliceFile, resolveSlicePath, resolveTaskFile, resolveTaskFiles, resolveTasksDir, relSliceFile, relSlicePath, relTaskFile } from "../paths.js"; import { hasSkillSnapshot, detectNewSkills, formatSkillsXml } from "../skill-discovery.js"; @@ -97,27 +98,30 @@ export async function buildBeforeAgentStartResult( warnDeprecatedAgentInstructions(); const injection = await buildGuidedExecuteContextInjection(event.prompt, process.cwd()); + + // Re-inject forensics context on follow-up turns (#2941) + const forensicsInjection = !injection ? buildForensicsContextInjection(process.cwd()) : null; + const worktreeBlock = buildWorktreeContextBlock(); const fullSystem = `${event.systemPrompt}\n\n[SYSTEM CONTEXT — GSD]\n\n${systemContent}${preferenceBlock}${knowledgeBlock}${memoryBlock}${newSkillsBlock}${worktreeBlock}`; stopContextTimer({ systemPromptSize: fullSystem.length, - injectionSize: injection?.length ?? 0, + injectionSize: injection?.length ?? forensicsInjection?.length ?? 0, hasPreferences: preferenceBlock.length > 0, hasNewSkills: newSkillsBlock.length > 0, }); + // Determine which context message to inject (guided execute takes priority) + const contextMessage = injection + ? { customType: "gsd-guided-context", content: injection, display: false as const } + : forensicsInjection + ? { customType: "gsd-forensics", content: forensicsInjection, display: false as const } + : null; + return { systemPrompt: fullSystem, - ...(injection - ? { - message: { - customType: "gsd-guided-context", - content: injection, - display: false as const, - }, - } - : {}), + ...(contextMessage ? { message: contextMessage } : {}), }; } @@ -375,3 +379,38 @@ function oneLine(text: string): string { return text.replace(/\s+/g, " ").trim(); } +// ─── Forensics Context Re-injection (#2941) ────────────────────────────────── + +/** + * Check for an active forensics session and return the prompt content + * so it can be re-injected on follow-up turns. + */ +function buildForensicsContextInjection(basePath: string): string | null { + const marker = readForensicsMarker(basePath); + if (!marker) return null; + + // Expire markers older than 2 hours to avoid stale context + const age = Date.now() - new Date(marker.createdAt).getTime(); + if (age > 2 * 60 * 60 * 1000) { + clearForensicsMarker(basePath); + return null; + } + + return marker.promptContent; +} + +/** + * Remove the active forensics marker file, e.g. when the investigation + * is complete or the session expires. + */ +export function clearForensicsMarker(basePath: string): void { + const markerPath = join(basePath, ".gsd", "runtime", "active-forensics.json"); + if (existsSync(markerPath)) { + try { + unlinkSync(markerPath); + } catch { + // non-fatal + } + } +} + diff --git a/src/resources/extensions/gsd/forensics.ts b/src/resources/extensions/gsd/forensics.ts index 78c074202..7549f7b6e 100644 --- a/src/resources/extensions/gsd/forensics.ts +++ b/src/resources/extensions/gsd/forensics.ts @@ -250,6 +250,9 @@ export async function handleForensics( { customType: "gsd-forensics", content, display: false }, { triggerTurn: true }, ); + + // Persist forensics context so follow-up turns can re-inject it (#2941) + writeForensicsMarker(basePath, savedPath, content); } // ─── Report Builder ─────────────────────────────────────────────────────────── @@ -896,6 +899,42 @@ function saveForensicReport(basePath: string, report: ForensicReport, problemDes return filePath; } +// ─── Forensics Session Marker ──────────────────────────────────────────────── + +export interface ForensicsMarker { + reportPath: string; + promptContent: string; + createdAt: string; +} + +/** + * Write a marker file so that buildBeforeAgentStartResult() can re-inject + * the forensics prompt on follow-up turns. (#2941) + */ +export function writeForensicsMarker(basePath: string, reportPath: string, promptContent: string): void { + const dir = join(gsdRoot(basePath), "runtime"); + mkdirSync(dir, { recursive: true }); + const marker: ForensicsMarker = { + reportPath, + promptContent, + createdAt: new Date().toISOString(), + }; + writeFileSync(join(dir, "active-forensics.json"), JSON.stringify(marker), "utf-8"); +} + +/** + * Read the active forensics marker, or null if none exists. + */ +export function readForensicsMarker(basePath: string): ForensicsMarker | null { + const markerPath = join(gsdRoot(basePath), "runtime", "active-forensics.json"); + if (!existsSync(markerPath)) return null; + try { + return JSON.parse(readFileSync(markerPath, "utf-8")) as ForensicsMarker; + } catch { + return null; + } +} + // ─── Prompt Formatter ───────────────────────────────────────────────────────── function formatReportForPrompt(report: ForensicReport): string { diff --git a/src/resources/extensions/gsd/tests/forensics-context-persist.test.ts b/src/resources/extensions/gsd/tests/forensics-context-persist.test.ts new file mode 100644 index 000000000..ab6cf91e8 --- /dev/null +++ b/src/resources/extensions/gsd/tests/forensics-context-persist.test.ts @@ -0,0 +1,129 @@ +import { describe, it, beforeEach, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const gsdDir = join(__dirname, ".."); + +/** + * Test suite for #2941: Forensics report context lost on follow-up turns. + * + * The forensics flow sends a one-shot message via sendMessage() with + * triggerTurn: true. On follow-up turns, the context is gone because + * there's no re-injection mechanism like buildGuidedExecuteContextInjection + * provides for task execution. + * + * Fix: write an active-forensics.json marker when forensics starts, and + * have buildBeforeAgentStartResult() re-inject the forensics prompt on + * subsequent turns. + */ + +describe("forensics context persistence (#2941)", () => { + // ─── Source-level invariant tests ────────────────────────────────────────── + + it("forensics.ts writes active-forensics marker after saving report", () => { + const src = readFileSync(join(gsdDir, "forensics.ts"), "utf-8"); + assert.ok( + src.includes("active-forensics.json"), + "forensics.ts must reference active-forensics.json marker file", + ); + assert.ok( + src.includes("writeForensicsMarker"), + "forensics.ts must call writeForensicsMarker to persist session state", + ); + }); + + it("system-context.ts checks for active forensics marker in buildBeforeAgentStartResult", () => { + const src = readFileSync(join(gsdDir, "bootstrap", "system-context.ts"), "utf-8"); + assert.ok( + src.includes("active-forensics.json"), + "system-context.ts must check for active-forensics.json marker", + ); + assert.ok( + src.includes("gsd-forensics"), + "system-context.ts must inject gsd-forensics customType message", + ); + }); + + it("system-context.ts exports clearForensicsMarker for cleanup", () => { + const src = readFileSync(join(gsdDir, "bootstrap", "system-context.ts"), "utf-8"); + assert.ok( + src.includes("clearForensicsMarker"), + "system-context.ts must export clearForensicsMarker function", + ); + }); + + // ─── Functional tests using temp directories ────────────────────────────── + + const tmpBase = join(__dirname, "__tmp_forensics_persist__"); + + beforeEach(() => { + rmSync(tmpBase, { recursive: true, force: true }); + mkdirSync(join(tmpBase, ".gsd", "runtime"), { recursive: true }); + mkdirSync(join(tmpBase, ".gsd", "forensics"), { recursive: true }); + }); + + afterEach(() => { + rmSync(tmpBase, { recursive: true, force: true }); + }); + + it("writeForensicsMarker creates marker with reportPath and promptContent", async () => { + const { writeForensicsMarker } = await import("../forensics.ts"); + + const reportPath = join(tmpBase, ".gsd", "forensics", "report-2026-01-01.md"); + writeFileSync(reportPath, "# Test Report", "utf-8"); + + writeForensicsMarker(tmpBase, reportPath, "Test forensics prompt content"); + + const markerPath = join(tmpBase, ".gsd", "runtime", "active-forensics.json"); + assert.ok(existsSync(markerPath), "marker file must be created"); + + const marker = JSON.parse(readFileSync(markerPath, "utf-8")); + assert.equal(marker.reportPath, reportPath); + assert.equal(marker.promptContent, "Test forensics prompt content"); + assert.ok(marker.createdAt, "marker must have createdAt timestamp"); + }); + + it("readForensicsMarker returns null when no marker exists", async () => { + const { readForensicsMarker } = await import("../forensics.ts"); + + const result = readForensicsMarker(join(tmpBase, "nonexistent")); + assert.equal(result, null); + }); + + it("readForensicsMarker returns marker data when file exists", async () => { + const { readForensicsMarker } = await import("../forensics.ts"); + + const markerPath = join(tmpBase, ".gsd", "runtime", "active-forensics.json"); + const markerData = { + reportPath: "/some/report.md", + promptContent: "forensics prompt", + createdAt: new Date().toISOString(), + }; + writeFileSync(markerPath, JSON.stringify(markerData), "utf-8"); + + const result = readForensicsMarker(tmpBase); + assert.ok(result); + assert.equal(result.reportPath, "/some/report.md"); + assert.equal(result.promptContent, "forensics prompt"); + }); + + it("clearForensicsMarker removes the marker file", async () => { + const { clearForensicsMarker } = await import("../bootstrap/system-context.ts"); + + const markerPath = join(tmpBase, ".gsd", "runtime", "active-forensics.json"); + writeFileSync(markerPath, JSON.stringify({ reportPath: "/x", promptContent: "y", createdAt: new Date().toISOString() }), "utf-8"); + assert.ok(existsSync(markerPath), "precondition: marker must exist"); + + clearForensicsMarker(tmpBase); + assert.ok(!existsSync(markerPath), "marker must be removed after clear"); + }); + + it("clearForensicsMarker is a no-op when no marker exists", async () => { + const { clearForensicsMarker } = await import("../bootstrap/system-context.ts"); + // Should not throw + clearForensicsMarker(join(tmpBase, "nonexistent")); + }); +});