mirror of
https://github.com/stablyai/orca.git
synced 2026-09-27 16:02:35 +00:00
test(runtime): cross-version journeys for the session-tab sync channel
What a paired host puts in a worktree's tab list is decided per connection from the client's advertised capabilities, so one host publishes different rows to two clients on the same socket. That is invisible from a single build, and the channel was explicitly outside the cross-version harness. Pairs current code against the newest release over session.tabs.list, .subscribe and .close, for two client states derived from the baseline's own list rather than written down: C0 removes the structured capabilities, C1 adds the reader back. Each runs against both builds, and each build's fallback copy is read from its own extracted checkout, because rewording a host's own string is not a break. Covers rows withheld from C0 and published verbatim to C1 with focus, groups and layout repaired around the withheld row; the host's own record left whole by either projection, with a close of an unseen row refused instead of pruning durable work; the projection applied to live updated frames and not only the opening snapshot; a mobile client keeping a metadata-only row under its build's fallback title and being refused when it closes a row it can see but not read; a client turning the advertisement on finding a pre-existing session already there; and the turn-item downgrade riding the same connection. The cross-version job runs a hand-listed file set, so the file is added to that list too: under the glob alone it runs in `pnpm test` and gates nothing. No production change.
This commit is contained in:
@@ -661,6 +661,7 @@ jobs:
|
||||
tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts
|
||||
tests/e2e/cross-version-wire/reported-lossy-initial-snapshot.unit.test.ts
|
||||
tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts
|
||||
tests/e2e/cross-version-wire/cross-version-session-tab-sync.unit.test.ts
|
||||
|
||||
managed_hook_node18:
|
||||
name: managed hooks on Node 18
|
||||
|
||||
@@ -154,10 +154,42 @@ Run it with:
|
||||
pnpm exec vitest run --config config/vitest.config.ts tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts
|
||||
```
|
||||
|
||||
The harness covers the terminal stream and the structured agent-session surface. It does
|
||||
**not** cover the session-tab sync channel, legacy agent-session publications, file or Git
|
||||
RPCs, mobile/E2EE framing, or the relay transport. A change on those paths still needs its
|
||||
own reasoning against the three rules above.
|
||||
`tests/e2e/cross-version-wire/cross-version-session-tab-sync.unit.test.ts` pairs the same
|
||||
two builds over the session-tab sync channel. What a host puts in a worktree's tab list is
|
||||
decided per connection from the client's advertised capabilities, so one host publishes
|
||||
different rows to two clients on the same socket, and no single build can show that. Both
|
||||
client states are derived from the baseline's own list rather than written down: C0 removes
|
||||
the structured capabilities from it, C1 adds the reader back. Each runs against both builds,
|
||||
and each build's fallback copy is read from its own checkout, because rewording a host's own
|
||||
string is not a break. It covers:
|
||||
|
||||
- structured rows withheld from C0 and published verbatim to C1, with focus, groups and
|
||||
layout repaired around the withheld row rather than left dangling;
|
||||
- the host's own record unchanged by either projection, so a client that cannot read a chat
|
||||
is never why the host stops holding it, and a destructive close of a row the client was
|
||||
never shown is refused instead of pruning durable work;
|
||||
- the projection applied to live `updated` frames and not only to the opening snapshot;
|
||||
- a mobile client keeping a metadata-only row under its build's fallback title, and being
|
||||
refused when it closes a row it can see but not read;
|
||||
- a client that turns the advertisement on finding a pre-existing session already there,
|
||||
with nothing republished for it;
|
||||
- the turn-item downgrade riding the same connection, so advertising that you can read the
|
||||
row stays independent of the item bodies inside it.
|
||||
|
||||
Run it with:
|
||||
|
||||
```bash
|
||||
pnpm exec vitest run --config config/vitest.config.ts tests/e2e/cross-version-wire/cross-version-session-tab-sync.unit.test.ts
|
||||
```
|
||||
|
||||
The cross-version job runs a hand-listed set of files (`.github/workflows/pr.yml`, the
|
||||
`cross-version-wire` job). A new file under this directory is picked up by `pnpm test` but
|
||||
gates nothing until it is added to that list.
|
||||
|
||||
The harness covers the terminal stream, the structured agent-session surface and the
|
||||
session-tab sync channel. It does **not** cover legacy agent-session publications, file or
|
||||
Git RPCs, mobile/E2EE framing, or the relay transport. A change on those paths still needs
|
||||
its own reasoning against the three rules above.
|
||||
|
||||
## Worked example: `agentWait` on terminal and worker reads
|
||||
|
||||
|
||||
@@ -0,0 +1,560 @@
|
||||
// Cross-version coverage for the session-tab sync channel, paired the way the terminal and
|
||||
// agent-session harnesses are: current code against a real published release.
|
||||
//
|
||||
// The wire-compatibility doc used to exclude this channel from the harness, and the exclusion
|
||||
// was load-bearing: what a paired host puts in a worktree's tab list is decided per connection
|
||||
// from the client's advertised capabilities, so the same host publishes different rows to two
|
||||
// clients on the same socket. Nothing about that is visible from one build alone.
|
||||
//
|
||||
// Two client states matter and both are derived from the baseline release rather than written
|
||||
// down here: C0, a client that never advertised structured chat, and C1, one that advertises the
|
||||
// structured reader. Each is run against both an old host and a new one, because the channel is
|
||||
// symmetric — the desktop is about to become a client of a host it also ships.
|
||||
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { setStructuredAgentSessionHost } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-registry'
|
||||
import {
|
||||
CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY,
|
||||
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY
|
||||
} from '../../../src/shared/protocol-version'
|
||||
import type {
|
||||
RuntimeMobileSessionClientTab,
|
||||
RuntimeMobileSessionTabsResult
|
||||
} from '../../../src/shared/runtime-types'
|
||||
import {
|
||||
importReleaseCheckoutModule,
|
||||
materializeReleaseCheckout,
|
||||
resolveBaselineReleaseRef
|
||||
} from './release-checkout'
|
||||
import { turnItemSkew } from './structured-agent-session-host-fixture'
|
||||
import {
|
||||
loadAgentSessionWireBuild,
|
||||
WORKING_TREE,
|
||||
type AgentSessionWireBuild,
|
||||
type RpcClientIdentity,
|
||||
type RpcReply
|
||||
} from './versioned-agent-session-wire'
|
||||
|
||||
// Why: a cold CI run extracts the baseline checkout before the first pairing.
|
||||
const SUITE_TIMEOUT_MS = 180_000
|
||||
|
||||
const WORKTREE = 'wt-cross-version'
|
||||
const TERMINAL_TAB = 'tab-1::leaf-1'
|
||||
const CODEX_TAB = 'agent-session:session-codex'
|
||||
const CLAUDE_TAB = 'agent-session:session-claude'
|
||||
const CODEX_SESSION = 'session-codex'
|
||||
const CLAUDE_SESSION = 'session-claude'
|
||||
const CODEX_TAB_TITLE = 'Codex Chat'
|
||||
const CLAUDE_TAB_TITLE = 'Claude Chat'
|
||||
const LIST_METHOD = 'session.tabs.list'
|
||||
const SUBSCRIBE_METHOD = 'session.tabs.subscribe'
|
||||
const CLOSE_METHOD = 'session.tabs.close'
|
||||
|
||||
const PROJECTION_MODULE = '/src/main/runtime/rpc/methods/session-tab-agent-status-projection.ts'
|
||||
|
||||
/** The copy a host substitutes when a client cannot render the chat behind a row. Read per build,
|
||||
* because it is the host's own string: a newer host may reword it, and that is not a break. */
|
||||
type FallbackTabTitles = { update: string; desktopOnly: string }
|
||||
|
||||
/**
|
||||
* One worktree carrying durable work of three kinds: a terminal, a codex chat and a claude chat,
|
||||
* laid out across two groups so the focus and layout repair a withheld row forces is exercised
|
||||
* rather than assumed. The chat holds focus, so a client that cannot see it must be handed some
|
||||
* other active tab or it selects into nothing.
|
||||
*/
|
||||
function hostSnapshot(): RuntimeMobileSessionTabsResult {
|
||||
return {
|
||||
worktree: WORKTREE,
|
||||
publicationEpoch: 'epoch-1',
|
||||
snapshotVersion: 7,
|
||||
activeGroupId: 'group-chat',
|
||||
activeTabId: CODEX_TAB,
|
||||
activeTabType: 'agent-session',
|
||||
tabGroups: [
|
||||
{ id: 'group-terminal', activeTabId: TERMINAL_TAB, tabOrder: [TERMINAL_TAB] },
|
||||
{
|
||||
id: 'group-chat',
|
||||
activeTabId: CODEX_TAB,
|
||||
tabOrder: [CODEX_TAB, CLAUDE_TAB],
|
||||
recentTabIds: [CODEX_TAB, CLAUDE_TAB]
|
||||
}
|
||||
],
|
||||
tabGroupLayout: {
|
||||
type: 'split',
|
||||
direction: 'horizontal',
|
||||
first: { type: 'leaf', groupId: 'group-terminal' },
|
||||
second: { type: 'leaf', groupId: 'group-chat' }
|
||||
},
|
||||
tabs: [
|
||||
{
|
||||
type: 'terminal',
|
||||
id: TERMINAL_TAB,
|
||||
parentTabId: 'tab-1',
|
||||
leafId: 'leaf-1',
|
||||
title: 'Terminal',
|
||||
status: 'ready',
|
||||
terminal: 'pty-1',
|
||||
isActive: false
|
||||
},
|
||||
{
|
||||
type: 'agent-session',
|
||||
id: CODEX_TAB,
|
||||
title: CODEX_TAB_TITLE,
|
||||
sessionId: CODEX_SESSION,
|
||||
agent: 'codex',
|
||||
isActive: true
|
||||
},
|
||||
{
|
||||
type: 'agent-session',
|
||||
id: CLAUDE_TAB,
|
||||
title: CLAUDE_TAB_TITLE,
|
||||
sessionId: CLAUDE_SESSION,
|
||||
agent: 'claude',
|
||||
isActive: false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
type SessionTabsRuntimeStub = {
|
||||
runtime: unknown
|
||||
/** What the host itself holds. A projection is a per-connection view; if this ever changes,
|
||||
* one client's capabilities have edited another client's durable work. */
|
||||
published: RuntimeMobileSessionTabsResult
|
||||
/** Push a host-side change to every live subscriber, as a real tab mutation does. */
|
||||
emitChange: (next: RuntimeMobileSessionTabsResult) => void
|
||||
closed: string[]
|
||||
restoreCalls: number
|
||||
}
|
||||
|
||||
function sessionTabsRuntimeStub(): SessionTabsRuntimeStub {
|
||||
const listeners = new Set<(snapshot: RuntimeMobileSessionTabsResult) => void>()
|
||||
const cleanups = new Map<string, () => void>()
|
||||
const stub: SessionTabsRuntimeStub = {
|
||||
runtime: null,
|
||||
published: hostSnapshot(),
|
||||
emitChange: () => {},
|
||||
closed: [],
|
||||
restoreCalls: 0
|
||||
}
|
||||
stub.emitChange = (next) => {
|
||||
stub.published = next
|
||||
for (const listener of Array.from(listeners)) {
|
||||
listener(next)
|
||||
}
|
||||
}
|
||||
stub.runtime = {
|
||||
getRuntimeId: () => 'runtime-1',
|
||||
getClientSettings: () => ({ experimentalStructuredNativeChat: true }),
|
||||
recordFeatureInteraction: () => {},
|
||||
restoreStructuredAgentSessionTabs: async () => {
|
||||
stub.restoreCalls += 1
|
||||
},
|
||||
listMobileSessionTabs: async () => stub.published,
|
||||
onMobileSessionTabsChanged: (listener: (snapshot: RuntimeMobileSessionTabsResult) => void) => {
|
||||
listeners.add(listener)
|
||||
return () => listeners.delete(listener)
|
||||
},
|
||||
closeMobileSessionTab: async (_worktree: string, tabId: string) => {
|
||||
stub.closed.push(tabId)
|
||||
return { closed: true }
|
||||
},
|
||||
refuseUnattributedMobileSessionTabClose: async (_worktree: string, tabId: string) => {
|
||||
stub.closed.push(tabId)
|
||||
return { refused: true, refusalReason: 'missing-intent' }
|
||||
},
|
||||
registerSubscriptionCleanup: (id: string, cleanup: () => void) => cleanups.set(id, cleanup),
|
||||
cleanupSubscription: (id: string) => {
|
||||
cleanups.get(id)?.()
|
||||
cleanups.delete(id)
|
||||
},
|
||||
cleanupSubscriptionsByPrefix: (prefix: string) => {
|
||||
for (const [id, cleanup] of cleanups) {
|
||||
if (id.startsWith(prefix)) {
|
||||
cleanup()
|
||||
cleanups.delete(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return stub
|
||||
}
|
||||
|
||||
let baselineRef: string
|
||||
let current: AgentSessionWireBuild
|
||||
let baseline: AgentSessionWireBuild
|
||||
let fallbackTitles: Map<string, FallbackTabTitles>
|
||||
|
||||
/** Read a build's own fallback copy from its own source, never from this file. */
|
||||
async function loadFallbackTabTitles(ref: string): Promise<FallbackTabTitles> {
|
||||
const module =
|
||||
ref === WORKING_TREE
|
||||
? ((await import('../../../src/main/runtime/rpc/methods/session-tab-agent-status-projection')) as unknown as Record<
|
||||
string,
|
||||
unknown
|
||||
>)
|
||||
: await importReleaseCheckoutModule(await materializeReleaseCheckout(ref), PROJECTION_MODULE)
|
||||
const update = module.STRUCTURED_CHAT_UPDATE_REQUIRED_TAB_TITLE
|
||||
const desktopOnly = module.CLAUDE_STRUCTURED_CHAT_DESKTOP_ONLY_TAB_TITLE
|
||||
if (typeof update !== 'string' || typeof desktopOnly !== 'string') {
|
||||
throw new Error(`Build ${ref} publishes no structured-chat fallback tab titles`)
|
||||
}
|
||||
return { update, desktopOnly }
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
baselineRef = resolveBaselineReleaseRef()
|
||||
current = await loadAgentSessionWireBuild(WORKING_TREE)
|
||||
baseline = await loadAgentSessionWireBuild(baselineRef)
|
||||
fallbackTitles = new Map([
|
||||
[current.label, await loadFallbackTabTitles(WORKING_TREE)],
|
||||
[baseline.label, await loadFallbackTabTitles(baselineRef)]
|
||||
])
|
||||
}, SUITE_TIMEOUT_MS)
|
||||
|
||||
/** Every host build the channel has to work against, named for the failure message. */
|
||||
function hostBuilds(): AgentSessionWireBuild[] {
|
||||
return [current, baseline]
|
||||
}
|
||||
|
||||
/**
|
||||
* C0 — what a paired client too old to read structured chat advertises: the baseline's own list
|
||||
* minus the structured strings. Derived rather than copied, so the day a release ships the desktop
|
||||
* advertisement this list still describes a client that lacks it instead of quietly becoming C1.
|
||||
*/
|
||||
function c0(): string[] {
|
||||
return baseline.capabilities.filter(
|
||||
(capability) =>
|
||||
capability !== STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY &&
|
||||
capability !== CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY
|
||||
)
|
||||
}
|
||||
|
||||
/** C1 — the reader advertisement itself, which admits codex rows and nothing else. */
|
||||
function c1(): string[] {
|
||||
return [...c0(), STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY]
|
||||
}
|
||||
|
||||
function c1WithClaudeReader(): string[] {
|
||||
return [...c1(), CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY]
|
||||
}
|
||||
|
||||
async function callBuild(
|
||||
build: AgentSessionWireBuild,
|
||||
method: string,
|
||||
params: unknown,
|
||||
client: RpcClientIdentity,
|
||||
runtime: unknown
|
||||
): Promise<RpcReply[]> {
|
||||
const replies: RpcReply[] = []
|
||||
await build
|
||||
.createDispatcher(runtime)
|
||||
.dispatchStreaming(
|
||||
{ id: `request-${method}`, authToken: 'cross-version-token', method, params },
|
||||
(raw) => replies.push(JSON.parse(raw) as RpcReply),
|
||||
client
|
||||
)
|
||||
return replies
|
||||
}
|
||||
|
||||
function runtimeClient(clientCapabilities: readonly string[]): RpcClientIdentity {
|
||||
return { clientKind: 'runtime', clientCapabilities, connectionId: 'connection-1' }
|
||||
}
|
||||
|
||||
function mobileClient(clientCapabilities: readonly string[]): RpcClientIdentity {
|
||||
return { clientKind: 'mobile', clientCapabilities, connectionId: 'connection-1' }
|
||||
}
|
||||
|
||||
async function listTabs(
|
||||
build: AgentSessionWireBuild,
|
||||
stub: SessionTabsRuntimeStub,
|
||||
client: RpcClientIdentity
|
||||
): Promise<RuntimeMobileSessionTabsResult> {
|
||||
const replies = await callBuild(build, LIST_METHOD, { worktree: WORKTREE }, client, stub.runtime)
|
||||
expect(replies, `${build.label}: ${LIST_METHOD} must answer exactly once`).toHaveLength(1)
|
||||
expect(replies[0], `${build.label}: ${LIST_METHOD} was refused`).toMatchObject({ ok: true })
|
||||
return replies[0]!.result as RuntimeMobileSessionTabsResult
|
||||
}
|
||||
|
||||
/** Open a live subscription and keep collecting its frames as the host emits. */
|
||||
async function subscribeTabs(
|
||||
build: AgentSessionWireBuild,
|
||||
stub: SessionTabsRuntimeStub,
|
||||
client: RpcClientIdentity
|
||||
): Promise<RpcReply[]> {
|
||||
const replies = await callBuild(
|
||||
build,
|
||||
SUBSCRIBE_METHOD,
|
||||
{ worktree: WORKTREE },
|
||||
client,
|
||||
stub.runtime
|
||||
)
|
||||
expect(replies, `${build.label}: ${SUBSCRIBE_METHOD} opened with no frame`).not.toHaveLength(0)
|
||||
return replies
|
||||
}
|
||||
|
||||
function frameTabs(reply: RpcReply | undefined): RuntimeMobileSessionClientTab[] {
|
||||
return (reply?.result as RuntimeMobileSessionTabsResult | undefined)?.tabs ?? []
|
||||
}
|
||||
|
||||
function tabIds(payload: { tabs: RuntimeMobileSessionClientTab[] }): string[] {
|
||||
return payload.tabs.map((tab) => tab.id)
|
||||
}
|
||||
|
||||
function titlesFor(build: AgentSessionWireBuild): FallbackTabTitles {
|
||||
const titles = fallbackTitles.get(build.label)
|
||||
if (!titles) {
|
||||
throw new Error(`No fallback titles loaded for ${build.label}`)
|
||||
}
|
||||
return titles
|
||||
}
|
||||
|
||||
describe('cross-version session-tab sync', () => {
|
||||
it(
|
||||
'skews current code against a real published release',
|
||||
() => {
|
||||
expect(baselineRef).toMatch(/^v?\d/)
|
||||
expect(baseline.revision).toMatch(/^[0-9a-f]{40}$/)
|
||||
expect(baseline.revision).not.toBe(current.revision)
|
||||
// Anti-vacuous: every claim below is about a method both builds really register, so a
|
||||
// registry that failed to load would fail here rather than pass as "withheld".
|
||||
for (const build of hostBuilds()) {
|
||||
for (const method of [LIST_METHOD, SUBSCRIBE_METHOD, CLOSE_METHOD]) {
|
||||
expect(build.methodNames, `${build.label} registers ${method}`).toContain(method)
|
||||
}
|
||||
}
|
||||
// The channel is additive: bumping the protocol number would strand every paired device
|
||||
// on this release rather than degrade one row.
|
||||
expect(current.protocolVersion).toBe(baseline.protocolVersion)
|
||||
},
|
||||
SUITE_TIMEOUT_MS
|
||||
)
|
||||
|
||||
describe('a paired client that never advertised structured chat', () => {
|
||||
let stub: SessionTabsRuntimeStub
|
||||
|
||||
beforeEach(() => {
|
||||
stub = sessionTabsRuntimeStub()
|
||||
})
|
||||
|
||||
it('is served the worktree with its structured rows withheld, by either build', async () => {
|
||||
// Anti-vacuous: the old client still advertises a real list, so what follows is the
|
||||
// capability gate answering rather than an empty negotiation.
|
||||
expect(c0().length).toBeGreaterThan(0)
|
||||
expect(c0()).not.toContain(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY)
|
||||
for (const build of hostBuilds()) {
|
||||
const projected = await listTabs(build, stub, runtimeClient(c0()))
|
||||
expect(tabIds(projected), `${build.label} withholds both chats`).toEqual([TERMINAL_TAB])
|
||||
// Focus followed the withheld row, so the repair is the difference between a usable
|
||||
// client and one selecting into a pane that renders neither chat nor terminal.
|
||||
expect(projected.activeTabId, `${build.label} repairs focus`).toBe(TERMINAL_TAB)
|
||||
expect(projected.activeTabType).toBe('terminal')
|
||||
expect(projected.activeGroupId).toBe('group-terminal')
|
||||
expect(projected.tabs[0]?.isActive).toBe(true)
|
||||
expect(projected.tabGroups?.map((group) => group.id)).toEqual(['group-terminal'])
|
||||
expect(projected.tabGroupLayout).toEqual({ type: 'leaf', groupId: 'group-terminal' })
|
||||
}
|
||||
})
|
||||
|
||||
it('is withheld the rows without the host losing them', async () => {
|
||||
for (const build of hostBuilds()) {
|
||||
await listTabs(build, stub, runtimeClient(c0()))
|
||||
}
|
||||
// The one thing a projection must never do: a per-connection view is not a deletion, and a
|
||||
// client that cannot read a chat must not be why the host stops holding it.
|
||||
expect(stub.published).toEqual(hostSnapshot())
|
||||
const stillCapable = await listTabs(current, stub, runtimeClient(c1()))
|
||||
expect(tabIds(stillCapable)).toEqual([TERMINAL_TAB, CODEX_TAB])
|
||||
})
|
||||
|
||||
it('cannot destroy a row it was never shown, on either build', async () => {
|
||||
for (const build of hostBuilds()) {
|
||||
const replies = await callBuild(
|
||||
build,
|
||||
CLOSE_METHOD,
|
||||
{ worktree: WORKTREE, tabId: CODEX_TAB, reason: 'user' },
|
||||
runtimeClient(c0()),
|
||||
stub.runtime
|
||||
)
|
||||
expect(replies, `${build.label}: ${CLOSE_METHOD} must answer exactly once`).toHaveLength(1)
|
||||
// A refusal, not silence and not a close: the client has to be able to tell that its
|
||||
// view is narrower than the host's rather than retry a vanished tab forever.
|
||||
expect(replies[0], `${build.label} refuses the unseen close`).toMatchObject({
|
||||
ok: false,
|
||||
error: { message: expect.stringContaining('tab_not_found') }
|
||||
})
|
||||
}
|
||||
expect(stub.closed).toEqual([])
|
||||
expect(stub.published).toEqual(hostSnapshot())
|
||||
})
|
||||
|
||||
it('keeps its live subscription projected on updates, not only on the opening frame', async () => {
|
||||
for (const build of hostBuilds()) {
|
||||
const frames = await subscribeTabs(build, stub, runtimeClient(c0()))
|
||||
expect(frameTabs(frames[0]).map((tab) => tab.id)).toEqual([TERMINAL_TAB])
|
||||
const renamed = hostSnapshot()
|
||||
renamed.tabs[1] = { ...renamed.tabs[1]!, title: 'Codex Chat (renamed)' }
|
||||
stub.emitChange(renamed)
|
||||
// A projection applied only to the opening snapshot leaks the row on the very next
|
||||
// title tick, and the leak is invisible to any test that only opens a subscription.
|
||||
expect(frames, `${build.label} pushed no update`).toHaveLength(2)
|
||||
expect(frames[1]?.result).toMatchObject({ type: 'updated' })
|
||||
expect(frameTabs(frames[1]).map((tab) => tab.id)).toEqual([TERMINAL_TAB])
|
||||
stub.emitChange(hostSnapshot())
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('a paired client advertising the structured reader', () => {
|
||||
let stub: SessionTabsRuntimeStub
|
||||
|
||||
beforeEach(() => {
|
||||
stub = sessionTabsRuntimeStub()
|
||||
})
|
||||
|
||||
it('is published the codex row verbatim by either build', async () => {
|
||||
for (const build of hostBuilds()) {
|
||||
const projected = await listTabs(build, stub, runtimeClient(c1()))
|
||||
expect(tabIds(projected), `${build.label} publishes the codex chat`).toEqual([
|
||||
TERMINAL_TAB,
|
||||
CODEX_TAB
|
||||
])
|
||||
const codex = projected.tabs.find((tab) => tab.id === CODEX_TAB)
|
||||
// Verbatim, not merely present: a reader that is handed a substituted title has been
|
||||
// told to update while being given the thing it can read.
|
||||
expect(codex, `${build.label} publishes the codex row unchanged`).toMatchObject({
|
||||
type: 'agent-session',
|
||||
title: CODEX_TAB_TITLE,
|
||||
sessionId: CODEX_SESSION,
|
||||
agent: 'codex'
|
||||
})
|
||||
expect(projected.activeTabId).toBe(CODEX_TAB)
|
||||
expect(projected.activeTabType).toBe('agent-session')
|
||||
}
|
||||
})
|
||||
|
||||
it('still receives no claude row until it says it can read one', async () => {
|
||||
for (const build of hostBuilds()) {
|
||||
const projected = await listTabs(build, stub, runtimeClient(c1()))
|
||||
expect(tabIds(projected), `${build.label} withholds the claude chat`).not.toContain(
|
||||
CLAUDE_TAB
|
||||
)
|
||||
const both = await listTabs(build, stub, runtimeClient(c1WithClaudeReader()))
|
||||
expect(tabIds(both), `${build.label} publishes both chats`).toEqual([
|
||||
TERMINAL_TAB,
|
||||
CODEX_TAB,
|
||||
CLAUDE_TAB
|
||||
])
|
||||
expect(both.tabs.find((tab) => tab.id === CLAUDE_TAB)).toMatchObject({
|
||||
title: CLAUDE_TAB_TITLE,
|
||||
sessionId: CLAUDE_SESSION
|
||||
})
|
||||
}
|
||||
expect(stub.published).toEqual(hostSnapshot())
|
||||
})
|
||||
|
||||
it('finds a session the host already had, with nothing republished for it', async () => {
|
||||
for (const build of hostBuilds()) {
|
||||
const beforeAdvertisement = await listTabs(build, stub, runtimeClient(c0()))
|
||||
expect(tabIds(beforeAdvertisement)).toEqual([TERMINAL_TAB])
|
||||
const afterAdvertisement = await listTabs(build, stub, runtimeClient(c1()))
|
||||
// The advertisement is the only thing that moved: same host, same record, same
|
||||
// publication epoch — so a client turning the reader on resumes into work that was
|
||||
// already there rather than waiting for the next host-side change to surface it.
|
||||
expect(tabIds(afterAdvertisement)).toEqual([TERMINAL_TAB, CODEX_TAB])
|
||||
expect(afterAdvertisement.publicationEpoch).toBe(beforeAdvertisement.publicationEpoch)
|
||||
expect(afterAdvertisement.snapshotVersion).toBe(beforeAdvertisement.snapshotVersion)
|
||||
}
|
||||
expect(stub.restoreCalls).toBeGreaterThan(0)
|
||||
expect(stub.published).toEqual(hostSnapshot())
|
||||
})
|
||||
})
|
||||
|
||||
describe('a mobile client that cannot render the chat behind the row', () => {
|
||||
let stub: SessionTabsRuntimeStub
|
||||
|
||||
beforeEach(() => {
|
||||
stub = sessionTabsRuntimeStub()
|
||||
})
|
||||
|
||||
it('keeps a metadata-only row under the fallback title its own build publishes', async () => {
|
||||
for (const build of hostBuilds()) {
|
||||
const titles = titlesFor(build)
|
||||
const projected = await listTabs(build, stub, mobileClient(c0()))
|
||||
// Nothing removed: the phone user was hunting for a chat the desktop insisted existed.
|
||||
expect(tabIds(projected), `${build.label} keeps every row for mobile`).toEqual([
|
||||
TERMINAL_TAB,
|
||||
CODEX_TAB,
|
||||
CLAUDE_TAB
|
||||
])
|
||||
expect(projected.tabs.find((tab) => tab.id === CODEX_TAB)).toMatchObject({
|
||||
title: titles.update,
|
||||
sessionId: CODEX_SESSION,
|
||||
agent: 'codex'
|
||||
})
|
||||
expect(projected.tabs.find((tab) => tab.id === CLAUDE_TAB)).toMatchObject({
|
||||
title: titles.desktopOnly,
|
||||
sessionId: CLAUDE_SESSION,
|
||||
agent: 'claude'
|
||||
})
|
||||
// Withheld content, not a corrupted row: the substitution replaces copy and nothing else.
|
||||
expect(projected.activeTabId).toBe(CODEX_TAB)
|
||||
expect(titles.update).not.toBe(CODEX_TAB_TITLE)
|
||||
expect(titles.desktopOnly).not.toBe(CLAUDE_TAB_TITLE)
|
||||
}
|
||||
})
|
||||
|
||||
it('is refused when it tries to close a row it can see but not read', async () => {
|
||||
for (const build of hostBuilds()) {
|
||||
const replies = await callBuild(
|
||||
build,
|
||||
CLOSE_METHOD,
|
||||
{ worktree: WORKTREE, tabId: CODEX_TAB, reason: 'user' },
|
||||
mobileClient(c0()),
|
||||
stub.runtime
|
||||
)
|
||||
expect(replies, `${build.label}: ${CLOSE_METHOD} must answer exactly once`).toHaveLength(1)
|
||||
expect(replies[0], `${build.label} refuses the mobile close`).toMatchObject({
|
||||
ok: false,
|
||||
error: { message: expect.stringContaining('structured_agent_session_unsupported') }
|
||||
})
|
||||
}
|
||||
// The row is visible to this client, so only the destructive gate stands between a
|
||||
// fallback title and a client closing durable work it was never able to open.
|
||||
expect(stub.closed).toEqual([])
|
||||
expect(stub.published).toEqual(hostSnapshot())
|
||||
})
|
||||
})
|
||||
|
||||
describe('the turn item rides the same projection', () => {
|
||||
let stub: SessionTabsRuntimeStub
|
||||
|
||||
beforeEach(() => {
|
||||
stub = sessionTabsRuntimeStub()
|
||||
turnItemSkew.install(CODEX_SESSION, WORKTREE)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
setStructuredAgentSessionHost(null)
|
||||
})
|
||||
|
||||
it('publishes the row to a reader that predates the turn item, and downgrades the item', async () => {
|
||||
for (const [clientCapabilities, item] of turnItemSkew.clients(baseline, current)) {
|
||||
const client = runtimeClient(clientCapabilities)
|
||||
const projected = await listTabs(current, stub, client)
|
||||
expect(tabIds(projected)).toContain(CODEX_TAB)
|
||||
const replies = await callBuild(
|
||||
current,
|
||||
'agentSession.history',
|
||||
{ sessionId: CODEX_SESSION, direction: 'tail' },
|
||||
client,
|
||||
stub.runtime
|
||||
)
|
||||
// Two independent gates on one connection: advertising that you can read the row says
|
||||
// nothing about the item bodies inside it, and a host that collapsed them would publish
|
||||
// an unknown kind to a client whose tab it had just decided to show.
|
||||
expect(replies[0]).toMatchObject({ ok: true, result: { page: { items: [item] } } })
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user