Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { completeSummarization, estimateTokens } from "./compaction.ts";
import {
computeFileLists,
createFileOps,
excludePlanItems,
extractFileOpsFromMessage,
type FileOperations,
formatFileOperations,
Expand Down Expand Up @@ -220,7 +221,8 @@ export function prepareBranchEntries(entries: SessionEntry[], tokenBudget: numbe
// Second pass: walk from newest to oldest, adding messages until token budget
for (let i = entries.length - 1; i >= 0; i--) {
const entry = entries[i];
const message = getMessageFromEntry(entry);
const rawMessage = getMessageFromEntry(entry);
const message = rawMessage ? excludePlanItems([rawMessage])[0] : undefined;
if (!message) continue;

// Extract file ops from assistant messages (tool calls)
Expand Down
15 changes: 9 additions & 6 deletions packages/coding-agent/src/core/compaction/compaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { contentText, type RetryCallbacks, type RetryPolicy, retryAssistantCall,
import type { AssistantMessage, Context, Model, SimpleStreamOptions, Usage } from "@diffexai/diffex-ai/compat";
import { completeSimple } from "@diffexai/diffex-ai/compat";
import { convertToLlm } from "../messages.ts";
import { stripCurrentPlanSnapshot } from "../plan-snapshot.ts";
import {
buildSessionContext,
type CompactionEntry,
Expand All @@ -27,6 +28,7 @@ import {
import {
computeFileLists,
createFileOps,
excludePlanItems,
extractFileOpsFromMessage,
type FileOperations,
formatFileOperations,
Expand Down Expand Up @@ -692,14 +694,15 @@ export async function generateSummaryWithUsage(
);

// Use update prompt if we have a previous summary, otherwise initial prompt
previousSummary = previousSummary === undefined ? undefined : stripCurrentPlanSnapshot(previousSummary);
let basePrompt = previousSummary ? UPDATE_SUMMARIZATION_PROMPT : SUMMARIZATION_PROMPT;
if (customInstructions) {
basePrompt = `${basePrompt}\n\nAdditional focus: ${customInstructions}`;
}

// Serialize conversation to text so model doesn't try to continue it
// Convert to LLM messages first (handles custom types like bashExecution, custom, etc.)
const llmMessages = convertToLlm(currentMessages);
const llmMessages = convertToLlm(excludePlanItems(currentMessages));
const conversationText = serializeConversation(llmMessages);

// Build the prompt with conversation wrapped in tags
Expand Down Expand Up @@ -788,7 +791,7 @@ export function prepareCompaction(
let boundaryStart = 0;
if (prevCompactionIndex >= 0) {
const prevCompaction = pathEntries[prevCompactionIndex] as CompactionEntry;
previousSummary = prevCompaction.summary;
previousSummary = stripCurrentPlanSnapshot(prevCompaction.summary);
const firstKeptEntryIndex = pathEntries.findIndex((entry) => entry.id === prevCompaction.firstKeptEntryId);
boundaryStart = firstKeptEntryIndex >= 0 ? firstKeptEntryIndex : prevCompactionIndex + 1;
}
Expand Down Expand Up @@ -839,8 +842,8 @@ export function prepareCompaction(

return {
firstKeptEntryId,
messagesToSummarize,
turnPrefixMessages,
messagesToSummarize: excludePlanItems(messagesToSummarize),
turnPrefixMessages: excludePlanItems(turnPrefixMessages),
isSplitTurn: cutPoint.isSplitTurn,
tokensBefore,
previousSummary,
Expand Down Expand Up @@ -905,7 +908,7 @@ export async function compact(
let summaryUsage: Usage;

if (isSplitTurn && turnPrefixMessages.length > 0) {
let historyText = "No prior history.";
let historyText = previousSummary ?? "No prior history.";
let historyUsage: Usage | undefined;
if (messagesToSummarize.length > 0) {
const historyResult = await generateSummaryWithUsage(
Expand Down Expand Up @@ -1004,7 +1007,7 @@ async function generateTurnPrefixSummary(
Math.floor(0.5 * reserveTokens),
model.maxTokens > 0 ? model.maxTokens : Number.POSITIVE_INFINITY,
); // Smaller budget for turn prefix
const llmMessages = convertToLlm(messages);
const llmMessages = convertToLlm(excludePlanItems(messages));
const conversationText = serializeConversation(llmMessages);
const promptText = `<conversation>\n${conversationText}\n</conversation>\n\n${TURN_PREFIX_SUMMARIZATION_PROMPT}`;
const summarizationMessages = [
Expand Down
19 changes: 19 additions & 0 deletions packages/coding-agent/src/core/compaction/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import type { AgentMessage } from "@diffexai/diffex-agent-core";
import { contentText, type Message } from "@diffexai/diffex-ai";
import { stripCurrentPlanSnapshot } from "../plan-snapshot.ts";

// ============================================================================
// File Operation Tracking
Expand Down Expand Up @@ -85,6 +86,21 @@ export function formatFileOperations(readFiles: string[], modifiedFiles: string[
// Message Serialization
// ============================================================================

/** Exclude plan data from a context projection without changing persisted messages. */
export function excludePlanItems(messages: AgentMessage[]): AgentMessage[] {
return messages.flatMap((message): AgentMessage[] => {
if (message.role === "toolResult" && message.toolName === "update_plan") return [];
if (message.role === "assistant") {
const content = message.content.filter((block) => block.type !== "toolCall" || block.name !== "update_plan");
return content.length > 0 ? [{ ...message, content }] : [];
}
if (message.role === "compactionSummary" || message.role === "branchSummary") {
return [{ ...message, summary: stripCurrentPlanSnapshot(message.summary) }];
}
return [message];
});
}

/** Maximum characters for a tool result in serialized summaries. */
const TOOL_RESULT_MAX_CHARS = 2000;

Expand All @@ -105,6 +121,7 @@ function truncateForSummary(text: string, maxChars: number): string {
*
* Tool results are truncated to keep the summarization request within
* reasonable token budgets. Full content is not needed for summarization.
* Plan calls and results are omitted because the latest plan is attached separately.
*/
export function serializeConversation(messages: Message[]): string {
const parts: string[] = [];
Expand All @@ -121,6 +138,7 @@ export function serializeConversation(messages: Message[]): string {
if (block.type === "thinking") {
thinkingParts.push(block.thinking);
} else if (block.type === "toolCall") {
if (block.name === "update_plan") continue;
const args = block.arguments as Record<string, unknown>;
const argsStr = Object.entries(args)
.map(([k, v]) => `${k}=${JSON.stringify(v)}`)
Expand All @@ -139,6 +157,7 @@ export function serializeConversation(messages: Message[]): string {
parts.push(`[Assistant tool calls]: ${toolCalls.join("; ")}`);
}
} else if (msg.role === "toolResult") {
if (msg.toolName === "update_plan") continue;
const content = contentText(msg.content, "");
if (content) {
parts.push(`[Tool result]: ${truncateForSummary(content, TOOL_RESULT_MAX_CHARS)}`);
Expand Down
3 changes: 2 additions & 1 deletion packages/coding-agent/src/core/current-plan-state.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { CURRENT_PLAN_SNAPSHOT_PREFIX } from "./plan-snapshot.ts";
import type { SessionManager } from "./session-manager.ts";
import {
createUpdatePlanToolDefinition,
Expand Down Expand Up @@ -57,7 +58,7 @@ export function formatCurrentPlanSnapshot(
: state.status === "inactive" && "plan" in state.operation
? state.operation.plan
: [];
return `Current task context (latest plan):\n${JSON.stringify({ plan }, null, 2)}`;
return `${CURRENT_PLAN_SNAPSHOT_PREFIX}${JSON.stringify({ plan }, null, 2)}`;
}

export function createSessionUpdatePlanToolDefinition(
Expand Down
25 changes: 25 additions & 0 deletions packages/coding-agent/src/core/plan-snapshot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
export const CURRENT_PLAN_SNAPSHOT_PREFIX = "Current task context (latest plan):\n";

// Branch navigation attaches this authority paragraph with the snapshot.
const BRANCH_PLAN_AUTHORITY =
"The plan below is authoritative for the selected conversation branch. Plan references in the exploration summary above are historical.\n";

/** Remove only the structured plan snapshot appended to a summary. */
export function stripCurrentPlanSnapshot(summary: string): string {
const index = summary.lastIndexOf(CURRENT_PLAN_SNAPSHOT_PREFIX);
const authorityStart = index - BRANCH_PLAN_AUTHORITY.length;
const hasBranchAuthority = authorityStart >= 0 && summary.slice(authorityStart, index) === BRANCH_PLAN_AUTHORITY;
const attachmentStart = hasBranchAuthority ? authorityStart : index;
if (index < 0 || (attachmentStart > 0 && summary.slice(attachmentStart - 2, attachmentStart) !== "\n\n")) {
return summary;
}
try {
const snapshot: unknown = JSON.parse(summary.slice(index + CURRENT_PLAN_SNAPSHOT_PREFIX.length));
if (typeof snapshot !== "object" || snapshot === null || !("plan" in snapshot) || !Array.isArray(snapshot.plan)) {
return summary;
}
} catch {
return summary;
}
return summary.slice(0, attachmentStart === 0 ? 0 : attachmentStart - 2);
}
24 changes: 23 additions & 1 deletion packages/coding-agent/src/core/session-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,15 @@ import { createInterface } from "readline";
import { StringDecoder } from "string_decoder";
import { getAgentDir as getDefaultAgentDir, getSessionsDir } from "../config.ts";
import { normalizePath, resolvePath } from "../utils/paths.ts";
import { excludePlanItems } from "./compaction/utils.ts";
import {
type BashExecutionMessage,
type CustomMessage,
createBranchSummaryMessage,
createCompactionSummaryMessage,
createCustomMessage,
} from "./messages.ts";
import { stripCurrentPlanSnapshot } from "./plan-snapshot.ts";

export const CURRENT_SESSION_VERSION = 3;

Expand Down Expand Up @@ -497,7 +499,27 @@ export function buildSessionContext(
): SessionContext {
const path = buildSessionPath(entries, leafId, byId);
const { thinkingLevel, model } = getSessionContextSettings(path);
const messages = buildContextEntries(entries, leafId, byId).flatMap(sessionEntryToContextMessages);
const contextEntries = buildContextEntries(entries, leafId, byId);
const contextEntryIds = new Set(contextEntries.map((entry) => entry.id));
// Context order puts the latest compaction before its retained tail. Use path
// order to find the snapshot that supersedes earlier plan calls and summaries.
let snapshotIndex = -1;
for (let i = path.length - 1; i >= 0; i--) {
const entry = path[i];
if (
contextEntryIds.has(entry.id) &&
(entry.type === "compaction" || entry.type === "branch_summary") &&
stripCurrentPlanSnapshot(entry.summary) !== entry.summary
) {
snapshotIndex = i;
break;
}
}
const supersededEntryIds = new Set(path.slice(0, Math.max(0, snapshotIndex)).map((entry) => entry.id));
const messages = contextEntries.flatMap((entry) => {
const projected = sessionEntryToContextMessages(entry);
return supersededEntryIds.has(entry.id) ? excludePlanItems(projected) : projected;
});
return { messages, thinkingLevel, model };
}

Expand Down
65 changes: 63 additions & 2 deletions packages/coding-agent/test/compaction-serialization.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,69 @@
import type { Message } from "@diffexai/diffex-ai";
import type { AgentMessage } from "@diffexai/diffex-agent-core";
import { fauxAssistantMessage, fauxToolCall, type Message } from "@diffexai/diffex-ai";
import { describe, expect, it } from "vitest";
import { serializeConversation } from "../src/core/compaction/utils.ts";
import { excludePlanItems, serializeConversation } from "../src/core/compaction/utils.ts";

describe("excludePlanItems", () => {
it("strips snapshots from summary messages and preserves user content without mutation", () => {
const snapshot = 'Current task context (latest plan):\n{"plan":[]}';
const user: AgentMessage = { role: "user", content: snapshot, timestamp: 1 };
const messages: AgentMessage[] = [
{ role: "compactionSummary", summary: `Compaction evidence\n\n${snapshot}`, tokensBefore: 100, timestamp: 1 },
{ role: "branchSummary", summary: `Branch evidence\n\n${snapshot}`, fromId: "branch", timestamp: 1 },
user,
fauxAssistantMessage(fauxToolCall("update_plan", { clear: true })),
];

const result = excludePlanItems(messages);

expect(result).toEqual([
{ ...messages[0], summary: "Compaction evidence" },
{ ...messages[1], summary: "Branch evidence" },
user,
]);
expect(messages[0]).toHaveProperty("summary", `Compaction evidence\n\n${snapshot}`);
expect(messages[1]).toHaveProperty("summary", `Branch evidence\n\n${snapshot}`);
});
});

describe("serializeConversation", () => {
it("omits plan calls and results while preserving other content in the same message", () => {
const messages: Message[] = [
fauxAssistantMessage([
{ type: "text", text: "Implementation findings" },
{ type: "thinking", thinking: "Reasoning about the implementation" },
fauxToolCall("update_plan", { clear: true, explanation: "Plan-only explanation" }),
fauxToolCall("read", { path: "src/example.ts" }),
]),
{
role: "toolResult",
toolCallId: "plan-result",
toolName: "update_plan",
content: [{ type: "text", text: "Plan cleared." }],
isError: false,
timestamp: Date.now(),
},
{
role: "toolResult",
toolCallId: "read-result",
toolName: "read",
content: [{ type: "text", text: "File contents" }],
isError: false,
timestamp: Date.now(),
},
];

const result = serializeConversation(messages);

expect(result).toContain("Implementation findings");
expect(result).toContain("Reasoning about the implementation");
expect(result).toContain('read(path="src/example.ts")');
expect(result).toContain("[Tool result]: File contents");
expect(result).not.toContain("update_plan");
expect(result).not.toContain("Plan-only explanation");
expect(result).not.toContain("Plan cleared.");
});

it("should truncate long tool results", () => {
const longContent = "x".repeat(5000);
const messages: Message[] = [
Expand Down
38 changes: 38 additions & 0 deletions packages/coding-agent/test/plan-snapshot.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import { CURRENT_PLAN_SNAPSHOT_PREFIX, stripCurrentPlanSnapshot } from "../src/core/plan-snapshot.ts";

describe("stripCurrentPlanSnapshot", () => {
it.each([
{ plan: [] },
{ plan: [{ id: "active", step: "Active", status: "in_progress", success_criteria: ["Retain criteria"] }] },
{ plan: [{ id: "finished", step: "Finished", status: "completed" }] },
])("removes the attached snapshot while preserving narrative and file metadata", ({ plan }) => {
const narrative = "## Goal\nContinue work\n\n<modified-files>\nsrc/example.ts\n</modified-files>";
const snapshot = `${CURRENT_PLAN_SNAPSHOT_PREFIX}${JSON.stringify({ plan }, null, 2)}`;

expect(stripCurrentPlanSnapshot(`${narrative}\n\n${snapshot}`)).toBe(narrative);
expect(stripCurrentPlanSnapshot(snapshot)).toBe("");
});

it("removes the authority paragraph and snapshot attached by branch navigation", () => {
const narrative = "Branch narrative\n\n<modified-files>\nsrc/example.ts\n</modified-files>";
const attachment =
'The plan below is authoritative for the selected conversation branch. Plan references in the exploration summary above are historical.\nCurrent task context (latest plan):\n{"plan":[]}';

expect(stripCurrentPlanSnapshot(`${narrative}\n\n${attachment}`)).toBe(narrative);
expect(stripCurrentPlanSnapshot(attachment)).toBe("");
expect(stripCurrentPlanSnapshot(`${narrative}\n\n${attachment}\nMore narrative`)).toBe(
`${narrative}\n\n${attachment}\nMore narrative`,
);
});

it.each([
"Narrative without a plan",
`Mention of ${CURRENT_PLAN_SNAPSHOT_PREFIX}{"plan": []}`,
`Narrative\n\n${CURRENT_PLAN_SNAPSHOT_PREFIX}not JSON`,
`Narrative\n\n${CURRENT_PLAN_SNAPSHOT_PREFIX}{"other": []}`,
`Narrative\n\n${CURRENT_PLAN_SNAPSHOT_PREFIX}{"plan": []}\nMore narrative`,
])("preserves text that is not an appended plan snapshot", (summary) => {
expect(stripCurrentPlanSnapshot(summary)).toBe(summary);
});
});
Loading
Loading