Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 40 additions & 1 deletion extensions/connector-oh-my-pi/oh-my-pi-extension.smoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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. */
Expand Down Expand Up @@ -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");
Expand Down
1 change: 1 addition & 0 deletions extensions/connector-oh-my-pi/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
82 changes: 81 additions & 1 deletion extensions/connector-oh-my-pi/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 {
Expand Down Expand Up @@ -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<string, unknown>;
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`. */
Expand All @@ -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
Expand All @@ -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 }));
},
Expand All @@ -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 ?? {}));
},
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading