pi-mono/packages/coding-agent/test/branch-summarization.test.ts
robopipi[bot] 269afc66ab
fix(coding-agent): pass thinking level to branch summarization (#11482)
The `/tree` "Summarize" option ignored the session's thinking level.
`AgentSession` already passes `thinkingLevel` into the
`generateBranchSummary` options, but `generateBranchSummary` never read
it, so the request was built without `reasoning`.

For reasoning models on OpenAI Responses and Azure Responses, a missing
`reasoning` becomes `effort: thinkingLevelMap.off ?? "none"`. Models
that don't accept `"none"` then reject the branch summary with a 400
error:

```
Unsupported value: 'none' is not supported with the 'gpt-6-astra-…' model.
```

This change adds `thinkingLevel` to `GenerateBranchSummaryOptions`. It
sets `reasoning` on the request when the model supports reasoning and
the level is not `off`, the same rule compaction uses in
`createSummarizationOptions`.

**Testing**
- Added a test to
`packages/coding-agent/test/branch-summarization.test.ts` that records
the stream options for three cases: a reasoning model at `xhigh`, a
reasoning model at `off`, and a non-reasoning model. The expected
`reasoning` values are `["xhigh", undefined, undefined]`. Without the
fix, the test fails and every case gets `undefined`. With the fix, it
passes.
- Ran `branch-summarization`, `agent-session-branching`,
`branch-summary-extensions` and `compaction-summary-reasoning`: all
pass.
- `npm run check` passes.
- Not tested against a real provider endpoint.

Fixes #11444

Requested by @davidbrai

Co-authored-by: triagent <triagent@localhost>
2026-10-27 15:38:06 +01:00

134 lines
3.9 KiB
TypeScript

import type { StreamFn } from "@earendil-works/pi-agent-core";
import {
type AssistantMessage,
createAssistantMessageEventStream,
fauxAssistantMessage,
type Model,
type SimpleStreamOptions,
} from "@earendil-works/pi-ai";
import { describe, expect, it } from "vitest";
import { generateBranchSummary } from "../src/core/compaction/index.ts";
import type { SessionEntry } from "../src/core/session-manager.ts";
const model: Model<"anthropic-messages"> = {
id: "test-model",
name: "Test Model",
api: "anthropic-messages",
provider: "anthropic",
baseUrl: "https://api.anthropic.com",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200000,
maxTokens: 8192,
};
const entries: SessionEntry[] = [
{
type: "message",
id: "branch-user",
parentId: null,
timestamp: new Date(1).toISOString(),
message: { role: "user", content: "Abandoned request", timestamp: 1 },
},
];
function response(content: AssistantMessage["content"]): AssistantMessage {
return {
...fauxAssistantMessage(""),
content,
api: model.api,
provider: model.provider,
model: model.id,
};
}
describe("branch summarization", () => {
it("passes the thinking level only for reasoning-capable models", async () => {
const seen: Array<SimpleStreamOptions["reasoning"]> = [];
const streamFn: StreamFn = (_model, _context, options) => {
seen.push(options?.reasoning);
const stream = createAssistantMessageEventStream();
queueMicrotask(() =>
stream.push({ type: "done", reason: "stop", message: response([{ type: "text", text: "summary" }]) }),
);
return stream;
};
const reasoningModel = { ...model, reasoning: true };
const signal = new AbortController().signal;
await generateBranchSummary(entries, { model: reasoningModel, signal, streamFn, thinkingLevel: "xhigh" });
await generateBranchSummary(entries, { model: reasoningModel, signal, streamFn, thinkingLevel: "off" });
await generateBranchSummary(entries, { model, signal, streamFn, thinkingLevel: "high" });
expect(seen).toEqual(["xhigh", undefined, undefined]);
});
it("does not override tool choice for branch summaries", async () => {
let requestOptions: SimpleStreamOptions | undefined;
const streamFn: StreamFn = (_model, _context, options) => {
requestOptions = options;
const stream = createAssistantMessageEventStream();
queueMicrotask(() =>
stream.push({ type: "done", reason: "stop", message: response([{ type: "text", text: "summary" }]) }),
);
return stream;
};
await generateBranchSummary(entries, {
model,
signal: new AbortController().signal,
streamFn,
});
expect(requestOptions?.toolChoice).toBeUndefined();
});
it("rejects tool calls from branch summaries", async () => {
const streamFn: StreamFn = () => {
const stream = createAssistantMessageEventStream();
queueMicrotask(() =>
stream.push({
type: "done",
reason: "toolUse",
message: response([
{ type: "toolCall", id: "tool-call-1", name: "read", arguments: { path: "README.md" } },
]),
}),
);
return stream;
};
const result = await generateBranchSummary(entries, {
model,
signal: new AbortController().signal,
streamFn,
});
expect(result.error).toBe("Branch summarization attempted to call a tool");
});
it("rejects length-limited branch summaries", async () => {
const streamFn: StreamFn = () => {
const stream = createAssistantMessageEventStream();
queueMicrotask(() =>
stream.push({
type: "done",
reason: "length",
message: { ...response([{ type: "text", text: "partial" }]), stopReason: "length" },
}),
);
return stream;
};
const result = await generateBranchSummary(entries, {
model,
signal: new AbortController().signal,
streamFn,
});
expect(result.error).toBe(
"Branch summarization failed: generation hit the token cap and the summary is incomplete",
);
});
});