mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-20 23:35:51 +00:00
fix: resolve dependency audit findings precisely (#361)
* fix: harden ACP communication boundaries Harden ACP communication boundaries Remote ACP sessions now cannot widen permission mode through untrusted metadata or client payloads. WebSocket ACP ingress measures payloads by bytes before binary decode, and prompt queue handoff keeps exactly one prompt active while queued prompts are drained FIFO. Constraint: ACP remote clients must not be able to open bypassPermissions without local launch intent Constraint: WebSocket payload limits must be byte-based and checked before binary decode Rejected: Keep promptToQueryContent wrapper | no production consumers remained after prompt conversion single-sourcing Confidence: high Scope-risk: moderate Directive: Do not re-enable remote bypassPermissions from _meta unless a local launch gate is verified in both acp-link and agent Tested: targeted ACP/RCS/acp-link prompt queue, bridge, permission, payload, and prompt conversion tests; bun run typecheck; bun run build Not-tested: Manual live ACP/RCS session against an external client * fix: restore repository verification gates Keep the full repository test, typecheck, build, and Biome lint gates usable after the ACP fix pass. This commit is intentionally separate from the ACP behavior change: it fixes Windows-safe Langfuse home redaction, removes stale lint suppressions, resolves Biome warning/info diagnostics, and keeps env expansion tests explicit without template-placeholder lint noise. Constraint: The project completion contract requires full typecheck, lint, test, and build evidence Rejected: Leave warning/info diagnostics as historical noise | they obscure future gate regressions and weaken flow-impact claims Confidence: high Scope-risk: narrow Directive: Keep repository gate cleanup separate from feature fixes when it is not part of the same runtime path Tested: bunx biome lint src/; bunx tsc --noEmit; bun test src/services/mcp/__tests__/envExpansion.test.ts src/utils/__tests__/sliceAnsi.test.ts src/utils/__tests__/stringUtils.test.ts; bun test; bun run build Not-tested: Manual Langfuse export against a real external Langfuse service * fix: harden ACP failure boundaries after review Deep review found several paths that made ACP communication failures look normal: prompt errors could finish as end_turn, permission pipeline exceptions could fall through to client approval, tool rawInput was deep-copied with JSON, and acp-link accepted unbounded or unvalidated WebSocket payloads. This keeps the behavior fail-closed, validates WS payloads before dispatch, caps payload size before JSON parse, and preserves cancellation intent with a generation counter. Constraint: User explicitly rejected pseudo-fixes, fallback behavior, and unbounded payload handling Rejected: Keep JSON stringify/parse rawInput copy | duplicates large payloads and silently drops non-JSON inputs Rejected: Delegate permission pipeline errors to client approval | allows a broken local permission check to be bypassed Confidence: high Scope-risk: moderate Directive: Do not convert ACP errors into normal end_turn responses without a protocol-level reason and regression tests Tested: bun test src/services/acp/__tests__/agent.test.ts src/services/acp/__tests__/bridge.test.ts src/services/acp/__tests__/permissions.test.ts Tested: bun test packages/acp-link/src/__tests__/server.test.ts Tested: bunx tsc --noEmit Tested: bunx biome lint src/ packages/acp-link/src/ Tested: bun run test:all Tested: bun run build Not-tested: Manual end-to-end ACP client session over a real editor WebSocket * fix: prevent ACP coverage runs from seeing partial mocks GitHub Actions failed under bun test --coverage because permissions.test.ts replaced ../bridge.js with a partial mock that omitted forwardSessionUpdates. Coverage worker ordering on Linux let sibling tests observe that incomplete module. This isolates ACP test mocks by snapshotting real exports, overriding only requested symbols, and restoring mocks in LIFO order. The shared helper also keeps the same behavior in agent.test.ts without duplicating mock infrastructure. Constraint: bun:test mock.module is process-global inside a worker. Rejected: Add fallback exports or production guards | the bridge export exists; the failure was test mock pollution. Rejected: Keep per-file helper copies | duplication would let restore semantics drift again. Confidence: high Scope-risk: narrow Directive: Prefer safeMockModule for partial mocks of real modules in ACP tests; plain mock.module is only appropriate for fully synthetic modules or isolated tests. Tested: bun test src/services/acp/__tests__/agent.test.ts src/services/acp/__tests__/bridge.test.ts src/services/acp/__tests__/permissions.test.ts Tested: bun test --coverage --coverage-reporter=lcov Tested: bunx tsc --noEmit Tested: bun run lint Tested: git diff --check Not-tested: Linux runner directly before push * fix: normalize ACP bypass requests without warning noise The previous CI repair removed the failing partial bridge mock, but it also added a shared safeMockModule helper and left the acp-link bypass normalization warning in the real new_session path. This tightens the fix: acp-link now treats an unauthorized client bypass request as normal permission-mode normalization without emitting a warning, and the ACP permission test explicitly preserves the real bridge and permission exports instead of using a shared helper. The agent test keeps its local mock preservation but names it by behavior and restores mocks in LIFO order. Constraint: CI output should not contain expected warning noise for covered policy branches. Rejected: Silence the test only | the normal new_session path would still warn for an expected normalization branch. Rejected: Keep the shared safeMockModule helper | the failing module was specific and should be fixed by preserving real exports at the mocking site. Confidence: high Scope-risk: narrow Directive: Treat client-requested bypassPermissions as data to normalize unless the local default explicitly enables bypass. Tested: bun test packages/acp-link/src/__tests__/server.test.ts Tested: bun test src/services/acp/__tests__/agent.test.ts src/services/acp/__tests__/bridge.test.ts src/services/acp/__tests__/permissions.test.ts Tested: bun test --coverage --coverage-reporter=lcov with UPPER_WARN_COUNT=0 Tested: bun run test:all Tested: bun run lint Tested: bunx tsc --noEmit Tested: git diff --check * fix: harden ACP bypass and CI warning gates ACP clients must not be able to enter bypassPermissions unless the local ACP gate and process environment both allow it. The same gate now controls session creation, explicit mode changes, and the ExitPlanMode option list, while session setup restores process.cwd so coverage and later work do not inherit ACP session state. Constraint: CI must stay warning-clean without hiding real ACP permission failures Rejected: Logging rejected bypass requests on the normal new_session path | it preserves audit text but reintroduces warning noise the runtime should not emit Rejected: Broad CI=true postinstall skip | it hides explicit Chrome MCP setup checks outside the install path Confidence: high Scope-risk: moderate Directive: Keep bypassPermissions gated through one ACP availability decision before exposing it to clients Tested: bun test src/services/acp/__tests__/permissions.test.ts src/services/acp/__tests__/agent.test.ts packages/acp-link/src/__tests__/server.test.ts Tested: bun run test:all Tested: bun run lint Tested: bun run build:vite with zero warning matches Tested: bun test --coverage --coverage-reporter lcov --coverage-dir coverage produced non-empty lcov with SF records and zero filtered warning matches Not-tested: GitHub Actions result after this push * fix: remove remaining CI warning noise The CI log still had three non-failing warnings after the ACP hardening commit: git init default-branch advice from checkout, a Node 20 action-runtime deprecation, and one additional known Vite dynamic-import diagnostic that only surfaced on Linux. The workflow now provides explicit git config and opts actions into Node 24, while Vite keeps a narrow allowlist for acknowledged optimizer diagnostics. Constraint: Do not use shell log filtering to hide warnings after they happen Rejected: Grep warning lines out of CI output | it would make future diagnostics harder to find Confidence: high Scope-risk: narrow Directive: Add new Vite warning allowlist entries only after checking that they are existing optimizer diagnostics, not new application defects Tested: bunx tsc --noEmit --pretty false Tested: bunx biome lint .github/workflows/ci.yml vite.config.ts Tested: bun run build:vite with zero warning matches Not-tested: GitHub Actions result after this push * fix: reject unauthorized ACP bypass and harden CI actions ACP clients now fail closed when permissionMode is malformed, unknown, or requests bypass without a local bypass opt-in. acp-link validates new_session input before forwarding to the agent and returns client error frames for expected unauthorized requests without logging create-failed noise. The direct AcpAgent path independently rejects invalid _meta.permissionMode and unauthorized bypass instead of falling back to settings. CI workflows and generated GitHub App templates now use Node 24-compatible actions pinned to immutable commit SHAs, and acp-link startup output no longer prints the auth token. Constraint: Must not hide warnings with test isolation or log filtering Rejected: Silent fallback to local permission mode | accepts invalid client intent and masks boundary behavior Rejected: Broad dependency churn from bun update | audit remained failing while package and lockfile churn expanded scope Confidence: high Scope-risk: moderate Directive: Client-provided permissionMode must stay fail-closed before reaching AcpAgent; only local settings.defaultMode may fall back to default on invalid local config Tested: bun test packages/acp-link/src/__tests__/server.test.ts src/services/acp/__tests__/agent.test.ts src/services/acp/__tests__/permissions.test.ts src/services/skillLearning/__tests__/skillLifecycle.test.ts src/utils/settings/__tests__/config.test.ts Tested: bunx tsc -p packages/acp-link/tsconfig.json --noEmit --pretty false Tested: bunx tsc --noEmit --pretty false Tested: bun run lint Tested: bun run test:all Tested: local CI equivalent install/typecheck/coverage/build with warning_scan=0 Not-tested: Pre-existing bun audit vulnerabilities require a separate dependency-hardening PR * fix: resolve dependency audit findings precisely Use dependency-native upgrades and lockfile resolution to close the audit findings without suppressions. Keep the chrome MCP setup aligned with the new dependency graph and add real integration coverage so the override behavior stays verified. Constraint: no audit ignores or warning suppression Rejected: broad google-auth/protobuf overrides | replaced with upstream-compatible resolution Confidence: high Scope-risk: moderate Directive: keep dependency fixes upstream-compatible; do not reintroduce blanket overrides unless the audit surface changes materially Tested: bun audit; bun audit --json; bun install --frozen-lockfile with CLAUDE_CODE_SKIP_CHROME_MCP_SETUP=1; bunx tsc --noEmit --pretty false; bun run lint; targeted tests; bun run test:all; bun test --coverage --coverage-reporter lcov --coverage-dir coverage; bun run build:vite Not-tested: unrelated pre-existing ACP/CORS/token fallback residual risks * fix: keep ACP auth tokens out of URLs Replace the ad hoc URL-token flow with crypto UUID-backed transport identifiers so the bearer token stays in structured request data instead of query strings. Keep the server, web client, and transport helpers aligned so the ACP/RCS handshake remains compatible after the API shape change. Constraint: token must not be embedded in the URL Rejected: token-as-uuid query fallback | leaked bearer tokens in URLs Confidence: high Scope-risk: moderate Directive: preserve the structured auth path; do not reintroduce query-token fallback when adjusting ACP transport code Tested: targeted ACP/RCS transport tests Not-tested: unrelated pre-existing ACP/CORS/token fallback residual risks * fix: normalize WebFetch request headers Normalize WebFetch headers before dispatch so canonicalization preserves auth semantics and duplicate forms do not slip through. Keep the behavior locked with a focused header test instead of broadening the request pipeline. Constraint: preserve header semantics without widening the fetch surface Rejected: ad hoc caller-side normalization | too easy to bypass in future call sites Confidence: high Scope-risk: narrow Directive: keep header normalization close to the WebFetch utility so future callers inherit the same behavior automatically Tested: targeted WebFetch header tests Not-tested: unrelated fetch backend behavior beyond header normalization * fix: harden ACP remote auth surfaces Tighten the remaining Claude security artifact items by requiring API keys on ACP global reads and relay upgrades, moving WebSocket tokens out of URLs, and replacing open web CORS with an explicit allowlist. Constraint: Browser WebSocket clients cannot set arbitrary Authorization headers, so the token is carried in a selected subprotocol instead of a query string. Rejected: Keep UUID auth for ACP channel groups | any caller can mint a UUID and read global ACP data. Rejected: Preserve ?token= compatibility | secrets leak into logs, history, referrers, and intermediaries. Confidence: high Scope-risk: moderate Directive: Do not reintroduce query-string bearer tokens; use Authorization or rcs.auth.<base64url-token>. Tested: bunx tsc --noEmit --pretty false Tested: bun run typecheck in packages/remote-control-server Tested: bun run build in packages/acp-link Tested: bun run lint Tested: bun audit Tested: focused RCS/acp-link/web tests, 160 pass Tested: Edge headless browser WebSocket subprotocol handshake Tested: bun run test:all, 3669 pass Tested: bun run build:vite Tested: bun run build Not-tested: Manual end-to-end relay with a live external ACP agent * fix: resolve CI dependency override lookup The CI runner does not expose @grpc/proto-loader as a root-resolvable package, and the test was relying on local hoisting rather than the real dependency owner. Resolve proto-loader through @opentelemetry/exporter-trace-otlp-grpc and @grpc/grpc-js so the smoke test follows the package graph it is validating. Constraint: Do not add a new root dependency for a transitive smoke test. Rejected: Skip or weaken the test | the test protects the protobuf 7 override path and should keep exercising loadSync. Rejected: Add @grpc/proto-loader directly to root package.json | that hides the owning-package resolution issue and broadens dependency surface. Confidence: high Scope-risk: narrow Directive: Dependency override smoke tests should resolve from the package that actually owns the dependency, not from incidental root hoisting. Tested: bun test tests/integration/dependency-overrides.test.ts; bunx tsc --noEmit --pretty false; bun run lint; bun audit; bun run test:all; git diff --check --------- Co-authored-by: unraid <local@unraid.local>
This commit is contained in:
@@ -1675,7 +1675,7 @@ async function stopWorkWithRetry(
|
||||
}
|
||||
const errMsg = errorMessage(err)
|
||||
if (attempt < MAX_ATTEMPTS) {
|
||||
const delay = addJitter(baseDelayMs * Math.pow(2, attempt - 1))
|
||||
const delay = addJitter(baseDelayMs * 2 ** (attempt - 1))
|
||||
logger.logVerbose(
|
||||
`Failed to stop work ${workId} (attempt ${attempt}/${MAX_ATTEMPTS}), retrying in ${formatDelay(delay)}: ${errMsg}`,
|
||||
)
|
||||
|
||||
@@ -518,7 +518,7 @@ export class SSETransport implements Transport {
|
||||
this.reconnectAttempts++
|
||||
|
||||
const baseDelay = Math.min(
|
||||
RECONNECT_BASE_DELAY_MS * Math.pow(2, this.reconnectAttempts - 1),
|
||||
RECONNECT_BASE_DELAY_MS * 2 ** (this.reconnectAttempts - 1),
|
||||
RECONNECT_MAX_DELAY_MS,
|
||||
)
|
||||
// Add ±25% jitter
|
||||
@@ -668,7 +668,7 @@ export class SSETransport implements Transport {
|
||||
}
|
||||
|
||||
const delayMs = Math.min(
|
||||
POST_BASE_DELAY_MS * Math.pow(2, attempt - 1),
|
||||
POST_BASE_DELAY_MS * 2 ** (attempt - 1),
|
||||
POST_MAX_DELAY_MS,
|
||||
)
|
||||
await sleep(delayMs)
|
||||
|
||||
@@ -516,7 +516,7 @@ export class WebSocketTransport implements Transport {
|
||||
this.reconnectAttempts++
|
||||
|
||||
const baseDelay = Math.min(
|
||||
DEFAULT_BASE_RECONNECT_DELAY * Math.pow(2, this.reconnectAttempts - 1),
|
||||
DEFAULT_BASE_RECONNECT_DELAY * 2 ** (this.reconnectAttempts - 1),
|
||||
DEFAULT_MAX_RECONNECT_DELAY,
|
||||
)
|
||||
// Add ±25% jitter to avoid thundering herd
|
||||
|
||||
@@ -61,7 +61,7 @@ function IDEScreen({
|
||||
} else if (value === 'None' && shouldShowDisableAutoConnectDialog()) {
|
||||
setShowDisableAutoConnectDialog(true)
|
||||
} else {
|
||||
onSelect(availableIDEs.find(ide => ide.port === parseInt(value)))
|
||||
onSelect(availableIDEs.find(ide => ide.port === parseInt(value, 10)))
|
||||
}
|
||||
},
|
||||
[availableIDEs, onSelect],
|
||||
@@ -216,7 +216,7 @@ function IDEOpenSelection({
|
||||
const handleSelectIDE = useCallback(
|
||||
(value: string) => {
|
||||
const selectedIDE = availableIDEs.find(
|
||||
ide => ide.port === parseInt(value),
|
||||
ide => ide.port === parseInt(value, 10),
|
||||
)
|
||||
onSelectIDE(selectedIDE)
|
||||
},
|
||||
|
||||
@@ -78,7 +78,7 @@ function ModelPickerWrapper({
|
||||
}
|
||||
|
||||
// Turn off fast mode if switching to unsupported model
|
||||
let wasFastModeToggledOn = undefined
|
||||
let wasFastModeToggledOn
|
||||
if (isFastModeEnabled()) {
|
||||
clearFastModeCooldown()
|
||||
if (!isFastModeSupportedByModel(model) && isFastMode) {
|
||||
@@ -214,7 +214,7 @@ function SetModelAndClose({
|
||||
}))
|
||||
let message = `Set model to ${chalk.bold(renderModelLabel(modelValue))}`
|
||||
|
||||
let wasFastModeToggledOn = undefined
|
||||
let wasFastModeToggledOn
|
||||
if (isFastModeEnabled()) {
|
||||
clearFastModeCooldown()
|
||||
if (!isFastModeSupportedByModel(modelValue) && isFastMode) {
|
||||
|
||||
@@ -133,7 +133,6 @@ export function AddMarketplace({
|
||||
void handleAdd()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: intentional
|
||||
}, []) // Only run once on mount
|
||||
|
||||
return (
|
||||
|
||||
@@ -190,7 +190,6 @@ export function ManageMarketplaces({
|
||||
}
|
||||
void loadMarketplaces()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: intentional
|
||||
}, [targetMarketplace, action, error])
|
||||
|
||||
// Check if there are any pending changes
|
||||
|
||||
@@ -204,7 +204,6 @@ export function AutoUpdater({
|
||||
// instead so the guard is always current without changing callback
|
||||
// identity (which would re-trigger the initial-check useEffect below).
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: isUpdating read via ref
|
||||
}, [onAutoUpdaterResult])
|
||||
|
||||
// Initial check
|
||||
|
||||
@@ -13,7 +13,6 @@ export function MemoryUsageIndicator(): React.ReactNode {
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
// biome-ignore lint/correctness/useHookAtTopLevel: USER_TYPE is a build-time constant
|
||||
const memoryUsage = useMemoryUsage()
|
||||
|
||||
if (!memoryUsage) {
|
||||
|
||||
@@ -879,7 +879,6 @@ function computeDiffStatsBetweenMessages(
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -169,7 +169,6 @@ export function NativeAutoUpdater({
|
||||
// instead so the guard is always current without changing callback
|
||||
// identity (which would re-trigger the initial-check useEffect below).
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: isUpdating read via ref
|
||||
}, [onAutoUpdaterResult, channel])
|
||||
|
||||
// Initial check
|
||||
|
||||
@@ -254,18 +254,17 @@ function NotificationContent({
|
||||
|
||||
// Voice state (VOICE_MODE builds only, runtime-gated by GrowthBook)
|
||||
const voiceState = feature('VOICE_MODE')
|
||||
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
?
|
||||
useVoiceState(s => s.voiceState)
|
||||
: ('idle' as const)
|
||||
// biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
const voiceEnabled = feature('VOICE_MODE') ? useVoiceEnabled() : false
|
||||
const voiceError = feature('VOICE_MODE')
|
||||
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
?
|
||||
useVoiceState(s => s.voiceError)
|
||||
: null
|
||||
const isBriefOnly =
|
||||
feature('KAIROS') || feature('KAIROS_BRIEF')
|
||||
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
?
|
||||
useAppState(s => s.isBriefOnly)
|
||||
: false
|
||||
|
||||
|
||||
@@ -449,7 +449,7 @@ function PromptInput({
|
||||
// its own marginTop, so the gap stays even without ours.
|
||||
const briefOwnsGap =
|
||||
feature('KAIROS') || feature('KAIROS_BRIEF')
|
||||
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
?
|
||||
useAppState(s => s.isBriefOnly) && !viewingAgentTaskId
|
||||
: false
|
||||
const mainLoopModel_ = useAppState(s => s.mainLoopModel)
|
||||
@@ -2384,7 +2384,7 @@ function PromptInput({
|
||||
useBuddyNotification()
|
||||
|
||||
const companionSpeaking = feature('BUDDY')
|
||||
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
?
|
||||
useAppState(s => s.companionReaction !== undefined)
|
||||
: false
|
||||
const { columns, rows } = useTerminalSize()
|
||||
|
||||
@@ -258,14 +258,13 @@ function ModeIndicator({
|
||||
proactiveModule?.getNextTickAt ?? NULL,
|
||||
NULL,
|
||||
)
|
||||
// biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
const voiceEnabled = feature('VOICE_MODE') ? useVoiceEnabled() : false
|
||||
const voiceState = feature('VOICE_MODE')
|
||||
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
?
|
||||
useVoiceState(s => s.voiceState)
|
||||
: ('idle' as const)
|
||||
const voiceWarmingUp = feature('VOICE_MODE')
|
||||
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
?
|
||||
useVoiceState(s => s.voiceWarmingUp)
|
||||
: false
|
||||
const hasSelection = useHasSelection()
|
||||
@@ -302,7 +301,7 @@ function ModeIndicator({
|
||||
'ctrl+x ctrl+k',
|
||||
)
|
||||
const voiceKeyShortcut = feature('VOICE_MODE')
|
||||
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
?
|
||||
useShortcutDisplay('voice:pushToTalk', 'Chat', 'Space')
|
||||
: ''
|
||||
// Captured at mount so the hint doesn't flicker mid-session if another
|
||||
@@ -311,14 +310,13 @@ function ModeIndicator({
|
||||
// shown" without tracking the exact render-time condition (which depends
|
||||
// on parts/hintParts computed after the early-return hooks boundary).
|
||||
const [voiceHintUnderCap] = feature('VOICE_MODE')
|
||||
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
?
|
||||
useState(
|
||||
() =>
|
||||
(getGlobalConfig().voiceFooterHintSeenCount ?? 0) <
|
||||
MAX_VOICE_HINT_SHOWS,
|
||||
)
|
||||
: [false]
|
||||
// biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
const voiceHintIncrementedRef = feature('VOICE_MODE') ? useRef(false) : null
|
||||
useEffect(() => {
|
||||
if (feature('VOICE_MODE')) {
|
||||
|
||||
@@ -100,7 +100,7 @@ function PromptInputQueuedCommandsImpl(): React.ReactNode {
|
||||
// component early-returns when viewing a teammate.
|
||||
const useBriefLayout =
|
||||
feature('KAIROS') || feature('KAIROS_BRIEF')
|
||||
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
?
|
||||
useAppState(s => s.isBriefOnly)
|
||||
: false
|
||||
|
||||
|
||||
@@ -258,7 +258,7 @@ export function computeWheelStep(
|
||||
// the curve handles it (gap=1000ms → m≈0.01 → mult≈1). No frac —
|
||||
// rounding loss is minor at high mult, and frac persisting across idle
|
||||
// was causing off-by-one on the first click back.
|
||||
const m = Math.pow(0.5, gap / WHEEL_DECAY_HALFLIFE_MS)
|
||||
const m = 0.5 ** (gap / WHEEL_DECAY_HALFLIFE_MS)
|
||||
const cap = Math.max(WHEEL_MODE_CAP, state.base * 2)
|
||||
const next = 1 + (state.mult - 1) * m + WHEEL_MODE_STEP * m
|
||||
state.mult = Math.min(cap, next, state.mult + WHEEL_MODE_RAMP)
|
||||
@@ -299,7 +299,7 @@ export function computeWheelStep(
|
||||
state.mult = 2
|
||||
state.frac = 0
|
||||
} else {
|
||||
const m = Math.pow(0.5, gap / WHEEL_DECAY_HALFLIFE_MS)
|
||||
const m = 0.5 ** (gap / WHEEL_DECAY_HALFLIFE_MS)
|
||||
const cap =
|
||||
gap >= WHEEL_DECAY_GAP_MS ? WHEEL_DECAY_CAP_SLOW : WHEEL_DECAY_CAP_FAST
|
||||
state.mult = Math.min(cap, 1 + (state.mult - 1) * m + WHEEL_DECAY_STEP * m)
|
||||
|
||||
@@ -95,7 +95,7 @@ export function SpinnerWithVerb(props: Props): React.ReactNode {
|
||||
// Hoisted to mount-time — this component re-renders at animation framerate.
|
||||
const briefEnvEnabled =
|
||||
feature('KAIROS') || feature('KAIROS_BRIEF')
|
||||
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
?
|
||||
useMemo(() => isEnvTruthy(process.env.CLAUDE_CODE_BRIEF), [])
|
||||
: false
|
||||
|
||||
|
||||
@@ -370,7 +370,6 @@ function StatusLineInner({
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: intentional
|
||||
}, []) // Only run once on mount - settings stable for initial logging
|
||||
|
||||
// Initial update on mount + cleanup on unmount
|
||||
@@ -384,7 +383,6 @@ function StatusLineInner({
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: intentional
|
||||
}, []) // Only run once on mount, not when doUpdate changes
|
||||
|
||||
// Get padding from settings or default to 0
|
||||
|
||||
@@ -48,20 +48,20 @@ export default function TextInput(props: Props): React.ReactNode {
|
||||
const reducedMotion = settings.prefersReducedMotion ?? false
|
||||
|
||||
const voiceState = feature('VOICE_MODE')
|
||||
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
?
|
||||
useVoiceState(s => s.voiceState)
|
||||
: ('idle' as const)
|
||||
const isVoiceRecording = voiceState === 'recording'
|
||||
|
||||
const audioLevels = feature('VOICE_MODE')
|
||||
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
?
|
||||
useVoiceState(s => s.voiceAudioLevels)
|
||||
: []
|
||||
const smoothedRef = useRef<number[]>(new Array(CURSOR_WAVEFORM_WIDTH).fill(0))
|
||||
|
||||
const needsAnimation = isVoiceRecording && !reducedMotion
|
||||
const [animRef, animTime] = feature('VOICE_MODE')
|
||||
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
?
|
||||
useAnimationFrame(needsAnimation ? 50 : null)
|
||||
: [() => {}, 0]
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ export function WorktreeExitDialog({
|
||||
'--count',
|
||||
`${worktreeSession.originalHeadCommit}..HEAD`,
|
||||
])
|
||||
const count = parseInt(commitsStr.trim()) || 0
|
||||
const count = parseInt(commitsStr.trim(), 10) || 0
|
||||
setCommitCount(count)
|
||||
|
||||
// If no changes and no commits, clean up silently
|
||||
@@ -94,7 +94,6 @@ export function WorktreeExitDialog({
|
||||
}
|
||||
void loadChanges()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: intentional
|
||||
}, [worktreeSession])
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -27,7 +27,7 @@ export function ColorPicker({
|
||||
const [selectedIndex, setSelectedIndex] = useState(
|
||||
Math.max(
|
||||
0,
|
||||
COLOR_OPTIONS.findIndex(opt => opt === currentColor),
|
||||
COLOR_OPTIONS.indexOf(currentColor),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ export function MCPSettings({ onComplete }: Props): React.ReactNode {
|
||||
const isSSE = client.config.type === 'sse'
|
||||
const isHTTP = client.config.type === 'http'
|
||||
const isClaudeAIProxy = client.config.type === 'claudeai-proxy'
|
||||
let isAuthenticated: boolean | undefined = undefined
|
||||
let isAuthenticated: boolean | undefined
|
||||
|
||||
if (isSSE || isHTTP) {
|
||||
const authProvider = new ClaudeAuthProvider(
|
||||
|
||||
@@ -88,7 +88,7 @@ export function MCPToolListView({
|
||||
<Select
|
||||
options={toolOptions}
|
||||
onChange={value => {
|
||||
const index = parseInt(value)
|
||||
const index = parseInt(value, 10)
|
||||
const tool = serverTools[index]
|
||||
if (tool) {
|
||||
onSelectTool(tool, index)
|
||||
|
||||
@@ -48,7 +48,7 @@ export function AttachmentMessage({
|
||||
const bg = useSelectedMessageBg()
|
||||
// Hoisted to mount-time — per-message component, re-renders on every scroll.
|
||||
const isDemoEnv = feature('EXPERIMENTAL_SKILL_SEARCH')
|
||||
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
?
|
||||
useMemo(() => isEnvTruthy(process.env.IS_DEMO), [])
|
||||
: false
|
||||
// Handle teammate_mailbox BEFORE switch
|
||||
|
||||
@@ -50,18 +50,18 @@ export function UserPromptMessage({
|
||||
// external builds.
|
||||
const isBriefOnly =
|
||||
feature('KAIROS') || feature('KAIROS_BRIEF')
|
||||
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
?
|
||||
useAppState(s => s.isBriefOnly)
|
||||
: false
|
||||
const viewingAgentTaskId =
|
||||
feature('KAIROS') || feature('KAIROS_BRIEF')
|
||||
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
?
|
||||
useAppState(s => s.viewingAgentTaskId)
|
||||
: null
|
||||
// Hoisted to mount-time — per-message component, re-renders on every scroll.
|
||||
const briefEnvEnabled =
|
||||
feature('KAIROS') || feature('KAIROS_BRIEF')
|
||||
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
?
|
||||
useMemo(() => isEnvTruthy(process.env.CLAUDE_CODE_BRIEF), [])
|
||||
: false
|
||||
const useBriefLayout =
|
||||
|
||||
@@ -53,7 +53,7 @@ export function UserToolSuccessMessage({
|
||||
// UserPromptMessage.tsx.
|
||||
const isBriefOnly =
|
||||
feature('KAIROS') || feature('KAIROS_BRIEF')
|
||||
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
?
|
||||
useAppState(s => s.isBriefOnly)
|
||||
: false
|
||||
|
||||
|
||||
@@ -77,7 +77,6 @@ export function TeamsDialog({ initialTeams, onDone }: Props): React.ReactNode {
|
||||
const teammateStatuses = useMemo(() => {
|
||||
return getTeammateStatuses(dialogLevel.teamName);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: intentional
|
||||
}, [dialogLevel.teamName, refreshKey]);
|
||||
|
||||
// Periodically refresh to pick up mode changes from teammates
|
||||
|
||||
@@ -31,13 +31,13 @@ jobs:
|
||||
actions: read # Required for Claude to read CI results on PRs
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2, 2026-04-25
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude Code
|
||||
id: claude
|
||||
uses: anthropics/claude-code-action@v1
|
||||
uses: anthropics/claude-code-action@567fe954a4527e81f132d87d1bdbcc94f7737434 # v1, 2026-04-25
|
||||
with:
|
||||
anthropic_api_key: \${{ secrets.ANTHROPIC_API_KEY }}
|
||||
|
||||
@@ -126,13 +126,13 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2, 2026-04-25
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude Code Review
|
||||
id: claude-review
|
||||
uses: anthropics/claude-code-action@v1
|
||||
uses: anthropics/claude-code-action@567fe954a4527e81f132d87d1bdbcc94f7737434 # v1, 2026-04-25
|
||||
with:
|
||||
anthropic_api_key: \${{ secrets.ANTHROPIC_API_KEY }}
|
||||
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
|
||||
|
||||
@@ -80,7 +80,6 @@ async function main(): Promise<void> {
|
||||
(args[0] === '--version' || args[0] === '-v' || args[0] === '-V')
|
||||
) {
|
||||
// MACRO.VERSION is inlined at build time
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(`${MACRO.VERSION} (Claude Code)`)
|
||||
return
|
||||
}
|
||||
@@ -101,7 +100,6 @@ async function main(): Promise<void> {
|
||||
const model = (modelIdx !== -1 && args[modelIdx + 1]) || getMainLoopModel()
|
||||
const { getSystemPrompt } = await import('../constants/prompts.js')
|
||||
const prompt = await getSystemPrompt([], model)
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(prompt.join('\n'))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ export function renderPlaceholder({
|
||||
renderedPlaceholder: string | undefined
|
||||
showPlaceholder: boolean
|
||||
} {
|
||||
let renderedPlaceholder: string | undefined = undefined
|
||||
let renderedPlaceholder: string | undefined
|
||||
|
||||
if (placeholder) {
|
||||
if (hidePlaceholderText) {
|
||||
|
||||
@@ -17,7 +17,7 @@ const HISTORY_CHUNK_SIZE = 10
|
||||
// Mode filter is included to ensure we don't mix filtered and unfiltered caches
|
||||
let pendingLoad: Promise<HistoryEntry[]> | null = null
|
||||
let pendingLoadTarget = 0
|
||||
let pendingLoadModeFilter: HistoryMode | undefined = undefined
|
||||
let pendingLoadModeFilter: HistoryMode | undefined
|
||||
|
||||
async function loadHistoryEntries(
|
||||
minCount: number,
|
||||
|
||||
@@ -92,7 +92,7 @@ export function GlobalKeybindingHandlers({
|
||||
// Brief view has its own dedicated toggle on ctrl+shift+b.
|
||||
const isBriefOnly =
|
||||
feature('KAIROS') || feature('KAIROS_BRIEF')
|
||||
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
?
|
||||
useAppState(s => s.isBriefOnly)
|
||||
: false
|
||||
const handleToggleTranscript = useCallback(() => {
|
||||
@@ -202,7 +202,6 @@ export function GlobalKeybindingHandlers({
|
||||
context: 'Global',
|
||||
})
|
||||
if (feature('KAIROS') || feature('KAIROS_BRIEF')) {
|
||||
// biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
useKeybinding('app:toggleBrief', handleToggleBrief, {
|
||||
context: 'Global',
|
||||
})
|
||||
|
||||
@@ -123,23 +123,23 @@ export function useReplBridge(
|
||||
const store = useAppStateStore()
|
||||
const { addNotification } = useNotifications()
|
||||
const replBridgeEnabled = feature('BRIDGE_MODE')
|
||||
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
?
|
||||
useAppState(s => s.replBridgeEnabled)
|
||||
: false
|
||||
const replBridgeConnected = feature('BRIDGE_MODE')
|
||||
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
?
|
||||
useAppState(s => s.replBridgeConnected)
|
||||
: false
|
||||
const replBridgeSessionActive = feature('BRIDGE_MODE')
|
||||
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
?
|
||||
useAppState(s => s.replBridgeSessionActive)
|
||||
: false
|
||||
const replBridgeOutboundOnly = feature('BRIDGE_MODE')
|
||||
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
?
|
||||
useAppState(s => s.replBridgeOutboundOnly)
|
||||
: false
|
||||
const replBridgeInitialName = feature('BRIDGE_MODE')
|
||||
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
?
|
||||
useAppState(s => s.replBridgeInitialName)
|
||||
: undefined
|
||||
|
||||
|
||||
@@ -236,14 +236,13 @@ export function useVoiceIntegration({
|
||||
// Voice state selectors. useVoiceEnabled = user intent (settings) +
|
||||
// auth + GB kill-switch, with the auth half memoized on authVersion so
|
||||
// render loops never hit a cold keychain spawn.
|
||||
// biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
const voiceEnabled = feature('VOICE_MODE') ? useVoiceEnabled() : false
|
||||
const voiceState = feature('VOICE_MODE')
|
||||
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
?
|
||||
useVoiceState(s => s.voiceState)
|
||||
: ('idle' as const)
|
||||
const voiceInterimTranscript = feature('VOICE_MODE')
|
||||
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
?
|
||||
useVoiceState(s => s.voiceInterimTranscript)
|
||||
: ''
|
||||
|
||||
@@ -416,10 +415,9 @@ export function useVoiceKeybindingHandler({
|
||||
const setVoiceState = useSetVoiceState()
|
||||
const keybindingContext = useOptionalKeybindingContext()
|
||||
const isModalOverlayActive = useIsModalOverlayActive()
|
||||
// biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
const voiceEnabled = feature('VOICE_MODE') ? useVoiceEnabled() : false
|
||||
const voiceState = feature('VOICE_MODE')
|
||||
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
?
|
||||
useVoiceState(s => s.voiceState)
|
||||
: 'idle'
|
||||
|
||||
|
||||
@@ -1758,7 +1758,6 @@ async function run(): Promise<CommanderCommand> {
|
||||
// Ignore "code" as a prompt - treat it the same as no prompt
|
||||
if (prompt === "code") {
|
||||
logEvent("tengu_code_prompt_ignored", {});
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.warn(
|
||||
chalk.yellow(
|
||||
"Tip: You can launch Claude Code with just `claude`",
|
||||
@@ -1826,7 +1825,6 @@ async function run(): Promise<CommanderCommand> {
|
||||
kairosGate
|
||||
) {
|
||||
if (!checkHasTrustDialogAccepted()) {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.warn(
|
||||
chalk.yellow(
|
||||
"Assistant mode disabled: directory is not trusted. Accept the trust dialog and restart.",
|
||||
@@ -2460,7 +2458,6 @@ async function run(): Promise<CommanderCommand> {
|
||||
});
|
||||
logForDebugging(`[Claude in Chrome] Error: ${error}`);
|
||||
logError(error);
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.error(
|
||||
`Error: Failed to run with Claude in Chrome.`,
|
||||
);
|
||||
@@ -2745,7 +2742,6 @@ async function run(): Promise<CommanderCommand> {
|
||||
|
||||
// Print any warnings from initialization
|
||||
warnings.forEach((warning) => {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.error(warning);
|
||||
});
|
||||
|
||||
@@ -2807,7 +2803,6 @@ async function run(): Promise<CommanderCommand> {
|
||||
inputFormat !== "text" &&
|
||||
inputFormat !== "stream-json"
|
||||
) {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.error(`Error: Invalid input format "${inputFormat}".`);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -2815,7 +2810,6 @@ async function run(): Promise<CommanderCommand> {
|
||||
inputFormat === "stream-json" &&
|
||||
outputFormat !== "stream-json"
|
||||
) {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.error(
|
||||
`Error: --input-format=stream-json requires output-format=stream-json.`,
|
||||
);
|
||||
@@ -2828,7 +2822,6 @@ async function run(): Promise<CommanderCommand> {
|
||||
inputFormat !== "stream-json" ||
|
||||
outputFormat !== "stream-json"
|
||||
) {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.error(
|
||||
`Error: --sdk-url requires both --input-format=stream-json and --output-format=stream-json.`,
|
||||
);
|
||||
@@ -2842,7 +2835,6 @@ async function run(): Promise<CommanderCommand> {
|
||||
inputFormat !== "stream-json" ||
|
||||
outputFormat !== "stream-json"
|
||||
) {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.error(
|
||||
`Error: --replay-user-messages requires both --input-format=stream-json and --output-format=stream-json.`,
|
||||
);
|
||||
@@ -6061,7 +6053,6 @@ async function run(): Promise<CommanderCommand> {
|
||||
setDirectConnectServerUrl(serverUrl);
|
||||
connectConfig = session.config;
|
||||
} catch (err) {
|
||||
// biome-ignore lint/suspicious/noConsole: intentional error output
|
||||
console.error(
|
||||
err instanceof DirectConnectError
|
||||
? err.message
|
||||
|
||||
@@ -206,7 +206,7 @@ export class FileIndex {
|
||||
|
||||
const { paths, lowerPaths, charBits, pathLens, readyCount } = this
|
||||
|
||||
outer: for (let i = 0; i < readyCount; i++) {
|
||||
for (let i = 0; i < readyCount; i++) {
|
||||
// O(1) bitmap reject: path must contain every letter in the needle
|
||||
if ((charBits[i]! & needleBitmap) !== needleBitmap) continue
|
||||
|
||||
|
||||
@@ -277,7 +277,6 @@ export async function* query(
|
||||
for (const uuid of consumedCommandUuids) {
|
||||
notifyCommandLifecycle(uuid, 'completed')
|
||||
}
|
||||
// biome-ignore lint/style/noNonNullAssertion: terminal is always assigned when queryLoop returns normally
|
||||
return terminal!
|
||||
}
|
||||
|
||||
@@ -331,7 +330,7 @@ async function* queryLoop(
|
||||
// multiple compacts: each subtracts the final context at that compact's
|
||||
// trigger point. Loop-local (not on State) to avoid touching the 7 continue
|
||||
// sites.
|
||||
let taskBudgetRemaining: number | undefined = undefined
|
||||
let taskBudgetRemaining: number | undefined
|
||||
|
||||
// Snapshot immutable env/statsig/session state once at entry. See QueryConfig
|
||||
// for what's included and why feature() gates are intentionally excluded.
|
||||
|
||||
@@ -639,6 +639,7 @@ function TranscriptSearchBar({
|
||||
const [indexStatus, setIndexStatus] = React.useState<'building' | { ms: number } | null>('building');
|
||||
React.useEffect(() => {
|
||||
let alive = true;
|
||||
let hideTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||
const warm = jumpRef.current?.warmSearchIndex;
|
||||
if (!warm) {
|
||||
setIndexStatus(null); // VML not mounted yet — rare, skip indicator
|
||||
@@ -652,14 +653,14 @@ function TranscriptSearchBar({
|
||||
setIndexStatus(null);
|
||||
} else {
|
||||
setIndexStatus({ ms });
|
||||
setTimeout(() => alive && setIndexStatus(null), 2000);
|
||||
hideTimeout = setTimeout(() => alive && setIndexStatus(null), 2000);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
if (hideTimeout) clearTimeout(hideTimeout);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []); // mount-only: bar opens once per /
|
||||
}, [jumpRef]); // mount-only per stable search bar ref
|
||||
// Gate the query effect on warm completion. setHighlight stays instant
|
||||
// (screen-space overlay, no indexing). setSearchQuery (the scan) waits.
|
||||
const warmDone = indexStatus !== 'building';
|
||||
@@ -667,8 +668,7 @@ function TranscriptSearchBar({
|
||||
if (!warmDone) return;
|
||||
jumpRef.current?.setSearchQuery(query);
|
||||
setHighlight(query);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [query, warmDone]);
|
||||
}, [jumpRef, query, setHighlight, warmDone]);
|
||||
const off = cursorOffset;
|
||||
const cursorChar = off < query.length ? query[off] : ' ';
|
||||
return (
|
||||
@@ -4992,16 +4992,19 @@ export function REPL({
|
||||
}
|
||||
}, [queuedCommands]);
|
||||
|
||||
const onInitRef = useRef(onInit);
|
||||
onInitRef.current = onInit;
|
||||
const diagnosticTrackerRef = useRef(diagnosticTracker);
|
||||
diagnosticTrackerRef.current = diagnosticTracker;
|
||||
|
||||
// Initial load
|
||||
useEffect(() => {
|
||||
void onInit();
|
||||
void onInitRef.current();
|
||||
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
void diagnosticTracker.shutdown();
|
||||
void diagnosticTrackerRef.current.shutdown();
|
||||
};
|
||||
// TODO: fix this
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Listen for suspend/resume events
|
||||
|
||||
@@ -86,7 +86,7 @@ function substituteVariables(
|
||||
// (replacer fn treats $ literally), and (2) double-substitution when user
|
||||
// content happens to contain {{varName}} matching a later variable.
|
||||
return template.replace(/\{\{(\w+)\}\}/g, (match, key: string) =>
|
||||
Object.prototype.hasOwnProperty.call(variables, key)
|
||||
Object.hasOwn(variables, key)
|
||||
? variables[key]!
|
||||
: match,
|
||||
)
|
||||
|
||||
@@ -206,7 +206,7 @@ function substituteVariables(
|
||||
// (replacer fn treats $ literally), and (2) double-substitution when user
|
||||
// content happens to contain {{varName}} matching a later variable.
|
||||
return template.replace(/\{\{(\w+)\}\}/g, (match, key: string) =>
|
||||
Object.prototype.hasOwnProperty.call(variables, key)
|
||||
Object.hasOwn(variables, key)
|
||||
? variables[key]!
|
||||
: match,
|
||||
)
|
||||
|
||||
@@ -4,56 +4,74 @@ import {
|
||||
test,
|
||||
mock,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
afterAll,
|
||||
spyOn,
|
||||
} from 'bun:test'
|
||||
|
||||
// ── Mock infrastructure ──────────────────────────────────────────
|
||||
// bun:test mock.module is process-global: it leaks to sibling test files
|
||||
// in the same worker. safeMockModule snapshots real exports before mocking
|
||||
// in the same worker. Preserve real exports before partial module mocking
|
||||
// so afterAll can restore them, preventing cross-file pollution.
|
||||
|
||||
const _restores: (() => void)[] = []
|
||||
const originalCwd = process.cwd()
|
||||
const originalAcpPermissionMode = process.env.ACP_PERMISSION_MODE
|
||||
const originalAcpAllowBypass = process.env.CLAUDE_CODE_ACP_ALLOW_BYPASS_PERMISSIONS
|
||||
|
||||
function safeMockModule(tsPath: string, overrides: Record<string, unknown>) {
|
||||
function mockModulePreservingExports(
|
||||
tsPath: string,
|
||||
overrides: Record<string, unknown>,
|
||||
) {
|
||||
const jsPath = tsPath.replace(/\.ts$/, '.js')
|
||||
const real = require(tsPath)
|
||||
const snapshot = { ...real }
|
||||
const snapshot = { ...(require(tsPath) as Record<string, unknown>) }
|
||||
mock.module(jsPath, () => ({ ...snapshot, ...overrides }))
|
||||
_restores.push(() => mock.module(jsPath, () => snapshot))
|
||||
}
|
||||
|
||||
afterAll(() => {
|
||||
for (let i = _restores.length - 1; i >= 0; i--) {
|
||||
_restores[i]()
|
||||
}
|
||||
_restores.length = 0
|
||||
restoreEnv('ACP_PERMISSION_MODE', originalAcpPermissionMode)
|
||||
restoreEnv(
|
||||
'CLAUDE_CODE_ACP_ALLOW_BYPASS_PERMISSIONS',
|
||||
originalAcpAllowBypass,
|
||||
)
|
||||
})
|
||||
|
||||
// ── Module mocks (must precede any import of the module under test) ──
|
||||
|
||||
const mockSetModel = mock(() => {})
|
||||
const mockSubmitMessage = mock(async function* (_input: string) {})
|
||||
|
||||
// Fully synthetic — no real module to snapshot, so plain mock.module suffices.
|
||||
mock.module('../../../QueryEngine.js', () => ({
|
||||
mockModulePreservingExports('../../../QueryEngine.ts', {
|
||||
QueryEngine: class MockQueryEngine {
|
||||
submitMessage = mock(async function* () {})
|
||||
submitMessage = mockSubmitMessage
|
||||
interrupt = mock(() => {})
|
||||
resetAbortController = mock(() => {})
|
||||
getAbortSignal = mock(() => new AbortController().signal)
|
||||
setModel = mockSetModel
|
||||
},
|
||||
}))
|
||||
})
|
||||
|
||||
safeMockModule('../../../tools.ts', {
|
||||
mockModulePreservingExports('../../../tools.ts', {
|
||||
getTools: mock(() => []),
|
||||
})
|
||||
|
||||
safeMockModule('../../../Tool.ts', {
|
||||
mockModulePreservingExports('../../../Tool.ts', {
|
||||
toolMatchesName: mock(() => false),
|
||||
findToolByName: mock(() => undefined),
|
||||
filterToolProgressMessages: mock(() => []),
|
||||
buildTool: mock((def: any) => def),
|
||||
})
|
||||
|
||||
safeMockModule('../../../utils/config.ts', {
|
||||
mockModulePreservingExports('../../../utils/config.ts', {
|
||||
enableConfigs: mock(() => {}),
|
||||
})
|
||||
|
||||
safeMockModule('../../../bootstrap/state.ts', {
|
||||
mockModulePreservingExports('../../../bootstrap/state.ts', {
|
||||
setOriginalCwd: mock(() => {}),
|
||||
addSlowOperation: mock(() => {}),
|
||||
})
|
||||
@@ -75,24 +93,16 @@ const mockGetDefaultAppState = mock(() => ({
|
||||
mainLoopModelForSession: null,
|
||||
}))
|
||||
|
||||
safeMockModule('../../../state/AppStateStore.ts', {
|
||||
mockModulePreservingExports('../../../state/AppStateStore.ts', {
|
||||
getDefaultAppState: mockGetDefaultAppState,
|
||||
})
|
||||
|
||||
// Single export, fully synthetic — no real module to snapshot.
|
||||
mock.module('../permissions.js', () => ({
|
||||
createAcpCanUseTool: mock(() =>
|
||||
mock(async () => ({ behavior: 'allow', updatedInput: {} })),
|
||||
),
|
||||
}))
|
||||
|
||||
safeMockModule('../utils.ts', {
|
||||
resolvePermissionMode: mock(() => 'default'),
|
||||
mockModulePreservingExports('../utils.ts', {
|
||||
computeSessionFingerprint: mock(() => '{}'),
|
||||
sanitizeTitle: mock((s: string) => s),
|
||||
})
|
||||
|
||||
safeMockModule('../bridge.ts', {
|
||||
mockModulePreservingExports('../bridge.ts', {
|
||||
forwardSessionUpdates: mock(async () => ({
|
||||
stopReason: 'end_turn' as const,
|
||||
})),
|
||||
@@ -105,33 +115,38 @@ safeMockModule('../bridge.ts', {
|
||||
})),
|
||||
})
|
||||
|
||||
safeMockModule('../../../utils/listSessionsImpl.ts', {
|
||||
mockModulePreservingExports('../../../utils/listSessionsImpl.ts', {
|
||||
listSessionsImpl: mock(async () => []),
|
||||
})
|
||||
|
||||
const mockGetMainLoopModel = mock(() => 'claude-sonnet-4-6')
|
||||
|
||||
safeMockModule('../../../utils/model/model.ts', {
|
||||
mockModulePreservingExports('../../../utils/model/model.ts', {
|
||||
getMainLoopModel: mockGetMainLoopModel,
|
||||
})
|
||||
|
||||
safeMockModule('../../../utils/model/modelOptions.ts', {
|
||||
mockModulePreservingExports('../../../utils/model/modelOptions.ts', {
|
||||
getModelOptions: mock(() => []),
|
||||
})
|
||||
|
||||
const mockApplySafeEnvVars = mock(() => {})
|
||||
safeMockModule('../../../utils/managedEnv.ts', {
|
||||
mockModulePreservingExports('../../../utils/managedEnv.ts', {
|
||||
applySafeConfigEnvironmentVariables: mockApplySafeEnvVars,
|
||||
})
|
||||
|
||||
const mockGetSettings = mock(() => ({}))
|
||||
mockModulePreservingExports('../../../utils/settings/settings.ts', {
|
||||
getSettings_DEPRECATED: mockGetSettings,
|
||||
})
|
||||
|
||||
const mockDeserializeMessages = mock((msgs: unknown[]) => msgs)
|
||||
safeMockModule('../../../utils/conversationRecovery.ts', {
|
||||
mockModulePreservingExports('../../../utils/conversationRecovery.ts', {
|
||||
deserializeMessages: mockDeserializeMessages,
|
||||
})
|
||||
|
||||
const mockGetLastSessionLog = mock(async () => null)
|
||||
const mockSessionIdExists = mock(() => false)
|
||||
safeMockModule('../../../utils/sessionStorage.ts', {
|
||||
mockModulePreservingExports('../../../utils/sessionStorage.ts', {
|
||||
getLastSessionLog: mockGetLastSessionLog,
|
||||
sessionIdExists: mockSessionIdExists,
|
||||
})
|
||||
@@ -161,7 +176,7 @@ const mockGetCommands = mock(async () => [
|
||||
},
|
||||
])
|
||||
|
||||
safeMockModule('../../../commands.ts', {
|
||||
mockModulePreservingExports('../../../commands.ts', {
|
||||
getCommands: mockGetCommands,
|
||||
})
|
||||
|
||||
@@ -181,16 +196,48 @@ function makeConn() {
|
||||
} as any
|
||||
}
|
||||
|
||||
function removeBypassMode(session: any) {
|
||||
session.modes = {
|
||||
...session.modes,
|
||||
availableModes: session.modes.availableModes.filter(
|
||||
(mode: any) => mode.id !== 'bypassPermissions',
|
||||
),
|
||||
}
|
||||
session.appState.toolPermissionContext = {
|
||||
...session.appState.toolPermissionContext,
|
||||
isBypassPermissionsModeAvailable: false,
|
||||
}
|
||||
}
|
||||
|
||||
function restoreEnv(name: string, value: string | undefined) {
|
||||
if (value === undefined) {
|
||||
delete process.env[name]
|
||||
} else {
|
||||
process.env[name] = value
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('AcpAgent', () => {
|
||||
afterAll(() => {
|
||||
for (const restore of _restores) restore()
|
||||
})
|
||||
beforeEach(() => {
|
||||
delete process.env.ACP_PERMISSION_MODE
|
||||
delete process.env.CLAUDE_CODE_ACP_ALLOW_BYPASS_PERMISSIONS
|
||||
mockSetModel.mockClear()
|
||||
mockSubmitMessage.mockReset()
|
||||
mockSubmitMessage.mockImplementation(async function* (_input: string) {})
|
||||
mockGetMainLoopModel.mockClear()
|
||||
mockGetDefaultAppState.mockClear()
|
||||
mockGetSettings.mockReset()
|
||||
mockGetSettings.mockImplementation(() => ({}))
|
||||
;(forwardSessionUpdates as ReturnType<typeof mock>).mockReset()
|
||||
;(forwardSessionUpdates as ReturnType<typeof mock>).mockImplementation(
|
||||
async () => ({ stopReason: 'end_turn' as const }),
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(originalCwd)
|
||||
})
|
||||
|
||||
describe('initialize', () => {
|
||||
@@ -255,6 +302,13 @@ describe('AcpAgent', () => {
|
||||
expect(r1.sessionId).not.toBe(r2.sessionId)
|
||||
})
|
||||
|
||||
test('does not leave process cwd changed after session creation', async () => {
|
||||
const cwdBeforeSession = process.cwd()
|
||||
const agent = new AcpAgent(makeConn())
|
||||
await agent.newSession({ cwd: '/tmp' } as any)
|
||||
expect(process.cwd()).toBe(cwdBeforeSession)
|
||||
})
|
||||
|
||||
test('calls getDefaultAppState to build session appState', async () => {
|
||||
const agent = new AcpAgent(makeConn())
|
||||
await agent.newSession({ cwd: '/tmp' } as any)
|
||||
@@ -290,6 +344,99 @@ describe('AcpAgent', () => {
|
||||
const res = await agent.newSession({ cwd: '/tmp' } as any)
|
||||
expect(res.sessionId).toBeDefined()
|
||||
})
|
||||
|
||||
test('uses settings permissions.defaultMode when _meta does not provide a mode', async () => {
|
||||
mockGetSettings.mockImplementationOnce(() => ({
|
||||
permissions: { defaultMode: 'acceptEdits' },
|
||||
}))
|
||||
const agent = new AcpAgent(makeConn())
|
||||
const res = await agent.newSession({ cwd: '/tmp' } as any)
|
||||
|
||||
expect(res.modes?.currentModeId).toBe('acceptEdits')
|
||||
})
|
||||
|
||||
test('uses _meta.permissionMode before settings permissions.defaultMode', async () => {
|
||||
mockGetSettings.mockImplementationOnce(() => ({
|
||||
permissions: { defaultMode: 'acceptEdits' },
|
||||
}))
|
||||
const agent = new AcpAgent(makeConn())
|
||||
const res = await agent.newSession({
|
||||
cwd: '/tmp',
|
||||
_meta: { permissionMode: 'plan' },
|
||||
} as any)
|
||||
|
||||
expect(res.modes?.currentModeId).toBe('plan')
|
||||
})
|
||||
|
||||
test('rejects _meta.permissionMode bypass without a local ACP bypass gate', async () => {
|
||||
mockGetSettings.mockImplementationOnce(() => ({
|
||||
permissions: { defaultMode: 'acceptEdits' },
|
||||
}))
|
||||
const consoleErrorSpy = spyOn(console, 'error').mockImplementation(() => {})
|
||||
const agent = new AcpAgent(makeConn())
|
||||
try {
|
||||
await expect(
|
||||
agent.newSession({
|
||||
cwd: '/tmp',
|
||||
_meta: { permissionMode: 'bypassPermissions' },
|
||||
} as any),
|
||||
).rejects.toThrow('Mode not available: bypassPermissions')
|
||||
|
||||
expect(consoleErrorSpy).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
consoleErrorSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
test('honors _meta.permissionMode bypass with a local ACP bypass gate', async () => {
|
||||
process.env.CLAUDE_CODE_ACP_ALLOW_BYPASS_PERMISSIONS = '1'
|
||||
const agent = new AcpAgent(makeConn())
|
||||
const res = await agent.newSession({
|
||||
cwd: '/tmp',
|
||||
_meta: { permissionMode: 'bypassPermissions' },
|
||||
} as any)
|
||||
|
||||
expect(res.modes?.currentModeId).toBe('bypassPermissions')
|
||||
expect(res.modes?.availableModes.map((mode: any) => mode.id)).toContain(
|
||||
'bypassPermissions',
|
||||
)
|
||||
})
|
||||
|
||||
test('falls back to default when settings permissions.defaultMode is invalid', async () => {
|
||||
mockGetSettings.mockImplementationOnce(() => ({
|
||||
permissions: { defaultMode: 'invalid-mode' },
|
||||
}))
|
||||
const consoleErrorSpy = spyOn(console, 'error').mockImplementation(() => {})
|
||||
const agent = new AcpAgent(makeConn())
|
||||
try {
|
||||
const res = await agent.newSession({ cwd: '/tmp' } as any)
|
||||
|
||||
expect(res.modes?.currentModeId).toBe('default')
|
||||
expect(consoleErrorSpy).toHaveBeenCalled()
|
||||
} finally {
|
||||
consoleErrorSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
test('rejects invalid _meta.permissionMode without falling back to settings', async () => {
|
||||
mockGetSettings.mockImplementationOnce(() => ({
|
||||
permissions: { defaultMode: 'acceptEdits' },
|
||||
}))
|
||||
const consoleErrorSpy = spyOn(console, 'error').mockImplementation(() => {})
|
||||
const agent = new AcpAgent(makeConn())
|
||||
try {
|
||||
await expect(
|
||||
agent.newSession({
|
||||
cwd: '/tmp',
|
||||
_meta: { permissionMode: 'invalid-mode' },
|
||||
} as any),
|
||||
).rejects.toThrow('Invalid _meta.permissionMode: invalid-mode')
|
||||
|
||||
expect(consoleErrorSpy).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
consoleErrorSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('prompt', () => {
|
||||
@@ -375,7 +522,7 @@ describe('AcpAgent', () => {
|
||||
expect(res2.stopReason).toBe('end_turn')
|
||||
})
|
||||
|
||||
test('returns end_turn on unexpected error', async () => {
|
||||
test('propagates unexpected prompt errors', async () => {
|
||||
const agent = new AcpAgent(makeConn())
|
||||
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
|
||||
;(
|
||||
@@ -383,16 +530,13 @@ describe('AcpAgent', () => {
|
||||
).mockImplementationOnce(async () => {
|
||||
throw new Error('unexpected')
|
||||
})
|
||||
const errorSpy = spyOn(console, 'error').mockImplementation(() => {})
|
||||
try {
|
||||
const res = await agent.prompt({
|
||||
|
||||
await expect(
|
||||
agent.prompt({
|
||||
sessionId,
|
||||
prompt: [{ type: 'text', text: 'hello' }],
|
||||
} as any)
|
||||
expect(res.stopReason).toBe('end_turn')
|
||||
} finally {
|
||||
errorSpy.mockRestore()
|
||||
}
|
||||
} as any),
|
||||
).rejects.toThrow('unexpected')
|
||||
})
|
||||
|
||||
test('returns usage from forwardSessionUpdates', async () => {
|
||||
@@ -676,15 +820,28 @@ describe('AcpAgent', () => {
|
||||
).rejects.toThrow('Session not found')
|
||||
})
|
||||
|
||||
test('availableModes includes bypassPermissions when not root', async () => {
|
||||
test('availableModes excludes bypassPermissions without a local ACP bypass gate', async () => {
|
||||
const agent = new AcpAgent(makeConn())
|
||||
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
|
||||
const session = agent.sessions.get(sessionId)
|
||||
const modeIds = session?.modes.availableModes.map((m: any) => m.id)
|
||||
expect(modeIds).toContain('bypassPermissions')
|
||||
expect(modeIds).not.toContain('bypassPermissions')
|
||||
})
|
||||
|
||||
test('can switch to bypassPermissions mode', async () => {
|
||||
test('rejects bypassPermissions without a local ACP bypass gate', async () => {
|
||||
const agent = new AcpAgent(makeConn())
|
||||
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
|
||||
await expect(
|
||||
agent.setSessionMode({ sessionId, modeId: 'bypassPermissions' } as any),
|
||||
).rejects.toThrow('Mode not available')
|
||||
|
||||
const session = agent.sessions.get(sessionId)
|
||||
expect(session?.modes.currentModeId).toBe('default')
|
||||
expect(session?.appState.toolPermissionContext.mode).toBe('default')
|
||||
})
|
||||
|
||||
test('can switch to bypassPermissions mode with a local ACP bypass gate', async () => {
|
||||
process.env.CLAUDE_CODE_ACP_ALLOW_BYPASS_PERMISSIONS = '1'
|
||||
const agent = new AcpAgent(makeConn())
|
||||
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
|
||||
await agent.setSessionMode({
|
||||
@@ -697,6 +854,21 @@ describe('AcpAgent', () => {
|
||||
'bypassPermissions',
|
||||
)
|
||||
})
|
||||
|
||||
test('rejects bypassPermissions when the session does not expose it', async () => {
|
||||
process.env.CLAUDE_CODE_ACP_ALLOW_BYPASS_PERMISSIONS = '1'
|
||||
const agent = new AcpAgent(makeConn())
|
||||
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
|
||||
const session = agent.sessions.get(sessionId)
|
||||
removeBypassMode(session)
|
||||
|
||||
await expect(
|
||||
agent.setSessionMode({ sessionId, modeId: 'bypassPermissions' } as any),
|
||||
).rejects.toThrow('Mode not available')
|
||||
|
||||
expect(session?.modes.currentModeId).toBe('default')
|
||||
expect(session?.appState.toolPermissionContext.mode).toBe('default')
|
||||
})
|
||||
})
|
||||
|
||||
describe('setSessionConfigOption', () => {
|
||||
@@ -723,6 +895,24 @@ describe('AcpAgent', () => {
|
||||
} as any),
|
||||
).rejects.toThrow('Invalid value')
|
||||
})
|
||||
|
||||
test('rejects unavailable mode config values', async () => {
|
||||
const agent = new AcpAgent(makeConn())
|
||||
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
|
||||
const session = agent.sessions.get(sessionId)
|
||||
removeBypassMode(session)
|
||||
|
||||
await expect(
|
||||
agent.setSessionConfigOption({
|
||||
sessionId,
|
||||
configId: 'mode',
|
||||
value: 'bypassPermissions',
|
||||
} as any),
|
||||
).rejects.toThrow('Mode not available')
|
||||
|
||||
expect(session?.modes.currentModeId).toBe('default')
|
||||
expect(session?.appState.toolPermissionContext.mode).toBe('default')
|
||||
})
|
||||
})
|
||||
|
||||
describe('prompt queueing', () => {
|
||||
@@ -758,6 +948,94 @@ describe('AcpAgent', () => {
|
||||
expect(r2.stopReason).toBe('end_turn')
|
||||
})
|
||||
|
||||
test('drains 1000 queued prompts in FIFO order without sorting the pending map', async () => {
|
||||
const agent = new AcpAgent(makeConn())
|
||||
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
|
||||
|
||||
let resolveFirst!: () => void
|
||||
;(
|
||||
forwardSessionUpdates as ReturnType<typeof mock>
|
||||
).mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<{ stopReason: string }>(resolve => {
|
||||
resolveFirst = () => resolve({ stopReason: 'end_turn' })
|
||||
}),
|
||||
)
|
||||
|
||||
const first = agent.prompt({
|
||||
sessionId,
|
||||
prompt: [{ type: 'text', text: 'first' }],
|
||||
} as any)
|
||||
const queued = Array.from({ length: 1000 }, (_, index) =>
|
||||
agent.prompt({
|
||||
sessionId,
|
||||
prompt: [{ type: 'text', text: `queued-${index}` }],
|
||||
} as any),
|
||||
)
|
||||
|
||||
resolveFirst()
|
||||
const results = await Promise.all([first, ...queued])
|
||||
|
||||
expect(results.every(result => result.stopReason === 'end_turn')).toBe(true)
|
||||
expect(mockSubmitMessage.mock.calls.map(call => call[0])).toEqual([
|
||||
'first',
|
||||
...Array.from({ length: 1000 }, (_, index) => `queued-${index}`),
|
||||
])
|
||||
})
|
||||
|
||||
test('keeps promptRunning true while handing off to the next queued prompt', async () => {
|
||||
const agent = new AcpAgent(makeConn())
|
||||
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
|
||||
|
||||
let resolveFirst!: () => void
|
||||
let resolveSecond!: () => void
|
||||
;(forwardSessionUpdates as ReturnType<typeof mock>).mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<{ stopReason: string }>(resolve => {
|
||||
resolveFirst = () => resolve({ stopReason: 'end_turn' })
|
||||
}),
|
||||
)
|
||||
;(forwardSessionUpdates as ReturnType<typeof mock>).mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<{ stopReason: string }>(resolve => {
|
||||
resolveSecond = () => resolve({ stopReason: 'end_turn' })
|
||||
}),
|
||||
)
|
||||
|
||||
const p1 = agent.prompt({
|
||||
sessionId,
|
||||
prompt: [{ type: 'text', text: 'first' }],
|
||||
} as any)
|
||||
const p2 = agent.prompt({
|
||||
sessionId,
|
||||
prompt: [{ type: 'text', text: 'second' }],
|
||||
} as any)
|
||||
|
||||
const p3 = p1.then(() =>
|
||||
agent.prompt({
|
||||
sessionId,
|
||||
prompt: [{ type: 'text', text: 'third' }],
|
||||
} as any),
|
||||
)
|
||||
|
||||
resolveFirst()
|
||||
await p1
|
||||
const session = agent.sessions.get(sessionId)
|
||||
expect(session?.promptRunning).toBe(true)
|
||||
expect(mockSubmitMessage.mock.calls.map(call => call[0])).toEqual([
|
||||
'first',
|
||||
'second',
|
||||
])
|
||||
|
||||
resolveSecond()
|
||||
await Promise.all([p2, p3])
|
||||
expect(mockSubmitMessage.mock.calls.map(call => call[0])).toEqual([
|
||||
'first',
|
||||
'second',
|
||||
'third',
|
||||
])
|
||||
})
|
||||
|
||||
test('queued prompts return cancelled when session is cancelled', async () => {
|
||||
const agent = new AcpAgent(makeConn())
|
||||
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
|
||||
@@ -787,6 +1065,46 @@ describe('AcpAgent', () => {
|
||||
expect(r1.stopReason).toBe('cancelled')
|
||||
expect(r2.stopReason).toBe('cancelled')
|
||||
})
|
||||
|
||||
test('queued prompt does not clear active prompt cancellation', async () => {
|
||||
const agent = new AcpAgent(makeConn())
|
||||
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
|
||||
|
||||
let resolveFirst!: () => void
|
||||
;(
|
||||
forwardSessionUpdates as ReturnType<typeof mock>
|
||||
).mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<{ stopReason: string }>(resolve => {
|
||||
resolveFirst = () => resolve({ stopReason: 'end_turn' })
|
||||
}),
|
||||
)
|
||||
;(forwardSessionUpdates as ReturnType<typeof mock>).mockResolvedValueOnce(
|
||||
{ stopReason: 'end_turn' },
|
||||
)
|
||||
|
||||
const p1 = agent.prompt({
|
||||
sessionId,
|
||||
prompt: [{ type: 'text', text: 'first' }],
|
||||
} as any)
|
||||
|
||||
await agent.cancel({ sessionId } as any)
|
||||
|
||||
const p2 = agent.prompt({
|
||||
sessionId,
|
||||
prompt: [{ type: 'text', text: 'second' }],
|
||||
} as any)
|
||||
|
||||
resolveFirst()
|
||||
|
||||
const [r1, r2] = await Promise.all([p1, p2])
|
||||
expect(r1.stopReason).toBe('cancelled')
|
||||
expect(r2.stopReason).toBe('end_turn')
|
||||
expect(mockSubmitMessage.mock.calls.map(call => call[0])).toEqual([
|
||||
'first',
|
||||
'second',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('commands', () => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
toolUpdateFromEditToolResponse,
|
||||
forwardSessionUpdates,
|
||||
} from '../bridge.js'
|
||||
import { promptToQueryInput } from '../promptConversion.js'
|
||||
import { markdownEscape, toDisplayPath } from '../utils.js'
|
||||
import type { AgentSideConnection, ToolKind } from '@agentclientprotocol/sdk'
|
||||
import type { SDKMessage } from '../../../entrypoints/sdk/coreTypes.js'
|
||||
@@ -336,6 +337,20 @@ describe('toolInfoFromToolUse', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('promptToQueryInput', () => {
|
||||
test('uses shared prompt conversion for resource links', () => {
|
||||
expect(
|
||||
promptToQueryInput([
|
||||
{
|
||||
type: 'resource_link',
|
||||
name: 'Spec',
|
||||
uri: 'file:///tmp/spec.md',
|
||||
} as any,
|
||||
]),
|
||||
).toBe('Resource link: name=Spec, uri=file:///tmp/spec.md')
|
||||
})
|
||||
})
|
||||
|
||||
// ── toolUpdateFromToolResult ───────────────────────────────────────
|
||||
|
||||
describe('toolUpdateFromToolResult', () => {
|
||||
@@ -709,6 +724,87 @@ describe('forwardSessionUpdates', () => {
|
||||
expect(result.stopReason).toBe('cancelled')
|
||||
})
|
||||
|
||||
test('cleans abort listeners when sdkMessages.next wins repeatedly', async () => {
|
||||
const ac = new AbortController()
|
||||
let abortListeners = 0
|
||||
const add = ac.signal.addEventListener.bind(ac.signal)
|
||||
const remove = ac.signal.removeEventListener.bind(ac.signal)
|
||||
const addEventListener: AbortSignal['addEventListener'] = (
|
||||
type: keyof AbortSignalEventMap,
|
||||
listener: EventListenerOrEventListenerObject,
|
||||
options?: boolean | AddEventListenerOptions,
|
||||
) => {
|
||||
if (type === 'abort') abortListeners++
|
||||
return add(type, listener, options)
|
||||
}
|
||||
const removeEventListener: AbortSignal['removeEventListener'] = (
|
||||
type: keyof AbortSignalEventMap,
|
||||
listener: EventListenerOrEventListenerObject,
|
||||
options?: boolean | EventListenerOptions,
|
||||
) => {
|
||||
if (type === 'abort') abortListeners--
|
||||
return remove(type, listener, options)
|
||||
}
|
||||
ac.signal.addEventListener = addEventListener
|
||||
ac.signal.removeEventListener = removeEventListener
|
||||
|
||||
const msgs = Array.from({ length: 10_000 }, () => ({
|
||||
type: 'system',
|
||||
subtype: 'api_retry',
|
||||
}) as unknown as SDKMessage)
|
||||
|
||||
const result = await forwardSessionUpdates(
|
||||
's1',
|
||||
makeStream(msgs),
|
||||
makeConn(),
|
||||
ac.signal,
|
||||
{},
|
||||
)
|
||||
|
||||
expect(result.stopReason).toBe('end_turn')
|
||||
expect(abortListeners).toBe(0)
|
||||
})
|
||||
|
||||
test('cleans abort listeners when abort wins the race', async () => {
|
||||
const ac = new AbortController()
|
||||
let abortListeners = 0
|
||||
const add = ac.signal.addEventListener.bind(ac.signal)
|
||||
const remove = ac.signal.removeEventListener.bind(ac.signal)
|
||||
ac.signal.addEventListener = (
|
||||
type: keyof AbortSignalEventMap,
|
||||
listener: EventListenerOrEventListenerObject,
|
||||
options?: boolean | AddEventListenerOptions,
|
||||
) => {
|
||||
if (type === 'abort') abortListeners++
|
||||
return add(type, listener, options)
|
||||
}
|
||||
ac.signal.removeEventListener = (
|
||||
type: keyof AbortSignalEventMap,
|
||||
listener: EventListenerOrEventListenerObject,
|
||||
options?: boolean | EventListenerOptions,
|
||||
) => {
|
||||
if (type === 'abort') abortListeners--
|
||||
return remove(type, listener, options)
|
||||
}
|
||||
|
||||
async function* never(): AsyncGenerator<SDKMessage, void, unknown> {
|
||||
await new Promise(() => {})
|
||||
}
|
||||
|
||||
const resultPromise = forwardSessionUpdates(
|
||||
's1',
|
||||
never(),
|
||||
makeConn(),
|
||||
ac.signal,
|
||||
{},
|
||||
)
|
||||
ac.abort()
|
||||
const result = await resultPromise
|
||||
|
||||
expect(result.stopReason).toBe('cancelled')
|
||||
expect(abortListeners).toBe(0)
|
||||
})
|
||||
|
||||
test('forwards assistant text message as agent_message_chunk', async () => {
|
||||
const conn = makeConn()
|
||||
const msgs: SDKMessage[] = [
|
||||
@@ -765,6 +861,7 @@ describe('forwardSessionUpdates', () => {
|
||||
|
||||
test('forwards tool_use block as tool_call', async () => {
|
||||
const conn = makeConn()
|
||||
const input = { command: 'ls' }
|
||||
const msgs: SDKMessage[] = [
|
||||
{
|
||||
type: 'assistant',
|
||||
@@ -774,7 +871,7 @@ describe('forwardSessionUpdates', () => {
|
||||
type: 'tool_use',
|
||||
id: 'tu_1',
|
||||
name: 'Bash',
|
||||
input: { command: 'ls' },
|
||||
input,
|
||||
},
|
||||
],
|
||||
role: 'assistant',
|
||||
@@ -794,6 +891,8 @@ describe('forwardSessionUpdates', () => {
|
||||
expect(update.toolCallId).toBe('tu_1')
|
||||
expect(update.kind).toBe('execute' as ToolKind)
|
||||
expect(update.status).toBe('pending')
|
||||
expect(update.rawInput).toEqual(input)
|
||||
expect(update.rawInput).not.toBe(input)
|
||||
})
|
||||
|
||||
test('sends usage_update on result message with correct tokens', async () => {
|
||||
|
||||
@@ -1,144 +1,306 @@
|
||||
import { describe, expect, test, mock } from 'bun:test'
|
||||
import { afterAll, beforeEach, describe, expect, mock, spyOn, test } from 'bun:test'
|
||||
import type { AgentSideConnection } from '@agentclientprotocol/sdk'
|
||||
import type { Tool as ToolType } from '../../../Tool.js'
|
||||
import type { Tool as ToolType, ToolUseContext } from '../../../Tool.js'
|
||||
import type { AssistantMessage } from '../../../types/message.js'
|
||||
|
||||
// ── Inline re-implementation of createAcpCanUseTool for isolated testing ──
|
||||
// We cannot import the real permissions.js because agent.test.ts mocks it globally.
|
||||
// Instead we re-implement the core logic here, using our own mocked bridge.js.
|
||||
const askDecision = {
|
||||
behavior: 'ask',
|
||||
message: 'approval required',
|
||||
decisionReason: { type: 'mode', mode: 'default' },
|
||||
} as const
|
||||
|
||||
function createAcpCanUseTool(
|
||||
conn: AgentSideConnection,
|
||||
sessionId: string,
|
||||
getCurrentMode: () => string,
|
||||
): any {
|
||||
return async (
|
||||
tool: { name: string },
|
||||
input: Record<string, unknown>,
|
||||
_context: any,
|
||||
_assistantMessage: any,
|
||||
toolUseID: string,
|
||||
): Promise<{ behavior: string; message?: string; updatedInput?: Record<string, unknown> }> => {
|
||||
if (getCurrentMode() === 'bypassPermissions') {
|
||||
return { behavior: 'allow', updatedInput: input }
|
||||
}
|
||||
const hasPermissionsMock = mock(async (): Promise<unknown> => askDecision)
|
||||
const toolInfoMock = mock(() => ({
|
||||
title: 'Bash',
|
||||
kind: 'execute',
|
||||
content: [],
|
||||
locations: [],
|
||||
}))
|
||||
|
||||
const TOOL_KIND_MAP: Record<string, string> = {
|
||||
Read: 'read', Edit: 'edit', Write: 'edit',
|
||||
Bash: 'execute', Glob: 'search', Grep: 'search',
|
||||
WebFetch: 'fetch', WebSearch: 'fetch',
|
||||
}
|
||||
|
||||
const toolCall = {
|
||||
toolCallId: toolUseID,
|
||||
title: tool.name,
|
||||
kind: TOOL_KIND_MAP[tool.name] ?? 'other',
|
||||
status: 'pending',
|
||||
rawInput: input,
|
||||
}
|
||||
|
||||
const options = [
|
||||
{ kind: 'allow_always', name: 'Always Allow', optionId: 'allow_always' },
|
||||
{ kind: 'allow_once', name: 'Allow', optionId: 'allow' },
|
||||
{ kind: 'reject_once', name: 'Reject', optionId: 'reject' },
|
||||
]
|
||||
|
||||
try {
|
||||
const response = await (conn as any).requestPermission({ sessionId, toolCall, options })
|
||||
|
||||
if (response.outcome.outcome === 'cancelled') {
|
||||
return { behavior: 'deny', message: 'Permission request cancelled by client' }
|
||||
}
|
||||
|
||||
if (response.outcome.outcome === 'selected' && response.outcome.optionId !== undefined) {
|
||||
const optionId = response.outcome.optionId
|
||||
if (optionId === 'allow' || optionId === 'allow_always') {
|
||||
return { behavior: 'allow', updatedInput: input }
|
||||
}
|
||||
}
|
||||
|
||||
return { behavior: 'deny', message: 'Permission denied by client' }
|
||||
} catch {
|
||||
return { behavior: 'deny', message: 'Permission request failed' }
|
||||
}
|
||||
}
|
||||
const permissionsModuleSnapshot = {
|
||||
...(require('../../../utils/permissions/permissions.ts') as Record<
|
||||
string,
|
||||
unknown
|
||||
>),
|
||||
}
|
||||
const bridgeModuleSnapshot = {
|
||||
...(require('../bridge.ts') as Record<string, unknown>),
|
||||
}
|
||||
|
||||
function makeConn(permissionResponse: Record<string, unknown>) {
|
||||
afterAll(() => {
|
||||
mock.module('../bridge.js', () => bridgeModuleSnapshot)
|
||||
mock.module('../../../utils/permissions/permissions.js', () => permissionsModuleSnapshot)
|
||||
})
|
||||
|
||||
mock.module('../../../utils/permissions/permissions.js', () => ({
|
||||
...permissionsModuleSnapshot,
|
||||
hasPermissionsToUseTool: hasPermissionsMock,
|
||||
}))
|
||||
|
||||
mock.module('../bridge.js', () => ({
|
||||
...bridgeModuleSnapshot,
|
||||
toolInfoFromToolUse: toolInfoMock,
|
||||
}))
|
||||
|
||||
const { createAcpCanUseTool } = await import('../permissions.js')
|
||||
|
||||
type PermissionResponse =
|
||||
| { outcome: { outcome: 'cancelled' } }
|
||||
| { outcome: { outcome: 'selected'; optionId: string } }
|
||||
|
||||
function makeConn(
|
||||
permissionResponse: PermissionResponse = {
|
||||
outcome: { outcome: 'selected', optionId: 'allow' },
|
||||
},
|
||||
): AgentSideConnection {
|
||||
return {
|
||||
requestPermission: mock(async () => permissionResponse),
|
||||
sessionUpdate: mock(async () => {}),
|
||||
} as unknown as AgentSideConnection
|
||||
}
|
||||
|
||||
function makeTool(name: string) {
|
||||
function makeTool(name: string): ToolType {
|
||||
return { name } as unknown as ToolType
|
||||
}
|
||||
|
||||
const dummyContext = {} as Record<string, unknown>
|
||||
const dummyMsg = {} as Record<string, unknown>
|
||||
const dummyContext = {} as unknown as ToolUseContext
|
||||
const dummyMsg = {} as unknown as AssistantMessage
|
||||
|
||||
describe('createAcpCanUseTool', () => {
|
||||
test('returns allow when client selects allow option', async () => {
|
||||
const conn = makeConn({ outcome: { outcome: 'selected', optionId: 'allow' } })
|
||||
const canUseTool = createAcpCanUseTool(conn, 'sess-1', () => 'default')
|
||||
const result = await canUseTool(makeTool('Bash'), { command: 'ls' }, dummyContext as any, dummyMsg as any, 'tu_1')
|
||||
expect(result.behavior).toBe('allow')
|
||||
beforeEach(() => {
|
||||
hasPermissionsMock.mockReset()
|
||||
hasPermissionsMock.mockResolvedValue(askDecision)
|
||||
toolInfoMock.mockClear()
|
||||
})
|
||||
|
||||
test('returns deny when client selects reject option', async () => {
|
||||
const conn = makeConn({ outcome: { outcome: 'selected', optionId: 'reject' } })
|
||||
test('returns pipeline allow without client delegation', async () => {
|
||||
const conn = makeConn()
|
||||
const input = { command: 'ls' }
|
||||
hasPermissionsMock.mockResolvedValueOnce({
|
||||
behavior: 'allow',
|
||||
updatedInput: input,
|
||||
})
|
||||
|
||||
const canUseTool = createAcpCanUseTool(conn, 'sess-1', () => 'default')
|
||||
const result = await canUseTool(makeTool('Bash'), {}, dummyContext as any, dummyMsg as any, 'tu_2')
|
||||
const result = await canUseTool(
|
||||
makeTool('Bash'),
|
||||
input,
|
||||
dummyContext,
|
||||
dummyMsg,
|
||||
'tu_1',
|
||||
)
|
||||
|
||||
expect(result).toEqual({ behavior: 'allow', updatedInput: input })
|
||||
expect(
|
||||
(conn.requestPermission as ReturnType<typeof mock>).mock.calls,
|
||||
).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('returns pipeline deny without client delegation', async () => {
|
||||
const conn = makeConn()
|
||||
hasPermissionsMock.mockResolvedValueOnce({
|
||||
behavior: 'deny',
|
||||
message: 'blocked by policy',
|
||||
decisionReason: { type: 'other', reason: 'blocked by policy' },
|
||||
})
|
||||
|
||||
const canUseTool = createAcpCanUseTool(conn, 'sess-1', () => 'default')
|
||||
const result = await canUseTool(
|
||||
makeTool('Bash'),
|
||||
{ command: 'rm -rf /' },
|
||||
dummyContext,
|
||||
dummyMsg,
|
||||
'tu_2',
|
||||
)
|
||||
|
||||
expect(result.behavior).toBe('deny')
|
||||
expect(
|
||||
(conn.requestPermission as ReturnType<typeof mock>).mock.calls,
|
||||
).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('returns deny when client cancels', async () => {
|
||||
const conn = makeConn({ outcome: { outcome: 'cancelled' } })
|
||||
test('denies when the permission pipeline throws', async () => {
|
||||
const conn = makeConn()
|
||||
hasPermissionsMock.mockRejectedValueOnce(new Error('rule loader failed'))
|
||||
const errorSpy = spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
try {
|
||||
const canUseTool = createAcpCanUseTool(conn, 'sess-1', () => 'default')
|
||||
const result = await canUseTool(
|
||||
makeTool('Edit'),
|
||||
{ file_path: '/tmp/x' },
|
||||
dummyContext,
|
||||
dummyMsg,
|
||||
'tu_3',
|
||||
)
|
||||
|
||||
expect(result).toMatchObject({
|
||||
behavior: 'deny',
|
||||
decisionReason: { type: 'other', reason: 'Permission pipeline failed' },
|
||||
toolUseID: 'tu_3',
|
||||
})
|
||||
if (result.behavior !== 'deny') {
|
||||
throw new Error('expected deny result')
|
||||
}
|
||||
expect(result.message).toBe('Permission pipeline failed')
|
||||
expect(
|
||||
(conn.requestPermission as ReturnType<typeof mock>).mock.calls,
|
||||
).toHaveLength(0)
|
||||
} finally {
|
||||
errorSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
test('delegates ask decisions to the ACP client', async () => {
|
||||
const conn = makeConn({
|
||||
outcome: { outcome: 'selected', optionId: 'allow' },
|
||||
})
|
||||
const input = { command: 'ls' }
|
||||
const canUseTool = createAcpCanUseTool(conn, 'sess-1', () => 'default')
|
||||
const result = await canUseTool(makeTool('Read'), { file_path: '/tmp/x' }, dummyContext as any, dummyMsg as any, 'tu_3')
|
||||
expect(result.behavior).toBe('deny')
|
||||
const result = await canUseTool(
|
||||
makeTool('Bash'),
|
||||
input,
|
||||
dummyContext,
|
||||
dummyMsg,
|
||||
'tu_4',
|
||||
)
|
||||
|
||||
expect(result).toEqual({ behavior: 'allow', updatedInput: input })
|
||||
const callArgs = (conn.requestPermission as ReturnType<typeof mock>).mock
|
||||
.calls[0][0] as Record<string, unknown>
|
||||
expect(callArgs.sessionId).toBe('sess-1')
|
||||
expect((callArgs.toolCall as Record<string, unknown>).toolCallId).toBe(
|
||||
'tu_4',
|
||||
)
|
||||
})
|
||||
|
||||
test('returns deny when requestPermission throws', async () => {
|
||||
test('returns deny when the client rejects or cancels', async () => {
|
||||
const rejectConn = makeConn({
|
||||
outcome: { outcome: 'selected', optionId: 'reject' },
|
||||
})
|
||||
const cancelConn = makeConn({ outcome: { outcome: 'cancelled' } })
|
||||
|
||||
const rejectResult = await createAcpCanUseTool(
|
||||
rejectConn,
|
||||
'sess-1',
|
||||
() => 'default',
|
||||
)(makeTool('Bash'), {}, dummyContext, dummyMsg, 'tu_5')
|
||||
const cancelResult = await createAcpCanUseTool(
|
||||
cancelConn,
|
||||
'sess-1',
|
||||
() => 'default',
|
||||
)(makeTool('Read'), {}, dummyContext, dummyMsg, 'tu_6')
|
||||
|
||||
expect(rejectResult.behavior).toBe('deny')
|
||||
expect(cancelResult.behavior).toBe('deny')
|
||||
})
|
||||
|
||||
test('returns deny when client permission request fails', async () => {
|
||||
const conn = {
|
||||
requestPermission: mock(async () => { throw new Error('connection lost') }),
|
||||
requestPermission: mock(async () => {
|
||||
throw new Error('connection lost')
|
||||
}),
|
||||
sessionUpdate: mock(async () => {}),
|
||||
} as unknown as AgentSideConnection
|
||||
const canUseTool = createAcpCanUseTool(conn, 'sess-1', () => 'default')
|
||||
const result = await canUseTool(makeTool('Edit'), {}, dummyContext as any, dummyMsg as any, 'tu_4')
|
||||
expect(result.behavior).toBe('deny')
|
||||
const errorSpy = spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
try {
|
||||
const result = await createAcpCanUseTool(conn, 'sess-1', () => 'default')(
|
||||
makeTool('Write'),
|
||||
{},
|
||||
dummyContext,
|
||||
dummyMsg,
|
||||
'tu_7',
|
||||
)
|
||||
|
||||
expect(result.behavior).toBe('deny')
|
||||
if (result.behavior !== 'deny') {
|
||||
throw new Error('expected deny result')
|
||||
}
|
||||
expect(result.message).toContain('Permission request failed')
|
||||
} finally {
|
||||
errorSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
test('passes correct sessionId and toolCallId to requestPermission', async () => {
|
||||
const conn = makeConn({ outcome: { outcome: 'selected', optionId: 'allow' } })
|
||||
const canUseTool = createAcpCanUseTool(conn, 'my-session', () => 'default')
|
||||
await canUseTool(makeTool('Glob'), { pattern: '**/*.ts' }, dummyContext as any, dummyMsg as any, 'tu_99')
|
||||
const rpMock = conn.requestPermission as ReturnType<typeof mock>
|
||||
expect(rpMock.mock.calls.length).toBeGreaterThan(0)
|
||||
const callArgs = rpMock.mock.calls[0][0] as Record<string, unknown>
|
||||
expect(callArgs.sessionId).toBe('my-session')
|
||||
expect((callArgs.toolCall as Record<string, unknown>).toolCallId).toBe('tu_99')
|
||||
})
|
||||
|
||||
test('returns allow in bypassPermissions mode without calling requestPermission', async () => {
|
||||
const conn = makeConn({ outcome: { outcome: 'selected', optionId: 'allow' } })
|
||||
const canUseTool = createAcpCanUseTool(conn, 'sess-bypass', () => 'bypassPermissions')
|
||||
const result = await canUseTool(makeTool('Bash'), { command: 'rm -rf /' }, dummyContext as any, dummyMsg as any, 'tu_bp')
|
||||
expect(result.behavior).toBe('allow')
|
||||
const rpMock = conn.requestPermission as ReturnType<typeof mock>
|
||||
expect(rpMock.mock.calls).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('options include allow_always, allow_once and reject_once', async () => {
|
||||
test('options include allow always, allow once, and reject once', async () => {
|
||||
const conn = makeConn({ outcome: { outcome: 'cancelled' } })
|
||||
const canUseTool = createAcpCanUseTool(conn, 'sess-3', () => 'default')
|
||||
await canUseTool(makeTool('Write'), {}, dummyContext as any, dummyMsg as any, 'tu_6')
|
||||
const rpMock = conn.requestPermission as ReturnType<typeof mock>
|
||||
expect(rpMock.mock.calls.length).toBeGreaterThan(0)
|
||||
const { options } = rpMock.mock.calls[0][0] as Record<string, unknown>
|
||||
await canUseTool(makeTool('Write'), {}, dummyContext, dummyMsg, 'tu_8')
|
||||
|
||||
const { options } = (conn.requestPermission as ReturnType<typeof mock>).mock
|
||||
.calls[0][0] as Record<string, unknown>
|
||||
const opts = options as Array<Record<string, unknown>>
|
||||
expect(opts.find((o) => o.kind === 'allow_always')).toBeTruthy()
|
||||
expect(opts.find((o) => o.kind === 'allow_once')).toBeTruthy()
|
||||
expect(opts.find((o) => o.kind === 'reject_once')).toBeTruthy()
|
||||
expect(opts.find(option => option.kind === 'allow_always')).toBeTruthy()
|
||||
expect(opts.find(option => option.kind === 'allow_once')).toBeTruthy()
|
||||
expect(opts.find(option => option.kind === 'reject_once')).toBeTruthy()
|
||||
})
|
||||
|
||||
test('ExitPlanMode omits bypass option when the session does not expose it', async () => {
|
||||
const conn = makeConn({ outcome: { outcome: 'cancelled' } })
|
||||
const canUseTool = createAcpCanUseTool(
|
||||
conn,
|
||||
'sess-4',
|
||||
() => 'plan',
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
() => false,
|
||||
)
|
||||
|
||||
await canUseTool(makeTool('ExitPlanMode'), {}, dummyContext, dummyMsg, 'tu_9')
|
||||
|
||||
const { options } = (conn.requestPermission as ReturnType<typeof mock>).mock
|
||||
.calls[0][0] as Record<string, unknown>
|
||||
const opts = options as Array<Record<string, unknown>>
|
||||
expect(opts.some(option => option.optionId === 'bypassPermissions')).toBe(false)
|
||||
})
|
||||
|
||||
test('ExitPlanMode includes bypass option when the session exposes it', async () => {
|
||||
const conn = makeConn({ outcome: { outcome: 'cancelled' } })
|
||||
const canUseTool = createAcpCanUseTool(
|
||||
conn,
|
||||
'sess-5',
|
||||
() => 'plan',
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
() => true,
|
||||
)
|
||||
|
||||
await canUseTool(makeTool('ExitPlanMode'), {}, dummyContext, dummyMsg, 'tu_10')
|
||||
|
||||
const { options } = (conn.requestPermission as ReturnType<typeof mock>).mock
|
||||
.calls[0][0] as Record<string, unknown>
|
||||
const opts = options as Array<Record<string, unknown>>
|
||||
expect(opts.some(option => option.optionId === 'bypassPermissions')).toBe(true)
|
||||
})
|
||||
|
||||
test('ExitPlanMode rejects a bypass selection that was not offered', async () => {
|
||||
const conn = makeConn({
|
||||
outcome: { outcome: 'selected', optionId: 'bypassPermissions' },
|
||||
})
|
||||
const onModeChange = mock(() => {})
|
||||
const canUseTool = createAcpCanUseTool(
|
||||
conn,
|
||||
'sess-6',
|
||||
() => 'plan',
|
||||
undefined,
|
||||
undefined,
|
||||
onModeChange,
|
||||
() => false,
|
||||
)
|
||||
|
||||
const result = await canUseTool(
|
||||
makeTool('ExitPlanMode'),
|
||||
{},
|
||||
dummyContext,
|
||||
dummyMsg,
|
||||
'tu_11',
|
||||
)
|
||||
|
||||
expect(result.behavior).toBe('deny')
|
||||
expect(onModeChange).not.toHaveBeenCalled()
|
||||
expect((conn.sessionUpdate as ReturnType<typeof mock>).mock.calls).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
28
src/services/acp/__tests__/promptConversion.test.ts
Normal file
28
src/services/acp/__tests__/promptConversion.test.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { promptToQueryInput } from '../promptConversion.js'
|
||||
|
||||
describe('promptToQueryInput', () => {
|
||||
test('converts text and embedded text resources', () => {
|
||||
expect(
|
||||
promptToQueryInput([
|
||||
{ type: 'text', text: 'hello' },
|
||||
{
|
||||
type: 'resource',
|
||||
resource: { text: 'resource body' },
|
||||
} as any,
|
||||
]),
|
||||
).toBe('hello\nresource body')
|
||||
})
|
||||
|
||||
test('renders resource_link as plain metadata instead of markdown link', () => {
|
||||
expect(
|
||||
promptToQueryInput([
|
||||
{
|
||||
type: 'resource_link',
|
||||
name: 'Spec',
|
||||
uri: 'file:///tmp/spec.md',
|
||||
} as any,
|
||||
]),
|
||||
).toBe('Resource link: name=Spec, uri=file:///tmp/spec.md')
|
||||
})
|
||||
})
|
||||
@@ -33,7 +33,6 @@ import type {
|
||||
SetSessionModelResponse,
|
||||
SetSessionConfigOptionRequest,
|
||||
SetSessionConfigOptionResponse,
|
||||
ContentBlock,
|
||||
ClientCapabilities,
|
||||
SessionModeState,
|
||||
SessionModelState,
|
||||
@@ -63,31 +62,39 @@ import {
|
||||
computeSessionFingerprint,
|
||||
sanitizeTitle,
|
||||
} from './utils.js'
|
||||
import { promptToQueryInput } from './promptConversion.js'
|
||||
import {
|
||||
listSessionsImpl,
|
||||
} from '../../utils/listSessionsImpl.js'
|
||||
import { getMainLoopModel } from '../../utils/model/model.js'
|
||||
import { getModelOptions } from '../../utils/model/modelOptions.js'
|
||||
import { getSettings_DEPRECATED } from '../../utils/settings/settings.js'
|
||||
|
||||
// ── Session state ─────────────────────────────────────────────────
|
||||
|
||||
type AcpSession = {
|
||||
queryEngine: QueryEngine
|
||||
cancelled: boolean
|
||||
cancelGeneration: number
|
||||
cwd: string
|
||||
sessionFingerprint: string
|
||||
modes: SessionModeState
|
||||
models: SessionModelState
|
||||
configOptions: SessionConfigOption[]
|
||||
promptRunning: boolean
|
||||
pendingMessages: Map<string, { resolve: (cancelled: boolean) => void; order: number }>
|
||||
nextPendingOrder: number
|
||||
pendingMessages: Map<string, PendingPrompt>
|
||||
pendingQueue: string[]
|
||||
pendingQueueHead: number
|
||||
toolUseCache: ToolUseCache
|
||||
clientCapabilities?: ClientCapabilities
|
||||
appState: AppState
|
||||
commands: Command[]
|
||||
}
|
||||
|
||||
type PendingPrompt = {
|
||||
resolve: (cancelled: boolean) => void
|
||||
}
|
||||
|
||||
// ── Agent class ───────────────────────────────────────────────────
|
||||
|
||||
export class AcpAgent implements Agent {
|
||||
@@ -157,7 +164,9 @@ export class AcpAgent implements Agent {
|
||||
// ── newSession ────────────────────────────────────────────────
|
||||
|
||||
async newSession(params: NewSessionRequest): Promise<NewSessionResponse> {
|
||||
return this.createSession(params)
|
||||
const result = await this.createSession(params)
|
||||
this.scheduleAvailableCommandsUpdate(result.sessionId)
|
||||
return result
|
||||
}
|
||||
|
||||
// ── resumeSession ──────────────────────────────────────────────
|
||||
@@ -166,9 +175,7 @@ export class AcpAgent implements Agent {
|
||||
params: ResumeSessionRequest,
|
||||
): Promise<ResumeSessionResponse> {
|
||||
const result = await this.getOrCreateSession(params)
|
||||
setTimeout(() => {
|
||||
this.sendAvailableCommandsUpdate(params.sessionId)
|
||||
}, 0)
|
||||
this.scheduleAvailableCommandsUpdate(result.sessionId)
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -176,9 +183,7 @@ export class AcpAgent implements Agent {
|
||||
|
||||
async loadSession(params: LoadSessionRequest): Promise<LoadSessionResponse> {
|
||||
const result = await this.getOrCreateSession(params)
|
||||
setTimeout(() => {
|
||||
this.sendAvailableCommandsUpdate(params.sessionId)
|
||||
}, 0)
|
||||
this.scheduleAvailableCommandsUpdate(result.sessionId)
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -216,9 +221,7 @@ export class AcpAgent implements Agent {
|
||||
_meta: params._meta,
|
||||
},
|
||||
)
|
||||
setTimeout(() => {
|
||||
this.sendAvailableCommandsUpdate(response.sessionId)
|
||||
}, 0)
|
||||
this.scheduleAvailableCommandsUpdate(response.sessionId)
|
||||
return response
|
||||
}
|
||||
|
||||
@@ -243,9 +246,6 @@ export class AcpAgent implements Agent {
|
||||
throw new Error(`Session ${params.sessionId} not found`)
|
||||
}
|
||||
|
||||
// Reset cancelled state at the start of each prompt (matches official impl)
|
||||
session.cancelled = false
|
||||
|
||||
// Extract text/image content from the prompt
|
||||
const promptInput = promptToQueryInput(params.prompt)
|
||||
|
||||
@@ -253,18 +253,27 @@ export class AcpAgent implements Agent {
|
||||
return { stopReason: 'end_turn' }
|
||||
}
|
||||
|
||||
const promptCancelGeneration = session.cancelGeneration
|
||||
|
||||
// Handle prompt queuing — if a prompt is already running, queue this one
|
||||
if (session.promptRunning) {
|
||||
const order = session.nextPendingOrder++
|
||||
const promptUuid = randomUUID()
|
||||
const cancelled = await new Promise<boolean>((resolve) => {
|
||||
session.pendingMessages.set(promptUuid, { resolve, order })
|
||||
session.pendingQueue.push(promptUuid)
|
||||
session.pendingMessages.set(promptUuid, { resolve })
|
||||
})
|
||||
if (cancelled) {
|
||||
return { stopReason: 'cancelled' }
|
||||
}
|
||||
}
|
||||
|
||||
if (session.cancelGeneration !== promptCancelGeneration) {
|
||||
return { stopReason: 'cancelled' }
|
||||
}
|
||||
|
||||
// Reset cancellation only when this prompt is about to run. Queued prompts
|
||||
// must not clear the cancellation state for the active prompt.
|
||||
session.cancelled = false
|
||||
session.promptRunning = true
|
||||
|
||||
try {
|
||||
@@ -324,19 +333,15 @@ export class AcpAgent implements Agent {
|
||||
)
|
||||
}
|
||||
|
||||
console.error('[ACP] prompt error:', err)
|
||||
return { stopReason: 'end_turn' }
|
||||
throw err
|
||||
} finally {
|
||||
session.promptRunning = false
|
||||
// Resolve next pending prompt if any
|
||||
if (session.pendingMessages.size > 0) {
|
||||
const next = [...session.pendingMessages.entries()].sort(
|
||||
(a, b) => a[1].order - b[1].order,
|
||||
)[0]
|
||||
if (next) {
|
||||
next[1].resolve(false)
|
||||
session.pendingMessages.delete(next[0])
|
||||
}
|
||||
const nextPrompt = popNextPendingPrompt(session)
|
||||
if (nextPrompt) {
|
||||
session.promptRunning = true
|
||||
nextPrompt.resolve(false)
|
||||
} else {
|
||||
session.promptRunning = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -349,12 +354,15 @@ export class AcpAgent implements Agent {
|
||||
|
||||
// Set cancelled flag — checked by prompt() loop to break out
|
||||
session.cancelled = true
|
||||
session.cancelGeneration += 1
|
||||
|
||||
// Cancel any queued prompts
|
||||
for (const [, pending] of session.pendingMessages) {
|
||||
pending.resolve(true)
|
||||
}
|
||||
session.pendingMessages.clear()
|
||||
session.pendingQueue = []
|
||||
session.pendingQueueHead = 0
|
||||
|
||||
// Interrupt the query engine to abort the current API call
|
||||
session.queryEngine.interrupt()
|
||||
@@ -379,7 +387,7 @@ export class AcpAgent implements Agent {
|
||||
|
||||
async unstable_setSessionModel(
|
||||
params: SetSessionModelRequest,
|
||||
): Promise<SetSessionModelResponse | void> {
|
||||
): Promise<SetSessionModelResponse> {
|
||||
const session = this.sessions.get(params.sessionId)
|
||||
if (!session) {
|
||||
throw new Error('Session not found')
|
||||
@@ -388,6 +396,7 @@ export class AcpAgent implements Agent {
|
||||
// parseUserSpecifiedModel() to resolve aliases (e.g. "sonnet" → "glm-5.1-turbo")
|
||||
session.queryEngine.setModel(params.modelId)
|
||||
await this.updateConfigOption(params.sessionId, 'model', params.modelId)
|
||||
return {}
|
||||
}
|
||||
|
||||
// ── setSessionConfigOption ──────────────────────────────────────
|
||||
@@ -449,23 +458,32 @@ export class AcpAgent implements Agent {
|
||||
|
||||
// Set CWD for the session
|
||||
setOriginalCwd(cwd)
|
||||
const previousProcessCwd = process.cwd()
|
||||
let processCwdChanged = false
|
||||
try {
|
||||
process.chdir(cwd)
|
||||
processCwdChanged = true
|
||||
} catch {
|
||||
// CWD may not exist yet; best-effort
|
||||
}
|
||||
|
||||
try {
|
||||
// Build tools with a permissive permission context.
|
||||
const permissionContext = getEmptyToolPermissionContext()
|
||||
const tools: Tools = getTools(permissionContext)
|
||||
|
||||
// Parse permission mode from _meta (passed by RCS/acp-link) or fall back to settings
|
||||
const metaPermissionMode = (params._meta as Record<string, unknown> | null | undefined)?.permissionMode as string | undefined
|
||||
console.log('[ACP Agent] Session create _meta:', JSON.stringify(params._meta), 'extracted mode:', metaPermissionMode)
|
||||
const permissionMode = resolvePermissionMode(
|
||||
metaPermissionMode ?? this.getSetting<string>('permissions.defaultMode'),
|
||||
// Parse permission mode from _meta (passed by RCS/acp-link) or settings.
|
||||
const meta = params._meta as Record<string, unknown> | null | undefined
|
||||
const hasMetaPermissionMode = hasOwnField(meta, 'permissionMode')
|
||||
const metaPermissionMode = hasMetaPermissionMode
|
||||
? meta?.permissionMode
|
||||
: undefined
|
||||
const settingsPermissionMode = this.getSetting<string>('permissions.defaultMode')
|
||||
const permissionMode = resolveSessionPermissionMode(
|
||||
metaPermissionMode,
|
||||
hasMetaPermissionMode,
|
||||
settingsPermissionMode,
|
||||
)
|
||||
console.log('[ACP Agent] Resolved permissionMode:', permissionMode)
|
||||
|
||||
// Create the permission bridge canUseTool function
|
||||
const canUseTool = createAcpCanUseTool(
|
||||
@@ -475,15 +493,15 @@ export class AcpAgent implements Agent {
|
||||
this.clientCapabilities,
|
||||
cwd,
|
||||
(modeId: string) => { this.applySessionMode(sessionId, modeId) },
|
||||
() => this.sessions.get(sessionId)?.appState
|
||||
.toolPermissionContext.isBypassPermissionsModeAvailable ?? false,
|
||||
)
|
||||
|
||||
// Parse MCP servers from ACP params
|
||||
// MCP server config is handled separately in the tools system
|
||||
|
||||
// Check if bypass permissions is available (not running as root unless in sandbox)
|
||||
const isBypassAvailable =
|
||||
(typeof process.geteuid === 'function' ? process.geteuid() !== 0 : true) ||
|
||||
!!process.env.IS_SANDBOX
|
||||
// ACP clients can expose bypass only when both the process and local config allow it.
|
||||
const isBypassAvailable = isAcpBypassPermissionModeAvailable(settingsPermissionMode)
|
||||
|
||||
// Create a mutable AppState for the session
|
||||
const appState: AppState = {
|
||||
@@ -519,7 +537,7 @@ export class AcpAgent implements Agent {
|
||||
|
||||
const queryEngine = new QueryEngine(engineConfig)
|
||||
|
||||
// Build modes — bypassPermissions only available when not running as root (or in sandbox)
|
||||
// Build modes — bypassPermissions is opt-in for ACP clients.
|
||||
const availableModes = [
|
||||
{ id: 'default', name: 'Default', description: 'Standard behavior, prompts for dangerous operations' },
|
||||
{ id: 'acceptEdits', name: 'Accept Edits', description: 'Auto-accept file edit operations' },
|
||||
@@ -557,13 +575,15 @@ export class AcpAgent implements Agent {
|
||||
const session: AcpSession = {
|
||||
queryEngine,
|
||||
cancelled: false,
|
||||
cancelGeneration: 0,
|
||||
cwd,
|
||||
modes,
|
||||
models,
|
||||
configOptions,
|
||||
promptRunning: false,
|
||||
pendingMessages: new Map(),
|
||||
nextPendingOrder: 0,
|
||||
pendingQueue: [],
|
||||
pendingQueueHead: 0,
|
||||
toolUseCache: {},
|
||||
clientCapabilities: this.clientCapabilities,
|
||||
appState,
|
||||
@@ -576,17 +596,17 @@ export class AcpAgent implements Agent {
|
||||
|
||||
this.sessions.set(sessionId, session)
|
||||
|
||||
// Send available commands after session creation
|
||||
setTimeout(() => {
|
||||
this.sendAvailableCommandsUpdate(sessionId)
|
||||
}, 0)
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
models,
|
||||
modes,
|
||||
configOptions,
|
||||
}
|
||||
} finally {
|
||||
if (processCwdChanged) {
|
||||
process.chdir(previousProcessCwd)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async getOrCreateSession(params: {
|
||||
@@ -672,12 +692,22 @@ export class AcpAgent implements Agent {
|
||||
}
|
||||
|
||||
private applySessionMode(sessionId: string, modeId: string): void {
|
||||
const validModes = ['auto', 'default', 'acceptEdits', 'bypassPermissions', 'dontAsk', 'plan']
|
||||
if (!validModes.includes(modeId)) {
|
||||
if (!isPermissionMode(modeId)) {
|
||||
throw new Error(`Invalid mode: ${modeId}`)
|
||||
}
|
||||
const session = this.sessions.get(sessionId)
|
||||
if (session) {
|
||||
if (
|
||||
modeId === 'bypassPermissions' &&
|
||||
!session.appState.toolPermissionContext.isBypassPermissionsModeAvailable
|
||||
) {
|
||||
throw new Error(`Mode not available: ${modeId}`)
|
||||
}
|
||||
const isAvailable = session.modes.availableModes.some(mode => mode.id === modeId)
|
||||
if (!isAvailable) {
|
||||
throw new Error(`Mode not available: ${modeId}`)
|
||||
}
|
||||
|
||||
session.modes = { ...session.modes, currentModeId: modeId }
|
||||
// Sync mode to appState so the permission pipeline sees the correct mode
|
||||
session.appState.toolPermissionContext = {
|
||||
@@ -750,38 +780,160 @@ export class AcpAgent implements Agent {
|
||||
})
|
||||
}
|
||||
|
||||
private scheduleAvailableCommandsUpdate(sessionId: string): void {
|
||||
setTimeout(() => {
|
||||
void this.sendAvailableCommandsUpdate(sessionId).catch(err => {
|
||||
console.error('[ACP] Failed to send available commands update:', err)
|
||||
})
|
||||
}, 0)
|
||||
}
|
||||
|
||||
/** Read a setting from Claude config (simplified — no file watching) */
|
||||
private getSetting<T>(key: string): T | undefined {
|
||||
// Simplified: read from environment or return undefined
|
||||
// In a full implementation, this would read from settings.json
|
||||
return undefined as T | undefined
|
||||
const settings = getSettings_DEPRECATED() as Record<string, unknown>
|
||||
const value = key.split('.').reduce<unknown>((current, segment) => {
|
||||
if (!current || typeof current !== 'object') return undefined
|
||||
return (current as Record<string, unknown>)[segment]
|
||||
}, settings)
|
||||
return value as T | undefined
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────
|
||||
|
||||
/** Extract prompt text from ACP ContentBlock array for QueryEngine input */
|
||||
function promptToQueryInput(
|
||||
prompt: Array<ContentBlock> | undefined,
|
||||
): string {
|
||||
if (!prompt || prompt.length === 0) return ''
|
||||
const permissionModeIds: readonly PermissionMode[] = [
|
||||
'auto',
|
||||
'default',
|
||||
'acceptEdits',
|
||||
'bypassPermissions',
|
||||
'dontAsk',
|
||||
'plan',
|
||||
]
|
||||
|
||||
const parts: string[] = []
|
||||
for (const block of prompt) {
|
||||
const b = block as Record<string, unknown>
|
||||
if (b.type === 'text') {
|
||||
parts.push(b.text as string)
|
||||
} else if (b.type === 'resource_link') {
|
||||
parts.push(`[${b.name ?? ''}](${b.uri as string})`)
|
||||
} else if (b.type === 'resource') {
|
||||
const resource = b.resource as Record<string, unknown> | undefined
|
||||
if (resource && 'text' in resource) {
|
||||
parts.push(resource.text as string)
|
||||
}
|
||||
function isPermissionMode(modeId: string): modeId is PermissionMode {
|
||||
return (permissionModeIds as readonly string[]).includes(modeId)
|
||||
}
|
||||
|
||||
function resolveSessionPermissionMode(
|
||||
metaMode: unknown,
|
||||
hasMetaMode: boolean,
|
||||
settingsMode: unknown,
|
||||
): PermissionMode {
|
||||
if (hasMetaMode) {
|
||||
const metaResolved = resolveRequiredPermissionMode(
|
||||
metaMode,
|
||||
'_meta.permissionMode',
|
||||
)
|
||||
if (
|
||||
metaResolved === 'bypassPermissions' &&
|
||||
!isAcpBypassPermissionModeAvailable(settingsMode)
|
||||
) {
|
||||
throw new Error(
|
||||
'Mode not available: bypassPermissions requires a local ACP bypass opt-in.',
|
||||
)
|
||||
}
|
||||
// Ignore image and other types for text-based prompt
|
||||
|
||||
return metaResolved
|
||||
}
|
||||
|
||||
const settingsResolved = resolveConfiguredPermissionMode(settingsMode)
|
||||
return settingsResolved ?? 'default'
|
||||
}
|
||||
|
||||
function resolveRequiredPermissionMode(
|
||||
mode: unknown,
|
||||
source: string,
|
||||
): PermissionMode {
|
||||
if (mode === undefined || mode === null) {
|
||||
throw new Error(`Invalid ${source}: expected a string.`)
|
||||
}
|
||||
|
||||
return resolvePermissionMode(mode, source) as PermissionMode
|
||||
}
|
||||
|
||||
function resolveConfiguredPermissionMode(mode: unknown): PermissionMode | undefined {
|
||||
if (mode === undefined || mode === null) return undefined
|
||||
|
||||
try {
|
||||
return resolvePermissionMode(mode, 'permissions.defaultMode') as PermissionMode
|
||||
} catch (err: unknown) {
|
||||
const reason = err instanceof Error ? err.message : String(err)
|
||||
console.error('[ACP] Invalid permissions.defaultMode, using default:', reason)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function hasOwnField(
|
||||
value: Record<string, unknown> | null | undefined,
|
||||
key: string,
|
||||
): boolean {
|
||||
return !!value && Object.hasOwn(value, key)
|
||||
}
|
||||
|
||||
function isAcpBypassPermissionModeAvailable(settingsMode?: unknown): boolean {
|
||||
return (
|
||||
isProcessBypassPermissionModeAvailable() &&
|
||||
(isAcpBypassLocallyEnabled() || isSettingsBypassPermissionMode(settingsMode))
|
||||
)
|
||||
}
|
||||
|
||||
function isProcessBypassPermissionModeAvailable(): boolean {
|
||||
if (process.env.IS_SANDBOX) return true
|
||||
if (typeof process.geteuid === 'function') return process.geteuid() !== 0
|
||||
if (typeof process.getuid === 'function') return process.getuid() !== 0
|
||||
return true
|
||||
}
|
||||
|
||||
function isAcpBypassLocallyEnabled(): boolean {
|
||||
return (
|
||||
process.env.ACP_PERMISSION_MODE === 'bypassPermissions' ||
|
||||
isTruthyEnv(process.env.CLAUDE_CODE_ACP_ALLOW_BYPASS_PERMISSIONS)
|
||||
)
|
||||
}
|
||||
|
||||
function isSettingsBypassPermissionMode(settingsMode: unknown): boolean {
|
||||
try {
|
||||
return resolvePermissionMode(settingsMode) === 'bypassPermissions'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function isTruthyEnv(value: string | undefined): boolean {
|
||||
return value === '1' || value?.toLowerCase() === 'true'
|
||||
}
|
||||
|
||||
function popNextPendingPrompt(session: AcpSession): PendingPrompt | undefined {
|
||||
while (session.pendingQueueHead < session.pendingQueue.length) {
|
||||
const nextId = session.pendingQueue[session.pendingQueueHead++]
|
||||
if (!nextId) continue
|
||||
const next = session.pendingMessages.get(nextId)
|
||||
if (!next) continue
|
||||
session.pendingMessages.delete(nextId)
|
||||
compactPendingQueue(session)
|
||||
return next
|
||||
}
|
||||
|
||||
compactPendingQueue(session)
|
||||
return undefined
|
||||
}
|
||||
|
||||
function compactPendingQueue(session: AcpSession): void {
|
||||
if (session.pendingQueueHead === 0) return
|
||||
|
||||
if (session.pendingQueueHead >= session.pendingQueue.length) {
|
||||
session.pendingQueue = []
|
||||
session.pendingQueueHead = 0
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
session.pendingQueueHead > 1024 &&
|
||||
session.pendingQueueHead * 2 > session.pendingQueue.length
|
||||
) {
|
||||
session.pendingQueue = session.pendingQueue.slice(session.pendingQueueHead)
|
||||
session.pendingQueueHead = 0
|
||||
}
|
||||
return parts.join('\n')
|
||||
}
|
||||
|
||||
function buildConfigOptions(
|
||||
|
||||
@@ -514,28 +514,25 @@ export function toolUpdateFromEditToolResponse(toolResponse: unknown): {
|
||||
return result
|
||||
}
|
||||
|
||||
// ── Prompt conversion ─────────────────────────────────────────────
|
||||
function nextSdkMessageOrAbort(
|
||||
sdkMessages: AsyncGenerator<SDKMessage, void, unknown>,
|
||||
abortSignal: AbortSignal,
|
||||
): Promise<IteratorResult<SDKMessage, void>> {
|
||||
if (abortSignal.aborted) {
|
||||
return Promise.resolve({ done: true, value: undefined })
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert ACP PromptRequest content blocks into content for QueryEngine.
|
||||
*/
|
||||
export function promptToQueryContent(
|
||||
prompt: Array<ContentBlock> | undefined,
|
||||
): string {
|
||||
if (!prompt) return ''
|
||||
return prompt
|
||||
.map((block) => {
|
||||
const b = block as Record<string, unknown>
|
||||
if (b.type === 'text') return b.text as string
|
||||
if (b.type === 'resource_link') return `[${b.name ?? ''}](${b.uri as string})`
|
||||
if (b.type === 'resource') {
|
||||
const resource = b.resource as Record<string, unknown> | undefined
|
||||
if (resource && 'text' in resource) return resource.text as string
|
||||
}
|
||||
return ''
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
let abortHandler: (() => void) | undefined
|
||||
const abortPromise = new Promise<IteratorResult<SDKMessage, void>>((resolve) => {
|
||||
abortHandler = () => resolve({ done: true, value: undefined })
|
||||
abortSignal.addEventListener('abort', abortHandler, { once: true })
|
||||
})
|
||||
|
||||
return Promise.race([sdkMessages.next(), abortPromise]).finally(() => {
|
||||
if (abortHandler) {
|
||||
abortSignal.removeEventListener('abort', abortHandler)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ── Main forwarding function ──────────────────────────────────────
|
||||
@@ -573,17 +570,7 @@ export async function forwardSessionUpdates(
|
||||
// Race the next message against the abort signal so we unblock
|
||||
// immediately when cancelled, even if the generator is waiting for
|
||||
// a slow API response.
|
||||
const nextResult = await Promise.race([
|
||||
sdkMessages.next(),
|
||||
new Promise<IteratorResult<SDKMessage, void>>((resolve) => {
|
||||
if (abortSignal.aborted) {
|
||||
resolve({ done: true, value: undefined })
|
||||
return
|
||||
}
|
||||
const handler = () => resolve({ done: true, value: undefined })
|
||||
abortSignal.addEventListener('abort', handler, { once: true })
|
||||
}),
|
||||
])
|
||||
const nextResult = await nextSdkMessageOrAbort(sdkMessages, abortSignal)
|
||||
if (nextResult.done || abortSignal.aborted) break
|
||||
const msg = nextResult.value
|
||||
|
||||
@@ -1059,12 +1046,7 @@ function toAcpNotifications(
|
||||
}
|
||||
} else {
|
||||
// Regular tool call
|
||||
let rawInput: Record<string, unknown> | undefined
|
||||
try {
|
||||
rawInput = JSON.parse(JSON.stringify(toolInput ?? {}))
|
||||
} catch {
|
||||
// Ignore parse failures
|
||||
}
|
||||
const rawInput = toolInput ? { ...toolInput } : {}
|
||||
|
||||
if (alreadyCached) {
|
||||
// Second encounter — send as tool_call_update
|
||||
|
||||
@@ -25,14 +25,6 @@ import type { AssistantMessage } from '../../types/message.js'
|
||||
import { hasPermissionsToUseTool } from '../../utils/permissions/permissions.js'
|
||||
import { toolInfoFromToolUse } from './bridge.js'
|
||||
|
||||
const IS_ROOT =
|
||||
typeof process.geteuid === 'function'
|
||||
? process.geteuid() === 0
|
||||
: typeof process.getuid === 'function'
|
||||
? process.getuid() === 0
|
||||
: false
|
||||
const ALLOW_BYPASS = !IS_ROOT || !!process.env.IS_SANDBOX
|
||||
|
||||
/**
|
||||
* Creates a CanUseToolFn that delegates permission decisions to the
|
||||
* ACP client via requestPermission().
|
||||
@@ -44,6 +36,7 @@ export function createAcpCanUseTool(
|
||||
clientCapabilities?: ClientCapabilities,
|
||||
cwd?: string,
|
||||
onModeChange?: (modeId: string) => void,
|
||||
isBypassModeAvailable?: () => boolean,
|
||||
): CanUseToolFn {
|
||||
return async (
|
||||
tool: ToolType,
|
||||
@@ -59,6 +52,7 @@ export function createAcpCanUseTool(
|
||||
if (tool.name === 'ExitPlanMode') {
|
||||
return handleExitPlanMode(
|
||||
conn, sessionId, toolUseID, input, supportsTerminalOutput, cwd, onModeChange,
|
||||
isBypassModeAvailable,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -84,8 +78,16 @@ export function createAcpCanUseTool(
|
||||
}
|
||||
// behavior === 'ask' → fall through to client delegation
|
||||
} catch (err) {
|
||||
// If the pipeline fails, fall through to client delegation
|
||||
console.error('[ACP Permissions] Pipeline error, falling back to client:', err)
|
||||
console.error('[ACP Permissions] Pipeline error:', err)
|
||||
return {
|
||||
behavior: 'deny',
|
||||
message: 'Permission pipeline failed',
|
||||
decisionReason: {
|
||||
type: 'other',
|
||||
reason: 'Permission pipeline failed',
|
||||
},
|
||||
toolUseID,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Delegate to ACP client for interactive permission decision ──
|
||||
@@ -144,7 +146,8 @@ export function createAcpCanUseTool(
|
||||
message: 'Permission denied by client',
|
||||
decisionReason: { type: 'mode', mode: 'default' },
|
||||
}
|
||||
} catch {
|
||||
} catch (err) {
|
||||
console.error('[ACP Permissions] Client request error:', err)
|
||||
return {
|
||||
behavior: 'deny',
|
||||
message: 'Permission request failed',
|
||||
@@ -162,6 +165,7 @@ async function handleExitPlanMode(
|
||||
supportsTerminalOutput: boolean,
|
||||
cwd?: string,
|
||||
onModeChange?: (modeId: string) => void,
|
||||
isBypassModeAvailable?: () => boolean,
|
||||
): Promise<PermissionAllowDecision | PermissionDenyDecision> {
|
||||
const options: Array<PermissionOption> = [
|
||||
{ kind: 'allow_always', name: 'Yes, and use "auto" mode', optionId: 'auto' },
|
||||
@@ -169,7 +173,7 @@ async function handleExitPlanMode(
|
||||
{ kind: 'allow_once', name: 'Yes, and manually approve edits', optionId: 'default' },
|
||||
{ kind: 'reject_once', name: 'No, keep planning', optionId: 'plan' },
|
||||
]
|
||||
if (ALLOW_BYPASS) {
|
||||
if (isBypassModeAvailable?.() === true) {
|
||||
options.unshift({
|
||||
kind: 'allow_always',
|
||||
name: 'Yes, and bypass permissions',
|
||||
@@ -211,11 +215,15 @@ async function handleExitPlanMode(
|
||||
response.outcome.optionId !== undefined
|
||||
) {
|
||||
const selectedOption = response.outcome.optionId
|
||||
const isOfferedOption = options.some(option => option.optionId === selectedOption)
|
||||
if (
|
||||
selectedOption === 'default' ||
|
||||
selectedOption === 'acceptEdits' ||
|
||||
selectedOption === 'auto' ||
|
||||
selectedOption === 'bypassPermissions'
|
||||
isOfferedOption &&
|
||||
(
|
||||
selectedOption === 'default' ||
|
||||
selectedOption === 'acceptEdits' ||
|
||||
selectedOption === 'auto' ||
|
||||
selectedOption === 'bypassPermissions'
|
||||
)
|
||||
) {
|
||||
// Sync mode to session state and appState
|
||||
onModeChange?.(selectedOption)
|
||||
|
||||
40
src/services/acp/promptConversion.ts
Normal file
40
src/services/acp/promptConversion.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { ContentBlock } from '@agentclientprotocol/sdk'
|
||||
|
||||
export function promptToQueryInput(
|
||||
prompt: Array<ContentBlock> | undefined,
|
||||
): string {
|
||||
if (!prompt || prompt.length === 0) return ''
|
||||
|
||||
const parts: string[] = []
|
||||
for (const block of prompt) {
|
||||
const b = block as Record<string, unknown>
|
||||
if (b.type === 'text') {
|
||||
parts.push(String(b.text ?? ''))
|
||||
} else if (b.type === 'resource_link') {
|
||||
const name = typeof b.name === 'string' ? b.name : undefined
|
||||
const uri = typeof b.uri === 'string' ? b.uri : undefined
|
||||
// Keep resource links as metadata, not markdown links, so models do not
|
||||
// infer user-visible click targets or silently rewrite URI semantics.
|
||||
parts.push(formatResourceLink(name, uri))
|
||||
} else if (b.type === 'resource') {
|
||||
const resource = b.resource as Record<string, unknown> | undefined
|
||||
if (resource && typeof resource.text === 'string') {
|
||||
parts.push(resource.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return parts.filter(part => part.length > 0).join('\n')
|
||||
}
|
||||
|
||||
function formatResourceLink(
|
||||
name: string | undefined,
|
||||
uri: string | undefined,
|
||||
): string {
|
||||
const details: string[] = []
|
||||
if (name && name.length > 0) details.push(`name=${name}`)
|
||||
if (uri && uri.length > 0) details.push(`uri=${uri}`)
|
||||
return details.length > 0
|
||||
? `Resource link: ${details.join(', ')}`
|
||||
: 'Resource link'
|
||||
}
|
||||
@@ -121,30 +121,31 @@ const PERMISSION_MODE_ALIASES: Record<string, PermissionMode> = {
|
||||
bypass: 'bypassPermissions',
|
||||
}
|
||||
|
||||
export function resolvePermissionMode(defaultMode?: unknown): PermissionMode {
|
||||
export function resolvePermissionMode(
|
||||
defaultMode?: unknown,
|
||||
source = 'permissions.defaultMode',
|
||||
): PermissionMode {
|
||||
if (defaultMode === undefined) {
|
||||
return 'default'
|
||||
}
|
||||
|
||||
if (typeof defaultMode !== 'string') {
|
||||
throw new Error('Invalid permissions.defaultMode: expected a string.')
|
||||
throw new Error(`Invalid ${source}: expected a string.`)
|
||||
}
|
||||
|
||||
const normalized = defaultMode.trim().toLowerCase()
|
||||
if (normalized === '') {
|
||||
throw new Error(
|
||||
'Invalid permissions.defaultMode: expected a non-empty string.',
|
||||
)
|
||||
throw new Error(`Invalid ${source}: expected a non-empty string.`)
|
||||
}
|
||||
|
||||
const mapped = PERMISSION_MODE_ALIASES[normalized]
|
||||
if (!mapped) {
|
||||
throw new Error(`Invalid permissions.defaultMode: ${defaultMode}.`)
|
||||
throw new Error(`Invalid ${source}: ${defaultMode}.`)
|
||||
}
|
||||
|
||||
if (mapped === 'bypassPermissions' && !ALLOW_BYPASS) {
|
||||
throw new Error(
|
||||
'Invalid permissions.defaultMode: bypassPermissions is not available when running as root.',
|
||||
`Invalid ${source}: bypassPermissions is not available when running as root.`,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ type LogEventMetadata = { [key: string]: boolean | number | undefined }
|
||||
const DATADOG_GATE_NAME = 'tengu_log_datadog_events'
|
||||
|
||||
// Module-level gate state - starts undefined, initialized during startup
|
||||
let isDatadogGateEnabled: boolean | undefined = undefined
|
||||
let isDatadogGateEnabled: boolean | undefined
|
||||
|
||||
/**
|
||||
* Check if Datadog tracking is enabled.
|
||||
|
||||
@@ -1544,11 +1544,11 @@ async function* queryModel(
|
||||
let start = Date.now()
|
||||
let attemptNumber = 0
|
||||
const attemptStartTimes: number[] = []
|
||||
let stream: Stream<BetaRawMessageStreamEvent> | undefined = undefined
|
||||
let streamRequestId: string | null | undefined = undefined
|
||||
let clientRequestId: string | undefined = undefined
|
||||
let stream: Stream<BetaRawMessageStreamEvent> | undefined
|
||||
let streamRequestId: string | null | undefined
|
||||
let clientRequestId: string | undefined
|
||||
// eslint-disable-next-line eslint-plugin-n/no-unsupported-features/node-builtins -- Response is available in Node 18+ and is used by the SDK
|
||||
let streamResponse: Response | undefined = undefined
|
||||
let streamResponse: Response | undefined
|
||||
|
||||
// Release all stream resources to prevent native memory leaks.
|
||||
// The Response object holds native TLS/socket buffers that live outside the
|
||||
@@ -1634,7 +1634,7 @@ async function* queryModel(
|
||||
const hasThinking =
|
||||
thinkingConfig.type !== 'disabled' &&
|
||||
!isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_THINKING)
|
||||
let thinking: BetaMessageStreamParams['thinking'] | undefined = undefined
|
||||
let thinking: BetaMessageStreamParams['thinking'] | undefined
|
||||
|
||||
// IMPORTANT: Do not change the adaptive-vs-budget thinking selection below
|
||||
// without notifying the model launch DRI and research. This is a sensitive
|
||||
@@ -1804,7 +1804,7 @@ async function* queryModel(
|
||||
|
||||
const newMessages: AssistantMessage[] = []
|
||||
let ttftMs = 0
|
||||
let partialMessage: BetaMessage | undefined = undefined
|
||||
let partialMessage: BetaMessage | undefined
|
||||
const contentBlocks: (BetaContentBlock | ConnectorTextBlock)[] = []
|
||||
let usage: NonNullableUsage = EMPTY_USAGE
|
||||
let costUSD = 0
|
||||
@@ -1812,8 +1812,8 @@ async function* queryModel(
|
||||
let didFallBackToNonStreaming = false
|
||||
let fallbackMessage: AssistantMessage | undefined
|
||||
let maxOutputTokens = 0
|
||||
let responseHeaders: globalThis.Headers | undefined = undefined
|
||||
let research: unknown = undefined
|
||||
let responseHeaders: globalThis.Headers | undefined
|
||||
let research: unknown
|
||||
let isFastModeRequest = isFastMode // Keep separate state as it may change if falling back
|
||||
let isAdvisorInProgress = false
|
||||
|
||||
|
||||
@@ -113,7 +113,7 @@ async function retryWithBackoff<T>(
|
||||
)
|
||||
|
||||
if (attempt < MAX_RETRIES) {
|
||||
const delayMs = BASE_DELAY_MS * Math.pow(2, attempt - 1)
|
||||
const delayMs = BASE_DELAY_MS * 2 ** (attempt - 1)
|
||||
logDebug(`Retrying ${operation} in ${delayMs}ms...`)
|
||||
await sleep(delayMs)
|
||||
}
|
||||
|
||||
@@ -103,7 +103,7 @@ export async function* queryModelGemini(
|
||||
const adaptedStream = adaptGeminiStreamToAnthropic(stream, geminiModel)
|
||||
const contentBlocks: Record<number, any> = {}
|
||||
const collectedMessages: AssistantMessage[] = []
|
||||
let partialMessage: any = undefined
|
||||
let partialMessage: any
|
||||
let ttftMs = 0
|
||||
const start = Date.now()
|
||||
|
||||
|
||||
@@ -95,7 +95,7 @@ export async function* queryModelGrok(
|
||||
|
||||
const contentBlocks: Record<number, any> = {}
|
||||
const collectedMessages: AssistantMessage[] = []
|
||||
let partialMessage: any = undefined
|
||||
let partialMessage: any
|
||||
let usage = {
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
|
||||
@@ -175,7 +175,7 @@ async function appendSessionLogImpl(
|
||||
return false
|
||||
}
|
||||
|
||||
const delayMs = Math.min(BASE_DELAY_MS * Math.pow(2, attempt - 1), 8000)
|
||||
const delayMs = Math.min(BASE_DELAY_MS * 2 ** (attempt - 1), 8000)
|
||||
logForDebugging(
|
||||
`Remote persistence attempt ${attempt}/${MAX_RETRIES} failed, retrying in ${delayMs}ms…`,
|
||||
)
|
||||
|
||||
@@ -540,7 +540,7 @@ export function getRetryDelay(
|
||||
}
|
||||
|
||||
const baseDelay = Math.min(
|
||||
BASE_DELAY_MS * Math.pow(2, attempt - 1),
|
||||
BASE_DELAY_MS * 2 ** (attempt - 1),
|
||||
maxDelayMs,
|
||||
)
|
||||
const jitter = Math.random() * 0.25 * baseDelay
|
||||
|
||||
@@ -1,12 +1,31 @@
|
||||
import { homedir } from 'os'
|
||||
|
||||
const MAX_OUTPUT_LENGTH = 500
|
||||
const REDACTED_FILE_TOOLS = new Set(['FileReadTool', 'FileWriteTool', 'FileEditTool'])
|
||||
const REDACTED_SHELL_TOOLS = new Set(['BashTool', 'PowerShellTool'])
|
||||
const SENSITIVE_OUTPUT_TOOLS = new Set(['ConfigTool', 'MCPTool'])
|
||||
|
||||
const HOME_DIR_PATTERN = new RegExp(
|
||||
(process.env.HOME ?? '/Users/[^/]+').replace(/[.*+?^${}()|[\]\\]/g, '\\$&'),
|
||||
'g',
|
||||
)
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
}
|
||||
|
||||
function homePathPatterns(): string[] {
|
||||
const homes = new Set<string>()
|
||||
for (const value of [process.env.HOME, process.env.USERPROFILE, homedir()]) {
|
||||
if (value) {
|
||||
homes.add(value)
|
||||
homes.add(value.replace(/\\/g, '/'))
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
...Array.from(homes, escapeRegExp),
|
||||
'/Users/[^/\\\\]+',
|
||||
'[A-Za-z]:[/\\\\]Users[/\\\\][^/\\\\]+',
|
||||
]
|
||||
}
|
||||
|
||||
const HOME_DIR_PATTERN = new RegExp(`(?:${homePathPatterns().join('|')})`, 'g')
|
||||
|
||||
const SENSITIVE_KEY_PATTERN = /(?:api_?key|token|secret|password|credential|auth_header)/i
|
||||
|
||||
|
||||
@@ -387,7 +387,7 @@ export function createLSPServerInstance(
|
||||
isContentModifiedError &&
|
||||
attempt < MAX_RETRIES_FOR_TRANSIENT_ERRORS
|
||||
) {
|
||||
const delay = RETRY_BASE_DELAY_MS * Math.pow(2, attempt)
|
||||
const delay = RETRY_BASE_DELAY_MS * 2 ** attempt
|
||||
logForDebugging(
|
||||
`LSP request '${method}' to '${name}' got ContentModified error, ` +
|
||||
`retrying in ${delay}ms (attempt ${attempt + 1}/${MAX_RETRIES_FOR_TRANSIENT_ERRORS})…`,
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { describe, expect, test, beforeEach, afterEach } from "bun:test";
|
||||
import { expandEnvVarsInString } from "../envExpansion";
|
||||
|
||||
const ENV_OPEN = "$" + "{";
|
||||
const ENV_CLOSE = "}";
|
||||
const envExpr = (value: string): string => `${ENV_OPEN}${value}${ENV_CLOSE}`;
|
||||
|
||||
describe("expandEnvVarsInString", () => {
|
||||
// Save and restore env vars touched by tests
|
||||
const savedEnv: Record<string, string | undefined> = {};
|
||||
@@ -33,21 +37,21 @@ describe("expandEnvVarsInString", () => {
|
||||
|
||||
test("expands a single env var that exists", () => {
|
||||
process.env.TEST_HOME = "/home/user";
|
||||
const result = expandEnvVarsInString("${TEST_HOME}");
|
||||
const result = expandEnvVarsInString(envExpr("TEST_HOME"));
|
||||
expect(result.expanded).toBe("/home/user");
|
||||
expect(result.missingVars).toEqual([]);
|
||||
});
|
||||
|
||||
test("returns original placeholder and tracks missing var when not found", () => {
|
||||
delete process.env.MISSING;
|
||||
const result = expandEnvVarsInString("${MISSING}");
|
||||
expect(result.expanded).toBe("${MISSING}");
|
||||
const result = expandEnvVarsInString(envExpr("MISSING"));
|
||||
expect(result.expanded).toBe(envExpr("MISSING"));
|
||||
expect(result.missingVars).toEqual(["MISSING"]);
|
||||
});
|
||||
|
||||
test("uses default value when var is missing and default is provided", () => {
|
||||
delete process.env.MISSING;
|
||||
const result = expandEnvVarsInString("${MISSING:-fallback}");
|
||||
const result = expandEnvVarsInString(envExpr("MISSING:-fallback"));
|
||||
expect(result.expanded).toBe("fallback");
|
||||
expect(result.missingVars).toEqual([]);
|
||||
});
|
||||
@@ -55,7 +59,9 @@ describe("expandEnvVarsInString", () => {
|
||||
test("expands multiple vars", () => {
|
||||
process.env.TEST_A = "hello";
|
||||
process.env.TEST_B = "world";
|
||||
const result = expandEnvVarsInString("${TEST_A}/${TEST_B}");
|
||||
const result = expandEnvVarsInString(
|
||||
`${envExpr("TEST_A")}/${envExpr("TEST_B")}`,
|
||||
);
|
||||
expect(result.expanded).toBe("hello/world");
|
||||
expect(result.missingVars).toEqual([]);
|
||||
});
|
||||
@@ -63,8 +69,10 @@ describe("expandEnvVarsInString", () => {
|
||||
test("handles mix of found and missing vars", () => {
|
||||
process.env.TEST_FOUND = "yes";
|
||||
delete process.env.MISSING;
|
||||
const result = expandEnvVarsInString("${TEST_FOUND}-${MISSING}");
|
||||
expect(result.expanded).toBe("yes-${MISSING}");
|
||||
const result = expandEnvVarsInString(
|
||||
`${envExpr("TEST_FOUND")}-${envExpr("MISSING")}`,
|
||||
);
|
||||
expect(result.expanded).toBe(`yes-${envExpr("MISSING")}`);
|
||||
expect(result.missingVars).toEqual(["MISSING"]);
|
||||
});
|
||||
|
||||
@@ -76,14 +84,14 @@ describe("expandEnvVarsInString", () => {
|
||||
|
||||
test("expands empty env var value", () => {
|
||||
process.env.TEST_EMPTY = "";
|
||||
const result = expandEnvVarsInString("${TEST_EMPTY}");
|
||||
const result = expandEnvVarsInString(envExpr("TEST_EMPTY"));
|
||||
expect(result.expanded).toBe("");
|
||||
expect(result.missingVars).toEqual([]);
|
||||
});
|
||||
|
||||
test("prefers env var value over default when var exists", () => {
|
||||
process.env.TEST_X = "real";
|
||||
const result = expandEnvVarsInString("${TEST_X:-default}");
|
||||
const result = expandEnvVarsInString(envExpr("TEST_X:-default"));
|
||||
expect(result.expanded).toBe("real");
|
||||
expect(result.missingVars).toEqual([]);
|
||||
});
|
||||
@@ -91,7 +99,7 @@ describe("expandEnvVarsInString", () => {
|
||||
test("handles default value containing colons", () => {
|
||||
// split(':-', 2) means only the first :- is the delimiter
|
||||
delete process.env.TEST_X;
|
||||
const result = expandEnvVarsInString("${TEST_X:-value:-with:-colons}");
|
||||
const result = expandEnvVarsInString(envExpr("TEST_X:-value:-with:-colons"));
|
||||
// The default is "value" because split(':-', 2) gives ["TEST_X", "value"]
|
||||
// Wait -- actually split(':-', 2) on "TEST_X:-value:-with:-colons" gives:
|
||||
// ["TEST_X", "value"] because limit=2 stops at 2 pieces
|
||||
@@ -103,11 +111,12 @@ describe("expandEnvVarsInString", () => {
|
||||
// ${${VAR}} - the regex [^}]+ matches "${VAR" (up to first })
|
||||
// so varName would be "${VAR" which won't be found in env
|
||||
delete process.env.VAR;
|
||||
const result = expandEnvVarsInString("${${VAR}}");
|
||||
const nestedExpr = `${ENV_OPEN}${envExpr("VAR")}${ENV_CLOSE}`;
|
||||
const result = expandEnvVarsInString(nestedExpr);
|
||||
// The regex \$\{([^}]+)\} matches "${${VAR}" with capture "${VAR"
|
||||
// That env var won't exist, so it stays as "${${VAR}" + remaining "}"
|
||||
expect(result.missingVars).toEqual(["${VAR"]);
|
||||
expect(result.expanded).toBe("${${VAR}}");
|
||||
expect(result.missingVars).toEqual([`${ENV_OPEN}VAR`]);
|
||||
expect(result.expanded).toBe(nestedExpr);
|
||||
});
|
||||
|
||||
test("handles empty string input", () => {
|
||||
@@ -118,14 +127,14 @@ describe("expandEnvVarsInString", () => {
|
||||
|
||||
test("handles var surrounded by text", () => {
|
||||
process.env.TEST_A = "middle";
|
||||
const result = expandEnvVarsInString("before-${TEST_A}-after");
|
||||
const result = expandEnvVarsInString(`before-${envExpr("TEST_A")}-after`);
|
||||
expect(result.expanded).toBe("before-middle-after");
|
||||
expect(result.missingVars).toEqual([]);
|
||||
});
|
||||
|
||||
test("handles default value that is empty string", () => {
|
||||
delete process.env.MISSING;
|
||||
const result = expandEnvVarsInString("${MISSING:-}");
|
||||
const result = expandEnvVarsInString(envExpr("MISSING:-"));
|
||||
expect(result.expanded).toBe("");
|
||||
expect(result.missingVars).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -2346,7 +2346,7 @@ export class ClaudeAuthProvider implements OAuthClientProvider {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const delayMs = 1000 * Math.pow(2, attempt - 1) // 1s, 2s, 4s
|
||||
const delayMs = 1000 * 2 ** (attempt - 1) // 1s, 2s, 4s
|
||||
logMCPDebug(
|
||||
this.serverName,
|
||||
`Token refresh failed, retrying in ${delayMs}ms (attempt ${attempt}/${MAX_ATTEMPTS})`,
|
||||
|
||||
@@ -57,8 +57,6 @@ export async function findAvailablePort(): Promise<number> {
|
||||
})
|
||||
return port
|
||||
} catch {
|
||||
// Port in use, try another random port
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ type RegistryResponse = {
|
||||
|
||||
// URLs stripped of query string and trailing slash — matches the normalization
|
||||
// done by getLoggingSafeMcpBaseUrl so direct Set.has() lookup works.
|
||||
let officialUrls: Set<string> | undefined = undefined
|
||||
let officialUrls: Set<string> | undefined
|
||||
|
||||
function normalizeUrl(url: string): string | undefined {
|
||||
try {
|
||||
|
||||
@@ -445,7 +445,7 @@ export function useManageMCPConnections(
|
||||
|
||||
// Schedule next retry with exponential backoff
|
||||
const backoffMs = Math.min(
|
||||
INITIAL_BACKOFF_MS * Math.pow(2, attempt - 1),
|
||||
INITIAL_BACKOFF_MS * 2 ** (attempt - 1),
|
||||
MAX_BACKOFF_MS,
|
||||
)
|
||||
logMCPDebug(
|
||||
|
||||
@@ -353,7 +353,6 @@ export async function installPluginOp(
|
||||
}
|
||||
} catch (error) {
|
||||
logError(toError(error))
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -310,7 +310,6 @@ export async function countTokensViaHaikuFallback(
|
||||
? betas.filter(b => VERTEX_COUNT_TOKENS_ALLOWED_BETAS.has(b))
|
||||
: betas
|
||||
|
||||
// biome-ignore lint/plugin: token counting needs specialized parameters (thinking, betas) that sideQuery doesn't support
|
||||
const apiStart = Date.now()
|
||||
const langfuseTrace = isLangfuseEnabled()
|
||||
? createTrace({
|
||||
|
||||
@@ -98,7 +98,6 @@ export function AppStateProvider({
|
||||
),
|
||||
}))
|
||||
}
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: intentional mount-only effect
|
||||
}, [])
|
||||
|
||||
// Listen for external settings changes and sync to AppState.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
const sliceAnsi = (await import("../sliceAnsi")).default;
|
||||
const ESC = "\x1b";
|
||||
|
||||
describe("sliceAnsi", () => {
|
||||
test("plain text slice identical to String.slice", () => {
|
||||
@@ -29,7 +30,7 @@ describe("sliceAnsi", () => {
|
||||
expect(result).toContain("\x1b[31m");
|
||||
expect(result).toContain("hello");
|
||||
// undoAnsiCodes uses specific close codes (e.g. \x1b[39m for foreground)
|
||||
expect(result).toMatch(new RegExp("\\x1b\\[\\d+m"));
|
||||
expect(result).toMatch(new RegExp(`${ESC}\\[\\d+m`));
|
||||
// The result should start with open code and end with a close code
|
||||
const withoutText = result.replace("hello", "");
|
||||
// Should have at least one open and one close code
|
||||
@@ -80,6 +81,6 @@ describe("sliceAnsi", () => {
|
||||
// undoAnsiCodes uses \x1b[39m for foreground reset, not \x1b[0m
|
||||
expect(result).toContain("b");
|
||||
expect(result).toContain("\x1b[31m");
|
||||
expect(result).toMatch(new RegExp("\\x1b\\[\\d+m.*\\x1b\\[\\d+m")); // open + close codes
|
||||
expect(result).toMatch(new RegExp(`${ESC}\\[\\d+m.*${ESC}\\[\\d+m`)); // open + close codes
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,7 +22,8 @@ describe("escapeRegExp", () => {
|
||||
});
|
||||
|
||||
test("escapes all special chars", () => {
|
||||
expect(escapeRegExp("^${}()|[]\\.*+?")).toBe(
|
||||
const allSpecialChars = "^$" + "{}()|[]\\.*+?";
|
||||
expect(escapeRegExp(allSpecialChars)).toBe(
|
||||
"\\^\\$\\{\\}\\(\\)\\|\\[\\]\\\\\\.\\*\\+\\?"
|
||||
);
|
||||
});
|
||||
|
||||
@@ -172,10 +172,10 @@ function fillBackground(px: Uint8Array, bg: AnsiColor): void {
|
||||
// not the classic VGA dither pattern. Alpha-blend toward background for the
|
||||
// same look.
|
||||
const SHADE_ALPHA: Record<number, number> = {
|
||||
0x2591: 0.25, // ░
|
||||
0x2592: 0.5, // ▒
|
||||
0x2593: 0.75, // ▓
|
||||
0x2588: 1.0, // █
|
||||
9617: 0.25, // ░
|
||||
9618: 0.5, // ▒
|
||||
9619: 0.75, // ▓
|
||||
9608: 1.0, // █
|
||||
}
|
||||
|
||||
function blitShade(
|
||||
|
||||
@@ -721,7 +721,6 @@ function collectCommands(
|
||||
child.type === 'select' ||
|
||||
child.type === ';'
|
||||
) {
|
||||
continue // structural tokens
|
||||
} else if (child.type === 'command_substitution') {
|
||||
// `for i in $(seq 1 3)` — inner cmd IS extracted and rule-checked.
|
||||
const err = collectCommandSubstitution(child, commands, varScope)
|
||||
@@ -1793,7 +1792,6 @@ function walkVariableAssignment(
|
||||
// node. Without this case it falls through to walkArgument below
|
||||
// → tooComplex on unknown type `+=`.
|
||||
isAppend = child.type === '+='
|
||||
continue
|
||||
} else if (child.type === 'command_substitution') {
|
||||
// $() as the variable's value. The output becomes a STRING stored in
|
||||
// the variable — it's NOT a positional argument (no path/flag concern).
|
||||
|
||||
@@ -853,15 +853,11 @@ function parseStatements(P: ParseState, terminator: string | null): TsNode[] {
|
||||
after.value === 'done' ||
|
||||
after.value === 'esac'))
|
||||
) {
|
||||
// Trailing separator — don't include it at program level unless
|
||||
// there's content after. But at inner levels we keep it.
|
||||
continue
|
||||
}
|
||||
} else if (sep.type === 'NEWLINE') {
|
||||
if (P.L.heredocs.length > 0) {
|
||||
scanHeredocBodies(P)
|
||||
}
|
||||
continue
|
||||
} else {
|
||||
restoreLex(P.L, save2)
|
||||
}
|
||||
|
||||
@@ -257,7 +257,6 @@ export function hasShellQuoteSingleQuoteBug(command: string): boolean {
|
||||
return true
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ export function shouldEnableClaudeInChrome(chromeFlag?: boolean): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
let shouldAutoEnable: boolean | undefined = undefined
|
||||
let shouldAutoEnable: boolean | undefined
|
||||
|
||||
export function shouldAutoEnableClaudeInChrome(): boolean {
|
||||
if (shouldAutoEnable !== undefined) {
|
||||
|
||||
@@ -276,7 +276,7 @@ async function animatedMove(
|
||||
const totalFrames = Math.floor(durationSec * frameRate)
|
||||
for (let frame = 1; frame <= totalFrames; frame++) {
|
||||
const t = frame / totalFrames
|
||||
const eased = 1 - Math.pow(1 - t, 3)
|
||||
const eased = 1 - (1 - t) ** 3
|
||||
await input.moveMouse(
|
||||
Math.round(start.x + deltaX * eased),
|
||||
Math.round(start.y + deltaY * eased),
|
||||
|
||||
@@ -42,7 +42,6 @@ export async function handleDeepLinkUri(uri: string): Promise<number> {
|
||||
action = parseDeepLink(uri)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
// biome-ignore lint/suspicious/noConsole: intentional error output
|
||||
console.error(`Deep link error: ${message}`)
|
||||
return 1
|
||||
}
|
||||
@@ -65,7 +64,6 @@ export async function handleDeepLinkUri(uri: string): Promise<number> {
|
||||
lastFetchMs: lastFetch?.getTime(),
|
||||
})
|
||||
if (!launched) {
|
||||
// biome-ignore lint/suspicious/noConsole: intentional error output
|
||||
console.error(
|
||||
'Failed to open a terminal. Make sure a supported terminal emulator is installed.',
|
||||
)
|
||||
|
||||
@@ -454,7 +454,7 @@ export const NodeFsOperations: FsOperations = {
|
||||
|
||||
readSync(fsPath, options) {
|
||||
using _ = slowLogging`fs.readSync(${fsPath}, ${options.length} bytes)`
|
||||
let fd: number | undefined = undefined
|
||||
let fd: number | undefined
|
||||
try {
|
||||
fd = fs.openSync(fsPath, 'r')
|
||||
const buffer = Buffer.alloc(options.length)
|
||||
|
||||
@@ -1235,7 +1235,6 @@ async function execCommandHook(
|
||||
child.stdin.destroy()
|
||||
}
|
||||
})
|
||||
continue
|
||||
}
|
||||
} catch {
|
||||
// Not JSON, just a normal line
|
||||
@@ -1750,7 +1749,7 @@ export async function getMatchingHooks(
|
||||
|
||||
// If you change the criteria below, then you must change
|
||||
// src/utils/hooks/hooksConfigManager.ts as well.
|
||||
let matchQuery: string | undefined = undefined
|
||||
let matchQuery: string | undefined
|
||||
switch (hookInput.hook_event_name) {
|
||||
case 'PreToolUse':
|
||||
case 'PostToolUse':
|
||||
|
||||
@@ -121,8 +121,6 @@ async function detectPluginDirectories(ideName: string): Promise<string[]> {
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors from stale IDE directories (ENOENT, EACCES, etc.)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -958,7 +958,6 @@ export function reorderMessagesInUI(
|
||||
})
|
||||
}
|
||||
toolUseGroups.get(toolUseID)!.postHooks.push(message)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2293,7 +2292,6 @@ export function normalizeMessagesForAPI(
|
||||
result[i] = mergeAssistantMessages(msg, normalizedMessage)
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -224,7 +224,7 @@ async function tryWithVersionLock(
|
||||
if (attempts < maxAttempts) {
|
||||
// Wait before retrying with exponential backoff
|
||||
const timeout = Math.min(
|
||||
minTimeout * Math.pow(2, attempts - 1),
|
||||
minTimeout * 2 ** (attempts - 1),
|
||||
maxTimeout,
|
||||
)
|
||||
await sleep(timeout)
|
||||
|
||||
@@ -1188,7 +1188,7 @@ export async function migrateFromEnabledPlugins(): Promise<void> {
|
||||
|
||||
let installPath: string
|
||||
let version = 'unknown'
|
||||
let gitCommitSha: string | undefined = undefined
|
||||
let gitCommitSha: string | undefined
|
||||
|
||||
if (typeof entry.source === 'string') {
|
||||
installPath = join(marketplaceInstallLocation, entry.source)
|
||||
|
||||
@@ -66,7 +66,7 @@ export const RETRY_CONFIG = {
|
||||
function calculateNextRetryDelay(retryCount: number): number {
|
||||
const delay =
|
||||
RETRY_CONFIG.INITIAL_DELAY_MS *
|
||||
Math.pow(RETRY_CONFIG.BACKOFF_MULTIPLIER, retryCount)
|
||||
RETRY_CONFIG.BACKOFF_MULTIPLIER ** retryCount
|
||||
return Math.min(delay, RETRY_CONFIG.MAX_DELAY_MS)
|
||||
}
|
||||
|
||||
|
||||
@@ -518,7 +518,7 @@ export const countFilesRoundedRg = memoize(
|
||||
if (count === 0) return 0
|
||||
|
||||
const magnitude = Math.floor(Math.log10(count))
|
||||
const power = Math.pow(10, magnitude)
|
||||
const power = 10 ** magnitude
|
||||
|
||||
// Round to nearest power of 10
|
||||
// e.g., 8 -> 10, 42 -> 100, 350 -> 100, 750 -> 1000
|
||||
|
||||
@@ -10,7 +10,7 @@ import { getPlatform } from './platform.js'
|
||||
// undefined = not yet loaded (need to check disk)
|
||||
// null = checked disk, no files exist (don't check again)
|
||||
// string = loaded and cached (use cached value)
|
||||
let sessionEnvScript: string | null | undefined = undefined
|
||||
let sessionEnvScript: string | null | undefined
|
||||
|
||||
export async function getSessionEnvDirPath(): Promise<string> {
|
||||
const sessionEnvDir = join(
|
||||
|
||||
@@ -4901,7 +4901,6 @@ function extractFirstPromptFromChunk(chunk: string): string {
|
||||
return result
|
||||
}
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
// Session started with a slash command but had no subsequent real message —
|
||||
|
||||
@@ -194,7 +194,6 @@ export function extractFirstPromptFromHead(head: string): string {
|
||||
return result
|
||||
}
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if (commandFallback) return commandFallback
|
||||
|
||||
@@ -152,9 +152,7 @@ function slowLoggingExternal(): Disposable {
|
||||
* using _ = slowLogging`structuredClone(${value})`
|
||||
* const result = structuredClone(value)
|
||||
*/
|
||||
export const slowLogging: {
|
||||
(strings: TemplateStringsArray, ...values: unknown[]): Disposable
|
||||
} = feature('SLOW_OPERATION_LOGGING') ? slowLoggingAnt : slowLoggingExternal
|
||||
export const slowLogging: (strings: TemplateStringsArray, ...values: unknown[]) => Disposable = feature('SLOW_OPERATION_LOGGING') ? slowLoggingAnt : slowLoggingExternal
|
||||
|
||||
// --- Wrapped operations ---
|
||||
|
||||
|
||||
@@ -54,42 +54,37 @@ function extractFirstFrame(output: string): string {
|
||||
/**
|
||||
* Renders a React node to a string with ANSI escape codes (for terminal output).
|
||||
*/
|
||||
export function renderToAnsiString(
|
||||
export async function renderToAnsiString(
|
||||
node: React.ReactNode,
|
||||
columns?: number,
|
||||
): Promise<string> {
|
||||
return new Promise(async resolve => {
|
||||
let output = ''
|
||||
let output = ''
|
||||
|
||||
// Capture all writes. Set .columns so Ink (ink.tsx:~165) picks up a
|
||||
// chosen width instead of PassThrough's undefined → 80 fallback —
|
||||
// useful for rendering at terminal width for file dumps that should
|
||||
// match what the user sees on screen.
|
||||
const stream = new PassThrough()
|
||||
if (columns !== undefined) {
|
||||
;(stream as unknown as { columns: number }).columns = columns
|
||||
}
|
||||
stream.on('data', chunk => {
|
||||
output += chunk.toString()
|
||||
})
|
||||
|
||||
// Render the component wrapped in RenderOnceAndExit
|
||||
// Non-TTY stdout (PassThrough) gives full-frame output instead of diffs
|
||||
const instance = await render(
|
||||
<RenderOnceAndExit>{node}</RenderOnceAndExit>,
|
||||
{
|
||||
stdout: stream as unknown as NodeJS.WriteStream,
|
||||
patchConsole: false,
|
||||
},
|
||||
)
|
||||
|
||||
// Wait for the component to exit naturally
|
||||
await instance.waitUntilExit()
|
||||
|
||||
// Extract only the first frame's content to avoid duplication
|
||||
// (Ink outputs multiple frames in non-TTY mode)
|
||||
await resolve(extractFirstFrame(output))
|
||||
// Capture all writes. Set .columns so Ink (ink.tsx:~165) picks up a
|
||||
// chosen width instead of PassThrough's undefined → 80 fallback —
|
||||
// useful for rendering at terminal width for file dumps that should
|
||||
// match what the user sees on screen.
|
||||
const stream = new PassThrough()
|
||||
if (columns !== undefined) {
|
||||
;(stream as unknown as { columns: number }).columns = columns
|
||||
}
|
||||
stream.on('data', chunk => {
|
||||
output += chunk.toString()
|
||||
})
|
||||
|
||||
// Render the component wrapped in RenderOnceAndExit
|
||||
// Non-TTY stdout (PassThrough) gives full-frame output instead of diffs
|
||||
const instance = await render(<RenderOnceAndExit>{node}</RenderOnceAndExit>, {
|
||||
stdout: stream as unknown as NodeJS.WriteStream,
|
||||
patchConsole: false,
|
||||
})
|
||||
|
||||
// Wait for the component to exit naturally
|
||||
await instance.waitUntilExit()
|
||||
|
||||
// Extract only the first frame's content to avoid duplication
|
||||
// (Ink outputs multiple frames in non-TTY mode)
|
||||
return extractFirstFrame(output)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -48,7 +48,7 @@ export function getSkillUsageScore(skillName: string): number {
|
||||
|
||||
// Recency decay: halve score every 7 days
|
||||
const daysSinceUse = (Date.now() - usage.lastUsedAt) / (1000 * 60 * 60 * 24)
|
||||
const recencyFactor = Math.pow(0.5, daysSinceUse / 7)
|
||||
const recencyFactor = 0.5 ** (daysSinceUse / 7)
|
||||
|
||||
// Minimum recency factor of 0.1 to avoid completely dropping old but heavily used skills
|
||||
return usage.usageCount * Math.max(recencyFactor, 0.1)
|
||||
|
||||
Reference in New Issue
Block a user