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-history-review-fixes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@sapiom/harness": patch
---

Preserve recent recorded Assistant state and reject contradictory legacy associations.
4 changes: 4 additions & 0 deletions packages/harness/docs/assistant-history.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
74 changes: 72 additions & 2 deletions packages/harness/src/core/assistant-continuation-brief.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
66 changes: 50 additions & 16 deletions packages/harness/src/core/assistant-continuation-brief.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Comment on lines +46 to +49

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Trailing placeholders erase Assistant state

When 12 trailing placeholders follow useful state, selected drops the latest useful Assistant part. The continuation retains placeholders and labels the useful state as omitted.

Learn more

Assistant messages can contain blank text and omitted parts for native bookkeeping. The 12-part cap currently selects by position alone. Therefore, 12 trailing placeholders displace every useful text, tool, or file part before them. The later useful flag examines the complete message, so it still marks this now-empty excerpt useful and preserves it during message reduction.

Example: An Assistant message contains one text part saying Verification still pending, followed by 12 step-finish parts projected as omitted. The brief includes 12 omission placeholders and 1 earlier parts omitted, but loses the verification state.

Recommended fix: Select the newest 12 useful Assistant parts while retaining any necessary omission marker. Define usefulness consistently with the message-level predicate, and add coverage where useful text precedes at least 12 blank or omitted parts.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

const parts = selected.map((part) => {
switch (part.type) {
case "text":
return clamp(part.text, 1_200);
Expand All @@ -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 = () =>
[
Expand All @@ -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 (
Expand All @@ -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(
Expand Down
46 changes: 46 additions & 0 deletions packages/harness/src/core/assistant-session-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 4 additions & 1 deletion packages/harness/src/core/assistant-session-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading