diff --git a/packages/coding-agent/src/core/goal-runtime.ts b/packages/coding-agent/src/core/goal-runtime.ts index b4c727516..17fe21eaf 100644 --- a/packages/coding-agent/src/core/goal-runtime.ts +++ b/packages/coding-agent/src/core/goal-runtime.ts @@ -19,6 +19,7 @@ import { GoalBlockIdRegistry, type GoalBudget, goalArchiveFromEntry, + goalCompactionReceipt, goalNoteDetails, goalSelectionError, goalSelectionGuidance, @@ -44,6 +45,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.", + "Visible b... IDs follow current context order and are reassigned after each compact_block call. Use current block headers; IDs in past tool calls are stale. Archive IDs (ctx-...) are guaranteed to stay unchanged for recall.", "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.", @@ -264,7 +266,8 @@ export class GoalRuntime { private index(messages: AgentMessage[], allocate = true): GoalBlock[] { // Count occurrences in the durable branch, including compacted history. - // Identical messages must remain distinct without renumbering survivors. + // Keep identical stored messages distinct; only the returned public selectors + // are numbered by their position in this request. const occurrences = new Map(); const stored = new Map(); for (const entry of this.host.manager.getBranch()) { @@ -282,19 +285,21 @@ export class GoalRuntime { identities.push(stored.get(id) ?? `${fingerprint}:${id}`); visible.set(fingerprint, identities); } - return this.blockIds.index( - messages, - messages.map((message) => { - const fingerprint = messageFingerprint(message); - const identity = visible.get(fingerprint)?.shift(); - if (identity) return identity; - // A new user message may be labeled before its entry is appended. - const occurrence = occurrences.get(fingerprint) ?? 0; - occurrences.set(fingerprint, occurrence + 1); - return `${fingerprint}:${occurrence}`; - }), - allocate, - ); + return this.blockIds + .index( + messages, + messages.map((message) => { + const fingerprint = messageFingerprint(message); + const identity = visible.get(fingerprint)?.shift(); + if (identity) return identity; + // A new user message may be labeled before its entry is appended. + const occurrence = occurrences.get(fingerprint) ?? 0; + occurrences.set(fingerprint, occurrence + 1); + return `${fingerprint}:${occurrence}`; + }), + allocate, + ) + .map((block, index) => ({ ...block, id: `b${String(index + 1).padStart(3, "0")}` })); } /** Add provider-compatible boundaries without changing any native assistant content. */ @@ -317,7 +322,13 @@ export class GoalRuntime { if (id && message.role === "toolResult") return [{ ...message, content: [{ type: "text", text: `[context ${id}]\n` }, ...message.content] }]; const block = starts.get(index); - if (!block || goalNoteDetails(message)) return [message]; + if (goalNoteDetails(message) && message.role === "custom" && typeof message.content === "string") { + // Rewrite only runtime-owned headers in the provider projection. Stored + // boundaries, signed assistant content, and historical tool arguments stay intact. + const visibleId = block?.id ?? `b${String(blocks.length + 1).padStart(3, "0")}`; + return [{ ...message, content: message.content.replace(/\[context b\d+\]$/, `[context ${visibleId}]`) }]; + } + if (!block) return [message]; return [ { role: "custom", @@ -338,6 +349,7 @@ export class GoalRuntime { this.blockIds.restore(this.host.manager.getEntries().flatMap(sessionEntryToContextMessages)); const blocks = this.index(messages); const details = this.blockIds.reserve(); + const visibleId = `b${String(blocks.length + 1).padStart(3, "0")}`; const key = this.requestKey(model); const lastAssistant = [...messages].reverse().find((message) => message.role === "assistant"); const includeUsage = @@ -371,9 +383,7 @@ export class GoalRuntime { (budget.nudge !== undefined && budget.nudge !== previousBudget.nudge && budget.usage > previousBudget.usage); - return reportUsage - ? `${formatGoalUsageNote(budget)}\n[context ${details.blockId}]` - : `[context ${details.blockId}]`; + return reportUsage ? `${formatGoalUsageNote(budget)}\n[context ${visibleId}]` : `[context ${visibleId}]`; }; let budget = calculateGoalBudget(budgetInput); let content = formatNote(budget); @@ -434,7 +444,7 @@ export class GoalRuntime { if (existing.startId !== startId || existing.endId !== endId) { throw new Error("This tool-call ID was already committed with different arguments."); } - return { content: [{ type: "text" as const, text: `Compacted into ${existing.archiveId}.` }], details: {} }; + return { content: [{ type: "text" as const, text: goalCompactionReceipt(existing.archiveId) }], details: {} }; } const snapshot = this.snapshot; if (!snapshot) throw new Error("No current goal context snapshot is available."); @@ -514,7 +524,7 @@ export class GoalRuntime { role: "toolResult", toolCallId: callId, toolName: "compact_block", - content: [{ type: "text", text: `Compacted into ${archiveId}.` }], + content: [{ type: "text", text: goalCompactionReceipt(archiveId) }], isError: false, timestamp: Date.now(), }; @@ -545,7 +555,7 @@ export class GoalRuntime { this.rebuildRequired = true; this.invalidateUsage(); return { - content: [{ type: "text" as const, text: `Compacted into ${archiveId}.` }], + content: [{ type: "text" as const, text: goalCompactionReceipt(archiveId) }], details: {}, usage: response.usage, }; diff --git a/packages/coding-agent/src/core/pocket-context.ts b/packages/coding-agent/src/core/pocket-context.ts index 4198f0d57..8dc769d18 100644 --- a/packages/coding-agent/src/core/pocket-context.ts +++ b/packages/coding-agent/src/core/pocket-context.ts @@ -53,6 +53,11 @@ export interface GoalArchive { usage?: Usage; } +/** Identical guidance for successful execution, idempotent retries, and crash replay. */ +export function goalCompactionReceipt(archiveId: string): string { + return `Compacted into ${archiveId}. Visible block IDs reassigned; use current block headers. IDs in past tool calls are stale. Archive IDs are unchanged.`; +} + function isStringArray(value: unknown): value is string[] { return Array.isArray(value) && value.every((item) => typeof item === "string" && item.length > 0); } @@ -255,7 +260,7 @@ export function projectGoalEntries(path: readonly SessionEntry[]): SessionEntry[ role: "toolResult", toolCallId: archive.toolCallId, toolName: "compact_block", - content: [{ type: "text", text: `Compacted into ${archive.archiveId}.` }], + content: [{ type: "text", text: goalCompactionReceipt(archive.archiveId) }], isError: false, timestamp: new Date(entry.timestamp).getTime(), }, @@ -390,7 +395,7 @@ export function indexGoalBlocks(messages: readonly AgentMessage[]): GoalBlock[] return blocks; } -/** Retain public IDs while the underlying stored message identities remain unchanged. */ +/** Retain internal identities independently of the positional selectors shown to the model. */ export class GoalBlockIdRegistry { private readonly ids = new Map(); private nextId = 1; diff --git a/packages/coding-agent/test/suite/agent-session-goal.test.ts b/packages/coding-agent/test/suite/agent-session-goal.test.ts index 1f6b1f0f5..c90281c8a 100644 --- a/packages/coding-agent/test/suite/agent-session-goal.test.ts +++ b/packages/coding-agent/test/suite/agent-session-goal.test.ts @@ -462,7 +462,7 @@ describe("experimental goal mode", () => { expect(requests[1].messages.map(getMessageText).join("\n")).not.toContain("Context usage: approximately"); expect(archiveId).toMatch(/^ctx-\d+$/); expect(resultFor(requests[2].messages, "compact")).toMatch( - new RegExp(`^\\[context b\\d+\\]\\s+Compacted into ${archiveId}\\.$`), + new RegExp(`^\\[context b\\d+\\]\\s+Compacted into ${archiveId}\\. Visible block IDs reassigned;`), ); expect(requests[2].messages.map(getMessageText).join("\n")).toContain( "Decision: the release remains blocked by the blue gate.", diff --git a/packages/coding-agent/test/suite/goal-context-markers.test.ts b/packages/coding-agent/test/suite/goal-context-markers.test.ts index da79677ff..9e56ea9ae 100644 --- a/packages/coding-agent/test/suite/goal-context-markers.test.ts +++ b/packages/coding-agent/test/suite/goal-context-markers.test.ts @@ -7,7 +7,7 @@ import { createTestResourceLoader } from "../extension-test-utils.ts"; import { goalNotes, toolResult } from "./goal-harness.ts"; import { createHarness, getMessageText } from "./harness.ts"; -it("assigns distinct stable markers to identical stored messages", async () => { +it("assigns distinct positional markers to identical stored messages after compaction", async () => { const manager = SessionManager.inMemory(); const userIds: string[] = []; for (const answer of ["First observation", "Second observation"]) { @@ -33,14 +33,16 @@ it("assigns distinct stable markers to identical stored messages", async () => { expect(new Set(markers).size).toBe(markers.length); manager.appendCompaction("Earlier observation archived.", userIds[1], 1_000); harness.session.agent.state.messages = manager.buildSessionContext().messages; + let retainedMarker = ""; harness.setResponses([ (context) => { const retained = context.messages.findIndex((message) => getMessageText(message) === "Inspect again"); - expect(getMessageText(context.messages[retained - 1])).toBe(`[context ${markers[2]}]`); + retainedMarker = getMessageText(context.messages[retained - 1]); return fauxAssistantMessage("Continued"); }, ]); await harness.session.prompt("Continue after compaction"); + expect(retainedMarker).toBe("[context b002]"); } finally { await harness.cleanup(); } 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 8a9a97a18..2e8699a7a 100644 --- a/packages/coding-agent/test/suite/goal-remaining-issues.test.ts +++ b/packages/coding-agent/test/suite/goal-remaining-issues.test.ts @@ -42,7 +42,7 @@ it("rejects a recovery suggestion after a concurrent context change", async () = expect(readGoalArchives(harness.sessionManager.getBranch()).size).toBe(0); }); -it("rejects removed block IDs after a successful compaction instead of selecting replacement content", async () => { +it("selects replacement content using its reassigned header after compaction", async () => { const harness = await createHarness({ models: [{ id: "goal", contextWindow: 50_000 }], settings: { compaction: { enabled: false, reserveTokens: 0 } }, @@ -53,7 +53,8 @@ it("rejects removed block IDs after a successful compaction instead of selecting await harness.session.prompt("current ".repeat(10_000)); await harness.session.setGoalModeEnabled(true); let oldId = ""; - let error = ""; + let receipt = ""; + let replacementId = ""; harness.setResponses([ (context) => { oldId = liveIds(context)[0]; @@ -62,22 +63,26 @@ it("rejects removed block IDs after a successful compaction instead of selecting { stopReason: "toolUse" }, ); }, - fauxAssistantMessage("Earlier observations completed."), + fauxAssistantMessage(`Earlier observations completed. ${"record ".repeat(2_000)}`), (context) => { expect(toolResult(context, "compact").isError).toBe(false); + const index = context.messages.findIndex((message) => getMessageText(message).startsWith("[ctx-0001]")); + replacementId = getMessageText(context.messages[index - 1]).match(/\[context (b\d+)\]/)![1]; return fauxAssistantMessage( - fauxToolCall("compact_block", { start_id: oldId, end_id: oldId }, { id: "stale" }), + fauxToolCall("compact_block", { start_id: replacementId, end_id: replacementId }, { id: "replacement" }), { stopReason: "toolUse" }, ); }, + fauxAssistantMessage("Earlier observations completed."), (context) => { - error = getMessageText(toolResult(context, "stale")); + receipt = getMessageText(toolResult(context, "replacement")); return fauxAssistantMessage("Received."); }, ]); await harness.session.prompt("Compact earlier observations."); - expect(error).toContain(`Unknown or stale block ID: ${oldId}`); - expect(readGoalArchives(harness.sessionManager.getBranch()).size).toBe(1); + expect(replacementId).toBe(oldId); + expect(receipt).toContain("Compacted into ctx-0002"); + expect(readGoalArchives(harness.sessionManager.getBranch()).get("ctx-0002")?.references).toEqual(["ctx-0001"]); }); afterEach(async () => { diff --git a/packages/coding-agent/test/suite/goal-renumbering.test.ts b/packages/coding-agent/test/suite/goal-renumbering.test.ts new file mode 100644 index 000000000..afd923372 --- /dev/null +++ b/packages/coding-agent/test/suite/goal-renumbering.test.ts @@ -0,0 +1,140 @@ +import { join } from "node:path"; +import { type Context, fauxAssistantMessage, fauxToolCall } from "@diffexai/diffex-ai"; +import { expect, it } from "vitest"; +import { readGoalArchives, validateGoalProvenance } from "../../src/core/pocket-context.ts"; +import { SessionManager } from "../../src/core/session-manager.ts"; +import { toolResult } from "./goal-harness.ts"; +import { createHarness, getMessageText } from "./harness.ts"; + +function marker(context: Context, text: string): string { + const index = context.messages.findIndex((message) => getMessageText(message) === text); + expect(index).toBeGreaterThan(0); + return getMessageText(context.messages[index - 1]).match(/\[context (b\d+)\]/)![1]; +} + +function markers(context: Context): string[] { + return context.messages.flatMap((message) => { + if (message.role !== "user") return []; + const id = getMessageText(message).match(/\[context (b\d+)\]$/)?.[1]; + return id ? [id] : []; + }); +} + +it("renumbers visible blocks after nested compaction and recalls stable archives after restart and fork", async () => { + const manager = SessionManager.inMemory(); + const first = `Receipt cobalt739: owner Mira. ${"record ".repeat(6_000)}`; + const second = `Receipt emerald840: owner Arun. ${"evidence ".repeat(12_000)}`; + for (const text of ["Unchanged prefix.", first, second]) { + manager.appendMessage({ role: "user", content: text, timestamp: Date.now() }); + manager.appendMessage(fauxAssistantMessage("Recorded.")); + } + const harness = await createHarness({ + sessionManager: manager, + models: [{ id: "goal", contextWindow: 60_000, maxTokens: 1_024 }], + settings: { compaction: { enabled: false, reserveTokens: 0 } }, + }); + try { + harness.session.agent.state.messages = manager.buildSessionContext().messages; + await harness.session.setGoalModeEnabled(true); + const requests: Context[] = []; + const calls: Array<{ start_id: string; end_id: string }> = []; + harness.setResponses([ + (context) => { + requests.push(structuredClone(context)); + calls.push({ start_id: marker(context, first), end_id: "b004" }); + return fauxAssistantMessage(fauxToolCall("compact_block", calls[0], { id: "first" }), { + stopReason: "toolUse", + }); + }, + fauxAssistantMessage("First receipt recorded."), + (context) => { + requests.push(structuredClone(context)); + calls.push({ + start_id: marker(context, "[ctx-0001] Assistant-generated decision record:\nFirst receipt recorded."), + end_id: marker(context, second), + }); + return fauxAssistantMessage(fauxToolCall("compact_block", calls[1], { id: "second" }), { + stopReason: "toolUse", + }); + }, + fauxAssistantMessage("Both receipts recorded."), + (context) => { + requests.push(structuredClone(context)); + return fauxAssistantMessage("Done."); + }, + ]); + await harness.session.prompt("Compact both investigations in sequence."); + expect(requests).toHaveLength(3); + expect(harness.getPendingResponseCount()).toBe(0); + for (const context of requests) { + const ids = markers(context); + expect(ids).toEqual(ids.map((_, index) => `b${String(index + 1).padStart(3, "0")}`)); + expect(context.messages.slice(0, 4)).toEqual(requests[0].messages.slice(0, 4)); + } + expect(marker(requests[0], second)).toBe("b005"); + expect(marker(requests[1], second)).toBe("b004"); + for (const callId of ["first", "second"]) { + const result = toolResult(requests[2], callId); + expect(result.isError).toBe(false); + expect(getMessageText(result)).toContain("Visible block IDs reassigned"); + expect(getMessageText(result)).toContain("past tool calls are stale"); + } + const persistedCalls = requests[2].messages.flatMap((message) => + message.role === "assistant" ? message.content.filter((part) => part.type === "toolCall") : [], + ); + expect(persistedCalls.map((call) => call.arguments)).toEqual(calls); + const archives = readGoalArchives(manager.getBranch()); + expect([...archives.keys()]).toEqual(["ctx-0001", "ctx-0002"]); + expect(archives.get("ctx-0002")?.references).toEqual(["ctx-0001"]); + validateGoalProvenance(manager.getBranch(), archives); + const exported = harness.session.exportToJsonl(join(harness.tempDir, "renumbered.jsonl")); + for (const restoredManager of [SessionManager.open(exported), manager.forkBranch(manager.getLeafId()!)]) { + const restored = await createHarness({ sessionManager: restoredManager }); + try { + restored.session.agent.state.messages = restoredManager.buildSessionContext().messages; + for (const [archiveId, query, fact] of [ + ["ctx-0001", "cobalt739", "owner Mira"], + ["ctx-0002", "cobalt739", "owner Mira"], + ["ctx-0002", "emerald840", "owner Arun"], + ]) { + const callId = `${archiveId}-${query}`; + let expanded = ""; + let answer = ""; + restored.setResponses([ + fauxAssistantMessage(fauxToolCall("query_block", { archive_id: archiveId, query }, { id: callId }), { + stopReason: "toolUse", + }), + (context) => { + const initial = context.messages + .map(getMessageText) + .join("\n") + .split("Initial search results: ")[1] + .split("\n")[0]; + const hit = (JSON.parse(initial) as { hits: Array<{ chunk_id: string }> }).hits[0]; + return fauxAssistantMessage( + fauxToolCall("expand_block", { block_id: hit.chunk_id }, { id: "expand" }), + { stopReason: "toolUse" }, + ); + }, + (context) => { + expanded = getMessageText(toolResult(context, "expand")); + return fauxAssistantMessage(`${fact} [${archiveId}]`); + }, + (context) => { + answer = getMessageText(toolResult(context, callId)); + return fauxAssistantMessage("Recalled."); + }, + ]); + await restored.session.prompt(`Recall ${query}.`); + expect(expanded).toContain(fact); + expect(answer).toContain(`${fact} [${archiveId}]`); + expect(restored.getPendingResponseCount()).toBe(0); + } + } finally { + await restored.cleanup(); + } + } + } finally { + await harness.cleanup(); + } +}); diff --git a/packages/coding-agent/test/suite/pocket-context-projection.test.ts b/packages/coding-agent/test/suite/pocket-context-projection.test.ts index ddbf87acd..1194d9ad6 100644 --- a/packages/coding-agent/test/suite/pocket-context-projection.test.ts +++ b/packages/coding-agent/test/suite/pocket-context-projection.test.ts @@ -56,6 +56,7 @@ describe("Pocket Context projection", () => { const resumed = buildSessionContext(entries).messages; expect(JSON.stringify(resumed)).not.toContain("Original evidence"); expect(JSON.stringify(resumed)).toContain("Compacted into ctx-0001."); + expect(JSON.stringify(resumed)).toContain("Visible block IDs reassigned; use current block headers."); const compacted = projectGoalEntries([ ...entries, { @@ -110,7 +111,7 @@ describe("Pocket Context projection", () => { ).toThrow(/cycle/i); }); - it("keeps live block IDs stable across removal of earlier blocks", () => { + it("keeps internal block identities stable across removal of earlier blocks", () => { const registry = new GoalBlockIdRegistry(); const messages: AgentMessage[] = [ { role: "user", content: "First", timestamp: 1 },