From eca61800b5346667c7efd629b2f504b62ab48678 Mon Sep 17 00:00:00 2001 From: Wolfgang Schoenberger <221313372+wolfiesch@users.noreply.github.com> Date: Sun, 4 Oct 2026 03:20:30 -0700 Subject: [PATCH] Render AgentTab tool calls as native Tern cards in OMP --- CHANGELOG.md | 1 + packages/omp/src/index.ts | 5 + packages/omp/src/native.ts | 214 +++++++++++++++++++++++++++++++ packages/omp/src/render.ts | 4 +- packages/omp/test/native.test.ts | 46 +++++++ 5 files changed, 268 insertions(+), 2 deletions(-) create mode 100644 packages/omp/src/native.ts create mode 100644 packages/omp/test/native.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d388ff..9cc7555 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## Unreleased +- Added native OMP tool cards for terminals that speak the Tern Surface Protocol. Each AgentTab call shows its action and target in the card head with the outcome, tab, and page revision as facts. Approval, human-input, and uncertain states get a warning tone, a badge, and the lifecycle flow. Multi-action batches list their steps, screenshots appear as zoomable images, and the redacted structured result folds into a collapsed section. Typed values, staged tokens, and sensitive fields stay hidden as they are in the text renderer. - Added `bun run dev:deploy` for validated extension builds, backups, atomic receipt replacement, rollback, and bounded readiness checks. Reject symlinked writable paths and unsupported default reload platforms before deployment. Extension health checks now require a ready host and an actual extension round-trip. - Added a DOM editable fallback to accessibility snapshots. When Chrome's debugger-backed accessibility tree omits an editable element, for example a framework's pre-hydration composer textarea, full-tree snapshots lead with up to 40 `dom_fallback` textbox nodes minted from DOM query results, scanning past accessibility-covered and unsupported matches until the limit of accepted editables or a bounded inspection budget is reached. Each carries a normal revisioned ref that the existing fill, type, and click paths already accept; password inputs are skipped, partial (`root_ref`) snapshots are unchanged, the `max_nodes` budget and `truncated` flag now cover the combined result, and a fallback capture failure never fails the snapshot itself. - Added unique CSS selector targets for Standard `click`, `type`, and `fill`, retaining sensitive-field and Commit checks and executing against the checked backend node. Normalize and truncate selector text in operation displays. diff --git a/packages/omp/src/index.ts b/packages/omp/src/index.ts index 7d11a20..d23ca6d 100644 --- a/packages/omp/src/index.ts +++ b/packages/omp/src/index.ts @@ -12,6 +12,7 @@ import { type MethodParams, type MutationMethod, } from "../../sdk-typescript/src/index"; +import { describeCallView, describeResultView } from "./native"; import { piSchema } from "./pi-schema"; import { createCallComponent, @@ -480,6 +481,10 @@ export function makeExtension(clientFactory?: ClientFactory) { createCallComponent(definition.name, args, theme), renderResult: (result: ToolResult, options: RenderOptions, theme: RenderTheme, args: unknown) => createResultComponent(definition.name, result, options, theme, args), + describeCall: (args: unknown) => describeCallView(definition.name, args), + describeResult: (result: ToolResult, options: RenderOptions, args: unknown) => + describeResultView(definition.name, result, options, args), + mergeCallAndResult: true, } : { renderCall: (args: unknown, theme: RenderTheme) => diff --git a/packages/omp/src/native.ts b/packages/omp/src/native.ts new file mode 100644 index 0000000..215b252 --- /dev/null +++ b/packages/omp/src/native.ts @@ -0,0 +1,214 @@ +import { + contextMeta, + createCallCard, + createResultCard, + expandedLines, + type OperationCard, + type OperationCardStatus, + type OperationCardStep, + type RenderOptions, + type ToolResult, +} from "./render"; +import type { ToolMethod } from "./tool-method"; + +/** + * Semantic tool views for terminals that speak the Tern Surface Protocol. + * + * omp calls `describeCall`/`describeResult` instead of `renderCall`/`renderResult` + * there and draws the frame, status motion, timer, collapse, and screenshot + * images itself; these views supply only the head data and body nodes. The + * shapes mirror omp's `NativeToolView` and TSP wire nodes structurally, so the + * adapter needs no omp import. + */ + +type Tone = "neutral" | "accent" | "info" | "success" | "warning" | "error" | "pending" | "muted"; + +interface Span { + readonly t: string; + readonly s?: string; +} + +type Text = string | readonly Span[]; + +export interface NativeNode { + readonly k: string; + readonly p?: Readonly>; + readonly c?: readonly NativeNode[]; + readonly key?: string; +} + +export interface NativeToolView { + readonly tool?: { + readonly title?: Text; + readonly target?: Text; + readonly targetKind?: "command" | "path" | "pattern" | "query" | "text"; + readonly meta?: readonly Text[]; + readonly badges?: readonly { text: string; tone?: Tone; title?: string }[]; + readonly note?: Text; + }; + readonly body?: readonly NativeNode[]; + readonly tone?: Tone; + readonly inline?: boolean; +} + +const ATTENTION_BADGE: Partial> = { + awaiting_user: { text: "needs you", tone: "warning", title: "Waiting on human-only input in the task tab" }, + awaiting_approval: { text: "review", tone: "warning", title: "Paused for AgentTab popup review before execution" }, + uncertain: { text: "uncertain", tone: "warning", title: "Execution may have occurred; inspect live state before retrying" }, +}; + +const STATUS_TONE: Partial> = { + awaiting_user: "warning", + awaiting_approval: "warning", + uncertain: "warning", + blocked: "error", +}; + +const STEP_STYLE: Readonly> = { + done: { mark: "✓", style: "success" }, + active: { mark: "▶", style: "accent strong" }, + pending: { mark: "·", style: "muted" }, + blocked: { mark: "×", style: "error" }, + uncertain: { mark: "?", style: "warning" }, +}; + +const callViews = new WeakMap>(); +const resultViews = new WeakMap>(); + +export function describeCallView(method: ToolMethod, args: unknown): NativeToolView { + return memo(callViews, args, method, () => { + const card = createCallCard(method, args); + const body = [...actionList(card), ...noticeNodes(card.notices)]; + return { + tool: head(card, card), + ...(body.length === 0 ? { inline: true } : { body }), + }; + }); +} + +export function describeResultView( + method: ToolMethod, + result: ToolResult, + options: RenderOptions, + args: unknown, +): NativeToolView { + const key = `${method}\0${options.isPartial === true ? "p" : ""}${options.expanded === true ? "x" : ""}`; + return memo(resultViews, result, key, () => { + const call = createCallCard(method, args); + const card = createResultCard(method, result, options, args); + const attention = ATTENTION_BADGE[card.status] !== undefined || card.status === "blocked"; + const body: NativeNode[] = []; + if (attention) body.push(flowNode(card.steps)); + body.push(...actionList(card), ...noticeNodes(card.notices)); + if (card.evidence.length > 0) body.push(evidenceNode(card.evidence)); + const details = detailsNode(card, options.expanded === true); + if (details !== undefined) body.push(details); + const tone = STATUS_TONE[card.status]; + return { + tool: head(call, card), + ...(body.length === 0 ? { inline: true } : { body }), + ...(tone === undefined ? {} : { tone }), + }; + }); +} + +/** Head: the call's verb and target once, the outcome and tab context as facts, attention as a badge. */ +function head(call: OperationCard, card: OperationCard): NonNullable { + const outcome = card === call || card.status === "running" || card.title === call.title ? undefined : card.title; + // Task id and ownership hold for every card of a task; repeating them per call is noise. + const meta = [ + ...(outcome === undefined ? [] : [outcome]), + ...contextMeta({ ...card.context, taskId: undefined, owned: false }), + ]; + const badge = ATTENTION_BADGE[card.status]; + const target = call.meta.join(" · "); + return { + title: call.title, + ...(target ? { target, targetKind: "text" as const } : {}), + ...(meta.length === 0 ? {} : { meta }), + ...(badge === undefined ? {} : { badges: [badge] }), + }; +} + +function flowNode(steps: readonly OperationCardStep[]): NativeNode { + const spans: Span[] = [{ t: "Flow ", s: "muted" }]; + steps.forEach((step, index) => { + if (index > 0) spans.push({ t: " ", s: "muted" }); + const style = STEP_STYLE[step.state]; + spans.push({ t: `${style.mark} ${step.label}`, s: style.style }); + }); + return { k: "text", p: { spans, wrap: "word" }, key: "flow" }; +} + +/** Numbered steps of a multi-action batch; a single action already is the head. */ +function actionList(card: OperationCard): NativeNode[] { + if (card.actions.length < 2) return []; + return [{ + k: "kv", + p: { items: card.actions.map((action, index) => ({ k: `${index + 1}.`, v: action })), layout: "grid" }, + key: "actions", + }]; +} + +function noticeNodes(notices: readonly string[]): NativeNode[] { + return notices.map((notice, index) => { + const [label, ...rest] = notice.split(" · "); + const style = label === "Blocked" ? "error" : "warning"; + const spans: Span[] = rest.length === 0 + ? [{ t: notice, s: style }] + : [{ t: `${label} `, s: `${style} strong` }, { t: rest.join(" · "), s: style }]; + return { k: "text", p: { spans, wrap: "word" }, key: `notice${index}` }; + }); +} + +function evidenceNode(evidence: readonly string[]): NativeNode { + return { + k: "kv", + p: { + items: evidence.map((entry) => { + const [label, ...rest] = entry.split(" · "); + return { k: label, v: [{ t: rest.join(" · "), s: "success" }] }; + }), + layout: "grid", + }, + key: "evidence", + }; +} + +/** The redacted structured result, folded unless the transcript is expanded. */ +function detailsNode(card: OperationCard, expanded: boolean): NativeNode | undefined { + if (card.details === undefined) return undefined; + const { _agenttab: _presentation, ...rest } = toRecord(card.details); + const payload = Object.keys(rest).length === 0 ? undefined : rest; + if (payload === undefined) return undefined; + return { + k: "section", + p: { head: [{ t: "Result", s: "muted" }, { t: " redacted", s: "dim" }], collapsible: true, collapsed: !expanded }, + c: [{ k: "code", p: { text: expandedLines(payload).join("\n"), lang: "json" } }], + key: "details", + }; +} + +function memo( + cache: WeakMap>, + owner: unknown, + key: string, + build: () => NativeToolView, +): NativeToolView { + if (owner === null || typeof owner !== "object") return build(); + let entries = cache.get(owner); + if (entries === undefined) { + entries = new Map(); + cache.set(owner, entries); + } + let view = entries.get(key); + if (view === undefined) { + view = build(); + entries.set(key, view); + } + return view; +} + +function toRecord(value: unknown): Record { + return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; +} diff --git a/packages/omp/src/render.ts b/packages/omp/src/render.ts index 1f5f9e4..c12456a 100644 --- a/packages/omp/src/render.ts +++ b/packages/omp/src/render.ts @@ -454,7 +454,7 @@ function operationContext( }; } -function contextMeta(context: OperationCard["context"]): string[] { +export function contextMeta(context: OperationCard["context"]): string[] { return present( context.taskId ? `task ${shortId(context.taskId)}` : undefined, tabMeta(context.tabId), @@ -644,7 +644,7 @@ function observationItems(details: unknown): unknown[] { return Array.isArray(snapshot.nodes) ? snapshot.nodes : []; } -function expandedLines(details: unknown): string[] { +export function expandedLines(details: unknown): string[] { if (details === undefined) return []; const safe = sanitize(details); let text: string; diff --git a/packages/omp/test/native.test.ts b/packages/omp/test/native.test.ts new file mode 100644 index 0000000..d67af14 --- /dev/null +++ b/packages/omp/test/native.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from "bun:test"; +import { describeCallView, describeResultView } from "../src/native"; + +describe("AgentTab native (Tern) tool views", () => { + test("typed values and staged tokens never reach the native view", () => { + const args = { + tab_id: 12, + expected_page_revision: 4, + actions: [ + { kind: "fill", ref: "ref=e5", text: "private@example.com" }, + { kind: "click", ref: "ref=e9" }, + ], + }; + const call = JSON.stringify(describeCallView("browser_act", args)); + const result = JSON.stringify(describeResultView("browser_act", { + details: { staged_token: "tok-secret-123", awaiting_human_approval: true, effect: "submit form" }, + }, { expanded: true }, args)); + for (const view of [call, result]) { + expect(view).not.toContain("private@example.com"); + expect(view).not.toContain("tok-secret-123"); + } + expect(result).toContain("[redacted]"); + }); + + test("approval-gated results carry a warning tone, review badge, and the lifecycle flow", () => { + const view = describeResultView("browser_act", { + details: { staged_token: "t", awaiting_human_approval: true, effect: "purchase" }, + }, {}, { tab_id: 3, actions: [{ kind: "click", ref: "ref=e2" }] }); + expect(view.tone).toBe("warning"); + expect(view.tool?.title).toBe("Click"); + expect(view.tool?.badges?.map((badge) => badge.text)).toEqual(["review"]); + expect(view.tool?.meta).toContain("Approval required for purchase"); + expect(view.body?.some((node) => node.key === "flow")).toBe(true); + }); + + test("routine observations stay quiet: no flow, details folded until expanded", () => { + const result = { details: { tabs: [{ tab_id: 5 }], tabs_count: 1, _agenttab: { outcome: "completed" } } }; + const collapsed = describeResultView("browser_tabs", result, {}, {}); + expect(collapsed.tone).toBeUndefined(); + expect(collapsed.tool?.badges).toBeUndefined(); + expect(collapsed.body?.some((node) => node.key === "flow")).toBe(false); + expect(collapsed.body?.find((node) => node.key === "details")?.p?.collapsed).toBe(true); + const expanded = describeResultView("browser_tabs", result, { expanded: true }, {}); + expect(expanded.body?.find((node) => node.key === "details")?.p?.collapsed).toBe(false); + }); +});