fix(zcode): 真模型跑通后发现的三处缺陷(register / 工具活动日志 / SSE 关停)

真模型端到端(场景 A 通过:6893 事件、175 秒、530 字回信)把三处只有真跑才
暴露的问题照了出来:

1. **register 调不通**:驱动按 pi 的客户端 API 写了 `client.register()`,
   而本插件的 GatewayClient 没有这个方法 —— 靠此前手工注册过才没暴露。
   补上后才发现第二个坑:`/agent/register` 的认证与其它接口**不同**,
   它只认 `Authorization: Bearer` 或 **body 里的 `secret`**,不认 `X-Agent-Secret`
   头(其它接口认)。实测报错:
     HTTP 400 需要 Authorization: Bearer <密钥> 或 body 里的 secret
   所以没密钥时把 secret 放进 body。

2. **一轮 6893 条事件,日志里什么也看不见**:邮件驱动的会话没有界面,
   「模型正在干什么」只能来自日志,否则一个五分钟的回合与一个卡死的回合
   在外部完全一样。新增 `describeRunEvent`,只记工具调用与权限事件
   (全记等于没有日志),并由 runTurn 通过 onEvent 逐个交出来。

3. **关停没真断 SSE**:驱动调的是 `client.stopSSE?.()`,而客户端没有这个方法
   (`?.` 让它静默变成空操作)。改成持有 createSSEClient 的句柄并在关停时 stop。

验证:单元 325/325、授权桥 e2e 5/5、驱动 e2e(桩)7/7、快照握手 12 项。
This commit is contained in:
2026-09-12 16:16:37 +08:00
parent 6a7356ebe7
commit 42816b4d1e
4 changed files with 351 additions and 5 deletions

View File

@ -107,6 +107,24 @@ export function describeError(e) {
return e?.message || String(e);
}
/**
* 把 ZCode 的事件流里「值得进日志」的那几条提出来。
*
* 一轮实测能吐 6893 条事件,全记等于没有日志。只记两类:
* **工具调用**(模型在干什么、有没有触发授权)与**错误**。
* 邮件驱动的会话没有界面,这两类是唯一能回答
* 「它是不是卡住了 / 为什么一直没有授权询问」的信息。
*/
export function describeRunEvent(event) {
const t = String(event?.type || '');
if (t === 'tool.call.started') return `工具 ${event.toolName || '?'}`;
if (t.startsWith('tool.permission')) {
return `权限 ${event.decision || event.behavior || '?'}${event.toolName || event.ruleId || '?'}`;
}
if (/error|failed|denied|aborted/i.test(t)) return `事件 ${t}`;
return null;
}
/**
* 造一个驱动实例。
*
@ -188,7 +206,7 @@ export function createDriver({ client, runTurnFn = runTurn, logFn = log, env = p
logFn(`注意:--mode ${mode} 不会产生权限询问(本档如此设计)`);
}
const prompt = buildMailPrompt({ agentName: CONFIG.agentName, data });
const prompt = buildMailPrompt({ agentName: CFG.agentName, data });
const outcome = await runTurnFn(
{
@ -207,11 +225,18 @@ export function createDriver({ client, runTurnFn = runTurn, logFn = log, env = p
AGENTMAIL_PERMISSION_MODE: tier,
AGENTMAIL_MAIL_SUBJECT: data?.subject || '',
AGENTMAIL_REPLY_TO: data?.mail_id || ''
},
onEvent: event => {
const line = describeRunEvent(event);
if (line) logFn(line);
}
},
{ log: logFn, onChild: kill => {
{
log: logFn,
onChild: kill => {
currentKill = kill;
} }
}
}
);
currentKill = null;
@ -375,12 +400,21 @@ async function main() {
log,
onEvent: (type, data) => driver.handleEvent(type, data)
});
// 留住句柄:关停时要真的断开,否则重连定时器还在跑(进程虽然马上就退,
// 但那是侥幸而不是设计)。
const sse = createSSEClient({
authHeaders: () => client.authHeaders(),
baseURL: client.baseURL,
path: '/api/v1/events/stream',
log,
onEvent: (type, data) => driver.handleEvent(type, data)
});
const shutdown = reason => {
log(`收到 ${reason},关停中…(已处理 ${driver.stats.turns} 轮,回信 ${driver.stats.relays} 封)`);
if (timer) clearInterval(timer);
driver.abort();
client.stopSSE?.();
sse.stop();
// 给杀进程留一点时间再退:自己先死会把 ZCode 变成孤儿。
setTimeout(() => process.exit(0), 1200);
};

View File

@ -103,7 +103,8 @@ export function parseStreamLine(line) {
* @param {{prompt:string, cwd:string, mode:string, maxTurns?:number,
* resumeSessionId?:string, turnTimeoutMs?:number,
* env?:Record<string,string>, cliPath?:string, nodePath?:string,
* onChild?:(kill:(signal?:string)=>void)=>void}} opts
* onChild?:(kill:(signal?:string)=>void)=>void,
* onEvent?:(event:any)=>void}} opts
* @param {{spawn?:Function, log?:Function}} [deps] spawn 可注入以便测试
* @returns {Promise<{sessionId:string, response:string, events:any[], exitCode:number,
* unparsable:number, timedOut:boolean, killed:boolean,
@ -239,6 +240,16 @@ export function runTurn(opts, deps = {}) {
result = parsed;
} else {
events.push(parsed.event);
// 逐个事件交给调用方:邮件驱动的会话没有界面,
// 「模型正在干什么」只能靠日志,否则一个五分钟的回合在外部看起来
// 与一个卡死的回合完全一样。
if (typeof opts.onEvent === 'function') {
try {
opts.onEvent(parsed.event);
} catch {
/* 调用方的日志出错不该影响这一轮 */
}
}
}
}
});