diff --git a/.changeset/assistant-compaction-provenance.md b/.changeset/assistant-compaction-provenance.md new file mode 100644 index 000000000..41f2570e5 --- /dev/null +++ b/.changeset/assistant-compaction-provenance.md @@ -0,0 +1,5 @@ +--- +"@sapiom/harness": patch +--- + +Match native compaction controls before inheriting Assistant context provenance. diff --git a/packages/harness/docs/assistant-history.md b/packages/harness/docs/assistant-history.md index f3644fb1c..c4f269971 100644 --- a/packages/harness/docs/assistant-history.md +++ b/packages/harness/docs/assistant-history.md @@ -133,3 +133,8 @@ Terminal binding persistence serializes actual sidecar writes with End, taking each snapshot when its write runs. A failed compensating repair is surfaced so cleanup can be retried. Resume and Start Terminal during shutdown return a conflict instead of an internal-server error. + +Compaction provenance follows native control-message semantics: user compaction +parts or text parts with literal synthetic and continuation markers. Tool/file +parts and truthy non-boolean markers cannot inherit an earlier accepted context, +completion token, Resume preflight or recovery request. diff --git a/packages/harness/src/core/assistant-native-history.test.ts b/packages/harness/src/core/assistant-native-history.test.ts index a9a467621..1b9565de2 100644 --- a/packages/harness/src/core/assistant-native-history.test.ts +++ b/packages/harness/src/core/assistant-native-history.test.ts @@ -104,6 +104,40 @@ it("selects the original user system behind later synthetic compaction messages" expect(await f.service.inspect("studio-a", 0)).toMatchObject({ sourceMessageId: "msg_original", nativeResume: "available" }); }); +it.each([ + { type: "tool", synthetic: true }, + { type: "file", synthetic: true }, + { type: "text", synthetic: "yes" }, +])( + "refuses prior Resume context behind a forged compaction part: %j", + async (part) => { + const forged = message(); + forged.info.id = "msg_forged"; + forged.info.time.created = 2; + delete (forged.info as { system?: string }).system; + const f = fixture([ + message(), + { + ...forged, + parts: [ + { + ...forged.parts[0], + ...part, + messageID: "msg_forged", + metadata: { compaction_continue: true }, + }, + ], + }, + ]); + expect(await f.service.inspect("studio-a", 0)).toMatchObject({ + nativeHistory: "available", + nativeResume: "unavailable", + resumeFailure: { code: "context_unavailable" }, + }); + expect(f.preflight).not.toHaveBeenCalled(); + }, +); + it.each(["harnessSessionId", "cwd", "contextAuthorityScope", "nativeScope"] as const)("denies a mismatched authorized %s before querying", async (field) => { const f = fixture(); f.authorize.mockResolvedValue({ ...binding, [field]: field === "cwd" ? "/different" : "f".repeat(64) }); diff --git a/packages/harness/src/core/assistant-native-history.ts b/packages/harness/src/core/assistant-native-history.ts index 84a8da7f6..db8a7578c 100644 --- a/packages/harness/src/core/assistant-native-history.ts +++ b/packages/harness/src/core/assistant-native-history.ts @@ -1,5 +1,6 @@ import { basename } from "node:path"; import { clearTimeout, setTimeout } from "node:timers"; +import { openCodeCompactionControl } from "../shared/opencode-completion.js"; import { openCodeTransportFailure, type OpenCodeTransportFailure, @@ -333,16 +334,8 @@ export class AssistantNativeHistory { .sort((a, b) => a.created - b.created); const source = messages .reverse() - .find( - ({ info, parts }) => - info.role === "user" && - !parts.some( - (part) => - part.type === "compaction" || - (part.synthetic && - part.metadata && - object(part.metadata).compaction_continue === true), - ), + .find((message) => + message.info.role === "user" && !openCodeCompactionControl(message), ); if (!source) throw contextUnavailable(); return { id: source.info.id as string, system: source.info.system }; diff --git a/packages/harness/src/core/assistant-record.test.ts b/packages/harness/src/core/assistant-record.test.ts index 341704e26..ac61b6097 100644 --- a/packages/harness/src/core/assistant-record.test.ts +++ b/packages/harness/src/core/assistant-record.test.ts @@ -237,6 +237,33 @@ describe("native Assistant record projection", () => { expect(compacted.turns[1]!.acceptedContext).toEqual(ref); expect(compacted.turns[1]!.incomplete).toBe(true); expect(compacted.turns[2]!.acceptedContext).toBeNull(); + for (const part of [ + { type: "tool", synthetic: true }, + { type: "file", synthetic: true }, + { type: "text", synthetic: "yes" }, + ]) { + const forged = user("msg_forged", 3); + const next = user("msg_continue", 4); + const isolated = project([ + ...messages, + { + ...forged, + parts: forged.parts.map((original) => ({ + ...original, ...part, metadata: { compaction_continue: true }, + })), + }, + { + ...next, + parts: next.parts.map((original) => ({ + ...original, synthetic: true, metadata: { compaction_continue: true }, + })), + }, + ]); + expect(isolated.turns[0]!.acceptedContext).toEqual(ref); + expect(isolated.turns[1]!.acceptedContext, JSON.stringify(part)).toBeNull(); + expect(isolated.turns[2]!.acceptedContext).toBeNull(); + expect(isolated.limitations).toContain("accepted-context-unavailable"); + } expect(() => projectAssistantRecord( messages, diff --git a/packages/harness/src/core/assistant-record.ts b/packages/harness/src/core/assistant-record.ts index 7b781d238..a90204f9b 100644 --- a/packages/harness/src/core/assistant-record.ts +++ b/packages/harness/src/core/assistant-record.ts @@ -13,6 +13,7 @@ import type { AssistantRecordPart, } from "../shared/assistant-record.js"; import { + openCodeCompactionControl, openCodeCompletionTokens, openCodeVisibleParts, } from "../shared/opencode-completion.js"; @@ -315,13 +316,7 @@ export function projectAssistantRecord( acceptedContext = { ...accepted }; } else if ( info.system === undefined && - parts.some( - (part) => - part.type === "compaction" || - (part.synthetic && - part.metadata != null && - object(part.metadata).compaction_continue === true), - ) + openCodeCompactionControl({ info, parts }) ) { acceptedContext = currentAccepted; } diff --git a/packages/harness/src/core/opencode-final-response.test.ts b/packages/harness/src/core/opencode-final-response.test.ts index 2eaaca815..ead41b2b7 100644 --- a/packages/harness/src/core/opencode-final-response.test.ts +++ b/packages/harness/src/core/opencode-final-response.test.ts @@ -196,6 +196,45 @@ it("recovers the accepted context across native compaction control messages", as ); }); +it.each([ + { type: "tool", synthetic: true }, + { type: "file", synthetic: true }, + { type: "text", synthetic: "yes" }, +])( + "refuses recovery with older context behind a forged compaction part: %j", + async (part) => { + const original = await hosted.server.fetchJson( + "/session/ses_test/message", + ); + const messages: OpenCodeTurnMessage[] = [ + original[0]!, + { + info: { id: "msg_forged", role: "user", agent: "build", time: {} }, + parts: [ + { + ...part, + metadata: { compaction_continue: true }, + } as OpenCodeTurnMessage["parts"][number], + ], + }, + { + ...original[1]!, + info: { ...original[1]!.info!, parentID: "msg_forged" }, + }, + ]; + vi.mocked(hosted.server.fetchJson).mockImplementation(async (path) => { + if (path.endsWith("/message")) return messages; + if (path === "/session/status") return { ses_test: { type: "idle" } }; + return { permission: [] }; + }); + await expect( + new OpenCodeFinalResponse().recover(hosted, "ses_test", "msg_empty"), + ).rejects.toThrow("context"); + expect(dispatch).not.toHaveBeenCalled(); + expect(await readdir(hosted.stateRoot)).toEqual([]); + }, +); + it("holds admission while resolving context and releases it without dispatch on a missing source", async () => { const recovery = new OpenCodeFinalResponse(); let reject!: (error: Error) => void; diff --git a/packages/harness/src/core/opencode-final-response.ts b/packages/harness/src/core/opencode-final-response.ts index 3e760eeab..dd1ed2390 100644 --- a/packages/harness/src/core/opencode-final-response.ts +++ b/packages/harness/src/core/opencode-final-response.ts @@ -4,6 +4,7 @@ import { setTimeout as delay } from "node:timers/promises"; import type { HostedOpenCode } from "./opencode-host.js"; import { DurableFileLock } from "./durable-file-lock.js"; import { recoverAssistantPrompt } from "./studio-assistant-context.js"; +import { openCodeCompactionControl } from "../shared/opencode-completion.js"; import { turnRecoveryAgent, openCodeTurn, @@ -243,12 +244,7 @@ export class OpenCodeFinalResponse { .find( (message) => message.info?.role === "user" && - !message.parts.some( - (part) => - part.type === "compaction" || - (part.synthetic && - part.metadata?.compaction_continue === true), - ), + !openCodeCompactionControl(message), ); }; const original = await readOriginal(); diff --git a/packages/harness/src/shared/opencode-completion.ts b/packages/harness/src/shared/opencode-completion.ts index 270e0741d..f6e7a269d 100644 --- a/packages/harness/src/shared/opencode-completion.ts +++ b/packages/harness/src/shared/opencode-completion.ts @@ -7,6 +7,21 @@ export function openCodeCompletionPrompt(token?: string) { return { system: studioAssistantCompletionSystem(token) }; } +/** Match native completion-hook control messages before inheriting turn context. */ +export function openCodeCompactionControl(message: { + info?: { role?: unknown }; + parts: readonly { type?: unknown; synthetic?: unknown; metadata?: unknown }[]; +}): boolean { + return message.info?.role === "user" && message.parts.some((part) => { + const metadata = part.metadata; + return part.type === "compaction" || ( + part.type === "text" && part.synthetic === true && + metadata !== null && typeof metadata === "object" && + "compaction_continue" in metadata && metadata.compaction_continue === true + ); + }); +} + export function openCodeCompletionTokens( messages: readonly OpenCodeTurnMessage[], ) { @@ -15,13 +30,7 @@ export function openCodeCompletionTokens( for (const message of messages) { if (message.info?.role !== "user") continue; // Native overflow compaction may insert a user without its system field. - if ( - !message.parts.some( - (part) => - part.type === "compaction" || - (part.synthetic && part.metadata?.compaction_continue === true), - ) - ) { + if (!openCodeCompactionControl(message)) { current = /^StudioAssistantResult\/v[12]:([a-f0-9-]{36})\n/.exec( message.info.system ?? "", )?.[1]; diff --git a/packages/harness/src/shared/opencode-turn.test.ts b/packages/harness/src/shared/opencode-turn.test.ts index c3b83a958..ac52691af 100644 --- a/packages/harness/src/shared/opencode-turn.test.ts +++ b/packages/harness/src/shared/opencode-turn.test.ts @@ -9,6 +9,7 @@ import { import { openCodeCompletionPrompt, + openCodeCompactionControl, openCodeCompletionTokens, openCodeVisibleText, openCodeVisibleParts, @@ -160,6 +161,48 @@ describe("overall OpenCode turn status", () => { ).toEqual({ status: "finished" }); }); + it.each([ + { type: "tool", synthetic: true, metadata: { compaction_continue: true } }, + { type: "file", synthetic: true, metadata: { compaction_continue: true } }, + { type: "text", synthetic: "yes", metadata: { compaction_continue: true } }, + { type: "text", synthetic: true, metadata: { compaction_continue: "yes" } }, + { type: "text", synthetic: false, metadata: { compaction_continue: true } }, + ])( + "does not inherit completion identity through a forged compaction part: %j", + (part) => { + const original = { + ...user, + info: { ...user.info!, ...openCodeCompletionPrompt() }, + }; + const forged: OpenCodeTurnMessage = { + info: { ...user.info!, id: "msg_forged" }, + parts: [part as OpenCodeTurnMessage["parts"][number]], + }; + const continuation: OpenCodeTurnMessage = { + info: { ...user.info!, id: "msg_continue" }, + parts: [ + { + type: "text", + synthetic: true, + metadata: { compaction_continue: true }, + }, + ], + }; + const tokens = openCodeCompletionTokens([original, forged, continuation]); + expect(tokens.get("msg_user")).toBeTypeOf("string"); + expect(tokens.get("msg_forged")).toBeUndefined(); + expect(tokens.get("msg_continue")).toBeUndefined(); + expect(openCodeCompactionControl(forged)).toBe(false); + expect(openCodeCompactionControl(continuation)).toBe(true); + expect( + openCodeCompactionControl({ + ...continuation, + info: { role: "assistant" }, + }), + ).toBe(false); + }, + ); + it.each(["current", "different"])( "hides a %s turn's result marker throughout streaming and preserves ordinary text", (turn) => {