mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 13:26:04 -06:00
fix(compaction): preserve exact request context safely
Co-authored-by: VACInc <3279061+VACInc@users.noreply.github.com> Worked on by: - @VACInc
This commit is contained in:
@@ -143,49 +143,38 @@ export function createSummaryQualityRetentionPlan(
|
||||
summary: string,
|
||||
truncatedMarker: string,
|
||||
params: {
|
||||
auditSummary?: string;
|
||||
identifiers: string[];
|
||||
latestAsk: string | null;
|
||||
requiredAskContext?: string;
|
||||
identifierPolicy?: CompactionSummarizationInstructions["identifierPolicy"];
|
||||
},
|
||||
): SummaryQualityRetentionPlan | null {
|
||||
const contents = parseRequiredSummarySectionContents(summary);
|
||||
const requiredAskContext = params.requiredAskContext?.trim() ?? "";
|
||||
const bodyHasLatestAsk = hasAskOverlap(params.auditSummary ?? summary, params.latestAsk);
|
||||
const requiredContextBlock =
|
||||
bodyHasLatestAsk && requiredAskContext
|
||||
? `## Latest user request context\n${JSON.stringify(requiredAskContext)}`
|
||||
: "";
|
||||
const parsedSummary =
|
||||
requiredContextBlock && summary.startsWith(`${requiredContextBlock}\n\n`)
|
||||
? summary.slice(requiredContextBlock.length + 2)
|
||||
: summary;
|
||||
const contents = parseRequiredSummarySectionContents(parsedSummary);
|
||||
if (!contents) {
|
||||
return null;
|
||||
}
|
||||
const enforceIdentifiers = (params.identifierPolicy ?? "strict") === "strict";
|
||||
const requiredAskContext = params.requiredAskContext?.trim() ?? "";
|
||||
const auditedIdentifiers = enforceIdentifiers ? params.identifiers : [];
|
||||
const marker = truncatedMarker.trim();
|
||||
// Protected tails render after each section's optional content so the audit
|
||||
// facts survive regardless of how much model text the budget keeps.
|
||||
// Weak token overlap is sufficient for the final presence audit, but not for semantic
|
||||
// ownership: incidental words in Decisions/Constraints must not classify an omitted ask.
|
||||
// Prefer explicit pending ownership, then preserve an exact source context in the section
|
||||
// selected by the model. Otherwise fail safe to Pending user asks because no trustworthy
|
||||
// completion state exists.
|
||||
const exactAskSectionIndex = requiredAskContext
|
||||
? contents[PENDING_ASK_SECTION_INDEX]?.includes(requiredAskContext)
|
||||
? PENDING_ASK_SECTION_INDEX
|
||||
: contents.findIndex((content) => content.includes(requiredAskContext))
|
||||
: -1;
|
||||
const modelAskSectionIndex =
|
||||
exactAskSectionIndex >= 0
|
||||
? exactAskSectionIndex
|
||||
: hasAskOverlap(contents[PENDING_ASK_SECTION_INDEX] ?? "", params.latestAsk)
|
||||
? PENDING_ASK_SECTION_INDEX
|
||||
: -1;
|
||||
const protectedAskSectionIndex =
|
||||
modelAskSectionIndex >= 0 ? modelAskSectionIndex : PENDING_ASK_SECTION_INDEX;
|
||||
// Response termination cannot prove task completion. Preserve the summarizer's
|
||||
// section choice instead of moving the request between completed and pending state.
|
||||
const protectedAskContext = requiredAskContext
|
||||
? modelAskSectionIndex >= 0
|
||||
? requiredAskContext
|
||||
: `${LATEST_USER_REQUEST_CONTEXT_LABEL}\n${requiredAskContext}`
|
||||
: "";
|
||||
// Keep the model's completed/pending classification unchanged. When it reflects the ask,
|
||||
// preserve exact source text in a neutral prefix; when it omits the ask, fail safe to Pending.
|
||||
const protectedAskContext =
|
||||
!bodyHasLatestAsk && requiredAskContext
|
||||
? `${LATEST_USER_REQUEST_CONTEXT_LABEL}\n${JSON.stringify(requiredAskContext)}`
|
||||
: "";
|
||||
const protectedTails = REQUIRED_SUMMARY_SECTIONS.map((_, index) =>
|
||||
index === protectedAskSectionIndex
|
||||
index === PENDING_ASK_SECTION_INDEX
|
||||
? protectedAskContext
|
||||
: index === EXACT_IDENTIFIERS_SECTION_INDEX
|
||||
? auditedIdentifiers.join("\n")
|
||||
@@ -194,8 +183,11 @@ export function createSummaryQualityRetentionPlan(
|
||||
const bodyHasIdentifiers = auditedIdentifiers.every((identifier) =>
|
||||
summaryIncludesIdentifier(summary, identifier),
|
||||
);
|
||||
const bodyHasLatestAsk = hasAskOverlap(summary, params.latestAsk);
|
||||
const bodyHasRequiredAskContext = !requiredAskContext || modelAskSectionIndex >= 0;
|
||||
const bodyHasRequiredAskContext = !requiredAskContext
|
||||
? true
|
||||
: requiredContextBlock
|
||||
? summary.startsWith(requiredContextBlock)
|
||||
: contents[PENDING_ASK_SECTION_INDEX]?.includes(protectedAskContext);
|
||||
const renderSections = (sectionContents: string[]) =>
|
||||
REQUIRED_SUMMARY_SECTIONS.map((heading, index) => {
|
||||
const content = sectionContents[index];
|
||||
@@ -206,7 +198,7 @@ export function createSummaryQualityRetentionPlan(
|
||||
if (!tail) {
|
||||
return optional;
|
||||
}
|
||||
if (index === protectedAskSectionIndex && optional.includes(tail)) {
|
||||
if (index === PENDING_ASK_SECTION_INDEX && optional.includes(tail)) {
|
||||
return optional;
|
||||
}
|
||||
if (index === EXACT_IDENTIFIERS_SECTION_INDEX) {
|
||||
@@ -217,7 +209,7 @@ export function createSummaryQualityRetentionPlan(
|
||||
}
|
||||
const retainedOptional =
|
||||
index === PENDING_ASK_SECTION_INDEX &&
|
||||
modelAskSectionIndex < 0 &&
|
||||
protectedAskContext &&
|
||||
/^(?:none|none captured|no pending asks)[.!]?$/iu.test(optional)
|
||||
? ""
|
||||
: optional;
|
||||
@@ -229,6 +221,7 @@ export function createSummaryQualityRetentionPlan(
|
||||
(heading, index) => `${heading}\n\n${protectedTails[index] ?? ""}`,
|
||||
);
|
||||
const minimumSummary = [
|
||||
...(requiredContextBlock ? [requiredContextBlock] : []),
|
||||
...minimumBlocks.slice(0, QUALITY_PROTECTED_SECTION_START),
|
||||
marker,
|
||||
...minimumBlocks.slice(QUALITY_PROTECTED_SECTION_START),
|
||||
@@ -298,6 +291,7 @@ export function createSummaryQualityRetentionPlan(
|
||||
const blocks = renderSections(sectionContents);
|
||||
return {
|
||||
text: [
|
||||
...(requiredContextBlock ? [requiredContextBlock] : []),
|
||||
...blocks.slice(0, QUALITY_PROTECTED_SECTION_START),
|
||||
...(trimmed ? [marker] : []),
|
||||
...blocks.slice(QUALITY_PROTECTED_SECTION_START),
|
||||
@@ -427,6 +421,7 @@ function hasAskOverlap(summary: string, latestAsk: string | null): boolean {
|
||||
export function auditSummaryQuality(params: {
|
||||
summary: string;
|
||||
structuralSummary: string;
|
||||
sourceSummaries?: string[];
|
||||
identifiers: string[];
|
||||
latestAsk: string | null;
|
||||
identifierPolicy?: CompactionSummarizationInstructions["identifierPolicy"];
|
||||
@@ -437,6 +432,13 @@ export function auditSummaryQuality(params: {
|
||||
if (!lines.has(section)) {
|
||||
reasons.push(`missing_section:${section}`);
|
||||
}
|
||||
if (
|
||||
params.sourceSummaries?.some(
|
||||
(source) => normalizedSummaryLines(source).filter((line) => line === section).length > 1,
|
||||
)
|
||||
) {
|
||||
reasons.push(`duplicate_section:${section}`);
|
||||
}
|
||||
}
|
||||
const enforceIdentifiers = (params.identifierPolicy ?? "strict") === "strict";
|
||||
if (enforceIdentifiers) {
|
||||
@@ -450,15 +452,5 @@ export function auditSummaryQuality(params: {
|
||||
if (!hasAskOverlap(params.summary, params.latestAsk)) {
|
||||
reasons.push("latest_user_ask_not_reflected");
|
||||
}
|
||||
if (params.structuralSummary.includes(LATEST_USER_REQUEST_CONTEXT_LABEL)) {
|
||||
const contents = parseRequiredSummarySectionContents(params.structuralSummary);
|
||||
const pendingAsks = contents?.[PENDING_ASK_SECTION_INDEX] ?? "";
|
||||
if (
|
||||
!pendingAsks.includes(LATEST_USER_REQUEST_CONTEXT_LABEL) ||
|
||||
!hasAskOverlap(pendingAsks, params.latestAsk)
|
||||
) {
|
||||
reasons.push("latest_user_ask_context_not_pending");
|
||||
}
|
||||
}
|
||||
return { ok: reasons.length === 0, reasons };
|
||||
}
|
||||
|
||||
@@ -746,6 +746,107 @@ describe("compaction-safeguard summary budgets", () => {
|
||||
}),
|
||||
).toEqual({ ok: true, reasons: [] });
|
||||
});
|
||||
|
||||
it("preserves real sections when recompacting encoded heading-like source context", () => {
|
||||
const oldAsk = [
|
||||
"keep this template:",
|
||||
"## Decisions",
|
||||
"old decision",
|
||||
"## Open TODOs",
|
||||
"old todo",
|
||||
"## Constraints/Rules",
|
||||
"old rule",
|
||||
"## Pending user asks",
|
||||
"old ask",
|
||||
"## Exact identifiers",
|
||||
"old id",
|
||||
].join("\n");
|
||||
const latestAsk = "report the current deployment status";
|
||||
const body = [
|
||||
`## Latest user request context\n${JSON.stringify(oldAsk)}`,
|
||||
"## Decisions",
|
||||
"REAL DECISION",
|
||||
"## Open TODOs",
|
||||
"REAL TODO",
|
||||
"## Constraints/Rules",
|
||||
"REAL RULE",
|
||||
"## Pending user asks",
|
||||
latestAsk,
|
||||
"## Exact identifiers",
|
||||
"None.",
|
||||
].join("\n\n");
|
||||
const finalized = requireRecord(
|
||||
budgetCompactionSummary(body, "", 1_000, {
|
||||
auditSummary: body,
|
||||
identifiers: [],
|
||||
latestAsk,
|
||||
requiredAskContext: latestAsk,
|
||||
identifierPolicy: "strict",
|
||||
}),
|
||||
);
|
||||
const summary = String(finalized.summary);
|
||||
|
||||
expect(summary).toContain(`## Latest user request context\n${JSON.stringify(latestAsk)}`);
|
||||
expect(summary).toContain("REAL DECISION");
|
||||
expect(summary).toContain("REAL TODO");
|
||||
expect(summary).toContain("REAL RULE");
|
||||
});
|
||||
|
||||
it("preserves exact identifiers when recompacting encoded heading-like context", () => {
|
||||
const latestAsk = [
|
||||
"zephyr quasar template must survive:",
|
||||
"## Decisions",
|
||||
"alpha",
|
||||
"## Open TODOs",
|
||||
"beta",
|
||||
"## Constraints/Rules",
|
||||
"gamma",
|
||||
"## Pending user asks",
|
||||
"delta",
|
||||
"## Exact identifiers",
|
||||
"epsilon",
|
||||
].join("\n");
|
||||
const identifier = "REAL-OLD-ID-MUST-SURVIVE";
|
||||
const body = [
|
||||
"## Decisions",
|
||||
"No related decision.",
|
||||
"## Open TODOs",
|
||||
"None.",
|
||||
"## Constraints/Rules",
|
||||
"Preserve exact context.",
|
||||
"## Pending user asks",
|
||||
"None.",
|
||||
"## Exact identifiers",
|
||||
identifier,
|
||||
].join("\n");
|
||||
const first = requireRecord(
|
||||
budgetCompactionSummary(body, "", 1_000, {
|
||||
auditSummary: body,
|
||||
identifiers: [identifier],
|
||||
latestAsk,
|
||||
requiredAskContext: latestAsk,
|
||||
identifierPolicy: "strict",
|
||||
}),
|
||||
);
|
||||
expect(String(first.summary)).toContain(
|
||||
`## Latest user request context\n${JSON.stringify(latestAsk)}`,
|
||||
);
|
||||
|
||||
const second = requireRecord(
|
||||
budgetCompactionSummary(String(first.summary), "", 700, {
|
||||
auditSummary: String(first.summary),
|
||||
identifiers: [identifier],
|
||||
latestAsk,
|
||||
requiredAskContext: latestAsk,
|
||||
identifierPolicy: "strict",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(String(second.summary)).toContain(identifier);
|
||||
expect(String(second.summary)).toContain(
|
||||
`## Latest user request context\n${JSON.stringify(latestAsk)}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeAdaptiveChunkRatio", () => {
|
||||
@@ -1545,28 +1646,25 @@ describe("compaction-safeguard recent-turn preservation", () => {
|
||||
expect(quality.reasons).toContain("latest_user_ask_not_reflected");
|
||||
});
|
||||
|
||||
it("rejects deterministic source context outside pending asks", () => {
|
||||
it("does not apply an older pending fallback marker to the current latest ask", () => {
|
||||
const latestAsk = "report whether the deployment is ready";
|
||||
const quality = auditSummaryQuality({
|
||||
summary: [
|
||||
"## Decisions",
|
||||
`Latest user request context:\n${latestAsk}`,
|
||||
"## Open TODOs",
|
||||
"None.",
|
||||
"## Constraints/Rules",
|
||||
"Preserve exact context.",
|
||||
"## Pending user asks",
|
||||
"None.",
|
||||
"## Exact identifiers",
|
||||
"None.",
|
||||
].join("\n"),
|
||||
identifiers: [],
|
||||
latestAsk,
|
||||
});
|
||||
const summary = [
|
||||
`## Latest user request context\n${JSON.stringify(latestAsk)}`,
|
||||
"## Decisions",
|
||||
"The deployment readiness report was delivered.",
|
||||
"## Open TODOs",
|
||||
"None.",
|
||||
"## Constraints/Rules",
|
||||
"Preserve exact context.",
|
||||
"## Pending user asks",
|
||||
"Latest user request context:\narchive the previous release notes",
|
||||
"## Exact identifiers",
|
||||
"None.",
|
||||
].join("\n\n");
|
||||
|
||||
expect(quality).toEqual({
|
||||
ok: false,
|
||||
reasons: ["latest_user_ask_context_not_pending"],
|
||||
expect(auditSummaryQuality({ summary, identifiers: [], latestAsk })).toEqual({
|
||||
ok: true,
|
||||
reasons: [],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2474,7 +2572,7 @@ describe("compaction-safeguard recent-turn preservation", () => {
|
||||
const latestAsk = "report whether the deployment is ready";
|
||||
const generatedSummary = [
|
||||
"## Decisions",
|
||||
"The deployment is ready for inspection.",
|
||||
"Inspection remains blocked.",
|
||||
"## Open TODOs",
|
||||
"Check the deployment status.",
|
||||
"## Constraints/Rules",
|
||||
@@ -2504,7 +2602,9 @@ describe("compaction-safeguard recent-turn preservation", () => {
|
||||
const { result } = await runCompactionScenario({ sessionManager, event, apiKey: "test-key" });
|
||||
|
||||
const summary = expectCompactionResult(result).summary;
|
||||
expect(summary).toContain(`## Pending user asks\nLatest user request context:\n${latestAsk}`);
|
||||
expect(summary).toContain(
|
||||
`## Pending user asks\nLatest user request context:\n${JSON.stringify(latestAsk)}`,
|
||||
);
|
||||
expect(summary).not.toContain("## Pending user asks\nNone.");
|
||||
expect(summary).not.toContain(`## Decisions\nLatest user request context:\n${latestAsk}`);
|
||||
expect(auditSummaryQuality({ summary, identifiers: [], latestAsk })).toEqual({
|
||||
@@ -2515,6 +2615,44 @@ describe("compaction-safeguard recent-turn preservation", () => {
|
||||
expect(consumeCompactionSafeguardCancelReason(sessionManager)).toBeNull();
|
||||
});
|
||||
|
||||
it("restores exact source qualifiers when a fitting pending ask only overlaps", async () => {
|
||||
mockSummarizeInStages.mockReset();
|
||||
const latestAsk = "delete production only after verified backup";
|
||||
const generatedSummary = [
|
||||
"## Decisions",
|
||||
"Keep the cleanup workflow.",
|
||||
"## Open TODOs",
|
||||
"None.",
|
||||
"## Constraints/Rules",
|
||||
"Use normal safeguards.",
|
||||
"## Pending user asks",
|
||||
"Delete the backup.",
|
||||
"## Exact identifiers",
|
||||
"None.",
|
||||
].join("\n");
|
||||
mockSummarizeInStages.mockResolvedValue(summaryResult(generatedSummary));
|
||||
|
||||
const sessionManager = stubSessionManager();
|
||||
setCompactionSafeguardRuntime(sessionManager, {
|
||||
model: createAnthropicModelFixture(),
|
||||
recentTurnsPreserve: 0,
|
||||
qualityGuardEnabled: true,
|
||||
qualityGuardMaxRetries: 0,
|
||||
});
|
||||
const event = createCompactionEvent({ messageText: latestAsk, tokensBefore: 1_500 });
|
||||
(event.preparation as { settings?: { reserveTokens: number } }).settings = {
|
||||
reserveTokens: 4_000,
|
||||
};
|
||||
|
||||
const { result } = await runCompactionScenario({ sessionManager, event, apiKey: "test-key" });
|
||||
|
||||
const summary = expectCompactionResult(result).summary;
|
||||
expect(summary).toContain(`## Latest user request context\n${JSON.stringify(latestAsk)}`);
|
||||
expect(summary).toContain("## Pending user asks\nDelete the backup.");
|
||||
expect(mockSummarizeInStages).toHaveBeenCalledTimes(1);
|
||||
expect(consumeCompactionSafeguardCancelReason(sessionManager)).toBeNull();
|
||||
});
|
||||
|
||||
it("fails closed when audit-required tail sections cannot fit the artifact cap", async () => {
|
||||
mockSummarizeInStages.mockReset();
|
||||
const latestAsk = "preserve the pending deployment status";
|
||||
@@ -2652,7 +2790,10 @@ describe("compaction-safeguard recent-turn preservation", () => {
|
||||
|
||||
const { result } = await runCompactionScenario({ sessionManager, event, apiKey: "test-key" });
|
||||
|
||||
expect(expectCompactionResult(result).summary).toBe(validRetry);
|
||||
const finalSummary = expectCompactionResult(result).summary;
|
||||
expect(finalSummary).toContain("## Pending user asks");
|
||||
expect(finalSummary).toContain(`${latestAsk} ${identifier}`);
|
||||
expect(finalSummary).toContain(`## Exact identifiers\n${identifier}`);
|
||||
expect(mockSummarizeInStages).toHaveBeenCalledTimes(2);
|
||||
const retry = requireRecord(mockCallArg(mockSummarizeInStages, 1));
|
||||
expect(retry.customInstructions).toContain("Quality check feedback");
|
||||
@@ -2713,6 +2854,9 @@ describe("compaction-safeguard recent-turn preservation", () => {
|
||||
];
|
||||
}).flat();
|
||||
const event = createCompactionEvent({ messageText: latestAsk, tokensBefore: 90_000 });
|
||||
(event.preparation as { settings?: { reserveTokens: number } }).settings = {
|
||||
reserveTokens: 4_000,
|
||||
};
|
||||
event.preparation.messagesToSummarize = [
|
||||
{ role: "user", content: latestAsk, timestamp: 1 },
|
||||
...toolChain,
|
||||
@@ -2787,6 +2931,152 @@ describe("compaction-safeguard recent-turn preservation", () => {
|
||||
expect(mockSummarizeInStages).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps exact source context with a model-classified completed paraphrase", async () => {
|
||||
mockSummarizeInStages.mockReset();
|
||||
const latestAsk = "combine the provider boxes into one completed artifact";
|
||||
const summary = [
|
||||
"## Decisions",
|
||||
"The provider boxes were combined into the final artifact.",
|
||||
"## Open TODOs",
|
||||
"None.",
|
||||
"## Constraints/Rules",
|
||||
"Preserve exact context.",
|
||||
"## Pending user asks",
|
||||
"None.",
|
||||
"## Exact identifiers",
|
||||
"None.",
|
||||
].join("\n");
|
||||
mockSummarizeInStages.mockResolvedValue(summaryResult(summary));
|
||||
|
||||
const sessionManager = stubSessionManager();
|
||||
setCompactionSafeguardRuntime(sessionManager, {
|
||||
model: createAnthropicModelFixture(),
|
||||
recentTurnsPreserve: 0,
|
||||
qualityGuardEnabled: true,
|
||||
qualityGuardMaxRetries: 0,
|
||||
});
|
||||
const event = createCompactionEvent({ messageText: latestAsk, tokensBefore: 90_000 });
|
||||
(event.preparation as { settings?: { reserveTokens: number } }).settings = {
|
||||
reserveTokens: 4_000,
|
||||
};
|
||||
|
||||
const { result } = await runCompactionScenario({ sessionManager, event, apiKey: "test-key" });
|
||||
|
||||
const finalSummary = expectCompactionResult(result).summary;
|
||||
expect(finalSummary).toContain(`## Latest user request context\n${JSON.stringify(latestAsk)}`);
|
||||
expect(finalSummary).toContain(`## Decisions\n${summary.split("\n")[1]}`);
|
||||
expect(finalSummary).toContain("## Pending user asks\nNone.");
|
||||
expect(mockSummarizeInStages).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps heading-like exact source context outside the structured model sections", async () => {
|
||||
mockSummarizeInStages.mockReset();
|
||||
const latestAsk = [
|
||||
"keep these headings verbatim:",
|
||||
"Latest user request context:",
|
||||
"## Decisions",
|
||||
"alpha",
|
||||
"## Open TODOs",
|
||||
"beta",
|
||||
"## Constraints/Rules",
|
||||
"gamma",
|
||||
"## Pending user asks",
|
||||
"delta",
|
||||
"## Exact identifiers",
|
||||
"epsilon",
|
||||
].join("\n");
|
||||
const summary = [
|
||||
"## Decisions",
|
||||
"Keep the requested headings.",
|
||||
"## Open TODOs",
|
||||
"None.",
|
||||
"## Constraints/Rules",
|
||||
"Preserve exact context.",
|
||||
"## Pending user asks",
|
||||
"Keep the headings verbatim.",
|
||||
"## Exact identifiers",
|
||||
"None.",
|
||||
].join("\n");
|
||||
mockSummarizeInStages.mockResolvedValue(summaryResult(summary));
|
||||
|
||||
const sessionManager = stubSessionManager();
|
||||
setCompactionSafeguardRuntime(sessionManager, {
|
||||
model: createAnthropicModelFixture(),
|
||||
recentTurnsPreserve: 0,
|
||||
qualityGuardEnabled: true,
|
||||
qualityGuardMaxRetries: 0,
|
||||
});
|
||||
const event = createCompactionEvent({ messageText: latestAsk, tokensBefore: 90_000 });
|
||||
(event.preparation as { settings?: { reserveTokens: number } }).settings = {
|
||||
reserveTokens: 4_000,
|
||||
};
|
||||
|
||||
const { result } = await runCompactionScenario({ sessionManager, event, apiKey: "test-key" });
|
||||
|
||||
const finalSummary = expectCompactionResult(result).summary;
|
||||
expect(finalSummary).toContain(
|
||||
`## Latest user request context\n${JSON.stringify(latestAsk)}\n\n## Decisions`,
|
||||
);
|
||||
expect(finalSummary).toContain("## Pending user asks\nKeep the headings verbatim.");
|
||||
});
|
||||
|
||||
it("retries model output that copies a heading-template ask into structured sections", async () => {
|
||||
mockSummarizeInStages.mockReset();
|
||||
const latestAsk = [
|
||||
"zephyr quasar template:",
|
||||
"## Decisions",
|
||||
"alpha",
|
||||
"## Open TODOs",
|
||||
"beta",
|
||||
"## Constraints/Rules",
|
||||
"gamma",
|
||||
"## Pending user asks",
|
||||
"delta",
|
||||
"## Exact identifiers",
|
||||
"epsilon",
|
||||
].join("\n");
|
||||
const structuredSummary = (decision: string, pending: string) =>
|
||||
[
|
||||
"## Decisions",
|
||||
decision,
|
||||
"## Open TODOs",
|
||||
"None.",
|
||||
"## Constraints/Rules",
|
||||
"Preserve exact context.",
|
||||
"## Pending user asks",
|
||||
pending,
|
||||
"## Exact identifiers",
|
||||
"None.",
|
||||
].join("\n");
|
||||
mockSummarizeInStages
|
||||
.mockResolvedValueOnce(summaryResult(structuredSummary(latestAsk, "None.")))
|
||||
.mockResolvedValueOnce(
|
||||
summaryResult(
|
||||
structuredSummary("No decision yet.", "Track the zephyr quasar template request."),
|
||||
),
|
||||
);
|
||||
|
||||
const sessionManager = stubSessionManager();
|
||||
setCompactionSafeguardRuntime(sessionManager, {
|
||||
model: createAnthropicModelFixture(),
|
||||
recentTurnsPreserve: 0,
|
||||
qualityGuardEnabled: true,
|
||||
qualityGuardMaxRetries: 1,
|
||||
});
|
||||
const event = createCompactionEvent({ messageText: latestAsk, tokensBefore: 90_000 });
|
||||
(event.preparation as { settings?: { reserveTokens: number } }).settings = {
|
||||
reserveTokens: 4_000,
|
||||
};
|
||||
|
||||
const { result } = await runCompactionScenario({ sessionManager, event, apiKey: "test-key" });
|
||||
|
||||
const finalSummary = expectCompactionResult(result).summary;
|
||||
expect(mockSummarizeInStages).toHaveBeenCalledTimes(2);
|
||||
expect(finalSummary).toContain(`## Latest user request context\n${JSON.stringify(latestAsk)}`);
|
||||
const retry = requireRecord(mockCallArg(mockSummarizeInStages, 1));
|
||||
expect(retry.customInstructions).toContain("duplicate_section");
|
||||
});
|
||||
|
||||
it("propagates caller abort during corrective generation", async () => {
|
||||
mockSummarizeInStages.mockReset();
|
||||
const controller = new AbortController();
|
||||
@@ -2827,14 +3117,14 @@ describe("compaction-safeguard recent-turn preservation", () => {
|
||||
expect(consumeCompactionSafeguardCancelReason(sessionManager)).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the split-turn summary's model-classified pending state", async () => {
|
||||
it("keeps the split-turn model's pending state while retaining exact source context", async () => {
|
||||
mockSummarizeInStages.mockReset();
|
||||
const latestAsk = "combine the provider boxes into one completed artifact";
|
||||
const identifier = "/tmp/pr130620/live/marker";
|
||||
const structuredSummary = (pendingAsk: string) =>
|
||||
[
|
||||
"## Decisions",
|
||||
`${latestAsk} was completed.`,
|
||||
"Keep the provider-box work active.",
|
||||
"## Open TODOs",
|
||||
"None.",
|
||||
"## Constraints/Rules",
|
||||
@@ -2856,7 +3146,12 @@ describe("compaction-safeguard recent-turn preservation", () => {
|
||||
preparation: {
|
||||
messagesToSummarize: [] as AgentMessage[],
|
||||
turnPrefixMessages: [
|
||||
{ role: "user", content: `${latestAsk} and preserve ${identifier}`, timestamp: 1 },
|
||||
{ role: "user", content: latestAsk, timestamp: 1 },
|
||||
castAgentMessage({
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: `Preserve ${identifier}.` }],
|
||||
timestamp: 2,
|
||||
}),
|
||||
] as AgentMessage[],
|
||||
firstKeptEntryId: "entry-1",
|
||||
tokensBefore: 90_000,
|
||||
@@ -2872,9 +3167,56 @@ describe("compaction-safeguard recent-turn preservation", () => {
|
||||
|
||||
const finalSummary = expectCompactionResult(result).summary;
|
||||
expect(mockSummarizeInStages).toHaveBeenCalledTimes(1);
|
||||
expect(finalSummary).toContain(`## Latest user request context\n${JSON.stringify(latestAsk)}`);
|
||||
expect(finalSummary).toContain(`## Pending user asks\n${latestAsk}`);
|
||||
});
|
||||
|
||||
it("does not revive a split-turn request the model classified as completed", async () => {
|
||||
mockSummarizeInStages.mockReset();
|
||||
const latestAsk = "combine the provider boxes into one completed artifact";
|
||||
const summary = [
|
||||
"## Decisions",
|
||||
"The provider boxes were combined into the final artifact.",
|
||||
"## Open TODOs",
|
||||
"None.",
|
||||
"## Constraints/Rules",
|
||||
"Preserve exact context.",
|
||||
"## Pending user asks",
|
||||
"None.",
|
||||
"## Exact identifiers",
|
||||
"None.",
|
||||
].join("\n");
|
||||
mockSummarizeInStages.mockResolvedValue(summaryResult(summary));
|
||||
|
||||
const sessionManager = stubSessionManager();
|
||||
setCompactionSafeguardRuntime(sessionManager, {
|
||||
model: createAnthropicModelFixture(),
|
||||
qualityGuardEnabled: true,
|
||||
qualityGuardMaxRetries: 0,
|
||||
});
|
||||
const event = {
|
||||
preparation: {
|
||||
messagesToSummarize: [] as AgentMessage[],
|
||||
turnPrefixMessages: [{ role: "user", content: latestAsk, timestamp: 1 }] as AgentMessage[],
|
||||
firstKeptEntryId: "entry-1",
|
||||
tokensBefore: 90_000,
|
||||
fileOps: { read: [], edited: [], written: [] },
|
||||
settings: { reserveTokens: 4_000 },
|
||||
isSplitTurn: true,
|
||||
},
|
||||
customInstructions: "",
|
||||
signal: new AbortController().signal,
|
||||
};
|
||||
|
||||
const { result } = await runCompactionScenario({ sessionManager, event, apiKey: "test-key" });
|
||||
|
||||
const finalSummary = expectCompactionResult(result).summary;
|
||||
expect(finalSummary).toContain(`## Latest user request context\n${JSON.stringify(latestAsk)}`);
|
||||
expect(finalSummary).toContain("## Pending user asks\nNone.");
|
||||
expect(finalSummary).not.toContain(`## Pending user asks\n${latestAsk}`);
|
||||
expect(mockSummarizeInStages).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("audits all-preserved fallback output against pre-partition source facts", async () => {
|
||||
mockSummarizeInStages.mockReset();
|
||||
const latestAsk = "report deployment status";
|
||||
|
||||
@@ -216,6 +216,7 @@ type CompactionSuffix = {
|
||||
};
|
||||
|
||||
type SummaryQualityRetention = {
|
||||
auditSummary?: string;
|
||||
identifiers: string[];
|
||||
latestAsk: string | null;
|
||||
requiredAskContext: string;
|
||||
@@ -1270,6 +1271,7 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void {
|
||||
|
||||
for (let attempt = 0; attempt < totalAttempts; attempt += 1) {
|
||||
let splitTurnSectionLocal = "";
|
||||
let splitTurnSummaryLocal = "";
|
||||
let historySummary = "";
|
||||
const producerLosses = new Set<CompactionLoss>();
|
||||
try {
|
||||
@@ -1292,6 +1294,7 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void {
|
||||
customInstructions: `${TURN_PREFIX_INSTRUCTIONS}\n\nAdditional requirements:\n\n${currentInstructions}`,
|
||||
previousSummary: undefined,
|
||||
});
|
||||
splitTurnSummaryLocal = prefixSummary;
|
||||
splitTurnSectionLocal = formatGeneratedSplitTurnSection(prefixSummary, () => {
|
||||
producerLosses.add("split-turn-tail");
|
||||
});
|
||||
@@ -1330,6 +1333,7 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void {
|
||||
producerLosses,
|
||||
qualityGuardEnabled
|
||||
? {
|
||||
auditSummary: unbudgetedSummary,
|
||||
identifiers,
|
||||
latestAsk: latestUserAsk,
|
||||
requiredAskContext: formatRequiredAskContext(latestUserAsk ?? ""),
|
||||
@@ -1358,6 +1362,7 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void {
|
||||
const quality = auditSummaryQuality({
|
||||
summary: finalized.summary,
|
||||
structuralSummary: finalized.structuralSummary,
|
||||
sourceSummaries: [historySummary, splitTurnSummaryLocal].filter(Boolean),
|
||||
identifiers,
|
||||
latestAsk: latestUserAsk,
|
||||
identifierPolicy,
|
||||
|
||||
Reference in New Issue
Block a user