refactor: standardize pending permission keying and user message hydration

This commit is contained in:
Mohamed Boudra
2025-11-15 17:36:15 +01:00
parent e26219c967
commit b45583a2b2
11 changed files with 678 additions and 311 deletions
@@ -342,19 +342,13 @@ export function AgentStreamView({
ListHeaderComponent={
pendingPermissionItems.length > 0 ? (
<View style={stylesheet.permissionsContainer}>
{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 (
<PermissionRequestCard
key={permissionKey}
permission={permission}
onResponse={onPermissionResponse}
/>
);
})}
{pendingPermissionItems.map((permission) => (
<PermissionRequestCard
key={permission.key}
permission={permission}
onResponse={onPermissionResponse}
/>
))}
</View>
) : null
}
+26 -4
View File
@@ -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;
});
});
+1
View File
@@ -14,6 +14,7 @@ export interface SelectedToolCall {
* Uses actual ACP types instead of any
*/
export interface PendingPermission {
key: string;
agentId: string;
request: AgentPermissionRequest;
}
@@ -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<string, unknown>;
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<Record<string, unknown>>;
output?: Record<string, unknown>;
}): 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<keyof typeof HARNESS_CALL_IDS, ToolCallItem | undefined> {
const lookup = Object.values(HARNESS_CALL_IDS).reduce<Record<string, ToolCallItem | undefined>>(
(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
);
}
+42 -220
View File
@@ -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 <name> 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
@@ -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<string, AgentToolCallData>,
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 {
@@ -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<T> implements AsyncIterable<T> {
@@ -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<ReturnType<typeof client.createSession>> | null = null;
let resumed: Awaited<ReturnType<typeof client.resumeSession>> | 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<AgentStreamEvent, { type: "timeline" }>;
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())
);
}
@@ -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();
@@ -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(