fix(durable): fail a run whose blocking compaction failed; keep compaction failures visible

A generation waiting on a blocking threshold compaction sent its request
whatever the outcome. When summarization failed, the context was still above
the blocking threshold: a session at 996K of a 1M window got an empty length
answer on every turn and nothing showed the error. The generation now fails
with model_error (Compaction failed: <error>) when its compaction failed,
faulted, was orphaned, or was aborted directly; a compaction that found
nothing to compact or was declined still lets the request go ahead.

pi.live.compactionFailure keeps the newest failure (task, reason, error)
until a compaction produces a summary. The agent events' snapshot carries it,
and compaction_end carries error for a failed compaction.
This commit is contained in:
Mario Zechner 2026-10-11 14:35:25 +02:00
parent 15e7bdc14c
commit 89e5c2c075
9 changed files with 215 additions and 47 deletions

View file

@ -22,9 +22,12 @@
- `TaskRuntime.abortOwned(id, context)`: abort a task this task owns and wait until it is terminal.
- `TaskRuntime.ownedTasks(context)`: the live tasks a task owns directly, whichever invocation created them, as copies. A tool call settling finds the nested calls it left running with it.
- `read` returns images as one image block instead of an `unsupported_image` error: PNG, JPEG, GIF, and WebP within the model's image limits (by default 2000x2000 pixels and 4.5 MB of base64) as they are, or, with `createCodingTools({ images })`, oriented, converted, and shrunk to fit. `@earendil-works/pi-durable/images` provides a Photon (WebAssembly) image processor, with `/images/node` and `/images/cloudflare` loaders; `/images/node` prepares images in a worker thread, so a large photo never blocks the event loop, and the thread exits after 30 s without images; nothing loads it unless imported. A model without image input gets a diagnostic saying it sees a placeholder.
- `pi.live.compactionFailure` keeps the newest compaction failure (task, reason, error) until a compaction produces a summary; the agent events' snapshot carries it as `compactionFailure`, and `compaction_end` carries `error` for a compaction that failed. A failed background or manual compaction was invisible once its status was removed.
### Fixed
- A generation whose blocking threshold compaction failed, faulted, was orphaned, or was aborted directly now fails with `model_error` (`Compaction failed: <error>`) instead of sending its request anyway. The context was still above the blocking threshold, so the request at best left no room for the answer: a session at 996K of a 1M window got an empty `length` answer on every turn, with no visible error.
- Compaction no longer includes thinking in the summarization request. Replayed thinking is often not in the live context, so a request that included it could be larger than the context it summarized and fail with "prompt is too long" on every attempt.
- A throwing `onReport` no longer replaces the error being reported or becomes an unhandled rejection, a throwing `now()` is reported once and `Date.now` used, and a watch listener's error is reported as well as ending the watch with `listener_error`.
- A task whose fault write failed no longer runs again in the same process.

View file

