Merge remote-tracking branch 'origin/master' into fix/web-transcript-projection

# Conflicts:
#	apps/web/tests/queue-actions.e2e.ts
#	packages/client/ui-conversation/README.i18n.yaml
#	packages/client/ui-conversation/src/client/chat/MessageItem.tsx
This commit is contained in:
Tianyi Cui
2026-07-31 17:17:00 +08:00
259 changed files with 72460 additions and 2930 deletions

View File

@@ -64,14 +64,14 @@ describe('web e2e: message IconActions and clocks on settled history', () => {
await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBe(1)
// Focus-reveal the footers (hover:hover keeps them opacity-hidden until
// hover/focus-within). User has three actions; each turn's last content
// assistant has copy + branch.
// hover/focus-within). User and each turn's last content assistant both
// have copy + branch.
const copyButtons = page.getByRole('button', { name: 'Copy' })
await expect.poll(() => copyButtons.count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(2)
await copyButtons.first().focus()
await expect.poll(() => page.getByRole('button', { name: 'Branch into a new conversation' }).count(), { timeout: 5_000 })
.toBeGreaterThanOrEqual(2)
await expect.poll(() => page.getByRole('button', { name: 'Edit' }).count(), { timeout: 5_000 }).toBe(1)
await expect.poll(() => page.getByRole('button', { name: 'Edit' }).count(), { timeout: 5_000 }).toBe(0)
}, 60_000)
it.skipIf(MODE === 'record')('matches the conversation aria golden with IconActions and clocks', async () => {

View File

@@ -0,0 +1,171 @@
// Web acceptance for current sandbox-policy context. A real Chromium drives
// the shipped /permission command through all three presets; record mode uses
// the real provider, while replay keeps the same provider-authored behavior
// keyless. Assertions read the exact durable header, runtime-context messages,
// and tool calls, so assistant prose alone cannot satisfy the scenario.
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import { canonicalPath } from '@deepseek-ai/dsh-sandbox'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import {
assertFixtureInventory, fixtureUserPrompts, launchWebScaffold, recordFixture,
watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/permission-policy-context', import.meta.url))
const FIXTURE = fileURLToPath(new URL('./snapshots/permission-policy-context/session.jsonl', import.meta.url))
const MODE = webSnapshotMode()
const PROMPTS = [
'Can you create or edit a normal file right now under the current policy? Answer directly in one sentence. Do not call a tool just to discover the policy.',
'Does the DSH file sandbox currently restrict file operations? Answer directly in one sentence. Do not call tools.',
'Reply with exactly WORKSPACE_POLICY_SEEN. Do not call tools.',
'Create the relative path policy-neutral.txt in the current workspace containing exactly POLICY_NEUTRAL_OK, verify its contents, then report completion.',
] as const
const PRESET_LABELS = ['Read Only', 'Full access', 'Workspace Write'] as const
function requestSystems(events: readonly SessionEvent[]): string[] {
return events.flatMap((event) => {
if (event.type !== 'request/header') return []
return typeof event.data.header.system === 'string' ? [event.data.header.system] : []
})
}
function runtimeContexts(events: readonly SessionEvent[]): string[] {
return events.flatMap((event) => {
if (event.type !== 'user/message'
|| event.data.source.kind !== 'plugin'
|| event.data.source.plugin !== '@deepseek-ai/dsh-system-prompt') return []
return event.data.content.flatMap(block => block.type === 'text' ? [block.text] : [])
})
}
function assistantTexts(events: readonly SessionEvent[]): string[] {
return events.flatMap((event) => {
if (event.type !== 'assistant/message') return []
const text = event.data.message.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('').replaceAll('**', '')
return text.length === 0 ? [] : [text]
})
}
function callArgs(event: Extract<SessionEvent, { type: 'tool/call' }>): Record<string, unknown> {
return JSON.parse(event.data.arguments) as Record<string, unknown>
}
describe('web e2e: current sandbox policy reaches the model before tools', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
let disposeApproval: (() => void) | undefined
let sessionWorkspace: string | undefined
const sessionEvents: SessionEvent[] = []
beforeAll(async () => {
scaffold = await launchWebScaffold(MODE === 'record' ? {} : { replayFixture: FIXTURE })
disposeApproval = scaffold.ctx.on('approval/request', () => Promise.resolve('allowed-once'), { prepend: true })
scaffold.ctx.on('session/event', (session, event: SessionEvent) => {
sessionWorkspace = session.header.cwd
sessionEvents.push(event)
})
browser = await chromium.launch()
page = await newEnglishPage(browser)
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
await connectFreshWorkspace(page)
}, 120_000)
afterAll(async () => {
await browser?.close()
disposeApproval?.()
await scaffold?.close()
})
it('switches read-only, danger-full-access, and workspace-write through the real GUI command path', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-permission-policy-context'))
if (MODE !== 'record') {
expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual(PROMPTS)
}
const input = page.locator('textarea').first()
let sessionId: Awaited<ReturnType<WebScaffold['whenTurnSettled']>> | undefined
for (const [index, preset] of ['read-only', 'danger-full-access', 'workspace-write'].entries()) {
await input.fill(`/permission ${preset}`)
await input.press('Enter')
await page.getByRole('button', { name: `Access mode, current: ${PRESET_LABELS[index]}` })
.waitFor({ timeout: 10_000 })
const settled = scaffold.whenTurnSettled()
await input.fill(PROMPTS[index] as string)
await input.press('Enter')
sessionId = await settled
await expect.poll(() => input.isEnabled(), { timeout: 10_000 }).toBe(true)
}
await input.fill('/permission read-only')
await input.press('Enter')
await page.getByRole('button', { name: 'Access mode, current: Read Only' }).waitFor({ timeout: 10_000 })
const settled = scaffold.whenTurnSettled()
await input.fill(PROMPTS[3])
await input.press('Enter')
sessionId = await settled
if (sessionId === undefined) throw new Error('permission-policy scenario completed no model turn')
if (MODE === 'record') await recordFixture(scaffold, sessionId, FIXTURE)
}, 240_000)
it.skipIf(MODE === 'record')('records cache-safe current policy before the corresponding model behavior', async () => {
const systems = requestSystems(sessionEvents)
expect(systems).toHaveLength(1)
expect(systems[0]).not.toContain('Current DSH file policy:')
expect(systems[0]).not.toContain('Approval policy:')
expect(systems[0]).not.toContain('Approval prompts are disabled in this session')
const contexts = runtimeContexts(sessionEvents)
expect(contexts).toHaveLength(4)
expect(contexts[0]).toContain('Current DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode.')
expect(contexts[0]).toContain('Do not refuse a required modification from this policy alone')
expect(contexts[0]).toContain('Approval policy: ask.')
expect(contexts[1]).toContain('Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.')
expect(contexts[1]).toContain('Approval prompts are disabled in this session')
if (sessionWorkspace === undefined) throw new Error('permission-policy scenario observed no session workspace')
expect(contexts[2]).toContain(`Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: ${JSON.stringify(canonicalPath(sessionWorkspace))}. Some platform temporary areas may also be writable.`)
expect(contexts[2]).toContain('Approval policy: ask.')
expect(contexts[2]).not.toContain('Approval prompts are disabled in this session')
expect(contexts[3]).toContain('Current DSH file policy: read-only.')
const answers = assistantTexts(sessionEvents)
expect(answers.length).toBeGreaterThanOrEqual(4)
expect(answers[0]).toMatch(/read-only.*(?:denied|cannot modify|cannot create or edit)/i)
expect(answers[1]).toMatch(/does not restrict.*(?:file operations|(?:write\/edit tools|write and edit tools).*one-shot bash commands)/i)
expect(answers[2]).toBe('WORKSPACE_POLICY_SEEN')
const calls = sessionEvents.filter(
(event): event is Extract<SessionEvent, { type: 'tool/call' }> => event.type === 'tool/call',
)
expect(calls.every(call => call.data.turn === 4)).toBe(true)
expect(calls.length).toBeGreaterThanOrEqual(2)
const firstCall = calls[0]
if (firstCall === undefined) throw new Error('neutral policy task produced no tool call')
expect(callArgs(firstCall)['sandbox_permissions']).toBeUndefined()
expect(calls.some(call => callArgs(call)['sandbox_permissions'] !== undefined)).toBe(true)
expect(sessionEvents.some(event => event.type === 'tool/result'
&& JSON.stringify(event.data).includes('[sandbox: file access denied under read-only mode]'))).toBe(true)
expect(sessionEvents.some(event => event.type === 'approval/asked')).toBe(true)
if (sessionWorkspace === undefined) throw new Error('permission-policy scenario observed no session workspace')
expect(await readFile(join(sessionWorkspace, 'policy-neutral.txt'), 'utf8')).toBe('POLICY_NEUTRAL_OK')
})
it.skipIf(MODE === 'record')('stays clean and keeps the fixture inventory closed', async () => {
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl'])
})
})

View File

@@ -131,9 +131,7 @@ describe('web e2e: queue row actions', () => {
const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
expect(sessionEvents.filter(event =>
event.type === 'user/message' && event.data.source.kind === 'user',
)).toHaveLength(1)
expect(sessionEvents.filter(event => event.type === 'user/message' && event.data.source.kind === 'user')).toHaveLength(1)
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])

View File

@@ -140,6 +140,17 @@ export interface LaunchOptions {
* keyless first-run configuration lane; the default disables the adapter.
*/
deepSeekMissingCredential?: boolean
/**
* Patch the shipped DeepSeek search row to a deterministic endpoint and
* credential reference. Browser search scenarios keep the real provider and
* credentials seam while avoiding external search traffic and ambient keys.
*/
deepSeekSearch?: {
/** Anthropic-compatible base URL; the provider appends `/messages`. */
baseURL: string
/** Credential reference resolved by the shipped search provider. */
apiKeyEnv: string
}
/** Leave the current welcome notice unacknowledged; ordinary scenarios publish it as complete before browser boot. */
welcomeNoticePending?: boolean
}
@@ -248,6 +259,15 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
...options.cordisTools === true
? [{ insert: [{ id: 'tool-cordis', name: 'cordis:tool-cordis' }] }]
: [],
...options.deepSeekSearch === undefined
? []
: [{
id: 'web-search-deepseek',
config: {
apiKeyEnv: options.deepSeekSearch.apiKeyEnv,
baseURL: options.deepSeekSearch.baseURL,
},
}],
...mode === 'record' || options.deepSeekMissingCredential === true
? []
: [{ id: 'llm-deepseek', disabled: true }],

View File

@@ -188,8 +188,12 @@ describe('dsh web keyless CLI smoke', () => {
mkdirSync(join(workspace, '.git'))
writeFileSync(join(workspace, 'AGENTS.md'), 'web-workspace-context-probe\n')
let resolveProviderRequest!: (request: { messages?: { role?: string; content?: string }[] }) => void
const providerRequest = new Promise<{ messages?: { role?: string; content?: string }[] }>((resolve) => {
interface NativeProviderRequest {
messages?: { role?: string; content?: string }[]
tools?: { function?: { name?: string } }[]
}
let resolveProviderRequest!: (request: NativeProviderRequest) => void
const providerRequest = new Promise<NativeProviderRequest>((resolve) => {
resolveProviderRequest = resolve
})
const provider = createServer((request, response) => {
@@ -197,7 +201,7 @@ describe('dsh web keyless CLI smoke', () => {
request.setEncoding('utf8')
request.on('data', (chunk: string) => { body += chunk })
request.on('end', () => {
resolveProviderRequest(JSON.parse(body) as { messages?: { role?: string; content?: string }[] })
resolveProviderRequest(JSON.parse(body) as NativeProviderRequest)
response.writeHead(200, { 'content-type': 'text/event-stream' })
response.end([
'data: {"choices":[{"delta":{"role":"assistant","content":null,"reasoning_content":""}}]}',
@@ -256,6 +260,13 @@ describe('dsh web keyless CLI smoke', () => {
"role": "user",
}
`)
expect(captured.tools?.map(tool => tool.function?.name)
.filter(name => name === 'web_search' || name === 'web_fetch'))
.toMatchInlineSnapshot(`
[
"web_search",
]
`)
} finally {
const closed = child.exitCode === null
? new Promise<void>((resolveClose) => { child.once('close', () => { resolveClose() }) })

View File

@@ -9,8 +9,10 @@
- img
- button "Branch into a new conversation":
- img
- button "Edit":
- button "Context injection":
- img
- img
- text: Context injection
- 'button "Think The user wants me to write a single `run_code` program that:"':
- img
- img
@@ -20,10 +22,8 @@
- img
- text: Code Run bash echo and catch missing file read
- img
- 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"
- text: Bash Echo CODE_ROUND_OK 失败 Read
- button "missing.txt"
- button "Think The program ran successfully. Let me now reply DONE as instructed.":
- img
- img

View File

@@ -9,8 +9,10 @@
- img
- button "Branch into a new conversation":
- img
- button "Edit":
- button "Context injection":
- img
- img
- text: Context injection
- button "Think The user wants me to:":
- img
- img

View File

@@ -9,8 +9,10 @@
- img
- button "Branch into a new conversation":
- img
- button "Edit":
- button "Context injection":
- img
- img
- text: Context injection
- button "Think The user wants me to run a simple bash command and reply with \"DONE\".":
- img
- img

View File

@@ -9,8 +9,10 @@
- img
- button "Branch into a new conversation":
- img
- button "Edit":
- button "Context injection":
- img
- img
- text: Context injection
- button "Think The user wants me to reply with a single word. Let me comply.":
- img
- img

View File

@@ -9,8 +9,10 @@
- img
- button "Branch into a new conversation":
- img
- button "Edit":
- button "Context injection":
- img
- img
- text: Context injection
- paragraph: partial
- text: Stopped
- button "Copy":

View File

@@ -9,8 +9,10 @@
- img
- button "Branch into a new conversation":
- img
- button "Edit":
- button "Context injection":
- img
- img
- text: Context injection
- textbox "Message the agent"
- button "Commands":
- img

View File

@@ -9,8 +9,10 @@
- img
- button "Branch into a new conversation":
- img
- button "Edit":
- button "Context injection":
- img
- img
- text: Context injection
- paragraph: partial
- status: Deep diving...
- textbox "Message the agent"

View File

@@ -9,8 +9,10 @@
- img
- button "Branch into a new conversation":
- img
- button "Edit":
- button "Context injection":
- img
- img
- text: Context injection
- 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.":

View File

@@ -10,22 +10,16 @@
- tooltip "Copy"
- button "Branch into a new conversation":
- img
- button "Edit":
- img
- button "Think The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel.":
- 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.
- button "Read a.txt":
- img
- img
- text: Read
- button "a.txt"
- button "Read b.txt":
- img
- img
- text: Read
- button "b.txt"
- img
- text: Read
- button "a.txt"
- 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

File diff suppressed because one or more lines are too long

View File

@@ -10,8 +10,10 @@
- img
- button "Branch into a new conversation":
- img
- button "Edit":
- button "Context injection":
- img
- img
- text: Context injection
- 'button "Think The user wants me to plan a small change to add a `--greeting` flag to a CLI. They explicitly told me not to read or write any files, and to call exit_plan_mode with a short plan. Let me do that directly."':
- img
- img

View File

@@ -9,8 +9,10 @@
- img
- button "Branch into a new conversation":
- img
- button "Edit":
- button "Context injection":
- img
- img
- text: Context injection
- button "Think The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that.":
- img
- img

View File

@@ -9,8 +9,10 @@
- img
- button "Branch into a new conversation":
- img
- button "Edit":
- button "Context injection":
- img
- img
- text: Context injection
- paragraph: partial
- status: Deep diving...
- button "2 queued messages"

View File

@@ -9,8 +9,10 @@
- img
- button "Branch into a new conversation":
- img
- button "Edit":
- button "Context injection":
- img
- img
- text: Context injection
- paragraph: partial
- status: Deep diving...
- button "2 queued messages" [disabled] [expanded]

View File

@@ -9,8 +9,10 @@
- img
- button "Branch into a new conversation":
- img
- button "Edit":
- button "Context injection":
- img
- img
- text: Context injection
- paragraph: partial
- status: Deep diving...
- list:

View File

@@ -9,22 +9,16 @@
- img
- button "Branch into a new conversation":
- img
- button "Edit":
- img
- button "Think The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel.":
- 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.
- button "Read a.txt":
- img
- img
- text: Read
- button "a.txt"
- button "Read b.txt":
- img
- img
- text: Read
- button "b.txt"
- img
- text: Read
- button "a.txt"
- 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

View File

@@ -9,22 +9,16 @@
- img
- button "Branch into a new conversation":
- img
- button "Edit":
- img
- button "Think The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel.":
- 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.
- button "Read a.txt":
- img
- img
- text: Read
- button "a.txt"
- button "Read b.txt":
- img
- img
- text: Read
- button "b.txt"
- img
- text: Read
- button "a.txt"
- 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

View File

@@ -9,8 +9,10 @@
- img
- button "Branch into a new conversation":
- img
- button "Edit":
- button "Context injection":
- img
- img
- text: Context injection
- button "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.":
- img
- img

View File

@@ -9,8 +9,10 @@
- img
- button "Branch into a new conversation":
- img
- button "Edit":
- button "Context injection":
- img
- img
- text: Context injection
- button "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.":
- img
- img

View File

@@ -0,0 +1,12 @@
{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1785456000000,"cwd":"{{cwd}}"}
{"type":"user/message","seq":0,"time":1785456000001,"data":{"content":[{"type":"text","text":"Use web_search to search exactly \"DeepSeek Harness snapshot search\". Then reply exactly SEARCH_DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"}
{"type":"assistant/chunk","seq":1,"time":1785456000002,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
{"type":"assistant/chunk","seq":2,"time":1785456000003,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_web_search","name":"web_search","argumentsDelta":"{\"query\":\"DeepSeek Harness snapshot search\"}"}}}
{"type":"assistant/chunk","seq":3,"time":1785456000004,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_web_search","name":"web_search","arguments":"{\"query\":\"DeepSeek Harness snapshot search\"}"}}}}
{"type":"assistant/chunk","seq":4,"time":1785456000005,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}
{"type":"assistant/chunk","seq":5,"time":1785456000006,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/chunk","seq":6,"time":1785456000007,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
{"type":"assistant/chunk","seq":7,"time":1785456000008,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"SEARCH_DONE"}}}
{"type":"assistant/chunk","seq":8,"time":1785456000009,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"SEARCH_DONE"}}}}
{"type":"assistant/chunk","seq":9,"time":1785456000010,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":12,"outputTokens":2}}}}
{"type":"assistant/chunk","seq":10,"time":1785456000011,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}

View File

@@ -0,0 +1,37 @@
- banner:
- navigation "Session hierarchy":
- button "Use web_search to search exactly" [disabled]
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- text: Use web_search to search exactly "DeepSeek Harness snapshot search". Then reply exactly SEARCH_DONE and stop. {{clock}}
- button "Copy":
- img
- button "Branch into a new conversation":
- img
- button "Context injection":
- img
- img
- text: Context injection
- img
- text: Search DeepSeek Harness snapshot search
- list:
- listitem:
- link "Snapshot Search Result":
- /url: https://docs.example.test/search
- text: Snapshot search excerpt. 2026-07-31
- paragraph: SEARCH_DONE
- button "Copy":
- img
- button "Branch into a new conversation":
- img
- text: {{clock}}
- textbox "Message the agent"
- button "Commands":
- img
- 'button "Access mode, current: Full access"': Full access
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
- button "Send message" [disabled]
- text: 1 turns · 2 steps Tool call {{duration}} Cache hit 0% Input 22 tok · Output 7 tok

View File

@@ -0,0 +1,207 @@
// Web e2e scenario for the shipped default search composition. A real browser
// drives `web_search`; the model stream is replayed while the real DeepSeek
// provider calls a deterministic local Anthropic-compatible endpoint through
// the real credentials service.
import { readFile } from 'node:fs/promises'
import { createServer, type Server } from 'node:http'
import type { AddressInfo } from 'node:net'
import { fileURLToPath } from 'node:url'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import { credentialRef } from '@deepseek-ai/dsh-credentials'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
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-search-round', import.meta.url))
const FIXTURE = fileURLToPath(new URL('./snapshots/web-search-round/session.jsonl', import.meta.url))
const UI_EXPECTED = fileURLToPath(new URL('./snapshots/web-search-round/ui.expected.md', import.meta.url))
const MODE = webSnapshotMode()
const QUERY = 'DeepSeek Harness snapshot search'
const PROMPT = `Use web_search to search exactly "${QUERY}". Then reply exactly SEARCH_DONE and stop.`
const SEARCH_CREDENTIAL_REF = credentialRef('DSH_WEB_SEARCH_E2E_KEY')
const SEARCH_CREDENTIAL = 'snapshot-search-key'
const RESULT_URL = 'https://docs.example.test/search'
interface CapturedSearchRequest {
path: string
apiKey: string | undefined
body: unknown
}
/** Start the deterministic DeepSeek Messages double used by the real provider. */
async function startSearchServer(captured: CapturedSearchRequest[]): Promise<{ server: Server; baseURL: string }> {
const server = createServer((request, response) => {
let body = ''
request.setEncoding('utf8')
request.on('data', (chunk: string) => { body += chunk })
request.on('end', () => {
captured.push({
path: request.url ?? '',
apiKey: typeof request.headers['x-api-key'] === 'string' ? request.headers['x-api-key'] : undefined,
body: JSON.parse(body) as unknown,
})
response.writeHead(200, { 'content-type': 'application/json' })
response.end(JSON.stringify({
content: [
{
type: 'text',
text: 'Found one source.',
citations: [{
type: 'web_search_result_location',
url: RESULT_URL,
cited_text: 'Snapshot search excerpt.',
}],
},
{
type: 'web_search_tool_result',
content: [{
type: 'web_search_result',
url: RESULT_URL,
title: 'Snapshot Search Result',
page_age: '2026-07-31',
}],
},
],
}))
})
})
await new Promise<void>((resolve, reject) => {
server.once('error', reject)
server.listen(0, '127.0.0.1', () => {
server.off('error', reject)
resolve()
})
})
const address = server.address() as AddressInfo
return { server, baseURL: `http://127.0.0.1:${address.port}` }
}
describe('web e2e: shipped default web search', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let searchServer: Server | undefined
let searchBaseURL: string
let tripwire: ReturnType<typeof watchConsole>
const searchRequests: CapturedSearchRequest[] = []
const sessionEvents: SessionEvent[] = []
beforeAll(async () => {
const search = await startSearchServer(searchRequests)
searchServer = search.server
searchBaseURL = search.baseURL
scaffold = await launchWebScaffold({
deepSeekSearch: {
baseURL: search.baseURL,
apiKeyEnv: SEARCH_CREDENTIAL_REF,
},
...(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: 15 }),
})
await scaffold.ctx.credentials.set(SEARCH_CREDENTIAL_REF, SEARCH_CREDENTIAL)
scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) })
browser = await chromium.launch()
page = await newEnglishPage(browser)
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
await connectFreshWorkspace(page)
}, 120_000)
afterAll(async () => {
await browser?.close()
await scaffold?.close()
await new Promise<void>((resolve, reject) => {
if (searchServer === undefined) {
resolve()
return
}
searchServer.close((error) => {
if (error === undefined) resolve()
else reject(error)
})
})
})
it('drives the recorded search to a settled turn (all modes)', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-search-drive'))
if (MODE !== 'record') {
expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT])
}
const input = page.locator('textarea').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') await recordFixture(scaffold, sessionId, FIXTURE)
}, 200_000)
it.skipIf(MODE === 'record')('uses the real provider and persists the structured result', () => {
expect(searchRequests).toHaveLength(1)
expect(searchRequests[0]).toMatchObject({
path: '/messages',
apiKey: SEARCH_CREDENTIAL,
body: {
messages: [{
role: 'user',
content: [{ type: 'text', text: `Perform a web search for the query: ${QUERY}` }],
}],
tools: [{ type: 'web_search_20250305', name: 'web_search' }],
},
})
const auxiliaryRequest = sessionEvents.find(
(event): event is Extract<SessionEvent, { type: 'web/deepseek-search-llm-request' }> =>
event.type === 'web/deepseek-search-llm-request',
)
expect(auxiliaryRequest?.data).toEqual({
endpoint: `${searchBaseURL}/messages`,
apiVersion: '2023-06-01',
body: searchRequests[0]?.body,
})
const searchCall = sessionEvents.find(
(event): event is Extract<SessionEvent, { type: 'tool/call' }> =>
event.type === 'tool/call' && event.data.name === 'web_search',
)
if (searchCall === undefined) throw new Error('the replayed turn did not call web_search')
const searchResult = sessionEvents.find(
(event): event is Extract<SessionEvent, { type: 'tool/result' }> =>
event.type === 'tool/result' && event.data.message.source.callId === searchCall.data.callId,
)
if (searchResult === undefined) throw new Error('web_search produced no durable result')
const content = searchResult.data.message.content[0]
expect(content.isError).toBe(false)
expect(content.content.filter(block => block.type === 'text').map(block => block.text).join(''))
.toContain(`[Snapshot Search Result](${RESULT_URL})`)
expect(searchResult.data.meta).toMatchObject({
sources: [{
url: RESULT_URL,
title: 'Snapshot Search Result',
snippet: 'Snapshot search excerpt.',
publishedAt: '2026-07-31',
}],
truncated: false,
})
})
it.skipIf(MODE === 'record')('matches the settled search card aria golden', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-search-aria'))
await expect.poll(() => page.getByText('SEARCH_DONE', { exact: true }).count(), { timeout: 15_000 })
.toBeGreaterThanOrEqual(1)
await page.locator('[data-tool="web_search"]').waitFor({ timeout: 10_000 })
const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
})
it.skipIf(MODE === 'record')('stayed clean and kept the exact fixture inventory', async () => {
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'ui.expected.md'])
})
})

View File

@@ -42,9 +42,11 @@
"tests/code-mode-round.e2e.ts",
"tests/composer-draft-scroll.e2e.ts",
"tests/cordis-tool-round.e2e.ts",
"tests/web-search-round.e2e.ts",
"tests/message-actions.e2e.ts",
"tests/queue-actions.e2e.ts",
"tests/skill-invocation-policy.e2e.ts",
"tests/permission-policy-context.e2e.ts",
"tests/access-confirmation.e2e.ts"
],
"references": [