From c4b2cbfc464ec3c905ab493c996a6b03bf699232 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 07:52:03 -0700 Subject: [PATCH] fix(launch-prompt): title a launch file's session by its prompt; say when the agent exited Stack QA 1.2: with a launch file, the tab label and the sidebar agent row showed "The full task is in the file ..." while the status prompt held the real text. They read the provider session title, taken from the transcript's first message (the pointer, cut to 96 characters). Title requests now carry their pane, and the host that spawned it maps the title through launchPromptShownForPane, which also recognises a pointer cut short. The pane stays on this host: it is not sent to an SSH relay. Stack QA 1.3: "The agent started, but your prompt wasn't sent" also showed when the agent crashed at startup with the prompt on its command line. The receipt now reports 'agent-exited' when the PTY exits or the shell is back in front, and that outcome gets its own notice with Copy prompt, in every catalog. --- .../agent-hooks/launch-file-prompt-by-pane.ts | 8 +-- .../ai-vault/session-title-launch-prompt.ts | 39 +++++++++++ .../ai-vault-session-title-routing.test.ts | 67 +++++++++++++++++++ .../ipc/ai-vault-session-title-routing.ts | 17 ++++- .../runtime/runtime-ai-vault-commands.test.ts | 32 +++++++++ src/main/runtime/runtime-ai-vault-commands.ts | 8 ++- src/renderer/src/i18n/locales/en.json | 5 ++ src/renderer/src/i18n/locales/es.json | 5 ++ src/renderer/src/i18n/locales/fr.json | 5 ++ src/renderer/src/i18n/locales/ja.json | 5 ++ src/renderer/src/i18n/locales/ko.json | 5 ++ src/renderer/src/i18n/locales/zh.json | 5 ++ ...gent-launch-prompt-not-delivered-notice.ts | 15 +++++ .../lib/agent-launch-prompt-receipt.test.ts | 9 +-- .../src/lib/agent-launch-prompt-receipt.ts | 13 ++-- .../src/lib/ai-vault-tab-title-requests.ts | 3 + .../src/lib/ai-vault-tab-title-sync.test.ts | 4 ++ .../src/lib/ai-vault-tab-title-sync.ts | 1 + ...unch-agent-new-tab-prompt-delivery.test.ts | 67 +++++++++++++++++++ .../launch-agent-new-tab-prompt-delivery.ts | 22 ++++-- src/shared/ai-vault-session-title.ts | 3 + src/shared/launch-prompt-file.ts | 5 ++ src/shared/rpc-contract/ai-vault-params.ts | 3 +- 23 files changed, 323 insertions(+), 23 deletions(-) create mode 100644 src/main/ai-vault/session-title-launch-prompt.ts create mode 100644 src/main/ipc/ai-vault-session-title-routing.test.ts create mode 100644 src/main/runtime/runtime-ai-vault-commands.test.ts create mode 100644 src/renderer/src/lib/launch-agent-new-tab-prompt-delivery.test.ts 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)