Merge native Claude Code integration with explicit opt-in
# Conflicts: # src/agent/teacher-agent.ts # src/server/keys.ts
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
import { claudeCodeInstallationResponse } from "@/src/server";
|
||||
|
||||
/** Installation/version only; never reads auth status or makes a model request. */
|
||||
export async function GET(request: Request) {
|
||||
return claudeCodeInstallationResponse(request);
|
||||
}
|
||||
@@ -38,6 +38,7 @@ import { useServerEvent, useServerResync } from "@/components/server-events";
|
||||
import {
|
||||
isProviderKeyRefusal,
|
||||
firstSourceTextParts,
|
||||
cliContextWindowOf,
|
||||
messageAnchorId,
|
||||
textPartMessageId,
|
||||
kbdEventForAction as kbdEventForChat,
|
||||
@@ -1691,6 +1692,7 @@ export function Chat({
|
||||
phase={phases.at(-1) ?? "probe"}
|
||||
turns={turns}
|
||||
contextTokens={contextTokens}
|
||||
cliContextWindow={cliContextWindowOf(nativeMessages)}
|
||||
pricing={pricing}
|
||||
onReserve={setDockReserve}
|
||||
dictationSlot={<DictationBar dictation={dictation} />}
|
||||
@@ -1917,6 +1919,7 @@ function DockOverlay({
|
||||
phase,
|
||||
turns,
|
||||
contextTokens,
|
||||
cliContextWindow,
|
||||
pricing,
|
||||
composer,
|
||||
onSubmitAnswer,
|
||||
@@ -1953,6 +1956,7 @@ function DockOverlay({
|
||||
/** The session's usage log — the stats bar beneath the composer folds it. */
|
||||
turns: TurnUsage[];
|
||||
contextTokens: number | null;
|
||||
cliContextWindow: ReturnType<typeof cliContextWindowOf>;
|
||||
pricing?: SessionPricing;
|
||||
composer: React.ReactNode;
|
||||
onSubmitAnswer: (output: QuestionAnswerData) => void;
|
||||
@@ -2408,6 +2412,7 @@ function DockOverlay({
|
||||
<StatsBar
|
||||
turns={turns}
|
||||
contextTokens={contextTokens}
|
||||
cliContextWindow={cliContextWindow}
|
||||
pricing={pricing}
|
||||
dictationSlot={dictationSlot}
|
||||
chatId={chatId}
|
||||
|
||||
@@ -39,7 +39,7 @@ export function useModelCatalog(): { catalog: ModelCatalogResponse | undefined;
|
||||
catalogReaders.add(read);
|
||||
if (first) {
|
||||
stopCatalogSignals = onSettingsSaved((section) => {
|
||||
if (section === "keys") {
|
||||
if (section === "keys" || section === "claudeCode") {
|
||||
catalogGeneration++; catalogInflight = undefined; catalogCache = undefined;
|
||||
fetchCatalog();
|
||||
} else if (section === "models") fetchCatalog(true);
|
||||
|
||||
@@ -13,15 +13,12 @@ import { useModelCatalog } from "@/components/model-catalog";
|
||||
export { useModelCatalog } from "@/components/model-catalog";
|
||||
import { defaultOrderLevel, populateLevel, THINKING_LABEL } from "@/components/thinking-default";
|
||||
|
||||
/** provider:id → contextWindow over the cached catalog — the client-side
|
||||
* twin of the server's catalogContextLimits join (context-meter
|
||||
* amendment, owner 2026-09-23: pi's live catalog is the ONLY limit
|
||||
* source — no hardcoded fallback exists anywhere, so a model missing
|
||||
* from the index reads limit-null and the meter renders usage-only,
|
||||
* the honest-render precedent). Consumed by the stats bar: the same
|
||||
* cached /api/models fetch the picker rides feeds the denominator. */
|
||||
/** provider:id → known contextWindow over the cached catalog, mirroring
|
||||
* the server's catalogContextLimits join. CLI aliases have no global
|
||||
* denominator: the stats bar uses that session's actual CLI report instead.
|
||||
* Missing limits always render usage-only, without a hardcoded fallback. */
|
||||
export function catalogLimits(catalog: Pick<ModelCatalogResponse, "models">): Map<string, number> {
|
||||
return new Map(catalog.models.map((m) => [`${m.provider}:${m.id}`, m.contextWindow]));
|
||||
return new Map(catalog.models.flatMap((m) => m.contextWindow === null ? [] : [[`${m.provider}:${m.id}`, m.contextWindow] as const]));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -20,7 +20,7 @@ export const SETTINGS_SAVED_EVENT = "lh:settings-saved";
|
||||
// "keys" joined the roster (zero-keys wave, 2026-09-25): keys-section
|
||||
// dispatches it after save AND delete — deletions used to be invisible to
|
||||
// the zero-keys pill and the setup dialog's auto-advance.
|
||||
export type SettingsSection = "general" | "keys" | "agent" | "models" | "appearance" | "graph" | "compaction" | "dictation";
|
||||
export type SettingsSection = "general" | "keys" | "agent" | "models" | "appearance" | "graph" | "compaction" | "dictation" | "claudeCode";
|
||||
|
||||
/** Writers call this after the PUT resolves ok — never before (a failed
|
||||
* write notifies nothing; the record is unchanged). */
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
"use client";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { CLAUDE_CODE_MINIMUM_VERSION, type ClaudeCodeStatus, type UserSettings } from "@/src/core";
|
||||
import { notifySettingsSaved, useSettingsSaved } from "@/components/settings-signal";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Toggle } from "./controls";
|
||||
|
||||
export function claudeCodeStatusLabel(status: ClaudeCodeStatus | null): string {
|
||||
switch (status?.state) {
|
||||
case "not-found": return "Not found";
|
||||
case "meets-minimum": return `Version ${status.version} · meets minimum`;
|
||||
case "update-required": return `Version ${status.version} · update required`;
|
||||
default: return "Version could not be checked";
|
||||
}
|
||||
}
|
||||
|
||||
export function ClaudeCodeInstallationStatus() {
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [status, setStatus] = useState<ClaudeCodeStatus | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const request = useRef<AbortController | null>(null);
|
||||
const settingsRead = useRef<AbortController | null>(null);
|
||||
const settingsWrite = useRef<AbortController | null>(null);
|
||||
|
||||
const read = useCallback(async () => {
|
||||
settingsRead.current?.abort();
|
||||
const controller = new AbortController();
|
||||
settingsRead.current = controller;
|
||||
setLoaded(false);
|
||||
try {
|
||||
const response = await fetch("/api/settings", { cache: "no-store", signal: controller.signal });
|
||||
if (!response.ok) throw new Error("settings unavailable");
|
||||
const body = await response.json() as { settings: UserSettings | null };
|
||||
if (!controller.signal.aborted) {
|
||||
setEnabled(body.settings?.claudeCode?.enabled === true);
|
||||
setLoaded(true);
|
||||
setError(null);
|
||||
}
|
||||
} catch {
|
||||
if (!controller.signal.aborted) setError("Could not load Claude Code settings.");
|
||||
}
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
void read();
|
||||
return () => { settingsRead.current?.abort(); settingsWrite.current?.abort(); };
|
||||
}, [read]);
|
||||
useSettingsSaved((section) => { if (section === "claudeCode") void read(); });
|
||||
|
||||
const save = async (next: boolean) => {
|
||||
const controller = new AbortController();
|
||||
settingsWrite.current = controller;
|
||||
settingsRead.current?.abort();
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch("/api/settings/claudeCode", { method: "PUT", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ enabled: next }), signal: controller.signal });
|
||||
if (!response.ok) throw new Error("settings write failed");
|
||||
const body = await response.json() as { settings: UserSettings };
|
||||
if (typeof body.settings?.claudeCode?.enabled !== "boolean") throw new Error("invalid settings response");
|
||||
if (!controller.signal.aborted) {
|
||||
setEnabled(body.settings.claudeCode.enabled);
|
||||
notifySettingsSaved("claudeCode");
|
||||
}
|
||||
} catch {
|
||||
if (!controller.signal.aborted) setError("Could not save Claude Code settings.");
|
||||
} finally {
|
||||
if (!controller.signal.aborted) setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!enabled) return;
|
||||
request.current?.abort();
|
||||
const controller = new AbortController();
|
||||
request.current = controller;
|
||||
setBusy(true);
|
||||
try {
|
||||
const response = await fetch("/api/claude-code", { cache: "no-store", signal: controller.signal, headers: { "x-learnharness-claude-code": "1" } });
|
||||
if (!response.ok) throw new Error("status unavailable");
|
||||
const value = await response.json() as ClaudeCodeStatus;
|
||||
if (!["not-found", "meets-minimum", "update-required", "version-unchecked"].includes(value.state)
|
||||
|| value.minimumVersion !== CLAUDE_CODE_MINIMUM_VERSION
|
||||
|| (value.version !== null && (typeof value.version !== "string" || !/^\d+\.\d+\.\d+$/.test(value.version)))) throw new Error("invalid status");
|
||||
if (!controller.signal.aborted) setStatus(value);
|
||||
} catch {
|
||||
if (!controller.signal.aborted) setStatus(null);
|
||||
} finally {
|
||||
if (!controller.signal.aborted) setBusy(false);
|
||||
}
|
||||
}, [enabled]);
|
||||
useEffect(() => {
|
||||
if (enabled) void refresh();
|
||||
else { request.current?.abort(); setStatus(null); setBusy(false); }
|
||||
return () => request.current?.abort();
|
||||
}, [enabled, refresh]);
|
||||
|
||||
return <div className="space-y-2" data-testid="claude-code-installation">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-prose text-(length:--lh-prose-size) text-foreground">Claude Code</span>
|
||||
<Toggle label="Claude Code" checked={enabled} disabled={!loaded || saving} onChange={(next) => void save(next)} />
|
||||
</div>
|
||||
{error && <p role="alert" className="text-xs text-red-400">{error}</p>}
|
||||
{enabled && <>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span role="status" className="text-xs text-muted-foreground">{busy ? "Checking…" : claudeCodeStatusLabel(status)}</span>
|
||||
<Button type="button" size="xs" variant="outline" className="cursor-pointer disabled:cursor-not-allowed" disabled={busy} onClick={() => void refresh()} data-testid="claude-code-check-again">Check again</Button>
|
||||
</div>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground"><strong className="font-medium">Install and authenticate Claude Code in your terminal, then select a Claude Code model here.</strong> LearnHarness starts it when needed—no background process required.</p>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">Checks installation/version only—not authentication, model access, or quota. Claude Code controls billing. Requires v{CLAUDE_CODE_MINIMUM_VERSION}+. <a href="https://code.claude.com/docs/en/setup" target="_blank" rel="noreferrer" className="underline underline-offset-2 hover:text-foreground">Setup ↗</a></p>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">Verify AI claims independently—they may be inaccurate, incomplete, misleading, or outdated.</p>
|
||||
</>}
|
||||
</div>;
|
||||
}
|
||||
@@ -4,11 +4,12 @@ import { createPortal } from "react-dom";
|
||||
import { useBackdropDismiss } from "@/components/backdrop-dismiss";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export function ConnectionRow({ label, action, disabled, onAction, children, testid, actionTestid, labelClassName }: { label: string; action: string; disabled: boolean; onAction: () => void; children?: React.ReactNode; testid?: string; actionTestid?: string; labelClassName?: string }) {
|
||||
export function ConnectionRow({ label, action, actionVariant, disabled, onAction, children, testid, actionTestid, labelClassName }: { label: string; action: string; actionVariant?: "outline" | "ghost"; disabled: boolean; onAction: () => void; children?: React.ReactNode; testid?: string; actionTestid?: string; labelClassName?: string }) {
|
||||
const variant = actionVariant ?? (action === "Connect" ? "outline" : "ghost");
|
||||
return <div className="flex flex-wrap items-center gap-3" data-testid={testid}>
|
||||
<span className={labelClassName ?? "font-ui text-xs text-foreground"}>{label}</span>
|
||||
{children}
|
||||
<Button type="button" size="xs" variant={action === "Connect" ? "outline" : "ghost"} className={`cursor-pointer disabled:cursor-not-allowed ${action === "Connect" ? "" : "text-muted-foreground hover:text-foreground"}`} data-testid={actionTestid} disabled={disabled} onClick={onAction}>{action}</Button>
|
||||
<Button type="button" size="xs" variant={variant} className={`cursor-pointer disabled:cursor-not-allowed ${variant === "outline" ? "" : "text-muted-foreground hover:text-foreground"}`} data-testid={actionTestid} disabled={disabled} onClick={onAction}>{action}</Button>
|
||||
</div>;
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ import { useEffect, useRef, useState } from "react";
|
||||
import { AUTH_KEYS, type AuthKeyStatus } from "@/src/core";
|
||||
import { SettingsGroup } from "./controls.tsx";
|
||||
import { SubscriptionConnections } from "./subscription-connections.tsx";
|
||||
import { ClaudeCodeInstallationStatus } from "./claude-code-status.tsx";
|
||||
import { ProviderMenuList } from "./model-menu.tsx";
|
||||
import { MenuShell } from "@/components/menu-shell";
|
||||
import { notifySettingsSaved } from "@/components/settings-signal";
|
||||
@@ -471,6 +472,10 @@ export function KeysSection({
|
||||
<SubscriptionConnections />
|
||||
</SettingsGroup>
|
||||
|
||||
<SettingsGroup label="External CLI" testid="group-external-cli">
|
||||
<ClaudeCodeInstallationStatus />
|
||||
</SettingsGroup>
|
||||
|
||||
{(["Dictation", "Search"] as const).map((label) => <SettingsGroup key={label} label={label} testid={label === "Dictation" ? "group-dictation" : "group-search"}>
|
||||
{toolRows.filter((status) => label === "Dictation" ? status.id === "DEEPGRAM_API_KEY" : status.id !== "DEEPGRAM_API_KEY").map((status) => (
|
||||
<KeyTableRow
|
||||
|
||||
@@ -21,11 +21,12 @@
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { ModelCatalogEntry } from "@/src/core";
|
||||
import { fuzzyScore, subscriptionLabel } from "@/src/core";
|
||||
import { CLAUDE_CODE_PROVIDER, fuzzyScore, subscriptionLabel } from "@/src/core";
|
||||
import { formatTokens } from "@/components/usage-format";
|
||||
import { SearchMenuList } from "./search-menu-list";
|
||||
|
||||
function priceLabel(cost: ModelCatalogEntry["cost"]): string {
|
||||
if (!cost) return "Unpriced";
|
||||
const fmt = (n: number) => `$${n < 1 ? n.toFixed(2).replace(/0$/, "") : n.toFixed(2)}`;
|
||||
return `${fmt(cost.input)}/${fmt(cost.output)}`;
|
||||
}
|
||||
@@ -72,13 +73,13 @@ export function ModelMenuList({
|
||||
renderItem={(m) => <>
|
||||
<span className="flex items-center justify-between gap-2">
|
||||
<span className="truncate text-card-foreground">{m.name}</span>
|
||||
<span className="shrink-0 tabular-nums text-muted-foreground" title={subscriptionLabel(`${m.provider}:${m.id}`) ? "Subscription connection" : "USD per 1M tokens in/out"}>
|
||||
{subscriptionLabel(`${m.provider}:${m.id}`) ?? priceLabel(m.cost)}
|
||||
<span className="shrink-0 tabular-nums text-muted-foreground" title={m.provider === CLAUDE_CODE_PROVIDER ? "Billing is determined by your Claude Code configuration; LearnHarness does not estimate it." : subscriptionLabel(`${m.provider}:${m.id}`) ? "Subscription connection" : m.cost ? "USD per 1M tokens in/out" : "Price unavailable"}>
|
||||
{m.provider === CLAUDE_CODE_PROVIDER ? "CLI billing" : subscriptionLabel(`${m.provider}:${m.id}`) ?? priceLabel(m.cost)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex items-center justify-between gap-2 text-muted-foreground">
|
||||
<span className="truncate">{subscriptionLabel(`${m.provider}:${m.id}`) ? `${subscriptionLabel(`${m.provider}:${m.id}`)} (subscription)` : m.provider} · {m.id}</span>
|
||||
<span className="shrink-0 tabular-nums">{formatTokens(m.contextWindow)}</span>
|
||||
<span className="shrink-0 tabular-nums">{m.contextWindow === null ? "—" : formatTokens(m.contextWindow)}</span>
|
||||
</span>
|
||||
</>}
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { subscriptionLabels, subscriptionLabel, type SessionPricing, type TurnUsage } from "@/src/core";
|
||||
import { CLAUDE_CODE_PROVIDER, subscriptionLabels, subscriptionLabel, type cliContextWindowOf, type SessionPricing, type TurnUsage } from "@/src/core";
|
||||
import { formatTokens, formatUsd, planCostLabel, prettyModel } from "@/components/usage-format";
|
||||
import { ModelControl, catalogLimits, useModelCatalog } from "@/components/model-picker";
|
||||
|
||||
@@ -10,6 +10,15 @@ export function formatContextMeasurement(tokens: number | null, limit: number |
|
||||
return limit === null ? numerator : `${numerator} / ${formatTokens(limit)}`;
|
||||
}
|
||||
|
||||
/** CLI aliases have no global catalog limit: only this session's matching
|
||||
* response can supply a denominator. Other providers keep their catalog join. */
|
||||
export function contextLimitForModel(model: string | undefined, limits: ReadonlyMap<string, number> | undefined,
|
||||
cliWindow: ReturnType<typeof cliContextWindowOf> = null): number | null {
|
||||
if (!model) return null;
|
||||
if (model.startsWith(`${CLAUDE_CODE_PROVIDER}:`)) return cliWindow?.model === model ? cliWindow.tokens : null;
|
||||
return limits?.get(model) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The below-composer stats bar (wave 4 observability): the session's
|
||||
* money/context footprint as one quiet row beneath the composer —
|
||||
@@ -24,12 +33,14 @@ export function formatContextMeasurement(tokens: number | null, limit: number |
|
||||
* Spending arrives already priced by the server through Pi's catalog and
|
||||
* calculator. Missing cost stays "—", never fake zero. Context is a separate
|
||||
* completed-conversation measurement, never a money-log/summarizer fold or
|
||||
* transcript estimate. Its catalog denominator follows the selected model;
|
||||
* model changes preserve the numerator. Successful compaction clears it to ?.
|
||||
* transcript estimate. Its denominator follows the selected model, from the
|
||||
* catalog or the session's CLI report; model changes preserve the numerator.
|
||||
* Successful compaction clears the numerator and any old CLI report.
|
||||
*/
|
||||
export function StatsBar({
|
||||
turns,
|
||||
contextTokens = null,
|
||||
cliContextWindow = null,
|
||||
pricing,
|
||||
dictationSlot,
|
||||
chatId,
|
||||
@@ -38,6 +49,7 @@ export function StatsBar({
|
||||
}: {
|
||||
turns: TurnUsage[];
|
||||
contextTokens?: number | null;
|
||||
cliContextWindow?: ReturnType<typeof cliContextWindowOf>;
|
||||
pricing?: SessionPricing;
|
||||
/** The bound session (per-session models, owner 2026-09-28): present
|
||||
* on the in-session bar — the model control binds THE SESSION'S own
|
||||
@@ -86,18 +98,19 @@ export function StatsBar({
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
};
|
||||
}, [open]);
|
||||
// Catalog-only denominator; the picker reports its effective selection.
|
||||
// Missing/pending catalog means usage-only, never a hardcoded maximum.
|
||||
// The picker reports its effective selection. CLI windows are session-
|
||||
// scoped; unknown metadata always means usage-only, never a guessed maximum.
|
||||
const { catalog } = useModelCatalog();
|
||||
const limits = useMemo(() => (catalog ? catalogLimits(catalog) : undefined), [catalog]);
|
||||
const limit = selectedModel ? limits?.get(selectedModel) ?? null : null;
|
||||
const limit = contextLimitForModel(selectedModel, limits, cliContextWindow);
|
||||
const contextLabel = formatContextMeasurement(contextTokens, limit);
|
||||
const { cost = 0, unpriced = turns.length, subscription = 0 } = pricing ?? {};
|
||||
// Honest cost: show dollars only when at least one turn priced;
|
||||
// otherwise "—" (never a fake $0.0000).
|
||||
const pricedCount = turns.length - unpriced - subscription;
|
||||
const providers = pricing?.subscriptionProviders ?? subscriptionLabels(turns.map((turn) => turn.model));
|
||||
const costLabel = planCostLabel(pricedCount > 0 ? cost : null, providers);
|
||||
const cliBilled = turns.some((turn) => turn.model.startsWith(`${CLAUDE_CODE_PROVIDER}:`));
|
||||
const costLabel = planCostLabel(pricedCount > 0 ? cost : null, cliBilled ? [...providers, "CLI"] : providers);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -151,6 +164,9 @@ export function StatsBar({
|
||||
{contextLabel}
|
||||
</span>
|
||||
</div>
|
||||
{selectedModel?.startsWith(`${CLAUDE_CODE_PROVIDER}:`) && (
|
||||
<p>Window reported by Claude Code; may be lower for auto-compaction.</p>
|
||||
)}
|
||||
<div className="flex justify-between gap-2">
|
||||
<span>{subscription ? "Usage" : "Total cost"}</span>
|
||||
<span className="tabular-nums text-card-foreground">{costLabel}</span>
|
||||
@@ -178,7 +194,7 @@ export function StatsBar({
|
||||
</span>
|
||||
<span className="flex shrink-0 items-center gap-2 tabular-nums text-card-foreground">
|
||||
<span className="text-muted-foreground">{turn.steps.length} steps</span>
|
||||
<span>{subscriptionLabel(turn.model) ?? (c === null ? "—" : formatUsd(c))}</span>
|
||||
<span>{turn.model.startsWith(`${CLAUDE_CODE_PROVIDER}:`) ? "CLI" : subscriptionLabel(turn.model) ?? (c === null ? "—" : formatUsd(c))}</span>
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
|
||||
Generated
+906
-30
File diff suppressed because it is too large
Load Diff
@@ -26,6 +26,7 @@
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-agent-core": "1.4.0",
|
||||
"@earendil-works/pi-ai": "1.4.0",
|
||||
"@modelcontextprotocol/sdk": "1.32.1",
|
||||
"@mozilla/readability": "^0.6.0",
|
||||
"@radix-ui/react-use-controllable-state": "^1.2.6",
|
||||
"@resvg/resvg-js": "^2.6.2",
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
|
||||
type BinaryLookup = {
|
||||
env: Record<string, string | undefined>;
|
||||
platform: NodeJS.Platform;
|
||||
homeDir: string;
|
||||
cwd?: string;
|
||||
isExecutable: (path: string) => boolean;
|
||||
};
|
||||
|
||||
type SearchSpec = {
|
||||
paths: path.PlatformPath;
|
||||
names: string[];
|
||||
knownDirs: string[];
|
||||
};
|
||||
|
||||
function getWindowsSearchSpec(env: BinaryLookup["env"], homeDir: string): SearchSpec {
|
||||
const paths = path.win32;
|
||||
const appData = env.APPDATA ?? paths.join(homeDir, "AppData", "Roaming");
|
||||
|
||||
return {
|
||||
paths,
|
||||
// Direct spawn deliberately uses no shell. A .cmd-only npm install
|
||||
// is not runnable and must not count as an available provider.
|
||||
names: ["claude.exe"],
|
||||
knownDirs: [
|
||||
paths.join(homeDir, ".local", "bin"),
|
||||
paths.join(homeDir, ".claude", "local"),
|
||||
env.npm_config_prefix ?? paths.join(appData, "npm"),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function getPosixSearchSpec(env: BinaryLookup["env"], homeDir: string): SearchSpec {
|
||||
const paths = path.posix;
|
||||
const npmBin = env.npm_config_prefix
|
||||
? paths.join(env.npm_config_prefix, "bin")
|
||||
: paths.join(homeDir, ".npm-global", "bin");
|
||||
|
||||
return {
|
||||
paths,
|
||||
names: ["claude"],
|
||||
knownDirs: [
|
||||
paths.join(homeDir, ".local", "bin"),
|
||||
paths.join(homeDir, ".claude", "local"),
|
||||
npmBin,
|
||||
"/opt/homebrew/bin",
|
||||
"/usr/local/bin",
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveClaudeBinary({ env, platform, homeDir, cwd = process.cwd(), isExecutable }: BinaryLookup): string | null {
|
||||
const { paths, names, knownDirs } = platform === "win32"
|
||||
? getWindowsSearchSpec(env, homeDir)
|
||||
: getPosixSearchSpec(env, homeDir);
|
||||
|
||||
const pathDirs = (env.PATH ?? "").split(paths.delimiter).filter(Boolean);
|
||||
|
||||
for (const name of names) {
|
||||
for (const dir of [...pathDirs, ...knownDirs]) {
|
||||
// Discovery is relative to the server cwd; probes/model work use
|
||||
// different directories and must receive the same absolute identity.
|
||||
const candidate = paths.resolve(cwd, dir, name);
|
||||
if (isExecutable(candidate)) return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function isExecutableFile(filePath: string): boolean {
|
||||
try {
|
||||
fs.accessSync(filePath, fs.constants.X_OK);
|
||||
return fs.statSync(filePath).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function getClaudeBinary(): string | null {
|
||||
return resolveClaudeBinary({
|
||||
env: process.env,
|
||||
platform: process.platform,
|
||||
homeDir: os.homedir(),
|
||||
isExecutable: isExecutableFile,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import type { Usage } from "@earendil-works/pi-ai";
|
||||
|
||||
export type CliUsage = Partial<Pick<Usage, "input" | "output" | "cacheRead" | "cacheWrite">>;
|
||||
|
||||
export type CliBlock = { type: "text" } | { type: "thinking" } | { type: "toolUse"; id: string; name: string };
|
||||
|
||||
export type CliEvent =
|
||||
| { type: "messageStart"; usage: CliUsage }
|
||||
| { type: "blockStart"; index: number; block: CliBlock }
|
||||
| { type: "textDelta"; index: number; text: string }
|
||||
| { type: "thinkingDelta"; index: number; thinking: string }
|
||||
| { type: "toolInputDelta"; index: number; partialJson: string }
|
||||
| { type: "blockStop"; index: number }
|
||||
| { type: "messageDelta"; stopReason: string | null; usage: CliUsage }
|
||||
| { type: "messageStop" }
|
||||
| { type: "assistantError"; error: string; text: string }
|
||||
| { type: "resultError"; text: string }
|
||||
| { type: "toolResults"; ids: string[] };
|
||||
|
||||
type RawUsage = {
|
||||
input_tokens?: number | null;
|
||||
output_tokens?: number | null;
|
||||
cache_read_input_tokens?: number | null;
|
||||
cache_creation_input_tokens?: number | null;
|
||||
};
|
||||
|
||||
type RawContentBlock = { type: string; id?: string; name?: string };
|
||||
|
||||
type RawDelta = {
|
||||
type?: string;
|
||||
text?: string;
|
||||
thinking?: string;
|
||||
partial_json?: string;
|
||||
stop_reason?: string | null;
|
||||
};
|
||||
|
||||
type RawStreamEvent = {
|
||||
type: string;
|
||||
index?: number;
|
||||
message?: { usage?: RawUsage };
|
||||
content_block?: RawContentBlock;
|
||||
delta?: RawDelta;
|
||||
usage?: RawUsage;
|
||||
};
|
||||
|
||||
type RawCliLine = {
|
||||
type?: string;
|
||||
event?: RawStreamEvent;
|
||||
error?: string | null;
|
||||
message?: { content?: { type: string; text?: string; tool_use_id?: string }[] };
|
||||
is_error?: boolean;
|
||||
result?: string;
|
||||
subtype?: string;
|
||||
errors?: string[];
|
||||
};
|
||||
|
||||
function toUsage(raw: RawUsage): CliUsage {
|
||||
const usage: CliUsage = {};
|
||||
|
||||
if (typeof raw.input_tokens === "number") usage.input = raw.input_tokens;
|
||||
if (typeof raw.output_tokens === "number") usage.output = raw.output_tokens;
|
||||
if (typeof raw.cache_read_input_tokens === "number") usage.cacheRead = raw.cache_read_input_tokens;
|
||||
if (typeof raw.cache_creation_input_tokens === "number") usage.cacheWrite = raw.cache_creation_input_tokens;
|
||||
|
||||
return usage;
|
||||
}
|
||||
|
||||
function toBlock(raw: RawContentBlock): CliBlock | null {
|
||||
switch (raw.type) {
|
||||
case "text":
|
||||
return { type: "text" };
|
||||
|
||||
case "thinking":
|
||||
return { type: "thinking" };
|
||||
|
||||
case "tool_use":
|
||||
if (typeof raw.id !== "string" || typeof raw.name !== "string") return null;
|
||||
return { type: "toolUse", id: raw.id, name: raw.name };
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseDelta(index: number, delta: RawDelta): CliEvent | null {
|
||||
switch (delta.type) {
|
||||
case "text_delta":
|
||||
if (typeof delta.text !== "string") return null;
|
||||
return { type: "textDelta", index, text: delta.text };
|
||||
|
||||
case "thinking_delta":
|
||||
if (typeof delta.thinking !== "string") return null;
|
||||
return { type: "thinkingDelta", index, thinking: delta.thinking };
|
||||
|
||||
case "input_json_delta":
|
||||
if (typeof delta.partial_json !== "string") return null;
|
||||
return { type: "toolInputDelta", index, partialJson: delta.partial_json };
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseBlockEvent(index: number, event: RawStreamEvent): CliEvent | null {
|
||||
switch (event.type) {
|
||||
case "content_block_start": {
|
||||
if (!event.content_block) return null;
|
||||
|
||||
const block = toBlock(event.content_block);
|
||||
return block ? { type: "blockStart", index, block } : null;
|
||||
}
|
||||
|
||||
case "content_block_delta":
|
||||
return event.delta ? parseDelta(index, event.delta) : null;
|
||||
|
||||
case "content_block_stop":
|
||||
return { type: "blockStop", index };
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseStreamEvent(event: RawStreamEvent): CliEvent | null {
|
||||
switch (event.type) {
|
||||
case "message_start":
|
||||
return { type: "messageStart", usage: toUsage(event.message?.usage ?? {}) };
|
||||
|
||||
case "message_delta": {
|
||||
const stopReason = event.delta?.stop_reason ?? null;
|
||||
return { type: "messageDelta", stopReason, usage: toUsage(event.usage ?? {}) };
|
||||
}
|
||||
|
||||
case "message_stop":
|
||||
return { type: "messageStop" };
|
||||
|
||||
default:
|
||||
return event.index === undefined ? null : parseBlockEvent(event.index, event);
|
||||
}
|
||||
}
|
||||
|
||||
function getText(line: RawCliLine): string {
|
||||
const blocks = Array.isArray(line.message?.content) ? line.message.content : [];
|
||||
const texts = blocks.flatMap((block) => (block.type === "text" && block.text ? [block.text] : []));
|
||||
|
||||
return texts.join("\n");
|
||||
}
|
||||
|
||||
function getResultErrorText(line: RawCliLine): string {
|
||||
if (line.result) return line.result;
|
||||
if (line.errors?.length) return line.errors.join("\n");
|
||||
return `claude CLI failed: ${line.subtype ?? "unknown error"}`;
|
||||
}
|
||||
|
||||
export function parseCliLine(text: string): CliEvent | null {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof parsed !== "object" || parsed === null) return null;
|
||||
|
||||
const line = parsed as RawCliLine;
|
||||
|
||||
switch (line.type) {
|
||||
case "stream_event":
|
||||
return line.event ? parseStreamEvent(line.event) : null;
|
||||
|
||||
case "assistant":
|
||||
if (typeof line.error !== "string") return null;
|
||||
return { type: "assistantError", error: line.error, text: getText(line) };
|
||||
|
||||
case "user": {
|
||||
const blocks = Array.isArray(line.message?.content) ? line.message.content : [];
|
||||
const ids = blocks.flatMap((block) => block.type === "tool_result" && typeof block.tool_use_id === "string" ? [block.tool_use_id] : []);
|
||||
return ids.length ? { type: "toolResults", ids } : null;
|
||||
}
|
||||
|
||||
case "result":
|
||||
if (line.is_error !== true) return null;
|
||||
return { type: "resultError", text: getResultErrorText(line) };
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export { CLAUDE_CODE_PROVIDER } from "../../core/contracts/claude-code.ts";
|
||||
|
||||
export const CLAUDE_CODE_API = "claude-code-cli";
|
||||
|
||||
export enum ClaudeCodeError {
|
||||
CliNotFound = "claude CLI not found",
|
||||
NotSignedIn = "not signed in",
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
const INITIALIZE_TIMEOUT_MS = 5_000;
|
||||
|
||||
/** A best-effort, local-only CLI control query. Wire contract matches the
|
||||
* official SDK0.3.259 source; this does not import or execute the SDK.
|
||||
* Summary deliberately avoids the default full mode's token-count requests.
|
||||
* CLI maxTokens is an effective/auto-compaction budget, not a nominal model max. */
|
||||
export class ContextWindowProbe {
|
||||
private readonly initializeId = randomUUID();
|
||||
private summaryId: string | undefined;
|
||||
private initialized = false;
|
||||
private timer: NodeJS.Timeout | undefined;
|
||||
private ready = false;
|
||||
private closed = false;
|
||||
|
||||
constructor(
|
||||
private readonly write: (line: string) => void,
|
||||
private readonly sendPrompt: () => void,
|
||||
private readonly onWindow: (tokens: number) => void,
|
||||
) {}
|
||||
|
||||
start(): void {
|
||||
this.timer = setTimeout(() => this.proceed(false), INITIALIZE_TIMEOUT_MS);
|
||||
this.timer.unref();
|
||||
this.request(this.initializeId, { subtype: "initialize" });
|
||||
}
|
||||
|
||||
/** Consume control replies separately from model/tool output, including
|
||||
* late/foreign replies while a native question tool is already waiting. */
|
||||
handle(value: unknown): boolean {
|
||||
if (!value || typeof value !== "object" || !("type" in value) || value.type !== "control_response") return false;
|
||||
if (this.closed || !("response" in value) || !value.response || typeof value.response !== "object") return true;
|
||||
const response = value.response as Record<string, unknown>;
|
||||
if (response.request_id === this.initializeId) this.proceed(response.subtype === "success");
|
||||
else if (this.summaryId && response.request_id === this.summaryId) {
|
||||
this.summaryId = undefined;
|
||||
if (response.subtype !== "success") return true;
|
||||
const body = response.response as Record<string, unknown> | undefined;
|
||||
const tokens = body?.maxTokens;
|
||||
if (typeof tokens === "number" && Number.isSafeInteger(tokens) && tokens > 0) this.onWindow(tokens);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Retry when the CLI has begun a model response: pre-first-turn metadata
|
||||
* is not guaranteed by the protocol. Refresh also follows later responses
|
||||
* within the same held process, without opening a second process. */
|
||||
refresh(): void {
|
||||
if (!this.initialized || this.closed) return;
|
||||
this.summaryId = randomUUID();
|
||||
this.request(this.summaryId, { subtype: "get_context_usage", detail: "summary" });
|
||||
}
|
||||
|
||||
close(): void { this.closed = true; clearTimeout(this.timer); }
|
||||
|
||||
private proceed(query: boolean): void {
|
||||
if (this.ready || this.closed) return;
|
||||
this.ready = true;
|
||||
clearTimeout(this.timer);
|
||||
this.initialized = query;
|
||||
if (query) this.refresh();
|
||||
// Never wait for metadata to run the actual user request. No support,
|
||||
// no response, or malformed metadata simply leaves the window unknown.
|
||||
this.sendPrompt();
|
||||
}
|
||||
|
||||
private request(id: string, request: Record<string, string>): void {
|
||||
this.write(JSON.stringify({ type: "control_request", request_id: id, request }));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { tmpdir } from "node:os";
|
||||
import { externalCliEnv } from "../../core/auth-env.ts";
|
||||
import { CLAUDE_CODE_MINIMUM_VERSION, type ClaudeCodeStatus } from "../../core/contracts/claude-code.ts";
|
||||
import { getClaudeBinary } from "./binary.ts";
|
||||
|
||||
export type ReadClaudeVersion = (binary: string) => Promise<string>;
|
||||
|
||||
/** No shell, auth-status command, model call, or credential-file inspection. */
|
||||
export const readClaudeVersion: ReadClaudeVersion = (binary) => new Promise((resolve, reject) => {
|
||||
execFile(binary, ["--version"], {
|
||||
cwd: tmpdir(), env: externalCliEnv(), windowsHide: true,
|
||||
timeout: 5_000, killSignal: "SIGKILL", maxBuffer: 4_096,
|
||||
}, (error, stdout) => error ? reject(error) : resolve(stdout));
|
||||
});
|
||||
|
||||
export async function inspectClaudeInstallation(
|
||||
findBinary: () => string | null = getClaudeBinary,
|
||||
readVersion: ReadClaudeVersion = readClaudeVersion,
|
||||
): Promise<ClaudeCodeStatus> {
|
||||
const base = { version: null, minimumVersion: CLAUDE_CODE_MINIMUM_VERSION };
|
||||
const binary = findBinary();
|
||||
if (!binary) return { ...base, state: "not-found" };
|
||||
try {
|
||||
const output = await readVersion(binary);
|
||||
// Expose only a version, never arbitrary CLI stdout/stderr or an absolute path.
|
||||
const match = /^(\d+)\.(\d+)\.(\d+)(?: \(Claude Code\))?$/.exec(output.trim());
|
||||
if (!match) return { ...base, state: "version-unchecked" };
|
||||
const parts = match.slice(1).map(Number);
|
||||
if (!parts.every(Number.isSafeInteger)) return { ...base, state: "version-unchecked" };
|
||||
const minimum = CLAUDE_CODE_MINIMUM_VERSION.split(".").map(Number);
|
||||
const differing = parts.findIndex((part, index) => part !== minimum[index]);
|
||||
const meetsMinimum = differing === -1 || parts[differing]! > minimum[differing]!;
|
||||
return { ...base, version: parts.join("."), state: meetsMinimum ? "meets-minimum" : "update-required" };
|
||||
} catch {
|
||||
return { ...base, state: "version-unchecked" };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { getClaudeBinary } from "./binary.ts";
|
||||
import { inspectClaudeInstallation, readClaudeVersion, type ReadClaudeVersion } from "./installation.ts";
|
||||
import type { RunClaude } from "./stream.ts";
|
||||
|
||||
export class ClaudeLaunchError extends Error {}
|
||||
|
||||
/** Injectable seams keep all verification offline; production uses the installed CLI. */
|
||||
export function createClaudeLauncher({
|
||||
findBinary = getClaudeBinary,
|
||||
readVersion = readClaudeVersion,
|
||||
spawnProcess = spawn,
|
||||
}: {
|
||||
findBinary?: () => string | null;
|
||||
readVersion?: ReadClaudeVersion;
|
||||
spawnProcess?: typeof spawn;
|
||||
} = {}): RunClaude {
|
||||
return async (args, options) => {
|
||||
if (options.signal?.aborted) throw new ClaudeLaunchError("Request was aborted");
|
||||
const binary = findBinary();
|
||||
if (!binary) return null;
|
||||
const status = await inspectClaudeInstallation(() => binary, readVersion);
|
||||
if (status.state === "update-required") {
|
||||
throw new ClaudeLaunchError(`Claude Code ${status.version} requires an update. LearnHarness requires at least ${status.minimumVersion}; update Claude Code outside LearnHarness.`);
|
||||
}
|
||||
if (status.state !== "meets-minimum") {
|
||||
throw new ClaudeLaunchError("Claude Code's version could not be checked. Check your installation outside LearnHarness and try again.");
|
||||
}
|
||||
if (options.signal?.aborted) throw new ClaudeLaunchError("Request was aborted");
|
||||
return spawnProcess(binary, args, { cwd: options.cwd, env: options.env, stdio: "pipe" });
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
export type LiveProcess = { readonly owner: symbol; readonly sessionId: string | undefined; toolCallIds: string[]; kill(): void };
|
||||
|
||||
// Tools usually call back within seconds, but a question tool holds its call until the learner
|
||||
// answers. Past this, the process is killed and the answer starts a fresh one with a full replay.
|
||||
// It stays under MCP_TOOL_TIMEOUT so the process is killed before the CLI gives up on its own.
|
||||
export const LIVE_PROCESS_IDLE_TIMEOUT_MS = 10 * 60_000;
|
||||
|
||||
/** Test seam (runner.ts's pdfModelOverride idiom). */
|
||||
export const liveProcessIdleTimeoutMs = { current: LIVE_PROCESS_IDLE_TIMEOUT_MS };
|
||||
|
||||
// Each live process mapped to the timer that kills it if pi never calls back.
|
||||
const liveProcesses = new Map<LiveProcess, NodeJS.Timeout>();
|
||||
|
||||
export function addLiveProcess(process: LiveProcess): void {
|
||||
removeLiveProcess(process);
|
||||
const timer = setTimeout(() => {
|
||||
removeLiveProcess(process);
|
||||
process.kill();
|
||||
}, liveProcessIdleTimeoutMs.current);
|
||||
|
||||
timer.unref();
|
||||
|
||||
liveProcesses.set(process, timer);
|
||||
}
|
||||
|
||||
function hasSameIds(ids: string[], otherIds: string[]): boolean {
|
||||
return ids.length === otherIds.length && new Set(otherIds).size === otherIds.length && ids.every((id) => otherIds.includes(id));
|
||||
}
|
||||
|
||||
export function getLiveProcess(toolCallIds: string[], owner: symbol, sessionId: string | undefined): LiveProcess | null {
|
||||
if (!sessionId || toolCallIds.length === 0) return null;
|
||||
|
||||
for (const process of liveProcesses.keys()) {
|
||||
if (process.owner === owner && process.sessionId === sessionId && hasSameIds(process.toolCallIds, toolCallIds)) return process;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function removeLiveProcess(process: LiveProcess): void {
|
||||
clearTimeout(liveProcesses.get(process));
|
||||
liveProcesses.delete(process);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { createProvider, type Api, type Model, type Provider, type SimpleStreamOptions, type StreamFunction } from "@earendil-works/pi-ai";
|
||||
|
||||
import { CLAUDE_CODE_API, CLAUDE_CODE_PROVIDER } from "./constants.ts";
|
||||
|
||||
const SHARED_MODEL_FIELDS = {
|
||||
api: CLAUDE_CODE_API,
|
||||
provider: CLAUDE_CODE_PROVIDER,
|
||||
baseUrl: "",
|
||||
input: ["text", "image"],
|
||||
// Pi-required placeholders, not prices: the CLI's own auth/routing
|
||||
// determines charges. Server pricing and the client catalog report unpriced.
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
reasoning: true,
|
||||
// The CLI has no thinking-off switch; effort low is the floor.
|
||||
thinkingLevelMap: { off: null, minimal: null, low: "low", medium: "medium", high: "high", xhigh: "xhigh", max: "max" },
|
||||
contextWindow: 1_000_000,
|
||||
maxTokens: 128_000,
|
||||
} satisfies Omit<Model<Api>, "id" | "name">;
|
||||
|
||||
export const CLAUDE_CODE_MODELS: readonly Model<Api>[] = [
|
||||
{ ...SHARED_MODEL_FIELDS, id: "opus", name: "Claude Opus" },
|
||||
{ ...SHARED_MODEL_FIELDS, id: "sonnet", name: "Claude Sonnet" },
|
||||
{ ...SHARED_MODEL_FIELDS, id: "haiku", name: "Claude Haiku" },
|
||||
];
|
||||
|
||||
export function createClaudeCodeProvider(streamSimple: StreamFunction<Api, SimpleStreamOptions>): Provider {
|
||||
return createProvider({
|
||||
id: CLAUDE_CODE_PROVIDER,
|
||||
name: "Claude Code",
|
||||
auth: { apiKey: { name: "Claude Code CLI", resolve: async () => ({ auth: {}, source: "claude CLI" }) } },
|
||||
models: CLAUDE_CODE_MODELS,
|
||||
api: { stream: streamSimple, streamSimple },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { Api, Message, Model, TranscriptContext } from "@earendil-works/pi-ai";
|
||||
import { getCurrentSystemPrompt, getCurrentTools } from "@earendil-works/pi-ai/utils/transcript";
|
||||
|
||||
/** Compare model-visible history, not app IDs, timestamps, usage, or Pi's added thinkingLevel. */
|
||||
export function conversationForReuse(messages: Message[]): unknown[] {
|
||||
return messages.flatMap((message): unknown[] => {
|
||||
switch (message.role) {
|
||||
case "system": return [];
|
||||
case "user": return [{ role: message.role, content: message.content }];
|
||||
case "assistant": return [{ role: message.role, content: message.content }];
|
||||
case "toolResult": return [{
|
||||
role: message.role,
|
||||
toolCallId: message.toolCallId,
|
||||
toolName: message.toolName,
|
||||
content: message.content,
|
||||
isError: message.isError,
|
||||
}];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function configurationForReuse(model: Model<Api>, context: TranscriptContext, effort: string): unknown {
|
||||
const systemPrompt = getCurrentSystemPrompt(context.messages);
|
||||
const tools = getCurrentTools(context.messages);
|
||||
// The wire is JSON: TypeBox's schema symbols are not CLI configuration.
|
||||
return JSON.parse(JSON.stringify({ model, effort, systemPrompt, tools })) as unknown;
|
||||
}
|
||||
@@ -0,0 +1,695 @@
|
||||
import { type ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { createInterface } from "node:readline";
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
|
||||
import {
|
||||
createAssistantMessageEventStream,
|
||||
type Api,
|
||||
type AssistantMessage,
|
||||
type AssistantMessageEventStream,
|
||||
type Message,
|
||||
type Model,
|
||||
type SimpleStreamOptions,
|
||||
type StopReason,
|
||||
type StreamFunction,
|
||||
type ThinkingLevel,
|
||||
type ToolCall,
|
||||
type ToolResultMessage,
|
||||
type TranscriptContext,
|
||||
} from "@earendil-works/pi-ai";
|
||||
import type { AnthropicEffort } from "@earendil-works/pi-ai/api/anthropic-messages";
|
||||
import { parseStreamingJson, parseToolCallArguments } from "@earendil-works/pi-ai/utils/json-parse";
|
||||
|
||||
import { externalCliEnv } from "../../core/auth-env.ts";
|
||||
import type { LhMessage } from "../../core/native-messages.ts";
|
||||
import { ClaudeLaunchError, createClaudeLauncher } from "./launch.ts";
|
||||
import { parseCliLine, type CliBlock, type CliEvent, type CliUsage } from "./cli-events.ts";
|
||||
import { ClaudeCodeError } from "./constants.ts";
|
||||
import { ContextWindowProbe } from "./context-window.ts";
|
||||
import { addLiveProcess, getLiveProcess, removeLiveProcess, type LiveProcess } from "./live-processes.ts";
|
||||
import { configurationForReuse, conversationForReuse } from "./reuse-context.ts";
|
||||
import { BRIDGE_SERVER_NAME, createToolBridge, toolNameFromMcp, type PendingToolCall, type ToolBridge } from "./tool-bridge.ts";
|
||||
import { toClaudeInput, toStreamJsonLine } from "./transcript.ts";
|
||||
|
||||
type RunClaudeOptions = { cwd: string; env: NodeJS.ProcessEnv; signal?: AbortSignal };
|
||||
|
||||
type ClaudeChild = ChildProcessWithoutNullStreams | null;
|
||||
export type RunClaude = (args: string[], options: RunClaudeOptions) => ClaudeChild | Promise<ClaudeChild>;
|
||||
|
||||
// A held MCP call lasts until pi calls back with the tool result, which for a question tool is
|
||||
// whenever the learner answers; this must outlast the live-process idle timeout so the CLI never
|
||||
// times out first.
|
||||
const MCP_TOOL_TIMEOUT_MS = 30 * 60_000;
|
||||
|
||||
const PRIVATE_FILE_MODE = 0o600;
|
||||
|
||||
const EFFORTS: Record<ThinkingLevel, AnthropicEffort> = {
|
||||
minimal: "low",
|
||||
low: "low",
|
||||
medium: "medium",
|
||||
high: "high",
|
||||
xhigh: "xhigh",
|
||||
max: "max",
|
||||
};
|
||||
|
||||
const STOP_REASONS: Record<string, StopReason> = {
|
||||
end_turn: "stop",
|
||||
pause_turn: "stop",
|
||||
stop_sequence: "stop",
|
||||
max_tokens: "length",
|
||||
tool_use: "toolUse",
|
||||
refusal: "error",
|
||||
};
|
||||
|
||||
const REFUSAL_MESSAGE = "The model refused to complete the request";
|
||||
const ABORT_MESSAGE = "Request was aborted";
|
||||
const START_FAILED_MESSAGE = "claude CLI failed to start";
|
||||
const NO_STOP_REASON_MESSAGE = "claude CLI ended the message without a stop reason";
|
||||
const NOT_SIGNED_IN_ERROR = "authentication_failed";
|
||||
|
||||
export function toEffort(level: ThinkingLevel | undefined): AnthropicEffort {
|
||||
return level ? EFFORTS[level] : "medium";
|
||||
}
|
||||
|
||||
type ClaudeArgsInput = {
|
||||
model: string;
|
||||
effort: AnthropicEffort;
|
||||
systemPromptFile: string;
|
||||
mcpConfigFile: string;
|
||||
allowedTools: string[];
|
||||
};
|
||||
|
||||
export function toClaudeArgs(input: ClaudeArgsInput): string[] {
|
||||
return [
|
||||
"-p",
|
||||
"--input-format",
|
||||
"stream-json",
|
||||
"--output-format",
|
||||
"stream-json",
|
||||
"--verbose",
|
||||
"--include-partial-messages",
|
||||
"--model",
|
||||
input.model,
|
||||
"--effort",
|
||||
input.effort,
|
||||
"--system-prompt-file",
|
||||
input.systemPromptFile,
|
||||
"--tools",
|
||||
"",
|
||||
"--strict-mcp-config",
|
||||
"--mcp-config",
|
||||
input.mcpConfigFile,
|
||||
"--setting-sources",
|
||||
"user",
|
||||
"--settings",
|
||||
JSON.stringify({ disableAllHooks: true }),
|
||||
"--disable-slash-commands",
|
||||
"--no-session-persistence",
|
||||
"--permission-prompts",
|
||||
"none",
|
||||
...(input.allowedTools.length > 0 ? ["--allowedTools", input.allowedTools.join(",")] : []),
|
||||
];
|
||||
}
|
||||
|
||||
function createOutput(model: Model<Api>): AssistantMessage {
|
||||
return {
|
||||
role: "assistant",
|
||||
content: [],
|
||||
api: model.api,
|
||||
provider: model.provider,
|
||||
model: model.id,
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "pending",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
function getAssistantErrorMessage(error: string, text: string): string {
|
||||
return error === NOT_SIGNED_IN_ERROR ? ClaudeCodeError.NotSignedIn : text || error;
|
||||
}
|
||||
|
||||
class ClaudeTurn {
|
||||
private readonly output: AssistantMessage & Pick<LhMessage, "cliContextWindow">;
|
||||
private readonly contentIndexes = new Map<number, number>();
|
||||
private readonly partialJson = new Map<number, string>();
|
||||
private readonly toolBlocks = new Map<number, ToolCall>();
|
||||
private readonly intents: ToolCall[] = [];
|
||||
started = false;
|
||||
finished = false;
|
||||
|
||||
constructor(
|
||||
private readonly stream: AssistantMessageEventStream,
|
||||
model: Model<Api>,
|
||||
) {
|
||||
this.output = createOutput(model);
|
||||
}
|
||||
|
||||
handle(event: CliEvent): void {
|
||||
if (this.finished) return;
|
||||
|
||||
switch (event.type) {
|
||||
case "messageStart":
|
||||
this.updateUsage(event.usage);
|
||||
this.start();
|
||||
return;
|
||||
|
||||
case "blockStart":
|
||||
this.startBlock(event.index, event.block);
|
||||
return;
|
||||
|
||||
case "textDelta": {
|
||||
const found = this.getBlock(event.index);
|
||||
if (!found) return;
|
||||
|
||||
const { contentIndex, block } = found;
|
||||
if (block.type !== "text") return;
|
||||
|
||||
block.text += event.text;
|
||||
this.stream.push({ type: "text_delta", contentIndex, delta: event.text, partial: this.output });
|
||||
return;
|
||||
}
|
||||
|
||||
case "thinkingDelta": {
|
||||
const found = this.getBlock(event.index);
|
||||
if (!found) return;
|
||||
|
||||
const { contentIndex, block } = found;
|
||||
if (block.type !== "thinking") return;
|
||||
|
||||
block.thinking += event.thinking;
|
||||
this.stream.push({ type: "thinking_delta", contentIndex, delta: event.thinking, partial: this.output });
|
||||
return;
|
||||
}
|
||||
|
||||
case "toolInputDelta": {
|
||||
const found = this.getBlock(event.index);
|
||||
if (!found) return;
|
||||
|
||||
const { block } = found;
|
||||
if (block.type !== "toolCall") return;
|
||||
|
||||
const partialJson = (this.partialJson.get(event.index) ?? "") + event.partialJson;
|
||||
this.partialJson.set(event.index, partialJson);
|
||||
block.arguments = parseStreamingJson(partialJson);
|
||||
return;
|
||||
}
|
||||
|
||||
case "blockStop":
|
||||
this.stopBlock(event.index);
|
||||
return;
|
||||
|
||||
case "messageDelta":
|
||||
this.updateUsage(event.usage);
|
||||
if (event.stopReason) this.setStopReason(event.stopReason);
|
||||
return;
|
||||
|
||||
case "messageStop":
|
||||
// Tool intent is not execution admission. The process publishes only
|
||||
// calls that actually reach the authenticated MCP bridge.
|
||||
if (this.output.stopReason !== "toolUse") this.finish();
|
||||
return;
|
||||
|
||||
case "assistantError":
|
||||
this.fail(getAssistantErrorMessage(event.error, event.text), "error");
|
||||
return;
|
||||
|
||||
case "resultError":
|
||||
this.fail(event.text, "error");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
get waitingForAdmission(): boolean { return !this.finished && this.output.stopReason === "toolUse"; }
|
||||
|
||||
getIntents(): ToolCall[] { return this.intents; }
|
||||
|
||||
getMessage(): AssistantMessage { return this.output; }
|
||||
|
||||
setContextWindow(tokens: number | undefined): void {
|
||||
if (!this.finished && tokens !== undefined) this.output.cliContextWindow = tokens;
|
||||
}
|
||||
|
||||
admit(calls: ToolCall[]): void {
|
||||
this.start();
|
||||
for (const call of calls) {
|
||||
const contentIndex = this.output.content.length;
|
||||
this.output.content.push(call);
|
||||
this.stream.push({ type: "toolcall_start", contentIndex, partial: this.output });
|
||||
this.stream.push({ type: "toolcall_end", contentIndex, toolCall: call, partial: this.output });
|
||||
}
|
||||
this.output.stopReason = "toolUse";
|
||||
this.finish();
|
||||
}
|
||||
|
||||
fail(message: string, reason: "aborted" | "error"): void {
|
||||
if (this.finished) return;
|
||||
|
||||
this.finished = true;
|
||||
this.output.stopReason = reason;
|
||||
this.output.errorMessage = message;
|
||||
this.stream.push({ type: "error", reason, error: this.output });
|
||||
this.stream.end();
|
||||
}
|
||||
|
||||
private start(): void {
|
||||
if (this.started) return;
|
||||
|
||||
this.started = true;
|
||||
this.stream.push({ type: "start", partial: this.output });
|
||||
}
|
||||
|
||||
private startBlock(index: number, block: CliBlock): void {
|
||||
const contentIndex = this.output.content.length;
|
||||
this.contentIndexes.set(index, contentIndex);
|
||||
|
||||
switch (block.type) {
|
||||
case "text":
|
||||
this.output.content.push({ type: "text", text: "" });
|
||||
this.stream.push({ type: "text_start", contentIndex, partial: this.output });
|
||||
return;
|
||||
|
||||
case "thinking":
|
||||
this.output.content.push({ type: "thinking", thinking: "" });
|
||||
this.stream.push({ type: "thinking_start", contentIndex, partial: this.output });
|
||||
return;
|
||||
|
||||
case "toolUse":
|
||||
if (!block.name.startsWith(`mcp__${BRIDGE_SERVER_NAME}__`)) {
|
||||
this.fail("claude CLI emitted an unsupported native tool call", "error");
|
||||
return;
|
||||
}
|
||||
this.toolBlocks.set(index, { type: "toolCall", id: block.id, name: toolNameFromMcp(block.name), arguments: {} });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private getBlock(index: number): { contentIndex: number; block: AssistantMessage["content"][number] } | null {
|
||||
const tool = this.toolBlocks.get(index);
|
||||
if (tool) return { contentIndex: -1, block: tool };
|
||||
const contentIndex = this.contentIndexes.get(index);
|
||||
if (contentIndex === undefined) return null;
|
||||
|
||||
const block = this.output.content[contentIndex];
|
||||
return block ? { contentIndex, block } : null;
|
||||
}
|
||||
|
||||
private stopBlock(index: number): void {
|
||||
const found = this.getBlock(index);
|
||||
if (!found) return;
|
||||
|
||||
const { contentIndex, block } = found;
|
||||
this.contentIndexes.delete(index);
|
||||
|
||||
switch (block.type) {
|
||||
case "text":
|
||||
this.stream.push({ type: "text_end", contentIndex, content: block.text, partial: this.output });
|
||||
return;
|
||||
|
||||
case "thinking":
|
||||
this.stream.push({ type: "thinking_end", contentIndex, content: block.thinking, partial: this.output });
|
||||
return;
|
||||
|
||||
case "toolCall":
|
||||
Object.assign(block, parseToolCallArguments(this.partialJson.get(index) ?? ""));
|
||||
this.partialJson.delete(index);
|
||||
this.toolBlocks.delete(index);
|
||||
this.intents.push(block);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private updateUsage(usage: CliUsage): void {
|
||||
Object.assign(this.output.usage, usage);
|
||||
|
||||
const { input, output, cacheRead, cacheWrite } = this.output.usage;
|
||||
this.output.usage.totalTokens = input + output + cacheRead + cacheWrite;
|
||||
}
|
||||
|
||||
private setStopReason(stopReason: string): void {
|
||||
const mapped = STOP_REASONS[stopReason];
|
||||
|
||||
if (!mapped) {
|
||||
this.output.stopReason = "error";
|
||||
this.output.errorMessage = `Unhandled stop reason: ${stopReason}`;
|
||||
return;
|
||||
}
|
||||
|
||||
this.output.stopReason = mapped;
|
||||
}
|
||||
|
||||
private finish(): void {
|
||||
const { stopReason } = this.output;
|
||||
|
||||
if (stopReason === "error") {
|
||||
this.fail(this.output.errorMessage ?? REFUSAL_MESSAGE, "error");
|
||||
return;
|
||||
}
|
||||
|
||||
if (stopReason === "pending" || stopReason === "aborted") {
|
||||
this.fail(this.output.errorMessage ?? NO_STOP_REASON_MESSAGE, "error");
|
||||
return;
|
||||
}
|
||||
|
||||
this.finished = true;
|
||||
this.stream.push({ type: "done", reason: stopReason, message: this.output });
|
||||
this.stream.end();
|
||||
}
|
||||
}
|
||||
|
||||
type TurnFiles = { cwd: string; privateDir: string; systemPromptFile: string; mcpConfigFile: string };
|
||||
|
||||
async function createTurnFiles(systemPrompt: string, mcpConfig: string): Promise<TurnFiles> {
|
||||
const cwd = await mkdtemp(join(tmpdir(), "claude-code-cwd-"));
|
||||
let privateDir: string | undefined;
|
||||
try {
|
||||
privateDir = await mkdtemp(join(tmpdir(), "claude-code-private-"));
|
||||
const systemPromptFile = join(privateDir, "system-prompt.md");
|
||||
const mcpConfigFile = join(privateDir, "mcp.json");
|
||||
await writeFile(systemPromptFile, systemPrompt, { mode: PRIVATE_FILE_MODE });
|
||||
await writeFile(mcpConfigFile, mcpConfig, { mode: PRIVATE_FILE_MODE });
|
||||
return { cwd, privateDir, systemPromptFile, mcpConfigFile };
|
||||
} catch (error) {
|
||||
await Promise.all([rm(cwd, { recursive: true, force: true }), ...(privateDir ? [rm(privateDir, { recursive: true, force: true })] : [])]);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeTurnResources(bridge: ToolBridge, files: TurnFiles): Promise<void> {
|
||||
await Promise.all([
|
||||
bridge.close(),
|
||||
rm(files.cwd, { recursive: true, force: true }),
|
||||
rm(files.privateDir, { recursive: true, force: true }),
|
||||
]);
|
||||
}
|
||||
|
||||
class ClaudeProcess implements LiveProcess {
|
||||
toolCallIds: string[] = [];
|
||||
private stderr = "";
|
||||
private closed = false;
|
||||
private stopping = false;
|
||||
private killTimer: NodeJS.Timeout | undefined;
|
||||
private signal: AbortSignal | undefined;
|
||||
private sourceCalls: ToolCall[] = [];
|
||||
private sourceComplete = false;
|
||||
private admitted: PendingToolCall[] = [];
|
||||
private readonly cancelledIds = new Set<string>();
|
||||
private pendingError: string | undefined;
|
||||
private contextWindow: number | undefined;
|
||||
private contextProbe: ContextWindowProbe | undefined;
|
||||
|
||||
constructor(
|
||||
private readonly child: ChildProcessWithoutNullStreams,
|
||||
private readonly bridge: ToolBridge,
|
||||
private readonly files: TurnFiles,
|
||||
private turn: ClaudeTurn,
|
||||
readonly owner: symbol,
|
||||
readonly sessionId: string | undefined,
|
||||
private readonly configuration: unknown,
|
||||
private conversation: unknown[],
|
||||
) {}
|
||||
|
||||
start(stdinLine: string, signal: AbortSignal | undefined): void {
|
||||
this.child.once("close", (code: number | null) => this.handleClose(`claude CLI exited with code ${code}`));
|
||||
this.child.once("error", (error) => {
|
||||
console.error("claude CLI process failed:", error);
|
||||
this.turn.fail(START_FAILED_MESSAGE, "error");
|
||||
this.handleClose(START_FAILED_MESSAGE);
|
||||
});
|
||||
this.child.stderr.setEncoding("utf8");
|
||||
this.child.stderr.on("data", (chunk: string) => { this.stderr = (this.stderr + chunk).slice(-65_536); });
|
||||
this.child.stdin.on("error", (error) => console.error("claude CLI stdin closed early:", error));
|
||||
this.contextProbe = new ContextWindowProbe(
|
||||
(line) => this.child.stdin.write(`${line}\n`),
|
||||
() => { if (!this.stopping) this.child.stdin.write(`${stdinLine}\n`); },
|
||||
(tokens) => { this.contextWindow = tokens; this.turn.setContextWindow(tokens); },
|
||||
);
|
||||
createInterface({ input: this.child.stdout }).on("line", (line) => {
|
||||
try { this.handleLine(line); }
|
||||
catch { this.fail("claude CLI emitted malformed stream output"); }
|
||||
});
|
||||
this.setSignal(signal);
|
||||
if (!this.stopping) this.contextProbe.start();
|
||||
}
|
||||
|
||||
canContinue(owner: symbol, sessionId: string | undefined, configuration: unknown, messages: Message[], results: ToolResultMessage[]): boolean {
|
||||
const conversation = conversationForReuse(messages);
|
||||
return !this.stopping && owner === this.owner && !!sessionId && sessionId === this.sessionId
|
||||
&& isDeepStrictEqual(configuration, this.configuration)
|
||||
&& isDeepStrictEqual(conversation.slice(0, this.conversation.length), this.conversation)
|
||||
&& conversation.length === this.conversation.length + this.admitted.length
|
||||
&& results.length === this.admitted.length
|
||||
&& new Set(results.map((result) => result.toolCallId)).size === results.length
|
||||
&& this.admitted.every((call) => results.some((result) => result.toolCallId === call.id && result.toolName === call.name));
|
||||
}
|
||||
|
||||
continueTurn(turn: ClaudeTurn, messages: Message[], results: ToolResultMessage[], signal: AbortSignal | undefined): void {
|
||||
removeLiveProcess(this);
|
||||
const admitted = this.admitted;
|
||||
this.admitted = [];
|
||||
this.conversation = structuredClone(conversationForReuse(messages));
|
||||
this.turn = turn;
|
||||
this.turn.setContextWindow(this.contextWindow);
|
||||
this.sourceComplete = this.sourceCalls.length > 0;
|
||||
this.setSignal(signal);
|
||||
if (this.stopping) return;
|
||||
for (const call of admitted) {
|
||||
const result = results.find((candidate) => candidate.toolCallId === call.id)!;
|
||||
this.bridge.resolveToolCall(call, result);
|
||||
}
|
||||
this.flushAdmissions();
|
||||
}
|
||||
|
||||
kill(): void {
|
||||
removeLiveProcess(this);
|
||||
if (this.stopping || this.closed) return;
|
||||
this.stopping = true;
|
||||
this.contextProbe?.close();
|
||||
this.child.kill();
|
||||
this.killTimer = setTimeout(() => this.child.kill("SIGKILL"), 1000);
|
||||
this.killTimer.unref();
|
||||
}
|
||||
|
||||
fail(message: string): void {
|
||||
// A parallel request can fail after the admitted slice was handed to
|
||||
// Pi. Retain that failure for its continuation instead of silently
|
||||
// replaying and hiding the compatibility error.
|
||||
if (this.sessionId && this.turn.finished && this.toolCallIds.length > 0) this.pendingError = message;
|
||||
this.turn.fail(message, "error");
|
||||
this.kill();
|
||||
if (this.pendingError) addLiveProcess(this);
|
||||
}
|
||||
|
||||
consumeFailure(owner: symbol, turn: ClaudeTurn): boolean {
|
||||
if (owner !== this.owner || !this.pendingError) return false;
|
||||
const message = this.pendingError;
|
||||
this.pendingError = undefined;
|
||||
this.kill();
|
||||
turn.fail(message, "error");
|
||||
return true;
|
||||
}
|
||||
|
||||
cancelCall(call: PendingToolCall): void {
|
||||
this.cancelledIds.add(call.id);
|
||||
// Already admitted work belongs to Pi and cannot be undone. A queued
|
||||
// request, however, must never become executable after cancellation.
|
||||
this.sourceCalls = this.sourceCalls.filter((intent) => intent.id !== call.id);
|
||||
}
|
||||
|
||||
/** Only authenticated MCP requests, never stdout intent alone, admit side effects. */
|
||||
flushAdmissions(): void {
|
||||
if (this.stopping || this.turn.finished || !this.sourceComplete) return;
|
||||
const pending = this.bridge.getPendingToolCalls();
|
||||
if (pending.length === 0) return;
|
||||
const calls: ToolCall[] = [];
|
||||
for (const call of pending) {
|
||||
const intent = this.sourceCalls.find((candidate) => candidate.id === call.id);
|
||||
if (!intent || intent.name !== call.name || !isDeepStrictEqual(intent.arguments, call.arguments)) {
|
||||
this.fail("claude CLI compatibility error: MCP call does not match its streamed tool intent");
|
||||
return;
|
||||
}
|
||||
calls.push(intent);
|
||||
}
|
||||
this.sourceCalls = this.sourceCalls.filter((call) => !pending.some((admitted) => admitted.id === call.id));
|
||||
this.admitted = pending;
|
||||
this.toolCallIds = pending.map((call) => call.id);
|
||||
this.turn.admit(calls);
|
||||
this.conversation.push(...structuredClone(conversationForReuse([this.turn.getMessage()])));
|
||||
if (this.sessionId) addLiveProcess(this);
|
||||
else this.kill(); // No trusted scope: return the batch, then use fresh replay.
|
||||
}
|
||||
|
||||
private readonly abort = () => {
|
||||
this.turn.fail(ABORT_MESSAGE, "aborted");
|
||||
this.kill();
|
||||
};
|
||||
|
||||
private setSignal(signal: AbortSignal | undefined): void {
|
||||
this.signal?.removeEventListener("abort", this.abort);
|
||||
this.signal = signal;
|
||||
signal?.addEventListener("abort", this.abort, { once: true });
|
||||
if (signal?.aborted) this.abort();
|
||||
}
|
||||
|
||||
private handleLine(line: string): void {
|
||||
if (this.stopping || !line.trim()) return;
|
||||
if (this.contextProbe?.handle(JSON.parse(line))) return;
|
||||
const event = parseCliLine(line);
|
||||
if (!event) return;
|
||||
if (event.type === "toolResults") {
|
||||
if (event.ids.some((id) => this.sourceCalls.some((call) => call.id === id))) {
|
||||
this.fail("claude CLI refused a harness tool call before MCP admission");
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Moving on before held calls were answered means the CLI did not
|
||||
// honor this tool wait. Never execute further stdout-only intentions.
|
||||
if (this.turn.finished) {
|
||||
this.fail("claude CLI moved on while harness tool calls were waiting for results");
|
||||
return;
|
||||
}
|
||||
if (event.type === "messageStart" && this.sourceComplete && this.sourceCalls.length > 0) {
|
||||
this.fail("claude CLI moved on without admitting its harness tool calls");
|
||||
return;
|
||||
}
|
||||
if (event.type === "messageStart") this.contextProbe?.refresh();
|
||||
this.turn.handle(event);
|
||||
if (this.turn.finished) { this.kill(); return; }
|
||||
if (event.type === "messageStop" && this.turn.waitingForAdmission) {
|
||||
const intents = this.turn.getIntents();
|
||||
if (intents.length === 0 || new Set(intents.map((call) => call.id)).size !== intents.length) {
|
||||
this.fail("claude CLI emitted an invalid tool batch");
|
||||
return;
|
||||
}
|
||||
this.sourceCalls = intents.filter((call) => !this.cancelledIds.has(call.id));
|
||||
this.sourceComplete = true;
|
||||
this.flushAdmissions();
|
||||
}
|
||||
}
|
||||
|
||||
private handleClose(message: string): void {
|
||||
if (this.closed) return;
|
||||
this.closed = true;
|
||||
this.contextProbe?.close();
|
||||
clearTimeout(this.killTimer);
|
||||
removeLiveProcess(this);
|
||||
if (this.pendingError) addLiveProcess(this);
|
||||
this.signal?.removeEventListener("abort", this.abort);
|
||||
if (this.stderr.trim()) console.error("claude CLI stderr:", this.stderr.trim());
|
||||
this.turn.fail(message, "error");
|
||||
removeTurnResources(this.bridge, this.files).catch((error: unknown) => {
|
||||
console.error("claude CLI cleanup failed:", error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function continueLiveProcess(turn: ClaudeTurn, owner: symbol, sessionId: string | undefined, configuration: unknown, messages: Message[], signal: AbortSignal | undefined): boolean {
|
||||
const assistantIndex = messages.findLastIndex((message) => message.role === "assistant");
|
||||
const assistant = messages[assistantIndex];
|
||||
if (assistant?.role !== "assistant") return false;
|
||||
const ids = assistant.content.flatMap((block) => block.type === "toolCall" ? [block.id] : []);
|
||||
const process = getLiveProcess(ids, owner, sessionId);
|
||||
if (!(process instanceof ClaudeProcess)) return false;
|
||||
if (process.consumeFailure(owner, turn)) return true;
|
||||
const trailing = messages.slice(assistantIndex + 1);
|
||||
const results = trailing.filter((message) => message.role === "toolResult");
|
||||
if (trailing.length !== results.length || !process.canContinue(owner, sessionId, configuration, messages, results)) {
|
||||
process.kill();
|
||||
return false;
|
||||
}
|
||||
process.continueTurn(turn, messages, results, signal);
|
||||
return true;
|
||||
}
|
||||
|
||||
function toClaudeEnv(): NodeJS.ProcessEnv {
|
||||
// Preserve native CLI auth/routing, including launch-environment credentials.
|
||||
// Claude Code chooses the account and billing method, not LearnHarness.
|
||||
return { ...externalCliEnv(), MCP_TOOL_TIMEOUT: String(MCP_TOOL_TIMEOUT_MS) };
|
||||
}
|
||||
|
||||
async function startProcess(
|
||||
runClaude: RunClaude,
|
||||
turn: ClaudeTurn,
|
||||
model: Model<Api>,
|
||||
context: TranscriptContext,
|
||||
options: SimpleStreamOptions | undefined,
|
||||
owner: symbol,
|
||||
configuration: unknown,
|
||||
env: NodeJS.ProcessEnv,
|
||||
): Promise<void> {
|
||||
if (options?.signal?.aborted) { turn.fail(ABORT_MESSAGE, "aborted"); return; }
|
||||
const input = toClaudeInput(context.messages);
|
||||
let cliProcess: ClaudeProcess | undefined;
|
||||
const bridge = await createToolBridge(input.tools, {
|
||||
onCall: () => cliProcess?.flushAdmissions(),
|
||||
onError: (error) => cliProcess?.fail(error.message),
|
||||
onCancel: (call) => cliProcess?.cancelCall(call),
|
||||
});
|
||||
let files: TurnFiles;
|
||||
try {
|
||||
files = await createTurnFiles(input.systemPrompt, bridge.mcpConfig);
|
||||
} catch (error) {
|
||||
await bridge.close();
|
||||
throw error;
|
||||
}
|
||||
if (options?.signal?.aborted) {
|
||||
turn.fail(ABORT_MESSAGE, "aborted");
|
||||
await removeTurnResources(bridge, files);
|
||||
return;
|
||||
}
|
||||
|
||||
let child: ChildProcessWithoutNullStreams | null;
|
||||
try {
|
||||
const args = toClaudeArgs({
|
||||
model: model.id,
|
||||
effort: toEffort(options?.reasoning),
|
||||
systemPromptFile: files.systemPromptFile,
|
||||
mcpConfigFile: files.mcpConfigFile,
|
||||
allowedTools: bridge.allowedTools,
|
||||
});
|
||||
child = await runClaude(args, { cwd: files.cwd, env, signal: options?.signal });
|
||||
} catch (error) {
|
||||
await removeTurnResources(bridge, files);
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!child) {
|
||||
turn.fail(ClaudeCodeError.CliNotFound, "error");
|
||||
await removeTurnResources(bridge, files);
|
||||
return;
|
||||
}
|
||||
|
||||
cliProcess = new ClaudeProcess(child, bridge, files, turn, owner, options?.sessionId, configuration, structuredClone(conversationForReuse(context.messages)));
|
||||
cliProcess.start(toStreamJsonLine(input.userContent), options?.signal);
|
||||
}
|
||||
|
||||
export function createClaudeCodeStreamFunction(runClaude: RunClaude): StreamFunction<Api, SimpleStreamOptions> {
|
||||
const owner = Symbol("claude-code stream owner");
|
||||
return (model, context, options) => {
|
||||
const stream = createAssistantMessageEventStream();
|
||||
const turn = new ClaudeTurn(stream, model);
|
||||
|
||||
const env = toClaudeEnv();
|
||||
// An existing child must not retain stale authentication/routing after env changes.
|
||||
const configuration = { request: configurationForReuse(model, context, toEffort(options?.reasoning)), env };
|
||||
if (continueLiveProcess(turn, owner, options?.sessionId, configuration, context.messages, options?.signal)) return stream;
|
||||
|
||||
startProcess(runClaude, turn, model, context, options, owner, configuration, env).catch((error: unknown) => {
|
||||
console.error("claude CLI turn failed:", error);
|
||||
turn.fail(options?.signal?.aborted ? ABORT_MESSAGE : error instanceof ClaudeLaunchError ? error.message : START_FAILED_MESSAGE, options?.signal?.aborted ? "aborted" : "error");
|
||||
});
|
||||
|
||||
return stream;
|
||||
};
|
||||
}
|
||||
|
||||
export const streamClaudeCode = createClaudeCodeStreamFunction(createClaudeLauncher());
|
||||
@@ -0,0 +1,234 @@
|
||||
import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
||||
import * as http from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
|
||||
import type { ImageContent, JsonObject, TextContent, Tool, ToolResultMessage } from "@earendil-works/pi-ai";
|
||||
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
||||
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
||||
import {
|
||||
CallToolRequestSchema,
|
||||
ErrorCode,
|
||||
ListToolsRequestSchema,
|
||||
McpError,
|
||||
type CallToolResult,
|
||||
type Tool as McpTool,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
|
||||
export const BRIDGE_SERVER_NAME = "learnharness";
|
||||
|
||||
const MCP_TOOL_PREFIX = `mcp__${BRIDGE_SERVER_NAME}__`;
|
||||
const LOOPBACK_HOST = "127.0.0.1";
|
||||
const TOKEN_BYTES = 32;
|
||||
|
||||
export type PendingToolCall = { id: string; name: string; arguments: JsonObject };
|
||||
|
||||
type BridgeCallbacks = { onCall(): void; onError(error: Error): void; onCancel?(call: PendingToolCall): void };
|
||||
|
||||
export const MCP_IDENTITY_ERROR = "claude CLI compatibility error: MCP calls must include a unique claudecode/toolUseId";
|
||||
|
||||
export type ToolBridge = {
|
||||
mcpConfig: string;
|
||||
allowedTools: string[];
|
||||
waitForToolCalls(count: number): Promise<PendingToolCall[]>;
|
||||
resolveToolCall(call: PendingToolCall, result: ToolResultMessage): void;
|
||||
getPendingToolCalls(): PendingToolCall[];
|
||||
close(): Promise<void>;
|
||||
};
|
||||
|
||||
type ToolCallWaiter = {
|
||||
count: number;
|
||||
resolve: (calls: PendingToolCall[]) => void;
|
||||
reject: (error: Error) => void;
|
||||
};
|
||||
|
||||
export function toolNameFromMcp(name: string): string {
|
||||
return name.startsWith(MCP_TOOL_PREFIX) ? name.slice(MCP_TOOL_PREFIX.length) : name;
|
||||
}
|
||||
|
||||
function toMcpContent(block: TextContent | ImageContent): CallToolResult["content"][number] {
|
||||
switch (block.type) {
|
||||
case "text":
|
||||
return { type: "text", text: block.text };
|
||||
|
||||
case "image":
|
||||
return { type: "image", data: block.data, mimeType: block.mimeType };
|
||||
}
|
||||
}
|
||||
|
||||
function toCallToolResult(result: ToolResultMessage): CallToolResult {
|
||||
return { content: result.content.map(toMcpContent), isError: result.isError };
|
||||
}
|
||||
|
||||
function toMcpTool(tool: Tool): McpTool {
|
||||
return { name: tool.name, description: tool.description, inputSchema: tool.parameters as McpTool["inputSchema"] };
|
||||
}
|
||||
|
||||
class ToolCallQueue {
|
||||
private readonly results = new Map<PendingToolCall, (result: CallToolResult) => void>();
|
||||
private waiters: ToolCallWaiter[] = [];
|
||||
|
||||
add(call: PendingToolCall, signal: AbortSignal, onCancel?: (call: PendingToolCall) => void): Promise<CallToolResult> {
|
||||
const result = new Promise<CallToolResult>((resolve) => {
|
||||
const cancel = () => {
|
||||
const settle = this.results.get(call);
|
||||
if (!settle) return;
|
||||
settle({ content: [{ type: "text", text: "MCP request cancelled" }], isError: true });
|
||||
onCancel?.(call);
|
||||
};
|
||||
this.results.set(call, (result) => {
|
||||
this.results.delete(call);
|
||||
signal.removeEventListener("abort", cancel);
|
||||
resolve(result);
|
||||
});
|
||||
signal.addEventListener("abort", cancel, { once: true });
|
||||
if (signal.aborted) cancel();
|
||||
});
|
||||
|
||||
this.wakeWaiters();
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
waitFor(count: number): Promise<PendingToolCall[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.waiters.push({ count, resolve, reject });
|
||||
this.wakeWaiters();
|
||||
});
|
||||
}
|
||||
|
||||
resolve(call: PendingToolCall, result: ToolResultMessage): void {
|
||||
this.results.get(call)?.(toCallToolResult(result));
|
||||
}
|
||||
|
||||
list(): PendingToolCall[] {
|
||||
return [...this.results.keys()];
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
for (const settle of this.results.values()) {
|
||||
settle({ content: [{ type: "text", text: "MCP bridge closed" }], isError: true });
|
||||
}
|
||||
|
||||
for (const waiter of this.waiters) waiter.reject(new Error("claude-code tool bridge closed"));
|
||||
this.waiters = [];
|
||||
}
|
||||
|
||||
private wakeWaiters(): void {
|
||||
const calls = this.list();
|
||||
|
||||
const ready = this.waiters.filter((waiter) => calls.length >= waiter.count);
|
||||
this.waiters = this.waiters.filter((waiter) => calls.length < waiter.count);
|
||||
|
||||
for (const waiter of ready) waiter.resolve(calls);
|
||||
}
|
||||
}
|
||||
|
||||
// Low-level Server on purpose: McpServer.registerTool only takes zod schemas, and pi tools carry
|
||||
// JSON Schema that must reach the CLI unchanged.
|
||||
function createMcpServer(tools: Tool[], queue: ToolCallQueue, callbacks?: BridgeCallbacks): Server {
|
||||
const server = new Server({ name: BRIDGE_SERVER_NAME, version: "1.0.0" }, { capabilities: { tools: {} } });
|
||||
|
||||
server.setRequestHandler(ListToolsRequestSchema, () => ({ tools: tools.map(toMcpTool) }));
|
||||
|
||||
const seenIds = new Set<string>();
|
||||
server.setRequestHandler(CallToolRequestSchema, (request, extra) => {
|
||||
const id = request.params._meta?.["claudecode/toolUseId"];
|
||||
if (typeof id !== "string" || !id.trim() || seenIds.has(id)) {
|
||||
const error = new McpError(ErrorCode.InvalidParams, MCP_IDENTITY_ERROR);
|
||||
callbacks?.onError(error);
|
||||
throw error;
|
||||
}
|
||||
if (!tools.some((tool) => tool.name === request.params.name)) {
|
||||
const error = new McpError(ErrorCode.InvalidParams, "claude CLI requested an undeclared harness tool");
|
||||
callbacks?.onError(error);
|
||||
throw error;
|
||||
}
|
||||
seenIds.add(id);
|
||||
const result = queue.add({ id, name: request.params.name, arguments: (request.params.arguments ?? {}) as JsonObject }, extra.signal, callbacks?.onCancel);
|
||||
callbacks?.onCall();
|
||||
return result;
|
||||
});
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
function isAuthorized(header: string | undefined, expected: Buffer): boolean {
|
||||
if (header === undefined) return false;
|
||||
|
||||
const actual = Buffer.from(header);
|
||||
return actual.length === expected.length && timingSafeEqual(actual, expected);
|
||||
}
|
||||
|
||||
function createHttpServer(transport: StreamableHTTPServerTransport, authorization: string): http.Server {
|
||||
const expected = Buffer.from(authorization);
|
||||
|
||||
return http.createServer((req, res) => {
|
||||
if (!isAuthorized(req.headers.authorization, expected)) {
|
||||
res.writeHead(401).end();
|
||||
return;
|
||||
}
|
||||
|
||||
transport.handleRequest(req, res).catch((error: unknown) => {
|
||||
console.error("claude-code tool bridge request failed", error);
|
||||
|
||||
if (!res.headersSent) res.writeHead(500);
|
||||
res.end();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function listen(server: http.Server): Promise<AddressInfo> {
|
||||
return new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
|
||||
server.listen(0, LOOPBACK_HOST, () => resolve(server.address() as AddressInfo));
|
||||
});
|
||||
}
|
||||
|
||||
function closeHttpServer(server: http.Server): Promise<void> {
|
||||
server.closeAllConnections();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error) reject(error);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function toMcpConfig(port: number, authorization: string): string {
|
||||
const server = { type: "http", url: `http://${LOOPBACK_HOST}:${port}/mcp`, headers: { Authorization: authorization } };
|
||||
|
||||
return JSON.stringify({ mcpServers: { [BRIDGE_SERVER_NAME]: server } });
|
||||
}
|
||||
|
||||
export async function createToolBridge(tools: Tool[], callbacks?: BridgeCallbacks): Promise<ToolBridge> {
|
||||
const authorization = `Bearer ${randomBytes(TOKEN_BYTES).toString("hex")}`;
|
||||
|
||||
const queue = new ToolCallQueue();
|
||||
const mcpServer = createMcpServer(tools, queue, callbacks);
|
||||
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID() });
|
||||
await mcpServer.connect(transport);
|
||||
|
||||
const httpServer = createHttpServer(transport, authorization);
|
||||
let port: number;
|
||||
try {
|
||||
({ port } = await listen(httpServer));
|
||||
} catch (error) {
|
||||
await mcpServer.close();
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
mcpConfig: toMcpConfig(port, authorization),
|
||||
allowedTools: tools.map((tool) => `${MCP_TOOL_PREFIX}${tool.name}`),
|
||||
waitForToolCalls: (count) => queue.waitFor(count),
|
||||
resolveToolCall: (call, result) => queue.resolve(call, result),
|
||||
getPendingToolCalls: () => queue.list(),
|
||||
close: async () => {
|
||||
queue.clear();
|
||||
await mcpServer.close();
|
||||
await closeHttpServer(httpServer);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { ImageContent, Message, TextContent, Tool } from "@earendil-works/pi-ai";
|
||||
import { getCurrentSystemPrompt, getCurrentTools } from "@earendil-works/pi-ai/utils/transcript";
|
||||
|
||||
export type ClaudeContentBlock =
|
||||
| { type: "text"; text: string }
|
||||
| { type: "image"; source: { type: "base64"; media_type: string; data: string } };
|
||||
|
||||
export type ClaudeInput = {
|
||||
systemPrompt: string;
|
||||
tools: Tool[];
|
||||
userContent: ClaudeContentBlock[];
|
||||
};
|
||||
|
||||
type ReplayPart = string | ImageContent;
|
||||
|
||||
function toImageBlock(image: ImageContent): ClaudeContentBlock {
|
||||
return { type: "image", source: { type: "base64", media_type: image.mimeType, data: image.data } };
|
||||
}
|
||||
|
||||
function getLabelledParts(label: string, content: string | (TextContent | ImageContent)[]): ReplayPart[] {
|
||||
if (typeof content === "string") return [`${label}\n${content}`];
|
||||
|
||||
const text = content.flatMap((block) => (block.type === "text" ? [block.text] : [])).join("\n");
|
||||
const images = content.filter((block) => block.type === "image");
|
||||
|
||||
return [text ? `${label}\n${text}` : label, ...images];
|
||||
}
|
||||
|
||||
function getReplayParts(message: Message): ReplayPart[] {
|
||||
switch (message.role) {
|
||||
case "system":
|
||||
return [];
|
||||
|
||||
case "user":
|
||||
return getLabelledParts("[user]", message.content);
|
||||
|
||||
case "assistant":
|
||||
return message.content.flatMap((block) => {
|
||||
if (block.type === "text") return [`[assistant]\n${block.text}`];
|
||||
if (block.type === "toolCall") return [`[tool call ${block.name}] ${JSON.stringify(block.arguments)}`];
|
||||
return [];
|
||||
});
|
||||
|
||||
case "toolResult": {
|
||||
const label = message.isError ? `[tool result ${message.toolName} error]` : `[tool result ${message.toolName}]`;
|
||||
return getLabelledParts(label, message.content);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toContentBlocks(parts: ReplayPart[]): ClaudeContentBlock[] {
|
||||
const blocks: ClaudeContentBlock[] = [];
|
||||
let pendingText: string[] = [];
|
||||
|
||||
const flushText = () => {
|
||||
if (pendingText.length > 0) blocks.push({ type: "text", text: pendingText.join("\n\n") });
|
||||
pendingText = [];
|
||||
};
|
||||
|
||||
for (const part of parts) {
|
||||
if (typeof part === "string") {
|
||||
pendingText.push(part);
|
||||
} else {
|
||||
flushText();
|
||||
blocks.push(toImageBlock(part));
|
||||
}
|
||||
}
|
||||
|
||||
flushText();
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
function toUnlabelledBlocks(content: string | (TextContent | ImageContent)[]): ClaudeContentBlock[] {
|
||||
if (typeof content === "string") return [{ type: "text", text: content }];
|
||||
|
||||
return content.map((block) => (block.type === "text" ? { type: "text", text: block.text } : toImageBlock(block)));
|
||||
}
|
||||
|
||||
function toUserContent(conversation: Message[]): ClaudeContentBlock[] {
|
||||
const [first] = conversation;
|
||||
if (conversation.length === 1 && first?.role === "user") return toUnlabelledBlocks(first.content);
|
||||
|
||||
return toContentBlocks(conversation.flatMap(getReplayParts));
|
||||
}
|
||||
|
||||
export function toClaudeInput(messages: Message[]): ClaudeInput {
|
||||
const conversation = messages.filter((message) => message.role !== "system");
|
||||
|
||||
return {
|
||||
systemPrompt: getCurrentSystemPrompt(messages),
|
||||
tools: getCurrentTools(messages),
|
||||
userContent: toUserContent(conversation),
|
||||
};
|
||||
}
|
||||
|
||||
export function toStreamJsonLine(content: ClaudeContentBlock[]): string {
|
||||
return JSON.stringify({ type: "user", message: { role: "user", content } });
|
||||
}
|
||||
@@ -17,6 +17,8 @@ import packageMetadata from "../../package.json" with { type: "json" };
|
||||
import { chatGPTModel } from "./chatgpt-bridge.ts";
|
||||
import { chatGPTStream } from "./chatgpt-stream.ts";
|
||||
import type { ChatGPTDispatchLifecycle } from "./chatgpt-request-cycle.ts";
|
||||
import { createClaudeCodeProvider } from "./claude-code/provider.ts";
|
||||
import { streamClaudeCode } from "./claude-code/stream.ts";
|
||||
import { nativeSubscriptionId, nativeSubscriptionModel } from "./subscription-bridge.ts";
|
||||
import { nativeSubscriptionStream } from "./subscription-stream.ts";
|
||||
|
||||
@@ -81,6 +83,7 @@ export function piModels() {
|
||||
if (!collection) {
|
||||
collection = createModels({ identity: APP_IDENTITY });
|
||||
for (const provider of builtinProviders()) collection.setProvider(provider);
|
||||
collection.setProvider(createClaudeCodeProvider(streamClaudeCode));
|
||||
}
|
||||
providerSource?.sync(collection);
|
||||
return collection;
|
||||
|
||||
@@ -18,6 +18,7 @@ import { practiceTools, type PracticeToolContext } from "./tools/practice.ts";
|
||||
import { healToolRecord, withArgumentHeal } from "./tools/heal.ts";
|
||||
import { sendToUserTool } from "./tools/send-to-user.ts";
|
||||
import { createReadTool, readTool } from "./tools/read.ts";
|
||||
import { CLAUDE_CODE_PROVIDER } from "./claude-code/constants.ts";
|
||||
import { quizInputObject, quizInputProblems } from "../core/question-core.ts";
|
||||
import { QUESTION_TOOLS, type EnabledTools } from "../core/pending.ts";
|
||||
import { toLlmMessages, nativeInputCause, PRACTICE_PROBLEM_TOOL } from "../core/native-messages.ts";
|
||||
@@ -205,7 +206,7 @@ function baseAgentOptions(kind: AgentKind, opts: AgentBuildOptions, tools: Agent
|
||||
...tools.map((tool) => tool.name === "read" && tool.execute === readTool.execute
|
||||
? { ...tool, execute: createReadTool(engine.model).execute as AgentTool<any>["execute"] }
|
||||
: tool),
|
||||
...(opts.tools === undefined && engine.model.id.toLowerCase().includes("claude")
|
||||
...(opts.tools === undefined && (engine.model.provider === CLAUDE_CODE_PROVIDER || engine.model.id.toLowerCase().includes("claude"))
|
||||
? [withArgumentHeal(sendToUserTool)]
|
||||
: []),
|
||||
],
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
/** Shared provenance only: no credential-store reads in the agent layer. */
|
||||
interface AuthBoot {
|
||||
/** Names owned by the original launch environment. */
|
||||
realEnv: Set<string>;
|
||||
/** Names reflected into process.env by LearnHarness's key store. */
|
||||
fillGap: Set<string>;
|
||||
}
|
||||
const BOOT_KEY = Symbol.for("lh.authBoot");
|
||||
|
||||
export function authEnvRecord(): AuthBoot {
|
||||
const g = globalThis as Record<symbol, AuthBoot | undefined>;
|
||||
if (!g[BOOT_KEY]) g[BOOT_KEY] = { realEnv: new Set(), fillGap: new Set() };
|
||||
return g[BOOT_KEY];
|
||||
}
|
||||
|
||||
/** External CLIs own their authentication. Never pass LearnHarness-only injections. */
|
||||
export function externalCliEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
|
||||
const result = { ...env };
|
||||
const { realEnv, fillGap } = authEnvRecord();
|
||||
for (const name of fillGap) {
|
||||
if (!realEnv.has(name)) delete result[name];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export const CLAUDE_CODE_PROVIDER = "claude-code";
|
||||
|
||||
/** Installation/version only. Never an authentication, quota, or billing verdict. */
|
||||
export interface ClaudeCodeStatus {
|
||||
state: "not-found" | "meets-minimum" | "update-required" | "version-unchecked";
|
||||
version: string | null;
|
||||
minimumVersion: string;
|
||||
}
|
||||
|
||||
// --permission-prompts none was added in 2.1.259. This is not an official
|
||||
// minimum-version/stability contract for claudecode/toolUseId; admission fails closed.
|
||||
export const CLAUDE_CODE_MINIMUM_VERSION = "2.1.259";
|
||||
@@ -21,9 +21,11 @@ export interface ModelCatalogEntry {
|
||||
* thinkingLevelMap — e.g. GLM-5.3-flash: low|high|max). The thinking
|
||||
* picker lists exactly these; anything else is a lie. */
|
||||
supportedLevels: ThinkingLevel[];
|
||||
cost: { input: number; output: number; cacheRead: number; cacheWrite: number };
|
||||
contextWindow: number;
|
||||
maxTokens: number;
|
||||
/** Null when LearnHarness cannot estimate prices for this connection. */
|
||||
cost: { input: number; output: number; cacheRead: number; cacheWrite: number } | null;
|
||||
/** Null for native CLI aliases whose effective limits are session-specific. */
|
||||
contextWindow: number | null;
|
||||
maxTokens: number | null;
|
||||
}
|
||||
|
||||
/** The conversation agents' resolved default (BYOK, owner 2026-09-22): the
|
||||
|
||||
@@ -56,6 +56,7 @@ export type { ChatGPTStatus, ChatGPTControlBridge } from "./contracts/chatgpt.ts
|
||||
export { NATIVE_SUBSCRIPTIONS } from "./contracts/subscriptions.ts";
|
||||
export type { NativeSubscriptionProviderId, NativeSubscriptionLogicalId, NativeSubscriptionStatus, NativeSubscriptionSnapshot, NativeSubscriptionScope, NativeSubscriptionControlBridge, SubscriptionInteraction, SubscriptionPrompt } from "./contracts/subscriptions.ts";
|
||||
export * from "./cost-summary.ts";
|
||||
export { CLAUDE_CODE_PROVIDER, CLAUDE_CODE_MINIMUM_VERSION, type ClaudeCodeStatus } from "./contracts/claude-code.ts";
|
||||
export * from "./fuzzy.ts";
|
||||
// settings.ts + settings-resolution.ts (settings-storage wave, 2026-09-19):
|
||||
// the user-settings record's shape/sanitizer (pure) and its fully-resolved
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
||||
import type { AssistantMessage, Message, ToolResultMessage, UserMessage } from "@earendil-works/pi-ai";
|
||||
import { QUESTION_TOOLS } from "./pending.ts";
|
||||
import { CLAUDE_CODE_PROVIDER } from "./contracts/claude-code.ts";
|
||||
import {
|
||||
CONCLUDE_ACK_TEXT,
|
||||
HANDOFF_TEXT,
|
||||
@@ -90,7 +91,26 @@ declare module "@earendil-works/pi-agent-core" {
|
||||
}
|
||||
|
||||
/** A record message: a native pi message (or our marker) plus the record id. */
|
||||
export type LhMessage = (UserMessage | AssistantMessage | ToolResultMessage | LhMarkerMessage) & { id: string };
|
||||
export type LhMessage = (UserMessage | AssistantMessage | ToolResultMessage | LhMarkerMessage) & {
|
||||
id: string;
|
||||
/** CLI-reported effective context budget, not a catalog model maximum.
|
||||
* App-only metadata: never transmitted as provider/model message content. */
|
||||
cliContextWindow?: number;
|
||||
};
|
||||
|
||||
/** Latest response only: never borrow an older process/model's window when
|
||||
* a fresh response has no metadata. Compaction invalidates the old report. */
|
||||
export function cliContextWindowOf(messages: readonly LhMessage[]): { model: string; tokens: number } | null {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const message = messages[i]!;
|
||||
if (message.role === "custom" && message.customType === "compaction") return null;
|
||||
if (message.role !== "assistant") continue;
|
||||
const tokens = message.cliContextWindow;
|
||||
return message.provider === CLAUDE_CODE_PROVIDER && typeof tokens === "number" && Number.isSafeInteger(tokens) && tokens > 0
|
||||
? { model: `${message.provider}:${message.model}`, tokens } : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** A record message list is assignable to the agent's context — that IS
|
||||
* the replay (ui-stream/chat-handler assign it to initialState.messages). */
|
||||
@@ -617,8 +637,8 @@ export function toLlmMessages(messages: readonly AgentMessage[]): Message[] {
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// Strip the record id: the wire carries the native shape only.
|
||||
const { id: _id, ...native } = m as LhMessage;
|
||||
// Strip app-only id/CLI context metadata: the wire carries the native shape only.
|
||||
const { id: _id, cliContextWindow: _cliContextWindow, ...native } = m as LhMessage;
|
||||
out.push(native as Message);
|
||||
// Repair unanswered calls from completed generations, not calls in
|
||||
// failed generations. Pi filters error/aborted assistants itself;
|
||||
|
||||
+12
-2
@@ -29,6 +29,7 @@
|
||||
|
||||
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
||||
import { AUTH_PROVIDER_KEYS } from "./auth-keys.ts";
|
||||
import { CLAUDE_CODE_PROVIDER } from "./contracts/claude-code.ts";
|
||||
import type { ModelRole } from "../../config.ts";
|
||||
import type { ChatSettings } from "./contracts/chat.ts";
|
||||
import type { SearchEngineId } from "./search.ts";
|
||||
@@ -156,10 +157,11 @@ export interface ProviderKeyMissing {
|
||||
/** The refusal wording's invariant tail — the matcher below keys on it
|
||||
* (the label prefix varies by provider). */
|
||||
const PROVIDER_KEY_MISSING_TAIL = "key is set — add one in Settings → Keys";
|
||||
const CLAUDE_CODE_UNAVAILABLE = "Claude Code is unavailable. Enable and set it up in Settings → Providers.";
|
||||
export function providerKeyMissing(provider: string, slots: ModelSlotName[]): ProviderKeyMissing {
|
||||
const label = AUTH_PROVIDER_KEYS.find((p) => p.id === provider)?.label ?? provider;
|
||||
return {
|
||||
error: `No ${label} ${PROVIDER_KEY_MISSING_TAIL}`,
|
||||
error: provider === CLAUDE_CODE_PROVIDER ? CLAUDE_CODE_UNAVAILABLE : `No ${label} ${PROVIDER_KEY_MISSING_TAIL}`,
|
||||
code: PROVIDER_KEY_MISSING_CODE,
|
||||
provider,
|
||||
slots,
|
||||
@@ -170,7 +172,7 @@ export function providerKeyMissing(provider: string, slots: ModelSlotName[]): Pr
|
||||
* scary-error) — the envelope's home module owns the string so the
|
||||
* writer and the matcher can never drift. */
|
||||
export function isProviderKeyRefusal(error: string | undefined): boolean {
|
||||
return !!error && error.endsWith(PROVIDER_KEY_MISSING_TAIL);
|
||||
return !!error && (error.endsWith(PROVIDER_KEY_MISSING_TAIL) || error === CLAUDE_CODE_UNAVAILABLE);
|
||||
}
|
||||
/** The typed throw for in-process seams (extraction pipeline) — the
|
||||
* ProviderKeyMissing sibling of ModelsNotConfiguredError: callers catch
|
||||
@@ -368,6 +370,8 @@ export interface UserSettings {
|
||||
version: number;
|
||||
general?: GeneralSettings;
|
||||
dictation?: DictationSettings;
|
||||
/** Explicit app-wide opt-in; absent/invalid is off, including older records. */
|
||||
claudeCode?: { enabled?: boolean };
|
||||
agent?: AgentSettings;
|
||||
/** The strong/cheap/vision model slots (model slots wave, 2026-09-20). */
|
||||
models?: ModelsSettings;
|
||||
@@ -631,6 +635,12 @@ export function sanitizeUserSettings(raw: unknown): UserSettings | null {
|
||||
? { language: (raw as DictationSettings).language }
|
||||
: {};
|
||||
}
|
||||
if (r.claudeCode !== undefined) {
|
||||
const raw = r.claudeCode;
|
||||
out.claudeCode = raw !== null && typeof raw === "object" && !Array.isArray(raw)
|
||||
&& typeof (raw as Record<string, unknown>).enabled === "boolean"
|
||||
? { enabled: (raw as { enabled: boolean }).enabled } : {};
|
||||
}
|
||||
if (r.agent !== null && typeof r.agent === "object" && !Array.isArray(r.agent)) {
|
||||
out.agent = sanitizeAgent(r.agent as Record<string, unknown>);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
type TeachingAgent,
|
||||
} from "../agent/teacher-agent.ts";
|
||||
import { piEngineForSpec, type PiEngine } from "../agent/models.ts";
|
||||
import { connectionBackgroundAllowed, connectionScopeCurrent, isSubscriptionProvider } from "../agent/subscription-connections.ts";
|
||||
import { connectionBackgroundAllowed as subscriptionBackgroundAllowed, connectionScopeCurrent, isSubscriptionProvider } from "../agent/subscription-connections.ts";
|
||||
import { nativeSubscriptionError } from "../agent/subscription-stream.ts";
|
||||
import { chatGPTStreamError } from "../agent/chatgpt-stream.ts";
|
||||
import { SubscriptionRequestCycle, type SubscriptionDispatchLifecycle } from "../agent/subscription-request-cycle.ts";
|
||||
@@ -33,6 +33,7 @@ import { providerEventNote } from "../agent/provider-events.ts";
|
||||
import { effectiveSettings, stores } from "./stores.ts";
|
||||
import { searchAvailable, searchProvider } from "./search.ts";
|
||||
import { providerKeyPresent } from "./keys.ts";
|
||||
import { CLAUDE_CODE_PROVIDER } from "../core/contracts/claude-code.ts";
|
||||
import {
|
||||
extractionEnabled,
|
||||
graphToolsEnabled,
|
||||
@@ -80,6 +81,12 @@ import type {CallUsage} from "../core/contracts.ts";
|
||||
* for write-ahead persistence, turn liveness, and the turn services.
|
||||
*/
|
||||
|
||||
/** Fresh check only at new background-run admission; never cancel a run
|
||||
* already in progress when the opt-in is switched off. */
|
||||
function connectionBackgroundAllowed(provider: string): boolean {
|
||||
return subscriptionBackgroundAllowed(provider) && (provider !== CLAUDE_CODE_PROVIDER || providerKeyPresent(provider));
|
||||
}
|
||||
|
||||
function settlementErrorText(error: unknown): string {
|
||||
return error instanceof TurnPersistenceError ? error.message : "An error occurred.";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { getClaudeBinary } from "../agent/claude-code/binary.ts";
|
||||
import { settingsStore } from "../store/settings.ts";
|
||||
|
||||
/** Fresh admission state, not a process-wide cache. Existing runs retain
|
||||
* their engines; only new chat/job/extraction/compaction admissions consult it. */
|
||||
export function claudeCodeEnabled(): boolean {
|
||||
return settingsStore.readSettings()?.claudeCode?.enabled === true;
|
||||
}
|
||||
|
||||
/** Installation/opt-in availability only, never authenticated readiness. */
|
||||
export function claudeCodeAvailable(): boolean {
|
||||
return claudeCodeEnabled() && getClaudeBinary() !== null;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { inspectClaudeInstallation } from "../agent/claude-code/installation.ts";
|
||||
import { initAuthEnv } from "./keys.ts";
|
||||
import { subscriptionRequestOrigin } from "./web-subscription-http.ts";
|
||||
import { claudeCodeEnabled } from "./claude-code-config.ts";
|
||||
|
||||
/** The Settings status seam: initialize provenance before any child env is copied. */
|
||||
export async function claudeCodeInstallationStatus() {
|
||||
initAuthEnv();
|
||||
return inspectClaudeInstallation();
|
||||
}
|
||||
|
||||
/** Same-origin browser intent, not user authentication. No CORS grants.
|
||||
* A public/shared server still requires its own access-control boundary. */
|
||||
export async function claudeCodeInstallationResponse(
|
||||
request: Request,
|
||||
inspect = claudeCodeInstallationStatus,
|
||||
enabled = claudeCodeEnabled,
|
||||
): Promise<Response> {
|
||||
const origin = subscriptionRequestOrigin(request);
|
||||
const supplied = request.headers.get("origin");
|
||||
const headers = { "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff" };
|
||||
if (request.method !== "GET" || !origin
|
||||
|| request.headers.get("x-learnharness-claude-code") !== "1"
|
||||
|| request.headers.get("sec-fetch-site") === "cross-site"
|
||||
|| (supplied !== null && supplied !== origin)) {
|
||||
return Response.json({ error: "Claude Code checks require a same-origin LearnHarness request." }, { status: 403, headers });
|
||||
}
|
||||
if (!enabled()) return Response.json({ error: "Claude Code is disabled." }, { status: 403, headers });
|
||||
return Response.json(await inspect(), { headers });
|
||||
}
|
||||
@@ -45,3 +45,4 @@ export * from "./observability.ts";
|
||||
export * from "./model-catalog.ts";
|
||||
export { subscriptionControlsResponse, subscriptionBrowserResponse } from "./web-subscription-http.ts";
|
||||
export { sessionCost } from "./pricing.ts";
|
||||
export { claudeCodeInstallationResponse } from "./claude-code.ts";
|
||||
|
||||
+8
-17
@@ -55,24 +55,12 @@ import { authStore } from "../store/auth.ts";
|
||||
import { isSubscriptionProvider, subscriptionConnected } from "../agent/subscription-connections.ts";
|
||||
import { piModels } from "../agent/models.ts";
|
||||
import { customProviderConfigured, installCustomProviderSource } from "./custom-providers.ts";
|
||||
import { claudeCodeAvailable } from "./claude-code-config.ts";
|
||||
import { CLAUDE_CODE_PROVIDER } from "../agent/claude-code/constants.ts";
|
||||
|
||||
// The once-per-process boot record — globalThis anchor (HMR re-imports the
|
||||
// module; the snapshot must not re-run and misread fill-gapped vars as real
|
||||
// env). Same pattern as globalThis.__lhExtractionRuns.
|
||||
interface AuthBoot {
|
||||
/** Vars that were in the REAL env at first init — env precedence's
|
||||
* untouchable set. Fill-gapped vars are everything else we set. */
|
||||
realEnv: Set<string>;
|
||||
/** Vars WE put into process.env (the file's reflection) — deletable. */
|
||||
fillGap: Set<string>;
|
||||
}
|
||||
const BOOT_KEY = Symbol.for("lh.authBoot");
|
||||
|
||||
function boot(): AuthBoot {
|
||||
const g = globalThis as Record<symbol, AuthBoot | undefined>;
|
||||
if (!g[BOOT_KEY]) g[BOOT_KEY] = { realEnv: new Set(), fillGap: new Set() };
|
||||
return g[BOOT_KEY];
|
||||
}
|
||||
// Shared HMR-stable provenance lets external CLIs exclude file-only injections
|
||||
// without importing this store-backed module into the agent layer.
|
||||
import { authEnvRecord as boot } from "../core/auth-env.ts";
|
||||
|
||||
/** Is `id` a WRITABLE key name — curated catalog or model-provider env
|
||||
* var (provider-keys interlude: PUT/DELETE accept both). */
|
||||
@@ -206,6 +194,9 @@ export function providerKeyPresent(provider: string): boolean {
|
||||
piModels(); // Synchronize hand-edited custom configuration before admission.
|
||||
const custom = customProviderConfigured(provider);
|
||||
if (custom !== undefined) return custom;
|
||||
// Explicit opt-in + installation only, never authentication readiness.
|
||||
// Consulted at admission, not inside already-running agent loops.
|
||||
if (provider === CLAUDE_CODE_PROVIDER) return claudeCodeAvailable();
|
||||
if (isSubscriptionProvider(provider)) return subscriptionConnected(provider);
|
||||
const info = AUTH_PROVIDER_KEYS.find((p) => p.id === provider);
|
||||
return info ? keyValue(info.envVar) !== undefined : true;
|
||||
|
||||
@@ -32,6 +32,7 @@ export async function refreshConnectionCatalog(provider?: string): Promise<void>
|
||||
]);
|
||||
}
|
||||
function connectionModel(provider: string, id: string): Model<Api> | undefined {
|
||||
if (provider === CLAUDE_CODE_PROVIDER && !claudeCodeAvailable()) return undefined;
|
||||
return provider === "chatgpt" ? chatGPTModel(id) : nativeSubscriptionId(provider) ? nativeSubscriptionModel(provider, id) : piModels().getModel(provider, id);
|
||||
}
|
||||
function connectionProviderIds(): string[] { return [...piModels().getProviders().map((p) => p.id), "chatgpt", ...NATIVE_SUBSCRIPTIONS.map((p) => p.logicalId)]; }
|
||||
@@ -49,6 +50,8 @@ import type {
|
||||
} from "../core/contracts/models.ts";
|
||||
import type { EffectiveModelSpec, EffectiveSlot } from "../core/settings-resolution.ts";
|
||||
import { AUTH_PROVIDER_KEYS } from "../core/auth-keys.ts";
|
||||
import { claudeCodeAvailable } from "./claude-code-config.ts";
|
||||
import { CLAUDE_CODE_PROVIDER } from "../agent/claude-code/constants.ts";
|
||||
import { BACKGROUND_MODEL_ROLES, MODEL_SLOTS, type BackgroundModelRole, type ModelSlotName, type ModelsSettings } from "../core/settings.ts";
|
||||
|
||||
/** The picker's client-safe model entry (contract: core/contracts/models.ts). */
|
||||
@@ -143,6 +146,7 @@ export function catalogProviders(): CatalogProvider[] {
|
||||
keyPresent: keyValue(p.envVar) !== undefined,
|
||||
})).concat([
|
||||
{ id: "chatgpt", envVar: "", keyPresent: chatGPTConnected() },
|
||||
{ id: CLAUDE_CODE_PROVIDER, envVar: "", keyPresent: claudeCodeAvailable() },
|
||||
...NATIVE_SUBSCRIPTIONS.map((p) => ({ id: p.logicalId, envVar: "", keyPresent: nativeSubscriptionConnected(p.providerId) })),
|
||||
...customProviderRows(),
|
||||
]).sort((a, b) => a.id.localeCompare(b.id));
|
||||
@@ -164,21 +168,22 @@ export function toModelDto(model: Model<Api>): ModelDto {
|
||||
reasoning: model.reasoning,
|
||||
input: [...model.input],
|
||||
supportedLevels: getSupportedThinkingLevels(model),
|
||||
cost: {
|
||||
cost: model.provider === CLAUDE_CODE_PROVIDER ? null : {
|
||||
input: model.cost.input,
|
||||
output: model.cost.output,
|
||||
cacheRead: model.cost.cacheRead,
|
||||
cacheWrite: model.cost.cacheWrite,
|
||||
},
|
||||
contextWindow: model.contextWindow,
|
||||
maxTokens: model.maxTokens,
|
||||
contextWindow: model.provider === CLAUDE_CODE_PROVIDER ? null : model.contextWindow,
|
||||
maxTokens: model.provider === CLAUDE_CODE_PROVIDER ? null : model.maxTokens,
|
||||
};
|
||||
}
|
||||
|
||||
/** The live-limits index (context-meter amendment, owner 2026-09-23):
|
||||
* provider:id → contextWindow from pi's catalog, over the SAME keyed
|
||||
* providers the picker federates. The one source for every context
|
||||
* meter's denominator — the stats-bar join holds /api/models' copy,
|
||||
* providers the picker federates. Native CLI aliases are excluded:
|
||||
* only a session-specific CLI report can provide their denominator.
|
||||
* For other models the stats-bar join holds /api/models' copy,
|
||||
* and the activity feed (observability.ts) resolves job limits
|
||||
* server-side through this. No hardcoded fallback exists anywhere:
|
||||
* a model missing here reads limit-null (usage-only, honest render). */
|
||||
@@ -186,7 +191,7 @@ export function catalogContextLimits(): Map<string, number> {
|
||||
const keyed = keyedProviderIds();
|
||||
const index = new Map<string, number>();
|
||||
for (const provider of piModels().getProviders()) {
|
||||
if (!keyed.has(provider.id)) continue;
|
||||
if (!keyed.has(provider.id) || provider.id === CLAUDE_CODE_PROVIDER) continue;
|
||||
for (const model of piModels().getModels(provider.id)) {
|
||||
index.set(`${provider.id}:${model.id}`, model.contextWindow);
|
||||
}
|
||||
|
||||
@@ -4,12 +4,15 @@
|
||||
*/
|
||||
import { calculateCost, type Usage } from "@earendil-works/pi-ai";
|
||||
import { piModels } from "../agent/models.ts";
|
||||
import { CLAUDE_CODE_PROVIDER } from "../core/contracts/claude-code.ts";
|
||||
import type { CallUsage, ChatRecord, JobRecord, TurnUsage } from "../core/contracts.ts";
|
||||
import { isSubscriptionModel, sessionCost as foldSessionCost, turnCost as foldTurnCost, jobCost as foldJobCost } from "../core/prices.ts";
|
||||
|
||||
function catalogModel(id: string) {
|
||||
const colon = id.indexOf(":");
|
||||
if (colon < 0 || isSubscriptionModel(id)) return undefined;
|
||||
// Native CLI routing determines billing. Adapter zero-rate placeholders
|
||||
// must never become an apparent $0 estimate or subscription classification.
|
||||
if (colon < 0 || isSubscriptionModel(id) || id.slice(0, colon) === CLAUDE_CODE_PROVIDER) return undefined;
|
||||
return piModels().getModel(id.slice(0, colon), id.slice(colon + 1));
|
||||
}
|
||||
|
||||
|
||||
@@ -91,6 +91,15 @@ export function parseGeneralSettingsBody(body: unknown): SettingsBodyResult<Gene
|
||||
return { ok: true, settings };
|
||||
}
|
||||
|
||||
/** claudeCode: explicit app-wide opt-in. Empty section resets to off. */
|
||||
export function parseClaudeCodeSettingsBody(body: unknown): SettingsBodyResult<{ enabled?: boolean }> {
|
||||
const raw = asRecord(body);
|
||||
if (!raw) return { ok: false, error: "Invalid JSON body" };
|
||||
if (raw.enabled === undefined) return { ok: true, settings: {} };
|
||||
if (typeof raw.enabled !== "boolean") return { ok: false, error: "enabled must be a boolean" };
|
||||
return { ok: true, settings: { enabled: raw.enabled } };
|
||||
}
|
||||
|
||||
/** agent.models: only the six known background roles; each override's
|
||||
* fields mirror ChatSettings (model pair + independent thinkingLevel). */
|
||||
function parseRoleOverride(value: unknown, role: string): { ok: true; override: RoleModelOverride } | { ok: false; error: string } {
|
||||
@@ -397,6 +406,7 @@ export const SETTINGS_SECTIONS = {
|
||||
compaction: parseCompactionSettingsBody,
|
||||
models: parseModelsSettingsBody,
|
||||
dictation: parseDictationSettingsBody,
|
||||
claudeCode: parseClaudeCodeSettingsBody,
|
||||
} as const;
|
||||
|
||||
export type SettingsSectionName = keyof typeof SETTINGS_SECTIONS;
|
||||
|
||||
@@ -77,14 +77,16 @@ test("Providers is the first/default page; General is last and other pages keep
|
||||
assert.doesNotMatch(html, />Model providers<\/button>/);
|
||||
});
|
||||
|
||||
test("Providers has exactly four ordered sections; unknown variables stay masked/read-only at the end of Model API keys", async () => {
|
||||
test("Providers has five ordered sections including External CLI; unknown variables stay masked/read-only at the end of Model API keys", async () => {
|
||||
globalThis.fetch = async (url) => {
|
||||
if (url === "/api/claude-code") return Response.json({ state: "not-found", version: null, minimumVersion: "2.1.259" });
|
||||
assert.equal(url, "/api/keys");
|
||||
return Response.json({ providers: [{ id: "openai", envVar: "OPENAI_API_KEY", label: "OpenAI" }], keys: [{ id: "FUTURE_VAR", info: null, present: true, masked: "…abcd", source: "file", shadowedByEnv: false }] });
|
||||
};
|
||||
fixture(); await mount(<KeysSection />);
|
||||
const headings = [...document.querySelectorAll('[data-testid^="group-"] > p')].map((el) => el.textContent);
|
||||
assert.deepEqual(headings, ["Model API keys", "Subscriptions", "Dictation", "Search"]);
|
||||
assert.deepEqual(headings, ["Model API keys", "Subscriptions", "External CLI", "Dictation", "Search"]);
|
||||
assert(byTest("claude-code-installation").closest('[data-testid="group-external-cli"]'));
|
||||
assert(byTest("key-deepgram_api_key").closest('[data-testid="group-dictation"]'));
|
||||
assert(byTest("key-tavily_api_key").closest('[data-testid="group-search"]'));
|
||||
assert(byTest("key-brave_api_key").closest('[data-testid="group-search"]'));
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Claude Code binary lookup (src/agent/claude-code/binary.ts).
|
||||
*
|
||||
* The packaged desktop app often starts with a minimal PATH, so lookup falls
|
||||
* back to the standard install locations. A fake isExecutable set stands in
|
||||
* for the filesystem.
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { resolveClaudeBinary } from "../src/agent/claude-code/binary.ts";
|
||||
|
||||
const HOME = "/home/u";
|
||||
|
||||
function resolveWith(executables: string[], env: Record<string, string> = {}, platform: NodeJS.Platform = "linux", homeDir = HOME) {
|
||||
const found = new Set(executables);
|
||||
return resolveClaudeBinary({ env, platform, homeDir, isExecutable: (p) => found.has(p) });
|
||||
}
|
||||
|
||||
test("prefers PATH hit", () => {
|
||||
const binary = resolveWith(["/usr/bin/claude", `${HOME}/.local/bin/claude`], { PATH: "/opt/x:/usr/bin" });
|
||||
assert.equal(binary, "/usr/bin/claude");
|
||||
});
|
||||
|
||||
test("falls back to ~/.local/bin when PATH misses", () => {
|
||||
const binary = resolveWith([`${HOME}/.local/bin/claude`, "/usr/local/bin/claude"], { PATH: "/usr/bin" });
|
||||
assert.equal(binary, `${HOME}/.local/bin/claude`);
|
||||
});
|
||||
|
||||
test("checks ~/.claude/local, npm global bin, /opt/homebrew/bin, /usr/local/bin in that order", () => {
|
||||
const all = [`${HOME}/.claude/local/claude`, `${HOME}/.npm-global/bin/claude`, "/opt/homebrew/bin/claude", "/usr/local/bin/claude"];
|
||||
for (let i = 0; i < all.length; i++) {
|
||||
assert.equal(resolveWith(all.slice(i), { PATH: "" }), all[i]);
|
||||
}
|
||||
});
|
||||
|
||||
test("npm global bin follows npm_config_prefix when set", () => {
|
||||
const binary = resolveWith(["/opt/npm/bin/claude", `${HOME}/.npm-global/bin/claude`], { npm_config_prefix: "/opt/npm" });
|
||||
assert.equal(binary, "/opt/npm/bin/claude");
|
||||
});
|
||||
|
||||
test("windows finds native claude.exe and does not advertise unsupported .cmd-only installs", () => {
|
||||
const winHome = "C:\\Users\\u";
|
||||
const env = { PATH: "C:\\tools;C:\\bin", APPDATA: `${winHome}\\AppData\\Roaming` };
|
||||
assert.equal(resolveWith(["C:\\bin\\claude.cmd"], env, "win32", winHome), null);
|
||||
assert.equal(resolveWith(["C:\\bin\\claude.exe"], env, "win32", winHome), "C:\\bin\\claude.exe");
|
||||
assert.equal(resolveWith([`${winHome}\\.local\\bin\\claude.exe`], env, "win32", winHome), `${winHome}\\.local\\bin\\claude.exe`);
|
||||
assert.equal(
|
||||
resolveWith([`${winHome}\\AppData\\Roaming\\npm\\claude.cmd`], env, "win32", winHome),
|
||||
null,
|
||||
);
|
||||
assert.equal(resolveWith(["C:\\bin\\claude"], env, "win32", winHome), null);
|
||||
});
|
||||
|
||||
test("windows prefers claude.exe anywhere over claude.cmd, which Node cannot spawn without a shell", () => {
|
||||
const winHome = "C:\\Users\\u";
|
||||
const env = { PATH: "C:\\bin", APPDATA: `${winHome}\\AppData\\Roaming` };
|
||||
const executables = ["C:\\bin\\claude.cmd", `${winHome}\\.local\\bin\\claude.exe`];
|
||||
assert.equal(resolveWith(executables, env, "win32", winHome), `${winHome}\\.local\\bin\\claude.exe`);
|
||||
});
|
||||
|
||||
test("relative Windows PATH entries are fixed to an absolute discovery identity", () => {
|
||||
assert.equal(resolveClaudeBinary({ env: { PATH: ".\\bin" }, platform: "win32", homeDir: "C:\\Users\\u", cwd: "C:\\server",
|
||||
isExecutable: (candidate) => candidate === "C:\\server\\bin\\claude.exe",
|
||||
}), "C:\\server\\bin\\claude.exe");
|
||||
});
|
||||
|
||||
test("returns null when every location misses", () => {
|
||||
assert.equal(resolveWith([], { PATH: "/usr/bin:/bin" }), null);
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
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, TurnUsage } 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 turn: TurnUsage = { id: "cli-turn", messageId: "cli-message", model: "claude-code:opus", at: "2026-10-28T00:00:00Z", steps: [{ inputTokens: 42000, outputTokens: 1, cacheReadTokens: undefined, cacheWriteTokens: undefined, reasoningTokens: undefined }] };
|
||||
const render = (window: { model: string; tokens: number } | null) => root!.render(<StatsBar chatId="cli-session" turns={[turn]} 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"));
|
||||
assert.equal(document.querySelector('[data-testid="stats-cost"]')?.textContent, "CLI");
|
||||
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 ?? "", /Window reported by Claude Code; may be lower for auto-compaction\./);
|
||||
assert.equal(document.querySelector('[data-testid="stats-detail"] li > span:last-child > span:last-child')?.textContent, "CLI");
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,319 @@
|
||||
/**
|
||||
* Claude Code provider driven through the real chat handler: a learner-facing
|
||||
* question ends the turn while the CLI process waits on the held MCP call, and
|
||||
* the learner's answer arrives later as that call's tool result.
|
||||
*
|
||||
* The runClaude seam spawns tests/fixtures/claude-code/fake-claude.mjs, one
|
||||
* scenario per spawn, so the spawn count shows whether the answer continued
|
||||
* the waiting process or started a fresh one with a replay.
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import { once } from "node:events";
|
||||
import { mkdtempSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { delimiter, join } from "node:path";
|
||||
import { setTimeout as sleep } from "node:timers/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import test, { after, type TestContext } from "node:test";
|
||||
|
||||
import type { RunClaude } from "../src/agent/claude-code/stream.ts";
|
||||
import type { PiEngine } from "../src/agent/models.ts";
|
||||
|
||||
// Opt-in writes must not race another test process's settings record.
|
||||
const testData = mkdtempSync(join(tmpdir(), "lh-cli-harness-data-"));
|
||||
process.env.LH_DATA_DIR = testData;
|
||||
after(() => rmSync(testData, { recursive: true, force: true }));
|
||||
const { PATHS, SETTINGS_FILE } = await import("../config.ts");
|
||||
const { readSettings, writeSettings } = await import("../src/store/settings.ts");
|
||||
const { LIVE_PROCESS_IDLE_TIMEOUT_MS, liveProcessIdleTimeoutMs } = await import("../src/agent/claude-code/live-processes.ts");
|
||||
const { CLAUDE_CODE_MODELS } = await import("../src/agent/claude-code/provider.ts");
|
||||
const { createClaudeCodeStreamFunction } = await import("../src/agent/claude-code/stream.ts");
|
||||
const { createTeacherAgent } = await import("../src/agent/teacher-agent.ts");
|
||||
const { cliContextWindowOf, danglingQuestionOf } = await import("../src/core/native-messages.ts");
|
||||
const { handleChatRequest } = await import("../src/server/chat-handler.ts");
|
||||
const { isTurnRunning } = await import("../src/server/turn-liveness.ts");
|
||||
const { runningTurn } = await import("../src/server/turn-runs.ts");
|
||||
const { chatStore } = await import("../src/store/chats.ts");
|
||||
|
||||
const FAKE_CLAUDE = fileURLToPath(new URL("./fixtures/claude-code/fake-claude.mjs", import.meta.url));
|
||||
const TEXT_FIXTURE = fileURLToPath(new URL("./fixtures/claude-code/text.jsonl", import.meta.url));
|
||||
const TURN_TIMEOUT_MS = 8000;
|
||||
|
||||
const ASK_ID = "toolu_harness_ask";
|
||||
const ASK_INPUT = { question: "What do you already know about sockets?" };
|
||||
const ANSWER_TEXT = "Only that they carry bytes between programs";
|
||||
|
||||
type FakeRun = { runClaude: RunClaude; children: ChildProcessWithoutNullStreams[]; stdinFiles: string[] };
|
||||
|
||||
function streamEvent(event: object): string {
|
||||
return JSON.stringify({ type: "stream_event", event });
|
||||
}
|
||||
|
||||
function toAskScenario(): string[] {
|
||||
return [
|
||||
streamEvent({ type: "message_start", message: { usage: {} } }),
|
||||
streamEvent({
|
||||
type: "content_block_start",
|
||||
index: 0,
|
||||
content_block: { type: "tool_use", id: ASK_ID, name: "mcp__learnharness__ask_user_question", input: {} },
|
||||
}),
|
||||
streamEvent({ type: "content_block_delta", index: 0, delta: { type: "input_json_delta", partial_json: JSON.stringify(ASK_INPUT) } }),
|
||||
streamEvent({ type: "content_block_stop", index: 0 }),
|
||||
streamEvent({ type: "message_delta", delta: { stop_reason: "tool_use" }, usage: {} }),
|
||||
streamEvent({ type: "message_stop" }),
|
||||
JSON.stringify({ fake: "call", id: ASK_ID, name: "ask_user_question", arguments: ASK_INPUT }),
|
||||
JSON.stringify({ fake: "reply" }),
|
||||
];
|
||||
}
|
||||
|
||||
function createFakeRun(t: TestContext, scenarios: string[][]): FakeRun {
|
||||
const settings = readSettings();
|
||||
writeSettings({ ...(settings ?? { version: 1 }), claudeCode: { enabled: true } });
|
||||
t.after(() => { if (settings) writeSettings(settings); else rmSync(SETTINGS_FILE, { force: true }); });
|
||||
const dir = mkdtempSync(join(tmpdir(), "claude-code-harness-"));
|
||||
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
||||
// Satisfy the real presence gate; this marker is NEVER executed. All work
|
||||
// below goes through the injected Node fake-CLI seam, not an installed CLI.
|
||||
writeFileSync(join(dir, process.platform === "win32" ? "claude.exe" : "claude"), "", { mode: 0o755 });
|
||||
const savedPath = process.env.PATH;
|
||||
process.env.PATH = `${dir}${delimiter}${savedPath ?? ""}`;
|
||||
t.after(() => { if (savedPath === undefined) delete process.env.PATH; else process.env.PATH = savedPath; });
|
||||
|
||||
const children: ChildProcessWithoutNullStreams[] = [];
|
||||
const stdinFiles: string[] = [];
|
||||
|
||||
const runClaude: RunClaude = (args, options) => {
|
||||
const scenario = scenarios[children.length];
|
||||
assert.ok(scenario, `unexpected spawn #${children.length + 1}`);
|
||||
|
||||
const scenarioFile = join(dir, `scenario-${children.length}.jsonl`);
|
||||
const stdinFile = join(dir, `stdin-${children.length}.jsonl`);
|
||||
writeFileSync(scenarioFile, `${scenario.join("\n")}\n`);
|
||||
|
||||
const child = spawn(process.execPath, [FAKE_CLAUDE, ...args], {
|
||||
cwd: options.cwd,
|
||||
env: { ...options.env, FAKE_CLAUDE_SCENARIO: scenarioFile, FAKE_CLAUDE_STDIN: stdinFile },
|
||||
stdio: ["pipe", "pipe", "pipe", "ipc"],
|
||||
}) as ChildProcessWithoutNullStreams;
|
||||
t.after(() => child.kill("SIGKILL"));
|
||||
|
||||
children.push(child);
|
||||
stdinFiles.push(stdinFile);
|
||||
return child;
|
||||
};
|
||||
|
||||
return { runClaude, children, stdinFiles };
|
||||
}
|
||||
|
||||
function createClaudeCodeChat(t: TestContext): string {
|
||||
const chat = chatStore.createChat("Claude Code: question answered later");
|
||||
chatStore.saveSettings(chat.id, { model: { provider: "claude-code", id: "sonnet" } });
|
||||
t.after(() => unlinkSync(join(PATHS.chats, `${chat.id}.json`)));
|
||||
|
||||
return chat.id;
|
||||
}
|
||||
|
||||
function createAgentFactory(fakeRun: FakeRun): Parameters<typeof handleChatRequest>[1] {
|
||||
const model = CLAUDE_CODE_MODELS.find((candidate) => candidate.id === "sonnet");
|
||||
assert.ok(model);
|
||||
|
||||
const engine: PiEngine = { model, streamFn: createClaudeCodeStreamFunction(fakeRun.runClaude) };
|
||||
|
||||
return (options) => createTeacherAgent(undefined, { engine, chatId: options.chatId, messages: options.messages });
|
||||
}
|
||||
|
||||
async function runChatTurn(chatId: string, input: object, agentFactory: Parameters<typeof handleChatRequest>[1]): Promise<void> {
|
||||
const request = new Request("http://test/api/chat", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ chatId, input }),
|
||||
});
|
||||
|
||||
const ack = await handleChatRequest(request, agentFactory);
|
||||
assert.equal(ack.status, 200, await ack.clone().text());
|
||||
|
||||
const handle = runningTurn(chatId);
|
||||
if (handle) await new Response(handle.subscribe()).text();
|
||||
|
||||
const deadline = Date.now() + TURN_TIMEOUT_MS;
|
||||
while (isTurnRunning(chatId)) {
|
||||
assert.ok(Date.now() < deadline, "turn did not settle");
|
||||
await sleep(10);
|
||||
}
|
||||
}
|
||||
|
||||
function toAnswerInput(): object {
|
||||
return {
|
||||
type: "answer",
|
||||
toolCallId: ASK_ID,
|
||||
answer: { kind: "ask", questionText: ASK_INPUT.question, dontKnow: false, answers: [], text: ANSWER_TEXT, answeredAt: new Date().toISOString() },
|
||||
};
|
||||
}
|
||||
|
||||
function getLastAssistantText(chatId: string): string {
|
||||
const messages = chatStore.getChat(chatId)?.messages ?? [];
|
||||
const assistant = messages.findLast((message) => message.role === "assistant");
|
||||
assert.ok(assistant?.role === "assistant");
|
||||
|
||||
return assistant.content.flatMap((block) => (block.type === "text" ? [block.text] : [])).join("");
|
||||
}
|
||||
|
||||
async function waitForExit(child: ChildProcessWithoutNullStreams): Promise<void> {
|
||||
if (child.exitCode !== null || child.signalCode !== null) return;
|
||||
|
||||
await once(child, "exit", { signal: AbortSignal.timeout(TURN_TIMEOUT_MS) });
|
||||
}
|
||||
|
||||
test("disabled opt-in refuses a saved CLI chat before write-ahead, liveness, or agent creation", async (t) => {
|
||||
const fakeRun = createFakeRun(t, [toAskScenario()]);
|
||||
const chatId = createClaudeCodeChat(t);
|
||||
writeSettings({ ...(readSettings() ?? { version: 1 }), claudeCode: { enabled: false } });
|
||||
const before = chatStore.getChat(chatId)!.messages;
|
||||
const response = await handleChatRequest(new Request("http://test/api/chat", { method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ chatId, input: { type: "text", text: "must not start" } }) }), () => assert.fail("disabled work must not build an agent"));
|
||||
assert.equal(response.status, 503);
|
||||
assert.match((await response.json()).error, /Enable and set it up in Settings/);
|
||||
assert.equal(isTurnRunning(chatId), false); assert.equal(fakeRun.children.length, 0);
|
||||
assert.deepEqual(chatStore.getChat(chatId)!.messages, before);
|
||||
assert.deepEqual(chatStore.getChat(chatId)!.settings?.model, { provider: "claude-code", id: "sonnet" });
|
||||
});
|
||||
|
||||
test("switching off blocks future requests but does not interrupt an already admitted CLI turn", async (t) => {
|
||||
const scenario = [JSON.stringify({ fake: "checkpoint" }), JSON.stringify({ fake: "wait-for-message" }), ...readFileSync(TEXT_FIXTURE, "utf8").trim().split("\n")];
|
||||
const fakeRun = createFakeRun(t, [scenario]);
|
||||
const run = fakeRun.runClaude;
|
||||
let ready!: (child: ChildProcessWithoutNullStreams) => void;
|
||||
let failed!: (error: unknown) => void;
|
||||
const checkpoint = new Promise<ChildProcessWithoutNullStreams>((resolve, reject) => { ready = resolve; failed = reject; });
|
||||
fakeRun.runClaude = async (args, options) => {
|
||||
const child = await run(args, options); assert(child);
|
||||
void once(child, "message", { signal: AbortSignal.timeout(TURN_TIMEOUT_MS) }).then(() => ready(child), failed);
|
||||
return child;
|
||||
};
|
||||
const chatId = createClaudeCodeChat(t);
|
||||
const response = await handleChatRequest(new Request("http://test/api/chat", { method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ chatId, input: { type: "text", text: "complete this admitted fixture" } }) }), createAgentFactory(fakeRun));
|
||||
assert.equal(response.status, 200);
|
||||
const child = await checkpoint;
|
||||
const handle = runningTurn(chatId); assert(handle);
|
||||
const finished = new Response(handle.subscribe()).text();
|
||||
writeSettings({ ...(readSettings() ?? { version: 1 }), claudeCode: { enabled: false } });
|
||||
assert.equal(child.signalCode, null, "opt-out must not kill an already running process");
|
||||
child.send("continue"); await finished;
|
||||
assert(getLastAssistantText(chatId).length > 0, "the admitted turn completes normally");
|
||||
assert.equal(readSettings()?.claudeCode?.enabled, false);
|
||||
await waitForExit(child);
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
await runChatTurn(chatId, { type: "text", text: "teach me sockets" }, agentFactory);
|
||||
|
||||
assert.equal(danglingQuestionOf(chatStore.getChat(chatId)?.messages ?? [])?.toolCallId, ASK_ID);
|
||||
const [waiting] = fakeRun.children;
|
||||
assert.ok(waiting);
|
||||
assert.equal(waiting.exitCode, null, "the CLI process should wait on the held question call");
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
test("a question pauses later calls from the same CLI response until the learner answers", async (t) => {
|
||||
const secondId = "toolu_second_question";
|
||||
const secondQuestion = "What do you want to learn next?";
|
||||
const scenario = toAskScenario();
|
||||
scenario.splice(4, 0,
|
||||
streamEvent({ type: "content_block_start", index: 1, content_block: { type: "tool_use", id: secondId, name: "mcp__learnharness__ask_user_question", input: {} } }),
|
||||
streamEvent({ type: "content_block_delta", index: 1, delta: { type: "input_json_delta", partial_json: JSON.stringify({ question: secondQuestion }) } }),
|
||||
streamEvent({ type: "content_block_stop", index: 1 }),
|
||||
);
|
||||
scenario.splice(scenario.length - 1, 0, JSON.stringify({ fake: "call", id: secondId, name: "ask_user_question", arguments: { question: secondQuestion } }));
|
||||
const fakeRun = createFakeRun(t, [scenario]);
|
||||
const factory = createAgentFactory(fakeRun);
|
||||
const chatId = createClaudeCodeChat(t);
|
||||
await runChatTurn(chatId, { type: "text", text: "teach me sockets" }, factory);
|
||||
assert.equal(danglingQuestionOf(chatStore.getChat(chatId)?.messages ?? [])?.toolCallId, ASK_ID);
|
||||
assert.equal(JSON.stringify(chatStore.getChat(chatId)?.messages).includes(secondId), false, "unadmitted second question must not be posed or persisted");
|
||||
await runChatTurn(chatId, toAnswerInput(), factory);
|
||||
assert.equal(danglingQuestionOf(chatStore.getChat(chatId)?.messages ?? [])?.toolCallId, secondId);
|
||||
await runChatTurn(chatId, {
|
||||
type: "answer", toolCallId: secondId,
|
||||
answer: { kind: "ask", questionText: secondQuestion, dontKnow: false, answers: [], text: "HTTP next", answeredAt: new Date().toISOString() },
|
||||
}, factory);
|
||||
assert.equal(fakeRun.children.length, 1);
|
||||
assert.ok(getLastAssistantText(chatId).includes(ANSWER_TEXT));
|
||||
assert.ok(getLastAssistantText(chatId).includes("HTTP next"));
|
||||
await waitForExit(fakeRun.children[0]!);
|
||||
});
|
||||
|
||||
test("an individually cancelled MCP question queued behind a learner wait never executes", async (t) => {
|
||||
const cancelledId = "toolu_cancelled_question";
|
||||
const question = "This cancelled question must never be posed";
|
||||
const scenario = toAskScenario();
|
||||
scenario.splice(4, 0,
|
||||
streamEvent({ type: "content_block_start", index: 1, content_block: { type: "tool_use", id: cancelledId, name: "mcp__learnharness__ask_user_question", input: {} } }),
|
||||
streamEvent({ type: "content_block_delta", index: 1, delta: { type: "input_json_delta", partial_json: JSON.stringify({ question }) } }),
|
||||
streamEvent({ type: "content_block_stop", index: 1 }),
|
||||
);
|
||||
scenario.splice(scenario.length - 2, 2,
|
||||
JSON.stringify({ fake: "start-call", id: ASK_ID, name: "ask_user_question", arguments: ASK_INPUT }),
|
||||
JSON.stringify({ fake: "wait-for-message" }),
|
||||
JSON.stringify({ fake: "start-call", id: cancelledId, name: "ask_user_question", arguments: { question } }),
|
||||
JSON.stringify({ fake: "cancel-call", id: cancelledId }),
|
||||
JSON.stringify({ fake: "checkpoint" }),
|
||||
JSON.stringify({ fake: "reply" }),
|
||||
);
|
||||
const fakeRun = createFakeRun(t, [scenario]);
|
||||
const factory = createAgentFactory(fakeRun);
|
||||
const chatId = createClaudeCodeChat(t);
|
||||
await runChatTurn(chatId, { type: "text", text: "teach me sockets" }, factory);
|
||||
assert.equal(danglingQuestionOf(chatStore.getChat(chatId)?.messages ?? [])?.toolCallId, ASK_ID);
|
||||
const child = fakeRun.children[0]!;
|
||||
const cancelledOnServer = once(child, "message", { signal: AbortSignal.timeout(TURN_TIMEOUT_MS) });
|
||||
child.send("queue and cancel B");
|
||||
assert.deepEqual((await cancelledOnServer)[0], { fake: "checkpoint" });
|
||||
await runChatTurn(chatId, toAnswerInput(), factory);
|
||||
assert.equal(fakeRun.children.length, 1);
|
||||
assert.equal(danglingQuestionOf(chatStore.getChat(chatId)?.messages ?? []), null);
|
||||
assert.equal(JSON.stringify(chatStore.getChat(chatId)?.messages).includes(cancelledId), false, "cancelled B must never reach native tool execution or persistence");
|
||||
assert.ok(getLastAssistantText(chatId).includes(ANSWER_TEXT));
|
||||
await waitForExit(child);
|
||||
});
|
||||
|
||||
test("a question answered after the idle timeout starts a fresh CLI process that replays the question and answer", async (t) => {
|
||||
liveProcessIdleTimeoutMs.current = 300;
|
||||
t.after(() => (liveProcessIdleTimeoutMs.current = LIVE_PROCESS_IDLE_TIMEOUT_MS));
|
||||
|
||||
const fakeRun = createFakeRun(t, [toAskScenario(), readFileSync(TEXT_FIXTURE, "utf8").split("\n").filter(Boolean)]);
|
||||
const agentFactory = createAgentFactory(fakeRun);
|
||||
const chatId = createClaudeCodeChat(t);
|
||||
|
||||
await runChatTurn(chatId, { type: "text", text: "teach me sockets" }, agentFactory);
|
||||
|
||||
const [waiting] = fakeRun.children;
|
||||
assert.ok(waiting);
|
||||
assert.equal(waiting.exitCode, null, "the CLI process should wait on the held question call");
|
||||
await waitForExit(waiting);
|
||||
|
||||
await runChatTurn(chatId, toAnswerInput(), agentFactory);
|
||||
|
||||
assert.equal(fakeRun.children.length, 2);
|
||||
assert.equal(getLastAssistantText(chatId), "hello there");
|
||||
|
||||
const replay = readFileSync(fakeRun.stdinFiles[1] ?? "", "utf8");
|
||||
assert.ok(replay.includes("[tool call ask_user_question]"), replay);
|
||||
assert.ok(replay.includes(ASK_INPUT.question), replay);
|
||||
assert.ok(replay.includes("[tool result ask_user_question]"), replay);
|
||||
assert.ok(replay.includes(ANSWER_TEXT), replay);
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { type ChildProcessWithoutNullStreams, type spawn } from "node:child_process";
|
||||
import { test } from "node:test";
|
||||
import { inspectClaudeInstallation } from "../src/agent/claude-code/installation.ts";
|
||||
import { ClaudeLaunchError, createClaudeLauncher } from "../src/agent/claude-code/launch.ts";
|
||||
|
||||
const binary = "/fake/claude";
|
||||
const options = { cwd: "/fake/work", env: { NODE_ENV: "test" as const, ANTHROPIC_API_KEY: "native-test-key" } };
|
||||
|
||||
test("missing installation does not execute a command", async () => {
|
||||
assert.deepEqual(await inspectClaudeInstallation(() => null, async () => { throw new Error("must not run"); }), {
|
||||
state: "not-found", version: null, minimumVersion: "2.1.259",
|
||||
});
|
||||
});
|
||||
|
||||
for (const [output, state, version] of [
|
||||
["2.1.258 (Claude Code)\n", "update-required", "2.1.258"],
|
||||
["2.1.259 (Claude Code)", "meets-minimum", "2.1.259"],
|
||||
["2.1.310 (Claude Code)", "meets-minimum", "2.1.310"],
|
||||
["2.2.0", "meets-minimum", "2.2.0"],
|
||||
["2.0.999", "update-required", "2.0.999"],
|
||||
["3.0.0", "meets-minimum", "3.0.0"],
|
||||
["unexpected output containing private details", "version-unchecked", null],
|
||||
["2.1.310-preview", "version-unchecked", null],
|
||||
["99999999999999999999999999.1.1", "version-unchecked", null],
|
||||
] as const) test(`installation version output ${JSON.stringify(output)} gives ${state}`, async () => {
|
||||
const status = await inspectClaudeInstallation(() => binary, async (path) => { assert.equal(path, binary); return output; });
|
||||
assert.deepEqual(status, { state, version, minimumVersion: "2.1.259" });
|
||||
});
|
||||
|
||||
test("probe failures expose neither raw errors nor binary paths", async () => {
|
||||
const status = await inspectClaudeInstallation(() => binary, async () => { throw new Error("timeout /private/path secret-token"); });
|
||||
assert.deepEqual(status, { state: "version-unchecked", version: null, minimumVersion: "2.1.259" });
|
||||
});
|
||||
|
||||
for (const output of ["2.1.258", "garbage", null]) test(`launcher refuses ${output ?? "failed"} version check before model work`, async () => {
|
||||
const launch = createClaudeLauncher({ findBinary: () => binary,
|
||||
readVersion: async () => { if (output === null) throw new Error("failed probe"); return output; },
|
||||
spawnProcess: (() => { assert.fail("must not start model work"); }) as typeof spawn,
|
||||
});
|
||||
await assert.rejects(launch(["-p"], options) as Promise<unknown>, ClaudeLaunchError);
|
||||
});
|
||||
|
||||
test("launcher uses the verified executable directly and preserves model args and native env", async () => {
|
||||
const child = {} as ChildProcessWithoutNullStreams;
|
||||
let calls = 0;
|
||||
const launch = createClaudeLauncher({ findBinary: () => binary, readVersion: async () => "2.1.310 (Claude Code)",
|
||||
spawnProcess: ((path: string, args: string[], opts: unknown) => {
|
||||
calls++;
|
||||
assert.equal(path, binary);
|
||||
assert.deepEqual(args, ["-p", "--tools", ""]);
|
||||
assert.deepEqual(opts, { ...options, stdio: "pipe" });
|
||||
return child;
|
||||
}) as typeof spawn,
|
||||
});
|
||||
assert.equal(await launch(["-p", "--tools", ""], options), child);
|
||||
assert.equal(calls, 1);
|
||||
});
|
||||
|
||||
test("aborting during a version check prevents model process creation", async () => {
|
||||
const controller = new AbortController();
|
||||
const launch = createClaudeLauncher({ findBinary: () => binary,
|
||||
readVersion: async () => { controller.abort(); return "2.1.310"; },
|
||||
spawnProcess: (() => { assert.fail("must not start after abort"); }) as typeof spawn,
|
||||
});
|
||||
await assert.rejects(launch([], { ...options, signal: controller.signal }) as Promise<unknown>, /aborted/);
|
||||
});
|
||||
|
||||
test("missing executable is not mistaken for an unchecked version", async () => {
|
||||
const launch = createClaudeLauncher({ findBinary: () => null, readVersion: async () => assert.fail("must not probe") });
|
||||
assert.equal(await launch([], options), null);
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { delimiter, join } from "node:path";
|
||||
import test, { after, type TestContext } from "node:test";
|
||||
|
||||
const testData = mkdtempSync(join(tmpdir(), "lh-cli-opt-in-data-"));
|
||||
process.env.LH_DATA_DIR = testData;
|
||||
after(() => rmSync(testData, { recursive: true, force: true }));
|
||||
const { SETTINGS_FILE } = await import("../config.ts");
|
||||
const { PUT } = await import("../app/api/settings/[section]/route.ts");
|
||||
const { sanitizeUserSettings, providerKeyMissing } = await import("../src/core/settings.ts");
|
||||
const { parseClaudeCodeSettingsBody } = await import("../src/server/settings-body.ts");
|
||||
const { claudeCodeAvailable, claudeCodeEnabled } = await import("../src/server/claude-code-config.ts");
|
||||
const { claudeCodeInstallationResponse } = await import("../src/server/claude-code.ts");
|
||||
const { modelCatalog, validateModelsSettings } = await import("../src/server/model-catalog.ts");
|
||||
const { providerKeyPresent } = await import("../src/server/keys.ts");
|
||||
const { readSettings, writeSettings } = await import("../src/store/settings.ts");
|
||||
const { chatStore } = await import("../src/store/chats.ts");
|
||||
const { jobStore } = await import("../src/store/jobs.ts");
|
||||
const { createJobRunner } = await import("../src/agent/subagents/runner.ts");
|
||||
const { CLAUDE_CODE_MODELS } = await import("../src/agent/claude-code/provider.ts");
|
||||
|
||||
function setup(t: TestContext) {
|
||||
const previous = readSettings();
|
||||
const path = process.env.PATH;
|
||||
const dir = mkdtempSync(join(tmpdir(), "claude-opt-in-"));
|
||||
// Never executed: only installation discovery observes this marker.
|
||||
writeFileSync(join(dir, process.platform === "win32" ? "claude.exe" : "claude"), "", { mode: 0o755 });
|
||||
process.env.PATH = `${dir}${delimiter}${path ?? ""}`;
|
||||
t.after(() => {
|
||||
if (previous) writeSettings(previous); else rmSync(SETTINGS_FILE, { force: true });
|
||||
if (path === undefined) delete process.env.PATH; else process.env.PATH = path;
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
writeSettings({ version: 1 });
|
||||
}
|
||||
async function save(body: unknown) {
|
||||
return PUT(new Request("http://test/api/settings/claudeCode", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }),
|
||||
{ params: Promise.resolve({ section: "claudeCode" }) });
|
||||
}
|
||||
|
||||
test("opt-in parses strictly and malformed stored values never enable the CLI", () => {
|
||||
assert.deepEqual(parseClaudeCodeSettingsBody({}), { ok: true, settings: {} });
|
||||
assert.deepEqual(parseClaudeCodeSettingsBody({ enabled: true, ignored: true }), { ok: true, settings: { enabled: true } });
|
||||
assert.deepEqual(parseClaudeCodeSettingsBody({ enabled: false }), { ok: true, settings: { enabled: false } });
|
||||
for (const raw of [true, null, [], "on", { enabled: "true" }, { enabled: 1 }]) assert.equal(parseClaudeCodeSettingsBody(raw).ok, false);
|
||||
for (const raw of [null, [], true, "on", { enabled: "true" }, { enabled: 1 }]) {
|
||||
assert.notEqual(sanitizeUserSettings({ version: 1, claudeCode: raw })?.claudeCode?.enabled, true);
|
||||
}
|
||||
assert.equal(sanitizeUserSettings({ version: 1 })?.claudeCode, undefined);
|
||||
});
|
||||
|
||||
test("default off even with an installed binary; section writes toggle availability but preserve saved model pins and unrelated settings", async (t) => {
|
||||
setup(t);
|
||||
const models = { strong: { model: { provider: "claude-code", id: "sonnet" } }, cheap: { model: { provider: "claude-code", id: "haiku" } } };
|
||||
writeSettings({ version: 1, models, graph: { enabled: false }, futureSection: { keep: 1 } });
|
||||
assert.equal(claudeCodeEnabled(), false); assert.equal(claudeCodeAvailable(), false);
|
||||
assert.equal(providerKeyPresent("claude-code"), false);
|
||||
assert(!modelCatalog().models.some((m) => m.provider === "claude-code"));
|
||||
assert.equal((await save({ enabled: true })).status, 200);
|
||||
assert.equal(claudeCodeEnabled(), true); assert.equal(claudeCodeAvailable(), true);
|
||||
assert.equal(modelCatalog().models.filter((m) => m.provider === "claude-code").length, 3);
|
||||
assert.equal((await save({ enabled: false })).status, 200);
|
||||
assert.equal(claudeCodeAvailable(), false);
|
||||
assert(!modelCatalog().models.some((m) => m.provider === "claude-code"));
|
||||
assert.deepEqual(readSettings()?.models, models);
|
||||
assert.deepEqual(readSettings()?.graph, { enabled: false });
|
||||
assert.deepEqual(readSettings()?.futureSection, { keep: 1 });
|
||||
assert.equal(validateModelsSettings(models, models).ok, true, "saved unavailable pins remain repairable, not silently removed");
|
||||
assert.equal((await save({ enabled: "true" })).status, 400); assert.equal(claudeCodeEnabled(), false);
|
||||
assert.equal((await save({})).status, 200); assert.equal(claudeCodeEnabled(), false);
|
||||
});
|
||||
|
||||
test("off cannot launch a version probe even through an otherwise authorized HTTP request", async (t) => {
|
||||
setup(t);
|
||||
const request = new Request("http://test/api/claude-code", { headers: { "x-learnharness-claude-code": "1" } });
|
||||
const response = await claudeCodeInstallationResponse(request, async () => assert.fail("disabled CLI must not execute"));
|
||||
assert.equal(response.status, 403);
|
||||
});
|
||||
|
||||
test("a saved CLI background job is refused before model execution while opt-in is off", async (t) => {
|
||||
setup(t);
|
||||
const chat = chatStore.createChat("CLI opt-in job fixture");
|
||||
t.after(() => { jobStore.deleteJobsForChat(chat.id); chatStore.deleteChat(chat.id); });
|
||||
const job = jobStore.createJob({ kind: "research", chatId: chat.id, toolCallId: "cli-off-job" });
|
||||
let calls = 0;
|
||||
const runner = createJobRunner({ chatStore, jobStore, deliver: async () => {},
|
||||
engineFor: () => ({ model: CLAUDE_CODE_MODELS[0]!, streamFn: () => { calls++; assert.fail("must not run a disabled CLI model"); } }),
|
||||
providerKeyMissing: (provider, tier) => providerKeyPresent(provider) ? null : providerKeyMissing(provider, [tier]),
|
||||
});
|
||||
await runner.runJob(job.id, { question: "Synthetic fixture only" });
|
||||
assert.equal(calls, 0);
|
||||
const refused = jobStore.getJob(job.id)!;
|
||||
assert.equal(refused.status, "error");
|
||||
assert.match(JSON.stringify(refused.result), /Enable and set it up in Settings/);
|
||||
assert(!JSON.stringify(refused.result).includes("add one in Settings → Keys"));
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Claude Code provider catalog (src/agent/claude-code/provider.ts).
|
||||
*
|
||||
* Registered on a fresh pi model collection with a stub stream function, so
|
||||
* only the catalog and auth surface are under test here.
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { clampThinkingLevel, createAssistantMessageEventStream, createModels, getSupportedThinkingLevels } from "@earendil-works/pi-ai";
|
||||
|
||||
import { CLAUDE_CODE_PROVIDER } from "../src/agent/claude-code/constants.ts";
|
||||
import { createClaudeCodeProvider } from "../src/agent/claude-code/provider.ts";
|
||||
|
||||
function createCollection() {
|
||||
const collection = createModels();
|
||||
collection.setProvider(createClaudeCodeProvider(() => createAssistantMessageEventStream()));
|
||||
return collection;
|
||||
}
|
||||
|
||||
test("lists exactly the opus, sonnet and haiku aliases", () => {
|
||||
const models = createCollection().getModels(CLAUDE_CODE_PROVIDER);
|
||||
assert.deepEqual(
|
||||
models.map((m) => m.id),
|
||||
["opus", "sonnet", "haiku"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
models.map((m) => m.name),
|
||||
["Claude Opus", "Claude Sonnet", "Claude Haiku"],
|
||||
);
|
||||
});
|
||||
|
||||
test("supports low through max thinking and clamps off to low", () => {
|
||||
for (const model of createCollection().getModels(CLAUDE_CODE_PROVIDER)) {
|
||||
assert.deepEqual(getSupportedThinkingLevels(model), ["low", "medium", "high", "xhigh", "max"]);
|
||||
assert.equal(clampThinkingLevel(model, "off"), "low");
|
||||
assert.equal(clampThinkingLevel(model, "minimal"), "low");
|
||||
}
|
||||
});
|
||||
|
||||
test("accepts text and image input", () => {
|
||||
for (const model of createCollection().getModels(CLAUDE_CODE_PROVIDER)) {
|
||||
assert.deepEqual(model.input, ["text", "image"]);
|
||||
}
|
||||
});
|
||||
|
||||
test("every cost field is zero", () => {
|
||||
for (const model of createCollection().getModels(CLAUDE_CODE_PROVIDER)) {
|
||||
assert.deepEqual(model.cost, { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 });
|
||||
}
|
||||
});
|
||||
|
||||
test("auth resolves as configured without any key", async () => {
|
||||
const collection = createCollection();
|
||||
assert.ok(await collection.checkAuth(CLAUDE_CODE_PROVIDER));
|
||||
assert.equal((await collection.getAvailable(CLAUDE_CODE_PROVIDER)).length, 3);
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { claudeCodeInstallationResponse as respond } from "../src/server/claude-code.ts";
|
||||
const claudeCodeInstallationResponse: typeof respond = (request, inspect) => respond(request, inspect, () => true);
|
||||
|
||||
function request(headers: Record<string, string> = {}) {
|
||||
return new Request("http://localhost:3000/api/claude-code", { headers: { "x-learnharness-claude-code": "1", ...headers } });
|
||||
}
|
||||
const status = { state: "not-found" as const, version: null, minimumVersion: "2.1.259" };
|
||||
|
||||
for (const headers of [
|
||||
{ origin: "https://attacker.invalid" },
|
||||
{ "sec-fetch-site": "cross-site" },
|
||||
{ origin: "http://localhost:3000", "sec-fetch-site": "cross-site" },
|
||||
{ origin: "null" },
|
||||
{ "x-learnharness-claude-code": "" },
|
||||
] as Record<string, string>[]) test(`unsafe status request ${JSON.stringify(headers)} never invokes the probe`, async () => {
|
||||
let probes = 0;
|
||||
const response = await claudeCodeInstallationResponse(request(headers), async () => { probes++; return status; });
|
||||
assert.equal(response.status, 403); assert.equal(probes, 0);
|
||||
assert.equal(response.headers.get("access-control-allow-origin"), null);
|
||||
});
|
||||
|
||||
test("disabled opt-in never starts even a version probe", async () => {
|
||||
let probes = 0;
|
||||
const response = await respond(request(), async () => { probes++; return status; }, () => false);
|
||||
assert.equal(response.status, 403); assert.equal(probes, 0);
|
||||
});
|
||||
|
||||
test("ordinary image/subresource GET without intent header cannot start a probe", async () => {
|
||||
const response = await claudeCodeInstallationResponse(new Request("http://localhost:3000/api/claude-code"), async () => assert.fail("must not probe"));
|
||||
assert.equal(response.status, 403);
|
||||
});
|
||||
|
||||
for (const headers of [{}, { origin: "http://localhost:3000", "sec-fetch-site": "same-origin" }] as Record<string, string>[]) test(`legitimate Providers GET ${JSON.stringify(headers)} is supported`, async () => {
|
||||
let probes = 0;
|
||||
const response = await claudeCodeInstallationResponse(request(headers), async () => { probes++; return status; });
|
||||
assert.equal(response.status, 200); assert.equal(probes, 1);
|
||||
assert.equal(response.headers.get("cache-control"), "no-store");
|
||||
assert.deepEqual(await response.json(), status);
|
||||
});
|
||||
|
||||
test("a forged forwarded origin cannot authorize a foreign Origin header", async () => {
|
||||
const response = await claudeCodeInstallationResponse(request({ origin: "https://attacker.invalid", "x-forwarded-host": "attacker.invalid", "x-forwarded-proto": "https" }), async () => assert.fail("must not probe"));
|
||||
assert.equal(response.status, 403);
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
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 { renderToStaticMarkup } from "react-dom/server";
|
||||
import { parseHTML } from "linkedom";
|
||||
import { ClaudeCodeInstallationStatus, claudeCodeStatusLabel } from "../components/settings/claude-code-status.tsx";
|
||||
import { ConnectionRow } from "../components/settings/connection-controls.tsx";
|
||||
import { useModelCatalog } from "../components/model-catalog.ts";
|
||||
import type { ClaudeCodeStatus } from "../src/core/contracts/claude-code.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;
|
||||
const status = (state: ClaudeCodeStatus["state"], version: string | null = null): ClaudeCodeStatus => ({ state, version, minimumVersion: "2.1.259" });
|
||||
const button = () => document.querySelector<HTMLButtonElement>('[data-testid="claude-code-check-again"]')!;
|
||||
const toggle = () => document.querySelector<HTMLButtonElement>('[role="switch"]')!;
|
||||
const label = () => document.querySelector('[role="status"]')?.textContent;
|
||||
function fixture(probe: (init?: RequestInit) => Promise<Response>, enabled = true, failSave = false) {
|
||||
const calls: string[] = [];
|
||||
let on = enabled;
|
||||
globalThis.fetch = (async (url, init) => {
|
||||
calls.push(`${init?.method ?? "GET"} ${url}`);
|
||||
if (url === "/api/settings") return Response.json({ settings: { version: 1, claudeCode: { enabled: on } } });
|
||||
if (url === "/api/settings/claudeCode") {
|
||||
assert.equal(init?.method, "PUT"); assert.equal(new Headers(init.headers).get("Content-Type"), "application/json");
|
||||
if (failSave) return new Response(null, { status: 500 });
|
||||
on = JSON.parse(String(init.body)).enabled;
|
||||
return Response.json({ settings: { version: 1, claudeCode: { enabled: on } } });
|
||||
}
|
||||
if (url === "/api/models") return Response.json({ models: on ? [{ provider: "claude-code", id: "sonnet" }] : [] });
|
||||
assert.equal(url, "/api/claude-code");
|
||||
assert.equal(init?.cache, "no-store");
|
||||
assert.equal(new Headers(init?.headers).get("x-learnharness-claude-code"), "1");
|
||||
return probe(init);
|
||||
}) as typeof fetch;
|
||||
return calls;
|
||||
}
|
||||
async function mount(extra?: React.ReactNode) {
|
||||
host = document.createElement("div"); document.body.append(host); root = createRoot(host);
|
||||
await act(async () => root!.render(<><ClaudeCodeInstallationStatus />{extra}</>));
|
||||
}
|
||||
afterEach(async () => {
|
||||
await act(async () => root?.unmount()); host?.remove(); root = undefined; host = undefined;
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
test("initial render is collapsed and off, with no setup prose or version probe action", () => {
|
||||
const html = renderToStaticMarkup(<ClaudeCodeInstallationStatus />);
|
||||
assert.match(html, /Claude Code/); assert.match(html, /aria-checked="false"/);
|
||||
assert.equal((html.match(/<button/g) ?? []).length, 1);
|
||||
assert(!html.includes("Check again")); assert(!html.includes("Authenticate")); assert(!html.includes("Setup ↗"));
|
||||
});
|
||||
|
||||
test("off by default; enabling persists before probing and reveals the version/action row below the toggle", async () => {
|
||||
const calls = fixture(async () => Response.json(status("not-found")), false);
|
||||
await mount();
|
||||
assert.equal(toggle().getAttribute("aria-checked"), "false"); assert(!button());
|
||||
assert.deepEqual(calls, ["GET /api/settings"]);
|
||||
await act(async () => toggle().click());
|
||||
assert.equal(toggle().getAttribute("aria-checked"), "true"); assert.equal(label(), "Not found");
|
||||
assert(calls.indexOf("PUT /api/settings/claudeCode") < calls.indexOf("GET /api/claude-code"));
|
||||
assert.notEqual(toggle().parentElement, button().parentElement);
|
||||
assert.equal(document.querySelector('[role="status"]')?.parentElement, button().parentElement);
|
||||
assert.match(document.body.textContent ?? "", /no background process required/);
|
||||
assert.match(document.body.textContent ?? "", /not authentication, model access, or quota/);
|
||||
assert.match(document.body.textContent ?? "", /Claude Code controls billing/);
|
||||
assert.match(document.body.textContent ?? "", /Verify AI claims independently—they may be inaccurate, incomplete, misleading, or outdated\./);
|
||||
assert(document.querySelector("strong")?.textContent?.includes("Install and authenticate"));
|
||||
assert.equal(document.querySelector("a")?.getAttribute("href"), "https://code.claude.com/docs/en/setup");
|
||||
await act(async () => toggle().click());
|
||||
assert.equal(toggle().getAttribute("aria-checked"), "false"); assert(!button()); assert.equal(label(), undefined);
|
||||
assert.equal(calls.filter((call) => call === "GET /api/claude-code").length, 1);
|
||||
});
|
||||
|
||||
test("recheck matches Connect styling; Manage remains ghost", async () => {
|
||||
fixture(async () => Response.json(status("not-found"))); await mount();
|
||||
const row = (action: string) => parseHTML(renderToStaticMarkup(<ConnectionRow label="Fixture" action={action} disabled={false} onAction={() => {}} />)).document.querySelector("button")!;
|
||||
assert.equal(button().getAttribute("class"), row("Connect").getAttribute("class"));
|
||||
assert.equal(button().getAttribute("data-variant"), "outline");
|
||||
assert.equal(row("Manage").getAttribute("data-variant"), "ghost");
|
||||
});
|
||||
|
||||
for (const [value, text] of [
|
||||
[status("not-found"), "Not found"],
|
||||
[status("meets-minimum", "2.1.310"), "Version 2.1.310 · meets minimum"],
|
||||
[status("update-required", "2.1.258"), "Version 2.1.258 · update required"],
|
||||
[status("version-unchecked"), "Version could not be checked"],
|
||||
] as const) test(`installation display: ${text}`, () => assert.equal(claudeCodeStatusLabel(value), text));
|
||||
|
||||
test("enabled mount checks once; outlined Check again refreshes without probing authentication", async () => {
|
||||
let probes = 0;
|
||||
const calls = fixture(async () => Response.json(++probes === 1 ? status("not-found") : status("meets-minimum", "2.1.310")));
|
||||
await mount(); assert.equal(label(), "Not found");
|
||||
await act(async () => button().click()); assert.equal(label(), "Version 2.1.310 · meets minimum");
|
||||
assert.equal(calls.filter((call) => call === "GET /api/claude-code").length, 2);
|
||||
});
|
||||
|
||||
test("failed opt-in save stays collapsed/off and never probes", async () => {
|
||||
const calls = fixture(async () => assert.fail("must not probe"), false, true);
|
||||
await mount(); await act(async () => toggle().click());
|
||||
assert.equal(toggle().getAttribute("aria-checked"), "false"); assert(!button());
|
||||
assert.match(document.querySelector('[role="alert"]')?.textContent ?? "", /Could not save/);
|
||||
assert(!calls.includes("GET /api/claude-code"));
|
||||
});
|
||||
|
||||
test("slow checks disable recheck; switching off aborts pending version work without starting another probe", async () => {
|
||||
let signal: AbortSignal | null | undefined;
|
||||
let resolve!: (response: Response) => void;
|
||||
fixture((init) => { signal = init?.signal; return new Promise<Response>((done) => { resolve = done; }); });
|
||||
await mount(); assert.equal(label(), "Checking…"); assert(button().disabled); assert(!toggle().disabled);
|
||||
await act(async () => toggle().click()); assert(signal?.aborted); assert(!button());
|
||||
await act(async () => resolve(Response.json(status("meets-minimum", "2.1.310")))); assert.equal(label(), undefined);
|
||||
});
|
||||
|
||||
for (const failure of ["network", "http", "payload"] as const) test(`${failure} failure never retains a stale meets-minimum status`, async () => {
|
||||
let calls = 0;
|
||||
fixture(async () => {
|
||||
if (++calls === 1) return Response.json(status("meets-minimum", "2.1.310"));
|
||||
if (failure === "network") throw new Error("offline");
|
||||
return failure === "http" ? new Response(null, { status: 503 }) : Response.json({ state: "authenticated" });
|
||||
});
|
||||
await mount(); assert.equal(label(), "Version 2.1.310 · meets minimum");
|
||||
await act(async () => button().click()); assert.equal(label(), "Version could not be checked"); assert(!button().disabled);
|
||||
});
|
||||
|
||||
function CatalogReader() {
|
||||
const { catalog } = useModelCatalog();
|
||||
return <span data-testid="cli-catalog-models">{catalog?.models.map((m) => `${m.provider}:${m.id}`).join(",") ?? "loading"}</span>;
|
||||
}
|
||||
test("persisted toggle updates invalidate shared model availability in both directions", async () => {
|
||||
fixture(async () => Response.json(status("not-found")), false);
|
||||
await mount(<CatalogReader />);
|
||||
const models = () => document.querySelector('[data-testid="cli-catalog-models"]')?.textContent;
|
||||
assert.equal(models(), "");
|
||||
await act(async () => toggle().click()); assert.equal(models(), "claude-code:sonnet");
|
||||
await act(async () => toggle().click()); assert.equal(models(), "");
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* Claude Code MCP tool bridge (src/agent/claude-code/tool-bridge.ts).
|
||||
*
|
||||
* A real MCP SDK client talks to the bridge over loopback HTTP, the same way
|
||||
* the claude CLI does, so listing, auth, pending calls and their results
|
||||
* are all exercised end to end.
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import * as net from "node:net";
|
||||
import * as os from "node:os";
|
||||
import test from "node:test";
|
||||
|
||||
import type { Tool, ToolResultMessage } from "@earendil-works/pi-ai";
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
||||
import { Type } from "typebox";
|
||||
|
||||
import { BRIDGE_SERVER_NAME, createToolBridge, toolNameFromMcp, type ToolBridge } from "../src/agent/claude-code/tool-bridge.ts";
|
||||
|
||||
const READ_TOOL: Tool = { name: "read", description: "Read a file", parameters: Type.Object({ path: Type.String() }) };
|
||||
const SCREENSHOT_TOOL: Tool = { name: "screenshot", description: "Capture the screen", parameters: Type.Object({}) };
|
||||
|
||||
type BridgeServerConfig = { type: string; url: string; headers: { Authorization: string } };
|
||||
|
||||
function getServerConfig(bridge: ToolBridge): BridgeServerConfig {
|
||||
const config = JSON.parse(bridge.mcpConfig) as { mcpServers: Record<string, BridgeServerConfig> };
|
||||
const server = config.mcpServers[BRIDGE_SERVER_NAME];
|
||||
assert.ok(server);
|
||||
return server;
|
||||
}
|
||||
|
||||
async function connectClient(bridge: ToolBridge, authorization = getServerConfig(bridge).headers.Authorization): Promise<Client> {
|
||||
const transport = new StreamableHTTPClientTransport(new URL(getServerConfig(bridge).url), {
|
||||
requestInit: { headers: { Authorization: authorization } },
|
||||
});
|
||||
const client = new Client({ name: "test", version: "0" });
|
||||
await client.connect(transport);
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
function toolResult(content: ToolResultMessage["content"], isError = false): ToolResultMessage {
|
||||
return { role: "toolResult", toolCallId: "call-1", toolName: "read", content, isError, timestamp: 0 };
|
||||
}
|
||||
|
||||
test("lists exactly the given tools with their JSON schemas", async (t) => {
|
||||
const bridge = await createToolBridge([READ_TOOL, SCREENSHOT_TOOL]);
|
||||
t.after(() => bridge.close());
|
||||
const client = await connectClient(bridge);
|
||||
t.after(() => client.close());
|
||||
|
||||
const { tools } = await client.listTools();
|
||||
|
||||
assert.deepEqual(
|
||||
tools.map((tool) => ({ name: tool.name, description: tool.description, inputSchema: tool.inputSchema })),
|
||||
[
|
||||
{ name: "read", description: "Read a file", inputSchema: JSON.parse(JSON.stringify(READ_TOOL.parameters)) },
|
||||
{ name: "screenshot", description: "Capture the screen", inputSchema: JSON.parse(JSON.stringify(SCREENSHOT_TOOL.parameters)) },
|
||||
],
|
||||
);
|
||||
|
||||
assert.deepEqual(bridge.allowedTools, ["mcp__learnharness__read", "mcp__learnharness__screenshot"]);
|
||||
});
|
||||
|
||||
test("rejects requests without the bearer token", async (t) => {
|
||||
const bridge = await createToolBridge([READ_TOOL]);
|
||||
t.after(() => bridge.close());
|
||||
|
||||
await assert.rejects(connectClient(bridge, "Bearer wrong"), (error: { code?: number }) => error.code === 401);
|
||||
const response = await fetch(getServerConfig(bridge).url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: "{}",
|
||||
});
|
||||
assert.equal(response.status, 401);
|
||||
});
|
||||
|
||||
test("binds 127.0.0.1", async (t) => {
|
||||
const bridge = await createToolBridge([READ_TOOL]);
|
||||
t.after(() => bridge.close());
|
||||
|
||||
const url = new URL(getServerConfig(bridge).url);
|
||||
assert.equal(url.hostname, "127.0.0.1");
|
||||
|
||||
const externalAddress = Object.values(os.networkInterfaces())
|
||||
.flat()
|
||||
.find((address) => address && !address.internal && address.family === "IPv4");
|
||||
|
||||
if (!externalAddress) {
|
||||
t.diagnostic("no non-loopback IPv4 interface; skipped external connect check");
|
||||
return;
|
||||
}
|
||||
|
||||
await assert.rejects(
|
||||
new Promise((resolve, reject) => {
|
||||
const socket = net.connect(Number(url.port), externalAddress.address, () => resolve(socket.end()));
|
||||
socket.once("error", reject);
|
||||
}),
|
||||
{ code: "ECONNREFUSED" },
|
||||
);
|
||||
});
|
||||
|
||||
test("tools/call stays pending until resolved and returns its text and image content", async (t) => {
|
||||
const bridge = await createToolBridge([READ_TOOL]);
|
||||
t.after(() => bridge.close());
|
||||
const client = await connectClient(bridge);
|
||||
t.after(() => client.close());
|
||||
|
||||
let settled = false;
|
||||
const pending = client.callTool({ name: "read", arguments: { path: "notes.md" }, _meta: { "claudecode/toolUseId": "call-1" } }).finally(() => {
|
||||
settled = true;
|
||||
});
|
||||
|
||||
const [call] = await bridge.waitForToolCalls(1);
|
||||
assert.ok(call);
|
||||
assert.deepEqual(call, { id: "call-1", name: "read", arguments: { path: "notes.md" } });
|
||||
assert.deepEqual(bridge.getPendingToolCalls(), [call]);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
assert.equal(settled, false);
|
||||
|
||||
bridge.resolveToolCall(
|
||||
call,
|
||||
toolResult([
|
||||
{ type: "text", text: "# Notes" },
|
||||
{ type: "image", data: "AAAA", mimeType: "image/png" },
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await pending;
|
||||
assert.deepEqual(result.content, [
|
||||
{ type: "text", text: "# Notes" },
|
||||
{ type: "image", data: "AAAA", mimeType: "image/png" },
|
||||
]);
|
||||
assert.notEqual(result.isError, true);
|
||||
assert.deepEqual(bridge.getPendingToolCalls(), []);
|
||||
});
|
||||
|
||||
test("individual MCP cancellation removes only that request and leaves the other request resolvable", async (t) => {
|
||||
let acknowledgeCancel!: () => void;
|
||||
const cancelled = new Promise<void>((resolve) => { acknowledgeCancel = resolve; });
|
||||
const cancellations: string[] = [];
|
||||
const bridge = await createToolBridge([READ_TOOL], {
|
||||
onCall() {}, onError(error) { throw error; },
|
||||
onCancel(call) { cancellations.push(call.id); acknowledgeCancel(); },
|
||||
});
|
||||
t.after(() => bridge.close());
|
||||
const client = await connectClient(bridge);
|
||||
t.after(() => client.close());
|
||||
const first = client.callTool({ name: "read", arguments: { path: "a" }, _meta: { "claudecode/toolUseId": "call-a" } });
|
||||
const controller = new AbortController();
|
||||
const second = client.callTool({ name: "read", arguments: { path: "b" }, _meta: { "claudecode/toolUseId": "call-b" } }, undefined, { signal: controller.signal });
|
||||
const rejected = assert.rejects(second, /cancelled B/);
|
||||
await bridge.waitForToolCalls(2);
|
||||
controller.abort("cancelled B");
|
||||
await cancelled;
|
||||
await rejected;
|
||||
assert.deepEqual(cancellations, ["call-b"]);
|
||||
const pending = bridge.getPendingToolCalls();
|
||||
assert.deepEqual(pending.map((call) => call.id), ["call-a"]);
|
||||
bridge.resolveToolCall(pending[0]!, toolResult([{ type: "text", text: "A" }]));
|
||||
assert.deepEqual((await first).content, [{ type: "text", text: "A" }]);
|
||||
assert.deepEqual(bridge.getPendingToolCalls(), []);
|
||||
});
|
||||
|
||||
test("waitForToolCalls resolves once that many calls are pending, in arrival order", async (t) => {
|
||||
const bridge = await createToolBridge([READ_TOOL, SCREENSHOT_TOOL]);
|
||||
t.after(() => bridge.close());
|
||||
const client = await connectClient(bridge);
|
||||
t.after(() => client.close());
|
||||
|
||||
let waited = false;
|
||||
const waiting = bridge.waitForToolCalls(2).finally(() => {
|
||||
waited = true;
|
||||
});
|
||||
|
||||
void client.callTool({ name: "read", arguments: { path: "a.md" }, _meta: { "claudecode/toolUseId": "call-1" } }).catch(() => {});
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
assert.equal(waited, false);
|
||||
|
||||
void client.callTool({ name: "screenshot", arguments: {}, _meta: { "claudecode/toolUseId": "call-2" } }).catch(() => {});
|
||||
|
||||
assert.deepEqual(await waiting, [
|
||||
{ id: "call-1", name: "read", arguments: { path: "a.md" } },
|
||||
{ id: "call-2", name: "screenshot", arguments: {} },
|
||||
]);
|
||||
});
|
||||
|
||||
test("isError result becomes MCP isError true", async (t) => {
|
||||
const bridge = await createToolBridge([READ_TOOL]);
|
||||
t.after(() => bridge.close());
|
||||
const client = await connectClient(bridge);
|
||||
t.after(() => client.close());
|
||||
|
||||
const pending = client.callTool({ name: "read", arguments: { path: "missing.md" }, _meta: { "claudecode/toolUseId": "call-1" } });
|
||||
const [call] = await bridge.waitForToolCalls(1);
|
||||
assert.ok(call);
|
||||
|
||||
bridge.resolveToolCall(call, toolResult([{ type: "text", text: "ENOENT" }], true));
|
||||
|
||||
const result = await pending;
|
||||
assert.equal(result.isError, true);
|
||||
assert.deepEqual(result.content, [{ type: "text", text: "ENOENT" }]);
|
||||
});
|
||||
|
||||
test("close fails pending calls and frees the port", async () => {
|
||||
const bridge = await createToolBridge([READ_TOOL]);
|
||||
const port = Number(new URL(getServerConfig(bridge).url).port);
|
||||
const client = await connectClient(bridge);
|
||||
const pending = client.callTool({ name: "read", arguments: { path: "notes.md" }, _meta: { "claudecode/toolUseId": "call-1" } });
|
||||
await bridge.waitForToolCalls(1);
|
||||
|
||||
await bridge.close();
|
||||
|
||||
await assert.rejects(pending);
|
||||
assert.deepEqual(bridge.getPendingToolCalls(), []);
|
||||
await client.close();
|
||||
|
||||
const probe = net.createServer();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
probe.once("error", reject);
|
||||
probe.listen(port, "127.0.0.1", resolve);
|
||||
});
|
||||
await new Promise((resolve) => probe.close(resolve));
|
||||
});
|
||||
|
||||
test("close rejects consumers still waiting for tool calls", { timeout: 1000 }, async () => {
|
||||
const bridge = await createToolBridge([READ_TOOL]);
|
||||
const waiting = bridge.waitForToolCalls(1);
|
||||
|
||||
await bridge.close();
|
||||
await assert.rejects(waiting, /tool bridge closed/);
|
||||
});
|
||||
|
||||
test('mcpConfig is {"mcpServers":{"learnharness":{"type":"http","url":...,"headers":{"Authorization":"Bearer ..."}}}}', async (t) => {
|
||||
const bridge = await createToolBridge([READ_TOOL]);
|
||||
t.after(() => bridge.close());
|
||||
|
||||
const config = JSON.parse(bridge.mcpConfig) as { mcpServers: Record<string, unknown> };
|
||||
assert.deepEqual(Object.keys(config.mcpServers), ["learnharness"]);
|
||||
|
||||
const server = getServerConfig(bridge);
|
||||
assert.deepEqual(Object.keys(server).sort(), ["headers", "type", "url"]);
|
||||
assert.equal(server.type, "http");
|
||||
assert.match(server.url, /^http:\/\/127\.0\.0\.1:\d+\/mcp$/);
|
||||
assert.match(server.headers.Authorization, /^Bearer [0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
test("missing and reused Claude tool IDs are rejected rather than matched by arguments", async (t) => {
|
||||
const failures: string[] = [];
|
||||
const bridge = await createToolBridge([READ_TOOL], { onCall: () => {}, onError: (error) => failures.push(error.message) });
|
||||
t.after(() => bridge.close());
|
||||
const client = await connectClient(bridge);
|
||||
t.after(() => client.close());
|
||||
await assert.rejects(client.callTool({ name: "read", arguments: { path: "a" } }), /claudecode\/toolUseId/);
|
||||
assert.deepEqual(bridge.getPendingToolCalls(), []);
|
||||
const params = { name: "read", arguments: { path: "a" }, _meta: { "claudecode/toolUseId": "same-id" } };
|
||||
const first = client.callTool(params);
|
||||
const [call] = await bridge.waitForToolCalls(1);
|
||||
assert.ok(call);
|
||||
bridge.resolveToolCall(call, toolResult([{ type: "text", text: "first" }]));
|
||||
await first;
|
||||
await assert.rejects(client.callTool(params), /claudecode\/toolUseId/);
|
||||
assert.deepEqual(bridge.getPendingToolCalls(), []);
|
||||
assert.equal(failures.length, 2);
|
||||
});
|
||||
|
||||
test("toolNameFromMcp strips the bridge prefix", () => {
|
||||
assert.equal(toolNameFromMcp("mcp__learnharness__read"), "read");
|
||||
assert.equal(toolNameFromMcp("read"), "read");
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* Claude Code transcript replay (src/agent/claude-code/transcript.ts).
|
||||
*
|
||||
* The CLI makes one model call per input user message, so earlier turns are
|
||||
* rendered as labelled text inside the single user message it receives.
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import type { AssistantMessage, Message, SystemMessage, Tool, ToolResultMessage, UserMessage } from "@earendil-works/pi-ai";
|
||||
|
||||
import { toClaudeInput, toStreamJsonLine } from "../src/agent/claude-code/transcript.ts";
|
||||
|
||||
const READ_TOOL: Tool = { name: "read", description: "Read a file", parameters: { type: "object", properties: {} } };
|
||||
const WRITE_TOOL: Tool = { name: "write", description: "Write a file", parameters: { type: "object", properties: {} } };
|
||||
|
||||
function system(fields: Partial<SystemMessage>): SystemMessage {
|
||||
return { role: "system", content: "", timestamp: 0, ...fields };
|
||||
}
|
||||
|
||||
function user(content: UserMessage["content"]): UserMessage {
|
||||
return { role: "user", content, timestamp: 0 };
|
||||
}
|
||||
|
||||
function assistant(content: AssistantMessage["content"]): AssistantMessage {
|
||||
return {
|
||||
role: "assistant",
|
||||
content,
|
||||
api: "claude-code-cli",
|
||||
provider: "claude-code",
|
||||
model: "opus",
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "stop",
|
||||
timestamp: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function toolResult(toolName: string, content: ToolResultMessage["content"], isError = false): ToolResultMessage {
|
||||
return { role: "toolResult", toolCallId: `${toolName}-1`, toolName, content, isError, timestamp: 0 };
|
||||
}
|
||||
|
||||
test("system prompt joins all system content, then the live sections", () => {
|
||||
const messages: Message[] = [
|
||||
system({ content: "You are a tutor.", sections: { goals: "Teach TCP.", style: "Be brief." } }),
|
||||
user("hi"),
|
||||
system({ content: "The learner switched topics.", sections: { goals: "Teach TLS.", style: null } }),
|
||||
user("next"),
|
||||
];
|
||||
assert.equal(toClaudeInput(messages).systemPrompt, "You are a tutor.\n\nThe learner switched topics.\n\nTeach TLS.");
|
||||
});
|
||||
|
||||
test("tools reflect toolsAdded minus toolsRemoved", () => {
|
||||
const messages: Message[] = [
|
||||
system({ content: "p", toolsAdded: [READ_TOOL, WRITE_TOOL] }),
|
||||
user("hi"),
|
||||
system({ toolsRemoved: [{ name: "read" }] }),
|
||||
user("next"),
|
||||
];
|
||||
assert.deepEqual(
|
||||
toClaudeInput(messages).tools.map((t) => t.name),
|
||||
["write"],
|
||||
);
|
||||
});
|
||||
|
||||
test("single user message passes through as text", () => {
|
||||
const input = toClaudeInput([system({ content: "p" }), user("what is a socket?")]);
|
||||
assert.deepEqual(input.userContent, [{ type: "text", text: "what is a socket?" }]);
|
||||
});
|
||||
|
||||
test("history renders user, assistant text, tool call name+JSON args and tool result text in order before the latest user message", () => {
|
||||
const messages: Message[] = [
|
||||
user([{ type: "text", text: "open notes.md" }]),
|
||||
assistant([
|
||||
{ type: "text", text: "Reading it." },
|
||||
{ type: "toolCall", id: "read-1", name: "read", arguments: { path: "notes.md" } },
|
||||
]),
|
||||
toolResult("read", [{ type: "text", text: "# Notes" }]),
|
||||
assistant([{ type: "text", text: "It has a heading." }]),
|
||||
user("summarise it"),
|
||||
];
|
||||
const expected = [
|
||||
"[user]\nopen notes.md",
|
||||
"[assistant]\nReading it.",
|
||||
'[tool call read] {"path":"notes.md"}',
|
||||
"[tool result read]\n# Notes",
|
||||
"[assistant]\nIt has a heading.",
|
||||
"[user]\nsummarise it",
|
||||
].join("\n\n");
|
||||
assert.deepEqual(toClaudeInput(messages).userContent, [{ type: "text", text: expected }]);
|
||||
});
|
||||
|
||||
test("error tool results are marked as errors in the replay", () => {
|
||||
const messages: Message[] = [
|
||||
user("open missing.md"),
|
||||
assistant([{ type: "toolCall", id: "read-1", name: "read", arguments: { path: "missing.md" } }]),
|
||||
toolResult("read", [{ type: "text", text: "ENOENT" }], true),
|
||||
user("why?"),
|
||||
];
|
||||
const expected = [
|
||||
"[user]\nopen missing.md",
|
||||
'[tool call read] {"path":"missing.md"}',
|
||||
"[tool result read error]\nENOENT",
|
||||
"[user]\nwhy?",
|
||||
].join("\n\n");
|
||||
assert.deepEqual(toClaudeInput(messages).userContent, [{ type: "text", text: expected }]);
|
||||
});
|
||||
|
||||
test("images in user and tool-result content become base64 image blocks", () => {
|
||||
const messages: Message[] = [
|
||||
user([
|
||||
{ type: "text", text: "look" },
|
||||
{ type: "image", data: "AAAA", mimeType: "image/png" },
|
||||
]),
|
||||
assistant([{ type: "toolCall", id: "shot-1", name: "screenshot", arguments: {} }]),
|
||||
toolResult("screenshot", [{ type: "image", data: "BBBB", mimeType: "image/jpeg" }]),
|
||||
user("compare"),
|
||||
];
|
||||
assert.deepEqual(toClaudeInput(messages).userContent, [
|
||||
{ type: "text", text: "[user]\nlook" },
|
||||
{ type: "image", source: { type: "base64", media_type: "image/png", data: "AAAA" } },
|
||||
{ type: "text", text: "[tool call screenshot] {}\n\n[tool result screenshot]" },
|
||||
{ type: "image", source: { type: "base64", media_type: "image/jpeg", data: "BBBB" } },
|
||||
{ type: "text", text: "[user]\ncompare" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("transcript ending on a tool result renders as history with no trailing user turn", () => {
|
||||
const messages: Message[] = [
|
||||
user("open notes.md"),
|
||||
assistant([{ type: "toolCall", id: "read-1", name: "read", arguments: { path: "notes.md" } }]),
|
||||
toolResult("read", [{ type: "text", text: "# Notes" }]),
|
||||
];
|
||||
const expected = ["[user]\nopen notes.md", '[tool call read] {"path":"notes.md"}', "[tool result read]\n# Notes"].join("\n\n");
|
||||
assert.deepEqual(toClaudeInput(messages).userContent, [{ type: "text", text: expected }]);
|
||||
});
|
||||
|
||||
test("thinking blocks are omitted from replay", () => {
|
||||
const messages: Message[] = [
|
||||
user("hi"),
|
||||
assistant([
|
||||
{ type: "thinking", thinking: "secret plan" },
|
||||
{ type: "text", text: "Hello." },
|
||||
]),
|
||||
user("bye"),
|
||||
];
|
||||
assert.deepEqual(toClaudeInput(messages).userContent, [{ type: "text", text: "[user]\nhi\n\n[assistant]\nHello.\n\n[user]\nbye" }]);
|
||||
});
|
||||
|
||||
test('toStreamJsonLine emits {"type":"user","message":{"role":"user","content":[...]}}', () => {
|
||||
assert.deepEqual(JSON.parse(toStreamJsonLine([{ type: "text", text: "hi" }])), {
|
||||
type: "user",
|
||||
message: { role: "user", content: [{ type: "text", text: "hi" }] },
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import React from "react";
|
||||
(globalThis as Record<string, unknown>).React = React;
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { CLAUDE_CODE_MODELS } from "../src/agent/claude-code/provider.ts";
|
||||
import { isSubscriptionModel, sumSteps, type TurnUsage } from "../src/core/index.ts";
|
||||
import { graphCostSummary } from "../src/core/cost-summary.ts";
|
||||
import { accumulateJobUsage } from "../src/core/contracts/jobs.ts";
|
||||
import { callCost, jobCost, sessionCost } from "../src/server/pricing.ts";
|
||||
import { toModelDto } from "../src/server/model-catalog.ts";
|
||||
import { ModelMenuList } from "../components/settings/model-menu.tsx";
|
||||
import { StatsBar } from "../components/stats-bar.tsx";
|
||||
|
||||
const step = { inputTokens: 1000, outputTokens: 100, cacheReadTokens: 50, cacheWriteTokens: 0, reasoningTokens: undefined };
|
||||
for (const model of CLAUDE_CODE_MODELS) test(`${model.id}: token usage is retained but dollars are unpriced, not free or subscription use`, () => {
|
||||
const id = `${model.provider}:${model.id}`;
|
||||
const turn: TurnUsage = { id: "cli-turn", messageId: "cli-message", at: "2026-10-28T00:00:00Z", model: id, steps: [step] };
|
||||
assert(!isSubscriptionModel(id));
|
||||
assert.equal(callCost(step, id), null);
|
||||
const pricing = sessionCost({ turns: [turn] });
|
||||
assert.equal(pricing.unpriced, 1); assert.equal(pricing.subscription, undefined);
|
||||
assert.equal(pricing.turnCosts[turn.id], null);
|
||||
assert.deepEqual(sumSteps(turn.steps), { ...step, cacheWrite1hTokens: undefined, reasoningTokens: undefined });
|
||||
assert.equal(jobCost({ usage: accumulateJobUsage(undefined, id, step) }), null);
|
||||
const html = renderToStaticMarkup(<StatsBar turns={[turn]} pricing={pricing} />);
|
||||
assert(!html.includes("$0"));
|
||||
assert.match(html, /data-testid="stats-cost">CLI<\/span>/);
|
||||
const summary = graphCostSummary({ chats: [{ title: "CLI fixture", extractions: [{ startedAt: 1, at: turn.at, durationMs: 1,
|
||||
stages: { extractor: { model: id, steps: [step] }, auditor: { model: id, steps: [step] } },
|
||||
}] }], sources: [] }, callCost);
|
||||
assert.equal(summary.unpricedRuns, 1); assert.equal(summary.subscriptionRuns, undefined);
|
||||
assert.equal(summary.runs[0]!.dollars, null);
|
||||
});
|
||||
|
||||
test("mixed session totals retain known API dollars and subscription labels alongside CLI billing", () => {
|
||||
const cli: TurnUsage = { id: "cli", messageId: "cli", at: "2026-10-28T00:00:00Z", model: "claude-code:sonnet", steps: [step] };
|
||||
const api = { ...cli, id: "api", messageId: "api", model: "openai:fixture" };
|
||||
const pricing = { cost: 0.12, unpriced: 1, turnCosts: { cli: null, api: 0.12 } };
|
||||
const html = renderToStaticMarkup(<StatsBar turns={[api, cli]} pricing={pricing} />);
|
||||
assert.match(html, /data-testid="stats-cost">\$0\.1200 \+ CLI<\/span>/);
|
||||
const subscription = { ...cli, id: "plan", messageId: "plan", model: "chatgpt:fixture" };
|
||||
const mixed = renderToStaticMarkup(<StatsBar turns={[api, cli, subscription]} pricing={{ ...pricing, subscription: 1, subscriptionProviders: ["ChatGPT"] }} />);
|
||||
assert.match(mixed, /data-testid="stats-cost">\$0\.1200 \+ ChatGPT \+ CLI<\/span>/);
|
||||
assert.equal(callCost(step, cli.model), null, "CLI stays unpriced in accounting");
|
||||
});
|
||||
|
||||
test("model-catalog API and menus never advertise the CLI adapter's placeholder zero rates", () => {
|
||||
const models = CLAUDE_CODE_MODELS.map(toModelDto);
|
||||
assert(models.every((model) => model.cost === null));
|
||||
const html = renderToStaticMarkup(<ModelMenuList models={models} effectiveId={undefined} onPick={() => {}} />);
|
||||
assert.equal((html.match(/>CLI billing<\/span>/g) ?? []).length, 3);
|
||||
assert.match(html, /does not estimate it/);
|
||||
assert(!html.includes("$0")); assert(!html.includes("USD per 1M")); assert(!html.includes("(subscription)"));
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
/** Real execFile boundary, authored fixture executables only. Never the installed CLI. */
|
||||
import assert from "node:assert/strict";
|
||||
import { type spawn } from "node:child_process";
|
||||
import { accessSync, constants, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { test, type TestContext } from "node:test";
|
||||
import { resolveClaudeBinary } from "../src/agent/claude-code/binary.ts";
|
||||
import { readClaudeVersion } from "../src/agent/claude-code/installation.ts";
|
||||
import { createClaudeLauncher } from "../src/agent/claude-code/launch.ts";
|
||||
|
||||
const unsupportedShebang = process.platform === "win32" || /\s/.test(process.execPath);
|
||||
function fixture(t: TestContext, mode: "normal" | "oversize" | "hang", name = "claude") {
|
||||
const dir = mkdtempSync(join(tmpdir(), "lh-version-runner-"));
|
||||
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
||||
const bin = join(dir, "bin"); mkdirSync(bin);
|
||||
const binary = join(bin, name);
|
||||
const body = mode === "hang" ? `
|
||||
import { writeFileSync } from "node:fs";
|
||||
writeFileSync(process.argv[1] + ".pid", String(process.pid));
|
||||
process.on("SIGTERM", () => {});
|
||||
setInterval(() => {}, 1000);
|
||||
` : mode === "oversize" ? 'process.stdout.write("x".repeat(100000));' : `
|
||||
if (JSON.stringify(process.argv.slice(2)) !== '["--version"]') process.exit(2);
|
||||
process.stdout.write("2.1.310 (Claude Code)\\n");
|
||||
`;
|
||||
writeFileSync(binary, `#!${process.execPath}\n${body}`, { mode: 0o755 });
|
||||
return { dir, binary };
|
||||
}
|
||||
|
||||
test("production version runner directly executes a spaced/metacharacter filename with only --version", { skip: unsupportedShebang }, async (t) => {
|
||||
const { binary } = fixture(t, "normal", "claude ; fixture");
|
||||
assert.equal(await readClaudeVersion(binary), "2.1.310 (Claude Code)\n");
|
||||
});
|
||||
|
||||
test("production version runner bounds stdout", { skip: unsupportedShebang }, async (t) => {
|
||||
const { binary } = fixture(t, "oversize");
|
||||
await assert.rejects(readClaudeVersion(binary), (error: NodeJS.ErrnoException) => error.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER");
|
||||
});
|
||||
|
||||
test("production version runner times out and SIGKILLs an uncooperative fixture", { skip: unsupportedShebang, timeout: 10000 }, async (t) => {
|
||||
const { binary } = fixture(t, "hang");
|
||||
const start = Date.now();
|
||||
await assert.rejects(readClaudeVersion(binary), (error: Error & { signal?: string }) => error.signal === "SIGKILL");
|
||||
assert(Date.now() - start < 8500, "bounded five-second timeout");
|
||||
const pid = Number(readFileSync(`${binary}.pid`, "utf8"));
|
||||
assert.throws(() => process.kill(pid, 0), /ESRCH/, "fixture no longer exists");
|
||||
});
|
||||
|
||||
test("relative PATH is resolved at server cwd and stays the same for probe and model launch", { skip: unsupportedShebang }, async (t) => {
|
||||
const { dir, binary } = fixture(t, "normal");
|
||||
const found = resolveClaudeBinary({ env: { PATH: "./bin" }, homeDir: dir, platform: process.platform, cwd: dir,
|
||||
isExecutable: (path) => { try { accessSync(path, constants.X_OK); return statSync(path).isFile(); } catch { return false; } },
|
||||
});
|
||||
assert.equal(found, binary);
|
||||
assert.equal(await readClaudeVersion(found!), "2.1.310 (Claude Code)\n", "probe changes cwd to tmpdir but executes the discovered file");
|
||||
let spawned = false;
|
||||
const launch = createClaudeLauncher({ findBinary: () => found,
|
||||
spawnProcess: ((path: string) => { assert.equal(path, binary); spawned = true; return null; }) as unknown as typeof spawn,
|
||||
});
|
||||
await launch(["-p"], { cwd: tmpdir(), env: { NODE_ENV: "test" } });
|
||||
assert(spawned, "same verified absolute identity passed to the model launcher");
|
||||
});
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Stand-in for the claude CLI in tests. Reads the stream-json input from
|
||||
* stdin, then replays the scenario file named by FAKE_CLAUDE_SCENARIO line by
|
||||
* line. Lines with a "fake" key are directives instead of output:
|
||||
* {"fake":"hold"} stay alive until killed, like the CLI waiting on a held MCP call
|
||||
* {"fake":"exit","code":1,"stderr":"..."} write stderr and exit with that code
|
||||
* {"fake":"call","id":"toolu_...","name":"read","arguments":{...}} call the MCP bridge and wait for its result
|
||||
* {"fake":"start-call",...} start the same request without waiting for its result
|
||||
* {"fake":"cancel-call","id":"toolu_..."} cancel an individual pending MCP request
|
||||
* {"fake":"checkpoint"} notify the parent after previous sends finish
|
||||
* {"fake":"wait-for-message"} pause until the test releases the child over IPC
|
||||
* {"fake":"ignore-term"} ignore SIGTERM to exercise kill escalation
|
||||
* {"fake":"reply"} stream one assistant message whose text is every result so far, joined by " | "
|
||||
* FAKE_CLAUDE_STDIN, when set, receives the stdin it read.
|
||||
*/
|
||||
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";
|
||||
|
||||
const parentMessages = on(process, "message");
|
||||
const HOLD_INTERVAL_MS = 60_000;
|
||||
const BRIDGE_SERVER_NAME = "learnharness";
|
||||
|
||||
function writeLine(line) {
|
||||
return new Promise((resolve) => process.stdout.write(`${line}\n`, resolve));
|
||||
}
|
||||
|
||||
function streamEvent(event) {
|
||||
return JSON.stringify({ type: "stream_event", event });
|
||||
}
|
||||
|
||||
let lastSend = Promise.resolve();
|
||||
|
||||
async function connectBridge(mcpConfig) {
|
||||
const { url, headers } = JSON.parse(mcpConfig).mcpServers[BRIDGE_SERVER_NAME];
|
||||
const client = new Client({ name: "fake-claude", version: "1.0.0" });
|
||||
|
||||
const transport = new StreamableHTTPClientTransport(new URL(url), { requestInit: { headers } });
|
||||
const send = transport.send.bind(transport);
|
||||
transport.send = (...args) => (lastSend = send(...args));
|
||||
await client.connect(transport);
|
||||
return client;
|
||||
}
|
||||
|
||||
async function writeReply(text) {
|
||||
await writeLine(streamEvent({ type: "message_start", message: { usage: {} } }));
|
||||
await writeLine(streamEvent({ type: "content_block_start", index: 0, content_block: { type: "text", text: "" } }));
|
||||
await writeLine(streamEvent({ type: "content_block_delta", index: 0, delta: { type: "text_delta", text } }));
|
||||
await writeLine(streamEvent({ type: "content_block_stop", index: 0 }));
|
||||
await writeLine(streamEvent({ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: {} }));
|
||||
await writeLine(streamEvent({ type: "message_stop" }));
|
||||
}
|
||||
|
||||
const mcpConfig = readFileSync(process.argv[process.argv.indexOf("--mcp-config") + 1], "utf8");
|
||||
|
||||
let 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 = [];
|
||||
const pendingResults = [];
|
||||
const controllers = new Map();
|
||||
const cancelled = new Set();
|
||||
const lines = readFileSync(process.env.FAKE_CLAUDE_SCENARIO, "utf8").split("\n").filter(Boolean);
|
||||
for (const line of lines) {
|
||||
const directive = JSON.parse(line);
|
||||
|
||||
if (directive.fake === "cancel-call") {
|
||||
cancelled.add(directive.id);
|
||||
controllers.get(directive.id)?.abort("cancelled by fake CLI");
|
||||
await lastSend;
|
||||
continue;
|
||||
}
|
||||
if (directive.fake === "checkpoint") {
|
||||
await lastSend;
|
||||
process.send?.({ fake: "checkpoint" });
|
||||
continue;
|
||||
}
|
||||
if (directive.fake === "wait-for-message") {
|
||||
await parentMessages.next();
|
||||
continue;
|
||||
}
|
||||
if (directive.fake === "hold") {
|
||||
setInterval(() => {}, HOLD_INTERVAL_MS);
|
||||
break;
|
||||
}
|
||||
|
||||
if (directive.fake === "exit") {
|
||||
process.stderr.write(directive.stderr ?? "");
|
||||
process.exit(directive.code);
|
||||
}
|
||||
|
||||
if (directive.fake === "ignore-term") {
|
||||
process.on("SIGTERM", () => {});
|
||||
continue;
|
||||
}
|
||||
if (directive.fake === "call" || directive.fake === "start-call") {
|
||||
bridge ??= await connectBridge(mcpConfig);
|
||||
const controller = new AbortController();
|
||||
controllers.set(directive.id, controller);
|
||||
const request = bridge.callTool({
|
||||
name: directive.name,
|
||||
arguments: directive.arguments,
|
||||
...(directive.id !== undefined ? { _meta: { "claudecode/toolUseId": directive.id } } : {}),
|
||||
}, undefined, { signal: controller.signal });
|
||||
if (directive.fake === "start-call") {
|
||||
pendingResults.push({ id: directive.id, request });
|
||||
void request.catch(() => {});
|
||||
await lastSend;
|
||||
} else {
|
||||
const result = await request;
|
||||
results.push(result.content.map((block) => block.text).join(""));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (directive.fake === "reply") {
|
||||
for (const result of await Promise.all(pendingResults.splice(0).filter(({ id }) => !cancelled.has(id)).map(({ request }) => request))) results.push(result.content.map((block) => block.text).join(""));
|
||||
await writeReply(results.join(" | "));
|
||||
continue;
|
||||
}
|
||||
|
||||
await writeLine(line);
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
{"type":"system","subtype":"init","cwd":"/tmp/claude-code-empty","session_id":"433f00b9-adec-48b0-8eb7-c5499f88cc6b","model":"claude-haiku-5-5","tools":[],"mcp_servers":[],"apiKeySource":"none","permissionMode":"default","claude_code_version":"2.1.308","uuid":"941c8a75-e5cc-4014-93ec-4b065e619d15"}
|
||||
{"type":"system","subtype":"status","status":"requesting","session_id":"433f00b9-adec-48b0-8eb7-c5499f88cc6b","uuid":"1cbc259e-ad27-49ae-8a03-1dd0bc7d04ae"}
|
||||
{"type":"assistant","message":{"diagnostics":null,"id":"a4eeead1-04bb-47fd-9567-bebc78052a3e","container":null,"model":"<synthetic>","role":"assistant","stop_details":null,"stop_reason":"stop_sequence","stop_sequence":"","type":"message","usage":{"output_tokens_details":null,"input_tokens":0,"output_tokens":0,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":null,"cache_creation":{"ephemeral_1h_input_tokens":0,"ephemeral_5m_input_tokens":0},"inference_geo":null,"iterations":null,"speed":null,"fallback_credit":null},"content":[{"type":"text","text":"Not logged in · Please run /login"}],"context_management":null},"parent_tool_use_id":null,"session_id":"433f00b9-adec-48b0-8eb7-c5499f88cc6b","uuid":"58c2fe59-d155-4d2d-a0e1-f64e6aa09199","timestamp":"2026-10-24T14:03:24.319Z","error":"authentication_failed","is_api_error_message":true}
|
||||
{"duration_api_ms":0,"stop_reason":"stop_sequence","session_id":"433f00b9-adec-48b0-8eb7-c5499f88cc6b","total_cost_usd":0,"usage":{"output_tokens_details":{"thinking_tokens":0},"input_tokens":0,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":0,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":0,"ephemeral_5m_input_tokens":0},"inference_geo":"","iterations":[],"speed":"standard","fallback_credit":null},"modelUsage":{},"permission_denials":[],"terminal_reason":"api_error","fast_mode_state":"off","fast_mode_disabled_reason":"sdk_opt_in_required","subagent_stats":{"spawned":0,"requested":{"background":0,"foreground":0,"unset":0},"started_in_background":0,"max_depth":0,"spawned_by_subagents":0,"completed":0,"failed":0,"killed":{"parent":0,"user":0,"system":0},"refused":{"depth_limit":0,"concurrency_limit":0,"budget":0},"by_type":{}},"safety_stops":0,"is_error":true,"num_turns":1,"subtype":"success","api_error_status":null,"result":"Not logged in · Please run /login","type":"result","duration_ms":21,"uuid":"7e41c29d-1912-4c14-8955-9f176fef725f","queued_turn_count":0,"result_index":0}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
{"type":"system","subtype":"init","cwd":"/tmp/claude-code-empty","session_id":"a489e311-9d94-4990-b539-63a765dd0edd","model":"claude-haiku-5-5","tools":[],"mcp_servers":[],"apiKeySource":"none","permissionMode":"default","claude_code_version":"2.1.308","uuid":"c7e564b7-af08-4024-a89e-66736710e2c1"}
|
||||
{"type":"system","subtype":"status","status":"requesting","session_id":"a489e311-9d94-4990-b539-63a765dd0edd","uuid":"0738b666-b249-4cca-8c36-f92df8326546"}
|
||||
{"type":"stream_event","event":{"type":"message_start","message":{"model":"claude-haiku-5-5","id":"msg_011CgMBka5NLhDVNVihRY4Lm","type":"message","role":"assistant","content":[],"container":null,"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":607,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":607},"output_tokens":5,"service_tier":"standard","inference_geo":"global","speed":"standard"},"input_transformations":[],"diagnostics":null,"context_management":null}},"session_id":"a489e311-9d94-4990-b539-63a765dd0edd","parent_tool_use_id":null,"uuid":"512e3a21-2563-457b-addb-adade96b3d2b","ttft_ms":1224,"thinking_display":"updates","api_message_id":"msg_011CgMBka5NLhDVNVihRY4Lm"}
|
||||
{"type":"stream_event","event":{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}},"session_id":"a489e311-9d94-4990-b539-63a765dd0edd","parent_tool_use_id":null,"uuid":"0b2a75fb-819f-43ef-9fc7-839f1a5c5a01","api_message_id":"msg_011CgMBka5NLhDVNVihRY4Lm"}
|
||||
{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hello there"}},"session_id":"a489e311-9d94-4990-b539-63a765dd0edd","parent_tool_use_id":null,"uuid":"85823b9b-d5e0-4973-93f9-8525a8894762","api_message_id":"msg_011CgMBka5NLhDVNVihRY4Lm"}
|
||||
{"type":"assistant","message":{"model":"claude-haiku-5-5","id":"msg_011CgMBka5NLhDVNVihRY4Lm","type":"message","role":"assistant","content":[{"type":"text","text":"hello there"}],"container":null,"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":607,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":607},"output_tokens":5,"service_tier":"standard","inference_geo":"global","speed":"standard"},"input_transformations":[],"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"a489e311-9d94-4990-b539-63a765dd0edd","uuid":"92acba82-60ca-4502-a5a9-bafd75bd7277","timestamp":"2026-10-24T14:02:46.324Z","request_id":"req_011CgMBkZrxiAPxuioCwrRRu"}
|
||||
{"type":"stream_event","event":{"type":"content_block_stop","index":0},"session_id":"a489e311-9d94-4990-b539-63a765dd0edd","parent_tool_use_id":null,"uuid":"1d19a483-51e6-45b6-96a2-bf445a9ab04b","api_message_id":"msg_011CgMBka5NLhDVNVihRY4Lm"}
|
||||
{"type":"stream_event","event":{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null,"stop_details":null,"container":null},"usage":{"input_tokens":2,"cache_creation_input_tokens":607,"cache_read_input_tokens":0,"output_tokens":5,"output_tokens_details":{"thinking_tokens":0},"iterations":[{"input_tokens":2,"output_tokens":5,"cache_read_input_tokens":0,"cache_creation_input_tokens":607,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":607},"type":"message"}]},"context_management":{"applied_edits":[]}},"session_id":"a489e311-9d94-4990-b539-63a765dd0edd","parent_tool_use_id":null,"uuid":"85ded13f-99fc-4ac2-b211-2c71a40bb817","api_message_id":"msg_011CgMBka5NLhDVNVihRY4Lm"}
|
||||
{"type":"stream_event","event":{"type":"message_stop"},"session_id":"a489e311-9d94-4990-b539-63a765dd0edd","parent_tool_use_id":null,"uuid":"d9085ac3-b00c-4e22-a17e-0fafcf7782a2","api_message_id":"msg_011CgMBka5NLhDVNVihRY4Lm"}
|
||||
{"type":"rate_limit_event","rate_limit_info":{"status":"allowed"},"uuid":"679f75df-0571-4b65-bc3e-c4cee96c7061","session_id":"a489e311-9d94-4990-b539-63a765dd0edd"}
|
||||
{"duration_api_ms":1236,"stop_reason":"end_turn","session_id":"a489e311-9d94-4990-b539-63a765dd0edd","total_cost_usd":0.0001241,"usage":{"input_tokens":2,"cache_creation_input_tokens":607,"cache_read_input_tokens":0,"output_tokens":5,"output_tokens_details":{"thinking_tokens":0},"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":607,"ephemeral_5m_input_tokens":0},"inference_geo":"global","iterations":[{"input_tokens":2,"output_tokens":5,"cache_read_input_tokens":0,"cache_creation_input_tokens":607,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":607},"type":"message"}],"speed":"standard","fallback_credit":null},"modelUsage":{"claude-haiku-5-5":{"inputTokens":2,"outputTokens":5,"cacheReadInputTokens":0,"cacheCreationInputTokens":607,"webSearchRequests":0,"costUSD":0.0001241,"contextWindow":1000000,"maxOutputTokens":128000,"thinkingTokens":0,"canonicalModel":"claude-haiku-5-5","provider":"firstParty","costBasis":"list"}},"permission_denials":[],"terminal_reason":"completed","fast_mode_state":"off","fast_mode_disabled_reason":"sdk_opt_in_required","subagent_stats":{"spawned":0,"requested":{"background":0,"foreground":0,"unset":0},"started_in_background":0,"max_depth":0,"spawned_by_subagents":0,"completed":0,"failed":0,"killed":{"parent":0,"user":0,"system":0},"refused":{"depth_limit":0,"concurrency_limit":0,"budget":0},"by_type":{}},"safety_stops":0,"is_error":false,"num_turns":1,"subtype":"success","api_error_status":null,"result":"hello there","ttft_ms":1245,"type":"result","duration_ms":1258,"uuid":"faf44ee1-84e1-40eb-914e-654abb6c2286","ttft_stream_ms":1244,"time_to_request_ms":21,"first_content_frame_ms":1245,"queued_turn_count":0,"result_index":0}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
{"type":"system","subtype":"init","cwd":"/tmp/claude-code-empty","session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","model":"claude-haiku-5-5","tools":["mcp__learnharness__get_weather"],"mcp_servers":[{"name":"learnharness","status":"connected","source":"dynamic"}],"apiKeySource":"none","permissionMode":"default","claude_code_version":"2.1.308","uuid":"9524e7cd-f580-4019-8e63-861e36aa190d"}
|
||||
{"type":"system","subtype":"status","status":"requesting","session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","uuid":"6a55f520-5449-4845-bfe7-d3d74e4f5dda"}
|
||||
{"type":"stream_event","event":{"type":"message_start","message":{"model":"claude-haiku-5-5","id":"msg_011CgMBnEgMnaPp7zJQPnYig","type":"message","role":"assistant","content":[],"container":null,"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1102,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":1102},"output_tokens":32,"service_tier":"standard","inference_geo":"global","speed":"standard"},"input_transformations":[],"diagnostics":null,"context_management":null}},"session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","parent_tool_use_id":null,"uuid":"16a84391-efcc-4221-9029-1fbea99edbab","ttft_ms":1170,"thinking_display":"updates","api_message_id":"msg_011CgMBnEgMnaPp7zJQPnYig"}
|
||||
{"type":"stream_event","event":{"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_01JBB4cJsPa585FWb1XrfbNX","name":"mcp__learnharness__get_weather","input":{},"caller":{"type":"direct"}}},"session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","parent_tool_use_id":null,"uuid":"460ff54c-2ab3-46a0-b2bf-c0d780d04e58","api_message_id":"msg_011CgMBnEgMnaPp7zJQPnYig"}
|
||||
{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":""}},"session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","parent_tool_use_id":null,"uuid":"e73df731-5ecc-439c-811a-7b6cc5219a7f","api_message_id":"msg_011CgMBnEgMnaPp7zJQPnYig"}
|
||||
{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"city\": \"Paris"}},"session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","parent_tool_use_id":null,"uuid":"1ae8e01b-8cfe-4282-a008-a9ef3f2e05fa","api_message_id":"msg_011CgMBnEgMnaPp7zJQPnYig"}
|
||||
{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"\"}"}},"session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","parent_tool_use_id":null,"uuid":"b7cf01be-dc67-463e-ad06-19267ea81cc3","api_message_id":"msg_011CgMBnEgMnaPp7zJQPnYig"}
|
||||
{"type":"assistant","message":{"model":"claude-haiku-5-5","id":"msg_011CgMBnEgMnaPp7zJQPnYig","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01JBB4cJsPa585FWb1XrfbNX","name":"mcp__learnharness__get_weather","input":{"city":"Paris"},"caller":{"type":"direct"}}],"container":null,"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1102,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":1102},"output_tokens":32,"service_tier":"standard","inference_geo":"global","speed":"standard"},"input_transformations":[],"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","uuid":"da987ff6-9b30-4c1e-8f5b-20bda7386663","timestamp":"2026-10-24T14:03:09.115Z","request_id":"req_011CgMBnETxUPY4jwbRZ3rFy","wire_tool_inputs":{"toolu_01JBB4cJsPa585FWb1XrfbNX":{"city":"Paris"}},"tool_use_meta":[{"id":"toolu_01JBB4cJsPa585FWb1XrfbNX","display_name":"Get Weather","server_display_name":"learnharness","tool_name":"get_weather"}]}
|
||||
{"type":"stream_event","event":{"type":"content_block_stop","index":0},"session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","parent_tool_use_id":null,"uuid":"f438dbab-5bac-4b68-a237-ef9564abd25c","api_message_id":"msg_011CgMBnEgMnaPp7zJQPnYig"}
|
||||
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01JBB4cJsPa585FWb1XrfbNX","type":"tool_result","content":[{"type":"text","text":"Sunny, 21C in Paris"}]}]},"parent_tool_use_id":null,"session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","uuid":"f44480b5-8e02-4958-888e-fbbe3b54dc80","timestamp":"2026-10-24T14:03:09.124Z","tool_use_result":[{"type":"text","text":"Sunny, 21C in Paris"}],"tool_result_meta":[{"id":"toolu_01JBB4cJsPa585FWb1XrfbNX","permission_decision":{"decision":"accept","source":"config","reason_type":"rule"}}]}
|
||||
{"type":"stream_event","event":{"type":"message_delta","delta":{"stop_reason":"tool_use","stop_sequence":null,"stop_details":null,"container":null},"usage":{"input_tokens":2,"cache_creation_input_tokens":1102,"cache_read_input_tokens":0,"output_tokens":59,"output_tokens_details":{"thinking_tokens":0},"iterations":[{"input_tokens":2,"output_tokens":59,"cache_read_input_tokens":0,"cache_creation_input_tokens":1102,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":1102},"type":"message"}]},"context_management":{"applied_edits":[]}},"session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","parent_tool_use_id":null,"uuid":"7fec4e77-f338-4f24-810c-d6fa959d154f","api_message_id":"msg_011CgMBnEgMnaPp7zJQPnYig"}
|
||||
{"type":"stream_event","event":{"type":"message_stop"},"session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","parent_tool_use_id":null,"uuid":"5d3b3311-08d0-4af6-be82-b0d3f472e99d","api_message_id":"msg_011CgMBnEgMnaPp7zJQPnYig"}
|
||||
{"type":"rate_limit_event","rate_limit_info":{"status":"allowed"},"uuid":"f21e5357-4ffc-4346-b9fc-6543e910eab3","session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038"}
|
||||
{"type":"system","subtype":"status","status":"requesting","session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","uuid":"22297606-28e0-4b2f-9208-bc4be5487a80"}
|
||||
{"type":"stream_event","event":{"type":"message_start","message":{"model":"claude-haiku-5-5","id":"msg_011CgMBnLsfzVJKssny6FQZm","type":"message","role":"assistant","content":[],"container":null,"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":100,"cache_read_input_tokens":1102,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":100},"output_tokens":1,"service_tier":"standard","inference_geo":"global","speed":"standard"},"input_transformations":[],"diagnostics":null,"context_management":null}},"session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","parent_tool_use_id":null,"uuid":"90d16779-8e65-40d2-a55e-a68a9786a927","ttft_ms":1274,"thinking_display":"updates","api_message_id":"msg_011CgMBnLsfzVJKssny6FQZm"}
|
||||
{"type":"stream_event","event":{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}},"session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","parent_tool_use_id":null,"uuid":"1f8d1832-7837-444e-a07e-2598927888bb","api_message_id":"msg_011CgMBnLsfzVJKssny6FQZm"}
|
||||
{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"It"}},"session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","parent_tool_use_id":null,"uuid":"29baece7-f120-4088-8901-9ee4c16f9ffa","api_message_id":"msg_011CgMBnLsfzVJKssny6FQZm"}
|
||||
{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"'s sunny in Paris"}},"session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","parent_tool_use_id":null,"uuid":"7189b574-320a-4730-9cf7-c92c089bdf95","api_message_id":"msg_011CgMBnLsfzVJKssny6FQZm"}
|
||||
{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" right"}},"session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","parent_tool_use_id":null,"uuid":"1db451a5-7027-4b25-bb69-2b8b6b94d600","api_message_id":"msg_011CgMBnLsfzVJKssny6FQZm"}
|
||||
{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" now, with a temperature of"}},"session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","parent_tool_use_id":null,"uuid":"7ca48472-b631-4117-a204-9f6fe7854f4a","api_message_id":"msg_011CgMBnLsfzVJKssny6FQZm"}
|
||||
{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" 21°C."}},"session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","parent_tool_use_id":null,"uuid":"86930cda-4e2b-405c-a45f-29ee42d2efca","api_message_id":"msg_011CgMBnLsfzVJKssny6FQZm"}
|
||||
{"type":"assistant","message":{"model":"claude-haiku-5-5","id":"msg_011CgMBnLsfzVJKssny6FQZm","type":"message","role":"assistant","content":[{"type":"text","text":"It's sunny in Paris right now, with a temperature of 21°C."}],"container":null,"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":100,"cache_read_input_tokens":1102,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":100},"output_tokens":1,"service_tier":"standard","inference_geo":"global","speed":"standard"},"input_transformations":[],"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","uuid":"69cad926-aa35-4a82-a24c-599cc5422d99","timestamp":"2026-10-24T14:03:10.497Z","request_id":"req_011CgMBnLc4EeExeJ32dkAoH"}
|
||||
{"type":"stream_event","event":{"type":"content_block_stop","index":0},"session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","parent_tool_use_id":null,"uuid":"c05c7c31-f90a-4f4b-8a5b-31f2dae60294","api_message_id":"msg_011CgMBnLsfzVJKssny6FQZm"}
|
||||
{"type":"stream_event","event":{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null,"stop_details":null,"container":null},"usage":{"input_tokens":2,"cache_creation_input_tokens":100,"cache_read_input_tokens":1102,"output_tokens":25,"output_tokens_details":{"thinking_tokens":0},"iterations":[{"input_tokens":2,"output_tokens":25,"cache_read_input_tokens":1102,"cache_creation_input_tokens":100,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":100},"type":"message"}]},"context_management":{"applied_edits":[]}},"session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","parent_tool_use_id":null,"uuid":"6193aeb9-61b1-41e8-959a-9ae646429d11","api_message_id":"msg_011CgMBnLsfzVJKssny6FQZm"}
|
||||
{"type":"stream_event","event":{"type":"message_stop"},"session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","parent_tool_use_id":null,"uuid":"22fbda30-76c1-4527-9819-cc3d00fb4e68","api_message_id":"msg_011CgMBnLsfzVJKssny6FQZm"}
|
||||
{"duration_api_ms":2827,"stop_reason":"end_turn","session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","total_cost_usd":0.00029382000000000003,"usage":{"input_tokens":4,"cache_creation_input_tokens":1202,"cache_read_input_tokens":1102,"output_tokens":84,"output_tokens_details":{"thinking_tokens":0},"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":1202,"ephemeral_5m_input_tokens":0},"inference_geo":"global","iterations":[{"input_tokens":2,"output_tokens":25,"cache_read_input_tokens":1102,"cache_creation_input_tokens":100,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":100},"type":"message"}],"speed":"standard","fallback_credit":null},"modelUsage":{"claude-haiku-5-5":{"inputTokens":4,"outputTokens":84,"cacheReadInputTokens":1102,"cacheCreationInputTokens":1202,"webSearchRequests":0,"costUSD":0.00029382000000000003,"contextWindow":1000000,"maxOutputTokens":128000,"thinkingTokens":0,"canonicalModel":"claude-haiku-5-5","provider":"firstParty","costBasis":"list"}},"permission_denials":[],"terminal_reason":"completed","fast_mode_state":"off","fast_mode_disabled_reason":"sdk_opt_in_required","subagent_stats":{"spawned":0,"requested":{"background":0,"foreground":0,"unset":0},"started_in_background":0,"max_depth":0,"spawned_by_subagents":0,"completed":0,"failed":0,"killed":{"parent":0,"user":0,"system":0},"refused":{"depth_limit":0,"concurrency_limit":0,"budget":0},"by_type":{}},"safety_stops":0,"is_error":false,"num_turns":2,"subtype":"success","api_error_status":null,"result":"It's sunny in Paris right now, with a temperature of 21°C.","ttft_ms":1457,"type":"result","duration_ms":2854,"uuid":"0be743a1-83e1-45ab-96f5-926ffee7a85b","ttft_stream_ms":1191,"time_to_request_ms":21,"first_content_frame_ms":1191,"queued_turn_count":0,"result_index":0}
|
||||
@@ -1,6 +1,13 @@
|
||||
import { describe, it } from "node:test";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { after, describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
const testData = fs.mkdtempSync(path.join(os.tmpdir(), "lh-cli-catalog-data-"));
|
||||
process.env.LH_DATA_DIR = testData;
|
||||
after(() => fs.rmSync(testData, { recursive: true, force: true }));
|
||||
const {
|
||||
catalogContextLimits,
|
||||
catalogDefaultsFor,
|
||||
catalogProviders,
|
||||
catalogRolePins,
|
||||
@@ -12,16 +19,18 @@ import {
|
||||
toModelDto,
|
||||
validateChatSettings,
|
||||
validateModelsSettings,
|
||||
} from "../src/server/model-catalog.ts";
|
||||
} = await import("../src/server/model-catalog.ts");
|
||||
import type { EffectiveSlot } from "../src/core/settings-resolution.ts";
|
||||
import type { SettingsValidation } from "../src/server/model-catalog.ts";
|
||||
import { clampThinkingLevel, getSupportedThinkingLevels } from "@earendil-works/pi-ai";
|
||||
import { DEFAULT_THINKING_ORDER } from "../src/core/settings.ts";
|
||||
import { piEngineForSpec, piModels } from "../src/agent/models.ts";
|
||||
const { piEngineForSpec, piModels } = await import("../src/agent/models.ts");
|
||||
import { BACKGROUND_MODEL_ROLES } from "../src/core/settings.ts";
|
||||
import { resolveSettings } from "../src/core/settings-resolution.ts";
|
||||
import { readSettings } from "../src/store/settings.ts";
|
||||
import { effectiveSettings } from "../src/server/stores.ts";
|
||||
const { readSettings, writeSettings } = await import("../src/store/settings.ts");
|
||||
const { SETTINGS_FILE } = await import("../config.ts");
|
||||
const { effectiveSettings } = await import("../src/server/stores.ts");
|
||||
const { providerKeyPresent } = await import("../src/server/keys.ts");
|
||||
|
||||
// The model-control wave (2026-09-12): pi-ai owns the registry — these pins
|
||||
// hold our projection + validation honest against the shipped catalog, and
|
||||
@@ -59,8 +68,10 @@ describe("the model catalog projection", () => {
|
||||
assert.equal(typeof m.id, "string");
|
||||
assert.equal(typeof m.name, "string");
|
||||
assert.ok(Array.isArray(m.supportedLevels));
|
||||
assert.ok(typeof m.cost.input === "number" && typeof m.cost.output === "number");
|
||||
assert.ok(m.contextWindow > 0);
|
||||
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");
|
||||
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");
|
||||
@@ -121,6 +132,7 @@ describe("the model catalog projection", () => {
|
||||
assert.equal(dto.id, raw.id);
|
||||
assert.equal(dto.name, raw.name);
|
||||
assert.deepEqual(dto.supportedLevels, getSupportedThinkingLevels(raw));
|
||||
assert.ok(dto.cost);
|
||||
assert.equal(dto.cost.input, raw.cost.input);
|
||||
assert.equal(dto.contextWindow, raw.contextWindow);
|
||||
});
|
||||
@@ -326,4 +338,81 @@ describe("pi thinking truth (pinned against pi-ai upgrades)", () => {
|
||||
const { piEngineFor } = await import("../src/agent/models.ts");
|
||||
assert.throws(() => piEngineFor("prober"), /no configured model for role "prober"/);
|
||||
});
|
||||
});
|
||||
});
|
||||
/** Runs fn with PATH and HOME pointed at a temp dir, so the CLI lookup only
|
||||
* finds a claude binary when installed is true. */
|
||||
function withClaudeInstalled(installed: boolean, fn: () => void, enabled = true): void {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "claude-catalog-"));
|
||||
const saved = { PATH: process.env.PATH, HOME: process.env.HOME, npm_config_prefix: process.env.npm_config_prefix };
|
||||
const settings = readSettings();
|
||||
writeSettings({ ...(settings ?? { version: 1 }), claudeCode: { enabled } });
|
||||
|
||||
if (installed) fs.writeFileSync(path.join(dir, "claude"), "#!/bin/sh\n", { mode: 0o755 });
|
||||
process.env.PATH = dir;
|
||||
process.env.HOME = dir;
|
||||
delete process.env.npm_config_prefix;
|
||||
|
||||
try {
|
||||
fn();
|
||||
} finally {
|
||||
for (const [name, value] of Object.entries(saved)) {
|
||||
if (value === undefined) delete process.env[name];
|
||||
else process.env[name] = value;
|
||||
}
|
||||
if (settings) writeSettings(settings);
|
||||
else fs.rmSync(SETTINGS_FILE, { force: true });
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
describe("the claude-code provider (native CLI authentication, no LearnHarness key)", () => {
|
||||
it("modelCatalog() lists claude-code opus/sonnet/haiku when the CLI is installed", () => {
|
||||
withClaudeInstalled(true, () => {
|
||||
const ids = modelCatalog()
|
||||
.models.filter((m) => m.provider === "claude-code")
|
||||
.map((m) => m.id);
|
||||
assert.deepEqual(ids, ["haiku", "opus", "sonnet"]);
|
||||
});
|
||||
});
|
||||
|
||||
it("catalogProviders() marks claude-code present when the CLI is installed", () => {
|
||||
withClaudeInstalled(true, () => {
|
||||
const row = catalogProviders().find((p) => p.id === "claude-code");
|
||||
assert.deepEqual(row, { id: "claude-code", envVar: "", keyPresent: true });
|
||||
});
|
||||
});
|
||||
|
||||
it("catalogProviders() marks claude-code absent and modelCatalog() hides it when the CLI is missing", () => {
|
||||
withClaudeInstalled(false, () => {
|
||||
const row = catalogProviders().find((p) => p.id === "claude-code");
|
||||
assert.equal(row?.keyPresent, false);
|
||||
assert.equal(
|
||||
modelCatalog().models.some((m) => m.provider === "claude-code"),
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("catalogContextLimits() never advertises placeholder CLI limits, even when installed", () => {
|
||||
withClaudeInstalled(true, () => {
|
||||
assert.equal(catalogContextLimits().has("claude-code:opus"), false);
|
||||
});
|
||||
});
|
||||
|
||||
it('piEngineForSpec({ provider: "claude-code", id: "sonnet" }) resolves', () => {
|
||||
const engine = piEngineForSpec({ provider: "claude-code", id: "sonnet" });
|
||||
assert.equal(engine.model.provider, "claude-code");
|
||||
assert.equal(engine.model.id, "sonnet");
|
||||
});
|
||||
|
||||
it('providerKeyPresent("claude-code") requires both explicit opt-in and executable presence', () => {
|
||||
withClaudeInstalled(true, () => assert.equal(providerKeyPresent("claude-code"), true));
|
||||
withClaudeInstalled(false, () => assert.equal(providerKeyPresent("claude-code"), false));
|
||||
withClaudeInstalled(true, () => {
|
||||
assert.equal(providerKeyPresent("claude-code"), false);
|
||||
assert.equal(catalogProviders().find((p) => p.id === "claude-code")?.keyPresent, false);
|
||||
assert(!modelCatalog().models.some((m) => m.provider === "claude-code"));
|
||||
assert.equal(validateChatSettings({ model: { provider: "claude-code", id: "sonnet" } }).ok, false);
|
||||
}, false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,7 +10,7 @@ const models = piModels().getProviders().flatMap((p) => piModels().getModels(p.i
|
||||
const nativeUsage = (input: number): Usage => ({ input, output: 1_000, cacheRead: 10_000, cacheWrite: 2_000, cacheWrite1h: 500, totalTokens: input + 13_000, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } });
|
||||
const saved = (u: Usage) => ({ inputTokens: u.input, outputTokens: u.output, cacheReadTokens: u.cacheRead, cacheWriteTokens: u.cacheWrite, cacheWrite1hTokens: u.cacheWrite1h, reasoningTokens: 500 });
|
||||
|
||||
test("all catalog models price through Pi, including every tier boundary; saved usage is never mutated", () => {
|
||||
test("API catalog models price through Pi at every tier boundary; native CLI stays unpriced and saved usage is never mutated", () => {
|
||||
assert.ok(models.length > 100);
|
||||
for (const model of models) {
|
||||
const sizes = [1_000, ...(model.cost.tiers ?? []).flatMap((t) => [Math.max(0, t.inputTokensAbove - 12_000), Math.max(0, t.inputTokensAbove - 12_000 + 1)])];
|
||||
@@ -18,7 +18,7 @@ test("all catalog models price through Pi, including every tier boundary; saved
|
||||
const usage = nativeUsage(input);
|
||||
const call = Object.freeze(saved(usage));
|
||||
const before = structuredClone(call);
|
||||
const expected = calculateCost(model, usage).total;
|
||||
const expected = model.provider === "claude-code" ? null : calculateCost(model, usage).total;
|
||||
assert.equal(callCost(call, `${model.provider}:${model.id}`), expected, `${model.provider}:${model.id} input=${input}`);
|
||||
assert.deepEqual(call, before);
|
||||
}
|
||||
|
||||
@@ -88,6 +88,9 @@ describe("send_to_user model gate", () => {
|
||||
["openrouter", "anthropic/claude-sonnet-5.5", true],
|
||||
["amazon-bedrock", "us.anthropic.claude-opus-5-5-v1:0", true],
|
||||
["fixture", "CLAUDE-fixture", true],
|
||||
["claude-code", "opus", true],
|
||||
["claude-code", "sonnet", true],
|
||||
["claude-code", "haiku", true],
|
||||
["openrouter", "z-ai/glm-5.3-flash", false],
|
||||
["openai", "gpt-4o-mini", false],
|
||||
["anthropic", "opaque-alias", false],
|
||||
|
||||
@@ -17,6 +17,7 @@ import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { after, describe, it } from "node:test";
|
||||
import { externalCliEnv } from "../src/core/auth-env.ts";
|
||||
|
||||
// THIS PROCESS'S ENV IS THE TEST SUBJECT — strip any inherited catalog vars
|
||||
// (a developer shell may export REAL keys; tests use throwaway values only,
|
||||
@@ -72,10 +73,12 @@ describe("boot fill-gap + precedence", () => {
|
||||
assert.ok(row.shadowedByEnv, "file value differs from the env value");
|
||||
assert.equal(row.masked, `…${ENV_OWNED.slice(-4)} (${ENV_OWNED.length} chars)`);
|
||||
assert.equal(authStore.authVars().OPENROUTER_API_KEY, "sk-or-v1-filefilefile", "file copy untouched");
|
||||
assert.equal(externalCliEnv().OPENROUTER_API_KEY, ENV_OWNED, "external CLIs retain launch-env ownership");
|
||||
});
|
||||
|
||||
it("unknown file var: fill-gapped into env, surfaced as a generic row", () => {
|
||||
assert.equal(process.env.FUTURE_PLANS_TOKEN, "opaque-plans-token", "reflected at boot");
|
||||
assert.equal(externalCliEnv().FUTURE_PLANS_TOKEN, undefined, "external CLIs exclude unknown file-only injections too");
|
||||
const row = keyStatuses().find((s) => s.id === "FUTURE_PLANS_TOKEN")!;
|
||||
assert.equal(row.info, null, "no curated form");
|
||||
assert.equal(row.source, "file");
|
||||
@@ -103,6 +106,7 @@ describe("saveKey / deleteKey (immediate effect, no restart)", () => {
|
||||
const r = saveKey("DEEPGRAM_API_KEY", value);
|
||||
assert.ok(r.ok);
|
||||
assert.equal(process.env.DEEPGRAM_API_KEY, value, "env updated immediately");
|
||||
assert.equal(externalCliEnv().DEEPGRAM_API_KEY, undefined);
|
||||
assert.equal(authStore.authVars().DEEPGRAM_API_KEY, value, "file updated");
|
||||
assert.equal(r.status.source, "file", "the reflected copy is the store's — the file is its origin of truth");
|
||||
assert.ok(!r.status.shadowedByEnv);
|
||||
@@ -112,6 +116,7 @@ describe("saveKey / deleteKey (immediate effect, no restart)", () => {
|
||||
const r = saveKey("OPENROUTER_API_KEY", "sk-or-v1-newvalue12345");
|
||||
assert.ok(r.ok);
|
||||
assert.equal(process.env.OPENROUTER_API_KEY, ENV_OWNED, "real env untouched");
|
||||
assert.equal(externalCliEnv().OPENROUTER_API_KEY, ENV_OWNED, "saving a shadowed key cannot remove real env ownership");
|
||||
assert.equal(authStore.authVars().OPENROUTER_API_KEY, "sk-or-v1-newvalue12345", "file updated for later");
|
||||
assert.ok(r.status.shadowedByEnv);
|
||||
});
|
||||
|
||||
@@ -239,7 +239,7 @@ describe("SETTINGS_SECTIONS (the route's section map)", () => {
|
||||
// threshold is engine behavior, not an agent-section key.
|
||||
assert.deepEqual(
|
||||
Object.keys(SETTINGS_SECTIONS),
|
||||
["general", "chat", "agent", "appearance", "graph", "compaction", "models", "dictation"],
|
||||
["general", "chat", "agent", "appearance", "graph", "compaction", "models", "dictation", "claudeCode"],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user