Merge finalized Web transcript parent
# Conflicts: # apps/cli/README.i18n.yaml # packages/client/runtime/README.i18n.yaml # packages/client/ui-trajectory/README.i18n.yaml # packages/client/ui-trajectory/README.zh.md
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/README.md
|
||||
README.md: a686cc7f3a783faed1e6b8a5c85306bd61e2992b
|
||||
README.zh.md: 24db23715c8b4186ff491b17f9bacbd9b06ae6a2
|
||||
README.md: db4489c6087f5e844dcc2dda723d208c8137186d
|
||||
README.zh.md: 7880e98459a844cdb8124ed89c0ef90ea6301868
|
||||
|
||||
@@ -18,8 +18,7 @@ The TUI surface:
|
||||
|
||||
`dsh upgrade` is a guided fresh-session entry over the default TUI surface: it mints a fresh session in the invoking directory and seeds its first turn with the bundled `dsh-upgrade` skill, exactly as if the user typed `/skill:<name>`. The launcher passes the skill name on the boot context ([`INITIAL_SKILL_KEY`](../../packages/ui/tui/README.md)), which the TUI auto-invokes once the chat is live. Both take no options — `--config`, `-p`, and `--resume` fail loud — and seed only on this first launch, so a later `dsh --resume <id>` of the session is an ordinary TUI session with no re-injection.
|
||||
|
||||
|
||||
The Web and headless surfaces boot `base.cordis.yml` plus `web.cordis.yml`, then apply `$DSH_HOME/config.yaml`; an explicit `--config <path>` replaces that personal overlay. Both surfaces otherwise share the same composition: both treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root <path>` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, and opt into first-message model titles. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`).
|
||||
The Web and headless surfaces boot `base.cordis.yml` plus `web.cordis.yml`, then apply `$DSH_HOME/config.yaml`; an explicit `--config <path>` replaces that personal overlay. Both surfaces otherwise share the same composition: both treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root <path>` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, opt into first-message model titles, use the same bounded transient model-request retry policy as the TUI, and mount a disposable in-memory SQLite content-index service. That service is ACTIVE at boot, while its `node:sqlite` module and database handle open only on the first content search. This keeps Node 22 startup output free of SQLite's experimental warning before search is used; the first actual search may still emit the runtime warning. Each service instance owns its database, so parallel invocations neither share unsupported SQLite state nor leave derived index files behind, and the first search lazily reconciles live and persisted logs. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`).
|
||||
|
||||
The shipped TUI and Web compositions register the native DeepSeek adapter plus pi-ai OpenAI and Anthropic profiles. Credentials and endpoint overrides come from the provider-standard `DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`, `OPENAI_API_KEY` / `OPENAI_BASE_URL`, and `ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL` pairs in the boot's layered environment.
|
||||
|
||||
|
||||
@@ -18,8 +18,7 @@ TUI 界面:
|
||||
|
||||
`dsh upgrade` 是默认 TUI 界面之上的引导式全新会话入口:它在调用目录中创建一个全新会话,并以内置 `dsh-upgrade` skill 播种其首轮,效果等同于用户手动键入 `/skill:<name>`。启动器将 skill 名称提供到启动上下文([`INITIAL_SKILL_KEY`](../../packages/ui/tui/README.md)),TUI 在聊天就绪后自动调用它。两者都不接受任何选项——`--config`、`-p`、`--resume` 都会明确报错——且仅在首次启动时播种,因此之后 `dsh --resume <id>` 恢复该会话时是普通 TUI 会话,不会重复注入。
|
||||
|
||||
|
||||
Web 和无头界面启动 `base.cordis.yml` 与 `web.cordis.yml`,随后应用 `$DSH_HOME/config.yaml`;显式的 `--config <path>` 会替代该个人覆盖。除此之外,两者共享同一套组合:两者都将调用目录视为默认项目和 Workspace 根目录,除非通过 `--workspace-root <path>` 覆盖,否则会在该根目录下创建具名 Workspace;它们会把适用的 `AGENTS.md`/`CLAUDE.md` 指令加载到每个 agent-loop 请求前缀中,渲染预算为 65,536 字节,并选用首条消息模型标题。无头界面唯一的差异是监听操作系统分配的端口(并行 `dsh -p` 运行绝不冲突;stderr 打印的 URL 会在浏览器中打开实时会话)。两者都需要先构建前端 dist 和客户端 bundle(`pnpm run build && pnpm run build:web`)。
|
||||
Web 和无头界面启动 `base.cordis.yml` 与 `web.cordis.yml`,随后应用 `$DSH_HOME/config.yaml`;显式的 `--config <path>` 会替代该个人覆盖。除此之外,两者共享同一套组合:两者都将调用目录视为默认项目和 Workspace 根目录,除非通过 `--workspace-root <path>` 覆盖,否则会在该根目录下创建具名 Workspace;它们会把适用的 `AGENTS.md`/`CLAUDE.md` 指令加载到每个 agent-loop 请求前缀中,渲染预算为 65,536 字节,选用首条消息模型标题,采用与 TUI 相同的有界暂时性模型请求重试策略,并挂载一个可丢弃的内存 SQLite 内容索引服务。该服务在启动时处于 ACTIVE 状态,但其 `node:sqlite` 模块与数据库句柄分别要到首次内容搜索才会导入和打开。这样可使 Node 22 在尚未使用搜索时的启动输出不出现 SQLite 实验性警告;首次实际搜索仍可能发出运行时警告。每个服务实例独占自己的数据库,因此并行调用既不会共享不受支持的 SQLite 状态,也不会留下派生索引文件,首次搜索还会惰性对账实时日志与持久化日志。无头界面唯一的差异是监听操作系统分配的端口(并行 `dsh -p` 运行绝不冲突;stderr 打印的 URL 会在浏览器中打开实时会话)。两者都需要先构建前端 dist 和客户端 bundle(`pnpm run build && pnpm run build:web`)。
|
||||
|
||||
已交付的 TUI 和 Web 组合会注册原生 DeepSeek 适配器,以及 pi-ai 的 OpenAI 和 Anthropic 提供方配置。凭据和端点覆盖来自启动分层环境中的提供方标准变量对:`DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`、`OPENAI_API_KEY` / `OPENAI_BASE_URL` 和 `ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL`。
|
||||
|
||||
|
||||
@@ -88,7 +88,8 @@
|
||||
(() => { const path = process.getBuiltinModule('node:path'); const home = process.getBuiltinModule('node:os').homedir(); const configured = process.env.DSH_HOME; const selected = configured !== undefined && configured.trim().length > 0 ? configured : path.join(home, '.dsh'); const expanded = selected === '~' ? home : selected.startsWith('~/') || selected.startsWith('~\\') ? path.join(home, selected.slice(2)) : selected; return path.join(path.resolve(expanded), 'sessions') })()
|
||||
|
||||
# TUI consumes this shared session capability. Its launcher supplies a unique
|
||||
# process-local path; non-TUI surfaces disable the row in their overlay.
|
||||
# process-local path; other surfaces repoint or disable the row in their
|
||||
# overlay (web patches it to an ephemeral in-memory index).
|
||||
- id: session-query-sqlite
|
||||
name: '@deepseek-ai/dsh-session-query-sqlite'
|
||||
config:
|
||||
|
||||
@@ -14,9 +14,14 @@
|
||||
- id: hmr
|
||||
disabled: true
|
||||
|
||||
# Session query is a TUI capability; Web owns its own session presentation.
|
||||
# Web content search runs on an ephemeral in-memory index. The service
|
||||
# activates at boot, while first-search defers the node:sqlite import and
|
||||
# in-memory handle so Node 22 startup stays quiet until content search
|
||||
# actually uses SQLite. That search then reconciles this boot's sources.
|
||||
- id: session-query-sqlite
|
||||
disabled: true
|
||||
config:
|
||||
path: ':memory:'
|
||||
openAt: first-search
|
||||
|
||||
- id: tools
|
||||
config:
|
||||
|
||||
@@ -80,6 +80,7 @@
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-projection": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-projection-cache": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-query": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-query-sqlite": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-reference": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-telemetry-otel": "workspace:^",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* for the Web/headless surface.
|
||||
* Everything here is what must exist before the Loader runs: the patch
|
||||
* composition over the shipped base and surface overlay (profile json + CLI
|
||||
* flags + the resolved frontend dist), and the fail-loud triple after the tree
|
||||
* flags + the resolved frontend dist), and the fail-loud activation audit after the tree
|
||||
* settles. The environment is what the bin already loaded (ambient plus the
|
||||
* invoking directory's `.env`); `$DSH_HOME/.env` belongs to the credential
|
||||
* provider and is never hoisted here.
|
||||
@@ -246,7 +246,7 @@ export class AppCLIEntry {
|
||||
if (telemetryPatch !== undefined) this.patches.push(telemetryPatch)
|
||||
}
|
||||
|
||||
/** Shared Loader boot; the dev HMR row mounts before await so the fail-loud sweep covers it. */
|
||||
/** Shared Loader boot; the dev HMR row mounts before await so the activation audit covers it. */
|
||||
private async bootTree(): Promise<void> {
|
||||
// One include of the shared base with every overlay as a sibling patch
|
||||
// list: patches never cross an include boundary, so nesting them would
|
||||
|
||||
@@ -57,12 +57,14 @@ export async function runWeb(
|
||||
void Promise.resolve(ctx.fiber.dispose()).finally(() => { process.exit(code) })
|
||||
}
|
||||
|
||||
// Install shutdown handling before publishing readiness: supervisors may
|
||||
// send a signal as soon as they observe the URL line.
|
||||
process.on('SIGTERM', () => { shutdown(0) })
|
||||
process.on('SIGINT', () => { shutdown(130) })
|
||||
|
||||
// The entry's boot-time snapshot, not a fresh sample: the printed LAN URL
|
||||
// must name an address the /api trust fence was configured with.
|
||||
const lanCandidate = entry.lanAddresses[0]
|
||||
const localUrl = `http://${LOOPBACK_HOST}:${boundPort}`
|
||||
console.log(`dsh web: ${localUrl}${lanCandidate === undefined ? '' : ` (LAN: http://${lanCandidate}:${boundPort})`}`)
|
||||
|
||||
process.on('SIGTERM', () => { shutdown(0) })
|
||||
process.on('SIGINT', () => { shutdown(130) })
|
||||
}
|
||||
|
||||
112
apps/cli/tests/lazy-search-startup.compat.spec.ts
Normal file
112
apps/cli/tests/lazy-search-startup.compat.spec.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Node 22 startup-output smoke for the shipped Web CLI composition.
|
||||
*
|
||||
* Only the dedicated Node compatibility gate opts this test in after building
|
||||
* both artifacts; ordinary Vitest inventory deterministically skips it.
|
||||
* The child runs built artifacts under plain Node with the real shipped
|
||||
* config (base.cordis.yml + the web.cordis.yml overlay).
|
||||
* Its URL line follows AppCLIEntry's settled boot; SIGTERM then exercises the
|
||||
* shipped quiescent disposer.
|
||||
*/
|
||||
|
||||
import { spawn } from 'node:child_process'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import yaml from 'js-yaml'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const repoRoot = fileURLToPath(new URL('../../../', import.meta.url))
|
||||
const builtBin = join(repoRoot, 'apps/cli/lib/bin.js')
|
||||
const webDist = join(repoRoot, 'apps/web/dist/index.html')
|
||||
// The web overlay owns the session-query-sqlite lazy-open patch row.
|
||||
const configPath = join(repoRoot, 'apps/cli/config/web.cordis.yml')
|
||||
const requireBuiltArtifacts = process.env.DSH_REQUIRE_BUILT_CLI_SMOKE === '1'
|
||||
|
||||
interface ConfigRow {
|
||||
id?: string
|
||||
config?: { openAt?: unknown }
|
||||
}
|
||||
|
||||
const jsExprType = new yaml.Type('tag:yaml.org,2002:js', {
|
||||
kind: 'scalar',
|
||||
construct: value => String(value),
|
||||
})
|
||||
const configSchema = yaml.JSON_SCHEMA.extend(jsExprType)
|
||||
|
||||
/** Boot the built Web CLI, wait for its settled URL, then dispose through SIGTERM. */
|
||||
function runBuiltWeb(cwd: string): Promise<{ stdout: string; stderr: string; code: number }> {
|
||||
return new Promise((resolveRun, rejectRun) => {
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
...process.env,
|
||||
DEEPSEEK_API_KEY: 'dsh-cli-smoke-dummy-key',
|
||||
DSH_HOME: join(cwd, '.dsh'),
|
||||
}
|
||||
delete env.DEEPSEEK_BASE_URL
|
||||
delete env.NODE_OPTIONS
|
||||
delete env.NODE_NO_WARNINGS
|
||||
const child = spawn(process.execPath, [
|
||||
builtBin,
|
||||
'web',
|
||||
'--host',
|
||||
'127.0.0.1',
|
||||
'--port',
|
||||
'0',
|
||||
], {
|
||||
cwd,
|
||||
env,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
})
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
let settled = false
|
||||
child.stdout.setEncoding('utf8')
|
||||
child.stderr.setEncoding('utf8')
|
||||
child.stdout.on('data', (chunk: string) => {
|
||||
stdout += chunk
|
||||
if (!settled && /dsh web: http:\/\/127\.0\.0\.1:\d+/u.test(stdout)) {
|
||||
settled = true
|
||||
child.kill('SIGTERM')
|
||||
}
|
||||
})
|
||||
child.stderr.on('data', (chunk: string) => { stderr += chunk })
|
||||
const timer = setTimeout(() => {
|
||||
child.kill('SIGKILL')
|
||||
rejectRun(new Error(`built Web CLI did not settle and dispose within 60s\nstdout:\n${stdout}\nstderr:\n${stderr}`))
|
||||
}, 60_000)
|
||||
child.on('error', (error) => {
|
||||
clearTimeout(timer)
|
||||
rejectRun(error)
|
||||
})
|
||||
child.on('close', (code) => {
|
||||
clearTimeout(timer)
|
||||
if (!settled) {
|
||||
rejectRun(new Error(`built Web CLI exited before settled startup (code ${String(code)})\nstdout:\n${stdout}\nstderr:\n${stderr}`))
|
||||
return
|
||||
}
|
||||
resolveRun({ stdout, stderr, code: code ?? -1 })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
describe.skipIf(!requireBuiltArtifacts)('built CLI lazy-search startup', () => {
|
||||
it('boots and disposes the shipped composition without a SQLite startup warning', async () => {
|
||||
expect(existsSync(builtBin), `missing built CLI ${resolve(builtBin)}; run pnpm build`).toBe(true)
|
||||
expect(existsSync(webDist), `missing Web dist ${resolve(webDist)}; run pnpm run build:web`).toBe(true)
|
||||
const rows = yaml.load(await readFile(configPath, 'utf8'), { schema: configSchema }) as ConfigRow[]
|
||||
const searchRow = rows.find(row => row.id === 'session-query-sqlite')
|
||||
expect(searchRow?.config?.openAt).toBe('first-search')
|
||||
|
||||
const cwd = await mkdtemp(join(tmpdir(), 'dsh-cli-lazy-search-'))
|
||||
try {
|
||||
const result = await runBuiltWeb(cwd)
|
||||
expect(result.stdout).toMatch(/dsh web: http:\/\/127\.0\.0\.1:\d+/u)
|
||||
expect(result.code).toBe(0)
|
||||
expect(result.stderr).not.toMatch(/ExperimentalWarning: SQLite/u)
|
||||
} finally {
|
||||
await rm(cwd, { recursive: true, force: true })
|
||||
}
|
||||
}, 70_000)
|
||||
})
|
||||
104
apps/web/tests/access-confirmation.e2e.ts
Normal file
104
apps/web/tests/access-confirmation.e2e.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
// Web e2e scenario: every visible permission picker gates Full access behind
|
||||
// the same locale-aware, in-page risk confirmation. Zero model calls: the
|
||||
// scenario boots the shipped Web composition and exercises the real
|
||||
// permission projection, client command path, HTTP RPC, and pushed update.
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import {
|
||||
assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
|
||||
launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { saveFailureShot } from './support.ts'
|
||||
|
||||
/**
|
||||
* connectFreshWorkspace twin over the product default Chinese locale (the
|
||||
* shared helper's anchors assume the English page every other scenario
|
||||
* boots; this scenario deliberately keeps zh, so the localized picker
|
||||
* copy is the anchor set).
|
||||
*/
|
||||
async function connectFreshWorkspaceZh(page: Page, name = 'workspace'): Promise<void> {
|
||||
await page.getByRole('button', { name: '选择工作区' }).click()
|
||||
await page.getByRole('menuitem', { name: '新建工作区' }).click()
|
||||
const dialog = page.getByRole('dialog', { name: '新建工作区' })
|
||||
await dialog.waitFor({ timeout: 10_000 })
|
||||
await dialog.getByLabel('新工作区名称').fill(name)
|
||||
await dialog.getByRole('button', { name: '创建工作区' }).click()
|
||||
await page.locator('textarea:enabled[placeholder="描述你想要构建的内容"]')
|
||||
.waitFor({ timeout: 15_000 })
|
||||
}
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/access-confirmation', import.meta.url))
|
||||
const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md')
|
||||
const MODE = webSnapshotMode()
|
||||
|
||||
describe('web e2e: Full access confirmation', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({})
|
||||
// CI uses Playwright's pinned browser. A developer may point this one
|
||||
// scenario at an installed Chromium when the matching browser download
|
||||
// is temporarily unavailable.
|
||||
const executablePath = process.env.DSH_PLAYWRIGHT_EXECUTABLE_PATH
|
||||
browser = await chromium.launch(executablePath === undefined ? {} : { executablePath })
|
||||
// Keep the product default Chinese locale: the golden pins the actual
|
||||
// registered dictionary rather than a test-local translation callback.
|
||||
page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
await connectFreshWorkspaceZh(page)
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close()
|
||||
await scaffold?.close()
|
||||
})
|
||||
|
||||
it('requires acknowledgement before the composer picker can enable Full access', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-full-access-confirmation'))
|
||||
const access = page.locator('button[aria-label^="访问模式"]').first()
|
||||
await access.waitFor({ timeout: 10_000 })
|
||||
|
||||
// Normalize the starting preset through the real command path. The
|
||||
// shipped web config may already start at Full access.
|
||||
if ((await access.getAttribute('aria-label'))?.endsWith('Full access') === true) {
|
||||
await access.click()
|
||||
await page.getByRole('menuitem', { name: 'Workspace Write' }).click()
|
||||
await expect.poll(() => access.getAttribute('aria-label'), { timeout: 10_000 })
|
||||
.toBe('访问模式,当前:Workspace Write')
|
||||
}
|
||||
|
||||
await access.click()
|
||||
await page.getByRole('menuitem', { name: 'Full access' }).click()
|
||||
const dialog = page.getByRole('dialog', { name: '确认启用 Full access?' })
|
||||
await dialog.waitFor({ timeout: 10_000 })
|
||||
const enable = dialog.getByRole('button', { name: '启用 Full access' })
|
||||
expect(await enable.isDisabled()).toBe(true)
|
||||
|
||||
// The modal is in this page's body (not a native/new window) and escapes
|
||||
// the sticky composer's stacking context.
|
||||
expect(await dialog.evaluate(node => node.parentElement?.parentElement === document.body)).toBe(true)
|
||||
const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
|
||||
|
||||
await dialog.getByRole('checkbox', { name: '我已了解风险,并愿意继续' }).check()
|
||||
expect(await enable.isEnabled()).toBe(true)
|
||||
await enable.click()
|
||||
await expect.poll(() => access.getAttribute('aria-label'), { timeout: 10_000 })
|
||||
.toBe('访问模式,当前:Full access')
|
||||
expect(await dialog.count()).toBe(0)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('keeps its snapshot inventory closed', async () => {
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md'])
|
||||
})
|
||||
})
|
||||
@@ -111,6 +111,29 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn
|
||||
expect(document.querySelector('[data-sample="bash-global"]')).not.toBeNull()
|
||||
}, { timeout: 10_000 })
|
||||
|
||||
// The write/edit turns render a real diff card through the assembled graph
|
||||
// (the keyed FileMutationRow + DiffBlock), not just the fixture's raw text.
|
||||
// The write turn's `hello fixture\n` proves the terminator rule end to end: a
|
||||
// trailing newline terminates its line, so the footer reads `+1` (not a
|
||||
// phantom `+2`) and one distinct file. The `+ ` prefix is a CSS ::before, so
|
||||
// it is absent from textContent — assert on the line body and the footer.
|
||||
const diffCards = [...document.querySelectorAll('[data-diff]')]
|
||||
expect(diffCards.length).toBeGreaterThan(0)
|
||||
const footers = diffCards.map(card => card.textContent ?? '')
|
||||
expect(footers.some(text => text.includes('hello fixture') && text.includes('+1 -0 · 1 file'))).toBe(true)
|
||||
|
||||
// The web render intent reaches the assembled boot graph: the fixture's
|
||||
// web_search / web_fetch turns render their keyed WebRow cards, proving the
|
||||
// registration, wire projection, and card rendering survive the real bundle
|
||||
// path (not just the per-package src benches). The selector pins the KEYED
|
||||
// WebRow (its own `data-variant="web"` wrapper), not the `[data-web]` attribute
|
||||
// WebBlock draws — the generic fallback renders the same WebBlock, so a silent
|
||||
// keyed-registration failure would still satisfy a bare `[data-web]` check.
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector('[data-variant="web"][data-tool="web_search"]')).not.toBeNull()
|
||||
expect(document.querySelector('[data-variant="web"][data-tool="web_fetch"]')).not.toBeNull()
|
||||
}, { timeout: 10_000 })
|
||||
|
||||
// Every bundle injected its plugin-owned style tag (the loader's CSS path).
|
||||
const styleOwners = [...document.head.querySelectorAll('style[data-plugin]')]
|
||||
.map(style => style.getAttribute('data-plugin'))
|
||||
|
||||
@@ -107,7 +107,8 @@ describe('web e2e: Cordis tools use the generic row variants', () => {
|
||||
|
||||
const mountRow = page.locator('[data-tool="cordis_mount"]').filter({ hasText: 'Mount temporary Plugin' }).first()
|
||||
await mountRow.waitFor({ timeout: 10_000 })
|
||||
await mountRow.locator('button[aria-expanded]').click()
|
||||
// The whole summary row is the expand toggle (unified tool-row interaction).
|
||||
await mountRow.locator('[aria-expanded]').first().click()
|
||||
await expect.poll(() => mountRow.locator('pre.shiki').textContent(), { timeout: 10_000 })
|
||||
.toContain(MOUNT_CODE)
|
||||
|
||||
|
||||
@@ -25,6 +25,8 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/lifecycle-chrome', import.meta.url))
|
||||
const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
|
||||
const HERO_EXPECTED = join(SNAPSHOT_DIR, 'hero.expected.md')
|
||||
const COMMAND_MENU_EXPECTED = join(SNAPSHOT_DIR, 'command-menu.expected.md')
|
||||
const PLAN_ACTIVE_EXPECTED = join(SNAPSHOT_DIR, 'plan-active.expected.md')
|
||||
// Post-reload golden: the same settled conversation rebuilt purely from
|
||||
// persistence + history — byte-equal rendering is exactly the recovery claim.
|
||||
const RELOADED_EXPECTED = join(SNAPSHOT_DIR, 'reloaded.expected.md')
|
||||
@@ -56,6 +58,88 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
|
||||
await scaffold?.close()
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('opens the shared slash menu from plus with only Command candidates', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-command-menu-launcher'))
|
||||
const launcher = page.getByRole('button', { name: 'Commands' })
|
||||
await launcher.click()
|
||||
const menu = page.getByRole('listbox', { name: 'Trigger suggestions' })
|
||||
await menu.waitFor({ timeout: 10_000 })
|
||||
const snapshot = await captureStableAria(page, '[role="listbox"]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(COMMAND_MENU_EXPECTED, snapshot, MODE)
|
||||
expect(snapshot).toContain('text: Commands')
|
||||
expect(snapshot).not.toContain('text: Skills')
|
||||
expect(snapshot).not.toContain('text: Subagents')
|
||||
const launchedBox = await menu.boundingBox()
|
||||
await page.locator('textarea').first().press('Escape')
|
||||
await expect.poll(() => menu.count()).toBe(0)
|
||||
const input = page.locator('textarea').first()
|
||||
await input.fill('/')
|
||||
await menu.waitFor({ timeout: 10_000 })
|
||||
const typedBox = await menu.boundingBox()
|
||||
expect(launchedBox).not.toBeNull()
|
||||
expect(typedBox).not.toBeNull()
|
||||
expect(Math.abs(launchedBox!.x - typedBox!.x)).toBeLessThan(1)
|
||||
expect(Math.abs(
|
||||
launchedBox!.y + launchedBox!.height - typedBox!.y - typedBox!.height,
|
||||
)).toBeLessThan(1)
|
||||
await input.fill('')
|
||||
await expect.poll(() => menu.count()).toBe(0)
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('shows active Plan as the warn-state status action', async () => {
|
||||
const activeScaffold = await launchWebScaffold()
|
||||
const activePage = await newEnglishPage(browser)
|
||||
const activeTripwire = watchConsole(activePage)
|
||||
try {
|
||||
await activePage.goto(activeScaffold.baseUrl, { waitUntil: 'load' })
|
||||
await activePage.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
await connectFreshWorkspace(activePage)
|
||||
const input = activePage.locator('textarea').first()
|
||||
await activePage.getByRole('button', { name: 'Commands' }).click()
|
||||
const menu = activePage.getByRole('listbox', { name: 'Trigger suggestions' })
|
||||
await menu.waitFor({ timeout: 10_000 })
|
||||
await menu.getByRole('option', { name: 'plan Enter or leave plan mode' }).click()
|
||||
await expect.poll(() => input.inputValue()).toBe('/plan ')
|
||||
await input.press('Enter')
|
||||
const planButton = activePage.getByRole('button', { name: 'Plan mode on, press to turn off' })
|
||||
await planButton.waitFor({ timeout: 10_000 })
|
||||
const planSnapshot = await captureStableAria(activePage, '[class*="frame"]', activeScaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(PLAN_ACTIVE_EXPECTED, planSnapshot, MODE)
|
||||
const planStyle = await planButton.evaluate((element) => {
|
||||
const probe = document.createElement('span')
|
||||
probe.style.color = 'var(--dsw-alias-state-warn-label)'
|
||||
probe.style.backgroundColor = 'var(--dsw-alias-state-warn-tertiary)'
|
||||
document.body.append(probe)
|
||||
const actual = getComputedStyle(element)
|
||||
const reference = getComputedStyle(probe)
|
||||
const result = {
|
||||
color: actual.color,
|
||||
backgroundColor: actual.backgroundColor,
|
||||
borderRadius: actual.borderRadius,
|
||||
fontSize: actual.fontSize,
|
||||
referenceColor: reference.color,
|
||||
referenceBackgroundColor: reference.backgroundColor,
|
||||
}
|
||||
probe.remove()
|
||||
return result
|
||||
})
|
||||
expect(planStyle.color).toBe(planStyle.referenceColor)
|
||||
expect(planStyle.backgroundColor).toBe(planStyle.referenceBackgroundColor)
|
||||
expect(planStyle.borderRadius).toBe('999px')
|
||||
expect(planStyle.fontSize).toBe('13px')
|
||||
await planButton.click()
|
||||
await expect.poll(() => planButton.count()).toBe(0)
|
||||
expect(activeTripwire.pageErrors).toEqual([])
|
||||
expect(activeTripwire.warnings).toEqual([])
|
||||
} catch (error) {
|
||||
await saveFailureShot(activePage, 'web-e2e-plan-active').catch(() => undefined)
|
||||
throw error
|
||||
} finally {
|
||||
await activePage.close()
|
||||
await activeScaffold.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('sends the first prompt from the empty-state hero (all modes)', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-lifecycle-send'))
|
||||
if (MODE !== 'record') {
|
||||
@@ -152,6 +236,8 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
|
||||
|
||||
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'hero.expected.md', 'reloaded.expected.md'])
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, [
|
||||
'session.jsonl', 'command-menu.expected.md', 'hero.expected.md', 'plan-active.expected.md', 'reloaded.expected.md',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -221,8 +221,8 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => {
|
||||
// only on change, so attempt count is invisible there).
|
||||
expect(sessionEvents.filter(e => e.type === 'llm/retry').length).toBeGreaterThanOrEqual(1)
|
||||
await expect.poll(() => page.getByText('event sourcing', { exact: false }).count(), { timeout: 10_000 }).toBeGreaterThan(0)
|
||||
// Golden of the recovered end-state: indistinguishable from a clean
|
||||
// completion — retries are deliberately invisible in the transcript.
|
||||
// Golden of the recovered end-state: the discarded partial stays absent,
|
||||
// while the settled retry row remains as durable recovery context.
|
||||
const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd)
|
||||
await compareOrRefreshGolden(RETRY_EXPECTED, snapshot, MODE)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
|
||||
@@ -23,6 +23,7 @@ import { newEnglishPage, saveFailureShot } from './support.ts'
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/navigation-panes', import.meta.url))
|
||||
const SEED = join(SNAPSHOT_DIR, 'seed.jsonl')
|
||||
const TRAJECTORY_EXPECTED = join(SNAPSHOT_DIR, 'trajectory.expected.md')
|
||||
const SEARCH_EXPECTED = join(SNAPSHOT_DIR, 'search-results.expected.md')
|
||||
const TERMINAL_EXPECTED = join(SNAPSHOT_DIR, 'terminal-card.expected.md')
|
||||
const MODE = webSnapshotMode()
|
||||
const SEED_ID = 'navigation-panes-web-e2e'
|
||||
@@ -95,39 +96,39 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
|
||||
expect(calls.map(e => e.data.name).sort()).toEqual(['bash', 'read', 'read'])
|
||||
}, 400_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('opens the seeded session and renders both turns from the log', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-open'))
|
||||
// Expand the collapsed group row, then open the revealed session row.
|
||||
const groupRow = page.locator('[role="treeitem"]').first()
|
||||
await groupRow.waitFor({ timeout: 15_000 })
|
||||
await groupRow.click()
|
||||
const sessionRow = page.locator('[role="treeitem"]').nth(1)
|
||||
await sessionRow.waitFor({ timeout: 10_000 })
|
||||
await sessionRow.click()
|
||||
it.skipIf(MODE === 'record')('finds an unopened seeded session by message content and opens it', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-search'))
|
||||
const search = page.getByPlaceholder('Search name, keywords', { exact: false })
|
||||
// The cold row has not been opened, so only the persisted log can satisfy
|
||||
// this query. First search lazily reconciles the SQLite content index.
|
||||
await search.fill('zzzqx-no-such-session')
|
||||
await page.getByText('No matching sessions').waitFor({ timeout: 30_000 })
|
||||
await expect.poll(
|
||||
() => page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem').count(),
|
||||
{ timeout: 10_000 },
|
||||
).toBe(0)
|
||||
|
||||
await search.fill('WATERFALL')
|
||||
const resultTree = page.getByRole('tree', { name: 'Search results' })
|
||||
const result = resultTree.getByRole('treeitem')
|
||||
await expect.poll(() => result.count(), { timeout: 30_000 }).toBe(1)
|
||||
await expect.poll(() => result.getByText('WATERFALL', { exact: false }).count(), {
|
||||
timeout: 10_000,
|
||||
}).toBeGreaterThanOrEqual(1)
|
||||
const snapshot = (await captureStableAria(page, '[class*="listArea"]', scaffold.workspaceCwd))
|
||||
.split(SEED_ID).join('{{seededId}}')
|
||||
await compareOrRefreshGolden(SEARCH_EXPECTED, snapshot, MODE)
|
||||
|
||||
await result.click()
|
||||
// Search navigation addresses the session, not a specific event, and the
|
||||
// query remains until the user explicitly clears it.
|
||||
await expect.poll(() => search.inputValue(), { timeout: 5_000 }).toBe('WATERFALL')
|
||||
await expect.poll(() => page.getByText('FIRST_DONE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)
|
||||
await expect.poll(() => page.getByRole('heading', { name: 'Navigation Summary' }).count(), { timeout: 15_000 }).toBe(1)
|
||||
}, 90_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('filters the sidebar tree by title through the search box', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-search'))
|
||||
// Runs after the session is open: a cold summary carries no title (the
|
||||
// sidebar shows the cwd basename), and the durable title lands with the
|
||||
// attach subscription's baseline — which is itself worth pinning: search
|
||||
// matches the title the user sees, not a hidden cold field.
|
||||
const search = page.getByPlaceholder('Search name, keywords', { exact: false })
|
||||
await expect.poll(() => page.getByText('NavScenario', { exact: false }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)
|
||||
// Negative: a garbage query empties the tree (group rows hide too).
|
||||
await search.fill('zzzqx-no-such-session')
|
||||
await expect.poll(() => page.locator('[role="treeitem"]').count(), { timeout: 10_000 }).toBe(0)
|
||||
// Positive: a title word narrows to the matched session + its group,
|
||||
// force-expanded by search mode (case-insensitive client-side filter).
|
||||
await search.fill('navscenario')
|
||||
await expect.poll(() => page.locator('[role="treeitem"]').count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(2)
|
||||
// Clear restores the unfiltered tree.
|
||||
await page.getByRole('button', { name: 'Clear search' }).click()
|
||||
await expect.poll(() => search.inputValue(), { timeout: 5_000 }).toBe('')
|
||||
await expect.poll(() => page.locator('[role="treeitem"]').count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(1)
|
||||
}, 60_000)
|
||||
}, 90_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('renders the trajectory ledger and opens its local record inspector', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-trajectory'))
|
||||
@@ -180,11 +181,13 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
|
||||
await bashRow.waitFor({ timeout: 15_000 })
|
||||
const frame = page.locator('[style*="grid-template-columns"]').first()
|
||||
expect(await frame.getAttribute('data-details-collapsed')).toBe('true')
|
||||
// The row click is the card's expand toggle (unified tool-row
|
||||
// interaction); it must not drive layout geometry either way.
|
||||
await bashRow.click()
|
||||
await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBe('true')
|
||||
// The card's own controls are outside the summary row and must not open
|
||||
// details either — the terminal card is read in place.
|
||||
await page.locator('[data-sample="bash-global"] ~ [data-terminal] [class*="_copyButton_"]').first().click()
|
||||
// details either — the expanded terminal card is read in place.
|
||||
await page.locator('[data-sample="bash-global"] ~ div [data-terminal] [class*="_copyButton_"]').first().click()
|
||||
await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBe('true')
|
||||
// Read summaries are host-open file links; they also must not open details.
|
||||
const fileLink = page.locator('[data-variant="read"] button').first()
|
||||
@@ -196,10 +199,14 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
|
||||
it.skipIf(MODE === 'record')('renders the bash row as a terminal card in the real browser', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-terminal'))
|
||||
await page.getByRole('tab', { name: 'Chat' }).click()
|
||||
// The card is resident in the keyed bash row (no expand gesture): the
|
||||
// recorded command's own output sits in the message flow, derived from the
|
||||
// logged call/result presentations alone.
|
||||
const card = page.locator('[data-sample="bash-global"] ~ [data-terminal], [data-sample="bash-global"] [data-terminal]').first()
|
||||
// The card is expand-gated behind the whole-row toggle (the unified
|
||||
// tool-row interaction): open it if a previous case left it collapsed.
|
||||
// Expanded, the recorded command's own output sits in the message flow,
|
||||
// derived from the logged call/result presentations alone.
|
||||
const bashRow = page.locator('[data-sample="bash-global"]').first()
|
||||
await bashRow.waitFor({ timeout: 15_000 })
|
||||
if (await bashRow.getAttribute('aria-expanded') !== 'true') await bashRow.click()
|
||||
const card = page.locator('[data-sample="bash-global"] ~ div [data-terminal]').first()
|
||||
await card.waitFor({ timeout: 15_000 })
|
||||
// Real layout, not jsdom's stub (which computes no geometry at all):
|
||||
// squeeze the output pane below its content width and the line must keep
|
||||
@@ -279,7 +286,8 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
|
||||
expect(slotErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, [
|
||||
'seed.jsonl', 'trajectory.expected.md', 'terminal-card.expected.md',
|
||||
'seed.jsonl', 'search-results.expected.md', 'trajectory.expected.md',
|
||||
'terminal-card.expected.md',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -96,6 +96,26 @@ describe('web e2e: queue row actions', () => {
|
||||
{ timeout: 10_000 },
|
||||
).toBe(2)
|
||||
|
||||
await page.setViewportSize({ width: 640, height: 1000 })
|
||||
const queueBox = await page.locator('[data-queue-dock]').boundingBox()
|
||||
const composerBox = await page.locator('[data-composer-card]').boundingBox()
|
||||
expect(queueBox).not.toBeNull()
|
||||
expect(composerBox).not.toBeNull()
|
||||
expect(queueBox!.x).toBeGreaterThanOrEqual(composerBox!.x)
|
||||
expect(queueBox!.x + queueBox!.width)
|
||||
.toBeLessThanOrEqual(composerBox!.x + composerBox!.width)
|
||||
const queueLeftInset = queueBox!.x - composerBox!.x
|
||||
const queueRightInset = composerBox!.x + composerBox!.width - queueBox!.x - queueBox!.width
|
||||
const composerMetrics = await page.locator('[data-composer-card]').evaluate((element) => {
|
||||
const style = getComputedStyle(element)
|
||||
return {
|
||||
dockInset: Number.parseFloat(style.getPropertyValue('--dsh-composer-dock-inset')),
|
||||
}
|
||||
})
|
||||
expect(queueLeftInset).toBeCloseTo(composerMetrics.dockInset, 1)
|
||||
expect(queueRightInset).toBeCloseTo(composerMetrics.dockInset, 1)
|
||||
await page.setViewportSize({ width: 1680, height: 1000 })
|
||||
|
||||
const editRow = page.getByText(EDIT, { exact: true }).locator('..')
|
||||
await editRow.getByRole('button', { name: 'Edit queued message' }).click()
|
||||
const editor = page.getByRole('textbox', { name: 'Edit queued message' })
|
||||
|
||||
@@ -200,6 +200,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
|
||||
const patches: PatchOptions[] = [
|
||||
...surfacePatches,
|
||||
{ id: 'session-persistence-jsonl', config: { root: persistenceRoot } },
|
||||
{ id: 'session-query-sqlite', config: { path: ':memory:', openAt: 'first-search' } },
|
||||
// storage-json's './.storages' yml default is cwd-relative and resolves
|
||||
// per write; the scaffold restores the original cwd after boot, so the
|
||||
// row gets an absolute temp root (removed with the workspace at close).
|
||||
|
||||
@@ -343,7 +343,7 @@ describe('web e2e: seeded history renders through cold resume', () => {
|
||||
// where neither half repeats the other (the dispatched `/` and its
|
||||
// argument stay out of the title, and the settlement text never restates
|
||||
// the command's own name).
|
||||
await page.getByRole('button', { name: 'Access mode, current: Danger Full Access' }).click()
|
||||
await page.getByRole('button', { name: 'Access mode, current: Full access' }).click()
|
||||
await page.getByRole('menuitem', { name: 'Workspace Write' }).click()
|
||||
await page.getByRole('button', { name: 'Access mode, current: Workspace Write' }).waitFor({ timeout: 10_000 })
|
||||
// Scoped to the row itself, so unrelated page text that happens to read
|
||||
|
||||
@@ -267,6 +267,98 @@ describe('dsh web keyless CLI smoke', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('retries a partial transport failure through the shipped Web composition', async () => {
|
||||
requireDist()
|
||||
const workspace = mkdtempSync(join(tmpdir(), 'dsh-web-retry-'))
|
||||
const promptMarker = 'WEB_RETRY_REQUEST'
|
||||
const recoveredMarker = 'WEB_RETRY_RECOVERED'
|
||||
let mainAttempts = 0
|
||||
const provider = createServer((request, response) => {
|
||||
let body = ''
|
||||
request.setEncoding('utf8')
|
||||
request.on('data', (chunk: string) => { body += chunk })
|
||||
request.on('end', () => {
|
||||
const parsed = JSON.parse(body) as { max_tokens?: number; messages?: unknown[] }
|
||||
const titleRequest = parsed.max_tokens === 64
|
||||
const mainRequest = !titleRequest && body.includes(promptMarker)
|
||||
response.writeHead(200, { 'content-type': 'text/event-stream' })
|
||||
if (!mainRequest) {
|
||||
response.end([
|
||||
'data: {"choices":[{"delta":{"content":"Web retry title"}}]}',
|
||||
'data: {"choices":[{"delta":{"content":""},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1}}',
|
||||
'data: [DONE]',
|
||||
'',
|
||||
].join('\n\n'))
|
||||
return
|
||||
}
|
||||
mainAttempts++
|
||||
if (mainAttempts === 1) {
|
||||
response.write('data: {"choices":[{"delta":{"content":"WEB_RETRY_DISCARDED"}}]}\n\n')
|
||||
setTimeout(() => { response.destroy() }, 20)
|
||||
return
|
||||
}
|
||||
response.end([
|
||||
`data: {"choices":[{"delta":{"content":"${recoveredMarker}"}}]}`,
|
||||
'data: {"choices":[{"delta":{"content":""},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}',
|
||||
'data: [DONE]',
|
||||
'',
|
||||
].join('\n\n'))
|
||||
})
|
||||
})
|
||||
await new Promise<void>(resolve => provider.listen(0, '127.0.0.1', resolve))
|
||||
const address = provider.address()
|
||||
if (address === null || typeof address === 'string') throw new Error('mock provider did not bind a TCP port')
|
||||
const tsxLoader = pathToFileURL(createRequire(join(REPO_ROOT, 'package.json')).resolve('tsx')).href
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
['--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web', '--port', '0'],
|
||||
{
|
||||
cwd: workspace,
|
||||
env: {
|
||||
...process.env,
|
||||
DEEPSEEK_API_KEY: 'keyless-web-retry',
|
||||
DEEPSEEK_BASE_URL: `http://127.0.0.1:${address.port}`,
|
||||
DSH_HOME: join(workspace, '.dsh'),
|
||||
TSX_TSCONFIG_PATH: join(REPO_ROOT, 'tsconfig.json'),
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
},
|
||||
)
|
||||
try {
|
||||
const baseUrl = await waitForReadyLine(child)
|
||||
const created = await rpc<{ sessionId: string }>(baseUrl, 'session.create', {})
|
||||
await rpc<{ accepted: true }>(baseUrl, 'session.prompt', {
|
||||
sessionId: created.sessionId,
|
||||
mode: 'queue',
|
||||
content: [{ type: 'text', text: promptMarker }],
|
||||
})
|
||||
let page: HistoryPage | undefined
|
||||
await expect.poll(async () => {
|
||||
page = await history(baseUrl, created.sessionId)
|
||||
return hasAssistantMarker(page, recoveredMarker)
|
||||
}, { timeout: 20_000 }).toBe(true)
|
||||
if (page === undefined) throw new Error('retry history was not observed')
|
||||
const retry = page.events.find(({ event }) => event.type === 'llm/retry')?.event
|
||||
expect(mainAttempts).toBe(2)
|
||||
expect(retry?.data).toMatchObject({
|
||||
turn: 1,
|
||||
step: 1,
|
||||
retry: 1,
|
||||
maxRetries: 2,
|
||||
failure: { code: 'TRANSPORT' },
|
||||
})
|
||||
expect(JSON.stringify(page.events)).toContain('WEB_RETRY_DISCARDED')
|
||||
} finally {
|
||||
const closed = child.exitCode === null
|
||||
? new Promise<void>((resolveClose) => { child.once('close', () => { resolveClose() }) })
|
||||
: Promise.resolve()
|
||||
if (child.exitCode === null) child.kill('SIGTERM')
|
||||
await closed
|
||||
await new Promise<void>(resolveClose => provider.close(() => { resolveClose() }))
|
||||
rmSync(workspace, { recursive: true, force: true })
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
it('DSH_TOOLS_MODE=code collapses the provider wire tools to run_code with the SDK prompt section', async () => {
|
||||
requireDist()
|
||||
const workspace = mkdtempSync(join(tmpdir(), 'dsh-web-code-mode-'))
|
||||
|
||||
10
apps/web/tests/snapshots/access-confirmation/ui.expected.md
Normal file
10
apps/web/tests/snapshots/access-confirmation/ui.expected.md
Normal file
@@ -0,0 +1,10 @@
|
||||
- dialog "确认启用 Full access?":
|
||||
- heading "确认启用 Full access?" [level=2]
|
||||
- button "Close":
|
||||
- img
|
||||
- img
|
||||
- paragraph: 启用 Full access 后,agent 将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任当前任务时使用。
|
||||
- checkbox "我已了解风险,并愿意继续"
|
||||
- text: 我已了解风险,并愿意继续
|
||||
- button "取消"
|
||||
- button "启用 Full access" [disabled]
|
||||
@@ -15,13 +15,15 @@
|
||||
- img
|
||||
- img
|
||||
- text: "Think The user wants me to write a single `run_code` program that:"
|
||||
- button:
|
||||
- button "Code Run bash echo and catch missing file read":
|
||||
- img
|
||||
- img
|
||||
- text: Code Run bash echo and catch missing file read
|
||||
- text: Code Run bash echo and catch missing file read
|
||||
- img
|
||||
- text: Bash Echo CODE_ROUND_OK Read
|
||||
- button "missing.txt"
|
||||
- text: Bash Echo CODE_ROUND_OK
|
||||
- 'button "Read Error: cannot read \"{{cwd}}/workspace/missing.txt\": not found"':
|
||||
- img
|
||||
- text: "Read Error: cannot read \"{{cwd}}/workspace/missing.txt\": not found"
|
||||
- button "Think The program ran successfully. Let me now reply DONE as instructed.":
|
||||
- img
|
||||
- img
|
||||
@@ -33,10 +35,9 @@
|
||||
- img
|
||||
- text: {{clock}}
|
||||
- textbox "Message the agent"
|
||||
- button "Add attachment":
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
|
||||
- button "Plan mode off, press to turn on": Plan off
|
||||
- 'button "Access mode, current: Full access"': Full access
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
|
||||
@@ -15,27 +15,30 @@
|
||||
- img
|
||||
- img
|
||||
- text: "Think The user wants me to:"
|
||||
- button:
|
||||
- button "Inspect temporary":
|
||||
- img
|
||||
- img
|
||||
- text: Inspect temporary
|
||||
- text: Inspect temporary
|
||||
- 'button "Think Good, no temporary plugins running. Now step 2: call cordis_mount with the exact code."':
|
||||
- img
|
||||
- img
|
||||
- text: "Think Good, no temporary plugins running. Now step 2: call cordis_mount with the exact code."
|
||||
- button [expanded]:
|
||||
- 'button "Mount temporary Plugin return { name: \"snapshot-noop\", apply(ctx) {} }" [expanded]':
|
||||
- img
|
||||
- text: Mount temporary Plugin typescript
|
||||
- text: "Mount temporary Plugin return { name: \"snapshot-noop\", apply(ctx) {} }"
|
||||
- text: typescript
|
||||
- button "Copy"
|
||||
- code: "return { name: \"snapshot-noop\", apply(ctx) {} }"
|
||||
- text: OUT Temporary Plugin dyn-1 is running (plugin "snapshot-noop"; available until unmounted or DSH restarts).
|
||||
- button "Inspect"
|
||||
- 'button "Think The id is \"dyn-1\". Now step 3: call cordis_unmount with that id."':
|
||||
- img
|
||||
- img
|
||||
- text: "Think The id is \"dyn-1\". Now step 3: call cordis_unmount with that id."
|
||||
- button:
|
||||
- button "Unmount temporary Plugin dyn-1":
|
||||
- img
|
||||
- img
|
||||
- text: Unmount temporary Plugin dyn-1
|
||||
- text: Unmount temporary Plugin dyn-1
|
||||
- button "Think All three calls succeeded. I should now reply exactly \"CORDIS_UI_DONE\" and stop.":
|
||||
- img
|
||||
- img
|
||||
@@ -47,10 +50,9 @@
|
||||
- img
|
||||
- text: {{clock}}
|
||||
- textbox "Message the agent"
|
||||
- button "Add attachment":
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
|
||||
- button "Plan mode off, press to turn on": Plan off
|
||||
- 'button "Access mode, current: Full access"': Full access
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
|
||||
@@ -15,10 +15,10 @@
|
||||
- img
|
||||
- img
|
||||
- text: Think The user wants me to run a simple bash command and reply with "DONE".
|
||||
- img
|
||||
- text: Bash Echo the test string Done workspace echo WEB_E2E_OK
|
||||
- button "Copy"
|
||||
- text: WEB_E2E_OK
|
||||
- button "Bash Echo the test string":
|
||||
- img
|
||||
- img
|
||||
- text: Bash Echo the test string
|
||||
- button "Think The command executed successfully and output \"WEB_E2E_OK\". I just need to reply with \"DONE\".":
|
||||
- img
|
||||
- img
|
||||
@@ -30,10 +30,9 @@
|
||||
- img
|
||||
- text: {{clock}}
|
||||
- textbox "Message the agent"
|
||||
- button "Add attachment":
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
|
||||
- button "Plan mode off, press to turn on": Plan off
|
||||
- 'button "Access mode, current: Full access"': Full access
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
- listbox "Trigger suggestions":
|
||||
- text: Commands
|
||||
- option "goal set or view the goal for a long-running task" [selected]
|
||||
- option "permission Switch the permission preset (sandbox mode + approval policy)"
|
||||
- option "plan Enter or leave plan mode"
|
||||
- option "model Select the model for this conversation"
|
||||
@@ -26,10 +26,9 @@
|
||||
- text: workspace
|
||||
- img
|
||||
- textbox "Describe what you want to build"
|
||||
- button "Add attachment":
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
|
||||
- button "Plan mode off, press to turn on": Plan off
|
||||
- 'button "Access mode, current: Full access"': Full access
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
- button "New session"
|
||||
- button "Collapse sidebar":
|
||||
- img
|
||||
- button "New session":
|
||||
- img
|
||||
- text: New Session
|
||||
- text: Workspaces
|
||||
- button "Group by":
|
||||
- img
|
||||
- button "Create workspace":
|
||||
- img
|
||||
- button "Search sessions":
|
||||
- img
|
||||
- textbox "Search name, keywords..."
|
||||
- tree "Sessions":
|
||||
- treeitem "workspace 1 session" [expanded]:
|
||||
- img
|
||||
- text: workspace 1 session
|
||||
- treeitem "New Session now" [selected]
|
||||
- button "Settings":
|
||||
- img
|
||||
- text: Settings
|
||||
- text: Let's start building
|
||||
- button "Choose workspace":
|
||||
- img
|
||||
- text: workspace
|
||||
- img
|
||||
- textbox "Describe what you want to build"
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Full access"': Full access
|
||||
- button "Plan mode on, press to turn off": Plan
|
||||
- button "Select model, current deepseek-v4-flash":
|
||||
- text: deepseek-v4-flash
|
||||
- img
|
||||
- button "Send message" [disabled]
|
||||
- text: Details
|
||||
- button "Close details"
|
||||
- text: Click a tool row in the message flow to view its details
|
||||
@@ -22,10 +22,9 @@
|
||||
- img
|
||||
- text: {{clock}}
|
||||
- textbox "Message the agent"
|
||||
- button "Add attachment":
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
|
||||
- button "Plan mode off, press to turn on": Plan off
|
||||
- 'button "Access mode, current: Full access"': Full access
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
|
||||
@@ -19,10 +19,9 @@
|
||||
- img
|
||||
- text: {{clock}}
|
||||
- textbox "Message the agent"
|
||||
- button "Add attachment":
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
|
||||
- button "Plan mode off, press to turn on": Plan off
|
||||
- 'button "Access mode, current: Full access"': Full access
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
|
||||
@@ -12,10 +12,9 @@
|
||||
- button "Edit":
|
||||
- img
|
||||
- textbox "Message the agent"
|
||||
- button "Add attachment":
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
|
||||
- button "Plan mode off, press to turn on": Plan off
|
||||
- 'button "Access mode, current: Full access"': Full access
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
- img
|
||||
- button "Edit":
|
||||
- img
|
||||
- group:
|
||||
- status: Retried model request (1/2) · {{duration}}
|
||||
- button "Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls.":
|
||||
- img
|
||||
- img
|
||||
@@ -22,10 +24,9 @@
|
||||
- img
|
||||
- text: {{clock}}
|
||||
- textbox "Message the agent"
|
||||
- button "Add attachment":
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
|
||||
- button "Plan mode off, press to turn on": Plan off
|
||||
- 'button "Access mode, current: Full access"': Full access
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
|
||||
@@ -16,12 +16,16 @@
|
||||
- img
|
||||
- img
|
||||
- text: Think The user wants me to read a.txt and b.txt, then reply with "DONE". Let me do both reads in parallel.
|
||||
- img
|
||||
- text: Read
|
||||
- button "a.txt"
|
||||
- img
|
||||
- text: Read
|
||||
- button "b.txt"
|
||||
- button "Read a.txt":
|
||||
- img
|
||||
- img
|
||||
- text: Read
|
||||
- button "a.txt"
|
||||
- button "Read b.txt":
|
||||
- img
|
||||
- img
|
||||
- text: Read
|
||||
- button "b.txt"
|
||||
- button "Think Both files have been read. a.txt contains \"alpha\" and b.txt contains \"beta\". I'll now reply with DONE as instructed.":
|
||||
- img
|
||||
- img
|
||||
@@ -33,10 +37,9 @@
|
||||
- img
|
||||
- text: 7/25 {{clock}}
|
||||
- textbox "Message the agent"
|
||||
- button "Add attachment":
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
|
||||
- button "Plan mode off, press to turn on": Plan off
|
||||
- 'button "Access mode, current: Full access"': Full access
|
||||
- button "Select model, current deepseek-v4-flash":
|
||||
- text: deepseek-v4-flash
|
||||
- img
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
- tree "Search results":
|
||||
- 'treeitem "{{workspace}} {{workspace}} ## Navigation Summary - alpha nav - beta nav ``` echo WATERFALL ```"'
|
||||
@@ -20,10 +20,10 @@
|
||||
- text: Since the user has explicitly asked me not to read or write any files and to go straight to planning, I'll proceed with
|
||||
- code: exit_plan_mode
|
||||
- text: .
|
||||
- button:
|
||||
- 'button "Tool call exit_plan_mode · # Add `--greeting` flag to CLI"':
|
||||
- img
|
||||
- img
|
||||
- text: "Tool call exit_plan_mode · # Add `--greeting` flag to CLI"
|
||||
- text: "Tool call exit_plan_mode · # Add `--greeting` flag to CLI"
|
||||
- 'button "Think The plan was approved. The user''s last instruction says: \"Once the plan is approved, reply with the single word DONE and stop.\" So I should just reply with DONE and stop."':
|
||||
- img
|
||||
- img
|
||||
@@ -35,10 +35,9 @@
|
||||
- img
|
||||
- text: {{clock}}
|
||||
- textbox "Message the agent"
|
||||
- button "Add attachment":
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
|
||||
- button "Plan mode off, press to turn on": Plan off
|
||||
- 'button "Access mode, current: Full access"': Full access
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
|
||||
@@ -15,10 +15,10 @@
|
||||
- img
|
||||
- img
|
||||
- text: Think The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that.
|
||||
- button:
|
||||
- button "Ask question 1/1 answered":
|
||||
- img
|
||||
- img
|
||||
- text: Ask question 1/1 answered
|
||||
- text: Ask question 1/1 answered
|
||||
- button "Think The user answered \"Blue\". I should now reply with the single word DONE and stop.":
|
||||
- img
|
||||
- img
|
||||
@@ -30,10 +30,9 @@
|
||||
- img
|
||||
- text: {{clock}}
|
||||
- textbox "Message the agent"
|
||||
- button "Add attachment":
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
|
||||
- button "Plan mode off, press to turn on": Plan off
|
||||
- 'button "Access mode, current: Full access"': Full access
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
|
||||
@@ -14,10 +14,9 @@
|
||||
- paragraph: partial
|
||||
- button "2 queued messages"
|
||||
- textbox "Message the agent"
|
||||
- button "Add attachment":
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
|
||||
- button "Plan mode off, press to turn on": Plan off
|
||||
- 'button "Access mode, current: Full access"': Full access
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
|
||||
@@ -27,10 +27,9 @@
|
||||
- button "Cancel editing":
|
||||
- img
|
||||
- textbox "Message the agent"
|
||||
- button "Add attachment":
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
|
||||
- button "Plan mode off, press to turn on": Plan off
|
||||
- 'button "Access mode, current: Full access"': Full access
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
|
||||
@@ -20,10 +20,9 @@
|
||||
- button "Remove queued message":
|
||||
- img
|
||||
- textbox "Message the agent"
|
||||
- button "Add attachment":
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
|
||||
- button "Plan mode off, press to turn on": Plan off
|
||||
- 'button "Access mode, current: Full access"': Full access
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
|
||||
@@ -15,12 +15,16 @@
|
||||
- img
|
||||
- img
|
||||
- text: Think The user wants me to read a.txt and b.txt, then reply with "DONE". Let me do both reads in parallel.
|
||||
- img
|
||||
- text: Read
|
||||
- button "a.txt"
|
||||
- img
|
||||
- text: Read
|
||||
- button "b.txt"
|
||||
- button "Read a.txt":
|
||||
- img
|
||||
- img
|
||||
- text: Read
|
||||
- button "a.txt"
|
||||
- button "Read b.txt":
|
||||
- img
|
||||
- img
|
||||
- text: Read
|
||||
- button "b.txt"
|
||||
- button "Think Both files have been read. a.txt contains \"alpha\" and b.txt contains \"beta\". I'll now reply with DONE as instructed.":
|
||||
- img
|
||||
- img
|
||||
@@ -41,10 +45,9 @@
|
||||
- img
|
||||
- text: permission preset workspace-write
|
||||
- textbox "Message the agent"
|
||||
- button "Add attachment":
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Plan mode off, press to turn on": Plan off
|
||||
- button "Select model, current deepseek-v4-flash":
|
||||
- text: deepseek-v4-flash
|
||||
- img
|
||||
|
||||
@@ -15,12 +15,16 @@
|
||||
- img
|
||||
- img
|
||||
- text: Think The user wants me to read a.txt and b.txt, then reply with "DONE". Let me do both reads in parallel.
|
||||
- img
|
||||
- text: Read
|
||||
- button "a.txt"
|
||||
- img
|
||||
- text: Read
|
||||
- button "b.txt"
|
||||
- button "Read a.txt":
|
||||
- img
|
||||
- img
|
||||
- text: Read
|
||||
- button "a.txt"
|
||||
- button "Read b.txt":
|
||||
- img
|
||||
- img
|
||||
- text: Read
|
||||
- button "b.txt"
|
||||
- button "Think Both files have been read. a.txt contains \"alpha\" and b.txt contains \"beta\". I'll now reply with DONE as instructed.":
|
||||
- img
|
||||
- img
|
||||
@@ -39,10 +43,9 @@
|
||||
- img
|
||||
- text: Context injection
|
||||
- textbox "Message the agent"
|
||||
- button "Add attachment":
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
|
||||
- button "Plan mode off, press to turn on": Plan off
|
||||
- 'button "Access mode, current: Full access"': Full access
|
||||
- button "Select model, current deepseek-v4-flash":
|
||||
- text: deepseek-v4-flash
|
||||
- img
|
||||
|
||||
@@ -15,10 +15,10 @@
|
||||
- img
|
||||
- img
|
||||
- text: Think The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that.
|
||||
- button:
|
||||
- button "Ask question waiting":
|
||||
- img
|
||||
- img
|
||||
- text: Ask question waiting
|
||||
- text: Ask question waiting
|
||||
- region "Ready to continue?":
|
||||
- text: Checkpoint
|
||||
- heading "Ready to continue?" [level=2]
|
||||
|
||||
@@ -15,10 +15,11 @@
|
||||
- img
|
||||
- img
|
||||
- text: Think The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that.
|
||||
- button:
|
||||
- button "Ask question 1/1 answered":
|
||||
- img
|
||||
- img
|
||||
- text: "Ask question 1/1 answered Interjection Interjection: include the word BANANA in your final reply."
|
||||
- text: Ask question 1/1 answered
|
||||
- text: "Interjection Interjection: include the word BANANA in your final reply."
|
||||
- button "Think The user selected \"Yes\" and wants me to include the word \"BANANA\" in my final reply. Let me acknowledge their answer.":
|
||||
- img
|
||||
- img
|
||||
@@ -30,10 +31,9 @@
|
||||
- img
|
||||
- text: {{clock}}
|
||||
- textbox "Message the agent"
|
||||
- button "Add attachment":
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
|
||||
- button "Plan mode off, press to turn on": Plan off
|
||||
- 'button "Access mode, current: Full access"': Full access
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
|
||||
@@ -43,7 +43,8 @@
|
||||
"tests/cordis-tool-round.e2e.ts",
|
||||
"tests/message-actions.e2e.ts",
|
||||
"tests/queue-actions.e2e.ts",
|
||||
"tests/skill-invocation-policy.e2e.ts"
|
||||
"tests/skill-invocation-policy.e2e.ts",
|
||||
"tests/access-confirmation.e2e.ts"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user