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
2 changes: 2 additions & 0 deletions packages/core/src/external-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,8 @@ export interface ExternalMakaSession {
cwd: string;
};
messages: readonly StoredMessage[];
/** Explicit source image parts, consumed host-side before publishing an import. */
sourceImages?: readonly { readonly messageId: string; readonly path: string }[];
}

export const EXTERNAL_SESSION_LIMIT_KINDS = [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1209,6 +1209,32 @@ test('retries a retained staged Session during a later recovery', async (t) => {
assert.equal(fixture.readHeader('imported-1')?.transcriptLedgerVersion, 1);
});

test('prepares explicit imported images before publishing the destination Session', async () => {
const fixture = coordinatorFixture([
adapterFixture({
readSession: async (sourceSessionId) => ({
sourceSessionId,
metadata: { name: 'Image-only import', cwd: '/external' },
messages: [
{ type: 'user', id: 'image-user', turnId: 'source-turn', ts: 1, text: '[Image]' },
],
sourceImages: [{ messageId: 'image-user', path: '/missing-original.png' }],
}),
}),
]);
const outcome = await fixture.coordinator.handlers['external-session.import'](
{ adapterId: 'codex', sourceSessionId: 'source-0' },
context,
);
assert.equal(outcome.ok, true);
const user = fixture.creates[0]?.messages[0];
assert.equal(user?.type, 'user');
if (user?.type !== 'user') throw new Error('Missing imported user message');
assert.match(user.text, /^\[Image\]/);
assert.match(user.text, /1 imported image attachment\(s\) unavailable/);
assert.equal(user.attachments, undefined);
});

function coordinatorFixture(
adapters: readonly ExternalSessionAdapter[],
storeOverrides: Partial<
Expand Down Expand Up @@ -1246,13 +1272,16 @@ function coordinatorFixture(
externalOrigin,
options,
) => {
options?.onCommitStarted?.();
sequence += 1;
const header = {
...sessionHeader(`imported-${sequence}`, input.cwd, input.name ?? 'Imported'),
transcriptLedgerVersion: 0 as const,
};
creates.push({ input, messages, externalOrigin });
const preparedMessages = options?.prepareMessages
? await options.prepareMessages(header.id, messages)
: messages;
options?.onCommitStarted?.();
creates.push({ input, messages: preparedMessages, externalOrigin });
records.set(header.id, {
header,
revision: 1,
Expand Down
2 changes: 2 additions & 0 deletions packages/runtime-host/src/server/execution-composition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3018,6 +3018,7 @@ export async function createExecutionRuntimeHostComposition(
});
const externalSessions = new HostExternalSessionCoordinator({
adapters: createExternalSessionAdapterRegistry(),
artifacts: openedArtifactStore,
admission: sessionAdmission,
sessions: stores.sessionStore,
workspaceResolver,
Expand All @@ -3028,6 +3029,7 @@ export async function createExecutionRuntimeHostComposition(
const outcomes = await Promise.allSettled([
stores.purgeConversationOperationalState(sessionId),
sessionTodoStore.purgeSessionState(sessionId),
openedArtifactStore.purgeSessionArtifacts(sessionId),
stores.sessionStore.remove(sessionId),
]);
for (const outcome of outcomes) {
Expand Down
33 changes: 24 additions & 9 deletions packages/runtime-host/src/server/external-session-coordinator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,14 @@ type ExternalSessionStore = {
input: CreateSessionInput,
messages: readonly StoredMessage[],
externalOrigin: SessionExternalOrigin,
options?: { readonly onCommitStarted?: () => void },
options?: {
readonly onCommitStarted?: () => void;
readonly prepareMessages?: (
sessionId: string,
messages: readonly StoredMessage[],
) => Promise<readonly StoredMessage[]>;
readonly discardPreparedMessages?: (sessionId: string) => Promise<void>;
},
): Promise<SessionHeader>;
lookupExternalSessionImports(
adapterId: string,
Expand All @@ -77,6 +84,7 @@ type ExternalSessionStore = {

export interface HostExternalSessionCoordinatorOptions {
readonly adapters: ExternalSessionAdapterRegistry;
readonly artifacts?: ConstructorParameters<typeof ExternalSessionImporter>[2];
readonly admission: SessionAdmissionGate;
readonly sessions: ExternalSessionStore;
readonly workspaceResolver: Pick<HostWorkspaceResolver, 'resolve'>;
Expand All @@ -95,6 +103,7 @@ export class HostExternalSessionCoordinator {
};

readonly #adapters: ExternalSessionAdapterRegistry;
readonly #artifacts: HostExternalSessionCoordinatorOptions['artifacts'];
readonly #admission: SessionAdmissionGate;
readonly #sessions: ExternalSessionStore;
readonly #workspaceResolver: Pick<HostWorkspaceResolver, 'resolve'>;
Expand Down Expand Up @@ -132,6 +141,7 @@ export class HostExternalSessionCoordinator {

constructor(options: HostExternalSessionCoordinatorOptions) {
this.#adapters = options.adapters;
this.#artifacts = options.artifacts;
this.#admission = options.admission;
this.#sessions = options.sessions;
this.#workspaceResolver = options.workspaceResolver;
Expand Down Expand Up @@ -321,15 +331,20 @@ export class HostExternalSessionCoordinator {
}

let commitAttempted = false;
const importer = new ExternalSessionImporter(this.#adapters, {
createImportedSession: async (sessionInput, messages, externalOrigin) => {
return this.#sessions.createImportedSession(sessionInput, messages, externalOrigin, {
onCommitStarted: () => {
commitAttempted = true;
},
});
const importer = new ExternalSessionImporter(
this.#adapters,
{
createImportedSession: async (sessionInput, messages, externalOrigin, options) => {
return this.#sessions.createImportedSession(sessionInput, messages, externalOrigin, {
...options,
onCommitStarted: () => {
commitAttempted = true;
},
});
},
},
});
this.#artifacts,
);
let header: SessionHeader;
try {
header = await importer.import({
Expand Down
47 changes: 47 additions & 0 deletions packages/storage/src/__tests__/codex-session-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,53 @@ async function listSessions(
}

describe('CodexSessionAdapter', () => {
test('keeps only explicit local image parts associated with their user message', async () => {
await withCodexHome(async (codexHome) => {
const id = 'codex-image-parts';
const content = [
JSON.stringify({ type: 'session_meta', payload: { id, cwd: '/workspace', source: 'cli' } }),
JSON.stringify({
type: 'event_msg',
payload: {
type: 'item_completed',
item: {
type: 'UserMessage',
id: 'image-user',
content: [
{ type: 'text', text: 'Compare /ordinary/path.png' },
{ type: 'local_image', path: '/explicit/one.png' },
{ type: 'local_image', path: '/explicit/two.png' },
],
},
},
}),
JSON.stringify({
type: 'event_msg',
payload: {
type: 'item_completed',
item: {
type: 'UserMessage',
id: 'image-only',
content: [{ type: 'local_image', path: '/explicit/three.png' }],
},
},
}),
'',
].join('\n');
await seedRawRollout(codexHome, id, content);
const session = await new CodexSessionAdapter({ codexHome }).readSession(id);
assert.deepEqual(session.sourceImages, [
{ messageId: 'image-user', path: '/explicit/one.png' },
{ messageId: 'image-user', path: '/explicit/two.png' },
{ messageId: 'image-only', path: '/explicit/three.png' },
]);
assert.deepEqual(
session.messages.filter((m) => m.type === 'user').map((m) => m.text),
['Compare /ordinary/path.png', '[Image]'],
);
});
});

test('lists active and archived root Sessions from the newest Codex state database', async () => {
await withCodexHome(async (codexHome) => {
const activePath = await seedFixtureRollout(codexHome, 'codex-session-1', false);
Expand Down
128 changes: 126 additions & 2 deletions packages/storage/src/__tests__/external-session-importer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
*/

import assert from 'node:assert/strict';
import { mkdir, mkdtemp, realpath, rm, stat } from 'node:fs/promises';
import { mkdir, mkdtemp, realpath, rm, stat, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { describe, test } from 'node:test';
Expand All @@ -32,8 +32,132 @@ import {
type ExternalSessionImportTarget,
} from '../external-session-importer.js';
import { createSessionStore, type SessionAuthorityStore } from '../session-store.js';
import { createSqliteArtifactStoreWriteAuthority } from '../artifact-store.js';
import { createAttachmentByteReader } from '../artifact-attachments.js';
import { MAX_ATTACHMENT_BYTES } from '@maka/core/attachments';

describe('ExternalSessionImporter', () => {
test('persists explicit images in the destination Session and survives source removal', async () => {
const root = await mkdtemp(join(tmpdir(), 'maka-import-images-'));
const source = join(root, 'original.png');
const png = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==',
'base64',
);
await writeFile(source, png);
const sessions = createSessionStore(root);
const authority = createSqliteArtifactStoreWriteAuthority(root);
try {
const adapter = fakeAdapter({
metadata: { name: 'Images', cwd: root },
messages: [
{
type: 'user',
id: 'user-1',
turnId: 'turn-1',
ts: 1,
text: `Compare this image; ordinary path: ${join(root, 'not-an-attachment.png')}`,
},
],
sourceImages: [{ messageId: 'user-1', path: source }],
});
const importer = new ExternalSessionImporter(
new ExternalSessionAdapterRegistry([adapter]),
sessions,
authority.store,
);
const header = await importer.import({
adapterId: 'fake',
sourceSessionId: 'source-1',
target: target(),
});
await rm(source);
const messages = await sessions.readMessages(header.id);
const user = messages[0];
assert.equal(user?.type, 'user');
if (user?.type !== 'user') throw new Error('Missing user');
assert.equal(
user.text,
`Compare this image; ordinary path: ${join(root, 'not-an-attachment.png')}`,
);
assert.equal(user.attachments?.length, 1);
const attachment = user.attachments![0]!;
assert.equal(attachment.ref.kind, 'session_file');
assert.equal(attachment.bytes, png.byteLength);
assert.deepEqual(
await createAttachmentByteReader({ artifactStore: authority.store, sessionId: header.id })(
attachment.ref,
),
{ ok: true, bytes: png },
);
assert.deepEqual(
await createAttachmentByteReader({
artifactStore: authority.store,
sessionId: 'different-session',
})(attachment.ref),
{ ok: false, reason: 'session_mismatch' },
);
assert.equal(JSON.stringify(messages).includes(source), false);
} finally {
await sessions.close?.();
authority.close();
await rm(root, { recursive: true, force: true });
}
});

test('bounds attempts even for missing images and reports unavailable originals', async () => {
const root = await mkdtemp(join(tmpdir(), 'maka-import-missing-images-'));
const invalid = join(root, 'invalid.png');
const large = join(root, 'large.png');
await writeFile(invalid, 'not an image');
const beyondAttemptLimit = join(root, 'ninth.png');
await writeFile(
beyondAttemptLimit,
Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==',
'base64',
),
);
const { open } = await import('node:fs/promises');
const handle = await open(large, 'w');
await handle.truncate(MAX_ATTACHMENT_BYTES + 1);
await handle.close();
const sessions = createSessionStore(root);
const authority = createSqliteArtifactStoreWriteAuthority(root);
try {
const adapter = fakeAdapter({
metadata: { name: 'Missing images', cwd: root },
messages: [message()],
sourceImages: [
join(root, 'missing.png'),
invalid,
large,
...Array.from({ length: 5 }, (_, index) => join(root, `missing-${index}.png`)),
beyondAttemptLimit,
].map((path) => ({
messageId: 'user-1',
path,
})),
});
const header = await new ExternalSessionImporter(
new ExternalSessionAdapterRegistry([adapter]),
sessions,
authority.store,
).import({ adapterId: 'fake', sourceSessionId: 'source-1', target: target() });
const user = (await sessions.readMessages(header.id))[0];
assert.equal(user?.type, 'user');
if (user?.type !== 'user') throw new Error('Missing user');
assert.match(user.text, /^hello/);
assert.match(user.text, /9 imported image attachment\(s\) unavailable/);
assert.equal(user.attachments, undefined);
assert.equal((await authority.store.listPage(header.id, { offset: 0, limit: 10 })).total, 0);
} finally {
await sessions.close?.();
authority.close();
await rm(root, { recursive: true, force: true });
}
});

test('forwards the exact external Session origin to imported persistence', async () => {
const calls: SessionExternalOrigin[] = [];
const adapter = fakeAdapter({
Expand Down Expand Up @@ -353,7 +477,7 @@ function message(): StoredMessage {
function fakeAdapter(
session: Pick<
Awaited<ReturnType<ExternalSessionAdapter['readSession']>>,
'metadata' | 'messages'
'metadata' | 'messages' | 'sourceImages'
>,
): ExternalSessionAdapter {
return {
Expand Down
Loading