Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
235 changes: 56 additions & 179 deletions src/main/agent-launch/agent-launch-pane-attachment.test.ts
Original file line number Diff line number Diff line change
@@ -1,98 +1,78 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { AgentLaunchResult } from '../../shared/agent-launch-intent'
import {
pendingAgentSessionOperationRow,
type AgentSessionOperationOutcome,
type AgentSessionOperationRow
} from '../../shared/agent-session-operation-ledger'
import { afterEach, describe, expect, it } from 'vitest'
import {
forgetFailedAgentLaunches,
markAgentLaunchesClosedByUser,
resetAgentLaunchPanesForTests,
resolveAgentLaunchPaneVerdict,
trackRunningAgentLaunchPane,
type AgentLaunchPaneEvidence
trackRunningAgentLaunchPane
} from './agent-launch-pane-attachment'

const PANE = { worktreeId: 'wt-1', paneKey: 'tab-1:leaf-1' }
const NOW = Date.now()
let rowCounter = 0

function row(
outcome: AgentSessionOperationOutcome,
overrides: Partial<AgentSessionOperationRow> = {}
): AgentSessionOperationRow {
rowCounter += 1
return {
...pendingAgentSessionOperationRow({
callerKey: 'caller',
operationId: `${NOW}-${rowCounter.toString(16).padStart(32, '0')}`,
fingerprint: 'fp',
now: NOW
}),
outcome,
ownedPane: PANE,
...overrides
}
}

function terminalLaunch(paneKey: string): AgentLaunchResult {
return {
outcome: { kind: 'terminal', handle: 'term_1', paneKey },
worktreeId: 'wt-1',
receipt: { mode: 'terminal', preferred: 'terminal', reason: 'user_default', detail: '' }
}
}

const SUCCEEDED_HERE: AgentSessionOperationOutcome = {
status: 'succeeded',
sessionId: '',
launch: terminalLaunch(PANE.paneKey)
}

function evidence(
rows: AgentSessionOperationRow[] | null,
overrides: Partial<AgentLaunchPaneEvidence> = {}
): AgentLaunchPaneEvidence {
return {
isPaneLive: () => false,
openedRows: () => rows,
launchPaneOnTab: () => null,
openRows: async () => rows ?? [],
now: () => NOW,
...overrides
}
}

async function verdictFor(found: AgentLaunchPaneEvidence) {
return (await resolveAgentLaunchPaneVerdict(PANE, found)) ?? { kind: 'no-check' }
}
const notLive = (): boolean => false

