Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
98a2e27
fix(native-chat): report a failed startup chat reconcile instead of f…
brennanb2025 Sep 30, 2026
872f31f
fix(native-chat): read restored chats without waiting on lease bookke…
brennanb2025 Sep 30, 2026
e5d1ab2
fix(native-chat): list chat tabs without opening every chat, and stop…
brennanb2025 Sep 30, 2026
d9571de
Merge remote-tracking branch 'origin/brennanb2025/records-readonly-st…
brennanb2025 Sep 30, 2026
b23eb28
fix(native-chat): keep every record-store write off the startup chat …
brennanb2025 Sep 30, 2026
f3ec1a1
fix(native-chat): start each restore pass from one lease check and st…
brennanb2025 Sep 30, 2026
25fffab
Merge remote-tracking branch 'origin/brennanb2025/records-readonly-st…
brennanb2025 Sep 30, 2026
a77e824
docs(native-chat): correct restore comments and a test name to match …
brennanb2025 Sep 30, 2026
2a030d0
fix(native-chat): keep the post-list chat restore to the chats still …
brennanb2025 Sep 30, 2026
855be2f
fix(native-chat): start the chat history restore after the tab list h…
brennanb2025 Sep 30, 2026
ba777ae
Merge remote-tracking branch 'origin/brennanb2025/records-readonly-st…
brennanb2025 Sep 30, 2026
bf3aebe
fix(native-chat): wait for the startup lease check again, and drop th…
brennanb2025 Sep 30, 2026
ebdc5b9
fix(native-chat): start the chat history restore once the tab list's …
brennanb2025 Sep 30, 2026
c3af299
test(native-chat): give the rest rig's and T3's dispatch waits a 10 s…
brennanb2025 Sep 30, 2026
0cbeb14
fix(native-chat): derive /clear replacements once per tab listing, no…
brennanb2025 Sep 30, 2026
4768d53
fix(native-chat): derive /clear replacements right before each listin…
brennanb2025 Sep 30, 2026
316d7cc
fix(native-chat): restore chat history after the tab list one chat at…
brennanb2025 Sep 30, 2026
4e5c839
Merge origin/main into brennanb2025/records-readonly-startup
brennanb2025 Sep 30, 2026
1df09d5
fix(native-chat): keep a throwing failure sink from failing the start…
brennanb2025 Sep 30, 2026
0a8bf77
test(native-chat): a send during a slow startup lease check starts it…
brennanb2025 Sep 30, 2026
c1a60fe
Merge remote-tracking branch 'origin/brennanb2025/records-readonly-st…
brennanb2025 Sep 30, 2026
e53a552
Merge remote-tracking branch 'origin/main' into brennanb2025/chat-tab…
brennanb2025 Sep 30, 2026
531c031
fix(native-chat): keep the runtime-id module within its 400-line limit
brennanb2025 Sep 30, 2026
5c03e00
Merge remote-tracking branch 'origin/main' into brennanb2025/chat-tab…
brennanb2025 Sep 30, 2026
8014257
Merge remote-tracking branch 'origin/main' into brennanb2025/chat-tab…
brennanb2025 Sep 30, 2026
617b30e
test(native-chat): close the chat as the user does in the restore-aft…
brennanb2025 Sep 30, 2026
a696070
Merge remote-tracking branch 'origin/main' into brennanb2025/chat-tab…
brennanb2025 Sep 30, 2026
661984b
test(native-chat): read a quit's turn verdict the way the next launch…
brennanb2025 Oct 1, 2026
50f747f
Merge remote-tracking branch 'origin/main' into brennanb2025/chat-tab…
brennanb2025 Oct 2, 2026
5735ee9
chore: put back main's orcad files the merge commit's formatter rewrote
brennanb2025 Oct 2, 2026
8f2d61a
Merge main into chat-tabs-list-without-replay
brennanb2025 Oct 3, 2026
d1bf8ae
fix(native-chat): the runtime-id module stays within its 400-line lim…
brennanb2025 Oct 3, 2026
00cdc2c
Merge main into chat-tabs-list-without-replay
brennanb2025 Oct 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -116,14 +116,34 @@ async function runningTurn(): Promise<AgentSessionStatusEvent[]> {
}

