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:
parent
155df22e9e
commit
da135e9334
3 changed files with 218 additions and 11 deletions
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue