From 9f1b6de9b561c4ea982f4fcc95cdaa184dab2e48 Mon Sep 17 00:00:00 2001 From: Yash Date: Mon, 14 Sep 2026 22:18:30 +0000 Subject: [PATCH] fix(harness): preserve exact Assistant history provenance --- .changeset/assistant-history-review-fixes.md | 5 ++ packages/harness/docs/assistant-history.md | 4 + .../core/assistant-continuation-brief.test.ts | 74 ++++++++++++++++++- .../src/core/assistant-continuation-brief.ts | 66 +++++++++++++---- .../src/core/assistant-session-store.test.ts | 46 ++++++++++++ .../src/core/assistant-session-store.ts | 5 +- 6 files changed, 181 insertions(+), 19 deletions(-) create mode 100644 .changeset/assistant-history-review-fixes.md diff --git a/.changeset/assistant-history-review-fixes.md b/.changeset/assistant-history-review-fixes.md new file mode 100644 index 000000000..7e3bbb6d2 --- /dev/null +++ b/.changeset/assistant-history-review-fixes.md @@ -0,0 +1,5 @@ +--- +"@sapiom/harness": patch +--- + +Preserve recent recorded Assistant state and reject contradictory legacy associations. diff --git a/packages/harness/docs/assistant-history.md b/packages/harness/docs/assistant-history.md index 617f1b7d5..e60c4d750 100644 --- a/packages/harness/docs/assistant-history.md +++ b/packages/harness/docs/assistant-history.md @@ -124,3 +124,7 @@ and content hash. Only that exact no-reply seed is excluded from human task stat and archive counts; arbitrary synthetic messages and unexpected answers retain ordinary task semantics. Opening or reloading it does not trigger recovery. Missing native history offers a separate action to review the retained record. + +Brief reduction preserves the newest useful Assistant state and the user's task +context, with explicit omission markers. Sibling legacy associations that claim +the same native scope with contradictory workspaces fail closed before creation. diff --git a/packages/harness/src/core/assistant-continuation-brief.test.ts b/packages/harness/src/core/assistant-continuation-brief.test.ts index 3a3f35955..a298fd67a 100644 --- a/packages/harness/src/core/assistant-continuation-brief.test.ts +++ b/packages/harness/src/core/assistant-continuation-brief.test.ts @@ -78,17 +78,87 @@ it("bounds an oversized final turn without dropping the honesty block", () => { { id: `prt_long_${index}`, type: "text" as const, - text: "data ".repeat(240), + text: `Reply ${index}: ${"data ".repeat(235)}`, truncated: false, }, ], })), ); record.messageCount = 31; + const original = JSON.stringify(record); const frozen = buildAssistantContinuationBrief(record); expect(frozen.estimatedTokens).toBeLessThanOrEqual(6000); - expect(frozen.text).toContain("excerpt truncated"); + expect(frozen.text).toContain("Assistant messages omitted from this excerpt"); expect(frozen.text).toContain("Record revision 7"); + expect(frozen.text).toContain("Task 0: preserve current work."); + expect(frozen.text).toContain("Assistant msg_reply_29:"); + expect(frozen.text).toContain("Reply 29:"); + expect(frozen.text).not.toContain("Assistant msg_reply_0:"); + expect(frozen).toEqual(buildAssistantContinuationBrief(record)); + expect(JSON.stringify(record)).toBe(original); +}); +it("reserves user context and the newest Assistant parts when both messages are oversized", () => { + const record = fixture(); + const parts = (prefix: string, count: number) => + Array.from({ length: count }, (_, index) => ({ + id: `prt_${prefix}_${index}`, + type: "text" as const, + text: `${prefix} state ${index}: ${"data ".repeat(235)}`, + truncated: false, + })); + record.turns[0]!.messages[0]!.parts = parts("user", 12); + record.turns[0]!.messages.push({ + id: "msg_reply", + role: "assistant", + parentId: "msg_user_0", + createdAt: 2, + completedAt: null, + parts: parts("assistant", 13), + }); + record.messageCount = 2; + const frozen = buildAssistantContinuationBrief(record); + expect(frozen.estimatedTokens).toBeLessThanOrEqual(6000); + expect(frozen.text).toContain("user state 0:"); + expect(frozen.text).toContain("excerpt truncated"); + expect(frozen.text).toContain("assistant state 12:"); + expect(frozen.text).not.toContain("assistant state 0:"); + expect(frozen.text).toContain("1 earlier parts omitted."); + expect(frozen.text).toContain("incomplete; do not assume success"); +}); +it("keeps the newest useful Assistant state ahead of later empty native messages", () => { + const record = fixture(); + record.turns[0]!.messages.push( + { + id: "msg_state", + role: "assistant", + parentId: "msg_user_0", + createdAt: 2, + completedAt: null, + parts: [ + { + id: "prt_state", + type: "text", + text: "Latest useful state: implementation ready; verification still pending.", + truncated: false, + }, + ], + }, + ...Array.from({ length: 200 }, (_, index) => ({ + id: `msg_empty_${index}_${"x".repeat(100)}`, + role: "assistant" as const, + parentId: "msg_user_0", + createdAt: index + 3, + completedAt: null, + parts: [], + })), + ); + record.messageCount = 202; + const frozen = buildAssistantContinuationBrief(record); + expect(frozen.estimatedTokens).toBeLessThanOrEqual(6000); + expect(frozen.text).toContain( + "Latest useful state: implementation ready; verification still pending.", + ); + expect(frozen.text).toContain("Assistant messages omitted from this excerpt"); }); it("fails without usable public history or with corrupt binding metadata", () => { const empty = fixture(0); diff --git a/packages/harness/src/core/assistant-continuation-brief.ts b/packages/harness/src/core/assistant-continuation-brief.ts index 6aa4d38bf..76eeeea47 100644 --- a/packages/harness/src/core/assistant-continuation-brief.ts +++ b/packages/harness/src/core/assistant-continuation-brief.ts @@ -43,7 +43,11 @@ export function buildAssistantContinuationBrief( .slice(-RESUME_BRIEF_DEFAULT_MAX_TURNS) .map((turn) => { const messages = turn.messages.map((message) => { - const parts = message.parts.slice(0, 12).map((part) => { + const selected = + message.role === "assistant" + ? message.parts.slice(-12) + : message.parts.slice(0, 12); + const parts = selected.map((part) => { switch (part.type) { case "text": return clamp(part.text, 1_200); @@ -55,11 +59,25 @@ export function buildAssistantContinuationBrief( return `Omitted ${part.nativeType} part.`; } }); - if (message.parts.length > 12) - parts.push(`${message.parts.length - 12} further parts omitted.`); - return `${message.role === "user" ? "User" : "Assistant"} ${message.id}:\n${parts.join("\n")}`; + if (message.parts.length > 12) { + const omitted = `${message.parts.length - 12} ${message.role === "assistant" ? "earlier" : "further"} parts omitted.`; + if (message.role === "assistant") parts.unshift(omitted); + else parts.push(omitted); + } + return { + text: `${message.role === "user" ? "User" : "Assistant"} ${message.id}:\n${parts.join("\n")}`, + useful: message.parts.some( + (part) => + part.type !== "omitted" && + (part.type !== "text" || part.text.trim().length > 0), + ), + }; }); - return `Turn ${turn.id} (${turn.incomplete ? "incomplete; do not assume success" : "recorded as complete"})\n${messages.join("\n\n")}`; + return { + heading: `Turn ${turn.id} (${turn.incomplete ? "incomplete; do not assume success" : "recorded as complete"})`, + messages, + omittedMessages: 0, + }; }); const render = () => [ @@ -69,7 +87,18 @@ export function buildAssistantContinuationBrief( `Workspace: ${clamp(record.binding.cwd, 400)}. Record revision ${record.revision}, captured ${record.capturedAt}.`, `Retained ${turns.length} of ${record.turnCount} recorded turns; ${record.turnCount - turns.length} omitted. Text and tool fields are excerpts.`, `Record limitations: ${record.limitations.length ? record.limitations.join(", ") : "none reported; this remains a reconstruction"}.`, - ...turns, + ...turns.map((turn) => + [ + turn.heading, + turn.messages[0]!.text, + ...(turn.omittedMessages + ? [ + `${turn.omittedMessages} Assistant messages omitted from this excerpt.`, + ] + : []), + ...turn.messages.slice(1).map((message) => message.text), + ].join("\n\n"), + ), ].join("\n\n"); let text = render(); while ( @@ -80,17 +109,22 @@ export function buildAssistantContinuationBrief( text = render(); } if (estimateBriefTokens(text) > RESUME_BRIEF_DEFAULT_MAX_TOKENS) { - // Keep the honesty/provenance block, and explicitly mark an oversized final turn. - turns[0] = clamp( - turns[0]!, - Math.max( - 1, - RESUME_BRIEF_DEFAULT_MAX_TOKENS * 4 - - (text.length - turns[0]!.length) - - 40, - ), - ); + // Reserve user context, then remove empty/old Assistant messages. Never + // prefix-clamp the whole turn: the newest useful state is at its end. + const newest = turns[0]!; + newest.messages[0]!.text = clamp(newest.messages[0]!.text, 1_200); text = render(); + while ( + newest.messages.length > 2 && + estimateBriefTokens(text) > RESUME_BRIEF_DEFAULT_MAX_TOKENS + ) { + const empty = newest.messages.findIndex( + (message, index) => index > 0 && !message.useful, + ); + newest.messages.splice(empty > 0 ? empty : 1, 1); + newest.omittedMessages++; + text = render(); + } } if (estimateBriefTokens(text) > RESUME_BRIEF_DEFAULT_MAX_TOKENS) throw new Error( diff --git a/packages/harness/src/core/assistant-session-store.test.ts b/packages/harness/src/core/assistant-session-store.test.ts index 2fecd8207..cdec5173e 100644 --- a/packages/harness/src/core/assistant-session-store.test.ts +++ b/packages/harness/src/core/assistant-session-store.test.ts @@ -119,6 +119,52 @@ it("rejects contradictory identities while scanning other bindings before legacy expect(create).not.toHaveBeenCalled(); }); +it("rejects a sibling claiming the same native scope for a different workspace without replacing history", async () => { + const nativeRoot = await legacy(); + const other = { + ...key, + contextAuthorityScope: "c".repeat(64), + cwd: "/other", + }; + const directory = await assistantDirectory( + root, + key.harnessSessionId, + other.contextAuthorityScope, + ); + const saved = { + version: 1, + ...other, + nativeScope, + conversationId: "ses_other", + createdAt: 1, + }; + await writeAssistantJson(directory, "association.json", saved); + await expect(store.associate(key, nativeScope, undefined)).rejects.toThrow(); + await expect(store.associate(key, nativeScope, create)).rejects.toThrow(); + expect(create).not.toHaveBeenCalled(); + expect(await store.association(key)).toBeNull(); + expect(await store.association(other)).toEqual(saved); + expect( + JSON.parse(await readFile(join(nativeRoot, "association.json"), "utf8")), + ).toEqual({ version: 1, conversationId: "ses_original" }); +}); + +it("imports the requested native scope when a sibling belongs to a different workspace and native scope", async () => { + await legacy(); + const other = { + ...key, + contextAuthorityScope: "c".repeat(64), + cwd: "/other", + }; + const saved = await store.associate(other, "d".repeat(64), create); + create.mockClear(); + expect( + (await store.associate(key, nativeScope, create))?.conversationId, + ).toBe("ses_original"); + expect(create).not.toHaveBeenCalled(); + expect(await store.association(other)).toEqual(saved); +}); + it("keeps the previous durable checkpoint when cancellation interrupts a write", async () => { const ended = await store.transition(key.harnessSessionId, 0, { lifecycle: "ended", diff --git a/packages/harness/src/core/assistant-session-store.ts b/packages/harness/src/core/assistant-session-store.ts index 4e0e89d95..cf6a89559 100644 --- a/packages/harness/src/core/assistant-session-store.ts +++ b/packages/harness/src/core/assistant-session-store.ts @@ -301,7 +301,10 @@ export class AssistantSessionStore { parsed.data.conversationId === key.harnessSessionId ) throw new AssistantStorageError(); - if (parsed.data.nativeScope === nativeScope) alreadyImported = true; + if (parsed.data.nativeScope === nativeScope) { + if (parsed.data.cwd !== key.cwd) throw new AssistantStorageError(); + alreadyImported = true; + } } } let conversationId: string | undefined;