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:
Merge Sim
2026-09-11 01:54:57 -07:00
parent 5fa62feda7
commit 06d5df3a42
3 changed files with 597 additions and 4 deletions
+1
View File
@@ -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
+36 -4
View File
@@ -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] } } })
}
})
})
})