mirror of
https://github.com/stablyai/orca.git
synced 2026-10-03 16:02:11 +00:00
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:
committed by
Merge Sim
parent
0a995cc30d
commit
4827f1b456
@@ -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: () => [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user