Merge remote-tracking branch 'origin/master' into xtr/agent-loop-message-machine
# Conflicts: # .agents/notes/implemented/feature/2026-06-14-acp-agent-client-protocol.md # .agents/notes/implemented/feature/2026-06-14-acp-agent-client-protocol.zh.md # .agents/notes/implemented/feature/2026-06-30-hook-bridges.i18n.yaml # .agents/notes/implemented/feature/2026-06-30-interception-seams.i18n.yaml # .agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml # .agents/notes/implemented/feature/2026-07-06-sandbox.md # .agents/notes/implemented/feature/2026-07-06-sandbox.zh.md # .agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml # .agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.i18n.yaml # .agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.i18n.yaml # .agents/notes/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.i18n.yaml # docs/architecture.i18n.yaml # docs/cookbook/adding-a-tool.i18n.yaml # docs/cookbook/extension-cookbook.i18n.yaml # docs/core-data-structures/llm-streaming.i18n.yaml # docs/core-data-structures/session.i18n.yaml # docs/core-data-structures/tools.i18n.yaml # docs/event-producer-consumer.md # docs/persistence-catalog.md # examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl # examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl # examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl # examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl # examples/acp-agent/tests/snapshots/permission-switching/session.jsonl # examples/acp-agent/tests/snapshots/plan-mode-reject/session.jsonl # examples/acp-agent/tests/snapshots/plan-mode/session.jsonl # examples/acp-agent/tests/snapshots/session-sandbox-root/session.jsonl # packages/context/session-reference/README.md # packages/core/agent-loop/tests/agent.spec.ts # packages/hooks/hooks-claude/tests/coverage-cases.ts # packages/host/runtime/tests/host-runtime.spec.ts # packages/llm/llm-retry/tests/retry.spec.ts # packages/session-persistence/session-persistence/src/coordinator.ts # packages/support/acp-snapshot/README.md # packages/support/acp-snapshot/src/normalize.ts # packages/ui/acp/acp-feature-support.md # packages/ui/acp/src/codec.ts # packages/ui/acp/src/index.ts # packages/ui/acp/tests/bridge.spec.ts # packages/ui/acp/tests/codec.spec.ts # packages/ui/acp/tests/config-options.spec.ts # packages/ui/acp/tests/dispose.spec.ts # packages/ui/acp/tests/edges.spec.ts # packages/ui/acp/tests/stream-update.spec.ts # packages/ui/acp/tests/turns.spec.ts
This commit is contained in:
@@ -5,10 +5,10 @@ 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 { SessionId } from '@deepseek-ai/dsh-session'
|
||||
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'
|
||||
|
||||
/**
|
||||
@@ -32,9 +32,6 @@ async function mount(config: acpAgent.Config, withBash = false): Promise<Context
|
||||
})
|
||||
}
|
||||
await ctx.plugin(acpAgent, config)
|
||||
// The bundle mounts its children inside apply() (not awaited there); let their
|
||||
// fibers settle so the spine services are ready.
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
return ctx
|
||||
}
|
||||
|
||||
@@ -82,7 +79,6 @@ describe('dsh-acp-demo composition', () => {
|
||||
persona: 'hi',
|
||||
persistenceRoot: '/tmp/dsh-acp-demo-test',
|
||||
persistenceCompression: 'none',
|
||||
sessionReferences: { candidateLimit: 1 },
|
||||
skills: await isolatedSkillsConfig(),
|
||||
workspaceContext: false,
|
||||
})
|
||||
@@ -90,24 +86,20 @@ describe('dsh-acp-demo composition', () => {
|
||||
expect(ctx.get('sessions')).toBeDefined()
|
||||
expect(ctx.get('sessionPersistence')).toBeDefined()
|
||||
expect(ctx.get('sessionQuery')).toBeDefined()
|
||||
expect(ctx.get('sessionReferences')).toBeDefined()
|
||||
expect(ctx.get('sessionReferences')).toBeUndefined()
|
||||
expect((ctx.get('sessionPersistence') as unknown as { config: { compression?: string } }).config.compression).toBe('none')
|
||||
expect(ctx.get('agentLoop')).toBeDefined()
|
||||
expect(ctx.get('userInteraction')).toBeDefined()
|
||||
expect(ctx.get('userInteraction')).toBeUndefined()
|
||||
expect(ctx.get('commands')).toBeUndefined()
|
||||
expect(ctx.get('tools')?.get('ask_user_question')).toBeUndefined()
|
||||
expect(ctx.get('goals')).toBeDefined()
|
||||
expect(ctx.get('tools')?.get('get_goal')).toBeDefined()
|
||||
const target = ctx.sessions.create(SessionId('candidate-target'))
|
||||
ctx.sessions.create(SessionId('candidate-one'))
|
||||
ctx.sessions.create(SessionId('candidate-two'))
|
||||
await expect(ctx.sessionReferences.listCandidates({ id: target.id, session: target } as Agent))
|
||||
.resolves.toHaveLength(1)
|
||||
// No pre-created agents — ACP session/new creates them on demand.
|
||||
expect(ctx.get('agents')!.list()).toHaveLength(0)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('can explicitly omit the persisted-goal stack and its command', async () => {
|
||||
it('can explicitly omit the persisted-goal stack', async () => {
|
||||
const ctx = await mount({
|
||||
provider: 'mock',
|
||||
model: 'mock',
|
||||
@@ -115,12 +107,7 @@ describe('dsh-acp-demo composition', () => {
|
||||
workspaceContext: false,
|
||||
})
|
||||
expect(ctx.get('goals')).toBeUndefined()
|
||||
const handle = await ctx.agents.create({
|
||||
sessionId: 'disabled-goals' as import('@deepseek-ai/dsh-session').SessionId,
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
})
|
||||
expect(ctx.commands.find(handle.agent, 'goal')).toBeUndefined()
|
||||
await handle.dispose()
|
||||
expect(ctx.get('tools')?.get('get_goal')).toBeUndefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
@@ -131,8 +118,12 @@ describe('dsh-acp-demo composition', () => {
|
||||
// persistenceRoot, so the runtime fallback is the one that fires.
|
||||
const ctx = new Context()
|
||||
// No persona: covers the omitted-persona forwarding branch too.
|
||||
acpAgent.apply(ctx, { provider: 'mock', model: 'mock', skills: await isolatedSkillsConfig(), workspaceContext: false })
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
await acpAgent.apply(ctx, {
|
||||
provider: 'mock',
|
||||
model: 'mock',
|
||||
skills: await isolatedSkillsConfig(),
|
||||
workspaceContext: false,
|
||||
})
|
||||
expect(ctx.get('sessionPersistence')).toBeDefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
@@ -153,8 +144,7 @@ describe('dsh-acp-demo composition', () => {
|
||||
it('uses default skill config when apply is called directly without skills', async () => {
|
||||
await withIsolatedSkillHomes(async () => {
|
||||
const ctx = new Context()
|
||||
acpAgent.apply(ctx, { provider: 'mock', model: 'mock', workspaceContext: false })
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
await acpAgent.apply(ctx, { provider: 'mock', model: 'mock', workspaceContext: false })
|
||||
expect(ctx.skills).toBeDefined()
|
||||
expect(await ctx.skills.list()).toEqual([])
|
||||
await ctx.fiber.dispose()
|
||||
|
||||
@@ -18,7 +18,6 @@ import { Readable, Writable } from 'node:stream'
|
||||
import { promisify } from 'node:util'
|
||||
import { zstdDecompress } from 'node:zlib'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { ACP_SESSION_REFERENCE_META_KEY } from '@deepseek-ai/dsh-acp'
|
||||
|
||||
/**
|
||||
* Published-entry smoke: run `lib/bin.js` under plain Node in a symlinked external consumer and
|
||||
@@ -36,8 +35,7 @@ const dshPackages = [
|
||||
'bash/bash-local', 'bash/tool-bash', 'context/workspace-context', 'support/invariants', 'ui/app-boot',
|
||||
'session-persistence/session-persistence',
|
||||
'session-persistence/session-checkpoint-policy', 'session-persistence/session-persistence-jsonl',
|
||||
'session-query/session-query', 'session-query/session-query-sqlite',
|
||||
'context/session-reference', 'ui/acp', 'examples/acp-demo', 'util/paths',
|
||||
'acp/acp', 'examples/acp-demo', 'util/paths',
|
||||
]
|
||||
const vendorPackages = [
|
||||
'cordis', 'loader', 'include', 'timer', 'hmr', 'logger-console',
|
||||
@@ -45,8 +43,8 @@ const vendorPackages = [
|
||||
]
|
||||
// Resolve ACP's declared third-party dependencies from that package, not this test: pnpm's strict
|
||||
// layout need not hoist them. Symlink those exact paths into the plain-Node consumer.
|
||||
const npmDeps = ['@agentclientprotocol/sdk', 'zod']
|
||||
const acpPkgDir = join(repoRoot, 'packages/ui/acp')
|
||||
const npmDeps = ['@agentclientprotocol/sdk']
|
||||
const acpPkgDir = join(repoRoot, 'packages/acp/acp')
|
||||
|
||||
async function pkgName(absDir: string): Promise<string> {
|
||||
const json = JSON.parse(await readFile(join(absDir, 'package.json'), 'utf8')) as { name: string }
|
||||
@@ -72,7 +70,7 @@ async function makeConsumer(): Promise<string> {
|
||||
await link(abs, await pkgName(abs), nm)
|
||||
}
|
||||
for (const dep of npmDeps) {
|
||||
// Resolve from `ui/acp`'s package.json URL (the package that declares the
|
||||
// Resolve from ACP's package.json URL (the package that declares the
|
||||
// dep), not this test file's location — `acp-agent` does not depend on these.
|
||||
const fromAcp = pathToFileURL(join(acpPkgDir, 'package.json')).href
|
||||
const resolved = fileURLToPath(import.meta.resolve(`${dep}/package.json`, fromAcp))
|
||||
@@ -154,8 +152,12 @@ describe.skipIf(!existsSync(acpBin))('dsh-acp-demo BUILT bin (node lib/bin.js, n
|
||||
Writable.toWeb(child.stdin!) as WritableStream<Uint8Array>,
|
||||
Readable.toWeb(passthrough) as ReadableStream<Uint8Array>,
|
||||
)
|
||||
const updates: SessionNotification['update'][] = []
|
||||
const makeClient = (_a: AcpAgent): Client => ({
|
||||
sessionUpdate(_p: SessionNotification): Promise<void> { return Promise.resolve() },
|
||||
sessionUpdate(params: SessionNotification): Promise<void> {
|
||||
updates.push(params.update)
|
||||
return Promise.resolve()
|
||||
},
|
||||
requestPermission(_p: RequestPermissionRequest): Promise<RequestPermissionResponse> {
|
||||
return Promise.resolve({ outcome: { outcome: 'cancelled' } })
|
||||
},
|
||||
@@ -163,33 +165,17 @@ describe.skipIf(!existsSync(acpBin))('dsh-acp-demo BUILT bin (node lib/bin.js, n
|
||||
const client = new ClientSideConnection(makeClient, stream)
|
||||
|
||||
const init = await client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
// A response at all proves the built bin booted the bridge (the settle-race
|
||||
// regression would exit before answering); loadSession proves the real app
|
||||
// mounted, not a collapsed export shape.
|
||||
expect(init.agentCapabilities?.loadSession).toBe(true)
|
||||
expect(init.agentCapabilities?.sessionCapabilities?.list).toEqual({})
|
||||
expect(init.agentCapabilities).toEqual({
|
||||
promptCapabilities: { image: false, audio: false, embeddedContext: false },
|
||||
})
|
||||
const sessionCwd = consumer
|
||||
const { sessionId } = await client.newSession({ cwd: sessionCwd, mcpServers: [] })
|
||||
const result = await client.prompt({ sessionId, prompt: [{ type: 'text', text: 'reply' }] })
|
||||
expect(result.stopReason).toBe('end_turn')
|
||||
await expect.poll(async () => {
|
||||
return (await client.listSessions({ cwd: sessionCwd })).sessions.find(candidate => candidate.sessionId === sessionId)
|
||||
}).toMatchObject({
|
||||
sessionId,
|
||||
cwd: sessionCwd,
|
||||
title: 'reply',
|
||||
})
|
||||
const listed = await client.listSessions({ cwd: sessionCwd })
|
||||
const reference = listed.sessions.find(candidate => candidate.sessionId === sessionId)
|
||||
?._meta?.[ACP_SESSION_REFERENCE_META_KEY]
|
||||
expect(reference).toBeTypeOf('object')
|
||||
expect(reference).not.toBeNull()
|
||||
expect(reference).toHaveProperty('uri')
|
||||
if (typeof reference !== 'object' || reference === null || !('uri' in reference)) {
|
||||
throw new Error('expected session reference metadata')
|
||||
}
|
||||
expect(reference.uri).toBeTypeOf('string')
|
||||
expect(reference.uri).toMatch(/^dsh-session:[A-Za-z0-9_-]+$/u)
|
||||
await expect.poll(() => updates).toEqual([{
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: { type: 'text', text: 'ACP BUILT OK' },
|
||||
}])
|
||||
const sessionsRoot = join(sessionCwd, '.sessions')
|
||||
let log: string | undefined
|
||||
await expect.poll(async () => {
|
||||
|
||||
@@ -17,11 +17,10 @@ import {
|
||||
} from '@agentclientprotocol/sdk'
|
||||
|
||||
/**
|
||||
* Source-path Loader smoke through the package's own bin, covering initialize, session/new, and
|
||||
* session/load across the `unwrapExports` path implicated by postmortem 0001. Session creation and
|
||||
* unknown-id loading reach factories but not the model, so a dummy key is sufficient. The temp cwd
|
||||
* is also the session workspace, and an explicit root tsconfig keeps unbuilt path aliases resolvable
|
||||
* when the child starts outside the repository.
|
||||
* Source-path Loader smoke through the package's own bin, covering the
|
||||
* automation server's initialize and fresh-session path across the
|
||||
* `unwrapExports` shape implicated by postmortem 0001. Session creation reaches
|
||||
* the factory but not the model, so a dummy key is sufficient.
|
||||
*/
|
||||
|
||||
const binScript = fileURLToPath(new URL('../src/bin.ts', import.meta.url))
|
||||
@@ -29,8 +28,8 @@ const tsxLoader = fileURLToPath(import.meta.resolve('tsx'))
|
||||
// Repo root is four levels up from packages/examples/acp-demo/tests.
|
||||
const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url))
|
||||
|
||||
// A minimal leaf that loads this app + the two backends — the same shape as
|
||||
// examples/acp-agent/cordis.yml, inlined so the package test owns its fixture.
|
||||
// A minimal opt-in leaf that loads this app + the two backends and the optional
|
||||
// session-query consumer/policies, inlined so the package test owns its fixture.
|
||||
const CORDIS_YML = `
|
||||
- id: llm-deepseek
|
||||
name: '@deepseek-ai/dsh-llm-deepseek'
|
||||
@@ -45,6 +44,16 @@ const CORDIS_YML = `
|
||||
model: deepseek-v4-flash
|
||||
persona: 'You are a test agent.'
|
||||
workspaceContext: false
|
||||
- id: tool-session-query
|
||||
name: '@deepseek-ai/dsh-tool-session-query'
|
||||
- id: timeout-policy
|
||||
name: '@deepseek-ai/dsh-timeout-policy'
|
||||
- id: spill-local
|
||||
name: '@deepseek-ai/dsh-spill-local'
|
||||
- id: spill-policy
|
||||
name: '@deepseek-ai/dsh-spill-policy'
|
||||
config:
|
||||
maxInlineBytes: 50000
|
||||
`
|
||||
|
||||
interface Spawned {
|
||||
@@ -107,7 +116,7 @@ async function boot(): Promise<Spawned & { cwd: string }> {
|
||||
}
|
||||
|
||||
describe('dsh-acp-demo real-load-path smoke (bin + Loader, keyless)', () => {
|
||||
it('boots via its bin and answers initialize → session/new → session/load', async () => {
|
||||
it('boots via its bin and exposes only fresh text sessions', async () => {
|
||||
const { client, cwd, stderr } = await boot()
|
||||
// initialize: a broken export shape (collapsed bridge plugin, dropped inject)
|
||||
// crashes the tree on the first service read here — see postmortem 0001.
|
||||
@@ -115,22 +124,14 @@ describe('dsh-acp-demo real-load-path smoke (bin + Loader, keyless)', () => {
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
clientCapabilities: {},
|
||||
})
|
||||
expect(init.agentCapabilities?.loadSession).toBe(true)
|
||||
expect(init.agentCapabilities).toEqual({
|
||||
promptCapabilities: { image: false, audio: false, embeddedContext: false },
|
||||
})
|
||||
|
||||
// session/new reaches the agent FACTORY (create) without the model.
|
||||
const { sessionId } = await client.newSession({ cwd, mcpServers: [] })
|
||||
expect(sessionId).toBeTruthy()
|
||||
|
||||
// session/load reaches the resume FACTORY + persistence without the model: load an UNKNOWN
|
||||
// id (loading the live `sessionId` would correctly reject as "already loaded"). Persistence
|
||||
// and resume run from the JSON-RPC loop outside bridge injection; a healthy tree reaches
|
||||
// not-found, while a collapsed export would fail earlier with missing injection.
|
||||
const unknownId = '00000000-0000-4000-8000-000000000000'
|
||||
await client.loadSession({ sessionId: unknownId, cwd, mcpServers: [] }).then(
|
||||
() => { throw new Error('expected session/load of an unknown id to reject') },
|
||||
(error: unknown) => { expect(String(error)).not.toContain('without inject') },
|
||||
)
|
||||
|
||||
expect(stderr.join('')).not.toContain('without inject')
|
||||
}, 30_000)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user