mirror of
https://github.com/stablyai/orca.git
synced 2026-11-01 09:37:33 +00:00
Use each native chat’s agent for naming by default (#32106)
* Default native chat naming to its conversation agent * Preserve naming account overrides and accurate default selection * Fix chat naming types and Japanese catalog spelling * Isolate WSL interactive chat fixture from automatic naming * Fix chat naming types after main integration * Prove chat naming catalog bounds and recorded output routes * Handle optional native launch environment in naming proof
This commit is contained in:
@@ -100,6 +100,11 @@ export type AcpDialect = {
|
||||
/** A child-session notice supplies only its outcome; the translator owns membership and replay. */
|
||||
subagentSessionEnd?(method: string, params: unknown): 'completed' | 'stopped' | undefined
|
||||
notification?(method: string, params: unknown, at: number): AcpDialectNotification | undefined
|
||||
/** Completion of an independent text task, apart from interactive turn projection. */
|
||||
textTaskCompletion?(
|
||||
method: string,
|
||||
params: unknown
|
||||
): { sessionId: string; promptId: string; stopReason: string } | undefined
|
||||
contextWindow?(models: unknown): number | undefined
|
||||
modelEfforts?(model: ModelInfo): { efforts: AgentSessionOptionChoice[]; defaultEffort?: string }
|
||||
/** The agent clamps an effort another model lacks, so unlisted models' menus are unknown, not empty. */
|
||||
|
||||
@@ -220,6 +220,10 @@ export const GROK_ACP_DIALECT: AcpDialect = {
|
||||
toolSubagents: grokToolSubagents,
|
||||
subagentSessionEnd,
|
||||
notification,
|
||||
textTaskCompletion: (method, params) =>
|
||||
method.replace(/^_/, '') === 'x.ai/session/prompt_complete'
|
||||
? promptCompleteSchema.safeParse(params).data
|
||||
: undefined,
|
||||
contextWindow,
|
||||
modelEfforts: grokModelEfforts,
|
||||
hookBlock,
|
||||
|
||||
@@ -55,6 +55,22 @@ export class AcpStructuredChildren {
|
||||
return failedStart && session
|
||||
}
|
||||
|
||||
async closeAll(tasks: (Promise<boolean | void> | undefined)[]): Promise<void> {
|
||||
const ids = new Set([...this.sessions.keys(), ...this.starts.failedSessionIds()])
|
||||
// The host aborts in-progress starts before this teardown.
|
||||
const results = await Promise.allSettled([
|
||||
...[...ids].map((sessionId) => this.stop(sessionId, true)),
|
||||
...tasks
|
||||
])
|
||||
const failure = results.find((result) => result.status === 'rejected')
|
||||
if (failure?.status === 'rejected') {
|
||||
throw failure.reason
|
||||
}
|
||||
if (results.some((result) => result.status === 'fulfilled' && result.value === false)) {
|
||||
throw new Error('an ACP agent child could not be proven stopped')
|
||||
}
|
||||
}
|
||||
|
||||
private async stopSession(sessionId: string, requested: boolean): Promise<boolean> {
|
||||
const session = this.sessions.get(sessionId)
|
||||
if (!session || session.ended) {
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import type {
|
||||
AcpLaunchInvocationDeps,
|
||||
AcpStructuredLaunch
|
||||
} from './acp-structured-launch-resolution'
|
||||
import type { StructuredAgentSessionAtRestCommands } from '../native-chat/agent-session-wire/structured-agent-session-at-rest-commands'
|
||||
import type { StructuredAgentSessionChildWorkProducer } from '../native-chat/agent-session-wire/structured-agent-session-subagent-rows'
|
||||
import type { StructuredAgentSessionLifecycleEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
|
||||
@@ -9,7 +13,6 @@ import type {
|
||||
import type { AgentJournalDispatchRejection } from '../../shared/agent-session-failure-words'
|
||||
import type { AcpLaunchSpec } from './acp-launch-specs'
|
||||
import type { ConnectAcpAgent } from './acp-structured-connection'
|
||||
import type { AcpStructuredLaunch } from './acp-structured-launch-resolution'
|
||||
import type { AgentChildWorkEvidence } from '../../shared/agent-status-child-work-evidence'
|
||||
import type { AgentChatPermissionChoice } from '../../shared/agent-chat-permission-mode'
|
||||
|
||||
@@ -19,6 +22,7 @@ export const ACP_OPTION_WRITE_TIMEOUT_MS = 30_000
|
||||
|
||||
export type AcpStructuredSessionAdapterDeps = {
|
||||
spec: AcpLaunchSpec
|
||||
textTaskLaunch?: AcpLaunchInvocationDeps
|
||||
atRestCommands?: StructuredAgentSessionAtRestCommands
|
||||
/** Sealed with the reservation's spawn token. */
|
||||
resolveLaunch: (input: {
|
||||
|
||||
@@ -35,18 +35,24 @@ import { stopAcpChildren, acpChildStopCapabilities } from './acp-structured-chil
|
||||
import { acpStartupFailure, isAcpReadySession } from './acp-structured-child'
|
||||
import { AcpStructuredChildren } from './acp-structured-child-lifecycle'
|
||||
import { AcpConversationCopies } from './acp-conversation-copies'
|
||||
import { AcpTextTasks } from './acp-text-task'
|
||||
import { supportsAcpStructuredLocation } from './acp-structured-location-support'
|
||||
|
||||
export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapter {
|
||||
readonly atRestCommands: AcpStructuredSessionAdapterDeps['atRestCommands']
|
||||
private readonly children: AcpStructuredChildren
|
||||
private readonly copies: AcpConversationCopies
|
||||
private readonly textTasks: AcpTextTasks
|
||||
constructor(private readonly deps: AcpStructuredSessionAdapterDeps) {
|
||||
this.atRestCommands = deps.atRestCommands
|
||||
this.children = new AcpStructuredChildren(deps)
|
||||
this.copies = new AcpConversationCopies(deps)
|
||||
this.textTasks = new AcpTextTasks(deps)
|
||||
}
|
||||
|
||||
generateText: NonNullable<StructuredAgentSessionAdapter['generateText']> = (input) =>
|
||||
this.textTasks.generate(input)
|
||||
|
||||
copyConversation: NonNullable<StructuredAgentSessionAdapter['copyConversation']> = (input) =>
|
||||
this.copies.copy(input)
|
||||
|
||||
@@ -304,23 +310,11 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte
|
||||
forceCloseSession = (sessionId: string): Promise<boolean> => this.children.stop(sessionId, false)
|
||||
|
||||
async closeAll(): Promise<void> {
|
||||
const commands = this.atRestCommands?.closeAll?.()
|
||||
const copiedChildren = this.copies.closeAll()
|
||||
const { children } = this
|
||||
const ids = new Set([...children.sessions.keys(), ...children.starts.failedSessionIds()])
|
||||
// The host aborts in-progress starts before this teardown.
|
||||
const results = await Promise.allSettled([
|
||||
...[...ids].map((sessionId) => children.stop(sessionId, true)),
|
||||
copiedChildren,
|
||||
commands
|
||||
await this.children.closeAll([
|
||||
this.atRestCommands?.closeAll?.(),
|
||||
this.copies.closeAll(),
|
||||
this.textTasks.closeAll()
|
||||
])
|
||||
const failure = results.find((result) => result.status === 'rejected')
|
||||
if (failure?.status === 'rejected') {
|
||||
throw failure.reason
|
||||
}
|
||||
if (results.some((result) => result.status === 'fulfilled' && result.value === false)) {
|
||||
throw new Error('an ACP agent child could not be proven stopped')
|
||||
}
|
||||
}
|
||||
|
||||
private live(sessionId: string): AcpStructuredSession {
|
||||
|
||||
@@ -0,0 +1,443 @@
|
||||
import { mkdtemp, readFile, writeFile, mkdir, rm, access } from 'node:fs/promises'
|
||||
import type * as NodeFsPromises from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { z } from 'zod'
|
||||
import { AcpStructuredSessionAdapter } from './acp-structured-session-adapter'
|
||||
import { acpStructuredAgentDefinition } from './acp-structured-agent-definitions'
|
||||
import { StructuredAgentRegistry } from '../native-chat/agent-session-wire/structured-agent-registry'
|
||||
import { StructuredAgentSessionAdapterRouter } from '../native-chat/agent-session-wire/structured-agent-session-adapter-router'
|
||||
import type { StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
|
||||
import { structuredChatNamingDeps } from '../runtime/structured-chat-naming-runtime'
|
||||
import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture'
|
||||
import { getDefaultSettings } from '../../shared/constants'
|
||||
import { FakeAcpChild } from './acp-scripted-connection.test-fixture'
|
||||
import { acpLaunchSpecFor, type AcpLaunchSpec } from './acp-launch-specs'
|
||||
import { AcpLaunchGate } from './acp-launch-gate'
|
||||
import type { AcpStructuredSessionAdapterDeps } from './acp-structured-session-adapter-deps'
|
||||
|
||||
const boundary = vi.hoisted(() => ({
|
||||
adapter: vi.fn<() => StructuredAgentSessionAdapter>(),
|
||||
afterTemp: vi.fn<(path: string) => Promise<void>>()
|
||||
}))
|
||||
vi.mock('../native-chat/agent-session-wire/structured-agent-session-registry', () => ({
|
||||
getStructuredAgentSessionHost: () => ({ deps: { adapter: boundary.adapter() } })
|
||||
}))
|
||||
vi.mock('node:fs/promises', async (importOriginal) => {
|
||||
const fs = await importOriginal<typeof NodeFsPromises>()
|
||||
return {
|
||||
...fs,
|
||||
mkdtemp: async (prefix: string) => {
|
||||
const path = await fs.mkdtemp(prefix)
|
||||
await boundary.afterTemp(path)
|
||||
return path
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const prompt = z.object({ _meta: z.object({ promptId: z.string() }) })
|
||||
const folders: string[] = []
|
||||
afterEach(async () => {
|
||||
vi.useRealTimers()
|
||||
vi.clearAllMocks()
|
||||
boundary.afterTemp.mockReset()
|
||||
await Promise.all(folders.splice(0).map((folder) => rm(folder, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
async function stagedRig(
|
||||
options: {
|
||||
antigravity?: boolean
|
||||
proveExit?: boolean
|
||||
deletion?: 'unsupported' | 'stalled'
|
||||
prepare?: () => Promise<Record<string, string>>
|
||||
onConnect?: () => void
|
||||
shouldExecute?: () => boolean
|
||||
close?: (child: FakeAcpChild) => Promise<boolean>
|
||||
runtime?: boolean
|
||||
} = {}
|
||||
) {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-naming-task-test-'))
|
||||
folders.push(root)
|
||||
const base = acpLaunchSpecFor(options.antigravity ? 'antigravity' : 'grok')
|
||||
if (!base) {
|
||||
throw new Error('ACP spec is missing')
|
||||
}
|
||||
const spec: AcpLaunchSpec = {
|
||||
...base,
|
||||
managedRuntime: undefined,
|
||||
launchGate: new AcpLaunchGate('Test')
|
||||
}
|
||||
let child: FakeAcpChild | undefined
|
||||
const ready = Promise.withResolvers<FakeAcpChild>()
|
||||
const logger = { warn: vi.fn(), error: vi.fn() }
|
||||
const deps: AcpStructuredSessionAdapterDeps = {
|
||||
logger,
|
||||
spec,
|
||||
dataDirectory: root,
|
||||
readLaunch: async () => {
|
||||
throw new Error('Interactive launch must not be read')
|
||||
},
|
||||
resolveLaunch: async () => {
|
||||
throw new Error('Interactive chat must not be acquired')
|
||||
},
|
||||
textTaskLaunch: {
|
||||
dataDirectory: root,
|
||||
resolveEnvironment: options.prepare ?? (async () => ({ PATH: '/usr/bin' })),
|
||||
resolveCommand: () => '/fixture/agent'
|
||||
},
|
||||
connect: (launch, connectionOptions) => {
|
||||
options.onConnect?.()
|
||||
child = new FakeAcpChild(launch, connectionOptions)
|
||||
if (options.proveExit === false) {
|
||||
child.proveClose = async () => false
|
||||
}
|
||||
if (options.close) {
|
||||
const close = options.close
|
||||
const connected = child
|
||||
child.proveClose = () => close(connected)
|
||||
}
|
||||
const agent = child.agent
|
||||
agent.on('initialize', (frame) =>
|
||||
agent.reply(frame, { protocolVersion: 1, agentCapabilities: {} })
|
||||
)
|
||||
agent.on('session/new', (frame) =>
|
||||
agent.reply(frame, { sessionId: '11111111-1111-4111-8111-111111111111' })
|
||||
)
|
||||
agent.on('_x.ai/session/delete', (frame) => {
|
||||
if (options.deletion === 'unsupported') {
|
||||
agent.fail(frame, -32601, 'Method not found')
|
||||
} else if (options.deletion !== 'stalled') {
|
||||
agent.reply(frame, { success: true })
|
||||
}
|
||||
})
|
||||
agent.on('session/prompt', () => {
|
||||
if (child) {
|
||||
ready.resolve(child)
|
||||
}
|
||||
})
|
||||
return child
|
||||
}
|
||||
}
|
||||
const tasks = new AcpStructuredSessionAdapter(deps)
|
||||
const registry = new StructuredAgentRegistry([
|
||||
{ definition: acpStructuredAgentDefinition(spec), adapter: tasks }
|
||||
])
|
||||
const router = new StructuredAgentSessionAdapterRouter(registry, () => tasks.closeAll())
|
||||
boundary.adapter.mockReturnValue(router)
|
||||
const record = agentSessionRecordFixture()
|
||||
record.provider = spec.agent
|
||||
record.accountHome = { variable: options.antigravity ? 'GEMINI_HOME' : 'GROK_HOME', path: root }
|
||||
const naming = structuredChatNamingDeps(
|
||||
() => ({ getSettings: () => getDefaultSettings(root) }),
|
||||
{
|
||||
resolveWorkspace: async () => ({ path: root, executionHostId: 'local' }),
|
||||
resolveNamingEnvironment: async () => ({ PATH: '/usr/bin' }),
|
||||
getAgentEnvResolvers: () => undefined,
|
||||
workerTaskTitle: () => null,
|
||||
retitleOpenTab: () => {}
|
||||
},
|
||||
logger
|
||||
)
|
||||
const result = options.runtime
|
||||
? naming.generate(record, 'Repair login')
|
||||
: router.generateText({
|
||||
agent: spec.agent,
|
||||
accountHome: record.accountHome,
|
||||
prompt: 'Name this chat',
|
||||
shouldExecute: options.shouldExecute
|
||||
})
|
||||
void result.catch(() => {})
|
||||
return { root, ready, result, tasks, logger, gate: spec.launchGate }
|
||||
}
|
||||
|
||||
async function rig(options: Parameters<typeof stagedRig>[0] = {}) {
|
||||
const state = await stagedRig(options)
|
||||
const { root, ready, result, tasks } = state
|
||||
const server = await Promise.race([
|
||||
ready.promise,
|
||||
result.then(() => {
|
||||
throw new Error('No prompt started')
|
||||
})
|
||||
])
|
||||
const agent = server.agent
|
||||
const frame = agent.frames.find((frame) => frame.method === 'session/prompt')
|
||||
if (!frame) {
|
||||
throw new Error('Naming prompt missing')
|
||||
}
|
||||
const promptId = prompt.parse(frame.params)._meta.promptId
|
||||
const text = (value: string) =>
|
||||
agent.notify('session/update', {
|
||||
sessionId: '11111111-1111-4111-8111-111111111111',
|
||||
update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: value } }
|
||||
})
|
||||
return { ...state, root, server, agent, frame, promptId, result, tasks, text }
|
||||
}
|
||||
|
||||
it.each(['turn_completed', 'x.ai/session/prompt_complete', '_x.ai/session/prompt_complete'])(
|
||||
'settles matching Grok %s without waiting for its prompt RPC',
|
||||
async (method) => {
|
||||
const r = await rig()
|
||||
r.text('Repair login')
|
||||
const complete = (sessionId: string, promptId: string, stopReason: string) => {
|
||||
if (method === 'turn_completed') {
|
||||
r.agent.notify('session/update', {
|
||||
sessionId,
|
||||
update: { sessionUpdate: method, prompt_id: promptId, stop_reason: stopReason }
|
||||
})
|
||||
} else {
|
||||
r.agent.notify(method, { sessionId, promptId, stopReason })
|
||||
}
|
||||
}
|
||||
complete('other-session', r.promptId, 'error')
|
||||
complete('11111111-1111-4111-8111-111111111111', 'other-prompt', 'error')
|
||||
await Promise.resolve()
|
||||
expect(r.server.exited).toBe(false)
|
||||
complete('11111111-1111-4111-8111-111111111111', r.promptId, 'end_turn')
|
||||
expect(await r.result).toBe('Repair login')
|
||||
const start = r.agent.frames.find((frame) => frame.method === 'session/new')
|
||||
expect(start?.params).toEqual({ cwd: r.server.launch.cwd, mcpServers: [] })
|
||||
expect(r.server.launch.args).toEqual(['agent', 'stdio'])
|
||||
expect(r.server.exited).toBe(true)
|
||||
expect(
|
||||
r.agent.frames.some((frame) =>
|
||||
['session/load', 'session/resume', 'session/fork', 'session/cancel'].includes(
|
||||
frame.method ?? ''
|
||||
)
|
||||
)
|
||||
).toBe(false)
|
||||
expect(r.agent.frames.find((frame) => frame.method === '_x.ai/session/delete')?.params).toEqual(
|
||||
{ sessionId: '11111111-1111-4111-8111-111111111111', cwd: r.server.launch.cwd }
|
||||
)
|
||||
await expect(access(r.server.launch.cwd!)).rejects.toThrow()
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['unsupported', 'stalled'] as const)(
|
||||
'preserves a title after %s deletion and closes',
|
||||
async (deletion) => {
|
||||
vi.useFakeTimers()
|
||||
const r = await rig({ deletion, shouldExecute: () => true })
|
||||
r.text('Repair login')
|
||||
r.agent.reply(r.frame, { stopReason: 'end_turn' })
|
||||
await vi.advanceTimersByTimeAsync(2_001)
|
||||
expect(await r.result).toBe('Repair login')
|
||||
expect(r.server.exited).toBe(true)
|
||||
}
|
||||
)
|
||||
|
||||
it('rejects standalone failure and cancels before deleting and closing', async () => {
|
||||
const r = await rig()
|
||||
r.agent.notify('_x.ai/session/prompt_complete', {
|
||||
sessionId: '11111111-1111-4111-8111-111111111111',
|
||||
promptId: r.promptId,
|
||||
stopReason: 'error'
|
||||
})
|
||||
await expect(r.result).rejects.toThrow('did not complete')
|
||||
const methods = r.agent.frames.map((frame) => frame.method)
|
||||
expect(methods.indexOf('session/cancel')).toBeLessThan(methods.indexOf('_x.ai/session/delete'))
|
||||
expect(r.server.exited).toBe(true)
|
||||
})
|
||||
|
||||
it('does not spawn when preparation finishes after the deadline', async () => {
|
||||
vi.useFakeTimers()
|
||||
const prepare = Promise.withResolvers<Record<string, string>>()
|
||||
const onConnect = vi.fn()
|
||||
const preparing = Promise.withResolvers<void>()
|
||||
const fixture = rig({
|
||||
prepare: () => {
|
||||
preparing.resolve()
|
||||
return prepare.promise
|
||||
},
|
||||
onConnect
|
||||
})
|
||||
const failure = expect(fixture).rejects.toThrow('Chat naming timed out')
|
||||
await preparing.promise
|
||||
await vi.advanceTimersByTimeAsync(60_000)
|
||||
await failure
|
||||
prepare.resolve({ PATH: '/usr/bin' })
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(onConnect).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['tool', 'overflow', 'shutdown'] as const)(
|
||||
'closes the helper after %s',
|
||||
async (failure) => {
|
||||
const r = await rig()
|
||||
if (failure === 'tool') {
|
||||
r.agent.notify('session/update', {
|
||||
sessionId: '11111111-1111-4111-8111-111111111111',
|
||||
update: {
|
||||
sessionUpdate: 'tool_call',
|
||||
toolCallId: 'tool',
|
||||
title: 'Shell',
|
||||
status: 'in_progress'
|
||||
}
|
||||
})
|
||||
}
|
||||
if (failure === 'overflow') {
|
||||
r.text('x'.repeat(128_001))
|
||||
}
|
||||
if (failure === 'shutdown') {
|
||||
await r.tasks.closeAll()
|
||||
}
|
||||
await expect(r.result).rejects.toThrow()
|
||||
expect(r.server.exited).toBe(true)
|
||||
}
|
||||
)
|
||||
|
||||
it('enforces the task deadline and tears down its child', async () => {
|
||||
vi.useFakeTimers()
|
||||
const r = await rig()
|
||||
const failure = expect(r.result).rejects.toThrow('Chat naming timed out')
|
||||
await vi.advanceTimersByTimeAsync(60_000)
|
||||
await failure
|
||||
expect(r.server.exited).toBe(true)
|
||||
})
|
||||
|
||||
it('removes only its owned Antigravity session files after proven exit, including late exit', async () => {
|
||||
const r = await rig({ antigravity: true, proveExit: false })
|
||||
const session = '11111111-1111-4111-8111-111111111111'
|
||||
const directory = join(r.root, 'antigravity-acp', 'conversations')
|
||||
await mkdir(directory, { recursive: true })
|
||||
const metadata = join(directory, `${session}.meta`)
|
||||
await writeFile(metadata, JSON.stringify({ cwd: r.server.launch.cwd }))
|
||||
await writeFile(join(directory, 'keep.meta'), '{}')
|
||||
r.text('Repair login')
|
||||
r.agent.reply(r.frame, { stopReason: 'end_turn' })
|
||||
expect(await r.result).toBe('Repair login')
|
||||
expect(await readFile(metadata, 'utf8')).toContain(r.server.launch.cwd)
|
||||
r.server.exit()
|
||||
await vi.waitFor(async () => {
|
||||
await expect(access(metadata)).rejects.toThrow()
|
||||
})
|
||||
expect(await readFile(join(directory, 'keep.meta'), 'utf8')).toBe('{}')
|
||||
})
|
||||
|
||||
it.each(['launch', 'private-temp'] as const)(
|
||||
'skips stale work after held %s preparation',
|
||||
async (stage) => {
|
||||
const entered = Promise.withResolvers<string>()
|
||||
const release = Promise.withResolvers<void>()
|
||||
let current = true
|
||||
const onConnect = vi.fn()
|
||||
if (stage === 'private-temp') {
|
||||
boundary.afterTemp.mockImplementation(async (path) => {
|
||||
if (path.includes('acp-temp')) {
|
||||
entered.resolve(path)
|
||||
await release.promise
|
||||
}
|
||||
})
|
||||
}
|
||||
const r = await stagedRig({
|
||||
antigravity: stage === 'private-temp',
|
||||
onConnect,
|
||||
shouldExecute: () => current,
|
||||
prepare: async () => {
|
||||
if (stage === 'launch') {
|
||||
entered.resolve('')
|
||||
await release.promise
|
||||
}
|
||||
return { PATH: '/usr/bin' }
|
||||
}
|
||||
})
|
||||
const folder = await entered.promise
|
||||
current = false
|
||||
release.resolve()
|
||||
expect(await r.result).toBeNull()
|
||||
expect(onConnect).not.toHaveBeenCalled()
|
||||
expect(r.logger.warn).not.toHaveBeenCalled()
|
||||
if (folder) {
|
||||
await vi.waitFor(async () => {
|
||||
await expect(access(folder)).rejects.toThrow()
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['success', 'failure', 'deadline'] as const)(
|
||||
'joins root and managed close on %s',
|
||||
async (outcome) => {
|
||||
vi.useFakeTimers()
|
||||
const closing = Promise.withResolvers<void>()
|
||||
const close = Promise.withResolvers<boolean>()
|
||||
const r = await rig({
|
||||
shouldExecute: () => true,
|
||||
close: async () => {
|
||||
closing.resolve()
|
||||
return close.promise
|
||||
}
|
||||
})
|
||||
const settled = vi.fn()
|
||||
void r.result.then(settled, settled)
|
||||
r.text('Repair login')
|
||||
if (outcome === 'deadline') {
|
||||
await vi.advanceTimersByTimeAsync(60_000)
|
||||
} else {
|
||||
r.agent.reply(r.frame, { stopReason: outcome === 'success' ? 'end_turn' : 'error' })
|
||||
}
|
||||
await closing.promise
|
||||
if (outcome === 'success') {
|
||||
r.server.exit()
|
||||
await Promise.resolve()
|
||||
expect(settled).not.toHaveBeenCalled()
|
||||
close.resolve(true)
|
||||
} else {
|
||||
close.resolve(false)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(settled).not.toHaveBeenCalled()
|
||||
r.server.exit()
|
||||
}
|
||||
if (outcome === 'success') {
|
||||
expect(await r.result).toBe('Repair login')
|
||||
} else {
|
||||
await expect(r.result).rejects.toThrow(
|
||||
outcome === 'deadline' ? 'timed out' : 'did not complete'
|
||||
)
|
||||
}
|
||||
expect(settled).toHaveBeenCalledTimes(1)
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['shutdown', 'account'] as const)(
|
||||
'allows %s to retry a retained root before releasing the caller',
|
||||
async (owner) => {
|
||||
const r = await rig({ proveExit: false, shouldExecute: () => true })
|
||||
const settled = vi.fn()
|
||||
void r.result.then(settled, settled)
|
||||
r.text('Repair login')
|
||||
r.agent.reply(r.frame, { stopReason: 'end_turn' })
|
||||
await vi.waitFor(() => expect(r.logger.warn).toHaveBeenCalled())
|
||||
expect(settled).not.toHaveBeenCalled()
|
||||
r.server.proveClose = async () => {
|
||||
r.server.exit()
|
||||
return true
|
||||
}
|
||||
const work = vi.fn(async () => {})
|
||||
if (owner === 'shutdown') {
|
||||
await r.tasks.closeAll()
|
||||
} else {
|
||||
await r.gate?.run(async () => {}, work)
|
||||
expect(work).toHaveBeenCalledOnce()
|
||||
}
|
||||
expect(await r.result).toBe('Repair login')
|
||||
expect(r.server.closes).toBe(2)
|
||||
expect(settled).toHaveBeenCalledTimes(1)
|
||||
}
|
||||
)
|
||||
|
||||
it('runs healthy own-agent naming through the runtime, registered router and ACP task', async () => {
|
||||
const r = await rig({ runtime: true })
|
||||
expect(r.frame.params).toEqual(
|
||||
expect.objectContaining({
|
||||
prompt: [expect.objectContaining({ text: expect.stringContaining('Repair login') })]
|
||||
})
|
||||
)
|
||||
expect(r.server.launch.env?.GROK_HOME).toBe(r.root)
|
||||
expect(r.server.launch.args).toEqual(['agent', 'stdio'])
|
||||
r.text('Repair login\n')
|
||||
r.agent.reply(r.frame, { stopReason: 'end_turn' })
|
||||
expect(await r.result).toEqual({ name: 'Repair login' })
|
||||
expect(r.logger.warn).not.toHaveBeenCalled()
|
||||
expect(r.server.exited).toBe(true)
|
||||
})
|
||||
@@ -0,0 +1,286 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { z } from 'zod'
|
||||
import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason'
|
||||
import type { StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
|
||||
import { ProviderAcquisitionStarts } from '../provider-process/provider-acquisition-starts'
|
||||
import { removeAntigravitySignInSessionFiles } from '../antigravity/antigravity-sign-in-session-files'
|
||||
import { SOURCE_CONTROL_GENERATION_TIMEOUT_MS } from '../text-generation/source-control-generation-limits'
|
||||
import { STRUCTURED_CHILD_ENV_TO_DELETE } from '../runtime/structured-session-child-env'
|
||||
import { resolveAcpLaunchInvocation } from './acp-structured-launch-resolution'
|
||||
import { prepareAcpLaunch } from './acp-structured-launch-preparation'
|
||||
import { acpPrivateTempConnect } from './acp-private-temp'
|
||||
import { AcpRpcError } from './acp-errors'
|
||||
import type { AcpStructuredConnection } from './acp-structured-connection'
|
||||
import type { AcpStructuredSessionAdapterDeps } from './acp-structured-session-adapter-deps'
|
||||
|
||||
type TextInput = Parameters<NonNullable<StructuredAgentSessionAdapter['generateText']>>[0]
|
||||
const envelope = z.looseObject({ sessionId: z.string() })
|
||||
const TASK_ENV_TO_DELETE = [
|
||||
...STRUCTURED_CHILD_ENV_TO_DELETE,
|
||||
'ORCA_AGENT_SESSION_ID',
|
||||
'ORCA_AGENT_SESSION_SPAWN_TOKEN',
|
||||
'ORCA_TERMINAL_HANDLE',
|
||||
'ORCA_STRUCTURED_SESSION',
|
||||
'ORCA_CHAT_SCRATCH_DIR',
|
||||
'ORCA_CHAT_VISUALS_DIR'
|
||||
]
|
||||
|
||||
/** Disposable native tasks belong to the adapter so shutdown also owns uncertain closes. */
|
||||
export class AcpTextTasks {
|
||||
private readonly children = new ProviderAcquisitionStarts<AcpStructuredConnection>()
|
||||
private readonly shutdown = new AbortController()
|
||||
private readonly pending = new Set<Promise<string | null>>()
|
||||
|
||||
constructor(
|
||||
private readonly deps: Pick<
|
||||
AcpStructuredSessionAdapterDeps,
|
||||
'spec' | 'textTaskLaunch' | 'connect' | 'dataDirectory' | 'logger'
|
||||
>
|
||||
) {}
|
||||
|
||||
generate = async (input: TextInput): Promise<string | null> => {
|
||||
const gate = this.deps.spec.launchGate?.enter(this.shutdown.signal)
|
||||
let rootExited = Promise.resolve()
|
||||
const task = this.run(input, gate?.signal ?? this.shutdown.signal, (connection) => {
|
||||
rootExited = new Promise((resolve) => connection.onExit(() => resolve()))
|
||||
})
|
||||
this.pending.add(task)
|
||||
const finalized = task.finally(() => {
|
||||
this.pending.delete(task)
|
||||
gate?.leave()
|
||||
})
|
||||
return input.shouldExecute ? finalized.finally(() => rootExited) : finalized
|
||||
}
|
||||
|
||||
async closeAll(): Promise<boolean> {
|
||||
this.shutdown.abort()
|
||||
await Promise.allSettled(this.pending)
|
||||
return (
|
||||
await Promise.all(this.children.failedSessionIds().map((id) => this.children.stopFailed(id)))
|
||||
).every(Boolean)
|
||||
}
|
||||
|
||||
private async run(
|
||||
input: TextInput,
|
||||
parent: AbortSignal,
|
||||
observeRoot: (connection: AcpStructuredConnection) => void
|
||||
): Promise<string | null> {
|
||||
const { spec } = this.deps
|
||||
const deadline = new AbortController()
|
||||
const timer = setTimeout(
|
||||
() => deadline.abort(new Error('Chat naming timed out')),
|
||||
SOURCE_CONTROL_GENERATION_TIMEOUT_MS
|
||||
)
|
||||
const signal = AbortSignal.any([parent, deadline.signal])
|
||||
const stale = Symbol('stale naming task')
|
||||
const checkCurrent = (): void => {
|
||||
signal.throwIfAborted()
|
||||
if (input.shouldExecute?.() === false) {
|
||||
throw stale
|
||||
}
|
||||
}
|
||||
let connection: AcpStructuredConnection | undefined
|
||||
let cwd: string | undefined
|
||||
let home = ''
|
||||
let sessionId = ''
|
||||
let completed = false
|
||||
let starting = false
|
||||
let done = false
|
||||
let disposed = false
|
||||
let unsubscribe = (): void => {}
|
||||
const dispose = async (): Promise<void> => {
|
||||
if (starting || !done || disposed || !cwd || (connection && !connection.exited)) {
|
||||
return
|
||||
}
|
||||
disposed = true
|
||||
try {
|
||||
if (spec.agent === 'antigravity') {
|
||||
await removeAntigravitySignInSessionFiles(home, sessionId, cwd)
|
||||
}
|
||||
await rm(cwd, { recursive: true, force: true })
|
||||
} catch (error) {
|
||||
this.deps.logger?.warn('Naming helper files could not be removed', {
|
||||
scope: 'acp-text-task',
|
||||
error
|
||||
})
|
||||
}
|
||||
}
|
||||
try {
|
||||
signal.throwIfAborted()
|
||||
const invocation = this.deps.textTaskLaunch
|
||||
if (!invocation) {
|
||||
throw new Error('Native text task launch is unavailable')
|
||||
}
|
||||
const launch = await waitForPromiseWithSignal(
|
||||
prepareAcpLaunch(
|
||||
this.deps,
|
||||
() => resolveAcpLaunchInvocation(spec, input.accountHome, invocation),
|
||||
() => signal.throwIfAborted()
|
||||
),
|
||||
signal
|
||||
)
|
||||
home = launch.env.GEMINI_HOME ?? ''
|
||||
cwd = await mkdtemp(join(tmpdir(), 'orca-chat-name-'))
|
||||
signal.throwIfAborted()
|
||||
const failed = new AbortController()
|
||||
const taskSignal = AbortSignal.any([signal, failed.signal])
|
||||
const reject = (): never => {
|
||||
const error = new AcpRpcError(
|
||||
-32601,
|
||||
'Tools and user input are unavailable during chat naming'
|
||||
)
|
||||
failed.abort(error)
|
||||
throw error
|
||||
}
|
||||
const promptId = randomUUID()
|
||||
const terminal = Promise.withResolvers<{ stopReason: string }>()
|
||||
const notification = (method: string, params: unknown): void => {
|
||||
if (!sessionId || envelope.safeParse(params).data?.sessionId !== sessionId) {
|
||||
return
|
||||
}
|
||||
const completion = spec.dialect.textTaskCompletion?.(method, params)
|
||||
if (completion?.promptId === promptId) {
|
||||
terminal.resolve(completion)
|
||||
}
|
||||
const mapped = spec.dialect.notification?.(method, params, Date.now())
|
||||
if (
|
||||
mapped?.disposition === 'map' &&
|
||||
mapped.turn === promptId &&
|
||||
!mapped.replay &&
|
||||
mapped.end
|
||||
) {
|
||||
terminal.resolve(mapped.end)
|
||||
}
|
||||
}
|
||||
const spawn = (connect: typeof this.deps.connect): AcpStructuredConnection => {
|
||||
connection = connect(
|
||||
{
|
||||
...launch,
|
||||
cwd,
|
||||
args: spec.args({ fullAccess: false, pluginDir: null }),
|
||||
envToDelete: [...launch.envToDelete, ...TASK_ENV_TO_DELETE]
|
||||
},
|
||||
{
|
||||
clientInfo: { name: 'orca-chat-naming', version: '1' },
|
||||
signIn: spec.signIn,
|
||||
onPermission: reject,
|
||||
onRequest: reject,
|
||||
onExtensionNotification: notification
|
||||
}
|
||||
)
|
||||
observeRoot(connection)
|
||||
this.children.retainFailed(promptId, connection)
|
||||
spec.launchGate?.retain(connection)
|
||||
connection.onExit(() => {
|
||||
void dispose()
|
||||
})
|
||||
return connection
|
||||
}
|
||||
const server = await waitForPromiseWithSignal(
|
||||
spec.privateTempDirectory
|
||||
? acpPrivateTempConnect(this.deps, checkCurrent).then(spawn)
|
||||
: Promise.resolve().then(() => {
|
||||
checkCurrent()
|
||||
return spawn(this.deps.connect)
|
||||
}),
|
||||
signal
|
||||
)
|
||||
let output = ''
|
||||
unsubscribe = server.subscribe((event) => {
|
||||
const params = event.kind === 'known' ? event.notification : event.raw
|
||||
notification('session/update', params)
|
||||
if (params.sessionId !== sessionId) {
|
||||
return
|
||||
}
|
||||
const update = params.update
|
||||
if (update.sessionUpdate === 'tool_call' || update.sessionUpdate === 'tool_call_update') {
|
||||
failed.abort(new Error('The naming agent attempted tool work'))
|
||||
}
|
||||
if (
|
||||
event.kind === 'known' &&
|
||||
event.notification.update.sessionUpdate === 'agent_message_chunk'
|
||||
) {
|
||||
const content = event.notification.update.content
|
||||
if (content.type === 'text') {
|
||||
if (output.length + content.text.length > 128_000) {
|
||||
failed.abort(new Error('Chat name output exceeded its limit'))
|
||||
} else {
|
||||
output += content.text
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
const initialized = await waitForPromiseWithSignal(server.initialize(), taskSignal)
|
||||
const authMethodId = spec.authMethod?.({
|
||||
advertised: initialized.authMethods?.map((method) => method.id) ?? [],
|
||||
env: launch.env
|
||||
})
|
||||
starting = true
|
||||
await waitForPromiseWithSignal(
|
||||
server
|
||||
.start({ cwd, mcpServers: [], authMethodId })
|
||||
.then((started) => {
|
||||
sessionId = started.sessionId
|
||||
})
|
||||
.finally(() => {
|
||||
starting = false
|
||||
void dispose()
|
||||
}),
|
||||
taskSignal
|
||||
)
|
||||
const response = await waitForPromiseWithSignal(
|
||||
Promise.race([
|
||||
server.prompt([{ type: 'text', text: input.prompt }], { promptId, requestId: promptId }),
|
||||
terminal.promise
|
||||
]),
|
||||
taskSignal
|
||||
)
|
||||
taskSignal.throwIfAborted()
|
||||
if (['cancelled', 'error', 'rate_limit'].includes(response.stopReason)) {
|
||||
throw new Error('The naming agent did not complete its reply')
|
||||
}
|
||||
completed = true
|
||||
return output
|
||||
} catch (error) {
|
||||
if (error === stale) {
|
||||
return null
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
unsubscribe()
|
||||
deadline.abort()
|
||||
const cleanup = async (work: () => Promise<unknown>): Promise<void> => {
|
||||
try {
|
||||
await waitForPromiseWithSignal(work(), AbortSignal.timeout(2_000))
|
||||
} catch (error) {
|
||||
this.deps.logger?.warn('Naming helper protocol cleanup failed', {
|
||||
scope: 'acp-text-task',
|
||||
error
|
||||
})
|
||||
}
|
||||
}
|
||||
if (connection && sessionId) {
|
||||
const server = connection
|
||||
if (!completed) {
|
||||
await cleanup(() => server.cancel())
|
||||
}
|
||||
if (spec.agent === 'grok') {
|
||||
await cleanup(() =>
|
||||
server.requestExtension('_x.ai/session/delete', { sessionId, cwd }, 2_000)
|
||||
)
|
||||
}
|
||||
}
|
||||
if (connection && !(await connection.close().catch(() => false))) {
|
||||
this.deps.logger?.warn('Naming helper exit is unproven; retained for cleanup', {
|
||||
scope: 'acp-text-task'
|
||||
})
|
||||
}
|
||||
done = true
|
||||
await dispose()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -180,6 +180,14 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi
|
||||
return reader(input)
|
||||
}
|
||||
|
||||
generateText: NonNullable<StructuredAgentSessionAdapter['generateText']> = (input) => {
|
||||
const { adapter } = this.requireAgent(input)
|
||||
if (!adapter.generateText) {
|
||||
return Promise.reject(new Error(`${input.agent} cannot run an independent text task`))
|
||||
}
|
||||
return adapter.generateText(input)
|
||||
}
|
||||
|
||||
copyConversation: NonNullable<StructuredAgentSessionAdapter['copyConversation']> = (input) => {
|
||||
const { adapter } = this.requireAgent(input.identity)
|
||||
if (!adapter.copyConversation) {
|
||||
@@ -301,7 +309,9 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi
|
||||
return route?.state === 'live' ? route.registration.adapter : null
|
||||
}
|
||||
|
||||
private requireAgent(identity: AgentSessionJournalIdentity): StructuredAgentRegistration {
|
||||
private requireAgent(
|
||||
identity: Pick<AgentSessionJournalIdentity, 'agent'>
|
||||
): StructuredAgentRegistration {
|
||||
const registration = this.agents.registration(identity.agent)
|
||||
if (!registration) {
|
||||
throw new Error(`structured sessions do not support ${identity.agent}`)
|
||||
|
||||
@@ -293,6 +293,13 @@ export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop &
|
||||
sessionId: string
|
||||
fence: number
|
||||
}): Promise<StructuredAgentSessionLiveOptions>
|
||||
/** A fresh independent text task; never acquires or writes a turn in the chat. */
|
||||
generateText?(input: {
|
||||
agent: string
|
||||
accountHome: AgentSessionAccountHome
|
||||
prompt: string
|
||||
shouldExecute?: () => boolean
|
||||
}): Promise<string | null>
|
||||
/** Copy the provider conversation without changing the chat’s ID; omit for CLI-based forks. */
|
||||
copyConversation?(input: {
|
||||
identity: AgentSessionJournalIdentity
|
||||
|
||||
@@ -3,6 +3,7 @@ import { AgentSessionJournal } from './agent-session-journal/journal-store'
|
||||
import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope'
|
||||
import { structuredChatNamingDeps } from '../runtime/structured-chat-naming-runtime'
|
||||
import { getDefaultSettings } from '../../shared/constants'
|
||||
import { normalizeSourceControlAiSettings } from '../../shared/source-control-ai-settings'
|
||||
import { setStructuredAgentSessionHost } from './agent-session-wire/structured-agent-session-registry'
|
||||
import * as agentLaunch from '../text-generation/source-control-agent-launch'
|
||||
import * as homeLock from '../codex-cli/codex-home-process-lock'
|
||||
@@ -61,10 +62,15 @@ function productionNaming(home: string) {
|
||||
})
|
||||
const settings = getDefaultSettings('/isolated')
|
||||
settings.defaultTuiAgent = 'codex'
|
||||
settings.sourceControlAi = normalizeSourceControlAiSettings({
|
||||
...normalizeSourceControlAiSettings(settings.sourceControlAi),
|
||||
actions: { conversationName: { agentId: 'codex' } }
|
||||
})
|
||||
const deps = structuredChatNamingDeps(
|
||||
() => ({ getSettings: () => settings }),
|
||||
{
|
||||
resolveWorkspace: async () => ({ path: '/same-folder', executionHostId: 'local' }),
|
||||
resolveNamingEnvironment: async () => ({ CODEX_HOME: home }),
|
||||
getAgentEnvResolvers: () => ({
|
||||
resolveBaseEnvironment: async () => ({ CODEX_HOME: home })
|
||||
}),
|
||||
|
||||
@@ -45,6 +45,7 @@ it.each(['J', 'R', 'P', 'L', 'T', 'U', 'C'] as const)(
|
||||
() => ({ getSettings: () => getDefaultSettings('/isolated') }),
|
||||
{
|
||||
resolveWorkspace: async () => ({ path: '/fixture', executionHostId: 'local' }),
|
||||
resolveNamingEnvironment: async () => ({}),
|
||||
getAgentEnvResolvers: () => undefined,
|
||||
workerTaskTitle: () => null,
|
||||
retitleOpenTab: runtime.refreshStructuredConversationTabTitle.bind(runtime)
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
[
|
||||
{
|
||||
"provider": "opencode",
|
||||
"version": "1.19.3",
|
||||
"exitCode": 0,
|
||||
"name": "QA2470 Lighthouse Signal",
|
||||
"error": null,
|
||||
"stdout": "{\"type\":\"step_start\",\"timestamp\":1792991875300,\"sessionID\":\"ses_e895b8375fferhsqRugDBAfOMG\",\"part\":{\"id\":\"prt_176a4d0e3001m57TVJpsACK53O\",\"messageID\":\"msg_176a47d7f001JaEXkAqkc0ZZ9i\",\"sessionID\":\"ses_e895b8375fferhsqRugDBAfOMG\",\"snapshot\":\"1335331e97b2b8302af6756ec01cb1b1da251fb3\",\"type\":\"step-start\"}}\n{\"type\":\"text\",\"timestamp\":1792991875307,\"sessionID\":\"ses_e895b8375fferhsqRugDBAfOMG\",\"part\":{\"id\":\"prt_176a4d0e9001Kd17lu7Ex0q8Tx\",\"messageID\":\"msg_176a47d7f001JaEXkAqkc0ZZ9i\",\"sessionID\":\"ses_e895b8375fferhsqRugDBAfOMG\",\"type\":\"text\",\"text\":\"QA2470 Lighthouse Signal\",\"time\":{\"start\":1792991875305,\"end\":1792991875306}}}\n{\"type\":\"step_finish\",\"timestamp\":1792991875504,\"sessionID\":\"ses_e895b8375fferhsqRugDBAfOMG\",\"part\":{\"id\":\"prt_176a4d1ab001RJrfENLLX1KW4r\",\"reason\":\"stop\",\"snapshot\":\"1335331e97b2b8302af6756ec01cb1b1da251fb3\",\"messageID\":\"msg_176a47d7f001JaEXkAqkc0ZZ9i\",\"sessionID\":\"ses_e895b8375fferhsqRugDBAfOMG\",\"type\":\"step-finish\",\"tokens\":{\"total\":8951,\"input\":7153,\"output\":6,\"reasoning\":0,\"cache\":{\"write\":0,\"read\":1792}},\"cost\":0}}\n",
|
||||
"stderr": "",
|
||||
"stdoutSha256": "f4fb472fd517f7c9b71d368c711153ffa317c8384747cebf7844850d0430f8a7",
|
||||
"stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
{
|
||||
"provider": "opencode2",
|
||||
"version": "2.0.21",
|
||||
"exitCode": 0,
|
||||
"name": "QA2409-OPENCODE2-RECORDED-REAL-OUTPUT",
|
||||
"error": null,
|
||||
"stdout": "{\"type\":\"step_start\",\"timestamp\":1792963133760,\"sessionID\":\"ses_e8b11e04fffejwGieQ5aEaX725\",\"part\":{\"id\":\"prt_174ee4140001jPttpvqKq3nqUG\",\"sessionID\":\"ses_e8b11e04fffejwGieQ5aEaX725\",\"messageID\":\"msg_174ee2aca0010gpdeVAaLShy3c\",\"type\":\"step-start\",\"snapshot\":\"63f0c12995d872e305bfefca54db12330153945a\"}}\n{\"type\":\"text\",\"timestamp\":1792963133934,\"sessionID\":\"ses_e8b11e04fffejwGieQ5aEaX725\",\"part\":{\"id\":\"prt_174ee4143001wQsGHsX1N5Y522\",\"sessionID\":\"ses_e8b11e04fffejwGieQ5aEaX725\",\"messageID\":\"msg_174ee2aca0010gpdeVAaLShy3c\",\"type\":\"text\",\"text\":\"QA2409-OPENCODE2-RECORDED-REAL-OUTPUT\",\"time\":{\"start\":1792963133763,\"end\":1792963133934}}}\n{\"type\":\"step_finish\",\"timestamp\":1792963134593,\"sessionID\":\"ses_e8b11e04fffejwGieQ5aEaX725\",\"part\":{\"id\":\"prt_174ee4481001E8vuO413WRUmM3\",\"sessionID\":\"ses_e8b11e04fffejwGieQ5aEaX725\",\"messageID\":\"msg_174ee2aca0010gpdeVAaLShy3c\",\"type\":\"step-finish\",\"reason\":\"stop\",\"snapshot\":\"d5bc3db45bd1a4f0f251bdf033349914a8c70ca2\",\"cost\":0,\"tokens\":{\"input\":3979,\"output\":20,\"reasoning\":0,\"cache\":{\"read\":2048,\"write\":0}}}}\n",
|
||||
"stderr": "",
|
||||
"stdoutSha256": "361fd0491ec7893d4ee48a5c46f668e8a61b4f23ff47d1f6ef8a02139eaa70ee",
|
||||
"stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
{
|
||||
"provider": "pi",
|
||||
"version": "print/text",
|
||||
"exitCode": 1,
|
||||
"name": null,
|
||||
"error": "Pi CLI command failed with code 1",
|
||||
"stdout": "",
|
||||
"stderr": "OAuth refresh failed for openai: OpenAI OAuth token request failed (400): {\n \"error\": \"invalid_grant\"\n}\n",
|
||||
"stdoutSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"stderrSha256": "789ac0ebfa846ae385959e1a56764768bc6cb3faad5c8417d8cc778af40c57b0"
|
||||
},
|
||||
{
|
||||
"provider": "omp",
|
||||
"version": "17.0.5",
|
||||
"exitCode": 0,
|
||||
"name": null,
|
||||
"error": "OMP returned an empty chat name.",
|
||||
"stdout": "",
|
||||
"stderr": "",
|
||||
"stdoutSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
}
|
||||
]
|
||||
@@ -152,6 +152,14 @@ function createAcpAdapter(
|
||||
})
|
||||
return new AcpStructuredSessionAdapter({
|
||||
spec,
|
||||
textTaskLaunch: {
|
||||
resolveEnvironment: sources.resolveBaseEnvironment,
|
||||
resolveLaunchEnv: sources.resolveAgentLaunchEnv,
|
||||
resolveCommandSettings: sources.resolveCommandSettings,
|
||||
refreshEnvironmentAfterMiss: sources.refreshAfterMissingProgram,
|
||||
...deps.acpLaunchCommand,
|
||||
dataDirectory: deps.stateDirectory
|
||||
},
|
||||
...(spec.agent === 'omp'
|
||||
? {
|
||||
atRestCommands: new AcpAtRestCommandCatalog(spec, {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { AgentSessionRecord } from '../../shared/agent-session-record'
|
||||
import { requireLegacyAgentSessionAccountHome } from '../../shared/agent-session-account-home'
|
||||
import { resolveTuiAgentLaunchEnv } from '../../shared/tui-agent-launch-defaults'
|
||||
import { acpLaunchSpecFor } from '../acp/acp-launch-specs'
|
||||
import { getClaudeProfileRouter } from '../claude-accounts/claude-profile-installed-router'
|
||||
import {
|
||||
claudeConfigDirEnvPatch,
|
||||
withoutInheritedClaudeConfigDir
|
||||
} from '../claude/claude-config-dir-pin'
|
||||
import { resolveClaudeStructuredLaunchHome } from '../claude/claude-structured-launch-home'
|
||||
import type { CommitMessageGenerationTarget } from '../text-generation/source-control-text-generation-types'
|
||||
|
||||
/** Default naming follows the native account binding; explicit recipes retain CLI routing. */
|
||||
export async function chatNamingAgentTarget(
|
||||
record: AgentSessionRecord,
|
||||
cwd: string,
|
||||
resolveEnvironment: (agent: string) => Promise<Record<string, string>>,
|
||||
settings: Parameters<typeof resolveTuiAgentLaunchEnv>[1]
|
||||
): Promise<CommitMessageGenerationTarget> {
|
||||
let env = await resolveEnvironment(record.provider)
|
||||
const spec = acpLaunchSpecFor(record.provider)
|
||||
if (spec) {
|
||||
env = spec.account.environment(record.accountHome, env)
|
||||
const deleted = spec.scrubEnvironment?.(env, process.env) ?? []
|
||||
const targetEnv: NodeJS.ProcessEnv = { ...env }
|
||||
for (const key of deleted) {
|
||||
targetEnv[key] = undefined
|
||||
}
|
||||
return { kind: 'local', cwd, env: targetEnv }
|
||||
}
|
||||
const home = requireLegacyAgentSessionAccountHome(record.accountHome)
|
||||
if (record.provider === 'claude') {
|
||||
env = {
|
||||
...withoutInheritedClaudeConfigDir(env),
|
||||
...resolveTuiAgentLaunchEnv('claude', settings)
|
||||
}
|
||||
const path = await resolveClaudeStructuredLaunchHome(getClaudeProfileRouter(), env, home.path)
|
||||
const patch = claudeConfigDirEnvPatch(path, { env })
|
||||
return { kind: 'local', cwd, env: { CLAUDE_CONFIG_DIR: undefined, ...env, ...patch } }
|
||||
}
|
||||
return { kind: 'local', cwd, env: { ...env, [home.variable]: home.path } }
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { dirname } from 'node:path'
|
||||
import { afterEach, beforeEach, expect, it, onTestFinished, vi } from 'vitest'
|
||||
import { getDefaultSettings } from '../../shared/constants'
|
||||
import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture'
|
||||
import { isTuiAgent } from '../../shared/tui-agent-config'
|
||||
import { getCommitMessageAgentSpec } from '../../shared/commit-message-agent-spec'
|
||||
import { toSshExecutionHostId, type ExecutionHostId } from '../../shared/execution-host'
|
||||
import {
|
||||
savedChatNamingCatalog,
|
||||
observeChatNamingCatalogWork
|
||||
} from '../../shared/chat-naming-catalog-work.test-fixture'
|
||||
import type { SourceControlActionRecipe } from '../../shared/source-control-ai-actions'
|
||||
import type { SpawnSourceControlAgent } from '../text-generation/source-control-text-generation-types'
|
||||
import { createMockDiscoveryChild } from '../text-generation/commit-message-text-generation-test-harness'
|
||||
import { structuredChatNamingDeps } from './structured-chat-naming-runtime'
|
||||
import type { StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
|
||||
import { AcpTextTasks } from '../acp/acp-text-task'
|
||||
import { acpLaunchSpecFor } from '../acp/acp-launch-specs'
|
||||
import { FakeAcpChild } from '../acp/acp-scripted-connection.test-fixture'
|
||||
import recordings from './__fixtures__/chat-naming-recorded-output.json'
|
||||
|
||||
const boundary = vi.hoisted(() => ({
|
||||
spawn: vi.fn<SpawnSourceControlAgent>(),
|
||||
native: vi.fn<NonNullable<StructuredAgentSessionAdapter['generateText']>>(),
|
||||
account: vi.fn((_provider: string, id: string) => ({
|
||||
XDG_DATA_HOME: `/profiles/${id}/data`,
|
||||
XDG_STATE_HOME: `/profiles/${id}/state`
|
||||
}))
|
||||
}))
|
||||
vi.mock('../text-generation/source-control-agent-launch', () => ({
|
||||
spawnSourceControlAgent: boundary.spawn
|
||||
}))
|
||||
vi.mock('../native-chat/agent-session-wire/structured-agent-session-registry', () => ({
|
||||
getStructuredAgentSessionHost: () => ({ deps: { adapter: { generateText: boundary.native } } })
|
||||
}))
|
||||
vi.mock('../managed-data-accounts/service', () => ({
|
||||
getManagedDataAccountService: () => ({
|
||||
restoreOriginalEnvironment: () => {},
|
||||
environmentForAccount: boundary.account
|
||||
})
|
||||
}))
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
boundary.spawn.mockReset()
|
||||
boundary.native.mockReset()
|
||||
vi.stubEnv('ORCA_PI_SOURCE_AGENT_DIR', undefined)
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
function rig() {
|
||||
const settings = getDefaultSettings('/isolated')
|
||||
settings.defaultTuiAgent = 'codex'
|
||||
settings.sourceControlAi = savedChatNamingCatalog(0)
|
||||
const env = {
|
||||
PATH: '/usr/bin',
|
||||
PI_CODING_AGENT_DIR: '/configured/pi',
|
||||
OPENCODE_CONFIG_DIR: '/configured/oc',
|
||||
OPENCODE_AUTH_CONTENT: 'current-account-sentinel'
|
||||
}
|
||||
const runtime = {
|
||||
resolveWorkspace: vi.fn(
|
||||
async (): Promise<{ path: string; executionHostId: ExecutionHostId }> => ({
|
||||
path: '/workspace/folder',
|
||||
executionHostId: 'local'
|
||||
})
|
||||
),
|
||||
resolveNamingEnvironment: vi.fn(async () => env),
|
||||
getAgentEnvResolvers: () => ({ resolveBaseEnvironment: async () => env }),
|
||||
workerTaskTitle: () => null,
|
||||
retitleOpenTab: () => {}
|
||||
}
|
||||
return {
|
||||
settings,
|
||||
runtime,
|
||||
deps: structuredChatNamingDeps(() => ({ getSettings: () => settings }), runtime, {
|
||||
warn: vi.fn(),
|
||||
error: vi.fn()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function reply(stdout: string, stderr = '', code = 0) {
|
||||
const child = createMockDiscoveryChild()
|
||||
child.stdin.end.mockImplementation(() =>
|
||||
queueMicrotask(() => {
|
||||
child.stdout.emit('data', Buffer.from(stdout))
|
||||
child.stderr.emit('data', Buffer.from(stderr))
|
||||
child.emit('exit', code, null)
|
||||
child.emit('close', code, null)
|
||||
})
|
||||
)
|
||||
boundary.spawn.mockReturnValue(child)
|
||||
return child
|
||||
}
|
||||
|
||||
function nativeReply() {
|
||||
const spec = acpLaunchSpecFor('grok')
|
||||
if (!spec) {
|
||||
throw new Error('Missing native task spec')
|
||||
}
|
||||
const tasks = new AcpTextTasks({
|
||||
spec,
|
||||
dataDirectory: '/fixture/state',
|
||||
textTaskLaunch: {
|
||||
dataDirectory: '/fixture/state',
|
||||
inheritedEnv: {},
|
||||
homePath: '/fixture',
|
||||
resolveEnvironment: async () => ({ PATH: '/usr/bin' }),
|
||||
resolveCommand: () => '/fixture/grok'
|
||||
},
|
||||
connect: (launch, options) => {
|
||||
expect(launch.env?.GROK_HOME).toBe('/recorded/grok')
|
||||
const child = new FakeAcpChild(launch, options)
|
||||
const { agent } = child
|
||||
const sessionId = '11111111-1111-4111-8111-111111111111'
|
||||
agent.on('initialize', (frame) =>
|
||||
agent.reply(frame, { protocolVersion: 1, agentCapabilities: {} })
|
||||
)
|
||||
agent.on('session/new', (frame) => agent.reply(frame, { sessionId }))
|
||||
agent.on('_x.ai/session/delete', (frame) => agent.reply(frame, { success: true }))
|
||||
agent.on('session/prompt', (frame) => {
|
||||
agent.notify('session/update', {
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: { type: 'text', text: 'Native title' }
|
||||
}
|
||||
})
|
||||
agent.reply(frame, { stopReason: 'end_turn' })
|
||||
})
|
||||
return child
|
||||
}
|
||||
})
|
||||
boundary.native.mockImplementation(tasks.generate)
|
||||
onTestFinished(async () => {
|
||||
await tasks.closeAll()
|
||||
})
|
||||
}
|
||||
|
||||
it.each(recordings)(
|
||||
'consumes recorded $provider output through runtime, request, planner and parser',
|
||||
async (capture) => {
|
||||
const { provider, stdout, stderr, exitCode, name, error } = capture
|
||||
if (!isTuiAgent(provider)) {
|
||||
throw new Error('Unknown recorded provider')
|
||||
}
|
||||
expect(createHash('sha256').update(stdout).digest('hex')).toBe(capture.stdoutSha256)
|
||||
expect(createHash('sha256').update(stderr).digest('hex')).toBe(capture.stderrSha256)
|
||||
const state = rig()
|
||||
const source = state.settings.sourceControlAi
|
||||
const spec = getCommitMessageAgentSpec(provider)
|
||||
if (!source || !spec) {
|
||||
throw new Error('Missing naming configuration')
|
||||
}
|
||||
source.agentId = 'codex'
|
||||
if (provider === 'pi') {
|
||||
source.selectedModelByAgent.pi = 'openai/gpt-6.1-sol'
|
||||
source.discoveredModelsByAgent = {
|
||||
pi: [{ id: 'openai/gpt-6.1-sol', label: 'Recorded model' }]
|
||||
}
|
||||
}
|
||||
const record = agentSessionRecordFixture()
|
||||
record.provider = provider
|
||||
record.accountHome =
|
||||
provider === 'opencode' || provider === 'opencode2'
|
||||
? { kind: 'opencode', locator: { kind: 'managed', managedProfileId: 'recorded' } }
|
||||
: { variable: 'PI_CODING_AGENT_DIR', path: '/recorded/pi' }
|
||||
const prompt = `Name this chat from the user's first request.
|
||||
Write a short, specific title of at most 48 characters.
|
||||
Output only the title, with no heading, quotes, or explanation.
|
||||
|
||||
First request:
|
||||
Repair login`
|
||||
const child = reply(stdout, stderr, exitCode)
|
||||
let promptPath = ''
|
||||
if (spec.promptDelivery === 'file') {
|
||||
boundary.spawn.mockImplementationOnce((input) => {
|
||||
promptPath = input.args.find((arg) => arg.startsWith('@'))?.slice(1) ?? ''
|
||||
expect(readFileSync(promptPath)).toEqual(Buffer.from(`\uFEFF${prompt}`))
|
||||
return child
|
||||
})
|
||||
}
|
||||
const result = await state.deps.generate(record, 'Repair login')
|
||||
if (name) {
|
||||
expect(result).toEqual({ name })
|
||||
} else {
|
||||
expect(result).toMatchObject({ error: expect.stringContaining(error ?? '') })
|
||||
expect(result).not.toHaveProperty('name')
|
||||
if (provider === 'pi') {
|
||||
expect(result).toMatchObject({ failureOutput: { exitCode: 1, stdout, stderr } })
|
||||
}
|
||||
}
|
||||
expect(boundary.spawn).toHaveBeenCalledOnce()
|
||||
const spawn = boundary.spawn.mock.calls[0]?.[0]
|
||||
// Delivery follows the common spec; the empty OMP recording remains a negative output control.
|
||||
expect(spawn).toMatchObject({
|
||||
binary: spec.binary,
|
||||
args: spec.buildArgs({
|
||||
prompt: spec.promptDelivery === 'file' ? `@${promptPath}` : prompt,
|
||||
model: provider === 'pi' ? 'openai/gpt-6.1-sol' : 'default'
|
||||
}),
|
||||
cwd: '/workspace/folder',
|
||||
stdinMode: 'pipe'
|
||||
})
|
||||
expect(child.stdin.end).toHaveBeenCalledExactlyOnceWith(
|
||||
spec.promptDelivery === 'stdin' ? prompt : undefined
|
||||
)
|
||||
if (spec.promptDelivery === 'file') {
|
||||
expect(spawn?.args).not.toContain(prompt)
|
||||
await vi.waitFor(() => expect(existsSync(dirname(promptPath))).toBe(false))
|
||||
}
|
||||
if (provider === 'opencode' || provider === 'opencode2') {
|
||||
expect(spawn?.env).toMatchObject({
|
||||
XDG_DATA_HOME: '/profiles/recorded/data',
|
||||
XDG_STATE_HOME: '/profiles/recorded/state',
|
||||
OPENCODE_AUTH_CONTENT: undefined
|
||||
})
|
||||
expect(boundary.account).toHaveBeenCalledWith('opencode', 'recorded')
|
||||
// The v2 recording used standalone/argv; these assertions separately cover today's launch.
|
||||
expect(spawn?.args).toEqual(['run', '--agent', 'build', '--format', 'json'])
|
||||
} else {
|
||||
expect(spawn?.env?.PI_CODING_AGENT_DIR).toBe('/recorded/pi')
|
||||
}
|
||||
expect(boundary.native).not.toHaveBeenCalled()
|
||||
if (provider === 'pi') {
|
||||
source.actions = { conversationName: { agentId: 'pi' } }
|
||||
boundary.spawn.mockClear()
|
||||
const explicit = reply(stdout, stderr, exitCode)
|
||||
expect(await state.deps.generate(record, 'Repair login')).toEqual(result)
|
||||
expect(boundary.spawn.mock.calls[0]?.[0]).toMatchObject({
|
||||
binary: spawn?.binary,
|
||||
args: spawn?.args,
|
||||
env: { PI_CODING_AGENT_DIR: '/configured/pi' }
|
||||
})
|
||||
expect(explicit.stdin.end.mock.calls).toEqual(child.stdin.end.mock.calls)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it.each([100, 1000])(
|
||||
'bounds actual eligible and skipped naming catalog work for %i rows',
|
||||
async (size) => {
|
||||
const state = rig()
|
||||
const source = savedChatNamingCatalog(size)
|
||||
state.settings.sourceControlAi = source
|
||||
const { work, reset } = observeChatNamingCatalogWork()
|
||||
const samples: Record<string, typeof work> = {}
|
||||
const record = agentSessionRecordFixture()
|
||||
const recipes: (SourceControlActionRecipe | undefined)[] = [
|
||||
undefined,
|
||||
{ agentId: 'claude' },
|
||||
{ agentArgs: '--model fast' },
|
||||
{ agentId: null, agentArgs: '--model fast' }
|
||||
]
|
||||
for (const [index, recipe] of recipes.entries()) {
|
||||
source.agentId = index === 0 ? null : 'claude'
|
||||
source.actions = recipe ? { conversationName: recipe } : {}
|
||||
boundary.spawn.mockClear()
|
||||
reply('Catalog title')
|
||||
reset()
|
||||
expect(await state.deps.generate(record, 'Repair login')).toEqual({ name: 'Catalog title' })
|
||||
expect(work.copiedRows).toBeLessThanOrEqual(3 * size)
|
||||
expect(work.copiedRows).toBeGreaterThanOrEqual(size)
|
||||
expect(work.copiedBytes).toBeLessThanOrEqual(3 * 64 * size)
|
||||
expect(work.copiedBytes).toBeGreaterThanOrEqual(32 * size)
|
||||
expect(work.selectedRowReads).toBeLessThanOrEqual(2 * size + 2)
|
||||
expect(work.selectedRowReads).toBeGreaterThanOrEqual(index === 3 ? 0 : size)
|
||||
const agent = index === 3 ? 'codex' : 'claude'
|
||||
expect(boundary.spawn).toHaveBeenCalledOnce()
|
||||
expect(boundary.spawn.mock.calls[0]?.[0].binary).toBe(agent)
|
||||
if (index > 1) {
|
||||
expect(boundary.spawn.mock.calls[0]?.[0].args).toEqual(
|
||||
expect.arrayContaining(['--model', 'fast'])
|
||||
)
|
||||
}
|
||||
if (index === 0) {
|
||||
expect(boundary.spawn.mock.calls[0]?.[0].env?.CLAUDE_CONFIG_DIR).toBe('/home/user/.claude')
|
||||
}
|
||||
samples[`cli-${index}`] = { ...work }
|
||||
}
|
||||
source.actions = {}
|
||||
source.agentId = null
|
||||
record.provider = 'grok'
|
||||
record.accountHome = { variable: 'GROK_HOME', path: '/recorded/grok' }
|
||||
nativeReply()
|
||||
reset()
|
||||
expect(await state.deps.generate(record, 'Repair login')).toEqual({ name: 'Native title' })
|
||||
expect(work.copiedRows).toBeLessThanOrEqual(2 * size)
|
||||
expect(work.copiedRows).toBeGreaterThanOrEqual(size)
|
||||
expect(work.copiedBytes).toBeLessThanOrEqual(2 * 64 * size)
|
||||
expect(work.selectedRowReads).toBe(0)
|
||||
expect(boundary.native).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ agent: 'grok', accountHome: record.accountHome })
|
||||
)
|
||||
samples.native = { ...work }
|
||||
for (const kind of ['remote', 'wsl']) {
|
||||
if (kind === 'wsl') {
|
||||
record.location.wslDistro = 'Ubuntu'
|
||||
}
|
||||
state.runtime.resolveWorkspace.mockResolvedValue({
|
||||
path: '/remote',
|
||||
executionHostId: kind === 'remote' ? toSshExecutionHostId('target') : 'local'
|
||||
})
|
||||
boundary.spawn.mockClear()
|
||||
boundary.native.mockClear()
|
||||
reset()
|
||||
expect(await state.deps.generate(record, 'Repair login')).toBeNull()
|
||||
expect(work).toEqual({ copiedRows: 0, copiedBytes: 0, selectedRowReads: 0 })
|
||||
expect(boundary.spawn).not.toHaveBeenCalled()
|
||||
expect(boundary.native).not.toHaveBeenCalled()
|
||||
samples[kind] = { ...work }
|
||||
}
|
||||
console.log(JSON.stringify({ metric: 'chat-naming-runtime-catalog', size, samples }))
|
||||
}
|
||||
)
|
||||
@@ -1,10 +1,16 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { ClaudeProfileRouter } from '../claude-accounts/claude-profile-router'
|
||||
import { installClaudeProfileRouter } from '../claude-accounts/claude-profile-installed-router'
|
||||
import { submission as journalSubmission } from '../native-chat/agent-session-wire/structured-agent-session-restart-resume-test-harness'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { getDefaultSettings } from '../../shared/constants'
|
||||
import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture'
|
||||
import { toSshExecutionHostId } from '../../shared/execution-host'
|
||||
import { normalizeSourceControlAiSettings } from '../../shared/source-control-ai-settings'
|
||||
import { structuredChatNamingDeps } from './structured-chat-naming-runtime'
|
||||
import { STRUCTURED_AGENT_RUNTIME_CATALOG } from './structured-agent-runtime-metadata'
|
||||
import type {
|
||||
AgentJournalRenderItem,
|
||||
AgentJournalSubmission,
|
||||
@@ -13,6 +19,11 @@ import type {
|
||||
import type * as TextGeneration from '../text-generation/commit-message-text-generation'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
account: vi.fn((_provider: string, id: string) => ({
|
||||
XDG_DATA_HOME: `/profiles/${id}/data`,
|
||||
XDG_STATE_HOME: `/profiles/${id}/state`
|
||||
})),
|
||||
native: vi.fn(async () => 'Native title'),
|
||||
generate: vi.fn(async () => ({ success: true as const, name: 'Login repair' })),
|
||||
target: vi.fn(async (cwd: string) => ({ kind: 'local' as const, cwd })),
|
||||
publishConversationName: vi.fn<(sessionId: string) => void>(),
|
||||
@@ -28,11 +39,23 @@ vi.mock('../agent-hooks/first-work-generation-target', () => ({
|
||||
}))
|
||||
vi.mock('../native-chat/agent-session-wire/structured-agent-session-registry', () => ({
|
||||
getStructuredAgentSessionHost: () => ({
|
||||
deps: { adapter: { generateText: mocks.native } },
|
||||
publishConversationName: mocks.publishConversationName,
|
||||
journalSnapshot: mocks.journalSnapshot
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('../managed-data-accounts/service', () => ({
|
||||
getManagedDataAccountService: () => ({
|
||||
restoreOriginalEnvironment: () => {},
|
||||
environmentForAccount: mocks.account
|
||||
})
|
||||
}))
|
||||
const roots: string[] = []
|
||||
afterEach(async () => {
|
||||
installClaudeProfileRouter(undefined)
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
|
||||
})
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
function rig() {
|
||||
@@ -44,6 +67,9 @@ function rig() {
|
||||
path: '/workspace/folder',
|
||||
executionHostId: 'local' as const
|
||||
})),
|
||||
resolveNamingEnvironment: vi.fn(async (): Promise<Record<string, string>> => ({
|
||||
PATH: '/usr/bin'
|
||||
})),
|
||||
getAgentEnvResolvers: vi.fn(() => undefined),
|
||||
workerTaskTitle: vi.fn(() => null),
|
||||
retitleOpenTab: vi.fn()
|
||||
@@ -58,6 +84,26 @@ function rig() {
|
||||
}
|
||||
|
||||
describe('structured chat naming runtime', () => {
|
||||
it.each(['grok', 'antigravity'] as const)(
|
||||
'codes %s native naming failures without exposing provider text',
|
||||
async (provider) => {
|
||||
const state = rig()
|
||||
const record = agentSessionRecordFixture()
|
||||
record.provider = provider
|
||||
for (const outcome of ['throw', 'empty'] as const) {
|
||||
if (outcome === 'throw') {
|
||||
mocks.native.mockRejectedValueOnce(new Error('private provider diagnostic'))
|
||||
} else {
|
||||
mocks.native.mockResolvedValueOnce('')
|
||||
}
|
||||
expect(await state.deps.generate(record, 'Repair login')).toEqual({
|
||||
errorCode: `${provider}Failed`,
|
||||
error: `${provider === 'grok' ? 'Grok' : 'Antigravity'} could not name this chat. Try generating the name again.`
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it.each([10, 100])(
|
||||
'scopes naming prompts with linear visits over %i retained items',
|
||||
async (count) => {
|
||||
@@ -140,8 +186,8 @@ describe('structured chat naming runtime', () => {
|
||||
await state.deps.generate(agentSessionRecordFixture(), 'Repair login')
|
||||
expect(mocks.generate).toHaveBeenLastCalledWith(
|
||||
{ firstPrompt: 'Repair login' },
|
||||
expect.objectContaining({ agentId: 'codex' }),
|
||||
{ kind: 'local', cwd: '/workspace/folder' },
|
||||
expect.objectContaining({ agentId: 'claude' }),
|
||||
expect.objectContaining({ kind: 'local', cwd: '/workspace/folder' }),
|
||||
undefined
|
||||
)
|
||||
state.settings.sourceControlAi = {
|
||||
@@ -167,13 +213,166 @@ describe('structured chat naming runtime', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it.each(
|
||||
STRUCTURED_AGENT_RUNTIME_CATALOG.map(({ metadata }) => metadata.definition.agent).filter(
|
||||
(agent) => agent !== 'pi' && agent !== 'omp'
|
||||
)
|
||||
)('names a %s chat with its own agent regardless of global selection', async (provider) => {
|
||||
const state = rig()
|
||||
state.settings.sourceControlAi = {
|
||||
...normalizeSourceControlAiSettings(state.settings.sourceControlAi),
|
||||
agentId: 'codex'
|
||||
}
|
||||
const record = agentSessionRecordFixture()
|
||||
record.provider = provider
|
||||
record.accountHome =
|
||||
provider === 'opencode' || provider === 'opencode2'
|
||||
? { kind: 'opencode', locator: { kind: 'unmanaged' } }
|
||||
: {
|
||||
variable:
|
||||
provider === 'codex'
|
||||
? 'CODEX_HOME'
|
||||
: provider === 'claude'
|
||||
? 'CLAUDE_CONFIG_DIR'
|
||||
: provider === 'grok'
|
||||
? 'GROK_HOME'
|
||||
: provider === 'antigravity'
|
||||
? 'GEMINI_HOME'
|
||||
: 'PI_CODING_AGENT_DIR',
|
||||
path: '/pinned-account'
|
||||
}
|
||||
const shouldExecute = () => true
|
||||
await state.deps.generate(record, 'Repair login', shouldExecute)
|
||||
if (provider === 'grok' || provider === 'antigravity') {
|
||||
expect(mocks.native).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ accountHome: record.accountHome, shouldExecute })
|
||||
)
|
||||
expect(mocks.generate).not.toHaveBeenCalled()
|
||||
} else {
|
||||
expect(mocks.generate).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ agentId: provider }),
|
||||
expect.objectContaining({
|
||||
env: expect.objectContaining(
|
||||
'variable' in record.accountHome
|
||||
? { [record.accountHome.variable]: record.accountHome.path }
|
||||
: {}
|
||||
)
|
||||
}),
|
||||
shouldExecute
|
||||
)
|
||||
}
|
||||
expect(state.settings.sourceControlAi?.actions?.conversationName?.agentId).toBeUndefined()
|
||||
})
|
||||
|
||||
it('uses the current routed Claude account', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-naming-account-test-'))
|
||||
roots.push(root)
|
||||
const home = join(root, 'claude-profiles', 'selected', 'home')
|
||||
await mkdir(home, { recursive: true })
|
||||
await writeFile(join(root, 'claude-profiles', 'selected', 'profile.json'), '{}')
|
||||
installClaudeProfileRouter(
|
||||
new ClaudeProfileRouter({
|
||||
getSettings: () => ({
|
||||
claudeManagedAccounts: [],
|
||||
activeClaudeManagedAccountId: 'selected',
|
||||
agentStatusHooksEnabled: false,
|
||||
disabledTuiAgents: []
|
||||
}),
|
||||
dataRoot: root,
|
||||
userHome: join(root, 'personal'),
|
||||
env: {}
|
||||
})
|
||||
)
|
||||
const state = rig()
|
||||
const record = agentSessionRecordFixture()
|
||||
await state.deps.generate(record, 'Repair login')
|
||||
expect(mocks.generate).toHaveBeenLastCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.objectContaining({ env: expect.objectContaining({ CLAUDE_CONFIG_DIR: home }) }),
|
||||
undefined
|
||||
)
|
||||
})
|
||||
|
||||
it.each([undefined, '/user-configured/claude'])(
|
||||
'distinguishes the explicit Claude directory %s from inherited environment under System default',
|
||||
async (configuredDir) => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-naming-system-account-test-'))
|
||||
roots.push(root)
|
||||
const router = new ClaudeProfileRouter({
|
||||
getSettings: () => ({
|
||||
claudeManagedAccounts: [],
|
||||
activeClaudeManagedAccountId: null,
|
||||
agentStatusHooksEnabled: false,
|
||||
disabledTuiAgents: []
|
||||
}),
|
||||
dataRoot: root,
|
||||
userHome: join(root, 'personal'),
|
||||
env: {}
|
||||
})
|
||||
installClaudeProfileRouter(router)
|
||||
const state = rig()
|
||||
state.settings.agentDefaultEnv = {
|
||||
claude: configuredDir ? { CLAUDE_CONFIG_DIR: configuredDir } : {}
|
||||
}
|
||||
state.runtime.resolveNamingEnvironment.mockResolvedValue({
|
||||
PATH: '/usr/bin',
|
||||
CLAUDE_CONFIG_DIR: configuredDir ?? '/inherited/claude'
|
||||
})
|
||||
await state.deps.generate(agentSessionRecordFixture(), 'Repair login')
|
||||
expect(mocks.generate).toHaveBeenLastCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
env: expect.objectContaining({
|
||||
CLAUDE_CONFIG_DIR: configuredDir ?? router.systemDefaultHome()
|
||||
})
|
||||
}),
|
||||
undefined
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it.each([false, true])(
|
||||
'preserves args-only CLI recipes (explicit null: %s)',
|
||||
async (explicitNull) => {
|
||||
const state = rig()
|
||||
state.settings.defaultTuiAgent = 'claude'
|
||||
state.settings.sourceControlAi = {
|
||||
...normalizeSourceControlAiSettings(state.settings.sourceControlAi),
|
||||
agentId: 'codex',
|
||||
actions: {
|
||||
conversationName: {
|
||||
...(explicitNull ? { agentId: null } : {}),
|
||||
agentArgs: '--model custom',
|
||||
commandInputTemplate: 'Title: {firstPrompt}'
|
||||
}
|
||||
}
|
||||
}
|
||||
const record = agentSessionRecordFixture()
|
||||
record.provider = 'antigravity'
|
||||
await state.deps.generate(record, 'Repair login')
|
||||
expect(mocks.generate).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
agentId: explicitNull ? 'claude' : 'codex',
|
||||
agentArgs: '--model custom'
|
||||
}),
|
||||
expect.anything(),
|
||||
undefined
|
||||
)
|
||||
expect(mocks.native).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it('resolves plain folder workspaces without requiring a git repository', async () => {
|
||||
const state = rig()
|
||||
const record = agentSessionRecordFixture()
|
||||
record.location.workspaceKind = 'folder'
|
||||
expect(await state.deps.generate(record, 'Repair login')).toEqual({ name: 'Login repair' })
|
||||
expect(state.runtime.resolveWorkspace).toHaveBeenCalledWith(record.location.workspaceId)
|
||||
expect(mocks.target).toHaveBeenCalledWith('/workspace/folder', 'codex', state.runtime)
|
||||
expect(state.runtime.resolveNamingEnvironment).toHaveBeenCalledWith('claude')
|
||||
})
|
||||
|
||||
it.each(['ssh', 'wsl'])(
|
||||
|
||||
@@ -1,3 +1,14 @@
|
||||
import {
|
||||
normalizeSourceControlAiSettings,
|
||||
resolveSourceControlActionRecipe
|
||||
} from '../../shared/source-control-ai'
|
||||
import { normalizeSourceControlActionRecipe } from '../../shared/source-control-ai-actions'
|
||||
import {
|
||||
renderConversationNamePrompt,
|
||||
sanitizeGeneratedConversationName
|
||||
} from '../../shared/conversation-name-generation'
|
||||
import { chatNamingAgentTarget } from './structured-chat-naming-agent-launch'
|
||||
import type { AgentLaunchProfileSettings } from '../../shared/tui-agent-launch-defaults'
|
||||
import { chatNamingFailure } from '../../shared/chat-naming-failure-copy'
|
||||
import { agentSessionCurrentContextRows } from '../../shared/agent-session-context-clear'
|
||||
import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../shared/execution-host'
|
||||
@@ -18,6 +29,7 @@ type StructuredChatNamingRuntime = {
|
||||
resolveWorkspace: (
|
||||
workspaceId: string
|
||||
) => Promise<{ path: string; executionHostId: ExecutionHostId }>
|
||||
resolveNamingEnvironment: (agent: string) => Promise<Record<string, string>>
|
||||
getAgentEnvResolvers: () => CommitMessageAgentEnvironmentResolvers | undefined
|
||||
workerTaskTitle: StructuredChatNamingDeps['workerTaskTitle']
|
||||
retitleOpenTab: StructuredChatNamingDeps['onNamed']
|
||||
@@ -26,6 +38,7 @@ type StructuredChatNamingRuntime = {
|
||||
export function structuredChatNamingDeps(
|
||||
getStore: () => {
|
||||
getSettings: () => Parameters<typeof resolveTextGenerationParams>[0] &
|
||||
AgentLaunchProfileSettings &
|
||||
ReturnType<StructuredChatNamingDeps['getSettings']>
|
||||
},
|
||||
runtime: StructuredChatNamingRuntime,
|
||||
@@ -58,18 +71,77 @@ export function structuredChatNamingDeps(
|
||||
if (workspace.executionHostId !== LOCAL_EXECUTION_HOST_ID) {
|
||||
return null
|
||||
}
|
||||
const current = getStore().getSettings()
|
||||
const source = normalizeSourceControlAiSettings(
|
||||
current.sourceControlAi,
|
||||
current.commitMessageAi
|
||||
)
|
||||
const recipe = resolveSourceControlActionRecipe({
|
||||
settings: current,
|
||||
actionId: 'conversationName'
|
||||
})
|
||||
const useChatAgent = !recipe.agentId && !recipe.agentArgs?.trim()
|
||||
const log = { scope: 'conversation-name', sessionId: record.sessionId }
|
||||
if (useChatAgent && (record.provider === 'grok' || record.provider === 'antigravity')) {
|
||||
const generateText = getStructuredAgentSessionHost()?.deps.adapter.generateText
|
||||
try {
|
||||
if (!generateText) {
|
||||
throw new Error('Native naming is unavailable')
|
||||
}
|
||||
if (!recipe.commandInputTemplate?.trim()) {
|
||||
throw new Error('Chat name template is empty')
|
||||
}
|
||||
const output = await generateText({
|
||||
agent: record.provider,
|
||||
accountHome: record.accountHome,
|
||||
prompt: renderConversationNamePrompt({ firstPrompt }, recipe),
|
||||
shouldExecute
|
||||
})
|
||||
if (output === null) {
|
||||
return null
|
||||
}
|
||||
const name = sanitizeGeneratedConversationName(output)
|
||||
if (name) {
|
||||
return { name }
|
||||
}
|
||||
throw new Error('The naming agent returned an empty title')
|
||||
} catch (error) {
|
||||
logger.warn('Native chat naming failed', { ...log, error })
|
||||
return chatNamingFailure(record.provider === 'grok' ? 'grokFailed' : 'antigravityFailed')
|
||||
}
|
||||
}
|
||||
const settings = resolveTextGenerationParams(
|
||||
getStore().getSettings(),
|
||||
useChatAgent
|
||||
? {
|
||||
...current,
|
||||
sourceControlAi: {
|
||||
...source,
|
||||
actions: {
|
||||
...source.actions,
|
||||
conversationName: normalizeSourceControlActionRecipe({
|
||||
...recipe,
|
||||
agentId: record.provider
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
: current,
|
||||
LOCAL_COMMIT_MESSAGE_HOST_KEY,
|
||||
'conversationName',
|
||||
null
|
||||
)
|
||||
const log = { scope: 'conversation-name', sessionId: record.sessionId }
|
||||
if (!settings.ok) {
|
||||
logger.warn(settings.error, log)
|
||||
return { error: settings.error }
|
||||
}
|
||||
const target = await resolveGenerationTarget(workspace.path, settings.params.agentId, runtime)
|
||||
const target = useChatAgent
|
||||
? await chatNamingAgentTarget(
|
||||
record,
|
||||
workspace.path,
|
||||
runtime.resolveNamingEnvironment,
|
||||
current
|
||||
)
|
||||
: await resolveGenerationTarget(workspace.path, settings.params.agentId, runtime)
|
||||
if (!target) {
|
||||
const failure = chatNamingFailure('noAgentAvailable')
|
||||
logger.warn(failure.error, log)
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { resolveTuiAgentLaunchEnv } from '../../../shared/tui-agent-launch-defaults'
|
||||
import type { GlobalSettings } from '../../../shared/global-settings-types'
|
||||
import type { StructuredAgentId } from '../../../shared/agent-session-provider-handle'
|
||||
import type { RuntimeWorkspaceCatalog } from './workspace-catalog'
|
||||
import type { OrcaRuntimeWithRefreshPtyWorktreeRecordsFromController } from '../terminal/lifecycle/pty-worktree-refresh'
|
||||
import type { RuntimeAccountController } from '../runtime-account-controller'
|
||||
@@ -8,6 +11,17 @@ import { OrcaRuntimeWithStartTuiIdleVisibleReadProbe } from '../terminal/wait/te
|
||||
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
|
||||
|
||||
export abstract class OrcaRuntimeWithWorktreePsDependencies extends OrcaRuntimeWithStartTuiIdleVisibleReadProbe {
|
||||
/** Apply the host-owning desktop's account overlay to configured agent environment. */
|
||||
protected structuredAgentLaunchEnv(
|
||||
agent: StructuredAgentId,
|
||||
settings: Pick<GlobalSettings, 'agentDefaultEnv'>
|
||||
) {
|
||||
return {
|
||||
...resolveTuiAgentLaunchEnv(agent, settings),
|
||||
...this.accounts.hostLaunchEnvFor(agent)
|
||||
}
|
||||
}
|
||||
|
||||
protected abstract listResolvedWorktreeSnapshot(
|
||||
...args: Parameters<RuntimeWorkspaceCatalog['listResolvedWorktreeSnapshot']>
|
||||
): ReturnType<RuntimeWorkspaceCatalog['listResolvedWorktreeSnapshot']>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { resolveHostStructuredAgentLaunchEnvironment } from '../structured-agent-launch-support'
|
||||
import { resolveStructuredCodexAccountKind } from '../structured-agent-account-home'
|
||||
import { getStructuredAgentSessionHost } from '../../native-chat/agent-session-wire/structured-agent-session-registry'
|
||||
import { collectRuntimeWorktreeAgentSources } from '../runtime-worktree-agent-sources'
|
||||
@@ -19,8 +20,6 @@ import { attachRuntimeWorktreeAgentRows } from '../runtime-worktree-agent-rows'
|
||||
import { attachRuntimeWorktreeAgentRowWaits } from '../runtime-worktree-agent-row-waits'
|
||||
import { compareWorktreePs } from '../runtime-worktree-status-projection'
|
||||
import type { Repo } from '../../../shared/repo-types'
|
||||
import type { StructuredAgentId } from '../../../shared/agent-session-provider-handle'
|
||||
import type { GlobalSettings } from '../../../shared/global-settings-types'
|
||||
import { enrichMissingRepoGitRemoteIdentities } from '../../repo-git-remote-identity-enrichment'
|
||||
import { ensureStructuredAgentSessionHost as installStructuredAgentSessionHost } from '../structured-agent-session-runtime'
|
||||
import {
|
||||
@@ -46,7 +45,6 @@ import {
|
||||
structuredWorkerOpenTaskTitle,
|
||||
structuredWorkerOwesWork
|
||||
} from '../structured-worker-custody'
|
||||
import { resolveTuiAgentLaunchEnv } from '../../../shared/tui-agent-launch-defaults'
|
||||
import { preModeChatGetsFullAccess } from '../../../shared/agent-chat-permission-mode'
|
||||
import { isTuiAgent } from '../../../shared/tui-agent-config'
|
||||
import { nativeChatShellEnvironmentPolicy } from '../../../shared/native-chat/native-chat-shell-environment'
|
||||
@@ -170,17 +168,6 @@ export abstract class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithWorktr
|
||||
}
|
||||
}
|
||||
|
||||
/** Apply the host-owning desktop's account overlay to configured agent environment. */
|
||||
protected structuredAgentLaunchEnv(
|
||||
agent: StructuredAgentId,
|
||||
settings: Pick<GlobalSettings, 'agentDefaultEnv'>
|
||||
) {
|
||||
return {
|
||||
...resolveTuiAgentLaunchEnv(agent, settings),
|
||||
...this.accounts.hostLaunchEnvFor(agent)
|
||||
}
|
||||
}
|
||||
|
||||
listRepos(): Repo[] {
|
||||
return this.store?.getRepos() ?? []
|
||||
}
|
||||
@@ -235,6 +222,8 @@ export abstract class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithWorktr
|
||||
return { path: target.worktree.path, executionHostId: target.executionHostId }
|
||||
},
|
||||
getAgentEnvResolvers: () => this.getCommitMessageAgentEnvironmentResolvers(),
|
||||
resolveNamingEnvironment: (agent) =>
|
||||
resolveHostStructuredAgentLaunchEnvironment(agent, this.requireStore().getSettings()),
|
||||
workerTaskTitle: (record) =>
|
||||
structuredWorkerOpenTaskTitle(this.getOrchestrationDbIfAvailable?.() ?? null, record),
|
||||
retitleOpenTab: (workspaceId, sessionId) =>
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import {
|
||||
buildConversationNamePrompt,
|
||||
clampConversationNameFirstPrompt,
|
||||
renderConversationNamePrompt,
|
||||
sanitizeGeneratedConversationName,
|
||||
type ConversationNameContext
|
||||
} from '../../shared/conversation-name-generation'
|
||||
import type { ResolvedSourceControlAiGenerationParams } from '../../shared/source-control-ai'
|
||||
import { renderSourceControlActionCommandTemplate } from '../../shared/source-control-ai-actions'
|
||||
import { captureAgentGenerationFailureOutput } from './agent-failure-output'
|
||||
import { runGenerationRequest } from './agent-generation-request'
|
||||
import type {
|
||||
@@ -22,15 +20,7 @@ export async function generateConversationName(input: {
|
||||
shouldExecute?: () => boolean
|
||||
}): Promise<GenerateConversationNameResult> {
|
||||
const { context, params } = input
|
||||
const firstPrompt = clampConversationNameFirstPrompt(context.firstPrompt)
|
||||
const basePrompt = buildConversationNamePrompt({ firstPrompt })
|
||||
const prompt =
|
||||
params.commandInputTemplate !== undefined
|
||||
? renderSourceControlActionCommandTemplate(params.commandInputTemplate, {
|
||||
basePrompt,
|
||||
firstPrompt
|
||||
})
|
||||
: buildConversationNamePrompt({ firstPrompt }, params.customPrompt ?? '')
|
||||
const prompt = renderConversationNamePrompt(context, params)
|
||||
|
||||
const result = await runGenerationRequest({
|
||||
...input,
|
||||
|
||||
@@ -67,7 +67,23 @@ export async function installWslChatFixture(lane: WslChatHost) {
|
||||
await writeChatFixtureFile(
|
||||
lane,
|
||||
cli,
|
||||
`#!/bin/sh\nexport ORCA_LANE_CLI_WRAPPER="$0"\nexec ${quotePosixShell(runtime)} ${quotePosixShell(script)} "$@"\n`
|
||||
`#!/bin/sh
|
||||
export ORCA_LANE_CLI_WRAPPER="$0"
|
||||
${
|
||||
agent === 'claude'
|
||||
? `export ORCA_LANE_CLAUDE_WRAPPER="$0"
|
||||
previous=
|
||||
for argument in "$@"; do
|
||||
if [ "$previous" = --output-format ] && [ "$argument" = text ]; then
|
||||
cat > /dev/null
|
||||
printf '%s\\n' 'WSL fixture title'
|
||||
exit 0
|
||||
fi
|
||||
previous=$argument
|
||||
done
|
||||
`
|
||||
: ''
|
||||
}exec ${quotePosixShell(runtime)} ${quotePosixShell(script)} "$@"\n`
|
||||
)
|
||||
await lane.guest(`chmod 700 ${quotePosixShell(cli)}`)
|
||||
return { script, cli }
|
||||
|
||||
@@ -8,6 +8,7 @@ import { translate } from '@/i18n/i18n'
|
||||
|
||||
type AppearanceAdvancedDisclosureProps = {
|
||||
label?: string
|
||||
forceOpen?: boolean
|
||||
showTopBorder?: boolean
|
||||
className?: string
|
||||
contentClassName?: string
|
||||
@@ -17,6 +18,7 @@ type AppearanceAdvancedDisclosureProps = {
|
||||
/** Search force-opens advanced controls so matches remain reachable. */
|
||||
export function AppearanceAdvancedDisclosure({
|
||||
label,
|
||||
forceOpen = false,
|
||||
showTopBorder = true,
|
||||
className,
|
||||
contentClassName,
|
||||
@@ -25,7 +27,8 @@ export function AppearanceAdvancedDisclosure({
|
||||
const searchQuery = useAppStore((state) => state.settingsSearchQuery)
|
||||
const isSearching = normalizeSettingsSearchQuery(searchQuery).length > 0
|
||||
const [open, setOpen] = useState(false)
|
||||
const expanded = open || isSearching
|
||||
const forced = isSearching || forceOpen
|
||||
const expanded = open || forced
|
||||
|
||||
return (
|
||||
<div className={cn('mt-3 pt-2', showTopBorder && 'border-t border-border/50', className)}>
|
||||
@@ -33,8 +36,8 @@ export function AppearanceAdvancedDisclosure({
|
||||
type="button"
|
||||
aria-expanded={expanded}
|
||||
onClick={() => setOpen((prev) => !prev)}
|
||||
// Search forces expansion; disable collapse instead of fighting the search state.
|
||||
disabled={isSearching}
|
||||
// Search matches and unsaved edits must remain reachable.
|
||||
disabled={forced}
|
||||
className="flex w-full items-center gap-2 py-1 text-sm font-semibold text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 disabled:cursor-default"
|
||||
>
|
||||
<ChevronRight
|
||||
|
||||
@@ -6,6 +6,10 @@ import type { GlobalSettings } from '../../../../shared/global-settings-types'
|
||||
import type { SourceControlAiSettingsPatch } from '../../../../shared/source-control-ai-types'
|
||||
import { normalizeSourceControlAiSettings } from '../../../../shared/source-control-ai'
|
||||
import { ChatNamingSetting } from './ChatNamingSetting'
|
||||
import {
|
||||
savedChatNamingCatalog,
|
||||
observeChatNamingCatalogWork
|
||||
} from '../../../../shared/chat-naming-catalog-work.test-fixture'
|
||||
import { toast } from 'sonner'
|
||||
import { startTransition, StrictMode, Suspense } from 'react'
|
||||
|
||||
@@ -24,7 +28,7 @@ beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
function renderSetting(overrides: Partial<GlobalSettings> = {}) {
|
||||
function renderSetting(overrides: Partial<GlobalSettings> = {}, expand = true) {
|
||||
const settings = { ...getDefaultSettings('/tmp'), ...overrides }
|
||||
const updateSettings = vi.fn()
|
||||
const onDirtyChange = vi.fn()
|
||||
@@ -45,6 +49,9 @@ function renderSetting(overrides: Partial<GlobalSettings> = {}) {
|
||||
/>
|
||||
)
|
||||
const view = render(element())
|
||||
if (expand && screen.queryByRole('button', { name: 'Advanced' })) {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Advanced' }))
|
||||
}
|
||||
return { ...view, element, settings, updateSettings, writeSourceControlAiSettings, onDirtyChange }
|
||||
}
|
||||
|
||||
@@ -65,6 +72,105 @@ function changeAgentArgs(value: string): void {
|
||||
}
|
||||
|
||||
describe('Chat names setting', () => {
|
||||
it.each([100, 1000])(
|
||||
'bounds actual edit, clear, collapsed and off catalog work for %i rows',
|
||||
(size) => {
|
||||
const source = savedChatNamingCatalog(size)
|
||||
const { work, reset } = observeChatNamingCatalogWork()
|
||||
const samples: Record<string, typeof work> = {}
|
||||
const check = (phase: string, passes: number, reads: number) => {
|
||||
expect(work.copiedRows).toBeLessThanOrEqual(passes * size)
|
||||
expect(work.copiedRows).toBeGreaterThanOrEqual(size)
|
||||
expect(work.copiedBytes).toBeLessThanOrEqual(passes * 64 * size)
|
||||
expect(work.copiedBytes).toBeGreaterThanOrEqual(32 * size)
|
||||
expect(work.selectedRowReads).toBeLessThanOrEqual(reads)
|
||||
if (reads) {
|
||||
expect(work.selectedRowReads).toBeGreaterThanOrEqual(size)
|
||||
}
|
||||
samples[phase] = { ...work }
|
||||
reset()
|
||||
}
|
||||
const view = renderSetting({ defaultTuiAgent: 'claude', sourceControlAi: source })
|
||||
reset()
|
||||
changeAgentArgs('--model fast')
|
||||
check('edit', 2, size + 1)
|
||||
expect(screen.getByRole('combobox').textContent).toContain(
|
||||
'Use configured CLI agent (Claude)'
|
||||
)
|
||||
changeAgentArgs('')
|
||||
check('clear', 1, 0)
|
||||
expect(screen.getByRole('combobox').textContent).toContain('Use chat agent')
|
||||
view.unmount()
|
||||
source.actions = { conversationName: { agentId: null, agentArgs: '--model fast' } }
|
||||
for (const enabled of [true, false]) {
|
||||
reset()
|
||||
const hidden = renderSetting(
|
||||
{ defaultTuiAgent: 'claude', nativeChatAutoName: enabled, sourceControlAi: source },
|
||||
false
|
||||
)
|
||||
expect(screen.queryByRole('combobox')).toBeNull()
|
||||
if (enabled) {
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Advanced' }).getAttribute('aria-expanded')
|
||||
).toBe('false')
|
||||
}
|
||||
expect(
|
||||
screen
|
||||
.getByRole('switch', { name: 'Name chats automatically' })
|
||||
.getAttribute('aria-checked')
|
||||
).toBe(String(enabled))
|
||||
check(enabled ? 'collapsed' : 'off', 2, size + 1)
|
||||
hidden.unmount()
|
||||
}
|
||||
console.log(JSON.stringify({ metric: 'chat-naming-settings-catalog', size, samples }))
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps alternate-agent setup in Advanced and opens it for search', () => {
|
||||
const { element, rerender } = renderSetting({}, false)
|
||||
expect(screen.queryByRole('combobox')).toBeNull()
|
||||
expect(screen.getByRole('button', { name: 'Advanced' }).getAttribute('aria-expanded')).toBe(
|
||||
'false'
|
||||
)
|
||||
state.settingsSearchQuery = 'naming agent'
|
||||
rerender(element())
|
||||
expect(screen.getByRole('combobox').textContent).toContain('Use chat agent')
|
||||
expect(screen.getByRole('button', { name: 'Advanced' }).hasAttribute('disabled')).toBe(true)
|
||||
})
|
||||
|
||||
it('defaults to on and writes the off preference', () => {
|
||||
const { updateSettings } = renderSetting()
|
||||
const toggle = screen.getByRole('switch', { name: 'Name chats automatically' })
|
||||
expect(toggle.getAttribute('aria-checked')).toBe('true')
|
||||
fireEvent.click(toggle)
|
||||
expect(updateSettings).toHaveBeenCalledWith({ nativeChatAutoName: false })
|
||||
})
|
||||
|
||||
it('labels the prospective default with retained arguments and returns to chat agent when cleared', async () => {
|
||||
const { settings, element, rerender } = renderSetting({
|
||||
defaultTuiAgent: 'claude',
|
||||
sourceControlAi: normalizeSourceControlAiSettings({
|
||||
...normalizeSourceControlAiSettings(undefined),
|
||||
agentId: 'pi',
|
||||
actions: { conversationName: { agentId: 'codex', agentArgs: '--model haiku' } }
|
||||
})
|
||||
})
|
||||
expect(screen.getByRole('combobox').textContent).toContain('Codex')
|
||||
fireEvent.click(screen.getByRole('combobox'))
|
||||
fireEvent.click(screen.getByRole('option', { name: 'Use configured CLI agent (Claude)' }))
|
||||
await waitFor(() =>
|
||||
expect(settings.sourceControlAi?.actions?.conversationName).toMatchObject({
|
||||
agentId: null,
|
||||
agentArgs: '--model haiku'
|
||||
})
|
||||
)
|
||||
rerender(element())
|
||||
expect(screen.getByRole('combobox').textContent).toContain('Use configured CLI agent (Claude)')
|
||||
expect(screen.getByText('Clear CLI arguments to use each chat’s agent.')).toBeTruthy()
|
||||
changeAgentArgs('')
|
||||
expect(screen.getByRole('combobox').textContent).toContain('Use chat agent')
|
||||
})
|
||||
|
||||
it('saves CLI arguments and a template through the shared writer', async () => {
|
||||
const { settings, writeSourceControlAiSettings, onDirtyChange } = renderSetting({
|
||||
sourceControlAi: normalizeSourceControlAiSettings({
|
||||
@@ -125,6 +231,7 @@ describe('Chat names setting', () => {
|
||||
rerender(element())
|
||||
expect(screen.getByDisplayValue('Keep my edits')).toBeTruthy()
|
||||
expect(onDirtyChange).toHaveBeenLastCalledWith(true)
|
||||
expect(screen.getByRole('button', { name: 'Advanced' }).hasAttribute('disabled')).toBe(true)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Discard' }))
|
||||
expect(screen.queryByText('Command template')).toBeNull()
|
||||
expect(onDirtyChange).toHaveBeenLastCalledWith(false)
|
||||
@@ -189,6 +296,7 @@ describe('Chat names setting', () => {
|
||||
unmount()
|
||||
expect(onDirtyChange).toHaveBeenLastCalledWith(false)
|
||||
render(<StrictMode>{element()}</StrictMode>)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Advanced' }))
|
||||
changeTemplate('New draft')
|
||||
expect(onDirtyChange).toHaveBeenLastCalledWith(true)
|
||||
onDirtyChange.mockClear()
|
||||
@@ -258,6 +366,7 @@ describe('Chat names setting', () => {
|
||||
</Suspense>
|
||||
)
|
||||
const { rerender, unmount } = render(element(false))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Advanced' }))
|
||||
changeTemplate('Committed save')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
await act(async () => {
|
||||
|
||||
@@ -2,13 +2,17 @@ import { useCallback, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import type { GlobalSettings } from '../../../../shared/global-settings-types'
|
||||
import type { SourceControlAiSettingsPatch } from '../../../../shared/source-control-ai-types'
|
||||
import { normalizeSourceControlAiSettings } from '../../../../shared/source-control-ai'
|
||||
import {
|
||||
normalizeSourceControlAiSettings,
|
||||
resolveSourceControlAiForOperation
|
||||
} from '../../../../shared/source-control-ai'
|
||||
import {
|
||||
DEFAULT_SOURCE_CONTROL_ACTION_COMMAND_TEMPLATES,
|
||||
setSourceControlActionDefault
|
||||
} from '../../../../shared/source-control-ai-actions'
|
||||
import { CUSTOM_AGENT_ID } from '../../../../shared/commit-message-agent-spec'
|
||||
import { isTuiAgent } from '../../../../shared/tui-agent-config'
|
||||
import { getAgentLabel } from '@/lib/agent-catalog'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { Switch } from '../ui/switch'
|
||||
import { Card, CardContent } from '../ui/card'
|
||||
@@ -18,6 +22,7 @@ import { SettingsSubsectionHeader } from './SettingsFormControls'
|
||||
import { SearchableSetting } from './SearchableSetting'
|
||||
import { getChatNamingSearchEntry } from './chat-naming-search'
|
||||
import type { ActionRecipeDraftValue } from './source-control-ai-action-recipe-draft'
|
||||
import { AppearanceAdvancedDisclosure } from './AppearanceAdvancedDisclosure'
|
||||
|
||||
type ChatNamingSettingProps = {
|
||||
settings: GlobalSettings
|
||||
@@ -127,6 +132,39 @@ export function ChatNamingSetting({
|
||||
}
|
||||
}
|
||||
|
||||
const cliRecipe = value.agentArgs?.trim()
|
||||
? resolveSourceControlAiForOperation({
|
||||
settings: {
|
||||
...settings,
|
||||
sourceControlAi: {
|
||||
...config,
|
||||
actions: {
|
||||
...config.actions,
|
||||
conversationName: {
|
||||
...recipe,
|
||||
...value,
|
||||
...(recipe?.agentId ? { agentId: null } : {})
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
operation: 'conversationName'
|
||||
})
|
||||
: null
|
||||
const cliAgent = cliRecipe?.ok ? cliRecipe.value.params.agentId : null
|
||||
const defaultAgentLabel = cliAgent
|
||||
? translate('settings.chat.names.configuredCliAgent', 'Use configured CLI agent ({{agent}})', {
|
||||
agent:
|
||||
cliAgent === 'custom'
|
||||
? translate(
|
||||
'auto.components.settings.SourceControlAiActionRecipeDefaults.0740d30915',
|
||||
'Custom command'
|
||||
)
|
||||
: getAgentLabel(cliAgent)
|
||||
})
|
||||
: cliRecipe
|
||||
? translate('settings.chat.names.configuredCliAgentUnknown', 'Use configured CLI agent')
|
||||
: translate('settings.chat.names.useChatAgent', 'Use chat agent')
|
||||
const searchEntry = getChatNamingSearchEntry()
|
||||
return (
|
||||
<SearchableSetting
|
||||
@@ -150,8 +188,13 @@ export function ChatNamingSetting({
|
||||
/>
|
||||
</div>
|
||||
{settings.nativeChatAutoName !== false || dirty ? (
|
||||
<>
|
||||
{recipe?.agentId === CUSTOM_AGENT_ID || customCommandDirty ? (
|
||||
<AppearanceAdvancedDisclosure
|
||||
forceOpen={dirty || isSaving}
|
||||
contentClassName="space-y-3 pt-3"
|
||||
>
|
||||
{recipe?.agentId === CUSTOM_AGENT_ID ||
|
||||
cliAgent === CUSTOM_AGENT_ID ||
|
||||
customCommandDirty ? (
|
||||
<CustomAgentCommandField
|
||||
id="chat-name-custom-command"
|
||||
value={customCommand}
|
||||
@@ -165,10 +208,32 @@ export function ChatNamingSetting({
|
||||
<SourceControlActionRecipeRow
|
||||
actionId="conversationName"
|
||||
selectedAgent={recipe?.agentId ?? null}
|
||||
defaultAgentLabel={defaultAgentLabel}
|
||||
defaultAgentSupportText={
|
||||
cliRecipe && !cliRecipe.ok
|
||||
? cliRecipe.error
|
||||
: cliRecipe
|
||||
? translate(
|
||||
'settings.chat.names.clearCliArguments',
|
||||
'Clear CLI arguments to use each chat’s agent.'
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
defaultAgentArgsPlaceholder={
|
||||
cliAgent
|
||||
? undefined
|
||||
: translate(
|
||||
'settings.chat.names.cliArgumentsPlaceholder',
|
||||
'Optional arguments for a configured CLI naming agent'
|
||||
)
|
||||
}
|
||||
|
||||
draftValue={value}
|
||||
baseValue={persisted}
|
||||
hasUnsavedChanges={dirty}
|
||||
defaultTuiAgent={settings.defaultTuiAgent}
|
||||
defaultTuiAgent={
|
||||
cliAgent && cliAgent !== 'custom' ? cliAgent : settings.defaultTuiAgent
|
||||
}
|
||||
isSavingTemplate={isSaving}
|
||||
onAgentChange={(_id, selected) => void saveAgent(selected)}
|
||||
onTemplateChange={(_id, template) =>
|
||||
@@ -190,7 +255,7 @@ export function ChatNamingSetting({
|
||||
}}
|
||||
onSave={() => void save()}
|
||||
/>
|
||||
</>
|
||||
</AppearanceAdvancedDisclosure>
|
||||
) : null}
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -123,6 +123,15 @@ describe('Chat settings page', () => {
|
||||
|
||||
it('retains the unsaved naming draft when search no longer matches Chat', () => {
|
||||
const { element, rerender, container } = renderChat(true, true, true)
|
||||
const naming = container.querySelector('#chat-names')
|
||||
if (!naming) {
|
||||
throw new Error('Chat naming section is missing')
|
||||
}
|
||||
const advanced = naming.querySelector('button[aria-expanded]')
|
||||
if (!advanced) {
|
||||
throw new Error('Advanced control is missing')
|
||||
}
|
||||
fireEvent.click(advanced)
|
||||
const template = screen.getByText('Command template').nextElementSibling
|
||||
if (!(template instanceof HTMLTextAreaElement)) {
|
||||
throw new Error('Command template textarea is missing')
|
||||
|
||||
@@ -46,6 +46,9 @@ type SourceControlActionRecipeRowProps<ActionId extends AiActionId> = {
|
||||
defaultTuiAgent: GlobalSettings['defaultTuiAgent']
|
||||
isSavingTemplate: boolean
|
||||
hasUnsavedChanges?: boolean
|
||||
defaultAgentLabel?: string
|
||||
defaultAgentSupportText?: string
|
||||
defaultAgentArgsPlaceholder?: string
|
||||
repoOverrideNote?: React.ReactNode
|
||||
onAgentChange: (
|
||||
actionId: ActionId,
|
||||
@@ -92,6 +95,9 @@ export function SourceControlActionRecipeRow<ActionId extends AiActionId>({
|
||||
defaultTuiAgent,
|
||||
isSavingTemplate,
|
||||
hasUnsavedChanges,
|
||||
defaultAgentLabel,
|
||||
defaultAgentSupportText,
|
||||
defaultAgentArgsPlaceholder,
|
||||
repoOverrideNote,
|
||||
onAgentChange,
|
||||
surface,
|
||||
@@ -117,12 +123,15 @@ export function SourceControlActionRecipeRow<ActionId extends AiActionId>({
|
||||
const isLaunchAction = !AI_TEXT_ACTION_ID_SET.has(actionId)
|
||||
const templateDirty =
|
||||
hasUnsavedChanges ?? JSON.stringify(draftValue) !== JSON.stringify(baseValue)
|
||||
const agentArgsPlaceholder = getSourceControlAgentArgsPlaceholder(
|
||||
resolveAgentArgsPlaceholderAgent(selectedAgent, defaultTuiAgent)
|
||||
)
|
||||
const agentArgsPlaceholder =
|
||||
(!selectedAgent && defaultAgentArgsPlaceholder) ||
|
||||
getSourceControlAgentArgsPlaceholder(
|
||||
resolveAgentArgsPlaceholderAgent(selectedAgent, defaultTuiAgent)
|
||||
)
|
||||
const agentOptions = getAgentCatalogForAction(actionId, selectedAgent)
|
||||
const agentWarningText = getSourceControlActionAgentWarningText(actionId, selectedAgent)
|
||||
const agentSupportText = getSourceControlActionAgentSupportText(actionId)
|
||||
const agentSupportText =
|
||||
(!selectedAgent && defaultAgentSupportText) || getSourceControlActionAgentSupportText(actionId)
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-border px-3 py-3">
|
||||
@@ -152,10 +161,11 @@ export function SourceControlActionRecipeRow<ActionId extends AiActionId>({
|
||||
<SelectItem value={DEFAULT_AGENT_VALUE}>
|
||||
<span className="flex items-center gap-2">
|
||||
<Terminal className="size-3.5 text-muted-foreground" />
|
||||
{translate(
|
||||
'auto.components.settings.SourceControlAiActionRecipeDefaults.ee0e5c2a48',
|
||||
'Use default agent'
|
||||
)}
|
||||
{defaultAgentLabel ??
|
||||
translate(
|
||||
'auto.components.settings.SourceControlAiActionRecipeDefaults.ee0e5c2a48',
|
||||
'Use default agent'
|
||||
)}
|
||||
</span>
|
||||
</SelectItem>
|
||||
{AI_TEXT_ACTION_ID_SET.has(actionId) ? (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { act, fireEvent, render, renderHook, screen, waitFor } from '@testing-library/react'
|
||||
import { act, fireEvent, render, renderHook, screen, waitFor, within } from '@testing-library/react'
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { toast } from 'sonner'
|
||||
import { setSourceControlActionDefault } from '../../../../shared/source-control-ai-actions'
|
||||
@@ -58,6 +58,11 @@ it('keeps the Chat draft and guard after actual persistence fails, then saves it
|
||||
const savedRecipe = model.settings?.sourceControlAi?.actions?.conversationName
|
||||
persist.mockRejectedValueOnce(new Error('Settings disk is unavailable'))
|
||||
render(<ChatPersistenceHarness model={model} />)
|
||||
const naming = document.getElementById('chat-names')
|
||||
if (!naming) {
|
||||
throw new Error('Chat naming section is missing')
|
||||
}
|
||||
fireEvent.click(within(naming).getByRole('button', { name: 'Advanced' }))
|
||||
const template = screen.getByText('Command template').nextElementSibling
|
||||
const args = screen.getByText('Arguments').nextElementSibling
|
||||
if (!(template instanceof HTMLTextAreaElement) || !(args instanceof HTMLInputElement)) {
|
||||
@@ -136,6 +141,11 @@ it('retains the legacy Git failure contract and composes later queued naming wri
|
||||
it('configures the offered Chat custom command without enabling Git or writing on each keystroke', async () => {
|
||||
const model = settingsModel()
|
||||
render(<ChatPersistenceHarness model={model} />)
|
||||
const naming = document.getElementById('chat-names')
|
||||
if (!naming) {
|
||||
throw new Error('Chat naming section is missing')
|
||||
}
|
||||
fireEvent.click(within(naming).getByRole('button', { name: 'Advanced' }))
|
||||
fireEvent.click(screen.getByRole('combobox'))
|
||||
fireEvent.click(screen.getByRole('option', { name: 'Custom command' }))
|
||||
const command = await screen.findByRole('textbox', { name: 'Custom command' })
|
||||
@@ -203,6 +213,11 @@ it.each(['original-command', 'later-command'])(
|
||||
})
|
||||
)
|
||||
render(<ChatPersistenceHarness model={model} />)
|
||||
const naming = document.getElementById('chat-names')
|
||||
if (!naming) {
|
||||
throw new Error('Chat naming section is missing')
|
||||
}
|
||||
fireEvent.click(within(naming).getByRole('button', { name: 'Advanced' }))
|
||||
const command = screen.getByRole('textbox', { name: 'Custom command' })
|
||||
fireEvent.change(command, { target: { value: 'submitted-command' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
@@ -226,6 +241,11 @@ it.each(['original-command', 'later-command'])(
|
||||
it('preserves a shared command changed elsewhere while a Chat recipe is being edited', async () => {
|
||||
const model = settingsModel()
|
||||
render(<ChatPersistenceHarness model={model} />)
|
||||
const naming = document.getElementById('chat-names')
|
||||
if (!naming) {
|
||||
throw new Error('Chat naming section is missing')
|
||||
}
|
||||
fireEvent.click(within(naming).getByRole('button', { name: 'Advanced' }))
|
||||
const template = screen.getByText('Command template').nextElementSibling
|
||||
if (!(template instanceof HTMLTextAreaElement)) {
|
||||
throw new Error('Chat recipe controls are missing')
|
||||
|
||||
@@ -7,7 +7,7 @@ export const getChatNamingSearchEntry = createLocalizedCatalog((): SettingsSearc
|
||||
title: translate('settings.chat.names.title', 'Chat names'),
|
||||
description: translate(
|
||||
'settings.chat.names.description',
|
||||
'Use an agent to name new chats from their first message. Until a name is generated, a chat shows its first message.'
|
||||
'Use each chat’s agent to name it from its first message. Choose another naming agent in Advanced. Until a name is generated, a chat shows its first message.'
|
||||
),
|
||||
keywords: [
|
||||
translate('settings.chat.names.enable', 'Name chats automatically'),
|
||||
|
||||
@@ -52,6 +52,11 @@ it('consumes an actual Git toggle failure and lets the following Chat write succ
|
||||
const model = settingsModel()
|
||||
persist.mockRejectedValueOnce(new Error('Settings disk is unavailable'))
|
||||
render(<GitPersistenceHarness model={model} />)
|
||||
const naming = document.getElementById('chat-names')
|
||||
if (!naming) {
|
||||
throw new Error('Chat naming section is missing')
|
||||
}
|
||||
fireEvent.click(within(naming).getByRole('button', { name: 'Advanced' }))
|
||||
const toggle = screen.getByRole('switch', { name: 'Show Source Control AI actions' })
|
||||
fireEvent.click(toggle)
|
||||
await waitFor(() =>
|
||||
@@ -90,6 +95,11 @@ it('does not let a failed actual Git agent selection roll back a queued Chat sel
|
||||
})
|
||||
)
|
||||
render(<GitPersistenceHarness model={settingsModel()} />)
|
||||
const naming = document.getElementById('chat-names')
|
||||
if (!naming) {
|
||||
throw new Error('Chat naming section is missing')
|
||||
}
|
||||
fireEvent.click(within(naming).getByRole('button', { name: 'Advanced' }))
|
||||
const agents = screen.getAllByRole('combobox')
|
||||
const gitAgent = agents[0]
|
||||
const chatAgent = agents.at(-1)
|
||||
|
||||
@@ -20,6 +20,15 @@ describe('nativeChatNamingErrorText', () => {
|
||||
messageCode: 'noName'
|
||||
})
|
||||
).toBe(es.tabHoverCard.namingFailure.errors.noName)
|
||||
for (const messageCode of ['grokFailed', 'antigravityFailed'] as const) {
|
||||
expect(
|
||||
nativeChatNamingErrorText({
|
||||
message: 'private provider diagnostic',
|
||||
failedAt: 1,
|
||||
messageCode
|
||||
})
|
||||
).toBe(es.tabHoverCard.namingFailure.errors[messageCode])
|
||||
}
|
||||
expect(nativeChatNamingErrorText({ message: 'claude exited 1', failedAt: 1 })).toBe(
|
||||
'claude exited 1'
|
||||
)
|
||||
|
||||
@@ -9,7 +9,10 @@ const COPY: Record<ChatNamingFailureCode, () => string> = {
|
||||
noName: () => translate('tabHoverCard.namingFailure.errors.noName', EN.noName),
|
||||
agentNotRun: () => translate('tabHoverCard.namingFailure.errors.agentNotRun', EN.agentNotRun),
|
||||
noAgentAvailable: () =>
|
||||
translate('tabHoverCard.namingFailure.errors.noAgentAvailable', EN.noAgentAvailable)
|
||||
translate('tabHoverCard.namingFailure.errors.noAgentAvailable', EN.noAgentAvailable),
|
||||
grokFailed: () => translate('tabHoverCard.namingFailure.errors.grokFailed', EN.grokFailed),
|
||||
antigravityFailed: () =>
|
||||
translate('tabHoverCard.namingFailure.errors.antigravityFailed', EN.antigravityFailed)
|
||||
}
|
||||
|
||||
function isChatNamingFailureCode(code: unknown): code is ChatNamingFailureCode {
|
||||
|
||||
+3
-1
@@ -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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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 はこのチャットに名前を付けられませんでした。もう一度名前を生成してください。"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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가 이 채팅의 이름을 지정하지 못했습니다. 이름 생성을 다시 시도하세요."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -21761,7 +21761,9 @@
|
||||
"errors": {
|
||||
"noName": "Агент названия чата не вернул название.",
|
||||
"agentNotRun": "Orca не удалось запустить агента названия чата.",
|
||||
"noAgentAvailable": "В этом рабочем пространстве нельзя запустить ни одного агента названия чата."
|
||||
"noAgentAvailable": "В этом рабочем пространстве нельзя запустить ни одного агента названия чата.",
|
||||
"grokFailed": "Grok не удалось назвать этот чат. Попробуйте сгенерировать название ещё раз.",
|
||||
"antigravityFailed": "Antigravity не удалось назвать этот чат. Попробуйте сгенерировать название ещё раз."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -20811,7 +20811,9 @@
|
||||
"errors": {
|
||||
"noName": "對話命名代理程式未傳回名稱。",
|
||||
"agentNotRun": "Orca 無法執行對話命名代理程式。",
|
||||
"noAgentAvailable": "此工作區中沒有可執行的對話命名代理程式。"
|
||||
"noAgentAvailable": "此工作區中沒有可執行的對話命名代理程式。",
|
||||
"grokFailed": "Grok 無法為此聊天命名。請嘗試重新產生名稱。",
|
||||
"antigravityFailed": "Antigravity 無法為此聊天命名。請嘗試重新產生名稱。"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -36,9 +36,14 @@
|
||||
"description": "选择聊天的外观和行为方式。",
|
||||
"names": {
|
||||
"title": "聊天名称",
|
||||
"description": "使用智能体根据首条消息为新聊天生成名称。生成名称前,聊天显示其首条消息。",
|
||||
"description": "使用各聊天的智能体根据首条消息命名。可在高级设置中选择其他命名智能体。名称生成前,聊天会显示首条消息。",
|
||||
"enable": "自动为聊天命名",
|
||||
"recipe": "聊天名称",
|
||||
"useChatAgent": "使用聊天智能体",
|
||||
"configuredCliAgent": "使用已配置的CLI智能体({{agent}})",
|
||||
"configuredCliAgentUnknown": "使用已配置的 CLI 代理",
|
||||
"clearCliArguments": "清除CLI参数以使用各聊天的智能体。",
|
||||
"cliArgumentsPlaceholder": "已配置的CLI命名智能体的可选参数",
|
||||
"recipeDescription": "根据新聊天的首条消息生成简短名称。",
|
||||
"customCommandDescription": "选择自定义命令的配置共用此命令行。使用 {prompt} 将输入作为参数传递,否则通过标准输入传递。",
|
||||
"saveFailed": "无法保存聊天命名设置。",
|
||||
@@ -21108,7 +21113,9 @@
|
||||
"errors": {
|
||||
"noName": "聊天命名智能体未返回名称。",
|
||||
"agentNotRun": "Orca 无法运行聊天命名智能体。",
|
||||
"noAgentAvailable": "此工作区中没有可运行的聊天命名智能体。"
|
||||
"noAgentAvailable": "此工作区中没有可运行的聊天命名智能体。",
|
||||
"grokFailed": "Grok 无法为此聊天命名。请尝试重新生成名称。",
|
||||
"antigravityFailed": "Antigravity 无法为此聊天命名。请尝试重新生成名称。"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { vi } from 'vitest'
|
||||
import { normalizeSourceControlAiSettings } from './source-control-ai-settings'
|
||||
|
||||
export function savedChatNamingCatalog(size: number) {
|
||||
const source = normalizeSourceControlAiSettings(undefined)
|
||||
source.selectedModelByAgent.claude = `catalog-${String(size - 1).padStart(4, '0')}`
|
||||
source.discoveredModelsByAgent = {
|
||||
claude: Array.from({ length: size }, (_, index) => ({
|
||||
id: `catalog-${String(index).padStart(4, '0')}`,
|
||||
label: `Catalog ${String(index).padStart(4, '0')}`
|
||||
}))
|
||||
}
|
||||
return source
|
||||
}
|
||||
|
||||
export function observeChatNamingCatalogWork() {
|
||||
const clone = globalThis.structuredClone
|
||||
const work = { copiedRows: 0, copiedBytes: 0, selectedRowReads: 0 }
|
||||
let cloning = false
|
||||
const catalog = (value: unknown): unknown[] =>
|
||||
value !== null && typeof value === 'object' && 'claude' in value && Array.isArray(value.claude)
|
||||
? value.claude
|
||||
: []
|
||||
vi.spyOn(globalThis, 'structuredClone').mockImplementation(
|
||||
<T>(value: T, options?: Parameters<typeof globalThis.structuredClone>[1]): T => {
|
||||
cloning = true
|
||||
let result: T
|
||||
try {
|
||||
const rows = catalog(value)
|
||||
work.copiedRows += rows.length
|
||||
if (rows.length) {
|
||||
work.copiedBytes += new TextEncoder().encode(JSON.stringify(rows)).length
|
||||
}
|
||||
result = clone(value, options)
|
||||
} finally {
|
||||
cloning = false
|
||||
}
|
||||
for (const row of catalog(result)) {
|
||||
if (
|
||||
row === null ||
|
||||
typeof row !== 'object' ||
|
||||
!('id' in row) ||
|
||||
typeof row.id !== 'string'
|
||||
) {
|
||||
throw new Error('Expected a discovered model row')
|
||||
}
|
||||
const id = row.id
|
||||
Object.defineProperty(row, 'id', {
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
get: () => {
|
||||
if (!cloning) {
|
||||
work.selectedRowReads++
|
||||
}
|
||||
return id
|
||||
}
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
)
|
||||
return {
|
||||
work,
|
||||
reset: () => Object.assign(work, { copiedRows: 0, copiedBytes: 0, selectedRowReads: 0 })
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,9 @@
|
||||
export const CHAT_NAMING_FAILURE_COPY = {
|
||||
noName: 'The chat-name agent returned no name.',
|
||||
agentNotRun: 'Orca could not run the chat-name agent.',
|
||||
noAgentAvailable: 'No chat-name agent can run in this workspace.'
|
||||
noAgentAvailable: 'No chat-name agent can run in this workspace.',
|
||||
grokFailed: 'Grok could not name this chat. Try generating the name again.',
|
||||
antigravityFailed: 'Antigravity could not name this chat. Try generating the name again.'
|
||||
} as const
|
||||
|
||||
export type ChatNamingFailureCode = keyof typeof CHAT_NAMING_FAILURE_COPY
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { normalizeAgentSessionConversationName } from './agent-session/agent-session-conversation-name'
|
||||
import { cleanGeneratedCommitMessage } from './commit-message-agent-output'
|
||||
import { sliceAtCodeUnitLimit } from './surrogate-safe-text-slice'
|
||||
import { renderSourceControlActionCommandTemplate } from './source-control-ai-actions'
|
||||
|
||||
export type ConversationNameContext = { firstPrompt: string }
|
||||
|
||||
@@ -29,6 +30,19 @@ ${firstPrompt}`
|
||||
return extra ? `${basePrompt}\n\nAdditional instructions:\n${extra}` : basePrompt
|
||||
}
|
||||
|
||||
export function renderConversationNamePrompt(
|
||||
context: ConversationNameContext,
|
||||
recipe: { commandInputTemplate?: string; customPrompt?: string }
|
||||
): string {
|
||||
const firstPrompt = clampConversationNameFirstPrompt(context.firstPrompt)
|
||||
return recipe.commandInputTemplate !== undefined
|
||||
? renderSourceControlActionCommandTemplate(recipe.commandInputTemplate, {
|
||||
basePrompt: buildConversationNamePrompt({ firstPrompt }),
|
||||
firstPrompt
|
||||
})
|
||||
: buildConversationNamePrompt({ firstPrompt }, recipe.customPrompt ?? '')
|
||||
}
|
||||
|
||||
export function sanitizeGeneratedConversationName(raw: string): string | null {
|
||||
const cleaned = cleanGeneratedCommitMessage(raw)
|
||||
if (cleaned.startsWith('<think>') || cleaned.startsWith('◁think▷')) {
|
||||
|
||||
Reference in New Issue
Block a user