gongxiangp2/apps/detail-agent-pi/test/native-wire.test.mjs
WuYongJie 1b6425b552 feat(desktop): connect native Pi sessions with scoped rules and recovery
Track the fixed PhotoCraft source baseline with the existing native client integration. Use the actual Pi SDK for normal sends, durable session recovery, demand-loaded enterprise rules and checked undoable canvas tools. Retain request identities and never resend unknown inference. Include local packaging and real-call acceptance evidence.
2026-10-22 10:49:58 +08:00

127 lines
9.8 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { randomUUID } from 'node:crypto';
import { ProjectStore } from '../src/store.mjs';
import { DetailPiAgent } from '../src/agent.mjs';
import { auditResponse } from '../src/response-audit.mjs';
import { projectContext } from '../src/context-budget.mjs';
import { cleanup } from './cleanup.mjs';
import { decodeToolReply } from '../src/json-tool-codec.mjs';
import { normalizeContext } from '@earendil-works/pi-ai/compat';
import { createAssistantMessageEventStream } from '@earendil-works/pi-ai/compat';
import { SessionManager } from '@earendil-works/pi-coding-agent';
import { atomic } from '../src/store.mjs';
const sse = (delta, finish) => `data: ${JSON.stringify({ id: 'wire', model: 'qa', choices: [{ index: 0, delta, finish_reason: finish }] })}\n\ndata: [DONE]\n\n`;
async function setup(reply) {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'pi-recovery-'));
const scope = 'f'.repeat(64), store = await ProjectStore.open(dir, { scope, project: randomUUID() }, { brandId: 'qa', shopId: 'A', categoryId: 'desk', platform: 'qa', version: '1', source: 'qa', rules: [] });
const requests = [];
const broker = { info: async () => ({ accountScope: scope, model: { id: 'qa', protocol: 'openai-chat' }, pending: [] }), round() {
return { id: randomUUID(), fetch: async (_url, init) => {
const p = JSON.parse(init.body); requests.push(p);
return new Response(reply(p, requests.length), { headers: { 'content-type': 'text/event-stream' } });
}, finish: async valid => { assert.equal(valid, true); return { phase: 'complete' }; } };
} };
const host = await DetailPiAgent.open({ store, broker, callBudget: 3 });
return { host, store, requests, close: async () => { await host.dispose(); await cleanup(dir); } };
}
test('actual native Pi wire sends function schemas, executes typed call and accepts a plain final reply', async () => {
const x = await setup((_p, n) => n === 1 ? sse({ tool_calls: [{ index: 0, id: 'call-memory', type: 'function', function: { name: 'memory_set', arguments: JSON.stringify({ key: 'color', value: '胡桃色', evidence: '测试颜色是胡桃色' }) } }] }, 'tool_calls') : sse({ content: '已记录胡桃色' }, 'stop'));
try {
const r = await x.host.prompt('测试颜色是胡桃色,请记住。', { requiredTool: 'memory_set' });
assert.equal(r.text, '已记录胡桃色');
assert.equal((await x.store.context()).confirmedMemory.color.value, '胡桃色');
assert.equal(x.requests.length, 2);
assert.deepEqual(x.requests[0].tool_choice, { type: 'function', function: { name: 'memory_set' } });
assert.equal(x.requests[0].parallel_tool_calls, false);
assert.ok(x.requests[0].tools.some(t => t.type === 'function' && t.function.name === 'memory_set'));
assert.ok(x.requests[1].messages.some(m => m.role === 'tool' && m.tool_call_id === 'call-memory'));
assert.equal(x.requests[0].response_format, undefined);
} finally { await x.close(); }
});
test('native partial arguments and length-stopped valid prefixes never execute memory writes', async () => {
for (const [args, finish] of [['{"key":"color","value":"红色","evidence":"测试颜色是红色"', 'tool_calls'], ['{"key":"color","value":"红色","evidence":"测试颜色是红色"}', 'length']]) {
const x = await setup(() => sse({ tool_calls: [{ index: 0, id: 'partial', type: 'function', function: { name: 'memory_set', arguments: args } }] }, finish));
try {
await assert.rejects(x.host.prompt('测试颜色是红色'), /未完整|输出上限/);
assert.deepEqual((await x.store.context()).confirmedMemory, {});
assert.equal(x.requests.length, 1);
} finally { await x.close(); }
}
});
test('required tool returned as prose is a known protocol failure with one POST and no execution', async () => {
const x = await setup(() => sse({ content: 'invoke tool memory_set with color red' }, 'stop'));
try {
await assert.rejects(x.host.prompt('测试颜色是红色', { requiredTool: 'memory_set' }), /未返回要求的标准函数调用/);
assert.equal(x.requests.length, 1); assert.deepEqual((await x.store.context()).confirmedMemory, {});
} finally { await x.close(); }
});
test('a literal marker in a normal explanation is ordinary text, never a command or a repair trigger', async () => {
const x = await setup(() => sse({ content: '结束标记的示例是 <|eos|>。' }, 'stop'));
try { const r = await x.host.prompt('解释一个结束标记,不要调用工具'); assert.match(r.text, /结束标记/); assert.equal(x.requests.length, 1); }
finally { await x.close(); }
});
test('projection bounds stale canvas bodies without deleting original facts, errors or evidence', () => {
const large = { revision: 1, context: { layers: Array.from({ length: 200 }, (_, i) => ({ id: i, name: '完整画布数据'.repeat(20) })) } };
const m = { role: 'toolResult', toolName: 'canvas_read', toolCallId: 'old', content: [{ type: 'text', text: JSON.stringify(large) }], details: large };
const messages = [{ role: 'user', content: '原始用户事实保留' }, m, { ...m, toolCallId: 'new' }];
const before = JSON.stringify(messages), out = projectContext({ messages });
assert.equal(JSON.stringify(messages), before); assert.equal(out.messages[0], messages[0]);
assert.ok(out.messages[1].content[0].text.length < 300); assert.equal(out.messages[1].details, undefined);
assert.equal(out.messages[2].content[0].text, m.content[0].text);
});
test('wire audit detects complete SSE with a literal early EOS instead of blaming the browser', () => {
const a = auditResponse(sse({ content: '{"reply":"未结束<|eos|>' }, 'stop'), 'openai-chat');
assert.equal(a.literalEos, true); assert.deepEqual(a.finishes, ['stop']);
});
test('a terminal marker without a completion reason remains unproven and cannot execute a tool', () => {
assert.throws(() => auditResponse(sse({ content: '只有前缀' }, null), 'openai-chat'), /缺少完成原因/);
});
test('JSON compatibility errors distinguish complete prose from a broken JSON envelope', () => {
const c = normalizeContext({ messages: [] });
assert.throws(() => decodeToolReply('产品编号与颜色核对正确。', c, 'id'), /文字而非约定的JSON/);
assert.throws(() => decodeToolReply('{"reply":"只返回一半<|eos|>', c, 'id'), /提前结束标记/);
assert.throws(() => decodeToolReply('{"reply":"未闭合', c, 'id'), /JSON指令未完整/);
});
async function compactionFixture({ withCanvas, callBudget = 4, large = false }) {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'pi-recovery-')), scope = '9'.repeat(64);
const store = await ProjectStore.open(dir, { scope, project: randomUUID() }, { brandId: 'qa', shopId: 'qa', categoryId: 'qa', platform: 'qa', source: 'qa', version: '1', rules: [] });
const manager = SessionManager.create(store.dir, path.join(store.dir, 'sessions'));
for (let i = 0; i < (large ? 30 : 12); i++) {
manager.appendMessage({ role: 'user', content: `archive-marker-${i}: ` + 'x'.repeat(large ? 1400 : 500), timestamp: Date.now() });
manager.appendMessage({ role: 'assistant', api: 'openai-completions', provider: 'jiansun-native-98k', model: 'qa', content: [{ type: 'text', text: 'Synthetic, not a live model reply. ' + 'y'.repeat(large ? 1400 : 500) }], stopReason: 'stop', timestamp: Date.now(), usage: { input: 5000, output: 500, cacheRead: 0, cacheWrite: 0, totalTokens: 5500, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } } });
}
await atomic(path.join(store.dir, 'session.json'), { file: manager.getSessionFile() });
let calls = 0, canvasCalls = 0;
const canvas = withCanvas ? { read() { canvasCalls++; throw Error('no canvas edit run during compaction'); }, delta() { canvasCalls++; throw Error('must not show internal summary'); } } : undefined;
const transport = { info: { accountScope: scope, model: { id: 'qa', protocol: 'openai-chat' }, pending: [] }, stream(model) {
calls++;
const stream = createAssistantMessageEventStream();
const message = { role: 'assistant', api: model.api, provider: model.provider, model: model.id, content: [{ type: 'text', text: 'Summary: original evidence remains archived.' }], stopReason: 'stop', timestamp: Date.now(), usage: { input: 5000, output: 20, cacheRead: 0, cacheWrite: 0, totalTokens: 5020, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } } };
queueMicrotask(() => { stream.push({ type: 'start', partial: message }); stream.push({ type: 'text_delta', contentIndex: 0, delta: message.content[0].text, partial: message }); stream.push({ type: 'done', reason: 'stop', message }); stream.end(message); });
return stream;
} };
const host = await DetailPiAgent.open({ store, transport, canvas, callBudget });
return { host, get calls() { return calls; }, get canvasCalls() { return canvasCalls; }, close: async () => { await host.dispose(); await cleanup(dir); } };
}
test('compaction with a connected canvas does not call an inactive edit run or show the internal summary', async () => {
const x = await compactionFixture({ withCanvas: true });
try {
const result = await x.host.compact();
assert.match(result.summary, /original evidence/); assert.ok(x.calls > 0); assert.equal(x.canvasCalls, 0);
assert.ok(x.host.manager.getEntries().some(e => e.type === 'message' && e.message.role === 'user' && e.message.content.includes('archive-marker-0')));
} finally { await x.close(); }
});
test('automatic compaction and subsequent inference share one user-turn call budget', async () => {
const x = await compactionFixture({ withCanvas: false, callBudget: 1, large: true });
try {
await assert.rejects(x.host.prompt('继续处理,但不能绕过预算'), /预算/);
assert.equal(x.calls, 1);
} finally { await x.close(); }
});