Compare commits

..

8 Commits

Author SHA1 Message Date
gnanam1990
3a25d71004 fix: comprehensive tool argument normalization hardening
- Remove all { raw: ... } returns that caused InputValidationError with
  z.strictObject schemas — return {} instead for clean Zod errors
- Extend normalizeAtStop buffering to all mapped tools (Read, Write,
  Edit, Glob, Grep) so streaming paths also get normalized
- Make repairPossiblyTruncatedObjectJson generic — repair any valid
  JSON object, not just ones with a command field
- Export hasToolFieldMapping for streaming normalizeAtStop decision
- Skip normalization on finish_reason: length to preserve raw truncated
  buffer
- Update all test expectations to match new behavior

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 18:38:33 +05:30
gnanam1990
50efbe5614 fix: skip streaming normalization on finish_reason length
Truncated tool calls (finish_reason: 'length') now preserve the raw
buffer instead of normalizing into executable commands, preventing
incomplete commands from becoming runnable.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 18:08:01 +05:30
gnanam1990
b20d878b76 fix: extend tool argument normalization to all tools and harden edge cases
- Extend STRING_ARGUMENT_TOOL_FIELDS to normalize Read, Write, Edit,
  Glob, and Grep plain-string arguments (fixes "Invalid tool parameters"
  errors reported by VennDev)
- Normalize streaming Bash args regardless of finish_reason, not only
  when finish_reason is 'tool_calls'
- Broaden isLikelyStructuredObjectLiteral to catch malformed object-shaped
  strings like {command:"pwd"} and {'command':'pwd'} (fixes CR2 from
  Vasanthdev2004)
- Apply blank/object-literal guard to all tools, not just Bash
- Extract duplicated JSON repair suffix combinations into shared constant
- Add 32 isolated unit tests for toolArgumentNormalization

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 18:01:24 +05:30
gnanam1990
f2fc454baf test: isolate provider profile env assertions 2026-04-06 17:25:27 +05:30
gnanam1990
10f17d38ea test: stabilize rebased PR 385 checks 2026-04-06 17:25:01 +05:30
gnanam1990
889c472ddb fix: preserve malformed Bash JSON literals 2026-04-06 17:22:58 +05:30
gnanam1990
0ad7746b7a fix: keep invalid Bash tool args from becoming commands 2026-04-06 17:22:58 +05:30
gnanam1990
91df124064 fix: normalize malformed Bash tool arguments from OpenAI-compatible providers 2026-04-06 17:22:58 +05:30
19 changed files with 63 additions and 1108 deletions

View File

@@ -52,11 +52,7 @@ async function renderFinalFrame(node: React.ReactNode): Promise<string> {
patchConsole: false, patchConsole: false,
}) })
// Timeout guard: if render throws before exit effect fires, don't hang await instance.waitUntilExit()
await Promise.race([
instance.waitUntilExit(),
new Promise<void>(resolve => setTimeout(resolve, 3000)),
])
return stripAnsi(extractLastFrame(getOutput())) return stripAnsi(extractLastFrame(getOutput()))
} }

View File

@@ -1,305 +0,0 @@
import { PassThrough } from 'node:stream'
import { afterEach, expect, mock, test } from 'bun:test'
import React from 'react'
import stripAnsi from 'strip-ansi'
import { createRoot } from '../ink.js'
import { AppStateProvider } from '../state/AppState.js'
const SYNC_START = '\x1B[?2026h'
const SYNC_END = '\x1B[?2026l'
const ORIGINAL_ENV = {
CLAUDE_CODE_USE_GITHUB: process.env.CLAUDE_CODE_USE_GITHUB,
GITHUB_TOKEN: process.env.GITHUB_TOKEN,
GH_TOKEN: process.env.GH_TOKEN,
}
function extractLastFrame(output: string): string {
let lastFrame: string | null = null
let cursor = 0
while (cursor < output.length) {
const start = output.indexOf(SYNC_START, cursor)
if (start === -1) {
break
}
const contentStart = start + SYNC_START.length
const end = output.indexOf(SYNC_END, contentStart)
if (end === -1) {
break
}
const frame = output.slice(contentStart, end)
if (frame.trim().length > 0) {
lastFrame = frame
}
cursor = end + SYNC_END.length
}
return lastFrame ?? output
}
function createTestStreams(): {
stdout: PassThrough
stdin: PassThrough & {
isTTY: boolean
setRawMode: (mode: boolean) => void
ref: () => void
unref: () => void
}
getOutput: () => string
} {
let output = ''
const stdout = new PassThrough()
const stdin = new PassThrough() as PassThrough & {
isTTY: boolean
setRawMode: (mode: boolean) => void
ref: () => void
unref: () => void
}
stdin.isTTY = true
stdin.setRawMode = () => {}
stdin.ref = () => {}
stdin.unref = () => {}
;(stdout as unknown as { columns: number }).columns = 120
stdout.on('data', chunk => {
output += chunk.toString()
})
return {
stdout,
stdin,
getOutput: () => output,
}
}
async function waitForCondition(
predicate: () => boolean,
options?: { timeoutMs?: number; intervalMs?: number },
): Promise<void> {
const timeoutMs = options?.timeoutMs ?? 2000
const intervalMs = options?.intervalMs ?? 10
const startedAt = Date.now()
while (Date.now() - startedAt < timeoutMs) {
if (predicate()) {
return
}
await Bun.sleep(intervalMs)
}
throw new Error('Timed out waiting for ProviderManager test condition')
}
function createDeferred<T>(): {
promise: Promise<T>
resolve: (value: T) => void
} {
let resolve!: (value: T) => void
const promise = new Promise<T>(r => {
resolve = r
})
return { promise, resolve }
}
function mockProviderProfilesModule(): void {
mock.module('../utils/providerProfiles.js', () => ({
addProviderProfile: () => null,
applyActiveProviderProfileFromConfig: () => {},
deleteProviderProfile: () => ({ removed: false, activeProfileId: null }),
getActiveProviderProfile: () => null,
getProviderPresetDefaults: () => ({
provider: 'openai',
name: 'Mock provider',
baseUrl: 'http://localhost:11434/v1',
model: 'mock-model',
apiKey: '',
}),
getProviderProfiles: () => [],
setActiveProviderProfile: () => null,
updateProviderProfile: () => null,
}))
}
function mockProviderManagerDependencies(
syncRead: () => string | undefined,
asyncRead: () => Promise<string | undefined>,
): void {
mockProviderProfilesModule()
mock.module('../utils/githubModelsCredentials.js', () => ({
clearGithubModelsToken: () => ({ success: true }),
GITHUB_MODELS_HYDRATED_ENV_MARKER: 'CLAUDE_CODE_GITHUB_TOKEN_HYDRATED',
hydrateGithubModelsTokenFromSecureStorage: () => {},
readGithubModelsToken: syncRead,
readGithubModelsTokenAsync: asyncRead,
}))
mock.module('../utils/settings/settings.js', () => ({
updateSettingsForSource: () => ({ error: null }),
}))
}
async function waitForFrameOutput(
getOutput: () => string,
predicate: (output: string) => boolean,
timeoutMs = 2500,
): Promise<string> {
let output = ''
await waitForCondition(() => {
output = stripAnsi(extractLastFrame(getOutput()))
return predicate(output)
}, { timeoutMs })
return output
}
async function mountProviderManager(
ProviderManager: React.ComponentType<{
mode: 'first-run' | 'manage'
onDone: () => void
}>,
): Promise<{
getOutput: () => string
dispose: () => Promise<void>
}> {
const { stdout, stdin, getOutput } = createTestStreams()
const root = await createRoot({
stdout: stdout as unknown as NodeJS.WriteStream,
stdin: stdin as unknown as NodeJS.ReadStream,
patchConsole: false,
})
root.render(
<AppStateProvider>
<ProviderManager
mode="manage"
onDone={() => {}}
/>
</AppStateProvider>,
)
return {
getOutput,
dispose: async () => {
root.unmount()
stdin.end()
stdout.end()
await Bun.sleep(0)
},
}
}
async function renderProviderManagerFrame(
ProviderManager: React.ComponentType<{
mode: 'first-run' | 'manage'
onDone: () => void
}>,
options?: {
waitForOutput?: (output: string) => boolean
timeoutMs?: number
},
): Promise<string> {
const mounted = await mountProviderManager(ProviderManager)
const output = await waitForFrameOutput(
mounted.getOutput,
frame => {
if (!options?.waitForOutput) {
return frame.includes('Provider manager')
}
return options.waitForOutput(frame)
},
options?.timeoutMs ?? 2500,
)
await mounted.dispose()
return output
}
afterEach(() => {
mock.restore()
for (const [key, value] of Object.entries(ORIGINAL_ENV)) {
if (value === undefined) {
delete process.env[key as keyof typeof ORIGINAL_ENV]
} else {
process.env[key as keyof typeof ORIGINAL_ENV] = value
}
}
})
test('ProviderManager resolves GitHub virtual provider from async storage without sync reads in render flow', async () => {
delete process.env.CLAUDE_CODE_USE_GITHUB
delete process.env.GITHUB_TOKEN
delete process.env.GH_TOKEN
const syncRead = mock(() => {
throw new Error('sync credential read should not run in ProviderManager render flow')
})
const asyncRead = mock(async () => 'stored-token')
mockProviderManagerDependencies(syncRead, asyncRead)
const nonce = `${Date.now()}-${Math.random()}`
const { ProviderManager } = await import(`./ProviderManager.js?ts=${nonce}`)
const output = await renderProviderManagerFrame(ProviderManager, {
waitForOutput: frame =>
frame.includes('Provider manager') &&
frame.includes('GitHub Models') &&
frame.includes('token stored'),
})
expect(output).toContain('Provider manager')
expect(output).toContain('GitHub Models')
expect(output).toContain('token stored')
expect(output).not.toContain('No provider profiles configured yet.')
expect(syncRead).not.toHaveBeenCalled()
expect(asyncRead).toHaveBeenCalled()
})
test('ProviderManager avoids first-frame false negative while stored-token lookup is pending', async () => {
delete process.env.CLAUDE_CODE_USE_GITHUB
delete process.env.GITHUB_TOKEN
delete process.env.GH_TOKEN
const syncRead = mock(() => {
throw new Error('sync credential read should not run in ProviderManager render flow')
})
const deferredStoredToken = createDeferred<string | undefined>()
const asyncRead = mock(async () => deferredStoredToken.promise)
mockProviderManagerDependencies(syncRead, asyncRead)
const nonce = `${Date.now()}-${Math.random()}`
const { ProviderManager } = await import(`./ProviderManager.js?ts=${nonce}`)
const mounted = await mountProviderManager(ProviderManager)
const firstFrame = await waitForFrameOutput(
mounted.getOutput,
frame => frame.includes('Provider manager'),
)
expect(firstFrame).toContain('Checking GitHub Models credentials...')
expect(firstFrame).not.toContain('No provider profiles configured yet.')
deferredStoredToken.resolve('stored-token')
const resolvedFrame = await waitForFrameOutput(
mounted.getOutput,
frame => frame.includes('GitHub Models') && frame.includes('token stored'),
)
expect(resolvedFrame).toContain('GitHub Models')
expect(resolvedFrame).toContain('token stored')
await mounted.dispose()
expect(syncRead).not.toHaveBeenCalled()
expect(asyncRead).toHaveBeenCalled()
})

