Ver Fonte

test(subagent-sdk): keyless Loader-composition e2e across the SDK wire

A test-only cordis.yml under examples/jsonrpc-agent boots the headless app
through the Loader; a scripted model delegates once to the SDK backend, whose
child is a COMPLETE second harness runtime (own cordis.yml, jsonrpc serving
surface, scripted cwd-echo model, own JSONL persistence). Asserts the parent
tool result AND the child's own persisted transcript both carry the parent
session's workspace cwd; child launch resolves through the shared
example-launch resolver so src/lib modes both hold.
Tianyi Cui há 2 meses atrás
pai
commit
3e89a73c71

+ 32 - 0
examples/jsonrpc-agent/tests/fixtures/subagent/subagent-sdk/child-mock-llm.ts

@@ -0,0 +1,32 @@
+import type { Context } from 'cordis'
+import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
+import { LlmAdapter } from '@deepseek-ai/dsh-llm'
+
+/**
+ * Scripted model for the CHILD runtime: answers every request with its own
+ * process cwd, so the driving e2e can prove the parent session's workspace
+ * reached the child process across the SDK wire. `options` carries the
+ * request; the reply depends only on process state.
+ */
+class CwdEchoAdapter extends LlmAdapter {
+  async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
+    void options
+    const reply = `child cwd: ${process.cwd()}`
+    yield { type: 'block-start', index: 0, blockType: 'text' }
+    yield { type: 'text-delta', index: 0, text: reply }
+    yield { type: 'block-end', index: 0, block: { type: 'text', text: reply } }
+    yield { type: 'usage', usage: { inputTokens: 3, outputTokens: reply.length } }
+    yield { type: 'finish', reason: { kind: 'stop' } }
+  }
+}
+
+export const name = 'child-mock-llm'
+export const inject = ['llm']
+
+/**
+ * Register the cwd-echo adapter under the `mock` provider.
+ * @param ctx - the plugin context supplying `ctx.llm`.
+ */
+export function apply(ctx: Context): void {
+  ctx.llm.registerAdapter(['mock'], new CwdEchoAdapter())
+}

+ 36 - 0
examples/jsonrpc-agent/tests/fixtures/subagent/subagent-sdk/child.cordis.yml

@@ -0,0 +1,36 @@
+# The CHILD runtime for the SDK subagent composition test: a complete
+# stdio JSON-RPC harness whose scripted model echoes its process cwd. The
+# parent's subagent-sdk backend spawns this composition per run; stdout is
+# reserved for JSON-RPC frames.
+- id: jsonrpc
+  name: '@deepseek-ai/dsh-jsonrpc'
+
+- id: child-mock-llm
+  name: './child-mock-llm.ts'
+
+- id: agent-core
+  name: '@deepseek-ai/dsh-agent-spine-demo'
+  config:
+    persona: 'Echo where you run.'
+    workspaceContext: false
+    skills:
+      enabled: false
+    toolBash:
+      enableRunInBackground: false
+    toolTasks: false
+
+# The child persists its own session log beside the parent's (distinct root),
+# so the driving e2e can inspect both transcripts after the run.
+- id: sessions
+  name: '@deepseek-ai/dsh-session-persistence-jsonl'
+  config:
+    root: !!js process.env.DSH_SESSION_ROOT ?? './.child-sessions'
+    compression: none
+
+- id: session-checkpoints
+  name: '@deepseek-ai/dsh-session-checkpoint-policy'
+
+- id: bash
+  name: '@deepseek-ai/dsh-bash-local'
+  config:
+    cwd: !!js process.env.DSH_CWD ?? process.cwd()

+ 42 - 0
examples/jsonrpc-agent/tests/fixtures/subagent/subagent-sdk/cordis.yml

