1821 lines
93 KiB
TypeScript
1821 lines
93 KiB
TypeScript
import { access, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
|
|
import { join } from 'node:path'
|
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
import type { ForgeReadiness } from '@deepseek-ai/dsh-experimental-forge'
|
|
import { setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy'
|
|
import { appendPluginRecord, SessionId } from '@deepseek-ai/dsh-session'
|
|
import type { Session } from '@deepseek-ai/dsh-session'
|
|
import WorktreeService from '@deepseek-ai/dsh-experimental-worktree'
|
|
import ForgePanelController from '../src/index.ts'
|
|
import type { Config, ForgePanelState, ForgeSessionWorktree, ForgeWorktreeState } from '../src/index.ts'
|
|
import { WORKTREE_RECORD } from '../src/worktree.ts'
|
|
import { missingWorktree } from '../src/panel.ts'
|
|
import {
|
|
bench, branches, commitAt, demo, follow, git, hold, notice, prompt, proposeStep, pullUrl,
|
|
rejectionOf, repository, scratch, sendPrompt, shellTurn, snapshot, startStep, startTurn,
|
|
} from './harness.ts'
|
|
import type { Bench, BenchOptions, Follower } from './harness.ts'
|
|
|
|
const cleanups: (() => Promise<void>)[] = []
|
|
|
|
afterEach(async () => {
|
|
for (const cleanup of cleanups.splice(0).reverse()) await cleanup()
|
|
})
|
|
|
|
const signal = new AbortController().signal
|
|
|
|
async function project(): Promise<{ root: string; repo: string }> {
|
|
const { path, remove } = await scratch('dsh-forge-panel-')
|
|
cleanups.push(remove)
|
|
const repo = await repository(path)
|
|
await git(repo, 'remote', 'add', 'origin', 'https://fake.test/octo/demo.git')
|
|
return { root: path, repo }
|
|
}
|
|
|
|
/** Point `origin/HEAD` at the current `main`, as a clone of the code host would, so the branch scope has a fork point. */
|
|
async function withDefaultBranch(repo: string): Promise<void> {
|
|
await git(repo, 'update-ref', 'refs/remotes/origin/main', 'HEAD')
|
|
await git(repo, 'symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/main')
|
|
}
|
|
|
|
async function plain(): Promise<string> {
|
|
const { path, remove } = await scratch('dsh-forge-plain-')
|
|
cleanups.push(remove)
|
|
return path
|
|
}
|
|
|
|
async function open(cwd: string, config: Config = {}, options: BenchOptions = {}): Promise<Bench> {
|
|
const opened = await bench(cwd, config, options)
|
|
cleanups.push(() => opened.ctx.fiber.dispose())
|
|
return opened
|
|
}
|
|
|
|
function reader(opened: Bench): Follower {
|
|
const follower = follow(opened.ctx, opened.session)
|
|
cleanups.push(follower.stop)
|
|
return follower
|
|
}
|
|
|
|
const ready = (state: ForgePanelState): boolean => state.checkout.state === 'ready' && state.readiness !== null
|
|
|
|
/** Worktree facts of a Session without a worktree service, request, or creation, whose checkout can host a worktree. */
|
|
const UNAVAILABLE = {
|
|
editable: false, blocker: null, request: null, created: null, creation: null, failure: null, missing: null, restoration: null,
|
|
waiting: null,
|
|
}
|
|
|
|
/** Matches a generated branch name with the default prefix. */
|
|
const GENERATED_BRANCH: unknown = expect.stringMatching(/^dsh-[0-9a-f]{8}$/)
|
|
/** Abbreviated commit object name Git prints for HEAD. */
|
|
const COMMIT: unknown = expect.stringMatching(/^[0-9a-f]{7,}$/)
|
|
|
|
/** Matches the failure of an attempt whose base branch does not exist. */
|
|
const MISSING_BASE: unknown = expect.stringMatching(/^worktree command failed \(128\): fatal: /)
|
|
|
|
/** Whether the change request at `url` holds a snapshot. */
|
|
function polled(state: ForgePanelState, url: string): boolean {
|
|
return (state.changeRequests.find(entry => entry.url === url)?.snapshot ?? null) !== null
|
|
}
|
|
|
|
/** Whether the latest polling result of the change request at `url` is a failure. */
|
|
function failing(state: ForgePanelState, url: string): boolean {
|
|
const update = state.changeRequests.find(entry => entry.url === url)?.update ?? null
|
|
return update !== null && 'failure' in update
|
|
}
|
|
|
|
/** Settle after every queued microtask, so a pending stream read would have received any published state. */
|
|
async function settle(): Promise<void> {
|
|
await new Promise(resolve => setImmediate(resolve))
|
|
}
|
|
|
|
/** Texts of the prompts queued for the Agent's next turn, in order. */
|
|
function queued(opened: Bench): string[] {
|
|
return opened.agent.inbox.nextTurn.map(message => message.content.map(block => block.type === 'text' ? block.text : '').join(''))
|
|
}
|
|
|
|
function worktreeState(opened: Bench, session: Session = opened.session): ForgeWorktreeState {
|
|
return opened.worktreeStates.stateOf(session)
|
|
}
|
|
|
|
/**
|
|
* Request a worktree and send the first message, which waits until the worktree exists, as the Web Session Remote does.
|
|
* @param opened - composition with the worktree service.
|
|
* @param from - base branch, or null for HEAD.
|
|
* @returns the created worktree.
|
|
*/
|
|
async function startInWorktree(opened: Bench, from: string | null = null): Promise<ForgeSessionWorktree> {
|
|
await opened.ctx.forgePanel.requestWorktree(opened.session, from, signal)
|
|
sendPrompt(opened.agent, 'first')
|
|
await opened.agent.whenIdle()
|
|
const created = worktreeState(opened)?.created ?? null
|
|
if (created === null) throw new Error('the requested worktree was not recorded')
|
|
return created
|
|
}
|
|
|
|
/** Record a created worktree for a Session without creating it. */
|
|
function recordWorktree(session: Session, worktree: ForgeSessionWorktree): void {
|
|
appendPluginRecord(session, WORKTREE_RECORD, { action: 'request', from: null })
|
|
appendPluginRecord(session, WORKTREE_RECORD, { action: 'created', worktree })
|
|
}
|
|
|
|
/**
|
|
* Hold the Agent with other maintenance until the returned release settles it.
|
|
* @param opened - composition whose Agent is held.
|
|
* @returns the release, which waits for the held task to end.
|
|
*/
|
|
function occupy(opened: Bench): () => Promise<void> {
|
|
const other: PromiseWithResolvers<void> = Promise.withResolvers()
|
|
const running = opened.agent.runMaintenance(() => other.promise)
|
|
return async () => {
|
|
other.resolve()
|
|
await running
|
|
}
|
|
}
|
|
|
|
describe('forgePanel.follow', () => {
|
|
it('streams a pending state, then the checkout, recognized repository, and sign-in state', async () => {
|
|
const { repo } = await project()
|
|
const opened = await open(repo)
|
|
const follower = reader(opened)
|
|
|
|
const settled = await follower.until(ready)
|
|
|
|
expect(follower.states[0]).toEqual({
|
|
directory: repo,
|
|
checkout: { state: 'pending' },
|
|
repository: null,
|
|
readiness: null,
|
|
changeRequests: [],
|
|
worktree: UNAVAILABLE,
|
|
local: null,
|
|
})
|
|
expect(settled).toEqual({
|
|
directory: repo,
|
|
checkout: { state: 'ready', checkout: { root: repo, branch: 'main', linked: false, commit: COMMIT } },
|
|
repository: demo,
|
|
readiness: { state: 'ready', message: null },
|
|
changeRequests: [],
|
|
worktree: UNAVAILABLE,
|
|
// The remote was never fetched, so it names no default branch to fork from.
|
|
local: null,
|
|
})
|
|
expect(opened.provider.probes).toEqual(['fake.test'])
|
|
})
|
|
|
|
it('reinspects after tool results and lists bound change requests with their polling results', async () => {
|
|
const { repo } = await project()
|
|
const opened = await open(repo)
|
|
const follower = reader(opened)
|
|
await follower.until(ready)
|
|
|
|
await git(repo, 'checkout', '-b', 'feat')
|
|
shellTurn(opened.session, 1, [{ callId: 'create', command: 'gh pr create --fill', output: `${pullUrl(7)}\n` }])
|
|
|
|
const bound = await follower.until(state => state.checkout.state === 'ready' && state.checkout.checkout.branch === 'feat'
|
|
&& polled(state, pullUrl(7)))
|
|
const ref = { repository: demo, number: 7 }
|
|
expect(bound.changeRequests).toMatchObject([{ url: pullUrl(7), origin: 'created', ref, update: { snapshot: snapshot(ref) }, snapshot: snapshot(ref) }])
|
|
expect(opened.provider.probes).toEqual(['fake.test'])
|
|
})
|
|
|
|
it('binds the open change requests of the upstream branch once per remote and branch, and looks again on refresh', async () => {
|
|
const { repo } = await project()
|
|
await git(repo, 'checkout', '-b', 'feat')
|
|
await git(repo, 'update-ref', 'refs/remotes/origin/feat', 'HEAD')
|
|
await git(repo, 'branch', '--set-upstream-to=origin/feat')
|
|
const opened = await open(repo)
|
|
opened.provider.heads.set('feat', [7])
|
|
const follower = reader(opened)
|
|
const on = (name: string) => (state: ForgePanelState): boolean => state.checkout.state === 'ready' && state.checkout.checkout.branch === name
|
|
|
|
const bound = await follower.until(state => state.changeRequests.some(entry => entry.url === pullUrl(7)))
|
|
expect(bound.changeRequests).toMatchObject([{ url: pullUrl(7), origin: 'discovered' }])
|
|
expect(opened.provider.lookups).toEqual(['octo/demo feat'])
|
|
|
|
// Leaving for a branch without an upstream and coming back looks up nothing more.
|
|
await git(repo, 'checkout', '-b', 'other')
|
|
shellTurn(opened.session, 1, [{ callId: 'leave', command: 'git checkout -b other', output: '' }])
|
|
await follower.next(on('other'))
|
|
await git(repo, 'checkout', 'feat')
|
|
shellTurn(opened.session, 2, [{ callId: 'back', command: 'git checkout feat', output: '' }])
|
|
await follower.next(on('feat'))
|
|
expect(opened.provider.lookups).toEqual(['octo/demo feat'])
|
|
|
|
await opened.ctx.forgePanel.refresh(opened.session, signal)
|
|
await vi.waitFor(() => { expect(opened.provider.lookups).toEqual(['octo/demo feat', 'octo/demo feat']) })
|
|
})
|
|
|
|
it('looks up nothing for an upstream on a remote that no provider recognizes', async () => {
|
|
const { repo } = await project()
|
|
await git(repo, 'remote', 'add', 'mirror', 'https://example.invalid/octo/demo.git')
|
|
await git(repo, 'update-ref', 'refs/remotes/mirror/main', 'HEAD')
|
|
await git(repo, 'branch', '--set-upstream-to=mirror/main')
|
|
const opened = await open(repo)
|
|
opened.provider.heads.set('main', [7])
|
|
|
|
await reader(opened).until(ready)
|
|
expect(opened.provider.lookups).toEqual([])
|
|
})
|
|
|
|
it('keeps the latest snapshot of a change request while later polls fail', async () => {
|
|
const { repo } = await project()
|
|
const opened = await open(repo)
|
|
opened.provider.failures.set(8, { code: 'not-found', message: 'gone' })
|
|
shellTurn(opened.session, 1, [{ callId: 'create', command: 'gh pr create --fill', output: `${pullUrl(7)}\n` }])
|
|
await opened.ctx.forgePanel.track(opened.session, pullUrl(8), signal)
|
|
const follower = reader(opened)
|
|
const seven = { repository: demo, number: 7 }
|
|
|
|
const first = await follower.until(state => polled(state, pullUrl(7)) && failing(state, pullUrl(8)))
|
|
opened.provider.failures.set(7, { code: 'unavailable', message: 'offline' })
|
|
await opened.ctx.forgePanel.refresh(opened.session, signal)
|
|
const later = await follower.until(state => failing(state, pullUrl(7)))
|
|
|
|
expect(first.changeRequests).toMatchObject([
|
|
{ url: pullUrl(7), update: { snapshot: snapshot(seven) }, snapshot: snapshot(seven) },
|
|
{ url: pullUrl(8), update: { failure: { code: 'not-found', message: 'gone' } }, snapshot: null },
|
|
])
|
|
expect(later.changeRequests).toMatchObject([
|
|
{ url: pullUrl(7), update: { failure: { code: 'unavailable', message: 'offline' } }, snapshot: snapshot(seven) },
|
|
{ url: pullUrl(8), update: { failure: { code: 'not-found', message: 'gone' } }, snapshot: null },
|
|
])
|
|
})
|
|
|
|
it('reports a directory outside a checkout without a repository or sign-in probe, where no worktree can start', async () => {
|
|
const opened = await open(await plain())
|
|
const follower = reader(opened)
|
|
|
|
const state = await follower.until(next => next.checkout.state !== 'pending')
|
|
|
|
expect(state).toMatchObject({ checkout: { state: 'none' }, repository: null, readiness: null, worktree: { blocker: 'not-git' } })
|
|
expect(opened.provider.probes).toEqual([])
|
|
})
|
|
|
|
it.each([
|
|
['an Error', new Error('sandbox unavailable'), 'sandbox unavailable'],
|
|
['another value', 'denied', 'denied'],
|
|
])('reports a failed inspection from %s', async (_label, failure, message) => {
|
|
const { repo } = await project()
|
|
const opened = await open(repo)
|
|
opened.sandbox.failure = failure
|
|
const follower = reader(opened)
|
|
|
|
await expect(follower.until(next => next.checkout.state !== 'pending')).resolves.toMatchObject({ checkout: { state: 'failed', message } })
|
|
})
|
|
|
|
it.each([
|
|
['an Error', () => Promise.reject(new Error('gh missing')), 'gh missing'],
|
|
// oxlint-disable-next-line typescript/prefer-promise-reject-errors -- the probe must report a rejection that is not an Error
|
|
['another value', () => Promise.reject('offline'), 'offline'],
|
|
])('reports a failed sign-in probe from %s as unavailable', async (_label, impl: () => Promise<ForgeReadiness>, message) => {
|
|
const { repo } = await project()
|
|
const opened = await open(repo)
|
|
opened.provider.readinessImpl = impl
|
|
const follower = reader(opened)
|
|
|
|
await expect(follower.until(ready)).resolves.toMatchObject({ readiness: { state: 'unavailable', message } })
|
|
})
|
|
|
|
it('times out a sign-in probe', async () => {
|
|
const { repo } = await project()
|
|
const opened = await open(repo, { readinessTimeoutMs: 50 })
|
|
opened.provider.readinessImpl = (_host, abort) => new Promise((_resolve, reject) => {
|
|
abort.addEventListener('abort', () => { reject(abort.reason as Error) })
|
|
})
|
|
const follower = reader(opened)
|
|
|
|
await expect(follower.until(ready)).resolves.toMatchObject({
|
|
readiness: { state: 'unavailable', message: 'FORGE_PANEL_READINESS_TIMEOUT after 50ms' },
|
|
})
|
|
})
|
|
|
|
it('probes again when the recognized host changes and drops a probe for a host it left', async () => {
|
|
const { repo } = await project()
|
|
const opened = await open(repo)
|
|
const held = Promise.withResolvers<ForgeReadiness>()
|
|
opened.provider.readinessImpl = host => host === 'fake.test' ? held.promise : Promise.resolve({ state: 'unauthenticated', message: 'sign in' })
|
|
const follower = reader(opened)
|
|
await follower.until(state => state.repository?.host === 'fake.test')
|
|
|
|
await git(repo, 'remote', 'set-url', 'origin', 'https://other.test/octo/demo.git')
|
|
shellTurn(opened.session, 1, [{ callId: 'list', command: 'ls', output: '' }])
|
|
const moved = await follower.until(state => state.repository?.host === 'other.test' && state.readiness !== null)
|
|
held.resolve({ state: 'ready', message: null })
|
|
await Promise.resolve()
|
|
|
|
expect(moved.readiness).toEqual({ state: 'unauthenticated', message: 'sign in' })
|
|
expect(opened.provider.probes).toEqual(['fake.test', 'other.test'])
|
|
shellTurn(opened.session, 2, [{ callId: 'again', command: 'ls', output: '' }])
|
|
await follower.next(state => state.checkout.state === 'ready')
|
|
expect(follower.states.at(-1)?.readiness).toEqual({ state: 'unauthenticated', message: 'sign in' })
|
|
expect(opened.provider.probes).toEqual(['fake.test', 'other.test'])
|
|
})
|
|
|
|
it('drops a probe result after the directory leaves every repository', async () => {
|
|
const { root, repo } = await project()
|
|
const outside = join(root, 'plain')
|
|
await mkdir(outside)
|
|
const opened = await open(repo)
|
|
const held = Promise.withResolvers<ForgeReadiness>()
|
|
opened.provider.readinessImpl = () => held.promise
|
|
const follower = reader(opened)
|
|
await follower.until(state => state.repository !== null)
|
|
|
|
await opened.ctx.workingDirectory.set(opened.agent, outside)
|
|
await follower.until(state => state.directory === outside && state.checkout.state === 'none')
|
|
held.resolve({ state: 'ready', message: null })
|
|
await new Promise(resolve => setTimeout(resolve, 20))
|
|
|
|
expect(follower.states.at(-1)).toMatchObject({ directory: outside, repository: null, readiness: null })
|
|
})
|
|
|
|
it('drops an inspection for a directory the Session left and inspects the new one', async () => {
|
|
const { repo } = await project()
|
|
const linked = join(repo, '.agents', 'worktrees', 'feat')
|
|
await git(repo, 'worktree', 'add', '-b', 'feat', linked)
|
|
const opened = await open(repo)
|
|
const gate: PromiseWithResolvers<void> = Promise.withResolvers()
|
|
opened.sandbox.gate = () => gate.promise
|
|
const follower = reader(opened)
|
|
await follower.until(state => state.checkout.state === 'pending')
|
|
await vi.waitFor(() => { expect(opened.sandbox.calls).toHaveLength(1) })
|
|
|
|
await opened.ctx.workingDirectory.set(opened.agent, linked)
|
|
opened.sandbox.gate = undefined
|
|
gate.resolve()
|
|
const moved = await follower.until(state => state.checkout.state === 'ready')
|
|
|
|
expect(moved.directory).toBe(linked)
|
|
expect(moved.checkout).toEqual({ state: 'ready', checkout: { root: linked, branch: 'feat', linked: true, commit: COMMIT } })
|
|
expect(follower.states.every(state => state.checkout.state !== 'ready' || state.directory === linked)).toBe(true)
|
|
})
|
|
|
|
it('shares one panel among followers and starts a fresh one after the last leaves', async () => {
|
|
const { repo } = await project()
|
|
const opened = await open(repo)
|
|
shellTurn(opened.session, 1, [{ callId: 'create', command: 'gh pr create --fill', output: `${pullUrl(7)}\n` }])
|
|
const first = reader(opened)
|
|
const second = reader(opened)
|
|
await first.until(state => polled(state, pullUrl(7)))
|
|
await second.until(ready)
|
|
const ref = { repository: demo, number: 7 }
|
|
|
|
await first.stop()
|
|
expect(opened.ctx.forge.latest(ref)).toBeDefined()
|
|
await second.stop()
|
|
expect(opened.ctx.forge.latest(ref)).toBeUndefined()
|
|
|
|
const third = reader(opened)
|
|
await expect(third.until(() => true)).resolves.toMatchObject({ checkout: { state: 'pending' } })
|
|
})
|
|
|
|
it('ends followers whose signal aborts while waiting and when the Remote stops', async () => {
|
|
const { repo } = await project()
|
|
const opened = await open(repo)
|
|
const controller = new AbortController()
|
|
const iterator = opened.ctx.forgePanel.follow(opened.session, controller.signal)[Symbol.asyncIterator]()
|
|
let state = await iterator.next()
|
|
while (state.done !== true && !ready(state.value)) state = await iterator.next()
|
|
const waiting = iterator.next()
|
|
controller.abort()
|
|
await expect(waiting).resolves.toEqual({ done: true, value: undefined })
|
|
|
|
const follower = reader(opened)
|
|
await follower.until(ready)
|
|
const ending = expect(follower.until(() => false)).rejects.toThrow('panel stream ended')
|
|
await opened.panel.dispose()
|
|
await ending
|
|
})
|
|
|
|
it('waits for an in-flight sign-in probe when the Remote stops, then drops its result', async () => {
|
|
const { repo } = await project()
|
|
const opened = await open(repo)
|
|
const held = Promise.withResolvers<ForgeReadiness>()
|
|
opened.provider.readinessImpl = () => held.promise
|
|
const follower = reader(opened)
|
|
await follower.until(state => state.repository !== null)
|
|
const ending = expect(follower.next(() => true)).rejects.toThrow('panel stream ended')
|
|
let stopped = false
|
|
|
|
const stopping = opened.panel.dispose().then(() => { stopped = true })
|
|
await settle()
|
|
expect(stopped).toBe(false)
|
|
held.resolve({ state: 'ready', message: null })
|
|
await stopping
|
|
|
|
await ending
|
|
expect(follower.states.at(-1)).toMatchObject({ repository: demo, readiness: null })
|
|
})
|
|
|
|
it('ends at once for a signal aborted before reading', async () => {
|
|
const { repo } = await project()
|
|
const opened = await open(repo)
|
|
const controller = new AbortController()
|
|
controller.abort()
|
|
|
|
const iterator = opened.ctx.forgePanel.follow(opened.session, controller.signal)[Symbol.asyncIterator]()
|
|
|
|
await expect(iterator.next()).resolves.toEqual({ done: true, value: undefined })
|
|
})
|
|
|
|
it('shows no repository when no provider serves the remotes', async () => {
|
|
const { repo } = await project()
|
|
await git(repo, 'remote', 'set-url', 'origin', 'https://unknown.example/octo/demo.git')
|
|
const opened = await open(repo)
|
|
const follower = reader(opened)
|
|
|
|
await expect(follower.until(state => state.checkout.state === 'ready')).resolves.toMatchObject({ repository: null, readiness: null })
|
|
expect(opened.provider.probes).toEqual([])
|
|
})
|
|
|
|
it('skips a queued inspection once the last follower has left', async () => {
|
|
const { repo } = await project()
|
|
const opened = await open(repo)
|
|
const gate: PromiseWithResolvers<void> = Promise.withResolvers()
|
|
opened.sandbox.gate = () => gate.promise
|
|
const follower = reader(opened)
|
|
await follower.until(state => state.checkout.state === 'pending')
|
|
await vi.waitFor(() => { expect(opened.sandbox.calls).toHaveLength(1) })
|
|
shellTurn(opened.session, 1, [{ callId: 'list', command: 'ls', output: '' }])
|
|
|
|
await follower.stop()
|
|
opened.sandbox.gate = undefined
|
|
gate.resolve()
|
|
await new Promise(resolve => setTimeout(resolve, 50))
|
|
|
|
expect(opened.sandbox.calls).toHaveLength(1)
|
|
})
|
|
})
|
|
|
|
describe('forgePanel local changes', () => {
|
|
it('reports filter configuration overflow separately and recovers after it is reduced', async () => {
|
|
const { repo } = await project()
|
|
await withDefaultBranch(repo)
|
|
await writeFile(join(repo, 'tracked.txt'), 'changed\n')
|
|
const key = `filter.${'long'.repeat(200)}.clean`
|
|
await git(repo, 'config', key, 'cat')
|
|
const opened = await open(repo, { maxDiffBytes: 512, maxOutputBytes: 512 })
|
|
const follower = reader(opened)
|
|
|
|
await expect(follower.until(ready)).resolves.toMatchObject({ checkout: { state: 'ready' }, local: null })
|
|
await expect(opened.ctx.forgePanel.localDiff(opened.session, { kind: 'unstaged' }, signal))
|
|
.rejects.toMatchObject({ code: 'forgePanel/filter-config-failed' })
|
|
|
|
await git(repo, 'config', '--unset', key)
|
|
await opened.ctx.forgePanel.refresh(opened.session, signal)
|
|
await expect(follower.until(state => state.local !== null)).resolves.toMatchObject({ local: { files: [{ path: 'tracked.txt' }] } })
|
|
await expect(opened.ctx.forgePanel.localDiff(opened.session, { kind: 'unstaged' }, signal))
|
|
.resolves.toMatchObject({ files: [{ path: 'tracked.txt' }] })
|
|
})
|
|
|
|
it('measures the branch scope at each inspection', async () => {
|
|
const { repo } = await project()
|
|
await withDefaultBranch(repo)
|
|
const opened = await open(repo)
|
|
const follower = reader(opened)
|
|
expect((await follower.until(ready)).local).toEqual({ base: 'origin/main', files: [] })
|
|
|
|
await git(repo, 'checkout', '--quiet', '-b', 'feat')
|
|
await writeFile(join(repo, 'notes.md'), 'hello\n')
|
|
shellTurn(opened.session, 1, [{ callId: 'write', command: 'echo hello > notes.md', output: '' }])
|
|
|
|
await expect(follower.until(state => state.local?.files.length === 1)).resolves.toMatchObject({
|
|
local: { base: 'origin/main', files: [{ path: 'notes.md', additions: 1, deletions: 0, binary: false }] },
|
|
})
|
|
})
|
|
|
|
it('leaves the size unknown when measuring it fails', async () => {
|
|
const { repo } = await project()
|
|
await withDefaultBranch(repo)
|
|
await writeFile(join(repo, 'notes.md'), 'hello\n')
|
|
const opened = await open(repo, { maxDiffBytes: 1 })
|
|
|
|
await expect(reader(opened).until(ready)).resolves.toMatchObject({ checkout: { state: 'ready' }, local: null })
|
|
})
|
|
|
|
it('reads the changes of a scope and the new side of a file', async () => {
|
|
const { repo } = await project()
|
|
await withDefaultBranch(repo)
|
|
await writeFile(join(repo, 'tracked.txt'), 'initial\nmore\n')
|
|
await writeFile(join(repo, 'notes.md'), 'hello\n')
|
|
const opened = await open(repo)
|
|
|
|
const diff = await opened.ctx.forgePanel.localDiff(opened.session, { kind: 'branch' }, signal)
|
|
expect(diff.base).toBe('origin/main')
|
|
expect(diff.files.map(file => [file.path, file.status, file.untracked])).toEqual([['tracked.txt', 'modified', false], ['notes.md', 'added', true]])
|
|
await expect(opened.ctx.forgePanel.localDiff(opened.session, { kind: 'staged' }, signal)).resolves.toEqual({ base: null, files: [], omitted: 0 })
|
|
await expect(opened.ctx.forgePanel.localText(opened.session, { kind: 'unstaged' }, 'tracked.txt', signal)).resolves.toBe('initial\nmore\n')
|
|
})
|
|
|
|
it('reports a directory outside a checkout, a branch without a fork point, and a diff over the limit', async () => {
|
|
const outside = await open(await plain())
|
|
await expect(outside.ctx.forgePanel.localDiff(outside.session, { kind: 'unstaged' }, signal)).rejects.toMatchObject({ code: 'forgePanel/not-a-checkout' })
|
|
await expect(outside.ctx.forgePanel.localText(outside.session, { kind: 'unstaged' }, 'a.txt', signal))
|
|
.rejects.toMatchObject({ code: 'forgePanel/not-a-checkout' })
|
|
|
|
const { repo } = await project()
|
|
await writeFile(join(repo, 'tracked.txt'), 'x\n'.repeat(2000))
|
|
const opened = await open(repo, { maxDiffBytes: 1024 })
|
|
await expect(opened.ctx.forgePanel.localDiff(opened.session, { kind: 'branch' }, signal)).rejects.toMatchObject({ code: 'forgePanel/no-fork-point' })
|
|
await expect(opened.ctx.forgePanel.localDiff(opened.session, { kind: 'unstaged' }, signal)).rejects.toMatchObject({ code: 'forgePanel/diff-too-large' })
|
|
await expect(opened.ctx.forgePanel.localText(opened.session, { kind: 'unstaged' }, 'tracked.txt', signal))
|
|
.rejects.toMatchObject({ code: 'forgePanel/diff-too-large' })
|
|
await expect(opened.ctx.forgePanel.localText(opened.session, { kind: 'unstaged' }, '../outside.txt', signal))
|
|
.rejects.toThrow('is not a path inside the checkout')
|
|
})
|
|
})
|
|
|
|
describe('forgePanel complete checks', () => {
|
|
it('reads only visible bindings and refuses a different head without changing polling state', async () => {
|
|
const { repo } = await project()
|
|
const opened = await open(repo)
|
|
shellTurn(opened.session, 1, [{ callId: 'create', command: 'gh pr create --fill', output: `${pullUrl(7)}\n` }])
|
|
await reader(opened).until(state => ready(state) && polled(state, pullUrl(7)))
|
|
const target = opened.provider.changeRequestFromUrl(pullUrl(7))!
|
|
const original = snapshot(target)
|
|
const complete = vi.fn(async () => original)
|
|
Object.assign(opened.provider, { fetchChecks: complete })
|
|
await expect(opened.ctx.forgePanel.changeRequestChecks(opened.session, pullUrl(7), original.headSha, signal)).resolves.toEqual(original)
|
|
await expect(opened.ctx.forgePanel.changeRequestChecks(opened.session, pullUrl(8), original.headSha, signal))
|
|
.rejects.toMatchObject({ code: 'forgePanel/not-bound' })
|
|
expect(complete).toHaveBeenCalledTimes(1)
|
|
await expect(opened.ctx.forgePanel.changeRequestChecks(opened.session, pullUrl(7), 'b'.repeat(40), signal))
|
|
.rejects.toMatchObject({ code: 'forgePanel/stale-head' })
|
|
Object.assign(opened.provider, { fetchChecks: async () => ({ code: 'too-large', message: 'Checks exceed limit' }) })
|
|
await expect(opened.ctx.forgePanel.changeRequestChecks(opened.session, pullUrl(7), original.headSha, signal))
|
|
.rejects.toMatchObject({ code: 'forgePanel/checks-failed', message: 'Checks exceed limit' })
|
|
})
|
|
})
|
|
|
|
describe('forgePanel change request diffs', () => {
|
|
const PATCH = 'diff --git a/src/a.ts b/src/a.ts\n--- a/src/a.ts\n+++ b/src/a.ts\n@@ -1 +1 @@\n-old\n+new\n'
|
|
|
|
it('reads the diff at the head of the latest status, with file pages and the commits the checkout has beyond it', async () => {
|
|
const { repo } = await project()
|
|
await git(repo, 'checkout', '--quiet', '-b', 'feat')
|
|
const head = await git(repo, 'rev-parse', 'HEAD')
|
|
await commitAt(repo, 'later.txt', '2026-03-01T00:00:00Z')
|
|
const opened = await open(repo)
|
|
opened.provider.overrides.set(7, { headSha: head, headBranch: 'feat' })
|
|
opened.provider.patches.set(7, PATCH)
|
|
opened.provider.texts.set(`${head}:src/a.ts`, 'new\n')
|
|
shellTurn(opened.session, 1, [{ callId: 'create', command: 'gh pr create --fill', output: `${pullUrl(7)}\n` }])
|
|
await reader(opened).until(state => polled(state, pullUrl(7)))
|
|
|
|
const diff = await opened.ctx.forgePanel.changeRequestDiff(opened.session, pullUrl(7), signal)
|
|
expect(diff).toMatchObject({ headSha: head, localAhead: 1, fileUrls: { 'src/a.ts': `${pullUrl(7)}/files/${head}/src/a.ts` } })
|
|
expect(diff.files.map(file => [file.path, file.additions, file.deletions])).toEqual([['src/a.ts', 1, 1]])
|
|
await expect(opened.ctx.forgePanel.changeRequestText(opened.session, pullUrl(7), head, 'src/a.ts', signal)).resolves.toBe('new\n')
|
|
})
|
|
|
|
it('counts no commits beyond the head when Git cannot answer', async () => {
|
|
const { repo } = await project()
|
|
const opened = await open(repo)
|
|
opened.provider.patches.set(7, PATCH)
|
|
shellTurn(opened.session, 1, [{ callId: 'create', command: 'gh pr create --fill', output: `${pullUrl(7)}\n` }])
|
|
await reader(opened).until(state => polled(state, pullUrl(7)))
|
|
opened.sandbox.failure = new Error('sandbox unavailable')
|
|
|
|
await expect(opened.ctx.forgePanel.changeRequestDiff(opened.session, pullUrl(7), signal)).resolves.toMatchObject({ localAhead: null })
|
|
})
|
|
|
|
it('downloads a diff once per head commit and base branch for every Session, keeping no failed read', async () => {
|
|
const { repo } = await project()
|
|
const opened = await open(repo)
|
|
shellTurn(opened.session, 1, [{ callId: 'create', command: 'gh pr create --fill', output: `${pullUrl(7)}\n` }])
|
|
const follower = reader(opened)
|
|
await follower.until(state => polled(state, pullUrl(7)))
|
|
const paths = async (session: Session = opened.session): Promise<string[]> =>
|
|
(await opened.ctx.forgePanel.changeRequestDiff(session, pullUrl(7), signal)).files.map(file => file.path)
|
|
const moved = async (snapshot: { headSha: string; baseBranch: string }): Promise<void> => {
|
|
opened.provider.overrides.set(7, snapshot)
|
|
await opened.ctx.forgePanel.refresh(opened.session, signal)
|
|
await follower.until(state => state.changeRequests[0]?.snapshot?.baseBranch === snapshot.baseBranch
|
|
&& state.changeRequests[0].snapshot.headSha === snapshot.headSha)
|
|
}
|
|
|
|
await expect(paths()).rejects.toMatchObject({ code: 'forgePanel/diff-failed', message: 'no diff of #7' })
|
|
opened.provider.patches.set(7, PATCH)
|
|
await expect(paths()).resolves.toEqual(['src/a.ts'])
|
|
opened.provider.patches.set(7, PATCH.replaceAll('src/a.ts', 'src/b.ts'))
|
|
await expect(paths()).resolves.toEqual(['src/a.ts'])
|
|
const other = opened.ctx.sessions.create(SessionId('forge-panel-other'), { meta: { cwd: repo } })
|
|
opened.ctx.forgeSession.track(other, pullUrl(7))
|
|
await expect(paths(other)).resolves.toEqual(['src/a.ts'])
|
|
expect(opened.provider.diffs).toEqual([7, 7])
|
|
|
|
await moved({ headSha: 'b'.repeat(40), baseBranch: 'main' })
|
|
await expect(paths()).resolves.toEqual(['src/b.ts'])
|
|
await moved({ headSha: 'b'.repeat(40), baseBranch: 'release' })
|
|
opened.provider.patches.set(7, PATCH)
|
|
await expect(paths()).resolves.toEqual(['src/a.ts'])
|
|
expect(opened.provider.diffs).toEqual([7, 7, 7, 7])
|
|
})
|
|
|
|
it('reports a diff or file over a size limit as too large', async () => {
|
|
const { repo } = await project()
|
|
const opened = await open(repo)
|
|
const head = 'a'.repeat(40)
|
|
opened.provider.patches.set(7, { code: 'too-large', message: 'the diff exceeded the maximum number of files (300)' })
|
|
opened.provider.texts.set(`${head}:big.bin`, { code: 'too-large', message: 'big.bin is too large to fetch' })
|
|
shellTurn(opened.session, 1, [{ callId: 'create', command: 'gh pr create --fill', output: `${pullUrl(7)}\n` }])
|
|
await reader(opened).until(state => polled(state, pullUrl(7)))
|
|
|
|
await expect(opened.ctx.forgePanel.changeRequestDiff(opened.session, pullUrl(7), signal))
|
|
.rejects.toMatchObject({ code: 'forgePanel/diff-too-large', message: 'the diff exceeded the maximum number of files (300)' })
|
|
await expect(opened.ctx.forgePanel.changeRequestText(opened.session, pullUrl(7), head, 'big.bin', signal))
|
|
.rejects.toMatchObject({ code: 'forgePanel/diff-too-large', message: 'big.bin is too large to fetch' })
|
|
})
|
|
|
|
it('reports an unbound change request, one not read yet, and a diff or file the host does not provide', async () => {
|
|
const { repo } = await project()
|
|
const opened = await open(repo)
|
|
opened.provider.failures.set(8, { code: 'unavailable', message: 'down' })
|
|
shellTurn(opened.session, 1, [
|
|
{ callId: 'seven', command: 'gh pr create --fill', output: `${pullUrl(7)}\n` },
|
|
{ callId: 'eight', command: 'gh pr create --fill', output: `${pullUrl(8)}\n` },
|
|
])
|
|
await reader(opened).until(state => polled(state, pullUrl(7)) && failing(state, pullUrl(8)))
|
|
|
|
await expect(opened.ctx.forgePanel.changeRequestDiff(opened.session, pullUrl(9), signal)).rejects.toMatchObject({ code: 'forgePanel/not-bound' })
|
|
await expect(opened.ctx.forgePanel.changeRequestDiff(opened.session, pullUrl(8), signal))
|
|
.rejects.toMatchObject({ code: 'forgePanel/diff-failed', message: 'The change request status has not been read yet' })
|
|
await expect(opened.ctx.forgePanel.changeRequestDiff(opened.session, pullUrl(7), signal))
|
|
.rejects.toMatchObject({ code: 'forgePanel/diff-failed', message: 'no diff of #7' })
|
|
await expect(opened.ctx.forgePanel.changeRequestText(opened.session, pullUrl(7), 'a'.repeat(40), 'gone.ts', signal))
|
|
.rejects.toMatchObject({ code: 'forgePanel/diff-failed', message: `no gone.ts at ${'a'.repeat(40)}` })
|
|
})
|
|
})
|
|
|
|
describe('forgePanel worktree blockers', () => {
|
|
it('recovers checkout inspection when the Session switches to full access on a host without a sandbox', async () => {
|
|
const { repo } = await project()
|
|
const opened = await open(repo)
|
|
opened.sandbox.failure = new Error('sandbox unavailable')
|
|
const follower = reader(opened)
|
|
await follower.until(state => state.checkout.state === 'failed')
|
|
|
|
setSandboxMode(opened.session, 'danger-full-access')
|
|
const full = await follower.next(ready)
|
|
expect(full.checkout).toMatchObject({ state: 'ready', checkout: { root: repo, branch: 'main' } })
|
|
|
|
setSandboxMode(opened.session, 'workspace-write')
|
|
await expect(follower.next(state => state.checkout.state === 'failed')).resolves.toMatchObject({
|
|
checkout: { state: 'failed', message: 'sandbox unavailable' },
|
|
})
|
|
})
|
|
|
|
it('reinspects when the sandbox mode changes, and blocks a worktree while the Session is read-only', async () => {
|
|
const { repo } = await project()
|
|
const opened = await open(repo)
|
|
const follower = reader(opened)
|
|
await follower.until(ready)
|
|
|
|
setSandboxMode(opened.session, 'read-only')
|
|
const readOnly = await follower.next(state => state.worktree.blocker !== null)
|
|
setSandboxMode(opened.session, 'workspace-write')
|
|
const writable = await follower.next(state => state.worktree.blocker !== 'read-only')
|
|
|
|
expect(readOnly.worktree).toEqual({ ...UNAVAILABLE, blocker: 'read-only' })
|
|
expect(readOnly.checkout).toEqual({ state: 'ready', checkout: { root: repo, branch: 'main', linked: false, commit: COMMIT } })
|
|
expect(writable.worktree).toEqual(UNAVAILABLE)
|
|
})
|
|
|
|
it.each([
|
|
['a linked worktree whose shared Git directory lies outside it', async (root: string, repo: string): Promise<string> => {
|
|
const linked = join(root, 'linked')
|
|
await git(repo, 'worktree', 'add', '-b', 'feat', linked)
|
|
return linked
|
|
}],
|
|
['a subdirectory of its checkout', async (_root: string, repo: string): Promise<string> => {
|
|
const nested = join(repo, 'src')
|
|
await mkdir(nested)
|
|
return nested
|
|
}],
|
|
])('blocks a worktree for a Session rooted in %s until the Session gets full access', async (_label, place) => {
|
|
const { root, repo } = await project()
|
|
const opened = await open(await place(root, repo))
|
|
const follower = reader(opened)
|
|
|
|
const blocked = await follower.until(ready)
|
|
setSandboxMode(opened.session, 'danger-full-access')
|
|
const unblocked = await follower.next(state => state.worktree.blocker !== 'outside-writable-root')
|
|
|
|
expect(blocked.repository).toEqual(demo)
|
|
expect(blocked.worktree.blocker).toBe('outside-writable-root')
|
|
expect(unblocked.worktree.blocker).toBeNull()
|
|
})
|
|
|
|
it('shows no blocker after an inspection fails', async () => {
|
|
const opened = await open(await plain())
|
|
const follower = reader(opened)
|
|
await follower.until(state => state.worktree.blocker === 'not-git')
|
|
|
|
opened.sandbox.failure = new Error('sandbox unavailable')
|
|
await opened.ctx.forgePanel.refresh(opened.session, signal)
|
|
|
|
await expect(follower.next(state => state.checkout.state === 'failed')).resolves.toMatchObject({ worktree: { blocker: null } })
|
|
})
|
|
})
|
|
|
|
describe('forgePanel worktree requests', () => {
|
|
it('publishes worktree requests and withdrawals until the first turn starts', async () => {
|
|
const { repo } = await project()
|
|
const opened = await open(repo, {}, { worktrees: true })
|
|
const follower = reader(opened)
|
|
await follower.until(ready)
|
|
|
|
await opened.ctx.forgePanel.requestWorktree(opened.session, null, signal)
|
|
const requested = await follower.next(() => true)
|
|
await opened.ctx.forgePanel.requestWorktree(opened.session, 'main', signal)
|
|
const replaced = await follower.next(() => true)
|
|
await opened.ctx.forgePanel.cancelWorktree(opened.session, signal)
|
|
const withdrawn = await follower.next(() => true)
|
|
startTurn(opened.session, 1)
|
|
const started = await follower.next(() => true)
|
|
|
|
const editable = { ...UNAVAILABLE, editable: true }
|
|
expect(follower.states[0]?.worktree).toEqual(editable)
|
|
expect(requested.worktree).toEqual({ ...editable, request: { from: null } })
|
|
expect(replaced.worktree).toEqual({ ...editable, request: { from: 'main' } })
|
|
expect(withdrawn.worktree).toEqual(editable)
|
|
expect(started.worktree).toEqual(UNAVAILABLE)
|
|
expect(opened.records).toEqual([{ action: 'request', from: null }, { action: 'request', from: 'main' }, { action: 'cancel' }])
|
|
})
|
|
|
|
it('withdraws only an open request', async () => {
|
|
const { repo } = await project()
|
|
const opened = await open(repo, {}, { worktrees: true })
|
|
|
|
await opened.ctx.forgePanel.cancelWorktree(opened.session, signal)
|
|
await opened.ctx.forgePanel.requestWorktree(opened.session, 'main', signal)
|
|
await opened.ctx.forgePanel.cancelWorktree(opened.session, signal)
|
|
await opened.ctx.forgePanel.cancelWorktree(opened.session, signal)
|
|
|
|
expect(opened.records).toEqual([{ action: 'request', from: 'main' }, { action: 'cancel' }])
|
|
})
|
|
|
|
it('refuses worktree changes after the first turn starts', async () => {
|
|
const { repo } = await project()
|
|
const opened = await open(repo, {}, { worktrees: true })
|
|
await opened.ctx.forgePanel.requestWorktree(opened.session, null, signal)
|
|
startTurn(opened.session, 1)
|
|
|
|
await expect(opened.ctx.forgePanel.requestWorktree(opened.session, 'main', signal)).rejects.toMatchObject({ code: 'forgePanel/session-started' })
|
|
await expect(opened.ctx.forgePanel.cancelWorktree(opened.session, signal)).rejects.toMatchObject({ code: 'forgePanel/session-started' })
|
|
expect(opened.records).toEqual([{ action: 'request', from: null }])
|
|
})
|
|
|
|
it('refuses worktree changes once the worktree exists, before any turn starts', async () => {
|
|
const { repo } = await project()
|
|
const opened = await open(repo, {}, { worktrees: true })
|
|
await opened.ctx.forgePanel.requestWorktree(opened.session, null, signal)
|
|
await proposeStep(opened.ctx, opened.agent, { turn: 1, step: 1, messages: [prompt('go')], decision: { kind: 'reject' } })
|
|
|
|
await expect(opened.ctx.forgePanel.requestWorktree(opened.session, 'main', signal)).rejects.toMatchObject({ code: 'forgePanel/session-started' })
|
|
await expect(opened.ctx.forgePanel.cancelWorktree(opened.session, signal)).rejects.toMatchObject({ code: 'forgePanel/session-started' })
|
|
expect(worktreeState(opened)).toMatchObject({ request: { from: null }, locked: false, stepped: false, restoring: false })
|
|
expect(opened.records).toHaveLength(2)
|
|
})
|
|
|
|
it('refuses worktree requests and restoration without a worktree service, and keeps a waiting message for the withdrawal', async () => {
|
|
const { repo } = await project()
|
|
const opened = await open(repo)
|
|
|
|
await expect(opened.ctx.forgePanel.requestWorktree(opened.session, null, signal)).rejects.toMatchObject({ code: 'forgePanel/worktrees-unavailable' })
|
|
expect(opened.records).toEqual([])
|
|
appendPluginRecord(opened.session, WORKTREE_RECORD, { action: 'request', from: null })
|
|
const follower = reader(opened)
|
|
sendPrompt(opened.agent, 'first')
|
|
const failed = await follower.until(state => state.worktree.failure !== null)
|
|
expect(failed.worktree.failure).toEqual({ branch: null, from: null, phase: 'prepare', message: 'No worktree service is mounted' })
|
|
// Withdrawing needs no worktree service, so the waiting message can go on.
|
|
await opened.ctx.forgePanel.cancelWorktree(opened.session, signal)
|
|
await opened.agent.whenIdle()
|
|
expect(opened.agent.wakes).toBe(1)
|
|
|
|
const worktree: ForgeSessionWorktree = {
|
|
path: join(repo, '.agents', 'worktrees', 'dsh', '0a1b2c3d'), branch: 'dsh/0a1b2c3d', from: null, baseCommit: 'c'.repeat(40), repositoryRoot: repo,
|
|
}
|
|
recordWorktree(opened.session, worktree)
|
|
await expect(opened.ctx.forgePanel.restoreWorktree(opened.session, signal)).rejects.toMatchObject({ code: 'forgePanel/worktrees-unavailable' })
|
|
await expect(access(worktree.path)).rejects.toMatchObject({ code: 'ENOENT' })
|
|
})
|
|
|
|
it('rejects an empty base branch', async () => {
|
|
const { repo } = await project()
|
|
const opened = await open(repo, {}, { worktrees: true })
|
|
|
|
await expect(opened.ctx.forgePanel.requestWorktree(opened.session, '', signal)).rejects.toMatchObject({ code: 'forgePanel/invalid-branch' })
|
|
expect(opened.records).toEqual([])
|
|
})
|
|
|
|
it('follows the Session into the worktree created at its first step', async () => {
|
|
const { repo } = await project()
|
|
const opened = await open(repo, {}, { worktrees: true })
|
|
const follower = reader(opened)
|
|
await follower.until(ready)
|
|
await opened.ctx.forgePanel.requestWorktree(opened.session, null, signal)
|
|
|
|
startTurn(opened.session, 1)
|
|
await proposeStep(opened.ctx, opened.agent, { turn: 1, step: 1, messages: [prompt('Fix the bug')] })
|
|
startStep(opened.session, 1)
|
|
|
|
const created = worktreeState(opened)?.created ?? null
|
|
expect(created).not.toBeNull()
|
|
const moved = await follower.until(state => state.directory === created?.path && state.checkout.state === 'ready' && state.worktree.created !== null)
|
|
expect(moved).toEqual({
|
|
directory: created?.path,
|
|
checkout: { state: 'ready', checkout: { root: created?.path, branch: created?.branch, linked: true, commit: COMMIT } },
|
|
repository: demo,
|
|
readiness: { state: 'ready', message: null },
|
|
changeRequests: [],
|
|
worktree: { ...UNAVAILABLE, request: { from: null }, created },
|
|
local: { base: created?.baseCommit.slice(0, 7), files: [] },
|
|
})
|
|
expect(opened.provider.probes).toEqual(['fake.test'])
|
|
})
|
|
|
|
it('publishes worktree facts only when they change', async () => {
|
|
const { repo } = await project()
|
|
const opened = await open(repo)
|
|
const follower = reader(opened)
|
|
await follower.until(ready)
|
|
appendPluginRecord(opened.session, WORKTREE_RECORD, { action: 'request', from: null })
|
|
await follower.next(state => state.worktree.request !== null)
|
|
const pending = follower.next(() => true)
|
|
let published = false
|
|
void pending.then(() => { published = true }, () => undefined)
|
|
|
|
startTurn(opened.session, 1)
|
|
appendPluginRecord(opened.session, WORKTREE_RECORD, { action: 'request', from: '' })
|
|
appendPluginRecord(opened.session, WORKTREE_RECORD, { action: 'request', from: 'main' })
|
|
await settle()
|
|
expect(published).toBe(false)
|
|
|
|
const worktree: ForgeSessionWorktree = { path: join(repo, 'elsewhere'), branch: 'dsh/0a1b2c3d', from: null, baseCommit: 'c'.repeat(40), repositoryRoot: repo }
|
|
appendPluginRecord(opened.session, WORKTREE_RECORD, { action: 'created', worktree })
|
|
await expect(pending).resolves.toMatchObject({ worktree: { ...UNAVAILABLE, request: { from: null }, created: worktree } })
|
|
|
|
const later = follower.next(() => true)
|
|
let republished = false
|
|
void later.then(() => { republished = true }, () => undefined)
|
|
startStep(opened.session, 1)
|
|
appendPluginRecord(opened.session, WORKTREE_RECORD, { action: 'created', worktree: { ...worktree, branch: 'dsh/ffffffff' } })
|
|
await settle()
|
|
expect(republished).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('forgePanel worktree creation while a message waits', () => {
|
|
it('creates the requested worktree as Agent maintenance while the first message waits in the inbox, showing each step, then starts its turn', async () => {
|
|
const { repo } = await project()
|
|
const opened = await open(repo, {}, { worktrees: true })
|
|
const follower = reader(opened)
|
|
await follower.until(ready)
|
|
await opened.ctx.forgePanel.requestWorktree(opened.session, null, signal)
|
|
const preparing = hold(opened.sandbox, 'check-ref-format')
|
|
const checkingOut = hold(opened.sandbox, 'reset')
|
|
|
|
sendPrompt(opened.agent, 'first')
|
|
const prepare = await follower.until(state => state.worktree.creation?.phase === 'prepare')
|
|
expect(() => opened.agent.runMaintenance(() => Promise.resolve())).toThrow('already has active work')
|
|
expect(queued(opened)).toEqual(['first'])
|
|
expect(opened.agent.wakes).toBe(0)
|
|
preparing.release()
|
|
const checkout = await follower.until(state => state.worktree.creation?.phase === 'checkout')
|
|
checkingOut.release()
|
|
await opened.agent.whenIdle()
|
|
const created = worktreeState(opened)?.created ?? null
|
|
const moved = await follower.until(state => state.directory === created?.path && state.checkout.state === 'ready' && state.worktree.waiting === null)
|
|
|
|
const branch = created?.branch ?? ''
|
|
const running = { branch, from: null, held: true }
|
|
const waiting = { ...UNAVAILABLE, editable: true, request: { from: null }, waiting: 'creation' }
|
|
expect(prepare.worktree).toEqual({ ...waiting, creation: { ...running, phase: 'prepare' } })
|
|
expect(checkout.worktree).toEqual({ ...waiting, creation: { ...running, phase: 'checkout' } })
|
|
expect(moved.worktree).toEqual({ ...UNAVAILABLE, request: { from: null }, created })
|
|
expect(moved.checkout).toEqual({ state: 'ready', checkout: { root: created?.path, branch, linked: true, commit: COMMIT } })
|
|
expect(created).toEqual({
|
|
path: join(repo, '.agents', 'worktrees', ...branch.split('/')),
|
|
branch: GENERATED_BRANCH,
|
|
from: null,
|
|
baseCommit: await git(repo, 'rev-parse', 'HEAD'),
|
|
repositoryRoot: repo,
|
|
})
|
|
expect(opened.agent.wakes).toBe(1)
|
|
expect(opened.ctx.workingDirectory.get(opened.session)).toBe(created?.path)
|
|
expect(opened.agent.inbox.nextStep).toEqual([notice(created?.path ?? '')])
|
|
expect(queued(opened)).toEqual(['first'])
|
|
expect(opened.records).toEqual([{ action: 'request', from: null }, { action: 'created', worktree: created }])
|
|
})
|
|
|
|
it('keeps the message waiting with the failure the panel shows, and tries again with a new branch when asked', async () => {
|
|
const { repo } = await project()
|
|
const opened = await open(repo, {}, { worktrees: true })
|
|
const follower = reader(opened)
|
|
await follower.until(ready)
|
|
await opened.ctx.forgePanel.requestWorktree(opened.session, 'later', signal)
|
|
|
|
sendPrompt(opened.agent, 'first')
|
|
const failed = await follower.until(state => state.worktree.failure !== null && state.worktree.creation === null)
|
|
expect(opened.agent.wakes).toBe(0)
|
|
await git(repo, 'branch', 'later')
|
|
await opened.ctx.forgePanel.retryWorktree(opened.session, signal)
|
|
const retrying = await follower.next(state => state.worktree.creation !== null)
|
|
await opened.agent.whenIdle()
|
|
|
|
expect(failed.worktree).toEqual({
|
|
...UNAVAILABLE, editable: true, request: { from: 'later' }, waiting: 'creation',
|
|
failure: { branch: GENERATED_BRANCH, from: 'later', phase: 'prepare', message: MISSING_BASE },
|
|
})
|
|
expect(retrying.worktree.failure).toBeNull()
|
|
const created = worktreeState(opened)?.created ?? null
|
|
expect(created).toMatchObject({ from: 'later', baseCommit: await git(repo, 'rev-parse', 'later') })
|
|
expect(created?.branch).not.toBe(failed.worktree.failure?.branch)
|
|
expect(opened.agent.wakes).toBe(1)
|
|
expect(queued(opened)).toEqual(['first'])
|
|
expect(opened.records).toEqual([{ action: 'request', from: 'later' }, { action: 'created', worktree: created }])
|
|
})
|
|
|
|
it('keeps the message waiting when the request changes, and lets it go on in the current checkout once the request is withdrawn', async () => {
|
|
const { repo } = await project()
|
|
const opened = await open(repo, {}, { worktrees: true })
|
|
const follower = reader(opened)
|
|
await follower.until(ready)
|
|
await opened.ctx.forgePanel.requestWorktree(opened.session, 'later', signal)
|
|
sendPrompt(opened.agent, 'first')
|
|
await follower.until(state => state.worktree.failure !== null)
|
|
|
|
await opened.ctx.forgePanel.requestWorktree(opened.session, 'gone', signal)
|
|
const changed = await follower.next(state => state.worktree.request?.from === 'gone')
|
|
await opened.ctx.forgePanel.retryWorktree(opened.session, signal)
|
|
const failedAgain = await follower.next(state => state.worktree.failure !== null)
|
|
const withdrawal = follower.next(state => state.worktree.request === null && state.worktree.waiting === null)
|
|
await opened.ctx.forgePanel.cancelWorktree(opened.session, signal)
|
|
await opened.agent.whenIdle()
|
|
const withdrawn = await withdrawal
|
|
|
|
expect(changed.worktree).toEqual({ ...UNAVAILABLE, editable: true, request: { from: 'gone' }, waiting: 'creation' })
|
|
expect(failedAgain.worktree.failure).toMatchObject({ from: 'gone' })
|
|
expect(withdrawn.worktree).toEqual({ ...UNAVAILABLE, editable: true })
|
|
expect(opened.agent.wakes).toBe(1)
|
|
expect(queued(opened)).toEqual(['first'])
|
|
expect(opened.agent.inbox.nextStep).toEqual([])
|
|
expect(opened.ctx.workingDirectory.get(opened.session)).toBe(repo)
|
|
expect(opened.records).toEqual([{ action: 'request', from: 'later' }, { action: 'request', from: 'gone' }, { action: 'cancel' }])
|
|
})
|
|
|
|
it('stops the running attempt, and the message keeps waiting with the request open until another attempt', async () => {
|
|
const { repo } = await project()
|
|
const opened = await open(repo, {}, { worktrees: true })
|
|
const follower = reader(opened)
|
|
await follower.until(ready)
|
|
await opened.ctx.forgePanel.requestWorktree(opened.session, null, signal)
|
|
await opened.ctx.forgePanel.stopCreation(opened.session, signal)
|
|
const held = hold(opened.sandbox, 'check-ref-format')
|
|
|
|
sendPrompt(opened.agent, 'first')
|
|
await held.reached
|
|
const stopping = follower.next(state => state.worktree.waiting === 'creation' && state.worktree.creation === null)
|
|
await opened.ctx.forgePanel.stopCreation(opened.session, signal)
|
|
held.release()
|
|
const stopped = await stopping
|
|
const before = { wakes: opened.agent.wakes, records: [...opened.records], cwd: opened.ctx.workingDirectory.get(opened.session) }
|
|
await opened.ctx.forgePanel.retryWorktree(opened.session, signal)
|
|
await opened.agent.whenIdle()
|
|
|
|
expect(stopped.worktree).toEqual({ ...UNAVAILABLE, editable: true, request: { from: null }, waiting: 'creation' })
|
|
expect(before).toEqual({ wakes: 0, records: [{ action: 'request', from: null }], cwd: repo })
|
|
expect(worktreeState(opened)?.created).toMatchObject({ from: null, repositoryRoot: repo })
|
|
expect(opened.agent.wakes).toBe(1)
|
|
})
|
|
|
|
it('makes no attempt when the request is withdrawn right after another attempt was asked for', async () => {
|
|
const { repo } = await project()
|
|
const opened = await open(repo, {}, { worktrees: true })
|
|
const follower = reader(opened)
|
|
await follower.until(ready)
|
|
await opened.ctx.forgePanel.requestWorktree(opened.session, 'later', signal)
|
|
sendPrompt(opened.agent, 'first')
|
|
await follower.until(state => state.worktree.failure !== null)
|
|
await git(repo, 'branch', 'later')
|
|
|
|
// Both decisions arrive before the wait resumes, so the next attempt reads the withdrawn request.
|
|
await Promise.all([
|
|
opened.ctx.forgePanel.retryWorktree(opened.session, signal),
|
|
opened.ctx.forgePanel.cancelWorktree(opened.session, signal),
|
|
])
|
|
await opened.agent.whenIdle()
|
|
|
|
expect(opened.agent.wakes).toBe(1)
|
|
expect(opened.records).toEqual([{ action: 'request', from: 'later' }, { action: 'cancel' }])
|
|
expect(opened.ctx.workingDirectory.get(opened.session)).toBe(repo)
|
|
})
|
|
|
|
it('refuses another attempt while no message waits or an attempt runs', async () => {
|
|
const { repo } = await project()
|
|
const opened = await open(repo, {}, { worktrees: true })
|
|
await expect(opened.ctx.forgePanel.retryWorktree(opened.session, signal)).rejects.toMatchObject({ code: 'forgePanel/not-waiting' })
|
|
await opened.ctx.forgePanel.requestWorktree(opened.session, null, signal)
|
|
const held = hold(opened.sandbox, 'check-ref-format')
|
|
|
|
sendPrompt(opened.agent, 'first')
|
|
await held.reached
|
|
await expect(opened.ctx.forgePanel.retryWorktree(opened.session, signal)).rejects.toMatchObject({ code: 'forgePanel/not-waiting' })
|
|
held.release()
|
|
await opened.agent.whenIdle()
|
|
|
|
expect(worktreeState(opened)?.created).not.toBeNull()
|
|
expect(opened.agent.wakes).toBe(1)
|
|
})
|
|
|
|
it('ends the wait and drops the waiting messages when the Agent is cancelled', async () => {
|
|
const { repo } = await project()
|
|
const opened = await open(repo, {}, { worktrees: true })
|
|
const follower = reader(opened)
|
|
await follower.until(ready)
|
|
await opened.ctx.forgePanel.requestWorktree(opened.session, 'later', signal)
|
|
sendPrompt(opened.agent, 'first')
|
|
await follower.until(state => state.worktree.failure !== null)
|
|
|
|
const ending = follower.next(state => state.worktree.waiting === null)
|
|
opened.agent.cancel({ kind: 'user' })
|
|
await opened.agent.whenIdle()
|
|
const ended = await ending
|
|
|
|
expect(ended.worktree).toEqual({ ...UNAVAILABLE, editable: true, request: { from: 'later' } })
|
|
expect(opened.agent.wakes).toBe(0)
|
|
expect(queued(opened)).toEqual([])
|
|
expect(worktreeState(opened)).toMatchObject({ request: { from: 'later' }, created: null })
|
|
})
|
|
|
|
it('lets the waiting message go on when the panel stops', async () => {
|
|
const { repo } = await project()
|
|
const opened = await open(repo, {}, { worktrees: true })
|
|
await opened.ctx.forgePanel.requestWorktree(opened.session, 'later', signal)
|
|
sendPrompt(opened.agent, 'first')
|
|
await vi.waitFor(() => { expect(() => opened.agent.runMaintenance(() => Promise.resolve())).toThrow('already has active work') })
|
|
|
|
await opened.panel.dispose()
|
|
await opened.agent.whenIdle()
|
|
|
|
expect(opened.agent.wakes).toBe(1)
|
|
expect(queued(opened)).toEqual(['first'])
|
|
expect(worktreeState(opened)).toMatchObject({ request: { from: 'later' }, created: null })
|
|
})
|
|
|
|
it('holds one wait for every message that arrives during it', async () => {
|
|
const { repo } = await project()
|
|
const opened = await open(repo, {}, { worktrees: true })
|
|
await opened.ctx.forgePanel.requestWorktree(opened.session, null, signal)
|
|
const held = hold(opened.sandbox, 'check-ref-format')
|
|
|
|
sendPrompt(opened.agent, 'first')
|
|
await held.reached
|
|
sendPrompt(opened.agent, 'second')
|
|
held.release()
|
|
await opened.agent.whenIdle()
|
|
|
|
expect(opened.agent.wakes).toBe(1)
|
|
expect(queued(opened)).toEqual(['first', 'second'])
|
|
expect(opened.records).toHaveLength(2)
|
|
})
|
|
|
|
it('leaves a message that arrives while the Agent has other work to the first step', async () => {
|
|
const { repo } = await project()
|
|
const opened = await open(repo, {}, { worktrees: true })
|
|
const follower = reader(opened)
|
|
await follower.until(ready)
|
|
await opened.ctx.forgePanel.requestWorktree(opened.session, null, signal)
|
|
await follower.until(state => state.worktree.request !== null)
|
|
const release = occupy(opened)
|
|
|
|
sendPrompt(opened.agent, 'first')
|
|
await settle()
|
|
const busy = follower.states.at(-1)
|
|
await release()
|
|
|
|
expect(busy?.worktree).toEqual({ ...UNAVAILABLE, editable: true, request: { from: null } })
|
|
expect(opened.agent.wakes).toBe(1)
|
|
expect(worktreeState(opened)).toMatchObject({ request: { from: null }, created: null })
|
|
expect(await branches(repo)).toEqual(['main'])
|
|
})
|
|
|
|
it('lets a message go on without a wait once the first turn started, leaving the worktree to the first step', async () => {
|
|
const { repo } = await project()
|
|
const opened = await open(repo, {}, { worktrees: true })
|
|
await opened.ctx.forgePanel.requestWorktree(opened.session, null, signal)
|
|
startTurn(opened.session, 1)
|
|
|
|
sendPrompt(opened.agent, 'steer')
|
|
await opened.agent.whenIdle()
|
|
|
|
expect(opened.agent.wakes).toBe(1)
|
|
expect(queued(opened)).toEqual(['steer'])
|
|
expect(worktreeState(opened)).toMatchObject({ request: { from: null }, created: null, locked: true })
|
|
expect(await branches(repo)).toEqual(['main'])
|
|
})
|
|
})
|
|
|
|
describe('forgePanel worktree requests during a creation', () => {
|
|
it('refuses to change or withdraw the request while the worktree is being created, so the creation is recorded', async () => {
|
|
const { repo } = await project()
|
|
const opened = await open(repo, {}, { worktrees: true })
|
|
await opened.ctx.forgePanel.requestWorktree(opened.session, null, signal)
|
|
const held = hold(opened.sandbox, 'check-ref-format')
|
|
|
|
sendPrompt(opened.agent, 'first')
|
|
await held.reached
|
|
await expect(opened.ctx.forgePanel.cancelWorktree(opened.session, signal)).rejects.toMatchObject({ code: 'forgePanel/session-busy' })
|
|
await expect(opened.ctx.forgePanel.requestWorktree(opened.session, 'main', signal))
|
|
.rejects.toMatchObject({ code: 'forgePanel/session-busy' })
|
|
held.release()
|
|
await opened.agent.whenIdle()
|
|
|
|
const created = worktreeState(opened)?.created ?? null
|
|
expect(created).toMatchObject({ from: null, repositoryRoot: repo })
|
|
expect(opened.ctx.workingDirectory.get(opened.session)).toBe(created?.path)
|
|
expect(opened.records).toEqual([{ action: 'request', from: null }, { action: 'created', worktree: created }])
|
|
})
|
|
})
|
|
|
|
describe('forgePanel removed worktrees', () => {
|
|
it('shows a removed worktree whose branch remains, and holds the next message until the branch is checked out again', async () => {
|
|
const { repo } = await project()
|
|
const opened = await open(repo, {}, { worktrees: true })
|
|
const follower = reader(opened)
|
|
await follower.until(ready)
|
|
const created = await startInWorktree(opened)
|
|
const tip = await commitAt(created.path, 'later.txt', '2026-05-01T00:00:00Z')
|
|
await follower.until(state => state.directory === created.path && state.checkout.state === 'ready')
|
|
await rm(created.path, { recursive: true, force: true })
|
|
|
|
sendPrompt(opened.agent, 'next')
|
|
const missing = await follower.until(state => state.worktree.waiting === 'restoration')
|
|
expect(opened.agent.wakes).toBe(1)
|
|
const pruning = hold(opened.sandbox, 'list')
|
|
const resetting = hold(opened.sandbox, 'reset')
|
|
const restoring = opened.ctx.forgePanel.restoreWorktree(opened.session, signal)
|
|
await pruning.reached
|
|
expect(() => opened.agent.runMaintenance(() => Promise.resolve())).toThrow('already has active work')
|
|
const preparing = await follower.until(state => state.worktree.restoration?.phase === 'prepare')
|
|
pruning.release()
|
|
await resetting.reached
|
|
const checkingOut = await follower.until(state => state.worktree.restoration?.phase === 'checkout')
|
|
const restoration = follower.next(state => state.worktree.missing === null && state.worktree.waiting === null && state.checkout.state === 'ready')
|
|
resetting.release()
|
|
await restoring
|
|
await opened.agent.whenIdle()
|
|
const restored = await restoration
|
|
|
|
expect(missing.worktree).toEqual({ ...UNAVAILABLE, request: { from: null }, created, missing: { branchExists: true }, waiting: 'restoration' })
|
|
expect(preparing.worktree).toMatchObject({ missing: { branchExists: true }, restoration: { phase: 'prepare' }, waiting: 'restoration' })
|
|
expect(checkingOut.worktree.restoration).toEqual({ phase: 'checkout' })
|
|
expect(restored).toMatchObject({ directory: created.path, checkout: { state: 'ready', checkout: { root: created.path, branch: created.branch } } })
|
|
expect(restored.worktree).toEqual({ ...UNAVAILABLE, request: { from: null }, created })
|
|
expect(await git(created.path, 'rev-parse', 'HEAD')).toBe(tip)
|
|
expect(await readFile(join(created.path, 'later.txt'), 'utf8')).toBe('later.txt\n')
|
|
expect(opened.ctx.workingDirectory.get(opened.session)).toBe(created.path)
|
|
expect(opened.agent.wakes).toBe(2)
|
|
expect(queued(opened)).toEqual(['first', 'next'])
|
|
})
|
|
|
|
it('shows a removed worktree whose branch is gone, and creates the branch again at its base commit', async () => {
|
|
const { repo } = await project()
|
|
const opened = await open(repo, {}, { worktrees: true })
|
|
const follower = reader(opened)
|
|
await follower.until(ready)
|
|
const created = await startInWorktree(opened)
|
|
await commitAt(created.path, 'later.txt', '2026-05-01T00:00:00Z')
|
|
await follower.until(state => state.directory === created.path && state.checkout.state === 'ready')
|
|
await rm(created.path, { recursive: true, force: true })
|
|
sendPrompt(opened.agent, 'next')
|
|
await follower.until(state => state.worktree.missing?.branchExists === true)
|
|
await git(repo, 'worktree', 'prune')
|
|
await git(repo, 'branch', '-D', created.branch)
|
|
|
|
await opened.ctx.forgePanel.refresh(opened.session, signal)
|
|
const gone = await follower.until(state => state.worktree.missing?.branchExists === false)
|
|
const restoration = follower.next(state => state.worktree.missing === null && state.checkout.state === 'ready')
|
|
await opened.ctx.forgePanel.restoreWorktree(opened.session, signal)
|
|
await opened.agent.whenIdle()
|
|
const restored = await restoration
|
|
|
|
expect(gone.worktree).toEqual({ ...UNAVAILABLE, request: { from: null }, created, missing: { branchExists: false }, waiting: 'restoration' })
|
|
expect(await git(repo, 'rev-parse', created.branch)).toBe(created.baseCommit)
|
|
expect(restored.checkout).toEqual({ state: 'ready', checkout: { root: created.path, branch: created.branch, linked: true, commit: COMMIT } })
|
|
await expect(access(join(created.path, 'later.txt'))).rejects.toMatchObject({ code: 'ENOENT' })
|
|
expect(opened.agent.wakes).toBe(2)
|
|
})
|
|
|
|
it('checks once for all the messages that wait, publishing the answer once', async () => {
|
|
const { repo } = await project()
|
|
const opened = await open(repo, {}, { worktrees: true })
|
|
const follower = reader(opened)
|
|
await follower.until(ready)
|
|
const created = await startInWorktree(opened)
|
|
await follower.until(state => state.directory === created.path && state.checkout.state === 'ready')
|
|
await rm(created.path, { recursive: true, force: true })
|
|
sendPrompt(opened.agent, 'next')
|
|
await follower.until(state => state.worktree.waiting === 'restoration')
|
|
const pending = follower.next(() => true)
|
|
let published = false
|
|
void pending.then(() => { published = true }, () => undefined)
|
|
const calls = opened.sandbox.calls.length
|
|
|
|
sendPrompt(opened.agent, 'again')
|
|
await settle()
|
|
|
|
expect(published).toBe(false)
|
|
expect(opened.sandbox.calls).toHaveLength(calls)
|
|
expect(queued(opened)).toEqual(['first', 'next', 'again'])
|
|
expect(opened.agent.wakes).toBe(1)
|
|
})
|
|
|
|
it('finds a removed worktree on inspection, and lets messages go on without offering restoration while Git cannot answer', async () => {
|
|
const { repo } = await project()
|
|
const opened = await open(repo, {}, { worktrees: true })
|
|
const follower = reader(opened)
|
|
await follower.until(ready)
|
|
const created = await startInWorktree(opened)
|
|
await follower.until(state => state.directory === created.path && state.checkout.state === 'ready')
|
|
await rm(created.path, { recursive: true, force: true })
|
|
|
|
await opened.ctx.forgePanel.refresh(opened.session, signal)
|
|
const inspected = await follower.next(state => state.worktree.missing !== null)
|
|
opened.sandbox.failure = new Error('sandbox unavailable')
|
|
sendPrompt(opened.agent, 'next')
|
|
await opened.agent.whenIdle()
|
|
const answer = follower.next(state => state.worktree.missing === null)
|
|
await opened.ctx.forgePanel.refresh(opened.session, signal)
|
|
const unanswered = await answer
|
|
|
|
expect(inspected).toMatchObject({ directory: created.path, checkout: { state: 'failed' }, worktree: { missing: { branchExists: true } } })
|
|
expect(unanswered).toMatchObject({ directory: created.path, checkout: { state: 'failed', message: 'sandbox unavailable' }, worktree: { waiting: null } })
|
|
expect(queued(opened)).toEqual(['first', 'next'])
|
|
expect(opened.agent.wakes).toBe(2)
|
|
})
|
|
|
|
it('lets the message go on, without publishing the answer, when the Session moved out of the worktree during the check', async () => {
|
|
const { repo } = await project()
|
|
const opened = await open(repo, {}, { worktrees: true })
|
|
const follower = reader(opened)
|
|
await follower.until(ready)
|
|
const created = await startInWorktree(opened)
|
|
await follower.until(state => state.directory === created.path && state.checkout.state === 'ready')
|
|
await rm(created.path, { recursive: true, force: true })
|
|
const branchCheck = hold(opened.sandbox, 'show-ref')
|
|
const inspection = hold(opened.sandbox, '--show-toplevel')
|
|
|
|
sendPrompt(opened.agent, 'next')
|
|
await branchCheck.reached
|
|
await opened.ctx.workingDirectory.set(opened.agent, repo)
|
|
await inspection.reached
|
|
branchCheck.release()
|
|
await opened.agent.whenIdle()
|
|
const moved = await follower.next(() => true)
|
|
inspection.release()
|
|
await follower.until(state => state.directory === repo && state.checkout.state === 'ready')
|
|
|
|
expect(moved).toMatchObject({ directory: repo, checkout: { state: 'pending' }, worktree: { missing: null, waiting: null } })
|
|
expect(opened.agent.wakes).toBe(2)
|
|
})
|
|
|
|
it('holds messages for a removed worktree of a Session that no panel follows, until it is left for the source checkout', async () => {
|
|
const { repo } = await project()
|
|
const opened = await open(repo, {}, { worktrees: true })
|
|
|
|
sendPrompt(opened.agent, 'before')
|
|
const created = await startInWorktree(opened)
|
|
sendPrompt(opened.agent, 'inside')
|
|
await opened.agent.whenIdle()
|
|
await rm(created.path, { recursive: true, force: true })
|
|
sendPrompt(opened.agent, 'removed')
|
|
await vi.waitFor(() => { expect(() => opened.agent.runMaintenance(() => Promise.resolve())).toThrow('already has active work') })
|
|
const waiting = opened.agent.wakes
|
|
await vi.waitFor(async () => { await expect(opened.ctx.forgePanel.continueInProject(opened.session, signal)).resolves.toBeUndefined() })
|
|
await opened.agent.whenIdle()
|
|
sendPrompt(opened.agent, 'outside')
|
|
|
|
expect(waiting).toBe(3)
|
|
expect(opened.agent.wakes).toBe(5)
|
|
expect(opened.ctx.workingDirectory.get(opened.session)).toBe(repo)
|
|
expect(queued(opened)).toEqual(['before', 'first', 'inside', 'removed', 'outside'])
|
|
})
|
|
|
|
it('leaves a worktree whose directory exists unchanged', async () => {
|
|
const { repo } = await project()
|
|
const opened = await open(repo, {}, { worktrees: true })
|
|
const created = await startInWorktree(opened)
|
|
const commands = opened.sandbox.calls.length
|
|
|
|
await opened.ctx.forgePanel.restoreWorktree(opened.session, signal)
|
|
|
|
expect(opened.sandbox.calls.slice(commands).every(call => !call.argv.includes('add'))).toBe(true)
|
|
expect(await git(created.path, 'symbolic-ref', '--short', 'HEAD')).toBe(created.branch)
|
|
})
|
|
|
|
it.each([
|
|
['an Error', new Error('sandbox unavailable'), 'sandbox unavailable'],
|
|
['another value', 'denied', 'denied'],
|
|
])('reports a restoration that fails with %s', async (_label, failure, message) => {
|
|
const { repo } = await project()
|
|
const opened = await open(repo, {}, { worktrees: true })
|
|
const created = await startInWorktree(opened)
|
|
await rm(created.path, { recursive: true, force: true })
|
|
opened.sandbox.failure = failure
|
|
|
|
await expect(opened.ctx.forgePanel.restoreWorktree(opened.session, signal))
|
|
.rejects.toMatchObject({ code: 'forgePanel/restore-failed', message, details: {}, cause: failure })
|
|
await expect(access(created.path)).rejects.toMatchObject({ code: 'ENOENT' })
|
|
})
|
|
|
|
it('keeps the message waiting when a restoration inside its wait fails or is aborted, and lets it go on once one succeeds', async () => {
|
|
const { repo } = await project()
|
|
const opened = await open(repo, {}, { worktrees: true })
|
|
const follower = reader(opened)
|
|
await follower.until(ready)
|
|
const created = await startInWorktree(opened)
|
|
await follower.until(state => state.directory === created.path && state.checkout.state === 'ready')
|
|
await rm(created.path, { recursive: true, force: true })
|
|
sendPrompt(opened.agent, 'next')
|
|
await follower.until(state => state.worktree.waiting === 'restoration')
|
|
const pruning = hold(opened.sandbox, 'list')
|
|
const caller = new AbortController()
|
|
const reason = new Error('closed')
|
|
|
|
const aborted = rejectionOf(opened.ctx.forgePanel.restoreWorktree(opened.session, caller.signal))
|
|
await pruning.reached
|
|
caller.abort(reason)
|
|
pruning.release()
|
|
expect(await aborted).toBe(reason)
|
|
opened.sandbox.failure = new Error('sandbox unavailable')
|
|
await expect(opened.ctx.forgePanel.restoreWorktree(opened.session, signal)).rejects.toMatchObject({ code: 'forgePanel/restore-failed' })
|
|
const still = follower.states.at(-1)
|
|
opened.sandbox.failure = undefined
|
|
await opened.ctx.forgePanel.restoreWorktree(opened.session, signal)
|
|
await opened.agent.whenIdle()
|
|
|
|
expect(still?.worktree.waiting).toBe('restoration')
|
|
expect(opened.agent.wakes).toBe(2)
|
|
expect(opened.ctx.workingDirectory.get(opened.session)).toBe(created.path)
|
|
})
|
|
|
|
it('stops a restoration when the caller aborts', async () => {
|
|
const { repo } = await project()
|
|
const opened = await open(repo, {}, { worktrees: true })
|
|
const created = await startInWorktree(opened)
|
|
await rm(created.path, { recursive: true, force: true })
|
|
const pruning = hold(opened.sandbox, 'list')
|
|
const caller = new AbortController()
|
|
const reason = new Error('closed')
|
|
|
|
const restoring = rejectionOf(opened.ctx.forgePanel.restoreWorktree(opened.session, caller.signal))
|
|
await pruning.reached
|
|
caller.abort(reason)
|
|
pruning.release()
|
|
|
|
expect(await restoring).toBe(reason)
|
|
await expect(access(created.path)).rejects.toMatchObject({ code: 'ENOENT' })
|
|
})
|
|
|
|
it('refuses to restore without a created worktree or while the Agent has other work', async () => {
|
|
const { repo } = await project()
|
|
const opened = await open(repo, {}, { worktrees: true })
|
|
|
|
await expect(opened.ctx.forgePanel.restoreWorktree(opened.session, signal)).rejects.toMatchObject({ code: 'forgePanel/no-worktree' })
|
|
const created = await startInWorktree(opened)
|
|
await rm(created.path, { recursive: true, force: true })
|
|
const release = occupy(opened)
|
|
await expect(opened.ctx.forgePanel.restoreWorktree(opened.session, signal)).rejects.toMatchObject({ code: 'forgePanel/session-busy' })
|
|
await release()
|
|
|
|
await expect(access(created.path)).rejects.toMatchObject({ code: 'ENOENT' })
|
|
})
|
|
})
|
|
|
|
describe('forgePanel leaving a worktree', () => {
|
|
it('moves the Session from its removed worktree to the source checkout inside the wait of the message that waits for it', async () => {
|
|
const { repo } = await project()
|
|
const opened = await open(repo, {}, { worktrees: true })
|
|
const follower = reader(opened)
|
|
await follower.until(ready)
|
|
const created = await startInWorktree(opened)
|
|
// The first step would have claimed the first message and the notice of entering the worktree.
|
|
opened.agent.inbox.clear()
|
|
await follower.until(state => state.directory === created.path && state.checkout.state === 'ready')
|
|
await rm(created.path, { recursive: true, force: true })
|
|
const maintenance = vi.spyOn(opened.agent, 'runMaintenance')
|
|
sendPrompt(opened.agent, 'next')
|
|
await follower.until(state => state.worktree.waiting === 'restoration')
|
|
|
|
const leaving = follower.next(state => state.directory === repo && state.checkout.state === 'ready' && state.worktree.waiting === null)
|
|
await opened.ctx.forgePanel.continueInProject(opened.session, signal)
|
|
await opened.agent.whenIdle()
|
|
const moved = await leaving
|
|
|
|
expect(maintenance).toHaveBeenCalledOnce()
|
|
expect(opened.agent.wakes).toBe(2)
|
|
expect(opened.ctx.workingDirectory.get(opened.session)).toBe(repo)
|
|
expect(opened.agent.inbox.nextStep).toEqual([notice(repo)])
|
|
expect(queued(opened)).toEqual(['next'])
|
|
expect(moved.checkout).toEqual({ state: 'ready', checkout: { root: repo, branch: 'main', linked: false, commit: COMMIT } })
|
|
expect(moved.worktree).toEqual({ ...UNAVAILABLE, request: { from: null }, created })
|
|
expect(await branches(repo)).toEqual([created.branch, 'main'])
|
|
})
|
|
|
|
it('moves the Session to the source checkout as Agent maintenance while no message waits', async () => {
|
|
const { repo } = await project()
|
|
const opened = await open(repo, {}, { worktrees: true })
|
|
await startInWorktree(opened)
|
|
const maintenance = vi.spyOn(opened.agent, 'runMaintenance')
|
|
|
|
await opened.ctx.forgePanel.continueInProject(opened.session, signal)
|
|
|
|
expect(maintenance).toHaveBeenCalledOnce()
|
|
expect(opened.ctx.workingDirectory.get(opened.session)).toBe(repo)
|
|
})
|
|
|
|
it('refuses to leave without a created worktree, while the Session is not loaded, or while its Agent has other work', async () => {
|
|
const { repo } = await project()
|
|
const opened = await open(repo, {}, { worktrees: true })
|
|
await expect(opened.ctx.forgePanel.continueInProject(opened.session, signal)).rejects.toMatchObject({ code: 'forgePanel/no-worktree' })
|
|
const created = await startInWorktree(opened)
|
|
const unloaded = opened.ctx.sessions.create(SessionId('forge-panel-unloaded'), { meta: { cwd: created.path } })
|
|
recordWorktree(unloaded, created)
|
|
const release = occupy(opened)
|
|
|
|
await expect(opened.ctx.forgePanel.continueInProject(unloaded, signal))
|
|
.rejects.toMatchObject({ code: 'forgePanel/session-busy', message: 'The Session is not loaded' })
|
|
await expect(opened.ctx.forgePanel.continueInProject(opened.session, signal))
|
|
.rejects.toMatchObject({ code: 'forgePanel/session-busy', message: 'The Session is busy' })
|
|
await release()
|
|
|
|
expect(opened.ctx.workingDirectory.get(unloaded)).toBe(created.path)
|
|
expect(opened.ctx.workingDirectory.get(opened.session)).toBe(created.path)
|
|
})
|
|
})
|
|
|
|
describe('forgePanel worktree service changes', () => {
|
|
it('republishes whether a worktree can be requested when the worktree service appears and leaves', async () => {
|
|
const { repo } = await project()
|
|
const opened = await open(repo)
|
|
const follower = reader(opened)
|
|
expect((await follower.until(ready)).worktree.editable).toBe(false)
|
|
|
|
const service = opened.ctx.plugin(WorktreeService, {})
|
|
await service
|
|
await follower.until(state => state.worktree.editable)
|
|
|
|
await service.dispose()
|
|
await follower.until(state => !state.worktree.editable)
|
|
})
|
|
|
|
it('accepts a prefix that Git accepts', async () => {
|
|
const opened = await open(await plain(), { worktreePrefix: 'team/dsh-' })
|
|
expect(opened.ctx.forgePanel).toBeDefined()
|
|
})
|
|
})
|
|
|
|
describe('forgePanel actions', () => {
|
|
it('tracks recognized URLs, rejects others, and dismisses bindings', async () => {
|
|
const { repo } = await project()
|
|
const opened = await open(repo)
|
|
const follower = reader(opened)
|
|
await follower.until(ready)
|
|
|
|
await expect(opened.ctx.forgePanel.track(opened.session, 'https://elsewhere.test/a/b/pull/1', signal))
|
|
.rejects.toMatchObject({ code: 'forgePanel/unrecognized-url' })
|
|
await opened.ctx.forgePanel.track(opened.session, pullUrl(9), signal)
|
|
await follower.until(state => state.changeRequests.some(entry => entry.url === pullUrl(9) && entry.origin === 'tracked'))
|
|
await opened.ctx.forgePanel.dismiss(opened.session, pullUrl(9), signal)
|
|
|
|
await expect(follower.next(state => state.changeRequests.length === 0)).resolves.toBeDefined()
|
|
})
|
|
|
|
it('refreshes the checkout, sign-in state, and change requests of a followed Session only', async () => {
|
|
const { repo } = await project()
|
|
const opened = await open(repo)
|
|
await opened.ctx.forgePanel.refresh(opened.session, signal)
|
|
shellTurn(opened.session, 1, [{ callId: 'create', command: 'gh pr create --fill', output: `${pullUrl(7)}\n` }])
|
|
const follower = reader(opened)
|
|
await follower.until(state => ready(state) && polled(state, pullUrl(7)))
|
|
const inspections = opened.sandbox.calls.length
|
|
|
|
await opened.ctx.forgePanel.refresh(opened.session, signal)
|
|
await follower.until(() => opened.provider.fetches.length === 2
|
|
&& opened.sandbox.calls.length > inspections && opened.provider.probes.length === 2)
|
|
|
|
expect(opened.provider.fetches).toEqual([[7], [7]])
|
|
})
|
|
|
|
it('settles a refresh once both its inspection and its sign-in probe have ended', async () => {
|
|
const { repo } = await project()
|
|
const opened = await open(repo)
|
|
const follower = reader(opened)
|
|
await follower.until(ready)
|
|
let settled = false
|
|
|
|
const inspection = hold(opened.sandbox, '--show-toplevel')
|
|
const inspected = opened.ctx.forgePanel.refresh(opened.session, signal).then(() => { settled = true })
|
|
await inspection.reached
|
|
await settle()
|
|
expect(settled).toBe(false)
|
|
inspection.release()
|
|
await inspected
|
|
await follower.next(() => true)
|
|
|
|
const probe = Promise.withResolvers<ForgeReadiness>()
|
|
opened.provider.readinessImpl = () => probe.promise
|
|
settled = false
|
|
const probed = opened.ctx.forgePanel.refresh(opened.session, signal).then(() => { settled = true })
|
|
await follower.next(() => true)
|
|
await settle()
|
|
expect(settled).toBe(false)
|
|
probe.resolve({ state: 'ready', message: null })
|
|
await probed
|
|
expect(opened.provider.probes).toHaveLength(3)
|
|
})
|
|
|
|
it('refreshes a Session outside a checkout without polling change requests that no provider serves', async () => {
|
|
const opened = await open(await plain())
|
|
shellTurn(opened.session, 1, [{ callId: 'create', command: 'gh pr create --fill', output: 'https://unserved.test/a/b/pull/3\n' }])
|
|
const follower = reader(opened)
|
|
await follower.until(state => state.checkout.state === 'none')
|
|
const inspections = opened.sandbox.calls.length
|
|
|
|
await opened.ctx.forgePanel.refresh(opened.session, signal)
|
|
|
|
const refreshed = await follower.next(state => state.checkout.state === 'none')
|
|
expect(refreshed.changeRequests).toEqual([{ url: 'https://unserved.test/a/b/pull/3', origin: 'created', ref: null, update: null, snapshot: null }])
|
|
expect(opened.sandbox.calls.length).toBeGreaterThan(inspections)
|
|
expect(opened.provider.fetches).toEqual([])
|
|
expect(opened.provider.probes).toEqual([])
|
|
})
|
|
|
|
it('marks a bound change request ready and merges it through its provider, reporting what the host refused', async () => {
|
|
const { repo } = await project()
|
|
const opened = await open(repo)
|
|
shellTurn(opened.session, 1, [{ callId: 'create', command: 'gh pr create --fill', output: `${pullUrl(7)}\n` }])
|
|
const follower = reader(opened)
|
|
await follower.until(state => ready(state) && polled(state, pullUrl(7)))
|
|
const head = 'a'.repeat(40)
|
|
|
|
await opened.ctx.forgePanel.markReady(opened.session, pullUrl(7), signal)
|
|
await opened.ctx.forgePanel.merge(opened.session, pullUrl(7), 'squash', head, signal)
|
|
opened.provider.actionResult = { code: 'stale', message: 'Head branch was modified' }
|
|
await expect(opened.ctx.forgePanel.merge(opened.session, pullUrl(7), 'rebase', head, signal))
|
|
.rejects.toMatchObject({ code: 'forgePanel/stale-head', message: 'Head branch was modified' })
|
|
opened.provider.actionResult = { code: 'unauthenticated', message: 'Bad credentials' }
|
|
await expect(opened.ctx.forgePanel.markReady(opened.session, pullUrl(7), signal))
|
|
.rejects.toMatchObject({ code: 'forgePanel/action-failed', message: 'Bad credentials' })
|
|
opened.provider.actionResult = { code: 'failed', message: 'Pull Request is not mergeable' }
|
|
await expect(opened.ctx.forgePanel.merge(opened.session, pullUrl(7), 'merge', head, signal))
|
|
.rejects.toMatchObject({ code: 'forgePanel/action-failed', message: 'Pull Request is not mergeable' })
|
|
|
|
expect(opened.provider.actions).toEqual(['ready 7', `merge 7 squash ${head}`, `merge 7 rebase ${head}`, 'ready 7', `merge 7 merge ${head}`])
|
|
})
|
|
|
|
it('publishes a review only for a visible binding and validates wire ranges before calling the provider', async () => {
|
|
const { repo } = await project()
|
|
const opened = await open(repo)
|
|
shellTurn(opened.session, 1, [{ callId: 'create', command: 'gh pr create --fill', output: `${pullUrl(7)}\n` }])
|
|
await reader(opened).until(state => ready(state) && polled(state, pullUrl(7)))
|
|
const request = { kind: 'comment' as const, headSha: 'a'.repeat(40),
|
|
comment: { path: 'src/app.ts', side: 'R' as const, startLine: 3, endLine: 5, body: 'Please check' } }
|
|
expect(await opened.ctx.forgePanel.submitReview(opened.session, pullUrl(7), request, signal))
|
|
.toMatchObject({ ok: false, code: 'unsupported' })
|
|
const submit = vi.fn(async () => ({ ok: true as const, url: pullUrl(7) }))
|
|
Object.assign(opened.provider, { submitReview: submit })
|
|
expect(await opened.ctx.forgePanel.submitReview(opened.session, pullUrl(7), request, signal))
|
|
.toEqual({ ok: true, url: pullUrl(7) })
|
|
expect(submit).toHaveBeenCalledTimes(1)
|
|
await expect(opened.ctx.forgePanel.submitReview(opened.session, pullUrl(8), request, signal))
|
|
.rejects.toMatchObject({ code: 'forgePanel/not-bound' })
|
|
await expect(opened.ctx.forgePanel.submitReview(opened.session, pullUrl(7), { ...request, headSha: 'invalid' }, signal)).rejects.toThrow('Invalid review commit')
|
|
await expect(opened.ctx.forgePanel.submitReview(opened.session, pullUrl(7), { ...request,
|
|
comment: { ...request.comment, startLine: 6 } }, signal)).rejects.toThrow('Invalid review comment')
|
|
await expect(opened.ctx.forgePanel.submitReview(opened.session, pullUrl(7), {
|
|
kind: 'review', headSha: request.headSha, event: 'comment', body: '', comments: [],
|
|
}, signal)).rejects.toThrow('Empty review')
|
|
expect(submit).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('acts only on change requests visibly bound to the Session that a provider serves', async () => {
|
|
const { repo } = await project()
|
|
const opened = await open(repo)
|
|
shellTurn(opened.session, 1, [
|
|
{ callId: 'create', command: 'gh pr create --fill', output: `${pullUrl(7)}\n` },
|
|
{ callId: 'other', command: 'gh pr create --fill', output: 'https://unserved.test/a/b/pull/3\n' },
|
|
])
|
|
const follower = reader(opened)
|
|
await follower.until(state => ready(state) && polled(state, pullUrl(7)))
|
|
await opened.ctx.forgePanel.dismiss(opened.session, pullUrl(7), signal)
|
|
const head = 'a'.repeat(40)
|
|
|
|
for (const url of [pullUrl(7), pullUrl(8), 'https://unserved.test/a/b/pull/3']) {
|
|
await expect(opened.ctx.forgePanel.markReady(opened.session, url, signal)).rejects.toMatchObject({ code: 'forgePanel/not-bound' })
|
|
await expect(opened.ctx.forgePanel.merge(opened.session, url, 'merge', head, signal)).rejects.toMatchObject({ code: 'forgePanel/not-bound' })
|
|
}
|
|
expect(opened.provider.actions).toEqual([])
|
|
})
|
|
|
|
it('lists the base branches of the Session\'s current checkout, at most maxBranches', async () => {
|
|
const { repo } = await project()
|
|
await commitAt(repo, 'main.txt', '2026-03-01T00:00:00Z')
|
|
await git(repo, 'checkout', '-b', 'older')
|
|
await commitAt(repo, 'older.txt', '2026-01-01T00:00:00Z')
|
|
await git(repo, 'checkout', '-b', 'newer', 'main')
|
|
await commitAt(repo, 'newer.txt', '2026-02-01T00:00:00Z')
|
|
const opened = await open(repo, { maxBranches: 2 })
|
|
|
|
await expect(opened.ctx.forgePanel.branches(opened.session, signal)).resolves.toEqual({ current: 'newer', branches: ['newer', 'main'] })
|
|
})
|
|
|
|
it('refuses to list branches outside a checkout', async () => {
|
|
const opened = await open(await plain())
|
|
|
|
await expect(opened.ctx.forgePanel.branches(opened.session, signal))
|
|
.rejects.toThrow(/^git (symbolic-ref|for-each-ref) failed \(128\): fatal: not a git repository/)
|
|
})
|
|
|
|
it('checks the caller signal before every action', async () => {
|
|
const { repo } = await project()
|
|
const opened = await open(repo, {}, { worktrees: true })
|
|
const controller = new AbortController()
|
|
controller.abort(new Error('closed'))
|
|
|
|
await expect(opened.ctx.forgePanel.track(opened.session, pullUrl(1), controller.signal)).rejects.toThrow('closed')
|
|
await expect(opened.ctx.forgePanel.dismiss(opened.session, pullUrl(1), controller.signal)).rejects.toThrow('closed')
|
|
await expect(opened.ctx.forgePanel.refresh(opened.session, controller.signal)).rejects.toThrow('closed')
|
|
await expect(opened.ctx.forgePanel.localDiff(opened.session, { kind: 'unstaged' }, controller.signal)).rejects.toThrow('closed')
|
|
await expect(opened.ctx.forgePanel.localText(opened.session, { kind: 'unstaged' }, 'tracked.txt', controller.signal)).rejects.toThrow('closed')
|
|
await expect(opened.ctx.forgePanel.changeRequestDiff(opened.session, pullUrl(1), controller.signal)).rejects.toThrow('closed')
|
|
await expect(opened.ctx.forgePanel.changeRequestText(opened.session, pullUrl(1), 'a'.repeat(40), 'x', controller.signal)).rejects.toThrow('closed')
|
|
await expect(opened.ctx.forgePanel.markReady(opened.session, pullUrl(1), controller.signal)).rejects.toThrow('closed')
|
|
await expect(opened.ctx.forgePanel.merge(opened.session, pullUrl(1), 'merge', 'a'.repeat(40), controller.signal)).rejects.toThrow('closed')
|
|
await expect(opened.ctx.forgePanel.branches(opened.session, controller.signal)).rejects.toThrow('closed')
|
|
await expect(opened.ctx.forgePanel.requestWorktree(opened.session, null, controller.signal)).rejects.toThrow('closed')
|
|
await expect(opened.ctx.forgePanel.cancelWorktree(opened.session, controller.signal)).rejects.toThrow('closed')
|
|
await expect(opened.ctx.forgePanel.stopCreation(opened.session, controller.signal)).rejects.toThrow('closed')
|
|
await expect(opened.ctx.forgePanel.restoreWorktree(opened.session, controller.signal)).rejects.toThrow('closed')
|
|
await expect(opened.ctx.forgePanel.continueInProject(opened.session, controller.signal)).rejects.toThrow('closed')
|
|
expect(opened.records).toEqual([])
|
|
})
|
|
})
|
|
|
|
describe('forgePanel lifecycle', () => {
|
|
it('removes its Remote, worktree projection, and inbox and pre-step listeners when stopped, and folds the recorded request again after a restart', async () => {
|
|
const { repo } = await project()
|
|
const opened = await open(repo, {}, { worktrees: true })
|
|
await opened.ctx.forgePanel.requestWorktree(opened.session, null, signal)
|
|
const proposed = prompt('go')
|
|
|
|
await opened.panel.dispose()
|
|
|
|
expect(opened.ctx.get('forgePanel')).toBeUndefined()
|
|
expect(opened.ctx.sessionProjections.checkpoint(opened.session)).not.toHaveProperty('forgeWorktree')
|
|
sendPrompt(opened.agent, 'go')
|
|
await expect(proposeStep(opened.ctx, opened.agent, { turn: 1, step: 1, messages: [proposed] })).resolves.toEqual({ kind: 'enter', messages: [proposed] })
|
|
expect(opened.ctx.workingDirectory.get(opened.session)).toBe(repo)
|
|
expect(opened.agent.wakes).toBe(1)
|
|
expect(queued(opened)).toEqual(['go'])
|
|
|
|
await opened.ctx.plugin(ForgePanelController, {})
|
|
|
|
expect(opened.ctx.sessionProjections.checkpoint(opened.session).forgeWorktree?.val)
|
|
.toEqual({ request: { from: null }, created: null, locked: false, stepped: false, cwd: repo, restoring: false })
|
|
})
|
|
})
|
|
|
|
describe('forgePanel configuration', () => {
|
|
const prefix = 'forge-panel: worktreePrefix must start a valid Git branch name'
|
|
|
|
it.each([
|
|
[{ gitCommand: ' ' }, 'forge-panel: gitCommand must not be empty'],
|
|
[{ gitTimeoutMs: 0 }, 'forge-panel: gitTimeoutMs must be a positive integer no greater than 2147483647'],
|
|
[{ graceMs: 1.5 }, 'forge-panel: graceMs must be a positive integer no greater than 2147483647'],
|
|
[{ maxOutputBytes: -1 }, 'forge-panel: maxOutputBytes must be a positive integer no greater than 2147483647'],
|
|
[{ readinessTimeoutMs: 2_147_483_648 }, 'forge-panel: readinessTimeoutMs must be a positive integer no greater than 2147483647'],
|
|
[{ maxBranches: 0 }, 'forge-panel: maxBranches must be a positive integer'],
|
|
[{ maxBranches: 2.5 }, 'forge-panel: maxBranches must be a positive integer'],
|
|
[{ maxCachedDiffBytes: 0 }, 'forge-panel: maxCachedDiffBytes must be a positive integer'],
|
|
[{ worktreePrefix: '-dsh/' }, prefix],
|
|
[{ worktreePrefix: 'dsh /' }, prefix],
|
|
[{ worktreePrefix: 'dsh:' }, prefix],
|
|
[{ worktreePrefix: 'dsh\\' }, prefix],
|
|
[{ worktreePrefix: '/dsh/' }, prefix],
|
|
[{ worktreePrefix: 'dsh//' }, prefix],
|
|
[{ worktreePrefix: './' }, prefix],
|
|
[{ worktreePrefix: 'dsh/../' }, prefix],
|
|
[{ worktreePrefix: '.x/' }, prefix],
|
|
[{ worktreePrefix: 'a..b/' }, prefix],
|
|
[{ worktreePrefix: 'x.lock/' }, prefix],
|
|
[{ worktreePrefix: 'x~' }, prefix],
|
|
[{ worktreePrefix: 'x^' }, prefix],
|
|
[{ worktreePrefix: 'x?' }, prefix],
|
|
[{ worktreePrefix: 'x*' }, prefix],
|
|
[{ worktreePrefix: 'x[' }, prefix],
|
|
[{ worktreePrefix: 'x@{' }, prefix],
|
|
[{ worktreePrefix: 'x\u007f' }, prefix],
|
|
[{ worktreePrefix: 'x\t' }, prefix],
|
|
])('rejects %j at load', async (config, message) => {
|
|
const opened = await open(await plain())
|
|
await opened.panel.dispose()
|
|
|
|
await expect(opened.ctx.plugin(ForgePanelController, config)).rejects.toThrow(message)
|
|
})
|
|
})
|
|
|
|
|
|
describe('worktree recovery and unload', () => {
|
|
it('keeps a failed restoration blocked after the panel reloads, despite its leftover directory', async () => {
|
|
const { repo } = await project()
|
|
const opened = await open(repo, {}, { worktrees: true })
|
|
const created = await startInWorktree(opened)
|
|
await rm(created.path, { recursive: true, force: true })
|
|
const occupied = join(repo, '.agents/worktrees/occupied')
|
|
await git(repo, 'worktree', 'add', '--force', occupied, created.branch)
|
|
await expect(opened.ctx.forgePanel.restoreWorktree(opened.session, signal)).rejects.toMatchObject({ code: 'forgePanel/restore-failed' })
|
|
await expect(access(created.path)).resolves.toBeUndefined()
|
|
expect(worktreeState(opened).restoring).toBe(true)
|
|
await opened.panel.dispose()
|
|
await opened.ctx.plugin(ForgePanelController, {})
|
|
const follower = reader(opened)
|
|
await follower.until(state => state.worktree.missing !== null)
|
|
const wakes = opened.agent.wakes
|
|
opened.sandbox.failure = new Error('Git temporarily unavailable')
|
|
sendPrompt(opened.agent, 'after failure')
|
|
await follower.until(state => state.worktree.waiting === 'restoration')
|
|
expect(opened.agent.wakes).toBe(wakes)
|
|
expect(follower.states.at(-1)?.worktree.missing).toEqual({ branchExists: null })
|
|
opened.sandbox.failure = undefined
|
|
await expect(opened.ctx.forgePanel.restoreWorktree(opened.session, signal)).rejects.toMatchObject({ code: 'forgePanel/restore-failed' })
|
|
expect(opened.agent.wakes).toBe(wakes)
|
|
await opened.ctx.forgePanel.continueInProject(opened.session, signal)
|
|
await opened.agent.whenIdle()
|
|
expect(opened.ctx.workingDirectory.get(opened.session)).toBe(repo)
|
|
})
|
|
|
|
it.each(['creation', 'restoration', 'validation'] as const)('waits for cancelled %s before completing panel unload', async (kind) => {
|
|
const { repo } = await project()
|
|
const opened = await open(repo, {}, { worktrees: true })
|
|
const created = kind !== 'creation' ? await startInWorktree(opened) : undefined
|
|
if (created !== undefined) await rm(created.path, { recursive: true, force: true })
|
|
else await opened.ctx.forgePanel.requestWorktree(opened.session, null, signal)
|
|
const entered: PromiseWithResolvers<void> = Promise.withResolvers()
|
|
const released: PromiseWithResolvers<void> = Promise.withResolvers()
|
|
let cancellation: AbortSignal | undefined
|
|
const confine = opened.ctx.sandbox.confine.bind(opened.ctx.sandbox)
|
|
const inspection = vi.spyOn(opened.ctx.sandbox, 'confine').mockImplementation(async (argv, policy, signal) => {
|
|
if (kind === 'validation' && argv.includes('show-ref')) {
|
|
cancellation = signal; entered.resolve(); await released.promise
|
|
}
|
|
return confine(argv, policy, signal)
|
|
})
|
|
const create = vi.spyOn(opened.ctx.worktrees, 'create').mockImplementation(async (_agent, _request, signal) => {
|
|
cancellation = signal; entered.resolve(); await released.promise
|
|
return { path: join(repo, '.agents/worktrees/pending'), branch: 'pending', repositoryRoot: repo, baseCommit: 'a'.repeat(40) }
|
|
})
|
|
const restore = vi.spyOn(opened.ctx.worktrees, 'restore').mockImplementation(async (_agent, request, signal) => {
|
|
cancellation = signal; entered.resolve(); await released.promise
|
|
return { path: request.path, branchRecreated: false }
|
|
})
|
|
const operation = rejectionOf(kind === 'creation'
|
|
? proposeStep(opened.ctx, opened.agent, { turn: 1, step: 1, messages: [prompt('go')] })
|
|
: opened.ctx.forgePanel.restoreWorktree(opened.session, signal))
|
|
let disposed = false
|
|
let unloading: Promise<void> | undefined
|
|
try {
|
|
await entered.promise
|
|
unloading = opened.panel.dispose().then(() => { disposed = true })
|
|
await vi.waitFor(() => { expect(cancellation?.aborted).toBe(true) })
|
|
expect(disposed).toBe(false)
|
|
released.resolve()
|
|
expect(await operation).toBeInstanceOf(Error)
|
|
await unloading
|
|
expect(opened.records.filter(record => typeof record === 'object' && record !== null && 'action' in record
|
|
&& record.action === (kind === 'creation' ? 'created' : 'restored'))).toEqual([])
|
|
} finally {
|
|
released.resolve()
|
|
await Promise.allSettled([operation, ...unloading === undefined ? [] : [unloading]])
|
|
create.mockRestore(); restore.mockRestore(); inspection.mockRestore()
|
|
}
|
|
})
|
|
})
|
|
|
|
|
|
it.each(['empty', 'foreign', 'non-git'] as const)('keeps a recorded worktree unavailable when its directory is %s', async (kind) => {
|
|
const { repo } = await project()
|
|
const opened = await open(repo, {}, { worktrees: true })
|
|
const created = await startInWorktree(opened)
|
|
await rm(created.path, { recursive: true, force: true })
|
|
const path = kind === 'non-git' ? await plain() : created.path
|
|
if (kind !== 'non-git') await mkdir(path)
|
|
if (kind === 'foreign') await git(path, 'init', '-b', 'other')
|
|
const state = { ...worktreeState(opened), created: { ...created, path } }
|
|
expect(await missingWorktree(opened.ctx, { gitCommand: 'git', gitTimeoutMs: 10_000, graceMs: 500, maxOutputBytes: 1_048_576 },
|
|
opened.session, state, path, signal)).toEqual({ branchExists: true })
|
|
})
|