Merge remote-tracking branch 'origin/master' into worktree/web-multimodal-image-input

# Conflicts:
#	apps/cli/README.md
#	apps/cli/src/web.ts
#	apps/web/tests/smoke-fixture.e2e.ts
#	docs/architecture.i18n.yaml
#	docs/module-graph.md
#	examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl
#	packages/client/connection/src/client/api.ts
#	packages/client/connection/src/client/fixture.ts
#	packages/client/runtime/README.md
#	packages/client/runtime/src/client/index.ts
#	packages/client/runtime/src/client/sessions/conversation.ts
#	packages/client/runtime/src/client/sessions/service.ts
#	packages/client/runtime/src/client/sessions/session.ts
#	packages/client/ui-conversation/src/client/apply.ts
#	packages/client/ui-conversation/src/client/contract/slots.ts
#	packages/client/ui-conversation/src/client/index.ts
#	packages/client/ui-conversation/src/client/service.ts
#	packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx
#	packages/client/ui-conversation/src/client/skeleton/EmptyState.tsx
#	packages/client/ui-conversation/src/client/skeleton/InputBar.tsx
#	packages/client/ui-conversation/src/client/stores.ts
#	packages/client/ui-conversation/tests/apply-inject.spec.tsx
#	packages/client/ui-conversation/tests/service-orchestration.spec.ts
#	packages/client/ui-conversation/tests/skeleton-branches.spec.tsx
#	packages/client/ui-conversation/tests/skeleton.spec.tsx
#	packages/cordis/tool-cordis/src/api-catalog.ts
#	packages/host/apiproxy/package.json
#	packages/host/apiproxy/src/api-proxy.ts
#	packages/host/apiproxy/src/api/sessions.schema.ts
#	packages/host/runtime/package.json
#	packages/host/runtime/src/boot.ts
#	packages/host/runtime/tests/host-runtime.spec.ts
#	packages/host/runtime/tsconfig.json
#	packages/host/webserver/README.md
#	packages/host/webserver/src/index.ts
#	packages/host/webserver/tests/webserver.spec.ts
#	packages/llm/llm-pi-ai/tests/convert.spec.ts
#	packages/ui/acp/src/codec.ts
#	packages/ui/acp/tests/codec.spec.ts
#	pnpm-lock.yaml
This commit is contained in:
Yichen Jiang
2026-07-25 22:35:22 +08:00
1264 changed files with 37694 additions and 29428 deletions

View File

