diff --git a/.changeset/acp-auth-gate-process-env-key.md b/.changeset/acp-auth-gate-process-env-key.md new file mode 100644 index 0000000000..6f709d0682 --- /dev/null +++ b/.changeset/acp-auth-gate-process-env-key.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix `kimi -p` refusing to start when the provider key comes only from the environment: the auth readiness gate now falls back to `process.env` through the provider's declared `apiKeyEnv`, with explicit `config.toml` credentials (inline `apiKey`, `[providers..env]`, oauth) still taking precedence. diff --git a/.changeset/acp-internal-turn-updates.md b/.changeset/acp-internal-turn-updates.md new file mode 100644 index 0000000000..3b87e11b00 --- /dev/null +++ b/.changeset/acp-internal-turn-updates.md @@ -0,0 +1,5 @@ +--- +'@moonshot-ai/kimi-code': patch +--- + +Forward internally-triggered turns (cron, background-task notifications) as ACP session/update notifications even when no client prompt is in flight. diff --git a/.changeset/acp-mode-from-engine.md b/.changeset/acp-mode-from-engine.md new file mode 100644 index 0000000000..bf1a874904 --- /dev/null +++ b/.changeset/acp-mode-from-engine.md @@ -0,0 +1,5 @@ +--- +'@moonshot-ai/kimi-code': patch +--- + +Report the engine's actual permission mode (and plan state) as the ACP current mode instead of a hardcoded `default`. diff --git a/.changeset/bash-subcommand-permission-rules.md b/.changeset/bash-subcommand-permission-rules.md new file mode 100644 index 0000000000..62f28dc69d --- /dev/null +++ b/.changeset/bash-subcommand-permission-rules.md @@ -0,0 +1,6 @@ +--- +'@moonshot-ai/agent-core-v2': patch +'@moonshot-ai/agent-core': patch +--- + +Evaluate Bash permission rules per sub-command (tree-sitter-bash decomposition) so compound commands cannot over-grant allows or bypass denies. diff --git a/.changeset/block-turns-during-compaction.md b/.changeset/block-turns-during-compaction.md new file mode 100644 index 0000000000..fae8f3d5f3 --- /dev/null +++ b/.changeset/block-turns-during-compaction.md @@ -0,0 +1,5 @@ +--- +'@moonshot-ai/agent-core-v2': patch +--- + +Block turns while auto compaction is in flight so a commit-time history-safety race can no longer cancel the compaction and kill the user's turn. diff --git a/.changeset/context-overflow-401-classification.md b/.changeset/context-overflow-401-classification.md new file mode 100644 index 0000000000..afcb391139 --- /dev/null +++ b/.changeset/context-overflow-401-classification.md @@ -0,0 +1,6 @@ +--- +'@moonshot-ai/agent-core-v2': patch +'@moonshot-ai/agent-core': patch +--- + +Classify context-limit 401 responses ("supports only N context") as context overflow rather than auth, so no pointless token refresh is forced and ACP clients see the provider message instead of "Authentication required". diff --git a/.changeset/external-hooks-config-reload.md b/.changeset/external-hooks-config-reload.md new file mode 100644 index 0000000000..c71f9ae77b --- /dev/null +++ b/.changeset/external-hooks-config-reload.md @@ -0,0 +1,5 @@ +--- +'@moonshot-ai/agent-core-v2': patch +--- + +Rebuild the external-hooks index when [[hooks]] arrive through a config change so late hooks fire and the heartbeat arms. diff --git a/.changeset/glob-slash-crossing.md b/.changeset/glob-slash-crossing.md new file mode 100644 index 0000000000..562a5f2477 --- /dev/null +++ b/.changeset/glob-slash-crossing.md @@ -0,0 +1,6 @@ +--- +'@moonshot-ai/agent-core-v2': patch +'@moonshot-ai/agent-core': patch +--- + +Match permission-rule glob subjects as opaque text (so `*` crosses `/` for commands, URLs, and search subjects) while path subjects keep path semantics. diff --git a/.changeset/non-tty-upgrade.md b/.changeset/non-tty-upgrade.md new file mode 100644 index 0000000000..8b4e7c75e7 --- /dev/null +++ b/.changeset/non-tty-upgrade.md @@ -0,0 +1,5 @@ +--- +'@moonshot-ai/kimi-code': patch +--- + +`kimi upgrade` in a non-TTY environment now runs the automatic install and reports failure with a non-zero exit instead of silently exiting 0. diff --git a/.changeset/prompt-plan-mode.md b/.changeset/prompt-plan-mode.md new file mode 100644 index 0000000000..edd2347850 --- /dev/null +++ b/.changeset/prompt-plan-mode.md @@ -0,0 +1,5 @@ +--- +'@moonshot-ai/kap-server': patch +--- + +Apply plan_mode from the prompt submission body so a new web session's first message actually enters plan mode. diff --git a/.changeset/select-tools-always-registered.md b/.changeset/select-tools-always-registered.md new file mode 100644 index 0000000000..afc95ed76d --- /dev/null +++ b/.changeset/select-tools-always-registered.md @@ -0,0 +1,5 @@ +--- +'@moonshot-ai/agent-core-v2': patch +--- + +Always register the select_tools disclosure tool so the announced MCP selector is actually callable under an allowlist-bound profile. diff --git a/.changeset/session-agent-profile-bind.md b/.changeset/session-agent-profile-bind.md new file mode 100644 index 0000000000..3afd5f0eb7 --- /dev/null +++ b/.changeset/session-agent-profile-bind.md @@ -0,0 +1,6 @@ +--- +'@moonshot-ai/kimi-code-sdk': patch +'@moonshot-ai/kimi-code': patch +--- + +Bind the requested --agent/--agent-file profile on interactive session create and thread agentFiles through the harness so the profile's tools apply. diff --git a/.changeset/stream-stall-timeout.md b/.changeset/stream-stall-timeout.md new file mode 100644 index 0000000000..03078acfa4 --- /dev/null +++ b/.changeset/stream-stall-timeout.md @@ -0,0 +1,5 @@ +--- +'@moonshot-ai/agent-core-v2': patch +--- + +Add a streaming idle/stall timeout (KIMI_CODE_STREAM_STALL_TIMEOUT_MS, default 300s) to the model-request path so a stalled provider stream aborts like a cancel and persists the partial content. diff --git a/.changeset/tui-wire-staleness-guard.md b/.changeset/tui-wire-staleness-guard.md new file mode 100644 index 0000000000..501340702e --- /dev/null +++ b/.changeset/tui-wire-staleness-guard.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +TUI: stop serving a stale in-memory transcript after an external client (ACP attach, mobile) appends turns to the session's wire journal. The send guard now compares the journal's newest user turn against the last turn the TUI rendered and refuses input — with a reload hint — when an unseen external turn would fork the session into two divergent histories. Re-selecting the current session from `/sessions` and `/reload` now re-read the journal from disk and re-hydrate the transcript instead of short-circuiting on the stale view. diff --git a/.changeset/wire-flush-before-dispose.md b/.changeset/wire-flush-before-dispose.md new file mode 100644 index 0000000000..b594171b89 --- /dev/null +++ b/.changeset/wire-flush-before-dispose.md @@ -0,0 +1,5 @@ +--- +'@moonshot-ai/agent-core-v2': patch +--- + +Flush the replayable wire before disposing an agent scope on session close so the tail assistant records are never dropped. diff --git a/apps/kimi-code/src/cli/run-shell.ts b/apps/kimi-code/src/cli/run-shell.ts index ceade4c813..81eb5cdb08 100644 --- a/apps/kimi-code/src/cli/run-shell.ts +++ b/apps/kimi-code/src/cli/run-shell.ts @@ -69,6 +69,11 @@ export async function runShell( homeDir: telemetryBootstrap.homeDir, identity: createKimiCodeHostIdentity(version), skillDirs: opts.skillsDirs, + // `--agent-file` registers the file for this launch; the harness is built + // before any session exists, and the v2 engine reads explicit agent files + // from its own bootstrap arguments, so the registration has to happen here. + // Selecting the profile it defines stays per-session (`agentProfile`). + agentFiles: opts.agentFiles, telemetry: telemetryClient, onOAuthRefresh: (outcome) => { if (outcome.success) { diff --git a/apps/kimi-code/src/cli/sub/upgrade.ts b/apps/kimi-code/src/cli/sub/upgrade.ts index c547106457..7e2284c94e 100644 --- a/apps/kimi-code/src/cli/sub/upgrade.ts +++ b/apps/kimi-code/src/cli/sub/upgrade.ts @@ -20,6 +20,7 @@ import { NPM_PACKAGE_NAME, type InstallSource, type UpdateCache, + type UpdateTarget, } from '#/cli/update/types'; interface WritableLike { @@ -86,7 +87,36 @@ export async function handleUpgrade( const source = await deps.detectInstallSource().catch(() => 'unsupported' as const); const installCommand = installCommandFor(source, target.version, deps.platform); - if (!canAutoInstall(source, deps.platform) || !deps.isInteractive) { + if (!deps.isInteractive) { + if (!canAutoInstall(source, deps.platform)) { + // Non-interactive session with no automatic install path: report the + // no-op with a non-zero exit code so automation can detect that the + // upgrade did not happen, and put the manual instructions on stderr. + trackUpgradeEvent(deps.track, 'upgrade_command_manual_command', { + current_version: currentVersion, + target_version: target.version, + source, + }); + logUpgradeWarn(deps.logger, 'manual upgrade unavailable in non-interactive session', { + currentVersion, + targetVersion: target.version, + source, + }); + deps.stderr.write(renderManualUpdateMessage(currentVersion, target, source, installCommand)); + return 1; + } + // Non-interactive but auto-install is supported: run the update like the + // automatic update path instead of printing instructions and exiting 0. + return runForegroundInstall( + deps, + currentVersion, + target, + source, + 'upgrade_command_auto_install_started', + ); + } + + if (!canAutoInstall(source, deps.platform)) { trackUpgradeEvent(deps.track, 'upgrade_command_manual_command', { current_version: currentVersion, target_version: target.version, @@ -131,8 +161,24 @@ export async function handleUpgrade( return 0; } + return runForegroundInstall( + deps, + currentVersion, + target, + source, + 'upgrade_command_install_selected', + ); +} + +async function runForegroundInstall( + deps: UpgradeDeps, + currentVersion: string, + target: UpdateTarget, + source: InstallSource, + startedEvent: string, +): Promise { try { - trackUpgradeEvent(deps.track, 'upgrade_command_install_selected', { + trackUpgradeEvent(deps.track, startedEvent, { current_version: currentVersion, target_version: target.version, source, diff --git a/apps/kimi-code/src/tui/controllers/session-event-handler.ts b/apps/kimi-code/src/tui/controllers/session-event-handler.ts index 4448e4c341..b3563403bb 100644 --- a/apps/kimi-code/src/tui/controllers/session-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -124,6 +124,8 @@ export interface SessionEventHost { handleShellOutput(event: { commandId: string; update: { kind: string; text?: string } }): void; handleShellStarted(event: { commandId: string; taskId: string }): void; sendNormalUserInput(text: string): void; + /** Re-read the active session's wire journal into the staleness baseline. */ + refreshWireTipFromDisk(): void; updateTerminalTitle(): void; sendQueuedMessage(session: Session, item: QueuedMessage): void; shiftQueuedMessage(): QueuedMessage | undefined; @@ -406,6 +408,9 @@ export class SessionEventHandler { } this.pluginMcpToolsUsedInTurn.clear(); this.scheduleQueuedGoalPromotion(); + // The turn's journal records are now on disk; advance the staleness + // baseline so the next input is not mistaken for an external append. + this.host.refreshWireTipFromDisk(); } private handleStepBegin(event: TurnStepStartedEvent): void { diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 5bb4ee6b60..ad1ff56961 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -181,6 +181,10 @@ import { groupTurns, turnsToTrim, } from './utils/transcript-window'; +import { + readWireTurnBoundaryTime, + wireTailAheadOfTranscript, +} from './utils/wire-staleness'; export type { TUIState } from './tui-state'; export { createTUIState } from './tui-state'; @@ -365,6 +369,16 @@ export class KimiTUI { private currentLoadingTip: { kind: LoadingTipKind; tip: string | undefined } | undefined = undefined; private lastHistoryContent: string | undefined; + /** + * The newest user-turn timestamp this TUI has observed on the active + * session's wire journal (seeded at session switch, advanced after each turn + * ends). The send guard compares the live journal against this to detect + * turns appended by external clients. `undefined` until a session exposes a + * readable journal (fresh sessions start undefined and stay fail-open). + */ + private wireTipTime: number | undefined; + /** In-flight {@link refreshWireTipFromDisk} read, awaited by the send guard. */ + private wireTipRefresh: Promise | null = null; // Live `!` shell output entries, keyed by commandId so concurrent commands // each update their own card and stale events are dropped. Mutated in place // as `shell.output` events arrive; removed when the command completes. @@ -1318,6 +1332,56 @@ export class KimiTUI { this.updateQueueDisplay(); } + /** + * Re-read the active session's newest wire user-turn into {@link wireTipTime}. + * Failures keep the last observed tip so the guard stays fail-open. + */ + refreshWireTipFromDisk(): void { + const sessionDir = this.session?.summary?.sessionDir; + if (sessionDir === undefined) return; + const task = readWireTurnBoundaryTime(sessionDir, MAIN_AGENT_ID) + .then((time) => { + this.wireTipTime = time; + }) + .catch(() => { + // Keep the last observed tip; the guard fails open when unreadable. + }); + this.wireTipRefresh = task; + void task.finally(() => { + if (this.wireTipRefresh === task) this.wireTipRefresh = null; + }); + } + + /** + * Block a prompt when an external client has appended a turn to the session's + * wire journal after this TUI last rendered one. Sending anyway would fork + * the session into two divergent histories, so the user is told to reload + * instead. Only meaningful while the session is idle — in-flight input is + * queued by the caller and must not be gated on the live journal. + */ + private async assertWireFresh(session: Session): Promise { + if (this.state.appState.streamingPhase !== 'idle') return false; + // A tip refresh kicked off by the last turn may still be in flight; await + // it so the comparison never sees a stale baseline and false-positives. + if (this.wireTipRefresh !== null) { + await this.wireTipRefresh.catch(() => undefined); + } + const sessionDir = session.summary?.sessionDir; + if (sessionDir === undefined) return false; + const wireTailTime = await readWireTurnBoundaryTime(sessionDir, MAIN_AGENT_ID).catch( + () => undefined, + ); + if (!wireTailAheadOfTranscript({ transcriptTipTime: this.wireTipTime, wireTailTime })) { + return false; + } + this.showStatus( + 'Session was modified outside this terminal; your transcript is out of date.\n' + + `Reload with /reload, or resume fresh with: kimi -S ${quoteShellArg(session.id)}`, + 'warning', + ); + return true; + } + async sendNormalUserInput(text: string, preExtracted?: ExtractionResult): Promise { if (this.btwPanelController.sendUserInput(text)) return; if (this.state.appState.model.trim().length === 0) { @@ -1354,6 +1418,18 @@ export class KimiTUI { session = await this.ensureSession(); if (session === undefined) return; } + // An external client (ACP/mobile) may have appended turns to the session's + // wire journal while this TUI was idle. Sending now would continue from a + // stale context and silently fork the session, so refuse until the user + // reloads the session. Only sessions that expose an on-disk journal are + // checked: the guard is a fail-open no-op otherwise, and gating on + // `sessionDir` keeps the send path synchronous for journal-less sessions + // (the check must not interpose an await between submit and send). + if (session.summary?.sessionDir !== undefined && (await this.assertWireFresh(session))) { + this.updateQueueDisplay(); + this.state.ui.requestRender(); + return; + } if (extraction.hasMedia) { this.sendMessage(session, text, { hasMedia: true, @@ -1925,6 +2001,9 @@ export class KimiTUI { this.harness.setTelemetryContext({ sessionId: session.id }); this.registerSessionHandlers(session); this.syncAdditionalDirs(session); + // Seed the wire staleness baseline for the newly active session (fresh + // sessions yield `undefined` and stay fail-open until their first turn). + this.refreshWireTipFromDisk(); } async syncRuntimeState(session: Session = this.requireSession()): Promise { @@ -2174,10 +2253,6 @@ export class KimiTUI { // checks below see settled state — the pending prompt would otherwise // replace the resumed session when creation completes. await this.waitForLazyCreation(); - if (targetSessionId === this.state.appState.sessionId) { - this.showStatus('Already on this session.'); - return true; - } if (this.state.appState.streamingPhase !== 'idle') { this.showError('Cannot switch sessions while streaming — press Esc or Ctrl-C first.'); return false; @@ -2186,6 +2261,23 @@ export class KimiTUI { this.showError('Cannot switch sessions while history is replaying.'); return false; } + if (targetSessionId === this.state.appState.sessionId) { + // Re-selecting the current session: an external client (ACP/mobile) may + // have appended turns to the wire journal since this TUI attached, and + // the in-memory transcript is stale. Reload from disk — the SDK closes + // the live scope and re-attaches, restoring the appended journal — and + // re-hydrate the transcript instead of short-circuiting. + const session = this.requireSession(); + try { + await session.reloadSession({ forcePluginSessionStartReminder: true }); + } catch (error) { + const msg = formatErrorMessage(error); + this.showError(`Failed to reload session ${targetSessionId}: ${msg}`); + return false; + } + await this.reloadCurrentSessionView(session, `Reloaded session (${session.id}).`); + return true; + } let session: Session; try { @@ -2253,7 +2345,21 @@ export class KimiTUI { } catch { /* keep the reloaded session usable even if dynamic skills fail */ } - this.sessionEventHandler.startSubscription(); + // A reload re-read the wire journal from disk (closing and re-attaching + // the live scope), so the fresh resume snapshot may contain turns appended + // by external clients (ACP/mobile) that the old in-memory view never saw. + // Clear the stale transcript and re-hydrate it; also re-seed the staleness + // baseline so the next input is not mistaken for an external append. + this.clearTranscriptAndRedraw(); + try { + await this.sessionReplay.hydrateFromReplay(session); + } catch (error) { + const msg = formatErrorMessage(error); + this.showError(`Failed to replay session history: ${msg}`); + } finally { + this.sessionEventHandler.startSubscription(); + } + this.refreshWireTipFromDisk(); const resumeState = session.getResumeState(); if (resumeState?.warning !== undefined) { this.showStatus(`Warning: ${resumeState.warning}`, 'warning'); diff --git a/apps/kimi-code/src/tui/utils/wire-staleness.ts b/apps/kimi-code/src/tui/utils/wire-staleness.ts new file mode 100644 index 0000000000..c67b48d23a --- /dev/null +++ b/apps/kimi-code/src/tui/utils/wire-staleness.ts @@ -0,0 +1,135 @@ +/** + * Staleness detection for the interactive TUI transcript vs the session's + * on-disk wire journal. + * + * The TUI renders a pure in-memory transcript while the engine persists every + * op to `/agents//wire.jsonl`. External clients (ACP + * attach, mobile) can append turns to that journal without the TUI ever seeing + * an event, so the user would keep typing against a stale context and silently + * fork the session into two divergent histories. These helpers let the TUI + * compare the newest user-turn it has rendered against the journal's newest + * user-turn before accepting the next input. + * + * The comparison deliberately keys on `turn.prompt` records rather than any + * timestamped record: compaction, plan-mode toggles and config writes advance + * the journal tail without opening a new conversational turn, so they must not + * count as external activity. `turn.prompt` is the wire's authoritative + * user-turn boundary (see `agent-core-v2/src/agent/loop/turnOps.ts`). + */ + +import { open } from 'node:fs/promises'; +import { join } from 'node:path'; + +/** Record types that open a new conversational turn in the wire journal. */ +const TURN_BOUNDARY_TYPES = new Set(['turn.prompt']); + +/** Path of an agent's persisted journal inside a session directory. */ +export function agentWirePath(sessionDir: string, agentId: string): string { + return join(sessionDir, 'agents', agentId, 'wire.jsonl'); +} + +/** Size of the backward scan windows used to locate the newest turn. */ +const WIRE_TAIL_READ_BYTES = 64 * 1024; + +/** + * Extract the newest user-turn (`turn.prompt`) timestamp from a chunk read off + * the tail of a JSONL wire journal. + * + * A tail chunk may begin mid-record (the read boundary split a line); the scan + * runs from the last line upward, skipping fragments that fail to parse and + * non-boundary records. Returns `undefined` when the chunk holds no complete + * `turn.prompt` record with a numeric `time`. + */ +export function lastTurnBoundaryTimeInChunk(chunk: string): number | undefined { + const lines = chunk.split('\n'); + for (let i = lines.length - 1; i >= 0; i -= 1) { + const line = lines[i]!.trim(); + if (line.length === 0) continue; + let record: { type?: unknown; time?: unknown }; + try { + record = JSON.parse(line) as { type?: unknown; time?: unknown }; + } catch { + // Fragment from the read boundary — keep scanning upward. + continue; + } + if (typeof record.time === 'number' && TURN_BOUNDARY_TYPES.has(record.type as string)) { + return record.time; + } + } + return undefined; +} + +/** + * True when the wire journal has a user-turn newer than the newest turn the + * TUI has rendered — i.e. an external client appended a turn the user never + * saw. Fails open (false) whenever either side is unknown. + */ +export function wireTailAheadOfTranscript(opts: { + readonly transcriptTipTime: number | undefined; + readonly wireTailTime: number | undefined; +}): boolean { + const { transcriptTipTime, wireTailTime } = opts; + if (transcriptTipTime === undefined || wireTailTime === undefined) return false; + return wireTailTime > transcriptTipTime; +} + +/** + * Read the newest user-turn timestamp of an agent's `wire.jsonl`. + * + * Walks backward from the journal tail in {@link WIRE_TAIL_READ_BYTES} windows + * until a `turn.prompt` boundary is found or the start of the file is reached, + * so a single external turn whose response/tool output spans more than one + * window cannot hide its prompt boundary. A record split across a read + * boundary is reassembled before scanning: the fragment at the top of each + * window is the tail half of a record whose head lives at the end of the next + * (older) window, and the two are joined back into one line. + * + * Failures (missing session dir, unreadable journal) degrade to `undefined` + * so the staleness guard always fails open rather than blocking the user on a + * corrupt file. + */ +export async function readWireTurnBoundaryTime( + sessionDir: string, + agentId: string, +): Promise { + try { + const file = await open(agentWirePath(sessionDir, agentId), 'r'); + try { + const { size } = await file.stat(); + if (size <= 0) return undefined; + let offset = size; + // Tail half of the record split by the last read boundary; its head is + // the final line of the next (older) window. + let carry = ''; + while (offset > 0) { + const start = Math.max(0, offset - WIRE_TAIL_READ_BYTES); + const length = offset - start; + const buffer = Buffer.alloc(length); + await file.read(buffer, 0, length, start); + const chunk = buffer.toString('utf8'); + const firstNl = chunk.indexOf('\n'); + if (firstNl >= 0) { + // The first line may be the tail half of a split record; the rest + // are complete. Appending the carried tail to the window's own last + // line (the head half) reassembles the split record, which is the + // newest line this window contributes and is scanned first. + const rest = chunk.slice(firstNl + 1); + const time = lastTurnBoundaryTimeInChunk(rest + carry); + if (time !== undefined) return time; + carry = chunk.slice(0, firstNl); + } else { + // The whole window is one un-terminated record — accumulate it with + // the carried tail so the record is reassembled in an older window. + carry = chunk + carry; + } + offset = start; + } + // The oldest record reached the head of the file still split. + return carry.length > 0 ? lastTurnBoundaryTimeInChunk(carry) : undefined; + } finally { + await file.close(); + } + } catch { + return undefined; + } +} diff --git a/apps/kimi-code/test/cli/run-shell.test.ts b/apps/kimi-code/test/cli/run-shell.test.ts index 73b7a22222..6bbcffea77 100644 --- a/apps/kimi-code/test/cli/run-shell.test.ts +++ b/apps/kimi-code/test/cli/run-shell.test.ts @@ -1,4 +1,7 @@ import { execFileSync } from 'node:child_process'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import type { createKimiDeviceId as createKimiDeviceIdFn } from '@moonshot-ai/kimi-code-oauth'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -165,11 +168,16 @@ vi.mock('../../src/utils/process/resolve-command', () => ({ })); describe('runShell', () => { + const tempDirs: string[] = []; + beforeEach(() => { vi.stubEnv('KIMI_CODE_LEGACY_FLAG', '1'); }); - afterEach(() => { + afterEach(async () => { + for (const dir of tempDirs.splice(0)) { + await rm(dir, { recursive: true, force: true }); + } vi.clearAllMocks(); vi.unstubAllEnvs(); mocks.harnessGetConfig.mockResolvedValue({ @@ -246,6 +254,39 @@ describe('runShell', () => { expect(mocks.kimiHarnessConstructor).not.toHaveBeenCalled(); }); + // `--agent-file` registers the file for the whole launch, and the v2 engine + // reads explicit agent files from the process bootstrap the harness sets up — + // so the flag has to reach the harness, not only the startup session. Runs on + // the default (v2) route, which is the one that reads the option. + it('forwards agentFiles from CLI options to the v2 harness', async () => { + stubTuiStartup(); + const agentDir = await mkdtemp(join(tmpdir(), 'kimi-code-agent-')); + tempDirs.push(agentDir); + const agentFile = join(agentDir, 'reviewer.md'); + await writeFile( + agentFile, + '---\nname: reviewer\ndescription: Reviews code.\n---\n\nReview the requested change.\n', + 'utf-8', + ); + + await withEnv( + { KIMI_CODE_LEGACY_FLAG: undefined, KIMI_CODE_EXPERIMENTAL_FLAG: undefined }, + async () => { + await runShell({ ...minimalCliOptions, agentFiles: [agentFile] }, '1.2.3-test'); + }, + ); + + expect(mocks.kimiHarnessV2Constructor).toHaveBeenCalledWith( + expect.objectContaining({ agentFiles: [agentFile] }), + ); + // The startup session still selects the profile that file defines. + expect(mocks.kimiTuiConstructor).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ agentProfile: 'reviewer' }), + ); + }); + it('uses the legacy harness when the legacy flag is truthy', async () => { stubTuiStartup(); await withEnv({ KIMI_CODE_LEGACY_FLAG: '1' }, async () => { diff --git a/apps/kimi-code/test/cli/upgrade.test.ts b/apps/kimi-code/test/cli/upgrade.test.ts index 7c02f1d298..7c0b606420 100644 --- a/apps/kimi-code/test/cli/upgrade.test.ts +++ b/apps/kimi-code/test/cli/upgrade.test.ts @@ -157,19 +157,85 @@ describe('handleUpgrade', () => { expect(stdout.join('')).toContain('To update manually, run: npm install -g @mbuckaway/kimi-code@0.5.0'); }); - it('prints the manual update command without prompting when not interactive', async () => { - const { stdout, writable } = captureOutput(); + it('auto-installs without prompting when not interactive and the install source supports it', async () => { + const { stdout, stderr, writable } = captureOutput(); const deps = createDeps({ latest: '0.5.0', source: 'npm-global', isInteractive: false }); await expect(handleUpgrade('0.4.0', { ...deps, ...writable })).resolves.toBe(0); expect(deps.promptForInstallChoice).not.toHaveBeenCalled(); + expect(deps.installUpdate).toHaveBeenCalledWith('npm-global', '0.5.0', 'darwin'); + expect(deps.track).toHaveBeenCalledWith('upgrade_command_auto_install_started', expect.objectContaining({ + current_version: '0.4.0', + target_version: '0.5.0', + source: 'npm-global', + })); + expect(deps.track).toHaveBeenCalledWith('upgrade_command_succeeded', expect.objectContaining({ + target_version: '0.5.0', + source: 'npm-global', + })); + expect(stdout.join('')).toContain('Updated @mbuckaway/kimi-code to 0.5.0'); + expect(stderr.join('')).toBe(''); + }); + + it('auto-installs a native update on non-Windows platforms when not interactive', async () => { + const { stdout, writable } = captureOutput(); + const deps = createDeps({ latest: '0.5.0', source: 'native', isInteractive: false }); + + await expect(handleUpgrade('0.4.0', { ...deps, ...writable })).resolves.toBe(0); + + expect(deps.promptForInstallChoice).not.toHaveBeenCalled(); + expect(deps.installUpdate).toHaveBeenCalledWith('native', '0.5.0', 'darwin'); + expect(stdout.join('')).toContain('Updated @mbuckaway/kimi-code to 0.5.0'); + }); + + it('returns a failing exit code when the non-interactive auto-install fails', async () => { + const { stdout, stderr, writable } = captureOutput(); + const deps = createDeps({ + latest: '0.5.0', + source: 'npm-global', + isInteractive: false, + installUpdate: vi.fn().mockRejectedValue(new Error('npm exited with code 1')), + }); + + await expect(handleUpgrade('0.4.0', { ...deps, ...writable })).resolves.toBe(1); + + expect(deps.track).toHaveBeenCalledWith('upgrade_command_failed', expect.objectContaining({ + target_version: '0.5.0', + source: 'npm-global', + stage: 'install', + })); + expect(stderr.join('')).toContain( + 'warning: failed to install @mbuckaway/kimi-code@0.5.0: npm exited with code 1', + ); + expect(stdout.join('')).not.toContain('Updated @mbuckaway/kimi-code'); + }); + + it('fails with a non-zero exit code when not interactive and the install source cannot be auto-installed', async () => { + const { stdout, stderr, writable } = captureOutput(); + const deps = createDeps({ latest: '0.5.0', source: 'unsupported', isInteractive: false }); + + await expect(handleUpgrade('0.4.0', { ...deps, ...writable })).resolves.toBe(1); + expect(deps.installUpdate).not.toHaveBeenCalled(); + expect(deps.promptForInstallChoice).not.toHaveBeenCalled(); expect(deps.track).toHaveBeenCalledWith('upgrade_command_manual_command', expect.objectContaining({ target_version: '0.5.0', - source: 'npm-global', + source: 'unsupported', })); - expect(stdout.join('')).toContain('To update manually, run: npm install -g @mbuckaway/kimi-code@0.5.0'); + expect(stderr.join('')).toContain('To update manually, run: npm install -g @mbuckaway/kimi-code@0.5.0'); + expect(stdout.join('')).toBe(''); + }); + + it('fails without installing when not interactive and the source is homebrew', async () => { + const { stderr, writable } = captureOutput(); + const deps = createDeps({ latest: '0.5.0', source: 'homebrew', isInteractive: false }); + + await expect(handleUpgrade('0.4.0', { ...deps, ...writable })).resolves.toBe(1); + + expect(deps.installUpdate).not.toHaveBeenCalled(); + expect(deps.promptForInstallChoice).not.toHaveBeenCalled(); + expect(stderr.join('')).toContain('To update manually, run: brew upgrade kimi-code'); }); it('returns a failing exit code when the foreground install fails', async () => { diff --git a/apps/kimi-code/test/tui/controllers/session-event-handler-background-task.test.ts b/apps/kimi-code/test/tui/controllers/session-event-handler-background-task.test.ts index 0c5588e46b..cb72468cb4 100644 --- a/apps/kimi-code/test/tui/controllers/session-event-handler-background-task.test.ts +++ b/apps/kimi-code/test/tui/controllers/session-event-handler-background-task.test.ts @@ -147,6 +147,7 @@ function makeSessionEventHost() { restoreInputText: vi.fn(), appendTranscriptEntry: vi.fn(), sendNormalUserInput: vi.fn(), + refreshWireTipFromDisk: vi.fn(), sendQueuedMessage: vi.fn(), shiftQueuedMessage: vi.fn(), btwPanelController: { routeEvent: vi.fn(() => false) }, diff --git a/apps/kimi-code/test/tui/controllers/session-event-handler-compaction.test.ts b/apps/kimi-code/test/tui/controllers/session-event-handler-compaction.test.ts index 86531df8f8..b32b5d46c3 100644 --- a/apps/kimi-code/test/tui/controllers/session-event-handler-compaction.test.ts +++ b/apps/kimi-code/test/tui/controllers/session-event-handler-compaction.test.ts @@ -56,6 +56,7 @@ function makeHost() { restoreInputText: vi.fn(), appendTranscriptEntry: vi.fn(), sendNormalUserInput: vi.fn(), + refreshWireTipFromDisk: vi.fn(), sendQueuedMessage: vi.fn(), shiftQueuedMessage: vi.fn(), btwPanelController: { routeEvent: vi.fn(() => false) }, diff --git a/apps/kimi-code/test/tui/controllers/session-event-handler-errors.test.ts b/apps/kimi-code/test/tui/controllers/session-event-handler-errors.test.ts index 5741cc0549..1da07f8e65 100644 --- a/apps/kimi-code/test/tui/controllers/session-event-handler-errors.test.ts +++ b/apps/kimi-code/test/tui/controllers/session-event-handler-errors.test.ts @@ -49,6 +49,7 @@ function makeHost() { restoreInputText: vi.fn(), appendTranscriptEntry: vi.fn(), sendNormalUserInput: vi.fn(), + refreshWireTipFromDisk: vi.fn(), sendQueuedMessage: vi.fn(), shiftQueuedMessage: vi.fn(), btwPanelController: { routeEvent: vi.fn(() => false) }, diff --git a/apps/kimi-code/test/tui/controllers/session-event-handler-goal-queue.test.ts b/apps/kimi-code/test/tui/controllers/session-event-handler-goal-queue.test.ts index 6a0bcdd33e..11e10f612f 100644 --- a/apps/kimi-code/test/tui/controllers/session-event-handler-goal-queue.test.ts +++ b/apps/kimi-code/test/tui/controllers/session-event-handler-goal-queue.test.ts @@ -94,6 +94,7 @@ function makeHost(options: { createGoalRejects?: boolean } = {}) { restoreInputText: vi.fn(), appendTranscriptEntry: vi.fn(), sendNormalUserInput: vi.fn(), + refreshWireTipFromDisk: vi.fn(), sendQueuedMessage: vi.fn(), shiftQueuedMessage: vi.fn(), btwPanelController: { routeEvent: vi.fn(() => false) }, diff --git a/apps/kimi-code/test/tui/controllers/session-event-handler-plugin-updates.test.ts b/apps/kimi-code/test/tui/controllers/session-event-handler-plugin-updates.test.ts index 882d79e4ed..c67bfdcb7e 100644 --- a/apps/kimi-code/test/tui/controllers/session-event-handler-plugin-updates.test.ts +++ b/apps/kimi-code/test/tui/controllers/session-event-handler-plugin-updates.test.ts @@ -55,6 +55,7 @@ function makeHost() { restoreInputText: vi.fn(), appendTranscriptEntry: vi.fn(), sendNormalUserInput: vi.fn(), + refreshWireTipFromDisk: vi.fn(), sendQueuedMessage: vi.fn(), shiftQueuedMessage: vi.fn(), btwPanelController: { routeEvent: vi.fn(() => false) }, diff --git a/apps/kimi-code/test/tui/controllers/session-event-handler-step-retry.test.ts b/apps/kimi-code/test/tui/controllers/session-event-handler-step-retry.test.ts index a60aa55c63..73f0dca8b8 100644 --- a/apps/kimi-code/test/tui/controllers/session-event-handler-step-retry.test.ts +++ b/apps/kimi-code/test/tui/controllers/session-event-handler-step-retry.test.ts @@ -52,6 +52,7 @@ function makeHost() { restoreInputText: vi.fn(), appendTranscriptEntry: vi.fn(), sendNormalUserInput: vi.fn(), + refreshWireTipFromDisk: vi.fn(), sendQueuedMessage: vi.fn(), shiftQueuedMessage: vi.fn(), btwPanelController: { routeEvent: vi.fn(() => false) }, diff --git a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index cea085ee9b..eb466acb38 100644 --- a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts @@ -1,5 +1,5 @@ import { AsyncLocalStorage } from 'node:async_hooks'; -import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { appendFile, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; @@ -1443,7 +1443,46 @@ command = "vim" const homeDir = await makeTempHome(); process.env['KIMI_CODE_HOME'] = homeDir; await writeFile(join(homeDir, 'tui.toml'), 'theme = "light"\n', 'utf-8'); - const { driver, session, harness } = await makeDriver(); + // /reload re-reads the wire journal from disk and re-hydrates the + // transcript from the fresh resume snapshot, so the snapshot must replay + // the turn sent before the reload (a real reload folds the wire). + const session = makeSession({ + getResumeState: vi.fn(() => ({ + sessionMetadata: {}, + agents: { + main: { + type: 'main', + config: { + modelAlias: 'k2', + provider: undefined, + modelCapabilities: undefined, + profileName: undefined, + thinkingEffort: undefined, + systemPrompt: undefined, + }, + context: { history: [], tokenCount: 0 }, + replay: [ + { + type: 'message', + time: 100, + message: { + role: 'user', + content: [{ type: 'text', text: 'hello before reload' }], + origin: { kind: 'user_input' }, + }, + }, + ], + permission: { mode: 'manual', rules: [] }, + plan: null, + swarmMode: false, + usage: {}, + tools: [], + background: [], + }, + }, + })), + }); + const { driver, harness } = await makeDriver(session); harness.track.mockClear(); session.reloadSession.mockClear(); driver.handleUserInput('hello before reload'); @@ -7096,3 +7135,202 @@ describe('transcript step and assistant folding', () => { expect(stripSgr(lastAssistant.render(120).join('\n'))).toContain(`msg-${cycles - 1}`); }); }); + +// --------------------------------------------------------------------------- +// Wire-journal staleness (issue #2835) +// +// The TUI renders a per-process in-memory transcript while the engine persists +// every op to `/agents/main/wire.jsonl`. An external client (ACP +// attach, mobile) can append turns to that journal without the TUI ever seeing +// an event; the send guard must refuse input that would silently fork the +// session, and re-selecting the current session must reload the wire instead +// of short-circuiting on the stale view. +// --------------------------------------------------------------------------- + +function wireJsonl(...records: Array>): string { + return records.map((record) => JSON.stringify(record)).join('\n'); +} + +function wireTurn(time: number, turnId: number): Record { + return { + type: 'turn.prompt', + time, + turnId, + input: { input: [], origin: { kind: 'user_input' } }, + }; +} + +function wireTurnEnded(time: number, turnId: number): Record { + return { type: 'turn.ended', time, turnId, reason: 'completed' }; +} + +async function writeWireJournal(sessionDir: string, contents: string): Promise { + await mkdir(join(sessionDir, 'agents', 'main'), { recursive: true }); + await writeFile(join(sessionDir, 'agents', 'main', 'wire.jsonl'), contents); +} + +async function appendWireJournal(sessionDir: string, extra: string): Promise { + await appendFile(join(sessionDir, 'agents', 'main', 'wire.jsonl'), `\n${extra}`); +} + +function makeReplayResumeState(replay: unknown[]): Record { + return { + sessionMetadata: {}, + agents: { + main: { + type: 'main', + config: { + modelAlias: 'k2', + provider: undefined, + modelCapabilities: undefined, + profileName: undefined, + thinkingEffort: undefined, + systemPrompt: undefined, + }, + context: { history: [], tokenCount: 0 }, + replay, + permission: { mode: 'manual', rules: [] }, + plan: null, + swarmMode: false, + usage: {}, + tools: [], + background: [], + }, + }, + }; +} + +function makeWireSession( + sessionDir: string, + opts: { resumeState?: Record; reloadSession?: () => Promise } = {}, +): ReturnType { + const resumeState = opts.resumeState ?? makeReplayResumeState([]); + return makeSession({ + summary: { title: null, sessionDir }, + getResumeState: vi.fn(() => resumeState), + reloadSession: opts.reloadSession === undefined ? vi.fn(async () => ({})) : vi.fn(opts.reloadSession), + }); +} + +describe('KimiTUI wire-journal staleness', () => { + function wireStartupInput(): KimiTUIStartupInput { + return { + ...makeStartupInput(), + engineV2: true, + cliOptions: { ...makeStartupInput().cliOptions, model: 'k2' }, + }; + } + + async function endActiveTurn(driver: MessageDriver): Promise { + driver.sessionEventHandler.handleEvent( + { + type: 'turn.ended', + agentId: 'main', + sessionId: 'ses-1', + turnId: 1, + reason: 'completed', + } as Event, + vi.fn(), + ); + } + + it('blocks a prompt when an external client appended a turn to the wire journal', async () => { + const dir = await mkdtemp(join(tmpdir(), 'kimi-tui-wire-stale-')); + try { + await writeWireJournal(dir, wireJsonl(wireTurn(1_000, 1), wireTurnEnded(1_100, 1))); + const session = makeWireSession(dir); + const { driver } = await makeDriver(session, {}, wireStartupInput()); + + driver.handleUserInput('first message'); + await vi.waitFor(() => { + expect(session.prompt).toHaveBeenCalledTimes(1); + }); + await endActiveTurn(driver); + + // Simulate an external (ACP/mobile) client appending a turn this TUI + // never rendered. + await appendWireJournal(dir, wireJsonl(wireTurn(2_000, 2), wireTurnEnded(2_100, 2))); + + driver.handleUserInput('second message'); + await vi.waitFor(() => { + expect(renderTranscript(driver)).toContain('Session was modified outside this terminal'); + }); + expect(session.prompt).toHaveBeenCalledTimes(1); + expect(renderTranscript(driver)).not.toContain('second message'); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + it('keeps sending normally while the wire journal is unchanged', async () => { + const dir = await mkdtemp(join(tmpdir(), 'kimi-tui-wire-fresh-')); + try { + await writeWireJournal(dir, wireJsonl(wireTurn(1_000, 1), wireTurnEnded(1_100, 1))); + const session = makeWireSession(dir); + const { driver } = await makeDriver(session, {}, wireStartupInput()); + + driver.handleUserInput('first message'); + await vi.waitFor(() => { + expect(session.prompt).toHaveBeenCalledTimes(1); + }); + await endActiveTurn(driver); + + driver.handleUserInput('second message'); + await vi.waitFor(() => { + expect(session.prompt).toHaveBeenCalledTimes(2); + }); + expect(session.prompt).toHaveBeenLastCalledWith('second message'); + expect(renderTranscript(driver)).not.toContain('Session was modified outside this terminal'); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + it('reloads and re-hydrates the current session when it is re-selected', async () => { + const dir = await mkdtemp(join(tmpdir(), 'kimi-tui-wire-reselect-')); + try { + await writeWireJournal(dir, wireJsonl(wireTurn(1_000, 1), wireTurnEnded(1_100, 1))); + const resumeState = makeReplayResumeState([]); + const session = makeWireSession(dir, { + resumeState, + reloadSession: async () => { + // A real reload re-reads the wire journal from disk; simulate the + // fresh snapshot now containing the externally-appended turn. + const agents = resumeState['agents'] as { main: { replay: unknown[] } }; + agents.main.replay = [ + { + type: 'message', + time: 2_000, + message: { + role: 'user', + content: [{ type: 'text', text: 'external turn from ACP' }], + origin: { kind: 'user_input' }, + }, + }, + ]; + return {}; + }, + }); + const { driver } = await makeDriver(session, {}, wireStartupInput()); + + driver.handleUserInput('first message'); + await vi.waitFor(() => { + expect(session.prompt).toHaveBeenCalledTimes(1); + }); + await endActiveTurn(driver); + + const resumed = await ( + driver as unknown as { resumeSession(targetSessionId: string): Promise } + ).resumeSession('ses-1'); + + expect(resumed).toBe(true); + expect(session.reloadSession).toHaveBeenCalledTimes(1); + await vi.waitFor(() => { + expect(renderTranscript(driver)).toContain('external turn from ACP'); + }); + expect(renderTranscript(driver)).not.toContain('Already on this session.'); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/kimi-code/test/tui/wire-staleness.test.ts b/apps/kimi-code/test/tui/wire-staleness.test.ts new file mode 100644 index 0000000000..bfc8c40730 --- /dev/null +++ b/apps/kimi-code/test/tui/wire-staleness.test.ts @@ -0,0 +1,206 @@ +/** + * Scenario: the interactive TUI detects when an external client has appended + * turns to the session's wire journal so it can warn the user before their + * next input silently forks the conversation. + * + * Responsibilities: `lastTurnBoundaryTimeInChunk` recovers the newest user-turn + * (`turn.prompt`) timestamp from a wire-journal tail chunk, + * `readWireTurnBoundaryTime` locates that boundary in the on-disk journal even + * when it has been pushed past the tail read window, and + * `wireTailAheadOfTranscript` decides staleness by comparing the boundary + * against the newest turn the in-memory transcript has rendered. + * + * Wiring: pure helpers + a single file read only — no TUI or SDK imports, so + * the check runs without any terminal, session, or engine dependency. + * Run: pnpm -C apps/kimi-code exec vitest run test/tui/wire-staleness.test.ts + */ + +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +import { + agentWirePath, + lastTurnBoundaryTimeInChunk, + readWireTurnBoundaryTime, + wireTailAheadOfTranscript, +} from '#/tui/utils/wire-staleness'; + +describe('wireTailAheadOfTranscript', () => { + it('detects a wire user-turn newer than the in-memory transcript tip', () => { + expect( + wireTailAheadOfTranscript({ + transcriptTipTime: 1_700_000_000_000, + wireTailTime: 1_700_000_500_000, + }), + ).toBe(true); + }); + + it('is false when the wire user-turn matches the transcript tip', () => { + expect( + wireTailAheadOfTranscript({ + transcriptTipTime: 1_700_000_500_000, + wireTailTime: 1_700_000_500_000, + }), + ).toBe(false); + }); + + it('is false when the wire user-turn predates the transcript tip', () => { + expect( + wireTailAheadOfTranscript({ + transcriptTipTime: 1_700_000_500_000, + wireTailTime: 1_700_000_000_000, + }), + ).toBe(false); + }); + + it('fails open when either timestamp is unknown', () => { + expect( + wireTailAheadOfTranscript({ transcriptTipTime: undefined, wireTailTime: 1_700_000_500_000 }), + ).toBe(false); + expect( + wireTailAheadOfTranscript({ transcriptTipTime: 1_700_000_000_000, wireTailTime: undefined }), + ).toBe(false); + expect( + wireTailAheadOfTranscript({ transcriptTipTime: undefined, wireTailTime: undefined }), + ).toBe(false); + }); +}); + +describe('lastTurnBoundaryTimeInChunk', () => { + it('returns the newest `turn.prompt` time, skipping non-boundary records', () => { + const chunk = [ + '{"type":"turn.prompt","time":100,"input":{"input":[],"origin":{}}}', + '{"type":"turn.ended","time":200,"turnId":1,"reason":"completed"}', + '{"type":"context.append_loop_event","time":300,"event":{"type":"content.part","part":{"type":"text","text":"hi"}}}', + '{"type":"usage.record","time":400,"usage":{}}', + ].join('\n'); + expect(lastTurnBoundaryTimeInChunk(chunk)).toBe(100); + }); + + it('returns the last of several `turn.prompt` records', () => { + const chunk = [ + '{"type":"turn.prompt","time":100,"input":{}}', + '{"type":"turn.ended","time":150,"turnId":1,"reason":"completed"}', + '{"type":"turn.prompt","time":200,"input":{}}', + '{"type":"turn.ended","time":250,"turnId":2,"reason":"completed"}', + ].join('\n'); + expect(lastTurnBoundaryTimeInChunk(chunk)).toBe(200); + }); + + it('returns undefined when the chunk holds no user-turn record', () => { + expect( + lastTurnBoundaryTimeInChunk( + '{"type":"metadata","protocol_version":"1","created_at":10}\n{"type":"usage.record","time":20,"usage":{}}\n', + ), + ).toBeUndefined(); + expect(lastTurnBoundaryTimeInChunk('')).toBeUndefined(); + }); + + it('scans past a partial line fragment from the read-tail boundary', () => { + // The read boundary split a long record; the fragment fails to parse and + // the scan keeps going until it finds the complete `turn.prompt`. + const chunk = + '{"type":"context.append_loop_event","time":1,"event":{"type":"content.part","part":{"type":"text","text":"' + + '\n{"type":"turn.prompt","time":300,"input":{}}'; + expect(lastTurnBoundaryTimeInChunk(chunk)).toBe(300); + }); + + it('ignores empty trailing lines and returns the newest complete record', () => { + const chunk = '{"type":"turn.prompt","time":100,"input":{}}\n\n'; + expect(lastTurnBoundaryTimeInChunk(chunk)).toBe(100); + }); +}); + +describe('readWireTurnBoundaryTime', () => { + /** Writes a journal and runs the assertion against it, cleaning up after. */ + async function withJournal( + lines: string[], + run: (dir: string, agentId: string) => Promise, + ): Promise { + const dir = await mkdtemp(join(tmpdir(), 'wire-staleness-')); + try { + const agentId = 'agent-1'; + await mkdir(join(dir, 'agents', agentId), { recursive: true }); + await writeFile(agentWirePath(dir, agentId), lines.join('\n')); + await run(dir, agentId); + } finally { + await rm(dir, { recursive: true, force: true }); + } + } + + it('reads the newest `turn.prompt` from a small journal', async () => { + await withJournal( + [ + '{"type":"metadata","protocol_version":"1","created_at":10}', + '{"type":"turn.prompt","time":100,"input":{}}', + '{"type":"turn.ended","time":200,"turnId":1,"reason":"completed"}', + '{"type":"turn.prompt","time":300,"input":{}}', + '{"type":"usage.record","time":400,"usage":{}}', + ], + async (dir, agentId) => { + await expect(readWireTurnBoundaryTime(dir, agentId)).resolves.toBe(300); + }, + ); + }); + + it('recovers a `turn.prompt` pushed past the 64 KiB tail window by a large post-prompt record', async () => { + await withJournal( + [ + '{"type":"metadata","protocol_version":"1","created_at":10}', + '{"type":"turn.prompt","time":100,"input":{}}', + // A single tool-output record larger than the 64 KiB tail read window + // sits between the prompt and the journal tail. A single tail read + // would only see the end of this record plus the newer records, miss + // the prompt boundary, and fail open — the exact silent-fork path this + // guard exists to catch. + `{"type":"context.append_loop_event","time":200,"event":{"type":"content.part","part":{"type":"text","text":"${'x'.repeat(80 * 1024)}"}}}`, + '{"type":"usage.record","time":300,"usage":{}}', + ], + async (dir, agentId) => { + await expect(readWireTurnBoundaryTime(dir, agentId)).resolves.toBe(100); + }, + ); + }); + + it('recovers a `turn.prompt` when a record spanning several windows sits after it', async () => { + await withJournal( + [ + '{"type":"turn.prompt","time":50,"input":{}}', + `{"type":"context.append_loop_event","time":60,"event":{"type":"content.part","part":{"type":"text","text":"${'y'.repeat(200 * 1024)}"}}}`, + '{"type":"usage.record","time":70,"usage":{}}', + ], + async (dir, agentId) => { + await expect(readWireTurnBoundaryTime(dir, agentId)).resolves.toBe(50); + }, + ); + }); + + it('fails open (undefined) when the journal holds no user-turn record', async () => { + await withJournal( + [ + '{"type":"metadata","protocol_version":"1","created_at":10}', + `{"type":"context.append_loop_event","time":20,"event":{"type":"content.part","part":{"type":"text","text":"${'z'.repeat(80 * 1024)}"}}}`, + '{"type":"usage.record","time":30,"usage":{}}', + ], + async (dir, agentId) => { + await expect(readWireTurnBoundaryTime(dir, agentId)).resolves.toBeUndefined(); + }, + ); + }); + + it('fails open (undefined) for an empty journal, missing session, or missing agent', async () => { + const dir = await mkdtemp(join(tmpdir(), 'wire-staleness-')); + try { + const agentId = 'agent-1'; + await mkdir(join(dir, 'agents', agentId), { recursive: true }); + await writeFile(agentWirePath(dir, agentId), ''); + await expect(readWireTurnBoundaryTime(dir, agentId)).resolves.toBeUndefined(); + await expect(readWireTurnBoundaryTime('/nonexistent-session', agentId)).resolves.toBeUndefined(); + await expect(readWireTurnBoundaryTime(dir, 'no-such-agent')).resolves.toBeUndefined(); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/fork/PATCHES.md b/fork/PATCHES.md index 356f70e890..4b968b86ad 100644 --- a/fork/PATCHES.md +++ b/fork/PATCHES.md @@ -17,6 +17,21 @@ mark the row `merged-upstream` (keep the row as history). | security review hardening | `review/fork-security-audit` | typescript-review pass over the fork delta: fs-watch logging + bounded path set, ACP socket error handler / connection cap / Windows boundary warning, provider quota status plumbing, module-hook path normalization, dependency CVE refresh | — | — | local | | language system-prompt fix | `fix/language-system-prompt` | Remove misleading "even after long stretches of English tool output" from system.md (both engines); add `[language]` config section with `reply_language` defaulting to `"en"`; inject `language_directive` template variable into every system prompt. See `fork/LANGUAGE-BUG.md` | — | [#1998](https://github.com/MoonshotAI/kimi-code/issues/1998) | local | | esc-interrupt thinking drop | `fix/esc-interrupt-thinking-drop` | Drop thinking-only assistant messages at the projector layer in **both** engines (`agent-core` v1 + `agent-core-v2`) so an ESC-interrupted turn never leaves a content-less message that 400s the session. Upstream `#2819` fixes only the v2 OpenAI-legacy serializer, so the v1 half stays fork-only | — | [#2691](https://github.com/MoonshotAI/kimi-code/issues/2691) | local | +| ACP internal-turn updates | `fix/acp-headless-regressions` | Forward internally-triggered turns (cron, background-task notifications) as `session/update` notifications over ACP even with no client prompt in flight | — | [#2878](https://github.com/MoonshotAI/kimi-code/issues/2878) | local | +| ACP mode from engine | `fix/acp-headless-regressions` | Seed ACP `currentModeId` from the engine's live permission/plan state (init, load, resume) instead of a hardcoded `default` | — | [#2828](https://github.com/MoonshotAI/kimi-code/issues/2828) | local | +| context-limit 401 classification | `fix/acp-headless-regressions` | Classify message-matched context-limit 401s as context overflow (both engines); skip the pointless OAuth refresh; preserve the provider message to the ACP wire | — | [#2613](https://github.com/MoonshotAI/kimi-code/issues/2613) | local | +| stream stall timeout | `fix/acp-headless-regressions` | Streaming idle/stall watchdog at the kosong stream-iteration layer (`KIMI_CODE_STREAM_STALL_TIMEOUT_MS`, default 300s) — a stalled provider stream aborts like a cancel and persists the partial content | — | [#2762](https://github.com/MoonshotAI/kimi-code/issues/2762) | local | +| wire flush before dispose | `fix/acp-headless-regressions` | Flush the replayable wire before agent dispose on session close so tail assistant records are never dropped (adopts upstream PR #2812) | [#2812](https://github.com/MoonshotAI/kimi-code/pull/2812) | [#2727](https://github.com/MoonshotAI/kimi-code/issues/2727) | local | +| block turns during compaction | `fix/acp-headless-regressions` | Block turns while auto compaction is in flight so the commit-time history-safety race cannot cancel the compaction and kill the turn (adapts upstream PR #2755) | [#2755](https://github.com/MoonshotAI/kimi-code/pull/2755) | [#2720](https://github.com/MoonshotAI/kimi-code/issues/2720) | local | +| Bash sub-command rules | `fix/acp-headless-regressions` | Evaluate Bash permission rules per sub-command (tree-sitter-bash decomposition; both engines) so compound commands cannot over-grant allows or bypass denies (adapts upstream PR #2757) | [#2757](https://github.com/MoonshotAI/kimi-code/pull/2757) | [#2756](https://github.com/MoonshotAI/kimi-code/issues/2756) | local | +| glob slash-crossing | `fix/acp-headless-regressions` | Match permission-rule glob subjects as opaque text so `*` crosses `/` for commands/URLs/search; path subjects keep path semantics (adapts upstream PR #2747; both engines) | [#2747](https://github.com/MoonshotAI/kimi-code/pull/2747) | [#2728](https://github.com/MoonshotAI/kimi-code/issues/2728) | local | +| -p env provider key | `fix/acp-headless-regressions` | The auth gate falls back to `process.env` via the provider's declared `apiKeyEnv` (two-stage resolution; adapts upstream PR #2746) | [#2746](https://github.com/MoonshotAI/kimi-code/pull/2746) | [#2745](https://github.com/MoonshotAI/kimi-code/issues/2745) | local | +| hooks config reload | `fix/acp-headless-regressions` | Rebuild the external-hooks index on `onDidChangeConfiguration` (coalesced) so late `[[hooks]]` fire and the heartbeat arms (adapts upstream PR #2822) | [#2822](https://github.com/MoonshotAI/kimi-code/pull/2822) | [#2779](https://github.com/MoonshotAI/kimi-code/issues/2779) | local | +| select_tools always registered | `fix/acp-headless-regressions` | Always register the `select_tools` disclosure tool under an allowlist-bound profile so the announced MCP selector is callable | — | [#2381](https://github.com/MoonshotAI/kimi-code/issues/2381) | local | +| TUI wire staleness guard | `fix/acp-headless-regressions` | TUI detects external wire-journal appends, blocks forking input, reloads + re-hydrates on session re-select (adapts upstream PR #2851) | [#2851](https://github.com/MoonshotAI/kimi-code/pull/2851) | [#2835](https://github.com/MoonshotAI/kimi-code/issues/2835) | local | +| non-TTY upgrade | `fix/acp-headless-regressions` | Non-TTY `kimi upgrade` runs the auto-install (or exits non-zero when unsupported) instead of silently exiting 0 | — | [#2629](https://github.com/MoonshotAI/kimi-code/issues/2629) | local | +| prompt plan_mode | `fix/acp-headless-regressions` | The prompt submit route applies `plan_mode` enter/exit-if-different so a new web session's first message enters plan mode (adapts upstream PR #2869) | [#2869](https://github.com/MoonshotAI/kimi-code/pull/2869) | [#2658](https://github.com/MoonshotAI/kimi-code/issues/2658) | local | +| session profile bind | `fix/acp-headless-regressions` | Session create binds the requested `--agent`/`--agent-file` profile and threads `agentFiles` through the harness so the profile's tools apply (adapts upstream PRs #2832, #2770) | [#2832](https://github.com/MoonshotAI/kimi-code/pull/2832) | [#2765](https://github.com/MoonshotAI/kimi-code/issues/2765), [#2767](https://github.com/MoonshotAI/kimi-code/issues/2767) | local | Changesets under `.changeset/` deliberately name the upstream package `@moonshot-ai/kimi-code`, not the fork's `@mbuckaway/kimi-code`, so a change can diff --git a/packages/acp-adapter/src/session.ts b/packages/acp-adapter/src/session.ts index 747b44ea9c..c08efb4523 100644 --- a/packages/acp-adapter/src/session.ts +++ b/packages/acp-adapter/src/session.ts @@ -1601,6 +1601,14 @@ function mapPromptError(err: unknown, sessionId: string): RequestError { }); return authErr; } + const overflowErr = contextOverflowFromUnknown(err); + if (overflowErr) { + log.warn('acp: prompt rejected with context overflow; preserving provider message', { + sessionId, + error: err instanceof Error ? err.message : String(err), + }); + return overflowErr; + } log.error('acp: prompt failed', { sessionId, error: err instanceof Error ? { message: err.message, stack: err.stack } : String(err), @@ -1657,6 +1665,28 @@ function authRequiredFromUnknown(err: unknown): RequestError | undefined { return undefined; } +/** + * Map a context-overflow-coded prompt rejection to a `RequestError` that + * preserves the provider's message instead of collapsing to the generic + * authRequired ("Authentication required" would tell the client to re-login, + * which cannot fix an over-long context). The ACP SDK has no dedicated + * overflow code, so the error stays a generic internalError whose message + * carries the provider text (issue #2613). + */ +function contextOverflowFromUnknown(err: unknown): RequestError | undefined { + if (err && typeof err === 'object' && 'code' in err) { + const code = (err as { code?: unknown }).code; + if (code === ErrorCodes.CONTEXT_OVERFLOW) { + const message = (err as { message?: unknown }).message; + return RequestError.internalError( + undefined, + typeof message === 'string' ? message : undefined, + ); + } + } + return undefined; +} + /** * Identifier the agent-core session emits for the main (user-facing) * agent. Subagents are issued generated ids by `Session.spawnAgent`; diff --git a/packages/acp-adapter/test/error-mapping.test.ts b/packages/acp-adapter/test/error-mapping.test.ts index f05bfef128..0de8c848ff 100644 --- a/packages/acp-adapter/test/error-mapping.test.ts +++ b/packages/acp-adapter/test/error-mapping.test.ts @@ -212,6 +212,31 @@ describe('AcpServer error mapping', () => { ).rejects.toMatchObject({ code: -32000 }); }); + it('maps a session.prompt rejection carrying context.overflow to internalError (-32603) with the provider message preserved', async () => { + const sessionId = 'sess-context-overflow-reject'; + const { session } = makeScriptedSession(sessionId, { + rejectWith: new KimiError(ErrorCodes.CONTEXT_OVERFLOW, 'k3-256k supports only 256K context.'), + }); + + const { agentStream, clientStream } = makeInMemoryStreamPair(); + new AgentSideConnection((c) => new AcpServer(makeHarnessWithSession(session), c), agentStream); + const client = new ClientSideConnection(() => new StubClient(), clientStream); + + await client.newSession({ cwd: '/tmp/x', mcpServers: [] }); + + let captured: unknown; + try { + await client.prompt({ sessionId, prompt: [textBlock('hi')] }); + } catch (err) { + captured = err; + } + // A context-overflow failure must NOT surface as -32000 authRequired + // (re-login cannot fix an over-long context); it stays a generic JSON-RPC + // error whose message preserves the provider text (issue #2613). + expect(captured).toMatchObject({ code: -32603 }); + expect((captured as { message?: string }).message).toContain('supports only 256K context'); + }); + it('maps a generic session.prompt rejection to internalError (-32603) without leaking the stack', async () => { const sessionId = 'sess-generic-error'; const stackTip = 'super-secret-stack-frame-do-not-leak'; diff --git a/packages/acp-server/src/modes.ts b/packages/acp-server/src/modes.ts index a0ecdb7cd7..a2710deea8 100644 --- a/packages/acp-server/src/modes.ts +++ b/packages/acp-server/src/modes.ts @@ -3,8 +3,11 @@ * * The 4 modes (`default`, `plan`, `auto`, `yolo`) are the locked decision. * Every `session/new` and `session/load` response advertises {@link ACP_MODES} - * as the mode picker plus {@link DEFAULT_MODE_ID} as `currentModeId`, so ACP - * clients render the dropdown from a single canonical source. + * as the mode picker plus the session's live mode as `currentModeId` (seeded + * from the engine's actual plan/permission state — see + * {@link acpModeFromState} — falling back to {@link DEFAULT_MODE_ID} when the + * engine state is unreadable), so ACP clients render the dropdown from a + * single canonical source. * * `session/set_mode` and the `mode` arm of `session/set_config_option` consume * the same source of truth: {@link isAcpModeId} narrows the wire string, and @@ -85,3 +88,24 @@ export function acpModeToToggles(id: AcpModeId): AcpModeToggles { } } } + +/** + * Inverse of {@link acpModeToToggles}: resolve the engine's LIVE plan / + * permission state back to the {@link AcpModeId} that produced it. Used to + * seed `currentModeId` at `init()` from the engine's actual posture (a session + * booted with `defaultPermissionMode = 'yolo'` must advertise `'yolo'`, not + * the stale `'default'`). Plan mode wins — `plan: true` maps to `'plan'` + * regardless of the permission posture, the same precedence `setMode` applies + * when entering plan. + */ +export function acpModeFromState(plan: boolean, permission: PermissionMode): AcpModeId { + if (plan) return 'plan'; + switch (permission) { + case 'manual': + return 'default'; + case 'auto': + return 'auto'; + case 'yolo': + return 'yolo'; + } +} diff --git a/packages/acp-server/src/server.ts b/packages/acp-server/src/server.ts index 4f1b3e4640..124dd38c84 100644 --- a/packages/acp-server/src/server.ts +++ b/packages/acp-server/src/server.ts @@ -56,6 +56,7 @@ import { type SetSessionModeRequest, type SetSessionModeResponse, } from '@agentclientprotocol/sdk'; +import type { PermissionMode } from '@moonshot-ai/agent-core-v2'; import type { AgentHandle, Klient, @@ -127,6 +128,15 @@ export interface AcpServerOptions { * scope. Absent → `persistOriginalImage`'s shared temp-dir fallback. */ readonly resolveOriginalsDir?: (sessionId: string) => string | undefined; + /** + * Resolve a live session's engine permission mode (Agent-scope + * `IAgentPermissionModeService.mode`). Composition-root concern reading the + * live engine scope tree — `start.ts` builds it from the bootstrapped App + * scope (same pattern as `resolveOriginalsDir`). Absent / returning + * undefined → `init()` falls back to the `defaultPermissionMode` config + * value. + */ + readonly resolvePermissionMode?: (sessionId: string) => PermissionMode | undefined; /** Static or per-session host command palette, compatible with acp-adapter. */ readonly slashCommands?: SlashCommandsResolver; } @@ -138,6 +148,7 @@ export class AcpServer { private readonly terminalAuthEnv: Readonly> | undefined; private readonly terminalAuthLegacyCommand: string | undefined; private readonly resolveOriginalsDir: ((sessionId: string) => string | undefined) | undefined; + private readonly resolvePermissionMode: ((sessionId: string) => PermissionMode | undefined) | undefined; private readonly resolveSlashCommands: ( session: SessionHandle, ) => Promise | SlashCommandsSnapshot>; @@ -159,6 +170,7 @@ export class AcpServer { this.terminalAuthEnv = opts.terminalAuthEnv; this.terminalAuthLegacyCommand = opts.terminalAuthLegacyCommand; this.resolveOriginalsDir = opts.resolveOriginalsDir; + this.resolvePermissionMode = opts.resolvePermissionMode; const slashCommands = opts.slashCommands; this.resolveSlashCommands = typeof slashCommands === 'function' @@ -547,6 +559,7 @@ export class AcpServer { this.acpConnection, Boolean(this.clientCapabilities?.elicitation?.form), this.resolveOriginalsDir, + this.resolvePermissionMode, hostCommands, ); await acpSession.init(); diff --git a/packages/acp-server/src/session.ts b/packages/acp-server/src/session.ts index 66741fe42b..cded2f8646 100644 --- a/packages/acp-server/src/session.ts +++ b/packages/acp-server/src/session.ts @@ -13,6 +13,14 @@ * settles gracefully with `end_turn`, mirroring the engine's `PromptHandle` * behavior. * + * Events are attributed per-turn: while a client `session/prompt` is in + * flight only its turn's events stream (see {@link AcpSession.forwardingFor}). + * With no prompt in flight the turn is internally triggered — cron fire / + * catch-up and background-task terminal notifications enqueue engine turns + * that run and persist but used to stream nothing to the ACP client — so + * those events are forwarded too, keeping the client's view in sync with the + * engine. + * * KLIENT GAPS (all reported; each marked `KLIENT-GAP` inline): * - no session MCP connection view / compaction service → the `/mcp` and * `/compact` builtin slash commands answer with an explanatory notice @@ -30,7 +38,7 @@ import type { ToolCallLocation, } from '@agentclientprotocol/sdk'; import { RequestError } from '@agentclientprotocol/sdk'; -import type { ContextMessage } from '@moonshot-ai/agent-core-v2'; +import type { ContextMessage, PermissionMode } from '@moonshot-ai/agent-core-v2'; import type { AgentEventPayloads, AgentHandle, @@ -84,7 +92,13 @@ import { import { AcpInteractionBridge } from './interaction-bridge'; import { log } from './log'; import { projectModelCatalog } from './model-catalog'; -import { ACP_MODES, type AcpModeId, acpModeToToggles, DEFAULT_MODE_ID } from './modes'; +import { + ACP_MODES, + type AcpModeId, + acpModeFromState, + acpModeToToggles, + DEFAULT_MODE_ID, +} from './modes'; import { projectHistoryToSessionUpdates } from './replay'; import { buildAcpSkillSlashCommands, detectSlashIntent } from './slash'; @@ -256,6 +270,15 @@ export class AcpSession { * shared temp-dir fallback applies. */ private readonly resolveOriginalsDir?: (sessionId: string) => string | undefined, + /** + * Resolve the live engine permission mode for a session + * (Agent-scope `IAgentPermissionModeService.mode`). This is a + * composition-root concern (it reads the live engine scope tree, not the + * klient facade) — `start.ts` builds it from the bootstrapped App scope, + * mirroring `resolveOriginalsDir`. Undefined / returning undefined → + * `init()` falls back to the `defaultPermissionMode` config value. + */ + private readonly resolvePermissionMode?: (sessionId: string) => PermissionMode | undefined, private readonly hostCommands: | ReadonlyArray | HostSlashCommandsSnapshot = [], @@ -351,9 +374,10 @@ export class AcpSession { try { this.currentModelId = await this.agent.getModel(); this.currentThinkingLevel = await this.agent.getThinking(); + this.currentModeId = await this.resolveCurrentMode(); } catch (error) { // Keep the unbound defaults — configOptions stays honest. - log.warn('acp: could not seed model/thinking state', { + log.warn('acp: could not seed model/thinking/mode state', { sessionId: this.sessionId, error: error instanceof Error ? error.message : String(error), }); @@ -363,6 +387,40 @@ export class AcpSession { await this.refreshSkills(); } + /** + * Seed `currentModeId` from the engine's LIVE plan / permission state — the + * inverse of {@link setMode}'s toggles — so the advertised `modes` / + * `mode` config-option reflect the mode the engine genuinely runs, not the + * stale `DEFAULT_MODE_ID`: + * - plan state via `agent.getPlan()` (non-null → plan mode active); + * - permission via {@link resolvePermissionMode} (the composition-root + * engine-scope read — a restored session reports its persisted + * `permission.set_mode` override, not the config default). + * Without a live resolver the engine's bootstrap behavior is mirrored: the + * `defaultPermissionMode` config value when set, else `'manual'` (the wire + * model's initial value). + */ + private async resolveCurrentMode(): Promise { + const plan = await this.agent.getPlan(); + const permission = this.resolvePermissionMode?.(this.sessionId); + if (permission !== undefined) return acpModeFromState(plan !== null, permission); + try { + const inspected = await this.klient.global.config.inspect( + 'defaultPermissionMode', + ); + const configured = inspected.value; + if (configured === 'manual' || configured === 'auto' || configured === 'yolo') { + return acpModeFromState(plan !== null, configured); + } + } catch (error) { + log.warn('acp: could not read defaultPermissionMode', { + sessionId: this.sessionId, + error: error instanceof Error ? error.message : String(error), + }); + } + return acpModeFromState(plan !== null, 'manual'); + } + /** Refresh the skill cache from the session catalog (best-effort). */ private async refreshSkills(): Promise { try { @@ -725,27 +783,35 @@ export class AcpSession { action(); } - /** The active driver, but only for events of ITS turn. */ - private driverFor(turnId: number): TurnDriver | undefined { + /** + * Whether turn-scoped events for `turnId` should stream to the client. With + * a client `session/prompt` in flight only events of ITS turn are forwarded + * (a still-draining prior turn is dropped — the same verdict the live path + * gives). With NO prompt in flight the turn is internally triggered — cron + * fire/catch-up and background-task terminal notifications enqueue engine + * turns (`SteerStepRequest` / `TaskNotificationStepRequest`) that run and + * persist in the engine but used to stream nothing to the ACP client — so + * every event is forwarded, keeping the client's view in sync with the + * engine's live state. + */ + private forwardingFor(turnId: number): boolean { const driver = this.driver; - if (driver === undefined || driver.turnId === undefined || driver.turnId !== turnId) { - return undefined; - } - return driver; + if (driver === undefined) return true; + return driver.turnId === turnId; } private onAssistantDelta(event: AgentEventPayloads['assistant.delta']): void { - if (this.driverFor(event.turnId) === undefined) return; + if (!this.forwardingFor(event.turnId)) return; this.emit(assistantDeltaToSessionUpdate(this.sessionId, event)); } private onThinkingDelta(event: AgentEventPayloads['thinking.delta']): void { - if (this.driverFor(event.turnId) === undefined) return; + if (!this.forwardingFor(event.turnId)) return; this.emit(thinkingDeltaToSessionUpdate(this.sessionId, event)); } private onToolCallStarted(event: AgentEventPayloads['tool.call.started']): void { - if (this.driverFor(event.turnId) === undefined) return; + if (!this.forwardingFor(event.turnId)) return; // The klient payload mirrors `ToolCallStartedEvent` (`args` / `display` // arrive as `unknown` — cast at this seam). const mapped = event as unknown as ToolCallStartedEvent; @@ -787,7 +853,7 @@ export class AcpSession { } private onToolCallDelta(event: AgentEventPayloads['tool.call.delta']): void { - if (this.driverFor(event.turnId) === undefined) return; + if (!this.forwardingFor(event.turnId)) return; // The klient payload mirrors `ToolCallDeltaEvent` field-for-field. const mapped = event as unknown as ToolCallDeltaEvent; const key = acpToolCallId(event.turnId, event.toolCallId); @@ -809,7 +875,7 @@ export class AcpSession { } private onToolProgress(event: AgentEventPayloads['tool.progress']): void { - if (this.driverFor(event.turnId) === undefined) return; + if (!this.forwardingFor(event.turnId)) return; // The klient payload mirrors `ToolProgressEvent` field-for-field; the // helper forwards only `status` updates with text (as a title refresh) // and returns null for everything else, which `emit` drops. @@ -817,7 +883,7 @@ export class AcpSession { } private onToolResult(event: AgentEventPayloads['tool.result']): void { - if (this.driverFor(event.turnId) === undefined) return; + if (!this.forwardingFor(event.turnId)) return; const key = acpToolCallId(event.turnId, event.toolCallId); const locations = this.toolLocations.get(key); this.toolLocations.delete(key); @@ -905,8 +971,11 @@ export class AcpSession { } private onTurnEnded(event: AgentEventPayloads['turn.ended']): void { - const driver = this.driverFor(event.turnId); - if (driver === undefined) return; + // Only a client-prompt turn has a driver to settle; an internal turn's + // `turn.ended` has no wire representation (the existing dispatch path maps + // turn settlement to the prompt response, not a `session/update`). + const driver = this.driver; + if (driver === undefined || driver.turnId !== event.turnId) return; const error = event.error as { readonly code: string; readonly message?: string } | undefined; this.settleDriver(driver, () => { // Auth failures must surface as a JSON-RPC `auth_required` error diff --git a/packages/acp-server/src/start.ts b/packages/acp-server/src/start.ts index 47835ecee5..8539086214 100644 --- a/packages/acp-server/src/start.ts +++ b/packages/acp-server/src/start.ts @@ -20,6 +20,8 @@ import { drainSessionIndexMirror, drainSessionMetadataWrites, getLiveSessionById, + IAgentLifecycleService, + IAgentPermissionModeService, IAppendLogStore, ISessionContext, ISessionIndexMirror, @@ -153,6 +155,20 @@ export async function runAcpServerWithStream( ? undefined : sessionMediaOriginalsDir(handle.accessor.get(ISessionContext).sessionDir); }, + // Seed the advertised ACP mode from the engine's LIVE permission mode + // (Agent-scope `IAgentPermissionModeService.mode` — the same read + // kap-server's prompt route uses). Reads the live scope tree because the + // klient facade has no permission getter; a restored session reports its + // persisted `permission.set_mode` override, not the config default. The + // session falls back to the `defaultPermissionMode` config when this + // yields undefined. + resolvePermissionMode: (sessionId) => { + const sessionHandle = getLiveSessionById(core.accessor, sessionId); + if (sessionHandle === undefined) return undefined; + const agentHandle = sessionHandle.accessor.get(IAgentLifecycleService).get('main'); + if (agentHandle === undefined) return undefined; + return agentHandle.accessor.get(IAgentPermissionModeService).mode; + }, }); let closePromise: Promise | undefined; diff --git a/packages/acp-server/test/_helpers/fakeModelConfig.ts b/packages/acp-server/test/_helpers/fakeModelConfig.ts index 4190b57940..beefde2320 100644 --- a/packages/acp-server/test/_helpers/fakeModelConfig.ts +++ b/packages/acp-server/test/_helpers/fakeModelConfig.ts @@ -31,6 +31,12 @@ export interface FakeModelConfigOptions { readonly altThinking?: boolean; readonly altSupportEfforts?: readonly string[]; readonly altDefaultEffort?: string; + /** + * Declare the engine's default permission posture + * (`defaultPermissionMode` config domain) — the mode a fresh main agent + * starts at. Absent → the engine's `'manual'` default. + */ + readonly defaultPermissionMode?: 'manual' | 'auto' | 'yolo'; } const configToml = (options?: FakeModelConfigOptions): string => { @@ -40,9 +46,9 @@ const configToml = (options?: FakeModelConfigOptions): string => { const altThinking = options?.altThinking === true; const altEfforts = options?.altSupportEfforts; const altDefaultEffort = options?.altDefaultEffort; + const defaultPermissionMode = options?.defaultPermissionMode; return `defaultModel = "${FAKE_MODEL_ID}" - -[models.${FAKE_MODEL_ID}] +${defaultPermissionMode !== undefined ? `defaultPermissionMode = "${defaultPermissionMode}"\n` : ''}[models.${FAKE_MODEL_ID}] name = "fake-model" protocol = "openai" baseUrl = "http://localhost" diff --git a/packages/acp-server/test/close.test.ts b/packages/acp-server/test/close.test.ts index 21a2bde25e..5fb3b1e9ee 100644 --- a/packages/acp-server/test/close.test.ts +++ b/packages/acp-server/test/close.test.ts @@ -1,14 +1,17 @@ -import { mkdtemp, rm } from 'node:fs/promises'; +import { mkdtemp, readdir, readFile, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; import { createTestClient, type TestClient } from './_helpers/acpClient'; +import { writeFakeModelConfig } from './_helpers/fakeModelConfig'; +import { createScriptedProvider, type ScriptedProvider } from './_helpers/scriptedProvider'; describe('acp-server session/close', () => { let homeDir: string | undefined; let client: TestClient | undefined; + let scripted: ScriptedProvider | undefined; afterEach(async () => { if (client !== undefined) { @@ -61,4 +64,47 @@ describe('acp-server session/close', () => { }, 30_000, ); + + it( + 'close-after-prompt flushes the assistant wire journal before disposing the agent scope', + async () => { + homeDir = await mkdtemp(join(tmpdir(), 'acp-close-')); + await writeFakeModelConfig(homeDir); + scripted = createScriptedProvider(); + client = await createTestClient({ homeDir, extraSeeds: [scripted.seed] }); + const c = client; + await c.send('initialize', { protocolVersion: 1, clientCapabilities: {} }); + + const created = (await c.send('session/new', { cwd: homeDir, mcpServers: [] })) as { + sessionId: string; + }; + await c.waitForSessionUpdate('available_commands_update', 10_000); + + scripted!.mockNextText('hello from the scripted model'); + const result = (await c.send('session/prompt', { + sessionId: created.sessionId, + prompt: [{ type: 'text', text: 'say hi' }], + })) as { stopReason: string }; + expect(result.stopReason).toBe('end_turn'); + + // The prompt response settles on `turn.ended`, but the loop's + // `content.part` / `turn.ended` wire records are still queued behind the + // async blob offload. `session/close` must flush them before the agent + // scope is disposed — otherwise the journal is missing the tail and the + // assistant output is lost (Refs #2727). + await c.send('session/close', { sessionId: created.sessionId }); + // The wire journal lives under the session's workspace bucket: + // `/sessions///agents/main/wire.jsonl`. + const sessionsRoot = join(homeDir, 'sessions'); + const [bucket] = await readdir(sessionsRoot); + expect(bucket).toBeDefined(); + const journal = await readFile( + join(sessionsRoot, bucket!, created.sessionId, 'agents', 'main', 'wire.jsonl'), + 'utf8', + ); + expect(journal).toContain('hello from the scripted model'); + expect(journal).toContain('turn.ended'); + }, + 30_000, + ); }); diff --git a/packages/acp-server/test/config.test.ts b/packages/acp-server/test/config.test.ts index 2b6e14e84c..c3dd2831eb 100644 --- a/packages/acp-server/test/config.test.ts +++ b/packages/acp-server/test/config.test.ts @@ -48,6 +48,7 @@ describe('acp-server config surface', () => { altThinking?: boolean; altSupportEfforts?: readonly string[]; altDefaultEffort?: string; + defaultPermissionMode?: 'manual' | 'auto' | 'yolo'; }): Promise { homeDir = await mkdtemp(join(tmpdir(), 'acp-config-')); if (opts?.fakeModel === true) { @@ -58,6 +59,7 @@ describe('acp-server config surface', () => { altThinking: opts?.altThinking === true, altSupportEfforts: opts?.altSupportEfforts, altDefaultEffort: opts?.altDefaultEffort, + defaultPermissionMode: opts?.defaultPermissionMode, }); } client = await createTestClient({ homeDir }); @@ -103,6 +105,62 @@ describe('acp-server config surface', () => { 30_000, ); + it( + 'session/new seeds the mode from the engine permission posture (defaultPermissionMode yolo)', + async () => { + // The engine applies `defaultPermissionMode` at main-agent bootstrap, so + // the advertised mode must be the engine's live posture — not the stale + // DEFAULT_MODE_ID ('default') the session used to project. + await boot({ fakeModel: true, defaultPermissionMode: 'yolo' }); + const { configOptions, modes } = await newSession(); + const mode = configOptions.find((o) => o.id === 'mode')!; + expect(mode.currentValue).toBe('yolo'); + expect(modes?.currentModeId).toBe('yolo'); + }, + 30_000, + ); + + it( + 'session/load re-seeds the mode from the restored engine permission posture', + async () => { + await boot({ fakeModel: true, defaultPermissionMode: 'yolo' }); + const { sessionId } = await newSession(); + // The bootstrap `permission.set_mode` op persisted; close then load so + // the engine re-materializes the session and replays the op. + await client!.send('session/close', { sessionId }); + const loaded = (await client!.send('session/load', { + sessionId, + cwd: homeDir, + mcpServers: [], + })) as NewSessionResult; + expect(loaded.modes?.currentModeId).toBe('yolo'); + expect(loaded.configOptions.find((o) => o.id === 'mode')?.currentValue).toBe('yolo'); + }, + 30_000, + ); + + it( + 'session/load reflects a persisted session/set_mode override, not the config default', + async () => { + // No defaultPermissionMode configured → the engine runs 'manual'. A + // client-driven `session/set_mode auto` persists a `permission.set_mode` + // op; after close + load the restored engine state must win over the + // config default. + await boot({ fakeModel: true }); + const { sessionId } = await newSession(); + await client!.send('session/set_mode', { sessionId, modeId: 'auto' }); + await client!.send('session/close', { sessionId }); + const loaded = (await client!.send('session/load', { + sessionId, + cwd: homeDir, + mcpServers: [], + })) as NewSessionResult; + expect(loaded.modes?.currentModeId).toBe('auto'); + expect(loaded.configOptions.find((o) => o.id === 'mode')?.currentValue).toBe('auto'); + }, + 30_000, + ); + it( 'session/set_mode pushes current_mode_update alongside config_option_update', async () => { diff --git a/packages/acp-server/test/e2e-turn.test.ts b/packages/acp-server/test/e2e-turn.test.ts index b14a327e45..2559656bbb 100644 --- a/packages/acp-server/test/e2e-turn.test.ts +++ b/packages/acp-server/test/e2e-turn.test.ts @@ -526,6 +526,159 @@ describe('acp-server real prompt turn (scripted LLM)', () => { }, 30_000); }); +describe('acp-server internal-turn event forwarding', () => { + let homeDir: string | undefined; + let client: TestClient | undefined; + let scripted: ScriptedProvider | undefined; + + afterEach(async () => { + if (client !== undefined) { + await client.close(); + client = undefined; + } + if (homeDir !== undefined) { + await rm(homeDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + homeDir = undefined; + } + }); + + async function boot(): Promise { + homeDir = await mkdtemp(join(tmpdir(), 'acp-internal-turn-')); + await writeFakeModelConfig(homeDir); + scripted = createScriptedProvider(); + client = await createTestClient({ homeDir, extraSeeds: [scripted.seed] }); + await client.send('initialize', { protocolVersion: 1, clientCapabilities: {} }); + return client; + } + + /** The main agent's live event bus (to publish synthetic turn events). */ + function mainAgentBus(sessionId: string): { publish(event: unknown): void } { + const session = getLiveSessionById(client!.server.core.accessor, sessionId); + const agentHandle = session?.accessor.get(IAgentLifecycleService).get('main'); + const bus = agentHandle?.accessor.get(IEventBus); + expect(bus).toBeDefined(); + return { publish: (event) => bus!.publish(event as never) }; + } + + /** + * Poll the received `agent_message_chunk`s until one carries `needle` + * (chunks from a prior turn may already be on the wire, so a plain + * waitForSessionUpdate is not specific enough). + */ + async function waitForTextChunk(c: TestClient, needle: string, timeoutMs = 10_000): Promise { + const deadline = Date.now() + timeoutMs; + for (;;) { + const hit = c + .sessionUpdates() + .map((m) => (m.params as { update?: { content?: { text?: string } } }).update) + .find((u) => u?.content?.text === needle); + if (hit !== undefined) return; + if (Date.now() > deadline) { + throw new Error(`timed out waiting for an agent_message_chunk '${needle}'`); + } + await new Promise((resolve) => setTimeout(resolve, 25)); + } + } + + it('streams an internally-triggered turn (no client prompt) as session/update notifications', async () => { + const c = await boot(); + const created = (await c.send('session/new', { cwd: homeDir, mcpServers: [] })) as { + sessionId: string; + }; + await c.waitForSessionUpdate('available_commands_update', 10_000); + + // An engine turn triggered outside any `session/prompt` (cron catch-up, + // background-task notification): its events carry a turnId with no driver, + // which used to drop every handler before it reached the wire. + const bus = mainAgentBus(created.sessionId); + bus.publish({ type: 'assistant.delta', turnId: 9001, delta: 'internal cron reply' }); + const chunk = await c.waitForSessionUpdate('agent_message_chunk', 10_000); + const update = (chunk.params as { update?: { content?: { text?: string } } }).update; + expect(update?.content?.text).toBe('internal cron reply'); + + // Tool traffic of the internal turn streams through the same dispatch path. + bus.publish({ + type: 'tool.call.started', + turnId: 9001, + toolCallId: 'call_internal', + name: 'Bash', + args: { command: 'echo internal' }, + }); + const tool = await c.waitForSessionUpdate('tool_call', 10_000); + const toolUpdate = (tool.params as { update?: { toolCallId?: string } }).update; + expect(toolUpdate?.toolCallId).toBe('9001:call_internal'); + + // A client-initiated turn afterwards still streams normally. + scripted!.mockNextText('client turn reply'); + const promptPromise = c.send('session/prompt', { + sessionId: created.sessionId, + prompt: [{ type: 'text', text: 'hi again' }], + }); + await waitForTextChunk(c, 'client turn reply'); + const result = (await promptPromise) as { stopReason: string }; + expect(result.stopReason).toBe('end_turn'); + }, 30_000); + + it('ignores turn events that are not attributable to the in-flight client prompt', async () => { + const c = await boot(); + // Park the first turn at a Bash approval so the in-flight driver's turnId + // is known and the turn stays alive while we publish the stray event. + scripted!.mockNextResponse({ + type: 'function', + id: 'call_1', + name: 'Bash', + arguments: '{"command":"echo busy_probe"}', + }); + scripted!.mockNextText('done'); + let answerPermission: ((response: unknown) => void) | undefined; + const permissionSeen = new Promise((seen) => { + c.onRequest('session/request_permission', () => { + seen(); + return new Promise((resolve) => { + answerPermission = resolve; + }); + }); + }); + + const created = (await c.send('session/new', { cwd: homeDir, mcpServers: [] })) as { + sessionId: string; + }; + await c.waitForSessionUpdate('available_commands_update', 10_000); + + const promptPromise = c.send('session/prompt', { + sessionId: created.sessionId, + prompt: [{ type: 'text', text: 'run echo' }], + }); + // Both the wire `tool_call` create (→ the client turn's turnId) and the + // parked approval mark the turn as in flight with a known id. + const [create] = await Promise.all([ + c.waitForSessionUpdate('tool_call', 10_000), + permissionSeen, + ]); + const wireId = (create.params as { update?: { toolCallId?: string } }).update?.toolCallId; + const turnId = Number(wireId?.split(':')[0]); + expect(Number.isFinite(turnId)).toBe(true); + + // A turn that is neither the in-flight prompt's nor otherwise attributable + // must NOT cross the wire while the client turn runs. + mainAgentBus(created.sessionId).publish({ + type: 'assistant.delta', + turnId: turnId + 1, + delta: 'stray internal text', + }); + + answerPermission!({ outcome: { outcome: 'selected', optionId: 'approve_once' } }); + const result = (await promptPromise) as { stopReason: string }; + expect(result.stopReason).toBe('end_turn'); + + const stray = c + .sessionUpdates() + .map((m) => (m.params as { update?: { content?: { text?: string } } }).update) + .filter((u) => u?.content?.text === 'stray internal text'); + expect(stray).toHaveLength(0); + }, 30_000); +}); + describe('mapPromptLaunchError', () => { it('maps an auth-coded rejection to auth_required (-32000)', () => { const error = Object.assign(new Error('Provider returned 401'), { diff --git a/packages/agent-core-v2/docs/state-manifest.d.ts b/packages/agent-core-v2/docs/state-manifest.d.ts index c8924cd53c..2fddc05b54 100644 --- a/packages/agent-core-v2/docs/state-manifest.d.ts +++ b/packages/agent-core-v2/docs/state-manifest.d.ts @@ -1060,6 +1060,7 @@ export interface AgentStateSnapshot { readonly maxCompletionTokens?: number; readonly usedContextTokens?: number; readonly maxContextTokens?: number; + readonly stallTimeoutMs?: number; readonly onTraceId?: (traceId: string | null) => void; }; readonly systemPrompt: string; diff --git a/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts b/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts index 1f356cc19d..77f9626124 100644 --- a/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts +++ b/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts @@ -146,6 +146,7 @@ export class AgentFullCompactionService extends Service implements IAgentFullCom private readonly strategy: CompactionStrategy; private _compacting: ActiveCompaction | null = null; + private deferSoftAutoCompaction = false; constructor( @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, @@ -193,8 +194,8 @@ export class AgentFullCompactionService extends Service implements IAgentFullCom }), ); this._register( - this.loopService.hooks.onDidFinishStep.register('full-compaction', async (_ctx, next) => { - await this.afterStep(); + this.loopService.hooks.onDidFinishStep.register('full-compaction', async (ctx, next) => { + await this.afterStep(ctx.finishReason === 'tool_calls'); await next(); }), ); @@ -464,6 +465,7 @@ export class AgentFullCompactionService extends Service implements IAgentFullCom this.compactionCountInTurn = 0; this.lastCompactedTokenCount = null; this.consecutiveOverflowCompactions = 0; + this.deferSoftAutoCompaction = false; } private async recoverFromContextOverflow( @@ -498,14 +500,24 @@ export class AgentFullCompactionService extends Service implements IAgentFullCom private async beforeStep(signal: AbortSignal, turnId?: number): Promise { this.activeTurnId = turnId; + const shouldBlock = this.strategy.shouldBlock(this.tokenCountWithPending()); + if (this.deferSoftAutoCompaction && !shouldBlock && this._compacting === null) { + this.deferSoftAutoCompaction = false; + return; + } + this.deferSoftAutoCompaction = false; this.checkAutoCompaction(); - if (this.strategy.shouldBlock(this.tokenCountWithPending())) { + if (this._compacting !== null || shouldBlock) { await this.block(signal, turnId); } } - private async afterStep(): Promise { + private async afterStep(hasToolContinuation: boolean): Promise { this.consecutiveOverflowCompactions = 0; + if (hasToolContinuation) { + this.deferSoftAutoCompaction = true; + return; + } if (this.strategy.checkAfterStep) { this.checkAutoCompaction(false); } @@ -547,6 +559,7 @@ export class AgentFullCompactionService extends Service implements IAgentFullCom await active.promise; } catch (error) { if (this.wasBlockingWaitAborted(active, signal, error)) return; + if (isAbortError(error) && signal?.aborted !== true) return; throw error; } } @@ -785,7 +798,8 @@ export class AgentFullCompactionService extends Service implements IAgentFullCom if ( isError2(error) && (error.code === ErrorCodes.AUTH_LOGIN_REQUIRED || - error.code === ErrorCodes.PROVIDER_AUTH_ERROR) + error.code === ErrorCodes.PROVIDER_AUTH_ERROR || + error.code === ErrorCodes.CONTEXT_OVERFLOW) ) { throw error; } diff --git a/packages/agent-core-v2/src/agent/loop/loopService.ts b/packages/agent-core-v2/src/agent/loop/loopService.ts index 68d3f4a700..a6428ae333 100644 --- a/packages/agent-core-v2/src/agent/loop/loopService.ts +++ b/packages/agent-core-v2/src/agent/loop/loopService.ts @@ -840,7 +840,7 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { try { response = await request.result; } catch (error) { - this.appendInterruptedStreamContent(turnId, currentStep, stepUuid, streamParts, turnSignal); + this.appendInterruptedStreamContent(turnId, currentStep, stepUuid, streamParts, turnSignal, error); throw error; } this.lastRequestTraceId = request.trace.traceId; @@ -912,8 +912,9 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { stepUuid: string, streamParts: StreamPartCollector, turnSignal: AbortSignal, + error?: unknown, ): void { - if (!turnSignal.aborted) return; + if (!turnSignal.aborted && !isAbortError(error)) return; for (const part of streamParts.drainInterruptedContent()) { this.context.appendLoopEvent({ type: 'content.part', diff --git a/packages/agent-core-v2/src/agent/permissionRules/matchesRule.ts b/packages/agent-core-v2/src/agent/permissionRules/matchesRule.ts index d67ca9d409..e098ac4cc8 100644 --- a/packages/agent-core-v2/src/agent/permissionRules/matchesRule.ts +++ b/packages/agent-core-v2/src/agent/permissionRules/matchesRule.ts @@ -77,7 +77,7 @@ export function matchPermissionRule({ return { rule, strategy: 'tool_name_only', hasRuleArgs: false }; } - return execution.matchesRule?.(parsed.argPattern) === true + return execution.matchesRule?.(parsed.argPattern, { decision: rule.decision }) === true ? { rule, strategy: 'matches_rule', hasRuleArgs: true } : undefined; } diff --git a/packages/agent-core-v2/src/agent/toolActivation/toolActivationService.ts b/packages/agent-core-v2/src/agent/toolActivation/toolActivationService.ts index 886f646fcc..96dc0acae3 100644 --- a/packages/agent-core-v2/src/agent/toolActivation/toolActivationService.ts +++ b/packages/agent-core-v2/src/agent/toolActivation/toolActivationService.ts @@ -7,7 +7,12 @@ * AND the bound Profile's tool policy (`profile`), it resolves the * Agent-scope service through the container — nothing constructs the tool * before this `accessor.get` — and registers the real instance into the - * runtime registry. + * runtime registry. The `select_tools` disclosure tool is exempt from the + * profile allowlist — it stays registered for the main agent regardless of + * which profile is bound, the same carve-out `toolSelect` and `toolPolicy` + * already apply to it — while the workspace veto, an explicit + * `disallowedTools` opt-out, and the contribution's `when` predicate still + * gate it. * * The fold is incremental: `view.onDidChange` re-folds deltas — an `added` * record walks the same activation judgment, a `removed` record (provider @@ -38,9 +43,10 @@ import { LifecycleScope } from '#/app/scopes'; import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { IEventBus } from '#/app/event/eventBus'; import { IAgentProfileService } from '#/agent/profile/profile'; -import { isToolActive } from '#/agent/toolPolicy/evaluate'; +import { isToolActive, type ToolActivationPolicy } from '#/agent/toolPolicy/evaluate'; import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry'; import { AgentToolContribution } from '#/agent/toolRegistry/toolContribution'; +import { SELECT_TOOLS_TOOL_NAME } from '#/agent/toolSelect/toolSelect'; import { ISessionToolPolicyGate } from '#/session/sessionToolPolicyGate/sessionToolPolicyGate'; import { IAgentToolActivationService } from './toolActivation'; @@ -90,7 +96,11 @@ export class AgentToolActivationService extends Service implements IAgentToolAct const source = options.source ?? 'builtin'; if (this.toolRegistry.resolve(options.name) !== undefined) continue; if (!isToolActive(workspaceVeto, options.name, source)) continue; - if (!isToolActive(policy, options.name, source)) continue; + const profilePolicy: ToolActivationPolicy = + options.name === SELECT_TOOLS_TOOL_NAME + ? { disallowedTools: policy.disallowedTools } + : policy; + if (!isToolActive(profilePolicy, options.name, source)) continue; if (options.when !== undefined && !options.when(accessor)) continue; const tool = accessor.get(id); const registration = this.toolRegistry.register(tool, { diff --git a/packages/agent-core-v2/src/agent/tools/os/bash/bashTool.ts b/packages/agent-core-v2/src/agent/tools/os/bash/bashTool.ts index 2454af736f..ac0067b74c 100644 --- a/packages/agent-core-v2/src/agent/tools/os/bash/bashTool.ts +++ b/packages/agent-core-v2/src/agent/tools/os/bash/bashTool.ts @@ -16,6 +16,8 @@ * the Task* tools being active * - `config` — `IConfigService`, task config (auto-background on * timeout, detach timeout) + * - `bashParser` — `IBashParserService`, decomposes the command into + * sub-commands for permission-rule matching * * Execution goes through `ISessionProcessRunner`, never directly via * `node:child_process`. @@ -54,7 +56,9 @@ import { } from '#/tool/result-builder'; import { registerAgentToolService } from '#/agent/toolRegistry/toolContribution'; import { toInputJsonSchema } from '#/tool/input-schema'; -import { literalRulePattern, matchesGlobRuleSubject } from '#/tool/rule-match'; +import { literalRulePattern } from '#/tool/rule-match'; +import { IBashParserService } from '#/app/bashParser/bashParser'; +import { createCommandPartsProvider, matchesDecomposedCommandRule } from './commandParts'; import { renderPrompt } from '#/_base/utils/render-prompt'; import { userCancellationReason } from '#/_base/utils/abort'; import bashDescriptionTemplate from './bash.md?raw'; @@ -138,6 +142,7 @@ export class BashTool implements IBashTool { @IAgentTaskService private readonly tasks: IAgentTaskService, @IAgentToolPolicyService private readonly toolPolicy: IAgentToolPolicyService, @IConfigService private readonly config: IConfigService, + @IBashParserService private readonly bashParser: IBashParserService, ) { this.isWindowsBash = this.env.osKind === 'Windows'; this.renderedDescription = renderBashDescription(this.env.shellName); @@ -171,6 +176,7 @@ export class BashTool implements IBashTool { resolveExecution(args: BashInput): ToolExecution { const preview = args.command.length > 50 ? `${args.command.slice(0, 50)}…` : args.command; + const commandParts = createCommandPartsProvider(this.bashParser, args.command); return { description: args.run_in_background ? `Starting background: ${preview}` @@ -183,7 +189,8 @@ export class BashTool implements IBashTool { language: 'bash', }, approvalRule: literalRulePattern(this.name, args.command), - matchesRule: (ruleArgs) => matchesGlobRuleSubject(ruleArgs, args.command), + matchesRule: (ruleArgs, context) => + matchesDecomposedCommandRule(ruleArgs, args.command, context?.decision, commandParts), execute: ({ signal, onUpdate, onForegroundTaskStart }) => this.execution(args, signal, onUpdate, onForegroundTaskStart), }; diff --git a/packages/agent-core-v2/src/agent/tools/os/bash/commandParts.ts b/packages/agent-core-v2/src/agent/tools/os/bash/commandParts.ts new file mode 100644 index 0000000000..5eeba82f60 --- /dev/null +++ b/packages/agent-core-v2/src/agent/tools/os/bash/commandParts.ts @@ -0,0 +1,126 @@ +/** + * `tools` domain — Bash command decomposition for permission-rule matching. + * + * Splits a parsed bash command into the executable unit texts — commands and + * test commands (with their redirections attached, and leading env-prefix + * assignments such as `DEBUG=1` stripped so the rule judges the command that + * actually runs), standalone variable assignments, redirected groups, and the + * payloads of command / process substitutions at any depth — so rule + * evaluation can judge a compound command per unit instead of as one opaque + * string. A `deny`/`ask` rule matches when the whole command or any unit + * matches. An `allow` rule auto-matches only when the command parses cleanly + * and every unit matches, or the pattern is the escaped literal of the whole + * command (the session-approval shape, which re-approves a previously + * approved compound command without letting a wildcard span operators) — + * parse failure or budget exhaustion must not fall back to whole-string + * globbing, since both are exactly the over-match being closed. An unknown + * (undefined) decision never expands into per-part matching, since that + * expansion is only sound once the allow/deny direction is known. Quoted + * operators and heredoc bodies are data, not units — extraction trusts the + * grammar, which only surfaces substitution nodes where bash would execute + * them. The tree walk is iterative because in-budget trees can still be + * thousands of levels deep. Collaborators: parses through `bashParser`, + * matches through `rule-match`. Pure functions plus a memoizing provider; no + * scoped service. + */ + +import type { BashSyntaxNode, IBashParserService } from '#/app/bashParser/bashParser'; +import { escapeRuleSubjectLiteral, matchesGlobRuleSubject } from '#/tool/rule-match'; +import type { RuleMatchDecision } from '#/tool/toolContract'; + +const BASH_RULE_PARSE_OPTIONS = { timeoutMs: 100, maxNodes: 50_000 } as const; + +const COMMAND_LIKE_TYPES: ReadonlySet = new Set([ + 'command', + 'test_command', + 'declaration_command', + 'unset_command', +]); + +const SUBSTITUTION_TYPES: ReadonlySet = new Set([ + 'command_substitution', + 'process_substitution', +]); + +export function matchesDecomposedCommandRule( + ruleArgs: string, + command: string, + decision: RuleMatchDecision | undefined, + parts: () => readonly string[] | null, +): boolean { + if (decision === 'allow') { + const resolved = parts(); + if ( + resolved !== null && + resolved.length > 0 && + resolved.every((part) => matchesGlobRuleSubject(ruleArgs, part)) + ) { + return true; + } + return ruleArgs === escapeRuleSubjectLiteral(command); + } + if (matchesGlobRuleSubject(ruleArgs, command)) return true; + if (decision === undefined) return false; + const resolved = parts(); + return resolved !== null && resolved.some((part) => matchesGlobRuleSubject(ruleArgs, part)); +} + +export function createCommandPartsProvider( + parser: IBashParserService, + command: string, +): () => readonly string[] | null { + let cached: readonly string[] | null | undefined; + return () => { + if (cached === undefined) cached = computeCommandParts(parser, command); + return cached; + }; +} + +function computeCommandParts(parser: IBashParserService, command: string): readonly string[] | null { + const parsed = parser.parse(command, BASH_RULE_PARSE_OPTIONS); + if (!parsed.ok || parsed.hasError) return null; + return extractCommandParts(parsed.root); +} + +export function extractCommandParts(root: BashSyntaxNode): string[] { + const parts: string[] = []; + const stack: Array = [[root, false]]; + while (stack.length > 0) { + const [node, covered] = stack.pop()!; + let childrenCovered = covered; + if (SUBSTITUTION_TYPES.has(node.type)) { + childrenCovered = false; + } else if (COMMAND_LIKE_TYPES.has(node.type)) { + if (!covered) parts.push(executableText(node)); + childrenCovered = true; + } else if (node.type === 'variable_assignment') { + if (!covered) parts.push(node.text); + childrenCovered = true; + } else if (node.type === 'redirected_statement') { + if (!covered) parts.push(node.text); + childrenCovered = node.children.some( + (child) => child.isNamed && COMMAND_LIKE_TYPES.has(child.type), + ); + } + for (let i = node.children.length - 1; i >= 0; i -= 1) { + const child = node.children[i]!; + if (child.isNamed) stack.push([child, childrenCovered]); + } + } + return parts; +} + +function executableText(node: BashSyntaxNode): string { + const assignments = leadingAssignments(node); + if (assignments.length === 0) return node.text; + return node.text.slice(assignments[assignments.length - 1]!.endIndex).trim(); +} + +function leadingAssignments(node: BashSyntaxNode): BashSyntaxNode[] { + const assignments: BashSyntaxNode[] = []; + for (const child of node.children) { + if (child.type === 'variable_assignment') assignments.push(child); + else break; + } + return assignments; +} diff --git a/packages/agent-core-v2/src/app/externalHooksRunner/externalHooksRunner.ts b/packages/agent-core-v2/src/app/externalHooksRunner/externalHooksRunner.ts index 95d273dde0..474e49a9b0 100644 --- a/packages/agent-core-v2/src/app/externalHooksRunner/externalHooksRunner.ts +++ b/packages/agent-core-v2/src/app/externalHooksRunner/externalHooksRunner.ts @@ -3,10 +3,10 @@ * configured external hooks. * * A single App-scope executor owns the configured-hook lifecycle (load from - * `IConfigService` + `IPluginService`, reload on plugin change) and runs - * matching hooks. Per-scope observers inject this runner and pass per-call - * caller facts (`cwd`, `sessionId`, `signal`, matcher/payload) at trigger - * time, so the runner itself holds no per-scope state. + * `IConfigService` + `IPluginService`, reload on plugin change or config + * change) and runs matching hooks. Per-scope observers inject this runner and + * pass per-call caller facts (`cwd`, `sessionId`, `signal`, matcher/payload) + * at trigger time, so the runner itself holds no per-scope state. */ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; @@ -24,7 +24,7 @@ export interface ExternalHooksRunnerTriggerArgs { export interface IExternalHooksRunnerService { readonly _serviceBrand: undefined; readonly ready: Promise; - /** Fired after the hook index is (re)built — initial load and plugin reloads. */ + /** Fired after the hook index is (re)built — initial load, plugin reloads, and config changes. */ readonly onDidReload: Event; trigger(event: string, args?: ExternalHooksRunnerTriggerArgs): Promise; triggerBlock( diff --git a/packages/agent-core-v2/src/app/externalHooksRunner/externalHooksRunnerService.ts b/packages/agent-core-v2/src/app/externalHooksRunner/externalHooksRunnerService.ts index 6bacea0fde..bca01b3453 100644 --- a/packages/agent-core-v2/src/app/externalHooksRunner/externalHooksRunnerService.ts +++ b/packages/agent-core-v2/src/app/externalHooksRunner/externalHooksRunnerService.ts @@ -3,8 +3,14 @@ * * Owns the configured-hook lifecycle: builds the event→hooks index from * `IConfigService` (`[[hooks]]`) + `IPluginService.enabledHooks()`, reloads it - * on `plugin.onDidReload`, and dispatches each trigger through the pure - * `runMatchedHooks`. The App-scope `IHostProcessService` is injected here and + * on plugin reload and on config change (the interactive TUI can (re)load + * `config.toml` into the layered config after app-scope construction, so the + * index must be rebuilt or late `[[hooks]]` would silently never fire, #2779), + * and dispatches each trigger through the pure + * `runMatchedHooks`. Rebuilds are serialized on a chain that triggers await, + * and a burst of config events coalesces into a single rebuild; a failed + * rebuild is logged (not silently swallowed) and the runner keeps failing + * open. The App-scope `IHostProcessService` is injected here and * threaded down to `runHook`, so hook commands spawn through the shared host * process service (cross-platform kill, hidden console on Windows) rather than * `node:child_process` directly. Per-call caller facts (`cwd` defaulting to @@ -18,8 +24,10 @@ import { Disposable } from '#/_base/di/lifecycle'; import { LifecycleScope } from '#/app/scopes'; import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { Emitter, type Event } from '#/_base/event'; +import { toErrorMessage } from '#/_base/errors/errorMessage'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { IConfigService } from '#/app/config/config'; +import { ILogService } from '#/_base/log/log'; import { IPluginService } from '#/app/plugin/plugin'; import { HOOKS_SECTION, type HookDefConfig } from '#/agent/externalHooks/configSection'; import type { HookBlockDecision, HookDef, HookResult } from '#/agent/externalHooks/types'; @@ -38,6 +46,11 @@ export class ExternalHooksRunnerService extends Disposable implements IExternalH private byEvent = new Map(); readonly ready: Promise; + // Serializes index rebuilds (a trigger that lands right after a config + // change awaits the newest rebuild) and coalesces a burst of config events + // into a single rebuild — only the final config state matters. + private reloadChain: Promise = Promise.resolve(); + private reloadQueued = false; private readonly _onDidReload = this._register(new Emitter()); readonly onDidReload: Event = this._onDidReload.event; @@ -47,15 +60,28 @@ export class ExternalHooksRunnerService extends Disposable implements IExternalH @IPluginService private readonly plugins: IPluginService, @IBootstrapService private readonly bootstrap: IBootstrapService, @IHostProcessService private readonly hostProcess: IHostProcessService, + @ILogService private readonly log: ILogService, private readonly callbacks: HookRunCallbacks = {}, ) { super(); this.ready = this.loadSafe(); this._register( this.plugins.onDidReload(() => { - void this.reloadSafe(); + this.queueReload(); }), ); + // Rebuild the hook index when the config changes, not just on plugin + // reload: the user config file (`config.toml`) can be (re)loaded into the + // layered config service after this runner's initial load, and in the + // interactive TUI `[[hooks]]` may arrive late (#2779). The member check + // keeps the subscription a no-op against partial `IConfigService` stubs. + if (this.config.onDidChangeConfiguration !== undefined) { + this._register( + this.config.onDidChangeConfiguration(() => { + this.queueReload(); + }), + ); + } } get summary(): Record { @@ -101,6 +127,7 @@ export class ExternalHooksRunnerService extends Disposable implements IExternalH args: ExternalHooksRunnerTriggerArgs, ): Promise { await this.ready; + await this.reloadChain; return runMatchedHooks( this.hostProcess, this.byEvent, @@ -120,13 +147,22 @@ export class ExternalHooksRunnerService extends Disposable implements IExternalH private async loadSafe(): Promise { try { await this.load(); - } catch {} + } catch (error) { + this.log.error('external hooks: failed to load the hook index', { + error: toErrorMessage(error), + }); + } } - private async reloadSafe(): Promise { - try { - await this.load(); - } catch {} + private queueReload(): void { + if (this.reloadQueued) return; + this.reloadQueued = true; + this.reloadChain = this.reloadChain + .catch(() => undefined) + .then(() => { + this.reloadQueued = false; + return this.loadSafe(); + }); } private async load(): Promise { diff --git a/packages/agent-core-v2/src/kosong/contract/errors.ts b/packages/agent-core-v2/src/kosong/contract/errors.ts index f3ce77978d..1edd9dd38d 100644 --- a/packages/agent-core-v2/src/kosong/contract/errors.ts +++ b/packages/agent-core-v2/src/kosong/contract/errors.ts @@ -300,6 +300,10 @@ const CONTEXT_OVERFLOW_MESSAGE_PATTERNS = [ /prompt is too long.*maximum/, /input token count.*exceeds?.*maximum number of tokens/, /request.*exceed(?:ed|s|ing)?.*model token limit/, + // Moonshot's managed API answers context-window violations with a 401 whose + // body names the model's window ("401 k3-256k supports only 256K context."); + // the status alone looks like auth, the message is the discriminator. + /supports only \d+(?:\.\d+)?\s*(?:k|m|b)?\s*(?:context|window|tokens)/, ] as const; const PROVIDER_RATE_LIMIT_MESSAGE_PATTERNS = [ @@ -404,7 +408,12 @@ export function parseTraceId(headers: unknown): string | null { } export function isContextOverflowStatusError(statusCode: number, message: string): boolean { - if (statusCode !== 400 && statusCode !== 413 && statusCode !== 422) return false; + // 401 is the Kimi managed API's status for plan/capability rejections — a + // context-window limit is one of them, so a message-matched 401 classifies + // as overflow; a plain 401 stays auth. + if (statusCode !== 400 && statusCode !== 401 && statusCode !== 413 && statusCode !== 422) { + return false; + } const lowerMessage = message.toLowerCase(); return CONTEXT_OVERFLOW_MESSAGE_PATTERNS.some((pattern) => pattern.test(lowerMessage)); } diff --git a/packages/agent-core-v2/src/kosong/contract/generate.ts b/packages/agent-core-v2/src/kosong/contract/generate.ts index 3fbecb6e15..73584f1bd8 100644 --- a/packages/agent-core-v2/src/kosong/contract/generate.ts +++ b/packages/agent-core-v2/src/kosong/contract/generate.ts @@ -5,8 +5,8 @@ * `ChatProvider.generate` and normalize the event stream": it merges streamed * deltas into a complete assistant `Message`, fires the caller's callbacks, * enforces the abort contract (standard abort DOMException, stream cancelled - * on abort), and rejects empty or thinking-only responses with - * `APIEmptyResponseError`. + * on abort), bounds silent stream idle gaps with the stall watchdog, and + * rejects empty or thinking-only responses with `APIEmptyResponseError`. */ import { APIEmptyResponseError, createAbortError } from './errors'; @@ -20,6 +20,7 @@ import { type ToolCall, } from './message'; import type { ChatProvider, FinishReason, GenerateOptions, StreamedMessage } from './provider'; +import { applyStreamStallTimeout, cancelStream, resolveStreamStallTimeoutMs } from './stallTimeout'; import type { Tool } from './tool'; import type { TokenUsage } from './usage'; @@ -68,12 +69,21 @@ export async function generate( await throwIfAborted(options?.signal, stream); + const stallTimeoutMs = resolveStreamStallTimeoutMs(options?.stallTimeoutMs); + const iterable = + stallTimeoutMs > 0 + ? applyStreamStallTimeout(stream, { + timeoutMs: stallTimeoutMs, + signal: options?.signal, + }) + : stream; + let serverDecodeMs = 0; let clientConsumeMs = 0; let firstPartAt: number | undefined; let lastResumeAt = 0; - for await (const part of stream) { + for await (const part of iterable) { const arrivedAt = Date.now(); if (firstPartAt === undefined) { firstPartAt = arrivedAt; @@ -181,23 +191,6 @@ export async function generate( return result; } -type CancelableStream = StreamedMessage & { - cancel?: () => unknown; - return?: () => unknown; -}; - -async function cancelStream(stream: StreamedMessage): Promise { - const cancelable = stream as CancelableStream; - - try { - await cancelable.cancel?.(); - } catch {} - - try { - await cancelable.return?.(); - } catch {} -} - async function throwIfAborted(signal?: AbortSignal, stream?: StreamedMessage): Promise { if (!signal?.aborted) { return; diff --git a/packages/agent-core-v2/src/kosong/contract/provider.ts b/packages/agent-core-v2/src/kosong/contract/provider.ts index 3d79b10039..1a975e24bf 100644 --- a/packages/agent-core-v2/src/kosong/contract/provider.ts +++ b/packages/agent-core-v2/src/kosong/contract/provider.ts @@ -98,6 +98,7 @@ export interface GenerateOptions { maxCompletionTokens?: number; usedContextTokens?: number; maxContextTokens?: number; + stallTimeoutMs?: number; onRequestStart?: () => void; onRequestSent?: () => void; onStreamEnd?: (stats?: StreamDecodeStats) => void; diff --git a/packages/agent-core-v2/src/kosong/contract/stallTimeout.ts b/packages/agent-core-v2/src/kosong/contract/stallTimeout.ts new file mode 100644 index 0000000000..aad591c10a --- /dev/null +++ b/packages/agent-core-v2/src/kosong/contract/stallTimeout.ts @@ -0,0 +1,103 @@ +/** + * `kosong/contract` domain — the stream idle-stall watchdog. + * + * Bounds the shared stream-iteration layer (`generate.ts`) against provider + * streams that silently stall (connection open, no bytes, no terminal event): + * awaiting the next part is raced against an idle window that resets on every + * received chunk, and an expired window cancels the stream and rejects the + * iteration with the standard abort DOMException — so the existing + * abort/interruption path classifies a stall like a cancellation instead of a + * provider failure. The window resolves from the per-call option first + * (`GenerateOptions.stallTimeoutMs`, where `0` disables the watchdog), then the + * `KIMI_CODE_STREAM_STALL_TIMEOUT_MS` env binding, then a sane default. + * Also exports the shared stream-cancel helper. + */ + +import { createAbortError } from './errors'; +import type { StreamedMessagePart } from './message'; +import type { StreamedMessage } from './provider'; + +export const DEFAULT_STREAM_STALL_TIMEOUT_MS = 300_000; + +export const STREAM_STALL_TIMEOUT_ENV = 'KIMI_CODE_STREAM_STALL_TIMEOUT_MS'; + +export function resolveStreamStallTimeoutMs(configured: number | undefined): number { + if (configured !== undefined) { + return configured > 0 ? configured : 0; + } + const raw = process.env[STREAM_STALL_TIMEOUT_ENV]; + if (raw !== undefined) { + const parsed = Number(raw); + if (Number.isInteger(parsed) && parsed > 0) return parsed; + if (parsed === 0) return 0; + } + return DEFAULT_STREAM_STALL_TIMEOUT_MS; +} + +export function applyStreamStallTimeout( + stream: StreamedMessage, + options: { readonly timeoutMs: number; readonly signal?: AbortSignal }, +): AsyncIterable { + const { timeoutMs, signal } = options; + return { + async *[Symbol.asyncIterator](): AsyncIterator { + const iterator = stream[Symbol.asyncIterator](); + let timer: ReturnType | undefined; + let stalled = false; + let stallReject: ((error: Error) => void) | undefined; + + const clearTimer = (): void => { + if (timer !== undefined) clearTimeout(timer); + timer = undefined; + }; + + const armTimer = (): void => { + clearTimer(); + timer = setTimeout(() => { + if (signal?.aborted) return; + const reject = stallReject; + if (reject === undefined) return; + stalled = true; + reject(createAbortError()); + }, timeoutMs); + }; + + try { + for (;;) { + const nextPromise = iterator.next(); + nextPromise.catch(() => undefined); + const idle = new Promise((_resolve, reject) => { + stallReject = reject; + armTimer(); + }); + const result = await Promise.race([nextPromise, idle]); + clearTimer(); + stallReject = undefined; + if (result.done === true) return; + yield result.value; + } + } finally { + clearTimer(); + stallReject = undefined; + if (stalled) await cancelStream(stream); + } + }, + }; +} + +type CancelableStream = StreamedMessage & { + cancel?: () => unknown; + return?: () => unknown; +}; + +export async function cancelStream(stream: StreamedMessage): Promise { + const cancelable = stream as CancelableStream; + + try { + await cancelable.cancel?.(); + } catch {} + + try { + await cancelable.return?.(); + } catch {} +} diff --git a/packages/agent-core-v2/src/kosong/model/modelAuth.ts b/packages/agent-core-v2/src/kosong/model/modelAuth.ts index e2a923edd4..5fd584d0c4 100644 --- a/packages/agent-core-v2/src/kosong/model/modelAuth.ts +++ b/packages/agent-core-v2/src/kosong/model/modelAuth.ts @@ -18,6 +18,16 @@ * that still carry a Claude marker (a `claude` substring or a bare family * word like `sonnet-latest`); clearly non-Claude names served over the * Anthropic protocol get no synthesized effort metadata. + * - The auth-readiness probe resolves credentials in two stages: explicitly + * configured credentials (inline `apiKey`, the provider's `env` bag, + * `oauth`) are resolved first, in isolation from the ambient process env, + * so a vendor key declared earlier in an endpoint chain can never outrank + * the one the user configured, and an unrelated ambient key can never + * invalidate a working oauth provider; only when nothing is configured + * anywhere does the probe fall back to `process.env` through the vendor's + * declared `apiKeyEnv` — the same source the request adapters read when + * they build the request, keeping the gate no stricter than the code it + * guards. */ import { Error2 } from '#/_base/errors/errors'; @@ -62,25 +72,25 @@ export function resolveModelAuthMaterial( } const providerAuthType = args.provider?.type ?? args.model.protocol; - const providerEndpoint = + const configuredEndpoint = providerAuthType === undefined ? {} : explainProviderEndpoint(providerAuthType, args.provider?.env ?? {}); - const providerApiKey = nonEmpty(args.provider?.apiKey) ?? nonEmpty(providerEndpoint.apiKey); - if (providerApiKey !== undefined && args.provider?.oauth !== undefined) { + const configuredApiKey = nonEmpty(args.provider?.apiKey) ?? nonEmpty(configuredEndpoint.apiKey); + if (configuredApiKey !== undefined && args.provider?.oauth !== undefined) { throw authConflictError('Provider', args.providerName); } - if (providerApiKey !== undefined) { + if (configuredApiKey !== undefined) { trace?.record( 'resolved.auth', nonEmpty(args.provider?.apiKey) !== undefined ? { kind: 'config', detail: `provider '${args.providerName}' apiKey` } : { kind: 'env', - detail: `${providerEndpoint.apiKeyEnvName ?? '?'} (provider '${args.providerName}' env bag)`, + detail: `${configuredEndpoint.apiKeyEnvName ?? '?'} (provider '${args.providerName}' env bag)`, }, ); - return { apiKey: providerApiKey }; + return { apiKey: configuredApiKey }; } if (args.provider?.oauth !== undefined) { trace?.record('resolved.auth', { @@ -92,6 +102,17 @@ export function resolveModelAuthMaterial( oauthProviderKey: args.model.providerId ?? args.model.provider, }; } + + const ambientEndpoint = + providerAuthType === undefined ? {} : explainProviderEndpoint(providerAuthType, process.env); + const ambientApiKey = nonEmpty(ambientEndpoint.apiKey); + if (ambientApiKey !== undefined) { + trace?.record('resolved.auth', { + kind: 'env', + detail: `${ambientEndpoint.apiKeyEnvName ?? '?'} (process env)`, + }); + return { apiKey: ambientApiKey }; + } trace?.record('resolved.auth', { kind: 'none', detail: 'no credential resolved at any layer (adapter construction may still read process.env)', diff --git a/packages/agent-core-v2/src/kosong/model/modelRequester.ts b/packages/agent-core-v2/src/kosong/model/modelRequester.ts index 7d2949292a..0efef26b90 100644 --- a/packages/agent-core-v2/src/kosong/model/modelRequester.ts +++ b/packages/agent-core-v2/src/kosong/model/modelRequester.ts @@ -61,6 +61,7 @@ export interface ModelRequestParams { readonly maxCompletionTokens?: number; readonly usedContextTokens?: number; readonly maxContextTokens?: number; + readonly stallTimeoutMs?: number; readonly onTraceId?: (traceId: string | null) => void; } diff --git a/packages/agent-core-v2/src/kosong/model/modelRequesterImpl.ts b/packages/agent-core-v2/src/kosong/model/modelRequesterImpl.ts index b22ed1eab7..eff98bf194 100644 --- a/packages/agent-core-v2/src/kosong/model/modelRequesterImpl.ts +++ b/packages/agent-core-v2/src/kosong/model/modelRequesterImpl.ts @@ -22,7 +22,12 @@ import { AsyncEventQueue } from '#/_base/asyncEventQueue'; import type { VideoURLPart } from '#/kosong/contract/message'; -import { APIStatusError, isAbortError, VideoUploadUnsupportedError } from '#/kosong/contract/errors'; +import { + APIContextOverflowError, + APIStatusError, + isAbortError, + VideoUploadUnsupportedError, +} from '#/kosong/contract/errors'; import { generate, type GenerateResult } from '#/kosong/contract/generate'; import type { ChatProvider, @@ -120,6 +125,7 @@ export class ModelRequesterImpl implements ModelRequester { maxCompletionTokens: params?.maxCompletionTokens, usedContextTokens: params?.usedContextTokens, maxContextTokens: params?.maxContextTokens, + stallTimeoutMs: params?.stallTimeoutMs, onRequestStart: () => { requestStartedAt = Date.now(); }, @@ -211,7 +217,10 @@ export class ModelRequesterImpl implements ModelRequester { } function isUnauthorizedStatusError(error: unknown): error is APIStatusError { - return error instanceof APIStatusError && error.statusCode === 401; + if (!(error instanceof APIStatusError) || error.statusCode !== 401) return false; + // A message-matched context-limit 401 is a context-window rejection, not a + // stale token — refreshing OAuth cannot help (issue #2613). + return !(error instanceof APIContextOverflowError); } type MutableModelRequestTiming = { -readonly [K in keyof ModelRequestTiming]: ModelRequestTiming[K] }; diff --git a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts index 4ccbbdd6f5..87e10baeb3 100644 --- a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts +++ b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts @@ -9,8 +9,9 @@ * Agent-scoped telemetry view. New logs receive a metadata * envelope while non-empty unversioned logs are rejected. Removal awaits the * agent task manager's graceful exit policy before draining turns and full - * compaction, then disposing the child scope. Fans session-level - * permission-mode switches out to every live agent. Bound at Session scope. + * compaction, flushing the replayable wire, then disposing the child scope. + * Fans session-level permission-mode switches out to every live agent. Bound + * at Session scope. * * No agent id is special here: the main agent is simply the agent created * with the conventional `MAIN_AGENT_ID`, and `fork` requires its source to @@ -268,8 +269,16 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle compaction.abortController.abort(reason); } await Promise.all([loop.settled(), compactionSettled]); - handle.dispose(); - this.onDidDisposeEmitter.fire(agentId); + try { + // `ContextModel` (and peers) dehydrate blobs asynchronously, so the + // tail `content.part` / `turn.ended` records may still be queued on + // `WireService.persistQueue`. Flush before dispose or the last records + // of a closed session are dropped (Refs #2727). + await handle.accessor.get(IWireService).flush(); + } finally { + handle.dispose(); + this.onDidDisposeEmitter.fire(agentId); + } } } diff --git a/packages/agent-core-v2/src/session/externalHooks/externalHooksService.ts b/packages/agent-core-v2/src/session/externalHooks/externalHooksService.ts index c27fb375b4..82f0d6958e 100644 --- a/packages/agent-core-v2/src/session/externalHooks/externalHooksService.ts +++ b/packages/agent-core-v2/src/session/externalHooks/externalHooksService.ts @@ -112,8 +112,8 @@ export class SessionExternalHooksService // Arm the heartbeat only once the configured-hook index has loaded and // only when the event has hooks at all, so sessions without a // SessionHeartbeat hook never hold a recurring timer. Re-sync on every - // hook-index reload (plugin reload) so late-registered heartbeat hooks - // still arm, and removed ones disarm. + // hook-index reload (plugin reload or config change) so late-registered + // heartbeat hooks still arm, and removed ones disarm. void this.runner.ready .then(() => this.syncHeartbeat()) .catch(() => undefined); diff --git a/packages/agent-core-v2/src/tool/rule-match.ts b/packages/agent-core-v2/src/tool/rule-match.ts index 94cf3720f7..de59fbd355 100644 --- a/packages/agent-core-v2/src/tool/rule-match.ts +++ b/packages/agent-core-v2/src/tool/rule-match.ts @@ -5,9 +5,15 @@ * and the rule-subject helpers (`literalRulePattern`, * `escapeRuleSubjectLiteral`, `matchesGlobRuleSubject`, * `matchesPathRuleSubject`) that tool implementations use to build their - * `matchesRule` closures and canonical rule strings. Path matching compares - * normalized path variants, so `./a`, `dir/../a`, and Windows separator or - * case variants can match the same rule. Pure functions; no scoped service. + * `matchesRule` closures and canonical rule strings. Glob matching accepts a + * subject under path-glob semantics or as opaque text where `*` also crosses + * `/`: the path-semantics attempt runs first so historical path-glob rules + * keep matching, then the subject is matched as opaque text by rewriting `/` + * to a NUL placeholder and enabling dots — NUL-bearing subjects skip that + * rewrite so distinct subjects cannot collide, and match under path-glob + * semantics only. Path matching compares normalized path variants, so `./a`, + * `dir/../a`, and Windows separator or case variants can match the same + * rule. Pure functions; no scoped service. */ import { isAbsolute, join, parse } from 'pathe'; @@ -27,19 +33,47 @@ interface PathMatchSemantics { readonly pathClass: PathClass; } +const SLASH_PLACEHOLDER = '\0'; + export function globMatch(value: string, pattern: string, options?: { nocase?: boolean }): boolean { - if (picomatch.isMatch(value, pattern, options)) return true; + if (pathSegmentGlobMatch(value, pattern, options)) return true; + + if (value.includes(SLASH_PLACEHOLDER) || pattern.includes(SLASH_PLACEHOLDER)) return false; + + const opaqueOptions = { ...options, dot: true }; + if (picomatch.isMatch(asOpaqueText(value), asOpaqueText(pattern), opaqueOptions)) return true; const normalizedValue = stripLeadingDotSlash(value); const normalizedPattern = stripLeadingDotSlash(pattern); if (normalizedValue === value && normalizedPattern === pattern) return false; - return picomatch.isMatch(normalizedValue, normalizedPattern, options); + return picomatch.isMatch( + asOpaqueText(normalizedValue), + asOpaqueText(normalizedPattern), + opaqueOptions, + ); +} + +function asOpaqueText(value: string): string { + return value.replaceAll('/', SLASH_PLACEHOLDER); } function stripLeadingDotSlash(value: string): string { return value.startsWith('./') ? value.slice(2) : value; } +function pathSegmentGlobMatch( + value: string, + pattern: string, + options?: { nocase?: boolean }, +): boolean { + if (picomatch.isMatch(value, pattern, options)) return true; + + const normalizedValue = stripLeadingDotSlash(value); + const normalizedPattern = stripLeadingDotSlash(pattern); + if (normalizedValue === value && normalizedPattern === pattern) return false; + return picomatch.isMatch(normalizedValue, normalizedPattern, options); +} + export function pathGlobMatch( value: string, pattern: string, @@ -48,11 +82,11 @@ export function pathGlobMatch( const semantics = pathMatchSemantics(value, pattern, pathOptions); const nocase = pathOptions?.caseInsensitivePaths ?? true; - if (globMatch(value, pattern, { nocase })) return true; + if (pathSegmentGlobMatch(value, pattern, { nocase })) return true; for (const valueVariant of pathVariants(value, semantics, pathOptions)) { for (const patternVariant of pathVariants(pattern, semantics, pathOptions)) { - if (globMatch(valueVariant, patternVariant, { nocase })) return true; + if (pathSegmentGlobMatch(valueVariant, patternVariant, { nocase })) return true; } } return false; diff --git a/packages/agent-core-v2/src/tool/toolContract.ts b/packages/agent-core-v2/src/tool/toolContract.ts index 5595621942..e23dc8b74a 100644 --- a/packages/agent-core-v2/src/tool/toolContract.ts +++ b/packages/agent-core-v2/src/tool/toolContract.ts @@ -74,6 +74,12 @@ export interface ExecutableToolContext { readonly onForegroundTaskStart?: ((taskId: string) => void) | undefined; } +export type RuleMatchDecision = 'allow' | 'deny' | 'ask'; + +export interface RuleMatchContext { + readonly decision: RuleMatchDecision; +} + export interface RunnableToolExecution { readonly isError?: false | undefined; readonly accesses?: ToolAccesses | undefined; @@ -81,7 +87,7 @@ export interface RunnableToolExecution { readonly description?: string; readonly stopBatchAfterThis?: boolean | undefined; readonly approvalRule: string; - readonly matchesRule?: ((ruleArgs: string) => boolean) | undefined; + readonly matchesRule?: ((ruleArgs: string, context?: RuleMatchContext) => boolean) | undefined; readonly execute: (ctx: ExecutableToolContext) => Promise; } diff --git a/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts b/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts index 604e17807e..f90bbe3669 100644 --- a/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts +++ b/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts @@ -431,11 +431,17 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec if (handle === undefined) return; await this.announceWillClose({ sessionId, handle, reason: 'exit' }); this.sessions.delete(sessionId); - await this.drainAgents(handle); - await drainSessionMetadataWrites(); - await this.indexMirror.drain(); - handle.dispose(); - this._onDidCloseSession.fire({ sessionId }); + try { + await this.drainAgents(handle); + await drainSessionMetadataWrites(); + await this.indexMirror.drain(); + } finally { + // An agent removal failure (e.g. the wire flush in + // `AgentLifecycleService.remove`) must not leak the live scope or skip + // the close announcement — dispose always runs. + handle.dispose(); + this._onDidCloseSession.fire({ sessionId }); + } } async archive(sessionId: string): Promise { diff --git a/packages/agent-core-v2/test/agent/externalHooks/runner-stub.ts b/packages/agent-core-v2/test/agent/externalHooks/runner-stub.ts index f2052b52b0..de7e922587 100644 --- a/packages/agent-core-v2/test/agent/externalHooks/runner-stub.ts +++ b/packages/agent-core-v2/test/agent/externalHooks/runner-stub.ts @@ -16,9 +16,12 @@ import { HOOKS_SECTION } from '#/agent/externalHooks/configSection'; import type { HookDef } from '#/agent/externalHooks/types'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { IConfigService } from '#/app/config/config'; +import type { ILogService } from '#/_base/log/log'; import { IPluginService } from '#/app/plugin/plugin'; import { HostProcessService } from '#/os/backends/node-local/hostProcessService'; +import { stubLog } from '../../_base/log/stubs'; + export function makeHookRunner( hooks: readonly HookDef[], options: { @@ -31,6 +34,12 @@ export function makeHookRunner( reason: string | undefined, durationMs: number, ) => void; + /** Emit when the backing config gains/loses hook sections (mirrors `IConfigService.onDidChangeConfiguration`). */ + onDidChangeConfiguration?: Event; + /** Override the plugin hook source (e.g. to make a rebuild fail). */ + enabledHooks?: () => Promise; + /** Log sink; defaults to a no-op stub. */ + log?: ILogService; } = {}, ): ExternalHooksRunnerService { return new ExternalHooksRunnerService( @@ -38,10 +47,11 @@ export function makeHookRunner( _serviceBrand: undefined, ready: Promise.resolve(), get: (section: string) => (section === HOOKS_SECTION ? hooks : undefined), + onDidChangeConfiguration: options.onDidChangeConfiguration ?? Event.None, } as unknown as IConfigService, { _serviceBrand: undefined, - enabledHooks: async () => [], + enabledHooks: options.enabledHooks ?? (async () => []), onDidReload: Event.None as IPluginService['onDidReload'], } as unknown as IPluginService, { @@ -50,6 +60,7 @@ export function makeHookRunner( clientIdentity: { productName: 'test', version: '0.0.0-test', platform: 'test_platform' }, } as unknown as IBootstrapService, new HostProcessService(), + options.log ?? stubLog(), { onTriggered: options.onTriggered, onResolved: options.onResolved }, ); } diff --git a/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts b/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts index 2aab73c8c7..ae08ff12e5 100644 --- a/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts +++ b/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts @@ -1665,6 +1665,219 @@ describe('FullCompaction', () => { await ctx.expectResumeMatches(); }); + it('blocks the turn while lower-ratio auto compaction is in flight', async () => { + const compactionRequested = deferred(); + const releaseCompaction = deferred(); + let llmCallCount = 0; + let ctx!: TestAgentContext; + const generate: GenerateFn = async () => { + llmCallCount += 1; + if (llmCallCount === 1) { + compactionRequested.resolve(); + await releaseCompaction.promise; + return textResult('Lower-ratio compacted summary.'); + } + if (llmCallCount === 2) { + return textResult('I can answer after lower-ratio compaction.'); + } + throw new Error(`Unexpected generate call ${String(llmCallCount)}`); + }; + ctx = testAgent({ + generate, + initialConfig: { + providers: {}, + loopControl: { + compactionTriggerRatio: 0.6, + reservedContextSize: 0, + }, + }, + }); + ctx.configure({ + provider: CATALOGUED_PROVIDER, + modelCapabilities: { + ...CATALOGUED_MODEL_CAPABILITIES, + max_context_tokens: 1_000_000, + }, + tools: SNAPSHOT_VISIBLE_TOOLS, + }); + ctx.appendExchange(1, 'old user one', 'old assistant one', 610_000); + + await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Answer after compacting' }] }); + await compactionRequested.promise; + await new Promise((resolve) => setImmediate(resolve)); + + expect(llmCallCount).toBe(1); + + releaseCompaction.resolve(); + const events = await ctx.untilTurnEnd(); + + expect(llmCallCount).toBe(2); + expect(countEvents(events, 'full_compaction.cancel')).toBe(0); + expect(eventIndex(events, 'full_compaction.complete')).toBeLessThan( + eventIndex(events, 'turn.step.started'), + ); + await ctx.expectResumeMatches(); + }); + + it('retries an auto compaction that loses the commit-time race instead of killing the turn', async () => { + const compactionRequested = deferred(); + const releaseCompaction = deferred(); + let llmCallCount = 0; + let ctx!: TestAgentContext; + const generate: GenerateFn = async () => { + llmCallCount += 1; + if (llmCallCount === 1) { + compactionRequested.resolve(); + await releaseCompaction.promise; + return textResult('Stale compacted summary.'); + } + if (llmCallCount === 2) { + // Realistic usage keeps the measured anchor at the full context size + // so afterStep re-triggers compaction on the current context. + return { + ...textResult('Turn reply after the raced compaction.'), + usage: { inputOther: 900_000, output: 1, inputCacheRead: 0, inputCacheCreation: 0 }, + }; + } + if (llmCallCount === 3) { + return textResult('Retried compacted summary.'); + } + throw new Error(`Unexpected generate call ${String(llmCallCount)}`); + }; + ctx = testAgent({ + generate, + initialConfig: { + providers: {}, + loopControl: { + compactionTriggerRatio: 0.6, + reservedContextSize: 0, + }, + }, + }); + ctx.configure({ + provider: CATALOGUED_PROVIDER, + modelCapabilities: { + ...CATALOGUED_MODEL_CAPABILITIES, + max_context_tokens: 1_000_000, + }, + tools: SNAPSHOT_VISIBLE_TOOLS, + }); + // 900k of a 1M window: above the 0.85 block floor even though the trigger + // is 0.6, so the first step head starts auto compaction AND blocks on it. + ctx.appendExchange(1, 'old user one', 'old assistant one', 900_000); + + await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Answer after compacting' }] }); + await compactionRequested.promise; + // Simulate the commit-time race: an assistant exchange lands while the + // auto compaction is in flight, so historySafeToCompact fails at commit. + ctx.appendExchange(2, 'new user while compacting', 'new assistant while compacting', 60_000); + await new Promise((resolve) => setImmediate(resolve)); + + releaseCompaction.resolve(); + const events = await ctx.untilTurnEnd(); + + // The raced round is cancelled, but the turn survives and the compaction + // retries at the next opportunity instead of killing the turn with an + // internal abort. + expect(countEvents(events, 'full_compaction.cancel')).toBe(1); + expect(events).toContainEqual( + expect.objectContaining({ + event: 'turn.ended', + args: expect.objectContaining({ reason: 'completed' }), + }), + ); + expect(events).not.toContainEqual( + expect.objectContaining({ + event: 'turn.ended', + args: expect.objectContaining({ reason: 'cancelled' }), + }), + ); + await vi.waitFor(() => { + expect(llmCallCount).toBeGreaterThanOrEqual(3); + expect( + ctx.allEvents.filter((event) => event.event === 'full_compaction.complete'), + ).toHaveLength(1); + }); + await ctx.expectResumeMatches(); + }); + + it('lets a tool continuation consume fresh results before soft auto compaction', async () => { + const largeResult = 'important-tool-result'; + const phases: string[] = []; + let continuationHistory: readonly Message[] = []; + let agentCallCount = 0; + const generate: GenerateFn = async (_provider, _system, _tools, history) => { + const isCompaction = history.some((message) => + messageText(message).includes('first-person handoff note'), + ); + if (isCompaction) { + phases.push('compaction'); + return textResult('Compacted after consuming the tool result.'); + } + agentCallCount += 1; + if (agentCallCount === 2) { + phases.push('continuation'); + continuationHistory = history; + return { + ...textResult('Consumed the fresh tool result.'), + usage: { inputOther: 13_000, output: 1, inputCacheRead: 0, inputCacheCreation: 0 }, + }; + } + phases.push('agent'); + return { + ...textResult(''), + message: { + role: 'assistant', + content: [], + toolCalls: [ + { + type: 'function', + id: 'call_large_result', + name: 'mcp__srv__large_result', + arguments: '{}', + }, + ], + }, + usage: { inputOther: 13_000, output: 1, inputCacheRead: 0, inputCacheCreation: 0 }, + finishReason: 'tool_calls', + rawFinishReason: 'tool_calls', + }; + }; + const ctx = testAgent({ + generate, + initialConfig: { + providers: {}, + loopControl: { + compactionTriggerRatio: 0.6, + reservedContextSize: 0, + }, + }, + }); + ctx + .get(IAgentToolRegistryService) + .register(mcpTool('mcp__srv__large_result', {}, largeResult), { source: 'mcp' }); + ctx.configure({ + provider: CATALOGUED_PROVIDER, + modelCapabilities: { + ...CATALOGUED_MODEL_CAPABILITIES, + max_context_tokens: 20_000, + }, + tools: ['mcp__srv__large_result'], + }); + const compacted = ctx.once('compaction.completed'); + + await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Inspect the large result' }] }); + await ctx.untilApproval(true); + await ctx.untilTurnEnd(); + await compacted; + + expect(phases).toEqual(['agent', 'continuation', 'compaction']); + expect(continuationHistory.some((message) => messageText(message).includes(largeResult))).toBe( + true, + ); + await ctx.expectResumeMatches(); + }); + it('attributes background auto compaction to the turn that started it', async () => { const compactionRequested = deferred(); const releaseCompaction = deferred(); @@ -3155,6 +3368,7 @@ function textMessage(role: 'user' | 'assistant', text: string): Message { function mcpTool( name: string, parameters: Record, + output: string = 'mcp ok', ): ExecutableTool> { return { name, @@ -3163,7 +3377,7 @@ function mcpTool( resolveExecution(): ToolExecution { return { approvalRule: name, - execute: async () => ({ output: 'mcp ok' }), + execute: async () => ({ output }), }; }, }; diff --git a/packages/agent-core-v2/test/agent/loop/loop.test.ts b/packages/agent-core-v2/test/agent/loop/loop.test.ts index 888ae1a8b9..2ebc7e8722 100644 --- a/packages/agent-core-v2/test/agent/loop/loop.test.ts +++ b/packages/agent-core-v2/test/agent/loop/loop.test.ts @@ -1,6 +1,6 @@ -import { type ToolCall } from '#/kosong/contract/message'; -import { emptyUsage } from '#/kosong/contract/usage'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { type ToolCall, type StreamedMessagePart } from '#/kosong/contract/message'; +import { emptyUsage, type TokenUsage } from '#/kosong/contract/usage'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { IDisposable } from '#/_base/di/lifecycle'; import { IAgentProfileService } from '#/index'; @@ -11,6 +11,14 @@ import { IAgentGoalService } from '#/agent/goal/goal'; import { IAgentLoopService, type Turn } from '#/agent/loop/loop'; import { ContinuationStepRequest, MessageStepRequest } from '#/agent/loop/stepRequest'; import { RetryStepRequest } from '#/agent/prompt/promptStepRequests'; +import type { + ChatProvider, + FinishReason, + StreamedMessage, +} from '#/kosong/contract/provider'; +import { STREAM_STALL_TIMEOUT_ENV } from '#/kosong/contract/stallTimeout'; +import { IProtocolAdapterRegistry } from '#/kosong/protocol/protocol'; +import { ProtocolAdapterRegistry } from '#/kosong/provider/protocolAdapterRegistry'; import type { ExecutableTool } from '#/tool/toolContract'; import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry'; import { IAgentUsageService } from '#/agent/usage/usage'; @@ -19,6 +27,7 @@ import { userCancellationReason } from '#/_base/utils/abort'; import { agentService, + appServices, createTestAgent, permissionModeServices, type TestAgentContext, @@ -28,6 +37,42 @@ import { recordingTelemetry, type TelemetryRecord } from '../../app/telemetry/st type GenerateFn = NonNullable; +class StalledProviderStream implements StreamedMessage { + readonly id: string | null = 'stall-1'; + readonly usage: TokenUsage | null = emptyUsage(); + readonly finishReason: FinishReason | null = 'completed'; + readonly rawFinishReason: string | null = 'stop'; + readonly traceId: string | null = null; + + async *[Symbol.asyncIterator](): AsyncIterator { + yield { type: 'text', text: 'partial answer' }; + await new Promise(() => {}); + } +} + +function stalledProviderRegistry(): IProtocolAdapterRegistry { + const real = new ProtocolAdapterRegistry(); + const provider: ChatProvider = { + name: 'kimi', + modelName: 'mock-model', + thinkingEffort: null, + generate: async (): Promise => new StalledProviderStream(), + }; + return { + _serviceBrand: undefined, + supportedProtocols: () => real.supportedProtocols(), + resolveAdapterIdentity: (protocol, providerType) => + real.resolveAdapterIdentity(protocol, providerType), + resolveProviderBaseId: (protocol, providerType) => + real.resolveProviderBaseId(protocol, providerType), + resolveCapability: (protocol, modelName, providerType) => + real.resolveCapability(protocol, modelName, providerType), + explainCapability: (protocol, modelName, providerType) => + real.explainCapability(protocol, modelName, providerType), + createChatProvider: () => provider, + } as IProtocolAdapterRegistry; +} + describe('Agent loop', () => { let ctx: TestAgentContext; let loop: IAgentLoopService; @@ -281,6 +326,47 @@ describe('Agent loop', () => { expect(called).toBe(false); }); + it('persists the partial stream and ends the turn when the provider stream stalls', async () => { + const local = createTestAgent( + appServices((reg) => + reg.defineInstance(IProtocolAdapterRegistry, stalledProviderRegistry()), + ), + ); + vi.stubEnv(STREAM_STALL_TIMEOUT_ENV, '40'); + try { + local.get(IAgentProfileService).update({ activeToolNames: [] }); + await local.rpc.prompt({ input: [{ type: 'text', text: 'Hello' }] }); + await local.untilTurnEnd(); + + const turnEnded = local.allEvents.find( + (entry) => entry.type === '[rpc]' && entry.event === 'turn.ended', + ); + expect(turnEnded?.args).toMatchObject({ turnId: 0, reason: 'cancelled' }); + + const interrupted = local.allEvents.find( + (entry) => entry.type === '[rpc]' && entry.event === 'turn.step.interrupted', + ); + expect(interrupted?.args).toMatchObject({ step: 1, reason: 'aborted' }); + + const records = await local.persistedWireRecords(); + const partialParts = records.filter( + (record) => + record.type === 'context.append_loop_event' && + (record as { event?: { type?: string } }).event?.type === 'content.part', + ); + expect(partialParts).toContainEqual( + expect.objectContaining({ + event: expect.objectContaining({ + part: { type: 'text', text: 'partial answer' }, + }), + }), + ); + } finally { + vi.unstubAllEnvs(); + await local.dispose(); + } + }); + it('fails with the error handler error when recovery throws', async () => { const recoveryError = new Error('recovery failed'); loop.registerLoopErrorHandler({ diff --git a/packages/agent-core-v2/test/agent/permissionRules/matchesRule.test.ts b/packages/agent-core-v2/test/agent/permissionRules/matchesRule.test.ts index 173f3d7575..043e59c87d 100644 --- a/packages/agent-core-v2/test/agent/permissionRules/matchesRule.test.ts +++ b/packages/agent-core-v2/test/agent/permissionRules/matchesRule.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from 'vitest'; +import { createCommandPartsProvider, matchesDecomposedCommandRule } from '#/agent/tools/os/bash/commandParts'; +import { BashParserService } from '#/app/bashParser/bashParserService'; import type { PermissionRule } from '#/agent/permissionRules/permissionRules'; import { matchPermissionRule, @@ -7,9 +9,11 @@ import { } from '#/agent/permissionRules/matchesRule'; import type { PermissionRuleMatchExecution } from '#/agent/permissionRules/matchesRule'; import { + escapeRuleSubjectLiteral, matchesGlobRuleSubject, matchesPathRuleSubject, } from '#/tool/rule-match'; +import type { RuleMatchContext, RuleMatchDecision } from '#/tool/toolContract'; function rule(pattern: string): PermissionRule { return { decision: 'allow', scope: 'user', pattern }; @@ -132,6 +136,45 @@ describe('permissionRules/matchPermissionRule', () => { expect(matches(rule('Bad(unclosed'), 'Bad', noArgs)).toBe(false); }); + it('matches glob rule subjects as opaque text rather than as paths', () => { + expect(matchesGlobRuleSubject('rm -rf*', 'rm -rf x')).toBe(true); + expect(matchesGlobRuleSubject('rm -rf*', 'rm -rf /tmp/x')).toBe(true); + expect(matchesGlobRuleSubject('rm -rf *', 'rm -rf build/x')).toBe(true); + expect(matchesGlobRuleSubject('git *', 'git commit -m "fix src/a.ts"')).toBe(true); + expect(matchesGlobRuleSubject('rm -rf*', 'rm -rf ./build')).toBe(true); + expect(matchesGlobRuleSubject('rm -rf*', 'rm -rf ~/.ssh')).toBe(true); + expect(matchesGlobRuleSubject('rm -rf*', 'rm -rf /home/u/.ssh')).toBe(true); + expect(matchesGlobRuleSubject('https://example.com/*', 'https://example.com/a/b')).toBe(true); + expect(matchesGlobRuleSubject('*acme corp*', 'news about acme corp / rivals')).toBe(true); + expect(matchesGlobRuleSubject('**rm**', 'rm -rf /tmp/x')).toBe(true); + expect(matchesGlobRuleSubject('git *', 'git status')).toBe(true); + expect(matchesGlobRuleSubject('git *', 'git2 status')).toBe(false); + expect(matchesGlobRuleSubject('rm -rf*', 'git status')).toBe(false); + expect(matchesGlobRuleSubject('git log -- src/*.ts', 'git log -- srcXx.ts')).toBe(false); + expect(matchesGlobRuleSubject('https://example.com/a', 'https://example.com/b')).toBe(false); + expect(matchesGlobRuleSubject('!git *', 'git commit -m "fix src/a.ts"')).toBe(false); + expect(matchesGlobRuleSubject('!git *', 'npm test')).toBe(true); + }); + + it('keeps historical glob matches that opaque-text semantics alone would drop', () => { + expect(matchesGlobRuleSubject('**/*.ts', 'a.ts')).toBe(true); + expect(matchesGlobRuleSubject('a/**/b', 'a/b')).toBe(true); + expect(matchesGlobRuleSubject('a/**/b', 'a/x/y/b')).toBe(true); + }); + + it('keeps NUL-bearing subjects distinct instead of conflating them', () => { + expect(matchesGlobRuleSubject('ab', 'a\u0000b')).toBe(false); + expect(matchesGlobRuleSubject('a/b', 'a\u0000b')).toBe(false); + expect(matchesGlobRuleSubject('a\u0000b', 'a\u0000b')).toBe(true); + expect(matchesGlobRuleSubject('a*', 'a\u0000b')).toBe(true); + }); + + it('keeps path rule subjects on path semantics where * does not cross /', () => { + expect(matchesPathRuleSubject('src/*', 'src/a.ts')).toBe(true); + expect(matchesPathRuleSubject('src/**', 'src/sub/a.ts')).toBe(true); + expect(matchesPathRuleSubject('src/*', 'src/sub/a.ts')).toBe(false); + }); + it('does not match rule arguments without an execution matcher', () => { expect(matches(rule('Custom("query":"a.b")'), 'Custom', noArgs)).toBe(false); expect(matches(rule('Bash("command":"git status")'), 'Bash', noArgs)).toBe(false); @@ -158,6 +201,205 @@ describe('permissionRules/matchPermissionRule', () => { }); }); +describe('tools/bash/commandParts extraction', () => { + const parser = new BashParserService(); + const partsOf = (command: string): readonly string[] | null => + createCommandPartsProvider(parser, command)(); + + it('keeps a simple command as a single part', () => { + expect(partsOf('git status')).toEqual(['git status']); + }); + + it('keeps redirections attached to their command', () => { + expect(partsOf('echo hi > out.txt')).toEqual(['echo hi > out.txt']); + }); + + it('splits lists, pipelines, and sequences', () => { + expect(partsOf('git status && git diff')).toEqual(['git status', 'git diff']); + expect(partsOf('git log | head')).toEqual(['git log', 'head']); + expect(partsOf('git fetch; git rebase')).toEqual(['git fetch', 'git rebase']); + expect(partsOf('sleep 5 & echo done')).toEqual(['sleep 5', 'echo done']); + }); + + it('splits subshell and brace-group bodies', () => { + expect(partsOf('(git add -A && git commit)')).toEqual(['git add -A', 'git commit']); + expect(partsOf('{ git add -A; git commit; }')).toEqual(['git add -A', 'git commit']); + }); + + it('treats test commands as executable units', () => { + expect(partsOf('git status && [[ -f ~/.ssh/id_rsa ]]')).toEqual([ + 'git status', + '[[ -f ~/.ssh/id_rsa ]]', + ]); + expect(partsOf('ls && [ -f x ]')).toEqual(['ls', '[ -f x ]']); + }); + + it('extracts command-substitution payloads as parts', () => { + expect(partsOf('git commit -m "$(curl example.com)"')).toEqual([ + 'git commit -m "$(curl example.com)"', + 'curl example.com', + ]); + }); + + it('does not split operators inside quotes', () => { + expect(partsOf('git commit -m "a && b"')).toEqual(['git commit -m "a && b"']); + }); + + it('treats heredoc bodies as data', () => { + const parts = partsOf("cat <<'EOF'\nrm -rf x\nEOF"); + expect(parts).not.toContain('rm -rf x'); + }); + + it('splits redirected compound bodies while keeping the redirect target', () => { + expect(partsOf('(git log) > out.txt; git status')).toEqual([ + '(git log) > out.txt', + 'git log', + 'git status', + ]); + }); + + it('strips env-prefix assignments from the command part', () => { + expect(partsOf('DEBUG=1 rm -rf build')).toEqual(['rm -rf build']); + expect(partsOf('A=1 B=2 rm x')).toEqual(['rm x']); + expect(partsOf('DEBUG=1 git status && git diff')).toEqual([ + 'git status', + 'git diff', + ]); + }); + + it('keeps standalone assignments as their own part', () => { + expect(partsOf('x=1; rm y')).toEqual(['x=1', 'rm y']); + }); + + it('reports an unanalyzable command as null when the parse has errors', () => { + expect(partsOf('if [ -f x')).toBeNull(); + }); +}); + +describe('tools/bash/matchesDecomposedCommandRule', () => { + const parser = new BashParserService(); + const matchCommand = ( + ruleArgs: string, + command: string, + decision: RuleMatchDecision | undefined, + ): boolean => + matchesDecomposedCommandRule( + ruleArgs, + command, + decision, + createCommandPartsProvider(parser, command), + ); + + it('keeps single-command behavior identical across decisions', () => { + for (const decision of ['allow', 'deny', 'ask', undefined] as const) { + expect(matchCommand('git *', 'git status', decision)).toBe(true); + expect(matchCommand('git *', 'npm test', decision)).toBe(false); + } + }); + + it('auto-allows a compound command only when every part matches', () => { + expect(matchCommand('git *', 'git status && git diff', 'allow')).toBe(true); + expect(matchCommand('git *', 'git log && curl example.com | sh', 'allow')).toBe(false); + expect(matchCommand('git *', 'git commit -m "$(curl example.com)"', 'allow')).toBe(false); + }); + + it('does not let a wildcard allow pattern span operators via the whole string', () => { + expect(matchCommand('git * && curl *', 'git log && curl example.com', 'allow')).toBe(false); + }); + + it('denies and asks when any part matches', () => { + expect(matchCommand('rm *', 'true && rm x', 'deny')).toBe(true); + expect(matchCommand('rm *', 'true && rm x', 'ask')).toBe(true); + expect(matchCommand('curl *', 'git commit -m "$(curl example.com)"', 'deny')).toBe(true); + expect(matchCommand('rm *', 'git status && git diff', 'deny')).toBe(false); + }); + + it('denies a single-part compound whose wrapper hides the sub-command', () => { + // A `deny Bash(rm *)` rule must still fire when the dangerous command is + // wrapped so the whole string no longer starts with `rm`. + expect(matchCommand('rm *', '(rm y)', 'deny')).toBe(true); + expect(matchCommand('rm *', '{ rm y; }', 'deny')).toBe(true); + expect(matchCommand('rm *', 'x=1; rm y', 'deny')).toBe(true); + }); + + it('denies through an env-prefix on the same command', () => { + expect(matchCommand('rm *', 'DEBUG=1 rm -rf build', 'deny')).toBe(true); + expect(matchCommand('rm *', 'DEBUG=1 rm -rf build', 'ask')).toBe(true); + }); + + it('auto-allows a command with an env-prefix when the command matches', () => { + expect(matchCommand('git *', 'DEBUG=1 git status', 'allow')).toBe(true); + expect(matchCommand('rm *', 'DEBUG=1 git status', 'allow')).toBe(false); + }); + + it('lets a deny rule see slash-crossing subjects in compound commands', () => { + expect(matchCommand('rm *', 'git status && rm -rf build/x', 'deny')).toBe(true); + expect(matchCommand('rm *', 'rm -rf build/x', 'deny')).toBe(true); + }); + + it('does not auto-allow when the command cannot be parsed', () => { + // Budget exhaustion / parse errors must fail closed for allow, never fall + // back to whole-string wildcard approval. The arithmetic node bomb + // exceeds the parse node budget and returns `aborted`. + const bomb = `echo $((1+${'1+'.repeat(60_000)}1))`; + expect(matchCommand('git *', bomb, 'allow')).toBe(false); + expect(matchCommand('git *', 'if [ -f x', 'allow')).toBe(false); + }); + + it('does not auto-allow a compound command that redirects into a file', () => { + expect(matchCommand('git *', '(git log) > out.txt; git status', 'allow')).toBe(false); + }); + + it('does not auto-allow when a chained test command is unmatched', () => { + // `[[ ... ]]` / `[ ... ]` are executable units; an allow rule must see them. + expect(matchCommand('git *', 'git status && [[ -f ~/.ssh/id_rsa ]]', 'allow')).toBe(false); + expect(matchCommand('git *', 'git status && [ -f secret ]', 'allow')).toBe(false); + }); + + it('keeps whole-string matching without a decision', () => { + expect(matchCommand('rm *', 'true && rm x', undefined)).toBe(false); + expect(matchCommand('git *', 'git log && curl example.com', undefined)).toBe(true); + }); + + it('round-trips session-approval literal patterns for compound commands', () => { + const command = 'git add -A && git commit'; + expect(matchCommand(escapeRuleSubjectLiteral(command), command, 'allow')).toBe(true); + expect(matchCommand(escapeRuleSubjectLiteral(command), 'git add -A && rm x', 'allow')).toBe( + false, + ); + }); + + it('matches through Bash rule patterns end to end with decision passthrough', () => { + const bashExecution = (command: string): PermissionRuleMatchExecution => ({ + matchesRule: (ruleArgs, context) => + matchesDecomposedCommandRule( + ruleArgs, + command, + context?.decision, + createCommandPartsProvider(parser, command), + ), + }); + const allowRule: PermissionRule = { decision: 'allow', scope: 'user', pattern: 'Bash(git *)' }; + const denyRule: PermissionRule = { decision: 'deny', scope: 'user', pattern: 'Bash(rm *)' }; + expect(matches(allowRule, 'Bash', bashExecution('git status && git diff'))).toBe(true); + expect(matches(allowRule, 'Bash', bashExecution('git log && curl example.com'))).toBe(false); + expect(matches(denyRule, 'Bash', bashExecution('true && rm x'))).toBe(true); + }); + + it('passes the rule decision through matchPermissionRule', () => { + let seen: RuleMatchContext | undefined; + const execution: PermissionRuleMatchExecution = { + matchesRule: (_ruleArgs, context) => { + seen = context; + return true; + }, + }; + const denyRule: PermissionRule = { decision: 'deny', scope: 'user', pattern: 'Bash(x)' }; + expect(matches(denyRule, 'Bash', execution)).toBe(true); + expect(seen).toEqual({ decision: 'deny' }); + }); +}); + function matches( permissionRule: PermissionRule, toolName: string, diff --git a/packages/agent-core-v2/test/agent/toolActivation/toolActivationService.test.ts b/packages/agent-core-v2/test/agent/toolActivation/toolActivationService.test.ts index d410dcb731..707f13780e 100644 --- a/packages/agent-core-v2/test/agent/toolActivation/toolActivationService.test.ts +++ b/packages/agent-core-v2/test/agent/toolActivation/toolActivationService.test.ts @@ -31,6 +31,7 @@ import { } from '#/agent/toolRegistry/toolContribution'; import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry'; import { AgentToolRegistryService } from '#/agent/toolRegistry/toolRegistryService'; +import { IAgentToolSelectService, SELECT_TOOLS_TOOL_NAME } from '#/agent/toolSelect/toolSelect'; import { ISessionToolPolicyGate } from '#/session/sessionToolPolicyGate/sessionToolPolicyGate'; import type { AgentTool, ToolExecution } from '#/tool/toolContract'; import '#/agent/tools/agent/agentTool'; @@ -67,10 +68,12 @@ class StubTool implements AgentTool { const IAlphaTool = createDecorator('activationTestAlphaTool'); const IBetaTool = createDecorator('activationTestBetaTool'); const IGammaTool = createDecorator('activationTestGammaTool'); +const IDisclosureTool = createDecorator('activationTestDisclosureTool'); let alphaConstructions = 0; let betaConstructions = 0; let gammaConstructions = 0; +let disclosureConstructions = 0; class AlphaTool extends StubTool { constructor() { @@ -93,6 +96,13 @@ class GammaTool extends StubTool { } } +class DisclosureTool extends StubTool { + constructor() { + super(SELECT_TOOLS_TOOL_NAME); + disclosureConstructions += 1; + } +} + class TestContributionAssembly extends Service { constructor() { super(); @@ -159,6 +169,7 @@ describe('AgentToolActivationService', () => { reg.define(IAlphaTool, AlphaTool); reg.define(IBetaTool, BetaTool); reg.define(IGammaTool, GammaTool); + reg.define(IDisclosureTool, DisclosureTool); }, }); disposables.add(ix.createInstance(TestContributionAssembly)); @@ -171,6 +182,7 @@ describe('AgentToolActivationService', () => { alphaConstructions = 0; betaConstructions = 0; gammaConstructions = 0; + disclosureConstructions = 0; _clearAgentToolContributionsForTests(); delete profileData.activeToolNames; delete profileData.disallowedTools; @@ -299,6 +311,68 @@ describe('AgentToolActivationService', () => { expect(registry.resolve('Beta')).toBeInstanceOf(BetaTool); }); + describe('select_tools disclosure carve-out', () => { + it('registers select_tools even when the profile allowlist omits it', async () => { + profileData.activeToolNames = ['Alpha']; + registerAgentToolService(IAlphaTool, AlphaTool, { name: 'Alpha' }); + registerAgentToolService(IDisclosureTool, DisclosureTool, { + name: SELECT_TOOLS_TOOL_NAME, + }); + const ix = createActivationHost(); + + await ix.get(IAgentToolActivationService).activate(); + + const registry = ix.get(IAgentToolRegistryService); + expect(registry.resolve('Alpha')).toBeInstanceOf(AlphaTool); + expect(registry.resolve(SELECT_TOOLS_TOOL_NAME)).toBeInstanceOf(DisclosureTool); + expect(registry.list().map((tool) => tool.name)).toContain(SELECT_TOOLS_TOOL_NAME); + }); + + it('still honors an explicit profile disallowedTools opt-out for select_tools', async () => { + profileData.activeToolNames = ['Alpha']; + profileData.disallowedTools = [SELECT_TOOLS_TOOL_NAME]; + registerAgentToolService(IAlphaTool, AlphaTool, { name: 'Alpha' }); + registerAgentToolService(IDisclosureTool, DisclosureTool, { + name: SELECT_TOOLS_TOOL_NAME, + }); + const ix = createActivationHost(); + + await ix.get(IAgentToolActivationService).activate(); + + const registry = ix.get(IAgentToolRegistryService); + expect(registry.resolve('Alpha')).toBeInstanceOf(AlphaTool); + expect(registry.resolve(SELECT_TOOLS_TOOL_NAME)).toBeUndefined(); + expect(disclosureConstructions).toBe(0); + }); + + it('still honors the workspace tool-policy veto for select_tools', async () => { + gateData.disabledTools = [SELECT_TOOLS_TOOL_NAME]; + registerAgentToolService(IDisclosureTool, DisclosureTool, { + name: SELECT_TOOLS_TOOL_NAME, + }); + const ix = createActivationHost(); + + await ix.get(IAgentToolActivationService).activate(); + + expect(ix.get(IAgentToolRegistryService).resolve(SELECT_TOOLS_TOOL_NAME)).toBeUndefined(); + expect(disclosureConstructions).toBe(0); + }); + + it('still runs the when predicate for select_tools', async () => { + profileData.activeToolNames = ['Alpha']; + registerAgentToolService(IDisclosureTool, DisclosureTool, { + name: SELECT_TOOLS_TOOL_NAME, + when: () => false, + }); + const ix = createActivationHost(); + + await ix.get(IAgentToolActivationService).activate(); + + expect(ix.get(IAgentToolRegistryService).resolve(SELECT_TOOLS_TOOL_NAME)).toBeUndefined(); + expect(disclosureConstructions).toBe(0); + }); + }); + describe('collection fold (scoped tree)', () => { beforeEach(() => { _clearScopedRegistryForTests(); @@ -330,6 +404,13 @@ describe('AgentToolActivationService', () => { ScopeActivation.OnScopeCreated, 'toolActivation', ); + registerScopedService( + LifecycleScope.Agent, + IDisclosureTool, + DisclosureTool, + ScopeActivation.OnDemand, + 'toolActivation', + ); registerScopedService( LifecycleScope.Agent, IDynamicToolProvider, @@ -343,6 +424,18 @@ describe('AgentToolActivationService', () => { return [ [IAgentProfileService, { data: () => profileData as ProfileData }], [IEventBus, { subscribe: () => toDisposable(() => {}) }], + [ + IAgentToolSelectService, + { + _serviceBrand: undefined, + enabled: () => false, + shapeTools: (entries) => entries, + shapeHistory: (messages) => messages, + load: () => ({ toLoad: [], alreadyAvailable: [], unknown: [] }), + drainPendingToolSchemas: () => undefined, + loadableToolsAnnouncement: () => undefined, + } satisfies IAgentToolSelectService, + ], ...extra, ]; } @@ -429,7 +522,24 @@ describe('AgentToolActivationService', () => { } await agent.accessor.get(IAgentToolActivationService).activate(); - expect(agent.accessor.get(IAgentToolRegistryService).list()).toHaveLength(0); + const registry = agent.accessor.get(IAgentToolRegistryService); + expect(registry.list().map((tool) => tool.name)).toEqual([SELECT_TOOLS_TOOL_NAME]); + expect(registry.resolve(SELECT_TOOLS_TOOL_NAME)?.name).toBe(SELECT_TOOLS_TOOL_NAME); + app.dispose(); + }); + + it('registers select_tools in a scoped agent tree when the profile allowlist omits it', async () => { + profileData.activeToolNames = ['Alpha']; + registerAgentToolService(IAlphaTool, AlphaTool, { name: 'Alpha' }); + registerAgentToolService(IDisclosureTool, DisclosureTool, { + name: SELECT_TOOLS_TOOL_NAME, + }); + const { app, agent } = createScopeTree(); + + await agent.accessor.get(IAgentToolActivationService).activate(); + const registry = agent.accessor.get(IAgentToolRegistryService); + expect(registry.resolve('Alpha')).toBeInstanceOf(AlphaTool); + expect(registry.resolve(SELECT_TOOLS_TOOL_NAME)).toBeInstanceOf(DisclosureTool); app.dispose(); }); }); diff --git a/packages/agent-core-v2/test/app/auth/auth.test.ts b/packages/agent-core-v2/test/app/auth/auth.test.ts index ebcf7bb71c..cd9ffe6ab0 100644 --- a/packages/agent-core-v2/test/app/auth/auth.test.ts +++ b/packages/agent-core-v2/test/app/auth/auth.test.ts @@ -39,6 +39,7 @@ import { MODELS_SECTION } from '#/app/kosongConfig/configSection'; import { IProviderService, type ProviderConfig, type ProvidersChangedEvent } from '#/kosong/provider/provider'; import '#/kosong/provider/providers/kimi/kimi.contrib'; +import '#/kosong/provider/providers/standard.contrib'; import { registerBootstrapServices } from '../bootstrap/stubs'; import { registerTelemetryServices } from '../telemetry/stubs'; @@ -46,6 +47,7 @@ import { stubAgentIdentity } from '../../app/agentIdentity/stubs'; const OAUTH_PROVIDER = 'managed:kimi-code'; const NON_OAUTH_PROVIDER = 'openai-main'; +const ENV_ONLY_PROVIDER = 'openai-env-only'; const deviceAuth = { userCode: 'ABCD-EFGH', @@ -1247,7 +1249,10 @@ describe('AuthSummaryService', () => { }, }); }); - afterEach(() => disposables.dispose()); + afterEach(() => { + disposables.dispose(); + vi.unstubAllEnvs(); + }); function createSummary(): IAuthSummaryService { return ix.get(IAuthSummaryService); @@ -1333,6 +1338,19 @@ describe('AuthSummaryService', () => { expect(getCachedAccessToken).not.toHaveBeenCalled(); }); + it('ensureReady accepts provider keys resolved from the process environment', async () => { + vi.stubEnv('OPENAI_API_KEY', 'process-env-key'); + providers[ENV_ONLY_PROVIDER] = { type: 'openai' }; + models[ENV_ONLY_PROVIDER] = { + provider: ENV_ONLY_PROVIDER, + model: 'gpt-4.1', + protocol: 'openai', + maxContextSize: 128000, + }; + await expect(createSummary().ensureReady(ENV_ONLY_PROVIDER)).resolves.toBeUndefined(); + expect(getCachedAccessToken).not.toHaveBeenCalled(); + }); + it('ensureReady accepts cached oauth tokens', async () => { getCachedAccessToken.mockResolvedValue('access-token'); await expect(createSummary().ensureReady('kimi')).resolves.toBeUndefined(); diff --git a/packages/agent-core-v2/test/app/externalHooksRunner/externalHooksRunner.test.ts b/packages/agent-core-v2/test/app/externalHooksRunner/externalHooksRunner.test.ts index 86809fa587..e5d499cc14 100644 --- a/packages/agent-core-v2/test/app/externalHooksRunner/externalHooksRunner.test.ts +++ b/packages/agent-core-v2/test/app/externalHooksRunner/externalHooksRunner.test.ts @@ -1,10 +1,13 @@ import { realpathSync } from 'node:fs'; import { tmpdir } from 'node:os'; +import { Emitter } from '#/_base/event'; +import type { HookDef } from '#/agent/externalHooks/types'; import type { ContentPart } from '#/kosong/contract/message'; import { describe, expect, it, vi } from 'vitest'; import { makeHookRunner } from '../../agent/externalHooks/runner-stub'; +import { stubLog } from '../../_base/log/stubs'; function nodeCommand(source: string): string { return `node -e ${JSON.stringify(source.replaceAll(/\s*\n\s*/g, ' '))}`; @@ -341,4 +344,110 @@ describe('ExternalHooksRunnerService', () => { expect(runner.hasHooksFor('SessionHeartbeat')).toBe(true); expect(runner.hasHooksFor('Stop')).toBe(false); }); + + it('rebuilds the hook index when [[hooks]] arrive through a config change', async () => { + const hooks: HookDef[] = []; + const configChange = new Emitter(); + const runner = makeHookRunner(hooks, { onDidChangeConfiguration: configChange.event }); + + await runner.ready; + expect(runner.hasHooksFor('SessionStart')).toBe(false); + expect(runner.hasHooksFor('UserPromptSubmit')).toBe(false); + + // The config gains a [[hooks]] section after the runner was constructed + // (the interactive TUI can load config.toml after app-scope construction). + // The runner must re-read the config instead of keeping the empty index + // forever, or the hooks would silently never fire (#2779). + hooks.push( + { event: 'SessionStart', command: nodeCommand('process.exit(0);'), timeout: 5 }, + { + event: 'UserPromptSubmit', + matcher: 'hello', + command: nodeCommand('process.exit(0);'), + timeout: 5, + }, + ); + configChange.fire(); + + await vi.waitFor(() => { + expect(runner.hasHooksFor('SessionStart')).toBe(true); + expect(runner.hasHooksFor('UserPromptSubmit')).toBe(true); + }); + await expect(runner.trigger('SessionStart')).resolves.toHaveLength(1); + await expect( + runner.trigger('UserPromptSubmit', { matcherValue: 'hello', inputData: {} }), + ).resolves.toHaveLength(1); + }); + + it('applies a rebuilt index to a trigger that lands right after the config change', async () => { + const hooks: HookDef[] = []; + const configChange = new Emitter(); + const runner = makeHookRunner(hooks, { onDidChangeConfiguration: configChange.event }); + + await runner.ready; + hooks.push({ event: 'SessionStart', command: nodeCommand('process.exit(0);'), timeout: 5 }); + configChange.fire(); + + // No await between the event and the trigger: the trigger must wait for + // the in-flight rebuild instead of reading the stale empty index. + const results = await runner.trigger('SessionStart'); + expect(results).toHaveLength(1); + expect(runner.hasHooksFor('SessionStart')).toBe(true); + }); + + it('coalesces a burst of config-change events into a single rebuild', async () => { + const hooks: HookDef[] = []; + const configChange = new Emitter(); + const runner = makeHookRunner(hooks, { onDidChangeConfiguration: configChange.event }); + await runner.ready; + + let reloads = 0; + runner.onDidReload(() => { + reloads += 1; + }); + + // A burst of config events (each config (re)load/set fires one). Only the + // final config state matters, so the runner must not rebuild per event. + hooks.push({ event: 'Stop', command: nodeCommand('process.exit(0);'), timeout: 5 }); + configChange.fire(); + configChange.fire(); + configChange.fire(); + hooks.push({ event: 'SessionStart', command: nodeCommand('process.exit(0);'), timeout: 5 }); + configChange.fire(); + + await vi.waitFor(() => { + expect(runner.hasHooksFor('Stop')).toBe(true); + expect(runner.hasHooksFor('SessionStart')).toBe(true); + }); + // Let any straggler rebuild land, then require the burst to have cost one. + await Promise.resolve(); + await Promise.resolve(); + expect(reloads).toBe(1); + }); + + it('logs a failed index rebuild and keeps failing open', async () => { + const hooks: HookDef[] = []; + const configChange = new Emitter(); + const errors: string[] = []; + const log = { ...stubLog(), error: (message: string) => errors.push(message) }; + const runner = makeHookRunner(hooks, { + onDidChangeConfiguration: configChange.event, + log, + enabledHooks: async () => { + throw new Error('plugin catalog unavailable'); + }, + }); + + await runner.ready; + expect(errors).toHaveLength(1); + + hooks.push({ event: 'SessionStart', command: nodeCommand('process.exit(0);'), timeout: 5 }); + configChange.fire(); + + await vi.waitFor(() => { + expect(errors).toHaveLength(2); + }); + // Fail open: the trigger still resolves instead of throwing. + await expect(runner.trigger('SessionStart')).resolves.toEqual([]); + }); }); diff --git a/packages/agent-core-v2/test/app/externalHooksRunner/integration.test.ts b/packages/agent-core-v2/test/app/externalHooksRunner/integration.test.ts index 468974256b..cede4af189 100644 --- a/packages/agent-core-v2/test/app/externalHooksRunner/integration.test.ts +++ b/packages/agent-core-v2/test/app/externalHooksRunner/integration.test.ts @@ -40,7 +40,7 @@ import { ExternalHooksRunnerService } from '#/app/externalHooksRunner/externalHo import { makeHookRunner } from '../../agent/externalHooks/runner-stub'; import type { AgentTaskInfo } from '#/agent/task/task'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; -import { IConfigService } from '#/app/config/config'; +import { IConfigService, type ConfigChangedEvent } from '#/app/config/config'; import { IEventBus } from '#/app/event/eventBus'; import { EventBusService } from '#/app/event/eventBusService'; import { IPluginService } from '#/app/plugin/plugin'; @@ -70,6 +70,7 @@ import { IModelService } from '#/kosong/model/model'; import { stubBootstrap } from '../bootstrap/stubs'; import { stubLoopWithHooks, stubToolExecutor } from '../../agent/loop/stubs'; +import { registerLogServices } from '../../_base/log/stubs'; import { registerStateServices } from '../../state/stubs'; import { registerTestAgentWireServices } from '../../wire/stubs'; @@ -617,6 +618,7 @@ describe('IExternalHooksRunnerService integration', () => { strict: true, additionalServices: (reg) => { registerStateServices(reg); + registerLogServices(reg); reg.defineInstance(IBootstrapService, stubBootstrap()); reg.defineInstance(ISessionContext, stubSessionContext()); reg.defineInstance(ISessionMetadata, stubSessionMetadata()); @@ -878,6 +880,7 @@ describe('IExternalHooksRunnerService integration', () => { strict: true, additionalServices: (reg) => { registerStateServices(reg); + registerLogServices(reg); reg.defineInstance(ISessionContext, { _serviceBrand: undefined, sessionId: 'session-1', @@ -1086,6 +1089,7 @@ describe('IExternalHooksRunnerService integration', () => { strict: true, additionalServices: (reg) => { registerStateServices(reg); + registerLogServices(reg); reg.defineInstance(ISessionContext, stubSessionContext()); reg.definePartialInstance(ISessionLifecycleService, lifecycle.service); reg.defineInstance(ISessionMetadata, stubSessionMetadata('My Session')); @@ -1401,4 +1405,81 @@ describe('IExternalHooksRunnerService integration', () => { vi.useRealTimers(); } }); + + it('arms SessionHeartbeat when [[hooks]] arrive through a config change', async () => { + vi.useFakeTimers(); + const disposables = new DisposableStore(); + let ix: TestInstantiationService | undefined; + try { + const hooks: Array<{ event: string; command: string; timeout: number }> = []; + const configChange = disposables.add(new Emitter()); + + ix = createServices(disposables, { + strict: true, + additionalServices: (reg) => { + registerStateServices(reg); + registerLogServices(reg); + reg.defineInstance(ISessionContext, stubSessionContext()); + reg.definePartialInstance(ISessionLifecycleService, stubSessionLifecycle().service); + reg.defineInstance(ISessionMetadata, stubSessionMetadata()); + reg.defineInstance(ISessionAgentProfileCatalog, stubProfileCatalog()); + reg.defineInstance(IModelService, stubModelService()); + reg.definePartialInstance(ISessionSubagentService, { + hooks: createHooks(['onWillStartAgentTask']), + onDidStopAgentTask: Event.None as Event, + }); + reg.definePartialInstance(IConfigService, { + ready: Promise.resolve(), + get: (domain: string): T => + (domain === HOOKS_SECTION ? hooks : undefined) as T, + onDidChangeConfiguration: configChange.event, + }); + reg.definePartialInstance(IPluginService, { + enabledHooks: async () => [], + onDidReload: Event.None as IPluginService['onDidReload'], + }); + reg.defineInstance(IBootstrapService, stubBootstrap()); + reg.define(IHostProcessService, HostProcessService); + }, + }); + ix.set(IExternalHooksRunnerService, new SyncDescriptor(ExternalHooksRunnerService)); + ix.set(ISessionExternalHooksService, new SyncDescriptor(SessionExternalHooksService)); + ix.get(ISessionExternalHooksService); + + const runner = ix.get(IExternalHooksRunnerService); + const fireAndForgetTrigger = vi + .spyOn(runner, 'fireAndForgetTrigger') + .mockResolvedValue([]); + + // No SessionHeartbeat hook at startup: the timer never arms. + await vi.advanceTimersByTimeAsync(120_000); + expect(fireAndForgetTrigger).not.toHaveBeenCalled(); + + // A [[hooks]] section lands in the config after construction: the + // runner rebuilds its index, the session service re-syncs, and the + // heartbeat timer arms. + hooks.push({ + event: 'SessionHeartbeat', + command: nodeCommand('process.exit(0);'), + timeout: 5, + }); + configChange.fire({ + domain: HOOKS_SECTION, + source: 'set', + value: undefined, + previousValue: undefined, + }); + // Triggering awaits the config-change rebuild (and the onDidReload + // re-sync that arms the timer). + await runner.trigger('SessionHeartbeat'); + + await vi.advanceTimersByTimeAsync(60_000); + expect(fireAndForgetTrigger).toHaveBeenCalledTimes(1); + expect(fireAndForgetTrigger.mock.calls[0]?.[0]).toBe('SessionHeartbeat'); + } finally { + ix?.dispose(); + disposables.dispose(); + vi.useRealTimers(); + } + }); }); diff --git a/packages/agent-core-v2/test/app/llmProtocol/errors.test.ts b/packages/agent-core-v2/test/app/llmProtocol/errors.test.ts index 75905c1892..41dd7e953e 100644 --- a/packages/agent-core-v2/test/app/llmProtocol/errors.test.ts +++ b/packages/agent-core-v2/test/app/llmProtocol/errors.test.ts @@ -367,6 +367,7 @@ describe('normalizeAPIStatusError', () => { [400, 'prompt is too long: 210000 tokens exceeds the maximum'], [400, 'input token count 131072 exceeds the maximum number of tokens allowed'], [400, 'Invalid request: Your request exceeded model token limit: 262144 (requested: 274613)'], + [401, 'Context length exceeded'], ])('normalizes %i "%s" to APIContextOverflowError', (statusCode, message) => { const error = normalizeAPIStatusError(statusCode, message, 'req-context'); expect(error).toBeInstanceOf(APIContextOverflowError); @@ -375,7 +376,6 @@ describe('normalizeAPIStatusError', () => { }); it.each([ - [401, 'Context length exceeded'], [500, 'Context length exceeded'], [400, 'Bad request'], [422, 'Invalid tool schema'], diff --git a/packages/agent-core-v2/test/kosong/contract/errors.test.ts b/packages/agent-core-v2/test/kosong/contract/errors.test.ts index a6e9537f5a..55ffaed65f 100644 --- a/packages/agent-core-v2/test/kosong/contract/errors.test.ts +++ b/packages/agent-core-v2/test/kosong/contract/errors.test.ts @@ -131,6 +131,28 @@ describe('isRetryableGenerateError', () => { }); }); +describe('context-limit 401 classification', () => { + it('classifies a 401 whose message says "supports only N context" as context overflow', () => { + const error = normalizeAPIStatusError(401, 'k3-256k supports only 256K context.'); + expect(error).toBeInstanceOf(APIContextOverflowError); + expect(error.statusCode).toBe(401); + expect(classifyApiError(error)).toEqual({ kind: 'context_overflow', statusCode: 401 }); + }); + + it('classifies a raw 401 status error with the message as context overflow', () => { + expect( + classifyApiError(new APIStatusError(401, 'k3-256k supports only 256K context.')), + ).toEqual({ kind: 'context_overflow', statusCode: 401 }); + }); + + it('keeps a plain 401 as auth', () => { + expect(classifyApiError(new APIStatusError(401, 'Unauthorized')).kind).toBe('auth'); + const error = normalizeAPIStatusError(401, 'Unauthorized'); + expect(error).toBeInstanceOf(APIStatusError); + expect(error).not.toBeInstanceOf(APIContextOverflowError); + }); +}); + describe('classifyApiError', () => { it('classifies typed errors and carries the status code', () => { expect(classifyApiError(new APIContextOverflowError(400, 'context length exceeded'))).toEqual({ diff --git a/packages/agent-core-v2/test/kosong/contract/generate.test.ts b/packages/agent-core-v2/test/kosong/contract/generate.test.ts index 68fcfd5a1a..6f9cfe768b 100644 --- a/packages/agent-core-v2/test/kosong/contract/generate.test.ts +++ b/packages/agent-core-v2/test/kosong/contract/generate.test.ts @@ -9,8 +9,9 @@ import { describe, expect, it, vi } from 'vitest'; -import { APIEmptyResponseError } from '#/kosong/contract/errors'; +import { APIEmptyResponseError, createAbortError } from '#/kosong/contract/errors'; import { generate, type GenerateResult } from '#/kosong/contract/generate'; +import { STREAM_STALL_TIMEOUT_ENV } from '#/kosong/contract/stallTimeout'; import type { Message, StreamedMessagePart, ToolCall } from '#/kosong/contract/message'; import type { ChatProvider, @@ -317,3 +318,173 @@ describe('generate() per-turn intent passthrough', () => { expect(generateSpy.mock.calls[0]?.[3]).toBe(options); }); }); + +class StalledStreamedMessage implements StreamedMessage { + readonly id: string | null = 'stall-1'; + readonly usage: TokenUsage | null = USAGE; + readonly finishReason: FinishReason | null = 'completed'; + readonly rawFinishReason: string | null = 'stop'; + readonly traceId: string | null = null; + cancelCalls = 0; + + constructor(private readonly parts: readonly StreamedMessagePart[]) {} + + async *[Symbol.asyncIterator](): AsyncIterator { + for (const part of this.parts) { + yield part; + } + await new Promise(() => {}); + } + + cancel(): void { + this.cancelCalls++; + } +} + +class SlowedStreamedMessage implements StreamedMessage { + readonly id: string | null = 'slow-1'; + readonly usage: TokenUsage | null = USAGE; + readonly finishReason: FinishReason | null = 'completed'; + readonly rawFinishReason: string | null = 'stop'; + readonly traceId: string | null = null; + + constructor( + private readonly parts: readonly StreamedMessagePart[], + private readonly gapMs: number, + ) {} + + async *[Symbol.asyncIterator](): AsyncIterator { + for (const part of this.parts) { + await new Promise((resolve) => setTimeout(resolve, this.gapMs)); + yield part; + } + } +} + +class SignalAwareStalledStream implements StreamedMessage { + readonly id: string | null = 'signal-stall-1'; + readonly usage: TokenUsage | null = USAGE; + readonly finishReason: FinishReason | null = 'completed'; + readonly rawFinishReason: string | null = 'stop'; + readonly traceId: string | null = null; + cancelCalls = 0; + + constructor(private readonly signal: AbortSignal) {} + + async *[Symbol.asyncIterator](): AsyncIterator { + yield { type: 'text', text: 'first' }; + await new Promise((_resolve, reject) => { + this.signal.addEventListener('abort', () => reject(createAbortError()), { once: true }); + }); + } + + cancel(): void { + this.cancelCalls++; + } +} + +describe('generate() stream stall timeout', () => { + it('aborts a stream that yields no data for the stall window and cancels it', async () => { + const stream = new StalledStreamedMessage([{ type: 'text', text: 'partial' }]); + const { provider } = createFakeProvider(stream); + + let caught: unknown; + try { + await generate(provider, SYSTEM_PROMPT, NO_TOOLS, HISTORY, undefined, { + stallTimeoutMs: 25, + }); + } catch (error) { + caught = error; + } + + expect(caught).toBeInstanceOf(DOMException); + expect((caught as DOMException).name).toBe('AbortError'); + expect(stream.cancelCalls).toBe(1); + }); + + it('aborts a first-token stall before any chunk arrives', async () => { + const stream = new StalledStreamedMessage([]); + const { provider } = createFakeProvider(stream); + + await expect( + generate(provider, SYSTEM_PROMPT, NO_TOOLS, HISTORY, undefined, { stallTimeoutMs: 25 }), + ).rejects.toMatchObject({ name: 'AbortError' }); + }); + + it('resets the stall window on every received chunk', async () => { + const stream = new SlowedStreamedMessage( + [ + { type: 'text', text: 'a' }, + { type: 'text', text: 'b' }, + { type: 'text', text: 'c' }, + ], + 10, + ); + const { provider } = createFakeProvider(stream); + + const result = await generate(provider, SYSTEM_PROMPT, NO_TOOLS, HISTORY, undefined, { + stallTimeoutMs: 30, + }); + + expect(result.message.content).toEqual([{ type: 'text', text: 'abc' }]); + }); + + it('applies the env-gated default stall window when not configured', async () => { + vi.stubEnv(STREAM_STALL_TIMEOUT_ENV, '25'); + try { + const stream = new StalledStreamedMessage([{ type: 'text', text: 'partial' }]); + const { provider } = createFakeProvider(stream); + + let caught: unknown; + try { + await generate(provider, SYSTEM_PROMPT, NO_TOOLS, HISTORY); + } catch (error) { + caught = error; + } + + expect(caught).toBeInstanceOf(DOMException); + expect((caught as DOMException).name).toBe('AbortError'); + expect(stream.cancelCalls).toBe(1); + } finally { + vi.unstubAllEnvs(); + } + }); + + it('does not interfere when the stall window is disabled', async () => { + const stream = new SlowedStreamedMessage( + [ + { type: 'text', text: 'a' }, + { type: 'text', text: 'b' }, + ], + 10, + ); + const { provider } = createFakeProvider(stream); + + const result = await generate(provider, SYSTEM_PROMPT, NO_TOOLS, HISTORY, undefined, { + stallTimeoutMs: 0, + }); + + expect(result.message.content).toEqual([{ type: 'text', text: 'ab' }]); + }); + + it('does not double-abort when the caller signal aborts during a stall', async () => { + const controller = new AbortController(); + const stream = new SignalAwareStalledStream(controller.signal); + const { provider } = createFakeProvider(stream); + setTimeout(() => controller.abort(), 10); + + let caught: unknown; + try { + await generate(provider, SYSTEM_PROMPT, NO_TOOLS, HISTORY, undefined, { + signal: controller.signal, + stallTimeoutMs: 500, + }); + } catch (error) { + caught = error; + } + + expect(caught).toBeInstanceOf(DOMException); + expect((caught as DOMException).name).toBe('AbortError'); + expect(stream.cancelCalls).toBe(0); + }); +}); diff --git a/packages/agent-core-v2/test/kosong/model/catalog.test.ts b/packages/agent-core-v2/test/kosong/model/catalog.test.ts index b450289521..73be164b92 100644 --- a/packages/agent-core-v2/test/kosong/model/catalog.test.ts +++ b/packages/agent-core-v2/test/kosong/model/catalog.test.ts @@ -18,7 +18,7 @@ * `notifyConfigChanged()` (the load-bearing test-harness contract). */ -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createScopedTestHost } from '#/_base/di/test'; import { isErrorCode } from '#/_base/errors/codes'; @@ -802,6 +802,9 @@ describe('ModelCatalog inspect', () => { }); it('attributes the definition defaultBaseUrl to builtin and reports missing credentials', () => { + // The kimi definition's apiKeyEnv is KIMI_API_KEY, so an ambient key from + // the runner environment must not leak into the "no credential" verdict. + vi.stubEnv('KIMI_API_KEY', ''); const { host, catalog } = createHost({ providers: { kimi: { type: 'kimi' } }, models: { k1: { provider: 'kimi', model: 'kimi-k2', maxContextSize: 1 } }, @@ -817,6 +820,7 @@ describe('ModelCatalog inspect', () => { expect(view.sources['resolved.auth']).toMatchObject({ kind: 'none' }); } finally { host.dispose(); + vi.unstubAllEnvs(); } }); diff --git a/packages/agent-core-v2/test/kosong/model/modelAuth.test.ts b/packages/agent-core-v2/test/kosong/model/modelAuth.test.ts index ec7540a765..8880f3b483 100644 --- a/packages/agent-core-v2/test/kosong/model/modelAuth.test.ts +++ b/packages/agent-core-v2/test/kosong/model/modelAuth.test.ts @@ -8,11 +8,15 @@ * - the env-bag fallback reads the vendor's declared `apiKeyEnv` chain via * `resolveProviderEndpoint` (kimi / anthropic / openai / google-genai * chain) — no per-protocol table; + * - when nothing is configured, the auth-readiness probe falls back to the + * ambient process env through the vendor's declared `apiKeyEnv`, matching + * what the request adapters read; explicit configuration (inline apiKey, + * env bag, oauth) is resolved first and never competes with ambient keys; * - `effectiveModelConfig` applies `overrides` and the Anthropic effort * profile — inferred only for vendors whose thinking is not trait-driven. */ -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { ConfigErrors } from '#/app/config/errors'; import '#/kosong/provider/providers/kimi/kimi.contrib'; @@ -101,10 +105,85 @@ describe('resolveModelAuthMaterial', () => { ).toEqual({ apiKey: 'vertex-env-key' }); }); + it('falls back to the process environment when the provider declares no env bag', () => { + vi.stubEnv('OPENAI_API_KEY', 'process-env-key'); + expect(authMaterial({ model: { model: 'm' }, provider: { type: 'openai' } })).toEqual({ + apiKey: 'process-env-key', + }); + }); + + it('prefers the provider env bag and inline apiKey over the process environment', () => { + vi.stubEnv('OPENAI_API_KEY', 'process-env-key'); + expect( + authMaterial({ + model: { model: 'm' }, + provider: { type: 'openai', env: { OPENAI_API_KEY: 'bag-key' } }, + }), + ).toEqual({ apiKey: 'bag-key' }); + expect( + authMaterial({ + model: { model: 'm' }, + provider: { type: 'openai', apiKey: 'inline-key' }, + }), + ).toEqual({ apiKey: 'inline-key' }); + }); + + it('prefers a configured env-bag key over an ambient key declared earlier in the chain', () => { + // google-genai declares VERTEXAI_API_KEY ahead of GOOGLE_API_KEY, so an + // ambient Vertex key must not outrank the Gemini key the user configured. + vi.stubEnv('VERTEXAI_API_KEY', 'ambient-vertex-key'); + expect( + authMaterial({ + model: { model: 'm' }, + provider: { type: 'google-genai', env: { GOOGLE_API_KEY: 'configured-google-key' } }, + }), + ).toEqual({ apiKey: 'configured-google-key' }); + }); + + it('does not let an ambient key invalidate a provider configured for oauth', () => { + vi.stubEnv('OPENAI_API_KEY', 'ambient-unrelated-key'); + expect( + authMaterial({ + model: { model: 'm', providerId: 'p1' }, + provider: { type: 'openai', oauth: { storage: 'file', key: 'k' } }, + }), + ).toEqual({ oauth: { storage: 'file', key: 'k' }, oauthProviderKey: 'p1' }); + }); + + it('still rejects a configured apiKey alongside oauth', () => { + expect(() => + authMaterial({ + model: { model: 'm' }, + provider: { type: 'openai', apiKey: 'k', oauth: { storage: 'file', key: 'k' } }, + }), + ).toThrowError(expect.objectContaining({ code: ConfigErrors.codes.CONFIG_INVALID })); + expect(() => + authMaterial({ + model: { model: 'm' }, + provider: { + type: 'openai', + env: { OPENAI_API_KEY: 'bag-key' }, + oauth: { storage: 'file', key: 'k' }, + }, + }), + ).toThrowError(expect.objectContaining({ code: ConfigErrors.codes.CONFIG_INVALID })); + }); + + it('does not leak an unrelated vendor key from the process environment', () => { + vi.stubEnv('OPENAI_API_KEY', 'process-env-key'); + vi.stubEnv('ANTHROPIC_API_KEY', ''); + expect(authMaterial({ model: { model: 'm' }, provider: { type: 'anthropic' } })).toEqual({}); + }); + it('returns empty material when nothing is configured', () => { + vi.stubEnv('OPENAI_API_KEY', ''); expect(authMaterial({ model: { model: 'm' }, provider: { type: 'openai' } })).toEqual({}); expect(authMaterial({ model: { model: 'm' } })).toEqual({}); }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); }); describe('effectiveModelConfig', () => { diff --git a/packages/agent-core-v2/test/kosong/model/modelRequester.test.ts b/packages/agent-core-v2/test/kosong/model/modelRequester.test.ts index 7daae99708..83150cc1db 100644 --- a/packages/agent-core-v2/test/kosong/model/modelRequester.test.ts +++ b/packages/agent-core-v2/test/kosong/model/modelRequester.test.ts @@ -18,7 +18,11 @@ import { describe, expect, it } from 'vitest'; import { isError2 } from '#/_base/errors/errors'; -import { APIStatusError, createAbortError } from '#/kosong/contract/errors'; +import { + APIContextOverflowError, + APIStatusError, + createAbortError, +} from '#/kosong/contract/errors'; import type { Message, StreamedMessagePart } from '#/kosong/contract/message'; import type { ChatProvider, @@ -89,6 +93,22 @@ function streamOf( }; } +function stalledStreamOf(parts: readonly StreamedMessagePart[]): StreamedMessage { + return { + id: 'stall-1', + usage: emptyUsage(), + finishReason: 'completed', + rawFinishReason: 'stop', + traceId: null, + async *[Symbol.asyncIterator]() { + for (const part of parts) { + yield part; + } + await new Promise(() => {}); + }, + }; +} + function registryReturning(provider: ChatProvider): IProtocolAdapterRegistry { return { _serviceBrand: undefined, @@ -141,6 +161,18 @@ async function collect(stream: AsyncIterable): Promise, +): Promise<{ events: ModelRequestEvent[]; error: unknown }> { + const events: ModelRequestEvent[] = []; + try { + for await (const event of stream) events.push(event); + return { events, error: undefined }; + } catch (error) { + return { events, error }; + } +} + const INPUT = { systemPrompt: 'sys', tools: [], messages: [] }; describe('ModelRequesterImpl request execution', () => { @@ -261,6 +293,29 @@ describe('ModelRequesterImpl request execution', () => { expect(provider.calls).toHaveLength(2); }); + it('does not force a token refresh for a context-limit 401', async () => { + const provider = new FakeChatProvider(); + provider.handler = () => + Promise.reject(new APIContextOverflowError(401, 'k3-256k supports only 256K context.')); + const authCalls: Array<{ force?: boolean }> = []; + const requester = new ModelRequesterImpl( + modelWith({ + canRefresh: true, + getAuth: (options) => { + authCalls.push(options ?? {}); + return Promise.resolve({ apiKey: 'tok-1' }); + }, + }), + registryReturning(provider), + ); + + const failure = await collect(requester.request(INPUT)).catch((error: unknown) => error); + expect(provider.calls).toHaveLength(1); + expect(authCalls).toEqual([{}]); + expect((failure as { code: string }).code).toBe(ProtocolErrors.codes.CONTEXT_OVERFLOW); + expect((failure as Error).message).toContain('supports only 256K context'); + }); + it('does not replay 401s against a non-refreshable auth provider', async () => { const provider = new FakeChatProvider(); provider.handler = () => Promise.reject(new APIStatusError(401, 'bad key')); @@ -287,6 +342,21 @@ describe('ModelRequesterImpl request execution', () => { expect(aborted).toBe(abort); }); + it('aborts the request when the provider stream stalls mid-stream', async () => { + const provider = new FakeChatProvider(); + provider.handler = () => Promise.resolve(stalledStreamOf([{ type: 'text', text: 'partial' }])); + const requester = new ModelRequesterImpl(modelWith(staticAuth()), registryReturning(provider)); + + const { events, error } = await collectWithFailure( + requester.request(INPUT, undefined, { stallTimeoutMs: 25 }), + ); + + expect(error).toBeInstanceOf(DOMException); + expect((error as DOMException).name).toBe('AbortError'); + expect(events.filter((event) => event.type === 'part')).toHaveLength(1); + expect(events.some((event) => event.type === 'finish')).toBe(false); + }); + it('uploadVideo presence is the capability declaration', async () => { const provider = new FakeChatProvider(); const requester = new ModelRequesterImpl( diff --git a/packages/agent-core-v2/test/os/backends/node-local/tools/bash.test.ts b/packages/agent-core-v2/test/os/backends/node-local/tools/bash.test.ts index 1360d01b2c..11d5107ad1 100644 --- a/packages/agent-core-v2/test/os/backends/node-local/tools/bash.test.ts +++ b/packages/agent-core-v2/test/os/backends/node-local/tools/bash.test.ts @@ -37,6 +37,8 @@ import { type ISessionContext, makeSessionContext } from '#/session/sessionConte import type { IProcess, ISessionProcessRunner } from '#/session/process/processRunner'; import { type BashInput, BashInputSchema } from '#/agent/tools/os/bash/bash'; import { BashTool } from '#/agent/tools/os/bash/bashTool'; +import { BashParserService } from '#/app/bashParser/bashParserService'; +import type { IBashParserService } from '#/app/bashParser/bashParser'; import type { ExecutableToolContext, ExecutableToolResult, ToolExecution } from '#/tool/toolContract'; const posixEnv: IHostEnvironment = { @@ -720,8 +722,9 @@ function bashTool( background: IAgentTaskService = createFakeTaskService().service, toolPolicy: IAgentToolPolicyService = stubToolPolicy(), config: IConfigService = stubConfig(), + bashParser: IBashParserService = new BashParserService(), ): BashTool { - return new BashTool(runner, env, ctx, background, toolPolicy, config); + return new BashTool(runner, env, ctx, background, toolPolicy, config, bashParser); } diff --git a/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts b/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts index 3716d50575..0a4428c1e5 100644 --- a/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts +++ b/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts @@ -52,6 +52,7 @@ import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStor import { ISessionContext } from '#/session/sessionContext/sessionContext'; import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; import { createWireMetadataRecord, type WireRecord } from '#/wire/record'; +import { IWireService } from '#/wire/wire'; import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; import { IAgentLoopService } from '#/agent/loop/loop'; import { IAgentFullCompactionService } from '#/agent/fullCompaction/fullCompaction'; @@ -456,6 +457,52 @@ describe('AgentLifecycleService', () => { expect(removed).toBe(true); }); + it('remove waits for the agent wire to flush before completing', async () => { + const svc = ix.get(IAgentLifecycleService); + const handle = await svc.create({ agentId: 'main' }); + let markFlushStarted!: () => void; + const flushStarted = new Promise((resolve) => { + markFlushStarted = resolve; + }); + let releaseFlush!: () => void; + const flushReleased = new Promise((resolve) => { + releaseFlush = resolve; + }); + const flush = vi + .spyOn(handle.accessor.get(IWireService), 'flush') + .mockImplementation(async () => { + markFlushStarted(); + await flushReleased; + }); + + let removed = false; + const removal = svc.remove('main').then(() => { + removed = true; + }); + await flushStarted; + expect(removed).toBe(false); + + releaseFlush(); + await removal; + expect(flush).toHaveBeenCalledOnce(); + expect(removed).toBe(true); + }); + + it('remove still disposes the agent when the wire flush rejects', async () => { + const svc = ix.get(IAgentLifecycleService); + const handle = await svc.create({ agentId: 'main' }); + const flushError = new Error('wire flush failed'); + vi.spyOn(handle.accessor.get(IWireService), 'flush').mockRejectedValueOnce(flushError); + const disposed: string[] = []; + disposables.add(svc.onDidDispose((id) => disposed.push(id))); + + await expect(svc.remove('main')).rejects.toBe(flushError); + + expect(svc.get('main')).toBeUndefined(); + expect(disposed).toEqual(['main']); + expect(() => handle.accessor.get(IWireService)).toThrow(); + }); + it('ignites the self-wiring toolDedupe plugin so its listeners exist before the first turn', async () => { const svc = ix.get(IAgentLifecycleService); await svc.create({ agentId: 'main' }); diff --git a/packages/agent-core-v2/test/workspace/sessionLifecycle/sessionLifecycle.test.ts b/packages/agent-core-v2/test/workspace/sessionLifecycle/sessionLifecycle.test.ts index 17f0173118..85ffe3f00c 100644 --- a/packages/agent-core-v2/test/workspace/sessionLifecycle/sessionLifecycle.test.ts +++ b/packages/agent-core-v2/test/workspace/sessionLifecycle/sessionLifecycle.test.ts @@ -672,6 +672,33 @@ describe('SessionLifecycleService', () => { expect(svc.get('s1')).toBeUndefined(); }); + it('close disposes the session while preserving an agent removal failure', async () => { + const removeError = new Error('wire flush failed'); + const onTeardown = vi.fn(); + const agentHandle = { + id: 'main', + kind: LifecycleScope.Agent, + accessor: { get: () => ({}) }, + dispose: () => {}, + } as unknown as IAgentScopeHandle; + const svc = await build([ + stubPair(IAgentLifecycleService, { + ...agentLifecycleStub(), + list: () => [agentHandle], + remove: () => Promise.reject(removeError), + }), + ]); + svc.onWillCreateSession((event) => { + event.onSessionDispose(onTeardown); + }); + await svc.create({ sessionId: 's1', workDir: '/tmp/proj' }); + + await expect(svc.close('s1')).rejects.toBe(removeError); + + expect(svc.get('s1')).toBeUndefined(); + expect(onTeardown).toHaveBeenCalledOnce(); + }); + it('create seeds identity and materializes metadata', async () => { const svc = await build(); const h = await svc.create({ sessionId: 's1', workDir: '/tmp/proj' }); diff --git a/packages/agent-core/package.json b/packages/agent-core/package.json index 32dcdee4fe..554ad730c9 100644 --- a/packages/agent-core/package.json +++ b/packages/agent-core/package.json @@ -78,6 +78,7 @@ "@moonshot-ai/kimi-code-oauth": "workspace:^", "@moonshot-ai/kosong": "workspace:^", "@moonshot-ai/protocol": "workspace:^", + "@moonshot-ai/tree-sitter-bash": "workspace:^", "@mozilla/readability": "^0.6.0", "ajv": "^8.18.0", "ajv-formats": "^3.0.1", @@ -116,4 +117,4 @@ "sinon": "^22.0.0", "yazl": "^3.3.1" } -} \ No newline at end of file +} diff --git a/packages/agent-core/src/agent/permission/matches-rule.ts b/packages/agent-core/src/agent/permission/matches-rule.ts index 1c629002a4..9945d002d8 100644 --- a/packages/agent-core/src/agent/permission/matches-rule.ts +++ b/packages/agent-core/src/agent/permission/matches-rule.ts @@ -92,7 +92,7 @@ export function matchPermissionRule({ return { rule, strategy: 'tool_name_only', hasRuleArgs: false }; } - return execution.matchesRule?.(parsed.argPattern) === true + return execution.matchesRule?.(parsed.argPattern, { decision: rule.decision }) === true ? { rule, strategy: 'matches_rule', hasRuleArgs: true } : undefined; } diff --git a/packages/agent-core/src/errors/serialize.ts b/packages/agent-core/src/errors/serialize.ts index 9da4b2929a..d944ba0fd1 100644 --- a/packages/agent-core/src/errors/serialize.ts +++ b/packages/agent-core/src/errors/serialize.ts @@ -1,5 +1,6 @@ import { APIConnectionError, + APIContextOverflowError, APIEmptyResponseError, APIProviderQuotaExhaustedError, APIStatusError, @@ -85,11 +86,13 @@ export function toKimiErrorPayload(error: unknown): KimiErrorPayload { const code: KimiErrorCode = error instanceof APIProviderQuotaExhaustedError ? ErrorCodes.PROVIDER_USAGE_LIMIT - : error.statusCode === 429 - ? ErrorCodes.PROVIDER_RATE_LIMIT - : error.statusCode === 401 - ? ErrorCodes.PROVIDER_AUTH_ERROR - : ErrorCodes.PROVIDER_API_ERROR; + : error instanceof APIContextOverflowError + ? ErrorCodes.CONTEXT_OVERFLOW + : error.statusCode === 429 + ? ErrorCodes.PROVIDER_RATE_LIMIT + : error.statusCode === 401 + ? ErrorCodes.PROVIDER_AUTH_ERROR + : ErrorCodes.PROVIDER_API_ERROR; return { code, message: sanitizeStatusErrorMessage(error.message), diff --git a/packages/agent-core/src/loop/index.ts b/packages/agent-core/src/loop/index.ts index 9e070cc033..cafc358850 100644 --- a/packages/agent-core/src/loop/index.ts +++ b/packages/agent-core/src/loop/index.ts @@ -26,6 +26,8 @@ export type { ToolExecution, ToolCall, ExecutableToolContext, + RuleMatchContext, + RuleMatchDecision, ToolExecutionHookContext, ResolvedToolExecutionHookContext, PrepareToolExecutionHook, diff --git a/packages/agent-core/src/loop/types.ts b/packages/agent-core/src/loop/types.ts index 3d361552ba..10493d70de 100644 --- a/packages/agent-core/src/loop/types.ts +++ b/packages/agent-core/src/loop/types.ts @@ -137,6 +137,12 @@ export interface ExecutableToolContext { readonly onForegroundTaskStart?: ((taskId: string) => void) | undefined; } +export type RuleMatchDecision = 'allow' | 'deny' | 'ask'; + +export interface RuleMatchContext { + readonly decision: RuleMatchDecision; +} + export interface RunnableToolExecution { readonly isError?: false | undefined; readonly accesses?: ToolAccesses | undefined; @@ -148,7 +154,7 @@ export interface RunnableToolExecution { */ readonly stopBatchAfterThis?: boolean | undefined; readonly approvalRule: string; - readonly matchesRule?: ((ruleArgs: string) => boolean) | undefined; + readonly matchesRule?: ((ruleArgs: string, context?: RuleMatchContext) => boolean) | undefined; readonly execute: (ctx: ExecutableToolContext) => Promise; } diff --git a/packages/agent-core/src/session/provider-manager.ts b/packages/agent-core/src/session/provider-manager.ts index 7fb313b461..fc6b901a6b 100644 --- a/packages/agent-core/src/session/provider-manager.ts +++ b/packages/agent-core/src/session/provider-manager.ts @@ -1,6 +1,7 @@ import type { Logger } from '#/logging/types'; import type { ProviderConfig as KosongProviderConfig, ModelCapability, ProviderRequestAuth } from '@moonshot-ai/kosong'; import { + APIContextOverflowError, APIStatusError, classifyKimiQuotaError, getModelCapability, @@ -214,6 +215,10 @@ export class ProviderManager implements ModelProvider { try { return await request(auth); } catch (error) { + // A context-overflow-classified 401 (e.g. "supports only 256K + // context") is a context-window rejection, not a stale token — + // refreshing OAuth cannot help (issue #2613). + if (error instanceof APIContextOverflowError) throw error; if (!(error instanceof APIStatusError) || error.statusCode !== 401) throw error; if (refreshed) { const reason = error.message.replaceAll('\r', ''); diff --git a/packages/agent-core/src/tools/builtin/shell/bash.ts b/packages/agent-core/src/tools/builtin/shell/bash.ts index 46d6d9ab0d..df73b75ae7 100644 --- a/packages/agent-core/src/tools/builtin/shell/bash.ts +++ b/packages/agent-core/src/tools/builtin/shell/bash.ts @@ -23,6 +23,7 @@ */ import type { Kaos, KaosProcess } from '@moonshot-ai/kaos'; +import { parse } from '@moonshot-ai/tree-sitter-bash'; import { z } from 'zod'; import { ProcessBackgroundTask, type BackgroundManager } from '../../../agent/background'; @@ -30,7 +31,8 @@ import type { BuiltinTool } from '../../../agent/tool'; import type { ExecutableToolResult, ToolExecution, ToolUpdate } from '../../../loop/types'; import { renderPrompt } from '../../../utils/render-prompt'; import { toInputJsonSchema } from '../../support/input-schema'; -import { literalRulePattern, matchesGlobRuleSubject } from '../../support/rule-match'; +import { literalRulePattern } from '../../support/rule-match'; +import { createCommandPartsProvider, matchesDecomposedCommandRule } from './command-parts'; import { type ExecutableToolResultBuilderResult, ToolResultBuilder, @@ -252,6 +254,7 @@ export class BashTool implements BuiltinTool { resolveExecution(args: BashInput): ToolExecution { const preview = args.command.length > 50 ? `${args.command.slice(0, 50)}…` : args.command; + const commandParts = createCommandPartsProvider(parse, args.command); return { description: args.run_in_background ? `Starting background: ${preview}` @@ -264,7 +267,8 @@ export class BashTool implements BuiltinTool { language: 'bash', }, approvalRule: literalRulePattern(this.name, args.command), - matchesRule: (ruleArgs) => matchesGlobRuleSubject(ruleArgs, args.command), + matchesRule: (ruleArgs, context) => + matchesDecomposedCommandRule(ruleArgs, args.command, context?.decision, commandParts), execute: ({ signal, onUpdate, onForegroundTaskStart }) => this.execution(args, signal, onUpdate, onForegroundTaskStart), }; diff --git a/packages/agent-core/src/tools/builtin/shell/command-parts.ts b/packages/agent-core/src/tools/builtin/shell/command-parts.ts new file mode 100644 index 0000000000..fbe6cf6988 --- /dev/null +++ b/packages/agent-core/src/tools/builtin/shell/command-parts.ts @@ -0,0 +1,129 @@ +/** + * Bash command decomposition for permission-rule matching (v1 mirror of the + * `agent-core-v2` helper, duplicated per package because the two engines + * share no code). + * + * Splits a parsed bash command into the executable unit texts — commands and + * test commands (with their redirections attached, and leading env-prefix + * assignments such as `DEBUG=1` stripped so the rule judges the command that + * actually runs), standalone variable assignments, redirected groups, and the + * payloads of command / process substitutions at any depth — so rule + * evaluation can judge a compound command per unit instead of as one opaque + * string. A `deny`/`ask` rule matches when the whole command or any unit + * matches. An `allow` rule auto-matches only when the command parses cleanly + * and every unit matches, or the pattern is the escaped literal of the whole + * command (the session-approval shape, which re-approves a previously + * approved compound command without letting a wildcard span operators) — + * parse failure or budget exhaustion must not fall back to whole-string + * globbing, since both are exactly the over-match being closed. An unknown + * (undefined) decision never expands into per-part matching, since that + * expansion is only sound once the allow/deny direction is known. Quoted + * operators and heredoc bodies are data, not units — extraction trusts the + * grammar, which only surfaces substitution nodes where bash would execute + * them. The tree walk is iterative because in-budget trees can still be + * thousands of levels deep. + */ + +import type { ParseOptions, ParseResult, SyntaxNode } from '@moonshot-ai/tree-sitter-bash'; + +import { escapeRuleSubjectLiteral, matchesGlobRuleSubject } from '../../support/rule-match'; +import type { RuleMatchDecision } from '../../../loop/types'; + +const BASH_RULE_PARSE_OPTIONS = { timeoutMs: 100, maxNodes: 50_000 } as const; + +const COMMAND_LIKE_TYPES: ReadonlySet = new Set([ + 'command', + 'test_command', + 'declaration_command', + 'unset_command', +]); + +const SUBSTITUTION_TYPES: ReadonlySet = new Set([ + 'command_substitution', + 'process_substitution', +]); + +export type BashRuleParser = (source: string, options?: ParseOptions) => ParseResult; + +export function matchesDecomposedCommandRule( + ruleArgs: string, + command: string, + decision: RuleMatchDecision | undefined, + parts: () => readonly string[] | null, +): boolean { + if (decision === 'allow') { + const resolved = parts(); + if ( + resolved !== null && + resolved.length > 0 && + resolved.every((part) => matchesGlobRuleSubject(ruleArgs, part)) + ) { + return true; + } + return ruleArgs === escapeRuleSubjectLiteral(command); + } + if (matchesGlobRuleSubject(ruleArgs, command)) return true; + if (decision === undefined) return false; + const resolved = parts(); + return resolved !== null && resolved.some((part) => matchesGlobRuleSubject(ruleArgs, part)); +} + +export function createCommandPartsProvider( + parser: BashRuleParser, + command: string, +): () => readonly string[] | null { + let cached: readonly string[] | null | undefined; + return () => { + if (cached === undefined) cached = computeCommandParts(parser, command); + return cached; + }; +} + +function computeCommandParts(parser: BashRuleParser, command: string): readonly string[] | null { + const parsed = parser(command, BASH_RULE_PARSE_OPTIONS); + if (!parsed.ok || parsed.hasError) return null; + return extractCommandParts(parsed.rootNode); +} + +export function extractCommandParts(root: SyntaxNode): string[] { + const parts: string[] = []; + const stack: Array = [[root, false]]; + while (stack.length > 0) { + const [node, covered] = stack.pop()!; + let childrenCovered = covered; + if (SUBSTITUTION_TYPES.has(node.type)) { + childrenCovered = false; + } else if (COMMAND_LIKE_TYPES.has(node.type)) { + if (!covered) parts.push(executableText(node)); + childrenCovered = true; + } else if (node.type === 'variable_assignment') { + if (!covered) parts.push(node.text); + childrenCovered = true; + } else if (node.type === 'redirected_statement') { + if (!covered) parts.push(node.text); + childrenCovered = node.children.some( + (child) => child.isNamed && COMMAND_LIKE_TYPES.has(child.type), + ); + } + for (let i = node.children.length - 1; i >= 0; i -= 1) { + const child = node.children[i]!; + if (child.isNamed) stack.push([child, childrenCovered]); + } + } + return parts; +} + +function executableText(node: SyntaxNode): string { + const assignments = leadingAssignments(node); + if (assignments.length === 0) return node.text; + return node.text.slice(assignments[assignments.length - 1]!.endIndex).trim(); +} + +function leadingAssignments(node: SyntaxNode): SyntaxNode[] { + const assignments: SyntaxNode[] = []; + for (const child of node.children) { + if (child.type === 'variable_assignment') assignments.push(child); + else break; + } + return assignments; +} diff --git a/packages/agent-core/src/tools/support/path-glob-match.ts b/packages/agent-core/src/tools/support/path-glob-match.ts index d3531fe163..00f177e26c 100644 --- a/packages/agent-core/src/tools/support/path-glob-match.ts +++ b/packages/agent-core/src/tools/support/path-glob-match.ts @@ -15,23 +15,58 @@ interface PathMatchSemantics { readonly pathClass: PathClass; } +const SLASH_PLACEHOLDER = '\0'; + /** * Match ordinary string fields, like command text or search patterns. * `*` and `**` work as wildcards, but the value is not treated as a file path. */ export function globMatch(value: string, pattern: string, options?: { nocase?: boolean }): boolean { - if (picomatch.isMatch(value, pattern, options)) return true; + // Try the historical path-semantics match first so rules that matched + // before keep matching (e.g. `a/**/b` still matches `a/b`). + if (pathSegmentGlobMatch(value, pattern, options)) return true; + + // Then match the subject as opaque text: picomatch gives wildcards path + // semantics (`*` stops at `/` and refuses dot segments), so rewrite `/` to + // a placeholder and allow dots instead. NUL-bearing subjects skip this: + // the historical match above already compares them literally, and the + // rewrite must stay injective so distinct subjects cannot collide. + if (value.includes(SLASH_PLACEHOLDER) || pattern.includes(SLASH_PLACEHOLDER)) return false; + + const opaqueOptions = { ...options, dot: true }; + if (picomatch.isMatch(asOpaqueText(value), asOpaqueText(pattern), opaqueOptions)) return true; const normalizedValue = stripLeadingDotSlash(value); const normalizedPattern = stripLeadingDotSlash(pattern); if (normalizedValue === value && normalizedPattern === pattern) return false; - return picomatch.isMatch(normalizedValue, normalizedPattern, options); + return picomatch.isMatch( + asOpaqueText(normalizedValue), + asOpaqueText(normalizedPattern), + opaqueOptions, + ); +} + +function asOpaqueText(value: string): string { + return value.replaceAll('/', SLASH_PLACEHOLDER); } function stripLeadingDotSlash(value: string): string { return value.startsWith('./') ? value.slice(2) : value; } +function pathSegmentGlobMatch( + value: string, + pattern: string, + options?: { nocase?: boolean }, +): boolean { + if (picomatch.isMatch(value, pattern, options)) return true; + + const normalizedValue = stripLeadingDotSlash(value); + const normalizedPattern = stripLeadingDotSlash(pattern); + if (normalizedValue === value && normalizedPattern === pattern) return false; + return picomatch.isMatch(normalizedValue, normalizedPattern, options); +} + /** * Match file path fields, like Read/Write/Edit `path`. * Also compares normalized forms, so `./a`, `dir/../a`, and Windows @@ -45,11 +80,11 @@ export function pathGlobMatch( const semantics = pathMatchSemantics(value, pattern, pathOptions); const nocase = pathOptions?.caseInsensitivePaths ?? true; - if (globMatch(value, pattern, { nocase })) return true; + if (pathSegmentGlobMatch(value, pattern, { nocase })) return true; for (const valueVariant of pathVariants(value, semantics, pathOptions)) { for (const patternVariant of pathVariants(pattern, semantics, pathOptions)) { - if (globMatch(valueVariant, patternVariant, { nocase })) return true; + if (pathSegmentGlobMatch(valueVariant, patternVariant, { nocase })) return true; } } return false; diff --git a/packages/agent-core/test/agent/permission.test.ts b/packages/agent-core/test/agent/permission.test.ts index 6ebf967680..aaab1d9f42 100644 --- a/packages/agent-core/test/agent/permission.test.ts +++ b/packages/agent-core/test/agent/permission.test.ts @@ -1,5 +1,6 @@ import type { Kaos } from '@moonshot-ai/kaos'; import type { ToolCall } from '@moonshot-ai/kosong'; +import { parse } from '@moonshot-ai/tree-sitter-bash'; import * as posixPath from 'node:path/posix'; import { describe, expect, it, vi } from 'vitest'; @@ -24,13 +25,18 @@ import { FallbackAskPermissionPolicy } from '../../src/agent/permission/policies import { createPermissionDecisionPolicies } from '../../src/agent/permission/policies'; import { SwarmModeAgentSwarmApprovePermissionPolicy } from '../../src/agent/permission/policies/swarm-mode-agent-swarm-approve'; import { YoloModeApprovePermissionPolicy } from '../../src/agent/permission/policies/yolo-mode-approve'; -import { ToolAccesses } from '../../src/loop'; +import { ToolAccesses, type RuleMatchContext, type RuleMatchDecision } from '../../src/loop'; import type { ToolInputDisplay } from '../../src/tools/display'; import { + escapeRuleSubjectLiteral, literalRulePattern, matchesPathRuleSubject, matchesGlobRuleSubject, } from '../../src/tools/support/rule-match'; +import { + createCommandPartsProvider, + matchesDecomposedCommandRule, +} from '../../src/tools/builtin/shell/command-parts'; import { createFakeKaos } from '../tools/fixtures/fake-kaos'; import { createCommandKaos, testAgent } from './harness/agent'; @@ -3799,6 +3805,45 @@ describe('Permission rule helpers', () => { expect(ruleMatches(permissionRule('Bad(unclosed'), 'Bad', {})).toBe(false); }); + it('matches glob rule subjects as opaque text rather than as paths', () => { + expect(matchesGlobRuleSubject('rm -rf*', 'rm -rf x')).toBe(true); + expect(matchesGlobRuleSubject('rm -rf*', 'rm -rf /tmp/x')).toBe(true); + expect(matchesGlobRuleSubject('rm -rf *', 'rm -rf build/x')).toBe(true); + expect(matchesGlobRuleSubject('git *', 'git commit -m "fix src/a.ts"')).toBe(true); + expect(matchesGlobRuleSubject('rm -rf*', 'rm -rf ./build')).toBe(true); + expect(matchesGlobRuleSubject('rm -rf*', 'rm -rf ~/.ssh')).toBe(true); + expect(matchesGlobRuleSubject('rm -rf*', 'rm -rf /home/u/.ssh')).toBe(true); + expect(matchesGlobRuleSubject('https://example.com/*', 'https://example.com/a/b')).toBe(true); + expect(matchesGlobRuleSubject('*acme corp*', 'news about acme corp / rivals')).toBe(true); + expect(matchesGlobRuleSubject('**rm**', 'rm -rf /tmp/x')).toBe(true); + expect(matchesGlobRuleSubject('git *', 'git status')).toBe(true); + expect(matchesGlobRuleSubject('git *', 'git2 status')).toBe(false); + expect(matchesGlobRuleSubject('rm -rf*', 'git status')).toBe(false); + expect(matchesGlobRuleSubject('git log -- src/*.ts', 'git log -- srcXx.ts')).toBe(false); + expect(matchesGlobRuleSubject('https://example.com/a', 'https://example.com/b')).toBe(false); + expect(matchesGlobRuleSubject('!git *', 'git commit -m "fix src/a.ts"')).toBe(false); + expect(matchesGlobRuleSubject('!git *', 'npm test')).toBe(true); + }); + + it('keeps historical glob matches that opaque-text semantics alone would drop', () => { + expect(matchesGlobRuleSubject('**/*.ts', 'a.ts')).toBe(true); + expect(matchesGlobRuleSubject('a/**/b', 'a/b')).toBe(true); + expect(matchesGlobRuleSubject('a/**/b', 'a/x/y/b')).toBe(true); + }); + + it('keeps NUL-bearing subjects distinct instead of conflating them', () => { + expect(matchesGlobRuleSubject('ab', 'a\u0000b')).toBe(false); + expect(matchesGlobRuleSubject('a/b', 'a\u0000b')).toBe(false); + expect(matchesGlobRuleSubject('a\u0000b', 'a\u0000b')).toBe(true); + expect(matchesGlobRuleSubject('a*', 'a\u0000b')).toBe(true); + }); + + it('keeps path rule subjects on path semantics where * does not cross /', () => { + expect(matchesPathRuleSubject('src/*', 'src/a.ts')).toBe(true); + expect(matchesPathRuleSubject('src/**', 'src/sub/a.ts')).toBe(true); + expect(matchesPathRuleSubject('src/*', 'src/sub/a.ts')).toBe(false); + }); + it('does not match rule arguments without an execution matcher', () => { expect( ruleMatches(permissionRule('Custom("query":"a.b")'), 'Custom', { @@ -3864,12 +3909,208 @@ describe('Permission rule helpers', () => { expect( ruleMatches(permissionRule('Read(semantic match)'), 'Read', { path: '/workspace/a.ts' }, execution), ).toBe(true); - expect(execution.matchesRule).toHaveBeenCalledWith('semantic match'); + expect(execution.matchesRule).toHaveBeenCalledWith('semantic match', { decision: 'allow' }); expect( ruleMatches(permissionRule('Read(other)'), 'Read', { path: '/workspace/a.ts' }, execution), ).toBe(false); }); + it('passes the rule decision through matchPermissionRule', () => { + let seen: RuleMatchContext | undefined; + const execution: PermissionRuleMatchExecution = { + matchesRule: (_ruleArgs, context) => { + seen = context; + return true; + }, + }; + + expect( + ruleMatches(permissionRule('Bash(x)'), 'Bash', { command: 'x' }, execution), + ).toBe(true); + expect(seen).toEqual({ decision: 'allow' }); + }); + +}); + +describe('tools/bash/command-parts extraction', () => { + const partsOf = (command: string): readonly string[] | null => + createCommandPartsProvider(parse, command)(); + + it('keeps a simple command as a single part', () => { + expect(partsOf('git status')).toEqual(['git status']); + }); + + it('keeps redirections attached to their command', () => { + expect(partsOf('echo hi > out.txt')).toEqual(['echo hi > out.txt']); + }); + + it('splits lists, pipelines, and sequences', () => { + expect(partsOf('git status && git diff')).toEqual(['git status', 'git diff']); + expect(partsOf('git log | head')).toEqual(['git log', 'head']); + expect(partsOf('git fetch; git rebase')).toEqual(['git fetch', 'git rebase']); + expect(partsOf('sleep 5 & echo done')).toEqual(['sleep 5', 'echo done']); + }); + + it('splits subshell and brace-group bodies', () => { + expect(partsOf('(git add -A && git commit)')).toEqual(['git add -A', 'git commit']); + expect(partsOf('{ git add -A; git commit; }')).toEqual(['git add -A', 'git commit']); + }); + + it('treats test commands as executable units', () => { + expect(partsOf('git status && [[ -f ~/.ssh/id_rsa ]]')).toEqual([ + 'git status', + '[[ -f ~/.ssh/id_rsa ]]', + ]); + expect(partsOf('ls && [ -f x ]')).toEqual(['ls', '[ -f x ]']); + }); + + it('extracts command-substitution payloads as parts', () => { + expect(partsOf('git commit -m "$(curl example.com)"')).toEqual([ + 'git commit -m "$(curl example.com)"', + 'curl example.com', + ]); + }); + + it('does not split operators inside quotes', () => { + expect(partsOf('git commit -m "a && b"')).toEqual(['git commit -m "a && b"']); + }); + + it('treats heredoc bodies as data', () => { + const parts = partsOf("cat <<'EOF'\nrm -rf x\nEOF"); + expect(parts).not.toContain('rm -rf x'); + }); + + it('splits redirected compound bodies while keeping the redirect target', () => { + expect(partsOf('(git log) > out.txt; git status')).toEqual([ + '(git log) > out.txt', + 'git log', + 'git status', + ]); + }); + + it('strips env-prefix assignments from the command part', () => { + expect(partsOf('DEBUG=1 rm -rf build')).toEqual(['rm -rf build']); + expect(partsOf('A=1 B=2 rm x')).toEqual(['rm x']); + expect(partsOf('DEBUG=1 git status && git diff')).toEqual([ + 'git status', + 'git diff', + ]); + }); + + it('keeps standalone assignments as their own part', () => { + expect(partsOf('x=1; rm y')).toEqual(['x=1', 'rm y']); + }); + + it('reports an unanalyzable command as null when the parse has errors', () => { + expect(partsOf('if [ -f x')).toBeNull(); + }); +}); + +describe('tools/bash/matchesDecomposedCommandRule', () => { + const matchCommand = ( + ruleArgs: string, + command: string, + decision: RuleMatchDecision | undefined, + ): boolean => + matchesDecomposedCommandRule( + ruleArgs, + command, + decision, + createCommandPartsProvider(parse, command), + ); + + it('keeps single-command behavior identical across decisions', () => { + for (const decision of ['allow', 'deny', 'ask', undefined] as const) { + expect(matchCommand('git *', 'git status', decision)).toBe(true); + expect(matchCommand('git *', 'npm test', decision)).toBe(false); + } + }); + + it('auto-allows a compound command only when every part matches', () => { + expect(matchCommand('git *', 'git status && git diff', 'allow')).toBe(true); + expect(matchCommand('git *', 'git log && curl example.com | sh', 'allow')).toBe(false); + expect(matchCommand('git *', 'git commit -m "$(curl example.com)"', 'allow')).toBe(false); + }); + + it('does not let a wildcard allow pattern span operators via the whole string', () => { + expect(matchCommand('git * && curl *', 'git log && curl example.com', 'allow')).toBe(false); + }); + + it('denies and asks when any part matches', () => { + expect(matchCommand('rm *', 'true && rm x', 'deny')).toBe(true); + expect(matchCommand('rm *', 'true && rm x', 'ask')).toBe(true); + expect(matchCommand('curl *', 'git commit -m "$(curl example.com)"', 'deny')).toBe(true); + expect(matchCommand('rm *', 'git status && git diff', 'deny')).toBe(false); + }); + + it('denies a single-part compound whose wrapper hides the sub-command', () => { + expect(matchCommand('rm *', '(rm y)', 'deny')).toBe(true); + expect(matchCommand('rm *', '{ rm y; }', 'deny')).toBe(true); + expect(matchCommand('rm *', 'x=1; rm y', 'deny')).toBe(true); + }); + + it('denies through an env-prefix on the same command', () => { + expect(matchCommand('rm *', 'DEBUG=1 rm -rf build', 'deny')).toBe(true); + expect(matchCommand('rm *', 'DEBUG=1 rm -rf build', 'ask')).toBe(true); + }); + + it('auto-allows a command with an env-prefix when the command matches', () => { + expect(matchCommand('git *', 'DEBUG=1 git status', 'allow')).toBe(true); + expect(matchCommand('rm *', 'DEBUG=1 git status', 'allow')).toBe(false); + }); + + it('lets a deny rule see slash-crossing subjects in compound commands', () => { + expect(matchCommand('rm *', 'git status && rm -rf build/x', 'deny')).toBe(true); + expect(matchCommand('rm *', 'rm -rf build/x', 'deny')).toBe(true); + }); + + it('does not auto-allow when the command cannot be parsed', () => { + // Budget exhaustion / parse errors must fail closed for allow, never fall + // back to whole-string wildcard approval. The arithmetic node bomb + // exceeds the parse node budget and returns `aborted`. + const bomb = `echo $((1+${'1+'.repeat(60_000)}1))`; + expect(matchCommand('git *', bomb, 'allow')).toBe(false); + expect(matchCommand('git *', 'if [ -f x', 'allow')).toBe(false); + }); + + it('does not auto-allow a compound command that redirects into a file', () => { + expect(matchCommand('git *', '(git log) > out.txt; git status', 'allow')).toBe(false); + }); + + it('does not auto-allow when a chained test command is unmatched', () => { + expect(matchCommand('git *', 'git status && [[ -f ~/.ssh/id_rsa ]]', 'allow')).toBe(false); + expect(matchCommand('git *', 'git status && [ -f secret ]', 'allow')).toBe(false); + }); + + it('keeps whole-string matching without a decision', () => { + expect(matchCommand('rm *', 'true && rm x', undefined)).toBe(false); + expect(matchCommand('git *', 'git log && curl example.com', undefined)).toBe(true); + }); + + it('round-trips session-approval literal patterns for compound commands', () => { + const command = 'git add -A && git commit'; + expect(matchCommand(escapeRuleSubjectLiteral(command), command, 'allow')).toBe(true); + expect(matchCommand(escapeRuleSubjectLiteral(command), 'git add -A && rm x', 'allow')).toBe( + false, + ); + }); + + it('matches through Bash rule patterns end to end with decision passthrough', () => { + const bashExecution = (command: string): PermissionRuleMatchExecution => ({ + matchesRule: (ruleArgs, context) => + matchesDecomposedCommandRule( + ruleArgs, + command, + context?.decision, + createCommandPartsProvider(parse, command), + ), + }); + const allowRule: PermissionRule = { decision: 'allow', scope: 'user', pattern: 'Bash(git *)' }; + const denyRule: PermissionRule = { decision: 'deny', scope: 'user', pattern: 'Bash(rm *)' }; + expect(ruleMatches(allowRule, 'Bash', { command: 'git status && git diff' }, bashExecution('git status && git diff'))).toBe(true); + expect(ruleMatches(allowRule, 'Bash', { command: 'git log && curl example.com' }, bashExecution('git log && curl example.com'))).toBe(false); + expect(ruleMatches(denyRule, 'Bash', { command: 'true && rm x' }, bashExecution('true && rm x'))).toBe(true); + }); }); function bashCall(): ToolCall { diff --git a/packages/agent-core/test/errors/serialize.test.ts b/packages/agent-core/test/errors/serialize.test.ts index 073f114806..883f21a667 100644 --- a/packages/agent-core/test/errors/serialize.test.ts +++ b/packages/agent-core/test/errors/serialize.test.ts @@ -1,6 +1,11 @@ -import { APIProviderQuotaExhaustedError, APIStatusError } from '@moonshot-ai/kosong'; +import { + APIContextOverflowError, + APIProviderQuotaExhaustedError, + APIStatusError, +} from '@moonshot-ai/kosong'; import { describe, expect, it } from 'vitest'; +import { ErrorCodes } from '#/errors/codes'; import { toKimiErrorPayload } from '#/errors/serialize'; const NGINX_413_HTML = @@ -47,6 +52,15 @@ describe('toKimiErrorPayload — APIStatusError message sanitization', () => { 'provider.auth_error', ); }); + + it('maps a context-limit 401 (APIContextOverflowError) to context.overflow, not auth', () => { + const payload = toKimiErrorPayload( + new APIContextOverflowError(401, 'k3-256k supports only 256K context.'), + ); + expect(payload.code).toBe(ErrorCodes.CONTEXT_OVERFLOW); + expect(payload.message).toBe('k3-256k supports only 256K context.'); + expect(payload.details).toMatchObject({ statusCode: 401 }); + }); }); describe('toKimiErrorPayload — quota-exhausted 429', () => { diff --git a/packages/agent-core/test/session/provider-manager.test.ts b/packages/agent-core/test/session/provider-manager.test.ts new file mode 100644 index 0000000000..108abd1bf0 --- /dev/null +++ b/packages/agent-core/test/session/provider-manager.test.ts @@ -0,0 +1,65 @@ +import { APIContextOverflowError, APIStatusError } from '@moonshot-ai/kosong'; +import { describe, expect, it } from 'vitest'; + +import { ErrorCodes } from '#/errors/codes'; +import { ProviderManager } from '#/session/provider-manager'; +import type { KimiConfig } from '#/config'; + +function makeOAuthProviderManager(): ProviderManager { + const tokenProvider = { getAccessToken: async () => 'tok' }; + return new ProviderManager({ + config: { + providers: { + kimi: { + type: 'kimi', + model: 'kimi-code/k3-256k', + oauth: { storage: 'file', key: 'kimi' }, + }, + }, + models: { + 'kimi-code/k3-256k': { + provider: 'kimi', + model: 'kimi-code/k3-256k', + maxContextSize: 262144, + }, + }, + } as KimiConfig, + resolveOAuthTokenProvider: () => tokenProvider, + }); +} + +describe('ProviderManager.resolveAuth — 401 refresh gate', () => { + it('does not force a token refresh for a context-limit 401', async () => { + const manager = makeOAuthProviderManager(); + const withAuth = manager.resolveAuth('kimi-code/k3-256k'); + expect(withAuth).toBeDefined(); + + let calls = 0; + const failure = await withAuth!(async () => { + calls += 1; + throw new APIContextOverflowError(401, 'k3-256k supports only 256K context.'); + }).catch((error: unknown) => error); + + // The context-overflow error passes through untouched: no refresh, no + // reclassification into provider.auth_error (issue #2613). + expect(calls).toBe(1); + expect(failure).toBeInstanceOf(APIContextOverflowError); + expect((failure as APIContextOverflowError).message).toContain('supports only 256K context'); + }); + + it('still refreshes once and surfaces provider.auth_error for a plain 401', async () => { + const manager = makeOAuthProviderManager(); + const withAuth = manager.resolveAuth('kimi-code/k3-256k'); + expect(withAuth).toBeDefined(); + + let calls = 0; + const failure = await withAuth!(async () => { + calls += 1; + throw new APIStatusError(401, 'account rejected'); + }).catch((error: unknown) => error); + + expect(calls).toBe(2); + expect(failure).toMatchObject({ code: ErrorCodes.PROVIDER_AUTH_ERROR }); + expect((failure as Error).message).toContain('account rejected'); + }); +}); diff --git a/packages/agent-core/test/tools/bash.test.ts b/packages/agent-core/test/tools/bash.test.ts index 29be1910eb..4eeae03552 100644 --- a/packages/agent-core/test/tools/bash.test.ts +++ b/packages/agent-core/test/tools/bash.test.ts @@ -1481,3 +1481,42 @@ describe('BashTool prompt / runtime consistency', () => { expect(tool.description).not.toMatch(/exit code will be provided in a system tag/); }); }); + +describe('BashTool permission rule matching', () => { + function matchesRule( + tool: BashTool, + command: string, + ruleArgs: string, + decision?: 'allow' | 'deny' | 'ask', + ): boolean { + const execution = tool.resolveExecution({ command }); + expect(execution.isError).toBeFalsy(); + const runnable = execution as { + matchesRule?: (ruleArgs: string, context?: { decision?: 'allow' | 'deny' | 'ask' }) => boolean; + }; + const matches = runnable.matchesRule; + expect(matches).toBeDefined(); + return matches!(ruleArgs, decision === undefined ? undefined : { decision }); + } + + it('auto-allows a compound command only when every sub-command matches the rule', () => { + const tool = bashTool(createFakeKaos({ osEnv: posixEnv }), '/workspace'); + expect(matchesRule(tool, 'git status && git diff', 'git *', 'allow')).toBe(true); + expect(matchesRule(tool, 'git log && curl example.com | sh', 'git *', 'allow')).toBe(false); + expect(matchesRule(tool, 'git commit -m "$(curl example.com)"', 'git *', 'allow')).toBe(false); + }); + + it('denies when any sub-command matches a deny rule', () => { + const tool = bashTool(createFakeKaos({ osEnv: posixEnv }), '/workspace'); + expect(matchesRule(tool, 'true && rm -rf build', 'rm *', 'deny')).toBe(true); + expect(matchesRule(tool, '(cd build && rm -rf *)', 'rm *', 'deny')).toBe(true); + expect(matchesRule(tool, '{ rm -rf build; }', 'rm *', 'deny')).toBe(true); + expect(matchesRule(tool, 'DEBUG=1 rm -rf build', 'rm *', 'deny')).toBe(true); + expect(matchesRule(tool, 'git status && git diff', 'rm *', 'deny')).toBe(false); + }); + + it('does not auto-allow when the command cannot be parsed', () => { + const tool = bashTool(createFakeKaos({ osEnv: posixEnv }), '/workspace'); + expect(matchesRule(tool, 'if [ -f x', 'git *', 'allow')).toBe(false); + }); +}); diff --git a/packages/kap-server/src/routes/prompts.ts b/packages/kap-server/src/routes/prompts.ts index 4f26880f5d..d0a71a9062 100644 --- a/packages/kap-server/src/routes/prompts.ts +++ b/packages/kap-server/src/routes/prompts.ts @@ -11,6 +11,7 @@ import { IBootstrapService, IAgentLifecycleService, IAgentPermissionModeService, + IAgentPlanService, IAgentProfileService, IAgentToolPolicyService, IAgentPromptService, @@ -119,6 +120,7 @@ async function resolvePromptFromSession(session: ISessionScopeHandle, agentId?: profile: agent.accessor.get(IAgentProfileService), toolPolicy: agent.accessor.get(IAgentToolPolicyService), permissionMode: agent.accessor.get(IAgentPermissionModeService), + plan: agent.accessor.get(IAgentPlanService), }; } @@ -257,6 +259,17 @@ export function registerPromptsRoutes(app: PromptRouteHost, core: Scope): void { if (req.body.thinking !== undefined && !thinkingConsumed) resolved.profile.setThinking(req.body.thinking); if (req.body.permission_mode !== undefined) resolved.permissionMode.setMode(req.body.permission_mode); + if (req.body.plan_mode !== undefined) { + // Apply-only-when-set, idempotent like the agent_config dispatch: + // plan state changes only when it differs from the request, and an + // omitted field never touches it. The first prompt of a new Web + // session can therefore enter plan mode before its turn is enqueued. + const active = (await resolved.plan.status()) !== null; + if (active !== req.body.plan_mode) { + if (req.body.plan_mode) await resolved.plan.enter(); + else resolved.plan.exit(); + } + } if (req.body.disabled_tools !== undefined) { // A session denylist before bind throws `profile.not_bound` — map it // onto 40001 like the profile-selection errors above. diff --git a/packages/kap-server/test/prompts.test.ts b/packages/kap-server/test/prompts.test.ts index 10ceb752a3..96146279fd 100644 --- a/packages/kap-server/test/prompts.test.ts +++ b/packages/kap-server/test/prompts.test.ts @@ -216,6 +216,71 @@ describe('server-v2 /api/v1 prompts', () => { expect(Array.isArray(list.body.data.queued)).toBe(true); }); + it('enters plan mode when the first prompt requests it', async () => { + const id = await createSession(home as string); + + const submitted = await call('POST', `/api/v1/sessions/${id}/prompts`, { + model: 'stub', + plan_mode: true, + content: [{ type: 'text', text: 'investigate the bug' }], + }); + expect(submitted.body.code).toBe(0); + + const status = await call<{ plan_mode: boolean }>( + 'GET', + `/api/v1/sessions/${id}/status`, + ); + expect(status.body.code).toBe(0); + expect(status.body.data.plan_mode).toBe(true); + }); + + it('exits plan mode when a prompt disables it', async () => { + const id = await createSession(home as string); + await createMainAgent(id); + + const profile = await call('POST', `/api/v1/sessions/${id}/profile`, { + agent_config: { plan_mode: true }, + }); + expect(profile.body.code).toBe(0); + + const submitted = await call('POST', `/api/v1/sessions/${id}/prompts`, { + model: 'stub', + plan_mode: false, + content: [{ type: 'text', text: 'implement the fix' }], + }); + expect(submitted.body.code).toBe(0); + + const status = await call<{ plan_mode: boolean }>( + 'GET', + `/api/v1/sessions/${id}/status`, + ); + expect(status.body.code).toBe(0); + expect(status.body.data.plan_mode).toBe(false); + }); + + it('leaves plan mode untouched when a prompt omits it', async () => { + const id = await createSession(home as string); + await createMainAgent(id); + + const profile = await call('POST', `/api/v1/sessions/${id}/profile`, { + agent_config: { plan_mode: true }, + }); + expect(profile.body.code).toBe(0); + + const submitted = await call('POST', `/api/v1/sessions/${id}/prompts`, { + model: 'stub', + content: [{ type: 'text', text: 'no plan mode preference' }], + }); + expect(submitted.body.code).toBe(0); + + const status = await call<{ plan_mode: boolean }>( + 'GET', + `/api/v1/sessions/${id}/status`, + ); + expect(status.body.code).toBe(0); + expect(status.body.data.plan_mode).toBe(true); + }); + it('makes the first three REST prompts available to title generation', async () => { const id = await createSession(home as string); await createMainAgent(id); diff --git a/packages/kosong/src/errors.ts b/packages/kosong/src/errors.ts index 40c36e3875..1fbb1464d9 100644 --- a/packages/kosong/src/errors.ts +++ b/packages/kosong/src/errors.ts @@ -361,6 +361,10 @@ const CONTEXT_OVERFLOW_MESSAGE_PATTERNS = [ /prompt is too long.*maximum/, /input token count.*exceeds?.*maximum number of tokens/, /request.*exceed(?:ed|s|ing)?.*model token limit/, + // Moonshot's managed API answers context-window violations with a 401 whose + // body names the model's window ("401 k3-256k supports only 256K context."); + // the status alone looks like auth, the message is the discriminator. + /supports only \d+(?:\.\d+)?\s*(?:k|m|b)?\s*(?:context|window|tokens)/, ] as const; const PROVIDER_RATE_LIMIT_MESSAGE_PATTERNS = [ @@ -490,7 +494,12 @@ export function parseRetryAfterMs(headers: unknown): number | null { } export function isContextOverflowStatusError(statusCode: number, message: string): boolean { - if (statusCode !== 400 && statusCode !== 413 && statusCode !== 422) return false; + // 401 is the Kimi managed API's status for plan/capability rejections — a + // context-window limit is one of them, so a message-matched 401 classifies + // as overflow; a plain 401 stays auth. + if (statusCode !== 400 && statusCode !== 401 && statusCode !== 413 && statusCode !== 422) { + return false; + } const lowerMessage = message.toLowerCase(); return CONTEXT_OVERFLOW_MESSAGE_PATTERNS.some((pattern) => pattern.test(lowerMessage)); } diff --git a/packages/kosong/test/errors.test.ts b/packages/kosong/test/errors.test.ts index 2a85aab6fd..75870452ac 100644 --- a/packages/kosong/test/errors.test.ts +++ b/packages/kosong/test/errors.test.ts @@ -235,6 +235,12 @@ describe('normalizeAPIStatusError', () => { [400, 'prompt is too long: 210000 tokens exceeds the maximum'], [400, 'input token count 131072 exceeds the maximum number of tokens allowed'], [400, 'Invalid request: Your request exceeded model token limit: 262144 (requested: 274613)'], + // Kimi managed API signals context-window limits with a 401 body naming + // the model's window ("401 k3-256k supports only 256K context.") — the + // message is the discriminator, not the status. + [401, 'k3-256k supports only 256K context.'], + [401, 'Context length exceeded'], + [401, 'This model supports only 1M context'], ])('normalizes %i "%s" to APIContextOverflowError', (statusCode, message) => { const error = normalizeAPIStatusError(statusCode, message, 'req-context'); expect(error).toBeInstanceOf(APIContextOverflowError); @@ -243,7 +249,6 @@ describe('normalizeAPIStatusError', () => { }); it.each([ - [401, 'Context length exceeded'], [500, 'Context length exceeded'], [400, 'Bad request'], [422, 'Invalid tool schema'], diff --git a/packages/node-sdk/src/sdk-rpc-client-v2.ts b/packages/node-sdk/src/sdk-rpc-client-v2.ts index d32b6e0a7b..b740d64c96 100644 --- a/packages/node-sdk/src/sdk-rpc-client-v2.ts +++ b/packages/node-sdk/src/sdk-rpc-client-v2.ts @@ -120,7 +120,7 @@ * interaction bridge already relies on. */ import { randomUUID } from 'node:crypto'; -import { readdir } from 'node:fs/promises'; +import { readFile, readdir } from 'node:fs/promises'; import { join } from 'node:path'; import { @@ -130,6 +130,8 @@ import { KimiError, limitAgentReplayByTurns, noopTelemetryClient, + parseAgentFileText, + resolveAgentPath, type AgentContextData, type BeginGlobalMcpServerAuthResult, type ExperimentalFeatureState, @@ -176,6 +178,7 @@ import { IBootstrapService, IConfigService, IEventService, + IExplicitAgentProfileLoader, IHostEnvironment, IHostFileSystem, IModelService, @@ -332,6 +335,19 @@ export interface SDKRpcClientV2Options { * source. Passed into the engine through `BootstrapInput.args.skillDirs`. */ readonly skillDirs?: readonly string[]; + /** + * Explicit agent files (the CLI's `--agent-file`): loaded at the highest + * catalog priority for every workspace this client hosts, on top of the + * discovered user / project / plugin agent roots. Passed into the engine + * through `BootstrapInput.args.agentFiles`, which is where the + * workspace-scoped explicit loader reads them from — a session selects one + * of the profiles they define with `createSession`'s `agentProfile`. + * + * The scope is this client's own engine bootstrap, not the OS process: a + * second client in the same process bootstraps its own engine and sees none + * of these files. + */ + readonly agentFiles?: readonly string[]; readonly telemetry?: TelemetryClient; readonly onOAuthRefresh?: (outcome: OAuthRefreshOutcome) => void; readonly uiMode?: string; @@ -372,6 +388,15 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { * here by deleting the entry in `closeSession` / `reloadSession`. */ private readonly printSteerStates = new Map(); + /** + * The explicit agent files this client was constructed with + * (`SDKRpcClientV2Options.agentFiles`), kept as the base of the engine's + * `agentFiles` channel: a `createSession` with its own `agentFiles` overrides + * the channel for that session, and the next session without files restores + * this base — so a per-session file never leaks into later sessions while + * the launch-level registration survives. + */ + private readonly baseAgentFiles: readonly string[] | undefined; /** * The model/provider registries (`IModelService` / `IProviderService`) * share the config service's ready trap: their `get`/`list` reads are @@ -429,6 +454,7 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { }); ensureKimiHome(this.homeDir); this.telemetry = options.telemetry ?? noopTelemetryClient; + this.baseAgentFiles = options.agentFiles; this.auth = new KimiAuthFacade({ homeDir: this.homeDir, configPath: this.configPath, @@ -450,6 +476,11 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { // `--skills-dir` (v1 parity): explicit skill dirs replace default // user / project discovery for every session this client hosts. skillDirs: options.skillDirs, + // `--agent-file` (v1 parity): explicit agent definition files, added + // to every workspace catalog at the highest priority. Passed through + // unresolved — the engine expands `~` and resolves relative paths + // against the workspace root, mirroring `skillDirs`. + agentFiles: options.agentFiles, }, }, [...logSeed(resolveLoggingConfig({ homeDir: this.homeDir, env: process.env }))], @@ -1217,12 +1248,39 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { // Wired before the optional main-agent materialization so a profile-bind // warning (oversized AGENTS.md) reaches the listeners like v1's create. this.wireSession(handle); + // `--agent` / `--agent-file` select the startup session's main-agent + // profile: an explicit `agentProfile` name wins; otherwise the first + // `agentFiles` entry contributes the profile under its frontmatter `name`. + const agentProfileName = await this.resolveStartupAgentProfile(input, workDir); + // Register the session's `--agent-file` files with the engine before the + // main agent binds: the workspace explicit loader reads only + // `IBootstrapService.args.agentFiles` (seeded from the client option), so + // a file supplied solely through this session's `agentFiles` would + // otherwise be absent from the session catalog and `profile.bind` would + // reject it as an unknown profile. The next session without files restores + // the client's base, so a per-session file does not leak into later + // sessions of this client. + const bootstrapService = this.engineAccessor.get(IBootstrapService); + // `bootstrap.args` is the plain object this client seeded at bootstrap; + // the `HostArgs` fields are typed `readonly`, but nothing re-derives + // them after construction, so the session's files ride the same channel + // the client option seeds. + const hostArgs = bootstrapService.args as { agentFiles?: readonly string[] }; + // Only touch the channel when this session has files or a previous session + // (or the client option) seeded it — restoring the base on the no-file + // path stops a prior session's files leaking into later sessions. + if (input.agentFiles !== undefined || hostArgs.agentFiles !== undefined) { + hostArgs.agentFiles = input.agentFiles ?? this.baseAgentFiles; + await handler.accessor.get(IExplicitAgentProfileLoader).reload(); + } if ( + agentProfileName !== undefined || input.model !== undefined || input.thinking !== undefined || input.permission !== undefined ) { const agent = await this.materializeMainAgent(handle, { + profile: agentProfileName, model: input.model, thinking: input.thinking, }); @@ -1499,29 +1557,32 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { /** * The session's materialized main agent with v1's eager default binding * applied: a freshly created agent whose profile is still unbound gets the - * default profile + configured default model (the same bind kap-server's - * prompt route performs on first use). A home with no configured model - * leaves the agent unbound instead of failing — v1's model-less session - * reads (`model: undefined`, `'off'` thinking, zero capabilities) map onto - * the unbound state exactly. + * requested startup profile (an explicit `--agent` / `--agent-file` + * selection) or, without one, the default profile + configured default + * model (the same bind kap-server's prompt route performs on first use). A + * home with no configured model leaves the agent unbound instead of failing + * — v1's model-less session reads (`model: undefined`, `'off'` thinking, + * zero capabilities) map onto the unbound state exactly. An agent that is + * already bound (a resumed profile restored from wire) is left alone so an + * existing binding is never overwritten. */ private async materializeMainAgent( session: ISessionScopeHandle, - binding?: { readonly model?: string; readonly thinking?: string }, + binding?: { readonly profile?: string; readonly model?: string; readonly thinking?: string }, ): Promise { await this.modelReady; const agent = await ensureMainAgent(session); const profile = agent.accessor.get(IAgentProfileService); - if (binding !== undefined || profile.data().profileName === undefined) { + if (binding?.profile !== undefined || profile.data().profileName === undefined) { try { await profile.bind({ - profile: DEFAULT_AGENT_PROFILE_NAME, + profile: binding?.profile ?? DEFAULT_AGENT_PROFILE_NAME, model: binding?.model, thinking: binding?.thinking, }); } catch (error) { if ( - binding === undefined && + binding?.profile === undefined && error instanceof ProfileError && error.code === ProfileErrors.codes.MODEL_NOT_CONFIGURED ) { @@ -1533,6 +1594,50 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { return agent; } + /** + * Resolve the `--agent` / `--agent-file` main-agent profile for a new + * session: an explicit `--agent` name wins; otherwise the first `--agent-file` + * contributes the profile under its frontmatter `name` (parsed here, fatal on + * error, so a bad file fails before any session materializes — the same + * precedence print mode applies in `run-v2-print`). + */ + private async resolveStartupAgentProfile( + input: CreateSessionOptions, + workDir: string, + ): Promise { + if (input.agentProfile !== undefined) return input.agentProfile; + const agentFile = input.agentFiles?.[0]; + if (agentFile === undefined) return undefined; + const agentFilePath = resolveAgentPath( + agentFile, + workDir, + this.engineAccessor.get(IBootstrapService).osHomeDir, + ); + let text: string; + try { + text = await readFile(agentFilePath, 'utf8'); + } catch (error) { + throw new KimiError( + ErrorCodes.AGENT_NOT_FOUND, + `Failed to read agent file "${agentFilePath}": ${error instanceof Error ? error.message : String(error)}`, + { cause: error }, + ); + } + try { + return parseAgentFileText({ + path: agentFilePath, + source: 'explicit', + text, + }).name; + } catch (error) { + throw new KimiError( + ErrorCodes.AGENT_NOT_FOUND, + `Invalid agent file "${agentFilePath}": ${error instanceof Error ? error.message : String(error)}`, + { cause: error }, + ); + } + } + /** The target agent's live scope handle (see the section header). */ private async agentScope(sessionId: string): Promise { const session = this.requireLiveSession(sessionId); diff --git a/packages/node-sdk/src/types.ts b/packages/node-sdk/src/types.ts index 6e444ce218..658f0c20b1 100644 --- a/packages/node-sdk/src/types.ts +++ b/packages/node-sdk/src/types.ts @@ -118,6 +118,19 @@ export interface KimiHarnessOptions { readonly autoLoadConfig?: boolean | undefined; readonly uiMode?: string; readonly skillDirs?: readonly string[]; + /** + * Explicit agent files (`--agent-file`) registered at the highest catalog + * priority. Registration is per harness, not per session: a session selects + * one of the profiles they define through + * {@link CreateSessionOptions.agentProfile}, and the profiles stay in the + * catalog for every later session this harness creates. Two harnesses in one + * process therefore have separate catalogs. + * + * `createKimiHarnessV2` only: the legacy harness ignores this option and + * takes explicit files per session through + * {@link CreateSessionOptions.agentFiles} instead. + */ + readonly agentFiles?: readonly string[]; readonly telemetry?: TelemetryClient | undefined; readonly onOAuthRefresh?: ((outcome: OAuthRefreshOutcome) => void) | undefined; readonly sessionStartedProperties?: TelemetryProperties; diff --git a/packages/node-sdk/test/sdk-rpc-client-v2.test.ts b/packages/node-sdk/test/sdk-rpc-client-v2.test.ts index 697385b637..daaab6f64f 100644 --- a/packages/node-sdk/test/sdk-rpc-client-v2.test.ts +++ b/packages/node-sdk/test/sdk-rpc-client-v2.test.ts @@ -9,7 +9,7 @@ */ import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { dirname, join } from 'node:path'; import { FileTokenStorage, @@ -32,11 +32,15 @@ import { foldAgentWireReplay } from '#/v2/resume-replay'; import { drainQueryStoreDisposals, drainSessionIndexMirror, + ensureMainAgent, + getLiveSessionById, HostProcessError, + IAgentProfileService, IHostRequestHeaders, ISessionLifecycleService, IWorkspaceLifecycleService, OsProcessErrors, + type ProfileData, } from '@moonshot-ai/agent-core-v2'; import { McpOAuthService } from '../../agent-core/src/mcp/oauth/service'; @@ -1114,6 +1118,158 @@ describe('removeProviderFromConfig', () => { }); }); +describe('SDKRpcClientV2.createSession startup agent profile', () => { + it('binds the --agent profile so its tools/disallowedTools policy applies to the interactive main agent', async () => { + const homeDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-profile-home-')); + tempDirs.push(homeDir); + const workDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-profile-work-')); + tempDirs.push(workDir); + await writeTestModelConfig(homeDir); + await writeAgentFile(join(homeDir, 'agents', 'restricted.md'), { + name: 'restricted', + description: 'Read-only test agent with a tools allowlist.', + tools: ['Read', 'Glob'], + disallowedTools: ['Bash'], + prompt: 'You are a read-only test agent.', + }); + const rpc = new SDKRpcClientV2({ homeDir, identity: TEST_IDENTITY }); + try { + const summary = await rpc.createSession({ + workDir, + model: TEST_MODEL, + agentProfile: 'restricted', + }); + const data = await mainAgentProfileData(rpc, summary.id); + expect(data.profileName).toBe('restricted'); + expect(data.activeToolNames).toEqual(['Read', 'Glob']); + expect(data.disallowedTools).toEqual(['Bash']); + } finally { + await rpc.close(); + } + }); + + it('falls back to the default profile when no --agent is selected', async () => { + const homeDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-profile-home-')); + tempDirs.push(homeDir); + const workDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-profile-work-')); + tempDirs.push(workDir); + await writeTestModelConfig(homeDir); + const rpc = new SDKRpcClientV2({ homeDir, identity: TEST_IDENTITY }); + try { + const summary = await rpc.createSession({ workDir, model: TEST_MODEL }); + const data = await mainAgentProfileData(rpc, summary.id); + expect(data.profileName).toBe('agent'); + } finally { + await rpc.close(); + } + }); + + it('binds an --agent-file profile supplied solely through createSession agentFiles', async () => { + const homeDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-profile-home-')); + tempDirs.push(homeDir); + const workDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-profile-work-')); + tempDirs.push(workDir); + await writeTestModelConfig(homeDir); + // Supplied only through the session's `agentFiles`: the file lives at the + // workDir root — not under /agents and not under any project + // agent root (.kimi-code/agents / .agents/agents) — so only the engine's + // explicit loader (seeded by this client) can register it for the bind. + const agentFilePath = join(workDir, 'explicit-only.md'); + await writeAgentFile(agentFilePath, { + name: 'explicit-only', + description: 'Agent-file-only profile for the interactive --agent-file bind.', + tools: ['Read', 'Glob'], + prompt: 'You are an explicit agent-file-only profile.', + }); + const rpc = new SDKRpcClientV2({ homeDir, identity: TEST_IDENTITY }); + try { + const summary = await rpc.createSession({ + workDir, + model: TEST_MODEL, + agentFiles: [agentFilePath], + }); + const data = await mainAgentProfileData(rpc, summary.id); + expect(data.profileName).toBe('explicit-only'); + expect(data.activeToolNames).toEqual(['Read', 'Glob']); + } finally { + await rpc.close(); + } + }); + + it('binds a profile registered through the client-level agentFiles option', async () => { + const homeDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-profile-home-')); + tempDirs.push(homeDir); + const workDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-profile-work-')); + tempDirs.push(workDir); + const explicitDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-profile-explicit-')); + tempDirs.push(explicitDir); + await writeTestModelConfig(homeDir); + // Outside every discovery root: the profile can only reach the catalog + // through the client-level `agentFiles` registration (the channel the + // harness forwards for the whole launch). + const agentFilePath = join(explicitDir, 'launch-only.md'); + await writeAgentFile(agentFilePath, { + name: 'launch-only', + description: 'Registered at client construction (the harness --agent-file channel).', + tools: ['Read', 'Glob'], + prompt: 'You are a launch-registered profile.', + }); + const rpc = new SDKRpcClientV2({ + homeDir, + identity: TEST_IDENTITY, + agentFiles: [agentFilePath], + }); + try { + const summary = await rpc.createSession({ + workDir, + model: TEST_MODEL, + agentProfile: 'launch-only', + }); + const data = await mainAgentProfileData(rpc, summary.id); + expect(data.profileName).toBe('launch-only'); + expect(data.systemPrompt).toContain('launch-registered profile.'); + } finally { + await rpc.close(); + } + }); + + it('does not leak a per-session --agent-file into a later session created without files', async () => { + const homeDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-profile-home-')); + tempDirs.push(homeDir); + const workDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-profile-work-')); + tempDirs.push(workDir); + await writeTestModelConfig(homeDir); + // Deliberately named after the builtin default profile: while registered, + // the explicit file shadows the builtin for the whole catalog. + const agentFilePath = join(workDir, 'agent.md'); + await writeAgentFile(agentFilePath, { + name: 'agent', + description: 'Shadows the builtin default while registered.', + prompt: 'Shadowed default prompt.', + }); + const rpc = new SDKRpcClientV2({ homeDir, identity: TEST_IDENTITY }); + try { + const first = await rpc.createSession({ + workDir, + model: TEST_MODEL, + agentProfile: 'agent', + agentFiles: [agentFilePath], + }); + expect((await mainAgentProfileData(rpc, first.id)).systemPrompt).toContain( + 'Shadowed default prompt.', + ); + // A later session without files must not inherit the per-session file: + // its default bind resolves the builtin profile, not the shadow. + const later = await rpc.createSession({ workDir, model: TEST_MODEL }); + expect((await mainAgentProfileData(rpc, later.id)).systemPrompt).not.toContain( + 'Shadowed default prompt.', + ); + } finally { + await rpc.close(); + } + }); +}); + async function writeSkill(dir: string, name: string): Promise { await mkdir(dir, { recursive: true }); await writeFile( join(dir, 'SKILL.md'), @@ -1121,3 +1277,57 @@ async function writeSkill(dir: string, name: string): Promise { await mkd 'utf-8', ); } + +const TEST_MODEL = 'kimi-test-model'; + +async function writeTestModelConfig(homeDir: string): Promise { + await writeFile( + join(homeDir, 'config.toml'), + ` +[providers.local] +type = "kimi" +base_url = "https://example.test/v1" +api_key = "sk-test" + +[models."${TEST_MODEL}"] +provider = "local" +model = "${TEST_MODEL}" +max_context_size = 1000 +`, + 'utf-8', + ); +} + +async function writeAgentFile( + path: string, + def: { + readonly name: string; + readonly description: string; + readonly tools?: readonly string[]; + readonly disallowedTools?: readonly string[]; + readonly prompt: string; + }, +): Promise { + await mkdir(dirname(path), { recursive: true }); + const lines = ['---', `name: ${def.name}`, `description: ${def.description}`]; + if (def.tools !== undefined) { + lines.push('tools:'); + lines.push(...def.tools.map((tool) => ` - ${tool}`)); + } + if (def.disallowedTools !== undefined) { + lines.push('disallowedTools:'); + lines.push(...def.disallowedTools.map((tool) => ` - ${tool}`)); + } + lines.push('---', '', def.prompt, ''); + await writeFile(path, lines.join('\n'), 'utf-8'); +} + +/** The profile the session's main agent is actually bound to. */ +async function mainAgentProfileData(rpc: SDKRpcClientV2, sessionId: string): Promise { + const session = getLiveSessionById(rpc.engineAccessor, sessionId); + if (session === undefined) { + throw new Error(`live session "${sessionId}" not found`); + } + const agent = await ensureMainAgent(session); + return agent.accessor.get(IAgentProfileService).data(); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ece50159a3..0ffef41c7e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -513,6 +513,9 @@ importers: '@moonshot-ai/protocol': specifier: workspace:^ version: link:../protocol + '@moonshot-ai/tree-sitter-bash': + specifier: workspace:^ + version: link:../tree-sitter-bash '@mozilla/readability': specifier: ^0.6.0 version: 0.6.0