fix: preserve image attachments in live and restored conversations

This commit is contained in:
2026-09-12 23:51:57 +08:00
parent 1d661f7a56
commit 03fc50f7a9
9 changed files with 347 additions and 10 deletions

View File

@@ -603,6 +603,23 @@ async function installCodingFirstChatHost(
await new Promise<void>((resolve) => { state.releaseSnapshot = resolve; });
state.snapshotPending = false;
}
const posted = state.captured.find((request) => (
request.path === `/api/coding/conversations/${conversation.id}/prompt` && request.method === 'POST'
));
if (!featureComplete && posted?.body) {
const refs = posted.body.attachments as Array<{ attachmentId: string }>;
return respond({ snapshot: {
...snapshot,
nodes: [{ kind: 'message', id: 'entry:sent-image', sourceEntryId: 'sent-image',
role: 'user', status: 'complete', blocks: [
{ kind: 'text', id: 'entry:sent-image:content:0', text: posted.body.text, status: 'complete' },
...refs.map(({ attachmentId }, index) => ({
kind: 'image', id: `entry:sent-image:image:${index}`, attachmentId, mime: 'image/png',
})),
] }],
cursor: { workerGeneration: 1, seq: 1 },
} });
}
const currentSnapshot = state.snapshotSettled
? {
...snapshot,
@@ -872,6 +889,20 @@ test('first PI Conversation is editable under 500 ms and submits before runtime
{ attachmentId: expect.any(String) },
]);
expect(JSON.stringify(prompt?.body)).not.toContain('data:image');
await releaseSnapshot(electronApp);
const persistedMessage = page.locator('[data-node-id="entry:sent-image"]');
await expect(persistedMessage).toBeVisible();
await expect(persistedMessage.getByRole('img', { name: '对话图片附件' })).toBeVisible();
await expect(persistedMessage.getByRole('button', { name: '从这里创建新对话分支' })).toBeVisible();
// A full renderer reload discards its optimistic state and blob URLs.
await page.reload();
page = await getStableWindow(electronApp);
await expect.poll(async () => (await readState(electronApp)).snapshotPending).toBe(true);
await releaseSnapshot(electronApp);
await expect(page.locator('[data-node-id="entry:sent-image"]').getByRole('img', {
name: '对话图片附件',
})).toBeVisible();
} finally {
await releaseSnapshot(electronApp);
}

View File