@ -457,7 +457,9 @@ settings: {
When a provider rejects a request because the context is too long, generation compacts and retries once. A summary that would cut before the start of the current context settles as `stale` when it is placed, so when several are in flight, the furthest cut stays in effect. Summarization spend counts in `pi.usage`. A `beforeCompact` hook on `CompactionTask` can decline or supply its own summary.
Running compactions are listed in `docs["pi.live"].compactions` with their reason, attempt, and retry backoff. The agent events add `compaction_start` and `compaction_end`, and a `compactions` field in the snapshot.
When a compaction that a generation waits for at the blocking threshold fails, the generation fails too, without sending a request: its inputs settle `unanswered` with `model_error` and detail `Compaction failed: <error>`. One that finds nothing to compact or is declined lets the request go ahead.
Running compactions are listed in `docs["pi.live"].compactions` with their reason, attempt, and retry backoff. The newest failure stays in `docs["pi.live"].compactionFailure` (task, reason, error) until a compaction produces a summary. The agent events add `compaction_start` and `compaction_end` (with `error` when the compaction failed), and `compactions` and `compactionFailure` fields in the snapshot.
## Agent Events (Experimental)

View file

@ -3660,6 +3660,18 @@ type LiveState = {
}[];
/** Live compaction tasks in task ID order; absent when none (section 8.7). */
compactions?: CompactionStatus[];
/**
* The newest compaction failure since a compaction last produced a summary (section 8.7), so a client can show it
* after the task ended.
*/
compactionFailure?: CompactionFailure;
};
type CompactionFailure = {
taskId: TaskId<CompactionResult>;
reason: CompactionReason;
/** The failed or faulted outcome's error message, or why the scheduler orphaned the task. */
error: string;
};
type CompactionStatus = {
@ -3738,6 +3750,8 @@ type GenerationCheckpoint =
compacted?: TaskId<CompactionResult>;
/** Error text of the overflow that started `compacted`; checked once when `prepare` resumes. */
overflow?: string;
/** Set when the blocking threshold started `compacted`; its outcome is checked once when `prepare` resumes. */
threshold?: true;
}
| {
phase: "request";
@ -3795,8 +3809,15 @@ The run's inputs live in `pi.live.run`, not in the task input.
- Above `contextWindow - reserveTokens`, the blocking threshold: instead of
appending, one commit creates a compaction owned by the generation with reason
`threshold`, adds its status, and commits `waiting` on it with `allSettled`
and checkpoint `{ phase: "prepare", attempt, compacted }`. Whatever its
outcome, `prepare` then runs again and sends the request.
and checkpoint `{ phase: "prepare", attempt, compacted, threshold: true }`.
When `prepare` resumes, it reads the compaction's outcome once. If the
compaction completed, with or without a summary (nothing to compact, or
declined), `prepare` continues and sends the request. If it failed,
faulted, was orphaned, or was aborted directly, the generation fails with
`model_error` without a request, and its inputs settle `unanswered` with
detail `Compaction failed: <error>` (`Compaction was aborted` for an
abort). A failed compaction leaves a context above the blocking threshold,
where a request would at best leave no room for the answer.
- Above `contextWindow - reserveTokens - backgroundTokens` with
`backgroundTokens > 0` and no compaction status in `pi.live`: the commit that
moves to `request` also creates a background compaction, owned by the
@ -4146,8 +4167,8 @@ the conversation.
where `wrapped` is `"The conversation history before this point was compacted
into the following summary:\n\n<summary>\n"`, the summary, and
`"\n</summary>"`. The commit that places it also removes the task's status and
commits the task's `completed` outcome:
`"\n</summary>"`. The commit that places it also removes the task's status,
removes `pi.live.compactionFailure`, and commits the task's `completed` outcome:
- A blocking compaction appends the entry directly and completes with
`{ entryId }`. Its generation holds the run and waits, so nothing else writes
@ -4173,10 +4194,18 @@ aborted with its generation, before the generation's abort handler runs
(section 5.5). A summary that is already queued is a write submission:
conversation abort keeps it, and `Submission.abort()` withdraws it.
When a blocking compaction ends without an `entryId`, because it found nothing
to compact, was declined, failed, faulted, or was aborted directly, a threshold
generation sends its request anyway and an overflow generation fails with
`model_error` (section 8.3).
**Failure.** The commit that fails a compaction (`model_error`, `no_model`)
removes its status and sets `pi.live.compactionFailure` to its task, reason,
and error message, replacing an older failure; the Harness cleanup does the
same for a compaction the scheduler faults or orphans, with the fault's
message or the orphan reason. Only a compaction that produces a summary
removes it; one that is aborted or finds nothing to compact leaves it.
When a blocking compaction ends without an `entryId`, an overflow generation
fails with `model_error`. A threshold generation sends its request anyway
when the compaction completed (it found nothing to compact or was declined),
and fails with `model_error` when it failed, faulted, was orphaned, or was
aborted directly (section 8.3).
## 9. Document observation and Chord
@ -4424,6 +4453,8 @@ type AgentEvent =
nestedTools: readonly NestedToolSlot[];
/** `pi.live.compactions` (section 8.2). */
compactions: readonly CompactionStatus[];
/** `pi.live.compactionFailure` (section 8.2). */
compactionFailure?: CompactionFailure;
inbox: readonly { id: SubmissionId; mode: InboxItem["mode"] }[];
/** `pi.agent`; `{}` when absent. */
agent: AgentState;
@ -4457,7 +4488,7 @@ type AgentEvent =
| { type: "task_failed"; taskId: TaskId; kind: string; message: string }
| { type: "compaction_start"; taskId: TaskId; reason: CompactionReason; blocking: boolean }
/** The task's receipt tells whether it produced a summary; the summary entry has its own events. */
| { type: "compaction_end"; taskId: TaskId; reason: CompactionReason };
| { type: "compaction_end"; taskId: TaskId; reason: CompactionReason; error?: string };
/** One change to the in-flight assistant message, relative to that message. */
type MessageChange =
@ -4531,8 +4562,9 @@ Events derive from committed changes:
gains or drops `retry`, or gains `deferred` or moves its `pollAt`.
- `task_failed`: a task of the conversation settles `faulted` or `orphaned`.
- `compaction_start`/`compaction_end`: a status appears in or disappears from
`pi.live.compactions`. A retry backoff shows only in the snapshot's
`compactions`. A queued summary is placed later with its own
`pi.live.compactions`. `compaction_end` carries `error` when the same commit
recorded the task in `pi.live.compactionFailure`. A retry backoff shows only
in the snapshot's `compactions`. A queued summary is placed later with its own
`message_start`/`message_end` and `submission` events.
One commit produces one batch, in this order: `tool_execution_start`,

View file

@ -15,7 +15,14 @@ import type {
Tx,
} from "../types.ts";
import { orderToolResults } from "./context.ts";
import { addCompactionStatus, compactionStatus, LiveDoc, type LiveState, removeCompactionStatus } from "./live.ts";
import {
addCompactionStatus,
compactionStatus,
failCompactionStatus,
LiveDoc,
type LiveState,
removeCompactionStatus,
} from "./live.ts";
import { ensureProviderSessionId } from "./provider.ts";
import { admitSubmission } from "./submissions.ts";
import type {
@ -197,8 +204,8 @@ export const CompactionTask = defineTask<CompactionInput, CompactionCheckpoint,
if (status !== undefined) status.retry = { at: until, error: message.errorMessage ?? "" };
return { status: "running", checkpoint: { phase: "retry", ...request, until } };
}
removeCompactionStatus(live, runtime.taskId);
const text = summaryFailure(message);
failCompactionStatus(live, runtime.taskId, text);
return {
status: "terminal",
outcome: { status: "failed", error: { message: text, detail: { reason: "model_error" } } },
@ -416,6 +423,7 @@ async function placeSummary(
summary: string,
): Promise<Next> {
removeCompactionStatus(live, runtime.taskId);
delete live.compactionFailure;
const text = `${SUMMARY_PREFIX}${summary}${SUMMARY_SUFFIX}`;
const entry = {
kind: CompactionEntry.kind,
@ -450,7 +458,7 @@ async function failNoModel(runtime: Runtime, ref: ModelRef | undefined, context:
const message =
ref === undefined ? "No model is configured" : `Model ${ref.provider}/${ref.modelId} is not available`;
await runtime.commit(async (tx) => {
removeCompactionStatus(await tx.doc(LiveDoc, runtime.conversationId), runtime.taskId);
failCompactionStatus(await tx.doc(LiveDoc, runtime.conversationId), runtime.taskId, message);
return { status: "terminal", outcome: { status: "failed", error: { message, detail: { reason: "no_model" } } } };
}, context);
}

View file

@ -15,7 +15,7 @@ import type {
} from "../types.ts";
import { AgentDoc } from "./agent.ts";
import type { InboxItem, InboxState } from "./inbox.ts";
import type { CompactionStatus, LiveState, NestedToolSlot, ToolSlot } from "./live.ts";
import type { CompactionFailure, CompactionStatus, LiveState, NestedToolSlot, ToolSlot } from "./live.ts";
import { NestedCallDoc, type NestedCallState } from "./tool.ts";
import type { AgentState, CompactionReason, Harness, NestedToolExecutionResult, ToolDiagnostic } from "./types.ts";
import { UsageDoc, type UsageState } from "./usage.ts";
@ -49,6 +49,8 @@ export type SnapshotEvent = {
nestedTools: readonly NestedToolSlot[];
/** `pi.live.compactions`: live compactions with their attempt and retry backoff. */
compactions: readonly CompactionStatus[];
/** `pi.live.compactionFailure`: the newest compaction failure since a compaction last produced a summary. */
compactionFailure?: CompactionFailure;
inbox: readonly QueuedItem[];
/** `pi.agent`; `{}` when absent. */
agent: AgentState;
@ -90,8 +92,11 @@ export type AgentEvent =
| { type: "usage_changed"; usage: UsageState }
| { type: "task_failed"; taskId: TaskId; kind: string; message: string }
| { type: "compaction_start"; taskId: TaskId; reason: CompactionReason; blocking: boolean }
/** The task's receipt tells whether it produced a summary; the summary entry has its own events. */
| { type: "compaction_end"; taskId: TaskId; reason: CompactionReason };
/**
* The task's receipt tells whether it produced a summary; the summary entry has its own events. `error` is set when
* the compaction failed, faulted, or was orphaned, as in `pi.live.compactionFailure`.
*/
| { type: "compaction_end"; taskId: TaskId; reason: CompactionReason; error?: string };
/**
* Which call a tool event is about. `toolCallId` is the call's ID in the transcript and in tool events: the provider's
@ -143,6 +148,7 @@ function snapshotOf(view: ConversationView): SnapshotEvent {
tools: live.tools ?? [],
nestedTools: live.nestedTools ?? [],
compactions: live.compactions ?? [],
...(live.compactionFailure === undefined ? {} : { compactionFailure: live.compactionFailure }),
inbox: queued(inbox),
agent: agent ?? AgentDoc.definition.initial(),
usage: usage ?? UsageDoc.definition.initial(),
@ -360,9 +366,11 @@ function translate(
// Compaction ends, task failures, then turn and run ends.
const compactionsBefore = was.live.compactions ?? [];
const compactions = now.live.compactions ?? [];
const failure = now.live.compactionFailure;
for (const { taskId, reason } of compactionsBefore) {
if (!compactions.some((status) => status.taskId === taskId)) {
events.push({ type: "compaction_end", taskId, reason });
const error = failure?.taskId === taskId ? failure.error : undefined;
events.push({ type: "compaction_end", taskId, reason, ...(error === undefined ? {} : { error }) });
}
}
// A generation's turn ends when its outcome is committed: at a `completing` hold or at terminal, whichever comes

View file

@ -21,6 +21,7 @@ import type {
NextTaskState,
SubmissionId,
TaskId,
TaskOutcome,
TaskRuntime,
Tx,
TypedEntry,
@ -56,6 +57,8 @@ export type GenerationCheckpoint =
compacted?: TaskId<CompactionResult>;
/** Error text of the overflow that started `compacted`; checked once when `prepare` resumes. */
overflow?: string;
/** Set when the blocking threshold started `compacted`; its outcome is checked once when `prepare` resumes. */
threshold?: true;
}
| {
phase: "request";
@ -130,13 +133,21 @@ export const GenerationTask = defineTask<GenerationInput, GenerationCheckpoint,
const { model, thinkingLevel } = agent;
const resolved = model === undefined ? undefined : runtime.models.getModel(model.provider, model.modelId);
if (model === undefined || resolved === undefined) return failNoModel(runtime, model, context);
const { attempt, compacted, overflow } = task.state.checkpoint;
const { attempt, compacted, overflow, threshold: blocked } = task.state.checkpoint;
if (compacted !== undefined && overflow !== undefined) {
const [outcome] = await runtime.outcomes([compacted], context);
if (outcome?.status !== "completed" || outcome.result.entryId === undefined) {
return failModelError(runtime, overflow, context);
}
}
// A threshold compaction that found nothing to compact or was declined leaves a request that may still fit. One
// that failed left a context it judged too large: sending it would at best leave no room for the answer.
if (compacted !== undefined && blocked === true) {
const [outcome] = await runtime.outcomes([compacted], context);
if (outcome !== undefined && outcome.status !== "completed") {
return failModelError(runtime, compactionFailure(outcome), context);
}
}
const view = await runtime.context(conversationId, context);
const shown = replaySections(view.messages);
const report = (error: unknown) => runtime.report(error);
@ -158,7 +169,7 @@ export const GenerationTask = defineTask<GenerationInput, GenerationCheckpoint,
// Compact first and prepare again; the transcript is unchanged until the compaction appends.
await runtime.commit(async (tx): Promise<Next> => {
const child = await createCompaction(tx, conversationId, { reason: "threshold" }, runtime.taskId);
const checkpoint = { phase: "prepare", attempt, compacted: child } as const;
const checkpoint = { phase: "prepare", attempt, compacted: child, threshold: true } as const;
return { status: "waiting", checkpoint, on: [child], policy: "allSettled" };
}, context);
return;
@ -327,6 +338,19 @@ function thresholdCompaction(
return over;
}
/** Error text for a generation whose blocking compaction ended without completing. */
function compactionFailure(outcome: Exclude<TaskOutcome<CompactionResult>, { status: "completed" }>): string {
switch (outcome.status) {
case "failed":
case "faulted":
return `Compaction failed: ${outcome.error.message}`;
case "orphaned":
return `Compaction failed: ${outcome.reason}`;
case "aborted":
return "Compaction was aborted";
}
}
/** Settle the run's inputs `unanswered` with `model_error` and fail with `text`. */
async function failModelError(runtime: Runtime, text: string, context: Context): Promise<void> {
await runtime.commit(async (tx) => {

View file

@ -70,6 +70,14 @@ export type CompactionStatus = {
retry?: { at: number; error: string };
};
/** The newest compaction that failed since a compaction last produced a summary (spec §8.7). */
export type CompactionFailure = {
taskId: TaskId<CompactionResult>;
reason: CompactionReason;
/** The failed or faulted outcome's error message, or why the scheduler orphaned the task. */
error: string;
};
/** Built-in live conversation state: run control and presentation of the current generation and tool round. */
export type LiveState = {
/** Run control: the task that settles the run's inputs, and those inputs; present exactly while busy. */
@ -93,6 +101,11 @@ export type LiveState = {
nestedTools?: NestedToolSlot[];
/** Live compaction tasks in task ID order; absent when none. */
compactions?: CompactionStatus[];
/**
* The newest compaction failure, so a client can show it after the task ended. A compaction that produces a summary
* removes it; one that is aborted or finds nothing to compact leaves it.
*/
compactionFailure?: CompactionFailure;
};
export const LiveDoc = defineDoc<LiveState>({
@ -152,6 +165,13 @@ export function removeCompactionStatus(live: Draft<LiveState>, taskId: TaskId):
if (statuses.length === 0) delete live.compactions;
}
/** Remove the status of compaction task `taskId` and record that it failed with `error`. */
export function failCompactionStatus(live: Draft<LiveState>, taskId: TaskId, error: string): void {
const status = compactionStatus(live, taskId);
if (status !== undefined) live.compactionFailure = { taskId: status.taskId, reason: status.reason, error };
removeCompactionStatus(live, taskId);
}
/**
* The slot of tool task `taskId`, model-issued or nested, if `pi.live` still lists it. Nested slots are found by binary
* search: a tool may make thousands of nested calls, each of which looks up its slot in several commits, and every
@ -207,7 +227,7 @@ export function clearProgress(slot: Draft<SlotProgress>): void {
/**
* Harness cleanup for a terminal outcome the scheduler writes itself (`faulted` or `orphaned`). A run task ends its
* run; a tool task's slot is marked done without an entry, and context derivation synthesizes the missing result; a
* compaction task's status is removed.
* compaction task's status is removed and its failure recorded.
* Ignores other kinds so it never creates `pi.live` elsewhere. The scheduler calls this without knowing task kinds;
* the Harness passes it in (spec §5.4).
* REMINDER: a committed generation partial becomes an aborted assistant entry here, exactly as in the generation abort
@ -229,7 +249,8 @@ export async function settleSchedulerOutcome(
return;
}
if (record.kind === COMPACTION_TASK_KIND) {
removeCompactionStatus(await tx.doc(LiveDoc, record.conversationId), record.id);
const error = outcome.status === "faulted" ? outcome.error.message : outcome.reason;
failCompactionStatus(await tx.doc(LiveDoc, record.conversationId), record.id, error);
return;
}
if (!RUN_TASK_KINDS.has(record.kind)) return;

View file

@ -40,6 +40,7 @@ export {
export { Harness } from "./harness/harness.ts";
export { InboxDoc, type InboxItem, type InboxState } from "./harness/inbox.ts";
export {
type CompactionFailure,
type CompactionStatus,
LiveDoc,
type LiveState,

View file

@ -1004,6 +1004,43 @@ describe("background threshold compaction", () => {
await chat.harness.close(context);
});
it("records a failure in pi.live, the snapshot, and compaction_end until a summary is produced", async () => {
const chat = await open({ contextWindow: 2000 });
await history(chat);
chat.setup.settings.compaction = BACKGROUND;
const events: AgentEvent[] = [];
const stream = await watchEvents(chat.harness, chat.root.id, context);
stream.start(async (batch) => {
events.push(...batch);
});
chat.faux.summaries.push(failure("bad request"));
await turn(chat, text("u4", 100), text("a4", 100));
await waitFor(() => events.some((event) => event.type === "compaction_end"));
const recorded = (await live(chat)).compactionFailure;
expect(recorded).toMatchObject({ reason: "threshold", error: "Summarization failed: bad request" });
expect(events.find((event) => event.type === "compaction_end")).toEqual({
type: "compaction_end",
taskId: recorded!.taskId,
reason: "threshold",
error: "Summarization failed: bad request",
});
const reopened = await watchEvents(chat.harness, chat.root.id, context);
expect(reopened.snapshot.compactionFailure).toEqual(recorded);
await reopened.stop();
// A compaction that produces a summary removes the failure, and ends without an error.
chat.setup.settings.compaction = MANUAL;
chat.faux.summaries.push(summary());
const manual = await chat.root.compact(undefined, context);
expect((await result(chat, manual)).status).toBe("completed");
expect((await live(chat)).compactionFailure).toBeUndefined();
await waitFor(() => events.some((event) => event.type === "compaction_end" && event.taskId === manual));
expect(events.find((event) => event.type === "compaction_end" && event.taskId === manual)).not.toHaveProperty(
"error",
);
await stream.stop();
await chat.harness.close(context);
});
it("stops through abortTask() and Conversation.abort() with background", async () => {
for (const stop of ["task", "conversation"] as const) {
const chat = await open({ contextWindow: 2000 });
@ -1074,38 +1111,63 @@ describe("blocking threshold compaction", () => {
await chat.harness.close(context);
});
for (const [name, prepare] of [
[
"declines",
(chat: Chat) => addHooks(chat.setup.registry, CompactionTask, { beforeCompact: () => ({ decline: true }) }),
],
["fails", (chat: Chat) => chat.faux.summaries.push(failure("bad request"))],
] as const) {
it(`sends the request anyway when its compaction ${name}`, async () => {
const chat = await open({ contextWindow: 1000 });
await history(chat);
chat.setup.settings.compaction = BLOCKING;
prepare(chat);
await turn(chat, text("u4", 200), "a4");
expect((await kinds(chat.root)).includes("pi.compaction")).toBe(false);
expect(firstUserText(chat.faux.agentRequests.at(-1)!.messages)).toBe(text("u1", 100));
await chat.harness.close(context);
});
}
it("sends the request anyway when its compaction declines", async () => {
const chat = await open({ contextWindow: 1000 });
await history(chat);
chat.setup.settings.compaction = BLOCKING;
addHooks(chat.setup.registry, CompactionTask, { beforeCompact: () => ({ decline: true }) });
await turn(chat, text("u4", 200), "a4");
expect((await kinds(chat.root)).includes("pi.compaction")).toBe(false);
expect(firstUserText(chat.faux.agentRequests.at(-1)!.messages)).toBe(text("u1", 100));
expect((await live(chat)).compactionFailure).toBeUndefined();
await chat.harness.close(context);
});
it("sends the request anyway when its compaction is aborted directly", async () => {
// A failed compaction leaves a context it judged too large; sending it once filled a 1M window and got an empty
// `length` answer on every turn while the failure stayed invisible.
it("fails the run with the compaction's error, without a request, when its compaction fails", async () => {
const chat = await open({ contextWindow: 1000 });
await history(chat);
chat.setup.settings.compaction = BLOCKING;
chat.faux.summaries.push(failure("bad request"));
const requests = chat.faux.agentRequests.length;
const input = await chat.root.submit({ type: "input", content: text("u4", 200) }, context);
expect(await input.wait(context)).toMatchObject({
status: "unanswered",
reason: "model_error",
detail: "Compaction failed: Summarization failed: bad request",
});
expect(chat.faux.agentRequests).toHaveLength(requests);
expect((await kinds(chat.root)).at(-1)).toBe("pi.user");
const state = await live(chat);
expect(state.compactions).toBeUndefined();
expect(state.compactionFailure).toMatchObject({
reason: "threshold",
error: "Summarization failed: bad request",
});
await chat.harness.close(context);
});
it("fails the run when its compaction is aborted directly", async () => {
const chat = await open({ contextWindow: 1000 });
await history(chat);
chat.setup.settings.compaction = BLOCKING;
const reached = deferred();
chat.faux.summaries.push(gated(deferred(), summary(), reached));
chat.faux.agent.push(answer("a4"));
const requests = chat.faux.agentRequests.length;
const input = await chat.root.submit({ type: "input", content: text("u4", 200) }, context);
await reached.promise;
const [child] = await compactionTasks(chat);
await chat.harness.abortTask(child!.id, context);
expect((await input.wait(context)).status).toBe("done");
expect(await input.wait(context)).toMatchObject({
status: "unanswered",
reason: "model_error",
detail: "Compaction was aborted",
});
expect(chat.faux.agentRequests).toHaveLength(requests);
expect((await kinds(chat.root)).includes("pi.compaction")).toBe(false);
// Aborting is not a failure.
expect((await live(chat)).compactionFailure).toBeUndefined();
await chat.harness.close(context);
});
@ -1889,7 +1951,7 @@ describe("compaction edge cases", () => {
await chat.harness.close(context);
});
it("sends the request anyway when its blocking compaction faults", async () => {
it("fails the run when its blocking compaction faults, and records the fault", async () => {
const chat = await open({ contextWindow: 1000 });
await history(chat);
chat.setup.settings.compaction = BLOCKING;
@ -1897,10 +1959,17 @@ describe("compaction edge cases", () => {
chat.setup.models.completeSimple = () => {
throw new Error("no credentials");
};
await turn(chat, text("u4", 200), "a4");
const input = await chat.root.submit({ type: "input", content: text("u4", 200) }, context);
expect(await input.wait(context)).toMatchObject({
status: "unanswered",
reason: "model_error",
detail: "Compaction failed: no credentials",
});
chat.setup.models.completeSimple = completeSimple;
expect((await kinds(chat.root)).includes("pi.compaction")).toBe(false);
expect((await live(chat)).compactions).toBeUndefined();
const state = await live(chat);
expect(state.compactions).toBeUndefined();
expect(state.compactionFailure).toMatchObject({ reason: "threshold", error: "no credentials" });
await chat.harness.close(context);
});