Run the repo's Custom GitHub Issue Command when starting a workspace from an issue (#6827)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
d6fb8e04f9
commit
11ca4955ae
|
|
@ -97,6 +97,13 @@ function makeDeps(store = makeStore()) {
|
|||
resolveSetupDecision: vi.fn().mockResolvedValue({ kind: 'decided', decision: 'inherit' }),
|
||||
resolvePrStartPoint: vi.fn(),
|
||||
confirmHooks: vi.fn().mockResolvedValue('run'),
|
||||
readIssueCommand: vi.fn().mockResolvedValue({
|
||||
effectiveContent: null,
|
||||
localContent: null,
|
||||
sharedContent: null,
|
||||
localFilePath: '',
|
||||
source: 'none'
|
||||
}),
|
||||
beginBackgroundCreate: vi.fn(() => 'creation-1'),
|
||||
continueBackgroundCreate: vi.fn(() => true),
|
||||
activatePendingCreate: vi.fn(),
|
||||
|
|
@ -607,4 +614,206 @@ describe('createGitHubWorkItemWorkspaceInBackground', () => {
|
|||
expect(request.startup?.command).toBe('codex --prompt-file')
|
||||
expect(buildAgentStartupPlan).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('attaches the rendered issue command when the repo configured one and trust runs', async () => {
|
||||
const deps = makeDeps()
|
||||
deps.readIssueCommand.mockResolvedValueOnce({
|
||||
effectiveContent: 'gh issue view {{issue}} --repo {{artifact_url}}',
|
||||
localContent: null,
|
||||
sharedContent: 'gh issue view {{issue}} --repo {{artifact_url}}',
|
||||
localFilePath: '',
|
||||
source: 'shared'
|
||||
})
|
||||
|
||||
await createGitHubWorkItemWorkspaceInBackground(
|
||||
{
|
||||
item: makeIssue(),
|
||||
repoId: 'repo-1',
|
||||
openModalFallback: vi.fn()
|
||||
},
|
||||
deps
|
||||
)
|
||||
|
||||
expect(deps.confirmHooks).toHaveBeenCalledWith(expect.anything(), 'repo-1', 'setup')
|
||||
expect(deps.confirmHooks).toHaveBeenCalledWith(expect.anything(), 'repo-1', 'issueCommand')
|
||||
const continueCall = deps.continueBackgroundCreate.mock.calls[0] as unknown[] | undefined
|
||||
expect(continueCall).toBeDefined()
|
||||
const request = continueCall?.[1] as WorktreeCreationRequest
|
||||
expect(request.issueCommand?.command).toBe(
|
||||
'gh issue view 42 --repo https://github.com/stablyai/orca/issues/42'
|
||||
)
|
||||
})
|
||||
|
||||
it('omits the issue command when the repo configured none', async () => {
|
||||
const deps = makeDeps()
|
||||
|
||||
await createGitHubWorkItemWorkspaceInBackground(
|
||||
{
|
||||
item: makeIssue(),
|
||||
repoId: 'repo-1',
|
||||
openModalFallback: vi.fn()
|
||||
},
|
||||
deps
|
||||
)
|
||||
|
||||
const continueCall = deps.continueBackgroundCreate.mock.calls[0] as unknown[] | undefined
|
||||
expect(continueCall).toBeDefined()
|
||||
const request = continueCall?.[1] as WorktreeCreationRequest
|
||||
expect(request.issueCommand).toBeUndefined()
|
||||
expect(deps.confirmHooks).not.toHaveBeenCalledWith(expect.anything(), 'repo-1', 'issueCommand')
|
||||
})
|
||||
|
||||
it('skips the issue command when setup trust was declined', async () => {
|
||||
const deps = makeDeps()
|
||||
deps.readIssueCommand.mockResolvedValueOnce({
|
||||
effectiveContent: 'echo {{issue}}',
|
||||
localContent: 'echo {{issue}}',
|
||||
sharedContent: null,
|
||||
localFilePath: '/repo/.orca/issue-command',
|
||||
source: 'local'
|
||||
})
|
||||
// Why: the setup confirmHooks call resolves 'skip', which mirrors the
|
||||
// composer suppressing the issue command when setup trust is declined.
|
||||
deps.confirmHooks.mockResolvedValue('skip')
|
||||
|
||||
await createGitHubWorkItemWorkspaceInBackground(
|
||||
{
|
||||
item: makeIssue(),
|
||||
repoId: 'repo-1',
|
||||
openModalFallback: vi.fn()
|
||||
},
|
||||
deps
|
||||
)
|
||||
|
||||
const continueCall = deps.continueBackgroundCreate.mock.calls[0] as unknown[] | undefined
|
||||
expect(continueCall).toBeDefined()
|
||||
const request = continueCall?.[1] as WorktreeCreationRequest
|
||||
expect(request.issueCommand).toBeUndefined()
|
||||
expect(deps.confirmHooks).not.toHaveBeenCalledWith(expect.anything(), 'repo-1', 'issueCommand')
|
||||
// Why: a declined setup trust short-circuits before the (up-to-15s) read,
|
||||
// so workspace creation is never stalled to fetch a command we will drop.
|
||||
expect(deps.readIssueCommand).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not run the issue command for PR items', async () => {
|
||||
const deps = makeDeps()
|
||||
deps.resolvePrStartPoint.mockResolvedValueOnce({
|
||||
baseBranch: 'feature/from-pr',
|
||||
pushTarget: { remote: 'origin', branch: 'feature/from-pr' },
|
||||
branchNameOverride: 'feature/from-pr',
|
||||
compareBaseRef: 'main'
|
||||
})
|
||||
deps.readIssueCommand.mockResolvedValueOnce({
|
||||
effectiveContent: 'echo {{issue}}',
|
||||
localContent: 'echo {{issue}}',
|
||||
sharedContent: null,
|
||||
localFilePath: '/repo/.orca/issue-command',
|
||||
source: 'local'
|
||||
})
|
||||
|
||||
await createGitHubWorkItemWorkspaceInBackground(
|
||||
{
|
||||
item: makeIssue({ type: 'pr', number: 7, url: 'https://github.com/stablyai/orca/pull/7' }),
|
||||
repoId: 'repo-1',
|
||||
openModalFallback: vi.fn()
|
||||
},
|
||||
deps
|
||||
)
|
||||
|
||||
const continueCall = deps.continueBackgroundCreate.mock.calls[0] as unknown[] | undefined
|
||||
expect(continueCall).toBeDefined()
|
||||
const request = continueCall?.[1] as WorktreeCreationRequest
|
||||
expect(request.issueCommand).toBeUndefined()
|
||||
expect(deps.confirmHooks).not.toHaveBeenCalledWith(expect.anything(), 'repo-1', 'issueCommand')
|
||||
})
|
||||
|
||||
it('fails closed when reading the issue command rejects', async () => {
|
||||
const deps = makeDeps()
|
||||
deps.readIssueCommand.mockRejectedValueOnce(new Error('runtime offline'))
|
||||
|
||||
const result = await createGitHubWorkItemWorkspaceInBackground(
|
||||
{
|
||||
item: makeIssue(),
|
||||
repoId: 'repo-1',
|
||||
openModalFallback: vi.fn()
|
||||
},
|
||||
deps
|
||||
)
|
||||
|
||||
expect(result).toEqual({ kind: 'background-started' })
|
||||
const continueCall = deps.continueBackgroundCreate.mock.calls[0] as unknown[] | undefined
|
||||
expect(continueCall).toBeDefined()
|
||||
const request = continueCall?.[1] as WorktreeCreationRequest
|
||||
expect(request.issueCommand).toBeUndefined()
|
||||
})
|
||||
|
||||
it('re-reads the store for each trust check so mid-flow trust updates are honored', async () => {
|
||||
const snapshots = [makeStore(), makeStore(), makeStore()]
|
||||
let getStoreCall = 0
|
||||
const deps = makeDeps(snapshots[0])
|
||||
deps.getStore = vi.fn(
|
||||
() => snapshots[Math.min(getStoreCall++, snapshots.length - 1)] ?? snapshots[0]
|
||||
)
|
||||
deps.readIssueCommand.mockResolvedValueOnce({
|
||||
effectiveContent: 'echo {{issue}}',
|
||||
localContent: null,
|
||||
sharedContent: 'echo {{issue}}',
|
||||
localFilePath: '',
|
||||
source: 'shared'
|
||||
})
|
||||
|
||||
await createGitHubWorkItemWorkspaceInBackground(
|
||||
{
|
||||
item: makeIssue(),
|
||||
repoId: 'repo-1',
|
||||
openModalFallback: vi.fn()
|
||||
},
|
||||
deps
|
||||
)
|
||||
|
||||
// Why: an "Always trust" stamped by the setup prompt only exists in a fresh
|
||||
// snapshot. Each trust check must read the store at call time instead of
|
||||
// reusing the snapshot captured when the flow began.
|
||||
const setupCall = deps.confirmHooks.mock.calls.find((call) => call[2] === 'setup')
|
||||
const issueCall = deps.confirmHooks.mock.calls.find((call) => call[2] === 'issueCommand')
|
||||
expect(setupCall?.[0]).toBeDefined()
|
||||
expect(issueCall?.[0]).toBeDefined()
|
||||
expect(setupCall?.[0]).not.toBe(snapshots[0])
|
||||
expect(issueCall?.[0]).not.toBe(snapshots[0])
|
||||
expect(issueCall?.[0]).not.toBe(setupCall?.[0])
|
||||
})
|
||||
|
||||
it('keeps the seeded agent prompt as the bare issue URL (not Complete <url>)', async () => {
|
||||
const store = makeStore({
|
||||
ensureDetectedAgents: vi.fn().mockResolvedValue(['codex'])
|
||||
})
|
||||
const deps = makeDeps(store)
|
||||
deps.readIssueCommand.mockResolvedValueOnce({
|
||||
effectiveContent: 'echo {{issue}}',
|
||||
localContent: 'echo {{issue}}',
|
||||
sharedContent: null,
|
||||
localFilePath: '/repo/.orca/issue-command',
|
||||
source: 'local'
|
||||
})
|
||||
|
||||
await createGitHubWorkItemWorkspaceInBackground(
|
||||
{
|
||||
item: makeIssue(),
|
||||
repoId: 'repo-1',
|
||||
openModalFallback: vi.fn()
|
||||
},
|
||||
deps
|
||||
)
|
||||
|
||||
const continueCall = deps.continueBackgroundCreate.mock.calls[0] as unknown[] | undefined
|
||||
expect(continueCall).toBeDefined()
|
||||
const request = continueCall?.[1] as WorktreeCreationRequest
|
||||
// Why: Brennan's confirmed scope keeps the quick-start prompt the bare link;
|
||||
// the issue command runs as a side-pane split, not as the agent prompt.
|
||||
expect(request.startupPlan?.draftPrompt).toBe('https://github.com/stablyai/orca/issues/42')
|
||||
expect(request.startupPlan?.draftPrompt ?? '').not.toContain('Complete')
|
||||
expect(request.quickPrompt).not.toContain('Complete')
|
||||
// The issue command itself still threads through as a side-pane split.
|
||||
expect(request.issueCommand?.command).toBe('echo 42')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -19,7 +19,10 @@ import {
|
|||
} from '@/lib/launch-work-item-direct-preflight'
|
||||
import { agentLaunchCommandErrorMessage } from '@/lib/launch-work-item-direct-messages'
|
||||
import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed'
|
||||
import { renderIssueCommandTemplate } from '@/lib/new-workspace'
|
||||
import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner'
|
||||
import { readRuntimeIssueCommand } from '@/runtime/runtime-hooks-client'
|
||||
import { isGitRepoKind } from '../../../shared/repo-kind'
|
||||
import { getRepoExecutionHostId, parseExecutionHostId } from '../../../shared/execution-host'
|
||||
import { evaluateRuntimeCompat } from '../../../shared/protocol-compat'
|
||||
import {
|
||||
|
|
@ -50,8 +53,9 @@ type BackgroundGitHubWorkItemCreateDeps = {
|
|||
confirmHooks: (
|
||||
store: GitHubWorkItemBackgroundStoreSnapshot,
|
||||
repoId: string,
|
||||
scope: 'setup'
|
||||
scope: 'setup' | 'issueCommand'
|
||||
) => ReturnType<typeof ensureHooksConfirmed>
|
||||
readIssueCommand: typeof readRuntimeIssueCommand
|
||||
beginBackgroundCreate: typeof beginBackgroundWorktreePreparation
|
||||
continueBackgroundCreate: typeof continueBackgroundWorktreeCreation
|
||||
activatePendingCreate: (creationId: string) => void
|
||||
|
|
@ -78,8 +82,9 @@ const DEFAULT_DEPS: BackgroundGitHubWorkItemCreateDeps = {
|
|||
useAppStore.getState().activePendingCreationId === creationId,
|
||||
resolveSetupDecision: resolveDirectSetupDecision,
|
||||
resolvePrStartPoint: resolveDirectPrStartPoint,
|
||||
confirmHooks: (store, repoId, scope) =>
|
||||
confirmHooks: (store, repoId, scope: 'setup' | 'issueCommand') =>
|
||||
ensureHooksConfirmed(store as ReturnType<typeof useAppStore.getState>, repoId, scope),
|
||||
readIssueCommand: readRuntimeIssueCommand,
|
||||
beginBackgroundCreate: beginBackgroundWorktreePreparation,
|
||||
continueBackgroundCreate: continueBackgroundWorktreeCreation,
|
||||
activatePendingCreate: (creationId) => {
|
||||
|
|
@ -222,7 +227,10 @@ export async function createGitHubWorkItemWorkspaceInBackground(
|
|||
}
|
||||
}
|
||||
|
||||
const trustDecision = await deps.confirmHooks(store, args.repoId, 'setup')
|
||||
// Why: trust prompts are serialized app-wide, so read the store fresh at
|
||||
// each check — an "Always trust" stamped by an earlier prompt (including
|
||||
// this flow's own setup prompt) must short-circuit instead of re-prompting.
|
||||
const trustDecision = await deps.confirmHooks(deps.getStore(), args.repoId, 'setup')
|
||||
if (!deps.hasPendingCreate(creationId)) {
|
||||
return { kind: 'background-started' }
|
||||
}
|
||||
|
|
@ -246,6 +254,49 @@ export async function createGitHubWorkItemWorkspaceInBackground(
|
|||
}
|
||||
const backendStartup = buildGitHubWorkItemBackendStartup(agent, startupPlan, quickTelemetry)
|
||||
|
||||
// Why: mirror the composer's trust-gated issue-command split. Only GitHub
|
||||
// issues (numeric issue number) on git repos run it; PRs/Linear/folders never
|
||||
// do. Reuse the setup trust decision: a 'skip' there also skips the command.
|
||||
let issueCommand: WorktreeCreationRequest['issueCommand']
|
||||
// Why: a declined setup trust also skips the issue command, so short-circuit
|
||||
// before the (up-to-15s) read rather than reading just to drop the result.
|
||||
if (
|
||||
trustDecision !== 'skip' &&
|
||||
isGitRepoKind(repo) &&
|
||||
args.item.type === 'issue' &&
|
||||
typeof args.item.number === 'number'
|
||||
) {
|
||||
// Why: read failures fail closed (no command), so create still proceeds.
|
||||
let effectiveContent = ''
|
||||
try {
|
||||
const issueCommandRead = await deps.readIssueCommand(repoOwnerSettings, args.repoId)
|
||||
effectiveContent = (issueCommandRead.effectiveContent ?? '').trim()
|
||||
} catch {
|
||||
effectiveContent = ''
|
||||
}
|
||||
if (!deps.hasPendingCreate(creationId)) {
|
||||
return { kind: 'background-started' }
|
||||
}
|
||||
if (effectiveContent.length > 0) {
|
||||
const issueCommandTrust = await deps.confirmHooks(
|
||||
deps.getStore(),
|
||||
args.repoId,
|
||||
'issueCommand'
|
||||
)
|
||||
if (!deps.hasPendingCreate(creationId)) {
|
||||
return { kind: 'background-started' }
|
||||
}
|
||||
if (issueCommandTrust === 'run') {
|
||||
issueCommand = {
|
||||
command: renderIssueCommandTemplate(effectiveContent, {
|
||||
issueNumber: args.item.number,
|
||||
artifactUrl: args.item.url ?? null
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const request: WorktreeCreationRequest = {
|
||||
...initialRequest,
|
||||
...(baseBranch ? { baseBranch } : {}),
|
||||
|
|
@ -255,6 +306,7 @@ export async function createGitHubWorkItemWorkspaceInBackground(
|
|||
agent,
|
||||
...(branchNameOverride ? { branchNameOverride } : {}),
|
||||
...(backendStartup ? { startup: backendStartup } : {}),
|
||||
...(issueCommand ? { issueCommand } : {}),
|
||||
startupPlan,
|
||||
quickPrompt,
|
||||
quickTelemetry
|
||||
|
|
|
|||
|
|
@ -0,0 +1,77 @@
|
|||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useAppStore } from '@/store'
|
||||
import {
|
||||
queueHookCommandsForFirstWorktreeTab,
|
||||
resetHookCommandDelayedDeliveryForTests
|
||||
} from './hook-command-delayed-delivery'
|
||||
|
||||
type AppState = ReturnType<typeof useAppStore.getState>
|
||||
|
||||
const initialTabsByWorktree = useAppStore.getState().tabsByWorktree
|
||||
const initialGetKnownWorktreeById = useAppStore.getState().getKnownWorktreeById
|
||||
|
||||
function setStorePartial(partial: Record<string, unknown>): void {
|
||||
useAppStore.setState(partial as Partial<AppState>)
|
||||
}
|
||||
|
||||
function markWorktreeKnown(worktreeId: string): void {
|
||||
setStorePartial({
|
||||
getKnownWorktreeById: ((id: string) =>
|
||||
id === worktreeId ? { id } : undefined) as unknown as AppState['getKnownWorktreeById']
|
||||
})
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
resetHookCommandDelayedDeliveryForTests()
|
||||
setStorePartial({
|
||||
tabsByWorktree: initialTabsByWorktree,
|
||||
getKnownWorktreeById: initialGetKnownWorktreeById
|
||||
})
|
||||
})
|
||||
|
||||
describe('queueHookCommandsForFirstWorktreeTab', () => {
|
||||
it('holds the delivery until the first worktree tab lands, then delivers exactly once', () => {
|
||||
markWorktreeKnown('wt-1')
|
||||
setStorePartial({ tabsByWorktree: {} })
|
||||
const deliver = vi.fn()
|
||||
|
||||
queueHookCommandsForFirstWorktreeTab({ worktreeId: 'wt-1', deliver })
|
||||
expect(deliver).not.toHaveBeenCalled()
|
||||
|
||||
setStorePartial({ tabsByWorktree: { 'wt-1': [{ id: 'mirror-tab-1' }] } })
|
||||
|
||||
expect(deliver).toHaveBeenCalledTimes(1)
|
||||
expect(deliver).toHaveBeenCalledWith(expect.anything(), 'mirror-tab-1')
|
||||
|
||||
// Later tab churn must not re-deliver the consumed entry.
|
||||
setStorePartial({ tabsByWorktree: { 'wt-1': [{ id: 'mirror-tab-1' }, { id: 'tab-2' }] } })
|
||||
expect(deliver).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('delivers immediately when the worktree already has a tab at queue time', () => {
|
||||
markWorktreeKnown('wt-1')
|
||||
setStorePartial({ tabsByWorktree: { 'wt-1': [{ id: 'tab-1' }] } })
|
||||
const deliver = vi.fn()
|
||||
|
||||
queueHookCommandsForFirstWorktreeTab({ worktreeId: 'wt-1', deliver })
|
||||
|
||||
expect(deliver).toHaveBeenCalledTimes(1)
|
||||
expect(deliver).toHaveBeenCalledWith(expect.anything(), 'tab-1')
|
||||
})
|
||||
|
||||
it('drops the pending delivery when the worktree is no longer known', () => {
|
||||
setStorePartial({
|
||||
tabsByWorktree: {},
|
||||
getKnownWorktreeById: (() => undefined) as unknown as AppState['getKnownWorktreeById']
|
||||
})
|
||||
const deliver = vi.fn()
|
||||
|
||||
queueHookCommandsForFirstWorktreeTab({ worktreeId: 'wt-gone', deliver })
|
||||
|
||||
// A tab appearing later (e.g. an id reused by mirror churn) must not
|
||||
// deliver commands for a worktree that was dropped while unknown.
|
||||
markWorktreeKnown('wt-gone')
|
||||
setStorePartial({ tabsByWorktree: { 'wt-gone': [{ id: 'tab-1' }] } })
|
||||
expect(deliver).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
import { useAppStore } from '@/store'
|
||||
|
||||
type AppStoreSnapshot = ReturnType<typeof useAppStore.getState>
|
||||
|
||||
type PendingWorktreeHookCommandDelivery = {
|
||||
worktreeId: string
|
||||
deliver: (state: AppStoreSnapshot, firstTerminalTabId: string) => void
|
||||
}
|
||||
|
||||
// Why: runtime-owned worktrees mirror their session tabs asynchronously, so a
|
||||
// fresh create usually has no tab to queue setup/issue commands on yet. Hold
|
||||
// the delivery until the first mirrored terminal tab lands instead of
|
||||
// dropping it. Mirrors agent-startup-delayed-delivery's lazy-subscription
|
||||
// shape: subscribed only while something is pending.
|
||||
const pendingHookCommandDeliveries = new Map<string, PendingWorktreeHookCommandDelivery>()
|
||||
let unsubscribePendingHookCommandDeliveries: (() => void) | null = null
|
||||
|
||||
export function queueHookCommandsForFirstWorktreeTab(
|
||||
delivery: PendingWorktreeHookCommandDelivery
|
||||
): void {
|
||||
pendingHookCommandDeliveries.set(delivery.worktreeId, delivery)
|
||||
ensurePendingHookCommandSubscription()
|
||||
flushPendingHookCommandDeliveries()
|
||||
}
|
||||
|
||||
export function resetHookCommandDelayedDeliveryForTests(): void {
|
||||
pendingHookCommandDeliveries.clear()
|
||||
unsubscribePendingHookCommandDeliveries?.()
|
||||
unsubscribePendingHookCommandDeliveries = null
|
||||
}
|
||||
|
||||
function ensurePendingHookCommandSubscription(): void {
|
||||
if (unsubscribePendingHookCommandDeliveries) {
|
||||
return
|
||||
}
|
||||
unsubscribePendingHookCommandDeliveries = useAppStore.subscribe(() => {
|
||||
flushPendingHookCommandDeliveries()
|
||||
})
|
||||
}
|
||||
|
||||
function stopPendingHookCommandSubscriptionIfIdle(): void {
|
||||
if (pendingHookCommandDeliveries.size > 0 || !unsubscribePendingHookCommandDeliveries) {
|
||||
return
|
||||
}
|
||||
unsubscribePendingHookCommandDeliveries()
|
||||
unsubscribePendingHookCommandDeliveries = null
|
||||
}
|
||||
|
||||
function flushPendingHookCommandDeliveries(): void {
|
||||
const state = useAppStore.getState()
|
||||
for (const [worktreeId, delivery] of pendingHookCommandDeliveries) {
|
||||
const firstTerminalTabId = state.tabsByWorktree[worktreeId]?.[0]?.id
|
||||
if (!firstTerminalTabId) {
|
||||
// Why: a worktree can be removed before its tabs ever mirror; drop the
|
||||
// entry so the subscription does not stay armed forever.
|
||||
if (!state.getKnownWorktreeById(worktreeId)) {
|
||||
pendingHookCommandDeliveries.delete(worktreeId)
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Delete before delivering so store writes inside deliver cannot re-enter
|
||||
// this entry through the subscription.
|
||||
pendingHookCommandDeliveries.delete(worktreeId)
|
||||
delivery.deliver(state, firstTerminalTabId)
|
||||
}
|
||||
stopPendingHookCommandSubscriptionIfIdle()
|
||||
}
|
||||
|
|
@ -77,6 +77,9 @@ export type WorktreeCreationRequest = {
|
|||
* agent launch is self-contained; otherwise the renderer drives startup via
|
||||
* `startupPlan`. */
|
||||
startup?: WorktreeStartupLaunch
|
||||
/** Repo Custom GitHub Issue Command to run in a side-pane split after the
|
||||
* workspace's first terminal is created. Mirrors the composer's trust-gated issueCommand. */
|
||||
issueCommand?: { command: string; env?: Record<string, string> }
|
||||
pendingFirstAgentMessageRename: boolean
|
||||
/** Post-create note persisted as the worktree comment. */
|
||||
note: string
|
||||
|
|
|
|||
|
|
@ -2,8 +2,16 @@
|
|||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { SetupScriptLaunchMode } from '../../../shared/types'
|
||||
import { activateAndRevealWorktree, ensureWorktreeHasInitialTerminal } from './worktree-activation'
|
||||
import { resetHookCommandDelayedDeliveryForTests } from './hook-command-delayed-delivery'
|
||||
import { useAppStore } from '@/store'
|
||||
|
||||
type AppStoreState = ReturnType<typeof useAppStore.getState>
|
||||
|
||||
const initialTabsByWorktree = useAppStore.getState().tabsByWorktree
|
||||
const initialGetKnownWorktreeById = useAppStore.getState().getKnownWorktreeById
|
||||
const initialPendingIssueCommandSplitByTabId =
|
||||
useAppStore.getState().pendingIssueCommandSplitByTabId
|
||||
|
||||
function setSetupScriptLaunchMode(mode: SetupScriptLaunchMode | null): void {
|
||||
useAppStore.setState((state) => ({
|
||||
settings: state.settings
|
||||
|
|
@ -22,6 +30,12 @@ afterEach(() => {
|
|||
: ({ activeRuntimeEnvironmentId: null } as unknown as typeof state.settings)
|
||||
}))
|
||||
setSetupScriptLaunchMode('new-tab')
|
||||
resetHookCommandDelayedDeliveryForTests()
|
||||
useAppStore.setState({
|
||||
tabsByWorktree: initialTabsByWorktree,
|
||||
getKnownWorktreeById: initialGetKnownWorktreeById,
|
||||
pendingIssueCommandSplitByTabId: initialPendingIssueCommandSplitByTabId
|
||||
} as Partial<AppStoreState>)
|
||||
})
|
||||
|
||||
function createMockStore(overrides: Record<string, unknown> = {}) {
|
||||
|
|
@ -266,6 +280,40 @@ describe('ensureWorktreeHasInitialTerminal', () => {
|
|||
)
|
||||
})
|
||||
|
||||
it('holds the issue command for the first mirrored web runtime tab when none exists yet', () => {
|
||||
;(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ = true
|
||||
useAppStore.setState((state) => ({
|
||||
settings: state.settings
|
||||
? { ...state.settings, activeRuntimeEnvironmentId: 'web-runtime-1' }
|
||||
: ({ activeRuntimeEnvironmentId: 'web-runtime-1' } as unknown as typeof state.settings)
|
||||
}))
|
||||
useAppStore.setState({
|
||||
tabsByWorktree: {},
|
||||
getKnownWorktreeById: ((id: string) =>
|
||||
id === 'wt-1'
|
||||
? { id: 'wt-1' }
|
||||
: undefined) as unknown as AppStoreState['getKnownWorktreeById']
|
||||
} as Partial<AppStoreState>)
|
||||
const store = createMockStore()
|
||||
|
||||
const result = ensureWorktreeHasInitialTerminal(store, 'wt-1', undefined, undefined, {
|
||||
command: 'gh issue view 42'
|
||||
})
|
||||
|
||||
// Why: runtime session tabs mirror in asynchronously — the command must be
|
||||
// held for the first mirrored tab rather than silently dropped.
|
||||
expect(result).toBeNull()
|
||||
expect(useAppStore.getState().pendingIssueCommandSplitByTabId).toEqual({})
|
||||
|
||||
useAppStore.setState({
|
||||
tabsByWorktree: { 'wt-1': [{ id: 'mirror-tab-1' }] }
|
||||
} as unknown as Partial<AppStoreState>)
|
||||
|
||||
expect(useAppStore.getState().pendingIssueCommandSplitByTabId['mirror-tab-1']).toEqual({
|
||||
command: 'gh issue view 42'
|
||||
})
|
||||
})
|
||||
|
||||
it('creates a local initial terminal for explicitly local worktrees while a runtime is focused', () => {
|
||||
useAppStore.setState((state) => ({
|
||||
settings: state.settings
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ import {
|
|||
import { isTuiAgent } from '../../../shared/tui-agent-config'
|
||||
import { repoIsRemote } from '../../../shared/agent-launch-remote'
|
||||
import { resumeSleepingAgentSessionsForWorktree } from '@/lib/resume-sleeping-agent-session'
|
||||
import { queueHookCommandsForFirstWorktreeTab } from '@/lib/hook-command-delayed-delivery'
|
||||
import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context'
|
||||
import {
|
||||
getRuntimeEnvironmentIdForWorktree,
|
||||
|
|
@ -506,6 +507,24 @@ export function ensureWorktreeHasInitialTerminal(
|
|||
)
|
||||
return existingTerminalTabId
|
||||
}
|
||||
if (setup || issueCommand) {
|
||||
// Why: runtime-owned worktrees mirror their session tabs asynchronously,
|
||||
// so right after create there is usually no tab yet. Hold the commands
|
||||
// for the first mirrored tab instead of silently dropping them.
|
||||
queueHookCommandsForFirstWorktreeTab({
|
||||
worktreeId,
|
||||
deliver: (state, firstTerminalTabId) =>
|
||||
queueSetupAndIssueCommands(
|
||||
state,
|
||||
worktreeId,
|
||||
firstTerminalTabId,
|
||||
setup,
|
||||
issueCommand,
|
||||
wrappedSetupCommandStr,
|
||||
opts
|
||||
)
|
||||
})
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -504,6 +504,70 @@ describe('staged background worktree creation', () => {
|
|||
})
|
||||
})
|
||||
|
||||
// Why: one-click "Start workspace from issue" commonly backgrounds, so the
|
||||
// user-moved-on path is the common delivery for the repo's issue command; it
|
||||
// must thread through as the 5th positional arg, not be dropped to undefined.
|
||||
it('threads the request issue command into the background terminal seed', async () => {
|
||||
store.activeView = 'tasks'
|
||||
store.createWorktree.mockResolvedValueOnce({
|
||||
worktree: {
|
||||
id: 'wt-1',
|
||||
repoId: 'repo-1'
|
||||
}
|
||||
})
|
||||
|
||||
const started = continueBackgroundWorktreeCreation(
|
||||
'creation-1',
|
||||
makeRequest({ issueCommand: { command: 'gh issue view 42' } }),
|
||||
{ revealCreationSurface: false }
|
||||
)
|
||||
|
||||
expect(started).toBe(true)
|
||||
// Why: vi.waitFor instead of a fixed microtask flush — the await count in
|
||||
// executeWorktreeCreation grows over time (e.g. VM preflight), and a fixed
|
||||
// flush silently starves this assertion in merged builds.
|
||||
await vi.waitFor(() =>
|
||||
expect(ensureWorktreeHasInitialTerminal).toHaveBeenCalledWith(
|
||||
store,
|
||||
'wt-1',
|
||||
undefined,
|
||||
undefined,
|
||||
{ command: 'gh issue view 42' },
|
||||
undefined,
|
||||
{ activateCreatedTabs: false }
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
// Why: the still-watching path activates the worktree directly, so the issue
|
||||
// command must reach activateAndRevealWorktree too — both branches carry it.
|
||||
it('threads the request issue command into the active reveal', async () => {
|
||||
store.activeView = 'terminal'
|
||||
store.activePendingCreationId = 'creation-1'
|
||||
store.createWorktree.mockResolvedValueOnce({
|
||||
worktree: {
|
||||
id: 'wt-1',
|
||||
repoId: 'repo-1',
|
||||
path: '/repo/wt-1'
|
||||
}
|
||||
})
|
||||
vi.mocked(activateAndRevealWorktree).mockReturnValueOnce({ primaryTabId: 'tab-1' })
|
||||
|
||||
const started = continueBackgroundWorktreeCreation(
|
||||
'creation-1',
|
||||
makeRequest({ issueCommand: { command: 'gh issue view 42' } })
|
||||
)
|
||||
|
||||
expect(started).toBe(true)
|
||||
await vi.waitFor(() =>
|
||||
expect(activateAndRevealWorktree).toHaveBeenCalledWith(
|
||||
'wt-1',
|
||||
expect.objectContaining({ issueCommand: { command: 'gh issue view 42' } })
|
||||
)
|
||||
)
|
||||
expect(ensureWorktreeHasInitialTerminal).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('toasts a staged create error after the user leaves the creation surface', async () => {
|
||||
store.activeView = 'tasks'
|
||||
store.createWorktree.mockRejectedValueOnce(new Error('create failed'))
|
||||
|
|
|
|||
|
|
@ -225,7 +225,8 @@ async function executeWorktreeCreation(
|
|||
sidebarRevealBehavior: 'auto',
|
||||
...(result.setup ? { setup: result.setup } : {}),
|
||||
...(result.defaultTabs ? { defaultTabs: result.defaultTabs } : {}),
|
||||
...(startupOpt ? { startup: startupOpt } : {})
|
||||
...(startupOpt ? { startup: startupOpt } : {}),
|
||||
...(preparedRequest.issueCommand ? { issueCommand: preparedRequest.issueCommand } : {})
|
||||
})
|
||||
primaryTabId = activation === false ? null : activation.primaryTabId
|
||||
} else {
|
||||
|
|
@ -237,7 +238,7 @@ async function executeWorktreeCreation(
|
|||
worktree.id,
|
||||
startupOpt,
|
||||
result.setup,
|
||||
undefined,
|
||||
preparedRequest.issueCommand,
|
||||
result.defaultTabs,
|
||||
{ activateCreatedTabs: false }
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in New Issue