// src/interface/__tests__/tool-render.test.ts // // renderSubagentCall 行为测试:拍平后从顶层 args 提取 agent/slug/task。 // // 背景:wave 3 flatten 把 task/slug/agent 等 13 字段从 args.startParam 嵌套层 // 移到 args 顶层。renderSubagentCall 的提取逻辑跟着改了,但之前无行为测试覆盖 // (sdk-contract.test.ts 只断言 renderCall 是 function,不断言行为)。此测试 // 锁住「拍平形态的 args 能被 renderSubagentCall 正确提取」——若有人改回 // args.startParam 路径,测试立即红。 // // 不走 registerSubagentTool 注册路径——renderSubagentCall 是纯函数,直接 import // 测试,避免 mock pi-ai/typebox/pi-tui 整条链。 import { describe, expect, it } from "vitest"; import type { Component } from "@earendil-works/pi-tui"; import { type RenderContext, renderSubagentCall } from "../tool-render.ts"; // ── 最小 ThemeLike stub ── // renderSubagentCall 只用 theme.fg/bold/dim(都是 (token, text) => string)。 // 不依赖真实 pi-tui 着色——我们只断言提取出的字符串出现在结果里。 function makeTheme(): { fg(color: string, text: string): string; bold(text: string): string; } { return { // 把 token 作为 [token:...] 包裹器返回,便于断言时不依赖颜色映射。 fg: (_color, text) => `<${_color}>${text}`, bold: (text) => `${text}`, }; } // Text.render() 是 pi-tui 的方法。tool-render 返回 new Text(parts.join(""), 0, 0)。 // 测试只关心 parts.join("") 的文本内容——用反射取构造时传入的字符串。 // Component 类型在 pi-tui 中是 opaque,这里用最小的反射 helper。 function renderText(component: Component): string { // Text 实例在 pi-tui v0.x 把构造首参存为 .text 或私有字段; // 通过遍历可枚举属性找到首个 string 字段(绕过具体字段名差异)。 const obj = component as unknown as Record; for (const key of Object.keys(obj)) { const v = obj[key]; if (typeof v === "string" && v.length > 0) return v; } return ""; } const CTX: RenderContext = { state: {} as Record, invalidate: () => {}, }; describe("renderSubagentCall — 拍平形态提取(regression for wave 3 flatten)", () => { it("从顶层 args 提取 agent(默认 general-purpose)", () => { const out = renderText(renderSubagentCall( { action: "start", task: "do stuff", slug: "x" }, makeTheme() as never, CTX, )); // 默认 agent 名(DEFAULT_AGENT_NAME)出现在结果里 expect(out).toContain("general-purpose"); }); it("从顶层 args 提取显式 agent 名", () => { const out = renderText(renderSubagentCall( { action: "start", agent: "coder", task: "do stuff", slug: "x" }, makeTheme() as never, CTX, )); expect(out).toContain("coder"); }); it("从顶层 args 提取 slug 并在 agent 后展示", () => { const out = renderText(renderSubagentCall( { action: "start", agent: "coder", task: "do stuff", slug: "fix-login" }, makeTheme() as never, CTX, )); expect(out).toContain("coder"); expect(out).toContain("fix-login"); }); it("task 不再渲染为 preview 行(标题块只有单行)", () => { const out = renderText(renderSubagentCall( { action: "start", agent: "coder", task: "Analyze the bug in parser", slug: "fix-parser" }, makeTheme() as never, CTX, )); // task 预览行已移除——task 内容不进对话流(完整 task 在 /subagents 详情可见) expect(out).not.toContain("Analyze the bug in parser"); expect(out).not.toContain("\n"); }); it("task 含换行时不进渲染(无 preview 泄漏)", () => { const out = renderText(renderSubagentCall( { action: "start", task: "first line\nsecond line", slug: "x" }, makeTheme() as never, CTX, )); expect(out).not.toContain("first line"); expect(out).not.toContain("second line"); expect(out).not.toContain("\n"); }); // 关键回归:若有人把提取路径改回 args.startParam,这些顶层调用都会失败 // (agent/slug 取不到,全用默认值)。此测试用顶层数据形态锁住 flatten。 // task 不在断言里——task 已不渲染(上方用例锁定)。 it("REGRESSION: 顶层 args 形态完整提取(防止回退到 startParam envelope)", () => { const out = renderText(renderSubagentCall( { action: "start", agent: "researcher", task: "search docs", slug: "search-docs" }, makeTheme() as never, CTX, )); // agent + slug 都应被提取(默认值 fallback 也能过单字段断言,但同时命中的 // 概率只有联合 fallback 才有——researcher/search-docs 都不是默认值) expect(out).toContain("researcher"); expect(out).toContain("search-docs"); }); it("args 缺所有字段时不崩(最防御)", () => { expect(() => renderSubagentCall({}, makeTheme() as never, CTX)).not.toThrow(); expect(() => renderSubagentCall(undefined, makeTheme() as never, CTX)).not.toThrow(); }); });