@@ -0,0 +1,42 @@
+# Test-only composition: the SDK subagent backend on the real Loader/app path.
+# The scripted model delegates once; the child — a COMPLETE second harness
+# runtime speaking stdio JSON-RPC — echoes its process cwd, so parent-session
+# cwd inheritance is asserted keylessly end to end across the SDK wire.
+# `cwd` is deliberately omitted — the inheritance branch under test. The child
+# launch is machine-absolute, so the driving e2e supplies it via
+# DSH_TEST_CHILD_COMMAND / DSH_TEST_CHILD_ARGS / DSH_TEST_CHILD_ENV (resolved
+# through the shared example-launch resolver, per testing policy).
+- id: mock-llm
+  name: './mock-delegating-llm.ts'
+
+- id: subagent
+  name: '@deepseek-ai/dsh-subagent'
+
+- id: subagent-sdk
+  name: '@deepseek-ai/dsh-subagent-sdk'
+  config:
+    providerName: sdk
+    command: !!js process.env.DSH_TEST_CHILD_COMMAND
+    args: !!js JSON.parse(process.env.DSH_TEST_CHILD_ARGS ?? '[]')
+    provider: mock
+    model: mock-echo
+    env: !!js JSON.parse(process.env.DSH_TEST_CHILD_ENV ?? '{}')
+
+- id: tool-subagent
+  name: '@deepseek-ai/dsh-tool-subagent'
+  config:
+    provider: sdk
+    toolName: subagent
+    # The SDK backend advertises no depthLimit: the child harness owns its own
+    # recursion budget, so the local numeric default cannot apply here.
+    maxDepth: 'provider-managed'
+
+- id: cli-agent
+  name: '@deepseek-ai/dsh-cli-demo'
+  config:
+    provider: mock
+    model: mock-delegate
+    persona: 'Test SDK subagent cwd inheritance.'
+    persistenceRoot: './.sessions'
+    persistenceCompression: 'none'
+    workspaceContext: false

+ 15 - 0
examples/jsonrpc-agent/tests/fixtures/subagent/subagent-sdk/driver.ts

@@ -0,0 +1,15 @@
+#!/usr/bin/env node
+/** Test driver: one delegation turn through a headless Loader composition. */
+
+import { boot, resolveConfigPath } from '@deepseek-ai/dsh-app-boot'
+import { runOneShot } from '@deepseek-ai/dsh-cli-demo/src/cli.ts'
+
+const configPath = process.argv[2]
+if (configPath === undefined) throw new Error('sdk-subagent cwd driver requires a config path')
+
+const ctx = await boot('sdk-subagent-cwd-e2e', resolveConfigPath(configPath, undefined))
+try {
+  await runOneShot(ctx, { task: 'delegate' })
+} finally {
+  await ctx.fiber.dispose()
+}

+ 48 - 0
examples/jsonrpc-agent/tests/fixtures/subagent/subagent-sdk/mock-delegating-llm.ts

@@ -0,0 +1,48 @@
+import type { Context } from 'cordis'
+import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
+import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm'
+
+/**
+ * Test adapter for the `mock-delegate` model: the first request calls the
+ * `subagent` tool once, and the follow-up streams the tool result text back
+ * verbatim — so the SDK child runtime's answer (the scripted child model's
+ * cwd echo) reaches the parent session log for the driving e2e to assert.
+ */
+class MockDelegatingAdapter extends LlmAdapter {
+  async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
+    const toolResultText = options.messages.at(-1)?.content
+      .filter(block => block.type === 'tool-result')
+      .flatMap(block => block.content)
+      .filter(block => block.type === 'text')
+      .map(block => block.text)
+      .join('') ?? ''
+
+    if (toolResultText.length === 0) {
+      const args = JSON.stringify({ description: 'cwd probe', prompt: 'report your workspace' })
+      yield { type: 'block-start', index: 0, blockType: 'tool-call' }
+      yield { type: 'tool-call-delta', index: 0, id: CallId('call-delegate'), name: 'subagent', argumentsDelta: args }
+      yield { type: 'block-end', index: 0, block: { type: 'tool-call', id: CallId('call-delegate'), name: 'subagent', arguments: args } }
+      yield { type: 'usage', usage: { inputTokens: 10, outputTokens: 5 } }
+      yield { type: 'finish', reason: { kind: 'tool-calls' } }
+      return
+    }
+
+    const reply = `child reported:\n${toolResultText}`
+    yield { type: 'block-start', index: 0, blockType: 'text' }
+    yield { type: 'text-delta', index: 0, text: reply }
+    yield { type: 'block-end', index: 0, block: { type: 'text', text: reply } }
+    yield { type: 'usage', usage: { inputTokens: 10, outputTokens: reply.length } }
+    yield { type: 'finish', reason: { kind: 'stop' } }
+  }
+}
+
+export const name = 'mock-llm'
+export const inject = ['llm']
+
+/**
+ * Register the delegating mock adapter under the `mock` provider.
+ * @param ctx - the plugin context supplying `ctx.llm`.
+ */
+export function apply(ctx: Context): void {
+  ctx.llm.registerAdapter(['mock'], new MockDelegatingAdapter())
+}

