feat(native-chat): show a structured chat's real conversation name

A native chat tab was labelled "Claude Chat" / "Codex Chat" forever, and
the sidebar agent row inherited that placeholder, because nothing carried
the provider's own name for the conversation.

Both providers now PUSH the name they have:

- Codex names the thread on `thread/start` / `thread/resume` and broadcasts
  `thread/name/updated` on every rename. The notification was decoded and
  dropped as status chrome; it is now read for this session's own thread
  only, since the app-server broadcasts one frame per stored thread and a
  sibling's rename must not relabel this chat.
- Claude's stream-json protocol carries no title frame at all, so a name it
  wrote earlier is read out of the session transcript when the session goes
  live, through the AI Vault parser that already understands `custom-title`
  and `ai-title` rather than a second parser. A name the user set outranks
  the generated one, as it does in the CLI. The read is off the acquire path:
  an unreadable transcript leaves the chat on its placeholder.

The name lands on the durable session record, so a restart relabels the tab
without asking the provider again. It reaches clients two ways: the tab
snapshot's existing `title` field, and a new optional `conversationName` on
the status summary the sidebar row already subscribes to. Both are additive
— an older client simply keeps its placeholder.

A user's own rename still wins everywhere. The tab strip already preferred
`customLabel`; the sidebar row read the host label directly, so it now reads
the rename first too.

