diff --git a/packages/coding-agent/docs/context-management.md b/packages/coding-agent/docs/context-management.md index f217b36e..35a57ffd 100644 --- a/packages/coding-agent/docs/context-management.md +++ b/packages/coding-agent/docs/context-management.md @@ -6,6 +6,17 @@ conversation; the canonical session history remains intact. CLM uses the configured model and retains native summary compaction and provider-overflow recovery as fallbacks. +## Request notice stability + +Working-context status and pressure notices remain at their original positions +in the request view during append-only conversation turns. They are not written +to the canonical transcript. Retries reuse the same notice content and timestamp; +new tool results and user messages follow previously issued notices. An edited, +shortened, or different context projection clears the retained notices so a +branch switch or compaction does not replay stale advice into the new context. +This preserves ordinary prompt-prefix reuse without preventing intentional +context edits. + ## Select a compression mode No flag or configuration change is needed to use CLM. An existing explicit diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index c6dde28f..2bd70504 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -78,6 +78,7 @@ import { type LiveContextOutcome, type LiveContextStatus, } from "./compaction/live-context/manager.ts"; +import { StableContextNotices } from "./compaction/live-context/stable-notices.ts"; import { DEFAULT_THINKING_LEVEL, THINKING_LEVEL_OPTIONS } from "./defaults.ts"; import { exportSessionToHtml, type ToolHtmlRenderer } from "./export-html/index.ts"; import { createToolHtmlRenderer } from "./export-html/tool-renderer.ts"; @@ -882,6 +883,7 @@ export class AgentSession { private _installLiveContext(): void { const transform = this.agent.transformContext; + const notices = new StableContextNotices(); this.agent.transformContext = async (raw, signal) => { const live = this._getLiveContext(); if (!live) return transform ? transform(raw, signal) : raw; @@ -911,9 +913,7 @@ export class AgentSession { this.model?.contextWindow ?? 0, guidanceOptions, ); - return notice || pressure - ? [...messages, ...(notice ? [notice] : []), ...(pressure ? [pressure] : [])] - : messages; + return notices.project(messages, [...(notice ? [notice] : []), ...(pressure ? [pressure] : [])]); }; const stream = this.agent.streamFunction; this._liveContextBaseStream = stream; diff --git a/packages/coding-agent/src/core/compaction/live-context/stable-notices.ts b/packages/coding-agent/src/core/compaction/live-context/stable-notices.ts new file mode 100644 index 00000000..21202c95 --- /dev/null +++ b/packages/coding-agent/src/core/compaction/live-context/stable-notices.ts @@ -0,0 +1,28 @@ +import type { AgentMessage } from "@step-harness/agent-core"; + +/** Keeps request-only notices at their original boundary during append-only turns. */ +export class StableContextNotices { + private previous: string[] = []; + private notices: { index: number; message: AgentMessage }[] = []; + + project(messages: AgentMessage[], added: AgentMessage[]): AgentMessage[] { + const fingerprints = messages.map((message) => JSON.stringify(message)); + // A branch switch, compaction or edited projection starts a new context epoch. + // Never replay notices belonging to a different history into that epoch. + if (this.previous.some((value, index) => fingerprints[index] !== value)) { + this.notices = []; + } + this.previous = fingerprints; + for (const message of added) this.notices.push({ index: messages.length, message }); + if (this.notices.length === 0) return messages; + + const result: AgentMessage[] = []; + let start = 0; + for (const notice of this.notices) { + result.push(...messages.slice(start, notice.index), notice.message); + start = notice.index; + } + result.push(...messages.slice(start)); + return result; + } +} diff --git a/packages/coding-agent/test/stable-context-notices.test.ts b/packages/coding-agent/test/stable-context-notices.test.ts new file mode 100644 index 00000000..d386dc5f --- /dev/null +++ b/packages/coding-agent/test/stable-context-notices.test.ts @@ -0,0 +1,54 @@ +import type { AgentMessage } from "@step-harness/agent-core"; +import { describe, expect, it } from "vitest"; +import { StableContextNotices } from "../src/core/compaction/live-context/stable-notices.ts"; + +const user = (content: string, timestamp = 1): AgentMessage => ({ role: "user", content, timestamp }); +const notice = (content: string): AgentMessage => ({ + role: "custom", + customType: "live-context-status", + display: false, + content, + timestamp: 123, +}); + +describe("stable request context notices", () => { + it("retains a one-shot notice before the newly appended conversation", () => { + const state = new StableContextNotices(); + const raw = [user("read A")]; + const notification = notice("pressure"); + const first = state.project(raw, [notification]); + const next = state.project([...raw, user("continue", 2)], []); + expect(next.slice(0, first.length)).toEqual(first); + expect(next).toEqual([...raw, notification, user("continue", 2)]); + expect(raw).toEqual([user("read A")]); + }); + + it("keeps multiple notifications in order and does not duplicate them on retries", () => { + const state = new StableContextNotices(); + const raw = [user("read A")]; + const a = notice("accepted"); + const b = notice("pressure"); + const first = state.project(raw, [a, b]); + expect(state.project(structuredClone(raw), [])).toEqual(first); + const extended = [...raw, user("continue", 2)]; + const c = notice("another tier"); + const second = state.project(extended, [c]); + expect(second).toEqual([...raw, a, b, extended[1], c]); + expect(state.project(extended, [])).toEqual(second); + }); + + it.each(["edit", "shrink", "branch"])("drops stale notices on a %s context change", (change) => { + const state = new StableContextNotices(); + const raw = [user("old"), user("tail", 2)]; + state.project(raw, [notice("old pressure")]); + const changed = change === "shrink" ? raw.slice(0, 1) : [user(change), raw[1]]; + const fresh = notice("new epoch"); + expect(state.project(changed, [fresh])).toEqual([...changed, fresh]); + expect(state.project([...changed, user("continue", 3)], [])).toEqual([...changed, fresh, user("continue", 3)]); + }); + + it("returns the original view when no notification has been issued", () => { + const raw = [user("continue")]; + expect(new StableContextNotices().project(raw, [])).toBe(raw); + }); +}); diff --git a/packages/coding-agent/test/suite/agent-session-live-context.test.ts b/packages/coding-agent/test/suite/agent-session-live-context.test.ts index 4b9bf32d..337d8023 100644 --- a/packages/coding-agent/test/suite/agent-session-live-context.test.ts +++ b/packages/coding-agent/test/suite/agent-session-live-context.test.ts @@ -35,10 +35,12 @@ describe("AgentSession CLM context integration", () => { await harness.session.prompt("Fix the parser and retain the public API."); await harness.session.prompt("Check progress."); let next: Context | undefined; + let sentMessages: Context["messages"] = []; harness.setResponses([ fauxAssistantMessage(fauxToolCall("organize_context", {}), { stopReason: "toolUse" }), (context) => { next = context; + sentMessages = structuredClone(context.messages); return fauxAssistantMessage("done"); }, ]); @@ -48,6 +50,16 @@ describe("AgentSession CLM context integration", () => { expect(next!.messages.some((m) => m.role === "toolResult" && m.toolName === "organize_context")).toBe(true); expect(JSON.stringify(harness.sessionManager.getEntries())).toContain("Verbose old exploration"); expect(harness.session.getLiveContextStatus()!.revision).toBe(1); + expect(JSON.stringify(sentMessages)).toContain("Context revision 1 accepted"); + harness.setResponses([ + (context) => { + expect(context.messages.slice(0, sentMessages.length)).toEqual(sentMessages); + expect(JSON.stringify(context.messages).match(/Context revision 1 accepted/g)).toHaveLength(1); + return fauxAssistantMessage("continued"); + }, + ]); + await harness.session.prompt("continue"); + expect(JSON.stringify(harness.sessionManager.getEntries())).not.toContain('"customType":"live-context-status"'); }); it("can inspect a large context index and edit it before native threshold compaction", async () => { let h: Harness;