feat(web): show a message only after the Host accepts it

The composer keeps a sent message, its references, and its attachments
read-only in a sending phase until session/prompt resolves, so an
admission listener can hold or refuse a prompt before it appears as sent.
The Session publishes the local echo and leaves the blank layout only
after acceptance; a refusal or failure keeps the draft, and
session/prompt-rejected with presented: true adds no toast.

This carries the client half of deepseek-harness/deepseek-harness#6178.
This commit is contained in:
Yichen Jiang committed 2026-10-17 17:09:44 +08:00
1 parent f34eaf7601
commit 2eb0df5628
44 files changed
+756 -634

No files matched your search

@@ -9,11 +9,11 @@
en: 5071232d9743096c
zh: b553a626538b3466
/agent-note-delivering-messages-from-outside-an-agent-through-the-agent-controller/decision:
en: e48e9b1004786748
zh: 893c041169618c03
en: 203f783e7e2903eb
zh: 0a73055c1e52ac1e
/agent-note-delivering-messages-from-outside-an-agent-through-the-agent-controller/alternatives-considered:
en: acc465cfe02b624c
zh: db1d06574f647130
/agent-note-delivering-messages-from-outside-an-agent-through-the-agent-controller/consequences:
en: 9d8486586d044709
zh: 24c7249421fd2541
en: 9420fde397cde65f
zh: a76ecf4a926e930a
@@ -29,7 +29,7 @@ The Web Session Remote adopts the controller first. `session/prompt` resolves or
Request deduplication stays in the Session Remote. It reads the Session history synchronously, and the [synchronous-read decision](2026-09-09-deprecate-synchronous-session-event-reads.md) prohibits copying such a read into new production code; the controller can take over deduplication once a projection records accepted request ids.
The Web client keeps its existing echo: Enter clears the composer and shows the message at once, and a refusal reaches the client as an ordinary prompt failure, which retires the echo and restores the draft unless new text was typed meanwhile.
The Web client shows a message only after the Host accepts it. The composer keeps the sent draft, references, and attachments read-only in a `sending` phase, with Send showing progress, until `session/prompt` resolves. Then it clears, and the Session publishes the message's echo and leaves the blank New Session layout. A refusal or failure returns the message to editing unchanged; the composer no longer clears on Enter and restores failed drafts.
## Alternatives considered
@@ -45,7 +45,7 @@ The liveshare proposal in PR #3854 also introduced an admission hook, for caller
## Consequences
- A listener that holds one message keeps later messages to the same Agent waiting; other Agents proceed.
- While a listener holds a Web prompt, the client already shows its echo; a refusal then retires the echo and returns the draft.
- A Web composer sends one message at a time. Editing waits for the Host's answer, which normally takes one round-trip and lasts as long as a listener holds the message.
- `prepare` stores attachments before the decision, so a refused prompt can leave a stored attachment unreferenced.
- Image prompts deliver inside the Session Remote's image admission chain, so a model switch waits while a listener holds an image prompt.
- Until every outside sender adopts `deliver()`, the stage is not a complete enforcement point.
@@ -29,7 +29,7 @@ Web Session Remote 首先接入控制器。`session/prompt` 自己解析或恢
请求去重留在 Session Remote。它同步读取 Session 历史,而[同步读取决策](2026-09-09-deprecate-synchronous-session-event-reads.zh.md)禁止把这类读取复制到新的生产代码;等有投影记录已接收的请求 id 后,控制器才能接管去重。
Web 客户端保留现有的回显方式:Enter 立即清空 composer 并显示消息;拒绝在客户端表现为一次普通的提示词失败,回显撤下,草稿回到 composer,除非期间已经输入了新内容。
Web 客户端只在 Host 接收消息后才显示它。composer 在 `sending` 阶段把已发送的草稿、引用和附件保持为只读,Send 显示发送中,直到 `session/prompt` 返回。之后 composer 清空,Session 公开该消息的回显并离开空白的新会话布局。被拒绝或失败时消息原样回到编辑状态;composer 不再在 Enter 时清空并回填失败草稿。
## 备选方案
@@ -45,7 +45,7 @@ PR #3854 中的 liveshare 方案也引入过一个接纳钩子,用于调用方
## 影响
- 监听器暂留一条消息时,同一 Agent 之后的消息都要等待;其他 Agent 不受影响。
- 监听器暂留一条 Web 提示词期间,客户端已经显示其回显;随后被拒绝时,回显撤下,草稿回到 composer。
- Web composer 同一时刻只发送一条消息。编辑要等 Host 答复,通常只需一次往返,监听器暂留消息时则一直等待。
- `prepare` 在决定之前存储附件,因此被拒绝的提示词可能留下一个无人引用的已存附件。
- 图片提示词在 Session Remote 的图片接纳链内投递,因此监听器暂留图片提示词期间,切换模型要等待。
- 在所有外部发送方都改用 `deliver()` 之前,这个阶段还不是完整的强制执行点。
+8 -10
View File
@@ -6,7 +6,7 @@ import { access, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { Browser, Page, Route } from 'playwright'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import type { StreamChunk } from '@deepseek-ai/dsh-llm'
@@ -679,14 +679,11 @@ describe('web e2e: long Chat scroll contract', () => {
yield chunk
}
})
const resolved = await world.scaffold.ctx.sessionController.resolveAgent(SessionId(`visible-fold-${scenario.name}`))
if ('error' in resolved) throw resolved.error
// Maintenance keeps the accepted message in the inbox, so its echo folds the earlier work before the Turn starts.
const promptGate = Promise.withResolvers<undefined>()
const promptBlocked = Promise.withResolvers<undefined>()
const holdPrompt = async (route: Route): Promise<void> => {
promptBlocked.resolve(undefined)
await promptGate.promise
await route.continue()
}
await page.route('**/api/session/prompt', holdPrompt)
const maintenance = resolved.agent.runMaintenance(() => promptGate.promise)
const trace = page.evaluate(({ marker, race }) => new Promise<FoldMotionSample[]>((resolveTrace) => {
const host = document.querySelector<HTMLElement>('[data-conversation-scroll]')!
const column = host.querySelector<HTMLElement>('[data-chat-flow]:has(> [data-slot="conversation.chat.flow"])')!
@@ -779,7 +776,7 @@ describe('web e2e: long Chat scroll contract', () => {
await page.clock.resume()
}
} else await page.getByRole('button', { name: 'Send message', exact: true }).click()
await promptBlocked.promise
await expect.poll(() => page.locator('[data-composer-input]').last().textContent()).toBe('')
await page.getByText(input, { exact: true }).waitFor()
await expect.poll(() => page.locator('[data-chat-motion="collapse"]').count()).toBe(0)
if (scenario.name !== 'cancel-all-during-fold') await expectBottom(page)
@@ -799,6 +796,7 @@ describe('web e2e: long Chat scroll contract', () => {
})
await page.locator('[data-conversation-scroll]').evaluate(host => host.dispatchEvent(new Event('dsh-test-fold-growth')))
promptGate.resolve(undefined)
await maintenance
release()
await finishBlocked.promise
await page.getByText('VISIBLE_FOLD_DONE', { exact: false }).waitFor()
@@ -806,8 +804,8 @@ describe('web e2e: long Chat scroll contract', () => {
await nextPaint(page)
} finally {
promptGate.resolve(undefined)
await maintenance
release()
await page.unroute('**/api/session/prompt', holdPrompt)
await page.locator('[data-conversation-scroll]').evaluate(host => host.dispatchEvent(new Event('dsh-test-fold-stop')))
samples = await trace
finishGate.resolve(undefined)
+6 -17
View File
@@ -3,7 +3,7 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { basename, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { Browser, Page, Route } from 'playwright'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import { deriveReplayScript } from '@deepseek-ai/dsh-llm-replay'
@@ -137,34 +137,23 @@ describe.skipIf(MODE === 'record')('web e2e: recorded collapse timing', () => {
const input = page.locator('[data-composer-input]').last()
await writeComposerDraft(page, input, prompt)
await expectProcess(1, true)
const resolved = await scaffold.ctx.sessionController.resolveAgent(SessionId(SEED_ID))
if ('error' in resolved) throw resolved.error
// Maintenance keeps the accepted message in the inbox, so its echo stays before the Turn starts.
const release = Promise.withResolvers<undefined>()
const promptBlocked = Promise.withResolvers<undefined>()
const holdPrompt = async (route: Route): Promise<void> => {
promptBlocked.resolve(undefined)
await release.promise
await route.continue()
}
await page.route('**/api/session/prompt', holdPrompt)
const maintenance = resolved.agent.runMaintenance(() => release.promise)
try {
await page.getByRole('button', { name: 'Send message', exact: true }).click()
const blockedTimeout = setTimeout(() => {
promptBlocked.reject(new Error('session prompt did not reach the route barrier'))
}, UI_WAIT.timeout)
try {
await promptBlocked.promise
} finally {
clearTimeout(blockedTimeout)
}
await expect.poll(() => page.locator('[data-submission-echo]').count(), UI_WAIT).toBe(1)
await expectProcess(1, false)
expect(await page.locator('[data-turn-process="2"]').count()).toBe(0)
await capture('input-pending.expected.md')
const settled = scaffold.whenTurnSettled()
release.resolve(undefined)
await maintenance
expect(await settled).toBe(SEED_ID)
} finally {
release.resolve(undefined)
await page.unrouteAll({ behavior: 'wait' })
}
await expect.poll(() => page.getByText('DONE', { exact: true }).count(), UI_WAIT).toBe(2)
await expect.poll(() => page.locator('[data-streaming="true"]').count(), UI_WAIT).toBe(0)
+5 -6
View File
@@ -279,17 +279,16 @@ describe('web e2e: generic file upload through the real assembly', () => {
await expect.poll(() => send.isEnabled(), { timeout: 15_000 }).toBe(true)
const settled = scaffold.whenTurnSettled()
await send.click()
const restoreDeadline = Date.now() + 10_000
let retainedCards = 0
while (retainedCards === 0
&& !sessionEvents.some(event => event.type === 'turn/start')
&& Date.now() < restoreDeadline) {
// The draft cards stay until the Host accepts the prompt, then leave with the cleared draft.
const acceptDeadline = Date.now() + 15_000
let retainedCards = await rail.locator(':scope > *').count()
while (retainedCards !== 0 && Date.now() < acceptDeadline) {
await page.waitForTimeout(100)
retainedCards = await rail.locator(':scope > *').count()
}
if (retainedCards !== 0) {
const feedback = await page.locator('[role="alert"], [role="status"]').allTextContents()
throw new Error(`submission retained ${String(retainedCards)} draft cards; feedback=${JSON.stringify(feedback)}; events=${sessionEvents.map(event => event.type).join(',')}`)
throw new Error(`the Host did not accept the submission; ${String(retainedCards)} draft cards remain; feedback=${JSON.stringify(feedback)}; events=${sessionEvents.map(event => event.type).join(',')}`)
}
const sessionId = await settled
if (MODE === 'record') await recordFixture(scaffold, sessionId, FIXTURE)
+20 -14
View File
@@ -10,7 +10,7 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { Browser, Page, Route } from 'playwright'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import type { StreamChunk } from '@deepseek-ai/dsh-llm'
@@ -679,31 +679,37 @@ async function run(world: World, scenario: Case): Promise<RunResult> {
const disposeFailure = scenario.origin === 'error'
? scaffold.ctx.on('system-prompt/assemble', async () => { throw new Error('FOLDMX_TURN_FAILED') })
: () => undefined
// A subagent child's composer sends through the subagent continuation route.
const promptRoute = scenario.surface === 'side' ? '**/api/subagents/prompt' : '**/api/session/prompt'
const promptGate = Promise.withResolvers<undefined>()
const promptHeld = Promise.withResolvers<undefined>()
const holdPrompt = async (route: Route): Promise<void> => {
promptHeld.resolve(undefined)
await promptGate.promise
await route.continue()
// The accepted message waits before its model call, so the fold settles before the Turn streams.
const turnGate = Promise.withResolvers<undefined>()
let held: Promise<unknown> = Promise.resolve()
let disposeHold = (): void => {}
if (scenario.surface === 'side') {
// A subagent child's composer sends through the continuation route, which resumes the cold child.
disposeHold = scaffold.ctx.on('agent/pre-step', async ({ agent }, next) => {
if (agent.id === target) await turnGate.promise
return await next()
})
} else {
const agent = scaffold.ctx.agents.get(target)
if (agent === undefined) throw new Error(`${scenario.id}: the target Agent is not live`)
// Maintenance keeps the accepted message in the inbox without starting its Turn.
held = agent.runMaintenance(() => turnGate.promise)
}
await page.route(promptRoute, holdPrompt)
const ended = turnEnded(scaffold, target)
try {
await expandSeededSteps(page, root, scenario)
await placeReader(page, root, scenario.reader)
await begin()
await send(world, NEXT_INPUT)
await promptHeld.promise
await page.locator(`${root} [data-submission-echo]`).waitFor()
afterFold = await folded()
promptGate.resolve(undefined)
turnGate.resolve(undefined)
await held
await ended
} finally {
promptGate.resolve(undefined)
turnGate.resolve(undefined)
disposeHold()
disposeFailure()
await page.unroute(promptRoute, holdPrompt)
}
break
}
+4 -98
View File
@@ -1,13 +1,10 @@
/** Keep idle submissions in Chat across independently delivered Inbox and transcript frames. */
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import { chromium, type Page } from 'playwright'
import { expect, it, onTestFinished } from 'vitest'
import type { InboxState } from '@deepseek-ai/dsh-agent/types'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import { deriveReplayScript, parseSessionLog } from '@deepseek-ai/dsh-llm-replay'
import {
parseRemoteStreamClientMessage, parseRemoteStreamServerMessage,
} from '@deepseek-ai/dsh-api-gateway/stream-protocol'
@@ -71,7 +68,6 @@ class HistoryDeliveryGate {
turnStartDelivery(): Delivery | undefined { return this.buffered.find(item => item.event?.type === 'turn/start') }
turnEndDelivery(): Delivery | undefined { return this.buffered.find(item => item.event?.type === 'turn/end') }
releaseThrough(target: Delivery): void {
let reached = false
@@ -123,8 +119,6 @@ it.skipIf(webSnapshotMode() === 'record').each(['inbox-first', 'transcript-first
'keeps an idle submission in Chat under CPU throttling (%s)', async (order) => {
const scaffold = await launchWebScaffold({ replayFixture: FIXTURE, compareReplaySession: false })
onTestFinished(() => scaffold.close())
const releasePrompt = Promise.withResolvers<undefined>()
const promptBlocked = Promise.withResolvers<undefined>()
const gate = new HistoryDeliveryGate()
try {
await seedSession(scaffold, await readFile(FIXTURE, 'utf8'), SESSION_ID)
@@ -140,21 +134,15 @@ it.skipIf(webSnapshotMode() === 'record').each(['inbox-first', 'transcript-first
await page.getByRole('button', { name: 'Send message', exact: true }).waitFor()
const cdp = await page.context().newCDPSession(page)
await cdp.send('Emulation.setCPUThrottlingRate', { rate: 6 })
await page.route('**/api/session/prompt', async (route) => {
promptBlocked.resolve(undefined)
await releasePrompt.promise
await route.continue()
}, { times: 1 })
const trace: Array<{ phase: string } & Awaited<ReturnType<typeof placement>>> = []
gate.holding = true
const settled = whenTurnSettled(scaffold)
const input = page.locator('[data-composer-input]').first()
await input.fill(TEXT)
await input.press('Enter')
await promptBlocked.promise
// The prompt receipt is not a stream frame: the accepted echo shows while the gate holds every Host frame.
await poll(() => placement(page)).toMatchObject({ echo: 1, dock: 0, durable: 0 })
trace.push({ phase: 'optimistic', ...await placement(page) })
const settled = whenTurnSettled(scaffold)
releasePrompt.resolve(undefined)
trace.push({ phase: 'accepted', ...await placement(page) })
if (order === 'transcript-first') {
await poll(() => gate.turnStartDelivery()).toBeDefined()
@@ -198,88 +186,6 @@ it.skipIf(webSnapshotMode() === 'record').each(['inbox-first', 'transcript-first
await cdp.detach()
expect(tripwire.pageErrors).toEqual([])
} finally {
releasePrompt.resolve(undefined)
gate.releaseAll()
}
},
)
it.skipIf(webSnapshotMode() === 'record').each(['ABC', 'ACB', 'BAC', 'BCA', 'CAB', 'CBA'])(
'hands off three rapid submissions through all Turns in Host order %s', async (hostOrder) => {
const root = await mkdtemp(join(tmpdir(), 'dsh-input-handoff-'))
onTestFinished(() => rm(root, { recursive: true, force: true }))
const releases = new Map(['A', 'B', 'C'].map(id => [id, Promise.withResolvers<undefined>()]))
const blocked = new Set<string>()
const gate = new HistoryDeliveryGate()
try {
const fixture = await readFile(FIXTURE, 'utf8')
const recorded = deriveReplayScript(parseSessionLog(fixture))
expect(recorded).toHaveLength(1)
const override = join(root, 'replay.override.json')
await writeFile(override, JSON.stringify([recorded[0], recorded[0], recorded[0]]))
const scaffold = await launchWebScaffold({ replayFixture: FIXTURE, replayOverride: override, compareReplaySession: false })
onTestFinished(() => scaffold.close())
await seedSession(scaffold, fixture, SESSION_ID)
const browser = await chromium.launch()
onTestFinished(() => browser.close())
const page = await newEnglishPage(browser)
const tripwire = watchConsole(page)
await gate.install(page)
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await page.locator('[role="treeitem"]').first().click()
await page.locator('[role="treeitem"]').nth(1).click()
await page.locator('[data-chat-flow-kind="user"]').first().waitFor()
await page.getByRole('button', { name: 'Send message', exact: true }).waitFor()
const text = (id: string) => `RAPID_INPUT_${id}`
await page.route('**/api/session/prompt', async (route) => {
const request = route.request().postData() ?? ''
const id = [...releases.keys()].find(id => request.includes(text(id)))
if (id === undefined) throw new Error('unexpected prompt in rapid input fixture')
blocked.add(id)
await releases.get(id)!.promise
await route.continue()
})
gate.holding = true
const input = page.locator('[data-composer-input]').first()
for (const id of releases.keys()) {
await input.fill(text(id))
await input.press('Enter')
await poll(() => blocked.has(id)).toBe(true)
await poll(() => placement(page, text(id))).toMatchObject({ echo: 1, dock: 0, durable: 0 })
}
const entered: string[] = []
for (const id of hostOrder) {
const settled = whenTurnSettled(scaffold)
releases.get(id)!.resolve(undefined)
await poll(() => gate.turnStartDelivery()).toBeDefined()
gate.releaseThrough(gate.turnStartDelivery()!)
await poll(() => gate.admissionDelivery(text(id))).toBeDefined()
gate.releaseThrough(gate.admissionDelivery(text(id))!)
entered.push(id)
for (const candidate of releases.keys()) {
const admitted = entered.includes(candidate)
await poll(() => placement(page, text(candidate))).toMatchObject({
echo: admitted ? 0 : 1, dock: 0, durable: admitted ? 1 : 0,
})
}
await poll(() => gate.inboxDelivery(true, text(id))).toBeDefined()
gate.releaseThrough(gate.inboxDelivery(true, text(id))!)
await poll(() => placement(page, text(id))).toMatchObject({ echo: 0, dock: 0, durable: 1 })
await poll(() => gate.inboxDelivery(false, text(id))).toBeDefined()
gate.releaseThrough(gate.inboxDelivery(false, text(id))!)
await poll(() => gate.turnEndDelivery()).toBeDefined()
gate.releaseThrough(gate.turnEndDelivery()!)
await settled
}
gate.releaseAll()
const actual = await page.locator('[data-chat-flow-kind="user"]').allTextContents()
expect(actual.filter(value => value.includes('RAPID_INPUT_')).map(value =>
[...releases.keys()].find(id => value.includes(text(id))))).toEqual(hostOrder.split(''))
expect(await page.locator('[data-submission-echo]').count()).toBe(0)
expect(await page.locator('[data-queue-dock]').count()).toBe(0)
expect(tripwire.pageErrors).toEqual([])
} finally {
for (const release of releases.values()) release.resolve(undefined)
gate.releaseAll()
}
},
+108
View File
@@ -0,0 +1,108 @@
// Keyless browser coverage for Agent admission through the shipped Web
// composition and the real wire: a Host `agent-controller/admission` listener holds a
// sent message, so the composer stays read-only behind the sending control
// until the listener admits it, and a refusal leaves the message in the
// composer, announced by a toast unless the refusing plugin presents it.
import { fileURLToPath } from 'node:url'
import type { Browser } from 'playwright'
import { chromium } from 'playwright'
import { afterEach, describe, expect, it, onTestFailed } from 'vitest'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import { launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold } from './scaffold.ts'
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/live-interactions/session.v3.jsonl', import.meta.url))
const MODE = webSnapshotMode()
const HELD = 'Message held by admission'
const FOLLOW_UP = 'Typed once the message was accepted'
const REFUSED = 'Message refused by admission'
const TOAST_REFUSAL = 'The workspace is not ready yet'
const PRESENTED_REFUSAL = 'The refusing plugin explains this itself'
describe('web e2e: prompt admission', () => {
let scaffold: WebScaffold | undefined
let browser: Browser | undefined
afterEach(async () => {
const failures: unknown[] = []
await browser?.close().catch((error: unknown) => failures.push(error))
browser = undefined
const closing = scaffold
scaffold = undefined
await closing?.close().catch((error: unknown) => failures.push(error))
if (failures.length === 1) throw failures[0]
if (failures.length > 1) throw new AggregateError(failures, 'prompt-admission teardown failed')
})
it.skipIf(MODE === 'record')('holds a message in the composer until admitted and keeps a refused one there', async () => {
const sessionEvents: SessionEvent[] = []
scaffold = await launchWebScaffold({ replayFixture: FIXTURE, compareReplaySession: false })
const host = scaffold
host.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) })
const userMessages = (): number => sessionEvents
.filter(event => event.type === 'user/message' && event.data.source.kind === 'user').length
browser = await chromium.launch()
const page = await newEnglishPage(browser)
const tripwire = watchConsole(page)
await page.goto(host.authenticatedUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
await connectFreshWorkspace(page, host.workspaceCwd)
onTestFailed(() => saveFailureShot(page, 'web-e2e-prompt-admission'))
const input = page.locator('[data-composer-input]').first()
const gate = Promise.withResolvers<undefined>()
const held = Promise.withResolvers<undefined>()
const disposeHold = host.ctx.on('agent-controller/admission', async (_admission, next) => {
held.resolve(undefined)
await gate.promise
return await next()
})
await input.fill(HELD)
await input.press('Enter')
await held.promise
const sending = page.getByRole('button', { name: 'Sending message', exact: true })
await expect.poll(() => sending.getAttribute('aria-busy')).toBe('true')
expect(await input.getAttribute('contenteditable')).toBe('false')
expect(await input.textContent()).toBe(HELD)
expect(await page.locator('[data-submission-echo]').count()).toBe(0)
expect(userMessages()).toBe(0)
const settled = host.whenTurnSettled()
gate.resolve(undefined)
await settled
await expect.poll(() => input.textContent()).toBe('')
expect(await input.getAttribute('contenteditable')).toBe('true')
// The keyboard returns to the composer, so the next message can be typed at once.
await page.keyboard.type(FOLLOW_UP)
await expect.poll(() => input.textContent()).toBe(FOLLOW_UP)
await page.locator('[data-chat-flow-kind="user"]', { hasText: HELD }).waitFor()
expect(userMessages()).toBe(1)
disposeHold()
let presented = false
host.ctx.on('agent-controller/admission', () => Promise.resolve({
kind: 'reject' as const, reason: 'e2e/workspace-not-ready', message: presented ? PRESENTED_REFUSAL : TOAST_REFUSAL, presented,
}))
await input.fill(REFUSED)
await input.press('Enter')
await page.getByRole('alert').filter({ hasText: TOAST_REFUSAL }).waitFor()
await expect.poll(() => input.getAttribute('contenteditable')).toBe('true')
expect(await input.textContent()).toBe(REFUSED)
presented = true
const response = page.waitForResponse('**/api/session/prompt')
await input.press('Enter')
// The rejection reaches the wire with its reason and the plugin's own presentation.
const envelope = await (await response).json() as { readonly result: unknown }
expect(envelope.result).toMatchObject({
ok: false,
error: { code: 'session/prompt-rejected', details: { reason: 'e2e/workspace-not-ready', presented: true } },
})
await expect.poll(() => input.getAttribute('contenteditable')).toBe('true')
expect(await input.textContent()).toBe(REFUSED)
expect(await page.getByRole('alert').filter({ hasText: PRESENTED_REFUSAL }).count()).toBe(0)
expect(userMessages()).toBe(1)
expect(tripwire.pageErrors).toEqual([])
})
})
+9 -13
View File
@@ -124,21 +124,15 @@ describe('web e2e: queue row actions', () => {
await input.fill(REMOVE)
await input.press('Enter')
await received.promise
const pending = page.locator('[data-queue-dock] [data-submission-echo]')
await pending.getByRole('status').waitFor()
expect(await pending.getByRole('status').textContent()).toBe('Sending…')
expect(await pending.getByRole('button').count()).toBe(3)
expect(await pending.getByRole('button').evaluateAll(buttons =>
buttons.every(button => (button as HTMLButtonElement).disabled))).toBe(true)
expect(await input.textContent()).toBe('')
expect(await input.getAttribute('contenteditable')).toBe('true')
// The composer keeps the message, read-only, until the Host accepts it; the queue shows nothing yet.
const sendingButton = page.getByRole('button', { name: 'Sending message', exact: true })
await sendingButton.waitFor()
expect(await sendingButton.getAttribute('aria-busy')).toBe('true')
expect(await input.textContent()).toBe(REMOVE)
expect(await input.getAttribute('contenteditable')).toBe('false')
expect(await page.locator('[data-queue-dock] [data-submission-echo]').count()).toBe(0)
const sending = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(SENDING_EXPECTED, sending, MODE)
await page.setViewportSize({ width: 390, height: 1000 })
await page.locator('[data-sidebar-collapsed="true"]').waitFor()
await expect.poll(() => pending.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true)
await page.setViewportSize({ width: 1680, height: 1000 })
await page.locator('[data-sidebar-collapsed="true"]').waitFor({ state: 'detached' })
} finally {
release.resolve(undefined)
}
@@ -148,6 +142,8 @@ describe('web e2e: queue row actions', () => {
expect(await page.locator('[data-queue-dock]').getByRole('status').count()).toBe(0)
await input.fill(EDIT)
await input.press('Enter')
// The Host queue row can arrive before the prompt reply that clears the composer.
await expect.poll(() => input.textContent(), { timeout: 10_000 }).toBe('')
const queueHeader = page.getByRole('button', { name: '2 queued messages' })
await expect.poll(() => queueHeader.getAttribute('aria-expanded'), { timeout: 10_000 })
.toBe('false')
+8 -5
View File
@@ -123,17 +123,20 @@ describe('web e2e: queued image submission', () => {
try {
await input.fill(QUEUED_TEXT)
await input.press('Enter')
await dockThumb.waitFor({ timeout: 15_000 })
await expect.poll(() => dockThumb.getAttribute('src'), { timeout: 15_000 }).toMatch(/^blob:/)
expect(await page.locator('[data-queue-dock] [data-submission-echo]').count()).toBe(1)
// The composer keeps the text and image, read-only, until the Host accepts the prompt.
await expect.poll(() => input.getAttribute('contenteditable'), { timeout: 15_000 }).toBe('false')
expect(await page.getByRole('img', { name: 'queued.png' }).count()).toBe(1)
expect(await page.locator('[data-queue-dock] [data-submission-echo]').count()).toBe(0)
releasePrompt.resolve(undefined)
await expect.poll(() => imageRequested, { timeout: 15_000 }).toBe(true)
await page.getByText(QUEUED_TEXT, { exact: true }).waitFor()
// The Host queue row can arrive before the prompt reply that clears the composer.
await expect.poll(() => input.textContent(), { timeout: 15_000 }).toBe('')
await page.locator('[data-queue-dock]').getByText(QUEUED_TEXT, { exact: true }).waitFor()
await page.getByRole('button', { name: 'Remove queued message', disabled: false }).waitFor({ timeout: 15_000 })
expect(await page.locator('[data-queue-dock] [data-submission-echo]').count()).toBe(0)
expect(await dockThumb.count()).toBe(0)
releaseImage.resolve(undefined)
// Admission replaces the optimistic image; wait for the durable row's own thumbnail.
// The Host queue row replaces the accepted echo; wait for the durable row's own thumbnail.
const durableThumb = page.locator('[data-queue-dock] li:not([data-submission-echo]) img[alt="Queued message image"]')
await durableThumb.waitFor({ timeout: 15_000 })
await expect.poll(() => durableThumb.getAttribute('src'), { timeout: 15_000 }).toMatch(/^blob:/)
+5 -1
View File
@@ -130,7 +130,7 @@ describe('web e2e: fresh round trip through the real assembly', () => {
key: 'Enter', code: 'Enter', bubbles: true, cancelable: true,
}))
// sendSession registered its paint yield first. This frame observes the
// committed echo while admission remains queued on the following task.
// composer holding the message while the Host has not accepted it yet.
await new Promise<void>((resolve) => { requestAnimationFrame(() => { resolve() }) })
const echo = document.querySelector<HTMLElement>('[data-submission-echo]')
return [
@@ -144,6 +144,10 @@ describe('web e2e: fresh round trip through the real assembly', () => {
expect(await response.json()).toMatchObject({ result: { ok: true, value: { accepted: true } } })
accepted = true
await compareOrRefreshGolden(ECHO_EXPECTED, echoSnapshot, MODE)
// Acceptance publishes the echo and clears the composer; maintenance keeps the Turn from starting.
await page.locator('[data-submission-echo]', { hasText: PROMPT }).waitFor()
await expect.poll(() => input.textContent()).toBe('')
expect(await input.getAttribute('contenteditable')).toBe('true')
const row = page.getByRole('tree', { name: 'Sessions', exact: true })
.locator('[role="treeitem"][aria-selected="true"]')
await expect.poll(() => row.getAttribute('draggable'), { timeout: 5_000 }).toBe('true')
+26 -25
View File
@@ -1,18 +1,17 @@
// @vitest-environment jsdom
// Local submission echo over the BUILT client graph (keyless RemoteMock
// transport): a text-plus-image send paints its echo bubble synchronously on
// the submit keystroke — before serialization, transport, or the fixture's
// durable admission — with the composer already cleared and editable, and the
// durable user/message replaces the echo without a duplicate. The fixture host
// echoes the prompt requestId as the durable source's rpcId, so the retirement
// path here is the production correlation, not a test hook.
// Submission echo over the BUILT client graph (keyless RemoteMock transport):
// a text-plus-image send stays in the read-only composer, with its image rail
// and a busy send control, until the fixture host accepts the prompt; only then
// does the composer clear, and the conversation shows the message once. The
// fixture host echoes the prompt requestId as the durable source's rpcId, so
// the reconciliation here is the production correlation, not a test hook.
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { expect, it } from 'vitest'
import { createAssembledSessionComposer, installAssembledBootEnv, mountAssembledApp } from './assembled-boot.ts'
installAssembledBootEnv()
it('paints the submission echo on the send keystroke and swaps it for the durable node', async () => {
it('keeps a send in the composer until the Host accepts it and then shows the message once', async () => {
const remote = mountAssembledApp()
const composer = await createAssembledSessionComposer(remote)
const image = new File([new Uint8Array([137, 80, 78, 71])], 'echoed.png', { type: 'image/png' })
@@ -33,30 +32,32 @@ it('paints the submission echo on the send keystroke and swaps it for the durabl
await waitFor(() => { expect(composer.textContent).toBe('回显这条消息') })
fireEvent.keyDown(composer, { key: 'Enter' })
// Synchronously after the keystroke: the echo bubble is in the flow with
// the draft text and the object-URL preview, while the prompt has not even
// been serialized yet (it starts after a paint yield). The composer is
// already cleared, editable, and free of the rail.
const echo = document.querySelector<HTMLElement>('[data-submission-echo]')
if (echo === null) throw new Error('submission echo missing on the send keystroke')
expect(echo.textContent).toContain('回显这条消息')
expect(echo.querySelector('img')?.getAttribute('src')?.split(':')[0]).toBe('blob')
expect(composer.textContent).toBe('')
expect(composer.getAttribute('contenteditable')).toBe('true')
expect(document.querySelector('[role="group"][aria-label="Pending attachments"]')).toBeNull()
// Synchronously after the keystroke, before the prompt is even serialized
// (it starts after a paint yield): the draft and its image stay in the
// read-only composer behind a busy send control, and the conversation shows
// nothing yet.
expect(document.querySelector('[data-submission-echo]')).toBeNull()
expect(composer.textContent).toBe('回显这条消息')
expect(composer.getAttribute('contenteditable')).toBe('false')
expect(document.querySelector('[role="group"][aria-label="Pending attachments"] img')).not.toBeNull()
expect(screen.getByRole('button', { name: 'Sending message' }).getAttribute('aria-busy')).toBe('true')
// The fixture's durable user/message (source.rpcId echoes the prompt
// requestId) replaces the echo: one bubble, no marker left, and the image
// now renders from the durable gallery.
// The Host accepts the prompt: the composer clears and becomes editable
// again, its rail is gone, and the durable user/message (source.rpcId
// echoes the prompt requestId) is the one bubble, its image rendering from
// the durable gallery.
await waitFor(() => {
expect(composer.textContent).toBe('')
expect(composer.getAttribute('contenteditable')).toBe('true')
expect(document.querySelector('[role="group"][aria-label="Pending attachments"]')).toBeNull()
}, { timeout: 10_000 })
await waitFor(() => {
if (document.querySelector('[data-submission-echo]') !== null) {
throw new Error('submission echo still present after the durable node arrived')
}
}, { timeout: 10_000 })
expect(screen.getAllByText('回显这条消息')).toHaveLength(1)
await waitFor(() => {
if (document.querySelector('[data-align="end"] img') === null) {
throw new Error('durable user gallery missing')
}
}, { timeout: 10_000 })
expect(screen.getAllByText('回显这条消息')).toHaveLength(1)
})
+1
View File
@@ -164,6 +164,7 @@
"tests/markdown-inline-code-links.e2e.ts",
"tests/clickable-links-gallery.e2e.ts",
"tests/queue-actions.e2e.ts",
"tests/prompt-admission.e2e.ts",
"tests/idle-submission-handoff.e2e.ts",
"tests/cold-blank-session.e2e.ts",
"tests/blank-writer-reuse.e2e.ts",
@@ -15,8 +15,8 @@
en: 1fdf707322fb6996
zh: 0204f7e9d4aa3cce
/session-controller/use-this-package:
en: f4fe58d2ab4b90a2
zh: c64b4b983f42c0e0
en: ba76393ac7f226a9
zh: 12925c6d961ddaba
/session-controller/client-references:
en: 41bdda1ac32df558
zh: aa5d0e325924066b
+1 -1
View File
@@ -53,7 +53,7 @@ Accepted prompts and observed running keep their Client display conversion acros
The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, `append`, and `settle-assistant` changes, and repairs reconnect or sequence gaps through a tail page. Backwards paging has two verbs: `loadOlder()` pulls one Turn-aligned page, and `loadThrough(seq)` — the turn-jump loader — loops Turn-aligned pages until the window covers the target seq, lowering a shared target on repeated calls, stopping on a page that makes no progress, and reporting busy through the same `loadingOlder` snapshot bit. The Web adapter explicitly opts into cursorless Assistant frames: each opening carries the active attempt's `startedAfterSeq`, `nextIndex`, and compact stream, and every stream member becomes a Client-only `assistant/live-chunk` entry ordered between durable cursors. The Host captures a follower-local arrival ordinal with that baseline and suppresses buffered frames at or before the cut; a replacement Agent may restart frame revision at one. A durable `assistant/message` or `assistant/attempt` arriving after an active opening stays staged only when its seq follows `startedAfterSeq` and its Turn and Step match; the matching end type, seq, and index releases the durable entry. A successful message retains its transient rows until the owning `step/end`, so pending tool identities remain available for dispatch. Failed or interrupted settlements retire their rows immediately; earlier same-step retries remain visible. Revision, dense-index, or settlement gaps for a known attempt reopen follow, while a controller that missed the start ignores unknown-attempt frames and publishes their durable settlement normally. An abandoned end publishes a settlement delta without a durable entry so its transient rows retire immediately. A durable gap-repair page has no Assistant baseline, so its held notification reopens follow once for a paired page and baseline. Every history record covers exactly its event seq. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces projection state instead of treating transient values as durable events. At each ready Host generation, a synchronous Client subscription clears retained projection values and watermarks before refreshing queries and restarting the control stream, including Sessions absent from the control baseline. The first control stream waits for generation readiness, so its opening values cannot precede invalidation. Outstanding list responses from the previous generation cannot republish those values. Within a generation, a delayed control baseline cannot overwrite or clear newer sequenced values, whether they arrived through a live Session's list block, a history page, or a frame; a cached list block, viewed from the projection cache for a cold Session, yields to the connected Session's baseline whatever watermark it carries. The durable `inbox` projection carries both pending lists through the same cold-read and reconnect path as other projections. Client Agent contexts provide the identity used by the independent [`fileUpload`](../../client/file-upload/README.md) service; Session objects expose lifecycle, prompt, queue, and history operations rather than file transfer.
The Session object also carries local submission echoes: `session.beginSubmission` inserts one into `SessionSnapshot.pendingSubmissions` synchronously, before the caller serializes and prompts, so a conversation UI can show the message on the submit click's own frame. The echo stores ordered image previews and durable file references. Session derives its `transcript`, `queued`, or `steering` placement from the current running state and requested delivery mode, then retains that placement until the display handoff. The prompt's `requestId` is the correlation identity: the Host echoes it as the durable user source's `rpcId`, including pending messages in the `inbox` projection. Queued echoes retire one animation frame after queue acceptance; Chat echoes follow the admission rule below. Identified failures and abandonment retire unadmitted echoes immediately. Disposal preserves observed admission and fails other unsettled submissions. Each retirement fires `onRetire` exactly once; an observed retirement includes the ordered durable attachment references so the composer can release successful cards while preserving failed drafts. Echoes are Client memory only; reload and reconnect rebuild the conversation from durable events alone.
The Session object also carries local submission echoes: `session.beginSubmission` registers one before the caller serializes and prompts, and Session publishes it in `SessionSnapshot.pendingSubmissions` once `prompt` reports that the Host accepted the prompt, so a conversation UI shows the message from acceptance until its durable entry renders. The blank-to-engaging transition also waits for that acceptance. The echo stores ordered image previews and durable file references. Session derives its `transcript`, `queued`, or `steering` placement from the current running state and requested delivery mode, then retains that placement until the display handoff. The prompt's `requestId` is the correlation identity: the Host echoes it as the durable user source's `rpcId`, including pending messages in the `inbox` projection. Queued echoes retire one animation frame after queue acceptance; Chat echoes follow the admission rule below. Identified failures and abandonment retire unadmitted echoes immediately. Disposal preserves observed admission and fails other unsettled submissions. Each retirement fires `onRetire` exactly once; an observed retirement includes the ordered durable attachment references so the sender can release or hand over the sent attachments. Echoes are Client memory only; reload and reconnect rebuild the conversation from durable events alone.
During uninterrupted following, transcript and steering echoes stay in Chat through Inbox acceptance and claim until their durable messages arrive. After a local Chat message is admitted, its identity remains until the Inbox watermark reaches its next-turn or next-step claim seq: Chat already renders the durable Node, while Chat and QueueDock exclude matching stale Inbox rows. Other queued messages remain visible. A replacement follow baseline withdraws receipt-confirmed echoes as observed, using their accepted attachment references; unconfirmed submissions remain pending. Host data then supplies the pending or admitted rows. Reconnecting between claim and admission can briefly omit that bubble; withdrawal confirms acceptance, not execution or failure.
+1 -1
View File
@@ -53,7 +53,7 @@ Client 列表刷新保留未变化的行对象,并在顺序和值均相同时
Client 适配器提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend`、`append` 与 `settle-assistant` 变更,并通过 tail page 修复重连或 seq 缺口。向后分页有两个动词:`loadOlder()` 拉一页按轮次开头对齐的历史,而 `loadThrough(seq)`——轮次跳转加载器——按轮次开头对齐页面循环拉取直到窗口覆盖目标 seq,重复调用会下调共享目标,遇到无进展的页即停止,忙碌状态复用同一个 `loadingOlder` 快照位。Web 适配器显式选择接收无 cursor 的 Assistant frame:每个 opening 携带活跃 attempt 的 `startedAfterSeq`、`nextIndex` 与紧凑 stream,每个 stream member 都成为排在持久 cursor 之间的 Client-only `assistant/live-chunk` 条目。Host 会随该 baseline 捕获 follower 本地到达序号,并抑制该 cut 及之前的 buffered frame;replacement Agent 可以从 revision 一重新开始。活跃 opening 之后到达的持久 `assistant/message` 或 `assistant/attempt` 只有在其 seq 晚于 `startedAfterSeq` 且轮次与步骤匹配时才会保持暂存;匹配的 end type、seq 与 index 会发布持久条目。成功消息的瞬态 row 保留到所属 `step/end`,让待派发工具的身份持续可用;失败或中断的 settlement 立即移除对应 row,同一步骤中更早的 retry 仍然保留。已知 attempt 的 revision、密集 index 或 settlement 缺口会重新打开 follow;若 controller 错过 start,则忽略 unknown-attempt frame,并正常发布其持久 settlement。Abandoned end 会发布不含持久条目的 settlement delta,使瞬态 row 立即退出。持久缺口修复 page 不携带 Assistant baseline,因此 held notification 会重新打开 follow 一次,以取得配对的 page 与 baseline。每条历史 record 只覆盖自身的事件 seq。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 projection 状态,而不会把瞬态值当作 durable event。每次 Host generation 就绪时,同步的 Client 订阅会先清除保留的投影值及其水位,再刷新查询并重新打开 control stream,其中也包括 control baseline 中没有列出的 Session。首次 control stream 会等待 generation 就绪,确保其 opening 值不会先于旧状态清理到达。上一代尚未完成的 list 响应无法重新发布这些值。同一 generation 内,延迟到达的 control baseline 不能覆盖或清除较新的 sequenced 值,无论它们来自活 Session 的 list block、history page 还是 frame;冷 Session 从 projection cache 看出来的 cached list block 则不论水位都让位于建连 Session 的 baseline。持久 `inbox` 投影通过与其他投影相同的冷读取和重连路径传输两份待处理列表。Client Agent 上下文提供独立 [`fileUpload`](../../client/file-upload/README.zh.md) 服务使用的身份;Session 对象提供生命周期、prompt、queue 与历史操作,不提供文件传输。
Session 对象还承载本地提交回显:`session.beginSubmission` 在调用方序列化与提示词之前,同步把一条回显写入 `SessionSnapshot.pendingSubmissions`,会话 UI 因此能在点击提交的当帧显示消息。回显按顺序存放图片预览与持久文件引用。Session 根据当前运行状态与请求的投递模式推导其 `transcript`、`queued` 或 `steering` 位置,并保留该位置直到展示接管。提示词的 `requestId` 是关联标识:Host 把它回显为 durable user source 的 `rpcId`,`inbox` 投影中的待处理消息也保留同一 source。排队回显在队列接受后延迟一个动画帧退休;Chat 回显遵循下述入档规则。尚未入档的回显在带标识的提示词失败或被放弃时立即退休。销毁时保留已观察到的入档结果,其余未结算提交按 failed 退休。每次退休恰好触发一次 `onRetire`;observed 退休还会携带有序的持久附件引用,让 composer 释放成功卡片并保留失败草稿。回显只存在于 Client 内存;刷新与重连只从持久事件重建会话。
Session 对象还承载本地提交回显:`session.beginSubmission` 在调用方序列化与发送提示词之前登记一条回显,`prompt` 报告 Host 已接收该提示词后,Session 才把它公开在 `SessionSnapshot.pendingSubmissions` 中,因此会话 UI 从接收起显示消息,直到它的持久条目渲染出来。新会话从空白进入 engaging 也要等到这次接收。回显按顺序存放图片预览与持久文件引用。Session 根据当前运行状态与请求的投递模式推导其 `transcript`、`queued` 或 `steering` 位置,并保留该位置直到展示接管。提示词的 `requestId` 是关联标识:Host 把它回显为 durable user source 的 `rpcId`,`inbox` 投影中的待处理消息也保留同一 source。排队回显在队列接受后延迟一个动画帧退休;Chat 回显遵循下述入档规则。尚未入档的回显在带标识的提示词失败或被放弃时立即退休。销毁时保留已观察到的入档结果,其余未结算提交按 failed 退休。每次退休恰好触发一次 `onRetire`;observed 退休还会携带有序的持久附件引用,让发送方释放或移交已发送的附件。回显只存在于 Client 内存;刷新与重连只从持久事件重建会话。
正常在线时,transcript 与 steering 回显在 Inbox 接受与领取期间留在 Chat,直到持久消息到达。本地 Chat 消息入档后,其身份保留到 Inbox 水位覆盖 next-turn 或 next-step 领取序号:Chat 已显示真实 Node,而 Chat 与 QueueDock 排除匹配的旧 Inbox 行。其他排队消息照常显示。新的 follow 基线会将已有接收回执的回显按 observed 撤去,使用已接受的附件引用完成结算;尚未确认接收的提交继续保留。待处理或已入档的消息随后由 Host 数据呈现。在领取与入档之间重连时,气泡可能短暂消失;撤去回显只确认接收,不代表执行或失败。
@@ -66,18 +66,22 @@ export interface ISession {
/** Host-computed projection values by key (the useProjection seat). */
readonly projections: ProjectionsFace
/**
* Register one local submission echo in `snapshot.pendingSubmissions`,
* synchronously, before the caller serializes and sends the prompt. The
* Chat echoes persist until durable admission; transcript identities also
* wait for the Inbox claim watermark so stale queue rows stay suppressed.
* Queued echoes retire on queue acceptance. Identified failures retire
* submissions that have not already reached durable admission.
* Register one local submission echo before the caller serializes and sends
* the prompt. The echo reconciles against Host events from then on, and
* enters `snapshot.pendingSubmissions` once {@link prompt} reports that the
* Host accepted the prompt. The Chat echoes persist until durable admission;
* transcript identities also wait for the Inbox claim watermark so stale
* queue rows stay suppressed. Queued echoes retire on queue acceptance.
* Identified failures retire submissions that have not already reached
* durable admission.
* @param input - echo content and the optional settlement callback.
* @returns the minted identity for {@link prompt} plus the pre-prompt abandon path.
*/
beginSubmission(input: BeginSubmissionInput): SubmissionHandle
/**
* Send a prompt into the session.
* Send a prompt into the session. The Host can hold the prompt at its
* admission stage, so the call can stay open; acceptance publishes the
* prompt's echo and moves a blank Session to engaging.
* @param content - text plus browser-owned temporary image uploads.
* @param mode - 'queue' appends a turn; 'steer' interrupts the running one.
* @param signal - optional caller cancellation for the complete admission round-trip.
@@ -38,10 +38,10 @@ export type PendingSubmissionAttachment =
export type PendingSubmissionPlacement = 'transcript' | 'queued' | 'steering'
/**
* One local prompt-submission echo: inserted synchronously when a submission
* begins, so the conversation can show the message before serialization,
* transport, and durable admission complete. Client-memory only — reload and
* reconnect rebuild the conversation from durable events alone.
* One local prompt-submission echo: registered when a submission begins and
* published once the Host accepts the prompt, so the conversation shows the
* message before its durable admission completes. Client-memory only — reload
* and reconnect rebuild the conversation from durable events alone.
*/
export interface PendingSubmission {
/** The prompt RPC identity; the durable `user/message` source echoes it as `rpcId`. */
@@ -68,7 +68,7 @@ export interface PromptError {
/** Immutable Session lifecycle and control snapshot. */
export interface SessionSnapshot {
readonly sessionId: SessionId
/** Local prompt-submission echoes not yet observed as durable events or queue occurrences. */
/** Local prompt-submission echoes the Host accepted and that are not yet observed as durable events or queue occurrences. */
readonly pendingSubmissions: readonly PendingSubmission[]
readonly running: boolean
readonly subagent: {
@@ -116,8 +116,8 @@ export class Session implements SessionFace {
private parentAvailable: boolean | undefined
/**
* Sticky send marker, private input of the composerPhase derivation: set
* synchronously before prompt()'s first await, never reset — the blank →
* engaging edge of the phase machine (see ComposerPhase).
* when the Host accepts a prompt, never reset — the blank → engaging edge of
* the phase machine (see ComposerPhase).
*/
private promptAttempted = false
/** A first accepted prompt stays in the engaging phase until its turn is observable. */
@@ -127,8 +127,10 @@ export class Session implements SessionFace {
private removed = false
private promptError: PromptError | null = null
private lastAgentError: string | null = null
/** Local submission echoes, insertion-ordered (see SessionSnapshot.pendingSubmissions). */
/** Local submission echoes, insertion-ordered, including those the Host has not accepted yet. */
private pendingSubmissions: readonly PendingSubmission[] = []
/** The accepted echoes, published as SessionSnapshot.pendingSubmissions; rebuilt when one is accepted or removed. */
private acceptedSubmissions: readonly PendingSubmission[] = []
/** Per-echo settlement state; `retiring` latches the first observation so a
* Inbox projection and its durable event cannot both retire one echo. */
private readonly submissionSettlements = new Map<SessionRequestId, {
@@ -142,6 +144,8 @@ export class Session implements SessionFace {
}
/** Durable admission may precede the Inbox projection acknowledging its claim. */
admitted?: readonly (ImageAttachmentRef | FileAttachmentRef)[]
/** Whether the Host accepted the prompt; the echo is published only from then on. */
accepted: boolean
readonly onRetire?: ((retirement: PendingSubmissionRetirement) => void) | undefined
retiring: boolean
}>()
@@ -219,9 +223,9 @@ export class Session implements SessionFace {
// ---- Operations ----
/**
* Register one local submission echo (see the ISession declaration).
* Synchronous through markDirty: the echo is in the very next snapshot, so
* the conversation can paint it before the caller starts serializing.
* Register one local submission echo (see the ISession declaration). The
* echo reconciles against Host events from now on, but it enters the
* snapshot only once {@link prompt} reports that the Host accepted it.
* @param input - echo content and the optional settlement callback.
* @returns the minted identity for {@link prompt} plus the pre-prompt abandon path.
*/
@@ -235,16 +239,15 @@ export class Session implements SessionFace {
text: input.text,
attachments: input.attachments,
}]
this.submissionSettlements.set(requestId, { placement, onRetire: input.onRetire, retiring: false })
// The blank → engaging edge flips here, ahead of prompt(): the composer
// docks and the echo renders on the click's own frame.
this.promptAttempted = true
this.notifier.markDirty()
this.submissionSettlements.set(requestId, { placement, accepted: false, onRetire: input.onRetire, retiring: false })
return { requestId, abandon: () => { this.retireFailedSubmission(requestId) } }
}
/**
* Send (queue/steer passed through 1:1); failures land in the snapshot's promptError.
* Send (queue/steer passed through 1:1); failures land in the snapshot's
* promptError. The Host can hold the prompt at its admission stage, so the
* call can stay open; the echo appears and the blank → engaging edge flips
* only once the Host accepts the prompt.
* @param content - text, browser-owned temporary image uploads, and staged-file receipts.
* @param mode - queue appends after the current turn; steer interrupts it.
* @param signal - optional caller cancellation for the complete admission round-trip.
@@ -259,12 +262,9 @@ export class Session implements SessionFace {
): Promise<RemoteResult<{ accepted: true }>> {
this.promptError = null
this.lastAgentError = null
// Synchronous, before the first await: the blank → engaging edge must be
// visible on the session area's very first frame when a caller sends
// ahead of navigation (first-send flow).
this.promptAttempted = true
if (this.blankBit) this.firstPromptPendingTurn = true
this.notifier.markDirty()
// Before the first await, so a turn observed ahead of the receipt clears it.
const firstPrompt = this.blankBit && !this.firstPromptPendingTurn
if (firstPrompt) this.firstPromptPendingTurn = true
let result: RemoteResult<{ accepted: true }>
if (this.address === undefined) {
const clientTimeZone = resolvedClientTimeZone()
@@ -300,16 +300,21 @@ export class Session implements SessionFace {
result = routed.ok ? { ok: true, value: { accepted: true } } : routed
}
if (!result.ok) {
// Rejection must leave a first prompt blank and eligible for workspace reuse.
if (firstPrompt) this.firstPromptPendingTurn = false
if (requestId !== undefined) this.retireFailedSubmission(requestId)
this.promptError = { op: 'send', error: result.error }
this.notifier.markDirty()
return result
}
// Rejection must leave a first prompt blank and eligible for workspace reuse.
if (this.blankBit) {
this.blankBit = false
this.notifier.markDirty()
const settlement = requestId === undefined ? undefined : this.submissionSettlements.get(requestId)
if (settlement !== undefined) {
settlement.accepted = true
this.refreshAcceptedSubmissions()
}
this.promptAttempted = true
this.blankBit = false
this.notifier.markDirty()
this.options.onEngaged?.(this)
return result
}
@@ -861,6 +866,11 @@ export class Session implements SessionFace {
this.finishSubmission(requestId, { reason: 'failed' })
}
private refreshAcceptedSubmissions(): void {
this.acceptedSubmissions = this.pendingSubmissions
.filter(echo => this.submissionSettlements.get(echo.requestId)?.accepted === true)
}
/** Single removal point: drop the echo, publish, then notify the owner. */
private finishSubmission(requestId: SessionRequestId, retirement: PendingSubmissionRetirement): void {
const settlement = this.submissionSettlements.get(requestId)
@@ -868,6 +878,7 @@ export class Session implements SessionFace {
if (settlement === undefined) return
this.submissionSettlements.delete(requestId)
this.pendingSubmissions = this.pendingSubmissions.filter(echo => echo.requestId !== requestId)
if (settlement.accepted) this.refreshAcceptedSubmissions()
this.notifier.markDirty()
settlement.onRetire?.(retirement)
}
@@ -889,7 +900,7 @@ export class Session implements SessionFace {
const identity = this.projections.values().subagent
return {
sessionId: this.sessionId,
pendingSubmissions: this.pendingSubmissions,
pendingSubmissions: this.acceptedSubmissions,
running: this.running,
subagent: this.address === undefined
? null
@@ -68,8 +68,21 @@ async function settleFrames(): Promise<void> {
await new Promise(resolve => setTimeout(resolve, 0))
}
type BenchSession = Awaited<ReturnType<typeof sessionBench>>
/** Register an echo and let the Host accept its prompt, which publishes the echo. */
async function submitAccepted(
session: BenchSession,
input: Parameters<BenchSession['beginSubmission']>[0],
): Promise<ReturnType<BenchSession['beginSubmission']>> {
const handle = session.beginSubmission(input)
const result = await session.prompt([{ type: 'text', text: input.text }], input.mode, undefined, handle.requestId)
expect(result.ok).toBe(true)
return handle
}
describe('beginSubmission', () => {
it('inserts the echo synchronously and flips the engaging edge before any prompt call', async ({ mock, start }) => {
it('publishes the echo and the engaging edge only once the Host accepts the prompt', async ({ mock, start }) => {
const session = await sessionBench(mock, start, SID)
expect(session.getSnapshot()).toMatchObject({ pendingSubmissions: [], promptAttempted: false })
const handle = session.beginSubmission({
@@ -79,6 +92,15 @@ describe('beginSubmission', () => {
type: 'image', value: { previewUrl: 'blob:p1', name: 'a.png', width: 4, height: 3 },
}],
})
expect(session.getSnapshot()).toMatchObject({ pendingSubmissions: [], promptAttempted: false })
expect(mock.log.requests()).toEqual([])
const receipt = Promise.withResolvers<{ ok: true; value: { accepted: true } }>()
mock.remote.session.prompt.mockImplementationOnce(() => receipt.promise)
const inFlight = session.prompt([{ type: 'text', text: '你好' }], 'queue', undefined, handle.requestId)
await Promise.resolve()
expect(session.getSnapshot()).toMatchObject({ pendingSubmissions: [], promptAttempted: false })
receipt.resolve({ ok: true, value: { accepted: true } })
expect((await inFlight).ok).toBe(true)
expect(session.getSnapshot().promptAttempted).toBe(true)
expect(session.getSnapshot().pendingSubmissions).toMatchObject([{
requestId: handle.requestId,
@@ -88,15 +110,32 @@ describe('beginSubmission', () => {
type: 'image', value: { previewUrl: 'blob:p1', name: 'a.png', width: 4, height: 3 },
}],
}])
expect(mock.log.requests()).toEqual([])
}, COLD_BOOT_TIMEOUT_MS)
it('never publishes an echo whose message was observed before the Host receipt', async ({ mock, start }) => {
const session = await sessionBench(mock, start, SID)
await session.open()
const retirements: PendingSubmissionRetirement[] = []
const handle = session.beginSubmission({
mode: 'queue', text: '先到', attachments: [], onRetire: retirement => retirements.push(retirement),
})
const receipt = Promise.withResolvers<{ ok: true; value: { accepted: true } }>()
mock.remote.session.prompt.mockImplementationOnce(() => receipt.promise)
const inFlight = session.prompt([{ type: 'text', text: '先到' }], 'queue', undefined, handle.requestId)
await pushEvent(mock, promptEvent(SessionSeq(0), handle.requestId))
await settleFrames()
expect(retirements).toEqual([{ reason: 'observed', attachments: [] }])
receipt.resolve({ ok: true, value: { accepted: true } })
expect((await inFlight).ok).toBe(true)
expect(session.getSnapshot().pendingSubmissions).toEqual([])
})
it('derives and captures the echo placement from running state and delivery mode', async ({ mock, start }) => {
const session = await sessionBench(mock, start, SID)
session.beginSubmission({ mode: 'queue', text: '空闲', attachments: [] })
await submitAccepted(session, { mode: 'queue', text: '空闲', attachments: [] })
session.handleRunning(true)
session.beginSubmission({ mode: 'queue', text: '排队', attachments: [] })
session.beginSubmission({ mode: 'steer', text: '纠偏', attachments: [] })
await submitAccepted(session, { mode: 'queue', text: '排队', attachments: [] })
await submitAccepted(session, { mode: 'steer', text: '纠偏', attachments: [] })
session.handleRunning(false)
expect(session.getSnapshot().pendingSubmissions.map(({ text, placement }) => ({ text, placement }))).toEqual([
{ text: '空闲', placement: 'transcript' },
@@ -107,12 +146,12 @@ describe('beginSubmission', () => {
it('keeps rapid ordinary submissions in Chat until the running update arrives', async ({ mock, start }) => {
const session = await sessionBench(mock, start, SID)
for (const text of ['A', 'B', 'C']) session.beginSubmission({ mode: 'queue', text, attachments: [] })
for (const text of ['A', 'B', 'C']) await submitAccepted(session, { mode: 'queue', text, attachments: [] })
expect(session.getSnapshot().pendingSubmissions.map(input => input.placement)).toEqual([
'transcript', 'transcript', 'transcript',
])
session.handleRunning(true)
session.beginSubmission({ mode: 'queue', text: 'D', attachments: [] })
await submitAccepted(session, { mode: 'queue', text: 'D', attachments: [] })
expect(session.getSnapshot().pendingSubmissions.map(input => input.placement)).toEqual([
'transcript', 'transcript', 'transcript', 'queued',
])
@@ -161,8 +200,8 @@ describe('prompt-coupled retirement', () => {
it('an unidentified prompt failure leaves registered echoes alone', async ({ mock, start }) => {
const session = await sessionBench(mock, start, SID)
await submitAccepted(session, { mode: 'queue', text: '还在', attachments: [] })
mock.remote.session.prompt.mockResolvedValue(err(new RemoteError('session/agent-busy', '忙', { reason: 'busy' })))
session.beginSubmission({ mode: 'queue', text: '还在', attachments: [] })
await session.prompt([{ type: 'text', text: '另一个' }], 'queue')
expect(session.getSnapshot().pendingSubmissions).toHaveLength(1)
})
@@ -173,7 +212,7 @@ describe('observed retirement', () => {
const session = await sessionBench(mock, start, SID)
await session.open()
const retirements: PendingSubmissionRetirement[] = []
const handle = session.beginSubmission({
const handle = await submitAccepted(session, {
mode: 'queue',
text: '发送',
attachments: [{ type: 'image', value: { previewUrl: 'blob:p1' } }],
@@ -193,7 +232,7 @@ describe('observed retirement', () => {
const session = await sessionBench(mock, start, SID)
await session.open()
const onRetire = vi.fn()
const handle = session.beginSubmission({ mode: 'steer', text: 'accepted', attachments: [], onRetire })
const handle = await submitAccepted(session, { mode: 'steer', text: 'accepted', attachments: [], onRetire })
const refs = [imageRef('claimed-image')]
const message = queuedItem(handle.requestId, refs)
session.projections.apply('inbox', { 'next-turn': [], 'next-step': [message] }, SessionSeq(0))
@@ -229,7 +268,7 @@ describe('observed retirement', () => {
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => frames.push(callback))
const onRetire = vi.fn()
session.handleRunning(mode === 'steer')
const handle = session.beginSubmission({ mode, text: 'local input', attachments: [], onRetire })
const handle = await submitAccepted(session, { mode, text: 'local input', attachments: [], onRetire })
const refs = [imageRef('idle-image'), fileRef('idle-file')]
const message = queuedItem(handle.requestId, refs)
const queued = { 'next-turn': [], 'next-step': [], [target]: [message] }
@@ -277,10 +316,10 @@ describe('observed retirement', () => {
const normalRetired = vi.fn()
const queueRetired = vi.fn()
const steerRetired = vi.fn()
const normal = session.beginSubmission({ mode: 'queue', text: 'opening', attachments: [], onRetire: normalRetired })
const normal = await submitAccepted(session, { mode: 'queue', text: 'opening', attachments: [], onRetire: normalRetired })
session.handleRunning(true)
const queued = session.beginSubmission({ mode: 'queue', text: 'queued', attachments: [], onRetire: queueRetired })
const steer = session.beginSubmission({ mode: 'steer', text: 'steering', attachments: [], onRetire: steerRetired })
const queued = await submitAccepted(session, { mode: 'queue', text: 'queued', attachments: [], onRetire: queueRetired })
const steer = await submitAccepted(session, { mode: 'steer', text: 'steering', attachments: [], onRetire: steerRetired })
const normalMessage = queuedItem(normal.requestId, [fileRef('opening-file')])
const queuedMessage = queuedItem(queued.requestId, [imageRef('queued-image')])
const steerMessage = queuedItem(steer.requestId, [imageRef('steer-image'), fileRef('steer-file')])
@@ -342,7 +381,7 @@ describe('observed retirement', () => {
await session.open()
session.handleRunning(true)
const onRetire = vi.fn()
const handle = session.beginSubmission({ mode: 'steer', text: 'accepted', attachments: [], onRetire })
const handle = await submitAccepted(session, { mode: 'steer', text: 'accepted', attachments: [], onRetire })
const refs = [imageRef('steer-image')]
const message = queuedItem(handle.requestId, refs)
const echo = session.getSnapshot().pendingSubmissions[0]
@@ -376,9 +415,9 @@ describe('observed retirement', () => {
session.handleRunning(target === 'next-step')
const canceled = vi.fn()
const rejected = vi.fn()
const first = session.beginSubmission({ mode: 'steer', text: 'cancel', attachments: [], onRetire: canceled })
const second = session.beginSubmission({ mode: 'steer', text: 'reject', attachments: [], onRetire: rejected })
const unclaimed = session.beginSubmission({ mode: 'steer', text: 'later', attachments: [] })
const first = await submitAccepted(session, { mode: 'steer', text: 'cancel', attachments: [], onRetire: canceled })
const second = await submitAccepted(session, { mode: 'steer', text: 'reject', attachments: [], onRetire: rejected })
const unclaimed = await submitAccepted(session, { mode: 'steer', text: 'later', attachments: [] })
const foreign = createUserMessage({ source: { kind: 'user' }, content: [] })
await pushEvent(mock, {
type: 'agent/inbox/spliced', seq: SessionSeq(0), time: 1,
@@ -469,7 +508,7 @@ describe('observed retirement', () => {
await session.open()
session.handleRunning(target === 'next-step')
const onRetire = vi.fn()
const handle = session.beginSubmission({ mode: 'steer', text: 'accepted', attachments: [], onRetire })
const handle = await submitAccepted(session, { mode: 'steer', text: 'accepted', attachments: [], onRetire })
const refs = [imageRef('steer-image'), fileRef('steer-file')]
const message = queuedItem(handle.requestId, refs)
if (target === 'next-turn') {
@@ -481,7 +520,7 @@ describe('observed retirement', () => {
data: { target, start: 0, inserted: [message] },
})
}
const unconfirmed = session.beginSubmission({ mode: 'steer', text: 'not accepted yet', attachments: [] })
const unconfirmed = await submitAccepted(session, { mode: 'steer', text: 'not accepted yet', attachments: [] })
const inbox = { 'next-turn': [], 'next-step': [], [target]: pending ? [message] : [] }
const replacement = {
...historyValue([ev.turnEnd(SessionSeq(10), 2)], true),
@@ -572,7 +611,7 @@ describe('observed retirement', () => {
frames.push(fn)
return frames.length
})
const handle = session.beginSubmission({ mode: 'queue', text: '帧', attachments: [] })
const handle = await submitAccepted(session, { mode: 'queue', text: '帧', attachments: [] })
await pushEvent(mock, promptEvent(SessionSeq(0), handle.requestId))
expect(session.getSnapshot().pendingSubmissions).toHaveLength(1)
expect(frames).toHaveLength(1)
@@ -613,7 +613,7 @@ describe('prompt and cancel errors', () => {
])
})
it('publishes the first-prompt lifecycle synchronously before the Remote settles', async ({ mock, start }) => {
it('publishes the first-prompt lifecycle once the Host accepts the prompt', async ({ mock, start }) => {
const session = await sessionBench(mock, start, SID)
session.handleBlank(true)
expect(session.getSnapshot()).toMatchObject({
@@ -621,7 +621,7 @@ describe('prompt and cancel errors', () => {
})
const inFlight = session.prompt([{ type: 'text', text: '要发的' }], 'queue')
expect(session.getSnapshot()).toMatchObject({
blank: true, promptAttempted: true, awaitingFirstTurn: true,
blank: true, promptAttempted: false, awaitingFirstTurn: false,
})
const result = await inFlight
expect(result.ok).toBe(true)
@@ -642,9 +642,22 @@ describe('prompt and cancel errors', () => {
const session = await sessionBench(mock, start, SID)
session.handleBlank(false)
const inFlight = session.prompt([{ type: 'text', text: '继续' }], 'queue')
expect(session.getSnapshot()).toMatchObject({ blank: false, promptAttempted: true, awaitingFirstTurn: false })
expect(session.getSnapshot()).toMatchObject({ blank: false, promptAttempted: false, awaitingFirstTurn: false })
expect((await inFlight).ok).toBe(true)
expect(session.getSnapshot()).toMatchObject({ blank: false, awaitingFirstTurn: false })
expect(session.getSnapshot()).toMatchObject({ blank: false, promptAttempted: true, awaitingFirstTurn: false })
})
it('does not await a first turn that started before the Host receipt arrived', async ({ mock, start }) => {
const session = await sessionBench(mock, start, SID)
session.handleBlank(true)
const receipt = Promise.withResolvers<{ ok: true; value: { accepted: true } }>()
mock.remote.session.prompt.mockImplementationOnce(() => receipt.promise)
const inFlight = session.prompt([{ type: 'text', text: '很快' }], 'queue')
session.handleRunning(true)
session.handleRunning(false)
receipt.resolve({ ok: true, value: { accepted: true } })
expect((await inFlight).ok).toBe(true)
expect(session.getSnapshot()).toMatchObject({ blank: false, promptAttempted: true, awaitingFirstTurn: false })
})
it('keeps an accepted first prompt converted when a later prompt is rejected', async ({ mock, start }) => {
@@ -662,7 +675,7 @@ describe('prompt and cancel errors', () => {
})
})
it('keeps the attempted-first-prompt state when the Host rejects the prompt', async ({ mock, start }) => {
it('keeps a first prompt the Host rejects blank and unattempted', async ({ mock, start }) => {
const session = await sessionBench(mock, start, SID)
session.handleBlank(true)
mock.remote.session.prompt.mockResolvedValue(err(new RemoteError('session/agent-busy', 'busy', { reason: 'x' })))
@@ -670,7 +683,7 @@ describe('prompt and cancel errors', () => {
expect(result.ok).toBe(false)
expect(session.getSnapshot().promptError).toMatchObject({ op: 'send', error: { code: 'session/agent-busy' } })
expect(session.getSnapshot()).toMatchObject({
blank: true, promptAttempted: true, awaitingFirstTurn: true,
blank: true, promptAttempted: false, awaitingFirstTurn: false,
})
})
@@ -21,8 +21,8 @@
en: 9912b286384c41d7
zh: 3e994887c1948677
/deepseek-ai-dsh-client-ui-conversation/shell-and-standard-props:
en: c4d4aaef46daab72
zh: 0dac50689d806ecf
en: f55957dfb2d48d88
zh: 7cfb08eca106f067
/deepseek-ai-dsh-client-ui-conversation/temporary-composer-entries:
en: bb1244310f5c0382
zh: 824b84077432b559
+2 -2
View File
@@ -102,9 +102,9 @@ Clicking unused space inside an editable composer preserves its current caret or
Each composer attempt carries its original occurrence time, gesture, delivery intent, and Session model/run-state snapshot through asynchronous command arbitration. The ordinary-message notification fires once before reference serialization, including attachment-only submissions; handled or claimed commands do not notify. Snapshot and notification failures cannot block sending.
Default sends commit optimistically: Enter clears the draft, occurrence table, and undo history in the same transaction, keeps the composer in `plain`, and runs the send as a detached attempt, so typing and further sends continue during the flight. `sendSession` registers a Session submission echo (`session.beginSubmission`) with the delivery mode before serializing, preserving selected image and file order in `pendingSubmissions`; Session derives the placement from that mode and its current running state, so idle sends use the transcript, busy Queue sends use QueueDock, and busy Steer sends use the pending-steering surface. It then yields one paint, encodes images through the browser's native `FileReader` data-URL path, and cites staged file receipts. Command submissions use the same receipts for generic files, so sending `/goal` or `/plan` never reads those browser files again. The prompt reuses the submission `requestId`; Session retires the echo once at its display handoff, correlated by that `rpcId`. Concurrent failures are restored together in submission order until the user edits the restored content; command submissions keep the frozen `submitting` phase. Detached attempts retain their attachment ids through admission and Session scope disposal. An observed retirement immediately exposes each image preview through the durable cache, replaces it with the canonical URL after fetching the admitted attachment, revokes each URL after its use ends, and releases file cards. Selected generic files enter one FIFO background-upload queue; `maxConcurrentFileUploads` defaults to two active Worker transports, the Conversation service retains queued and active operations plus byte progress across Session navigation, and removing a draft skips its queued transfer or aborts its active transport. Where the browser shell exposes `__DSH_HOST_PATHS__` (the Desktop application), dropped or pasted folders and dropped, picked, or pasted non-image files with a real path become `@path` chips; images keep uploading. Drag-and-drop and paste identify directories through the browser entry API; when that API is absent or returns no entry, pasted items retain ordinary file handling. The file picker cannot select directories. References require the `ui-reference` plugin and the original paths to remain readable by the model's file tools. Paths inside the workspace are relative; others remain absolute. The whole batch is validated before insertion, retains source order and selected text, and uses whitespace-separated, closed-quote mentions. A browser without the bridge refuses dropped or pasted folders; a Desktop folder without a reported path is rejected separately. Continuable subagents accept pasted and dropped PNG, JPEG, WebP, and GIF images with the same projected limits; a batch containing a generic file, unsupported image format, or directory is refused before intake. Their sends register the same local echo; the continuation request carries the echo's `requestId`, which the Host records as the child message's `rpcId`.
A default send waits for the Host: Enter captures the draft text, its reference chips, and its attachments in the attempt, and the composer enters `sending`, with the editor and attachment rail read-only and Send showing progress, until the Host accepts the prompt. Only then do the draft, occurrence table, and undo history clear in one transaction; a refusal or failure returns the composer to `plain` with the draft and attachments unchanged, so one message is in flight at a time. The browser moves the keyboard to the document body when the editor turns read-only; if it is still there when a read-only phase ends, the editor takes it back, while a control the user focused meanwhile keeps it. `sendSession` registers a Session submission echo (`session.beginSubmission`) with the delivery mode before serializing, preserving selected image and file order; Session publishes it in `pendingSubmissions` once the Host accepts the prompt and derives the placement from that mode and its running state at registration, so idle sends use the transcript, busy Queue sends use QueueDock, and busy Steer sends use the pending-steering surface. It then yields one paint, encodes images through the browser's native `FileReader` data-URL path, and cites staged file receipts. Command submissions use the same receipts for generic files, so sending `/goal` or `/plan` never reads those browser files again. The prompt reuses the submission `requestId`; Session retires the echo once at its display handoff, correlated by that `rpcId`. Command submissions keep the frozen `submitting` phase. Accepted attachments leave the draft. An observed retirement immediately exposes each image preview through the durable cache, replaces it with the canonical URL after fetching the admitted attachment, revokes each URL after its use ends, and releases file cards; an accepted prompt whose echo retires unobserved releases its attachments, and Session scope disposal releases the attachments of a message the Host has not accepted. Selected generic files enter one FIFO background-upload queue; `maxConcurrentFileUploads` defaults to two active Worker transports, the Conversation service retains queued and active operations plus byte progress across Session navigation, and removing a draft skips its queued transfer or aborts its active transport. Where the browser shell exposes `__DSH_HOST_PATHS__` (the Desktop application), dropped or pasted folders and dropped, picked, or pasted non-image files with a real path become `@path` chips; images keep uploading. Drag-and-drop and paste identify directories through the browser entry API; when that API is absent or returns no entry, pasted items retain ordinary file handling. The file picker cannot select directories. References require the `ui-reference` plugin and the original paths to remain readable by the model's file tools. Paths inside the workspace are relative; others remain absolute. The whole batch is validated before insertion, retains source order and selected text, and uses whitespace-separated, closed-quote mentions. A browser without the bridge refuses dropped or pasted folders; a Desktop folder without a reported path is rejected separately. Continuable subagents accept pasted and dropped PNG, JPEG, WebP, and GIF images with the same projected limits; a batch containing a generic file, unsupported image format, or directory is refused before intake. Their sends register the same local echo; the continuation request carries the echo's `requestId`, which the Host records as the child message's `rpcId`.
Queued submission echoes show “Sending…” beside disabled edit, remove, and steer buttons; a collapsed dock keeps the sending status in its header. A matching Host queue row replaces the echo and enables each action according to its normal text-content and running-state requirements. Prompt acknowledgement alone does not enable queue actions. A failed submission removes its echo and displays an error; the composer restores the failed draft when it is empty or still contains the previous automatic restoration, preserving subsequently typed text.
Queued submission echoes show “Sending…” beside disabled edit, remove, and steer buttons; a collapsed dock keeps the sending status in its header. A matching Host queue row replaces the echo and enables each action according to its normal text-content and running-state requirements. Prompt acknowledgement alone does not enable queue actions. A refused or failed submission publishes no echo and leaves the draft in the composer with an error notice. A refusal from the Agent admission stage (`session/prompt-rejected`) shows its own text, unless `presented` says the refusing plugin explains it in its own interface, in which case the composer adds no notice.
Disabled Send and Stop buttons suppress their tooltips, including a Stop button that becomes a disabled Send button when the turn ends. While a normal composer is running, its primary pointer action remains Stop when the draft is empty or input is unavailable. Actionable text or attachments switch the same seat to Send; clearing or successfully submitting the draft restores Stop. The busy-Enter setting selects the Queue or Steer delivery for ordinary Sessions and continuable children, and the running Send button delivers through the same mode plain Enter resolves to; while it is enabled (no upload pending) over a plain message draft its label names that mode (Queue message or Steer message), so the setting governs Enter and the button together while Cmd/Ctrl+Enter still uses the other mode, and idle sessions, empty drafts, and `/` command lines keep the plain Send label. Their QueueDock rows share Edit, Remove, and Steer, and an empty draft shares the steer-all chord. One-shot children remain read-only. Plan mode and active goals do not change attachment intake. Continuable children keep separate Send and Stop actions and accept pasted or dropped images, but expose no File row; if their parent is offline, Send and the composer gestures lock while QueueDock controls for the live inbox remain available ([decisions](../../../.agents/notes/archived/bug-fix/2026-08-20-running-draft-primary-send.md), [inbox controls](../../subagent/subagent/README.md)).
+2 -2
View File
@@ -102,9 +102,9 @@ Session 首次绑定或缓存的 Session 成为 current 时,shell 会在渲染
每次输入框提交都携带原始发生时间、操作来源、投递意图以及会话模型和运行状态快照,贯穿异步命令裁决。普通消息通知在引用序列化前发出一次,纯附件提交也遵循这一规则;已处理或认领的命令不发出通知。快照读取与通知消费异常不能阻断发送。
默认发送采用乐观提交:Enter 在同一事务里清空草稿、occurrence 表和撤销历史,composer 保持 `plain`,发送作为 detached attempt 运行,发送期间可以继续输入和提交。`sendSession` 在序列化之前用投递模式注册 Session 提交回显(`session.beginSubmission`),并在 `pendingSubmissions` 中保留图片与文件的选择顺序;Session 根据该模式与当前运行状态推导位置,因此空闲发送进入 transcript(文本记录),繁忙时 Queue 进入 QueueDock,繁忙时 Steer 进入 pending-steering 区域。随后让出一帧,图片经浏览器原生 `FileReader` data-URL 路径编码,文件则引用已暂存凭证。命令提交也用同一凭证表示通用文件,因此发送 `/goal` 或 `/plan` 时不会再次读取这些浏览器文件。提示词复用提交 `requestId`;Session 按同一 `rpcId` 关联展示接管,并仅退休回显一次。多个并发发送失败时,在用户编辑还原内容之前按提交顺序合并还原;命令提交保持冻结的 `submitting` 阶段。Detached attempt 持有附件 id,直到 admission 完成或 Session scope 销毁。回显以 observed 退休时,durable 图片缓存立即公开每个预览 URL,读取 admitted 附件后用规范化 URL 替换预览,并在各 URL 停止使用后撤销,同时释放文件卡。选中的通用文件进入同一个先进先出的后台上传队列;`maxConcurrentFileUploads` 默认允许两个 Worker transport 同时运行,Conversation 服务在切换 Session 时继续持有排队和运行中的传输操作及字节进度,移除草稿会跳过排队中的传输或中止正在运行的传输。浏览器 shell 暴露 `__DSH_HOST_PATHS__` 时(桌面应用),拖入或粘贴的文件夹以及拖入、选择或粘贴的带真实路径的非图片文件会成为 `@路径` chip;图片仍然上传。拖放和粘贴通过浏览器 entry API 识别目录;该 API 不可用或没有返回 entry 时,粘贴项沿用普通文件处理。文件选择器不能选择目录。引用需要启用 `ui-reference` 插件,原路径也必须仍可由模型的文件工具读取。工作区内的路径使用相对形式,其他路径保留绝对形式。整批文件先校验再插入,保留来源顺序和已选中的文字,引用之间有空白分隔,含空格的路径使用闭合引号。没有该桥的浏览器会拒绝拖入或粘贴的文件夹,桌面端无法获取文件夹路径时单独报错。continuable 子代理允许粘贴和拖入 PNG、JPEG、WebP、GIF 图片,并沿用相同的投影限制;包含通用文件、不支持的图片格式或目录的批次会在进入草稿前整批拒绝。它们的发送同样注册本地回显;continuation 请求携带回显的 `requestId`,Host 把它记录为子会话消息的 `rpcId`。
默认发送等待 Host 接收:Enter 把草稿文字、引用胶囊和附件一起记入本次 attempt,composer 进入 `sending`,编辑器和附件栏只读,Send 显示发送中,直到 Host 接收该提示词。之后才在同一事务里清空草稿、occurrence 表和撤销历史;被拒绝或失败时 composer 回到 `plain`,草稿和附件保持不变,因此同一时刻只有一条消息在途。编辑器变为只读时,浏览器会把键盘焦点移到页面 body 上;只读阶段结束时焦点若仍在那里,编辑器会把它取回,用户期间聚焦的其他控件则保留焦点。`sendSession` 在序列化之前用投递模式注册 Session 提交回显(`session.beginSubmission`),保留图片与文件的选择顺序;Host 接收提示词后,Session 才在 `pendingSubmissions` 中公开它,并按注册时的模式与运行状态推导位置,因此空闲发送进入 transcript(文本记录),繁忙时 Queue 进入 QueueDock,繁忙时 Steer 进入 pending-steering 区域。随后让出一帧,图片经浏览器原生 `FileReader` data-URL 路径编码,文件则引用已暂存凭证。命令提交也用同一凭证表示通用文件,因此发送 `/goal` 或 `/plan` 时不会再次读取这些浏览器文件。提示词复用提交 `requestId`;Session 按同一 `rpcId` 关联展示接管,并仅退休回显一次。命令提交保持冻结的 `submitting` 阶段。被接收的附件离开草稿。回显以 observed 退休时,durable 图片缓存立即公开每个预览 URL,读取 admitted 附件后用规范化 URL 替换预览,并在各 URL 停止使用后撤销,同时释放文件卡;已被接收但回显未被观察到就退休的提示词会释放其附件,Session scope 销毁时会释放尚未被 Host 接收的消息的附件。选中的通用文件进入同一个先进先出的后台上传队列;`maxConcurrentFileUploads` 默认允许两个 Worker transport 同时运行,Conversation 服务在切换 Session 时继续持有排队和运行中的传输操作及字节进度,移除草稿会跳过排队中的传输或中止正在运行的传输。浏览器 shell 暴露 `__DSH_HOST_PATHS__` 时(桌面应用),拖入或粘贴的文件夹以及拖入、选择或粘贴的带真实路径的非图片文件会成为 `@路径` chip;图片仍然上传。拖放和粘贴通过浏览器 entry API 识别目录;该 API 不可用或没有返回 entry 时,粘贴项沿用普通文件处理。文件选择器不能选择目录。引用需要启用 `ui-reference` 插件,原路径也必须仍可由模型的文件工具读取。工作区内的路径使用相对形式,其他路径保留绝对形式。整批文件先校验再插入,保留来源顺序和已选中的文字,引用之间有空白分隔,含空格的路径使用闭合引号。没有该桥的浏览器会拒绝拖入或粘贴的文件夹,桌面端无法获取文件夹路径时单独报错。continuable 子代理允许粘贴和拖入 PNG、JPEG、WebP、GIF 图片,并沿用相同的投影限制;包含通用文件、不支持的图片格式或目录的批次会在进入草稿前整批拒绝。它们的发送同样注册本地回显;continuation 请求携带回显的 `requestId`,Host 把它记录为子会话消息的 `rpcId`。
排队提交的本地回显在禁用的编辑、删除、插话按钮旁显示“发送中…”;折叠后的队列在标题栏保留发送状态。匹配的 Host 队列行替换回显后,各操作按原有的纯文本内容和运行状态要求启用。仅收到提示词确认不会启用队列操作。提交失败会移除回显并显示错误;输入框为空或仍保留上一次自动恢复的内容时,composer 恢复失败草稿,保留用户随后输入的文字。
排队提交的本地回显在禁用的编辑、删除、插话按钮旁显示“发送中…”;折叠后的队列在标题栏保留发送状态。匹配的 Host 队列行替换回显后,各操作按原有的纯文本内容和运行状态要求启用。仅收到提示词确认不会启用队列操作。被拒绝或失败的提交不会公开回显,草稿留在 composer 中并显示错误提示。Agent 接纳阶段的拒绝(`session/prompt-rejected`)显示它自带的文字;若 `presented` 表明拒绝它的插件会在自己的界面里说明,composer 不再提示。
Send 和 Stop 按钮禁用时不显示提示气泡,轮次结束后由 Stop 切换成禁用 Send 的按钮也遵循此规则。普通 composer 运行时,如果草稿为空或输入不可用,主指针操作保持为 Stop。可提交的文字或附件会把同一位置切换为 Send;清空或成功提交草稿后恢复 Stop。繁忙态 Enter 设置为普通 Session 与可继续 child 选择 Queue 或 Steer 投递,运行中的 Send 按钮按 plain Enter 解析出的同一模式投递;当它在普通消息草稿上可用(没有待上传文件)时,其标签以该模式命名(排队发送或插话发送),因此该设置同时约束 Enter 与按钮,而 Cmd/Ctrl+Enter 仍使用另一模式;空闲会话、空草稿与 `/` 命令行保留普通的 Send 标签。它们的 QueueDock 行共享 Edit、Remove 与 Steer,空草稿也共享 steer-all 组合键。One-shot child 继续只读。Plan Mode 与 active goal 不改变附件入口。可继续 child 保留独立的 Send 与 Stop 操作,允许粘贴或拖入图片,但不提供「文件」菜单项;parent 离线时,Send 与 composer 手势锁定,但在线 inbox 的 QueueDock 控制仍可使用([决策](../../../.agents/notes/archived/bug-fix/2026-08-20-running-draft-primary-send.md)、[inbox 控制](../../subagent/subagent/README.zh.md))。
@@ -487,7 +487,8 @@ export function apply(ctx: Context, config: Config = Config({})): void {
keyboard: shell,
addFiles: (files, directories = new Set()) => {
if (sessions.binding(sessionId) === undefined) return t('file.sessionUnavailable')
if (shell.snapshot.phase === 'adjudicating' || shell.snapshot.phase === 'submitting') {
const { phase } = shell.snapshot
if (phase === 'adjudicating' || phase === 'submitting' || phase === 'sending') {
return t('attachment.dropBlocked')
}
const uploads: File[] = []
@@ -286,7 +286,13 @@ export interface InputState {
readonly attachmentIds: readonly DraftAttachmentId[]
/** Monotonic editor revision (span CAS compares against this). */
readonly draftRev: number
readonly phase: 'plain' | 'adjudicating' | 'claimed' | 'submitting'
/**
* Submit plane: `adjudicating` while a `/` line is checked against the
* commands, `submitting` while a claimed command runs, and `sending` while
* an ordinary message waits for the Host to accept it. The editor and the
* attachments are read-only in all three.
*/
readonly phase: 'plain' | 'adjudicating' | 'claimed' | 'submitting' | 'sending'
/** Present exactly while claimed/submitting (claim snapshot during flight; submit closure withheld). */
readonly claim?: { readonly name: string; readonly token: string; readonly hint?: string; readonly attachments?: boolean }
/** Reference occurrence view of the editor's chips, sorted by offset. */
@@ -298,16 +304,20 @@ export interface InputState {
/**
* One in-flight submission attempt: the ONLY id concept in the submit plane.
* Created on enter; carried by adjudicated/submit-settled/sink-settled
* events; stale attempts are dropped (anti-backwash). Command attempts hold
* the single frozen in-flight slot; default-sink attempts run detached and
* concurrently. release/session teardown aborts them all, keeping every
* promise bounded.
* events; stale attempts are dropped (anti-backwash). Command and message
* attempts hold the single frozen in-flight slot, so the draft they carry is
* the one the user sent. release/session teardown aborts the attempt, keeping
* every promise bounded.
*/
export interface SubmitAttempt {
readonly seq: number
readonly signal: AbortSignal
/** Clipboard-projection draft captured before an optimistic default-send commit. */
/** Clipboard-projection draft captured when the user sent. */
readonly draftSnapshot: string
/** Reference chips of the sent draft, captured with it. */
readonly occurrences: readonly Occurrence[]
/** Attachments of the sent draft, captured with it, in order. */
readonly attachmentIds: readonly DraftAttachmentId[]
/** Default-message delivery intent retained while slash adjudication is pending. */
readonly mode: InputSubmitMode
/** Original message occurrence, retained across command arbitration. */
@@ -324,8 +334,15 @@ export type InputEvent =
| { readonly type: 'draft-changed'; readonly command: CommandClaim | undefined }
/** The editor inserted a command chip: enter claimed. */
| { readonly type: 'claim'; readonly claim: CommandClaim }
/** Enter submission with the current clipboard projection. */
| { readonly type: 'enter'; readonly mode: InputSubmitMode; readonly draft: string; readonly submission?: MessageSubmission }
/** Enter submission with the current clipboard projection, its reference chips, and its attachments. */
| {
readonly type: 'enter'
readonly mode: InputSubmitMode
readonly draft: string
readonly occurrences: readonly Occurrence[]
readonly attachmentIds: readonly DraftAttachmentId[]
readonly submission?: MessageSubmission
}
| { readonly type: 'adjudicated'; readonly attempt: SubmitAttempt; readonly outcome: PickOutcome }
| { readonly type: 'adjudication-failed'; readonly attempt: SubmitAttempt; readonly message: string }
/** Settlement carries the leading command chip's claim, which the claim follows after a failure. */
@@ -337,10 +354,8 @@ export type InputEvent =
readonly outcome?: SubmitOutcome
readonly message?: string
}
/** Settlement of one optimistic default send, independent of the frozen command slot. */
/** Settlement of the message being sent: the Host accepted it, or refused it or failed. */
| { readonly type: 'sink-settled'; readonly attempt: SubmitAttempt; readonly ok: boolean; readonly outcome?: SubmitOutcome; readonly message?: string }
/** Commit an attachment-only send whose empty draft did not need an attempt. */
| { readonly type: 'send-committed' }
| { readonly type: 'release' }
/**
@@ -350,7 +365,7 @@ export type InputEvent =
export type InputEffect =
| { readonly type: 'adjudicate'; readonly attempt: SubmitAttempt; readonly draft: string }
| { readonly type: 'begin-submit'; readonly attempt: SubmitAttempt; readonly claim: CommandClaim; readonly args: string }
/** Detached default send; the shell captures its editor projection before the following commit effect. */
/** Send the attempt's frozen draft, references, and attachments as one ordinary message. */
| {
readonly type: 'default-sink'
readonly attempt: SubmitAttempt
@@ -362,7 +377,7 @@ export type InputEffect =
* Clear the committed draft in the editor and cut undo history. A string
* snapshot keeps a pure suffix typed during the Host round-trip (content
* appended after the sent snapshot survives; interleaved edits cannot be
* separated and clear whole); null clears unconditionally (attachment-only
* sends have no draft to retain).
* separated and clear whole); null clears unconditionally (an accepted
* message froze the editor, so nothing was typed after it).
*/
| { readonly type: 'commit-draft'; readonly retainSuffixOf: string | null }
@@ -20,7 +20,7 @@ import type {
SessionInput, SubmitAttempt, SubmitAttachment, SubmitOutcome,
} from '../contract/input.ts'
import type {
ArbitrateKey, ArbitrateOutcome, ComposerKeyboard, DraftInput, DraftSnapshot, Occurrence, ReferenceInsert, TokenSpan,
ArbitrateKey, ArbitrateOutcome, ComposerKeyboard, DraftInput, DraftSnapshot, ReferenceInsert, TokenSpan,
} from '../contract/draft-editor.ts'
import type { InputSubmitMode, MessageSubmission, MessageSubmissionState } from '../contract/composer-submission.ts'
import { SubmitMachine } from './machine.ts'
@@ -79,10 +79,15 @@ function guardOf(phase: InputState['phase']): 'plain' | 'claimed' | 'frozen' {
switch (phase) {
case 'plain': return 'plain'
case 'claimed': return 'claimed'
default: return 'frozen' // adjudicating / submitting
default: return 'frozen' // adjudicating / submitting / sending
}
}
/** Whether an attempt owns the draft: the editor and attachments are read-only until it settles. */
function frozen(phase: InputState['phase']): boolean {
return phase === 'adjudicating' || phase === 'submitting' || phase === 'sending'
}
/** Whether two projections differ in content (selection and caret excluded). */
function projectionContentChanged(prev: EditorProjection, next: EditorProjection): boolean {
if (prev.clipboardText !== next.clipboardText || prev.detectText !== next.detectText) return true
@@ -94,18 +99,16 @@ function projectionContentChanged(prev: EditorProjection, next: EditorProjection
})
}
/** Message of a failure surfaced as a composer notice. */
function errorText(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
const EMPTY_QUEUE: InboxState['next-turn'] = []
/** Unavailable catalogs contain no named text references. */
const EMPTY_LEXICON: ReadonlyMap<'/' | '@', readonly string[]> = new Map()
/** Editor and attachment snapshot owned by one detached default send. */
interface DetachedDraft {
readonly draft: string
readonly occurrences: readonly Occurrence[]
readonly attachmentIds: readonly DraftAttachmentId[]
}
/**
* The per-session input facade: scoped-event application verbs +
* setDraft/submit + the published InputState store, over a shell-owned
@@ -124,7 +127,7 @@ export class SessionInputShell implements SessionInput {
readonly actions: InputActions = {
captureInsertion: () => ({ ...this.caretSpan(), draftRev: this.rev }),
insertText: (text, span) => {
if (this.snapshot.phase === 'adjudicating' || this.snapshot.phase === 'submitting' || this.disposed) return false
if (frozen(this.snapshot.phase) || this.disposed) return false
if (span.draftRev !== this.rev) return false
return this.draftEditor.insertAsyncText(span, text)
},
@@ -156,20 +159,6 @@ export class SessionInputShell implements SessionInput {
private commandRestore: AbortController | undefined
/** The mounted composer's file-picker opener (scoped pick-files event target). */
private filePicker: Parameters<ComposerKeyboard['bindFilePicker']>[0] | undefined
/** Default sends retained until admission settles or scope disposal releases their attachments. */
private readonly detachedDrafts = new Map<number, DetachedDraft>()
/** Failed default sends waiting to be restored together in submission order. */
private readonly failedDetached = new Map<number, DetachedDraft>()
/** Revision of the last automatic failure restoration. */
private failedRestoreRev: number | undefined
private restoringFailures = false
private attachmentFlightSeq = 0
/** Attachment-only sends retained until admission settles or scope disposal releases their attachments. */
private readonly attachmentFlights = new Map<number, {
readonly controller: AbortController
readonly attachmentIds: readonly DraftAttachmentId[]
}>()
private readonly unsubscribeInbox: (() => void) | undefined
constructor(private readonly deps: SessionInputDeps) {
@@ -196,10 +185,6 @@ export class SessionInputShell implements SessionInput {
// caret motion and subscribers do not re-render per caret move.
if (projectionContentChanged(prev, this.projection)) {
this.rev += 1
if (!this.restoringFailures && this.failedRestoreRev !== undefined) {
this.failedDetached.clear()
this.failedRestoreRev = undefined
}
this.dispatchRun(({ type: 'draft-changed', command: this.projection.command }))
}
const caret = this.projection.caret
@@ -299,14 +284,12 @@ export class SessionInputShell implements SessionInput {
}
private draftInitializationBlocked(): boolean {
const { phase } = this.core.state
return this.disposed || phase === 'adjudicating' || phase === 'submitting'
|| this.detachedDrafts.size > 0 || this.attachmentFlights.size > 0
return this.disposed || frozen(this.core.state.phase)
}
/** Append ordered attachment ids unless an admission transaction is locked. */
addAttachments(ids: readonly DraftAttachmentId[]): boolean {
if (this.snapshot.phase === 'adjudicating' || this.snapshot.phase === 'submitting') return false
if (frozen(this.snapshot.phase)) return false
if (ids.length === 0) return true
this.attachmentIds = [...this.attachmentIds, ...ids]
this.publish()
@@ -320,7 +303,7 @@ export class SessionInputShell implements SessionInput {
* @returns false when admission is locked or the editor refuses the insertion.
*/
addFiles(references: readonly ReferenceInsert[], ids: readonly DraftAttachmentId[]): boolean {
if (this.snapshot.phase === 'adjudicating' || this.snapshot.phase === 'submitting') return false
if (frozen(this.snapshot.phase)) return false
if (!this.draftEditor.insertFileReferences(references)) return false
this.attachmentIds = [...this.attachmentIds, ...ids]
this.publish()
@@ -329,11 +312,11 @@ export class SessionInputShell implements SessionInput {
/**
* Remove one attachment id from this draft. Busy admission phases refuse, like
* {@link addAttachments}: a removal landing while a command submit serializes
* would otherwise vanish from the rail yet still ride the in-flight send.
* {@link addAttachments}: a removal landing while a send serializes would
* otherwise vanish from the rail yet still ride the in-flight send.
*/
removeAttachment(id: DraftAttachmentId): boolean {
if (this.snapshot.phase === 'adjudicating' || this.snapshot.phase === 'submitting') return false
if (frozen(this.snapshot.phase)) return false
const next = this.attachmentIds.filter(candidate => candidate !== id)
if (next.length === this.attachmentIds.length) return false
this.attachmentIds = next
@@ -353,18 +336,6 @@ export class SessionInputShell implements SessionInput {
this.publish()
}
/**
* Clear the draft as a successful-send commit: the editor empties (no undo
* unit) and the undo history is cut, so Ctrl/Cmd-Z cannot resurrect sent
* content (the command path gets the same discipline from submit-settled).
* @param attachmentIds - admitted attachment ids to remove from this draft.
*/
commitSend(attachmentIds: readonly DraftAttachmentId[]): void {
const submitted = new Set(attachmentIds)
this.attachmentIds = this.attachmentIds.filter(id => !submitted.has(id))
this.dispatchRun(({ type: 'send-committed' }))
}
/**
* Insert pasted plain text over the current editor selection
* (placeholder-sanitized). The paste event's own default is suppressed by
@@ -377,10 +348,11 @@ export class SessionInputShell implements SessionInput {
}
/**
* Enter adjudication + submit transaction + default sink. Effects fan out
* from the machine; this method only feeds the event. Lock entry
* (adjudicating/submitting) force-closes the transient layers: the popup
* dismisses and the menu tracks frozen.
* Enter adjudication + submit transaction + default sink with the draft, its
* reference chips, and its attachments as the user sent them. Effects fan
* out from the machine; this method only feeds the event. Lock entry
* (adjudicating/submitting/sending) force-closes the transient layers: the
* popup dismisses and the menu tracks frozen.
*/
submit(mode: InputSubmitMode = 'queue', source?: 'click' | 'enter'): void {
if (this.disposed) return
@@ -392,28 +364,6 @@ export class SessionInputShell implements SessionInput {
const submission: MessageSubmission = Object.freeze({
timestamp, mode, ...source === undefined ? {} : { source }, ...state === undefined ? {} : { state },
})
if (this.snapshot.draft.trim() === '' && this.attachmentIds.length > 0) {
if (this.snapshot.phase === 'plain') {
const attachmentIds = [...this.attachmentIds]
const controller = new AbortController()
this.attachmentFlightSeq += 1
const flight = this.attachmentFlightSeq
this.attachmentFlights.set(flight, { controller, attachmentIds })
this.commitSend(attachmentIds)
this.notifySubmission(submission)
void this.deps.defaultSink('', attachmentIds, mode, controller.signal).then((outcome) => {
if (this.disposed || !this.attachmentFlights.delete(flight)) return
if (outcome.kind === 'success') return
this.restoreAttachments(attachmentIds)
if (outcome.text !== undefined) this.notify('error', outcome.text)
}, (error: unknown) => {
if (this.disposed || !this.attachmentFlights.delete(flight)) return
this.restoreAttachments(attachmentIds)
this.notify('error', error instanceof Error ? error.message : String(error))
})
}
return
}
// Claimed pre-gate: a claim that does not declare attachment acceptance never
// submits while attachments are present — one notice, everything retained.
// Enter-time adjudication applies the same policy for unclaimed lines
@@ -423,9 +373,15 @@ export class SessionInputShell implements SessionInput {
this.notify('error', this.deps.commandAttachments.unsupportedNotice(before.claim?.token ?? before.draft))
return
}
this.dispatchRun({ type: 'enter', mode, draft: this.projection.clipboardText, submission })
const phase = this.snapshot.phase
if (phase === 'adjudicating' || phase === 'submitting') {
this.dispatchRun({
type: 'enter',
mode,
draft: this.projection.clipboardText,
occurrences: [...this.projection.occurrences],
attachmentIds: [...this.attachmentIds],
submission,
})
if (frozen(this.snapshot.phase)) {
this.deps.popup?.()?.dismiss()
this.deps.inputTriggers?.()?.track(this.projection.detectText, 0, { tier: 'frozen' }, this.rev)
}
@@ -597,29 +553,20 @@ export class SessionInputShell implements SessionInput {
// ---- wiring-layer extras (not on the frozen SessionInput face) ----
/**
* Teardown the shell and return every browser-owned attachment still retained by
* the draft or an unsettled default send.
* Teardown the shell, abort the in-flight attempt, and return every
* browser-owned attachment the draft still holds, including those of a
* message the Host has not accepted yet.
* @returns attachment ids the scope disposer must release.
*/
dispose(): readonly DraftAttachmentId[] {
if (this.disposed) return []
const retained = new Set(this.attachmentIds)
for (const record of this.detachedDrafts.values()) {
for (const attachmentId of record.attachmentIds) retained.add(attachmentId)
}
for (const flight of this.attachmentFlights.values()) {
for (const attachmentId of flight.attachmentIds) retained.add(attachmentId)
flight.controller.abort()
}
const retained = [...this.attachmentIds]
this.disposed = true
this.cancelCommandRestore()
this.dispatchRun(({ type: 'release' }))
this.unsubscribeInbox?.()
this.unregister()
this.detachedDrafts.clear()
this.failedDetached.clear()
this.attachmentFlights.clear()
return [...retained]
return retained
}
/** Read the live input state (guard derivation reads here). */
@@ -723,10 +670,11 @@ export class SessionInputShell implements SessionInput {
}
/**
* Prompt serialization before the sink: expand each chip occurrence to its
* owner's model form via the session controller's codec routing. Owner
* missing or serialization failure rejects the detached send and restores
* its editor snapshot. Chip-free drafts skip the async detour.
* Prompt serialization before the sink: expand each chip occurrence of the
* sent draft to its owner's model form via the session controller's codec
* routing, then send it with the attachments captured at Enter. Owner
* missing or serialization failure settles the send as failed, which keeps
* the draft. Chip-free drafts skip the async detour.
*/
private sinkSerialized(
attempt: SubmitAttempt,
@@ -734,15 +682,7 @@ export class SessionInputShell implements SessionInput {
mode: InputSubmitMode,
): void {
this.notifySubmission(attempt.submission)
const attachmentIds = [...this.attachmentIds]
this.attachmentIds = []
const occurrences = this.projection.occurrences
const record = { draft, occurrences, attachmentIds }
this.detachedDrafts.set(attempt.seq, record)
if (this.failedRestoreRev === this.rev) {
this.failedDetached.clear()
this.failedRestoreRev = undefined
}
const { occurrences, attachmentIds } = attempt
if (occurrences.length === 0) {
this.settleSink(attempt, this.deps.defaultSink(draft.trim(), attachmentIds, mode, attempt.signal))
return
@@ -757,7 +697,7 @@ export class SessionInputShell implements SessionInput {
}
})).then(
(parts) => {
if (this.disposed) return
if (this.dead(attempt)) return
// Splice model forms over their clipboard ranges (offsets are
// clipboard-projection; parts arrive offset-sorted since chips walk in
// document order).
@@ -772,8 +712,7 @@ export class SessionInputShell implements SessionInput {
},
(error: unknown) => {
if (this.dead(attempt)) return
const message = error instanceof Error ? error.message : String(error)
this.settleDetachedFailure(attempt, message)
this.dispatchRun(({ type: 'sink-settled', attempt, ok: false, message: errorText(error) }))
},
)
}
@@ -783,7 +722,12 @@ export class SessionInputShell implements SessionInput {
try { this.deps.messageSubmitted?.(submission) } catch (_error) { /* Notification consumers cannot interrupt submission. */ }
}
/** Settle one detached default send independently of other sends. */
/**
* Settle the message being sent. Once the Host accepts it, its attachments
* leave the draft before the editor clears; ConversationController releases
* them when the message's echo retires. Any other outcome keeps the draft
* and its attachments for correction.
*/
private settleSink(
attempt: SubmitAttempt,
pending: Promise<SubmitOutcome>,
@@ -791,68 +735,20 @@ export class SessionInputShell implements SessionInput {
pending.then(
(outcome) => {
if (this.dead(attempt)) return
if (outcome.kind !== 'success') {
this.settleDetachedFailure(attempt, outcome.text)
return
const accepted = outcome.kind === 'success'
if (accepted && attempt.attachmentIds.length > 0) {
const sent = new Set(attempt.attachmentIds)
this.attachmentIds = this.attachmentIds.filter(id => !sent.has(id))
}
this.detachedDrafts.delete(attempt.seq)
this.dispatchRun(({ type: 'sink-settled', attempt, ok: true, outcome }))
this.dispatchRun(({ type: 'sink-settled', attempt, ok: accepted, outcome }))
},
(error: unknown) => {
if (this.dead(attempt)) return
this.settleDetachedFailure(attempt, error instanceof Error ? error.message : String(error))
this.dispatchRun(({ type: 'sink-settled', attempt, ok: false, message: errorText(error) }))
},
)
}
/** Restore one failed detached send without overwriting text entered after a restoration. */
private settleDetachedFailure(attempt: SubmitAttempt, message?: string): void {
const record = this.detachedDrafts.get(attempt.seq)
if (record === undefined) return
this.detachedDrafts.delete(attempt.seq)
this.restoreAttachments(record.attachmentIds)
this.failedDetached.set(attempt.seq, record)
if (this.projection.clipboardText === '' || this.failedRestoreRev === this.rev) {
this.restoreFailedDrafts()
}
this.dispatchRun(({ type: 'sink-settled', attempt, ok: false, ...(message === undefined ? {} : { message }) }))
}
/** Rebuild all currently failed snapshots in submission order. */
private restoreFailedDrafts(): void {
const records = [...this.failedDetached.entries()].sort(([a], [b]) => a - b).map(([, record]) => record)
if (records.length === 0) return
const separator = '\n\n'
let draft = ''
const occurrences: Occurrence[] = []
for (const record of records) {
const base = draft.length + (draft === '' ? 0 : separator.length)
if (draft !== '') draft += separator
draft += record.draft
for (const occurrence of record.occurrences) {
occurrences.push({ ...occurrence, offset: base + occurrence.offset })
}
}
this.restoringFailures = true
try {
this.draftEditor.restoreDraft(draft, occurrences)
this.draftEditor.clearHistory()
this.failedRestoreRev = this.rev
} finally {
this.restoringFailures = false
}
}
/** Return failed-send attachments to the head of the rail; release happens only after success. */
private restoreAttachments(attachmentIds: readonly DraftAttachmentId[]): void {
if (attachmentIds.length === 0) return
const current = new Set(this.attachmentIds)
const restored = attachmentIds.filter(id => !current.has(id))
if (restored.length === 0) return
this.attachmentIds = [...restored, ...this.attachmentIds]
this.publish()
}
/** Enter adjudication: poll the session controller; failure = notice + draft retained (never a silent downgrade). */
private adjudicate(attempt: SubmitAttempt, draft: string): void {
const inputTriggers = this.deps.inputTriggers?.()
@@ -882,7 +778,7 @@ export class SessionInputShell implements SessionInput {
* for correction.
*/
private beginSubmit(attempt: SubmitAttempt, claim: CommandClaim, args: string): void {
const attachmentIds = claim.attachments === true ? [...this.attachmentIds] : []
const attachmentIds = claim.attachments === true ? attempt.attachmentIds : []
Promise.resolve()
.then(async () => {
const attachments = attachmentIds.length > 0 ? await this.deps.commandAttachments.serialize(attachmentIds) : []
@@ -225,10 +225,10 @@ export class InputHub implements SessionInputResolver {
}
/**
* Default sink: optimistic clear + prompt. The session is always a real
* host entity (materialized when its workspace was picked), so there is
* exactly one path; a failed first prompt is an ordinary prompt failure
* (banner via promptError, draft restored only while untouched).
* Default sink: prompt while the composer holds the sent draft. The session
* is always a real host entity (materialized when its workspace was picked),
* so there is exactly one path; a refused or failed prompt is an ordinary
* prompt failure (banner via promptError, draft kept in the composer).
*/
private sink(
session: SessionFace,
@@ -3,12 +3,16 @@
* Events in, effects out; zero React / DOM / cordis. Package-private; the
* SessionInput shell owns editor state and executes the returned effects.
*
* Claimed commands occupy the frozen in-flight slot. Ordinary messages detach
* at Enter, so the editor can clear immediately and accept another message
* while earlier admissions remain in flight.
* Claimed commands and ordinary messages occupy the one frozen in-flight
* slot. A message stays in the read-only editor until the Host accepts it;
* only then does the editor clear, and a refusal or failure returns it to
* editing unchanged.
*/
import type { InputSubmitMode, MessageSubmission } from '../contract/composer-submission.ts'
import type { CommandClaim, InputEffect, InputEvent, InputState, SubmitAttempt } from '../contract/input.ts'
import type { Occurrence } from '../contract/draft-editor.ts'
import type {
CommandClaim, DraftAttachmentId, InputEffect, InputEvent, InputState, SubmitAttempt,
} from '../contract/input.ts'
/** Exhaustiveness backstop for the closed InputEvent union. */
function unreachable(value: never): never {
@@ -42,8 +46,6 @@ export class SubmitMachine {
readonly attempt: SubmitAttempt
readonly controller: AbortController
} | undefined
/** Ordinary sends detached from the editor, retained for settlement validation and cancellation. */
private readonly detached = new Map<number, AbortController>()
/** Read-only snapshot of the submit-plane state. */
get state(): SubmitSnapshot {
@@ -72,12 +74,11 @@ export class SubmitMachine {
switch (ev.type) {
case 'draft-changed': return this.followCommand(ev.command)
case 'claim': return this.followCommand(ev.claim)
case 'enter': return this.onEnter(ev.mode, ev.draft, ev.submission)
case 'enter': return this.onEnter(ev)
case 'adjudicated': return this.onAdjudicated(ev.attempt, ev.outcome)
case 'adjudication-failed': return this.onAdjudicationFailed(ev.attempt, ev.message)
case 'submit-settled': return this.onSubmitSettled(ev)
case 'sink-settled': return this.onSinkSettled(ev)
case 'send-committed': return this.onSendCommitted()
case 'release': return this.onRelease()
default: return unreachable(ev)
}
@@ -97,58 +98,52 @@ export class SubmitMachine {
return []
}
/** Mint an attempt and controller without assigning its lifecycle owner. */
private mintAttempt(mode: InputSubmitMode, draft: string, submission?: MessageSubmission): {
readonly attempt: SubmitAttempt
readonly controller: AbortController
} {
/** Occupy the frozen slot with the draft, references, and attachments the user sent. */
private beginAttempt(
mode: InputSubmitMode,
draft: string,
occurrences: readonly Occurrence[],
attachmentIds: readonly DraftAttachmentId[],
submission?: MessageSubmission,
): SubmitAttempt {
const controller = new AbortController()
this.seq += 1
return {
attempt: { seq: this.seq, signal: controller.signal, draftSnapshot: draft, mode, ...submission === undefined ? {} : { submission } },
controller,
const attempt: SubmitAttempt = {
seq: this.seq,
signal: controller.signal,
draftSnapshot: draft,
occurrences,
attachmentIds,
mode,
...submission === undefined ? {} : { submission },
}
this.inflight = { attempt, controller }
return attempt
}
/** Mint the frozen command/adjudication attempt. */
private beginAttempt(mode: InputSubmitMode, draft: string, submission?: MessageSubmission): SubmitAttempt {
const flight = this.mintAttempt(mode, draft, submission)
this.inflight = flight
return flight.attempt
}
/** Mint an ordinary send that leaves the phase plain. */
private beginDetached(mode: InputSubmitMode, draft: string, submission?: MessageSubmission): SubmitAttempt {
const flight = this.mintAttempt(mode, draft, submission)
this.detached.set(flight.attempt.seq, flight.controller)
/** Send the frozen message; the editor stays read-only until the Host accepts it. */
private send(attempt: SubmitAttempt): readonly InputEffect[] {
this.claim = undefined
this.phase = 'plain'
return flight.attempt
this.phase = 'sending'
return [{ type: 'default-sink', attempt, draft: attempt.draftSnapshot, mode: attempt.mode }]
}
/** Default-send effects capture the sink input before the editor commit. */
private detachedEffects(attempt: SubmitAttempt): readonly InputEffect[] {
return [
{ type: 'default-sink', attempt, draft: attempt.draftSnapshot, mode: attempt.mode },
{ type: 'commit-draft', retainSuffixOf: attempt.draftSnapshot },
]
}
private onEnter(mode: InputSubmitMode, draft: string, submission?: MessageSubmission): readonly InputEffect[] {
if (this.phase === 'adjudicating' || this.phase === 'submitting') return []
private onEnter(ev: Extract<InputEvent, { type: 'enter' }>): readonly InputEffect[] {
if (this.phase === 'adjudicating' || this.phase === 'submitting' || this.phase === 'sending') return []
const { mode, draft, occurrences, attachmentIds, submission } = ev
if (this.phase === 'claimed' && this.claim !== undefined) {
const attempt = this.beginAttempt(mode, draft, submission)
const attempt = this.beginAttempt(mode, draft, occurrences, attachmentIds, submission)
this.phase = 'submitting'
return [{ type: 'begin-submit', attempt, claim: this.claim, args: argsAfter(draft, this.claim.token) }]
}
const trimmed = draft.trim()
if (trimmed === '') return []
if (trimmed === '' && attachmentIds.length === 0) return []
const attempt = this.beginAttempt(mode, draft, occurrences, attachmentIds, submission)
if (trimmed.startsWith('/')) {
const attempt = this.beginAttempt(mode, draft, submission)
this.phase = 'adjudicating'
return [{ type: 'adjudicate', attempt, draft }]
}
return this.detachedEffects(this.beginDetached(mode, draft, submission))
return this.send(attempt)
}
private onAdjudicated(
@@ -167,11 +162,10 @@ export class SubmitMachine {
args: argsAfter(attempt.draftSnapshot, outcome.claim.token),
}]
}
if (outcome === undefined) return this.send(attempt)
this.inflight = undefined
this.phase = 'plain'
if (outcome !== undefined) return []
this.detached.set(attempt.seq, flight.controller)
return this.detachedEffects(attempt)
return []
}
private onAdjudicationFailed(attempt: SubmitAttempt, message: string): readonly InputEffect[] {
@@ -201,19 +195,18 @@ export class SubmitMachine {
return text === undefined ? [] : [{ type: 'notice', level: 'error', text }]
}
/** Settle one ordinary send independently of current phase and other detached sends. */
/** An accepted message clears the editor; a refused or failed one returns to editing unchanged. */
private onSinkSettled(ev: Extract<InputEvent, { type: 'sink-settled' }>): readonly InputEffect[] {
if (!this.detached.delete(ev.attempt.seq)) return []
const flight = this.inflight
if (this.phase !== 'sending' || flight === undefined || flight.attempt.seq !== ev.attempt.seq) return []
this.inflight = undefined
this.phase = 'plain'
const effects: InputEffect[] = ev.ok ? [{ type: 'commit-draft', retainSuffixOf: null }] : []
const text = ev.message ?? ev.outcome?.text
if (text === undefined) return []
return [{ type: 'notice', level: ev.ok && ev.outcome?.kind !== 'error' ? 'info' : 'error', text }]
}
/** Clear after an accepted attachment-only send; it has no text suffix to retain. */
private onSendCommitted(): readonly InputEffect[] {
if (this.phase !== 'plain') return []
this.claim = undefined
return [{ type: 'commit-draft', retainSuffixOf: null }]
if (text !== undefined) {
effects.push({ type: 'notice', level: ev.ok && ev.outcome?.kind !== 'error' ? 'info' : 'error', text })
}
return effects
}
private onRelease(): readonly InputEffect[] {
@@ -221,8 +214,6 @@ export class SubmitMachine {
this.inflight.controller.abort()
this.inflight = undefined
}
for (const controller of this.detached.values()) controller.abort()
this.detached.clear()
this.phase = 'plain'
this.claim = undefined
return []
@@ -27,6 +27,7 @@ export const zh = {
'input.file': '文件',
'input.stop': '停止生成',
'input.send': '发送消息',
'input.sending': '正在发送消息',
'input.send.queue': '排队发送',
'input.send.steer': '插话发送',
'attachment.pending': '待发送附件',
@@ -407,6 +408,7 @@ export const en = {
'input.file': 'File',
'input.stop': 'Stop generating',
'input.send': 'Send message',
'input.sending': 'Sending message',
'input.send.queue': 'Queue message',
'input.send.steer': 'Steer message',
'attachment.pending': 'Pending attachments',
@@ -213,12 +213,15 @@ export class ConversationController extends Service implements IConversation {
}
/**
* Submit ordered draft attachments with text through one host admission. A local
* submission echo enters the session snapshot synchronously; serialization
* and the prompt round-trip start after the browser can paint it. On the
* echo's observed retirement seeds admitted image previews into the durable
* cache and removes every attachment from the draft registry. On failure,
* every attachment remains registered so the composer can restore it.
* Submit ordered draft attachments with text through one host admission. A
* local submission echo is registered first and enters the session snapshot
* once the Host accepts the prompt; serialization and the prompt round-trip
* start after the browser can paint the composer's sending state. The
* outcome is success as soon as the Host accepts the prompt. The echo's
* observed retirement seeds admitted image previews into the durable cache
* and removes every attachment from the draft registry; an accepted prompt
* whose echo retires unobserved also releases them. Until the Host accepts
* the prompt, every attachment remains registered for the composer.
* @param session - target session.
* @param text - serialized prompt text.
* @param attachmentIds - ordered draft-local attachment ids.
@@ -261,17 +264,16 @@ export class ConversationController extends Service implements IConversation {
? { type: 'image' as const, ...await this.encodeImage(attachment.file) }
: { type: 'file' as const, receiptId: uploadFor(attachment).receiptId }),
)
let finishRetirement: ((retirement: PendingSubmissionRetirement) => void) | undefined
const retirement = attachments.length === 0
? undefined
: new Promise<PendingSubmissionRetirement>((resolve) => { finishRetirement = resolve })
// The composer owns the attachments until the Host accepts the prompt.
const ownership = { accepted: false, retiredUnobserved: false }
const submission = session.beginSubmission({
mode,
text,
attachments: pendingAttachments,
onRetire: (settlement) => {
this.settleSubmittedAttachments(session.sessionId, attachments, settlement)
finishRetirement?.(settlement)
if (settlement.reason === 'observed') this.settleSubmittedAttachments(session.sessionId, attachments, settlement)
else if (ownership.accepted) this.releaseDraftAttachments(attachments)
else ownership.retiredUnobserved = true
},
})
let content: Parameters<SessionFace['prompt']>[0]
@@ -285,7 +287,9 @@ export class ConversationController extends Service implements IConversation {
}
const result = await session.prompt(content, mode, signal, submission.requestId)
if (!result.ok) return { kind: 'error' }
if (retirement !== undefined && (await retirement).reason !== 'observed') return { kind: 'error' }
ownership.accepted = true
// An accepted prompt whose echo already retired unobserved, as when its queue entry was removed.
if (ownership.retiredUnobserved) this.releaseDraftAttachments(attachments)
return { kind: 'success' }
}
@@ -535,19 +539,17 @@ export class ConversationController extends Service implements IConversation {
}
/**
* Settle one submission's draft attachments when its echo retires. Observed:
* each image leaves the registry, handing its preview URL to the durable
* image cache (seeded under the admitted reference so the transcript node
* renders immediately while the cache reads canonical bytes) or revoking it
* when the cache already holds that reference. Failed: nothing changes;
* the ids stay registered for the composer's rail restore.
* Settle one submission's draft attachments when its echo is observed: each
* file is released, and each image leaves the registry, handing its preview
* URL to the durable image cache (seeded under the admitted reference so the
* transcript node renders immediately while the cache reads canonical
* bytes) or revoking it when the cache already holds that reference.
*/
private settleSubmittedAttachments(
sessionId: SessionId,
attachments: readonly ComposerAttachment[],
retirement: PendingSubmissionRetirement,
retirement: Extract<PendingSubmissionRetirement, { readonly reason: 'observed' }>,
): void {
if (retirement.reason !== 'observed') return
const uiConversation = this.ctx.get('uiConversation')
let observedIndex = 0
for (const attachment of attachments) {
@@ -404,6 +404,27 @@
cursor: default;
}
/* Sending: the message waits for the Host to accept it. The circle keeps its
full fill rather than the disabled fade, and an arc turns where the arrow was. */
.primary[data-sending]:disabled {
opacity: 1;
cursor: progress;
}
.sendingSpinner {
animation: input-sending 800ms linear infinite;
}
@media (prefers-reduced-motion: reduce) {
.sendingSpinner {
animation-duration: 2400ms;
}
}
@keyframes input-sending {
to { transform: rotate(360deg); }
}
.retry {
margin-left: 8px;
padding: 1px 8px;
@@ -100,7 +100,8 @@ export const InputBar = memo(function InputBar({
const imageLimits = useProjection('imageLimits')
// Prompt failures are ordinary failures (no create/attach transaction exists
// anymore): the toast announces promptError, the draft stays in the machine,
// and the user resubmits. A remount over a session whose machine still holds
// and the user resubmits. A refusal whose plugin presents its own
// explanation adds no toast; another refusal shows the plugin's text. A remount over a session whose machine still holds
// an unresolved promptError deliberately re-announces it once — the failure
// is still pending, and a transient banner is its only surface. Attachment
// rejections show product copy keyed by the wire reason — whichever domain
@@ -113,6 +114,10 @@ export const InputBar = memo(function InputBar({
showToast(t('error.sessionInUse'))
return
}
if (error.code === 'session/prompt-rejected') {
if (!error.details.presented) showToast(error.message)
return
}
showToast(error.code === 'session/attachment-invalid' || error.code === 'subagent/attachment-invalid'
? attachmentErrorText(t, error.details.reason, imageLimits)
: `${error.message} (${error.code})`)
@@ -144,7 +149,9 @@ export const InputBar = memo(function InputBar({
// the composer asking for the only thing it prevents. The other reasons to
// be disabled do lock it — there is no session to choose a model for.
const modelSeatLocked = removed || inert || !live
const machineBusy = input?.phase === 'adjudicating' || input?.phase === 'submitting'
const machineBusy = input?.phase === 'adjudicating' || input?.phase === 'submitting' || input?.phase === 'sending'
// The message waits for the Host to accept it; Send shows progress instead of the arrow.
const sending = input?.phase === 'sending'
// The no-workspace surface remains the resident DOM node but acts as the
// existing picker trigger. Message controls stay locked until a Session
// exists; the trigger itself is read-only rather than disabled so pointer
@@ -182,6 +189,22 @@ export const InputBar = memo(function InputBar({
focusDraftEditor(editor, revealSelection)
}, [locked, sessionId, editor])
// A busy phase leaves the box read-only, and the browser then moves the
// keyboard to the document body. When the phase ends with the keyboard still
// there, return it, so the next message can be typed as soon as a send
// settles; a control the user moved to meanwhile keeps the keyboard.
const busyPhaseSeen = useRef(false)
useEffect(() => {
if (machineBusy) {
busyPhaseSeen.current = true
return
}
if (!busyPhaseSeen.current) return
busyPhaseSeen.current = false
if (locked || editor === null) return
if (document.activeElement === null || document.activeElement === document.body) focusDraftEditor(editor, revealSelection)
}, [machineBusy])
// A persisted draft arrives AFTER the unlock effect: DefaultConversationViews
// adopts it in its own mount effect, and a parent's mount effect runs after
// its children's. Reveal when the draft becomes non-empty so a restored long
@@ -328,9 +351,11 @@ export const InputBar = memo(function InputBar({
const plainMessageDraft = !empty && input?.phase === 'plain' && !draft.trimStart().startsWith('/')
const primaryLabel = primaryStops
? t('input.stop')
: running && steeringAvailable && !disabled && !uploadsPending && plainMessageDraft
? t(primarySubmitMode === 'steer' ? 'input.send.steer' : 'input.send.queue')
: t('input.send')
: sending
? t('input.sending')
: running && steeringAvailable && !disabled && !uploadsPending && plainMessageDraft
? t(primarySubmitMode === 'steer' ? 'input.send.steer' : 'input.send.queue')
: t('input.send')
const onPrimary = (): void => {
if (primaryStops) {
stop?.()
@@ -499,6 +524,8 @@ export const InputBar = memo(function InputBar({
type="button"
className={css.primary}
aria-label={primaryLabel}
aria-busy={sending}
data-sending={sending ? '' : undefined}
disabled={primaryDisabled}
onMouseDown={keepFocus}
onClick={onPrimary}
@@ -507,6 +534,11 @@ export const InputBar = memo(function InputBar({
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden>
<rect x="3" y="3" width="10" height="10" rx="3" fill="currentColor" />
</svg>
) : sending ? (
<svg className={css.sendingSpinner} viewBox="0 0 16 16" width="16" height="16" aria-hidden>
<circle cx="8" cy="8" r="6" fill="none" stroke="currentColor" strokeOpacity="0.35" strokeWidth="2" />
<path d="M8 2a6 6 0 0 1 6 6" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
) : (
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden>
<path d="M8.3125 0.980183C8.66767 1.0531 8.97902 1.20418 9.2627 1.43233C9.48724 1.61297 9.73029 1.85793 9.97949 2.10714L14.707 6.83468L13.293 8.24874L9 3.95577V15.0417H7V3.95577L2.70703 8.24874L1.29297 6.83468L6.02051 2.10714C6.26971 1.85793 6.51277 1.61297 6.7373 1.43233C6.97662 1.23986 7.28445 1.04402 7.6875 0.980183C7.8973 0.947006 8.1031 0.95516 8.3125 0.980183Z" fill="currentColor" />
@@ -346,14 +346,15 @@ describe('Conversation inject API', () => {
actions.setDraft('hello')
actions.submit()
// Optimistic commit clears the draft at enter; the prompt lands after the
// paint-yield inside the send pipeline.
expect(state.getSnapshot().draft).toBe('')
// The draft stays, read-only, until the Host accepts the prompt that
// follows the paint-yield inside the send pipeline.
expect(state.getSnapshot()).toMatchObject({ phase: 'sending', draft: 'hello' })
await vi.waitFor(() => {
expect(b.sessionFake.prompt).toHaveBeenCalledWith(
[{ type: 'text', text: 'hello' }], 'queue', expect.any(AbortSignal), expect.any(String),
)
})
await vi.waitFor(() => { expect(state.getSnapshot().draft).toBe('') })
b.sessionFake.prompt.mockResolvedValueOnce({
ok: false, error: new RemoteError('session/agent-busy', 'busy', { reason: 'busy' }),
@@ -567,26 +567,30 @@ describe('draft documents in the input shell', () => {
}
})
it.each(['detached default-send', 'attachment'] as const)(
'refuses draft initialization during a %s flight despite its editable phase', async (kind) => {
it.each(['message', 'attachment-only message'] as const)(
'refuses draft initialization and attachments while a %s waits for the Host', async (kind) => {
const outcome = Promise.withResolvers<SubmitOutcome>()
const started = Promise.withResolvers<undefined>()
const sink = vi.fn<SessionInputDeps['defaultSink']>(() => { started.resolve(undefined); return outcome.promise })
const shell = makeShell({ defaultSink: sink })
try {
const sentAttachment = 'sent-image' as DraftAttachmentId
if (kind === 'attachment') shell.addAttachments([sentAttachment])
const attachmentOnly = kind === 'attachment-only message'
if (attachmentOnly) shell.addAttachments([sentAttachment])
else shell.setDraft('outbound message')
shell.submit()
await started.promise
expect(sink).toHaveBeenCalledOnce()
expect(sink.mock.calls[0]?.[1]).toEqual(kind === 'attachment' ? [sentAttachment] : [])
shell.setDraft(documentOf('next message ', file))
shell.addAttachments(['next-image' as DraftAttachmentId])
expect(shell.snapshot.phase).toBe('plain')
expect(sink.mock.calls[0]?.[1]).toEqual(attachmentOnly ? [sentAttachment] : [])
expect(shell.snapshot.phase).toBe('sending')
expect(shell.addAttachments(['next-image' as DraftAttachmentId])).toBe(false)
expect(shell.removeAttachment(sentAttachment)).toBe(false)
expectInitializationBlocked(shell)
outcome.resolve({ kind: 'error', text: 'flight rejected' })
await vi.waitFor(() => { expect(shell.notices.getSnapshot()?.text).toBe('flight rejected') })
expect(shell.snapshot).toMatchObject({
phase: 'plain', draft: attachmentOnly ? '' : 'outbound message', attachmentIds: attachmentOnly ? [sentAttachment] : [],
})
expect(shell.requestDraftInitialization({ prompt: 'after settlement', clearPreviousDraft: true })).toBe('applied')
} finally {
shell.dispose()
@@ -637,7 +641,7 @@ describe('draft documents in the input shell', () => {
expect(chips(shell)).toMatchObject([{ ref: file.ref, appearance: 'file' }])
})
it('restores and persists failed structured sends in submission order after reversed settlement', async () => {
it('keeps a structured message and its persisted draft until the Host accepts a retry', async () => {
const first = Promise.withResolvers<SubmitOutcome>()
const second = Promise.withResolvers<SubmitOutcome>()
const provider = triggerProvider(createSnapshotStore<ReadonlyMap<'/' | '@', readonly string[]>>(new Map()))
@@ -646,24 +650,24 @@ describe('draft documents in the input shell', () => {
const shell = makeShell({ inputTriggers: () => provider, defaultSink: sink })
const write = vi.fn<(draft: DraftSnapshot) => void>()
shell.bindDraftPersistence(write)
const firstDraft = documentOf('first 🙂 ', file)
const secondDraft = documentOf('second ', session)
const draft = documentOf('first 🙂 ', file, ' and ', session)
try {
shell.setDraft(firstDraft)
shell.setDraft(draft)
shell.submit()
shell.setDraft(secondDraft)
await vi.waitFor(() => { expect(sink).toHaveBeenCalledTimes(1) })
expect(shell.draftSnapshot).toEqual(draft)
expect(write).toHaveBeenLastCalledWith(draft)
first.resolve({ kind: 'error', text: 'first rejected' })
await vi.waitFor(() => { expect(shell.snapshot.phase).toBe('plain') })
expect(shell.draftSnapshot).toEqual(draft)
expect(parseStoredDraft(JSON.parse(JSON.stringify(write.mock.calls.at(-1)?.[0])))).toEqual(draft)
expect(chips(shell)).toMatchObject([{ ref: file.ref }, { ref: session.ref }])
shell.submit()
await vi.waitFor(() => { expect(sink).toHaveBeenCalledTimes(2) })
expect(shell.draftSnapshot).toEqual({ text: '', references: [] })
second.resolve({ kind: 'error', text: 'second rejected' })
await vi.waitFor(() => { expect(shell.draftSnapshot).toEqual(secondDraft) })
expect(write).toHaveBeenLastCalledWith(secondDraft)
first.resolve({ kind: 'error', text: 'first rejected' })
const expected = documentOf('first 🙂 ', file, '\n\nsecond ', session)
await vi.waitFor(() => { expect(shell.draftSnapshot).toEqual(expected) })
expect(write).toHaveBeenLastCalledWith(expected)
expect(parseStoredDraft(JSON.parse(JSON.stringify(write.mock.calls.at(-1)?.[0])))).toEqual(expected)
expect(chips(shell)).toMatchObject([{ ref: file.ref }, { ref: session.ref }])
second.resolve({ kind: 'success' })
await vi.waitFor(() => { expect(shell.draftSnapshot).toEqual({ text: '', references: [] }) })
expect(write).toHaveBeenLastCalledWith({ text: '', references: [] })
expect(shell.requestDraftInitialization({ prompt: 'next', clearPreviousDraft: true })).toBe('applied')
} finally {
shell.dispose()
@@ -697,7 +701,7 @@ describe('draft documents in the input shell', () => {
shell.submit()
const signal = await started.promise
expect(signal.aborted).toBe(false)
expect(shell.snapshot.phase).toBe('plain')
expect(shell.snapshot.phase).toBe('sending')
expectInitializationBlocked(shell)
shell.bindDraftPersistence(write)
@@ -379,6 +379,44 @@ describe('composer focus handoff', () => {
// selection itself is asserted in the browser lane.
expect(lexicalFocus).toHaveBeenCalled()
})
it.each<[string, SubmitOutcome]>([
['the Host accepts it', { kind: 'success' }],
['it fails', { kind: 'error', text: 'send failed' }],
])('returns the keyboard to the editor once a sent message settles because %s', async (_label, outcome) => {
const { shell, textarea, sink } = bench()
writeDraft(shell, 'draft text')
act(() => { shell.focus() })
let settle!: (outcome: SubmitOutcome) => void
sink.mockImplementationOnce(() => new Promise<SubmitOutcome>((resolve) => { settle = resolve }))
fireEvent.keyDown(textarea, { key: 'Enter' })
expect(textarea.getAttribute('contenteditable')).toBe('false')
// A browser moves the keyboard to the body once the focused box stops being editable; jsdom does not.
act(() => { textarea.blur() })
expect(document.activeElement).toBe(document.body)
await act(async () => { settle(outcome) })
expect(textarea.getAttribute('contenteditable')).toBe('true')
expect(document.activeElement).toBe(textarea)
})
it('leaves the keyboard with a control the user moved to while a message was sending', async () => {
const { shell, textarea, sink } = bench()
const other = document.createElement('button')
document.body.appendChild(other)
onTestFinished(() => { other.remove() })
writeDraft(shell, 'draft text')
act(() => { shell.focus() })
let settle!: (outcome: SubmitOutcome) => void
sink.mockImplementationOnce(() => new Promise<SubmitOutcome>((resolve) => { settle = resolve }))
fireEvent.keyDown(textarea, { key: 'Enter' })
act(() => { other.focus() })
await act(async () => { settle({ kind: 'success' }) })
expect(document.activeElement).toBe(other)
})
})
describe('composer placeholder visibility', () => {
@@ -657,13 +695,18 @@ describe('image draft rail', () => {
sink.mockImplementationOnce(() => new Promise<SubmitOutcome>((resolve) => { settle = resolve }))
fireEvent.keyDown(textarea, { key: 'Enter' })
expect(sink).toHaveBeenCalledWith('', ['draft-1'], 'queue', expect.any(AbortSignal))
// Optimistic commit: the rail clears at submit, before the admission settles.
expect(attachmentOwner(result.slotCalls).attachments).toEqual([])
// The rail keeps the sent image, and Send shows progress, until the Host accepts the message.
expect(attachmentOwner(result.slotCalls).attachments).toEqual([attachments[0]])
const sending = view.getByRole('button', { name: '正在发送消息' }) as HTMLButtonElement
expect(sending.disabled).toBe(true)
expect(sending.getAttribute('aria-busy')).toBe('true')
expect(sending.hasAttribute('data-sending')).toBe(true)
await act(async () => { settle({ kind: 'success' }) })
expect(attachmentOwner(result.slotCalls).attachments).toEqual([])
expect(view.getByRole('button', { name: '发送消息' }).getAttribute('aria-busy')).toBe('false')
})
it('returns an image-only draft to the rail when its admission fails', async () => {
it('keeps an image-only draft in the rail when its admission fails', async () => {
const file = new File([Uint8Array.of(1)], 'pixel.png', { type: 'image/png' })
const attachments = [
{ kind: 'image' as const, id: 'draft-1' as DraftAttachmentId, file, previewUrl: 'blob:draft-1' },
@@ -673,7 +716,7 @@ describe('image draft rail', () => {
let fail!: (outcome: SubmitOutcome) => void
sink.mockImplementationOnce(() => new Promise<SubmitOutcome>((resolve) => { fail = resolve }))
fireEvent.keyDown(textarea, { key: 'Enter' })
expect(attachmentOwner(result.slotCalls).attachments).toEqual([])
expect(attachmentOwner(result.slotCalls).attachments).toEqual([attachments[0]])
await act(async () => { fail({ kind: 'error', text: '图片发送失败' }) })
await vi.waitFor(() => {
expect(attachmentOwner(result.slotCalls).attachments).toEqual([attachments[0]])
@@ -149,14 +149,15 @@ describe('matrix row: plain', () => {
expect(unsubscribe).toHaveBeenCalledOnce()
})
it('enter falls to the default sink; no claim on the currency; edits free', async () => {
it('enter falls to the default sink; no claim on the currency; the draft clears once accepted', async () => {
const { textarea, shell, sink } = bench()
act(() => { shell.setDraft('普通消息') })
expect(shell.snapshot.claim).toBeUndefined()
fireEvent.keyDown(textarea, { key: 'Enter' })
expect(sink).toHaveBeenCalledWith('普通消息', [], 'queue', expect.any(AbortSignal))
// The detached default send never freezes the composer.
expect(shell.snapshot.phase).toBe('plain')
// The composer holds the message, read-only, until the Host accepts it.
expect(shell.snapshot).toMatchObject({ phase: 'sending', draft: '普通消息' })
await vi.waitFor(() => { expect(shell.snapshot.phase).toBe('plain') })
expect(shell.snapshot.draft).toBe('')
expect(shell.snapshot.claim).toBeUndefined()
})
@@ -99,12 +99,10 @@ describe('reference submission', () => {
})
shell.submit('queue')
// Optimistic commit: the composer clears at enter and stays unlocked
// while the detached flight runs.
expect(shell.snapshot.phase).toBe('plain')
expect(shell.snapshot.draft).toBe('')
// The composer keeps the sent draft, read-only, until the Host answers.
expect(shell.snapshot).toMatchObject({ phase: 'sending', draft: `${mention} ` })
await vi.waitFor(() => {
expect(shell.snapshot.draft).toBe(`${mention} `)
expect(shell.snapshot.phase).toBe('plain')
})
expect(sink).toHaveBeenNthCalledWith(1, mention, [], 'queue', expect.any(AbortSignal))
expect(shell.snapshot).toMatchObject({
@@ -117,10 +115,11 @@ describe('reference submission', () => {
})
shell.submit('queue')
expect(shell.snapshot.draft).toBe('')
expect(shell.snapshot).toMatchObject({ phase: 'sending', draft: `${mention} ` })
await vi.waitFor(() => {
expect(sink).toHaveBeenNthCalledWith(2, mention, [], 'queue', expect.any(AbortSignal))
expect(shell.snapshot.draft).toBe('')
})
expect(sink).toHaveBeenNthCalledWith(2, mention, [], 'queue', expect.any(AbortSignal))
expect(shell.snapshot.occurrences).toEqual([])
expect(serializeReference).toHaveBeenCalledTimes(2)
})
@@ -140,10 +139,11 @@ describe('reference submission', () => {
})
chip(shell)
shell.submit()
// The serializer rejection restores the optimistic commit with its chip.
// The serializer rejection settles the send as failed; the draft never left.
await vi.waitFor(() => {
expect(shell.snapshot.draft).toBe(`${mention} `)
expect(shell.snapshot.phase).toBe('plain')
})
expect(shell.snapshot.draft).toBe(`${mention} `)
expect(sink).not.toHaveBeenCalled()
expect(shell.snapshot.occurrences).toHaveLength(1)
expect(shell.notices.getSnapshot()).toMatchObject({
@@ -168,9 +168,8 @@ describe('reference submission', () => {
shell.dispose()
expect(signal?.aborted).toBe(true)
expect(shell.snapshot.phase).toBe('plain')
// The optimistic commit stands: disposal drops the settlement, so the
// sent draft is not restored into the dying composer.
expect(shell.snapshot.draft).toBe('')
// The Host never accepted the message, so the composer still holds it.
expect(shell.snapshot.draft).toBe('send this')
})
it('retains a rejected default message without duplicating its prompt error notice', async () => {
@@ -188,23 +187,24 @@ describe('reference submission', () => {
expect(shell.notices.getSnapshot()).toBeNull()
})
it('restores concurrent failed messages in submission order', async () => {
it('sends one message at a time and ignores Enter while the Host decides', async () => {
const settlements: Array<(outcome: SubmitOutcome) => void> = []
const sink = vi.fn(() => new Promise<SubmitOutcome>((resolve) => { settlements.push(resolve) }))
const shell = new SessionInputShell({
actx: {} as Context,
defaultSink: () => new Promise<SubmitOutcome>((resolve) => { settlements.push(resolve) }),
defaultSink: sink,
commandAttachments,
})
shell.setDraft('first')
shell.submit()
shell.setDraft('second')
shell.submit()
expect(shell.snapshot.draft).toBe('')
expect(sink).toHaveBeenCalledTimes(1)
expect(shell.snapshot).toMatchObject({ phase: 'sending', draft: 'first' })
expect(shell.requestDraftInitialization({ prompt: 'replacement', clearPreviousDraft: true })).toBe('blocked')
settlements[0]?.({ kind: 'error' })
await vi.waitFor(() => { expect(shell.snapshot.draft).toBe('first') })
settlements[1]?.({ kind: 'error' })
await vi.waitFor(() => { expect(shell.snapshot.draft).toBe('first\n\nsecond') })
await vi.waitFor(() => { expect(shell.snapshot.phase).toBe('plain') })
expect(shell.snapshot.draft).toBe('first')
})
})
@@ -356,7 +356,7 @@ it.each(['handled', 'claim', 'message'] as const)('counts only a message after a
} finally { shell.dispose() }
})
it('retains occurrence time and Session facts across arbitration and independent failed sends', async () => {
it('retains occurrence time and Session facts across arbitration and failed sends', async () => {
const pending = Promise.withResolvers<PickOutcome>()
const submitted = vi.fn()
const firstSend = Promise.withResolvers<SubmitOutcome>()
@@ -387,13 +387,14 @@ it('retains occurrence time and Session facts across arbitration and independent
pending.resolve(undefined)
await vi.waitFor(() => { expect(submitted).toHaveBeenCalledOnce() })
expect(submitted).toHaveBeenLastCalledWith({ timestamp: 100, source: 'click', mode: 'steer', state: { running: true, runMode: 'plan' } })
firstSend.resolve({ kind: 'error', text: 'first rejected' })
await vi.waitFor(() => { expect(shell.snapshot.phase).toBe('plain') })
shell.setDraft('second')
shell.actions.submit()
expect(submitted).toHaveBeenLastCalledWith({ timestamp: 200, mode: 'queue', state: { running: false, runMode: 'default' } })
await vi.waitFor(() => { expect(sink).toHaveBeenCalledTimes(2) })
secondSend.resolve({ kind: 'error', text: 'second rejected' })
firstSend.resolve({ kind: 'error', text: 'first rejected' })
await Promise.all([firstSend.promise, secondSend.promise])
await vi.waitFor(() => { expect(shell.snapshot.phase).toBe('plain') })
expect(submitted).toHaveBeenCalledTimes(2)
} finally {
firstSend.resolve({ kind: 'success' })
@@ -149,7 +149,7 @@ describe('ConversationController', () => {
if (attachment === undefined) throw new Error('draft attachment missing')
b.shell.addAttachments([attachment.id])
b.shell.submit()
expect(b.shell.snapshot.attachmentIds).toEqual([])
expect(b.shell.snapshot).toMatchObject({ phase: 'sending', attachmentIds: [attachment.id] })
await b.runtime.sessions.disposeScopes()
expect(b.root.resolveDraftAttachments([attachment.id])).toEqual([])
expect(revoked).toHaveBeenCalledWith('blob:detached')
@@ -7,10 +7,14 @@
*/
import { describe, expect, it } from 'vitest'
import type { CommandClaim } from '../src/client/contract/input.ts'
import type { InputEffect, SubmitAttempt } from '../src/client/contract/input.ts'
import type { DraftAttachmentId, InputEffect, SubmitAttempt } from '../src/client/contract/input.ts'
import { SubmitMachine } from '../src/client/input/machine.ts'
import { scanTextRefs } from '../src/client/input/decorations.ts'
function attachmentId(id: string): DraftAttachmentId {
return id as DraftAttachmentId
}
function claimOf(name: string, hint?: string): CommandClaim {
return {
name,
@@ -30,7 +34,7 @@ function effectAt<T extends InputEffect['type']>(
/** Drive plain → adjudicating and hand back the minted attempt. */
function enterAdjudicating(m: SubmitMachine, draft: string, mode: 'queue' | 'steer' = 'queue'): SubmitAttempt {
const fx = m.dispatch({ type: 'enter', mode, draft })
const fx = m.dispatch({ type: 'enter', mode, draft, occurrences: [], attachmentIds: [] })
return effectAt(fx, 0, 'adjudicate').attempt
}
@@ -38,42 +42,76 @@ function enterAdjudicating(m: SubmitMachine, draft: string, mode: 'queue' | 'ste
function enterSubmitting(m: SubmitMachine, name: string, args: string): { attempt: SubmitAttempt; claim: CommandClaim } {
const claim = claimOf(name)
m.dispatch({ type: 'claim', claim })
const fx = m.dispatch({ type: 'enter', mode: 'queue', draft: claim.token + args })
const fx = m.dispatch({ type: 'enter', mode: 'queue', draft: claim.token + args, occurrences: [], attachmentIds: [] })
return { attempt: effectAt(fx, 0, 'begin-submit').attempt, claim }
}
function staleAttempt(): SubmitAttempt {
return { seq: 9999, signal: new AbortController().signal, draftSnapshot: '', mode: 'queue' }
return {
seq: 9999, signal: new AbortController().signal, draftSnapshot: '', occurrences: [], attachmentIds: [], mode: 'queue',
}
}
describe('submit-machine: plain × enter', () => {
it('empty and whitespace-only drafts produce nothing', () => {
const m = new SubmitMachine()
expect(m.dispatch({ type: 'enter', mode: 'queue', draft: '' })).toEqual([])
expect(m.dispatch({ type: 'enter', mode: 'queue', draft: ' \n ' })).toEqual([])
expect(m.dispatch({ type: 'enter', mode: 'queue', draft: '', occurrences: [], attachmentIds: [] })).toEqual([])
expect(m.dispatch({ type: 'enter', mode: 'queue', draft: ' \n ', occurrences: [], attachmentIds: [] })).toEqual([])
expect(m.state.phase).toBe('plain')
})
it('non-command text falls to the default sink with the draft and mode', () => {
it('non-command text is sent with the draft and mode and stays until the Host accepts it', () => {
const m = new SubmitMachine()
const fx = m.dispatch({ type: 'enter', mode: 'queue', draft: 'hello' })
const attachmentIds = [attachmentId('image-1')]
const fx = m.dispatch({ type: 'enter', mode: 'queue', draft: 'hello', occurrences: [], attachmentIds })
expect(fx).toHaveLength(1)
const sink = effectAt(fx, 0, 'default-sink')
expect(sink.draft).toBe('hello')
expect(sink.mode).toBe('queue')
expect(sink.attempt.draftSnapshot).toBe('hello')
expect(effectAt(fx, 1, 'commit-draft').retainSuffixOf).toBe('hello')
expect(sink.attempt).toMatchObject({ draftSnapshot: 'hello', attachmentIds })
expect(m.state.phase).toBe('sending')
expect(m.dispatch({ type: 'enter', mode: 'queue', draft: 'again', occurrences: [], attachmentIds: [] })).toEqual([])
const accepted = m.dispatch({ type: 'sink-settled', attempt: sink.attempt, ok: true, outcome: { kind: 'success' } })
expect(effectAt(accepted, 0, 'commit-draft').retainSuffixOf).toBeNull()
expect(m.state.phase).toBe('plain')
})
it('returns a refused or failed message to editing unchanged with its notice', () => {
const m = new SubmitMachine()
const refused = effectAt(m.dispatch({ type: 'enter', mode: 'queue', draft: 'kept', occurrences: [], attachmentIds: [] }), 0, 'default-sink')
expect(m.dispatch({ type: 'sink-settled', attempt: refused.attempt, ok: false, outcome: { kind: 'error' } })).toEqual([])
expect(m.state.phase).toBe('plain')
const failed = effectAt(m.dispatch({ type: 'enter', mode: 'queue', draft: 'kept', occurrences: [], attachmentIds: [] }), 0, 'default-sink')
expect(m.dispatch({ type: 'sink-settled', attempt: failed.attempt, ok: false, message: 'offline' }))
.toEqual([{ type: 'notice', level: 'error', text: 'offline' }])
expect(m.state.phase).toBe('plain')
const informed = effectAt(m.dispatch({ type: 'enter', mode: 'queue', draft: 'sent', occurrences: [], attachmentIds: [] }), 0, 'default-sink')
expect(m.dispatch({
type: 'sink-settled', attempt: informed.attempt, ok: true, outcome: { kind: 'success', text: 'queued' },
})).toEqual([{ type: 'commit-draft', retainSuffixOf: null }, { type: 'notice', level: 'info', text: 'queued' }])
expect(m.dispatch({ type: 'sink-settled', attempt: informed.attempt, ok: true })).toEqual([])
})
it('sends an attachment-only draft as a message', () => {
const m = new SubmitMachine()
const attachmentIds = [attachmentId('image-only')]
const fx = m.dispatch({ type: 'enter', mode: 'queue', draft: ' ', occurrences: [], attachmentIds })
expect(effectAt(fx, 0, 'default-sink').attempt.attachmentIds).toEqual(attachmentIds)
expect(m.state.phase).toBe('sending')
})
it('retains an explicit steer mode on the default sink effect', () => {
const m = new SubmitMachine()
const fx = m.dispatch({ type: 'enter', mode: 'steer', draft: 'go' })
const fx = m.dispatch({ type: 'enter', mode: 'steer', draft: 'go', occurrences: [], attachmentIds: [] })
expect(effectAt(fx, 0, 'default-sink').mode).toBe('steer')
})
it('leading "/" enters adjudicating with a minted attempt carrying the draft snapshot', () => {
const m = new SubmitMachine()
const fx = m.dispatch({ type: 'enter', mode: 'queue', draft: '/goal write tests' })
const fx = m.dispatch({ type: 'enter', mode: 'queue', draft: '/goal write tests', occurrences: [], attachmentIds: [] })
const adjudicate = effectAt(fx, 0, 'adjudicate')
expect(adjudicate.draft).toBe('/goal write tests')
expect(adjudicate.attempt.draftSnapshot).toBe('/goal write tests')
@@ -83,13 +121,13 @@ describe('submit-machine: plain × enter', () => {
it('leading is judged after trim including newlines', () => {
const m = new SubmitMachine()
const fx = m.dispatch({ type: 'enter', mode: 'queue', draft: ' \n /goal x' })
const fx = m.dispatch({ type: 'enter', mode: 'queue', draft: ' \n /goal x', occurrences: [], attachmentIds: [] })
expect(effectAt(fx, 0, 'adjudicate').draft).toBe(' \n /goal x')
})
it('a non-whitespace prefix before "/" is not leading — default sink', () => {
const m = new SubmitMachine()
const fx = m.dispatch({ type: 'enter', mode: 'queue', draft: 'see /goal' })
const fx = m.dispatch({ type: 'enter', mode: 'queue', draft: 'see /goal', occurrences: [], attachmentIds: [] })
expect(effectAt(fx, 0, 'default-sink').draft).toBe('see /goal')
})
})
@@ -122,10 +160,11 @@ describe('submit-machine: adjudication outcomes', () => {
const attempt = enterAdjudicating(m, '/unknown thing', 'steer')
const fx = m.dispatch({ type: 'adjudicated', attempt, outcome: undefined })
const sink = effectAt(fx, 0, 'default-sink')
expect(fx).toHaveLength(1)
expect(sink.draft).toBe('/unknown thing')
expect(sink.mode).toBe('steer')
expect(effectAt(fx, 1, 'commit-draft').retainSuffixOf).toBe('/unknown thing')
expect(m.state.phase).toBe('plain')
expect(sink.attempt).toBe(attempt)
expect(m.state.phase).toBe('sending')
})
it("'handled' lands plain with zero effects (popup shell path)", () => {
@@ -146,7 +185,7 @@ describe('submit-machine: adjudication outcomes', () => {
it('enter is a no-op while adjudicating (pending lock)', () => {
const m = new SubmitMachine()
enterAdjudicating(m, '/goal x')
expect(m.dispatch({ type: 'enter', mode: 'queue', draft: '/goal x' })).toEqual([])
expect(m.dispatch({ type: 'enter', mode: 'queue', draft: '/goal x', occurrences: [], attachmentIds: [] })).toEqual([])
expect(m.state.phase).toBe('adjudicating')
})
@@ -218,7 +257,7 @@ describe('submit-machine: claimed lifecycle', () => {
const m = new SubmitMachine()
const claim = { ...claimOf(name), token }
m.dispatch({ type: 'claim', claim })
const fx = m.dispatch({ type: 'enter', mode: 'queue', draft: token.trimEnd() })
const fx = m.dispatch({ type: 'enter', mode: 'queue', draft: token.trimEnd(), occurrences: [], attachmentIds: [] })
const begin = effectAt(fx, 0, 'begin-submit')
expect(begin.args).toBe('')
m.dispatch({ type: 'submit-settled', attempt: begin.attempt, ok: false, command: claim })
@@ -229,7 +268,7 @@ describe('submit-machine: claimed lifecycle', () => {
it('reads arguments typed directly against the chip without a separator', () => {
const m = new SubmitMachine()
m.dispatch({ type: 'claim', claim: { ...claimOf('goal'), token: '/目标 ' } })
const fx = m.dispatch({ type: 'enter', mode: 'queue', draft: '/目标发布' })
const fx = m.dispatch({ type: 'enter', mode: 'queue', draft: '/目标发布', occurrences: [], attachmentIds: [] })
expect(effectAt(fx, 0, 'begin-submit').args).toBe('发布')
})
@@ -244,7 +283,7 @@ describe('submit-machine: claimed lifecycle', () => {
it('enter begins the submit transaction: args = draft minus token, multi-line legal', () => {
const m = new SubmitMachine()
m.dispatch({ type: 'claim', claim: claimOf('goal') })
const fx = m.dispatch({ type: 'enter', mode: 'queue', draft: '/goal line one\nline two' })
const fx = m.dispatch({ type: 'enter', mode: 'queue', draft: '/goal line one\nline two', occurrences: [], attachmentIds: [] })
expect(effectAt(fx, 0, 'begin-submit').args).toBe('line one\nline two')
})
})
@@ -253,7 +292,7 @@ describe('submit-machine: submitting transaction', () => {
it('enter and claim are locked while submitting; draft-changed is recorded without leaving submitting', () => {
const m = new SubmitMachine()
enterSubmitting(m, 'goal', 'x')
expect(m.dispatch({ type: 'enter', mode: 'queue', draft: '/goal x' })).toEqual([])
expect(m.dispatch({ type: 'enter', mode: 'queue', draft: '/goal x', occurrences: [], attachmentIds: [] })).toEqual([])
m.dispatch({ type: 'draft-changed', command: undefined })
expect(m.state.phase).toBe('submitting')
})
@@ -313,7 +352,7 @@ describe('submit-machine: submitting transaction', () => {
const m = new SubmitMachine()
const { attempt: first, claim } = enterSubmitting(m, 'goal', 'x')
m.dispatch({ type: 'submit-settled', attempt: first, ok: false, command: claim, message: 'try again' })
const fx = m.dispatch({ type: 'enter', mode: 'queue', draft: '/goal x' })
const fx = m.dispatch({ type: 'enter', mode: 'queue', draft: '/goal x', occurrences: [], attachmentIds: [] })
const second = effectAt(fx, 0, 'begin-submit').attempt
expect(m.dispatch({ type: 'submit-settled', attempt: first, ok: true, command: claim })).toEqual([])
expect(m.state.phase).toBe('submitting')
@@ -330,13 +369,13 @@ describe('submit-machine: submitting transaction', () => {
expect(m.state.phase).toBe('plain')
})
it('send-committed clears unconditionally (image-only sends have no draft to retain)', () => {
it('drops a settlement for a message the composer released', () => {
const m = new SubmitMachine()
const fx = m.dispatch({ type: 'send-committed' })
expect(effectAt(fx, 0, 'commit-draft').retainSuffixOf).toBeNull()
const busy = new SubmitMachine()
enterSubmitting(busy, 'goal', 'x')
expect(busy.dispatch({ type: 'send-committed' })).toEqual([])
const sink = effectAt(m.dispatch({ type: 'enter', mode: 'queue', draft: 'gone', occurrences: [], attachmentIds: [] }), 0, 'default-sink')
m.dispatch({ type: 'release' })
expect(sink.attempt.signal.aborted).toBe(true)
expect(m.dispatch({ type: 'sink-settled', attempt: sink.attempt, ok: true })).toEqual([])
expect(m.state.phase).toBe('plain')
})
})
@@ -345,11 +384,11 @@ describe('submit-machine: per-session isolation', () => {
const a = new SubmitMachine()
const b = new SubmitMachine()
const { attempt } = enterSubmitting(a, 'goal', 'x')
const fx = b.dispatch({ type: 'enter', mode: 'queue', draft: 'hello' })
const fx = b.dispatch({ type: 'enter', mode: 'queue', draft: 'hello', occurrences: [], attachmentIds: [] })
expect(effectAt(fx, 0, 'default-sink').draft).toBe('hello')
a.dispatch({ type: 'submit-settled', attempt, ok: true, command: undefined })
expect(a.state.phase).toBe('plain')
expect(b.state.phase).toBe('plain')
expect(b.state.phase).toBe('sending')
})
})
@@ -1,3 +1,3 @@
echo: Use the bash tool to run exactly: echo WEB_E2E_OK. Then reply with the single word DONE and stop.
composer: ""
contenteditable: true
echo: (missing)
composer: "Use the bash tool to run exactly: echo WEB_E2E_OK. Then reply with the single word DONE and stop."
contenteditable: false
@@ -11,15 +11,9 @@
- paragraph: partial
- status: Deep diving
- text: Deep diving for {{duration}} ···
- list:
- listitem:
- text: Queue item to remove
- status: Sending…
- button "Edit queued message" [disabled]
- button "Remove queued message" [disabled]
- button "Steer queued message" [disabled]
- textbox "Message or run a task, / commands, @ files or sessions"
- textbox "Message or run a task, / commands, @ files or sessions":
- paragraph: Queue item to remove
- button "Add files or run commands"
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current DeepSeek-V4-Flash": DeepSeek-V4-Flash
- button "Stop generating"
- button "Sending message" [disabled]
+1
View File
@@ -157,6 +157,7 @@
"apps/web/tests/markdown-inline-code-links.e2e.ts",
"apps/web/tests/clickable-links-gallery.e2e.ts",
"apps/web/tests/queue-actions.e2e.ts",
"apps/web/tests/prompt-admission.e2e.ts",
"apps/web/tests/idle-submission-handoff.e2e.ts",
"apps/web/tests/queue-image.e2e.ts",
"apps/web/tests/skill-invocation-policy.e2e.ts",