From f98f50ec0a20b319872c5f89ae4c89803ca57d0f Mon Sep 17 00:00:00 2001 From: Etan Joseph Heyman Date: Sun, 2 Aug 2026 18:11:59 +0300 Subject: [PATCH] fix: restore exact focus after pane creation --- src/agent-engine.ts | 15 + src/cmux-client.ts | 28 +- src/cmux-socket-client.ts | 39 +- src/cmux-transport-self-heal.ts | 1 + src/server.ts | 358 +++++++++++++++---- tests/cmux-client.test.ts | 69 ++++ tests/cmux-socket-client.test.ts | 48 +++ tests/cmux-transport-self-heal.test.ts | 26 ++ tests/server-agent-tools.test.ts | 471 ++++++++++++++++++++++++- tests/server.test.ts | 28 +- 10 files changed, 989 insertions(+), 94 deletions(-) diff --git a/src/agent-engine.ts b/src/agent-engine.ts index d4e171f7..3a32f312 100644 --- a/src/agent-engine.ts +++ b/src/agent-engine.ts @@ -158,6 +158,13 @@ export interface SpawnAgentParams { auto_archive_on_done?: boolean; max_cost_per_agent?: number; crash_recover?: boolean; + /** Internal lifecycle hook: runs immediately after cmux creates the surface, + * before launcher I/O or readiness polling can give the user time to move. + */ + on_surface_created?: (surface: { + surface: string; + workspace?: string; + }) => void | Promise; } export interface SpawnAgentResult { @@ -4591,6 +4598,14 @@ export class AgentEngine { await this.cleanupUnboundCreatedSurface(surface, "agent-placement"); throw error; } + try { + await spawnParams.on_surface_created?.({ + surface: surface.surface, + workspace: surface.actual_workspace ?? surface.workspace, + }); + } catch { + // Focus observation is advisory and must never discard a created handle. + } // 2. Write initial state (creating → booting) const now = new Date().toISOString(); diff --git a/src/cmux-client.ts b/src/cmux-client.ts index 5a47ef92..2e2408bb 100644 --- a/src/cmux-client.ts +++ b/src/cmux-client.ts @@ -378,10 +378,6 @@ export class CmuxClient { focus?: boolean; }, ): Promise { - if (opts?.focus === false) { - throw new Error("cmux does not support creating unfocused splits"); - } - if (opts?.type === "browser") { if (opts.surface || opts.pane) { throw new Error( @@ -392,6 +388,8 @@ export class CmuxClient { const args = ["new-pane", "--type", "browser", "--direction", direction]; if (opts.workspace) args.push("--workspace", opts.workspace); if (opts.url) args.push("--url", opts.url); + if (opts.focus !== undefined) + args.push("--focus", String(opts.focus)); const raw = await this.run(args); const parsed = this.parse>(raw, "new-pane"); @@ -404,6 +402,8 @@ export class CmuxClient { const args = ["new-split", direction]; if (opts?.workspace) args.push("--workspace", opts.workspace); + if (opts?.focus !== undefined) + args.push("--focus", String(opts.focus)); const anchorSurface = opts?.surface ?? (opts?.pane @@ -535,6 +535,20 @@ export class CmuxClient { await this.run(args); } + async focusSurface( + surface: string, + opts?: { workspace?: string }, + ): Promise { + await this.run([ + "rpc", + "surface.focus", + JSON.stringify({ + surface_id: surface, + ...(opts?.workspace ? { workspace_id: opts.workspace } : {}), + }), + ]); + } + async createWorkspace( title: string, ): Promise<{ workspace: string; title: string }> { @@ -715,8 +729,10 @@ export class CmuxClient { return this.parseStatusOutput(raw); } - async identify(surface: string): Promise { - const raw = await this.run(["identify", "--surface", surface]); + async identify(surface?: string): Promise { + const args = ["identify"]; + if (surface) args.push("--surface", surface); + const raw = await this.run(args); return this.parse(raw, "identify"); } diff --git a/src/cmux-socket-client.ts b/src/cmux-socket-client.ts index 58cf175a..9a43cce5 100644 --- a/src/cmux-socket-client.ts +++ b/src/cmux-socket-client.ts @@ -267,6 +267,23 @@ export class CmuxSocketClient { } } + async focusSurface( + surface: string, + opts?: { workspace?: string }, + ): Promise { + try { + await this.call("surface.focus", { + surface_id: surface, + ...(opts?.workspace ? { workspace_id: opts.workspace } : {}), + }); + } catch (e) { + if (this.isMethodNotFound(e) && this.cliFallback) { + return this.cliFallbackPinned()!.focusSurface(surface, opts); + } + throw e; + } + } + async createWorkspace( title: string, ): Promise<{ workspace: string; title: string }> { @@ -392,10 +409,18 @@ export class CmuxSocketClient { focus?: boolean; }, ): Promise { - if (opts?.focus === false) { - throw new CmuxSocketError( - "cmux does not support creating unfocused splits", - ); + // The installed CLI documents --focus, but the v2 surface.split focus + // parameter is not part of cmuxlayer's verified socket contract. Route an + // explicit preference through the pinned CLI instead of risking a silently + // ignored socket field. + if (opts?.focus !== undefined) { + if (!this.cliFallback) { + throw new CmuxSocketError( + "newSplit focus requires the CLI fallback", + "unsupported_focus_option", + ); + } + return this.cliFallbackPinned()!.newSplit(direction, opts); } if (opts?.type === "browser") { @@ -742,7 +767,7 @@ export class CmuxSocketClient { } } - async identify(surface: string): Promise<{ + async identify(surface?: string): Promise<{ caller?: { workspace_ref?: string; surface_ref?: string; @@ -754,7 +779,9 @@ export class CmuxSocketClient { pane_ref?: string; }; }> { - return this.call("system.identify", { surface_id: surface }); + return this.call("system.identify", { + ...(surface ? { surface_id: surface } : {}), + }); } async browser(args: string[]): Promise { diff --git a/src/cmux-transport-self-heal.ts b/src/cmux-transport-self-heal.ts index 15ce2838..9a297097 100644 --- a/src/cmux-transport-self-heal.ts +++ b/src/cmux-transport-self-heal.ts @@ -90,6 +90,7 @@ const FORWARDED_ASYNC_METHODS = [ "pasteText", "sendKey", "selectWorkspace", + "focusSurface", "createWorkspace", "deleteWorkspace", "readScreen", diff --git a/src/server.ts b/src/server.ts index e3eec818..390df380 100644 --- a/src/server.ts +++ b/src/server.ts @@ -4628,6 +4628,33 @@ export function createServer(opts?: CreateServerOptions): McpServer { } }; + type FocusTarget = { + workspace: string; + surface?: string; + }; + + type FocusRestoreLease = { + prior: FocusTarget; + expected: FocusTarget; + }; + + /** Currently-focused workspace and surface, with workspace-only fallback. */ + const currentFocusTarget = async (): Promise => { + try { + const focused = (await client.identify()).focused; + if (focused?.workspace_ref) { + return { + workspace: focused.workspace_ref, + ...(focused.surface_ref ? { surface: focused.surface_ref } : {}), + }; + } + } catch { + // Older/degraded transports may not expose global focus via identify. + } + const workspace = await currentFocusedWorkspace(); + return workspace ? { workspace } : null; + }; + const focusedWorkspaceFallbackWarning = "No explicit workspace, caller workspace, or repo workspace could be resolved; falling back to the currently focused workspace."; @@ -4665,41 +4692,101 @@ export function createServer(opts?: CreateServerOptions): McpServer { return { workspace: undefined, warnings: [] }; }; - /** - * Focus the target workspace before a split when it differs from the prior - * focus. Returns the prior focus ref IF a jump was performed (so the caller - * passes it to restoreFocusAfterRender), or null when no jump was needed. + /** Capture origin focus, select the placement workspace, and record the + * exact focus state caused by that selection. The expected state is refreshed + * immediately after pane creation so restoration never depends on whether a + * cmux transport focuses newly-created surfaces by default. */ const focusTargetBeforeSplit = async ( targetWorkspace: string | undefined, - ): Promise => { + restore = true, + capturedPrior?: FocusTarget | null, + ): Promise => { if (!targetWorkspace) return null; - const prior = await currentFocusedWorkspace(); - if (!prior || prior === targetWorkspace) return null; - await client.selectWorkspace(targetWorkspace); - return prior; + const prior = + capturedPrior === undefined + ? await currentFocusTarget() + : capturedPrior; + const placementFocus = + capturedPrior === undefined ? prior : await currentFocusTarget(); + if (!placementFocus || placementFocus.workspace !== targetWorkspace) { + await client.selectWorkspace(targetWorkspace); + } + if (!prior || !restore) return null; + const expected = await currentFocusTarget(); + // Without an exact expected surface, a later same-workspace user move + // cannot be distinguished from cmuxlayer's own placement focus. + if (!expected?.surface) return null; + return { prior, expected }; + }; + + /** Refresh the lease immediately after the surface mutation. */ + const capturePostCreationFocus = async ( + lease: FocusRestoreLease | null, + ): Promise => { + if (!lease) return null; + const expected = await currentFocusTarget(); + return expected?.surface ? { ...lease, expected } : null; }; + const sameExactFocus = (left: FocusTarget, right: FocusTarget): boolean => + Boolean( + left.surface && + right.surface && + left.workspace === right.workspace && + left.surface === right.surface, + ); + /** - * Restore the prior focus AFTER the new terminal is fully rendered — only - * when a jump actually happened (priorFocus non-null). Waits for shell - * readiness so focus is not restored mid-render. Restores focus even if - * readiness times out (never strand focus on the wrong workspace). + * Restore the prior surface AFTER the new terminal is fully rendered. Waits + * for shell readiness so focus is not restored mid-render. Restores focus + * even if readiness times out (never strand focus on the spawned pane). */ const restoreFocusAfterRender = async ( - priorFocus: string | null, + lease: FocusRestoreLease | null, surface: string | undefined, workspace: string | undefined, - ): Promise => { - if (!priorFocus) return; - if (surface) { + opts?: { waitForReady?: boolean }, + ): Promise => { + if (!lease) return null; + if (surface && opts?.waitForReady !== false) { try { await waitForLaunchShellReady({ surface, workspace }); } catch { // Readiness timed out — restore focus anyway rather than strand it. } } - await client.selectWorkspace(priorFocus); + const current = await currentFocusTarget(); + // The user may deliberately move while a pane boots. Restore only while + // focus still exactly matches the post-creation state cmuxlayer caused. + if (!current || !sameExactFocus(current, lease.expected)) return null; + try { + if (lease.prior.surface) { + await client.focusSurface(lease.prior.surface, { + workspace: lease.prior.workspace, + }); + return null; + } + await client.selectWorkspace(lease.prior.workspace); + return null; + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + return `Focus restore failed: ${message}`; + } + }; + + /** Explicit focus:true is best-effort so the created handle is never lost. */ + const focusCreatedSurface = async ( + surface: string, + workspace: string | undefined, + ): Promise => { + try { + await client.focusSurface(surface, { workspace }); + return null; + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + return `Focus request failed: ${message}`; + } }; const startBackgroundDelivery = (record: DeliveryRecord) => { @@ -5700,8 +5787,9 @@ export function createServer(opts?: CreateServerOptions): McpServer { focus: z .boolean() .optional() - .default(true) - .describe("Focus the new pane"), + .describe( + "Set true to focus the new pane and leave focus there; otherwise cmuxlayer restores the exact origin after render", + ), boot_prompt_path: z .string() .nullable() @@ -5720,6 +5808,7 @@ export function createServer(opts?: CreateServerOptions): McpServer { ANNOTATIONS.mutating, async (args) => { let result: CmuxNewSplitResult | undefined; + let focusRestoreLease: FocusRestoreLease | null = null; try { const bootPromptPath = getBootPromptPath(args.boot_prompt_path); const shouldInferRole = @@ -5783,7 +5872,7 @@ export function createServer(opts?: CreateServerOptions): McpServer { // Auto-focus only applies to workspace-targeted splits (no explicit // pane/surface anchor). Captured right before creation, AFTER all // validation, so a rejected request has no focus side effects. - let priorFocus: string | null = null; + let focusRequestWarning: string | null = null; let actualPlacement: "split" | "surface" = "split"; let actualDirection: string | null = args.direction; if (inferredRole && (args.type ?? "terminal") === "terminal") { @@ -5833,18 +5922,16 @@ export function createServer(opts?: CreateServerOptions): McpServer { actualPlacement = placement.kind; actualDirection = placement.kind === "split" ? placement.direction : null; - if (placement.kind === "surface" && args.focus === false) { - throw new Error( - "focus=false is not supported when role-based new_split reuses an existing pane as a tab", - ); - } // Role-based placement has no explicit pane/surface (validated above), // so it is always a workspace-targeted split — apply auto-focus. assertSurfaceObserverEpochCurrent( rolePlacementObserverEpoch, "role-based new_split placement", ); - priorFocus = await focusTargetBeforeSplit(targetWorkspace); + focusRestoreLease = await focusTargetBeforeSplit( + targetWorkspace, + args.focus !== true, + ); assertSurfaceObserverEpochCurrent( rolePlacementObserverEpoch, "role-based new_split placement", @@ -5863,7 +5950,6 @@ export function createServer(opts?: CreateServerOptions): McpServer { type: args.type, url: args.url, title: args.title, - focus: args.focus, }); assertSurfaceObserverEpochCurrent( rolePlacementObserverEpoch, @@ -5873,7 +5959,10 @@ export function createServer(opts?: CreateServerOptions): McpServer { // Only workspace-targeted splits need auto-focus; an explicit // pane/surface anchor already pins the destination workspace. if (!args.pane && !args.surface) { - priorFocus = await focusTargetBeforeSplit(targetWorkspace); + focusRestoreLease = await focusTargetBeforeSplit( + targetWorkspace, + args.focus !== true, + ); } result = await client.newSplit(args.direction, { workspace: targetWorkspace, @@ -5882,9 +5971,18 @@ export function createServer(opts?: CreateServerOptions): McpServer { type: args.type, url: args.url, title: args.title, - focus: args.focus, }); } + if (args.focus === true) { + focusRequestWarning = await focusCreatedSurface( + result.surface, + result.workspace || targetWorkspace, + ); + } else { + focusRestoreLease = await capturePostCreationFocus( + focusRestoreLease, + ); + } if (args.title) { await client.renameTab(result.surface, args.title, { workspace: result.workspace || targetWorkspace, @@ -5924,16 +6022,22 @@ export function createServer(opts?: CreateServerOptions): McpServer { : undefined, }); } - await restoreFocusAfterRender( - priorFocus, + const focusRestoreWarning = await restoreFocusAfterRender( + focusRestoreLease, result.surface, result.workspace || targetWorkspace, + { waitForReady: !bootPromptPath }, ); const data: Record = { ...result }; data.placement = actualPlacement; data.direction = actualDirection; - if (targetResolution.warnings.length > 0) { - data.warnings = targetResolution.warnings; + const warnings = [ + ...targetResolution.warnings, + ...(focusRequestWarning ? [focusRequestWarning] : []), + ...(focusRestoreWarning ? [focusRestoreWarning] : []), + ]; + if (warnings.length > 0) { + data.warnings = warnings; } if (inferredRole) { data.role = inferredRole; @@ -5957,6 +6061,14 @@ export function createServer(opts?: CreateServerOptions): McpServer { data, ); } catch (e) { + // Creation or boot delivery may fail after cmuxlayer selected a target + // workspace. Return focus when the user has not moved since then. + await restoreFocusAfterRender( + focusRestoreLease, + result?.surface, + result?.workspace, + { waitForReady: false }, + ); if (e instanceof SurfaceGoneError) { return err(e, surfaceGonePayload(e)); } @@ -8469,24 +8581,45 @@ export function createServer(opts?: CreateServerOptions): McpServer { const spawnPrompt = hasInlinePrompt(args.prompt) ? args.prompt : (bootPromptText ?? ""); - const result = await engine.spawnAgent({ - repo: args.repo, - model: args.model, - cli: args.cli, - prompt: spawnPrompt, - boot_prompt_pending: - hasInlinePrompt(args.prompt) || Boolean(bootPromptPath), - workspace: spawnWorkspace, - cwd: worktree.prepared?.path, - mcp_env: worktree.mcpEnv, - mcp_profile_label: worktree.mcpProfileLabel, - worktree_branch: worktree.prepared?.branch, - parent_agent_id: args.parent_agent_id, - role: effectiveRole, - auto_archive_on_done: args.auto_archive_on_done ?? false, - max_cost_per_agent: args.max_cost_per_agent, - crash_recover: args.crash_recover, - }); + let focusRestoreLease = + await focusTargetBeforeSplit(spawnWorkspace); + let result: Awaited>; + try { + result = await engine.spawnAgent({ + repo: args.repo, + model: args.model, + cli: args.cli, + prompt: spawnPrompt, + boot_prompt_pending: + hasInlinePrompt(args.prompt) || Boolean(bootPromptPath), + workspace: spawnWorkspace, + cwd: worktree.prepared?.path, + mcp_env: worktree.mcpEnv, + mcp_profile_label: worktree.mcpProfileLabel, + worktree_branch: worktree.prepared?.branch, + parent_agent_id: args.parent_agent_id, + role: effectiveRole, + auto_archive_on_done: args.auto_archive_on_done ?? false, + max_cost_per_agent: args.max_cost_per_agent, + crash_recover: args.crash_recover, + on_surface_created: async () => { + focusRestoreLease = await capturePostCreationFocus( + focusRestoreLease, + ); + }, + }); + } catch (e) { + try { + await restoreFocusAfterRender( + focusRestoreLease, + undefined, + spawnWorkspace, + ); + } catch { + // Preserve the original spawn error response. + } + throw e; + } const originalLaunchCommand = originalLaunchCommandsBySurface.get( result.surface_id, ); @@ -8581,6 +8714,18 @@ export function createServer(opts?: CreateServerOptions): McpServer { } catch { // Preserve the original boot prompt error response. } + try { + // Boot delivery already performed its own readiness wait. On a + // timeout, restore immediately instead of starting a second wait. + await restoreFocusAfterRender( + focusRestoreLease, + result.surface_id, + spawnDeliveryWorkspace(result, spawnWorkspace), + { waitForReady: false }, + ); + } catch { + // Preserve the original boot prompt error response. + } const extra = { agent_id: result.agent_id, surface_id: result.surface_id, @@ -8610,6 +8755,22 @@ export function createServer(opts?: CreateServerOptions): McpServer { return err(e, extra); } + const focusRestoreWarning = await restoreFocusAfterRender( + focusRestoreLease, + result.surface_id, + spawnDeliveryWorkspace(result, spawnWorkspace), + { + waitForReady: + !hasInlinePrompt(args.prompt) && !Boolean(bootPromptPath), + }, + ); + if (focusRestoreWarning) { + result.warnings = [ + ...(result.warnings ?? []), + focusRestoreWarning, + ]; + } + await refreshManagedMetadataBestEffort(result.agent_id); await lifecycleSeatManifestPublisher({ agentId: result.agent_id, @@ -8729,6 +8890,9 @@ export function createServer(opts?: CreateServerOptions): McpServer { }, ANNOTATIONS.mutating, async (args) => { + let focusRestoreLease: FocusRestoreLease | null = null; + let result: Awaited> | undefined; + let mutationWorkspace: string | undefined; try { assertBootPromptMode(args.prompt, null); await refreshManagedMetadataBestEffort(args.parent_agent_id); @@ -8741,19 +8905,19 @@ export function createServer(opts?: CreateServerOptions): McpServer { callerWorkspace: parentWorkspace, repo: args.repo, }); - const mutationWorkspace = targetResolution.workspace; + mutationWorkspace = targetResolution.workspace; await assertWorkspaceMutationAllowed( "new_worktree_split", mutationWorkspace, ); - const priorFocus = await focusTargetBeforeSplit(mutationWorkspace); const worktree = await prepareSpawnWorktree( args.repo, args.worktree ?? true, args.mcp_profile as McpProfile | undefined, ); + focusRestoreLease = await focusTargetBeforeSplit(mutationWorkspace); const hasPrompt = hasInlinePrompt(args.prompt); - const result = await engine.spawnAgent({ + result = await engine.spawnAgent({ repo: args.repo, model: args.model, cli: args.cli, @@ -8768,6 +8932,11 @@ export function createServer(opts?: CreateServerOptions): McpServer { role: "worker", auto_archive_on_done: args.auto_archive_on_done ?? false, crash_recover: args.crash_recover, + on_surface_created: async () => { + focusRestoreLease = await capturePostCreationFocus( + focusRestoreLease, + ); + }, }); const originalLaunchCommand = originalLaunchCommandsBySurface.get( result.surface_id, @@ -8791,17 +8960,17 @@ export function createServer(opts?: CreateServerOptions): McpServer { bootPromptDelivery = await deliverBootPrompt({ surface: result.surface_id, workspace: deliveryWorkspace, - resolveRoute: () => resolveManagedDeliveryRoute(result.agent_id), + resolveRoute: () => resolveManagedDeliveryRoute(result!.agent_id), cli: args.cli, prompt: args.prompt, timeout_ms: args.boot_prompt_timeout_ms, onUpdateShellRelaunch: () => relaunchSpawnAgentAfterUpdate({ - agentId: result.agent_id, - surface: result.surface_id, + agentId: result!.agent_id, + surface: result!.surface_id, workspace: deliveryWorkspace, - model: result.model ?? args.model, - mcpEnv: result.mcp_env, + model: result!.model ?? args.model, + mcpEnv: result!.mcp_env, originalCommand: originalLaunchCommand, }), }); @@ -8813,11 +8982,18 @@ export function createServer(opts?: CreateServerOptions): McpServer { registry.set(result.agent_id, updated); } - await restoreFocusAfterRender( - priorFocus, + const focusRestoreWarning = await restoreFocusAfterRender( + focusRestoreLease, result.surface_id, spawnDeliveryWorkspace(result, mutationWorkspace), + { waitForReady: !hasPrompt }, ); + if (focusRestoreWarning) { + result.warnings = [ + ...(result.warnings ?? []), + focusRestoreWarning, + ]; + } await refreshManagedMetadataBestEffort(result.agent_id); await lifecycleSeatManifestPublisher({ agentId: result.agent_id, @@ -8861,6 +9037,14 @@ export function createServer(opts?: CreateServerOptions): McpServer { formatOk("new_worktree_split", formattedData), ); } catch (e) { + await restoreFocusAfterRender( + focusRestoreLease, + result?.surface_id, + result + ? spawnDeliveryWorkspace(result, mutationWorkspace) + : mutationWorkspace, + { waitForReady: false }, + ); if (e instanceof DeliverySafetyGateError) { return err(e, { error_code: e.error_code, @@ -8914,24 +9098,37 @@ export function createServer(opts?: CreateServerOptions): McpServer { }, ANNOTATIONS.mutating, async (args) => { + const originFocus = await currentFocusTarget(); + let focusRestoreLease: FocusRestoreLease | null = null; + let workspace: string | undefined; + let lastSurface: string | undefined; try { await assertWorkspaceMutationAllowed( "spawn_in_workspace", args.reuse_workspace ?? (await currentCallerWorkspace()), ); + // A newly created workspace may auto-focus immediately, so capture + // the user's origin before createWorkspace can move it. const workspaceResult = args.reuse_workspace ? { workspace: args.reuse_workspace, title: args.workspace_title } : await client.createWorkspace(args.workspace_title); - const workspace = workspaceResult.workspace; + workspace = workspaceResult.workspace; if (!workspace) { throw new Error("create_workspace returned an empty workspace ref"); } - const priorFocus = await focusTargetBeforeSplit(workspace); - // Always focus the target so agents spawn into it; harmless when the - // workspace was just created (and is already selected) or already - // focused. priorFocus drives the focus-back only when a jump happened. - await client.selectWorkspace(workspace); + focusRestoreLease = await focusTargetBeforeSplit( + workspace, + true, + originFocus, + ); + // focusTargetBeforeSplit ensures the target is selected when cmux's + // current focus cannot prove it already is. The lease drives + // focus-back only while the user has not moved since cmuxlayer's + // latest placement mutation. + focusRestoreLease = await capturePostCreationFocus( + focusRestoreLease, + ); const spawnedAgents: Array<{ agent_id: string; @@ -8957,6 +9154,11 @@ export function createServer(opts?: CreateServerOptions): McpServer { workspace, role: agent.role, auto_archive_on_done: false, + on_surface_created: async () => { + focusRestoreLease = await capturePostCreationFocus( + focusRestoreLease, + ); + }, }); const originalLaunchCommand = originalLaunchCommandsBySurface.get( result.surface_id, @@ -9064,16 +9266,22 @@ export function createServer(opts?: CreateServerOptions): McpServer { ); } - const lastSurface = - spawnedAgents[spawnedAgents.length - 1]?.surface_id; - await restoreFocusAfterRender(priorFocus, lastSurface, workspace); + lastSurface = spawnedAgents[spawnedAgents.length - 1]?.surface_id; + const focusRestoreWarning = await restoreFocusAfterRender( + focusRestoreLease, + lastSurface, + workspace, + ); // spawn_in_workspace builds its response from the per-agent objects, // which drop each result.warnings — so surface the stale-build warning // at the aggregate level (otherwise a stale MCP serving a multi-agent // workspace spawn would return NO warning). const staleWarning = staleBuildWarning(); - const workspaceWarnings = staleWarning ? [staleWarning] : []; + const workspaceWarnings = [ + ...(staleWarning ? [staleWarning] : []), + ...(focusRestoreWarning ? [focusRestoreWarning] : []), + ]; const formattedData = { workspace, @@ -9105,6 +9313,12 @@ export function createServer(opts?: CreateServerOptions): McpServer { }, ); } catch (e) { + await restoreFocusAfterRender( + focusRestoreLease, + lastSurface, + workspace, + { waitForReady: false }, + ); if (e instanceof DeliverySafetyGateError) { return err(e, { error_code: e.error_code, diff --git a/tests/cmux-client.test.ts b/tests/cmux-client.test.ts index 19b59282..bba9ed19 100644 --- a/tests/cmux-client.test.ts +++ b/tests/cmux-client.test.ts @@ -203,6 +203,32 @@ describe("CmuxClient.newSplit", () => { ]); }); + it("forwards an explicit focus preference to cmux", async () => { + const data = { + workspace: "workspace:1", + surface: "surface:3", + pane: "pane:2", + title: "Split", + type: "terminal", + }; + const { client, exec } = mockClient(data); + + await client.newSplit("right", { + workspace: "workspace:1", + focus: false, + }); + + expect(exec).toHaveBeenCalledWith("cmux", [ + "--json", "--id-format", "both", + "new-split", + "right", + "--workspace", + "workspace:1", + "--focus", + "false", + ]); + }); + it("resolves a pane target to the selected surface instead of using --panel", async () => { const paneSurfaces = { workspace_ref: "workspace:1", @@ -744,6 +770,49 @@ describe("CmuxClient.selectWorkspace", () => { }); }); +describe("CmuxClient.focusSurface", () => { + it("calls the surface.focus RPC with the exact surface and workspace", async () => { + const { client, exec } = mockClient({}); + + await client.focusSurface("surface:origin", { + workspace: "workspace:1", + }); + + expect(exec).toHaveBeenCalledWith("cmux", [ + "--json", + "--id-format", + "both", + "rpc", + "surface.focus", + JSON.stringify({ + surface_id: "surface:origin", + workspace_id: "workspace:1", + }), + ]); + }); +}); + +describe("CmuxClient.identify", () => { + it("can read global focus without supplying a caller surface", async () => { + const { client, exec } = mockClient({ + focused: { + workspace_ref: "workspace:1", + surface_ref: "surface:origin", + }, + }); + + const result = await client.identify(); + + expect(exec).toHaveBeenCalledWith("cmux", [ + "--json", + "--id-format", + "both", + "identify", + ]); + expect(result.focused?.surface_ref).toBe("surface:origin"); + }); +}); + describe("CmuxClient.readScreen", () => { it("calls cmux read-screen with surface", async () => { const data = { diff --git a/tests/cmux-socket-client.test.ts b/tests/cmux-socket-client.test.ts index 10cd8fee..a3eb9d40 100644 --- a/tests/cmux-socket-client.test.ts +++ b/tests/cmux-socket-client.test.ts @@ -90,6 +90,7 @@ const MOCK_RESPONSES: Record = { }, "surface.send_text": {}, "surface.send_key": {}, + "surface.focus": {}, "surface.read_text": { surface_ref: "surface:1", text: "$ echo hello\nhello\n$", @@ -490,6 +491,31 @@ describe.skipIf(!CAN_BIND_MOCK_SOCKET)("CmuxSocketClient", () => { expect(lastV2Request!.params).toEqual({ workspace_id: "workspace:1" }); }); + it("focusSurface sends a surface.focus request", async () => { + const client = new CmuxSocketClient({ socketPath: MOCK_SOCKET_PATH }); + + await client.focusSurface("surface:origin", { + workspace: "workspace:1", + }); + + expect(lastV2Request).not.toBeNull(); + expect(lastV2Request!.method).toBe("surface.focus"); + expect(lastV2Request!.params).toEqual({ + surface_id: "surface:origin", + workspace_id: "workspace:1", + }); + }); + + it("identify can read global focus without a caller surface", async () => { + const client = new CmuxSocketClient({ socketPath: MOCK_SOCKET_PATH }); + + await client.identify(); + + expect(lastV2Request).not.toBeNull(); + expect(lastV2Request!.method).toBe("system.identify"); + expect(lastV2Request!.params).toEqual({}); + }); + it("deleteWorkspace sends a workspace.close request", async () => { const client = new CmuxSocketClient({ socketPath: MOCK_SOCKET_PATH }); @@ -1106,6 +1132,28 @@ describe.skipIf(!CAN_BIND_MOCK_SOCKET)("CmuxSocketClient V2→CLI fallback", () } as unknown as CmuxClient; } + it("newSplit routes an explicit focus preference through the verified CLI contract", async () => { + const client = new CmuxSocketClient({ + socketPath: MOCK_SOCKET_PATH, + cliFallback: createMockCli(), + }); + + await client.newSplit("right", { + workspace: "workspace:1", + focus: false, + }); + + expect(cliCalls).toEqual([ + { + method: "newSplit", + args: [ + "right", + { workspace: "workspace:1", focus: false }, + ], + }, + ]); + }); + it("newSplit falls back to CLI when surface.split returns method_not_found", async () => { const saved = MOCK_RESPONSES["surface.split"]; delete MOCK_RESPONSES["surface.split"]; diff --git a/tests/cmux-transport-self-heal.test.ts b/tests/cmux-transport-self-heal.test.ts index b3135bba..c0288283 100644 --- a/tests/cmux-transport-self-heal.test.ts +++ b/tests/cmux-transport-self-heal.test.ts @@ -1025,6 +1025,32 @@ describe.skipIf(!CAN_BIND_MOCK_SOCKET)("transport self-healing", () => { client.stop(); }); + it("forwards focusSurface through the self-healing transport", async () => { + const socketPath = join(tmpdir(), `cmux-focus-surface-${process.pid}.sock`); + const cli = new CmuxClient({ + exec: vi.fn().mockResolvedValue({ stdout: "{}", stderr: "" }), + bin: "cmux", + }); + const socket = { + currentSocketPath: () => socketPath, + disconnect: vi.fn(), + focusSurface: vi.fn().mockResolvedValue(undefined), + } as unknown as CmuxSocketClient; + const client = wrapSocketWithSelfHeal(socket, cli, { + socketPath, + reprobeIntervalMs: 60_000, + }); + + await (client as unknown as CmuxClient).focusSurface("surface:origin", { + workspace: "workspace:1", + }); + + expect(socket.focusSurface).toHaveBeenCalledWith("surface:origin", { + workspace: "workspace:1", + }); + client.stop(); + }); + it("flushes queued failed payloads sequentially", async () => { const socketPath = join(tmpdir(), `cmux-queue-seq-${process.pid}.sock`); let activeFlushes = 0; diff --git a/tests/server-agent-tools.test.ts b/tests/server-agent-tools.test.ts index 595148e8..4075370e 100644 --- a/tests/server-agent-tools.test.ts +++ b/tests/server-agent-tools.test.ts @@ -8649,10 +8649,18 @@ describe("auto-focus discipline (focus target before split, restore after render // read-screen polls before reporting ready. function makeFocusExec(opts: { selectedWorkspace: string; + focusedSurface?: string; notReadyFor?: number; + moveFocusDuringReadinessTo?: { + workspace: string; + surface: string; + }; + focusSurfaceFails?: boolean; }): { exec: ExecFn; calls: string[][]; readScreenCount: () => number } { const calls: string[][] = []; let readScreens = 0; + let focusedWorkspace = opts.selectedWorkspace; + let focusedSurface = opts.focusedSurface ?? "surface:origin"; const exec = vi.fn(async (_cmd: string, args: string[]) => { calls.push(args); if (args.includes("list-workspaces")) { @@ -8663,14 +8671,14 @@ describe("auto-focus discipline (focus target before split, restore after render ref: "workspace:1", title: "One", index: 0, - selected: opts.selectedWorkspace === "workspace:1", + selected: focusedWorkspace === "workspace:1", pinned: false, }, { ref: "workspace:2", title: "Two", index: 1, - selected: opts.selectedWorkspace === "workspace:2", + selected: focusedWorkspace === "workspace:2", pinned: false, }, ], @@ -8678,9 +8686,30 @@ describe("auto-focus discipline (focus target before split, restore after render stderr: "", }; } + if (args.includes("identify")) { + return { + stdout: JSON.stringify({ + caller: { + workspace_ref: focusedWorkspace, + surface_ref: focusedSurface, + pane_ref: "pane:origin", + }, + focused: { + workspace_ref: focusedWorkspace, + surface_ref: focusedSurface, + pane_ref: "pane:origin", + }, + }), + stderr: "", + }; + } if (args.includes("read-screen")) { readScreens++; const notReady = (opts.notReadyFor ?? 0) >= readScreens; + if (opts.moveFocusDuringReadinessTo) { + focusedWorkspace = opts.moveFocusDuringReadinessTo.workspace; + focusedSurface = opts.moveFocusDuringReadinessTo.surface; + } return { stdout: JSON.stringify({ surface: "surface:new", @@ -8693,6 +8722,23 @@ describe("auto-focus discipline (focus target before split, restore after render stderr: "", }; } + if (args.includes("select-workspace")) { + focusedWorkspace = args[args.indexOf("--workspace") + 1]; + focusedSurface = + focusedWorkspace === "workspace:1" + ? "surface:origin" + : "surface:target"; + } + if (args.includes("rpc") && args.includes("surface.focus")) { + if (opts.focusSurfaceFails) throw new Error("focus restore failed"); + const payload = JSON.parse(args.at(-1) ?? "{}") as { + surface_id?: string; + workspace_id?: string; + }; + focusedWorkspace = payload.workspace_id ?? focusedWorkspace; + focusedSurface = payload.surface_id ?? focusedSurface; + return { stdout: "{}", stderr: "" }; + } // Default: split/surface creation result. return { stdout: JSON.stringify({ @@ -8710,12 +8756,104 @@ describe("auto-focus discipline (focus target before split, restore after render const selectIdx = (calls: string[][], ws: string) => calls.findIndex((a) => a.includes("select-workspace") && a.includes(ws)); + const focusSurfaceIdx = (calls: string[][], surface: string) => + calls.findIndex( + (a) => + a.includes("rpc") && + a.includes("surface.focus") && + a.some((value) => value.includes(surface)), + ); const firstReadScreenIdx = (calls: string[][]) => calls.findIndex((a) => a.includes("read-screen")); const lastReadScreenIdx = (calls: string[][]) => calls.reduce((last, a, i) => (a.includes("read-screen") ? i : last), -1); - it("new_split focuses the target workspace before the split and restores prior focus after readiness when a jump is needed", async () => { + function makeFocusLifecycleExec(opts?: { + selectedWorkspace?: string; + focusedSurface?: string; + moveFocusDuringReadinessTo?: { + workspace: string; + surface: string; + }; + focusSurfaceFails?: boolean; + }): { exec: ExecFn; calls: string[][] } { + const calls: string[][] = []; + const lifecycleExec = makeLifecycleExec(); + let focusedWorkspace = opts?.selectedWorkspace ?? "workspace:1"; + let focusedSurface = opts?.focusedSurface ?? "surface:origin"; + let spawnCreated = false; + const exec = vi.fn(async (cmd: string, args: string[]) => { + calls.push(args); + if (args.includes("identify")) { + return { + stdout: JSON.stringify({ + caller: { + workspace_ref: focusedWorkspace, + surface_ref: focusedSurface, + pane_ref: "pane:origin", + }, + focused: { + workspace_ref: focusedWorkspace, + surface_ref: focusedSurface, + pane_ref: "pane:origin", + }, + }), + stderr: "", + }; + } + if (args.includes("rpc") && args.includes("surface.focus")) { + if (opts?.focusSurfaceFails) throw new Error("focus restore failed"); + const payload = JSON.parse(args.at(-1) ?? "{}") as { + surface_id?: string; + workspace_id?: string; + }; + focusedWorkspace = payload.workspace_id ?? focusedWorkspace; + focusedSurface = payload.surface_id ?? focusedSurface; + return { stdout: "{}", stderr: "" }; + } + if (args.includes("select-workspace")) { + focusedWorkspace = args[args.indexOf("--workspace") + 1]; + focusedSurface = + focusedWorkspace === "workspace:1" + ? "surface:origin" + : "surface:target"; + } + if (args.includes("create-workspace")) { + focusedWorkspace = "workspace:2"; + focusedSurface = "surface:target"; + return { + stdout: JSON.stringify({ + workspace: "workspace:2", + title: "Review team", + }), + stderr: "", + }; + } + const result = await lifecycleExec(cmd, args); + if (args.includes("new-split") || args.includes("new-surface")) { + spawnCreated = true; + return { + ...result, + stdout: JSON.stringify({ + ...(JSON.parse(result.stdout) as Record), + workspace: focusedWorkspace, + }), + }; + } + if ( + spawnCreated && + args.includes("read-screen") && + opts?.moveFocusDuringReadinessTo + ) { + focusedWorkspace = opts.moveFocusDuringReadinessTo.workspace; + focusedSurface = opts.moveFocusDuringReadinessTo.surface; + } + return result; + }) as unknown as ExecFn; + return { exec, calls }; + } + + it("new_split restores the prior surface after a cross-workspace spawn", async () => { const { exec, calls } = makeFocusExec({ selectedWorkspace: "workspace:1" }); const server = createLifecycleServer(exec); const tool = (server as any)._registeredTools["new_split"]; @@ -8729,7 +8867,7 @@ describe("auto-focus discipline (focus target before split, restore after render expect(parsed.surface).toBe("surface:new"); const focusTarget = selectIdx(calls, "workspace:2"); - const restorePrior = selectIdx(calls, "workspace:1"); + const restorePrior = focusSurfaceIdx(calls, "surface:origin"); const readScreen = firstReadScreenIdx(calls); // Target was focused BEFORE the prior focus was restored. @@ -8738,9 +8876,11 @@ describe("auto-focus discipline (focus target before split, restore after render // Readiness was awaited between the split and the focus-back. expect(readScreen).toBeGreaterThan(focusTarget); expect(readScreen).toBeLessThan(restorePrior); + // Restoring only the workspace can land on a different pane/tab. + expect(selectIdx(calls, "workspace:1")).toBe(-1); }); - it("new_split does NOT touch focus when the target is already the focused workspace", async () => { + it("new_split restores the prior surface after a same-workspace spawn", async () => { const { exec, calls } = makeFocusExec({ selectedWorkspace: "workspace:2" }); const server = createLifecycleServer(exec); const tool = (server as any)._registeredTools["new_split"]; @@ -8752,6 +8892,325 @@ describe("auto-focus discipline (focus target before split, restore after render const selectCalls = calls.filter((a) => a.includes("select-workspace")); expect(selectCalls).toHaveLength(0); + expect(focusSurfaceIdx(calls, "surface:origin")).toBeGreaterThanOrEqual(0); + }); + + it("spawn_agent restores the prior surface after a same-workspace spawn", async () => { + const { exec, calls } = makeFocusLifecycleExec(); + const server = createLifecycleServer(exec); + const tool = (server as any)._registeredTools["spawn_agent"]; + + const result = await tool.handler( + { + repo: "cmuxlayer", + cli: "codex", + workspace: "workspace:1", + force_new: true, + }, + {} as any, + ); + + expect(result.structuredContent.ok).toBe(true); + expect(focusSurfaceIdx(calls, "surface:origin")).toBeGreaterThanOrEqual(0); + }); + + it("spawn_agent restores the prior surface after a cross-workspace spawn", async () => { + const { exec, calls } = makeFocusLifecycleExec(); + const server = createLifecycleServer(exec); + const tool = (server as any)._registeredTools["spawn_agent"]; + + const result = await tool.handler( + { + repo: "cmuxlayer", + cli: "codex", + workspace: "workspace:2", + force_new: true, + }, + {} as any, + ); + + expect(result.structuredContent.ok).toBe(true); + expect(selectIdx(calls, "workspace:2")).toBeGreaterThanOrEqual(0); + expect(focusSurfaceIdx(calls, "surface:origin")).toBeGreaterThanOrEqual(0); + }); + + it("new_worktree_split restores the prior surface after a cross-workspace spawn", async () => { + const { exec, calls } = makeFocusLifecycleExec(); + const server = createLifecycleServer(exec); + const tool = (server as any)._registeredTools["new_worktree_split"]; + + const result = await tool.handler( + { + repo: "cmuxlayer", + cli: "codex", + model: "gpt-5.6-sol", + workspace: "workspace:2", + worktree: false, + }, + {} as any, + ); + + expect(result.structuredContent.ok).toBe(true); + expect(selectIdx(calls, "workspace:2")).toBeGreaterThanOrEqual(0); + expect(focusSurfaceIdx(calls, "surface:origin")).toBeGreaterThanOrEqual(0); + }); + + it("spawn_in_workspace restores the prior surface after a cross-workspace spawn", async () => { + const { exec, calls } = makeFocusLifecycleExec(); + const server = createLifecycleServer(exec); + const tool = (server as any)._registeredTools["spawn_in_workspace"]; + + const result = await tool.handler( + { + workspace_title: "Review team", + reuse_workspace: "workspace:2", + agents: [ + { + repo: "cmuxlayer", + cli: "codex", + model: "gpt-5.6-sol", + role: "worker", + }, + ], + }, + {} as any, + ); + + expect(result.structuredContent.ok).toBe(true); + expect(selectIdx(calls, "workspace:2")).toBeGreaterThanOrEqual(0); + expect(focusSurfaceIdx(calls, "surface:origin")).toBeGreaterThanOrEqual(0); + }); + + it("spawn_in_workspace captures the origin before a new workspace auto-focuses", async () => { + const { exec, calls } = makeFocusLifecycleExec(); + const server = createLifecycleServer(exec); + const tool = (server as any)._registeredTools["spawn_in_workspace"]; + + const result = await tool.handler( + { + workspace_title: "Review team", + agents: [ + { + repo: "cmuxlayer", + cli: "codex", + model: "gpt-5.6-sol", + role: "worker", + }, + ], + }, + {} as any, + ); + + expect(result.structuredContent.ok).toBe(true); + expect(focusSurfaceIdx(calls, "surface:origin")).toBeGreaterThanOrEqual(0); + }); + + it("spawn_agent keeps its success response when focus restoration fails", async () => { + const { exec } = makeFocusLifecycleExec({ focusSurfaceFails: true }); + const server = createLifecycleServer(exec); + const tool = (server as any)._registeredTools["spawn_agent"]; + + const result = await tool.handler( + { + repo: "cmuxlayer", + cli: "codex", + workspace: "workspace:1", + force_new: true, + }, + {} as any, + ); + + expect(result.structuredContent.ok).toBe(true); + expect(result.structuredContent.agent_id).toEqual(expect.any(String)); + expect(result.structuredContent.surface_id).toBe("surface:new"); + expect(result.structuredContent.warnings).toEqual( + expect.arrayContaining([expect.stringMatching(/focus restore failed/i)]), + ); + }); + + it("new_split keeps its success response when focus restoration fails", async () => { + const { exec } = makeFocusExec({ + selectedWorkspace: "workspace:1", + focusSurfaceFails: true, + }); + const server = createLifecycleServer(exec); + const tool = (server as any)._registeredTools["new_split"]; + + const result = await tool.handler( + { direction: "right", workspace: "workspace:2", type: "terminal" }, + {} as any, + ); + + expect(result.structuredContent.ok).toBe(true); + expect(result.structuredContent.surface).toBe("surface:new"); + expect(result.structuredContent.warnings).toEqual( + expect.arrayContaining([expect.stringMatching(/focus restore failed/i)]), + ); + }); + + it("new_split does not steal focus back after the user moves during readiness", async () => { + const { exec, calls } = makeFocusExec({ + selectedWorkspace: "workspace:1", + moveFocusDuringReadinessTo: { + workspace: "workspace:1", + surface: "surface:user-choice", + }, + }); + const server = createLifecycleServer(exec); + const tool = (server as any)._registeredTools["new_split"]; + + const result = await tool.handler( + { direction: "right", workspace: "workspace:2", type: "terminal" }, + {} as any, + ); + + expect(result.structuredContent.ok).toBe(true); + expect(focusSurfaceIdx(calls, "surface:origin")).toBe(-1); + }); + + it("spawn_agent does not steal focus back after the user moves during readiness", async () => { + const { exec, calls } = makeFocusLifecycleExec({ + moveFocusDuringReadinessTo: { + workspace: "workspace:1", + surface: "surface:user-choice", + }, + }); + const server = createLifecycleServer(exec); + const tool = (server as any)._registeredTools["spawn_agent"]; + + const result = await tool.handler( + { + repo: "cmuxlayer", + cli: "codex", + workspace: "workspace:1", + force_new: true, + }, + {} as any, + ); + + expect(result.structuredContent.ok).toBe(true); + expect(focusSurfaceIdx(calls, "surface:origin")).toBe(-1); + }); + + it("new_worktree_split does not steal focus back after the user moves during readiness", async () => { + const { exec, calls } = makeFocusLifecycleExec({ + moveFocusDuringReadinessTo: { + workspace: "workspace:1", + surface: "surface:user-choice", + }, + }); + const server = createLifecycleServer(exec); + const tool = (server as any)._registeredTools["new_worktree_split"]; + + const result = await tool.handler( + { + repo: "cmuxlayer", + cli: "codex", + model: "gpt-5.6-sol", + workspace: "workspace:2", + worktree: false, + }, + {} as any, + ); + + expect(result.structuredContent.ok).toBe(true); + expect(focusSurfaceIdx(calls, "surface:origin")).toBe(-1); + }); + + it("spawn_in_workspace does not steal focus back after the user moves during readiness", async () => { + const { exec, calls } = makeFocusLifecycleExec({ + moveFocusDuringReadinessTo: { + workspace: "workspace:1", + surface: "surface:user-choice", + }, + }); + const server = createLifecycleServer(exec); + const tool = (server as any)._registeredTools["spawn_in_workspace"]; + + const result = await tool.handler( + { + workspace_title: "Review team", + reuse_workspace: "workspace:2", + agents: [ + { + repo: "cmuxlayer", + cli: "codex", + model: "gpt-5.6-sol", + role: "worker", + }, + ], + }, + {} as any, + ); + + expect(result.structuredContent.ok).toBe(true); + expect(focusSurfaceIdx(calls, "surface:origin")).toBe(-1); + }); + + it("spawn_agent restores the prior surface when pane creation fails", async () => { + const calls: string[][] = []; + const lifecycleExec = makeLifecycleExec(); + const exec = vi.fn(async (cmd: string, args: string[]) => { + calls.push(args); + if (args.includes("identify")) { + return { + stdout: JSON.stringify({ + caller: { + workspace_ref: "workspace:1", + surface_ref: "surface:origin", + pane_ref: "pane:origin", + }, + focused: { + workspace_ref: "workspace:1", + surface_ref: "surface:origin", + pane_ref: "pane:origin", + }, + }), + stderr: "", + }; + } + if (args.includes("new-split") || args.includes("new-surface")) { + throw new Error("pane creation failed"); + } + return lifecycleExec(cmd, args); + }) as unknown as ExecFn; + const server = createLifecycleServer(exec); + const tool = (server as any)._registeredTools["spawn_agent"]; + + const result = await tool.handler( + { + repo: "cmuxlayer", + cli: "codex", + workspace: "workspace:2", + force_new: true, + }, + {} as any, + ); + + expect(result.structuredContent.ok).toBe(false); + expect(result.structuredContent.error).toContain("pane creation failed"); + expect(focusSurfaceIdx(calls, "surface:origin")).toBeGreaterThanOrEqual(0); + }); + + it("new_split with focus=true explicitly focuses and stays on the new surface", async () => { + const { exec, calls } = makeFocusExec({ selectedWorkspace: "workspace:1" }); + const server = createLifecycleServer(exec); + const tool = (server as any)._registeredTools["new_split"]; + + await tool.handler( + { + direction: "right", + workspace: "workspace:2", + type: "terminal", + focus: true, + }, + {} as any, + ); + + expect(selectIdx(calls, "workspace:2")).toBeGreaterThanOrEqual(0); + expect(focusSurfaceIdx(calls, "surface:new")).toBeGreaterThanOrEqual(0); + expect(focusSurfaceIdx(calls, "surface:origin")).toBe(-1); + expect(selectIdx(calls, "workspace:1")).toBe(-1); }); it("new_split waits for the new terminal to render before restoring focus", async () => { @@ -8769,7 +9228,7 @@ describe("auto-focus discipline (focus target before split, restore after render // Polled until ready (2 not-ready + 1 ready) and only then restored focus. expect(readScreenCount()).toBeGreaterThanOrEqual(3); - const restorePrior = selectIdx(calls, "workspace:1"); + const restorePrior = focusSurfaceIdx(calls, "surface:origin"); expect(restorePrior).toBeGreaterThan(lastReadScreenIdx(calls)); }); }); diff --git a/tests/server.test.ts b/tests/server.test.ts index 36e99af6..74f32a43 100644 --- a/tests/server.test.ts +++ b/tests/server.test.ts @@ -5355,6 +5355,7 @@ describe("tool handler integration", () => { title: "", type: "terminal", }), + selectWorkspace: vi.fn().mockResolvedValue(undefined), renameTab: vi.fn().mockResolvedValue(undefined), }; const server = createServer({ @@ -5441,6 +5442,7 @@ describe("tool handler integration", () => { title: "", type: "terminal", }), + selectWorkspace: vi.fn().mockResolvedValue(undefined), renameTab: vi.fn().mockResolvedValue(undefined), }; const server = createServer({ @@ -6955,7 +6957,7 @@ describe("tool handler integration", () => { ); }); - it("new_split with role=worker rejects focus=false while seeding the worker column", async () => { + it("new_split with role=worker allows focus=false while seeding the worker column", async () => { const stateDir = join(CHANNEL_TEST_DIR, "new-split-role-focus-state"); rmSync(stateDir, { recursive: true, force: true }); const stateMgr = new StateManager(stateDir); @@ -7037,6 +7039,18 @@ describe("tool handler integration", () => { stderr: "", }; } + if (args.includes("new-surface") || args.includes("new-split")) { + return { + stdout: JSON.stringify({ + workspace: "workspace:1", + surface: "surface:new-worker", + pane: "pane:right", + title: "", + type: "terminal", + }), + stderr: "", + }; + } return { stdout: "{}", stderr: "" }; }); @@ -7060,11 +7074,17 @@ describe("tool handler integration", () => { const parsed = result.structuredContent ?? JSON.parse(result.content[0].text); - expect(parsed.ok).toBe(false); - expect(parsed.error).toContain("unfocused splits"); + expect(parsed.ok).toBe(true); + expect(parsed.surface).toBe("surface:new-worker"); + expect( + mockExec.mock.calls.some( + ([, args]) => + args.includes("new-surface") || args.includes("new-split"), + ), + ).toBe(true); expect(mockExec).not.toHaveBeenCalledWith( "cmux", - expect.arrayContaining(["new-surface"]), + expect.arrayContaining(["surface.focus"]), ); });