diff --git a/docs/reference/remote-wire-compatibility.md b/docs/reference/remote-wire-compatibility.md index 1fceb67d5d5..8a27345bdc0 100644 --- a/docs/reference/remote-wire-compatibility.md +++ b/docs/reference/remote-wire-compatibility.md @@ -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. diff --git a/src/main/native-chat/agent-session-wire/structured-provider-session-ownership.ts b/src/main/native-chat/agent-session-wire/structured-provider-session-ownership.ts index ad211902433..ed6f7b9c2b9 100644 --- a/src/main/native-chat/agent-session-wire/structured-provider-session-ownership.ts +++ b/src/main/native-chat/agent-session-wire/structured-provider-session-ownership.ts @@ -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 +} diff --git a/src/main/runtime/orca-runtime-structured-session-restore.test.ts b/src/main/runtime/orca-runtime-structured-session-restore.test.ts index 39275ebedc1..f3ab3942b02 100644 --- a/src/main/runtime/orca-runtime-structured-session-restore.test.ts +++ b/src/main/runtime/orca-runtime-structured-session-restore.test.ts @@ -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: () => [ diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 3fd419cb69b..a6ae79e1e04 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -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 diff --git a/src/main/runtime/structured-agent-session-tab-provider-session.test.ts b/src/main/runtime/structured-agent-session-tab-provider-session.test.ts new file mode 100644 index 00000000000..2295ff2d561 --- /dev/null +++ b/src/main/runtime/structured-agent-session-tab-provider-session.test.ts @@ -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): 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 = { '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') + }) +}) diff --git a/src/renderer/src/lib/ai-vault-tab-title-requests.ts b/src/renderer/src/lib/ai-vault-tab-title-requests.ts index 1efeb48e001..50ec2795f1c 100644 --- a/src/renderer/src/lib/ai-vault-tab-title-requests.ts +++ b/src/renderer/src/lib/ai-vault-tab-title-requests.ts @@ -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 { + const titles = new Map() + 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 } diff --git a/src/renderer/src/lib/ai-vault-tab-title-sync-inputs.ts b/src/renderer/src/lib/ai-vault-tab-title-sync-inputs.ts index 569d79b0dc8..3a3867e0621 100644 --- a/src/renderer/src/lib/ai-vault-tab-title-sync-inputs.ts +++ b/src/renderer/src/lib/ai-vault-tab-title-sync-inputs.ts @@ -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) diff --git a/src/renderer/src/lib/ai-vault-tab-title-sync.test.ts b/src/renderer/src/lib/ai-vault-tab-title-sync.test.ts index a062b9df906..7e29098dcde 100644 --- a/src/renderer/src/lib/ai-vault-tab-title-sync.test.ts +++ b/src/renderer/src/lib/ai-vault-tab-title-sync.test.ts @@ -81,6 +81,7 @@ function makeState(args: { } : {}, tabsByWorktree: { [args.worktreeId]: [tab] }, + unifiedTabsByWorktree: {}, terminalLayoutsByTabId: { 'tab-1': { root: { type: 'leaf', leafId: 'leaf-1' }, diff --git a/src/renderer/src/lib/ai-vault-tab-title-sync.ts b/src/renderer/src/lib/ai-vault-tab-title-sync.ts index bac035ff265..44a6ba0eae3 100644 --- a/src/renderer/src/lib/ai-vault-tab-title-sync.ts +++ b/src/renderer/src/lib/ai-vault-tab-title-sync.ts @@ -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) { diff --git a/src/renderer/src/lib/structured-agent-session-tab-title.test.ts b/src/renderer/src/lib/structured-agent-session-tab-title.test.ts new file mode 100644 index 00000000000..898a804447f --- /dev/null +++ b/src/renderer/src/lib/structured-agent-session-tab-title.test.ts @@ -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 { + 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 +): (args: AiVaultSessionTitlesArgs) => Promise { + 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() + }) +}) diff --git a/src/renderer/src/runtime/web-session-tabs-sync.ts b/src/renderer/src/runtime/web-session-tabs-sync.ts index a213b2dbfe3..653bed6ae0c 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.ts @@ -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. diff --git a/src/renderer/src/store/slices/agent-session-tab-ai-vault-title.ts b/src/renderer/src/store/slices/agent-session-tab-ai-vault-title.ts new file mode 100644 index 00000000000..fb2d0631ba1 --- /dev/null +++ b/src/renderer/src/store/slices/agent-session-tab-ai-vault-title.ts @@ -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, + tabId: string, + aiVaultTitle: AiVaultSessionTitle | null +): Record | 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 +} diff --git a/src/renderer/src/store/terminals/terminal-tab-presentation.ts b/src/renderer/src/store/terminals/terminal-tab-presentation.ts index 7e1d2f64442..582376b0bf3 100644 --- a/src/renderer/src/store/terminals/terminal-tab-presentation.ts +++ b/src/renderer/src/store/terminals/terminal-tab-presentation.ts @@ -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) diff --git a/src/shared/runtime-mobile-session-tab-contracts.ts b/src/shared/runtime-mobile-session-tab-contracts.ts index 40f60230218..e9f549b478f 100644 --- a/src/shared/runtime-mobile-session-tab-contracts.ts +++ b/src/shared/runtime-mobile-session-tab-contracts.ts @@ -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 diff --git a/src/shared/tab-types.ts b/src/shared/tab-types.ts index 5f2b74d7127..12fd27b2e35 100644 --- a/src/shared/tab-types.ts +++ b/src/shared/tab-types.ts @@ -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 diff --git a/src/shared/workspace-session-schema.ts b/src/shared/workspace-session-schema.ts index 2ed368baedf..4b8d77cb206 100644 --- a/src/shared/workspace-session-schema.ts +++ b/src/shared/workspace-session-schema.ts @@ -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