From 18479f6c2871d507f748bbb9fdcdb3c2e18001f3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 6 Oct 2026 02:49:29 +0000 Subject: [PATCH 01/13] feat: add opt-in lightweight context mode Co-authored-by: DanielWalnut --- README.md | 20 ++++ README_ZH.md | 18 ++++ .../agent-host-compaction-executor.ts | 32 ++++-- .../agent-host/execution/executor.test.ts | 102 ++++++++++++++++++ .../agent-host/execution/executor.ts | 19 +++- .../agent-host/execution/prompt.ts | 36 +++++++ packages/protocol/src/local.ts | 11 ++ packages/tui/src/cli/contract.ts | 25 +++++ packages/tui/src/cli/main.ts | 2 + packages/tui/src/cli/run-exec-command.ts | 1 + packages/tui/src/headless/invocation.ts | 11 ++ packages/tui/src/runtime/adapter.ts | 15 ++- packages/tui/src/runtime/lifecycle.ts | 3 + packages/tui/src/tui/launcher.ts | 4 + packages/tui/test/unit/acp-agent.test.ts | 2 +- .../tui/test/unit/headless-invocation.test.ts | 20 ++++ packages/tui/test/unit/tui-app.test.ts | 26 +++++ .../tui/test/unit/tui-runtime-adapter.test.ts | 45 ++++++++ 18 files changed, 376 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 2203463a2..d6a5a08da 100644 --- a/README.md +++ b/README.md @@ -146,6 +146,26 @@ Use `mcode init .` to generate or update project guidance in `AGENTS.md`. Descri | Headless | `mcode exec [prompt]` | Shell scripts, CI, batch work, and evaluations. | | ACP | `mcode acp` | Editors and clients supporting Agent Client Protocol. | +### Lightweight conversations + +For simple conversation or general knowledge questions, start a new Session with +`--mode lightweight`: + +```bash +mcode --mode lightweight "Explain how DNS caching works." +mcode exec --mode lightweight "Summarize the CAP theorem." +``` + +Lightweight mode sends a small conversational system prompt and no tool schemas. It does not load +workspace instructions, Skills, memory, MCP tools, or environment details, so it cannot inspect or +change local files. Start a new standard Session for coding or tool use with +`mcode --mode standard` or `mcode exec --mode standard`; omitting `--mode` is identical to standard +mode. + +The mode is fixed when a root Session is created. `--mode lightweight` therefore cannot be combined +with `--session` or `--continue`. Child/sub-agent Sessions do not inherit it, `/compact` continues to +use the standard compaction context, and ACP and desktop Sessions remain standard. + ### Continue your work ```bash diff --git a/README_ZH.md b/README_ZH.md index c239078bf..9376fe54e 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -146,6 +146,24 @@ mcode "Find a failing test, fix the implementation, and run the relevant tests." | Headless | `mcode exec [prompt]` | Shell、CI、批处理与评测。 | | ACP | `mcode acp` | 支持 Agent Client Protocol 的编辑器与客户端。 | +### 轻量对话 + +对于简单对话或通用知识问答,可用 `--mode lightweight` 创建新会话: + +```bash +mcode --mode lightweight "解释 DNS 缓存的工作原理。" +mcode exec --mode lightweight "概述 CAP 定理。" +``` + +轻量模式只发送精简的对话系统提示词,不发送工具 schema,也不会加载工作区指令、Skills、 +memory、MCP 工具或环境信息,因此不能检查或修改本地文件。需要编程或工具能力时,请使用 +`mcode --mode standard` 或 `mcode exec --mode standard` 创建新的标准会话;省略 `--mode` +与标准模式完全一致。 + +模式在根会话创建时固定,因此 `--mode lightweight` 不能与 `--session` 或 `--continue` +同时使用。子代理/子会话不会继承轻量模式,`/compact` 仍使用标准压缩上下文,ACP 与桌面端 +会话也始终使用标准模式。 + ### 继续之前的工作 ```bash diff --git a/packages/local-runtime-v2/src/service/turn-system/agent-host/compaction/agent-host-compaction-executor.ts b/packages/local-runtime-v2/src/service/turn-system/agent-host/compaction/agent-host-compaction-executor.ts index 9cd9cfccf..d80e8264d 100644 --- a/packages/local-runtime-v2/src/service/turn-system/agent-host/compaction/agent-host-compaction-executor.ts +++ b/packages/local-runtime-v2/src/service/turn-system/agent-host/compaction/agent-host-compaction-executor.ts @@ -56,7 +56,11 @@ import { createCompactionLifecycleMetadata, createManualCompactionChange, } from './compaction-history.js'; -import { joinPrompt, readPreparedSystemPrompt } from '../execution/prompt.js'; +import { + joinPrompt, + readPreparedSystemPrompt, + resolveProviderContextMode, +} from '../execution/prompt.js'; import { localPluginHookCoordinator } from '../assembly/local-turn-plugin-hooks.js'; import { createAgentHostPluginHookTranscript, @@ -507,6 +511,21 @@ export class AgentHostCompactionExecutor< sessionId: input.lease.sessionId, signal: input.lease.signal, }); + const providerContext = resolveProviderContextMode( + session, + { + systemPrompt: joinPrompt( + assembly.systemPromptPrefix, + readPreparedSystemPrompt(preparation.agentConfig), + ), + tools: assembly.tools.map(({ def }) => ({ + name: def.name, + description: def.description, + parameters: def.schema, + })), + }, + 'compaction', + ); const compaction = captureContextCompactionResult( await compactionDependencies.manual.compactManual( createManualCompactionRequest({ @@ -516,15 +535,8 @@ export class AgentHostCompactionExecutor< thinkingLevel, streamFn, payloadTransform, - systemPrompt: joinPrompt( - assembly.systemPromptPrefix, - readPreparedSystemPrompt(preparation.agentConfig), - ), - tools: assembly.tools.map(({ def }) => ({ - name: def.name, - description: def.description, - parameters: def.schema, - })), + systemPrompt: providerContext.systemPrompt, + tools: [...providerContext.tools], }), { onStarted: () => diff --git a/packages/local-runtime-v2/src/service/turn-system/agent-host/execution/executor.test.ts b/packages/local-runtime-v2/src/service/turn-system/agent-host/execution/executor.test.ts index 20c33fc36..6acbe47f2 100644 --- a/packages/local-runtime-v2/src/service/turn-system/agent-host/execution/executor.test.ts +++ b/packages/local-runtime-v2/src/service/turn-system/agent-host/execution/executor.test.ts @@ -83,6 +83,11 @@ import { import { localPluginHookCoordinator } from "../assembly/local-turn-plugin-hooks.js"; import { NativeLocalTurnExecutionPreparationSource } from "../assembly/local-turn-execution-preparation.js"; import { LocalTurnInputPreparer } from "../assembly/local-turn-input-preparation.js"; +import { LIGHTWEIGHT_SESSION_PURPOSE } from "@mavis/protocol/local"; +import { + LIGHTWEIGHT_SYSTEM_PROMPT, + resolveProviderContextMode, +} from "./prompt.js"; interface LocalToolContext extends ToolExecutionContext { readonly permissionScope?: string; @@ -1132,6 +1137,103 @@ describe("LocalRuntimeTurnExecutor Bash output capability", () => { }); describe("LocalRuntimeTurnExecutor", () => { + it("keeps the standard provider context byte-for-byte and trims only explicit lightweight roots", async () => { + const tool = { + def: { + name: "fixture_tool", + description: "Fixture tool", + schema: { type: "object", properties: { value: { type: "string" } } }, + }, + impl: { execute: vi.fn() }, + } as never; + const captures: LocalRuntimeTurnRunnerInput[] = []; + const runner = new LocalRuntimeTurnExecutor( + options(async (runInput) => { + captures.push(runInput); + await runInput.eventWriter.pushRuntime(terminalEvent(RuntimeEventStatus.COMPLETED)); + }), + ); + const base = executionInput({ + assembly: { ...assembly(), tools: [tool] }, + }); + const standardFixture = + '{"systemPrompt":"extension system\\n\\nbase system","tools":[{"name":"fixture_tool","description":"Fixture tool","schema":{"type":"object","properties":{"value":{"type":"string"}}}}]}'; + + await runner.execute(base); + const standard = captures.at(-1)!; + expect( + JSON.stringify({ + systemPrompt: standard.systemPrompt, + tools: standard.tools?.map(({ def }) => ({ + name: def.name, + description: def.description, + schema: def.schema, + })), + }), + ).toBe(standardFixture); + + await runner.execute( + executionInput({ + assembly: { ...assembly(), tools: [tool] }, + session: { + ...base.session, + purpose: LIGHTWEIGHT_SESSION_PURPOSE, + }, + }), + ); + const lightweight = captures.at(-1)!; + expect(lightweight.systemPrompt).toBe(LIGHTWEIGHT_SYSTEM_PROMPT); + expect(lightweight.tools).toEqual([]); + expect(lightweight.contextUsagePromptRanges).toBeUndefined(); + }); + + it("does not propagate lightweight mode to task or branch child Sessions", async () => { + const prompts: string[] = []; + const runner = new LocalRuntimeTurnExecutor( + options(async (runInput) => { + prompts.push(runInput.systemPrompt); + await runInput.eventWriter.pushRuntime(terminalEvent(RuntimeEventStatus.COMPLETED)); + }), + ); + const base = executionInput(); + for (const session of [ + { + ...base.session, + purpose: LIGHTWEIGHT_SESSION_PURPOSE, + sessionType: "branch" as const, + sessionKind: "task" as const, + parentSessionId: "parent", + }, + { + ...base.session, + purpose: LIGHTWEIGHT_SESSION_PURPOSE, + sessionType: "branch" as const, + parentSessionId: "parent", + }, + ]) { + await runner.execute(executionInput({ session })); + } + expect(prompts).toEqual([ + "extension system\n\nbase system", + "extension system\n\nbase system", + ]); + }); + + it("keeps compaction on the standard provider context for a lightweight Session", () => { + const base = executionInput(); + const context = { + systemPrompt: "full compaction system prompt", + tools: [{ name: "read" }], + }; + expect( + resolveProviderContextMode( + { ...base.session, purpose: LIGHTWEIGHT_SESSION_PURPOSE }, + context, + "compaction", + ), + ).toBe(context); + }); + it("projects runtime events before they reach the v2 commit pipeline", async () => { const delivered: string[] = []; const input = executionInput({ diff --git a/packages/local-runtime-v2/src/service/turn-system/agent-host/execution/executor.ts b/packages/local-runtime-v2/src/service/turn-system/agent-host/execution/executor.ts index ad4286c98..28f239f3d 100644 --- a/packages/local-runtime-v2/src/service/turn-system/agent-host/execution/executor.ts +++ b/packages/local-runtime-v2/src/service/turn-system/agent-host/execution/executor.ts @@ -63,6 +63,7 @@ import { readPreparedContextUsagePromptRanges, readPreparedSystemPrompt, renderAgentRuntimeReminders, + resolveProviderContextMode, } from './prompt.js'; import { createAgentHostPluginHookTranscript, @@ -484,7 +485,7 @@ export class LocalRuntimeTurnExecutor< (tool) => tool.def.name === 'task_output' && (tool.source === undefined || tool.source === 'builtin'), ); - const tools = prepareBashTurnTools(admittedTools, canConsumeBackgroundBashOutput); + const standardTools = prepareBashTurnTools(admittedTools, canConsumeBackgroundBashOutput); const toolContext = { ...toolResolution.context, ...(input.pluginHooks?.length @@ -496,12 +497,24 @@ export class LocalRuntimeTurnExecutor< canConsumeBackgroundBashOutput, } as TContext; const caller = preparedExecution.caller; - const systemPrompt = joinPrompt(input.assembly.systemPromptPrefix, baseSystemPrompt); - const contextUsagePromptRanges = readPreparedContextUsagePromptRanges( + const standardSystemPrompt = joinPrompt(input.assembly.systemPromptPrefix, baseSystemPrompt); + const standardContextUsagePromptRanges = readPreparedContextUsagePromptRanges( input.preparation.agentConfig, input.assembly.systemPromptPrefix, baseSystemPrompt, ); + const providerContext = resolveProviderContextMode(input.session, { + systemPrompt: standardSystemPrompt, + tools: standardTools, + ...(standardContextUsagePromptRanges === undefined + ? {} + : { contextUsagePromptRanges: standardContextUsagePromptRanges }), + }); + const { + systemPrompt, + tools, + contextUsagePromptRanges, + } = providerContext; input.onContextUsagePromptRangesResolved?.(contextUsagePromptRanges); const userPromptPrefix = joinUserPrompt( input.assembly.userPromptPrefix, diff --git a/packages/local-runtime-v2/src/service/turn-system/agent-host/execution/prompt.ts b/packages/local-runtime-v2/src/service/turn-system/agent-host/execution/prompt.ts index 326d1f484..fcc27e3c3 100644 --- a/packages/local-runtime-v2/src/service/turn-system/agent-host/execution/prompt.ts +++ b/packages/local-runtime-v2/src/service/turn-system/agent-host/execution/prompt.ts @@ -1,8 +1,44 @@ import type { ReminderEmission } from '@mavis/agent-runtime'; +import { LIGHTWEIGHT_SESSION_PURPOSE } from '@mavis/protocol/local'; import { CONTEXT_USAGE_PROMPT_KINDS } from '../preparation/contracts.js'; +import type { SessionRecord } from '../../../session-system/index.js'; import type { LocalContextUsagePromptRange } from './contracts.js'; +export const LIGHTWEIGHT_SYSTEM_PROMPT = [ + 'You are MiniMax Code in lightweight mode, optimized for conversation and general knowledge questions.', + 'Answer directly and concisely. Follow explicit user language and formatting requests.', + '', + 'This mode has no tools and does not load workspace files, project instructions, Skills, memory, MCP tools, or environment details.', + 'Do not claim to inspect or change local files. If a request needs coding or tools, explain that the user must start a new standard session with `mcode --mode standard` or `mcode exec --mode standard`; standard is also the default when `--mode` is omitted.', +].join('\n'); + +export interface ProviderContext { + readonly systemPrompt: string; + readonly tools: readonly TTool[]; + readonly contextUsagePromptRanges?: readonly LocalContextUsagePromptRange[]; +} + +export function resolveProviderContextMode( + session: SessionRecord, + context: ProviderContext, + operation: 'turn' | 'compaction' = 'turn', +): ProviderContext { + if (operation === 'compaction' || !isLightweightRootSession(session)) return context; + return { + systemPrompt: LIGHTWEIGHT_SYSTEM_PROMPT, + tools: [], + }; +} + +export function isLightweightRootSession(session: SessionRecord): boolean { + return ( + session.purpose === LIGHTWEIGHT_SESSION_PURPOSE && + session.sessionKind === 'conversation' && + !session.parentSessionId + ); +} + export function renderAgentRuntimeReminders(reminders: readonly ReminderEmission[]): string { return reminders .map(({ providerName, reminder }) => { diff --git a/packages/protocol/src/local.ts b/packages/protocol/src/local.ts index 10c8268f9..e12d15990 100644 --- a/packages/protocol/src/local.ts +++ b/packages/protocol/src/local.ts @@ -52,6 +52,17 @@ export const SessionInteractionModeView = { export type SessionInteractionModeView = (typeof SessionInteractionModeView)[keyof typeof SessionInteractionModeView]; +export const MCODE_CONTEXT_MODES = ['standard', 'lightweight'] as const; +export type McodeContextMode = (typeof MCODE_CONTEXT_MODES)[number]; + +/** + * Immutable marker for an explicitly created lightweight root Session. + * + * Purpose is already persisted with Sessions and intentionally does not flow + * into child Sessions, which have their own task/side-session purpose. + */ +export const LIGHTWEIGHT_SESSION_PURPOSE = 'context-mode:lightweight'; + export const SessionKind = { Unknown: 0, Conversation: 1, diff --git a/packages/tui/src/cli/contract.ts b/packages/tui/src/cli/contract.ts index 8b863b7f1..0a2c0ec49 100644 --- a/packages/tui/src/cli/contract.ts +++ b/packages/tui/src/cli/contract.ts @@ -3,6 +3,7 @@ import { parseHeadlessModelOverride } from '../headless/model-selection.js'; import type { TuiMode } from '../tui/engine/public.js'; import { parseTuiStartupEnvironment } from './environment.js'; import type { TuiBuildEnvironment } from '../auth/environment.js'; +import type { McodeContextMode } from '@mavis/protocol/local'; const RETIRED_TOP_LEVEL_COMMAND_NAMES = new Set(['git', 'changes', 'projects']); @@ -15,6 +16,7 @@ export interface TuiInteractiveLaunchRequest { readonly workspaceDir?: string; readonly resumeDraftAfterLogin?: boolean; readonly tuiMode?: TuiMode; + readonly contextMode?: McodeContextMode; readonly lane?: string; } @@ -24,6 +26,7 @@ export interface RawTuiInteractiveOptions { readonly continue?: boolean; readonly resume?: string; readonly tuiMode?: TuiMode; + readonly mode?: string; readonly lane?: string; readonly env?: TuiBuildEnvironment; } @@ -45,6 +48,11 @@ export function applyInteractiveCliContract( parseTuiMode, ), ) + .addOption( + new Option('--mode ', 'Context mode: standard (default) or lightweight').argParser( + parseContextMode, + ), + ) .addOption(new Option('--resume ').hideHelp()) .allowExcessArguments(false) .showHelpAfterError(); @@ -78,6 +86,12 @@ export function resolveInteractiveLaunchRequest( if (requestedModes > 1) { throw new Error('--session, --continue, and --resume cannot be combined'); } + if ( + commandOptions.mode === 'lightweight' && + (explicitSessionId || compatibilitySessionId || showSessionPicker || commandOptions.continue) + ) { + throw new Error('--mode lightweight requires a new Session'); + } const sessionId = explicitSessionId || compatibilitySessionId; const model = commandOptions.model?.trim(); if (commandOptions.model !== undefined) { @@ -95,6 +109,7 @@ export function resolveInteractiveLaunchRequest( ...(showSessionPicker ? { showSessionPicker: true } : {}), ...(commandOptions.continue ? { continueLatestSession: true } : {}), ...(commandOptions.tuiMode ? { tuiMode: commandOptions.tuiMode } : {}), + ...(commandOptions.mode === 'lightweight' ? { contextMode: 'lightweight' as const } : {}), ...(commandOptions.lane ? { lane: commandOptions.lane } : {}), }; } @@ -112,6 +127,11 @@ export function applyExecCliContract(command: Command): Command { ) .addOption(new Option('--model ', 'override the model for this Run only')) .addOption(new Option('--effort ', 'override the reasoning effort for this Run only')) + .addOption( + new Option('--mode ', 'Context mode: standard (default) or lightweight').argParser( + parseContextMode, + ), + ) .addOption( new Option('--prompt-mode ', 'Prompt mode: tui, coding, or work') .choices(['tui', 'coding', 'work']) @@ -177,6 +197,11 @@ function parseTuiMode(value: string): TuiMode { throw new InvalidArgumentError('TUI mode must be regular or fullscreen'); } +function parseContextMode(value: string): McodeContextMode { + if (value === 'standard' || value === 'lightweight') return value; + throw new InvalidArgumentError('mode must be standard or lightweight'); +} + function parseStartupEnvironmentOption(value: string): TuiBuildEnvironment { try { return parseTuiStartupEnvironment(value); diff --git a/packages/tui/src/cli/main.ts b/packages/tui/src/cli/main.ts index e1849612d..d2fa9a088 100644 --- a/packages/tui/src/cli/main.ts +++ b/packages/tui/src/cli/main.ts @@ -109,6 +109,7 @@ export async function runTuiCli(dependencies: RunTuiCliDependencies = {}): Promi continueLatestSession, workspaceDir, tuiMode, + contextMode, lane, }) => { const launchTui = dependencies.launchTui ?? defaultLaunchTui; @@ -121,6 +122,7 @@ export async function runTuiCli(dependencies: RunTuiCliDependencies = {}): Promi ...(continueLatestSession ? { continueLatestSession: true } : {}), ...(workspaceDir ? { workspaceDir } : {}), ...(tuiMode ? { tuiMode } : {}), + ...(contextMode ? { contextMode } : {}), ...(lane ? { lane } : {}), ...(resumeDraftAfterLogin ? { resumeDraftAfterLogin: true } : {}), }); diff --git a/packages/tui/src/cli/run-exec-command.ts b/packages/tui/src/cli/run-exec-command.ts index 6cb36d776..24b498798 100644 --- a/packages/tui/src/cli/run-exec-command.ts +++ b/packages/tui/src/cli/run-exec-command.ts @@ -74,6 +74,7 @@ export async function runTuiExecCommand( surface: 'headless', promptMode: invocation.promptMode ?? 'tui', permissionMode: runtimePermissionMode(invocation.permission), + ...(invocation.contextMode ? { contextMode: invocation.contextMode } : {}), ...(commandOptions.lane ? { lane: commandOptions.lane } : {}), }, explicitConfig diff --git a/packages/tui/src/headless/invocation.ts b/packages/tui/src/headless/invocation.ts index b78aa98c2..a79dee2fb 100644 --- a/packages/tui/src/headless/invocation.ts +++ b/packages/tui/src/headless/invocation.ts @@ -13,6 +13,7 @@ import { inferTuiNativeVideoMimeType } from '../application/video-mime.js'; import { resolveWslPath } from '../host/wsl-path.js'; import { TuiExecError } from './exit-policy.js'; import type { TuiExecFormat } from './output.js'; +import type { McodeContextMode } from '@mavis/protocol/local'; export type TuiInputFormat = 'text' | 'json'; export type TuiPermissionPolicy = 'smart' | 'full' | 'off'; @@ -26,6 +27,7 @@ export interface RawTuiExecOptions { file?: string[]; model?: string; effort?: string; + mode?: string; promptMode?: string; session?: string; continue?: boolean; @@ -46,6 +48,7 @@ export interface ResolvedTuiExecInvocation { attachments: TuiAttachment[]; model?: string; effort?: string; + contextMode?: McodeContextMode; promptMode?: 'tui' | 'coding' | 'work'; sessionId?: string; continueSession: boolean; @@ -97,6 +100,13 @@ export async function resolveTuiExecInvocation( if (options.session && options.continue) { throw invocationError('--session and --continue are mutually exclusive.'); } + const contextMode = readEnum('--mode', options.mode ?? 'standard', [ + 'standard', + 'lightweight', + ] as const); + if (contextMode === 'lightweight' && (options.session || options.continue)) { + throw invocationError('--mode lightweight requires a new Session.'); + } const promptMode = readEnum('--prompt-mode', options.promptMode ?? 'tui', [ 'tui', 'coding', @@ -180,6 +190,7 @@ export async function resolveTuiExecInvocation( attachments, ...(options.model?.trim() ? { model: options.model.trim() } : {}), ...(effort === undefined ? {} : { effort }), + ...(contextMode === 'lightweight' ? { contextMode } : {}), ...(options.session?.trim() ? { sessionId: options.session.trim() } : {}), continueSession: options.continue === true, ...(configPath ? { configPath } : {}), diff --git a/packages/tui/src/runtime/adapter.ts b/packages/tui/src/runtime/adapter.ts index 2b6738c31..8e60b2d04 100644 --- a/packages/tui/src/runtime/adapter.ts +++ b/packages/tui/src/runtime/adapter.ts @@ -76,6 +76,10 @@ import { resolveTuiEffortChoice } from "../application/model-effort.js"; import type { TuiObservability } from "../observability/index.js"; import type { TuiTokenPlanAccountStatus } from "../account/matrix-account-client.js"; import type { TuiDailyCheckinOutcome } from "../checkin/application.js"; +import { + LIGHTWEIGHT_SESSION_PURPOSE, + type McodeContextMode, +} from "@mavis/protocol/local"; export * from "./port.js"; @@ -87,6 +91,7 @@ export interface TuiRuntimeAdapterOptions { synchronizeAuth?: () => Promise; accountIdentityGetter?: () => TuiAccountStatus["identity"]; observability?: TuiObservability; + contextMode?: McodeContextMode; tokenPlanAccountStatusGetter?: (options?: { readonly forceRefresh?: boolean; }) => Promise; @@ -127,6 +132,7 @@ export class TuiRuntimeAdapter implements TuiRuntime { private readonly accountIdentityGetter: TuiRuntimeAdapterOptions["accountIdentityGetter"]; private readonly feedback: TuiRuntimeAdapterOptions["feedback"]; private readonly dailyCheckin: TuiRuntimeAdapterOptions["dailyCheckin"]; + private readonly contextMode: McodeContextMode; constructor(cliService: CliService, options: TuiRuntimeAdapterOptions = {}) { this.cliService = cliService; @@ -137,6 +143,7 @@ export class TuiRuntimeAdapter implements TuiRuntime { this.accountIdentityGetter = options.accountIdentityGetter; this.feedback = options.feedback; this.dailyCheckin = options.dailyCheckin; + this.contextMode = options.contextMode ?? "standard"; this.conversationAccess = new TuiConversationAccess(cliService); this.pluginAccess = new TuiPluginAccess(cliService); this.context = new TuiRuntimeAccessContext({ @@ -185,15 +192,19 @@ export class TuiRuntimeAdapter implements TuiRuntime { } async createSession(input: CreateTuiSessionInput): Promise { + const sessionInput = + this.contextMode === "lightweight" && !input.parentSessionId && !input.purpose + ? { ...input, purpose: LIGHTWEIGHT_SESSION_PURPOSE } + : input; const defaultModel = await this.productAccess .listModels() .then((models) => models.find((model) => model.selected === true)) .catch(() => undefined); - if (!defaultModel) return this.sessionAccess.createSession(input); + if (!defaultModel) return this.sessionAccess.createSession(sessionInput); // Inherit Runtime's saved global selection before catalog defaults, using // the same rule as the status line and without changing existing Sessions. const effort = resolveTuiEffortChoice(defaultModel)?.trim(); - return this.sessionAccess.createSession(input, { + return this.sessionAccess.createSession(sessionInput, { providerId: defaultModel.providerId, modelId: defaultModel.modelId, ...(defaultModel.variant !== undefined diff --git a/packages/tui/src/runtime/lifecycle.ts b/packages/tui/src/runtime/lifecycle.ts index e75437408..e196bd0db 100644 --- a/packages/tui/src/runtime/lifecycle.ts +++ b/packages/tui/src/runtime/lifecycle.ts @@ -62,6 +62,7 @@ import { import { TuiDailyCheckinApplication } from '../checkin/application.js'; import { TuiDailyCheckinHttpGateway } from '../checkin/http-gateway.js'; import { createMcodeSharedAuthSession } from './auth-session.js'; +import type { McodeContextMode } from '@mavis/protocol/local'; import { resolveTuiManagedBackendLane } from '../cli/environment.js'; import { prepareTuiMcodeToolsIntegration, @@ -81,6 +82,7 @@ export interface CreateTuiRuntimeOptions { surface?: TuiObservabilitySurface; observability?: TuiObservability; permissionMode?: NonNullable; + contextMode?: McodeContextMode; lane?: string; } @@ -495,6 +497,7 @@ export async function createTuiRuntime( adapter: new TuiRuntimeAdapter(host.cliService, { workspaceDir: options.workspaceDir, observability, + ...(options.contextMode ? { contextMode: options.contextMode } : {}), onSessionDeleted: browserProvider?.disposeSession ? browserProvider.disposeSession.bind(browserProvider) : (sessionId) => disposeTuiBrowserSessionStorage(options.dataDir, sessionId), diff --git a/packages/tui/src/tui/launcher.ts b/packages/tui/src/tui/launcher.ts index 50fee9b03..e82fbac68 100644 --- a/packages/tui/src/tui/launcher.ts +++ b/packages/tui/src/tui/launcher.ts @@ -67,6 +67,7 @@ import { import { schedulePendingMcodePrefixUpdate } from '../update/prefix-update.js'; import { MCODE_TUI_RESULT_PATH_ENV } from './automation/result-writer.js'; import { startTuiStartupStatus, type TuiStartupStatus } from './startup-status.js'; +import type { McodeContextMode } from '@mavis/protocol/local'; const MINIMAX_CODE_EXIT_SLOGAN = 'Intelligence with everyone, bye~'; export interface LaunchTuiOptions { @@ -85,6 +86,7 @@ export interface LaunchTuiOptions { theme?: string; externalEditorCommand?: string; resumeDraftAfterLogin?: boolean; + contextMode?: McodeContextMode; lane?: string; } @@ -107,6 +109,7 @@ interface RuntimeLifecycleModule { version: string; surface: 'tui'; observability: TuiObservability; + contextMode?: McodeContextMode; lane?: string; }, dependencies?: Pick, @@ -348,6 +351,7 @@ export async function launchTui( version: options.version, surface: 'tui', observability, + ...(options.contextMode ? { contextMode: options.contextMode } : {}), ...(bedrockLane ? { lane: bedrockLane } : {}), }, { sharedAuthCore }, diff --git a/packages/tui/test/unit/acp-agent.test.ts b/packages/tui/test/unit/acp-agent.test.ts index 68f7bcc96..fcdba3cb0 100644 --- a/packages/tui/test/unit/acp-agent.test.ts +++ b/packages/tui/test/unit/acp-agent.test.ts @@ -1124,7 +1124,7 @@ describe('MiniMax Code ACP agent', () => { ); }); - it('negotiates truthful capabilities and runs a prompt through the local Runtime', async () => { + it('keeps ACP Session creation in standard mode and runs a prompt through the local Runtime', async () => { const { runtime, createSession, sendMessage } = createRuntime([ { type: 'delta', diff --git a/packages/tui/test/unit/headless-invocation.test.ts b/packages/tui/test/unit/headless-invocation.test.ts index c454e3c54..13912a894 100644 --- a/packages/tui/test/unit/headless-invocation.test.ts +++ b/packages/tui/test/unit/headless-invocation.test.ts @@ -111,6 +111,26 @@ describe('headless invocation', () => { expect(readStdin).not.toHaveBeenCalled(); }); + it('opts only new exec Sessions into lightweight mode', async () => { + const cwd = await workspace(); + const readStdin = vi.fn(async () => 'ignored'); + + await expect( + resolveTuiExecInvocation('hi', { cwd, mode: 'lightweight' }, readStdin), + ).resolves.toMatchObject({ contextMode: 'lightweight' }); + await expect( + resolveTuiExecInvocation('hi', { cwd, mode: 'standard' }, readStdin), + ).resolves.not.toHaveProperty('contextMode'); + for (const options of [ + { cwd, mode: 'lightweight', session: 'existing' }, + { cwd, mode: 'lightweight', continue: true }, + ]) { + await expect(resolveTuiExecInvocation('hi', options, readStdin)).rejects.toThrow( + 'requires a new Session', + ); + } + }); + it('accepts JSON stdin and rejects prompt/source conflicts', async () => { const cwd = await workspace(); await expect( diff --git a/packages/tui/test/unit/tui-app.test.ts b/packages/tui/test/unit/tui-app.test.ts index dc6a1dbf5..c8c735777 100644 --- a/packages/tui/test/unit/tui-app.test.ts +++ b/packages/tui/test/unit/tui-app.test.ts @@ -15362,6 +15362,32 @@ describe("interactive model argument contract", () => { expect(launch).toHaveBeenCalledWith({ initialPrompt: "hello" }); }); + it("opts a new Session into lightweight mode without changing the standard contract", async () => { + const lightweight = program(); + await lightweight.command.parseAsync(["hello", "--mode", "lightweight"], { from: "user" }); + expect(lightweight.launch).toHaveBeenCalledWith({ + initialPrompt: "hello", + contextMode: "lightweight", + }); + + const standard = program(); + await standard.command.parseAsync(["hello", "--mode", "standard"], { from: "user" }); + expect(standard.launch).toHaveBeenCalledWith({ initialPrompt: "hello" }); + }); + + it("rejects lightweight mode for resumed or selected Sessions", async () => { + for (const args of [ + ["--continue", "--mode", "lightweight"], + ["--session", "existing", "--mode", "lightweight"], + ]) { + const { command, launch } = program(); + await expect(command.parseAsync(args, { from: "user" })).rejects.toThrow( + "requires a new Session", + ); + expect(launch).not.toHaveBeenCalled(); + } + }); + it.each(["-m", "--model"])("scans startup environment after %s values", (flag) => { expect( resolveTuiStartupEnvironmentOption([flag, "provider/model", "--env", "staging"], true), diff --git a/packages/tui/test/unit/tui-runtime-adapter.test.ts b/packages/tui/test/unit/tui-runtime-adapter.test.ts index fc117af72..016f9e494 100644 --- a/packages/tui/test/unit/tui-runtime-adapter.test.ts +++ b/packages/tui/test/unit/tui-runtime-adapter.test.ts @@ -1,7 +1,52 @@ import { describe, expect, it, vi } from "vitest"; import { TuiRuntimeAdapter } from "../../src/runtime/adapter.js"; +import { LIGHTWEIGHT_SESSION_PURPOSE } from "@mavis/protocol/local"; describe("TuiRuntimeAdapter process-local facades", () => { + it("marks only newly created root Sessions as lightweight", async () => { + const createSession = vi.fn(async () => ({ sessionId: "created" })); + const adapter = new TuiRuntimeAdapter( + { + listModels: vi.fn(async () => []), + createSession, + } as never, + { contextMode: "lightweight" }, + ); + + await adapter.createSession({ workspaceDir: "/workspace" }); + await adapter.createSession({ + workspaceDir: "/workspace", + parentSessionId: "parent", + purpose: "local-task:child", + }); + + expect(createSession).toHaveBeenNthCalledWith(1, { + name: "mavis", + workspaceDir: "/workspace", + purpose: LIGHTWEIGHT_SESSION_PURPOSE, + }); + expect(createSession).toHaveBeenNthCalledWith(2, { + name: "mavis", + workspaceDir: "/workspace", + parentSessionId: "parent", + purpose: "local-task:child", + }); + }); + + it("leaves the standard Session creation request byte-for-byte unchanged", async () => { + const createSession = vi.fn(async () => ({ sessionId: "created" })); + const adapter = new TuiRuntimeAdapter({ + listModels: vi.fn(async () => []), + createSession, + } as never); + + await adapter.createSession({ workspaceDir: "/workspace", title: "hello" }); + + expect(JSON.stringify(createSession.mock.calls[0]?.[0])).toBe( + '{"name":"mavis","workspaceDir":"/workspace","title":"hello"}', + ); + }); + it("projects active and recent terminal Runtime background work for the current Session", async () => { const listBackgroundTasks = vi.fn( async (input: { statuses?: readonly string[] }) => From 1e60d0e2a4a714e91a19d71c183b2cb39d01d880 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 6 Oct 2026 02:55:34 +0000 Subject: [PATCH 02/13] docs: clarify lightweight context boundary Co-authored-by: DanielWalnut --- README.md | 6 +++--- README_ZH.md | 4 ++-- .../src/service/turn-system/agent-host/execution/prompt.ts | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index d6a5a08da..90bf1d590 100644 --- a/README.md +++ b/README.md @@ -156,9 +156,9 @@ mcode --mode lightweight "Explain how DNS caching works." mcode exec --mode lightweight "Summarize the CAP theorem." ``` -Lightweight mode sends a small conversational system prompt and no tool schemas. It does not load -workspace instructions, Skills, memory, MCP tools, or environment details, so it cannot inspect or -change local files. Start a new standard Session for coding or tool use with +Lightweight mode sends a small conversational system prompt and no tool schemas. It omits workspace +instructions, Skills, memory blocks, MCP schemas, and the full environment block from provider +context, so it cannot inspect or change local files. Start a new standard Session for coding or tool use with `mcode --mode standard` or `mcode exec --mode standard`; omitting `--mode` is identical to standard mode. diff --git a/README_ZH.md b/README_ZH.md index 9376fe54e..0e93722d6 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -155,8 +155,8 @@ mcode --mode lightweight "解释 DNS 缓存的工作原理。" mcode exec --mode lightweight "概述 CAP 定理。" ``` -轻量模式只发送精简的对话系统提示词,不发送工具 schema,也不会加载工作区指令、Skills、 -memory、MCP 工具或环境信息,因此不能检查或修改本地文件。需要编程或工具能力时,请使用 +轻量模式只发送精简的对话系统提示词,不发送工具 schema,并从提供方上下文中省略工作区 +指令、Skills、memory 块、MCP schema 和完整环境块,因此不能检查或修改本地文件。需要编程或工具能力时,请使用 `mcode --mode standard` 或 `mcode exec --mode standard` 创建新的标准会话;省略 `--mode` 与标准模式完全一致。 diff --git a/packages/local-runtime-v2/src/service/turn-system/agent-host/execution/prompt.ts b/packages/local-runtime-v2/src/service/turn-system/agent-host/execution/prompt.ts index fcc27e3c3..78cc2b2c7 100644 --- a/packages/local-runtime-v2/src/service/turn-system/agent-host/execution/prompt.ts +++ b/packages/local-runtime-v2/src/service/turn-system/agent-host/execution/prompt.ts @@ -9,7 +9,7 @@ export const LIGHTWEIGHT_SYSTEM_PROMPT = [ 'You are MiniMax Code in lightweight mode, optimized for conversation and general knowledge questions.', 'Answer directly and concisely. Follow explicit user language and formatting requests.', '', - 'This mode has no tools and does not load workspace files, project instructions, Skills, memory, MCP tools, or environment details.', + 'This mode exposes no tools and omits the full coding prompt, workspace and project instructions, Skills catalog, memory blocks, MCP schemas, and full environment block from provider context.', 'Do not claim to inspect or change local files. If a request needs coding or tools, explain that the user must start a new standard session with `mcode --mode standard` or `mcode exec --mode standard`; standard is also the default when `--mode` is omitted.', ].join('\n'); From 6e4cd36bcaa2eeef001b931bba5a59b9d95adbbb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 6 Oct 2026 03:52:35 +0000 Subject: [PATCH 03/13] fix: harden lightweight session behavior Co-authored-by: DanielWalnut --- README.md | 4 +- README_ZH.md | 3 +- .../agent-host/execution/executor.test.ts | 40 +++++ .../agent-host/execution/executor.ts | 8 + .../projection/visible-presentation.ts | 2 + packages/tui/src/tui/shell/chrome.ts | 39 +++-- packages/tui/src/tui/shell/contracts.ts | 2 + .../tui/test/unit/tui-runtime-adapter.test.ts | 26 +++ .../test/unit/tui-status-line-items.test.ts | 11 ++ .../projection/visible-presentation.test.ts | 23 +++ test/byok.test.mjs | 148 ++++++++++++++++++ 11 files changed, 290 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 90bf1d590..cf518b604 100644 --- a/README.md +++ b/README.md @@ -164,7 +164,9 @@ mode. The mode is fixed when a root Session is created. `--mode lightweight` therefore cannot be combined with `--session` or `--continue`. Child/sub-agent Sessions do not inherit it, `/compact` continues to -use the standard compaction context, and ACP and desktop Sessions remain standard. +use the standard compaction context, and ACP and desktop Sessions remain standard. Reopening a +lightweight Session keeps its mode and shows `Lightweight` in the TUI status line; opening a standard +Session from a lightweight launch remains standard and shows no indicator. ### Continue your work diff --git a/README_ZH.md b/README_ZH.md index 0e93722d6..370efc0e8 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -162,7 +162,8 @@ mcode exec --mode lightweight "概述 CAP 定理。" 模式在根会话创建时固定,因此 `--mode lightweight` 不能与 `--session` 或 `--continue` 同时使用。子代理/子会话不会继承轻量模式,`/compact` 仍使用标准压缩上下文,ACP 与桌面端 -会话也始终使用标准模式。 +会话也始终使用标准模式。重新打开轻量会话时会保留该模式,并在 TUI 状态栏显示 +`Lightweight`;从轻量模式启动的 TUI 打开标准会话时仍保持标准模式,且不显示该标记。 ### 继续之前的工作 diff --git a/packages/local-runtime-v2/src/service/turn-system/agent-host/execution/executor.test.ts b/packages/local-runtime-v2/src/service/turn-system/agent-host/execution/executor.test.ts index 6acbe47f2..e34a89247 100644 --- a/packages/local-runtime-v2/src/service/turn-system/agent-host/execution/executor.test.ts +++ b/packages/local-runtime-v2/src/service/turn-system/agent-host/execution/executor.test.ts @@ -1171,6 +1171,7 @@ describe("LocalRuntimeTurnExecutor", () => { })), }), ).toBe(standardFixture); + expect(standard.shouldStopAfterTurn).toBeUndefined(); await runner.execute( executionInput({ @@ -1185,6 +1186,18 @@ describe("LocalRuntimeTurnExecutor", () => { expect(lightweight.systemPrompt).toBe(LIGHTWEIGHT_SYSTEM_PROMPT); expect(lightweight.tools).toEqual([]); expect(lightweight.contextUsagePromptRanges).toBeUndefined(); + expect( + await lightweight.shouldStopAfterTurn?.({ + message: { + content: [{ type: "toolCall", id: "call", name: "write", arguments: {} }], + }, + } as never), + ).toBe(true); + expect( + await lightweight.shouldStopAfterTurn?.({ + message: { content: [{ type: "text", text: "ordinary answer" }] }, + } as never), + ).toBe(false); }); it("does not propagate lightweight mode to task or branch child Sessions", async () => { @@ -1219,6 +1232,33 @@ describe("LocalRuntimeTurnExecutor", () => { ]); }); + it.each([ + "code-review:context-mode:lightweight", + "context-mode:lightweight:im", + " context-mode:lightweight", + ])("keeps mixed purpose %j on the standard tool surface", async (purpose) => { + let captured: LocalRuntimeTurnRunnerInput | undefined; + const tool = { + def: { name: "read", description: "Read", schema: { type: "object" } }, + impl: { execute: vi.fn() }, + } as never; + const runner = new LocalRuntimeTurnExecutor( + options(async (runInput) => { + captured = runInput; + await runInput.eventWriter.pushRuntime(terminalEvent(RuntimeEventStatus.COMPLETED)); + }), + ); + const base = executionInput(); + await runner.execute( + executionInput({ + session: { ...base.session, purpose }, + assembly: { ...assembly(), tools: [tool] }, + }), + ); + expect(captured?.tools?.map(({ def }) => def.name)).toEqual(["read"]); + expect(captured?.systemPrompt).toBe("extension system\n\nbase system"); + }); + it("keeps compaction on the standard provider context for a lightweight Session", () => { const base = executionInput(); const context = { diff --git a/packages/local-runtime-v2/src/service/turn-system/agent-host/execution/executor.ts b/packages/local-runtime-v2/src/service/turn-system/agent-host/execution/executor.ts index 28f239f3d..3ad114510 100644 --- a/packages/local-runtime-v2/src/service/turn-system/agent-host/execution/executor.ts +++ b/packages/local-runtime-v2/src/service/turn-system/agent-host/execution/executor.ts @@ -60,6 +60,7 @@ import type { import { joinPrompt, joinUserPrompt, + isLightweightRootSession, readPreparedContextUsagePromptRanges, readPreparedSystemPrompt, renderAgentRuntimeReminders, @@ -471,6 +472,7 @@ export class LocalRuntimeTurnExecutor< : { maxSerializedInputBytes: maxRequestBodyBytes }), }; const baseSystemPrompt = readPreparedSystemPrompt(input.preparation.agentConfig); + const lightweightMode = isLightweightRootSession(input.session); const preparedExecution = await this.options.executionPreparation.prepare({ execution: input, eventWriter, @@ -602,6 +604,12 @@ export class LocalRuntimeTurnExecutor< toolContext, beforeLlmCallHooks, caller, + ...(lightweightMode + ? { + shouldStopAfterTurn: ({ message }) => + message.content.some((block) => block.type === 'toolCall'), + } + : {}), ...(contextUsagePromptRanges !== undefined ? { contextUsagePromptRanges, diff --git a/packages/tui/src/tui/controller/projection/visible-presentation.ts b/packages/tui/src/tui/controller/projection/visible-presentation.ts index daf0fcab2..2c03c3621 100644 --- a/packages/tui/src/tui/controller/projection/visible-presentation.ts +++ b/packages/tui/src/tui/controller/projection/visible-presentation.ts @@ -17,6 +17,7 @@ import { formatSideConversationLabel, type TuiSideConversationPresentation, } from '../../commands/side-session.js'; +import { LIGHTWEIGHT_SESSION_PURPOSE } from '@mavis/protocol/local'; export interface ResolveTuiVisiblePresentationInput { readonly snapshot: TuiChatSnapshot; @@ -314,6 +315,7 @@ function resolveStableShell(input: ResolveTuiVisiblePresentationInput): TuiShell ? 'offline' : 'ready', sessionTitle: session?.title ?? 'New session', + ...(session?.purpose === LIGHTWEIGHT_SESSION_PURPOSE ? { lightweightMode: true } : {}), sessionRole: delegatedSession ? 'subagent' : 'root', sessionAgentName: delegatedSession?.agentName, parentSessionTitle: delegatedSession diff --git a/packages/tui/src/tui/shell/chrome.ts b/packages/tui/src/tui/shell/chrome.ts index a3b585e8c..995f853db 100644 --- a/packages/tui/src/tui/shell/chrome.ts +++ b/packages/tui/src/tui/shell/chrome.ts @@ -20,6 +20,8 @@ import { renderCustomStatusLines } from './custom-status-text.js'; export { type TuiRuntimeStatus, type TuiShellState } from './contracts.js'; export { TuiWelcome } from './welcome/component.js'; +type ResolvedStatusLineItem = TuiStatusLineItem | 'lightweight-mode'; + export class TuiUpdateNotice implements Component { private availableVersion: string | undefined; @@ -131,21 +133,21 @@ export class TuiStatusLine implements Component { * default line never leaks the marker regardless of how the bundle was built. * When configured, `build-mode` owns the line; an empty list renders nothing. */ -function resolveStatusLineItems(state: TuiShellState): readonly TuiStatusLineItem[] { +function resolveStatusLineItems(state: TuiShellState): readonly ResolvedStatusLineItem[] { const items = state.statusLineItems ?? TUI_STATUS_LINE_DEFAULT_ITEMS; - if (!items.includes('build-mode')) { - if (state.statusLineItems !== undefined) { - // The opt-in meter replaces the percentage at the meter's configured position. - return items.includes('context-meter') + if (items.includes('build-mode')) return ['build-mode']; + const visibleItems: readonly TuiStatusLineItem[] = + state.statusLineItems !== undefined + ? // The opt-in meter replaces the percentage at the meter's configured position. + items.includes('context-meter') ? items.filter((item) => item !== 'context-remaining') - : items; - } - const remaining = contextRemainingPercent(state); - return items.filter((item) => - remaining === undefined ? item !== 'context-remaining' : item !== 'context-window', - ); - } - return ['build-mode']; + : items + : items.filter((item) => + contextRemainingPercent(state) === undefined + ? item !== 'context-remaining' + : item !== 'context-window', + ); + return state.lightweightMode ? ['lightweight-mode', ...visibleItems] : visibleItems; } /** @@ -156,11 +158,20 @@ function resolveStatusLineItems(state: TuiShellState): readonly TuiStatusLineIte * space first on a narrow terminal. */ function buildStatusSegment( - item: TuiStatusLineItem, + item: ResolvedStatusLineItem, state: TuiShellState, customStatusConfig?: TuiCustomStatusLineConfig, ): StatusSegment | undefined { switch (item) { + case 'lightweight-mode': + return createStatusSegment( + [ + chalk.bold.hex(colors.signal)('Lightweight'), + chalk.bold.hex(colors.signal)('Light'), + chalk.bold.hex(colors.signal)('Lite'), + ], + { shrinkPriority: 75, preserveWhenNarrow: true }, + ); case 'build-mode': return createStatusSegment([renderBuildMode(state)], { shrinkPriority: 70, diff --git a/packages/tui/src/tui/shell/contracts.ts b/packages/tui/src/tui/shell/contracts.ts index 14ed35e21..ee7a66cfc 100644 --- a/packages/tui/src/tui/shell/contracts.ts +++ b/packages/tui/src/tui/shell/contracts.ts @@ -31,6 +31,8 @@ export interface TuiShellState { agentBackgroundCount?: number; homeDir?: string; sessionTitle?: string; + /** Visible only when the active Session carries the exact lightweight purpose marker. */ + lightweightMode?: boolean; sessionRole?: 'root' | 'subagent'; sessionAgentName?: string; parentSessionTitle?: string; diff --git a/packages/tui/test/unit/tui-runtime-adapter.test.ts b/packages/tui/test/unit/tui-runtime-adapter.test.ts index 016f9e494..9c832392a 100644 --- a/packages/tui/test/unit/tui-runtime-adapter.test.ts +++ b/packages/tui/test/unit/tui-runtime-adapter.test.ts @@ -47,6 +47,32 @@ describe("TuiRuntimeAdapter process-local facades", () => { ); }); + it("preserves the persisted mode when reopening Sessions regardless of launch mode", async () => { + const reopenedLightweight = new TuiRuntimeAdapter({ + getSession: vi.fn(async () => ({ + session: { + sessionId: "lightweight", + purpose: LIGHTWEIGHT_SESSION_PURPOSE, + }, + })), + } as never); + const reopenedStandardFromLightweightLaunch = new TuiRuntimeAdapter( + { + getSession: vi.fn(async () => ({ + session: { sessionId: "standard" }, + })), + } as never, + { contextMode: "lightweight" }, + ); + + await expect(reopenedLightweight.getSession("lightweight")).resolves.toMatchObject({ + purpose: LIGHTWEIGHT_SESSION_PURPOSE, + }); + await expect( + reopenedStandardFromLightweightLaunch.getSession("standard"), + ).resolves.not.toHaveProperty("purpose"); + }); + it("projects active and recent terminal Runtime background work for the current Session", async () => { const listBackgroundTasks = vi.fn( async (input: { statuses?: readonly string[] }) => diff --git a/packages/tui/test/unit/tui-status-line-items.test.ts b/packages/tui/test/unit/tui-status-line-items.test.ts index 090d07fcb..f55fdd65b 100644 --- a/packages/tui/test/unit/tui-status-line-items.test.ts +++ b/packages/tui/test/unit/tui-status-line-items.test.ts @@ -71,6 +71,17 @@ describe('status line item parsing', () => { }); describe('TuiStatusLine default items', () => { + it('adds a compact lightweight indicator without changing the standard footer', () => { + const standard = render(BASE_STATE); + expect(stripAnsi(standard)).toMatchInlineSnapshot(); + expect(render({ ...BASE_STATE, lightweightMode: false })).toBe(standard); + + const lightweight = stripAnsi(render({ ...BASE_STATE, lightweightMode: true })); + expect(lightweight).toContain('Lightweight'); + expect(lightweight).toContain(stripAnsi(standard).trim()); + expect(stripAnsi(render({ ...BASE_STATE, lightweightMode: true }, 12))).toContain('Lite'); + }); + it('keeps build-mode and cache diagnostics out of the default order', () => { expect(TUI_STATUS_LINE_DEFAULT_ITEMS).not.toContain('build-mode'); expect(TUI_STATUS_LINE_DEFAULT_ITEMS).toContain('context-remaining'); diff --git a/packages/tui/test/unit/tui/controller/projection/visible-presentation.test.ts b/packages/tui/test/unit/tui/controller/projection/visible-presentation.test.ts index 49ffec222..3f97ef8c6 100644 --- a/packages/tui/test/unit/tui/controller/projection/visible-presentation.test.ts +++ b/packages/tui/test/unit/tui/controller/projection/visible-presentation.test.ts @@ -7,6 +7,7 @@ import { formatTuiShortcut } from "../../../../../src/tui/shell/shortcut-labels. import { normalizeAccountStatus } from "../../../../../src/runtime/adapters/normalizers.js"; import { TuiWelcome } from "../../../../../src/tui/shell/welcome/component.js"; import { stripAnsi } from "../../../../../src/tui/rendering/text.js"; +import { LIGHTWEIGHT_SESSION_PURPOSE } from "@mavis/protocol/local"; const defaultQueueLabel = `${formatTuiShortcut("alt+enter")} queue`; @@ -120,6 +121,28 @@ describe("visible presentation selector", () => { ).toBe("/other/workspace"); }); + it("projects lightweight mode only for the exact persisted Session purpose", () => { + const shellForPurpose = (purpose: string | undefined) => + resolve({ + snapshot: { + ...idleChat, + session: { + sessionId: "resumed", + ...(purpose ? { purpose } : {}), + }, + }, + }).shell; + + expect(shellForPurpose(LIGHTWEIGHT_SESSION_PURPOSE).lightweightMode).toBe(true); + for (const purpose of [ + undefined, + "code-review:context-mode:lightweight", + "context-mode:lightweight:im", + ]) { + expect(shellForPurpose(purpose)).not.toHaveProperty("lightweightMode"); + } + }); + it("projects Runtime context independently from account quota and cache metrics", () => { const contextUsage = { usedTokens: 80, diff --git a/test/byok.test.mjs b/test/byok.test.mjs index af845cc1f..e18fcc7a3 100644 --- a/test/byok.test.mjs +++ b/test/byok.test.mjs @@ -611,6 +611,154 @@ test( cancellationTest("lock"), ); +test( + "lightweight mode stops safely when a provider emits an unoffered tool call", + { timeout: 45000 }, + async (t) => { + const fixtureDir = mkdtempSync(path.join(tmpdir(), "minimax-code-lightweight-tool-")); + const dataDir = path.join(fixtureDir, "data"); + const workspaceDir = path.join(fixtureDir, "workspace"); + const homeDir = path.join(fixtureDir, "home"); + for (const dir of [dataDir, workspaceDir, homeDir]) mkdirSync(dir); + const networkAudit = path.join(fixtureDir, "network-audit.log"); + const marker = path.join(workspaceDir, "must-not-exist.txt"); + let streamRequests = 0; + const server = createServer(async (req, res) => { + let raw = ""; + for await (const chunk of req) raw += chunk; + const body = JSON.parse(raw); + if (req.url?.endsWith("/responses/input_tokens")) { + res + .writeHead(200, { "content-type": "application/json" }) + .end(JSON.stringify({ input_tokens: 1 })); + return; + } + assert.equal(body.stream, true); + assert.equal(body.tools, undefined, "Lightweight requests must not offer tools"); + streamRequests += 1; + res.writeHead(200, { "content-type": "text/event-stream" }); + for (const chunk of [ + { + choices: [ + { + index: 0, + delta: { + role: "assistant", + content: "LIGHTWEIGHT_TOOL_CALL_TEXT", + tool_calls: [ + { + index: 0, + id: "unoffered-write", + type: "function", + function: { + name: "write", + arguments: JSON.stringify({ + path: marker, + content: "must not be written", + }), + }, + }, + ], + }, + finish_reason: null, + }, + ], + }, + { + choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }, + ]) { + res.write( + `data: ${JSON.stringify({ + id: "fixture", + object: "chat.completion.chunk", + created: 1, + model: "fixture", + ...chunk, + })}\n\n`, + ); + } + res.end("data: [DONE]\n\n"); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + t.after(async () => { + server.closeAllConnections(); + await new Promise((resolve) => server.close(resolve)); + rmSync(fixtureDir, { recursive: true, force: true }); + }); + const origin = `http://127.0.0.1:${server.address().port}`; + writeFileSync( + path.join(dataDir, "config.yaml"), + stringifyYaml({ + defaultModel: "custom_provider:fixture/fixture", + custom_provider: { + fixture: { + name: "fixture", + kind: "custom", + enabled: true, + api: "openai-completions", + options: { + apiKey: "synthetic-key", + baseURL: `${origin}/v1`, + authMode: "api-key", + }, + models: { fixture: { limit: { context: 32768, output: 4096 } } }, + }, + }, + }), + ); + const child = spawn( + process.execPath, + [ + cli, + "exec", + "Edit a local file.", + "--mode", + "lightweight", + "--permission", + "off", + "--cwd", + workspaceDir, + "--timeout", + "30s", + "--max-steps", + "4", + ], + { + cwd: workspaceDir, + env: { + ...withoutProxyEnvironment(process.env), + HOME: homeDir, + XDG_CONFIG_HOME: path.join(homeDir, "config"), + XDG_DATA_HOME: path.join(homeDir, "data"), + MINIMAX_DATA_DIR: dataDir, + MAVIS_DATA_DIR: dataDir, + MCODE_TEST_ALLOWED_ORIGIN: origin, + MCODE_TEST_NETWORK_AUDIT: networkAudit, + MCODE_TEST_MANAGED_OFFLINE: "1", + NODE_OPTIONS: `--import=${new URL("./network-deny.mjs", import.meta.url).href}`, + }, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk) => { + stdout += chunk; + }); + child.stderr.on("data", (chunk) => { + stderr += chunk; + }); + const [code] = await once(child, "close"); + assert.equal(code, 0, `${stdout}\n${stderr}`); + assert.match(stdout, /LIGHTWEIGHT_TOOL_CALL_TEXT/u); + assert.equal(streamRequests, 1, "The rejected tool call must not trigger another model step"); + assert.equal(existsSync(marker), false, "An unoffered tool call must never execute"); + assert.equal(existsSync(networkAudit), false, "No external requests are allowed"); + }, +); + test( "cancelling a running tool preserves its completed display message", { timeout: 45000, skip: process.platform === "win32" }, From 59ea1ce853827d8ee090c017df0771935b428ef7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 6 Oct 2026 03:54:06 +0000 Subject: [PATCH 04/13] fix: type lightweight turn guard and localize indicator Co-authored-by: DanielWalnut --- .../turn-system/agent-host/execution/executor.ts | 6 ++++-- packages/tui/src/tui/shell/chrome.ts | 13 ++++++++----- .../tui/test/unit/tui-status-line-items.test.ts | 13 ++++++++++++- 3 files changed, 24 insertions(+), 8 deletions(-) diff --git a/packages/local-runtime-v2/src/service/turn-system/agent-host/execution/executor.ts b/packages/local-runtime-v2/src/service/turn-system/agent-host/execution/executor.ts index 3ad114510..e4d20435a 100644 --- a/packages/local-runtime-v2/src/service/turn-system/agent-host/execution/executor.ts +++ b/packages/local-runtime-v2/src/service/turn-system/agent-host/execution/executor.ts @@ -517,6 +517,9 @@ export class LocalRuntimeTurnExecutor< tools, contextUsagePromptRanges, } = providerContext; + const lightweightShouldStopAfterTurn: NonNullable< + LocalRuntimeTurnRunnerInput['shouldStopAfterTurn'] + > = ({ message }) => message.content.some((block) => block.type === 'toolCall'); input.onContextUsagePromptRangesResolved?.(contextUsagePromptRanges); const userPromptPrefix = joinUserPrompt( input.assembly.userPromptPrefix, @@ -606,8 +609,7 @@ export class LocalRuntimeTurnExecutor< caller, ...(lightweightMode ? { - shouldStopAfterTurn: ({ message }) => - message.content.some((block) => block.type === 'toolCall'), + shouldStopAfterTurn: lightweightShouldStopAfterTurn, } : {}), ...(contextUsagePromptRanges !== undefined diff --git a/packages/tui/src/tui/shell/chrome.ts b/packages/tui/src/tui/shell/chrome.ts index 995f853db..e098be4ab 100644 --- a/packages/tui/src/tui/shell/chrome.ts +++ b/packages/tui/src/tui/shell/chrome.ts @@ -1,4 +1,5 @@ import type { TuiCustomStatusLineConfig } from '@mavis/config'; +import { getRuntimeLocaleLanguage } from '@mavis/shared/runtime-i18n'; import { formatContextWindow } from '../../application/context-window.js'; import type { Component } from '../rendering/component.js'; import { stripAnsi, truncateToWidth, visibleWidth } from '../rendering/text.js'; @@ -165,11 +166,7 @@ function buildStatusSegment( switch (item) { case 'lightweight-mode': return createStatusSegment( - [ - chalk.bold.hex(colors.signal)('Lightweight'), - chalk.bold.hex(colors.signal)('Light'), - chalk.bold.hex(colors.signal)('Lite'), - ], + lightweightModeLabels().map((label) => chalk.bold.hex(colors.signal)(label)), { shrinkPriority: 75, preserveWhenNarrow: true }, ); case 'build-mode': @@ -394,6 +391,12 @@ function fitStatusSegments(segments: readonly StatusSegment[], width: number): s return fitLine(render(), width); } +function lightweightModeLabels(locale = Intl.DateTimeFormat().resolvedOptions().locale): string[] { + return getRuntimeLocaleLanguage(locale) === 'zh' + ? ['轻量模式', '轻量', '轻'] + : ['Lightweight', 'Light', 'Lite']; +} + function renderSessionTitle(state: TuiShellState, maxWidth?: number): string { if (state.sessionRole === 'subagent') return ''; const title = sanitizeTerminalText(state.sessionTitle?.trim() ?? ''); diff --git a/packages/tui/test/unit/tui-status-line-items.test.ts b/packages/tui/test/unit/tui-status-line-items.test.ts index f55fdd65b..46ce7cd32 100644 --- a/packages/tui/test/unit/tui-status-line-items.test.ts +++ b/packages/tui/test/unit/tui-status-line-items.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { applyTuiRenderTheme, getTuiThemeSnapshot, tuiChalk, tuiColors } from '../../src/tui/theme/runtime.js'; import { MINIMAX_CODE_DARK_THEME, MINIMAX_CODE_LIGHT_THEME } from '../../src/tui/theme/palettes.js'; @@ -82,6 +82,17 @@ describe('TuiStatusLine default items', () => { expect(stripAnsi(render({ ...BASE_STATE, lightweightMode: true }, 12))).toContain('Lite'); }); + it('localizes the lightweight indicator for a Chinese runtime locale', () => { + const locale = vi + .spyOn(Intl.DateTimeFormat.prototype, 'resolvedOptions') + .mockReturnValue({ locale: 'zh-CN' } as Intl.ResolvedDateTimeFormatOptions); + try { + expect(stripAnsi(render({ ...BASE_STATE, lightweightMode: true }))).toContain('轻量模式'); + } finally { + locale.mockRestore(); + } + }); + it('keeps build-mode and cache diagnostics out of the default order', () => { expect(TUI_STATUS_LINE_DEFAULT_ITEMS).not.toContain('build-mode'); expect(TUI_STATUS_LINE_DEFAULT_ITEMS).toContain('context-remaining'); From 4941903eb153afd9b77e4c415f262db7002634d6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 6 Oct 2026 03:55:12 +0000 Subject: [PATCH 05/13] fix: import lightweight turn callback contract Co-authored-by: DanielWalnut --- .../src/service/turn-system/agent-host/execution/executor.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/local-runtime-v2/src/service/turn-system/agent-host/execution/executor.ts b/packages/local-runtime-v2/src/service/turn-system/agent-host/execution/executor.ts index e4d20435a..2c7b09907 100644 --- a/packages/local-runtime-v2/src/service/turn-system/agent-host/execution/executor.ts +++ b/packages/local-runtime-v2/src/service/turn-system/agent-host/execution/executor.ts @@ -52,6 +52,7 @@ import { applyProcessLocalToolResultPolicy } from '../runner/policy/process-loca import type { LocalContextUsagePromptRange, LocalRuntimeTurnExecutorOptions, + LocalRuntimeTurnRunnerInput, LocalRuntimeTurnRunnerResult, LocalTurnHookMergeOptions, LocalTurnPermissionResolution, From c71ffd4ed4a8a4682a36232039243819bd43d8e3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 6 Oct 2026 03:56:47 +0000 Subject: [PATCH 06/13] fix: stop lightweight turns after unoffered tool calls Co-authored-by: DanielWalnut --- packages/agent-core/src/pi-turn-runner/agent.ts | 12 +++++++++--- packages/agent-core/src/pi-turn-runner/types.ts | 5 +++++ .../agent-host/execution/executor.test.ts | 15 ++------------- .../turn-system/agent-host/execution/executor.ts | 6 +----- 4 files changed, 17 insertions(+), 21 deletions(-) diff --git a/packages/agent-core/src/pi-turn-runner/agent.ts b/packages/agent-core/src/pi-turn-runner/agent.ts index 5a7bc1289..915192064 100644 --- a/packages/agent-core/src/pi-turn-runner/agent.ts +++ b/packages/agent-core/src/pi-turn-runner/agent.ts @@ -98,9 +98,15 @@ export function newAgent(turn: turnState): Agent { ...(turn.input.shouldStopAfterSteering ? { shouldStopAfterSteering: turn.input.shouldStopAfterSteering } : {}), - ...(turn.input.shouldStopAfterTurn - ? { shouldStopAfterTurn: turn.input.shouldStopAfterTurn } - : {}), + ...(turn.input.stopOnUnexpectedToolCall + ? { + shouldStopAfterTurn: async (context) => + context.message.content.some((block) => block.type === 'toolCall') || + (await turn.input.shouldStopAfterTurn?.()) === true, + } + : turn.input.shouldStopAfterTurn + ? { shouldStopAfterTurn: turn.input.shouldStopAfterTurn } + : {}), ...(turn.llm.payloadTransform ? { onPayload: turn.llm.payloadTransform } : {}), ...(turn.llm.responseObserver ? { onResponse: turn.llm.responseObserver } : {}), }); diff --git a/packages/agent-core/src/pi-turn-runner/types.ts b/packages/agent-core/src/pi-turn-runner/types.ts index abcb543fb..61032c8cf 100644 --- a/packages/agent-core/src/pi-turn-runner/types.ts +++ b/packages/agent-core/src/pi-turn-runner/types.ts @@ -202,6 +202,11 @@ export interface RunTurnInput boolean | Promise; /** Gracefully ends after the current assistant/tool step and before any follow-up poll. */ shouldStopAfterTurn?: () => boolean | Promise; + /** + * Stops after a provider emits a tool call that the host intentionally did not expose. + * The provider text remains visible and no second model hop is attempted. + */ + stopOnUnexpectedToolCall?: boolean; /** * Atomically seals external continuation acceptance once steering is empty. * `false` means work won the close race and the runner must poll once more. diff --git a/packages/local-runtime-v2/src/service/turn-system/agent-host/execution/executor.test.ts b/packages/local-runtime-v2/src/service/turn-system/agent-host/execution/executor.test.ts index e34a89247..e293e0331 100644 --- a/packages/local-runtime-v2/src/service/turn-system/agent-host/execution/executor.test.ts +++ b/packages/local-runtime-v2/src/service/turn-system/agent-host/execution/executor.test.ts @@ -1171,7 +1171,7 @@ describe("LocalRuntimeTurnExecutor", () => { })), }), ).toBe(standardFixture); - expect(standard.shouldStopAfterTurn).toBeUndefined(); + expect(standard.stopOnUnexpectedToolCall).toBeUndefined(); await runner.execute( executionInput({ @@ -1186,18 +1186,7 @@ describe("LocalRuntimeTurnExecutor", () => { expect(lightweight.systemPrompt).toBe(LIGHTWEIGHT_SYSTEM_PROMPT); expect(lightweight.tools).toEqual([]); expect(lightweight.contextUsagePromptRanges).toBeUndefined(); - expect( - await lightweight.shouldStopAfterTurn?.({ - message: { - content: [{ type: "toolCall", id: "call", name: "write", arguments: {} }], - }, - } as never), - ).toBe(true); - expect( - await lightweight.shouldStopAfterTurn?.({ - message: { content: [{ type: "text", text: "ordinary answer" }] }, - } as never), - ).toBe(false); + expect(lightweight.stopOnUnexpectedToolCall).toBe(true); }); it("does not propagate lightweight mode to task or branch child Sessions", async () => { diff --git a/packages/local-runtime-v2/src/service/turn-system/agent-host/execution/executor.ts b/packages/local-runtime-v2/src/service/turn-system/agent-host/execution/executor.ts index 2c7b09907..6757d0e94 100644 --- a/packages/local-runtime-v2/src/service/turn-system/agent-host/execution/executor.ts +++ b/packages/local-runtime-v2/src/service/turn-system/agent-host/execution/executor.ts @@ -52,7 +52,6 @@ import { applyProcessLocalToolResultPolicy } from '../runner/policy/process-loca import type { LocalContextUsagePromptRange, LocalRuntimeTurnExecutorOptions, - LocalRuntimeTurnRunnerInput, LocalRuntimeTurnRunnerResult, LocalTurnHookMergeOptions, LocalTurnPermissionResolution, @@ -518,9 +517,6 @@ export class LocalRuntimeTurnExecutor< tools, contextUsagePromptRanges, } = providerContext; - const lightweightShouldStopAfterTurn: NonNullable< - LocalRuntimeTurnRunnerInput['shouldStopAfterTurn'] - > = ({ message }) => message.content.some((block) => block.type === 'toolCall'); input.onContextUsagePromptRangesResolved?.(contextUsagePromptRanges); const userPromptPrefix = joinUserPrompt( input.assembly.userPromptPrefix, @@ -610,7 +606,7 @@ export class LocalRuntimeTurnExecutor< caller, ...(lightweightMode ? { - shouldStopAfterTurn: lightweightShouldStopAfterTurn, + stopOnUnexpectedToolCall: true, } : {}), ...(contextUsagePromptRanges !== undefined From 15e59d1a7c3f4d52ad73c4a3c734e7b15db7324f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 6 Oct 2026 03:58:15 +0000 Subject: [PATCH 07/13] test: snapshot standard footer and forward tool guard Co-authored-by: DanielWalnut --- .../src/service/turn-system/agent-host/execution/executor.ts | 2 ++ packages/tui/test/unit/tui-status-line-items.test.ts | 5 ++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/local-runtime-v2/src/service/turn-system/agent-host/execution/executor.ts b/packages/local-runtime-v2/src/service/turn-system/agent-host/execution/executor.ts index 6757d0e94..59f61697c 100644 --- a/packages/local-runtime-v2/src/service/turn-system/agent-host/execution/executor.ts +++ b/packages/local-runtime-v2/src/service/turn-system/agent-host/execution/executor.ts @@ -367,6 +367,7 @@ export class LocalRuntimeTurnExecutor< input.request.provenance.sourceContext?.executionDiagnostics === true, ), caller: prepared.caller, + ...(prepared.stopOnUnexpectedToolCall ? { stopOnUnexpectedToolCall: true } : {}), getSteeringMessages: async (context) => { for (;;) { // Subscribe before draining so an append racing the wait cannot be missed. @@ -800,6 +801,7 @@ interface PreparedRunnerInput { readonly toolContext: TContext; readonly beforeLlmCallHooks: readonly PiBeforeLlmCallHook[]; readonly caller: RunTurnCaller; + readonly stopOnUnexpectedToolCall?: boolean; readonly contextUsagePromptRanges?: readonly LocalContextUsagePromptRange[]; readonly contextUsageRequiresProviderAnchor?: boolean; readonly readTaskOutputTaskIds: Set; diff --git a/packages/tui/test/unit/tui-status-line-items.test.ts b/packages/tui/test/unit/tui-status-line-items.test.ts index 46ce7cd32..b0f844eb6 100644 --- a/packages/tui/test/unit/tui-status-line-items.test.ts +++ b/packages/tui/test/unit/tui-status-line-items.test.ts @@ -73,7 +73,10 @@ describe('status line item parsing', () => { describe('TuiStatusLine default items', () => { it('adds a compact lightweight indicator without changing the standard footer', () => { const standard = render(BASE_STATE); - expect(stripAnsi(standard)).toMatchInlineSnapshot(); + expect(stripAnsi(standard)).toMatchInlineSnapshot(` + " + ~/repo │ ◇ Ship status line │ ⎇ feat/status-line │ Auto │ ✦ m2 · Thinking On" + `); expect(render({ ...BASE_STATE, lightweightMode: false })).toBe(standard); const lightweight = stripAnsi(render({ ...BASE_STATE, lightweightMode: true })); From 021e79e2f8a3e790bec864ee2cd6f5e3b6940419 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 6 Oct 2026 04:00:44 +0000 Subject: [PATCH 08/13] fix: forward lightweight stop policy to agent loop Co-authored-by: DanielWalnut --- .../turn-system/agent-host/runner/local-agent-turn-runner.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/local-runtime-v2/src/service/turn-system/agent-host/runner/local-agent-turn-runner.ts b/packages/local-runtime-v2/src/service/turn-system/agent-host/runner/local-agent-turn-runner.ts index 05fccdf62..dd3ac1271 100644 --- a/packages/local-runtime-v2/src/service/turn-system/agent-host/runner/local-agent-turn-runner.ts +++ b/packages/local-runtime-v2/src/service/turn-system/agent-host/runner/local-agent-turn-runner.ts @@ -798,6 +798,7 @@ function toPiCoreInput( : {}), ...(input.includeDetailedUsage === true ? { includeDetailedUsage: true } : {}), ...(input.caller ? { caller: input.caller } : {}), + ...(input.stopOnUnexpectedToolCall === true ? { stopOnUnexpectedToolCall: true } : {}), }; } From 4f10eccd0f9cb3d00badd3bc7741cc5f19d40327 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 6 Oct 2026 04:04:14 +0000 Subject: [PATCH 09/13] fix: normalize unexpected lightweight tool calls Co-authored-by: DanielWalnut --- .../agent-core/src/pi-turn-runner/agent.ts | 15 ++++++------- .../agent-core/src/pi-turn-runner/types.ts | 7 ++----- .../agent-host/execution/executor.test.ts | 5 +++-- .../agent-host/execution/executor.ts | 9 +++++--- .../agent-host/execution/prompt.ts | 3 +++ .../runner/local-agent-turn-runner.ts | 4 +++- .../pi-mono/packages/agent/src/agent-loop.ts | 21 +++++++++++++++++-- .../pi-mono/packages/agent/src/agent.ts | 5 +++++ .../pi-mono/packages/agent/src/types.ts | 6 ++++++ 9 files changed, 53 insertions(+), 22 deletions(-) diff --git a/packages/agent-core/src/pi-turn-runner/agent.ts b/packages/agent-core/src/pi-turn-runner/agent.ts index 915192064..04cb14535 100644 --- a/packages/agent-core/src/pi-turn-runner/agent.ts +++ b/packages/agent-core/src/pi-turn-runner/agent.ts @@ -98,15 +98,12 @@ export function newAgent(turn: turnState): Agent { ...(turn.input.shouldStopAfterSteering ? { shouldStopAfterSteering: turn.input.shouldStopAfterSteering } : {}), - ...(turn.input.stopOnUnexpectedToolCall - ? { - shouldStopAfterTurn: async (context) => - context.message.content.some((block) => block.type === 'toolCall') || - (await turn.input.shouldStopAfterTurn?.()) === true, - } - : turn.input.shouldStopAfterTurn - ? { shouldStopAfterTurn: turn.input.shouldStopAfterTurn } - : {}), + ...(turn.input.shouldStopAfterTurn + ? { shouldStopAfterTurn: turn.input.shouldStopAfterTurn } + : {}), + ...(turn.input.unexpectedToolCallFallback + ? { unexpectedToolCallFallback: turn.input.unexpectedToolCallFallback } + : {}), ...(turn.llm.payloadTransform ? { onPayload: turn.llm.payloadTransform } : {}), ...(turn.llm.responseObserver ? { onResponse: turn.llm.responseObserver } : {}), }); diff --git a/packages/agent-core/src/pi-turn-runner/types.ts b/packages/agent-core/src/pi-turn-runner/types.ts index 61032c8cf..3e9df8313 100644 --- a/packages/agent-core/src/pi-turn-runner/types.ts +++ b/packages/agent-core/src/pi-turn-runner/types.ts @@ -202,11 +202,8 @@ export interface RunTurnInput boolean | Promise; /** Gracefully ends after the current assistant/tool step and before any follow-up poll. */ shouldStopAfterTurn?: () => boolean | Promise; - /** - * Stops after a provider emits a tool call that the host intentionally did not expose. - * The provider text remains visible and no second model hop is attempted. - */ - stopOnUnexpectedToolCall?: boolean; + /** Text returned when a provider emits a tool call the host intentionally did not expose. */ + unexpectedToolCallFallback?: string; /** * Atomically seals external continuation acceptance once steering is empty. * `false` means work won the close race and the runner must poll once more. diff --git a/packages/local-runtime-v2/src/service/turn-system/agent-host/execution/executor.test.ts b/packages/local-runtime-v2/src/service/turn-system/agent-host/execution/executor.test.ts index e293e0331..57bd119de 100644 --- a/packages/local-runtime-v2/src/service/turn-system/agent-host/execution/executor.test.ts +++ b/packages/local-runtime-v2/src/service/turn-system/agent-host/execution/executor.test.ts @@ -86,6 +86,7 @@ import { LocalTurnInputPreparer } from "../assembly/local-turn-input-preparation import { LIGHTWEIGHT_SESSION_PURPOSE } from "@mavis/protocol/local"; import { LIGHTWEIGHT_SYSTEM_PROMPT, + LIGHTWEIGHT_TOOL_CALL_FALLBACK, resolveProviderContextMode, } from "./prompt.js"; @@ -1171,7 +1172,7 @@ describe("LocalRuntimeTurnExecutor", () => { })), }), ).toBe(standardFixture); - expect(standard.stopOnUnexpectedToolCall).toBeUndefined(); + expect(standard.unexpectedToolCallFallback).toBeUndefined(); await runner.execute( executionInput({ @@ -1186,7 +1187,7 @@ describe("LocalRuntimeTurnExecutor", () => { expect(lightweight.systemPrompt).toBe(LIGHTWEIGHT_SYSTEM_PROMPT); expect(lightweight.tools).toEqual([]); expect(lightweight.contextUsagePromptRanges).toBeUndefined(); - expect(lightweight.stopOnUnexpectedToolCall).toBe(true); + expect(lightweight.unexpectedToolCallFallback).toBe(LIGHTWEIGHT_TOOL_CALL_FALLBACK); }); it("does not propagate lightweight mode to task or branch child Sessions", async () => { diff --git a/packages/local-runtime-v2/src/service/turn-system/agent-host/execution/executor.ts b/packages/local-runtime-v2/src/service/turn-system/agent-host/execution/executor.ts index 59f61697c..7911e6339 100644 --- a/packages/local-runtime-v2/src/service/turn-system/agent-host/execution/executor.ts +++ b/packages/local-runtime-v2/src/service/turn-system/agent-host/execution/executor.ts @@ -61,6 +61,7 @@ import { joinPrompt, joinUserPrompt, isLightweightRootSession, + LIGHTWEIGHT_TOOL_CALL_FALLBACK, readPreparedContextUsagePromptRanges, readPreparedSystemPrompt, renderAgentRuntimeReminders, @@ -367,7 +368,9 @@ export class LocalRuntimeTurnExecutor< input.request.provenance.sourceContext?.executionDiagnostics === true, ), caller: prepared.caller, - ...(prepared.stopOnUnexpectedToolCall ? { stopOnUnexpectedToolCall: true } : {}), + ...(prepared.unexpectedToolCallFallback + ? { unexpectedToolCallFallback: prepared.unexpectedToolCallFallback } + : {}), getSteeringMessages: async (context) => { for (;;) { // Subscribe before draining so an append racing the wait cannot be missed. @@ -607,7 +610,7 @@ export class LocalRuntimeTurnExecutor< caller, ...(lightweightMode ? { - stopOnUnexpectedToolCall: true, + unexpectedToolCallFallback: LIGHTWEIGHT_TOOL_CALL_FALLBACK, } : {}), ...(contextUsagePromptRanges !== undefined @@ -801,7 +804,7 @@ interface PreparedRunnerInput { readonly toolContext: TContext; readonly beforeLlmCallHooks: readonly PiBeforeLlmCallHook[]; readonly caller: RunTurnCaller; - readonly stopOnUnexpectedToolCall?: boolean; + readonly unexpectedToolCallFallback?: string; readonly contextUsagePromptRanges?: readonly LocalContextUsagePromptRange[]; readonly contextUsageRequiresProviderAnchor?: boolean; readonly readTaskOutputTaskIds: Set; diff --git a/packages/local-runtime-v2/src/service/turn-system/agent-host/execution/prompt.ts b/packages/local-runtime-v2/src/service/turn-system/agent-host/execution/prompt.ts index 78cc2b2c7..5b9ab54f8 100644 --- a/packages/local-runtime-v2/src/service/turn-system/agent-host/execution/prompt.ts +++ b/packages/local-runtime-v2/src/service/turn-system/agent-host/execution/prompt.ts @@ -13,6 +13,9 @@ export const LIGHTWEIGHT_SYSTEM_PROMPT = [ 'Do not claim to inspect or change local files. If a request needs coding or tools, explain that the user must start a new standard session with `mcode --mode standard` or `mcode exec --mode standard`; standard is also the default when `--mode` is omitted.', ].join('\n'); +export const LIGHTWEIGHT_TOOL_CALL_FALLBACK = + 'This request needs tools, which are unavailable in lightweight mode. Start a new standard Session with `mcode --mode standard`; standard is also the default when `--mode` is omitted.'; + export interface ProviderContext { readonly systemPrompt: string; readonly tools: readonly TTool[]; diff --git a/packages/local-runtime-v2/src/service/turn-system/agent-host/runner/local-agent-turn-runner.ts b/packages/local-runtime-v2/src/service/turn-system/agent-host/runner/local-agent-turn-runner.ts index dd3ac1271..107bfa820 100644 --- a/packages/local-runtime-v2/src/service/turn-system/agent-host/runner/local-agent-turn-runner.ts +++ b/packages/local-runtime-v2/src/service/turn-system/agent-host/runner/local-agent-turn-runner.ts @@ -798,7 +798,9 @@ function toPiCoreInput( : {}), ...(input.includeDetailedUsage === true ? { includeDetailedUsage: true } : {}), ...(input.caller ? { caller: input.caller } : {}), - ...(input.stopOnUnexpectedToolCall === true ? { stopOnUnexpectedToolCall: true } : {}), + ...(input.unexpectedToolCallFallback + ? { unexpectedToolCallFallback: input.unexpectedToolCallFallback } + : {}), }; } diff --git a/third_party/pi-mono/packages/agent/src/agent-loop.ts b/third_party/pi-mono/packages/agent/src/agent-loop.ts index d91369cea..e22a6deaa 100644 --- a/third_party/pi-mono/packages/agent/src/agent-loop.ts +++ b/third_party/pi-mono/packages/agent/src/agent-loop.ts @@ -345,7 +345,7 @@ async function streamAssistantResponse( case "done": case "error": { - const finalMessage = await response.result(); + const finalMessage = normalizeUnexpectedToolCall(await response.result(), config); if (addedPartial) { context.messages[context.messages.length - 1] = finalMessage; } else { @@ -360,7 +360,7 @@ async function streamAssistantResponse( } } - const finalMessage = await response.result(); + const finalMessage = normalizeUnexpectedToolCall(await response.result(), config); if (addedPartial) { context.messages[context.messages.length - 1] = finalMessage; } else { @@ -371,6 +371,23 @@ async function streamAssistantResponse( return finalMessage; } +function normalizeUnexpectedToolCall( + message: AssistantMessage, + config: AgentLoopConfig, +): AssistantMessage { + const fallback = config.unexpectedToolCallFallback?.trim(); + if (!fallback || !message.content.some((block) => block.type === "toolCall")) return message; + const content = message.content.filter((block) => block.type !== "toolCall"); + return { + ...message, + content: + content.some((block) => block.type === "text" && block.text.trim()) + ? content + : [{ type: "text", text: fallback }], + stopReason: "stop", + }; +} + /** * Execute tool calls from an assistant message. */ diff --git a/third_party/pi-mono/packages/agent/src/agent.ts b/third_party/pi-mono/packages/agent/src/agent.ts index d080e5114..d20c1a16a 100644 --- a/third_party/pi-mono/packages/agent/src/agent.ts +++ b/third_party/pi-mono/packages/agent/src/agent.ts @@ -117,6 +117,7 @@ export interface AgentOptions { transport?: Transport; maxRetryDelayMs?: number; toolExecution?: ToolExecutionMode; + unexpectedToolCallFallback?: string; } class PendingMessageQueue { @@ -208,6 +209,8 @@ export class Agent { public maxRetryDelayMs?: number; /** Tool execution strategy for assistant messages that contain multiple tool calls. */ public toolExecution: ToolExecutionMode; + /** Optional text fallback used instead of executing provider-emitted unoffered tools. */ + public unexpectedToolCallFallback?: string; constructor(options: AgentOptions = {}) { this._state = createMutableAgentState(options.initialState); @@ -230,6 +233,7 @@ export class Agent { this.transport = options.transport ?? "auto"; this.maxRetryDelayMs = options.maxRetryDelayMs; this.toolExecution = options.toolExecution ?? "parallel"; + this.unexpectedToolCallFallback = options.unexpectedToolCallFallback; } /** @@ -450,6 +454,7 @@ export class Agent { thinkingBudgets: this.thinkingBudgets, maxRetryDelayMs: this.maxRetryDelayMs, toolExecution: this.toolExecution, + unexpectedToolCallFallback: this.unexpectedToolCallFallback, beforeToolCall: this.beforeToolCall, onToolExecutionStart: this.onToolExecutionStart, afterToolCall: this.afterToolCall, diff --git a/third_party/pi-mono/packages/agent/src/types.ts b/third_party/pi-mono/packages/agent/src/types.ts index 2eeb55be5..c32ce4d41 100644 --- a/third_party/pi-mono/packages/agent/src/types.ts +++ b/third_party/pi-mono/packages/agent/src/types.ts @@ -263,6 +263,12 @@ export interface AgentLoopConfig extends SimpleStreamOptions { */ toolExecution?: ToolExecutionMode; + /** + * Converts an unexpected provider tool call into a terminal text response. + * Omit to preserve the normal missing-tool error/result loop. + */ + unexpectedToolCallFallback?: string; + /** * Called before a tool is executed, after arguments have been validated. * From 144b705f4e46f9fc593063b68fa24b6b1a4317b3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 6 Oct 2026 04:06:18 +0000 Subject: [PATCH 10/13] test: require clear lightweight tool fallback Co-authored-by: DanielWalnut --- test/byok.test.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/byok.test.mjs b/test/byok.test.mjs index e18fcc7a3..8a5dc646d 100644 --- a/test/byok.test.mjs +++ b/test/byok.test.mjs @@ -644,7 +644,6 @@ test( index: 0, delta: { role: "assistant", - content: "LIGHTWEIGHT_TOOL_CALL_TEXT", tool_calls: [ { index: 0, @@ -752,7 +751,8 @@ test( }); const [code] = await once(child, "close"); assert.equal(code, 0, `${stdout}\n${stderr}`); - assert.match(stdout, /LIGHTWEIGHT_TOOL_CALL_TEXT/u); + assert.match(stdout, /tools, which are unavailable in lightweight mode/u); + assert.match(stdout, /mcode --mode standard/u); assert.equal(streamRequests, 1, "The rejected tool call must not trigger another model step"); assert.equal(existsSync(marker), false, "An unoffered tool call must never execute"); assert.equal(existsSync(networkAudit), false, "No external requests are allowed"); From b0608ba1f6d90f9a5c9c090b024640275e67f28a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 6 Oct 2026 04:15:01 +0000 Subject: [PATCH 11/13] test: cover resumed mode indicator in TUI Co-authored-by: DanielWalnut --- packages/tui/test/unit/tui-app.test.ts | 43 ++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/packages/tui/test/unit/tui-app.test.ts b/packages/tui/test/unit/tui-app.test.ts index c8c735777..b7dbd23c3 100644 --- a/packages/tui/test/unit/tui-app.test.ts +++ b/packages/tui/test/unit/tui-app.test.ts @@ -49,6 +49,7 @@ import type { import { VirtualTerminalScreen } from "../helpers/virtual-terminal.js"; import { VirtualTerminal } from "../pi-084-upstream/virtual-terminal.js"; import { TuiFailure } from "../../src/failure.js"; +import { LIGHTWEIGHT_SESSION_PURPOSE } from "@mavis/protocol/local"; const runtimeEvent = (event: RawTuiRuntimeEvent): TuiRuntimeEvent => normalizeTuiRuntimeEvent(event); @@ -9680,6 +9681,48 @@ describe("createTuiApp", () => { await app.stop(); }); + it.each([ + { + label: "lightweight", + purpose: LIGHTWEIGHT_SESSION_PURPOSE, + indicator: true, + }, + { + label: "standard", + purpose: undefined, + indicator: false, + }, + ])("renders the persisted mode after /resume of a $label Session", async ({ purpose, indicator }) => { + const terminal = new FakeTerminal(); + const runtime = createRuntime(); + vi.mocked(runtime.listSessions).mockResolvedValue([ + { + sessionId: "session-resumed-mode", + title: "Resumed mode", + workspaceDir: "/workspace", + ...(purpose ? { purpose } : {}), + }, + ]); + const app = createTuiApp({ + runtime, + terminal, + version: "0.1.0", + workspaceDir: "/workspace", + }); + try { + await app.ready; + await app.submit("/resume session-resumed-mode"); + await vi.waitFor(() => + expect(app.controller.snapshot().session?.sessionId).toBe("session-resumed-mode"), + ); + const rendered = stripAnsi(app.tui.render(120).join("\n")); + if (indicator) expect(rendered).toContain("Lightweight"); + else expect(rendered).not.toContain("Lightweight"); + } finally { + await app.stop(); + } + }); + it("queries the current workspace first and reloads the global catalog on Ctrl+A", async () => { const terminal = new FakeTerminal(); const runtime = createRuntime(); From 85561200873f931deb69fbc7de43b2176e5dd50c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 6 Oct 2026 04:16:24 +0000 Subject: [PATCH 12/13] test: hydrate resumed session purpose in TUI Co-authored-by: DanielWalnut --- packages/tui/test/unit/tui-app.test.ts | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/packages/tui/test/unit/tui-app.test.ts b/packages/tui/test/unit/tui-app.test.ts index b7dbd23c3..4eea37b9d 100644 --- a/packages/tui/test/unit/tui-app.test.ts +++ b/packages/tui/test/unit/tui-app.test.ts @@ -9695,14 +9695,18 @@ describe("createTuiApp", () => { ])("renders the persisted mode after /resume of a $label Session", async ({ purpose, indicator }) => { const terminal = new FakeTerminal(); const runtime = createRuntime(); - vi.mocked(runtime.listSessions).mockResolvedValue([ - { - sessionId: "session-resumed-mode", - title: "Resumed mode", - workspaceDir: "/workspace", - ...(purpose ? { purpose } : {}), - }, - ]); + const session = { + sessionId: "session-resumed-mode", + title: "Resumed mode", + workspaceDir: "/workspace", + ...(purpose ? { purpose } : {}), + }; + vi.mocked(runtime.listSessions).mockResolvedValue([session]); + vi.mocked(runtime.listSessionPage).mockResolvedValue({ + sessions: [session], + hasMore: false, + }); + vi.mocked(runtime.getSession).mockResolvedValue(session); const app = createTuiApp({ runtime, terminal, From e1efd217db678070ef698aba40208c1ca91aa35a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 6 Oct 2026 04:25:45 +0000 Subject: [PATCH 13/13] fix: preserve failed unexpected tool responses Co-authored-by: DanielWalnut --- third_party/pi-mono/MINIMAX_CHANGES.md | 8 ++ .../pi-mono/packages/agent/src/agent-loop.ts | 24 +++-- .../packages/agent/test/agent-loop.test.ts | 87 +++++++++++++++++++ 3 files changed, 114 insertions(+), 5 deletions(-) diff --git a/third_party/pi-mono/MINIMAX_CHANGES.md b/third_party/pi-mono/MINIMAX_CHANGES.md index 0d4b96a7c..f0d7f7f18 100644 --- a/third_party/pi-mono/MINIMAX_CHANGES.md +++ b/third_party/pi-mono/MINIMAX_CHANGES.md @@ -13,6 +13,14 @@ This directory vendors `pi-mono` as source so MiniMax can patch, validate, and s No upstream source files are changed in the baseline import. +### 2026-10-06 — opt-in fallback for unexpected provider tool calls + +- Reason: a conversational host can intentionally advertise no tools, but a provider may still emit a structured tool call. The normal missing-tool result starts another model hop and can leave the host without a final assistant response. +- Affected package: `packages/agent` (`@earendil-works/pi-agent-core`), agent options, loop config, and successful assistant-response finalization. +- Change type: generic, upstreamable opt-in host seam. When `unexpectedToolCallFallback` is present, a successful response that contains tool calls removes those calls, preserves prior text, and appends the fallback exactly once before ending normally. Provider responses with `stopReason: "error"` or `"aborted"` remain untouched so retry and error reporting preserve the real failure. Omitting the option retains upstream behavior. +- Upstream PR: not opened. +- Validation: focused `packages/agent/test/agent-loop.test.ts` regressions cover text-plus-tool guidance, deduplication, and unchanged error/aborted responses; the distribution BYOK mock-provider test proves no unoffered tool executes and no second model request occurs. `pnpm test:release-tools` and `pnpm check:source` validate the vendored patch record and public inventory. + ### 2026-10-01 — keep Anthropic classifier refusal details - Reason: Claude safety-classifier refusals arrive as HTTP 200 with `stop_reason: "refusal"` (see [Refusals and fallback](https://platform.claude.com/docs/en/build-with-claude/refusals-and-fallback)). They were mapped to `error` and surfaced as `An unknown error occurred`, which dropped `stop_details.category` / `explanation`, hid the refusal from downstream classification, and let BYOK retry it as an unknown failure. diff --git a/third_party/pi-mono/packages/agent/src/agent-loop.ts b/third_party/pi-mono/packages/agent/src/agent-loop.ts index e22a6deaa..b43d59108 100644 --- a/third_party/pi-mono/packages/agent/src/agent-loop.ts +++ b/third_party/pi-mono/packages/agent/src/agent-loop.ts @@ -376,14 +376,28 @@ function normalizeUnexpectedToolCall( config: AgentLoopConfig, ): AssistantMessage { const fallback = config.unexpectedToolCallFallback?.trim(); - if (!fallback || !message.content.some((block) => block.type === "toolCall")) return message; + if ( + !fallback || + message.stopReason === "error" || + message.stopReason === "aborted" || + !message.content.some((block) => block.type === "toolCall") + ) { + return message; + } const content = message.content.filter((block) => block.type !== "toolCall"); + const existingFallback = content.some((block) => block.type === "text" && block.text.includes(fallback)); + if (!existingFallback) { + const textIndex = content.findLastIndex((block) => block.type === "text"); + const textBlock = content[textIndex]; + if (textBlock?.type === "text" && textBlock.text.trim()) { + content[textIndex] = { ...textBlock, text: `${textBlock.text.trimEnd()}\n\n${fallback}` }; + } else { + content.push({ type: "text", text: fallback }); + } + } return { ...message, - content: - content.some((block) => block.type === "text" && block.text.trim()) - ? content - : [{ type: "text", text: fallback }], + content, stopReason: "stop", }; } diff --git a/third_party/pi-mono/packages/agent/test/agent-loop.test.ts b/third_party/pi-mono/packages/agent/test/agent-loop.test.ts index 276cb7457..b685b82fe 100644 --- a/third_party/pi-mono/packages/agent/test/agent-loop.test.ts +++ b/third_party/pi-mono/packages/agent/test/agent-loop.test.ts @@ -1491,6 +1491,93 @@ describe("agentLoop with AgentMessage", () => { expect(llmCalls).toBe(1); }); + + it.each(["error", "aborted"] as const)( + "does not normalize an interrupted unexpected tool call with stop reason %s", + async (stopReason) => { + const fallback = "Switch to standard mode."; + const message = { + ...createAssistantMessage( + [{ type: "toolCall", id: "tool-1", name: "read", arguments: {} }], + stopReason, + ), + errorMessage: "Synthetic provider failure", + }; + const stream = agentLoop( + [createUserMessage("Read a file")], + { systemPrompt: "", messages: [], tools: [] }, + { + model: createModel(), + convertToLlm: identityConverter, + unexpectedToolCallFallback: fallback, + }, + undefined, + () => { + const mockStream = new MockAssistantStream(); + queueMicrotask(() => { + mockStream.push({ type: "error", reason: stopReason, error: message }); + }); + return mockStream; + }, + ); + + for await (const _event of stream) { + // consume + } + const assistant = (await stream.result()).at(-1); + expect(assistant).toMatchObject({ + role: "assistant", + stopReason, + errorMessage: "Synthetic provider failure", + content: [{ type: "toolCall", name: "read" }], + }); + }, + ); + + it.each([ + ["I'll read the file.", "I'll read the file.\n\nSwitch to standard mode."], + ["I'll read the file.\n\nSwitch to standard mode.", "I'll read the file.\n\nSwitch to standard mode."], + ])("appends unexpected-tool guidance once after provider text", async (providerText, expected) => { + const fallback = "Switch to standard mode."; + const stream = agentLoop( + [createUserMessage("Read a file")], + { systemPrompt: "", messages: [], tools: [] }, + { + model: createModel(), + convertToLlm: identityConverter, + unexpectedToolCallFallback: fallback, + }, + undefined, + () => { + const mockStream = new MockAssistantStream(); + queueMicrotask(() => { + const message = createAssistantMessage( + [ + { type: "text", text: providerText }, + { type: "toolCall", id: "tool-1", name: "read", arguments: {} }, + ], + "toolUse", + ); + mockStream.push({ type: "done", reason: "toolUse", message }); + }); + return mockStream; + }, + ); + + for await (const _event of stream) { + // consume + } + const assistant = (await stream.result()).at(-1); + expect(assistant).toMatchObject({ + role: "assistant", + stopReason: "stop", + content: [{ type: "text", text: expected }], + }); + expect((assistant as AssistantMessage).content).not.toEqual( + expect.arrayContaining([expect.objectContaining({ type: "toolCall" })]), + ); + expect(expected.split(fallback)).toHaveLength(2); + }); }); describe("agentLoopContinue with AgentMessage", () => {