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.
127 lines
9.8 KiB
JavaScript
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(); }
|
|
});
|