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

# Conflicts:
#	.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml
#	docs/architecture.i18n.yaml
#	docs/core-data-structures/core.i18n.yaml
#	packages/client/runtime/README.i18n.yaml
#	packages/client/ui-conversation/src/client/apply.ts
#	packages/client/ui-conversation/src/client/chat/ChatView.tsx
#	packages/client/ui-conversation/src/client/skeleton/InputBar.tsx
#	packages/client/ui-conversation/tests/input-bar.spec.tsx
#	packages/client/ui-conversation/tests/input-matrix.spec.tsx
#	packages/client/ui-conversation/tests/input-scenarios.spec.tsx
#	packages/client/ui-conversation/tests/skeleton.spec.tsx
This commit is contained in:
Yichen Jiang
2026-07-28 16:07:58 +08:00
243 changed files with 3167 additions and 1684 deletions
@@ -87,12 +87,13 @@ async function bench() {
}
}
const providers: TestProvider[] = []
const absentInfo = { sessionId: undefined, hooks: {}, props: {} }
const sessionsFake = {
list: listStore,
binding: (id: SessionId) => ({ sessionId: id, session: sessionFake, ctx: mint(id) }),
scope: (id: SessionId) => mint(id),
provideInfo: () => undefined,
maybeProvideInfo: () => ({ hooks: {}, props: {} }),
currentProvideInfo: { getSnapshot: () => absentInfo, subscribe: () => () => {} },
provide: (descriptor: TestProvider) => { providers.push(descriptor); return () => {} },
scopeOf,
sessionOf: (actx: Context) => (scopeOf(actx) === undefined ? undefined : sessionFake),
@@ -32,12 +32,13 @@ async function bench() {
current: undefined,
phase: 'ready',
})
const absentInfo = { sessionId: undefined, hooks: {}, props: {} }
const sessionsFake = {
list: listStore,
binding: vi.fn(),
scope: () => undefined,
provideInfo: () => undefined,
maybeProvideInfo: () => ({ hooks: {}, props: {} }),
currentProvideInfo: { getSnapshot: () => absentInfo, subscribe: () => () => {} },
provide: vi.fn(() => () => {}),
create: vi.fn(),
open: vi.fn(),
@@ -87,6 +87,9 @@ async function bench(snapshot: ConversationSnapshot) {
// 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: {} }
// Identity-stable currentProvideInfo snapshot (uSES getSnapshot contract),
// materialized on first render after the provide contributions landed.
let infoCell: { sessionId: SessionId; hooks: Record<string, unknown>; props: Record<string, unknown> } | undefined
const sessionsFake = {
list,
binding: (id: SessionId) => (id === SID
@@ -103,9 +106,10 @@ async function bench(snapshot: ConversationSnapshot) {
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 }),
currentProvideInfo: {
getSnapshot: () => infoCell ??= { sessionId: SID, hooks: { session, ...provided.hooks }, props: provided.props },
subscribe: () => () => {},
},
create: vi.fn(),
open: vi.fn(),
}
@@ -251,7 +255,7 @@ describe('run_code sub-calls through the real chat machinery', () => {
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.
// same data-state chrome (row sweep) a native in-flight row wears.
const nested = view.container.querySelector('[data-subcalls] [data-variant][data-state="running"]')
expect(nested).not.toBeNull()
})
@@ -64,6 +64,16 @@ describe('tool-call-model', () => {
expect(toolRowModel('', running({ argsRaw: '' })).summary).toBe('c1')
})
it('displays workspace-rooted paths relative to the session cwd', () => {
const cwd = '/Users/u/ws/'
expect(toolRowModel('edit', running({ name: 'edit', argsRaw: '{"file_path":"/Users/u/ws/src/x.ts"}' }), cwd).summary).toBe('src/x.ts')
expect(toolRowModel('read', running({ name: 'read', argsRaw: '{"path":"/Users/u/ws/a.md"}' }), cwd).summary).toBe('a.md')
// Paths outside the workspace (and non-path summaries) stay verbatim.
expect(toolRowModel('read', running({ name: 'read', argsRaw: '{"path":"/etc/hosts"}' }), cwd).summary).toBe('/etc/hosts')
expect(toolRowModel('bash', running({ argsRaw: '{"command":"pwd"}' }), cwd).summary).toBe('pwd')
expect(toolRowModel('read', running({ name: 'read', argsRaw: '{"path":"/Users/u/ws/a.md"}' }), '').summary).toBe('/Users/u/ws/a.md')
})
it('body pretty-prints JSON args, keeps raw non-JSON, null when empty', () => {
expect(toolRowModel('bash', running({ argsRaw: '{"a":1}' })).body).toBe('{\n "a": 1\n}')
expect(toolRowModel('bash', running({ argsRaw: 'raw' })).body).toBe('raw')
@@ -125,12 +135,12 @@ describe('ToolRow', () => {
expect(view.getByText('List files')).toBeTruthy()
})
it('running and error states replace the icon with a StateDot', () => {
it('running keeps the icon (row sweep carries the signal); error swaps in a StateDot', () => {
const runningView = render(<ToolRow {...rowProps} state="running" />)
expect(runningView.queryByTestId('tool-icon')).toBeNull()
expect(runningView.queryByTestId('tool-icon')).not.toBeNull()
expect(runningView.container.querySelector('[data-state="running"]')).not.toBeNull()
const errorView = render(<ToolRow {...rowProps} state="error" />)
expect(errorView.queryByTestId('tool-icon')).toBeNull()
expect(errorView.container.querySelector('[data-testid="tool-icon"]')).toBeNull()
})
it('non-expandable rows render a passive leading slot', () => {
@@ -24,6 +24,9 @@ import type { ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/clien
const SID = 's1' as SessionId
/** Identity-stable no-session bundle (uSES getSnapshot contract). */
const ABSENT_INFO = { sessionId: undefined, hooks: {}, props: {} }
afterEach(cleanup)
// The chat store persists under its declared key; clear between cases.
beforeEach(() => {
@@ -89,30 +92,28 @@ async function bench(nodes: ToolResultNode[]) {
subscribe: (fn: () => void) => session.subscribe(fn),
},
})
const 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
}
ctx.provide('sessions', {
list,
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) {
// `this` inside an object-literal method is any under strict lint; the
// fake resolves through its own provideInfo above.
/* eslint-disable-next-line @typescript-eslint/no-unsafe-return,
@typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */
return (id === undefined ? undefined : this.provideInfo(id)) ?? { hooks: {}, props: {} }
provideInfo,
currentProvideInfo: {
getSnapshot: () => provideInfo(SID),
subscribe: () => () => {},
},
provide: (d: { resolve: (typeof providers)[number] }) => { providers.push(d.resolve); return () => {} },
scopeOf: () => SID,
@@ -254,7 +255,10 @@ describe('registrant load-order seam', () => {
binding: () => undefined,
scope: () => undefined,
provideInfo: () => undefined,
maybeProvideInfo: () => ({ hooks: {}, props: {} }),
currentProvideInfo: {
getSnapshot: () => ABSENT_INFO,
subscribe: () => () => {},
},
provide: () => () => {},
create: vi.fn(),
open: vi.fn(),
@@ -138,6 +138,22 @@ describe('chat-flow derivation', () => {
expect(flowKeys(items)).toBe('n1|n2|g3|n5|g6')
expect(flowKeys(deriveChatFlow([...nodes, toolResult(7, 'd')]))).toBe('n1|n2|g3|n5|g6')
})
it('skips render-nothing assistant nodes so tool runs stay one group', () => {
// A tool-call-only step message (and blank text/reasoning) renders nothing:
// it must not split the run into two groups with an empty line between.
const headsOnly: AssistantMessageNode = {
kind: 'assistant', seq: 4, time: 4_000, turn: 1, step: 2,
blocks: [{ kind: 'tool-call', callId: 'b', name: 'read', argsRaw: '{}' }, { kind: 'text', text: ' \n' }, { kind: 'reasoning', text: '' }],
}
const items = deriveChatFlow([toolResult(3, 'a'), headsOnly, toolResult(5, 'b')])
expect(flowKeys(items)).toBe('g3')
const group = items[0]!
expect(group.kind === 'tool-group' && group.results.map(r => r.callId)).toEqual(['a', 'b'])
// Interrupted and visible-content nodes still render (已停止 marker / prose).
expect(flowKeys(deriveChatFlow([toolResult(3, 'a'), { ...headsOnly, interrupted: true }, toolResult(5, 'b')]))).toBe('g3|n4|g5')
expect(flowKeys(deriveChatFlow([toolResult(3, 'a'), assistant(4, 'found'), toolResult(5, 'b')]))).toBe('g3|n4|g5')
})
})
describe('ChatView', () => {
@@ -90,7 +90,7 @@ describe('tails', () => {
expect(view.container.querySelector('[data-state="ok"]')).not.toBeNull()
})
it('BashRow shows StateDot chrome for running/error/stopped (root session arm)', () => {
it('BashRow carries data-state for running (row sweep) and StateDots for error/stopped (root session arm)', () => {
const sid = 'root-1' as SessionId
const list = createSnapshotStore<SessionListState>({
ids: [sid],
@@ -59,7 +59,11 @@ function bench(over?: BenchOptions) {
// 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']> }
? {
slash: (() => ({
lexicon: { getSnapshot: () => lex, subscribe: () => () => {} },
})) as unknown as NonNullable<ShellDeps['slash']>,
}
: {}),
})
if (over?.draft !== undefined && over.draft !== '') shell.setDraft(over.draft)
@@ -98,6 +102,8 @@ function bench(over?: BenchOptions) {
const attachment = over?.attachments?.find(candidate => candidate.id === id)
return attachment === undefined ? [] : [attachment]
}),
useNotices: bindSnapshotSelector(shell.notices),
useLexicon: bindSnapshotSelector(shell.lexicon),
stop,
renderSlot,
variant: over?.variant ?? 'composer',
@@ -45,6 +45,8 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled
addImages: () => null,
removeImage: () => {},
draftImages: () => [],
useNotices: bindSnapshotSelector(shell.notices),
useLexicon: bindSnapshotSelector(shell.lexicon),
renderSlot: (() => null) as InputBarProps['renderSlot'],
stop: vi.fn(),
variant: 'composer',
@@ -131,6 +131,8 @@ async function scopedBench(register?: (slash: SlashService) => void) {
addImages: () => null,
removeImage: () => {},
draftImages: () => [],
useNotices: bindSnapshotSelector(shell.notices),
useLexicon: bindSnapshotSelector(shell.lexicon),
renderSlot: (() => null) as InputBarProps['renderSlot'],
stop: vi.fn(),
variant: 'composer',
@@ -237,6 +239,35 @@ describe('scenario H: backspace breaks the token', () => {
})
})
describe('scenario: reference decoration lights up when the lexicon settles', () => {
it('a typed /name token gains the text-ref mark without further input once the roll goes hot', async () => {
let roll: readonly string[] | undefined
let notify: (() => void) | undefined
const b = await scopedBench((slash) => {
slash.registerSource({
trigger: '/', name: 'skill',
candidates: () => Promise.resolve([]),
onPick: () => undefined,
lexicon: () => roll,
subscribeLexicon: (_session: ClientSessionContext, listener: () => void) => {
notify = listener
return () => { notify = undefined }
},
} as never)
})
// Typed before the catalog settled: a plain token, no decoration.
b.type('/deploy now')
expect(b.view.container.querySelector('[data-decoration="text-ref"]')).toBeNull()
// The catalog settles (ui-skill's settle path fires the same notification).
act(() => {
roll = ['deploy']
notify?.()
})
const mark = b.view.container.querySelector('[data-decoration="text-ref"]')
expect(mark?.textContent).toBe('/deploy')
})
})
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()
@@ -11,6 +11,9 @@ import { createChatStore } from '../src/client/stores.ts'
const sid = (s: string): SessionId => s as SessionId
/** Identity-stable no-session bundle (uSES getSnapshot contract). */
const ABSENT_INFO = { sessionId: undefined, hooks: {}, props: {} }
interface Bench {
slots: SlotsService
chat: ReturnType<typeof createChatStore>
@@ -23,7 +26,10 @@ function bench(): Bench {
ids: [], byId: {}, current: undefined, phase: 'ready',
}),
provideInfo: () => undefined,
maybeProvideInfo: () => ({ hooks: {}, props: {} }),
currentProvideInfo: {
getSnapshot: () => ABSENT_INFO,
subscribe: () => () => {},
},
provide: () => () => {},
})
ctx.provide('workspaces', {
@@ -122,6 +122,8 @@ function mount(
addImages={() => null}
removeImage={() => {}}
draftImages={() => []}
useNotices={bindSnapshotSelector(wiring.notices)}
useLexicon={bindSnapshotSelector(wiring.lexicon)}
stop={stop}
renderSlot={(() => null) as InputBarProps['renderSlot']}
{...bar}