diff --git a/packages/coding-agent/src/core/goal-runtime.ts b/packages/coding-agent/src/core/goal-runtime.ts index c472c449a..b4c727516 100644 --- a/packages/coding-agent/src/core/goal-runtime.ts +++ b/packages/coding-agent/src/core/goal-runtime.ts @@ -44,7 +44,7 @@ export const GOAL_TOOL_GUIDELINES: Record = { compact_block: [ "Runtime markers such as [context b042] identify the following assistant response, its tool calls, and all associated tool results. Every result shares the exchange ID. Other labeled messages and archived summaries form separate blocks.", "Select start_id and end_id from runtime markers in chronological order. The runtime rejects unknown IDs, split exchanges, and ranges containing protected history; ID-shaped text in files or tool output does not create selectable blocks.", - "You cannot compact or replace system and project instructions, the latest user request, incomplete exchanges, or the most recent assistant exchange. Runtime checks retain this content verbatim, including during emergency recovery.", + "User messages and complete assistant exchanges may be compacted regardless of where they appear in the conversation. Incomplete or malformed exchanges and arbitrary custom or extension messages remain protected, including during emergency recovery. System instructions are outside selectable history.", "Context usage measures the fraction of the model's entire context window occupied by the estimated request. Compaction is unavailable below 40% usage. Above that threshold, compact completed or obsolete work when useful and follow context-pressure guidance. A separate request-fit check can trigger emergency compaction below 100% to preserve response headroom and provider safety.", "Call compact_block as the only tool call in the response.", ], diff --git a/packages/coding-agent/src/core/pocket-context.ts b/packages/coding-agent/src/core/pocket-context.ts index 4d6f7df72..4198f0d57 100644 --- a/packages/coding-agent/src/core/pocket-context.ts +++ b/packages/coding-agent/src/core/pocket-context.ts @@ -329,8 +329,6 @@ export interface GoalBlock { /** Index complete exchanges without exposing runtime notes as selectable blocks. */ export function indexGoalBlocks(messages: readonly AgentMessage[]): GoalBlock[] { const blocks: GoalBlock[] = []; - let latestUser = -1; - let latestExchange = -1; for (let index = 0; index < messages.length; ) { let message = messages[index]; const boundary = goalNoteDetails(message); @@ -379,10 +377,8 @@ export function indexGoalBlocks(messages: readonly AgentMessage[]): GoalBlock[] index++; } incomplete ||= pending.size > 0; - latestExchange = blocks.length; } else { index++; - if (message.role === "user") latestUser = blocks.length; } blocks.push({ id: boundary?.blockId ?? `b${String(blocks.length + 1).padStart(3, "0")}`, @@ -391,8 +387,6 @@ export function indexGoalBlocks(messages: readonly AgentMessage[]): GoalBlock[] protected: incomplete, }); } - if (latestUser >= 0) blocks[latestUser].protected = true; - if (latestExchange >= 0) blocks[latestExchange].protected = true; return blocks; } diff --git a/packages/coding-agent/test/suite/agent-session-goal-tool-loop.test.ts b/packages/coding-agent/test/suite/agent-session-goal-tool-loop.test.ts index 3eeb2bcb8..1bc203e67 100644 --- a/packages/coding-agent/test/suite/agent-session-goal-tool-loop.test.ts +++ b/packages/coding-agent/test/suite/agent-session-goal-tool-loop.test.ts @@ -96,7 +96,7 @@ it("protects an exchange if an assistant response arrives before its tool result const blocks = indexGoalBlocks(messages); expect(blocks).toHaveLength(3); expect(blocks[1]).toMatchObject({ startIndex: 1, endIndex: 4, protected: true }); - expect(blocks[2]).toMatchObject({ startIndex: 5, endIndex: 6, protected: true }); + expect(blocks[2]).toMatchObject({ startIndex: 5, endIndex: 6, protected: false }); }); it("compacts and recalls an earlier completed tool batch within one user request", async () => { diff --git a/packages/coding-agent/test/suite/goal-block-index.test.ts b/packages/coding-agent/test/suite/goal-block-index.test.ts index 986788c1a..2a8d3d7f5 100644 --- a/packages/coding-agent/test/suite/goal-block-index.test.ts +++ b/packages/coding-agent/test/suite/goal-block-index.test.ts @@ -40,7 +40,7 @@ function history(): AgentMessage[] { } describe("goal block selection contract", () => { - it("indexes complete exchanges as one unit and protects the latest request and incomplete exchange", () => { + it("indexes complete exchanges as one unit and protects only the incomplete exchange", () => { const blocks = indexGoalBlocks(history()); const previous = blocks.find((block) => block.startIndex <= 1 && block.endIndex >= 4); const latestUser = blocks.find((block) => block.startIndex <= 6 && block.endIndex >= 6); @@ -48,7 +48,7 @@ describe("goal block selection contract", () => { expect(previous).toBeDefined(); expect(previous?.protected).toBe(false); expect(previous?.endIndex).toBeLessThan(6); - expect(latestUser?.protected).toBe(true); + expect(latestUser?.protected).toBe(false); expect(unfinished?.protected).toBe(true); expect(blocks.some((block) => block.startIndex === 5 && block.endIndex === 5)).toBe(false); }); @@ -60,7 +60,7 @@ describe("goal block selection contract", () => { const unfinished = blocks.find((block) => block.startIndex <= 7 && block.endIndex >= 7); if (!previous || !latestUser || !unfinished) throw new Error("Missing indexed test blocks"); expect(validateGoalSelection(blocks, previous.id, previous.id)).toBe(true); - expect(validateGoalSelection(blocks, previous.id, latestUser.id)).toBe(false); + expect(validateGoalSelection(blocks, previous.id, latestUser.id)).toBe(true); expect(validateGoalSelection(blocks, previous.id, unfinished.id)).toBe(false); expect(validateGoalSelection(blocks, unfinished.id, unfinished.id)).toBe(false); }); diff --git a/packages/coding-agent/test/suite/goal-complete-context.test.ts b/packages/coding-agent/test/suite/goal-complete-context.test.ts new file mode 100644 index 000000000..7b59f33d1 --- /dev/null +++ b/packages/coding-agent/test/suite/goal-complete-context.test.ts @@ -0,0 +1,70 @@ +import { fauxAssistantMessage, fauxToolCall } from "@diffexai/diffex-ai"; +import { Type } from "typebox"; +import { expect, it } from "vitest"; +import { readGoalArchives } from "../../src/core/pocket-context.ts"; +import { toolResult } from "./goal-harness.ts"; +import { createHarness, getMessageText } from "./harness.ts"; + +it.each(["first user", "latest user", "latest assistant", "latest tool exchange"])( + "compacts the %s block through the tool loop", + async (target) => { + const evidence = `Receipt cobalt-739. ${"evidence ".repeat(12_000)}`; + const harness = await createHarness({ + models: [{ id: "goal", contextWindow: 50_000 }], + settings: { compaction: { enabled: false, reserveTokens: 0 } }, + tools: [ + { + name: "probe", + label: "Probe", + description: "Read evidence", + parameters: Type.Object({}), + async execute() { + return { content: [{ type: "text", text: evidence }], details: {} }; + }, + }, + ], + }); + try { + await harness.session.setGoalModeEnabled(true); + if (target === "latest user" || target === "latest assistant") { + harness.setResponses([fauxAssistantMessage(target === "latest assistant" ? evidence : "Recorded.")]); + await harness.session.prompt("Initial request."); + } + let selected = ""; + harness.setResponses([ + ...(target === "latest tool exchange" + ? [fauxAssistantMessage(fauxToolCall("probe", {}, { id: "probe" }), { stopReason: "toolUse" })] + : []), + (context) => { + const index = context.messages.findIndex((message) => getMessageText(message).includes(evidence)); + expect(index).toBeGreaterThan(0); + selected = getMessageText(context.messages[target === "latest tool exchange" ? index : index - 1]).match( + /\[context (b\d+)\]/, + )![1]; + return fauxAssistantMessage( + fauxToolCall("compact_block", { start_id: selected, end_id: selected }, { id: "compact" }), + { stopReason: "toolUse" }, + ); + }, + (context) => { + expect(context.messages.map(getMessageText).join("\n")).toContain(evidence); + return fauxAssistantMessage("Receipt recorded."); + }, + (context) => { + expect(toolResult(context, "compact").isError).toBe(false); + expect(context.messages.map(getMessageText).join("\n")).not.toContain(evidence); + return fauxAssistantMessage("Done."); + }, + ]); + await harness.session.prompt( + target === "latest assistant" || target === "latest tool exchange" ? "Continue." : evidence, + ); + const archive = readGoalArchives(harness.sessionManager.getBranch()).get("ctx-0001"); + expect(archive?.startId).toBe(selected); + expect(archive?.endId).toBe(selected); + expect(harness.getPendingResponseCount()).toBe(0); + } finally { + await harness.cleanup(); + } + }, +); diff --git a/packages/coding-agent/test/suite/goal-protected-context.test.ts b/packages/coding-agent/test/suite/goal-protected-context.test.ts index 2a2a1a33b..fa2610301 100644 --- a/packages/coding-agent/test/suite/goal-protected-context.test.ts +++ b/packages/coding-agent/test/suite/goal-protected-context.test.ts @@ -9,7 +9,7 @@ afterEach(async () => { while (harnesses.length) await harnesses.pop()?.cleanup(); }); -it.each(["project", "latest user", "incomplete", "latest assistant"])( +it.each(["project", "incomplete"])( "rejects a model attempt to compact %s content without invoking a summarizer", async (target) => { const harness = await createHarness({ @@ -40,9 +40,7 @@ it.each(["project", "latest user", "incomplete", "latest assistant"])( const systemPrompt = harness.session.systemPrompt; const expected = { project: "Project instruction.", - "latest user": "Latest user request.", incomplete: "Incomplete exchange.", - "latest assistant": "Latest assistant exchange.", }[target]!; let error = ""; harness.setResponses([ diff --git a/packages/coding-agent/test/suite/goal-recovery-lifecycle.test.ts b/packages/coding-agent/test/suite/goal-recovery-lifecycle.test.ts index a6a81a426..533edd43d 100644 --- a/packages/coding-agent/test/suite/goal-recovery-lifecycle.test.ts +++ b/packages/coding-agent/test/suite/goal-recovery-lifecycle.test.ts @@ -52,7 +52,7 @@ it("archives and retains whole tool exchanges during emergency recovery inside t fauxAssistantMessage("Investigation completed."), (context) => { expect(context.systemPrompt).toBe(systemPrompt); - expect(context.messages.map(getMessageText)).toContain("Inspect the deployment evidence in two probes."); + expect(context.messages.map(getMessageText)).not.toContain("Inspect the deployment evidence in two probes."); expect(context.messages.map(getMessageText)).toContain("Keep project instructions verbatim."); expect( context.messages.some((message) => message.role === "toolResult" && message.toolCallId === "old-probe"), @@ -76,13 +76,13 @@ it("archives and retains whole tool exchanges during emergency recovery inside t message.content.some((part) => part.type === "toolCall" && part.id === "old-probe"), ), ).toBe(true); - expect(removed.some((message) => message.role === "user")).toBe(false); + expect(removed.some((message) => message.role === "user")).toBe(true); const exported = harness.session.exportToJsonl(join(harness.tempDir, "protected-recovery.jsonl")); for (const manager of [ SessionManager.open(exported), harness.sessionManager.forkBranch(harness.sessionManager.getLeafId()!), ]) { - expect(manager.buildSessionContext().messages.map(getMessageText)).toContain( + expect(manager.buildSessionContext().messages.map(getMessageText)).not.toContain( "Inspect the deployment evidence in two probes.", ); expect(manager.buildSessionContext().messages.map(getMessageText)).toContain( diff --git a/packages/coding-agent/test/suite/goal-remaining-issues.test.ts b/packages/coding-agent/test/suite/goal-remaining-issues.test.ts index 3d42a69ef..8a9a97a18 100644 --- a/packages/coding-agent/test/suite/goal-remaining-issues.test.ts +++ b/packages/coding-agent/test/suite/goal-remaining-issues.test.ts @@ -188,6 +188,11 @@ it.each([ harnesses.push(harness); harness.setResponses([fauxAssistantMessage("Recorded.")]); await harness.session.prompt("record ".repeat(8_000)); + await harness.session.sendCustomMessage({ + customType: "project-rule", + content: "Protected project instruction.", + display: false, + }); await harness.session.setGoalModeEnabled(true); let error = ""; let receipt = ""; @@ -195,7 +200,7 @@ it.each([ (context) => { const oldest = liveIds(context)[0]; const requestIndex = context.messages.findIndex( - (message) => getMessageText(message) === "Compact completed work.", + (message) => getMessageText(message) === "Protected project instruction.", ); const protectedId = getMessageText(context.messages[requestIndex - 1]).match(/\[context (b\d+)\]/)![1]; const args = diff --git a/packages/coding-agent/test/suite/goal-stress.test.ts b/packages/coding-agent/test/suite/goal-stress.test.ts index b4516a27f..46186107d 100644 --- a/packages/coding-agent/test/suite/goal-stress.test.ts +++ b/packages/coding-agent/test/suite/goal-stress.test.ts @@ -151,7 +151,7 @@ it.each([17, 739, 918])( 60_000, ); -it("fails closed on an oversized latest exchange, preserves evidence, and recovers with a larger model", async () => { +it("preserves evidence after failed oversized recovery and recovers with a larger model", async () => { const harness = await createHarness({ models: [{ id: "stress", contextWindow: 32_000, maxTokens: 8_000 }], settings: { compaction: { enabled: false, reserveTokens: 0, keepRecentTokens: 1 } }, @@ -179,11 +179,12 @@ it("fails closed on an oversized latest exchange, preserves evidence, and recove await harness.session.setGoalModeEnabled(true); harness.setResponses([ fauxAssistantMessage(fauxToolCall("probe", {}, { id: "oversized" }), { stopReason: "toolUse" }), + fauxAssistantMessage("", { stopReason: "length" }), ]); await harness.session.prompt("Inspect the evidence and determine the deployment decision."); expect(harness.getPendingResponseCount()).toBe(0); expect(harness.session.state.errorMessage).toContain("Emergency context compaction failed"); - expect(harness.faux.state.callCount).toBe(1); + expect(harness.faux.state.callCount).toBe(2); expect(readGoalArchives(harness.sessionManager.getBranch()).size).toBe(0); expect(harness.session.messages.map(getMessageText)).toContain( "Inspect the evidence and determine the deployment decision.",