Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
5 changes: 5 additions & 0 deletions packages/omp/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) =>
Expand Down
214 changes: 214 additions & 0 deletions packages/omp/src/native.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown>>;
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<Record<OperationCardStatus, { text: string; tone: Tone; title: string }>> = {
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<Record<OperationCardStatus, Tone>> = {
awaiting_user: "warning",
awaiting_approval: "warning",
uncertain: "warning",
blocked: "error",
};

const STEP_STYLE: Readonly<Record<OperationCardStep["state"], { mark: string; style: string }>> = {
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<object, Map<string, NativeToolView>>();
const resultViews = new WeakMap<object, Map<string, NativeToolView>>();

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<NativeToolView["tool"]> {
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 } : {}),
Comment on lines +119 to +127

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should-fix: These head strings bypass the terminal-safe normalization that renderHeader() applies. For example, describeCallView("browser_wait", { condition: { kind: "text", value: "A\tB" } }) emits the tab verbatim in tool.target, and an error containing a tab is copied verbatim into tool.meta. That violates the TUI contract that every render path replace tabs/control characters and bound displayed text. Normalize and truncate the native head fields before returning them.

...(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" } }],

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should-fix: expandedLines() caps only the number of lines; the ANSI renderer then truncates every line to the viewport, but this native path joins each full line directly into the TSP code node. A single long result value therefore remains a single multi-kilobyte line (and control characters other than redacted keys remain intact), contrary to the claimed bounded-detail/TUI sanitization contract. Bound each native detail line before serializing it.

key: "details",
};
}

function memo(
cache: WeakMap<object, Map<string, NativeToolView>>,
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<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
4 changes: 2 additions & 2 deletions packages/omp/src/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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;
Expand Down
46 changes: 46 additions & 0 deletions packages/omp/test/native.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading