mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
Merge remote-tracking branch 'origin/master' into worktree/composer-editable-gate
# Conflicts: # apps/web/tests/permission-policy-context.e2e.ts
This commit is contained in:
@@ -23,7 +23,9 @@ import {
|
||||
captureStableAria, compareOrRefreshGolden, launchWebScaffold, seedSession, watchConsole,
|
||||
webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
|
||||
import {
|
||||
connectFreshWorkspace, newEnglishPage, saveFailureShot, writeComposerDraft,
|
||||
} from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/agent-preset-selection', import.meta.url))
|
||||
const HERO_EXPECTED = join(SNAPSHOT_DIR, 'hero.expected.md')
|
||||
@@ -287,7 +289,7 @@ describe('web e2e: agent-preset selection', () => {
|
||||
// `minimal` mounts neither the compaction group nor plan mode nor local
|
||||
// skill discovery, so the catalog the composer warmed under the
|
||||
// deployment default must not survive the switch.
|
||||
await composer.fill('/')
|
||||
await writeComposerDraft(page, composer, '/')
|
||||
await expect.poll(() => menuOptions(page), { timeout: 15_000 })
|
||||
.not.toEqual(expect.arrayContaining([expect.stringContaining(SKILL_NAME)]))
|
||||
const onMinimal = await menuOptions(page)
|
||||
@@ -297,7 +299,7 @@ describe('web e2e: agent-preset selection', () => {
|
||||
// remains outside every preset.
|
||||
expect(onMinimal.some(option => option.startsWith('goal'))).toBe(false)
|
||||
expect(onMinimal.some(option => option.startsWith('model'))).toBe(true)
|
||||
await composer.fill('')
|
||||
await writeComposerDraft(page, composer, '')
|
||||
|
||||
// Switching back up reaches the host at all — the chip compares the pick
|
||||
// against its list row, so a row that never reprojected the first switch
|
||||
@@ -307,14 +309,14 @@ describe('web e2e: agent-preset selection', () => {
|
||||
await page.getByRole('menuitem', { name: /^Standard mode/ }).first().click()
|
||||
await expect.poll(() => livePreset(scaffold), { timeout: 15_000 }).toBe('standard')
|
||||
|
||||
await composer.fill('/')
|
||||
await writeComposerDraft(page, composer, '/')
|
||||
await expect.poll(() => menuOptions(page), { timeout: 15_000 })
|
||||
.toEqual(expect.arrayContaining([expect.stringContaining(SKILL_NAME)]))
|
||||
const onStandard = await menuOptions(page)
|
||||
expect(onStandard.some(option => option.startsWith('compact'))).toBe(true)
|
||||
expect(onStandard.some(option => option.startsWith('goal'))).toBe(true)
|
||||
expect(onStandard.some(option => option.startsWith('plan'))).toBe(true)
|
||||
await composer.fill('')
|
||||
await writeComposerDraft(page, composer, '')
|
||||
}, 90_000)
|
||||
|
||||
it('labels a resumed session with the preset it was created under', async () => {
|
||||
|
||||
@@ -106,7 +106,7 @@ describe('web e2e: current sandbox policy reaches the model before tools', () =>
|
||||
await writeComposerDraft(page, input, PROMPTS[index] as string)
|
||||
await input.press('Enter')
|
||||
sessionId = await settled
|
||||
await expect.poll(() => input.getAttribute('contenteditable'), { timeout: 10_000 }).toBe('true')
|
||||
await page.locator('[data-composer-input][contenteditable="true"]').first().waitFor({ timeout: 10_000 })
|
||||
}
|
||||
|
||||
await writeComposerDraft(page, input, '/permission read-only')
|
||||
|
||||
@@ -267,7 +267,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
|
||||
|
||||
it('keeps known descendants reachable across a stale empty catalog response', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-stale-catalog'))
|
||||
const pattern = '**/api/subagent.list'
|
||||
const pattern = '**/api/subagents/list'
|
||||
let firstClaimed = false
|
||||
let emptyDelivered = false
|
||||
let trailingRequested = false
|
||||
@@ -373,7 +373,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
|
||||
|
||||
it('keeps a restored child neutral until its parent availability arrives', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-restore'))
|
||||
const pattern = '**/api/subagent.list'
|
||||
const pattern = '**/api/subagents/list'
|
||||
let requested = false
|
||||
let releaseCatalog = (): void => {}
|
||||
const catalogHeld = new Promise<void>((resolve) => { releaseCatalog = resolve })
|
||||
@@ -546,7 +546,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
|
||||
const input = page.locator('[data-composer-input][contenteditable="true"]').first()
|
||||
await input.waitFor()
|
||||
const promptResponse = page.waitForResponse(response =>
|
||||
new URL(response.url()).pathname === '/api/subagent.prompt')
|
||||
new URL(response.url()).pathname === '/api/subagents/prompt')
|
||||
await input.fill(POST_FORK_FOLLOWUP)
|
||||
await input.press('Enter')
|
||||
const promptReceipt = await (await promptResponse).json() as {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Web e2e scenario: the composer's independent Stop interrupts a running
|
||||
// continuable child. The child holds its model turn open through a replay
|
||||
// hang entry; the browser proves Send and Stop coexist, the parent-offline
|
||||
// disabled-Send-with-Stop composer, the subagent.interrupt
|
||||
// disabled-Send-with-Stop composer, the subagents/interruptByParent
|
||||
// (never session.cancel) transport, the parked follow-up, and the FIFO resume
|
||||
// on a waking send.
|
||||
//
|
||||
@@ -187,7 +187,7 @@ describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running co
|
||||
// parentAvailable: false while the child Activation stays live (the
|
||||
// interrupt RPC itself needs no live parent — covered host-side by
|
||||
// subagent-interrupt.e2e.ts).
|
||||
const pattern = '**/api/subagent.list'
|
||||
const pattern = '**/api/subagents/list'
|
||||
await page.route(pattern, async (route) => {
|
||||
const response = await route.fetch()
|
||||
const body = await response.json() as {
|
||||
@@ -228,7 +228,7 @@ describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running co
|
||||
)
|
||||
const aborted = waitForAbortedTurn(scaffold, childId)
|
||||
const interruptResponse = page.waitForResponse(response =>
|
||||
new URL(response.url()).pathname === '/api/subagent.interrupt')
|
||||
new URL(response.url()).pathname === '/api/subagents/interruptByParent')
|
||||
await stop.click()
|
||||
expect(((await (await interruptResponse).json()) as {
|
||||
result: { ok: boolean; value?: { accepted: boolean } }
|
||||
@@ -252,7 +252,7 @@ describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running co
|
||||
}
|
||||
}, 60_000)
|
||||
|
||||
it('interrupts through subagent.interrupt, parks the follow-up, and resumes it FIFO', async () => {
|
||||
it('interrupts through subagents/interruptByParent, parks the follow-up, and resumes it FIFO', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-interrupt-flow'))
|
||||
// Reselect the child with the truthful catalog: parent available again.
|
||||
await page.getByRole('navigation', { name: 'Session hierarchy' })
|
||||
@@ -265,7 +265,7 @@ describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running co
|
||||
|
||||
// Queue a follow-up through Send while independent Stop remains available.
|
||||
const promptResponse = page.waitForResponse(response =>
|
||||
new URL(response.url()).pathname === '/api/subagent.prompt')
|
||||
new URL(response.url()).pathname === '/api/subagents/prompt')
|
||||
await input.fill(FOLLOWUP)
|
||||
await page.getByRole('button', { name: 'Send message' }).click()
|
||||
expect(((await (await promptResponse).json()) as { result: { ok: boolean } }).result)
|
||||
@@ -275,7 +275,7 @@ describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running co
|
||||
const stop = page.getByRole('button', { name: 'Stop generating' })
|
||||
expect(await stop.count()).toBe(1)
|
||||
const interruptResponse = page.waitForResponse(response =>
|
||||
new URL(response.url()).pathname === '/api/subagent.interrupt')
|
||||
new URL(response.url()).pathname === '/api/subagents/interruptByParent')
|
||||
await stop.click()
|
||||
expect(((await (await interruptResponse).json()) as {
|
||||
result: { ok: boolean; value?: { accepted: boolean } }
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Web e2e scenario (browserless): the subagent.interrupt RPC against the real
|
||||
// Web e2e scenario (browserless): the subagents interrupt Remote against the real
|
||||
// composition. A live continuable child holds its model turn open through a
|
||||
// replay hang entry; plain HTTP queues a follow-up, interrupts the turn, and
|
||||
// proves from the real session state that the turn aborted, the follow-up
|
||||
@@ -22,25 +22,12 @@ const WAKING = 'And add one concrete example.'
|
||||
|
||||
type RpcResult<T> = { ok: true; value: T } | { ok: false; error: { code: string; message: string } }
|
||||
|
||||
/** POST one API Proxy unary RPC through the real HTTP carrier and unwrap its result. */
|
||||
async function rpc<T>(scaffold: WebScaffold, method: string, payload: unknown): Promise<RpcResult<T>> {
|
||||
const response = await scaffold.hostFetch(`/api/${method}`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
type: 'client-request',
|
||||
rpcId: `interrupt-e2e-${method}-${randomUUID()}`,
|
||||
method,
|
||||
payload,
|
||||
}),
|
||||
})
|
||||
if (!response.ok) throw new Error(`${method} failed over HTTP ${response.status}: ${await response.text()}`)
|
||||
return (await response.json() as { result: RpcResult<T> }).result
|
||||
}
|
||||
|
||||
/** POST one generated Session Remote unary through the API Gateway carrier. */
|
||||
async function sessionRemote<T>(scaffold: WebScaffold, method: string, request: unknown): Promise<RpcResult<T>> {
|
||||
const endpoint = `session/${method}`
|
||||
/** POST one generated Remote unary through the API Gateway carrier. */
|
||||
async function remote<T>(
|
||||
scaffold: WebScaffold,
|
||||
endpoint: string,
|
||||
args: Readonly<Record<string, unknown>>,
|
||||
): Promise<RpcResult<T>> {
|
||||
const response = await scaffold.hostFetch(`/api/${endpoint}`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
@@ -48,13 +35,18 @@ async function sessionRemote<T>(scaffold: WebScaffold, method: string, request:
|
||||
type: 'client-request',
|
||||
rpcId: `interrupt-e2e-${endpoint}-${randomUUID()}`,
|
||||
method: endpoint,
|
||||
payload: { args: { request } },
|
||||
payload: { args },
|
||||
}),
|
||||
})
|
||||
if (!response.ok) throw new Error(`${endpoint} failed over HTTP ${response.status}: ${await response.text()}`)
|
||||
return (await response.json() as { result: RpcResult<T> }).result
|
||||
}
|
||||
|
||||
/** POST one generated Session Remote unary through the API Gateway carrier. */
|
||||
function sessionRemote<T>(scaffold: WebScaffold, method: string, request: unknown): Promise<RpcResult<T>> {
|
||||
return remote<T>(scaffold, `session/${method}`, { request })
|
||||
}
|
||||
|
||||
/** Poll a synchronous condition (hook-safe; expect.poll is test-body only). */
|
||||
async function waitFor(predicate: () => boolean, what: string, timeoutMs = 30_000): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
@@ -78,7 +70,7 @@ function textCompletion(text: string): object {
|
||||
}
|
||||
}
|
||||
|
||||
describe.skipIf(MODE === 'record')('web e2e: subagent.interrupt over the real composition', () => {
|
||||
describe.skipIf(MODE === 'record')('web e2e: subagents/interruptByParent over the real composition', () => {
|
||||
let scaffold: WebScaffold
|
||||
let sidecarRoot: string
|
||||
let readyFile: string
|
||||
@@ -138,18 +130,21 @@ describe.skipIf(MODE === 'record')('web e2e: subagent.interrupt over the real co
|
||||
|
||||
it('parks a queued follow-up on interrupt and resumes it FIFO on a waking send', async () => {
|
||||
// Queue the follow-up while the turn is still open, then interrupt.
|
||||
const queued = await rpc<{ messageId: string }>(scaffold, 'subagent.prompt', {
|
||||
parentSessionId: parentId,
|
||||
childSessionId: childId,
|
||||
mode: 'continuable',
|
||||
content: [{ type: 'text', text: FOLLOWUP }],
|
||||
const queued = await remote<{ messageId: string }>(scaffold, 'subagents/prompt', {
|
||||
request: {
|
||||
requestId: randomUUID(),
|
||||
parentSessionId: parentId,
|
||||
childSessionId: childId,
|
||||
mode: 'continuable',
|
||||
content: [{ type: 'text', text: FOLLOWUP }],
|
||||
},
|
||||
})
|
||||
expect(queued).toMatchObject({ ok: true })
|
||||
|
||||
const settled = scaffold.whenTurnSettled()
|
||||
const interrupted = await rpc<{ accepted: true }>(scaffold, 'subagent.interrupt', {
|
||||
parentSessionId: parentId,
|
||||
const interrupted = await remote<{ accepted: true }>(scaffold, 'subagents/interruptByParent', {
|
||||
childSessionId: childId,
|
||||
parentSessionId: parentId,
|
||||
mode: 'continuable',
|
||||
})
|
||||
expect(interrupted).toMatchObject({ ok: true, value: { accepted: true } })
|
||||
@@ -169,11 +164,14 @@ describe.skipIf(MODE === 'record')('web e2e: subagent.interrupt over the real co
|
||||
|
||||
// Only an explicit waking send resumes the parked queue, FIFO, then the
|
||||
// child runs both turns to completion and settles.
|
||||
const waking = await rpc<{ messageId: string }>(scaffold, 'subagent.prompt', {
|
||||
parentSessionId: parentId,
|
||||
childSessionId: childId,
|
||||
mode: 'continuable',
|
||||
content: [{ type: 'text', text: WAKING }],
|
||||
const waking = await remote<{ messageId: string }>(scaffold, 'subagents/prompt', {
|
||||
request: {
|
||||
requestId: randomUUID(),
|
||||
parentSessionId: parentId,
|
||||
childSessionId: childId,
|
||||
mode: 'continuable',
|
||||
content: [{ type: 'text', text: WAKING }],
|
||||
},
|
||||
})
|
||||
expect(waking).toMatchObject({ ok: true })
|
||||
await expect.poll(() => scaffold.ctx.agents.get(childId), { timeout: 60_000 }).toBeUndefined()
|
||||
|
||||
@@ -6,12 +6,24 @@
|
||||
// selecting the ledger record renders the shared ui-attachment gallery from
|
||||
// the durable session-log reference, and the browser URL is the SAME object
|
||||
// URL Chat resolved — one sessions.attachment read per session attachment.
|
||||
import { fireEvent, screen, waitFor, within } from '@testing-library/react'
|
||||
import { act, fireEvent, screen, waitFor, within } from '@testing-library/react'
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { installAssembledBootEnv, mountAssembledApp } from './assembled-boot.ts'
|
||||
|
||||
installAssembledBootEnv()
|
||||
|
||||
/**
|
||||
* How long the mounted tree waits out the virtual ledger's scroll-idle timer.
|
||||
* jsdom fires no `scrollend`, so `@tanstack/react-virtual` falls back to a
|
||||
* debounce it re-arms on every scroll event (`isScrollingResetDelay`, 150ms by
|
||||
* default) and its unsubscribe removes only the listeners; a scenario that
|
||||
* ends inside that window leaves the timer to re-render the table after vitest
|
||||
* has torn this file's jsdom down, where React reads a `window` that is gone.
|
||||
* Armed later and with a longer delay than the debounce, this wait always
|
||||
* expires after it.
|
||||
*/
|
||||
const SCROLL_IDLE_DRAIN_MS = 400
|
||||
|
||||
/** Open the fixture history session and wait for the Chat gallery to load. */
|
||||
async function openFixtureSession(): Promise<void> {
|
||||
const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
|
||||
@@ -41,16 +53,24 @@ async function scrollRowIntoWindow(needle: string): Promise<HTMLElement> {
|
||||
}, { timeout: 10_000 })
|
||||
const pane = document.querySelector('[data-trajectory-scroll] table')?.parentElement
|
||||
if (!(pane instanceof HTMLElement)) throw new Error('trajectory scroll pane missing')
|
||||
for (let top = 0; top <= 40_000; top += 1_000) {
|
||||
const findRow = (): HTMLElement | undefined =>
|
||||
[...document.querySelectorAll<HTMLElement>('tr[data-trajectory-row-key]')]
|
||||
.find(row => row.textContent?.includes(needle))
|
||||
let mounted = false
|
||||
for (let top = 0; !mounted && top <= 40_000; top += 1_000) {
|
||||
pane.scrollTop = top
|
||||
fireEvent.scroll(pane)
|
||||
// Let the virtualizer publish the new window before probing.
|
||||
await new Promise(resolve => setTimeout(resolve, 25))
|
||||
const hit = [...document.querySelectorAll<HTMLElement>('tr[data-trajectory-row-key]')]
|
||||
.find(row => row.textContent?.includes(needle))
|
||||
if (hit !== undefined) return hit
|
||||
mounted = findRow() !== undefined
|
||||
}
|
||||
throw new Error(`trajectory row containing ${JSON.stringify(needle)} never mounted`)
|
||||
// Nothing scrolls the ledger after this, so draining the scroll-idle
|
||||
// debounce here leaves no timer armed for the rest of the scenario. The
|
||||
// drained reset re-renders the window, so the row is read afterwards.
|
||||
await act(async () => { await new Promise(resolve => setTimeout(resolve, SCROLL_IDLE_DRAIN_MS)) })
|
||||
const hit = findRow()
|
||||
if (hit === undefined) throw new Error(`trajectory row containing ${JSON.stringify(needle)} never mounted`)
|
||||
return hit
|
||||
}
|
||||
|
||||
it('renders durable record images in the Trajectory details panel from the shared cache', async () => {
|
||||
|
||||
Reference in New Issue
Block a user