mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 08:03:20 +00:00
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:
@@ -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 } : {})
|
||||
}
|
||||
|
||||
+77
@@ -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 }
|
||||
}
|
||||
@@ -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,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) &&
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user