Merge latest master into PR 555

# Conflicts:
#	.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml
#	.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md
#	.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md
#	packages/client/ui-conversation/README.i18n.yaml
#	packages/client/ui-conversation/src/client/chat/MessageItem.tsx
#	packages/client/ui-conversation/src/client/input/facade.ts
#	packages/client/ui-conversation/src/client/input/hub.ts
#	packages/client/ui-conversation/src/client/service.ts
#	packages/client/ui-conversation/tests/apply-inject.spec.tsx
#	packages/client/ui-conversation/tests/input-bar.spec.tsx
#	packages/client/ui-conversation/tests/input-matrix.spec.tsx
#	packages/client/ui-conversation/tests/input-scenarios.spec.tsx
#	packages/client/ui-conversation/tests/skeleton.spec.tsx
This commit is contained in:
creatixchu
2026-07-31 20:11:28 +08:00
28 changed files with 236 additions and 175 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
README.md: 2dafe3c69bb087550f6c0f7faab12bff1f1324ac
README.zh.md: 4201441c6c6397b7b35fe0fab99fc6fc6271719f
README.md: c36e2768bb1562b2e7ec65973f23267a915eb505
README.zh.md: f20556bf0031a58ee46e14beec50dbc7ff7dc8e4

View File

@@ -63,4 +63,4 @@ None; this package neither assembles nor sends a provider request.
- **The approval panel's "Always allow this type" is deferred** — durable grants need a grant-storage design; only allow-once/reject answer today.
- **TodoPanel truncates long item text to one ellipsized line** — the figma strip has no wrap or expand affordance; full text is not readable inline.
- **Queue edit is text-only** — rows containing non-text blocks still show a flattened preview, but their edit control is disabled because the inline editor cannot preserve those blocks. A text row's edit mode replaces delete with save and cancel; Enter saves and Escape cancels. QueueDock exposes no send-now control.
- **Web exposes pending Queue only** — the Host omits pending steering from the Queue snapshot until steering has its own interaction. A consumed `steering/message` still renders in the durable transcript so external steering remains truthful on replay.
- **Web exposes pending Queue only** — the composer and `conversation.send` never submit `mode:'steer'`. The Host omits pending steering from the Queue snapshot. A consumed `steering/message` still folds into the durable transcript as a plain bubble (no interjection chrome) so external/host steering remains truthful on replay.

View File

@@ -63,4 +63,4 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插
- **审批面板的「始终允许此类」暂缓**:持久授权需要授权存储设计;今天只能回答允许一次/拒绝。
- **TodoPanel 将过长条目截成单行省略号**figma 条没有换行或展开入口,完整文本无法在行内读完。
- **Queue 编辑仅支持文本**包含非文本块的行仍显示扁平化预览但由于内联编辑器无法保留这些块其编辑控件会被禁用。文本行进入编辑模式后删除会替换为保存和取消Enter 保存Escape 取消。QueueDock 不提供立即发送控件。
- **Web 仅暴露待处理 Queue**在 steering中途引导拥有专用交互之前Host 不会把待处理 steering 纳入 Queue 快照。已消费的 `steering/message` 仍会渲染到持久 transcript 中,因此从外部提交的 steering 在回放时仍能如实呈现。
- **Web 仅暴露待处理 Queue**composer 与 `conversation.send` 从不提交 `mode:'steer'`Host 不会把待处理 steering(中途引导)纳入 Queue 快照。已消费的 `steering/message` 仍会折叠进持久 transcript文本记录并以无「插话」徽章的普通气泡呈现因此从外部Host 提交的 steering 在回放时仍能如实呈现。

View File

@@ -29,16 +29,6 @@
color: var(--dsw-alias-label-primary);
}
.badge {
display: inline-block;
margin-bottom: 4px;
padding: 1px 6px;
border-radius: 6px;
background: var(--dsw-alias-state-warn-primary);
color: var(--dsw-alias-label-primary-foreground);
font-size: 11px;
}
.contextRow {
padding: 2px 0;
}

View File

