mirror of
https://github.com/stablyai/orca.git
synced 2026-11-03 02:27:43 +00:00
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
This commit is contained in:
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -18,7 +18,8 @@ export type StructuredChatNamingDeps = {
|
||||
getSettings: () => Pick<GlobalSettings, 'nativeChatAutoName'>
|
||||
readFirstPrompt: (sessionId: string, hostStartedAt: number) => Promise<string>
|
||||
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<string | null>
|
||||
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<ReturnType<StructuredChatNamingDeps['getStore']>>,
|
||||
record: AgentSessionRecord
|
||||
): Promise<string | null> {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
|
||||
@@ -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() }
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user