diff --git a/src/main/agent-hooks/launch-file-prompt-by-pane.ts b/src/main/agent-hooks/launch-file-prompt-by-pane.ts index c43409aff1b..2ccfd82ce8b 100644 --- a/src/main/agent-hooks/launch-file-prompt-by-pane.ts +++ b/src/main/agent-hooks/launch-file-prompt-by-pane.ts @@ -1,4 +1,4 @@ -import { isLaunchFilePointer, type LaunchFile } from '../../shared/launch-prompt-file' +import { opensLikeLaunchFilePointer, type LaunchFile } from '../../shared/launch-prompt-file' // Why bounded: an entry outlives its launch until newer ones push it out; the rename reads only a // prompt's opening, and a pane's rename runs within minutes of its launch. @@ -27,8 +27,8 @@ export function rememberLaunchFilePrompt( } } -/** The one place Orca reads a pane's reported prompt: a launch file's pointer reads as the prompt - * it points at, when this host kept it. */ +/** The one place Orca reads a pane's reported prompt or session title: a launch file's pointer, + * whole or cut short, reads as the prompt it points at, when this host kept it. */ export function launchPromptShownForPane(paneKey: string, prompt: string): string { - return isLaunchFilePointer(prompt) ? (promptByPane.get(paneKey) ?? prompt) : prompt + return opensLikeLaunchFilePointer(prompt) ? (promptByPane.get(paneKey) ?? prompt) : prompt } diff --git a/src/main/ai-vault/session-title-launch-prompt.ts b/src/main/ai-vault/session-title-launch-prompt.ts new file mode 100644 index 00000000000..1eb550e5a16 --- /dev/null +++ b/src/main/ai-vault/session-title-launch-prompt.ts @@ -0,0 +1,39 @@ +import type { + AiVaultSessionTitleRequest, + AiVaultSessionTitlesResult +} from '../../shared/ai-vault-session-title' +import { launchPromptShownForPane } from '../agent-hooks/launch-file-prompt-by-pane' +import { normalizeTitleText } from './session-scanner-text-normalization' + +/** + * Titles a launch file's session with the prompt it carried. Its transcript opens with Orca's + * pointer, so the tab label and agent row would otherwise show "The full task is in the file …". + */ +export function showLaunchPromptsInSessionTitles( + requests: readonly AiVaultSessionTitleRequest[], + result: AiVaultSessionTitlesResult +): AiVaultSessionTitlesResult { + const paneBySession = new Map( + requests.flatMap((request) => + request.paneKey ? [[`${request.agent}\0${request.sessionId}`, request.paneKey] as const] : [] + ) + ) + if (paneBySession.size === 0) { + return result + } + return { + titles: result.titles.map((title) => { + const paneKey = paneBySession.get(`${title.agent}\0${title.sessionId}`) + const shown = paneKey ? launchPromptShownForPane(paneKey, title.title) : title.title + return shown === title.title + ? title + : { ...title, title: normalizeTitleText(shown) ?? title.title } + }) + } +} + +/** A request as another host receives it: the pane is this host's to read. */ +export function withoutPaneKey(request: AiVaultSessionTitleRequest): AiVaultSessionTitleRequest { + const { paneKey: _paneKey, ...forwarded } = request + return forwarded +} diff --git a/src/main/ipc/ai-vault-session-title-routing.test.ts b/src/main/ipc/ai-vault-session-title-routing.test.ts new file mode 100644 index 00000000000..3747d052545 --- /dev/null +++ b/src/main/ipc/ai-vault-session-title-routing.test.ts @@ -0,0 +1,67 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { resolveLocalAiVaultSessionTitles } from '../ai-vault/session-title-resolver' +import { rememberLaunchFilePrompt } from '../agent-hooks/launch-file-prompt-by-pane' +import { carryInLaunchFile, buildLaunchFilePointer } from '../../shared/launch-prompt-file' +import { resolveAiVaultSessionTitlesByHost } from './ai-vault-session-title-routing' +import { requestActiveSshAiVaultSessionTitles } from './ssh' + +vi.mock('../ai-vault/session-title-resolver', () => ({ + resolveLocalAiVaultSessionTitles: vi.fn() +})) +vi.mock('./ssh', () => ({ requestActiveSshAiVaultSessionTitles: vi.fn() })) + +const PANE = 'tab-title:11111111-1111-4111-8111-111111111111' +// The transcript's first message is the pointer; its title is cut to 96 characters. +const POINTER_TITLE = buildLaunchFilePointer( + '/Users/ada/Library/Caches/orca-launch-file-1-a/task-context.md' +).slice(0, 96) + +// Why: a launch file's agent opens its transcript with Orca's pointer, and the tab label and the +// sidebar agent row read the session title from it (stack QA 1.2). +describe('a session title for a launch file’s agent', () => { + beforeEach(() => { + rememberLaunchFilePrompt(PANE, carryInLaunchFile('QA-STACK fix the flaky test').launchFile) + }) + + it('shows the prompt the launch file carried, not the pointer', async () => { + vi.mocked(resolveLocalAiVaultSessionTitles).mockResolvedValue({ + titles: [{ agent: 'claude', sessionId: 's-1', title: POINTER_TITLE }] + }) + const result = await resolveAiVaultSessionTitlesByHost({ + requests: [{ agent: 'claude', sessionId: 's-1', paneKey: PANE }] + }) + expect(result.titles).toEqual([ + { agent: 'claude', sessionId: 's-1', title: 'QA-STACK fix the flaky test' } + ]) + }) + + it('leaves another pane’s title, and a request without a pane, as the transcript has it', async () => { + vi.mocked(resolveLocalAiVaultSessionTitles).mockResolvedValue({ + titles: [ + { agent: 'claude', sessionId: 's-2', title: POINTER_TITLE }, + { agent: 'codex', sessionId: 's-3', title: 'fix the build' } + ] + }) + const result = await resolveAiVaultSessionTitlesByHost({ + requests: [ + { agent: 'claude', sessionId: 's-2' }, + { agent: 'codex', sessionId: 's-3', paneKey: PANE } + ] + }) + expect(result.titles.map((title) => title.title)).toEqual([POINTER_TITLE, 'fix the build']) + }) + + it('maps an SSH session’s title here, never sending the relay this host’s pane', async () => { + vi.mocked(requestActiveSshAiVaultSessionTitles).mockResolvedValue({ + titles: [{ agent: 'claude', sessionId: 's-4', title: POINTER_TITLE }] + }) + const result = await resolveAiVaultSessionTitlesByHost({ + executionHostScope: 'ssh:dev-box', + requests: [{ agent: 'claude', sessionId: 's-4', paneKey: PANE }] + }) + expect(requestActiveSshAiVaultSessionTitles).toHaveBeenCalledWith('dev-box', { + requests: [{ agent: 'claude', sessionId: 's-4' }] + }) + expect(result.titles[0]?.title).toBe('QA-STACK fix the flaky test') + }) +}) diff --git a/src/main/ipc/ai-vault-session-title-routing.ts b/src/main/ipc/ai-vault-session-title-routing.ts index cca3bafd893..698dbb94920 100644 --- a/src/main/ipc/ai-vault-session-title-routing.ts +++ b/src/main/ipc/ai-vault-session-title-routing.ts @@ -10,6 +10,10 @@ import { import { resolveLocalAiVaultSessionTitles } from '../ai-vault/session-title-resolver' import { parseAiVaultSessionTitlesResult } from '../ai-vault/session-title-result-validation' import { requestActiveSshAiVaultSessionTitles } from './ssh' +import { + showLaunchPromptsInSessionTitles, + withoutPaneKey +} from '../ai-vault/session-title-launch-prompt' export type RuntimeAiVaultSessionTitleResolver = ( environmentId: string, @@ -19,6 +23,17 @@ export type RuntimeAiVaultSessionTitleResolver = ( export async function resolveAiVaultSessionTitlesByHost( args: AiVaultSessionTitlesArgs, resolveRuntime?: RuntimeAiVaultSessionTitleResolver +): Promise { + // Why here: this host spawned the panes it shows, local and SSH alike, so it kept their prompts. + return showLaunchPromptsInSessionTitles( + args.requests, + await resolveTitlesOnHost(args, resolveRuntime) + ) +} + +async function resolveTitlesOnHost( + args: AiVaultSessionTitlesArgs, + resolveRuntime?: RuntimeAiVaultSessionTitleResolver ): Promise { const executionHostScope = requestedExecutionHostScope(args.executionHostScope) if (executionHostScope === LOCAL_EXECUTION_HOST_ID) { @@ -28,7 +43,7 @@ export async function resolveAiVaultSessionTitlesByHost( if (parsed?.kind === 'ssh') { try { const result = await requestActiveSshAiVaultSessionTitles(parsed.targetId, { - requests: args.requests + requests: args.requests.map(withoutPaneKey) }) return result === null ? { titles: [] } : parseAiVaultSessionTitlesResult(result) } catch { diff --git a/src/main/runtime/runtime-ai-vault-commands.test.ts b/src/main/runtime/runtime-ai-vault-commands.test.ts new file mode 100644 index 00000000000..8e514bc46b2 --- /dev/null +++ b/src/main/runtime/runtime-ai-vault-commands.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it, vi } from 'vitest' +import { resolveLocalAiVaultSessionTitles } from '../ai-vault/session-title-resolver' +import { rememberLaunchFilePrompt } from '../agent-hooks/launch-file-prompt-by-pane' +import { buildLaunchFilePointer, carryInLaunchFile } from '../../shared/launch-prompt-file' +import { RuntimeAiVaultCommands } from './runtime-ai-vault-commands' + +vi.mock('../ai-vault/cached-session-list', () => ({ listAiVaultSessions: vi.fn() })) +vi.mock('../ai-vault/session-title-resolver', () => ({ + resolveLocalAiVaultSessionTitles: vi.fn() +})) + +// Why: a paired web client and the phone ask this host for titles over RPC; the host spawned the +// pane, so it shows the launch file's prompt there too. +describe('session titles a paired client asks this host for', () => { + it('show a launch file’s prompt, not the pointer its transcript opens with', async () => { + const pane = 'tab-rpc:22222222-2222-4222-8222-222222222222' + rememberLaunchFilePrompt(pane, carryInLaunchFile('QA-STACK review the diff').launchFile) + vi.mocked(resolveLocalAiVaultSessionTitles).mockResolvedValue({ + titles: [ + { + agent: 'codex', + sessionId: 's-rpc', + title: buildLaunchFilePointer('/tmp/orca-launch-file-1-b/task-context.md').slice(0, 96) + } + ] + }) + const result = await new RuntimeAiVaultCommands(() => null).resolveTitles([ + { agent: 'codex', sessionId: 's-rpc', paneKey: pane } + ]) + expect(result.titles[0]?.title).toBe('QA-STACK review the diff') + }) +}) diff --git a/src/main/runtime/runtime-ai-vault-commands.ts b/src/main/runtime/runtime-ai-vault-commands.ts index 2701d782b61..801cffe044e 100644 --- a/src/main/runtime/runtime-ai-vault-commands.ts +++ b/src/main/runtime/runtime-ai-vault-commands.ts @@ -9,6 +9,7 @@ import type { import type { AiVaultListArgs, AiVaultListResult } from '../../shared/ai-vault-types' import { listAiVaultSessions } from '../ai-vault/cached-session-list' import { resolveLocalAiVaultSessionTitles } from '../ai-vault/session-title-resolver' +import { showLaunchPromptsInSessionTitles } from '../ai-vault/session-title-launch-prompt' export class RuntimeAiVaultCommands { constructor( @@ -21,11 +22,14 @@ export class RuntimeAiVaultCommands { return listAiVaultSessions(args) } - resolveTitles( + async resolveTitles( requests: AiVaultSessionTitleRequest[], signal?: AbortSignal ): Promise { - return resolveLocalAiVaultSessionTitles(requests, signal) + return showLaunchPromptsInSessionTitles( + requests, + await resolveLocalAiVaultSessionTitles(requests, signal) + ) } prepare(args: AiVaultPrepareSessionResumeArgs): Promise { diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index d0965c13ca6..824d27887ca 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -669,6 +669,11 @@ } } }, + "exited": { + "notice": { + "message": "The agent exited at startup, before it received your prompt. Copy the prompt and start the agent again." + } + }, "unconfirmed": { "notice": { "message": "Orca couldn't confirm the agent received your prompt. Check the agent before sending it again." diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 108866cc951..717d95fe4bf 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -423,6 +423,11 @@ } } }, + "exited": { + "notice": { + "message": "El agente se cerró al iniciarse, antes de recibir tu prompt. Copia el prompt e inicia el agente de nuevo." + } + }, "unconfirmed": { "notice": { "message": "Orca no pudo confirmar que el agente recibió tu prompt. Revisa el agente antes de enviarlo de nuevo." diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 302301bce64..7917d38eb25 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -633,6 +633,11 @@ } } }, + "exited": { + "notice": { + "message": "L’agent s’est arrêté au démarrage, avant de recevoir votre prompt. Copiez le prompt et relancez l’agent." + } + }, "unconfirmed": { "notice": { "message": "Orca n’a pas pu confirmer que l’agent a reçu votre prompt. Vérifiez l’agent avant de le renvoyer." diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 98b8a4a5888..0845d693128 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -534,6 +534,11 @@ } } }, + "exited": { + "notice": { + "message": "Agent は起動時に終了したため、プロンプトを受け取っていません。プロンプトをコピーして、Agent をもう一度起動してください。" + } + }, "unconfirmed": { "notice": { "message": "Agent がプロンプトを受け取ったことを Orca で確認できませんでした。再送する前に Agent を確認してください。" diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index e7276436f7e..8b139074dcd 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -534,6 +534,11 @@ } } }, + "exited": { + "notice": { + "message": "에이전트가 시작 중에 종료되어 프롬프트를 받지 못했습니다. 프롬프트를 복사한 뒤 에이전트를 다시 시작하세요." + } + }, "unconfirmed": { "notice": { "message": "에이전트가 프롬프트를 받았는지 Orca가 확인하지 못했습니다. 다시 보내기 전에 에이전트를 확인하세요." diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 76fa0863fea..770c606c143 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -534,6 +534,11 @@ } } }, + "exited": { + "notice": { + "message": "代理在启动时退出,未收到你的提示词。请复制提示词并重新启动代理。" + } + }, "unconfirmed": { "notice": { "message": "Orca 无法确认代理已收到你的提示词。再次发送前请先检查代理。" diff --git a/src/renderer/src/lib/agent-launch-prompt-not-delivered-notice.ts b/src/renderer/src/lib/agent-launch-prompt-not-delivered-notice.ts index feb0af95c36..0f212a71519 100644 --- a/src/renderer/src/lib/agent-launch-prompt-not-delivered-notice.ts +++ b/src/renderer/src/lib/agent-launch-prompt-not-delivered-notice.ts @@ -47,6 +47,21 @@ export function showAgentLaunchPromptUnconfirmedNotice(args: { }) } +/** + * The agent had the prompt on its command line but exited at startup, before it read it. It did + * not really start, so the user is handed the text to start it again with. + */ +export function showAgentLaunchExitedNotice(args: { agent: TuiAgent; prompt: string }): void { + showPromptCopyNotice( + translate( + 'auto.lib.agent.launch.prompt.exited.notice.message', + 'The agent exited at startup, before it received your prompt. Copy the prompt and start the agent again.' + ), + args.prompt + ) + track('agent_error', { error_class: 'unknown', agent_kind: tuiAgentToAgentKind(args.agent) }) +} + /** * The host refused to start the agent because it could not write the launch file that carries the * prompt. Nothing ran, so the user is handed the text to launch again with. diff --git a/src/renderer/src/lib/agent-launch-prompt-receipt.test.ts b/src/renderer/src/lib/agent-launch-prompt-receipt.test.ts index 25080f8854b..c995eae63a7 100644 --- a/src/renderer/src/lib/agent-launch-prompt-receipt.test.ts +++ b/src/renderer/src/lib/agent-launch-prompt-receipt.test.ts @@ -92,12 +92,13 @@ describe('whether a prompt that rode the launch command reached the agent', () = }) // Why: a shell back at its prompt looks ready too; readiness alone never counts. - it('is not delivered when the read finds the shell, however ready it looked', async () => { + // Why: the agent had the prompt on its line and quit at startup, before reading it. + it('reports the agent exited when the read finds the shell, however ready it looked', async () => { mocks.readForeground.mockResolvedValue('shell') mocks.setState({ agentStatusByPaneKey: { [PANE]: { updatedAt: 50 } } }) const pending = receipt() mocks.setState({ ptyIdsByTabId: { 'tab-1': ['pty-1'] } }) - await expect(pending).resolves.toBe('not-delivered') + await expect(pending).resolves.toBe('agent-exited') }) it('is unconfirmed when the host cannot tell and no hook turn arrives', async () => { @@ -119,12 +120,12 @@ describe('whether a prompt that rode the launch command reached the agent', () = await expect(pending).resolves.toBe('delivered') }) - it('is not delivered when the PTY exits first', async () => { + it('reports the agent exited when the PTY exits first', async () => { mocks.readiness.mockReturnValue(new Promise(() => {})) const pending = receipt() mocks.setState({ ptyIdsByTabId: { 'tab-1': ['pty-1'] } }) mocks.setState({ ptyIdsByTabId: { 'tab-1': [] } }) - await expect(pending).resolves.toBe('not-delivered') + await expect(pending).resolves.toBe('agent-exited') }) it('is not delivered when the PTY never spawns, as when the host refused its launch file', async () => { diff --git a/src/renderer/src/lib/agent-launch-prompt-receipt.ts b/src/renderer/src/lib/agent-launch-prompt-receipt.ts index 23d58bd026e..a3b97be085b 100644 --- a/src/renderer/src/lib/agent-launch-prompt-receipt.ts +++ b/src/renderer/src/lib/agent-launch-prompt-receipt.ts @@ -9,7 +9,7 @@ import { getSettingsForAgentTabRuntimeOwner, PTY_SPAWN_TIMEOUT_MS } from './agen type AppState = ReturnType -export type LaunchPromptReceipt = 'delivered' | 'not-delivered' | 'unconfirmed' +export type LaunchPromptReceipt = 'delivered' | 'not-delivered' | 'agent-exited' | 'unconfirmed' /** How long a hook turn may still arrive after a read that could not tell. */ const HOOK_GRACE_AFTER_UNKNOWN_READ_MS = 2000 @@ -41,8 +41,9 @@ async function readLaunchedAgentForeground( * a fresh read on the execution host that finds the launched agent, not its shell, in front of the * terminal (`readLaunchedAgentForeground`). A ready signal never counts on its own: it only times * that read, and bracketed paste turned off (`2004l`) revokes it. Not delivered when the PTY never - * spawns (a refused launch file included), exits first, the tab closes, or the read finds the - * shell. Unconfirmed when nothing proves either, as on a Windows host without hooks. + * spawns (a refused launch file included) or the tab closes. Agent exited when the PTY exits or + * the read finds the shell back in front: the agent had the prompt on its line and quit before + * reading it. Unconfirmed when nothing proves either, as on a Windows host without hooks. */ export function waitForLaunchPromptReceipt(args: { tabId: string @@ -78,7 +79,7 @@ export function waitForLaunchPromptReceipt(args: { if (foreground === 'agent') { finish('delivered') } else if (foreground === 'shell') { - finish('not-delivered') + finish('agent-exited') } else { timers.push( window.setTimeout(() => finish('unconfirmed'), HOOK_GRACE_AFTER_UNKNOWN_READ_MS) @@ -101,8 +102,10 @@ export function waitForLaunchPromptReceipt(args: { if (ptyId && !boundPtyId) { boundPtyId = ptyId readAfterReadiness(ptyId) - } else if ((boundPtyId && !ptyId) || !tabExists(state, tabId)) { + } else if (!tabExists(state, tabId)) { finish('not-delivered') + } else if (boundPtyId && !ptyId) { + finish('agent-exited') } } unsubscribe = useAppStore.subscribe(observe) diff --git a/src/renderer/src/lib/ai-vault-tab-title-requests.ts b/src/renderer/src/lib/ai-vault-tab-title-requests.ts index 1efeb48e001..92ab347a08c 100644 --- a/src/renderer/src/lib/ai-vault-tab-title-requests.ts +++ b/src/renderer/src/lib/ai-vault-tab-title-requests.ts @@ -14,6 +14,8 @@ export type AiVaultTitleRequest = { providerSession: AgentProviderSessionMetadata refresh: boolean tabId: string + /** The pane the session runs in, so its host can title a launch file's session by its prompt. */ + paneKey: string worktreeId: string } @@ -62,6 +64,7 @@ function registerCandidate( providerSession: args.providerSession, refresh: args.refresh, tabId, + paneKey: args.paneKey, worktreeId, priority }) diff --git a/src/renderer/src/lib/ai-vault-tab-title-sync.test.ts b/src/renderer/src/lib/ai-vault-tab-title-sync.test.ts index a062b9df906..217d12d4672 100644 --- a/src/renderer/src/lib/ai-vault-tab-title-sync.test.ts +++ b/src/renderer/src/lib/ai-vault-tab-title-sync.test.ts @@ -192,6 +192,8 @@ describe('AI Vault tab title sync', () => { { agent, sessionId: `${agent}-session`, + // Why: the host titles a launch file's session by the prompt it kept for this pane. + paneKey: 'tab-1:leaf-1', transcriptPath: `/sessions/${agent}.jsonl` } ] @@ -373,6 +375,7 @@ describe('AI Vault tab title sync', () => { providerSession: { key: 'session_id', id: `session-${index}` }, refresh: true, tabId: `tab-${index}`, + paneKey: `tab-${index}:leaf-${index}`, worktreeId: `worktree-${index}` }) const groups = batchAiVaultTitleRequests( @@ -391,6 +394,7 @@ describe('AI Vault tab title sync', () => { providerSession: { key: 'session_id' as const, id: `session-${index}` }, refresh: true, tabId: `tab-${index}`, + paneKey: `tab-${index}:leaf-${index}`, worktreeId: `worktree-${index}` }) const requests = [ diff --git a/src/renderer/src/lib/ai-vault-tab-title-sync.ts b/src/renderer/src/lib/ai-vault-tab-title-sync.ts index bac035ff265..bf0457f2a6a 100644 --- a/src/renderer/src/lib/ai-vault-tab-title-sync.ts +++ b/src/renderer/src/lib/ai-vault-tab-title-sync.ts @@ -94,6 +94,7 @@ export function startAiVaultTabTitleSync(dependencies: SyncDependencies): () => requests: requests.map((request) => ({ agent: request.agent, sessionId: request.providerSession.id, + paneKey: request.paneKey, ...(request.providerSession.transcriptPath ? { transcriptPath: request.providerSession.transcriptPath } : {}) diff --git a/src/renderer/src/lib/launch-agent-new-tab-prompt-delivery.test.ts b/src/renderer/src/lib/launch-agent-new-tab-prompt-delivery.test.ts new file mode 100644 index 00000000000..42a7b56722d --- /dev/null +++ b/src/renderer/src/lib/launch-agent-new-tab-prompt-delivery.test.ts @@ -0,0 +1,67 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + receipt: vi.fn(), + exited: vi.fn(), + unconfirmed: vi.fn(), + notDelivered: vi.fn() +})) + +vi.mock('@/lib/agent-launch-prompt-receipt', () => ({ waitForLaunchPromptReceipt: mocks.receipt })) +vi.mock('@/lib/agent-launch-prompt-not-delivered-notice', () => ({ + showAgentLaunchExitedNotice: mocks.exited, + showAgentLaunchPromptUnconfirmedNotice: mocks.unconfirmed, + showAgentLaunchPromptNotDeliveredNotice: mocks.notDelivered +})) +vi.mock('@/lib/agent-launch-prompt-delivery', () => ({ + deliverLaunchPromptToAgentTab: vi.fn(), + seedNativeChatLaunchDraftForAgentTab: vi.fn(), + seedNativeChatLaunchPromptForAgentTab: vi.fn() +})) +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => ({ tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'pty-1' }] } }) + } +})) +vi.mock('@/lib/telemetry', () => ({ track: vi.fn(), tuiAgentToAgentKind: vi.fn() })) + +import { deliverNewTabLaunchPrompt } from './launch-agent-new-tab-prompt-delivery' + +function deliver() { + return deliverNewTabLaunchPrompt({ + worktreeId: 'wt-1', + tabId: 'tab-1', + agent: 'claude', + prompt: 'fix the failing checks', + promptDelivery: 'submit-after-ready', + pasteDraftAfterLaunch: null, + submitPastedPrompt: false, + promptInLaunchFile: false, + launchedAt: 0 + }) +} + +// Why: the notice must say what happened (stack QA 1.3); "the agent started, but your prompt +// wasn't sent" was shown for an agent that crashed with the prompt already on its command line. +describe('the notice for a prompt the launch line carried', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('says the agent exited at startup when it quit before reading the prompt', async () => { + mocks.receipt.mockResolvedValue('agent-exited') + await expect(deliver()).resolves.toEqual({ delivered: false, failureNotified: true }) + expect(mocks.exited).toHaveBeenCalledWith({ + agent: 'claude', + prompt: 'fix the failing checks' + }) + expect(mocks.notDelivered).not.toHaveBeenCalled() + }) + + it('keeps "wasn’t sent" for a prompt that never reached a running agent', async () => { + mocks.receipt.mockResolvedValue('not-delivered') + await expect(deliver()).resolves.toMatchObject({ delivered: false }) + expect(mocks.notDelivered).toHaveBeenCalled() + expect(mocks.exited).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/lib/launch-agent-new-tab-prompt-delivery.ts b/src/renderer/src/lib/launch-agent-new-tab-prompt-delivery.ts index bab1b8759d5..6b66424eec4 100644 --- a/src/renderer/src/lib/launch-agent-new-tab-prompt-delivery.ts +++ b/src/renderer/src/lib/launch-agent-new-tab-prompt-delivery.ts @@ -1,6 +1,9 @@ import { createPasteReadinessTimeoutNotice } from '@/lib/launch-agent-paste-timeout-notice' import { waitForLaunchPromptReceipt } from '@/lib/agent-launch-prompt-receipt' -import { showAgentLaunchPromptUnconfirmedNotice } from '@/lib/agent-launch-prompt-not-delivered-notice' +import { + showAgentLaunchExitedNotice, + showAgentLaunchPromptUnconfirmedNotice +} from '@/lib/agent-launch-prompt-not-delivered-notice' import { deliverLaunchPromptToAgentTab, seedNativeChatLaunchDraftForAgentTab, @@ -65,12 +68,19 @@ export function deliverNewTabLaunchPrompt(args: { pasteDraftAfterLaunch === null ? waitForLaunchPromptReceipt({ tabId, agent, launchedAt: args.launchedAt }).then( (receipt) => { - if (receipt !== 'unconfirmed') { - return timeoutNotice.settle(receipt === 'delivered', args.onPromptDelivered) + switch (receipt) { + case 'delivered': + case 'not-delivered': + return timeoutNotice.settle(receipt === 'delivered', args.onPromptDelivered) + case 'agent-exited': + // Why its own words: the agent did not really start, so "paste it once ready" is wrong. + showAgentLaunchExitedNotice({ agent, prompt }) + return { delivered: false, failureNotified: true } + case 'unconfirmed': + // The agent may have the prompt: say so, and never invite pasting it a second time. + showAgentLaunchPromptUnconfirmedNotice({ agent, prompt }) + return { delivered: false, failureNotified: true } } - // The agent may have the prompt: say so, and never invite pasting it a second time. - showAgentLaunchPromptUnconfirmedNotice({ agent, prompt }) - return { delivered: false, failureNotified: true } } ) : deliverLaunchPromptToAgentTab({ diff --git a/src/shared/ai-vault-session-title.ts b/src/shared/ai-vault-session-title.ts index d72d5b6192b..99cd4c75616 100644 --- a/src/shared/ai-vault-session-title.ts +++ b/src/shared/ai-vault-session-title.ts @@ -13,6 +13,9 @@ export type AiVaultSessionTitleRequest = { agent: AiVaultSessionTitle['agent'] sessionId: string transcriptPath?: string + /** The pane showing this session, so the host that spawned it titles a launch file's session + * with the prompt, not the pointer the transcript holds. */ + paneKey?: string } export type AiVaultSessionTitlesArgs = { diff --git a/src/shared/launch-prompt-file.ts b/src/shared/launch-prompt-file.ts index d28e4c010ce..c3f9b9e0e14 100644 --- a/src/shared/launch-prompt-file.ts +++ b/src/shared/launch-prompt-file.ts @@ -47,6 +47,11 @@ export function isLaunchFilePointer(prompt: string): boolean { ) } +/** Whether text opens like Orca's pointer, as a session title cut from one does. */ +export function opensLikeLaunchFilePointer(text: string): boolean { + return text.trim().startsWith(POINTER_LEAD) +} + export function carryInLaunchFile(content: string): { prompt: string; launchFile: LaunchFile } { const bytes = crypto.getRandomValues(new Uint8Array(16)) const placeholder = `orca-launch-file-${Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('')}` diff --git a/src/shared/rpc-contract/ai-vault-params.ts b/src/shared/rpc-contract/ai-vault-params.ts index d036af9177f..084aa388ae3 100644 --- a/src/shared/rpc-contract/ai-vault-params.ts +++ b/src/shared/rpc-contract/ai-vault-params.ts @@ -66,7 +66,8 @@ export const AiVaultSessionTitlesParams = z.object({ z.object({ agent: z.enum(['claude', 'codex']), sessionId: z.string().min(1).max(512), - transcriptPath: z.string().min(1).max(32_768).optional() + transcriptPath: z.string().min(1).max(32_768).optional(), + paneKey: z.string().min(1).max(512).optional() }) ) .max(AI_VAULT_SESSION_TITLE_REQUEST_MAX_COUNT)