Merge remote-tracking branch 'origin/master' into worktree/web-mention-ux-polish-1bf698

This commit is contained in:
Yichen Jiang
2026-08-27 15:35:28 +08:00
181 changed files with 3267 additions and 1873 deletions
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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.
+2 -2
View File
@@ -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(智能体)沙箱之外的受信任可执行代码。
+34 -28
View File
@@ -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)
+9 -3
View File
@@ -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 搜索提供方。
+137
View File
@@ -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'])
})
})
+48 -1
View File
@@ -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([])
+7 -5
View File
@@ -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,
},
+1
View File
@@ -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",