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
75 changes: 75 additions & 0 deletions packages/cli/src/__tests__/pi-transcript.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { before, describe, test } from 'node:test';
import { visibleWidth } from '@earendil-works/pi-tui';
import { _setColorLevelForTesting } from '../tui-ansi.js';
import type { PipeShellOutput, PtyShellOutput, ShellRunToolResult } from '@maka/core';
import { computerUseModelCallArgs } from '@maka/core';
import type { SessionEvent, ToolResultContent } from '@maka/core/events';
import type { StoredMessage } from '@maka/core/session';
import {
Expand Down Expand Up @@ -4649,6 +4650,80 @@ describe('Maka Pi TUI activity strip', () => {
});
});

describe('Maka Pi TUI Computer Use rows', () => {
/**
* The desktop is not the only renderer of tool activity, and the row that
* reads `Maka Computer` reads it in both. The arguments are put through
* `computerUseModelCallArgs`, because that is what the runtime persists and
* emits for this tool — a fixture written as the wire call, or run through
* some other projection, would assert a label from fields the TUI can never
* receive. That projection speaks the tool's own dialect: `window_id` and
* `element_id`, not `windowId` and `elementId`.
*/
test('says what each Computer Use call did instead of repeating the tool name', () => {
const state = createMakaPiTranscriptState();
appendUserPrompt(state, 'add seven and eight');
const calls = [
{ action: 'observe', app: 'Calculator', window_id: 7 },
{
action: 'click_element',
app: 'Calculator',
window_id: 7,
observation_id: 'frame-1',
element_id: 'e7',
},
{
action: 'click_element',
app: 'Calculator',
window_id: 7,
observation_id: 'frame-1',
element_id: 'e9',
},
];
calls.forEach((wireArgs, index) => {
applyMakaSessionEventToTranscript(
state,
event({
type: 'tool_start',
toolUseId: `cu-${index}`,
toolName: 'maka_computer',
displayName: 'Maka Computer',
activityKind: 'computer',
args: computerUseModelCallArgs(wireArgs),
}),
);
applyMakaSessionEventToTranscript(
state,
event({
type: 'tool_result',
toolUseId: `cu-${index}`,
isError: false,
content: { kind: 'text', text: 'ok' },
}),
);
});

const rendered = renderMakaPiTranscript(state, meta(), 120).map(stripAnsi);
const rows = rendered.filter((line) => line.includes('Maka Computer'));
assert.equal(rows.length, 3, 'one row per call');
assert.equal(
new Set(rows.map((row) => row.trim())).size,
3,
'three rows that read differently',
);
assert.ok(
rows.some((row) => /click_element · element e7 · Calculator window 7/.test(row)),
`expected the first click to name its element, got:\n${rows.join('\n')}`,
);
assert.ok(rows.some((row) => /click_element · element e9/.test(row)));
assert.ok(rows.some((row) => /observe · Calculator window 7/.test(row)));
// The host's own approval bookkeeping is not something the model asked for
// and has no business in a transcript row.
const all = rows.join('\n');
assert.equal(/approvalClass|rememberForTurnAllowed|observation_id/.test(all), false, all);
});
});

function meta() {
return {
title: 'Maka',
Expand Down
46 changes: 46 additions & 0 deletions packages/cli/src/pi-transcript-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -770,6 +770,11 @@ function toolInputSummary(entry: MakaPiToolEntry): string {
}
break;
}
case 'maka_computer': {
const line = computerCallSummary(obj);
if (line) return line;
break;
}
}
if (input === undefined) return '';
// Generic fallback: use the shared invocation-line formatter instead of
Expand All @@ -782,3 +787,44 @@ function toolInputSummary(entry: MakaPiToolEntry): string {
// Absolute last resort — still single-line for the compact header contract.
return `input: ${limitText(formatUnknownInline(input), 600)}`;
}

