diff --git a/packages/experimental/forge-session/tests/commands.spec.ts b/packages/experimental/forge-session/tests/commands.spec.ts index ea4b2abd90..b33f0a5c6e 100644 --- a/packages/experimental/forge-session/tests/commands.spec.ts +++ b/packages/experimental/forge-session/tests/commands.spec.ts @@ -32,6 +32,7 @@ describe('commandEvidence', () => { ['git --no-pager push --force-with-lease', pushed], ['cd repo && git push && gh pr create --fill', { ...created, pushes: true }], ['gh pr view 12 --json url', { urls: 'referenced', pushes: false, named: [] }], + ['gh pr view 12 --repo octo/demo --json number', { urls: 'referenced', pushes: false, named: [] }], [`gh pr view '${url}' --json title`, { urls: 'referenced', pushes: false, named: [url] }], [`gh pr edit ${url}/files --add-label bug`, { urls: 'referenced', pushes: false, named: [url] }], [`gh pr checkout ${url} && git push`, { urls: 'referenced', pushes: true, named: [url] }], diff --git a/packages/experimental/forge-session/tests/reference-lookup.spec.ts b/packages/experimental/forge-session/tests/reference-lookup.spec.ts new file mode 100644 index 0000000000..bfbee34916 --- /dev/null +++ b/packages/experimental/forge-session/tests/reference-lookup.spec.ts @@ -0,0 +1,191 @@ +/** Reference observation through Cordis services without a platform shell or CLI process. */ + +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { Context } from '@deepseek-ai/cordis' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import LlmRuntime, { ToolCallId } from '@deepseek-ai/dsh-llm' +import { pluginRecordOf, SessionId } from '@deepseek-ai/dsh-session' +import ShellExecutor from '@deepseek-ai/dsh-shell' +import type { ShellExecRequest, ShellExecSpec, ShellExecution, ShellRunResult } from '@deepseek-ai/dsh-shell' +import * as ShellEnv from '@deepseek-ai/dsh-shell-env' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRuntime from '@deepseek-ai/dsh-tools' +import type { ToolExecution, ToolExecutionResult, ToolExecutionToken } from '@deepseek-ai/dsh-tools' +import { bench, pullUrl } from './fixtures.ts' + +const contexts: Context[] = [] +const releases: (() => void)[] = [] + +afterEach(async () => { + for (const release of releases.splice(0)) release() + for (const ctx of contexts.splice(0)) await ctx.fiber.dispose() +}) + +function output(overrides: Partial = {}): ShellRunResult { + return { + exitCode: 0, signal: null, timedOut: false, aborted: false, timeoutMs: 1_234, + stdout: { text: JSON.stringify({ url: pullUrl(9) }), truncated: false }, + stderr: { text: '', truncated: false }, + ...overrides, + } +} + +function execution(result: () => Promise): ShellExecution { + const reader = { readFrom: (from: number) => ({ text: '', nextOffset: from, lossy: false }) } + return { + status: 'completed', exitCode: 0, signal: null, done: Promise.resolve(), + observed: { stdout: reader, stderr: reader }, + readOutput: () => ({ delta: '', lossy: false }), kill: () => false, result, + } +} + +class LookupShell extends ShellExecutor { + resolve(request: ShellExecRequest): ShellExecSpec { + return { + ...request, + workdir: request.workdir ?? process.cwd(), timeoutMs: request.timeoutMs ?? 1_234, + onExpiry: 'kill', stdoutMaxBytes: 64_000, sandboxPolicy: request.sandboxPolicy, + } + } + + async execute(): Promise { + return execution(async () => output()) + } +} + +async function fixture() { + const b = await bench({ discoveryTimeoutMs: 1_234 }) + const { ctx } = b + contexts.push(ctx) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRuntime) + await ctx.plugin(LlmRuntime) + await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(ShellEnv, { dshHome: process.cwd() }) + await ctx.plugin(LookupShell) + const { agent } = await ctx.agents.create({ sessionId: SessionId('reference-observer') }) + const controller = new AbortController() + const exec: ToolExecution = { + name: 'bash', agent, + callId: ToolCallId('reference'), rootCallId: ToolCallId('reference'), token: Symbol('reference') as ToolExecutionToken, + arguments: { command: 'gh pr view 9 -R octo/demo --json number' }, signal: controller.signal, + } + const result: ToolExecutionResult = { + isError: false, content: [{ type: 'text', text: '{"number":9}' }], + value: { kind: 'foreground', cwd: process.cwd(), exitCode: 0, signal: null, timedOut: false, aborted: false }, + } + const records = () => agent.session.snapshotEvents().flatMap((event) => { + const record = pluginRecordOf(event) + return record?.type === 'plugin:forge-session/binding' ? [record.data] : [] + }) + const execute = vi.spyOn(ctx.shell, 'execute') + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) + const emit = (call: ToolExecution = exec, outcome: ToolExecutionResult = result) => { ctx.emit('tools/result', call, outcome) } + return { ...b, agent, exec, result, controller, records, execute, warn, emit } +} + +describe('PR reference lookup', () => { + it('records the resolved URL with the caller cwd and environment, without restoring a dismissed binding', async () => { + const f = await fixture() + const resolve = vi.spyOn(f.ctx.shell, 'resolve') + f.emit() + await vi.waitFor(() => { expect(f.records()).toEqual([{ action: 'reference', url: pullUrl(9) }]) }) + expect(resolve).toHaveBeenCalledWith(expect.objectContaining({ + command: 'env gh pr view 9 --repo octo/demo --json url', workdir: process.cwd(), timeoutMs: 1_234, + dshEnv: f.ctx.shellEnv.collect(f.exec), + })) + f.ctx.forgeSession.dismiss(f.agent.session, pullUrl(9)) + f.emit() + expect(f.execute).toHaveBeenCalledTimes(2) + const returned = f.execute.mock.results[1] + if (returned?.type !== 'return') throw new Error('missing second lookup') + await (await returned.value).result() + await f.service.dispose() + expect(f.records()).toEqual([{ action: 'reference', url: pullUrl(9) }, { action: 'dismiss', url: pullUrl(9) }]) + expect(f.warn).not.toHaveBeenCalled() + }) + + it.each([ + ['failed exit', { exitCode: 1 }], + ['signal', { signal: 'SIGTERM' }], + ['timeout', { timedOut: true }], + ['abort result', { aborted: true }], + ['truncated output', { stdout: { text: '{', truncated: true } }], + ['invalid JSON', { stdout: { text: '{', truncated: false } }], + ['missing URL', { stdout: { text: '{}', truncated: false } }], + ['non-PR URL', { stdout: { text: '{"url":"https://fake.test/octo/demo/issues/9"}', truncated: false } }], + ] satisfies [string, Partial][])('reports %s without changing bindings or the completed tool result', async (_label, overrides) => { + const f = await fixture() + f.execute.mockResolvedValue(execution(async () => output(overrides))) + f.emit() + await vi.waitFor(() => { expect(f.warn).toHaveBeenCalledWith(expect.stringContaining('PR reference lookup failed')) }) + expect(f.records()).toEqual([]) + expect(f.result.isError).toBe(false) + }) + + it('reports a shell preparation rejection that is not an Error', async () => { + const f = await fixture() + f.execute.mockRejectedValue('unavailable') + f.emit() + await vi.waitFor(() => { expect(f.warn).toHaveBeenCalledWith(expect.stringContaining('unavailable')) }) + expect(f.records()).toEqual([]) + }) + + it.each(['caller', 'service'] as const)('joins pending preparation after %s cancellation and ignores its late result', async (owner) => { + const f = await fixture() + const pending = Promise.withResolvers() + const started = Promise.withResolvers() + releases.push(() => { pending.resolve(execution(async () => output())) }) + f.execute.mockImplementation((spec) => { started.resolve(spec); return pending.promise }) + f.emit() + const spec = await started.promise + const aborted = Promise.withResolvers() + spec.signal!.addEventListener('abort', () => { aborted.resolve(undefined) }, { once: true }) + if (owner === 'caller') f.controller.abort() + let disposed = false + const disposal = f.service.dispose().then(() => { disposed = true }) + await aborted.promise + expect(disposed).toBe(false) + pending.resolve(execution(async () => output())) + await disposal + expect(f.records()).toEqual([]) + expect(f.warn).not.toHaveBeenCalled() + }) + + it('joins a cancelled preparation rejection without warning', async () => { + const f = await fixture() + const pending = Promise.withResolvers() + const started = Promise.withResolvers() + releases.push(() => { pending.resolve(execution(async () => output())) }) + f.execute.mockImplementation(() => { started.resolve(undefined); return pending.promise }) + f.emit() + await started.promise + f.controller.abort() + pending.reject(new Error('cancelled during preparation')) + await f.service.dispose() + expect(f.records()).toEqual([]) + expect(f.warn).not.toHaveBeenCalled() + }) + + it('skips unsupported, unsuccessful, already identified, nested, or cancelled calls', async () => { + const f = await fixture() + f.emit({ ...f.exec, name: 'pwsh' }) + const { agent: _agent, ...withoutAgent } = f.exec + f.emit(withoutAgent) + f.emit({ ...f.exec, parent: f.exec.token }) + f.emit(f.exec, { isError: true, error: { message: 'failed' }, content: [] }) + f.emit({ ...f.exec, arguments: {} }) + f.emit(f.exec, { isError: false, value: { kind: 'background' }, content: [] }) + f.emit({ ...f.exec, arguments: { command: 'gh pr view 9' } }, { ...f.result, content: [{ type: 'reasoning', text: 'thinking' }] }) + f.emit({ ...f.exec, arguments: { command: 'gh pr view 9 -R fake.test/octo/demo' } }) + f.emit(f.exec, { ...f.result, content: [{ type: 'text', text: JSON.stringify({ url: pullUrl(9) }) }] }) + f.controller.abort() + f.emit() + await f.service.dispose() + expect(f.execute).not.toHaveBeenCalled() + expect(f.records()).toEqual([]) + expect(f.warn).not.toHaveBeenCalled() + }) +}) diff --git a/packages/experimental/worktree/tests/worktree.spec.ts b/packages/experimental/worktree/tests/worktree.spec.ts index 01b58ccf3f..7db4752462 100644 --- a/packages/experimental/worktree/tests/worktree.spec.ts +++ b/packages/experimental/worktree/tests/worktree.spec.ts @@ -454,7 +454,7 @@ describe('restoring removed worktrees', () => { expect(ctx.workingDirectory.get(agent.session)).toBe(root) }) - it('retains a caller cancellation reason when restoration is cancelled before its allocation starts', async () => { + it.each([new Error('caller stopped'), 'caller stopped'])('retains cancellation reason %s when restoration is cancelled before its allocation starts', async (reason) => { const root = await repo() ctx = await harness(root) const agent = testAgent(ctx, root) @@ -463,7 +463,7 @@ describe('restoring removed worktrees', () => { const controller = new AbortController() const resolveExecutable = ctx.subprocess.resolveExecutable.bind(ctx.subprocess) const probe = vi.spyOn(ctx.subprocess, 'resolveExecutable').mockImplementation(async (command, env, signal) => { - if (command === 'node') controller.abort('caller stopped') + if (command === 'node') controller.abort(reason) return resolveExecutable(command, env, signal) }) try { @@ -559,13 +559,16 @@ it('does not remove a registration when its directory reappears during restorati const created = await ctx.worktrees.create(agent, { name: 'topic' }) await rm(created.path, { recursive: true, force: true }) const resolve = ctx.fs.resolve.bind(ctx.fs) + const target = await resolve(created.path) let targetReads = 0 const probe = vi.spyOn(ctx.fs, 'resolve').mockImplementation(async (path, options) => { - if (path === created.path && ++targetReads === 2) await mkdir(created.path) - return resolve(path, options) + const resolved = await resolve(path, options) + if (resolved.targetKey === target.targetKey && ++targetReads === 2) await mkdir(created.path) + return resolved }) try { await expect(ctx.worktrees.restore(agent, created)).rejects.toThrow('worktree path already exists') + expect(targetReads).toBe(2) expect(await git(root, 'worktree', 'list', '--porcelain')).toContain(`worktree ${created.path.split(sep).join('/')}`) } finally { probe.mockRestore() } }) diff --git a/scripts/pre-merge-check.spec.ts b/scripts/pre-merge-check.spec.ts index fb49b9bc3b..064f5c5ddb 100644 --- a/scripts/pre-merge-check.spec.ts +++ b/scripts/pre-merge-check.spec.ts @@ -45,7 +45,7 @@ describe('pre-merge check', () => { it('checks the versions before it installs, and installs before the builds', () => { const steps = preMergeSteps('/repo') expect(steps.map(step => step.name)).toEqual(['versions', 'install', 'host contracts', 'client']) - expect(steps[0]).toEqual({ name: 'versions', command: process.execPath, args: ['/repo/scripts/verify-version-sync.ts', '/repo'] }) + expect(steps[0]).toEqual({ name: 'versions', command: process.execPath, args: [join('/repo', 'scripts', 'verify-version-sync.ts'), '/repo'] }) expect(steps[1]).toEqual({ name: 'install', command: 'pnpm', args: ['install', '--frozen-lockfile'] }) expect(steps[3]?.args).toEqual(['exec', 'tsx', 'scripts/compile-referenced-projects.ts', 'client']) })