Merge remote-tracking branch 'origin/master' into worktree-guifork
This commit is contained in:
@@ -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(),
|
||||
|
||||
@@ -132,6 +132,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],
|
||||
|
||||
@@ -56,7 +56,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)
|
||||
@@ -88,6 +92,8 @@ function bench(over?: BenchOptions) {
|
||||
useInput: bindSnapshotSelector(shell.state),
|
||||
inputActions: shell.actions,
|
||||
keyboard: shell,
|
||||
useNotices: bindSnapshotSelector(shell.notices),
|
||||
useLexicon: bindSnapshotSelector(shell.lexicon),
|
||||
stop,
|
||||
renderSlot,
|
||||
variant: over?.variant ?? 'composer',
|
||||
|
||||
@@ -43,6 +43,8 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled
|
||||
useInput: bindSnapshotSelector(shell.state),
|
||||
inputActions: shell.actions,
|
||||
keyboard: shell,
|
||||
useNotices: bindSnapshotSelector(shell.notices),
|
||||
useLexicon: bindSnapshotSelector(shell.lexicon),
|
||||
renderSlot: (() => null) as InputBarProps['renderSlot'],
|
||||
stop: vi.fn(),
|
||||
variant: 'composer',
|
||||
|
||||
@@ -129,6 +129,8 @@ async function scopedBench(register?: (slash: SlashService) => void) {
|
||||
useInput: bindSnapshotSelector(shell.state),
|
||||
inputActions: shell.actions,
|
||||
keyboard: shell,
|
||||
useNotices: bindSnapshotSelector(shell.notices),
|
||||
useLexicon: bindSnapshotSelector(shell.lexicon),
|
||||
renderSlot: (() => null) as InputBarProps['renderSlot'],
|
||||
stop: vi.fn(),
|
||||
variant: 'composer',
|
||||
@@ -235,6 +237,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', {
|
||||
|
||||
@@ -120,6 +120,8 @@ function mount(
|
||||
useInput={useInput}
|
||||
inputActions={inputActions}
|
||||
keyboard={wiring}
|
||||
useNotices={bindSnapshotSelector(wiring.notices)}
|
||||
useLexicon={bindSnapshotSelector(wiring.lexicon)}
|
||||
stop={stop}
|
||||
renderSlot={(() => null) as InputBarProps['renderSlot']}
|
||||
{...bar}
|
||||
|
||||
Reference in New Issue
Block a user