fix(pi): serialize write-leased tool batches
This commit is contained in:
1 parent
6073bd6f4c
commit
49112b688d
7 files changed
+304
-4
No files matched your search
@@ -6,6 +6,7 @@ import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { PiAgentServerProcess } from '../../electron/coding-runtime/pi/agent-server-process';
|
||||
import { PiManagedExtensionHost } from '../../electron/coding-runtime/pi/extension-host';
|
||||
import { materializeMakelorePiExtension } from '../../electron/coding-runtime/pi/extensions/makelore-runtime';
|
||||
import { MAKELORE_DEFAULT_LANGUAGE_PROMPT } from '../../electron/coding-runtime/pi/resource-loader';
|
||||
import type { PiRpcEvent } from '../../electron/coding-runtime/pi/rpc-client';
|
||||
@@ -81,11 +82,207 @@ async function startHeldProvider(): Promise<{
|
||||
};
|
||||
}
|
||||
|
||||
async function startBashBatchProvider(): Promise<{
|
||||
baseUrl: string;
|
||||
requests: Array<Record<string, unknown>>;
|
||||
close(): Promise<void>;
|
||||
}> {
|
||||
const requests: Array<Record<string, unknown>> = [];
|
||||
const provider = createServer((request, response) => {
|
||||
const chunks: Buffer[] = [];
|
||||
request.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
request.on('end', () => {
|
||||
const body = JSON.parse(Buffer.concat(chunks).toString('utf8')) as Record<string, unknown>;
|
||||
requests.push(body);
|
||||
const messages = Array.isArray(body.messages) ? body.messages : [];
|
||||
const hasToolResult = messages.some((message) => (
|
||||
message && typeof message === 'object' && !Array.isArray(message)
|
||||
&& (message as { role?: unknown }).role === 'tool'
|
||||
));
|
||||
response.writeHead(200, { 'content-type': 'text/event-stream' });
|
||||
const writeChunk = (delta: Record<string, unknown>, finishReason: string | null) => {
|
||||
response.write(`data: ${JSON.stringify({
|
||||
id: 'chatcmpl-bash-batch-test',
|
||||
object: 'chat.completion.chunk',
|
||||
created: Math.floor(Date.now() / 1_000),
|
||||
model: 'test-model',
|
||||
choices: [{ index: 0, delta, finish_reason: finishReason }],
|
||||
...(finishReason ? { usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 } } : {}),
|
||||
})}\n\n`);
|
||||
};
|
||||
if (!hasToolResult) {
|
||||
writeChunk({
|
||||
role: 'assistant',
|
||||
tool_calls: [
|
||||
{
|
||||
index: 0,
|
||||
id: 'bash-timeout',
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'bash',
|
||||
arguments: JSON.stringify({ command: 'sleep 1', timeout: 0.05 }),
|
||||
},
|
||||
},
|
||||
{
|
||||
index: 1,
|
||||
id: 'bash-success',
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'bash',
|
||||
arguments: JSON.stringify({ command: 'printf SECOND_OK' }),
|
||||
},
|
||||
},
|
||||
],
|
||||
}, null);
|
||||
writeChunk({}, 'tool_calls');
|
||||
} else {
|
||||
writeChunk({ role: 'assistant', content: 'BASH_BATCH_DONE' }, null);
|
||||
writeChunk({}, 'stop');
|
||||
}
|
||||
response.end('data: [DONE]\n\n');
|
||||
});
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
provider.once('error', reject);
|
||||
provider.listen(0, '127.0.0.1', resolve);
|
||||
});
|
||||
const address = provider.address();
|
||||
if (!address || typeof address === 'string') throw new Error('Test Provider did not bind');
|
||||
return {
|
||||
baseUrl: `http://127.0.0.1:${address.port}/v1`,
|
||||
requests,
|
||||
close: async () => {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
provider.close((error) => error ? reject(error) : resolve());
|
||||
provider.closeIdleConnections?.();
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
describe('Pi Agent Server real process', () => {
|
||||
it('executes two write-leased Bash calls without waiting for the HTTP idle timeout', async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), 'makelore-pi-agent-server-bash-batch-'));
|
||||
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 startBashBatchProvider();
|
||||
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: 'test-model',
|
||||
name: 'Test model',
|
||||
reasoning: false,
|
||||
input: ['text'],
|
||||
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, 'Use the provided Bash calls.'),
|
||||
writeFile(languagePromptPath, MAKELORE_DEFAULT_LANGUAGE_PROMPT),
|
||||
]);
|
||||
const extensionHost = new PiManagedExtensionHost();
|
||||
const registration = await extensionHost.registerWorker({
|
||||
conversationId: 'bash-batch',
|
||||
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 worker = server.createWorker({
|
||||
executablePath: process.execPath,
|
||||
cliPath: path.join(runtimeRoot, 'dist', 'cli.js'),
|
||||
cwd: projectPath,
|
||||
configDir,
|
||||
sessionDir,
|
||||
conversationId: 'bash-batch',
|
||||
workerGeneration: 1,
|
||||
tools: ['bash'],
|
||||
additionalArgs: [
|
||||
'--provider', 'makelore-test',
|
||||
'--model', 'test-model',
|
||||
'--thinking', 'off',
|
||||
'--system-prompt', promptPath,
|
||||
'--append-system-prompt', languagePromptPath,
|
||||
'--extension', registration.extensionPath,
|
||||
'--session-id', 'session-bash-batch',
|
||||
],
|
||||
env: { MAKELORE_TEST_KEY: 'secret-bash-batch', ...registration.env },
|
||||
sensitiveValues: ['secret-bash-batch', ...registration.sensitiveValues],
|
||||
});
|
||||
try {
|
||||
await worker.start();
|
||||
await extensionHost.bindRun('bash-batch', 1, 'run-bash-batch');
|
||||
const settled = new Promise<void>((resolve) => {
|
||||
const unsubscribe = worker.subscribe((event) => {
|
||||
if (event.type !== 'agent_settled') return;
|
||||
unsubscribe();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
const startedAt = Date.now();
|
||||
await worker.request({ type: 'prompt', message: 'RUN_BASH_BATCH' });
|
||||
await settled;
|
||||
expect(Date.now() - startedAt).toBeLessThan(2_000);
|
||||
const messagesResponse = await worker.request<{ messages: Array<Record<string, unknown>> }>({
|
||||
type: 'get_messages',
|
||||
});
|
||||
const toolResults = messagesResponse.data?.messages.filter(({ role }) => role === 'toolResult') ?? [];
|
||||
const textFor = (toolCallId: string) => {
|
||||
const message = toolResults.find((candidate) => candidate.toolCallId === toolCallId);
|
||||
const content = Array.isArray(message?.content) ? message.content : [];
|
||||
return content.flatMap((item) => (
|
||||
item && typeof item === 'object' && !Array.isArray(item)
|
||||
&& typeof (item as { text?: unknown }).text === 'string'
|
||||
? [(item as { text: string }).text]
|
||||
: []
|
||||
)).join('\n');
|
||||
};
|
||||
expect(textFor('bash-timeout')).toContain('Command timed out after 0.05 seconds');
|
||||
expect(textFor('bash-success')).toContain('SECOND_OK');
|
||||
expect(JSON.stringify(toolResults)).not.toContain('fetch failed');
|
||||
expect(provider.requests).toHaveLength(2);
|
||||
} 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('hosts isolated Conversation threads in one long-lived process', async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), 'makelore-pi-agent-server-'));
|
||||
roots.push(root);
|
||||
|
||||
@@ -22,6 +22,7 @@ type ExtensionHandler = (...arguments_: unknown[]) => Promise<unknown> | unknown
|
||||
type ExtensionTool = {
|
||||
name: string;
|
||||
parameters?: Record<string, unknown>;
|
||||
executionMode?: 'sequential';
|
||||
execute?: (...arguments_: unknown[]) => Promise<unknown>;
|
||||
};
|
||||
|
||||
@@ -213,6 +214,7 @@ describe('Makelore Pi extension bundle', () => {
|
||||
expect(tools.has('data_service_inspect')).toBe(true);
|
||||
expect(tools.has('data_service_put_document')).toBe(false);
|
||||
expect(tools.get('data_service_inspect')?.parameters).toEqual(pluginTool.inputSchema);
|
||||
expect(tools.get('data_service_inspect')?.executionMode).toBe('sequential');
|
||||
const context = JSON.parse(await readFile(registration.env.MAKELORE_PI_CONTEXT_FILE as string, 'utf8'));
|
||||
expect(context).toMatchObject({
|
||||
catalogRevision: 19,
|
||||
|
||||
@@ -227,7 +227,7 @@ describe('managed Pi worker opener', () => {
|
||||
expect(argv).toContain('grilling');
|
||||
expect(argv).toContain('--session-id');
|
||||
expect(argv).toContain('--extension');
|
||||
expect(argv).toContain('makelore-runtime-v4.mjs');
|
||||
expect(argv).toContain('makelore-runtime-v5.mjs');
|
||||
expect(options.additionalArgs?.filter((argument) => argument === '--extension')).toHaveLength(1);
|
||||
expect(argv).not.toContain('PRIVATE MANAGED PROMPT');
|
||||
expect(argv).not.toContain('provider-secret-value');
|
||||
|
||||
Reference in new issue
Block a user