From caf49fcf037e214beffb1e6e9c9e13488842ecea Mon Sep 17 00:00:00 2001 From: me2seeks Date: Mon, 24 Aug 2026 06:35:52 +0800 Subject: [PATCH 1/6] fix(runtime-host,ui,cli): name live tool calls on compact and collapsed rows Rebase of #3376 onto current main as one clean change: live tool_start frames may carry optional intent / argsPreview keys so compact and collapsed rows can name the call before durable args arrive. The strict decoder's allowed-key union retains main's shellRunRef alongside them, and correlated hidden-shell polls keep publishing only their correlation ref. Compatibility epoch advances to 45. Generated-by: maka --- .../tool-args-redaction-contract.test.ts | 21 ++ .../cli/src/__tests__/pi-transcript.test.ts | 84 +++++++ packages/cli/src/pi-transcript-tools.ts | 24 +- packages/cli/src/pi-transcript.ts | 8 +- .../src/__tests__/tool-quiet-preview.test.ts | 158 ++++++++++++- packages/core/src/events.ts | 6 + packages/core/src/tool-quiet-preview.ts | 223 +++++++++++++++++- .../src/__tests__/protocol.test.ts | 19 ++ .../session-continuity-coordinator.test.ts | 36 +++ .../src/__tests__/session-projector.test.ts | 36 +++ .../src/adapter/session-projector.ts | 4 + packages/runtime-host/src/protocol/index.ts | 11 +- .../src/protocol/session-continuity.ts | 41 ++++ .../server/session-continuity-coordinator.ts | 24 ++ .../tool-activity-presentation.test.ts | 84 +++++++ packages/ui/src/live-turn-projection.ts | 1 + packages/ui/src/materialize.ts | 6 + packages/ui/src/tool-activity.tsx | 23 +- .../ui/src/tool-activity/builtin-preview.ts | 6 +- 19 files changed, 800 insertions(+), 15 deletions(-) diff --git a/apps/desktop/src/main/__tests__/tool-args-redaction-contract.test.ts b/apps/desktop/src/main/__tests__/tool-args-redaction-contract.test.ts index 077b31d7c9..f727042db6 100644 --- a/apps/desktop/src/main/__tests__/tool-args-redaction-contract.test.ts +++ b/apps/desktop/src/main/__tests__/tool-args-redaction-contract.test.ts @@ -20,6 +20,7 @@ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { formatRedactedJson, formatToolIntent } from '@maka/ui'; +import { formatToolInvocationLine, projectToolArgsPreview } from '@maka/core/tool-quiet-preview'; describe('tool args redaction', () => { it('redacts JSON-shaped args before they are rendered', () => { @@ -44,4 +45,24 @@ describe('tool args redaction', () => { assert.ok(rendered.length <= 241); }); + + it('keeps secrets out of the collapsed-row invocation line and its wire preview', () => { + // Built at runtime so no literal secret ever sits in the repo. + const bearerToken = ['sk', 'live', 'test', '9f8e7d6c5b4a'].join('-'); + const passwordValue = ['maka', 'pw', '1a2b3c4d'].join('-'); + const args = { + command: `curl -H "Authorization: Bearer ${bearerToken}" https://example.test`, + password: passwordValue, + }; + const line = formatToolInvocationLine({ toolName: 'Bash', args }, 'en'); + assert.ok(line !== undefined); + assert.doesNotMatch(line, new RegExp(bearerToken)); + assert.match(line, /redacted/i); + + const preview = projectToolArgsPreview('Bash', args); + const serialized = JSON.stringify(preview ?? null); + assert.doesNotMatch(serialized, new RegExp(bearerToken)); + assert.doesNotMatch(serialized, new RegExp(passwordValue)); + assert.doesNotMatch(serialized, /password/); + }); }); diff --git a/packages/cli/src/__tests__/pi-transcript.test.ts b/packages/cli/src/__tests__/pi-transcript.test.ts index 035545f4e0..c9092ad0d7 100644 --- a/packages/cli/src/__tests__/pi-transcript.test.ts +++ b/packages/cli/src/__tests__/pi-transcript.test.ts @@ -4249,6 +4249,90 @@ describe('Maka Pi TUI transcript', () => { } }); + test('names a live quiet Bash row from the wire args preview', () => { + const state = createMakaPiTranscriptState(); + // Runtime Host live tool_start omits full args; the bounded preview is all + // the compact row has until the turn-end reconcile. + applyMakaSessionEventToTranscript( + state, + event({ + type: 'tool_start', + toolUseId: 'bash-preview', + toolName: 'Bash', + args: undefined, + argsPreview: { command: 'git status --porcelain' }, + }), + ); + applyMakaSessionEventToTranscript( + state, + event({ + type: 'tool_result', + toolUseId: 'bash-preview', + isError: false, + content: { + kind: 'terminal', + cwd: '/repo', + cmd: 'git status --porcelain', + status: 'completed', + exitCode: 0, + output: { mode: 'pipes', stdout: '', stderr: '' }, + }, + }), + ); + + const rendered = renderMakaPiTranscript(state, meta(), 80).map(stripAnsi).join('\n'); + assert.match(rendered, /\$ git status --porcelain/); + // Once the row names the call, the quiet-success disclaimer is noise. + assert.doesNotMatch(rendered, /\(no output\)/); + }); + + test('keeps the no-output placeholder when the row cannot name the call', () => { + const state = createMakaPiTranscriptState(); + applyMakaSessionEventToTranscript( + state, + event({ type: 'tool_start', toolUseId: 'bash-blind', toolName: 'Bash', args: undefined }), + ); + applyMakaSessionEventToTranscript( + state, + event({ + type: 'tool_result', + toolUseId: 'bash-blind', + isError: false, + content: { + kind: 'terminal', + cwd: '/repo', + cmd: 'true', + status: 'completed', + exitCode: 0, + output: { mode: 'pipes', stdout: '', stderr: '' }, + }, + }), + ); + + const rendered = renderMakaPiTranscript(state, meta(), 80).map(stripAnsi).join('\n'); + assert.match(rendered, /\(no output\)/); + }); + + test('names a task_create row by its first subject, not a JSON dump', () => { + const state = createMakaPiTranscriptState(); + applyMakaSessionEventToTranscript( + state, + event({ + type: 'tool_start', + toolUseId: 'task-1', + toolName: 'task_create', + displayName: 'Task Create', + args: undefined, + argsPreview: { tasks: [{ subject: '修复登录 bug' }], tasksTotal: 2 }, + }), + ); + + const rendered = renderMakaPiTranscript(state, meta(), 80).map(stripAnsi).join('\n'); + assert.match(rendered, /修复登录 bug/); + assert.doesNotMatch(rendered, /tasks:/); + assert.doesNotMatch(rendered, /\(no output\)/); + }); + test('orders and de-dupes tool_output_delta by seq and marks redacted chunks', () => { const state = createMakaPiTranscriptState(); applyMakaSessionEventToTranscript( diff --git a/packages/cli/src/pi-transcript-tools.ts b/packages/cli/src/pi-transcript-tools.ts index 450ebba5c7..35442ebf99 100644 --- a/packages/cli/src/pi-transcript-tools.ts +++ b/packages/cli/src/pi-transcript-tools.ts @@ -93,14 +93,19 @@ function toolDurationText(entry: MakaPiToolEntry): string { * expand, so the row needs neither * a separator glyph nor an expand marker. Short annotations are reserved * whole during truncation: a long command can never hide an `exit 1`. + * + * The `no output` placeholder appears only when the row cannot name the call + * (no input summary): once the target says what ran, `● Bash $ git add -A` + * reads complete on its own and the disclaimer is noise. */ function renderCompactToolBlock(entry: MakaPiToolEntry, width: number): string[] { const inputSummary = collapseToSingleLine(toolInputSummary(entry)); const head = `${toolDisc(entry)} ${entry.title ?? entry.toolName}`; const annotation = compactAnnotation(entry); + const annotationText = annotation.placeholderOnly && inputSummary ? '' : annotation.text; return [ fitLine( - assembleCompactToolRow(head, inputSummary, annotation.text, width, annotation.protect), + assembleCompactToolRow(head, inputSummary, annotationText, width, annotation.protect), width, ), ]; @@ -114,19 +119,26 @@ function renderCompactToolBlock(entry: MakaPiToolEntry, width: number): string[] * `protect` reports whether every part is a fixed shape (durations always * are): only protected annotations are reserved whole during truncation. */ -function compactAnnotation(entry: MakaPiToolEntry): { text: string; protect: boolean } { +function compactAnnotation(entry: MakaPiToolEntry): { + text: string; + protect: boolean; + /** True when the annotation is solely the dim `no output` placeholder. */ + placeholderOnly: boolean; +} { const parts: string[] = []; const duration = toolDurationText(entry); if (duration) parts.push(duration); let protect = true; + let placeholderOnly = false; if (makaPiToolPresentationStatus(entry) !== 'running') { const summary = compactToolSummary(entry); if (summary && !(summary.placeholder && parts.length > 0)) { parts.push(collapseToSingleLine(summary.text)); protect = summary.protect === true; + placeholderOnly = summary.placeholder === true && parts.length === 1; } } - return { text: parts.length > 0 ? `(${parts.join(' · ')})` : '', protect }; + return { text: parts.length > 0 ? `(${parts.join(' · ')})` : '', protect, placeholderOnly }; } /** @@ -823,7 +835,11 @@ function toolInputSummary(entry: MakaPiToolEntry): string { const line = formatToolInvocationLine({ toolName: entry.toolName, args: input }, 'en'); if (line) return limitText(line, 600); // Absolute last resort — still single-line for the compact header contract. - return `input: ${limitText(formatUnknownInline(input), 600)}`; + // An empty args object carries no information; leave the row bare instead of + // printing `input: {}` noise (and let a quiet result keep its placeholder). + const inline = formatUnknownInline(input); + if (inline === '{}') return ''; + return `input: ${limitText(inline, 600)}`; } /** diff --git a/packages/cli/src/pi-transcript.ts b/packages/cli/src/pi-transcript.ts index 29e14e4878..65697693d6 100644 --- a/packages/cli/src/pi-transcript.ts +++ b/packages/cli/src/pi-transcript.ts @@ -814,14 +814,16 @@ export function applyMakaSessionEventToTranscript( const suppressed = (event.toolName === 'Read' || event.toolName === 'StopBackgroundTask') && !!ref && - !!findShellRunParent(state, ref, event.toolUseId); - state.entries.push({ + !!findShellRunParent(state, ref, event.toolUseId); state.entries.push({ kind: 'tool', turnId: event.turnId, toolUseId: event.toolUseId, toolName: event.toolName, ...(event.displayName ? { title: event.displayName } : {}), - input: projectToolActivityArgs(event.toolName, event.args), + // Live Runtime Host frames omit full args; the bounded wire preview + // still lets the compact row name the call. The turn-end reconcile + // replaces it with the durable full args. + input: projectToolActivityArgs(event.toolName, event.args ?? event.argsPreview), resultVersion: 0, progress: createProgressBuffer(), outputDeltas: createOutputBuffer(), diff --git a/packages/core/src/__tests__/tool-quiet-preview.test.ts b/packages/core/src/__tests__/tool-quiet-preview.test.ts index e12978de88..f624b29e04 100644 --- a/packages/core/src/__tests__/tool-quiet-preview.test.ts +++ b/packages/core/src/__tests__/tool-quiet-preview.test.ts @@ -19,7 +19,13 @@ import { strict as assert } from 'node:assert'; import { describe, it } from 'node:test'; -import { formatAsKeyValueLines, formatQuietJsonValue } from '../tool-quiet-preview.js'; +import { + formatAsKeyValueLines, + formatQuietJsonValue, + formatToolInvocationLine, + projectToolArgsPreview, +} from '../tool-quiet-preview.js'; +import { projectToolActivityArgs } from '../tool-activity-args.js'; describe('tool quiet preview', () => { it('redacts secrets in values and embedded keys', () => { @@ -31,3 +37,153 @@ describe('tool quiet preview', () => { assert.match(key, /redacted/i); }); }); + +describe('formatToolInvocationLine', () => { + it('names a Bash call by its command', () => { + const line = formatToolInvocationLine( + { toolName: 'Bash', args: { command: 'git status --porcelain' } }, + 'en', + ); + assert.equal(line, 'git status --porcelain'); + }); + + it('names a task_create call by its first subject with a count suffix', () => { + const line = formatToolInvocationLine( + { + toolName: 'task_create', + args: { tasks: [{ subject: '修复登录 bug' }, { subject: '写测试' }] }, + }, + 'zh', + ); + assert.equal(line, '修复登录 bug 等 2 项'); + const en = formatToolInvocationLine( + { + toolName: 'task_create', + args: { tasks: [{ subject: 'Fix login' }, { subject: 'Add tests' }, { subject: 'Ship' }] }, + }, + 'en', + ); + assert.equal(en, 'Fix login +2 more'); + }); + + it('names a task_update call by subject, then id and status', () => { + assert.equal( + formatToolInvocationLine( + { toolName: 'task_update', args: { id: 'T1', subject: '改名后的任务' } }, + 'zh', + ), + '改名后的任务', + ); + assert.equal( + formatToolInvocationLine( + { toolName: 'task_update', args: { id: 'T1', status: 'completed' } }, + 'en', + ), + 'T1 → completed', + ); + }); + + it('names a GoalSet call by its condition', () => { + const line = formatToolInvocationLine( + { toolName: 'GoalSet', args: { condition: 'all tests in packages/runtime pass' } }, + 'en', + ); + assert.equal(line, 'all tests in packages/runtime pass'); + }); + + it('names an AskUserQuestion call by its first question with a count suffix', () => { + const line = formatToolInvocationLine( + { + toolName: 'AskUserQuestion', + args: { + questions: [ + { question: '选哪个方案?', options: [{ label: 'A' }, { label: 'B' }] }, + { question: '继续吗?', options: [{ label: '是' }, { label: '否' }] }, + ], + }, + }, + 'zh', + ); + assert.equal(line, '选哪个方案? 等 2 问'); + }); + + it('keeps the ScheduledTask title headline', () => { + const line = formatToolInvocationLine( + { + toolName: 'ScheduledTask', + args: { title: '每天 9:00 生成日报', schedule: { kind: 'cron' } }, + }, + 'zh', + ); + assert.equal(line, '每天 9:00 生成日报'); + }); +}); + +describe('projectToolArgsPreview', () => { + it('keeps only the formatter-readable fields, shaped like the args', () => { + const preview = projectToolArgsPreview('Write', { + path: 'packages/ui/src/tool-activity.tsx', + content: 'a very large file body that must never reach the wire', + }); + assert.deepEqual(preview, { path: 'packages/ui/src/tool-activity.tsx' }); + }); + + it('redacts secrets embedded in command strings', () => { + const preview = projectToolArgsPreview('Bash', { + command: 'curl -H "Authorization: Bearer super-secret-token-value" https://example.com', + }); + const serialized = JSON.stringify(preview); + assert.doesNotMatch(serialized, /super-secret-token-value/); + assert.match(serialized, /redacted/i); + }); + + it('drops sensitive keys entirely', () => { + const preview = projectToolArgsPreview('SomeTool', { + title: 'hello', + password: 'hunter2', + api_key: 'abcdef', + }); + assert.deepEqual(preview, { title: 'hello' }); + }); + + it('bounds long values and whole-preview size', () => { + const preview = projectToolArgsPreview('Bash', { command: 'x'.repeat(5000) }); + const command = (preview as { command: string }).command; + assert.ok(command.length <= 240, `expected <=240 chars, got ${command.length}`); + assert.ok(command.endsWith('…')); + assert.ok(JSON.stringify(preview).length <= 2048); + }); + + it('keeps task subjects so the formatter reports the original count', () => { + const preview = projectToolArgsPreview('task_create', { + tasks: [ + { subject: 'one', parent_id: 'p' }, + { subject: 'two' }, + { subject: 'three' }, + { subject: 'four' }, + { subject: 'five' }, + ], + }); + const line = formatToolInvocationLine({ toolName: 'task_create', args: preview }, 'zh'); + assert.equal(line, 'one 等 5 项'); + }); + + it('preserves the WriteStdin projected inputPreview shape', () => { + const projected = projectToolActivityArgs('WriteStdin', { + ref: 'maka://runtime/background-tasks/1', + input: 'ls -la\n', + size: { cols: 80, rows: 24 }, + }); + const preview = projectToolArgsPreview('WriteStdin', projected); + const line = formatToolInvocationLine({ toolName: 'WriteStdin', args: preview }, 'zh'); + assert.ok(line !== undefined); + assert.match(line, /后台终端交互/); + assert.match(line, /80x24/); + }); + + it('returns undefined when nothing displayable exists', () => { + assert.equal(projectToolArgsPreview('Bash', {}), undefined); + assert.equal(projectToolArgsPreview('Bash', undefined), undefined); + assert.equal(projectToolArgsPreview('Bash', { content: 'not a headline field' }), undefined); + }); +}); diff --git a/packages/core/src/events.ts b/packages/core/src/events.ts index 9d942ce1a1..dc9c11c41d 100644 --- a/packages/core/src/events.ts +++ b/packages/core/src/events.ts @@ -563,6 +563,12 @@ export interface ToolStartEvent extends BaseEvent, ToolActivityIdentity { providerExecuted?: boolean; displayName?: string; intent?: string; + /** + * Transient, never persisted: a bounded/redacted args subset synthesized at + * the Runtime Host client seam (live `tool_start` frames omit full args). + * Display formatters read `args ?? argsPreview`; durable replay never has it. + */ + argsPreview?: unknown; /** * Id of the assistant step this tool call belongs to (equals the step's * AssistantMessage id / the step's text+thinking messageId). Lets model diff --git a/packages/core/src/tool-quiet-preview.ts b/packages/core/src/tool-quiet-preview.ts index f59d1ce93b..e49a21fe53 100644 --- a/packages/core/src/tool-quiet-preview.ts +++ b/packages/core/src/tool-quiet-preview.ts @@ -30,7 +30,7 @@ */ import { redactSecrets } from './display-redaction.js'; -import { readWriteStdinInputPreview } from './tool-activity-args.js'; +import { projectToolActivityArgs, readWriteStdinInputPreview } from './tool-activity-args.js'; import type { UiLocale } from './ui-locale.js'; // ── Locale ─────────────────────────────────────────────────────────────── @@ -47,6 +47,10 @@ interface QuietPreviewStrings { written: string; /** Format a byte count suffix, e.g. `共 7 字节` / `7 bytes`. */ bytes: (n: number) => string; + /** Suffix for a task list previewed by its first entry, e.g. `等 3 项` / `+2 more`. */ + moreTasks: (total: number) => string; + /** Suffix for a question list previewed by its first entry, e.g. `等 2 问` / `+1 more`. */ + moreQuestions: (total: number) => string; } const STRINGS_BY_LOCALE: Record = { @@ -58,6 +62,8 @@ const STRINGS_BY_LOCALE: Record = { replacements: (n) => `${n} 处`, written: '已写入', bytes: (n) => `共 ${n} 字节`, + moreTasks: (total) => (total > 1 ? ` 等 ${total} 项` : ''), + moreQuestions: (total) => (total > 1 ? ` 等 ${total} 问` : ''), }, en: { backgroundTerminal: 'Background terminal interaction', @@ -67,6 +73,8 @@ const STRINGS_BY_LOCALE: Record = { replacements: (n) => (n === 1 ? '1 replacement' : `${n} replacements`), written: 'written', bytes: (n) => `${n} bytes`, + moreTasks: (total) => (total > 1 ? ` +${total - 1} more` : ''), + moreQuestions: (total) => (total > 1 ? ` +${total - 1} more` : ''), }, }; @@ -255,6 +263,49 @@ export function formatToolInvocationLine( return parts.join(' · '); } + // Session task ledger and other structured-args tools: the generic key:value + // fallback renders their nested payloads as `tasks:` / `questions:` dumps, so + // give the row the one fact a reader needs — the first subject / question, + // the goal condition — with a count suffix when more entries exist. + if (name === 'task_create') { + const tasks = Array.isArray(args.tasks) ? args.tasks : undefined; + const firstSubject = tasks + ?.map((task) => stringField(asRecord(task), 'subject')) + .find((subject) => subject !== undefined); + if (firstSubject) { + // A wire args preview caps the list; `tasksTotal` keeps the real count. + const total = numberField(args, 'tasksTotal') ?? tasks!.length; + return redactSecrets(`${firstSubject}${s.moreTasks(total)}`); + } + } + + if (name === 'task_update') { + const subject = stringField(args, 'subject'); + if (subject) return redactSecrets(subject); + const id = stringField(args, 'id'); + if (id) { + const status = stringField(args, 'status'); + const shortId = id.length > 12 ? `${id.slice(0, 8)}…` : id; + return redactSecrets(status ? `${shortId} → ${status}` : shortId); + } + } + + if (name === 'GoalSet') { + const condition = stringField(args, 'condition'); + if (condition) return redactSecrets(condition); + } + + if (name === 'AskUserQuestion') { + const questions = Array.isArray(args.questions) ? args.questions : undefined; + const firstQuestion = questions + ?.map((question) => stringField(asRecord(question), 'question')) + .find((questionText) => questionText !== undefined); + if (firstQuestion) { + const total = numberField(args, 'questionsTotal') ?? questions!.length; + return redactSecrets(`${firstQuestion}${s.moreQuestions(total)}`); + } + } + if (name === 'Grep' || (pattern && (name === 'Glob' || path))) { if (pattern) { const scope = path ? ` in ${path}` : ''; @@ -288,6 +339,176 @@ export function formatToolInvocationLine( return lines.length > 0 ? lines : undefined; } +// ── Public API: args preview (live wire) ──────────────────────────────── + +/** + * Per-value cap for the wire preview. Long commands/paths still identify the + * call; the full value arrives with the durable transcript at turn end. + */ +const ARGS_PREVIEW_STRING_MAX_CHARS = 240; +/** Whole-preview cap; lowest-priority fields drop until the preview fits. */ +const ARGS_PREVIEW_MAX_CHARS = 2048; +/** List fields (tasks / questions) keep only their leading entries. */ +const ARGS_PREVIEW_LIST_MAX_ITEMS = 4; + +/** + * Whitelist of scalar args keys {@link formatToolInvocationLine} can read, in + * display-priority order. Anything not listed here (file contents, option + * payloads, provider blobs) never enters the live wire preview. + */ +const ARGS_PREVIEW_SCALAR_KEYS = [ + 'command', + 'cmd', + 'script', + 'path', + 'file', + 'pattern', + 'glob', + 'cwd', + 'query', + 'url', + 'name', + 'title', + 'subject', + 'condition', + 'status', + 'id', + 'ref', + 'input', +] as const; + +const ARGS_PREVIEW_NUMBER_KEYS = ['offset', 'limit'] as const; + +function boundPreviewString(value: string): string { + const redacted = redactSecrets(value); + const chars = Array.from(redacted); + return chars.length <= ARGS_PREVIEW_STRING_MAX_CHARS + ? redacted + : `${chars.slice(0, ARGS_PREVIEW_STRING_MAX_CHARS - 1).join('')}…`; +} + +function previewStringField(record: Record, key: string): string | undefined { + const raw = record[key]; + return typeof raw === 'string' && raw.trim().length > 0 ? boundPreviewString(raw) : undefined; +} + +function previewListSubjects( + record: Record, + listKey: 'tasks' | 'questions', + itemKey: 'subject' | 'question', +): { items: Record[]; total: number } | undefined { + const list = record[listKey]; + if (!Array.isArray(list) || list.length === 0) return undefined; + const items: Record[] = []; + for (const entry of list.slice(0, ARGS_PREVIEW_LIST_MAX_ITEMS)) { + const text = previewStringField(asRecord(entry) ?? {}, itemKey); + if (text !== undefined) items.push({ [itemKey]: text }); + } + return items.length > 0 ? { items, total: list.length } : undefined; +} + +function previewInputPreview(value: unknown): Record | undefined { + const preview = asRecord(value); + if (!preview) return undefined; + const text = previewStringField(preview, 'text'); + if (text === undefined) return undefined; + const bytes = numberField(preview, 'bytes'); + return { + text, + bytes: bytes ?? Array.from(text).length, + truncated: preview.truncated === true, + }; +} + +function previewSize(value: unknown): Record | undefined { + const size = asRecord(value); + if (!size) return undefined; + const cols = numberField(size, 'cols'); + const rows = numberField(size, 'rows'); + return cols !== undefined && rows !== undefined ? { cols, rows } : undefined; +} + +/** + * A bounded, redacted, wire-safe preview of a tool call's args, shaped like + * the args themselves so `formatToolInvocationLine` renders the same line from + * the preview as from the full args. + * + * Runtime Host live `tool_start` frames deliberately omit full args (the lean + * subscription channel predates per-frame budgets; a Write can carry a whole + * file). Without any args signal, compact/collapsed tool rows render name-only + * for the whole live window — the only window a watching user sees. This + * preview carries just the fields the invocation-line formatter reads, each + * string redacted and capped, so live rows can say what the call does without + * streaming file bodies or option payloads. + * + * Sensitive keys are dropped structurally (never redacted-in-place) and every + * string still passes `redactSecrets`; the durable transcript remains the + * authority for full args. + */ +export function projectToolArgsPreview( + toolName: string, + args: unknown, +): Record | undefined { + const record = asRecord(args); + if (!record) return undefined; + + // Apply the canonical activity projection first so WriteStdin's inputPreview + // shape (bounded, display-safe) is what the whitelist picks up. + const projected = asRecord(projectToolActivityArgs(toolName, args)) ?? record; + + const picked = new Map(); + for (const key of ARGS_PREVIEW_SCALAR_KEYS) { + if (isSensitiveKey(key)) continue; + const value = previewStringField(projected, key); + if (value !== undefined) picked.set(key, value); + } + for (const key of ARGS_PREVIEW_NUMBER_KEYS) { + const value = numberField(projected, key); + if (value !== undefined) picked.set(key, value); + } + const inputPreview = previewInputPreview(projected.inputPreview); + if (inputPreview) picked.set('inputPreview', inputPreview); + const size = previewSize(projected.size); + if (size) picked.set('size', size); + const tasks = previewListSubjects(projected, 'tasks', 'subject'); + if (tasks) { + picked.set('tasks', tasks.items); + if (tasks.total > tasks.items.length) picked.set('tasksTotal', tasks.total); + } + const questions = previewListSubjects(projected, 'questions', 'question'); + if (questions) { + picked.set('questions', questions.items); + if (questions.total > questions.items.length) picked.set('questionsTotal', questions.total); + } + + if (picked.size === 0) return undefined; + + // Enforce the whole-preview budget by dropping lowest-priority fields; the + // first picked (highest-priority) field always survives. + const keysByPriority = [ + ...ARGS_PREVIEW_SCALAR_KEYS, + ...ARGS_PREVIEW_NUMBER_KEYS, + 'inputPreview', + 'size', + 'tasks', + 'tasksTotal', + 'questions', + 'questionsTotal', + ]; + const result: Record = {}; + for (const key of keysByPriority) { + const value = picked.get(key); + if (value !== undefined) result[key] = value; + } + const droppable = [...keysByPriority].reverse(); + while (JSON.stringify(result).length > ARGS_PREVIEW_MAX_CHARS && droppable.length > 1) { + const key = droppable.shift()!; + if (key === keysByPriority.find((candidate) => result[candidate] !== undefined)) continue; + delete result[key]; + } + return result; +} + // ── Public API: quiet JSON value ───────────────────────────────────────── export interface QuietPreview { diff --git a/packages/runtime-host/src/__tests__/protocol.test.ts b/packages/runtime-host/src/__tests__/protocol.test.ts index 7a5e8551ef..8c4b8e3f2b 100644 --- a/packages/runtime-host/src/__tests__/protocol.test.ts +++ b/packages/runtime-host/src/__tests__/protocol.test.ts @@ -499,6 +499,13 @@ describe('Runtime Host bootstrap protocol', () => { toolName: 'read', displayName: 'Read file', }, + { + ...identity, + type: 'tool_start', + toolName: 'Bash', + intent: '只读探索:定位渲染入口', + argsPreview: { command: 'git status --porcelain' }, + }, { ...identity, type: 'tool_output_delta', @@ -539,6 +546,18 @@ describe('Runtime Host bootstrap protocol', () => { toolName: 'read', args: { path: '/private' }, }, + { + ...identity, + type: 'tool_start', + toolName: 'read', + argsPreview: { command: 'x'.repeat(9 * 1024) }, + }, + { + ...identity, + type: 'tool_start', + toolName: 'read', + intent: 42, + }, { ...identity, type: 'tool_result', diff --git a/packages/runtime-host/src/__tests__/session-continuity-coordinator.test.ts b/packages/runtime-host/src/__tests__/session-continuity-coordinator.test.ts index 10f56328c7..622d8a3344 100644 --- a/packages/runtime-host/src/__tests__/session-continuity-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/session-continuity-coordinator.test.ts @@ -1829,6 +1829,42 @@ test('rejoin seeds tool_result_preview at the open nextSequence without sequence coordinator.close(); }); +test('live tool_start projects intent and a bounded args preview, never full args', async () => { + const coordinator = new SessionContinuityCoordinator( + HOST_EPOCH, + async () => canonical(), + new SessionAdmissionGate(), + ); + const sink = new RecordingSink(); + const connection = coordinator.attachConnection('connection-tool-start', sink); + const opened = await open(coordinator, 'connection-tool-start'); + connection.activate(opened.subscriptionId); + + await coordinator.acceptRuntimeEvent(SESSION_ID, 'run-1', { + type: 'tool_start', + id: 'start-1', + turnId: 'turn-1', + ts: 1, + toolUseId: 'tool-1', + toolName: 'Bash', + intent: '只读探索:检查渲染入口', + args: { command: 'git status --porcelain', content: 'x'.repeat(100 * 1024) }, + }); + await delayImmediate(); + + const frame = sink.frames.find((candidate) => candidate.kind === 'subscription.session_event'); + assert.ok(frame && frame.kind === 'subscription.session_event'); + const event = frame.event; + assert.equal(event.type, 'tool_start'); + if (event.type !== 'tool_start') return; + assert.equal(event.intent, '只读探索:检查渲染入口'); + assert.deepEqual(event.argsPreview, { command: 'git status --porcelain' }); + assert.equal('args' in event, false); + + connection.abort(opened.subscriptionId); + coordinator.close(); +}); + test('tool_result clears retained tool_result_preview so a later open does not seed it', async () => { const coordinator = new SessionContinuityCoordinator( HOST_EPOCH, diff --git a/packages/runtime-host/src/__tests__/session-projector.test.ts b/packages/runtime-host/src/__tests__/session-projector.test.ts index c679f46126..5fbcce8395 100644 --- a/packages/runtime-host/src/__tests__/session-projector.test.ts +++ b/packages/runtime-host/src/__tests__/session-projector.test.ts @@ -798,3 +798,39 @@ function assistant(id: string, text: string): Extract { + const projector = new RuntimeHostSessionProjector( + snapshot(), + createRuntimeHostSessionProjectionSeed([], snapshot()), + () => 10, + [], + ); + + const update = projector.accept({ + kind: 'subscription.session_event', + hostEpoch: 'host-1', + subscriptionId: 'subscription-1', + sequence: 1, + sessionId: 'session-1', + runId: 'run-1', + event: { + type: 'tool_start', + id: 'event-1', + turnId: 'turn-1', + ts: 1, + toolUseId: 'tool-1', + toolName: 'Bash', + intent: '只读探索:检查渲染入口', + argsPreview: { command: 'git status --porcelain' }, + }, + }); + + assert.equal(update.events.length, 1); + const event = update.events[0]!; + assert.equal(event.type, 'tool_start'); + if (event.type !== 'tool_start') return; + assert.equal(event.intent, '只读探索:检查渲染入口'); + assert.deepEqual(event.argsPreview, { command: 'git status --porcelain' }); + assert.equal(event.args, undefined); +}); diff --git a/packages/runtime-host/src/adapter/session-projector.ts b/packages/runtime-host/src/adapter/session-projector.ts index 4ac3ed939d..0288be35ef 100644 --- a/packages/runtime-host/src/adapter/session-projector.ts +++ b/packages/runtime-host/src/adapter/session-projector.ts @@ -610,6 +610,10 @@ function projectSessionEvent( ...(event.operationId ? { operationId: event.operationId } : {}), ...(event.activityKind ? { activityKind: event.activityKind } : {}), ...(event.displayName ? { displayName: event.displayName } : {}), + ...(event.intent ? { intent: event.intent } : {}), + ...(event.argsPreview !== undefined + ? { argsPreview: structuredClone(event.argsPreview) } + : {}), ...(event.stepId ? { stepId: event.stepId } : {}), ...(event.shellRunRef ? { shellRunRef: event.shellRunRef } : {}), }; diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index b735d3549d..e1fed986bd 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -94,7 +94,11 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const; export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const; // Increment when the same protocol version no longer guarantees safe Client-Host // interoperability. Mismatches are rejected before domain commands are admitted. -export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 64 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 65 as const; +// 65: live `tool_start` frames may carry optional `intent` / `argsPreview` +// keys. Older Clients decode the event with a strict allowed-key list and tear +// the connection down on unknown keys, so the pair must be refused up front. +// The strict decoder's allowed-key union also retains `shellRunRef`. // 64: execution.inspect drops the retired resolve operation. Older peers still // know execution.inspect.resolve and would send it only to fail mid-connection, // so removing it needs its own handshake boundary. @@ -154,8 +158,6 @@ export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 64 as const; // 41: Context compaction returns a typed terminal outcome on both Turn // snapshots and context.compact results. Epoch-40 peers reject these closed // shapes after admission, so mixed peers must fail during the handshake. -// 40: The message queue gains per-entry mutation operations -// (queue.entry.promote, queue.entry.retract, queue.entries.reorder). // 39: Client Capability tool descriptors carry trusted activity semantics and // invocations can stream bounded progress frames. // 38: `execute` is no longer a permission mode. Frame decoders reject it, so a @@ -177,6 +179,9 @@ export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 64 as const; // needed its client identity. An older peer still offers both. // 30: Access credential pairing adds prepare/finalize operations. Older Hosts // cannot complete the staged credential handoff used by managed onboarding. +// 30: live `tool_start` frames may carry optional `intent` / `argsPreview` +// keys. Older Clients decode the event with a strict allowed-key list and tear +// the connection down on unknown keys, so the pair must be refused up front. // 29: `goal.arm` is a new wire operation. An older Host decodes it as unknown // and tears the connection down, so the pair must be refused up front. // 28: Relay model profiles carry the Fast service-tier declaration. Older diff --git a/packages/runtime-host/src/protocol/session-continuity.ts b/packages/runtime-host/src/protocol/session-continuity.ts index f55387f1c5..5ed3397d5f 100644 --- a/packages/runtime-host/src/protocol/session-continuity.ts +++ b/packages/runtime-host/src/protocol/session-continuity.ts @@ -64,6 +64,14 @@ export const SESSION_LIVE_DELTA_MAX_BYTES = 16 * 1024; // needs at most three UTF-8 bytes (an astral pair needs four bytes total). export const SESSION_TOOL_OUTPUT_DELTA_MAX_BYTES = 3 * TOOL_OUTPUT_DELTA_MAX_CHARS; export const SESSION_TOOL_NAME_MAX_BYTES = 256; +export const SESSION_TOOL_INTENT_MAX_BYTES = 512; +/** + * Live `tool_start` frames carry a bounded, redacted args preview (never the + * full args — a Write can carry a whole file) so compact tool rows can name + * the call during the live window. Sized to fit `@maka/core` + * `projectToolArgsPreview`'s 2,048-char JSON cap with UTF-8 headroom. + */ +export const SESSION_TOOL_ARGS_PREVIEW_MAX_BYTES = 8 * 1024; export const SESSION_SUBSCRIPTION_FRAME_MAX_BYTES = 64 * 1024 - 1; export const SESSION_RUNTIME_RESOURCE_PTY_DATA_MAX_BYTES = 48 * 1024; @@ -167,6 +175,18 @@ export type SessionToolEvent = // an event the rest of the system considered valid. activityKind?: ToolActivityKind; displayName?: string; + /** + * Model/runtime-authored call intent (e.g. ExploreAgent's objective). + * Pass-through from the durable event; bounded on the wire. + */ + intent?: string; + /** + * Bounded, redacted subset of the call args (see `@maka/core` + * `projectToolArgsPreview`) so live compact tool rows can name what the + * call does before the durable transcript delivers full args at turn + * end. Shaped like the args themselves; never carries file contents. + */ + argsPreview?: unknown; stepId?: string; shellRunRef?: string; }) @@ -774,6 +794,8 @@ function decodeSessionToolEvent(value: unknown): SessionToolEvent { 'operationId', 'activityKind', 'displayName', + 'intent', + 'argsPreview', 'stepId', 'shellRunRef', ]; @@ -786,6 +808,13 @@ function decodeSessionToolEvent(value: unknown): SessionToolEvent { 'toolUseId', 'toolName', ]); + if (record.argsPreview !== undefined) { + requireEncodedByteLimit( + record.argsPreview, + 'Session tool args preview', + SESSION_TOOL_ARGS_PREVIEW_MAX_BYTES, + ); + } return { type: record.type, ...identity, @@ -809,6 +838,18 @@ function decodeSessionToolEvent(value: unknown): SessionToolEvent { SESSION_TOOL_NAME_MAX_BYTES, ), }), + ...(record.intent === undefined + ? {} + : { + intent: requireUtf8BoundedString( + record.intent, + 'Session tool intent', + SESSION_TOOL_INTENT_MAX_BYTES, + ), + }), + ...(record.argsPreview === undefined + ? {} + : { argsPreview: structuredClone(record.argsPreview) }), ...(record.stepId === undefined ? {} : { stepId: requireEntityId(record.stepId, 'stepId') }), ...(record.shellRunRef === undefined ? {} diff --git a/packages/runtime-host/src/server/session-continuity-coordinator.ts b/packages/runtime-host/src/server/session-continuity-coordinator.ts index e440ca6e89..cb3b9fc815 100644 --- a/packages/runtime-host/src/server/session-continuity-coordinator.ts +++ b/packages/runtime-host/src/server/session-continuity-coordinator.ts @@ -20,6 +20,7 @@ import { randomUUID } from 'node:crypto'; import { isDeepStrictEqual } from 'node:util'; import type { SessionEvent, ShellRunUpdate } from '@maka/core/events'; +import { projectToolArgsPreview } from '@maka/core/tool-quiet-preview'; import { decodeRuntimeResourceRef, encodeProtocolMessage, @@ -29,6 +30,8 @@ import { SESSION_RUNTIME_RESOURCE_CHANGES_MAX, SESSION_SUBSCRIPTION_FRAME_MAX_BYTES, SUBSCRIPTION_OPEN_RESULT_MAX_BYTES, + SESSION_TOOL_ARGS_PREVIEW_MAX_BYTES, + SESSION_TOOL_INTENT_MAX_BYTES, SESSION_TOOL_NAME_MAX_BYTES, type AgentGraphChangedFrame, type AgentGraphChangedReason, @@ -1966,6 +1969,13 @@ function projectSessionEvent( ...(event.displayName === undefined ? {} : { displayName: boundedUtf8(event.displayName, SESSION_TOOL_NAME_MAX_BYTES) }), + ...(event.intent === undefined + ? {} + : { intent: boundedUtf8(event.intent, SESSION_TOOL_INTENT_MAX_BYTES) }), + // A correlated hidden-shell poll publishes only its correlation ref: + // the frame is deliberately minimal (#3569), so no args preview rides + // along. Every other live tool start names itself for compact rows. + ...(shellRunRef ? {} : projectArgsPreviewForWire(event.toolName, event.args)), ...(event.stepId === undefined ? {} : { stepId: event.stepId }), ...(shellRunRef ? { shellRunRef } : {}), }; @@ -2010,6 +2020,20 @@ function projectSessionEvent( } } +/** + * Build the wire `argsPreview` spread for a live `tool_start`. The preview is + * computed and bounded in `@maka/core`; the extra byte check here is the + * wire-budget guard so a formatter change cannot silently bloat frames. + */ +function projectArgsPreviewForWire(toolName: string, args: unknown): { argsPreview?: unknown } { + const preview = projectToolArgsPreview(toolName, args); + if (preview === undefined) return {}; + if (Buffer.byteLength(JSON.stringify(preview), 'utf8') > SESSION_TOOL_ARGS_PREVIEW_MAX_BYTES) { + return {}; + } + return { argsPreview: preview }; +} + function boundedUtf8(value: string, maxBytes: number): string { if (Buffer.byteLength(value, 'utf8') <= maxBytes) return value; let bounded = ''; diff --git a/packages/ui/src/__tests__/tool-activity-presentation.test.ts b/packages/ui/src/__tests__/tool-activity-presentation.test.ts index ecb7ef2aa9..c9fe906d7a 100644 --- a/packages/ui/src/__tests__/tool-activity-presentation.test.ts +++ b/packages/ui/src/__tests__/tool-activity-presentation.test.ts @@ -468,3 +468,87 @@ describe('tool activity presentation', () => { assert.match(enMarkup, /aria-label="Copy: BetaTool"/); }); }); + +describe('collapsed tool row target', () => { + const baseItem = { + toolUseId: 'tool-collapsed', + toolName: 'Bash', + status: 'running' as const, + }; + + it('shows the invocation line derived from args when no intent exists', async () => { + const { ToolTrow } = await import('../tool-activity.js'); + const markup = renderToStaticMarkup(createElement(ToolTrow, { + items: [{ + ...baseItem, + args: { command: 'git status --porcelain' }, + }], + })); + assert.match(markup, /git status --porcelain/); + }); + + it('prefers the runtime-authored intent over the args-derived line', async () => { + const { ToolTrow } = await import('../tool-activity.js'); + const markup = renderToStaticMarkup(createElement(ToolTrow, { + items: [{ + ...baseItem, + toolName: 'ExploreAgent', + intent: '只读探索:定位渲染入口', + args: { objective: '定位渲染入口' }, + }], + })); + assert.match(markup, /只读探索/); + }); + + it('names a live call from the wire args preview before full args arrive', async () => { + const { ToolTrow } = await import('../tool-activity.js'); + const markup = renderToStaticMarkup(createElement(ToolTrow, { + items: [{ + ...baseItem, + args: undefined, + argsPreview: { command: 'npm test' }, + }], + })); + assert.match(markup, /npm test/); + }); + + it('names a task ledger call by its first subject', async () => { + const { ToolTrow } = await import('../tool-activity.js'); + const markup = renderToStaticMarkup(createElement(ToolTrow, { + items: [{ + ...baseItem, + toolName: 'task_create', + displayName: 'Task Create', + args: { tasks: [{ subject: '修复登录 bug' }, { subject: '补测试' }] }, + }], + })); + assert.match(markup, /修复登录 bug 等 2 项/); + }); + + it('caps a long command so the collapsed row stays single-line', async () => { + const { ToolTrow } = await import('../tool-activity.js'); + const markup = renderToStaticMarkup(createElement(ToolTrow, { + items: [{ + ...baseItem, + args: { command: `echo ${'x'.repeat(300)}` }, + }], + })); + const matches = markup.match(/x{100,}/g) ?? []; + for (const run of matches) { + assert.ok(run.length <= 119, `expected a capped run, got ${run.length}`); + } + assert.match(markup, /…/); + }); + + it('redacts secrets in the collapsed target', async () => { + const { ToolTrow } = await import('../tool-activity.js'); + const markup = renderToStaticMarkup(createElement(ToolTrow, { + items: [{ + ...baseItem, + args: { command: 'curl -H "Authorization: Bearer live-secret-token" https://example.com' }, + }], + })); + assert.doesNotMatch(markup, /live-secret-token/); + assert.match(markup, /redacted/i); + }); +}); diff --git a/packages/ui/src/live-turn-projection.ts b/packages/ui/src/live-turn-projection.ts index 6391c0e42f..fd5f74afb2 100644 --- a/packages/ui/src/live-turn-projection.ts +++ b/packages/ui/src/live-turn-projection.ts @@ -373,6 +373,7 @@ export function applyLiveTurnEvent( ...(event.activityKind !== undefined ? { activityKind: event.activityKind } : {}), ...(event.displayName !== undefined ? { displayName: event.displayName } : {}), ...(event.intent !== undefined ? { intent: event.intent } : {}), + ...(event.argsPreview !== undefined ? { argsPreview: event.argsPreview } : {}), ...projectToolActivityIdentity(event), ...(event.stepId !== undefined ? { stepId: event.stepId } : {}), status: 'running', diff --git a/packages/ui/src/materialize.ts b/packages/ui/src/materialize.ts index 1ea01c5d4d..916e3db1d4 100644 --- a/packages/ui/src/materialize.ts +++ b/packages/ui/src/materialize.ts @@ -85,6 +85,12 @@ export interface ToolActivityItem { activityKind?: ToolActivityKind; displayName?: string; intent?: string; + /** + * Live-only bounded/redacted args subset from the Runtime Host wire (full + * args arrive with the durable transcript at turn end). Display formatters + * read `args ?? argsPreview`; never rendered as raw JSON. + */ + argsPreview?: unknown; origin?: 'provider' | 'code_mode'; modelVisibility?: 'visible' | 'hidden'; parentToolCallId?: string; diff --git a/packages/ui/src/tool-activity.tsx b/packages/ui/src/tool-activity.tsx index f10cc2c7bd..c9af674bc4 100644 --- a/packages/ui/src/tool-activity.tsx +++ b/packages/ui/src/tool-activity.tsx @@ -493,7 +493,7 @@ function standardToolCall( // arguments says what happened instead. name: computerActionLabel(item, locale) ?? resolveToolDisplayName(item, locale), status: astryxToolStatus(item), - target: item.intent ? formatToolIntent(item.intent) : inferredTarget, + target: collapsedToolTarget(item, locale, inferredTarget), duration: formatDuration(item.durationMs) ?? undefined, errorMessage: toolCallErrorMessage(item, locale), stats: item.progress && isInFlightToolStatus(toolActivityPresentationStatus(item)) @@ -511,6 +511,27 @@ function standardToolCall( }; } +/** + * What the collapsed row (and a collapsed group's header) says about the call. + * `intent` wins when the runtime authored one; otherwise fall back to the + * shared invocation line derived from the call's args — or, during the live + * window, from the bounded wire args preview (full args arrive at turn end). + * Only the first line is shown, hard-capped so a long command cannot stretch + * the group header (Astryx ellipsizes too, but the header row is shared). + */ +function collapsedToolTarget( + item: ToolActivityItem, + locale: UiLocale, + preferred?: string, +): string | undefined { + if (item.intent) return formatToolIntent(item.intent); + const line = preferred ?? formatToolInvocationLine(item, locale); + if (!line) return undefined; + const firstLine = line.split('\n')[0]!.trim(); + if (!firstLine) return undefined; + return firstLine.length > 120 ? `${firstLine.slice(0, 119)}…` : firstLine; +} + function linkedAgentRows( item: ToolActivityItem, locale: UiLocale, diff --git a/packages/ui/src/tool-activity/builtin-preview.ts b/packages/ui/src/tool-activity/builtin-preview.ts index 5bb17c5482..c491e95973 100644 --- a/packages/ui/src/tool-activity/builtin-preview.ts +++ b/packages/ui/src/tool-activity/builtin-preview.ts @@ -38,11 +38,13 @@ import type { ToolActivityItem } from '../materialize.js'; /** Desktop-adapted wrapper with an explicit resolved locale. */ export function formatToolInvocationLine( - item: Pick, + item: Pick, locale: UiLocale, ): string | undefined { + // Live Runtime Host frames carry only the bounded args preview; the durable + // transcript supplies full args at turn end. Format from whichever exists. return coreFormatToolInvocationLine( - { toolName: item.toolName, args: item.args }, + { toolName: item.toolName, args: item.args ?? item.argsPreview }, locale, ); } From 2207715d2d25b1e89462d95c167dfd2d3bea6cf4 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Tue, 25 Aug 2026 13:58:32 +0800 Subject: [PATCH 2/6] fix: address display and epoch changelog reviews - pi-transcript: split joined push line (Biome) - tool-quiet-preview: redact list subjects via isSensitiveKey/redactSecrets - protocol: restore missing 40 and drop duplicate 30 Generated-by: maka --- packages/cli/src/pi-transcript.ts | 3 ++- packages/core/src/tool-quiet-preview.ts | 6 +++++- packages/runtime-host/src/protocol/index.ts | 5 ++--- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/pi-transcript.ts b/packages/cli/src/pi-transcript.ts index 65697693d6..350918f548 100644 --- a/packages/cli/src/pi-transcript.ts +++ b/packages/cli/src/pi-transcript.ts @@ -814,7 +814,8 @@ export function applyMakaSessionEventToTranscript( const suppressed = (event.toolName === 'Read' || event.toolName === 'StopBackgroundTask') && !!ref && - !!findShellRunParent(state, ref, event.toolUseId); state.entries.push({ + !!findShellRunParent(state, ref, event.toolUseId); + state.entries.push({ kind: 'tool', turnId: event.turnId, toolUseId: event.toolUseId, diff --git a/packages/core/src/tool-quiet-preview.ts b/packages/core/src/tool-quiet-preview.ts index e49a21fe53..d509234585 100644 --- a/packages/core/src/tool-quiet-preview.ts +++ b/packages/core/src/tool-quiet-preview.ts @@ -397,12 +397,16 @@ function previewListSubjects( listKey: 'tasks' | 'questions', itemKey: 'subject' | 'question', ): { items: Record[]; total: number } | undefined { + if (isSensitiveKey(listKey) || isSensitiveKey(itemKey)) return undefined; const list = record[listKey]; if (!Array.isArray(list) || list.length === 0) return undefined; const items: Record[] = []; for (const entry of list.slice(0, ARGS_PREVIEW_LIST_MAX_ITEMS)) { const text = previewStringField(asRecord(entry) ?? {}, itemKey); - if (text !== undefined) items.push({ [itemKey]: text }); + if (text === undefined) continue; + const redacted = redactSecrets(text); + if (redacted.trim().length === 0) continue; + items.push({ [itemKey]: redacted }); } return items.length > 0 ? { items, total: list.length } : undefined; } diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index e1fed986bd..3947a24737 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -158,6 +158,8 @@ export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 65 as const; // 41: Context compaction returns a typed terminal outcome on both Turn // snapshots and context.compact results. Epoch-40 peers reject these closed // shapes after admission, so mixed peers must fail during the handshake. +// 40: The message queue gains per-entry mutation operations +// (queue.entry.promote, queue.entry.retract, queue.entries.reorder). // 39: Client Capability tool descriptors carry trusted activity semantics and // invocations can stream bounded progress frames. // 38: `execute` is no longer a permission mode. Frame decoders reject it, so a @@ -179,9 +181,6 @@ export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 65 as const; // needed its client identity. An older peer still offers both. // 30: Access credential pairing adds prepare/finalize operations. Older Hosts // cannot complete the staged credential handoff used by managed onboarding. -// 30: live `tool_start` frames may carry optional `intent` / `argsPreview` -// keys. Older Clients decode the event with a strict allowed-key list and tear -// the connection down on unknown keys, so the pair must be refused up front. // 29: `goal.arm` is a new wire operation. An older Host decodes it as unknown // and tears the connection down, so the pair must be refused up front. // 28: Relay model profiles carry the Fast service-tier declaration. Older From 695542be7139671f71cb24f19df2603e5ed0932c Mon Sep 17 00:00:00 2001 From: me2seeks Date: Fri, 28 Aug 2026 10:19:38 +0800 Subject: [PATCH 3/6] fix: tighten live tool preview privacy Generated-by: Codex --- .../cli/src/__tests__/pi-transcript.test.ts | 34 +++++++++++++++++ packages/cli/src/pi-transcript-tools.ts | 5 ++- .../src/__tests__/tool-quiet-preview.test.ts | 27 +++++++++++++ packages/core/src/tool-quiet-preview.ts | 35 +++++++++++++---- .../session-continuity-coordinator.test.ts | 38 +++++++++++++++++++ 5 files changed, 130 insertions(+), 9 deletions(-) diff --git a/packages/cli/src/__tests__/pi-transcript.test.ts b/packages/cli/src/__tests__/pi-transcript.test.ts index c9092ad0d7..c44cb5c497 100644 --- a/packages/cli/src/__tests__/pi-transcript.test.ts +++ b/packages/cli/src/__tests__/pi-transcript.test.ts @@ -4286,6 +4286,40 @@ describe('Maka Pi TUI transcript', () => { assert.doesNotMatch(rendered, /\(no output\)/); }); + test('never renders a secret Bash command from the durable shell_run result', () => { + const state = createMakaPiTranscriptState(); + const secret = 'super-secret-token-value'; + const command = `# preserve the multiline result-side path\ncurl -H \"Authorization: Bearer ${secret}\" https://example.com`; + applyMakaSessionEventToTranscript( + state, + event({ + type: 'tool_start', + toolUseId: 'bash-durable-redaction', + toolName: 'Bash', + args: { command }, + }), + ); + applyMakaSessionEventToTranscript( + state, + event({ + type: 'tool_result', + toolUseId: 'bash-durable-redaction', + isError: false, + content: shellRun({ + cmd: command, + status: 'completed', + completedAt: 2_000, + exitCode: 0, + }), + }), + ); + assert.equal(toggleAllToolExpansion(state), true); + + const rendered = renderMakaPiTranscript(state, meta(), 100).map(stripAnsi).join('\n'); + assert.doesNotMatch(rendered, new RegExp(secret)); + assert.match(rendered, /redacted/i); + }); + test('keeps the no-output placeholder when the row cannot name the call', () => { const state = createMakaPiTranscriptState(); applyMakaSessionEventToTranscript( diff --git a/packages/cli/src/pi-transcript-tools.ts b/packages/cli/src/pi-transcript-tools.ts index 35442ebf99..bdf7dc46fc 100644 --- a/packages/cli/src/pi-transcript-tools.ts +++ b/packages/cli/src/pi-transcript-tools.ts @@ -19,6 +19,7 @@ import type { ToolOutputStream, ToolResultContent } from '@maka/core/events'; import { formatQuietJsonValue, formatToolInvocationLine } from '@maka/core/tool-quiet-preview'; +import { redactSecrets } from '@maka/core/redaction'; import { isActiveShellRunStatus, type PtyShellOutput, @@ -691,7 +692,7 @@ function renderShellRunResult( const inputShowsFullCommand = typeof command === 'string' && command.trim() !== '' && !command.includes('\n'); if (!inputShowsFullCommand) { - lines.push(...renderIndented(ansi.dim(`$ ${content.cmd}`), width, 2)); + lines.push(...renderIndented(ansi.dim(`$ ${redactSecrets(content.cmd)}`), width, 2)); } lines.push(...renderIndented(ansi.dim(`cwd: ${content.cwd}`), width, 2)); const settled = !isActiveShellRunStatus(content.status) && content.status !== 'completed'; @@ -768,7 +769,7 @@ function toolInputSummary(entry: MakaPiToolEntry): string { .split('\n') .map((line) => line.trim()) .find((line) => line !== '' && !line.startsWith('#')); - return `$ ${firstRealLine ?? command.split('\n')[0]!.trim()}`; + return `$ ${redactSecrets(firstRealLine ?? command.split('\n')[0]!.trim())}`; } break; } diff --git a/packages/core/src/__tests__/tool-quiet-preview.test.ts b/packages/core/src/__tests__/tool-quiet-preview.test.ts index f624b29e04..d8c7685d60 100644 --- a/packages/core/src/__tests__/tool-quiet-preview.test.ts +++ b/packages/core/src/__tests__/tool-quiet-preview.test.ts @@ -146,6 +146,33 @@ describe('projectToolArgsPreview', () => { assert.deepEqual(preview, { title: 'hello' }); }); + it('does not put generic input payloads on the live wire', () => { + assert.equal( + projectToolArgsPreview('third_party_tool', { + input: 'short private body', + inputPreview: { text: 'forged private body', bytes: 19, truncated: false }, + size: { cols: 80, rows: 24 }, + }), + undefined, + ); + }); + + it('names deep research starts from their bounded objective preview', () => { + const preview = projectToolArgsPreview('deep_research_start', { + objective: 'Inspect the runtime host boundary', + scope_level: 'standard', + artifact_content: 'must not reach the live wire', + }); + assert.deepEqual(preview, { + objective: 'Inspect the runtime host boundary', + scope_level: 'standard', + }); + assert.equal( + formatToolInvocationLine({ toolName: 'deep_research_start', args: preview }, 'en'), + 'Inspect the runtime host boundary (standard)', + ); + }); + it('bounds long values and whole-preview size', () => { const preview = projectToolArgsPreview('Bash', { command: 'x'.repeat(5000) }); const command = (preview as { command: string }).command; diff --git a/packages/core/src/tool-quiet-preview.ts b/packages/core/src/tool-quiet-preview.ts index d509234585..5e9fd502da 100644 --- a/packages/core/src/tool-quiet-preview.ts +++ b/packages/core/src/tool-quiet-preview.ts @@ -263,6 +263,14 @@ export function formatToolInvocationLine( return parts.join(' · '); } + if (name === 'deep_research_start') { + const objective = stringField(args, 'objective'); + if (objective) { + const scopeLevel = stringField(args, 'scope_level'); + return redactSecrets(scopeLevel ? `${objective} (${scopeLevel})` : objective); + } + } + // Session task ledger and other structured-args tools: the generic key:value // fallback renders their nested payloads as `tasks:` / `questions:` dumps, so // give the row the one fact a reader needs — the first subject / question, @@ -374,9 +382,13 @@ const ARGS_PREVIEW_SCALAR_KEYS = [ 'status', 'id', 'ref', - 'input', ] as const; +// This tool's objective is the compact row's durable headline. Keep it +// explicit rather than widening the generic wire allowlist with a broad key +// such as `input`: non-WriteStdin tools otherwise retain arbitrary payloads. +const DEEP_RESEARCH_START_PREVIEW_SCALAR_KEYS = ['objective', 'scope_level'] as const; + const ARGS_PREVIEW_NUMBER_KEYS = ['offset', 'limit'] as const; function boundPreviewString(value: string): string { @@ -459,9 +471,13 @@ export function projectToolArgsPreview( // Apply the canonical activity projection first so WriteStdin's inputPreview // shape (bounded, display-safe) is what the whitelist picks up. const projected = asRecord(projectToolActivityArgs(toolName, args)) ?? record; + const scalarKeys = + toolName === 'deep_research_start' + ? [...ARGS_PREVIEW_SCALAR_KEYS, ...DEEP_RESEARCH_START_PREVIEW_SCALAR_KEYS] + : ARGS_PREVIEW_SCALAR_KEYS; const picked = new Map(); - for (const key of ARGS_PREVIEW_SCALAR_KEYS) { + for (const key of scalarKeys) { if (isSensitiveKey(key)) continue; const value = previewStringField(projected, key); if (value !== undefined) picked.set(key, value); @@ -470,10 +486,15 @@ export function projectToolArgsPreview( const value = numberField(projected, key); if (value !== undefined) picked.set(key, value); } - const inputPreview = previewInputPreview(projected.inputPreview); - if (inputPreview) picked.set('inputPreview', inputPreview); - const size = previewSize(projected.size); - if (size) picked.set('size', size); + // Only WriteStdin owns these shapes. Other tools retain arbitrary args, so + // accepting a caller-supplied inputPreview here would reopen a generic free- + // text payload path around its canonical safe-text projection. + if (toolName === 'WriteStdin') { + const inputPreview = previewInputPreview(projected.inputPreview); + if (inputPreview) picked.set('inputPreview', inputPreview); + const size = previewSize(projected.size); + if (size) picked.set('size', size); + } const tasks = previewListSubjects(projected, 'tasks', 'subject'); if (tasks) { picked.set('tasks', tasks.items); @@ -490,7 +511,7 @@ export function projectToolArgsPreview( // Enforce the whole-preview budget by dropping lowest-priority fields; the // first picked (highest-priority) field always survives. const keysByPriority = [ - ...ARGS_PREVIEW_SCALAR_KEYS, + ...scalarKeys, ...ARGS_PREVIEW_NUMBER_KEYS, 'inputPreview', 'size', diff --git a/packages/runtime-host/src/__tests__/session-continuity-coordinator.test.ts b/packages/runtime-host/src/__tests__/session-continuity-coordinator.test.ts index 622d8a3344..e62fbc0b0a 100644 --- a/packages/runtime-host/src/__tests__/session-continuity-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/session-continuity-coordinator.test.ts @@ -1865,6 +1865,44 @@ test('live tool_start projects intent and a bounded args preview, never full arg coordinator.close(); }); +test('live tool_start never forwards a generic input payload as argsPreview', async () => { + const coordinator = new SessionContinuityCoordinator( + HOST_EPOCH, + async () => canonical(), + new SessionAdmissionGate(), + ); + const sink = new RecordingSink(); + const connection = coordinator.attachConnection('connection-tool-input', sink); + const opened = await open(coordinator, 'connection-tool-input'); + connection.activate(opened.subscriptionId); + + await coordinator.acceptRuntimeEvent(SESSION_ID, 'run-1', { + type: 'tool_start', + id: 'start-input', + turnId: 'turn-1', + ts: 1, + toolUseId: 'tool-input', + toolName: 'third_party_tool', + args: { + input: 'short private body', + inputPreview: { text: 'forged private body', bytes: 19, truncated: false }, + size: { cols: 80, rows: 24 }, + }, + }); + await delayImmediate(); + + const frame = sink.frames.find((candidate) => candidate.kind === 'subscription.session_event'); + assert.ok(frame && frame.kind === 'subscription.session_event'); + const event = frame.event; + assert.equal(event.type, 'tool_start'); + if (event.type !== 'tool_start') return; + assert.equal(event.argsPreview, undefined); + assert.doesNotMatch(JSON.stringify(event), /private body/); + + connection.abort(opened.subscriptionId); + coordinator.close(); +}); + test('tool_result clears retained tool_result_preview so a later open does not seed it', async () => { const coordinator = new SessionContinuityCoordinator( HOST_EPOCH, From 22ce4f1b01ad13bd66575f0fe6605b0388fd2b8b Mon Sep 17 00:00:00 2001 From: me2seeks Date: Sat, 29 Aug 2026 20:50:46 +0800 Subject: [PATCH 4/6] refactor: split Task Ledger timeline from live tool previews Generated-by: Codex --- .../cli/src/__tests__/pi-transcript.test.ts | 20 ------- .../src/__tests__/tool-quiet-preview.test.ts | 56 +++--------------- packages/core/src/tool-quiet-preview.ts | 59 ++++--------------- .../tool-activity-presentation.test.ts | 13 ---- 4 files changed, 20 insertions(+), 128 deletions(-) diff --git a/packages/cli/src/__tests__/pi-transcript.test.ts b/packages/cli/src/__tests__/pi-transcript.test.ts index c44cb5c497..d2f0c4b14c 100644 --- a/packages/cli/src/__tests__/pi-transcript.test.ts +++ b/packages/cli/src/__tests__/pi-transcript.test.ts @@ -4347,26 +4347,6 @@ describe('Maka Pi TUI transcript', () => { assert.match(rendered, /\(no output\)/); }); - test('names a task_create row by its first subject, not a JSON dump', () => { - const state = createMakaPiTranscriptState(); - applyMakaSessionEventToTranscript( - state, - event({ - type: 'tool_start', - toolUseId: 'task-1', - toolName: 'task_create', - displayName: 'Task Create', - args: undefined, - argsPreview: { tasks: [{ subject: '修复登录 bug' }], tasksTotal: 2 }, - }), - ); - - const rendered = renderMakaPiTranscript(state, meta(), 80).map(stripAnsi).join('\n'); - assert.match(rendered, /修复登录 bug/); - assert.doesNotMatch(rendered, /tasks:/); - assert.doesNotMatch(rendered, /\(no output\)/); - }); - test('orders and de-dupes tool_output_delta by seq and marks redacted chunks', () => { const state = createMakaPiTranscriptState(); applyMakaSessionEventToTranscript( diff --git a/packages/core/src/__tests__/tool-quiet-preview.test.ts b/packages/core/src/__tests__/tool-quiet-preview.test.ts index d8c7685d60..89a05bc7ab 100644 --- a/packages/core/src/__tests__/tool-quiet-preview.test.ts +++ b/packages/core/src/__tests__/tool-quiet-preview.test.ts @@ -47,42 +47,6 @@ describe('formatToolInvocationLine', () => { assert.equal(line, 'git status --porcelain'); }); - it('names a task_create call by its first subject with a count suffix', () => { - const line = formatToolInvocationLine( - { - toolName: 'task_create', - args: { tasks: [{ subject: '修复登录 bug' }, { subject: '写测试' }] }, - }, - 'zh', - ); - assert.equal(line, '修复登录 bug 等 2 项'); - const en = formatToolInvocationLine( - { - toolName: 'task_create', - args: { tasks: [{ subject: 'Fix login' }, { subject: 'Add tests' }, { subject: 'Ship' }] }, - }, - 'en', - ); - assert.equal(en, 'Fix login +2 more'); - }); - - it('names a task_update call by subject, then id and status', () => { - assert.equal( - formatToolInvocationLine( - { toolName: 'task_update', args: { id: 'T1', subject: '改名后的任务' } }, - 'zh', - ), - '改名后的任务', - ); - assert.equal( - formatToolInvocationLine( - { toolName: 'task_update', args: { id: 'T1', status: 'completed' } }, - 'en', - ), - 'T1 → completed', - ); - }); - it('names a GoalSet call by its condition', () => { const line = formatToolInvocationLine( { toolName: 'GoalSet', args: { condition: 'all tests in packages/runtime pass' } }, @@ -181,18 +145,14 @@ describe('projectToolArgsPreview', () => { assert.ok(JSON.stringify(preview).length <= 2048); }); - it('keeps task subjects so the formatter reports the original count', () => { - const preview = projectToolArgsPreview('task_create', { - tasks: [ - { subject: 'one', parent_id: 'p' }, - { subject: 'two' }, - { subject: 'three' }, - { subject: 'four' }, - { subject: 'five' }, - ], - }); - const line = formatToolInvocationLine({ toolName: 'task_create', args: preview }, 'zh'); - assert.equal(line, 'one 等 5 项'); + it('excludes Task Ledger tools until their durable semantic projection owns identity', () => { + assert.equal(projectToolArgsPreview('task_create', { tasks: [{ subject: 'one' }] }), undefined); + assert.equal( + projectToolArgsPreview('task_update', { id: 'T1', status: 'completed' }), + undefined, + ); + assert.equal(projectToolArgsPreview('task_list', { status: 'pending' }), undefined); + assert.equal(projectToolArgsPreview('task_get', { id: 'T1' }), undefined); }); it('preserves the WriteStdin projected inputPreview shape', () => { diff --git a/packages/core/src/tool-quiet-preview.ts b/packages/core/src/tool-quiet-preview.ts index 5e9fd502da..368ceb57d4 100644 --- a/packages/core/src/tool-quiet-preview.ts +++ b/packages/core/src/tool-quiet-preview.ts @@ -47,8 +47,6 @@ interface QuietPreviewStrings { written: string; /** Format a byte count suffix, e.g. `共 7 字节` / `7 bytes`. */ bytes: (n: number) => string; - /** Suffix for a task list previewed by its first entry, e.g. `等 3 项` / `+2 more`. */ - moreTasks: (total: number) => string; /** Suffix for a question list previewed by its first entry, e.g. `等 2 问` / `+1 more`. */ moreQuestions: (total: number) => string; } @@ -62,7 +60,6 @@ const STRINGS_BY_LOCALE: Record = { replacements: (n) => `${n} 处`, written: '已写入', bytes: (n) => `共 ${n} 字节`, - moreTasks: (total) => (total > 1 ? ` 等 ${total} 项` : ''), moreQuestions: (total) => (total > 1 ? ` 等 ${total} 问` : ''), }, en: { @@ -73,7 +70,6 @@ const STRINGS_BY_LOCALE: Record = { replacements: (n) => (n === 1 ? '1 replacement' : `${n} replacements`), written: 'written', bytes: (n) => `${n} bytes`, - moreTasks: (total) => (total > 1 ? ` +${total - 1} more` : ''), moreQuestions: (total) => (total > 1 ? ` +${total - 1} more` : ''), }, }; @@ -271,33 +267,6 @@ export function formatToolInvocationLine( } } - // Session task ledger and other structured-args tools: the generic key:value - // fallback renders their nested payloads as `tasks:` / `questions:` dumps, so - // give the row the one fact a reader needs — the first subject / question, - // the goal condition — with a count suffix when more entries exist. - if (name === 'task_create') { - const tasks = Array.isArray(args.tasks) ? args.tasks : undefined; - const firstSubject = tasks - ?.map((task) => stringField(asRecord(task), 'subject')) - .find((subject) => subject !== undefined); - if (firstSubject) { - // A wire args preview caps the list; `tasksTotal` keeps the real count. - const total = numberField(args, 'tasksTotal') ?? tasks!.length; - return redactSecrets(`${firstSubject}${s.moreTasks(total)}`); - } - } - - if (name === 'task_update') { - const subject = stringField(args, 'subject'); - if (subject) return redactSecrets(subject); - const id = stringField(args, 'id'); - if (id) { - const status = stringField(args, 'status'); - const shortId = id.length > 12 ? `${id.slice(0, 8)}…` : id; - return redactSecrets(status ? `${shortId} → ${status}` : shortId); - } - } - if (name === 'GoalSet') { const condition = stringField(args, 'condition'); if (condition) return redactSecrets(condition); @@ -356,9 +325,11 @@ export function formatToolInvocationLine( const ARGS_PREVIEW_STRING_MAX_CHARS = 240; /** Whole-preview cap; lowest-priority fields drop until the preview fits. */ const ARGS_PREVIEW_MAX_CHARS = 2048; -/** List fields (tasks / questions) keep only their leading entries. */ +/** Question lists keep only their leading entries. */ const ARGS_PREVIEW_LIST_MAX_ITEMS = 4; +const TASK_LEDGER_TOOL_NAMES = new Set(['task_create', 'task_update', 'task_list', 'task_get']); + /** * Whitelist of scalar args keys {@link formatToolInvocationLine} can read, in * display-priority order. Anything not listed here (file contents, option @@ -404,21 +375,18 @@ function previewStringField(record: Record, key: string): strin return typeof raw === 'string' && raw.trim().length > 0 ? boundPreviewString(raw) : undefined; } -function previewListSubjects( +function previewQuestions( record: Record, - listKey: 'tasks' | 'questions', - itemKey: 'subject' | 'question', ): { items: Record[]; total: number } | undefined { - if (isSensitiveKey(listKey) || isSensitiveKey(itemKey)) return undefined; - const list = record[listKey]; + const list = record.questions; if (!Array.isArray(list) || list.length === 0) return undefined; const items: Record[] = []; for (const entry of list.slice(0, ARGS_PREVIEW_LIST_MAX_ITEMS)) { - const text = previewStringField(asRecord(entry) ?? {}, itemKey); + const text = previewStringField(asRecord(entry) ?? {}, 'question'); if (text === undefined) continue; const redacted = redactSecrets(text); if (redacted.trim().length === 0) continue; - items.push({ [itemKey]: redacted }); + items.push({ question: redacted }); } return items.length > 0 ? { items, total: list.length } : undefined; } @@ -467,6 +435,10 @@ export function projectToolArgsPreview( ): Record | undefined { const record = asRecord(args); if (!record) return undefined; + // Task rows need committed IDs and the exact Task Ledger mutation snapshot; + // args alone cannot identify the user-facing task reliably. Keep them out of + // the generic live preview until the Host-owned semantic timeline (#4179). + if (TASK_LEDGER_TOOL_NAMES.has(toolName)) return undefined; // Apply the canonical activity projection first so WriteStdin's inputPreview // shape (bounded, display-safe) is what the whitelist picks up. @@ -495,12 +467,7 @@ export function projectToolArgsPreview( const size = previewSize(projected.size); if (size) picked.set('size', size); } - const tasks = previewListSubjects(projected, 'tasks', 'subject'); - if (tasks) { - picked.set('tasks', tasks.items); - if (tasks.total > tasks.items.length) picked.set('tasksTotal', tasks.total); - } - const questions = previewListSubjects(projected, 'questions', 'question'); + const questions = previewQuestions(projected); if (questions) { picked.set('questions', questions.items); if (questions.total > questions.items.length) picked.set('questionsTotal', questions.total); @@ -515,8 +482,6 @@ export function projectToolArgsPreview( ...ARGS_PREVIEW_NUMBER_KEYS, 'inputPreview', 'size', - 'tasks', - 'tasksTotal', 'questions', 'questionsTotal', ]; diff --git a/packages/ui/src/__tests__/tool-activity-presentation.test.ts b/packages/ui/src/__tests__/tool-activity-presentation.test.ts index c9fe906d7a..2127683cfb 100644 --- a/packages/ui/src/__tests__/tool-activity-presentation.test.ts +++ b/packages/ui/src/__tests__/tool-activity-presentation.test.ts @@ -512,19 +512,6 @@ describe('collapsed tool row target', () => { assert.match(markup, /npm test/); }); - it('names a task ledger call by its first subject', async () => { - const { ToolTrow } = await import('../tool-activity.js'); - const markup = renderToStaticMarkup(createElement(ToolTrow, { - items: [{ - ...baseItem, - toolName: 'task_create', - displayName: 'Task Create', - args: { tasks: [{ subject: '修复登录 bug' }, { subject: '补测试' }] }, - }], - })); - assert.match(markup, /修复登录 bug 等 2 项/); - }); - it('caps a long command so the collapsed row stays single-line', async () => { const { ToolTrow } = await import('../tool-activity.js'); const markup = renderToStaticMarkup(createElement(ToolTrow, { From a84ea26400d9949169ffaf7ff320fe8af4f66376 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Sat, 29 Aug 2026 20:55:46 +0800 Subject: [PATCH 5/6] fix: close live tool preview review gaps Generated-by: Codex --- .../cli/src/__tests__/pi-transcript.test.ts | 18 ++++++++++++++++++ packages/cli/src/pi-transcript-tools.ts | 4 ++++ packages/cli/src/pi-transcript.ts | 3 +++ .../src/__tests__/tool-quiet-preview.test.ts | 9 +++++++++ packages/core/src/tool-quiet-preview.ts | 12 ++++++++---- .../session-continuity-coordinator.test.ts | 2 ++ 6 files changed, 44 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/__tests__/pi-transcript.test.ts b/packages/cli/src/__tests__/pi-transcript.test.ts index d2f0c4b14c..e7eeeba183 100644 --- a/packages/cli/src/__tests__/pi-transcript.test.ts +++ b/packages/cli/src/__tests__/pi-transcript.test.ts @@ -4286,6 +4286,24 @@ describe('Maka Pi TUI transcript', () => { assert.doesNotMatch(rendered, /\(no output\)/); }); + test('prefers a redacted runtime intent for a live compact row', () => { + const state = createMakaPiTranscriptState(); + applyMakaSessionEventToTranscript( + state, + event({ + type: 'tool_start', + toolUseId: 'explore-intent', + toolName: 'ExploreAgent', + args: undefined, + intent: ' inspect render entry with sk-secret-value ', + }), + ); + + const rendered = renderMakaPiTranscript(state, meta(), 100).map(stripAnsi).join('\n'); + assert.match(rendered, /inspect render entry with \[redacted\]/); + assert.doesNotMatch(rendered, /sk-secret-value/); + }); + test('never renders a secret Bash command from the durable shell_run result', () => { const state = createMakaPiTranscriptState(); const secret = 'super-secret-token-value'; diff --git a/packages/cli/src/pi-transcript-tools.ts b/packages/cli/src/pi-transcript-tools.ts index bdf7dc46fc..458e4e4f18 100644 --- a/packages/cli/src/pi-transcript-tools.ts +++ b/packages/cli/src/pi-transcript-tools.ts @@ -756,6 +756,10 @@ function formatPtyControlOperation( } function toolInputSummary(entry: MakaPiToolEntry): string { + if (entry.intent) { + const safe = redactSecrets(entry.intent.replace(/\s+/g, ' ').trim()); + if (safe) return safe.length > 240 ? `${safe.slice(0, 240)}…` : safe; + } const input = entry.input; const obj = input !== null && typeof input === 'object' ? (input as Record) : undefined; diff --git a/packages/cli/src/pi-transcript.ts b/packages/cli/src/pi-transcript.ts index 350918f548..bbbec37545 100644 --- a/packages/cli/src/pi-transcript.ts +++ b/packages/cli/src/pi-transcript.ts @@ -173,6 +173,8 @@ export type MakaPiTranscriptEntry = toolUseId: string; toolName: string; title?: string; + /** Runtime-authored, bounded description of the live call's purpose. */ + intent?: string; input: unknown; /** Structured result returned by the tool. */ result?: ToolResultContent; @@ -821,6 +823,7 @@ export function applyMakaSessionEventToTranscript( toolUseId: event.toolUseId, toolName: event.toolName, ...(event.displayName ? { title: event.displayName } : {}), + ...(event.intent ? { intent: event.intent } : {}), // Live Runtime Host frames omit full args; the bounded wire preview // still lets the compact row name the call. The turn-end reconcile // replaces it with the durable full args. diff --git a/packages/core/src/__tests__/tool-quiet-preview.test.ts b/packages/core/src/__tests__/tool-quiet-preview.test.ts index 89a05bc7ab..a64ff365d9 100644 --- a/packages/core/src/__tests__/tool-quiet-preview.test.ts +++ b/packages/core/src/__tests__/tool-quiet-preview.test.ts @@ -155,6 +155,15 @@ describe('projectToolArgsPreview', () => { assert.equal(projectToolArgsPreview('task_get', { id: 'T1' }), undefined); }); + it('does not accept forged question payloads from third-party tools', () => { + assert.equal( + projectToolArgsPreview('third_party_tool', { + questions: [{ question: 'private body' }], + }), + undefined, + ); + }); + it('preserves the WriteStdin projected inputPreview shape', () => { const projected = projectToolActivityArgs('WriteStdin', { ref: 'maka://runtime/background-tasks/1', diff --git a/packages/core/src/tool-quiet-preview.ts b/packages/core/src/tool-quiet-preview.ts index 368ceb57d4..f3474dfb78 100644 --- a/packages/core/src/tool-quiet-preview.ts +++ b/packages/core/src/tool-quiet-preview.ts @@ -467,10 +467,14 @@ export function projectToolArgsPreview( const size = previewSize(projected.size); if (size) picked.set('size', size); } - const questions = previewQuestions(projected); - if (questions) { - picked.set('questions', questions.items); - if (questions.total > questions.items.length) picked.set('questionsTotal', questions.total); + // Only the built-in interaction tool owns this free-text shape. Arbitrary + // third-party tools may use the same field names for private payloads. + if (toolName === 'AskUserQuestion') { + const questions = previewQuestions(projected); + if (questions) { + picked.set('questions', questions.items); + if (questions.total > questions.items.length) picked.set('questionsTotal', questions.total); + } } if (picked.size === 0) return undefined; diff --git a/packages/runtime-host/src/__tests__/session-continuity-coordinator.test.ts b/packages/runtime-host/src/__tests__/session-continuity-coordinator.test.ts index e62fbc0b0a..ccb25b8115 100644 --- a/packages/runtime-host/src/__tests__/session-continuity-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/session-continuity-coordinator.test.ts @@ -1887,6 +1887,7 @@ test('live tool_start never forwards a generic input payload as argsPreview', as input: 'short private body', inputPreview: { text: 'forged private body', bytes: 19, truncated: false }, size: { cols: 80, rows: 24 }, + questions: [{ question: 'forged private question' }], }, }); await delayImmediate(); @@ -1898,6 +1899,7 @@ test('live tool_start never forwards a generic input payload as argsPreview', as if (event.type !== 'tool_start') return; assert.equal(event.argsPreview, undefined); assert.doesNotMatch(JSON.stringify(event), /private body/); + assert.doesNotMatch(JSON.stringify(event), /private question/); connection.abort(opened.subscriptionId); coordinator.close(); From 3accefbe63123b5e9411720a7e02d059bbe25b03 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Sat, 29 Aug 2026 21:01:48 +0800 Subject: [PATCH 6/6] fix(cli): use the shared display redactor Generated-by: Codex --- packages/cli/src/__tests__/pi-transcript.test.ts | 6 +++--- packages/cli/src/pi-transcript-tools.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/__tests__/pi-transcript.test.ts b/packages/cli/src/__tests__/pi-transcript.test.ts index e7eeeba183..a72fd0bc2d 100644 --- a/packages/cli/src/__tests__/pi-transcript.test.ts +++ b/packages/cli/src/__tests__/pi-transcript.test.ts @@ -4295,13 +4295,13 @@ describe('Maka Pi TUI transcript', () => { toolUseId: 'explore-intent', toolName: 'ExploreAgent', args: undefined, - intent: ' inspect render entry with sk-secret-value ', + intent: ' inspect render entry with sk-1234567890abcdef ', }), ); const rendered = renderMakaPiTranscript(state, meta(), 100).map(stripAnsi).join('\n'); - assert.match(rendered, /inspect render entry with \[redacted\]/); - assert.doesNotMatch(rendered, /sk-secret-value/); + assert.match(rendered, /inspect render entry with /); + assert.doesNotMatch(rendered, /sk-1234567890abcdef/); }); test('never renders a secret Bash command from the durable shell_run result', () => { diff --git a/packages/cli/src/pi-transcript-tools.ts b/packages/cli/src/pi-transcript-tools.ts index 458e4e4f18..6b9bdb5ad3 100644 --- a/packages/cli/src/pi-transcript-tools.ts +++ b/packages/cli/src/pi-transcript-tools.ts @@ -19,7 +19,7 @@ import type { ToolOutputStream, ToolResultContent } from '@maka/core/events'; import { formatQuietJsonValue, formatToolInvocationLine } from '@maka/core/tool-quiet-preview'; -import { redactSecrets } from '@maka/core/redaction'; +import { redactSecrets } from '@maka/core/display-redaction'; import { isActiveShellRunStatus, type PtyShellOutput,