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 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher 2026-03-30 15:49:50 -04:00 committed by GitHub
parent 155df22e9e
commit da135e9334
3 changed files with 218 additions and 11 deletions

View file

@ -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
}
}
}

View file

@ -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 {

View file

@ -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"));
});
});