@@ -3,8 +3,8 @@
// shape: the conversation surface (views triple, send choreography incl.
// optimistic clear + failure restore THROUGH the declared store actions,
// openDetails = select action + layout orchestration, sessions.open
// navigation), the injectless-but-closeDetails details surface, and the
// one-callback empty surface. Complements chat-apply.spec.tsx (registration)
// navigation), and the closeDetails details surface. Complements
// chat-apply.spec.tsx (registration)
// and selection-survival.spec.ts (store axis). History opening is NOT an
// inject concern anymore — the runtime sessions service opens on watch
// (sessions-service.spec.ts owns that behavior).
@@ -14,9 +14,11 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup } from '@testing-library/react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { SlotsService, scopeOf } from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
import type {
SessionId, SessionListState, WorkspaceListState,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { SlotRendererHost } from '@deepseek-ai/dsh-client-web-react'
import { ConversationService, apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type {
ChatViewInjected, ConversationInjected, DetailsInjected, EmptyStateInjected,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
@@ -53,13 +55,17 @@ async function bench() {
ids: [ROOT],
byId: { [ROOT]: { id: ROOT, title: 'R', displayTitle: 'R', cwd: '/proj', running: false, updatedAt: 1 } },
current: ROOT,
} as SessionListState)
intent: undefined,
phase: 'ready',
})
const sessionFake = {
open: vi.fn(() => Promise.resolve()),
loadOlder: vi.fn(() => Promise.resolve()),
updatePendingPrompt: vi.fn(),
updatePendingImages: 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 } })),
readAttachment: vi.fn(() => Promise.reject(new Error('attachment response not configured'))),
cancel: vi.fn<() => Promise<{ ok: boolean; value?: object; error?: { code: string; message: string } }>>(
() => Promise.resolve({ ok: true, value: { accepted: true } })),
}
@@ -74,16 +80,25 @@ async function bench() {
}
const sessionsFake = {
list: listStore,
manager: { get: () => sessionFake },
binding: (id: SessionId) => ({ sessionId: id, session: sessionFake, ctx: mint(id) }),
scope: (id: SessionId) => mint(id),
cell: () => undefined,
scopeOf,
hostDescription: () => undefined,
create: vi.fn(() => Promise.resolve(ROOT)),
createWorkspace: vi.fn(() => Promise.resolve(ROOT)),
open: vi.fn(),
updateIntent: vi.fn(),
intent: () => sessionFake,
}
ctx.provide('sessions', sessionsFake)
const workspaceStore = createSnapshotStore<WorkspaceListState>({
items: [], intent: undefined, state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,
})
const workspacesFake = {
list: workspaceStore,
startSession: vi.fn(),
sendSession: vi.fn(),
}
ctx.provide('workspaces', workspacesFake)
const layoutFake = { openDetails: vi.fn(), closeDetails: vi.fn() }
ctx.provide('layout', layoutFake)
ctx.provide('i18n', { bind: () => (key: string) => key })
@@ -126,20 +141,25 @@ async function bench() {
id, instance.actions)
return { instance, injected }
}
return { ctx, slots, hostFace, entryOf, conversationSurface, chatViewSurface, sessionFake, sessionsFake, layoutFake, mint }
const emptySurface = () => {
const entry = entryOf('conversation.empty')
return (entry.inject as unknown as () => EmptyStateInjected)()
}
return {
ctx, slots, hostFace, entryOf, conversationSurface, chatViewSurface, emptySurface,
sessionFake, sessionsFake, workspacesFake, layoutFake, mint,
}
}
describe('conversation slot inject surface', () => {
it('assembles the thin surface side-effect-free, navigates via sessions.open', async () => {
it('assembles the thin surface side-effect-free', async () => {
const b = await bench()
const { injected } = b.conversationSurface(ROOT)
// Assembly has no session side effects: opening the event window belongs
// to the runtime watch path, not the inject factory.
expect(b.sessionFake.open).not.toHaveBeenCalled()
expect(injected.views.list().map(v => v.id)).toEqual(['chat'])
injected.open(ROOT)
expect(b.sessionsFake.open).toHaveBeenCalledWith(ROOT)
// loadOlder moved to the chat view entry's face (the ring rider).
const chatView = b.chatViewSurface(ROOT)
chatView.injected.loadOlder()
expect(b.sessionFake.loadOlder).toHaveBeenCalledTimes(1)
@@ -205,6 +225,17 @@ describe('conversation slot inject surface', () => {
expect(conv.instance).toBe(instance)
})
it('routes navigation through SessionsService and the retained prompt through the scoped Session', async () => {
const b = await bench()
const { injected } = b.conversationSurface(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()
})
it('views read face projects the ring ledger (subscribe/version through ctx.slots)', async () => {
const b = await bench()
const { injected } = b.conversationSurface(ROOT)
@@ -228,7 +259,7 @@ describe('conversation slot inject surface', () => {
})
})
describe('details and empty inject surfaces', () => {
describe('details inject surface', () => {
it('details injects the one layout callback; selection rides the shared store instead', async () => {
const b = await bench()
const entry = b.entryOf('details')
@@ -242,35 +273,18 @@ describe('details and empty inject surfaces', () => {
expect(details).toBe(conv)
})
it('empty injects draft-image lifecycle, startSession, and createWorkspaceSession without a store', async () => {
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 = (entry.inject as unknown as () => EmptyStateInjected)()
expect(Object.keys(injected).sort()).toEqual([
'createDraftImages',
'createWorkspaceSession',
'releaseDraftImage',
'releaseDraftImages',
'startSession',
])
await injected.startSession({ text: 'go', mode: 'queue' })
expect(b.sessionsFake.create).toHaveBeenCalled()
expect(b.sessionsFake.open).toHaveBeenCalledWith(ROOT)
expect(b.sessionFake.prompt).toHaveBeenCalledWith([{ type: 'text', text: 'go' }], 'queue')
b.sessionsFake.open.mockClear()
await injected.createWorkspaceSession('Fresh')
expect(b.sessionsFake.createWorkspace).toHaveBeenCalledWith('Fresh')
expect(b.sessionsFake.open).toHaveBeenCalledWith(ROOT)
})
it('startSession fails loud on a torn boot (conversation service fiber gone)', async () => {
const b = await bench()
const injected = (b.entryOf('conversation.empty').inject as unknown as () => EmptyStateInjected)()
// Tear the service's own fiber (registry keyed by the class): the slot
// entries survive, so the gesture-time read hits the loud branch.
b.ctx.registry.delete(ConversationService)
await vi.waitFor(() => { expect(b.ctx.get('conversation')).toBeUndefined() })
expect(() => injected.startSession({ text: 'go', mode: 'queue' })).toThrow(/conversation service unavailable/)
const injected = b.emptySurface()
injected.startSession(undefined, 'fresh')
injected.startSession('workspace-1' as never, 'retargeted')
injected.updateSessionPrompt('typed')
await injected.sendSession([])
expect(b.workspacesFake.startSession).toHaveBeenNthCalledWith(1, undefined, 'fresh')
expect(b.workspacesFake.startSession).toHaveBeenNthCalledWith(2, 'workspace-1', 'retargeted')
expect(b.sessionsFake.updateIntent).toHaveBeenCalledWith('typed')
expect(b.workspacesFake.sendSession).toHaveBeenCalledOnce()
})
})

View File

@@ -30,16 +30,23 @@ async function bench() {
[CHILD]: { id: CHILD, title: 'C', displayTitle: 'C', parentId: ROOT, running: false, updatedAt: 2 },
},
current: undefined,
intent: undefined,
phase: 'ready',
} as SessionListState)
const sessionsFake = {
list: listStore,
manager: { get: vi.fn() },
binding: vi.fn(),
scope: () => undefined,
cell: () => undefined,
create: vi.fn(),
open: vi.fn(),
updateIntent: vi.fn(),
}
ctx.provide('sessions', sessionsFake)
ctx.provide('workspaces', {
startSession: vi.fn(),
sendSession: vi.fn(),
})
ctx.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
ctx.provide('i18n', { bind: () => (key: string) => key })
@@ -84,7 +91,7 @@ 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 declares none', async () => {
it('occupies the three slots + the ring; session entries share one store handle, empty injects runtime actions', async () => {
const b = await bench()
await b.fiber.await()
const conversation = renderEntryOf(b.slots, 'conversation')

View File

@@ -27,8 +27,8 @@ const assistant = (seq: number, turn: number, usage?: unknown): AssistantMessage
function snapshotBase(): ConversationSnapshot {
return {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [],
pending: [], running: false, removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, lastAgentError: null,
pending: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, intent: null, pendingPrompt: null, lastAgentError: null,
}
}
@@ -127,6 +127,8 @@ describe('bash sample row', () => {
[CHILD]: { id: CHILD, title: 'c', displayTitle: 'c', parentId: ROOT, running: false, updatedAt: 0 },
},
current: undefined,
intent: undefined,
phase: 'ready',
} as SessionListState)
}

View File

@@ -1,10 +1,5 @@
// @vitest-environment jsdom
/**
* createChatStore unit account (slot terminal design §4): the declared
* actions write set, persist round-trip through the scope-suffixed key, and
* factory purity (every create() is an independent instance; the factory
* itself holds no singleton state).
*/
/** Chat-store actions, scoped persistence, and instance isolation. */
import { beforeEach, describe, expect, it } from 'vitest'
import { createChatStore } from '../src/client/stores.ts'

View File

@@ -15,7 +15,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, render } from '@testing-library/react'
import { createSnapshotStore, SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import type {
ConversationSnapshot, SessionId, SessionListState, ToolResultNode,
ConversationSnapshot, 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'
@@ -40,8 +40,8 @@ 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, removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, lastAgentError: null,
pending: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, intent: null, pendingPrompt: null, lastAgentError: null,
} as ConversationSnapshot
}
@@ -65,9 +65,11 @@ async function bench(nodes: ToolResultNode[]) {
const session = createSnapshotStore<ConversationSnapshot>(snapshotWith(nodes))
const list = createSnapshotStore<SessionListState>({
ids: [SID],
byId: { [SID]: { id: SID, title: 'S', running: false, updatedAt: 1 } },
byId: { [SID]: { id: SID, title: 'S', displayTitle: 'S', running: false, updatedAt: 1 } },
current: SID,
} as SessionListState)
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 }
@@ -75,11 +77,20 @@ async function bench(nodes: ToolResultNode[]) {
const layout = { openDetails: vi.fn(), closeDetails: vi.fn() }
ctx.provide('sessions', {
list,
manager: { get: () => ({ loadOlder: vi.fn() }) },
binding: (id: SessionId) => ({ sessionId: id, session: { loadOlder: vi.fn() } }),
scope: () => ({ get: () => scoped }),
cell: (id: string) => (id === SID ? cell : undefined),
create: vi.fn(),
open: vi.fn(),
updateIntent: vi.fn(),
})
ctx.provide('workspaces', {
list: createSnapshotStore<WorkspaceListState>({
items: [], intent: undefined, 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 })
@@ -146,8 +157,6 @@ describe('keyed toolview hole through the real machinery', () => {
it('a duplicate key registration fails loud at load', async () => {
const b = await bench([])
// The bash sample already holds the 'bash' key (later-wins retired with
// the ring — the keyed ledger throws instead).
expect(() => b.slots.register(
{ name: 'conversation.chat.toolview', key: 'bash' },
() => null,
@@ -184,12 +193,23 @@ describe('registrant load-order seam', () => {
await slotsFiber.await()
const slots = ctx.get('slots') as SlotsService
ctx.provide('sessions', {
list: createSnapshotStore<SessionListState>({ ids: [], byId: {}, current: undefined } as SessionListState),
manager: { get: vi.fn() },
list: createSnapshotStore<SessionListState>({
ids: [], byId: {}, current: undefined, intent: undefined, phase: 'ready',
}),
binding: () => undefined,
scope: () => undefined,
cell: () => undefined,
create: vi.fn(),
open: vi.fn(),
updateIntent: vi.fn(),
})
ctx.provide('workspaces', {
list: createSnapshotStore<WorkspaceListState>({
items: [], intent: undefined, state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,
}),
startSession: vi.fn(),
sendSession: vi.fn(),
})
ctx.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
ctx.provide('i18n', { bind: () => (key: string) => key })

View File

@@ -7,7 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { Profiler } from 'react'
import { act, cleanup, fireEvent, render } from '@testing-library/react'
import type {
AssistantMessageNode, ConversationNode, ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, UserMessageNode,
AssistantMessageNode, ConversationNode, ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, UserMessageNode, WorkspaceListState,
} from '@deepseek-ai/dsh-client-runtime/client'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { createSnapshotStore, PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
@@ -29,8 +29,8 @@ const SID = 's1' as SessionId
function snapshotBase(): ConversationSnapshot {
return {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [],
pending: [], running: false, removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, lastAgentError: null,
pending: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, intent: null, pendingPrompt: null, lastAgentError: null,
}
}
@@ -69,10 +69,18 @@ const runningCall = (callId: string, name = 'bash'): RunningToolCall => ({
callId, name, argsRaw: `{"command":"cmd-${callId}"}`, turn: 2, step: 1, time: 1_000, callView: null,
})
/** Empty sessions-list hook stub (the global standard-kit seat; engines carry no hook since the store migration — bind here). */
/** Empty sessions-list hook for the global standard-kit seat. */
function emptySessions() {
const store = createSnapshotStore<SessionListState>(
{ ids: [], byId: {}, current: undefined } as SessionListState)
{ ids: [], byId: {}, current: undefined, intent: undefined, phase: 'ready' })
return bindSnapshotSelector(store)
}
function emptyWorkspaces() {
const store = createSnapshotStore<WorkspaceListState>({
items: [], intent: undefined, state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,
})
return bindSnapshotSelector(store)
}
@@ -95,6 +103,7 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
sessionId: SID,
useSession: bindSnapshotSelector(source),
useSessions: emptySessions(),
useWorkspaces: emptyWorkspaces(),
useStore: bindSnapshotSelector(chat),
actions: chat.actions,
renderSlot,

View File

@@ -87,8 +87,10 @@ describe('tails', () => {
const sid = 'root-1' as SessionId
const list = createSnapshotStore<SessionListState>({
ids: [sid],
byId: { [sid]: { id: sid, title: 'r', running: false, updatedAt: 0 } },
byId: { [sid]: { id: sid, title: 'r', displayTitle: 'r', running: false, updatedAt: 0 } },
current: undefined,
intent: undefined,
phase: 'ready',
} as SessionListState)
const props = {
callId: 'c1', toolName: 'bash', block: errorResult, openDetails: vi.fn(),

View File

@@ -1,16 +1,11 @@
// @vitest-environment jsdom
// Final branch tails for the coverage gate, terminal slot form:
// AssistantMarkdown non-final reasoning, StatsLine usage-less node,
// DetailsPanel titleless selection. (The old cwd WeakMap-cache account
// retired with the mechanism — derivation lives in EmptyState now, covered
// by the skeleton specs.)
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, 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 { UseSession } from '@deepseek-ai/dsh-client-web-react'
import type { ConversationSnapshot, SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
import type { ConversationSnapshot, SessionId, SessionListState, WorkspaceListState } from '@deepseek-ai/dsh-client-runtime/client'
import type { SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { createChatStore } from '../src/client/stores.ts'
import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
@@ -24,8 +19,8 @@ const SID = 's1' as SessionId
function snapshotBase(): ConversationSnapshot {
return {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [],
pending: [], running: false, removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, lastAgentError: null,
pending: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, intent: null, pendingPrompt: null, lastAgentError: null,
} as ConversationSnapshot
}
@@ -70,12 +65,17 @@ 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 } as SessionListState)
{ ids: [], byId: {}, current: undefined, intent: undefined, phase: 'ready' })
const emptyWorkspaces = createSnapshotStore<WorkspaceListState>({
items: [], intent: undefined, 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)}
useStore={bindSnapshotSelector(chat)}
actions={chat.actions}
closeDetails={vi.fn()}

View File

@@ -1,26 +0,0 @@
/**
* Test-local selector-hook binder: the engine carries no hook since the store
* migration (runtime is React-free); the renderer binds in production, specs
* bind here. Delegates to web-react's bindSnapshotSelector SOURCE (same
* with-selector uSES shim as production, so selector-level render economics —
* a top-level snapshot swap with an unchanged slice does NOT re-render — hold
* in Profiler-count specs). Source-relative import: the package dependency
* edge to web-react is gone (store migration §7); tests reach the sibling
* package the same way they reach their own src internals.
*/
import { bindSnapshotSelector } from '../../web-react/src/bind.ts'
/** Minimal observable source (engine stores and scripted fakes both satisfy it). */
export interface HookSource<T> {
getSnapshot(): T
subscribe(fn: () => void): () => void
}
/**
* Bind a selector hook over a snapshot source.
* @param src - the source.
* @returns a SnapshotSelectorHook-shaped hook.
*/
export function hookOf<T>(src: HookSource<T>) {
return bindSnapshotSelector<T>(src)
}

View File

@@ -19,9 +19,9 @@ function setup(over?: Partial<InputBarProps>) {
}
const view = render(<InputBar {...props} />)
const textarea = view.container.querySelector('textarea')!
// aria-label (not role name): title also contains 发送/停止 and would double-match.
// aria-label (not role name): title carries the same label and would double-match.
const button = view.container.querySelector<HTMLButtonElement>(
`button[aria-label="${over?.running === true ? '停止' : '发送'}"]`,
`button[aria-label="${over?.running === true ? 'Stop generating' : 'Send message'}"]`,
)!
return { view, textarea, button, props }
}
@@ -80,7 +80,7 @@ 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)
expect(button.getAttribute('aria-label')).toBe('停止')
expect(button.getAttribute('aria-label')).toBe('Stop generating')
fireEvent.click(button)
expect(props.onStop).toHaveBeenCalledTimes(1)
expect(props.onSend).not.toHaveBeenCalled()
@@ -100,30 +100,30 @@ describe('running lock and primary button', () => {
const textarea = view.container.querySelector('textarea')!
expect(document.activeElement).toBe(textarea)
textarea.blur()
fireEvent.mouseDown(view.container.querySelector('button[aria-label="发送"]')!)
fireEvent.mouseDown(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: '' })
expect(textarea.placeholder).toBe('会话不可用')
expect(textarea.placeholder).toBe('Session unavailable')
const live = setup({ draft: '' })
expect(live.textarea.placeholder).toContain('Enter 发送')
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).toContain('停止')
const custom = setup({ placeholder: '自定义' })
expect(custom.textarea.placeholder).toBe('自定义')
expect(runningPh.textarea.placeholder).toBe('Generating a response…')
const custom = setup({ 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.getByText(/发送失败:boom/)).toBeTruthy()
expect(send.view.container.querySelector('[role="alert"]')?.textContent).toBe('boom')
const stop = setup({ error: { op: 'stop', message: 'halt' } })
expect(stop.view.getByText(/停止失败:halt/)).toBeTruthy()
expect(stop.view.container.querySelector('[role="alert"]')?.textContent).toBe('halt')
})
it('hero variant adds the hero class and accessory row renders', () => {
@@ -212,7 +212,7 @@ describe('image draft rail', () => {
const { view, textarea, props } = setup({
draft: '', attachments: [attachment], onRemoveAttachment,
})
const send = view.getByRole('button', { name: '发送' }) as HTMLButtonElement
const send = view.getByRole('button', { name: 'Send message' }) as HTMLButtonElement
expect(send.disabled).toBe(false)
fireEvent.keyDown(textarea, { key: 'Enter' })
expect(props.onSend).toHaveBeenCalledWith('queue')
@@ -230,7 +230,7 @@ describe('image draft rail', () => {
describe('placeholder chrome', () => {
it('renders attach / Plan / Read-only / model controls', () => {
const { view } = setup()
expect(view.getByLabelText('添加')).toBeTruthy()
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')
@@ -256,7 +256,7 @@ describe('placeholder chrome', () => {
it('running locks the chrome selects and attach control', () => {
const { view } = setup({ running: true })
expect((view.getByLabelText('添加') as HTMLButtonElement).disabled).toBe(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)
})

View File

@@ -1,38 +1,35 @@
// @vitest-environment jsdom
/**
* Selection survival across the store seat (terminal design §4): the chat
* store now carries what the per-scope selection account used to — this pins
* the same behavior contract in the new mechanism. Drives the REAL
* SlotsService store axis with the shared createChatStore handle (the exact
* apply.ts shape: one handle, two session-slot registrations): same session's
* two slots resolve one instance (conversation writes, details reads);
* sessions are isolated; a session's death buries its instance AND its
* persisted draft; a list refresh does not touch instance identity.
* Exercises selection persistence through the real SlotsService store axis;
* component stubs cannot prove per-session identity or disposal.
*/
import { Context } from 'cordis'
import { beforeEach, describe, expect, it } from 'vitest'
import { SessionsService, SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import { createSnapshotStore, SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionId, SessionListState, WorkspaceListState } from '@deepseek-ai/dsh-client-runtime/client'
import { createChatStore } from '../src/client/stores.ts'
// The runtime package's programmable fake lives in its tests; import through
// the src path (same pattern the runtime specs use — test-support material).
import { FakeApiClient, ok } from '../../runtime/tests/fake-api.ts'
const sid = (s: string): SessionId => s as SessionId
interface Bench {
ctx: Context
api: FakeApiClient
sessions: SessionsService
slots: SlotsService
chat: ReturnType<typeof createChatStore>
}
function bench(): Bench {
const ctx = new Context()
const api = new FakeApiClient()
const sessions = new SessionsService(ctx, api)
ctx.provide('sessions', {
list: createSnapshotStore<SessionListState>({
ids: [], byId: {}, current: undefined, intent: undefined, phase: 'ready',
}),
cell: () => undefined,
})
ctx.provide('workspaces', {
list: createSnapshotStore<WorkspaceListState>({
items: [], intent: undefined, state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,
}),
})
// Service self-registers as ctx 'slots' (cordis Service constructor).
const slots = new SlotsService(ctx)
const chat = createChatStore()
@@ -49,22 +46,7 @@ function bench(): Bench {
}, (_p: { renderSlot?: unknown }) => null)
slots.register({ name: 'conversation', store: chat }, () => null)
slots.register({ name: 'details', store: chat }, () => null)
return { ctx, api, sessions, slots, chat }
}
async function flush(): Promise<void> {
// Manager notifier + store batching are microtask-based.
await Promise.resolve()
await Promise.resolve()
}
function feed(b: Bench, rows: { id: string; cwd?: string; running?: boolean }[]): void {
b.api.onList = () => Promise.resolve(ok({
items: rows.map(r => ({
sessionId: sid(r.id), updatedAt: 1, running: r.running ?? false,
...(r.cwd !== undefined ? { cwd: r.cwd } : {}),
})),
}) as never)
return { slots, chat }
}
/** Resolve the store instance the renderer would hand a slot's component for a session. */
@@ -94,11 +76,8 @@ beforeEach(() => {
})
describe('selection survives on the store seat', () => {
it('one session, two slots: conversation writes, details reads the SAME instance', async () => {
it('one session, two slots: conversation writes, details reads the SAME instance', () => {
const b = bench()
feed(b, [{ id: 's1' }])
await b.sessions.manager.refreshList()
await flush()
const conv = storeFor(b, 'conversation', sid('s1'))
const details = storeFor(b, 'details', sid('s1'))
@@ -108,11 +87,8 @@ describe('selection survives on the store seat', () => {
expect(details).toBe(conv)
})
it('sessions are isolated: s2 selection never bleeds into s1', async () => {
it('sessions are isolated: s2 selection never bleeds into s1', () => {
const b = bench()
feed(b, [{ id: 's1' }, { id: 's2' }])
await b.sessions.manager.refreshList()
await flush()
const one = storeFor(b, 'conversation', sid('s1'))
const two = storeFor(b, 'conversation', sid('s2'))
@@ -123,25 +99,17 @@ describe('selection survives on the store seat', () => {
expect(two.store.getSnapshot().selection).toEqual({ turnSeq: 9, callId: 'z' })
})
it('a display-title-upgrading list refresh keeps instance identity and the selection value', async () => {
it('a list-projection update keeps instance identity and the selection value', () => {
const b = bench()
// First-send shape: client-side create inserts the row without cwd (title = bare id).
b.api.onCreate = () => Promise.resolve(ok({ sessionId: sid('s1') }))
const id = await b.sessions.create({})
await flush()
expect(b.sessions.list.getSnapshot().byId[id]).toMatchObject({ displayTitle: 's1' })
expect(b.sessions.list.getSnapshot().byId[id]?.title).toBeUndefined()
const id = sid('s1')
const projection = createSnapshotStore({ displayTitle: 's1' })
const store = storeFor(b, 'conversation', id)
store.actions.select({ turnSeq: 3, callId: 'c1' })
store.actions.setDraft('half-typed')
// The late list refresh lands (host knows the cwd → better fallback label).
feed(b, [{ id: 's1', cwd: '/w/proj-a' }])
await b.sessions.manager.refreshList()
await flush()
expect(b.sessions.list.getSnapshot().byId[id]).toMatchObject({ displayTitle: 'proj-a' })
expect(b.sessions.list.getSnapshot().byId[id]?.title).toBeUndefined()
projection.set({ displayTitle: 'proj-a' })
expect(projection.getSnapshot().displayTitle).toBe('proj-a')
const after = storeFor(b, 'conversation', id)
expect(after).toBe(store)
@@ -149,32 +117,20 @@ describe('selection survives on the store seat', () => {
expect(after.store.getSnapshot().draft).toBe('half-typed')
})
it('session death buries the instance and its persisted draft', async () => {
it('session death buries the instance and its persisted draft', () => {
const b = bench()
feed(b, [{ id: 's1' }, { id: 's2' }])
await b.sessions.manager.refreshList()
await flush()
// Mint the scope (store prune rides the scope-teardown axis: no scope,
// no teardown — the real page always resolves the binding to render).
b.sessions.binding(sid('s1'))
const doomed = storeFor(b, 'conversation', sid('s1'))
doomed.actions.setDraft('to be buried')
doomed.actions.select({ turnSeq: 1 })
expect(localStorage.getItem('dsh.conversation.chat.s1')).not.toBeNull()
// Watch elsewhere so s1's scope teardown is not deferred, then remove it.
b.sessions.binding(sid('s2'))
feed(b, [{ id: 's2' }])
await b.sessions.manager.refreshList()
await flush()
// SessionsService calls this public slot lifecycle seam when the scope dies.
b.slots.pruneStoreScope(sid('s1'))
// 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.
feed(b, [{ id: 's1' }, { id: 's2' }])
await b.sessions.manager.refreshList()
await flush()
const reborn = storeFor(b, 'conversation', sid('s1'))
expect(reborn).not.toBe(doomed)
expect(reborn.store.getSnapshot()).toEqual({ selection: null, draft: '', imageIds: [], view: null })

View File

@@ -1,330 +1,70 @@
// @vitest-environment jsdom
/**
* ConversationService orchestration half after the store-seat slimming:
* scope-addressed send/cancel (result folding, root throw), the startSession
* chain (create → scoped send → sessions.open), and the service-unavailable
* loud failures. Selection/draft state left this service for the declared
* chat store (chat-store.spec.ts / selection-survival.spec.ts); the view
* registry left for the 'conversation.view' slot (views-type-chain.spec.tsx).
*/
import { Context } from 'cordis'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { AttachmentId } from '@deepseek-ai/dsh-attachment'
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import { describe, expect, it, vi } from 'vitest'
import { scopeOf } from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
import { ConversationService } from '@deepseek-ai/dsh-client-ui-conversation/client'
const sid = (s: string): SessionId => s as SessionId
/** Recover the module-private scope tag through the public seam (same probe as apply-inject.spec). */
const sid = (id: string) => id as SessionId
const SCOPE_TAG: symbol = (() => {
const recorded: (string | symbol)[] = []
const spy = new Proxy(new Context(), {
get(target, prop, receiver): unknown {
recorded.push(prop)
return Reflect.get(target, prop, receiver)
const reads: (string | symbol)[] = []
const proxy = new Proxy(new Context(), {
get(target, property, receiver): unknown {
reads.push(property)
return Reflect.get(target, property, receiver)
},
})
void scopeOf(spy)
const symbol = recorded.find((p): p is symbol => typeof p === 'symbol')
if (symbol === undefined) throw new Error('scopeOf probe recorded no symbol read')
return symbol
void scopeOf(proxy)
return reads.find((value): value is symbol => typeof value === 'symbol')!
})()
interface SessionDouble {
prompt: ReturnType<typeof vi.fn>
cancel: ReturnType<typeof vi.fn>
readAttachment: ReturnType<typeof vi.fn>
}
afterEach(() => {
vi.unstubAllGlobals()
})
async function bench(opts?: {
sessions?: boolean
description?: ReturnType<SessionsService['hostDescription']>
}) {
async function bench(withSessions = true) {
const ctx = new Context()
const sessionDoubles = new Map<SessionId, SessionDouble>()
const scopes = new Map<SessionId, Context>()
const mint = (id: SessionId): Context => {
let scoped = scopes.get(id)
if (scoped === undefined) {
const fiber = ctx.plugin(() => {})
scoped = fiber.ctx.extend({ [SCOPE_TAG]: id })
scopes.set(id, scoped)
}
return scoped
}
const createMock = vi.fn(() => Promise.resolve(sid('new-1')))
const openMock = vi.fn()
const sessionsFake = {
manager: {
get: (id: SessionId) => {
let s = sessionDoubles.get(id)
if (s === undefined) {
s = {
prompt: vi.fn(() => Promise.resolve({ ok: true, value: { accepted: true } })),
cancel: vi.fn(() => Promise.resolve({ ok: true, value: { accepted: true } })),
readAttachment: vi.fn(() => Promise.reject(new Error('attachment response not configured'))),
}
sessionDoubles.set(id, s)
}
return s
},
},
create: createMock,
open: openMock,
scope: (id: SessionId) => (id === sid('new-1') ? mint(id) : scopes.get(id)),
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 },
}),
scopeOf,
hostDescription: () => opts?.description,
} as unknown as SessionsService
if (opts?.sessions !== false) ctx.provide('sessions', sessionsFake)
// Class-plugin mount — the same form apply.ts uses in production.
const fiber = ctx.plugin(ConversationService)
await fiber.await()
const svc = ctx.get('conversation') as ConversationService
const scopedSvc = (id: SessionId) => mint(id).get('conversation') as ConversationService
return { ctx, svc, scopedSvc, mint, sessionDoubles, sessionsFake, createMock, openMock }
if (withSessions) ctx.provide('sessions', sessions)
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 }
}
describe('send / cancel', () => {
it('sends one text block through the scoped session with the mode', async () => {
describe('ConversationService', () => {
it('routes ordinary and retained-prompt operations through the public Session binding', async () => {
const b = await bench()
await b.scopedSvc(sid('s1')).send('hello', 'steer')
expect(b.sessionDoubles.get(sid('s1'))!.prompt).toHaveBeenCalledWith(
[{ type: 'text', text: 'hello' }], 'steer')
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 business failure into a thrown error carrying code and message', async () => {
it('folds Session business failures into callback rejections', async () => {
const b = await bench()
const s = b.scopedSvc(sid('s1'))
// Materialize the double first (manager.get is the lazy mint point).
b.sessionsFake.manager.get(sid('s1'))
const double = b.sessionDoubles.get(sid('s1'))!
double.prompt.mockResolvedValue({ ok: false, error: { code: 'agent-busy', message: 'busy' } })
await expect(s.send('x', 'queue')).rejects.toThrow(/send failed: agent-busy: busy/)
b.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'busy', details: {} } } as never)
await expect(b.scoped.send('x', 'queue')).rejects.toThrow('conversation.send failed: agent-busy: busy')
b.cancel.mockResolvedValueOnce({ ok: false, error: { code: 'internal', message: 'nope', details: {} } } as never)
await expect(b.scoped.cancel()).rejects.toThrow('conversation.cancel failed: internal: nope')
})
it('uploads temporary browser files as base64 image parts at the send boundary', async () => {
it('fails loudly from the root scope or without SessionsService', async () => {
const b = await bench()
const file = new File([Uint8Array.of(1, 2, 3)], 'pixel.png', { type: 'image/png' })
Object.defineProperty(file, 'arrayBuffer', {
value: () => Promise.resolve(Uint8Array.of(1, 2, 3).buffer),
})
await b.scopedSvc(sid('s1')).send('describe', 'queue', [file])
expect(b.sessionDoubles.get(sid('s1'))!.prompt).toHaveBeenCalledWith([
{ type: 'image', mediaType: 'image/png', data: 'AQID', name: 'pixel.png' },
{ type: 'text', text: 'describe' },
], 'queue')
})
it('rejects unsupported browser media before prompting the session', async () => {
const b = await bench()
const file = new File([Uint8Array.of(1)], 'clip.mp4', { type: 'video/mp4' })
Object.defineProperty(file, 'arrayBuffer', {
value: () => Promise.resolve(Uint8Array.of(1).buffer),
})
await expect(b.scopedSvc(sid('s1')).send('', 'queue', [file]))
.rejects.toThrow(/不支持的图片格式/)
expect(b.sessionDoubles.get(sid('s1'))?.prompt).not.toHaveBeenCalled()
})
it('cancel resolves on ok and throws the folded business error', async () => {
const b = await bench()
const s = b.scopedSvc(sid('s1'))
await s.cancel()
const double = b.sessionDoubles.get(sid('s1'))!
expect(double.cancel).toHaveBeenCalledTimes(1)
double.cancel.mockResolvedValue({ ok: false, error: { code: 'internal', message: 'nope' } })
await expect(s.cancel()).rejects.toThrow(/cancel failed: internal: nope/)
})
it('root-context send and cancel throw the addressing hint', async () => {
const b = await bench()
await expect(b.svc.send('x', 'queue')).rejects.toThrow(/requires a session scope/)
await expect(b.svc.cancel()).rejects.toThrow(/requires a session scope/)
})
})
describe('image admission and URL lifecycle', () => {
const description: NonNullable<ReturnType<SessionsService['hostDescription']>> = {
version: '0',
cwd: '/f',
attachedSessions: 0,
activeModel: {
provider: 'anthropic',
id: 'claude-opus-4-8',
name: 'Opus',
inputModalities: ['text', 'image'],
outputModalities: ['text'],
},
imageLimits: {
maxImageBytes: 3,
maxImagesPerMessage: 2,
maxMessageImageBytes: 4,
maxImagePixels: 100,
mediaTypes: ['image/png'],
},
}
it('preflights host limits before allocating previews and releases draft URLs', async () => {
const createObjectURL = vi.fn(() => 'blob:draft')
const revokeObjectURL = vi.fn()
vi.stubGlobal('URL', { createObjectURL, revokeObjectURL })
const b = await bench({ description })
const first = new File([Uint8Array.of(1, 2, 3)], 'first.png', { type: 'image/png' })
const second = new File([Uint8Array.of(4, 5)], 'second.png', { type: 'image/png' })
const attachments = b.svc.createDraftImages([first])
expect(attachments[0]).toMatchObject({
kind: 'image',
file: first,
previewUrl: 'blob:draft',
})
expect(() => b.svc.createDraftImages([second], attachments)).toThrow(/总大小/)
expect(createObjectURL).toHaveBeenCalledTimes(1)
b.svc.releaseDraftImages(attachments)
expect(revokeObjectURL).toHaveBeenCalledWith('blob:draft')
})
it('rejects unsupported model capability, media type, count, and per-image bytes', async () => {
const createObjectURL = vi.fn(() => 'blob:unexpected')
vi.stubGlobal('URL', { createObjectURL, revokeObjectURL: vi.fn() })
const textOnly = await bench({
description: {
...description,
activeModel: { ...description.activeModel!, inputModalities: ['text'] },
},
})
const png = new File([Uint8Array.of(1)], 'pixel.png', { type: 'image/png' })
expect(() => textOnly.svc.createDraftImages([png], [], true))
.toThrow(/当前模型不支持图片/)
const b = await bench({ description })
const video = new File([Uint8Array.of(1)], 'clip.mp4', { type: 'video/mp4' })
expect(() => b.svc.createDraftImages([video])).toThrow(/不支持的图片格式/)
const large = new File([Uint8Array.of(1, 2, 3, 4)], 'large.png', {
type: 'image/png',
})
expect(() => b.svc.createDraftImages([large])).toThrow(/单张大小限制/)
const existing = b.svc.createDraftImages([png, png])
expect(() => b.svc.createDraftImages([png], existing)).toThrow(/最多添加 2 张/)
expect(createObjectURL).toHaveBeenCalledTimes(2)
})
it('deduplicates historical loads and revokes their URLs when the session scope ends', async () => {
const createObjectURL = vi.fn()
.mockReturnValueOnce('blob:history-1')
.mockReturnValueOnce('blob:history-2')
const revokeObjectURL = vi.fn()
vi.stubGlobal('URL', { createObjectURL, revokeObjectURL })
const b = await bench()
const ref: ImageAttachmentRef = {
attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`),
mediaType: 'image/png',
bytes: 1,
width: 1,
height: 1,
}
b.sessionsFake.manager.get(sid('s1'))
const session = b.sessionDoubles.get(sid('s1'))!
session.readAttachment.mockResolvedValue({
ok: true,
value: { attachment: ref, data: [1] },
})
await expect(Promise.all([
b.svc.resolveImage(sid('s1'), ref),
b.svc.resolveImage(sid('s1'), ref),
])).resolves.toEqual(['blob:history-1', 'blob:history-1'])
expect(session.readAttachment).toHaveBeenCalledTimes(1)
b.svc.releaseSessionImages(sid('s1'))
await vi.waitFor(() => {
expect(revokeObjectURL).toHaveBeenCalledWith('blob:history-1')
})
await expect(b.svc.resolveImage(sid('s1'), ref)).resolves.toBe('blob:history-2')
expect(session.readAttachment).toHaveBeenCalledTimes(2)
})
it('revokes a historical URL whose load completes after its session scope was released', async () => {
const createObjectURL = vi.fn(() => 'blob:late')
const revokeObjectURL = vi.fn()
vi.stubGlobal('URL', { createObjectURL, revokeObjectURL })
const b = await bench()
const ref: ImageAttachmentRef = {
attachmentId: AttachmentId(`sha256:${'b'.repeat(64)}`),
mediaType: 'image/png',
bytes: 1,
width: 1,
height: 1,
}
const response = Promise.withResolvers<{
ok: true
value: { attachment: ImageAttachmentRef; data: number[] }
}>()
b.sessionsFake.manager.get(sid('s1'))
b.sessionDoubles.get(sid('s1'))!.readAttachment.mockReturnValue(response.promise)
const pending = b.svc.resolveImage(sid('s1'), ref)
b.svc.releaseSessionImages(sid('s1'))
response.resolve({ ok: true, value: { attachment: ref, data: [1] } })
await expect(pending).rejects.toThrow(/scope was released/)
expect(revokeObjectURL).toHaveBeenCalledWith('blob:late')
})
})
describe('startSession chain', () => {
it('creates, sends through the new scope, then navigates through sessions.open', async () => {
const b = await bench()
await b.svc.startSession({ cwd: '/proj', text: 'first', mode: 'queue' })
expect(b.createMock).toHaveBeenCalledWith({ cwd: '/proj' })
expect(b.openMock).toHaveBeenCalledWith(sid('new-1'))
const prompt = b.sessionDoubles.get(sid('new-1'))!.prompt
expect(prompt).toHaveBeenCalledWith([{ type: 'text', text: 'first' }], 'queue')
// Navigation is the publication point: it must not precede send acceptance.
expect(b.openMock.mock.invocationCallOrder[0]!).toBeGreaterThan(prompt.mock.invocationCallOrder[0]!)
})
it('does not navigate when the first send is rejected (empty state keeps the draft)', async () => {
const b = await bench()
const doomed = b.sessionsFake.manager.get(sid('new-1')) as unknown as SessionDouble
doomed.prompt.mockResolvedValue({ ok: false, error: { code: 'agent-busy', message: 'nope' } })
await expect(b.svc.startSession({ text: 'first', mode: 'queue' })).rejects.toThrow(/agent-busy/)
expect(b.openMock).not.toHaveBeenCalled()
})
it('omits cwd from create when not chosen', async () => {
const b = await bench()
await b.svc.startSession({ text: 't', mode: 'steer' })
expect(b.createMock).toHaveBeenCalledWith({})
})
it('fails loud when the created session resolves no scope', async () => {
const b = await bench()
;(b.sessionsFake.create as ReturnType<typeof vi.fn>).mockResolvedValue(sid('ghost'))
await expect(b.svc.startSession({ text: 't', mode: 'queue' })).rejects.toThrow(/resolved no scope/)
})
})
describe('service-unavailable loud failures', () => {
it('throws when sessions is missing', async () => {
const b = await bench({ sessions: false })
await expect(b.svc.startSession({ text: 't', mode: 'queue' })).rejects.toThrow(/sessions service unavailable/)
})
it('startSession fails loud when the new scope cannot resolve conversation', async () => {
const b = await bench()
// A scope minted outside the service tree: scoped.get('conversation') finds nothing.
const foreign = new Context()
const foreignScope = foreign.plugin(() => {}).ctx.extend({})
;(b.sessionsFake.scope as unknown) = () => foreignScope
await expect(b.svc.startSession({ text: 't', mode: 'queue' }))
.rejects.toThrow(/conversation service unavailable through the new scope/)
await expect(b.root.send('x', 'queue')).rejects.toThrow(/requires a session scope/)
const missing = await bench(false)
await expect(missing.root.send('x', 'queue')).rejects.toThrow(/sessions service unavailable/)
})
})

View File

@@ -1,338 +0,0 @@
// @vitest-environment jsdom
// Skeleton branch tails for the coverage gate (complements skeleton.spec.tsx
// acceptance flows), four-share props form: breadcrumb ancestry derivation +
// error strip in ConversationRoot, DetailsPanel non-JSON args / non-text
// result blocks / error-only results over the shared store, EmptyState
// failure surface and path-modal confirm with in-component cwd derivation.
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, waitFor } from '@testing-library/react'
import { hookOf } from './hook.ts'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { UseSession } from '@deepseek-ai/dsh-client-ui-slots'
import type { ConversationSnapshot, SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
import type { SelectionTarget, ViewTab } from '@deepseek-ai/dsh-client-ui-conversation/client'
// Export discipline: packages/client/AGENTS.md.
import { createChatStore } from '../src/client/stores.ts'
import { ConversationRoot, type ConversationRootProps } from '../src/client/skeleton/ConversationRoot.tsx'
import { DetailsPanel } from '../src/client/skeleton/DetailsPanel.tsx'
import { EmptyState } from '../src/client/skeleton/EmptyState.tsx'
afterEach(cleanup)
const SID = 's1' as SessionId
/** Fallback-only chain stub (no takeover registered in these benches). */
const fallbackRenderSlotChain: ConversationRootProps['renderSlotChain'] =
(_key, _owner, opts) => opts?.fallback ?? null
function snapshotBase(): ConversationSnapshot {
return {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [],
pending: [], running: false, removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, lastAgentError: null,
} as ConversationSnapshot
}
function sessionSource(over?: Partial<ConversationSnapshot>) {
const snap = { ...snapshotBase(), ...over }
return {
getSnapshot: () => snap,
subscribe: () => () => {},
}
}
/** Sessions-list stub over a snapshot store (the standard useSessions hook shape). */
function listHook(rows: { id: string; title: string; cwd?: string; parentId?: string }[]) {
const store = createSnapshotStore<SessionListState>({
ids: rows.map(r => r.id as SessionId),
byId: Object.fromEntries(rows.map(r => [r.id, {
id: r.id as SessionId, title: `durable ${r.title}`, displayTitle: r.title, running: false, updatedAt: 1,
...(r.cwd !== undefined ? { cwd: r.cwd } : {}),
...(r.parentId !== undefined ? { parentId: r.parentId as SessionId } : {}),
}])),
current: undefined,
} as SessionListState)
return hookOf(store)
}
describe('ConversationRoot branches', () => {
const chatTab: ViewTab = { id: 'chat', label: 'Chat' }
/** renderSlot stub in the outlet's baked shape (ring key + only filter marker). */
const stubRenderSlot = (() => <div data-testid="view-body" />) as unknown as ConversationRootProps['renderSlot']
/** SessionProvider seat stub (render-prop pass-through; ConversationRoot never invokes it). */
const SessionProviderStub: ConversationRootProps['SessionProvider'] = ({ children }) => <>{children(SID)}</>
function rootProps(over?: {
rows?: { id: string; title: string; parentId?: string }[]
snapshot?: Partial<ConversationSnapshot>
}) {
const open = vi.fn()
const chat = createChatStore().create()
const view = render(
<ConversationRoot
sessionId={SID}
useSession={hookOf(sessionSource(over?.snapshot)) as unknown as UseSession<ConversationSnapshot>}
useSessions={listHook(over?.rows ?? [])}
useStore={hookOf(chat)}
actions={chat.actions}
renderSlot={stubRenderSlot}
renderSlotChain={fallbackRenderSlotChain}
SessionProvider={SessionProviderStub}
views={{ list: () => [chatTab], subscribe: () => () => {}, version: () => 1 }}
addImages={vi.fn(() => null)}
removeImage={vi.fn()}
draftImages={() => []}
releaseSessionImages={vi.fn()}
send={vi.fn()}
stop={vi.fn()}
open={open}
/>,
)
return { view, open, chat }
}
it('derives the ancestry breadcrumb from the sessions list and navigates on ancestor click', () => {
const { view, open } = rootProps({
rows: [{ id: 'root-1', title: 'Workspace' }, { id: 's1', title: 'Current', parentId: 'root-1' }],
})
expect(view.getByText('Workspace')).toBeTruthy()
expect(view.getByText('/')).toBeTruthy()
fireEvent.click(view.getByText('Workspace'))
expect(open).toHaveBeenCalledWith('root-1' as SessionId)
// The last crumb is the current session: disabled, no navigation.
fireEvent.click(view.getByText('Current'))
expect(open).toHaveBeenCalledTimes(1)
})
it('a broken parent link stops the ancestry walk at the known chain', () => {
const { view } = rootProps({
rows: [{ id: 's1', title: 'Orphan', parentId: 'vanished' }],
})
// The walk keeps s1 itself and stops where the parent is unknown.
expect(view.getByText('Orphan')).toBeTruthy()
})
it('falls back to the raw session id without ancestry and counts user turns', () => {
const { view } = rootProps({
snapshot: { nodes: [{ kind: 'user', seq: 1 } as never, { kind: 'assistant', seq: 2 } as never] },
})
expect(view.getByText(SID)).toBeTruthy()
expect(view.getByText(/1 turns/)).toBeTruthy()
})
it('surfaces promptError through the composer error strip', () => {
const { view } = rootProps({
snapshot: { promptError: { op: 'stop', error: { message: 'halt', code: 'internal' } } as never },
})
expect(view.getByText(/停止失败:halt(internal)/)).toBeTruthy()
})
it('an unknown stored view id falls back to the first registered view', () => {
const { chat } = rootProps({})
cleanup()
chat.actions.setView('gone')
const view = render(
<ConversationRoot
sessionId={SID}
useSession={hookOf(sessionSource()) as unknown as UseSession<ConversationSnapshot>}
useSessions={listHook([])}
useStore={hookOf(chat)}
actions={chat.actions}
renderSlot={stubRenderSlot}
renderSlotChain={fallbackRenderSlotChain}
SessionProvider={SessionProviderStub}
views={{ list: () => [chatTab], subscribe: () => () => {}, version: () => 1 }}
addImages={vi.fn(() => null)}
removeImage={vi.fn()}
draftImages={() => []}
releaseSessionImages={vi.fn()}
send={vi.fn()}
stop={vi.fn()}
open={vi.fn()}
/>,
)
expect(view.getByTestId('view-body')).toBeTruthy()
})
})
describe('DetailsPanel branches', () => {
function panel(selection: SelectionTarget | null, snapshot?: Partial<ConversationSnapshot>) {
const chat = createChatStore().create()
if (selection !== null) chat.actions.select(selection)
return render(
<DetailsPanel
sessionId={SID}
useSession={hookOf(sessionSource(snapshot)) as unknown as UseSession<ConversationSnapshot>}
useSessions={listHook([])}
useStore={hookOf(chat)}
actions={chat.actions}
closeDetails={vi.fn()}
/>,
)
}
it('shows non-JSON args verbatim (streaming fragment path)', () => {
const view = panel({ turnSeq: 1, callId: 'c1', toolName: 'bash' }, {
runningCalls: [{ callId: 'c1', name: 'bash', argsRaw: '{"cmd": tru', turn: 1, step: 1, time: 1_000, callView: null }],
})
expect(view.getByText('{"cmd": tru')).toBeTruthy()
})
it('a selection without callId renders the empty hint (selector null arm)', () => {
const view = panel({ turnSeq: 2 })
expect(view.getByText(/点击消息流中的工具行查看详情/)).toBeTruthy()
})
it('snapshot updates re-run the material selector through the shallow equality arm', () => {
let snap = { ...snapshotBase(), runningCalls: [{ callId: 'c9', name: 'bash', argsRaw: '{"a":1}', turn: 1, step: 1, time: 1_000, callView: null }] } as ConversationSnapshot
const subs = new Set<() => void>()
const source = {
getSnapshot: () => snap,
subscribe: (fn: () => void) => {
subs.add(fn)
return () => subs.delete(fn)
},
}
const chat = createChatStore().create()
chat.actions.select({ turnSeq: 1, callId: 'c9' })
const view = render(
<DetailsPanel
sessionId={SID}
useSession={hookOf(source) as unknown as UseSession<ConversationSnapshot>}
useSessions={listHook([])}
useStore={hookOf(chat)}
actions={chat.actions}
closeDetails={vi.fn()}
/>,
)
expect(view.getByText(/"a": 1/)).toBeTruthy()
// Top-level swap with identical material members: the eq arm short-circuits.
snap = { ...snap }
for (const fn of [...subs]) fn()
expect(view.getByText(/"a": 1/)).toBeTruthy()
})
it('windowless call material: no name/args fallback to callId, mixed node walk skips non-matches', () => {
// A tool-result whose call head fell outside the window (call === null),
// preceded by non-matching nodes so the walk exercises both filter arms.
const view = panel({ turnSeq: 1, callId: 'c8' }, {
nodes: [
{ kind: 'user', seq: 1, content: [], source: null } as never,
{ kind: 'tool-result', seq: 2, callId: 'other', call: { name: 'x', argsRaw: '{}' }, content: [], isError: false, callView: null, resultView: null } as never,
{ kind: 'tool-result', seq: 3, callId: 'c8', call: null, content: [], isError: false, callView: null, resultView: null } as never,
],
})
expect(view.getByText('c8')).toBeTruthy()
})
it('stringifies non-text result blocks and renders error-only results', () => {
const withBlocks = panel({ turnSeq: 1, callId: 'c2' }, {
nodes: [{
kind: 'tool-result', seq: 3, callId: 'c2', call: { name: 'read', argsRaw: '{}' },
content: [{ type: 'image', data: 'x' } as never],
isError: false, callView: null, resultView: null,
} as never],
})
expect(withBlocks.getByText(/"type": "image"/)).toBeTruthy()
const errorOnly = panel({ turnSeq: 1, callId: 'c3' }, {
nodes: [{
kind: 'tool-result', seq: 4, callId: 'c3', call: { name: 'bash', argsRaw: '{}' },
content: [], isError: true, error: { name: 'ToolError', code: 'timeout' },
callView: null, resultView: null,
} as never],
})
expect(errorOnly.getByText(/ToolError: timeout/)).toBeTruthy()
})
})
describe('EmptyState branches', () => {
const noopCreate = () => Promise.resolve()
it('keeps the draft and surfaces a local error strip when startSession rejects', async () => {
const startSession = vi.fn(() => Promise.reject(new Error('create down')))
const view = render(
<EmptyState
useSessions={listHook([{ id: 'a', title: 'a', cwd: '/proj' }])}
createDraftImages={() => []}
releaseDraftImage={() => {}}
releaseDraftImages={() => {}}
startSession={startSession}
createWorkspaceSession={noopCreate}
/>,
)
const textarea = view.container.querySelector('textarea')!
fireEvent.change(textarea, { target: { value: 'first task' } })
fireEvent.keyDown(textarea, { key: 'Enter' })
await waitFor(() => expect(view.getByText(/发送失败:create down/)).toBeTruthy())
expect((textarea as HTMLTextAreaElement).value).toBe('first task')
})
it('non-Error rejection reasons stringify into the error strip', async () => {
const startSession = vi.fn(() => Promise.reject('plain-string'))
const view = render(
<EmptyState
useSessions={listHook([])}
createDraftImages={() => []}
releaseDraftImage={() => {}}
releaseDraftImages={() => {}}
startSession={startSession}
createWorkspaceSession={noopCreate}
/>,
)
const textarea = view.container.querySelector('textarea')!
fireEvent.change(textarea, { target: { value: 'go' } })
fireEvent.keyDown(textarea, { key: 'Enter' })
await waitFor(() => expect(view.getByText(/发送失败:plain-string/)).toBeTruthy())
})
it('cwd derivation skips blank cwds; menu picks, path modal confirms, submits the typed path', async () => {
const startSession = vi.fn(() => Promise.resolve())
const view = render(
<EmptyState
useSessions={listHook([
{ id: 'a', title: 'a', cwd: '/proj' },
{ id: 'b', title: 'b' }, // no cwd: filtered from the option set
])}
createDraftImages={() => []}
releaseDraftImage={() => {}}
releaseDraftImages={() => {}}
startSession={startSession}
createWorkspaceSession={noopCreate}
/>,
)
fireEvent.click(view.getByRole('button', { name: '项目目录' }))
expect([...view.getByRole('menu').querySelectorAll('[role="menuitem"]')].map(el => el.textContent))
.toEqual(['proj', 'New Workspace'])
fireEvent.click(view.getByRole('menuitem', { name: 'proj' }))
expect(view.getByRole('button', { name: '项目目录' }).textContent).toContain('proj')
fireEvent.click(view.getByRole('button', { name: '项目目录' }))
fireEvent.mouseEnter(view.getByRole('menuitem', { name: 'New Workspace' }).parentElement as HTMLElement)
fireEvent.click(view.getByRole('menuitem', { name: 'Use a existing folder' }))
const custom = view.getByLabelText('Folder path')
fireEvent.change(custom, { target: { value: '/typed/dir' } })
fireEvent.click(view.getByRole('button', { name: 'Open Folder' }))
const textarea = view.container.querySelector('textarea')!
fireEvent.change(textarea, { target: { value: 'task' } })
fireEvent.keyDown(textarea, { key: 'Enter' })
await waitFor(() => expect(startSession).toHaveBeenCalledWith({ text: 'task', mode: 'queue', cwd: '/typed/dir' }))
})
it('Create modal surfaces inject failures inline', async () => {
const createWorkspaceSession = vi.fn(() => Promise.reject(new Error('mkdir blocked')))
const view = render(
<EmptyState
useSessions={listHook([])}
createDraftImages={() => []}
releaseDraftImage={() => {}}
releaseDraftImages={() => {}}
startSession={() => Promise.resolve()}
createWorkspaceSession={createWorkspaceSession}
/>,
)
fireEvent.click(view.getByRole('button', { name: '项目目录' }))
fireEvent.mouseEnter(view.getByRole('menuitem', { name: 'New Workspace' }).parentElement as HTMLElement)
fireEvent.click(view.getByRole('menuitem', { name: 'Create new' }))
fireEvent.click(view.getByRole('button', { name: 'Create' }))
await waitFor(() => expect(view.getByRole('alert').textContent).toContain('mkdir blocked'))
})
})

View File

@@ -1,398 +1,212 @@
// @vitest-environment jsdom
/**
* Skeleton acceptance over the four-share props form: empty-state transition
* (same InputBar component in hero position, startSession submit, in-component
* cwd derivation), ConversationRoot view switching through the store's view
* field, DetailsPanel selection through the shared store. Components stay
* pure — the framework shares are stubbed (useSession/useSessions), the store
* share is a REAL createChatStore().create() instance (same construction path
* as production), injected callbacks are spies.
*/
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
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'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
import type { ConversationSnapshot, PendingInteraction, SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
import { PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
import type { SelectionTarget, ViewTab } from '@deepseek-ai/dsh-client-ui-conversation/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'
// Export discipline: packages/client/AGENTS.md.
import { createChatStore } from '../src/client/stores.ts'
import { ConversationRoot } from '../src/client/skeleton/ConversationRoot.tsx'
import { DetailsPanel } from '../src/client/skeleton/DetailsPanel.tsx'
import { EmptyState } from '../src/client/skeleton/EmptyState.tsx'
const sid = (s: string): SessionId => s as SessionId
afterEach(cleanup)
beforeEach(() => {
// jsdom normally provides localStorage; some host Node builds surface it as undefined.
globalThis.localStorage?.clear()
beforeEach(() => { localStorage.clear() })
const sid = (id: string) => id as SessionId
const wid = (id: string) => id as WorkspaceId
const SID = sid('s1')
function workspace(id = 'w1'): WorkspaceView {
return {
workspaceId: wid(id), path: `/projects/${id}`, title: id, sessionIds: [],
createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z',
}
}
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,
baselinesReady: true, recentWorkspaceId: undefined,
})
const hook = <T,>(snapshot: T) => <S,>(selector: (state: T) => S): S => selector(snapshot)
/** Minimal conversation snapshot slice the skeleton reads. */
interface FakeSnapshot {
nodes: readonly {
kind: string
seq?: number
time?: number
callId?: string
call?: { name: string; argsRaw: string } | null
callTime?: number | null
content?: readonly { type: string; text?: string }[]
isError?: boolean
callView?: null
resultView?: null
}[]
runningCalls: readonly {
callId: string
name: string
argsRaw: string
turn?: number
step?: number
time?: number
callView?: null
}[]
running: boolean
removed: boolean
promptError: { op: 'send' | 'stop'; error: { message: string; code: string } } | null
pending: readonly PendingInteraction[]
function mountEmpty(
intent: SessionIntent,
items: readonly WorkspaceView[] = [],
localWorkspace?: WorkspaceIntent,
) {
const updateSessionPrompt = vi.fn()
const sendSession = vi.fn(() => Promise.resolve())
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}
createDraftImages={() => []}
releaseDraftImage={() => {}}
releaseDraftImages={() => {}}
sendSession={sendSession}
startSession={startSession}
renderSlot={((_key: string, owner: unknown) => { pickerOwner = owner; return null }) as EmptyStateProps['renderSlot']}
/>,
)
return { view, updateSessionPrompt, sendSession, startSession, pickerOwner: () => pickerOwner }
}
function fakeSession(init: Partial<FakeSnapshot> = {}) {
const store = createSnapshotStore<FakeSnapshot>({
nodes: [], runningCalls: [], running: false, removed: false, promptError: null, pending: [], ...init,
})
return { store, useSession: bindSnapshotSelector(store) as unknown as UseSession<ConversationSnapshot> }
}
/** Sessions-list stub: the standard useSessions hook over a snapshot store. */
function fakeSessions(rows: { id: string; title: string; cwd?: string; parentId?: string }[]) {
const store = createSnapshotStore<SessionListState>({
ids: rows.map(r => sid(r.id)),
byId: Object.fromEntries(rows.map(r => [r.id, {
id: sid(r.id), title: `durable ${r.title}`, displayTitle: r.title, running: false, updatedAt: 1,
...(r.cwd !== undefined ? { cwd: r.cwd } : {}),
...(r.parentId !== undefined ? { parentId: sid(r.parentId) } : {}),
}])),
current: undefined,
} as SessionListState)
return { store, useSessions: bindSnapshotSelector(store) }
}
/** SessionProvider seat stub (render-prop pass-through; ConversationRoot never invokes it). */
const SessionProviderStub: ConversationRootProps['SessionProvider'] = ({ children }) => <>{children(sid('s1'))}</>
describe('EmptyState', () => {
const noopCreate = () => Promise.resolve()
/** Required draft-image lifecycle props for tests not exercising images. */
const noopImages = {
createDraftImages: () => [],
releaseDraftImage: () => {},
releaseDraftImages: () => {},
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 {
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,
}
}
it('derives cwd options from the sessions list, submits startSession, failure surfaces locally', async () => {
const { useSessions } = fakeSessions([
{ id: 'a', title: 'a', cwd: '/w/app' },
{ id: 'b', title: 'b', cwd: '/w/lib' },
{ id: 'c', title: 'c', cwd: '/w/app' }, // duplicate cwd dedupes
])
let reject!: (e: Error) => void
const startSession = vi.fn(() => new Promise<void>((_res, rej) => { reject = rej }))
render(
<EmptyState
useSessions={useSessions}
{...noopImages}
startSession={startSession}
createWorkspaceSession={noopCreate}
/>,
)
function mountConversation(pendingPrompt: ConversationSnapshot['pendingPrompt'] = null) {
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 },
},
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 chat = createChatStore().create()
chat.actions.setDraft('ordinary draft')
const send = vi.fn()
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 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),
useSessions: bindSnapshotSelector(sessions),
useWorkspaces: bindSnapshotSelector(workspaces),
useStore: bindSnapshotSelector(chat),
actions: chat.actions,
renderSlot,
renderSlotChain,
SessionProvider,
views: { list: () => [{ id: 'chat', label: 'Chat' }], subscribe: () => () => {}, version: () => 1 },
addImages: () => null,
removeImage: () => {},
draftImages: () => [],
releaseSessionImages: () => {},
send,
stop,
open,
updateSessionPrompt,
retrySessionPrompt,
}
const view = render(<ConversationRoot {...props} />)
return { view, chat, send, open, updateSessionPrompt, retrySessionPrompt }
}
const trigger = screen.getByRole('button', { name: '项目目录' })
fireEvent.click(trigger)
const menu = screen.getByRole('menu')
expect([...menu.querySelectorAll('[role="menuitem"]')].map(el => el.textContent))
.toEqual(['app', 'lib', 'New Workspace'])
fireEvent.click(screen.getByRole('menuitem', { name: 'app' }))
const box = screen.getByPlaceholderText('Message to run task, plan and build, enter for / commands')
fireEvent.change(box, { target: { value: '造一个轮子' } })
describe('ConversationRoot draft ownership', () => {
it('keeps ordinary per-Session composer text in the chat store and selects through runtime actions', () => {
const b = mountConversation()
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(startSession).toHaveBeenCalledWith({ text: '造一个轮子', mode: 'queue', cwd: '/w/app' })
reject(new Error('后端拒收'))
expect(await screen.findByText(/后端拒收/)).toBeTruthy()
// Draft survives the failure for retry.
expect((box as HTMLTextAreaElement).value).toBe('造一个轮子')
expect(b.send).toHaveBeenCalledWith('ordinary revised', [], 'queue')
fireEvent.click(b.view.getByRole('button', { name: 'Root' }))
expect(b.open).toHaveBeenCalledWith(sid('root'))
})
it('Use a existing folder opens the path modal and Open Folder sets the chip', () => {
const { useSessions } = fakeSessions([])
render(
<EmptyState
useSessions={useSessions}
{...noopImages}
startSession={() => Promise.resolve()}
createWorkspaceSession={noopCreate}
/>,
)
fireEvent.click(screen.getByRole('button', { name: '项目目录' }))
const newWs = screen.getByRole('menuitem', { name: 'New Workspace' })
fireEvent.mouseEnter(newWs.parentElement as HTMLElement)
fireEvent.click(screen.getByRole('menuitem', { name: 'Use a existing folder' }))
expect(screen.getByRole('dialog', { name: 'Enter an existing folder path' })).toBeTruthy()
const path = screen.getByLabelText('Folder path') as HTMLInputElement
fireEvent.change(path, { target: { value: '/tmp/fresh' } })
fireEvent.click(screen.getByRole('button', { name: 'Open Folder' }))
expect(screen.queryByRole('dialog')).toBeNull()
expect(screen.getByRole('button', { name: '项目目录' }).textContent).toContain('fresh')
})
it('Create new opens the modal and createWorkspaceSession succeeds', async () => {
const { useSessions } = fakeSessions([])
const createWorkspaceSession = vi.fn(() => Promise.resolve())
render(
<EmptyState
useSessions={useSessions}
{...noopImages}
startSession={() => Promise.resolve()}
createWorkspaceSession={createWorkspaceSession}
/>,
)
fireEvent.click(screen.getByRole('button', { name: '项目目录' }))
fireEvent.mouseEnter(screen.getByRole('menuitem', { name: 'New Workspace' }).parentElement as HTMLElement)
fireEvent.click(screen.getByRole('menuitem', { name: 'Create new' }))
expect(screen.getByRole('dialog', { name: 'Create new workspace' })).toBeTruthy()
const name = screen.getByLabelText('Workspace name') as HTMLInputElement
expect(name.value).toBe('New WorkSpace')
fireEvent.change(name, { target: { value: 'My Proj' } })
fireEvent.keyDown(name, { key: 'Enter' })
await vi.waitFor(() => expect(createWorkspaceSession).toHaveBeenCalledWith('My Proj'))
})
it('Create modal Cancel dismisses without calling createWorkspaceSession', () => {
const { useSessions } = fakeSessions([])
const createWorkspaceSession = vi.fn(() => Promise.resolve())
render(
<EmptyState
useSessions={useSessions}
{...noopImages}
startSession={() => Promise.resolve()}
createWorkspaceSession={createWorkspaceSession}
/>,
)
fireEvent.click(screen.getByRole('button', { name: '项目目录' }))
fireEvent.mouseEnter(screen.getByRole('menuitem', { name: 'New Workspace' }).parentElement as HTMLElement)
fireEvent.click(screen.getByRole('menuitem', { name: 'Create new' }))
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
expect(screen.queryByRole('dialog')).toBeNull()
expect(createWorkspaceSession).not.toHaveBeenCalled()
})
it('routes empty-state draft image creation and release through the injected lifecycle', () => {
const { useSessions } = fakeSessions([])
const file = new File([Uint8Array.of(1)], 'pixel.png', { type: 'image/png' })
const attachment = {
kind: 'image' as const,
id: 'draft-1',
file,
previewUrl: 'blob:draft-1',
}
const createDraftImages = vi.fn()
.mockReturnValueOnce([attachment])
.mockImplementationOnce(() => { throw new Error('图片过大') })
const releaseDraftImage = vi.fn()
const releaseDraftImages = vi.fn()
const view = render(
<EmptyState
useSessions={useSessions}
createDraftImages={createDraftImages}
releaseDraftImage={releaseDraftImage}
releaseDraftImages={releaseDraftImages}
startSession={() => Promise.resolve()}
createWorkspaceSession={noopCreate}
/>,
)
const textarea = view.container.querySelector('textarea')!
const clipboardData = {
items: [{ kind: 'file', type: 'image/png', getAsFile: () => file }],
getData: () => '',
}
fireEvent.paste(textarea, { clipboardData })
expect(createDraftImages).toHaveBeenCalledWith([file], [])
fireEvent.click(view.getByRole('button', { name: '移除图片 pixel.png' }))
expect(releaseDraftImage).toHaveBeenCalledWith('draft-1')
fireEvent.paste(textarea, { clipboardData })
expect(view.getByText('图片过大')).toBeTruthy()
view.unmount()
expect(releaseDraftImages).toHaveBeenCalledWith([])
})
})
describe('ConversationRoot', () => {
function bench(
tabs: ViewTab[], activeView?: string, init: Partial<FakeSnapshot> = {},
renderSlotChain?: ConversationRootProps['renderSlotChain'],
) {
const { useSession } = fakeSession({ nodes: [{ kind: 'user' }, { kind: 'user' }], ...init })
const { useSessions } = fakeSessions([
{ id: 'root', title: 'proj' },
{ id: 's1', title: 'child', parentId: 'root' },
])
const chat = createChatStore().create()
if (activeView !== undefined) chat.actions.setView(activeView)
const send = vi.fn()
const stop = vi.fn()
const open = vi.fn()
// The renderSlot share as the outlet would bake it: renders a marker for
// the ring key carrying the active-id filter (a Mock cannot satisfy the
// generic method type directly — cast once at the prop seam).
const renderSlot = vi.fn((key: string, _owner: object, opts?: { only?: string }) => (
<div data-testid={`view-${opts?.only ?? '(all)'}`} data-slot={key} />
))
const ui = render(
<ConversationRoot
sessionId={sid('s1')}
useSession={useSession}
useSessions={useSessions}
useStore={bindSnapshotSelector(chat)}
actions={chat.actions}
renderSlot={renderSlot as unknown as ConversationRootProps['renderSlot']}
renderSlotChain={renderSlotChain ?? ((_key, _owner, opts) => opts?.fallback ?? null)}
SessionProvider={SessionProviderStub}
views={{
list: () => tabs,
subscribe: () => () => {},
version: () => 1,
}}
addImages={vi.fn(() => null)}
removeImage={vi.fn()}
draftImages={() => []}
releaseSessionImages={vi.fn()}
send={send}
stop={stop}
open={open}
/>)
return { ui, chat, send, stop, open, renderSlot }
}
const tab = (id: string, label: string): ViewTab => ({ id, label })
it('renders breadcrumb chain (useSessions-derived), meta turns, and the default chat view', () => {
const { open } = bench([tab('chat', 'Chat'), tab('trajectory', 'Trajectory')])
expect(screen.getByText('proj')).toBeTruthy()
expect(screen.getByText('child')).toBeTruthy()
expect(screen.getByText(/2 turns/)).toBeTruthy()
expect(screen.getByTestId('view-chat')).toBeTruthy()
// Ancestor crumb navigates; current crumb is disabled.
fireEvent.click(screen.getByRole('button', { name: 'proj' }))
expect(open).toHaveBeenCalledWith('root')
expect((screen.getByRole('button', { name: 'child' }) as HTMLButtonElement).disabled).toBe(true)
})
it('switches views through the store view field and falls back on unknown ids', () => {
const { chat } = bench([tab('chat', 'Chat'), tab('trajectory', 'Trajectory')])
fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
expect(chat.store.getSnapshot().view).toBe('trajectory')
expect(screen.getByTestId('view-trajectory')).toBeTruthy()
cleanup()
// A stale persisted id (its view plugin unloaded) falls to the first view.
bench([tab('chat', 'Chat'), tab('trajectory', 'Trajectory')], 'ghost-view')
expect(screen.getByTestId('view-chat')).toBeTruthy()
})
it('renders the active view through the declared ring slot with the only filter', () => {
const { renderSlot } = bench([tab('chat', 'Chat')])
// No owner share: views take everything from the standard kit (contract).
expect(renderSlot).toHaveBeenCalledWith('conversation.view', {}, { only: 'chat' })
expect(screen.getByTestId('view-chat').getAttribute('data-slot')).toBe('conversation.view')
})
it('hides the tab strip with a single view; composer writes the store draft and sends it', () => {
const { chat, send } = bench([tab('chat', 'Chat')])
expect(screen.queryByRole('tablist')).toBeNull()
const box = screen.getByPlaceholderText(/输入消息/)
fireEvent.change(box, { target: { value: 'hi' } })
// Typing goes through actions.setDraft into the shared store.
expect(chat.store.getSnapshot().draft).toBe('hi')
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',
})
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(send).toHaveBeenCalledWith('hi', [], 'queue')
})
it('dispatches the pending list to the composer chain; all-decline falls back to InputBar', () => {
const wait = new PendingWait('question', RpcId('rq'), sid('s1'),
{ questions: [{ id: 'mode', question: 'Choose?', options: [{ label: 'Fast' }] }] } as PendingWait<'question'>['payload'], vi.fn())
// A matching entry takes the composer over.
const renderSlotChain = vi.fn(() => <div>question takeover</div>) as unknown as ConversationRootProps['renderSlotChain']
bench([tab('chat', 'Chat')], undefined, { pending: [wait] }, renderSlotChain)
expect(screen.getByText('question takeover')).toBeTruthy()
expect(screen.queryByPlaceholderText(/输入消息/)).toBeNull()
// The owner dispatches the raw pending list (chain currency); routing
// lives in entry selectors, not here.
expect(renderSlotChain).toHaveBeenCalledWith(
'conversation.composer',
expect.objectContaining({
interactions: expect.arrayContaining([expect.objectContaining({ key: 'q:rq' })]),
}),
expect.objectContaining({ fallback: expect.anything() }),
)
cleanup()
// Zero registered entries (default all-decline stub): the fallback IS the
// default InputBar — behavior equals the pre-chain composer.
bench([tab('chat', 'Chat')], undefined, { pending: [wait] })
expect(screen.getByPlaceholderText(/输入消息/)).toBeTruthy()
})
})
describe('DetailsPanel', () => {
function benchDetails(snapshot: Partial<FakeSnapshot>, selection: SelectionTarget | null) {
const { useSession } = fakeSession(snapshot)
const { useSessions } = fakeSessions([])
const chat = createChatStore().create()
if (selection !== null) chat.actions.select(selection)
const closeDetails = vi.fn()
render(
<DetailsPanel
sessionId={sid('s1')}
useSession={useSession}
useSessions={useSessions}
useStore={bindSnapshotSelector(chat)}
actions={chat.actions}
closeDetails={closeDetails}
/>)
return { closeDetails, chat }
}
it('renders the selected call args and result off the shared store; close fires the injected callback', () => {
const { closeDetails } = benchDetails({
nodes: [{
kind: 'tool-result', seq: 1, time: 1_000, callId: 'c1',
call: { name: 'bash', argsRaw: '{"cmd":"ls"}' },
callTime: 500,
content: [{ type: 'text', text: 'file-a\nfile-b' }],
isError: false, callView: null, resultView: null,
}],
}, { turnSeq: 1, callId: 'c1' })
expect(screen.getByText('bash')).toBeTruthy()
expect(screen.getByText(/"cmd": "ls"/)).toBeTruthy()
expect(screen.getByText(/file-a/)).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: '关闭详情' }))
expect(closeDetails).toHaveBeenCalledTimes(1)
})
it('shows the empty hint without a selection and the running state for open calls', () => {
benchDetails({ runningCalls: [{ callId: 'c9', name: 'bash', argsRaw: '{}', turn: 1, step: 1, time: 1_000, callView: null }] }, null)
expect(screen.getByText(/点击消息流中的工具行/)).toBeTruthy()
cleanup()
benchDetails({ runningCalls: [{ callId: 'c9', name: 'bash', argsRaw: '{}', turn: 1, step: 1, time: 1_000, callView: null }] }, { turnSeq: 1, callId: 'c9' })
expect(screen.getByText('运行中…')).toBeTruthy()
})
it('reports an out-of-window call distinctly', () => {
benchDetails({}, { turnSeq: 1, callId: 'ghost' })
expect(screen.getByText(/不在当前窗口内/)).toBeTruthy()
expect(b.retrySessionPrompt).toHaveBeenCalledOnce()
expect(b.send).not.toHaveBeenCalled()
})
})