@@ -1,6 +1,7 @@
// MessageItem: simple chat nodes — user bubble (right-aligned, with
// clock + copy / branch IconActions), steering (badged bubble), context
// injection, compaction marker, retry disclosure, and unknown-surface JSON rows.
// clock + copy / branch IconActions), steering (same bubble, no actions),
// context injection, compaction marker, retry disclosure, and
// unknown-surface JSON rows.
import { memo, useEffect, useMemo, useState } from 'react'
import type { ReactNode } from 'react'
@@ -178,25 +179,31 @@ function projectUserText(text: string): ReactNode {
return <>{parts}</>
}
function UserContentStack({ parts, imageLoader, steering = false, t, truncated }: {
parts: ReturnType<typeof contentParts>
/** Right-aligned bubble shared by user and steering rows (steering has no actions). */
function UserStyleBubble({
content, imageLoader, actions, t,
}: {
content: readonly unknown[]
imageLoader: ImageLoader
steering?: boolean
/** Optional IconActions (or similar) below the bubble; receives the joined text. */
actions?: (text: string) => ReactNode
t: ChatViewSlotProps['t']
truncated: (total: number) => string
}) {
const { text, images, rest } = parts
const showBubble = steering || text !== '' || rest.length > 0
}): ReactNode {
const { text, images, rest } = contentParts(content)
const truncated = (total: number): string => t('json.truncated', { total })
const showBubble = text !== '' || rest.length > 0
return (
<div className={css.userStack}>
<ImageGallery images={images} load={imageLoader} align="end" t={t} />
{showBubble && <div className={css.bubble}>
{steering && <span className={css.badge}>{t('message.steering')}</span>}
{projectUserText(text)}
{rest.map((block, i) => (
<JsonBlock key={i} label={t('message.extraBlock')} payload={block} truncatedLabel={truncated} />
))}
</div>}
<div className={css.userRow}>
<div className={css.userStack}>
<ImageGallery images={images} load={imageLoader} align="end" t={t} />
{showBubble && <div className={css.bubble}>
{projectUserText(text)}
{rest.map((block, i) => (
<JsonBlock key={i} label={t('message.extraBlock')} payload={block} truncatedLabel={truncated} />
))}
</div>}
</div>
{actions?.(text)}
</div>
)
}
@@ -207,30 +214,26 @@ export const MessageItem = memo(function MessageItem({
const imageLoader = loadImage ?? (() => Promise.reject(new Error(t('image.serviceUnavailable'))))
const truncated = (total: number): string => t('json.truncated', { total })
switch (node.kind) {
case 'user': {
const parts = contentParts(node.content)
case 'user':
return (
<div className={css.userRow}>
<UserContentStack parts={parts} imageLoader={imageLoader} t={t} truncated={truncated} />
<MessageIconActions
text={parts.text}
time={node.time}
clock="start"
onBranch={onFork === undefined ? undefined : () => { onFork(node.seq) }}
className={css.actions}
t={t}
/>
</div>
<UserStyleBubble
content={node.content}
imageLoader={imageLoader}
t={t}
actions={text => (
<MessageIconActions
text={text}
time={node.time}
clock="start"
onBranch={onFork === undefined ? undefined : () => { onFork(node.seq) }}
className={css.actions}
t={t}
/>
)}
/>
)
}
case 'steering': {
const parts = contentParts(node.content)
return (
<div className={css.userRow}>
<UserContentStack parts={parts} imageLoader={imageLoader} steering t={t} truncated={truncated} />
</div>
)
}
case 'steering':
return <UserStyleBubble content={node.content} imageLoader={imageLoader} t={t} />
case 'context':
return (
<ContextInjectionRow content={node.content} source={node.source} t={t} />

View File

@@ -43,7 +43,7 @@ export interface SessionInput extends InputTarget {
/** Drop ids whose browser objects no longer exist. */
pruneImages(ids: readonly DraftAttachmentId[]): void
/** THE complexity sink: enter adjudication, submit transaction, and the default sink live inside. */
submit(mode?: 'queue' | 'steer'): void
submit(): void
/**
* Surface a notice outside the machine's own effect stream: detached
* command results and business notifications render through here.
@@ -83,7 +83,7 @@ export interface InputActions {
/** Drop ids whose browser objects no longer exist. */
pruneImages(ids: readonly DraftAttachmentId[]): void
/** Enter submission (adjudication / claim transaction / default sink inside). */
submit(mode?: 'queue' | 'steer'): void
submit(): void
}
/** One surfaced notice (command results, adjudication failures). seq keys re-render of repeats. */
@@ -264,7 +264,7 @@ export type InputEvent =
| { readonly type: 'paste-upgrade'; readonly attemptId: number; readonly span: TokenSpan; readonly reference: ReferenceInsert }
/** Shell-observed attempt killers the machine cannot see itself (caret/selection ops, Slash interaction updates). */
| { readonly type: 'invalidate-paste' }
| { readonly type: 'enter'; readonly mode: 'queue' | 'steer' }
| { readonly type: 'enter' }
| { readonly type: 'adjudicated'; readonly attempt: SubmitAttempt; readonly outcome: PickOutcome }
| { readonly type: 'adjudication-failed'; readonly attempt: SubmitAttempt; readonly message: string }
| { readonly type: 'submit-settled'; readonly attempt: SubmitAttempt; readonly ok: boolean; readonly outcome?: SubmitOutcome; readonly message?: string }
@@ -283,5 +283,5 @@ 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 }
| { readonly type: 'default-sink'; readonly draft: string; readonly mode: 'queue' | 'steer' }
| { readonly type: 'default-sink'; readonly draft: string }
| { readonly type: 'notice'; readonly level: 'info' | 'error'; readonly text: string }

View File

@@ -39,7 +39,7 @@ export interface SessionInputDeps {
/** Queue read face; overlaid onto InputState.queue (absent = empty). */
queue?: ObservableSnapshot<readonly QueuedMessage[]> | undefined
/** The plain-message sink (send choreography / materialize fork — the hub owns it). */
defaultSink(text: string, mode: 'queue' | 'steer', imageIds: readonly DraftAttachmentId[]): void
defaultSink(text: string, imageIds: readonly DraftAttachmentId[]): void
}
/** Guard tier from the machine phase. */
@@ -71,7 +71,7 @@ export class SessionInputShell implements SessionInput {
addImages: ids => this.addImages(ids),
removeImage: (id) => { this.removeImage(id) },
pruneImages: (ids) => { this.pruneImages(ids) },
submit: (mode) => { this.submit(mode) },
submit: () => { this.submit() },
}
// Real wall clock: the typing-run merge window must actually expire in
@@ -200,14 +200,13 @@ export class SessionInputShell implements SessionInput {
* 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.
* @param mode - default-sink mode (queue appends; steer interrupts).
*/
submit(mode: 'queue' | 'steer' = 'queue'): void {
submit(): void {
if (this.snapshot.draft.trim() === '' && this.imageIds.length > 0) {
if (this.snapshot.phase === 'plain') this.deps.defaultSink('', mode, [...this.imageIds])
if (this.snapshot.phase === 'plain') this.deps.defaultSink('', [...this.imageIds])
return
}
this.run(this.core.dispatch({ type: 'enter', mode }))
this.run(this.core.dispatch({ type: 'enter' }))
const phase = this.snapshot.phase
if (phase === 'adjudicating' || phase === 'submitting') {
this.deps.popup?.()?.dismiss()
@@ -392,7 +391,7 @@ export class SessionInputShell implements SessionInput {
return
}
case 'default-sink': {
this.sinkSerialized(fx.draft, fx.mode)
this.sinkSerialized(fx.draft)
return
}
default:
@@ -407,11 +406,11 @@ export class SessionInputShell implements SessionInput {
* send — notice + draft and chips retained, never a silent downgrade to
* the clipboard text. Chip-free drafts skip the async detour.
*/
private sinkSerialized(draft: string, mode: 'queue' | 'steer'): void {
private sinkSerialized(draft: string): void {
const imageIds = [...this.imageIds]
const occurrences = this.core.state.occurrences
if (occurrences.length === 0) {
this.deps.defaultSink(draft.trim(), mode, imageIds)
this.deps.defaultSink(draft.trim(), imageIds)
return
}
const slash = this.deps.slash?.()
@@ -431,7 +430,7 @@ export class SessionInputShell implements SessionInput {
cursor = part.offset + 1
}
out += draft.slice(cursor)
this.deps.defaultSink(out.trim(), mode, imageIds)
this.deps.defaultSink(out.trim(), imageIds)
},
(error: unknown) => {
controller.abort()

View File

@@ -25,7 +25,6 @@ interface ConversationAttachmentFace {
sendSession(
session: SessionFace,
text: string,
mode: 'queue' | 'steer',
imageIds: readonly DraftAttachmentId[],
): Promise<void>
releaseDraftImage(id: DraftAttachmentId): void
@@ -67,7 +66,7 @@ export class InputHub implements InputService {
slash: () => this.controller(actx),
popup: () => this.popup(actx),
queue: queueReadFaceOf(session),
defaultSink: (text, mode, imageIds) => { this.sink(session, text, mode, imageIds) },
defaultSink: (text, imageIds) => { this.sink(session, text, imageIds) },
})
this.shells.set(id, shell)
// The one teardown axis: listeners, shell, and map entries all ride the
@@ -148,14 +147,13 @@ export class InputHub implements InputService {
private sink(
session: SessionFace,
text: string,
mode: 'queue' | 'steer',
imageIds: readonly DraftAttachmentId[],
): void {
if (text === '' && imageIds.length === 0) return
const shell = this.shells.get(session.sessionId)
// Commit, not an editable clear: undo must not resurrect sent content.
shell?.commitSend(imageIds)
void this.conversation().sendSession(session, text, mode, imageIds).catch(() => {
void this.conversation().sendSession(session, text, imageIds).catch(() => {
// Restore only into the shell that still owns the session: if the scope
// died while the send was in flight, `commitSend` already removed the
// ids from the (now disposed) shell, so the teardown release could not

View File

@@ -112,7 +112,6 @@ export class InputMachine {
private inflight: {
readonly attempt: SubmitAttempt
readonly controller: AbortController
readonly mode: 'queue' | 'steer'
} | undefined
private log: Transaction[] = []
private redoStack: Transaction[] = []
@@ -164,7 +163,7 @@ export class InputMachine {
this.paste = undefined
return []
}
case 'enter': return this.onEnter(ev.mode)
case 'enter': return this.onEnter()
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)
@@ -463,18 +462,18 @@ export class InputMachine {
// ---- submit plane ----
/** Mint the next SubmitAttempt and take the in-flight slot. */
private beginAttempt(mode: 'queue' | 'steer'): SubmitAttempt {
private beginAttempt(): SubmitAttempt {
const controller = new AbortController()
this.seq += 1
const attempt: SubmitAttempt = { seq: this.seq, signal: controller.signal, draftSnapshot: this.draft }
this.inflight = { attempt, controller, mode }
this.inflight = { attempt, controller }
return attempt
}
private onEnter(mode: 'queue' | 'steer'): InputEffect[] {
private onEnter(): InputEffect[] {
if (this.phase === 'adjudicating' || this.phase === 'submitting') return []
if (this.phase === 'claimed' && this.claim !== undefined) {
const attempt = this.beginAttempt(mode)
const attempt = this.beginAttempt()
this.phase = 'submitting'
this.paste = undefined
return [{ type: 'begin-submit', attempt, claim: this.claim, args: argsAfter(this.draft, this.claim.token) }]
@@ -483,11 +482,11 @@ export class InputMachine {
if (trimmed === '') return []
this.paste = undefined
if (trimmed.startsWith('/')) {
const attempt = this.beginAttempt(mode)
const attempt = this.beginAttempt()
this.phase = 'adjudicating'
return [{ type: 'adjudicate', attempt, draft: this.draft }]
}
return [{ type: 'default-sink', draft: this.draft, mode }]
return [{ type: 'default-sink', draft: this.draft }]
}
private onAdjudicated(attempt: SubmitAttempt, outcome: Extract<InputEvent, { type: 'adjudicated' }>['outcome']): InputEffect[] {
@@ -508,7 +507,7 @@ export class InputMachine {
this.inflight = undefined
this.phase = 'plain'
return outcome === undefined
? [{ type: 'default-sink', draft: attempt.draftSnapshot, mode: flight.mode }]
? [{ type: 'default-sink', draft: attempt.draftSnapshot }]
: []
}

View File

@@ -61,7 +61,6 @@ export const zh = {
'chat.loadOlder': '加载更早',
'chat.toBottom': '回到底部',
'message.extraBlock': '附加内容块',
'message.steering': '插话',
'message.contextInjection': '上下文注入',
'message.compaction': '上下文已压缩',
'message.compaction.expand': '点击查看压缩摘要',
@@ -176,7 +175,6 @@ export const en = {
'chat.loadOlder': 'Load earlier',
'chat.toBottom': 'Back to bottom',
'message.extraBlock': 'Extra content block',
'message.steering': 'Interjection',
'message.contextInjection': 'Context injection',
'message.compaction': 'Context compacted',
'message.compaction.expand': 'View compaction summary',

View File

@@ -27,12 +27,11 @@ export interface IConversation {
/** The per-session input machine registry (InputService face). */
readonly input: InputService
/**
* Send a prompt into the caller scope's session.
* Send a prompt into the caller scope's session (queued turn).
* @param text - prompt text, sent verbatim as one text block.
* @param mode - queue after the current turn, or steer into it.
* @returns completion; business failures reject (and land in promptError).
*/
send(text: string, mode: 'queue' | 'steer'): Promise<void>
send(text: string): Promise<void>
/**
* Apply one operation to a pending queue occurrence.
* @param itemId - agent-owned inbox occurrence identity.
@@ -110,11 +109,10 @@ export class ConversationService extends Service implements IConversation {
* session snapshot's promptError (object-layer surface); the rejection here
* exists for caller choreography (the composer restores the draft on it).
* @param text - prompt text, sent verbatim as one text block when non-empty.
* @param mode - queue after the current turn, or steer into it.
*/
async send(text: string, mode: 'queue' | 'steer'): Promise<void> {
async send(text: string): Promise<void> {
const session = this.scopedSession('send')
await this.sendFiles(session, text, mode, [])
await this.sendFiles(session, text, [])
}
/**
@@ -123,32 +121,29 @@ export class ConversationService extends Service implements IConversation {
* persisted or stale id.
* @param session - target session.
* @param text - serialized prompt text.
* @param mode - queue or steer.
* @param imageIds - ordered draft-local attachment ids.
*/
async sendSession(
session: SessionFace,
text: string,
mode: 'queue' | 'steer',
imageIds: readonly DraftAttachmentId[],
): Promise<void> {
const attachments = this.draftImages(imageIds)
if (attachments.length !== imageIds.length) {
throw new Error('conversation.sendSession: one or more draft images are no longer available')
}
await this.sendFiles(session, text, mode, attachments.map(attachment => attachment.file))
await this.sendFiles(session, text, attachments.map(attachment => attachment.file))
this.releaseDraftImages(attachments)
}
private async sendFiles(
session: SessionFace,
text: string,
mode: 'queue' | 'steer',
images: readonly File[],
): Promise<void> {
const uploaded = await this.serializeImages(images)
const content = [...uploaded, ...(text === '' ? [] : [{ type: 'text' as const, text }])]
const result = await session.prompt(content, mode)
const result = await session.prompt(content, 'queue')
if (!result.ok) throw new Error(`conversation.send failed: ${result.error.code}: ${result.error.message}`)
}

View File

@@ -213,7 +213,7 @@ export function InputBar({
e.preventDefault()
if (e.repeat) return // held-down Enter must not machine-gun sends
if (locked || machineBusy) return
inputActions.submit('queue')
inputActions.submit()
}
const onChange = (e: ChangeEvent<HTMLTextAreaElement>): void => {
@@ -355,7 +355,7 @@ export function InputBar({
return
}
/* v8 ignore next -- defensive: the primary button is disabled while empty||disabled, so a click cannot reach the false arm. */
if (!empty && !disabled && !machineBusy) inputActions.submit('queue')
if (!empty && !disabled && !machineBusy) inputActions.submit()
}
// The Access seat: the projection-fed permission chip (renders nothing

View File

@@ -107,7 +107,7 @@ async function bench() {
const actions = info.props['inputActions'] as {
setDraft: (text: string) => void
addImages: (ids: readonly DraftAttachmentId[]) => boolean
submit: (mode?: 'queue' | 'steer') => void
submit: () => void
}
return { state, actions }
}
@@ -146,25 +146,25 @@ describe('conversation slot inject surface', () => {
const { state, actions } = b.inputSurface(ROOT)
// Whitespace-only: the machine treats it as empty — no prompt, draft kept.
actions.setDraft(' ')
actions.submit('queue')
actions.submit()
expect(b.sessionFake.prompt).not.toHaveBeenCalled()
expect(state.getSnapshot().draft).toBe(' ')
// Success: cleared and stays cleared.
actions.setDraft('hello')
actions.submit('queue')
actions.submit()
expect(state.getSnapshot().draft).toBe('')
await Promise.resolve()
expect(b.sessionFake.prompt).toHaveBeenCalledWith([{ type: 'text', text: 'hello' }], 'queue')
// Failure: restored (draft still empty when the rejection lands).
b.sessionFake.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'b', details: { reason: 'b' } } })
actions.setDraft('retry me')
actions.submit('queue')
actions.submit()
await vi.waitFor(() => {
expect(state.getSnapshot().draft).toBe('retry me')
})
// Failure landing after new typing: no clobber (restore fills empty only).
b.sessionFake.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'b', details: { reason: 'b' } } })
actions.submit('queue')
actions.submit()
actions.setDraft('typed during flight')
await new Promise(r => setTimeout(r, 0))
expect(state.getSnapshot().draft).toBe('typed during flight')

View File

@@ -102,7 +102,7 @@ describe('MessageItem arms', () => {
fireEvent.click(screen.getByRole('button', { name: '复制' }))
})
it('steering bubbles carry the interjection badge and non-text rest blocks, without user actions', () => {
it('steering bubbles render text and non-text rest blocks, without user actions or a badge', () => {
const view = render(
<MessageItem t={t} node={{
kind: 'steering', seq: 2, turn: 1, source: null,
@@ -110,7 +110,7 @@ describe('MessageItem arms', () => {
} as never}
/>,
)
expect(view.getByText('插话')).toBeTruthy()
expect(view.queryByText('插话')).toBeNull()
expect(view.getByText('steer!')).toBeTruthy()
expect(view.getByText(/附加内容块/)).toBeTruthy()
expect(view.queryByRole('button', { name: '复制' })).toBeNull()

View File

@@ -147,7 +147,7 @@ describe('Enter semantics', () => {
it('plain Enter submits queue mode through the machine; repeat and empty are suppressed', () => {
const { textarea, sink } = bench({ draft: 'hello' })
fireEvent.keyDown(textarea, { key: 'Enter' })
expect(sink).toHaveBeenCalledWith('hello', 'queue', [])
expect(sink).toHaveBeenCalledWith('hello', [])
fireEvent.keyDown(textarea, { key: 'Enter', repeat: true })
expect(sink).toHaveBeenCalledTimes(1)
const empty = bench({ draft: ' ' })
@@ -215,7 +215,7 @@ describe('running and lock semantics (queue cut 1)', () => {
expect(textarea.disabled).toBe(false) // running no longer locks
fireEvent.change(textarea, { target: { value: '排队消息2' } })
fireEvent.keyDown(textarea, { key: 'Enter' })
expect(sink).toHaveBeenCalledWith('排队消息2', 'queue', [])
expect(sink).toHaveBeenCalledWith('排队消息2', [])
expect(button.getAttribute('aria-label')).toBe('停止生成')
fireEvent.click(button)
expect(stop).toHaveBeenCalledTimes(1)
@@ -231,7 +231,7 @@ describe('running and lock semantics (queue cut 1)', () => {
it('idle primary sends and disables on empty draft', () => {
const { button, sink } = bench({ draft: 'go' })
fireEvent.click(button)
expect(sink).toHaveBeenCalledWith('go', 'queue', [])
expect(sink).toHaveBeenCalledWith('go', [])
const empty = bench()
expect(empty.button.disabled).toBe(true)
})
@@ -371,7 +371,7 @@ describe('machine pending lock', () => {
},
{ start: 0, end: 6, draftRev: shell.snapshot.draftRev },
)
shell.submit('queue')
shell.submit()
})
expect(shell.snapshot.phase).toBe('submitting')
const textarea = view.container.querySelector('textarea')!
@@ -390,7 +390,7 @@ describe('machine pending lock', () => {
},
{ start: 0, end: 6, draftRev: shell.snapshot.draftRev },
)
shell.submit('queue')
shell.submit()
})
expect(shell.snapshot.phase).toBe('submitting')
// A refused batch must be observable (the workspace-switch transfer keeps
@@ -588,7 +588,7 @@ describe('image draft rail', () => {
const send = view.getByRole('button', { name: '发送消息' }) as HTMLButtonElement
expect(send.disabled).toBe(false)
fireEvent.keyDown(textarea, { key: 'Enter' })
expect(sink).toHaveBeenCalledWith('', 'queue', ['draft-1'])
expect(sink).toHaveBeenCalledWith('', ['draft-1'])
fireEvent.click(view.getByRole('button', { name: '移除图片 pixel.png' }))
expect(removeImage).toHaveBeenCalledWith('draft-1')

View File

@@ -40,9 +40,9 @@ function effectAt<T extends InputEffect['type']>(
}
/** Drive plain → adjudicating and hand back the minted attempt. */
function enterAdjudicating(m: InputMachine, draft: string, mode: 'queue' | 'steer' = 'queue'): SubmitAttempt {
function enterAdjudicating(m: InputMachine, draft: string): SubmitAttempt {
m.dispatch({ type: 'draft-changed', draft })
const fx = m.dispatch({ type: 'enter', mode })
const fx = m.dispatch({ type: 'enter' })
return effectAt(fx, 0, 'adjudicate').attempt
}
@@ -52,7 +52,7 @@ function enterSubmitting(m: InputMachine, name: string, args: string): { attempt
m.dispatch({ type: 'draft-changed', draft: `/${name.slice(0, 2)}` })
m.dispatch({ type: 'begin-command', claim, span: spanOf(m, 0, m.state.draft.length) })
m.dispatch({ type: 'draft-changed', draft: claim.token + args })
const fx = m.dispatch({ type: 'enter', mode: 'queue' })
const fx = m.dispatch({ type: 'enter' })
return { attempt: effectAt(fx, 0, 'begin-submit').attempt, claim }
}
@@ -63,24 +63,24 @@ function staleAttempt(): SubmitAttempt {
describe('input-machine: plain × enter', () => {
it('empty and whitespace-only drafts produce nothing', () => {
const m = new InputMachine()
expect(m.dispatch({ type: 'enter', mode: 'queue' })).toEqual([])
expect(m.dispatch({ type: 'enter' })).toEqual([])
m.dispatch({ type: 'draft-changed', draft: ' \n ' })
expect(m.dispatch({ type: 'enter', mode: 'queue' })).toEqual([])
expect(m.dispatch({ type: 'enter' })).toEqual([])
expect(m.state.phase).toBe('plain')
})
it('non-command text falls to the default sink with the given mode', () => {
it('non-command text falls to the default sink', () => {
const m = new InputMachine()
m.dispatch({ type: 'draft-changed', draft: 'hello world' })
expect(m.dispatch({ type: 'enter', mode: 'steer' }))
.toEqual([{ type: 'default-sink', draft: 'hello world', mode: 'steer' }])
expect(m.dispatch({ type: 'enter' }))
.toEqual([{ type: 'default-sink', draft: 'hello world' }])
expect(m.state.phase).toBe('plain')
})
it('leading "/" enters adjudicating with a minted attempt carrying the draft snapshot', () => {
const m = new InputMachine()
m.dispatch({ type: 'draft-changed', draft: '/goal x' })
const fx = m.dispatch({ type: 'enter', mode: 'queue' })
const fx = m.dispatch({ type: 'enter' })
const eff = effectAt(fx, 0, 'adjudicate')
expect(eff.draft).toBe('/goal x')
expect(eff.attempt.draftSnapshot).toBe('/goal x')
@@ -91,14 +91,14 @@ describe('input-machine: plain × enter', () => {
it('leading is judged after trim including newlines', () => {
const m = new InputMachine()
m.dispatch({ type: 'draft-changed', draft: '\n\n/goal x' })
expect(m.dispatch({ type: 'enter', mode: 'queue' })[0]?.type).toBe('adjudicate')
expect(m.dispatch({ type: 'enter' })[0]?.type).toBe('adjudicate')
})
it('a non-whitespace prefix before "/" is not leading — default sink', () => {
const m = new InputMachine()
m.dispatch({ type: 'draft-changed', draft: '第一行\n/goal x' })
expect(m.dispatch({ type: 'enter', mode: 'queue' }))
.toEqual([{ type: 'default-sink', draft: '第一行\n/goal x', mode: 'queue' }])
expect(m.dispatch({ type: 'enter' }))
.toEqual([{ type: 'default-sink', draft: '第一行\n/goal x' }])
})
})
@@ -124,11 +124,11 @@ describe('input-machine: adjudication outcomes', () => {
expect(effectAt(b.dispatch({ type: 'adjudicated', attempt: attemptB, outcome: { claim: claimOf('goal') } }), 0, 'begin-submit').args).toBe('x')
})
it('undefined outcome falls back to the default sink preserving the enter mode', () => {
it('undefined outcome falls back to the default sink', () => {
const m = new InputMachine()
const attempt = enterAdjudicating(m, '/unknown thing', 'steer')
const attempt = enterAdjudicating(m, '/unknown thing')
expect(m.dispatch({ type: 'adjudicated', attempt, outcome: undefined }))
.toEqual([{ type: 'default-sink', draft: '/unknown thing', mode: 'steer' }])
.toEqual([{ type: 'default-sink', draft: '/unknown thing' }])
expect(m.state.phase).toBe('plain')
})
@@ -152,7 +152,7 @@ describe('input-machine: adjudication outcomes', () => {
it('enter is a no-op while adjudicating (pending lock)', () => {
const m = new InputMachine()
enterAdjudicating(m, '/goal x')
expect(m.dispatch({ type: 'enter', mode: 'queue' })).toEqual([])
expect(m.dispatch({ type: 'enter' })).toEqual([])
expect(m.state.phase).toBe('adjudicating')
})
@@ -587,7 +587,7 @@ describe('input-machine: paste plane', () => {
const b = new InputMachine()
b.dispatch({ type: 'paste-begin', text: 'plain text', selection: { start: 0, end: 0 } })
b.dispatch({ type: 'enter', mode: 'queue' })
b.dispatch({ type: 'enter' })
expect(b.state.paste).toBeUndefined()
})
@@ -755,7 +755,7 @@ describe('input-machine: submitting transaction', () => {
it('enter and begin-command are locked; draft-changed is recorded without leaving submitting', () => {
const m = new InputMachine()
enterSubmitting(m, 'goal', 'x')
expect(m.dispatch({ type: 'enter', mode: 'queue' })).toEqual([])
expect(m.dispatch({ type: 'enter' })).toEqual([])
expect(m.dispatch({ type: 'draft-changed', draft: '/goal y' })).toEqual([])
expect(m.state).toMatchObject({ phase: 'submitting', draft: '/goal y' })
})
@@ -768,7 +768,7 @@ describe('input-machine: submitting transaction', () => {
m.dispatch({ type: 'draft-changed', draft: '/go', editRange: { start: 0, end: 1, insertedLength: 0 } })
m.dispatch({ type: 'begin-command', claim: claimOf('goal'), span: spanOf(m, 0, 3) })
m.dispatch({ type: 'draft-changed', draft: '/goal go' })
const attempt = effectAt(m.dispatch({ type: 'enter', mode: 'queue' }), 0, 'begin-submit').attempt
const attempt = effectAt(m.dispatch({ type: 'enter' }), 0, 'begin-submit').attempt
const fx = m.dispatch({ type: 'submit-settled', attempt, ok: true, outcome: { kind: 'success', text: 'goal set' } })
expect(fx).toEqual([{ type: 'notice', level: 'info', text: 'goal set' }])
expect(m.state).toMatchObject({ phase: 'plain', draft: '', occurrences: [] })
@@ -809,7 +809,7 @@ describe('input-machine: submitting transaction', () => {
const m = new InputMachine()
const { attempt: first } = enterSubmitting(m, 'goal', 'x')
m.dispatch({ type: 'submit-settled', attempt: first, ok: false, message: 'retry' })
const second = effectAt(m.dispatch({ type: 'enter', mode: 'queue' }), 0, 'begin-submit').attempt
const second = effectAt(m.dispatch({ type: 'enter' }), 0, 'begin-submit').attempt
expect(second.seq).not.toBe(first.seq)
expect(m.dispatch({ type: 'submit-settled', attempt: first, ok: true })).toEqual([])
expect(m.state.phase).toBe('submitting')

View File

@@ -90,7 +90,7 @@ describe('matrix row: plain', () => {
fireEvent.change(textarea, { target: { value: '普通消息' } })
expect(shell.snapshot.claim).toBeUndefined()
fireEvent.keyDown(textarea, { key: 'Enter' })
expect(sink).toHaveBeenCalledWith('普通消息', 'queue', [])
expect(sink).toHaveBeenCalledWith('普通消息', [])
expect(shell.snapshot.phase).toBe('plain')
})
})
@@ -189,7 +189,7 @@ describe('matrix row: locked (session disabled)', () => {
expect((textarea).disabled).toBe(false)
fireEvent.change(textarea, { target: { value: '排队' } })
fireEvent.keyDown(textarea, { key: 'Enter' })
expect(sink).toHaveBeenCalledWith('排队', 'queue', [])
expect(sink).toHaveBeenCalledWith('排队', [])
})
})

View File

@@ -237,7 +237,7 @@ describe('scenario D: execute-kind /compact', () => {
act(() => { b2.shell.setDraft('/compact 现在') })
fireEvent.keyDown(b2.textarea, { key: 'Enter' })
// execute with trailing → matchEnter answers undefined → default sink.
await vi.waitFor(() => { expect(b2.sink).toHaveBeenCalledWith('/compact 现在', 'queue', []) })
await vi.waitFor(() => { expect(b2.sink).toHaveBeenCalledWith('/compact 现在', []) })
expect(b2.executed).toHaveLength(0)
})
})
@@ -291,7 +291,7 @@ describe('scenario I: unknown /xyz + enter', () => {
const b = await bench()
act(() => { b.shell.setDraft('/xyz 干点啥') })
fireEvent.keyDown(b.textarea, { key: 'Enter' })
await vi.waitFor(() => { expect(b.sink).toHaveBeenCalledWith('/xyz 干点啥', 'queue', []) })
await vi.waitFor(() => { expect(b.sink).toHaveBeenCalledWith('/xyz 干点啥', []) })
expect(b.shell.snapshot.phase).toBe('plain')
expect(b.execute).not.toHaveBeenCalled()
})

View File

@@ -34,11 +34,11 @@ async function bench(readAttachment?: SessionFace['readAttachment']) {
describe('ConversationService', () => {
it('routes operations through the public Session binding', async () => {
const b = await bench()
await b.scoped.send('hello', 'steer')
await b.scoped.send('hello')
await b.scoped.updateQueue('item-1' as never, { kind: 'remove' })
await b.scoped.cancel()
await b.scoped.loadOlder()
expect(b.prompt).toHaveBeenCalledWith([{ type: 'text', text: 'hello' }], 'steer')
expect(b.prompt).toHaveBeenCalledWith([{ type: 'text', text: 'hello' }], 'queue')
expect(b.updateQueue).toHaveBeenCalledWith('item-1', { kind: 'remove' })
expect(b.cancel).toHaveBeenCalledOnce()
expect(b.loadOlder).toHaveBeenCalledOnce()
@@ -48,7 +48,7 @@ describe('ConversationService', () => {
it('folds Session business failures into callback rejections', async () => {
const b = await bench()
b.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'busy', details: {} } } as never)
await expect(b.scoped.send('x', 'queue')).rejects.toThrow('conversation.send failed: agent-busy: busy')
await expect(b.scoped.send('x')).rejects.toThrow('conversation.send failed: agent-busy: busy')
b.cancel.mockResolvedValueOnce({ ok: false, error: { code: 'internal', message: 'nope', details: {} } } as never)
await expect(b.scoped.cancel()).rejects.toThrow('conversation.cancel failed: internal: nope')
await b.runtime.dispose()
@@ -110,7 +110,7 @@ describe('ConversationService', () => {
let reject!: (error: Error) => void
b.prompt.mockReturnValueOnce(new Promise((_resolve, rej) => { reject = rej }) as never)
shell.setDraft('x')
shell.submit('queue')
shell.submit()
// commitSend already removed the ids from the shell; kill the scope
// while the RPC is still pending, then land the failure.
await b.runtime.sessions.remove('s1')
@@ -141,7 +141,7 @@ describe('ConversationService', () => {
shell.addImages([first.id])
shell.setDraft('describe')
shell.submit('queue')
shell.submit()
expect(shell.addImages([second.id])).toBe(true)
expect(shell.snapshot.imageIds).toEqual([second.id])
@@ -186,9 +186,9 @@ describe('ConversationService', () => {
it('fails loudly from the root scope, on an unbound session, or without SessionsService', async () => {
const b = await bench()
await expect(b.root.send('x', 'queue')).rejects.toThrow(/requires a session scope/)
await expect(b.root.send('x')).rejects.toThrow(/requires a session scope/)
await b.runtime.sessions.remove('s1')
await expect(b.scoped.send('x', 'queue')).rejects.toThrow(/resolved no binding/)
await expect(b.scoped.send('x')).rejects.toThrow(/resolved no binding/)
await b.runtime.dispose()
// No SessionsService at all: a bare context (the runtime always provides one).
const bare = new Context()
@@ -196,6 +196,6 @@ describe('ConversationService', () => {
input: new InputHub(bare),
}).await()
const orphan = bare.get('conversation') as ConversationService
await expect(orphan.send('x', 'queue')).rejects.toThrow(/sessions service unavailable/)
await expect(orphan.send('x')).rejects.toThrow(/sessions service unavailable/)
})
})

View File

@@ -221,7 +221,7 @@ describe('ConversationRoot resident composer', () => {
fireEvent.change(box, { target: { value: 'ordinary revised' } })
expect(b.chat.store.getSnapshot().draft).toBe('ordinary revised')
fireEvent.keyDown(box, { key: 'Enter' })
expect(b.sink).toHaveBeenCalledWith('ordinary revised', 'queue', [])
expect(b.sink).toHaveBeenCalledWith('ordinary revised', [])
fireEvent.click(b.view.getByRole('button', { name: 'Root' }))
expect(b.open).toHaveBeenCalledWith(sid('root'))
})