feat(composer): terse tool-call headline for the activity pill

- Rewrite the tool branch of getActivityHeadline (agentSessionTranscriptPresentation.ts)
  to use the descriptor's terse action tier — verb + compact object, e.g.
  "Read foo.ts" — instead of "label · full preview" ("Read file ·
  src/agents/ui/foo.ts"), so the composer pill's 200px cap shows the
  informative part of the action instead of ellipsizing a long preview
- Basename path-like objects only (file-read / file-edit / skill-read);
  shell commands stay whole since they legitimately contain "/", and file
  edits reuse the already-basenamed fileEditSummary.filename
- Fall back to the previous "label · preview" format when a descriptor
  carries no action (older descriptors)
- Export the existing private basename() from agentSessionFileEditDiff.ts
  rather than adding a third copy
- Document the terse format on deriveActivityPillLabel (composerLiveActivity.ts);
  no logic change — the pill picks it up via getActivityHeadline, whose only
  production consumer is the pill
- Tests: update presentation expectations ("Send Message · abc" → "Sent abc"),
  add cases for file-read/file-edit basenaming, shell commands kept whole,
  and the no-action fallback; add a pill-level terse-headline case to
  composerLiveActivity.test.mjs

Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
Taylor Ho
2026-08-05 16:41:59 -07:00
committed by Carl
parent 1bb36782af
commit 690cc8f753
5 changed files with 159 additions and 7 deletions
@@ -197,7 +197,8 @@ function getDiffStats(
return null;
}
function basename(path: string) {
/** Final path segment (cross-platform); returns the input when empty. */
export function basename(path: string) {
const parts = path.replace(/\\/g, "/").split("/");
return parts[parts.length - 1] || path;
}
@@ -40,8 +40,9 @@ function makeMessage(overrides = {}) {
};
}
test("getActivityHeadline formats tool titles and assistant text", () => {
assert.equal(getActivityHeadline(makeTool()), "Send Message · abc");
test("getActivityHeadline formats tool actions tersely and assistant text", () => {
// Tool items use the terse action tier (verb + object), not label · preview.
assert.equal(getActivityHeadline(makeTool()), "Sent abc");
assert.equal(
getActivityHeadline(makeMessage({ text: "First line\nSecond line" })),
"First line",
@@ -49,6 +50,72 @@ test("getActivityHeadline formats tool titles and assistant text", () => {
assert.equal(getActivityHeadline(makeMessage({ text: " " })), "Responding");
});
test("getActivityHeadline basenames path-like tool objects", () => {
assert.equal(
getActivityHeadline(
makeTool({
title: "Read file",
toolName: "dev__read_file",
buzzToolName: null,
args: { path: "src/agents/ui/foo.ts" },
descriptor: {
renderClass: "file-read",
label: "Read file",
preview: "src/agents/ui/foo.ts",
action: { verb: "Read", object: "src/agents/ui/foo.ts" },
source: "harness",
groupKey: "read_file",
},
}),
),
"Read foo.ts",
);
assert.equal(
getActivityHeadline(
makeTool({
title: "Edit file",
toolName: "dev__str_replace",
buzzToolName: null,
args: { path: "desktop/src/features/channels/ui/ChannelPane.tsx" },
descriptor: {
renderClass: "file-edit",
label: "Edited file",
preview: "desktop/src/features/channels/ui/ChannelPane.tsx",
action: {
verb: "Edited",
object: "desktop/src/features/channels/ui/ChannelPane.tsx",
},
source: "harness",
groupKey: "file-edit:str_replace",
},
}),
),
"Edited ChannelPane.tsx",
);
});
test("getActivityHeadline keeps shell commands whole (no basenaming)", () => {
assert.equal(
getActivityHeadline(
makeTool({
title: "Shell",
toolName: "dev__shell",
buzzToolName: null,
args: { command: "cat src/agents/ui/foo.ts" },
descriptor: {
renderClass: "shell",
label: "Ran command",
preview: "cat src/agents/ui/foo.ts",
action: { verb: "Ran", object: "cat src/agents/ui/foo.ts" },
source: "harness",
groupKey: "shell:command",
},
}),
),
"Ran cat src/agents/ui/foo.ts",
);
});
test("isMeaningfulItem ignores lifecycle noise and raw JSON-RPC metadata", () => {
assert.equal(
isMeaningfulItem({
@@ -111,7 +178,7 @@ test("isMeaningfulItem ignores lifecycle noise and raw JSON-RPC metadata", () =>
);
});
test("getActivityHeadline uses semantic tool descriptors", () => {
test("getActivityHeadline falls back to label · preview when a descriptor has no action", () => {
assert.equal(
getActivityHeadline(
makeTool({
@@ -225,7 +292,7 @@ test("two-tier headline: metadata excluded when spine work is present", () => {
"System prompt should not headline when spine work exists",
);
assert.ok(
headlines.some((h) => h?.includes("Send Message")),
headlines.some((h) => h?.includes("Sent")),
"Tool headline should appear",
);
});
@@ -1,5 +1,9 @@
import type { TranscriptItem } from "./agentSessionTypes";
import { buildCompactToolSummary } from "./agentSessionToolSummary";
import { basename } from "./agentSessionFileEditDiff";
import {
buildCompactToolSummary,
type CompactToolSummary,
} from "./agentSessionToolSummary";
/**
* Whether a polished activity row should render the opt-in timestamp footer.
@@ -26,10 +30,49 @@ const LIFECYCLE_NOISE = new Set([
"wire parse error",
]);
/** Human-readable headline for a single transcript item. */
/**
* Render classes whose action object is a filesystem path — compact to the
* basename for terse headlines. Shell commands are deliberately excluded:
* they legitimately contain "/" (e.g. `cat src/foo.ts`) and must not be
* reduced to their last path segment.
*/
const PATHLIKE_RENDER_CLASSES = new Set([
"file-read",
"file-edit",
"skill-read",
]);
function terseActionObject(summary: CompactToolSummary): string | null {
if (summary.fileEditSummary) {
return summary.fileEditSummary.filename;
}
const object = summary.action?.object ?? null;
if (
object &&
PATHLIKE_RENDER_CLASSES.has(summary.kind) &&
/[\\/]/.test(object)
) {
return basename(object);
}
return object;
}
/**
* Human-readable headline for a single transcript item.
*
* Tool items use the terse action tier — verb + compact object (file paths
* reduced to their basename), e.g. "Read foo.ts" rather than
* "Read file · src/agents/ui/foo.ts" — so the composer pill's 200px cap
* shows the informative part instead of ellipsizing a long preview.
*/
export function getActivityHeadline(item: TranscriptItem): string | null {
if (item.type === "tool") {
const summary = buildCompactToolSummary(item);
if (summary.action) {
return [summary.action.verb, terseActionObject(summary)]
.filter(Boolean)
.join(" ");
}
return [summary.label, summary.preview].filter(Boolean).join(" · ");
}
@@ -48,6 +48,32 @@ const lifecycleMeta = (acpSource, timestamp, channelId = CHANNEL) => ({
channelId,
});
/** Tool item with a classifier descriptor: headlines tersely (verb + object). */
const fileReadTool = (path, timestamp, channelId = CHANNEL) => ({
id: `tool-${path}-${timestamp}`,
type: "tool",
renderClass: "file-read",
title: "Read file",
toolName: "dev__read_file",
buzzToolName: null,
status: "completed",
args: { path },
result: "",
isError: false,
timestamp,
startedAt: timestamp,
completedAt: timestamp,
descriptor: {
renderClass: "file-read",
label: "Read file",
preview: path,
action: { verb: "Read", object: path },
source: "harness",
groupKey: "read_file",
},
channelId,
});
/** Streaming assistant message: headline is the (growing) first line. */
const assistantMessage = (id, text, timestamp, channelId = CHANNEL) => ({
id,
@@ -71,6 +97,17 @@ test("deriveActivityPillLabel returns the newest headline, no rotation", () => {
assert.deepEqual(headline, { id: editing.id, label: "Editing ChannelPane" });
});
test("deriveActivityPillLabel headlines tool items tersely (verb + basename)", () => {
const read = fileReadTool("src/agents/ui/foo.ts", secondsBeforeNow(2));
const headline = deriveActivityPillLabel({
channelId: CHANNEL,
transcript: [read],
});
// Terse action tier, not "Read file · src/agents/ui/foo.ts" — the pill's
// narrow cap must show the informative part of the action.
assert.deepEqual(headline, { id: read.id, label: "Read foo.ts" });
});
test("deriveActivityPillLabel keeps the last action headline regardless of age", () => {
// A quiet stretch (long tool call, thinking gap) must NOT decay the label
// to the generic placeholder — the last real action stays informative.
@@ -28,6 +28,10 @@ export type ActivityPillHeadline = {
/**
* Latest action headline for a working agent's composer pill.
*
* Tool items headline in the terse action format from getActivityHeadline —
* verb + compact object ("Read foo.ts"), not "label · full preview" — so the
* pill's narrow cap shows the informative part of the action.
*
* Channel-scoped, two-tier scan (spine items headline over metadata reads,
* mirroring the session transcript's noise gate), newest wins. The headline
* PERSISTS regardless of age — while a turn is in progress, the last real