Three files were at their max-lines budget, so this splits out the concerns
that had accumulated in them: retired-claim-key retention and the
visible-tab-index reads/writes leave the record store, and the host's
teardown ordering moves into the teardown module it already delegates to.
This commit is contained in:
Merge Sim
2026-09-06 18:18:41 -07:00
parent 0adb3fe0d9
commit 120ecbf64d
36 changed files with 1213 additions and 61 deletions
@@ -12,6 +12,7 @@ import {
import { dispatchClaudeTurn } from './claude-structured-dispatch'
import { releaseClaudeAcquisition } from './claude-structured-acquisition-release'
import { acquireClaudeSession } from './claude-structured-session-acquisition'
import { reportPersistedClaudeConversationName } from './claude-transcript-conversation-name'
export { CLAUDE_STRUCTURED_INIT_TIMEOUT_MS } from './claude-structured-session-acquisition'
import { supportsClaudeStructuredLocation } from './claude-structured-location-support'
import { setClaudeStructuredOption } from './claude-structured-options'
@@ -55,8 +56,8 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda
supportsLocation = supportsClaudeStructuredLocation
acquire = (input: StructuredAgentSessionAcquireInput): Promise<AgentSessionAcquisition> =>
acquireClaudeSession({
acquire = async (input: StructuredAgentSessionAcquireInput): Promise<AgentSessionAcquisition> => {
const acquired = await acquireClaudeSession({
input,
deps: this.deps,
sessions: this.sessions,
@@ -69,6 +70,10 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda
settleExit: (sessionId, exit) => this.settleUnexpectedExit(sessionId, exit)
}
})
const { sessionId } = input.identity
reportPersistedClaudeConversationName(sessionId, this.sessions.get(sessionId), this.deps)
return acquired
}
private deliver(attempt: ClaudeAcquisitionAttempt, sessionId: string, event: () => void): void {
if (!attempt.published) {
@@ -74,6 +74,14 @@ export type ClaudeStructuredSessionAdapterDeps = {
leafUuid: string | null
fence: number
}) => Promise<void>
/** Claude named (or the user renamed) the conversation behind this session. */
onConversationName?: (sessionId: string, conversationName: string) => void
/** The name Claude already persisted for this provider session, if any. Its
* stream carries no title frame, so the transcript is the only source. */
readTranscriptConversationName?: (input: {
providerSessionId: string
claudeConfigDir: string
}) => Promise<string | null>
/** Read the durable transcript branch after a child has flushed its final rows. */
readTranscriptLeaf?: (input: {
providerSessionId: string
@@ -0,0 +1,118 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
readClaudeTranscriptConversationName,
reportPersistedClaudeConversationName
} from './claude-transcript-conversation-name'
let root: string
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), 'orca-claude-title-'))
})
afterEach(async () => {
await rm(root, { recursive: true, force: true })
})
async function transcript(lines: readonly unknown[]): Promise<string> {
const path = join(root, 'session-1.jsonl')
await writeFile(path, lines.map((line) => JSON.stringify(line)).join('\n'), 'utf8')
return path
}
describe('readClaudeTranscriptConversationName', () => {
it('reads the generated title Claude persisted', async () => {
const path = await transcript([
{ type: 'user', sessionId: 'session-1' },
{ type: 'ai-title', aiTitle: 'Lease probe flake', sessionId: 'session-1' }
])
await expect(readClaudeTranscriptConversationName(path)).resolves.toBe('Lease probe flake')
})
it('takes the latest generated title when Claude revised it', async () => {
const path = await transcript([
{ type: 'ai-title', aiTitle: 'First guess', sessionId: 'session-1' },
{ type: 'ai-title', aiTitle: 'Lease probe flake', sessionId: 'session-1' }
])
await expect(readClaudeTranscriptConversationName(path)).resolves.toBe('Lease probe flake')
})
it('prefers a name the user set over the generated one', async () => {
const path = await transcript([
{ type: 'ai-title', aiTitle: 'Lease probe flake', sessionId: 'session-1' },
{ type: 'custom-title', customTitle: 'My own name', sessionId: 'session-1' }
])
await expect(readClaudeTranscriptConversationName(path)).resolves.toBe('My own name')
})
it('reports null for a transcript that carries no name', async () => {
const path = await transcript([{ type: 'user', sessionId: 'session-1' }])
await expect(readClaudeTranscriptConversationName(path)).resolves.toBeNull()
})
})
describe('reportPersistedClaudeConversationName', () => {
const session = { providerSessionId: 'provider-1', claudeConfigDir: '/home/dev/.claude' }
it('hands on the name the transcript held', async () => {
const onConversationName = vi.fn()
const readTranscriptConversationName = vi.fn(async () => 'Lease probe flake')
reportPersistedClaudeConversationName('session-1', session, {
readTranscriptConversationName,
onConversationName
})
await vi.waitFor(() => expect(onConversationName).toHaveBeenCalled())
expect(readTranscriptConversationName).toHaveBeenCalledWith(session)
expect(onConversationName).toHaveBeenCalledExactlyOnceWith('session-1', 'Lease probe flake')
})
it('reports nothing when the transcript holds no name', async () => {
const onConversationName = vi.fn()
const readTranscriptConversationName = vi.fn(async () => null)
reportPersistedClaudeConversationName('session-1', session, {
readTranscriptConversationName,
onConversationName
})
await vi.waitFor(() => expect(readTranscriptConversationName).toHaveBeenCalled())
expect(onConversationName).not.toHaveBeenCalled()
})
it('swallows an unreadable transcript rather than failing the acquisition', async () => {
const onConversationName = vi.fn()
const readTranscriptConversationName = vi.fn(async () => {
throw new Error('ENOENT')
})
expect(() =>
reportPersistedClaudeConversationName('session-1', session, {
readTranscriptConversationName,
onConversationName
})
).not.toThrow()
await vi.waitFor(() => expect(readTranscriptConversationName).toHaveBeenCalled())
expect(onConversationName).not.toHaveBeenCalled()
})
it('does nothing for a session that is not live', async () => {
const readTranscriptConversationName = vi.fn(async () => 'Lease probe flake')
reportPersistedClaudeConversationName('session-1', undefined, {
readTranscriptConversationName,
onConversationName: vi.fn()
})
expect(readTranscriptConversationName).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,72 @@
// The conversation name Claude has already written into a session transcript,
// and how a live session hands it on.
//
// Claude's stream-json protocol carries no title frame, so the transcript is the
// only place a name it generated (or a name the user set from the CLI) survives.
// The records are read through the AI Vault session parser rather than a second
// one, so `custom-title` / `ai-title` keep meaning exactly what they mean there.
import { createInterface } from 'node:readline'
import { openTranscriptReadStream } from '../native-chat/wsl-transcript-fs-access'
import {
consumeClaudeSessionLine,
createClaudeSessionParseState
} from '../ai-vault/session-scanner-primary-parsers'
/**
* The transcript's stored name, or null when it holds none.
*
* A user's own `custom-title` outranks the generated `ai-title`, matching the
* precedence the CLI itself applies when it shows the session's name.
*/
export async function readClaudeTranscriptConversationName(
transcriptPath: string
): Promise<string | null> {
const state = createClaudeSessionParseState({
path: transcriptPath,
mtimeMs: 0,
modifiedAt: new Date(0).toISOString()
})
const stream = openTranscriptReadStream(transcriptPath, { encoding: 'utf-8' }, 'scan')
const lines = createInterface({ input: stream, crlfDelay: Infinity })
try {
for await (const line of lines) {
consumeClaudeSessionLine(state, line)
}
} finally {
lines.close()
stream.destroy()
}
return state.accumulator.title || state.generatedTitle || null
}
export type ClaudeConversationNameReporter = {
readTranscriptConversationName?: (input: {
providerSessionId: string
claudeConfigDir: string
}) => Promise<string | null>
onConversationName?: (sessionId: string, conversationName: string) => void
}
/**
* Reports the name Claude persisted for a session that just went live.
*
* Deliberately not awaited: an unreadable or unnamed transcript must leave the
* chat on its placeholder label rather than delay or fail the acquisition.
*/
export function reportPersistedClaudeConversationName(
sessionId: string,
session: { providerSessionId: string; claudeConfigDir: string } | undefined,
deps: ClaudeConversationNameReporter
): void {
const read = deps.readTranscriptConversationName
if (!session || !read || !deps.onConversationName) {
return
}
void read({
providerSessionId: session.providerSessionId,
claudeConfigDir: session.claudeConfigDir
})
.then((name) => (name ? deps.onConversationName?.(sessionId, name) : undefined))
.catch(() => undefined)
}
@@ -192,6 +192,7 @@ export async function acquireCodexStructuredSession(input: {
...codexSessionLifecycle(acquireInput.fence, acquired.acquisitionGeneration as string),
threadId: opened.threadId,
historyPath: opened.historyPath,
conversationName: opened.name ?? null,
prompts: acquisition.prompts,
options: restoredCodexSessionOptions(acquireInput.options),
reportedOptions: reportedCodexThreadOptions(opened),
@@ -208,6 +209,10 @@ export async function acquireCodexStructuredSession(input: {
}
turnCancellation.register(session)
sessions.set(sessionId, session)
// A resumed thread arrives already named; only a rename is notified after this.
if (session.conversationName) {
deps.onConversationName?.(sessionId, session.conversationName)
}
for (const event of acquisition.drain()) {
event()
}
@@ -35,6 +35,7 @@ import {
deliverCodexServerRequest,
deliverCodexUnhandledFrame
} from './codex-structured-provider-events'
import { readCodexThreadId, readCodexThreadName } from './codex-structured-thread-facts'
import { CodexStructuredTurnCancellation } from './codex-structured-turn-cancellation'
import { createCodexStructuredNotificationRetry } from './codex-structured-notification-retry'
import { acquireCodexStructuredSession } from './codex-structured-session-acquire'
@@ -118,11 +119,34 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap
if (this.turnCancellation.handleNotification(sessionId, session, method, params)) {
return { accepted: true }
}
this.captureConversationName(sessionId, session, method, params)
return deliverCodexNotification(sessionId, session, method, params, (current, event) =>
this.emit(current, event)
)
}
/** Codex broadcasts `thread/name/updated` for every thread it has stored, so a
* frame naming another thread must not relabel this session's chat. */
private captureConversationName(
sessionId: string,
session: CodexSession,
method: string,
params: unknown
): void {
if (method !== 'thread/name/updated') {
return
}
if ((readCodexThreadId(params) ?? session.threadId) !== session.threadId) {
return
}
const conversationName = readCodexThreadName(params)
if (!conversationName || conversationName === session.conversationName) {
return
}
session.conversationName = conversationName
this.deps.onConversationName?.(sessionId, conversationName)
}
/** Journal first so observers never see an event ahead of its durable row. */
private emit(
session: CodexSession,
@@ -0,0 +1,124 @@
import { describe, expect, it, vi } from 'vitest'
import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types'
import type {
CodexAppServerConnection,
CodexAppServerConnectionHandlers,
CodexAppServerLaunch,
openCodexAppServerConnection
} from './codex-app-server-connection'
import { CodexStructuredSessionAdapter } from './codex-structured-session-adapter'
const THREAD_ID = 'thread-abc'
const SESSION = 'session-1'
const identity: AgentSessionJournalIdentity = {
sessionId: SESSION,
workspaceId: 'ws-1',
hostId: 'host-1',
agent: 'codex',
providerHandle: { kind: 'codex', threadId: THREAD_ID }
}
type FakeConnection = Omit<CodexAppServerConnection, 'closed'> & {
closed: boolean
handlers: CodexAppServerConnectionHandlers
}
/** A `codex app-server` that answers `thread/start` and `thread/resume` and lets
* a test push the notifications Codex would broadcast. */
function fakeCodex(threadName?: string): {
connections: FakeConnection[]
openConnection: typeof openCodexAppServerConnection
} {
const connections: FakeConnection[] = []
const openConnection = (async (
_launch: CodexAppServerLaunch,
handlers: CodexAppServerConnectionHandlers = {}
) => {
const connection: FakeConnection = {
handlers,
pid: 4321,
closed: false,
request: async () => ({
thread: { id: THREAD_ID, ...(threadName ? { name: threadName } : {}) }
}),
notify: () => {},
respond: () => {},
respondWithError: () => {},
close: async () => {
connection.closed = true
return true
}
} as FakeConnection
connections.push(connection)
return connection
}) as typeof openCodexAppServerConnection
return { connections, openConnection }
}
async function acquired(codex: ReturnType<typeof fakeCodex>, resumeThreadId: string | null = null) {
const onConversationName = vi.fn()
const adapter = new CodexStructuredSessionAdapter({
resolveLaunch: async () => ({
command: 'codex',
args: ['app-server'],
cwd: '/work/repo',
codexHome: null,
resumeThreadId
}),
openConnection: codex.openConnection,
readProcessStartTime: async () => 1_700_000_000_000,
onConversationName
})
await adapter.acquire({ identity, fence: 7, spawnToken: 'spawn-9' })
return { adapter, onConversationName }
}
describe('Codex structured conversation name', () => {
it('reports the name a resumed thread already carried', async () => {
const { onConversationName } = await acquired(fakeCodex('Fix the lease probe'), THREAD_ID)
expect(onConversationName).toHaveBeenCalledExactlyOnceWith(SESSION, 'Fix the lease probe')
})
it('reports nothing for a thread the app-server has never named', async () => {
const { onConversationName } = await acquired(fakeCodex())
expect(onConversationName).not.toHaveBeenCalled()
})
it("reports a rename of this session's own thread", async () => {
const codex = fakeCodex()
const { onConversationName } = await acquired(codex)
codex.connections[0]!.handlers.onNotification?.('thread/name/updated', {
threadId: THREAD_ID,
threadName: 'Fix the lease probe'
})
expect(onConversationName).toHaveBeenCalledExactlyOnceWith(SESSION, 'Fix the lease probe')
})
it('reports a rename only once while the name is unchanged', async () => {
const codex = fakeCodex()
const { onConversationName } = await acquired(codex)
const rename = { threadId: THREAD_ID, threadName: 'Fix the lease probe' }
codex.connections[0]!.handlers.onNotification?.('thread/name/updated', rename)
codex.connections[0]!.handlers.onNotification?.('thread/name/updated', rename)
expect(onConversationName).toHaveBeenCalledOnce()
})
it('ignores a name-updated broadcast for another stored thread', async () => {
const codex = fakeCodex()
const { onConversationName } = await acquired(codex)
codex.connections[0]!.handlers.onNotification?.('thread/name/updated', {
threadId: 'some-other-thread',
threadName: 'Someone else’s chat'
})
expect(onConversationName).not.toHaveBeenCalled()
})
})
@@ -42,6 +42,8 @@ export type CodexStructuredSessionAdapterDeps = {
identity: AgentSessionJournalIdentity
}) => Promise<CodexStructuredLaunch>
onEvent?: (event: CodexStructuredSessionEvent) => void
/** Codex named (or renamed) the thread behind this session. */
onConversationName?: (sessionId: string, conversationName: string) => void
openConnection?: typeof openCodexAppServerConnection
readProcessStartTime?: (pid: number) => Promise<number | null>
mintLinkId?: () => string
@@ -63,6 +65,8 @@ export type CodexSession = {
acquisitionGeneration: string
threadId: string
historyPath: string | null
/** Codex's own name for the thread; null until Codex reports one. */
conversationName: string | null
prompts: CodexAcquisitionWindow['prompts']
options: Map<string, string>
reportedOptions: { model?: string; effort?: string }
@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest'
import { readCodexThreadName } from './codex-structured-thread-facts'
describe('readCodexThreadName', () => {
it('reads the name off the nested thread a start/resume/read reply carries', () => {
expect(readCodexThreadName({ thread: { id: 't1', name: 'Fix the lease probe' } })).toBe(
'Fix the lease probe'
)
})
it('reads the envelope field a name-updated notification carries', () => {
expect(readCodexThreadName({ threadId: 't1', threadName: 'Fix the lease probe' })).toBe(
'Fix the lease probe'
)
})
it('reads the snake_case spelling the session-configured event uses', () => {
expect(readCodexThreadName({ thread_name: 'Fix the lease probe' })).toBe('Fix the lease probe')
})
it('reports null for an unnamed thread, a cleared name, and a non-object payload', () => {
expect(readCodexThreadName({ thread: { id: 't1' } })).toBeNull()
expect(readCodexThreadName({ threadId: 't1', threadName: '' })).toBeNull()
expect(readCodexThreadName({ threadId: 't1', threadName: null })).toBeNull()
expect(readCodexThreadName('thread-1')).toBeNull()
expect(readCodexThreadName(null)).toBeNull()
})
})
@@ -28,6 +28,21 @@ export function readCodexThreadPath(payload: unknown): string | null {
return root ? nonEmptyString(record(root.thread)?.path) : null
}
/** The thread's name. `thread/start`, `thread/resume`, and `thread/read` carry it
* on the nested thread; `thread/name/updated` puts it on the envelope, and the
* session-configured event spells it snake_case. */
export function readCodexThreadName(payload: unknown): string | null {
const root = record(payload)
if (!root) {
return null
}
return (
nonEmptyString(record(root.thread)?.name) ??
nonEmptyString(root.threadName) ??
nonEmptyString(root.thread_name)
)
}
/** `turn/start` responses carry `turn.id`; `turn/started` notifications carry
* the same under `turn`, and older builds put `turnId` on the envelope. */
export function readCodexTurnId(payload: unknown): string | null {
@@ -131,3 +131,28 @@ describe('openCodexThread', () => {
expect(request).toHaveBeenCalledTimes(2)
})
})
describe('openCodexThread conversation name', () => {
it('carries the stored thread name back from a resume', async () => {
const request = vi.fn(async () => ({
thread: { id: 'thread-1', name: 'Fix the lease probe' }
}))
await expect(
openCodexThread(connectionFor(request), { cwd: '/w', resumeThreadId: 'thread-1' }, 2_000)
).resolves.toMatchObject({ threadId: 'thread-1', name: 'Fix the lease probe' })
})
it('reports no name for a thread the app-server has not named', async () => {
const request = vi.fn(async () => ({ thread: { id: 'thread-1' } }))
const opened = await openCodexThread(
connectionFor(request),
{ cwd: '/w', resumeThreadId: null },
2_000
)
expect(opened.threadId).toBe('thread-1')
expect(opened.name).toBeUndefined()
})
})
@@ -9,13 +9,19 @@ import {
isCodexAppServerRequestError,
type CodexAppServerConnection
} from './codex-app-server-connection'
import { readCodexThreadId, readCodexThreadPath } from './codex-structured-thread-facts'
import {
readCodexThreadId,
readCodexThreadName,
readCodexThreadPath
} from './codex-structured-thread-facts'
export type CodexOpenedThread = {
threadId: string
thread?: Record<string, unknown>
/** Rollout file Codex named, when it named one. */
historyPath: string | null
/** Conversation name already stored for the thread, when it has one. */
name?: string
model?: string
effort?: string
}
@@ -88,10 +94,12 @@ export async function openCodexThread(
: {}
const model = nonEmptyString(result.model)
const effort = nonEmptyString(result.reasoningEffort)
const name = readCodexThreadName(opened)
return {
threadId,
thread,
historyPath: readCodexThreadPath(opened),
...(name ? { name } : {}),
...(model ? { model } : {}),
...(effort ? { effort } : {})
}
@@ -0,0 +1,77 @@
import { describe, expect, it, vi } from 'vitest'
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
import { StructuredAgentSessionConversationNames } from './structured-agent-session-conversation-name'
const SESSION = 'session-1'
function harness(
options: {
stored?: string | undefined
setConversationName?: (
sessionId: string,
conversationName: string,
now: number
) => Promise<AgentSessionRecord>
} = {}
) {
const stored = { conversationName: options.stored }
const setConversationName =
options.setConversationName ??
vi.fn(async (_sessionId: string, conversationName: string) => {
stored.conversationName = conversationName
return {} as AgentSessionRecord
})
const onChanged = vi.fn()
const names = new StructuredAgentSessionConversationNames({
store: {
getRecord: () => stored as AgentSessionRecord,
setConversationName: setConversationName as never
},
now: () => 5,
onChanged
})
return { names, onChanged, setConversationName, stored }
}
describe('StructuredAgentSessionConversationNames', () => {
it('persists a published name and announces the change once', async () => {
const { names, onChanged, setConversationName, stored } = harness()
await names.publish(SESSION, ' Fix the\nlease probe ')
expect(setConversationName).toHaveBeenCalledWith(SESSION, 'Fix the lease probe', 5)
expect(stored.conversationName).toBe('Fix the lease probe')
expect(onChanged).toHaveBeenCalledExactlyOnceWith(SESSION, 'Fix the lease probe')
})
it('does not rewrite or re-announce a name the record already holds', async () => {
const { names, onChanged, setConversationName } = harness({ stored: 'Fix the lease probe' })
await names.publish(SESSION, 'Fix the lease probe')
expect(setConversationName).not.toHaveBeenCalled()
expect(onChanged).not.toHaveBeenCalled()
})
it('ignores a report that carries no usable name', async () => {
const { names, onChanged, setConversationName } = harness()
await names.publish(SESSION, '')
await names.publish(SESSION, null)
await names.publish(SESSION, { name: 'nope' })
expect(setConversationName).not.toHaveBeenCalled()
expect(onChanged).not.toHaveBeenCalled()
})
it('keeps a store failure off the caller and announces nothing', async () => {
const { names, onChanged } = harness({
setConversationName: vi.fn(async () => {
throw new Error('agent_session_identity_required')
})
})
await expect(names.publish(SESSION, 'Fix the lease probe')).resolves.toBeUndefined()
expect(onChanged).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,42 @@
// Where a provider's name for a conversation becomes the structured chat's label.
//
// Providers PUSH: Codex reports the thread's name when it opens one and again on
// every rename, and Claude's name is read out of its transcript once a session
// is live. Nothing polls, so the name has one path in and one way out — the
// durable record — and only a name that actually CHANGED notifies. A re-read on
// every attach must not re-publish an unchanged label.
//
// Nothing here may fail an attach or a turn: the name is display metadata, so a
// store write that loses a race with a close is dropped rather than retried.
import { normalizeAgentSessionConversationName } from '../../../shared/agent-session-conversation-name'
import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store'
export type StructuredAgentSessionConversationNameDeps = {
store: Pick<AgentSessionRecordStore, 'getRecord' | 'setConversationName'>
now: () => number
onChanged: (sessionId: string, conversationName: string) => void
}
export class StructuredAgentSessionConversationNames {
constructor(private readonly deps: StructuredAgentSessionConversationNameDeps) {}
/** Records a name a provider published. Ignores anything that is not a usable name. */
publish = async (sessionId: string, reported: unknown): Promise<void> => {
const conversationName = normalizeAgentSessionConversationName(reported)
if (!conversationName) {
return
}
// Read first so an unchanged name costs no durable transaction and no fan-out.
if (this.deps.store.getRecord(sessionId)?.conversationName === conversationName) {
return
}
try {
await this.deps.store.setConversationName(sessionId, conversationName, this.deps.now())
} catch {
// The record is gone or reconciling. A label is never worth surfacing a failure for.
return
}
this.deps.onChanged(sessionId, conversationName)
}
}
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest'
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
import { listStructuredAgentSessionTabs } from './structured-agent-session-host-tabs'
const sessions = new Map([
['named', { params: { location: { workspaceId: 'ws-1' }, provider: 'codex' as const } }],
['unnamed', { params: { location: { workspaceId: 'ws-1' }, provider: 'claude' as const } }]
])
describe('listStructuredAgentSessionTabs', () => {
it('publishes the conversation name a record kept, and omits it where none exists', () => {
const tabs = listStructuredAgentSessionTabs(sessions, (sessionId) =>
sessionId === 'named'
? ({ conversationName: 'Fix the lease probe' } as AgentSessionRecord)
: null
)
expect(tabs).toEqual([
{ sessionId: 'named', workspaceId: 'ws-1', agent: 'codex', title: 'Fix the lease probe' },
{ sessionId: 'unnamed', workspaceId: 'ws-1', agent: 'claude' }
])
})
it('reports no titles at all when the caller supplies no record lookup', () => {
expect(listStructuredAgentSessionTabs(sessions)).toEqual([
{ sessionId: 'named', workspaceId: 'ws-1', agent: 'codex' },
{ sessionId: 'unnamed', workspaceId: 'ws-1', agent: 'claude' }
])
})
})
@@ -4,17 +4,24 @@ export type StructuredAgentSessionTab = {
sessionId: string
workspaceId: string
agent: AgentSessionRecord['provider']
/** Provider's conversation name; absent until one exists, so the caller keeps its placeholder. */
title?: string
}
export function listStructuredAgentSessionTabs(
sessions: ReadonlyMap<
string,
{ params: { location: { workspaceId: string }; provider: AgentSessionRecord['provider'] } }
>
>,
getRecord: (sessionId: string) => AgentSessionRecord | null = () => null
): StructuredAgentSessionTab[] {
return [...sessions.entries()].map(([sessionId, session]) => ({
sessionId,
workspaceId: session.params.location.workspaceId,
agent: session.params.provider
}))
return [...sessions.entries()].map(([sessionId, session]) => {
const title = getRecord(sessionId)?.conversationName
return {
sessionId,
workspaceId: session.params.location.workspaceId,
agent: session.params.provider,
...(title ? { title } : {})
}
})
}
@@ -7,6 +7,7 @@
// nothing can ever close them.
import { agentSessionJournalCloseRetries } from '../agent-session-journal/journal-close-retry'
import { withTimeout } from '../../../shared/promise-timeout-fallback'
import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types'
export type StructuredAgentSessionTeardownPhase = {
@@ -14,6 +15,38 @@ export type StructuredAgentSessionTeardownPhase = {
run: () => Promise<void> | void
}
/** Quit must not wait indefinitely on an in-flight handoff; see the drain phase below. */
const HANDOFF_DRAIN_TIMEOUT_MS = 5_000
/**
* What the host stops, in the order that makes each stop safe.
*
* Handoffs drain BEFORE the session map is dropped: a flow left running writes
* rows into a journal this teardown is about to close, and publishes against a
* session it removed. That drain is bounded because it sits on the app-quit path
* and a flow wedged in `launchTui` would otherwise hold the quit open forever;
* giving up merely restores the old orphaning, which the publish guard already
* makes survivable.
*/
export function structuredAgentSessionTeardownPhases(
holds: { dispose: () => void },
runtimeState: { stopLeaseRenewal: () => void; flushAllEventSinks: () => Promise<unknown> },
handoffs: { stopTuiHistoryCatchup: () => void; drain: () => Promise<unknown> },
tasks: { drainAttaches: () => Promise<unknown> }
): StructuredAgentSessionTeardownPhase[] {
return [
{ name: 'dispose-holds', run: () => holds.dispose() },
{ name: 'stop-lease-renewal', run: () => runtimeState.stopLeaseRenewal() },
{ name: 'stop-tui-catchup', run: () => handoffs.stopTuiHistoryCatchup() },
{
name: 'drain-handoffs',
run: () => withTimeout(handoffs.drain(), HANDOFF_DRAIN_TIMEOUT_MS, undefined)
},
{ name: 'drain-attaches', run: async () => void (await tasks.drainAttaches()) },
{ name: 'flush-event-sinks', run: async () => void (await runtimeState.flushAllEventSinks()) }
]
}
export async function tearDownStructuredAgentSessionHost(input: {
phases: readonly StructuredAgentSessionTeardownPhase[]
sessions: Map<string, StructuredAgentSessionHostSession>
@@ -40,7 +40,10 @@ import {
setStructuredAgentSessionOption,
type StructuredAgentSessionMutationContext
} from './structured-agent-session-host-mutations'
import { tearDownStructuredAgentSessionHost } from './structured-agent-session-host-teardown'
import {
structuredAgentSessionTeardownPhases,
tearDownStructuredAgentSessionHost
} from './structured-agent-session-host-teardown'
import type {
StructuredAgentSessionCaller,
StructuredAgentSessionHostDeps,
@@ -50,10 +53,7 @@ import type {
import { StructuredAgentSessionStatusFeed } from './structured-agent-session-status-feed'
import { StructuredAgentSessionEventRecovery } from './structured-agent-session-event-recovery'
import { StructuredAgentSessionBackgroundTaskChannel } from './structured-agent-session-background-task-channel'
import { withTimeout } from '../../../shared/promise-timeout-fallback'
export type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types'
/** Quit must not wait indefinitely on an in-flight handoff; see the drain phase below. */
const HANDOFF_DRAIN_TIMEOUT_MS = 5_000
export class StructuredAgentSessionHost {
private readonly sessions = new Map<string, StructuredAgentSessionHostSession>()
@@ -205,7 +205,9 @@ export class StructuredAgentSessionHost {
providerSupport.adapterSupportsCreate(this.deps.adapter, location, agent)
listSessionTabs() {
return listStructuredAgentSessionTabs(this.sessions)
return listStructuredAgentSessionTabs(this.sessions, (sessionId) =>
this.deps.store.getRecord(sessionId)
)
}
getPersistedVisibleSessionTabIndex(): { present: boolean; sessionIds: string[] } {
@@ -253,22 +255,12 @@ export class StructuredAgentSessionHost {
async flushAllStreamedEvents(): Promise<void> {
await tearDownStructuredAgentSessionHost({
phases: [
{ name: 'dispose-holds', run: () => this.holds.dispose() },
{ name: 'stop-lease-renewal', run: () => this.runtimeState.stopLeaseRenewal() },
{ name: 'stop-tui-catchup', run: () => this.handoffs.stopTuiHistoryCatchup() },
// Before the session map is dropped: a handoff flow left running writes rows into a
// journal this teardown is about to close, and publishes against a session it removed.
// Why bounded: this phase is on the app-quit path, and a flow wedged in `launchTui` would
// otherwise hold the quit open forever. Giving up merely restores the old orphaning, which
// the publish guard above already makes survivable.
{
name: 'drain-handoffs',
run: () => withTimeout(this.handoffs.drain(), HANDOFF_DRAIN_TIMEOUT_MS, undefined)
},
{ name: 'drain-attaches', run: () => this.tasks.drainAttaches() },
{ name: 'flush-event-sinks', run: () => this.runtimeState.flushAllEventSinks() }
],
phases: structuredAgentSessionTeardownPhases(
this.holds,
this.runtimeState,
this.handoffs,
this.tasks
),
sessions: this.sessions
})
}
@@ -337,6 +329,9 @@ export class StructuredAgentSessionHost {
) => this.backgroundTasks.publish(sessionId, state)
unsubscribe = (sessionId: string, id: string): void => this.subscribers.close(sessionId, id)
/** Re-projects one session after its RECORD changed; journal writes publish themselves. */
republishStatus = (sessionId: string): void => this.statusFeed.publish(sessionId)
/** Every session's projected status for session lists; unlike `subscribe`, retains nothing. */
subscribeStatus = (
subscriber: Parameters<StructuredAgentSessionStatusFeed['subscribe']>[0]
@@ -281,3 +281,42 @@ describe('StructuredAgentSessionStatusFeed', () => {
})
})
})
describe('StructuredAgentSessionStatusFeed conversation name', () => {
it('carries the record’s conversation name into the projected summary', async () => {
const journal = await openJournal()
const { events } = feedFor(new Map([[SESSION, { journal }]]), {
providerHandleChain: [],
conversationName: 'Fix the lease probe'
})
expect(events[0]).toMatchObject({
type: 'snapshot',
sessions: [{ sessionId: SESSION, conversationName: 'Fix the lease probe' }]
})
})
it('omits the field entirely while the session has no name', async () => {
const journal = await openJournal()
const { events } = feedFor(new Map([[SESSION, { journal }]]), { providerHandleChain: [] })
const [snapshot] = events as [{ type: 'snapshot'; sessions: Record<string, unknown>[] }]
expect(snapshot.sessions[0]).not.toHaveProperty('conversationName')
})
it('republishes when only the conversation name changed', async () => {
const journal = await openJournal()
const record: Partial<AgentSessionRecord> = { providerHandleChain: [] }
const { feed, events } = feedFor(new Map([[SESSION, { journal }]]), record)
feed.publish(SESSION)
expect(events).toHaveLength(1)
record.conversationName = 'Fix the lease probe'
feed.publish(SESSION)
expect(events.slice(1)).toMatchObject([
{ type: 'status', session: { conversationName: 'Fix the lease probe' } }
])
})
})
@@ -48,6 +48,7 @@ function summariesEqual(a: AgentSessionStatusSummary, b: AgentSessionStatusSumma
a.toolName === b.toolName &&
a.toolInput === b.toolInput &&
a.lastAssistantMessage === b.lastAssistantMessage &&
a.conversationName === b.conversationName &&
agentProviderSessionsEqual(undefined, a.providerSession, b.providerSession)
)
}
@@ -110,12 +111,14 @@ export class StructuredAgentSessionStatusFeed {
// The journal has no model: the record's acknowledged options are where an owner
// handoff or a mid-session switch lands, so the row follows whichever is in force.
const model = normalizeOptionalField(record?.options?.model, AGENT_MODEL_MAX_LENGTH)
const conversationName = record?.conversationName
return {
sessionId,
workspaceId: session.params.location.workspaceId,
agent: session.params.provider,
...projectStructuredAgentSessionStatusSummary(items),
...(model ? { model } : {}),
...(conversationName ? { conversationName } : {}),
...(providerSession ? { providerSession } : {}),
updatedAt: this.deps.now()
}
@@ -0,0 +1,30 @@
// How long a retired HMAC claim key stays verifiable.
//
// A rotation must never strand a running agent: the lease it was granted under
// was signed by the old key, so that key has to keep verifying for as long as a
// session could plausibly still be holding it.
export type RetiredAgentSessionClaimKey = { keyId: string; retiredAt: number }
export const AGENT_SESSION_CLAIM_KEY_RETENTION_MS = 30 * 24 * 60 * 60 * 1000
export function isAgentSessionClaimKeyVerifiable(
retiredKeys: readonly RetiredAgentSessionClaimKey[],
keyId: string,
now: number
): boolean {
const retired = retiredKeys.find((entry) => entry.keyId === keyId)
return !retired || now - retired.retiredAt <= AGENT_SESSION_CLAIM_KEY_RETENTION_MS
}
/** Records the retirement and drops every key that has aged out of the window. */
export function retireAgentSessionClaimKey(
retiredKeys: readonly RetiredAgentSessionClaimKey[],
keyId: string,
now: number
): RetiredAgentSessionClaimKey[] {
const retained = retiredKeys.some((entry) => entry.keyId === keyId)
? [...retiredKeys]
: [...retiredKeys, { keyId, retiredAt: now }]
return retained.filter((entry) => now - entry.retiredAt <= AGENT_SESSION_CLAIM_KEY_RETENTION_MS)
}
@@ -0,0 +1,124 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { isAgentSessionRecord } from '../../shared/agent-session-record'
import { AgentSessionRecordStore } from './agent-session-record-store'
import type { AgentSessionReserveRequest } from './agent-session-reservation-admission'
const NOW = 1_800_000_000_000
const SESSION = 'session-conversation-name'
let directory: string
function request(): AgentSessionReserveRequest {
return {
sessionId: SESSION,
location: {
executionHostId: 'local',
wslDistro: null,
workspaceId: 'workspace-1',
workspaceKind: 'git-worktree'
},
provider: 'codex',
accountHome: { variable: 'CODEX_HOME', path: '/home/dev/.codex' },
runtimeKind: 'native',
expectedFence: null,
spawnToken: 'spawn-a',
claimKeyId: 'key-1',
handoffOperationId: null,
probe: { outcome: 'reservation-unused' },
operation: {
callerKey: 'client-1',
operationId: `${NOW}-00000000000000000000000000000001`,
fingerprint: 'fp-1'
},
now: NOW
}
}
beforeEach(async () => {
directory = await mkdtemp(join(tmpdir(), 'orca-agent-session-conversation-name-'))
})
afterEach(async () => {
await rm(directory, { recursive: true, force: true })
})
describe('agent session conversation name', () => {
it('survives a store reopen so a restart relabels the tab without asking the provider', async () => {
const store = await AgentSessionRecordStore.open({ directory, hostId: 'local' })
await store.reserveOwner(request())
await store.setConversationName(SESSION, 'Fix the lease probe', NOW + 1)
const reopened = await AgentSessionRecordStore.open({ directory, hostId: 'local' })
expect(reopened.getRecord(SESSION)?.conversationName).toBe('Fix the lease probe')
})
it('leaves the record untouched when the name it is given is the stored one', async () => {
const store = await AgentSessionRecordStore.open({ directory, hostId: 'local' })
await store.reserveOwner(request())
const named = await store.setConversationName(SESSION, 'Fix the lease probe', NOW + 1)
const again = await store.setConversationName(SESSION, 'Fix the lease probe', NOW + 2)
expect(again.updatedAt).toBe(named.updatedAt)
})
it('refuses a name for a session that has no record', async () => {
const store = await AgentSessionRecordStore.open({ directory, hostId: 'local' })
await expect(store.setConversationName('missing-session', 'name', NOW)).rejects.toThrow(
'agent_session_identity_required'
)
})
})
describe('isAgentSessionRecord conversation name', () => {
function recordWith(conversationName: unknown): unknown {
return {
schemaVersion: 2,
sessionId: SESSION,
location: {
executionHostId: 'local',
wslDistro: null,
workspaceId: 'workspace-1',
workspaceKind: 'git-worktree'
},
provider: 'codex',
providerHandleChain: [],
accountHome: { variable: 'CODEX_HOME', path: '/home/dev/.codex' },
conversationName,
lease: {
sessionId: SESSION,
runtimeKind: 'native',
runtimeFence: 1,
handoffStage: null,
provenHandleLinkId: null,
ownerProcess: null,
reservedSpawnToken: 'spawn-a',
leaseDeadlineAt: NOW,
lastRenewedAt: NOW,
handoffOperationId: null,
journalCheckpoint: null,
claimKeyId: 'key-1',
claimStatus: 'reserved',
unreconciled: false,
deathEvidence: null
},
createdAt: NOW,
updatedAt: NOW
}
}
it('accepts a bounded name and an absent one', () => {
expect(isAgentSessionRecord(recordWith('Fix the lease probe'))).toBe(true)
expect(isAgentSessionRecord(recordWith(undefined))).toBe(true)
})
it('rejects an empty, oversized, or non-string name rather than loading it', () => {
expect(isAgentSessionRecord(recordWith(''))).toBe(false)
expect(isAgentSessionRecord(recordWith('a'.repeat(201)))).toBe(false)
expect(isAgentSessionRecord(recordWith(7))).toBe(false)
})
})
@@ -0,0 +1,18 @@
import type { AgentSessionRecord } from '../../shared/agent-session-record'
/**
* Records the provider's name for a conversation.
*
* Unfenced on purpose: a name is display metadata, not ownership, so a reader
* that learned it must not have to win the lease to keep it. An unchanged name
* is returned as-is, so a re-read costs no durable write.
*/
export function setAgentSessionRecordConversationName(
record: AgentSessionRecord,
conversationName: string,
now: number
): AgentSessionRecord {
return record.conversationName === conversationName
? record
: { ...record, conversationName, updatedAt: now }
}
+29 -27
View File
@@ -43,6 +43,16 @@ import {
collectAgentSessionRestartProbes,
type AgentSessionRestartProbeArgs
} from './agent-session-restart-reconciliation'
import {
isAgentSessionClaimKeyVerifiable,
retireAgentSessionClaimKey
} from './agent-session-claim-key-retention'
import { setAgentSessionRecordConversationName } from './agent-session-record-conversation-name'
import {
listVisibleAgentSessionIds,
setAgentSessionTabVisibility,
visibleAgentSessionTabIndex
} from './agent-session-visible-tab-index'
import { replaceAgentSessionRecordOptions } from './agent-session-record-options'
import {
setAgentSessionReservationProcesslessProof,
@@ -69,8 +79,7 @@ import {
export const AGENT_SESSION_LEASE_TTL_MS = 30_000,
AGENT_SESSION_LEASE_RENEW_INTERVAL_MS = 10_000
/** Retired claim keys stay verifiable this long so a rotation cannot strand a running agent. */
export const AGENT_SESSION_CLAIM_KEY_RETENTION_MS = 30 * 24 * 60 * 60 * 1000
export { AGENT_SESSION_CLAIM_KEY_RETENTION_MS } from './agent-session-claim-key-retention'
export class AgentSessionRecordStore {
private constructor(private readonly transactions: AgentSessionStoreTransactionQueue) {}
@@ -115,27 +124,13 @@ export class AgentSessionRecordStore {
listRecords = (): AgentSessionRecord[] => [...this.state.records.values()]
listVisibleSessionIds = (): string[] =>
[...this.state.visibleSessionIds].filter((sessionId) => this.state.records.has(sessionId))
listVisibleSessionIds = (): string[] => listVisibleAgentSessionIds(this.state)
getVisibleSessionTabIndex = (): { present: boolean; sessionIds: string[] } => ({
present: this.state.visibleSessionIdsIndexPresent,
sessionIds: this.listVisibleSessionIds()
})
getVisibleSessionTabIndex = (): { present: boolean; sessionIds: string[] } =>
visibleAgentSessionTabIndex(this.state)
/** Persist the user-visible tab reference separately from the rollback-sensitive profile tabs. */
setSessionTabVisibility(sessionId: string, visible: boolean): Promise<void> {
return this.transact(() => {
if (visible) {
if (!this.state.records.has(sessionId)) {
throw new Error('agent_session_identity_required')
}
this.state.visibleSessionIds.add(sessionId)
} else {
this.state.visibleSessionIds.delete(sessionId)
}
this.state.visibleSessionIdsIndexPresent = true
})
return this.transact(() => setAgentSessionTabVisibility(this.state, sessionId, visible))
}
listByScope(location: AgentSessionExecutionLocation): AgentSessionRecord[] {
@@ -151,8 +146,7 @@ export class AgentSessionRecordStore {
listOperationRows = (): AgentSessionOperationRow[] => [...this.state.operations.values()]
isClaimKeyVerifiable(keyId: string, now: number): boolean {
const retired = this.state.retiredClaimKeys.find((entry) => entry.keyId === keyId)
return !retired || now - retired.retiredAt <= AGENT_SESSION_CLAIM_KEY_RETENTION_MS
return isAgentSessionClaimKeyVerifiable(this.state.retiredClaimKeys, keyId, now)
}
/** Spawn tokens observed on the host with no matching lease. Stop them; never adopt them. */
@@ -316,13 +310,21 @@ export class AgentSessionRecordStore {
replaceSessionOptions = (args: AgentSessionOptionsReplacement): Promise<AgentSessionRecord> =>
this.mutate(args.sessionId, (record) => replaceAgentSessionRecordOptions(record, args))
setConversationName = (
sessionId: string,
conversationName: string,
now: number
): Promise<AgentSessionRecord> =>
this.mutate(sessionId, (record) =>
setAgentSessionRecordConversationName(record, conversationName, now)
)
async retireClaimKey(keyId: string, now: number): Promise<void> {
await this.transact(() => {
if (!this.state.retiredClaimKeys.some((entry) => entry.keyId === keyId)) {
this.state.retiredClaimKeys.push({ keyId, retiredAt: now })
}
this.state.retiredClaimKeys = this.state.retiredClaimKeys.filter(
(entry) => now - entry.retiredAt <= AGENT_SESSION_CLAIM_KEY_RETENTION_MS
this.state.retiredClaimKeys = retireAgentSessionClaimKey(
this.state.retiredClaimKeys,
keyId,
now
)
})
}
@@ -1,3 +1,5 @@
import type { AgentSessionStoreState } from './agent-session-record-store-file'
export function parseVisibleSessionIds(
raw: unknown,
schemaVersion: number,
@@ -19,3 +21,35 @@ export function parseVisibleSessionIds(
}
return { ids, present: true, valid: true }
}
/** Sessions the user has a chat tab open for, minus any whose record is gone. */
export function listVisibleAgentSessionIds(state: AgentSessionStoreState): string[] {
return [...state.visibleSessionIds].filter((sessionId) => state.records.has(sessionId))
}
export function visibleAgentSessionTabIndex(state: AgentSessionStoreState): {
present: boolean
sessionIds: string[]
} {
return {
present: state.visibleSessionIdsIndexPresent,
sessionIds: listVisibleAgentSessionIds(state)
}
}
/** Marking a session visible asserts it exists; a session with no record has no tab. */
export function setAgentSessionTabVisibility(
state: AgentSessionStoreState,
sessionId: string,
visible: boolean
): void {
if (visible) {
if (!state.records.has(sessionId)) {
throw new Error('agent_session_identity_required')
}
state.visibleSessionIds.add(sessionId)
} else {
state.visibleSessionIds.delete(sessionId)
}
state.visibleSessionIdsIndexPresent = true
}
@@ -25,6 +25,7 @@ import { resolveLocalWindowsAgentStartupShell } from '../../shared/windows-termi
import { resolveStartupShell, tokenizeStartupCommand } from '../../shared/tui-agent-startup-shell'
import { resolveCodexStructuredAppServerArgs } from '../codex/codex-structured-app-server-args'
import type { StructuredAgentSessionHandoffTransport } from '../native-chat/agent-session-wire/structured-agent-session-handoff-types'
import type { RuntimeMobileSessionTabsSnapshot } from '../../shared/runtime-types'
import { hostname } from 'node:os'
import { claudeStructuredAuthPolicyForSettings } from '../claude-accounts/claude-structured-auth-policy'
import { probeAgentSessionProcessIdentity } from './agent-session-process-identity-probe'
@@ -156,6 +157,12 @@ export class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithStructuredAgent
claudeStructuredAuthPolicyForSettings(this.requireStore().getSettings()),
// Same gate and same settings as agentSession.createSupport, re-read on every acquisition.
getClaudeManagedAccountGateSettings: () => this.requireStore().getSettings(),
onConversationName: ({ sessionId, workspaceId, conversationName }) =>
this.applyStructuredAgentSessionConversationName({
workspaceId,
sessionId,
conversationName
}),
handoffTransport: this.createStructuredAgentSessionHandoffTransport()
})
}
@@ -201,6 +208,29 @@ export class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithStructuredAgent
)
}
/** Relabels a published chat tab once its provider names the conversation. The
* user's own rename lives on the client tab, which never reads this field. */
applyStructuredAgentSessionConversationName(input: {
workspaceId: string
sessionId: string
conversationName: string
}): void {
const existing = this.mobileSessionTabsByWorktree.get(input.workspaceId)
const id = `agent-session:${input.sessionId}`
if (!existing?.tabs.some((tab) => tab.id === id && tab.title !== input.conversationName)) {
return
}
const snapshot: RuntimeMobileSessionTabsSnapshot = {
...existing,
snapshotVersion: existing.snapshotVersion + 1,
tabs: existing.tabs.map((tab) =>
tab.id === id ? { ...tab, title: input.conversationName } : tab
)
}
this.storeMobileSessionSnapshot(input.workspaceId, snapshot)
this.emitMobileSessionTabsSnapshot(snapshot)
}
protected createStructuredAgentSessionHandoffTransport(): StructuredAgentSessionHandoffTransport {
return {
hostLabel: hostname(),
@@ -69,6 +69,7 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu
agent: 'claude' | 'codex'
activate: boolean
notify?: boolean
title?: string
}): Promise<void> {
const host = getStructuredAgentSessionHost()
if (typeof host?.setSessionTabVisibility === 'function') {
@@ -77,7 +78,17 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu
const existing = this.mobileSessionTabsByWorktree.get(input.workspaceId)
const id = `agent-session:${input.sessionId}`
if (existing?.tabs.some((tab) => tab.id === id)) {
const conversationName = input.title?.trim()
if (!input.activate) {
// Republishing an already-open tab is how a restored session hands over
// the name it was persisted with; the rest of the snapshot is unchanged.
if (conversationName) {
this.applyStructuredAgentSessionConversationName({
workspaceId: input.workspaceId,
sessionId: input.sessionId,
conversationName
})
}
return
}
const priorGroups = existing.tabGroups ?? []
@@ -95,7 +106,11 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu
tabGroups: priorGroups.map((group) =>
group.id === groupId ? { ...group, activeTabId: id } : group
),
tabs: existing.tabs.map((tab) => ({ ...tab, isActive: tab.id === id }))
tabs: existing.tabs.map((tab) => ({
...tab,
isActive: tab.id === id,
...(conversationName && tab.id === id ? { title: conversationName } : {})
}))
}
this.storeMobileSessionSnapshot(input.workspaceId, snapshot)
if (input.notify !== false) {
@@ -106,7 +121,7 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu
const tab: RuntimeMobileSessionAgentTab = {
type: 'agent-session',
id,
title: defaultAgentChatLabel(input.agent),
title: input.title?.trim() || defaultAgentChatLabel(input.agent),
sessionId: input.sessionId,
agent: input.agent,
isActive: input.activate
@@ -375,6 +375,93 @@ describe('structured session cold restoration', () => {
)
})
it('publishes a restored session under the conversation name its record kept', async () => {
const runtime = new OrcaRuntimeService()
const internal = runtime as unknown as {
hasPersistedStructuredAgentSessionStore(): boolean
getKnownWorkspaceSessionWorktreeIds(): Set<string>
hydrateHeadlessMobileSessionTabsFromWorkspaceSession(): Set<string>
refreshMobileSessionPtyRecords(): Promise<Set<string> | null>
ensureStructuredAgentSessionHost(): Promise<void>
}
internal.hasPersistedStructuredAgentSessionStore = () => true
internal.getKnownWorkspaceSessionWorktreeIds = () => new Set()
internal.hydrateHeadlessMobileSessionTabsFromWorkspaceSession = () => new Set()
internal.refreshMobileSessionPtyRecords = async () => new Set()
internal.ensureStructuredAgentSessionHost = async () => undefined
setStructuredAgentSessionHost({
reconcileRestartLeases: async () => undefined,
restoreReadableSessions: async () => undefined,
listSessionTabs: () => [
{
sessionId: 'agent-session:named-codex',
workspaceId: 'workspace-1',
agent: 'codex',
title: 'Fix the lease probe'
}
]
} as never)
await runtime.restoreStructuredAgentSessionTabs()
const restored = await runtime.listMobileSessionTabs('id:workspace-1')
expect(restored.tabs).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: 'agent-session:named-codex',
title: 'Fix the lease probe'
})
])
)
})
it('relabels an open chat tab when its provider names the conversation', async () => {
const runtime = new OrcaRuntimeService()
await runtime.publishStructuredAgentSessionTab({
workspaceId: 'workspace-1',
sessionId: 'session-1',
agent: 'codex',
activate: true
})
const before = await runtime.listMobileSessionTabs('id:workspace-1')
expect(before.tabs[0]).toMatchObject({ title: 'Codex Chat' })
runtime.applyStructuredAgentSessionConversationName({
workspaceId: 'workspace-1',
sessionId: 'session-1',
conversationName: 'Fix the lease probe'
})
const after = await runtime.listMobileSessionTabs('id:workspace-1')
expect(after.tabs[0]).toMatchObject({ title: 'Fix the lease probe' })
expect(after.snapshotVersion).toBeGreaterThan(before.snapshotVersion ?? 0)
})
it('publishes nothing when the name it was given is the one already shown', async () => {
const runtime = new OrcaRuntimeService()
await runtime.publishStructuredAgentSessionTab({
workspaceId: 'workspace-1',
sessionId: 'session-1',
agent: 'codex',
activate: true
})
runtime.applyStructuredAgentSessionConversationName({
workspaceId: 'workspace-1',
sessionId: 'session-1',
conversationName: 'Fix the lease probe'
})
const settled = await runtime.listMobileSessionTabs('id:workspace-1')
runtime.applyStructuredAgentSessionConversationName({
workspaceId: 'workspace-1',
sessionId: 'session-1',
conversationName: 'Fix the lease probe'
})
const again = await runtime.listMobileSessionTabs('id:workspace-1')
expect(again.snapshotVersion).toBe(settled.snapshotVersion)
})
it('commits the host close when the renderer already removed the structured tab', async () => {
const runtime = new OrcaRuntimeService()
runtime.setNotifier({
@@ -18,6 +18,7 @@ import {
import type { ClaudeStructuredSessionAdapterDeps } from '../claude/claude-structured-session-adapter'
import { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host'
import { StructuredAgentSessionAdapterRouter } from '../native-chat/agent-session-wire/structured-agent-session-adapter-router'
import { StructuredAgentSessionConversationNames } from '../native-chat/agent-session-wire/structured-agent-session-conversation-name'
import type { StructuredAgentSessionHandoffTransport } from '../native-chat/agent-session-wire/structured-agent-session-handoff-types'
import { setStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry'
import {
@@ -76,6 +77,12 @@ export type StructuredAgentSessionRuntimeDeps = {
resolveEnvironment?: () => Promise<NodeJS.ProcessEnv>
resolveCodexOverrides?: () => NodeJS.ProcessEnv
onError?: (input: { scope: string; error: unknown }) => void
/** A provider named one conversation; the runtime relabels the tab it published. */
onConversationName?: (input: {
sessionId: string
workspaceId: string
conversationName: string
}) => void
handoffTransport?: StructuredAgentSessionHandoffTransport
reapOrphanChildren?: typeof stopOrphanAgentSessionChildren
}
@@ -210,6 +217,20 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise<Install
try {
let host: StructuredAgentSessionHost | null = null
let recoveryChain = Promise.resolve()
// Owned here rather than by the host: the record, not the live session map,
// is what says which workspace a named conversation belongs to, so a name
// arriving for an evicted session still relabels the right tab.
const conversationNames = new StructuredAgentSessionConversationNames({
store,
now: () => Date.now(),
onChanged: (sessionId, conversationName) => {
host?.republishStatus(sessionId)
const workspaceId = store.getRecord(sessionId)?.location.workspaceId
if (workspaceId) {
deps.onConversationName?.({ sessionId, workspaceId, conversationName })
}
}
})
const codex = new CodexStructuredSessionAdapter({
resolveLaunch: createCodexStructuredLaunchResolver({
store,
@@ -219,6 +240,8 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise<Install
}),
...(deps.openCodexConnection ? { openConnection: deps.openCodexConnection } : {}),
...(deps.readProcessStartTime ? { readProcessStartTime: deps.readProcessStartTime } : {}),
onConversationName: (sessionId, conversationName) =>
void conversationNames.publish(sessionId, conversationName),
onEvent: (event) => {
if (event.type !== 'ended' || !('cause' in event) || event.cause !== 'unexpected-exit') {
return
@@ -260,6 +283,8 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise<Install
},
onBackgroundTasksChanged: (sessionId, state) =>
host?.publishBackgroundTaskState(sessionId, state),
onConversationName: (sessionId, conversationName) =>
void conversationNames.publish(sessionId, conversationName),
...(deps.openClaudeConnection ? { openClaudeConnection: deps.openClaudeConnection } : {}),
...(deps.readProcessStartTime ? { readProcessStartTime: deps.readProcessStartTime } : {})
})
@@ -9,6 +9,7 @@ import {
type ClaudeStructuredSessionAdapterDeps
} from '../claude/claude-structured-session-adapter'
import { claudeProviderHandleLink } from '../claude/claude-structured-owner-identity'
import { readClaudeTranscriptConversationName } from '../claude/claude-transcript-conversation-name'
import type { StructuredAgentSessionLifecycleEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
import {
readClaudeTranscriptLeafUuid,
@@ -77,6 +78,12 @@ export function createStructuredClaudeRuntimeAdapter(
? await readClaudeTranscriptLeafUuid(transcriptPath, providerSessionId, previousLeafUuid)
: null
},
readTranscriptConversationName: async ({ providerSessionId, claudeConfigDir }) => {
const transcriptPath = await resolveSessionFilePath('claude', providerSessionId, {
claudeProjectsDir: join(claudeConfigDir, 'projects')
})
return transcriptPath ? await readClaudeTranscriptConversationName(transcriptPath) : null
},
onEvent: (event) => {
if (
event.type === 'ended' &&
@@ -355,6 +355,32 @@ describe('StructuredAgentSessionStatusBridge', () => {
expect(feed().target).toEqual({ kind: 'environment', environmentId: 'env-1' })
})
it('names the sidebar row with the conversation name the tab now carries', async () => {
mocks.store?.setState({
unifiedTabsByWorktree: { 'wt-1': [{ ...structuredTab, label: 'Fix the lease probe' }] }
})
render(<StructuredAgentSessionStatusBridge />)
await waitFor(() => expect(mocks.subscribeStatus).toHaveBeenCalledOnce())
act(() => feed().emit({ type: 'snapshot', sessions: [summary()] }))
expect(statuses()).toEqual([expect.objectContaining({ terminalTitle: 'Fix the lease probe' })])
})
it("keeps the user's own rename above the provider's conversation name", async () => {
mocks.store?.setState({
unifiedTabsByWorktree: {
'wt-1': [{ ...structuredTab, label: 'Fix the lease probe', customLabel: 'My chat' }]
}
})
render(<StructuredAgentSessionStatusBridge />)
await waitFor(() => expect(mocks.subscribeStatus).toHaveBeenCalledOnce())
act(() => feed().emit({ type: 'snapshot', sessions: [summary()] }))
expect(statuses()).toEqual([expect.objectContaining({ terminalTitle: 'My chat' })])
})
it('does not project an unknown provider as Codex', async () => {
mocks.store?.setState({
unifiedTabsByWorktree: {
@@ -56,6 +56,12 @@ function useStructuredAgentSessionStatusSummary(
)
}
/** The sidebar row reads this as its live title, so the user's own rename has to
* outrank the provider's conversation name here as it does in the tab strip. */
function rowTitle(tab: StructuredTab): string {
return tab.customLabel?.trim() || tab.label
}
function projectStatus(tab: StructuredTab, summary: AgentSessionStatusSummary | null): void {
const paneKey = structuredAgentSessionPaneKey(tab.id, tab.entityId)
const store = useAppStore.getState()
@@ -94,7 +100,7 @@ function projectStatus(tab: StructuredTab, summary: AgentSessionStatusSummary |
current.toolInput === summary.toolInput &&
current.lastAssistantMessage === summary.lastAssistantMessage &&
current.sessionBoundary === desired.sessionBoundary &&
current.terminalTitle === tab.label &&
current.terminalTitle === rowTitle(tab) &&
current.tabId === tab.id &&
current.worktreeId === tab.worktreeId &&
current.terminalResumeEligible === false &&
@@ -109,7 +115,7 @@ function projectStatus(tab: StructuredTab, summary: AgentSessionStatusSummary |
store.setAgentStatus(
paneKey,
desired,
tab.label,
rowTitle(tab),
undefined,
{ tabId: tab.id, worktreeId: tab.worktreeId },
{
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest'
import {
AGENT_SESSION_CONVERSATION_NAME_MAX_LENGTH,
isAgentSessionConversationName,
normalizeAgentSessionConversationName
} from './agent-session-conversation-name'
describe('normalizeAgentSessionConversationName', () => {
it('keeps a plain single-line name unchanged', () => {
expect(normalizeAgentSessionConversationName('Fix the flaky lease probe')).toBe(
'Fix the flaky lease probe'
)
})
it('flattens whitespace so a multi-line name cannot break the tab strip', () => {
expect(normalizeAgentSessionConversationName('Fix the\nlease\tprobe ')).toBe(
'Fix the lease probe'
)
})
it('rejects an empty or whitespace-only name rather than blanking the label', () => {
expect(normalizeAgentSessionConversationName('')).toBeNull()
expect(normalizeAgentSessionConversationName(' \n ')).toBeNull()
})
it('rejects anything that is not a string', () => {
expect(normalizeAgentSessionConversationName(undefined)).toBeNull()
expect(normalizeAgentSessionConversationName(null)).toBeNull()
expect(normalizeAgentSessionConversationName(42)).toBeNull()
expect(normalizeAgentSessionConversationName({ title: 'x' })).toBeNull()
})
it('bounds a pasted essay to the stored maximum', () => {
const normalized = normalizeAgentSessionConversationName('a'.repeat(1000))
expect(normalized).toHaveLength(AGENT_SESSION_CONVERSATION_NAME_MAX_LENGTH)
expect(isAgentSessionConversationName(normalized)).toBe(true)
})
})
describe('isAgentSessionConversationName', () => {
it('accepts a bounded non-empty string and nothing else', () => {
expect(isAgentSessionConversationName('Fix the probe')).toBe(true)
expect(isAgentSessionConversationName('')).toBe(false)
expect(isAgentSessionConversationName('a'.repeat(201))).toBe(false)
expect(isAgentSessionConversationName(7)).toBe(false)
})
})
@@ -0,0 +1,30 @@
// The conversation name a provider gives one structured chat, normalized once
// so every surface that shows it agrees on the text.
//
// Providers publish this as free text a user can also edit from another client,
// so it is bounded and flattened here rather than at each display site: a name
// carrying a newline would break the tab strip and the sidebar row alike.
/** Well past any provider's own cap, short enough that a pasted essay cannot become a tab label. */
export const AGENT_SESSION_CONVERSATION_NAME_MAX_LENGTH = 200
export function normalizeAgentSessionConversationName(value: unknown): string | null {
if (typeof value !== 'string') {
return null
}
const collapsed = value.replace(/\s+/g, ' ').trim()
if (!collapsed) {
return null
}
return collapsed.length > AGENT_SESSION_CONVERSATION_NAME_MAX_LENGTH
? collapsed.slice(0, AGENT_SESSION_CONVERSATION_NAME_MAX_LENGTH).trimEnd()
: collapsed
}
export function isAgentSessionConversationName(value: unknown): value is string {
return (
typeof value === 'string' &&
value.length > 0 &&
value.length <= AGENT_SESSION_CONVERSATION_NAME_MAX_LENGTH
)
}
+6
View File
@@ -6,6 +6,7 @@
* question of which process is currently allowed to write to it.
*/
import { isAgentSessionConversationName } from './agent-session-conversation-name'
import type { ExecutionHostId } from './execution-host'
import {
isAgentSessionProviderHandleChain,
@@ -125,6 +126,9 @@ export type AgentSessionRecord = {
accountHome: AgentSessionAccountHome
/** Provider options acknowledged for the next turn, restored across owner replacement. */
options?: Record<string, string>
/** Name the PROVIDER gave this conversation. A user's own rename lives on the
* client tab and always outranks it; nothing here may overwrite that. */
conversationName?: string
launchArgs?: AgentSessionLaunchArgs
lease: AgentSessionLease
createdAt: number
@@ -335,6 +339,8 @@ export function isAgentSessionRecord(value: unknown): value is AgentSessionRecor
isAgentSessionProviderHandleChain(record.providerHandleChain) &&
isAgentSessionAccountHome(record.accountHome) &&
(record.options === undefined || isAgentSessionOptions(record.options)) &&
(record.conversationName === undefined ||
isAgentSessionConversationName(record.conversationName)) &&
(record.launchArgs === undefined || isAgentSessionLaunchArgs(record.launchArgs)) &&
!Object.hasOwn(record, 'launchEnv') &&
isAgentSessionLease(record.lease) &&
+3
View File
@@ -185,6 +185,9 @@ export type AgentSessionStatusSummary = {
toolInput?: string
/** Preview of the newest assistant prose, so a settled row says what the agent said. */
lastAssistantMessage?: string
/** Provider's name for the conversation; absent until one exists. Additive, so
* an older client simply keeps showing its placeholder label. */
conversationName?: string
providerSession?: AgentProviderSessionMetadata
updatedAt: number
}