Files
deepseek-harness/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts
T
creatixchu b19d1643f7 fix(web): harden the composer submit transaction
- wire the directory pick's continue flag through InsertTextRequest to the
  input shell, which re-tracks at the caret so descent reopens completion
  (the flag was produced and forwarded but never consumed)
- guard the image-only send against a second Enter during the Host round-trip
- keep text appended after the sent snapshot when a submit settles ok; only
  interleaved edits clear with the committed content
- drop the dead restoreImages left from the sink rewrite
- read recall labels through a shared defensive sessionRecallLabels helper
  instead of unchecked casts over durable log data, and take the reference
  summary separator from the locale dictionary
- align the composer specs merged from master with the transactional submit
  contract (sinks resolve SubmitOutcome, settlement is awaited, call
  assertions carry the AbortSignal), and restore master's forms where the
  divergence served nothing (queue-mode sink case, single-line filter
  expectation, component-identity slot lookup)
2026-08-17 18:35:45 +08:00

141 lines
6.1 KiB
TypeScript

/** ui-subagent browser half: catalog actions and read-only composer routing. */
import { Context } from '@deepseek-ai/cordis'
import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
import { describe, expect, it } from 'vitest'
import {
SlotRegistry, type ConversationSnapshot, type SessionId, type SessionListState,
type SessionSummary, type SubagentAddress,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { ComposerChainProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { apply as applyLocale, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client'
import {
SubagentCatalogAction, type SubagentCatalogInjected,
} from '../src/client/SubagentCatalogAction.tsx'
import {
SubagentReadOnlyComposer, type SubagentReadOnlyMatch,
} from '../src/client/SubagentReadOnlyComposer.tsx'
import { apply, inject } from '../src/client/index.ts'
function summary(partial: Partial<SessionSummary> & { id: SessionId }): SessionSummary {
return {
displayTitle: partial.id,
running: false,
updatedAt: 0,
...partial,
} as SessionSummary
}
const sid = (id: string) => id as SessionId
/** Fake root sessions face for catalog actions. */
function sessionsWith(sessions: SessionSummary[]) {
const byId: Record<string, SessionSummary> = {}
for (const s of sessions) byId[s.id] = s
const snapshot = { ids: sessions.map(s => s.id), byId, current: undefined } as unknown as SessionListState
const actionCalls: { method: string; args: unknown[] }[] = []
return {
list: {
getSnapshot: () => snapshot,
subscribe: () => () => {},
},
actionCalls,
openSubagent: (address: SubagentAddress) => {
actionCalls.push({ method: 'openSubagent', args: [address] })
},
refreshSubagents: (parentSessionId: SessionId) => {
actionCalls.push({ method: 'refreshSubagents', args: [parentSessionId] })
return Promise.resolve()
},
setSubagentCatalogOpen: (parentSessionId: SessionId, open: boolean) => {
actionCalls.push({ method: 'setSubagentCatalogOpen', args: [parentSessionId, open] })
},
}
}
async function provideSlotFaces(ctx: Context): Promise<void> {
await ctx.plugin(SlotRegistry).await()
ctx.slots.register({
name: 'root',
children: {
'conversation.session.header.actions': { kind: 'list', scope: 'session' },
'conversation.composer': { kind: 'chain', scope: 'session' },
},
} as never, () => null)
}
/** Boot the plugin over fake sessions and slot faces. */
async function fullBench(sessions: SessionSummary[]) {
const ctx = new Context()
const face = sessionsWith(sessions)
ctx.provide('sessions', face)
ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
ctx.provide('remote', { $on: () => () => {} } as never)
ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
await provideSlotFaces(ctx)
await ctx.plugin({ inject: localeInject, apply: applyLocale }).await()
await ctx.plugin({ inject: [...inject], apply }).await()
return { face, ctx }
}
const FAMILY: SessionSummary[] = [
summary({ id: sid('parent'), displayTitle: 'parent', running: true }),
summary({ id: sid('c1'), parentId: sid('parent'), displayTitle: 'worker-1', running: true }),
summary({ id: sid('c2'), parentId: sid('parent'), displayTitle: 'worker-2', running: true }),
// Filtered out: not running / other parent / label miss.
summary({ id: sid('c3'), parentId: sid('parent'), displayTitle: 'worker-3', running: false }),
summary({ id: sid('c4'), parentId: sid('other'), displayTitle: 'worker-4', running: true }),
summary({ id: sid('c5'), parentId: sid('parent'), displayTitle: 'scout', running: true }),
]
describe('apply', () => {
it('declares the services it binds', () => {
expect(inject).toEqual(['sessions', 'slots', 'locale'])
})
it('registers catalog actions and selects read-only subagent composers from session facts', async () => {
const { ctx, face } = await fullBench(FAMILY)
const catalogEntry = ctx.slots.entries('conversation.session.header.actions')
.find(entry => entry.component === SubagentCatalogAction)!
const actions = (catalogEntry.inject as unknown as (id: SessionId) => SubagentCatalogInjected)(sid('parent'))
const address: SubagentAddress = {
parentSessionId: sid('parent'),
childSessionId: sid('c1'),
mode: 'continuable',
}
actions.openChild(address)
actions.refresh(sid('parent'))
actions.setCatalogOpen(sid('parent'), true)
expect(face.actionCalls).toEqual([
{ method: 'openSubagent', args: [address] },
{ method: 'refreshSubagents', args: [sid('parent')] },
{ method: 'setSubagentCatalogOpen', args: [sid('parent'), true] },
])
const composerEntry = ctx.slots.entries('conversation.composer')
.find(entry => entry.component === SubagentReadOnlyComposer)!
const select = composerEntry.select as (owner: ComposerChainProps) => SubagentReadOnlyMatch | null
const owner = (
subagent: ConversationSnapshot['subagent'] | undefined,
running = false,
): ComposerChainProps => ({
interactions: [],
session: subagent === undefined
? undefined
: ({ subagent, running } as unknown as ConversationSnapshot),
})
expect(select(owner(undefined))).toBeNull()
expect(select(owner(null))).toBeNull()
expect(select(owner({ address: { ...address, mode: 'one-shot' }, parentAvailable: true })))
.toEqual({ reason: 'one-shot' })
// One-shot stays read-only even while running: it has no stop action.
expect(select(owner({ address: { ...address, mode: 'one-shot' }, parentAvailable: true }, true)))
.toEqual({ reason: 'one-shot' })
expect(select(owner({ address, parentAvailable: true }))).toBeNull()
expect(select(owner({ address, parentAvailable: false })))
.toEqual({ reason: 'parent-unavailable' })
// A RUNNING parent-offline continuable yields the default composer, whose
// disabled input still carries the primary Stop; stopped, it takes back over.
expect(select(owner({ address, parentAvailable: false }, true))).toBeNull()
})
})