describe('a pane an agent launch laid out', () => {
afterEach(() => {
resetAgentLaunchPanesForTests()
})

it('costs a pane nothing launches into not even a wait', () => {
expect(resolveAgentLaunchPaneVerdict(PANE, evidence([]))).toBeNull()
expect(
resolveAgentLaunchPaneVerdict(PANE, evidence([row({ status: 'failed', code: 'x' })]))
).not.toBeNull()
expect(resolveAgentLaunchPaneVerdict(PANE, evidence(null))).toBeNull()
it('is an ordinary pane when no launch runs for it, as after a restart', () => {
expect(resolveAgentLaunchPaneVerdict(PANE, notLive)).toBeNull()
})

it('waits for the launch running in this process, then reads how it ended off the record', async () => {
it('waits for the launch running in this process, then says how it ended', async () => {
const running = trackRunningAgentLaunchPane(PANE)
const rows: AgentSessionOperationRow[] = []
const verdict = resolveAgentLaunchPaneVerdict(PANE, evidence(rows))
const verdict = resolveAgentLaunchPaneVerdict(PANE, notLive)
expect(verdict).not.toBeNull()

rows.push(row({ status: 'failed', code: 'agent_session_exited_during_start' }))
running.finish({ tabTakenBack: false })
running.finish({ kind: 'not-started', code: 'agent_session_exited_during_start' })

await expect(verdict).resolves.toEqual({
kind: 'not-started',
code: 'agent_session_exited_during_start'
})
// Over, for this process: a later spawn of the pane gets the same answer, never a shell.
await expect(resolveAgentLaunchPaneVerdict(PANE, notLive)).resolves.toEqual({
kind: 'not-started',
code: 'agent_session_exited_during_start'
})
// After a restart nothing is left: the pane is an ordinary one.
resetAgentLaunchPanesForTests()
expect(resolveAgentLaunchPaneVerdict(PANE, notLive)).toBeNull()
})

it('forgets a failed launch once the user closes its pane, or a process holds it', async () => {
const pane = { worktreeId: 'wt-1', paneKey: 'tab-1:3f2504e0-4f89-41d3-9a0c-0305e82c3301' }
trackRunningAgentLaunchPane(pane).finish({ kind: 'not-started', code: 'boom' })
markAgentLaunchesClosedByUser('wt-1', { kind: 'tab', tabId: 'tab-1' })
expect(resolveAgentLaunchPaneVerdict(pane, notLive)).toBeNull()

trackRunningAgentLaunchPane(PANE).finish({ kind: 'unconfirmed' })
await expect(resolveAgentLaunchPaneVerdict(PANE, () => true)).resolves.toEqual({
kind: 'proceed'
})
expect(resolveAgentLaunchPaneVerdict(PANE, notLive)).toBeNull()
})

it('keeps no failure for a pane the user closed before its launch ended', () => {
const pane = { worktreeId: 'wt-1', paneKey: 'tab-1:3f2504e0-4f89-41d3-9a0c-0305e82c3301' }
const running = trackRunningAgentLaunchPane(pane)
markAgentLaunchesClosedByUser('wt-1', { kind: 'tab', tabId: 'tab-1' })
running.finish({ kind: 'not-started', code: 'agent_launch_tab_closed' })
expect(resolveAgentLaunchPaneVerdict(pane, notLive)).toBeNull()
})

it('forgets a failure once its pane moves to another tab or its workspace goes', () => {
const leafId = '3f2504e0-4f89-41d3-9a0c-0305e82c3301'
const moved = { worktreeId: 'wt-1', paneKey: `tab-1:${leafId}` }
const removed = { worktreeId: 'wt-2', paneKey: `tab-2:${leafId}` }
trackRunningAgentLaunchPane(moved).finish({ kind: 'unconfirmed' })
trackRunningAgentLaunchPane(removed).finish({ kind: 'not-started', code: 'boom' })

forgetFailedAgentLaunches('wt-1', { kind: 'pane', tabId: 'tab-1', leafId })
forgetFailedAgentLaunches('wt-2', null)

expect(resolveAgentLaunchPaneVerdict(moved, notLive)).toBeNull()
expect(resolveAgentLaunchPaneVerdict(removed, notLive)).toBeNull()
})

it("hears the user's close only while the pane's launch is running", () => {
Expand All @@ -115,113 +95,10 @@ describe('a pane an agent launch laid out', () => {
expect(running.closedByUser()).toBe(true)
})

it('never offers a shell in a tab the host is taking back', async () => {
it('attaches to a process that holds the pane, whatever the launch says', async () => {
const running = trackRunningAgentLaunchPane(PANE)
const verdict = resolveAgentLaunchPaneVerdict(PANE, evidence([]))
running.finish({ tabTakenBack: true })
await expect(verdict).resolves.toEqual({ kind: 'withdrawn' })
})

it('attaches to a process that holds the pane, whatever the record says', async () => {
const live = { isPaneLive: () => true }
await expect(verdictFor(evidence([row({ status: 'unknown' })], live))).resolves.toEqual({
kind: 'proceed'
})
await expect(
verdictFor(evidence([row({ status: 'failed', code: 'x' })], live))
).resolves.toEqual({ kind: 'proceed' })
})

it.each([
[
'a failed launch: not started, with its reason',
[row({ status: 'failed', code: 'boom' })],
{ kind: 'not-started', code: 'boom' }
],
[
'an outcome nobody knows: unconfirmed, never a failure',
[row({ status: 'unknown' })],
{ kind: 'unconfirmed' }
],
[
'a launch that ran into another surface: withdrawn',
[row({ status: 'succeeded', sessionId: 's-1', launch: terminalLaunch('other:pane') })],
{ kind: 'withdrawn' }
],
[
'an agent that ran here and is gone: an ordinary terminal',
[row(SUCCEEDED_HERE)],
{ kind: 'proceed' }
],
[
'an expired row owns nothing',
[row({ status: 'failed', code: 'boom' }, { expiresAt: NOW - 1 })],
{ kind: 'no-check' }
],
[
'a refused second launch never overrides the one that ran here',
[
row(SUCCEEDED_HERE, { recordedAt: NOW }),
row({ status: 'failed', code: 'agent_launch_pane_already_live' }, { recordedAt: NOW + 1 })
],
{ kind: 'proceed' }
],
[
'another pane of the same tab is not owned',
[
row(
{ status: 'failed', code: 'boom' },
{ ownedPane: { worktreeId: 'wt-1', paneKey: 'tab-1:leaf-2' } }
)
],
{ kind: 'no-check' }
]
] as const)('%s', async (_name, rows, expected) => {
await expect(verdictFor(evidence([...rows]))).resolves.toEqual(expected)
})

it('after a restart, opens the record for a pane whose launch was still open', async () => {
const restarted = evidence(null, {
launchPaneOnTab: () => ({}),
openRows: async () => [row({ status: 'unknown' })]
})
await expect(verdictFor(restarted)).resolves.toEqual({ kind: 'unconfirmed' })
})

it("answers from the tab once its pane's fate is final, without the record and past its expiry", async () => {
const openRows = vi.fn(async () => [])
const restarted = evidence(null, {
launchPaneOnTab: () => ({ outcome: { kind: 'not-started', code: 'boom' } }),
openRows
})
await expect(verdictFor(restarted)).resolves.toEqual({ kind: 'not-started', code: 'boom' })
expect(openRows).not.toHaveBeenCalled()
})

it('adopts a process its persisted binding names, even over a final "couldn\'t confirm"', async () => {
const survived = evidence(null, {
isPaneLive: () => true,
launchPaneOnTab: () => ({ outcome: { kind: 'unconfirmed' } })
})
await expect(verdictFor(survived)).resolves.toEqual({ kind: 'proceed' })
})

it('settles a pane no record names as an ordinary terminal, so its tab can forget the launch', async () => {
const orphaned = evidence(null, { launchPaneOnTab: () => ({}), openRows: async () => [] })
await expect(verdictFor(orphaned)).resolves.toEqual({ kind: 'proceed' })
})

it('reads nothing for a pane whose tab keeps nothing and no record row names', () => {
const openRows = vi.fn(async () => [])
expect(resolveAgentLaunchPaneVerdict(PANE, evidence(null, { openRows }))).toBeNull()
expect(openRows).not.toHaveBeenCalled()
})

it('leaves an ordinary terminal when the record cannot be read: bookkeeping never gates the pane', async () => {
const unreadable = evidence(null, {
launchPaneOnTab: () => ({}),
openRows: () => Promise.reject(new Error('journal_open_refused'))
})
await expect(verdictFor(unreadable)).resolves.toEqual({ kind: 'proceed' })
const verdict = resolveAgentLaunchPaneVerdict(PANE, () => true)
running.finish({ kind: 'not-started', code: 'boom' })
await expect(verdict).resolves.toEqual({ kind: 'proceed' })
})
})
Loading
Loading