diff --git a/src/main/claude/claude-structured-session-adapter.ts b/src/main/claude/claude-structured-session-adapter.ts index c00a588e891..386e1c37841 100644 --- a/src/main/claude/claude-structured-session-adapter.ts +++ b/src/main/claude/claude-structured-session-adapter.ts @@ -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 => - acquireClaudeSession({ + acquire = async (input: StructuredAgentSessionAcquireInput): Promise => { + 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) { diff --git a/src/main/claude/claude-structured-session-state.ts b/src/main/claude/claude-structured-session-state.ts index 1c0b1862913..f336b323c1f 100644 --- a/src/main/claude/claude-structured-session-state.ts +++ b/src/main/claude/claude-structured-session-state.ts @@ -74,6 +74,14 @@ export type ClaudeStructuredSessionAdapterDeps = { leafUuid: string | null fence: number }) => Promise + /** 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 /** Read the durable transcript branch after a child has flushed its final rows. */ readTranscriptLeaf?: (input: { providerSessionId: string diff --git a/src/main/claude/claude-transcript-conversation-name.test.ts b/src/main/claude/claude-transcript-conversation-name.test.ts new file mode 100644 index 00000000000..14a2f29feb6 --- /dev/null +++ b/src/main/claude/claude-transcript-conversation-name.test.ts @@ -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 { + 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() + }) +}) diff --git a/src/main/claude/claude-transcript-conversation-name.ts b/src/main/claude/claude-transcript-conversation-name.ts new file mode 100644 index 00000000000..2c70af812cd --- /dev/null +++ b/src/main/claude/claude-transcript-conversation-name.ts @@ -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 { + 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 + 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) +} diff --git a/src/main/codex/codex-structured-session-acquire.ts b/src/main/codex/codex-structured-session-acquire.ts index 04a48a6250e..5855a19fa15 100644 --- a/src/main/codex/codex-structured-session-acquire.ts +++ b/src/main/codex/codex-structured-session-acquire.ts @@ -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() } diff --git a/src/main/codex/codex-structured-session-adapter.ts b/src/main/codex/codex-structured-session-adapter.ts index 17cf12331ef..71236ac0065 100644 --- a/src/main/codex/codex-structured-session-adapter.ts +++ b/src/main/codex/codex-structured-session-adapter.ts @@ -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, diff --git a/src/main/codex/codex-structured-session-conversation-name.test.ts b/src/main/codex/codex-structured-session-conversation-name.test.ts new file mode 100644 index 00000000000..ca3a1d87f8d --- /dev/null +++ b/src/main/codex/codex-structured-session-conversation-name.test.ts @@ -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 & { + 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, 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() + }) +}) diff --git a/src/main/codex/codex-structured-session-state.ts b/src/main/codex/codex-structured-session-state.ts index 2f805e8570f..ff66303aba6 100644 --- a/src/main/codex/codex-structured-session-state.ts +++ b/src/main/codex/codex-structured-session-state.ts @@ -42,6 +42,8 @@ export type CodexStructuredSessionAdapterDeps = { identity: AgentSessionJournalIdentity }) => Promise 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 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 reportedOptions: { model?: string; effort?: string } diff --git a/src/main/codex/codex-structured-thread-facts.test.ts b/src/main/codex/codex-structured-thread-facts.test.ts new file mode 100644 index 00000000000..88b18acf852 --- /dev/null +++ b/src/main/codex/codex-structured-thread-facts.test.ts @@ -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() + }) +}) diff --git a/src/main/codex/codex-structured-thread-facts.ts b/src/main/codex/codex-structured-thread-facts.ts index 349246ecf29..1d5ddfbe602 100644 --- a/src/main/codex/codex-structured-thread-facts.ts +++ b/src/main/codex/codex-structured-thread-facts.ts @@ -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 { diff --git a/src/main/codex/codex-structured-thread-open.test.ts b/src/main/codex/codex-structured-thread-open.test.ts index 39c66468ca5..cc516745c13 100644 --- a/src/main/codex/codex-structured-thread-open.test.ts +++ b/src/main/codex/codex-structured-thread-open.test.ts @@ -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() + }) +}) diff --git a/src/main/codex/codex-structured-thread-open.ts b/src/main/codex/codex-structured-thread-open.ts index de3dbe235d8..908312a113d 100644 --- a/src/main/codex/codex-structured-thread-open.ts +++ b/src/main/codex/codex-structured-thread-open.ts @@ -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 /** 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 } : {}) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-name.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-name.test.ts new file mode 100644 index 00000000000..1447fc695eb --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-name.test.ts @@ -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 + } = {} +) { + 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() + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-name.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-name.ts new file mode 100644 index 00000000000..1e8d1777196 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-name.ts @@ -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 + 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 => { + 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) + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-tabs.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-tabs.test.ts new file mode 100644 index 00000000000..93336b3988a --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-tabs.test.ts @@ -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' } + ]) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-tabs.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-tabs.ts index 122907c4737..e9842368836 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-tabs.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-tabs.ts @@ -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 } : {}) + } + }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts index 2b230db1357..649400c2696 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts @@ -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 } +/** 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 }, + handoffs: { stopTuiHistoryCatchup: () => void; drain: () => Promise }, + tasks: { drainAttaches: () => Promise } +): 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 diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index 13b7d7b441e..9630a9f3e3f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -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() @@ -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 { 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[0] diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts index d44e3c07eb8..9ca0b56c65f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts @@ -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[] }] + expect(snapshot.sessions[0]).not.toHaveProperty('conversationName') + }) + + it('republishes when only the conversation name changed', async () => { + const journal = await openJournal() + const record: Partial = { 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' } } + ]) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts index 902acbc6112..5c95032e619 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts @@ -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() } diff --git a/src/main/runtime/agent-session-claim-key-retention.ts b/src/main/runtime/agent-session-claim-key-retention.ts new file mode 100644 index 00000000000..e89108c244b --- /dev/null +++ b/src/main/runtime/agent-session-claim-key-retention.ts @@ -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) +} diff --git a/src/main/runtime/agent-session-conversation-name-store.test.ts b/src/main/runtime/agent-session-conversation-name-store.test.ts new file mode 100644 index 00000000000..6fb0f1564cb --- /dev/null +++ b/src/main/runtime/agent-session-conversation-name-store.test.ts @@ -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) + }) +}) diff --git a/src/main/runtime/agent-session-record-conversation-name.ts b/src/main/runtime/agent-session-record-conversation-name.ts new file mode 100644 index 00000000000..e564e72c642 --- /dev/null +++ b/src/main/runtime/agent-session-record-conversation-name.ts @@ -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 } +} diff --git a/src/main/runtime/agent-session-record-store.ts b/src/main/runtime/agent-session-record-store.ts index 6fc0e59f212..2af0c32dda6 100644 --- a/src/main/runtime/agent-session-record-store.ts +++ b/src/main/runtime/agent-session-record-store.ts @@ -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 { - 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 => this.mutate(args.sessionId, (record) => replaceAgentSessionRecordOptions(record, args)) + setConversationName = ( + sessionId: string, + conversationName: string, + now: number + ): Promise => + this.mutate(sessionId, (record) => + setAgentSessionRecordConversationName(record, conversationName, now) + ) + async retireClaimKey(keyId: string, now: number): Promise { 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 ) }) } diff --git a/src/main/runtime/agent-session-visible-tab-index.ts b/src/main/runtime/agent-session-visible-tab-index.ts index e55ccce4ae6..abfea84f46c 100644 --- a/src/main/runtime/agent-session-visible-tab-index.ts +++ b/src/main/runtime/agent-session-visible-tab-index.ts @@ -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 +} diff --git a/src/main/runtime/orca-runtime-get-worktree-ps.ts b/src/main/runtime/orca-runtime-get-worktree-ps.ts index 06391e2ae83..076412fa5bd 100644 --- a/src/main/runtime/orca-runtime-get-worktree-ps.ts +++ b/src/main/runtime/orca-runtime-get-worktree-ps.ts @@ -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(), diff --git a/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts b/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts index 945d452302d..3e544cde7e3 100644 --- a/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts +++ b/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts @@ -69,6 +69,7 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu agent: 'claude' | 'codex' activate: boolean notify?: boolean + title?: string }): Promise { 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 diff --git a/src/main/runtime/orca-runtime-structured-session-restore.test.ts b/src/main/runtime/orca-runtime-structured-session-restore.test.ts index d6ec1e22782..1ad175808b0 100644 --- a/src/main/runtime/orca-runtime-structured-session-restore.test.ts +++ b/src/main/runtime/orca-runtime-structured-session-restore.test.ts @@ -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 + hydrateHeadlessMobileSessionTabsFromWorkspaceSession(): Set + refreshMobileSessionPtyRecords(): Promise | null> + ensureStructuredAgentSessionHost(): Promise + } + 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({ diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index d670c16f47d..819b6124939 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -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 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 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 + 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 host?.publishBackgroundTaskState(sessionId, state), + onConversationName: (sessionId, conversationName) => + void conversationNames.publish(sessionId, conversationName), ...(deps.openClaudeConnection ? { openClaudeConnection: deps.openClaudeConnection } : {}), ...(deps.readProcessStartTime ? { readProcessStartTime: deps.readProcessStartTime } : {}) }) diff --git a/src/main/runtime/structured-claude-runtime-adapter.ts b/src/main/runtime/structured-claude-runtime-adapter.ts index 95151f1dae9..a519cc50ae3 100644 --- a/src/main/runtime/structured-claude-runtime-adapter.ts +++ b/src/main/runtime/structured-claude-runtime-adapter.ts @@ -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' && diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx index bfa522e4b83..99c728dd82f 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx @@ -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() + 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() + 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: { diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx index 601592a11a7..98392736eb2 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx @@ -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 }, { diff --git a/src/shared/agent-session-conversation-name.test.ts b/src/shared/agent-session-conversation-name.test.ts new file mode 100644 index 00000000000..b4f75140300 --- /dev/null +++ b/src/shared/agent-session-conversation-name.test.ts @@ -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) + }) +}) diff --git a/src/shared/agent-session-conversation-name.ts b/src/shared/agent-session-conversation-name.ts new file mode 100644 index 00000000000..2107209fe47 --- /dev/null +++ b/src/shared/agent-session-conversation-name.ts @@ -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 + ) +} diff --git a/src/shared/agent-session-record.ts b/src/shared/agent-session-record.ts index 71369cffaed..1e0d11a4823 100644 --- a/src/shared/agent-session-record.ts +++ b/src/shared/agent-session-record.ts @@ -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 + /** 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) && diff --git a/src/shared/agent-session-wire.ts b/src/shared/agent-session-wire.ts index e4911f6154e..250fc31f4fc 100644 --- a/src/shared/agent-session-wire.ts +++ b/src/shared/agent-session-wire.ts @@ -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 }