Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,17 @@ function textDelta(sessionId: string, text: string) {
};
}

function assistantMessage(sessionId: string, apiId: string, text: string) {
function assistantMessage(
sessionId: string,
apiId: string,
text: string,
usage?: {
input_tokens: number;
output_tokens: number;
cache_read_input_tokens: number;
cache_creation_input_tokens: number;
},
) {
return {
type: "assistant",
parent_tool_use_id: null,
Expand All @@ -159,6 +169,20 @@ function assistantMessage(sessionId: string, apiId: string, text: string) {
id: apiId,
role: "assistant",
content: [{ type: "text", text }],
...(usage ? { usage } : {}),
},
};
}

function compactBoundary(sessionId: string) {
return {
type: "system",
subtype: "compact_boundary",
session_id: sessionId,
uuid: "compact-1",
compact_metadata: {
trigger: "auto",
pre_tokens: 434_000,
},
};
}
Expand Down Expand Up @@ -192,6 +216,23 @@ function messageChunkTexts(
.map((update) => update?.content?.text ?? "");
}

function usageUpdates(
calls: ClientMocks["sessionUpdate"]["mock"]["calls"],
): Array<{ used: number; size: number }> {
return calls.flatMap(([call]) => {
const update = (
call as {
update?: { sessionUpdate?: string; used?: number; size?: number };
}
).update;
return update?.sessionUpdate === "usage_update" &&
typeof update.used === "number" &&
typeof update.size === "number"
? [{ used: update.used, size: update.size }]
: [];
});
}

describe("ClaudeAcpAgent.prompt — streamed assistant text wiring", () => {
beforeEach(() => {
vi.clearAllMocks();
Expand Down Expand Up @@ -243,6 +284,48 @@ describe("ClaudeAcpAgent.prompt — streamed assistant text wiring", () => {
]);
});

it("does not replace known context usage with incomplete gateway snapshots", async () => {
const { agent, client } = makeAgent();
const sessionId = "s-context-usage";
const { query, input } = installFakeSession(agent, sessionId);
const session = (
agent as unknown as {
session: { contextUsed?: number; lastContextWindowSize?: number };
}
).session;
session.contextUsed = 434_000;
session.lastContextWindowSize = 1_000_000;
vi.mocked(query.getContextUsage).mockRejectedValue(
new Error("context usage unavailable"),
);

const promptPromise = agent.prompt({
sessionId,
prompt: [{ type: "text", text: "continue" }],
});
await tick();

await echoUserMessage(query, input);
await send(query, messageStart(sessionId, "msg-context"));
await send(query, compactBoundary(sessionId));
await send(
query,
assistantMessage(sessionId, "msg-context", "done", {
input_tokens: 440_000,
output_tokens: 100,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
}),
);
await send(query, resultSuccess(sessionId));
await promptPromise;

const updates = usageUpdates(client.sessionUpdate.mock.calls);
expect(updates.length).toBeGreaterThan(0);
expect(updates.every(({ used }) => used >= 434_000)).toBe(true);
expect(updates.every(({ size }) => size === 1_000_000)).toBe(true);
});

it("keeps the original turn open until a pending steer is consumed", async () => {
const { agent, client } = makeAgent();
const sessionId = "s-steer-ordering";
Expand Down
63 changes: 41 additions & 22 deletions packages/agent/src/adapters/claude/claude-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -650,6 +650,21 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
};
};

const recordContextUsage = (nextTotal: number): boolean => {
if (nextTotal <= 0 || nextTotal === lastAssistantTotalUsage) {
return false;
}
const knownTotal = Math.max(
lastAssistantTotalUsage ?? 0,
session.contextUsed ?? 0,
);
if (nextTotal < knownTotal) {
return false;
}
lastAssistantTotalUsage = nextTotal;
return true;
};

const resetTurnScratch = () => {
lastAssistantTotalUsage = null;
lastRefusalExplanation = null;
Expand Down Expand Up @@ -831,16 +846,19 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
fetchContextUsedTokens(query, this.logger),
cancelController.signal,
);
lastAssistantTotalUsage =
usedTokens.result === "success" ? (usedTokens.value ?? 0) : 0;
await this.client.sessionUpdate({
sessionId,
update: {
sessionUpdate: "usage_update",
used: lastAssistantTotalUsage,
size: windowSize(),
},
});
if (usedTokens.result === "success" && usedTokens.value != null) {
lastAssistantTotalUsage = usedTokens.value;
session.contextUsed = usedTokens.value;
session.contextSize = windowSize();
await this.client.sessionUpdate({
sessionId,
update: {
sessionUpdate: "usage_update",
used: lastAssistantTotalUsage,
size: windowSize(),
},
});
}
}
if (message.subtype === "commands_changed") {
session.knownSlashCommands = collectKnownSlashCommands(
Expand Down Expand Up @@ -1200,8 +1218,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
lastStreamUsage.cache_read_input_tokens +
lastStreamUsage.cache_creation_input_tokens;

if (nextTotal !== lastAssistantTotalUsage) {
lastAssistantTotalUsage = nextTotal;
if (recordContextUsage(nextTotal)) {
await this.client.sessionUpdate({
sessionId,
update: {
Expand Down Expand Up @@ -1296,21 +1313,23 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
cache_read_input_tokens: number | null;
cache_creation_input_tokens: number | null;
};
lastAssistantTotalUsage =
const nextTotal =
(usage.input_tokens ?? 0) +
(usage.output_tokens ?? 0) +
(usage.cache_read_input_tokens ?? 0) +
(usage.cache_creation_input_tokens ?? 0);

await this.client.sessionUpdate({
sessionId,
update: {
sessionUpdate: "usage_update",
used: lastAssistantTotalUsage,
size: windowSize(),
cost: null,
},
});
if (recordContextUsage(nextTotal)) {
await this.client.sessionUpdate({
sessionId,
update: {
sessionUpdate: "usage_update",
used: nextTotal,
size: windowSize(),
cost: null,
},
});
}
}

const result = await handleUserAssistantMessage(message, context);
Expand Down
28 changes: 14 additions & 14 deletions packages/agent/src/adapters/claude/session/models.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,13 @@ describe("model capability flags", () => {
xhighEffort: false,
mcpInjection: false,
},
{
modelId: "@cf/zai-org/glm-5.2",
oneMContext: false,
effort: true,
xhighEffort: false,
mcpInjection: true,
},
])(
"$modelId capability flags",
({ modelId, oneMContext, effort, xhighEffort, mcpInjection }) => {
Expand All @@ -118,6 +125,7 @@ describe("resolveEffortForModel", () => {
["claude-opus-4-7", undefined, "high"],
["claude-sonnet-4-6", undefined, "high"],
["claude-sonnet-5", undefined, "high"],
["@cf/zai-org/glm-5.2", undefined, "high"],
// Models without effort support stay unset (SDK disables thinking).
["claude-haiku-4-5", undefined, undefined],
["claude-opus-4-6", undefined, undefined],
Expand All @@ -139,20 +147,12 @@ describe("getEffortOptions", () => {
expect(getEffortOptions("claude-opus-4-6")).toBeNull();
});

it("returns low/medium/high for effort-supporting models", () => {
const opts = getEffortOptions("claude-sonnet-4-6");
expect(opts?.map((o) => o.value)).toEqual(["low", "medium", "high"]);
});

it("appends xhigh and max for xhigh-supporting models", () => {
const opts = getEffortOptions("claude-opus-4-7");
expect(opts?.map((o) => o.value)).toEqual([
"low",
"medium",
"high",
"xhigh",
"max",
]);
it.each([
["claude-sonnet-4-6", ["low", "medium", "high"]],
["claude-opus-4-7", ["low", "medium", "high", "xhigh", "max"]],
["@cf/zai-org/glm-5.2", ["high", "max"]],
])("returns the exact effort levels for %s", (modelId, expected) => {
expect(getEffortOptions(modelId)?.map((o) => o.value)).toEqual(expected);
});
});

Expand Down
66 changes: 33 additions & 33 deletions packages/agent/src/adapters/claude/session/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,23 +33,27 @@ export function supports1MContext(modelId: string): boolean {
return MODELS_WITH_1M_CONTEXT.has(modelId);
}

const MODELS_WITH_EFFORT = new Set([
"claude-opus-4-7",
"claude-opus-4-8",
"claude-sonnet-4-6",
"claude-sonnet-5",
"claude-fable-5",
]);

const MODELS_WITH_XHIGH_EFFORT = new Set([
"claude-opus-4-7",
"claude-opus-4-8",
"claude-sonnet-5",
"claude-fable-5",
]);
const STANDARD_EFFORT_LEVELS: readonly EffortLevel[] = [
"low",
"medium",
"high",
];
const EXTENDED_EFFORT_LEVELS: readonly EffortLevel[] = [
...STANDARD_EFFORT_LEVELS,
"xhigh",
"max",
];
const MODEL_EFFORT_LEVELS: Readonly<Record<string, readonly EffortLevel[]>> = {
"claude-opus-4-7": EXTENDED_EFFORT_LEVELS,
"claude-opus-4-8": EXTENDED_EFFORT_LEVELS,
"claude-sonnet-4-6": STANDARD_EFFORT_LEVELS,
"claude-sonnet-5": EXTENDED_EFFORT_LEVELS,
"claude-fable-5": EXTENDED_EFFORT_LEVELS,
"@cf/zai-org/glm-5.2": ["high", "max"],
};

export function supportsEffort(modelId: string): boolean {
return MODELS_WITH_EFFORT.has(modelId);
return MODEL_EFFORT_LEVELS[modelId] !== undefined;
}

export function resolveEffortForModel(
Expand All @@ -61,7 +65,7 @@ export function resolveEffortForModel(
}

export function supportsXhighEffort(modelId: string): boolean {
return MODELS_WITH_XHIGH_EFFORT.has(modelId);
return MODEL_EFFORT_LEVELS[modelId]?.includes("xhigh") ?? false;
}

const MODELS_TO_EXCLUDE_MCP_TOOLS = new Set(["claude-haiku-4-5"]);
Expand All @@ -82,27 +86,23 @@ export function fastModeStateEnabled(state: string | undefined): boolean {
}

interface EffortOption {
value: string;
value: EffortLevel;
name: string;
}

export function getEffortOptions(modelId: string): EffortOption[] | null {
if (!supportsEffort(modelId)) return null;

const options: EffortOption[] = [
{ value: "low", name: "Low" },
{ value: "medium", name: "Medium" },
{ value: "high", name: "High" },
];

if (supportsXhighEffort(modelId)) {
options.push(
{ value: "xhigh", name: "Extra High" },
{ value: "max", name: "Max" },
);
}
const EFFORT_LABELS: Record<EffortLevel, string> = {
low: "Low",
medium: "Medium",
high: "High",
xhigh: "Extra High",
max: "Max",
};

return options;
export function getEffortOptions(modelId: string): EffortOption[] | null {
const levels = MODEL_EFFORT_LEVELS[modelId];
return (
levels?.map((value) => ({ value, name: EFFORT_LABELS[value] })) ?? null
);
}

// Model alias resolution — lets callers use human-friendly aliases like
Expand Down
12 changes: 12 additions & 0 deletions packages/agent/src/adapters/reasoning-effort.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,16 @@ describe("isSupportedReasoningEffort", () => {
isSupportedReasoningEffort("claude", "claude-sonnet-4-6", "xhigh"),
).toBe(false);
});

it.each([
["high", true],
["max", true],
["low", false],
["medium", false],
["xhigh", false],
])("validates GLM 5.2 effort %s", (effort, expected) => {
expect(
isSupportedReasoningEffort("claude", "@cf/zai-org/glm-5.2", effort),
).toBe(expected);
});
});
Loading
Loading