Merge remote-tracking branch 'origin/master' into worktree/web-model-request-retry

# Conflicts:
#	apps/web/tests/smoke-real.e2e.ts
#	packages/client/runtime/src/client/index.ts
#	packages/client/runtime/src/client/sessions/session.ts
#	packages/client/ui-conversation/src/client/chat/ChatView.tsx
#	packages/client/ui-conversation/src/client/chat/MessageItem.module.css
#	packages/client/ui-conversation/src/client/chat/MessageItem.tsx
This commit is contained in:
Yichen Jiang
2026-07-27 12:14:23 +08:00
1521 changed files with 44884 additions and 10950 deletions

View File

@@ -20,7 +20,7 @@ import type {
import type { SlotRendererHost } from '@deepseek-ai/dsh-client-web-react'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type {
ChatViewInjected, ConversationInjected, DetailsInjected, EmptyStateInjected,
ChatViewInjected, ComposerBarInjected, ConversationInjected, ConversationSessionInjected, DetailsInjected,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { createChatStore } from '../src/client/stores.ts'
@@ -53,20 +53,21 @@ async function bench() {
const listStore = createSnapshotStore<SessionListState>({
ids: [ROOT],
byId: { [ROOT]: { id: ROOT, title: 'R', displayTitle: 'R', cwd: '/proj', running: false, updatedAt: 1 } },
byId: { [ROOT]: { id: ROOT, title: 'R', displayTitle: 'R', cwd: '/proj', running: false, blank: false, updatedAt: 1 } },
current: ROOT,
intent: undefined,
phase: 'ready',
})
const sessionFake = {
sessionId: ROOT,
open: vi.fn(() => Promise.resolve()),
loadOlder: vi.fn(() => Promise.resolve()),
updatePendingPrompt: vi.fn(),
retryPendingPrompt: vi.fn(),
prompt: vi.fn<() => Promise<{ ok: boolean; value?: object; error?: { code: string; message: string } }>>(
() => Promise.resolve({ ok: true, value: { accepted: true } })),
cancel: vi.fn<() => Promise<{ ok: boolean; value?: object; error?: { code: string; message: string } }>>(
() => Promise.resolve({ ok: true, value: { accepted: true } })),
// Observable face (the input machine's queue read face rides it).
getSnapshot: () => ({ queue: [] }),
subscribe: () => () => {},
}
const scopes = new Map<SessionId, Context>()
const mint = (id: SessionId): Context => {
@@ -77,24 +78,31 @@ async function bench() {
}
return scoped
}
type TestProvider = {
resolve(binding: { sessionId: SessionId; session: typeof sessionFake; ctx: Context }): {
hooks?: Record<string, unknown>; props?: Record<string, unknown>
}
}
const providers: TestProvider[] = []
const sessionsFake = {
list: listStore,
binding: (id: SessionId) => ({ sessionId: id, session: sessionFake, ctx: mint(id) }),
scope: (id: SessionId) => mint(id),
cell: () => undefined,
provideInfo: () => undefined,
maybeProvideInfo: () => ({ hooks: {}, props: {} }),
provide: (descriptor: TestProvider) => { providers.push(descriptor); return () => {} },
scopeOf,
sessionOf: (actx: Context) => (scopeOf(actx) === undefined ? undefined : sessionFake),
open: vi.fn(),
updateIntent: vi.fn(),
}
ctx.provide('sessions', sessionsFake)
const workspaceStore = createSnapshotStore<WorkspaceListState>({
items: [], intent: undefined, state: 'idle', phase: 'ready', error: null,
items: [], state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,
})
const workspacesFake = {
list: workspaceStore,
startSession: vi.fn(),
sendSession: vi.fn(),
connectWorkspace: vi.fn(async () => ROOT),
}
ctx.provide('workspaces', workspacesFake)
const layoutFake = { openDetails: vi.fn(), closeDetails: vi.fn() }
@@ -107,9 +115,8 @@ async function bench() {
slots.register({
name: 'root',
children: {
'conversation': { kind: 'single', scope: 'session' },
'conversation': { kind: 'single', scope: 'session-maybe' },
'details': { kind: 'single', scope: 'session' },
'conversation.empty': { kind: 'single', scope: 'root' },
},
}, (_p: { renderSlot?: unknown }) => null)
@@ -122,15 +129,23 @@ async function bench() {
slots.install({ renderRoot: (h) => { host = h; return null } })
slots.renderSlot('root', {})
const hostFace = host!
const entryOf = (key: 'conversation' | 'conversation.view' | 'details' | 'conversation.empty') => hostFace.entriesOf(key)[0]!
const entryOf = (key: 'conversation' | 'conversation.session' | 'conversation.composer.bar' | 'conversation.view' | 'details') => hostFace.entriesOf(key)[0]!
/** Resolve store instance + call the inject the way the outlet would. */
const conversationSurface = (id: SessionId) => {
const entry = entryOf('conversation')
const entry = entryOf('conversation.session')
const instance = hostFace.storeOf(entry, id) as ChatInstance
const injected = (entry.inject as unknown as (sessionId: SessionId, actions: ChatActions) => ConversationInjected)(
const injected = (entry.inject as unknown as (sessionId: SessionId, actions: ChatActions) => ConversationSessionInjected)(
id, instance.actions)
return { instance, injected }
}
const residentSurface = (id: SessionId | undefined) => {
const entry = entryOf('conversation')
return (entry.inject as unknown as (sessionId: SessionId | undefined) => ConversationInjected)(id)
}
const composerSurface = (id: SessionId | undefined) => {
const entry = entryOf('conversation.composer.bar')
return (entry.inject as unknown as (sessionId: SessionId | undefined) => ComposerBarInjected)(id)
}
/** Same resolution for the chat entry riding the view ring. */
const chatViewSurface = (id: SessionId) => {
const entry = entryOf('conversation.view')
@@ -139,12 +154,19 @@ async function bench() {
id, instance.actions)
return { instance, injected }
}
const emptySurface = () => {
const entry = entryOf('conversation.empty')
return (entry.inject as unknown as () => EmptyStateInjected)()
/** Materialize the input provide contribution the way the runtime does. */
const inputSurface = (id: SessionId) => {
const contribution = providers[0]!.resolve(sessionsFake.binding(id))
const state = contribution.hooks!['input'] as {
getSnapshot(): { draft: string }; subscribe(fn: () => void): () => void
}
const actions = contribution.props!['inputActions'] as {
setDraft(text: string): void; submit(mode?: 'queue' | 'steer'): void
}
return { state, actions }
}
return {
ctx, slots, hostFace, entryOf, conversationSurface, chatViewSurface, emptySurface,
ctx, slots, hostFace, entryOf, conversationSurface, residentSurface, composerSurface, chatViewSurface, inputSurface,
sessionFake, sessionsFake, workspacesFake, layoutFake, mint,
}
}
@@ -163,52 +185,60 @@ describe('conversation slot inject surface', () => {
expect(b.sessionFake.loadOlder).toHaveBeenCalledTimes(1)
})
it('send trims, optimistically clears through actions, restores on failure without clobbering new typing', async () => {
it('the provide-channel input face submits through the machine sink: trim, optimistic clear, failure restore without clobber', async () => {
const b = await bench()
const { instance, injected } = b.conversationSurface(ROOT)
// Whitespace-only: no send, and the (whitespace) draft is not cleared.
instance.actions.setDraft(' ')
injected.send(' ', 'queue')
const { injected } = b.conversationSurface(ROOT)
const { state, actions } = b.inputSurface(ROOT)
// Whitespace-only: the machine treats it as empty — no prompt, draft kept.
actions.setDraft(' ')
actions.submit('queue')
expect(b.sessionFake.prompt).not.toHaveBeenCalled()
expect(instance.store.getSnapshot().draft).toBe(' ')
expect(state.getSnapshot().draft).toBe(' ')
// Success: cleared and stays cleared.
instance.actions.setDraft('hello')
injected.send('hello', 'queue')
expect(instance.store.getSnapshot().draft).toBe('')
actions.setDraft('hello')
actions.submit('queue')
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' } })
instance.actions.setDraft('retry me')
injected.send('retry me', 'queue')
actions.setDraft('retry me')
actions.submit('queue')
await vi.waitFor(() => {
expect(instance.store.getSnapshot().draft).toBe('retry me')
expect(state.getSnapshot().draft).toBe('retry me')
})
// Failure landing after new typing: no clobber (restoreDraft fills empty only).
// Failure landing after new typing: no clobber (restore fills empty only).
b.sessionFake.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'b' } })
injected.send('retry me', 'queue')
instance.actions.setDraft('typed during flight')
actions.submit('queue')
actions.setDraft('typed during flight')
await new Promise(r => setTimeout(r, 0))
expect(instance.store.getSnapshot().draft).toBe('typed during flight')
expect(state.getSnapshot().draft).toBe('typed during flight')
// The provide contribution is idempotent per session: one shell identity.
expect(b.inputSurface(ROOT).state).toBe(state)
// The draft mirror rides the conversation inject face.
const mirrored: string[] = []
const unbind = injected.bindDraftMirror(text => mirrored.push(text))
actions.setDraft('mirrored text')
expect(mirrored).toEqual(['mirrored text'])
unbind()
// Stop failure is swallowed (promptError owns the surface).
b.sessionFake.cancel.mockResolvedValueOnce({ ok: false, error: { code: 'internal', message: 'x' } })
injected.stop()
b.composerSurface(ROOT).stop()
await new Promise(r => setTimeout(r, 0))
expect(b.sessionFake.cancel).toHaveBeenCalledTimes(1)
})
it('inject fails loud when the session resolves no scope or the scope lacks the service', async () => {
const b = await bench()
const entry = b.entryOf('conversation')
const instance = b.hostFace.storeOf(entry, ROOT) as ChatInstance
const injectFn = entry.inject as unknown as (sessionId: SessionId, actions: ChatActions) => ConversationInjected
const entry = b.entryOf('conversation.composer.bar')
const injectFn = entry.inject as unknown as (sessionId: SessionId) => ComposerBarInjected
// Unknown session: sessions.scope answers nothing.
;(b.sessionsFake.scope as unknown) = () => undefined
expect(() => injectFn(ROOT, instance.actions)).toThrow(/resolved no scope/)
expect(() => injectFn(ROOT).stop()).toThrow(/resolved no scope/)
// A scope minted outside the service tree: no conversation service on it.
const foreign = new Context()
;(b.sessionsFake.scope as unknown) = () => foreign.plugin(() => {}).ctx.extend({})
expect(() => injectFn(ROOT, instance.actions)).toThrow(/unavailable through the session scope/)
expect(() => injectFn(ROOT).stop()).toThrow(/unavailable through the session scope/)
})
it('openDetails (chat view face) writes the selection through the store actions and opens the panel', async () => {
@@ -223,15 +253,28 @@ describe('conversation slot inject surface', () => {
expect(conv.instance).toBe(instance)
})
it('routes navigation through SessionsService and the retained prompt through the scoped Session', async () => {
it('routes navigation and workspace switching through the runtime owners, carrying the draft', async () => {
const b = await bench()
const { injected } = b.conversationSurface(ROOT)
const resident = b.residentSurface(ROOT)
injected.open(ROOT)
injected.updateSessionPrompt('revised')
injected.retrySessionPrompt()
expect(b.sessionsFake.open).toHaveBeenCalledWith(ROOT)
expect(b.sessionFake.updatePendingPrompt).toHaveBeenCalledWith('revised')
expect(b.sessionFake.retryPendingPrompt).toHaveBeenCalledOnce()
// Same-session connect (the picked workspace resolves to this session):
// no draft movement, plain re-open.
const { state, actions } = b.inputSurface(ROOT)
actions.setDraft('carry me')
resident.selectWorkspace('workspace-1' as never)
await vi.waitFor(() => { expect(b.sessionsFake.open).toHaveBeenCalledTimes(2) })
expect(b.workspacesFake.connectWorkspace).toHaveBeenCalledWith('workspace-1')
expect(state.getSnapshot().draft).toBe('carry me')
// Cross-session connect: the draft MOVES — the old machine empties, the
// new session's machine receives the text, then navigation lands there.
const OTHER = 'other-1' as SessionId
b.workspacesFake.connectWorkspace.mockResolvedValueOnce(OTHER)
resident.selectWorkspace('workspace-2' as never)
await vi.waitFor(() => { expect(b.sessionsFake.open).toHaveBeenCalledWith(OTHER) })
expect(state.getSnapshot().draft).toBe('')
expect(b.inputSurface(OTHER).state.getSnapshot().draft).toBe('carry me')
})
it('views read face projects the ring ledger (subscribe/version through ctx.slots)', async () => {
@@ -266,23 +309,9 @@ describe('details inject surface', () => {
injected.closeDetails()
expect(b.layoutFake.closeDetails).toHaveBeenCalledTimes(1)
// The shared handle: details resolves the SAME instance conversation writes.
const conv = b.hostFace.storeOf(b.entryOf('conversation'), ROOT)
const conv = b.hostFace.storeOf(b.entryOf('conversation.session'), ROOT)
const details = b.hostFace.storeOf(entry, ROOT)
expect(details).toBe(conv)
})
it('empty state injects the runtime intent actions and remains storeless', async () => {
const b = await bench()
const entry = b.entryOf('conversation.empty')
expect(entry.store).toBeUndefined()
const injected = b.emptySurface()
injected.startSession(undefined, 'fresh')
injected.startSession('workspace-1' as never, 'retargeted')
injected.updateSessionPrompt('typed')
injected.sendSession()
expect(b.workspacesFake.startSession).toHaveBeenNthCalledWith(1, undefined, 'fresh')
expect(b.workspacesFake.startSession).toHaveBeenNthCalledWith(2, 'workspace-1', 'retargeted')
expect(b.sessionsFake.updateIntent).toHaveBeenCalledWith('typed')
expect(b.workspacesFake.sendSession).toHaveBeenCalledOnce()
})
})

View File

@@ -26,18 +26,19 @@ async function bench() {
const listStore = createSnapshotStore<SessionListState>({
ids: [ROOT, CHILD],
byId: {
[ROOT]: { id: ROOT, title: 'R', displayTitle: 'R', running: false, updatedAt: 1 },
[CHILD]: { id: CHILD, title: 'C', displayTitle: 'C', parentId: ROOT, running: false, updatedAt: 2 },
[ROOT]: { id: ROOT, title: 'R', displayTitle: 'R', running: false, blank: false, updatedAt: 1 },
[CHILD]: { id: CHILD, title: 'C', displayTitle: 'C', parentId: ROOT, running: false, blank: false, updatedAt: 2 },
},
current: undefined,
intent: undefined,
phase: 'ready',
} as SessionListState)
const sessionsFake = {
list: listStore,
binding: vi.fn(),
scope: () => undefined,
cell: () => undefined,
provideInfo: () => undefined,
maybeProvideInfo: () => ({ hooks: {}, props: {} }),
provide: vi.fn(() => () => {}),
create: vi.fn(),
open: vi.fn(),
updateIntent: vi.fn(),
@@ -57,9 +58,8 @@ async function bench() {
slots.register({
name: 'root',
children: {
'conversation': { kind: 'single', scope: 'session' },
'conversation': { kind: 'single', scope: 'session-maybe' },
'details': { kind: 'single', scope: 'session' },
'conversation.empty': { kind: 'single', scope: 'root' },
},
}, (_p: { renderSlot?: unknown }) => null)
@@ -68,7 +68,7 @@ async function bench() {
}
/** First stored entry for a key (inject/store live directly on StoredEntry). */
function renderEntryOf(slots: SlotsService, key: 'conversation' | 'conversation.view' | 'details' | 'conversation.empty') {
function renderEntryOf(slots: SlotsService, key: 'conversation' | 'conversation.session' | 'conversation.view' | 'details') {
return slots.entries(key)[0] as undefined | { inject?: unknown; store?: unknown }
}
@@ -91,23 +91,24 @@ describe('apply wiring', () => {
expect(b.slots.spec('conversation.chat.toolview')).toEqual({ kind: 'keyed', scope: 'session' })
})
it('occupies the three slots + the ring; session entries share one store handle, empty injects runtime actions', async () => {
it('occupies the slots + the ring; session entries share one store handle', async () => {
const b = await bench()
await b.fiber.await()
const conversation = renderEntryOf(b.slots, 'conversation')
const conversationSession = renderEntryOf(b.slots, 'conversation.session')
const chatView = renderEntryOf(b.slots, 'conversation.view')
const details = renderEntryOf(b.slots, 'details')
const empty = renderEntryOf(b.slots, 'conversation.empty')
expect(conversation?.inject).toBeTypeOf('function')
expect(chatView?.inject).toBeTypeOf('function')
expect(details?.inject).toBeTypeOf('function')
expect(empty?.inject).toBeTypeOf('function')
// The shared handle: one apply-built store value on ALL session entries.
expect(conversation?.store).toBeDefined()
expect(details?.store).toBe(conversation?.store)
expect(chatView?.store).toBe(conversation?.store)
// The empty slot is storeless (local state + useSessions derivation).
expect(empty?.store).toBeUndefined()
// The shared handle: one apply-built store value on ALL session entries
// (the session-maybe 'conversation' shell carries no store by design).
expect(conversationSession?.store).toBeDefined()
expect(details?.store).toBe(conversationSession?.store)
expect(chatView?.store).toBe(conversationSession?.store)
// The hero workspace picker hole rides the conversation entry's children
// declaration (the empty-state occupant is gone).
expect(b.slots.spec('conversation.hero.workspace')).toEqual({ kind: 'single', scope: 'root' })
})
it('mounts the bash sample as a keyed entry through the load-order seam', async () => {
@@ -130,7 +131,6 @@ describe('apply wiring', () => {
expect(b.slots.entries('conversation.chat.toolview')).toHaveLength(0)
expect(b.slots.spec('conversation.chat.toolview')).toBeUndefined()
expect(b.slots.entries('details')).toHaveLength(0)
expect(b.slots.entries('conversation.empty')).toHaveLength(0)
expect(b.ctx.get('conversation')).toBeUndefined()
})
})

View File

@@ -0,0 +1,248 @@
// @vitest-environment jsdom
// Code Mode sub-call acceptance on the REAL machinery stack (same bench as
// chat-toolview-slot.spec): a run_code result renders the 'code' variant row
// (description summary, program body), its logged sub-dispatches render as
// always-visible nested rows through the SAME keyed toolview hole — the bash
// sub-call lands in the bash sample plugin's registration exactly like a
// top-level bash row, unregistered sub-tools fall back to GenericToolCard —
// and a sub-row click opens details for the sub-callId. Running parents
// (runningCalls) nest their so-far dispatches the same way.
import { Context } from 'cordis'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render } from '@testing-library/react'
import { createSnapshotStore, SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import type {
CodeSubCall, ConversationSnapshot, RunningToolCall, SessionId, SessionListState,
ToolResultNode, WorkspaceListState,
} from '@deepseek-ai/dsh-client-runtime/client'
import { createSlotRenderer } from '@deepseek-ai/dsh-client-web-react'
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client'
const SID = 's1' as SessionId
afterEach(cleanup)
beforeEach(() => {
localStorage.clear()
})
const PROGRAM = 'const listing = await tools.bash({ command: "ls notes", description: "List notes" })\nreturn listing'
const RUN_CODE_ARGS = JSON.stringify({ code: PROGRAM, description: 'List the notes directory' })
const codeResult = (seq: number, callId: string): ToolResultNode => ({
kind: 'tool-result', seq, time: seq * 1_000, callId,
call: { name: 'run_code', argsRaw: RUN_CODE_ARGS },
callTime: seq * 1_000 - 500,
content: [{ type: 'text', text: 'demo.txt' }], isError: false, callView: null, resultView: null,
})
const runningCode = (callId: string): RunningToolCall => ({
callId, name: 'run_code', argsRaw: RUN_CODE_ARGS, turn: 9, step: 0, time: 9_000, callView: null,
})
const subCall = (seq: number, parent: string, n: number, name: string, args: object, resultText: string, isError = false): CodeSubCall => ({
kind: 'tool-result', seq, time: seq * 1_000,
callId: `${parent}:code:${n}`,
call: { name, argsRaw: JSON.stringify(args) },
callTime: seq * 1_000,
content: [{ type: 'text', text: resultText }], isError, callView: null, resultView: null,
})
function snapshotWith(
nodes: ToolResultNode[],
codeDispatches: ReadonlyMap<string, readonly CodeSubCall[]>,
runningCalls: RunningToolCall[] = [],
): ConversationSnapshot {
return {
sessionId: SID, nodes, foldDegraded: false, partial: null, runningCalls, codeDispatches,
pending: [], queue: [], running: runningCalls.length > 0, composerPhase: 'active', removed: false,
openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
} as ConversationSnapshot
}
/** Test-owned AppFrame role: declares and renders the resident conversation area. */
type AppRootProps = PropsRenderSlots<'conversation' | 'details'>
function AppRoot({ renderSlot }: AppRootProps) {
return <>{renderSlot('conversation', {})}</>
}
/** Same real-stack bench as the toolview-slot spec: SlotsService + renderer + this package's apply; fakes only at service seams. */
async function bench(snapshot: ConversationSnapshot) {
const ctx = new Context()
const slotsFiber = ctx.plugin(SlotsService)
await slotsFiber.await()
const slots = ctx.get('slots') as SlotsService
const session = createSnapshotStore<ConversationSnapshot>(snapshot)
const list = createSnapshotStore<SessionListState>({
ids: [SID],
byId: { [SID]: { id: SID, title: 'S', displayTitle: 'S', running: false, blank: false, updatedAt: 1 } },
current: SID,
phase: 'ready',
})
const scoped = { send: vi.fn(async () => {}), cancel: vi.fn(async () => {}) }
const layout = { openDetails: vi.fn(), closeDetails: vi.fn() }
// Provide-channel contributions land in this bundle the way the runtime
// materializes them; the renderer host serves it through provideInfo.
const provided: { hooks: Record<string, unknown>; props: Record<string, unknown> } = { hooks: {}, props: {} }
const sessionsFake = {
list,
binding: (id: SessionId) => (id === SID
? { sessionId: SID, session, ctx: { effect: () => {}, on: () => () => {} } }
: undefined),
scope: () => ({ get: () => scoped }),
scopeOf: () => SID,
provide: (descriptor: { resolve: (binding: unknown) => { hooks?: Record<string, unknown>; props?: Record<string, unknown> } }) => {
const contribution = descriptor.resolve(sessionsFake.binding(SID))
Object.assign(provided.hooks, contribution.hooks ?? {})
Object.assign(provided.props, contribution.props ?? {})
return () => {}
},
provideInfo: (id: string) => (id === SID
? { sessionId: SID, hooks: { session, ...provided.hooks }, props: provided.props }
: undefined),
maybeProvideInfo: (id: string | undefined) => (id === SID
? { sessionId: SID, hooks: { session, ...provided.hooks }, props: provided.props }
: { hooks: provided.hooks, props: provided.props }),
create: vi.fn(),
open: vi.fn(),
}
ctx.provide('sessions', sessionsFake)
ctx.provide('workspaces', {
list: createSnapshotStore<WorkspaceListState>({
items: [], state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,
}),
startSession: vi.fn(),
sendSession: vi.fn(),
})
ctx.provide('layout', layout)
ctx.provide('i18n', { bind: () => (key: string) => key })
slots.install(createSlotRenderer())
slots.register({
name: 'root',
children: {
'conversation': { kind: 'single', scope: 'session-maybe' },
'details': { kind: 'single', scope: 'session' },
},
}, AppRoot)
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
return { ctx, slots, fiber, session, layout }
}
function mountApp(slots: SlotsService) {
return render(<>{slots.renderSlot('root', {})}</>)
}
describe('run_code sub-calls through the real chat machinery', () => {
it('renders the code-variant parent row with the description summary and nested sub-rows', async () => {
const parent = 'call-64'
const dispatches = new Map([[parent, [
subCall(11, parent, 1, 'bash', { command: 'ls notes', description: 'List notes' }, 'demo.txt'),
subCall(12, parent, 2, 'mystery', { n: 1 }, 'ok'),
]]])
const b = await bench(snapshotWith([codeResult(10, parent)], dispatches))
const view = mountApp(b.slots)
// Parent row: the code variant with the model-authored description.
const codeRoot = view.container.querySelector('[data-variant="code"]')
expect(codeRoot).not.toBeNull()
expect(view.getByText('Code')).toBeTruthy()
expect(view.getByText('List the notes directory')).toBeTruthy()
// Nested rows are ALWAYS visible (no parent expand needed): the bash
// sub-call landed in the bash sample plugin's keyed registration — the
// exact component a native top-level bash row uses — and the unregistered
// sub-tool fell back to GenericToolCard at the same render site.
const nest = view.container.querySelector('[data-subcalls]')
expect(nest).not.toBeNull()
expect(nest!.querySelector('[data-sample="bash-global"]')).not.toBeNull()
expect(view.getByText('List notes')).toBeTruthy()
expect(view.getByText('Tool call')).toBeTruthy()
})
it('expanding the code row reveals the program body verbatim (shiki-tokenized)', async () => {
const parent = 'call-64'
const b = await bench(snapshotWith([codeResult(10, parent)], new Map()))
const view = mountApp(b.slots)
// The code row is expandable via its leading control (body = the program).
const toggle = view.container.querySelector('[data-variant="code"] button[aria-expanded]')
expect(toggle).not.toBeNull()
fireEvent.click(toggle!)
// Shiki splits the program into token spans inside one <pre class="shiki">:
// assert the whole text and the highlighted tree rather than one node.
const pre = view.container.querySelector('pre.shiki')
expect(pre).not.toBeNull()
expect(pre!.textContent).toContain('const listing = await tools.bash')
expect(pre!.querySelectorAll('span[style]').length).toBeGreaterThan(3)
})
it('an isError sub-call renders the error state dot exactly like a failed native row', async () => {
const parent = 'call-64'
const dispatches = new Map([[parent, [
subCall(11, parent, 1, 'mystery', { n: 1 }, 'Error: boom', true),
]]])
const b = await bench(snapshotWith([codeResult(10, parent)], dispatches))
const view = mountApp(b.slots)
const nested = view.container.querySelector('[data-subcalls] [data-variant][data-state="error"]')
expect(nested).not.toBeNull()
})
it('a sub-row click opens details for the sub-callId', async () => {
const parent = 'call-64'
const dispatches = new Map([[parent, [
subCall(11, parent, 1, 'bash', { command: 'ls notes', description: 'List notes' }, 'demo.txt'),
]]])
const b = await bench(snapshotWith([codeResult(10, parent)], dispatches))
const view = mountApp(b.slots)
view.getByText('List notes').click()
expect(b.layout.openDetails).toHaveBeenCalledTimes(1)
})
it('a RUNNING run_code call nests its so-far dispatches under the spinner row', async () => {
const parent = 'call-live'
const dispatches = new Map([[parent, [
subCall(21, parent, 1, 'bash', { command: 'ls notes', description: 'List notes' }, 'demo.txt'),
]]])
const b = await bench(snapshotWith([], dispatches, [runningCode(parent)]))
const view = mountApp(b.slots)
const running = view.container.querySelector('[data-variant="code"][data-state="running"]')
expect(running).not.toBeNull()
const nest = view.container.querySelector('[data-subcalls]')
expect(nest).not.toBeNull()
expect(nest!.querySelector('[data-sample="bash-global"]')).not.toBeNull()
})
it('a started-but-unsettled sub-call renders the running state exactly like a native in-flight row', async () => {
const parent = 'call-live'
const runningSub: CodeSubCall = {
callId: `${parent}:code:1`, name: 'grep', argsRaw: '{"pattern":"todo"}',
turn: 0, step: 0, time: 21_000, callView: null,
}
const dispatches = new Map([[parent, [runningSub]]])
const b = await bench(snapshotWith([], dispatches, [runningCode(parent)]))
const view = mountApp(b.slots)
// The nested row derives 'running' from the RunningToolCall shape — the
// same StateDot ring a native in-flight row wears.
const nested = view.container.querySelector('[data-subcalls] [data-variant][data-state="running"]')
expect(nested).not.toBeNull()
})
it('an ordinary tool row renders no sub-call nest', async () => {
const parent = 'call-64'
const plain: ToolResultNode = {
kind: 'tool-result', seq: 10, time: 10_000, callId: parent,
call: { name: 'mystery', argsRaw: '{"n":1}' },
callTime: 9_500,
content: [], isError: false, callView: null, resultView: null,
}
const b = await bench(snapshotWith([plain], new Map()))
const view = mountApp(b.slots)
expect(view.container.querySelector('[data-subcalls]')).toBeNull()
})
})

View File

@@ -26,9 +26,9 @@ const assistant = (seq: number, turn: number, usage?: unknown): AssistantMessage
function snapshotBase(): ConversationSnapshot {
return {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [],
pending: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, intent: null, pendingPrompt: null, lastAgentError: null,
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
}
}
@@ -123,11 +123,10 @@ describe('bash sample row', () => {
return createSnapshotStore<SessionListState>({
ids: [ROOT, CHILD],
byId: {
[ROOT]: { id: ROOT, title: 'r', displayTitle: 'r', running: false, updatedAt: 0 },
[CHILD]: { id: CHILD, title: 'c', displayTitle: 'c', parentId: ROOT, running: false, updatedAt: 0 },
[ROOT]: { id: ROOT, title: 'r', displayTitle: 'r', running: false, blank: false, updatedAt: 0 },
[CHILD]: { id: CHILD, title: 'c', displayTitle: 'c', parentId: ROOT, running: false, blank: false, updatedAt: 0 },
},
current: undefined,
intent: undefined,
phase: 'ready',
} as SessionListState)
}
@@ -160,7 +159,7 @@ describe('bash sample row', () => {
const orphan = 'late-child' as SessionId
store.update((d) => {
d.ids.push(orphan)
d.byId[orphan] = { id: orphan, title: 'l', displayTitle: 'l', running: false, updatedAt: 0 }
d.byId[orphan] = { id: orphan, title: 'l', displayTitle: 'l', running: false, blank: false, updatedAt: 0 }
})
const view = render(<BashRow {...rowProps(orphan, { store })} />)
expect(view.container.querySelector('[data-sample="bash-global"]')).not.toBeNull()

View File

@@ -39,16 +39,16 @@ const toolResult = (seq: number, callId: string, name: string, args = '{"command
function snapshotWith(nodes: ToolResultNode[]): ConversationSnapshot {
return {
sessionId: SID, nodes, foldDegraded: false, partial: null, runningCalls: [],
pending: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, intent: null, pendingPrompt: null, lastAgentError: null,
sessionId: SID, nodes, foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
} as ConversationSnapshot
}
/** Test-owned AppFrame role: declares the layout-owned children and renders the conversation area under the framework session provider. */
type AppRootProps = PropsRenderSlots<'conversation' | 'details' | 'conversation.empty'>
function AppRoot({ renderSlot, SessionProvider }: AppRootProps) {
return <SessionProvider>{() => renderSlot('conversation', {})}</SessionProvider>
/** Test-owned AppFrame role: declares and renders the resident conversation area. */
type AppRootProps = PropsRenderSlots<'conversation' | 'details'>
function AppRoot({ renderSlot }: AppRootProps) {
return <>{renderSlot('conversation', {})}</>
}
/**
@@ -65,28 +65,60 @@ async function bench(nodes: ToolResultNode[]) {
const session = createSnapshotStore<ConversationSnapshot>(snapshotWith(nodes))
const list = createSnapshotStore<SessionListState>({
ids: [SID],
byId: { [SID]: { id: SID, title: 'S', displayTitle: 'S', running: false, updatedAt: 1 } },
byId: { [SID]: { id: SID, title: 'S', displayTitle: 'S', running: false, blank: false, updatedAt: 1 } },
current: SID,
intent: undefined,
phase: 'ready',
})
// Identity-stable cell: the renderer caches hooks per source and inject
// results per cell, both by object identity.
const cell = { sessionId: SID, session }
// Identity-stable provide bundle: the renderer caches hooks per source and
// inject results per bundle, both by object identity. Registered providers
// (the package's input contribution) materialize into it lazily, once.
const providers: ((binding: object) => { hooks?: object; props?: object })[] = []
let info: { sessionId: SessionId; hooks: object; props: object } | undefined
const scoped = { send: vi.fn(async () => {}), cancel: vi.fn(async () => {}) }
const layout = { openDetails: vi.fn(), closeDetails: vi.fn() }
const actxFake = { get: () => scoped, effect: () => {}, on: () => () => {} }
const bindingOf = (id: SessionId) => ({
sessionId: id,
ctx: actxFake,
session: {
sessionId: id,
loadOlder: vi.fn(),
prompt: vi.fn(async () => ({ ok: true, value: { accepted: true } })),
// Observable face for the input machine's queue read face.
getSnapshot: () => session.getSnapshot(),
subscribe: (fn: () => void) => session.subscribe(fn),
},
})
ctx.provide('sessions', {
list,
binding: (id: SessionId) => ({ sessionId: id, session: { loadOlder: vi.fn() } }),
scope: () => ({ get: () => scoped }),
cell: (id: string) => (id === SID ? cell : undefined),
binding: bindingOf,
scope: () => actxFake,
provideInfo: (id: string) => {
if (id !== SID) return undefined
if (info === undefined) {
const hooks: Record<string, unknown> = { session }
const props: Record<string, unknown> = {}
for (const provider of providers) {
const c = provider(bindingOf(SID))
Object.assign(hooks, c.hooks ?? {})
Object.assign(props, c.props ?? {})
}
info = { sessionId: SID, hooks, props }
}
return info
},
maybeProvideInfo(id: string | undefined) {
return (id === undefined ? undefined : this.provideInfo(id)) ?? { hooks: {}, props: {} }
},
provide: (d: { resolve: (typeof providers)[number] }) => { providers.push(d.resolve); return () => {} },
scopeOf: () => SID,
create: vi.fn(),
open: vi.fn(),
updateIntent: vi.fn(),
})
ctx.provide('workspaces', {
list: createSnapshotStore<WorkspaceListState>({
items: [], intent: undefined, state: 'idle', phase: 'ready', error: null,
items: [], state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,
}),
startSession: vi.fn(),
@@ -99,9 +131,8 @@ async function bench(nodes: ToolResultNode[]) {
slots.register({
name: 'root',
children: {
'conversation': { kind: 'single', scope: 'session' },
'conversation': { kind: 'single', scope: 'session-maybe' },
'details': { kind: 'single', scope: 'session' },
'conversation.empty': { kind: 'single', scope: 'root' },
},
}, AppRoot)
@@ -194,18 +225,20 @@ describe('registrant load-order seam', () => {
const slots = ctx.get('slots') as SlotsService
ctx.provide('sessions', {
list: createSnapshotStore<SessionListState>({
ids: [], byId: {}, current: undefined, intent: undefined, phase: 'ready',
ids: [], byId: {}, current: undefined, phase: 'ready',
}),
binding: () => undefined,
scope: () => undefined,
cell: () => undefined,
provideInfo: () => undefined,
maybeProvideInfo: () => ({ hooks: {}, props: {} }),
provide: () => () => {},
create: vi.fn(),
open: vi.fn(),
updateIntent: vi.fn(),
})
ctx.provide('workspaces', {
list: createSnapshotStore<WorkspaceListState>({
items: [], intent: undefined, state: 'idle', phase: 'ready', error: null,
items: [], state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,
}),
startSession: vi.fn(),
@@ -216,10 +249,9 @@ describe('registrant load-order seam', () => {
slots.register({
name: 'root',
children: {
'conversation': { kind: 'single', scope: 'session' },
'conversation': { kind: 'single', scope: 'session-maybe' },
'details': { kind: 'single', scope: 'session' },
'conversation.empty': { kind: 'single', scope: 'root' },
},
},
}, AppRoot)
// Third-party posture, mounted BEFORE ui-conversation: real fiber inject

View File

@@ -28,9 +28,9 @@ const SID = 's1' as SessionId
function snapshotBase(): ConversationSnapshot {
return {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [],
pending: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, intent: null, pendingPrompt: null, lastAgentError: null,
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
}
}
@@ -77,13 +77,13 @@ const runningCall = (callId: string, name = 'bash'): RunningToolCall => ({
/** Empty sessions-list hook for the global standard-kit seat. */
function emptySessions() {
const store = createSnapshotStore<SessionListState>(
{ ids: [], byId: {}, current: undefined, intent: undefined, phase: 'ready' })
{ ids: [], byId: {}, current: undefined, phase: 'ready' })
return bindSnapshotSelector(store)
}
function emptyWorkspaces() {
const store = createSnapshotStore<WorkspaceListState>({
items: [], intent: undefined, state: 'idle', phase: 'ready', error: null,
items: [], state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,
})
return bindSnapshotSelector(store)
@@ -109,6 +109,8 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
useSession: bindSnapshotSelector(source),
useSessions: emptySessions(),
useWorkspaces: emptyWorkspaces(),
useInput: (() => { throw new Error('unused') }) as never,
inputActions: { setDraft: () => {}, submit: () => {} } as never,
useStore: bindSnapshotSelector(chat),
actions: chat.actions,
renderSlot,

View File

@@ -87,9 +87,8 @@ describe('tails', () => {
const sid = 'root-1' as SessionId
const list = createSnapshotStore<SessionListState>({
ids: [sid],
byId: { [sid]: { id: sid, title: 'r', displayTitle: 'r', running: false, updatedAt: 0 } },
byId: { [sid]: { id: sid, title: 'r', displayTitle: 'r', running: false, blank: false, updatedAt: 0 } },
current: undefined,
intent: undefined,
phase: 'ready',
} as SessionListState)
const props = {

View File

@@ -18,9 +18,9 @@ const SID = 's1' as SessionId
function snapshotBase(): ConversationSnapshot {
return {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [],
pending: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, intent: null, pendingPrompt: null, lastAgentError: null,
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
} as ConversationSnapshot
}
@@ -65,9 +65,9 @@ describe('render branch tails', () => {
const chat = createChatStore().create()
chat.actions.select({ turnSeq: 1, callId: 'ghost' } satisfies SelectionTarget)
const emptyList = createSnapshotStore<SessionListState>(
{ ids: [], byId: {}, current: undefined, intent: undefined, phase: 'ready' })
{ ids: [], byId: {}, current: undefined, phase: 'ready' })
const emptyWorkspaces = createSnapshotStore<WorkspaceListState>({
items: [], intent: undefined, state: 'idle', phase: 'ready', error: null,
items: [], state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,
})
const view = render(
@@ -76,6 +76,8 @@ describe('render branch tails', () => {
useSession={bindSnapshotSelector({ getSnapshot: () => snap, subscribe: () => () => {} }) as unknown as UseSession<ConversationSnapshot>}
useSessions={bindSnapshotSelector(emptyList)}
useWorkspaces={bindSnapshotSelector(emptyWorkspaces)}
useInput={(() => { throw new Error('unused') }) as never}
inputActions={{ setDraft: () => {}, submit: () => {} } as never}
useStore={bindSnapshotSelector(chat)}
actions={chat.actions}
closeDetails={vi.fn()}
@@ -84,4 +86,42 @@ describe('render branch tails', () => {
expect(view.getByText('详情')).toBeTruthy()
expect(view.getByText('该调用不在当前窗口内')).toBeTruthy()
})
it('DetailsPanel resolves a run_code sub-callId to its full logged args and output', () => {
localStorage.clear()
const snap = snapshotBase()
const longText = 'x'.repeat(1_000)
snap.codeDispatches = new Map([['p1', [{
kind: 'tool-result', seq: 8, time: 8_000, callId: 'p1:code:1',
call: { name: 'read', argsRaw: '{"path":"notes/demo.txt"}' },
callTime: 8_000,
content: [{ type: 'text', text: longText }], isError: false, callView: null, resultView: null,
}]]])
const chat = createChatStore().create()
chat.actions.select({ turnSeq: 8, callId: 'p1:code:1', toolName: 'read' } satisfies SelectionTarget)
const emptyList = createSnapshotStore<SessionListState>(
{ ids: [], byId: {}, current: undefined, phase: 'ready' })
const emptyWorkspaces = createSnapshotStore<WorkspaceListState>({
items: [], state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,
})
const view = render(
<DetailsPanel
sessionId={SID}
useSession={bindSnapshotSelector({ getSnapshot: () => snap, subscribe: () => () => {} }) as unknown as UseSession<ConversationSnapshot>}
useSessions={bindSnapshotSelector(emptyList)}
useWorkspaces={bindSnapshotSelector(emptyWorkspaces)}
useInput={(() => { throw new Error('unused') }) as never}
inputActions={{ setDraft: () => {}, submit: () => {} } as never}
useStore={bindSnapshotSelector(chat)}
actions={chat.actions}
closeDetails={vi.fn()}
/>,
)
// Sub-call material: the sub-tool name titles the panel, args pretty-print,
// and the COMPLETE logged output renders (no truncation anywhere).
expect(view.getByText('read')).toBeTruthy()
expect(view.getByText(/notes\/demo\.txt/)).toBeTruthy()
expect(view.getByText(longText)).toBeTruthy()
})
})

View File

@@ -1,21 +1,100 @@
// @vitest-environment jsdom
// InputBar behavior: Enter-send semantics (IME guard, shift newline,
// ctrl/meta insert, repeat suppression), the running lock with stop-only
// action, unlock refocus, error strip copy, and the focus-keeping mousedown.
// InputBar behavior over the machine wiring: Enter-send semantics (IME guard,
// shift newline, ctrl/meta insert, repeat suppression), queue-cut-1 running
// semantics (input stays free; primary turns stop), the machine pending lock,
// decoration backdrop, error/notice strips, and the focus-keeping mousedown.
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render } from '@testing-library/react'
import { act, cleanup, fireEvent, render } from '@testing-library/react'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { ClientContext, ConversationSnapshot, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import { SessionInputShell } from '../src/client/input/facade.ts'
import { InputBar } from '../src/client/skeleton/InputBar.tsx'
import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx'
afterEach(cleanup)
function setup(over?: Partial<InputBarProps>) {
const SCTX = {} as ClientContext
const SID = 's1' as SessionId
function snapshotOf(overrides: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
return {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, lastAgentError: null,
...overrides,
}
}
interface BenchOptions {
planEntry?: React.ReactNode
modelEntry?: React.ReactNode
/** Hot text-ref lexicon (injects a minimal slash stub exposing only lexicon()). */
lexicon?: ReadonlyMap<'/' | '@', readonly string[]>
draft?: string
running?: boolean
disabled?: boolean
promptError?: ConversationSnapshot['promptError']
variant?: 'hero' | 'composer'
placeholder?: string
accessory?: React.ReactNode
overlay?: React.ReactNode
leftItems?: React.ReactNode
rightItems?: React.ReactNode
}
/** Real machine behind the bar entry: sink spy, no slash pipeline (plain text goes straight to the sink). */
function bench(over?: BenchOptions) {
const sink = vi.fn()
const lex = over?.lexicon
type ShellDeps = ConstructorParameters<typeof SessionInputShell>[0]
const shell = new SessionInputShell({
actx: SCTX,
defaultSink: sink,
// Lexicon-only stub: adjudication untouched (undefined slash methods are
// never reached — these benches drive plain-draft flows only).
...(lex !== undefined
? { slash: (() => ({ lexicon: () => lex })) as unknown as NonNullable<ShellDeps['slash']> }
: {}),
})
if (over?.draft !== undefined && over.draft !== '') shell.setDraft(over.draft)
const session = createSnapshotStore<ConversationSnapshot>(snapshotOf({
running: over?.running ?? false,
removed: over?.disabled ?? false,
promptError: over?.promptError ?? null,
}))
const stop = vi.fn()
const slotCalls: { key: string; owner: unknown }[] = []
const renderSlot = ((key: string, owner: object) => {
slotCalls.push({ key, owner })
if (key === 'conversation.input.plan') return over?.planEntry ?? null
if (key === 'conversation.input.model') return over?.modelEntry ?? null
return null
}) as InputBarProps['renderSlot']
const props: InputBarProps = {
draft: 'hello', running: false, disabled: false, error: null,
variant: 'composer',
onDraftChange: vi.fn(), onSend: vi.fn(), onStop: vi.fn(),
...over,
sessionId: SID,
SessionProvider: ({ children }) => children(SID),
useSession: bindSnapshotSelector(session),
useSessions: bindSnapshotSelector(createSnapshotStore({
ids: [], byId: {}, current: undefined, phase: 'ready',
})) as InputBarProps['useSessions'],
useWorkspaces: bindSnapshotSelector(createSnapshotStore({
items: [], state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,
})) as InputBarProps['useWorkspaces'],
useInput: bindSnapshotSelector(shell.state),
inputActions: shell.actions,
keyboard: shell,
stop,
renderSlot,
variant: over?.variant ?? 'composer',
...(over?.placeholder !== undefined ? { placeholder: over.placeholder } : {}),
...(over?.accessory !== undefined ? { accessory: over.accessory } : {}),
...(over?.overlay !== undefined ? { overlay: over.overlay } : {}),
...(over?.leftItems !== undefined ? { leftItems: over.leftItems } : {}),
...(over?.rightItems !== undefined ? { rightItems: over.rightItems } : {}),
}
const view = render(<InputBar {...props} />)
const textarea = view.container.querySelector('textarea')!
@@ -23,147 +102,280 @@ function setup(over?: Partial<InputBarProps>) {
const button = view.container.querySelector<HTMLButtonElement>(
`button[aria-label="${over?.running === true ? 'Stop generating' : 'Send message'}"]`,
)!
return { view, textarea, button, props }
return { view, textarea, button, props, sink, shell, wiring: shell, session, stop, slotCalls }
}
describe('Enter semantics', () => {
it('plain Enter sends queue mode; repeat and empty are suppressed', () => {
const { textarea, props } = setup()
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(props.onSend).toHaveBeenCalledWith('queue')
expect(sink).toHaveBeenCalledWith('hello', 'queue')
fireEvent.keyDown(textarea, { key: 'Enter', repeat: true })
expect(props.onSend).toHaveBeenCalledTimes(1)
const empty = setup({ draft: ' ' })
expect(sink).toHaveBeenCalledTimes(1)
const empty = bench({ draft: ' ' })
fireEvent.keyDown(empty.textarea, { key: 'Enter' })
expect(empty.props.onSend).not.toHaveBeenCalled()
expect(empty.sink).not.toHaveBeenCalled()
})
it('non-Enter keys and Shift+Enter fall through to native behavior', () => {
const { textarea, props } = setup()
const { textarea, sink } = bench({ draft: 'hello' })
fireEvent.keyDown(textarea, { key: 'a' })
fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: true })
expect(props.onSend).not.toHaveBeenCalled()
expect(sink).not.toHaveBeenCalled()
})
it('Ctrl/Meta+Enter inserts a newline through execCommand instead of sending', () => {
const exec = vi.fn()
;(document as unknown as { execCommand: typeof exec }).execCommand = exec
const { textarea, props } = setup()
it('Shift+Enter newline wins even inside IME composition (unconditional precedence)', () => {
const { textarea, sink } = bench({ draft: 'hello' })
fireEvent.compositionStart(textarea)
fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: true })
expect(sink).not.toHaveBeenCalled() // and not preventDefault'd: native newline
})
it('Ctrl/Meta+Enter inserts a newline through the machine (no browser execCommand)', () => {
const { textarea, shell, sink } = bench({ draft: 'hello' })
textarea.setSelectionRange(5, 5)
fireEvent.keyDown(textarea, { key: 'Enter', ctrlKey: true })
expect(exec).toHaveBeenCalledWith('insertText', false, '\n')
expect(props.onSend).not.toHaveBeenCalled()
expect(shell.snapshot.draft).toBe('hello\n')
expect(sink).not.toHaveBeenCalled()
})
it('composition Enter never sends: ref guard, isComposing, and keyCode 229 paths', async () => {
it('platform undo/redo chords route to the machine, never the browser stack', () => {
const { textarea, shell } = bench({ draft: '' })
fireEvent.change(textarea, { target: { value: 'first' } })
fireEvent.change(textarea, { target: { value: 'first second' } })
fireEvent.keyDown(textarea, { key: 'z', ctrlKey: true })
expect(shell.snapshot.draft).not.toBe('first second')
fireEvent.keyDown(textarea, { key: 'z', ctrlKey: true, shiftKey: true })
expect(shell.snapshot.draft).toBe('first second')
})
it('composition Enter never sends: ref guard, isComposing, and keyCode 229 paths', () => {
vi.useFakeTimers()
try {
const { textarea, props } = setup()
const { textarea, sink } = bench({ draft: 'hello' })
fireEvent.compositionStart(textarea)
fireEvent.keyDown(textarea, { key: 'Enter' })
expect(props.onSend).not.toHaveBeenCalled()
expect(sink).not.toHaveBeenCalled()
fireEvent.compositionEnd(textarea)
// Safari delivers the closing keydown before the deferred clear.
fireEvent.keyDown(textarea, { key: 'Enter' })
expect(props.onSend).not.toHaveBeenCalled()
expect(sink).not.toHaveBeenCalled()
vi.advanceTimersByTime(20)
fireEvent.keyDown(textarea, { key: 'Enter', keyCode: 229 })
expect(props.onSend).not.toHaveBeenCalled()
expect(sink).not.toHaveBeenCalled()
fireEvent.keyDown(textarea, { key: 'Enter' })
expect(props.onSend).toHaveBeenCalledTimes(1)
expect(sink).toHaveBeenCalledTimes(1)
} finally {
vi.useRealTimers()
}
})
})
describe('running lock and primary button', () => {
it('running locks the textarea and turns the primary into stop', () => {
const { textarea, button, props } = setup({ running: true })
expect(textarea.disabled).toBe(true)
describe('running and lock semantics (queue cut 1)', () => {
it('running keeps the input free (typing + Enter queue) while the primary turns stop', () => {
const { textarea, button, stop, sink } = bench({ running: true, draft: '排队消息' })
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(button.getAttribute('aria-label')).toBe('Stop generating')
fireEvent.click(button)
expect(props.onStop).toHaveBeenCalledTimes(1)
expect(props.onSend).not.toHaveBeenCalled()
expect(stop).toHaveBeenCalledTimes(1)
})
it('disabled (session removed) locks the textarea and chrome', () => {
const { textarea, view } = bench({ disabled: true })
expect(textarea.disabled).toBe(true)
expect(textarea.placeholder).toBe('Session unavailable')
expect((view.getByLabelText('Add attachment') as HTMLButtonElement).disabled).toBe(true)
})
it('idle primary sends and disables on empty draft', () => {
const { button, props } = setup()
const { button, sink } = bench({ draft: 'go' })
fireEvent.click(button)
expect(props.onSend).toHaveBeenCalledWith('queue')
const empty = setup({ draft: '' })
expect(sink).toHaveBeenCalledWith('go', 'queue')
const empty = bench()
expect(empty.button.disabled).toBe(true)
})
it('unlock refocuses the textarea; mousedown on the button keeps focus', () => {
const { view, props } = setup({ running: true })
view.rerender(<InputBar {...props} running={false} />)
const textarea = view.container.querySelector('textarea')!
const first = bench({ disabled: true, draft: 'x' })
act(() => { first.session.set(snapshotOf({ removed: false })) })
const textarea = first.view.container.querySelector('textarea')!
expect(document.activeElement).toBe(textarea)
textarea.blur()
fireEvent.mouseDown(view.container.querySelector('button[aria-label="Send message"]')!)
fireEvent.mouseDown(first.view.container.querySelector('button[aria-label="Send message"]')!)
expect(document.activeElement).toBe(textarea)
})
it('disabled state shows the unavailable placeholder; typing forwards drafts', () => {
const { textarea } = setup({ disabled: true, draft: '' })
it('typing forwards through the machine (draft state echoes back)', () => {
const { textarea, wiring } = bench()
fireEvent.change(textarea, { target: { value: 'typed' } })
expect(wiring.state.getSnapshot().draft).toBe('typed')
expect((textarea as HTMLTextAreaElement).value).toBe('typed')
})
it('disabled state shows the unavailable placeholder; custom placeholder wins', () => {
const { textarea } = bench({ disabled: true })
expect(textarea.placeholder).toBe('Session unavailable')
const live = setup({ draft: '' })
const live = bench()
expect(live.textarea.placeholder).toBe('Message the agent')
fireEvent.change(live.textarea, { target: { value: 'typed' } })
expect(live.props.onDraftChange).toHaveBeenCalledWith('typed')
const runningPh = setup({ running: true, draft: '' })
expect(runningPh.textarea.placeholder).toBe('Generating a response…')
const custom = setup({ placeholder: 'Custom placeholder' })
const custom = bench({ placeholder: 'Custom placeholder' })
expect(custom.textarea.placeholder).toBe('Custom placeholder')
})
})
describe('error strip and variants', () => {
it('renders send and stop failure copy', () => {
const send = setup({ error: { op: 'send', message: 'boom' } })
expect(send.view.container.querySelector('[role="alert"]')?.textContent).toBe('boom')
const stop = setup({ error: { op: 'stop', message: 'halt' } })
expect(stop.view.container.querySelector('[role="alert"]')?.textContent).toBe('halt')
describe('machine pending lock', () => {
it('submitting renders read-only textarea, pending dot, and a disabled primary', () => {
const { view, shell } = bench()
// Drive the machine into submitting through a claim + enter.
act(() => {
shell.setDraft('/goal ')
shell.beginCommand(
{
token: '/goal ',
submit: () => new Promise<never>(() => {}), // never settles: stays submitting
},
{ start: 0, end: 6, draftRev: shell.snapshot.draftRev },
)
shell.submit('queue')
})
expect(shell.snapshot.phase).toBe('submitting')
const textarea = view.container.querySelector('textarea')!
expect(textarea.readOnly).toBe(true)
expect(view.container.querySelector('[data-input-pending]')).not.toBeNull()
expect(view.container.querySelector<HTMLButtonElement>('button[aria-label="Send message"]')!.disabled).toBe(true)
})
})
describe('decorations', () => {
it('claimed token renders the mirror highlight and the blank-args hint', () => {
const { view, shell } = bench()
act(() => {
shell.setDraft('/goal ')
shell.beginCommand(
{ token: '/goal ', hint: '目标内容', submit: () => Promise.resolve({ kind: 'success' as const }) },
{ start: 0, end: 6, draftRev: shell.snapshot.draftRev },
)
})
const token = view.container.querySelector('[data-decoration="token"]')
expect(token?.textContent).toBe('/goal ')
expect(view.container.querySelector('[data-decoration="hint"]')?.textContent).toBe('目标内容')
// Args typed: the hint disappears, the token highlight stays.
act(() => { shell.setDraft('/goal 发布') })
expect(view.container.querySelector('[data-decoration="hint"]')).toBeNull()
expect(view.container.querySelector('[data-decoration="token"]')).not.toBeNull()
})
it('an inserted reference renders as a chip at its placeholder offset', () => {
const { view, shell } = bench()
act(() => {
shell.setDraft('参考 @w1 内容')
shell.insertReference(
{ source: 'subagent', ref: 'w1', label: '@w1', clipboardText: '@w1' },
{ start: 3, end: 6, draftRev: shell.snapshot.draftRev },
)
})
const chip = view.container.querySelector('[data-decoration="chip"]')
expect(chip?.textContent).toBe('@w1')
expect(shell.snapshot.occurrences).toHaveLength(1)
// The draft carries exactly one placeholder char where the token was.
expect(shell.snapshot.draft).toBe('参考 \uFFFC 内容')
})
it('a lexicon-matched plain token renders the text-ref mark (decision 21)', () => {
const lexicon = new Map<'/' | '@', readonly string[]>([['/', ['fixture-demo']]])
const { view, shell } = bench({ lexicon })
act(() => { shell.setDraft('use /fixture-demo now') })
const mark = view.container.querySelector('[data-decoration="text-ref"]')
expect(mark?.textContent).toBe('/fixture-demo')
// Editing the token out of match shape drops the decoration.
act(() => { shell.setDraft('use /fixture-dem now') })
expect(view.container.querySelector('[data-decoration="text-ref"]')).toBeNull()
})
})
describe('insertText (decision 21 scoped event body)', () => {
it('splices plain text over the span and reports success as true', () => {
const { shell } = bench({ draft: '/fix' })
const ok = shell.insertText('/fixture-demo ', { start: 0, end: 4, draftRev: shell.snapshot.draftRev })
expect(ok).toBe(true)
expect(shell.snapshot.draft).toBe('/fixture-demo ')
expect(shell.snapshot.occurrences).toEqual([])
})
it('a stale draftRev refuses whole: false, draft untouched', () => {
const { shell } = bench({ draft: '/fix' })
const span = { start: 0, end: 4, draftRev: shell.snapshot.draftRev }
act(() => { shell.setDraft('/fixX') })
expect(shell.insertText('/fixture-demo ', span)).toBe(false)
expect(shell.snapshot.draft).toBe('/fixX')
})
})
describe('strips and variants', () => {
it('derives the failure strip from promptError (ordinary failure — no transaction UI, no Retry)', () => {
const send = bench({ promptError: { op: 'send', error: { code: 'agent-busy', message: 'boom', details: { reason: 'boom' } } } })
expect(send.view.container.querySelector('[role="alert"]')?.textContent).toBe('boom (agent-busy)')
expect(send.view.queryByRole('button', { name: 'Retry' })).toBeNull()
})
it('renders the notice strip from the machine notice store', () => {
const { view, shell } = bench()
act(() => { shell.notify('error', '命令失败了') })
expect(view.getByText('命令失败了')).toBeTruthy()
})
it('hero variant adds the hero class and accessory row renders', () => {
const { view } = setup({ variant: 'hero', accessory: <i data-testid="acc" /> })
const { view } = bench({ variant: 'hero', accessory: <i data-testid="acc" /> })
expect(view.getByTestId('acc')).toBeTruthy()
expect(view.container.querySelector('[class*="hero"]')).not.toBeNull()
})
it('renders overlay anchor and left/right slot items', () => {
const { view } = bench({
overlay: <i data-testid="ov" />,
leftItems: <i data-testid="li" />,
rightItems: <i data-testid="ri" />,
})
expect(view.getByTestId('ov')).toBeTruthy()
expect(view.getByTestId('li')).toBeTruthy()
expect(view.getByTestId('ri')).toBeTruthy()
})
})
describe('placeholder chrome', () => {
it('renders attach / Plan / Read-only / model controls', () => {
const { view } = setup()
describe('placeholder chrome and control seats', () => {
it('renders attach + Access placeholder; plan/model seats render EMPTY without entries (B ruling)', () => {
const { view, slotCalls } = bench()
expect(view.getByLabelText('Add attachment')).toBeTruthy()
expect((view.getByLabelText('Plan mode') as HTMLSelectElement).value).toBe('plan')
expect((view.getByLabelText('Access mode') as HTMLSelectElement).value).toBe('readonly')
expect((view.getByLabelText('Model') as HTMLSelectElement).value).toBe('v4-pro-high')
// Both seats dispatched, nothing rendered.
expect(slotCalls.map(c => c.key)).toEqual(['conversation.input.plan', 'conversation.input.model'])
expect(view.queryByLabelText('Plan mode')).toBeNull()
expect(view.queryByLabelText('Model')).toBeNull()
})
it('native select change updates the selected option', () => {
const { view } = setup()
const plan = view.getByLabelText('Plan mode') as HTMLSelectElement
fireEvent.change(plan, { target: { value: 'agent' } })
expect(plan.value).toBe('agent')
const access = view.getByLabelText('Access mode') as HTMLSelectElement
fireEvent.change(access, { target: { value: 'readwrite' } })
expect(access.value).toBe('readwrite')
it('a registered entry fills its seat and receives the locked owner prop', () => {
const { view, slotCalls } = bench({
disabled: true,
planEntry: <i data-testid="plan-entry" />,
modelEntry: <i data-testid="model-entry" />,
})
expect(view.getByTestId('plan-entry')).toBeTruthy()
expect(view.getByTestId('model-entry')).toBeTruthy()
// The bar hands its chrome disable state to the filling entry.
expect(slotCalls.every(c => (c.owner as { locked: boolean }).locked === true)).toBe(true)
cleanup()
const live = bench({ running: true })
expect(live.slotCalls.every(c => (c.owner as { locked: boolean }).locked === false)).toBe(true)
})
it('model select can drop the High option', () => {
const { view } = setup()
const model = view.getByLabelText('Model') as HTMLSelectElement
fireEvent.change(model, { target: { value: 'v4-pro' } })
expect(model.value).toBe('v4-pro')
expect(model.selectedOptions[0]?.textContent).toBe('DeepSeek-V4-Pro')
})
it('running locks the chrome selects and attach control', () => {
const { view } = setup({ running: true })
it('disabled locks the Access placeholder and attach control (running does not)', () => {
const { view } = bench({ disabled: true })
expect((view.getByLabelText('Add attachment') as HTMLButtonElement).disabled).toBe(true)
expect((view.getByLabelText('Plan mode') as HTMLSelectElement).disabled).toBe(true)
expect((view.getByLabelText('Model') as HTMLSelectElement).disabled).toBe(true)
expect((view.getByLabelText('Access mode') as HTMLSelectElement).disabled).toBe(true)
cleanup()
const live = bench({ running: true })
expect((live.view.getByLabelText('Access mode') as HTMLSelectElement).disabled).toBe(false)
})
})

View File

@@ -0,0 +1,846 @@
/**
* InputMachine unit account (design §9.1, eng. plan §3.9-3.12): the submit
* plane carried over from the InputCore era (adjudication, span CAS, drift
* guard, anti-backwash), plus the occurrence table (shift / whole-chip
* deletion / same-name independence), the self-managed undo log (typing
* coalescing, paste two-stage undo, redo chain), consume-token guards, the
* paste attempt lifecycle, projectClipboard, and the decoration projection.
* Pure event sequences — no React, no DOM, no ambient clock.
*/
import { describe, expect, it } from 'vitest'
import type { CommandClaim, ReferenceInsert, TokenSpan } from '@deepseek-ai/dsh-client-ui-slash/client'
import type { InputEffect, SubmitAttempt } from '../src/client/input/contract.ts'
import { InputMachine, PLACEHOLDER, projectClipboard } from '../src/client/input/machine.ts'
import { deriveDecorations, scanTextRefs } from '../src/client/input/decorations.ts'
const P = PLACEHOLDER
function claimOf(name: string, hint?: string): CommandClaim {
return {
token: `/${name} `,
...(hint !== undefined ? { hint } : {}),
submit: async () => ({ kind: 'success' }),
}
}
function refOf(name: string, source = 'skill'): ReferenceInsert {
return { source, ref: name, label: name, clipboardText: `/${name}` }
}
function spanOf(m: InputMachine, start: number, end: number): TokenSpan {
return { start, end, draftRev: m.state.draftRev }
}
function effectAt<T extends InputEffect['type']>(
effects: readonly InputEffect[], index: number, type: T,
): Extract<InputEffect, { type: T }> {
const e = effects[index]
expect(e?.type).toBe(type)
return e as Extract<InputEffect, { type: T }>
}
/** Drive plain → adjudicating and hand back the minted attempt. */
function enterAdjudicating(m: InputMachine, draft: string, mode: 'queue' | 'steer' = 'queue'): SubmitAttempt {
m.dispatch({ type: 'draft-changed', draft })
const fx = m.dispatch({ type: 'enter', mode })
return effectAt(fx, 0, 'adjudicate').attempt
}
/** Drive plain → claimed → submitting and hand back attempt + claim. */
function enterSubmitting(m: InputMachine, name: string, args: string): { attempt: SubmitAttempt; claim: CommandClaim } {
const claim = claimOf(name)
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' })
return { attempt: effectAt(fx, 0, 'begin-submit').attempt, claim }
}
function staleAttempt(): SubmitAttempt {
return { seq: 9999, signal: new AbortController().signal, draftSnapshot: '' }
}
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([])
m.dispatch({ type: 'draft-changed', draft: ' \n ' })
expect(m.dispatch({ type: 'enter', mode: 'queue' })).toEqual([])
expect(m.state.phase).toBe('plain')
})
it('non-command text falls to the default sink with the given mode', () => {
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.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 eff = effectAt(fx, 0, 'adjudicate')
expect(eff.draft).toBe('/goal x')
expect(eff.attempt.draftSnapshot).toBe('/goal x')
expect(eff.attempt.signal.aborted).toBe(false)
expect(m.state.phase).toBe('adjudicating')
})
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')
})
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' }])
})
})
describe('input-machine: adjudication outcomes', () => {
it('{claim} moves to submitting; args split on the first whitespace, newlines kept', () => {
const m = new InputMachine()
const attempt = enterAdjudicating(m, '/goal x\ny')
const fx = m.dispatch({ type: 'adjudicated', attempt, outcome: { claim: claimOf('goal') } })
const eff = effectAt(fx, 0, 'begin-submit')
expect(eff.args).toBe('x\ny')
expect(eff.attempt.seq).toBe(attempt.seq)
expect(m.state.phase).toBe('submitting')
expect(m.state.claim).toEqual({ token: '/goal ' })
})
it('bare "/goal" claim yields empty args; leading whitespace snapshot yields trimmed args', () => {
const a = new InputMachine()
const attemptA = enterAdjudicating(a, '/goal')
expect(effectAt(a.dispatch({ type: 'adjudicated', attempt: attemptA, outcome: { claim: claimOf('goal') } }), 0, 'begin-submit').args).toBe('')
const b = new InputMachine()
const attemptB = enterAdjudicating(b, '\n\n/goal x')
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', () => {
const m = new InputMachine()
const attempt = enterAdjudicating(m, '/unknown thing', 'steer')
expect(m.dispatch({ type: 'adjudicated', attempt, outcome: undefined }))
.toEqual([{ type: 'default-sink', draft: '/unknown thing', mode: 'steer' }])
expect(m.state.phase).toBe('plain')
})
it("'handled' lands plain with zero effects (popup shell path)", () => {
const m = new InputMachine()
const attempt = enterAdjudicating(m, '/model')
expect(m.dispatch({ type: 'adjudicated', attempt, outcome: 'handled' })).toEqual([])
expect(m.state.phase).toBe('plain')
expect(m.state.draft).toBe('/model')
})
it('adjudication failure notices and keeps the draft — no silent downgrade', () => {
const m = new InputMachine()
const attempt = enterAdjudicating(m, '/goal x')
expect(m.dispatch({ type: 'adjudication-failed', attempt, message: 'warmup failed' }))
.toEqual([{ type: 'notice', level: 'error', text: 'warmup failed' }])
expect(m.state.phase).toBe('plain')
expect(m.state.draft).toBe('/goal x')
})
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.state.phase).toBe('adjudicating')
})
it('a stale attempt on adjudicated/adjudication-failed is dropped: same state, zero effects', () => {
const m = new InputMachine()
enterAdjudicating(m, '/goal x')
expect(m.dispatch({ type: 'adjudicated', attempt: staleAttempt(), outcome: { claim: claimOf('goal') } })).toEqual([])
expect(m.dispatch({ type: 'adjudication-failed', attempt: staleAttempt(), message: 'x' })).toEqual([])
expect(m.state.phase).toBe('adjudicating')
})
it('an adjudicated result arriving after release is dropped (anti-backwash)', () => {
const m = new InputMachine()
const attempt = enterAdjudicating(m, '/goal x')
m.dispatch({ type: 'release' })
expect(m.dispatch({ type: 'adjudicated', attempt, outcome: { claim: claimOf('goal') } })).toEqual([])
expect(m.state.phase).toBe('plain')
})
})
describe('input-machine: begin-command CAS', () => {
it('valid span replaces it with the token and enters claimed; success = draftRev advance', () => {
const m = new InputMachine()
m.dispatch({ type: 'draft-changed', draft: '/go' })
const before = m.state.draftRev
const fx = m.dispatch({ type: 'begin-command', claim: claimOf('goal', 'objective'), span: spanOf(m, 0, 3) })
expect(fx).toEqual([])
expect(m.state.draftRev).toBeGreaterThan(before)
expect(m.state.draft).toBe('/goal ')
expect(m.state.phase).toBe('claimed')
expect(m.state.claim).toEqual({ token: '/goal ', hint: 'objective' })
})
it('a leading-whitespace prefix is dropped so the startsWith watch holds', () => {
const m = new InputMachine()
m.dispatch({ type: 'draft-changed', draft: '\n\n/go' })
m.dispatch({ type: 'begin-command', claim: claimOf('goal'), span: spanOf(m, 2, 5) })
expect(m.state.draft).toBe('/goal ')
m.dispatch({ type: 'draft-changed', draft: '/goal x' })
expect(m.state.phase).toBe('claimed')
})
it('a stale draftRev no-ops the whole action — no state change, no revision bump', () => {
const m = new InputMachine()
m.dispatch({ type: 'draft-changed', draft: '/go' })
const span = spanOf(m, 0, 3)
m.dispatch({ type: 'draft-changed', draft: '/goX' })
const rev = m.state.draftRev
expect(m.dispatch({ type: 'begin-command', claim: claimOf('goal'), span })).toEqual([])
expect(m.state).toMatchObject({ phase: 'plain', draft: '/goX', draftRev: rev })
})
it('a non-whitespace prefix before the span no-ops (leading-trigger contract)', () => {
const m = new InputMachine()
m.dispatch({ type: 'draft-changed', draft: 'x /go' })
expect(m.dispatch({ type: 'begin-command', claim: claimOf('goal'), span: spanOf(m, 2, 5) })).toEqual([])
expect(m.state.phase).toBe('plain')
})
it('claimed overwrites in place — no stack', () => {
const m = new InputMachine()
m.dispatch({ type: 'draft-changed', draft: '/go' })
m.dispatch({ type: 'begin-command', claim: claimOf('goal'), span: spanOf(m, 0, 3) })
m.dispatch({ type: 'begin-command', claim: claimOf('model'), span: spanOf(m, 0, 6) })
expect(m.state.draft).toBe('/model ')
expect(m.state.claim?.token).toBe('/model ')
expect(m.state.phase).toBe('claimed')
})
it('submitting rejects begin-command (lock)', () => {
const m = new InputMachine()
enterSubmitting(m, 'goal', 'x')
expect(m.dispatch({ type: 'begin-command', claim: claimOf('model'), span: spanOf(m, 0, 6) })).toEqual([])
expect(m.state.claim?.token).toBe('/goal ')
expect(m.state.phase).toBe('submitting')
})
it('undo reverts the claim transaction and the watch releases the claim', () => {
const m = new InputMachine()
m.dispatch({ type: 'draft-changed', draft: '/go' })
m.dispatch({ type: 'begin-command', claim: claimOf('goal'), span: spanOf(m, 0, 3) })
m.dispatch({ type: 'undo' })
expect(m.state).toMatchObject({ draft: '/go', phase: 'plain' })
expect(m.state.claim).toBeUndefined()
})
})
describe('input-machine: insert-ref and the occurrence table', () => {
it('valid span becomes one placeholder + one occurrence with cached projections', () => {
const m = new InputMachine()
m.dispatch({ type: 'draft-changed', draft: 'see @wor now' })
const fx = m.dispatch({ type: 'insert-ref', reference: refOf('worker-1', 'subagent'), span: spanOf(m, 4, 8) })
expect(fx).toEqual([])
expect(m.state.draft).toBe(`see ${P} now`)
expect(m.state.occurrences).toEqual([{
occurrenceId: 1, source: 'subagent', ref: 'worker-1', offset: 4,
label: 'worker-1', clipboardText: '/worker-1',
}])
expect(m.state.phase).toBe('plain')
})
it('same-named references stay independent: distinct occurrenceIds, one deletion leaves the other', () => {
const m = new InputMachine()
m.dispatch({ type: 'draft-changed', draft: '/alp' })
m.dispatch({ type: 'insert-ref', reference: refOf('alpha'), span: spanOf(m, 0, 4) })
m.dispatch({ type: 'draft-changed', draft: `${P} and /alp`, editRange: { start: 1, end: 1, insertedLength: 9 } })
m.dispatch({ type: 'insert-ref', reference: refOf('alpha'), span: spanOf(m, 6, 10) })
expect(m.state.draft).toBe(`${P} and ${P}`)
expect(m.state.occurrences.map(o => o.occurrenceId)).toEqual([1, 2])
// Delete the first chip whole; the second survives with its own identity.
m.dispatch({ type: 'draft-changed', draft: ` and ${P}`, editRange: { start: 0, end: 1, insertedLength: 0 } })
expect(m.state.occurrences).toEqual([expect.objectContaining({ occurrenceId: 2, offset: 5 })])
})
it('claimed stays claimed across an inline insert (inline "@" during command args)', () => {
const m = new InputMachine()
m.dispatch({ type: 'draft-changed', draft: '/go' })
m.dispatch({ type: 'begin-command', claim: claimOf('goal'), span: spanOf(m, 0, 3) })
m.dispatch({ type: 'draft-changed', draft: '/goal ask @wor' })
m.dispatch({ type: 'insert-ref', reference: refOf('worker-1', 'subagent'), span: spanOf(m, 10, 14) })
expect(m.state.draft).toBe(`/goal ask ${P}`)
expect(m.state.phase).toBe('claimed')
expect(m.state.occurrences).toHaveLength(1)
})
it('a stale draftRev no-ops: no draft change, no occurrence', () => {
const m = new InputMachine()
m.dispatch({ type: 'draft-changed', draft: 'see @wor' })
const span = spanOf(m, 4, 8)
m.dispatch({ type: 'draft-changed', draft: 'see @work' })
expect(m.dispatch({ type: 'insert-ref', reference: refOf('w'), span })).toEqual([])
expect(m.state.occurrences).toEqual([])
})
})
describe('input-machine: occurrence reconciliation on draft edits', () => {
/** Machine with one chip at offset 4 inside `see ${P} now`. */
function withChip(): InputMachine {
const m = new InputMachine()
m.dispatch({ type: 'draft-changed', draft: 'see @wor now' })
m.dispatch({ type: 'insert-ref', reference: refOf('worker-1', 'subagent'), span: spanOf(m, 4, 8) })
return m
}
it('an edit before the placeholder shifts the offset by the length delta (explicit editRange)', () => {
const m = withChip()
m.dispatch({ type: 'draft-changed', draft: `I see ${P} now`, editRange: { start: 0, end: 0, insertedLength: 2 } })
expect(m.state.occurrences[0]?.offset).toBe(6)
m.dispatch({ type: 'draft-changed', draft: `see ${P} now`, editRange: { start: 0, end: 2, insertedLength: 0 } })
expect(m.state.occurrences[0]?.offset).toBe(4)
})
it('an edit after the placeholder leaves the offset alone', () => {
const m = withChip()
m.dispatch({ type: 'draft-changed', draft: `see ${P} later`, editRange: { start: 6, end: 9, insertedLength: 5 } })
expect(m.state.occurrences[0]?.offset).toBe(4)
})
it('a deletion covering the placeholder removes the whole occurrence', () => {
const m = withChip()
m.dispatch({ type: 'draft-changed', draft: 'see now', editRange: { start: 4, end: 5, insertedLength: 0 } })
expect(m.state.occurrences).toEqual([])
expect(m.state.draft).toBe('see now')
})
it('a replacement spanning the placeholder removes the occurrence and keeps the replacement text', () => {
const m = withChip()
m.dispatch({ type: 'draft-changed', draft: 'see all of it now', editRange: { start: 4, end: 5, insertedLength: 9 } })
expect(m.state.occurrences).toEqual([])
})
it('without editRange the prefix/suffix diff scan recovers the edit (shift path)', () => {
const m = withChip()
m.dispatch({ type: 'draft-changed', draft: `see there ${P} now` })
expect(m.state.occurrences[0]?.offset).toBe(10)
})
it('without editRange the diff scan detects placeholder deletion', () => {
const m = withChip()
m.dispatch({ type: 'draft-changed', draft: 'see now' })
expect(m.state.occurrences).toEqual([])
})
it('an identical draft is a no-op: no revision bump, no undo entry', () => {
const m = withChip()
const rev = m.state.draftRev
expect(m.dispatch({ type: 'draft-changed', draft: m.state.draft })).toEqual([])
expect(m.state.draftRev).toBe(rev)
})
})
describe('input-machine: newline transaction (F1)', () => {
it('inserts \\n at the caret and shifts trailing occurrences', () => {
const m = new InputMachine()
m.dispatch({ type: 'draft-changed', draft: 'ab @wor' })
m.dispatch({ type: 'insert-ref', reference: refOf('w'), span: spanOf(m, 3, 7) })
m.dispatch({ type: 'newline', selection: { start: 2, end: 2 } })
expect(m.state.draft).toBe(`ab\n ${P}`)
expect(m.state.occurrences[0]?.offset).toBe(4)
m.dispatch({ type: 'undo' })
expect(m.state.draft).toBe(`ab ${P}`)
})
it('replaces a selection, breaks the claim prefix when leading, and rejects out-of-bounds', () => {
const m = new InputMachine()
m.dispatch({ type: 'draft-changed', draft: '/go' })
m.dispatch({ type: 'begin-command', claim: claimOf('goal'), span: spanOf(m, 0, 3) })
expect(m.dispatch({ type: 'newline', selection: { start: 0, end: 99 } })).toEqual([])
expect(m.state.phase).toBe('claimed')
m.dispatch({ type: 'newline', selection: { start: 0, end: 0 } })
expect(m.state.draft).toBe('\n/goal ')
expect(m.state.phase).toBe('plain')
expect(m.state.claim).toBeUndefined()
})
})
describe('input-machine: consume-token guards', () => {
it('span guard: CAS pass deletes the token — success observable as a draftRev advance', () => {
const m = new InputMachine()
m.dispatch({ type: 'draft-changed', draft: '/model rest' })
const before = m.state.draftRev
m.dispatch({ type: 'consume-token', guard: { kind: 'span', span: spanOf(m, 0, 7) } })
expect(m.state.draftRev).toBeGreaterThan(before)
expect(m.state.draft).toBe('rest')
m.dispatch({ type: 'undo' })
expect(m.state.draft).toBe('/model rest')
})
it('span guard: a stale draftRev refuses — no deletion, no revision bump', () => {
const m = new InputMachine()
m.dispatch({ type: 'draft-changed', draft: '/model' })
const span = spanOf(m, 0, 6)
m.dispatch({ type: 'draft-changed', draft: '/model x' })
const rev = m.state.draftRev
expect(m.dispatch({ type: 'consume-token', guard: { kind: 'span', span } })).toEqual([])
expect(m.state).toMatchObject({ draft: '/model x', draftRev: rev })
})
it('bare-token guard: trimmed equality clears the draft; mismatch refuses', () => {
const m = new InputMachine()
m.dispatch({ type: 'draft-changed', draft: ' /model \n' })
m.dispatch({ type: 'consume-token', guard: { kind: 'bare-token', token: '/model' } })
expect(m.state.draft).toBe('')
m.dispatch({ type: 'undo' })
expect(m.state.draft).toBe(' /model \n')
m.dispatch({ type: 'draft-changed', draft: '/model extra' })
const rev = m.state.draftRev
expect(m.dispatch({ type: 'consume-token', guard: { kind: 'bare-token', token: '/model' } })).toEqual([])
expect(m.state).toMatchObject({ draft: '/model extra', draftRev: rev })
})
it('a chip elsewhere in the draft shifts across a span consume', () => {
const m = new InputMachine()
m.dispatch({ type: 'draft-changed', draft: '/model @wor' })
m.dispatch({ type: 'insert-ref', reference: refOf('w'), span: spanOf(m, 7, 11) })
m.dispatch({ type: 'consume-token', guard: { kind: 'span', span: spanOf(m, 0, 7) } })
expect(m.state.draft).toBe(P)
expect(m.state.occurrences[0]?.offset).toBe(0)
})
})
describe('input-machine: undo / redo', () => {
it('the default constant clock coalesces contiguous single-char typing into one transaction', () => {
const m = new InputMachine()
m.dispatch({ type: 'draft-changed', draft: 'a', editRange: { start: 0, end: 0, insertedLength: 1 } })
m.dispatch({ type: 'draft-changed', draft: 'ab', editRange: { start: 1, end: 1, insertedLength: 1 } })
m.dispatch({ type: 'draft-changed', draft: 'abc', editRange: { start: 2, end: 2, insertedLength: 1 } })
m.dispatch({ type: 'undo' })
expect(m.state.draft).toBe('')
m.dispatch({ type: 'redo' })
expect(m.state.draft).toBe('abc')
})
it('the merge window splits typing runs: within merges, beyond opens a new transaction', () => {
let t = 0
const m = new InputMachine({ mergeWindowMs: 1000, now: () => t })
m.dispatch({ type: 'draft-changed', draft: 'a', editRange: { start: 0, end: 0, insertedLength: 1 } })
t = 900
m.dispatch({ type: 'draft-changed', draft: 'ab', editRange: { start: 1, end: 1, insertedLength: 1 } })
t = 2500 // beyond the window from the previous char
m.dispatch({ type: 'draft-changed', draft: 'abc', editRange: { start: 2, end: 2, insertedLength: 1 } })
m.dispatch({ type: 'undo' })
expect(m.state.draft).toBe('ab')
m.dispatch({ type: 'undo' })
expect(m.state.draft).toBe('')
})
it('non-contiguous or multi-char edits never merge into a typing run', () => {
const m = new InputMachine()
m.dispatch({ type: 'draft-changed', draft: 'a', editRange: { start: 0, end: 0, insertedLength: 1 } })
m.dispatch({ type: 'draft-changed', draft: 'ba', editRange: { start: 0, end: 0, insertedLength: 1 } })
m.dispatch({ type: 'draft-changed', draft: 'baXY', editRange: { start: 2, end: 2, insertedLength: 2 } })
m.dispatch({ type: 'undo' })
expect(m.state.draft).toBe('ba')
m.dispatch({ type: 'undo' })
expect(m.state.draft).toBe('a')
m.dispatch({ type: 'undo' })
expect(m.state.draft).toBe('')
})
it('a new transaction cuts the redo chain', () => {
const m = new InputMachine()
m.dispatch({ type: 'draft-changed', draft: 'a', editRange: { start: 0, end: 0, insertedLength: 1 } })
m.dispatch({ type: 'undo' })
m.dispatch({ type: 'draft-changed', draft: 'z', editRange: { start: 0, end: 0, insertedLength: 1 } })
expect(m.dispatch({ type: 'redo' })).toEqual([])
expect(m.state.draft).toBe('z')
})
it('undo on an empty log and redo on an empty chain are no-ops', () => {
const m = new InputMachine()
expect(m.dispatch({ type: 'undo' })).toEqual([])
expect(m.dispatch({ type: 'redo' })).toEqual([])
})
it('the log ring caps at 100 transactions', () => {
let t = 0
const m = new InputMachine({ mergeWindowMs: 0, now: () => (t += 10) })
let draft = ''
for (let i = 0; i < 110; i += 1) {
draft += 'x'
m.dispatch({ type: 'draft-changed', draft, editRange: { start: i, end: i, insertedLength: 1 } })
}
for (let i = 0; i < 100; i += 1) m.dispatch({ type: 'undo' })
expect(m.state.draft).toBe('x'.repeat(10))
expect(m.dispatch({ type: 'undo' })).toEqual([])
expect(m.state.draft).toBe('x'.repeat(10))
})
it('undo restores the occurrence table with the draft (chip resurrection)', () => {
const m = new InputMachine()
m.dispatch({ type: 'draft-changed', draft: '@wor' })
m.dispatch({ type: 'insert-ref', reference: refOf('w'), span: spanOf(m, 0, 4) })
m.dispatch({ type: 'draft-changed', draft: '', editRange: { start: 0, end: 1, insertedLength: 0 } })
expect(m.state.occurrences).toEqual([])
m.dispatch({ type: 'undo' })
expect(m.state.draft).toBe(P)
expect(m.state.occurrences).toHaveLength(1)
})
it('a committed submit clears the log: undo cannot resurrect sent content', () => {
const m = new InputMachine()
const { attempt } = enterSubmitting(m, 'goal', 'x')
m.dispatch({ type: 'submit-settled', attempt, ok: true })
expect(m.state.draft).toBe('')
expect(m.dispatch({ type: 'undo' })).toEqual([])
expect(m.state.draft).toBe('')
})
})
describe('input-machine: paste plane', () => {
it('paste replaces the selection as one transaction and opens a match attempt', () => {
const m = new InputMachine()
m.dispatch({ type: 'draft-changed', draft: 'abc' })
m.dispatch({ type: 'paste-begin', text: 'XY', selection: { start: 1, end: 2 }, generation: 7 })
expect(m.state.draft).toBe('aXYc')
expect(m.state.paste).toEqual({ attemptId: 1, insertedRange: { start: 1, end: 3 }, generation: 7 })
m.dispatch({ type: 'undo' })
expect(m.state.draft).toBe('abc')
})
it('pasted text is sanitized: raw U+FFFC never enters the draft as a fake chip', () => {
const m = new InputMachine()
m.dispatch({ type: 'paste-begin', text: `x${P}y`, selection: { start: 0, end: 0 } })
expect(m.state.draft).toBe('xy')
expect(m.state.occurrences).toEqual([])
})
it('sync hot-snapshot components mint inside the SAME transaction: one undo returns to pre-paste', () => {
const m = new InputMachine()
m.dispatch({ type: 'draft-changed', draft: 'hi ' })
m.dispatch({
type: 'paste-begin', text: '/alpha x', selection: { start: 3, end: 3 },
components: [{ start: 0, end: 6, reference: refOf('alpha') }],
})
expect(m.state.draft).toBe(`hi ${P} x`)
expect(m.state.occurrences).toEqual([expect.objectContaining({ ref: 'alpha', offset: 3 })])
expect(m.state.paste?.insertedRange).toEqual({ start: 3, end: 6 })
m.dispatch({ type: 'undo' })
expect(m.state).toMatchObject({ draft: 'hi ', occurrences: [] })
})
it('async upgrade is an INDEPENDENT transaction: undo #1 → token text, undo #2 → pre-paste', () => {
const m = new InputMachine()
m.dispatch({ type: 'paste-begin', text: '/alpha rest', selection: { start: 0, end: 0 } })
expect(m.state.paste?.attemptId).toBe(1)
m.dispatch({ type: 'paste-upgrade', attemptId: 1, span: spanOf(m, 0, 6), reference: refOf('alpha') })
expect(m.state.draft).toBe(`${P} rest`)
expect(m.state.occurrences).toHaveLength(1)
m.dispatch({ type: 'undo' })
expect(m.state).toMatchObject({ draft: '/alpha rest', occurrences: [] })
m.dispatch({ type: 'undo' })
expect(m.state.draft).toBe('')
})
it('the attempt survives upgrades: successive tokens re-CAS against the advanced revision', () => {
const m = new InputMachine()
m.dispatch({ type: 'paste-begin', text: '/alpha /beta', selection: { start: 0, end: 0 } })
m.dispatch({ type: 'paste-upgrade', attemptId: 1, span: spanOf(m, 0, 6), reference: refOf('alpha') })
expect(m.state.paste?.insertedRange).toEqual({ start: 0, end: 7 })
m.dispatch({ type: 'paste-upgrade', attemptId: 1, span: spanOf(m, 2, 7), reference: refOf('beta') })
expect(m.state.draft).toBe(`${P} ${P}`)
expect(m.state.occurrences.map(o => o.ref)).toEqual(['alpha', 'beta'])
expect(m.state.paste?.insertedRange).toEqual({ start: 0, end: 3 })
})
it('a stale span CAS drops one upgrade without ending the attempt', () => {
const m = new InputMachine()
m.dispatch({ type: 'paste-begin', text: '/alpha /beta', selection: { start: 0, end: 0 } })
const preSpan = spanOf(m, 7, 12)
m.dispatch({ type: 'paste-upgrade', attemptId: 1, span: spanOf(m, 0, 6), reference: refOf('alpha') })
expect(m.dispatch({ type: 'paste-upgrade', attemptId: 1, span: preSpan, reference: refOf('beta') })).toEqual([])
expect(m.state.occurrences).toHaveLength(1)
expect(m.state.paste).toBeDefined()
})
it('any new input transaction ends the attempt; late upgrades drop whole', () => {
const m = new InputMachine()
m.dispatch({ type: 'paste-begin', text: '/alpha', selection: { start: 0, end: 0 } })
m.dispatch({ type: 'draft-changed', draft: '/alpha!', editRange: { start: 6, end: 6, insertedLength: 1 } })
expect(m.state.paste).toBeUndefined()
expect(m.dispatch({ type: 'paste-upgrade', attemptId: 1, span: spanOf(m, 0, 6), reference: refOf('alpha') })).toEqual([])
expect(m.state.occurrences).toEqual([])
})
it('invalidate-paste (caret/selection/slash activity) and submit start end the attempt', () => {
const a = new InputMachine()
a.dispatch({ type: 'paste-begin', text: '/alpha', selection: { start: 0, end: 0 } })
a.dispatch({ type: 'invalidate-paste' })
expect(a.state.paste).toBeUndefined()
const b = new InputMachine()
b.dispatch({ type: 'paste-begin', text: 'plain text', selection: { start: 0, end: 0 } })
b.dispatch({ type: 'enter', mode: 'queue' })
expect(b.state.paste).toBeUndefined()
})
it('a mismatched attemptId is dropped (superseded paste)', () => {
const m = new InputMachine()
m.dispatch({ type: 'paste-begin', text: '/alpha', selection: { start: 0, end: 0 } })
m.dispatch({ type: 'paste-begin', text: ' /beta', selection: { start: 6, end: 6 } })
expect(m.state.paste?.attemptId).toBe(2)
expect(m.dispatch({ type: 'paste-upgrade', attemptId: 1, span: spanOf(m, 0, 6), reference: refOf('alpha') })).toEqual([])
expect(m.state.occurrences).toEqual([])
})
})
describe('input-machine: set-invalid styling bits', () => {
it('flags exactly the listed occurrences without a transaction', () => {
const m = new InputMachine()
m.dispatch({ type: 'draft-changed', draft: '/alp' })
m.dispatch({ type: 'insert-ref', reference: refOf('alpha'), span: spanOf(m, 0, 4) })
m.dispatch({ type: 'draft-changed', draft: `${P} /bet`, editRange: { start: 1, end: 1, insertedLength: 5 } })
m.dispatch({ type: 'insert-ref', reference: refOf('beta'), span: spanOf(m, 2, 6) })
const rev = m.state.draftRev
m.dispatch({ type: 'set-invalid', invalidIds: [1] })
expect(m.state.draftRev).toBe(rev)
expect(m.state.occurrences.map(o => o.invalid === true)).toEqual([true, false])
// Recovery: the same source/ref resolving again clears the bit.
m.dispatch({ type: 'set-invalid', invalidIds: [] })
expect(m.state.occurrences.every(o => o.invalid === undefined)).toBe(true)
})
it('a no-change call keeps the table reference (no spurious publish)', () => {
const m = new InputMachine()
m.dispatch({ type: 'draft-changed', draft: '/alp' })
m.dispatch({ type: 'insert-ref', reference: refOf('alpha'), span: spanOf(m, 0, 4) })
const table = m.state.occurrences
expect(m.dispatch({ type: 'set-invalid', invalidIds: [] })).toEqual([])
expect(m.state.occurrences).toBe(table)
})
})
describe('input-machine: projectClipboard', () => {
it('expands each placeholder to its occurrence clipboardText in draft order', () => {
const m = new InputMachine()
m.dispatch({ type: 'draft-changed', draft: 'use /alp' })
m.dispatch({ type: 'insert-ref', reference: refOf('alpha'), span: spanOf(m, 4, 8) })
m.dispatch({ type: 'draft-changed', draft: `use ${P} then /bet`, editRange: { start: 5, end: 5, insertedLength: 10 } })
m.dispatch({ type: 'insert-ref', reference: refOf('beta'), span: spanOf(m, 11, 15) })
expect(m.state.draft).toBe(`use ${P} then ${P}`)
expect(projectClipboard(m.state)).toBe('use /alpha then /beta')
})
it('is the identity on a chip-free draft', () => {
expect(projectClipboard({ draft: 'plain text', occurrences: [] })).toBe('plain text')
})
})
describe('decorations: scanTextRefs (decision 21)', () => {
const LEX: ReadonlyMap<'/' | '@', readonly string[]> = new Map([
['/', ['commit-helper', 'fixture-demo']],
['@', ['worker-1']],
])
it('matches lexicon tokens at line start and after whitespace, in draft order', () => {
expect(scanTextRefs('/commit-helper then @worker-1 ok', LEX)).toEqual([
{ start: 0, end: 14, trigger: '/' },
{ start: 20, end: 29, trigger: '@' },
])
})
it('a cold (empty) lexicon scans nothing', () => {
expect(scanTextRefs('/commit-helper', new Map())).toEqual([])
})
it('names off the lexicon do not match; triggers are routed per lexicon list', () => {
expect(scanTextRefs('/unknown @commit-helper', LEX)).toEqual([])
})
it('word boundary: a trigger glued to text never matches', () => {
expect(scanTextRefs('x/commit-helper', LEX)).toEqual([])
expect(scanTextRefs('a@worker-1', LEX)).toEqual([])
})
it('tokens never cross a newline; a token straight after one matches', () => {
expect(scanTextRefs('line\n/commit-helper', LEX)).toEqual([
{ start: 5, end: 19, trigger: '/' },
])
})
it('deriveDecorations threads the lexicon through as textRefs', () => {
const m = new InputMachine()
m.dispatch({ type: 'draft-changed', draft: 'use /commit-helper now' })
expect(deriveDecorations(m.state, LEX).textRefs).toEqual([
{ start: 4, end: 18, trigger: '/' },
])
})
})
describe('input-machine: decorations', () => {
it('projects chips from the occurrence table with identity, offset, label, and invalid bit', () => {
const m = new InputMachine()
m.dispatch({ type: 'draft-changed', draft: '/alp' })
m.dispatch({ type: 'insert-ref', reference: refOf('alpha'), span: spanOf(m, 0, 4) })
m.dispatch({ type: 'set-invalid', invalidIds: [1] })
expect(deriveDecorations(m.state)).toEqual({
token: null,
chips: [{ occurrenceId: 1, offset: 0, label: 'alpha', invalid: true }],
textRefs: [],
hint: null,
})
})
it('claim token range and ghost hint show while claimed with blank args; args clear the hint', () => {
const m = new InputMachine()
m.dispatch({ type: 'draft-changed', draft: '/go' })
m.dispatch({ type: 'begin-command', claim: claimOf('goal', 'objective'), span: spanOf(m, 0, 3) })
expect(deriveDecorations(m.state)).toEqual({
token: { start: 0, end: 6 },
chips: [],
textRefs: [],
hint: 'objective',
})
m.dispatch({ type: 'draft-changed', draft: '/goal x' })
expect(deriveDecorations(m.state)).toMatchObject({ token: { start: 0, end: 6 }, hint: null })
})
it('the token range persists through submitting; a hintless claim never ghosts', () => {
const m = new InputMachine()
enterSubmitting(m, 'goal', '')
expect(deriveDecorations(m.state)).toEqual({ token: { start: 0, end: 6 }, chips: [], textRefs: [], hint: null })
})
})
describe('input-machine: claimed lifecycle', () => {
it('breaking startsWith(token) auto-releases back to plain', () => {
const m = new InputMachine()
m.dispatch({ type: 'draft-changed', draft: '/go' })
m.dispatch({ type: 'begin-command', claim: claimOf('goal'), span: spanOf(m, 0, 3) })
m.dispatch({ type: 'draft-changed', draft: '/goal make' })
expect(m.state.phase).toBe('claimed')
m.dispatch({ type: 'draft-changed', draft: '/goa make' })
expect(m.state.phase).toBe('plain')
expect(m.state.claim).toBeUndefined()
expect(m.state.draft).toBe('/goa make')
})
it('explicit release returns to plain when nothing is in flight', () => {
const m = new InputMachine()
m.dispatch({ type: 'draft-changed', draft: '/go' })
m.dispatch({ type: 'begin-command', claim: claimOf('goal'), span: spanOf(m, 0, 3) })
expect(m.dispatch({ type: 'release' })).toEqual([])
expect(m.state.phase).toBe('plain')
expect(m.state.claim).toBeUndefined()
})
it('enter begins the submit transaction: args = draft minus token, multi-line legal', () => {
const m = new InputMachine()
const { attempt, claim } = enterSubmitting(m, 'goal', 'line1\nline2')
expect(attempt.draftSnapshot).toBe('/goal line1\nline2')
m.dispatch({ type: 'submit-settled', attempt, ok: true })
expect(m.state.draft).toBe('')
expect(claim.token).toBe('/goal ')
})
})
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: 'draft-changed', draft: '/goal y' })).toEqual([])
expect(m.state).toMatchObject({ phase: 'submitting', draft: '/goal y' })
})
it('commit clears draft and occurrences, releases the claim, and relays the outcome text', () => {
const m = new InputMachine()
m.dispatch({ type: 'draft-changed', draft: '@wor' })
m.dispatch({ type: 'insert-ref', reference: refOf('worker-1', 'subagent'), span: spanOf(m, 0, 4) })
m.dispatch({ type: 'draft-changed', draft: `${P}/go`, editRange: { start: 1, end: 1, insertedLength: 3 } })
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 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: [] })
expect(m.state.claim).toBeUndefined()
})
it('rollback with an undeviated draft keeps the snapshot and re-enters claimed (same claim)', () => {
const m = new InputMachine()
const { attempt } = enterSubmitting(m, 'goal', 'x')
const fx = m.dispatch({ type: 'submit-settled', attempt, ok: false, message: 'boom' })
expect(fx).toEqual([{ type: 'notice', level: 'error', text: 'boom' }])
expect(m.state).toMatchObject({ phase: 'claimed', draft: '/goal x' })
expect(m.state.claim?.token).toBe('/goal ')
})
it('rollback with a deviated draft only notices — the newer input wins', () => {
const m = new InputMachine()
const { attempt } = enterSubmitting(m, 'goal', 'x')
m.dispatch({ type: 'draft-changed', draft: 'fresh typing' })
const fx = m.dispatch({ type: 'submit-settled', attempt, ok: false, message: 'boom' })
expect(fx).toEqual([{ type: 'notice', level: 'error', text: 'boom' }])
expect(m.state).toMatchObject({ phase: 'plain', draft: 'fresh typing' })
expect(m.state.claim).toBeUndefined()
})
it('enter-path rollback cannot re-enter claimed when the snapshot never carried the bare token prefix', () => {
// '\n\n/goal x' round-trips through adjudication; the whitespace prefix
// would instantly break the claimed watch, so rollback lands plain.
const m = new InputMachine()
const attempt = enterAdjudicating(m, '\n\n/goal x')
m.dispatch({ type: 'adjudicated', attempt, outcome: { claim: claimOf('goal') } })
const fx = m.dispatch({ type: 'submit-settled', attempt, ok: false, message: 'boom' })
expect(fx).toEqual([{ type: 'notice', level: 'error', text: 'boom' }])
expect(m.state).toMatchObject({ phase: 'plain', draft: '\n\n/goal x' })
})
it('a stale settle after rollback + resubmit is dropped (anti-backwash)', () => {
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
expect(second.seq).not.toBe(first.seq)
expect(m.dispatch({ type: 'submit-settled', attempt: first, ok: true })).toEqual([])
expect(m.state.phase).toBe('submitting')
m.dispatch({ type: 'submit-settled', attempt: second, ok: true })
expect(m.state.draft).toBe('')
})
it('release mid-flight aborts the attempt and later settles are dropped', () => {
const m = new InputMachine()
const { attempt } = enterSubmitting(m, 'goal', 'x')
expect(m.dispatch({ type: 'release' })).toEqual([])
expect(attempt.signal.aborted).toBe(true)
expect(m.state.phase).toBe('plain')
expect(m.dispatch({ type: 'submit-settled', attempt, ok: true })).toEqual([])
expect(m.state.draft).toBe('/goal x')
})
})
describe('input-machine: per-session isolation', () => {
it('one instance per session: A submitting never locks B; settles land on their own instance', () => {
const a = new InputMachine()
const b = new InputMachine()
const { attempt } = enterSubmitting(a, 'goal', 'from A')
// B stays fully live while A holds its lock.
b.dispatch({ type: 'draft-changed', draft: '/mo' })
b.dispatch({ type: 'begin-command', claim: claimOf('model'), span: spanOf(b, 0, 3) })
expect(b.state.phase).toBe('claimed')
expect(a.state.phase).toBe('submitting')
// A's commit falls back to A alone.
a.dispatch({ type: 'submit-settled', attempt, ok: true })
expect(a.state).toMatchObject({ phase: 'plain', draft: '' })
expect(b.state).toMatchObject({ phase: 'claimed', draft: '/model ' })
})
})

View File

@@ -0,0 +1,193 @@
// @vitest-environment jsdom
/**
* Impact-matrix projection tests (design §5.2 影响矩阵, row by row): what each
* phase projects onto the InputBar — enter routing, visuals (token color /
* hint / pending), edit freedom, and the published currency's claim seat.
* React over jsdom per the client testing discipline; the machine is real.
*/
import { afterEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render } from '@testing-library/react'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { ClientContext, ConversationSnapshot, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type { SubmitOutcome } from '@deepseek-ai/dsh-client-ui-slash/client'
import { SessionInputShell } from '../src/client/input/facade.ts'
import { InputBar } from '../src/client/skeleton/InputBar.tsx'
import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx'
afterEach(cleanup)
const SCTX = {} as ClientContext
const SID = 's1' as SessionId
/** Standard-props InputBar mount over a real shell (the composer-bar entry shape). */
function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled?: boolean }) {
const session = createSnapshotStore<ConversationSnapshot>({
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: over?.running ?? false, composerPhase: 'active',
removed: over?.disabled ?? false, openState: 'open', openError: null, hasMore: false,
loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
})
const props: InputBarProps = {
sessionId: SID,
SessionProvider: ({ children }) => children(SID),
useSession: bindSnapshotSelector(session),
useSessions: bindSnapshotSelector(createSnapshotStore({
ids: [], byId: {}, current: undefined, phase: 'ready',
})) as InputBarProps['useSessions'],
useWorkspaces: bindSnapshotSelector(createSnapshotStore({
items: [], state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,
})) as InputBarProps['useWorkspaces'],
useInput: bindSnapshotSelector(shell.state),
inputActions: shell.actions,
keyboard: shell,
renderSlot: (() => null) as InputBarProps['renderSlot'],
stop: vi.fn(),
variant: 'composer',
}
return render(<InputBar {...props} />)
}
function bench(over?: { running?: boolean; disabled?: boolean; submit?: (args: string) => Promise<SubmitOutcome> }) {
const sink = vi.fn()
const shell = new SessionInputShell({ actx: SCTX, defaultSink: sink })
const wiring = shell
const view = mountBar(shell, over)
const textarea = view.container.querySelector('textarea')!
const claim = (token = '/goal ', hint = '目标') => {
act(() => {
shell.setDraft(token)
shell.beginCommand(
{
token, hint,
submit: over?.submit ?? (() => Promise.resolve({ kind: 'success' as const, source: 'command', name: 'goal' })),
},
{ start: 0, end: token.length, draftRev: shell.snapshot.draftRev },
)
})
}
return { view, textarea, shell, wiring, sink, claim }
}
describe('matrix row: plain', () => {
it('enter falls to the default sink; no claim on the currency; edits free', () => {
const { textarea, shell, sink } = bench()
fireEvent.change(textarea, { target: { value: '普通消息' } })
expect(shell.snapshot.claim).toBeUndefined()
fireEvent.keyDown(textarea, { key: 'Enter' })
expect(sink).toHaveBeenCalledWith('普通消息', 'queue')
expect(shell.snapshot.phase).toBe('plain')
})
})
describe('matrix row: claimed', () => {
it('publishes the claim currency, colors the token, hints while args are blank, and edits stay free', () => {
const { view, textarea, shell, claim } = bench()
claim()
expect(shell.snapshot.claim).toEqual({ token: '/goal ', hint: '目标' })
expect(view.container.querySelector('[data-decoration="token"]')?.textContent).toBe('/goal ')
expect(view.container.querySelector('[data-decoration="hint"]')?.textContent).toBe('目标')
expect((textarea as HTMLTextAreaElement).readOnly).toBe(false)
// Free editing beyond the token: hint drops, claim holds.
fireEvent.change(textarea, { target: { value: '/goal 发布版本' } })
expect(shell.snapshot.phase).toBe('claimed')
expect(view.container.querySelector('[data-decoration="hint"]')).toBeNull()
})
it('enter routes to claim.submit (command lane, never the queue sink)', async () => {
const submit = vi.fn(() => Promise.resolve({ kind: 'success' as const, text: '完成', source: 'command', name: 'goal' }))
const { view, textarea, sink, claim } = bench({ submit })
claim()
fireEvent.change(textarea, { target: { value: '/goal 发布' } })
fireEvent.keyDown(textarea, { key: 'Enter' })
expect(sink).not.toHaveBeenCalled()
await vi.waitFor(() => { expect(submit).toHaveBeenCalledWith('发布', SCTX) })
// Commit: draft cleared, notice surfaced, back to plain.
await vi.waitFor(() => { expect((textarea as HTMLTextAreaElement).value).toBe('') })
expect(view.getByText('完成')).toBeTruthy()
})
it('backspacing the token auto-releases to plain and the visuals vanish (scenario H)', () => {
const { view, textarea, shell, claim } = bench()
claim()
fireEvent.change(textarea, { target: { value: '/goa 发布' } }) // token broken
expect(shell.snapshot.phase).toBe('plain')
expect(shell.snapshot.claim).toBeUndefined()
expect(view.container.querySelector('[data-decoration="token"]')).toBeNull()
})
})
describe('matrix row: submitting', () => {
it('locks enter, renders pending + read-only, keeps the claim snapshot on the currency', async () => {
const submit = vi.fn(() => new Promise<SubmitOutcome>(() => {})) // never settles
const { view, textarea, shell, sink, claim } = bench({ submit })
claim()
fireEvent.keyDown(textarea, { key: 'Enter' })
expect(shell.snapshot.phase).toBe('submitting')
expect(shell.snapshot.claim).toBeDefined()
expect((textarea as HTMLTextAreaElement).readOnly).toBe(true)
expect(view.container.querySelector('[data-input-pending]')).not.toBeNull()
// Enter is dead inside the lock (submit dispatch is microtask-deferred).
await vi.waitFor(() => { expect(submit).toHaveBeenCalledTimes(1) })
fireEvent.keyDown(textarea, { key: 'Enter' })
await Promise.resolve()
expect(submit).toHaveBeenCalledTimes(1)
expect(sink).not.toHaveBeenCalled()
})
it('rollback with unchanged draft returns to claimed with the notice; drifted draft only notices', async () => {
let rejectSubmit!: (e: Error) => void
const submit = vi.fn(() => new Promise<SubmitOutcome>((_res, rej) => { rejectSubmit = rej }))
const first = bench({ submit })
first.claim()
fireEvent.keyDown(first.textarea, { key: 'Enter' })
await vi.waitFor(() => { expect(submit).toHaveBeenCalled() })
act(() => { rejectSubmit(new Error('执行失败')) })
await vi.waitFor(() => { expect(first.shell.snapshot.phase).toBe('claimed') })
expect((first.textarea as HTMLTextAreaElement).value).toBe('/goal ')
expect(first.view.getByText('执行失败')).toBeTruthy()
cleanup()
// Drift: typing during flight wins; no restore, plain, notice only.
const submit2 = vi.fn(() => new Promise<SubmitOutcome>((_res, rej) => { rejectSubmit = rej }))
const second = bench({ submit: submit2 })
second.claim()
fireEvent.keyDown(second.textarea, { key: 'Enter' })
await vi.waitFor(() => { expect(submit2).toHaveBeenCalled() })
act(() => { second.shell.setDraft('用户飞行中打的新稿') })
act(() => { rejectSubmit(new Error('晚到失败')) })
await vi.waitFor(() => { expect(second.shell.snapshot.phase).toBe('plain') })
expect((second.textarea as HTMLTextAreaElement).value).toBe('用户飞行中打的新稿')
expect(second.view.getByText('晚到失败')).toBeTruthy()
})
})
describe('matrix row: locked (session disabled)', () => {
it('disables the textarea and chrome; the machine currency is untouched', () => {
const { view, textarea, shell } = bench({ disabled: true })
expect((textarea as HTMLTextAreaElement).disabled).toBe(true)
expect((view.getByLabelText('Add attachment') as HTMLButtonElement).disabled).toBe(true)
expect(shell.snapshot.phase).toBe('plain')
})
it('running does NOT lock (queue cut 1): typing and enter-queue stay live', () => {
const { textarea, sink } = bench({ running: true })
expect((textarea as HTMLTextAreaElement).disabled).toBe(false)
fireEvent.change(textarea, { target: { value: '排队' } })
fireEvent.keyDown(textarea, { key: 'Enter' })
expect(sink).toHaveBeenCalledWith('排队', 'queue')
})
})
describe('matrix row: takeover (orthogonal axis)', () => {
it('the machine state survives outside the render tree (claim lives on the shell, not the DOM)', () => {
const { view, shell, claim } = bench()
claim()
// Takeover hides the composer (overlay chain keeps it mounted-but-hidden);
// even a full unmount keeps the claim: state lives on the resident shell.
view.unmount()
expect(shell.snapshot.phase).toBe('claimed')
expect(shell.snapshot.claim?.token).toBe('/goal ')
expect(shell.snapshot.draft).toBe('/goal ')
})
})

View File

@@ -0,0 +1,264 @@
// @vitest-environment jsdom
/**
* Scenario-chain integration (design §8 A/C/D/H/I): the real per-session
* SlashController pipeline over a real session scope (SessionsService over
* a listed host session) + a command source implementing the decision
* table's relevant cells + the real SessionInput machine (scoped-event
* listeners wired the way the hub does) + the real InputBar. ui-command
* itself is not a dependency of this package; the source below is the
* decision-table contract at the SlashSource seam.
*/
import { Context } from 'cordis'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render } from '@testing-library/react'
import { SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import { SlashService } from '@deepseek-ai/dsh-client-ui-slash/client'
import type { ClientSessionContext, CommandClaim, PickOutcome, SubmitOutcome } from '@deepseek-ai/dsh-client-ui-slash/client'
import { FakeApiClient, ok } from '../../runtime/tests/fake-api.ts'
import { SessionInputShell } from '../src/client/input/facade.ts'
import { InputBar } from '../src/client/skeleton/InputBar.tsx'
import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
afterEach(cleanup)
/** Directory row driving kind derivation (input? = leadingInput, else execute). */
interface FakeCommand {
name: string
description: string
input?: { hint: string }
}
/** T6 decision-table source over an in-memory directory (menu/space/enter columns for leadingInput + execute). */
function commandSource(commands: FakeCommand[], execute: (line: string) => Promise<SubmitOutcome>) {
const resolve = (name: string): FakeCommand | undefined => commands.find(c => c.name === name)
const leadingClaim = (desc: FakeCommand): CommandClaim => ({
token: `/${desc.name} `,
...(desc.input !== undefined ? { hint: desc.input.hint } : {}),
submit: args => execute(`/${desc.name} ${args}`),
})
const executed: string[] = []
return {
executed,
source: {
trigger: '/' as const,
name: 'command',
candidates: (_session: ClientSessionContext, req: { query: string; position: string }) =>
Promise.resolve(commands
.filter(c => c.name.startsWith(req.query))
.filter(c => req.position === 'leading' || c.input === undefined)
.map(c => ({ name: c.name, description: c.description, ...(c.input !== undefined ? { hint: c.input.hint } : {}) }))),
onPick: (pick: { candidate: { name: string } }): PickOutcome => {
const desc = resolve(pick.candidate.name)
if (desc === undefined) return undefined
if (desc.input !== undefined) return { claim: leadingClaim(desc) }
executed.push(`/${desc.name}`)
void execute(`/${desc.name}`)
return 'handled'
},
matchSpace: (_session: ClientSessionContext, token: string): PickOutcome => {
const desc = resolve(token.slice(1))
if (desc?.input === undefined) return undefined
return { claim: leadingClaim(desc) }
},
matchEnter: (_session: ClientSessionContext, line: string): Promise<PickOutcome> => {
const trimmed = line.trim()
const ws = trimmed.search(/\s/)
const token = ws === -1 ? trimmed : trimmed.slice(0, ws)
const desc = resolve(token.slice(1))
if (desc === undefined) return Promise.resolve(undefined)
if (desc.input !== undefined) return Promise.resolve({ claim: leadingClaim(desc) })
if (ws !== -1) return Promise.resolve(undefined) // execute with trailing → default sink
executed.push(trimmed)
void execute(trimmed)
return Promise.resolve('handled')
},
},
}
}
const COMMANDS: FakeCommand[] = [
{ name: 'goal', description: '设定目标', input: { hint: '目标内容' } },
{ name: 'compact', description: '压缩上下文' },
]
/** Real scope bench: SessionsService over one listed session + SlashController + shell listeners (the hub wiring shape). */
async function scopedBench(register?: (slash: SlashService) => void) {
const ctx = new Context()
const api = new FakeApiClient()
api.onWorkspaceList = () => Promise.resolve(ok({ items: [] }))
const sessionId = 'scenario-s1' as Parameters<SessionsService['open']>[0]
api.onList = () => Promise.resolve(ok({
items: [{ sessionId, updatedAt: 1, running: false, blank: false, cwd: '/w/a' }],
}) as never)
const sessions = new SessionsService(ctx, api) // provides 'sessions' itself
await sessions.refresh()
await Promise.resolve() // manager notifier flush
await ctx.plugin(SlashService).await()
const slash = ctx.get('slash') as SlashService
register?.(slash)
const actx = sessions.scope(sessionId)! as ClientContext
const controller = slash.sessionOf(actx)
const sink = vi.fn()
const shell = new SessionInputShell({ actx, slash: () => controller, defaultSink: sink })
// The hub's listener wiring, verbatim.
actx.on('slash/input-begin-command', req => shell.beginCommand(req.claim, req.span) ? true : undefined)
actx.on('slash/input-insert-reference', req => shell.insertReference(req.reference, req.span) ? true : undefined)
actx.on('slash/input-consume-token', req => shell.consumeToken(req.guard) ? true : undefined)
const wiring = shell
const sessionStore = createSnapshotStore<ConversationSnapshot>({
sessionId, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, lastAgentError: null,
})
const barProps: InputBarProps = {
sessionId,
SessionProvider: ({ children }) => children(sessionId),
useSession: bindSnapshotSelector(sessionStore),
useSessions: bindSnapshotSelector(createSnapshotStore({
ids: [], byId: {}, current: undefined, phase: 'ready',
})) as InputBarProps['useSessions'],
useWorkspaces: bindSnapshotSelector(createSnapshotStore({
items: [], state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,
})) as InputBarProps['useWorkspaces'],
useInput: bindSnapshotSelector(shell.state),
inputActions: shell.actions,
keyboard: shell,
renderSlot: (() => null) as InputBarProps['renderSlot'],
stop: vi.fn(),
variant: 'composer',
}
const view = render(<InputBar {...barProps} />)
const textarea = view.container.querySelector('textarea')! as HTMLTextAreaElement
const type = (text: string): void => {
fireEvent.change(textarea, { target: { value: text } })
}
return { ctx, slash, controller, shell, wiring, view, textarea, type, sink }
}
async function bench(executeImpl?: (line: string) => Promise<SubmitOutcome>) {
const execute = vi.fn(executeImpl ?? ((line: string) =>
Promise.resolve({ kind: 'success' as const, text: `已执行 ${line}` })))
const { source, executed } = commandSource(COMMANDS, execute)
const base = await scopedBench((slash) => { slash.registerSource(source as never) })
return { ...base, execute, executed }
}
describe('scenario A: menu-pick /goal, type args, enter submits', () => {
it('runs the whole claim chain through the real pipeline', async () => {
const b = await bench()
b.type('/go')
// Candidates land async; the menu opens with the goal row.
await vi.waitFor(() => {
const menu = b.controller.menu.getSnapshot()
expect(menu.open).toBe(true)
expect(menu.groups[0]?.items.map(i => i.name)).toContain('goal')
})
// Pointer pick (menu path executes through the bound target inside the pipeline).
act(() => { b.controller.pick('command', 0) })
expect(b.shell.snapshot.phase).toBe('claimed')
expect(b.textarea.value).toBe('/goal ')
expect(b.view.container.querySelector('[data-decoration="token"]')?.textContent).toBe('/goal ')
expect(b.view.container.querySelector('[data-decoration="hint"]')?.textContent).toBe('目标内容')
// Continue typing args; hint drops; claim holds.
b.type('/goal 发布 v1')
expect(b.shell.snapshot.phase).toBe('claimed')
// Enter: submitting → command execute → commit clears.
fireEvent.keyDown(b.textarea, { key: 'Enter' })
await vi.waitFor(() => { expect(b.execute).toHaveBeenCalledWith('/goal 发布 v1') })
await vi.waitFor(() => { expect(b.textarea.value).toBe('') })
expect(b.shell.snapshot.phase).toBe('plain')
expect(b.view.getByText('已执行 /goal 发布 v1')).toBeTruthy()
expect(b.sink).not.toHaveBeenCalled()
})
})
describe('scenario C: pasted /goal xxx + enter (menu never opened)', () => {
it('adjudicates on enter, claims and submits in one stroke', async () => {
const b = await bench()
// Paste lands whole; caret at end means detectTrigger sees no token under
// the caret mid-whitespace — menu stays closed; enter runs adjudication.
act(() => { b.shell.setDraft('/goal 尽快发布') })
fireEvent.keyDown(b.textarea, { key: 'Enter' })
await vi.waitFor(() => { expect(b.execute).toHaveBeenCalledWith('/goal 尽快发布') })
await vi.waitFor(() => { expect(b.shell.snapshot.phase).toBe('plain') })
expect(b.textarea.value).toBe('')
expect(b.sink).not.toHaveBeenCalled()
})
})
describe('scenario D: execute-kind /compact', () => {
it('menu pick executes immediately without touching the draft machine phase', async () => {
const b = await bench()
b.type('/comp')
await vi.waitFor(() => { expect(b.controller.menu.getSnapshot().open).toBe(true) })
act(() => { b.controller.pick('command', 0) })
// 'handled': no claim, machine still plain; the source ran the detached execute.
expect(b.shell.snapshot.phase).toBe('plain')
expect(b.executed).toContain('/compact')
})
it('bare /compact + enter executes; trailing text falls to the default sink (scenario I twin)', async () => {
const b = await bench()
act(() => { b.shell.setDraft('/compact') })
fireEvent.keyDown(b.textarea, { key: 'Enter' })
await vi.waitFor(() => { expect(b.executed).toContain('/compact') })
// 'handled' flows back as the adjudicated event one microtask later.
await vi.waitFor(() => { expect(b.shell.snapshot.phase).toBe('plain') })
cleanup()
const b2 = await bench()
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') })
expect(b2.executed).toHaveLength(0)
})
})
describe('scenario H: backspace breaks the token', () => {
it('claim releases automatically; the enter after that goes through adjudication again', async () => {
const b = await bench()
b.type('/goal')
await vi.waitFor(() => { expect(b.controller.menu.getSnapshot().open).toBe(true) })
// Space adjudication claims (space column, leadingInput).
fireEvent.keyDown(b.textarea, { key: ' ' })
expect(b.shell.snapshot.phase).toBe('claimed')
// Backspace into the token: watch break → plain, visuals gone.
b.type('/goa ')
expect(b.shell.snapshot.phase).toBe('plain')
expect(b.view.container.querySelector('[data-decoration="token"]')).toBeNull()
})
})
describe('scenario I: unknown /xyz + enter', () => {
it('adjudication misses in one hop and the whole line rides the default sink', async () => {
const b = await bench()
act(() => { b.shell.setDraft('/xyz 干点啥') })
fireEvent.keyDown(b.textarea, { key: 'Enter' })
await vi.waitFor(() => { expect(b.sink).toHaveBeenCalledWith('/xyz 干点啥', 'queue') })
expect(b.shell.snapshot.phase).toBe('plain')
expect(b.execute).not.toHaveBeenCalled()
})
it('adjudication failure (source warmup throw) notices and keeps the draft', async () => {
const b = await scopedBench((slash) => {
slash.registerSource({
trigger: '/', name: 'command',
candidates: () => Promise.resolve([]),
onPick: () => undefined,
matchEnter: () => Promise.reject(new Error('目录预热失败')),
} as never)
})
act(() => { b.shell.setDraft('/plan 上线') })
fireEvent.keyDown(b.textarea, { key: 'Enter' })
await vi.waitFor(() => { expect(b.view.getByText('目录预热失败')).toBeTruthy() })
// Never a silent downgrade: draft retained, sink untouched.
expect(b.textarea.value).toBe('/plan 上线')
expect(b.sink).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,99 @@
// @vitest-environment jsdom
/**
* QueueDock rendering (web input-triggers queue cut 1): empty queue renders
* nothing, rows render one preview line each keyed by rpcId, and the strip
* follows queue changes through the useSession selector.
*/
import { afterEach, describe, expect, it } from 'vitest'
import { act, cleanup, render } from '@testing-library/react'
import { useSyncExternalStore } from 'react'
import type { ConversationSnapshot, QueuedMessage, SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import type { InputState } from '../src/client/input/contract.ts'
import { QueueDock, queueDockEntry } from '../src/client/queue/QueueDock.tsx'
afterEach(cleanup)
const SID = 's1' as SessionId
function snapshotWith(queue: QueuedMessage[]): ConversationSnapshot {
return {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue, running: true, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
}
}
/** Minimal live source backing the useSession stub (queue swaps notify subscribers). */
function liveSession(initial: ConversationSnapshot) {
let snapshot = initial
const listeners = new Set<() => void>()
const useSession: SnapshotSelectorHook<ConversationSnapshot> = sel =>
useSyncExternalStore(
(fn) => {
listeners.add(fn)
return () => listeners.delete(fn)
},
() => sel(snapshot),
)
return {
useSession,
push(next: ConversationSnapshot): void {
snapshot = next
for (const fn of [...listeners]) fn()
},
}
}
/** InputZone owner stub (the dock reads useSession only; the zone fields satisfy the owner share). */
const INPUT_STATE: InputState = { draft: '', draftRev: 0, phase: 'plain', occurrences: [], queue: [] }
function kitFor(snapshot: ConversationSnapshot) {
return {
sessionId: SID,
useSessions: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<SessionListState>,
useWorkspaces: (() => { throw new Error('unused') }) as never,
useInput: (() => { throw new Error('unused') }) as never,
inputActions: { setDraft: () => {}, submit: () => {} } as never,
session: snapshot,
input: INPUT_STATE,
}
}
describe('QueueDock', () => {
it('renders null while the queue is empty', () => {
const snap = snapshotWith([])
const source = liveSession(snap)
const { container } = render(<QueueDock {...kitFor(snap)} useSession={source.useSession} />)
expect(container.innerHTML).toBe('')
})
it('renders one preview row per queued message with the count strip', () => {
const snap = snapshotWith([
{ key: 'p-1', preview: '第一条排队消息' },
{ key: 'p-2', preview: 'second queued line' },
])
const source = liveSession(snap)
const { container } = render(<QueueDock {...kitFor(snap)} useSession={source.useSession} />)
expect(container.textContent).toContain('已排队 2 条')
const rows = [...container.querySelectorAll('li')]
expect(rows.map(r => r.textContent)).toEqual(['第一条排队消息', 'second queued line'])
})
it('follows queue changes: retirement empties the strip back to null', () => {
const snap = snapshotWith([{ key: 'p-1', preview: '在场' }])
const source = liveSession(snap)
const { container } = render(<QueueDock {...kitFor(snap)} useSession={source.useSession} />)
expect(container.textContent).toContain('在场')
act(() => { source.push(snapshotWith([])) })
expect(container.innerHTML).toBe('')
})
it('ships the registrant plugin shape (list entry into conversation.input.dock)', () => {
// Registration itself runs under T5's slot declaration; here we pin the
// frozen registration surface so the wiring layer can mount it verbatim.
expect(queueDockEntry.name).toBe('conversation-queue-dock')
expect(queueDockEntry.inject).toEqual(['slots', 'conversation'])
expect(typeof queueDockEntry.apply).toBe('function')
})
})

View File

@@ -20,13 +20,15 @@ function bench(): Bench {
const ctx = new Context()
ctx.provide('sessions', {
list: createSnapshotStore<SessionListState>({
ids: [], byId: {}, current: undefined, intent: undefined, phase: 'ready',
ids: [], byId: {}, current: undefined, phase: 'ready',
}),
cell: () => undefined,
provideInfo: () => undefined,
maybeProvideInfo: () => ({ hooks: {}, props: {} }),
provide: () => () => {},
})
ctx.provide('workspaces', {
list: createSnapshotStore<WorkspaceListState>({
items: [], intent: undefined, state: 'idle', phase: 'ready', error: null,
items: [], state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,
}),
})
@@ -40,17 +42,20 @@ function bench(): Bench {
slots.register({
name: 'root',
children: {
'conversation': { kind: 'single', scope: 'session' },
'conversation': { kind: 'single', scope: 'session-maybe' },
'conversation.session': { kind: 'single', scope: 'session' },
'details': { kind: 'single', scope: 'session' },
},
}, (_p: { renderSlot?: unknown }) => null)
slots.register({ name: 'conversation', store: chat }, () => null)
// apply.ts mounts the shared chat handle only under session-scope slots
// (the session-maybe 'conversation' shell carries no store).
slots.register({ name: 'conversation.session', store: chat }, () => null)
slots.register({ name: 'details', store: chat }, () => null)
return { slots, chat }
}
/** Resolve the store instance the renderer would hand a slot's component for a session. */
function storeFor(b: Bench, slot: 'conversation' | 'details', sessionId: SessionId) {
function storeFor(b: Bench, slot: 'conversation.session' | 'details', sessionId: SessionId) {
const host = renderHost(b)
const entry = host.entriesOf(slot)[0]!
return host.storeOf(entry, sessionId)! as ReturnType<ReturnType<typeof createChatStore>['create']>
@@ -79,7 +84,7 @@ describe('selection survives on the store seat', () => {
it('one session, two slots: conversation writes, details reads the SAME instance', () => {
const b = bench()
const conv = storeFor(b, 'conversation', sid('s1'))
const conv = storeFor(b, 'conversation.session', sid('s1'))
const details = storeFor(b, 'details', sid('s1'))
conv.actions.select({ turnSeq: 3, callId: 'c1' })
expect(details.store.getSnapshot().selection).toEqual({ turnSeq: 3, callId: 'c1' })
@@ -90,8 +95,8 @@ describe('selection survives on the store seat', () => {
it('sessions are isolated: s2 selection never bleeds into s1', () => {
const b = bench()
const one = storeFor(b, 'conversation', sid('s1'))
const two = storeFor(b, 'conversation', sid('s2'))
const one = storeFor(b, 'conversation.session', sid('s1'))
const two = storeFor(b, 'conversation.session', sid('s2'))
expect(two).not.toBe(one)
one.actions.select({ turnSeq: 1, callId: 'a' })
two.actions.select({ turnSeq: 9, callId: 'z' })
@@ -104,14 +109,14 @@ describe('selection survives on the store seat', () => {
const id = sid('s1')
const projection = createSnapshotStore({ displayTitle: 's1' })
const store = storeFor(b, 'conversation', id)
const store = storeFor(b, 'conversation.session', id)
store.actions.select({ turnSeq: 3, callId: 'c1' })
store.actions.setDraft('half-typed')
projection.set({ displayTitle: 'proj-a' })
expect(projection.getSnapshot().displayTitle).toBe('proj-a')
const after = storeFor(b, 'conversation', id)
const after = storeFor(b, 'conversation.session', id)
expect(after).toBe(store)
expect(after.store.getSnapshot().selection).toEqual({ turnSeq: 3, callId: 'c1' })
expect(after.store.getSnapshot().draft).toBe('half-typed')
@@ -120,7 +125,7 @@ describe('selection survives on the store seat', () => {
it('session death buries the instance and its persisted draft', () => {
const b = bench()
const doomed = storeFor(b, 'conversation', sid('s1'))
const doomed = storeFor(b, 'conversation.session', sid('s1'))
doomed.actions.setDraft('to be buried')
doomed.actions.select({ turnSeq: 1 })
expect(localStorage.getItem('dsh.conversation.chat.s1')).not.toBeNull()
@@ -131,7 +136,7 @@ describe('selection survives on the store seat', () => {
// Persisted residue is gone with the session...
expect(localStorage.getItem('dsh.conversation.chat.s1')).toBeNull()
// ...and a re-created same-id session starts from a FRESH instance.
const reborn = storeFor(b, 'conversation', sid('s1'))
const reborn = storeFor(b, 'conversation.session', sid('s1'))
expect(reborn).not.toBe(doomed)
expect(reborn.store.getSnapshot()).toEqual({ selection: null, draft: '', view: null })
})

View File

@@ -23,11 +23,9 @@ async function bench(withSessions = true) {
const prompt = vi.fn(() => Promise.resolve({ ok: true as const, value: { accepted: true as const } }))
const cancel = vi.fn(() => Promise.resolve({ ok: true as const, value: { accepted: true as const } }))
const loadOlder = vi.fn(() => Promise.resolve())
const updatePendingPrompt = vi.fn()
const retryPendingPrompt = vi.fn()
const sessions = {
binding: (sessionId: SessionId) => ({
sessionId, session: { prompt, cancel, loadOlder, updatePendingPrompt, retryPendingPrompt },
sessionId, session: { prompt, cancel, loadOlder },
}),
scopeOf,
} as unknown as SessionsService
@@ -35,22 +33,18 @@ async function bench(withSessions = true) {
await ctx.plugin(ConversationService).await()
const root = ctx.get('conversation') as ConversationService
const scoped = ctx.plugin(() => {}).ctx.extend({ [SCOPE_TAG]: sid('s1') }).get('conversation') as ConversationService
return { root, scoped, prompt, cancel, loadOlder, updatePendingPrompt, retryPendingPrompt }
return { root, scoped, prompt, cancel, loadOlder }
}
describe('ConversationService', () => {
it('routes ordinary and retained-prompt operations through the public Session binding', async () => {
it('routes operations through the public Session binding', async () => {
const b = await bench()
await b.scoped.send('hello', 'steer')
await b.scoped.cancel()
await b.scoped.loadOlder()
b.scoped.updatePendingPrompt('revised')
b.scoped.retryPendingPrompt()
expect(b.prompt).toHaveBeenCalledWith([{ type: 'text', text: 'hello' }], 'steer')
expect(b.cancel).toHaveBeenCalledOnce()
expect(b.loadOlder).toHaveBeenCalledOnce()
expect(b.updatePendingPrompt).toHaveBeenCalledWith('revised')
expect(b.retryPendingPrompt).toHaveBeenCalledOnce()
})
it('folds Session business failures into callback rejections', async () => {

View File

@@ -1,4 +1,7 @@
// @vitest-environment jsdom
// ConversationRoot skeleton behavior: the ONE resident composer across the
// hero (blank session) and active phases — same textarea DOM node, machine-
// owned draft, and the hero workspace picker (switching = retargetWorkspace).
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render } from '@testing-library/react'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
@@ -6,11 +9,22 @@ import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type {
ConversationSnapshot, SessionId, SessionListState, WorkspaceId, WorkspaceListState, WorkspaceView,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { EmptyStateProps } from '../src/client/skeleton/EmptyState.tsx'
import type { ConversationRootProps } from '../src/client/skeleton/ConversationRoot.tsx'
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import { createChatStore } from '../src/client/stores.ts'
import { SessionInputShell } from '../src/client/input/facade.ts'
import { ConversationRoot } from '../src/client/skeleton/ConversationRoot.tsx'
import { EmptyState } from '../src/client/skeleton/EmptyState.tsx'
import { ConversationSession } from '../src/client/skeleton/ConversationSession.tsx'
import { InputBar } from '../src/client/skeleton/InputBar.tsx'
import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx'
import type { ComposerBarOwnerProps } from '../src/client/contract/slots.ts'
/** Machine-backed wiring over a sink spy. */
function fakeWiring() {
const sink = vi.fn()
const shell = new SessionInputShell({ actx: {} as ClientContext, defaultSink: sink })
return { wiring: shell, sink, shell }
}
afterEach(cleanup)
beforeEach(() => { localStorage.clear() })
@@ -26,180 +40,169 @@ function workspace(id = 'w1'): WorkspaceView {
}
}
type SessionIntent = NonNullable<SessionListState['intent']>
type WorkspaceIntent = NonNullable<WorkspaceListState['intent']>
const workspaceState = (
items: readonly WorkspaceView[], workspaceIntent?: WorkspaceIntent,
): WorkspaceListState => ({
items, intent: workspaceIntent, state: 'idle', phase: 'ready', error: null,
const workspaceState = (items: readonly WorkspaceView[]): WorkspaceListState => ({
items, state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,
})
const hook = <T,>(snapshot: T) => <S,>(selector: (state: T) => S): S => selector(snapshot)
function mountEmpty(
intent: SessionIntent,
items: readonly WorkspaceView[] = [],
localWorkspace?: WorkspaceIntent,
) {
const updateSessionPrompt = vi.fn()
const sendSession = vi.fn()
const startSession = vi.fn()
let pickerOwner: unknown
const sessionState: SessionListState = {
ids: [], byId: {}, current: intent.sessionId, intent, phase: 'ready',
}
const workspaceIntent = intent.target.kind === 'workspace-intent'
? localWorkspace ?? { name: 'workspace', phase: 'ready' as const }
: undefined
const view = render(
<EmptyState
useSessions={hook(sessionState)}
useWorkspaces={hook(workspaceState(items, workspaceIntent))}
updateSessionPrompt={updateSessionPrompt}
sendSession={sendSession}
startSession={startSession}
renderSlot={((_key: string, owner: unknown) => { pickerOwner = owner; return null }) as EmptyStateProps['renderSlot']}
/>,
)
return { view, updateSessionPrompt, sendSession, startSession, pickerOwner: () => pickerOwner }
}
describe('EmptyState', () => {
it('reads the Workspace and Session intents from runtime projections', () => {
const b = mountEmpty({
sessionId: sid('local-1'), target: { kind: 'workspace-intent' },
prompt: 'draft', phase: 'ready',
})
expect(b.view.getByRole('button', { name: 'Choose workspace' }).textContent).toContain('workspace')
fireEvent.click(b.view.getByRole('button', { name: 'Add attachment' }))
expect((b.pickerOwner() as { open: boolean }).open).toBe(false)
fireEvent.change(b.view.getByPlaceholderText('Describe what you want to build'), { target: { value: 'build it' } })
expect(b.updateSessionPrompt).toHaveBeenCalledWith('build it')
fireEvent.click(b.view.getByRole('button', { name: 'Send message' }))
expect(b.sendSession).toHaveBeenCalledOnce()
})
it('uses useWorkspaces for the selected label and preserves the prompt when retargeting', () => {
const first = workspace('first')
const b = mountEmpty({
sessionId: sid('local-2'), target: { kind: 'workspace', workspaceId: first.workspaceId },
prompt: 'keep me', phase: 'ready',
}, [first])
expect(b.view.getByRole('button', { name: 'Choose workspace' }).textContent).toContain('first')
fireEvent.click(b.view.getByRole('button', { name: 'Choose workspace' }))
const owner = b.pickerOwner() as { onPick(id: WorkspaceId): void }
owner.onPick(wid('second'))
expect(b.startSession).toHaveBeenCalledWith(wid('second'), 'keep me')
})
it('exposes materialization phase and failure text', () => {
const creating = mountEmpty({
sessionId: sid('local-3'), target: { kind: 'workspace-intent' },
prompt: 'x', phase: 'ready',
}, [], { name: 'workspace', phase: 'creating' })
expect(creating.view.getByRole('status').textContent).toBe('Creating workspace…')
cleanup()
const workspaceFailed = mountEmpty({
sessionId: sid('local-3'), target: { kind: 'workspace-intent' },
prompt: 'x', phase: 'ready',
}, [], { name: 'workspace', phase: 'ready', error: 'offline' })
expect(workspaceFailed.view.getByRole('alert').textContent).toBe('Workspace creation failed: offline')
cleanup()
const failed = mountEmpty({
sessionId: sid('local-3'), target: { kind: 'workspace', workspaceId: wid('w1') },
prompt: 'x', phase: 'ready', error: { step: 'session', message: 'offline' },
}, [workspace()])
expect(failed.view.getByRole('alert').textContent).toBe('Session creation failed: offline')
})
})
function conversationSnapshot(
composerPhase: ConversationSnapshot['composerPhase'],
pendingPrompt: ConversationSnapshot['pendingPrompt'] = null,
): ConversationSnapshot {
function conversationSnapshot(overrides: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
return {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [],
pending: [], running: false, composerPhase, removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, intent: null, pendingPrompt, lastAgentError: null,
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, lastAgentError: null,
...overrides,
}
}
function mountConversation(pendingPrompt: ConversationSnapshot['pendingPrompt'] = null) {
function mount(snapshot: ConversationSnapshot, workspaceRows: WorkspaceView[] = [{ ...workspace('one'), sessionIds: [SID] }]) {
const root = sid('root')
const sessions = createSnapshotStore<SessionListState>({
ids: [root, SID],
byId: {
[root]: { id: root, displayTitle: 'Root', running: false, updatedAt: 1 },
[SID]: { id: SID, displayTitle: 'Child', parentId: root, cwd: '/projects/one', running: false, updatedAt: 2 },
[root]: { id: root, displayTitle: 'Root', running: false, blank: false, updatedAt: 1 },
[SID]: { id: SID, displayTitle: 'Child', parentId: root, cwd: '/projects/one', running: false, blank: false, updatedAt: 2 },
},
current: SID,
intent: undefined,
phase: 'ready',
})
const workspaces = createSnapshotStore<WorkspaceListState>(workspaceState([{ ...workspace('one'), sessionIds: [SID] }]))
const session = createSnapshotStore<ConversationSnapshot>(conversationSnapshot(
pendingPrompt === null ? 'active' : 'blank', pendingPrompt,
))
const workspaces = createSnapshotStore<WorkspaceListState>(workspaceState(workspaceRows))
const session = createSnapshotStore<ConversationSnapshot>(snapshot)
const useSession = bindSnapshotSelector(session)
const chat = createChatStore().create()
chat.actions.setDraft('ordinary draft')
const send = vi.fn()
const { wiring, sink } = fakeWiring()
const useInput = bindSnapshotSelector(wiring.state)
const inputActions = wiring.actions
const stop = vi.fn()
const open = vi.fn()
const updateSessionPrompt = vi.fn()
const retrySessionPrompt = vi.fn()
const renderSlot = ((_key: string, _owner: object, opts?: { only?: string }) => (
<div data-testid={`view-${opts?.only ?? 'all'}`} />
)) as ConversationRootProps['renderSlot']
const retargetWorkspace = vi.fn()
const slotCalls: string[] = []
let pickerOwner: unknown
const renderSlot = ((key: string, owner: object, opts?: { only?: string }) => {
slotCalls.push(key)
if (key === 'conversation.hero.workspace') { pickerOwner = owner; return null }
if (key === 'conversation.session') {
return (
<ConversationSession
sessionId={SID}
SessionProvider={({ children }) => children(SID)}
useSession={useSession}
useSessions={props.useSessions}
useWorkspaces={props.useWorkspaces}
useInput={useInput}
inputActions={inputActions}
useStore={bindSnapshotSelector(chat)}
actions={chat.actions}
renderSlot={renderSlot as never}
views={{ list: () => [{ id: 'chat', label: 'Chat' }], subscribe: () => () => {}, version: () => 1 }}
bindDraftMirror={write => wiring.bindMirror(write)}
open={open}
/>
)
}
if (key === 'conversation.composer.bar') {
// The real entry, mounted the way the outlet composes it: standard kit
// (shared with the root's props below) + this entry's inject + owner.
const bar = owner as ComposerBarOwnerProps
return (
<InputBar
sessionId={SID}
SessionProvider={({ children }) => children(SID)}
useSession={useSession}
useSessions={props.useSessions}
useWorkspaces={props.useWorkspaces}
useInput={useInput}
inputActions={inputActions}
keyboard={wiring}
stop={stop}
renderSlot={(() => null) as InputBarProps['renderSlot']}
{...bar}
/>
)
}
return <div data-testid={`view-${opts?.only ?? key}`} />
}) as ConversationRootProps['renderSlot']
const renderSlotChain = ((_key, _owner, opts) => opts?.fallback ?? null) as ConversationRootProps['renderSlotChain']
const SessionProvider: ConversationRootProps['SessionProvider'] = ({ children }) => <>{children(SID)}</>
const props: ConversationRootProps = {
sessionId: SID,
useSession: bindSnapshotSelector(session),
SessionProvider: ({ children }) => children(SID),
useSession,
useSessions: bindSnapshotSelector(sessions),
useWorkspaces: bindSnapshotSelector(workspaces),
useStore: bindSnapshotSelector(chat),
actions: chat.actions,
useInput,
inputActions,
renderSlot,
renderSlotChain,
SessionProvider,
views: { list: () => [{ id: 'chat', label: 'Chat' }], subscribe: () => () => {}, version: () => 1 },
send,
stop,
open,
updateSessionPrompt,
retrySessionPrompt,
selectWorkspace: retargetWorkspace,
}
const view = render(<ConversationRoot {...props} />)
return { view, chat, send, open, updateSessionPrompt, retrySessionPrompt }
return {
view, chat, sink, open, retargetWorkspace, session, slotCalls,
pickerOwner: () => pickerOwner,
rerender: () => { view.rerender(<ConversationRoot {...props} />) },
}
}
describe('ConversationRoot draft ownership', () => {
it('keeps ordinary per-Session composer text in the chat store and selects through runtime actions', () => {
const b = mountConversation()
describe('ConversationRoot resident composer', () => {
it('keeps composer text in the machine, mirrors to the chat store, and submits through the sink', () => {
const b = mount(conversationSnapshot())
const box = b.view.getByRole('textbox')
expect((box as HTMLTextAreaElement).value).toBe('ordinary draft')
fireEvent.change(box, { target: { value: 'ordinary revised' } })
expect(b.chat.store.getSnapshot().draft).toBe('ordinary revised')
fireEvent.keyDown(box, { key: 'Enter' })
expect(b.send).toHaveBeenCalledWith('ordinary revised', 'queue')
expect(b.sink).toHaveBeenCalledWith('ordinary revised', 'queue')
fireEvent.click(b.view.getByRole('button', { name: 'Root' }))
expect(b.open).toHaveBeenCalledWith(sid('root'))
})
it('reads a retained prompt from useSession and edits/retries it through the scoped Session', () => {
const b = mountConversation({
workspaceId: wid('one'), text: 'retry me', phase: 'failed',
retry: 'send', error: 'offline',
})
it('hero phase: same textarea, hero chrome, no header, picker switches the workspace', () => {
const b = mount(conversationSnapshot({ composerPhase: 'blank', blank: true }))
// Hero chrome present, view ring absent.
expect(b.view.getByText("Let's start building")).toBeTruthy()
expect(b.view.queryByTestId('view-chat')).toBeNull()
// The same machine-backed textarea is live in the hero, and the
// persistence mirror stays bound (ConversationSession mounts chrome-less
// for blank sessions): hero typing reaches the chat store.
const box = b.view.getByRole('textbox')
expect((box as HTMLTextAreaElement).value).toBe('retry me')
expect(b.view.getByRole('alert').textContent).toBe('Message send failed: offline')
fireEvent.change(box, { target: { value: 'revised prompt' } })
expect(b.updateSessionPrompt).toHaveBeenCalledWith('revised prompt')
expect(b.chat.store.getSnapshot().draft).toBe('ordinary draft')
fireEvent.keyDown(box, { key: 'Enter' })
expect(b.retrySessionPrompt).toHaveBeenCalledOnce()
expect(b.send).not.toHaveBeenCalled()
fireEvent.change(box, { target: { value: 'draft in hero' } })
expect(b.chat.store.getSnapshot().draft).toBe('draft in hero')
// Picker: open through the chip; a pick switches to the other
// workspace's blank session (draft carry is apply-layer wiring).
fireEvent.click(b.view.getByRole('button', { name: 'Choose workspace' }))
const owner = b.pickerOwner() as { open: boolean; onPick(id: WorkspaceId): void }
expect(owner.open).toBe(true)
owner.onPick(wid('second'))
expect(b.retargetWorkspace).toHaveBeenCalledWith(wid('second'))
})
it('textarea DOM identity survives the hero → active flip', () => {
const b = mount(conversationSnapshot({ composerPhase: 'blank', blank: true }))
const before = b.view.getByRole('textbox')
fireEvent.change(before, { target: { value: 'kept across flip' } })
// First message landed: content exists, phase leaves blank.
b.session.set(conversationSnapshot({ composerPhase: 'active', blank: false }))
b.rerender()
const after = b.view.getByRole('textbox')
expect(after).toBe(before)
expect((after as HTMLTextAreaElement).value).toBe('kept across flip')
expect(b.view.queryByText("Let's start building")).toBeNull()
expect(b.view.getByTestId('view-chat')).toBeTruthy()
})
it('blank session keeps the interactive picker chip (workspace switchable until the first message)', () => {
const b = mount(conversationSnapshot({ composerPhase: 'blank', blank: true }))
const chip = b.view.getByRole('button', { name: 'Choose workspace' })
expect((chip as HTMLButtonElement).disabled).toBe(false)
expect(b.slotCalls).toContain('conversation.hero.workspace')
})
it('prompt failure renders the promptError strip (ordinary failure, no transaction UI)', () => {
const b = mount(conversationSnapshot({
promptError: { op: 'send', error: { code: 'offline', message: 'Message send failed' } as never },
}))
expect(b.view.getByRole('alert').textContent).toContain('Message send failed (offline)')
expect(b.view.queryByRole('button', { name: 'Retry' })).toBeNull()
})
})