@@ -0,0 +1,69 @@
// @vitest-environment node
import { mkdtemp, readdir, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { AgentBrowserModule } from '../../electron/agent-browser';
import { createCodingComposition } from '../../electron/api/coding-composition';
import { CodingAttachmentStore } from '../../electron/coding-projects/attachment-store';
import { createMemoryCodingProjectStorage } from '../../electron/coding-projects/project-store';
import type { PiConversationRuntimeOptions } from '../../electron/coding-runtime/pi/runtime';
const captured = vi.hoisted(() => ({ options: undefined as PiConversationRuntimeOptions | undefined }));
vi.mock('../../electron/coding-runtime/pi/runtime', async (importOriginal) => {
const original = await importOriginal<typeof import('../../electron/coding-runtime/pi/runtime')>();
return { ...original, PiConversationRuntime: class extends original.PiConversationRuntime {
constructor(options: PiConversationRuntimeOptions) {
super(options);
captured.options = options;
}
} };
});
const roots: string[] = [];
afterEach(async () => {
captured.options = undefined;
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
describe('coding composition images', () => {
it('connects live and persisted Pi images to readable attachments and reuses their bytes after restart', async () => {
const userDataDir = await mkdtemp(path.join(tmpdir(), 'makelore-composition-images-'));
roots.push(userDataDir);
const composition = createCodingComposition({
storage: createMemoryCodingProjectStorage(),
browser: { close: vi.fn(async () => undefined) } as unknown as AgentBrowserModule,
paths: {
executablePath: process.execPath,
cliPath: path.join(userDataDir, 'unused-cli.js'),
serverPath: path.join(userDataDir, 'unused-server.mjs'),
userDataDir,
bundledSkillsDir: path.resolve('resources/coding-skills'),
},
});
try {
const bytes = Buffer.from('image bytes');
const uploaded = await composition.attachments.put(bytes, 'image/png');
const images = await captured.options!.resolveImages!([uploaded]);
expect(images).toEqual([{ type: 'image', data: bytes.toString('base64'), mimeType: 'image/png' }]);
expect(captured.options!.projectImage).toBeTypeOf('function');
for (const source of ['live', 'session'] as const) {
const projected = await captured.options!.projectImage!({
conversationId: 'conversation-image', source, data: images[0].data, mime: images[0].mimeType,
});
expect(projected.attachmentId).toBe(uploaded.attachmentId);
expect((await composition.attachments.read(projected.attachmentId)).data).toEqual(bytes);
}
const root = path.join(userDataDir, 'coding-runtime', 'attachments');
const reopened = new CodingAttachmentStore(root);
expect(await reopened.put(bytes, 'image/png')).toEqual(uploaded);
expect((await readdir(root)).sort()).toEqual([
`${uploaded.attachmentId}.bin`, `${uploaded.attachmentId}.json`,
]);
expect((await reopened.put(bytes, 'image/jpeg')).attachmentId).not.toBe(uploaded.attachmentId);
} finally {
await composition.shutdown();
}
});
});

View File

@@ -368,6 +368,123 @@ describe('Pi Agent Server real process', () => {
}
}, 10_000);
it('sends DeepSeek images in fresh and restored real sessions', async () => {
const root = await mkdtemp(path.join(tmpdir(), 'makelore-pi-agent-server-image-delivery-'));
roots.push(root);
const projectPath = path.join(root, 'project');
const configDir = path.join(root, 'config');
const sessionDir = path.join(root, 'sessions');
const extensionDir = path.join(root, 'extensions');
await Promise.all([
mkdir(projectPath, { recursive: true }),
mkdir(configDir, { recursive: true }),
mkdir(sessionDir, { recursive: true }),
mkdir(extensionDir, { recursive: true }),
]);
const provider = await startHeldProvider();
await Promise.all([
writeFile(path.join(configDir, 'settings.json'), JSON.stringify({ httpIdleTimeoutMs: 250 })),
writeFile(path.join(configDir, 'models.json'), JSON.stringify({
providers: {
'makelore-test': {
baseUrl: provider.baseUrl,
api: 'openai-completions',
apiKey: '$MAKELORE_TEST_KEY',
models: [{
id: 'deepseek-flash',
name: 'Test model',
reasoning: true,
compat: { supportsDeveloperRole: false, supportsReasoningEffort: false,
thinkingFormat: 'deepseek', requiresReasoningContentOnAssistantMessages: true },
input: ['text', 'image'],
contextWindow: 32_000,
maxTokens: 4_096,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
}],
},
},
})),
]);
const promptPath = path.join(root, 'system.md');
const languagePromptPath = path.join(root, 'language.md');
await Promise.all([
writeFile(promptPath, 'Describe the supplied image.'),
writeFile(languagePromptPath, MAKELORE_DEFAULT_LANGUAGE_PROMPT),
]);
const extensionHost = new PiManagedExtensionHost();
const registration = await extensionHost.registerWorker({
conversationId: 'image-delivery',
generation: 1,
projectId: 'project-a',
projectPath,
extensionsDir: extensionDir,
});
const runtimeRoot = path.resolve('node_modules/@earendil-works/pi-coding-agent');
const server = new PiAgentServerProcess({
executablePath: process.execPath,
serverPath: path.resolve('resources/pi-agent-server.mjs'),
runtimeRoot,
configDir,
});
const workerOptions: PiWorkerProcessOptions = {
executablePath: process.execPath,
cliPath: path.join(runtimeRoot, 'dist', 'cli.js'),
cwd: projectPath,
configDir,
sessionDir,
conversationId: 'image-delivery',
workerGeneration: 1,
tools: ['bash'],
additionalArgs: [
'--provider', 'makelore-test',
'--model', 'deepseek-flash',
'--thinking', 'off',
'--system-prompt', promptPath,
'--append-system-prompt', languagePromptPath,
'--extension', registration.extensionPath,
'--session-id', 'session-image-delivery',
],
env: { MAKELORE_TEST_KEY: 'secret-image-delivery', ...registration.env },
sensitiveValues: ['secret-image-delivery', ...registration.sensitiveValues],
};
let worker = server.createWorker(workerOptions);
try {
for (let pass = 0; pass < 2; pass += 1) {
await worker.start();
await extensionHost.bindRun('image-delivery', 1, 'run-image-delivery');
const settled = new Promise<void>((resolve) => {
const unsubscribe = worker.subscribe((event) => {
if (event.type !== 'agent_settled') return;
unsubscribe();
resolve();
});
});
const image = { type: 'image', data: 'aW1hZ2U=', mimeType: 'image/png' };
await worker.request(pass === 0
? { type: 'prompt', message: 'Describe this image', images: [image] }
: { type: 'prompt', message: 'Describe the earlier image again' });
await expect.poll(() => provider.requests.length).toBe(pass + 1);
const request = JSON.parse(provider.requests[pass].body);
expect(request.messages).toContainEqual({
role: 'user', content: [
{ type: 'text', text: 'Describe this image' },
{ type: 'image_url', image_url: { url: 'data:image/png;base64,aW1hZ2U=' } },
],
});
provider.release();
await settled;
await worker.stop('test_injection');
if (pass === 0) worker = server.createWorker(workerOptions);
}
} finally {
await worker.stop('test_injection').catch(() => undefined);
await server.stop().catch(() => undefined);
await registration.dispose().catch(() => undefined);
await extensionHost.close().catch(() => undefined);
await provider.close().catch(() => undefined);
}
}, 10_000);
it('boots from the packaged sibling resource layout', async () => {
const root = await mkdtemp(path.join(tmpdir(), 'makelore-pi-agent-server-layout-'));
roots.push(root);

View File

@@ -171,6 +171,49 @@ afterEach(async () => {
});
describe('Pi Conversation runtime', () => {
it.each(['What is in this picture?', ''])('keeps submitted image references visible before Pi emits its user message (%s)', async (text) => {
const projectPath = await mkdtemp(path.join(tmpdir(), 'makelore-image-runtime-'));
roots.push(projectPath);
const projectStore = createCodingProjectStore(createMemoryCodingProjectStorage(), {
createId: () => 'project-image', now: () => NOW,
});
await createLocalCodingProject({ projectPath, now: NOW }, projectStore);
const model = { accountId: 'account-image', modelId: 'deepseek-flash', thinkingLevel: 'off' as const };
await createCodingProjectAgent(projectPath, {
id: 'agent-image', avatarId: 'avatar-01', roleName: 'Reader', name: 'Reader',
model, modelResolution: 'resolved',
responsibility: { mission: 'Read images', owns: [], boundaries: [], collaborators: [], principles: [] },
}, { now: NOW });
const conversation = await createCodingConversationStore(projectPath).create({
agentId: 'agent-image', title: 'Image', model, modelResolution: 'resolved',
});
let worker: RuntimeFakeWorker;
const runtime = new PiConversationRuntime({
pool: new PiWorkerPool({ openWorker: async ({ generation }) => {
worker = new RuntimeFakeWorker('image-worker', generation);
return { worker, session: { piSessionId: 'image-session', sessionKey: 'image-key' } };
} }),
registry: new PiSessionRegistry({ projectStore }),
resolveModel: async (candidate) => ({ ...candidate, runtimeProviderId: 'image-provider', input: ['text', 'image'] }),
resolveImages: async () => [{ type: 'image', data: 'aW1hZ2U=', mimeType: 'image/png' }],
});
try {
await runtime.prepare({ conversationId: conversation.id, projectId: 'project-image',
agentId: 'agent-image', title: 'Image', model: { model, modelResolution: 'resolved' } });
await runtime.prompt({ conversationId: conversation.id, clientRequestId: 'image-request',
mode: 'prompt', text, attachments: [{ attachmentId: 'image-attachment' }] });
expect(worker!.requests.find(command => command.type === 'prompt')).toMatchObject({
images: [{ type: 'image', data: 'aW1hZ2U=', mimeType: 'image/png' }],
});
const snapshot = await runtime.getSnapshot(conversation.id);
expect(snapshot.nodes).toContainEqual(expect.objectContaining({
role: 'user', blocks: expect.arrayContaining([expect.objectContaining({
kind: 'image', attachmentId: 'image-attachment', mime: 'image/png',
})]),
}));
} finally { await runtime.shutdown(); }
});
it('persists native managed choices and rejects removed effort or unsupported images before prompt', async () => {
const projectPath = await mkdtemp(path.join(tmpdir(), 'makelore-managed-runtime-'));
roots.push(projectPath);