Merge remote-tracking branch 'origin/master' into worktree/web-session-model-selector

# Conflicts:
#	.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.i18n.yaml
#	.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml
#	apps/cli/src/web.ts
#	apps/web/tests/session-title.snapshot.ts
#	apps/web/tests/smoke-fixture.e2e.ts
#	packages/client/connection/src/client/api.ts
#	packages/client/connection/src/client/fixture.ts
#	packages/client/connection/src/client/index.ts
#	packages/client/runtime/src/client/index.ts
#	packages/client/runtime/src/client/sessions/conversation.ts
#	packages/client/runtime/src/client/sessions/session.ts
#	packages/client/runtime/tests/fake-api.ts
#	packages/client/ui-conversation/src/client/contract/slots.ts
#	packages/client/ui-conversation/src/client/index.ts
#	packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx
#	packages/client/ui-conversation/src/client/skeleton/InputBar.module.css
#	packages/client/ui-conversation/src/client/skeleton/InputBar.tsx
#	packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx
#	packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx
#	packages/client/ui-conversation/tests/chat-view.spec.tsx
#	packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx
#	packages/client/ui-conversation/tests/skeleton-branches.spec.tsx
#	packages/client/ui-conversation/tests/skeleton.spec.tsx
#	packages/host/apiproxy/README.md
#	packages/host/apiproxy/src/api-proxy.ts
#	packages/host/apiproxy/src/api/rpc.schema.ts
#	packages/host/apiproxy/src/api/rpc.ts
#	packages/host/apiproxy/tests/api-proxy-models.spec.ts
#	packages/host/apiproxy/tests/rpc-schemas.spec.ts
#	packages/host/runtime/README.md
#	packages/llm/llm-deepseek/README.md
#	scripts/translation-pairing.manifest.json
#	tsconfig.client.json
This commit is contained in:
Yichen Jiang
2026-07-26 13:13:53 +08:00
1600 changed files with 56281 additions and 31357 deletions

View File

@@ -13,7 +13,7 @@ import { apply, inject } from '../src/client/index.ts'
const SID = 'selector-session' as SessionId
async function bench() {
async function bench(hasBinding = true) {
const ctx = new Context()
await ctx.plugin(SlotsService).await()
const slots = ctx.get('slots') as SlotsService
@@ -31,7 +31,7 @@ async function bench() {
value: { selected: target },
})),
}
ctx.provide('sessions', { manager: { get: () => session } })
ctx.provide('sessions', { binding: () => hasBinding ? { session } : undefined })
ctx.provide('conversation', {})
return { ctx, slots, session }
}
@@ -44,7 +44,7 @@ describe('model-selector browser plugin', () => {
it('fails loud when the conversation control slot is not declared', async () => {
const ctx = new Context()
await ctx.plugin(SlotsService).await()
ctx.provide('sessions', { manager: { get: vi.fn() } })
ctx.provide('sessions', { binding: vi.fn() })
ctx.provide('conversation', {})
await expect(ctx.plugin({ inject: [...inject], apply }))
.rejects.toThrow(/slot "conversation\.composer\.control" is not declared/)
@@ -72,6 +72,14 @@ describe('model-selector browser plugin', () => {
.resolves.toBe(false)
})
it('fails loud when slot injection resolves no Session binding', async () => {
const { ctx, slots } = await bench(false)
await ctx.plugin({ inject: [...inject], apply }).await()
const entry = slots.entries('conversation.composer.control')[0]
expect(() => (entry?.inject as (sessionId: SessionId) => unknown)(SID))
.toThrow(`ui-model-selector: session "${SID}" resolved no binding`)
})
it('unregisters the occupant when its plugin fiber is disposed', async () => {
const { ctx, slots } = await bench()
const fiber = ctx.plugin({ inject: [...inject], apply })

View File

@@ -38,8 +38,12 @@ const ready: ModelSelectionSnapshot = {
error: null,
}
function setup(selection: ModelSelectionSnapshot = ready, removed = false) {
let current = { modelSelection: selection, removed } as unknown as ConversationSnapshot
function setup(
selection: ModelSelectionSnapshot = ready,
removed = false,
intent: ConversationSnapshot['intent'] = null,
) {
let current = { modelSelection: selection, removed, intent } as unknown as ConversationSnapshot
const useSession = ((selector: (snapshot: ConversationSnapshot) => unknown) =>
selector(current)) as ModelSelectorProps['useSession']
const refreshModels = vi.fn()
@@ -50,6 +54,8 @@ function setup(selection: ModelSelectionSnapshot = ready, removed = false) {
useSession,
useSessions: ((selector: (snapshot: never) => unknown) =>
selector({} as never)) as ModelSelectorProps['useSessions'],
useWorkspaces: ((selector: (snapshot: never) => unknown) =>
selector({} as never)) as ModelSelectorProps['useWorkspaces'],
refreshModels,
retryModelOperation,
selectModel,
@@ -60,8 +66,16 @@ function setup(selection: ModelSelectionSnapshot = ready, removed = false) {
refreshModels,
retryModelOperation,
selectModel,
update(next: ModelSelectionSnapshot, nextRemoved = removed) {
current = { modelSelection: next, removed: nextRemoved } as unknown as ConversationSnapshot
update(
next: ModelSelectionSnapshot,
nextRemoved = removed,
nextIntent: ConversationSnapshot['intent'] = intent,
) {
current = {
modelSelection: next,
removed: nextRemoved,
intent: nextIntent,
} as unknown as ConversationSnapshot
view.rerender(<ModelSelector {...props} />)
},
}
@@ -190,4 +204,17 @@ describe('model selector', () => {
setup(ready, true)
expect(trigger().disabled).toBe(true)
})
it('waits for a frontend Session Intent to publish before rendering or loading models', () => {
const b = setup(ready, false, {
target: { kind: 'workspace-intent' },
phase: 'connecting',
})
expect(screen.queryByRole('button', { name: /选择模型,当前/ })).toBeNull()
expect(b.refreshModels).not.toHaveBeenCalled()
b.update(ready, false, null)
expect(trigger().textContent).toBe('DeepSeek-V4-Flash')
expect(b.refreshModels).toHaveBeenCalledOnce()
})
})