From 9fcbb2ce3ef04596f709d2574cf879103813c833 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 12 Oct 2026 13:02:26 -0700 Subject: [PATCH] fix(chat): name orchestration worker chat tabs after their task title (#28045) * fix(chat): name orchestration worker chats after their task title Worker chats were skipped by chat naming and stayed "Claude Chat". Name them from the task they were started for instead, with no model call. * Fix legacy worker chat titles without prompt generation * Remove unrelated lockfile dependency --- ...ructured-chat-naming-command-first.test.ts | 2 +- ...structured-chat-naming-status-hook.test.ts | 2 +- .../structured-chat-naming.test.ts | 42 ++++++++++--- .../native-chat/structured-chat-naming.ts | 38 ++++++------ .../runtime/orca-runtime-get-worktree-ps.ts | 9 ++- .../structured-chat-naming-runtime.test.ts | 2 +- .../runtime/structured-chat-naming-runtime.ts | 4 +- .../runtime/structured-worker-at-rest.test.ts | 20 +++++- src/main/runtime/structured-worker-custody.ts | 61 +++++++++++++++---- 9 files changed, 134 insertions(+), 46 deletions(-) diff --git a/src/main/native-chat/structured-chat-naming-command-first.test.ts b/src/main/native-chat/structured-chat-naming-command-first.test.ts index 87ff8c92cc2..730b66be541 100644 --- a/src/main/native-chat/structured-chat-naming-command-first.test.ts +++ b/src/main/native-chat/structured-chat-naming-command-first.test.ts @@ -50,7 +50,7 @@ describe.each(['claude', 'codex'] as const)('%s command-first naming', (provider const naming = createStructuredChatNamingHandler({ getStore: () => store, getSettings: () => ({}), - hasOpenDispatch: () => false, + workerTaskTitle: () => null, readFirstPrompt, generate, onNamed: vi.fn(), diff --git a/src/main/native-chat/structured-chat-naming-status-hook.test.ts b/src/main/native-chat/structured-chat-naming-status-hook.test.ts index 57db3c7bb3b..f00d207591d 100644 --- a/src/main/native-chat/structured-chat-naming-status-hook.test.ts +++ b/src/main/native-chat/structured-chat-naming-status-hook.test.ts @@ -73,7 +73,7 @@ describe.each(['claude', 'codex'] as const)('%s first-message naming status hook const deps: StructuredChatNamingDeps = { getStore: () => store, getSettings: () => ({}), - hasOpenDispatch: () => false, + workerTaskTitle: () => null, readFirstPrompt, generate, onNamed: vi.fn(), diff --git a/src/main/native-chat/structured-chat-naming.test.ts b/src/main/native-chat/structured-chat-naming.test.ts index 454ac4d4804..abb907ec48e 100644 --- a/src/main/native-chat/structured-chat-naming.test.ts +++ b/src/main/native-chat/structured-chat-naming.test.ts @@ -44,7 +44,7 @@ function rig(provider: 'claude' | 'codex') { getStore: () => ({ getRecord, compareAndSetConversationName: setConversationName }), getSettings: () => ({}), now: () => 100, - hasOpenDispatch: vi.fn(() => false), + workerTaskTitle: vi.fn(() => null), readFirstPrompt: vi.fn(async () => 'Please repair the login flow'), generate: vi.fn(async () => 'auth/login'), onNamed: vi.fn(), @@ -104,25 +104,49 @@ describe.each(['claude', 'codex'] as const)('structured %s chat naming', (provid expect(state.deps.generate).not.toHaveBeenCalled() }) - it.each(['missing', 'named', 'worker'] as const)('skips a %s record', async (kind) => { + it.each(['missing', 'named'] as const)('skips a %s record', async (kind) => { const state = rig(provider) const record = state.read() if (!record) { throw new Error('Missing fixture') } - if (kind === 'missing') { - state.replace(null) - } else if (kind === 'named') { - state.replace({ ...record, conversationName: 'Existing name' }) - } else { - state.deps.hasOpenDispatch = () => true - } + state.replace(kind === 'missing' ? null : { ...record, conversationName: 'Existing name' }) state.handle(state.summary, { replay: false }) await settle() expect(state.deps.readFirstPrompt).not.toHaveBeenCalled() expect(state.deps.generate).not.toHaveBeenCalled() }) + it('names an orchestration worker after its task without generating', async () => { + const state = rig(provider) + state.deps.workerTaskTitle = () => ({ title: 'PR27814 final readiness review' }) + state.handle(state.summary, { replay: false }) + await settle() + expect(state.read()?.conversationName).toBe('PR27814 final readiness review') + expect(state.deps.onNamed).toHaveBeenCalledTimes(1) + expect(state.deps.readFirstPrompt).not.toHaveBeenCalled() + expect(state.deps.generate).not.toHaveBeenCalled() + }) + + it('leaves a worker unnamed when naming is off', async () => { + const state = rig(provider) + state.deps.workerTaskTitle = () => ({ title: 'PR27814 final readiness review' }) + state.deps.getSettings = () => ({ nativeChatAutoName: false }) + state.handle(state.summary, { replay: false }) + await settle() + expect(state.read()?.conversationName).toBeUndefined() + }) + + it('does not generate from a worker preamble when its task has no usable title', async () => { + const state = rig(provider) + state.deps.workerTaskTitle = () => ({ title: '' }) + state.handle(state.summary, { replay: false }) + await settle() + expect(state.read()?.conversationName).toBeUndefined() + expect(state.deps.readFirstPrompt).not.toHaveBeenCalled() + expect(state.deps.generate).not.toHaveBeenCalled() + }) + it('stays unnamed when disabled, including after enabling', async () => { const state = rig(provider) state.deps.getSettings = () => ({ nativeChatAutoName: false }) diff --git a/src/main/native-chat/structured-chat-naming.ts b/src/main/native-chat/structured-chat-naming.ts index 3bb34cba48c..546c2deb4df 100644 --- a/src/main/native-chat/structured-chat-naming.ts +++ b/src/main/native-chat/structured-chat-naming.ts @@ -18,7 +18,8 @@ export type StructuredChatNamingDeps = { getSettings: () => Pick readFirstPrompt: (sessionId: string, hostStartedAt: number) => Promise now?: () => number - hasOpenDispatch: (record: AgentSessionRecord) => boolean + /** An orchestration worker's chat takes its task's title instead of a generated name. */ + workerTaskTitle: (record: AgentSessionRecord) => { title: string } | null generate: (record: AgentSessionRecord, firstPrompt: string) => Promise onNamed: (workspaceId: string, sessionId: string) => void logger: StructuredAgentSessionLogger @@ -42,32 +43,35 @@ export function createStructuredChatNamingHandler(deps: StructuredChatNamingDeps } } - async function name(summary: AgentSessionStatusSummary) { - const store = deps.getStore() - const record = store?.getRecord(summary.sessionId) - if ( - !store || - !record || - record.conversationName !== undefined || - deps.hasOpenDispatch(record) - ) { - return - } + async function generateName( + store: NonNullable>, + record: AgentSessionRecord + ): Promise { const firstPrompt = clampConversationNameFirstPrompt( - await deps.readFirstPrompt(summary.sessionId, hostStartedAt) + await deps.readFirstPrompt(record.sessionId, hostStartedAt) ) if ( !hasStructuredChatPromptText(firstPrompt) || - store.getRecord(summary.sessionId)?.conversationName !== undefined || + store.getRecord(record.sessionId)?.conversationName !== undefined || deps.getSettings().nativeChatAutoName === false ) { + return null + } + return deps.generate(record, firstPrompt) + } + + async function name(summary: AgentSessionStatusSummary) { + const store = deps.getStore() + const record = store?.getRecord(summary.sessionId) + if (!store || !record || record.conversationName !== undefined) { return } - const generated = await deps.generate(record, firstPrompt) - if (!generated) { + const workerTask = deps.workerTaskTitle(record) + const chatName = workerTask ? workerTask.title : await generateName(store, record) + if (!chatName || deps.getSettings().nativeChatAutoName === false) { return } - const named = await store.compareAndSetConversationName(summary.sessionId, generated, null) + const named = await store.compareAndSetConversationName(summary.sessionId, chatName, null) if (named) { refresh(named) } diff --git a/src/main/runtime/orca-runtime-get-worktree-ps.ts b/src/main/runtime/orca-runtime-get-worktree-ps.ts index c7fe146b604..4fec277d980 100644 --- a/src/main/runtime/orca-runtime-get-worktree-ps.ts +++ b/src/main/runtime/orca-runtime-get-worktree-ps.ts @@ -36,7 +36,10 @@ import { structuredChatNamingDeps } from './structured-chat-naming-runtime' import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import { buildWorktreeListingPage } from './worktree-listing-host-scope' -import { structuredWorkerOwesWork } from './structured-worker-custody' +import { + structuredWorkerOpenTaskTitle, + structuredWorkerOwesWork +} from './structured-worker-custody' import { resolvedTuiAgentArgsBypassPermissions, resolveTuiAgentLaunchEnv @@ -186,8 +189,8 @@ export class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithStartTuiIdleVis return { path: target.worktree.path, executionHostId: target.executionHostId } }, getAgentEnvResolvers: () => this.getCommitMessageAgentEnvironmentResolvers(), - hasOpenDispatch: (record) => - structuredWorkerOwesWork(this.getOrchestrationDbIfAvailable?.() ?? null, record), + workerTaskTitle: (record) => + structuredWorkerOpenTaskTitle(this.getOrchestrationDbIfAvailable?.() ?? null, record), retitleOpenTab: (workspaceId, sessionId) => this.refreshStructuredConversationTabTitle(workspaceId, sessionId) }, diff --git a/src/main/runtime/structured-chat-naming-runtime.test.ts b/src/main/runtime/structured-chat-naming-runtime.test.ts index dc1dbe4adad..d2162a39c99 100644 --- a/src/main/runtime/structured-chat-naming-runtime.test.ts +++ b/src/main/runtime/structured-chat-naming-runtime.test.ts @@ -37,7 +37,7 @@ function rig() { executionHostId: 'local' as const })), getAgentEnvResolvers: vi.fn(() => undefined), - hasOpenDispatch: vi.fn(() => false), + workerTaskTitle: vi.fn(() => null), retitleOpenTab: vi.fn() } const logger = { warn: vi.fn(), error: vi.fn() } diff --git a/src/main/runtime/structured-chat-naming-runtime.ts b/src/main/runtime/structured-chat-naming-runtime.ts index 12c93227c8a..49f37c3f6f8 100644 --- a/src/main/runtime/structured-chat-naming-runtime.ts +++ b/src/main/runtime/structured-chat-naming-runtime.ts @@ -17,7 +17,7 @@ type StructuredChatNamingRuntime = { workspaceId: string ) => Promise<{ path: string; executionHostId: ExecutionHostId }> getAgentEnvResolvers: () => CommitMessageAgentEnvironmentResolvers | undefined - hasOpenDispatch: StructuredChatNamingDeps['hasOpenDispatch'] + workerTaskTitle: StructuredChatNamingDeps['workerTaskTitle'] retitleOpenTab: StructuredChatNamingDeps['onNamed'] } @@ -35,7 +35,7 @@ export function structuredChatNamingDeps( ? firstStructuredChatNamingPrompt(await host.journalSnapshot(sessionId), hostStartedAt) : '' }, - hasOpenDispatch: runtime.hasOpenDispatch, + workerTaskTitle: runtime.workerTaskTitle, generate: async (record, firstPrompt) => { const settings = resolveTextGenerationParams( getStore().getSettings(), diff --git a/src/main/runtime/structured-worker-at-rest.test.ts b/src/main/runtime/structured-worker-at-rest.test.ts index 426b3053a87..c9fa2854f28 100644 --- a/src/main/runtime/structured-worker-at-rest.test.ts +++ b/src/main/runtime/structured-worker-at-rest.test.ts @@ -15,7 +15,7 @@ vi.mock('../native-chat/agent-session-wire/structured-agent-session-registry', ( const { observeStructuredWorker, resolveStructuredWorkerAuthority, structuredSessionCloseSettled } = await import('./structured-worker-authority') -const { structuredWorkerOwesWork, structuredWorkerOwned } = +const { structuredWorkerOpenTaskTitle, structuredWorkerOwesWork, structuredWorkerOwned } = await import('./structured-worker-custody') const { mintStructuredWorkerHandle, @@ -129,6 +129,24 @@ describe('an open dispatch keeps its worker running (P2-19 i)', () => { expect(structuredWorkerOwesWork(db, record({}))).toBe(false) }) + it('names the worker after the task it still owes, and nothing once that settles', () => { + const dispatch = dispatchWorker() + expect(structuredWorkerOpenTaskTitle(db, record({}))).toEqual({ title: 'structured work' }) + db.markWorkerDispatchReady(dispatch.id) + db.beginWorkerStop(dispatch.id, 'epoch_home') + db.settleWorkerStop(dispatch.id) + expect(structuredWorkerOpenTaskTitle(db, record({}))).toBeNull() + expect(structuredWorkerOpenTaskTitle(null, record({}))).toBeNull() + }) + + it('derives the title of an owned open worker task with a legacy null title', () => { + const dispatch = dispatchWorker() + db.db.prepare('UPDATE tasks SET task_title = NULL WHERE id = ?').run(dispatch.task_id) + expect(structuredWorkerOpenTaskTitle(db, record({}))).toEqual({ title: 'structured work' }) + db.db.prepare("UPDATE tasks SET spec = '' WHERE id = ?").run(dispatch.task_id) + expect(structuredWorkerOpenTaskTitle(db, record({}))).toEqual({ title: '' }) + }) + // Custody is the list state coordinators see: `active` owes work, `reclaimable` does not. it('lets a worker awaiting its coordinator rest, and keeps one whose stop is in doubt', () => { const done = dispatchWorker() diff --git a/src/main/runtime/structured-worker-custody.ts b/src/main/runtime/structured-worker-custody.ts index d11694532ff..d0fff24fd5a 100644 --- a/src/main/runtime/structured-worker-custody.ts +++ b/src/main/runtime/structured-worker-custody.ts @@ -12,6 +12,7 @@ */ import type { AgentSessionRecord } from '../../shared/agent-session-record' +import { buildOrchestrationTaskDisplayMetadata } from '../../shared/orchestration-task-display' import { isOrcaSessionId } from '../../shared/orca-session-address' import { canonicalOrcaSessionId } from './orchestration/canonical-orca-session-id' import type { RunningStructuredSession } from './orchestration/structured-session-lineage' @@ -124,9 +125,55 @@ export function structuredWorkerOwesWork( db: OrchestrationDb | null, record: AgentSessionRecord ): boolean { + const incarnation = db && ownedWorkerIncarnation(db, record) + return ( + !!db && + !!incarnation && + db.db + .prepare( + `SELECT 1 FROM dispatch_contexts + WHERE process_incarnation = ? AND status IN ('pending', 'dispatched') LIMIT 1` + ) + .get(incarnation) !== undefined + ) +} + +/** The first task this worker still owes, which names its chat. */ +export function structuredWorkerOpenTaskTitle( + db: OrchestrationDb | null, + record: AgentSessionRecord +): { title: string } | null { + const incarnation = db && ownedWorkerIncarnation(db, record) + if (!db || !incarnation) { + return null + } + const row: unknown = db.db + .prepare( + `SELECT task.task_title, task.spec FROM dispatch_contexts dispatch + JOIN tasks task ON task.id = dispatch.task_id + WHERE dispatch.process_incarnation = ? AND dispatch.status IN ('pending', 'dispatched') + ORDER BY dispatch.rowid LIMIT 1` + ) + .get(incarnation) + return typeof row === 'object' && + row !== null && + 'task_title' in row && + (typeof row.task_title === 'string' || row.task_title === null) && + 'spec' in row && + typeof row.spec === 'string' + ? { + title: buildOrchestrationTaskDisplayMetadata({ + taskTitle: row.task_title, + spec: row.spec + }).taskTitle + } + : null +} + +function ownedWorkerIncarnation(db: OrchestrationDb, record: AgentSessionRecord): string | null { const hostScope = structuredWorkerHostScope(record.location) - if (!db || !hostScope) { - return false + if (!hostScope) { + return null } const incarnation = structuredWorkerProcessIncarnation( isOrcaSessionId(record.sessionId) ? canonicalOrcaSessionId(record.sessionId) : record.sessionId @@ -137,13 +184,5 @@ export function structuredWorkerOwesWork( WHERE process_incarnation = ? AND host_scope IS ? AND ownership_state = 'owned' LIMIT 1` ) .get(incarnation, JSON.stringify(hostScope)) - return ( - owned !== undefined && - db.db - .prepare( - `SELECT 1 FROM dispatch_contexts - WHERE process_incarnation = ? AND status IN ('pending', 'dispatched') LIMIT 1` - ) - .get(incarnation) !== undefined - ) + return owned === undefined ? null : incarnation }