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 }