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
13 changes: 12 additions & 1 deletion packages/coding-agent/src/session/agent-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11645,10 +11645,21 @@ export class AgentSession {
options?: { commitGate?: (actual: { prunedCount: number; tokensSaved: number }) => boolean },
): Promise<{ prunedCount: number; tokensSaved: number; committed: boolean } | undefined> {
const branchEntries = this.sessionManager.getBranch();
const artifactManager = this.sessionManager.getArtifactManager();
// Prefer ensureArtifactManager so in-memory / non-persistent sessions get an
// ephemeral store (or a visible install failure) instead of silently pruning
// without durable eviction. Fail closed when tool-output eviction is planned
// but no artifact store can be established.
const artifactManager = await this.sessionManager.ensureArtifactManager();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Add the session fix to the package changelog

This changes the user-visible mid-run maintenance outcome and artifact-preservation behavior in packages/coding-agent, but the commit adds no entry under packages/coding-agent/CHANGELOG.mdUnreleased, so the fix will be absent from the package's release notes. Add a concise Fixed entry for the fail-closed behavior.

AGENTS.md reference: AGENTS.md:L178-L178

Useful? React with 👍 / 👎.

const prunedArtifacts: Array<{ entryId: string; id: string; toolType: string; originalText: string }> = [];
let reservedArtifactId: string | undefined;
let artifactAllocationAvailable = artifactManager !== null;
const pruneEstimate = estimateToolOutputPruneSavings(branchEntries, DEFAULT_PRUNE_CONFIG, {
relaxedMinimum: overThreshold ? 0 : undefined,
artifactRefMaxChars: PRUNED_ARTIFACT_REF_MAX_CHARS,
});
if (!artifactManager && pruneEstimate.prunableCount > 0) {
return undefined;
Comment on lines +11660 to +11661

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Fail closed when artifact ID allocation fails

When a session already has an ArtifactManager but its storage is unavailable—for example, the artifact directory becomes read-only, the disk is full, or a managed-store operation fails—this guard is bypassed. The following allocatePath() catch (and the later allocateId() catch) merely disables artifact references, after which pruneToolOutputs still commits truncation notices and rewrites the canonical entries, permanently discarding the original output without an artifact. Treat reservation/allocation failure like a missing manager and abort the prune before committing any mutations.

Useful? React with 👍 / 👎.

}
if (artifactManager) {
try {
reservedArtifactId = (await artifactManager.allocatePath("tool-output")).id;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@ import { loadExtensions } from "@gajae-code/coding-agent/extensibility/extension
import { ExtensionRunner } from "@gajae-code/coding-agent/extensibility/extensions/runner";
import { AgentSession } from "@gajae-code/coding-agent/session/agent-session";
import { AuthStorage } from "@gajae-code/coding-agent/session/auth-storage";
import { getLatestCompactionEntry, SessionManager } from "@gajae-code/coding-agent/session/session-manager";
import {
getLatestCompactionEntry,
SessionManager,
SessionManagerTestHooks,
} from "@gajae-code/coding-agent/session/session-manager";
import { getProjectAgentDir, TempDir } from "@gajae-code/utils";

/**
Expand Down Expand Up @@ -149,6 +153,57 @@ describe("AgentSession mid-run maintenance outcomes", () => {
function contextOf(s: AgentSession): AgentContext {
return { systemPrompt: s.state.systemPrompt, messages: s.messages, tools: [] };
}
/**
* Seed an older large bash tool result (prunable) plus a recent protected
* turn fence so mid-run maintenance prefers tool-output eviction.
*/
async function seedPrunableToolConversation(
s: AgentSession,
output: string,
finalUsageTotal: number,
): Promise<string> {
const toolCallId = "evict-call";
await seed(s, [
{ role: "user", content: "first request", timestamp: Date.now() },
{
role: "assistant",
content: [{ type: "toolCall", id: toolCallId, name: "bash", arguments: { command: "cat" } }],
api: s.model!.api,
provider: s.model!.provider,
model: s.model!.id,
usage: usage(1_000),
stopReason: "toolUse",
timestamp: Date.now(),
},
{ role: "user", content: "second request", timestamp: Date.now() },
assistant(s.model!, usage(1_000), "second response"),
{ role: "user", content: "third request", timestamp: Date.now() },
assistant(s.model!, usage(finalUsageTotal), "final response"),
]);
await seed(s, [
{
role: "toolResult",
toolCallId,
toolName: "bash",
content: [{ type: "text", text: output }],
isError: false,
timestamp: Date.now(),
},
{
role: "toolResult",
toolCallId: "recent-call",
toolName: "bash",
content: [{ type: "text", text: "recent-protected-".repeat(10_000) }],
isError: false,
timestamp: Date.now(),
},
]);
await seed(s, [
{ role: "user", content: "fence request one", timestamp: Date.now() },
{ role: "user", content: "fence request two", timestamp: Date.now() },
]);
return toolCallId;
}

async function waitFor(predicate: () => boolean): Promise<void> {
const deadline = Date.now() + 1_000;
Expand Down Expand Up @@ -496,6 +551,35 @@ describe("AgentSession mid-run maintenance outcomes", () => {
}
});

it("fails closed when tool-output eviction artifacts are unavailable", async () => {
// Force ephemeral artifact-manager install failure so ensureArtifactManager
// returns null. Mid-run maintenance must not report a successful prune that
// skipped durable eviction — outcome is failed and original tool text remains.
SessionManagerTestHooks.beforeEphemeralArtifactManagerInstall = async () => {
throw new Error("injected ephemeral artifact install failure");
};
try {
session = await buildSession({ settings: { "compaction.keepRecentTokens": 10 } });
const output = "unavailable-output-".repeat(35_000);
const toolCallId = await seedPrunableToolConversation(session, output, 1_000);
const outcome = await session.runMidRunMaintenanceForTests(contextOf(session));
expect(outcome).toBe("failed");
const entry = session.sessionManager
.getBranch()
.find(
(candidate): candidate is Extract<typeof candidate, { type: "message" }> =>
candidate.type === "message" &&
candidate.message.role === "toolResult" &&
candidate.message.toolCallId === toolCallId,
);
expect(entry?.type).toBe("message");
if (entry?.type !== "message" || entry.message.role !== "toolResult") return;
expect(entry.message.content).toEqual([{ type: "text", text: output }]);
} finally {
SessionManagerTestHooks.beforeEphemeralArtifactManagerInstall = undefined;
}
}, 15_000);

it("T6 attempts identical provider-response anchors at most once", async () => {
session = await buildSession({ shortCircuit: false });
await seed(session, [
Expand Down
Loading