From 4f4e7257b8d824be7840d6222e6dc4d1c8c6a074 Mon Sep 17 00:00:00 2001 From: Yash Date: Mon, 14 Sep 2026 23:11:27 +0000 Subject: [PATCH] fix(harness): retain useful continuation parts before placeholders --- .changeset/assistant-useful-brief-parts.md | 5 ++ packages/harness/docs/assistant-history.md | 4 ++ .../core/assistant-continuation-brief.test.ts | 71 ++++++++++++++++++- .../src/core/assistant-continuation-brief.ts | 17 ++--- 4 files changed, 88 insertions(+), 9 deletions(-) create mode 100644 .changeset/assistant-useful-brief-parts.md diff --git a/.changeset/assistant-useful-brief-parts.md b/.changeset/assistant-useful-brief-parts.md new file mode 100644 index 000000000..b5781c6f4 --- /dev/null +++ b/.changeset/assistant-useful-brief-parts.md @@ -0,0 +1,5 @@ +--- +"@sapiom/harness": patch +--- + +Use the same usefulness predicate for part selection and message reduction. Preserve the latest useful text/tool/file parts and report all omitted parts without claiming they were necessarily earlier. diff --git a/packages/harness/docs/assistant-history.md b/packages/harness/docs/assistant-history.md index e9e2dff34..18660e13f 100644 --- a/packages/harness/docs/assistant-history.md +++ b/packages/harness/docs/assistant-history.md @@ -185,3 +185,7 @@ the exact previous revision; compacted tasks and recorded real replies remain. A failed Terminal binding publication can roll back only its original close generation and exact proposed marker. A queued End retains its durable tombstone or confirmed removal, and other sessions keep their own process and binding. + +Brief part selection keeps the latest useful Assistant text, tool results and +attachments even when blank or omitted control parts follow them. Omission counts +remain explicit without incorrectly labeling every removed part as earlier. diff --git a/packages/harness/src/core/assistant-continuation-brief.test.ts b/packages/harness/src/core/assistant-continuation-brief.test.ts index a298fd67a..46eb16461 100644 --- a/packages/harness/src/core/assistant-continuation-brief.test.ts +++ b/packages/harness/src/core/assistant-continuation-brief.test.ts @@ -122,7 +122,7 @@ it("reserves user context and the newest Assistant parts when both messages are 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("1 part omitted."); expect(frozen.text).toContain("incomplete; do not assume success"); }); it("keeps the newest useful Assistant state ahead of later empty native messages", () => { @@ -170,3 +170,72 @@ it("fails without usable public history or with corrupt binding metadata", () => }), ).toThrow(); }); + +it.each(["blank", "omitted", "mixed"])( + "keeps the latest useful text, tool and attachment before thirteen %s parts", + (kind) => { + const record = fixture(); + record.turns[0]!.messages.push({ + id: "msg_state", + role: "assistant", + parentId: "msg_user_0", + createdAt: 2, + completedAt: 3, + parts: [ + { + id: "prt_state", + type: "text", + text: "Latest result: tests pass; deployment pending.", + truncated: false, + }, + { + id: "prt_tool", + type: "tool", + callId: "call_verify", + name: "test", + status: "completed", + input: "{}", + output: "12 tests passed", + error: null, + startedAt: 2, + completedAt: 3, + truncated: false, + }, + { + id: "prt_file", + type: "file", + name: "test-report.txt", + mime: "text/plain", + }, + ...Array.from({ length: 13 }, (_, index) => + kind === "omitted" || (kind === "mixed" && index % 2 === 0) + ? { + id: `prt_empty_${index}`, + type: "omitted" as const, + nativeType: "step-finish", + } + : { + id: `prt_empty_${index}`, + type: "text" as const, + text: " \n\t ", + truncated: false, + }, + ), + ], + }); + record.messageCount = 2; + const frozen = buildAssistantContinuationBrief(record); + expect(frozen.text).toContain( + "Latest result: tests pass; deployment pending.", + ); + expect(frozen.text).toContain("Recorded tool test (completed)"); + expect(frozen.text).toContain("12 tests passed"); + expect(frozen.text).toContain( + "Attachment test-report.txt (text/plain); contents omitted.", + ); + expect(frozen.text).toContain("13 parts omitted."); + expect(frozen.text).not.toContain("earlier parts omitted"); + expect(frozen.estimatedTokens).toBeLessThanOrEqual(6000); + expect(frozen).toEqual(buildAssistantContinuationBrief(record)); + }, +); diff --git a/packages/harness/src/core/assistant-continuation-brief.ts b/packages/harness/src/core/assistant-continuation-brief.ts index 76eeeea47..3383e7fd5 100644 --- a/packages/harness/src/core/assistant-continuation-brief.ts +++ b/packages/harness/src/core/assistant-continuation-brief.ts @@ -2,6 +2,7 @@ import { createHash } from "node:crypto"; import type { AssistantRecord, AssistantRecordBinding, + AssistantRecordPart, } from "../shared/assistant-record.js"; import { validateAssistantRecord } from "./assistant-record.js"; import { @@ -25,6 +26,9 @@ const clamp = (value: string, limit: number) => value.length <= limit ? value : `${value.slice(0, limit)}… [excerpt truncated]`; +const usefulPart = (part: AssistantRecordPart) => + part.type !== "omitted" && + (part.type !== "text" || part.text.trim().length > 0); /** Freeze a bounded public reconstruction. It carries context, never an instruction to replay work. */ export function buildAssistantContinuationBrief( @@ -45,7 +49,7 @@ export function buildAssistantContinuationBrief( const messages = turn.messages.map((message) => { const selected = message.role === "assistant" - ? message.parts.slice(-12) + ? message.parts.filter(usefulPart).slice(-12) : message.parts.slice(0, 12); const parts = selected.map((part) => { switch (part.type) { @@ -59,18 +63,15 @@ export function buildAssistantContinuationBrief( return `Omitted ${part.nativeType} part.`; } }); - if (message.parts.length > 12) { - const omitted = `${message.parts.length - 12} ${message.role === "assistant" ? "earlier" : "further"} parts omitted.`; + if (message.parts.length > selected.length) { + const count = message.parts.length - selected.length; + const omitted = `${count} ${message.role === "assistant" ? "" : "further "}part${count === 1 ? "" : "s"} 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), - ), + useful: selected.some(usefulPart), }; }); return {