Merge branch 'stack/agent-profiles-3-wire' into stack/agent-profiles-5-web-ui
The Client API carrier's `agentPresets` member was the one member of its class without an `IApiClient[...]` annotation. Inferring it inlined `AgentPresetEntry` into the emitted declaration by the specifier TS picks — the host `index.ts` — dragging the whole gateway, and with it the host `Context` merges, into every Client program importing the carrier. Annotated like its siblings. `ApiRemoteAgentOptions.setup` now takes the inspected session rather than its header alone: this layer resolves a resumed session's preset from the LOG, because a session that switched while blank ran its turns under the newer composition and the header is written once at creation. Conflicts: apps/web/tests/snapshots/*/*.expected.md packages/client/ui-conversation/src/client/skeleton/InputBar.tsx packages/host/apiproxy/src/api-proxy.ts scripts/doc-budgets.manifest.json
This commit is contained in:
@@ -130,6 +130,8 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
|
||||
const chat = createChatStore().create()
|
||||
const renderSlot = ((_key: string, _owner: object, opts?: { fallback?: React.ReactNode }) =>
|
||||
opts?.fallback ?? null) as unknown as ChatViewSlotProps['renderSlot']
|
||||
const renderSlotChain = ((_key: string, _owner: object, opts?: { fallback?: React.ReactNode }) =>
|
||||
opts?.fallback ?? null) as unknown as ChatViewSlotProps['renderSlotChain']
|
||||
// SessionProvider seat arrives with the session-scope child declaration;
|
||||
// ChatView never invokes it (render-prop pass-through stub).
|
||||
const SessionProviderStub: ChatViewSlotProps['SessionProvider'] = ({ children }) => <>{children(SID)}</>
|
||||
@@ -144,6 +146,7 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
|
||||
useStore: bindSnapshotSelector(chat),
|
||||
actions: chat.actions,
|
||||
renderSlot,
|
||||
renderSlotChain,
|
||||
SessionProvider: SessionProviderStub,
|
||||
openDetails,
|
||||
openFile,
|
||||
@@ -732,7 +735,8 @@ describe('ChatView', () => {
|
||||
// Count renderSlot invocations: the memo boundary holds when CallRow does
|
||||
// not re-render, so the row's renderSlot call count freezes during chunks.
|
||||
let rowRenders = 0
|
||||
h.props.renderSlot = ((_key: string, _owner: object) => {
|
||||
h.props.renderSlot = ((key: string, _owner: object) => {
|
||||
if (key !== 'conversation.chat.toolview') return null
|
||||
rowRenders += 1
|
||||
return <div data-testid="counting-row" />
|
||||
})
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Context } from 'cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { ConversationService } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { ComposerBlockRegistry } from '../src/client/input/blocks.ts'
|
||||
import { InputHub } from '../src/client/input/hub.ts'
|
||||
|
||||
async function bench() {
|
||||
@@ -23,6 +24,7 @@ async function bench() {
|
||||
// factories); the bench passes its own instance explicitly.
|
||||
const fiber = runtime.ctx.plugin(ConversationService, {
|
||||
input: new InputHub(runtime.ctx),
|
||||
blocks: new ComposerBlockRegistry(),
|
||||
})
|
||||
await fiber.await()
|
||||
const root = runtime.ctx.get('conversation') as ConversationService
|
||||
@@ -86,6 +88,7 @@ describe('ConversationService', () => {
|
||||
const bare = new Context()
|
||||
await bare.plugin(ConversationService, {
|
||||
input: new InputHub(bare),
|
||||
blocks: new ComposerBlockRegistry(),
|
||||
}).await()
|
||||
const orphan = bare.get('conversation') as ConversationService
|
||||
await expect(orphan.send('x')).rejects.toThrow(/sessions service unavailable/)
|
||||
|
||||
@@ -91,6 +91,8 @@ function mount(
|
||||
omitSummaryRow?: boolean
|
||||
/** Classify the selected child as a subagent instead of an ordinary fork. */
|
||||
summaryOrigin?: 'subagent'
|
||||
/** A composer block another plugin raised for this session. */
|
||||
composerBlock?: { reason: string }
|
||||
} = {},
|
||||
) {
|
||||
const root = sid('root')
|
||||
@@ -118,9 +120,14 @@ function mount(
|
||||
const stop = vi.fn()
|
||||
const open = vi.fn()
|
||||
const slotCalls: string[] = []
|
||||
/** Owner share handed to the two composer tool-row seats, per render. */
|
||||
const seatOwners: { key: string; owner: unknown }[] = []
|
||||
let pickerOwner: unknown
|
||||
const renderSlot = ((key: string, owner: object, opts?: { only?: string }) => {
|
||||
slotCalls.push(key)
|
||||
if (key === 'conversation.input.model' || key === 'conversation.input.plan') {
|
||||
seatOwners.push({ key, owner })
|
||||
}
|
||||
if (key === 'conversation.hero.workspace') { pickerOwner = owner; return null }
|
||||
if (key === 'conversation.session.header') {
|
||||
return (
|
||||
@@ -198,7 +205,12 @@ function mount(
|
||||
stop={stop}
|
||||
command={() => Promise.resolve(true)}
|
||||
t={t}
|
||||
renderSlot={(() => null) as InputBarProps['renderSlot']}
|
||||
renderSlot={((key: string, seatOwner: object) => {
|
||||
// The bar's own seats: recorded so a case can assert what share
|
||||
// each tool-row control received.
|
||||
seatOwners.push({ key, owner: seatOwner })
|
||||
return null
|
||||
}) as InputBarProps['renderSlot']}
|
||||
{...bar}
|
||||
/>
|
||||
)
|
||||
@@ -224,6 +236,7 @@ function mount(
|
||||
useSessions: bindSnapshotSelector(sessions),
|
||||
useWorkspaces: bindSnapshotSelector(workspaces),
|
||||
useProjection: (() => undefined),
|
||||
useComposerBlock: select => select(options.composerBlock),
|
||||
useInput,
|
||||
inputActions,
|
||||
renderSlot,
|
||||
@@ -233,7 +246,7 @@ function mount(
|
||||
}
|
||||
const view = render(<ConversationRoot {...props} />)
|
||||
return {
|
||||
view, chat, sink, retargetWorkspace, session, slotCalls, open,
|
||||
view, chat, sink, retargetWorkspace, session, slotCalls, seatOwners, open,
|
||||
pickerOwner: () => pickerOwner,
|
||||
rerender: () => { view.rerender(<ConversationRoot {...props} />) },
|
||||
}
|
||||
@@ -242,12 +255,44 @@ function mount(
|
||||
describe('Hero chrome', () => {
|
||||
it('renders the English preview badge through the hero locale seat', () => {
|
||||
const view = render(<HeroShell t={makeTranslate(en, commonEn)} />)
|
||||
expect(view.getByText('Let\'s start building')).toBeTruthy()
|
||||
expect(view.getByText('Into the Unknown')).toBeTruthy()
|
||||
expect(view.getByText('Preview')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ConversationRoot resident composer', () => {
|
||||
it('renders the composer inert with the blocker\u2019s own reason', () => {
|
||||
const b = mount(conversationSnapshot(), undefined, undefined, {
|
||||
composerBlock: { reason: 'select a model first' },
|
||||
})
|
||||
const box = b.view.getByRole('textbox') as HTMLTextAreaElement
|
||||
// One disabled textarea with the blocker's placeholder, never a second
|
||||
// tree: the DOM survives the block being raised and cleared.
|
||||
expect(box.disabled).toBe(true)
|
||||
expect(box.placeholder).toBe('select a model first')
|
||||
fireEvent.keyDown(box, { key: 'Enter' })
|
||||
expect(b.sink).not.toHaveBeenCalled()
|
||||
|
||||
// The model seat stays live. Locking it too would leave the composer
|
||||
// asking for the one thing it prevents — every block this contract has is
|
||||
// cleared by choosing a model.
|
||||
const seat = (key: string) => b.seatOwners.filter(call => call.key === key).at(-1)?.owner
|
||||
expect(seat('conversation.input.model')).toEqual({ locked: false })
|
||||
expect(seat('conversation.input.plan')).toEqual({ locked: true })
|
||||
})
|
||||
|
||||
it('lets the no-workspace posture win over a block', () => {
|
||||
// Picking a workspace is the earlier prerequisite; naming a model first
|
||||
// would send the user somewhere they cannot act yet.
|
||||
const b = mount(conversationSnapshot({ composerPhase: 'blank' }), [], undefined, {
|
||||
summaryBlank: true,
|
||||
composerBlock: { reason: 'select a model first' },
|
||||
})
|
||||
const box = b.view.getByRole('textbox') as HTMLTextAreaElement
|
||||
expect(box.disabled).toBe(true)
|
||||
expect(box.placeholder).not.toBe('select a model first')
|
||||
})
|
||||
|
||||
it('keeps composer text in the machine, mirrors to the chat store, and submits through the sink', () => {
|
||||
const b = mount(conversationSnapshot())
|
||||
const box = b.view.getByRole('textbox')
|
||||
@@ -306,7 +351,7 @@ describe('ConversationRoot resident composer', () => {
|
||||
const header = b.view.container.querySelector('header')
|
||||
expect(host).not.toBeNull()
|
||||
expect(header?.getAttribute('aria-hidden')).toBe('true')
|
||||
expect(b.view.getByText('开始构建吧')).toBeTruthy()
|
||||
expect(b.view.getByText('探索未知之境')).toBeTruthy()
|
||||
expect(b.view.getByText('预览版')).toBeTruthy()
|
||||
expect(b.view.queryByTestId('view-chat')).toBeNull()
|
||||
// The same machine-backed textarea is live in the hero, and the
|
||||
@@ -330,7 +375,7 @@ describe('ConversationRoot resident composer', () => {
|
||||
const b = mount(conversationSnapshot({ composerPhase: 'blank', blank: true, openState: 'loading' }))
|
||||
const root = b.view.container.querySelector('[data-phase]')
|
||||
expect(root?.getAttribute('data-phase')).toBe('settling')
|
||||
expect(b.view.queryByText('开始构建吧')).toBeNull()
|
||||
expect(b.view.queryByText('探索未知之境')).toBeNull()
|
||||
})
|
||||
|
||||
it('settling phase: a session the list has no row for settles conservatively', () => {
|
||||
@@ -355,7 +400,7 @@ describe('ConversationRoot resident composer', () => {
|
||||
// blank the column for the history round-trip.
|
||||
const root = b.view.container.querySelector('[data-phase]')
|
||||
expect(root?.getAttribute('data-phase')).toBe('hero')
|
||||
expect(b.view.getByText('开始构建吧')).toBeTruthy()
|
||||
expect(b.view.getByText('探索未知之境')).toBeTruthy()
|
||||
expect(b.view.getByRole('textbox')).toBeTruthy()
|
||||
})
|
||||
|
||||
@@ -373,7 +418,7 @@ describe('ConversationRoot resident composer', () => {
|
||||
expect(after.value).toBe('kept across flip')
|
||||
expect(b.chat.store.getSnapshot().draft).toBe('kept across flip')
|
||||
expect(b.view.container.querySelector('[data-conversation-scroll]')?.contains(after)).toBe(true)
|
||||
expect(b.view.queryByText('开始构建吧')).toBeNull()
|
||||
expect(b.view.queryByText('探索未知之境')).toBeNull()
|
||||
expect(b.view.getByTestId('view-chat')).toBeTruthy()
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user