feat: upgrade investigation and presentation runtime
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
// Opt-in behavioral evaluation: real inference, synthetic user data, isolated native sessions.
|
||||
import "../../src/config.js";
|
||||
import assert from "node:assert/strict";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { createServer } from "node:http";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { OpenCodeClient } from "@opencode/client";
|
||||
import { getAgentModelOptions, loadAgentModelConfig, parseModelReference } from "../../src/chat/modelConfig.js";
|
||||
import { answerUiAnchors, AnswerUiPublication } from "../../src/chat/answer-ui-publication.js";
|
||||
import { parseAnswerUiInput } from "../../src/chat/answer-ui.js";
|
||||
import { serviceOpencodeConfig } from "../../src/runtime/opencode2-config.js";
|
||||
import { startOpencode2 } from "../../src/runtime/opencode2-server.js";
|
||||
import { createConversationWorkspace } from "../../src/runtime/conversationWorkspace.js";
|
||||
|
||||
if (process.env.TJWATER_ANSWER_UI_EVAL !== "1") {
|
||||
console.log("Skipped. Set TJWATER_ANSWER_UI_EVAL=1 to send three synthetic prompts to a real model.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const cases = [
|
||||
{ id: "comparison", component: "Table", prompt: "以下仅为演练资料:管道甲管径400 mm、长度120 m;乙管径250 mm、长度90 m;丙管径300 mm、长度150 m。请比较这三根管道,并说明哪些结论不能仅凭这些属性判断。只依据这些资料回答,不查询外部数据。" },
|
||||
{ id: "trend", component: "Chart", prompt: "以下仅为演练资料:今天北京时间08:00压力0.42 MPa,09:00压力0.36 MPa,10:00压力0.24 MPa。请分析压力的变化趋势,指出哪个时段下降更明显,并说明判断局限。只依据这些资料回答,不查询外部数据。" },
|
||||
{ id: "short-explanation", component: null, prompt: "请用一句话解释供水管网中的压力是什么意思。" },
|
||||
];
|
||||
const models = loadAgentModelConfig();
|
||||
const model = process.env.TJWATER_ANSWER_UI_EVAL_MODEL ?? models.defaults.chat;
|
||||
assert(getAgentModelOptions(models).some((option) => option.id === model), "Select an enabled chat model from agent-models.json");
|
||||
const { providerID, modelID } = parseModelReference(model);
|
||||
const root = await mkdtemp(join(tmpdir(), "tjwater-answer-ui-selection-"));
|
||||
const controller = new AbortController();
|
||||
const stop = () => controller.abort();
|
||||
process.once("SIGINT", stop);
|
||||
process.once("SIGTERM", stop);
|
||||
let native: Awaited<ReturnType<typeof startOpencode2>> | undefined;
|
||||
let sessionID: string | undefined;
|
||||
let failures = 0;
|
||||
const internalToken = randomBytes(32).toString("hex");
|
||||
// Only the host-question gate is mocked; both presentation tools execute their real code.
|
||||
const bridge = createServer(async (req, res) => {
|
||||
if (req.method !== "POST" || req.headers["x-agent-internal-token"] !== internalToken) { res.writeHead(403).end(); return; }
|
||||
let raw = "";
|
||||
for await (const chunk of req) raw += chunk;
|
||||
const body = JSON.parse(raw) as { session_id?: string; tool?: string };
|
||||
if (!sessionID || body.session_id !== sessionID) { res.writeHead(403).end(); return; }
|
||||
if (req.url === "/internal/chat-interactions/policy") {
|
||||
res.setHeader("content-type", "application/json"); res.end(JSON.stringify({ enabled: false, waiting: false }));
|
||||
} else if (["/internal/chat-interactions/enter", "/internal/chat-interactions/leave"].includes(req.url ?? "")
|
||||
&& ["present_answer", "final_answer"].includes(body.tool ?? "")) {
|
||||
res.setHeader("content-type", "application/json"); res.end("{}");
|
||||
} else res.writeHead(403).end();
|
||||
});
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => { bridge.once("error", reject); bridge.listen(0, "127.0.0.1", resolve); });
|
||||
const bridgeUrl = `http://127.0.0.1:${(bridge.address() as { port: number }).port}`;
|
||||
const source = JSON.parse(await readFile("opencode.json", "utf8"));
|
||||
const permissions = [
|
||||
{ action: "*", resource: "*", effect: "deny" as const },
|
||||
...["present_answer", "final_answer"].map((action) => ({ action, resource: "*", effect: "allow" as const })),
|
||||
];
|
||||
const nativeConfig = serviceOpencodeConfig({ ...source, mcp: { servers: {} }, plugins: [] }, models, root);
|
||||
nativeConfig.permissions = permissions;
|
||||
nativeConfig.skills = [];
|
||||
nativeConfig.agents.instruction.permissions = permissions;
|
||||
native = await startOpencode2({
|
||||
command: process.env.OPENCODE_COMMAND ?? "opencode2", hostname: "127.0.0.1", port: 0,
|
||||
timeout: 30_000, signal: controller.signal, config: nativeConfig,
|
||||
env: {
|
||||
AGENT_INTERNAL_TOKEN: internalToken, TJWATER_AGENT_WORKSPACE_ROOT: root,
|
||||
TJWATER_AGENT_INTERNAL_BASE_URL: bridgeUrl,
|
||||
TJWATER_AGENT_MCP_CONNECTION_PATH: join(root, "absent-query-mcp.json"),
|
||||
OPENCODE_DB: join(root, "data", "opencode.db"), XDG_DATA_HOME: join(root, "data"),
|
||||
XDG_CONFIG_HOME: join(root, "config"), XDG_CACHE_HOME: join(root, "cache"), XDG_STATE_HOME: join(root, "state"),
|
||||
},
|
||||
});
|
||||
console.log(JSON.stringify({ model, cases: cases.length, scope: "native component selection; no business access or browser rendering" }));
|
||||
for (const scenario of cases) {
|
||||
controller.signal.throwIfAborted();
|
||||
const started = Date.now();
|
||||
const directory = await createConversationWorkspace(root);
|
||||
sessionID = (await native.client.session.create({ agent: "instruction", model: { providerID, id: modelID }, location: { directory } })).id;
|
||||
try {
|
||||
const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(120_000)]);
|
||||
await native.client.session.prompt({ sessionID, text: scenario.prompt }, { signal });
|
||||
await native.client.session.wait({ sessionID }, { signal });
|
||||
const messages: Awaited<ReturnType<OpenCodeClient["message"]["list"]>>["data"] = (await native.client.message.list({ sessionID, order: "asc", limit: 100 }, { signal })).data;
|
||||
const publication = new AnswerUiPublication();
|
||||
const blocks = new Map<string, NonNullable<ReturnType<typeof parseAnswerUiInput>>>();
|
||||
let final: { answer: string; ui_block_ids: string[] } | undefined;
|
||||
for (const message of messages) {
|
||||
if (message.type !== "assistant") continue;
|
||||
assert(!message.error, "Model response failed");
|
||||
for (const content of message.content) {
|
||||
if (content.type !== "tool") continue;
|
||||
assert(["present_answer", "final_answer"].includes(content.name), "Unexpected tool attempted");
|
||||
assert.equal(content.state.status, "completed", "Presentation tool did not complete");
|
||||
if (content.state.status !== "completed") continue;
|
||||
const input = content.state.input;
|
||||
if (content.name === "present_answer") {
|
||||
const block = parseAnswerUiInput(input);
|
||||
assert(block, "Invalid answer UI payload");
|
||||
assert.equal(publication.publish(block), undefined, "Invalid publication lifecycle");
|
||||
blocks.set(block.block_id, block);
|
||||
} else {
|
||||
assert.equal(final, undefined, "Final answer was submitted more than once");
|
||||
const value = input as { answer: string; ui_block_ids?: string[] };
|
||||
final = { answer: value.answer, ui_block_ids: value.ui_block_ids ?? [] };
|
||||
assert.deepEqual([...new Set(answerUiAnchors(final.answer))], final.ui_block_ids, "Final anchors differ from references");
|
||||
assert.deepEqual(publication.finalize(final.ui_block_ids), final.ui_block_ids, "Final answer references unavailable blocks");
|
||||
}
|
||||
}
|
||||
}
|
||||
assert(!messages.some((message) => message.type === "idle" && message.outcome === "failed"), "Native session failed before completion");
|
||||
assert(final, "Missing final answer");
|
||||
assert(final.answer.trim(), "Empty final answer");
|
||||
const nodes = final.ui_block_ids.flatMap((id) => Object.values(blocks.get(id)!.spec.elements));
|
||||
if (scenario.component) {
|
||||
const matches = nodes.filter((node) => node.type === scenario.component);
|
||||
assert(matches.length > 0, `Final answer did not retain ${scenario.component}`);
|
||||
if (scenario.component === "Chart") {
|
||||
assert(matches.some((node) => {
|
||||
const props = node.props as { x_data: string[]; series: Array<{ data: number[] }> };
|
||||
return props.x_data.length === 3 && ["08:00", "09:00", "10:00"].every((time, i) => props.x_data[i].includes(time))
|
||||
&& props.series.some((series) => JSON.stringify(series.data) === "[0.42,0.36,0.24]")
|
||||
&& JSON.stringify(node.props).includes("MPa");
|
||||
}), "Trend lost input values, times or units");
|
||||
} else {
|
||||
assert(matches.some((node) => {
|
||||
const props = node.props as { rows: string[][] };
|
||||
return props.rows.length === 3 && [["甲", "400", "120"], ["乙", "250", "90"], ["丙", "300", "150"]]
|
||||
.every((values) => props.rows.some((row) => values.every((value) => row.some((cell) => cell.includes(value)))));
|
||||
}), "Comparison lost supplied objects or values");
|
||||
}
|
||||
} else assert.equal(blocks.size, 0, "Short explanation generated unnecessary UI");
|
||||
console.log(JSON.stringify({ case: scenario.id, passed: true, components: nodes.map((node) => node.type), duration_ms: Date.now() - started }));
|
||||
} catch (error) {
|
||||
failures += 1;
|
||||
// Assertion messages are ours; never print provider response bodies or credentials.
|
||||
console.log(JSON.stringify({ case: scenario.id, passed: false, reason: error instanceof assert.AssertionError ? error.message : "Native request failed or timed out", duration_ms: Date.now() - started }));
|
||||
} finally {
|
||||
await native.client.session.interrupt({ sessionID }).catch(() => {});
|
||||
await native.client.session.remove({ sessionID }).catch(() => {});
|
||||
sessionID = undefined;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (native && sessionID) await native.client.session.remove({ sessionID }).catch(() => {});
|
||||
native?.close();
|
||||
if (native) await native.exited;
|
||||
await new Promise<void>((resolve) => bridge.close(() => resolve()));
|
||||
await rm(root, { recursive: true, force: true });
|
||||
process.removeListener("SIGINT", stop);
|
||||
process.removeListener("SIGTERM", stop);
|
||||
}
|
||||
if (failures) process.exitCode = 1;
|
||||
@@ -0,0 +1,43 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { mkdtemp,mkdir,rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { OpenCode } from '@opencode/client';
|
||||
// Opt in: RUN_HOST_QUESTION_NATIVE_SMOKE=1 bun tests/integration/hostQuestionNativeSmoke.ts
|
||||
// Uses only owned temporary roots and resume:false; never calls a provider.
|
||||
if (process.env.RUN_HOST_QUESTION_NATIVE_SMOKE !== "1") {
|
||||
console.log("Skipped native host-question smoke; set RUN_HOST_QUESTION_NATIVE_SMOKE=1 to run.");
|
||||
process.exit(0);
|
||||
}
|
||||
const root=await mkdtemp(join(tmpdir(),'tjwater-native-question-'));
|
||||
await mkdir(join(root,'home')); await mkdir(join(root,'workspace'));
|
||||
let child:any;
|
||||
async function start(){
|
||||
child=spawn(process.env.OPENCODE_COMMAND ?? 'opencode',['serve','--hostname=127.0.0.1','--port=0'],{cwd:root,env:{PATH:process.env.PATH,HOME:join(root,'home'),XDG_CONFIG_HOME:join(root,'config'),XDG_DATA_HOME:join(root,'data'),XDG_CACHE_HOME:join(root,'cache'),OPENCODE_DB:join(root,'native.sqlite'),OPENCODE_CONFIG_CONTENT:JSON.stringify({})},stdio:['ignore','pipe','pipe']});
|
||||
const endpoint=await new Promise<any>((resolve,reject)=>{let output='';const timer=setTimeout(()=>reject(new Error('startup timeout')),20000);child.on('exit',()=>reject(new Error('startup exit')));child.stderr.resume();child.stdout.on('data',(chunk:any)=>{output+=chunk;const url=/server listening on (https?:\/\/\S+)/.exec(output)?.[1];const password=/server password (\S+)/.exec(output)?.[1];if(url&&password){clearTimeout(timer);resolve({url,password});}});});
|
||||
return OpenCode.make({baseUrl:endpoint.url,headers:{authorization:`Basic ${Buffer.from(`opencode:${endpoint.password}`).toString('base64')}`}});
|
||||
}
|
||||
async function stop(){if(!child)return; const closing=new Promise<void>(r=>child.once('close',()=>r()));child.kill('SIGTERM');await closing;child=undefined;}
|
||||
try{
|
||||
let client=await start();
|
||||
const session=await client.session.create({title:'No-inference host question smoke',location:{directory:join(root,'workspace')}});
|
||||
const input={sessionID:session.id,id:'msg_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',text:'Durable fixed-ID user answer',resume:false};
|
||||
await client.session.prompt(input);
|
||||
let duplicate='accepted';try{await client.session.prompt(input);}catch(e:any){duplicate=e?._tag??e?.name??'rejected';}
|
||||
const queued=await client.session.inbox.list({sessionID:session.id});
|
||||
if(queued.filter((x:any)=>x.id===input.id).length!==1)throw new Error('fixed input duplicated');
|
||||
if((await client.session.active())[session.id])throw new Error('resume:false started inference');
|
||||
const form=await client.session.form.create({sessionID:session.id,title:'Smoke question',fields:[{key:'q',type:'string',required:true}]});
|
||||
const before=await client.session.form.get({sessionID:session.id,formID:form.id});
|
||||
if(before.state.status !== 'pending') throw new Error('new form was not pending');
|
||||
await client.session.form.reply({sessionID:session.id,formID:form.id,answer:{q:'answer'}});
|
||||
let terminal='unknown';try{terminal=(await client.session.form.get({sessionID:session.id,formID:form.id})).state.status;}catch(e:any){terminal=e?._tag;}
|
||||
const cancelled=await client.session.form.create({sessionID:session.id,title:'Cancel smoke',fields:[{key:'q',type:'string'}]});
|
||||
await client.session.form.cancel({sessionID:session.id,formID:cancelled.id});
|
||||
let cancelledState='unknown';try{cancelledState=(await client.session.form.get({sessionID:session.id,formID:cancelled.id})).state.status;}catch(e:any){cancelledState=e?._tag;}
|
||||
await stop();client=await start();
|
||||
const recovered=await client.session.inbox.list({sessionID:session.id});
|
||||
if(recovered.filter((x:any)=>x.id===input.id).length!==1)throw new Error('fixed input lost after restart');
|
||||
let restored='unknown';try{restored=(await client.session.form.get({sessionID:session.id,formID:form.id})).state.status;}catch(e:any){restored=e?._tag;}
|
||||
console.log(JSON.stringify({native:'current isolated executable',fixed_id_unique:true,duplicate_result:duplicate,restart_durable:true,form_pending:before.state.status,form_after_reply:terminal,form_after_cancel:cancelledState,form_after_restart:restored,inference_started:false}));
|
||||
}catch(e:any){console.log(JSON.stringify({error:e?.message,tag:e?._tag,name:e?.name}));process.exitCode=1;}finally{await stop();await rm(root,{recursive:true,force:true});}
|
||||
@@ -78,7 +78,7 @@ try {
|
||||
for (const action of ["browser", "browser_click"]) {
|
||||
assert.equal((await native.client.permission.create({ sessionID: sessionId, action, resources: ["*"], save: [] })).effect, "deny");
|
||||
}
|
||||
for (const action of ["present_answer", "show_chart"]) {
|
||||
for (const action of ["present_answer", "present_view"]) {
|
||||
assert.equal((await native.client.permission.create({ sessionID: sessionId, action, resources: ["*"], save: [] })).effect, "allow");
|
||||
}
|
||||
for (const id of ["opencode_models", "opencode_session_move", "opencode_session_rename",
|
||||
@@ -88,8 +88,7 @@ try {
|
||||
assert(!tools.some((tool) => tool.id === "mcp_tjwater_web_search"), "web search must not have a duplicate MCP tool");
|
||||
for (const id of ["query_project_data", "query_operational_task_data", "get_operational_task_context", "session_search", "web_search", "geocode", "todoread", "list_investigation_tasks", "get_investigation_task", "read_agent_messages"]) assert(!tools.some((tool) => tool.id === id), `${id} must not remain a standalone plugin query`);
|
||||
for (const id of ["memory_manager", "skill_manager", "send_task_message", "reply_agent_message", "todowrite",
|
||||
"present_answer", "final_answer", "locate_features", "zoom_to_map", "show_chart", "view_history",
|
||||
"render_scada_analysis", "clear_scada_analysis", "publish_execution_plan", "publish_task_report",
|
||||
"present_answer", "final_answer", "present_view", "publish_execution_plan", "publish_task_report",
|
||||
"publish_task_progress", "publish_task_presentation", "propose_task_action", "add_task_human_evidence",
|
||||
"request_task_data_access"]) assert(tools.some((tool) => tool.id === id), `${id} must remain a plugin tool`);
|
||||
for (const tool of ["mcp_tjwater_session_search", "mcp_tjwater_todoread"]) {
|
||||
|
||||
Reference in New Issue
Block a user