+ 7 - 6
examples/package.json

@@ -3,7 +3,7 @@
   "private": true,
   "version": "0.0.1",
   "type": "module",
-  "description": "Workspace umbrella for runnable demos and example-owned test compositions: declares their cordis.yml packages so plain Node resolves real exports\u2192lib. Not a build target.",
+  "description": "Workspace umbrella for runnable demos and example-owned test compositions: declares their cordis.yml packages so plain Node resolves real exports→lib. Not a build target.",
   "dependencies": {
     "@cordisjs/plugin-hmr": "workspace:*",
     "@cordisjs/plugin-include": "workspace:*",
@@ -30,24 +30,24 @@
     "@deepseek-ai/dsh-llm-replay": "workspace:*",
     "@deepseek-ai/dsh-lsp": "workspace:*",
     "@deepseek-ai/dsh-lsp-local": "workspace:*",
-    "@deepseek-ai/dsh-plan-mode": "workspace:*",
     "@deepseek-ai/dsh-permission": "workspace:*",
+    "@deepseek-ai/dsh-plan-mode": "workspace:*",
     "@deepseek-ai/dsh-pty": "workspace:*",
     "@deepseek-ai/dsh-pty-local": "workspace:*",
     "@deepseek-ai/dsh-repeat-tool-guard": "workspace:*",
     "@deepseek-ai/dsh-sandbox-local": "workspace:*",
     "@deepseek-ai/dsh-sandbox-policy": "workspace:^",
-    "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:*",
     "@deepseek-ai/dsh-session-checkpoint-policy": "workspace:*",
+    "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:*",
     "@deepseek-ai/dsh-session-query": "workspace:*",
     "@deepseek-ai/dsh-session-query-sqlite": "workspace:*",
+    "@deepseek-ai/dsh-session-title-first-message-llm": "workspace:*",
     "@deepseek-ai/dsh-spill-local": "workspace:*",
     "@deepseek-ai/dsh-spill-policy": "workspace:*",
-    "@deepseek-ai/dsh-tui-demo": "workspace:*",
-    "@deepseek-ai/dsh-session-title-first-message-llm": "workspace:*",
     "@deepseek-ai/dsh-subagent": "workspace:*",
     "@deepseek-ai/dsh-subagent-acp": "workspace:*",
     "@deepseek-ai/dsh-subagent-fork": "workspace:*",
+    "@deepseek-ai/dsh-subagent-sdk": "workspace:*",
     "@deepseek-ai/dsh-subagent-spawn": "workspace:*",
     "@deepseek-ai/dsh-tasks-local": "workspace:*",
     "@deepseek-ai/dsh-time-context": "workspace:*",
@@ -57,15 +57,16 @@
     "@deepseek-ai/dsh-tool-cordis": "workspace:*",
     "@deepseek-ai/dsh-tool-fs": "workspace:*",
     "@deepseek-ai/dsh-tool-fs-search": "workspace:*",
-    "@deepseek-ai/dsh-tool-pty": "workspace:*",
     "@deepseek-ai/dsh-tool-goal": "workspace:*",
     "@deepseek-ai/dsh-tool-lsp": "workspace:*",
+    "@deepseek-ai/dsh-tool-pty": "workspace:*",
     "@deepseek-ai/dsh-tool-ralph": "workspace:*",
     "@deepseek-ai/dsh-tool-session-query": "workspace:*",
     "@deepseek-ai/dsh-tool-subagent": "workspace:*",
     "@deepseek-ai/dsh-tool-todo": "workspace:*",
     "@deepseek-ai/dsh-tool-workflow": "workspace:*",
     "@deepseek-ai/dsh-tools": "workspace:*",
+    "@deepseek-ai/dsh-tui-demo": "workspace:*",
     "@deepseek-ai/dsh-user-approval": "workspace:*",
     "@deepseek-ai/dsh-web": "workspace:*",
     "@deepseek-ai/dsh-web-fetch-local": "workspace:*",

+ 19 - 4
knip.json

@@ -36,6 +36,9 @@
         "tui-agent/tests/fixtures/tui-scripted-llm.ts",
         "acp-agent/tests/fixtures/subagent/subagent-acp/mock-delegating-llm.ts",
         "acp-agent/tests/fixtures/subagent/subagent-acp/driver.ts",
+        "jsonrpc-agent/tests/fixtures/subagent/subagent-sdk/driver.ts",
+        "jsonrpc-agent/tests/fixtures/subagent/subagent-sdk/child-mock-llm.ts",
+        "jsonrpc-agent/tests/fixtures/subagent/subagent-sdk/mock-delegating-llm.ts",
         "*/tests/**/*.e2e.ts",
         "*/tests/**/*.snapshot.ts"
       ],
