diff --git a/mobile/src/session/mobile-source-control-launch-agent.ts b/mobile/src/session/mobile-source-control-launch-agent.ts index cdfcc839d18..a4b90605973 100644 --- a/mobile/src/session/mobile-source-control-launch-agent.ts +++ b/mobile/src/session/mobile-source-control-launch-agent.ts @@ -20,8 +20,9 @@ import { isMobileTuiAgent } from '../tasks/mobile-tui-agents' import type { MobileAgentLaunchContext } from './mobile-new-tab-agent-loader' export type MobileSourceControlLaunchAgent = - /** `agentArgs`: the recipe's saved launch arguments, sent as the desktop sends them. */ - { kind: 'agent'; agent: TuiAgent; agentArgs?: string } | { kind: 'unavailable'; message: string } + /** `recipe`: the action's saved prompt template and agent arguments; null with no action. */ + | { kind: 'agent'; agent: TuiAgent; recipe: SourceControlActionRecipe | null } + | { kind: 'unavailable'; message: string } type LaunchAgentSettings = Pick< GlobalSettings, @@ -55,11 +56,7 @@ export function resolveMobileSourceControlLaunchAgent( disabledAgents: settings?.disabledTuiAgents }) return agent - ? { - kind: 'agent', - agent, - ...(recipe?.agentArgs !== undefined ? { agentArgs: recipe.agentArgs } : {}) - } + ? { kind: 'agent', agent, recipe } : { kind: 'unavailable', message: 'No enabled AI agent was detected on this workspace host.' } } diff --git a/mobile/src/session/pr-ai-triage-launch.test.ts b/mobile/src/session/pr-ai-triage-launch.test.ts index b6fc02c70b4..7ce8eb0c02c 100644 --- a/mobile/src/session/pr-ai-triage-launch.test.ts +++ b/mobile/src/session/pr-ai-triage-launch.test.ts @@ -126,6 +126,46 @@ describe('launchAgentWithPrompt', () => { expect(launchParams(sendRequest)).toMatchObject({ agent: 'claude' }) }) + it("wraps the prompt in the action's saved template, as the desktop does", async () => { + const { client, sendRequest } = hostClient({ + settings: { + defaultTuiAgent: 'claude', + sourceControlAi: { + actions: { fixChecks: { commandInputTemplate: '/review first\n\n{basePrompt}' } } + } + }, + launch: launchedWith({ delivery: 'submit', outcome: 'handed-to-terminal' }) + }) + await run(client) + expect(launchParams(sendRequest)).toMatchObject({ + prompt: { text: '/review first\n\nFix the failing checks', delivery: 'submit' } + }) + }) + + it('refuses to launch with an empty saved template rather than send no prompt', async () => { + const { client, sendRequest } = hostClient({ + settings: { + defaultTuiAgent: 'claude', + sourceControlAi: { actions: { fixChecks: { commandInputTemplate: ' ' } } } + } + }) + await expect(run(client)).resolves.toMatchObject({ kind: 'not-started' }) + expect(launchParams(sendRequest)).toBeUndefined() + }) + + it('falls back to the default agent on an older host that publishes no recipes', async () => { + const { client, sendRequest } = hostClient({ + // A host before the recipe projection: settings.get carries no sourceControlAi at all. + settings: { defaultTuiAgent: 'codex', disabledTuiAgents: [] }, + launch: launchedWith({ delivery: 'submit', outcome: 'handed-to-terminal' }) + }) + await expect(run(client)).resolves.toEqual({ kind: 'sent' }) + expect(launchParams(sendRequest)).toMatchObject({ + agent: 'codex', + prompt: { text: 'Fix the failing checks' } + }) + }) + it("sends the action's saved agent arguments, as the desktop does", async () => { const { client, sendRequest } = hostClient({ settings: { @@ -177,7 +217,10 @@ describe('launchAgentWithPrompt', () => { const { client } = hostClient({ launch: launchedWith({ delivery: 'submit', outcome: 'not-delivered' }) }) - await expect(run(client)).resolves.toEqual({ kind: 'prompt-not-sent' }) + await expect(run(client)).resolves.toEqual({ + kind: 'prompt-not-sent', + prompt: 'Fix the failing checks' + }) }) it('passes the host warning through', async () => { @@ -211,17 +254,17 @@ describe('launchAgentWithPrompt', () => { describe('promptedLaunchNotice', () => { it('keeps the prompt only when the agent started without it', () => { - expect(promptedLaunchNotice({ kind: 'prompt-not-sent' }, 'p')).toEqual({ + expect(promptedLaunchNotice({ kind: 'prompt-not-sent', prompt: 'p' })).toEqual({ succeeded: false, error: AGENT_PROMPT_NOT_SENT_MESSAGE, undeliveredPrompt: 'p' }) - expect(promptedLaunchNotice({ kind: 'sent' }, 'p')).toEqual({ + expect(promptedLaunchNotice({ kind: 'sent' })).toEqual({ succeeded: true, error: null, undeliveredPrompt: null }) - expect(promptedLaunchNotice({ kind: 'unconfirmed', message: 'm' }, 'p')).toEqual({ + expect(promptedLaunchNotice({ kind: 'unconfirmed', message: 'm' })).toEqual({ succeeded: false, error: 'm', undeliveredPrompt: null diff --git a/mobile/src/session/pr-ai-triage-launch.ts b/mobile/src/session/pr-ai-triage-launch.ts index 613572524e2..de66832f2e8 100644 --- a/mobile/src/session/pr-ai-triage-launch.ts +++ b/mobile/src/session/pr-ai-triage-launch.ts @@ -1,4 +1,5 @@ import type { SourceControlLaunchActionId } from '../../../src/shared/source-control-ai-actions' +import { buildSourceControlRecoveryAgentCommandInput } from '../../../src/shared/source-control-recovery-agent-command' import type { RpcClient } from '../transport/rpc-client' import { AGENT_LAUNCH_UPDATE_REQUIRED_MESSAGE, @@ -14,8 +15,8 @@ import { resolveMobileSourceControlLaunchAgent } from './mobile-source-control-l export type MobilePromptedAgentLaunch = | { kind: 'sent'; warning?: string } - /** The agent started but the prompt did not reach it; the caller keeps the text. */ - | { kind: 'prompt-not-sent'; warning?: string } + /** The agent started but `prompt`, the text as sent, did not reach it; the caller offers it. */ + | { kind: 'prompt-not-sent'; prompt: string; warning?: string } /** Nothing started; `message` says why. */ | { kind: 'not-started'; message: string } /** The agent may be running; do not launch again until the user has looked. */ @@ -47,13 +48,29 @@ export async function launchAgentWithPrompt(args: { if (resolved.kind === 'unavailable') { return { kind: 'not-started', message: resolved.message } } + const { recipe } = resolved + // The desktop wraps each action's prompt in the user's saved template the same way. + const text = args.actionId + ? buildSourceControlRecoveryAgentCommandInput({ + actionId: args.actionId, + commandInputTemplate: recipe?.commandInputTemplate, + basePrompt: args.prompt + }) + : args.prompt + if (!text) { + return { + kind: 'not-started', + message: + "This action's saved prompt is empty. Update Source Control AI settings on your computer." + } + } const launched = await launchAgentInExistingWorkspace({ client: args.client, hostCapabilities: args.hostCapabilities, worktreeId: args.worktreeId, agent: resolved.agent, - ...(resolved.agentArgs !== undefined ? { agentArgs: resolved.agentArgs } : {}), - prompt: { text: args.prompt, delivery: 'submit' }, + ...(recipe?.agentArgs !== undefined ? { agentArgs: recipe.agentArgs } : {}), + prompt: { text, delivery: 'submit' }, launchSource: args.launchSource }) switch (launched.kind) { @@ -62,7 +79,7 @@ export async function launchAgentWithPrompt(args: { const extra = warning ? { warning } : {} return launched.promptDelivered ? { kind: 'sent', ...extra } - : { kind: 'prompt-not-sent', ...extra } + : { kind: 'prompt-not-sent', prompt: text, ...extra } } case 'unsupported': return { kind: 'not-started', message: AGENT_LAUNCH_UPDATE_REQUIRED_MESSAGE } @@ -74,10 +91,11 @@ export async function launchAgentWithPrompt(args: { } /** What the button shows after a launch; one mapping so every AI button reads the same. */ -export function promptedLaunchNotice( - result: MobilePromptedAgentLaunch, - prompt: string -): { succeeded: boolean; error: string | null; undeliveredPrompt: string | null } { +export function promptedLaunchNotice(result: MobilePromptedAgentLaunch): { + succeeded: boolean + error: string | null + undeliveredPrompt: string | null +} { switch (result.kind) { case 'sent': return { succeeded: true, error: result.warning ?? null, undeliveredPrompt: null } @@ -87,7 +105,7 @@ export function promptedLaunchNotice( error: result.warning ? `${AGENT_PROMPT_NOT_SENT_MESSAGE} ${result.warning}` : AGENT_PROMPT_NOT_SENT_MESSAGE, - undeliveredPrompt: prompt + undeliveredPrompt: result.prompt } case 'not-started': case 'unconfirmed': diff --git a/mobile/src/session/use-mobile-pr-ai-triage.ts b/mobile/src/session/use-mobile-pr-ai-triage.ts index e88b18fd956..6f05dd911b9 100644 --- a/mobile/src/session/use-mobile-pr-ai-triage.ts +++ b/mobile/src/session/use-mobile-pr-ai-triage.ts @@ -69,7 +69,7 @@ export function useMobilePrAiTriage(input: Input) { prompt, ...TRIAGE_LAUNCH[key] }) - const notice = promptedLaunchNotice(result, prompt) + const notice = promptedLaunchNotice(result) if (notice.succeeded) { triggerSuccess() } else { diff --git a/mobile/src/source-control/use-mobile-commit-failure-recovery.ts b/mobile/src/source-control/use-mobile-commit-failure-recovery.ts index 42e4ede98be..b977e8ff6f3 100644 --- a/mobile/src/source-control/use-mobile-commit-failure-recovery.ts +++ b/mobile/src/source-control/use-mobile-commit-failure-recovery.ts @@ -77,7 +77,7 @@ export function useMobileCommitFailureRecovery({ client, connState, worktreeId, actionId: 'fixCommitFailure', launchSource: 'source_control_recovery' }) - const notice = promptedLaunchNotice(result, prompt) + const notice = promptedLaunchNotice(result) if (notice.succeeded) { triggerSuccess() } else {