Merge origin/master into feat/tui-master-port

This commit is contained in:
Tianyi Cui
2026-07-27 23:34:56 +08:00
713 changed files with 16615 additions and 13373 deletions

View File

@@ -1,12 +1,14 @@
import { describe, expect, it } from 'vitest'
import { randomUUID } from 'node:crypto'
import { mkdtemp } from 'node:fs/promises'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
import { agentEvents } from '@deepseek-ai/dsh-agent'
import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt'
import type { Message } from '@deepseek-ai/dsh-llm'
import { SessionId } from '@deepseek-ai/dsh-session'
import * as acpAgent from '../src/index.ts'
/**
@@ -29,6 +31,7 @@ async function mount(config: acpAgent.Config, withBash = false): Promise<Context
start() { throw new Error('composition test does not execute bash') },
})
}
config.persistenceRoot ??= await mkdtemp(join(tmpdir(), 'dsh-acp-demo-persistence-'))
await ctx.plugin(acpAgent, config)
return ctx
}
@@ -42,12 +45,9 @@ async function isolatedSkillsConfig(catalogDescriptionMaxLength?: number): Promi
}
async function composePrefix(ctx: Context): Promise<Message[]> {
const agent = { session: { header: { cwd: '/tmp' } } } as unknown as Agent
const empty: Message[] = []
return await agentEvents(ctx, agent).waterfall(
'agent/session-prefix', empty, new AbortController().signal,
() => Promise.resolve(empty),
)
const agent = ctx.agentLoop.create(SessionId(`acp-demo-prefix-${randomUUID()}`), {}, { cwd: '/tmp' })
await agentEvents(ctx, agent).serial('agent/step', 1, 1, new AbortController().signal)
return agent.session.deriveMessages()
}
async function withIsolatedSkillHomes<T>(run: () => Promise<T>): Promise<T> {

View File

@@ -33,7 +33,7 @@ const decompress = promisify(zstdDecompress)
const dshPackages = [
'examples/agent-spine-demo', 'core/agent', 'core/session', 'core/system-prompt',
'core/tools', 'core/agent-loop', 'llm/llm', 'bash/bash',
'bash/bash-local', 'bash/tool-bash', 'context/workspace-context', 'support/invariants', 'ui/app-boot',
'bash/bash-local', 'bash/tool-bash', 'subprocess/subprocess', 'subprocess/subprocess-local', 'context/workspace-context', 'support/invariants', 'ui/app-boot',
'session-persistence/session-persistence',
'session-persistence/session-checkpoint-policy', 'session-persistence/session-persistence-jsonl',
'acp/acp', 'examples/acp-demo', 'util/paths',
@@ -95,6 +95,8 @@ async function makeConsumer(): Promise<string> {
await writeFile(join(dir, 'cordis.yml'), [
'- id: mock-llm',
' name: \'./mock-llm.mjs\'',
'- id: subprocess',
' name: \'@deepseek-ai/dsh-subprocess-local\'',
'- id: bash',
' name: \'@deepseek-ai/dsh-bash-local\'',
'- id: acp-agent',

View File

@@ -35,6 +35,8 @@ const CORDIS_YML = `
name: '@deepseek-ai/dsh-llm-deepseek'
config:
apiKey: !!js process.env.DEEPSEEK_API_KEY
- id: subprocess
name: '@deepseek-ai/dsh-subprocess-local'
- id: bash
name: '@deepseek-ai/dsh-bash-local'
- id: acp-agent

View File

@@ -65,6 +65,7 @@
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-paths": "workspace:^",
"@deepseek-ai/dsh-llm-retry": "workspace:^",
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
"@deepseek-ai/dsh-sandbox-local": "workspace:^",
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
"@deepseek-ai/dsh-scope": "workspace:^",

View File

@@ -26,12 +26,9 @@ declare module '@deepseek-ai/dsh-tasks' {
}
async function composePrefix(ctx: Context, cwd: string): Promise<Message[]> {
const agent = { session: { header: { cwd } } } as unknown as Agent
const empty: Message[] = []
return await agentEvents(ctx, agent).waterfall(
'agent/session-prefix', empty, new AbortController().signal,
() => Promise.resolve(empty),
)
const agent = ctx.agentLoop.create(SessionId('agent-spine-prefix'), {}, { cwd })
await agentEvents(ctx, agent).serial('agent/step', 1, 1, new AbortController().signal)
return agent.session.deriveMessages()
}
/**
@@ -99,15 +96,8 @@ async function withIsolatedSkillHomes<T>(run: () => Promise<T>): Promise<T> {
}
}
function waitForIdle(ctx: Context, target: Agent): Promise<void> {
return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (agent, status) => {
if (agent === target && status === 'idle') {
dispose()
resolve()
}
})
})
function waitForIdle(_ctx: Context, target: Agent): Promise<void> {
return target.whenIdle()
}
function messageText(message: Message | undefined): string {
@@ -235,7 +225,7 @@ describe('dsh-agent-spine-demo bundle', () => {
agentOptions: { provider: 'mock', model: 'mock' },
})
handle.agent.followup([{ type: 'text', text: 'recover' }])
handle.agent.followup({ content: [{ type: 'text', text: 'recover' }], source: { kind: 'user' } })
await waitForIdle(ctx, handle.agent)
expect(adapter.requests).toBe(2)
@@ -335,7 +325,7 @@ describe('dsh-agent-spine-demo bundle', () => {
})
const agent = handle.agent
agent.followup([{ type: 'text', text: 'hi' }])
agent.followup({ content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } })
await waitForIdle(ctx, agent)
const sentText = adapter.requests[0]?.messages.map(messageText).join('\n')
@@ -364,7 +354,7 @@ describe('dsh-agent-spine-demo bundle', () => {
agentOptions: { provider: 'mock', model: 'mock' },
})
handle.agent.followup([{ type: 'text', text: 'hi' }])
handle.agent.followup({ content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } })
await waitForIdle(ctx, handle.agent)
expect(adapter.requests[0]?.messages).toEqual([{ role: 'user', content: [{ type: 'text', text: 'hi' }] }])
@@ -454,11 +444,11 @@ describe('dsh-agent-spine-demo bundle', () => {
agentOptions: { provider: 'mock', model: 'mock' },
})
handle.agent.followup([{ type: 'text', text: 'hi' }])
handle.agent.followup({ content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } })
await waitForIdle(ctx, handle.agent)
expect(messageText(adapter.requests[0]?.messages[0])).toContain('workspace rule before skills')
expect(messageText(adapter.requests[0]?.messages[1])).toContain('prefix-order-skill')
expect(messageText(adapter.requests[0]?.messages[1])).toContain('workspace rule before skills')
expect(messageText(adapter.requests[0]?.messages[2])).toContain('prefix-order-skill')
await handle.dispose()
await ctx.fiber.dispose()
} finally {

View File

@@ -5,6 +5,7 @@ import { basename, join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
import * as FsPolicy from '@deepseek-ai/dsh-fs-policy'
import SandboxedFileSystem from '@deepseek-ai/dsh-fs-sandbox'
import { CallId } from '@deepseek-ai/dsh-llm'
@@ -52,6 +53,7 @@ beforeEach(async () => {
ctx = new Context()
await ctx.plugin(LocalSandboxProvider, {})
await ctx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: fallbackRoot })
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(SandboxBashExecutor, { cwd: fallbackRoot, timeoutMs: 30_000 })
await ctx.plugin(SandboxedFileSystem, { cwd: fallbackRoot })
await ctx.plugin(agentSpine, {

View File

@@ -225,22 +225,28 @@ export async function runOneShot(ctx: Context, options: OneShotOptions): Promise
let targetTurn: number | undefined
let reason: TurnEndReason | undefined
let result = ''
const usageByStep = new Map<number, TokenUsage>()
const usageByStep = new Map<string, TokenUsage>()
let outputError: Error | undefined
let resolveTurn!: () => void
let rejectTurn!: (error: Error) => void
let settled = false
let firstTurnEnded = false
const turnEnded = new Promise<void>((resolve, reject) => {
resolveTurn = resolve
rejectTurn = reject
})
const settleResolved = (): void => {
settled = true
if (firstTurnEnded) return
firstTurnEnded = true
resolveTurn()
}
const settleRejected = (error: Error): void => {
settled = true
// The once-registered abort listener is the only rejecter, and a settled
// prompt makes targetTurn defined so onAbort skips rejection entirely;
// kept for symmetry with settleResolved.
/* v8 ignore next -- unreachable second settlement, see above */
if (firstTurnEnded) return
firstTurnEnded = true
rejectTurn(error)
}
const observe = (sessionId: string, event: SessionEvent): void => {
@@ -254,20 +260,26 @@ export async function runOneShot(ctx: Context, options: OneShotOptions): Promise
}
const disposeListener = ctx.on('session/event', (session, event) => {
if (session !== agent.session || settled) return
if (session !== agent.session) return
if (targetTurn === undefined) {
if (event.type !== 'turn/start' || event.data.trigger.kind !== 'message') return
targetTurn = event.data.turn
} else if (event.type === 'turn/start' && event.data.trigger.kind === 'retry'
&& reason?.kind === 'error') {
targetTurn = event.data.turn
reason = undefined
}
observe(session.id, event)
if (event.type === 'assistant/chunk'
&& event.data.turn === targetTurn
&& event.data.chunk.type === 'usage') {
usageByStep.set(event.data.step, event.data.chunk.usage)
usageByStep.set(`${event.data.turn}/${event.data.step}`, event.data.chunk.usage)
}
if (event.type === 'assistant/message' && event.data.turn === targetTurn) {
result = assistantText(event) ?? result
if (event.data.usage !== undefined) usageByStep.set(event.data.step, event.data.usage)
if (event.data.usage !== undefined) {
usageByStep.set(`${event.data.turn}/${event.data.step}`, event.data.usage)
}
}
if (event.type === 'turn/end' && event.data.turn === targetTurn) {
reason = event.data.reason
@@ -289,14 +301,14 @@ export async function runOneShot(ctx: Context, options: OneShotOptions): Promise
try {
/* v8 ignore next -- skips send only when cancellation wins the listener-registration race above */
if (!settled) { // eslint-disable-line @typescript-eslint/no-unnecessary-condition
agent.followup([{ type: 'text', text: options.task }])
if (!firstTurnEnded) { // eslint-disable-line @typescript-eslint/no-unnecessary-condition
agent.followup({ content: [{ type: 'text', text: options.task }], source: { kind: 'user' } })
}
await turnEnded
} finally {
if (onAbort !== undefined) signal?.removeEventListener('abort', onAbort)
disposeListener()
await agent.whenIdle()
disposeListener()
}
/* v8 ignore next 3 -- turnEnded resolves only from the matching branch that assigns both values */
@@ -380,7 +392,6 @@ export function formatTurnFailure(reason: TurnEndReason): string {
case 'error': return `failed at step ${reason.step}: ${'failure' in reason ? reason.failure.message : reason.message}`
case 'disposed': return 'was disposed'
case 'max-tokens': return 'reached the model output-token limit'
case 'rejected': return `was rejected: ${reason.reason}`
case 'interrupted': return 'was interrupted during persistence recovery'
default: return `ended with ${JSON.stringify(reason)}`
}

View File

@@ -23,7 +23,7 @@ const decompress = promisify(zstdDecompress)
const dshPackages = [
'examples/agent-spine-demo', 'examples/cli-demo', 'core/agent', 'core/session',
'core/system-prompt', 'core/tools', 'core/agent-loop', 'llm/llm', 'bash/bash',
'bash/bash-local', 'bash/tool-bash', 'support/invariants', 'ui/app-boot',
'bash/bash-local', 'bash/tool-bash', 'subprocess/subprocess', 'subprocess/subprocess-local', 'support/invariants', 'ui/app-boot',
'session-persistence/session-persistence', 'session-persistence/session-checkpoint-policy',
'session-persistence/session-persistence-jsonl',
'context/workspace-context',
@@ -80,6 +80,8 @@ async function makeConsumer(): Promise<string> {
await writeFile(join(dir, 'cordis.yml'), [
'- id: mock-llm',
" name: './mock-llm.ts'",
'- id: subprocess',
" name: '@deepseek-ai/dsh-subprocess-local'",
'- id: bash',
" name: '@deepseek-ai/dsh-bash-local'",
'- id: cli-agent',

View File

@@ -1,9 +1,11 @@
import { mkdtemp } from 'node:fs/promises'
import { randomUUID } from 'node:crypto'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
import { agentEvents } from '@deepseek-ai/dsh-agent'
import { SessionId } from '@deepseek-ai/dsh-session'
import { CallId, type Message } from '@deepseek-ai/dsh-llm'
import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt'
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
@@ -33,18 +35,16 @@ async function mount(config: cliDemo.Config, withBash = false): Promise<Context>
})
}
contexts.push(ctx)
config.persistenceRoot ??= await mkdtemp(join(tmpdir(), 'dsh-cli-demo-persistence-'))
await ctx.plugin(cliDemo, config)
await new Promise(resolve => setTimeout(resolve, 80))
return ctx
}
async function composePrefix(ctx: Context): Promise<Message[]> {
const agent = { session: { header: { cwd: '/tmp' } } } as unknown as Agent
const empty: Message[] = []
return await agentEvents(ctx, agent).waterfall(
'agent/session-prefix', empty, new AbortController().signal,
() => Promise.resolve(empty),
)
const agent = ctx.agentLoop.create(SessionId(`cli-demo-prefix-${randomUUID()}`), {}, { cwd: '/tmp' })
await agentEvents(ctx, agent).serial('agent/step', 1, 1, new AbortController().signal)
return agent.session.deriveMessages()
}
afterEach(async () => {

View File

@@ -319,7 +319,7 @@ describe('runOneShot and executeCli', () => {
const { ctx, agent, persistenceRoot } = await harness([textResponse('final answer')])
const output = await invoke(ctx, ['task'])
expect(output).toEqual({ code: 0, stdout: 'final answer\n', stderr: '' })
expect(agent.status).toBe('disposed')
expect(agent.status).toBe('idle')
const files = await readdir(persistenceRoot, { recursive: true })
expect(files.some(file => file.endsWith('.jsonl.zstd'))).toBe(true)
})
@@ -372,18 +372,20 @@ describe('runOneShot and executeCli', () => {
ctx.on('agent/inbox/enqueue', (subject) => {
if (subject !== agent || injected) return
injected = true
agent.inject([{ type: 'text', text: 'startup injection' }], { source: { kind: 'plugin', plugin: 'test' } })
agent.inject({ content: [{ type: 'text', text: 'startup injection' }], source: { kind: 'plugin', plugin: 'test' } })
other.append('turn/start', { turn: 1, trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'test' } } })
other.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
})
const output = await invoke(ctx, ['--output-format', 'stream-json', 'task'])
const lines = output.stdout.trimEnd().split('\n').map(line => JSON.parse(line) as Record<string, unknown>)
const events = lines.slice(0, -1).map(line => line['event'] as SessionEvent)
expect(lines.at(-1)).toMatchObject({ type: 'result', success: true, turn: 2, result: 'streamed' })
expect(events[0]).toMatchObject({ type: 'turn/start', data: { turn: 2, trigger: { kind: 'message' } } })
expect(events.at(-1)).toMatchObject({ type: 'turn/end', data: { turn: 2 } })
expect(lines.at(-1)).toMatchObject({ type: 'result', success: true, turn: 1, result: 'streamed' })
expect(events[0]).toMatchObject({ type: 'turn/start', data: { turn: 1, trigger: { kind: 'message' } } })
expect(events.at(-1)).toMatchObject({ type: 'turn/end', data: { turn: 1 } })
expect(lines.slice(0, -1).every(line => line['sessionId'] === agent.session.id)).toBe(true)
expect(events.some(event => event.type === 'user/message' && event.data.source.kind !== 'user')).toBe(false)
expect(events.some(event => event.type === 'user/message'
&& event.data.source.kind === 'plugin'
&& event.data.source.plugin === 'test')).toBe(false)
})
it('emits partial data and a diagnostic for non-completed turns', async () => {
@@ -409,7 +411,7 @@ describe('runOneShot and executeCli', () => {
expect(JSON.parse(output.stdout)).toMatchObject({ success: false, reason: { kind: 'aborted' } })
expect(output.code).toBe(1)
expect(output.stderr).toContain('turn 1 was aborted')
expect(agent.status).toBe('disposed')
expect(agent.status).toBe('idle')
})
it('contains stream-writer failures, cancels, flushes, and returns the output error', async () => {
@@ -444,7 +446,7 @@ describe('runOneShot and executeCli', () => {
expect(output.code).toBe(1)
expect(output.stdout).toBe('')
expect(output.stderr).toContain('stdout closed')
expect(final.agent.status).toBe('disposed')
expect(final.agent.status).toBe('idle')
const disposal = await harness([textResponse('answer')])
const disposalOutput = await invoke(disposal.ctx, ['task'], { failDispose: true })
@@ -471,7 +473,7 @@ describe('runOneShot and executeCli', () => {
startup.ctx.on('session/event', (session, event) => {
if (session === startup.agent.session && event.type === 'assistant/chunk') started()
})
startup.agent.followup([{ type: 'text', text: 'first' }])
startup.agent.followup({ content: [{ type: 'text', text: 'first' }], source: { kind: 'user' } })
await running
const startupAbort = new AbortController()
const waiting = runOneShot(startup.ctx, { task: 'second', signal: startupAbort.signal })
@@ -499,7 +501,6 @@ describe('formatTurnFailure', () => {
[{ kind: 'error', step: 3, failure: { message: 'provider bad', code: 'SERVER' } }, 'failed at step 3: provider bad'],
[{ kind: 'disposed' }, 'was disposed'],
[{ kind: 'max-tokens' }, 'output-token limit'],
[{ kind: 'rejected', reason: 'policy' }, 'was rejected: policy'],
[{ kind: 'interrupted' }, 'persistence recovery'],
]
for (const [reason, expected] of cases) expect(formatTurnFailure(reason)).toContain(expected)