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:
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
846
packages/client/ui-conversation/tests/input-machine.spec.ts
Normal file
846
packages/client/ui-conversation/tests/input-machine.spec.ts
Normal 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 ' })
|
||||
})
|
||||
})
|
||||
193
packages/client/ui-conversation/tests/input-matrix.spec.tsx
Normal file
193
packages/client/ui-conversation/tests/input-matrix.spec.tsx
Normal 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 ')
|
||||
})
|
||||
})
|
||||
264
packages/client/ui-conversation/tests/input-scenarios.spec.tsx
Normal file
264
packages/client/ui-conversation/tests/input-scenarios.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
99
packages/client/ui-conversation/tests/queue-dock.spec.tsx
Normal file
99
packages/client/ui-conversation/tests/queue-dock.spec.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
})
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user