/** What the settle wrote, read back from the journal the next reader opens. */
async function settledTurn() {
await host.restoreReadableSessions([SESSION])
const { items } = await host.journalSnapshot(SESSION)
async function settledTurn(reader = host) {
await reader.restoreReadableSessions([SESSION])
const { items } = await reader.journalSnapshot(SESSION)
const turn = items.map((item) => readAgentJournalTurn(item.body)).find(Boolean)
const [settled] = [...selectStructuredAgentSettledTurns(items).values()]
return { turn, settled }
}

/** What the next launch reads: a host that has begun quitting opens no chat. */
async function settledTurnAfterQuit() {
const state = hostTestState()
const reader = new StructuredAgentSessionHost({
logger: createStructuredAgentSessionLogger(),
store: state.store,
adapter: adapter(),
journalDatabase: openTestJournalHostDatabase(state.root),
recoveryCapsule: new AgentSessionRecoveryCapsule(state.root),
claimKeyId: 'key-1',
mintSpawnToken: () => 'spawn-a',
now: () => HOST_TEST_NOW
})
try {
return await settledTurn(reader)
} finally {
await reader.flushAllStreamedEvents()
}
}

function lastSummary(statuses: AgentSessionStatusEvent[]) {
const last = statuses.at(-1)
return last?.type === 'status' ? last.session : null
Expand Down Expand Up @@ -247,7 +267,7 @@ describe('a turn cut short by closing its provider', () => {
expect(statuses.findLast((event) => event.type === 'status')).toMatchObject({
session: { status: 'idle', turnOutcome: 'interruption' }
})
const { settled } = await settledTurn()
const { settled } = await settledTurnAfterQuit()
expect(
settled && describeNativeChatTurnStatus({ elapsedSeconds: 0, ...settled })
).toMatchObject({ key: 'failedAfter' })
Expand Down Expand Up @@ -309,7 +329,7 @@ describe('a turn cut short by closing its provider', () => {

await host.flushAllStreamedEvents({ trigger: 'quit' })

const { turn } = await settledTurn()
const { turn } = await settledTurnAfterQuit()
expect(turn).toMatchObject({ state: 'interrupted' })
expect(turn).not.toHaveProperty('outcome')
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types'
import { StructuredAgentSessionIdleSweep } from './structured-agent-session-idle-sweep'
import { AGENT_SESSION_NOT_ATTACHED } from './structured-agent-session-mutation-admission'
import { sessionTabListed } from './structured-agent-session-host-tabs'
import { adapterSupportsRecord } from './structured-agent-session-provider-support'
import { deferredStructuredAgentSessionLogger } from './structured-agent-session-logger'

Expand Down Expand Up @@ -64,11 +65,8 @@ export function createStructuredAgentSessionConversationLifetime(host: {
closeStructuredAgentSessionConversationUnderSerialize(
{
sessions,
closeStatus: (id) => {
const tabs = deps().store.getVisibleSessionTabIndex()
// A legacy store cannot say, so the row stays; restart is the boundary that forgets.
host.closeStatus(id, { listed: !tabs.present || tabs.sessionIds.includes(id) })
}
// A legacy store cannot say, so the row stays; restart is the boundary that forgets.
closeStatus: (id) => host.closeStatus(id, { listed: sessionTabListed(deps().store, id) })
},
sessionId
)
Expand Down Expand Up @@ -102,6 +100,7 @@ export function createStructuredAgentSessionConversationLifetime(host: {
return {
idleSweep,
stopAgent,
isDisposed: (): boolean => disposed,
/** Quit has begun: nothing opens a conversation or sweeps one after this. */
dispose: (): void => {
disposed = true
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store'
import type { StructuredAgentSessionLogger } from './structured-agent-session-logger'
import { adapterSupportsRecord } from './structured-agent-session-provider-support'

/**
* Chat-tab visibility is the deletion funnel: every path that removes a chat as a user-facing
Expand Down Expand Up @@ -37,37 +38,52 @@ export function setStructuredAgentSessionTabVisibility(
return host.deps.store.setSessionTabVisibility(sessionId, visible, tabId)
}

/** Whether the chat still has its tab. A legacy store with no tab index cannot say, so yes. */
export function sessionTabListed(
store: { getVisibleSessionTabIndex: () => { present: boolean; sessionIds: string[] } },
sessionId: string
): boolean {
const tabs = store.getVisibleSessionTabIndex()
return !tabs.present || tabs.sessionIds.includes(sessionId)
}

export type StructuredAgentSessionTab = {
sessionId: string
workspaceId: string
agent: AgentSessionRecord['provider']
}

export function listStructuredAgentSessionTabs(
sessions: ReadonlyMap<
string,
{ params: { location: { workspaceId: string }; provider: AgentSessionRecord['provider'] } }
>
/** Tabs from durable state alone: each requested id, once, in the order given, that has a record
* this host serves. Opens nothing: a chat whose history is unreadable keeps its tab and its read
* says why; one whose history is missing keeps its tab and reads empty. */
export function listPersistedSessionTabs(
deps: {
store: { getRecord: (sessionId: string) => AgentSessionRecord | null }
adapter: Parameters<typeof adapterSupportsRecord>[0]
},
sessionIds: readonly string[]
): StructuredAgentSessionTab[] {
return [...sessions.entries()].map(([sessionId, session]) => ({
sessionId,
workspaceId: session.params.location.workspaceId,
agent: session.params.provider
}))
const tabs = new Map<string, StructuredAgentSessionTab>()
for (const sessionId of sessionIds) {
const record = deps.store.getRecord(sessionId)
if (!record || tabs.has(sessionId) || !adapterSupportsRecord(deps.adapter, record)) {
continue
}
tabs.set(sessionId, {
sessionId,
workspaceId: record.location.workspaceId,
agent: record.provider
})
}
return [...tabs.values()]
}

type TabSessions = ReadonlyMap<
string,
{
child?: unknown
params: { location: { workspaceId: string }; provider: AgentSessionRecord['provider'] }
}
>
type TabSessions = ReadonlyMap<string, { child?: unknown }>

/** The host's chat-tab surface; reads `host.deps` per call, so it sees the host's wrapped deps. */
export function createStructuredAgentSessionTabSurface(
host: Parameters<typeof setStructuredAgentSessionTabVisibility>[0] & {
deps: {
deps: Parameters<typeof listPersistedSessionTabs>[0] & {
store: Pick<
AgentSessionRecordStore,
'getVisibleSessionTabIndex' | 'getSessionTabId' | 'showSessionTabs'
Expand All @@ -78,7 +94,8 @@ export function createStructuredAgentSessionTabSurface(
forgetStatus: (sessionId: string) => void
) {
return {
listSessionTabs: () => listStructuredAgentSessionTabs(sessions),
/** From the record store and the given tab ids; opens no conversation. */
listSessionTabs: (ids: readonly string[]) => listPersistedSessionTabs(host.deps, ids),
getPersistedVisibleSessionTabIndex: () => host.deps.store.getVisibleSessionTabIndex(),
getSessionTabId: (sessionId: string): string | null =>
host.deps.store.getSessionTabId(sessionId),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -592,7 +592,7 @@ describe('restart', () => {
const restoreReads = listRecords.mock.calls.length
await host.restoreReadableSessions()

expect(host.listSessionTabs()).toEqual([
expect(host.listSessionTabs([SESSION])).toEqual([
{ sessionId: SESSION, workspaceId: 'workspace-1', agent: 'codex' }
])
const history = await host.history({ sessionId: SESSION, direction: 'tail' })
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ export class StructuredAgentSessionHost {
resolveRecovery: (sessionId) => this.runtimeState.resolveRecovery(sessionId),
serialize: (sessionId, task) => this.serialize(sessionId, task),
hasSession: this.hasSession,
isDisposed: () => this.lifetime.isDisposed(),
// Site 10: cannot overwrite a live entry — the restorer returns early on
// `hasSession` inside the same serialized step as this `set`.
onReadable: this.conversationDelivery.adoptOpened
Expand Down Expand Up @@ -233,8 +234,7 @@ export class StructuredAgentSessionHost {

reconcileRestartLeases = (): Promise<void> => this.restore.reconcileRestartLeases()

restoreReadableSessions = (sessionIds?: readonly string[]): Promise<void> =>
this.restore.restoreReadableSessions(sessionIds)
restoreReadableSessions = (ids?: readonly string[]) => this.restore.restoreReadableSessions(ids)

/** Make one persisted session addressable again; see `structured-agent-session-reveal`. */
revealSession = (sessionId: string): Promise<StructuredAgentSessionReveal> =>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
// A restarted host lists a chat's tab from its record and the tab table, never from whether its
// history opened: a chat with no history on disk, and one whose history cannot be read, both keep
// their tab.

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { isAgentSessionRefusalError } from '../../../shared/agent-session-wire-refusals'
import { closeTestJournalHostDatabases } from '../agent-session-journal/journal-host-database-test-support'
import {
createRestTestRig,
restTestChat,
sendRestTestMessage,
type RestTestRig
} from './structured-agent-session-rest-test-rig'
import { restTestOpens } from './structured-agent-session-rest-test-observations'

let rig: RestTestRig

beforeEach(async () => {
rig = await createRestTestRig()
})

afterEach(async () => {
await rig.dispose()
closeTestJournalHostDatabases()
vi.restoreAllMocks()
})

const tab = (sessionId: string, workspaceId = 'workspace-1') => ({
sessionId,
workspaceId,
agent: 'codex'
})

describe('the tab list after a restart', () => {
it('lists every chat it is given without opening one', async () => {
await restTestChat(rig, 'session-a', { message: 'one' })
await restTestChat(rig, 'session-b', { message: 'two' })
await rig.crash()
const host = await rig.boot()

expect(host.listSessionTabs(['session-a', 'session-b'])).toEqual([
tab('session-a'),
tab('session-b')
])
expect(rig.adapter.historyFilePath).not.toHaveBeenCalled()
})

it('keeps a chat with no history on disk, which then reads empty and takes a send (T3)', async () => {
// Its first start failed before the conversation opened, so no history was ever written.
rig.adapter.acquire.mockRejectedValueOnce(new Error('spawn failed'))
vi.spyOn(console, 'warn').mockImplementation(() => undefined)
await expect(restTestChat(rig, 'session-new')).rejects.toThrow(/attach refused/)
await rig.store.setSessionTabVisibility('session-new', true)
await restTestChat(rig, 'session-old', { message: 'hello' })
await rig.crash()
const host = await rig.boot()
const ids = ['session-new', 'session-old']

await host.restoreReadableSessions(ids)

expect(host.listSessionTabs(ids)).toEqual([tab('session-new'), tab('session-old')])
// The restore founds no history for it; the tab stands anyway.
expect(restTestOpens(rig, 'session-new')).toBe(0)
const page = await host.history({ sessionId: 'session-new', direction: 'tail' })
expect(page.ok && page.page.items).toEqual([])
const sent = await sendRestTestMessage(rig, 'session-new', 'first words')
expect(sent).toMatchObject({ ok: true })
await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledOnce(), { timeout: 10_000 })
const after = await host.history({ sessionId: 'session-new', direction: 'tail' })
expect(JSON.stringify(after.ok && after.page)).toContain('first words')
})

it('keeps a chat whose history cannot be opened, and its read says why (T4)', async () => {
await restTestChat(rig, 'session-bad', { message: 'lost' })
await restTestChat(rig, 'session-good', { message: 'kept' })
await rig.crash()
const host = await rig.boot()
rig.adapter.historyFilePath.mockImplementation(async (sessionId) => {
if (sessionId === 'session-bad') {
throw new Error('EACCES: permission denied')
}
return null
})
vi.spyOn(console, 'warn').mockImplementation(() => undefined)
const ids = ['session-bad', 'session-good']

await host.restoreReadableSessions(ids).catch(() => undefined)

expect(host.listSessionTabs(ids)).toEqual([tab('session-bad'), tab('session-good')])
const read = await host.history({ sessionId: 'session-bad', direction: 'tail' }).then(
() => null,
(error: unknown) => error
)
expect(isAgentSessionRefusalError(read) && read.refusal.code).toBe(
'agent_session_journal_unreadable'
)
})

it('lists in the order it is given, once each, whatever order the records are in (T7)', async () => {
await restTestChat(rig, 'session-c')
await restTestChat(rig, 'session-a')
await restTestChat(rig, 'session-b')
await rig.crash()
const host = await rig.boot()

expect(host.listSessionTabs(['session-b', 'session-c', 'session-a', 'session-b'])).toEqual([
tab('session-b'),
tab('session-c'),
tab('session-a')
])
})

it('lists no chat without a record, and none this host cannot serve (T10, regression guard)', async () => {
await restTestChat(rig, 'session-served', { message: 'here' })
await restTestChat(rig, 'session-gated', { workspaceId: 'workspace-gated', message: 'there' })
await rig.crash()
const host = await rig.boot()
rig.unsupportedWorkspaceIds.add('workspace-gated')
const ids = ['session-served', 'session-gated', 'session-missing']

await host.restoreReadableSessions(ids)

expect(host.listSessionTabs(ids)).toEqual([tab('session-served')])
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ describe('structured session provider restore', () => {

await restarted.restoreReadableSessions()

expect(restarted.listSessionTabs()).toEqual([
expect(restarted.listSessionTabs([HOST_TEST_SESSION])).toEqual([
{ sessionId: HOST_TEST_SESSION, workspaceId: 'workspace-1', agent: 'claude' }
])
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ describe('StructuredAgentSessionReadableRestorer', () => {
resolveRecovery: async () => true,
serialize: async (_sessionId, task) => task(),
hasSession: () => false,
isDisposed: () => false,
isListed: () => true,
onReadable: () => undefined
})

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// What a rest rig's run observed: how often each chat's journal opened, and the status rows its
// stream carried.

import type { RestTestRig } from './structured-agent-session-rest-test-rig'

export function restTestOpens(rig: RestTestRig, sessionId: string): number {
return rig.adapter.historyFilePath.mock.calls.filter(([id]) => id === sessionId).length
}

/** The newest row the status stream carried for a session. */
export function latestRestTestStatus(rig: RestTestRig, sessionId: string) {
for (const event of rig.statusEvents.toReversed()) {
if (event.type === 'status' && event.session.sessionId === sessionId) {
return event.session
}
if (event.type === 'snapshot') {
const found = event.sessions.find((session) => session.sessionId === sessionId)
if (found) {
return found
}
}
}
return undefined
}
Loading
Loading