/**
* What one Computer Use call did, for the row that names it.
*
* Every other tool's row is legible from its name plus one headline argument.
* This tool's name is `Maka Computer` for observing a window, clicking a
* button and observing again alike, so ten calls in a turn printed ten
* identical headers; the generic fallback then spelled the arguments out as
* `action: … / approvalClass: … / rememberForTurnAllowed: …`, which names the
* host's own approval bookkeeping rather than anything the model asked for.
*
* The arguments here are a `ComputerUseModelCallArgs`, not the raw wire call:
* `ToolRuntime` substitutes that projection for any tool declaring
* `categoryHint: 'computer_use'` before anything is persisted. It keeps every
* key the model sent in the names the tool accepts — `window_id`, `element_id`
* — and reduces a screen-derived or typed value to its shape, so the action,
* the app, the window and the element are available and a written value is
* not. Reading `windowId`/`elementId` here instead is not a compile error and
* not a crash; it is every row silently losing its element, which is the defect
* this function exists to remove.
*/
function computerCallSummary(args: Record<string, unknown> | undefined): string | undefined {
if (!args) return undefined;
const text = (key: string): string | undefined => {
const value = args[key];
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
};
const action = text('action');
if (!action) return undefined;
const parts = [action];
const elementId = text('element_id');
if (elementId) parts.push(`element ${elementId}`);
const app = text('app');
const windowId = args.window_id;
const target =
app && typeof windowId === 'number'
? `${app} window ${windowId}`
: (app ?? (typeof windowId === 'number' ? `window ${windowId}` : undefined));
if (target) parts.push(target);
return limitText(parts.join(' · '), 600);
}
38 changes: 38 additions & 0 deletions packages/core/src/__tests__/computer-use.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,44 @@ describe('Computer Use foundation contract', () => {
assert.equal(summary.observationId?.includes('sk-test-observation'), false);
});

/**
* The identifier shape `[A-Za-z0-9._:-]{1,256}` is also the shape of an API
* key, so admitting an element id by shape is not on its own a privacy
* boundary. `computerUseModelCallArgs` is what `ToolRuntime` persists as the
* Computer Use call's arguments, what the model reads back as its own history
* and what both renderers turn into a row; arguments are not validated before
* it runs, so a model that put a key under `element_id` reaches it. Remove
* the redaction and this test goes red.
*/
test('a secret-shaped element id is redacted on the same terms as an app name', () => {
for (const secret of [
'sk-ant-api03-AbCdEfGhIjKlMnOpQrStUvWxYz0123456789AbCdEfGhIjKlMnOpQrStUvWxYz01',
'ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789',
]) {
const asElement = computerUseModelCallArgs({
action: 'click_element',
app: 'Example',
window_id: 42,
observation_id: 'frame-7',
element_id: secret,
});
const asApp = computerUseModelCallArgs({
action: 'click_element',
window_id: 42,
observation_id: 'frame-7',
app: secret,
});
assert.equal(asElement.element_id?.includes(secret), false);
assert.equal(asElement.element_id, asApp.app);
}
// An ordinary element id is untouched: redaction must not cost the row the
// one field that tells two clicks in a turn apart.
assert.equal(
computerUseModelCallArgs({ action: 'click_element', element_id: 'e12' }).element_id,
'e12',
);
});

