Show built-in Claude slash command output

Claude CLI's built-in slash commands (/voice, /usage, "Unknown command:
...") run client-side with no model turn — they arrive as a result with
output_tokens: 0 and the user-visible text carried in result.result, but
no preceding assistant_message in the stream. Surface that text as an
assistant_message so the turn doesn't end silently.

Also unify Codex skill-command text blocks to always carry the
"$skill-name" prefix in the text payload, matching the no-args path
which already did.
This commit is contained in:
Mohamed Boudra
2026-05-15 13:34:07 +07:00
parent 37bb168204
commit 12e24f9620
4 changed files with 115 additions and 29 deletions

View File

@@ -1398,4 +1398,67 @@ describe("ClaudeAgentSession context window usage", () => {
contextWindowUsedTokens: 62,
});
});
test("result.result is surfaced as an assistant message when no model output was produced", async () => {
const session = await createSessionForTest();
const events = session.translateMessageToEvents({
type: "result",
subtype: "success",
result: "Unknown command: /foo-doesnt-exist",
is_error: false,
duration_ms: 2,
duration_api_ms: 0,
num_turns: 0,
stop_reason: null,
total_cost_usd: 0,
usage: {
input_tokens: 0,
cache_read_input_tokens: 0,
output_tokens: 0,
},
permission_denials: [],
uuid: "result-unknown-1",
session_id: "session-1",
} as unknown as SDKMessage);
expect(events).toContainEqual({
type: "timeline",
provider: "claude",
item: {
type: "assistant_message",
text: "Unknown command: /foo-doesnt-exist",
messageId: "result-unknown-1",
},
});
expect(events.some((event) => event.type === "turn_completed")).toBe(true);
});
test("result.result is not duplicated when the model produced output during the turn", async () => {
const session = await createSessionForTest();
const events = session.translateMessageToEvents({
type: "result",
subtype: "success",
result: "Here is the answer.",
is_error: false,
duration_ms: 100,
duration_api_ms: 80,
num_turns: 1,
stop_reason: null,
total_cost_usd: 0.01,
usage: {
input_tokens: 10,
cache_read_input_tokens: 0,
output_tokens: 42,
},
permission_denials: [],
uuid: "result-normal-1",
session_id: "session-1",
} as unknown as SDKMessage);
const timelineEvents = events.filter((event) => event.type === "timeline");
expect(timelineEvents).toEqual([]);
expect(events.some((event) => event.type === "turn_completed")).toBe(true);
});
});

View File

@@ -3097,29 +3097,7 @@ class ClaudeAgentSession implements AgentSession {
return;
}
if (message.subtype === "task_notification") {
// TODO: subagent timelines are best-effort. Subagent task_notifications
// arrive without parent_tool_use_id but with tool_use_id pointing at the
// parent's Task call, so they slip past the sidechain router and pollute
// the parent timeline. Drop them here; eventually thread them into the
// parent Task tool call's sub_agent log instead.
const taskUseId = message.tool_use_id;
const cachedTool = taskUseId ? this.toolUseCache.get(taskUseId) : undefined;
if (cachedTool?.name === "Task") {
return;
}
const taskNotificationItem = mapTaskNotificationSystemRecordToToolCall(message);
if (taskNotificationItem) {
events.push({
type: "timeline",
item: taskNotificationItem,
provider: "claude",
});
}
const usage = readUsageFromTaskNotification(message);
if (typeof usage === "number") {
this.lastContextWindowUsedTokens = usage;
events.push(this.createUsageUpdatedEvent(usage));
}
this.appendTaskNotificationEvents(message, events);
return;
}
if (message.subtype === "task_progress") {
@@ -3131,6 +3109,35 @@ class ClaudeAgentSession implements AgentSession {
}
}
private appendTaskNotificationEvents(
message: Extract<SDKMessage, { type: "system"; subtype: "task_notification" }>,
events: AgentStreamEvent[],
): void {
// TODO: subagent timelines are best-effort. Subagent task_notifications
// arrive without parent_tool_use_id but with tool_use_id pointing at the
// parent's Task call, so they slip past the sidechain router and pollute
// the parent timeline. Drop them here; eventually thread them into the
// parent Task tool call's sub_agent log instead.
const taskUseId = message.tool_use_id;
const cachedTool = taskUseId ? this.toolUseCache.get(taskUseId) : undefined;
if (cachedTool?.name === "Task") {
return;
}
const taskNotificationItem = mapTaskNotificationSystemRecordToToolCall(message);
if (taskNotificationItem) {
events.push({
type: "timeline",
item: taskNotificationItem,
provider: "claude",
});
}
const usage = readUsageFromTaskNotification(message);
if (typeof usage === "number") {
this.lastContextWindowUsedTokens = usage;
events.push(this.createUsageUpdatedEvent(usage));
}
}
private appendUserMessageEvents(
message: Extract<SDKMessage, { type: "user" }>,
events: AgentStreamEvent[],
@@ -3222,6 +3229,24 @@ class ClaudeAgentSession implements AgentSession {
): void {
const usage = this.convertUsage(message, message.modelUsage);
if (message.subtype === "success") {
// Built-in slash commands (e.g. /voice, /usage, "Unknown command: …")
// run client-side in the Claude CLI with no model turn — output_tokens
// is 0 and the user-visible text is carried in `result`. Surface it as
// an assistant message so the turn doesn't end silently. Normal turns
// have output_tokens > 0 and their text is already in the stream.
const resultText = typeof message.result === "string" ? message.result.trim() : "";
const outputTokens = message.usage?.output_tokens;
if (resultText.length > 0 && outputTokens === 0) {
events.push({
type: "timeline",
provider: "claude",
item: {
type: "assistant_message",
text: resultText,
messageId: message.uuid,
},
});
}
events.push({ type: "turn_completed", provider: "claude", usage });
return;
}

View File

@@ -874,7 +874,7 @@ describe("Codex app-server provider", () => {
},
{
type: "text",
text: "in a worktree, remember to use Claude for the UI",
text: "$paseo-implement in a worktree, remember to use Claude for the UI",
text_elements: [],
},
],

View File

@@ -3158,14 +3158,12 @@ class CodexAppServerAgentSession implements AgentSession {
}
const skill = this.cachedSkills.find((entry) => entry.name === commandName);
if (skill) {
const trimmedArgs = args?.trim() ?? "";
const text = trimmedArgs ? `$${skill.name} ${trimmedArgs}` : `$${skill.name}`;
const input: CodexPromptContentBlock[] = [
{ type: "skill", name: skill.name, path: skill.path },
{ type: "text", text },
];
if (args && args.trim().length > 0) {
input.push({ type: "text", text: args.trim() });
} else {
input.push({ type: "text", text: `$${skill.name}` });
}
return input;
}