Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type * as SourceControlAgentLaunchModule from '@/lib/source-control-agent-launch'

const mocks = vi.hoisted(() => ({
launchSourceControlAgent: vi.fn(),
launchAgentInNewTab: vi.fn(),
showNotDelivered: vi.fn(),
onLaunchAccepted: vi.fn(),
onLaunchAborted: vi.fn(),
onLaunched: vi.fn(),
onClose: vi.fn(),
toastError: vi.fn()
}))

vi.mock('@/lib/source-control-agent-launch', async (importOriginal) => ({
...(await importOriginal<typeof SourceControlAgentLaunchModule>()),
launchSourceControlAgent: mocks.launchSourceControlAgent
}))
vi.mock('@/lib/launch-agent-in-new-tab', () => ({ launchAgentInNewTab: mocks.launchAgentInNewTab }))
vi.mock('@/lib/agent-launch-prompt-not-delivered-notice', () => ({
showAgentLaunchPromptNotDeliveredNotice: mocks.showNotDelivered
}))
vi.mock('@/lib/focus-terminal-tab-surface', () => ({ focusTerminalTabSurface: vi.fn() }))
vi.mock('sonner', () => ({ toast: { error: mocks.toastError, warning: vi.fn() } }))

import { runSourceControlAgentActionStart } from './runSourceControlAgentActionStart'

function buildArgs(
overrides: Partial<Parameters<typeof runSourceControlAgentActionStart>[0]> = {}
): Parameters<typeof runSourceControlAgentActionStart>[0] {
return {
selectedAgent: 'claude',
trimmedCommandInput: 'Resolve the conflicts.\nFiles: a.ts',
agentArgs: '--model opus',
agentArgsApply: true,
commandTemplate: '{basePrompt}',
saveTargetValue: 'none',
actionId: 'resolveConflicts',
repoId: null,
settings: null,
repo: null,
worktreeId: 'wt-1',
groupId: 'group-1',
promptDelivery: 'submit-after-ready',
launchPlatform: 'linux',
launchSource: 'conflict_resolution',
onLaunchAccepted: mocks.onLaunchAccepted,
onLaunchAborted: mocks.onLaunchAborted,
onLaunched: mocks.onLaunched,
onClose: mocks.onClose,
...overrides
}
}

beforeEach(() => {
vi.clearAllMocks()
})

