Compare commits

..

1 Commits

Author SHA1 Message Date
gnanam1990
9906b33fa0 fix(typecheck): make bun run typecheck actionable on main (#473)
Issue #473 reported that `bun run typecheck` fails on main with ~4400
errors due to repo-foundation drift, masking branch-specific
regressions. Per kevincodex1's guidance ("lets narrow the typecheck
scope for now and then we expand step by step") this PR addresses the
foundational root causes and brings the error count down 60% so the
gate is actionable for branch reviews.

Changes:

- tsconfig.json: bump target to ES2023 + add lib ["ES2023", "DOM"]
  so Array.findLast / findLastIndex resolve (kills 41 TS2550 errors).
  Add `noEmit: true` for typecheck-only mode and
  `allowImportingTsExtensions: true` (kills 40 TS5097 errors). Set
  `noImplicitAny: false` because cleaning up TSX-component implicit
  any is explicitly out of scope per the issue.

- src/global.d.ts: ambient declaration for the build-time MACRO
  global injected by scripts/build.ts via Bun's `define` option
  (kills 9 TS2304 'Cannot find name MACRO' errors).

- src/types/{message,utils,tools}.ts: stubs for the highest-impact
  missing modules from the partial source snapshot (~21 importers
  for message alone). Document the snapshot caveat at the top of each
  stub and reference issue #473 so future readers know they're
  placeholders.

- src/entrypoints/sdk/controlTypes.ts and src/constants/querySource.ts:
  similar one-file stubs unblocking 18 + 19 importers respectively.

- src/entrypoints/agentSdkTypes.ts: append `any`-typed aliases for
  ~70 SDK names that callers expect on the public surface but that
  live in stubbed sub-files (PermissionMode, SDKCompactBoundaryMessage,
  HookEvent, ModelUsage, ModelInfo, etc. — exactly the list from
  auriti's bug-report enumeration).

Verified locally on Linux:
- baseline `bunx tsc --noEmit` on stashed main: 4434 errors
- with PR applied:                              1782 errors (60% drop)
- `bun run build`:                              passes (v0.7.0)
- `bun test`:                                   1632 pass; the 4
   remaining failures (StartupScreen, thinking) reproduce on main
   and are unrelated.
- TS2550 (lib): 41 → 0
- TS5097 (.ts imports): 40 → 0
- TS2304 'MACRO': 9 → 0
- TS2307 missing modules: 587 → 325

Remaining errors are localized to specific stubbed modules and can
be addressed in smaller follow-up issues, matching the issue's
"Definition of done" criterion.
2026-04-28 12:44:12 +05:30
37 changed files with 93 additions and 4915 deletions

View File

@@ -25,18 +25,12 @@ OpenClaude is also mirrored to GitLawb:
<a href="https://bankr.bot">
<img src="https://bankr.bot/favicon.svg" alt="Bankr.bot logo" width="96">
</a>
&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://atomic.chat/">
<img src="docs/assets/atomic-chat-logo.png" alt="Atomic Chat logo" width="96">
</a>
</p>
<p align="center">
<a href="https://gitlawb.com"><strong>GitLawb</strong></a>
&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://bankr.bot"><strong>Bankr.bot</strong></a>
&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://atomic.chat/"><strong>Atomic Chat</strong></a>
</p>
## Star History

View File

@@ -28,7 +28,6 @@
"@opentelemetry/sdk-trace-base": "2.6.1",
"@opentelemetry/sdk-trace-node": "2.6.1",
"@opentelemetry/semantic-conventions": "1.40.0",
"@vscode/ripgrep": "^1.17.1",
"ajv": "8.18.0",
"auto-bind": "5.0.1",
"axios": "1.15.0",
@@ -462,8 +461,6 @@
"@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
"@vscode/ripgrep": ["@vscode/ripgrep@1.17.1", "", { "dependencies": { "https-proxy-agent": "^7.0.2", "proxy-from-env": "^1.1.0", "yauzl": "^2.9.2" } }, "sha512-xTs7DGyAO3IsJYOCTBP8LnTvPiYVKEuyv8s0xyJDBXfs8rhBfqnZPvb6xDT+RnwWzcXqW27xLS/aGrkjX7lNWw=="],
"accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
@@ -494,8 +491,6 @@
"bowser": ["bowser@2.14.1", "", {}, "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg=="],
"buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="],
"buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="],
"bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
@@ -614,8 +609,6 @@
"fast-xml-parser": ["fast-xml-parser@5.5.8", "", { "dependencies": { "fast-xml-builder": "^1.1.4", "path-expression-matcher": "^1.2.0", "strnum": "^2.2.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ=="],
"fd-slicer": ["fd-slicer@1.1.0", "", { "dependencies": { "pend": "~1.2.0" } }, "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g=="],
"fflate": ["fflate@0.8.2", "", {}, "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="],
"figures": ["figures@6.1.0", "", { "dependencies": { "is-unicode-supported": "^2.0.0" } }, "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg=="],
@@ -794,8 +787,6 @@
"path-to-regexp": ["path-to-regexp@8.4.1", "", {}, "sha512-fvU78fIjZ+SBM9YwCknCvKOUKkLVqtWDVctl0s7xIqfmfb38t2TT4ZU2gHm+Z8xGwgW+QWEU3oQSAzIbo89Ggw=="],
"pend": ["pend@1.2.0", "", {}, "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg=="],
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
"pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="],
@@ -810,7 +801,7 @@
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
"proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="],
"proxy-from-env": ["proxy-from-env@2.1.0", "", {}, "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="],
"qrcode": ["qrcode@1.5.4", "", { "dependencies": { "dijkstrajs": "^1.0.1", "pngjs": "^5.0.0", "yargs": "^15.3.1" }, "bin": { "qrcode": "bin/qrcode" } }, "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg=="],
@@ -962,8 +953,6 @@
"yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
"yauzl": ["yauzl@2.10.0", "", { "dependencies": { "buffer-crc32": "~0.2.3", "fd-slicer": "~1.1.0" } }, "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g=="],
"yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="],
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
@@ -1380,8 +1369,6 @@
"@smithy/uuid/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"axios/proxy-from-env": ["proxy-from-env@2.1.0", "", {}, "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="],
"cli-highlight/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"cli-highlight/yargs": ["yargs@16.2.0", "", { "dependencies": { "cliui": "^7.0.2", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.0", "y18n": "^5.0.5", "yargs-parser": "^20.2.2" } }, "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw=="],
@@ -1442,8 +1429,6 @@
"@aws-sdk/nested-clients/@smithy/util-base64/@smithy/util-buffer-from": ["@smithy/util-buffer-from@4.2.2", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q=="],
"@mendable/firecrawl-js/axios/proxy-from-env": ["proxy-from-env@2.1.0", "", {}, "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="],
"@opentelemetry/exporter-trace-otlp-grpc/@opentelemetry/core/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.28.0", "", {}, "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA=="],
"@opentelemetry/exporter-trace-otlp-grpc/@opentelemetry/otlp-transformer/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.57.2", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-uIX52NnTM0iBh84MShlpouI7UKqkZ7MrUszTmaypHBu4r7NofznSnQRfJ+uUeDtQDj6w8eFGg5KBLDAwAPz1+A=="],
@@ -1524,8 +1509,6 @@
"cliui/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"firecrawl/axios/proxy-from-env": ["proxy-from-env@2.1.0", "", {}, "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="],
"form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"qrcode/yargs/cliui": ["cliui@6.0.0", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ=="],

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

View File

@@ -74,7 +74,6 @@
"@opentelemetry/sdk-trace-base": "2.6.1",
"@opentelemetry/sdk-trace-node": "2.6.1",
"@opentelemetry/semantic-conventions": "1.40.0",
"@vscode/ripgrep": "^1.17.1",
"ajv": "8.18.0",
"auto-bind": "5.0.1",
"axios": "1.15.0",

View File

@@ -472,11 +472,6 @@ ${exports}
'@aws-sdk/credential-providers',
'@azure/identity',
'google-auth-library',
// @vscode/ripgrep ships a platform-specific binary alongside its
// index.js and resolves the path via __dirname at runtime. Bundling
// would freeze the build host's absolute path into dist/cli.mjs, so we
// keep it external and rely on the npm package being installed.
'@vscode/ripgrep',
],
})

View File

