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:
@@ -1398,4 +1398,67 @@ describe("ClaudeAgentSession context window usage", () => {
|
|||||||
contextWindowUsedTokens: 62,
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3097,29 +3097,7 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (message.subtype === "task_notification") {
|
if (message.subtype === "task_notification") {
|
||||||
// TODO: subagent timelines are best-effort. Subagent task_notifications
|
this.appendTaskNotificationEvents(message, events);
|
||||||
// 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));
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (message.subtype === "task_progress") {
|
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(
|
private appendUserMessageEvents(
|
||||||
message: Extract<SDKMessage, { type: "user" }>,
|
message: Extract<SDKMessage, { type: "user" }>,
|
||||||
events: AgentStreamEvent[],
|
events: AgentStreamEvent[],
|
||||||
@@ -3222,6 +3229,24 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
): void {
|
): void {
|
||||||
const usage = this.convertUsage(message, message.modelUsage);
|
const usage = this.convertUsage(message, message.modelUsage);
|
||||||
if (message.subtype === "success") {
|
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 });
|
events.push({ type: "turn_completed", provider: "claude", usage });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -874,7 +874,7 @@ describe("Codex app-server provider", () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "text",
|
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: [],
|
text_elements: [],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -3158,14 +3158,12 @@ class CodexAppServerAgentSession implements AgentSession {
|
|||||||
}
|
}
|
||||||
const skill = this.cachedSkills.find((entry) => entry.name === commandName);
|
const skill = this.cachedSkills.find((entry) => entry.name === commandName);
|
||||||
if (skill) {
|
if (skill) {
|
||||||
|
const trimmedArgs = args?.trim() ?? "";
|
||||||
|
const text = trimmedArgs ? `$${skill.name} ${trimmedArgs}` : `$${skill.name}`;
|
||||||
const input: CodexPromptContentBlock[] = [
|
const input: CodexPromptContentBlock[] = [
|
||||||
{ type: "skill", name: skill.name, path: skill.path },
|
{ 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;
|
return input;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user