feat: use session-reported Claude CLI context windows
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
import React, { act } from "react";
|
||||
(globalThis as Record<string, unknown>).React = React;
|
||||
import assert from "node:assert/strict";
|
||||
import { afterEach, test } from "node:test";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { parseHTML } from "linkedom";
|
||||
import { StatsBar } from "../components/stats-bar.tsx";
|
||||
import { CLAUDE_CODE_MODELS } from "../src/agent/claude-code/provider.ts";
|
||||
import { toModelDto } from "../src/server/model-catalog.ts";
|
||||
import type { ModelCatalogResponse } from "../src/core/index.ts";
|
||||
|
||||
const dom = parseHTML("<html><body></body></html>");
|
||||
Object.assign(globalThis, { window: dom.window, document: dom.document, Element: dom.window.Element, HTMLElement: dom.window.HTMLElement,
|
||||
CustomEvent: dom.window.CustomEvent, IS_REACT_ACT_ENVIRONMENT: true });
|
||||
const originalFetch = globalThis.fetch;
|
||||
let root: Root | undefined, host: HTMLDivElement | undefined;
|
||||
afterEach(async () => { await act(async () => root?.unmount()); host?.remove(); root = undefined; globalThis.fetch = originalFetch; });
|
||||
|
||||
test("compact CLI context is numeric only; effective-window explanation appears only in expanded session metadata", async () => {
|
||||
const catalog: ModelCatalogResponse = { models: CLAUDE_CODE_MODELS.map(toModelDto), providers: [],
|
||||
slots: { strong: null, cheap: null, vision: null }, defaults: null,
|
||||
rolePins: { researcher: null, demoBuilder: null, visualMaker: null, extractor: null, auditor: null } };
|
||||
globalThis.fetch = (async (url) => {
|
||||
if (url === "/api/models") return Response.json(catalog);
|
||||
if (url === "/api/settings") return Response.json({ settings: { models: {} } });
|
||||
if (url === "/api/chats/cli-session") return Response.json({ settings: { model: { provider: "claude-code", id: "opus" } } });
|
||||
throw new Error(`unexpected request ${url}`);
|
||||
}) as typeof fetch;
|
||||
host = document.createElement("div"); document.body.append(host); root = createRoot(host);
|
||||
const render = (window: { model: string; tokens: number } | null) => root!.render(<StatsBar chatId="cli-session" turns={[]} contextTokens={42000} cliContextWindow={window} />);
|
||||
await act(async () => render({ model: "claude-code:opus", tokens: 200000 }));
|
||||
assert.equal(document.querySelector('[data-testid="stats-context"]')?.textContent, "42k / 200k");
|
||||
assert(!document.body.textContent?.includes("auto-compaction"));
|
||||
const summary = document.querySelector<HTMLButtonElement>('[data-testid="stats-summary"]')!;
|
||||
assert(!summary.textContent?.includes("window")); assert(!summary.textContent?.includes("budget"));
|
||||
await act(async () => summary.click());
|
||||
assert.match(document.querySelector('[data-testid="stats-detail"]')?.textContent ?? "", /auto-compaction budget rather than the model’s full maximum/);
|
||||
assert.equal(document.querySelector('[data-testid="stats-context"]')?.textContent, "42k / 200k");
|
||||
await act(async () => render(null));
|
||||
assert.equal(document.querySelector('[data-testid="stats-context"]')?.textContent, "42k");
|
||||
await act(async () => render({ model: "claude-code:sonnet", tokens: 1000000 }));
|
||||
assert.equal(document.querySelector('[data-testid="stats-context"]')?.textContent, "42k", "another alias cannot supply this session's denominator");
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { ContextWindowProbe } from "../src/agent/claude-code/context-window.ts";
|
||||
import { CLAUDE_CODE_MODELS } from "../src/agent/claude-code/provider.ts";
|
||||
import { asAgentMessages, cliContextWindowOf, toLlmMessages, type LhMessage } from "../src/core/native-messages.ts";
|
||||
import { catalogLimits } from "../components/model-picker.tsx";
|
||||
import { contextLimitForModel, formatContextMeasurement } from "../components/stats-bar.tsx";
|
||||
import { toModelDto } from "../src/server/model-catalog.ts";
|
||||
|
||||
function setup() {
|
||||
const writes: { type: string; request_id: string; request: Record<string, string> }[] = [];
|
||||
const windows: number[] = [];
|
||||
let prompts = 0;
|
||||
const probe = new ContextWindowProbe((line) => writes.push(JSON.parse(line)), () => prompts++, (tokens) => windows.push(tokens));
|
||||
probe.start();
|
||||
const reply = (index: number, body: unknown, subtype = "success") => probe.handle({ type: "control_response", response: {
|
||||
request_id: writes[index]!.request_id, subtype, response: body,
|
||||
} });
|
||||
return { probe, writes, windows, reply, prompts: () => prompts };
|
||||
}
|
||||
|
||||
test("control initialization precedes a summary-only query; neither needs user input or a model request", () => {
|
||||
const p = setup();
|
||||
assert.deepEqual(p.writes[0]!.request, { subtype: "initialize" }); assert.equal(p.prompts(), 0);
|
||||
p.reply(0, {});
|
||||
assert.deepEqual(p.writes[1]!.request, { subtype: "get_context_usage", detail: "summary" });
|
||||
assert.equal(p.prompts(), 1, "conversation does not wait for metadata");
|
||||
assert.notEqual(p.writes[0]!.request_id, p.writes[1]!.request_id);
|
||||
p.probe.handle({ type: "control_response", response: { request_id: "foreign", subtype: "success", response: { maxTokens: 999 } } });
|
||||
assert.deepEqual(p.windows, []);
|
||||
p.reply(1, { maxTokens: 200000, rawMaxTokens: 1000000 });
|
||||
assert.deepEqual(p.windows, [200000], "effective maxTokens, not an assumed nominal window");
|
||||
p.reply(0, {}); p.reply(1, { maxTokens: 1 });
|
||||
assert.equal(p.prompts(), 1); assert.deepEqual(p.windows, [200000]);
|
||||
p.probe.close();
|
||||
});
|
||||
|
||||
test("metadata unavailable before first response can be queried again locally; stale/closed replies are ignored", () => {
|
||||
const p = setup(); p.reply(0, {}); p.reply(1, {}, "error");
|
||||
assert.equal(p.prompts(), 1); assert.deepEqual(p.windows, []);
|
||||
p.probe.refresh();
|
||||
assert.deepEqual(p.writes[2]!.request, { subtype: "get_context_usage", detail: "summary" });
|
||||
p.reply(1, { maxTokens: 1 }); assert.deepEqual(p.windows, []);
|
||||
p.reply(2, { maxTokens: 200000 }); assert.deepEqual(p.windows, [200000]);
|
||||
p.probe.refresh(); p.probe.close(); p.reply(3, { maxTokens: 999999 });
|
||||
assert.deepEqual(p.windows, [200000]); assert.equal(p.prompts(), 1);
|
||||
});
|
||||
|
||||
for (const tokens of [null, undefined, 0, -1, 1.5, Infinity, Number.MAX_SAFE_INTEGER + 1, "200000"]) test(`invalid summary budget ${tokens} remains unknown without failing the prompt`, () => {
|
||||
const p = setup(); p.reply(0, {}); p.reply(1, { maxTokens: tokens });
|
||||
assert.deepEqual(p.windows, []); assert.equal(p.prompts(), 1); p.probe.close();
|
||||
});
|
||||
|
||||
test("initialization errors/timeouts fall back to ordinary prompt delivery; close cancels the timer", (t) => {
|
||||
t.mock.timers.enable({ apis: ["setTimeout"] });
|
||||
const error = setup(); error.reply(0, undefined, "error");
|
||||
assert.equal(error.prompts(), 1); assert.equal(error.writes.length, 1); error.probe.close();
|
||||
const timeout = setup(); t.mock.timers.tick(5000);
|
||||
assert.equal(timeout.prompts(), 1); assert.equal(timeout.writes.length, 1);
|
||||
timeout.reply(0, {}); assert.equal(timeout.writes.length, 1); timeout.probe.close();
|
||||
const closed = setup(); closed.probe.close(); t.mock.timers.tick(5000);
|
||||
assert.equal(closed.prompts(), 0);
|
||||
});
|
||||
|
||||
function assistant(model = "opus", tokens: number | undefined = 200000): LhMessage & { role: "assistant" } {
|
||||
return { id: "cli-assistant", role: "assistant", api: "claude-code-cli", provider: "claude-code", model,
|
||||
content: [], timestamp: 0, stopReason: "stop", cliContextWindow: tokens,
|
||||
usage: { input: 1, output: 1, cacheRead: 0, cacheWrite: 0, totalTokens: 2, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } } };
|
||||
}
|
||||
|
||||
test("per-session metadata survives JSON persistence, never borrows older reports, and cannot override other model selections", () => {
|
||||
const first = assistant();
|
||||
const window = cliContextWindowOf(JSON.parse(JSON.stringify([first])));
|
||||
assert.deepEqual(window, { model: "claude-code:opus", tokens: 200000 });
|
||||
assert(!("cliContextWindow" in toLlmMessages(asAgentMessages([first]))[0]!));
|
||||
assert.equal(first.cliContextWindow, 200000, "provider projection never mutates the stored report");
|
||||
const catalog = new Map([["anthropic:fixture", 100000], ["claude-code:opus", 1000000]]);
|
||||
assert.equal(contextLimitForModel("claude-code:opus", catalog, window), 200000);
|
||||
assert.equal(contextLimitForModel("claude-code:sonnet", catalog, window), null);
|
||||
assert.equal(contextLimitForModel("claude-code:opus", catalog, null), null, "no placeholder fallback");
|
||||
assert.equal(contextLimitForModel("anthropic:fixture", catalog, window), 100000);
|
||||
assert.equal(contextLimitForModel(undefined, catalog, window), null);
|
||||
assert.equal(formatContextMeasurement(42000, 200000), "42k / 200k");
|
||||
assert.equal(formatContextMeasurement(42000, null), "42k");
|
||||
const unreported = assistant("opus"); delete unreported.cliContextWindow;
|
||||
assert.equal(cliContextWindowOf([first, unreported]), null);
|
||||
const other = assistant("api"); other.provider = "anthropic";
|
||||
assert.equal(cliContextWindowOf([first, other]), null);
|
||||
const compacted: LhMessage = { id: "compact", role: "custom", customType: "compaction", content: "summary", display: false, details: {}, timestamp: 1 };
|
||||
assert.equal(cliContextWindowOf([first, compacted]), null);
|
||||
assert.equal(cliContextWindowOf([]), null);
|
||||
});
|
||||
|
||||
test("CLI catalog limits remain unknown and cannot create a global denominator", () => {
|
||||
const models = CLAUDE_CODE_MODELS.map(toModelDto);
|
||||
assert(models.every((model) => model.contextWindow === null && model.maxTokens === null));
|
||||
assert.equal(catalogLimits({ models }).size, 0);
|
||||
});
|
||||
@@ -23,7 +23,7 @@ import { CLAUDE_CODE_MODELS } from "../src/agent/claude-code/provider.ts";
|
||||
import { createClaudeCodeStreamFunction, type RunClaude } from "../src/agent/claude-code/stream.ts";
|
||||
import type { PiEngine } from "../src/agent/models.ts";
|
||||
import { createTeacherAgent } from "../src/agent/teacher-agent.ts";
|
||||
import { danglingQuestionOf } from "../src/core/native-messages.ts";
|
||||
import { cliContextWindowOf, danglingQuestionOf } from "../src/core/native-messages.ts";
|
||||
import { handleChatRequest } from "../src/server/chat-handler.ts";
|
||||
import { isTurnRunning } from "../src/server/turn-liveness.ts";
|
||||
import { runningTurn } from "../src/server/turn-runs.ts";
|
||||
@@ -157,6 +157,8 @@ async function waitForExit(child: ChildProcessWithoutNullStreams): Promise<void>
|
||||
|
||||
test("a question answered before the idle timeout continues the waiting CLI process with the answer", async (t) => {
|
||||
const fakeRun = createFakeRun(t, [toAskScenario()]);
|
||||
const run = fakeRun.runClaude;
|
||||
fakeRun.runClaude = (args, options) => run(args, { ...options, env: { ...options.env, FAKE_CLAUDE_WINDOW: "200000" } });
|
||||
const agentFactory = createAgentFactory(fakeRun);
|
||||
const chatId = createClaudeCodeChat(t);
|
||||
|
||||
@@ -170,6 +172,8 @@ test("a question answered before the idle timeout continues the waiting CLI proc
|
||||
await runChatTurn(chatId, toAnswerInput(), agentFactory);
|
||||
|
||||
assert.equal(fakeRun.children.length, 1);
|
||||
assert.deepEqual(cliContextWindowOf(chatStore.getChat(chatId)!.messages), { model: "claude-code:sonnet", tokens: 200000 });
|
||||
assert.deepEqual(cliContextWindowOf(JSON.parse(readFileSync(join(PATHS.chats, `${chatId}.json`), "utf8")).messages), { model: "claude-code:sonnet", tokens: 200000 });
|
||||
assert.ok(getLastAssistantText(chatId).includes(ANSWER_TEXT), getLastAssistantText(chatId));
|
||||
await waitForExit(waiting);
|
||||
});
|
||||
|
||||
@@ -620,13 +620,18 @@ test("does not inject credentials reflected only from LearnHarness's key store",
|
||||
assert.equal(process.env[name], "lh-only-key", "other LearnHarness providers keep their env reflection");
|
||||
});
|
||||
|
||||
test("writes the transcript as one stream-json user line on stdin", async (t) => {
|
||||
test("writes one user prompt after local-only control initialization and summary request", async (t) => {
|
||||
const stdinFile = join(mkdtempSync(join(tmpdir(), "claude-code-stdin-")), "stdin.jsonl");
|
||||
t.after(() => rmSync(dirname(stdinFile), { recursive: true, force: true }));
|
||||
|
||||
await runScenario(t, readFixtureLines("text.jsonl"), undefined, { FAKE_CLAUDE_STDIN: stdinFile });
|
||||
|
||||
assert.equal(readFileSync(stdinFile, "utf8"), `${toStreamJsonLine([{ type: "text", text: "hi" }])}\n`);
|
||||
const lines = readFileSync(stdinFile, "utf8").trim().split("\n");
|
||||
assert(lines.length >= 3);
|
||||
assert.deepEqual(JSON.parse(lines[0]!).request, { subtype: "initialize" });
|
||||
assert.deepEqual(JSON.parse(lines[1]!).request, { subtype: "get_context_usage", detail: "summary" });
|
||||
assert.equal(lines[2], toStreamJsonLine([{ type: "text", text: "hi" }]));
|
||||
assert(lines.slice(3).every((line) => JSON.parse(line).request.detail === "summary"));
|
||||
});
|
||||
|
||||
test("parseCliLine ignores non-JSON, system, rate-limit and successful result lines", () => {
|
||||
@@ -1002,6 +1007,28 @@ test("a caller without a session ID uses fresh replay and retains no held proces
|
||||
await assertCleanedUp(getOnlyCall(fresh.calls));
|
||||
});
|
||||
|
||||
test("CLI window metadata stays on its own process and survives a held-tool continuation", async (t) => {
|
||||
const read: ToolUse = { id: "toolu_window", name: "read", arguments: { path: "one" } };
|
||||
const one = createFakeRun(t, [...toToolUseMessage([read]), callTool(read), REPLY], { FAKE_CLAUDE_WINDOW: "200000" });
|
||||
const two = createFakeRun(t, [...toToolUseMessage([read]), callTool(read), REPLY], { FAKE_CLAUDE_WINDOW: "750000" });
|
||||
let spawns = 0;
|
||||
const stream = createClaudeCodeStreamFunction((args, opts) => (++spawns === 1 ? one : two).runClaude(args, opts));
|
||||
const window = (m: AssistantMessage) => (m as AssistantMessage & { cliContextWindow?: number }).cliContextWindow;
|
||||
const a = await runTurn(stream, [HI], { sessionId: "context-one" });
|
||||
const b = await runTurn(stream, [HI], { sessionId: "context-two" });
|
||||
assert.equal(window(a), 200000); assert.equal(window(b), 750000);
|
||||
const ra = await runTurn(stream, [HI, a, toToolResult(read, "a")], { sessionId: "context-one" });
|
||||
const rb = await runTurn(stream, [HI, b, toToolResult(read, "b")], { sessionId: "context-two" });
|
||||
assert.equal(window(ra), 200000); assert.equal(window(rb), 750000); assert.equal(spawns, 2);
|
||||
});
|
||||
|
||||
for (const extraEnv of [{ FAKE_CLAUDE_INIT: "error" }, { FAKE_CLAUDE_WINDOW: "-1" }, {}] as Record<string, string>[]) test(`missing/unsupported CLI window never prevents a normal response: ${JSON.stringify(extraEnv)}`, async (t) => {
|
||||
const { events } = await runScenario(t, readFixtureLines("text.jsonl"), undefined, extraEnv);
|
||||
const result = getLastEvent(events);
|
||||
assert.equal(result.type, "done");
|
||||
assert(!("cliContextWindow" in (result.type === "done" ? result.message : {})));
|
||||
});
|
||||
|
||||
for (const sharedIdentity of [false, true]) test(`two concurrent stream owners keep separate processes${sharedIdentity ? " even with identical call IDs and arguments" : ""}`, async (t) => {
|
||||
const readOne: ToolUse = { id: "toolu_concurrent_one", name: "read", arguments: { path: "one" } };
|
||||
const readTwo: ToolUse = sharedIdentity ? { ...readOne } : { id: "toolu_concurrent_two", name: "read", arguments: { path: "two" } };
|
||||
|
||||
+24
-2
@@ -15,6 +15,7 @@
|
||||
*/
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { on } from "node:events";
|
||||
import { createInterface } from "node:readline";
|
||||
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
||||
@@ -56,8 +57,29 @@ async function writeReply(text) {
|
||||
const mcpConfig = readFileSync(process.argv[process.argv.indexOf("--mcp-config") + 1], "utf8");
|
||||
|
||||
let input = "";
|
||||
for await (const chunk of process.stdin) input += chunk;
|
||||
if (process.env.FAKE_CLAUDE_STDIN) writeFileSync(process.env.FAKE_CLAUDE_STDIN, input);
|
||||
let receiveUser;
|
||||
const userReceived = new Promise((resolve) => { receiveUser = resolve; });
|
||||
createInterface({ input: process.stdin }).on("line", async (line) => {
|
||||
input += `${line}\n`;
|
||||
const message = JSON.parse(line);
|
||||
if (message.type !== "control_request") {
|
||||
if (process.env.FAKE_CLAUDE_STDIN) writeFileSync(process.env.FAKE_CLAUDE_STDIN, input);
|
||||
receiveUser(); return;
|
||||
}
|
||||
if (message.request.subtype === "initialize") {
|
||||
if (process.env.FAKE_CLAUDE_INIT === "ignore") return;
|
||||
await writeLine(JSON.stringify({ type: "control_response", response: {
|
||||
request_id: message.request_id, subtype: process.env.FAKE_CLAUDE_INIT === "error" ? "error" : "success",
|
||||
response: {}, error: "unsupported initialize",
|
||||
} }));
|
||||
} else if (message.request.subtype === "get_context_usage") {
|
||||
await writeLine(JSON.stringify({ type: "control_response", response: {
|
||||
request_id: message.request_id, subtype: "success",
|
||||
response: { maxTokens: process.env.FAKE_CLAUDE_WINDOW ? JSON.parse(process.env.FAKE_CLAUDE_WINDOW) : null },
|
||||
} }));
|
||||
}
|
||||
});
|
||||
await userReceived;
|
||||
|
||||
let bridge = null;
|
||||
const results = [];
|
||||
|
||||
@@ -66,7 +66,8 @@ describe("the model catalog projection", () => {
|
||||
assert.ok(Array.isArray(m.supportedLevels));
|
||||
if (m.provider === "claude-code") assert.equal(m.cost, null);
|
||||
else assert.ok(m.cost && typeof m.cost.input === "number" && typeof m.cost.output === "number");
|
||||
assert.ok(m.contextWindow > 0);
|
||||
if (m.provider === "claude-code") assert.equal(m.contextWindow, null);
|
||||
else assert.ok(m.contextWindow !== null && m.contextWindow > 0);
|
||||
}
|
||||
const glm = models.find((m) => m.id === "z-ai/glm-5.3-flash");
|
||||
assert.ok(glm, "glm-5.3-flash in catalog");
|
||||
@@ -384,9 +385,9 @@ describe("the claude-code provider (native CLI authentication, no LearnHarness k
|
||||
});
|
||||
});
|
||||
|
||||
it("catalogContextLimits() has claude-code:opus when the CLI is installed", () => {
|
||||
it("catalogContextLimits() never advertises placeholder CLI limits, even when installed", () => {
|
||||
withClaudeInstalled(true, () => {
|
||||
assert.ok((catalogContextLimits().get("claude-code:opus") ?? 0) > 0);
|
||||
assert.equal(catalogContextLimits().has("claude-code:opus"), false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user