describe('a source-control action started through the host', () => {
it('asks the host for this agent, workspace and prompt', async () => {
mocks.launchSourceControlAgent.mockResolvedValue({ kind: 'launched', promptDelivered: true })

await runSourceControlAgentActionStart(buildArgs())

expect(mocks.launchSourceControlAgent).toHaveBeenCalledWith({
agent: 'claude',
worktreeId: 'wt-1',
prompt: 'Resolve the conflicts.\nFiles: a.ts',
agentArgs: '--model opus',
launchSource: 'conflict_resolution',
onLaunchAccepted: expect.any(Function)
})
expect(mocks.launchAgentInNewTab).not.toHaveBeenCalled()
})

it('confirms the launch only once the host delivered the prompt', async () => {
mocks.launchSourceControlAgent.mockImplementation(async (args) => {
args.onLaunchAccepted()
return { kind: 'launched', promptDelivered: true }
})

await expect(runSourceControlAgentActionStart(buildArgs())).resolves.toBe(true)

expect(mocks.onLaunchAccepted).toHaveBeenCalledOnce()
expect(mocks.onLaunched).toHaveBeenCalledOnce()
expect(mocks.onLaunchAborted).not.toHaveBeenCalled()
})

it('aborts the caller’s follow-ups and hands over the prompt when the host kept it', async () => {
mocks.launchSourceControlAgent.mockImplementation(async (args) => {
args.onLaunchAccepted()
return { kind: 'launched', promptDelivered: false }
})

await expect(runSourceControlAgentActionStart(buildArgs())).resolves.toBe(false)

expect(mocks.onLaunchAborted).toHaveBeenCalledOnce()
expect(mocks.onLaunched).not.toHaveBeenCalled()
expect(mocks.showNotDelivered).toHaveBeenCalledOnce()
// The notice already told the user; a second "could not start" would be false.
expect(mocks.toastError).not.toHaveBeenCalled()
})

it('omits the arguments when they do not apply, so the host uses the settings default', async () => {
mocks.launchSourceControlAgent.mockResolvedValue({ kind: 'launched', promptDelivered: true })

await runSourceControlAgentActionStart(buildArgs({ agentArgsApply: false }))

expect(mocks.launchSourceControlAgent.mock.calls[0]?.[0]).not.toHaveProperty('agentArgs')
})

it('uses the tab paste on a host without the launch', async () => {
mocks.launchSourceControlAgent.mockResolvedValue({ kind: 'unsupported' })
mocks.launchAgentInNewTab.mockReturnValue({
surface: { kind: 'local-terminal', tabId: 'tab-1' },
pasteDraftAfterLaunch: true,
promptDeliveryResult: Promise.resolve({ delivered: true, failureNotified: false })
})

await expect(runSourceControlAgentActionStart(buildArgs())).resolves.toBe(true)

expect(mocks.launchAgentInNewTab).toHaveBeenCalledOnce()
})

it('keeps a draft on the tab paste, which the host has no way to deliver', async () => {
mocks.launchAgentInNewTab.mockReturnValue(null)

await runSourceControlAgentActionStart(buildArgs({ promptDelivery: 'draft' }))

expect(mocks.launchSourceControlAgent).not.toHaveBeenCalled()
expect(mocks.launchAgentInNewTab).toHaveBeenCalledOnce()
})

it('reports a refused launch once, with the host’s reason', async () => {
mocks.launchSourceControlAgent.mockResolvedValue({ kind: 'failed', message: 'Agent disabled' })

await expect(runSourceControlAgentActionStart(buildArgs())).resolves.toBe(false)

expect(mocks.toastError).toHaveBeenCalledTimes(1)
expect(mocks.toastError).toHaveBeenCalledWith("Couldn't start the agent.", {
description: 'Agent disabled'
})
})
})
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface'
import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab'
import {
launchSourceControlAgent,
settleSourceControlAgentLaunch
} from '@/lib/source-control-agent-launch'
import type { GlobalSettings } from '../../../../shared/global-settings-types'
import type { Repo } from '../../../../shared/repo-types'
import type { TuiAgent } from '../../../../shared/tui-agent'
Expand Down Expand Up @@ -101,34 +105,56 @@ export async function runSourceControlAgentActionStart({
notifyLaunchAccepted()
}
} else if (worktreeId) {
const result = launchAgentInNewTab({
agent: selectedAgent,
worktreeId,
groupId: groupId ?? worktreeId,
prompt: trimmedCommandInput,
agentArgs: launchAgentArgs,
promptDelivery,
launchPlatform,
launchSource
})
launched = Boolean(result)
if (result?.surface.kind === 'local-terminal') {
focusTerminalTabSurface(result.surface.tabId)
}
// Why: lets callers park launch-scoped state before submit-after-ready finishes
// (can take tens of seconds); host mutations still wait for delivery below.
if (launched) {
notifyLaunchAccepted()
}
if (result?.promptDeliveryResult) {
try {
const deliveryResult = await result.promptDeliveryResult
launched = deliveryResult.delivered
launchFailureNotified = deliveryResult.failureNotified
} catch (error) {
console.error('promptDeliveryResult rejected', error)
launched = false
// Why: the host delivers a generated prompt after readiness; other deliveries keep the tab paste.
const hosted =
promptDelivery === 'submit-after-ready'
? await launchSourceControlAgent({
agent: selectedAgent,
worktreeId,
prompt: trimmedCommandInput,
...(launchAgentArgs !== undefined ? { agentArgs: launchAgentArgs } : {}),
launchSource,
onLaunchAccepted: notifyLaunchAccepted
})
: ({ kind: 'unsupported' } as const)
if (hosted.kind === 'unsupported') {
const result = launchAgentInNewTab({
agent: selectedAgent,
worktreeId,
groupId: groupId ?? worktreeId,
prompt: trimmedCommandInput,
agentArgs: launchAgentArgs,
promptDelivery,
launchPlatform,
launchSource
})
launched = Boolean(result)
if (result?.surface.kind === 'local-terminal') {
focusTerminalTabSurface(result.surface.tabId)
}
// Why: lets callers park launch-scoped state before submit-after-ready finishes
// (can take tens of seconds); host mutations still wait for delivery below.
if (launched) {
notifyLaunchAccepted()
}
if (result?.promptDeliveryResult) {
try {
const deliveryResult = await result.promptDeliveryResult
launched = deliveryResult.delivered
launchFailureNotified = deliveryResult.failureNotified
} catch (error) {
console.error('promptDeliveryResult rejected', error)
launched = false
}
}
} else {
const settled = settleSourceControlAgentLaunch(hosted, {
agent: selectedAgent,
prompt: trimmedCommandInput
})
// Irreversible follow-ups (posting replies, resolving threads) wait for the prompt itself.
launched = settled.promptDelivered
launchFailureNotified = settled.failureNotified
}
}
if (!launched) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type * as SourceControlAgentLaunchModule from '@/lib/source-control-agent-launch'

const mocks = vi.hoisted(() => ({
launchSourceControlAgent: vi.fn(),
launchAgentInNewTab: vi.fn(),
showNotDelivered: vi.fn(),
toastSuccess: vi.fn(),
toastError: vi.fn()
}))

vi.mock('@/lib/source-control-agent-launch', async (importOriginal) => ({
...(await importOriginal<typeof SourceControlAgentLaunchModule>()),
launchSourceControlAgent: mocks.launchSourceControlAgent
}))
vi.mock('@/lib/launch-agent-in-new-tab', () => ({ launchAgentInNewTab: mocks.launchAgentInNewTab }))
vi.mock('@/lib/agent-launch-prompt-not-delivered-notice', () => ({
showAgentLaunchPromptNotDeliveredNotice: mocks.showNotDelivered
}))
vi.mock('@/lib/connection-context', () => ({ getConnectionId: () => null }))
vi.mock('@/lib/focus-terminal-tab-surface', () => ({ focusTerminalTabSurface: vi.fn() }))
vi.mock('sonner', () => ({
toast: { success: mocks.toastSuccess, error: mocks.toastError, warning: vi.fn() }
}))

import type { TuiAgent } from '../../../../../../shared/tui-agent'
import {
getDefaultSourceControlRecoveryLaunchCopy,
launchSourceControlRecoveryAgentWithDefault
} from './recovery-launch'

const COPY = getDefaultSourceControlRecoveryLaunchCopy('commit')
const DETECTED: TuiAgent[] = ['claude']

function launch() {
return launchSourceControlRecoveryAgentWithDefault({
activeWorktreeId: 'wt-1',
activeGroupId: 'group-1',
activeSourceControlLaunchPlatform: 'darwin',
actionId: 'fixCommitFailure',
basePrompt: 'The commit failed:\nhook output',
getLaunchActionRecipe: () => ({ agentArgs: '--model opus' }),
getStoreState: () => ({
settings: null,
ensureDetectedAgents: async () => DETECTED,
ensureRemoteDetectedAgents: async () => DETECTED
}),
copy: COPY
})
}

beforeEach(() => {
vi.clearAllMocks()
})

describe('commit-failure recovery on a host that takes the launch', () => {
it('hands the host the recovery prompt under its own launch source', async () => {
mocks.launchSourceControlAgent.mockResolvedValue({ kind: 'launched', promptDelivered: true })

await expect(launch()).resolves.toBe(true)

expect(mocks.launchSourceControlAgent).toHaveBeenCalledWith(
expect.objectContaining({
agent: 'claude',
worktreeId: 'wt-1',
agentArgs: '--model opus',
launchSource: 'source_control_recovery',
prompt: expect.stringContaining('The commit failed:\nhook output')
})
)
expect(mocks.launchAgentInNewTab).not.toHaveBeenCalled()
expect(mocks.toastSuccess).toHaveBeenCalledWith(COPY.success)
})

it('claims no success when the host kept the prompt, and hands it over instead', async () => {
mocks.launchSourceControlAgent.mockResolvedValue({ kind: 'launched', promptDelivered: false })

await expect(launch()).resolves.toBe(true)

expect(mocks.toastSuccess).not.toHaveBeenCalled()
expect(mocks.showNotDelivered).toHaveBeenCalledOnce()
})

it('uses the tab paste on a host without the launch', async () => {
mocks.launchSourceControlAgent.mockResolvedValue({ kind: 'unsupported' })
mocks.launchAgentInNewTab.mockReturnValue({
surface: { kind: 'local-terminal', tabId: 'tab-1' }
})

await expect(launch()).resolves.toBe(true)

expect(mocks.launchAgentInNewTab).toHaveBeenCalledWith(
expect.objectContaining({ promptDelivery: 'submit-after-ready', launchPlatform: 'darwin' })
)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ import { toast } from 'sonner'
import type { AppState } from '@/store'
import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface'
import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab'
import {
launchSourceControlAgent,
settleSourceControlAgentLaunch
} from '@/lib/source-control-agent-launch'
import { getConnectionId } from '@/lib/connection-context'
import { planAgentCliArgsSuffix } from '@/lib/tui-agent-startup'
import {
Expand Down Expand Up @@ -155,6 +159,20 @@ export async function launchSourceControlRecoveryAgentWithDefault({
toast.error(copy.noEnabledAgent)
return false
}
const hosted = await launchSourceControlAgent({
agent,
worktreeId: activeWorktreeId,
prompt,
agentArgs: savedRecipe.agentArgs,
launchSource: 'source_control_recovery'
})
if (hosted.kind !== 'unsupported') {
const settled = settleSourceControlAgentLaunch(hosted, { agent, prompt })
if (settled.promptDelivered) {
toast.success(copy.success)
}
return settled.started
}
const result = launchAgentInNewTab({
agent,
worktreeId: activeWorktreeId,
Expand Down
Loading
Loading