Files
deepseek-harness/packages/experimental/forge-session/tests/loader-composition.spec.ts
T
Yichen Jiang cdc72eb229 Merge remote-tracking branch 'origin/worktree/forge-change-request-bindings' into worktree/forge-pr-worktree-panel
The parent branch now carries master through #6426, including the
agent-controller revert.

- Package manifests keep both sides: the ./types and ./locale/*.json
  exports of the forge packages, and the Web app's forge bundle and
  message quotes dependencies.
- The type manifest drops the removed agent-controller entries and keeps
  the forge panel entries.
- The event matrix and the capability-seams pairing are regenerated.
- The forge bundle stays on the main plugin list and now ranks before the
  held-back developer tools; the plugin page expectations follow.
2026-10-22 17:44:45 +08:00

230 lines
12 KiB
TypeScript

/** Real Loader and AgentLoop composition; only the model and the GitHub CLI are fixtures. */
import { chmod, mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import Loader from '@deepseek-ai/cordis-plugin-loader'
import Include from '@deepseek-ai/cordis-plugin-include'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import LocalBashExecutor from '@deepseek-ai/dsh-bash-local'
import ForgeService from '@deepseek-ai/dsh-experimental-forge'
import * as ForgeGitHub from '@deepseek-ai/dsh-experimental-forge-github'
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
import LlmRuntime, { createUserMessage, LlmAdapter, ToolCallId } from '@deepseek-ai/dsh-llm'
import type { GenerateOptions, LlmResolvedModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore, { pluginRecordOf, SessionId } from '@deepseek-ai/dsh-session'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import * as ShellEnv from '@deepseek-ai/dsh-shell-env'
import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native'
import ToolRuntime from '@deepseek-ai/dsh-tools'
import WorkingDirectoryService from '@deepseek-ai/dsh-working-directory'
import ForgeSessionService from '../src/index.ts'
import type { SessionChangeRequest, SessionChangeRequestsListener } from '../src/index.ts'
/**
* GitHub CLI fixture: `pr create` prints PR 8; `pr view` prints PR 9, whose description links PR 6;
* `api graphql` answers the head lookup and snapshot queries.
*/
const FAKE_GH = String.raw`
import { appendFileSync } from 'node:fs'
const args = process.argv.slice(2)
appendFileSync(new URL('./gh.log', import.meta.url), JSON.stringify(args) + '\n')
const reply = (data) => { process.stdout.write(JSON.stringify({ data })); process.exit(0) }
if (args[0] === 'pr' && args[1] === 'create') {
process.stdout.write('https://github.com/octo/demo/pull/8\n')
process.stderr.write('\nCreating pull request for other into main in octo/demo\n\n')
process.exit(0)
}
if (args[0] === 'pr' && args[1] === 'view') {
process.stdout.write('title:\tChange 9\nstate:\tOPEN\nurl:\thttps://github.com/octo/demo/pull/9\n--\nStacked on https://github.com/octo/demo/pull/6\n')
process.exit(0)
}
if (args[0] !== 'api' || args[1] !== 'graphql') {
process.stderr.write('unexpected gh invocation\n')
process.exit(2)
}
const fields = Object.fromEntries(args.flatMap((arg, index) => args[index - 1] === '-f' ? [[arg.slice(0, arg.indexOf('=')), arg.slice(arg.indexOf('=') + 1)]] : []))
if ('branch' in fields) {
reply({ repository: { defaultBranchRef: { name: 'main' }, pullRequests: { nodes: fields.branch === 'feat' ? [{ number: 7, isCrossRepository: false }] : [] } } })
}
const node = (number) => ({
number,
url: 'https://github.com/octo/demo/pull/' + number,
title: 'Change ' + number,
state: 'OPEN',
isDraft: false,
updatedAt: '2026-10-09T00:00:00Z',
closedAt: null,
author: { login: 'octo' },
headRefName: number === 7 ? 'feat' : 'other',
headRefOid: 'a'.repeat(40),
baseRefName: 'main',
additions: 1,
deletions: 0,
mergeable: 'MERGEABLE',
mergeStateStatus: number === 7 ? 'BLOCKED' : 'CLEAN',
reviewDecision: null,
reviewThreads: { nodes: [] },
commits: { nodes: [{ commit: { statusCheckRollup: {
state: number === 7 ? 'PENDING' : 'SUCCESS',
contexts: { pageInfo: { hasNextPage: false }, nodes: [{
__typename: 'CheckRun', name: 'test', status: number === 7 ? 'IN_PROGRESS' : 'COMPLETED', conclusion: number === 7 ? null : 'SUCCESS',
detailsUrl: null, startedAt: null, completedAt: null, checkSuite: { workflowRun: { workflow: { name: 'ci' } } },
}] },
} } }] },
})
const data = {}
let repository
for (const match of fields.query.matchAll(/(r\d+): repository\(|(p\d+): pullRequest\(number: (\d+)\)/g)) {
if (match[1] !== undefined) data[repository = match[1]] = {}
else data[repository][match[2]] = node(Number(match[3]))
}
reply(data)
`
/** `git push` fixture printing the refs a new branch push reports. */
const FAKE_GIT = `#!/bin/sh
printf 'To github.com:octo/demo.git\\n * [new branch] feat -> feat\\n' >&2
`
class ScriptedModel extends LlmAdapter {
readonly requests: GenerateOptions[] = []
constructor(private readonly commands: readonly string[]) {
super()
}
override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
return Promise.resolve({ provider, id: model, name: model, inputModalities: ['text'] })
}
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
this.requests.push(options)
const command = this.commands[this.requests.length - 1]
if (command !== undefined) {
const call = { type: 'tool-call' as const, id: ToolCallId(`shell-${this.requests.length}`), name: 'bash', arguments: JSON.stringify({ command, description: 'Run the scripted command' }) }
yield { type: 'block-start', index: 0, blockType: 'tool-call' }
yield { type: 'block-end', index: 0, block: call }
yield { type: 'finish', reason: { kind: 'tool-calls' } }
return
}
yield { type: 'block-start', index: 0, blockType: 'text' }
yield { type: 'block-end', index: 0, block: { type: 'text', text: 'Pushed and opened the pull request.' } }
yield { type: 'finish', reason: { kind: 'stop' } }
}
}
let context: Context | undefined
let root: string | undefined
afterEach(async () => {
await context?.fiber.dispose()
context = undefined
if (root !== undefined) await rm(root, { recursive: true, force: true })
root = undefined
})
const suite = process.platform === 'win32' ? describe.skip : describe
suite('forge-session through a real cordis.yml Loader composition', () => {
it('binds the pull requests that bash pushes discover, creates, and views, and polls them through the GitHub provider', async () => {
root = await realpath(await mkdtemp(join(tmpdir(), 'dsh-forge-session-loader-')))
const bin = join(root, 'bin')
await mkdir(bin)
await writeFile(join(root, 'fake-gh.mjs'), FAKE_GH)
await writeFile(join(bin, 'gh'), `#!/bin/sh\nexec ${JSON.stringify(process.execPath)} ${JSON.stringify(join(root, 'fake-gh.mjs'))} "$@"\n`)
await writeFile(join(bin, 'git'), FAKE_GIT)
await chmod(join(bin, 'gh'), 0o755)
await chmod(join(bin, 'git'), 0o755)
const modules = new Map<string, unknown>([
['@deepseek-ai/dsh-llm', LlmRuntime],
['@deepseek-ai/dsh-session', SessionStore],
['@deepseek-ai/dsh-session-projection', SessionProjectionRegistry],
['@deepseek-ai/dsh-system-prompt', SystemPrompt],
['@deepseek-ai/dsh-fs-local', LocalFileSystem],
['@deepseek-ai/dsh-working-directory', WorkingDirectoryService],
['@deepseek-ai/dsh-tools', ToolRuntime],
['@deepseek-ai/dsh-tool-executor-native', NativeToolExecutor],
['@deepseek-ai/dsh-agent', AgentRegistry],
['@deepseek-ai/dsh-agent-loop', AgentLoop],
['@deepseek-ai/dsh-subprocess-local', LocalSubprocessRuntime],
['@deepseek-ai/dsh-shell-env', ShellEnv],
['@deepseek-ai/dsh-bash-local', LocalBashExecutor],
['@deepseek-ai/dsh-tool-bash', ToolBash],
['@deepseek-ai/dsh-experimental-forge', ForgeService],
['@deepseek-ai/dsh-experimental-forge-github', ForgeGitHub],
['@deepseek-ai/dsh-experimental-forge-session', ForgeSessionService],
])
const configs: Record<string, readonly string[]> = {
'@deepseek-ai/dsh-agent-loop': ['agents: []'],
'@deepseek-ai/dsh-shell-env': [`dshHome: ${JSON.stringify(join(root, 'dsh-home'))}`],
'@deepseek-ai/dsh-bash-local': ['timeoutMs: 20000'],
'@deepseek-ai/dsh-experimental-forge': ['activeIntervalMs: 600000', 'idleIntervalMs: 600000'],
'@deepseek-ai/dsh-experimental-forge-github': [`ghCommand: ${JSON.stringify(join(bin, 'gh'))}`],
'@deepseek-ai/dsh-experimental-forge-session': ['discoveryTimeoutMs: 20000'],
}
const configPath = join(root, 'cordis.yml')
await writeFile(configPath, [...modules.keys()].flatMap(name => [
`- name: 'cordis:${name}'`,
...name in configs ? [' config:', ...(configs[name] ?? []).map(line => ` ${line}`)] : [],
]).join('\n') + '\n')
const ctx = context = new Context()
ctx.baseUrl = pathToFileURL(root).href + '/'
await ctx.plugin(Loader)
ctx.loader.builtins.include = Include
for (const [specifier, plugin] of modules) ctx.loader.builtins[specifier] = plugin
await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(configPath).href } })
await ctx.loader.await()
for (const entry of ctx.loader.entries()) await entry.fiber?.await()
const path = `PATH=${JSON.stringify(`${bin}:`)}"$PATH"`
const model = new ScriptedModel([`${path} git push -u origin feat`, `${path} gh pr create --fill --head other`, `${path} gh pr view 9`])
ctx.llm.registerAdapter(['fixture'], model)
const agent = await ctx.agentLoop.create(SessionId('forge-session-loader'), { provider: 'fixture', model: 'fixture' }, { cwd: root })
const idle: PromiseWithResolvers<void> = Promise.withResolvers()
const stop = ctx.on('agent/status', ({ agent: subject, status }) => {
if (subject === agent && status === 'idle') idle.resolve()
})
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Push feat and open a pull request.' }], source: { kind: 'user' } }))
await idle.promise
stop()
expect(model.requests).toHaveLength(4)
const github = (number: number) => ({ repository: { forge: 'github', host: 'github.com', owner: 'octo', name: 'demo' }, number })
const records = () => agent.session.snapshotEvents().flatMap((event) => {
const record = pluginRecordOf(event)
return record === undefined ? [] : [record.data]
})
await vi.waitFor(() => { expect(records()).toEqual([
{ action: 'discover', url: 'https://github.com/octo/demo/pull/7' },
]) })
const byUrl = (entries: readonly SessionChangeRequest[]) => [...entries].sort((left, right) => left.url.localeCompare(right.url))
expect(byUrl(ctx.forgeSession.changeRequests(agent.session))).toEqual([
{ url: 'https://github.com/octo/demo/pull/7', origin: 'discovered', ref: github(7), update: null },
{ url: 'https://github.com/octo/demo/pull/8', origin: 'created', ref: github(8), update: null },
{ url: 'https://github.com/octo/demo/pull/9', origin: 'referenced', ref: github(9), update: null },
])
const lookups = (await readFile(join(root, 'gh.log'), 'utf8')).trim().split('\n').map(line => JSON.parse(line) as string[])
expect(lookups.find(args => args.includes('branch=feat'))).toEqual(expect.arrayContaining(['--hostname', 'github.com', 'owner=octo', 'name=demo']))
const listener = vi.fn<SessionChangeRequestsListener>()
ctx.forgeSession.watch(agent.session, listener)
await vi.waitFor(() => { expect(byUrl(listener.mock.lastCall?.[0] ?? []).map(entry => entry.update !== null && 'snapshot' in entry.update
? [entry.update.snapshot.title, entry.update.snapshot.checksRollup]
: null)).toEqual([['Change 7', 'pending'], ['Change 8', 'success'], ['Change 9', 'success']]) })
const entry = [...ctx.loader.entries()].find(candidate => candidate.options.name === 'cordis:@deepseek-ai/dsh-experimental-forge-session')
await entry?.fiber?.dispose()
expect(ctx.sessionProjections.checkpoint(agent.session)).not.toHaveProperty('forgeChangeRequests')
}, 60_000)
})