@@ -111,7 +111,7 @@ import { BackgroundTasksDialog } from '../tasks/BackgroundTasksDialog.js';
import { shouldHideTasksFooter } from '../tasks/taskStatusUtils.js';
import { TeamsDialog } from '../teams/TeamsDialog.js';
import VimTextInput from '../VimTextInput.js';
import { detectModeEntry, getModeFromInput, getValueFromInput } from './inputModes.js';
import { getModeFromInput, getValueFromInput } from './inputModes.js';
import { FOOTER_TEMPORARY_STATUS_TIMEOUT, Notifications } from './Notifications.js';
import PromptInputFooter from './PromptInputFooter.js';
import type { SuggestionItem } from './PromptInputFooterSuggestions.js';
@@ -878,22 +878,24 @@ function PromptInput({
abortPromptSuggestion();
abortSpeculation(setAppState);
// Strip the mode character from the buffer when entering bash mode — the
// mode itself is shown via the prompt prefix in the UI. Without this,
// typing `!` into empty input would enter bash mode but leave the literal
// `!` in the buffer (issue #662).
const modeEntry = detectModeEntry({
value,
prevInputLength: input.length,
cursorOffset,
});
if (modeEntry) {
onModeChange(modeEntry.mode);
const cleaned = modeEntry.strippedValue.replaceAll('\t', ' ');
pushToBuffer(input, cursorOffset, pastedContents);
trackAndSetInput(cleaned);
setCursorOffset(cleaned.length);
return;
// Check if this is a single character insertion at the start
const isSingleCharInsertion = value.length === input.length + 1;
const insertedAtStart = cursorOffset === 0;
const mode = getModeFromInput(value);
if (insertedAtStart && mode !== 'prompt') {
if (isSingleCharInsertion) {
onModeChange(mode);
return;
}
// Multi-char insertion into empty input (e.g. tab-accepting "! gcloud auth login")
if (input.length === 0) {
onModeChange(mode);
const valueWithoutMode = getValueFromInput(value).replaceAll('\t', ' ');
pushToBuffer(input, cursorOffset, pastedContents);
trackAndSetInput(valueWithoutMode);
setCursorOffset(valueWithoutMode.length);
return;
}
}
const processedValue = value.replaceAll('\t', ' ');

View File

@@ -1,104 +0,0 @@
import { describe, expect, it } from 'bun:test'
import {
detectModeEntry,
getModeFromInput,
getValueFromInput,
isInputModeCharacter,
prependModeCharacterToInput,
} from './inputModes.js'
describe('inputModes', () => {
describe('getModeFromInput', () => {
it('returns bash mode for input starting with !', () => {
expect(getModeFromInput('!')).toBe('bash')
expect(getModeFromInput('!ls')).toBe('bash')
})
it('returns prompt mode for non-bash input', () => {
expect(getModeFromInput('')).toBe('prompt')
expect(getModeFromInput('hello')).toBe('prompt')
expect(getModeFromInput(' !')).toBe('prompt')
})
})
describe('getValueFromInput', () => {
it('strips the leading ! when entering bash mode', () => {
expect(getValueFromInput('!')).toBe('')
expect(getValueFromInput('!ls -la')).toBe('ls -la')
})
it('returns input unchanged in prompt mode', () => {
expect(getValueFromInput('')).toBe('')
expect(getValueFromInput('hello')).toBe('hello')
})
})
describe('isInputModeCharacter', () => {
it('returns true only for the bare ! character', () => {
expect(isInputModeCharacter('!')).toBe(true)
expect(isInputModeCharacter('!ls')).toBe(false)
expect(isInputModeCharacter('')).toBe(false)
})
})
describe('prependModeCharacterToInput', () => {
it('prepends ! when mode is bash', () => {
expect(prependModeCharacterToInput('ls', 'bash')).toBe('!ls')
expect(prependModeCharacterToInput('', 'bash')).toBe('!')
})
it('returns input unchanged in prompt mode', () => {
expect(prependModeCharacterToInput('hello', 'prompt')).toBe('hello')
})
})
describe('detectModeEntry', () => {
// Regression for #662 — typing `!` into empty input must switch to bash
// mode AND yield an empty stripped buffer. Before the fix the single-char
// path returned without stripping, leaving `!` visible in the buffer.
it('strips the mode character when typing ! into empty input', () => {
expect(
detectModeEntry({ value: '!', prevInputLength: 0, cursorOffset: 0 }),
).toEqual({ mode: 'bash', strippedValue: '' })
})
it('strips the mode character when pasting !cmd into empty input', () => {
expect(
detectModeEntry({ value: '!ls -la', prevInputLength: 0, cursorOffset: 0 }),
).toEqual({ mode: 'bash', strippedValue: 'ls -la' })
})
it('returns null when the cursor is not at the start', () => {
expect(
detectModeEntry({ value: '!', prevInputLength: 0, cursorOffset: 1 }),
).toBeNull()
})
it('returns null when the value does not start with !', () => {
expect(
detectModeEntry({ value: 'hello', prevInputLength: 0, cursorOffset: 0 }),
).toBeNull()
})
it('returns null when typing ! after existing text', () => {
// value="ab!" with prevInputLength=2 is a single-char insertion but does
// not start with ! — getModeFromInput returns 'prompt'.
expect(
detectModeEntry({ value: 'ab!', prevInputLength: 2, cursorOffset: 0 }),
).toBeNull()
})
it('returns null when prepending ! to non-empty existing text', () => {
// Single-char insertion at start that produces "!ab" from "ab" — value
// length is 3, prevInputLength is 2, so isSingleCharInsertion is true
// and isMultiCharIntoEmpty is false. We accept the mode change here so
// that typing ! at the start of existing text still toggles mode.
const result = detectModeEntry({
value: '!ab',
prevInputLength: 2,
cursorOffset: 0,
})
expect(result).toEqual({ mode: 'bash', strippedValue: 'ab' })
})
})
})

View File

@@ -31,30 +31,3 @@ export function getValueFromInput(input: string): string {
export function isInputModeCharacter(input: string): boolean {
return input === '!'
}
export type ModeEntryDecision = {
mode: HistoryMode
strippedValue: string
}
/**
* Decide whether an onChange `value` should switch the input mode (e.g.
* `prompt` → `bash`) and what the stripped buffer value should be.
*
* Returns null when no mode change applies. Returns a decision otherwise so
* callers run a single update path — no separate single-char vs multi-char
* branches that can drift apart.
*/
export function detectModeEntry(args: {
value: string
prevInputLength: number
cursorOffset: number
}): ModeEntryDecision | null {
if (args.cursorOffset !== 0) return null
const mode = getModeFromInput(args.value)
if (mode === 'prompt') return null
const isSingleCharInsertion = args.value.length === args.prevInputLength + 1
const isMultiCharIntoEmpty = args.prevInputLength === 0
if (!isSingleCharInsertion && !isMultiCharIntoEmpty) return null
return { mode, strippedValue: getValueFromInput(args.value) }
}

View File

@@ -1,518 +0,0 @@
// Type declarations for @gitlawb/openclaude SDK
// Manually maintained — keep in sync with src/entrypoints/sdk/index.ts
// Drift is caught by validate-externals.ts (runs in CI)
// ============================================================================
// Error
// ============================================================================
export class AbortError extends Error {
override readonly name: 'AbortError'
}
export class ClaudeError extends Error {
constructor(message: string)
}
export class SDKError extends ClaudeError {
constructor(message: string)
}
export class SDKAuthenticationError extends SDKError {
constructor(message?: string)
}
export class SDKBillingError extends SDKError {
constructor(message?: string)
}
export class SDKRateLimitError extends SDKError {
constructor(
message?: string,
readonly resetsAt?: number,
readonly rateLimitType?: string,
)
}
export class SDKInvalidRequestError extends SDKError {
constructor(message?: string)
}
export class SDKServerError extends SDKError {
constructor(message?: string)
}
export class SDKMaxOutputTokensError extends SDKError {
constructor(message?: string)
}
export type SDKAssistantMessageError =
| 'authentication_failed'
| 'billing_error'
| 'rate_limit'
| 'invalid_request'
| 'server_error'
| 'unknown'
| 'max_output_tokens'
export function sdkErrorFromType(
errorType: SDKAssistantMessageError,
message?: string,
): SDKError | ClaudeError
// ============================================================================
// Types
// ============================================================================
export type ApiKeySource = 'user' | 'project' | 'org' | 'temporary' | 'oauth' | 'none'
export type RewindFilesResult = {
canRewind: boolean
error?: string
filesChanged?: string[]
insertions?: number
deletions?: number
}
export type McpServerStatus = {
name: string
status: 'connected' | 'failed' | 'needs-auth' | 'pending' | 'disabled'
serverInfo?: { name: string; version: string }
error?: string
scope?: string
tools?: {
name: string
description?: string
annotations?: {
readOnly?: boolean
destructive?: boolean
openWorld?: boolean
}
}[]
}
export type PermissionResult = ({
behavior: 'allow'
updatedInput?: Record<string, unknown>
updatedPermissions?: ({
type: 'addRules'
rules: { toolName: string; ruleContent?: string }[]
behavior: 'allow' | 'deny' | 'ask'
destination: 'userSettings' | 'projectSettings' | 'localSettings' | 'session' | 'cliArg'
}) | ({
type: 'replaceRules'
rules: { toolName: string; ruleContent?: string }[]
behavior: 'allow' | 'deny' | 'ask'
destination: 'userSettings' | 'projectSettings' | 'localSettings' | 'session' | 'cliArg'
}) | ({
type: 'removeRules'
rules: { toolName: string; ruleContent?: string }[]
behavior: 'allow' | 'deny' | 'ask'
destination: 'userSettings' | 'projectSettings' | 'localSettings' | 'session' | 'cliArg'
}) | ({
type: 'setMode'
mode: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' | 'dontAsk'
destination: 'userSettings' | 'projectSettings' | 'localSettings' | 'session' | 'cliArg'
}) | ({
type: 'addDirectories'
directories: string[]
destination: 'userSettings' | 'projectSettings' | 'localSettings' | 'session' | 'cliArg'
}) | ({
type: 'removeDirectories'
directories: string[]
destination: 'userSettings' | 'projectSettings' | 'localSettings' | 'session' | 'cliArg'
})[]
toolUseID?: string
decisionClassification?: 'user_temporary' | 'user_permanent' | 'user_reject'
}) | ({
behavior: 'deny'
message: string
interrupt?: boolean
toolUseID?: string
decisionClassification?: 'user_temporary' | 'user_permanent' | 'user_reject'
})
export type SDKSessionInfo = {
sessionId: string
summary: string
lastModified: number
fileSize?: number
customTitle?: string
firstPrompt?: string
gitBranch?: string
cwd?: string
tag?: string
createdAt?: number
}
export type ListSessionsOptions = {
dir?: string
limit?: number
offset?: number
includeWorktrees?: boolean
}
export type GetSessionInfoOptions = {
dir?: string
}
export type GetSessionMessagesOptions = {
dir?: string
limit?: number
offset?: number
includeSystemMessages?: boolean
}
export type SessionMutationOptions = {
dir?: string
}
export type ForkSessionOptions = {
dir?: string
upToMessageId?: string
title?: string
}
export type ForkSessionResult = {
sessionId: string
}
export type SessionMessage = {
role: 'user' | 'assistant' | 'system'
content: unknown
timestamp?: string
uuid?: string
parentUuid?: string | null
[key: string]: unknown
}
// Re-export precise SDK message types from generated types
// These use camelCase field names and discriminated unions for full IntelliSense
export type { SDKMessage as SDKMessage } from './sdk/coreTypes.generated.js'
export type { SDKUserMessage as SDKUserMessage } from './sdk/coreTypes.generated.js'
export type { SDKResultMessage as SDKResultMessage } from './sdk/coreTypes.generated.js'
// ============================================================================
// Query types
// ============================================================================
export type QueryPermissionMode =
| 'default'
| 'plan'
| 'auto-accept'
| 'bypass-permissions'
| 'bypassPermissions'
| 'acceptEdits'
export type QueryOptions = {
cwd: string
additionalDirectories?: string[]
model?: string
sessionId?: string
/** Fork the session before resuming (requires sessionId). */
fork?: boolean
/** Alias for fork. When true, resumed session forks to a new session ID. */
forkSession?: boolean
/** Resume the most recent session for this cwd (no sessionId needed). */
continue?: boolean
resume?: string
/** When resuming, resume messages up to and including this message UUID. */
resumeSessionAt?: string
permissionMode?: QueryPermissionMode
abortController?: AbortController
executable?: string
allowDangerouslySkipPermissions?: boolean
disallowedTools?: string[]
hooks?: Record<string, unknown[]>
mcpServers?: Record<string, unknown>
settings?: {
env?: Record<string, string>
attribution?: { commit: string; pr: string }
}
/** Environment variables to apply during query execution. Overrides process.env. Takes precedence over settings.env. */
env?: Record<string, string | undefined>
/**
* Callback invoked before each tool use. Return `{ behavior: 'allow' }` to
* permit the call or `{ behavior: 'deny', message?: string }` to reject it.
*
* **Secure-by-default**: If neither `canUseTool` nor `onPermissionRequest`
* is provided, ALL tool uses are denied. You MUST provide at least one of
* these callbacks to allow tool execution.
*/
canUseTool?: (
name: string,
input: unknown,
options?: { toolUseID?: string },
) => Promise<{ behavior: 'allow' | 'deny'; message?: string; updatedInput?: unknown }>
/**
* Callback invoked when a tool needs permission approval. The host receives
* the request immediately and can resolve it by calling
* `query.respondToPermission(toolUseId, decision)` before the timeout.
* If omitted, tools that require permission fall through to the default
* permission logic immediately (no timeout).
*/
onPermissionRequest?: (message: SDKPermissionRequestMessage) => void
systemPrompt?:
| string
| { type: 'preset'; preset: string; append?: string }
| { type: 'custom'; content: string }
/** Agent definitions to register with the query engine. */
agents?: Record<string, {
description: string
prompt: string
tools?: string[]
disallowedTools?: string[]
model?: string
maxTurns?: number
}>
settingSources?: string[]
/** When true, yields stream_event messages for token-by-token streaming. */
includePartialMessages?: boolean
/** @internal Timeout in ms for permission request resolution. Default 30000. */
_permissionTimeoutMs?: number
stderr?: (data: string) => void
}
export interface Query {
readonly sessionId: string
[Symbol.asyncIterator](): AsyncIterator<SDKMessage>
setModel(model: string): Promise<void>
setPermissionMode(mode: QueryPermissionMode): Promise<void>
close(): void
interrupt(): void
respondToPermission(toolUseId: string, decision: PermissionResult): void
/** Check if file rewind is possible. */
rewindFiles(): RewindFilesResult
/** Actually perform the file rewind. Returns files changed and diff stats. */
rewindFilesAsync(): Promise<RewindFilesResult>
supportedCommands(): string[]
supportedModels(): string[]
supportedAgents(): string[]
mcpServerStatus(): McpServerStatus[]
accountInfo(): Promise<{ apiKeySource: ApiKeySource; [key: string]: unknown }>
setMaxThinkingTokens(tokens: number): void
}
/**
* Permission request message emitted when a tool needs permission approval.
* Hosts can respond via respondToPermission() using the request_id.
*/
export type SDKPermissionRequestMessage = {
type: 'permission_request'
request_id: string
tool_name: string
tool_use_id: string
input: Record<string, unknown>
uuid: string
session_id: string
}
export type SDKPermissionTimeoutMessage = {
type: 'permission_timeout'
tool_name: string
tool_use_id: string
timed_out_after_ms: number
uuid: string
session_id: string
}
// ============================================================================
// V2 API types
// ============================================================================
export type SDKSessionOptions = {
cwd: string
model?: string
permissionMode?: QueryPermissionMode
abortController?: AbortController
/**
* Callback invoked before each tool use. Return `{ behavior: 'allow' }` to
* permit the call or `{ behavior: 'deny', message?: string }` to reject it.
*
* **Secure-by-default**: If neither `canUseTool` nor `onPermissionRequest`
* is provided, ALL tool uses are denied. You MUST provide at least one of
* these callbacks to allow tool execution.
*/
canUseTool?: (
name: string,
input: unknown,
options?: { toolUseID?: string },
) => Promise<{ behavior: 'allow' | 'deny'; message?: string; updatedInput?: unknown }>
/** MCP server configurations for this session. */
mcpServers?: Record<string, unknown>
/**
* Callback invoked when a tool needs permission approval. The host receives
* the request immediately and can resolve it via respondToPermission().
*/
onPermissionRequest?: (message: SDKPermissionRequestMessage) => void
}
export interface SDKSession {
sessionId: string
sendMessage(content: string): AsyncIterable<SDKMessage>
getMessages(): SDKMessage[]
interrupt(): void
/** Respond to a pending permission prompt. */
respondToPermission(toolUseId: string, decision: PermissionResult): void
}
// ============================================================================
// MCP tool types
// ============================================================================
export interface SdkMcpToolDefinition<Schema = any> {
name: string
description: string
inputSchema: Schema
handler: (args: any, extra: unknown) => Promise<any>
annotations?: any
searchHint?: string
alwaysLoad?: boolean
}
// ============================================================================
// Session functions
// ============================================================================
export function listSessions(
options?: ListSessionsOptions,
): Promise<SDKSessionInfo[]>
export function getSessionInfo(
sessionId: string,
options?: GetSessionInfoOptions,
): Promise<SDKSessionInfo | undefined>
export function getSessionMessages(
sessionId: string,
options?: GetSessionMessagesOptions,
): Promise<SessionMessage[]>
export function renameSession(
sessionId: string,
title: string,
options?: SessionMutationOptions,
): Promise<void>
export function tagSession(
sessionId: string,
tag: string | null,
options?: SessionMutationOptions,
): Promise<void>
export function forkSession(
sessionId: string,
options?: ForkSessionOptions,
): Promise<ForkSessionResult>
export function deleteSession(
sessionId: string,
options?: SessionMutationOptions,
): Promise<void>
// ============================================================================
// Query functions
// ============================================================================
export function query(params: {
prompt: string | AsyncIterable<SDKUserMessage>
options?: QueryOptions
}): Query
export function queryAsync(params: {
prompt: string | AsyncIterable<SDKUserMessage>
options?: QueryOptions
}): Promise<Query>
// ============================================================================
// V2 API functions
// ============================================================================
export function unstable_v2_createSession(options: SDKSessionOptions): SDKSession
export function unstable_v2_resumeSession(
sessionId: string,
options: SDKSessionOptions,
): Promise<SDKSession>
export function unstable_v2_prompt(
message: string,
options: SDKSessionOptions,
): Promise<SDKResultMessage>
// ============================================================================
// MCP tool functions
// ============================================================================
export function tool<Schema = any>(
name: string,
description: string,
inputSchema: Schema,
handler: (args: any, extra: unknown) => Promise<any>,
extras?: {
annotations?: any
searchHint?: string
alwaysLoad?: boolean
},
): SdkMcpToolDefinition<Schema>
/**
* MCP server transport configuration types.
* Matches McpServerConfigForProcessTransport from coreTypes.generated.ts.
*/
export type SdkMcpStdioConfig = {
type?: "stdio"
command: string
args?: string[]
env?: Record<string, string>
}
export type SdkMcpSSEConfig = {
type: "sse"
url: string
headers?: Record<string, string>
}
export type SdkMcpHttpConfig = {
type: "http"
url: string
headers?: Record<string, string>
}
export type SdkMcpSdkConfig = {
type: "sdk"
name: string
}
export type SdkMcpServerConfig = SdkMcpStdioConfig | SdkMcpSSEConfig | SdkMcpHttpConfig | SdkMcpSdkConfig
/**
* Scoped MCP server config with session scope.
* Returned by createSdkMcpServer() for use with mcpServers option.
*/
export type SdkScopedMcpServerConfig = SdkMcpServerConfig & {
scope: "session"
}
/**
* Wraps an MCP server configuration for use with the SDK.
* Adds the 'session' scope marker so the SDK knows this server
* should be connected per-session (not globally).
*
* @param config - MCP server config (stdio, sse, http, or sdk type)
* @returns Scoped config with scope: 'session' added
*
* @example
* ```typescript
* const server = createSdkMcpServer({
* type: 'stdio',
* command: 'npx',
* args: ['-y', '@modelcontextprotocol/server-filesystem', '/tmp'],
* })
* const session = unstable_v2_createSession({
* cwd: '/my/project',
* mcpServers: { 'fs': server },
* })
* ```
*/
export function createSdkMcpServer(config: SdkMcpServerConfig): SdkScopedMcpServerConfig

View File

@@ -55,7 +55,7 @@ export const OutputFormatSchema = lazySchema(() =>
// ============================================================================
export const ApiKeySourceSchema = lazySchema(() =>
z.enum(['user', 'project', 'org', 'temporary', 'oauth', 'none']),
z.enum(['user', 'project', 'org', 'temporary', 'oauth']),
)
export const ConfigScopeSchema = lazySchema(() =>
@@ -1851,18 +1851,6 @@ export const SDKSessionInfoSchema = lazySchema(() =>
.describe('Session metadata returned by listSessions and getSessionInfo.'),
)
export const SDKPermissionRequestMessageSchema = lazySchema(() =>
z.object({
type: z.literal('permission_request'),
request_id: z.string().describe('Unique request ID for this permission prompt'),
tool_name: z.string().describe('Name of the tool requesting permission'),
tool_use_id: z.string().describe('Tool use ID for matching with respondToPermission'),
input: z.record(z.string(), z.unknown()).describe('Tool input parameters'),
uuid: UUIDPlaceholder(),
session_id: z.string(),
}),
)
export const SDKMessageSchema = lazySchema(() =>
z.union([
SDKAssistantMessageSchema(),
@@ -1889,7 +1877,6 @@ export const SDKMessageSchema = lazySchema(() =>
SDKRateLimitEventSchema(),
SDKElicitationCompleteMessageSchema(),
SDKPromptSuggestionMessageSchema(),
SDKPermissionRequestMessageSchema(),
]),
)

File diff suppressed because it is too large Load Diff

View File

@@ -79,7 +79,6 @@ import { headlessProfilerCheckpoint } from './utils/headlessProfiler.js'
import {
getDefaultMainLoopModelSetting,
getRuntimeMainLoopModel,
parseUserSpecifiedModel,
renderModelName,
} from './utils/model/model.js'
import {
@@ -625,7 +624,7 @@ async function* queryLoop(
getDefaultMainLoopModelSetting()
let currentModel = getRuntimeMainLoopModel({
permissionMode,
mainLoopModel: parseUserSpecifiedModel(appStateMainLoopModel),
mainLoopModel: appStateMainLoopModel,
exceeds200kTokens:
permissionMode === 'plan' &&
doesMostRecentAssistantMessageExceed200k(messagesForQuery),

View File

@@ -1283,21 +1283,6 @@ async function* queryModel(
let messagesForAPI = normalizeMessagesForAPI(messages, filteredTools)
queryCheckpoint('query_message_normalization_end')
// Apply hybrid context strategy for optimal cache/fresh balance
if (feature('HYBRID_CONTEXT_STRATEGY')) {
const { applyHybridStrategy } = await import('../../utils/hybridContextStrategy.js')
// Cap at 200k to avoid edge case with very large context windows
const strategyResult = applyHybridStrategy(messagesForAPI, {
cacheWeight: 0.4,
freshWeight: 0.6,
maxTotalTokens: Math.min(
getContextWindowForModel(model, getSdkBetas()) - COMPACT_MAX_OUTPUT_TOKENS,
200000
),
})
messagesForAPI = strategyResult.selectedMessages
}
// Model-specific post-processing: strip tool-search-specific fields if the
// selected model doesn't support tool search.
//

View File

@@ -1,104 +0,0 @@
import { describe, expect, it } from 'bun:test'
import {
analyzeConversationPatterns,
predictContextNeeds,
preloadContext,
createPreloadStrategy,
} from './contextPreload.js'
function createMessage(role: string, content: string, createdAt: number = Date.now()): any {
return {
message: { role, content, id: 'test', type: 'message', created_at: createdAt },
sender: role,
}
}
describe('contextPreload', () => {
describe('analyzeConversationPatterns', () => {
it('extracts patterns from messages', () => {
const messages = [
createMessage('user', 'Fix the error in my code', 1000),
createMessage('assistant', 'I found the bug', 2000),
]
const patterns = analyzeConversationPatterns(messages)
expect(patterns.length).toBeGreaterThanOrEqual(0)
})
it('detects debug patterns', () => {
const messages = [
createMessage('user', 'Debug this error please', 1000),
createMessage('assistant', 'Found it', 2000),
]
const patterns = analyzeConversationPatterns(messages)
expect(patterns.some(p => p.userQuery === 'debug')).toBe(true)
})
it('detects code patterns', () => {
const messages = [
createMessage('user', 'Write a function for me', 1000),
createMessage('assistant', 'Here is the code', 2000),
]
const patterns = analyzeConversationPatterns(messages)
expect(patterns.some(p => p.userQuery === 'code')).toBe(true)
})
})
describe('predictContextNeeds', () => {
it('predicts context needs based on query', () => {
const patterns = [{ userQuery: 'debug', neededContext: ['error_history'], frequency: 1 }]
const prediction = predictContextNeeds('Fix the bug', patterns, {
maxPreloadTokens: 10000,
confidenceThreshold: 0.3,
})
expect(prediction.confidence).toBeGreaterThan(0)
expect(prediction.predictedNeed.length).toBeGreaterThan(0)
})
it('returns non-empty predictedNeed when pattern matches', () => {
const patterns = [
{ userQuery: 'debug', neededContext: ['error_history', 'stack_trace'], frequency: 2 },
]
const prediction = predictContextNeeds('debug this error', patterns, {
maxPreloadTokens: 10000,
confidenceThreshold: 0.1,
})
expect(prediction.predictedNeed).toContain('error_history')
})
})
describe('preloadContext', () => {
it('preloads relevant context', () => {
const messages = [
createMessage('system', 'System prompt'),
createMessage('user', 'Debug error'),
createMessage('assistant', 'Fixed'),
]
const prediction = { predictedNeed: ['error'], confidence: 0.8, suggestedMessages: [] }
const result = preloadContext(messages, prediction, { maxPreloadTokens: 5000 })
expect(result.length).toBeGreaterThan(0)
})
})
describe('createPreloadStrategy', () => {
it('creates strategy with all methods', () => {
const strategy = createPreloadStrategy({ maxPreloadTokens: 10000 })
expect(strategy.analyze).toBeDefined()
expect(strategy.predict).toBeDefined()
expect(strategy.preload).toBeDefined()
})
})
})

View File

@@ -1,145 +0,0 @@
/**
* Context Pre-loading - Production Grade
*
* Proactively loads relevant context before it's needed.
* Prediction based on conversation patterns.
*/
import { roughTokenCountEstimation } from '../services/tokenEstimation.js'
import type { Message } from '../types/message.js'
export interface PreloadConfig {
maxPreloadTokens: number
predictionWindow?: number
confidenceThreshold?: number
}
export interface PreloadPrediction {
predictedNeed: string[]
confidence: number
suggestedMessages: Message[]
}
export interface ConversationPattern {
userQuery: string
neededContext: string[]
frequency: number
}
const PATTERN_KEYWORDS: Record<string, string[]> = {
'code': ['code', 'function', 'implement', 'write'],
'debug': ['error', 'bug', 'fix', 'issue', 'debug'],
'refactor': ['refactor', 'improve', 'clean', 'optimize'],
'test': ['test', 'spec', 'coverage', 'verify'],
'explain': ['explain', 'what', 'how', 'why', 'describe'],
'search': ['find', 'search', 'look', 'grep', 'glob'],
}
export function analyzeConversationPatterns(messages: Message[]): ConversationPattern[] {
const patterns: ConversationPattern[] = []
const recentMessages = messages.slice(-10)
for (let i = 0; i < recentMessages.length - 1; i++) {
const userMsg = recentMessages[i]
const assistantMsg = recentMessages[i + 1]
const userContent = typeof userMsg.message?.content === 'string' ? userMsg.message.content : ''
const assistantContent = typeof assistantMsg.message?.content === 'string' ? assistantMsg.message.content : ''
for (const [category, keywords] of Object.entries(PATTERN_KEYWORDS)) {
if (keywords.some(k => userContent.toLowerCase().includes(k))) {
patterns.push({
userQuery: category,
neededContext: extractContextNeeds(assistantContent),
frequency: 1,
})
}
}
}
return patterns
}
function extractContextNeeds(content: string): string[] {
const needs: string[] = []
if (content.includes('file')) needs.push('file_context')
if (content.includes('function')) needs.push('function_defs')
if (content.includes('error')) needs.push('error_history')
if (content.includes('test')) needs.push('test_files')
return needs
}
export function predictContextNeeds(
currentQuery: string,
patterns: ConversationPattern[],
config: PreloadConfig,
): PreloadPrediction {
const threshold = config.confidenceThreshold ?? 0.5
let matchedCategory = ''
let highestConfidence = 0
for (const [category, keywords] of Object.entries(PATTERN_KEYWORDS)) {
const matches = keywords.filter(k => currentQuery.toLowerCase().includes(k)).length
const confidence = matches / keywords.length
if (confidence > highestConfidence && confidence >= threshold) {
highestConfidence = confidence
matchedCategory = category
}
}
const relevantPatterns = patterns.filter(p => p.userQuery === matchedCategory)
const allNeeds = relevantPatterns.flatMap(p => p.neededContext)
return {
predictedNeed: [...new Set(allNeeds)],
confidence: highestConfidence,
suggestedMessages: [],
}
}
export function preloadContext(
availableContext: Message[],
prediction: PreloadPrediction,
config: PreloadConfig,
): Message[] {
const targetTokens = config.maxPreloadTokens ?? 30000
const selected: Message[] = []
let usedTokens = 0
const priorityTypes = prediction.predictedNeed
const sorted = [...availableContext].sort((a, b) => {
const aContent = typeof a.message?.content === 'string' ? a.message.content : ''
const bContent = typeof b.message?.content === 'string' ? b.message.content : ''
const aPriority = priorityTypes.some(t => aContent.includes(t)) ? 1 : 0
const bPriority = priorityTypes.some(t => bContent.includes(t)) ? 1 : 0
if (bPriority !== aPriority) return bPriority - aPriority
return (b.message?.created_at ?? 0) - (a.message?.created_at ?? 0)
})
for (const msg of sorted) {
const tokens = roughTokenCountEstimation(
typeof msg.message?.content === 'string' ? msg.message.content : ''
)
if (usedTokens + tokens > targetTokens) break
selected.push(msg)
usedTokens += tokens
}
return selected
}
export function createPreloadStrategy(config: PreloadConfig) {
return {
analyze: analyzeConversationPatterns,
predict: (query: string, patterns: ConversationPattern[]) =>
predictContextNeeds(query, patterns, config),
preload: (context: Message[], prediction: PreloadPrediction) =>
preloadContext(context, prediction, config),
}
}

View File

@@ -201,95 +201,6 @@ export type AxiosErrorKind =
| 'http' // other axios error (may have status)
| 'other' // not an axios error
// ============================================================================
// SDK-specific error classes
// ============================================================================
/**
* Base class for all SDK errors. Extends ClaudeError so that existing
* `catch (e) { if (e instanceof ClaudeError) … }` checks still work,
* while giving SDK consumers a more specific base to match against.
*/
export class SDKError extends ClaudeError {
constructor(message: string) {
super(message)
this.name = 'SDKError'
}
}
export class SDKAuthenticationError extends SDKError {
constructor(message?: string) {
super(message ?? 'Authentication failed')
this.name = 'SDKAuthenticationError'
}
}
export class SDKBillingError extends SDKError {
constructor(message?: string) {
super(message ?? 'Billing error - check subscription')
this.name = 'SDKBillingError'
}
}
export class SDKRateLimitError extends SDKError {
constructor(
message?: string,
public readonly resetsAt?: number,
public readonly rateLimitType?: string,
) {
super(message ?? 'Rate limit exceeded')
this.name = 'SDKRateLimitError'
}
}
export class SDKInvalidRequestError extends SDKError {
constructor(message?: string) {
super(message ?? 'Invalid request')
this.name = 'SDKInvalidRequestError'
}
}
export class SDKServerError extends SDKError {
constructor(message?: string) {
super(message ?? 'Server error')
this.name = 'SDKServerError'
}
}
export class SDKMaxOutputTokensError extends SDKError {
constructor(message?: string) {
super(message ?? 'Max output tokens reached')
this.name = 'SDKMaxOutputTokensError'
}
}
export type SDKAssistantMessageError =
| 'authentication_failed'
| 'billing_error'
| 'rate_limit'
| 'invalid_request'
| 'server_error'
| 'unknown'
| 'max_output_tokens'
/**
* Convert an SDKAssistantMessageError type string to the proper Error class.
*/
export function sdkErrorFromType(
errorType: SDKAssistantMessageError,
message?: string,
): SDKError | ClaudeError {
switch (errorType) {
case 'authentication_failed': return new SDKAuthenticationError(message)
case 'billing_error': return new SDKBillingError(message)
case 'rate_limit': return new SDKRateLimitError(message)
case 'invalid_request': return new SDKInvalidRequestError(message)
case 'server_error': return new SDKServerError(message)
case 'max_output_tokens': return new SDKMaxOutputTokensError(message)
default: return new ClaudeError(message ?? 'Unknown error')
}
}
/**
* Classify a caught error from an axios request into one of a few buckets.
* Replaces the ~20-line isAxiosError → 401/403 → ECONNABORTED → ECONNREFUSED

View File

@@ -2,7 +2,7 @@ import type { UUID } from 'crypto'
import { logEvent } from 'src/services/analytics/index.js'
import type { AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS } from 'src/services/analytics/metadata.js'
import { type Command, getCommandName, isCommandEnabled } from '../commands.js'
import { selectableUserMessagesFilter } from './messageFilters.js'
import { selectableUserMessagesFilter } from '../components/MessageSelector.js'
import type { SpinnerMode } from '../components/Spinner/types.js'
import type { QuerySource } from '../constants/querySource.js'
import { expandPastedTextRefs, parseReferences } from '../history.js'

View File

@@ -1,230 +0,0 @@
import { describe, expect, it } from 'bun:test'
import {
splitContext,
applyHybridStrategy,
optimizeForCost,
optimizeForAccuracy,
getHybridStats,
} from './hybridContextStrategy.js'
function createMessage(role: string, content: string, createdAt: number = Date.now()): any {
return {
message: { role, content, id: 'test', type: 'message', created_at: createdAt },
sender: role,
}
}
describe('hybridContextStrategy', () => {
describe('splitContext', () => {
it('splits context into cached and fresh', () => {
const messages = [
createMessage('system', 'System prompt', Date.now() - 86400000),
createMessage('user', 'Hello'),
createMessage('assistant', 'Hi there'),
]
const split = splitContext(messages, {
cacheWeight: 0.4,
freshWeight: 0.6,
maxTotalTokens: 10000,
})
expect(split.cachedTokens).toBeGreaterThanOrEqual(0)
expect(split.freshTokens).toBeGreaterThanOrEqual(0)
expect(split.totalTokens).toBeGreaterThan(0)
})
it('respects weight configuration', () => {
const messages = [
createMessage('system', 'Old system', Date.now() - 86400000),
createMessage('user', 'Recent message', Date.now()),
]
const split = splitContext(messages, {
cacheWeight: 0.5,
freshWeight: 0.5,
maxTotalTokens: 10000,
})
expect(split.cached).toBeDefined()
expect(split.fresh).toBeDefined()
})
})
describe('applyHybridStrategy', () => {
it('applies strategy and returns messages', () => {
const messages = [
createMessage('user', 'Message 1'),
createMessage('assistant', 'Response 1'),
]
const result = applyHybridStrategy(messages, {
cacheWeight: 0.5,
freshWeight: 0.5,
maxTotalTokens: 10000,
})
expect(result.selectedMessages.length).toBeGreaterThan(0)
expect(['cache_heavy', 'fresh_heavy', 'balanced']).toContain(result.strategy)
})
it('calculates estimated cost', () => {
const messages = [
createMessage('user', 'Test message'),
]
const result = applyHybridStrategy(messages, {
cacheWeight: 0.5,
freshWeight: 0.5,
maxTotalTokens: 10000,
})
expect(result.estimatedCost).toBeGreaterThanOrEqual(0)
})
})
describe('optimizeForCost', () => {
it('returns messages within budget', () => {
const messages = [
createMessage('user', 'Message 1'),
createMessage('assistant', 'Response 1'),
]
const result = optimizeForCost(messages, 0.001)
expect(result.length).toBeGreaterThanOrEqual(0)
})
})
describe('optimizeForAccuracy', () => {
it('optimizes for accuracy with token limit', () => {
const messages = [
createMessage('user', 'Message 1'),
createMessage('assistant', 'Response 1'),
]
const result = optimizeForAccuracy(messages, 5000)
expect(result.length).toBeGreaterThan(0)
})
})
describe('getHybridStats', () => {
it('returns statistics', () => {
const messages = [
createMessage('system', 'System', Date.now() - 86400000),
createMessage('user', 'Hello'),
]
const split = splitContext(messages, { cacheWeight: 0.5, freshWeight: 0.5, maxTotalTokens: 10000 })
const stats = getHybridStats(split)
expect(stats.cacheRatio).toBeGreaterThanOrEqual(0)
expect(stats.freshRatio).toBeGreaterThanOrEqual(0)
expect(stats.totalTokens).toBeGreaterThan(0)
})
})
describe('tool_use/tool_result pairing', () => {
it('preserves tool_use and tool_result together', () => {
const toolUseId = 'tool-use-123'
const messages = [
{
type: 'assistant',
uuid: 'uuid-1',
message: {
role: 'assistant',
content: [{ type: 'tool_use', id: toolUseId, name: 'Read' }],
id: 'msg-1',
created_at: 1000,
},
},
{
type: 'user',
uuid: 'uuid-2',
message: {
role: 'user',
content: [{ type: 'tool_result', tool_use_id: toolUseId, content: 'file content' }],
id: 'msg-2',
created_at: 2000,
},
},
{
type: 'assistant',
uuid: 'uuid-3',
message: {
role: 'assistant',
content: 'Response after tool',
id: 'msg-3',
created_at: 3000,
},
},
] as any[]
const result = applyHybridStrategy(messages, {
cacheWeight: 0.5,
freshWeight: 0.5,
maxTotalTokens: 10000,
})
const hasToolUse = result.selectedMessages.some(
m => Array.isArray(m.message?.content) && m.message.content.some((b: any) => b.type === 'tool_use')
)
const hasToolResult = result.selectedMessages.some(
m => Array.isArray(m.message?.content) && m.message.content.some((b: any) => b.type === 'tool_result')
)
expect(hasToolUse).toBe(true)
expect(hasToolResult).toBe(true)
})
it('accounts for large tool_use input in token counting', () => {
const largeInput = 'x'.repeat(5000)
const messages = [
{
type: 'assistant',
message: {
role: 'assistant',
content: [
{ type: 'tool_use', id: 'tu1', name: 'Edit', input: { path: 'test.js', content: largeInput } },
],
created_at: 1000,
},
},
] as any[]
const result = applyHybridStrategy(messages, {
cacheWeight: 0.5,
freshWeight: 0.5,
maxTotalTokens: 20000,
})
expect(result.totalTokens).toBeGreaterThan(1000)
})
it('accounts for large thinking blocks in token counting', () => {
const longThinking = 'Thinking '.repeat(1000)
const messages = [
{
type: 'assistant',
message: {
role: 'assistant',
content: [
{ type: 'thinking', thinking: longThinking },
{ type: 'text', text: 'Final response' },
],
created_at: 1000,
},
},
] as any[]
const result = applyHybridStrategy(messages, {
cacheWeight: 0.5,
freshWeight: 0.5,
maxTotalTokens: 20000,
})
expect(result.totalTokens).toBeGreaterThan(500)
})
})
})

View File

@@ -1,306 +0,0 @@
/**
* Hybrid Context Strategy - Production Grade
*
* Combines cached + new tokens intelligently.
* Optimizes for cost vs accuracy.
*/
import { roughTokenCountEstimation } from '../services/tokenEstimation.js'
import type { Message } from '../types/message.js'
export interface HybridConfig {
cacheWeight: number
freshWeight: number
maxTotalTokens: number
costThreshold?: number
}
export interface ContextSplit {
cached: Message[]
fresh: Message[]
cachedTokens: number
freshTokens: number
totalTokens: number
}
export interface HybridStrategyResult {
selectedMessages: Message[]
totalTokens: number
strategy: 'cache_heavy' | 'fresh_heavy' | 'balanced'
estimatedCost: number
}
const DEFAULT_CONFIG: Required<HybridConfig> = {
cacheWeight: 0.4,
freshWeight: 0.6,
maxTotalTokens: 100000,
costThreshold: 0.01,
}
// Keep enough for: tool_use -> tool_result -> assistant -> user -> next
const MIN_TAILMessages = 5
function getMessageChain(
messages: Message[],
): { chains: Message[][]; orphans: Message[] } {
const toolUseIds = new Set<string>()
const toolUseMessages = new Map<string, Message[]>()
const allMessagesByUuid = new Map<string, Message[]>()
for (const msg of messages) {
const uuid = msg.uuid ?? ''
if (uuid) {
const existing = allMessagesByUuid.get(uuid) ?? []
existing.push(msg)
allMessagesByUuid.set(uuid, existing)
}
const content = msg.message?.content
if (Array.isArray(content)) {
for (const block of content) {
if (block?.type === 'tool_use' && block?.id) {
toolUseIds.add(block.id)
const existing = toolUseMessages.get(block.id) ?? []
existing.push(msg)
toolUseMessages.set(block.id, existing)
}
}
}
}
const chains: Message[][] = []
const orphans: Message[] = []
for (const [toolUseId, msgs] of toolUseMessages) {
const chainMessages: Message[] = [...msgs]
for (const msg of messages) {
const content = msg.message?.content
if (Array.isArray(content)) {
for (const block of content) {
if (block?.type === 'tool_result' && block?.tool_use_id === toolUseId) {
chainMessages.push(msg)
}
}
}
}
chains.push(chainMessages)
}
const chainMessageUuids = new Set<string>()
for (const chain of chains) {
for (const msg of chain) {
if (msg.uuid) chainMessageUuids.add(msg.uuid)
}
}
for (const [uuid, msgs] of allMessagesByUuid) {
if (!chainMessageUuids.has(uuid)) {
orphans.push(...msgs)
}
}
return { chains, orphans }
}
function getCacheAge(message: Message): number {
const created = message.message?.created_at ?? 0
if (created === 0) return 1000
return (Date.now() - created) / (1000 * 60 * 60)
}
function getMessageTokenCount(message: Message): number {
const content = message.message?.content
if (typeof content === 'string') {
return roughTokenCountEstimation(content)
}
if (Array.isArray(content)) {
let tokens = 0
for (const block of content) {
if (typeof block !== 'object' || block === null) continue
const b = block as Record<string, unknown>
if (b.type === 'text' && typeof b.text === 'string') {
tokens += roughTokenCountEstimation(b.text)
} else if (b.type === 'tool_use') {
const inputSize = JSON.stringify(b.input ?? {}).length
tokens += Math.ceil(inputSize / 4) + 20
} else if (b.type === 'tool_result') {
if (typeof b.content === 'string') {
tokens += roughTokenCountEstimation(b.content)
} else if (Array.isArray(b.content)) {
for (const rc of b.content) {
if (typeof rc === 'object' && rc !== null && 'text' in rc) {
tokens += roughTokenCountEstimation((rc as { text: string }).text)
}
}
} else {
tokens += 50
}
if (b.is_error === true) tokens += 10
} else if (b.type === 'thinking' && typeof b.thinking === 'string') {
tokens += roughTokenCountEstimation(b.thinking)
}
}
return tokens
}
return 0
}
function calculateCacheValue(message: Message): number {
const content = typeof message.message?.content === 'string' ? message.message.content : ''
const age = getCacheAge(message)
let value = 0.5
if (content.includes('error') || content.includes('fail')) value += 0.3
if (content.includes('function') || content.includes('class')) value += 0.2
if (content.includes('important') || content.includes('key')) value += 0.15
if (age < 1) value += 0.2
else if (age < 6) value += 0.1
else value -= 0.2
if (message.message?.role === 'system') value += 0.1
return Math.max(0, Math.min(1, value))
}
export function splitContext(
messages: Message[],
config: HybridConfig,
): ContextSplit {
const cfg = { ...DEFAULT_CONFIG, ...config }
const sorted = [...messages].sort((a, b) => {
const aValue = calculateCacheValue(a)
const bValue = calculateCacheValue(b)
return bValue - aValue
})
const cached: Message[] = []
const fresh: Message[] = []
let cachedTokens = 0
let freshTokens = 0
const cacheTarget = Math.floor(cfg.maxTotalTokens * cfg.cacheWeight)
const freshTarget = Math.floor(cfg.maxTotalTokens * cfg.freshWeight)
for (const msg of sorted) {
const tokens = getMessageTokenCount(msg)
const age = getCacheAge(msg)
if (age > 24 && cachedTokens < cacheTarget) {
if (cachedTokens + tokens <= cacheTarget) {
cached.push(msg)
cachedTokens += tokens
continue
}
}
if (freshTokens + tokens <= freshTarget) {
fresh.push(msg)
freshTokens += tokens
}
}
return {
cached,
fresh,
cachedTokens,
freshTokens,
totalTokens: cachedTokens + freshTokens,
}
}
export function applyHybridStrategy(
messages: Message[],
config: HybridConfig,
): HybridStrategyResult {
const cfg = { ...DEFAULT_CONFIG, ...config }
// Preserve message chains (tool_use/tool_result pairs)
const { chains, orphans } = getMessageChain(messages)
// Always preserve the conversation tail (last N messages)
const tailMessages = messages.slice(-MIN_TAILMessages)
const coreMessages = messages.slice(0, -MIN_TAILMessages)
const split = splitContext(coreMessages, cfg)
let strategy: HybridStrategyResult['strategy'] = 'balanced'
if (split.cachedTokens > split.freshTokens * 1.5) {
strategy = 'cache_heavy'
} else if (split.freshTokens > split.cachedTokens * 1.5) {
strategy = 'fresh_heavy'
}
const allSelected = [
...chains.flat(),
...split.cached,
...split.fresh,
...tailMessages
]
const seenUuids = new Set<string>()
const selectedMessages: Message[] = []
for (const msg of allSelected) {
const uuid = msg.uuid ?? msg.message?.id ?? ''
if (!seenUuids.has(uuid)) {
seenUuids.add(uuid)
selectedMessages.push(msg)
}
}
selectedMessages.sort(
(a, b) => (a.message?.created_at ?? 0) - (b.message?.created_at ?? 0)
)
let totalTokens = 0
for (const msg of selectedMessages) {
totalTokens += getMessageTokenCount(msg)
}
const estimatedCost = totalTokens * 0.000001 * 0.5
return {
selectedMessages,
totalTokens,
strategy,
estimatedCost,
}
}
export function optimizeForCost(messages: Message[], budget: number): Message[] {
const result = applyHybridStrategy(messages, {
cacheWeight: 0.7,
freshWeight: 0.3,
maxTotalTokens: Math.floor(budget * 1000),
costThreshold: budget,
})
return result.selectedMessages
}
export function optimizeForAccuracy(messages: Message[], maxTokens: number): Message[] {
const result = applyHybridStrategy(messages, {
cacheWeight: 0.3,
freshWeight: 0.7,
maxTotalTokens: maxTokens,
})
return result.selectedMessages
}
export function getHybridStats(split: ContextSplit) {
const cacheRatio = split.totalTokens > 0 ? split.cachedTokens / split.totalTokens : 0
const freshRatio = split.totalTokens > 0 ? split.freshTokens / split.totalTokens : 0
return {
cacheRatio: Math.round(cacheRatio * 100),
freshRatio: Math.round(freshRatio * 100),
totalTokens: split.totalTokens,
messageCount: split.cached.length + split.fresh.length,
efficiency: split.totalTokens / (split.cachedTokens + split.freshTokens + 1),
}
}

View File

@@ -1,81 +0,0 @@
import type { ContentBlockParam, TextBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'
import type { Message, UserMessage } from '../types/message.js'
import {
BASH_STDERR_TAG,
BASH_STDOUT_TAG,
LOCAL_COMMAND_STDERR_TAG,
LOCAL_COMMAND_STDOUT_TAG,
TASK_NOTIFICATION_TAG,
TEAMMATE_MESSAGE_TAG,
TICK_TAG,
} from '../constants/xml.js'
import { isSyntheticMessage, isToolUseResultMessage } from './messages.js'
function isTextBlock(block: ContentBlockParam): block is TextBlockParam {
return block.type === 'text'
}
export function selectableUserMessagesFilter(message: Message): message is UserMessage {
if (message.type !== 'user') {
return false
}
if (Array.isArray(message.message.content) && message.message.content[0]?.type === 'tool_result') {
return false
}
if (isSyntheticMessage(message)) {
return false
}
if (message.isMeta) {
return false
}
if (message.isCompactSummary || message.isVisibleInTranscriptOnly) {
return false
}
const content = message.message.content
const lastBlock = typeof content === 'string' ? null : content[content.length - 1]
const messageText = typeof content === 'string' ? content.trim() : lastBlock && isTextBlock(lastBlock) ? lastBlock.text.trim() : ''
// Filter out non-user-authored messages (command outputs, task notifications, ticks).
if (messageText.indexOf(`<${LOCAL_COMMAND_STDOUT_TAG}>`) !== -1 || messageText.indexOf(`<${LOCAL_COMMAND_STDERR_TAG}>`) !== -1 || messageText.indexOf(`<${BASH_STDOUT_TAG}>`) !== -1 || messageText.indexOf(`<${BASH_STDERR_TAG}>`) !== -1 || messageText.indexOf(`<${TASK_NOTIFICATION_TAG}>`) !== -1 || messageText.indexOf(`<${TICK_TAG}>`) !== -1 || messageText.indexOf(`<${TEAMMATE_MESSAGE_TAG}`) !== -1) {
return false
}
return true
}
/**
* Checks if all messages after the given index are synthetic (interruptions, cancels, etc.)
* or non-meaningful content. Returns true if there's nothing meaningful to confirm -
* for example, if the user hit enter then immediately cancelled.
*/
export function messagesAfterAreOnlySynthetic(messages: Message[], fromIndex: number): boolean {
for (let i = fromIndex + 1; i < messages.length; i++) {
const msg = messages[i]
if (!msg) continue
// Skip known non-meaningful message types
if (isSyntheticMessage(msg)) continue
if (isToolUseResultMessage(msg)) continue
if (msg.type === 'progress') continue
if (msg.type === 'system') continue
if (msg.type === 'attachment') continue
if (msg.type === 'user' && msg.isMeta) continue
// Assistant with actual content = meaningful
if (msg.type === 'assistant') {
const content = msg.message.content
if (Array.isArray(content)) {
const hasMeaningfulContent = content.some(block => block.type === 'text' && block.text.trim() || block.type === 'tool_use')
if (hasMeaningfulContent) return false
}
continue
}
// User messages that aren't synthetic or meta = meaningful
if (msg.type === 'user') {
return false
}
// Other types (e.g., tombstone) are non-meaningful, continue
}
return true
}

View File

@@ -158,19 +158,6 @@ export const CLAUDE_OPUS_4_6_CONFIG = {
minimax: 'MiniMax-M2.5',
} as const satisfies ModelConfig
export const CLAUDE_OPUS_4_7_CONFIG = {
firstParty: 'claude-opus-4-7',
bedrock: 'us.anthropic.claude-opus-4-7-v1',
vertex: 'claude-opus-4-7',
foundry: 'claude-opus-4-7',
openai: 'gpt-4o',
gemini: 'gemini-2.5-pro',
github: 'github:copilot',
codex: 'gpt-5.5',
'nvidia-nim': 'nvidia/llama-3.1-nemotron-70b-instruct',
minimax: 'MiniMax-M2.5',
} as const satisfies ModelConfig
export const CLAUDE_SONNET_4_6_CONFIG = {
firstParty: 'claude-sonnet-4-6',
bedrock: 'us.anthropic.claude-sonnet-4-6',
@@ -197,7 +184,6 @@ export const ALL_MODEL_CONFIGS = {
opus41: CLAUDE_OPUS_4_1_CONFIG,
opus45: CLAUDE_OPUS_4_5_CONFIG,
opus46: CLAUDE_OPUS_4_6_CONFIG,
opus47: CLAUDE_OPUS_4_7_CONFIG,
} as const satisfies Record<string, ModelConfig>
export type ModelKey = keyof typeof ALL_MODEL_CONFIGS

View File

@@ -83,8 +83,7 @@ export function isNonCustomOpusModel(model: ModelName): boolean {
model === getModelStrings().opus40 ||
model === getModelStrings().opus41 ||
model === getModelStrings().opus45 ||
model === getModelStrings().opus46 ||
model === getModelStrings().opus47
model === getModelStrings().opus46
)
}
@@ -205,12 +204,12 @@ export function getDefaultOpusModel(): ModelName {
return process.env.OPENAI_MODEL || 'grok-4'
}
// 3P providers (Bedrock, Vertex, Foundry) — kept as a separate branch
// since 3P availability lags firstParty and these will diverge again at
// the next model launch. Keep 3P on Opus 4.6 until they roll out 4.7.
// even when values match, since 3P availability lags firstParty and
// these will diverge again at the next model launch.
if (getAPIProvider() !== 'firstParty') {
return getModelStrings().opus46
}
return getModelStrings().opus47
return getModelStrings().opus46
}
// @[MODEL LAUNCH]: Update the default Sonnet model (3P providers may lag so keep defaults unchanged).
@@ -408,10 +407,7 @@ export function getDefaultMainLoopModel(): ModelName {
export function firstPartyNameToCanonical(name: ModelName): ModelShortName {
name = name.toLowerCase()
// Special cases for Claude 4+ models to differentiate versions
// Order matters: check more specific versions first (4-7 before 4-6 before 4-5 before 4)
if (name.includes('claude-opus-4-7')) {
return 'claude-opus-4-7'
}
// Order matters: check more specific versions first (4-5 before 4)
if (name.includes('claude-opus-4-6')) {
return 'claude-opus-4-6'
}
@@ -482,9 +478,9 @@ export function getClaudeAiUserDefaultModelDescription(
): string {
if (isMaxSubscriber() || isTeamPremiumSubscriber()) {
if (isOpus1mMergeEnabled()) {
return `Opus 4.7 with 1M context · Most capable for complex work${fastMode ? getOpus46PricingSuffix(true) : ''}`
return `Opus 4.6 with 1M context · Most capable for complex work${fastMode ? getOpus46PricingSuffix(true) : ''}`
}
return `Opus 4.7 · Most capable for complex work${fastMode ? getOpus46PricingSuffix(true) : ''}`
return `Opus 4.6 · Most capable for complex work${fastMode ? getOpus46PricingSuffix(true) : ''}`
}
return 'Sonnet 4.6 · Best for everyday tasks'
}
@@ -493,7 +489,7 @@ export function renderDefaultModelSetting(
setting: ModelName | ModelAlias,
): string {
if (setting === 'opusplan') {
return 'Opus 4.7 in plan mode, else Sonnet 4.6'
return 'Opus 4.6 in plan mode, else Sonnet 4.6'
}
return renderModelName(parseUserSpecifiedModel(setting))
}
@@ -586,14 +582,10 @@ export function getPublicModelDisplayName(model: ModelName): string | null {
return 'GPT-5.4'
case 'gpt-5.3-codex-spark':
return 'GPT-5.3 Codex Spark'
case getModelStrings().opus47 + '[1m]':
return 'Opus 4.7 (1M context)'
case getModelStrings().opus47:
return 'Opus 4.7'
case getModelStrings().opus46 + '[1m]':
return 'Opus 4.6 (1M context)'
case getModelStrings().opus46:
return 'Opus 4.6'
case getModelStrings().opus46 + '[1m]':
return 'Opus 4.6 (1M context)'
case getModelStrings().opus45:
return 'Opus 4.5'
case getModelStrings().opus41:
@@ -833,9 +825,6 @@ export function getMarketingNameForModel(modelId: string): string | undefined {
const has1m = modelId.toLowerCase().includes('[1m]')
const canonical = getCanonicalName(modelId)
if (canonical.includes('claude-opus-4-7')) {
return has1m ? 'Opus 4.7 (with 1M context)' : 'Opus 4.7'
}
if (canonical.includes('claude-opus-4-6')) {
return has1m ? 'Opus 4.6 (with 1M context)' : 'Opus 4.6'
}

View File

@@ -159,16 +159,6 @@ function getOpus41Option(): ModelOption {
}
}
function getOpus47Option(fastMode = false): ModelOption {
const is3P = getAPIProvider() !== 'firstParty'
return {
value: is3P ? getModelStrings().opus47 : 'opus',
label: 'Opus',
description: `Opus 4.7 · Most capable for complex work${getOpus46PricingSuffix(fastMode)}`,
descriptionForModel: 'Opus 4.7 - most capable for complex work',
}
}
function getOpus46Option(fastMode = false): ModelOption {
const is3P = getAPIProvider() !== 'firstParty'
return {
@@ -251,7 +241,7 @@ function getMaxOpusOption(fastMode = false): ModelOption {
return {
value: 'opus',
label: 'Opus',
description: `Opus 4.7 · Most capable for complex work${fastMode ? getOpus46PricingSuffix(true) : ''}`,
description: `Opus 4.6 · Most capable for complex work${fastMode ? getOpus46PricingSuffix(true) : ''}`,
}
}
@@ -279,9 +269,9 @@ function getMergedOpus1MOption(fastMode = false): ModelOption {
return {
value: is3P ? getModelStrings().opus46 + '[1m]' : 'opus[1m]',
label: 'Opus (1M context)',
description: `${is3P ? 'Opus 4.6' : 'Opus 4.7'} with 1M context · Most capable for complex work${!is3P && fastMode ? getOpus46PricingSuffix(fastMode) : ''}`,
description: `Opus 4.6 with 1M context · Most capable for complex work${!is3P && fastMode ? getOpus46PricingSuffix(fastMode) : ''}`,
descriptionForModel:
`${is3P ? 'Opus 4.6' : 'Opus 4.7'} with 1M context - most capable for complex work`,
'Opus 4.6 with 1M context - most capable for complex work',
}
}
@@ -301,7 +291,7 @@ function getOpusPlanOption(): ModelOption {
return {
value: 'opusplan',
label: 'Opus Plan Mode',
description: 'Use Opus 4.7 in plan mode, Sonnet 4.6 otherwise',
description: 'Use Opus 4.6 in plan mode, Sonnet 4.6 otherwise',
}
}
@@ -514,7 +504,7 @@ function getModelOptionsBase(fastMode = false): ModelOption[] {
}
}
// PAYG 1P API: Default (Sonnet) + Sonnet 1M + Opus 4.7 + Opus 4.6 + Opus 1M + Haiku
// PAYG 1P API: Default (Sonnet) + Sonnet 1M + Opus 4.6 + Opus 1M + Haiku
if (getAPIProvider() === 'firstParty') {
const payg1POptions = [getDefaultOptionForUser(fastMode)]
if (checkSonnet1mAccess()) {
@@ -523,7 +513,6 @@ function getModelOptionsBase(fastMode = false): ModelOption[] {
if (isOpus1mMergeEnabled()) {
payg1POptions.push(getMergedOpus1MOption(fastMode))
} else {
payg1POptions.push(getOpus47Option(fastMode))
payg1POptions.push(getOpus46Option(fastMode))
if (checkOpus1mAccess()) {
payg1POptions.push(getOpus46_1MOption(fastMode))
@@ -557,9 +546,8 @@ function getModelOptionsBase(fastMode = false): ModelOption[] {
if (customOpus !== undefined) {
payg3pOptions.push(customOpus)
} else {
// Add Opus 4.1, Opus 4.7, Opus 4.6 and Opus 4.6 1M
// Add Opus 4.1, Opus 4.6 and Opus 4.6 1M
payg3pOptions.push(getOpus41Option()) // This is the default opus
payg3pOptions.push(getOpus47Option(fastMode))
payg3pOptions.push(getOpus46Option(fastMode))
if (checkOpus1mAccess()) {
payg3pOptions.push(getOpus46_1MOption(fastMode))

View File

@@ -23,23 +23,6 @@ const TIERS = [
},
] as const
function buildCapabilityOverrideCacheKey(
model: string,
capability: ModelCapabilityOverride,
): string {
const envParts = TIERS.flatMap(tier => [
process.env[tier.modelEnvVar] ?? '',
process.env[tier.capabilitiesEnvVar] ?? '',
])
return [
model.toLowerCase(),
capability,
getAPIProvider(),
...envParts,
].join('\0')
}
/**
* Check whether a 3p model capability override is set for a model that matches one of
* the pinned ANTHROPIC_DEFAULT_*_MODEL env vars.
@@ -63,5 +46,5 @@ export const get3PModelCapabilityOverride = memoize(
}
return undefined
},
buildCapabilityOverrideCacheKey,
(model, capability) => `${model.toLowerCase()}:${capability}`,
)

View File

@@ -202,9 +202,6 @@ function get3PFallbackSuggestion(model: string): string | undefined {
return undefined
}
const lowerModel = model.toLowerCase()
if (lowerModel.includes('opus-4-7') || lowerModel.includes('opus_4_7')) {
return getModelStrings().opus46
}
if (lowerModel.includes('opus-4-6') || lowerModel.includes('opus_4_6')) {
return getModelStrings().opus41
}

View File

@@ -11,7 +11,6 @@ import {
CLAUDE_OPUS_4_1_CONFIG,
CLAUDE_OPUS_4_5_CONFIG,
CLAUDE_OPUS_4_6_CONFIG,
CLAUDE_OPUS_4_7_CONFIG,
CLAUDE_OPUS_4_CONFIG,
CLAUDE_SONNET_4_5_CONFIG,
CLAUDE_SONNET_4_6_CONFIG,
@@ -124,8 +123,6 @@ export const MODEL_COSTS: Record<ModelShortName, ModelCosts> = {
COST_TIER_5_25,
[firstPartyNameToCanonical(CLAUDE_OPUS_4_6_CONFIG.firstParty)]:
COST_TIER_5_25,
[firstPartyNameToCanonical(CLAUDE_OPUS_4_7_CONFIG.firstParty)]:
COST_TIER_5_25,
}
/**

View File

@@ -5,15 +5,16 @@ import { resolveRipgrepConfig, wrapRipgrepUnavailableError } from './ripgrep.js'
const MOCK_BUILTIN_PATH = path.normalize(
process.platform === 'win32'
? `node_modules/@vscode/ripgrep/bin/rg.exe`
: `node_modules/@vscode/ripgrep/bin/rg`,
? `vendor/ripgrep/${process.arch}-win32/rg.exe`
: `vendor/ripgrep/${process.arch}-${process.platform}/rg`,
)
test('falls back to system rg when @vscode/ripgrep cannot be resolved', () => {
test('ripgrepCommand falls back to system rg when builtin binary is missing', () => {
const config = resolveRipgrepConfig({
userWantsSystemRipgrep: false,
bundledMode: false,
builtinCommand: null,
builtinCommand: MOCK_BUILTIN_PATH,
builtinExists: false,
systemExecutablePath: '/usr/bin/rg',
processExecPath: '/fake/bun',
})
@@ -25,11 +26,12 @@ test('falls back to system rg when @vscode/ripgrep cannot be resolved', () => {
})
})
test('uses builtin @vscode/ripgrep path when the package resolves', () => {
test('ripgrepCommand keeps builtin mode when bundled binary exists', () => {
const config = resolveRipgrepConfig({
userWantsSystemRipgrep: false,
bundledMode: false,
builtinCommand: MOCK_BUILTIN_PATH,
builtinExists: true,
systemExecutablePath: '/usr/bin/rg',
processExecPath: '/fake/bun',
})
@@ -41,59 +43,10 @@ test('uses builtin @vscode/ripgrep path when the package resolves', () => {
})
})
test('honors USE_BUILTIN_RIPGREP=0 by selecting system rg even when builtin is available', () => {
const config = resolveRipgrepConfig({
userWantsSystemRipgrep: true,
bundledMode: false,
builtinCommand: MOCK_BUILTIN_PATH,
systemExecutablePath: '/usr/bin/rg',
processExecPath: '/fake/bun',
})
expect(config).toMatchObject({
mode: 'system',
command: 'rg',
args: [],
})
})
test('keeps embedded mode for Bun-compiled standalone executables', () => {
const config = resolveRipgrepConfig({
userWantsSystemRipgrep: false,
bundledMode: true,
builtinCommand: null,
systemExecutablePath: '/usr/bin/rg',
processExecPath: '/opt/openclaude/bin/openclaude',
})
expect(config).toMatchObject({
mode: 'embedded',
command: '/opt/openclaude/bin/openclaude',
args: ['--no-config'],
argv0: 'rg',
})
})
test('falls through to system rg as a last resort even when not on PATH', () => {
const config = resolveRipgrepConfig({
userWantsSystemRipgrep: false,
bundledMode: false,
builtinCommand: null,
systemExecutablePath: 'rg',
processExecPath: '/fake/bun',
})
expect(config).toMatchObject({
mode: 'system',
command: 'rg',
args: [],
})
})
test('wrapRipgrepUnavailableError explains missing packaged fallback', () => {
const error = wrapRipgrepUnavailableError(
{ code: 'ENOENT', message: 'spawn rg ENOENT' },
{ mode: 'builtin', command: 'C:\\fake\\node_modules\\@vscode\\ripgrep\\bin\\rg.exe', args: [] },
{ mode: 'builtin', command: 'C:\\fake\\vendor\\ripgrep\\rg.exe', args: [] },
'win32',
)

View File

@@ -5,6 +5,7 @@ import memoize from 'lodash-es/memoize.js'
import { homedir } from 'os'
import * as path from 'path'
import { logEvent } from 'src/services/analytics/index.js'
import { fileURLToPath } from 'url'
import { isInBundledMode } from './bundledMode.js'
import { logForDebugging } from './debug.js'
import { isEnvDefinedFalsy } from './envUtils.js'
@@ -14,6 +15,13 @@ import { logError } from './log.js'
import { getPlatform } from './platform.js'
import { countCharInString } from './stringUtils.js'
const __filename = fileURLToPath(import.meta.url)
// we use node:path.join instead of node:url.resolve because the former doesn't encode spaces
const __dirname = path.join(
__filename,
process.env.NODE_ENV === 'test' ? '../../../' : '../',
)
type RipgrepConfig = {
mode: 'system' | 'builtin' | 'embedded'
command: string
@@ -27,31 +35,11 @@ function isErrnoException(error: unknown): error is NodeJS.ErrnoException {
return error instanceof Error
}
/**
* Returns the ripgrep binary path provided by the @vscode/ripgrep package.
* The package downloads a platform/arch-specific binary at npm install time
* (cached under the package's bin/ directory). Returns null when the package
* cannot be resolved — for example when running as a Bun-compiled standalone
* executable that doesn't ship node_modules.
*/
function resolveBuiltinRgPath(): string | null {
try {
// Lazy require so the resolution failure path stays graceful at import
// time. The package only exports `rgPath`, so we do not need the rest.
const mod = require('@vscode/ripgrep') as { rgPath?: string }
if (mod.rgPath && existsSync(mod.rgPath)) {
return mod.rgPath
}
} catch {
// Falls through to null — caller decides the fallback.
}
return null
}
type ResolveRipgrepConfigArgs = {
userWantsSystemRipgrep: boolean
bundledMode: boolean
builtinCommand: string | null
builtinCommand: string
builtinExists: boolean
systemExecutablePath: string
processExecPath?: string
}
@@ -60,6 +48,7 @@ export function resolveRipgrepConfig({
userWantsSystemRipgrep,
bundledMode,
builtinCommand,
builtinExists,
systemExecutablePath,
processExecPath = process.execPath,
}: ResolveRipgrepConfigArgs): RipgrepConfig {
@@ -77,7 +66,7 @@ export function resolveRipgrepConfig({
}
}
if (builtinCommand) {
if (builtinExists) {
return { mode: 'builtin', command: builtinCommand, args: [] }
}
@@ -85,9 +74,7 @@ export function resolveRipgrepConfig({
return { mode: 'system', command: 'rg', args: [] }
}
// Last resort — leaves error reporting to the executor when no binary
// can be located. wrapRipgrepUnavailableError() surfaces an install hint.
return { mode: 'system', command: 'rg', args: [] }
return { mode: 'builtin', command: builtinCommand, args: [] }
}
const getRipgrepConfig = memoize((): RipgrepConfig => {
@@ -95,13 +82,19 @@ const getRipgrepConfig = memoize((): RipgrepConfig => {
process.env.USE_BUILTIN_RIPGREP,
)
const bundledMode = isInBundledMode()
const builtinCommand = resolveBuiltinRgPath()
const rgRoot = path.resolve(__dirname, 'vendor', 'ripgrep')
const builtinCommand =
process.platform === 'win32'
? path.resolve(rgRoot, `${process.arch}-win32`, 'rg.exe')
: path.resolve(rgRoot, `${process.arch}-${process.platform}`, 'rg')
const builtinExists = existsSync(builtinCommand)
const { cmd: systemExecutablePath } = findExecutable('rg', [])
return resolveRipgrepConfig({
userWantsSystemRipgrep,
bundledMode,
builtinCommand,
builtinExists,
systemExecutablePath,
})
})

View File

@@ -97,22 +97,13 @@ describe("Secure Storage Platform Implementations", () => {
expect(options2.input).toContain("token'quote");
});
test("delete() skips legacy PasswordVault by default", () => {
windowsCredentialStorage.delete();
expect(mockExecaSync).toHaveBeenCalledTimes(1);
const script = mockExecaSync.mock.calls[0][1][1];
expect(script).not.toContain("System.Runtime.WindowsRuntime");
});
test("delete() includes legacy assembly load when explicitly enabled", () => {
process.env.OPENCLAUDE_ENABLE_LEGACY_WINDOWS_PASSWORDVAULT = "1";
test("delete() includes assembly load", () => {
windowsCredentialStorage.delete();
const script = mockExecaSync.mock.calls[1][1][1];
expect(script).toContain("Add-Type -AssemblyName System.Runtime.WindowsRuntime");
});
test("escapes double quotes in username", () => {
process.env.OPENCLAUDE_ENABLE_LEGACY_WINDOWS_PASSWORDVAULT = "1";
process.env.USER = 'user"name';
windowsCredentialStorage.read();
const script = mockExecaSync.mock.calls[1][1][1];
@@ -120,17 +111,7 @@ describe("Secure Storage Platform Implementations", () => {
expect(script).not.toContain('user"name');
});
test("read() does not touch legacy PasswordVault by default", () => {
mockExecaSync.mockImplementationOnce(() => ({ exitCode: 1, stdout: "" }));
const result = windowsCredentialStorage.read();
expect(result).toBeNull();
expect(mockExecaSync).toHaveBeenCalledTimes(1);
});
test("read() falls back to legacy PasswordVault when explicitly enabled", () => {
process.env.OPENCLAUDE_ENABLE_LEGACY_WINDOWS_PASSWORDVAULT = "1";
test("read() falls back to legacy PasswordVault when the DPAPI payload is invalid JSON", () => {
mockExecaSync
.mockImplementationOnce(() => ({ exitCode: 0, stdout: "{not-json" }))
.mockImplementationOnce(() => ({
@@ -145,7 +126,6 @@ describe("Secure Storage Platform Implementations", () => {
});
test("read() fails closed when the legacy PasswordVault payload is invalid JSON", () => {
process.env.OPENCLAUDE_ENABLE_LEGACY_WINDOWS_PASSWORDVAULT = "1";
mockExecaSync
.mockImplementationOnce(() => ({ exitCode: 1, stdout: "" }))
.mockImplementationOnce(() => ({ exitCode: 0, stdout: "{not-json" }));

View File

@@ -30,10 +30,6 @@ function getWindowsSecureStorageFilePath(): string {
return join(getClaudeConfigHomeDir(), `${resourceName}.secure.dpapi`)
}
function shouldUseLegacyPasswordVault(): boolean {
return process.env.OPENCLAUDE_ENABLE_LEGACY_WINDOWS_PASSWORDVAULT === '1'
}
function runPowerShell(
script: string,
options?: { input?: string },
@@ -65,10 +61,6 @@ function getFailureWarning(
}
function readLegacyPasswordVault(): SecureStorageData | null {
if (!shouldUseLegacyPasswordVault()) {
return null
}
const resourceName = getLegacyResourceName().replace(/"/g, '`"')
const username = getUsername().replace(/"/g, '`"')
const script = `
@@ -212,23 +204,21 @@ export const windowsCredentialStorage: SecureStorage = {
`
const removeDpapiResult = runPowerShell(removeDpapiScript)
if (shouldUseLegacyPasswordVault()) {
const resourceName = getLegacyResourceName().replace(/"/g, '`"')
const username = getUsername().replace(/"/g, '`"')
const removeLegacyScript = `
Add-Type -AssemblyName System.Runtime.WindowsRuntime
try {
$vault = New-Object Windows.Security.Credentials.PasswordVault
$cred = $vault.Retrieve("${resourceName}", "${username}")
$vault.Remove($cred)
} catch {
exit 0
}
`
const removeLegacyResult = runPowerShell(removeLegacyScript)
const resourceName = getLegacyResourceName().replace(/"/g, '`"')
const username = getUsername().replace(/"/g, '`"')
const removeLegacyScript = `
Add-Type -AssemblyName System.Runtime.WindowsRuntime
try {
$vault = New-Object Windows.Security.Credentials.PasswordVault
$cred = $vault.Retrieve("${resourceName}", "${username}")
$vault.Remove($cred)
} catch {
exit 0
}
`
const removeLegacyResult = runPowerShell(removeLegacyScript)
void removeLegacyResult
}
void removeLegacyResult
return (removeDpapiResult?.exitCode ?? 1) === 0
},

View File

@@ -1,165 +0,0 @@
import { describe, expect, it } from 'bun:test'
import { StreamingTokenCounter } from './streamingTokenCounter.js'
describe('StreamingTokenCounter', () => {
describe('start', () => {
it('resets state and sets input tokens', () => {
const counter = new StreamingTokenCounter()
counter.start(1000)
expect(counter.total).toBe(1000)
})
})
describe('addChunk', () => {
it('accumulates content', () => {
const counter = new StreamingTokenCounter()
counter.start(500)
counter.addChunk('Hello world ')
expect(counter.characterCount).toBe(12)
})
it('accumulates multiple chunks', () => {
const counter = new StreamingTokenCounter()
counter.start(500)
counter.addChunk('Hello ')
counter.addChunk('world ')
expect(counter.characterCount).toBeGreaterThanOrEqual(10)
})
it('handles empty chunks', () => {
const counter = new StreamingTokenCounter()
counter.start(50)
counter.addChunk(undefined)
counter.addChunk('')
expect(counter.output).toBe(0)
expect(counter.total).toBe(50)
})
it('updates cached token count at word boundaries during streaming', () => {
const counter = new StreamingTokenCounter()
counter.start(100)
counter.addChunk('Hello ')
const afterFirst = counter.output
expect(afterFirst).toBeGreaterThan(0)
counter.addChunk('world ')
const afterSecond = counter.output
expect(afterSecond).toBeGreaterThan(afterFirst)
})
it('advances count past space after word boundary', () => {
const counter = new StreamingTokenCounter()
counter.start()
counter.addChunk('Hello ') // counts Hello
const count1 = counter.output
counter.addChunk('world') // short chunk, no space - shouldn't advance
const count2 = counter.output
expect(count2).toBe(count1)
counter.addChunk(' ') // space triggers count
const count3 = counter.output
expect(count3).toBeGreaterThan(count2)
})
})
describe('finalize', () => {
it('counts all content after finalize', () => {
const counter = new StreamingTokenCounter()
counter.start(500)
counter.addChunk('Hello world')
counter.finalize()
expect(counter.output).toBeGreaterThan(0)
})
it('counts tokens after finalize', () => {
const counter = new StreamingTokenCounter()
counter.start(100)
counter.addChunk('Hello ')
counter.addChunk('world ')
counter.finalize()
expect(counter.output).toBeGreaterThan(0)
expect(counter.total).toBe(100 + counter.output)
})
})
describe('total', () => {
it('sums input and output after finalize', () => {
const counter = new StreamingTokenCounter()
counter.start(500)
counter.addChunk('Test content ')
counter.finalize()
expect(counter.total).toBeGreaterThanOrEqual(500)
})
})
describe('tokensPerSecond', () => {
it('calculates tokens per second', () => {
const counter = new StreamingTokenCounter()
counter.start()
counter.addChunk('123456789 ')
expect(typeof counter.tokensPerSecond).toBe('number')
})
})
describe('estimateRemainingTokens', () => {
it('returns positive when under target', () => {
const counter = new StreamingTokenCounter()
counter.start(500)
counter.addChunk('Hello ')
counter.finalize()
expect(counter.estimateRemainingTokens(1000)).toBeGreaterThan(0)
})
it('returns 0 when at or over target', () => {
const counter = new StreamingTokenCounter()
counter.start(500)
counter.addChunk('Hello ')
counter.finalize()
expect(counter.estimateRemainingTokens(1)).toBe(0)
})
})
describe('estimateRemainingTimeMs', () => {
it('returns estimate based on rate', () => {
const counter = new StreamingTokenCounter()
counter.start()
counter.addChunk('Hello world ')
expect(counter.estimateRemainingTimeMs(100)).toBeGreaterThanOrEqual(0)
})
})
describe('characterCount', () => {
it('returns accumulated character count', () => {
const counter = new StreamingTokenCounter()
counter.addChunk('Hello')
expect(counter.characterCount).toBe(5)
})
it('accumulates content from chunks', () => {
const counter = new StreamingTokenCounter()
counter.start(100)
counter.addChunk('Hello ')
counter.addChunk('world ')
expect(counter.characterCount).toBeGreaterThan(0)
})
})
describe('reset', () => {
it('clears all state', () => {
const counter = new StreamingTokenCounter()
counter.start(500)
counter.addChunk('Hello world ')
counter.reset()
expect(counter.characterCount).toBe(0)
})
it('resets correctly', () => {
const counter = new StreamingTokenCounter()
counter.start(100)
counter.addChunk('test ')
counter.reset()
expect(counter.characterCount).toBe(0)
expect(counter.total).toBe(0)
})
})
})

View File

@@ -1,133 +0,0 @@
/**
* Streaming Token Counter - Accurate token counting during generation
*
* Accumulates raw content and counts tokens at consistent boundaries
* to avoid dependency on arbitrary chunk boundaries.
*/
import { roughTokenCountEstimation } from '../services/tokenEstimation.js'
export class StreamingTokenCounter {
private inputTokens = 0
private accumulatedContent = ''
private lastCountedIndex = 0
private cachedOutputTokens = 0
private startTime = 0
/**
* Start tracking a new stream
* @param initialInputTokens - Token count for system prompt + history
*/
start(initialInputTokens?: number): void {
this.reset()
this.startTime = Date.now()
this.inputTokens = initialInputTokens ?? 0
}
/**
* Add content from a streaming chunk
* Accumulates raw content, counting only at word boundaries
* to avoid instability from arbitrary chunk boundaries.
*/
addChunk(deltaContent?: string): void {
if (deltaContent) {
this.accumulatedContent += deltaContent
this.recountAtWordBoundary()
}
}
/**
* Recount tokens at word boundaries for stability.
* Only counts after whitespace to avoid mid-word splits.
*/
private recountAtWordBoundary(): void {
const content = this.accumulatedContent
const unprocessedContent = content.slice(this.lastCountedIndex)
const searchStart = unprocessedContent[0] === ' ' ? 1 : 0
const nextSpaceIndex = unprocessedContent.indexOf(' ', searchStart)
const shouldCount =
nextSpaceIndex > 0 ||
unprocessedContent.length > 50 ||
unprocessedContent.length === 0
let boundaryIndex: number
if (nextSpaceIndex > 0) {
boundaryIndex = this.lastCountedIndex + nextSpaceIndex
} else if (unprocessedContent.length > 50) {
boundaryIndex = content.length
} else {
return
}
const toCount = content.slice(0, boundaryIndex)
this.cachedOutputTokens = roughTokenCountEstimation(toCount)
this.lastCountedIndex = boundaryIndex
}
/**
* Flush remaining content and finalize count.
* Call this when stream completes.
*/
finalize(): number {
if (this.accumulatedContent.length > this.lastCountedIndex) {
this.cachedOutputTokens = roughTokenCountEstimation(this.accumulatedContent)
this.lastCountedIndex = this.accumulatedContent.length
}
return this.cachedOutputTokens
}
/** Get total tokens (input + output) */
get total(): number {
return this.inputTokens + this.cachedOutputTokens
}
/** Get output tokens only */
get output(): number {
return this.cachedOutputTokens
}
/** Get elapsed time in milliseconds */
get elapsedMs(): number {
return this.startTime > 0 ? Date.now() - this.startTime : 0
}
/** Get tokens per second generation rate */
get tokensPerSecond(): number {
if (this.elapsedMs === 0) return 0
return (this.cachedOutputTokens / this.elapsedMs) * 1000
}
/** Get estimated total generation time based on current rate */
getEstimatedGenerationTimeMs(): number {
if (this.tokensPerSecond === 0) return 0
return Math.round((this.cachedOutputTokens / this.tokensPerSecond) * 1000)
}
/** Estimate remaining tokens until target output size */
estimateRemainingTokens(targetOutputTokens: number): number {
return Math.max(0, targetOutputTokens - this.cachedOutputTokens)
}
/** Estimate remaining time based on target output tokens */
estimateRemainingTimeMs(targetOutputTokens: number): number {
if (this.tokensPerSecond === 0) return 0
const remaining = this.estimateRemainingTokens(targetOutputTokens)
return Math.round((remaining / this.tokensPerSecond) * 1000)
}
/** Get character count for raw content */
get characterCount(): number {
return this.accumulatedContent.length
}
/** Reset counter */
reset(): void {
this.inputTokens = 0
this.accumulatedContent = ''
this.lastCountedIndex = 0
this.cachedOutputTokens = 0
this.startTime = 0
}
}

View File

@@ -1,12 +1,5 @@
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
import { resetSettingsCache } from './settings/settingsCache.js'
mock.module('./model/providers.js', () => ({
getAPIProvider: () =>
process.env.CLAUDE_CODE_USE_OPENAI === '1' ? 'openai' : 'firstParty',
}))
const { modelSupportsThinking } = await import('./thinking.js')
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import { modelSupportsThinking } from './thinking.js'
const ENV_KEYS = [
'CLAUDE_CODE_USE_OPENAI',
@@ -21,13 +14,6 @@ const ENV_KEYS = [
'OPENAI_MODEL',
'NVIDIA_NIM',
'MINIMAX_API_KEY',
'XAI_API_KEY',
'ANTHROPIC_DEFAULT_OPUS_MODEL',
'ANTHROPIC_DEFAULT_OPUS_MODEL_SUPPORTED_CAPABILITIES',
'ANTHROPIC_DEFAULT_SONNET_MODEL',
'ANTHROPIC_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES',
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
'ANTHROPIC_DEFAULT_HAIKU_MODEL_SUPPORTED_CAPABILITIES',
'USER_TYPE',
]
@@ -38,7 +24,6 @@ beforeEach(() => {
originalEnv[key] = process.env[key]
delete process.env[key]
}
resetSettingsCache()
})
afterEach(() => {
@@ -49,7 +34,6 @@ afterEach(() => {
process.env[key] = originalEnv[key]
}
}
resetSettingsCache()
})
describe('modelSupportsThinking — Z.AI GLM', () => {
@@ -77,19 +61,4 @@ describe('modelSupportsThinking — Z.AI GLM', () => {
expect(modelSupportsThinking('glm-50')).toBe(false)
})
test('does not reuse stale capability overrides after env changes', () => {
process.env.CLAUDE_CODE_USE_OPENAI = '1'
process.env.OPENAI_BASE_URL = 'https://dashscope.aliyuncs.com/compatible-mode/v1'
process.env.ANTHROPIC_DEFAULT_SONNET_MODEL = 'GLM-5.1'
process.env.ANTHROPIC_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES = ''
expect(modelSupportsThinking('GLM-5.1')).toBe(false)
delete process.env.ANTHROPIC_DEFAULT_SONNET_MODEL
delete process.env.ANTHROPIC_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES
process.env.OPENAI_BASE_URL = 'https://api.z.ai/api/coding/paas/v4'
expect(modelSupportsThinking('GLM-5.1')).toBe(true)
})
})
})

View File

@@ -131,7 +131,7 @@ export function modelSupportsAdaptiveThinking(model: string): boolean {
}
const canonical = getCanonicalName(model)
// Supported by a subset of Claude 4 models
if (canonical.includes('opus-4-7') || canonical.includes('opus-4-6') || canonical.includes('sonnet-4-6')) {
if (canonical.includes('opus-4-6') || canonical.includes('sonnet-4-6')) {
return true
}
// Exclude any other known legacy models (allowlist above catches 4-6 variants first)

View File

@@ -10,12 +10,9 @@ function installCommonMocks(options?: {
oauthEmail?: string
gitEmail?: string
}) {
// NOTE: Do NOT mock ../bootstrap/state.js here.
// mock.module() is process-global in bun:test and mock.restore() does NOT
// undo it. Mocking state.js leaks getSessionId = () => 'session-test' into
// every other test file that imports state.js (e.g. SDK CON-1 tests).
// The dynamic import (importFreshUserModule) will use the real state.js,
// which is fine — these tests only assert email, not sessionId.
mock.module('../bootstrap/state.js', () => ({
getSessionId: () => 'session-test',
}))
mock.module('./auth.js', () => ({
getOauthAccountInfo: () =>

View File

@@ -1,54 +0,0 @@
/**
* Shared validation utilities for SDK-facing APIs.
*/
/**
* Validate an array of items using a per-item validator.
* Throws TypeError with the index and missing field if validation fails.
*/
export function validateArrayOf<T>(
items: unknown[],
validator: (item: unknown, index: number) => T,
label: string,
): T[] {
if (!Array.isArray(items)) {
throw new TypeError(`${label}: expected an array, got ${typeof items}`)
}
return items.map((item, i) => {
try {
return validator(item, i)
} catch (err) {
if (err instanceof TypeError) {
throw new TypeError(`${label}: item at index ${i} - ${err.message}`)
}
throw err
}
})
}
/**
* Assert that a value is a non-empty string.
*/
export function assertNonEmptyString(value: unknown, field: string): asserts value is string {
if (typeof value !== 'string' || value.length === 0) {
throw new TypeError(`missing or empty '${field}' (expected non-empty string)`)
}
}
/**
* Assert that a value is a non-null object (but not an array).
*/
export function assertObject(value: unknown, field: string): asserts value is Record<string, unknown> {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
throw new TypeError(`missing or invalid '${field}' (expected object)`)
}
}
/**
* Assert that a value is a function.
*/
export function assertFunction(value: unknown, field: string): asserts value is (...args: any[]) => any {
if (typeof value !== 'function') {
throw new TypeError(`missing or invalid '${field}' (expected function)`)
}
}

View File

@@ -1,279 +0,0 @@
import { describe, test, expect } from 'bun:test'
import {
SDKAssistantMessageSchema,
SDKSystemMessageSchema,
SDKCompactBoundaryMessageSchema,
SDKMessageSchema,
SDKUserMessageSchema,
SDKResultMessageSchema,
SDKResultSuccessSchema,
SDKResultErrorSchema,
SDKSessionInfoSchema,
PermissionModeSchema,
ThinkingConfigSchema,
AgentDefinitionSchema,
McpServerStatusSchema,
ModelUsageSchema,
FastModeStateSchema,
HookInputSchema,
ExitReasonSchema,
} from '../../src/entrypoints/sdk/coreSchemas.js'
import { z } from 'zod/v4'
/**
* Tests for generated SDK types from Zod schemas.
*
* These tests verify that:
* 1. All schemas materialize correctly (no lazy errors)
* 2. Schemas can parse valid data
* 3. Key discriminated fields are correct
* 4. The full SDKMessage union accepts all message variants
*/
describe('SDK Zod schemas (type generation source)', () => {
test('SDKAssistantMessageSchema accepts valid data', () => {
const schema = SDKAssistantMessageSchema()
const result = schema.safeParse({
type: 'assistant',
message: { role: 'assistant', content: [{ type: 'text', text: 'hi' }] },
parent_tool_use_id: null,
uuid: '12345678-1234-1234-1234-123456789012',
session_id: '12345678-1234-1234-1234-123456789012',
})
expect(result.success).toBe(true)
})
test('SDKSystemMessageSchema accepts valid data', () => {
const schema = SDKSystemMessageSchema()
const result = schema.safeParse({
type: 'system',
subtype: 'init',
apiKeySource: 'user',
claude_code_version: '0.3.0',
cwd: '/home/user/project',
tools: ['Read', 'Write'],
mcp_servers: [{ name: 'test', status: 'connected' }],
model: 'claude-sonnet-4-6',
permissionMode: 'default',
slash_commands: [],
output_style: 'default',
skills: [],
plugins: [],
uuid: '12345678-1234-1234-1234-123456789012',
session_id: '12345678-1234-1234-1234-123456789012',
})
expect(result.success).toBe(true)
})
test('SDKCompactBoundaryMessageSchema accepts valid data', () => {
const schema = SDKCompactBoundaryMessageSchema()
const result = schema.safeParse({
type: 'system',
subtype: 'compact_boundary',
compact_metadata: {
trigger: 'manual',
pre_tokens: 1000,
},
uuid: '12345678-1234-1234-1234-123456789012',
session_id: '12345678-1234-1234-1234-123456789012',
})
expect(result.success).toBe(true)
})
test('SDKCompactBoundaryMessageSchema accepts preserved_segment', () => {
const schema = SDKCompactBoundaryMessageSchema()
const result = schema.safeParse({
type: 'system',
subtype: 'compact_boundary',
compact_metadata: {
trigger: 'auto',
pre_tokens: 50000,
preserved_segment: {
head_uuid: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
anchor_uuid: 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb',
tail_uuid: 'cccccccc-cccc-cccc-cccc-cccccccccccc',
},
},
uuid: '12345678-1234-1234-1234-123456789012',
session_id: '12345678-1234-1234-1234-123456789012',
})
expect(result.success).toBe(true)
})
test('SDKUserMessageSchema accepts valid data', () => {
const schema = SDKUserMessageSchema()
const result = schema.safeParse({
type: 'user',
message: { role: 'user', content: 'hello' },
parent_tool_use_id: null,
})
expect(result.success).toBe(true)
})
test('SDKResultSuccessSchema accepts valid data', () => {
const schema = SDKResultSuccessSchema()
const result = schema.safeParse({
type: 'result',
subtype: 'success',
duration_ms: 1500,
duration_api_ms: 1200,
is_error: false,
num_turns: 1,
result: 'Done',
stop_reason: 'end_turn',
total_cost_usd: 0.01,
usage: { input_tokens: 100, output_tokens: 50 },
modelUsage: {},
permission_denials: [],
uuid: '12345678-1234-1234-1234-123456789012',
session_id: '12345678-1234-1234-1234-123456789012',
})
expect(result.success).toBe(true)
})
test('SDKResultErrorSchema accepts valid data', () => {
const schema = SDKResultErrorSchema()
const result = schema.safeParse({
type: 'result',
subtype: 'error_during_execution',
duration_ms: 100,
duration_api_ms: 80,
is_error: true,
num_turns: 1,
stop_reason: null,
total_cost_usd: 0.001,
usage: { input_tokens: 50, output_tokens: 10 },
modelUsage: {},
permission_denials: [],
errors: ['Something went wrong'],
uuid: '12345678-1234-1234-1234-123456789012',
session_id: '12345678-1234-1234-1234-123456789012',
})
expect(result.success).toBe(true)
})
test('SDKMessageSchema accepts all message types', () => {
const schema = SDKMessageSchema()
const messages = [
{
type: 'assistant',
message: {},
parent_tool_use_id: null,
uuid: '12345678-1234-1234-1234-123456789012',
session_id: '12345678-1234-1234-1234-123456789012',
},
{
type: 'user',
message: {},
parent_tool_use_id: null,
},
{
type: 'system',
subtype: 'init',
apiKeySource: 'user',
claude_code_version: '0.3.0',
cwd: '/tmp',
tools: [],
mcp_servers: [],
model: 'sonnet',
permissionMode: 'default',
slash_commands: [],
output_style: 'default',
skills: [],
plugins: [],
uuid: '12345678-1234-1234-1234-123456789012',
session_id: '12345678-1234-1234-1234-123456789012',
},
{
type: 'system',
subtype: 'compact_boundary',
compact_metadata: { trigger: 'manual', pre_tokens: 100 },
uuid: '12345678-1234-1234-1234-123456789012',
session_id: '12345678-1234-1234-1234-123456789012',
},
]
for (const msg of messages) {
const result = schema.safeParse(msg)
expect(result.success).toBe(true)
}
})
test('SDKSessionInfoSchema accepts valid data', () => {
const schema = SDKSessionInfoSchema()
const result = schema.safeParse({
sessionId: '12345678-1234-1234-1234-123456789012',
summary: 'Test session',
lastModified: Date.now(),
})
expect(result.success).toBe(true)
})
test('PermissionModeSchema accepts valid modes', () => {
const schema = PermissionModeSchema()
const modes = ['default', 'acceptEdits', 'bypassPermissions', 'plan', 'dontAsk']
for (const mode of modes) {
expect(schema.safeParse(mode).success).toBe(true)
}
expect(schema.safeParse('invalid').success).toBe(false)
})
test('ThinkingConfigSchema accepts all variants', () => {
const schema = ThinkingConfigSchema()
expect(schema.safeParse({ type: 'adaptive' }).success).toBe(true)
expect(schema.safeParse({ type: 'enabled' }).success).toBe(true)
expect(schema.safeParse({ type: 'enabled', budgetTokens: 10000 }).success).toBe(true)
expect(schema.safeParse({ type: 'disabled' }).success).toBe(true)
expect(schema.safeParse({ type: 'unknown' }).success).toBe(false)
})
test('FastModeStateSchema accepts valid states', () => {
const schema = FastModeStateSchema()
expect(schema.safeParse('off').success).toBe(true)
expect(schema.safeParse('cooldown').success).toBe(true)
expect(schema.safeParse('on').success).toBe(true)
expect(schema.safeParse('unknown').success).toBe(false)
})
test('ExitReasonSchema accepts valid reasons', () => {
const schema = ExitReasonSchema()
const reasons = ['clear', 'resume', 'logout', 'prompt_input_exit', 'other', 'bypass_permissions_disabled']
for (const r of reasons) {
expect(schema.safeParse(r).success).toBe(true)
}
expect(schema.safeParse('invalid').success).toBe(false)
})
test('ModelUsageSchema accepts valid data', () => {
const schema = ModelUsageSchema()
const result = schema.safeParse({
inputTokens: 100,
outputTokens: 50,
cacheReadInputTokens: 200,
cacheCreationInputTokens: 300,
webSearchRequests: 1,
costUSD: 0.01,
contextWindow: 200000,
maxOutputTokens: 8192,
})
expect(result.success).toBe(true)
})
test('AgentDefinitionSchema accepts valid data', () => {
const schema = AgentDefinitionSchema()
const result = schema.safeParse({
description: 'Test agent',
prompt: 'You are a test agent',
})
expect(result.success).toBe(true)
})
test('McpServerStatusSchema accepts valid data', () => {
const schema = McpServerStatusSchema()
const result = schema.safeParse({
name: 'test-server',
status: 'connected',
})
expect(result.success).toBe(true)
})
})