mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
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,
|
||||
});
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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: [],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user