mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-09-09 04:02:35 +00:00
- 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)
141 lines
6.1 KiB
TypeScript
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()
|
|
})
|
|
})
|