View File

@@ -20,7 +20,6 @@ import {
GITHUB_MODELS_HYDRATED_ENV_MARKER, GITHUB_MODELS_HYDRATED_ENV_MARKER,
hydrateGithubModelsTokenFromSecureStorage, hydrateGithubModelsTokenFromSecureStorage,
readGithubModelsToken, readGithubModelsToken,
readGithubModelsTokenAsync,
} from '../utils/githubModelsCredentials.js' } from '../utils/githubModelsCredentials.js'
import { isEnvTruthy } from '../utils/envUtils.js' import { isEnvTruthy } from '../utils/envUtils.js'
import { updateSettingsForSource } from '../utils/settings/settings.js' import { updateSettingsForSource } from '../utils/settings/settings.js'
@@ -119,38 +118,25 @@ function profileSummary(profile: ProviderProfile, isActive: boolean): string {
return `${providerKind} · ${profile.baseUrl} · ${profile.model} · ${keyInfo}${activeSuffix}` return `${providerKind} · ${profile.baseUrl} · ${profile.model} · ${keyInfo}${activeSuffix}`
} }
function getGithubCredentialSourceFromEnv( function getGithubCredentialSource(
processEnv: NodeJS.ProcessEnv = process.env, processEnv: NodeJS.ProcessEnv = process.env,
): GithubCredentialSource { ): GithubCredentialSource {
if (readGithubModelsToken()?.trim()) {
return 'stored'
}
if (processEnv.GITHUB_TOKEN?.trim() || processEnv.GH_TOKEN?.trim()) { if (processEnv.GITHUB_TOKEN?.trim() || processEnv.GH_TOKEN?.trim()) {
return 'env' return 'env'
} }
return 'none' return 'none'
} }
async function resolveGithubCredentialSource(
processEnv: NodeJS.ProcessEnv = process.env,
): Promise<GithubCredentialSource> {
const envSource = getGithubCredentialSourceFromEnv(processEnv)
if (envSource !== 'none') {
return envSource
}
if (await readGithubModelsTokenAsync()) {
return 'stored'
}
return 'none'
}
function isGithubProviderAvailable( function isGithubProviderAvailable(
credentialSource: GithubCredentialSource,
processEnv: NodeJS.ProcessEnv = process.env, processEnv: NodeJS.ProcessEnv = process.env,
): boolean { ): boolean {
if (isEnvTruthy(processEnv.CLAUDE_CODE_USE_GITHUB)) { if (isEnvTruthy(processEnv.CLAUDE_CODE_USE_GITHUB)) {
return true return true
} }
return credentialSource !== 'none' return getGithubCredentialSource(processEnv) !== 'none'
} }
function getGithubProviderModel( function getGithubProviderModel(
@@ -178,24 +164,19 @@ function getGithubProviderSummary(
} }
export function ProviderManager({ mode, onDone }: Props): React.ReactNode { export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
const initialGithubCredentialSource = getGithubCredentialSourceFromEnv()
const initialIsGithubActive = isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB)
const initialHasGithubCredential = initialGithubCredentialSource !== 'none'
const [profiles, setProfiles] = React.useState(() => getProviderProfiles()) const [profiles, setProfiles] = React.useState(() => getProviderProfiles())
const [activeProfileId, setActiveProfileId] = React.useState( const [activeProfileId, setActiveProfileId] = React.useState(
() => getActiveProviderProfile()?.id, () => getActiveProviderProfile()?.id,
) )
const [githubProviderAvailable, setGithubProviderAvailable] = React.useState( const [githubProviderAvailable, setGithubProviderAvailable] = React.useState(() =>
() => isGithubProviderAvailable(initialGithubCredentialSource), isGithubProviderAvailable(),
) )
const [githubCredentialSource, setGithubCredentialSource] = React.useState<GithubCredentialSource>( const [githubCredentialSource, setGithubCredentialSource] = React.useState<GithubCredentialSource>(
() => initialGithubCredentialSource, () => getGithubCredentialSource(),
)
const [isGithubActive, setIsGithubActive] = React.useState(() =>
isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB),
) )
const [isGithubActive, setIsGithubActive] = React.useState(() => initialIsGithubActive)
const [isGithubCredentialSourceResolved, setIsGithubCredentialSourceResolved] =
React.useState(() => initialHasGithubCredential || initialIsGithubActive)
const githubRefreshEpochRef = React.useRef(0)
const [screen, setScreen] = React.useState<Screen>( const [screen, setScreen] = React.useState<Screen>(
mode === 'first-run' ? 'select-preset' : 'menu', mode === 'first-run' ? 'select-preset' : 'menu',
) )
@@ -215,48 +196,13 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
const currentStepKey = currentStep.key const currentStepKey = currentStep.key
const currentValue = draft[currentStepKey] const currentValue = draft[currentStepKey]
const refreshGithubProviderState = React.useCallback((): void => {
const envCredentialSource = getGithubCredentialSourceFromEnv()
const githubActive = isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB)
const canResolveFromEnv = githubActive || envCredentialSource !== 'none'
if (canResolveFromEnv) {
githubRefreshEpochRef.current += 1
setGithubCredentialSource(envCredentialSource)
setGithubProviderAvailable(isGithubProviderAvailable(envCredentialSource))
setIsGithubActive(githubActive)
setIsGithubCredentialSourceResolved(true)
return
}
setIsGithubCredentialSourceResolved(false)
const refreshEpoch = ++githubRefreshEpochRef.current
void (async () => {
const credentialSource = await resolveGithubCredentialSource()
if (refreshEpoch !== githubRefreshEpochRef.current) {
return
}
setGithubCredentialSource(credentialSource)
setGithubProviderAvailable(isGithubProviderAvailable(credentialSource))
setIsGithubActive(isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB))
setIsGithubCredentialSourceResolved(true)
})()
}, [])
React.useEffect(() => {
refreshGithubProviderState()
return () => {
githubRefreshEpochRef.current += 1
}
}, [refreshGithubProviderState])
function refreshProfiles(): void { function refreshProfiles(): void {
const nextProfiles = getProviderProfiles() const nextProfiles = getProviderProfiles()
setProfiles(nextProfiles) setProfiles(nextProfiles)
setActiveProfileId(getActiveProviderProfile()?.id) setActiveProfileId(getActiveProviderProfile()?.id)
refreshGithubProviderState() setGithubProviderAvailable(isGithubProviderAvailable())
setGithubCredentialSource(getGithubCredentialSource())
setIsGithubActive(isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB))
} }
function clearStartupProviderOverrideFromUserSettings(): string | null { function clearStartupProviderOverrideFromUserSettings(): string | null {
@@ -694,11 +640,7 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
{statusMessage && <Text>{statusMessage}</Text>} {statusMessage && <Text>{statusMessage}</Text>}
<Box flexDirection="column"> <Box flexDirection="column">
{profiles.length === 0 && !githubProviderAvailable ? ( {profiles.length === 0 && !githubProviderAvailable ? (
isGithubCredentialSourceResolved ? ( <Text dimColor>No provider profiles configured yet.</Text>
<Text dimColor>No provider profiles configured yet.</Text>
) : (
<Text dimColor>Checking GitHub Models credentials...</Text>
)
) : ( ) : (
<> <>
{profiles.map(profile => ( {profiles.map(profile => (

View File

@@ -40,7 +40,7 @@ export class GrpcServer {
grpc.ServerCredentials.createInsecure(), grpc.ServerCredentials.createInsecure(),
(error, boundPort) => { (error, boundPort) => {
if (error) { if (error) {
console.error('Failed to start gRPC server') console.error('Failed to start gRPC server', error)
return return
} }
console.log(`gRPC Server running at ${host}:${boundPort}`) console.log(`gRPC Server running at ${host}:${boundPort}`)
@@ -225,7 +225,7 @@ export class GrpcServer {
call.end() call.end()
} }
} catch (err: any) { } catch (err: any) {
console.error('Error processing stream') console.error("Error processing stream:", err)
call.write({ call.write({
error: { error: {
message: err.message || "Internal server error", message: err.message || "Internal server error",

View File

@@ -366,12 +366,14 @@ const reconciler = createReconciler<
createTextInstance( createTextInstance(
text: string, text: string,
_root: DOMElement, _root: DOMElement,
_hostContext: HostContext, hostContext: HostContext,
): TextNode { ): TextNode {
// react-compiler memoization can reuse cached <Text> elements without if (!hostContext.isInsideText) {
// re-traversing getChildHostContext, so hostContext.isInsideText may be throw new Error(
// stale. Always create the text node — Ink will render it correctly `Text string "${text}" must be rendered inside <Text> component`,
// regardless of the context tracking state. )
}
return createTextNode(text) return createTextNode(text)
}, },
resetTextContent() {}, resetTextContent() {},

View File

@@ -261,58 +261,6 @@ test('preserves Gemini tool call extra_content in follow-up requests', async ()
}) })
}) })
test('does not infer Gemini mode from OPENAI_BASE_URL path substrings', async () => {
let capturedAuthorization: string | null = null
process.env.OPENAI_BASE_URL =
'https://evil.example/generativelanguage.googleapis.com/v1beta/openai'
delete process.env.OPENAI_API_KEY
process.env.GEMINI_API_KEY = 'gemini-secret'
globalThis.fetch = (async (_input, init) => {
const headers = init?.headers as Record<string, string> | undefined
capturedAuthorization =
headers?.Authorization ?? headers?.authorization ?? null
return new Response(
JSON.stringify({
id: 'chatcmpl-1',
model: 'fake-model',
choices: [
{
message: {
role: 'assistant',
content: 'ok',
},
finish_reason: 'stop',
},
],
usage: {
prompt_tokens: 12,
completion_tokens: 4,
total_tokens: 16,
},
}),
{
headers: {
'Content-Type': 'application/json',
},
},
)
}) as FetchType
const client = createOpenAIShimClient({}) as OpenAIShimClient
await client.beta.messages.create({
model: 'fake-model',
messages: [{ role: 'user', content: 'hello' }],
max_tokens: 64,
stream: false,
})
expect(capturedAuthorization).toBeNull()
})
test('preserves image tool results as placeholders in follow-up requests', async () => { test('preserves image tool results as placeholders in follow-up requests', async () => {
let requestBody: Record<string, unknown> | undefined let requestBody: Record<string, unknown> | undefined
@@ -1821,237 +1769,3 @@ test('coalesces consecutive assistant messages preserving tool_calls (issue #202
expect(assistantMsgs?.length).toBe(1) // two assistant turns merged into one expect(assistantMsgs?.length).toBe(1) // two assistant turns merged into one
expect(assistantMsgs?.[0]?.tool_calls?.length).toBeGreaterThan(0) expect(assistantMsgs?.[0]?.tool_calls?.length).toBeGreaterThan(0)
}) })
test('non-streaming: reasoning_content emitted as thinking block, used as text when content is null', async () => {
globalThis.fetch = (async (_input, _init) => {
return new Response(
JSON.stringify({
id: 'chatcmpl-1',
model: 'glm-5',
choices: [
{
message: {
role: 'assistant',
content: null,
reasoning_content: 'Let me think about this step by step.',
},
finish_reason: 'stop',
},
],
usage: {
prompt_tokens: 10,
completion_tokens: 20,
total_tokens: 30,
},
}),
{
headers: {
'Content-Type': 'application/json',
},
},
)
}) as FetchType
const client = createOpenAIShimClient({}) as OpenAIShimClient
const result = (await client.beta.messages.create({
model: 'glm-5',
system: 'test system',
messages: [{ role: 'user', content: 'hello' }],
max_tokens: 64,
stream: false,
})) as { content: Array<Record<string, unknown>> }
expect(result.content).toEqual([
{ type: 'thinking', thinking: 'Let me think about this step by step.' },
{ type: 'text', text: 'Let me think about this step by step.' },
])
})
test('non-streaming: empty string content does not fall through to reasoning_content as text', async () => {
globalThis.fetch = (async (_input, _init) => {
return new Response(
JSON.stringify({
id: 'chatcmpl-1',
model: 'glm-5',
choices: [
{
message: {
role: 'assistant',
content: '',
reasoning_content: 'Chain of thought here.',
},
finish_reason: 'stop',
},
],
usage: {
prompt_tokens: 10,
completion_tokens: 20,
total_tokens: 30,
},
}),
{
headers: {
'Content-Type': 'application/json',
},
},
)
}) as FetchType
const client = createOpenAIShimClient({}) as OpenAIShimClient
const result = (await client.beta.messages.create({
model: 'glm-5',
system: 'test system',
messages: [{ role: 'user', content: 'hello' }],
max_tokens: 64,
stream: false,
})) as { content: Array<Record<string, unknown>> }
// reasoning_content should be a thinking block, and also used as text
// since content is empty string (treated as absent)
expect(result.content).toEqual([
{ type: 'thinking', thinking: 'Chain of thought here.' },
{ type: 'text', text: 'Chain of thought here.' },
])
})
test('non-streaming: real content takes precedence over reasoning_content', async () => {
globalThis.fetch = (async (_input, _init) => {
return new Response(
JSON.stringify({
id: 'chatcmpl-1',
model: 'glm-5',
choices: [
{
message: {
role: 'assistant',
content: 'The answer is 42.',
reasoning_content: 'I need to calculate this.',
},
finish_reason: 'stop',
},
],
usage: {
prompt_tokens: 10,
completion_tokens: 20,
total_tokens: 30,
},
}),
{
headers: {
'Content-Type': 'application/json',
},
},
)
}) as FetchType
const client = createOpenAIShimClient({}) as OpenAIShimClient
const result = (await client.beta.messages.create({
model: 'glm-5',
system: 'test system',
messages: [{ role: 'user', content: 'hello' }],
max_tokens: 64,
stream: false,
})) as { content: Array<Record<string, unknown>> }
expect(result.content).toEqual([
{ type: 'thinking', thinking: 'I need to calculate this.' },
{ type: 'text', text: 'The answer is 42.' },
])
})
test('streaming: thinking block closed before tool call', async () => {
globalThis.fetch = (async (_input, _init) => {
const chunks = makeStreamChunks([
{
id: 'chatcmpl-1',
object: 'chat.completion.chunk',
model: 'glm-5',
choices: [
{
index: 0,
delta: { role: 'assistant', reasoning_content: 'Thinking...' },
finish_reason: null,
},
],
},
{
id: 'chatcmpl-1',
object: 'chat.completion.chunk',
model: 'glm-5',
choices: [
{
index: 0,
delta: {
tool_calls: [
{
index: 0,
id: 'call-1',
type: 'function',
function: {
name: 'Bash',
arguments: '{"command":"ls"}',
},
},
],
},
finish_reason: null,
},
],
},
{
id: 'chatcmpl-1',
object: 'chat.completion.chunk',
model: 'glm-5',
choices: [
{
index: 0,
delta: {},
finish_reason: 'tool_calls',
},
],
},
])
return makeSseResponse(chunks)
}) as FetchType
const client = createOpenAIShimClient({}) as OpenAIShimClient
const result = await client.beta.messages
.create({
model: 'glm-5',
system: 'test system',
messages: [{ role: 'user', content: 'Run ls' }],
max_tokens: 64,
stream: true,
})
.withResponse()
const events: Array<Record<string, unknown>> = []
for await (const event of result.data) {
events.push(event)
}
const types = events.map(e => e.type)
// Verify thinking block is started, then closed, then tool call starts
const thinkingStartIdx = types.indexOf('content_block_start')
const firstStopIdx = types.indexOf('content_block_stop')
const toolStartIdx = types.indexOf(
'content_block_start',
thinkingStartIdx + 1,
)
expect(thinkingStartIdx).toBeGreaterThanOrEqual(0)
expect(firstStopIdx).toBeGreaterThan(thinkingStartIdx)
expect(toolStartIdx).toBeGreaterThan(firstStopIdx)
// Verify thinking block start content
const thinkingStart = events[thinkingStartIdx] as {
content_block?: Record<string, unknown>
}
expect(thinkingStart?.content_block?.type).toBe('thinking')
})

View File

@@ -60,22 +60,11 @@ const GITHUB_API_VERSION = '2022-11-28'
const GITHUB_429_MAX_RETRIES = 3 const GITHUB_429_MAX_RETRIES = 3
const GITHUB_429_BASE_DELAY_SEC = 1 const GITHUB_429_BASE_DELAY_SEC = 1
const GITHUB_429_MAX_DELAY_SEC = 32 const GITHUB_429_MAX_DELAY_SEC = 32
const GEMINI_API_HOST = 'generativelanguage.googleapis.com'
function isGithubModelsMode(): boolean { function isGithubModelsMode(): boolean {
return isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB) return isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB)
} }
function hasGeminiApiHost(baseUrl: string | undefined): boolean {
if (!baseUrl) return false
try {
return new URL(baseUrl).hostname.toLowerCase() === GEMINI_API_HOST
} catch {
return false
}
}
function formatRetryAfterHint(response: Response): string { function formatRetryAfterHint(response: Response): string {
const ra = response.headers.get('retry-after') const ra = response.headers.get('retry-after')
return ra ? ` (Retry-After: ${ra})` : '' return ra ? ` (Retry-After: ${ra})` : ''
@@ -212,13 +201,6 @@ function convertContentBlocks(
return parts return parts
} }
function isGeminiMode(): boolean {
return (
isEnvTruthy(process.env.CLAUDE_CODE_USE_GEMINI) ||
hasGeminiApiHost(process.env.OPENAI_BASE_URL)
)
}
function convertMessages( function convertMessages(
messages: Array<{ role: string; message?: { role?: string; content?: unknown }; content?: unknown }>, messages: Array<{ role: string; message?: { role?: string; content?: unknown }; content?: unknown }>,
system: unknown, system: unknown,
@@ -270,7 +252,6 @@ function convertMessages(
// Check for tool_use blocks // Check for tool_use blocks
if (Array.isArray(content)) { if (Array.isArray(content)) {
const toolUses = content.filter((b: { type?: string }) => b.type === 'tool_use') const toolUses = content.filter((b: { type?: string }) => b.type === 'tool_use')
const thinkingBlock = content.find((b: { type?: string }) => b.type === 'thinking')
const textContent = content.filter( const textContent = content.filter(
(b: { type?: string }) => b.type !== 'tool_use' && b.type !== 'thinking', (b: { type?: string }) => b.type !== 'tool_use' && b.type !== 'thinking',
) )
@@ -290,46 +271,18 @@ function convertMessages(
name?: string name?: string
input?: unknown input?: unknown
extra_content?: Record<string, unknown> extra_content?: Record<string, unknown>
signature?: string }) => ({
}, index) => { id: tu.id ?? `call_${crypto.randomUUID().replace(/-/g, '')}`,
const toolCall: NonNullable<OpenAIMessage['tool_calls']>[number] = { type: 'function' as const,
id: tu.id ?? `call_${crypto.randomUUID().replace(/-/g, '')}`, function: {
type: 'function' as const, name: tu.name ?? 'unknown',
function: { arguments:
name: tu.name ?? 'unknown', typeof tu.input === 'string'
arguments: ? tu.input
typeof tu.input === 'string' : JSON.stringify(tu.input ?? {}),
? tu.input },
: JSON.stringify(tu.input ?? {}), ...(tu.extra_content ? { extra_content: tu.extra_content } : {}),
}, }),
}
// Preserve existing extra_content if present
if (tu.extra_content) {
toolCall.extra_content = { ...tu.extra_content }
}
// Handle Gemini thought_signature
if (isGeminiMode()) {
// If the model provided a signature in the tool_use block itself (e.g. from a previous Turn/Step)
// Use thinkingBlock.signature for ALL tool calls in the same assistant turn if available.
// The API requires the same signature on every replayed function call part in a parallel set.
const signature = tu.signature ?? (thinkingBlock as any)?.signature
// Merge into existing google-specific metadata if present
const existingGoogle = (toolCall.extra_content?.google as Record<string, unknown>) ?? {}
toolCall.extra_content = {
...toolCall.extra_content,
google: {
...existingGoogle,
thought_signature: signature ?? "skip_thought_signature_validator"
}
}
}
return toolCall
},
) )
} }
@@ -448,7 +401,7 @@ function normalizeSchemaForOpenAI(
function convertTools( function convertTools(
tools: Array<{ name: string; description?: string; input_schema?: Record<string, unknown> }>, tools: Array<{ name: string; description?: string; input_schema?: Record<string, unknown> }>,
): OpenAITool[] { ): OpenAITool[] {
const isGemini = isGeminiMode() const isGemini = isEnvTruthy(process.env.CLAUDE_CODE_USE_GEMINI)
return tools return tools
.filter(t => t.name !== 'ToolSearchTool') // Not relevant for OpenAI .filter(t => t.name !== 'ToolSearchTool') // Not relevant for OpenAI
@@ -490,7 +443,6 @@ interface OpenAIStreamChunk {
delta: { delta: {
role?: string role?: string
content?: string | null content?: string | null
reasoning_content?: string | null
tool_calls?: Array<{ tool_calls?: Array<{
index: number index: number
id?: string id?: string
@@ -573,8 +525,6 @@ async function* openaiStreamToAnthropic(
} }
>() >()
let hasEmittedContentStart = false let hasEmittedContentStart = false
let hasEmittedThinkingStart = false
let hasClosedThinking = false
let lastStopReason: 'tool_use' | 'max_tokens' | 'end_turn' | null = null let lastStopReason: 'tool_use' | 'max_tokens' | 'end_turn' | null = null
let hasEmittedFinalUsage = false let hasEmittedFinalUsage = false
let hasProcessedFinishReason = false let hasProcessedFinishReason = false
@@ -631,34 +581,9 @@ async function* openaiStreamToAnthropic(
for (const choice of chunk.choices ?? []) { for (const choice of chunk.choices ?? []) {
const delta = choice.delta const delta = choice.delta
// Reasoning models (e.g. GLM-5, DeepSeek) may stream chain-of-thought
// in `reasoning_content` before the actual reply appears in `content`.
// Emit reasoning as a thinking block and content as a text block.
if (delta.reasoning_content != null && delta.reasoning_content !== '') {
if (!hasEmittedThinkingStart) {
yield {
type: 'content_block_start',
index: contentBlockIndex,
content_block: { type: 'thinking', thinking: '' },
}
hasEmittedThinkingStart = true
}
yield {
type: 'content_block_delta',
index: contentBlockIndex,
delta: { type: 'thinking_delta', thinking: delta.reasoning_content },
}
}
// Text content — use != null to distinguish absent field from empty string, // Text content — use != null to distinguish absent field from empty string,
// some providers send "" as first delta to signal streaming start // some providers send "" as first delta to signal streaming start
if (delta.content != null && delta.content !== '') { if (delta.content != null) {
// Close thinking block if transitioning from reasoning to content
if (hasEmittedThinkingStart && !hasClosedThinking) {
yield { type: 'content_block_stop', index: contentBlockIndex }
contentBlockIndex++
hasClosedThinking = true
}
if (!hasEmittedContentStart) { if (!hasEmittedContentStart) {
yield { yield {
type: 'content_block_start', type: 'content_block_start',
@@ -678,12 +603,7 @@ async function* openaiStreamToAnthropic(
if (delta.tool_calls) { if (delta.tool_calls) {
for (const tc of delta.tool_calls) { for (const tc of delta.tool_calls) {
if (tc.id && tc.function?.name) { if (tc.id && tc.function?.name) {
// New tool call starting — close any open thinking block first // New tool call starting
if (hasEmittedThinkingStart && !hasClosedThinking) {
yield { type: 'content_block_stop', index: contentBlockIndex }
contentBlockIndex++
hasClosedThinking = true
}
if (hasEmittedContentStart) { if (hasEmittedContentStart) {
yield { yield {
type: 'content_block_stop', type: 'content_block_stop',
@@ -713,13 +633,6 @@ async function* openaiStreamToAnthropic(
name: tc.function.name, name: tc.function.name,
input: {}, input: {},
...(tc.extra_content ? { extra_content: tc.extra_content } : {}), ...(tc.extra_content ? { extra_content: tc.extra_content } : {}),
// Extract Gemini signature from extra_content
...((tc.extra_content?.google as any)?.thought_signature
? {
signature: (tc.extra_content.google as any)
.thought_signature,
}
: {}),
}, },
} }
contentBlockIndex++ contentBlockIndex++
@@ -765,12 +678,6 @@ async function* openaiStreamToAnthropic(
if (choice.finish_reason && !hasProcessedFinishReason) { if (choice.finish_reason && !hasProcessedFinishReason) {
hasProcessedFinishReason = true hasProcessedFinishReason = true
// Close any open thinking block that wasn't closed by content transition
if (hasEmittedThinkingStart && !hasClosedThinking) {
yield { type: 'content_block_stop', index: contentBlockIndex }
contentBlockIndex++
hasClosedThinking = true
}
// Close any open content blocks // Close any open content blocks
if (hasEmittedContentStart) { if (hasEmittedContentStart) {
yield { yield {
@@ -1096,7 +1003,7 @@ class OpenAIShimMessages {
...(options?.headers ?? {}), ...(options?.headers ?? {}),
} }
const isGemini = isGeminiMode() const isGemini = isEnvTruthy(process.env.CLAUDE_CODE_USE_GEMINI)
const apiKey = const apiKey =
this.providerOverride?.apiKey ?? process.env.OPENAI_API_KEY ?? '' this.providerOverride?.apiKey ?? process.env.OPENAI_API_KEY ?? ''
// Detect Azure endpoints by hostname (not raw URL) to prevent bypass via // Detect Azure endpoints by hostname (not raw URL) to prevent bypass via
@@ -1209,7 +1116,6 @@ class OpenAIShimMessages {
| string | string
| null | null
| Array<{ type?: string; text?: string }> | Array<{ type?: string; text?: string }>
reasoning_content?: string | null
tool_calls?: Array<{ tool_calls?: Array<{
id: string id: string
function: { name: string; arguments: string } function: { name: string; arguments: string }
@@ -1231,17 +1137,7 @@ class OpenAIShimMessages {
const choice = data.choices?.[0] const choice = data.choices?.[0]
const content: Array<Record<string, unknown>> = [] const content: Array<Record<string, unknown>> = []
// Some reasoning models (e.g. GLM-5) put their reply in reasoning_content const rawContent = choice?.message?.content
// while content stays null — emit reasoning as a thinking block, then
// fall back to it for visible text if content is empty.
const reasoningText = choice?.message?.reasoning_content
if (typeof reasoningText === 'string' && reasoningText) {
content.push({ type: 'thinking', thinking: reasoningText })
}
const rawContent =
choice?.message?.content !== '' && choice?.message?.content != null
? choice?.message?.content
: choice?.message?.reasoning_content
if (typeof rawContent === 'string' && rawContent) { if (typeof rawContent === 'string' && rawContent) {
content.push({ type: 'text', text: rawContent }) content.push({ type: 'text', text: rawContent })
} else if (Array.isArray(rawContent) && rawContent.length > 0) { } else if (Array.isArray(rawContent) && rawContent.length > 0) {
@@ -1274,10 +1170,6 @@ class OpenAIShimMessages {
name: tc.function.name, name: tc.function.name,
input, input,
...(tc.extra_content ? { extra_content: tc.extra_content } : {}), ...(tc.extra_content ? { extra_content: tc.extra_content } : {}),
// Extract Gemini signature from extra_content
...((tc.extra_content?.google as any)?.thought_signature
? { signature: (tc.extra_content.google as any).thought_signature }
: {}),
}) })
} }
} }

View File

@@ -1,127 +0,0 @@
import { describe, expect, test } from 'bun:test'
import type { Message } from '../../types/message.js'
import { createAssistantMessage, createUserMessage } from '../../utils/messages.js'
// We test the exported collectCompactableToolIds behavior indirectly via
// the public microcompactMessages + time-based path. But first we need to
// verify the core predicate: MCP tools (prefixed 'mcp__') should be
// compactable alongside the built-in tool set.
// Import internals we can test
import { evaluateTimeBasedTrigger } from './microCompact.js'
/**
* Helper: build a minimal assistant message with a tool_use block.
*/
function assistantWithToolUse(toolName: string, toolId: string): Message {
return createAssistantMessage({
content: [
{
type: 'tool_use' as const,
id: toolId,
name: toolName,
input: {},
},
],
})
}
/**
* Helper: build a user message with a tool_result block.
*/
function userWithToolResult(toolId: string, output: string): Message {
return createUserMessage({
content: [
{
type: 'tool_result' as const,
tool_use_id: toolId,
content: output,
},
],
})
}
describe('microCompact MCP tool compaction', () => {
// We can't easily unit-test the private isCompactableTool directly,
// but we can test the full time-based microcompact path which exercises
// collectCompactableToolIds → isCompactableTool under the hood.
// The time-based path is the simplest to trigger: it content-clears
// old tool results when the gap since last assistant message exceeds
// the threshold.
// However, evaluateTimeBasedTrigger depends on config (GrowthBook).
// So instead, let's test the observable behavior by importing the
// microcompactMessages function and checking that MCP tool_use blocks
// are collected.
// Since collectCompactableToolIds is not exported, we test the predicate
// behavior by verifying that the module loads without error and that
// built-in and MCP tools are treated consistently.
test('module exports load correctly', async () => {
const mod = await import('./microCompact.js')
expect(mod.microcompactMessages).toBeFunction()
expect(mod.estimateMessageTokens).toBeFunction()
expect(mod.evaluateTimeBasedTrigger).toBeFunction()
})
test('estimateMessageTokens counts MCP tool_use blocks', async () => {
const { estimateMessageTokens } = await import('./microCompact.js')
const builtinMessages: Message[] = [
assistantWithToolUse('Read', 'tool-builtin-1'),
userWithToolResult('tool-builtin-1', 'file contents here'),
]
const mcpMessages: Message[] = [
assistantWithToolUse('mcp__github__get_file_contents', 'tool-mcp-1'),
userWithToolResult('tool-mcp-1', 'file contents here'),
]
const builtinTokens = estimateMessageTokens(builtinMessages)
const mcpTokens = estimateMessageTokens(mcpMessages)
// Both should produce non-zero estimates
expect(builtinTokens).toBeGreaterThan(0)
expect(mcpTokens).toBeGreaterThan(0)
// The tool_result content is identical, so token estimates should be
// similar (tool_use name differs slightly, so not exactly equal)
expect(Math.abs(builtinTokens - mcpTokens)).toBeLessThan(50)
})
test('microcompactMessages processes MCP tools without error', async () => {
const { microcompactMessages } = await import('./microCompact.js')
const messages: Message[] = [
assistantWithToolUse('mcp__slack__send_message', 'tool-mcp-2'),
userWithToolResult('tool-mcp-2', 'Message sent successfully'),
assistantWithToolUse('mcp__github__create_pull_request', 'tool-mcp-3'),
userWithToolResult('tool-mcp-3', JSON.stringify({ number: 42, url: 'https://github.com/org/repo/pull/42' })),
]
// Should not throw — MCP tools should be handled gracefully
const result = await microcompactMessages(messages)
expect(result).toBeDefined()
expect(result.messages).toBeDefined()
expect(result.messages.length).toBe(messages.length)
})
test('microcompactMessages processes mixed built-in and MCP tools', async () => {
const { microcompactMessages } = await import('./microCompact.js')
const messages: Message[] = [
assistantWithToolUse('Read', 'tool-read-1'),
userWithToolResult('tool-read-1', 'some file content'),
assistantWithToolUse('mcp__playwright__screenshot', 'tool-mcp-4'),
userWithToolResult('tool-mcp-4', 'base64-encoded-screenshot-data'.repeat(100)),
assistantWithToolUse('Bash', 'tool-bash-1'),
userWithToolResult('tool-bash-1', 'command output'),
]
const result = await microcompactMessages(messages)
expect(result).toBeDefined()
expect(result.messages.length).toBe(messages.length)
})
})

View File

@@ -37,7 +37,7 @@ export const TIME_BASED_MC_CLEARED_MESSAGE = '[Old tool result content cleared]'
const IMAGE_MAX_TOKEN_SIZE = 2000 const IMAGE_MAX_TOKEN_SIZE = 2000
// Only compact these built-in tools (MCP tools are also compactable via prefix match) // Only compact these tools
const COMPACTABLE_TOOLS = new Set<string>([ const COMPACTABLE_TOOLS = new Set<string>([
FILE_READ_TOOL_NAME, FILE_READ_TOOL_NAME,
...SHELL_TOOL_NAMES, ...SHELL_TOOL_NAMES,
@@ -49,13 +49,7 @@ const COMPACTABLE_TOOLS = new Set<string>([
FILE_WRITE_TOOL_NAME, FILE_WRITE_TOOL_NAME,
]) ])
const MCP_TOOL_PREFIX = 'mcp__' // --- Cached microcompact state (internal-only, gated by feature('CACHED_MICROCOMPACT')) ---
function isCompactableTool(name: string): boolean {
return COMPACTABLE_TOOLS.has(name) || name.startsWith(MCP_TOOL_PREFIX)
}
// --- Cached microcompact state (gated by feature('CACHED_MICROCOMPACT')) ---
// Lazy-initialized cached MC module and state to avoid importing in external builds. // Lazy-initialized cached MC module and state to avoid importing in external builds.
// The imports and state live inside feature() checks for dead code elimination. // The imports and state live inside feature() checks for dead code elimination.
@@ -237,7 +231,7 @@ function collectCompactableToolIds(messages: Message[]): string[] {
Array.isArray(message.message.content) Array.isArray(message.message.content)
) { ) {
for (const block of message.message.content) { for (const block of message.message.content) {
if (block.type === 'tool_use' && isCompactableTool(block.name)) { if (block.type === 'tool_use' && COMPACTABLE_TOOLS.has(block.name)) {
ids.push(block.id) ids.push(block.id)
} }
} }

View File

@@ -1,33 +0,0 @@
import { describe, expect, test } from 'bun:test'
import { SkillTool } from '../../tools/SkillTool/SkillTool.js'
import {
getSchemaValidationErrorOverride,
getSchemaValidationToolUseResult,
} from './toolExecution.js'
describe('getSchemaValidationErrorOverride', () => {
test('returns actionable missing-skill error for SkillTool', () => {
expect(getSchemaValidationErrorOverride(SkillTool, {})).toBe(
'Missing skill name. Pass the slash command name as the skill parameter (e.g., skill: "commit" for /commit, skill: "review-pr" for /review-pr).',
)
})
test('does not override unrelated tool schema failures', () => {
expect(getSchemaValidationErrorOverride({ name: 'Read' } as never, {})).toBe(
null,
)
})
test('does not override SkillTool when skill is present', () => {
expect(
getSchemaValidationErrorOverride(SkillTool, { skill: 'commit' }),
).toBe(null)
})
test('uses the actionable override for structured toolUseResult too', () => {
expect(getSchemaValidationToolUseResult(SkillTool, {} as never)).toBe(
'InputValidationError: Missing skill name. Pass the slash command name as the skill parameter (e.g., skill: "commit" for /commit, skill: "review-pr" for /review-pr).',
)
})
})

View File

@@ -43,7 +43,6 @@ import { FILE_READ_TOOL_NAME } from '../../tools/FileReadTool/prompt.js'
import { FILE_WRITE_TOOL_NAME } from '../../tools/FileWriteTool/prompt.js' import { FILE_WRITE_TOOL_NAME } from '../../tools/FileWriteTool/prompt.js'
import { NOTEBOOK_EDIT_TOOL_NAME } from '../../tools/NotebookEditTool/constants.js' import { NOTEBOOK_EDIT_TOOL_NAME } from '../../tools/NotebookEditTool/constants.js'
import { POWERSHELL_TOOL_NAME } from '../../tools/PowerShellTool/toolName.js' import { POWERSHELL_TOOL_NAME } from '../../tools/PowerShellTool/toolName.js'
import { SKILL_TOOL_NAME } from '../../tools/SkillTool/constants.js'
import { parseGitCommitId } from '../../tools/shared/gitOperationTracking.js' import { parseGitCommitId } from '../../tools/shared/gitOperationTracking.js'
import { import {
isDeferredTool, isDeferredTool,
@@ -597,31 +596,6 @@ export function buildSchemaNotSentHint(
) )
} }
export function getSchemaValidationErrorOverride(
tool: Tool,
input: unknown,
): string | null {
if (tool.name !== SKILL_TOOL_NAME || !input || typeof input !== 'object') {
return null
}
const skill = (input as { skill?: unknown }).skill
if (skill === undefined || skill === null) {
return 'Missing skill name. Pass the slash command name as the skill parameter (e.g., skill: "commit" for /commit, skill: "review-pr" for /review-pr).'
}
return null
}
export function getSchemaValidationToolUseResult(
tool: Tool,
input: unknown,
fallbackMessage?: string,
): string {
const override = getSchemaValidationErrorOverride(tool, input)
return `InputValidationError: ${override ?? fallbackMessage ?? ''}`
}
async function checkPermissionsAndCallTool( async function checkPermissionsAndCallTool(
tool: Tool, tool: Tool,
toolUseID: string, toolUseID: string,
@@ -640,9 +614,7 @@ async function checkPermissionsAndCallTool(
// Validate input types with zod (surprisingly, the model is not great at generating valid input) // Validate input types with zod (surprisingly, the model is not great at generating valid input)
const parsedInput = tool.inputSchema.safeParse(input) const parsedInput = tool.inputSchema.safeParse(input)
if (!parsedInput.success) { if (!parsedInput.success) {
const fallbackErrorContent = formatZodValidationError(tool.name, parsedInput.error) let errorContent = formatZodValidationError(tool.name, parsedInput.error)
let errorContent =
getSchemaValidationErrorOverride(tool, input) ?? fallbackErrorContent
const schemaHint = buildSchemaNotSentHint( const schemaHint = buildSchemaNotSentHint(
tool, tool,
@@ -700,11 +672,7 @@ async function checkPermissionsAndCallTool(
tool_use_id: toolUseID, tool_use_id: toolUseID,
}, },
], ],
toolUseResult: getSchemaValidationToolUseResult( toolUseResult: `InputValidationError: ${parsedInput.error.message}`,
tool,
input,
parsedInput.error.message,
),
sourceToolAssistantUUID: assistantMessage.uuid, sourceToolAssistantUUID: assistantMessage.uuid,
}), }),
}, },

View File

@@ -1,31 +0,0 @@
import { describe, expect, test } from 'bun:test'
import { SkillTool } from './SkillTool.js'
describe('SkillTool missing parameter handling', () => {
test('missing skill stays required at the schema level', async () => {
const parsed = SkillTool.inputSchema.safeParse({})
expect(parsed.success).toBe(false)
})
test('validateInput still returns an actionable error when called with missing skill', async () => {
const result = await SkillTool.validateInput?.({} as never, {
options: { tools: [] },
messages: [],
} as never)
expect(result).toEqual({
result: false,
message:
'Missing skill name. Pass the slash command name as the skill parameter (e.g., skill: "commit" for /commit, skill: "review-pr" for /review-pr).',
errorCode: 1,
})
})
test('valid skill input still parses and validates', async () => {
const parsed = SkillTool.inputSchema.safeParse({ skill: 'commit' })
expect(parsed.success).toBe(true)
})
})

View File

@@ -352,16 +352,6 @@ export const SkillTool: Tool<InputSchema, Output, Progress> = buildTool({
toAutoClassifierInput: ({ skill }) => skill ?? '', toAutoClassifierInput: ({ skill }) => skill ?? '',
async validateInput({ skill }, context): Promise<ValidationResult> { async validateInput({ skill }, context): Promise<ValidationResult> {
if (!skill || typeof skill !== 'string') {
return {
result: false,
message:
'Missing skill name. Pass the slash command name as the skill parameter ' +
'(e.g., skill: "commit" for /commit, skill: "review-pr" for /review-pr).',
errorCode: 1,
}
}
// Skills are just skill names, no arguments // Skills are just skill names, no arguments
const trimmed = skill.trim() const trimmed = skill.trim()
if (!trimmed) { if (!trimmed) {
@@ -444,7 +434,7 @@ export const SkillTool: Tool<InputSchema, Output, Progress> = buildTool({
context, context,
): Promise<PermissionDecision> { ): Promise<PermissionDecision> {
// Skills are just skill names, no arguments // Skills are just skill names, no arguments
const trimmed = skill ?? '' const trimmed = skill.trim()
// Remove leading slash if present (for compatibility) // Remove leading slash if present (for compatibility)
const commandName = trimmed.startsWith('/') ? trimmed.substring(1) : trimmed const commandName = trimmed.startsWith('/') ? trimmed.substring(1) : trimmed
@@ -602,7 +592,7 @@ export const SkillTool: Tool<InputSchema, Output, Progress> = buildTool({
// - Skill is a prompt-based skill // - Skill is a prompt-based skill
// Skills are just names, with optional arguments // Skills are just names, with optional arguments
const trimmed = skill ?? '' const trimmed = skill.trim()
// Remove leading slash if present (for compatibility) // Remove leading slash if present (for compatibility)
const commandName = trimmed.startsWith('/') ? trimmed.substring(1) : trimmed const commandName = trimmed.startsWith('/') ? trimmed.substring(1) : trimmed

View File

@@ -1,7 +1,6 @@
import { expect, test } from 'bun:test' import { expect, test } from 'bun:test'
import { z } from 'zod/v4' import { z } from 'zod/v4'
import { getEmptyToolPermissionContext, type Tool, type Tools } from '../Tool.js' import { getEmptyToolPermissionContext, type Tool, type Tools } from '../Tool.js'
import { SkillTool } from '../tools/SkillTool/SkillTool.js'
import { toolToAPISchema } from './api.js' import { toolToAPISchema } from './api.js'
test('toolToAPISchema preserves provider-specific schema keywords in input_schema', async () => { test('toolToAPISchema preserves provider-specific schema keywords in input_schema', async () => {
@@ -65,16 +64,3 @@ test('toolToAPISchema preserves provider-specific schema keywords in input_schem
}, },
}) })
}) })
test('toolToAPISchema keeps skill required for SkillTool', async () => {
const schema = await toolToAPISchema(SkillTool, {
getToolPermissionContext: async () => getEmptyToolPermissionContext(),
tools: [] as unknown as Tools,
agents: [],
})
expect((schema as { input_schema: unknown }).input_schema).toMatchObject({
type: 'object',
required: ['skill'],
})
})

View File

@@ -4,10 +4,6 @@ import { tmpdir } from 'os'
import { join } from 'path' import { join } from 'path'
import { extractDraggedFilePaths } from './dragDropPaths.js' import { extractDraggedFilePaths } from './dragDropPaths.js'
function escapeFinderDraggedPath(filePath: string): string {
return filePath.replace(/([\\ ])/g, '\\$1')
}
describe('extractDraggedFilePaths', () => { describe('extractDraggedFilePaths', () => {
// Paths that exist on any system. // Paths that exist on any system.
const thisFile = import.meta.path const thisFile = import.meta.path
@@ -84,12 +80,6 @@ describe('extractDraggedFilePaths', () => {
}) })
}) })
test('escapeFinderDraggedPath escapes spaces and backslashes', () => {
expect(escapeFinderDraggedPath('/tmp/my\\notes file.txt')).toBe(
'/tmp/my\\\\notes\\ file.txt',
)
})
// Backslash-escaped paths are a Finder/macOS + Linux convention — on // Backslash-escaped paths are a Finder/macOS + Linux convention — on
// Windows the shell-escape step is skipped, so these cases do not apply. // Windows the shell-escape step is skipped, so these cases do not apply.
if (process.platform !== 'win32') { if (process.platform !== 'win32') {
@@ -102,7 +92,7 @@ describe('extractDraggedFilePaths', () => {
test('resolves an escaped real file with a space in its name', () => { test('resolves an escaped real file with a space in its name', () => {
// Raw form matches what a terminal delivers on Finder drag. // Raw form matches what a terminal delivers on Finder drag.
const escaped = escapeFinderDraggedPath(spacedFile) const escaped = spacedFile.replace(/ /g, '\\ ')
expect(extractDraggedFilePaths(escaped)).toEqual([spacedFile]) expect(extractDraggedFilePaths(escaped)).toEqual([spacedFile])
}) })
}) })

View File

@@ -41,7 +41,7 @@ describe('hydrateGithubModelsTokenFromSecureStorage', () => {
})) }))
const { hydrateGithubModelsTokenFromSecureStorage } = await import( const { hydrateGithubModelsTokenFromSecureStorage } = await import(
'./githubModelsCredentials.js?hydrate=sets-token' './githubModelsCredentials.js'
) )
hydrateGithubModelsTokenFromSecureStorage() hydrateGithubModelsTokenFromSecureStorage()
expect(process.env.GITHUB_TOKEN).toBe('stored-secret') expect(process.env.GITHUB_TOKEN).toBe('stored-secret')
@@ -62,7 +62,7 @@ describe('hydrateGithubModelsTokenFromSecureStorage', () => {
})) }))
const { hydrateGithubModelsTokenFromSecureStorage } = await import( const { hydrateGithubModelsTokenFromSecureStorage } = await import(
'./githubModelsCredentials.js?hydrate=preserve-existing' './githubModelsCredentials.js'
) )
hydrateGithubModelsTokenFromSecureStorage() hydrateGithubModelsTokenFromSecureStorage()
expect(process.env.GITHUB_TOKEN).toBe('already') expect(process.env.GITHUB_TOKEN).toBe('already')

View File

@@ -1,11 +1,13 @@
import { describe, expect, test } from 'bun:test' import { describe, expect, test } from 'bun:test'
describe('readGithubModelsToken', () => { import {
test('returns undefined in bare mode', async () => { clearGithubModelsToken,
const { readGithubModelsToken } = await import( readGithubModelsToken,
'./githubModelsCredentials.js?read-bare-mode' saveGithubModelsToken,
) } from './githubModelsCredentials.js'
describe('readGithubModelsToken', () => {
test('returns undefined in bare mode', () => {
const prev = process.env.CLAUDE_CODE_SIMPLE const prev = process.env.CLAUDE_CODE_SIMPLE
process.env.CLAUDE_CODE_SIMPLE = '1' process.env.CLAUDE_CODE_SIMPLE = '1'
expect(readGithubModelsToken()).toBeUndefined() expect(readGithubModelsToken()).toBeUndefined()
@@ -18,11 +20,7 @@ describe('readGithubModelsToken', () => {
}) })
describe('saveGithubModelsToken / clearGithubModelsToken', () => { describe('saveGithubModelsToken / clearGithubModelsToken', () => {
test('save returns failure in bare mode', async () => { test('save returns failure in bare mode', () => {
const { saveGithubModelsToken } = await import(
'./githubModelsCredentials.js?save-bare-mode'
)
const prev = process.env.CLAUDE_CODE_SIMPLE const prev = process.env.CLAUDE_CODE_SIMPLE
process.env.CLAUDE_CODE_SIMPLE = '1' process.env.CLAUDE_CODE_SIMPLE = '1'
const r = saveGithubModelsToken('abc') const r = saveGithubModelsToken('abc')
@@ -35,11 +33,7 @@ describe('saveGithubModelsToken / clearGithubModelsToken', () => {
} }
}) })
test('clear succeeds in bare mode', async () => { test('clear succeeds in bare mode', () => {
const { clearGithubModelsToken } = await import(
'./githubModelsCredentials.js?clear-bare-mode'
)
const prev = process.env.CLAUDE_CODE_SIMPLE const prev = process.env.CLAUDE_CODE_SIMPLE
process.env.CLAUDE_CODE_SIMPLE = '1' process.env.CLAUDE_CODE_SIMPLE = '1'
expect(clearGithubModelsToken().success).toBe(true) expect(clearGithubModelsToken().success).toBe(true)

View File

@@ -23,19 +23,6 @@ export function readGithubModelsToken(): string | undefined {
} }
} }
export async function readGithubModelsTokenAsync(): Promise<string | undefined> {
if (isBareMode()) return undefined
try {
const data = (await getSecureStorage().readAsync()) as
| ({ githubModels?: GithubModelsCredentialBlob } & Record<string, unknown>)
| null
const t = data?.githubModels?.accessToken?.trim()
return t || undefined
} catch {
return undefined
}
}
/** /**
* If GitHub Models mode is on and no token is in the environment, copy the * If GitHub Models mode is on and no token is in the environment, copy the
* stored token into process.env so the OpenAI shim and validation see it. * stored token into process.env so the OpenAI shim and validation see it.

View File

@@ -97,12 +97,8 @@ export function renderToAnsiString(node: React.ReactNode, columns?: number): Pro
patchConsole: false patchConsole: false
}); });
// Wait for the component to exit naturally, with a timeout guard so // Wait for the component to exit naturally
// tests never hang indefinitely if a render error prevents exit(). await instance.waitUntilExit();
await Promise.race([
instance.waitUntilExit(),
new Promise<void>(resolve => setTimeout(resolve, 3000)),
]);
// Extract only the first frame's content to avoid duplication // Extract only the first frame's content to avoid duplication
// (Ink outputs multiple frames in non-TTY mode) // (Ink outputs multiple frames in non-TTY mode)