diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index eadbc4f83b6..d88a0eb5091 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -137,6 +137,7 @@ declare global { readMessages(sessionId: string): Promise; listTurns(sessionId: string): Promise; retryTurn(sessionId: string, input: RetryTurnInput): Promise; + compact(sessionId: string): Promise; regenerateTurn(sessionId: string, input: RegenerateTurnInput): Promise; branchFromTurn(sessionId: string, input: BranchFromTurnInput): Promise; respondToPermission(sessionId: string, response: PermissionResponse): Promise; diff --git a/apps/desktop/src/main/__tests__/compact-routing-contract.test.ts b/apps/desktop/src/main/__tests__/compact-routing-contract.test.ts new file mode 100644 index 00000000000..c211645d44e --- /dev/null +++ b/apps/desktop/src/main/__tests__/compact-routing-contract.test.ts @@ -0,0 +1,43 @@ +import { strict as assert } from 'node:assert'; +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { describe, it } from 'node:test'; +import { readRendererShellSource } from './renderer-shell-source-helpers.js'; + +const REPO_ROOT = resolve(import.meta.dirname, '../../../../..'); + +// Characterization tests pinning the /compact routing across the desktop +// boundary, so a future change cannot silently drop the interception and send +// the literal "/compact" text to the model. Follows the repo's source-matching +// contract style (see session-open-routing-contract.test.ts). +describe('/compact routing contract', () => { + it('renderer intercepts exact /compact and routes to sessions.compact, never send', async () => { + const shell = await readRendererShellSource('app-shell.tsx'); + const send = shell.match(/async function sendWithAttachments\(text: string\): Promise \{[\s\S]*?\n \}/)?.[0] ?? ''; + + // exact /compact (after trim) is the only trigger + assert.match(send, /if \(text\.trim\(\) === '\/compact'\) \{/); + // routes to compact only when an active session exists — the no-active-session guard + assert.match(send, /if \(activeId\) await window\.maka\.sessions\.compact\(activeId\);/); + // returns early so /compact never falls through to the normal send path + assert.match(send, /return true;/); + // non-compact text still goes through the normal send path + assert.match(send, /const ok = await send\(text, pending\);/); + }); + + it('main sessions:compact IPC drives runtime.compactSession via streamEvents', async () => { + const main = await readFile(resolve(REPO_ROOT, 'apps/desktop/src/main/main.ts'), 'utf8'); + const handler = main.match(/ipcMain\.handle\('sessions:compact'[\s\S]*?\n \}\);/)?.[0] ?? ''; + + assert.match(handler, /await ensureSessionCanSend\(sessionId\);/); + assert.match(handler, /runtime\.compactSession\(sessionId, \{ turnId \}\)/); + assert.match(handler, /streamEvents\(sessionId, runtime\.compactSession/); + }); + + it('preload exposes compact() bound to the sessions:compact IPC', async () => { + const preload = await readFile(resolve(REPO_ROOT, 'apps/desktop/src/preload/preload.ts'), 'utf8'); + const compact = preload.match(/compact\(sessionId: string\): Promise \{[\s\S]*?\n \}/)?.[0] ?? ''; + + assert.match(compact, /ipcRenderer\.invoke\('sessions:compact', sessionId\)/); + }); +}); diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index f2a38b33dba..618c6cdd23d 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -148,6 +148,7 @@ import { resolveSessionSend } from './session-send-resolve.js'; import { buildExploreAgentTool } from './explore-agent-tool.js'; import { buildOfficeDocumentEditTool, buildOfficeDocumentTool } from './office-document-tool.js'; import { + buildLlmHistorySummarizer, loadHistoryCompactBlocksFromArtifacts, persistHistoryCompactBlocksToArtifacts, } from '@maka/runtime'; @@ -624,6 +625,13 @@ backends.register('ai-sdk', async (ctx) => { supportsVision, loadHistoryCompact: (event) => loadHistoryCompactBlocksFromArtifacts(artifactStore, event), writeHistoryCompact: (event) => persistHistoryCompactBlocksToArtifacts(artifactStore, event, { + summarize: buildLlmHistorySummarizer({ + // Reuse the same connection/model the session already drives, so the + // summary stays consistent with the model that will consume it. + resolveModel: () => + getAIModel({ connection, apiKey: apiKey ?? '', modelId: model, fetch: modelFetch }), + maxOutputTokens: 4096, + }), onArtifactCreated: (artifact) => { safeSendToRenderer('artifacts:changed', { reason: 'created', @@ -1193,6 +1201,11 @@ function registerIpc(): void { const turnId = normalized.turnId ?? randomUUID(); void streamEvents(sessionId, runtime.retryTurn(sessionId, { ...normalized, turnId }), turnId); }); + ipcMain.handle('sessions:compact', async (_event, sessionId: string) => { + await ensureSessionCanSend(sessionId); + const turnId = randomUUID(); + void streamEvents(sessionId, runtime.compactSession(sessionId, { turnId }), turnId); + }); ipcMain.handle('sessions:regenerateTurn', async (_event, sessionId: string, input: unknown) => { await ensureSessionCanSend(sessionId); const normalized = normalizeRegenerateTurnInput(input); diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 89d9d8ef0e6..59ca962482a 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -152,6 +152,9 @@ contextBridge.exposeInMainWorld('maka', { } return ipcRenderer.invoke('sessions:send', sessionId, command); }, + compact(sessionId: string): Promise { + return ipcRenderer.invoke('sessions:compact', sessionId); + }, stop(sessionId: string, input?: { source?: 'stop_button' }): Promise { return ipcRenderer.invoke('sessions:stop', sessionId, input); }, diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index ee1afb7bfde..7b579f4fc9f 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -941,6 +941,10 @@ export function AppShell({ } async function sendWithAttachments(text: string): Promise { + if (text.trim() === '/compact') { + if (activeId) await window.maka.sessions.compact(activeId); + return true; + } const pending = pendingAttachments.length > 0 ? pendingAttachments : undefined; const ok = await send(text, pending); if (ok !== false) setPendingByKey((map) => clearPending(map, attachmentDraftKey)); diff --git a/packages/cli/src/runtime-bootstrap.ts b/packages/cli/src/runtime-bootstrap.ts index 74e8270620b..7b5bfce14e6 100644 --- a/packages/cli/src/runtime-bootstrap.ts +++ b/packages/cli/src/runtime-bootstrap.ts @@ -8,6 +8,7 @@ import { SessionManager, buildBuiltinTools, buildDefaultContextBudgetPolicy, + buildLlmHistorySummarizer, buildManualCompactLookupPolicy, buildProviderOptions, buildSubscriptionModelFetch, @@ -105,7 +106,20 @@ export async function createMakaCliRuntimeContext( { highWaterName: 'cli-manual-history-compact' }, ), loadHistoryCompact: (event) => loadHistoryCompactBlocksFromArtifacts(artifactStore, event), - writeHistoryCompact: (event) => persistHistoryCompactBlocksToArtifacts(artifactStore, event), + writeHistoryCompact: (event) => persistHistoryCompactBlocksToArtifacts(artifactStore, event, { + summarize: buildLlmHistorySummarizer({ + // Reuse the same connection/model the session already drives, so the + // summary stays consistent with the model that will consume it. + resolveModel: () => + getAIModel({ + connection: ready.connection, + apiKey: ready.apiKey, + modelId: ready.model, + fetch: modelFetch, + }), + maxOutputTokens: 4096, + }), + }), systemPrompt: async ({ cwd }) => { const settings = await settingsStore.get(); return buildCliSystemPrompt({ settings, cwd }); diff --git a/packages/runtime/src/__tests__/history-compact-summarizer.test.ts b/packages/runtime/src/__tests__/history-compact-summarizer.test.ts new file mode 100644 index 00000000000..bd062636ea5 --- /dev/null +++ b/packages/runtime/src/__tests__/history-compact-summarizer.test.ts @@ -0,0 +1,146 @@ +/** + * Tests for buildLlmHistorySummarizer — the AI-SDK-backed LLM summary that + * replaces the deterministic excerpt draft when wiring injects it. + * + * Run: `npm --workspace @maka/runtime run test` + */ +import { describe, test } from 'node:test'; +import { expect } from '../test-helpers.js'; +import type { RuntimeEvent, RuntimeEventContent } from '@maka/core/runtime-event'; +import type { HistoryCompactWriteInput } from '../ai-sdk-backend.js'; +import { buildLlmHistorySummarizer, type AiSdkGenerateTextLike } from '../history-compact-summarizer.js'; + +const ts = 1_700_000_000_000; +let __seq = 0; +function ev(overrides: Partial & { content?: RuntimeEventContent }): RuntimeEvent { + __seq += 1; + return { + id: `evt-${__seq}`, + invocationId: 'inv-1', + runId: 'run-1', + sessionId: 'sess-1', + turnId: 'turn-1', + ts: ts + __seq, + partial: false, + ...overrides, + } as RuntimeEvent; +} + +function inputWith(events: RuntimeEvent[], abortSignal?: AbortSignal): HistoryCompactWriteInput { + return { + sessionId: 'sess-1', + turnId: 'turn-1', + source: { + draftBlock: {} as HistoryCompactWriteInput['source']['draftBlock'], + foldedRuntimeEvents: events, + }, + limits: { + maxBlocks: 1, + maxBlockEstimatedTokens: 2048, + maxEstimatedTokens: 4096, + charsPerToken: 4, + }, + ...(abortSignal ? { abortSignal } : {}), + }; +} + +describe('buildLlmHistorySummarizer', () => { + test('returns the LLM summary and sends the tool-bearing conversation to generateText', async () => { + const seen: Array<{ system: string; messages: unknown[] }> = []; + const generateText: AiSdkGenerateTextLike = async (opts) => { + seen.push(opts); + return { text: '## Goal\n做到 X' }; + }; + + const summarize = buildLlmHistorySummarizer({ resolveModel: () => 'fake-model', generateText }); + + const events: RuntimeEvent[] = [ + ev({ role: 'user', author: 'user', content: { kind: 'text', text: '读 package.json' } }), + ev({ + role: 'model', + author: 'agent', + content: { kind: 'function_call', id: 'fc1', name: 'read', args: { path: 'package.json' } }, + }), + ev({ + role: 'tool', + author: 'tool', + content: { kind: 'function_response', id: 'fc1', name: 'read', result: { name: 'maka' } }, + }), + ev({ role: 'model', author: 'agent', content: { kind: 'text', text: '项目名是 maka' } }), + ]; + + const result = await summarize(inputWith(events)); + + expect(result).toBe('## Goal\n做到 X'); + expect(seen.length).toBe(1); + const serialized = JSON.stringify(seen[0]!.messages); + // summarizer 收到的是模型可见的含 tool 对话,而不是纯文本摘要 + expect(serialized).toContain('package.json'); + expect(serialized).toContain('maka'); + }); + + test('produces schema-valid tool-result messages (toolName + wrapped output) and does not fall back', async () => { + const seen: Array<{ messages: unknown[] }> = []; + const generateText: AiSdkGenerateTextLike = async (opts) => { + seen.push(opts); + return { text: '## Goal\nX' }; + }; + const summarize = buildLlmHistorySummarizer({ resolveModel: () => 'fake-model', generateText }); + + const events: RuntimeEvent[] = [ + ev({ role: 'user', author: 'user', content: { kind: 'text', text: '读 package.json' } }), + ev({ + role: 'model', + author: 'agent', + content: { kind: 'function_call', id: 'fc1', name: 'read', args: { path: 'package.json' } }, + }), + ev({ + role: 'tool', + author: 'tool', + content: { kind: 'function_response', id: 'fc1', name: 'read', result: { name: 'maka' } }, + }), + ev({ role: 'model', author: 'agent', content: { kind: 'text', text: 'ok' } }), + ]; + + const result = await summarize(inputWith(events)); + expect(result).toBe('## Goal\nX'); + + const messages = seen[0]!.messages as Array<{ + role: string; + content: Array<{ type: string; toolName?: string; output?: unknown }>; + }>; + const toolPart = messages.find((m) => m.role === 'tool')!.content[0]!; + expect(toolPart.type).toBe('tool-result'); + // toolName must be present (AI SDK v6 tool-result content requires it) + expect(toolPart.toolName).toBe('read'); + // output must be the {type, value} wrapper, not the raw result object + expect(toolPart.output).toEqual({ type: 'json', value: { name: 'maka' } }); + }); + + test('fail-open: returns undefined when generateText throws, so runtime falls back to the draft', async () => { + const generateText: AiSdkGenerateTextLike = async () => { + throw new Error('model down'); + }; + const summarize = buildLlmHistorySummarizer({ resolveModel: () => 'fake-model', generateText }); + + const result = await summarize( + inputWith([ev({ role: 'user', author: 'user', content: { kind: 'text', text: 'hi' } })]), + ); + + expect(result).toBe(undefined); + }); + + test('returns undefined without calling generateText when there are no events to summarize', async () => { + let called = false; + const generateText: AiSdkGenerateTextLike = async () => { + called = true; + return { text: 'should not reach' }; + }; + const summarize = buildLlmHistorySummarizer({ resolveModel: () => 'fake-model', generateText }); + + const result = await summarize(inputWith([])); + + expect(result).toBe(undefined); + expect(called).toBe(false); + }); +}); diff --git a/packages/runtime/src/ai-sdk-backend.ts b/packages/runtime/src/ai-sdk-backend.ts index 8245b0d021f..0180b40a1ba 100644 --- a/packages/runtime/src/ai-sdk-backend.ts +++ b/packages/runtime/src/ai-sdk-backend.ts @@ -100,6 +100,7 @@ import { rewriteActiveToolResultsInMessages, type ActiveToolResultPruneDiagnosticPatch, } from './active-tool-result-prune.js'; +import { toolResultOutput } from './ai-sdk-tool-output.js'; import { rewriteActiveFullCompactInMessages, type ActiveFullCompactBlock, @@ -170,12 +171,6 @@ export { normalizeAiSdkUsage } from './model-adapter.js'; export type { ModelFactory, ModelFactoryInput, RepairableAiSdkToolCall } from './model-adapter.js'; export type { RunTraceEvent, RunTraceRecorder } from './run-trace.js'; -type AiSdkToolResultOutput = - | { type: 'text'; value: string } - | { type: 'json'; value: JSONValue } - | { type: 'error-text'; value: string } - | { type: 'error-json'; value: JSONValue }; - // ============================================================================ // AgentBackend interface // ============================================================================ @@ -2341,17 +2336,6 @@ function buildInvalidMakaTool(): MakaTool<{ tool?: string; error?: string }, nev }; } -function toolResultOutput(value: unknown, isError: boolean): AiSdkToolResultOutput { - if (isError) { - return typeof value === 'string' - ? { type: 'error-text', value } - : { type: 'error-json', value: jsonValue(value) }; - } - return typeof value === 'string' - ? { type: 'text', value } - : { type: 'json', value: jsonValue(value) }; -} - function sha256(text: string): string { return createHash('sha256').update(text).digest('hex'); } @@ -2676,16 +2660,4 @@ function mergeCountsInto(target: Record, source: Record Promise<{ text: string }>; + +export interface BuildLlmHistorySummarizerOptions { + /** Resolve the AI SDK model used for summarization. Reuses the session model. */ + resolveModel: () => unknown; + /** Optional cap on the generated summary length. */ + maxOutputTokens?: number; + /** Injectable `generateText` for tests; defaults to the real AI SDK export. */ + generateText?: AiSdkGenerateTextLike; +} + +// Conversation-summarization prompt (sectioned, modelled on pi/opencode): +// asks for a checkpoint another LLM can continue from. Tool calls and their +// results are part of the conversation sent to the summarizer, because the +// folded events are projected with the same policy the model would see them. +const SUMMARIZATION_SYSTEM_PROMPT = [ + 'You are a context summarization assistant.', + 'Read the conversation between a user and an AI assistant, then produce a structured summary another LLM will use to continue the same task.', + 'Do NOT continue the conversation. Do NOT answer questions in it. ONLY output the structured summary.', + '', + 'Use this exact format:', + '', + '## Goal', + '[What the user is trying to accomplish]', + '', + '## Progress', + '### Done', + '- [Completed work and changes]', + '### In Progress', + '- [Current work]', + '', + '## Key Decisions', + '- **[Decision]**: [Brief rationale]', + '', + '## Next Steps', + '1. [Ordered list of what should happen next]', + '', + '## Critical Context', + '- [Files, commands/results, errors, anything needed to continue; or "(none)"]', + '', + 'Keep each section concise. Preserve exact file paths, function names, commands, and error messages.', +].join('\n'); + +export function buildLlmHistorySummarizer(options: BuildLlmHistorySummarizerOptions) { + return async (input: HistoryCompactWriteInput): Promise => { + if (input.source.foldedRuntimeEvents.length === 0) return undefined; + try { + const plan = buildRuntimeEventModelReplayPlan(input.source.foldedRuntimeEvents); + const messages = replayPlanItemsToModelMessages(plan.items); + const generateText = options.generateText ?? (await loadAiSdkGenerateText()); + const result = await generateText({ + model: options.resolveModel(), + system: SUMMARIZATION_SYSTEM_PROMPT, + messages, + ...(options.maxOutputTokens !== undefined ? { maxOutputTokens: options.maxOutputTokens } : {}), + ...(input.abortSignal ? { abortSignal: input.abortSignal } : {}), + }); + return result.text; + } catch { + // Fail-open: a summarizer failure returns undefined so the runtime + // keeps the deterministic draft summary instead of aborting the compact. + return undefined; + } + }; +} + +async function loadAiSdkGenerateText(): Promise { + const ai = await import('ai').catch((err) => { + throw new Error(`Failed to load 'ai' package for history summarization. Run \`npm install ai\`. Inner: ${(err as Error).message}`); + }); + const { generateText } = ai as { generateText: AiSdkGenerateTextLike }; + return generateText; +} + +type ReplayPlanItems = ReturnType['items']; + +function replayPlanItemsToModelMessages(items: ReplayPlanItems): ModelMessage[] { + const out: ModelMessage[] = []; + for (const item of items) { + if (item.kind === 'text') { + // Split on role so each push matches exactly one ModelMessage arm — no cast. + const textPart = { type: 'text' as const, text: item.content }; + if (item.role === 'user') { + out.push({ role: 'user', content: [textPart] }); + } else { + out.push({ role: 'assistant', content: [textPart] }); + } + } else if (item.kind === 'tool_call') { + out.push({ + role: 'assistant', + content: [ + { type: 'tool-call', toolCallId: item.toolCallId, toolName: item.toolName, input: item.input }, + ], + }); + } else if (item.kind === 'tool_result') { + out.push({ + role: 'tool', + content: [{ type: 'tool-result', toolCallId: item.toolCallId, toolName: item.toolName, output: toolResultOutput(item.output, item.isError) }], + }); + } + // thinking entries are intentionally skipped for summarization + } + return out; +} diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index df2d9fb4d36..bd3a13e1915 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -256,6 +256,10 @@ export type { HistoryCompactArtifactStore, PersistHistoryCompactBlocksDeps, } from './history-compact-artifacts.js'; +export { buildLlmHistorySummarizer } from './history-compact-summarizer.js'; +export type { + BuildLlmHistorySummarizerOptions, +} from './history-compact-summarizer.js'; export { ACTIVE_ARCHIVED_TOOL_RESULT_PLACEHOLDER_KIND, ARCHIVED_TOOL_RESULT_PLACEHOLDER_KIND,