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)[] = [] 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 { 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 { const { path, remove } = await scratch('dsh-forge-plain-') cleanups.push(remove) return path } async function open(cwd: string, config: Config = {}, options: BenchOptions = {}): Promise { 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 { 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 { 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 { const other: PromiseWithResolvers = 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, 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() 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() 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 = 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() 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 = 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 => (await opened.ctx.forgePanel.changeRequestDiff(session, pullUrl(7), signal)).files.map(file => file.path) const moved = async (snapshot: { headSha: string; baseBranch: string }): Promise => { 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 => { 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 => { 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() 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 = Promise.withResolvers() const released: PromiseWithResolvers = 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 | 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 }) })