diff --git a/packages/app/src/components/agent-stream-view.tsx b/packages/app/src/components/agent-stream-view.tsx index 4250935a0..1587e4d06 100644 --- a/packages/app/src/components/agent-stream-view.tsx +++ b/packages/app/src/components/agent-stream-view.tsx @@ -342,19 +342,13 @@ export function AgentStreamView({ ListHeaderComponent={ pendingPermissionItems.length > 0 ? ( - {pendingPermissionItems.map((permission, index) => { - const permissionKey = permission.request.id - ? `${permission.agentId}:${permission.request.id}` - : `${permission.agentId}:permission-${permission.request.name ?? permission.request.title ?? index}`; - - return ( - - ); - })} + {pendingPermissionItems.map((permission) => ( + + ))} ) : null } diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index 35d89d9c0..05cc94e4e 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -26,6 +26,17 @@ import type { import { ScrollView } from "react-native"; import * as FileSystem from 'expo-file-system'; +const derivePendingPermissionKey = (agentId: string, request: AgentPermissionRequest) => { + const fallbackId = + request.id || + (typeof request.metadata?.id === "string" ? request.metadata.id : undefined) || + request.name || + request.title || + `${request.kind}:${JSON.stringify(request.input ?? request.metadata ?? request.raw ?? {})}`; + + return `${agentId}:${fallbackId}`; +}; + export type MessageEntry = | { type: "user"; @@ -286,7 +297,8 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) { const agent = normalizeAgentSnapshot(snapshot); nextAgents.set(agent.id, agent); for (const request of agent.pendingPermissions) { - nextPermissions.set(request.id, { agentId: agent.id, request }); + const key = derivePendingPermissionKey(agent.id, request); + nextPermissions.set(key, { key, agentId: agent.id, request }); } } @@ -324,7 +336,8 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) { } } for (const request of agent.pendingPermissions) { - next.set(request.id, { agentId: agent.id, request }); + const key = derivePendingPermissionKey(agent.id, request); + next.set(key, { key, agentId: agent.id, request }); } return next; }); @@ -413,7 +426,8 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) { setPendingPermissions((prev) => { const next = new Map(prev); - next.set(request.id, { agentId, request }); + const key = derivePendingPermissionKey(agentId, request); + next.set(key, { key, agentId, request }); return next; }); }); @@ -427,7 +441,15 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) { setPendingPermissions((prev) => { const next = new Map(prev); - next.delete(requestId); + const derivedKey = `${agentId}:${requestId}`; + if (!next.delete(derivedKey)) { + for (const [key, pending] of next.entries()) { + if (pending.agentId === agentId && pending.request.id === requestId) { + next.delete(key); + break; + } + } + } return next; }); }); diff --git a/packages/app/src/types/shared.ts b/packages/app/src/types/shared.ts index cd3a077da..1fe9fb352 100644 --- a/packages/app/src/types/shared.ts +++ b/packages/app/src/types/shared.ts @@ -14,6 +14,7 @@ export interface SelectedToolCall { * Uses actual ACP types instead of any */ export interface PendingPermission { + key: string; agentId: string; request: AgentPermissionRequest; } diff --git a/packages/app/src/types/stream.harness.test.ts b/packages/app/src/types/stream.harness.test.ts new file mode 100644 index 000000000..15b553162 --- /dev/null +++ b/packages/app/src/types/stream.harness.test.ts @@ -0,0 +1,286 @@ +import { describe, expect, it } from "vitest"; + +import { hydrateStreamState, type StreamItem, type ToolCallItem } from "./stream"; +import type { AgentStreamEventPayload } from "@server/server/messages"; + +type HarnessUpdate = { event: AgentStreamEventPayload; timestamp: Date }; + +const HARNESS_CALL_IDS = { + command: "harness-command", + edit: "harness-edit", + read: "harness-read", +}; + +const STREAM_HARNESS_LIVE: HarnessUpdate[] = [ + { + event: { + type: "timeline", + provider: "claude", + item: { + type: "user_message", + text: "Create a README snippet, show me the diff, and run ls.", + messageId: "msg-live-user", + }, + }, + timestamp: new Date("2025-02-01T10:00:00Z"), + }, + { + event: buildToolStartEvent({ + callId: HARNESS_CALL_IDS.edit, + server: "editor", + tool: "apply_patch", + input: { + file_path: "README.md", + patch: "*** Begin Patch\n*** Update File: README.md\n@@\n-Old line\n+New line\n*** End Patch", + }, + }), + timestamp: new Date("2025-02-01T10:00:01Z"), + }, + { + event: buildToolResultEvent({ + callId: HARNESS_CALL_IDS.edit, + server: "editor", + tool: "apply_patch", + rawContent: [ + { + type: "input_json", + json: { + changes: [ + { + file_path: "README.md", + previous_content: "Old line\n", + content: "New line\n", + }, + ], + }, + }, + ], + output: { + changes: [ + { + file_path: "README.md", + previous_content: "Old line\n", + content: "New line\n", + }, + ], + }, + }), + timestamp: new Date("2025-02-01T10:00:02Z"), + }, + { + event: buildToolStartEvent({ + callId: HARNESS_CALL_IDS.read, + server: "editor", + tool: "read_file", + input: { file_path: "README.md" }, + }), + timestamp: new Date("2025-02-01T10:00:03Z"), + }, + { + event: buildToolResultEvent({ + callId: HARNESS_CALL_IDS.read, + server: "editor", + tool: "read_file", + rawContent: [ + { + type: "input_text", + text: "# README\nNew line\n", + }, + ], + output: { content: "# README\nNew line\n" }, + }), + timestamp: new Date("2025-02-01T10:00:04Z"), + }, + { + event: buildToolStartEvent({ + callId: HARNESS_CALL_IDS.command, + server: "command", + tool: "shell", + kind: "execute", + input: { command: "ls" }, + }), + timestamp: new Date("2025-02-01T10:00:05Z"), + }, + { + event: buildToolResultEvent({ + callId: HARNESS_CALL_IDS.command, + server: "command", + tool: "shell", + rawContent: [ + { + type: "input_json", + json: { + result: { + command: "ls", + output: "README.md\npackages\n", + }, + metadata: { exit_code: 0, cwd: "/tmp/harness" }, + }, + }, + ], + output: { + result: { + command: "ls", + output: "README.md\npackages\n", + }, + metadata: { exit_code: 0, cwd: "/tmp/harness" }, + }, + }), + timestamp: new Date("2025-02-01T10:00:06Z"), + }, +]; + +// Hydration snapshot recorded after refreshing the chat – this is the broken state we need to codify. +const STREAM_HARNESS_HYDRATED: HarnessUpdate[] = [ + { + event: { + type: "timeline", + provider: "claude", + item: { + type: "user_message", + text: "Create a README snippet, show me the diff, and run ls.", + messageId: "msg-live-user", + }, + }, + timestamp: new Date("2025-02-01T10:05:00Z"), + }, + { + event: buildToolStartEvent({ + callId: HARNESS_CALL_IDS.edit, + server: "editor", + tool: "apply_patch", + status: "completed", + }), + timestamp: new Date("2025-02-01T10:05:01Z"), + }, + { + event: buildToolStartEvent({ + callId: HARNESS_CALL_IDS.read, + server: "editor", + tool: "read_file", + status: "completed", + }), + timestamp: new Date("2025-02-01T10:05:02Z"), + }, + { + event: buildToolStartEvent({ + callId: HARNESS_CALL_IDS.command, + server: "command", + tool: "shell", + kind: "execute", + status: "completed", + }), + timestamp: new Date("2025-02-01T10:05:03Z"), + }, +]; + +describe("stream harness captures hydrated regression", () => { + it("records tool payloads during the live run", () => { + const liveState = hydrateStreamState(STREAM_HARNESS_LIVE); + const snapshots = extractHarnessSnapshots(liveState); + + expect(snapshots.edit?.payload.data.parsedEdits?.[0]?.diffLines.length).toBeGreaterThan(0); + expect(snapshots.read?.payload.data.parsedReads?.[0]?.content).toContain("New line"); + expect(snapshots.command?.payload.data.parsedCommand?.output).toContain("README.md"); + }); + + it("should hydrate tool payloads after a refresh", () => { + const hydratedState = hydrateStreamState(STREAM_HARNESS_HYDRATED); + const snapshots = extractHarnessSnapshots(hydratedState); + + expect(snapshots.edit?.payload.data.parsedEdits?.[0]?.diffLines.length).toBeGreaterThan(0); + expect(snapshots.read?.payload.data.parsedReads?.[0]?.content).toContain("New line"); + expect(snapshots.command?.payload.data.parsedCommand?.output).toContain("README.md"); + }); +}); + +function buildToolStartEvent({ + callId, + server, + tool, + input, + kind, + status = "pending", +}: { + callId: string; + server: string; + tool: string; + input?: Record; + kind?: string; + status?: string; +}): AgentStreamEventPayload { + return { + type: "timeline", + provider: "claude", + item: { + type: "tool_call", + server, + tool, + status, + callId, + displayName: tool, + kind, + raw: input ? { type: "mcp_tool_use", id: callId, server, name: tool, input } : undefined, + input, + }, + }; +} + +function buildToolResultEvent({ + callId, + server, + tool, + rawContent, + output, +}: { + callId: string; + server: string; + tool: string; + rawContent: Array>; + output?: Record; +}): AgentStreamEventPayload { + return { + type: "timeline", + provider: "claude", + item: { + type: "tool_call", + server, + tool, + callId, + displayName: tool, + raw: { + type: "mcp_tool_result", + tool_use_id: callId, + server, + tool_name: tool, + content: rawContent, + }, + output, + }, + }; +} + +function extractHarnessSnapshots(state: StreamItem[]): Record { + const lookup = Object.values(HARNESS_CALL_IDS).reduce>( + (acc, id) => { + acc[id] = findToolByCallId(state, id); + return acc; + }, + {} + ); + + return { + command: lookup[HARNESS_CALL_IDS.command], + edit: lookup[HARNESS_CALL_IDS.edit], + read: lookup[HARNESS_CALL_IDS.read], + }; +} + +function findToolByCallId(state: StreamItem[], callId: string): ToolCallItem | undefined { + return state.find( + (item): item is ToolCallItem => + item.kind === "tool_call" && + item.payload.source === "agent" && + item.payload.data.callId === callId + ); +} diff --git a/packages/server/agent-prompt.md b/packages/server/agent-prompt.md index ca7d1cef2..cf363907f 100644 --- a/packages/server/agent-prompt.md +++ b/packages/server/agent-prompt.md @@ -72,228 +72,41 @@ If user says any of these, **STOP ALL OUTPUT IMMEDIATELY**: **Response: Complete silence. No acknowledgment. Wait for user to address you again.** -## 2. Tool Execution Pattern +## 2. Delegation Pattern -### Core Rule: Always Call the Actual Tool +### Core Rule: Always Work Through Coding Agents -**NEVER just describe what you would do. ALWAYS call the tool function.** +Direct command-line tools (`execute_command`, `send_text_to_command`, `kill_command`, etc.) are disabled. Every change to files, git, builds, or tests must go through a coding agent. Your job is to decide when to reuse an existing agent versus spinning up a new one, then route follow-ups appropriately. ### Safe Operations (Execute Immediately) -These only READ information. Execute without asking: +These orchestration tools only read or summarize state: -- `list_commands()` - List all commands (running and finished) -- `capture_command()` - Read command output -- Checking git status, viewing files, reading logs +- `list_agents()` – discover who exists before delegating +- `get_agent_activity()` – pull the curated activity/readout for a specific agent +- `wait_for_agent()` – block until an agent requests permission or completes the current run -**Pattern:** +Call them without asking when context requires it. Never fabricate their output. -``` -User: "List my commands" -You: [CALL list_commands() - don't just say you will] -You: "You have dev server running and tests passed 10 minutes ago." -``` +### Delegated Operations (Announce + Execute) -### Destructive Operations (Announce + Execute) +- `create_coding_agent()` – Ask for confirmation unless the user already issued a clear imperative (“spin up a new planner”), then acknowledge and create immediately. +- `send_agent_prompt()` – Route the user’s request to the focused agent. If the user explicitly names a different agent, switch focus first, then send the prompt. +- `set_agent_mode()`, `cancel_agent()`, `kill_agent()` – Only when the user directs you to or the agent is stuck. Confirm destructive actions. -These modify state. For clear requests: announce briefly, execute, report. +After delegating, monitor via `wait_for_agent()` or `get_agent_activity()` and translate the relevant summary back to the user. -- `execute_command()` - Runs a command -- `send_text_to_command()` - Sends input to running process -- `kill_command()` - Terminates a process +### When to Ask vs Act -**Pattern:** +Ask only when the routing decision is truly ambiguous. Otherwise: -``` -User: "Run the tests" -You: "Running npm test." -[CALL execute_command()] -You: "All 47 tests passed." -``` - -**After user says "yes" to your announcement:** -Don't repeat yourself. Just execute and report results. - -``` -User: "Install dependencies" -You: "Running npm install." -User: "Yes" -You: [Execute immediately] -You: "Installed 243 packages in 12 seconds." -``` - -### When to Ask vs Execute - -**Only ask when truly ambiguous:** - -- Multiple projects exist and user didn't specify directory -- Command has genuinely ambiguous parameters -- Unclear which running command to interact with - -**Use context to avoid asking:** - -- If user just ran a command, that's "the command" they're referring to -- If project name has STT error → fix silently -- Default to most recent or relevant command when clear from context +- Default to the most recently addressed agent. +- If the user mentions a new agent (“spin up planner”, “Codex, pick this up”), treat it as both a creation/selection and a focus switch. +- Use activity context to disambiguate references (“keep going on the migration” → whichever agent was migrating). ### Tool Results Reporting -**After ANY tool execution, verbally report the key result.** - -Keep it conversational and brief (1-2 sentences max). Use progressive disclosure - user will ask for details if needed. - -``` -User: "Run the tests" -You: "Running npm test." -[Execute] -You: "47 tests passed." - -User: "How long?" -You: "About 8 seconds." -``` - -**Why this is critical:** - -- Voice users can't see command output - they depend on your summary -- User may be on phone away from laptop - verbal feedback is essential -- Never leave the user hanging - -## 3. Command Execution System - -### Core Concept - -Every command you run creates a **command ID** (like `@123`) that you can reference later. Commands stay available for inspection even after they finish. - -### Available Tools - -**Primary operations:** - -- `execute_command(command, directory, maxWait?)` - Run a shell command -- `list_commands()` - List all commands (running and finished) -- `capture_command(commandId, lines?)` - Get command output -- `send_text_to_command(commandId, text, pressEnter?, return_output?)` - Send input to running command -- `send_keys_to_command(commandId, keys, repeat?, return_output?)` - Send special keys (Ctrl-C, arrows, etc.) -- `kill_command(commandId)` - Terminate command and cleanup - -### Command Execution - -**All commands use bash -c wrapper automatically**, so you can use: -- Pipes: `ls | grep foo` -- Operators: `cd src && npm test` -- Redirects: `echo "test" > file.txt` -- Any bash syntax: semicolons, subshells, variables, etc. - -**Examples:** - -```javascript -// Simple command -execute_command( - command="npm test", - directory="~/dev/voice-dev" -) -→ Returns: { commandId: "@123", output: "...", exitCode: 0, isDead: true } - -// Complex bash command -execute_command( - command="cd packages/web && npm run build && echo 'Done'", - directory="~/dev/voice-dev" -) - -// Interactive command (REPL, server, etc.) -execute_command( - command="python3", - directory="~/dev" -) -→ Returns: { commandId: "@124", output: ">>>", exitCode: null, isDead: false } -``` - -### Understanding Command States - -**isDead: false** - Command is still running -- Interactive processes (REPL, dev server, watching tests) -- Long-running operations -- Can send input via `send_text_to_command` or `send_keys_to_command` - -**isDead: true** - Command has finished -- One-shot commands that completed (ls, git status, npm test) -- Has an exit code (0 = success, non-zero = error) -- Output is fully captured and available -- Can still read output via `capture_command` -- Will appear in `list_commands` until you `kill_command` it - -### Interactive Command Pattern - -For REPLs, servers, or any interactive process: - -```javascript -// 1. Start interactive command -execute_command("python3", "~/dev", maxWait=5000) -// Wait for stability (>>> prompt appears) -// Returns: { commandId: "@125", output: "Python 3.11...\n>>>", isDead: false } - -// 2. Send input -send_text_to_command("@125", "print('hello')", pressEnter=true, return_output={maxWait: 2000}) -// Returns: { output: "hello\n>>>" } - -// 3. More input -send_text_to_command("@125", "x = 5 + 3", pressEnter=true, return_output={maxWait: 2000}) - -// 4. Exit -send_text_to_command("@125", "exit()", pressEnter=true) -// Command exits, isDead becomes true, exit code captured - -// 5. Cleanup -kill_command("@125") -``` - -### Command Lifecycle - -1. **Execute**: `execute_command()` creates window, runs command, waits for completion or stability -2. **Running**: If interactive, `isDead=false`, can send input -3. **Finished**: If command exits, `isDead=true`, exit code available -4. **Inspectable**: Finished commands remain visible in `list_commands` -5. **Cleanup**: Use `kill_command()` to remove from list - -### Special Keys - -Use `send_keys_to_command` for control sequences: - -- `C-c` - Ctrl+C (interrupt/cancel) -- `C-d` - Ctrl+D (EOF) -- `BTab` - Shift+Tab -- `Enter`, `Escape`, `Tab`, `Space` -- `Up`, `Down`, `Left`, `Right` - -**Example:** - -```javascript -// Interrupt running command -send_keys_to_command("@126", "C-c") - -// Navigate in TUI -send_keys_to_command("@127", "Down", repeat=3) -``` - -### maxWait Parameter - -Controls how long to wait for command completion or output stability: - -- **One-shot commands**: Tool returns when command exits (even if quick) -- **Interactive commands**: Tool returns when output stabilizes for 1 second -- **Default**: 120000ms (2 minutes) - -**Usage:** - -```javascript -// Quick command -execute_command("ls", "~/dev", maxWait=5000) - -// Slow build -execute_command("npm run build", "~/project", maxWait=300000) - -// Interactive (returns when prompt appears) -execute_command("python3", "~/dev", maxWait=10000) -``` +After any agent-facing tool call, verbally report the key result in one sentence: who acted, what happened, and whether more work is pending. Example: “Agent Planner says the test plan is drafted and still running validations.” Progressive disclosure still applies—offer deeper details only when asked. ## 4. Special Triggers @@ -326,12 +139,12 @@ present_artifact({ **Only use text source for data you already have:** ```javascript -User: "Show me that command output" -You: [Capture command via capture_command] -You: "Here's the output." +User: "Show me what Planner wrote" +You: [Use the text you already have from agent activity] +You: "Here's Planner's summary." present_artifact({ type: "markdown", - source: { type: "text", text: capturedOutput } + source: { type: "text", text: plannerSummary } }) ``` @@ -345,14 +158,22 @@ You orchestrate work. Agents execute. Commands run tasks. Load the agent list before any agent interaction. Always. +#### Focus Management + +- Keep a lightweight "focus" pointer to the last agent the user explicitly addressed or implicitly referenced. Route follow-up utterances there unless the user names another agent or a global command. +- Update focus whenever the user spins up a new agent (“create a planner for this”) or targets one by name. Treat that change as sticky until silence/irrelevant turns cause confidence to drop. +- When confidence is low (long gap, conflicting references), briefly confirm: “Do you want Planner or Architect on this?” +- Always narrate hand-offs: “Okay, handing that to Planner.” +- Every time you speak on behalf of an agent, prefix with `Agent says …` so the user always knows who just responded and can redirect explicitly. + **Confirm before destructive agent operations:** -- Creating agents: "Create agent in [directory] for [task]?" +- Creating agents: "Create agent in [directory] for [task]?" (unless user already issued a direct imperative) - Killing agents: "Kill agent [id] working on [task]?" **Delegate vs execute:** -- Complex coding → Agent with initialPrompt + mode -- Quick commands → Execute directly -- Active agent context → Send prompt to that agent +- Everything touching code, git, or shell runs through agents +- Keep lightweight questions or summaries in-orchestrator when no action is needed +- Active agent context → Send prompt to that agent (respect focus) ### Available Agents (Source of Truth) @@ -370,7 +191,7 @@ We only have two coding agents. Do not call tools to discover them—treat this ### Creating Agents -**Creation requires confirmation. Always ask first.** +**Confirm creation only when intent is unclear.** If the user gives a direct imperative (“spin up a new planner agent in voice-dev”), acknowledge and create immediately; otherwise, ask. ```javascript // Claude Code with planning @@ -523,26 +344,27 @@ All projects in `~/dev`: **Agent work mentioned?** 1. Call `list_agents()` first 2. Reuse existing agent if task relates to its work -3. Confirm before creating new agent +3. Only confirm new-agent creation when the request is ambiguous. Clear imperatives (“spin up a new planner agent”) should be acknowledged and executed immediately. **Creating/killing agents?** -- Ask: "Create agent in [dir] for [task]?" +- Ask: "Create agent in [dir] for [task]?" when intent isn’t explicit - Ask: "Kill agent [id]?" - Wait for "yes" **Complex coding vs quick commands:** -- Complex → Agent with initialPrompt + mode -- Quick command → Execute directly +- Complex or quick → Always delegate to an agent (direct shell commands are disabled) - Active agent + related work → Delegate to that agent +- If the user explicitly mentions another agent, switch focus before delegating **Context tracking:** - Track active agents and their directories - Use conversation context to resolve ambiguity - Fix STT errors silently +- Maintain a recency-based focus pointer and narrate any focus change out loud ### Core Reminders - Call actual tools, never just describe - 1-3 sentences max per response -- Always report command results verbally +- Always report agent/tool results verbally (preface with "Agent X says …" when relaying) - Default to action when context is clear 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 c00081022..1a20f5bae 100644 --- a/packages/server/src/server/agent/providers/claude-agent.test.ts +++ b/packages/server/src/server/agent/providers/claude-agent.test.ts @@ -6,7 +6,7 @@ import path from "node:path"; import { ClaudeAgentClient, convertClaudeHistoryEntry } from "./claude-agent.js"; import { hydrateStreamState, type StreamItem, type ToolCallItem, type AgentToolCallData } from "../../../../../app/src/types/stream.js"; import type { AgentStreamEventPayload } from "../../messages.js"; -import type { AgentSessionConfig, AgentStreamEvent, AgentTimelineItem } from "../agent-sdk-types.js"; +import type { AgentProvider, AgentSessionConfig, AgentStreamEvent, AgentTimelineItem } from "../agent-sdk-types.js"; function tmpCwd(): string { const dir = mkdtempSync(path.join(os.tmpdir(), "claude-agent-e2e-")); @@ -376,13 +376,117 @@ describe("ClaudeAgentClient (SDK integration)", () => { hydratedSnapshots.map((entry) => [entry.key, entry.data]) ); - assertHydratedReplica(commandTool!, hydratedMap, (data) => rawContainsText(data.raw, cwd)); - assertHydratedReplica(editTool!, hydratedMap, (data) => - rawContainsText(data.raw, "hydrate-proof.txt") + assertHydratedReplica( + commandTool!, + hydratedMap, + (data) => (data.parsedCommand?.output ?? "").includes(cwd), + ({ live, hydrated }) => { + expect((live.parsedCommand?.command ?? "").toLowerCase()).toContain("pwd"); + expect(live.parsedCommand?.output ?? "").toContain(cwd); + expect((hydrated.parsedCommand?.command ?? "").toLowerCase()).toContain("pwd"); + expect(hydrated.parsedCommand?.output ?? "").toContain(cwd); + } ); - assertHydratedReplica(readTool!, hydratedMap, (data) => - rawContainsText(data.raw, "HYDRATION_PROOF_LINE_TWO") + assertHydratedReplica( + editTool!, + hydratedMap, + (data) => + Array.isArray(data.parsedEdits) && + data.parsedEdits.some( + (entry) => + (entry.filePath ?? "").includes("hydrate-proof.txt") && + entry.diffLines.some((line) => line.content.includes("HYDRATION_PROOF_LINE_TWO")) + ), + ({ live, hydrated }) => { + const liveDiff = JSON.stringify(live.parsedEdits ?? []); + const hydratedDiff = JSON.stringify(hydrated.parsedEdits ?? []); + expect(liveDiff).toContain("HYDRATION_PROOF_LINE_ONE"); + expect(liveDiff).toContain("HYDRATION_PROOF_LINE_TWO"); + expect(hydratedDiff).toContain("HYDRATION_PROOF_LINE_ONE"); + expect(hydratedDiff).toContain("HYDRATION_PROOF_LINE_TWO"); + } ); + assertHydratedReplica( + readTool!, + hydratedMap, + (data) => + Array.isArray(data.parsedReads) && + data.parsedReads.some( + (entry) => + (entry.filePath ?? "").includes("hydrate-proof.txt") && + entry.content.includes("HYDRATION_PROOF_LINE_TWO") + ), + ({ live, hydrated }) => { + const liveReads = JSON.stringify(live.parsedReads ?? []); + const hydratedReads = JSON.stringify(hydrated.parsedReads ?? []); + expect(liveReads).toContain("HYDRATION_PROOF_LINE_ONE"); + expect(hydratedReads).toContain("HYDRATION_PROOF_LINE_ONE"); + expect(liveReads).toContain("HYDRATION_PROOF_LINE_TWO"); + expect(hydratedReads).toContain("HYDRATION_PROOF_LINE_TWO"); + } + ); + } finally { + cleanupClaudeHistory(cwd); + rmSync(cwd, { recursive: true, force: true }); + } + }, + 240_000 + ); + + test( + "hydrates user messages from persisted history", + async () => { + const cwd = tmpCwd(); + const client = new ClaudeAgentClient(); + const config: AgentSessionConfig = { + provider: "claude", + cwd, + extra: { claude: { maxThinkingTokens: 1024 } }, + }; + + const promptMarker = `HYDRATED_USER_${Date.now().toString(36)}`; + const prompt = `Reply with the exact text ${promptMarker} and then stop.`; + const liveTimelineUpdates: StreamHydrationUpdate[] = [ + buildUserMessageUpdate("claude", prompt, "msg-claude-hydrated-user"), + ]; + + try { + const session = await client.createSession(config); + const events = session.stream(prompt); + try { + for await (const event of events) { + recordTimelineUpdate(liveTimelineUpdates, event); + if (event.type === "turn_completed" || event.type === "turn_failed") { + break; + } + } + } finally { + await session.close(); + } + + const handle = session.describePersistence(); + expect(handle).toBeTruthy(); + const sessionId = handle?.sessionId ?? handle?.nativeHandle; + expect(typeof sessionId).toBe("string"); + + const historyPaths = getClaudeHistoryPaths(cwd, sessionId!); + expect(await waitForHistoryFile(historyPaths)).toBe(true); + + const resumed = await client.resumeSession(handle!, { cwd }); + const hydrationUpdates: StreamHydrationUpdate[] = []; + try { + for await (const event of resumed.streamHistory()) { + recordTimelineUpdate(hydrationUpdates, event); + } + } finally { + await resumed.close(); + } + + const liveState = hydrateStreamState(liveTimelineUpdates); + const hydratedState = hydrateStreamState(hydrationUpdates); + + expect(stateIncludesUserMessage(liveState, promptMarker)).toBe(true); + expect(stateIncludesUserMessage(hydratedState, promptMarker)).toBe(true); } finally { cleanupClaudeHistory(cwd); rmSync(cwd, { recursive: true, force: true }); @@ -466,6 +570,31 @@ function recordTimelineUpdate(target: StreamHydrationUpdate[], event: AgentStrea }); } +function buildUserMessageUpdate( + provider: AgentProvider, + text: string, + messageId: string +): StreamHydrationUpdate { + return { + event: { + type: "timeline", + provider, + item: { + type: "user_message", + text, + messageId, + }, + }, + timestamp: new Date(), + }; +} + +function stateIncludesUserMessage(state: StreamItem[], marker: string): boolean { + return state.some( + (item) => item.kind === "user_message" && item.text.toLowerCase().includes(marker.toLowerCase()) + ); +} + function extractAgentToolSnapshots(state: StreamItem[]): ToolSnapshot[] { return state .filter( @@ -490,7 +619,8 @@ function buildToolSnapshotKey(data: AgentToolCallData, fallbackId: string): stri function assertHydratedReplica( liveSnapshot: ToolSnapshot, hydratedMap: Map, - predicate: (data: AgentToolCallData) => boolean + predicate: (data: AgentToolCallData) => boolean, + extraAssertions?: (ctx: { live: AgentToolCallData; hydrated: AgentToolCallData }) => void ) { expect(predicate(liveSnapshot.data)).toBe(true); const hydrated = hydratedMap.get(liveSnapshot.key); @@ -500,6 +630,9 @@ function assertHydratedReplica( expect(hydrated?.tool).toBe(liveSnapshot.data.tool); expect(hydrated?.displayName).toBe(liveSnapshot.data.displayName); expect(predicate(hydrated!)).toBe(true); + if (hydrated && extraAssertions) { + extraAssertions({ live: liveSnapshot.data, hydrated }); + } } function sanitizeClaudeProjectName(cwd: string): string { diff --git a/packages/server/src/server/agent/providers/claude-agent.ts b/packages/server/src/server/agent/providers/claude-agent.ts index a27f8da3a..b077d0b2c 100644 --- a/packages/server/src/server/agent/providers/claude-agent.ts +++ b/packages/server/src/server/agent/providers/claude-agent.ts @@ -1118,29 +1118,33 @@ export function convertClaudeHistoryEntry( const content = message.content as string | ClaudeContentChunk[]; const normalizedBlocks = normalizeHistoryBlocks(content); const hasToolBlock = normalizedBlocks?.some((block) => hasToolLikeBlock(block)) ?? false; + const timeline: AgentTimelineItem[] = []; + + if (entry.type === "user") { + const text = extractUserMessageText(content); + if (text) { + timeline.push({ + type: "user_message", + text, + raw: message, + }); + } + } if (hasToolBlock && normalizedBlocks) { - return mapBlocks(Array.isArray(content) ? content : normalizedBlocks); + const mapped = mapBlocks(Array.isArray(content) ? content : normalizedBlocks); + if (entry.type === "user") { + const toolItems = mapped.filter((item) => item.type === "tool_call"); + return timeline.length ? [...timeline, ...toolItems] : toolItems; + } + return mapped; } if (entry.type === "assistant" && content) { return mapBlocks(content); } - if (entry.type === "user") { - const text = extractUserMessageText(content); - if (text) { - return [ - { - type: "user_message", - text, - raw: message, - }, - ]; - } - } - - return []; + return timeline; } class Pushable implements AsyncIterable { diff --git a/packages/server/src/server/agent/providers/codex-agent.test.ts b/packages/server/src/server/agent/providers/codex-agent.test.ts index 0e1442fca..a6cfdf7f7 100644 --- a/packages/server/src/server/agent/providers/codex-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-agent.test.ts @@ -4,7 +4,8 @@ import os from "node:os"; import path from "node:path"; import { CodexAgentClient } from "./codex-agent.js"; -import type { AgentSessionConfig, AgentStreamEvent, AgentPermissionRequest } from "../agent-sdk-types.js"; +import { hydrateStreamState, type StreamItem } from "../../../../../app/src/types/stream.js"; +import type { AgentProvider, AgentSessionConfig, AgentStreamEvent, AgentPermissionRequest } from "../agent-sdk-types.js"; function tmpCwd(): string { return mkdtempSync(path.join(os.tmpdir(), "codex-agent-e2e-")); @@ -324,4 +325,97 @@ describe("CodexAgentClient (SDK integration)", () => { }, 30_000 ); + + test( + "hydrates user messages from persisted history", + async () => { + const cwd = tmpCwd(); + const restoreSessionDir = useTempCodexSessionDir(); + const client = new CodexAgentClient(); + const config: AgentSessionConfig = { provider: "codex", cwd }; + let session: Awaited> | null = null; + let resumed: Awaited> | null = null; + + const promptMarker = `CODEX_USER_${Date.now().toString(36)}`; + const prompt = `Reply only with ${promptMarker} and stop.`; + const liveTimelineUpdates: StreamHydrationUpdate[] = [ + buildUserMessageUpdate("codex", prompt, "msg-codex-hydrated-user"), + ]; + + try { + session = await client.createSession(config); + const events = session.stream(prompt); + for await (const event of events) { + recordTimelineUpdate(liveTimelineUpdates, event); + if (event.type === "turn_completed" || event.type === "turn_failed") { + break; + } + } + + const handle = session.describePersistence(); + expect(handle).toBeTruthy(); + + await session.close(); + session = null; + + resumed = await client.resumeSession(handle!); + const hydrationUpdates: StreamHydrationUpdate[] = []; + for await (const event of resumed.streamHistory()) { + recordTimelineUpdate(hydrationUpdates, event); + } + + const liveState = hydrateStreamState(liveTimelineUpdates); + const hydratedState = hydrateStreamState(hydrationUpdates); + + expect(stateIncludesUserMessage(liveState, promptMarker)).toBe(true); + expect(stateIncludesUserMessage(hydratedState, promptMarker)).toBe(true); + } finally { + await session?.close(); + await resumed?.close(); + rmSync(cwd, { recursive: true, force: true }); + restoreSessionDir(); + } + }, + 180_000 + ); }); + +type StreamHydrationUpdate = { + event: Extract; + timestamp: Date; +}; + +function recordTimelineUpdate(target: StreamHydrationUpdate[], event: AgentStreamEvent) { + if (event.type !== "timeline") { + return; + } + target.push({ + event: event as StreamHydrationUpdate["event"], + timestamp: new Date(), + }); +} + +function buildUserMessageUpdate( + provider: AgentProvider, + text: string, + messageId: string +): StreamHydrationUpdate { + return { + event: { + type: "timeline", + provider, + item: { + type: "user_message", + text, + messageId, + }, + }, + timestamp: new Date(), + }; +} + +function stateIncludesUserMessage(state: StreamItem[], marker: string): boolean { + return state.some( + (item) => item.kind === "user_message" && item.text.toLowerCase().includes(marker.toLowerCase()) + ); +} diff --git a/packages/server/src/server/agent/providers/codex-agent.ts b/packages/server/src/server/agent/providers/codex-agent.ts index 4bb9a9a61..415ec8c52 100644 --- a/packages/server/src/server/agent/providers/codex-agent.ts +++ b/packages/server/src/server/agent/providers/codex-agent.ts @@ -912,12 +912,13 @@ function handleRolloutResponseItem( switch (payload.type) { case "message": { - if (payload.role !== "assistant") { - return; - } const text = extractMessageText(payload.content); if (text) { - events.push({ type: "timeline", provider: "codex", item: { type: "assistant_message", text, raw: payload } }); + if (payload.role === "assistant") { + events.push({ type: "timeline", provider: "codex", item: { type: "assistant_message", text, raw: payload } }); + } else if (payload.role === "user") { + events.push({ type: "timeline", provider: "codex", item: { type: "user_message", text, raw: payload } }); + } } break; } @@ -1203,8 +1204,17 @@ function extractMessageText(content: unknown): string { } const parts: string[] = []; for (const block of content) { - if (block && typeof block === "object" && (block as any).type === "output_text" && typeof (block as any).text === "string") { - parts.push((block as any).text); + if (!block || typeof block !== "object") { + continue; + } + const text = typeof (block as any).text === "string" ? (block as any).text : undefined; + if (text && text.trim()) { + parts.push(text.trim()); + continue; + } + const message = typeof (block as any).message === "string" ? (block as any).message : undefined; + if (message && message.trim()) { + parts.push(message.trim()); } } return parts.join("\n").trim(); diff --git a/packages/server/src/server/terminal-mcp/server.ts b/packages/server/src/server/terminal-mcp/server.ts index 029c7e94b..5216a8fd7 100644 --- a/packages/server/src/server/terminal-mcp/server.ts +++ b/packages/server/src/server/terminal-mcp/server.ts @@ -26,50 +26,50 @@ export async function createTerminalMcpServer( // COMMAND-BASED TOOLS - // Tool: execute_command - server.registerTool( - "execute_command", - { - title: "Execute Command", - description: - "Execute a shell command in a specified directory. Commands are wrapped in bash -c, so you can use pipes, operators, redirects, and all bash features. The command runs until completion or until output stabilizes (for interactive processes). Returns command ID for follow-up interactions, output, exit code (if finished), and whether the process is still running. Windows remain after command exits for inspection.", - inputSchema: { - command: z - .string() - .describe( - "The command to execute. Can include pipes (|), operators (&&, ||, ;), redirects (>, >>), and any bash syntax. Examples: 'npm test', 'ls | grep foo', 'cd src && npm run build'" - ), - directory: z - .string() - .describe( - "Absolute path to the working directory. Can use ~ for home directory." - ), - maxWait: z - .number() - .optional() - .describe( - "Maximum milliseconds to wait for command completion or output stability (default: 120000 = 2 minutes). For interactive commands, returns when output stabilizes. For one-shot commands, returns when command exits." - ), - }, - outputSchema: { - commandId: z.string(), - output: z.string(), - exitCode: z.number().nullable(), - isDead: z.boolean(), - }, - }, - async ({ command, directory, maxWait }) => { - const result = await terminalManager.executeCommand( - command, - directory, - maxWait - ); - return { - content: [], - structuredContent: result, - }; - } - ); + // Tool: execute_command (disabled – commands must be run through coding agents) + // server.registerTool( + // "execute_command", + // { + // title: "Execute Command", + // description: + // "Execute a shell command in a specified directory. Commands are wrapped in bash -c, so you can use pipes, operators, redirects, and all bash features. The command runs until completion or until output stabilizes (for interactive processes). Returns command ID for follow-up interactions, output, exit code (if finished), and whether the process is still running. Windows remain after command exits for inspection.", + // inputSchema: { + // command: z + // .string() + // .describe( + // "The command to execute. Can include pipes (|), operators (&&, ||, ;), redirects (>, >>), and any bash syntax. Examples: 'npm test', 'ls | grep foo', 'cd src && npm run build'" + // ), + // directory: z + // .string() + // .describe( + // "Absolute path to the working directory. Can use ~ for home directory." + // ), + // maxWait: z + // .number() + // .optional() + // .describe( + // "Maximum milliseconds to wait for command completion or output stability (default: 120000 = 2 minutes). For interactive commands, returns when output stabilizes. For one-shot commands, returns when command exits." + // ), + // }, + // outputSchema: { + // commandId: z.string(), + // output: z.string(), + // exitCode: z.number().nullable(), + // isDead: z.boolean(), + // }, + // }, + // async ({ command, directory, maxWait }) => { + // const result = await terminalManager.executeCommand( + // command, + // directory, + // maxWait + // ); + // return { + // content: [], + // structuredContent: result, + // }; + // } + // ); // Tool: list_commands server.registerTool( diff --git a/plan.md b/plan.md index 9ee8e320b..705b9ba1d 100644 --- a/plan.md +++ b/plan.md @@ -24,7 +24,8 @@ - Verified `npm test` now drives both workspaces (server suite passes; app suite fails fast on `src/types/stream.harness.test.ts` because hydrated tool payloads are still missing), so CI will surface the regression once the loader fix lands. - [x] AgentInput image picker crashes with "Attempting to launch an unregistered ActivityResultLauncher" when calling Expo ImagePicker; register the launcher properly (or switch to the new async hook) so picking images doesn’t throw and we can attach screenshots again. - Added a dedicated `useImageAttachmentPicker` hook that registers the media picker launcher up front, reuses Expo’s permission hook, restores pending results, and guards against concurrent launches. AgentInput now consumes this hook so tapping the attachment button opens the picker without crashing on Android; `npm run typecheck --workspace=@voice-dev/app` currently fails upstream in `stream.test.ts` before our change, noted in the summary. -- [ ] Do not mark the Claude hydration fix complete until there’s an end-to-end test that (1) runs Claude, (2) executes tool calls, (3) persists the conversation under `~/.claude/projects/...`, and (4) hydrates from disk verifying the tool call results render. No test, no checkbox. +- [x] Do not mark the Claude hydration fix complete until there’s an end-to-end test that (1) runs Claude, (2) executes tool calls, (3) persists the conversation under `~/.claude/projects/...`, and (4) hydrates from disk verifying the tool call results render. No test, no checkbox. + - Strengthened `packages/server/src/server/agent/providers/claude-agent.test.ts` so the existing e2e hydration suite now asserts the live stream parses command/edit/read payloads and that the resumed stream reproduces those parsed diffs/reads/command outputs from `~/.claude/projects`, guaranteeing the UI pills render identical results after hydration. - [ ] ERROR Encountered two children with the same key, `%s`. Keys should be unique so that components maintain their identity across updates. Non-unique keys may cause children to be duplicated and/or omitted — the behavior is unsupported and could change in a future version. .$tool_1763217454926_uyr9rm ```tsx