Merge origin/master into codex/status-bar-token-metrics
Resolve the agent-loop import conflict by retaining both durable request context and runtime policy context. Refresh the combined session fixtures and regenerate documentation catalogs. Mark PDF artifacts as binary so staged whitespace checks do not parse PDF bytes as text.
This commit is contained in:
171
apps/web/tests/permission-policy-context.e2e.ts
Normal file
171
apps/web/tests/permission-policy-context.e2e.ts
Normal 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'])
|
||||
})
|
||||
})
|
||||
@@ -131,7 +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')).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([])
|
||||
|
||||
|
||||
@@ -139,6 +139,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
|
||||
}
|
||||
@@ -247,6 +258,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 }],
|
||||
|
||||
@@ -190,8 +190,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) => {
|
||||
@@ -199,7 +203,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":""}}]}',
|
||||
@@ -261,6 +265,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() }) })
|
||||
|
||||
@@ -11,6 +11,10 @@
|
||||
- img
|
||||
- button "Edit":
|
||||
- img
|
||||
- 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 +24,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
|
||||
|
||||
@@ -11,6 +11,10 @@
|
||||
- img
|
||||
- button "Edit":
|
||||
- img
|
||||
- button "Context injection":
|
||||
- img
|
||||
- img
|
||||
- text: Context injection
|
||||
- button "Think The user wants me to:":
|
||||
- img
|
||||
- img
|
||||
|
||||
@@ -11,6 +11,10 @@
|
||||
- img
|
||||
- button "Edit":
|
||||
- img
|
||||
- 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
|
||||
|
||||
@@ -11,6 +11,10 @@
|
||||
- img
|
||||
- button "Edit":
|
||||
- img
|
||||
- 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
|
||||
|
||||
@@ -11,6 +11,10 @@
|
||||
- img
|
||||
- button "Edit":
|
||||
- img
|
||||
- button "Context injection":
|
||||
- img
|
||||
- img
|
||||
- text: Context injection
|
||||
- paragraph: partial
|
||||
- text: Stopped
|
||||
- button "Copy":
|
||||
|
||||
@@ -11,6 +11,10 @@
|
||||
- img
|
||||
- button "Edit":
|
||||
- img
|
||||
- button "Context injection":
|
||||
- img
|
||||
- img
|
||||
- text: Context injection
|
||||
- textbox "Message the agent"
|
||||
- button "Commands":
|
||||
- img
|
||||
|
||||
@@ -11,6 +11,10 @@
|
||||
- img
|
||||
- button "Edit":
|
||||
- img
|
||||
- button "Context injection":
|
||||
- img
|
||||
- img
|
||||
- text: Context injection
|
||||
- paragraph: partial
|
||||
- status: Deep diving...
|
||||
- textbox "Message the agent"
|
||||
|
||||
@@ -11,6 +11,10 @@
|
||||
- img
|
||||
- button "Edit":
|
||||
- img
|
||||
- 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.":
|
||||
|
||||
@@ -16,16 +16,12 @@
|
||||
- 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
|
||||
|
||||
124
apps/web/tests/snapshots/permission-policy-context/session.jsonl
Normal file
124
apps/web/tests/snapshots/permission-policy-context/session.jsonl
Normal file
File diff suppressed because one or more lines are too long
@@ -12,6 +12,10 @@
|
||||
- img
|
||||
- button "Edit":
|
||||
- img
|
||||
- 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
|
||||
|
||||
@@ -11,6 +11,10 @@
|
||||
- img
|
||||
- button "Edit":
|
||||
- img
|
||||
- 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
|
||||
|
||||
@@ -11,6 +11,10 @@
|
||||
- img
|
||||
- button "Edit":
|
||||
- img
|
||||
- button "Context injection":
|
||||
- img
|
||||
- img
|
||||
- text: Context injection
|
||||
- paragraph: partial
|
||||
- status: Deep diving...
|
||||
- button "2 queued messages"
|
||||
|
||||
@@ -11,6 +11,10 @@
|
||||
- img
|
||||
- button "Edit":
|
||||
- img
|
||||
- button "Context injection":
|
||||
- img
|
||||
- img
|
||||
- text: Context injection
|
||||
- paragraph: partial
|
||||
- status: Deep diving...
|
||||
- button "2 queued messages" [disabled] [expanded]
|
||||
|
||||
@@ -11,6 +11,10 @@
|
||||
- img
|
||||
- button "Edit":
|
||||
- img
|
||||
- button "Context injection":
|
||||
- img
|
||||
- img
|
||||
- text: Context injection
|
||||
- paragraph: partial
|
||||
- status: Deep diving...
|
||||
- list:
|
||||
|
||||
@@ -15,16 +15,12 @@
|
||||
- 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
|
||||
|
||||
@@ -15,16 +15,12 @@
|
||||
- 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
|
||||
|
||||
@@ -11,6 +11,10 @@
|
||||
- img
|
||||
- button "Edit":
|
||||
- img
|
||||
- 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
|
||||
|
||||
@@ -11,6 +11,10 @@
|
||||
- img
|
||||
- button "Edit":
|
||||
- img
|
||||
- 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
|
||||
|
||||
12
apps/web/tests/snapshots/web-search-round/session.jsonl
Normal file
12
apps/web/tests/snapshots/web-search-round/session.jsonl
Normal 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"}}}}
|
||||
39
apps/web/tests/snapshots/web-search-round/ui.expected.md
Normal file
39
apps/web/tests/snapshots/web-search-round/ui.expected.md
Normal file
@@ -0,0 +1,39 @@
|
||||
- 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 "Edit":
|
||||
- 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
|
||||
207
apps/web/tests/web-search-round.e2e.ts
Normal file
207
apps/web/tests/web-search-round.e2e.ts
Normal 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'])
|
||||
})
|
||||
})
|
||||
@@ -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": [
|
||||
|
||||
Reference in New Issue
Block a user