diff --git a/extensions/connector-oh-my-pi/oh-my-pi-extension.smoke.ts b/extensions/connector-oh-my-pi/oh-my-pi-extension.smoke.ts index 54bd86b7..3f3c157d 100644 --- a/extensions/connector-oh-my-pi/oh-my-pi-extension.smoke.ts +++ b/extensions/connector-oh-my-pi/oh-my-pi-extension.smoke.ts @@ -7,7 +7,7 @@ * Asserts: inert without identity; with identity it registers the cotal_* tool surface, subscribes * to the lifecycle events, and cotal_inbox is read-only. */ -import cotalMesh from "./src/extension.ts"; +import cotalMesh, { cotalCallSummary } from "./src/extension.ts"; import * as zodV4 from "zod/v4"; import { MeshAgent } from "@cotal-ai/connector-core"; import { setImmediate as settle } from "node:timers/promises"; @@ -26,6 +26,7 @@ interface RegisteredTool { parameters: unknown; approval?: string; execute: (...a: unknown[]) => Promise<{ content: { type: string; text: string }[]; details: unknown }>; + renderCall?: (args: unknown, options?: unknown, theme?: unknown) => { render: (width: number) => readonly string[] }; } /** A fake ExtensionAPI that records everything the factory does. */ @@ -97,6 +98,44 @@ process.env.COTAL_SERVERS = "nats://127.0.0.1:4222"; // never actually connected assert(!inboxResult.content[0].text.startsWith("⚠"), "cotal_inbox execute does not error on empty inbox"); console.log("3) cotal_inbox read-only OK ✅"); + // ---- 3b. every cotal_* tool carries a renderCall (spinner-fallback fix) ---- + // A tool with no renderCall falls back to OMP's generic animated-spinner glyph. Asserting a + // renderCall on every registered tool is the regression guard for that artifact. Each must + // return a Component (a `render(width)` producing lines), not throw, for real and empty args. + for (const [name, tool] of tools) { + assert(typeof tool.renderCall === "function", `${name} carries a renderCall (no spinner fallback)`); + const comp = tool.renderCall!({}, {}, {}); + assert(comp && typeof comp.render === "function", `${name} renderCall returns a Component`); + const lines = comp.render(80); + assert(Array.isArray(lines) && lines.some((l) => l.length > 0), `${name} renderCall renders a non-empty line`); + } + console.log(` all ${tools.size} tools carry a renderCall`); + + // The renderCall enriches the title with a per-surface summary drawn from args (not just the + // bare label): a cotal_dm to a peer shows the recipient; cotal_send shows the channel. + const dmLine = tools.get("cotal_dm")!.renderCall!({ to: "mercator", text: "ping" }, {}, {}).render(200).join(" "); + assert(dmLine.includes("mercator"), "cotal_dm renderCall shows the recipient"); + const sendLine = tools.get("cotal_send")!.renderCall!({ channel: "svc.cotal", text: "hi" }, {}, {}).render(200).join(" "); + assert(sendLine.includes("svc.cotal"), "cotal_send renderCall shows the channel"); + console.log("3b) tool renderCall present + enriched OK ✅"); + + // ---- 3c. cotal_send card names the REAL destination + render clamps zero width ---- + // cotalCallSummary is pure + exported. When `channel` is omitted, the card must show the + // destination the send actually resolves to — the caller's `defaultChannel` + // (config.subscribe.find(isConcreteChannel) ?? "general") — NOT a hardcoded "#general". + // Regression guard: the old renderer hardcoded "general", so an agent whose default channel + // was e.g. "svc.cotal" saw a card claiming "#general" while the message went to #svc.cotal. + const omittedCh = cotalCallSummary("cotal_send", { text: "hi" }, "svc.cotal"); + assert(omittedCh.startsWith("#svc.cotal"), `omitted send channel shows the resolved default, not a guess (got: ${omittedCh})`); + assert(!omittedCh.includes("general"), "omitted send channel must not fabricate #general when the default differs"); + const explicitCh = cotalCallSummary("cotal_send", { channel: "random", text: "hi" }, "svc.cotal"); + assert(explicitCh.startsWith("#random"), "an explicit send channel still wins over the default"); + // A zero-width render slot must yield a single empty line, never the untruncated title + // (the width-bounded render contract — OMP can hand a Component a zero-width slot). + const zeroWidth = tools.get("cotal_send")!.renderCall!({ channel: "svc.cotal", text: "hi" }, {}, {}).render(0); + assert(zeroWidth.length === 1 && zeroWidth[0] === "", `zero-width render returns [""], not an over-wide line (got: ${JSON.stringify(zeroWidth)})`); + console.log("3c) send card names real destination + zero-width clamp OK ✅"); + // The factory started a MeshAgent with a background reconnect loop; fire session_shutdown to stop // it so the smoke process can exit (no live mesh in this test). const shutdown = events.get("session_shutdown"); diff --git a/extensions/connector-oh-my-pi/package.json b/extensions/connector-oh-my-pi/package.json index 33686176..8c89ebf8 100644 --- a/extensions/connector-oh-my-pi/package.json +++ b/extensions/connector-oh-my-pi/package.json @@ -40,6 +40,7 @@ }, "devDependencies": { "@cotal-ai/core": "workspace:*", + "@oh-my-pi/pi-tui": "^16.3.12", "esbuild": "^0.28.0", "zod": "^4.4.3" }, diff --git a/extensions/connector-oh-my-pi/src/extension.ts b/extensions/connector-oh-my-pi/src/extension.ts index b3471972..ebedf08a 100644 --- a/extensions/connector-oh-my-pi/src/extension.ts +++ b/extensions/connector-oh-my-pi/src/extension.ts @@ -26,6 +26,13 @@ // resolves to the specific .d.ts and typechecks clean. Revert to the root import once the upstream // type-build fix is published (see the header comment in src/peer.ts). import type { ExtensionAPI, ExtensionContext } from "@oh-my-pi/pi-coding-agent/extensibility/extensions/types"; +// TUI Component TYPE only (erased at build; no runtime import, so it never drags pi-tui's +// Bun-coupled transitive graph — @oh-my-pi/pi-utils' barrel pulls `bun`, which the Node/tsx smoke +// can't load). A `renderCall` returning a Component takes OMP's custom-renderer branch instead of +// the generic animated-spinner fallback (the glyph artifact on cotal_* cards); we return a minimal +// hand-rolled Component (just the required `render(width)`) rather than pi-tui's `Text`. See the +// renderer helpers below. +import type { Component } from "@oh-my-pi/pi-tui"; import { configFromEnv, hasIdentity, @@ -35,7 +42,7 @@ import { type ToolResult, type MeshLogger, } from "@cotal-ai/connector-core"; -import type { PresenceStatus } from "@cotal-ai/core"; +import { isConcreteChannel, type PresenceStatus } from "@cotal-ai/core"; import { runPeerLoop } from "./interactive-loop.js"; export default function cotalMesh(pi: ExtensionAPI): void { @@ -135,6 +142,73 @@ export default function cotalMesh(pi: ExtensionAPI): void { ); } +/** Truncate to `max` display columns with a trailing ellipsis, collapsing internal whitespace to + * single spaces first. Shared by the call summary and the render clamp so both handle the narrow + * and zero/negative-width edges identically: `max <= 0` → empty, `max === 1` → just the ellipsis. */ +function truncate(s: string, max: number): string { + const flat = s.replace(/\s+/g, " "); + if (max <= 0) return ""; + if (flat.length <= max) return flat; + return max === 1 ? "…" : `${flat.slice(0, max - 1)}…`; +} + +/** One-line, human-readable summary of a cotal_* tool call, keyed by tool name and drawn from the + * shared spec's args. Display-only (feeds `renderCall`); pure + exported for the smoke. Unknown + * tools and absent args degrade to an empty summary (the label alone still leaves the spinner + * fallback). Args are `unknown` because each tool's shape differs; we read defensively. + * `defaultChannel` is the destination `cotal_send` resolves an omitted `channel` to — the caller + * passes the SAME value the endpoint uses (`config.subscribe.find(isConcreteChannel) ?? "general"`, + * mirrored from CotalEndpoint.multicast), so the card names the real target instead of a guess. */ +export function cotalCallSummary(name: string, args: unknown, defaultChannel: string): string { + const a = (args ?? {}) as Record; + const str = (v: unknown): string => (typeof v === "string" ? v.trim() : ""); + const preview = (v: unknown, max = 60): string => truncate(str(v), max); + switch (name) { + case "cotal_send": { + const ch = str(a.channel) || defaultChannel; + const ment = Array.isArray(a.mentions) && a.mentions.length ? ` @${a.mentions.map(str).filter(Boolean).join(" @")}` : ""; + const body = preview(a.text); + return `#${ch}${ment}${body ? ` — ${body}` : ""}`; + } + case "cotal_dm": { + const to = str(a.to) || "?"; + const body = preview(a.text); + return `${to}${body ? ` — ${body}` : ""}`; + } + case "cotal_anycast": { + const role = str(a.role) || "?"; + const body = preview(a.text); + return `@${role}${body ? ` — ${body}` : ""}`; + } + case "cotal_status": { + const parts = [str(a.status), str(a.attention)].filter(Boolean); + const act = preview(a.activity, 40); + return `${parts.join(" · ")}${act ? `${parts.length ? " · " : ""}${act}` : ""}`; + } + default: + return ""; + } +} + +/** Build the display-only Component for a cotal_* tool call: a titled single line (label + summary). + * Returning a Component from `renderCall` is what takes OMP's custom-renderer branch instead of the + * generic animated-spinner fallback — the whole point of wiring these hooks. Minimal by design: a + * one-line renderer truncated to the render width. We hand-roll the Component (only `render(width)` + * is required by the interface) instead of using pi-tui's `Text`, so no runtime pi-tui import is + * pulled — that would drag @oh-my-pi/pi-utils' Bun-coupled barrel and break the Node/tsx smoke. + * A zero/negative or non-finite width yields a single empty line: OMP can hand a Component a + * zero-width slot, and the width-bounded render contract must never return an over-wide line. */ +function renderCotalCall(label: string, summary: string): Component { + const line = summary ? `${label} — ${summary}` : label; + return { + render(width: number): readonly string[] { + if (!Number.isFinite(width) || width <= 0) return [""]; + const w = Math.floor(width); + return [line.length > w ? truncate(line, w) : line]; + }, + }; +} + /** Render one shared CotalToolSpec onto `pi.registerTool`. `cotal_inbox` is forced read-only * (peek): this extension delivers + acks each turn, so the agent's inbox tool must never drain, * or it would race the ack. All others pass their args straight through to the spec's `run`. */ @@ -150,6 +224,10 @@ function registerSpec( details: {}, }); + // The channel `cotal_send` resolves an omitted `channel` to — the same expression the endpoint + // uses (CotalEndpoint.multicast), so the tool card names the real destination, not a guess. + const defaultChannel = config.subscribe.find(isConcreteChannel) ?? "general"; + if (spec.name === "cotal_inbox") { // Empty params (this tool takes none). The explicit `registerTool<…>` generic below pins // `TParams` so the tool registry doesn't infer it from the literal and recurse into @@ -162,6 +240,7 @@ function registerSpec( "Show the peer messages currently waiting for you (incl. focus-mode recall). You don't normally need this — the extension delivers peer messages into your turns automatically; use it to re-check what's pending mid-task. Read-only: it never consumes them.", parameters, approval: "read", + renderCall: () => renderCotalCall(spec.title, "peek inbox"), async execute(_id, _params, _signal, _onUpdate, _ctx: ExtensionContext) { return toResult(await spec.run(agent, config, { peek: true })); }, @@ -178,6 +257,7 @@ function registerSpec( label: spec.title, description: spec.description, parameters, + renderCall: (args) => renderCotalCall(spec.title, cotalCallSummary(spec.name, args, defaultChannel)), async execute(_id, params, _signal, _onUpdate, _ctx: ExtensionContext) { return toResult(await spec.run(agent, config, params ?? {})); }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2ccb583a..10322f1c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -227,6 +227,9 @@ importers: '@cotal-ai/core': specifier: workspace:* version: link:../../packages/core + '@oh-my-pi/pi-tui': + specifier: ^16.3.12 + version: 16.3.12 esbuild: specifier: ^0.28.0 version: 0.28.1