Skip to content
Closed
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
5 changes: 5 additions & 0 deletions .changeset/assistant-compaction-provenance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@sapiom/harness": patch
---

Match native compaction controls before inheriting Assistant context provenance.
5 changes: 5 additions & 0 deletions packages/harness/docs/assistant-history.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
34 changes: 34 additions & 0 deletions packages/harness/src/core/assistant-native-history.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) });
Expand Down
13 changes: 3 additions & 10 deletions packages/harness/src/core/assistant-native-history.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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 };
Expand Down
27 changes: 27 additions & 0 deletions packages/harness/src/core/assistant-record.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
9 changes: 2 additions & 7 deletions packages/harness/src/core/assistant-record.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type {
AssistantRecordPart,
} from "../shared/assistant-record.js";
import {
openCodeCompactionControl,
openCodeCompletionTokens,
openCodeVisibleParts,
} from "../shared/opencode-completion.js";
Expand Down Expand Up @@ -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;
}
Expand Down
39 changes: 39 additions & 0 deletions packages/harness/src/core/opencode-final-response.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<OpenCodeTurnMessage[]>(
"/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;
Expand Down
8 changes: 2 additions & 6 deletions packages/harness/src/core/opencode-final-response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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();
Expand Down
23 changes: 16 additions & 7 deletions packages/harness/src/shared/opencode-completion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[],
) {
Expand All @@ -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];
Expand Down
43 changes: 43 additions & 0 deletions packages/harness/src/shared/opencode-turn.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {

import {
openCodeCompletionPrompt,
openCodeCompactionControl,
openCodeCompletionTokens,
openCodeVisibleText,
openCodeVisibleParts,
Expand Down Expand Up @@ -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) => {
Expand Down
Loading