diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 89dd2438..95913327 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,6 +4,9 @@ ### Added +- **`get_tree` and `navigate_tree` RPC commands** - Session-tree inspection and navigation for external frontends (TUI `/tree` parity). `get_tree` returns a stable serializable DTO per node (id, parentId, type, role, 200-char preview, timestamp, label, children) plus the current `leafId` — no raw entry/message payloads. `navigate_tree` takes `targetId` plus the core navigation options (`summarize`, `customInstructions`, `replaceInstructions`, `label`) and returns `{ cancelled, editorText? }`; unknown target ids, mid-stream navigation, concurrent navigation/compaction, and summarize-without-model all surface as explicit `success: false` errors. `RpcClient` gains `getTree()` and `navigateTree()` (with a client-side 5-minute timeout for LLM-bound summarization). ([#313](https://github.com/aebrer/dreb/issues/313)) +- **Streaming/compaction guard in `AgentSession.navigateTree`** - Navigating the session tree mid-stream, during compaction, or concurrently with another navigation now throws loudly instead of silently replacing agent messages or interleaving tree mutations. ([#313](https://github.com/aebrer/dreb/issues/313)) + - **`list_all_sessions` RPC command** - List sessions across all projects for external frontends. If the underlying listing fails (I/O error reading the sessions store), the command responds `success: false` instead of a silently-empty list, so clients can tell "no sessions" apart from "listing failed." - **`delete_session` RPC command** - Delete a session file (trash-first) with active-session protection. Uses the same unrestricted, cross-project path addressing as `switch_session` (no sessions-directory containment guard — this is a trusted local channel, and a frontend exposing it owns its own authorization); the path is `resolve()`d before the active-session compare and deletion. Refuses the active session, non-`.jsonl` paths, and nonexistent files. Session deletion logic moved from the TUI into `SessionManager.deleteSession`. diff --git a/packages/coding-agent/docs/rpc.md b/packages/coding-agent/docs/rpc.md index fa93e418..621cf7b4 100644 --- a/packages/coding-agent/docs/rpc.md +++ b/packages/coding-agent/docs/rpc.md @@ -861,6 +861,124 @@ Response: Each session has the same fields as `list_sessions`. +### Session Tree + +Sessions are append-only trees: editing/retrying a message or navigating back creates a branch rather than discarding entries. These commands expose tree inspection and navigation — the scriptable equivalent of the TUI's `/tree` selector. + +#### get_tree + +Get the session tree as a serializable DTO, plus the current leaf position. + +```json +{"type": "get_tree"} +``` + +Response: +```json +{ + "type": "response", + "command": "get_tree", + "success": true, + "data": { + "roots": [ + { + "id": "a1b2c3d4", + "parentId": null, + "type": "message", + "role": "user", + "preview": "Help me refactor the auth module", + "timestamp": "2024-01-15T10:30:00.000Z", + "label": "start", + "children": [ + { + "id": "e5f6a7b8", + "parentId": "a1b2c3d4", + "type": "message", + "role": "assistant", + "preview": "Sure — let's look at the middleware first.", + "timestamp": "2024-01-15T10:30:05.000Z", + "children": [] + } + ] + } + ], + "leafId": "e5f6a7b8" + } +} +``` + +Each node has: +- `id`: Entry id (use with `navigate_tree`, `fork`) +- `parentId`: Parent entry id, or `null` for a root. Orphaned roots keep their original non-null `parentId` (referencing an entry not in the tree) — prefer `children` for hierarchy +- `type`: Entry type (`message`, `compaction`, `branch_summary`, `model_change`, `thinking_level_change`, `custom`, `custom_message`, `label`, `session_info`) +- `role`: Message role, present only for `type: "message"` entries (`user`, `assistant`, `toolResult`, `bashExecution`) +- `preview`: Short single-line content preview (whitespace-collapsed, max 200 chars). Non-text entries use bracketed forms like `[compaction: 50k tokens]`, `[branch summary]: ...`, `[model: claude-sonnet-4]`, `[bash]: npm test` +- `timestamp`: ISO timestamp of the entry +- `label`: Resolved user label, if any +- `children`: Child nodes, oldest first + +`leafId` is the id of the current leaf entry (`null` for an empty session) — the "you are here" marker for a tree UI. The DTO is stable and deliberately does **not** include full message payloads; use `get_messages` for content after navigation. + +A well-formed session has exactly one root; orphaned entries (broken parent chains) also surface as roots. + +#### navigate_tree + +Navigate the current session to a different tree node, optionally summarizing the abandoned branch. Unlike `fork` (which creates a new session file), navigation stays within the same session file. + +```json +{"type": "navigate_tree", "targetId": "a1b2c3d4"} +``` + +With branch summarization: +```json +{ + "type": "navigate_tree", + "targetId": "a1b2c3d4", + "summarize": true, + "customInstructions": "Focus on decisions made", + "replaceInstructions": false, + "label": "before-refactor" +} +``` + +Options (all optional, passed through verbatim to the core navigation — the TUI's interactive summarize prompt is not replicated): +- `summarize`: Generate an LLM summary of the branch being abandoned and attach it at the navigation target. Requires a model and API key. +- `customInstructions`: Extra instructions for the summarizer. +- `replaceInstructions`: If `true`, `customInstructions` replaces the default summarizer prompt instead of augmenting it. +- `label`: Label to attach to the branch summary entry (or to the target entry when not summarizing). + +Response: +```json +{ + "type": "response", + "command": "navigate_tree", + "success": true, + "data": {"cancelled": false, "editorText": "Help me refactor the auth module"} +} +``` + +- `cancelled`: `true` if an extension (`session_before_tree`) cancelled the navigation or summarization was aborted. +- `editorText`: Present when navigating to a `user` (or `custom_message`) entry — the text of that message. The leaf moves to the entry's *parent* so the message can be re-edited and resubmitted; a client should pre-fill its input with this text (this is what the TUI does). Navigating to any other entry type moves the leaf to the entry itself and returns no `editorText`. + +After a successful `navigate_tree`, `get_state` and `get_messages` reflect the post-navigation session state. + +Errors are explicit `success: false` responses: +```json +{ + "type": "response", + "command": "navigate_tree", + "success": false, + "error": "Entry zzz not found" +} +``` + +- Unknown `targetId`: `Entry not found` +- Agent currently streaming: `Cannot navigate the session tree while the agent is streaming. Abort or wait for idle first.` +- Branch summarization or compaction in progress: `Cannot navigate the session tree while summarization or compaction is in progress. Wait for idle first.` +- `summarize: true` with no model available: `No model available for summarization` + +Note: with `summarize: true` the command is LLM-bound and can take a while. `RpcClient.navigateTree` uses a 5-minute client timeout (overridable via its client-side `timeoutMs` option, which is not sent over the wire); raw-protocol clients should budget accordingly. There is no scriptable abort for an in-flight branch summarization over RPC. A client-side timeout does not stop the server: a timed-out `navigate_tree` may still complete server-side and move the leaf — after a timeout, resync with `get_tree`/`get_state` instead of assuming the navigation failed. + ### Version #### get_version diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 84b116d1..5976899f 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -3469,6 +3469,22 @@ export class AgentSession { targetId: string, options: { summarize?: boolean; customInstructions?: string; replaceInstructions?: boolean; label?: string } = {}, ): Promise<{ editorText?: string; cancelled: boolean; aborted?: boolean; summaryEntry?: BranchSummaryEntry }> { + // Navigating mid-stream would replace agent messages during an active run, + // corrupting the conversation state. Fail loudly instead. + if (this.isStreaming) { + throw new Error( + "Cannot navigate the session tree while the agent is streaming. Abort or wait for idle first.", + ); + } + + // A second concurrent navigation (or a compaction) would clobber the shared branch-summary + // abort controller and interleave tree mutations. Fail loudly instead. + if (this.isCompacting) { + throw new Error( + "Cannot navigate the session tree while summarization or compaction is in progress. Wait for idle first.", + ); + } + const oldLeafId = this.sessionManager.getLeafId(); // No-op if already at target @@ -3509,142 +3525,160 @@ export class AgentSession { label, }; - // Set up abort controller for summarization + // Set up abort controller for summarization. Cleared in the finally below on every + // exit path (throws, cancel returns, summary abort, success) so isCompacting cannot + // be left wedged true after an early exit. this._branchSummaryAbortController = new AbortController(); - let extensionSummary: { summary: string; details?: unknown } | undefined; - let fromExtension = false; + try { + let extensionSummary: { summary: string; details?: unknown } | undefined; + let fromExtension = false; - // Emit session_before_tree event - if (this._extensionRunner?.hasHandlers("session_before_tree")) { - const result = (await this._extensionRunner.emit({ - type: "session_before_tree", - preparation, - signal: this._branchSummaryAbortController.signal, - })) as SessionBeforeTreeResult | undefined; + // Emit session_before_tree event + if (this._extensionRunner?.hasHandlers("session_before_tree")) { + const result = (await this._extensionRunner.emit({ + type: "session_before_tree", + preparation, + signal: this._branchSummaryAbortController.signal, + })) as SessionBeforeTreeResult | undefined; - if (result?.cancel) { - return { cancelled: true }; - } + if (result?.cancel) { + return { cancelled: true }; + } - if (result?.summary && options.summarize) { - extensionSummary = result.summary; - fromExtension = true; - } + if (result?.summary && options.summarize) { + extensionSummary = result.summary; + fromExtension = true; + } - // Allow extensions to override instructions and label - if (result?.customInstructions !== undefined) { - customInstructions = result.customInstructions; - } - if (result?.replaceInstructions !== undefined) { - replaceInstructions = result.replaceInstructions; - } - if (result?.label !== undefined) { - label = result.label; + // Allow extensions to override instructions and label + if (result?.customInstructions !== undefined) { + customInstructions = result.customInstructions; + } + if (result?.replaceInstructions !== undefined) { + replaceInstructions = result.replaceInstructions; + } + if (result?.label !== undefined) { + label = result.label; + } } - } - // Run default summarizer if needed - let summaryText: string | undefined; - let summaryDetails: unknown; - if (options.summarize && entriesToSummarize.length > 0 && !extensionSummary) { - const model = this.model!; - const apiKey = await this._modelRegistry.getApiKey(model); - if (!apiKey) { - throw new Error(`No API key for ${model.provider}`); - } - const branchSummarySettings = this.settingsManager.getBranchSummarySettings(); - const result = await generateBranchSummary(entriesToSummarize, { - model, - apiKey, - signal: this._branchSummaryAbortController.signal, - customInstructions, - replaceInstructions, - reserveTokens: branchSummarySettings.reserveTokens, - }); - this._branchSummaryAbortController = undefined; - if (result.aborted) { - return { cancelled: true, aborted: true }; - } - if (result.error) { - throw new Error(result.error); + // Run default summarizer if needed + let summaryText: string | undefined; + let summaryDetails: unknown; + if (options.summarize && entriesToSummarize.length > 0 && !extensionSummary) { + const model = this.model!; + const apiKey = await this._modelRegistry.getApiKey(model); + if (!apiKey) { + throw new Error(`No API key for ${model.provider}`); + } + const branchSummarySettings = this.settingsManager.getBranchSummarySettings(); + const result = await generateBranchSummary(entriesToSummarize, { + model, + apiKey, + signal: this._branchSummaryAbortController.signal, + customInstructions, + replaceInstructions, + reserveTokens: branchSummarySettings.reserveTokens, + }); + if (result.aborted) { + return { cancelled: true, aborted: true }; + } + if (result.error) { + throw new Error(result.error); + } + summaryText = result.summary; + summaryDetails = { + readFiles: result.readFiles || [], + modifiedFiles: result.modifiedFiles || [], + }; + } else if (extensionSummary) { + summaryText = extensionSummary.summary; + summaryDetails = extensionSummary.details; + } + + // Re-check after extension/summarization awaits: prompt dispatch can start streaming + // while we yielded (same race class guarded in the prompt path), and the mutation + // block below must not replace messages during an active run. + if (this.isStreaming) { + throw new Error( + "Cannot navigate the session tree while the agent is streaming. Abort or wait for idle first.", + ); } - summaryText = result.summary; - summaryDetails = { - readFiles: result.readFiles || [], - modifiedFiles: result.modifiedFiles || [], - }; - } else if (extensionSummary) { - summaryText = extensionSummary.summary; - summaryDetails = extensionSummary.details; - } - - // Determine the new leaf position based on target type - let newLeafId: string | null; - let editorText: string | undefined; - - if (targetEntry.type === "message" && targetEntry.message.role === "user") { - // User message: leaf = parent (null if root), text goes to editor - newLeafId = targetEntry.parentId; - editorText = this._extractUserMessageText(targetEntry.message.content); - } else if (targetEntry.type === "custom_message") { - // Custom message: leaf = parent (null if root), text goes to editor - newLeafId = targetEntry.parentId; - editorText = - typeof targetEntry.content === "string" - ? targetEntry.content - : targetEntry.content - .filter((c): c is { type: "text"; text: string } => c.type === "text") - .map((c) => c.text) - .join(""); - } else { - // Non-user message: leaf = selected node - newLeafId = targetId; - } - // Switch leaf (with or without summary) - // Summary is attached at the navigation target position (newLeafId), not the old branch - let summaryEntry: BranchSummaryEntry | undefined; - if (summaryText) { - // Create summary at target position (can be null for root) - const summaryId = this.sessionManager.branchWithSummary(newLeafId, summaryText, summaryDetails, fromExtension); - summaryEntry = this.sessionManager.getEntry(summaryId) as BranchSummaryEntry; + // Determine the new leaf position based on target type + let newLeafId: string | null; + let editorText: string | undefined; + + if (targetEntry.type === "message" && targetEntry.message.role === "user") { + // User message: leaf = parent (null if root), text goes to editor + newLeafId = targetEntry.parentId; + editorText = this._extractUserMessageText(targetEntry.message.content); + } else if (targetEntry.type === "custom_message") { + // Custom message: leaf = parent (null if root), text goes to editor + newLeafId = targetEntry.parentId; + editorText = + typeof targetEntry.content === "string" + ? targetEntry.content + : targetEntry.content + .filter((c): c is { type: "text"; text: string } => c.type === "text") + .map((c) => c.text) + .join(""); + } else { + // Non-user message: leaf = selected node + newLeafId = targetId; + } + + // Switch leaf (with or without summary) + // Summary is attached at the navigation target position (newLeafId), not the old branch + let summaryEntry: BranchSummaryEntry | undefined; + if (summaryText) { + // Create summary at target position (can be null for root) + const summaryId = this.sessionManager.branchWithSummary( + newLeafId, + summaryText, + summaryDetails, + fromExtension, + ); + summaryEntry = this.sessionManager.getEntry(summaryId) as BranchSummaryEntry; - // Attach label to the summary entry - if (label) { - this.sessionManager.appendLabelChange(summaryId, label); + // Attach label to the summary entry + if (label) { + this.sessionManager.appendLabelChange(summaryId, label); + } + } else if (newLeafId === null) { + // No summary, navigating to root - reset leaf + this.sessionManager.resetLeaf(); + } else { + // No summary, navigating to non-root + this.sessionManager.branch(newLeafId); } - } else if (newLeafId === null) { - // No summary, navigating to root - reset leaf - this.sessionManager.resetLeaf(); - } else { - // No summary, navigating to non-root - this.sessionManager.branch(newLeafId); - } - // Attach label to target entry when not summarizing (no summary entry to label) - if (label && !summaryText) { - this.sessionManager.appendLabelChange(targetId, label); - } + // Attach label to target entry when not summarizing (no summary entry to label) + if (label && !summaryText) { + this.sessionManager.appendLabelChange(targetId, label); + } - // Update agent state - const sessionContext = this.sessionManager.buildSessionContext(); - this.agent.replaceMessages(sessionContext.messages); + // Update agent state + const sessionContext = this.sessionManager.buildSessionContext(); + this.agent.replaceMessages(sessionContext.messages); - // Emit session_tree event - if (this._extensionRunner) { - await this._extensionRunner.emit({ - type: "session_tree", - newLeafId: this.sessionManager.getLeafId(), - oldLeafId, - summaryEntry, - fromExtension: summaryText ? fromExtension : undefined, - }); - } + // Emit session_tree event + if (this._extensionRunner) { + await this._extensionRunner.emit({ + type: "session_tree", + newLeafId: this.sessionManager.getLeafId(), + oldLeafId, + summaryEntry, + fromExtension: summaryText ? fromExtension : undefined, + }); + } - // Emit to custom tools + // Emit to custom tools - this._branchSummaryAbortController = undefined; - return { editorText, cancelled: false, summaryEntry }; + return { editorText, cancelled: false, summaryEntry }; + } finally { + this._branchSummaryAbortController = undefined; + } } /** diff --git a/packages/coding-agent/src/modes/rpc/index.ts b/packages/coding-agent/src/modes/rpc/index.ts index 421cff58..22b40b6a 100644 --- a/packages/coding-agent/src/modes/rpc/index.ts +++ b/packages/coding-agent/src/modes/rpc/index.ts @@ -16,4 +16,5 @@ export type { RpcSessionInfo, RpcSessionState, RpcSlashCommand, + RpcTreeNode, } from "./rpc-types.js"; diff --git a/packages/coding-agent/src/modes/rpc/rpc-client.ts b/packages/coding-agent/src/modes/rpc/rpc-client.ts index aa1ab7e1..6e4404f1 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-client.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-client.ts @@ -11,7 +11,14 @@ import type { SessionStats } from "../../core/agent-session.js"; import type { BashResult } from "../../core/bash-executor.js"; import type { CompactionResult } from "../../core/compaction/index.js"; import { attachJsonlLineReader, serializeJsonLine } from "./jsonl.js"; -import type { RpcCommand, RpcResponse, RpcSessionInfo, RpcSessionState, RpcSlashCommand } from "./rpc-types.js"; +import type { + RpcCommand, + RpcResponse, + RpcSessionInfo, + RpcSessionState, + RpcSlashCommand, + RpcTreeNode, +} from "./rpc-types.js"; // ============================================================================ // Types @@ -479,6 +486,35 @@ export class RpcClient { return this.getData<{ messages: Array<{ entryId: string; text: string }> }>(response).messages; } + /** + * Get the session tree and current leaf. + */ + async getTree(): Promise<{ roots: RpcTreeNode[]; leafId: string | null }> { + const response = await this.send({ type: "get_tree" }); + return this.getData(response); + } + + /** + * Navigate to a session tree entry. + * @param targetId Entry ID to navigate to + * @param options Navigation options; timeoutMs is client-side only and is not sent over RPC + * @returns Object with `cancelled: true` if navigation was cancelled, and `editorText` when re-editing a message + */ + async navigateTree( + targetId: string, + options?: { + summarize?: boolean; + customInstructions?: string; + replaceInstructions?: boolean; + label?: string; + timeoutMs?: number; + }, + ): Promise<{ cancelled: boolean; editorText?: string }> { + const { timeoutMs = 300000, ...commandOptions } = options ?? {}; + const response = await this.send({ type: "navigate_tree", targetId, ...commandOptions }, timeoutMs); + return this.getData(response); + } + /** * Get text of last assistant message. */ @@ -630,7 +666,7 @@ export class RpcClient { } } - private async send(command: RpcCommandBody): Promise { + private async send(command: RpcCommandBody, timeoutMs = 30000): Promise { if (this._dead || !this.process?.stdin) { // Surface the real spawn cause (e.g. uid/gid EPERM) if one was captured, // rather than a generic message that hides why the process is gone. @@ -646,7 +682,7 @@ export class RpcClient { const timeout = setTimeout(() => { this.pendingRequests.delete(id); reject(new Error(`Timeout waiting for response to ${command.type}. Stderr: ${this.stderr}`)); - }, 30000); + }, timeoutMs); this.pendingRequests.set(id, { resolve: (response) => { diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index dae78367..3d576899 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -21,7 +21,7 @@ import type { } from "../../core/extensions/index.js"; import { parseModelPattern } from "../../core/model-resolver.js"; import { takeOverStdout, writeRawStdout } from "../../core/output-guard.js"; -import type { SessionInfo } from "../../core/session-manager.js"; +import type { SessionInfo, SessionTreeNode } from "../../core/session-manager.js"; import { SessionManager } from "../../core/session-manager.js"; import { type Theme, theme } from "../interactive/theme/theme.js"; import { attachJsonlLineReader, serializeJsonLine } from "./jsonl.js"; @@ -33,6 +33,7 @@ import type { RpcSessionInfo, RpcSessionState, RpcSlashCommand, + RpcTreeNode, } from "./rpc-types.js"; // Re-export types for consumers @@ -97,6 +98,147 @@ export async function listAllSessionsForRpc(): Promise { return sessions.map(toRpcSessionInfo); } +function normalizePreview(text: string): string { + return text.replace(/\s+/g, " ").trim().slice(0, 200); +} + +function extractTextContent(content: unknown): string { + if (typeof content === "string") return content; + if (!Array.isArray(content)) return ""; + + let text = ""; + for (const part of content) { + if ( + typeof part === "object" && + part !== null && + "type" in part && + part.type === "text" && + "text" in part && + typeof part.text === "string" + ) { + text += part.text; + } + } + return text; +} + +function getRpcEntryPreview(node: SessionTreeNode): string { + const entry = node.entry; + + switch (entry.type) { + case "message": { + const msg = entry.message as { + role: string; + content?: unknown; + stopReason?: string; + errorMessage?: string; + toolName?: string; + command?: string; + }; + const role = msg.role; + if (role === "user") { + return normalizePreview(extractTextContent(msg.content)); + } + if (role === "assistant") { + const textContent = normalizePreview(extractTextContent(msg.content)); + if (textContent) return textContent; + if (msg.stopReason === "aborted") return "(aborted)"; + if (msg.errorMessage) return normalizePreview(msg.errorMessage); + return "(no content)"; + } + if (role === "toolResult") { + return normalizePreview(`[${msg.toolName ?? "tool"}]`); + } + if (role === "bashExecution") { + return normalizePreview(`[bash]: ${msg.command ?? ""}`); + } + return normalizePreview(`[${role}]`); + } + case "custom_message": + return normalizePreview(`[${entry.customType}]: ${extractTextContent(entry.content)}`); + case "compaction": + return normalizePreview(`[compaction: ${Math.round(entry.tokensBefore / 1000)}k tokens]`); + case "branch_summary": + return normalizePreview(`[branch summary]: ${entry.summary}`); + case "model_change": + return normalizePreview(`[model: ${entry.modelId}]`); + case "thinking_level_change": + return normalizePreview(`[thinking: ${entry.thinkingLevel}]`); + case "custom": + return normalizePreview(`[custom: ${entry.customType}]`); + case "label": + return normalizePreview(`[label: ${entry.label ?? "(cleared)"}]`); + case "session_info": + return normalizePreview(`[title: ${entry.name || "empty"}]`); + default: { + // Compile-time exhaustiveness guard: a new SessionEntry type forces an update here. + const _exhaustive: never = entry; + // Runtime: unknown types from forward-compat/corrupt session files get a placeholder. + return normalizePreview(`[${(entry as { type: string }).type}]`); + } + } +} + +function toRpcTreeNode(node: SessionTreeNode): RpcTreeNode { + const role = node.entry.type === "message" ? String(node.entry.message.role) : undefined; + return { + id: node.entry.id, + parentId: node.entry.parentId, + type: node.entry.type, + ...(role !== undefined ? { role } : {}), + preview: getRpcEntryPreview(node), + timestamp: node.entry.timestamp, + ...(node.label !== undefined ? { label: node.label } : {}), + children: [], + }; +} + +/** + * Map core session tree nodes to stable RPC DTOs without leaking raw entries/messages. + * Uses an explicit stack so deep linear session trees do not overflow the JS call stack. + */ +export function toRpcTreeNodes(nodes: SessionTreeNode[]): RpcTreeNode[] { + const roots: RpcTreeNode[] = []; + const stack: Array<{ source: SessionTreeNode; targetSiblings: RpcTreeNode[] }> = []; + + for (let i = nodes.length - 1; i >= 0; i--) { + stack.push({ source: nodes[i]!, targetSiblings: roots }); + } + + while (stack.length > 0) { + const { source, targetSiblings } = stack.pop()!; + const dto = toRpcTreeNode(source); + targetSiblings.push(dto); + + for (let i = source.children.length - 1; i >= 0; i--) { + stack.push({ source: source.children[i]!, targetSiblings: dto.children }); + } + } + + return roots; +} + +/** Return the current session tree and active leaf as RPC DTOs. */ +export function getTreeForRpc(sessionManager: Pick): { + roots: RpcTreeNode[]; + leafId: string | null; +} { + return { roots: toRpcTreeNodes(sessionManager.getTree()), leafId: sessionManager.getLeafId() }; +} + +/** Navigate the active session tree, returning only the stable RPC result fields. */ +export async function navigateTreeForRpc( + session: Pick, + targetId: string, + options?: { summarize?: boolean; customInstructions?: string; replaceInstructions?: boolean; label?: string }, +): Promise<{ cancelled: boolean; editorText?: string }> { + const result = await session.navigateTree(targetId, options ?? {}); + return { + cancelled: result.cancelled, + ...(result.editorText !== undefined ? { editorText: result.editorText } : {}), + }; +} + /** * Run in RPC mode. * Listens for JSON commands on stdin, outputs events and responses on stdout. @@ -635,6 +777,24 @@ export async function runRpcMode(session: AgentSession, modelFallbackMessage?: s return success(id, "get_fork_messages", { messages }); } + case "get_tree": { + return success(id, "get_tree", getTreeForRpc(session.sessionManager)); + } + + case "navigate_tree": { + try { + const result = await navigateTreeForRpc(session, command.targetId, { + summarize: command.summarize, + customInstructions: command.customInstructions, + replaceInstructions: command.replaceInstructions, + label: command.label, + }); + return success(id, "navigate_tree", result); + } catch (e) { + return error(id, "navigate_tree", e instanceof Error ? e.message : String(e)); + } + } + case "get_last_assistant_text": { const text = session.getLastAssistantText(); return success(id, "get_last_assistant_text", { text }); diff --git a/packages/coding-agent/src/modes/rpc/rpc-types.ts b/packages/coding-agent/src/modes/rpc/rpc-types.ts index 92d012ee..d00cac2d 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-types.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-types.ts @@ -10,6 +10,7 @@ import type { ImageContent, Model } from "@dreb/ai"; import type { SessionStats } from "../../core/agent-session.js"; import type { BashResult } from "../../core/bash-executor.js"; import type { CompactionResult } from "../../core/compaction/index.js"; +import type { SessionEntry } from "../../core/session-manager.js"; import type { SourceInfo } from "../../core/source-info.js"; // ============================================================================ @@ -65,6 +66,16 @@ export type RpcCommand = | { id?: string; type: "delete_session"; sessionPath: string } | { id?: string; type: "fork"; entryId: string } | { id?: string; type: "get_fork_messages" } + | { id?: string; type: "get_tree" } + | { + id?: string; + type: "navigate_tree"; + targetId: string; + summarize?: boolean; + customInstructions?: string; + replaceInstructions?: boolean; + label?: string; + } | { id?: string; type: "get_last_assistant_text" } | { id?: string; type: "set_session_name"; name: string } @@ -227,6 +238,20 @@ export type RpcResponse = success: true; data: { messages: Array<{ entryId: string; text: string }> }; } + | { + id?: string; + type: "response"; + command: "get_tree"; + success: true; + data: { roots: RpcTreeNode[]; leafId: string | null }; + } + | { + id?: string; + type: "response"; + command: "navigate_tree"; + success: true; + data: { cancelled: boolean; editorText?: string }; + } | { id?: string; type: "response"; @@ -294,6 +319,30 @@ export interface RpcSessionInfo { firstMessage: string; } +/** Serializable session tree node returned by get_tree. Stable DTO — no raw entry payloads. */ +export interface RpcTreeNode { + /** Entry id */ + id: string; + /** + * Parent entry id, or null for a root. Orphaned roots (broken parent chains) keep their + * original non-null parentId, which references an entry not present in the tree — prefer + * the nested `children` structure over parentId when reconstructing hierarchy. + */ + parentId: string | null; + /** Session entry type */ + type: SessionEntry["type"]; + /** Message role, present only when type === "message" (user, assistant, toolResult, bashExecution, ...) */ + role?: string; + /** Short single-line content preview (whitespace-collapsed, max 200 chars) */ + preview: string; + /** ISO timestamp of the entry */ + timestamp: string; + /** Resolved label, if any */ + label?: string; + /** Child nodes, oldest first */ + children: RpcTreeNode[]; +} + // ============================================================================ // Extension UI Events (stdout) // ============================================================================ diff --git a/packages/coding-agent/test/core/dream.test.ts b/packages/coding-agent/test/core/dream.test.ts index d9306b1e..e14da836 100644 --- a/packages/coding-agent/test/core/dream.test.ts +++ b/packages/coding-agent/test/core/dream.test.ts @@ -385,7 +385,7 @@ describe("dream", () => { } finally { release(); } - }); + }, 20_000); it("creates lock file in specified directory", async () => { const release = await acquireDreamLock(tempDir); diff --git a/packages/coding-agent/test/performance-tracker.test.ts b/packages/coding-agent/test/performance-tracker.test.ts index 6615e3e1..f4496e12 100644 --- a/packages/coding-agent/test/performance-tracker.test.ts +++ b/packages/coding-agent/test/performance-tracker.test.ts @@ -378,7 +378,7 @@ describe("PerformanceTracker", () => { closeSync(fd); warnSpy.mockRestore(); } - }); + }, 20_000); // malformed lines ---------------------------------------------------------- diff --git a/packages/coding-agent/test/rpc-tree-commands.test.ts b/packages/coding-agent/test/rpc-tree-commands.test.ts new file mode 100644 index 00000000..c5cfb681 --- /dev/null +++ b/packages/coding-agent/test/rpc-tree-commands.test.ts @@ -0,0 +1,557 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { type SessionEntry, SessionManager } from "../src/core/session-manager.js"; +import { RpcClient } from "../src/modes/rpc/rpc-client.js"; +import { getTreeForRpc, navigateTreeForRpc, toRpcTreeNodes } from "../src/modes/rpc/rpc-mode.js"; +import type { RpcTreeNode } from "../src/modes/rpc/rpc-types.js"; +import { createHarnessWithExtensions } from "./test-harness.js"; +import { assistantMsg, buildTestTree, createTestSession, userMsg } from "./utilities.js"; + +const sessionContexts: Array<{ cleanup: () => void }> = []; + +type Deferred = { + promise: Promise; + resolve: (value: T) => void; + reject: (reason?: unknown) => void; +}; + +function createDeferred(): Deferred { + let resolve: (value: T) => void = () => {}; + let reject: (reason?: unknown) => void = () => {}; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +function trackSessionContext void }>(context: T): T { + sessionContexts.push(context); + return context; +} + +function findNode(nodes: RpcTreeNode[], id: string): RpcTreeNode | undefined { + const stack = [...nodes]; + while (stack.length > 0) { + const node = stack.pop()!; + if (node.id === id) return node; + stack.push(...node.children); + } + return undefined; +} + +function buildBranchedTree(sessionManager: SessionManager): Map { + return buildTestTree(sessionManager, { + messages: [ + { role: "user", text: "u1 text" }, + { role: "assistant", text: "a1 text" }, + { role: "user", text: "u2 text" }, + { role: "assistant", text: "a2 text" }, + { role: "user", text: "u3 text", branchFrom: "a1 text" }, + { role: "assistant", text: "a3 text" }, + ], + }); +} + +function previewFor(sessionManager: SessionManager, id: string): string { + return findNode(toRpcTreeNodes(sessionManager.getTree()), id)!.preview; +} + +afterEach(() => { + vi.restoreAllMocks(); + for (const context of sessionContexts.splice(0, sessionContexts.length).reverse()) { + context.cleanup(); + } +}); + +describe("toRpcTreeNodes and getTreeForRpc", () => { + it("maps roots, children, branch ordering, and id/parent wiring", () => { + const sessionManager = SessionManager.inMemory(); + const ids = buildBranchedTree(sessionManager); + + const roots = toRpcTreeNodes(sessionManager.getTree()); + + expect(roots).toHaveLength(1); + const u1 = roots[0]!; + expect(u1.id).toBe(ids.get("u1 text")); + expect(u1.parentId).toBeNull(); + expect(u1.children).toHaveLength(1); + + const a1 = u1.children[0]!; + expect(a1.id).toBe(ids.get("a1 text")); + expect(a1.parentId).toBe(u1.id); + expect(a1.children.map((child) => child.id)).toEqual([ids.get("u2 text"), ids.get("u3 text")]); + + const u2 = a1.children[0]!; + const u3 = a1.children[1]!; + expect(u2.parentId).toBe(a1.id); + expect(u2.children[0]!.id).toBe(ids.get("a2 text")); + expect(u2.children[0]!.parentId).toBe(u2.id); + expect(u3.parentId).toBe(a1.id); + expect(u3.children[0]!.id).toBe(ids.get("a3 text")); + expect(u3.children[0]!.parentId).toBe(u3.id); + }); + + it("sets role only for message entries", () => { + const sessionManager = SessionManager.inMemory(); + const userId = sessionManager.appendMessage(userMsg("hello")); + const thinkingId = sessionManager.appendThinkingLevelChange("high"); + + const roots = toRpcTreeNodes(sessionManager.getTree()); + const userNode = findNode(roots, userId)!; + const thinkingNode = findNode(roots, thinkingId)!; + + expect(userNode.role).toBe("user"); + expect(thinkingNode.role).toBeUndefined(); + expect(thinkingNode.preview).toBe("[thinking: high]"); + }); + + it("extracts user previews, collapses whitespace, and truncates to 200 characters", () => { + const simple = SessionManager.inMemory(); + const simpleId = simple.appendMessage(userMsg("hello world")); + expect(findNode(toRpcTreeNodes(simple.getTree()), simpleId)!.preview).toBe("hello world"); + + const whitespace = SessionManager.inMemory(); + const whitespaceId = whitespace.appendMessage(userMsg("hello\n\tthere friend")); + expect(findNode(toRpcTreeNodes(whitespace.getTree()), whitespaceId)!.preview).toBe("hello there friend"); + + const long = SessionManager.inMemory(); + const longText = "x".repeat(250); + const longId = long.appendMessage(userMsg(longText)); + const preview = findNode(toRpcTreeNodes(long.getTree()), longId)!.preview; + expect(preview).toHaveLength(200); + expect(preview).toBe("x".repeat(200)); + }); + + it("renders assistant previews for text, aborted, errors, and empty content", () => { + const sessionManager = SessionManager.inMemory(); + const textId = sessionManager.appendMessage(assistantMsg("assistant\ntext")); + const abortedId = sessionManager.appendMessage({ ...assistantMsg(""), content: [], stopReason: "aborted" }); + const errorId = sessionManager.appendMessage({ + ...assistantMsg(""), + content: [], + stopReason: "error", + errorMessage: " provider\nfailed ", + }); + const emptyId = sessionManager.appendMessage({ ...assistantMsg(""), content: [] }); + + expect(previewFor(sessionManager, textId)).toBe("assistant text"); + expect(previewFor(sessionManager, abortedId)).toBe("(aborted)"); + expect(previewFor(sessionManager, errorId)).toBe("provider failed"); + expect(previewFor(sessionManager, emptyId)).toBe("(no content)"); + }); + + it("renders tool, bash, and unknown-role message previews", () => { + const sessionManager = SessionManager.inMemory(); + const toolId = sessionManager.appendMessage({ + role: "toolResult", + toolCallId: "tc1", + toolName: "read", + content: [{ type: "text", text: "contents" }], + isError: false, + timestamp: Date.now(), + }); + const fallbackToolId = sessionManager.appendMessage({ + role: "toolResult", + toolCallId: "tc2", + content: [{ type: "text", text: "contents" }], + isError: false, + timestamp: Date.now(), + } as Parameters[0]); + const bashId = sessionManager.appendMessage({ + role: "bashExecution", + command: "npm test", + output: "ok", + exitCode: 0, + cancelled: false, + truncated: false, + timestamp: Date.now(), + }); + const unknownRoleId = sessionManager.appendMessage({ + role: "futureRole", + content: "future", + timestamp: Date.now(), + // Targeted cast: exercises the unknown-role fallback for forward-compat session data. + } as unknown as Parameters[0]); + + expect(previewFor(sessionManager, toolId)).toBe("[read]"); + expect(previewFor(sessionManager, fallbackToolId)).toBe("[tool]"); + expect(previewFor(sessionManager, bashId)).toBe("[bash]: npm test"); + expect(previewFor(sessionManager, unknownRoleId)).toBe("[futureRole]"); + }); + + it("renders custom, compaction, model, and session metadata previews", () => { + const sessionManager = SessionManager.inMemory(); + const rootId = sessionManager.appendMessage(userMsg("root")); + const customMessageId = sessionManager.appendCustomMessageEntry("note", "hello\nthere", true); + const compactionId = sessionManager.appendCompaction("summary", rootId, 50000); + const modelId = sessionManager.appendModelChange("anthropic", "claude-3-5-sonnet"); + const customId = sessionManager.appendCustomEntry("artifact", { id: 1 }); + const sessionInfoId = sessionManager.appendSessionInfo("Project Name"); + const emptyTitleId = sessionManager.appendSessionInfo(" "); + + expect(previewFor(sessionManager, customMessageId)).toBe("[note]: hello there"); + expect(previewFor(sessionManager, compactionId)).toBe("[compaction: 50k tokens]"); + expect(previewFor(sessionManager, modelId)).toBe("[model: claude-3-5-sonnet]"); + expect(previewFor(sessionManager, customId)).toBe("[custom: artifact]"); + expect(previewFor(sessionManager, sessionInfoId)).toBe("[title: Project Name]"); + // Empty titles (reachable via extension setSessionName("")) match the TUI's "[title: empty]" + expect(previewFor(sessionManager, emptyTitleId)).toBe("[title: empty]"); + }); + + it("renders a placeholder for unknown entry types from future or corrupt session files", () => { + // Targeted cast: session files are JSON-parsed, so runtime data can contain + // forward-compatible or corrupt entry types outside the current SessionEntry union. + const futureEntry = { + type: "future_type", + id: "future-1", + parentId: null, + timestamp: "2024-01-01T00:00:00.000Z", + } as unknown as SessionEntry; + + const roots = toRpcTreeNodes([{ entry: futureEntry, children: [] }]); + + expect(roots[0]!.preview).toBe("[future_type]"); + }); + + it("resolves labels and renders label entries", () => { + const sessionManager = SessionManager.inMemory(); + const targetId = sessionManager.appendMessage(userMsg("labeled message")); + const labelEntryId = sessionManager.appendLabelChange(targetId, "important"); + + const roots = toRpcTreeNodes(sessionManager.getTree()); + const targetNode = findNode(roots, targetId)!; + const labelNode = findNode(roots, labelEntryId)!; + + expect(targetNode.label).toBe("important"); + expect(labelNode.label).toBeUndefined(); + expect(labelNode.role).toBeUndefined(); + expect(labelNode.preview).toBe("[label: important]"); + }); + + it("renders non-message previews", () => { + const sessionManager = SessionManager.inMemory(); + const userId = sessionManager.appendMessage(userMsg("root")); + const thinkingId = sessionManager.appendThinkingLevelChange("medium"); + const summaryId = sessionManager.branchWithSummary(userId, "old branch summary"); + + const roots = toRpcTreeNodes(sessionManager.getTree()); + + expect(findNode(roots, thinkingId)!.preview).toBe("[thinking: medium]"); + expect(findNode(roots, summaryId)!.preview).toBe("[branch summary]: old branch summary"); + }); + + it("does not leak raw entry or message payloads", () => { + const sessionManager = SessionManager.inMemory(); + const id = sessionManager.appendMessage(userMsg("hello")); + + const node = findNode(toRpcTreeNodes(sessionManager.getTree()), id)!; + + expect(Object.keys(node)).toEqual(["id", "parentId", "type", "role", "preview", "timestamp", "children"]); + expect(JSON.stringify(node)).not.toContain('"entry":'); + expect(JSON.stringify(node)).not.toContain('"message":'); + }); + + it("returns the current leaf id and handles an empty tree", () => { + const sessionManager = SessionManager.inMemory(); + buildBranchedTree(sessionManager); + + expect(getTreeForRpc(sessionManager)).toEqual({ + roots: toRpcTreeNodes(sessionManager.getTree()), + leafId: sessionManager.getLeafId(), + }); + + const fresh = SessionManager.inMemory(); + expect(getTreeForRpc(fresh)).toEqual({ roots: [], leafId: null }); + }); + + it("maps deep linear trees without recursion", () => { + const sessionManager = SessionManager.inMemory(); + let lastId = ""; + for (let i = 0; i < 5000; i++) { + lastId = sessionManager.appendMessage(userMsg(`message ${i}`)); + } + + const roots = toRpcTreeNodes(sessionManager.getTree()); + + expect(roots).toHaveLength(1); + expect(findNode(roots, lastId)?.preview).toBe("message 4999"); + }); +}); + +describe("navigateTreeForRpc", () => { + it("forwards options verbatim", async () => { + const options = { + summarize: true, + customInstructions: "summarize this", + replaceInstructions: true, + label: "kept", + }; + const session = { + navigateTree: vi.fn().mockResolvedValue({ cancelled: false }), + }; + + await navigateTreeForRpc(session, "target-id", options); + + expect(session.navigateTree).toHaveBeenCalledWith("target-id", options); + }); + + it("returns only cancelled and editorText fields", async () => { + const session = { + navigateTree: vi.fn().mockResolvedValue({ + cancelled: false, + editorText: "edit me", + aborted: true, + summaryEntry: { id: "summary" }, + }), + }; + + await expect(navigateTreeForRpc(session, "target-id")).resolves.toEqual({ + cancelled: false, + editorText: "edit me", + }); + }); + + it("propagates thrown errors", async () => { + const session = { + navigateTree: vi.fn().mockRejectedValue(new Error("navigation failed")), + }; + + await expect(navigateTreeForRpc(session, "target-id")).rejects.toThrow("navigation failed"); + }); +}); + +describe("navigateTreeForRpc real-session integration", () => { + it("navigates to a root user message, returns editor text, and updates session state", async () => { + const { session, sessionManager } = trackSessionContext(createTestSession({ inMemory: true })); + const ids = buildBranchedTree(sessionManager); + + await expect(navigateTreeForRpc(session, ids.get("u1 text")!)).resolves.toEqual({ + cancelled: false, + editorText: "u1 text", + }); + + expect(sessionManager.getLeafId()).toBeNull(); + expect(session.messages).toEqual([]); + }); + + it("navigates to an assistant entry without editor text", async () => { + const { session, sessionManager } = trackSessionContext(createTestSession({ inMemory: true })); + const ids = buildBranchedTree(sessionManager); + const assistantId = ids.get("a1 text")!; + + await expect(navigateTreeForRpc(session, assistantId)).resolves.toEqual({ cancelled: false }); + + expect(sessionManager.getLeafId()).toBe(assistantId); + expect( + session.messages.map((message) => ({ + role: message.role, + content: "content" in message ? message.content : undefined, + })), + ).toEqual([ + { role: "user", content: "u1 text" }, + { role: "assistant", content: [{ type: "text", text: "a1 text" }] }, + ]); + }); + + it("rejects unknown target ids with the core error", async () => { + const { session } = trackSessionContext(createTestSession({ inMemory: true })); + + await expect(navigateTreeForRpc(session, "missing-entry")).rejects.toThrow("Entry missing-entry not found"); + }); + + it("rejects navigation while streaming", async () => { + const { session, sessionManager } = trackSessionContext(createTestSession({ inMemory: true })); + const targetId = sessionManager.appendMessage(userMsg("blocked")); + vi.spyOn(session, "isStreaming", "get").mockReturnValue(true); + + await expect(navigateTreeForRpc(session, targetId)).rejects.toThrow( + "Cannot navigate the session tree while the agent is streaming", + ); + }); + + it("rejects if streaming starts during async tree preparation without mutating session state", async () => { + const { session, sessionManager } = trackSessionContext( + await createHarnessWithExtensions({ + extensionFactories: [ + (dreb) => { + dreb.on("session_before_tree", async () => { + await Promise.resolve(); + }); + }, + ], + }), + ); + const ids = buildBranchedTree(sessionManager); + const targetId = ids.get("u1 text")!; + const originalLeafId = sessionManager.getLeafId(); + const originalEntries = sessionManager.getEntries(); + let isStreamingCalls = 0; + vi.spyOn(session, "isStreaming", "get").mockImplementation(() => isStreamingCalls++ > 0); + + await expect(navigateTreeForRpc(session, targetId)).rejects.toThrow( + "Cannot navigate the session tree while the agent is streaming. Abort or wait for idle first.", + ); + + expect(sessionManager.getLeafId()).toBe(originalLeafId); + expect(sessionManager.getEntries()).toEqual(originalEntries); + // The abort controller must be cleared on the throw path, or isCompacting wedges true + // (queuing all interactive input and misreporting get_state) until the next navigation. + expect(session.isCompacting).toBe(false); + }); + + it("rejects concurrent navigation while the first navigation is preparing the tree", async () => { + const firstParked = createDeferred(); + const releaseFirst = createDeferred(); + let beforeTreeCalls = 0; + const { session, sessionManager } = trackSessionContext( + await createHarnessWithExtensions({ + extensionFactories: [ + (dreb) => { + dreb.on("session_before_tree", async () => { + beforeTreeCalls++; + if (beforeTreeCalls === 1) { + firstParked.resolve(); + await releaseFirst.promise; + } + }); + }, + ], + }), + ); + const ids = buildBranchedTree(sessionManager); + const firstTargetId = ids.get("u1 text")!; + const secondTargetId = ids.get("u2 text")!; + + const firstNavigation = navigateTreeForRpc(session, firstTargetId); + await firstParked.promise; + + await expect(navigateTreeForRpc(session, secondTargetId)).rejects.toThrow( + "Cannot navigate the session tree while summarization or compaction is in progress. Wait for idle first.", + ); + + releaseFirst.resolve(); + await expect(firstNavigation).resolves.toEqual({ cancelled: false, editorText: "u1 text" }); + expect(beforeTreeCalls).toBe(1); + expect(session.isCompacting).toBe(false); + }); + + it("clears compaction state when an extension cancels tree navigation", async () => { + const { session, sessionManager } = trackSessionContext( + await createHarnessWithExtensions({ + extensionFactories: [ + (dreb) => { + dreb.on("session_before_tree", () => ({ cancel: true })); + }, + ], + }), + ); + const ids = buildBranchedTree(sessionManager); + const targetId = ids.get("u1 text")!; + const originalLeafId = sessionManager.getLeafId(); + const originalEntries = sessionManager.getEntries(); + + await expect(navigateTreeForRpc(session, targetId)).resolves.toEqual({ cancelled: true }); + + expect(sessionManager.getLeafId()).toBe(originalLeafId); + expect(sessionManager.getEntries()).toEqual(originalEntries); + expect(session.isCompacting).toBe(false); + }); + + it("rejects summarize when no model is available", async () => { + const { session, sessionManager } = trackSessionContext(createTestSession({ inMemory: true })); + const ids = buildBranchedTree(sessionManager); + vi.spyOn(session, "model", "get").mockReturnValue(undefined); + + await expect(navigateTreeForRpc(session, ids.get("u1 text")!, { summarize: true })).rejects.toThrow( + "No model available for summarization", + ); + }); +}); + +describe("RpcClient tree commands", () => { + it("getTree sends the get_tree command and unwraps tree data", async () => { + const client = new RpcClient() as any; + const data: { roots: RpcTreeNode[]; leafId: string | null } = { + roots: [ + { + id: "u1", + parentId: null, + type: "message", + role: "user", + preview: "hello", + timestamp: "2024-01-01T00:00:00.000Z", + children: [], + }, + ], + leafId: "u1", + }; + client.send = vi.fn().mockResolvedValue({ + type: "response", + command: "get_tree", + success: true, + data, + }); + + await expect(client.getTree()).resolves.toEqual(data); + expect(client.send).toHaveBeenCalledWith({ type: "get_tree" }); + expect(client.send.mock.calls[0]).toHaveLength(1); + }); + + it("navigateTree uses the five-minute default timeout when no options are provided", async () => { + const client = new RpcClient() as any; + const data = { cancelled: false }; + client.send = vi.fn().mockResolvedValue({ + type: "response", + command: "navigate_tree", + success: true, + data, + }); + + await expect(client.navigateTree("u1")).resolves.toEqual(data); + expect(client.send).toHaveBeenCalledWith({ type: "navigate_tree", targetId: "u1" }, 300000); + }); + + it("navigateTree sends options without timeoutMs and unwraps navigation data", async () => { + const client = new RpcClient() as any; + const data = { cancelled: false, editorText: "edit" }; + client.send = vi.fn().mockResolvedValue({ + type: "response", + command: "navigate_tree", + success: true, + data, + }); + + await expect( + client.navigateTree("u1", { + summarize: true, + customInstructions: "custom", + replaceInstructions: true, + label: "label", + timeoutMs: 123, + }), + ).resolves.toEqual(data); + expect(client.send).toHaveBeenCalledWith( + { + type: "navigate_tree", + targetId: "u1", + summarize: true, + customInstructions: "custom", + replaceInstructions: true, + label: "label", + }, + 123, + ); + }); + + it("navigateTree rejects with the RPC error message on failure", async () => { + const client = new RpcClient() as any; + client.send = vi.fn().mockResolvedValue({ + type: "response", + command: "navigate_tree", + success: false, + error: "Entry missing not found", + }); + + await expect(client.navigateTree("missing")).rejects.toThrow("Entry missing not found"); + }); +}); diff --git a/packages/coding-agent/test/rpc.test.ts b/packages/coding-agent/test/rpc.test.ts index 6eefce28..c176e0ea 100644 --- a/packages/coding-agent/test/rpc.test.ts +++ b/packages/coding-agent/test/rpc.test.ts @@ -320,6 +320,49 @@ describe.skipIf(!process.env.ANTHROPIC_API_KEY && !process.env.ANTHROPIC_OAUTH_T expect(sessionInfoEntries[0].name).toBe("my-test-session"); }, 60000); + test("should get tree and navigate it", async () => { + await client.start(); + + // Build a small tree: user + assistant entries + await client.promptAndWait("Reply with just the word 'hello'"); + + // get_tree round-trips through the real command dispatch + const tree = await client.getTree(); + expect(tree.roots.length).toBeGreaterThanOrEqual(1); + expect(tree.leafId).toBeTruthy(); + const findUserNode = (nodes: typeof tree.roots): (typeof tree.roots)[number] | undefined => { + const stack = [...nodes]; + while (stack.length > 0) { + const node = stack.pop()!; + if (node.type === "message" && node.role === "user") return node; + stack.push(...node.children); + } + return undefined; + }; + const userNode = findUserNode(tree.roots); + expect(userNode).toBeDefined(); + expect(userNode!.preview).toContain("hello"); + expect(userNode!.children.length).toBeGreaterThanOrEqual(1); + + // navigate_tree with a label exercises the handler's option field-mapping: + // the label must reach AgentSession.navigateTree and land on the target entry + const result = await client.navigateTree(userNode!.id, { label: "back-here" }); + expect(result.cancelled).toBe(false); + expect(result.editorText).toContain("hello"); + + // Post-navigation state: the leaf moved off the assistant reply and get_state + // reflects the emptied message list (user-message navigation rewinds to its parent) + const state = await client.getState(); + expect(state.messageCount).toBe(0); + const treeAfter = await client.getTree(); + expect(treeAfter.leafId).not.toBe(tree.leafId); + expect(findUserNode(treeAfter.roots)?.label).toBe("back-here"); + + // Unknown target ids surface the handler's clean error text, not the + // generic top-level "Command failed:" wrapper + await expect(client.navigateTree("missing-entry")).rejects.toThrow("Entry missing-entry not found"); + }, 90000); + test("should resolve model patterns via resolve_model command", async () => { await client.start();