mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
Merge remote-tracking branch 'origin/master' into worktree/web-mention-ux-polish-1bf698
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write apps/cli/reference/README.md
|
||||
README.md: 0391cb18d89263d6b53319d8b125731178ac5132
|
||||
README.zh.md: 13a4873bbd19401991d2a62f50602e133330aa35
|
||||
README.md: dcb5f7fd36ba2f5db3e4ce65abc23f266872c4d3
|
||||
README.zh.md: bc87059883f641d850e18516b86f4bb089d8158e
|
||||
|
||||
@@ -89,9 +89,9 @@ New sessions in base-backed profiles default to the `workspace-write` permission
|
||||
|
||||
## Shared deployment behavior
|
||||
|
||||
The base bundle mounts the native DeepSeek adapter, settings and credential providers, stable `web_search`, the public-only HTTP fetch provider, and disabled session telemetry. Provider credentials resolve from the inherited environment, `$DSH_HOME/.credentials.yaml`, the invoking directory's `.env`, then `$DSH_HOME/.env`; the managed document is never materialized into `process.env`, while both `.env` files are ordinary launch environment layers. Search uses `DEEPSEEK_API_KEY` and accepts `DEEPSEEK_SEARCH_BASE_URL`. The Web app's `cordis`, `code`, and `standard` agent presets expose `web_fetch` in every sandbox and approval mode without per-call confirmation; the provider still rejects non-public destinations before connecting.
|
||||
The base bundle mounts the native DeepSeek adapter, settings and credential providers, stable `web_search`, the public-only HTTP fetch provider, and feedback-gated session telemetry. Provider credentials resolve from the inherited environment, `$DSH_HOME/.credentials.yaml`, the invoking directory's `.env`, then `$DSH_HOME/.env`; the managed document is never materialized into `process.env`, while both `.env` files are ordinary launch environment layers. Search uses `DEEPSEEK_API_KEY` and accepts `DEEPSEEK_SEARCH_BASE_URL`. The Web app's `cordis`, `code`, and `standard` agent presets expose `web_fetch` in every sandbox and approval mode without per-call confirmation; the provider still rejects non-public destinations before connecting.
|
||||
|
||||
Session telemetry stays local by default. `DSH_TELEMETRY_MODE=FULL` streams every projected session event as OTLP/HTTP logs, while `DSH_TELEMETRY_MODE=FEEDBACK_ONLY` uploads a session-log suffix only when feedback is recorded. `DSH_TELEMETRY_OTLP_URL` selects another collector, and any non-empty `DSH_TELEMETRY_DISABLED` remains an authoritative hard opt-out. The shipped base has no telemetry redaction rule, so explicitly enabled exports can contain message text, tool arguments and results, and workspace paths; the [default-off Agent Note](../../../.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.md) owns that deployment decision.
|
||||
Session telemetry defaults to feedback-gated sharing: nothing is uploaded until the user records `/feedback`, and each recorded feedback uploads the session records not yet shared, through that event; a resumed session shares only its current lifecycle. `DSH_TELEMETRY_MODE=FULL` instead streams every projected session event as OTLP/HTTP logs, `DSH_TELEMETRY_MODE=DISABLED` keeps everything local, and any non-empty `DSH_TELEMETRY_DISABLED` remains an authoritative hard opt-out. `DSH_TELEMETRY_OTLP_URL` selects another collector. The shipped base has no telemetry redaction rule, so released exports can contain message text, tool arguments and results, and workspace paths; the [feedback-gated-default Agent Note](../../../.agents/notes/implemented/feature/2026-08-25-feedback-gated-telemetry-default.md) owns that deployment decision.
|
||||
|
||||
Install external plugin bundles through `dsh plugin --profile <name> add <package-or-git-spec>`. The installed package owns its dependencies and contributes its declared `cordis.patch.yml` layer. The CLI also ships `@deepseek-ai/dsh-mcp-client` as a dependency for patch layers, but no MCP server is enabled by default because each server command is trusted executable code outside the agent sandbox.
|
||||
|
||||
|
||||
@@ -89,9 +89,9 @@ dsh web --help
|
||||
|
||||
## 共享部署行为
|
||||
|
||||
基础组合包挂载原生 DeepSeek 适配器、settings 与凭据提供方、稳定的 `web_search`、仅限公网的 HTTP fetch 提供方,以及已禁用的会话遥测。提供方凭据依次从继承环境、`$DSH_HOME/.credentials.yaml`、调用目录的 `.env` 和 `$DSH_HOME/.env` 解析;受管文档从不物化进 `process.env`,而两个 `.env` 文件都是普通启动环境层。搜索使用 `DEEPSEEK_API_KEY` 并接受 `DEEPSEEK_SEARCH_BASE_URL`。Web app 的 `cordis`、`code` 与 `standard` agent preset 会在所有 sandbox 和审批模式下暴露 `web_fetch`,无需逐次确认;提供方仍会在连接前拒绝非公开目的地址。
|
||||
基础组合包挂载原生 DeepSeek 适配器、settings 与凭据提供方、稳定的 `web_search`、仅限公网的 HTTP fetch 提供方,以及按反馈门控的会话遥测。提供方凭据依次从继承环境、`$DSH_HOME/.credentials.yaml`、调用目录的 `.env` 和 `$DSH_HOME/.env` 解析;受管文档从不物化进 `process.env`,而两个 `.env` 文件都是普通启动环境层。搜索使用 `DEEPSEEK_API_KEY` 并接受 `DEEPSEEK_SEARCH_BASE_URL`。Web app 的 `cordis`、`code` 与 `standard` agent preset 会在所有 sandbox 和审批模式下暴露 `web_fetch`,无需逐次确认;提供方仍会在连接前拒绝非公开目的地址。
|
||||
|
||||
会话遥测默认留在本地。`DSH_TELEMETRY_MODE=FULL` 将每条已投影会话事件作为 OTLP/HTTP 日志流式发送,`DSH_TELEMETRY_MODE=FEEDBACK_ONLY` 则仅在记录反馈时上传会话日志后缀。`DSH_TELEMETRY_OTLP_URL` 选择其他 collector。任何非空的 `DSH_TELEMETRY_DISABLED` 都是具有最终效力的遥测强制关闭开关。随附基础配置没有遥测脱敏规则,因此显式启用的导出可能包含消息文本、工具参数和结果,以及 workspace 路径;相关部署决策见[默认关闭 Agent Note](../../../.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.zh.md)。
|
||||
会话遥测默认按反馈门控共享:在用户记录 `/feedback` 之前不上传任何数据,每条已记录的反馈通过该事件上传尚未共享的会话记录;恢复的会话只共享当前生命周期。`DSH_TELEMETRY_MODE=FULL` 改为将每条已投影会话事件作为 OTLP/HTTP 日志流式发送,`DSH_TELEMETRY_MODE=DISABLED` 让全部数据留在本地,任何非空的 `DSH_TELEMETRY_DISABLED` 仍是具有最终效力的遥测强制关闭开关。`DSH_TELEMETRY_OTLP_URL` 选择其他 collector。随附基础配置没有遥测脱敏规则,因此释放的导出可能包含消息文本、工具参数和结果,以及 workspace 路径;相关部署决策见[反馈门控默认值 Agent Note](../../../.agents/notes/implemented/feature/2026-08-25-feedback-gated-telemetry-default.zh.md)。
|
||||
|
||||
通过 `dsh plugin --profile <name> add <package-or-git-spec>` 安装外部插件组合包。安装的包拥有其依赖,并贡献其声明的 `cordis.patch.yml` 层。CLI 还随附 `@deepseek-ai/dsh-mcp-client` 作为供 patch 层使用的依赖,但默认不启用 MCP 服务器,因为每条服务器命令都是 agent(智能体)沙箱之外的受信任可执行代码。
|
||||
|
||||
|
||||
@@ -19,6 +19,10 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
/** Published-entry acceptance for argument errors, profile lifecycle, and boot-free config dumps. */
|
||||
const repoRoot = fileURLToPath(new URL('../../../', import.meta.url))
|
||||
// The dsh built bin cold-starts slowly on the contended self-hosted Windows pool; the
|
||||
// execa deadline, its error text, the outer vitest case budget, and waitForFile all
|
||||
// share this value so a widening cannot leave a stale 25s diagnostic behind.
|
||||
const SPAWN_TIMEOUT_MS = 60_000
|
||||
// The release version, including a prerelease such as 0.0.1-rc.1: `--version`
|
||||
// prints what this manifest carries, so no test may pin it to a literal.
|
||||
const cliVersion = (JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as { version: string }).version
|
||||
@@ -36,7 +40,7 @@ async function runBuiltBin(
|
||||
)
|
||||
const result = await execa(process.execPath, [dshBin, ...args], {
|
||||
input: '',
|
||||
timeout: 25_000,
|
||||
timeout: SPAWN_TIMEOUT_MS,
|
||||
killSignal: 'SIGKILL',
|
||||
reject: false,
|
||||
env: childEnv,
|
||||
@@ -44,13 +48,13 @@ async function runBuiltBin(
|
||||
...cwd === undefined ? {} : { cwd },
|
||||
})
|
||||
if (result.timedOut) {
|
||||
throw new Error(`dsh built bin did not exit within 25s. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`)
|
||||
throw new Error(`dsh built bin did not exit within ${SPAWN_TIMEOUT_MS / 1_000}s. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`)
|
||||
}
|
||||
return { stdout: result.stdout, code: result.exitCode ?? -1, stderr: result.stderr }
|
||||
}
|
||||
|
||||
async function waitForFile(file: string): Promise<void> {
|
||||
const deadline = Date.now() + 20_000
|
||||
const deadline = Date.now() + SPAWN_TIMEOUT_MS
|
||||
while (!existsSync(file)) {
|
||||
if (Date.now() >= deadline) throw new Error(`dsh profile lifecycle marker did not appear: ${file}`)
|
||||
await new Promise(resolve => setTimeout(resolve, 20))
|
||||
@@ -146,6 +150,8 @@ function startProfileLifecycle(fixture: ProfileLifecycleFixture, args: readonly
|
||||
return execa(process.execPath, [dshBin, '--profile', 'lifecycle', ...args], {
|
||||
cwd: fixture.home,
|
||||
input: '',
|
||||
timeout: SPAWN_TIMEOUT_MS,
|
||||
killSignal: 'SIGKILL',
|
||||
reject: false,
|
||||
env: {
|
||||
DSH_HOME: fixture.home,
|
||||
@@ -310,7 +316,7 @@ function startStartupProfile(fixture: StartupFixture, args: readonly string[]) {
|
||||
cwd: fixture.home,
|
||||
input: '',
|
||||
reject: false,
|
||||
timeout: 25_000,
|
||||
timeout: SPAWN_TIMEOUT_MS,
|
||||
killSignal: 'SIGKILL',
|
||||
env: {
|
||||
DSH_HOME: fixture.home,
|
||||
@@ -335,7 +341,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
const result = await runBuiltBin(removed)
|
||||
expect(result.code).toBe(1)
|
||||
}
|
||||
}, 30_000)
|
||||
}, SPAWN_TIMEOUT_MS * 3 + 30_000)
|
||||
|
||||
it('routes help and usage errors without activating startup-dependent rows', async () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'dsh-app-help-'))
|
||||
@@ -392,7 +398,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true })
|
||||
}
|
||||
}, process.platform === 'win32' ? 60_000 : 30_000)
|
||||
}, SPAWN_TIMEOUT_MS * 3 + 30_000)
|
||||
|
||||
it('reports SDK startup failure when stdin reaches EOF first', async () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'dsh-built-sdk-startup-failure-'))
|
||||
@@ -416,14 +422,14 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true })
|
||||
}
|
||||
}, 30_000)
|
||||
}, SPAWN_TIMEOUT_MS + 30_000)
|
||||
|
||||
it('serves the SDK protocol through the sdk profile and exits after shutdown', async () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'dsh-built-sdk-'))
|
||||
const child = execa(process.execPath, [dshBin, '--profile', 'sdk'], {
|
||||
cwd: home,
|
||||
reject: false,
|
||||
timeout: 25_000,
|
||||
timeout: SPAWN_TIMEOUT_MS,
|
||||
killSignal: 'SIGKILL',
|
||||
env: {
|
||||
...process.env,
|
||||
@@ -471,7 +477,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
await child
|
||||
rmSync(home, { recursive: true, force: true })
|
||||
}
|
||||
}, 30_000)
|
||||
}, SPAWN_TIMEOUT_MS + 30_000)
|
||||
|
||||
it('runs a mock-backed ACP turn through the acp profile and exits on disconnect', async () => {
|
||||
const apiKey = 'built-acp-profile-key'
|
||||
@@ -484,7 +490,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
const child = execa(process.execPath, [dshBin, '--profile', 'acp'], {
|
||||
cwd: home,
|
||||
reject: false,
|
||||
timeout: 25_000,
|
||||
timeout: SPAWN_TIMEOUT_MS,
|
||||
killSignal: 'SIGKILL',
|
||||
env: {
|
||||
...process.env,
|
||||
@@ -555,7 +561,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
await server.close()
|
||||
rmSync(home, { recursive: true, force: true })
|
||||
}
|
||||
}, 30_000)
|
||||
}, SPAWN_TIMEOUT_MS + 30_000)
|
||||
|
||||
it('runs the headless profile through its app-owned task positional', async () => {
|
||||
const apiKey = 'built-dsh-headless-key'
|
||||
@@ -583,7 +589,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
await server.close()
|
||||
rmSync(home, { recursive: true, force: true })
|
||||
}
|
||||
}, 30_000)
|
||||
}, SPAWN_TIMEOUT_MS + 30_000)
|
||||
|
||||
it('does not load a project environment for --version', async () => {
|
||||
const project = mkdtempSync(join(tmpdir(), 'dsh-version-project-'))
|
||||
@@ -606,7 +612,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true })
|
||||
}
|
||||
}, 30_000)
|
||||
}, SPAWN_TIMEOUT_MS + 30_000)
|
||||
|
||||
it('uses the launching endpoint and managed credential through the published entry', async () => {
|
||||
const apiKey = 'built-home-layer-key'
|
||||
@@ -646,7 +652,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
rmSync(home, { recursive: true, force: true })
|
||||
rmSync(project, { recursive: true, force: true })
|
||||
}
|
||||
}, 30_000)
|
||||
}, SPAWN_TIMEOUT_MS + 30_000)
|
||||
|
||||
it('reports a patch-overlay boot failure without hanging', async () => {
|
||||
// The HMR main watcher's initial scan once refreshed the include
|
||||
@@ -666,7 +672,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true })
|
||||
}
|
||||
}, 30_000)
|
||||
}, SPAWN_TIMEOUT_MS + 30_000)
|
||||
|
||||
it('lets a profile without a parser ignore app arguments and dispose on a startup-time signal', async () => {
|
||||
const fixture = createProfileLifecycleFixture()
|
||||
@@ -682,7 +688,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
child.kill('SIGKILL')
|
||||
rmSync(fixture.home, { recursive: true, force: true })
|
||||
}
|
||||
}, 30_000)
|
||||
}, SPAWN_TIMEOUT_MS + 30_000)
|
||||
|
||||
it('fully settles a custom profile, hot-reloads its patch layer with removal reverting, and disposes on a signal', async () => {
|
||||
const fixture = createProfileLifecycleFixture()
|
||||
@@ -734,7 +740,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
child.kill('SIGKILL')
|
||||
rmSync(fixture.home, { recursive: true, force: true })
|
||||
}
|
||||
}, 30_000)
|
||||
}, SPAWN_TIMEOUT_MS + 30_000)
|
||||
|
||||
it('hands the app arguments to the profile, which applies them before its rows start', async () => {
|
||||
const fixture = createStartupFixture()
|
||||
@@ -750,7 +756,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
child.kill('SIGKILL')
|
||||
rmSync(fixture.home, { recursive: true, force: true })
|
||||
}
|
||||
}, 30_000)
|
||||
}, SPAWN_TIMEOUT_MS + 30_000)
|
||||
|
||||
it('starts a consumer on its composed value when the invocation carries no app arguments', async () => {
|
||||
const fixture = createStartupFixture()
|
||||
@@ -764,7 +770,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
child.kill('SIGKILL')
|
||||
rmSync(fixture.home, { recursive: true, force: true })
|
||||
}
|
||||
}, 30_000)
|
||||
}, SPAWN_TIMEOUT_MS + 30_000)
|
||||
|
||||
it('keeps the app arguments across a user patch reload', async () => {
|
||||
// A live edit recomposes every row while the provider service remains
|
||||
@@ -798,7 +804,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
child.kill('SIGKILL')
|
||||
rmSync(fixture.home, { recursive: true, force: true })
|
||||
}
|
||||
}, 30_000)
|
||||
}, SPAWN_TIMEOUT_MS + 30_000)
|
||||
|
||||
it("prints the app's own help, starts none of its rows, and exits", async () => {
|
||||
const fixture = createStartupFixture()
|
||||
@@ -811,7 +817,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
} finally {
|
||||
rmSync(fixture.home, { recursive: true, force: true })
|
||||
}
|
||||
}, 30_000)
|
||||
}, SPAWN_TIMEOUT_MS + 30_000)
|
||||
|
||||
it('anchors a relative add spec to the invoking directory, not the profile', async () => {
|
||||
// `dsh plugin --profile x add .` from a plugin checkout must install THAT
|
||||
@@ -829,7 +835,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
const result = await execa(process.execPath, [dshBin, 'plugin', '--profile', 'anchor', 'add', '.'], {
|
||||
cwd: checkout,
|
||||
input: '',
|
||||
timeout: 60_000,
|
||||
timeout: SPAWN_TIMEOUT_MS,
|
||||
killSignal: 'SIGKILL',
|
||||
reject: false,
|
||||
env: { DSH_HOME: home },
|
||||
@@ -860,7 +866,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
rmSync(home, { recursive: true, force: true })
|
||||
rmSync(checkout, { recursive: true, force: true })
|
||||
}
|
||||
}, 90_000)
|
||||
}, SPAWN_TIMEOUT_MS * 2 + 30_000)
|
||||
|
||||
it('activates a dependency that gained dsh.bundle in a later update', async () => {
|
||||
// Reconcile runs against the INSTALLED state on every successful pnpm
|
||||
@@ -897,7 +903,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true })
|
||||
}
|
||||
}, 30_000)
|
||||
}, SPAWN_TIMEOUT_MS * 2 + 30_000)
|
||||
|
||||
describe('config dump', () => {
|
||||
let home: string
|
||||
@@ -913,7 +919,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
expect(stdout).toContain('# == @deepseek-ai/dsh-base')
|
||||
expect(stdout).toContain("name: '@deepseek-ai/dsh-host-webserver'")
|
||||
expect(existsSync(join(home, 'profiles', 'node_modules'))).toBe(false)
|
||||
}, 30_000)
|
||||
}, SPAWN_TIMEOUT_MS + 30_000)
|
||||
|
||||
it('prints the headless profile without Host or browser layers', async () => {
|
||||
const { stdout, code, stderr } = await runBuiltBin(
|
||||
@@ -926,7 +932,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
expect(stdout).not.toMatch(/name: '@deepseek-ai\/dsh-host-/)
|
||||
expect(stdout).not.toContain("name: '@deepseek-ai/dsh-web-app'")
|
||||
expect(stdout).not.toMatch(/name: '@deepseek-ai\/dsh-client-/)
|
||||
}, 30_000)
|
||||
}, SPAWN_TIMEOUT_MS + 30_000)
|
||||
|
||||
it('prints the exact standalone sdk-minimal tree without dsh-base', async () => {
|
||||
const { stdout, code, stderr } = await runBuiltBin(
|
||||
@@ -959,7 +965,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
expect(stdout).toContain('# == @deepseek-ai/dsh-sdk-minimal')
|
||||
expect(stdout).not.toContain('@deepseek-ai/dsh-base')
|
||||
expect(stdout).not.toContain('@deepseek-ai/dsh-web-app')
|
||||
}, 30_000)
|
||||
}, SPAWN_TIMEOUT_MS * 2 + 30_000)
|
||||
|
||||
it('composes the profile user layer and a --patch overlay in order', async () => {
|
||||
// Auto-init the web profile first, then write its user layer.
|
||||
@@ -998,6 +1004,6 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
// Both layers patched the row; the comment lists them in application order.
|
||||
expect(stdout).toContain(`patched by ${profilePatch}, ${overlay}`)
|
||||
expect(stderr).toContain('patch: entry "absent-row" not found')
|
||||
}, 30_000)
|
||||
}, SPAWN_TIMEOUT_MS * 2 + 30_000)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -124,7 +124,6 @@
|
||||
config:
|
||||
provider: spawn
|
||||
toolName: subagent
|
||||
enableModelSelection: true
|
||||
backgroundMode: continuable
|
||||
maxDepth: 1
|
||||
|
||||
|
||||
@@ -153,7 +153,7 @@ describe('Python SDK dsh profile keyless smoke', () => {
|
||||
const tools = modelRequests[0]?.tools as { function?: { name?: string } }[]
|
||||
expect(modelRequests[0]?.reasoning_effort).toBe('max')
|
||||
expect(modelRequests[0]?.max_tokens).toBe(1234)
|
||||
expect(tools.map(tool => tool.function?.name)).toContain('list_subagent_models')
|
||||
expect(tools.map(tool => tool.function?.name)).not.toContain('list_subagent_models')
|
||||
|
||||
child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id: 3, method: 'shutdown' })}\n`)
|
||||
const shutdown = await waitForLine(lines, value => value.id === 3, () => stderr)
|
||||
|
||||
@@ -241,13 +241,19 @@ describe('the shipped Web composition', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('applies the default-off subagent model-selection preference only to new sessions', async () => {
|
||||
await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { enabled: false })
|
||||
it('applies the default-off subagent model allowlist only to new sessions', async () => {
|
||||
await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, {
|
||||
enabled: false,
|
||||
allowedModels: [],
|
||||
})
|
||||
const disabled = await ctx.agents.create({
|
||||
sessionId: SessionId('preset-model-selection-disabled'),
|
||||
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined),
|
||||
})
|
||||
await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { enabled: true })
|
||||
await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, {
|
||||
enabled: true,
|
||||
allowedModels: [{ provider: 'deepseek-official', model: 'deepseek-v4-flash' }],
|
||||
})
|
||||
const enabled = await ctx.agents.create({
|
||||
sessionId: SessionId('preset-model-selection-enabled'),
|
||||
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined),
|
||||
|
||||
@@ -19,10 +19,6 @@
|
||||
- text: 关闭
|
||||
- heading "模型" [level=2]
|
||||
- paragraph: 填入各提供方的 API 密钥即可使用其模型。
|
||||
- region "Subagent 自选模型":
|
||||
- heading "Subagent 自选模型" [level=3]
|
||||
- paragraph: 允许新会话为 subagent 选择提供方、模型和推理强度。运行中的会话不会改变。
|
||||
- switch "允许 subagent 自选模型"
|
||||
- status: 已保存 minimax-cn。
|
||||
- list:
|
||||
- listitem:
|
||||
|
||||
@@ -19,10 +19,6 @@
|
||||
- text: 关闭
|
||||
- heading "模型" [level=2]
|
||||
- paragraph: 填入各提供方的 API 密钥即可使用其模型。
|
||||
- region "Subagent 自选模型":
|
||||
- heading "Subagent 自选模型" [level=3]
|
||||
- paragraph: 允许新会话为 subagent 选择提供方、模型和推理强度。运行中的会话不会改变。
|
||||
- switch "允许 subagent 自选模型"
|
||||
- list:
|
||||
- listitem:
|
||||
- text: minimax-cn
|
||||
|
||||
@@ -19,10 +19,6 @@
|
||||
- text: 关闭
|
||||
- heading "模型" [level=2]
|
||||
- paragraph: 填入各提供方的 API 密钥即可使用其模型。
|
||||
- region "Subagent 自选模型":
|
||||
- heading "Subagent 自选模型" [level=3]
|
||||
- paragraph: 允许新会话为 subagent 选择提供方、模型和推理强度。运行中的会话不会改变。
|
||||
- switch "允许 subagent 自选模型"
|
||||
- list:
|
||||
- listitem:
|
||||
- text: minimax-cn
|
||||
|
||||
@@ -19,10 +19,6 @@
|
||||
- text: 关闭
|
||||
- heading "模型" [level=2]
|
||||
- paragraph: 填入各提供方的 API 密钥即可使用其模型。
|
||||
- region "Subagent 自选模型":
|
||||
- heading "Subagent 自选模型" [level=3]
|
||||
- paragraph: 允许新会话为 subagent 选择提供方、模型和推理强度。运行中的会话不会改变。
|
||||
- switch "允许 subagent 自选模型"
|
||||
- list
|
||||
- text: 提供方
|
||||
- combobox "提供方":
|
||||
|
||||
@@ -19,10 +19,6 @@
|
||||
- text: 关闭
|
||||
- heading "模型" [level=2]
|
||||
- paragraph: 填入各提供方的 API 密钥即可使用其模型。
|
||||
- region "Subagent 自选模型":
|
||||
- heading "Subagent 自选模型" [level=3]
|
||||
- paragraph: 允许新会话为 subagent 选择提供方、模型和推理强度。运行中的会话不会改变。
|
||||
- switch "允许 subagent 自选模型"
|
||||
- list:
|
||||
- listitem:
|
||||
- text: DeepSeek
|
||||
|
||||
@@ -19,10 +19,6 @@
|
||||
- text: 关闭
|
||||
- heading "模型" [level=2]
|
||||
- paragraph: 填入各提供方的 API 密钥即可使用其模型。
|
||||
- region "Subagent 自选模型":
|
||||
- heading "Subagent 自选模型" [level=3]
|
||||
- paragraph: 允许新会话为 subagent 选择提供方、模型和推理强度。运行中的会话不会改变。
|
||||
- switch "允许 subagent 自选模型"
|
||||
- list:
|
||||
- listitem:
|
||||
- text: DeepSeek
|
||||
|
||||
@@ -32,6 +32,10 @@
|
||||
- 'button "展开设置: Agent 循环"':
|
||||
- text: Agent 循环 Agent 如何派发工具调用。
|
||||
- img
|
||||
- listitem:
|
||||
- 'button "展开设置: Subagent"':
|
||||
- text: Subagent 控制 Agent 为 Subagent 选择模型的权限。
|
||||
- img
|
||||
- listitem:
|
||||
- 'button "展开设置: 网页搜索"':
|
||||
- text: 网页搜索 DeepSeek 搜索提供方。
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
// Keyless assembled-browser coverage for the shipped FEEDBACK_ONLY default
|
||||
// over the Web bundles and the real host wire. The scaffold mounts the
|
||||
// shipped telemetry row in FEEDBACK_ONLY mode against this suite's own
|
||||
// loopback mock collector, so the default release path is real: /feedback
|
||||
// releases the session records through that event (exactly one OTLP request,
|
||||
// carrying the drive prompt and the feedback text), the acknowledgement pins
|
||||
// the feedback-gated disclosure sentence, and a second feedback releases only
|
||||
// the records since the first handoff — the earlier prompt does not repeat.
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
import { createServer, type Server } from 'node:http'
|
||||
import { once } from 'node:events'
|
||||
import { gunzipSync } from 'node:zlib'
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import {
|
||||
assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
|
||||
launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/feedback-release', import.meta.url))
|
||||
// The release path needs only a settled ordinary turn, so this lane replays
|
||||
// the feedback-command scenario's recorded session (declared as this
|
||||
// manifest's `session.source`) instead of recording a duplicate.
|
||||
const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/feedback-command/session.jsonl', import.meta.url))
|
||||
const ACK_EXPECTED = join(SNAPSHOT_DIR, 'ack.expected.md')
|
||||
const MODE = webSnapshotMode()
|
||||
|
||||
const PROMPT = 'Reply with the single word LIGHTHOUSE and stop.'
|
||||
|
||||
describe('web e2e: feedback-gated release under the shipped default mode', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
let collector: Server
|
||||
const uploads: string[] = []
|
||||
|
||||
beforeAll(async () => {
|
||||
collector = createServer((request, response) => {
|
||||
const chunks: Buffer[] = []
|
||||
request.on('data', chunk => chunks.push(chunk as Buffer))
|
||||
request.on('end', () => {
|
||||
const raw = Buffer.concat(chunks)
|
||||
uploads.push((request.headers['content-encoding'] === 'gzip' ? gunzipSync(raw) : raw).toString())
|
||||
response.writeHead(200, { 'content-type': 'application/json' }).end('{}')
|
||||
})
|
||||
})
|
||||
collector.listen(0, '127.0.0.1')
|
||||
await once(collector, 'listening')
|
||||
const address = collector.address()
|
||||
if (address === null || typeof address === 'string') throw new Error('collector has no port')
|
||||
scaffold = await launchWebScaffold({
|
||||
telemetryUrl: `http://127.0.0.1:${address.port}/v1/logs`,
|
||||
telemetryMode: 'FEEDBACK_ONLY',
|
||||
// The replayed session.jsonl belongs to the feedback-command scenario;
|
||||
// comparing (or refreshing) the persisted session here would rewrite
|
||||
// that shared source with this lane's feedback events. The release
|
||||
// evidence lives in this lane's golden and collector assertions.
|
||||
compareReplaySession: false,
|
||||
...(MODE === 'record' ? {} : { replayFixture: FIXTURE }),
|
||||
})
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
await connectFreshWorkspace(page, scaffold.workspaceCwd)
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close()
|
||||
await scaffold?.close()
|
||||
collector?.close()
|
||||
collector?.closeAllConnections()
|
||||
})
|
||||
|
||||
it('drives the recorded prompt to a settled turn (all modes)', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-feedback-release-drive'))
|
||||
if (MODE !== 'record') {
|
||||
// Drift guard: the shared fixture must carry exactly the drive prompt.
|
||||
expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT])
|
||||
}
|
||||
const input = page.locator('[data-composer-input]').first()
|
||||
await input.waitFor({ timeout: 10_000 })
|
||||
const settled = scaffold.whenTurnSettled()
|
||||
await input.fill(PROMPT)
|
||||
await input.press('Enter')
|
||||
const sessionId = await settled
|
||||
if (MODE === 'record') {
|
||||
// Re-records the SHARED feedback-command session this lane replays.
|
||||
await recordFixture(scaffold, sessionId, FIXTURE)
|
||||
}
|
||||
}, 60_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('releases the session records through the feedback and pins the disclosure', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-feedback-release'))
|
||||
await page.getByText('LIGHTHOUSE', { exact: true }).waitFor({ timeout: 15_000 })
|
||||
expect(uploads).toEqual([])
|
||||
const input = page.locator('[data-composer-input]').first()
|
||||
await input.fill('/feedback the diff view is unreadable')
|
||||
await input.press('Enter')
|
||||
|
||||
await page.getByText(/Feedback recorded for session/).waitFor({ timeout: 10_000 })
|
||||
expect(await page.getByText(/recording feedback uploads the session records not yet shared/).count()).toBe(1)
|
||||
|
||||
// FEEDBACK_ONLY releases through the committed feedback event: exactly
|
||||
// one request reaches the collector, carrying the whole unshared range.
|
||||
await expect.poll(() => uploads.length, { timeout: 15_000 }).toBe(1)
|
||||
expect(uploads[0]).toContain('the diff view is unreadable')
|
||||
expect(uploads[0]).toContain(PROMPT)
|
||||
|
||||
const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(ACK_EXPECTED, snapshot, MODE)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('releases only the records since the last handoff on a second feedback', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-feedback-release-suffix'))
|
||||
const input = page.locator('[data-composer-input]').first()
|
||||
await input.fill('/feedback the second remark')
|
||||
await input.press('Enter')
|
||||
await expect.poll(() => uploads.length, { timeout: 15_000 }).toBe(2)
|
||||
// Suffix semantics: the second release starts after the first feedback's
|
||||
// handoff, so the drive prompt already shared must not repeat.
|
||||
expect(uploads[1]).toContain('the second remark')
|
||||
expect(uploads[1]).not.toContain(PROMPT)
|
||||
}, 60_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['ack.expected.md'])
|
||||
})
|
||||
})
|
||||
@@ -76,7 +76,9 @@ describe('web e2e: plugin configuration section', () => {
|
||||
const dialog = await openPlugins()
|
||||
|
||||
// Every card the shipped web composition exposes: the shell executor, the
|
||||
// agent loop, and the DeepSeek search provider.
|
||||
// agent loop, subagent selection, and the DeepSeek search provider.
|
||||
await dialog.getByText('Subagent', { exact: true }).waitFor({ timeout: 10_000 })
|
||||
expect(await dialog.getByRole('button', { name: '展开设置: Subagent' }).count()).toBe(1)
|
||||
await dialog.getByText('终端', { exact: true }).waitFor({ timeout: 10_000 })
|
||||
expect(await dialog.getByText('Agent 循环', { exact: true }).count()).toBe(1)
|
||||
expect(await dialog.getByText('网页搜索', { exact: true }).count()).toBe(1)
|
||||
@@ -88,6 +90,45 @@ describe('web e2e: plugin configuration section', () => {
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('persists selected adapter routes as the subagent model allowlist', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-config-subagent-model-selection'))
|
||||
const dialog = await openPlugins()
|
||||
await dialog.getByText('Subagent', { exact: true }).click()
|
||||
const toggle = dialog.getByRole('switch', { name: '允许 Agent 为 Subagent 选择模型' })
|
||||
|
||||
await toggle.click()
|
||||
const models = dialog.getByRole('group', { name: 'Agent 可选择的模型' })
|
||||
await models.waitFor({ timeout: 10_000 })
|
||||
const firstModel = models.getByRole('checkbox').first()
|
||||
await firstModel.check()
|
||||
await dialog.getByRole('button', { name: '保存', exact: true }).click()
|
||||
|
||||
const expandSubagent = dialog.getByRole('button', { name: '展开设置: Subagent' })
|
||||
await expandSubagent.waitFor({ timeout: 5_000 })
|
||||
await expect.poll(async () => (await settingsDocument()).includes('subagent-model-selection:'), { timeout: 10_000 })
|
||||
.toBe(true)
|
||||
expect(await settingsDocument()).toContain('enabled: true')
|
||||
expect(await settingsDocument()).toContain('allowedModels:')
|
||||
expect(await settingsDocument()).toContain('provider:')
|
||||
expect(await settingsDocument()).toContain('model:')
|
||||
await expandSubagent.click()
|
||||
await expect.poll(() => toggle.getAttribute('aria-checked'), { timeout: 5_000 }).toBe('true')
|
||||
await expect.poll(() => dialog.getByRole('button', { name: '保存', exact: true }).isDisabled()).toBe(true)
|
||||
expect(await dialog.getByText('未保存', { exact: true }).count()).toBe(0)
|
||||
|
||||
await toggle.click()
|
||||
await dialog.getByRole('button', { name: '保存', exact: true }).click()
|
||||
await expandSubagent.waitFor({ timeout: 5_000 })
|
||||
await expect.poll(async () => (await settingsDocument()).includes('enabled: false'), { timeout: 10_000 })
|
||||
.toBe(true)
|
||||
expect(await settingsDocument()).toContain('allowedModels:')
|
||||
expect(await settingsDocument()).toContain('provider:')
|
||||
expect(await settingsDocument()).toContain('model:')
|
||||
await expandSubagent.click()
|
||||
await expect.poll(() => toggle.getAttribute('aria-checked'), { timeout: 5_000 }).toBe('false')
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('stages an edit and writes it only when saved', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-config-write'))
|
||||
const dialog = await openPlugins()
|
||||
@@ -109,6 +150,9 @@ describe('web e2e: plugin configuration section', () => {
|
||||
|
||||
await expect.poll(async () => (await settingsDocument()).includes('timeoutMs: 12000'), { timeout: 10_000 })
|
||||
.toBe(true)
|
||||
const expandTerminal = dialog.getByRole('button', { name: '展开设置: 终端' })
|
||||
await expandTerminal.waitFor({ timeout: 5_000 })
|
||||
await expandTerminal.click()
|
||||
// Presence in the user layer is what the badge reports, and the reset is
|
||||
// offered only for a field that has one.
|
||||
await expect.poll(() => dialog.getByText('已覆盖').count(), { timeout: 5_000 }).toBe(1)
|
||||
@@ -167,6 +211,9 @@ describe('web e2e: plugin configuration section', () => {
|
||||
|
||||
await expect.poll(async () => (await settingsDocument()).includes('timeoutMs'), { timeout: 10_000 })
|
||||
.toBe(false)
|
||||
const expandTerminal = dialog.getByRole('button', { name: '展开设置: 终端' })
|
||||
await expandTerminal.waitFor({ timeout: 5_000 })
|
||||
await expandTerminal.click()
|
||||
expect(await timeout.inputValue()).toBe('60000')
|
||||
expect(await dialog.getByText('已覆盖').count()).toBe(0)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
|
||||
@@ -329,12 +329,14 @@ export interface LaunchOptions {
|
||||
default: string
|
||||
}
|
||||
/**
|
||||
* Mount the shipped telemetry row in FULL mode against this exporter URL
|
||||
* instead of disabling it. Used to pin a real backend disclosure in
|
||||
* assembled coverage; point the URL at a local dead endpoint so no record
|
||||
* leaves the process.
|
||||
* Mount the shipped telemetry row against this exporter URL instead of
|
||||
* disabling it. Used to pin a real backend disclosure in assembled
|
||||
* coverage; point the URL at a local endpoint (a dead port, or a scenario's
|
||||
* own mock collector) so no record leaves the machine.
|
||||
*/
|
||||
telemetryUrl?: string
|
||||
/** Uploading mode for the mounted telemetry row. Defaults to `FULL`. */
|
||||
telemetryMode?: 'FULL' | 'FEEDBACK_ONLY'
|
||||
/**
|
||||
* Browse through a trusted non-loopback hostname that the browser resolves
|
||||
* to loopback (for example `*.localhost`). The test server stays bound to
|
||||
@@ -497,7 +499,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
|
||||
: {
|
||||
id: 'session-telemetry-otel',
|
||||
config: {
|
||||
mode: 'FULL',
|
||||
mode: options.telemetryMode ?? 'FULL',
|
||||
exporter: { url: options.telemetryUrl },
|
||||
shutdownTimeoutMillis: 1_000,
|
||||
},
|
||||
|
||||
@@ -82,6 +82,7 @@
|
||||
"tests/shipped-composition.e2e.ts",
|
||||
"tests/schedule-after.e2e.ts",
|
||||
"tests/feedback-command.e2e.ts",
|
||||
"tests/feedback-release.e2e.ts",
|
||||
"tests/agent-team-panel.e2e.ts",
|
||||
"tests/startup-auto-selection.e2e.ts",
|
||||
"tests/produced-files.e2e.ts",
|
||||
|
||||
Reference in New Issue
Block a user