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.
This commit is contained in:
Brennan Benson
2026-09-25 12:28:56 -07:00
parent f7671dc7ea
commit 7fd1555d20
5 changed files with 81 additions and 23 deletions
@@ -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.' }
}
+47 -4
View File
@@ -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
+28 -10
View File
@@ -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':
@@ -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 {
@@ -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 {