Merge origin/master into worktree/fix-question-drafts-session-switch

This commit is contained in:
Yichen Jiang
2026-08-26 15:51:15 +08:00
165 changed files with 2709 additions and 1423 deletions
+53 -10
View File
@@ -9,7 +9,8 @@
//
// Zero model calls: no replay fixture mounts, so a stray stream fails loud.
import { fileURLToPath } from 'node:url'
import { mkdir, writeFile } from 'node:fs/promises'
import { mkdir, mkdtemp, realpath, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
@@ -22,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')
@@ -32,6 +35,26 @@ const MODE = webSnapshotMode()
const SEED_ID = 'agent-preset-selection-web-e2e'
/** A project skill only a preset that mounts `skill-filesystem` can discover. */
const SKILL_NAME = 'preset-catalog-demo'
/** The preset whose rows resolve and then refuse to start. */
const REFUSING_ID = 'zz-refusing'
/**
* Seed a preset discovery reports healthy and the mount refuses.
*
* Every row resolves — the module is right there beside the composition — so
* health has nothing to report and the chip offers the preset like any other.
* Only starting it finds out, which is the case the chip's banner exists for.
* @param root - the lane's writable preset root.
*/
async function seedRefusingPreset(root: string): Promise<void> {
const directory = join(root, REFUSING_ID)
await mkdir(directory, { recursive: true })
await writeFile(join(directory, 'refuses.mjs'),
'export const name = \'refuses\'\nexport function apply() { throw new Error(\'this row refuses to start\') }\n')
await writeFile(join(directory, 'agent.cordis.yml'), '- id: refuses\n name: ./refuses.mjs\n')
await writeFile(join(directory, 'preset.yml'),
'name: Refusing mode\ndescription: Resolves, then refuses to start.\n')
}
/**
* Seed one project skill under the connected workspace.
@@ -176,11 +199,16 @@ describe('web e2e: agent-preset selection', () => {
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
let presetRoot: string
beforeAll(async () => {
// The scaffold's default roster pin is exactly this scenario's shape: the
// plugin's shipped presets, default `standard`.
scaffold = await launchWebScaffold({})
// The shipped presets, plus one lane-owned preset that mounts and refuses:
// the chip's own failure path needs a preset the roster offers.
presetRoot = await realpath(await mkdtemp(join(tmpdir(), 'dsh-web-e2e-refusing-')))
await seedRefusingPreset(presetRoot)
scaffold = await launchWebScaffold({
agentPresets: { roots: [{ path: presetRoot, trust: 'user' }], default: 'standard' },
})
// A resumed session runs what it was created with; seeding one that
// records `minimal` is what makes the header label a claim about the
// session rather than an echo of the current default.
@@ -237,8 +265,23 @@ describe('web e2e: agent-preset selection', () => {
await expect.poll(() => livePreset(scaffold), { timeout: 15_000 }).toBe('minimal')
})
it('says why a switch was refused instead of letting the chip revert in silence', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-preset-refused'))
await page.getByRole('button', { name: 'Minimal mode' }).click()
await page.getByRole('menuitem', { name: /Refusing mode/ }).click()
// Health cleared every row, so nothing on the settings page says this
// preset is unusable — the banner is where the host's reason lands, and
// without it the chip just snaps back to the preset it already ran.
const banner = page.getByRole('alert').filter({ hasText: 'Refusing mode' })
await banner.waitFor({ timeout: 15_000 })
expect(await banner.textContent()).toContain('this row refuses to start')
await expect.poll(() => livePreset(scaffold), { timeout: 15_000 }).toBe('minimal')
await page.getByRole('button', { name: 'Minimal mode' }).waitFor({ timeout: 10_000 })
}, 60_000)
it('re-reads the slash catalog through the composition the switch installed', async () => {
// Continues the previous case: the chip has already applied `minimal` to
// Continues 'applies the staged pick': the chip has already applied `minimal` to
// the blank session, and this one reads the menu that switch left behind.
onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-preset-slash-catalog'))
const composer = page.locator('[data-composer-input][contenteditable="true"]').last()
@@ -246,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)
@@ -256,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
@@ -266,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 () => {
@@ -5,3 +5,4 @@
- menuitem "PTC mode All Standard mode capabilities, with tools exposed through the Code Mode SDK so the model can combine multi-step operations in one TypeScript program."
- menuitem "Minimal mode Two-tool coding agent with persistent bash and str_replace_editor."
- menuitem "Creator mode Built for creating custom agent presets, with all Standard mode capabilities plus runtime inspection, plugin experiments, and preset-authoring guidance."
- menuitem "Refusing mode Resolves, then refuses to start."
+3 -3
View File
@@ -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 {
+6 -6
View File
@@ -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 } }
+32 -34
View File
@@ -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 () => {