diff --git a/README.md b/README.md index 2203463a2..cf518b604 100644 --- a/README.md +++ b/README.md @@ -146,6 +146,28 @@ 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 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. + +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. 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 ```bash diff --git a/README_ZH.md b/README_ZH.md index c239078bf..370efc0e8 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -146,6 +146,25 @@ 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 schema 和完整环境块,因此不能检查或修改本地文件。需要编程或工具能力时,请使用 +`mcode --mode standard` 或 `mcode exec --mode standard` 创建新的标准会话;省略 `--mode` +与标准模式完全一致。 + +模式在根会话创建时固定,因此 `--mode lightweight` 不能与 `--session` 或 `--continue` +同时使用。子代理/子会话不会继承轻量模式,`/compact` 仍使用标准压缩上下文,ACP 与桌面端 +会话也始终使用标准模式。重新打开轻量会话时会保留该模式,并在 TUI 状态栏显示 +`Lightweight`;从轻量模式启动的 TUI 打开标准会话时仍保持标准模式,且不显示该标记。 + ### 继续之前的工作 ```bash diff --git a/packages/agent-core/src/pi-turn-runner/agent.ts b/packages/agent-core/src/pi-turn-runner/agent.ts index 5a7bc1289..04cb14535 100644 --- a/packages/agent-core/src/pi-turn-runner/agent.ts +++ b/packages/agent-core/src/pi-turn-runner/agent.ts @@ -101,6 +101,9 @@ export function newAgent(turn: turnState): Agent { ...(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 abcb543fb..3e9df8313 100644 --- a/packages/agent-core/src/pi-turn-runner/types.ts +++ b/packages/agent-core/src/pi-turn-runner/types.ts @@ -202,6 +202,8 @@ export interface RunTurnInput boolean | Promise; /** Gracefully ends after the current assistant/tool step and before any follow-up poll. */ shouldStopAfterTurn?: () => boolean | Promise; + /** 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/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..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 @@ -83,6 +83,12 @@ 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, + LIGHTWEIGHT_TOOL_CALL_FALLBACK, + resolveProviderContextMode, +} from "./prompt.js"; interface LocalToolContext extends ToolExecutionContext { readonly permissionScope?: string; @@ -1132,6 +1138,132 @@ 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); + expect(standard.unexpectedToolCallFallback).toBeUndefined(); + + 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(); + expect(lightweight.unexpectedToolCallFallback).toBe(LIGHTWEIGHT_TOOL_CALL_FALLBACK); + }); + + 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.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 = { + 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..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 @@ -60,9 +60,12 @@ import type { import { joinPrompt, joinUserPrompt, + isLightweightRootSession, + LIGHTWEIGHT_TOOL_CALL_FALLBACK, readPreparedContextUsagePromptRanges, readPreparedSystemPrompt, renderAgentRuntimeReminders, + resolveProviderContextMode, } from './prompt.js'; import { createAgentHostPluginHookTranscript, @@ -365,6 +368,9 @@ export class LocalRuntimeTurnExecutor< input.request.provenance.sourceContext?.executionDiagnostics === true, ), caller: prepared.caller, + ...(prepared.unexpectedToolCallFallback + ? { unexpectedToolCallFallback: prepared.unexpectedToolCallFallback } + : {}), getSteeringMessages: async (context) => { for (;;) { // Subscribe before draining so an append racing the wait cannot be missed. @@ -470,6 +476,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, @@ -484,7 +491,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 +503,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, @@ -589,6 +608,11 @@ export class LocalRuntimeTurnExecutor< toolContext, beforeLlmCallHooks, caller, + ...(lightweightMode + ? { + unexpectedToolCallFallback: LIGHTWEIGHT_TOOL_CALL_FALLBACK, + } + : {}), ...(contextUsagePromptRanges !== undefined ? { contextUsagePromptRanges, @@ -780,6 +804,7 @@ interface PreparedRunnerInput { readonly toolContext: TContext; readonly beforeLlmCallHooks: readonly PiBeforeLlmCallHook[]; readonly caller: RunTurnCaller; + 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 326d1f484..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 @@ -1,8 +1,47 @@ 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 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'); + +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[]; + 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/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..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,6 +798,9 @@ function toPiCoreInput( : {}), ...(input.includeDetailedUsage === true ? { includeDetailedUsage: true } : {}), ...(input.caller ? { caller: input.caller } : {}), + ...(input.unexpectedToolCallFallback + ? { unexpectedToolCallFallback: input.unexpectedToolCallFallback } + : {}), }; } 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/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/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/src/tui/shell/chrome.ts b/packages/tui/src/tui/shell/chrome.ts index a3b585e8c..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'; @@ -20,6 +21,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 +134,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 +159,16 @@ 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( + lightweightModeLabels().map((label) => chalk.bold.hex(colors.signal)(label)), + { shrinkPriority: 75, preserveWhenNarrow: true }, + ); case 'build-mode': return createStatusSegment([renderBuildMode(state)], { shrinkPriority: 70, @@ -383,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/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/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..4eea37b9d 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,52 @@ 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(); + 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, + 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(); @@ -15362,6 +15409,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..9c832392a 100644 --- a/packages/tui/test/unit/tui-runtime-adapter.test.ts +++ b/packages/tui/test/unit/tui-runtime-adapter.test.ts @@ -1,7 +1,78 @@ 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("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..b0f844eb6 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'; @@ -71,6 +71,31 @@ 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(` + " + ~/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 })); + expect(lightweight).toContain('Lightweight'); + expect(lightweight).toContain(stripAnsi(standard).trim()); + 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'); 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..8a5dc646d 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", + 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, /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"); + }, +); + test( "cancelling a running tool preserves its completed display message", { timeout: 45000, skip: process.platform === "win32" }, 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 d91369cea..b43d59108 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,37 @@ async function streamAssistantResponse( return finalMessage; } +function normalizeUnexpectedToolCall( + message: AssistantMessage, + config: AgentLoopConfig, +): AssistantMessage { + const fallback = config.unexpectedToolCallFallback?.trim(); + 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, + 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. * 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", () => {