Compare commits

...

12 Commits

Author SHA1 Message Date
OpenClaude Worker 3
af5bb8fed8 fix: gate startup checks strictly on first submission, remove grace period (issue #363)
As gnanam1990 pointed out, the 3s grace period still allows the failure
mode: if a user pauses for a few seconds before typing, startup checks
fire and recommendation dialogs steal focus. A grace period is still a
timing mitigation, not a reliable fix.

New approach: startup checks only run after the user has submitted their
first message (submitCount > 0). No grace period, no timeout. This
guarantees the prompt gets first interaction — no dialog can steal focus
before the user has actually used the CLI.

If the user never submits a message, startup checks never run. That's
acceptable because with no user interaction there's no need for plugin
installations or marketplace seeding.
2026-04-08 11:55:23 +05:30
OpenClaude Worker 3
ad76b1174a fix: move startup checks after submitCount declaration to avoid temporal dead zone
Code quality bot flagged that submitCount was used before its declaration.
Moved the entire startup checks block to after the submitCount useState
declaration. Also added nullish coalescing (submitCount ?? 0) per bot
suggestion.
2026-04-08 11:47:26 +05:30
OpenClaude Worker 3
c457d9db3c fix: gate startup checks on prompt readiness, not just a timeout (issue #363)
The previous approach used a fixed 1500ms timeout, but as gnanam1990
pointed out, if a user pauses for >1.5s before typing the timer can
still fire and recommendation dialogs can steal focus. This is a
timing mitigation, not a reliable fix.

New approach: gate startup checks on actual prompt readiness:
1. After first message submission (submitCount > 0) — always safe
2. After grace period (3s) elapsed AND user is idle — safe because
   no dialog will interrupt an idle user who hasn't started typing
3. While user is actively typing — deferrred until they stop

This ensures startup checks never steal focus from a prompt the user
is about to type into, regardless of how long they pause before typing.

Also removes the old STARTUP_CHECK_DELAY_MS constant in favor of
STARTUP_GRACE_PERIOD_MS with clearer semantics.
2026-04-08 11:39:21 +05:30
OpenClaude Worker 3
d1f79088a1 fix: move startup checks effect after promptTypingSuppressionActive declaration
Fixes temporal dead zone warning flagged by code-quality bot.
promptTypingSuppressionActive is declared on line ~1340 but the
useEffect was on line ~800, causing a reference-before-declaration.
Also adds missing semicolons for style consistency.
2026-04-08 11:35:48 +05:30
OpenClaude Worker 3
106f85d0bf fix: defer startup plugin checks and suppress recommendation dialogs during startup window (issue #363)
Root cause: performStartupChecks() fires immediately on REPL mount,
triggering plugin loading which populates trackedFiles, which triggers
useLspPluginRecommendation to surface an LSP recommendation dialog.
Since promptTypingSuppressionActive is false before any user input,
getFocusedInputDialog() returns the dialog, unmounting PromptInput
entirely and making the CLI appear frozen.

Fix: Two-pronged approach:
1. Defer performStartupChecks by 1500ms and gate on
   promptTypingSuppressionActive so startup checks dont run while
   the user is typing or has early input buffered
2. Suppress lower-priority startup dialogs (LSP recommendation,
   plugin hint, desktop upsell) until startupChecksStartedRef is
   true, preventing them from stealing focus during the vulnerable
   startup window

This also explains why --bare mode and disabling plugins work:
--bare mode skips plugin loading entirely, and disabling the
autoresearch plugin eliminates the LSP match, so lspRecommendation
stays null and PromptInput renders normally.
2026-04-08 11:24:36 +05:30
lunamonke
3188f6ac66 fix example agents (#438) 2026-04-08 02:55:27 +08:00
Kevin Codex
69ea1f1e4a fix: restore default context window for unknown 3p models (#494)
* fix: restore default context window for unknown 3p models

* fix: add MiniMax context metadata
2026-04-08 02:45:49 +08:00
KRATOS
f9ce81bfb3 fix: handle missing skill parameter in SkillTool (#485)
* fix: handle missing skill parameter in SkillTool

* fix: preserve SkillTool schema contract

* fix: align SkillTool schema error output
2026-04-08 00:33:52 +08:00
Juan Camilo Auriti
4975cfc2e0 fix: strip Anthropic params from 3P resume paths (#479)
* fix: strip Anthropic-specific params from 3P provider paths

Three silent failure modes affecting all third-party provider users:

1. Thinking blocks serialized as <thinking> text corrupt multi-turn
   context — strip them instead of converting to raw text tags.

2. Unknown models fall through to 200k context window default, so
   auto-compact never triggers — use conservative 8k for unknown
   3P models with a warning log.

3. Session resume with thinking blocks causes 400 or context corruption
   on 3P providers — strip thinking/redacted_thinking content blocks
   from deserialized messages when resuming against a non-Anthropic
   provider.

Addresses findings 2, 3, and 5 from #248.

* test: align resume stripping expectation with orphan-thinking filter

* test: isolate provider env in conversation recovery tests

* test: move provider-sensitive resume coverage behind module mocks

* test: trim extra blank lines in conversation recovery test

Keep the focused provider-resume test diff clean so the regression branch stays easy to review.

Co-Authored-By: Claude Opus 4.6 <noreply@openclaude.dev>

---------

Co-authored-by: Claude Opus 4.6 <noreply@openclaude.dev>
2026-04-07 23:24:10 +08:00
ibaaaaal
600c01faf7 fix: restore Grep and Glob reliability on OpenAI paths (#461)
* fix: restore Grep and Glob reliability on OpenAI paths

Preserve Grep and Glob pattern fields during OpenAI/Codex schema sanitization, and fall back to system ripgrep when the packaged binary is missing. This keeps search tool schemas intact and improves Linux usability for npm/source installs.

Co-Authored-By: Claude Opus 4.6 <noreply@openclaude.dev>

* test: clean up ripgrep fallback test helpers

Remove the unused ripgrepCommand import and normalize mocked builtin ripgrep paths so the test behaves consistently across platforms.

Co-Authored-By: Claude Opus 4.6 <noreply@openclaude.dev>

* test: remove duplicate Codex URI schema case

Drop the duplicated WebFetch URI-format test in codexShim.test.ts so test names stay unique and failures remain easier to read.

Co-Authored-By: Claude Opus 4.6 <noreply@openclaude.dev>

* test: stabilize ripgrep fallback coverage

Avoid fs/module mocking in ripgrep fallback tests by extracting the config selection logic into a pure helper. This preserves the fallback coverage while removing the test interaction that caused the narrowed Bun hang repro.

Co-Authored-By: Claude Opus 4.6 <noreply@openclaude.dev>

* test: tighten ripgrep and schema coverage

Align the ripgrep fallback test with the actual auto-fallback branch, clean up strict typing in schema sanitizer tests, and tighten ripgrep error narrowing for type safety.

Co-Authored-By: Claude Opus 4.6 <noreply@openclaude.dev>

---------

Co-authored-by: Claude Opus 4.6 <noreply@openclaude.dev>
2026-04-07 17:26:00 +08:00
makskopchan-tech
b07bafa5bd Security code scanning (#459)
* fix: address code scanning alerts

Parse Gemini hostnames instead of matching raw URL substrings, redact gRPC error logs, and harden the Finder drag-drop test escape helper so the flagged paths are fixed without regressing working behavior.

* Potential fix for pull request finding 'CodeQL / Clear-text logging of sensitive information'

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

* fix: restore safe grpc error summaries

A later autofix commit removed the exported gRPC error summarizer while the new regression test still imported it. Restore the safe name/code-only summary so CI stays green without reintroducing clear-text logging.

* fix: keep grpc logging generic

Remove the stale helper/test pair and keep the gRPC startup and stream logs free of error-derived data so the CodeQL clear-text logging alert stays closed while the rest of the security fixes remain intact.

---------

Co-authored-by: OpenClaude Worker 3 <worker-3@openclaude.local>
Co-authored-by: Vasanth T <148849890+Vasanthdev2004@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-04-07 16:03:16 +08:00
changjiaoxigua
85aa8b0985 fix: add File polyfill for Node < 20 to prevent startup deadlock with proxy (#442)
When a proxy is configured, configureGlobalAgents() loads undici to set a
global dispatcher. However, undici v7.24.6 requires Node.js >= 20.18.1 and
references globalThis.File at module evaluation time for webidl type assertions.

Node 18 lacks the File global, causing ReferenceError inside the bundled
__commonJS require chain, which deadlocks due to unresolved circular
dependencies in the module initialization.

Fix by polyfilling globalThis.File early in cli.tsx entrypoint, before any
undici code loads. Try node:buffer.File (available in Node 18.13+), fallback
to minimal Blob-based stub.

Fixes: bun run start hangs indefinitely when HTTP_PROXY/HTTPS_PROXY is set

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 16:02:05 +08:00
24 changed files with 808 additions and 70 deletions

View File

@@ -68,11 +68,11 @@ When a user describes what they want an agent to do, you will:
assistant: "Now let me use the test-runner agent to run the tests"
</example>
- <example>
Context: User is creating an agent to respond to the word "hello" with a friendly jok.
user: "Hello"
assistant: "I'm going to use the ${AGENT_TOOL_NAME} tool to launch the greeting-responder agent to respond with a friendly joke"
Context: User is creating an agent for Claude Code product questions.
user: "How do I configure Claude Code hooks?"
assistant: "I'm going to use the ${AGENT_TOOL_NAME} tool to launch the claude-code-guide agent to answer the question"
<commentary>
Since the user is greeting, use the greeting-responder agent to respond with a friendly joke.
Since the user is asking how to use Claude Code, use the claude-code-guide agent.
</commentary>
</example>
- If the user mentioned or implied that the agent should be used proactively, you should include examples of this.

View File

@@ -8,6 +8,34 @@ import {
validateProviderEnvOrExit,
} from '../utils/providerValidation.js'
// OpenClaude: polyfill globalThis.File for Node < 20.
// undici v7 references `File` at module evaluation time (webidl type
// assertions). Node 18 lacks the global, causing a ReferenceError inside
// the bundled __commonJS require chain which deadlocks the process when a
// proxy is configured (configureGlobalAgents → require_undici).
// eslint-disable-next-line custom-rules/no-top-level-side-effects
if (typeof globalThis.File === 'undefined') {
try {
// Node 18.13+ exposes File in node:buffer but not as a global.
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { File: NodeFile } = require('node:buffer')
// @ts-expect-error -- polyfilling missing global
globalThis.File = NodeFile
} catch {
// Absolute fallback: stub so `MakeTypeAssertion(File)` doesn't throw.
// @ts-expect-error -- minimal polyfill
globalThis.File = class File extends Blob {
name: string
lastModified: number
constructor(parts: BlobPart[], name: string, opts?: FilePropertyBag) {
super(parts, opts)
this.name = name
this.lastModified = opts?.lastModified ?? Date.now()
}
}
}
}
// OpenClaude: disable experimental API betas by default.
// Tool search (defer_loading), global cache scope, and context management
// require internal API support not available to external accounts → 500.

View File

@@ -238,6 +238,7 @@ import { usePromptsFromClaudeInChrome } from 'src/hooks/usePromptsFromClaudeInCh
import { getTipToShowOnSpinner, recordShownTip } from 'src/services/tips/tipScheduler.js';
import type { Theme } from 'src/utils/theme.js';
import { isPromptTypingSuppressionActive } from './replInputSuppression.js';
import { shouldRunStartupChecks } from './replStartupGates.js';
import { checkAndDisableBypassPermissionsIfNeeded, checkAndDisableAutoModeIfNeeded, useKickOffCheckAndDisableBypassPermissionsIfNeeded, useKickOffCheckAndDisableAutoModeIfNeeded } from 'src/utils/permissions/bypassPermissionsKillswitch.js';
import { SandboxManager } from 'src/utils/sandbox/sandbox-adapter.js';
import { SANDBOX_NETWORK_ACCESS_TOOL_NAME } from 'src/cli/structuredIO.js';
@@ -792,10 +793,8 @@ export function REPL({
// accepts, and only then is the REPL component mounted and this effect runs.
// This ensures that plugin installations from repository and user settings only
// happen after explicit user consent to trust the current working directory.
useEffect(() => {
if (isRemoteSession) return;
void performStartupChecks(setAppState);
}, [setAppState, isRemoteSession]);
// Deferring startup checks is handled below (after promptTypingSuppressionActive
// is declared) to avoid temporal dead zone issues.
// Allow Claude in Chrome MCP to send prompts through MCP notifications
// and sync permission mode changes to the Chrome extension
@@ -1429,6 +1428,25 @@ export function REPL({
const activeRemote = sshRemote.isRemoteMode ? sshRemote : directConnect.isRemoteMode ? directConnect : remoteSession;
const [pastedContents, setPastedContents] = useState<Record<number, PastedContent>>({});
const [submitCount, setSubmitCount] = useState(0);
// Defer startup checks until the user has submitted their first message.
// A timeout or grace period is insufficient (issue #363): if the user pauses
// before typing, startup checks can still fire and recommendation dialogs
// steal focus. Only the user's first submission guarantees the prompt was
// the first thing they interacted with.
const startupChecksStartedRef = React.useRef(false);
const hasHadFirstSubmission = (submitCount ?? 0) > 0;
useEffect(() => {
if (isRemoteSession) return;
if (startupChecksStartedRef.current) return;
if (!shouldRunStartupChecks({
isRemoteSession,
hasStarted: startupChecksStartedRef.current,
hasHadFirstSubmission,
})) return;
startupChecksStartedRef.current = true;
void performStartupChecks(setAppState);
}, [setAppState, isRemoteSession, hasHadFirstSubmission]);
// Ref instead of state to avoid triggering React re-renders on every
// streaming text_delta. The spinner reads this via its animation timer.
const responseLengthRef = useRef(0);
@@ -2061,13 +2079,14 @@ export function REPL({
if (allowDialogsWithAnimation && showRemoteCallout) return 'remote-callout';
// LSP plugin recommendation (lowest priority - non-blocking suggestion)
if (allowDialogsWithAnimation && lspRecommendation) return 'lsp-recommendation';
// Suppress during startup window to prevent stealing focus from the prompt (issue #363)
if (allowDialogsWithAnimation && lspRecommendation && startupChecksStartedRef.current) return 'lsp-recommendation';
// Plugin hint from CLI/SDK stderr (same priority band as LSP rec)
if (allowDialogsWithAnimation && hintRecommendation) return 'plugin-hint';
if (allowDialogsWithAnimation && hintRecommendation && startupChecksStartedRef.current) return 'plugin-hint';
// Desktop app upsell (max 3 launches, lowest priority)
if (allowDialogsWithAnimation && showDesktopUpsellStartup) return 'desktop-upsell';
if (allowDialogsWithAnimation && showDesktopUpsellStartup && startupChecksStartedRef.current) return 'desktop-upsell';
return undefined;
}
const focusedInputDialog = getFocusedInputDialog();

View File

@@ -0,0 +1,53 @@
import { describe, expect, test } from 'bun:test'
import { shouldRunStartupChecks } from './replStartupGates.js'
describe('shouldRunStartupChecks', () => {
test('runs checks after first message submission', () => {
expect(shouldRunStartupChecks({
isRemoteSession: false,
hasStarted: false,
hasHadFirstSubmission: true,
})).toBe(true)
})
test('skips checks in remote sessions even after submission', () => {
expect(shouldRunStartupChecks({
isRemoteSession: true,
hasStarted: false,
hasHadFirstSubmission: true,
})).toBe(false)
})
test('skips checks if already started', () => {
expect(shouldRunStartupChecks({
isRemoteSession: false,
hasStarted: true,
hasHadFirstSubmission: true,
})).toBe(false)
})
test('does not run checks before first submission', () => {
expect(shouldRunStartupChecks({
isRemoteSession: false,
hasStarted: false,
hasHadFirstSubmission: false,
})).toBe(false)
})
test('does not run checks when idle before first submission', () => {
expect(shouldRunStartupChecks({
isRemoteSession: false,
hasStarted: false,
hasHadFirstSubmission: false,
})).toBe(false)
})
test('skips checks in remote session regardless of other conditions', () => {
expect(shouldRunStartupChecks({
isRemoteSession: true,
hasStarted: false,
hasHadFirstSubmission: false,
})).toBe(false)
})
})

View File

@@ -0,0 +1,35 @@
/**
* Startup gates for the REPL.
*
* Prevents startup plugin checks and recommendation dialogs from stealing
* focus before the user has interacted with the prompt.
*
* This addresses the root cause of issue #363: on mount, performStartupChecks
* triggers plugin loading, which populates trackedFiles, which triggers
* useLspPluginRecommendation to surface an LSP recommendation dialog. Since
* promptTypingSuppressionActive is false before the user has typed anything,
* getFocusedInputDialog() returns the dialog, unmounting PromptInput entirely.
*
* The fix gates startup checks on actual prompt interaction. A pure timeout
* or grace period is insufficient because pausing before typing would still
* allow dialogs to steal focus. Only the user's first submission guarantees
* the prompt is no longer in the vulnerable pre-interaction window.
*/
/**
* Determines whether startup checks should run.
*
* Startup checks are deferred until the user has submitted their first
* message. This guarantees the prompt was the first thing the user interacted
* with, so no recommendation dialog can steal focus before the first keystroke.
*/
export function shouldRunStartupChecks(options: {
isRemoteSession: boolean;
hasStarted: boolean;
hasHadFirstSubmission: boolean;
}): boolean {
if (options.isRemoteSession) return false;
if (options.hasStarted) return false;
if (!options.hasHadFirstSubmission) return false;
return true;
}

View File

@@ -201,6 +201,117 @@ describe('Codex request translation', () => {
])
})
test('preserves Grep tool pattern field in Codex strict schemas', () => {
const tools = convertToolsToResponsesTools([
{
name: 'Grep',
description: 'Search file contents',
input_schema: {
type: 'object',
properties: {
pattern: { type: 'string', description: 'Search pattern' },
path: { type: 'string' },
},
required: ['pattern'],
additionalProperties: false,
},
},
])
expect(tools).toEqual([
{
type: 'function',
name: 'Grep',
description: 'Search file contents',
parameters: {
type: 'object',
properties: {
pattern: { type: 'string', description: 'Search pattern' },
path: { type: 'string' },
},
required: ['pattern', 'path'],
additionalProperties: false,
},
strict: true,
},
])
})
test('preserves Glob tool pattern field in Codex strict schemas', () => {
const tools = convertToolsToResponsesTools([
{
name: 'Glob',
description: 'Find files by pattern',
input_schema: {
type: 'object',
properties: {
pattern: { type: 'string', description: 'Glob pattern' },
path: { type: 'string' },
},
required: ['pattern'],
additionalProperties: false,
},
},
])
expect(tools).toEqual([
{
type: 'function',
name: 'Glob',
description: 'Find files by pattern',
parameters: {
type: 'object',
properties: {
pattern: { type: 'string', description: 'Glob pattern' },
path: { type: 'string' },
},
required: ['pattern', 'path'],
additionalProperties: false,
},
strict: true,
},
])
})
test('strips validator pattern keyword but keeps string field named pattern in Codex schemas', () => {
const tools = convertToolsToResponsesTools([
{
name: 'RegexProbe',
description: 'Probe regex schema handling',
input_schema: {
type: 'object',
properties: {
pattern: {
type: 'string',
pattern: '^[a-z]+$',
},
},
required: ['pattern'],
additionalProperties: false,
},
},
])
expect(tools).toEqual([
{
type: 'function',
name: 'RegexProbe',
description: 'Probe regex schema handling',
parameters: {
type: 'object',
properties: {
pattern: {
type: 'string',
},
},
required: ['pattern'],
additionalProperties: false,
},
strict: true,
},
])
})
test('removes unsupported uri format from strict Responses schemas', () => {
const tools = convertToolsToResponsesTools([
{

View File

@@ -261,6 +261,73 @@ test('preserves Gemini tool call extra_content in follow-up requests', async ()
})
})
test('preserves Grep tool pattern field in OpenAI-compatible schemas', async () => {
let requestBody: Record<string, unknown> | undefined
globalThis.fetch = (async (_input, init) => {
requestBody = JSON.parse(String(init?.body))
return new Response(
JSON.stringify({
id: 'chatcmpl-grep-schema',
model: 'qwen/qwen3.6-plus',
choices: [
{
message: {
role: 'assistant',
content: 'done',
},
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: 'qwen/qwen3.6-plus',
system: 'test system',
messages: [{ role: 'user', content: 'Use Grep' }],
tools: [
{
name: 'Grep',
description: 'Search file contents',
input_schema: {
type: 'object',
properties: {
pattern: { type: 'string', description: 'Search pattern' },
path: { type: 'string' },
},
required: ['pattern'],
additionalProperties: false,
},
},
],
max_tokens: 64,
stream: false,
})
const tools = requestBody?.tools as Array<Record<string, unknown>> | undefined
const grepTool = tools?.find(tool => (tool.function as Record<string, unknown>)?.name === 'Grep') as
| { function?: { parameters?: { properties?: Record<string, unknown>; required?: string[] } } }
| undefined
expect(Object.keys(grepTool?.function?.parameters?.properties ?? {})).toContain('pattern')
expect(grepTool?.function?.parameters?.required).toContain('pattern')
})
test('does not infer Gemini mode from OPENAI_BASE_URL path substrings', async () => {
let capturedAuthorization: string | null = null

View File

@@ -195,10 +195,12 @@ function convertContentBlocks(
// handled separately
break
case 'thinking':
// Append thinking as text with a marker for models that support reasoning
if (block.thinking) {
parts.push({ type: 'text', text: `<thinking>${block.thinking}</thinking>` })
}
case 'redacted_thinking':
// Strip thinking blocks for OpenAI-compatible providers.
// These are Anthropic-specific content types that 3P providers
// don't understand. Serializing them as <thinking> text corrupts
// multi-turn context: the model sees the tags as part of its
// previous reply and may mimic or misattribute them.
break
default:
if (block.text) {

View File

@@ -0,0 +1,33 @@
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,6 +43,7 @@ import { FILE_READ_TOOL_NAME } from '../../tools/FileReadTool/prompt.js'
import { FILE_WRITE_TOOL_NAME } from '../../tools/FileWriteTool/prompt.js'
import { NOTEBOOK_EDIT_TOOL_NAME } from '../../tools/NotebookEditTool/constants.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 {
isDeferredTool,
@@ -596,6 +597,31 @@ 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(
tool: Tool,
toolUseID: string,
@@ -614,7 +640,9 @@ async function checkPermissionsAndCallTool(
// Validate input types with zod (surprisingly, the model is not great at generating valid input)
const parsedInput = tool.inputSchema.safeParse(input)
if (!parsedInput.success) {
let errorContent = formatZodValidationError(tool.name, parsedInput.error)
const fallbackErrorContent = formatZodValidationError(tool.name, parsedInput.error)
let errorContent =
getSchemaValidationErrorOverride(tool, input) ?? fallbackErrorContent
const schemaHint = buildSchemaNotSentHint(
tool,
@@ -672,7 +700,11 @@ async function checkPermissionsAndCallTool(
tool_use_id: toolUseID,
},
],
toolUseResult: `InputValidationError: ${parsedInput.error.message}`,
toolUseResult: getSchemaValidationToolUseResult(
tool,
input,
parsedInput.error.message,
),
sourceToolAssistantUUID: assistantMessage.uuid,
}),
},

View File

@@ -156,34 +156,24 @@ ${AGENT_TOOL_NAME}({
const currentExamples = `Example usage:
<example_agent_descriptions>
"test-runner": use this agent after you are done writing code to run tests
"greeting-responder": use this agent to respond to user greetings with a friendly joke
"claude-code-guide": use this agent when the user asks how Claude Code works or how to use its features
"statusline-setup": use this agent to configure the user's Claude Code status line setting
</example_agent_descriptions>
<example>
user: "Please write a function that checks if a number is prime"
assistant: I'm going to use the ${FILE_WRITE_TOOL_NAME} tool to write the following code:
<code>
function isPrime(n) {
if (n <= 1) return false
for (let i = 2; i * i <= n; i++) {
if (n % i === 0) return false
}
return true
}
</code>
user: "How do I configure Claude Code hooks?"
<commentary>
Since a significant piece of code was written and the task was completed, now use the test-runner agent to run the tests
This is a Claude Code usage question, so use the claude-code-guide agent
</commentary>
assistant: Uses the ${AGENT_TOOL_NAME} tool to launch the test-runner agent
assistant: Uses the ${AGENT_TOOL_NAME} tool to launch the claude-code-guide agent
</example>
<example>
user: "Hello"
user: "Set up my Claude Code status line"
<commentary>
Since the user is greeting, use the greeting-responder agent to respond with a friendly joke
This matches the statusline-setup agent, so use it to configure the setting
</commentary>
assistant: "I'm going to use the ${AGENT_TOOL_NAME} tool to launch the greeting-responder agent"
assistant: "I'm going to use the ${AGENT_TOOL_NAME} tool to launch the statusline-setup agent"
</example>
`

View File

@@ -0,0 +1,31 @@
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,6 +352,16 @@ export const SkillTool: Tool<InputSchema, Output, Progress> = buildTool({
toAutoClassifierInput: ({ skill }) => skill ?? '',
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
const trimmed = skill.trim()
if (!trimmed) {
@@ -434,7 +444,7 @@ export const SkillTool: Tool<InputSchema, Output, Progress> = buildTool({
context,
): Promise<PermissionDecision> {
// Skills are just skill names, no arguments
const trimmed = skill.trim()
const trimmed = skill ?? ''
// Remove leading slash if present (for compatibility)
const commandName = trimmed.startsWith('/') ? trimmed.substring(1) : trimmed
@@ -592,7 +602,7 @@ export const SkillTool: Tool<InputSchema, Output, Progress> = buildTool({
// - Skill is a prompt-based skill
// Skills are just names, with optional arguments
const trimmed = skill.trim()
const trimmed = skill ?? ''
// Remove leading slash if present (for compatibility)
const commandName = trimmed.startsWith('/') ? trimmed.substring(1) : trimmed

View File

@@ -1,6 +1,7 @@
import { expect, test } from 'bun:test'
import { z } from 'zod/v4'
import { getEmptyToolPermissionContext, type Tool, type Tools } from '../Tool.js'
import { SkillTool } from '../tools/SkillTool/SkillTool.js'
import { toolToAPISchema } from './api.js'
test('toolToAPISchema preserves provider-specific schema keywords in input_schema', async () => {
@@ -64,3 +65,16 @@ 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

@@ -94,3 +94,22 @@ test('gpt-5.4 family keeps large max output overrides within provider limits', (
expect(getMaxOutputTokensForModel('gpt-5.4-mini')).toBe(128_000)
expect(getMaxOutputTokensForModel('gpt-5.4-nano')).toBe(128_000)
})
test('MiniMax-M2.7 uses explicit provider-specific context and output caps', () => {
process.env.CLAUDE_CODE_USE_OPENAI = '1'
delete process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS
expect(getContextWindowForModel('MiniMax-M2.7')).toBe(204_800)
expect(getModelMaxOutputTokens('MiniMax-M2.7')).toEqual({
default: 131_072,
upperLimit: 131_072,
})
expect(getMaxOutputTokensForModel('MiniMax-M2.7')).toBe(131_072)
})
test('unknown openai-compatible models still use the conservative fallback window', () => {
process.env.CLAUDE_CODE_USE_OPENAI = '1'
delete process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS
expect(getContextWindowForModel('some-unknown-3p-model')).toBe(8_000)
})

View File

@@ -72,16 +72,23 @@ export function getContextWindowForModel(
return 1_000_000
}
// OpenAI-compatible provider — use known context windows for the model
if (
// OpenAI-compatible provider — use known context windows for the model.
// Unknown models get a conservative 8k default so auto-compact triggers
// before hitting a hard context_window_exceeded error.
const isOpenAIProvider =
isEnvTruthy(process.env.CLAUDE_CODE_USE_OPENAI) ||
isEnvTruthy(process.env.CLAUDE_CODE_USE_GEMINI) ||
isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB)
) {
if (isOpenAIProvider) {
const openaiWindow = getOpenAIContextWindow(model)
if (openaiWindow !== undefined) {
return openaiWindow
}
console.error(
`[context] Warning: model "${model}" not in context window table — using conservative 8k default. ` +
'Add it to src/utils/model/openaiContextWindows.ts for accurate compaction.',
)
return 8_000
}
const cap = getModelCapability(model)

View File

@@ -69,3 +69,93 @@ test('loadConversationForResume rejects oversized transcripts before resume hook
)
expect(hookSpy).not.toHaveBeenCalled()
})
test('deserializeMessagesWithInterruptDetection strips thinking blocks only for OpenAI-compatible providers', async () => {
const serializedMessages = [
user(id(10), 'hello'),
{
type: 'assistant',
uuid: id(11),
parentUuid: id(10),
timestamp: ts,
cwd: '/tmp',
sessionId,
version: 'test',
message: {
role: 'assistant',
content: [
{ type: 'thinking', thinking: 'secret reasoning' },
{ type: 'text', text: 'visible reply' },
],
},
},
{
type: 'assistant',
uuid: id(12),
parentUuid: id(11),
timestamp: ts,
cwd: '/tmp',
sessionId,
version: 'test',
message: {
role: 'assistant',
content: [{ type: 'thinking', thinking: 'only hidden reasoning' }],
},
},
user(id(13), 'follow up'),
]
mock.module('./model/providers.js', () => ({
getAPIProvider: () => 'openai',
isOpenAICompatibleProvider: (provider: string) =>
provider === 'openai' ||
provider === 'gemini' ||
provider === 'github' ||
provider === 'codex',
}))
const openaiModule = await import(`./conversationRecovery.ts?provider=openai-${Date.now()}`)
const thirdParty = openaiModule.deserializeMessagesWithInterruptDetection(serializedMessages as never[])
const thirdPartyAssistantMessages = thirdParty.messages.filter(
message => message.type === 'assistant',
)
expect(thirdPartyAssistantMessages).toHaveLength(2)
expect(thirdPartyAssistantMessages[0]?.message?.content).toEqual([
{ type: 'text', text: 'visible reply' },
])
expect(
JSON.stringify(thirdPartyAssistantMessages.map(message => message.message?.content)),
).not.toContain('secret reasoning')
expect(
JSON.stringify(thirdPartyAssistantMessages.map(message => message.message?.content)),
).not.toContain('only hidden reasoning')
mock.restore()
mock.module('./model/providers.js', () => ({
getAPIProvider: () => 'bedrock',
isOpenAICompatibleProvider: (provider: string) =>
provider === 'openai' ||
provider === 'gemini' ||
provider === 'github' ||
provider === 'codex',
}))
const bedrockModule = await import(`./conversationRecovery.ts?provider=bedrock-${Date.now()}`)
const anthropicCompatible = bedrockModule.deserializeMessagesWithInterruptDetection(serializedMessages as never[])
const anthropicAssistantMessages = anthropicCompatible.messages.filter(
message => message.type === 'assistant',
)
expect(anthropicAssistantMessages).toHaveLength(2)
expect(anthropicAssistantMessages[0]?.message?.content).toEqual([
{ type: 'thinking', thinking: 'secret reasoning' },
{ type: 'text', text: 'visible reply' },
])
expect(
JSON.stringify(anthropicAssistantMessages.map(message => message.message?.content)),
).toContain('secret reasoning')
expect(
JSON.stringify(anthropicAssistantMessages.map(message => message.message?.content)),
).not.toContain('only hidden reasoning')
})

View File

@@ -13,6 +13,7 @@ const originalSimple = process.env.CLAUDE_CODE_SIMPLE
const sessionId = '00000000-0000-4000-8000-000000001999'
const ts = '2026-04-02T00:00:00.000Z'
function id(n: number): string {
return `00000000-0000-4000-8000-${String(n).padStart(12, '0')}`
}
@@ -76,4 +77,3 @@ test('loadConversationForResume rejects oversized reconstructed transcripts', as
'Reconstructed transcript is too large to resume safely',
)
})

View File

@@ -24,6 +24,7 @@ import {
type FileHistorySnapshot,
} from './fileHistory.js'
import { logError } from './log.js'
import { getAPIProvider } from './model/providers.js'
import {
createAssistantMessage,
createUserMessage,
@@ -177,6 +178,25 @@ export type DeserializeResult = {
turnInterruptionState: TurnInterruptionState
}
/**
* Remove thinking/redacted_thinking content blocks from assistant messages.
* Messages that become empty after stripping are removed entirely.
*/
function stripThinkingBlocks(messages: NormalizedMessage[]): NormalizedMessage[] {
return messages.reduce<NormalizedMessage[]>((acc, msg) => {
if (msg.type !== 'assistant' || !Array.isArray(msg.message?.content)) {
acc.push(msg)
return acc
}
const filtered = msg.message.content.filter(
(block: { type?: string }) => block.type !== 'thinking' && block.type !== 'redacted_thinking',
)
if (filtered.length === 0) return acc
acc.push({ ...msg, message: { ...msg.message, content: filtered } })
return acc
}, [])
}
/**
* Deserializes messages from a log file into the format expected by the REPL.
* Filters unresolved tool uses, orphaned thinking messages, and appends a
@@ -227,10 +247,19 @@ export function deserializeMessagesWithInterruptDetection(
filteredToolUses,
) as NormalizedMessage[]
// Strip thinking/redacted_thinking content blocks from assistant messages
// when resuming against a 3P provider. These Anthropic-specific blocks cause
// 400 errors or context corruption on OpenAI-compatible providers (issue #248 finding 5).
const provider = getAPIProvider()
const isThirdPartyProvider = provider !== 'firstParty' && provider !== 'bedrock' && provider !== 'vertex' && provider !== 'foundry'
const thinkingStripped = isThirdPartyProvider
? stripThinkingBlocks(filteredThinking)
: filteredThinking
// Filter out assistant messages with only whitespace text content.
// This can happen when model outputs "\n\n" before thinking, user cancels mid-stream.
const filteredMessages = filterWhitespaceOnlyAssistantMessages(
filteredThinking,
thinkingStripped,
) as NormalizedMessage[]
const internalState = detectTurnInterruption(filteredMessages)

View File

@@ -44,6 +44,10 @@ const OPENAI_CONTEXT_WINDOWS: Record<string, number> = {
'mistral-large-latest': 131_072,
'mistral-small-latest': 131_072,
// MiniMax
'MiniMax-M2.7': 204_800,
'minimax-m2.7': 204_800,
// Google (via OpenRouter)
'google/gemini-2.0-flash':1_048_576,
'google/gemini-2.5-pro': 1_048_576,
@@ -110,6 +114,10 @@ const OPENAI_MAX_OUTPUT_TOKENS: Record<string, number> = {
'mistral-large-latest': 32_768,
'mistral-small-latest': 32_768,
// MiniMax
'MiniMax-M2.7': 131_072,
'minimax-m2.7': 131_072,
// Google (via OpenRouter)
'google/gemini-2.0-flash': 8_192,
'google/gemini-2.5-pro': 65_536,

View File

@@ -1,11 +1,52 @@
import { expect, test } from 'bun:test'
import path from 'path'
import { wrapRipgrepUnavailableError } from './ripgrep.ts'
import { resolveRipgrepConfig, wrapRipgrepUnavailableError } from './ripgrep.js'
const MOCK_BUILTIN_PATH = path.normalize(
process.platform === 'win32'
? `vendor/ripgrep/${process.arch}-win32/rg.exe`
: `vendor/ripgrep/${process.arch}-${process.platform}/rg`,
)
test('ripgrepCommand falls back to system rg when builtin binary is missing', () => {
const config = resolveRipgrepConfig({
userWantsSystemRipgrep: false,
bundledMode: false,
builtinCommand: MOCK_BUILTIN_PATH,
builtinExists: false,
systemExecutablePath: '/usr/bin/rg',
processExecPath: '/fake/bun',
})
expect(config).toMatchObject({
mode: 'system',
command: 'rg',
args: [],
})
})
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',
})
expect(config).toMatchObject({
mode: 'builtin',
command: MOCK_BUILTIN_PATH,
args: [],
})
})
test('wrapRipgrepUnavailableError explains missing packaged fallback', () => {
const error = wrapRipgrepUnavailableError(
{ code: 'ENOENT', message: 'spawn rg ENOENT' },
{ mode: 'builtin', command: 'C:\\fake\\vendor\\ripgrep\\rg.exe' },
{ mode: 'builtin', command: 'C:\\fake\\vendor\\ripgrep\\rg.exe', args: [] },
'win32',
)
@@ -18,7 +59,7 @@ test('wrapRipgrepUnavailableError explains missing packaged fallback', () => {
test('wrapRipgrepUnavailableError explains missing system ripgrep', () => {
const error = wrapRipgrepUnavailableError(
{ code: 'ENOENT', message: 'spawn rg ENOENT' },
{ mode: 'system', command: 'rg' },
{ mode: 'system', command: 'rg', args: [] },
'linux',
)

View File

@@ -1,5 +1,6 @@
import type { ChildProcess, ExecFileException } from 'child_process'
import { execFile, spawn } from 'child_process'
import { existsSync } from 'fs'
import memoize from 'lodash-es/memoize.js'
import { homedir } from 'os'
import * as path from 'path'
@@ -30,40 +31,72 @@ type RipgrepConfig = {
type RipgrepErrorLike = Pick<NodeJS.ErrnoException, 'code' | 'message'>
const getRipgrepConfig = memoize((): RipgrepConfig => {
const userWantsSystemRipgrep = isEnvDefinedFalsy(
process.env.USE_BUILTIN_RIPGREP,
)
function isErrnoException(error: unknown): error is NodeJS.ErrnoException {
return error instanceof Error
}
// Try system ripgrep if user wants it
if (userWantsSystemRipgrep) {
const { cmd: systemPath } = findExecutable('rg', [])
if (systemPath !== 'rg') {
// SECURITY: Use command name 'rg' instead of systemPath to prevent PATH hijacking
// If we used systemPath, a malicious ./rg.exe in current directory could be executed
// Using just 'rg' lets the OS resolve it safely with NoDefaultCurrentDirectoryInExePath protection
return { mode: 'system', command: 'rg', args: [] }
}
type ResolveRipgrepConfigArgs = {
userWantsSystemRipgrep: boolean
bundledMode: boolean
builtinCommand: string
builtinExists: boolean
systemExecutablePath: string
processExecPath?: string
}
export function resolveRipgrepConfig({
userWantsSystemRipgrep,
bundledMode,
builtinCommand,
builtinExists,
systemExecutablePath,
processExecPath = process.execPath,
}: ResolveRipgrepConfigArgs): RipgrepConfig {
if (userWantsSystemRipgrep && systemExecutablePath !== 'rg') {
// SECURITY: Use command name 'rg' instead of systemExecutablePath to prevent PATH hijacking
return { mode: 'system', command: 'rg', args: [] }
}
// In bundled (native) mode, ripgrep is statically compiled into bun-internal
// and dispatches based on argv[0]. We spawn ourselves with argv0='rg'.
if (isInBundledMode()) {
if (bundledMode) {
return {
mode: 'embedded',
command: process.execPath,
command: processExecPath,
args: ['--no-config'],
argv0: 'rg',
}
}
if (builtinExists) {
return { mode: 'builtin', command: builtinCommand, args: [] }
}
if (systemExecutablePath !== 'rg') {
return { mode: 'system', command: 'rg', args: [] }
}
return { mode: 'builtin', command: builtinCommand, args: [] }
}
const getRipgrepConfig = memoize((): RipgrepConfig => {
const userWantsSystemRipgrep = isEnvDefinedFalsy(
process.env.USE_BUILTIN_RIPGREP,
)
const bundledMode = isInBundledMode()
const rgRoot = path.resolve(__dirname, 'vendor', 'ripgrep')
const command =
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 { mode: 'builtin', command, args: [] }
return resolveRipgrepConfig({
userWantsSystemRipgrep,
bundledMode,
builtinCommand,
builtinExists,
systemExecutablePath,
})
})
export function ripgrepCommand(): {
@@ -324,7 +357,9 @@ async function ripGrepFileCount(
if (settled) return
settled = true
reject(
err.code === 'ENOENT' ? wrapRipgrepUnavailableError(err) : err,
isErrnoException(err) && err.code === 'ENOENT'
? wrapRipgrepUnavailableError(err)
: err,
)
})
})
@@ -388,7 +423,9 @@ export async function ripGrepStream(
if (settled) return
settled = true
reject(
err.code === 'ENOENT' ? wrapRipgrepUnavailableError(err) : err,
isErrnoException(err) && err.code === 'ENOENT'
? wrapRipgrepUnavailableError(err)
: err,
)
})
})
@@ -436,7 +473,9 @@ export async function ripGrep(
const CRITICAL_ERROR_CODES = ['ENOENT', 'EACCES', 'EPERM']
if (CRITICAL_ERROR_CODES.includes(error.code as string)) {
reject(
error.code === 'ENOENT' ? wrapRipgrepUnavailableError(error) : error,
isErrnoException(error) && error.code === 'ENOENT'
? wrapRipgrepUnavailableError(error)
: error,
)
return
}

View File

@@ -0,0 +1,68 @@
import { describe, expect, test } from 'bun:test'
import { sanitizeSchemaForOpenAICompat } from './schemaSanitizer'
describe('sanitizeSchemaForOpenAICompat', () => {
test('preserves Grep-like properties.pattern while keeping it required', () => {
const schema = {
type: 'object',
properties: {
pattern: {
type: 'string',
description: 'The regular expression pattern to search for in file contents',
},
path: { type: 'string' },
glob: { type: 'string' },
},
required: ['pattern'],
}
const sanitized = sanitizeSchemaForOpenAICompat(schema)
const properties = sanitized.properties as Record<string, unknown> | undefined
expect(Object.keys(properties ?? {})).toEqual(['pattern', 'path', 'glob'])
expect(properties?.pattern).toEqual({
type: 'string',
description: 'The regular expression pattern to search for in file contents',
})
expect(sanitized.required).toEqual(['pattern'])
})
test('preserves Glob-like properties.pattern while keeping it required', () => {
const schema = {
type: 'object',
properties: {
pattern: {
type: 'string',
description: 'The glob pattern to match files against',
},
path: { type: 'string' },
},
required: ['pattern'],
}
const sanitized = sanitizeSchemaForOpenAICompat(schema)
const properties = sanitized.properties as Record<string, unknown> | undefined
expect(Object.keys(properties ?? {})).toEqual(['pattern', 'path'])
expect(properties?.pattern).toEqual({
type: 'string',
description: 'The glob pattern to match files against',
})
expect(sanitized.required).toEqual(['pattern'])
})
test('strips JSON Schema validator pattern from string schemas', () => {
const schema = {
type: 'string',
pattern: '^[a-z]+$',
minLength: 1,
}
const sanitized = sanitizeSchemaForOpenAICompat(schema)
expect(sanitized).toEqual({
type: 'string',
})
})
})

View File

@@ -33,6 +33,15 @@ function stripSchemaKeywords(schema: unknown, keywords: Set<string>): unknown {
const result: Record<string, unknown> = {}
for (const [key, value] of Object.entries(schema)) {
if (key === 'properties' && isSchemaRecord(value)) {
const sanitizedProps: Record<string, unknown> = {}
for (const [propName, propSchema] of Object.entries(value)) {
sanitizedProps[propName] = stripSchemaKeywords(propSchema, keywords)
}
result[key] = sanitizedProps
continue
}
if (keywords.has(key)) {
continue
}
@@ -215,10 +224,13 @@ export function sanitizeSchemaForOpenAICompat(
}
}
if (Array.isArray(record.required) && isSchemaRecord(record.properties)) {
const properties = isSchemaRecord(record.properties)
? record.properties
: undefined
if (Array.isArray(record.required) && properties) {
record.required = record.required.filter(
(value): value is string =>
typeof value === 'string' && value in record.properties,
(value): value is string => typeof value === 'string' && value in properties,
)
}