@@ -262,8 +265,14 @@
       ]
     },
     "packages/session-query/session-query-sqlite": {
-      "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"],
-      "project": ["src/**/*.ts", "tests/**/*.ts"]
+      "entry": [
+        "tests/**/*.spec.ts",
+        "tests/**/*.e2e.ts"
+      ],
+      "project": [
+        "src/**/*.ts",
+        "tests/**/*.ts"
+      ]
     },
     "packages/code-runtime/code-runtime-worker": {
       "entry": [
@@ -316,8 +325,14 @@
       ]
     },
     "packages/session-persistence/session-checkpoint-policy": {
-      "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"],
-      "project": ["src/**/*.ts", "tests/**/*.ts"]
+      "entry": [
+        "tests/**/*.spec.ts",
+        "tests/**/*.e2e.ts"
+      ],
+      "project": [
+        "src/**/*.ts",
+        "tests/**/*.ts"
+      ]
     },
     "packages/util/paths": {
       "entry": [

+ 101 - 0
packages/subagent/subagent-sdk/tests/loader-composition.e2e.ts

@@ -0,0 +1,101 @@
+/**
+ * Keyless REAL-composition coverage for parent-session cwd inheritance across
+ * the SDK wire: a test-only cordis.yml boots the headless app through the
+ * Loader with the SDK backend's `cwd` omitted, a scripted model delegates
+ * once, and the child — a COMPLETE second harness runtime booted from its own
+ * cordis.yml and driven over stdio JSON-RPC — echoes where it actually ran.
+ * Both the parent's tool result and the child's own persisted session log
+ * must carry the parent session's cwd. Mock-only composition, so only this
+ * keyless tier applies (the with-key tier lives in subagent-sdk.e2e.ts).
+ */
+
+import { realpathSync } from 'node:fs'
+import { readFile, readdir } from 'node:fs/promises'
+import { join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { describe, expect, it } from 'vitest'
+import { type SessionEvent } from '@deepseek-ai/dsh-session'
+import { LOADER_SMOKE_TEST_TIMEOUT_MS, resolveExampleLaunch, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
+
+const fixtureDir = new URL('../../../../examples/jsonrpc-agent/tests/fixtures/subagent/subagent-sdk/', import.meta.url)
+const driver = fileURLToPath(new URL('driver.ts', fixtureDir))
+const configPath = fileURLToPath(new URL('cordis.yml', fixtureDir))
+const childConfigPath = fileURLToPath(new URL('child.cordis.yml', fixtureDir))
+const runtimeBin = fileURLToPath(new URL('../../../../packages/examples/jsonrpc-demo/src/bin.ts', import.meta.url))
+const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url))
+
+async function jsonlFiles(dir: string): Promise<string[]> {
+  const entries = await readdir(dir, { withFileTypes: true })
+  const paths = await Promise.all(entries.map(async (entry) => {
+    const path = join(dir, entry.name)
+    if (entry.isDirectory()) return jsonlFiles(path)
+    return entry.isFile() && entry.name.endsWith('.jsonl') ? [path] : []
+  }))
+  return paths.flat()
+}
+
+async function sessionEvents(log: string): Promise<SessionEvent[]> {
+  const lines = (await readFile(log, 'utf8')).trimEnd().split('\n')
+  return lines.slice(1).map(line => JSON.parse(line) as SessionEvent)
+}
+
+describe('SDK subagent cwd inheritance through a real cordis.yml', () => {
+  it('runs the child runtime in the parent session workspace', async () => {
+    // The child launch honors the same src/lib mode as the driving harness,
+    // per the shared example-launch resolver (testing policy forbids
+    // hand-written `--import tsx` argv for example subprocesses).
+    const childLaunch = resolveExampleLaunch({
+      srcBin: runtimeBin,
+      configArgs: [childConfigPath],
+      tsconfigPath: repoTsconfig,
+    })
+
+    let events: SessionEvent[] = []
+    let childEvents: SessionEvent[] = []
+    let workspace = ''
+    const { stderr } = await runLoaderSmoke({
+      label: 'sdk-subagent cwd composition smoke',
+      tempDirPrefix: 'sdk-subagent-cwd-e2e-',
+      binScript: driver,
+      libBinScript: driver,
+      configPath,
+      tsconfigPath: repoTsconfig,
+      env: {
+        DSH_TEST_CHILD_COMMAND: childLaunch.command,
+        DSH_TEST_CHILD_ARGS: JSON.stringify(childLaunch.args),
+        DSH_TEST_CHILD_ENV: JSON.stringify({
+          ...Object.fromEntries(Object.entries(childLaunch.env).filter(([, value]) => value !== undefined)),
+        }),
+      },
+      inspect: async (cwd) => {
+        // The child reports realpaths; canonicalize the temp workspace to match.
+        workspace = realpathSync(cwd)
+        const parentLogs = await jsonlFiles(join(cwd, '.sessions'))
+        expect(parentLogs).toHaveLength(1)
+        events = await sessionEvents(parentLogs[0] as string)
+        // The child runtime persisted its own transcript in ITS cwd — which
+        // must be the parent session's workspace for the inheritance to hold.
+        const childLogs = await jsonlFiles(join(cwd, '.child-sessions'))
+        expect(childLogs).toHaveLength(1)
+        childEvents = await sessionEvents(childLogs[0] as string)
+      },
+    })
+    expect(stderr).not.toContain('UNHANDLED')
+
+    // The parent's tool result carries the child model's echo of its real
+    // process.cwd() — the parent session's workspace, never the harness
+    // process's launch directory.
+    const results = events.filter(event => event.type === 'tool/result')
+    expect(results).toHaveLength(1)
+    const resultText = results[0]!.data.content
+      .filter(block => block.type === 'text')
+      .map(block => block.text)
+      .join('')
+    expect(resultText).toBe(`child cwd: ${workspace}`)
+
+    // The child ran a real turn of its own: user message in, assistant out.
+    expect(childEvents.some(event => event.type === 'user/message')).toBe(true)
+    const childAnswers = childEvents.filter(event => event.type === 'assistant/message')
+    expect(childAnswers.length).toBeGreaterThan(0)
+  }, LOADER_SMOKE_TEST_TIMEOUT_MS)
+})