diff --git a/apps/desktop/src/main/__tests__/ipc-surface-contract.test.ts b/apps/desktop/src/main/__tests__/ipc-surface-contract.test.ts index 490960139d7..c1c46c48313 100644 --- a/apps/desktop/src/main/__tests__/ipc-surface-contract.test.ts +++ b/apps/desktop/src/main/__tests__/ipc-surface-contract.test.ts @@ -72,7 +72,8 @@ describe('IPC surface contract', () => { // The ai-sdk backend wiring moved into session-stream.ts (arch R5); these two // pins now target the combined main-process source instead of main.ts itself. assert.match(combinedMainProcess, /const memoryPromptSnapshot = await systemPromptService\.buildLocalMemoryPromptFragment\(\)/); - assert.match(combinedMainProcess, /systemPrompt: \(\{ cwd \}\) => systemPromptService\.buildBackendSystemPrompt\(ctx\.header, cwd, \{[\s\S]*childInstruction: ctx\.systemPrompt/); + assert.match(combinedMainProcess, /systemPrompt: async \(\{ cwd \}\) => \{/); + assert.match(combinedMainProcess, /const base = await systemPromptService\.buildBackendSystemPrompt\([\s\S]*ctx\.header,[\s\S]*cwd,[\s\S]*childInstruction: ctx\.systemPrompt/); assert.match(combinedMainProcess, /async function buildBackendSystemPrompt/); assert.match(combinedMainProcess, /childInstruction[\s\S]*memoryFragment: null, includePersonalization: false/); assert.match(combinedMainProcess, /子代理必须继承当前会话的权限、隐私、工作区和技能约束/); diff --git a/apps/desktop/src/main/__tests__/plan-mode-new-chat-contract.test.ts b/apps/desktop/src/main/__tests__/plan-mode-new-chat-contract.test.ts new file mode 100644 index 00000000000..19dd11ad555 --- /dev/null +++ b/apps/desktop/src/main/__tests__/plan-mode-new-chat-contract.test.ts @@ -0,0 +1,28 @@ +import { strict as assert } from 'node:assert'; +import { describe, it } from 'node:test'; +import { readRendererShellSources } from './renderer-shell-source-helpers.js'; +import { readMainProcessCombinedSource } from './main-process-contract-source-helpers.js'; + +describe('new-chat Plan Mode contract', () => { + it('keeps the Plan switch available before a session exists', async () => { + const source = await readRendererShellSources(['app-shell.tsx']); + + assert.match(source, /const \[newChatPlanModeActive, setNewChatPlanModeActive\] = useState\(false\);/); + assert.match(source, /if \(!sessionId\) \{\s*setNewChatPlanModeActive\(active\);\s*return;\s*\}/); + assert.match(source, /onPlanModeChange=\{setPlanMode\}/); + assert.doesNotMatch(source, /onPlanModeChange=\{activeId \? setPlanMode : undefined\}/); + }); + + it('carries the selected collaboration mode into first-message session creation', async () => { + const renderer = await readRendererShellSources(['app-shell.tsx', 'app-shell-chat-actions.ts']); + const main = await readMainProcessCombinedSource(); + + assert.match(renderer, /newChatCollaborationMode: newChatPlanModeActive \? 'plan' : 'agent'/); + assert.match(renderer, /collaborationMode: newChatCollaborationMode/); + assert.match(main, /const collaborationMode = input\?\.collaborationMode \?\? 'agent';/); + assert.ok( + (main.match(/\s+collaborationMode,\s/g) ?? []).length >= 2, + 'both fake and ai-sdk session creation must persist the selected collaboration mode', + ); + }); +}); diff --git a/apps/desktop/src/main/__tests__/plan-mode-panel-style-contract.test.ts b/apps/desktop/src/main/__tests__/plan-mode-panel-style-contract.test.ts new file mode 100644 index 00000000000..5ac767a51b7 --- /dev/null +++ b/apps/desktop/src/main/__tests__/plan-mode-panel-style-contract.test.ts @@ -0,0 +1,82 @@ +import { strict as assert } from 'node:assert'; +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { describe, it } from 'node:test'; + +const repoRoot = resolve(import.meta.dirname, '../../../../..'); +const rendererRoot = resolve(repoRoot, 'apps', 'desktop', 'src', 'renderer'); + +describe('Plan Mode proposal presentation', () => { + it('renders proposal steps and risks as individual items', async () => { + const source = await readFile(resolve(rendererRoot, 'plan-mode-panel.tsx'), 'utf8'); + + assert.match(source, /className="plan-proposal-steps"/); + assert.match(source, /proposal\.steps\.map\(\(step, index\)/); + assert.match(source, /className="plan-proposal-step-number"/); + assert.match(source, /className="plan-proposal-step-content"/); + assert.match(source, /\{step\.title\}<\/strong>/); + assert.match(source, /

\{step\.description\}<\/p>/); + assert.match(source, /proposal\.risks\.map\(\(risk, index\)/); + assert.match(source, /reviewable && \(/); + assert.match(source, /proposalStatusLabel\(proposal\.status\)/); + }); + + it('uses Maka theme tokens instead of fixed light-mode colors', async () => { + const styles = await readFile(resolve(rendererRoot, 'styles/plan-mode.css'), 'utf8'); + + assert.match(styles, /background: var\(--background-elevated\)/); + assert.match(styles, /color: var\(--foreground\)/); + assert.match(styles, /solid var\(--border\)/); + assert.doesNotMatch(styles, /#[\da-f]{3,8}\b/i); + }); + + it('anchors every proposal to its historical turn and keeps execution above the composer', async () => { + const appShell = await readFile(resolve(rendererRoot, 'app-shell.tsx'), 'utf8'); + const chatView = await readFile( + resolve(repoRoot, 'packages', 'ui', 'src', 'chat-view.tsx'), + 'utf8', + ); + + assert.match(appShell, /afterTurnId: proposal\.turnId/); + assert.match(appShell, /conversationItems=\{planConversationItems\}/); + assert.match(appShell, /[\s\S]*? { + const source = await readFile(resolve(rendererRoot, 'plan-mode-panel.tsx'), 'utf8'); + + assert.match(source, /isPlanToolResult\(event\)/); + assert.match(source, /kind === 'plan_progress_updated'/); + assert.match(source, /className="plan-execution-steps"/); + assert.match(source, /executionStepStatusLabel\(step\.status\)/); + assert.match(source, /\{step\.title\}<\/span>/); + }); + + it('keeps execution details collapsed by default and offers interrupted actions', async () => { + const [source, preload, bridge, main] = await Promise.all([ + readFile(resolve(rendererRoot, 'plan-mode-panel.tsx'), 'utf8'), + readFile(resolve(repoRoot, 'apps', 'desktop', 'src', 'preload', 'preload.ts'), 'utf8'), + readFile( + resolve(repoRoot, 'apps', 'desktop', 'src', 'preload', 'bridge-contract.d.ts'), + 'utf8', + ), + readFile(resolve(repoRoot, 'apps', 'desktop', 'src', 'main', 'sessions-ipc-main.ts'), 'utf8'), + ]); + + assert.match(source, /const \[expanded, setExpanded\] = useState\(false\)/); + assert.match(source, /aria-expanded=\{expanded\}/); + assert.match(source, /\{expanded && \(/); + assert.match(source, />\s*恢复执行\s*\s*放弃计划\s* { it('is injected as a current-turn tail instead of durable system prefix', async () => { const source = await readMainProcessCombinedSource(); - assert.match(source, /turnTailPrompt:\s*\(\{ cwd, sessionId \}\) => systemPromptService\.buildTurnTailPrompt\(cwd, sessionId\)/); + assert.match(source, /turnTailPrompt:\s*async \(\{ cwd, sessionId \}\) => \{/); + assert.match(source, /const base = await systemPromptService\.buildTurnTailPrompt\(cwd, sessionId\)/); assert.match(source, /async function buildTurnTailPrompt\(cwd\?: string, sessionId\?: string\)/); assert.match(source, /projectGit:\s*await resolveProjectGitInfo\(cwd\)/); assert.doesNotMatch(source, /personalization\.text,\n\s*environment,\n\s*deepResearch/); diff --git a/apps/desktop/src/main/__tests__/session-sticky-model-contract.test.ts b/apps/desktop/src/main/__tests__/session-sticky-model-contract.test.ts index 1225181d906..b9efdb8ca10 100644 --- a/apps/desktop/src/main/__tests__/session-sticky-model-contract.test.ts +++ b/apps/desktop/src/main/__tests__/session-sticky-model-contract.test.ts @@ -45,7 +45,7 @@ describe('PR-SESSION-STICKY-MODEL-0 contract', () => { assert.match(coreReadiness, /normalizeRequestedModelForReadiness\(\s*connection: LlmConnection,\s*requestedModel: string \| undefined,\s*\)/); assert.match(coreReadiness, /CODEX_SUBSCRIPTION_UNSUPPORTED_CHATGPT_MODELS\.has\(requestedModel\)/); assert.match(main, /const \{ connection, apiKey, model \} = await getReadyConnection\(ctx\.header\.llmConnectionSlug, ctx\.header\.model\)/); - assert.match(main, /header: \{ \.\.\.ctx\.header, model \}/); + assert.match(main, /header: \{ \.\.\.ctx\.header, model, permissionMode: effectivePermissionMode \}/); assert.match(main, /modelId: model/); // #1038: the locked/sticky guarantee lives in the core projection — // the desktop send gate delegates the decision to it. diff --git a/apps/desktop/src/main/__tests__/skills.test.ts b/apps/desktop/src/main/__tests__/skills.test.ts index 3987f9da7fd..8f68b99e3a0 100644 --- a/apps/desktop/src/main/__tests__/skills.test.ts +++ b/apps/desktop/src/main/__tests__/skills.test.ts @@ -113,7 +113,7 @@ Use the Office tools.`); assert.match(mainProcess, /\[\.\.\.builtinTools, \.\.\.buildMcpTools\(mcpManager\)\]/); assert.match(mainProcess, /const backendTools = computerUseToolsForModel\(/); assert.match(mainProcess, /const backendToolNames = new Set\(/); - assert.match(mainProcess, /backendTools\.map\(\(tool\) => tool\.name\)/); + assert.match(mainProcess, /selectedTools\.map\(\(tool\) => tool\.name\)/); assert.match(mainProcess, /if \(!ctx\.tools\) desktopSessionSkillHosts\.set\(ctx\.sessionId, backendSkillHost\)/); assert.match(mainProcess, /host: backendSkillHost/); }); diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 49259413a56..ab5d4cf5724 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -56,6 +56,7 @@ import { createReadImageSnapshotter, createConnectionStore, createPlanReminderStore, + createPlanStore, openRuntimeEventPersistence, createSessionStore, createSettingsStore, @@ -195,6 +196,7 @@ const workspaceRoot = join(app.getPath('userData'), 'workspaces', visualSmokeFix // process, so quit needs no special teardown. const keepSystemAwake = createKeepSystemAwakeController(powerSaveBlocker); const store = createSessionStore(workspaceRoot); +const planStore = createPlanStore(workspaceRoot); const runStore = createAgentRunStore(workspaceRoot); const runtimePersistence = await openRuntimeEventPersistence({ workspaceRoot, @@ -566,6 +568,7 @@ const { builtinTools, toolAvailability, childAgentTools, + sandboxDiagnosticsProvider, } = assembleDesktopTools({ isComputerUseRealModelE2e, workspaceRoot, @@ -683,10 +686,12 @@ backends.register('ai-sdk', createAiSdkBackendFactory({ agentTeamLeadTools, builtinTools, toolAvailability, + sandboxDiagnosticsProvider, persistToolArtifacts, persistArchivedToolResult, readArchivedToolResult, runtimeCommitStore: runtimePersistence.runtimeCommitStore, + planStore, safeSendToRenderer, getRuntime: () => runtime, getLookupPricing: () => lookupPricing, @@ -709,6 +714,7 @@ if (isE2e) { const runtime = new SessionManager({ store, + planStore, runStore, runtimeEventStore, ...(runtimePersistence.runtimeCommitStore @@ -987,6 +993,8 @@ const streamEvents = createSessionStreamer({ computerUseTools, safeSendToRenderer, emitSessionsChanged, + interruptActivePlanExecution: (sessionId, reason) => + runtime.interruptActivePlanExecution(sessionId, reason), }); async function ensureSessionCanSend(sessionId: string): Promise { diff --git a/apps/desktop/src/main/session-stream.ts b/apps/desktop/src/main/session-stream.ts index c9a72eadbfb..4dfe3bad35e 100644 --- a/apps/desktop/src/main/session-stream.ts +++ b/apps/desktop/src/main/session-stream.ts @@ -1,6 +1,7 @@ import { randomUUID } from 'node:crypto'; -import { expertTeamIdFromLabels, resolveModelVisionSupport } from '@maka/core'; +import { activePlanExecution, expertTeamIdFromLabels, resolveModelVisionSupport } from '@maka/core'; import type { SessionChangedReason, SessionEvent } from '@maka/core'; +import type { PlanStore } from '@maka/core/plan'; import type { LlmConnection } from '@maka/core/llm-connections'; import type { LlmCallRecord, ToolInvocationRecord } from '@maka/core/usage-stats/types'; import { @@ -11,6 +12,9 @@ import { buildLlmHistorySummarizer, buildMcpTools, buildProviderOptions, + buildCancelPlanTool, + buildSubmitPlanTool, + buildUpdatePlanTool, createProviderRequestCaptureRecorder, getAIModel, loadHistoryCompactBlocksFromArtifacts, @@ -18,7 +22,11 @@ import { persistSynthesisCacheBlocksToArtifacts, recordLlmCall, recordToolInvocation, + renderPlanExecutionPrompt, + renderInterruptedPlanContext, + renderPlanModePrompt, resolveSelectedModelContextWindow, + selectCollaborationTools, } from '@maka/runtime'; import type { BackendFactory, @@ -92,10 +100,12 @@ export interface AiSdkBackendFactoryDeps { agentTeamLeadTools: AssembledTools['agentTeamLeadTools']; builtinTools: AssembledTools['builtinTools']; toolAvailability: AssembledTools['toolAvailability']; + sandboxDiagnosticsProvider: AssembledTools['sandboxDiagnosticsProvider']; persistToolArtifacts: ToolArtifactPersistence['persistToolArtifacts']; persistArchivedToolResult: ToolArtifactPersistence['persistArchivedToolResult']; readArchivedToolResult: ToolArtifactPersistence['readArchivedToolResult']; runtimeCommitStore: RuntimeCommitStore; + planStore: PlanStore; safeSendToRenderer: (channel: string, ...args: unknown[]) => void; getRuntime: () => SessionManager; getLookupPricing: () => PricingLookup; @@ -127,10 +137,12 @@ export function createAiSdkBackendFactory(deps: AiSdkBackendFactoryDeps): Backen agentTeamLeadTools, builtinTools, toolAvailability, + sandboxDiagnosticsProvider, persistToolArtifacts, persistArchivedToolResult, readArchivedToolResult, runtimeCommitStore, + planStore, safeSendToRenderer, getRuntime, getLookupPricing, @@ -144,6 +156,12 @@ export function createAiSdkBackendFactory(deps: AiSdkBackendFactoryDeps): Backen const modelFetch = buildSubscriptionModelFetch(connection, ctx.sessionId, model); const memoryPromptSnapshot = await systemPromptService.buildLocalMemoryPromptFragment(); const supportsVision = modelSupportsVision(connection, model); + const collaborationMode = ctx.header.collaborationMode ?? 'agent'; + const planState = await planStore.readState(ctx.sessionId); + const activeExecution = activePlanExecution(planState); + const interruptedExecution = [...planState.executions] + .reverse() + .find((execution) => execution.status === 'interrupted'); const candidateTools = isComputerUseRealModelE2e ? computerUseTools : ctx.tools @@ -163,8 +181,16 @@ export function createAiSdkBackendFactory(deps: AiSdkBackendFactoryDeps): Backen const agentTeam = ctx.agentTeam ?? (expertTeamId ? { role: 'lead' as const, teamId: expertTeamId, agentId: 'lead' } : undefined); + const planControlTools = collaborationMode === 'plan' + ? [buildSubmitPlanTool(planStore, interruptedExecution?.executionId)] + : activeExecution + ? [ + buildUpdatePlanTool(planStore, activeExecution.executionId), + buildCancelPlanTool(planStore, activeExecution.executionId), + ] + : []; const backendTools = computerUseToolsForModel( - candidateTools, + [...candidateTools, ...planControlTools], computerUseTools, supportsVision, ); @@ -172,28 +198,47 @@ export function createAiSdkBackendFactory(deps: AiSdkBackendFactoryDeps): Backen candidateToolAvailability, supportsVision, ); - const backendToolNames = new Set([ - ...backendTools.map((tool) => tool.name), - ...(expertDispatchTool ? [expertDispatchTool.name, ...agentTeamLeadTools.map((tool) => tool.name)] : []), - ]); + const selectedTools = selectCollaborationTools({ + mode: collaborationMode, + tools: expertDispatchTool + ? [...backendTools, expertDispatchTool, ...agentTeamLeadTools] + : backendTools, + hasActiveExecution: activeExecution !== undefined, + }); + const backendToolNames = new Set(selectedTools.map((tool) => tool.name)); const backendSkillHost = buildHostCapabilitiesFromBinding(backendToolNames); // Child backends share the parent sessionId but intentionally have a // narrower tool surface. They do not receive the Desktop Skill tool, so // they must not overwrite the parent session's resolver entry. if (!ctx.tools) desktopSessionSkillHosts.set(ctx.sessionId, backendSkillHost); + const effectivePermissionMode = collaborationMode === 'plan' ? 'explore' : ctx.header.permissionMode; + const sandboxDiagnosticsSnapshot = await sandboxDiagnosticsProvider.resolve({ + mode: effectivePermissionMode, + cwd: ctx.header.cwd, + }); return new AiSdkBackend({ sessionId: ctx.sessionId, - header: { ...ctx.header, model }, + header: { ...ctx.header, model, permissionMode: effectivePermissionMode }, appendMessage: ctx.appendMessage ?? ((message) => ctx.store.appendMessage(ctx.sessionId, message)), connection, apiKey: apiKey ?? '', modelId: model, permissionEngine, modelFactory: (input) => getAIModel({ ...input, fetch: modelFetch }), - tools: expertDispatchTool - ? [...backendTools, expertDispatchTool, ...agentTeamLeadTools] - : backendTools, + tools: selectedTools, + sandboxDiagnosticsSnapshot, + planTraceContext: { + mode: collaborationMode, + storeVersion: planState.storeVersion, + ...(activeExecution + ? { + planId: activeExecution.planId, + proposalId: activeExecution.proposalId, + executionId: activeExecution.executionId, + } + : {}), + }, agentTeam, toolAvailability: backendToolAvailability, spawnChildAgent: (input) => getRuntime().spawnChildAgent(ctx.sessionId, input), @@ -204,13 +249,34 @@ export function createAiSdkBackendFactory(deps: AiSdkBackendFactoryDeps): Backen name: 'desktop-default-history-budget', modelId: model, }), - systemPrompt: ({ cwd }) => systemPromptService.buildBackendSystemPrompt(ctx.header, cwd, { - memoryFragment: memoryPromptSnapshot, - childInstruction: ctx.systemPrompt, - skillBudget: { contextWindow: resolveSelectedModelContextWindow(connection, model) }, - host: backendSkillHost, - }), - turnTailPrompt: ({ cwd, sessionId }) => systemPromptService.buildTurnTailPrompt(cwd, sessionId), + systemPrompt: async ({ cwd }) => { + const base = await systemPromptService.buildBackendSystemPrompt( + ctx.header, + cwd, + { + memoryFragment: memoryPromptSnapshot, + childInstruction: ctx.systemPrompt, + skillBudget: { contextWindow: resolveSelectedModelContextWindow(connection, model) }, + host: backendSkillHost, + }, + ); + return collaborationMode === 'plan' ? `${base}\n\n${renderPlanModePrompt()}` : base; + }, + turnTailPrompt: async ({ cwd, sessionId }) => { + const base = await systemPromptService.buildTurnTailPrompt(cwd, sessionId); + const execution = activeExecution ?? ( + collaborationMode === 'plan' ? interruptedExecution : undefined + ); + if (!execution) return base; + const proposal = planState.proposals.find( + (candidate) => candidate.proposalId === execution.proposalId, + ); + if (!proposal) return base; + const planContext = activeExecution + ? renderPlanExecutionPrompt({ proposal, execution: activeExecution }) + : renderInterruptedPlanContext({ proposal, execution }); + return `${base}\n\n${planContext}`; + }, shellRunContextSummary: ctx.shellRunContextSummary, lookupPricing: getLookupPricing(), recordLlmCall: (event: LlmCallRecord) => recordLlmCall({ repo: telemetryRepo, lookupPricing: getLookupPricing() }, event), @@ -311,6 +377,7 @@ export interface SessionStreamerDeps { computerUseTools: AssembledTools['computerUseTools']; safeSendToRenderer: (channel: string, ...args: unknown[]) => void; emitSessionsChanged: (reason: SessionChangedReason, sessionId?: string) => void; + interruptActivePlanExecution?: (sessionId: string, reason: string) => Promise; } function isStatusChangingSessionEvent(event: SessionEvent): boolean { @@ -340,6 +407,7 @@ export function createSessionStreamer(deps: SessionStreamerDeps): StreamEvents { computerUseTools, safeSendToRenderer, emitSessionsChanged, + interruptActivePlanExecution, } = deps; return function streamEvents( @@ -393,8 +461,17 @@ export function createSessionStreamer(deps: SessionStreamerDeps): StreamEvents { computerUseOverlay.clearForSession(sessionId); computerUseTools.clearSession(sessionId); }, - onDrained: () => { + onDrained: async (outcome) => { emitSessionsChanged('message-appended', sessionId); + if ( + interruptActivePlanExecution && + (outcome.kind === 'aborted' || outcome.kind === 'errored') + ) { + await interruptActivePlanExecution( + sessionId, + outcome.kind === 'aborted' ? 'turn_aborted' : `turn_error:${outcome.reason}`, + ).catch(() => undefined); + } }, }); if (started.kind === 'unavailable') throw new Error(started.reason); diff --git a/apps/desktop/src/main/sessions-ipc-main.ts b/apps/desktop/src/main/sessions-ipc-main.ts index 44df837a966..27b630fe3ff 100644 --- a/apps/desktop/src/main/sessions-ipc-main.ts +++ b/apps/desktop/src/main/sessions-ipc-main.ts @@ -4,6 +4,7 @@ import { stat } from 'node:fs/promises'; import { ipcMain } from 'electron'; import { DEFAULT_SESSION_NAME, + isCollaborationMode, isPermissionMode, isThinkingLevel, sanitizeTaskLedgerTask, @@ -181,6 +182,10 @@ export function registerSessionsIpc(deps: SessionsIpcDeps): void { ipcMain.handle('sessions:list', (_event, filter?: SessionListFilter) => runtime.listSessions(filter)); ipcMain.handle('sessions:create', async (_event, input?: Partial) => { const cwd = input?.cwd ?? (await currentProjectRoot()); + const collaborationMode = input?.collaborationMode ?? 'agent'; + if (!isCollaborationMode(collaborationMode)) { + throw new TypeError('Invalid collaboration mode.'); + } if (input?.backend === 'fake') { if (!canCreateFakeSession()) { throw new Error('FakeBackend sessions are only available in development.'); @@ -191,6 +196,7 @@ export function registerSessionsIpc(deps: SessionsIpcDeps): void { llmConnectionSlug: input.llmConnectionSlug ?? 'fake', model: input.model ?? 'fake-model', permissionMode: input.permissionMode ?? (await resolveDefaultPermissionMode(() => settingsStore.get())), + collaborationMode, name: input.name ?? DEFAULT_SESSION_NAME, labels: input.labels, }); @@ -209,6 +215,7 @@ export function registerSessionsIpc(deps: SessionsIpcDeps): void { model, ...(thinkingLevel !== undefined ? { thinkingLevel } : {}), permissionMode: input?.permissionMode ?? (await resolveDefaultPermissionMode(() => settingsStore.get())), + collaborationMode, name: input?.name ?? DEFAULT_SESSION_NAME, labels: input?.labels, }); @@ -270,6 +277,7 @@ export function registerSessionsIpc(deps: SessionsIpcDeps): void { computerUseOverlay.clearForSession(sessionId); computerUseTools.clearSession(sessionId); await runtime.stopSession(sessionId, normalizeStopSessionInput(input)); + await runtime.interruptActivePlanExecution(sessionId, 'user_stopped_execution').catch(() => null); emitSessionsChanged('status-change', sessionId); emitSessionsChanged('turn-status-change', sessionId); emitSessionsChanged('message-appended', sessionId); @@ -419,6 +427,84 @@ export function registerSessionsIpc(deps: SessionsIpcDeps): void { return session; }); }); + ipcMain.handle('sessions:setCollaborationMode', (_event, sessionId: string, mode: unknown) => { + if (!isCollaborationMode(mode)) { + throw new Error(`Invalid collaboration mode: ${String(mode)}`); + } + return runtime.setCollaborationMode(sessionId, mode).then((session) => { + emitSessionsChanged('mode-change', sessionId); + return session; + }); + }); + ipcMain.handle('plan-mode:getState', (_event, sessionId: string) => + runtime.getPlanState(sessionId)); + ipcMain.handle('plan-mode:requestRevision', async (_event, sessionId: string, proposalId: unknown) => { + if (typeof proposalId !== 'string' || !proposalId) throw new Error('Invalid proposal id'); + const result = await runtime.requestPlanRevision(sessionId, proposalId); + emitSessionsChanged('mode-change', sessionId); + return result.state; + }); + ipcMain.handle('plan-mode:abandon', async ( + _event, + sessionId: string, + proposalId: unknown, + ) => { + if (typeof proposalId !== 'string' || !proposalId) throw new Error('Invalid proposal id'); + const result = await runtime.abandonPlanProposal(sessionId, proposalId); + emitSessionsChanged('mode-change', sessionId); + return result.state; + }); + ipcMain.handle('plan-mode:approve', async (_event, sessionId: string, input: unknown) => { + if (!input || typeof input !== 'object') throw new Error('Invalid plan approval'); + const proposalId = (input as { proposalId?: unknown }).proposalId; + const expectedRevision = (input as { expectedRevision?: unknown }).expectedRevision; + const expectedStoreVersion = (input as { expectedStoreVersion?: unknown }).expectedStoreVersion; + if (typeof proposalId !== 'string' || !proposalId || + typeof expectedRevision !== 'number' || !Number.isSafeInteger(expectedRevision) || + (expectedStoreVersion !== undefined && + (typeof expectedStoreVersion !== 'number' || !Number.isSafeInteger(expectedStoreVersion)))) { + throw new Error('Invalid plan approval'); + } + await ensureSessionWorkspaceAvailable(sessionId); + const result = await runtime.approvePlan({ + sessionId, + proposalId, + expectedRevision, + ...(expectedStoreVersion !== undefined ? { expectedStoreVersion } : {}), + }); + if (result.event.type !== 'plan_approved') throw new Error('Plan approval did not create an execution'); + const turnId = randomUUID(); + const iterator = runtime.sendMessage(sessionId, { + turnId, + text: `Execute the approved plan ${result.event.execution.planId}.`, + }); + void streamEvents(sessionId, iterator, { turnId, goalBoundary: 'external' }); + emitSessionsChanged('mode-change', sessionId); + return { state: result.state, turnId, executionId: result.event.execution.executionId }; + }); + ipcMain.handle('plan-mode:resume', async (_event, sessionId: string, executionId: unknown) => { + if (typeof executionId !== 'string' || !executionId) throw new Error('Invalid execution id'); + await ensureSessionWorkspaceAvailable(sessionId); + const result = await runtime.resumePlanExecution(sessionId, executionId); + const turnId = randomUUID(); + const iterator = runtime.sendMessage(sessionId, { + turnId, + text: `Resume the approved plan execution ${executionId}.`, + }); + void streamEvents(sessionId, iterator, { turnId, goalBoundary: 'external' }); + emitSessionsChanged('mode-change', sessionId); + return { state: result.state, turnId, executionId }; + }); + ipcMain.handle('plan-mode:abandonExecution', async ( + _event, + sessionId: string, + executionId: unknown, + ) => { + if (typeof executionId !== 'string' || !executionId) throw new Error('Invalid execution id'); + const result = await runtime.cancelPlanExecution(sessionId, executionId); + emitSessionsChanged('mode-change', sessionId); + return result.state; + }); ipcMain.handle('sessions:setModel', async (_event, sessionId: string, input: unknown) => { const { llmConnectionSlug, model } = normalizeSessionModelSelection(input); const header = await store.readHeader(sessionId); diff --git a/apps/desktop/src/main/tool-assembly.ts b/apps/desktop/src/main/tool-assembly.ts index 2b910fd93e1..b6f5efb15b8 100644 --- a/apps/desktop/src/main/tool-assembly.ts +++ b/apps/desktop/src/main/tool-assembly.ts @@ -10,6 +10,7 @@ import { buildParentAgentTools, assertProductBindingCatalogClean, createBuiltinSandboxManager, + createSandboxDiagnosticsProvider, createFilesystemWorkerLaunchSpecProvider, FilesystemWorkerClient, resolveSkillDiscoveryPaths, @@ -92,18 +93,28 @@ export function assembleDesktopTools(deps: DesktopToolAssemblyDeps) { } = deps; const sandboxManager = createBuiltinSandboxManager(); - const filesystemWorker = process.platform === 'darwin' && sandboxManager - ? new FilesystemWorkerClient({ - sandboxManager, - getLaunchSpec: createFilesystemWorkerLaunchSpecProvider({ + const filesystemWorkerLaunchSpecProvider = + process.platform === 'darwin' + ? createFilesystemWorkerLaunchSpecProvider({ runtime: 'electron', executable: process.execPath, resourceLocation: app.isPackaged ? { kind: 'desktop-packaged', resourcesPath: process.resourcesPath } : { kind: 'runtime' }, - }), + }) + : undefined; + const filesystemWorker = sandboxManager && filesystemWorkerLaunchSpecProvider + ? new FilesystemWorkerClient({ + sandboxManager, + getLaunchSpec: filesystemWorkerLaunchSpecProvider, }) : undefined; + const sandboxDiagnosticsProvider = createSandboxDiagnosticsProvider({ + ...(sandboxManager ? { sandboxManager } : {}), + ...(filesystemWorkerLaunchSpecProvider + ? { getFilesystemWorkerLaunchSpec: filesystemWorkerLaunchSpecProvider } + : {}), + }); // Unified tool availability (issue #37). Deferred capability groups (Rive, // Office, browser, agent orchestration) are withheld from the // per-turn prompt and loaded on demand via `load_tools`, keeping their schemas @@ -273,5 +284,6 @@ export function assembleDesktopTools(deps: DesktopToolAssemblyDeps) { builtinTools, toolAvailability, childAgentTools, + sandboxDiagnosticsProvider, }; } diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index 705c4ed1754..fd6a3248a7c 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -13,6 +13,8 @@ import type { PermissionResponse, UserQuestionResponse, PermissionMode, + CollaborationMode, + PlanSessionState, SearchErrorReason, SearchRequest, SearchResult, @@ -206,6 +208,24 @@ export interface MakaBridge { setFlagged(sessionId: string, isFlagged: boolean): Promise; rename(sessionId: string, name: string): Promise; setPermissionMode(sessionId: string, mode: PermissionMode): Promise; + setCollaborationMode(sessionId: string, mode: CollaborationMode): Promise; + getPlanState(sessionId: string): Promise; + requestPlanRevision(sessionId: string, proposalId: string): Promise; + abandonPlanProposal( + sessionId: string, + proposalId: string, + ): Promise; + approvePlan(sessionId: string, input: { + proposalId: string; + expectedRevision: number; + expectedStoreVersion?: number; + }): Promise<{ state: PlanSessionState; turnId: string; executionId: string }>; + resumePlan(sessionId: string, executionId: string): Promise<{ + state: PlanSessionState; + turnId: string; + executionId: string; + }>; + abandonPlanExecution(sessionId: string, executionId: string): Promise; setModel(sessionId: string, input: { llmConnectionSlug: string; model: string }): Promise; setThinkingLevel(sessionId: string, level: ThinkingLevel | undefined | null): Promise; remove(sessionId: string): Promise; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 5950675c76b..3c8ad83562c 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -25,6 +25,8 @@ import type { PermissionResponse, UserQuestionResponse, PermissionMode, + CollaborationMode, + PlanSessionState, SearchErrorReason, SearchRequest, SearchResult, @@ -206,6 +208,38 @@ const makaBridge = { setPermissionMode(sessionId: string, mode: PermissionMode): Promise { return ipcRenderer.invoke('sessions:setPermissionMode', sessionId, mode); }, + setCollaborationMode(sessionId: string, mode: CollaborationMode): Promise { + return ipcRenderer.invoke('sessions:setCollaborationMode', sessionId, mode); + }, + getPlanState(sessionId: string): Promise { + return ipcRenderer.invoke('plan-mode:getState', sessionId); + }, + requestPlanRevision(sessionId: string, proposalId: string): Promise { + return ipcRenderer.invoke('plan-mode:requestRevision', sessionId, proposalId); + }, + abandonPlanProposal( + sessionId: string, + proposalId: string, + ): Promise { + return ipcRenderer.invoke('plan-mode:abandon', sessionId, proposalId); + }, + approvePlan(sessionId: string, input: { + proposalId: string; + expectedRevision: number; + expectedStoreVersion?: number; + }): Promise<{ state: PlanSessionState; turnId: string; executionId: string }> { + return ipcRenderer.invoke('plan-mode:approve', sessionId, input); + }, + resumePlan(sessionId: string, executionId: string): Promise<{ + state: PlanSessionState; + turnId: string; + executionId: string; + }> { + return ipcRenderer.invoke('plan-mode:resume', sessionId, executionId); + }, + abandonPlanExecution(sessionId: string, executionId: string): Promise { + return ipcRenderer.invoke('plan-mode:abandonExecution', sessionId, executionId); + }, setModel(sessionId: string, input: { llmConnectionSlug: string; model: string }): Promise { return ipcRenderer.invoke('sessions:setModel', sessionId, input); }, diff --git a/apps/desktop/src/renderer/app-shell-chat-actions.ts b/apps/desktop/src/renderer/app-shell-chat-actions.ts index 2ccc311105f..2b14120085f 100644 --- a/apps/desktop/src/renderer/app-shell-chat-actions.ts +++ b/apps/desktop/src/renderer/app-shell-chat-actions.ts @@ -1,4 +1,5 @@ import type { + CollaborationMode, PermissionResponse, SessionSummary, StoredMessage, @@ -160,6 +161,7 @@ export function createAppShellChatActions(deps: { upsertSessionSummary: (session: SessionSummary) => void; validPendingNewChatModel: PendingNewChatModel; pendingNewChatThinkingLevel: PendingNewChatThinkingLevel; + newChatCollaborationMode: CollaborationMode; }): AppShellChatActions { const { uiLocale, @@ -184,6 +186,7 @@ export function createAppShellChatActions(deps: { upsertSessionSummary, validPendingNewChatModel, pendingNewChatThinkingLevel, + newChatCollaborationMode, } = deps; const copy = getShellCopy(uiLocale).chatActions; @@ -273,6 +276,7 @@ export function createAppShellChatActions(deps: { } : {}), ...(pendingNewChatThinkingLevel ? { thinkingLevel: pendingNewChatThinkingLevel } : {}), + collaborationMode: newChatCollaborationMode, }); upsertSessionSummary(session); optimisticSessionId = session.id; diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 38e8dbedb25..cdac0060671 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -33,6 +33,11 @@ import { useCommandPalette } from './command-palette'; import { ChatMessageSurface } from './chat-message-surface'; import { ChatComposerRegion } from './chat-composer-region'; import { ChatWorkbar } from './chat-workbar'; +import { + PlanExecutionPanel, + PlanProposalCard, + usePlanModeState, +} from './plan-mode-panel'; import { McpPage } from './mcp-page'; import { useOnboardingSnapshot } from './use-onboarding-snapshot'; import type { OnboardingSnapshot } from '../preload/bridge-contract.js'; @@ -203,6 +208,8 @@ function AppShellContent({ removeAttachment, clearSubmittedAttachments, } = useAppShellComposerAttachments({ draftKey: attachmentDraftKey, toastApi }); + const [newChatPlanModeActive, setNewChatPlanModeActive] = useState(false); + const [pendingCollaborationModeBySession, setPendingCollaborationModeBySession] = useState>({}); // P3: session ids with a live embedded-browser view. The right-side // BrowserPanel mounts only for these, so ordinary chats reserve no space. const [liveBrowserSessionIds, setLiveBrowserSessionIds] = useState([]); @@ -409,6 +416,7 @@ function AppShellContent({ const pendingTurnActions = turnActionRegistry.keys; const sessionRowActionRegistry = useKeyedPendingRegistry(); const permissionModeChangeRegistry = useKeyedPendingRegistry(); + const collaborationModeChangeRegistry = useKeyedPendingRegistry(); const sessionModelChangeRegistry = useKeyedPendingRegistry(); const pendingKeyOf = (sessionId: string, turnId: string, actionId: TurnFooterActionMeta['id']) => `${sessionId}:${turnId}:${actionId}`; @@ -444,6 +452,8 @@ function AppShellContent({ clearOwnedSessionState(sessionId); turnActionRegistry.clearForSession(sessionId); permissionModeChangeRegistry.keysRef.current.delete(sessionId); + collaborationModeChangeRegistry.keysRef.current.delete(sessionId); + setPendingCollaborationModeBySession((current) => omitSessionKey(current, sessionId)); sessionModelChangeRegistry.keysRef.current.delete(sessionId); } @@ -488,6 +498,67 @@ function AppShellContent({ toastApi, }); + async function setPlanMode(active: boolean): Promise { + const sessionId = activeIdRef.current; + if (!sessionId) { + setNewChatPlanModeActive(active); + return; + } + if (!addPendingSessionAction( + sessionId, + collaborationModeChangeRegistry.keysRef, + setPendingCollaborationModeBySession, + )) return; + + try { + const planState = await window.maka.sessions.getPlanState(sessionId); + if (active && planState.activeExecutionId) { + toastApi.error( + shellCopy.planModeExecutionActiveTitle, + shellCopy.planModeExecutionActiveDescription, + ); + return; + } + const latestProposal = planState.proposals.find( + (proposal) => proposal.proposalId === planState.latestProposalId, + ); + if (!active && latestProposal?.status === 'pending_approval') { + const confirmed = await toastApi.confirm({ + title: shellCopy.planModeExitPendingTitle, + description: shellCopy.planModeExitPendingDescription(latestProposal.title), + confirmLabel: shellCopy.planModeExitConfirm, + cancelLabel: shellCopy.planModeExitCancel, + destructive: true, + }); + if (!confirmed) return; + await window.maka.sessions.abandonPlanProposal(sessionId, latestProposal.proposalId); + setSessions((current) => current.map((session) => ( + session.id === sessionId ? { ...session, collaborationMode: 'agent' } : session + ))); + } else { + const next = await window.maka.sessions.setCollaborationMode( + sessionId, + active ? 'plan' : 'agent', + ); + setSessions((current) => current.map((session) => session.id === next.id ? next : session)); + } + await refreshSessions(); + } catch (error) { + if (activeIdRef.current === sessionId) { + toastApi.error( + shellCopy.planModeFailedTitle, + localizedShellErrorMessage(error, shellCopy.planModeFallback, uiLocale), + ); + } + } finally { + clearPendingSessionAction( + sessionId, + collaborationModeChangeRegistry.keysRef, + setPendingCollaborationModeBySession, + ); + } + } + const { turnFooterActionsByTurn, turnFailedReasonLabels, @@ -636,6 +707,12 @@ function AppShellContent({ permissionMode: defaultPermissionMode, } : undefined); + const planMode = usePlanModeState(activeSessionForView); + const planConversationItems = (planMode.state?.proposals ?? []).map((proposal) => ({ + id: proposal.proposalId, + afterTurnId: proposal.turnId, + content: , + })); const activeMessageLoading = Boolean(activeId && messageLoadPending); // PR110c: OnboardingState is now the single source of truth for // first-run UI. The renderer never re-derives provider readiness; @@ -883,6 +960,7 @@ function AppShellContent({ upsertSessionSummary, validPendingNewChatModel, pendingNewChatThinkingLevel: newChatThinkingLevel ?? null, + newChatCollaborationMode: newChatPlanModeActive ? 'plan' : 'agent', }); const { handleTurnFooterAction } = useStableActions(createAppShellTurnActions, { @@ -1108,6 +1186,7 @@ function AppShellContent({ async function createSession() { startNewSession(); + setNewChatPlanModeActive(false); setNavSelection({ section: 'sessions', filter: 'chats' }); setSearchScrollTarget(null); // New-task affordances reset to the empty-state composer; move focus @@ -1489,8 +1568,12 @@ function AppShellContent({ setNavSelection({ section: target }); }} onStartPlanReminder={openPlanReminderForm} + conversationItems={planConversationItems} /> )} + {navSelection.section === 'sessions' && ( + + )} setPermissionMode(mode)} + planModeActive={activeId + ? (activeSessionForView?.collaborationMode ?? 'agent') === 'plan' + : newChatPlanModeActive} + planModePending={activeId ? pendingCollaborationModeBySession[activeId] === true : false} + planModeDisabledReason={ + activeId && pendingCollaborationModeBySession[activeId] === true + ? shellCopy.planModeChanging + : activeStreamingLive + ? shellCopy.planModeStreaming + : activeId && activeSessionForView?.status === 'running' + ? shellCopy.planModeRunning + : activeId && activeSessionForView?.status === 'waiting_for_user' + ? shellCopy.planModeWaiting + : undefined + } + onPlanModeChange={setPlanMode} /> {navSelection.section === 'sessions' && activeId && !workbarCollapsed && ( diff --git a/apps/desktop/src/renderer/locales/shell-copy.ts b/apps/desktop/src/renderer/locales/shell-copy.ts index 5286f056971..85abf154e9f 100644 --- a/apps/desktop/src/renderer/locales/shell-copy.ts +++ b/apps/desktop/src/renderer/locales/shell-copy.ts @@ -397,6 +397,18 @@ type ShellCopy = { permissionModeStreaming: string; permissionModeRunning: string; permissionModeWaiting: string; + planModeChanging: string; + planModeStreaming: string; + planModeRunning: string; + planModeWaiting: string; + planModeFailedTitle: string; + planModeFallback: string; + planModeExitPendingTitle: string; + planModeExitPendingDescription(title: string): string; + planModeExitConfirm: string; + planModeExitCancel: string; + planModeExecutionActiveTitle: string; + planModeExecutionActiveDescription: string; resizeWorkbar: string; }; }; @@ -1033,6 +1045,19 @@ const SHELL_COPY_BY_LOCALE = { permissionModeStreaming: '当前对话正在流式输出,等结束后再切换权限模式。', permissionModeRunning: '当前对话正在运行,等结束后再切换权限模式。', permissionModeWaiting: '当前有工具调用正在等待确认,处理后再切换权限模式。', + planModeChanging: 'Plan Mode 正在切换,完成后再继续操作。', + planModeStreaming: '当前对话正在流式输出,等结束后再切换 Plan Mode。', + planModeRunning: '当前对话正在运行,等结束后再切换 Plan Mode。', + planModeWaiting: '当前有工具调用正在等待确认,处理后再切换 Plan Mode。', + planModeFailedTitle: '切换 Plan Mode 失败', + planModeFallback: 'Plan Mode 暂时无法切换,请稍后重试。', + planModeExitPendingTitle: '放弃当前方案?', + planModeExitPendingDescription: (title: string) => + `「${title}」尚未审批。退出 Plan Mode 后,该方案会标记为已放弃,但历史记录仍会保留。`, + planModeExitConfirm: '放弃并退出', + planModeExitCancel: '继续规划', + planModeExecutionActiveTitle: '计划仍在执行', + planModeExecutionActiveDescription: '请先中断当前执行,再进入 Plan Mode 调整方案。', resizeWorkbar: '调整会话工作栏宽度', }, }, @@ -1497,6 +1522,19 @@ const SHELL_COPY_BY_LOCALE = { 'This conversation is streaming. Wait for it to finish before changing the permission mode.', permissionModeRunning: 'This conversation is running. Wait for it to finish before changing the permission mode.', permissionModeWaiting: 'A tool call is waiting for confirmation. Respond before changing the permission mode.', + planModeChanging: 'Plan Mode is changing. Wait for it to finish before continuing.', + planModeStreaming: 'This conversation is streaming. Wait for it to finish before changing Plan Mode.', + planModeRunning: 'This conversation is running. Wait for it to finish before changing Plan Mode.', + planModeWaiting: 'A tool call is waiting for confirmation. Respond before changing Plan Mode.', + planModeFailedTitle: 'Could not change Plan Mode', + planModeFallback: 'Plan Mode could not be changed. Try again later.', + planModeExitPendingTitle: 'Abandon the current plan?', + planModeExitPendingDescription: (title: string) => + `“${title}” has not been approved. Leaving Plan Mode will mark it as abandoned while preserving its history.`, + planModeExitConfirm: 'Abandon and leave', + planModeExitCancel: 'Keep planning', + planModeExecutionActiveTitle: 'The plan is still running', + planModeExecutionActiveDescription: 'Interrupt the active execution before entering Plan Mode to revise it.', resizeWorkbar: 'Resize conversation workbar', }, }, diff --git a/apps/desktop/src/renderer/plan-mode-panel.tsx b/apps/desktop/src/renderer/plan-mode-panel.tsx new file mode 100644 index 00000000000..bba2a734b05 --- /dev/null +++ b/apps/desktop/src/renderer/plan-mode-panel.tsx @@ -0,0 +1,308 @@ +import { useCallback, useEffect, useId, useState, type JSX } from 'react'; +import type { + PlanExecutionStep, + PlanProposal, + PlanSessionState, + SessionEvent, + SessionSummary, +} from '@maka/core'; +import { Button as UiButton, useToast } from '@maka/ui'; +import { ChevronDown } from '@maka/ui/icons'; + +export interface PlanModeState { + state: PlanSessionState | undefined; + pending: boolean; + error: string | undefined; + requestRevision(proposalId: string): Promise; + approve(proposal: PlanProposal): Promise; + resume(executionId: string): Promise; + abandon(executionId: string, title: string): Promise; +} + +export function usePlanModeState(session: SessionSummary | undefined): PlanModeState { + const toastApi = useToast(); + const [state, setState] = useState(); + const [pending, setPending] = useState(false); + const [error, setError] = useState(); + + const refresh = useCallback(async () => { + if (!session) return; + setState(await window.maka.sessions.getPlanState(session.id)); + }, [session?.id]); + + useEffect(() => { + setState(undefined); + setError(undefined); + if (!session) return; + const refreshOrReport = () => void refresh().catch((cause) => setError(message(cause))); + refreshOrReport(); + return window.maka.sessions.subscribeEvents(session.id, (event: SessionEvent) => { + if ( + event.type === 'plan_submitted' + || event.type === 'complete' + || event.type === 'abort' + || isPlanToolResult(event) + ) { + refreshOrReport(); + } + }); + }, [session?.id, session?.collaborationMode, refresh]); + + const run = useCallback(async (action: () => Promise): Promise => { + setPending(true); + setError(undefined); + try { + await action(); + await refresh(); + } catch (cause) { + setError(message(cause)); + } finally { + setPending(false); + } + }, [refresh]); + + const requestRevision = useCallback(async (proposalId: string): Promise => { + if (!session) return; + await run(async () => { + await window.maka.sessions.requestPlanRevision(session.id, proposalId); + }); + }, [run, session?.id]); + + const approve = useCallback(async (proposal: PlanProposal): Promise => { + if (!session) return; + await run(async () => { + await window.maka.sessions.approvePlan(session.id, { + proposalId: proposal.proposalId, + expectedRevision: proposal.revision, + expectedStoreVersion: state?.storeVersion, + }); + }); + }, [run, session?.id, state?.storeVersion]); + + const resume = useCallback(async (executionId: string): Promise => { + if (!session) return; + await run(async () => { + await window.maka.sessions.resumePlan(session.id, executionId); + }); + }, [run, session?.id]); + + const abandon = useCallback(async (executionId: string, title: string): Promise => { + if (!session) return; + const confirmed = await toastApi.confirm({ + title: '放弃这个计划?', + description: `“${title}”的执行记录会保留,但之后不能继续恢复。`, + confirmLabel: '放弃计划', + cancelLabel: '取消', + destructive: true, + }); + if (!confirmed) return; + await run(async () => { + await window.maka.sessions.abandonPlanExecution(session.id, executionId); + }); + }, [run, session?.id, toastApi]); + + return { state, pending, error, requestRevision, approve, resume, abandon }; +} + +export function PlanProposalCard(props: { + proposal: PlanProposal; + planMode: PlanModeState; +}): JSX.Element { + const { proposal, planMode } = props; + const reviewable = + proposal.status === 'pending_approval' + && planMode.state?.latestProposalId === proposal.proposalId; + + return ( +

+
+
+
+ 计划方案 + {proposal.title} +
+
+ Revision {proposal.revision} + + {proposalStatusLabel(proposal.status)} + +
+
+ {proposal.overview &&

{proposal.overview}

} +
+

执行步骤

+
    + {proposal.steps.map((step, index) => ( +
  1. + +
    + {step.title} +

    {step.description}

    +
    +
  2. + ))} +
+
+ {proposal.risks && proposal.risks.length > 0 && ( +
+

风险

+
    + {proposal.risks.map((risk, index) =>
  • {risk}
  • )} +
+
+ )} + {reviewable && ( +
+ void planMode.requestRevision(proposal.proposalId)} + > + 继续修改 + + void planMode.approve(proposal)} + > + 执行计划 + +
+ )} + {planMode.error && reviewable && ( +

{planMode.error}

+ )} +
+
+ ); +} + +export function PlanExecutionPanel(props: { + planMode: PlanModeState; +}): JSX.Element | null { + const { planMode } = props; + const [expanded, setExpanded] = useState(false); + const detailsId = useId(); + const active = planMode.state?.executions.find( + (item) => item.executionId === planMode.state?.activeExecutionId, + ); + const interrupted = [...(planMode.state?.executions ?? [])].reverse().find( + (item) => item.status === 'interrupted', + ); + const execution = active ?? interrupted; + useEffect(() => { + setExpanded(false); + }, [execution?.executionId]); + if (!execution) return null; + + const proposal = planMode.state?.proposals.find( + (item) => item.proposalId === execution.proposalId, + ); + const completedCount = execution.steps.filter( + (step) => step.status === 'completed' || step.status === 'skipped', + ).length; + + return ( +
+ + {expanded && ( +
+
    + {execution.steps.map((step) => ( +
  1. + + {executionStepMark(step.status)} + + {step.title} +
  2. + ))} +
+ {execution.status === 'interrupted' && ( +
+ void planMode.resume(execution.executionId)} + > + 恢复执行 + + void planMode.abandon( + execution.executionId, + proposal?.title ?? '已批准计划', + )} + > + 放弃计划 + +
+ )} +
+ )} + {planMode.error &&

{planMode.error}

} +
+ ); +} + +function isPlanToolResult(event: SessionEvent): boolean { + if (event.type !== 'tool_result' || event.content.kind !== 'json') return false; + const value = event.content.value; + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + const kind = (value as { kind?: unknown }).kind; + return kind === 'plan_progress_updated' + || kind === 'plan_execution_completed' + || kind === 'plan_execution_cancelled'; +} + +function proposalStatusLabel(status: PlanProposal['status']): string { + if (status === 'approved') return '已批准'; + if (status === 'stale') return '已过期'; + return '等待确认'; +} + +function executionStepStatusLabel(status: PlanExecutionStep['status']): string { + if (status === 'completed') return '已完成'; + if (status === 'in_progress') return '正在执行'; + if (status === 'skipped') return '已跳过'; + return '未开始'; +} + +function executionStepMark(status: PlanExecutionStep['status']): string { + if (status === 'completed') return '✓'; + if (status === 'in_progress') return '•'; + if (status === 'skipped') return '–'; + return ''; +} + +function message(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/apps/desktop/src/renderer/styles.css b/apps/desktop/src/renderer/styles.css index db8c0320532..4032fc7d5da 100644 --- a/apps/desktop/src/renderer/styles.css +++ b/apps/desktop/src/renderer/styles.css @@ -23,6 +23,7 @@ @import "./styles/model-switcher.css"; @import "./styles/chat-header.css"; @import "./styles/task-ledger.css"; +@import "./styles/plan-mode.css"; @import "./styles/error.css"; @import "./styles/toast.css"; @import "./styles/help.css"; diff --git a/apps/desktop/src/renderer/styles/composer.css b/apps/desktop/src/renderer/styles/composer.css index ee837676eee..b825508cb57 100644 --- a/apps/desktop/src/renderer/styles/composer.css +++ b/apps/desktop/src/renderer/styles/composer.css @@ -44,6 +44,22 @@ flex: 0 0 auto; } +.maka-composer-plan-mode-control { + height: 26px; + display: inline-flex; + align-items: center; + gap: var(--space-1-5); + padding: 0 var(--space-0-5) 0 var(--space-1); + color: var(--foreground-secondary); + font-size: var(--font-size-ui); + font-weight: var(--font-weight-medium); + white-space: nowrap; +} + +.maka-composer-plan-mode-label { + line-height: var(--leading-none); +} + .maka-composer-right-controls { flex: 0 0 auto; justify-content: flex-end; diff --git a/apps/desktop/src/renderer/styles/plan-mode.css b/apps/desktop/src/renderer/styles/plan-mode.css new file mode 100644 index 00000000000..49fe18507aa --- /dev/null +++ b/apps/desktop/src/renderer/styles/plan-mode.css @@ -0,0 +1,355 @@ +.plan-mode-panel { + width: min(var(--maka-chat-measure), calc(100% - var(--space-8))); + margin: var(--space-3) auto var(--space-2); + color: var(--foreground); +} + +.plan-proposal-card { + display: grid; + gap: var(--space-3); + padding: var(--space-4); + border: var(--border-width-hairline) solid var(--border); + border-radius: var(--radius-surface); + background: var(--background-elevated); + box-shadow: + 0 1px 2px oklch(from var(--foreground) l c h / 0.04), + 0 8px 24px oklch(from var(--foreground) l c h / 0.05); +} + +.plan-proposal-heading, +.plan-proposal-actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); +} + +.plan-proposal-meta { + flex: 0 0 auto; + display: flex; + align-items: center; + gap: var(--space-1); +} + +.plan-proposal-title { + min-width: 0; + display: grid; + gap: var(--space-0-5); +} + +.plan-proposal-title strong { + overflow-wrap: anywhere; + font-size: var(--font-size-base); + font-weight: var(--font-weight-semibold); + line-height: var(--leading-snug); +} + +.plan-proposal-kicker, +.plan-proposal-revision { + color: var(--muted-foreground); + font-size: var(--font-size-caption); + line-height: var(--leading-snug); +} + +.plan-proposal-kicker { + font-weight: var(--font-weight-medium); +} + +.plan-proposal-revision { + padding: var(--space-0-5) var(--space-1-5); + border-radius: var(--radius-pill); + background: var(--foreground-5); + font-family: var(--font-mono); + font-variant-numeric: tabular-nums; +} + +.plan-proposal-status { + padding: var(--space-0-5) var(--space-1-5); + border-radius: var(--radius-pill); + font-size: var(--font-size-caption); + font-weight: var(--font-weight-medium); + line-height: var(--leading-snug); +} + +.plan-proposal-status[data-status="pending_approval"] { + background: oklch(from var(--status-running) l c h / 0.10); + color: var(--foreground); +} + +.plan-proposal-status[data-status="approved"] { + background: oklch(from var(--success) l c h / 0.12); + color: var(--success-text); +} + +.plan-proposal-status[data-status="stale"] { + background: var(--foreground-5); + color: var(--muted-foreground); +} + +.plan-proposal-overview { + margin: 0; + color: var(--foreground-secondary); + font-size: var(--font-size-ui); + line-height: var(--leading-normal); +} + +.plan-proposal-section { + display: grid; + gap: var(--space-1-5); +} + +.plan-proposal-section h3 { + margin: 0; + color: var(--foreground-secondary); + font-size: var(--font-size-caption); + font-weight: var(--font-weight-medium); + line-height: var(--leading-snug); +} + +.plan-proposal-steps, +.plan-proposal-risks ul { + display: grid; + gap: var(--space-1); + margin: 0; + padding: 0; + list-style: none; +} + +.plan-proposal-steps li { + display: grid; + grid-template-columns: var(--h-control-sm) minmax(0, 1fr); + align-items: start; + gap: var(--space-2); + padding: var(--space-2); + border: var(--border-width-hairline) solid var(--border); + border-radius: var(--radius-control); + background: var(--foreground-2); + color: var(--foreground); + font-size: var(--font-size-ui); + line-height: var(--leading-normal); +} + +.plan-proposal-step-number { + width: var(--h-control-sm); + height: var(--h-control-sm); + display: inline-flex; + align-items: center; + justify-content: center; + border: var(--border-width-hairline) solid oklch(from var(--status-running) l c h / 0.28); + border-radius: var(--radius-pill); + background: oklch(from var(--status-running) l c h / 0.10); + color: var(--foreground); + font-family: var(--font-mono); + font-size: var(--font-size-caption); + font-variant-numeric: tabular-nums; + line-height: var(--leading-none); +} + +.plan-proposal-step-content { + min-width: 0; + display: grid; + gap: var(--space-1); +} + +.plan-proposal-step-content strong { + overflow-wrap: anywhere; + font-weight: var(--font-weight-semibold); + line-height: var(--leading-snug); +} + +.plan-proposal-step-content p { + margin: 0; + overflow-wrap: anywhere; + color: var(--foreground-secondary); +} + +.plan-proposal-risks ul { + color: var(--warning-text); + font-size: var(--font-size-caption); + line-height: var(--leading-normal); +} + +.plan-proposal-risks li { + position: relative; + padding: var(--space-1-5) var(--space-2) var(--space-1-5) var(--space-5); + border-radius: var(--radius-control); + background: oklch(from var(--warning) l c h / 0.08); +} + +.plan-proposal-risks li::before { + position: absolute; + top: var(--space-1-5); + left: var(--space-2); + content: "•"; +} + +.plan-proposal-actions { + justify-content: flex-end; + padding-top: var(--space-1); + border-top: var(--border-width-hairline) solid var(--border); +} + +.plan-execution-panel { + width: min(var(--maka-chat-measure), calc(100% - var(--space-8))); + display: grid; + gap: var(--space-2); + margin: 0 auto var(--space-2); + padding: var(--space-2-5) var(--space-3); + border: var(--border-width-hairline) solid var(--border); + border-radius: var(--radius-surface); + background: var(--background-elevated); + box-shadow: 0 1px 2px oklch(from var(--foreground) l c h / 0.04); +} + +.plan-execution-toggle { + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); + padding: 0; + border: 0; + background: transparent; + color: inherit; + text-align: left; +} + +.plan-execution-toggle:hover strong { + color: var(--foreground); +} + +.plan-execution-toggle:focus-visible { + outline: none; + box-shadow: 0 0 0 2px var(--focus-ring); + border-radius: var(--radius-control); +} + +.plan-execution-toggle > div { + min-width: 0; + display: grid; + gap: var(--space-0-5); +} + +.plan-execution-toggle span { + color: var(--muted-foreground); + font-size: var(--font-size-caption); + line-height: var(--leading-snug); +} + +.plan-execution-toggle strong { + min-width: 0; + overflow: hidden; + color: var(--foreground); + font-size: var(--font-size-ui); + font-weight: var(--font-weight-medium); + line-height: var(--leading-snug); + text-overflow: ellipsis; + white-space: nowrap; +} + +.plan-execution-summary { + flex: 0 0 auto; + display: inline-flex; + align-items: center; + gap: var(--space-1); +} + +.plan-execution-summary svg { + width: var(--icon-size); + height: var(--icon-size); + transition: transform var(--duration-quick) var(--ease-out-strong); +} + +.plan-execution-toggle[aria-expanded="true"] .plan-execution-summary svg { + transform: rotate(180deg); +} + +.plan-execution-count { + flex: 0 0 auto; + font-family: var(--font-mono); + font-variant-numeric: tabular-nums; +} + +.plan-execution-details { + display: grid; + gap: var(--space-2); +} + +.plan-execution-steps { + max-height: min(28vh, calc(var(--space-16) * 3)); + margin: 0; + padding: 0; + overflow-y: auto; + border: var(--border-width-hairline) solid var(--border); + border-radius: var(--radius-control); + background: var(--foreground-2); + list-style: none; +} + +.plan-execution-steps li { + display: grid; + grid-template-columns: var(--h-control-xs) minmax(0, 1fr); + align-items: center; + gap: var(--space-2); + min-height: var(--h-control-lg); + padding: var(--space-1-5) var(--space-2); + color: var(--foreground-secondary); + font-size: var(--font-size-ui); + line-height: var(--leading-snug); +} + +.plan-execution-steps li + li { + border-top: var(--border-width-hairline) solid var(--border); +} + +.plan-execution-steps li[data-status="in_progress"] { + color: var(--foreground); + background: oklch(from var(--status-running) l c h / 0.06); +} + +.plan-execution-steps li[data-status="completed"], +.plan-execution-steps li[data-status="skipped"] { + color: var(--muted-foreground); +} + +.plan-execution-step-marker { + width: var(--h-control-xs); + height: var(--h-control-xs); + display: inline-flex; + align-items: center; + justify-content: center; + border: var(--border-width-hairline) solid var(--border-strong); + border-radius: var(--radius-pill); + color: var(--muted-foreground); + font-family: var(--font-mono); + font-size: var(--font-size-caption); + line-height: var(--leading-none); +} + +.plan-execution-step-marker[data-status="in_progress"] { + border-color: oklch(from var(--status-running) l c h / 0.38); + background: oklch(from var(--status-running) l c h / 0.12); + color: var(--status-running); +} + +.plan-execution-step-marker[data-status="completed"] { + border-color: oklch(from var(--success) l c h / 0.36); + background: oklch(from var(--success) l c h / 0.12); + color: var(--success-text); +} + +.plan-execution-step-marker[data-status="skipped"] { + border-color: transparent; + background: var(--foreground-5); +} + +.plan-execution-actions { + display: flex; + justify-content: flex-end; +} + +.plan-mode-error { + margin: var(--space-1-5) 0 0; + color: var(--destructive-text); + font-size: var(--font-size-caption); +} diff --git a/packages/cli/src/runtime-bootstrap.ts b/packages/cli/src/runtime-bootstrap.ts index 9552d882283..30a9982d5fe 100644 --- a/packages/cli/src/runtime-bootstrap.ts +++ b/packages/cli/src/runtime-bootstrap.ts @@ -19,6 +19,7 @@ import { buildRuntimeEventModelReplayPlan, buildChildAgentTools, createBuiltinSandboxManager, + createSandboxDiagnosticsProvider, createProviderRequestCaptureRecorder, createFilesystemWorkerLaunchSpecProvider, createLocalContinuationSafetyInspector, @@ -219,16 +220,26 @@ export async function createMakaCliRuntimeContext( }, }); const sandboxManager = createBuiltinSandboxManager(); + const filesystemWorkerLaunchSpecProvider = + process.platform === 'darwin' + ? createFilesystemWorkerLaunchSpecProvider({ + runtime: 'node', + resourceLocation: { kind: 'runtime' }, + }) + : undefined; const filesystemWorker = - process.platform === 'darwin' && sandboxManager + sandboxManager && filesystemWorkerLaunchSpecProvider ? new FilesystemWorkerClient({ sandboxManager, - getLaunchSpec: createFilesystemWorkerLaunchSpecProvider({ - runtime: 'node', - resourceLocation: { kind: 'runtime' }, - }), + getLaunchSpec: filesystemWorkerLaunchSpecProvider, }) : undefined; + const sandboxDiagnosticsProvider = createSandboxDiagnosticsProvider({ + ...(sandboxManager ? { sandboxManager } : {}), + ...(filesystemWorkerLaunchSpecProvider + ? { getFilesystemWorkerLaunchSpec: filesystemWorkerLaunchSpecProvider } + : {}), + }); const tools = buildBuiltinTools({ shellRuns, runtimeResources: shellRuns, @@ -573,6 +584,10 @@ export async function createMakaCliRuntimeContext( } : {}), }); + const sandboxDiagnosticsSnapshot = await sandboxDiagnosticsProvider.resolve({ + mode: header.permissionMode, + cwd: header.cwd, + }); return new AiSdkBackend({ sessionId: ctx.sessionId, header: { ...header, model: ready.model }, @@ -584,6 +599,7 @@ export async function createMakaCliRuntimeContext( permissionEngine, modelFactory: (modelInput) => getAIModel({ ...modelInput, fetch: modelFetch }), tools: allTools, + sandboxDiagnosticsSnapshot, toolAvailability, ...(input.surface === 'tui' ? { @@ -633,6 +649,7 @@ export async function createMakaCliRuntimeContext( turnTailPrompt: ({ cwd }) => buildCliTurnTailPrompt({ cwd, sessionId: ctx.sessionId, automationManager, goalManager }), shellRunContextSummary: ctx.shellRunContextSummary, + recordRunTrace: ctx.recordRunTrace, ...(ctx.recordProviderRequestCapture ? { recordProviderRequestCapture: createProviderRequestCaptureRecorder({ diff --git a/packages/core/package.json b/packages/core/package.json index 9e4b10df717..d6815687b48 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -12,6 +12,8 @@ "./execution-evidence": "./dist/execution-evidence.js", "./events": "./dist/events.js", "./session": "./dist/session.js", + "./collaboration": "./dist/collaboration.js", + "./plan": "./dist/plan.js", "./agent-run": "./dist/agent-run.js", "./shell-run": "./dist/shell-run.js", "./browser": "./dist/browser.js", diff --git a/packages/core/src/agent-run.ts b/packages/core/src/agent-run.ts index 1dd892dbeb9..d0d479e0f96 100644 --- a/packages/core/src/agent-run.ts +++ b/packages/core/src/agent-run.ts @@ -1,4 +1,5 @@ import { isPermissionMode, type PermissionMode } from './permission.js'; +import { isCollaborationMode, type CollaborationMode } from './collaboration.js'; import type { BackendKind } from './session.js'; import { defineObjectShape, @@ -45,6 +46,8 @@ export interface AgentRunHeader { /** Authoritative host identity for the workspace observed when the run was created. */ workspaceIdentity?: string; permissionMode: PermissionMode; + /** Snapshot of the session collaboration mode. Optional on legacy runs. */ + collaborationMode?: CollaborationMode; createdAt: number; updatedAt: number; completedAt?: number; @@ -75,6 +78,16 @@ export const AGENT_RUN_EVENT_TYPES = [ 'run_created', 'run_started', 'turn_started', + 'sandbox_context_resolved', + 'plan_context_resolved', + 'plan_submitted', + 'plan_execution_started', + 'plan_progress_updated', + 'plan_execution_completed', + 'plan_execution_cancelled', + 'plan_execution_interrupted', + 'plan_execution_resumed', + 'plan_transition_failed', 'run_status_changed', 'model_resolved', 'model_resolve_failed', @@ -157,6 +170,7 @@ const AGENT_RUN_HEADER_SHAPE = defineObjectShape()( 'failureMessage', 'abortSource', 'traceWriteError', + 'collaborationMode', ], ); @@ -179,6 +193,7 @@ export function decodeAgentRunHeader(value: unknown): AgentRunHeader { typeof value.modelId === 'string' && typeof value.cwd === 'string' && isPermissionMode(value.permissionMode) && + (value.collaborationMode === undefined || isCollaborationMode(value.collaborationMode)) && isFiniteNumber(value.createdAt) && isFiniteNumber(value.updatedAt) && isOptionalString(value.invocationId) && diff --git a/packages/core/src/collaboration.ts b/packages/core/src/collaboration.ts new file mode 100644 index 00000000000..ac2ec74181d --- /dev/null +++ b/packages/core/src/collaboration.ts @@ -0,0 +1,7 @@ +export const COLLABORATION_MODES = ['agent', 'plan'] as const; + +export type CollaborationMode = (typeof COLLABORATION_MODES)[number]; + +export function isCollaborationMode(value: unknown): value is CollaborationMode { + return typeof value === 'string' && (COLLABORATION_MODES as readonly string[]).includes(value); +} diff --git a/packages/core/src/events.ts b/packages/core/src/events.ts index f576a94c42a..e204c38d449 100644 --- a/packages/core/src/events.ts +++ b/packages/core/src/events.ts @@ -479,13 +479,19 @@ export interface PermissionDecisionAckEvent extends BaseEvent { export interface PlanSubmittedEvent extends BaseEvent { type: 'plan_submitted'; planId: string; + proposalId?: string; + revision?: number; title: string; - markdownPath: string; + overview?: string; + risks?: string[]; + /** Legacy file-backed proposal representation. */ + markdownPath?: string; steps?: PlanStep[]; } export interface PlanStep { id: string; + title: string; description: string; status: 'pending' | 'in_progress' | 'completed' | 'skipped'; files?: string[]; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 5e4efb929eb..ff07003327c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -8,6 +8,8 @@ */ export * from './mcp.js'; +export * from './collaboration.js'; +export * from './plan.js'; // events.ts export type { diff --git a/packages/core/src/plan.ts b/packages/core/src/plan.ts new file mode 100644 index 00000000000..bb4d70089d0 --- /dev/null +++ b/packages/core/src/plan.ts @@ -0,0 +1,206 @@ +export const PLAN_PROPOSAL_STATUSES = ['pending_approval', 'stale', 'approved'] as const; +export type PlanProposalStatus = (typeof PLAN_PROPOSAL_STATUSES)[number]; + +export const PLAN_EXECUTION_STATUSES = ['active', 'completed', 'cancelled', 'interrupted'] as const; +export type PlanExecutionStatus = (typeof PLAN_EXECUTION_STATUSES)[number]; + +export const PLAN_STEP_STATUSES = ['pending', 'in_progress', 'completed', 'skipped'] as const; +export type PlanStepStatus = (typeof PLAN_STEP_STATUSES)[number]; + +export interface PlanStepDefinition { + id: string; + title: string; + description: string; + files?: string[]; + complexity?: 'low' | 'medium' | 'high'; +} + +export interface PlanProposal { + planId: string; + proposalId: string; + sessionId: string; + turnId: string; + revision: number; + supersedesProposalId?: string; + /** Interrupted execution whose remaining work this proposal replans. */ + sourceExecutionId?: string; + title: string; + overview?: string; + steps: PlanStepDefinition[]; + risks?: string[]; + status: PlanProposalStatus; + submittedAt: number; +} + +export interface PlanExecutionStep extends PlanStepDefinition { + status: PlanStepStatus; + note?: string; + updatedAt: number; +} + +export interface PlanExecution { + executionId: string; + planId: string; + proposalId: string; + sessionId: string; + status: PlanExecutionStatus; + steps: PlanExecutionStep[]; + startedAt: number; + updatedAt: number; + completedAt?: number; + cancelledAt?: number; + interruptedAt?: number; + cancelReason?: string; + interruptionReason?: string; +} + +export interface PlanSessionState { + schemaVersion: 1; + sessionId: string; + storeVersion: number; + proposals: PlanProposal[]; + executions: PlanExecution[]; + latestProposalId?: string; + activeExecutionId?: string; +} + +interface PlanEventBase { + id: string; + sessionId: string; + ts: number; + storeVersion: number; +} + +export type PlanEvent = + | (PlanEventBase & { + type: 'plan_submitted'; + proposal: PlanProposal; + }) + | (PlanEventBase & { + type: 'plan_revision_requested'; + proposalId: string; + }) + | (PlanEventBase & { + type: 'plan_abandoned'; + proposalId: string; + reason: string; + }) + | (PlanEventBase & { + type: 'plan_approved'; + proposalId: string; + execution: PlanExecution; + }) + | (PlanEventBase & { + type: 'plan_progress_updated'; + executionId: string; + steps: PlanExecutionStep[]; + explanation?: string; + }) + | (PlanEventBase & { + type: 'plan_execution_completed'; + executionId: string; + steps: PlanExecutionStep[]; + }) + | (PlanEventBase & { + type: 'plan_execution_cancelled'; + executionId: string; + reason: string; + }) + | (PlanEventBase & { + type: 'plan_execution_interrupted'; + executionId: string; + reason: string; + }) + | (PlanEventBase & { + type: 'plan_execution_resumed'; + executionId: string; + }); + +export interface SubmitPlanProposalInput { + sessionId: string; + turnId: string; + title: string; + overview?: string; + steps: PlanStepDefinition[]; + risks?: string[]; + sourceExecutionId?: string; +} + +export interface ApprovePlanProposalInput { + sessionId: string; + proposalId: string; + expectedRevision: number; + expectedStoreVersion?: number; +} + +export interface RequestPlanRevisionInput { + sessionId: string; + proposalId: string; +} + +export interface AbandonPlanProposalInput { + sessionId: string; + proposalId: string; + reason: string; +} + +export interface UpdatePlanExecutionInput { + sessionId: string; + executionId: string; + steps: Array<{ + id: string; + status: PlanStepStatus; + note?: string; + }>; + explanation?: string; +} + +export interface CancelPlanExecutionInput { + sessionId: string; + executionId: string; + reason: string; +} + +export interface PlanMutationResult { + event: PlanEvent; + state: PlanSessionState; +} + +export interface PlanStore { + readState(sessionId: string): Promise; + submitProposal(input: SubmitPlanProposalInput): Promise; + requestRevision(input: RequestPlanRevisionInput): Promise; + abandonProposal(input: AbandonPlanProposalInput): Promise; + approveProposal(input: ApprovePlanProposalInput): Promise; + updateExecution(input: UpdatePlanExecutionInput): Promise; + cancelExecution(input: CancelPlanExecutionInput): Promise; + interruptActiveExecution(sessionId: string, reason: string): Promise; + resumeExecution(sessionId: string, executionId: string): Promise; +} + +export class PlanConflictError extends Error { + constructor(message: string) { + super(message); + this.name = 'PlanConflictError'; + } +} + +export function emptyPlanSessionState(sessionId: string): PlanSessionState { + return { + schemaVersion: 1, + sessionId, + storeVersion: 0, + proposals: [], + executions: [], + }; +} + +export function activePlanExecution(state: PlanSessionState): PlanExecution | undefined { + if (!state.activeExecutionId) return undefined; + return state.executions.find((execution) => execution.executionId === state.activeExecutionId); +} + +export function latestPlanProposal(state: PlanSessionState): PlanProposal | undefined { + if (!state.latestProposalId) return undefined; + return state.proposals.find((proposal) => proposal.proposalId === state.latestProposalId); +} diff --git a/packages/core/src/runtime-inputs.ts b/packages/core/src/runtime-inputs.ts index 45ed88932c3..58768314309 100644 --- a/packages/core/src/runtime-inputs.ts +++ b/packages/core/src/runtime-inputs.ts @@ -6,6 +6,7 @@ import type { AttachmentRef } from './events.js'; import type { BackendKind, SessionBlockedReason, SessionStatus } from './session.js'; import type { PermissionMode } from './permission.js'; import type { ThinkingLevel } from './model-thinking.js'; +import type { CollaborationMode } from './collaboration.js'; export interface CreateSessionInput { /** Absolute path to the session's working dir (project root). */ @@ -19,6 +20,8 @@ export interface CreateSessionInput { /** Per-model reasoning-depth variant; `undefined` = model default. */ thinkingLevel?: ThinkingLevel; permissionMode: PermissionMode; + /** Defaults to `agent`. */ + collaborationMode?: CollaborationMode; status?: SessionStatus; blockedReason?: SessionBlockedReason; parentSessionId?: string; diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 47a9dd68fe0..0e8c36b6f59 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -13,6 +13,7 @@ import { type ToolResultContent, } from './events.js'; import type { PermissionMode } from './permission.js'; +import type { CollaborationMode } from './collaboration.js'; import type { CacheMissInputSource, ContextBudgetDiagnostic, @@ -119,6 +120,8 @@ export interface SessionHeader { /** Per-model reasoning-depth variant; `undefined` = model default. Cleared on model switch. */ thinkingLevel?: import('./model-thinking.js').ThinkingLevel; permissionMode: PermissionMode; + /** Defaults to `agent` when absent on legacy session records. */ + collaborationMode?: CollaborationMode; /** Forward-compatible schema versioning. V0.1 only writes 1. */ schemaVersion: 1; @@ -157,6 +160,8 @@ export interface SessionSummary { /** Per-model reasoning-depth variant; `undefined` = model default. Cleared on model switch. */ thinkingLevel?: import('./model-thinking.js').ThinkingLevel; permissionMode: PermissionMode; + /** Defaults to `agent` when absent on legacy summaries. */ + collaborationMode?: CollaborationMode; } export type SessionChangedReason = diff --git a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts index ed1408592da..1180477bc24 100644 --- a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts +++ b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts @@ -62,12 +62,178 @@ import { buildRuntimeEventModelReplayPlan, buildSteeringEnvelope } from '../mode import type { ActiveFullCompactBlock } from '../active-full-compact.js'; import type { SemanticCompactBlock } from '../semantic-compact.js'; import { HistoryCompactSummarizerError } from '../history-compact-summarizer.js'; +import type { AutoApprovalReviewContext } from '../approval-reviewer.js'; +import type { SandboxDiagnosticsSnapshot } from '../sandbox/diagnostics.js'; +import { SandboxCommandError } from '../sandbox/errors.js'; +import { RunTrace } from '../run-trace.js'; import type { ProviderRequestAttemptRecord, ProviderRequestCaptureRecord, } from '../provider-request-telemetry.js'; describe('AiSdkBackend model history', () => { + test('exposes one active sandbox snapshot to the model and durable run trace', async () => { + const model = completionModel(); + const traces: RunTraceEvent[] = []; + const backend = new AiSdkBackend({ + sessionId: 'session-1', + header: header(), + appendMessage: async () => {}, + connection: connection(), + apiKey: 'sk-test', + modelId: 'mock-model-id', + permissionEngine: new PermissionEngine({ newId: () => 'permission-id', now: () => 1 }), + modelFactory: () => model, + tools: [], + sandboxDiagnosticsSnapshot: sandboxSnapshot(), + recordRunTrace: (event) => traces.push(event), + newId: idGenerator(), + now: monotonicClock(), + }); + + await drain(backend.send({ turnId: 'turn-current', text: 'current user', context: [] })); + + assert.match(JSON.stringify(compactPrompt(model)), /Maka runtime sandbox context/); + assert.match(JSON.stringify(compactPrompt(model)), /Working directory: \/tmp\/maka/); + const contextEvent = traces.find((event) => event.type === 'sandbox_context_resolved'); + const traceSnapshot = contextEvent?.data?.snapshot as + | { profile?: { name?: string } } + | undefined; + assert.equal(traceSnapshot?.profile?.name, 'workspace-write'); + assert.equal(JSON.stringify(contextEvent).includes('/tmp/maka'), false); + }); + + test('passes the active sandbox snapshot into automatic approval review', async () => { + let reviewContext: AutoApprovalReviewContext | undefined; + const backend = new AiSdkBackend({ + sessionId: 'session-1', + header: header('execute'), + appendMessage: async () => {}, + connection: connection(), + apiKey: 'sk-test', + modelId: 'mock-model-id', + permissionEngine: new PermissionEngine({ newId: idGenerator(), now: () => 1 }), + autoApprovalReviewer: { + review: async ({ context }) => { + reviewContext = context; + return { outcome: 'allow', riskLevel: 'high', rationale: 'test approval' }; + }, + }, + modelFactory: () => ({}), + tools: [], + sandboxDiagnosticsSnapshot: sandboxSnapshot(), + newId: idGenerator(), + now: monotonicClock(), + }); + const tool: MakaTool = { + name: 'Bash', + description: 'shell', + parameters: {}, + permissionRequired: true, + impl: async () => ({ kind: 'text', text: 'ok' }), + }; + const execute = ( + backend as unknown as { + wrapToolExecute( + tool: MakaTool, + turnId: string, + queue: { push(event: SessionEvent): void }, + ): ( + args: unknown, + ctx: { toolCallId: string; abortSignal: AbortSignal }, + ) => Promise; + } + ).wrapToolExecute(tool, 'turn-1', { push: () => {} }); + + await execute( + { command: 'rm local-file' }, + { toolCallId: 'tool-1', abortSignal: new AbortController().signal }, + ); + + assert.deepEqual(reviewContext?.sandbox, { + platform: 'darwin', + profileName: 'workspace-write', + fileSystem: 'workspace-write', + network: 'restricted', + commandSandbox: 'available (macos-seatbelt)', + filesystemSandbox: 'available (macos-seatbelt)', + commandSandboxSelectionReason: 'platform_sandbox_selected', + filesystemSandboxSelectionReason: 'platform_sandbox_selected', + }); + }); + + test('records structured sandbox failure metadata on tool failure traces', async () => { + const traces: RunTraceEvent[] = []; + const backend = new AiSdkBackend({ + sessionId: 'session-1', + header: header('bypass'), + appendMessage: async () => {}, + connection: connection(), + apiKey: 'sk-test', + modelId: 'mock-model-id', + permissionEngine: new PermissionEngine({ newId: idGenerator(), now: () => 1 }), + modelFactory: () => ({}), + tools: [], + newId: idGenerator(), + now: monotonicClock(), + }); + (backend as unknown as { currentRunTrace: RunTrace | null }).currentRunTrace = new RunTrace({ + sessionId: 'session-1', + turnId: 'turn-1', + connectionSlug: 'anthropic-main', + providerId: 'anthropic', + modelId: 'mock-model-id', + newId: idGenerator(), + now: monotonicClock(), + record: (event) => traces.push(event), + }); + const tool: MakaTool = { + name: 'Bash', + description: 'shell', + parameters: {}, + permissionRequired: false, + impl: async () => { + throw new SandboxCommandError({ + domain: 'command', + stage: 'transform', + reason: 'backend_not_available', + backend: 'macos-seatbelt', + recoverable: false, + profileName: 'workspace-write', + message: 'contains /private/workspace/path', + }); + }, + }; + const execute = ( + backend as unknown as { + wrapToolExecute( + tool: MakaTool, + turnId: string, + queue: { push(event: SessionEvent): void }, + ): ( + args: unknown, + ctx: { toolCallId: string; abortSignal: AbortSignal }, + ) => Promise; + } + ).wrapToolExecute(tool, 'turn-1', { push: () => {} }); + + await execute( + { command: 'true' }, + { toolCallId: 'tool-1', abortSignal: new AbortController().signal }, + ); + + const failure = traces.find((event) => event.type === 'tool_failed'); + assert.deepEqual(failure?.data?.sandbox, { + domain: 'command', + stage: 'transform', + reason: 'backend_not_available', + recoverable: false, + backend: 'macos-seatbelt', + profileName: 'workspace-write', + }); + assert.equal(JSON.stringify(failure).includes('/private/workspace/path'), false); + }); + test('omits an empty system prompt from the provider request', async () => { const model = completionModel(); const backend = new AiSdkBackend({ @@ -11785,6 +11951,34 @@ function header(permissionMode: SessionHeader['permissionMode'] = 'ask'): Sessio }; } +function sandboxSnapshot(): SandboxDiagnosticsSnapshot { + return { + schemaVersion: 1, + platform: 'darwin', + profile: { + name: 'workspace-write', + type: 'managed', + fileSystem: 'workspace-write', + network: 'restricted', + cwd: '/tmp/maka', + workspaceRoots: ['/tmp/maka'], + protectedMetadata: ['.git', '.agents', '.codex'], + }, + capabilities: { + command: { + status: 'available', + backend: 'macos-seatbelt', + selectionReason: 'platform_sandbox_selected', + }, + filesystem: { + status: 'available', + backend: 'macos-seatbelt', + selectionReason: 'platform_sandbox_selected', + }, + }, + }; +} + function connection(): LlmConnection { return { slug: 'anthropic-main', diff --git a/packages/runtime/src/__tests__/plan-mode.test.ts b/packages/runtime/src/__tests__/plan-mode.test.ts new file mode 100644 index 00000000000..5c1e416dbfa --- /dev/null +++ b/packages/runtime/src/__tests__/plan-mode.test.ts @@ -0,0 +1,176 @@ +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; + +import { + renderInterruptedPlanContext, + renderPlanExecutionPrompt, + renderPlanModePrompt, + selectCollaborationTools, +} from '../plan-mode.js'; +import { buildSubmitPlanTool } from '../plan-tools.js'; +import type { MakaTool } from '../tool-runtime.js'; + +describe('Plan Mode tool surface', () => { + test('requires plain-text step titles and descriptions', () => { + const submitPlan = buildSubmitPlanTool({} as never); + const schema = submitPlan.parameters as { + safeParse(input: unknown): { success: boolean }; + }; + const valid = { + title: 'Plan', + steps: [{ id: 'inspect', title: 'Inspect code', description: 'Read the relevant files.' }], + }; + + assert.equal(schema.safeParse(valid).success, true); + assert.equal( + schema.safeParse({ title: 'Plan', steps: [{ id: 'inspect', description: 'Read files.' }] }) + .success, + false, + ); + assert.equal( + schema.safeParse({ + title: 'Plan', + steps: [{ id: 'inspect', title: '**Inspect code**', description: 'Read files.' }], + }).success, + false, + ); + assert.equal( + schema.safeParse({ + title: 'Plan', + steps: [{ id: 'inspect', title: 'Inspect code', description: '- Read files' }], + }).success, + false, + ); + assert.match(renderPlanModePrompt(), /plain text without Markdown formatting/); + }); + + test('keeps read tools and plan controls while removing writes and subagents', () => { + const selected = selectCollaborationTools({ + mode: 'plan', + hasActiveExecution: false, + tools: [ + tool('Read', 'read'), + tool('WebSearch', 'web_read'), + tool('Write', 'file_write'), + tool('ExploreAgent', 'subagent'), + tool('AskUserQuestion'), + tool('SubmitPlan'), + tool('update_plan'), + ], + }); + assert.deepEqual( + selected.map((tool) => tool.name), + ['Read', 'WebSearch', 'AskUserQuestion', 'SubmitPlan'], + ); + }); + + test('active execution exposes progress controls and removes subagents', () => { + const selected = selectCollaborationTools({ + mode: 'agent', + hasActiveExecution: true, + tools: [ + tool('Write', 'file_write'), + tool('ExploreAgent', 'subagent'), + tool('SubmitPlan'), + tool('update_plan'), + tool('cancel_plan'), + ], + }); + assert.deepEqual( + selected.map((tool) => tool.name), + ['Write', 'update_plan', 'cancel_plan'], + ); + }); + + test('injects interrupted progress as replanning context without resuming execution', () => { + const prompt = renderInterruptedPlanContext({ + proposal: { + planId: 'plan-1', + proposalId: 'proposal-1', + sessionId: 'session-1', + turnId: 'turn-1', + revision: 1, + title: 'Original plan', + steps: [{ id: 'inspect', title: 'Inspect code', description: 'Inspect' }], + status: 'approved', + submittedAt: 1, + }, + execution: { + executionId: 'execution-1', + planId: 'plan-1', + proposalId: 'proposal-1', + sessionId: 'session-1', + status: 'interrupted', + steps: [ + { + id: 'inspect', + title: 'Inspect code', + description: 'Inspect', + status: 'completed', + updatedAt: 2, + }, + ], + startedAt: 1, + updatedAt: 2, + interruptedAt: 2, + interruptionReason: 'User stopped execution', + }, + }); + + assert.match(prompt, /Interrupted execution ID: execution-1/); + assert.match(prompt, /Inspect code<\/title>/); + assert.match(prompt, /<description>Inspect<\/description>/); + assert.match(prompt, /<status>completed<\/status>/); + assert.match(prompt, /Do not resume execution or modify files/); + }); + + test('requires execution progress updates at step boundaries', () => { + const prompt = renderPlanExecutionPrompt({ + proposal: { + planId: 'plan-1', + proposalId: 'proposal-1', + sessionId: 'session-1', + turnId: 'turn-1', + revision: 1, + title: 'Implement plan', + steps: [{ id: 'change', title: 'Change implementation', description: 'Change code' }], + status: 'approved', + submittedAt: 1, + }, + execution: { + executionId: 'execution-1', + planId: 'plan-1', + proposalId: 'proposal-1', + sessionId: 'session-1', + status: 'active', + steps: [ + { + id: 'change', + title: 'Change implementation', + description: 'Change code', + status: 'pending', + updatedAt: 1, + }, + ], + startedAt: 1, + updatedAt: 1, + }, + }); + + assert.match(prompt, /Before implementation, call update_plan/); + assert.match(prompt, /<title>Change implementation<\/title>/); + assert.match(prompt, /<description>Change code<\/description>/); + assert.match(prompt, /Immediately after finishing a step, call update_plan again/); + assert.match(prompt, /Before the final response, update every finished or skipped step/); + }); +}); + +function tool(name: string, categoryHint?: MakaTool['categoryHint']): MakaTool { + return { + name, + description: name, + parameters: {}, + ...(categoryHint ? { categoryHint } : {}), + impl: () => null, + }; +} diff --git a/packages/runtime/src/__tests__/run-trace.test.ts b/packages/runtime/src/__tests__/run-trace.test.ts index 43f24b67fcf..b11cab465a9 100644 --- a/packages/runtime/src/__tests__/run-trace.test.ts +++ b/packages/runtime/src/__tests__/run-trace.test.ts @@ -3,6 +3,50 @@ import { describe, test } from 'node:test'; import { RunTrace, type RunTraceEvent } from '../run-trace.js'; describe('RunTrace error diagnostics', () => { + test('records a path-free active sandbox snapshot', () => { + const events: RunTraceEvent[] = []; + const trace = new RunTrace({ + sessionId: 'session-1', + turnId: 'turn-1', + connectionSlug: 'deepseek', + providerId: 'openai-compatible', + modelId: 'deepseek-v4-pro', + newId: () => `trace-${events.length + 1}`, + now: () => 123, + record: (event) => events.push(event), + }); + + trace.sandboxContextResolved({ + schemaVersion: 1, + platform: 'darwin', + profile: { + name: 'workspace-write', + type: 'managed', + fileSystem: 'workspace-write', + network: 'restricted', + protectedMetadata: ['.git'], + }, + capabilities: { + command: { + status: 'available', + backend: 'macos-seatbelt', + selectionReason: 'platform_sandbox_selected', + }, + filesystem: { + status: 'unavailable', + backend: 'macos-seatbelt', + failure: { stage: 'launch', reason: 'filesystem_worker_unavailable' }, + }, + }, + }); + + assert.equal(events[0]?.type, 'sandbox_context_resolved'); + assert.equal(events[0]?.phase, 'sandbox'); + assert.equal(JSON.stringify(events[0]).includes('/Users/'), false); + const snapshot = events[0]?.data?.snapshot as { profile?: { name?: string } } | undefined; + assert.equal(snapshot?.profile?.name, 'workspace-write'); + }); + test('model stream failures keep generic copy plus redacted raw diagnostics', () => { const events: RunTraceEvent[] = []; const trace = new RunTrace({ diff --git a/packages/runtime/src/__tests__/runtime-event-read-model.test.ts b/packages/runtime/src/__tests__/runtime-event-read-model.test.ts index 2d2dc141757..cf4239baa03 100644 --- a/packages/runtime/src/__tests__/runtime-event-read-model.test.ts +++ b/packages/runtime/src/__tests__/runtime-event-read-model.test.ts @@ -382,6 +382,83 @@ describe('projectRuntimeEventsToStoredMessages', () => { expect(out.diagnostics).toEqual([]); }); + test('restores legacy Plan tool results and consumes proposal state events', () => { + const output = projectRuntimeEventsToStoredMessages( + [ + ev({ + id: 'evt-plan-call', + ts: ts + 1, + role: 'model', + author: 'agent', + content: { + kind: 'function_call', + id: 'plan-tool-1', + name: 'SubmitPlan', + args: { + title: 'Plan', + steps: [{ id: 'one', title: 'First step', description: 'One' }], + }, + }, + refs: { toolCallId: 'plan-tool-1' }, + }), + ev({ + id: 'evt-plan-result', + ts: ts + 2, + role: 'tool', + author: 'tool', + content: { + kind: 'function_response', + id: 'plan-tool-1', + name: 'SubmitPlan', + result: { + kind: 'plan_submitted', + proposal: { proposalId: 'proposal-1' }, + storeVersion: 1, + } as never, + }, + refs: { toolCallId: 'plan-tool-1' }, + }), + ev({ + id: 'evt-plan-submitted', + ts: ts + 3, + role: 'system', + author: 'agent', + actions: { + stateDelta: { + planId: 'plan-1', + proposalId: 'proposal-1', + title: 'Plan', + steps: [ + { + id: 'one', + title: 'First step', + description: 'One', + status: 'pending', + }, + ], + }, + }, + }), + ev({ + id: 'evt-plan-complete', + ts: ts + 4, + status: 'completed', + actions: { endInvocation: true }, + }), + ], + { runHeaders: [header] }, + ); + + expect(output.diagnostics).toEqual([]); + expect(output.messages.map((message) => message.type)).toEqual([ + 'tool_call', + 'tool_result', + 'turn_state', + ]); + const result = output.messages.find((message) => message.type === 'tool_result'); + expect(result?.type === 'tool_result' ? result.content.kind : undefined).toBe('json'); + }); + test('normalizes an exact legacy terminal result at RuntimeEvent restore', () => { const out = projectRuntimeEventsToStoredMessages( [ diff --git a/packages/runtime/src/__tests__/sandbox-diagnostics.test.ts b/packages/runtime/src/__tests__/sandbox-diagnostics.test.ts new file mode 100644 index 00000000000..31834cd0d23 --- /dev/null +++ b/packages/runtime/src/__tests__/sandbox-diagnostics.test.ts @@ -0,0 +1,166 @@ +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; + +import { createDangerFullAccessPermissionProfile } from '@maka/core'; + +import { MacosSeatbeltBackend } from '../sandbox/macos-seatbelt.js'; +import { SandboxManager } from '../sandbox/sandbox-manager.js'; +import { + createSandboxDiagnosticsProvider, + toSandboxRunTraceProjection, +} from '../sandbox/diagnostics.js'; +import { SandboxCommandError, serializeSandboxError } from '../sandbox/errors.js'; +import { renderSandboxTurnTailPrompt } from '../system-prompt/sandbox-context-prompt.js'; +import { FilesystemWorkerClientError } from '../filesystem-worker/client.js'; + +describe('sandbox diagnostics', () => { + test('reports the effective profile and both active macOS enforcement capabilities', async () => { + const provider = createSandboxDiagnosticsProvider({ + platform: 'darwin', + sandboxManager: new SandboxManager([new MacosSeatbeltBackend()]), + getFilesystemWorkerLaunchSpec: async () => ({ + ok: true, + spec: { + program: '/runtime/node', + args: ['/runtime/worker.js'], + env: {}, + runtimeReadableRoots: ['/runtime/worker.js'], + executableRoots: ['/runtime'], + }, + }), + isExecutable: async () => true, + canonicalizePath: async (path) => path, + }); + + const snapshot = await provider.resolve({ mode: 'ask', cwd: '/workspace' }); + + assert.deepEqual(snapshot.profile, { + name: 'workspace-write', + type: 'managed', + fileSystem: 'workspace-write', + network: 'restricted', + cwd: '/workspace', + workspaceRoots: ['/workspace'], + protectedMetadata: ['.git', '.agents', '.codex'], + }); + assert.deepEqual(snapshot.capabilities.command, { + status: 'available', + backend: 'macos-seatbelt', + selectionReason: 'platform_sandbox_selected', + }); + assert.deepEqual(snapshot.capabilities.filesystem, { + status: 'available', + backend: 'macos-seatbelt', + selectionReason: 'platform_sandbox_selected', + }); + }); + + test('keeps typed selection and filesystem-worker failure reasons', async () => { + const unsupported = createSandboxDiagnosticsProvider({ + platform: 'win32', + canonicalizePath: async (path) => path, + }); + const unsupportedSnapshot = await unsupported.resolve({ + mode: 'execute', + cwd: 'C:\\workspace', + }); + assert.deepEqual(unsupportedSnapshot.capabilities.command.failure, { + stage: 'selection', + reason: 'unsupported_platform', + }); + + const noWorker = createSandboxDiagnosticsProvider({ + platform: 'darwin', + sandboxManager: new SandboxManager([new MacosSeatbeltBackend()]), + isExecutable: async () => true, + canonicalizePath: async (path) => path, + }); + const noWorkerSnapshot = await noWorker.resolve({ mode: 'ask', cwd: '/workspace' }); + assert.deepEqual(noWorkerSnapshot.capabilities.filesystem, { + status: 'unavailable', + backend: 'macos-seatbelt', + selectionReason: 'platform_sandbox_selected', + failure: { stage: 'launch', reason: 'filesystem_worker_unavailable' }, + }); + }); + + test('reports unrestricted profiles as not requiring a Maka sandbox', async () => { + const provider = createSandboxDiagnosticsProvider({ + platform: 'darwin', + canonicalizePath: async (path) => path, + }); + const snapshot = await provider.resolve({ + mode: 'bypass', + cwd: '/workspace', + permissionProfile: createDangerFullAccessPermissionProfile(), + }); + + assert.equal(snapshot.profile.name, 'danger-full-access'); + assert.equal(snapshot.profile.network, 'enabled'); + assert.deepEqual(snapshot.capabilities.command, { + status: 'not_required', + backend: 'none', + selectionReason: 'sandbox_not_required', + }); + assert.deepEqual(snapshot.capabilities.filesystem, snapshot.capabilities.command); + }); + + test('removes paths from durable trace projection but renders them in the turn tail', async () => { + const provider = createSandboxDiagnosticsProvider({ + platform: 'darwin', + sandboxManager: new SandboxManager([new MacosSeatbeltBackend()]), + canonicalizePath: async (path) => path, + isExecutable: async () => true, + }); + const snapshot = await provider.resolve({ mode: 'ask', cwd: '/secret/workspace' }); + const projection = toSandboxRunTraceProjection(snapshot); + + assert.equal(JSON.stringify(projection).includes('/secret/workspace'), false); + assert.match(renderSandboxTurnTailPrompt(snapshot), /Working directory: \/secret\/workspace/); + assert.match(renderSandboxTurnTailPrompt(snapshot), /launch:filesystem_worker_unavailable/); + }); +}); + +describe('sandbox error diagnostics', () => { + test('serializes stable metadata without copying the raw error message', () => { + const error = new SandboxCommandError({ + domain: 'command', + stage: 'transform', + reason: 'backend_not_available', + backend: 'macos-seatbelt', + recoverable: false, + profileName: 'workspace-write', + message: 'private path: /Users/example/secret', + }); + + const serialized = serializeSandboxError(error); + assert.deepEqual(serialized, { + domain: 'command', + stage: 'transform', + reason: 'backend_not_available', + recoverable: false, + backend: 'macos-seatbelt', + profileName: 'workspace-write', + }); + assert.equal(JSON.stringify(serialized).includes('/Users/example/secret'), false); + }); + + test('serializes filesystem worker validation failures through the same contract', () => { + const serialized = serializeSandboxError( + new FilesystemWorkerClientError({ + reason: 'path_denied', + stage: 'validation', + recoverable: false, + requestId: 'request-1', + }), + ); + + assert.deepEqual(serialized, { + domain: 'filesystem', + stage: 'validation', + reason: 'path_denied', + recoverable: false, + requestId: 'request-1', + }); + }); +}); diff --git a/packages/runtime/src/__tests__/tool-runtime-durable-boundary.test.ts b/packages/runtime/src/__tests__/tool-runtime-durable-boundary.test.ts index fc76bd087df..fd982c228c3 100644 --- a/packages/runtime/src/__tests__/tool-runtime-durable-boundary.test.ts +++ b/packages/runtime/src/__tests__/tool-runtime-durable-boundary.test.ts @@ -114,6 +114,35 @@ describe('ToolRuntime durable boundary', () => { assert.equal(outcomes[0]?.runtimeEvent.refs?.operationId, prepared[0]?.operationId); }); + it('wraps business-domain kind values as canonical JSON tool results', async () => { + const outcomes: ToolOutcomeCommit[] = []; + const harness = makeHarness({ + commitToolPrepared: async () => ({ created: true, runtimeEventSeq: 1 }), + commitToolOutcome: async (input) => { + outcomes.push(input); + return { created: true, runtimeEventSeq: 2 }; + }, + }); + const output = { + kind: 'plan_submitted', + proposal: { proposalId: 'proposal-1' }, + storeVersion: 1, + }; + + assert.deepEqual(await harness.execute(tool(() => output)), output); + const response = outcomes[0]?.runtimeEvent.content; + assert.equal(response?.kind, 'function_response'); + assert.deepEqual(response?.kind === 'function_response' ? response.result : undefined, { + kind: 'json', + value: output, + }); + const message = harness.messages.find((candidate) => candidate.type === 'tool_result'); + assert.deepEqual(message?.type === 'tool_result' ? message.content : undefined, { + kind: 'json', + value: output, + }); + }); + it('does not create a prepared journal operation when permission is denied', async () => { let preparedCalls = 0; let implementationCalls = 0; diff --git a/packages/runtime/src/__tests__/tool-runtime-extraction-contract.test.ts b/packages/runtime/src/__tests__/tool-runtime-extraction-contract.test.ts index bbfe3189946..8d271f5ba6d 100644 --- a/packages/runtime/src/__tests__/tool-runtime-extraction-contract.test.ts +++ b/packages/runtime/src/__tests__/tool-runtime-extraction-contract.test.ts @@ -206,7 +206,7 @@ describe('RunTrace extraction contract', () => { trace, // Whitespace-tolerant: the formatter may lay the union out on one line // or one variant per line; the contract is the variant set, not layout. - /type RunTracePhase =\s*\|?\s*'turn'\s*\|\s*'model'\s*\|\s*'tool'\s*\|\s*'permission'\s*\|\s*'sandbox'\s*\|\s*'abort'\s*\|\s*'usage'/, + /type RunTracePhase =\s*\|?\s*'turn'\s*\|\s*'model'\s*\|\s*'tool'\s*\|\s*'permission'\s*\|\s*'sandbox'\s*\|\s*'plan'\s*\|\s*'abort'\s*\|\s*'usage'/, ); assert.doesNotMatch(events, /RunTrace/); assert.doesNotMatch(events, /trace_/); diff --git a/packages/runtime/src/agent-run.ts b/packages/runtime/src/agent-run.ts index 1b948eb8676..7d3a485c585 100644 --- a/packages/runtime/src/agent-run.ts +++ b/packages/runtime/src/agent-run.ts @@ -888,6 +888,7 @@ export class AgentRun { cwd: this.header.cwd, ...(this.input.workspaceIdentity ? { workspaceIdentity: this.input.workspaceIdentity } : {}), permissionMode: this.header.permissionMode, + collaborationMode: this.header.collaborationMode ?? 'agent', createdAt, updatedAt: createdAt, ...this.lineage, diff --git a/packages/runtime/src/ai-sdk-backend.ts b/packages/runtime/src/ai-sdk-backend.ts index 0cca4c8d549..7c9f79fec40 100644 --- a/packages/runtime/src/ai-sdk-backend.ts +++ b/packages/runtime/src/ai-sdk-backend.ts @@ -66,6 +66,7 @@ import type { LlmConnection } from '@maka/core/llm-connections'; import type { RuntimeEvent } from '@maka/core/runtime-event'; import type { ToolPermissionRule } from '@maka/core/permission'; import type { UserQuestionResponse } from '@maka/core/user-question'; +import type { PlanToolResult } from './plan-tools.js'; import type { AttachmentByteReader } from '@maka/core/attachments'; import { MAX_PROVIDER_IMAGE_REQUEST_BYTES, @@ -84,6 +85,7 @@ import { PermissionEngine } from './permission-engine.js'; import { AiSdkAutoApprovalReviewer, ApprovalCoordinator, + type AutoApprovalReviewContext, type AutoApprovalReviewer, } from './approval-reviewer.js'; import { AsyncEventQueue } from './async-queue.js'; @@ -132,6 +134,12 @@ import { } from './ai-sdk-compaction.js'; import type { ToolArtifactRecorder } from './tool-artifacts.js'; import { RunTrace, type RunTraceRecorder } from './run-trace.js'; +import { + toSandboxRunTraceProjection, + type SandboxDiagnosticCapability, + type SandboxDiagnosticsSnapshot, +} from './sandbox/diagnostics.js'; +import { renderSandboxTurnTailPrompt } from './system-prompt/sandbox-context-prompt.js'; import { computeCost } from './telemetry/cost.js'; import { getBuiltinPricing } from './telemetry/builtin-pricing.js'; import { @@ -279,6 +287,32 @@ function joinPromptFragments(fragments: readonly (string | undefined)[]): string return joined.length > 0 ? joined : undefined; } +function autoApprovalSandboxContext( + snapshot: SandboxDiagnosticsSnapshot, +): NonNullable<AutoApprovalReviewContext['sandbox']> { + const { command, filesystem } = snapshot.capabilities; + return { + platform: snapshot.platform, + profileName: snapshot.profile.name, + fileSystem: snapshot.profile.fileSystem, + network: snapshot.profile.network, + commandSandbox: formatSandboxCapability(command), + filesystemSandbox: formatSandboxCapability(filesystem), + ...(command.selectionReason ? { commandSandboxSelectionReason: command.selectionReason } : {}), + ...(filesystem.selectionReason + ? { filesystemSandboxSelectionReason: filesystem.selectionReason } + : {}), + ...(command.failure ? { commandSandboxFailureReason: command.failure.reason } : {}), + ...(filesystem.failure ? { filesystemSandboxFailureReason: filesystem.failure.reason } : {}), + }; +} + +function formatSandboxCapability(capability: SandboxDiagnosticCapability): string { + return capability.backend === 'none' + ? capability.status + : `${capability.status} (${capability.backend})`; +} + // ============================================================================ // Constructor input — single object matches @kabi's BackendRegistry call site // ============================================================================ @@ -427,6 +461,16 @@ export interface AiSdkBackendInput { /** Canonical-named tools available this session. Backend wraps each with * permission gating before passing to ai-sdk. */ tools: MakaTool[]; + /** Active profile and enforcement capability snapshot for this session backend. */ + sandboxDiagnosticsSnapshot?: SandboxDiagnosticsSnapshot; + /** Diagnostic-only Plan Mode/execution identity snapshot. */ + planTraceContext?: { + mode: 'agent' | 'plan'; + storeVersion: number; + planId?: string; + proposalId?: string; + executionId?: string; + }; /** Trusted identity for expert-team lead/member collaboration tools. */ agentTeam?: AgentTeamExecutionContext; /** @@ -620,6 +664,7 @@ export class AiSdkBackend implements AgentBackend { private abortController: AbortController | null = null; private currentTurnId: string | null = null; private stopAfterStepRequested = false; + private handoffStopReason: CompleteEvent['stopReason'] | undefined; private currentInvocationId: string | null = null; /** * User messages steered into the running turn, drained from the caller's @@ -745,6 +790,9 @@ export class AiSdkBackend implements AgentBackend { }), getAutoApprovalReviewContext: () => ({ ...(this.currentUserIntent !== undefined ? { userIntent: this.currentUserIntent } : {}), + ...(input.sandboxDiagnosticsSnapshot + ? { sandbox: autoApprovalSandboxContext(input.sandboxDiagnosticsSnapshot) } + : {}), }), }); } @@ -880,6 +928,21 @@ export class AiSdkBackend implements AgentBackend { }); this.currentRunTrace = trace; trace.turnStarted(); + if (this.input.planTraceContext) { + trace.emit('plan', 'plan_context_resolved', 'Plan context resolved', { + ...this.input.planTraceContext, + }); + if (this.input.planTraceContext.executionId) { + trace.emit('plan', 'plan_execution_started', 'Plan execution turn started', { + ...this.input.planTraceContext, + }); + } + } + if (this.input.sandboxDiagnosticsSnapshot) { + trace.sandboxContextResolved( + toSandboxRunTraceProjection(this.input.sandboxDiagnosticsSnapshot), + ); + } const recordProviderRequestCapture = this.input.recordProviderRequestCapture; const providerRequestTraceId = recordProviderRequestCapture ? this.newId() : undefined; const providerRequestTracker = providerRequestTraceId @@ -959,6 +1022,9 @@ export class AiSdkBackend implements AgentBackend { const output = await execute(args, context); const providerError = providerToolError(output); if (providerError) throw new Error(providerError); + if (isPlanToolResult(output)) { + this.handlePlanToolResult(output, turnId, queue); + } return output; }, toModelOutput: @@ -1023,6 +1089,9 @@ export class AiSdkBackend implements AgentBackend { : joinPromptFragments([ await this.resolveTurnTailPrompt(), await this.resolveShellRunContextSummary(), + this.input.sandboxDiagnosticsSnapshot + ? renderSandboxTurnTailPrompt(this.input.sandboxDiagnosticsSnapshot) + : undefined, ]); const currentUserContent = input.continuation ? undefined @@ -1735,9 +1804,10 @@ export class AiSdkBackend implements AgentBackend { // win even when it arrives during post-stream usage persistence. if (this.aborted) throw Object.assign(new Error('aborted'), { name: 'AbortError' }); const stopReason = - this.maxSteps !== undefined && finishReason === 'tool-calls' + this.handoffStopReason ?? + (this.maxSteps !== undefined && finishReason === 'tool-calls' ? 'step_limit' - : this.mapFinishReason(finishReason); + : this.mapFinishReason(finishReason)); trace.modelStreamCompleted(stopReason); queue.push({ type: 'complete', @@ -1889,6 +1959,49 @@ export class AiSdkBackend implements AgentBackend { return this.toolRuntime.wrapToolExecute(tool, turnId, queue); } + private handlePlanToolResult( + result: PlanToolResult, + turnId: string, + queue: AsyncEventQueue<SessionEvent>, + ): void { + if (result.kind === 'plan_submitted') { + const proposal = result.proposal; + queue.push({ + type: 'plan_submitted', + id: this.newId(), + turnId, + ts: this.now(), + planId: proposal.planId, + proposalId: proposal.proposalId, + revision: proposal.revision, + title: proposal.title, + ...(proposal.overview ? { overview: proposal.overview } : {}), + ...(proposal.risks ? { risks: proposal.risks } : {}), + steps: proposal.steps.map((step) => ({ ...step, status: 'pending' })), + }); + this.currentRunTrace?.emit('plan', 'plan_submitted', 'Plan submitted', { + planId: proposal.planId, + proposalId: proposal.proposalId, + revision: proposal.revision, + storeVersion: result.storeVersion, + }); + this.handoffStopReason = 'plan_handoff'; + this.stopAfterStepRequested = true; + return; + } + + const traceType = result.kind; + this.currentRunTrace?.emit('plan', traceType, 'Plan execution state changed', { + planId: result.execution.planId, + proposalId: result.execution.proposalId, + executionId: result.execution.executionId, + storeVersion: result.storeVersion, + }); + if (result.kind === 'plan_execution_completed' || result.kind === 'plan_execution_cancelled') { + this.stopAfterStepRequested = true; + } + } + // -------------------------------------------------------------------------- // Helpers // -------------------------------------------------------------------------- @@ -2757,6 +2870,7 @@ export class AiSdkBackend implements AgentBackend { this.currentUserIntent = undefined; this.currentStepMessageId = null; this.stopAfterStepRequested = false; + this.handoffStopReason = undefined; this.injectedSteeringMessages = []; this.toolRuntime.endTurn(turnId, this.aborted ? 'aborted' : 'completed'); this.aborted = false; @@ -2881,6 +2995,16 @@ function providerToolError(output: unknown): string | undefined { return record.error; } +function isPlanToolResult(output: unknown): output is PlanToolResult { + if (!output || typeof output !== 'object') return false; + return [ + 'plan_submitted', + 'plan_progress_updated', + 'plan_execution_completed', + 'plan_execution_cancelled', + ].includes(String((output as { kind?: unknown }).kind)); +} + export function repairMakaToolCall(input: { toolCall: RepairableAiSdkToolCall; availableToolNames: readonly string[]; diff --git a/packages/runtime/src/ai-sdk-flow.ts b/packages/runtime/src/ai-sdk-flow.ts index f1c54618a34..8e8f2743b74 100644 --- a/packages/runtime/src/ai-sdk-flow.ts +++ b/packages/runtime/src/ai-sdk-flow.ts @@ -476,8 +476,13 @@ function mapBackendSessionEvent( actions: { stateDelta: { planId: event.planId, + ...(event.proposalId ? { proposalId: event.proposalId } : {}), + ...(event.revision !== undefined ? { revision: event.revision } : {}), title: event.title, - markdownPath: event.markdownPath, + ...(event.overview ? { overview: event.overview } : {}), + ...(event.risks ? { risks: event.risks } : {}), + ...(event.markdownPath ? { markdownPath: event.markdownPath } : {}), + ...(event.steps ? { steps: event.steps } : {}), }, }, }; diff --git a/packages/runtime/src/approval-reviewer.ts b/packages/runtime/src/approval-reviewer.ts index 394bfe7d709..cf5b48f26ad 100644 --- a/packages/runtime/src/approval-reviewer.ts +++ b/packages/runtime/src/approval-reviewer.ts @@ -21,10 +21,16 @@ export interface AutoApprovalReviewContext { readonly permissionMode: PermissionMode; readonly userIntent?: string; readonly sandbox?: { + readonly platform?: string; readonly profileName?: string; readonly fileSystem?: string; readonly network?: string; readonly commandSandbox?: string; + readonly filesystemSandbox?: string; + readonly commandSandboxSelectionReason?: string; + readonly filesystemSandboxSelectionReason?: string; + readonly commandSandboxFailureReason?: string; + readonly filesystemSandboxFailureReason?: string; }; } diff --git a/packages/runtime/src/builtin-tools.ts b/packages/runtime/src/builtin-tools.ts index db265ed385f..b407ec8f452 100644 --- a/packages/runtime/src/builtin-tools.ts +++ b/packages/runtime/src/builtin-tools.ts @@ -45,6 +45,7 @@ import type { MakaTool, MakaToolContext } from './tool-runtime.js'; export type { MakaTool, MakaToolContext }; import { withFileWriteLock } from './file-write-lock.js'; import type { SandboxManager } from './sandbox/sandbox-manager.js'; +import { SandboxCommandError } from './sandbox/errors.js'; import { linuxExecutableRoots } from './sandbox/linux-sandbox.js'; import type { SandboxPlatform, SandboxType } from './sandbox/types.js'; import type { ChildFdInput } from './child-fd-input.js'; @@ -213,6 +214,7 @@ export function buildBuiltinTools(options: BuildBuiltinToolsOptions = {}): MakaT command, pty, ctx, + 'background_command', ), } : {}), @@ -756,6 +758,7 @@ function buildExecutorBashTool( const executionResult = { ...result, ...(transformed?.sandboxType ? { sandboxType: transformed.sandboxType } : {}), + ...(transformed?.profileName ? { profileName: transformed.profileName } : {}), sandboxed: transformed?.sandboxType === 'macos-seatbelt' || transformed?.sandboxType === 'linux', }; @@ -800,6 +803,7 @@ function sandboxCommand( command: string, pty: boolean, ctx: MakaToolContext, + domain: 'command' | 'background_command' = 'command', ): | { argv?: readonly string[]; @@ -807,6 +811,7 @@ function sandboxCommand( env?: NodeJS.ProcessEnv; fdInputs?: readonly ChildFdInput[]; sandboxType?: SandboxType; + profileName?: string; } | undefined { const cwd = canonicalExistingPath(ctx.cwd); @@ -815,7 +820,14 @@ function sandboxCommand( const additionalGrant = ctx.permissionContext?.additionalGrant; const escalationGrant = ctx.permissionContext?.sandboxEscalationGrant; if (additionalGrant && escalationGrant) { - throw new Error('Additional permissions and sandbox escalation cannot be applied together.'); + throw new SandboxCommandError({ + domain, + stage: 'validation', + reason: 'conflicting_permission_context', + recoverable: false, + profileName: effective.profile.name ?? effective.profile.type, + message: 'Additional permissions and sandbox escalation cannot be applied together.', + }); } if (escalationGrant) { assertSandboxEscalationGrantForExecution({ grant: escalationGrant, command, cwd }); @@ -823,15 +835,30 @@ function sandboxCommand( if (pty) { if (escalationGrant) return { cwd, env, sandboxType: 'none' }; if (profileRequiresSandbox(effective.profile)) { - throw new Error( - 'PTY Bash is unavailable while the active permission profile requires command sandboxing.', - ); + throw new SandboxCommandError({ + domain, + stage: 'capability', + reason: 'pty_sandbox_unavailable', + recoverable: false, + profileName: effective.profile.name ?? effective.profile.type, + message: + 'PTY Bash is unavailable while the active permission profile requires command sandboxing.', + }); } return undefined; } if (!escalationGrant && !manager.canEnforce({ profile: effective.profile, platform })) { if (profileRequiresSandbox(effective.profile)) { - throw new Error(`Command sandbox is required but unavailable on platform ${platform}.`); + const selection = manager.selectInitial({ profile: effective.profile, platform }); + throw new SandboxCommandError({ + domain, + stage: selection.ok ? 'capability' : 'selection', + reason: selection.ok ? 'backend_not_available' : selection.reason, + backend: selection.sandboxType, + recoverable: false, + profileName: effective.profile.name ?? effective.profile.type, + message: `Command sandbox is required but unavailable on platform ${platform}.`, + }); } return undefined; } @@ -867,7 +894,15 @@ function sandboxCommand( ...(escalationGrant ? { preference: 'forbid' as const } : {}), }); if (!result.ok) { - throw new Error(result.message ?? `Sandbox transform failed: ${result.reason}`); + throw new SandboxCommandError({ + domain, + stage: 'transform', + reason: result.reason, + backend: result.sandboxType, + recoverable: false, + profileName: effective.profile.name ?? effective.profile.type, + message: result.message ?? `Sandbox transform failed: ${result.reason}`, + }); } return { argv: result.exec.argv, @@ -875,6 +910,7 @@ function sandboxCommand( ...(result.exec.env ? { env: { ...result.exec.env } } : {}), ...(result.exec.fdInputs ? { fdInputs: result.exec.fdInputs } : {}), sandboxType: result.exec.sandboxType, + profileName: result.exec.effectiveProfile.name ?? result.exec.effectiveProfile.type, }; } @@ -1017,10 +1053,22 @@ function terminalError( result: Pick<WorkspaceExecResult, 'stdout' | 'stderr' | 'stdoutTruncated' | 'stderrTruncated'> & { sandboxType?: SandboxType; sandboxed?: boolean; + profileName?: string; }, code: number, ): Error { - const error = new Error(message); + const sandboxDenied = isLikelySandboxDenial(result); + const error = sandboxDenied + ? new SandboxCommandError({ + domain: 'command', + stage: 'operation', + reason: 'sandbox_denial', + backend: result.sandboxType, + recoverable: true, + profileName: result.profileName, + message, + }) + : new Error(message); Object.assign(error, { stdout: result.stdout, stderr: result.stderr, @@ -1029,7 +1077,7 @@ function terminalError( code, ...(result.sandboxType ? { sandboxType: result.sandboxType } : {}), sandboxed: result.sandboxed === true, - ...(isLikelySandboxDenial(result) ? { reason: 'sandbox_denial', recoverable: true } : {}), + ...(sandboxDenied ? { reason: 'sandbox_denial', recoverable: true } : {}), }); return error; } diff --git a/packages/runtime/src/filesystem-worker/client.ts b/packages/runtime/src/filesystem-worker/client.ts index 5c77706a408..0cf95d1d17d 100644 --- a/packages/runtime/src/filesystem-worker/client.ts +++ b/packages/runtime/src/filesystem-worker/client.ts @@ -82,10 +82,13 @@ export type FilesystemWorkerClientErrorReason = export class FilesystemWorkerClientError extends Error { readonly code = 'SANDBOX_FILESYSTEM_OPERATION_FAILED'; + readonly domain = 'filesystem' as const; readonly reason: FilesystemWorkerClientErrorReason; readonly stage: 'validation' | 'transform' | 'launch' | 'protocol' | 'operation'; readonly recoverable: boolean; readonly requestId?: string; + readonly backend?: 'none' | 'macos-seatbelt' | 'linux'; + readonly profileName?: string; constructor(input: { reason: FilesystemWorkerClientErrorReason; @@ -93,6 +96,8 @@ export class FilesystemWorkerClientError extends Error { message?: string; recoverable?: boolean; requestId?: string; + backend?: 'none' | 'macos-seatbelt' | 'linux'; + profileName?: string; }) { super(input.message ?? `Filesystem worker failed: ${input.reason}.`); this.name = 'FilesystemWorkerClientError'; @@ -100,6 +105,8 @@ export class FilesystemWorkerClientError extends Error { this.stage = input.stage; this.recoverable = input.recoverable ?? false; this.requestId = input.requestId; + this.backend = input.backend; + this.profileName = input.profileName; } } @@ -219,7 +226,10 @@ export class FilesystemWorkerClient { }, }); if (!transformed.ok) { - throw clientError(transformed.reason, 'transform', requestId, transformed.message); + throw clientError(transformed.reason, 'transform', requestId, transformed.message, false, { + backend: transformed.sandboxType, + profileName: effectiveProfile.name ?? effectiveProfile.type, + }); } let processResult: Awaited<ReturnType<FilesystemWorkerProcessRunner>>; @@ -327,6 +337,17 @@ function clientError( requestId: string, message?: string, recoverable = false, + metadata: { + backend?: 'none' | 'macos-seatbelt' | 'linux'; + profileName?: string; + } = {}, ): FilesystemWorkerClientError { - return new FilesystemWorkerClientError({ reason, stage, requestId, message, recoverable }); + return new FilesystemWorkerClientError({ + reason, + stage, + requestId, + message, + recoverable, + ...metadata, + }); } diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index f31822e7342..c3363f9be52 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -127,6 +127,18 @@ export type { MakaTool, MakaToolContext } from './tool-runtime.js'; export { buildMcpTools, mcpProxyToolName } from './mcp-tools.js'; export type { McpToolProvider, BuildMcpToolsOptions } from './mcp-tools.js'; export { buildAskUserQuestionTool } from './ask-user-question-tool.js'; +export { + buildSubmitPlanTool, + buildUpdatePlanTool, + buildCancelPlanTool, +} from './plan-tools.js'; +export type { PlanToolResult } from './plan-tools.js'; +export { + selectCollaborationTools, + renderPlanModePrompt, + renderPlanExecutionPrompt, + renderInterruptedPlanContext, +} from './plan-mode.js'; export { terminateChildProcessTree } from './process-tree-terminator.js'; export type { AttachmentByteReader } from '@maka/core/attachments'; export type { @@ -376,9 +388,11 @@ export { buildSeatbeltPolicy, createDefaultSandboxManager, createBuiltinSandboxManager, + createSandboxDiagnosticsProvider, createSeatbeltExecArgs, escapeSeatbeltRegex, detectLinuxSandboxCapability, + toSandboxRunTraceProjection, } from './sandbox/index.js'; export type { BuildSeatbeltPolicyInput, @@ -388,6 +402,17 @@ export type { DetectLinuxSandboxCapabilityInput, LinuxBubblewrapBackendOptions, LinuxSandboxCapability, + CreateSandboxDiagnosticsProviderInput, + ResolveSandboxDiagnosticsInput, + SandboxDiagnosticCapability, + SandboxDiagnosticCapabilityStatus, + SandboxDiagnosticFailureReason, + SandboxDiagnosticFailureStage, + SandboxDiagnosticFileSystemMode, + SandboxDiagnosticNetworkMode, + SandboxDiagnosticsProvider, + SandboxDiagnosticsSnapshot, + SandboxRunTraceProjection, } from './sandbox/index.js'; export type { SandboxBackend, @@ -403,6 +428,15 @@ export type { SandboxTransformResult, SandboxType, SandboxablePreference, + SandboxErrorDomain, + SandboxErrorMetadata, + SandboxErrorStage, + SandboxErrorWithMetadata, +} from './sandbox/index.js'; +export { + SandboxCommandError, + sandboxErrorMetadata, + serializeSandboxError, } from './sandbox/index.js'; export { AGENT_CONTEXT_ISOLATED, diff --git a/packages/runtime/src/plan-mode.ts b/packages/runtime/src/plan-mode.ts new file mode 100644 index 00000000000..ff47dce57b7 --- /dev/null +++ b/packages/runtime/src/plan-mode.ts @@ -0,0 +1,119 @@ +import { classifyToolUse } from '@maka/core/permission'; +import type { CollaborationMode } from '@maka/core/collaboration'; +import type { PlanExecution, PlanProposal } from '@maka/core/plan'; + +import type { MakaTool } from './tool-runtime.js'; + +const PLAN_CONTROL_TOOLS = new Set(['SubmitPlan', 'update_plan', 'cancel_plan']); + +export function selectCollaborationTools(input: { + mode: CollaborationMode; + tools: readonly MakaTool[]; + hasActiveExecution: boolean; +}): MakaTool[] { + if (input.mode === 'plan') { + return input.tools.filter((tool) => { + if (tool.name === 'SubmitPlan' || tool.name === 'AskUserQuestion') return true; + if (PLAN_CONTROL_TOOLS.has(tool.name)) return false; + const category = classifyToolUse({ + toolName: tool.name, + args: {}, + ...(tool.categoryHint ? { categoryHint: tool.categoryHint } : {}), + }); + return category === 'read' || category === 'web_read'; + }); + } + + return input.tools.filter((tool) => { + if (tool.name === 'SubmitPlan') return false; + if (tool.categoryHint === 'subagent' && input.hasActiveExecution) return false; + if (tool.name === 'update_plan' || tool.name === 'cancel_plan') { + return input.hasActiveExecution; + } + return true; + }); +} + +export function renderPlanModePrompt(): string { + return [ + '<collaboration_mode>', + '# Collaboration Mode: Plan', + 'You are planning only. Inspect the repository and discuss tradeoffs, but do not modify files or perform side effects.', + 'Use AskUserQuestion only when a bounded answer is required. Subagents are unavailable in this mode.', + 'When the plan is ready for approval, call SubmitPlan exactly once with a concise title, overview, ordered steps, and material risks.', + 'Every step must have a short title (30 characters or fewer) and a detailed description. Both fields must be plain text without Markdown formatting.', + 'Do not claim that implementation has started or completed.', + '</collaboration_mode>', + ].join('\n'); +} + +export function renderInterruptedPlanContext(input: { + proposal: PlanProposal; + execution: PlanExecution; +}): string { + const steps = input.execution.steps.map((step) => renderExecutionStep(step)).join('\n'); + return [ + '<interrupted_plan_context>', + `Plan: ${input.proposal.title}`, + `Plan ID: ${input.proposal.planId}`, + `Proposal: ${input.proposal.proposalId} (revision ${input.proposal.revision})`, + `Interrupted execution ID: ${input.execution.executionId}`, + input.execution.interruptionReason + ? `Interruption reason: ${input.execution.interruptionReason}` + : '', + 'Progress at interruption:', + steps, + 'The user entered Plan Mode to replan the remaining work. Do not resume execution or modify files. A submitted proposal will supersede this interrupted execution when approved.', + '</interrupted_plan_context>', + ] + .filter(Boolean) + .join('\n'); +} + +export function renderPlanExecutionPrompt(input: { + proposal: PlanProposal; + execution: PlanExecution; +}): string { + const steps = input.execution.steps.map((step) => renderExecutionStep(step)).join('\n'); + return [ + '<plan_execution_context>', + `Plan: ${input.proposal.title}`, + `Plan ID: ${input.proposal.planId}`, + `Proposal: ${input.proposal.proposalId} (revision ${input.proposal.revision})`, + `Execution ID: ${input.execution.executionId}`, + input.proposal.overview ? `Overview: ${input.proposal.overview}` : '', + 'Approved steps:', + steps, + 'Execute this approved plan. Before implementation, call update_plan with the first actionable step in_progress and every other step at its current status. Immediately after finishing a step, call update_plan again to mark it completed and move the next step to in_progress. Before the final response, update every finished or skipped step so the execution can close. If the user explicitly abandons the plan, call cancel_plan. Do not delegate to subagents while this execution is active.', + '</plan_execution_context>', + ] + .filter(Boolean) + .join('\n'); +} + +function statusMark(status: PlanExecution['steps'][number]['status']): string { + if (status === 'completed') return 'x'; + if (status === 'in_progress') return '>'; + if (status === 'skipped') return '-'; + return ' '; +} + +function renderExecutionStep(step: PlanExecution['steps'][number]): string { + return [ + '<step>', + `<id>${escapeXml(step.id)}</id>`, + `<title>${escapeXml(step.title)}`, + `${escapeXml(step.description)}`, + `${step.status}`, + '', + ].join('\n'); +} + +function escapeXml(value: string): string { + return value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} diff --git a/packages/runtime/src/plan-tools.ts b/packages/runtime/src/plan-tools.ts new file mode 100644 index 00000000000..202b3a26380 --- /dev/null +++ b/packages/runtime/src/plan-tools.ts @@ -0,0 +1,167 @@ +import { z } from 'zod'; +import type { + PlanExecution, + PlanMutationResult, + PlanProposal, + PlanStepStatus, + PlanStore, +} from '@maka/core/plan'; + +import type { MakaTool } from './tool-runtime.js'; + +const MARKDOWN_PATTERN = + /(^|\n)\s{0,3}(?:#{1,6}\s|[-*+]\s|\d+[.)]\s|>\s|```|~~~)|!?(?:\[[^\]\n]+\]\([^)\n]+\))|(?:\*\*|__|`)/; + +const plainTextSchema = (label: string) => + z + .string() + .trim() + .min(1) + .refine((value) => !MARKDOWN_PATTERN.test(value), { + message: `${label} must be plain text without Markdown formatting`, + }); + +const stepDefinitionSchema = z.object({ + id: z.string().trim().min(1), + title: plainTextSchema('Plan step title').max(30), + description: plainTextSchema('Plan step description'), + files: z.array(z.string().min(1)).optional(), + complexity: z.enum(['low', 'medium', 'high']).optional(), +}); + +const executionStepSchema = z.object({ + id: z.string().min(1), + status: z.enum(['pending', 'in_progress', 'completed', 'skipped']), + note: z.string().min(1).optional(), +}); + +export type PlanToolResult = + | { + kind: 'plan_submitted'; + proposal: PlanProposal; + storeVersion: number; + } + | { + kind: 'plan_progress_updated' | 'plan_execution_completed'; + execution: PlanExecution; + storeVersion: number; + } + | { + kind: 'plan_execution_cancelled'; + execution: PlanExecution; + storeVersion: number; + }; + +export function buildSubmitPlanTool( + planStore: PlanStore, + sourceExecutionId?: string, +): MakaTool< + { + title: string; + overview?: string; + steps: Array<{ + id: string; + title: string; + description: string; + files?: string[]; + complexity?: 'low' | 'medium' | 'high'; + }>; + risks?: string[]; + }, + PlanToolResult +> { + return { + name: 'SubmitPlan', + description: + 'Submit the finished implementation plan for user approval. Every step requires a concise plain-text title and a detailed plain-text description; do not use Markdown in either field. This ends the planning turn, so do not call it until the plan is ready to review.', + parameters: z.object({ + title: z.string().min(1), + overview: z.string().min(1).optional(), + steps: z.array(stepDefinitionSchema).min(1).max(50), + risks: z.array(z.string().min(1)).max(20).optional(), + }), + permissionRequired: false, + impl: async (input, context) => { + const result = await planStore.submitProposal({ + sessionId: context.sessionId, + turnId: context.turnId, + ...(sourceExecutionId ? { sourceExecutionId } : {}), + ...input, + }); + return { + kind: 'plan_submitted', + proposal: result.state.proposals.find( + (proposal) => proposal.proposalId === result.state.latestProposalId, + )!, + storeVersion: result.state.storeVersion, + }; + }, + }; +} + +export function buildUpdatePlanTool( + planStore: PlanStore, + executionId: string, +): MakaTool< + { + steps: Array<{ id: string; status: PlanStepStatus; note?: string }>; + explanation?: string; + }, + PlanToolResult +> { + return { + name: 'update_plan', + description: + 'Update execution progress for the approved plan. Include every plan step and keep at most one step in_progress.', + parameters: z.object({ + steps: z.array(executionStepSchema).min(1).max(50), + explanation: z.string().min(1).optional(), + }), + permissionRequired: false, + impl: async (input, context) => { + const result = await planStore.updateExecution({ + sessionId: context.sessionId, + executionId, + ...input, + }); + return executionResult(result); + }, + }; +} + +export function buildCancelPlanTool( + planStore: PlanStore, + executionId: string, +): MakaTool<{ reason: string }, PlanToolResult> { + return { + name: 'cancel_plan', + description: + 'Cancel the active plan execution when the user explicitly asks to abandon it. Explain the user request in reason.', + parameters: z.object({ reason: z.string().min(1) }), + permissionRequired: false, + impl: async ({ reason }, context) => { + const result = await planStore.cancelExecution({ + sessionId: context.sessionId, + executionId, + reason, + }); + return executionResult(result); + }, + }; +} + +function executionResult(result: PlanMutationResult): PlanToolResult { + const executionId = + 'executionId' in result.event ? result.event.executionId : result.state.activeExecutionId; + const execution = result.state.executions.find( + (candidate) => candidate.executionId === executionId, + ); + if (!execution) throw new Error('Plan execution projection is missing'); + if (result.event.type === 'plan_execution_completed') { + return { kind: 'plan_execution_completed', execution, storeVersion: result.state.storeVersion }; + } + if (result.event.type === 'plan_execution_cancelled') { + return { kind: 'plan_execution_cancelled', execution, storeVersion: result.state.storeVersion }; + } + return { kind: 'plan_progress_updated', execution, storeVersion: result.state.storeVersion }; +} diff --git a/packages/runtime/src/run-trace.ts b/packages/runtime/src/run-trace.ts index f12fb71688c..bda5db7528a 100644 --- a/packages/runtime/src/run-trace.ts +++ b/packages/runtime/src/run-trace.ts @@ -8,6 +8,7 @@ import type { ToolSchemaChangeReason, ToolAvailabilityDiagnostic, } from '@maka/core/usage-stats/types'; +import type { SandboxRunTraceProjection } from './sandbox/diagnostics.js'; export type RunTracePhase = | 'turn' @@ -15,11 +16,22 @@ export type RunTracePhase = | 'tool' | 'permission' | 'sandbox' + | 'plan' | 'abort' | 'usage'; export type RunTraceEventType = | 'turn_started' + | 'sandbox_context_resolved' + | 'plan_context_resolved' + | 'plan_submitted' + | 'plan_execution_started' + | 'plan_progress_updated' + | 'plan_execution_completed' + | 'plan_execution_cancelled' + | 'plan_execution_interrupted' + | 'plan_execution_resumed' + | 'plan_transition_failed' | 'model_resolved' | 'model_resolve_failed' | 'model_stream_started' @@ -55,7 +67,7 @@ export interface RunTraceEvent { data?: Record; } -export type RunTraceRecorder = (event: RunTraceEvent) => void; +export type RunTraceRecorder = (event: RunTraceEvent) => unknown; const REDACTED_ERROR_MESSAGE_MAX_CHARS = 2_048; @@ -90,7 +102,8 @@ export class RunTrace { ...(data ? { data: sanitizeTraceData(data) } : {}), }; try { - this.input.record?.(event); + const recorded = this.input.record?.(event); + if (isPromiseLike(recorded)) void Promise.resolve(recorded).catch(() => {}); } catch { // Tracing is diagnostic-only and must not perturb model/tool execution. } @@ -104,6 +117,10 @@ export class RunTrace { }); } + sandboxContextResolved(snapshot: SandboxRunTraceProjection): void { + this.emit('sandbox', 'sandbox_context_resolved', 'Sandbox context resolved', { snapshot }); + } + modelResolved(): void { this.emit('model', 'model_resolved', 'Model resolved', { connectionSlug: this.input.connectionSlug, @@ -279,3 +296,12 @@ function sha256(value: string): string { function sanitizeTraceData(data: Record): Record { return Object.fromEntries(Object.entries(data).filter(([, value]) => value !== undefined)); } + +function isPromiseLike(value: unknown): value is PromiseLike { + return Boolean( + value && + (typeof value === 'object' || typeof value === 'function') && + 'then' in value && + typeof value.then === 'function', + ); +} diff --git a/packages/runtime/src/runtime-event-read-model.ts b/packages/runtime/src/runtime-event-read-model.ts index f87ca874799..5dc531da862 100644 --- a/packages/runtime/src/runtime-event-read-model.ts +++ b/packages/runtime/src/runtime-event-read-model.ts @@ -182,6 +182,12 @@ export function projectRuntimeEventsToStoredMessages( projected = true; } + if (isPlanProposalStateDelta(event)) { + // Plan proposals render from PlanStore as approval cards. This event is + // still a canonical runtime fact, but intentionally has no legacy chat row. + projected = true; + } + if (event.actions?.permissionDecision) { projected = projectPermissionDecision(event, state, messages) || projected; } @@ -633,12 +639,16 @@ function projectFunctionResponse( }, ); } - const archivedPlaceholder = isArchivedToolResultPlaceholder(event.content.result) - ? event.content.result + const legacyPlanResult = isLegacyPlanToolResult(event.content.result) + ? { kind: 'json' as const, value: event.content.result } + : undefined; + const compatibleResult = legacyPlanResult ?? event.content.result; + const archivedPlaceholder = isArchivedToolResultPlaceholder(compatibleResult) + ? compatibleResult : undefined; const normalizedShellResult = archivedPlaceholder ? { state: 'not_shell' as const } - : normalizeShellToolResultContent(event.content.result); + : normalizeShellToolResultContent(compatibleResult); if (normalizedShellResult.state === 'invalid') { diagnostic( state, @@ -651,7 +661,7 @@ function projectFunctionResponse( if ( !archivedPlaceholder && normalizedShellResult.state === 'not_shell' && - !isToolResultContent(event.content.result) + !isToolResultContent(compatibleResult) ) { diagnostic( state, @@ -694,7 +704,7 @@ function projectFunctionResponse( } : normalizedShellResult.state === 'valid' ? normalizedShellResult.content - : (event.content.result as ToolResultContent); + : (compatibleResult as ToolResultContent); messages.push({ type: 'tool_result', id: stableMessageId(event, state, 'tool_result'), @@ -1031,6 +1041,27 @@ function isToolResultContent(value: unknown): value is ToolResultContent { ); } +function isLegacyPlanToolResult(value: unknown): boolean { + if (!value || typeof value !== 'object') return false; + const kind = (value as { kind?: unknown }).kind; + return ( + kind === 'plan_submitted' || + kind === 'plan_progress_updated' || + kind === 'plan_execution_completed' || + kind === 'plan_execution_cancelled' + ); +} + +function isPlanProposalStateDelta(event: RuntimeEvent): boolean { + const stateDelta = event.actions?.stateDelta; + return ( + event.role === 'system' && + event.author === 'agent' && + typeof stateDelta?.planId === 'string' && + typeof stateDelta.title === 'string' + ); +} + function diagnostic( state: ProjectionState, event: RuntimeEvent, diff --git a/packages/runtime/src/sandbox/diagnostics.ts b/packages/runtime/src/sandbox/diagnostics.ts new file mode 100644 index 00000000000..e548a308b4b --- /dev/null +++ b/packages/runtime/src/sandbox/diagnostics.ts @@ -0,0 +1,439 @@ +import { constants } from 'node:fs'; +import { access, realpath } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { resolve } from 'node:path'; + +import { compilePermissionProfile, type PermissionMode, type PermissionProfile } from '@maka/core'; + +import type { FilesystemWorkerLaunchSpecProvider } from '../filesystem-worker/launch-spec.js'; +import type { SandboxManager } from './sandbox-manager.js'; +import type { + SandboxPlatform, + SandboxSelectionReason, + SandboxTransformFailureReason, + SandboxType, +} from './types.js'; + +export type SandboxDiagnosticFileSystemMode = + | 'read-only' + | 'workspace-write' + | 'unrestricted' + | 'custom-restricted' + | 'external' + | 'disabled'; + +export type SandboxDiagnosticNetworkMode = 'restricted' | 'enabled' | 'unmanaged'; +export type SandboxDiagnosticCapabilityStatus = + | 'available' + | 'unavailable' + | 'not_required' + | 'external'; +export type SandboxDiagnosticFailureStage = 'selection' | 'transform' | 'launch' | 'capability'; +export type SandboxDiagnosticFailureReason = + | SandboxTransformFailureReason + | 'filesystem_worker_unavailable' + | 'worker_bundle_unavailable' + | 'runtime_executable_unavailable' + | 'executable_unavailable' + | 'capability_probe_failed'; + +export interface SandboxDiagnosticCapability { + readonly status: SandboxDiagnosticCapabilityStatus; + readonly backend: SandboxType; + readonly selectionReason?: SandboxSelectionReason; + readonly failure?: { + readonly stage: SandboxDiagnosticFailureStage; + readonly reason: SandboxDiagnosticFailureReason; + }; +} + +export interface SandboxDiagnosticsSnapshot { + readonly schemaVersion: 1; + readonly platform: string; + readonly profile: { + readonly name: string; + readonly type: PermissionProfile['type']; + readonly fileSystem: SandboxDiagnosticFileSystemMode; + readonly network: SandboxDiagnosticNetworkMode; + readonly cwd: string; + readonly workspaceRoots: readonly string[]; + readonly protectedMetadata: readonly string[]; + }; + readonly capabilities: { + readonly command: SandboxDiagnosticCapability; + readonly filesystem: SandboxDiagnosticCapability; + }; +} + +/** Path-free projection suitable for durable diagnostics and telemetry. */ +export interface SandboxRunTraceProjection { + readonly schemaVersion: 1; + readonly platform: string; + readonly profile: { + readonly name: string; + readonly type: PermissionProfile['type']; + readonly fileSystem: SandboxDiagnosticFileSystemMode; + readonly network: SandboxDiagnosticNetworkMode; + readonly protectedMetadata: readonly string[]; + }; + readonly capabilities: SandboxDiagnosticsSnapshot['capabilities']; +} + +export interface ResolveSandboxDiagnosticsInput { + mode: PermissionMode; + cwd: string; + workspaceRoots?: readonly string[]; + permissionProfile?: PermissionProfile; +} + +export interface SandboxDiagnosticsProvider { + resolve(input: ResolveSandboxDiagnosticsInput): Promise; +} + +export interface CreateSandboxDiagnosticsProviderInput { + sandboxManager?: SandboxManager; + getFilesystemWorkerLaunchSpec?: FilesystemWorkerLaunchSpecProvider; + platform?: SandboxPlatform; + isExecutable?: (path: string) => Promise; + canonicalizePath?: (path: string) => Promise; +} + +export function createSandboxDiagnosticsProvider( + input: CreateSandboxDiagnosticsProviderInput, +): SandboxDiagnosticsProvider { + return { + resolve: async (request) => { + const canonicalize = input.canonicalizePath ?? canonicalPath; + const cwd = await canonicalize(request.cwd); + const workspaceRoots = await Promise.all((request.workspaceRoots ?? [cwd]).map(canonicalize)); + const compiled = request.permissionProfile + ? { profile: request.permissionProfile, workspaceRoots } + : compilePermissionProfile({ mode: request.mode, cwd, workspaceRoots }); + const platform = input.platform ?? process.platform; + const capabilityInput: ProbeCapabilityInput = { + profile: compiled.profile, + cwd, + workspaceRoots: compiled.workspaceRoots, + platform, + sandboxManager: input.sandboxManager, + isExecutable: input.isExecutable ?? defaultIsExecutable, + }; + const command = await probeCommandCapability(capabilityInput); + const filesystem = await probeFilesystemCapability({ + ...capabilityInput, + getLaunchSpec: input.getFilesystemWorkerLaunchSpec, + }); + + return { + schemaVersion: 1, + platform, + profile: { + name: profileName(compiled.profile), + type: compiled.profile.type, + fileSystem: summarizeFileSystem(compiled.profile), + network: summarizeNetwork(compiled.profile), + cwd, + workspaceRoots: [...new Set(compiled.workspaceRoots)], + protectedMetadata: protectedMetadataNames(compiled.profile), + }, + capabilities: { command, filesystem }, + }; + }, + }; +} + +export function toSandboxRunTraceProjection( + snapshot: SandboxDiagnosticsSnapshot, +): SandboxRunTraceProjection { + return { + schemaVersion: snapshot.schemaVersion, + platform: snapshot.platform, + profile: { + name: snapshot.profile.name, + type: snapshot.profile.type, + fileSystem: snapshot.profile.fileSystem, + network: snapshot.profile.network, + protectedMetadata: [...snapshot.profile.protectedMetadata], + }, + capabilities: { + command: cloneCapability(snapshot.capabilities.command), + filesystem: cloneCapability(snapshot.capabilities.filesystem), + }, + }; +} + +interface ProbeCapabilityInput { + profile: PermissionProfile; + cwd: string; + workspaceRoots: readonly string[]; + platform: SandboxPlatform; + sandboxManager?: SandboxManager; + isExecutable: (path: string) => Promise; +} + +async function probeCommandCapability( + input: ProbeCapabilityInput, +): Promise { + const passive = passiveCapability(input.profile); + if (passive) return passive; + const manager = input.sandboxManager; + if (!manager) { + return unavailable( + expectedSandboxType(input.platform), + 'selection', + input.platform === 'darwin' || input.platform === 'linux' + ? 'backend_not_available' + : 'unsupported_platform', + ); + } + + const selection = manager.selectInitial({ profile: input.profile, platform: input.platform }); + if (!selection.ok) { + return unavailable( + selection.sandboxType ?? expectedSandboxType(input.platform), + 'selection', + selection.reason, + ); + } + if (selection.sandboxType === 'none') { + return { + status: 'not_required', + backend: 'none', + selectionReason: selection.reason, + }; + } + + try { + const transformed = manager.transform({ + platform: input.platform, + command: { + program: '/bin/sh', + args: ['-c', 'true'], + cwd: input.cwd, + env: {}, + profile: input.profile, + pathContext: { + workspaceRoots: input.workspaceRoots, + tmpdir: await canonicalPath(tmpdir()), + slashTmp: await canonicalPath('/tmp'), + }, + }, + }); + if (!transformed.ok) { + return unavailable( + transformed.sandboxType ?? selection.sandboxType, + 'transform', + transformed.reason, + selection.reason, + ); + } + const executable = transformed.exec.argv[0]; + if (!executable || !(await safelyCheckExecutable(executable, input.isExecutable))) { + return unavailable( + transformed.sandboxType, + 'capability', + 'executable_unavailable', + selection.reason, + ); + } + return { + status: 'available', + backend: transformed.sandboxType, + selectionReason: selection.reason, + }; + } catch { + return unavailable( + selection.sandboxType, + 'capability', + 'capability_probe_failed', + selection.reason, + ); + } +} + +async function probeFilesystemCapability( + input: ProbeCapabilityInput & { getLaunchSpec?: FilesystemWorkerLaunchSpecProvider }, +): Promise { + const passive = passiveCapability(input.profile); + if (passive) return passive; + if (!input.sandboxManager) { + return unavailable(expectedSandboxType(input.platform), 'selection', 'backend_not_available'); + } + + const selection = input.sandboxManager.selectInitial({ + profile: input.profile, + platform: input.platform, + }); + if (!selection.ok) { + return unavailable( + selection.sandboxType ?? expectedSandboxType(input.platform), + 'selection', + selection.reason, + ); + } + if (!input.getLaunchSpec) { + return unavailable( + selection.sandboxType, + 'launch', + 'filesystem_worker_unavailable', + selection.reason, + ); + } + + let launch: Awaited>; + try { + launch = await input.getLaunchSpec(); + } catch { + return unavailable( + selection.sandboxType, + 'capability', + 'capability_probe_failed', + selection.reason, + ); + } + if (!launch.ok) { + return unavailable(selection.sandboxType, 'launch', launch.reason, selection.reason); + } + + try { + const transformed = input.sandboxManager.transform({ + platform: input.platform, + command: { + program: launch.spec.program, + args: launch.spec.args, + cwd: input.cwd, + env: launch.spec.env, + profile: input.profile, + pathContext: { + workspaceRoots: input.workspaceRoots, + tmpdir: await canonicalPath(tmpdir()), + slashTmp: await canonicalPath('/tmp'), + runtimeReadableRoots: launch.spec.runtimeReadableRoots, + executableRoots: launch.spec.executableRoots, + }, + }, + }); + if (!transformed.ok) { + return unavailable( + transformed.sandboxType ?? selection.sandboxType, + 'transform', + transformed.reason, + selection.reason, + ); + } + const executable = transformed.exec.argv[0]; + if (!executable || !(await safelyCheckExecutable(executable, input.isExecutable))) { + return unavailable( + transformed.sandboxType, + 'capability', + 'executable_unavailable', + selection.reason, + ); + } + return { + status: 'available', + backend: transformed.sandboxType, + selectionReason: selection.reason, + }; + } catch { + return unavailable( + selection.sandboxType, + 'capability', + 'capability_probe_failed', + selection.reason, + ); + } +} + +function passiveCapability(profile: PermissionProfile): SandboxDiagnosticCapability | undefined { + if (profile.type === 'external') return { status: 'external', backend: 'none' }; + if ( + profile.type === 'disabled' || + (profile.type === 'managed' && profile.fileSystem.kind !== 'restricted') + ) { + return { + status: 'not_required', + backend: 'none', + selectionReason: 'sandbox_not_required', + }; + } + return undefined; +} + +function unavailable( + backend: SandboxType, + stage: SandboxDiagnosticFailureStage, + reason: SandboxDiagnosticFailureReason, + selectionReason?: SandboxSelectionReason, +): SandboxDiagnosticCapability { + return { + status: 'unavailable', + backend, + ...(selectionReason ? { selectionReason } : {}), + failure: { stage, reason }, + }; +} + +function expectedSandboxType(platform: SandboxPlatform): SandboxType { + if (platform === 'darwin') return 'macos-seatbelt'; + if (platform === 'linux') return 'linux'; + return 'none'; +} + +function profileName(profile: PermissionProfile): string { + return profile.name ?? profile.type; +} + +function summarizeFileSystem(profile: PermissionProfile): SandboxDiagnosticFileSystemMode { + if (profile.type === 'disabled') return 'disabled'; + if (profile.type === 'external') return 'external'; + if (profile.fileSystem.kind === 'unrestricted') return 'unrestricted'; + if (profile.fileSystem.kind === 'external_sandbox') return 'external'; + if (!profile.fileSystem.entries.some((entry) => entry.access === 'write')) return 'read-only'; + return profile.fileSystem.entries.some( + (entry) => + entry.kind === 'special' && entry.special === ':workspace_roots' && entry.access === 'write', + ) + ? 'workspace-write' + : 'custom-restricted'; +} + +function summarizeNetwork(profile: PermissionProfile): SandboxDiagnosticNetworkMode { + return profile.type === 'disabled' ? 'unmanaged' : profile.network.kind; +} + +function protectedMetadataNames(profile: PermissionProfile): readonly string[] { + if (profile.type !== 'managed') return []; + return [...(profile.fileSystem.protectedMetadata?.names ?? [])]; +} + +function cloneCapability(capability: SandboxDiagnosticCapability): SandboxDiagnosticCapability { + return { + ...capability, + ...(capability.failure ? { failure: { ...capability.failure } } : {}), + }; +} + +async function safelyCheckExecutable( + path: string, + isExecutable: (path: string) => Promise, +): Promise { + try { + return await isExecutable(path); + } catch { + return false; + } +} + +async function defaultIsExecutable(path: string): Promise { + try { + await access(path, constants.X_OK); + return true; + } catch { + return false; + } +} + +async function canonicalPath(path: string): Promise { + return await realpath(path).catch(() => resolve(path)); +} diff --git a/packages/runtime/src/sandbox/errors.ts b/packages/runtime/src/sandbox/errors.ts new file mode 100644 index 00000000000..24afad133ef --- /dev/null +++ b/packages/runtime/src/sandbox/errors.ts @@ -0,0 +1,100 @@ +import type { SandboxType } from './types.js'; + +const SANDBOX_ERROR_DOMAINS = ['command', 'background_command', 'filesystem'] as const; +export type SandboxErrorDomain = (typeof SANDBOX_ERROR_DOMAINS)[number]; +const SANDBOX_ERROR_STAGES = [ + 'capability', + 'context', + 'selection', + 'validation', + 'transform', + 'launch', + 'protocol', + 'operation', +] as const; +export type SandboxErrorStage = (typeof SANDBOX_ERROR_STAGES)[number]; + +const SANDBOX_TYPES = ['none', 'macos-seatbelt', 'linux'] as const; +const STABLE_CODE_PATTERN = /^[a-z0-9_]+$/; +const SAFE_IDENTIFIER_PATTERN = /^[A-Za-z0-9._:-]+$/; +const MAX_METADATA_VALUE_CHARS = 128; + +export interface SandboxErrorMetadata { + domain: SandboxErrorDomain; + stage: SandboxErrorStage; + reason: string; + backend?: SandboxType; + recoverable: boolean; + profileName?: string; + requestId?: string; +} + +export interface SandboxErrorWithMetadata extends Error, SandboxErrorMetadata { + code: string; +} + +export class SandboxCommandError extends Error implements SandboxErrorWithMetadata { + readonly code = 'SANDBOX_COMMAND_EXECUTION_FAILED'; + readonly domain: SandboxErrorDomain; + readonly stage: SandboxErrorStage; + readonly reason: string; + readonly backend?: SandboxType; + readonly recoverable: boolean; + readonly profileName?: string; + + constructor(input: SandboxErrorMetadata & { message?: string }) { + super(input.message ?? `Command sandbox failed: ${input.reason}.`); + this.name = 'SandboxCommandError'; + this.domain = input.domain; + this.stage = input.stage; + this.reason = input.reason; + this.backend = input.backend; + this.recoverable = input.recoverable; + this.profileName = input.profileName; + } +} + +export function sandboxErrorMetadata(error: unknown): SandboxErrorMetadata | undefined { + if (!error || typeof error !== 'object') return undefined; + const value = error as Partial; + if ( + !isMember(value.domain, SANDBOX_ERROR_DOMAINS) || + !isMember(value.stage, SANDBOX_ERROR_STAGES) || + !isBoundedMatch(value.reason, STABLE_CODE_PATTERN) || + typeof value.recoverable !== 'boolean' || + (value.backend !== undefined && !isMember(value.backend, SANDBOX_TYPES)) + ) { + return undefined; + } + return { + domain: value.domain, + stage: value.stage, + reason: value.reason, + recoverable: value.recoverable, + ...(value.backend ? { backend: value.backend } : {}), + ...(isBoundedMatch(value.profileName, SAFE_IDENTIFIER_PATTERN) + ? { profileName: value.profileName } + : {}), + ...(isBoundedMatch(value.requestId, SAFE_IDENTIFIER_PATTERN) + ? { requestId: value.requestId } + : {}), + }; +} + +export function serializeSandboxError(error: unknown): Record | undefined { + const metadata = sandboxErrorMetadata(error); + return metadata ? { ...metadata } : undefined; +} + +function isMember(value: unknown, values: readonly T[]): value is T { + return typeof value === 'string' && values.includes(value as T); +} + +function isBoundedMatch(value: unknown, pattern: RegExp): value is string { + return ( + typeof value === 'string' && + value.length > 0 && + value.length <= MAX_METADATA_VALUE_CHARS && + pattern.test(value) + ); +} diff --git a/packages/runtime/src/sandbox/index.ts b/packages/runtime/src/sandbox/index.ts index b4ab19eb469..b844006d068 100644 --- a/packages/runtime/src/sandbox/index.ts +++ b/packages/runtime/src/sandbox/index.ts @@ -1,4 +1,32 @@ export { SandboxManager } from './sandbox-manager.js'; +export { + createSandboxDiagnosticsProvider, + toSandboxRunTraceProjection, +} from './diagnostics.js'; +export type { + CreateSandboxDiagnosticsProviderInput, + ResolveSandboxDiagnosticsInput, + SandboxDiagnosticCapability, + SandboxDiagnosticCapabilityStatus, + SandboxDiagnosticFailureReason, + SandboxDiagnosticFailureStage, + SandboxDiagnosticFileSystemMode, + SandboxDiagnosticNetworkMode, + SandboxDiagnosticsProvider, + SandboxDiagnosticsSnapshot, + SandboxRunTraceProjection, +} from './diagnostics.js'; +export { + SandboxCommandError, + sandboxErrorMetadata, + serializeSandboxError, +} from './errors.js'; +export type { + SandboxErrorDomain, + SandboxErrorMetadata, + SandboxErrorStage, + SandboxErrorWithMetadata, +} from './errors.js'; export { createBuiltinSandboxManager, createDefaultSandboxManager, diff --git a/packages/runtime/src/session-manager.ts b/packages/runtime/src/session-manager.ts index 8e5fc9b1b34..1a684c4cb7f 100644 --- a/packages/runtime/src/session-manager.ts +++ b/packages/runtime/src/session-manager.ts @@ -45,6 +45,13 @@ import type { import type { PermissionResponse } from '@maka/core/permission'; import type { UserQuestionResponse } from '@maka/core/user-question'; import type { PermissionMode } from '@maka/core/permission'; +import type { CollaborationMode } from '@maka/core/collaboration'; +import type { + ApprovePlanProposalInput, + PlanMutationResult, + PlanSessionState, + PlanStore, +} from '@maka/core/plan'; import { DEFAULT_SESSION_NAME, DEEP_RESEARCH_SESSION_LABEL, @@ -305,6 +312,7 @@ export class BackendRegistry { export interface SessionManagerDeps { store: SessionStore; + planStore?: PlanStore; runStore?: AgentRunStore; runtimeEventStore?: RuntimeEventStore; /** Host capability; RuntimeKernel gates it by the selected backend. */ @@ -419,7 +427,19 @@ export class SessionManager { const ownUpdates = await shellRuns.listSessionUpdates(sessionId); const ownToolCalls = new Set(ownUpdates.map((update) => update.sourceToolCallId)); - const messages = await this.getMessages(sessionId); + let messages: StoredMessage[]; + try { + messages = await this.getMessages(sessionId); + } catch (error) { + if (!(error instanceof RuntimeReadModelError)) throw error; + // ShellRun hydration is a best-effort UI projection. A legacy RuntimeEvent + // incompatibility must not turn its retry loop into a permanent IPC error. + try { + messages = await this.deps.store.readMessages(sessionId); + } catch { + return ownUpdates; + } + } const bashToolCalls = new Set( messages.flatMap((message) => message.type === 'tool_call' && message.toolName === 'Bash' ? [message.id] : [], @@ -494,6 +514,14 @@ export class SessionManager { const recovered = new Set(); for (const session of interrupted) { if (this.runtimeKernel.hasActiveRuns(session.id)) continue; + if (this.deps.planStore) { + const planRecovery = await recoverOr( + policy, + () => this.deps.planStore!.interruptActiveExecution(session.id, 'runtime_recovery'), + null, + ); + if (planRecovery) recovered.add(session.id); + } if (this.deps.shellRuns) { const recoveredShellRuns = await recoverOr( policy, @@ -677,6 +705,136 @@ export class SessionManager { return headerToSummary(next); } + async getPlanState(sessionId: string): Promise { + return this.requirePlanStore().readState(sessionId); + } + + async setCollaborationMode(sessionId: string, mode: CollaborationMode): Promise { + const previous = await this.deps.store.readHeader(sessionId); + const from = previous.collaborationMode ?? 'agent'; + if (from === mode) return headerToSummary(previous); + if (this.runtimeKernel.hasActiveRuns(sessionId)) { + throw new Error('当前对话正在运行,等结束后再切换协作模式。'); + } + if (previous.status === 'waiting_for_user') { + throw new Error('当前有工具调用正在等待确认,处理后再切换协作模式。'); + } + const planState = await this.requirePlanStore().readState(sessionId); + if (mode === 'plan' && planState.activeExecutionId) { + throw new Error('当前计划仍在执行,结束或中断后才能切换到 Plan Mode。'); + } + const latestProposal = planState.proposals.find( + (proposal) => proposal.proposalId === planState.latestProposalId, + ); + if (mode === 'agent' && latestProposal?.status === 'pending_approval') { + throw new Error('当前方案正在等待审批,请明确放弃方案后再退出 Plan Mode。'); + } + + const next = await this.deps.store.updateHeader(sessionId, { + collaborationMode: mode, + }); + await this.deps.store.appendMessage(sessionId, { + type: 'system_note', + id: this.deps.newId(), + ts: this.deps.now(), + kind: 'mode_change', + data: { dimension: 'collaboration', from, to: mode }, + } satisfies SystemNoteMessage); + this.runtimeKernel.updateCachedHeader(sessionId, next); + await this.runtimeKernel.disposeBackend(sessionId); + return headerToSummary(next); + } + + async requestPlanRevision(sessionId: string, proposalId: string): Promise { + const result = await this.requirePlanStore().requestRevision({ sessionId, proposalId }); + const header = await this.deps.store.readHeader(sessionId); + if ((header.collaborationMode ?? 'agent') !== 'plan') { + const next = await this.deps.store.updateHeader(sessionId, { collaborationMode: 'plan' }); + this.runtimeKernel.updateCachedHeader(sessionId, next); + } + await this.runtimeKernel.disposeBackend(sessionId); + return result; + } + + async abandonPlanProposal(sessionId: string, proposalId: string): Promise { + const header = await this.deps.store.readHeader(sessionId); + if (this.runtimeKernel.hasActiveRuns(sessionId)) { + throw new Error('当前对话仍在运行,无法放弃计划。'); + } + if (header.status === 'waiting_for_user') { + throw new Error('当前有工具调用正在等待确认,无法放弃计划。'); + } + const result = await this.requirePlanStore().abandonProposal({ + sessionId, + proposalId, + reason: 'User exited Plan Mode before approval.', + }); + const from = header.collaborationMode ?? 'agent'; + const next = await this.deps.store.updateHeader(sessionId, { collaborationMode: 'agent' }); + if (from !== 'agent') { + await this.deps.store.appendMessage(sessionId, { + type: 'system_note', + id: this.deps.newId(), + ts: this.deps.now(), + kind: 'mode_change', + data: { dimension: 'collaboration', from, to: 'agent' }, + } satisfies SystemNoteMessage); + } + this.runtimeKernel.updateCachedHeader(sessionId, next); + await this.runtimeKernel.disposeBackend(sessionId); + return result; + } + + async approvePlan(input: ApprovePlanProposalInput): Promise { + const header = await this.deps.store.readHeader(input.sessionId); + if (this.runtimeKernel.hasActiveRuns(input.sessionId)) { + throw new Error('当前对话仍在运行,无法批准计划。'); + } + if (header.status === 'waiting_for_user') { + throw new Error('当前有工具调用正在等待确认,无法批准计划。'); + } + const result = await this.requirePlanStore().approveProposal(input); + const next = await this.deps.store.updateHeader(input.sessionId, { + collaborationMode: 'agent', + }); + this.runtimeKernel.updateCachedHeader(input.sessionId, next); + await this.runtimeKernel.disposeBackend(input.sessionId); + return result; + } + + async resumePlanExecution(sessionId: string, executionId: string): Promise { + const result = await this.requirePlanStore().resumeExecution(sessionId, executionId); + const next = await this.deps.store.updateHeader(sessionId, { collaborationMode: 'agent' }); + this.runtimeKernel.updateCachedHeader(sessionId, next); + await this.runtimeKernel.disposeBackend(sessionId); + return result; + } + + async cancelPlanExecution(sessionId: string, executionId: string): Promise { + const planStore = this.requirePlanStore(); + const state = await planStore.readState(sessionId); + const execution = state.executions.find((item) => item.executionId === executionId); + if (execution?.status !== 'interrupted') { + throw new Error('只有已中断的计划可以从这里放弃。'); + } + const result = await planStore.cancelExecution({ + sessionId, + executionId, + reason: 'User abandoned the interrupted plan.', + }); + await this.runtimeKernel.disposeBackend(sessionId); + return result; + } + + async interruptActivePlanExecution( + sessionId: string, + reason: string, + ): Promise { + const result = await this.requirePlanStore().interruptActiveExecution(sessionId, reason); + if (result) await this.runtimeKernel.disposeBackend(sessionId); + return result; + } + async remove(sessionId: string): Promise { const shellRunClose = await this.deps.shellRuns?.terminateSession(sessionId); try { @@ -1304,6 +1462,11 @@ export class SessionManager { return next; } + private requirePlanStore(): PlanStore { + if (!this.deps.planStore) throw new Error('Plan Mode is unavailable on this surface'); + return this.deps.planStore; + } + private async appendTurnState( sessionId: string, turnId: string, @@ -1705,6 +1868,7 @@ export function headerToSummary(h: SessionHeader): SessionSummary { connectionLocked: h.connectionLocked, model: h.model, permissionMode: h.permissionMode ?? 'ask', + collaborationMode: h.collaborationMode ?? 'agent', }; if (h.thinkingLevel !== undefined) summary.thinkingLevel = h.thinkingLevel; if (h.lastMessageAt !== undefined) { @@ -1723,7 +1887,8 @@ export function changesBackendConfig(patch: Partial): boolean { 'llmConnectionSlug' in patch || 'model' in patch || 'thinkingLevel' in patch || - 'cwd' in patch + 'cwd' in patch || + 'collaborationMode' in patch ); } diff --git a/packages/runtime/src/system-prompt/sandbox-context-prompt.ts b/packages/runtime/src/system-prompt/sandbox-context-prompt.ts new file mode 100644 index 00000000000..d54ec1776c9 --- /dev/null +++ b/packages/runtime/src/system-prompt/sandbox-context-prompt.ts @@ -0,0 +1,74 @@ +import type { + SandboxDiagnosticCapability, + SandboxDiagnosticsSnapshot, +} from '../sandbox/diagnostics.js'; + +const MAX_RENDERED_ROOTS = 16; +const MAX_RENDERED_PATH_CHARS = 1_024; + +export function renderSandboxTurnTailPrompt(snapshot: SandboxDiagnosticsSnapshot): string { + const lines = [ + 'Maka runtime sandbox context (authoritative; enforced by the runtime):', + '', + ` Profile: ${sanitizeLine(snapshot.profile.name, 'profile name')}`, + ` File system: ${snapshot.profile.fileSystem}`, + ` Network: ${snapshot.profile.network}`, + ` Working directory: ${renderPath(snapshot.profile.cwd, 'working directory')}`, + ]; + + const additionalRoots = snapshot.profile.workspaceRoots.filter( + (root) => root !== snapshot.profile.cwd, + ); + if (snapshot.profile.fileSystem === 'unrestricted') { + lines.push(' Workspace access: unrestricted by Maka'); + } else if (snapshot.profile.fileSystem === 'disabled') { + lines.push(' Workspace access: not managed by Maka'); + } else if (additionalRoots.length === 0) { + lines.push(' Workspace access: constrained to the current workspace'); + } else { + lines.push(' Workspace roots:'); + for (const root of additionalRoots.slice(0, MAX_RENDERED_ROOTS)) { + lines.push(` - ${renderPath(root, 'workspace root')}`); + } + if (additionalRoots.length > MAX_RENDERED_ROOTS) { + lines.push(` - ${additionalRoots.length - MAX_RENDERED_ROOTS} additional root(s) omitted`); + } + } + + lines.push( + ` Protected metadata: ${renderList(snapshot.profile.protectedMetadata)}`, + ` Command sandbox: ${renderCapability(snapshot.capabilities.command)}`, + ` Filesystem sandbox: ${renderCapability(snapshot.capabilities.filesystem)}`, + '', + ); + return lines.join('\n'); +} + +function renderCapability(capability: SandboxDiagnosticCapability): string { + const details = [ + capability.backend !== 'none' ? capability.backend : undefined, + capability.selectionReason, + capability.failure ? `${capability.failure.stage}:${capability.failure.reason}` : undefined, + ].filter((value): value is string => Boolean(value)); + return details.length === 0 ? capability.status : `${capability.status} (${details.join(', ')})`; +} + +function renderList(values: readonly string[]): string { + return values.length === 0 + ? 'none' + : values.map((value) => sanitizeLine(value, 'metadata name')).join(', '); +} + +function renderPath(value: string, label: string): string { + if (value.length > MAX_RENDERED_PATH_CHARS) { + throw new Error(`Sandbox context ${label} exceeds the rendering limit.`); + } + return sanitizeLine(value, label); +} + +function sanitizeLine(value: string, label: string): string { + if (!value || /[\r\n\t]/.test(value)) { + throw new Error(`Sandbox context ${label} must be a non-empty single-line value.`); + } + return value; +} diff --git a/packages/runtime/src/tool-runtime.ts b/packages/runtime/src/tool-runtime.ts index a22bb034db0..2742d722e4c 100644 --- a/packages/runtime/src/tool-runtime.ts +++ b/packages/runtime/src/tool-runtime.ts @@ -1,4 +1,8 @@ -import { projectAgentSwarmResult, projectToolActivityArgs } from '@maka/core'; +import { + decodeCanonicalToolResultContent, + projectAgentSwarmResult, + projectToolActivityArgs, +} from '@maka/core'; import type { SessionEvent, ToolActivityKind, @@ -62,6 +66,7 @@ import { type ToolRecoveryMode, } from './runtime-commit-sink.js'; import { ChildAgentRunLimiter } from './child-agent-run-limiter.js'; +import { serializeSandboxError } from './sandbox/errors.js'; export type ToolModelOutputPart = | { type: 'text'; text: string } @@ -1323,6 +1328,7 @@ export class ToolRuntime { } catch (err) { if (err instanceof RuntimeCommitBoundaryError) throw err; output.flush(); + const sandboxError = serializeSandboxError(err); const terminalFailure = coerceTerminalFailure( tool, this.input.header.cwd, @@ -1397,6 +1403,7 @@ export class ToolRuntime { durationMs, status: 'error', errorClass: classifyError(err), + ...(sandboxError ? { sandbox: sandboxError } : {}), }); return this.errorReturn(terminalFailure.message); } @@ -1429,6 +1436,7 @@ export class ToolRuntime { durationMs: Math.max(0, this.input.now() - startedAt), status: 'error', errorClass: classifyError(err), + ...(sandboxError ? { sandbox: sandboxError } : {}), }); return this.errorReturn(msg); } finally { @@ -1850,7 +1858,13 @@ function coerceResultContent(raw: unknown): ToolResultContent { if (typeof raw === 'string') return { kind: 'text', text: raw }; if (raw && typeof raw === 'object') { const obj = raw as { kind?: string; text?: string }; - if (typeof obj.kind === 'string') return raw as ToolResultContent; + if (typeof obj.kind === 'string') { + try { + return decodeCanonicalToolResultContent(raw); + } catch { + return { kind: 'json', value: raw }; + } + } if (typeof obj.text === 'string') return { kind: 'text', text: obj.text }; return { kind: 'json', value: raw }; } diff --git a/packages/storage/src/__tests__/plan-store.test.ts b/packages/storage/src/__tests__/plan-store.test.ts new file mode 100644 index 00000000000..884ba454c92 --- /dev/null +++ b/packages/storage/src/__tests__/plan-store.test.ts @@ -0,0 +1,237 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, test } from 'node:test'; + +import { createPlanStore } from '../plan-store.js'; + +describe('FilePlanStore', () => { + test('requires plain-text titles and descriptions for every step', async () => { + await withStore(async (store) => { + await assert.rejects( + store.submitProposal({ + sessionId: 'session-1', + turnId: 'turn-1', + title: 'Plan', + steps: [{ id: 'one', title: '**First step**', description: 'Do the work' }], + }), + /plain text without Markdown formatting/, + ); + await assert.rejects( + store.submitProposal({ + sessionId: 'session-1', + turnId: 'turn-1', + title: 'Plan', + steps: [{ id: 'one', title: 'First step', description: '- Do the work' }], + }), + /plain text without Markdown formatting/, + ); + }); + }); + + test('persists proposal revisions and completes an approved execution', async () => { + await withStore(async (store) => { + const first = await store.submitProposal({ + sessionId: 'session-1', + turnId: 'turn-1', + title: 'First plan', + steps: [{ id: 'inspect', title: 'Inspect code', description: 'Inspect the code' }], + }); + const second = await store.submitProposal({ + sessionId: 'session-1', + turnId: 'turn-2', + title: 'Revised plan', + steps: [ + { id: 'inspect', title: 'Inspect code', description: 'Inspect the code' }, + { id: 'implement', title: 'Implement change', description: 'Implement the change' }, + ], + }); + + assert.equal(first.state.proposals[0]?.status, 'pending_approval'); + assert.equal(second.state.proposals[0]?.status, 'stale'); + assert.equal(second.state.proposals[1]?.revision, 2); + assert.equal(second.state.proposals[1]?.planId, first.state.proposals[0]?.planId); + + const proposal = second.state.proposals[1]!; + const approved = await store.approveProposal({ + sessionId: 'session-1', + proposalId: proposal.proposalId, + expectedRevision: proposal.revision, + expectedStoreVersion: second.state.storeVersion, + }); + assert.equal(approved.state.activeExecutionId, approved.state.executions[0]?.executionId); + + const execution = approved.state.executions[0]!; + const completed = await store.updateExecution({ + sessionId: 'session-1', + executionId: execution.executionId, + steps: execution.steps.map((step) => ({ id: step.id, status: 'completed' })), + }); + assert.equal(completed.event.type, 'plan_execution_completed'); + assert.equal(completed.state.activeExecutionId, undefined); + assert.equal((await store.readState('session-1')).executions[0]?.status, 'completed'); + + const nextPlan = await store.submitProposal({ + sessionId: 'session-1', + turnId: 'turn-3', + title: 'A different plan', + steps: [{ id: 'new', title: 'Start task', description: 'Start a different task' }], + }); + assert.notEqual(nextPlan.state.proposals[2]?.planId, proposal.planId); + assert.equal(nextPlan.state.proposals[2]?.revision, 1); + }); + }); + + test('serializes concurrent approval and returns the same execution id', async () => { + await withStore(async (store) => { + const submitted = await store.submitProposal({ + sessionId: 'session-1', + turnId: 'turn-1', + title: 'Plan', + steps: [{ id: 'one', title: 'First step', description: 'One' }], + }); + const proposal = submitted.state.proposals[0]!; + const input = { + sessionId: 'session-1', + proposalId: proposal.proposalId, + expectedRevision: proposal.revision, + }; + const [left, right] = await Promise.all([ + store.approveProposal(input), + store.approveProposal(input), + ]); + assert.equal(left.state.executions.length, 1); + assert.equal(right.state.executions.length, 1); + assert.equal(left.state.activeExecutionId, right.state.activeExecutionId); + }); + }); + + test('abandons only the latest pending proposal without deleting its history', async () => { + await withStore(async (store) => { + const submitted = await store.submitProposal({ + sessionId: 'session-1', + turnId: 'turn-1', + title: 'Pending plan', + steps: [{ id: 'one', title: 'First step', description: 'One' }], + }); + const proposal = submitted.state.proposals[0]!; + + const abandoned = await store.abandonProposal({ + sessionId: 'session-1', + proposalId: proposal.proposalId, + reason: 'User exited Plan Mode', + }); + + assert.equal(abandoned.event.type, 'plan_abandoned'); + assert.equal(abandoned.state.proposals[0]?.status, 'stale'); + assert.equal(abandoned.state.proposals[0]?.title, 'Pending plan'); + await assert.rejects( + store.approveProposal({ + sessionId: 'session-1', + proposalId: proposal.proposalId, + expectedRevision: proposal.revision, + }), + /latest pending plan proposal/, + ); + }); + }); + + test('replans an interrupted execution and retires it when the revision is approved', async () => { + await withStore(async (store) => { + const submitted = await store.submitProposal({ + sessionId: 'session-1', + turnId: 'turn-1', + title: 'Original plan', + steps: [{ id: 'one', title: 'First step', description: 'One' }], + }); + const originalProposal = submitted.state.proposals[0]!; + const approved = await store.approveProposal({ + sessionId: 'session-1', + proposalId: originalProposal.proposalId, + expectedRevision: originalProposal.revision, + }); + const originalExecution = approved.state.executions[0]!; + await store.interruptActiveExecution('session-1', 'User stopped execution'); + + const replanned = await store.submitProposal({ + sessionId: 'session-1', + turnId: 'turn-2', + title: 'Replanned remaining work', + sourceExecutionId: originalExecution.executionId, + steps: [{ id: 'one', title: 'Finish differently', description: 'Finish one differently' }], + }); + const revision = replanned.state.proposals[1]!; + assert.equal(revision.planId, originalProposal.planId); + assert.equal(revision.revision, 2); + assert.equal(revision.sourceExecutionId, originalExecution.executionId); + + const replacement = await store.approveProposal({ + sessionId: 'session-1', + proposalId: revision.proposalId, + expectedRevision: revision.revision, + }); + assert.equal(replacement.state.executions[0]?.status, 'cancelled'); + assert.match(replacement.state.executions[0]?.cancelReason ?? '', /Replanned by proposal/); + assert.equal(replacement.state.executions[1]?.status, 'active'); + assert.equal( + replacement.state.activeExecutionId, + replacement.state.executions[1]?.executionId, + ); + }); + }); + + test('allows the user to abandon an interrupted execution', async () => { + await withStore(async (store) => { + const submitted = await store.submitProposal({ + sessionId: 'session-1', + turnId: 'turn-1', + title: 'Interrupted plan', + steps: [{ id: 'one', title: 'First step', description: 'One' }], + }); + const proposal = submitted.state.proposals[0]!; + const approved = await store.approveProposal({ + sessionId: 'session-1', + proposalId: proposal.proposalId, + expectedRevision: proposal.revision, + }); + const execution = approved.state.executions[0]!; + await store.interruptActiveExecution('session-1', 'User stopped execution'); + + const cancelled = await store.cancelExecution({ + sessionId: 'session-1', + executionId: execution.executionId, + reason: 'User abandoned the interrupted plan', + }); + + assert.equal(cancelled.event.type, 'plan_execution_cancelled'); + assert.equal(cancelled.state.executions[0]?.status, 'cancelled'); + assert.equal(cancelled.state.activeExecutionId, undefined); + await assert.rejects( + store.cancelExecution({ + sessionId: 'session-1', + executionId: execution.executionId, + reason: 'Cancel twice', + }), + /cannot be cancelled/, + ); + }); + }); + + test('interrupt is a no-op when a queued mutation already ended the execution', async () => { + await withStore(async (store) => { + assert.equal(await store.interruptActiveExecution('session-1', 'shutdown'), null); + }); + }); +}); + +async function withStore( + run: (store: ReturnType) => Promise, +): Promise { + const root = await mkdtemp(join(tmpdir(), 'maka-plan-store-')); + try { + await run(createPlanStore(root)); + } finally { + await rm(root, { recursive: true, force: true }); + } +} diff --git a/packages/storage/src/__tests__/session-store.test.ts b/packages/storage/src/__tests__/session-store.test.ts index 0b03cdd0943..ff1f0fec686 100644 --- a/packages/storage/src/__tests__/session-store.test.ts +++ b/packages/storage/src/__tests__/session-store.test.ts @@ -41,12 +41,14 @@ describe('FileSessionStore CRUD', () => { const header = await store.create(makeInput({ name: 'Status' })); assert.equal(header.status, 'active'); + assert.equal(header.collaborationMode, 'agent'); assert.equal(typeof header.statusUpdatedAt, 'number'); const [summary] = await store.list(); assert.equal(summary?.status, 'active'); assert.equal(summary?.statusUpdatedAt, header.statusUpdatedAt); assert.equal(summary?.model, 'fake-model'); assert.equal(summary?.cwd, '/tmp/cwd'); + assert.equal(summary?.collaborationMode, 'agent'); }); }); diff --git a/packages/storage/src/index.ts b/packages/storage/src/index.ts index 5eaea856672..fcdbd2ed856 100644 --- a/packages/storage/src/index.ts +++ b/packages/storage/src/index.ts @@ -22,6 +22,7 @@ export * from './artifact-store.js'; export * from './artifact-attachments.js'; export * from './provider-request-capture-artifact.js'; export * from './plan-reminder-store.js'; +export * from './plan-store.js'; export * from './task-ledger-store.js'; export * from './foreign-session-store.js'; export * from './agent-mailbox-store.js'; diff --git a/packages/storage/src/plan-store.ts b/packages/storage/src/plan-store.ts new file mode 100644 index 00000000000..d1e2e6bef32 --- /dev/null +++ b/packages/storage/src/plan-store.ts @@ -0,0 +1,622 @@ +import { randomUUID } from 'node:crypto'; +import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'; +import { dirname, join, resolve } from 'node:path'; +import { + PlanConflictError, + activePlanExecution, + type AbandonPlanProposalInput, + emptyPlanSessionState, + latestPlanProposal, + type ApprovePlanProposalInput, + type CancelPlanExecutionInput, + type PlanEvent, + type PlanExecution, + type PlanExecutionStep, + type PlanMutationResult, + type PlanProposal, + type PlanSessionState, + type PlanStepDefinition, + type PlanStore, + type RequestPlanRevisionInput, + type SubmitPlanProposalInput, + type UpdatePlanExecutionInput, +} from '@maka/core/plan'; +import { appendJsonl } from './jsonl-append.js'; +import { classifyJsonRecord } from './json-prefix.js'; +import { chainWrite } from './write-queue.js'; + +const SAFE_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/; + +export interface CreatePlanStoreOptions { + newId?: () => string; + now?: () => number; +} + +export function createPlanStore( + workspaceRoot: string, + options: CreatePlanStoreOptions = {}, +): PlanStore { + return new FilePlanStore(workspaceRoot, options); +} + +class FilePlanStore implements PlanStore { + private readonly durabilityRoot: string; + private readonly sessionsRoot: string; + private readonly queues = new Map>(); + private readonly newId: () => string; + private readonly now: () => number; + + constructor(workspaceRoot: string, options: CreatePlanStoreOptions) { + this.durabilityRoot = resolve(workspaceRoot); + this.sessionsRoot = join(this.durabilityRoot, 'sessions'); + this.newId = options.newId ?? randomUUID; + this.now = options.now ?? Date.now; + } + + async readState(sessionId: string): Promise { + return (await this.readLedger(sessionId)).state; + } + + async submitProposal(input: SubmitPlanProposalInput): Promise { + return this.mutate(input.sessionId, async (state) => { + const title = requiredText(input.title, 'Plan title'); + const steps = normalizeDefinitions(input.steps); + const latest = latestPlanProposal(state); + if (state.activeExecutionId) { + throw new PlanConflictError('Cannot submit a new proposal while a plan is executing'); + } + const sourceExecution = input.sourceExecutionId + ? executionById(state, input.sourceExecutionId) + : undefined; + if (sourceExecution && sourceExecution.status !== 'interrupted') { + throw new PlanConflictError('Only an interrupted execution can be replanned'); + } + const revisesLatest = + latest !== undefined && + (latest.status !== 'approved' || sourceExecution?.proposalId === latest.proposalId); + const planId = revisesLatest ? latest.planId : this.newId(); + const proposalId = this.newId(); + const submittedAt = this.now(); + const proposal: PlanProposal = { + planId, + proposalId, + sessionId: input.sessionId, + turnId: input.turnId, + revision: revisesLatest ? latest.revision + 1 : 1, + ...(revisesLatest ? { supersedesProposalId: latest.proposalId } : {}), + ...(sourceExecution ? { sourceExecutionId: sourceExecution.executionId } : {}), + title, + ...(optionalText(input.overview) ? { overview: optionalText(input.overview) } : {}), + steps, + ...(input.risks && input.risks.length > 0 + ? { risks: input.risks.map((risk) => requiredText(risk, 'Plan risk')) } + : {}), + status: 'pending_approval', + submittedAt, + }; + return { + type: 'plan_submitted', + id: this.newId(), + sessionId: input.sessionId, + ts: submittedAt, + storeVersion: state.storeVersion + 1, + proposal, + }; + }); + } + + async requestRevision(input: RequestPlanRevisionInput): Promise { + return this.mutate(input.sessionId, async (state) => { + const proposal = proposalById(state, input.proposalId); + if (proposal.status === 'stale') { + throw new PlanConflictError('Plan proposal is already stale'); + } + if (proposal.status === 'approved') { + throw new PlanConflictError('An approved plan proposal cannot be revised'); + } + if (state.latestProposalId !== proposal.proposalId) { + throw new PlanConflictError('Only the latest plan proposal can be revised'); + } + return { + type: 'plan_revision_requested', + id: this.newId(), + sessionId: input.sessionId, + ts: this.now(), + storeVersion: state.storeVersion + 1, + proposalId: proposal.proposalId, + }; + }); + } + + async abandonProposal(input: AbandonPlanProposalInput): Promise { + return this.mutate(input.sessionId, async (state) => { + const proposal = proposalById(state, input.proposalId); + if ( + proposal.status !== 'pending_approval' || + state.latestProposalId !== proposal.proposalId + ) { + throw new PlanConflictError('Only the latest pending plan proposal can be abandoned'); + } + return { + type: 'plan_abandoned', + id: this.newId(), + sessionId: input.sessionId, + ts: this.now(), + storeVersion: state.storeVersion + 1, + proposalId: proposal.proposalId, + reason: requiredText(input.reason, 'Plan abandonment reason'), + }; + }); + } + + async approveProposal(input: ApprovePlanProposalInput): Promise { + let duplicate: PlanMutationResult | undefined; + const result = await this.mutateOptional(input.sessionId, async (state, events) => { + const proposal = proposalById(state, input.proposalId); + if (proposal.revision !== input.expectedRevision) { + throw new PlanConflictError('Plan proposal revision does not match'); + } + if (proposal.status === 'approved') { + const prior = [...events] + .reverse() + .find( + (event): event is Extract => + event.type === 'plan_approved' && event.proposalId === proposal.proposalId, + ); + if (!prior) throw new PlanConflictError('Approved plan execution is missing'); + duplicate = { event: prior, state }; + return null; + } + if ( + input.expectedStoreVersion !== undefined && + state.storeVersion !== input.expectedStoreVersion + ) { + throw new PlanConflictError('Plan state changed before approval'); + } + if ( + proposal.status !== 'pending_approval' || + state.latestProposalId !== proposal.proposalId + ) { + throw new PlanConflictError('Only the latest pending plan proposal can be approved'); + } + if (state.activeExecutionId) { + throw new PlanConflictError('This session already has an active plan execution'); + } + if (proposal.sourceExecutionId) { + const sourceExecution = executionById(state, proposal.sourceExecutionId); + if (sourceExecution.status !== 'interrupted') { + throw new PlanConflictError('The execution being replanned is no longer interrupted'); + } + } + const startedAt = this.now(); + const execution: PlanExecution = { + executionId: this.newId(), + planId: proposal.planId, + proposalId: proposal.proposalId, + sessionId: input.sessionId, + status: 'active', + steps: proposal.steps.map((step) => ({ + ...structuredClone(step), + status: 'pending', + updatedAt: startedAt, + })), + startedAt, + updatedAt: startedAt, + }; + return { + type: 'plan_approved', + id: this.newId(), + sessionId: input.sessionId, + ts: startedAt, + storeVersion: state.storeVersion + 1, + proposalId: proposal.proposalId, + execution, + }; + }); + if (duplicate) return duplicate; + if (!result) throw new Error('Plan approval completed without a result'); + return result; + } + + async updateExecution(input: UpdatePlanExecutionInput): Promise { + return this.mutate(input.sessionId, async (state) => { + const execution = requireActiveExecution(state, input.executionId); + const steps = mergeExecutionSteps(execution, input.steps, this.now()); + const completed = steps.every( + (step) => step.status === 'completed' || step.status === 'skipped', + ); + return completed + ? { + type: 'plan_execution_completed', + id: this.newId(), + sessionId: input.sessionId, + ts: this.now(), + storeVersion: state.storeVersion + 1, + executionId: execution.executionId, + steps, + } + : { + type: 'plan_progress_updated', + id: this.newId(), + sessionId: input.sessionId, + ts: this.now(), + storeVersion: state.storeVersion + 1, + executionId: execution.executionId, + steps, + ...(optionalText(input.explanation) + ? { explanation: optionalText(input.explanation) } + : {}), + }; + }); + } + + async cancelExecution(input: CancelPlanExecutionInput): Promise { + return this.mutate(input.sessionId, async (state) => { + const execution = requireCancellableExecution(state, input.executionId); + return { + type: 'plan_execution_cancelled', + id: this.newId(), + sessionId: input.sessionId, + ts: this.now(), + storeVersion: state.storeVersion + 1, + executionId: execution.executionId, + reason: requiredText(input.reason, 'Plan cancellation reason'), + }; + }); + } + + async interruptActiveExecution( + sessionId: string, + reason: string, + ): Promise { + return this.mutateOptional(sessionId, async (fresh) => { + const execution = activePlanExecution(fresh); + if (!execution) return null; + return { + type: 'plan_execution_interrupted', + id: this.newId(), + sessionId, + ts: this.now(), + storeVersion: fresh.storeVersion + 1, + executionId: execution.executionId, + reason: requiredText(reason, 'Plan interruption reason'), + }; + }); + } + + async resumeExecution(sessionId: string, executionId: string): Promise { + return this.mutate(sessionId, async (state) => { + if (state.activeExecutionId) { + throw new PlanConflictError('This session already has an active plan execution'); + } + const execution = executionById(state, executionId); + if (execution.status !== 'interrupted') { + throw new PlanConflictError('Only an interrupted plan execution can be resumed'); + } + return { + type: 'plan_execution_resumed', + id: this.newId(), + sessionId, + ts: this.now(), + storeVersion: state.storeVersion + 1, + executionId, + }; + }); + } + + private async mutate( + sessionId: string, + build: (state: PlanSessionState, events: readonly PlanEvent[]) => Promise, + ): Promise { + const result = await this.mutateOptional(sessionId, build); + if (!result) throw new Error('Plan mutation completed without a result'); + return result; + } + + private async mutateOptional( + sessionId: string, + build: (state: PlanSessionState, events: readonly PlanEvent[]) => Promise, + ): Promise { + assertSafeId(sessionId); + let result: PlanMutationResult | null = null; + await chainWrite(this.queues, sessionId, async () => { + const ledger = await this.readLedger(sessionId); + const event = await build(ledger.state, ledger.events); + if (!event) return; + await mkdir(this.sessionDir(sessionId), { recursive: true }); + await appendJsonl(this.eventsPath(sessionId), `${JSON.stringify(event)}\n`, { + durable: true, + durabilityRoot: this.durabilityRoot, + }); + const state = applyPlanEvent(ledger.state, event); + await this.writeProjection(sessionId, state).catch(() => { + // Derived cache only. The append-only event ledger remains authoritative. + }); + result = { event, state }; + }); + return result; + } + + private async readLedger( + sessionId: string, + ): Promise<{ events: PlanEvent[]; state: PlanSessionState }> { + assertSafeId(sessionId); + let text: string; + try { + text = await readFile(this.eventsPath(sessionId), 'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return { events: [], state: emptyPlanSessionState(sessionId) }; + } + throw error; + } + const rawLines = text.split('\n'); + const events: PlanEvent[] = []; + for (let index = 0; index < rawLines.length; index += 1) { + const line = rawLines[index]!; + if (!line.trim()) continue; + try { + events.push(decodePlanEvent(JSON.parse(line), sessionId)); + } catch (error) { + const isLast = index === rawLines.length - 1; + if (isLast && !text.endsWith('\n') && classifyJsonRecord(line) === 'incomplete-prefix') { + continue; + } + throw new Error(`Invalid Plan event at line ${index + 1}`, { cause: error }); + } + } + let state = emptyPlanSessionState(sessionId); + for (const event of events) state = applyPlanEvent(state, event); + return { events, state }; + } + + private async writeProjection(sessionId: string, state: PlanSessionState): Promise { + const path = this.projectionPath(sessionId); + await mkdir(dirname(path), { recursive: true }); + const temp = `${path}.${process.pid}.${this.newId()}.tmp`; + await writeFile(temp, `${JSON.stringify(state)}\n`, { encoding: 'utf8', mode: 0o600 }); + try { + await rename(temp, path); + } finally { + await rm(temp, { force: true }).catch(() => {}); + } + } + + private sessionDir(sessionId: string): string { + return join(this.sessionsRoot, sessionId); + } + + private eventsPath(sessionId: string): string { + return join(this.sessionDir(sessionId), 'plan-events.jsonl'); + } + + private projectionPath(sessionId: string): string { + return join(this.sessionDir(sessionId), 'plans.json'); + } +} + +export function applyPlanEvent(state: PlanSessionState, event: PlanEvent): PlanSessionState { + if (event.sessionId !== state.sessionId) throw new Error('Plan event session mismatch'); + if (event.storeVersion !== state.storeVersion + 1) { + throw new Error('Plan event storeVersion is not contiguous'); + } + const next = structuredClone(state); + next.storeVersion = event.storeVersion; + switch (event.type) { + case 'plan_submitted': { + const prior = latestPlanProposal(next); + if (prior && prior.status === 'pending_approval') prior.status = 'stale'; + next.proposals.push(structuredClone(event.proposal)); + next.latestProposalId = event.proposal.proposalId; + break; + } + case 'plan_revision_requested': + proposalById(next, event.proposalId).status = 'stale'; + break; + case 'plan_abandoned': + proposalById(next, event.proposalId).status = 'stale'; + break; + case 'plan_approved': { + const proposal = proposalById(next, event.proposalId); + proposal.status = 'approved'; + if (proposal.sourceExecutionId) { + const sourceExecution = executionById(next, proposal.sourceExecutionId); + sourceExecution.status = 'cancelled'; + sourceExecution.updatedAt = event.ts; + sourceExecution.cancelledAt = event.ts; + sourceExecution.cancelReason = `Replanned by proposal ${proposal.proposalId}`; + delete sourceExecution.interruptedAt; + delete sourceExecution.interruptionReason; + } + next.executions.push(structuredClone(event.execution)); + next.activeExecutionId = event.execution.executionId; + break; + } + case 'plan_progress_updated': { + const execution = executionById(next, event.executionId); + execution.steps = structuredClone(event.steps); + execution.updatedAt = event.ts; + break; + } + case 'plan_execution_completed': { + const execution = executionById(next, event.executionId); + execution.steps = structuredClone(event.steps); + execution.status = 'completed'; + execution.updatedAt = event.ts; + execution.completedAt = event.ts; + if (next.activeExecutionId === execution.executionId) delete next.activeExecutionId; + break; + } + case 'plan_execution_cancelled': { + const execution = executionById(next, event.executionId); + execution.status = 'cancelled'; + execution.updatedAt = event.ts; + execution.cancelledAt = event.ts; + execution.cancelReason = event.reason; + if (next.activeExecutionId === execution.executionId) delete next.activeExecutionId; + break; + } + case 'plan_execution_interrupted': { + const execution = executionById(next, event.executionId); + execution.status = 'interrupted'; + execution.updatedAt = event.ts; + execution.interruptedAt = event.ts; + execution.interruptionReason = event.reason; + if (next.activeExecutionId === execution.executionId) delete next.activeExecutionId; + break; + } + case 'plan_execution_resumed': { + const execution = executionById(next, event.executionId); + execution.status = 'active'; + execution.updatedAt = event.ts; + delete execution.interruptedAt; + delete execution.interruptionReason; + next.activeExecutionId = execution.executionId; + break; + } + } + return next; +} + +function mergeExecutionSteps( + execution: PlanExecution, + updates: UpdatePlanExecutionInput['steps'], + now: number, +): PlanExecutionStep[] { + if (updates.length !== execution.steps.length) { + throw new PlanConflictError('update_plan must include every execution step'); + } + const byId = new Map(updates.map((step) => [step.id, step])); + if (byId.size !== updates.length) throw new PlanConflictError('Plan step ids must be unique'); + const merged = execution.steps.map((step) => { + const update = byId.get(step.id); + if (!update) throw new PlanConflictError(`Plan step ${step.id} is missing`); + if ( + (step.status === 'completed' || step.status === 'skipped') && + update.status !== step.status + ) { + throw new PlanConflictError(`Terminal plan step ${step.id} cannot be reopened`); + } + return { + ...structuredClone(step), + status: update.status, + ...(optionalText(update.note) ? { note: optionalText(update.note) } : {}), + updatedAt: now, + }; + }); + if (merged.filter((step) => step.status === 'in_progress').length > 1) { + throw new PlanConflictError('Only one plan step may be in progress'); + } + return merged; +} + +function normalizeDefinitions(steps: PlanStepDefinition[]): PlanStepDefinition[] { + if (!Array.isArray(steps) || steps.length === 0 || steps.length > 50) { + throw new PlanConflictError('A plan must contain between 1 and 50 steps'); + } + const normalized = steps.map((step, index) => ({ + id: optionalText(step.id) ?? `step-${index + 1}`, + title: requiredPlainText(step.title, 'Plan step title', 30), + description: requiredPlainText(step.description, 'Plan step description'), + ...(step.files && step.files.length > 0 + ? { files: step.files.map((file) => requiredText(file, 'Plan step file')) } + : {}), + ...(step.complexity ? { complexity: step.complexity } : {}), + })); + if (new Set(normalized.map((step) => step.id)).size !== normalized.length) { + throw new PlanConflictError('Plan step ids must be unique'); + } + return normalized; +} + +function proposalById(state: PlanSessionState, proposalId: string): PlanProposal { + const proposal = state.proposals.find((candidate) => candidate.proposalId === proposalId); + if (!proposal) throw new PlanConflictError(`Unknown plan proposal: ${proposalId}`); + return proposal; +} + +function executionById(state: PlanSessionState, executionId: string): PlanExecution { + const execution = state.executions.find((candidate) => candidate.executionId === executionId); + if (!execution) throw new PlanConflictError(`Unknown plan execution: ${executionId}`); + return execution; +} + +function requireActiveExecution(state: PlanSessionState, executionId: string): PlanExecution { + if (state.activeExecutionId !== executionId) { + throw new PlanConflictError('The plan tool is bound to a stale execution'); + } + const execution = executionById(state, executionId); + if (execution.status !== 'active') { + throw new PlanConflictError('Plan execution is not active'); + } + return execution; +} + +function requireCancellableExecution(state: PlanSessionState, executionId: string): PlanExecution { + const execution = executionById(state, executionId); + if (execution.status !== 'active' && execution.status !== 'interrupted') { + throw new PlanConflictError('Plan execution cannot be cancelled'); + } + if (execution.status === 'active' && state.activeExecutionId !== executionId) { + throw new PlanConflictError('The plan tool is bound to a stale execution'); + } + return execution; +} + +function requiredText(value: string, label: string): string { + const normalized = value.trim(); + if (!normalized) throw new PlanConflictError(`${label} cannot be empty`); + return normalized; +} + +function requiredPlainText(value: string, label: string, maxLength?: number): string { + const normalized = requiredText(value, label); + if (maxLength !== undefined && normalized.length > maxLength) { + throw new PlanConflictError(`${label} must be ${maxLength} characters or fewer`); + } + if ( + /(^|\n)\s{0,3}(?:#{1,6}\s|[-*+]\s|\d+[.)]\s|>\s|```|~~~)|!?(?:\[[^\]\n]+\]\([^)\n]+\))|(?:\*\*|__|`)/.test( + normalized, + ) + ) { + throw new PlanConflictError(`${label} must be plain text without Markdown formatting`); + } + return normalized; +} + +function optionalText(value: string | undefined): string | undefined { + const normalized = value?.trim(); + return normalized ? normalized : undefined; +} + +function assertSafeId(value: string): void { + if (!SAFE_ID_PATTERN.test(value)) throw new Error('Invalid session id'); +} + +function decodePlanEvent(value: unknown, sessionId: string): PlanEvent { + if (!value || typeof value !== 'object') throw new Error('Plan event must be an object'); + const event = value as Partial; + if ( + typeof event.id !== 'string' || + event.sessionId !== sessionId || + typeof event.ts !== 'number' || + !Number.isFinite(event.ts) || + typeof event.storeVersion !== 'number' || + !Number.isSafeInteger(event.storeVersion) || + event.storeVersion < 1 || + ![ + 'plan_submitted', + 'plan_revision_requested', + 'plan_abandoned', + 'plan_approved', + 'plan_progress_updated', + 'plan_execution_completed', + 'plan_execution_cancelled', + 'plan_execution_interrupted', + 'plan_execution_resumed', + ].includes(String(event.type)) + ) { + throw new Error('Invalid Plan event envelope'); + } + return value as PlanEvent; +} diff --git a/packages/storage/src/session-store.ts b/packages/storage/src/session-store.ts index bb74d9a6597..97ba45825ee 100644 --- a/packages/storage/src/session-store.ts +++ b/packages/storage/src/session-store.ts @@ -12,6 +12,7 @@ import { chainWrite } from './write-queue.js'; import { DEFAULT_SESSION_NAME, deriveTurnRecords, + isCollaborationMode, isPermissionMode, isSessionBlockedReason, isSessionStatus, @@ -114,6 +115,7 @@ class FileSessionStore implements SessionStore { connectionLocked: false, model: input.model ?? 'default', permissionMode: input.permissionMode, + collaborationMode: input.collaborationMode ?? 'agent', ...(input.thinkingLevel !== undefined ? { thinkingLevel: input.thinkingLevel } : {}), schemaVersion: 1, }; @@ -544,11 +546,18 @@ export function isSafeSessionId(sessionId: string): boolean { type StoredSessionHeader = Omit< SessionHeader, - 'backend' | 'model' | 'permissionMode' | 'status' | 'blockedReason' | 'titleIsManual' + | 'backend' + | 'model' + | 'permissionMode' + | 'collaborationMode' + | 'status' + | 'blockedReason' + | 'titleIsManual' > & { backend: string; model?: unknown; permissionMode?: unknown; + collaborationMode?: unknown; status?: unknown; blockedReason?: unknown; titleIsManual?: unknown; @@ -574,6 +583,9 @@ function createJsonlCorruptionNote( function migrateHeader(header: StoredSessionHeader, sessionId: string): SessionHeader { const permissionMode = isPermissionMode(header.permissionMode) ? header.permissionMode : 'ask'; + const collaborationMode = isCollaborationMode(header.collaborationMode) + ? header.collaborationMode + : 'agent'; const model = typeof header.model === 'string' && header.model.length > 0 ? header.model : 'default'; const status = resolveMigratedStatus(header); @@ -597,19 +609,43 @@ function migrateHeader(header: StoredSessionHeader, sessionId: string): SessionH : normalizeSessionName(header.name) !== DEFAULT_SESSION_NAME; if (header.backend === 'claude') { return normalizeMigratedHeader( - { ...header, ...statusFields, titleIsManual, backend: 'ai-sdk', model, permissionMode }, + { + ...header, + ...statusFields, + titleIsManual, + backend: 'ai-sdk', + model, + permissionMode, + collaborationMode, + }, sessionId, ); } if (header.backend === 'pi-agent') { return normalizeMigratedHeader( - { ...header, ...statusFields, titleIsManual, backend: 'pi-agent', model, permissionMode }, + { + ...header, + ...statusFields, + titleIsManual, + backend: 'pi-agent', + model, + permissionMode, + collaborationMode, + }, sessionId, ); } if (header.backend === 'pi') { return normalizeMigratedHeader( - { ...header, ...statusFields, titleIsManual, backend: 'pi-agent', model, permissionMode }, + { + ...header, + ...statusFields, + titleIsManual, + backend: 'pi-agent', + model, + permissionMode, + collaborationMode, + }, sessionId, ); } @@ -621,6 +657,7 @@ function migrateHeader(header: StoredSessionHeader, sessionId: string): SessionH backend: header.backend === 'ai-sdk' ? 'ai-sdk' : 'fake', model, permissionMode, + collaborationMode, }, sessionId, ); @@ -660,6 +697,7 @@ function normalizeMigratedHeader(header: SessionHeader, sessionId: string): Sess typeof header.connectionLocked === 'boolean' && typeof header.model === 'string' && isPermissionMode(header.permissionMode) && + isCollaborationMode(header.collaborationMode) && header.schemaVersion === 1; if (!valid) { throw new Error(`Invalid session header for session ${sessionId}: malformed fields`); @@ -709,6 +747,7 @@ function toSummary(header: SessionHeader, messages: StoredMessage[] = []): Sessi connectionLocked: header.connectionLocked, model: header.model, permissionMode: header.permissionMode, + collaborationMode: header.collaborationMode ?? 'agent', ...(header.thinkingLevel !== undefined ? { thinkingLevel: header.thinkingLevel } : {}), }; } diff --git a/packages/ui/src/__tests__/conversation-localization.test.tsx b/packages/ui/src/__tests__/conversation-localization.test.tsx index ac3e5f165ce..4b766819f5b 100644 --- a/packages/ui/src/__tests__/conversation-localization.test.tsx +++ b/packages/ui/src/__tests__/conversation-localization.test.tsx @@ -107,6 +107,33 @@ describe('localized conversation journey', () => { assert.match(en, /Go to model settings/); }); + it('renders the compact Plan Mode switch only when the session action is available', () => { + const inactive = render( + 'zh', + {}} onStop={() => {}} onPlanModeChange={() => {}} />, + ); + assert.match(inactive, /maka-composer-plan-mode-control/); + assert.match(inactive, />Plan {}} + onStop={() => {}} + planModeActive + onPlanModeChange={() => {}} + />, + ); + assert.match(active, /aria-label="Disable Plan Mode"/); + assert.match(active, /aria-checked="true"/); + + const unavailable = render('zh', {}} onStop={() => {}} />); + assert.doesNotMatch(unavailable, /maka-composer-plan-mode-control/); + }); + it('localizes permission and question chrome while preserving raw values', () => { const surface = ( <> diff --git a/packages/ui/src/chat-view.tsx b/packages/ui/src/chat-view.tsx index fabfc3eee85..470f9aac754 100644 --- a/packages/ui/src/chat-view.tsx +++ b/packages/ui/src/chat-view.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useRef, type ReactNode } from 'react'; +import { Fragment, useCallback, useMemo, useRef, type ReactNode } from 'react'; import { Button as BaseButton } from '@base-ui/react/button'; import { AlertTriangle, @@ -82,6 +82,12 @@ export function ChatView(props: { * the regular prompt-suggestion hero shows. */ emptyOverride?: ReactNode; + /** Session-owned records anchored after a durable conversation turn. */ + conversationItems?: ReadonlyArray<{ + id: string; + afterTurnId: string; + content: ReactNode; + }>; /** * Active autonomous-goal indicator for the session, or undefined when no * goal is running. Surfaces the loop (turn counter) with a one-click clear @@ -258,6 +264,16 @@ export function ChatView(props: { (targetTurnId: string) => onLineageBadgeClickRef.current?.(targetTurnId), [], ); + const conversationItemsByTurn = useMemo(() => { + const items = new Map>(); + for (const item of props.conversationItems ?? []) { + const current = items.get(item.afterTurnId) ?? []; + current.push({ id: item.id, content: item.content }); + items.set(item.afterTurnId, current); + } + return items; + }, [props.conversationItems]); + const turnIds = useMemo(() => new Set(turns.map((turn) => turn.turnId)), [turns]); const { highlightedTurnId, onScroll, @@ -294,6 +310,7 @@ export function ChatView(props: { contentClassName="maka-chatContent" > {props.emptyOverride ?? } + {props.conversationItems?.map((item) => {item.content})} ); @@ -395,31 +412,35 @@ export function ChatView(props: { )} {turns.map((turn) => { return ( - + + + {conversationItemsByTurn.get(turn.turnId)?.map((item) => ( + {item.content} + ))} + ); })} {/* #642 fallback: streaming began before the optimistic user turn @@ -439,6 +460,9 @@ export function ChatView(props: { )} + {props.conversationItems + ?.filter((item) => !turnIds.has(item.afterTurnId)) + .map((item) => {item.content})} {/* Defensive: if any tool ended up outside a turn (e.g. legacy sessions without turnId), render those at the very end so they still appear instead of vanishing. materializeTurns already diff --git a/packages/ui/src/composer.tsx b/packages/ui/src/composer.tsx index 999a9651a68..9e263b5a87a 100644 --- a/packages/ui/src/composer.tsx +++ b/packages/ui/src/composer.tsx @@ -30,7 +30,7 @@ import { ComposerMentionPopup, mentionOptionId } from './composer-mention-popup. import { useMentionPopup } from './use-mention-popup.js'; import { ComposerWorkspaceRow, type ComposerBranchPicker, type ComposerWorkspacePicker } from './composer-workspace-row.js'; import type { AttachmentRef, PermissionMode, ProviderType, SessionSummary } from '@maka/core'; -import { Button as UiButton } from './ui.js'; +import { Button as UiButton, Switch } from './ui.js'; import { Textarea as UiTextarea } from './primitives/textarea.js'; import { AttachmentFileCard } from './attachment-file-card.js'; import { Kbd } from './primitives/kbd.js'; @@ -165,6 +165,14 @@ export const Composer = forwardRef< permissionModePending?: boolean; permissionModeDisabledReason?: string; onPermissionModeChange?(mode: PermissionMode): void | Promise; + /** + * Session collaboration mode switch. Agent mode is the implicit default, + * so the composer only exposes whether Plan mode is enabled. + */ + planModeActive?: boolean; + planModePending?: boolean; + planModeDisabledReason?: string; + onPlanModeChange?(active: boolean): void | Promise; /** * Composer mention popups (v1 plain-text tokens; see * docs/archive/composer-mentions-spec-2026-07-14.md). Both are optional and the @@ -652,6 +660,30 @@ export const Composer = forwardRef< disabledReason={props.permissionModeDisabledReason} /> ) : null} + {props.onPlanModeChange ? ( + + {copy.planModeLabel} + { + void props.onPlanModeChange?.(checked); + }} + aria-label={props.planModeActive ? copy.disablePlanMode : copy.enablePlanMode} + title={ + props.planModeDisabledReason + ?? (props.planModeActive ? copy.disablePlanMode : copy.enablePlanMode) + } + /> + + ) : null} {props.disabled ? ( diff --git a/packages/ui/src/conversation-copy.ts b/packages/ui/src/conversation-copy.ts index aa765e37798..8a0a2f569cd 100644 --- a/packages/ui/src/conversation-copy.ts +++ b/packages/ui/src/conversation-copy.ts @@ -80,6 +80,9 @@ export interface ConversationCopy { switchDisabledStreaming: string; switchDisabledRunning: string; switchDisabledPermission: string; + planModeLabel: string; + enablePlanMode: string; + disablePlanMode: string; /** Inline hint shown above the composer when no model connection exists yet. */ noModelHint: string; /** Link-button on that hint that opens Settings · 模型. */ @@ -293,6 +296,7 @@ const CONVERSATION_COPY = { interruptHint: '或点停止中断', addContext: '添加上下文', importText: '导入文本文件', attachFile: '附加文件', expertTeam: '专家团', selectModel: '选择模型', dropToImport: '松开以导入文件内容', addingAttachment: '正在添加附件', add: '添加', addTitle: '添加文件、专家团…', addFileOrDirectory: '添加文件或目录', switchDisabledStreaming: '当前对话正在流式输出,等结束后再切换模型。', switchDisabledRunning: '当前对话正在运行,等结束后再切换模型。', switchDisabledPermission: '当前有工具调用正在等待确认,处理后再切换模型。', + planModeLabel: 'Plan', enablePlanMode: '开启 Plan Mode', disablePlanMode: '退出 Plan Mode', noModelHint: '还没有可用的模型连接,无法发送。', noModelAction: '前往模型设置', noModelSendTitle: '先添加一个模型连接才能发送。', }, model: { @@ -402,6 +406,7 @@ const CONVERSATION_COPY = { interruptHint: 'or click Stop to interrupt', addContext: 'Add context', importText: 'Import text file', attachFile: 'Attach file', expertTeam: 'Expert team', selectModel: 'Choose model', dropToImport: 'Drop to import file contents', addingAttachment: 'Adding attachment', add: 'Add', addTitle: 'Add files or an expert team…', addFileOrDirectory: 'Add file or directory', switchDisabledStreaming: 'Wait for the current response to finish before switching models.', switchDisabledRunning: 'Wait for the current run to finish before switching models.', switchDisabledPermission: 'Resolve the pending tool permission before switching models.', + planModeLabel: 'Plan', enablePlanMode: 'Enable Plan Mode', disablePlanMode: 'Disable Plan Mode', noModelHint: 'No model connection yet, so sending is unavailable.', noModelAction: 'Go to model settings', noModelSendTitle: 'Add a model connection before sending.', }, model: {