diff --git a/src/main/acp/acp-dialects/acp-dialect.ts b/src/main/acp/acp-dialects/acp-dialect.ts index d9d2e9a0305..eb32dfcef18 100644 --- a/src/main/acp/acp-dialects/acp-dialect.ts +++ b/src/main/acp/acp-dialects/acp-dialect.ts @@ -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. */ diff --git a/src/main/acp/acp-dialects/grok-dialect.ts b/src/main/acp/acp-dialects/grok-dialect.ts index 1ebaa4823bf..c0308ff8863 100644 --- a/src/main/acp/acp-dialects/grok-dialect.ts +++ b/src/main/acp/acp-dialects/grok-dialect.ts @@ -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, diff --git a/src/main/acp/acp-structured-child-lifecycle.ts b/src/main/acp/acp-structured-child-lifecycle.ts index 33a3ba9d2ae..469fc25c7e4 100644 --- a/src/main/acp/acp-structured-child-lifecycle.ts +++ b/src/main/acp/acp-structured-child-lifecycle.ts @@ -55,6 +55,22 @@ export class AcpStructuredChildren { return failedStart && session } + async closeAll(tasks: (Promise | undefined)[]): Promise { + 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 { const session = this.sessions.get(sessionId) if (!session || session.ended) { diff --git a/src/main/acp/acp-structured-session-adapter-deps.ts b/src/main/acp/acp-structured-session-adapter-deps.ts index 86a28453c38..52eee130b2b 100644 --- a/src/main/acp/acp-structured-session-adapter-deps.ts +++ b/src/main/acp/acp-structured-session-adapter-deps.ts @@ -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: { diff --git a/src/main/acp/acp-structured-session-adapter.ts b/src/main/acp/acp-structured-session-adapter.ts index d0ff7df0127..fc26d3bd61a 100644 --- a/src/main/acp/acp-structured-session-adapter.ts +++ b/src/main/acp/acp-structured-session-adapter.ts @@ -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 = (input) => + this.textTasks.generate(input) + copyConversation: NonNullable = (input) => this.copies.copy(input) @@ -304,23 +310,11 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte forceCloseSession = (sessionId: string): Promise => this.children.stop(sessionId, false) async closeAll(): Promise { - 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 { diff --git a/src/main/acp/acp-text-task.test.ts b/src/main/acp/acp-text-task.test.ts new file mode 100644 index 00000000000..f7ca1737fbe --- /dev/null +++ b/src/main/acp/acp-text-task.test.ts @@ -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>() +})) +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() + 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> + onConnect?: () => void + shouldExecute?: () => boolean + close?: (child: FakeAcpChild) => Promise + 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() + 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[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>() + const onConnect = vi.fn() + const preparing = Promise.withResolvers() + 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() + const release = Promise.withResolvers() + 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() + const close = Promise.withResolvers() + 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) +}) diff --git a/src/main/acp/acp-text-task.ts b/src/main/acp/acp-text-task.ts new file mode 100644 index 00000000000..2f5c10d8099 --- /dev/null +++ b/src/main/acp/acp-text-task.ts @@ -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>[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() + private readonly shutdown = new AbortController() + private readonly pending = new Set>() + + constructor( + private readonly deps: Pick< + AcpStructuredSessionAdapterDeps, + 'spec' | 'textTaskLaunch' | 'connect' | 'dataDirectory' | 'logger' + > + ) {} + + generate = async (input: TextInput): Promise => { + 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 { + 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 { + 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 => { + 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): Promise => { + 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() + } + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts index 713fb61cd5b..91aadc0499b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts @@ -180,6 +180,14 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi return reader(input) } + generateText: NonNullable = (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 = (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 + ): StructuredAgentRegistration { const registration = this.agents.registration(identity.agent) if (!registration) { throw new Error(`structured sessions do not support ${identity.agent}`) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index 22ceb939a49..28fa9de3f5d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -293,6 +293,13 @@ export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop & sessionId: string fence: number }): Promise + /** 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 /** Copy the provider conversation without changing the chat’s ID; omit for CLI-based forks. */ copyConversation?(input: { identity: AgentSessionJournalIdentity diff --git a/src/main/native-chat/structured-chat-naming-execution.test.ts b/src/main/native-chat/structured-chat-naming-execution.test.ts index d2a395520c5..f733b4fd4bd 100644 --- a/src/main/native-chat/structured-chat-naming-execution.test.ts +++ b/src/main/native-chat/structured-chat-naming-execution.test.ts @@ -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 }) }), diff --git a/src/main/native-chat/structured-chat-naming-publication.test.ts b/src/main/native-chat/structured-chat-naming-publication.test.ts index c6b1e262729..767ff4ec11e 100644 --- a/src/main/native-chat/structured-chat-naming-publication.test.ts +++ b/src/main/native-chat/structured-chat-naming-publication.test.ts @@ -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) diff --git a/src/main/runtime/__fixtures__/chat-naming-recorded-output.json b/src/main/runtime/__fixtures__/chat-naming-recorded-output.json new file mode 100644 index 00000000000..1fd0f2f3019 --- /dev/null +++ b/src/main/runtime/__fixtures__/chat-naming-recorded-output.json @@ -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" + } +] diff --git a/src/main/runtime/structured-agent-runtime-registrations.ts b/src/main/runtime/structured-agent-runtime-registrations.ts index ed570e9c2cd..19f016067f9 100644 --- a/src/main/runtime/structured-agent-runtime-registrations.ts +++ b/src/main/runtime/structured-agent-runtime-registrations.ts @@ -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, { diff --git a/src/main/runtime/structured-chat-naming-agent-launch.ts b/src/main/runtime/structured-chat-naming-agent-launch.ts new file mode 100644 index 00000000000..fa23a6ed66a --- /dev/null +++ b/src/main/runtime/structured-chat-naming-agent-launch.ts @@ -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>, + settings: Parameters[1] +): Promise { + 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 } } +} diff --git a/src/main/runtime/structured-chat-naming-contract.test.ts b/src/main/runtime/structured-chat-naming-contract.test.ts new file mode 100644 index 00000000000..11b094bb8ec --- /dev/null +++ b/src/main/runtime/structured-chat-naming-contract.test.ts @@ -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(), + native: vi.fn>(), + 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 = {} + 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 })) + } +) diff --git a/src/main/runtime/structured-chat-naming-runtime.test.ts b/src/main/runtime/structured-chat-naming-runtime.test.ts index 8cbddae1c2e..7aa4f9b97b2 100644 --- a/src/main/runtime/structured-chat-naming-runtime.test.ts +++ b/src/main/runtime/structured-chat-naming-runtime.test.ts @@ -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> => ({ + 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'])( diff --git a/src/main/runtime/structured-chat-naming-runtime.ts b/src/main/runtime/structured-chat-naming-runtime.ts index 6d219fffe58..1a7af868b76 100644 --- a/src/main/runtime/structured-chat-naming-runtime.ts +++ b/src/main/runtime/structured-chat-naming-runtime.ts @@ -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> getAgentEnvResolvers: () => CommitMessageAgentEnvironmentResolvers | undefined workerTaskTitle: StructuredChatNamingDeps['workerTaskTitle'] retitleOpenTab: StructuredChatNamingDeps['onNamed'] @@ -26,6 +38,7 @@ type StructuredChatNamingRuntime = { export function structuredChatNamingDeps( getStore: () => { getSettings: () => Parameters[0] & + AgentLaunchProfileSettings & ReturnType }, 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) diff --git a/src/main/runtime/workspaces/workspace-process-listing-dependencies.ts b/src/main/runtime/workspaces/workspace-process-listing-dependencies.ts index 4cc34eedb4b..a8f62248ed3 100644 --- a/src/main/runtime/workspaces/workspace-process-listing-dependencies.ts +++ b/src/main/runtime/workspaces/workspace-process-listing-dependencies.ts @@ -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 + ) { + return { + ...resolveTuiAgentLaunchEnv(agent, settings), + ...this.accounts.hostLaunchEnvFor(agent) + } + } + protected abstract listResolvedWorktreeSnapshot( ...args: Parameters ): ReturnType diff --git a/src/main/runtime/workspaces/workspace-process-listing.ts b/src/main/runtime/workspaces/workspace-process-listing.ts index 18158bf29ff..85f239a635d 100644 --- a/src/main/runtime/workspaces/workspace-process-listing.ts +++ b/src/main/runtime/workspaces/workspace-process-listing.ts @@ -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 - ) { - 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) => diff --git a/src/main/text-generation/conversation-name-generation-request.ts b/src/main/text-generation/conversation-name-generation-request.ts index 902b2684659..b6541919737 100644 --- a/src/main/text-generation/conversation-name-generation-request.ts +++ b/src/main/text-generation/conversation-name-generation-request.ts @@ -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 { 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, diff --git a/src/main/wsl/wsl-orcad-chat-provider-fixture.ts b/src/main/wsl/wsl-orcad-chat-provider-fixture.ts index f600cd7cc3d..1b00216ccef 100644 --- a/src/main/wsl/wsl-orcad-chat-provider-fixture.ts +++ b/src/main/wsl/wsl-orcad-chat-provider-fixture.ts @@ -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 } diff --git a/src/renderer/src/components/settings/AppearanceAdvancedDisclosure.tsx b/src/renderer/src/components/settings/AppearanceAdvancedDisclosure.tsx index 42cdf04cf35..8407000fc10 100644 --- a/src/renderer/src/components/settings/AppearanceAdvancedDisclosure.tsx +++ b/src/renderer/src/components/settings/AppearanceAdvancedDisclosure.tsx @@ -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 (
@@ -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" > { vi.clearAllMocks() }) -function renderSetting(overrides: Partial = {}) { +function renderSetting(overrides: Partial = {}, expand = true) { const settings = { ...getDefaultSettings('/tmp'), ...overrides } const updateSettings = vi.fn() const onDirtyChange = vi.fn() @@ -45,6 +49,9 @@ function renderSetting(overrides: Partial = {}) { /> ) 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 = {} + 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({element()}) + fireEvent.click(screen.getByRole('button', { name: 'Advanced' })) changeTemplate('New draft') expect(onDirtyChange).toHaveBeenLastCalledWith(true) onDirtyChange.mockClear() @@ -258,6 +366,7 @@ describe('Chat names setting', () => { ) 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 () => { diff --git a/src/renderer/src/components/settings/ChatNamingSetting.tsx b/src/renderer/src/components/settings/ChatNamingSetting.tsx index b24748abb6b..501e7fc7580 100644 --- a/src/renderer/src/components/settings/ChatNamingSetting.tsx +++ b/src/renderer/src/components/settings/ChatNamingSetting.tsx @@ -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 (
{settings.nativeChatAutoName !== false || dirty ? ( - <> - {recipe?.agentId === CUSTOM_AGENT_ID || customCommandDirty ? ( + + {recipe?.agentId === CUSTOM_AGENT_ID || + cliAgent === CUSTOM_AGENT_ID || + customCommandDirty ? ( void saveAgent(selected)} onTemplateChange={(_id, template) => @@ -190,7 +255,7 @@ export function ChatNamingSetting({ }} onSave={() => void save()} /> - + ) : null} diff --git a/src/renderer/src/components/settings/ChatSettingsSection.test.tsx b/src/renderer/src/components/settings/ChatSettingsSection.test.tsx index 52644019ada..54a1f712ff7 100644 --- a/src/renderer/src/components/settings/ChatSettingsSection.test.tsx +++ b/src/renderer/src/components/settings/ChatSettingsSection.test.tsx @@ -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') diff --git a/src/renderer/src/components/settings/SourceControlActionRecipeRow.tsx b/src/renderer/src/components/settings/SourceControlActionRecipeRow.tsx index d3c1856d393..ee0b8e71bea 100644 --- a/src/renderer/src/components/settings/SourceControlActionRecipeRow.tsx +++ b/src/renderer/src/components/settings/SourceControlActionRecipeRow.tsx @@ -46,6 +46,9 @@ type SourceControlActionRecipeRowProps = { 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({ defaultTuiAgent, isSavingTemplate, hasUnsavedChanges, + defaultAgentLabel, + defaultAgentSupportText, + defaultAgentArgsPlaceholder, repoOverrideNote, onAgentChange, surface, @@ -117,12 +123,15 @@ export function SourceControlActionRecipeRow({ 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 (
@@ -152,10 +161,11 @@ export function SourceControlActionRecipeRow({ - {translate( - 'auto.components.settings.SourceControlAiActionRecipeDefaults.ee0e5c2a48', - 'Use default agent' - )} + {defaultAgentLabel ?? + translate( + 'auto.components.settings.SourceControlAiActionRecipeDefaults.ee0e5c2a48', + 'Use default agent' + )} {AI_TEXT_ACTION_ID_SET.has(actionId) ? ( diff --git a/src/renderer/src/components/settings/chat-naming-persistence.test.tsx b/src/renderer/src/components/settings/chat-naming-persistence.test.tsx index 954fe4eb3fe..d7070dfea21 100644 --- a/src/renderer/src/components/settings/chat-naming-persistence.test.tsx +++ b/src/renderer/src/components/settings/chat-naming-persistence.test.tsx @@ -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() + 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() + 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() + 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() + 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') diff --git a/src/renderer/src/components/settings/chat-naming-search.ts b/src/renderer/src/components/settings/chat-naming-search.ts index 6818da3c606..cf064faa8f7 100644 --- a/src/renderer/src/components/settings/chat-naming-search.ts +++ b/src/renderer/src/components/settings/chat-naming-search.ts @@ -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'), diff --git a/src/renderer/src/components/settings/source-control-ai-settings-persistence.test.tsx b/src/renderer/src/components/settings/source-control-ai-settings-persistence.test.tsx index 7569527ab06..5f5c04c895a 100644 --- a/src/renderer/src/components/settings/source-control-ai-settings-persistence.test.tsx +++ b/src/renderer/src/components/settings/source-control-ai-settings-persistence.test.tsx @@ -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() + 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() + 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) diff --git a/src/renderer/src/components/tab-bar/native-chat-naming-failure-copy.test.ts b/src/renderer/src/components/tab-bar/native-chat-naming-failure-copy.test.ts index 65a5361356c..0a3183852fa 100644 --- a/src/renderer/src/components/tab-bar/native-chat-naming-failure-copy.test.ts +++ b/src/renderer/src/components/tab-bar/native-chat-naming-failure-copy.test.ts @@ -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' ) diff --git a/src/renderer/src/components/tab-bar/native-chat-naming-failure-copy.ts b/src/renderer/src/components/tab-bar/native-chat-naming-failure-copy.ts index 6b17be15bed..e63eff1ae34 100644 --- a/src/renderer/src/components/tab-bar/native-chat-naming-failure-copy.ts +++ b/src/renderer/src/components/tab-bar/native-chat-naming-failure-copy.ts @@ -9,7 +9,10 @@ const COPY: Record 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 { diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index c16f1835250..f2a87c06e7d 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -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." } } }, diff --git a/src/renderer/src/i18n/locales/de.json b/src/renderer/src/i18n/locales/de.json index c8eba94e72c..6530ca8d7a7 100644 --- a/src/renderer/src/i18n/locales/de.json +++ b/src/renderer/src/i18n/locales/de.json @@ -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." } } }, diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 11cf862a6d1..655f99f7a53 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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." } } }, diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 65c75af81a0..401f2268c2b 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -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." } } }, diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 2cf386e4949..bf5e1d8cc5c 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -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." } } }, diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 64aee77f870..8fb4c1223d0 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -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 はこのチャットに名前を付けられませんでした。もう一度名前を生成してください。" } } }, diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 73171f056ec..e5cc8445ec2 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -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가 이 채팅의 이름을 지정하지 못했습니다. 이름 생성을 다시 시도하세요." } } }, diff --git a/src/renderer/src/i18n/locales/pt.json b/src/renderer/src/i18n/locales/pt.json index ecbc2d3c4a5..f18cc6741e2 100644 --- a/src/renderer/src/i18n/locales/pt.json +++ b/src/renderer/src/i18n/locales/pt.json @@ -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." } } }, diff --git a/src/renderer/src/i18n/locales/ru.json b/src/renderer/src/i18n/locales/ru.json index 02cfc042bee..48c4764f436 100644 --- a/src/renderer/src/i18n/locales/ru.json +++ b/src/renderer/src/i18n/locales/ru.json @@ -21761,7 +21761,9 @@ "errors": { "noName": "Агент названия чата не вернул название.", "agentNotRun": "Orca не удалось запустить агента названия чата.", - "noAgentAvailable": "В этом рабочем пространстве нельзя запустить ни одного агента названия чата." + "noAgentAvailable": "В этом рабочем пространстве нельзя запустить ни одного агента названия чата.", + "grokFailed": "Grok не удалось назвать этот чат. Попробуйте сгенерировать название ещё раз.", + "antigravityFailed": "Antigravity не удалось назвать этот чат. Попробуйте сгенерировать название ещё раз." } } }, diff --git a/src/renderer/src/i18n/locales/tr.json b/src/renderer/src/i18n/locales/tr.json index 6b6b04697a1..be46bf7d4e2 100644 --- a/src/renderer/src/i18n/locales/tr.json +++ b/src/renderer/src/i18n/locales/tr.json @@ -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." } } }, diff --git a/src/renderer/src/i18n/locales/zh-Hant.json b/src/renderer/src/i18n/locales/zh-Hant.json index abc9ec46b89..cddf4655082 100644 --- a/src/renderer/src/i18n/locales/zh-Hant.json +++ b/src/renderer/src/i18n/locales/zh-Hant.json @@ -20811,7 +20811,9 @@ "errors": { "noName": "對話命名代理程式未傳回名稱。", "agentNotRun": "Orca 無法執行對話命名代理程式。", - "noAgentAvailable": "此工作區中沒有可執行的對話命名代理程式。" + "noAgentAvailable": "此工作區中沒有可執行的對話命名代理程式。", + "grokFailed": "Grok 無法為此聊天命名。請嘗試重新產生名稱。", + "antigravityFailed": "Antigravity 無法為此聊天命名。請嘗試重新產生名稱。" } } }, diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 1a62ba00b2f..12f939c3c1b 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -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 无法为此聊天命名。请尝试重新生成名称。" } } }, diff --git a/src/shared/chat-naming-catalog-work.test-fixture.ts b/src/shared/chat-naming-catalog-work.test-fixture.ts new file mode 100644 index 00000000000..c6bcedfe9e3 --- /dev/null +++ b/src/shared/chat-naming-catalog-work.test-fixture.ts @@ -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( + (value: T, options?: Parameters[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 }) + } +} diff --git a/src/shared/chat-naming-failure-copy.ts b/src/shared/chat-naming-failure-copy.ts index 13a770b4cba..a1d6d33cd4b 100644 --- a/src/shared/chat-naming-failure-copy.ts +++ b/src/shared/chat-naming-failure-copy.ts @@ -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 diff --git a/src/shared/conversation-name-generation.ts b/src/shared/conversation-name-generation.ts index b3fe48ca77f..41fbbf70672 100644 --- a/src/shared/conversation-name-generation.ts +++ b/src/shared/conversation-name-generation.ts @@ -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('') || cleaned.startsWith('◁think▷')) {