fix(native-chat): name structured Codex chat tabs from their own session

Structured app-server chat tabs kept the generic "Codex Chat" label while an
equivalent terminal-backed Codex tab picked up the name Codex writes for the
thread, so several open chats were indistinguishable.

Routes structured chats into the existing AI Vault tab-title pipeline rather
than adding a second title source: the host publishes the session's Codex
thread id (`RuntimeMobileSessionAgentTab.providerSessionId`, the head of the
record's provider-handle chain) as identity only, and the renderer's existing
title sync resolves the name against the host that owns the session and writes
it to the same `aiVaultTitle` slot terminal Codex tabs use. Precedence,
refresh cadence, remote host routing and persistence therefore come from the
pipeline unchanged — a manual rename still outranks the provider name, and a
generated title still does not.

Absence stays unknown: the thread id is omitted until the provider proves one,
a late-proven thread reaches clients through a republished snapshot, and the
mirror now preserves the client-owned `customLabel` and `aiVaultTitle` it was
overwriting on every snapshot, which is what made a rename vanish and would
have blinked a named tab back to the generic label.
This commit is contained in:
Brennan Benson
2026-08-29 18:57:58 -07:00
committed by Merge Sim
parent 0a995cc30d
commit 4827f1b456
16 changed files with 706 additions and 16 deletions
@@ -242,3 +242,23 @@ predicate. It is unobservable today — the host publishes neither field for a c
all, so a mirror has nothing to take either way. If the capability-gated publish this section
anticipates ever lands, narrow them the same way rather than by placement kind: a mirror should
take a failure it cannot otherwise see, and only the hosting client should refuse it.
## Who names a structured chat tab
`RuntimeMobileSessionAgentTab.providerSessionId` is Rule 1: an optional field naming the Codex
thread the session currently writes to (the head of the record's provider-handle chain). An old
host omits it and the chat keeps the generic label; an old client ignores it. The field is
identity, not a name — the name is read from the provider by the AI Vault title pipeline on the
host that owns the session, which is why a remote structured chat resolves against that host's
Codex home rather than the client's.
Absence is unknown, never "no thread". The host publishes the key only once the provider has
proven a handle, and a blank string would ask the title pipeline to name a conversation that has
no name yet. A late-proven thread reaches clients through a republished snapshot, so the tab is
named without waiting for a restart.
The name itself is client-owned, the same shape as the browser-tab carve-out above. A host
snapshot carries no `customLabel` and no `aiVaultTitle`, so the mirror in `web-session-tabs-sync.ts`
preserves both from the tab it is replacing. Dropping either one renames the tab back to the
generic label on the next snapshot and takes the user's own rename with it. The cross-version
harness does not exercise the session-tab sync channel, so nothing fails if this is forgotten.
@@ -1,3 +1,4 @@
import { agentSessionProviderHandleChainHead } from '../../../shared/agent-session-provider-handle'
import type { AgentSessionLease, AgentSessionRecord } from '../../../shared/agent-session-record'
export type StructuredProviderSessionOwnership = {
@@ -22,3 +23,16 @@ export function listStructuredProviderSessionOwnership(
}))
)
}
/**
* Provider conversation the session currently writes to, or null while the provider has not proven
* one. Only the chain head counts: an earlier link names a conversation this session has moved on
* from, and a fork's root is a different conversation entirely.
*/
export function headStructuredProviderSessionId(record: AgentSessionRecord): string | null {
const handle = agentSessionProviderHandleChainHead(record.providerHandleChain)?.handle
if (!handle) {
return null
}
return handle.provider === 'codex' ? handle.threadId : handle.sessionId
}
@@ -124,6 +124,7 @@ describe('structured session cold restoration', () => {
internal.refreshMobileSessionPtyRecords = async () => new Set()
internal.ensureStructuredAgentSessionHost = async () => undefined
setStructuredAgentSessionHost({
deps: { store: { getRecord: () => null } },
reconcileRestartLeases: async () => undefined,
restoreReadableSessions: async () => undefined,
listSessionTabs: () => [
+49
View File
@@ -288,6 +288,7 @@ import { shouldForwardHeadlessTerminalQueryReply } from './headless-terminal-que
import type { TerminalRevealIdentity } from '../../shared/terminal-reveal-identity'
import { structuredAgentSessionTabId } from '../../shared/structured-agent-session-projection'
import { collectSavedStructuredAgentSessionIds } from './saved-structured-agent-session-restoration'
import { headStructuredProviderSessionId } from '../native-chat/agent-session-wire/structured-provider-session-ownership'
import type {
OrchestrationCompatibilityEvidence,
OrchestrationCompatibilityHostStamp
@@ -12346,6 +12347,12 @@ export class OrcaRuntimeService {
}
}
/** Codex thread behind a structured session, or null while the provider has not proven one. */
private structuredAgentSessionProviderSessionId(sessionId: string): string | null {
const record = getStructuredAgentSessionHost()?.deps.store.getRecord(sessionId) ?? null
return record ? headStructuredProviderSessionId(record) : null
}
publishStructuredAgentSessionTab(input: {
workspaceId: string
sessionId: string
@@ -12355,7 +12362,14 @@ export class OrcaRuntimeService {
}): void {
const existing = this.mobileSessionTabsByWorktree.get(input.workspaceId)
const id = `agent-session:${input.sessionId}`
const providerSessionId = this.structuredAgentSessionProviderSessionId(input.sessionId)
if (existing?.tabs.some((tab) => tab.id === id)) {
this.republishStructuredAgentSessionProviderSessionId({
existing,
id,
providerSessionId,
notify: input.notify
})
return
}
const tab: RuntimeMobileSessionAgentTab = {
@@ -12364,6 +12378,9 @@ export class OrcaRuntimeService {
title: 'Codex Chat',
sessionId: input.sessionId,
agent: input.agent,
// Why: omit rather than blank — an unproven identity is unknown, and an empty id would ask
// the title pipeline to name a conversation that has no name yet.
...(providerSessionId ? { providerSessionId } : {}),
isActive: input.activate
}
const tabs = [...(existing?.tabs ?? [])].map((candidate) => ({
@@ -12407,6 +12424,38 @@ export class OrcaRuntimeService {
}
}
/** A thread proven after the tab was published (or replaced by a resume) still has to reach the
* client, or the chat keeps the generic name for the life of the session. */
private republishStructuredAgentSessionProviderSessionId(input: {
existing: RuntimeMobileSessionTabsSnapshot
id: string
providerSessionId: string | null
notify?: boolean
}): void {
const current = input.existing.tabs.find(
(tab): tab is RuntimeMobileSessionAgentTab =>
tab.type === 'agent-session' && tab.id === input.id
)
if (
!current ||
!input.providerSessionId ||
current.providerSessionId === input.providerSessionId
) {
return
}
const snapshot: RuntimeMobileSessionTabsSnapshot = {
...input.existing,
snapshotVersion: input.existing.snapshotVersion + 1,
tabs: input.existing.tabs.map((tab) =>
tab.id === input.id ? { ...tab, providerSessionId: input.providerSessionId! } : tab
)
}
this.mobileSessionTabsByWorktree.set(snapshot.worktree, snapshot)
if (input.notify !== false) {
this.emitMobileSessionTabsSnapshot(snapshot)
}
}
private async resolveRuntimeGitTarget(worktreeSelector: string): Promise<{
worktree: ResolvedWorktree
repo?: Repo
@@ -0,0 +1,131 @@
import { afterEach, describe, expect, it } from 'vitest'
import {
AGENT_SESSION_RECORD_SCHEMA_VERSION,
type AgentSessionRecord
} from '../../shared/agent-session-record'
import {
agentSessionProviderHandleKey,
appendAgentSessionProviderHandleLink,
type AgentSessionProviderHandleLink
} from '../../shared/agent-session-provider-handle'
import { agentSessionLeaseFixture } from '../../shared/agent-session-record.test-fixture'
import { setStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry'
import { OrcaRuntimeService } from './orca-runtime'
afterEach(() => setStructuredAgentSessionHost(null))
function codexLink(threadId: string, linkId: string): AgentSessionProviderHandleLink {
return {
linkId,
origin: 'created',
mintedAtFence: 7,
observedAt: 1_000,
handle: { provider: 'codex', threadId }
}
}
function codexRecord(chain: AgentSessionProviderHandleLink[]): AgentSessionRecord {
return {
schemaVersion: AGENT_SESSION_RECORD_SCHEMA_VERSION,
sessionId: 'session-1',
location: {
executionHostId: 'local',
wslDistro: null,
workspaceId: 'workspace-1',
workspaceKind: 'git-worktree'
},
provider: 'codex',
providerHandleChain: chain,
accountHome: { variable: 'CODEX_HOME', path: '/home/user/.codex' },
lease: agentSessionLeaseFixture({ sessionId: 'session-1' }),
createdAt: 1,
updatedAt: 2
}
}
function installHost(recordsBySessionId: Record<string, AgentSessionRecord | null>): void {
setStructuredAgentSessionHost({
deps: { store: { getRecord: (id: string) => recordsBySessionId[id] ?? null } }
} as never)
}
async function publishedAgentTab(
runtime: OrcaRuntimeService
): Promise<{ providerSessionId?: string } | undefined> {
const snapshot = await runtime.listMobileSessionTabs('id:workspace-1')
return snapshot.tabs.find((tab) => tab.type === 'agent-session') as
| { providerSessionId?: string }
| undefined
}
describe('structured chat tab provider session identity', () => {
it('publishes the Codex thread the session is bound to', async () => {
installHost({ 'session-1': codexRecord([codexLink('thread-1', 'link-1')]) })
const runtime = new OrcaRuntimeService()
runtime.publishStructuredAgentSessionTab({
workspaceId: 'workspace-1',
sessionId: 'session-1',
agent: 'codex',
activate: true
})
expect((await publishedAgentTab(runtime))?.providerSessionId).toBe('thread-1')
})
it('publishes no identity while the provider has not proven one', async () => {
installHost({ 'session-1': codexRecord([]) })
const runtime = new OrcaRuntimeService()
runtime.publishStructuredAgentSessionTab({
workspaceId: 'workspace-1',
sessionId: 'session-1',
agent: 'codex',
activate: true
})
const tab = await publishedAgentTab(runtime)
expect(tab).toBeDefined()
expect(tab).not.toHaveProperty('providerSessionId')
})
it('carries a thread proven after the tab was already published', async () => {
const records: Record<string, AgentSessionRecord> = { 'session-1': codexRecord([]) }
installHost(records)
const runtime = new OrcaRuntimeService()
runtime.publishStructuredAgentSessionTab({
workspaceId: 'workspace-1',
sessionId: 'session-1',
agent: 'codex',
activate: true
})
records['session-1'] = codexRecord([codexLink('thread-1', 'link-1')])
runtime.publishStructuredAgentSessionTab({
workspaceId: 'workspace-1',
sessionId: 'session-1',
agent: 'codex',
activate: false
})
expect((await publishedAgentTab(runtime))?.providerSessionId).toBe('thread-1')
})
it('names the conversation the session writes to now, not the one it forked from', async () => {
const chain = appendAgentSessionProviderHandleLink([codexLink('thread-old', 'link-1')], {
...codexLink('thread-new', 'link-2'),
origin: 'forked',
forkedFromKey: agentSessionProviderHandleKey({ provider: 'codex', threadId: 'thread-old' })
})
installHost({ 'session-1': codexRecord(chain) })
const runtime = new OrcaRuntimeService()
runtime.publishStructuredAgentSessionTab({
workspaceId: 'workspace-1',
sessionId: 'session-1',
agent: 'codex',
activate: true
})
expect((await publishedAgentTab(runtime))?.providerSessionId).toBe('thread-new')
})
})
@@ -4,6 +4,7 @@ import type { AiVaultSessionTitle } from '../../../shared/ai-vault-session-title
import { isAiVaultTitleAgent } from '../../../shared/ai-vault-session-title'
import type { ExecutionHostId } from '../../../shared/execution-host'
import { parsePaneKey } from '../../../shared/stable-pane-id'
import type { Tab } from '../../../shared/tab-types'
import type { TerminalTab } from '../../../shared/terminal-tab-types'
import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner'
import type { AppState } from '@/store/types'
@@ -67,6 +68,35 @@ function registerCandidate(
})
}
/** Structured chat tabs the host has given a provider conversation id. */
export function structuredAgentSessionTitleTabs(state: AppState): Tab[] {
return Object.values(state.unifiedTabsByWorktree)
.flat()
.filter(
(tab) =>
tab.contentType === 'agent-session' &&
isAiVaultTitleAgent(tab.agentSessionAgent) &&
Boolean(tab.agentSessionProviderSessionId)
)
}
/**
* Where a tab's currently stored provider name lives, across both tab models. The sync compares
* against this to decide whether a request still needs a scan, so it has to see chat tabs too.
*/
export function aiVaultTitleByTabId(
state: AppState
): Map<string, AiVaultSessionTitle | null | undefined> {
const titles = new Map<string, AiVaultSessionTitle | null | undefined>()
for (const tab of Object.values(state.tabsByWorktree).flat()) {
titles.set(tab.id, tab.aiVaultTitle)
}
for (const tab of structuredAgentSessionTitleTabs(state)) {
titles.set(tab.id, tab.aiVaultTitle)
}
return titles
}
export function collectAiVaultTitleRequests(state: AppState): AiVaultTitleRequest[] {
const tabsById = new Map(
Object.values(state.tabsByWorktree)
@@ -109,5 +139,18 @@ export function collectAiVaultTitleRequests(state: AppState): AiVaultTitleReques
})
}
return [...candidates.values()].map(({ priority: _priority, ...request }) => request)
const requests = [...candidates.values()].map(({ priority: _priority, ...request }) => request)
for (const tab of structuredAgentSessionTitleTabs(state)) {
requests.push({
agent: tab.agentSessionAgent as AiVaultSessionTitle['agent'],
executionHostId: getExecutionHostIdForWorktree(state, tab.worktreeId),
providerSession: { key: 'session_id', id: tab.agentSessionProviderSessionId! },
// Why: a chat the user is holding open is live, so its provider name is re-read on the same
// cadence a terminal-backed session gets — that is how a later rename reaches the tab.
refresh: true,
tabId: tab.id,
worktreeId: tab.worktreeId
})
}
return requests
}
@@ -3,7 +3,10 @@ import { isAiVaultTitleAgent } from '../../../shared/ai-vault-session-title'
import type { TerminalTab } from '../../../shared/terminal-tab-types'
import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner'
import type { AppState } from '@/store/types'
import { collectAiVaultTitleRequests } from './ai-vault-tab-title-requests'
import {
collectAiVaultTitleRequests,
structuredAgentSessionTitleTabs
} from './ai-vault-tab-title-requests'
function providerSessionEqual(
left: AgentProviderSessionMetadata | undefined,
@@ -130,6 +133,24 @@ function terminalTabsEqual(current: AppState, previous: AppState): boolean {
return true
}
function structuredChatTabsEqual(current: AppState, previous: AppState): boolean {
const currentTabs = structuredAgentSessionTitleTabs(current)
const previousTabs = structuredAgentSessionTitleTabs(previous)
if (currentTabs.length !== previousTabs.length) {
return false
}
return currentTabs.every((tab, index) => {
const other = previousTabs[index]!
return (
tab.id === other.id &&
tab.worktreeId === other.worktreeId &&
tab.agentSessionAgent === other.agentSessionAgent &&
tab.agentSessionProviderSessionId === other.agentSessionProviderSessionId &&
titleEqual(tab.aiVaultTitle, other.aiVaultTitle)
)
})
}
function activePanesEqual(current: AppState, previous: AppState): boolean {
const currentKeys = Object.keys(current.terminalLayoutsByTabId)
const previousKeys = Object.keys(previous.terminalLayoutsByTabId)
@@ -169,6 +190,12 @@ export function aiVaultTitleSyncInputsChanged(current: AppState, previous: AppSt
if (current.tabsByWorktree !== previous.tabsByWorktree && !terminalTabsEqual(current, previous)) {
return true
}
if (
current.unifiedTabsByWorktree !== previous.unifiedTabsByWorktree &&
!structuredChatTabsEqual(current, previous)
) {
return true
}
if (
current.terminalLayoutsByTabId !== previous.terminalLayoutsByTabId &&
!activePanesEqual(current, previous)
@@ -81,6 +81,7 @@ function makeState(args: {
}
: {},
tabsByWorktree: { [args.worktreeId]: [tab] },
unifiedTabsByWorktree: {},
terminalLayoutsByTabId: {
'tab-1': {
root: { type: 'leaf', leafId: 'leaf-1' },
@@ -4,6 +4,7 @@ import type {
} from '../../../shared/ai-vault-session-title'
import type { AppState } from '@/store/types'
import {
aiVaultTitleByTabId,
collectAiVaultTitleRequests,
type AiVaultTitleRequest
} from './ai-vault-tab-title-requests'
@@ -43,13 +44,9 @@ function nextLiveRefreshDelay(state: AppState, requests: AiVaultTitleRequest[]):
if (liveRequests.length === 0) {
return null
}
const tabsById = new Map(
Object.values(state.tabsByWorktree)
.flat()
.map((tab) => [tab.id, tab] as const)
)
const storedTitleByTabId = aiVaultTitleByTabId(state)
const hasMissingTitle = liveRequests.some((request) => {
const stored = tabsById.get(request.tabId)?.aiVaultTitle
const stored = storedTitleByTabId.get(request.tabId)
return (
stored?.agent !== request.agent ||
stored.sessionId !== request.providerSession.id ||
@@ -141,14 +138,10 @@ export function startAiVaultTabTitleSync(dependencies: SyncDependencies): () =>
}
const state = dependencies.getState()
const tabsById = new Map(
Object.values(state.tabsByWorktree)
.flat()
.map((tab) => [tab.id, tab] as const)
)
const storedTitleByTabId = aiVaultTitleByTabId(state)
const requests = collectAiVaultTitleRequests(state)
const requestsToScan = requests.filter((request) => {
const stored = tabsById.get(request.tabId)?.aiVaultTitle
const stored = storedTitleByTabId.get(request.tabId)
const identityMatches =
stored?.agent === request.agent && stored.sessionId === request.providerSession.id
if (stored && !identityMatches) {
@@ -0,0 +1,345 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type {
AiVaultSessionTitlesArgs,
AiVaultSessionTitlesResult
} from '../../../shared/ai-vault-session-title'
import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types'
import type { Tab } from '../../../shared/tab-types'
import { resolveUnifiedTabLabel } from '../../../shared/tab-title-resolution'
import { workspaceSessionStateSchema } from '../../../shared/workspace-session-schema'
import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
import {
applyLocalStructuredSessionTabSnapshots,
LOCAL_STRUCTURED_SESSION_OWNER
} from '../runtime/local-structured-session-tabs-sync'
import { resetWebSessionTabsSnapshotFreshnessForTests } from '../runtime/web-session-tabs-sync'
import { patchTab } from '../store/slices/tab-group-state'
import { buildHydratedTabState } from '../store/slices/tabs-hydration'
import { applyAgentSessionAiVaultTitle } from '../store/slices/agent-session-tab-ai-vault-title'
import { buildPersistedUnifiedTabSessionData } from './workspace-session-unified-tabs'
import { startAiVaultTabTitleSync } from './ai-vault-tab-title-sync'
import type { AppState } from '@/store/types'
const WORKTREE_ID = 'repo-1::worktree-1'
const GROUP_ID = 'group-1'
afterEach(() => {
resetWebSessionTabsSnapshotFreshnessForTests()
})
type SessionSpec = { sessionId: string; providerSessionId?: string; title?: string }
function hostSnapshot(
epoch: string,
snapshotVersion: number,
sessions: readonly SessionSpec[]
): RuntimeMobileSessionTabsResult {
const tabIds = sessions.map((session) => `agent-session:${session.sessionId}`)
return {
worktree: WORKTREE_ID,
publicationEpoch: epoch,
snapshotVersion,
activeGroupId: GROUP_ID,
activeTabId: tabIds[0] ?? null,
activeTabType: 'agent-session',
tabGroups: [{ id: GROUP_ID, activeTabId: tabIds[0] ?? null, tabOrder: tabIds }],
tabs: sessions.map((session, index) => ({
type: 'agent-session' as const,
id: tabIds[index]!,
title: session.title ?? 'Codex Chat',
sessionId: session.sessionId,
agent: 'codex' as const,
...(session.providerSessionId ? { providerSessionId: session.providerSessionId } : {}),
isActive: index === 0
}))
}
}
function emptyState(): AppState {
return {
activeBrowserTabId: null,
activeBrowserTabIdByWorktree: {},
activeFileId: null,
activeFileIdByWorktree: {},
activeGroupIdByWorktree: { [WORKTREE_ID]: GROUP_ID },
activeTabId: null,
activeTabIdByWorktree: {},
activeTabType: null,
activeTabTypeByWorktree: {},
activeWorktreeId: WORKTREE_ID,
agentStatusByPaneKey: {},
agentStatusEpoch: 0,
browserCertificateFailuresByPageId: {},
browserPagesByWorkspace: {},
browserTabsByWorktree: {},
detectedWorktreesByRepo: {},
folderWorkspaces: [],
getKnownWorktreeById: () => ({ path: '/workspace/repo-1' }),
groupsByWorktree: { [WORKTREE_ID]: [] },
layoutByWorktree: {},
openFiles: [],
ptyIdsByTabId: {},
remoteBrowserPageHandlesByPageId: {},
repos: [],
retainedAgentsByPaneKey: {},
sleepingAgentSessionsByPaneKey: {},
sortEpoch: 0,
tabBarOrderByWorktree: {},
tabsByWorktree: {},
terminalLayoutsByTabId: {},
unifiedTabsByWorktree: {},
unreadTerminalTabs: {},
worktreesByRepo: {}
} as unknown as AppState
}
let epochCounter = 0
function makeStore() {
const epoch = `epoch-${(epochCounter += 1)}`
const listeners = new Set<(next: AppState, previous: AppState) => void>()
const labelHistory: string[] = []
let state = emptyState()
const commit = (next: AppState): void => {
if (next === state) {
return
}
const previous = state
state = next
const tab = chatTabs(state)[0]
if (tab) {
labelHistory.push(resolveUnifiedTabLabel(tab, false, 'Codex Chat'))
}
for (const listener of listeners) {
listener(state, previous)
}
}
// Why: the real store action, so a test cannot pass on a hand-written title write.
state = {
...state,
setAiVaultTabTitle: (tabId: string, aiVaultTitle: Tab['aiVaultTitle'] | null) => {
const patched = applyAgentSessionAiVaultTitle(
state.unifiedTabsByWorktree,
tabId,
aiVaultTitle ?? null
)
if (patched) {
commit({ ...state, unifiedTabsByWorktree: patched })
}
}
} as AppState
return {
getState: () => state,
labelHistory,
subscribe: (listener: (next: AppState, previous: AppState) => void) => {
listeners.add(listener)
return () => listeners.delete(listener)
},
publish: (snapshotVersion: number, sessions: readonly SessionSpec[]) => {
commit(
applyLocalStructuredSessionTabSnapshots(
state,
[hostSnapshot(epoch, snapshotVersion, sessions)],
LOCAL_STRUCTURED_SESSION_OWNER
) as AppState
)
},
rename: (tabId: string, label: string) => {
const patched = patchTab(state.unifiedTabsByWorktree, tabId, { customLabel: label })
if (patched) {
commit({ ...state, ...patched } as AppState)
}
}
}
}
/** Lets the sync's queued reconcile run to completion before the next host publish. */
function settle(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0))
}
function chatTabs(state: AppState): Tab[] {
return (state.unifiedTabsByWorktree[WORKTREE_ID] ?? []).filter(
(tab) => tab.contentType === 'agent-session'
)
}
function labelOf(state: AppState, sessionId: string): string {
const tab = chatTabs(state).find((candidate) => candidate.entityId === sessionId)
return tab ? resolveUnifiedTabLabel(tab, false, 'Codex Chat') : 'missing'
}
function titleResolver(
titleByProviderSessionId: Record<string, string>
): (args: AiVaultSessionTitlesArgs) => Promise<AiVaultSessionTitlesResult> {
return async (args) => ({
titles: args.requests.flatMap((request) => {
const title = titleByProviderSessionId[request.sessionId]
return title ? [{ agent: request.agent, sessionId: request.sessionId, title }] : []
})
})
}
describe('structured Codex chat tab titles', () => {
it('adopts the provider session title when the chat tab is created', async () => {
const store = makeStore()
const stop = startAiVaultTabTitleSync({
getState: store.getState,
subscribe: store.subscribe,
resolveSessionTitles: titleResolver({ 'thread-1': 'Rewrite the parser' })
})
// Created after the sync is already running, as a real launch is.
store.publish(1, [{ sessionId: 'codex-1', providerSessionId: 'thread-1' }])
await vi.waitFor(() => expect(labelOf(store.getState(), 'codex-1')).toBe('Rewrite the parser'))
stop()
})
it('names the chat once the host proves the thread it was launched without', async () => {
const store = makeStore()
store.publish(1, [{ sessionId: 'codex-1' }])
const stop = startAiVaultTabTitleSync({
getState: store.getState,
subscribe: store.subscribe,
resolveSessionTitles: titleResolver({ 'thread-1': 'Rewrite the parser' })
})
await settle()
expect(labelOf(store.getState(), 'codex-1')).toBe('Codex Chat')
store.publish(2, [{ sessionId: 'codex-1', providerSessionId: 'thread-1' }])
await vi.waitFor(() => expect(labelOf(store.getState(), 'codex-1')).toBe('Rewrite the parser'))
stop()
})
it('picks up a later provider title without ever falling back to the generic label', async () => {
const store = makeStore()
store.publish(1, [{ sessionId: 'codex-1', providerSessionId: 'thread-1' }])
let title = 'Rewrite the parser'
let refresh: (() => void) | null = null
const delays: number[] = []
const stop = startAiVaultTabTitleSync({
getState: store.getState,
subscribe: store.subscribe,
resolveSessionTitles: async () => ({
titles: [{ agent: 'codex', sessionId: 'thread-1', title }]
}),
setTimer: (callback, delay) => {
refresh = callback
delays.push(delay)
return 0
},
clearTimer: () => {}
})
await vi.waitFor(() => expect(labelOf(store.getState(), 'codex-1')).toBe(title))
// The live-session refresh is what carries a renamed thread to the tab.
title = 'Ship the parser fix'
await vi.waitFor(() => expect(refresh).not.toBeNull())
refresh!()
await vi.waitFor(() => expect(labelOf(store.getState(), 'codex-1')).toBe(title))
// A host republish carries no name of its own; the tab must not blink back to the generic one.
store.publish(2, [{ sessionId: 'codex-1', providerSessionId: 'thread-1' }])
await settle()
expect(labelOf(store.getState(), 'codex-1')).toBe(title)
expect(store.labelHistory.slice(1)).not.toContain('Codex Chat')
// A named chat is on the settled cadence, not the every-20s hunt for a missing name.
expect(delays).toEqual([300_000, 300_000])
stop()
})
it('keeps a manual rename ahead of a later provider title', async () => {
const store = makeStore()
store.publish(1, [{ sessionId: 'codex-1', providerSessionId: 'thread-1' }])
const tabId = chatTabs(store.getState())[0]!.id
store.rename(tabId, 'Parser work')
const stop = startAiVaultTabTitleSync({
getState: store.getState,
subscribe: store.subscribe,
resolveSessionTitles: titleResolver({ 'thread-1': 'Rewrite the parser' })
})
await vi.waitFor(() =>
expect(chatTabs(store.getState())[0]!.aiVaultTitle?.title).toBe('Rewrite the parser')
)
store.publish(2, [{ sessionId: 'codex-1', providerSessionId: 'thread-1' }])
expect(labelOf(store.getState(), 'codex-1')).toBe('Parser work')
expect(store.labelHistory).not.toContain('Rewrite the parser')
stop()
})
it('restores the provider title and its session identity across a restart', async () => {
const store = makeStore()
store.publish(1, [{ sessionId: 'codex-1', providerSessionId: 'thread-1' }])
const stop = startAiVaultTabTitleSync({
getState: store.getState,
subscribe: store.subscribe,
resolveSessionTitles: titleResolver({ 'thread-1': 'Rewrite the parser' })
})
await vi.waitFor(() => expect(labelOf(store.getState(), 'codex-1')).toBe('Rewrite the parser'))
stop()
const persisted = buildPersistedUnifiedTabSessionData({
activeGroupIdByWorktree: store.getState().activeGroupIdByWorktree,
groupsByWorktree: store.getState().groupsByWorktree,
layoutByWorktree: store.getState().layoutByWorktree,
unifiedTabsByWorktree: store.getState().unifiedTabsByWorktree
})
const reloaded: WorkspaceSessionState = workspaceSessionStateSchema.parse({
...persisted,
activeRepoId: 'repo-1',
activeWorktreeId: WORKTREE_ID,
activeTabId: null,
terminalLayoutsByTabId: {},
tabsByWorktree: {}
})
const hydrated = buildHydratedTabState(reloaded, new Set([WORKTREE_ID]))
const restored = hydrated.unifiedTabsByWorktree[WORKTREE_ID]!.find(
(tab) => tab.contentType === 'agent-session'
)!
expect(resolveUnifiedTabLabel(restored, false, 'Codex Chat')).toBe('Rewrite the parser')
expect(restored.agentSessionProviderSessionId).toBe('thread-1')
})
it('never lets one Codex session name another chat tab', async () => {
const store = makeStore()
store.publish(1, [
{ sessionId: 'codex-1', providerSessionId: 'thread-1' },
{ sessionId: 'codex-2', providerSessionId: 'thread-2' }
])
const stop = startAiVaultTabTitleSync({
getState: store.getState,
subscribe: store.subscribe,
resolveSessionTitles: titleResolver({
'thread-1': 'Rewrite the parser',
'thread-2': 'Fix the flaky suite'
})
})
await vi.waitFor(() => {
expect(labelOf(store.getState(), 'codex-1')).toBe('Rewrite the parser')
expect(labelOf(store.getState(), 'codex-2')).toBe('Fix the flaky suite')
})
stop()
// An answer for one thread must not name the other; unknown stays unknown.
const partial = makeStore()
partial.publish(1, [
{ sessionId: 'codex-3', providerSessionId: 'thread-3' },
{ sessionId: 'codex-4', providerSessionId: 'thread-4' }
])
const stopPartial = startAiVaultTabTitleSync({
getState: partial.getState,
subscribe: partial.subscribe,
resolveSessionTitles: titleResolver({ 'thread-4': 'Fix the flaky suite' })
})
await vi.waitFor(() =>
expect(labelOf(partial.getState(), 'codex-4')).toBe('Fix the flaky suite')
)
expect(labelOf(partial.getState(), 'codex-3')).toBe('Codex Chat')
stopPartial()
})
})
@@ -998,8 +998,17 @@ function buildMirroredAgentTabs(
worktreeId: snapshot.worktree,
contentType: 'agent-session',
agentSessionAgent: tab.agent,
// Why: the host owns identity, the client owns the name. Dropping either on a republish
// renamed the tab back to the generic label and lost the user's own rename with it.
...((tab.providerSessionId ?? existing?.agentSessionProviderSessionId)
? {
agentSessionProviderSessionId:
tab.providerSessionId ?? existing?.agentSessionProviderSessionId
}
: {}),
label: tab.title.trim() || 'Codex Chat',
customLabel: null,
...(existing?.aiVaultTitle ? { aiVaultTitle: existing.aiVaultTitle } : {}),
customLabel: existing?.customLabel ?? null,
color: tab.color !== undefined ? tab.color : (existing?.color ?? null),
sortOrder: sortOffset + index,
createdAt: existing?.createdAt ?? now + sortOffset + index,
@@ -2670,6 +2679,7 @@ function tabEqual(a: Tab, b: Tab): boolean {
a.executionHostId === b.executionHostId &&
a.contentType === b.contentType &&
a.agentSessionAgent === b.agentSessionAgent &&
a.agentSessionProviderSessionId === b.agentSessionProviderSessionId &&
a.label === b.label &&
// Why: the generated label is the visible tab title; ignoring it let the
// equality bail keep a unified tab that disagreed with its terminal tab.
@@ -0,0 +1,37 @@
import type { AiVaultSessionTitle } from '../../../../shared/ai-vault-session-title'
import type { Tab } from '../../../../shared/tab-types'
function sameTitle(
left: AiVaultSessionTitle | null | undefined,
right: AiVaultSessionTitle | null | undefined
): boolean {
return (
left?.agent === right?.agent &&
left?.sessionId === right?.sessionId &&
left?.title === right?.title
)
}
/**
* Names a structured chat tab from the AI Vault pipeline. Structured chats have no terminal tab to
* carry the name, so the unified row is the only record of it.
*
* Returns null when nothing changed, so an unchanged provider name never re-renders the tab strip.
*/
export function applyAgentSessionAiVaultTitle(
unifiedTabsByWorktree: Record<string, Tab[]>,
tabId: string,
aiVaultTitle: AiVaultSessionTitle | null
): Record<string, Tab[]> | null {
for (const [worktreeId, tabs] of Object.entries(unifiedTabsByWorktree)) {
const current = tabs.find((tab) => tab.contentType === 'agent-session' && tab.id === tabId)
if (!current || sameTitle(current.aiVaultTitle, aiVaultTitle)) {
continue
}
return {
...unifiedTabsByWorktree,
[worktreeId]: tabs.map((tab) => (tab.id === tabId ? { ...tab, aiVaultTitle } : tab))
}
}
return null
}
@@ -5,6 +5,7 @@ import {
applyGeneratedTabTitleUpdates,
applyTerminalTabTitleUpdates
} from '../slices/terminal-tab-title-batch'
import { applyAgentSessionAiVaultTitle } from '../slices/agent-session-tab-ai-vault-title'
import {
adoptTerminalTabOwnerMetadataOnlyBuckets,
getTerminalTabOwnerWorktreeId
@@ -52,7 +53,16 @@ export function createTerminalTabPresentationActions(
set((s) => {
const ownerWorktreeId = getTerminalTabOwnerWorktreeId(s.tabsByWorktree, tabId)
if (!ownerWorktreeId) {
return s
const unifiedTabsByWorktree = applyAgentSessionAiVaultTitle(
s.unifiedTabsByWorktree,
tabId,
aiVaultTitle
)
if (!unifiedTabsByWorktree) {
return s
}
scheduleRuntimeGraphSync()
return { unifiedTabsByWorktree }
}
const tabs = s.tabsByWorktree[ownerWorktreeId] ?? []
const current = tabs.find((tab) => tab.id === tabId)
@@ -92,6 +92,9 @@ export type RuntimeMobileSessionAgentTab = {
title: string
sessionId: string
agent: 'codex'
/** Provider conversation the session is bound to (Codex thread id). Absent until the provider
* proves one — absence is unknown identity, never a title. */
providerSessionId?: string
color?: string | null
isPinned?: boolean
isActive: boolean
+3
View File
@@ -70,6 +70,9 @@ export type Tab = {
isPinned?: boolean // pinned tabs survive "close others"
/** Provider backing a structured agent-session tab. */
agentSessionAgent?: AgentType
/** Provider conversation id behind a structured agent-session tab, so the AI Vault title
* pipeline can name it exactly as it names a terminal-backed session. */
agentSessionProviderSessionId?: string
/** Structured session adopted from this terminal's Codex TUI. */
structuredSessionId?: string
/** Why: per-tab rendering mode for coding-agent terminals. `'chat'` shows the
+3
View File
@@ -128,6 +128,9 @@ const tabSchema = z.object({
// Why: a structured terminal tab must recover its durable host session after
// restart; omitting this additive field silently routes it back through PTY.
structuredSessionId: z.string().min(1).optional().catch(undefined),
// Why: the provider conversation id is what the AI Vault title pipeline keys on; without it a
// restored chat tab cannot refresh its own name until the host republishes.
agentSessionProviderSessionId: z.string().min(1).optional().catch(undefined),
label: z.string(),
generatedLabel: z.string().nullable().optional(),
aiVaultTitle: z