Use each native chat’s agent for naming by default (#32106)

* Default native chat naming to its conversation agent

* Preserve naming account overrides and accurate default selection

* Fix chat naming types and Japanese catalog spelling

* Isolate WSL interactive chat fixture from automatic naming

* Fix chat naming types after main integration

* Prove chat naming catalog bounds and recorded output routes

* Handle optional native launch environment in naming proof
This commit is contained in:
Brennan Benson
2026-10-29 02:09:23 -07:00
committed by GitHub
parent cfdd7d6720
commit 7decee7ed7
46 changed files with 1943 additions and 92 deletions
+5
View File
@@ -100,6 +100,11 @@ export type AcpDialect = {
/** A child-session notice supplies only its outcome; the translator owns membership and replay. */
subagentSessionEnd?(method: string, params: unknown): 'completed' | 'stopped' | undefined
notification?(method: string, params: unknown, at: number): AcpDialectNotification | undefined
/** Completion of an independent text task, apart from interactive turn projection. */
textTaskCompletion?(
method: string,
params: unknown
): { sessionId: string; promptId: string; stopReason: string } | undefined
contextWindow?(models: unknown): number | undefined
modelEfforts?(model: ModelInfo): { efforts: AgentSessionOptionChoice[]; defaultEffort?: string }
/** The agent clamps an effort another model lacks, so unlisted models' menus are unknown, not empty. */
@@ -220,6 +220,10 @@ export const GROK_ACP_DIALECT: AcpDialect = {
toolSubagents: grokToolSubagents,
subagentSessionEnd,
notification,
textTaskCompletion: (method, params) =>
method.replace(/^_/, '') === 'x.ai/session/prompt_complete'
? promptCompleteSchema.safeParse(params).data
: undefined,
contextWindow,
modelEfforts: grokModelEfforts,
hookBlock,
@@ -55,6 +55,22 @@ export class AcpStructuredChildren {
return failedStart && session
}
async closeAll(tasks: (Promise<boolean | void> | undefined)[]): Promise<void> {
const ids = new Set([...this.sessions.keys(), ...this.starts.failedSessionIds()])
// The host aborts in-progress starts before this teardown.
const results = await Promise.allSettled([
...[...ids].map((sessionId) => this.stop(sessionId, true)),
...tasks
])
const failure = results.find((result) => result.status === 'rejected')
if (failure?.status === 'rejected') {
throw failure.reason
}
if (results.some((result) => result.status === 'fulfilled' && result.value === false)) {
throw new Error('an ACP agent child could not be proven stopped')
}
}
private async stopSession(sessionId: string, requested: boolean): Promise<boolean> {
const session = this.sessions.get(sessionId)
if (!session || session.ended) {
@@ -1,3 +1,7 @@
import type {
AcpLaunchInvocationDeps,
AcpStructuredLaunch
} from './acp-structured-launch-resolution'
import type { StructuredAgentSessionAtRestCommands } from '../native-chat/agent-session-wire/structured-agent-session-at-rest-commands'
import type { StructuredAgentSessionChildWorkProducer } from '../native-chat/agent-session-wire/structured-agent-session-subagent-rows'
import type { StructuredAgentSessionLifecycleEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
@@ -9,7 +13,6 @@ import type {
import type { AgentJournalDispatchRejection } from '../../shared/agent-session-failure-words'
import type { AcpLaunchSpec } from './acp-launch-specs'
import type { ConnectAcpAgent } from './acp-structured-connection'
import type { AcpStructuredLaunch } from './acp-structured-launch-resolution'
import type { AgentChildWorkEvidence } from '../../shared/agent-status-child-work-evidence'
import type { AgentChatPermissionChoice } from '../../shared/agent-chat-permission-mode'
@@ -19,6 +22,7 @@ export const ACP_OPTION_WRITE_TIMEOUT_MS = 30_000
export type AcpStructuredSessionAdapterDeps = {
spec: AcpLaunchSpec
textTaskLaunch?: AcpLaunchInvocationDeps
atRestCommands?: StructuredAgentSessionAtRestCommands
/** Sealed with the reservation's spawn token. */
resolveLaunch: (input: {
+10 -16
View File
@@ -35,18 +35,24 @@ import { stopAcpChildren, acpChildStopCapabilities } from './acp-structured-chil
import { acpStartupFailure, isAcpReadySession } from './acp-structured-child'
import { AcpStructuredChildren } from './acp-structured-child-lifecycle'
import { AcpConversationCopies } from './acp-conversation-copies'
import { AcpTextTasks } from './acp-text-task'
import { supportsAcpStructuredLocation } from './acp-structured-location-support'
export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapter {
readonly atRestCommands: AcpStructuredSessionAdapterDeps['atRestCommands']
private readonly children: AcpStructuredChildren
private readonly copies: AcpConversationCopies
private readonly textTasks: AcpTextTasks
constructor(private readonly deps: AcpStructuredSessionAdapterDeps) {
this.atRestCommands = deps.atRestCommands
this.children = new AcpStructuredChildren(deps)
this.copies = new AcpConversationCopies(deps)
this.textTasks = new AcpTextTasks(deps)
}
generateText: NonNullable<StructuredAgentSessionAdapter['generateText']> = (input) =>
this.textTasks.generate(input)
copyConversation: NonNullable<StructuredAgentSessionAdapter['copyConversation']> = (input) =>
this.copies.copy(input)
@@ -304,23 +310,11 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte
forceCloseSession = (sessionId: string): Promise<boolean> => this.children.stop(sessionId, false)
async closeAll(): Promise<void> {
const commands = this.atRestCommands?.closeAll?.()
const copiedChildren = this.copies.closeAll()
const { children } = this
const ids = new Set([...children.sessions.keys(), ...children.starts.failedSessionIds()])
// The host aborts in-progress starts before this teardown.
const results = await Promise.allSettled([
...[...ids].map((sessionId) => children.stop(sessionId, true)),
copiedChildren,
commands
await this.children.closeAll([
this.atRestCommands?.closeAll?.(),
this.copies.closeAll(),
this.textTasks.closeAll()
])
const failure = results.find((result) => result.status === 'rejected')
if (failure?.status === 'rejected') {
throw failure.reason
}
if (results.some((result) => result.status === 'fulfilled' && result.value === false)) {
throw new Error('an ACP agent child could not be proven stopped')
}
}
private live(sessionId: string): AcpStructuredSession {
+443
View File
@@ -0,0 +1,443 @@
import { mkdtemp, readFile, writeFile, mkdir, rm, access } from 'node:fs/promises'
import type * as NodeFsPromises from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, expect, it, vi } from 'vitest'
import { z } from 'zod'
import { AcpStructuredSessionAdapter } from './acp-structured-session-adapter'
import { acpStructuredAgentDefinition } from './acp-structured-agent-definitions'
import { StructuredAgentRegistry } from '../native-chat/agent-session-wire/structured-agent-registry'
import { StructuredAgentSessionAdapterRouter } from '../native-chat/agent-session-wire/structured-agent-session-adapter-router'
import type { StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
import { structuredChatNamingDeps } from '../runtime/structured-chat-naming-runtime'
import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture'
import { getDefaultSettings } from '../../shared/constants'
import { FakeAcpChild } from './acp-scripted-connection.test-fixture'
import { acpLaunchSpecFor, type AcpLaunchSpec } from './acp-launch-specs'
import { AcpLaunchGate } from './acp-launch-gate'
import type { AcpStructuredSessionAdapterDeps } from './acp-structured-session-adapter-deps'
const boundary = vi.hoisted(() => ({
adapter: vi.fn<() => StructuredAgentSessionAdapter>(),
afterTemp: vi.fn<(path: string) => Promise<void>>()
}))
vi.mock('../native-chat/agent-session-wire/structured-agent-session-registry', () => ({
getStructuredAgentSessionHost: () => ({ deps: { adapter: boundary.adapter() } })
}))
vi.mock('node:fs/promises', async (importOriginal) => {
const fs = await importOriginal<typeof NodeFsPromises>()
return {
...fs,
mkdtemp: async (prefix: string) => {
const path = await fs.mkdtemp(prefix)
await boundary.afterTemp(path)
return path
}
}
})
const prompt = z.object({ _meta: z.object({ promptId: z.string() }) })
const folders: string[] = []
afterEach(async () => {
vi.useRealTimers()
vi.clearAllMocks()
boundary.afterTemp.mockReset()
await Promise.all(folders.splice(0).map((folder) => rm(folder, { recursive: true, force: true })))
})
async function stagedRig(
options: {
antigravity?: boolean
proveExit?: boolean
deletion?: 'unsupported' | 'stalled'
prepare?: () => Promise<Record<string, string>>
onConnect?: () => void
shouldExecute?: () => boolean
close?: (child: FakeAcpChild) => Promise<boolean>
runtime?: boolean
} = {}
) {
const root = await mkdtemp(join(tmpdir(), 'orca-naming-task-test-'))
folders.push(root)
const base = acpLaunchSpecFor(options.antigravity ? 'antigravity' : 'grok')
if (!base) {
throw new Error('ACP spec is missing')
}
const spec: AcpLaunchSpec = {
...base,
managedRuntime: undefined,
launchGate: new AcpLaunchGate('Test')
}
let child: FakeAcpChild | undefined
const ready = Promise.withResolvers<FakeAcpChild>()
const logger = { warn: vi.fn(), error: vi.fn() }
const deps: AcpStructuredSessionAdapterDeps = {
logger,
spec,
dataDirectory: root,
readLaunch: async () => {
throw new Error('Interactive launch must not be read')
},
resolveLaunch: async () => {
throw new Error('Interactive chat must not be acquired')
},
textTaskLaunch: {
dataDirectory: root,
resolveEnvironment: options.prepare ?? (async () => ({ PATH: '/usr/bin' })),
resolveCommand: () => '/fixture/agent'
},
connect: (launch, connectionOptions) => {
options.onConnect?.()
child = new FakeAcpChild(launch, connectionOptions)
if (options.proveExit === false) {
child.proveClose = async () => false
}
if (options.close) {
const close = options.close
const connected = child
child.proveClose = () => close(connected)
}
const agent = child.agent
agent.on('initialize', (frame) =>
agent.reply(frame, { protocolVersion: 1, agentCapabilities: {} })
)
agent.on('session/new', (frame) =>
agent.reply(frame, { sessionId: '11111111-1111-4111-8111-111111111111' })
)
agent.on('_x.ai/session/delete', (frame) => {
if (options.deletion === 'unsupported') {
agent.fail(frame, -32601, 'Method not found')
} else if (options.deletion !== 'stalled') {
agent.reply(frame, { success: true })
}
})
agent.on('session/prompt', () => {
if (child) {
ready.resolve(child)
}
})
return child
}
}
const tasks = new AcpStructuredSessionAdapter(deps)
const registry = new StructuredAgentRegistry([
{ definition: acpStructuredAgentDefinition(spec), adapter: tasks }
])
const router = new StructuredAgentSessionAdapterRouter(registry, () => tasks.closeAll())
boundary.adapter.mockReturnValue(router)
const record = agentSessionRecordFixture()
record.provider = spec.agent
record.accountHome = { variable: options.antigravity ? 'GEMINI_HOME' : 'GROK_HOME', path: root }
const naming = structuredChatNamingDeps(
() => ({ getSettings: () => getDefaultSettings(root) }),
{
resolveWorkspace: async () => ({ path: root, executionHostId: 'local' }),
resolveNamingEnvironment: async () => ({ PATH: '/usr/bin' }),
getAgentEnvResolvers: () => undefined,
workerTaskTitle: () => null,
retitleOpenTab: () => {}
},
logger
)
const result = options.runtime
? naming.generate(record, 'Repair login')
: router.generateText({
agent: spec.agent,
accountHome: record.accountHome,
prompt: 'Name this chat',
shouldExecute: options.shouldExecute
})
void result.catch(() => {})
return { root, ready, result, tasks, logger, gate: spec.launchGate }
}
async function rig(options: Parameters<typeof stagedRig>[0] = {}) {
const state = await stagedRig(options)
const { root, ready, result, tasks } = state
const server = await Promise.race([
ready.promise,
result.then(() => {
throw new Error('No prompt started')
})
])
const agent = server.agent
const frame = agent.frames.find((frame) => frame.method === 'session/prompt')
if (!frame) {
throw new Error('Naming prompt missing')
}
const promptId = prompt.parse(frame.params)._meta.promptId
const text = (value: string) =>
agent.notify('session/update', {
sessionId: '11111111-1111-4111-8111-111111111111',
update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: value } }
})
return { ...state, root, server, agent, frame, promptId, result, tasks, text }
}
it.each(['turn_completed', 'x.ai/session/prompt_complete', '_x.ai/session/prompt_complete'])(
'settles matching Grok %s without waiting for its prompt RPC',
async (method) => {
const r = await rig()
r.text('Repair login')
const complete = (sessionId: string, promptId: string, stopReason: string) => {
if (method === 'turn_completed') {
r.agent.notify('session/update', {
sessionId,
update: { sessionUpdate: method, prompt_id: promptId, stop_reason: stopReason }
})
} else {
r.agent.notify(method, { sessionId, promptId, stopReason })
}
}
complete('other-session', r.promptId, 'error')
complete('11111111-1111-4111-8111-111111111111', 'other-prompt', 'error')
await Promise.resolve()
expect(r.server.exited).toBe(false)
complete('11111111-1111-4111-8111-111111111111', r.promptId, 'end_turn')
expect(await r.result).toBe('Repair login')
const start = r.agent.frames.find((frame) => frame.method === 'session/new')
expect(start?.params).toEqual({ cwd: r.server.launch.cwd, mcpServers: [] })
expect(r.server.launch.args).toEqual(['agent', 'stdio'])
expect(r.server.exited).toBe(true)
expect(
r.agent.frames.some((frame) =>
['session/load', 'session/resume', 'session/fork', 'session/cancel'].includes(
frame.method ?? ''
)
)
).toBe(false)
expect(r.agent.frames.find((frame) => frame.method === '_x.ai/session/delete')?.params).toEqual(
{ sessionId: '11111111-1111-4111-8111-111111111111', cwd: r.server.launch.cwd }
)
await expect(access(r.server.launch.cwd!)).rejects.toThrow()
}
)
it.each(['unsupported', 'stalled'] as const)(
'preserves a title after %s deletion and closes',
async (deletion) => {
vi.useFakeTimers()
const r = await rig({ deletion, shouldExecute: () => true })
r.text('Repair login')
r.agent.reply(r.frame, { stopReason: 'end_turn' })
await vi.advanceTimersByTimeAsync(2_001)
expect(await r.result).toBe('Repair login')
expect(r.server.exited).toBe(true)
}
)
it('rejects standalone failure and cancels before deleting and closing', async () => {
const r = await rig()
r.agent.notify('_x.ai/session/prompt_complete', {
sessionId: '11111111-1111-4111-8111-111111111111',
promptId: r.promptId,
stopReason: 'error'
})
await expect(r.result).rejects.toThrow('did not complete')
const methods = r.agent.frames.map((frame) => frame.method)
expect(methods.indexOf('session/cancel')).toBeLessThan(methods.indexOf('_x.ai/session/delete'))
expect(r.server.exited).toBe(true)
})
it('does not spawn when preparation finishes after the deadline', async () => {
vi.useFakeTimers()
const prepare = Promise.withResolvers<Record<string, string>>()
const onConnect = vi.fn()
const preparing = Promise.withResolvers<void>()
const fixture = rig({
prepare: () => {
preparing.resolve()
return prepare.promise
},
onConnect
})
const failure = expect(fixture).rejects.toThrow('Chat naming timed out')
await preparing.promise
await vi.advanceTimersByTimeAsync(60_000)
await failure
prepare.resolve({ PATH: '/usr/bin' })
await vi.advanceTimersByTimeAsync(0)
expect(onConnect).not.toHaveBeenCalled()
})
it.each(['tool', 'overflow', 'shutdown'] as const)(
'closes the helper after %s',
async (failure) => {
const r = await rig()
if (failure === 'tool') {
r.agent.notify('session/update', {
sessionId: '11111111-1111-4111-8111-111111111111',
update: {
sessionUpdate: 'tool_call',
toolCallId: 'tool',
title: 'Shell',
status: 'in_progress'
}
})
}
if (failure === 'overflow') {
r.text('x'.repeat(128_001))
}
if (failure === 'shutdown') {
await r.tasks.closeAll()
}
await expect(r.result).rejects.toThrow()
expect(r.server.exited).toBe(true)
}
)
it('enforces the task deadline and tears down its child', async () => {
vi.useFakeTimers()
const r = await rig()
const failure = expect(r.result).rejects.toThrow('Chat naming timed out')
await vi.advanceTimersByTimeAsync(60_000)
await failure
expect(r.server.exited).toBe(true)
})
it('removes only its owned Antigravity session files after proven exit, including late exit', async () => {
const r = await rig({ antigravity: true, proveExit: false })
const session = '11111111-1111-4111-8111-111111111111'
const directory = join(r.root, 'antigravity-acp', 'conversations')
await mkdir(directory, { recursive: true })
const metadata = join(directory, `${session}.meta`)
await writeFile(metadata, JSON.stringify({ cwd: r.server.launch.cwd }))
await writeFile(join(directory, 'keep.meta'), '{}')
r.text('Repair login')
r.agent.reply(r.frame, { stopReason: 'end_turn' })
expect(await r.result).toBe('Repair login')
expect(await readFile(metadata, 'utf8')).toContain(r.server.launch.cwd)
r.server.exit()
await vi.waitFor(async () => {
await expect(access(metadata)).rejects.toThrow()
})
expect(await readFile(join(directory, 'keep.meta'), 'utf8')).toBe('{}')
})
it.each(['launch', 'private-temp'] as const)(
'skips stale work after held %s preparation',
async (stage) => {
const entered = Promise.withResolvers<string>()
const release = Promise.withResolvers<void>()
let current = true
const onConnect = vi.fn()
if (stage === 'private-temp') {
boundary.afterTemp.mockImplementation(async (path) => {
if (path.includes('acp-temp')) {
entered.resolve(path)
await release.promise
}
})
}
const r = await stagedRig({
antigravity: stage === 'private-temp',
onConnect,
shouldExecute: () => current,
prepare: async () => {
if (stage === 'launch') {
entered.resolve('')
await release.promise
}
return { PATH: '/usr/bin' }
}
})
const folder = await entered.promise
current = false
release.resolve()
expect(await r.result).toBeNull()
expect(onConnect).not.toHaveBeenCalled()
expect(r.logger.warn).not.toHaveBeenCalled()
if (folder) {
await vi.waitFor(async () => {
await expect(access(folder)).rejects.toThrow()
})
}
}
)
it.each(['success', 'failure', 'deadline'] as const)(
'joins root and managed close on %s',
async (outcome) => {
vi.useFakeTimers()
const closing = Promise.withResolvers<void>()
const close = Promise.withResolvers<boolean>()
const r = await rig({
shouldExecute: () => true,
close: async () => {
closing.resolve()
return close.promise
}
})
const settled = vi.fn()
void r.result.then(settled, settled)
r.text('Repair login')
if (outcome === 'deadline') {
await vi.advanceTimersByTimeAsync(60_000)
} else {
r.agent.reply(r.frame, { stopReason: outcome === 'success' ? 'end_turn' : 'error' })
}
await closing.promise
if (outcome === 'success') {
r.server.exit()
await Promise.resolve()
expect(settled).not.toHaveBeenCalled()
close.resolve(true)
} else {
close.resolve(false)
await vi.advanceTimersByTimeAsync(0)
expect(settled).not.toHaveBeenCalled()
r.server.exit()
}
if (outcome === 'success') {
expect(await r.result).toBe('Repair login')
} else {
await expect(r.result).rejects.toThrow(
outcome === 'deadline' ? 'timed out' : 'did not complete'
)
}
expect(settled).toHaveBeenCalledTimes(1)
}
)
it.each(['shutdown', 'account'] as const)(
'allows %s to retry a retained root before releasing the caller',
async (owner) => {
const r = await rig({ proveExit: false, shouldExecute: () => true })
const settled = vi.fn()
void r.result.then(settled, settled)
r.text('Repair login')
r.agent.reply(r.frame, { stopReason: 'end_turn' })
await vi.waitFor(() => expect(r.logger.warn).toHaveBeenCalled())
expect(settled).not.toHaveBeenCalled()
r.server.proveClose = async () => {
r.server.exit()
return true
}
const work = vi.fn(async () => {})
if (owner === 'shutdown') {
await r.tasks.closeAll()
} else {
await r.gate?.run(async () => {}, work)
expect(work).toHaveBeenCalledOnce()
}
expect(await r.result).toBe('Repair login')
expect(r.server.closes).toBe(2)
expect(settled).toHaveBeenCalledTimes(1)
}
)
it('runs healthy own-agent naming through the runtime, registered router and ACP task', async () => {
const r = await rig({ runtime: true })
expect(r.frame.params).toEqual(
expect.objectContaining({
prompt: [expect.objectContaining({ text: expect.stringContaining('Repair login') })]
})
)
expect(r.server.launch.env?.GROK_HOME).toBe(r.root)
expect(r.server.launch.args).toEqual(['agent', 'stdio'])
r.text('Repair login\n')
r.agent.reply(r.frame, { stopReason: 'end_turn' })
expect(await r.result).toEqual({ name: 'Repair login' })
expect(r.logger.warn).not.toHaveBeenCalled()
expect(r.server.exited).toBe(true)
})
+286
View File
@@ -0,0 +1,286 @@
import { randomUUID } from 'node:crypto'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { z } from 'zod'
import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason'
import type { StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
import { ProviderAcquisitionStarts } from '../provider-process/provider-acquisition-starts'
import { removeAntigravitySignInSessionFiles } from '../antigravity/antigravity-sign-in-session-files'
import { SOURCE_CONTROL_GENERATION_TIMEOUT_MS } from '../text-generation/source-control-generation-limits'
import { STRUCTURED_CHILD_ENV_TO_DELETE } from '../runtime/structured-session-child-env'
import { resolveAcpLaunchInvocation } from './acp-structured-launch-resolution'
import { prepareAcpLaunch } from './acp-structured-launch-preparation'
import { acpPrivateTempConnect } from './acp-private-temp'
import { AcpRpcError } from './acp-errors'
import type { AcpStructuredConnection } from './acp-structured-connection'
import type { AcpStructuredSessionAdapterDeps } from './acp-structured-session-adapter-deps'
type TextInput = Parameters<NonNullable<StructuredAgentSessionAdapter['generateText']>>[0]
const envelope = z.looseObject({ sessionId: z.string() })
const TASK_ENV_TO_DELETE = [
...STRUCTURED_CHILD_ENV_TO_DELETE,
'ORCA_AGENT_SESSION_ID',
'ORCA_AGENT_SESSION_SPAWN_TOKEN',
'ORCA_TERMINAL_HANDLE',
'ORCA_STRUCTURED_SESSION',
'ORCA_CHAT_SCRATCH_DIR',
'ORCA_CHAT_VISUALS_DIR'
]
/** Disposable native tasks belong to the adapter so shutdown also owns uncertain closes. */
export class AcpTextTasks {
private readonly children = new ProviderAcquisitionStarts<AcpStructuredConnection>()
private readonly shutdown = new AbortController()
private readonly pending = new Set<Promise<string | null>>()
constructor(
private readonly deps: Pick<
AcpStructuredSessionAdapterDeps,
'spec' | 'textTaskLaunch' | 'connect' | 'dataDirectory' | 'logger'
>
) {}
generate = async (input: TextInput): Promise<string | null> => {
const gate = this.deps.spec.launchGate?.enter(this.shutdown.signal)
let rootExited = Promise.resolve()
const task = this.run(input, gate?.signal ?? this.shutdown.signal, (connection) => {
rootExited = new Promise((resolve) => connection.onExit(() => resolve()))
})
this.pending.add(task)
const finalized = task.finally(() => {
this.pending.delete(task)
gate?.leave()
})
return input.shouldExecute ? finalized.finally(() => rootExited) : finalized
}
async closeAll(): Promise<boolean> {
this.shutdown.abort()
await Promise.allSettled(this.pending)
return (
await Promise.all(this.children.failedSessionIds().map((id) => this.children.stopFailed(id)))
).every(Boolean)
}
private async run(
input: TextInput,
parent: AbortSignal,
observeRoot: (connection: AcpStructuredConnection) => void
): Promise<string | null> {
const { spec } = this.deps
const deadline = new AbortController()
const timer = setTimeout(
() => deadline.abort(new Error('Chat naming timed out')),
SOURCE_CONTROL_GENERATION_TIMEOUT_MS
)
const signal = AbortSignal.any([parent, deadline.signal])
const stale = Symbol('stale naming task')
const checkCurrent = (): void => {
signal.throwIfAborted()
if (input.shouldExecute?.() === false) {
throw stale
}
}
let connection: AcpStructuredConnection | undefined
let cwd: string | undefined
let home = ''
let sessionId = ''
let completed = false
let starting = false
let done = false
let disposed = false
let unsubscribe = (): void => {}
const dispose = async (): Promise<void> => {
if (starting || !done || disposed || !cwd || (connection && !connection.exited)) {
return
}
disposed = true
try {
if (spec.agent === 'antigravity') {
await removeAntigravitySignInSessionFiles(home, sessionId, cwd)
}
await rm(cwd, { recursive: true, force: true })
} catch (error) {
this.deps.logger?.warn('Naming helper files could not be removed', {
scope: 'acp-text-task',
error
})
}
}
try {
signal.throwIfAborted()
const invocation = this.deps.textTaskLaunch
if (!invocation) {
throw new Error('Native text task launch is unavailable')
}
const launch = await waitForPromiseWithSignal(
prepareAcpLaunch(
this.deps,
() => resolveAcpLaunchInvocation(spec, input.accountHome, invocation),
() => signal.throwIfAborted()
),
signal
)
home = launch.env.GEMINI_HOME ?? ''
cwd = await mkdtemp(join(tmpdir(), 'orca-chat-name-'))
signal.throwIfAborted()
const failed = new AbortController()
const taskSignal = AbortSignal.any([signal, failed.signal])
const reject = (): never => {
const error = new AcpRpcError(
-32601,
'Tools and user input are unavailable during chat naming'
)
failed.abort(error)
throw error
}
const promptId = randomUUID()
const terminal = Promise.withResolvers<{ stopReason: string }>()
const notification = (method: string, params: unknown): void => {
if (!sessionId || envelope.safeParse(params).data?.sessionId !== sessionId) {
return
}
const completion = spec.dialect.textTaskCompletion?.(method, params)
if (completion?.promptId === promptId) {
terminal.resolve(completion)
}
const mapped = spec.dialect.notification?.(method, params, Date.now())
if (
mapped?.disposition === 'map' &&
mapped.turn === promptId &&
!mapped.replay &&
mapped.end
) {
terminal.resolve(mapped.end)
}
}
const spawn = (connect: typeof this.deps.connect): AcpStructuredConnection => {
connection = connect(
{
...launch,
cwd,
args: spec.args({ fullAccess: false, pluginDir: null }),
envToDelete: [...launch.envToDelete, ...TASK_ENV_TO_DELETE]
},
{
clientInfo: { name: 'orca-chat-naming', version: '1' },
signIn: spec.signIn,
onPermission: reject,
onRequest: reject,
onExtensionNotification: notification
}
)
observeRoot(connection)
this.children.retainFailed(promptId, connection)
spec.launchGate?.retain(connection)
connection.onExit(() => {
void dispose()
})
return connection
}
const server = await waitForPromiseWithSignal(
spec.privateTempDirectory
? acpPrivateTempConnect(this.deps, checkCurrent).then(spawn)
: Promise.resolve().then(() => {
checkCurrent()
return spawn(this.deps.connect)
}),
signal
)
let output = ''
unsubscribe = server.subscribe((event) => {
const params = event.kind === 'known' ? event.notification : event.raw
notification('session/update', params)
if (params.sessionId !== sessionId) {
return
}
const update = params.update
if (update.sessionUpdate === 'tool_call' || update.sessionUpdate === 'tool_call_update') {
failed.abort(new Error('The naming agent attempted tool work'))
}
if (
event.kind === 'known' &&
event.notification.update.sessionUpdate === 'agent_message_chunk'
) {
const content = event.notification.update.content
if (content.type === 'text') {
if (output.length + content.text.length > 128_000) {
failed.abort(new Error('Chat name output exceeded its limit'))
} else {
output += content.text
}
}
}
})
const initialized = await waitForPromiseWithSignal(server.initialize(), taskSignal)
const authMethodId = spec.authMethod?.({
advertised: initialized.authMethods?.map((method) => method.id) ?? [],
env: launch.env
})
starting = true
await waitForPromiseWithSignal(
server
.start({ cwd, mcpServers: [], authMethodId })
.then((started) => {
sessionId = started.sessionId
})
.finally(() => {
starting = false
void dispose()
}),
taskSignal
)
const response = await waitForPromiseWithSignal(
Promise.race([
server.prompt([{ type: 'text', text: input.prompt }], { promptId, requestId: promptId }),
terminal.promise
]),
taskSignal
)
taskSignal.throwIfAborted()
if (['cancelled', 'error', 'rate_limit'].includes(response.stopReason)) {
throw new Error('The naming agent did not complete its reply')
}
completed = true
return output
} catch (error) {
if (error === stale) {
return null
}
throw error
} finally {
clearTimeout(timer)
unsubscribe()
deadline.abort()
const cleanup = async (work: () => Promise<unknown>): Promise<void> => {
try {
await waitForPromiseWithSignal(work(), AbortSignal.timeout(2_000))
} catch (error) {
this.deps.logger?.warn('Naming helper protocol cleanup failed', {
scope: 'acp-text-task',
error
})
}
}
if (connection && sessionId) {
const server = connection
if (!completed) {
await cleanup(() => server.cancel())
}
if (spec.agent === 'grok') {
await cleanup(() =>
server.requestExtension('_x.ai/session/delete', { sessionId, cwd }, 2_000)
)
}
}
if (connection && !(await connection.close().catch(() => false))) {
this.deps.logger?.warn('Naming helper exit is unproven; retained for cleanup', {
scope: 'acp-text-task'
})
}
done = true
await dispose()
}
}
}
@@ -180,6 +180,14 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi
return reader(input)
}
generateText: NonNullable<StructuredAgentSessionAdapter['generateText']> = (input) => {
const { adapter } = this.requireAgent(input)
if (!adapter.generateText) {
return Promise.reject(new Error(`${input.agent} cannot run an independent text task`))
}
return adapter.generateText(input)
}
copyConversation: NonNullable<StructuredAgentSessionAdapter['copyConversation']> = (input) => {
const { adapter } = this.requireAgent(input.identity)
if (!adapter.copyConversation) {
@@ -301,7 +309,9 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi
return route?.state === 'live' ? route.registration.adapter : null
}
private requireAgent(identity: AgentSessionJournalIdentity): StructuredAgentRegistration {
private requireAgent(
identity: Pick<AgentSessionJournalIdentity, 'agent'>
): StructuredAgentRegistration {
const registration = this.agents.registration(identity.agent)
if (!registration) {
throw new Error(`structured sessions do not support ${identity.agent}`)
@@ -293,6 +293,13 @@ export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop &
sessionId: string
fence: number
}): Promise<StructuredAgentSessionLiveOptions>
/** A fresh independent text task; never acquires or writes a turn in the chat. */
generateText?(input: {
agent: string
accountHome: AgentSessionAccountHome
prompt: string
shouldExecute?: () => boolean
}): Promise<string | null>
/** Copy the provider conversation without changing the chat’s ID; omit for CLI-based forks. */
copyConversation?(input: {
identity: AgentSessionJournalIdentity
@@ -3,6 +3,7 @@ import { AgentSessionJournal } from './agent-session-journal/journal-store'
import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope'
import { structuredChatNamingDeps } from '../runtime/structured-chat-naming-runtime'
import { getDefaultSettings } from '../../shared/constants'
import { normalizeSourceControlAiSettings } from '../../shared/source-control-ai-settings'
import { setStructuredAgentSessionHost } from './agent-session-wire/structured-agent-session-registry'
import * as agentLaunch from '../text-generation/source-control-agent-launch'
import * as homeLock from '../codex-cli/codex-home-process-lock'
@@ -61,10 +62,15 @@ function productionNaming(home: string) {
})
const settings = getDefaultSettings('/isolated')
settings.defaultTuiAgent = 'codex'
settings.sourceControlAi = normalizeSourceControlAiSettings({
...normalizeSourceControlAiSettings(settings.sourceControlAi),
actions: { conversationName: { agentId: 'codex' } }
})
const deps = structuredChatNamingDeps(
() => ({ getSettings: () => settings }),
{
resolveWorkspace: async () => ({ path: '/same-folder', executionHostId: 'local' }),
resolveNamingEnvironment: async () => ({ CODEX_HOME: home }),
getAgentEnvResolvers: () => ({
resolveBaseEnvironment: async () => ({ CODEX_HOME: home })
}),
@@ -45,6 +45,7 @@ it.each(['J', 'R', 'P', 'L', 'T', 'U', 'C'] as const)(
() => ({ getSettings: () => getDefaultSettings('/isolated') }),
{
resolveWorkspace: async () => ({ path: '/fixture', executionHostId: 'local' }),
resolveNamingEnvironment: async () => ({}),
getAgentEnvResolvers: () => undefined,
workerTaskTitle: () => null,
retitleOpenTab: runtime.refreshStructuredConversationTabTitle.bind(runtime)
@@ -0,0 +1,46 @@
[
{
"provider": "opencode",
"version": "1.19.3",
"exitCode": 0,
"name": "QA2470 Lighthouse Signal",
"error": null,
"stdout": "{\"type\":\"step_start\",\"timestamp\":1792991875300,\"sessionID\":\"ses_e895b8375fferhsqRugDBAfOMG\",\"part\":{\"id\":\"prt_176a4d0e3001m57TVJpsACK53O\",\"messageID\":\"msg_176a47d7f001JaEXkAqkc0ZZ9i\",\"sessionID\":\"ses_e895b8375fferhsqRugDBAfOMG\",\"snapshot\":\"1335331e97b2b8302af6756ec01cb1b1da251fb3\",\"type\":\"step-start\"}}\n{\"type\":\"text\",\"timestamp\":1792991875307,\"sessionID\":\"ses_e895b8375fferhsqRugDBAfOMG\",\"part\":{\"id\":\"prt_176a4d0e9001Kd17lu7Ex0q8Tx\",\"messageID\":\"msg_176a47d7f001JaEXkAqkc0ZZ9i\",\"sessionID\":\"ses_e895b8375fferhsqRugDBAfOMG\",\"type\":\"text\",\"text\":\"QA2470 Lighthouse Signal\",\"time\":{\"start\":1792991875305,\"end\":1792991875306}}}\n{\"type\":\"step_finish\",\"timestamp\":1792991875504,\"sessionID\":\"ses_e895b8375fferhsqRugDBAfOMG\",\"part\":{\"id\":\"prt_176a4d1ab001RJrfENLLX1KW4r\",\"reason\":\"stop\",\"snapshot\":\"1335331e97b2b8302af6756ec01cb1b1da251fb3\",\"messageID\":\"msg_176a47d7f001JaEXkAqkc0ZZ9i\",\"sessionID\":\"ses_e895b8375fferhsqRugDBAfOMG\",\"type\":\"step-finish\",\"tokens\":{\"total\":8951,\"input\":7153,\"output\":6,\"reasoning\":0,\"cache\":{\"write\":0,\"read\":1792}},\"cost\":0}}\n",
"stderr": "",
"stdoutSha256": "f4fb472fd517f7c9b71d368c711153ffa317c8384747cebf7844850d0430f8a7",
"stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
},
{
"provider": "opencode2",
"version": "2.0.21",
"exitCode": 0,
"name": "QA2409-OPENCODE2-RECORDED-REAL-OUTPUT",
"error": null,
"stdout": "{\"type\":\"step_start\",\"timestamp\":1792963133760,\"sessionID\":\"ses_e8b11e04fffejwGieQ5aEaX725\",\"part\":{\"id\":\"prt_174ee4140001jPttpvqKq3nqUG\",\"sessionID\":\"ses_e8b11e04fffejwGieQ5aEaX725\",\"messageID\":\"msg_174ee2aca0010gpdeVAaLShy3c\",\"type\":\"step-start\",\"snapshot\":\"63f0c12995d872e305bfefca54db12330153945a\"}}\n{\"type\":\"text\",\"timestamp\":1792963133934,\"sessionID\":\"ses_e8b11e04fffejwGieQ5aEaX725\",\"part\":{\"id\":\"prt_174ee4143001wQsGHsX1N5Y522\",\"sessionID\":\"ses_e8b11e04fffejwGieQ5aEaX725\",\"messageID\":\"msg_174ee2aca0010gpdeVAaLShy3c\",\"type\":\"text\",\"text\":\"QA2409-OPENCODE2-RECORDED-REAL-OUTPUT\",\"time\":{\"start\":1792963133763,\"end\":1792963133934}}}\n{\"type\":\"step_finish\",\"timestamp\":1792963134593,\"sessionID\":\"ses_e8b11e04fffejwGieQ5aEaX725\",\"part\":{\"id\":\"prt_174ee4481001E8vuO413WRUmM3\",\"sessionID\":\"ses_e8b11e04fffejwGieQ5aEaX725\",\"messageID\":\"msg_174ee2aca0010gpdeVAaLShy3c\",\"type\":\"step-finish\",\"reason\":\"stop\",\"snapshot\":\"d5bc3db45bd1a4f0f251bdf033349914a8c70ca2\",\"cost\":0,\"tokens\":{\"input\":3979,\"output\":20,\"reasoning\":0,\"cache\":{\"read\":2048,\"write\":0}}}}\n",
"stderr": "",
"stdoutSha256": "361fd0491ec7893d4ee48a5c46f668e8a61b4f23ff47d1f6ef8a02139eaa70ee",
"stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
},
{
"provider": "pi",
"version": "print/text",
"exitCode": 1,
"name": null,
"error": "Pi CLI command failed with code 1",
"stdout": "",
"stderr": "OAuth refresh failed for openai: OpenAI OAuth token request failed (400): {\n \"error\": \"invalid_grant\"\n}\n",
"stdoutSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"stderrSha256": "789ac0ebfa846ae385959e1a56764768bc6cb3faad5c8417d8cc778af40c57b0"
},
{
"provider": "omp",
"version": "17.0.5",
"exitCode": 0,
"name": null,
"error": "OMP returned an empty chat name.",
"stdout": "",
"stderr": "",
"stdoutSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
}
]
@@ -152,6 +152,14 @@ function createAcpAdapter(
})
return new AcpStructuredSessionAdapter({
spec,
textTaskLaunch: {
resolveEnvironment: sources.resolveBaseEnvironment,
resolveLaunchEnv: sources.resolveAgentLaunchEnv,
resolveCommandSettings: sources.resolveCommandSettings,
refreshEnvironmentAfterMiss: sources.refreshAfterMissingProgram,
...deps.acpLaunchCommand,
dataDirectory: deps.stateDirectory
},
...(spec.agent === 'omp'
? {
atRestCommands: new AcpAtRestCommandCatalog(spec, {
@@ -0,0 +1,42 @@
import type { AgentSessionRecord } from '../../shared/agent-session-record'
import { requireLegacyAgentSessionAccountHome } from '../../shared/agent-session-account-home'
import { resolveTuiAgentLaunchEnv } from '../../shared/tui-agent-launch-defaults'
import { acpLaunchSpecFor } from '../acp/acp-launch-specs'
import { getClaudeProfileRouter } from '../claude-accounts/claude-profile-installed-router'
import {
claudeConfigDirEnvPatch,
withoutInheritedClaudeConfigDir
} from '../claude/claude-config-dir-pin'
import { resolveClaudeStructuredLaunchHome } from '../claude/claude-structured-launch-home'
import type { CommitMessageGenerationTarget } from '../text-generation/source-control-text-generation-types'
/** Default naming follows the native account binding; explicit recipes retain CLI routing. */
export async function chatNamingAgentTarget(
record: AgentSessionRecord,
cwd: string,
resolveEnvironment: (agent: string) => Promise<Record<string, string>>,
settings: Parameters<typeof resolveTuiAgentLaunchEnv>[1]
): Promise<CommitMessageGenerationTarget> {
let env = await resolveEnvironment(record.provider)
const spec = acpLaunchSpecFor(record.provider)
if (spec) {
env = spec.account.environment(record.accountHome, env)
const deleted = spec.scrubEnvironment?.(env, process.env) ?? []
const targetEnv: NodeJS.ProcessEnv = { ...env }
for (const key of deleted) {
targetEnv[key] = undefined
}
return { kind: 'local', cwd, env: targetEnv }
}
const home = requireLegacyAgentSessionAccountHome(record.accountHome)
if (record.provider === 'claude') {
env = {
...withoutInheritedClaudeConfigDir(env),
...resolveTuiAgentLaunchEnv('claude', settings)
}
const path = await resolveClaudeStructuredLaunchHome(getClaudeProfileRouter(), env, home.path)
const patch = claudeConfigDirEnvPatch(path, { env })
return { kind: 'local', cwd, env: { CLAUDE_CONFIG_DIR: undefined, ...env, ...patch } }
}
return { kind: 'local', cwd, env: { ...env, [home.variable]: home.path } }
}
@@ -0,0 +1,320 @@
import { createHash } from 'node:crypto'
import { existsSync, readFileSync } from 'node:fs'
import { dirname } from 'node:path'
import { afterEach, beforeEach, expect, it, onTestFinished, vi } from 'vitest'
import { getDefaultSettings } from '../../shared/constants'
import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture'
import { isTuiAgent } from '../../shared/tui-agent-config'
import { getCommitMessageAgentSpec } from '../../shared/commit-message-agent-spec'
import { toSshExecutionHostId, type ExecutionHostId } from '../../shared/execution-host'
import {
savedChatNamingCatalog,
observeChatNamingCatalogWork
} from '../../shared/chat-naming-catalog-work.test-fixture'
import type { SourceControlActionRecipe } from '../../shared/source-control-ai-actions'
import type { SpawnSourceControlAgent } from '../text-generation/source-control-text-generation-types'
import { createMockDiscoveryChild } from '../text-generation/commit-message-text-generation-test-harness'
import { structuredChatNamingDeps } from './structured-chat-naming-runtime'
import type { StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
import { AcpTextTasks } from '../acp/acp-text-task'
import { acpLaunchSpecFor } from '../acp/acp-launch-specs'
import { FakeAcpChild } from '../acp/acp-scripted-connection.test-fixture'
import recordings from './__fixtures__/chat-naming-recorded-output.json'
const boundary = vi.hoisted(() => ({
spawn: vi.fn<SpawnSourceControlAgent>(),
native: vi.fn<NonNullable<StructuredAgentSessionAdapter['generateText']>>(),
account: vi.fn((_provider: string, id: string) => ({
XDG_DATA_HOME: `/profiles/${id}/data`,
XDG_STATE_HOME: `/profiles/${id}/state`
}))
}))
vi.mock('../text-generation/source-control-agent-launch', () => ({
spawnSourceControlAgent: boundary.spawn
}))
vi.mock('../native-chat/agent-session-wire/structured-agent-session-registry', () => ({
getStructuredAgentSessionHost: () => ({ deps: { adapter: { generateText: boundary.native } } })
}))
vi.mock('../managed-data-accounts/service', () => ({
getManagedDataAccountService: () => ({
restoreOriginalEnvironment: () => {},
environmentForAccount: boundary.account
})
}))
beforeEach(() => {
vi.clearAllMocks()
boundary.spawn.mockReset()
boundary.native.mockReset()
vi.stubEnv('ORCA_PI_SOURCE_AGENT_DIR', undefined)
})
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllEnvs()
})
function rig() {
const settings = getDefaultSettings('/isolated')
settings.defaultTuiAgent = 'codex'
settings.sourceControlAi = savedChatNamingCatalog(0)
const env = {
PATH: '/usr/bin',
PI_CODING_AGENT_DIR: '/configured/pi',
OPENCODE_CONFIG_DIR: '/configured/oc',
OPENCODE_AUTH_CONTENT: 'current-account-sentinel'
}
const runtime = {
resolveWorkspace: vi.fn(
async (): Promise<{ path: string; executionHostId: ExecutionHostId }> => ({
path: '/workspace/folder',
executionHostId: 'local'
})
),
resolveNamingEnvironment: vi.fn(async () => env),
getAgentEnvResolvers: () => ({ resolveBaseEnvironment: async () => env }),
workerTaskTitle: () => null,
retitleOpenTab: () => {}
}
return {
settings,
runtime,
deps: structuredChatNamingDeps(() => ({ getSettings: () => settings }), runtime, {
warn: vi.fn(),
error: vi.fn()
})
}
}
function reply(stdout: string, stderr = '', code = 0) {
const child = createMockDiscoveryChild()
child.stdin.end.mockImplementation(() =>
queueMicrotask(() => {
child.stdout.emit('data', Buffer.from(stdout))
child.stderr.emit('data', Buffer.from(stderr))
child.emit('exit', code, null)
child.emit('close', code, null)
})
)
boundary.spawn.mockReturnValue(child)
return child
}
function nativeReply() {
const spec = acpLaunchSpecFor('grok')
if (!spec) {
throw new Error('Missing native task spec')
}
const tasks = new AcpTextTasks({
spec,
dataDirectory: '/fixture/state',
textTaskLaunch: {
dataDirectory: '/fixture/state',
inheritedEnv: {},
homePath: '/fixture',
resolveEnvironment: async () => ({ PATH: '/usr/bin' }),
resolveCommand: () => '/fixture/grok'
},
connect: (launch, options) => {
expect(launch.env?.GROK_HOME).toBe('/recorded/grok')
const child = new FakeAcpChild(launch, options)
const { agent } = child
const sessionId = '11111111-1111-4111-8111-111111111111'
agent.on('initialize', (frame) =>
agent.reply(frame, { protocolVersion: 1, agentCapabilities: {} })
)
agent.on('session/new', (frame) => agent.reply(frame, { sessionId }))
agent.on('_x.ai/session/delete', (frame) => agent.reply(frame, { success: true }))
agent.on('session/prompt', (frame) => {
agent.notify('session/update', {
sessionId,
update: {
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: 'Native title' }
}
})
agent.reply(frame, { stopReason: 'end_turn' })
})
return child
}
})
boundary.native.mockImplementation(tasks.generate)
onTestFinished(async () => {
await tasks.closeAll()
})
}
it.each(recordings)(
'consumes recorded $provider output through runtime, request, planner and parser',
async (capture) => {
const { provider, stdout, stderr, exitCode, name, error } = capture
if (!isTuiAgent(provider)) {
throw new Error('Unknown recorded provider')
}
expect(createHash('sha256').update(stdout).digest('hex')).toBe(capture.stdoutSha256)
expect(createHash('sha256').update(stderr).digest('hex')).toBe(capture.stderrSha256)
const state = rig()
const source = state.settings.sourceControlAi
const spec = getCommitMessageAgentSpec(provider)
if (!source || !spec) {
throw new Error('Missing naming configuration')
}
source.agentId = 'codex'
if (provider === 'pi') {
source.selectedModelByAgent.pi = 'openai/gpt-6.1-sol'
source.discoveredModelsByAgent = {
pi: [{ id: 'openai/gpt-6.1-sol', label: 'Recorded model' }]
}
}
const record = agentSessionRecordFixture()
record.provider = provider
record.accountHome =
provider === 'opencode' || provider === 'opencode2'
? { kind: 'opencode', locator: { kind: 'managed', managedProfileId: 'recorded' } }
: { variable: 'PI_CODING_AGENT_DIR', path: '/recorded/pi' }
const prompt = `Name this chat from the user's first request.
Write a short, specific title of at most 48 characters.
Output only the title, with no heading, quotes, or explanation.
First request:
Repair login`
const child = reply(stdout, stderr, exitCode)
let promptPath = ''
if (spec.promptDelivery === 'file') {
boundary.spawn.mockImplementationOnce((input) => {
promptPath = input.args.find((arg) => arg.startsWith('@'))?.slice(1) ?? ''
expect(readFileSync(promptPath)).toEqual(Buffer.from(`\uFEFF${prompt}`))
return child
})
}
const result = await state.deps.generate(record, 'Repair login')
if (name) {
expect(result).toEqual({ name })
} else {
expect(result).toMatchObject({ error: expect.stringContaining(error ?? '') })
expect(result).not.toHaveProperty('name')
if (provider === 'pi') {
expect(result).toMatchObject({ failureOutput: { exitCode: 1, stdout, stderr } })
}
}
expect(boundary.spawn).toHaveBeenCalledOnce()
const spawn = boundary.spawn.mock.calls[0]?.[0]
// Delivery follows the common spec; the empty OMP recording remains a negative output control.
expect(spawn).toMatchObject({
binary: spec.binary,
args: spec.buildArgs({
prompt: spec.promptDelivery === 'file' ? `@${promptPath}` : prompt,
model: provider === 'pi' ? 'openai/gpt-6.1-sol' : 'default'
}),
cwd: '/workspace/folder',
stdinMode: 'pipe'
})
expect(child.stdin.end).toHaveBeenCalledExactlyOnceWith(
spec.promptDelivery === 'stdin' ? prompt : undefined
)
if (spec.promptDelivery === 'file') {
expect(spawn?.args).not.toContain(prompt)
await vi.waitFor(() => expect(existsSync(dirname(promptPath))).toBe(false))
}
if (provider === 'opencode' || provider === 'opencode2') {
expect(spawn?.env).toMatchObject({
XDG_DATA_HOME: '/profiles/recorded/data',
XDG_STATE_HOME: '/profiles/recorded/state',
OPENCODE_AUTH_CONTENT: undefined
})
expect(boundary.account).toHaveBeenCalledWith('opencode', 'recorded')
// The v2 recording used standalone/argv; these assertions separately cover today's launch.
expect(spawn?.args).toEqual(['run', '--agent', 'build', '--format', 'json'])
} else {
expect(spawn?.env?.PI_CODING_AGENT_DIR).toBe('/recorded/pi')
}
expect(boundary.native).not.toHaveBeenCalled()
if (provider === 'pi') {
source.actions = { conversationName: { agentId: 'pi' } }
boundary.spawn.mockClear()
const explicit = reply(stdout, stderr, exitCode)
expect(await state.deps.generate(record, 'Repair login')).toEqual(result)
expect(boundary.spawn.mock.calls[0]?.[0]).toMatchObject({
binary: spawn?.binary,
args: spawn?.args,
env: { PI_CODING_AGENT_DIR: '/configured/pi' }
})
expect(explicit.stdin.end.mock.calls).toEqual(child.stdin.end.mock.calls)
}
}
)
it.each([100, 1000])(
'bounds actual eligible and skipped naming catalog work for %i rows',
async (size) => {
const state = rig()
const source = savedChatNamingCatalog(size)
state.settings.sourceControlAi = source
const { work, reset } = observeChatNamingCatalogWork()
const samples: Record<string, typeof work> = {}
const record = agentSessionRecordFixture()
const recipes: (SourceControlActionRecipe | undefined)[] = [
undefined,
{ agentId: 'claude' },
{ agentArgs: '--model fast' },
{ agentId: null, agentArgs: '--model fast' }
]
for (const [index, recipe] of recipes.entries()) {
source.agentId = index === 0 ? null : 'claude'
source.actions = recipe ? { conversationName: recipe } : {}
boundary.spawn.mockClear()
reply('Catalog title')
reset()
expect(await state.deps.generate(record, 'Repair login')).toEqual({ name: 'Catalog title' })
expect(work.copiedRows).toBeLessThanOrEqual(3 * size)
expect(work.copiedRows).toBeGreaterThanOrEqual(size)
expect(work.copiedBytes).toBeLessThanOrEqual(3 * 64 * size)
expect(work.copiedBytes).toBeGreaterThanOrEqual(32 * size)
expect(work.selectedRowReads).toBeLessThanOrEqual(2 * size + 2)
expect(work.selectedRowReads).toBeGreaterThanOrEqual(index === 3 ? 0 : size)
const agent = index === 3 ? 'codex' : 'claude'
expect(boundary.spawn).toHaveBeenCalledOnce()
expect(boundary.spawn.mock.calls[0]?.[0].binary).toBe(agent)
if (index > 1) {
expect(boundary.spawn.mock.calls[0]?.[0].args).toEqual(
expect.arrayContaining(['--model', 'fast'])
)
}
if (index === 0) {
expect(boundary.spawn.mock.calls[0]?.[0].env?.CLAUDE_CONFIG_DIR).toBe('/home/user/.claude')
}
samples[`cli-${index}`] = { ...work }
}
source.actions = {}
source.agentId = null
record.provider = 'grok'
record.accountHome = { variable: 'GROK_HOME', path: '/recorded/grok' }
nativeReply()
reset()
expect(await state.deps.generate(record, 'Repair login')).toEqual({ name: 'Native title' })
expect(work.copiedRows).toBeLessThanOrEqual(2 * size)
expect(work.copiedRows).toBeGreaterThanOrEqual(size)
expect(work.copiedBytes).toBeLessThanOrEqual(2 * 64 * size)
expect(work.selectedRowReads).toBe(0)
expect(boundary.native).toHaveBeenCalledWith(
expect.objectContaining({ agent: 'grok', accountHome: record.accountHome })
)
samples.native = { ...work }
for (const kind of ['remote', 'wsl']) {
if (kind === 'wsl') {
record.location.wslDistro = 'Ubuntu'
}
state.runtime.resolveWorkspace.mockResolvedValue({
path: '/remote',
executionHostId: kind === 'remote' ? toSshExecutionHostId('target') : 'local'
})
boundary.spawn.mockClear()
boundary.native.mockClear()
reset()
expect(await state.deps.generate(record, 'Repair login')).toBeNull()
expect(work).toEqual({ copiedRows: 0, copiedBytes: 0, selectedRowReads: 0 })
expect(boundary.spawn).not.toHaveBeenCalled()
expect(boundary.native).not.toHaveBeenCalled()
samples[kind] = { ...work }
}
console.log(JSON.stringify({ metric: 'chat-naming-runtime-catalog', size, samples }))
}
)
@@ -1,10 +1,16 @@
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { ClaudeProfileRouter } from '../claude-accounts/claude-profile-router'
import { installClaudeProfileRouter } from '../claude-accounts/claude-profile-installed-router'
import { submission as journalSubmission } from '../native-chat/agent-session-wire/structured-agent-session-restart-resume-test-harness'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { getDefaultSettings } from '../../shared/constants'
import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture'
import { toSshExecutionHostId } from '../../shared/execution-host'
import { normalizeSourceControlAiSettings } from '../../shared/source-control-ai-settings'
import { structuredChatNamingDeps } from './structured-chat-naming-runtime'
import { STRUCTURED_AGENT_RUNTIME_CATALOG } from './structured-agent-runtime-metadata'
import type {
AgentJournalRenderItem,
AgentJournalSubmission,
@@ -13,6 +19,11 @@ import type {
import type * as TextGeneration from '../text-generation/commit-message-text-generation'
const mocks = vi.hoisted(() => ({
account: vi.fn((_provider: string, id: string) => ({
XDG_DATA_HOME: `/profiles/${id}/data`,
XDG_STATE_HOME: `/profiles/${id}/state`
})),
native: vi.fn(async () => 'Native title'),
generate: vi.fn(async () => ({ success: true as const, name: 'Login repair' })),
target: vi.fn(async (cwd: string) => ({ kind: 'local' as const, cwd })),
publishConversationName: vi.fn<(sessionId: string) => void>(),
@@ -28,11 +39,23 @@ vi.mock('../agent-hooks/first-work-generation-target', () => ({
}))
vi.mock('../native-chat/agent-session-wire/structured-agent-session-registry', () => ({
getStructuredAgentSessionHost: () => ({
deps: { adapter: { generateText: mocks.native } },
publishConversationName: mocks.publishConversationName,
journalSnapshot: mocks.journalSnapshot
})
}))
vi.mock('../managed-data-accounts/service', () => ({
getManagedDataAccountService: () => ({
restoreOriginalEnvironment: () => {},
environmentForAccount: mocks.account
})
}))
const roots: string[] = []
afterEach(async () => {
installClaudeProfileRouter(undefined)
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
})
beforeEach(() => vi.clearAllMocks())
function rig() {
@@ -44,6 +67,9 @@ function rig() {
path: '/workspace/folder',
executionHostId: 'local' as const
})),
resolveNamingEnvironment: vi.fn(async (): Promise<Record<string, string>> => ({
PATH: '/usr/bin'
})),
getAgentEnvResolvers: vi.fn(() => undefined),
workerTaskTitle: vi.fn(() => null),
retitleOpenTab: vi.fn()
@@ -58,6 +84,26 @@ function rig() {
}
describe('structured chat naming runtime', () => {
it.each(['grok', 'antigravity'] as const)(
'codes %s native naming failures without exposing provider text',
async (provider) => {
const state = rig()
const record = agentSessionRecordFixture()
record.provider = provider
for (const outcome of ['throw', 'empty'] as const) {
if (outcome === 'throw') {
mocks.native.mockRejectedValueOnce(new Error('private provider diagnostic'))
} else {
mocks.native.mockResolvedValueOnce('')
}
expect(await state.deps.generate(record, 'Repair login')).toEqual({
errorCode: `${provider}Failed`,
error: `${provider === 'grok' ? 'Grok' : 'Antigravity'} could not name this chat. Try generating the name again.`
})
}
}
)
it.each([10, 100])(
'scopes naming prompts with linear visits over %i retained items',
async (count) => {
@@ -140,8 +186,8 @@ describe('structured chat naming runtime', () => {
await state.deps.generate(agentSessionRecordFixture(), 'Repair login')
expect(mocks.generate).toHaveBeenLastCalledWith(
{ firstPrompt: 'Repair login' },
expect.objectContaining({ agentId: 'codex' }),
{ kind: 'local', cwd: '/workspace/folder' },
expect.objectContaining({ agentId: 'claude' }),
expect.objectContaining({ kind: 'local', cwd: '/workspace/folder' }),
undefined
)
state.settings.sourceControlAi = {
@@ -167,13 +213,166 @@ describe('structured chat naming runtime', () => {
)
})
it.each(
STRUCTURED_AGENT_RUNTIME_CATALOG.map(({ metadata }) => metadata.definition.agent).filter(
(agent) => agent !== 'pi' && agent !== 'omp'
)
)('names a %s chat with its own agent regardless of global selection', async (provider) => {
const state = rig()
state.settings.sourceControlAi = {
...normalizeSourceControlAiSettings(state.settings.sourceControlAi),
agentId: 'codex'
}
const record = agentSessionRecordFixture()
record.provider = provider
record.accountHome =
provider === 'opencode' || provider === 'opencode2'
? { kind: 'opencode', locator: { kind: 'unmanaged' } }
: {
variable:
provider === 'codex'
? 'CODEX_HOME'
: provider === 'claude'
? 'CLAUDE_CONFIG_DIR'
: provider === 'grok'
? 'GROK_HOME'
: provider === 'antigravity'
? 'GEMINI_HOME'
: 'PI_CODING_AGENT_DIR',
path: '/pinned-account'
}
const shouldExecute = () => true
await state.deps.generate(record, 'Repair login', shouldExecute)
if (provider === 'grok' || provider === 'antigravity') {
expect(mocks.native).toHaveBeenCalledWith(
expect.objectContaining({ accountHome: record.accountHome, shouldExecute })
)
expect(mocks.generate).not.toHaveBeenCalled()
} else {
expect(mocks.generate).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ agentId: provider }),
expect.objectContaining({
env: expect.objectContaining(
'variable' in record.accountHome
? { [record.accountHome.variable]: record.accountHome.path }
: {}
)
}),
shouldExecute
)
}
expect(state.settings.sourceControlAi?.actions?.conversationName?.agentId).toBeUndefined()
})
it('uses the current routed Claude account', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-naming-account-test-'))
roots.push(root)
const home = join(root, 'claude-profiles', 'selected', 'home')
await mkdir(home, { recursive: true })
await writeFile(join(root, 'claude-profiles', 'selected', 'profile.json'), '{}')
installClaudeProfileRouter(
new ClaudeProfileRouter({
getSettings: () => ({
claudeManagedAccounts: [],
activeClaudeManagedAccountId: 'selected',
agentStatusHooksEnabled: false,
disabledTuiAgents: []
}),
dataRoot: root,
userHome: join(root, 'personal'),
env: {}
})
)
const state = rig()
const record = agentSessionRecordFixture()
await state.deps.generate(record, 'Repair login')
expect(mocks.generate).toHaveBeenLastCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({ env: expect.objectContaining({ CLAUDE_CONFIG_DIR: home }) }),
undefined
)
})
it.each([undefined, '/user-configured/claude'])(
'distinguishes the explicit Claude directory %s from inherited environment under System default',
async (configuredDir) => {
const root = await mkdtemp(join(tmpdir(), 'orca-naming-system-account-test-'))
roots.push(root)
const router = new ClaudeProfileRouter({
getSettings: () => ({
claudeManagedAccounts: [],
activeClaudeManagedAccountId: null,
agentStatusHooksEnabled: false,
disabledTuiAgents: []
}),
dataRoot: root,
userHome: join(root, 'personal'),
env: {}
})
installClaudeProfileRouter(router)
const state = rig()
state.settings.agentDefaultEnv = {
claude: configuredDir ? { CLAUDE_CONFIG_DIR: configuredDir } : {}
}
state.runtime.resolveNamingEnvironment.mockResolvedValue({
PATH: '/usr/bin',
CLAUDE_CONFIG_DIR: configuredDir ?? '/inherited/claude'
})
await state.deps.generate(agentSessionRecordFixture(), 'Repair login')
expect(mocks.generate).toHaveBeenLastCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({
env: expect.objectContaining({
CLAUDE_CONFIG_DIR: configuredDir ?? router.systemDefaultHome()
})
}),
undefined
)
}
)
it.each([false, true])(
'preserves args-only CLI recipes (explicit null: %s)',
async (explicitNull) => {
const state = rig()
state.settings.defaultTuiAgent = 'claude'
state.settings.sourceControlAi = {
...normalizeSourceControlAiSettings(state.settings.sourceControlAi),
agentId: 'codex',
actions: {
conversationName: {
...(explicitNull ? { agentId: null } : {}),
agentArgs: '--model custom',
commandInputTemplate: 'Title: {firstPrompt}'
}
}
}
const record = agentSessionRecordFixture()
record.provider = 'antigravity'
await state.deps.generate(record, 'Repair login')
expect(mocks.generate).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
agentId: explicitNull ? 'claude' : 'codex',
agentArgs: '--model custom'
}),
expect.anything(),
undefined
)
expect(mocks.native).not.toHaveBeenCalled()
}
)
it('resolves plain folder workspaces without requiring a git repository', async () => {
const state = rig()
const record = agentSessionRecordFixture()
record.location.workspaceKind = 'folder'
expect(await state.deps.generate(record, 'Repair login')).toEqual({ name: 'Login repair' })
expect(state.runtime.resolveWorkspace).toHaveBeenCalledWith(record.location.workspaceId)
expect(mocks.target).toHaveBeenCalledWith('/workspace/folder', 'codex', state.runtime)
expect(state.runtime.resolveNamingEnvironment).toHaveBeenCalledWith('claude')
})
it.each(['ssh', 'wsl'])(
@@ -1,3 +1,14 @@
import {
normalizeSourceControlAiSettings,
resolveSourceControlActionRecipe
} from '../../shared/source-control-ai'
import { normalizeSourceControlActionRecipe } from '../../shared/source-control-ai-actions'
import {
renderConversationNamePrompt,
sanitizeGeneratedConversationName
} from '../../shared/conversation-name-generation'
import { chatNamingAgentTarget } from './structured-chat-naming-agent-launch'
import type { AgentLaunchProfileSettings } from '../../shared/tui-agent-launch-defaults'
import { chatNamingFailure } from '../../shared/chat-naming-failure-copy'
import { agentSessionCurrentContextRows } from '../../shared/agent-session-context-clear'
import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../shared/execution-host'
@@ -18,6 +29,7 @@ type StructuredChatNamingRuntime = {
resolveWorkspace: (
workspaceId: string
) => Promise<{ path: string; executionHostId: ExecutionHostId }>
resolveNamingEnvironment: (agent: string) => Promise<Record<string, string>>
getAgentEnvResolvers: () => CommitMessageAgentEnvironmentResolvers | undefined
workerTaskTitle: StructuredChatNamingDeps['workerTaskTitle']
retitleOpenTab: StructuredChatNamingDeps['onNamed']
@@ -26,6 +38,7 @@ type StructuredChatNamingRuntime = {
export function structuredChatNamingDeps(
getStore: () => {
getSettings: () => Parameters<typeof resolveTextGenerationParams>[0] &
AgentLaunchProfileSettings &
ReturnType<StructuredChatNamingDeps['getSettings']>
},
runtime: StructuredChatNamingRuntime,
@@ -58,18 +71,77 @@ export function structuredChatNamingDeps(
if (workspace.executionHostId !== LOCAL_EXECUTION_HOST_ID) {
return null
}
const current = getStore().getSettings()
const source = normalizeSourceControlAiSettings(
current.sourceControlAi,
current.commitMessageAi
)
const recipe = resolveSourceControlActionRecipe({
settings: current,
actionId: 'conversationName'
})
const useChatAgent = !recipe.agentId && !recipe.agentArgs?.trim()
const log = { scope: 'conversation-name', sessionId: record.sessionId }
if (useChatAgent && (record.provider === 'grok' || record.provider === 'antigravity')) {
const generateText = getStructuredAgentSessionHost()?.deps.adapter.generateText
try {
if (!generateText) {
throw new Error('Native naming is unavailable')
}
if (!recipe.commandInputTemplate?.trim()) {
throw new Error('Chat name template is empty')
}
const output = await generateText({
agent: record.provider,
accountHome: record.accountHome,
prompt: renderConversationNamePrompt({ firstPrompt }, recipe),
shouldExecute
})
if (output === null) {
return null
}
const name = sanitizeGeneratedConversationName(output)
if (name) {
return { name }
}
throw new Error('The naming agent returned an empty title')
} catch (error) {
logger.warn('Native chat naming failed', { ...log, error })
return chatNamingFailure(record.provider === 'grok' ? 'grokFailed' : 'antigravityFailed')
}
}
const settings = resolveTextGenerationParams(
getStore().getSettings(),
useChatAgent
? {
...current,
sourceControlAi: {
...source,
actions: {
...source.actions,
conversationName: normalizeSourceControlActionRecipe({
...recipe,
agentId: record.provider
})
}
}
}
: current,
LOCAL_COMMIT_MESSAGE_HOST_KEY,
'conversationName',
null
)
const log = { scope: 'conversation-name', sessionId: record.sessionId }
if (!settings.ok) {
logger.warn(settings.error, log)
return { error: settings.error }
}
const target = await resolveGenerationTarget(workspace.path, settings.params.agentId, runtime)
const target = useChatAgent
? await chatNamingAgentTarget(
record,
workspace.path,
runtime.resolveNamingEnvironment,
current
)
: await resolveGenerationTarget(workspace.path, settings.params.agentId, runtime)
if (!target) {
const failure = chatNamingFailure('noAgentAvailable')
logger.warn(failure.error, log)
@@ -1,3 +1,6 @@
import { resolveTuiAgentLaunchEnv } from '../../../shared/tui-agent-launch-defaults'
import type { GlobalSettings } from '../../../shared/global-settings-types'
import type { StructuredAgentId } from '../../../shared/agent-session-provider-handle'
import type { RuntimeWorkspaceCatalog } from './workspace-catalog'
import type { OrcaRuntimeWithRefreshPtyWorktreeRecordsFromController } from '../terminal/lifecycle/pty-worktree-refresh'
import type { RuntimeAccountController } from '../runtime-account-controller'
@@ -8,6 +11,17 @@ import { OrcaRuntimeWithStartTuiIdleVisibleReadProbe } from '../terminal/wait/te
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
export abstract class OrcaRuntimeWithWorktreePsDependencies extends OrcaRuntimeWithStartTuiIdleVisibleReadProbe {
/** Apply the host-owning desktop's account overlay to configured agent environment. */
protected structuredAgentLaunchEnv(
agent: StructuredAgentId,
settings: Pick<GlobalSettings, 'agentDefaultEnv'>
) {
return {
...resolveTuiAgentLaunchEnv(agent, settings),
...this.accounts.hostLaunchEnvFor(agent)
}
}
protected abstract listResolvedWorktreeSnapshot(
...args: Parameters<RuntimeWorkspaceCatalog['listResolvedWorktreeSnapshot']>
): ReturnType<RuntimeWorkspaceCatalog['listResolvedWorktreeSnapshot']>
@@ -1,3 +1,4 @@
import { resolveHostStructuredAgentLaunchEnvironment } from '../structured-agent-launch-support'
import { resolveStructuredCodexAccountKind } from '../structured-agent-account-home'
import { getStructuredAgentSessionHost } from '../../native-chat/agent-session-wire/structured-agent-session-registry'
import { collectRuntimeWorktreeAgentSources } from '../runtime-worktree-agent-sources'
@@ -19,8 +20,6 @@ import { attachRuntimeWorktreeAgentRows } from '../runtime-worktree-agent-rows'
import { attachRuntimeWorktreeAgentRowWaits } from '../runtime-worktree-agent-row-waits'
import { compareWorktreePs } from '../runtime-worktree-status-projection'
import type { Repo } from '../../../shared/repo-types'
import type { StructuredAgentId } from '../../../shared/agent-session-provider-handle'
import type { GlobalSettings } from '../../../shared/global-settings-types'
import { enrichMissingRepoGitRemoteIdentities } from '../../repo-git-remote-identity-enrichment'
import { ensureStructuredAgentSessionHost as installStructuredAgentSessionHost } from '../structured-agent-session-runtime'
import {
@@ -46,7 +45,6 @@ import {
structuredWorkerOpenTaskTitle,
structuredWorkerOwesWork
} from '../structured-worker-custody'
import { resolveTuiAgentLaunchEnv } from '../../../shared/tui-agent-launch-defaults'
import { preModeChatGetsFullAccess } from '../../../shared/agent-chat-permission-mode'
import { isTuiAgent } from '../../../shared/tui-agent-config'
import { nativeChatShellEnvironmentPolicy } from '../../../shared/native-chat/native-chat-shell-environment'
@@ -170,17 +168,6 @@ export abstract class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithWorktr
}
}
/** Apply the host-owning desktop's account overlay to configured agent environment. */
protected structuredAgentLaunchEnv(
agent: StructuredAgentId,
settings: Pick<GlobalSettings, 'agentDefaultEnv'>
) {
return {
...resolveTuiAgentLaunchEnv(agent, settings),
...this.accounts.hostLaunchEnvFor(agent)
}
}
listRepos(): Repo[] {
return this.store?.getRepos() ?? []
}
@@ -235,6 +222,8 @@ export abstract class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithWorktr
return { path: target.worktree.path, executionHostId: target.executionHostId }
},
getAgentEnvResolvers: () => this.getCommitMessageAgentEnvironmentResolvers(),
resolveNamingEnvironment: (agent) =>
resolveHostStructuredAgentLaunchEnvironment(agent, this.requireStore().getSettings()),
workerTaskTitle: (record) =>
structuredWorkerOpenTaskTitle(this.getOrchestrationDbIfAvailable?.() ?? null, record),
retitleOpenTab: (workspaceId, sessionId) =>
@@ -1,11 +1,9 @@
import {
buildConversationNamePrompt,
clampConversationNameFirstPrompt,
renderConversationNamePrompt,
sanitizeGeneratedConversationName,
type ConversationNameContext
} from '../../shared/conversation-name-generation'
import type { ResolvedSourceControlAiGenerationParams } from '../../shared/source-control-ai'
import { renderSourceControlActionCommandTemplate } from '../../shared/source-control-ai-actions'
import { captureAgentGenerationFailureOutput } from './agent-failure-output'
import { runGenerationRequest } from './agent-generation-request'
import type {
@@ -22,15 +20,7 @@ export async function generateConversationName(input: {
shouldExecute?: () => boolean
}): Promise<GenerateConversationNameResult> {
const { context, params } = input
const firstPrompt = clampConversationNameFirstPrompt(context.firstPrompt)
const basePrompt = buildConversationNamePrompt({ firstPrompt })
const prompt =
params.commandInputTemplate !== undefined
? renderSourceControlActionCommandTemplate(params.commandInputTemplate, {
basePrompt,
firstPrompt
})
: buildConversationNamePrompt({ firstPrompt }, params.customPrompt ?? '')
const prompt = renderConversationNamePrompt(context, params)
const result = await runGenerationRequest({
...input,
@@ -67,7 +67,23 @@ export async function installWslChatFixture(lane: WslChatHost) {
await writeChatFixtureFile(
lane,
cli,
`#!/bin/sh\nexport ORCA_LANE_CLI_WRAPPER="$0"\nexec ${quotePosixShell(runtime)} ${quotePosixShell(script)} "$@"\n`
`#!/bin/sh
export ORCA_LANE_CLI_WRAPPER="$0"
${
agent === 'claude'
? `export ORCA_LANE_CLAUDE_WRAPPER="$0"
previous=
for argument in "$@"; do
if [ "$previous" = --output-format ] && [ "$argument" = text ]; then
cat > /dev/null
printf '%s\\n' 'WSL fixture title'
exit 0
fi
previous=$argument
done
`
: ''
}exec ${quotePosixShell(runtime)} ${quotePosixShell(script)} "$@"\n`
)
await lane.guest(`chmod 700 ${quotePosixShell(cli)}`)
return { script, cli }
@@ -8,6 +8,7 @@ import { translate } from '@/i18n/i18n'
type AppearanceAdvancedDisclosureProps = {
label?: string
forceOpen?: boolean
showTopBorder?: boolean
className?: string
contentClassName?: string
@@ -17,6 +18,7 @@ type AppearanceAdvancedDisclosureProps = {
/** Search force-opens advanced controls so matches remain reachable. */
export function AppearanceAdvancedDisclosure({
label,
forceOpen = false,
showTopBorder = true,
className,
contentClassName,
@@ -25,7 +27,8 @@ export function AppearanceAdvancedDisclosure({
const searchQuery = useAppStore((state) => state.settingsSearchQuery)
const isSearching = normalizeSettingsSearchQuery(searchQuery).length > 0
const [open, setOpen] = useState(false)
const expanded = open || isSearching
const forced = isSearching || forceOpen
const expanded = open || forced
return (
<div className={cn('mt-3 pt-2', showTopBorder && 'border-t border-border/50', className)}>
@@ -33,8 +36,8 @@ export function AppearanceAdvancedDisclosure({
type="button"
aria-expanded={expanded}
onClick={() => setOpen((prev) => !prev)}
// Search forces expansion; disable collapse instead of fighting the search state.
disabled={isSearching}
// Search matches and unsaved edits must remain reachable.
disabled={forced}
className="flex w-full items-center gap-2 py-1 text-sm font-semibold text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 disabled:cursor-default"
>
<ChevronRight
@@ -6,6 +6,10 @@ import type { GlobalSettings } from '../../../../shared/global-settings-types'
import type { SourceControlAiSettingsPatch } from '../../../../shared/source-control-ai-types'
import { normalizeSourceControlAiSettings } from '../../../../shared/source-control-ai'
import { ChatNamingSetting } from './ChatNamingSetting'
import {
savedChatNamingCatalog,
observeChatNamingCatalogWork
} from '../../../../shared/chat-naming-catalog-work.test-fixture'
import { toast } from 'sonner'
import { startTransition, StrictMode, Suspense } from 'react'
@@ -24,7 +28,7 @@ beforeEach(() => {
vi.clearAllMocks()
})
function renderSetting(overrides: Partial<GlobalSettings> = {}) {
function renderSetting(overrides: Partial<GlobalSettings> = {}, expand = true) {
const settings = { ...getDefaultSettings('/tmp'), ...overrides }
const updateSettings = vi.fn()
const onDirtyChange = vi.fn()
@@ -45,6 +49,9 @@ function renderSetting(overrides: Partial<GlobalSettings> = {}) {
/>
)
const view = render(element())
if (expand && screen.queryByRole('button', { name: 'Advanced' })) {
fireEvent.click(screen.getByRole('button', { name: 'Advanced' }))
}
return { ...view, element, settings, updateSettings, writeSourceControlAiSettings, onDirtyChange }
}
@@ -65,6 +72,105 @@ function changeAgentArgs(value: string): void {
}
describe('Chat names setting', () => {
it.each([100, 1000])(
'bounds actual edit, clear, collapsed and off catalog work for %i rows',
(size) => {
const source = savedChatNamingCatalog(size)
const { work, reset } = observeChatNamingCatalogWork()
const samples: Record<string, typeof work> = {}
const check = (phase: string, passes: number, reads: number) => {
expect(work.copiedRows).toBeLessThanOrEqual(passes * size)
expect(work.copiedRows).toBeGreaterThanOrEqual(size)
expect(work.copiedBytes).toBeLessThanOrEqual(passes * 64 * size)
expect(work.copiedBytes).toBeGreaterThanOrEqual(32 * size)
expect(work.selectedRowReads).toBeLessThanOrEqual(reads)
if (reads) {
expect(work.selectedRowReads).toBeGreaterThanOrEqual(size)
}
samples[phase] = { ...work }
reset()
}
const view = renderSetting({ defaultTuiAgent: 'claude', sourceControlAi: source })
reset()
changeAgentArgs('--model fast')
check('edit', 2, size + 1)
expect(screen.getByRole('combobox').textContent).toContain(
'Use configured CLI agent (Claude)'
)
changeAgentArgs('')
check('clear', 1, 0)
expect(screen.getByRole('combobox').textContent).toContain('Use chat agent')
view.unmount()
source.actions = { conversationName: { agentId: null, agentArgs: '--model fast' } }
for (const enabled of [true, false]) {
reset()
const hidden = renderSetting(
{ defaultTuiAgent: 'claude', nativeChatAutoName: enabled, sourceControlAi: source },
false
)
expect(screen.queryByRole('combobox')).toBeNull()
if (enabled) {
expect(
screen.getByRole('button', { name: 'Advanced' }).getAttribute('aria-expanded')
).toBe('false')
}
expect(
screen
.getByRole('switch', { name: 'Name chats automatically' })
.getAttribute('aria-checked')
).toBe(String(enabled))
check(enabled ? 'collapsed' : 'off', 2, size + 1)
hidden.unmount()
}
console.log(JSON.stringify({ metric: 'chat-naming-settings-catalog', size, samples }))
}
)
it('keeps alternate-agent setup in Advanced and opens it for search', () => {
const { element, rerender } = renderSetting({}, false)
expect(screen.queryByRole('combobox')).toBeNull()
expect(screen.getByRole('button', { name: 'Advanced' }).getAttribute('aria-expanded')).toBe(
'false'
)
state.settingsSearchQuery = 'naming agent'
rerender(element())
expect(screen.getByRole('combobox').textContent).toContain('Use chat agent')
expect(screen.getByRole('button', { name: 'Advanced' }).hasAttribute('disabled')).toBe(true)
})
it('defaults to on and writes the off preference', () => {
const { updateSettings } = renderSetting()
const toggle = screen.getByRole('switch', { name: 'Name chats automatically' })
expect(toggle.getAttribute('aria-checked')).toBe('true')
fireEvent.click(toggle)
expect(updateSettings).toHaveBeenCalledWith({ nativeChatAutoName: false })
})
it('labels the prospective default with retained arguments and returns to chat agent when cleared', async () => {
const { settings, element, rerender } = renderSetting({
defaultTuiAgent: 'claude',
sourceControlAi: normalizeSourceControlAiSettings({
...normalizeSourceControlAiSettings(undefined),
agentId: 'pi',
actions: { conversationName: { agentId: 'codex', agentArgs: '--model haiku' } }
})
})
expect(screen.getByRole('combobox').textContent).toContain('Codex')
fireEvent.click(screen.getByRole('combobox'))
fireEvent.click(screen.getByRole('option', { name: 'Use configured CLI agent (Claude)' }))
await waitFor(() =>
expect(settings.sourceControlAi?.actions?.conversationName).toMatchObject({
agentId: null,
agentArgs: '--model haiku'
})
)
rerender(element())
expect(screen.getByRole('combobox').textContent).toContain('Use configured CLI agent (Claude)')
expect(screen.getByText('Clear CLI arguments to use each chat’s agent.')).toBeTruthy()
changeAgentArgs('')
expect(screen.getByRole('combobox').textContent).toContain('Use chat agent')
})
it('saves CLI arguments and a template through the shared writer', async () => {
const { settings, writeSourceControlAiSettings, onDirtyChange } = renderSetting({
sourceControlAi: normalizeSourceControlAiSettings({
@@ -125,6 +231,7 @@ describe('Chat names setting', () => {
rerender(element())
expect(screen.getByDisplayValue('Keep my edits')).toBeTruthy()
expect(onDirtyChange).toHaveBeenLastCalledWith(true)
expect(screen.getByRole('button', { name: 'Advanced' }).hasAttribute('disabled')).toBe(true)
fireEvent.click(screen.getByRole('button', { name: 'Discard' }))
expect(screen.queryByText('Command template')).toBeNull()
expect(onDirtyChange).toHaveBeenLastCalledWith(false)
@@ -189,6 +296,7 @@ describe('Chat names setting', () => {
unmount()
expect(onDirtyChange).toHaveBeenLastCalledWith(false)
render(<StrictMode>{element()}</StrictMode>)
fireEvent.click(screen.getByRole('button', { name: 'Advanced' }))
changeTemplate('New draft')
expect(onDirtyChange).toHaveBeenLastCalledWith(true)
onDirtyChange.mockClear()
@@ -258,6 +366,7 @@ describe('Chat names setting', () => {
</Suspense>
)
const { rerender, unmount } = render(element(false))
fireEvent.click(screen.getByRole('button', { name: 'Advanced' }))
changeTemplate('Committed save')
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await act(async () => {
@@ -2,13 +2,17 @@ import { useCallback, useLayoutEffect, useRef, useState } from 'react'
import { toast } from 'sonner'
import type { GlobalSettings } from '../../../../shared/global-settings-types'
import type { SourceControlAiSettingsPatch } from '../../../../shared/source-control-ai-types'
import { normalizeSourceControlAiSettings } from '../../../../shared/source-control-ai'
import {
normalizeSourceControlAiSettings,
resolveSourceControlAiForOperation
} from '../../../../shared/source-control-ai'
import {
DEFAULT_SOURCE_CONTROL_ACTION_COMMAND_TEMPLATES,
setSourceControlActionDefault
} from '../../../../shared/source-control-ai-actions'
import { CUSTOM_AGENT_ID } from '../../../../shared/commit-message-agent-spec'
import { isTuiAgent } from '../../../../shared/tui-agent-config'
import { getAgentLabel } from '@/lib/agent-catalog'
import { translate } from '@/i18n/i18n'
import { Switch } from '../ui/switch'
import { Card, CardContent } from '../ui/card'
@@ -18,6 +22,7 @@ import { SettingsSubsectionHeader } from './SettingsFormControls'
import { SearchableSetting } from './SearchableSetting'
import { getChatNamingSearchEntry } from './chat-naming-search'
import type { ActionRecipeDraftValue } from './source-control-ai-action-recipe-draft'
import { AppearanceAdvancedDisclosure } from './AppearanceAdvancedDisclosure'
type ChatNamingSettingProps = {
settings: GlobalSettings
@@ -127,6 +132,39 @@ export function ChatNamingSetting({
}
}
const cliRecipe = value.agentArgs?.trim()
? resolveSourceControlAiForOperation({
settings: {
...settings,
sourceControlAi: {
...config,
actions: {
...config.actions,
conversationName: {
...recipe,
...value,
...(recipe?.agentId ? { agentId: null } : {})
}
}
}
},
operation: 'conversationName'
})
: null
const cliAgent = cliRecipe?.ok ? cliRecipe.value.params.agentId : null
const defaultAgentLabel = cliAgent
? translate('settings.chat.names.configuredCliAgent', 'Use configured CLI agent ({{agent}})', {
agent:
cliAgent === 'custom'
? translate(
'auto.components.settings.SourceControlAiActionRecipeDefaults.0740d30915',
'Custom command'
)
: getAgentLabel(cliAgent)
})
: cliRecipe
? translate('settings.chat.names.configuredCliAgentUnknown', 'Use configured CLI agent')
: translate('settings.chat.names.useChatAgent', 'Use chat agent')
const searchEntry = getChatNamingSearchEntry()
return (
<SearchableSetting
@@ -150,8 +188,13 @@ export function ChatNamingSetting({
/>
</div>
{settings.nativeChatAutoName !== false || dirty ? (
<>
{recipe?.agentId === CUSTOM_AGENT_ID || customCommandDirty ? (
<AppearanceAdvancedDisclosure
forceOpen={dirty || isSaving}
contentClassName="space-y-3 pt-3"
>
{recipe?.agentId === CUSTOM_AGENT_ID ||
cliAgent === CUSTOM_AGENT_ID ||
customCommandDirty ? (
<CustomAgentCommandField
id="chat-name-custom-command"
value={customCommand}
@@ -165,10 +208,32 @@ export function ChatNamingSetting({
<SourceControlActionRecipeRow
actionId="conversationName"
selectedAgent={recipe?.agentId ?? null}
defaultAgentLabel={defaultAgentLabel}
defaultAgentSupportText={
cliRecipe && !cliRecipe.ok
? cliRecipe.error
: cliRecipe
? translate(
'settings.chat.names.clearCliArguments',
'Clear CLI arguments to use each chat’s agent.'
)
: undefined
}
defaultAgentArgsPlaceholder={
cliAgent
? undefined
: translate(
'settings.chat.names.cliArgumentsPlaceholder',
'Optional arguments for a configured CLI naming agent'
)
}
draftValue={value}
baseValue={persisted}
hasUnsavedChanges={dirty}
defaultTuiAgent={settings.defaultTuiAgent}
defaultTuiAgent={
cliAgent && cliAgent !== 'custom' ? cliAgent : settings.defaultTuiAgent
}
isSavingTemplate={isSaving}
onAgentChange={(_id, selected) => void saveAgent(selected)}
onTemplateChange={(_id, template) =>
@@ -190,7 +255,7 @@ export function ChatNamingSetting({
}}
onSave={() => void save()}
/>
</>
</AppearanceAdvancedDisclosure>
) : null}
</div>
</CardContent>
@@ -123,6 +123,15 @@ describe('Chat settings page', () => {
it('retains the unsaved naming draft when search no longer matches Chat', () => {
const { element, rerender, container } = renderChat(true, true, true)
const naming = container.querySelector('#chat-names')
if (!naming) {
throw new Error('Chat naming section is missing')
}
const advanced = naming.querySelector('button[aria-expanded]')
if (!advanced) {
throw new Error('Advanced control is missing')
}
fireEvent.click(advanced)
const template = screen.getByText('Command template').nextElementSibling
if (!(template instanceof HTMLTextAreaElement)) {
throw new Error('Command template textarea is missing')
@@ -46,6 +46,9 @@ type SourceControlActionRecipeRowProps<ActionId extends AiActionId> = {
defaultTuiAgent: GlobalSettings['defaultTuiAgent']
isSavingTemplate: boolean
hasUnsavedChanges?: boolean
defaultAgentLabel?: string
defaultAgentSupportText?: string
defaultAgentArgsPlaceholder?: string
repoOverrideNote?: React.ReactNode
onAgentChange: (
actionId: ActionId,
@@ -92,6 +95,9 @@ export function SourceControlActionRecipeRow<ActionId extends AiActionId>({
defaultTuiAgent,
isSavingTemplate,
hasUnsavedChanges,
defaultAgentLabel,
defaultAgentSupportText,
defaultAgentArgsPlaceholder,
repoOverrideNote,
onAgentChange,
surface,
@@ -117,12 +123,15 @@ export function SourceControlActionRecipeRow<ActionId extends AiActionId>({
const isLaunchAction = !AI_TEXT_ACTION_ID_SET.has(actionId)
const templateDirty =
hasUnsavedChanges ?? JSON.stringify(draftValue) !== JSON.stringify(baseValue)
const agentArgsPlaceholder = getSourceControlAgentArgsPlaceholder(
resolveAgentArgsPlaceholderAgent(selectedAgent, defaultTuiAgent)
)
const agentArgsPlaceholder =
(!selectedAgent && defaultAgentArgsPlaceholder) ||
getSourceControlAgentArgsPlaceholder(
resolveAgentArgsPlaceholderAgent(selectedAgent, defaultTuiAgent)
)
const agentOptions = getAgentCatalogForAction(actionId, selectedAgent)
const agentWarningText = getSourceControlActionAgentWarningText(actionId, selectedAgent)
const agentSupportText = getSourceControlActionAgentSupportText(actionId)
const agentSupportText =
(!selectedAgent && defaultAgentSupportText) || getSourceControlActionAgentSupportText(actionId)
return (
<div className="rounded-md border border-border px-3 py-3">
@@ -152,10 +161,11 @@ export function SourceControlActionRecipeRow<ActionId extends AiActionId>({
<SelectItem value={DEFAULT_AGENT_VALUE}>
<span className="flex items-center gap-2">
<Terminal className="size-3.5 text-muted-foreground" />
{translate(
'auto.components.settings.SourceControlAiActionRecipeDefaults.ee0e5c2a48',
'Use default agent'
)}
{defaultAgentLabel ??
translate(
'auto.components.settings.SourceControlAiActionRecipeDefaults.ee0e5c2a48',
'Use default agent'
)}
</span>
</SelectItem>
{AI_TEXT_ACTION_ID_SET.has(actionId) ? (
@@ -1,5 +1,5 @@
// @vitest-environment happy-dom
import { act, fireEvent, render, renderHook, screen, waitFor } from '@testing-library/react'
import { act, fireEvent, render, renderHook, screen, waitFor, within } from '@testing-library/react'
import { expect, it, vi } from 'vitest'
import { toast } from 'sonner'
import { setSourceControlActionDefault } from '../../../../shared/source-control-ai-actions'
@@ -58,6 +58,11 @@ it('keeps the Chat draft and guard after actual persistence fails, then saves it
const savedRecipe = model.settings?.sourceControlAi?.actions?.conversationName
persist.mockRejectedValueOnce(new Error('Settings disk is unavailable'))
render(<ChatPersistenceHarness model={model} />)
const naming = document.getElementById('chat-names')
if (!naming) {
throw new Error('Chat naming section is missing')
}
fireEvent.click(within(naming).getByRole('button', { name: 'Advanced' }))
const template = screen.getByText('Command template').nextElementSibling
const args = screen.getByText('Arguments').nextElementSibling
if (!(template instanceof HTMLTextAreaElement) || !(args instanceof HTMLInputElement)) {
@@ -136,6 +141,11 @@ it('retains the legacy Git failure contract and composes later queued naming wri
it('configures the offered Chat custom command without enabling Git or writing on each keystroke', async () => {
const model = settingsModel()
render(<ChatPersistenceHarness model={model} />)
const naming = document.getElementById('chat-names')
if (!naming) {
throw new Error('Chat naming section is missing')
}
fireEvent.click(within(naming).getByRole('button', { name: 'Advanced' }))
fireEvent.click(screen.getByRole('combobox'))
fireEvent.click(screen.getByRole('option', { name: 'Custom command' }))
const command = await screen.findByRole('textbox', { name: 'Custom command' })
@@ -203,6 +213,11 @@ it.each(['original-command', 'later-command'])(
})
)
render(<ChatPersistenceHarness model={model} />)
const naming = document.getElementById('chat-names')
if (!naming) {
throw new Error('Chat naming section is missing')
}
fireEvent.click(within(naming).getByRole('button', { name: 'Advanced' }))
const command = screen.getByRole('textbox', { name: 'Custom command' })
fireEvent.change(command, { target: { value: 'submitted-command' } })
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
@@ -226,6 +241,11 @@ it.each(['original-command', 'later-command'])(
it('preserves a shared command changed elsewhere while a Chat recipe is being edited', async () => {
const model = settingsModel()
render(<ChatPersistenceHarness model={model} />)
const naming = document.getElementById('chat-names')
if (!naming) {
throw new Error('Chat naming section is missing')
}
fireEvent.click(within(naming).getByRole('button', { name: 'Advanced' }))
const template = screen.getByText('Command template').nextElementSibling
if (!(template instanceof HTMLTextAreaElement)) {
throw new Error('Chat recipe controls are missing')
@@ -7,7 +7,7 @@ export const getChatNamingSearchEntry = createLocalizedCatalog((): SettingsSearc
title: translate('settings.chat.names.title', 'Chat names'),
description: translate(
'settings.chat.names.description',
'Use an agent to name new chats from their first message. Until a name is generated, a chat shows its first message.'
'Use each chat’s agent to name it from its first message. Choose another naming agent in Advanced. Until a name is generated, a chat shows its first message.'
),
keywords: [
translate('settings.chat.names.enable', 'Name chats automatically'),
@@ -52,6 +52,11 @@ it('consumes an actual Git toggle failure and lets the following Chat write succ
const model = settingsModel()
persist.mockRejectedValueOnce(new Error('Settings disk is unavailable'))
render(<GitPersistenceHarness model={model} />)
const naming = document.getElementById('chat-names')
if (!naming) {
throw new Error('Chat naming section is missing')
}
fireEvent.click(within(naming).getByRole('button', { name: 'Advanced' }))
const toggle = screen.getByRole('switch', { name: 'Show Source Control AI actions' })
fireEvent.click(toggle)
await waitFor(() =>
@@ -90,6 +95,11 @@ it('does not let a failed actual Git agent selection roll back a queued Chat sel
})
)
render(<GitPersistenceHarness model={settingsModel()} />)
const naming = document.getElementById('chat-names')
if (!naming) {
throw new Error('Chat naming section is missing')
}
fireEvent.click(within(naming).getByRole('button', { name: 'Advanced' }))
const agents = screen.getAllByRole('combobox')
const gitAgent = agents[0]
const chatAgent = agents.at(-1)
@@ -20,6 +20,15 @@ describe('nativeChatNamingErrorText', () => {
messageCode: 'noName'
})
).toBe(es.tabHoverCard.namingFailure.errors.noName)
for (const messageCode of ['grokFailed', 'antigravityFailed'] as const) {
expect(
nativeChatNamingErrorText({
message: 'private provider diagnostic',
failedAt: 1,
messageCode
})
).toBe(es.tabHoverCard.namingFailure.errors[messageCode])
}
expect(nativeChatNamingErrorText({ message: 'claude exited 1', failedAt: 1 })).toBe(
'claude exited 1'
)
@@ -9,7 +9,10 @@ const COPY: Record<ChatNamingFailureCode, () => string> = {
noName: () => translate('tabHoverCard.namingFailure.errors.noName', EN.noName),
agentNotRun: () => translate('tabHoverCard.namingFailure.errors.agentNotRun', EN.agentNotRun),
noAgentAvailable: () =>
translate('tabHoverCard.namingFailure.errors.noAgentAvailable', EN.noAgentAvailable)
translate('tabHoverCard.namingFailure.errors.noAgentAvailable', EN.noAgentAvailable),
grokFailed: () => translate('tabHoverCard.namingFailure.errors.grokFailed', EN.grokFailed),
antigravityFailed: () =>
translate('tabHoverCard.namingFailure.errors.antigravityFailed', EN.antigravityFailed)
}
function isChatNamingFailureCode(code: unknown): code is ChatNamingFailureCode {
+3 -1
View File
@@ -2312,7 +2312,9 @@
"errors": {
"agentNotRun": "Orca could not run the chat-name agent.",
"noAgentAvailable": "No chat-name agent can run in this workspace.",
"noName": "The chat-name agent returned no name."
"noName": "The chat-name agent returned no name.",
"grokFailed": "Grok could not name this chat. Try generating the name again.",
"antigravityFailed": "Antigravity could not name this chat. Try generating the name again."
}
}
},
+3 -1
View File
@@ -21106,7 +21106,9 @@
"errors": {
"noName": "Der Agent zur Chat-Benennung hat keinen Namen zurückgegeben.",
"agentNotRun": "Orca konnte den Agenten zur Chat-Benennung nicht ausführen.",
"noAgentAvailable": "In diesem Arbeitsbereich kann kein Agent zur Chat-Benennung ausgeführt werden."
"noAgentAvailable": "In diesem Arbeitsbereich kann kein Agent zur Chat-Benennung ausgeführt werden.",
"grokFailed": "Grok konnte diesen Chat nicht benennen. Versuche erneut, den Namen zu generieren.",
"antigravityFailed": "Antigravity konnte diesen Chat nicht benennen. Versuche erneut, den Namen zu generieren."
}
}
},
+9 -2
View File
@@ -433,9 +433,14 @@
"description": "Choose how chats look and behave.",
"names": {
"title": "Chat names",
"description": "Use an agent to name new chats from their first message. Until a name is generated, a chat shows its first message.",
"description": "Use each chat’s agent to name it from its first message. Choose another naming agent in Advanced. Until a name is generated, a chat shows its first message.",
"enable": "Name chats automatically",
"recipe": "Chat name",
"useChatAgent": "Use chat agent",
"configuredCliAgent": "Use configured CLI agent ({{agent}})",
"configuredCliAgentUnknown": "Use configured CLI agent",
"clearCliArguments": "Clear CLI arguments to use each chat’s agent.",
"cliArgumentsPlaceholder": "Optional arguments for a configured CLI naming agent",
"recipeDescription": "Generate a short name from the first message in a new chat.",
"customCommandDescription": "Command line shared by recipes that select Custom command. Use {prompt} to pass the input as an argument; otherwise it is piped to stdin.",
"saveFailed": "Could not save chat name settings.",
@@ -21527,7 +21532,9 @@
"errors": {
"noName": "The chat-name agent returned no name.",
"agentNotRun": "Orca could not run the chat-name agent.",
"noAgentAvailable": "No chat-name agent can run in this workspace."
"noAgentAvailable": "No chat-name agent can run in this workspace.",
"grokFailed": "Grok could not name this chat. Try generating the name again.",
"antigravityFailed": "Antigravity could not name this chat. Try generating the name again."
}
}
},
+9 -2
View File
@@ -36,9 +36,14 @@
"description": "Elige cómo se ven y se comportan los chats.",
"names": {
"title": "Nombres de chats",
"description": "Usa un agente para nombrar los chats nuevos a partir de su primer mensaje. Hasta que se genere un nombre, el chat muestra su primer mensaje.",
"description": "Usa el agente de cada chat para darle un nombre a partir del primer mensaje. Elige otro agente en Avanzado. Hasta que se genere un nombre, el chat muestra su primer mensaje.",
"enable": "Nombrar chats automáticamente",
"recipe": "Nombre del chat",
"useChatAgent": "Usar el agente del chat",
"configuredCliAgent": "Usar el agente CLI configurado ({{agent}})",
"configuredCliAgentUnknown": "Usar el agente CLI configurado",
"clearCliArguments": "Borra los argumentos CLI para usar el agente de cada chat.",
"cliArgumentsPlaceholder": "Argumentos opcionales para un agente CLI de nombres configurado",
"recipeDescription": "Genera un nombre breve a partir del primer mensaje de un chat nuevo.",
"customCommandDescription": "Línea de comandos compartida por las recetas que seleccionan Comando personalizado. Usa {prompt} para pasar la entrada como argumento; de lo contrario, se envía por la entrada estándar.",
"saveFailed": "No se pudo guardar la configuración de nombres de chats.",
@@ -21771,7 +21776,9 @@
"errors": {
"noName": "El agente de nombres de chat no devolvió ningún nombre.",
"agentNotRun": "Orca no pudo ejecutar el agente de nombres de chat.",
"noAgentAvailable": "No hay ningún agente de nombres de chat que pueda ejecutarse en este espacio de trabajo."
"noAgentAvailable": "No hay ningún agente de nombres de chat que pueda ejecutarse en este espacio de trabajo.",
"grokFailed": "Grok no pudo asignar un nombre a este chat. Intenta generar el nombre de nuevo.",
"antigravityFailed": "Antigravity no pudo asignar un nombre a este chat. Intenta generar el nombre de nuevo."
}
}
},
+9 -2
View File
@@ -256,9 +256,14 @@
"description": "Choisissez l’apparence et le comportement des chats.",
"names": {
"title": "Noms des chats",
"description": "Utilisez un agent pour nommer les nouveaux chats à partir de leur premier message. En attendant un nom généré, le chat affiche son premier message.",
"description": "Utilisez l’agent de chaque chat pour le nommer à partir de son premier message. Choisissez un autre agent dans Avancé. En attendant un nom, le chat affiche son premier message.",
"enable": "Nommer automatiquement les chats",
"recipe": "Nom du chat",
"useChatAgent": "Utiliser l’agent du chat",
"configuredCliAgent": "Utiliser l’agent CLI configuré ({{agent}})",
"configuredCliAgentUnknown": "Utiliser l’agent CLI configuré",
"clearCliArguments": "Effacez les arguments CLI pour utiliser l’agent de chaque chat.",
"cliArgumentsPlaceholder": "Arguments facultatifs pour un agent CLI de nommage configuré",
"recipeDescription": "Génère un nom court à partir du premier message d’un nouveau chat.",
"customCommandDescription": "Ligne de commande partagée par les recettes qui sélectionnent Commande personnalisée. Utilisez {prompt} pour transmettre le texte en argument ; sinon, il est envoyé sur l’entrée standard.",
"saveFailed": "Impossible d’enregistrer les paramètres de nom des chats.",
@@ -21771,7 +21776,9 @@
"errors": {
"noName": "L’agent de nommage des chats n’a renvoyé aucun nom.",
"agentNotRun": "Orca n’a pas pu exécuter l’agent de nommage des chats.",
"noAgentAvailable": "Aucun agent de nommage des chats ne peut s’exécuter dans cet espace de travail."
"noAgentAvailable": "Aucun agent de nommage des chats ne peut s’exécuter dans cet espace de travail.",
"grokFailed": "Grok n’a pas pu nommer cette discussion. Essayez de générer le nom à nouveau.",
"antigravityFailed": "Antigravity n’a pas pu nommer cette discussion. Essayez de générer le nom à nouveau."
}
}
},
+9 -2
View File
@@ -36,9 +36,14 @@
"description": "チャットの外観と動作を選びます。",
"names": {
"title": "チャット名",
"description": "選択したツールが最初のメッセージから新規チャットの名前を生成します。名前が生成されるまでは、最初のメッセージが表示されます。",
"description": "各チャットの Agent で最初のメッセージから名前を生成します。「詳細設定」で別の Agent を選べます。名前が生成されるまでは最初のメッセージを表示します。",
"enable": "チャットに自動で名前を付ける",
"recipe": "チャット名",
"useChatAgent": "チャットの Agent を使用",
"configuredCliAgent": "設定済みのCLIエージェントを使用({{agent}})",
"configuredCliAgentUnknown": "設定済みの CLI Agent を使用",
"clearCliArguments": "CLI引数を消去すると各チャットの Agent を使用します。",
"cliArgumentsPlaceholder": "名前生成用のCLIエージェントの追加引数",
"recipeDescription": "新規チャットの最初のメッセージから短い名前を生成します。",
"customCommandDescription": "「カスタムコマンド」を選ぶレシピで共有するコマンドです。{prompt} を使うと入力を引数として渡し、使わない場合は標準入力で渡します。",
"saveFailed": "チャット名の設定を保存できませんでした。",
@@ -21107,7 +21112,9 @@
"errors": {
"noName": "チャット名 Agent から名前が返されませんでした。",
"agentNotRun": "Orca はチャット名 Agent を実行できませんでした。",
"noAgentAvailable": "このワークスペースで実行できるチャット名 Agent はありません。"
"noAgentAvailable": "このワークスペースで実行できるチャット名 Agent はありません。",
"grokFailed": "Grok はこのチャットに名前を付けられませんでした。もう一度名前を生成してください。",
"antigravityFailed": "Antigravity はこのチャットに名前を付けられませんでした。もう一度名前を生成してください。"
}
}
},
+9 -2
View File
@@ -36,9 +36,14 @@
"description": "채팅의 모양과 동작 방식을 선택하세요.",
"names": {
"title": "채팅 이름",
"description": "에이전트가 첫 메시지를 바탕으로 새 채팅의 이름을 생성합니다. 이름이 생성되기 전까지는 첫 메시지가 표시됩니다.",
"description": "각 채팅의 에이전트가 첫 메시지로 이름을 만듭니다. 고급에서 다른 에이전트를 선택할 수 있습니다. 이름이 생성되기 전에는 첫 메시지가 표시됩니다.",
"enable": "채팅 이름 자동 지정",
"recipe": "채팅 이름",
"useChatAgent": "채팅 에이전트 사용",
"configuredCliAgent": "설정된 CLI 에이전트 사용 ({{agent}})",
"configuredCliAgentUnknown": "설정된 CLI 에이전트 사용",
"clearCliArguments": "각 채팅의 에이전트를 사용하려면 CLI 인수를 지우세요.",
"cliArgumentsPlaceholder": "설정된 CLI 이름 생성 에이전트의 선택적 인수",
"recipeDescription": "새 채팅의 첫 메시지에서 짧은 이름을 생성합니다.",
"customCommandDescription": "사용자 지정 명령을 선택한 레시피가 공유하는 명령줄입니다. {prompt}를 사용하면 입력을 인수로 전달하고, 그렇지 않으면 표준 입력으로 전달합니다.",
"saveFailed": "채팅 이름 설정을 저장하지 못했습니다.",
@@ -21107,7 +21112,9 @@
"errors": {
"noName": "채팅 이름 에이전트가 이름을 반환하지 않았습니다.",
"agentNotRun": "Orca가 채팅 이름 에이전트를 실행하지 못했습니다.",
"noAgentAvailable": "이 워크스페이스에서 실행할 수 있는 채팅 이름 에이전트가 없습니다."
"noAgentAvailable": "이 워크스페이스에서 실행할 수 있는 채팅 이름 에이전트가 없습니다.",
"grokFailed": "Grok가 이 채팅의 이름을 지정하지 못했습니다. 이름 생성을 다시 시도하세요.",
"antigravityFailed": "Antigravity가 이 채팅의 이름을 지정하지 못했습니다. 이름 생성을 다시 시도하세요."
}
}
},
+9 -2
View File
@@ -1217,9 +1217,14 @@
"description": "Escolha a aparência e o comportamento dos chats.",
"names": {
"title": "Nomes dos chats",
"description": "Use um agente para nomear novos chats a partir da primeira mensagem. Até que um nome seja gerado, o chat mostra a primeira mensagem.",
"description": "Use o agente de cada chat para nomeá-lo a partir da primeira mensagem. Escolha outro agente em Avançado. Até que um nome seja gerado, o chat mostra a primeira mensagem.",
"enable": "Nomear chats automaticamente",
"recipe": "Nome do chat",
"useChatAgent": "Usar o agente do chat",
"configuredCliAgent": "Usar o agente CLI configurado ({{agent}})",
"configuredCliAgentUnknown": "Usar o agente CLI configurado",
"clearCliArguments": "Limpe os argumentos CLI para usar o agente de cada chat.",
"cliArgumentsPlaceholder": "Argumentos opcionais para um agente CLI de nomes configurado",
"recipeDescription": "Gere um nome curto a partir da primeira mensagem de um novo chat.",
"customCommandDescription": "Linha de comando compartilhada pelas receitas que selecionam Comando personalizado. Use {prompt} para passar a entrada como argumento; caso contrário, ela será enviada ao stdin.",
"saveFailed": "Não foi possível salvar as configurações de nomes dos chats.",
@@ -21525,7 +21530,9 @@
"errors": {
"noName": "O agente que nomeia os chats não retornou nenhum nome.",
"agentNotRun": "O Orca não conseguiu executar o agente que nomeia os chats.",
"noAgentAvailable": "Nenhum agente que nomeia os chats pode ser executado neste espaço de trabalho."
"noAgentAvailable": "Nenhum agente que nomeia os chats pode ser executado neste espaço de trabalho.",
"grokFailed": "Grok não conseguiu dar um nome a este chat. Tente gerar o nome novamente.",
"antigravityFailed": "Antigravity não conseguiu dar um nome a este chat. Tente gerar o nome novamente."
}
}
},
+3 -1
View File
@@ -21761,7 +21761,9 @@
"errors": {
"noName": "Агент названия чата не вернул название.",
"agentNotRun": "Orca не удалось запустить агента названия чата.",
"noAgentAvailable": "В этом рабочем пространстве нельзя запустить ни одного агента названия чата."
"noAgentAvailable": "В этом рабочем пространстве нельзя запустить ни одного агента названия чата.",
"grokFailed": "Grok не удалось назвать этот чат. Попробуйте сгенерировать название ещё раз.",
"antigravityFailed": "Antigravity не удалось назвать этот чат. Попробуйте сгенерировать название ещё раз."
}
}
},
+3 -1
View File
@@ -21089,7 +21089,9 @@
"errors": {
"noName": "Sohbeti adlandıran ajan bir ad döndürmedi.",
"agentNotRun": "Sohbeti adlandıran ajan Orca tarafından çalıştırılamadı.",
"noAgentAvailable": "Bu çalışma alanında sohbeti adlandıran hiçbir ajan çalıştırılamaz."
"noAgentAvailable": "Bu çalışma alanında sohbeti adlandıran hiçbir ajan çalıştırılamaz.",
"grokFailed": "Grok bu sohbete ad veremedi. Adı yeniden oluşturmayı deneyin.",
"antigravityFailed": "Antigravity bu sohbete ad veremedi. Adı yeniden oluşturmayı deneyin."
}
}
},
+3 -1
View File
@@ -20811,7 +20811,9 @@
"errors": {
"noName": "對話命名代理程式未傳回名稱。",
"agentNotRun": "Orca 無法執行對話命名代理程式。",
"noAgentAvailable": "此工作區中沒有可執行的對話命名代理程式。"
"noAgentAvailable": "此工作區中沒有可執行的對話命名代理程式。",
"grokFailed": "Grok 無法為此聊天命名。請嘗試重新產生名稱。",
"antigravityFailed": "Antigravity 無法為此聊天命名。請嘗試重新產生名稱。"
}
}
},
+9 -2
View File
@@ -36,9 +36,14 @@
"description": "选择聊天的外观和行为方式。",
"names": {
"title": "聊天名称",
"description": "使用智能体根据首条消息为新聊天生成名称。生成名称前,聊天显示其首条消息。",
"description": "使用各聊天的智能体根据首条消息命名。可在高级设置中选择其他命名智能体。名称生成前,聊天会显示首条消息。",
"enable": "自动为聊天命名",
"recipe": "聊天名称",
"useChatAgent": "使用聊天智能体",
"configuredCliAgent": "使用已配置的CLI智能体({{agent}})",
"configuredCliAgentUnknown": "使用已配置的 CLI 代理",
"clearCliArguments": "清除CLI参数以使用各聊天的智能体。",
"cliArgumentsPlaceholder": "已配置的CLI命名智能体的可选参数",
"recipeDescription": "根据新聊天的首条消息生成简短名称。",
"customCommandDescription": "选择自定义命令的配置共用此命令行。使用 {prompt} 将输入作为参数传递,否则通过标准输入传递。",
"saveFailed": "无法保存聊天命名设置。",
@@ -21108,7 +21113,9 @@
"errors": {
"noName": "聊天命名智能体未返回名称。",
"agentNotRun": "Orca 无法运行聊天命名智能体。",
"noAgentAvailable": "此工作区中没有可运行的聊天命名智能体。"
"noAgentAvailable": "此工作区中没有可运行的聊天命名智能体。",
"grokFailed": "Grok 无法为此聊天命名。请尝试重新生成名称。",
"antigravityFailed": "Antigravity 无法为此聊天命名。请尝试重新生成名称。"
}
}
},
@@ -0,0 +1,66 @@
import { vi } from 'vitest'
import { normalizeSourceControlAiSettings } from './source-control-ai-settings'
export function savedChatNamingCatalog(size: number) {
const source = normalizeSourceControlAiSettings(undefined)
source.selectedModelByAgent.claude = `catalog-${String(size - 1).padStart(4, '0')}`
source.discoveredModelsByAgent = {
claude: Array.from({ length: size }, (_, index) => ({
id: `catalog-${String(index).padStart(4, '0')}`,
label: `Catalog ${String(index).padStart(4, '0')}`
}))
}
return source
}
export function observeChatNamingCatalogWork() {
const clone = globalThis.structuredClone
const work = { copiedRows: 0, copiedBytes: 0, selectedRowReads: 0 }
let cloning = false
const catalog = (value: unknown): unknown[] =>
value !== null && typeof value === 'object' && 'claude' in value && Array.isArray(value.claude)
? value.claude
: []
vi.spyOn(globalThis, 'structuredClone').mockImplementation(
<T>(value: T, options?: Parameters<typeof globalThis.structuredClone>[1]): T => {
cloning = true
let result: T
try {
const rows = catalog(value)
work.copiedRows += rows.length
if (rows.length) {
work.copiedBytes += new TextEncoder().encode(JSON.stringify(rows)).length
}
result = clone(value, options)
} finally {
cloning = false
}
for (const row of catalog(result)) {
if (
row === null ||
typeof row !== 'object' ||
!('id' in row) ||
typeof row.id !== 'string'
) {
throw new Error('Expected a discovered model row')
}
const id = row.id
Object.defineProperty(row, 'id', {
enumerable: true,
configurable: true,
get: () => {
if (!cloning) {
work.selectedRowReads++
}
return id
}
})
}
return result
}
)
return {
work,
reset: () => Object.assign(work, { copiedRows: 0, copiedBytes: 0, selectedRowReads: 0 })
}
}
+3 -1
View File
@@ -2,7 +2,9 @@
export const CHAT_NAMING_FAILURE_COPY = {
noName: 'The chat-name agent returned no name.',
agentNotRun: 'Orca could not run the chat-name agent.',
noAgentAvailable: 'No chat-name agent can run in this workspace.'
noAgentAvailable: 'No chat-name agent can run in this workspace.',
grokFailed: 'Grok could not name this chat. Try generating the name again.',
antigravityFailed: 'Antigravity could not name this chat. Try generating the name again.'
} as const
export type ChatNamingFailureCode = keyof typeof CHAT_NAMING_FAILURE_COPY
@@ -1,6 +1,7 @@
import { normalizeAgentSessionConversationName } from './agent-session/agent-session-conversation-name'
import { cleanGeneratedCommitMessage } from './commit-message-agent-output'
import { sliceAtCodeUnitLimit } from './surrogate-safe-text-slice'
import { renderSourceControlActionCommandTemplate } from './source-control-ai-actions'
export type ConversationNameContext = { firstPrompt: string }
@@ -29,6 +30,19 @@ ${firstPrompt}`
return extra ? `${basePrompt}\n\nAdditional instructions:\n${extra}` : basePrompt
}
export function renderConversationNamePrompt(
context: ConversationNameContext,
recipe: { commandInputTemplate?: string; customPrompt?: string }
): string {
const firstPrompt = clampConversationNameFirstPrompt(context.firstPrompt)
return recipe.commandInputTemplate !== undefined
? renderSourceControlActionCommandTemplate(recipe.commandInputTemplate, {
basePrompt: buildConversationNamePrompt({ firstPrompt }),
firstPrompt
})
: buildConversationNamePrompt({ firstPrompt }, recipe.customPrompt ?? '')
}
export function sanitizeGeneratedConversationName(raw: string): string | null {
const cleaned = cleanGeneratedCommitMessage(raw)
if (cleaned.startsWith('<think>') || cleaned.startsWith('◁think▷')) {