Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/core/src/backend-types.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';

Expand All@@ -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. */
Expand Down
153 changes: 153 additions & 0 deletions packages/runtime/src/__tests__/ai-sdk-backend.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 {
Expand All@@ -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({
Expand DownExpand Up@@ -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<unknown>): Promise<void> {
for await (const _ of iterable) {
// consume
}
}

function header(permissionMode: SessionHeader['permissionMode'] = 'ask'): SessionHeader {
return {
id: 'session-1',
Expand Down
16 changes: 16 additions & 0 deletions packages/runtime/src/__tests__/ai-sdk-flow.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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' })],
});
Expand All@@ -197,6 +211,7 @@ describe('AiSdkFlow seam', () => {
text: 'hi',
attachments: [attachment],
context: history,
runtimeContext,
source: 'test',
});

Expand All@@ -207,6 +222,7 @@ describe('AiSdkFlow seam', () => {
text: 'hi',
attachments: [attachment],
context: history,
runtimeContext,
});
});

Expand Down
56 changes: 56 additions & 0 deletions packages/runtime/src/__tests__/runtime-event-adapters.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -35,6 +35,7 @@ import {
} from '../runtime-event-adapters.js';
import {
buildModelHistoryFromRuntimeEvents,
buildTextModelMessagesFromRuntimeEvents,
type ModelHistoryEntry,
} from '../model-history.js';

Expand DownExpand Up@@ -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' },
]);
});
});

// ============================================================================
Expand Down
17 changes: 16 additions & 1 deletion packages/runtime/src/__tests__/runtime-runner.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<AgentFlowLike['run']>[1] | undefined;
const flow: AgentFlowLike = {
async *run(ctx, input) {
Expand All@@ -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({
Expand Down
Loading