fix(runtime): refuse headless tab split/move into an unknown group (#27381)

The headless split and move-to-group handlers answered {moved:true} when the
target group did not exist, hiding desktop/server group-id drift (#26522).
They now refuse with target_group_not_found, as reorder already does.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
Jinwoo Hong
2026-10-11 01:54:29 -04:00
committed by GitHub
parent ac156c0302
commit 92fb883cf2
2 changed files with 117 additions and 6 deletions
@@ -0,0 +1,99 @@
import { describe, expect, it } from 'vitest'
import { OrcaRuntimeService } from './orca-runtime'
import type {
RuntimeMobileSessionTabMove,
RuntimeMobileSessionTabsSnapshot
} from '../../shared/runtime-types'
function terminalTab(parentTabId: string, isActive: boolean) {
return {
type: 'terminal' as const,
id: `${parentTabId}::leaf-1`,
parentTabId,
leafId: 'leaf-1',
title: 'Terminal',
isActive
}
}
// No notifier, so moves take the headless (serve) path.
function headlessRuntime(): {
runtime: OrcaRuntimeService
snapshots: Map<string, RuntimeMobileSessionTabsSnapshot>
} {
const runtime = new OrcaRuntimeService()
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the published snapshot map is protected; seeding it stands in for a prior publication.
const internals = runtime as unknown as {
mobileSessionTabsByWorktree: Map<string, RuntimeMobileSessionTabsSnapshot>
}
const snapshots = internals.mobileSessionTabsByWorktree
snapshots.set('wt-1', {
worktree: 'wt-1',
publicationEpoch: 'epoch-1',
snapshotVersion: 1,
activeGroupId: 'group-a',
activeTabId: 'tab-a::leaf-1',
activeTabType: 'terminal',
tabGroups: [
{ id: 'group-a', activeTabId: 'tab-a', tabOrder: ['tab-a', 'tab-b'] },
{ id: 'group-c', activeTabId: 'tab-c', tabOrder: ['tab-c'] }
],
tabGroupLayout: {
type: 'split',
direction: 'horizontal',
first: { type: 'leaf', groupId: 'group-a' },
second: { type: 'leaf', groupId: 'group-c' }
},
tabs: [terminalTab('tab-a', true), terminalTab('tab-b', false), terminalTab('tab-c', false)]
})
return { runtime, snapshots }
}
async function expectNoOp(move: RuntimeMobileSessionTabMove): Promise<void> {
const { runtime, snapshots } = headlessRuntime()
const before = snapshots.get('wt-1')
await expect(runtime.moveMobileSessionTab('id:wt-1', move)).resolves.toEqual({ moved: true })
expect(snapshots.get('wt-1')).toBe(before)
}
describe('headless session tab moves', () => {
it('refuses a split onto a group the host does not have', async () => {
const { runtime, snapshots } = headlessRuntime()
const before = snapshots.get('wt-1')
await expect(
runtime.moveMobileSessionTab('id:wt-1', {
kind: 'split',
tabId: 'tab-b',
targetGroupId: 'group-missing',
splitDirection: 'right'
})
).rejects.toThrow('target_group_not_found')
expect(snapshots.get('wt-1')).toBe(before)
})
it('refuses a move into a group the host does not have', async () => {
const { runtime, snapshots } = headlessRuntime()
const before = snapshots.get('wt-1')
await expect(
runtime.moveMobileSessionTab('id:wt-1', {
kind: 'move-to-group',
tabId: 'tab-b',
targetGroupId: 'group-missing'
})
).rejects.toThrow('target_group_not_found')
expect(snapshots.get('wt-1')).toBe(before)
})
it('keeps a same-group drop a successful no-op', async () => {
await expectNoOp({ kind: 'move-to-group', tabId: 'tab-b', targetGroupId: 'group-a' })
})
it('keeps splitting the last tab off its own group a successful no-op', async () => {
await expectNoOp({
kind: 'split',
tabId: 'tab-c',
targetGroupId: 'group-c',
splitDirection: 'right'
})
})
})
@@ -33,11 +33,7 @@ export class OrcaRuntimeWithMoveHeadlessMobileSessionTab extends OrcaRuntimeWith
if (!hostTabId) {
throw new Error('tab_not_found')
}
const publicSnapshot = this.toMobileSessionTabsResult(snapshot)
const targetGroup = publicSnapshot.tabGroups?.find((group) => group.id === move.targetGroupId)
if (!targetGroup) {
throw new Error('target_group_not_found')
}
const targetGroup = this.requireHeadlessTargetGroup(snapshot, move.targetGroupId)
const tabOrder = this.normalizeMobileSessionTabOrder(snapshot, targetGroup, move.tabOrder)
const orderIndexByParentTabId = new Map(tabOrder.map((tabId, index) => [tabId, index]))
const nextTabs = [...snapshot.tabs].sort((a, b) => {
@@ -87,6 +83,7 @@ export class OrcaRuntimeWithMoveHeadlessMobileSessionTab extends OrcaRuntimeWith
if (!hostTabId) {
throw new Error('tab_not_found')
}
this.requireHeadlessTargetGroup(snapshot, move.targetGroupId)
const split = buildHeadlessTabGroupSplit({
groups: snapshot.tabGroups ?? [],
layout: snapshot.tabGroupLayout,
@@ -125,6 +122,7 @@ export class OrcaRuntimeWithMoveHeadlessMobileSessionTab extends OrcaRuntimeWith
if (!hostTabId) {
throw new Error('tab_not_found')
}
this.requireHeadlessTargetGroup(snapshot, move.targetGroupId)
const moved = buildHeadlessTabGroupMove({
groups: snapshot.tabGroups ?? [],
layout: snapshot.tabGroupLayout,
@@ -133,7 +131,7 @@ export class OrcaRuntimeWithMoveHeadlessMobileSessionTab extends OrcaRuntimeWith
index: move.index
})
if (!moved) {
// Same-group / missing-target drop is a renderer no-op; mirror that.
// Same-group drop is a renderer no-op; mirror that.
return { moved: true }
}
const layout = moved.layout ?? { type: 'leaf' as const, groupId: move.targetGroupId }
@@ -152,6 +150,20 @@ export class OrcaRuntimeWithMoveHeadlessMobileSessionTab extends OrcaRuntimeWith
return { moved: true }
}
// Why: the builders return null for a missing target AND a legit no-op; only the no-op may succeed.
protected requireHeadlessTargetGroup(
snapshot: RuntimeMobileSessionTabsSnapshot,
targetGroupId: string
): RuntimeMobileSessionTabGroup {
const targetGroup = this.toMobileSessionTabsResult(snapshot).tabGroups?.find(
(group) => group.id === targetGroupId
)
if (!targetGroup) {
throw new Error('target_group_not_found')
}
return targetGroup
}
// Persist the headless tab-GROUP layout so snapshot rebuilds keep the split; the tab bar and rows
// follow the groups. Deleted by the switch (PR 6), when moves are layout commands.
protected persistHeadlessTabGroups(