From 7eebdf0c019a08860cd55e66d3bb5b90fcb2ca2b Mon Sep 17 00:00:00 2001 From: likun Date: Mon, 15 Jun 2026 12:24:34 +0800 Subject: [PATCH] Prefer RuntimeEvent model history when complete --- packages/core/src/backend-types.ts | 6 + .../src/__tests__/ai-sdk-backend.test.ts | 153 ++++++++++++++++++ .../runtime/src/__tests__/ai-sdk-flow.test.ts | 16 ++ .../__tests__/runtime-event-adapters.test.ts | 56 +++++++ .../src/__tests__/runtime-runner.test.ts | 17 +- .../src/__tests__/session-manager.test.ts | 64 ++++++++ packages/runtime/src/agent-flow.ts | 5 + packages/runtime/src/agent-run.ts | 67 +++++++- packages/runtime/src/ai-sdk-backend.ts | 23 ++- packages/runtime/src/ai-sdk-flow.ts | 1 + packages/runtime/src/invocation-context.ts | 5 + packages/runtime/src/model-history.ts | 59 +++++++ packages/runtime/src/runtime-runner.ts | 1 + packages/runtime/src/session-manager.ts | 1 + 14 files changed, 465 insertions(+), 9 deletions(-) diff --git a/packages/core/src/backend-types.ts b/packages/core/src/backend-types.ts index 66da9df7fd..a708800e0b 100644 --- a/packages/core/src/backend-types.ts +++ b/packages/core/src/backend-types.ts @@ -10,6 +10,7 @@ */ import type { AttachmentRef } from './events.js'; +import type { RuntimeEvent } from './runtime-event.js'; import type { StoredMessage } from './session.js'; import type { PermissionResponse } from './permission.js'; @@ -23,6 +24,11 @@ export interface BackendSendInput { * expected conversation shape. */ context: StoredMessage[]; + /** + * Optional prior RuntimeEvent ledger for model-history projection. Backends + * prefer this only when supplied and usable; `context` remains the fallback. + */ + runtimeContext?: RuntimeEvent[]; } /** Alias for clarity at the backend boundary. */ diff --git a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts index f6688a3f80..599262197e 100644 --- a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts +++ b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts @@ -4,6 +4,7 @@ import { MockLanguageModelV3, simulateReadableStream } from 'ai/test'; import type { LanguageModelV3StreamPart } from '@ai-sdk/provider'; import type { LlmConnection, SessionHeader } from '@maka/core'; import type { SessionEvent } from '@maka/core/events'; +import type { RuntimeEvent } from '@maka/core/runtime-event'; import type { ToolResultMessage } from '@maka/core/session'; import type { LlmCallRecord } from '@maka/core/usage-stats/types'; import { @@ -19,6 +20,92 @@ import { } from '../ai-sdk-backend.js'; import { PermissionEngine } from '../permission-engine.js'; +describe('AiSdkBackend model history', () => { + test('prefers RuntimeEvent prior messages and appends current user once', async () => { + const model = completionModel(); + 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: [], + newId: idGenerator(), + now: monotonicClock(), + }); + + await drain(backend.send({ + turnId: 'turn-current', + text: 'current user', + context: [ + { type: 'user', id: 'legacy-u', turnId: 'turn-prev', ts: 1, text: 'legacy user' }, + { type: 'assistant', id: 'legacy-a', turnId: 'turn-prev', ts: 2, text: 'legacy assistant', modelId: 'm' }, + ], + runtimeContext: [ + runtimeTextEvent({ id: 'rt-u', turnId: 'turn-prev', role: 'user', author: 'user', text: 'runtime user' }), + runtimeTextEvent({ id: 'rt-a', turnId: 'turn-prev', role: 'model', author: 'agent', text: 'runtime assistant' }), + runtimeTextEvent({ id: 'rt-current', turnId: 'turn-current', role: 'user', author: 'user', text: 'current from runtime' }), + ], + })); + + assert.deepEqual(compactPrompt(model), [ + { role: 'user', content: [{ type: 'text', text: 'runtime user' }] }, + { role: 'assistant', content: [{ type: 'text', text: 'runtime assistant' }] }, + { role: 'user', content: [{ type: 'text', text: 'current user' }] }, + ]); + }); + + test('falls back to StoredMessage context when RuntimeEvent projection is empty', async () => { + const model = completionModel(); + 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: [], + newId: idGenerator(), + now: monotonicClock(), + }); + + await drain(backend.send({ + turnId: 'turn-current', + text: 'current user', + context: [ + { type: 'user', id: 'legacy-u', turnId: 'turn-prev', ts: 1, text: 'legacy user' }, + { type: 'assistant', id: 'legacy-a', turnId: 'turn-prev', ts: 2, text: 'legacy assistant', modelId: 'm' }, + ], + runtimeContext: [ + { + id: 'rt-terminal', + invocationId: 'inv-1', + runId: 'run-prev', + sessionId: 'session-1', + turnId: 'turn-prev', + ts: 1, + partial: false, + role: 'model', + author: 'agent', + status: 'completed', + actions: { endInvocation: true }, + }, + ], + })); + + assert.deepEqual(compactPrompt(model), [ + { role: 'user', content: [{ type: 'text', text: 'legacy user' }] }, + { role: 'assistant', content: [{ type: 'text', text: 'legacy assistant' }] }, + { role: 'user', content: [{ type: 'text', text: 'current user' }] }, + ]); + }); +}); + describe('AiSdkBackend error surfaces', () => { test('generalizes model setup errors before emitting renderer events', async () => { const backend = new AiSdkBackend({ @@ -1286,6 +1373,72 @@ describe('AiSdkBackend tool-call repair', () => { }); }); +function completionModel(): MockLanguageModelV3 { + const chunks: LanguageModelV3StreamPart[] = [ + { type: 'stream-start', warnings: [] }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: 'stop' }, + usage: { + inputTokens: { + total: 1, + noCache: 1, + cacheRead: 0, + cacheWrite: 0, + }, + outputTokens: { + total: 1, + text: 1, + reasoning: 0, + }, + }, + }, + ]; + return new MockLanguageModelV3({ + doStream: { + stream: simulateReadableStream({ + chunks, + initialDelayInMs: null, + chunkDelayInMs: null, + }), + }, + }); +} + +function runtimeTextEvent(input: { + id: string; + turnId: string; + role: 'user' | 'model'; + author: 'user' | 'agent'; + text: string; +}): RuntimeEvent { + return { + id: input.id, + invocationId: 'inv-1', + runId: 'run-prev', + sessionId: 'session-1', + turnId: input.turnId, + ts: 1, + partial: false, + role: input.role, + author: input.author, + content: { kind: 'text', text: input.text }, + }; +} + +function compactPrompt(model: MockLanguageModelV3): unknown { + return model.doStreamCalls[0]?.prompt.map((message) => ({ + role: message.role, + content: message.content, + })); +} + +async function drain(iterable: AsyncIterable): Promise { + for await (const _ of iterable) { + // consume + } +} + function header(permissionMode: SessionHeader['permissionMode'] = 'ask'): SessionHeader { return { id: 'session-1', diff --git a/packages/runtime/src/__tests__/ai-sdk-flow.test.ts b/packages/runtime/src/__tests__/ai-sdk-flow.test.ts index 753567a96e..9a0a425e8b 100644 --- a/packages/runtime/src/__tests__/ai-sdk-flow.test.ts +++ b/packages/runtime/src/__tests__/ai-sdk-flow.test.ts @@ -178,6 +178,20 @@ describe('AiSdkFlow seam', () => { text: 'previous', }, ]; + const runtimeContext: RuntimeEvent[] = [ + { + id: 'rt-prev', + invocationId: 'inv-prev', + runId: 'run-prev', + sessionId: 'session-1', + turnId: 'turn-prev', + ts: 1, + partial: false, + role: 'user', + author: 'user', + content: { kind: 'text', text: 'previous' }, + }, + ]; const backend = new ScriptedBackend({ events: [ev({ type: 'complete', stopReason: 'end_turn' })], }); @@ -197,6 +211,7 @@ describe('AiSdkFlow seam', () => { text: 'hi', attachments: [attachment], context: history, + runtimeContext, source: 'test', }); @@ -207,6 +222,7 @@ describe('AiSdkFlow seam', () => { text: 'hi', attachments: [attachment], context: history, + runtimeContext, }); }); diff --git a/packages/runtime/src/__tests__/runtime-event-adapters.test.ts b/packages/runtime/src/__tests__/runtime-event-adapters.test.ts index 2b2d2aa2ef..8f0cb04fc3 100644 --- a/packages/runtime/src/__tests__/runtime-event-adapters.test.ts +++ b/packages/runtime/src/__tests__/runtime-event-adapters.test.ts @@ -35,6 +35,7 @@ import { } from '../runtime-event-adapters.js'; import { buildModelHistoryFromRuntimeEvents, + buildTextModelMessagesFromRuntimeEvents, type ModelHistoryEntry, } from '../model-history.js'; @@ -778,6 +779,61 @@ describe('buildModelHistoryFromRuntimeEvents', () => { 'text', ]); }); + + test('text-only AI SDK projection skips unsupported entries and preserves user attachment refs', () => { + const events: RuntimeEvent[] = [ + ev({ + role: 'user', + author: 'user', + content: { kind: 'text', text: 'see attached', attachments: [attachment] }, + }), + ev({ + partial: true, + role: 'model', + author: 'agent', + content: { kind: 'text', text: 'partial' }, + }), + ev({ + role: 'system', + author: 'system', + content: { kind: 'text', text: 'system note' }, + }), + ev({ + role: 'model', + author: 'agent', + content: { kind: 'thinking', text: 'private reasoning' }, + }), + ev({ + role: 'model', + author: 'agent', + content: { kind: 'function_call', id: 'fc1', name: 'Read', args: {} }, + }), + ev({ + role: 'tool', + author: 'tool', + content: { kind: 'function_response', id: 'fc1', name: 'Read', result: 'data' }, + }), + ev({ + role: 'model', + author: 'agent', + content: { kind: 'text', text: 'final answer' }, + }), + ev({ + role: 'model', + author: 'agent', + status: 'completed', + actions: { endInvocation: true }, + }), + ]; + + expect(buildTextModelMessagesFromRuntimeEvents(events)).toEqual([ + { + role: 'user', + content: 'see attached\n\n[attachment: brief.pdf (application/pdf)]', + }, + { role: 'assistant', content: 'final answer' }, + ]); + }); }); // ============================================================================ diff --git a/packages/runtime/src/__tests__/runtime-runner.test.ts b/packages/runtime/src/__tests__/runtime-runner.test.ts index 60d71677e8..ea6a728891 100644 --- a/packages/runtime/src/__tests__/runtime-runner.test.ts +++ b/packages/runtime/src/__tests__/runtime-runner.test.ts @@ -410,6 +410,20 @@ describe('RuntimeRunner', () => { text: 'previous', }, ]; + const runtimeContext: RuntimeEvent[] = [ + { + id: 'rt-prev', + invocationId: 'inv-prev', + runId: 'run-prev', + sessionId: 'sess-1', + turnId: 'prev-turn', + ts: 1, + partial: false, + role: 'user', + author: 'user', + content: { kind: 'text', text: 'previous' }, + }, + ]; let seenInput: Parameters[1] | undefined; const flow: AgentFlowLike = { async *run(ctx, input) { @@ -420,13 +434,14 @@ describe('RuntimeRunner', () => { const runner = new RuntimeRunner({ flow, providers }); const result = await runner.run( - makeRequest({ text: 'with file', context, attachments: [attachment] }), + makeRequest({ text: 'with file', context, runtimeContext, attachments: [attachment] }), ); expect(result.status).toBe('completed'); expect(seenInput).toEqual({ text: 'with file', context, + runtimeContext, attachments: [attachment], }); expect(result.events[0]!.content).toEqual({ diff --git a/packages/runtime/src/__tests__/session-manager.test.ts b/packages/runtime/src/__tests__/session-manager.test.ts index 9d32e0f039..c644805197 100644 --- a/packages/runtime/src/__tests__/session-manager.test.ts +++ b/packages/runtime/src/__tests__/session-manager.test.ts @@ -290,6 +290,68 @@ describe('SessionManager permission mode updates', () => { expect(await runStore.readRuntimeEvents(session.id, run.runId)).toEqual([]); }); + test('next turn receives complete prior RuntimeEvent context alongside legacy context', async () => { + const store = new MemorySessionStore(); + const runStore = new MemoryAgentRunStore(); + const backends = new BackendRegistry(); + const backendInstances: TestBackend[] = []; + backends.register('fake', (ctx) => { + const backend = new TestBackend(ctx); + backendInstances.push(backend); + return backend; + }); + const manager = new SessionManager({ + store, + runStore, + backends, + newId: nextId(), + now: nextNow(6_800), + runtimeSource: 'test', + }); + const session = await manager.createSession(makeInput()); + + await drain(manager.sendMessage(session.id, { turnId: 'turn-1', text: 'first' })); + await drain(manager.sendMessage(session.id, { turnId: 'turn-2', text: 'second' })); + + const secondInput = backendInstances[0]?.sendInputs[1]; + if (!secondInput) throw new Error('second backend input was not recorded'); + expect(secondInput.context.some((message) => message.type === 'user' && message.turnId === 'turn-1')).toBe(true); + expect(secondInput.context.some((message) => message.type === 'user' && message.turnId === 'turn-2')).toBe(true); + expect(secondInput.runtimeContext?.map((event) => event.turnId)).toEqual(['turn-1', 'turn-1', 'turn-1']); + expect(secondInput.runtimeContext?.map((event) => event.role)).toEqual(['user', 'model', 'system']); + expect(secondInput.runtimeContext?.[0]?.content).toEqual({ kind: 'text', text: 'first' }); + }); + + test('next turn remains legacy-only when prior RuntimeEvent ledger is unusable', async () => { + const store = new MemorySessionStore(); + const runStore = new MemoryAgentRunStore({ failRuntimeEventAppends: true }); + const backends = new BackendRegistry(); + const backendInstances: TestBackend[] = []; + backends.register('fake', (ctx) => { + const backend = new TestBackend(ctx); + backendInstances.push(backend); + return backend; + }); + const manager = new SessionManager({ + store, + runStore, + backends, + newId: nextId(), + now: nextNow(6_900), + runtimeSource: 'test', + }); + const session = await manager.createSession(makeInput()); + + await drain(manager.sendMessage(session.id, { turnId: 'turn-1', text: 'first' })); + await drain(manager.sendMessage(session.id, { turnId: 'turn-2', text: 'second' })); + + const secondInput = backendInstances[0]?.sendInputs[1]; + if (!secondInput) throw new Error('second backend input was not recorded'); + expect(secondInput.runtimeContext).toBeUndefined(); + expect(secondInput.context.some((message) => message.type === 'user' && message.turnId === 'turn-1')).toBe(true); + expect(secondInput.context.some((message) => message.type === 'user' && message.turnId === 'turn-2')).toBe(true); + }); + test('sendMessage production source uses AiSdkFlow instead of an inline mapper flow', async () => { const source = await readFile(new URL('../../src/session-manager.ts', import.meta.url), 'utf8'); const sendMessageSource = source.slice( @@ -925,12 +987,14 @@ describe('SessionManager permission mode updates', () => { class TestBackend implements AgentBackend { readonly kind = 'fake' as const; readonly sessionId: string; + readonly sendInputs: BackendSendInput[] = []; constructor(private readonly ctx: BackendFactoryContext, private readonly gate?: Gate) { this.sessionId = ctx.sessionId; } async *send(input: BackendSendInput): AsyncIterable { + this.sendInputs.push(input); yield { type: 'text_delta', id: `${input.turnId}-delta`, turnId: input.turnId, ts: 1, messageId: `${input.turnId}-m`, text: 'ok' }; await this.gate?.promise; yield { type: 'complete', id: `${input.turnId}-complete`, turnId: input.turnId, ts: 2, stopReason: 'end_turn' }; diff --git a/packages/runtime/src/agent-flow.ts b/packages/runtime/src/agent-flow.ts index 329d8a6ec2..a03119437a 100644 --- a/packages/runtime/src/agent-flow.ts +++ b/packages/runtime/src/agent-flow.ts @@ -93,6 +93,11 @@ export interface FlowInput { * resolved. */ context: StoredMessage[]; + /** + * Optional prior RuntimeEvent ledger for model-history projection. Flows + * forward this to backends that can prefer it over legacy context. + */ + runtimeContext?: RuntimeEvent[]; /** Abort signal propagated to the underlying engine. */ abortSignal?: AbortSignal; } diff --git a/packages/runtime/src/agent-run.ts b/packages/runtime/src/agent-run.ts index 14cfa1104d..ea54002c97 100644 --- a/packages/runtime/src/agent-run.ts +++ b/packages/runtime/src/agent-run.ts @@ -15,6 +15,10 @@ import type { BackendSendInput } from '@maka/core/backend-types'; import type { AgentBackend } from './ai-sdk-backend.js'; import type { RunTraceEvent } from './run-trace.js'; import type { SessionStore, StopSessionInput } from './session-manager.js'; +import { + buildTextModelMessagesFromRuntimeEvents, + type TextModelMessage, +} from './model-history.js'; export interface AgentRunActiveSession { sessionId: string; @@ -147,13 +151,17 @@ export class AgentRun { await this.input.hooks.updateStatus(this.sessionId, 'running', undefined, this.lastTs); + const legacyContext = await this.input.store.readMessages(this.sessionId); + const runtimeContext = await this.buildRuntimeContextIfComplete(legacyContext); + return { backend: this.active.backend, backendInput: { turnId: this.turnId, text: this.input.userInput.text, ...(this.input.userInput.attachments ? { attachments: this.input.userInput.attachments } : {}), - context: await this.input.store.readMessages(this.sessionId), + context: legacyContext, + ...(runtimeContext !== undefined ? { runtimeContext } : {}), }, }; } @@ -276,6 +284,35 @@ export class AgentRun { } } + private async buildRuntimeContextIfComplete( + legacyContext: readonly StoredMessage[], + ): Promise { + if (!this.input.runStore || !this.runStoreAvailable) return undefined; + try { + const runtimeContext: RuntimeEvent[] = []; + const runs = await this.input.runStore.listSessionRuns(this.sessionId); + for (const run of runs) { + if (run.runId === this.runId || run.turnId === this.turnId) continue; + const events = await this.input.runStore.readRuntimeEvents(this.sessionId, run.runId); + runtimeContext.push( + ...events.filter((event) => event.runId !== this.runId && event.turnId !== this.turnId), + ); + } + if (runtimeContext.length === 0) return undefined; + + const runtimeMessages = buildTextModelMessagesFromRuntimeEvents(runtimeContext); + if (runtimeMessages.length === 0) return undefined; + + const legacyPriorMessages = materializeLegacyTextMessages( + legacyContext.filter((message) => message.turnId !== this.turnId), + ); + if (!runtimeMessagesCoverLegacy(runtimeMessages, legacyPriorMessages)) return undefined; + return runtimeContext; + } catch { + return undefined; + } + } + private async markRunStarted(ts: number): Promise { if (!this.input.runStore || !this.runStoreAvailable) return; this.enqueueRunStore('mark run started', async () => { @@ -462,6 +499,34 @@ function traceToRunEvent(event: RunTraceEvent, runId: string): AgentRunEvent { }; } +function materializeLegacyTextMessages(stored: readonly StoredMessage[]): TextModelMessage[] { + const out: TextModelMessage[] = []; + for (const message of stored) { + if (message.type === 'user') { + out.push({ role: 'user', content: message.text }); + } else if (message.type === 'assistant') { + out.push({ role: 'assistant', content: message.text }); + } + } + return out; +} + +function runtimeMessagesCoverLegacy( + runtimeMessages: readonly TextModelMessage[], + legacyMessages: readonly TextModelMessage[], +): boolean { + if (runtimeMessages.length !== legacyMessages.length) return false; + return legacyMessages.every((legacyMessage, index) => { + const runtimeMessage = runtimeMessages[index]; + if (!runtimeMessage || runtimeMessage.role !== legacyMessage.role) return false; + if (runtimeMessage.content === legacyMessage.content) return true; + return ( + runtimeMessage.role === 'user' && + runtimeMessage.content.startsWith(`${legacyMessage.content}\n\n[attachment: `) + ); + }); +} + function sanitizeTraceData(data: Record | undefined): Record | undefined { if (!data) return undefined; return Object.fromEntries( diff --git a/packages/runtime/src/ai-sdk-backend.ts b/packages/runtime/src/ai-sdk-backend.ts index 37c0102045..78a7a027ab 100644 --- a/packages/runtime/src/ai-sdk-backend.ts +++ b/packages/runtime/src/ai-sdk-backend.ts @@ -80,6 +80,10 @@ import { } from './model-adapter.js'; import type { ToolArtifactRecorder } from './tool-artifacts.js'; import { RunTrace, type RunTraceRecorder } from './run-trace.js'; +import { + buildTextModelMessagesFromRuntimeEvents, + formatTextWithAttachmentRefs, +} from './model-history.js'; export { DEFAULT_PERMISSION_TIMEOUT_MS, @@ -298,10 +302,17 @@ export class AiSdkBackend implements AgentBackend { }; } - // --- Build messages from context (StoredMessage[] → ai-sdk format) --- - const messages = this.materializePriorMessages( - input.context.filter((message) => message.turnId !== input.turnId), - ); + // --- Build messages from context, preferring durable RuntimeEvents when usable. --- + const runtimeMessages = input.runtimeContext + ? buildTextModelMessagesFromRuntimeEvents( + input.runtimeContext.filter((event) => event.turnId !== input.turnId), + ) + : []; + const messages = runtimeMessages.length > 0 + ? runtimeMessages + : this.materializePriorMessages( + input.context.filter((message) => message.turnId !== input.turnId), + ); messages.push({ role: 'user', content: this.buildUserContent(input.text, input.attachments), @@ -583,9 +594,7 @@ export class AiSdkBackend implements AgentBackend { /** Build the user content payload for the current turn (text + attachment refs). */ private buildUserContent(text: string, attachments?: AttachmentRef[]): string { - if (!attachments || attachments.length === 0) return text; - const refs = attachments.map((a) => `[attachment: ${a.name} (${a.mimeType})]`).join(' '); - return `${text}\n\n${refs}`; + return formatTextWithAttachmentRefs(text, attachments); } private async resolveSystemPrompt(): Promise { diff --git a/packages/runtime/src/ai-sdk-flow.ts b/packages/runtime/src/ai-sdk-flow.ts index ee3c6a293e..345915dec3 100644 --- a/packages/runtime/src/ai-sdk-flow.ts +++ b/packages/runtime/src/ai-sdk-flow.ts @@ -458,6 +458,7 @@ export class AiSdkFlow implements AgentFlow, AgentFlowControl { text: input.text, ...(input.attachments !== undefined ? { attachments: input.attachments } : {}), context: input.context, + ...(input.runtimeContext !== undefined ? { runtimeContext: input.runtimeContext } : {}), })) { const runtimeEvent = mapSessionEventToRuntimeEvent(sessionEvent, ctx, memory); await this.onSessionEvent?.(sessionEvent, runtimeEvent); diff --git a/packages/runtime/src/invocation-context.ts b/packages/runtime/src/invocation-context.ts index 4cd2fa6ed1..3d52c791bb 100644 --- a/packages/runtime/src/invocation-context.ts +++ b/packages/runtime/src/invocation-context.ts @@ -74,6 +74,11 @@ export interface InvocationRequest { * receive an undefined model-history input. */ context?: StoredMessage[]; + /** + * Optional prior RuntimeEvent ledger resolved by the caller. RuntimeRunner + * passes this through without adding the current turn's RuntimeEvents. + */ + runtimeContext?: RuntimeEvent[]; source: InvocationSource; /** Optional branch/agent lane; forwarded onto every emitted event. */ branch?: string; diff --git a/packages/runtime/src/model-history.ts b/packages/runtime/src/model-history.ts index 6ebc9c0fef..7ac4e69dcf 100644 --- a/packages/runtime/src/model-history.ts +++ b/packages/runtime/src/model-history.ts @@ -37,9 +37,11 @@ import { isPartialRuntimeEvent, runtimeEventHasModelVisibleContent, type RuntimeEvent, + type RuntimeEventTextContent, type RuntimeEventContent, type RuntimeEventRole, } from '@maka/core/runtime-event'; +import type { AttachmentRef } from '@maka/core/events'; // ============================================================================ // Output type @@ -57,6 +59,11 @@ export interface ModelHistoryEntry { eventId: string; } +export interface TextModelMessage { + role: 'user' | 'assistant' | 'system'; + content: string; +} + // ============================================================================ // Options // ============================================================================ @@ -140,3 +147,55 @@ export function buildModelHistoryFromRuntimeEvents( } return out; } + +export interface RuntimeEventTextMessageOptions { + includeSystemEvents?: boolean; +} + +/** + * Convert projected RuntimeEvent history into the current AI SDK text-only + * message shape. Tool/function and thinking entries are intentionally skipped. + */ +export function buildTextModelMessagesFromRuntimeEvents( + events: readonly RuntimeEvent[], + options: RuntimeEventTextMessageOptions = {}, +): TextModelMessage[] { + const history = buildModelHistoryFromRuntimeEvents(events, { + includeToolEvents: false, + includeSystemEvents: options.includeSystemEvents ?? false, + includeThinking: false, + }); + const out: TextModelMessage[] = []; + for (const entry of history) { + if (entry.content.kind !== 'text') continue; + if (entry.role === 'tool') continue; + if (entry.role === 'system' && !options.includeSystemEvents) continue; + const role = entry.role === 'model' + ? 'assistant' + : entry.role === 'user' + ? 'user' + : entry.role === 'system' + ? 'system' + : undefined; + if (!role) continue; + out.push({ + role, + content: formatTextWithAttachmentRefs(entry.content), + }); + } + return out; +} + +export function formatTextWithAttachmentRefs( + textOrContent: string | RuntimeEventTextContent, + attachments?: AttachmentRef[], +): string { + const text = typeof textOrContent === 'string' ? textOrContent : textOrContent.text; + const refs = typeof textOrContent === 'string' ? attachments : textOrContent.attachments; + if (!refs || refs.length === 0) return text; + return `${text}\n\n${formatAttachmentRefs(refs)}`; +} + +function formatAttachmentRefs(attachments: readonly AttachmentRef[]): string { + return attachments.map((a) => `[attachment: ${a.name} (${a.mimeType})]`).join(' '); +} diff --git a/packages/runtime/src/runtime-runner.ts b/packages/runtime/src/runtime-runner.ts index 06b97361e4..e535711811 100644 --- a/packages/runtime/src/runtime-runner.ts +++ b/packages/runtime/src/runtime-runner.ts @@ -295,6 +295,7 @@ function buildFlowInput(request: InvocationRequest): FlowInput { return { text: request.text, context: request.context ?? [], + ...(request.runtimeContext !== undefined ? { runtimeContext: request.runtimeContext } : {}), ...(request.attachments !== undefined ? { attachments: request.attachments } : {}), ...(request.abortSignal ? { abortSignal: request.abortSignal } : {}), }; diff --git a/packages/runtime/src/session-manager.ts b/packages/runtime/src/session-manager.ts index 93f7fa8227..37820b8e5f 100644 --- a/packages/runtime/src/session-manager.ts +++ b/packages/runtime/src/session-manager.ts @@ -395,6 +395,7 @@ export class SessionManager { text: input.text, ...(begin.backendInput.attachments ? { attachments: begin.backendInput.attachments } : {}), context: begin.backendInput.context, + ...(begin.backendInput.runtimeContext !== undefined ? { runtimeContext: begin.backendInput.runtimeContext } : {}), source: this.deps.runtimeSource ?? 'desktop', lineage: run.lineage, abortSignal: abortController.signal,