diff --git a/packages/server/src/server/agent/providers/claude/agent.test.ts b/packages/server/src/server/agent/providers/claude/agent.test.ts index 79d116ef8..be2fec564 100644 --- a/packages/server/src/server/agent/providers/claude/agent.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.test.ts @@ -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); + }); }); diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index 0dda6fb3d..f84f66ea9 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -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, + 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, 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; } diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts index 15a554767..3841c8f3a 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts @@ -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: [], }, ], diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index 3afc2be0a..c043fef78 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -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; }