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 {
|
import {
|
||||||
isProviderKeyRefusal,
|
isProviderKeyRefusal,
|
||||||
firstSourceTextParts,
|
firstSourceTextParts,
|
||||||
|
cliContextWindowOf,
|
||||||
messageAnchorId,
|
messageAnchorId,
|
||||||
textPartMessageId,
|
textPartMessageId,
|
||||||
kbdEventForAction as kbdEventForChat,
|
kbdEventForAction as kbdEventForChat,
|
||||||
@@ -1691,6 +1692,7 @@ export function Chat({
|
|||||||
phase={phases.at(-1) ?? "probe"}
|
phase={phases.at(-1) ?? "probe"}
|
||||||
turns={turns}
|
turns={turns}
|
||||||
contextTokens={contextTokens}
|
contextTokens={contextTokens}
|
||||||
|
cliContextWindow={cliContextWindowOf(nativeMessages)}
|
||||||
pricing={pricing}
|
pricing={pricing}
|
||||||
onReserve={setDockReserve}
|
onReserve={setDockReserve}
|
||||||
dictationSlot={<DictationBar dictation={dictation} />}
|
dictationSlot={<DictationBar dictation={dictation} />}
|
||||||
@@ -1917,6 +1919,7 @@ function DockOverlay({
|
|||||||
phase,
|
phase,
|
||||||
turns,
|
turns,
|
||||||
contextTokens,
|
contextTokens,
|
||||||
|
cliContextWindow,
|
||||||
pricing,
|
pricing,
|
||||||
composer,
|
composer,
|
||||||
onSubmitAnswer,
|
onSubmitAnswer,
|
||||||
@@ -1953,6 +1956,7 @@ function DockOverlay({
|
|||||||
/** The session's usage log — the stats bar beneath the composer folds it. */
|
/** The session's usage log — the stats bar beneath the composer folds it. */
|
||||||
turns: TurnUsage[];
|
turns: TurnUsage[];
|
||||||
contextTokens: number | null;
|
contextTokens: number | null;
|
||||||
|
cliContextWindow: ReturnType<typeof cliContextWindowOf>;
|
||||||
pricing?: SessionPricing;
|
pricing?: SessionPricing;
|
||||||
composer: React.ReactNode;
|
composer: React.ReactNode;
|
||||||
onSubmitAnswer: (output: QuestionAnswerData) => void;
|
onSubmitAnswer: (output: QuestionAnswerData) => void;
|
||||||
@@ -2408,6 +2412,7 @@ function DockOverlay({
|
|||||||
<StatsBar
|
<StatsBar
|
||||||
turns={turns}
|
turns={turns}
|
||||||
contextTokens={contextTokens}
|
contextTokens={contextTokens}
|
||||||
|
cliContextWindow={cliContextWindow}
|
||||||
pricing={pricing}
|
pricing={pricing}
|
||||||
dictationSlot={dictationSlot}
|
dictationSlot={dictationSlot}
|
||||||
chatId={chatId}
|
chatId={chatId}
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export function useModelCatalog(): { catalog: ModelCatalogResponse | undefined;
|
|||||||
catalogReaders.add(read);
|
catalogReaders.add(read);
|
||||||
if (first) {
|
if (first) {
|
||||||
stopCatalogSignals = onSettingsSaved((section) => {
|
stopCatalogSignals = onSettingsSaved((section) => {
|
||||||
if (section === "keys") {
|
if (section === "keys" || section === "claudeCode") {
|
||||||
catalogGeneration++; catalogInflight = undefined; catalogCache = undefined;
|
catalogGeneration++; catalogInflight = undefined; catalogCache = undefined;
|
||||||
fetchCatalog();
|
fetchCatalog();
|
||||||
} else if (section === "models") fetchCatalog(true);
|
} else if (section === "models") fetchCatalog(true);
|
||||||
|
|||||||
@@ -13,15 +13,12 @@ import { useModelCatalog } from "@/components/model-catalog";
|
|||||||
export { useModelCatalog } from "@/components/model-catalog";
|
export { useModelCatalog } from "@/components/model-catalog";
|
||||||
import { defaultOrderLevel, populateLevel, THINKING_LABEL } from "@/components/thinking-default";
|
import { defaultOrderLevel, populateLevel, THINKING_LABEL } from "@/components/thinking-default";
|
||||||
|
|
||||||
/** provider:id → contextWindow over the cached catalog — the client-side
|
/** provider:id → known contextWindow over the cached catalog, mirroring
|
||||||
* twin of the server's catalogContextLimits join (context-meter
|
* the server's catalogContextLimits join. CLI aliases have no global
|
||||||
* amendment, owner 2026-09-23: pi's live catalog is the ONLY limit
|
* denominator: the stats bar uses that session's actual CLI report instead.
|
||||||
* source — no hardcoded fallback exists anywhere, so a model missing
|
* Missing limits always render usage-only, without a hardcoded fallback. */
|
||||||
* 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. */
|
|
||||||
export function catalogLimits(catalog: Pick<ModelCatalogResponse, "models">): Map<string, number> {
|
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
|
// "keys" joined the roster (zero-keys wave, 2026-09-25): keys-section
|
||||||
// dispatches it after save AND delete — deletions used to be invisible to
|
// dispatches it after save AND delete — deletions used to be invisible to
|
||||||
// the zero-keys pill and the setup dialog's auto-advance.
|
// 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
|
/** Writers call this after the PUT resolves ok — never before (a failed
|
||||||
* write notifies nothing; the record is unchanged). */
|
* 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 { useBackdropDismiss } from "@/components/backdrop-dismiss";
|
||||||
import { Button } from "@/components/ui/button";
|
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}>
|
return <div className="flex flex-wrap items-center gap-3" data-testid={testid}>
|
||||||
<span className={labelClassName ?? "font-ui text-xs text-foreground"}>{label}</span>
|
<span className={labelClassName ?? "font-ui text-xs text-foreground"}>{label}</span>
|
||||||
{children}
|
{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>;
|
</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { useEffect, useRef, useState } from "react";
|
|||||||
import { AUTH_KEYS, type AuthKeyStatus } from "@/src/core";
|
import { AUTH_KEYS, type AuthKeyStatus } from "@/src/core";
|
||||||
import { SettingsGroup } from "./controls.tsx";
|
import { SettingsGroup } from "./controls.tsx";
|
||||||
import { SubscriptionConnections } from "./subscription-connections.tsx";
|
import { SubscriptionConnections } from "./subscription-connections.tsx";
|
||||||
|
import { ClaudeCodeInstallationStatus } from "./claude-code-status.tsx";
|
||||||
import { ProviderMenuList } from "./model-menu.tsx";
|
import { ProviderMenuList } from "./model-menu.tsx";
|
||||||
import { MenuShell } from "@/components/menu-shell";
|
import { MenuShell } from "@/components/menu-shell";
|
||||||
import { notifySettingsSaved } from "@/components/settings-signal";
|
import { notifySettingsSaved } from "@/components/settings-signal";
|
||||||
@@ -471,6 +472,10 @@ export function KeysSection({
|
|||||||
<SubscriptionConnections />
|
<SubscriptionConnections />
|
||||||
</SettingsGroup>
|
</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"}>
|
{(["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) => (
|
{toolRows.filter((status) => label === "Dictation" ? status.id === "DEEPGRAM_API_KEY" : status.id !== "DEEPGRAM_API_KEY").map((status) => (
|
||||||
<KeyTableRow
|
<KeyTableRow
|
||||||
|
|||||||
@@ -21,11 +21,12 @@
|
|||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import type { ModelCatalogEntry } from "@/src/core";
|
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 { formatTokens } from "@/components/usage-format";
|
||||||
import { SearchMenuList } from "./search-menu-list";
|
import { SearchMenuList } from "./search-menu-list";
|
||||||
|
|
||||||
function priceLabel(cost: ModelCatalogEntry["cost"]): string {
|
function priceLabel(cost: ModelCatalogEntry["cost"]): string {
|
||||||
|
if (!cost) return "Unpriced";
|
||||||
const fmt = (n: number) => `$${n < 1 ? n.toFixed(2).replace(/0$/, "") : n.toFixed(2)}`;
|
const fmt = (n: number) => `$${n < 1 ? n.toFixed(2).replace(/0$/, "") : n.toFixed(2)}`;
|
||||||
return `${fmt(cost.input)}/${fmt(cost.output)}`;
|
return `${fmt(cost.input)}/${fmt(cost.output)}`;
|
||||||
}
|
}
|
||||||
@@ -72,13 +73,13 @@ export function ModelMenuList({
|
|||||||
renderItem={(m) => <>
|
renderItem={(m) => <>
|
||||||
<span className="flex items-center justify-between gap-2">
|
<span className="flex items-center justify-between gap-2">
|
||||||
<span className="truncate text-card-foreground">{m.name}</span>
|
<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"}>
|
<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"}>
|
||||||
{subscriptionLabel(`${m.provider}:${m.id}`) ?? priceLabel(m.cost)}
|
{m.provider === CLAUDE_CODE_PROVIDER ? "CLI billing" : subscriptionLabel(`${m.provider}:${m.id}`) ?? priceLabel(m.cost)}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center justify-between gap-2 text-muted-foreground">
|
<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="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>
|
</span>
|
||||||
</>}
|
</>}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
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 { formatTokens, formatUsd, planCostLabel, prettyModel } from "@/components/usage-format";
|
||||||
import { ModelControl, catalogLimits, useModelCatalog } from "@/components/model-picker";
|
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)}`;
|
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
|
* The below-composer stats bar (wave 4 observability): the session's
|
||||||
* money/context footprint as one quiet row beneath the composer —
|
* 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
|
* Spending arrives already priced by the server through Pi's catalog and
|
||||||
* calculator. Missing cost stays "—", never fake zero. Context is a separate
|
* calculator. Missing cost stays "—", never fake zero. Context is a separate
|
||||||
* completed-conversation measurement, never a money-log/summarizer fold or
|
* completed-conversation measurement, never a money-log/summarizer fold or
|
||||||
* transcript estimate. Its catalog denominator follows the selected model;
|
* transcript estimate. Its denominator follows the selected model, from the
|
||||||
* model changes preserve the numerator. Successful compaction clears it to ?.
|
* 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({
|
export function StatsBar({
|
||||||
turns,
|
turns,
|
||||||
contextTokens = null,
|
contextTokens = null,
|
||||||
|
cliContextWindow = null,
|
||||||
pricing,
|
pricing,
|
||||||
dictationSlot,
|
dictationSlot,
|
||||||
chatId,
|
chatId,
|
||||||
@@ -38,6 +49,7 @@ export function StatsBar({
|
|||||||
}: {
|
}: {
|
||||||
turns: TurnUsage[];
|
turns: TurnUsage[];
|
||||||
contextTokens?: number | null;
|
contextTokens?: number | null;
|
||||||
|
cliContextWindow?: ReturnType<typeof cliContextWindowOf>;
|
||||||
pricing?: SessionPricing;
|
pricing?: SessionPricing;
|
||||||
/** The bound session (per-session models, owner 2026-09-28): present
|
/** The bound session (per-session models, owner 2026-09-28): present
|
||||||
* on the in-session bar — the model control binds THE SESSION'S own
|
* on the in-session bar — the model control binds THE SESSION'S own
|
||||||
@@ -86,18 +98,19 @@ export function StatsBar({
|
|||||||
document.removeEventListener("keydown", onKeyDown);
|
document.removeEventListener("keydown", onKeyDown);
|
||||||
};
|
};
|
||||||
}, [open]);
|
}, [open]);
|
||||||
// Catalog-only denominator; the picker reports its effective selection.
|
// The picker reports its effective selection. CLI windows are session-
|
||||||
// Missing/pending catalog means usage-only, never a hardcoded maximum.
|
// scoped; unknown metadata always means usage-only, never a guessed maximum.
|
||||||
const { catalog } = useModelCatalog();
|
const { catalog } = useModelCatalog();
|
||||||
const limits = useMemo(() => (catalog ? catalogLimits(catalog) : undefined), [catalog]);
|
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 contextLabel = formatContextMeasurement(contextTokens, limit);
|
||||||
const { cost = 0, unpriced = turns.length, subscription = 0 } = pricing ?? {};
|
const { cost = 0, unpriced = turns.length, subscription = 0 } = pricing ?? {};
|
||||||
// Honest cost: show dollars only when at least one turn priced;
|
// Honest cost: show dollars only when at least one turn priced;
|
||||||
// otherwise "—" (never a fake $0.0000).
|
// otherwise "—" (never a fake $0.0000).
|
||||||
const pricedCount = turns.length - unpriced - subscription;
|
const pricedCount = turns.length - unpriced - subscription;
|
||||||
const providers = pricing?.subscriptionProviders ?? subscriptionLabels(turns.map((turn) => turn.model));
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -151,6 +164,9 @@ export function StatsBar({
|
|||||||
{contextLabel}
|
{contextLabel}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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">
|
<div className="flex justify-between gap-2">
|
||||||
<span>{subscription ? "Usage" : "Total cost"}</span>
|
<span>{subscription ? "Usage" : "Total cost"}</span>
|
||||||
<span className="tabular-nums text-card-foreground">{costLabel}</span>
|
<span className="tabular-nums text-card-foreground">{costLabel}</span>
|
||||||
@@ -178,7 +194,7 @@ export function StatsBar({
|
|||||||
</span>
|
</span>
|
||||||
<span className="flex shrink-0 items-center gap-2 tabular-nums text-card-foreground">
|
<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 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>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
|
|||||||
Generated
+906
-30
File diff suppressed because it is too large
Load Diff
@@ -26,6 +26,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@earendil-works/pi-agent-core": "1.4.0",
|
"@earendil-works/pi-agent-core": "1.4.0",
|
||||||
"@earendil-works/pi-ai": "1.4.0",
|
"@earendil-works/pi-ai": "1.4.0",
|
||||||
|
"@modelcontextprotocol/sdk": "1.32.1",
|
||||||
"@mozilla/readability": "^0.6.0",
|
"@mozilla/readability": "^0.6.0",
|
||||||
"@radix-ui/react-use-controllable-state": "^1.2.6",
|
"@radix-ui/react-use-controllable-state": "^1.2.6",
|
||||||
"@resvg/resvg-js": "^2.6.2",
|
"@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 { chatGPTModel } from "./chatgpt-bridge.ts";
|
||||||
import { chatGPTStream } from "./chatgpt-stream.ts";
|
import { chatGPTStream } from "./chatgpt-stream.ts";
|
||||||
import type { ChatGPTDispatchLifecycle } from "./chatgpt-request-cycle.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 { nativeSubscriptionId, nativeSubscriptionModel } from "./subscription-bridge.ts";
|
||||||
import { nativeSubscriptionStream } from "./subscription-stream.ts";
|
import { nativeSubscriptionStream } from "./subscription-stream.ts";
|
||||||
|
|
||||||
@@ -81,6 +83,7 @@ export function piModels() {
|
|||||||
if (!collection) {
|
if (!collection) {
|
||||||
collection = createModels({ identity: APP_IDENTITY });
|
collection = createModels({ identity: APP_IDENTITY });
|
||||||
for (const provider of builtinProviders()) collection.setProvider(provider);
|
for (const provider of builtinProviders()) collection.setProvider(provider);
|
||||||
|
collection.setProvider(createClaudeCodeProvider(streamClaudeCode));
|
||||||
}
|
}
|
||||||
providerSource?.sync(collection);
|
providerSource?.sync(collection);
|
||||||
return collection;
|
return collection;
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { practiceTools, type PracticeToolContext } from "./tools/practice.ts";
|
|||||||
import { healToolRecord, withArgumentHeal } from "./tools/heal.ts";
|
import { healToolRecord, withArgumentHeal } from "./tools/heal.ts";
|
||||||
import { sendToUserTool } from "./tools/send-to-user.ts";
|
import { sendToUserTool } from "./tools/send-to-user.ts";
|
||||||
import { createReadTool, readTool } from "./tools/read.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 { quizInputObject, quizInputProblems } from "../core/question-core.ts";
|
||||||
import { QUESTION_TOOLS, type EnabledTools } from "../core/pending.ts";
|
import { QUESTION_TOOLS, type EnabledTools } from "../core/pending.ts";
|
||||||
import { toLlmMessages, nativeInputCause, PRACTICE_PROBLEM_TOOL } from "../core/native-messages.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
|
...tools.map((tool) => tool.name === "read" && tool.execute === readTool.execute
|
||||||
? { ...tool, execute: createReadTool(engine.model).execute as AgentTool<any>["execute"] }
|
? { ...tool, execute: createReadTool(engine.model).execute as AgentTool<any>["execute"] }
|
||||||
: tool),
|
: 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)]
|
? [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
|
* thinkingLevelMap — e.g. GLM-5.3-flash: low|high|max). The thinking
|
||||||
* picker lists exactly these; anything else is a lie. */
|
* picker lists exactly these; anything else is a lie. */
|
||||||
supportedLevels: ThinkingLevel[];
|
supportedLevels: ThinkingLevel[];
|
||||||
cost: { input: number; output: number; cacheRead: number; cacheWrite: number };
|
/** Null when LearnHarness cannot estimate prices for this connection. */
|
||||||
contextWindow: number;
|
cost: { input: number; output: number; cacheRead: number; cacheWrite: number } | null;
|
||||||
maxTokens: number;
|
/** 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
|
/** 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 { NATIVE_SUBSCRIPTIONS } from "./contracts/subscriptions.ts";
|
||||||
export type { NativeSubscriptionProviderId, NativeSubscriptionLogicalId, NativeSubscriptionStatus, NativeSubscriptionSnapshot, NativeSubscriptionScope, NativeSubscriptionControlBridge, SubscriptionInteraction, SubscriptionPrompt } from "./contracts/subscriptions.ts";
|
export type { NativeSubscriptionProviderId, NativeSubscriptionLogicalId, NativeSubscriptionStatus, NativeSubscriptionSnapshot, NativeSubscriptionScope, NativeSubscriptionControlBridge, SubscriptionInteraction, SubscriptionPrompt } from "./contracts/subscriptions.ts";
|
||||||
export * from "./cost-summary.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";
|
export * from "./fuzzy.ts";
|
||||||
// settings.ts + settings-resolution.ts (settings-storage wave, 2026-09-19):
|
// settings.ts + settings-resolution.ts (settings-storage wave, 2026-09-19):
|
||||||
// the user-settings record's shape/sanitizer (pure) and its fully-resolved
|
// 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 { AgentMessage } from "@earendil-works/pi-agent-core";
|
||||||
import type { AssistantMessage, Message, ToolResultMessage, UserMessage } from "@earendil-works/pi-ai";
|
import type { AssistantMessage, Message, ToolResultMessage, UserMessage } from "@earendil-works/pi-ai";
|
||||||
import { QUESTION_TOOLS } from "./pending.ts";
|
import { QUESTION_TOOLS } from "./pending.ts";
|
||||||
|
import { CLAUDE_CODE_PROVIDER } from "./contracts/claude-code.ts";
|
||||||
import {
|
import {
|
||||||
CONCLUDE_ACK_TEXT,
|
CONCLUDE_ACK_TEXT,
|
||||||
HANDOFF_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. */
|
/** 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
|
/** A record message list is assignable to the agent's context — that IS
|
||||||
* the replay (ui-stream/chat-handler assign it to initialState.messages). */
|
* the replay (ui-stream/chat-handler assign it to initialState.messages). */
|
||||||
@@ -617,8 +637,8 @@ export function toLlmMessages(messages: readonly AgentMessage[]): Message[] {
|
|||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// Strip the record id: the wire carries the native shape only.
|
// Strip app-only id/CLI context metadata: the wire carries the native shape only.
|
||||||
const { id: _id, ...native } = m as LhMessage;
|
const { id: _id, cliContextWindow: _cliContextWindow, ...native } = m as LhMessage;
|
||||||
out.push(native as Message);
|
out.push(native as Message);
|
||||||
// Repair unanswered calls from completed generations, not calls in
|
// Repair unanswered calls from completed generations, not calls in
|
||||||
// failed generations. Pi filters error/aborted assistants itself;
|
// failed generations. Pi filters error/aborted assistants itself;
|
||||||
|
|||||||
+12
-2
@@ -29,6 +29,7 @@
|
|||||||
|
|
||||||
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
||||||
import { AUTH_PROVIDER_KEYS } from "./auth-keys.ts";
|
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 { ModelRole } from "../../config.ts";
|
||||||
import type { ChatSettings } from "./contracts/chat.ts";
|
import type { ChatSettings } from "./contracts/chat.ts";
|
||||||
import type { SearchEngineId } from "./search.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 refusal wording's invariant tail — the matcher below keys on it
|
||||||
* (the label prefix varies by provider). */
|
* (the label prefix varies by provider). */
|
||||||
const PROVIDER_KEY_MISSING_TAIL = "key is set — add one in Settings → Keys";
|
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 {
|
export function providerKeyMissing(provider: string, slots: ModelSlotName[]): ProviderKeyMissing {
|
||||||
const label = AUTH_PROVIDER_KEYS.find((p) => p.id === provider)?.label ?? provider;
|
const label = AUTH_PROVIDER_KEYS.find((p) => p.id === provider)?.label ?? provider;
|
||||||
return {
|
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,
|
code: PROVIDER_KEY_MISSING_CODE,
|
||||||
provider,
|
provider,
|
||||||
slots,
|
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
|
* scary-error) — the envelope's home module owns the string so the
|
||||||
* writer and the matcher can never drift. */
|
* writer and the matcher can never drift. */
|
||||||
export function isProviderKeyRefusal(error: string | undefined): boolean {
|
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
|
/** The typed throw for in-process seams (extraction pipeline) — the
|
||||||
* ProviderKeyMissing sibling of ModelsNotConfiguredError: callers catch
|
* ProviderKeyMissing sibling of ModelsNotConfiguredError: callers catch
|
||||||
@@ -368,6 +370,8 @@ export interface UserSettings {
|
|||||||
version: number;
|
version: number;
|
||||||
general?: GeneralSettings;
|
general?: GeneralSettings;
|
||||||
dictation?: DictationSettings;
|
dictation?: DictationSettings;
|
||||||
|
/** Explicit app-wide opt-in; absent/invalid is off, including older records. */
|
||||||
|
claudeCode?: { enabled?: boolean };
|
||||||
agent?: AgentSettings;
|
agent?: AgentSettings;
|
||||||
/** The strong/cheap/vision model slots (model slots wave, 2026-09-20). */
|
/** The strong/cheap/vision model slots (model slots wave, 2026-09-20). */
|
||||||
models?: ModelsSettings;
|
models?: ModelsSettings;
|
||||||
@@ -631,6 +635,12 @@ export function sanitizeUserSettings(raw: unknown): UserSettings | null {
|
|||||||
? { language: (raw as DictationSettings).language }
|
? { 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)) {
|
if (r.agent !== null && typeof r.agent === "object" && !Array.isArray(r.agent)) {
|
||||||
out.agent = sanitizeAgent(r.agent as Record<string, unknown>);
|
out.agent = sanitizeAgent(r.agent as Record<string, unknown>);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
type TeachingAgent,
|
type TeachingAgent,
|
||||||
} from "../agent/teacher-agent.ts";
|
} from "../agent/teacher-agent.ts";
|
||||||
import { piEngineForSpec, type PiEngine } from "../agent/models.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 { nativeSubscriptionError } from "../agent/subscription-stream.ts";
|
||||||
import { chatGPTStreamError } from "../agent/chatgpt-stream.ts";
|
import { chatGPTStreamError } from "../agent/chatgpt-stream.ts";
|
||||||
import { SubscriptionRequestCycle, type SubscriptionDispatchLifecycle } from "../agent/subscription-request-cycle.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 { effectiveSettings, stores } from "./stores.ts";
|
||||||
import { searchAvailable, searchProvider } from "./search.ts";
|
import { searchAvailable, searchProvider } from "./search.ts";
|
||||||
import { providerKeyPresent } from "./keys.ts";
|
import { providerKeyPresent } from "./keys.ts";
|
||||||
|
import { CLAUDE_CODE_PROVIDER } from "../core/contracts/claude-code.ts";
|
||||||
import {
|
import {
|
||||||
extractionEnabled,
|
extractionEnabled,
|
||||||
graphToolsEnabled,
|
graphToolsEnabled,
|
||||||
@@ -80,6 +81,12 @@ import type {CallUsage} from "../core/contracts.ts";
|
|||||||
* for write-ahead persistence, turn liveness, and the turn services.
|
* 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 {
|
function settlementErrorText(error: unknown): string {
|
||||||
return error instanceof TurnPersistenceError ? error.message : "An error occurred.";
|
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 * from "./model-catalog.ts";
|
||||||
export { subscriptionControlsResponse, subscriptionBrowserResponse } from "./web-subscription-http.ts";
|
export { subscriptionControlsResponse, subscriptionBrowserResponse } from "./web-subscription-http.ts";
|
||||||
export { sessionCost } from "./pricing.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 { isSubscriptionProvider, subscriptionConnected } from "../agent/subscription-connections.ts";
|
||||||
import { piModels } from "../agent/models.ts";
|
import { piModels } from "../agent/models.ts";
|
||||||
import { customProviderConfigured, installCustomProviderSource } from "./custom-providers.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
|
// Shared HMR-stable provenance lets external CLIs exclude file-only injections
|
||||||
// module; the snapshot must not re-run and misread fill-gapped vars as real
|
// without importing this store-backed module into the agent layer.
|
||||||
// env). Same pattern as globalThis.__lhExtractionRuns.
|
import { authEnvRecord as boot } from "../core/auth-env.ts";
|
||||||
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];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Is `id` a WRITABLE key name — curated catalog or model-provider env
|
/** Is `id` a WRITABLE key name — curated catalog or model-provider env
|
||||||
* var (provider-keys interlude: PUT/DELETE accept both). */
|
* 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.
|
piModels(); // Synchronize hand-edited custom configuration before admission.
|
||||||
const custom = customProviderConfigured(provider);
|
const custom = customProviderConfigured(provider);
|
||||||
if (custom !== undefined) return custom;
|
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);
|
if (isSubscriptionProvider(provider)) return subscriptionConnected(provider);
|
||||||
const info = AUTH_PROVIDER_KEYS.find((p) => p.id === provider);
|
const info = AUTH_PROVIDER_KEYS.find((p) => p.id === provider);
|
||||||
return info ? keyValue(info.envVar) !== undefined : true;
|
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 {
|
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);
|
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)]; }
|
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";
|
} from "../core/contracts/models.ts";
|
||||||
import type { EffectiveModelSpec, EffectiveSlot } from "../core/settings-resolution.ts";
|
import type { EffectiveModelSpec, EffectiveSlot } from "../core/settings-resolution.ts";
|
||||||
import { AUTH_PROVIDER_KEYS } from "../core/auth-keys.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";
|
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). */
|
/** 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,
|
keyPresent: keyValue(p.envVar) !== undefined,
|
||||||
})).concat([
|
})).concat([
|
||||||
{ id: "chatgpt", envVar: "", keyPresent: chatGPTConnected() },
|
{ id: "chatgpt", envVar: "", keyPresent: chatGPTConnected() },
|
||||||
|
{ id: CLAUDE_CODE_PROVIDER, envVar: "", keyPresent: claudeCodeAvailable() },
|
||||||
...NATIVE_SUBSCRIPTIONS.map((p) => ({ id: p.logicalId, envVar: "", keyPresent: nativeSubscriptionConnected(p.providerId) })),
|
...NATIVE_SUBSCRIPTIONS.map((p) => ({ id: p.logicalId, envVar: "", keyPresent: nativeSubscriptionConnected(p.providerId) })),
|
||||||
...customProviderRows(),
|
...customProviderRows(),
|
||||||
]).sort((a, b) => a.id.localeCompare(b.id));
|
]).sort((a, b) => a.id.localeCompare(b.id));
|
||||||
@@ -164,21 +168,22 @@ export function toModelDto(model: Model<Api>): ModelDto {
|
|||||||
reasoning: model.reasoning,
|
reasoning: model.reasoning,
|
||||||
input: [...model.input],
|
input: [...model.input],
|
||||||
supportedLevels: getSupportedThinkingLevels(model),
|
supportedLevels: getSupportedThinkingLevels(model),
|
||||||
cost: {
|
cost: model.provider === CLAUDE_CODE_PROVIDER ? null : {
|
||||||
input: model.cost.input,
|
input: model.cost.input,
|
||||||
output: model.cost.output,
|
output: model.cost.output,
|
||||||
cacheRead: model.cost.cacheRead,
|
cacheRead: model.cost.cacheRead,
|
||||||
cacheWrite: model.cost.cacheWrite,
|
cacheWrite: model.cost.cacheWrite,
|
||||||
},
|
},
|
||||||
contextWindow: model.contextWindow,
|
contextWindow: model.provider === CLAUDE_CODE_PROVIDER ? null : model.contextWindow,
|
||||||
maxTokens: model.maxTokens,
|
maxTokens: model.provider === CLAUDE_CODE_PROVIDER ? null : model.maxTokens,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The live-limits index (context-meter amendment, owner 2026-09-23):
|
/** The live-limits index (context-meter amendment, owner 2026-09-23):
|
||||||
* provider:id → contextWindow from pi's catalog, over the SAME keyed
|
* provider:id → contextWindow from pi's catalog, over the SAME keyed
|
||||||
* providers the picker federates. The one source for every context
|
* providers the picker federates. Native CLI aliases are excluded:
|
||||||
* meter's denominator — the stats-bar join holds /api/models' copy,
|
* 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
|
* and the activity feed (observability.ts) resolves job limits
|
||||||
* server-side through this. No hardcoded fallback exists anywhere:
|
* server-side through this. No hardcoded fallback exists anywhere:
|
||||||
* a model missing here reads limit-null (usage-only, honest render). */
|
* 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 keyed = keyedProviderIds();
|
||||||
const index = new Map<string, number>();
|
const index = new Map<string, number>();
|
||||||
for (const provider of piModels().getProviders()) {
|
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)) {
|
for (const model of piModels().getModels(provider.id)) {
|
||||||
index.set(`${provider.id}:${model.id}`, model.contextWindow);
|
index.set(`${provider.id}:${model.id}`, model.contextWindow);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,12 +4,15 @@
|
|||||||
*/
|
*/
|
||||||
import { calculateCost, type Usage } from "@earendil-works/pi-ai";
|
import { calculateCost, type Usage } from "@earendil-works/pi-ai";
|
||||||
import { piModels } from "../agent/models.ts";
|
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 type { CallUsage, ChatRecord, JobRecord, TurnUsage } from "../core/contracts.ts";
|
||||||
import { isSubscriptionModel, sessionCost as foldSessionCost, turnCost as foldTurnCost, jobCost as foldJobCost } from "../core/prices.ts";
|
import { isSubscriptionModel, sessionCost as foldSessionCost, turnCost as foldTurnCost, jobCost as foldJobCost } from "../core/prices.ts";
|
||||||
|
|
||||||
function catalogModel(id: string) {
|
function catalogModel(id: string) {
|
||||||
const colon = id.indexOf(":");
|
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));
|
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 };
|
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
|
/** agent.models: only the six known background roles; each override's
|
||||||
* fields mirror ChatSettings (model pair + independent thinkingLevel). */
|
* fields mirror ChatSettings (model pair + independent thinkingLevel). */
|
||||||
function parseRoleOverride(value: unknown, role: string): { ok: true; override: RoleModelOverride } | { ok: false; error: string } {
|
function parseRoleOverride(value: unknown, role: string): { ok: true; override: RoleModelOverride } | { ok: false; error: string } {
|
||||||
@@ -397,6 +406,7 @@ export const SETTINGS_SECTIONS = {
|
|||||||
compaction: parseCompactionSettingsBody,
|
compaction: parseCompactionSettingsBody,
|
||||||
models: parseModelsSettingsBody,
|
models: parseModelsSettingsBody,
|
||||||
dictation: parseDictationSettingsBody,
|
dictation: parseDictationSettingsBody,
|
||||||
|
claudeCode: parseClaudeCodeSettingsBody,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type SettingsSectionName = keyof typeof SETTINGS_SECTIONS;
|
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>/);
|
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) => {
|
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");
|
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 }] });
|
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 />);
|
fixture(); await mount(<KeysSection />);
|
||||||
const headings = [...document.querySelectorAll('[data-testid^="group-"] > p')].map((el) => el.textContent);
|
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-deepgram_api_key").closest('[data-testid="group-dictation"]'));
|
||||||
assert(byTest("key-tavily_api_key").closest('[data-testid="group-search"]'));
|
assert(byTest("key-tavily_api_key").closest('[data-testid="group-search"]'));
|
||||||
assert(byTest("key-brave_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 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,
|
catalogDefaultsFor,
|
||||||
catalogProviders,
|
catalogProviders,
|
||||||
catalogRolePins,
|
catalogRolePins,
|
||||||
@@ -12,16 +19,18 @@ import {
|
|||||||
toModelDto,
|
toModelDto,
|
||||||
validateChatSettings,
|
validateChatSettings,
|
||||||
validateModelsSettings,
|
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 { EffectiveSlot } from "../src/core/settings-resolution.ts";
|
||||||
import type { SettingsValidation } from "../src/server/model-catalog.ts";
|
import type { SettingsValidation } from "../src/server/model-catalog.ts";
|
||||||
import { clampThinkingLevel, getSupportedThinkingLevels } from "@earendil-works/pi-ai";
|
import { clampThinkingLevel, getSupportedThinkingLevels } from "@earendil-works/pi-ai";
|
||||||
import { DEFAULT_THINKING_ORDER } from "../src/core/settings.ts";
|
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 { BACKGROUND_MODEL_ROLES } from "../src/core/settings.ts";
|
||||||
import { resolveSettings } from "../src/core/settings-resolution.ts";
|
import { resolveSettings } from "../src/core/settings-resolution.ts";
|
||||||
import { readSettings } from "../src/store/settings.ts";
|
const { readSettings, writeSettings } = await import("../src/store/settings.ts");
|
||||||
import { effectiveSettings } from "../src/server/stores.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
|
// The model-control wave (2026-09-12): pi-ai owns the registry — these pins
|
||||||
// hold our projection + validation honest against the shipped catalog, and
|
// 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.id, "string");
|
||||||
assert.equal(typeof m.name, "string");
|
assert.equal(typeof m.name, "string");
|
||||||
assert.ok(Array.isArray(m.supportedLevels));
|
assert.ok(Array.isArray(m.supportedLevels));
|
||||||
assert.ok(typeof m.cost.input === "number" && typeof m.cost.output === "number");
|
if (m.provider === "claude-code") assert.equal(m.cost, null);
|
||||||
assert.ok(m.contextWindow > 0);
|
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");
|
const glm = models.find((m) => m.id === "z-ai/glm-5.3-flash");
|
||||||
assert.ok(glm, "glm-5.3-flash in catalog");
|
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.id, raw.id);
|
||||||
assert.equal(dto.name, raw.name);
|
assert.equal(dto.name, raw.name);
|
||||||
assert.deepEqual(dto.supportedLevels, getSupportedThinkingLevels(raw));
|
assert.deepEqual(dto.supportedLevels, getSupportedThinkingLevels(raw));
|
||||||
|
assert.ok(dto.cost);
|
||||||
assert.equal(dto.cost.input, raw.cost.input);
|
assert.equal(dto.cost.input, raw.cost.input);
|
||||||
assert.equal(dto.contextWindow, raw.contextWindow);
|
assert.equal(dto.contextWindow, raw.contextWindow);
|
||||||
});
|
});
|
||||||
@@ -327,3 +339,80 @@ describe("pi thinking truth (pinned against pi-ai upgrades)", () => {
|
|||||||
assert.throws(() => piEngineFor("prober"), /no configured model for role "prober"/);
|
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 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 });
|
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);
|
assert.ok(models.length > 100);
|
||||||
for (const model of models) {
|
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)])];
|
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 usage = nativeUsage(input);
|
||||||
const call = Object.freeze(saved(usage));
|
const call = Object.freeze(saved(usage));
|
||||||
const before = structuredClone(call);
|
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.equal(callCost(call, `${model.provider}:${model.id}`), expected, `${model.provider}:${model.id} input=${input}`);
|
||||||
assert.deepEqual(call, before);
|
assert.deepEqual(call, before);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,6 +88,9 @@ describe("send_to_user model gate", () => {
|
|||||||
["openrouter", "anthropic/claude-sonnet-5.5", true],
|
["openrouter", "anthropic/claude-sonnet-5.5", true],
|
||||||
["amazon-bedrock", "us.anthropic.claude-opus-5-5-v1:0", true],
|
["amazon-bedrock", "us.anthropic.claude-opus-5-5-v1:0", true],
|
||||||
["fixture", "CLAUDE-fixture", 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],
|
["openrouter", "z-ai/glm-5.3-flash", false],
|
||||||
["openai", "gpt-4o-mini", false],
|
["openai", "gpt-4o-mini", false],
|
||||||
["anthropic", "opaque-alias", false],
|
["anthropic", "opaque-alias", false],
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import * as fs from "node:fs";
|
|||||||
import * as os from "node:os";
|
import * as os from "node:os";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import { after, describe, it } from "node:test";
|
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
|
// 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,
|
// (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.ok(row.shadowedByEnv, "file value differs from the env value");
|
||||||
assert.equal(row.masked, `…${ENV_OWNED.slice(-4)} (${ENV_OWNED.length} chars)`);
|
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(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", () => {
|
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(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")!;
|
const row = keyStatuses().find((s) => s.id === "FUTURE_PLANS_TOKEN")!;
|
||||||
assert.equal(row.info, null, "no curated form");
|
assert.equal(row.info, null, "no curated form");
|
||||||
assert.equal(row.source, "file");
|
assert.equal(row.source, "file");
|
||||||
@@ -103,6 +106,7 @@ describe("saveKey / deleteKey (immediate effect, no restart)", () => {
|
|||||||
const r = saveKey("DEEPGRAM_API_KEY", value);
|
const r = saveKey("DEEPGRAM_API_KEY", value);
|
||||||
assert.ok(r.ok);
|
assert.ok(r.ok);
|
||||||
assert.equal(process.env.DEEPGRAM_API_KEY, value, "env updated immediately");
|
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(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.equal(r.status.source, "file", "the reflected copy is the store's — the file is its origin of truth");
|
||||||
assert.ok(!r.status.shadowedByEnv);
|
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");
|
const r = saveKey("OPENROUTER_API_KEY", "sk-or-v1-newvalue12345");
|
||||||
assert.ok(r.ok);
|
assert.ok(r.ok);
|
||||||
assert.equal(process.env.OPENROUTER_API_KEY, ENV_OWNED, "real env untouched");
|
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.equal(authStore.authVars().OPENROUTER_API_KEY, "sk-or-v1-newvalue12345", "file updated for later");
|
||||||
assert.ok(r.status.shadowedByEnv);
|
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.
|
// threshold is engine behavior, not an agent-section key.
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
Object.keys(SETTINGS_SECTIONS),
|
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