From 7fd1555d20f57f5dfbba821354a06df46114e155 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 12:28:56 -0700 Subject: [PATCH] fix(mobile): wrap an AI button's prompt in the action's saved template The desktop renders every source-control launch's prompt through the action's saved template (buildSourceControlRecoveryAgentCommandInput); the phone sent its built-in prompt as is. Now that the host publishes the recipes, the phone renders through the same shared function, refuses an empty result as the desktop does, and offers the rendered text when it could not be delivered. Review notes have no recipe and are unchanged. --- .../mobile-source-control-launch-agent.ts | 11 ++-- .../src/session/pr-ai-triage-launch.test.ts | 51 +++++++++++++++++-- mobile/src/session/pr-ai-triage-launch.ts | 38 ++++++++++---- mobile/src/session/use-mobile-pr-ai-triage.ts | 2 +- .../use-mobile-commit-failure-recovery.ts | 2 +- 5 files changed, 81 insertions(+), 23 deletions(-) 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 {