test('approval scope separates read, screenshot, and mutation classes', () => {
const metadata = computerUseApprovalScopeKey({
action: 'observe',
Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@ import { defineObjectShape, hasExactShape, isRecord } from './record-schema.js';
export const TOOL_OUTPUT_STREAMS = ['stdout', 'stderr'] as const;
export const TOOL_OUTPUT_DELTA_MAX_CHARS = 8192;
export const TOOL_ACTIVITY_KINDS = [
// Driving the user's own machine is not "a tool call". It has its own risk,
// its own approval classes and its own place in a transcript, and reading it
// under the same gear icon as everything else hides the one activity a person
// most wants to pick out at a glance.
'computer',
'read',
'search',
'websearch',
Expand Down
44 changes: 43 additions & 1 deletion packages/runtime-host/src/__tests__/protocol.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import assert from 'node:assert/strict';
import { describe, test } from 'node:test';
import { MAX_ATTACHMENT_BYTES, MAX_ATTACHMENT_COUNT } from '@maka/core/attachments';
import { TOOL_OUTPUT_DELTA_MAX_CHARS } from '@maka/core/events';
import { TOOL_ACTIVITY_KINDS, TOOL_OUTPUT_DELTA_MAX_CHARS } from '@maka/core/events';
import {
decodeClientFrame,
decodeHostFrame,
Expand Down Expand Up @@ -311,6 +311,48 @@ describe('Runtime Host bootstrap protocol', () => {
);
});

/**
* The decoder is the worst place to keep a second copy of a vocabulary: a
* kind added anywhere else made this reject the whole frame, and the failure
* arrived as a protocol violation naming nothing. `requireToolActivityKind`
* was a hand-written chain that had already fallen behind — it threw on
* `'computer'` — and nothing in this package exercised it, so putting it back
* would have cost no test at all.
*
* Every kind on the wire decodes; a plausible one that is not on it does not.
*/
test('accepts every declared tool activity kind and nothing else', () => {
const envelope = {
kind: 'subscription.session_event' as const,
hostEpoch: 'epoch-1',
subscriptionId: 'subscription-1',
sequence: 1,
sessionId: 'session-1',
runId: 'run-1',
};
const start = {
id: 'event-1',
turnId: 'turn-1',
ts: 1,
toolUseId: 'tool-1',
type: 'tool_start' as const,
toolName: 'maka_computer',
};
assert.ok(TOOL_ACTIVITY_KINDS.includes('computer'));
for (const activityKind of TOOL_ACTIVITY_KINDS) {
assert.doesNotThrow(
() => decodeHostFrame({ ...envelope, event: { ...start, activityKind } }),
`the wire declares ${activityKind}, so the decoder must accept it`,
);
}
for (const activityKind of ['desktop', 'Computer', '', 7]) {
assert.throws(
() => decodeHostFrame({ ...envelope, event: { ...start, activityKind } }),
isInvalidFrame,
);
}
});

test('enforces UTF-8 snapshot, live field, and whole-frame byte bounds', () => {
const snapshot = continuitySnapshot('epoch-1');
assert.ok(Buffer.byteLength(JSON.stringify(snapshot)) < SESSION_CONTINUITY_SNAPSHOT_MAX_BYTES);
Expand Down
43 changes: 17 additions & 26 deletions packages/runtime-host/src/protocol/session-continuity.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { TOOL_OUTPUT_DELTA_MAX_CHARS } from '@maka/core/events';
import { TOOL_ACTIVITY_KINDS, TOOL_OUTPUT_DELTA_MAX_CHARS } from '@maka/core/events';
import type { ToolActivityKind } from '@maka/core/events';
import {
assertExactKeys,
requireCount,
Expand Down Expand Up @@ -115,16 +116,10 @@ export type SessionToolEvent =
type: 'tool_start';
toolName: string;
operationId?: string;
activityKind?:
| 'read'
| 'search'
| 'websearch'
| 'webfetch'
| 'edit'
| 'command'
| 'explore'
| 'browser'
| 'tool';
// From the one list, not a fourth copy of it. This was written out by
// hand and drifted the moment a kind was added — the wire then rejected
// an event the rest of the system considered valid.
activityKind?: ToolActivityKind;
displayName?: string;
stepId?: string;
})
Expand Down Expand Up @@ -645,21 +640,17 @@ function requireEncodedByteLimit(value: unknown, label: string, maxBytes: number
}
}

function requireToolActivityKind(
value: unknown,
): Extract<SessionToolEvent, { type: 'tool_start' }>['activityKind'] {
if (
value === 'read' ||
value === 'search' ||
value === 'websearch' ||
value === 'webfetch' ||
value === 'edit' ||
value === 'command' ||
value === 'explore' ||
value === 'browser' ||
value === 'tool'
)
return value;
/**
* Read against the one list rather than a copy of it.
*
* This was a hand-written chain, and a decoder is the worst place for a copy:
* a kind added anywhere else made this reject the whole frame, so the failure
* arrives as a protocol violation rather than as anything naming the field.
*/
function requireToolActivityKind(value: unknown): ToolActivityKind {
if (typeof value === 'string' && (TOOL_ACTIVITY_KINDS as readonly string[]).includes(value)) {
return value as ToolActivityKind;
}
throw invalidProtocolFrame('Invalid Session tool activity kind');
}

Expand Down
Loading
Loading