mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
feat(ui): make a session that cannot send refuse to accept one
A default naming a route the Models page has since removed left the composer saying 选择模型 while the input still accepted a message, which then failed inside the adapter mid-turn. `session.prompt` now refuses with `model-unavailable` before opening a turn. That is the enforcement boundary: the method stays callable no matter what a client disables. `session.models` reports the same fact as `routable`, and ui-model pushes a block through the new `ctx.conversation.blocks` registry so the bar renders the disabled textarea it already renders without a workspace, carrying the blocker's own reason. The push direction is forced — ui-model already depends on ui-conversation, so ui-conversation cannot read it back. The gate is `routable`, not "matches no advertised group": catalog membership is advisory, so a route serving a model it stopped advertising is missing from the groups yet perfectly usable, and `null` before the first load never blocks so a slow Host cannot lock a working composer. The scaffold gains a route-only adapter for fixture-less keyless scenarios. Registering zero providers is a test artifact — every product composition mounts one — and the goldens that froze the seat's fallback label now show the model those scenarios actually route to.
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.md
|
||||
2026-08-07-default-model-follows-the-picker.md: 5174b224a17728b65f7fd69d7f72388d50e8e825
|
||||
2026-08-07-default-model-follows-the-picker.zh.md: 6ead561b928572a479f2c7b19e409b3845363ed6
|
||||
2026-08-07-default-model-follows-the-picker.md: 4142b3aea6a807001831df62c2038ddf57bbd6ad
|
||||
2026-08-07-default-model-follows-the-picker.zh.md: c3566567781edac12cd9269d63f86528139c8796
|
||||
|
||||
@@ -10,7 +10,7 @@ The route a new session started from was frozen into the gateway's composition e
|
||||
|
||||
## Decision
|
||||
|
||||
`ApiProxyService` registers its `{provider, model, reasoningEffort?}` slice as the `api-gateway` settings section: the composition entry is the `base` layer and `settings.yaml` layers the user's choice over it. `workspaceRoot` stays outside the section — a launcher fact, not a preference. The section schema is picked out of `static Config` rather than restated, because the configuration-catalog generator reads that literal statically and a spread breaks it.
|
||||
`ApiProxyService` registers its `{provider, model, reasoningEffort?}` slice as the `api-gateway` settings section: the composition entry is the `base` layer and `settings.yaml` layers the user's choice over it. `workspaceRoot` stays outside the section — a launcher fact, not a preference. `reasoningEffort` is the mirror case: it lives in the section but NOT in the plugin config, because the seam merges the user layer over the composition entry per field and an absent key cannot override a present one. A composition-set effort would therefore survive every later switch to a model without one — precisely the stranding the wholesale `replace` exists to prevent. Effort is a per-model fact anyway; a deployment default for it belongs on the adapter profile, which resolves per model.
|
||||
|
||||
`session.selectModel` records an accepted switch as the new default. There is no separate gesture: switching models in the composer IS how the default is chosen. The write is `replace`, not `update` — switching to a model with no reasoning effort has to clear a stored one, and a merged patch would strand it for the next session to fail on. A storage failure is logged without undoing the switch, which already applies to its own session, and a deployment with no settings provider keeps the entry with the switch staying process-local.
|
||||
|
||||
@@ -24,6 +24,16 @@ The stored route is not validated against the registry. A default naming a route
|
||||
|
||||
`ApiProxyDefaults` changed shape, updating ~40 test construction sites. `host.describe` now reports the live default rather than a captured one, which is what it always meant. `settings.yaml` gains an `api-gateway:` section the moment a user switches models; the `api-gateway` namespace is deliberately NOT added to the gateway's exposed-namespace allowlist, so the Settings page neither reads nor writes it — the model picker is its editor.
|
||||
|
||||
## Follow-up: blocking a session that cannot send
|
||||
|
||||
A default naming a route the Models page has since removed leaves the composer saying "Select model" while the input still accepts a message, which then fails inside the adapter mid-turn. Two changes close it.
|
||||
|
||||
The Host refuses. `session.prompt` checks whether an adapter serves the session's route and answers `model-unavailable` before opening a turn. This is the enforcement boundary: a client that disables its composer is an affordance, and the method stays callable regardless.
|
||||
|
||||
The composer goes inert. `session.models` reports `routable`, and ui-model pushes a block through the new `ctx.conversation.blocks` registry; the bar renders the same disabled textarea it already renders without a workspace, with the blocker's own localized reason as the placeholder. The push direction is forced — ui-model already depends on ui-conversation, so ui-conversation cannot read it back.
|
||||
|
||||
The gate is `routable`, NOT "the current target matches no advertised group". Catalog membership is advisory by design: a route serving a model it stopped advertising is absent from the groups yet perfectly usable, and blocking there would break a supported configuration (a narrowed `models` list over a live route). `routable` is also three-valued on the client — `null` before the first load or after a failed one never blocks, so a slow or unreachable Host cannot lock a working composer.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Falling back to the composition entry when the stored route is unregistered.** Rejected: the composer would then name the shipped DeepSeek model instead of prompting, which is both a silent switch to a provider the user did not pick and the opposite of the requested behavior.
|
||||
|
||||
@@ -10,7 +10,7 @@ Status: implemented
|
||||
|
||||
## 决定
|
||||
|
||||
`ApiProxyService` 把自己的 `{provider, model, reasoningEffort?}` 切片注册为 `api-gateway` 设置段:组合条目是 `base` 层,`settings.yaml` 把用户的选择叠加其上。`workspaceRoot` 留在段外——它是启动器事实,不是偏好。段 schema 从 `static Config` 里挑出来而不是重述一遍,因为配置目录生成器是静态读取那个字面量的,展开语法会让它失败。
|
||||
`ApiProxyService` 把自己的 `{provider, model, reasoningEffort?}` 切片注册为 `api-gateway` 设置段:组合条目是 `base` 层,`settings.yaml` 把用户的选择叠加其上。`workspaceRoot` 留在段外——它是启动器事实,不是偏好。`reasoningEffort` 则是镜像的一例:它在段里、但**不在**插件配置里,因为 seam 是按字段把用户层合并到组合条目之上的,缺席的键覆盖不了存在的键。组合层设的推理等级因此会在此后每一次切到不支持推理的模型时继续存活——正是整段 `replace` 想要杜绝的那种滞留。何况推理等级本就是按模型的事实,它的部署级默认值属于适配器 profile,那里是按模型解析的。
|
||||
|
||||
`session.selectModel` 把被接受的切换记录为新的默认值。没有另一个单独的手势:在输入框切模型**就是**选定默认值的方式。写入用 `replace` 而非 `update`——切到一个不支持推理的模型必须清掉已存的等级,而合并补丁会把它滞留下来,让下一个会话在它上面失败。存储失败只记日志,不撤销这次切换(它对自己所在的会话已经生效);没有设置提供方的部署保留组合条目,切换只停留在进程内。
|
||||
|
||||
@@ -24,6 +24,16 @@ Status: implemented
|
||||
|
||||
`ApiProxyDefaults` 形状变了,约 40 处测试构造点随之更新。`host.describe` 现在报告的是活的默认值而非捕获的快照,这本就是它一直想表达的含义。用户一旦切换模型,`settings.yaml` 就会多出一个 `api-gateway:` 段;`api-gateway` 这个 namespace 刻意**没有**加进网关的暴露名单,因此设置页既不读也不写它——模型选择器就是它的编辑器。
|
||||
|
||||
## 后续:让发不出消息的会话禁止输入
|
||||
|
||||
默认值指向一条模型页已删除的路由时,编辑器显示「选择模型」,输入框却仍接受消息,然后这一轮在适配器内部失败。两处改动关掉这个口子。
|
||||
|
||||
宿主拒绝。`session.prompt` 检查是否有适配器服务该会话的路由,在开启轮次之前就以 `model-unavailable` 应答。这是执行边界:客户端禁用编辑器只是提示性设计,这个方法始终可被调用。
|
||||
|
||||
编辑器变惰性。`session.models` 报告 `routable`,ui-model 经新的 `ctx.conversation.blocks` 注册表推送一个 block;输入栏渲染的仍是它在没有 Workspace 时就会渲染的那个禁用 textarea,只是把抬起方自己的本地化理由作为 placeholder。推送方向是被迫的——ui-model 本就依赖 ui-conversation,因此 ui-conversation 读不回去。
|
||||
|
||||
闸门是 `routable`,**不是**「当前目标匹配不到任何已公布分组」。目录成员关系按设计是咨询性的:一条仍在服务、只是不再公布该模型的路由不在分组里,却完全可用,在那里阻断会破坏一种受支持的配置(对一条活着的路由收窄 `models` 列表)。`routable` 在客户端还是三值的——首次加载之前或加载失败之后的 `null` 绝不阻断,因此慢的或够不着的宿主锁不死一个本来能用的编辑器。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
- **存下来的路由未注册时回落到组合条目。** 否决:那样输入框会显示出厂的 DeepSeek 模型而不是提示选择,既是静默切到用户没选的提供方,也与要求的行为正好相反。
|
||||
|
||||
@@ -4,11 +4,14 @@
|
||||
// a route keeps deriving from its own log — the tier order the gateway
|
||||
// resolves on every read.
|
||||
// Zero model calls: the switch is settings/llm-domain traffic only, so there
|
||||
// is no fixture and a stray stream would fail loud on the open seam. A second
|
||||
// route is declared host-side (not through the UI, which has its own
|
||||
// scenario) purely so the picker has somewhere to switch to: the keyless
|
||||
// replay catalog publishes a single model.
|
||||
// is no fixture and a stray stream would fail loud on the open seam. Both
|
||||
// routes are declared host-side (not through the UI, which has its own
|
||||
// scenario) through the pi-ai adapter the shipped tree already mounts: a
|
||||
// fixture-less scaffold registers no adapter at all, so the routes the
|
||||
// picker offers — and the one the composer must start on — have to come from
|
||||
// somewhere, and settings profiles are the product's own way to add them.
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
@@ -18,7 +21,13 @@ import { settingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import { launchWebScaffold, watchConsole, type WebScaffold } from './scaffold.ts'
|
||||
import { ZH_BROWSER_LOCALE, connectFreshWorkspaceZh, saveFailureShot } from './support.ts'
|
||||
|
||||
/** The route declared for this scenario, and the model the switch lands on. */
|
||||
/** Points the shipped `api-gateway` default at this scenario's own route. */
|
||||
const OVERLAY = fileURLToPath(new URL('./default-model.overlay.yml', import.meta.url))
|
||||
|
||||
/** The route this scenario starts on, patched over the shipped default. */
|
||||
const START_ROUTE = 'origin-gateway'
|
||||
const START_MODEL = 'origin-large'
|
||||
/** The route the switch lands on, which then becomes the saved default. */
|
||||
const ROUTE = 'acme-gateway'
|
||||
const MODEL = 'acme-large'
|
||||
|
||||
@@ -49,12 +58,19 @@ describe('web e2e: the composer model switch is the default for later sessions',
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({})
|
||||
// A second route so the picker has two models. Declared through the
|
||||
// settings seam rather than the Models page: this scenario is about the
|
||||
// composer, and the declaring flow is covered by models-settings.e2e.
|
||||
scaffold = await launchWebScaffold({ extraOverlayPath: OVERLAY })
|
||||
// Two routes so the picker has somewhere to start and somewhere to go.
|
||||
// Declared through the settings seam rather than the Models page: this
|
||||
// scenario is about the composer, and the declaring flow is covered by
|
||||
// models-settings.e2e.
|
||||
await scaffold.ctx.settings.update(settingsNamespace('llm-pi-ai'), {
|
||||
providers: {
|
||||
[START_ROUTE]: {
|
||||
displayName: 'Origin Gateway',
|
||||
api: 'openai-completions',
|
||||
baseURL: 'https://gateway.origin.example/v1',
|
||||
models: [{ id: START_MODEL, name: 'Origin Large' }],
|
||||
},
|
||||
[ROUTE]: {
|
||||
displayName: 'Acme Gateway',
|
||||
api: 'openai-completions',
|
||||
@@ -84,7 +100,7 @@ describe('web e2e: the composer model switch is the default for later sessions',
|
||||
// leaves behind: its own logged route.
|
||||
const loggedId = await createSession('default-model-logged')
|
||||
scaffold.ctx.sessions.get(SessionId(loggedId))?.append('request/header', {
|
||||
header: { config: { provider: 'deepseek-official', model: 'deepseek-v4-flash' } },
|
||||
header: { config: { provider: START_ROUTE, model: START_MODEL } },
|
||||
reason: 'initial',
|
||||
})
|
||||
|
||||
@@ -108,8 +124,35 @@ describe('web e2e: the composer model switch is the default for later sessions',
|
||||
expect(await currentOf(await createSession('default-model-after')))
|
||||
.toEqual({ provider: ROUTE, model: MODEL })
|
||||
// ...while the one holding a logged route keeps deriving from its log.
|
||||
expect(await currentOf(loggedId))
|
||||
.toEqual({ provider: 'deepseek-official', model: 'deepseek-v4-flash' })
|
||||
expect(await currentOf(loggedId)).toEqual({ provider: START_ROUTE, model: START_MODEL })
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('goes inert when the route the default names stops being served', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-default-model-blocked'))
|
||||
const box = page.locator('textarea[data-input-phase], textarea').first()
|
||||
await expect.poll(async () => box.isEnabled(), { timeout: 10_000 }).toBe(true)
|
||||
|
||||
// What removing the provider on the Models page leaves behind: the saved
|
||||
// default still names the route, and nothing serves it any more.
|
||||
// `replace`, not `update`: a merge patch of `{providers: {}}` leaves every
|
||||
// stored profile in place.
|
||||
await scaffold.ctx.settings.replace(settingsNamespace('llm-pi-ai'), { providers: {} })
|
||||
|
||||
await expect.poll(async () => box.isEnabled(), { timeout: 15_000 }).toBe(false)
|
||||
expect(await box.getAttribute('placeholder')).toBe('当前模型不可用,请先选择模型')
|
||||
|
||||
// The block is an affordance; the refusal is the Host's. A client that
|
||||
// never disabled anything still cannot start a turn on a dead route.
|
||||
const refused = await scaffold.ctx.apiProxy.sessions.prompt({
|
||||
rpcId: 'default-model-refused' as never,
|
||||
payload: {
|
||||
sessionId: SessionId(await createSession('default-model-refusal')),
|
||||
mode: 'queue' as const,
|
||||
content: [{ type: 'text' as const, text: 'hi' }],
|
||||
},
|
||||
})
|
||||
expect(refused.result).toMatchObject({ ok: false, error: { code: 'model-unavailable' } })
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
})
|
||||
|
||||
8
apps/web/tests/default-model.overlay.yml
Normal file
8
apps/web/tests/default-model.overlay.yml
Normal file
@@ -0,0 +1,8 @@
|
||||
# The fixture-less web scaffold registers no adapter, so the shipped
|
||||
# deepseek-official default would be a route nothing serves — which the
|
||||
# composer now correctly refuses to type into. This scenario declares its own
|
||||
# pi-ai routes and starts the default on one of them.
|
||||
- id: api-gateway
|
||||
config:
|
||||
provider: origin-gateway
|
||||
model: origin-large
|
||||
@@ -126,7 +126,7 @@ describe('web e2e: message IconActions and clocks on settled history', () => {
|
||||
|
||||
it.skipIf(MODE === 'record')('matches the conversation aria golden with IconActions and clocks', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-message-actions-aria'))
|
||||
await page.getByRole('button', { name: 'Select model', exact: true })
|
||||
await page.getByRole('button', { name: /^Select model, current/ })
|
||||
.waitFor({ timeout: 10_000 })
|
||||
// Keep a footer focused so opacity-hidden actions stay in the a11y tree
|
||||
// as an active/focused control during the capture.
|
||||
|
||||
@@ -45,6 +45,10 @@ import {
|
||||
WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_SETTINGS_NAMESPACE, WELCOME_NOTICE_VERSION,
|
||||
} from '@deepseek-ai/dsh-client-ui-settings-general'
|
||||
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import { LlmAdapter } from '@deepseek-ai/dsh-llm'
|
||||
import type {
|
||||
LlmModelInfo, LlmProviderInfo, LlmResolvedModelInfo, StreamChunk,
|
||||
} from '@deepseek-ai/dsh-llm'
|
||||
import type { ReplayHandle } from '@deepseek-ai/dsh-llm-replay'
|
||||
import { installLlmReplay, parseSessionLog } from '@deepseek-ai/dsh-llm-replay'
|
||||
import SessionStore, {
|
||||
@@ -93,6 +97,46 @@ const REPLAY_PROVIDERS = [{
|
||||
models: [{ id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', contextWindow: 128_000 }],
|
||||
}]
|
||||
|
||||
/**
|
||||
* The routes a shipped composition always has, with no ability to stream.
|
||||
* A fixture-less keyless scenario issues no model calls, but its tree must
|
||||
* still answer `listProviders()` — surfaces legitimately gate on whether any
|
||||
* adapter serves a session's route, and an empty registry is a test artifact,
|
||||
* not a product state.
|
||||
*/
|
||||
class RouteOnlyAdapter extends LlmAdapter {
|
||||
constructor(private readonly providers: typeof REPLAY_PROVIDERS) {
|
||||
super()
|
||||
}
|
||||
|
||||
override providerInfo(provider: string): LlmProviderInfo {
|
||||
return { id: provider, name: this.providers.find(entry => entry.id === provider)?.name ?? provider }
|
||||
}
|
||||
|
||||
override listModels(provider: string): Promise<readonly LlmModelInfo[]> {
|
||||
return Promise.resolve((this.providers.find(entry => entry.id === provider)?.models ?? [])
|
||||
.map(model => ({ provider, id: model.id, name: model.name })))
|
||||
}
|
||||
|
||||
override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
|
||||
const listed = this.providers.find(entry => entry.id === provider)?.models
|
||||
.find(entry => entry.id === model)
|
||||
return Promise.resolve({
|
||||
provider,
|
||||
id: model,
|
||||
name: listed?.name ?? model,
|
||||
...listed?.contextWindow === undefined ? {} : { contextWindow: listed.contextWindow },
|
||||
})
|
||||
}
|
||||
|
||||
override async *stream(): AsyncIterable<StreamChunk> {
|
||||
throw new Error(
|
||||
'web e2e scaffold: a model call was issued by a scenario that declared no replay fixture'
|
||||
+ ' — pass replayFixture, or keep the scenario free of model calls',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function replayProviders(contextWindow: number | undefined): typeof REPLAY_PROVIDERS {
|
||||
if (contextWindow === undefined) return REPLAY_PROVIDERS
|
||||
return REPLAY_PROVIDERS.map(provider => ({
|
||||
@@ -390,6 +434,16 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
|
||||
...(options.replayChildFixtures === undefined ? {} : { childFiles: options.replayChildFixtures }),
|
||||
...(options.paceMs === undefined ? {} : { paceMs: options.paceMs }),
|
||||
})
|
||||
} else if (mode !== 'record' && options.deepSeekMissingCredential !== true) {
|
||||
// No fixture and no shipped adapter would leave the tree with ZERO
|
||||
// provider routes — a state no product composition has, and one the
|
||||
// composer now correctly refuses to type into. Register the same routes
|
||||
// a fixture would, with streaming that still fails loud: the scenario
|
||||
// issues no model calls, and one that slipped in must not pass quietly.
|
||||
ctx.effect(() => ctx.llm.registerAdapter(
|
||||
replayProviders(options.replayContextWindow).map(provider => provider.id),
|
||||
new RouteOnlyAdapter(replayProviders(options.replayContextWindow)),
|
||||
), 'web e2e scaffold: route-only adapter')
|
||||
}
|
||||
} catch (error) {
|
||||
if (process.cwd() !== originalCwd) process.chdir(originalCwd)
|
||||
|
||||
@@ -280,10 +280,10 @@ describe('web e2e: seeded history renders through cold resume', () => {
|
||||
|
||||
it.skipIf(MODE === 'record')('matches the historical conversation aria golden', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-aria'))
|
||||
// This scenario deliberately leaves the LLM seam open to prove zero
|
||||
// model calls. History still restores the routed id, but without an
|
||||
// advertised catalog row the selector prompts for a listed replacement.
|
||||
await page.getByRole('button', { name: 'Select model', exact: true })
|
||||
// This scenario issues zero model calls — the scaffold's route-only
|
||||
// adapter serves the catalog and refuses to stream — so history restores
|
||||
// the routed id and the seat resolves it against an advertised row.
|
||||
await page.getByRole('button', { name: /^Select model, current/ })
|
||||
.waitFor({ timeout: 10_000 })
|
||||
const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
|
||||
.split(SEED_ID).join('{{seededId}}')
|
||||
|
||||
@@ -23,8 +23,8 @@
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Select model":
|
||||
- text: Select model
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
- button "Send message" [disabled]
|
||||
- text: 1 turns · 1 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 0% Input 10 tok · Output 10 tok
|
||||
|
||||
@@ -30,8 +30,8 @@
|
||||
- img
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Plan mode on, press to turn off": Plan
|
||||
- button "Select model":
|
||||
- text: Select model
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
- button "Send message" [disabled]
|
||||
- text: Details
|
||||
|
||||
@@ -42,8 +42,8 @@
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Select model":
|
||||
- text: Select model
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
- button "Send message" [disabled]
|
||||
- text: 1 turns · 1 steps LLM {{duration}} Input 0 tok · Output 0 tok
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Select model":
|
||||
- text: Select model
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
- button "Send message" [disabled]
|
||||
- text: 1 turns · 1 steps LLM {{duration}} Input 0 tok · Output 0 tok
|
||||
|
||||
@@ -33,8 +33,8 @@
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Select model":
|
||||
- text: Select model
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
- button "Send message" [disabled]
|
||||
- text: 1 turns · 1 steps LLM {{duration}} Input 0 tok · Output 0 tok
|
||||
|
||||
@@ -37,8 +37,8 @@
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Select model":
|
||||
- text: Select model
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
- button "Send message" [disabled]
|
||||
- text: 1 turns · 1 steps LLM {{duration}} Input 0 tok · Output 0 tok
|
||||
|
||||
@@ -45,8 +45,8 @@
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Select model":
|
||||
- text: Select model
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
- button "Send message" [disabled]
|
||||
- text: 2 turns · 3 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 98% Input 7.8K tok · Output 103 tok
|
||||
|
||||
@@ -44,8 +44,8 @@
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Read Only"': Read Only
|
||||
- button "Select model":
|
||||
- text: Select model
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
- button "Send message" [disabled]
|
||||
- text: 1 turns · 2 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 98% Input 15.8K tok · Output 135 tok
|
||||
|
||||
@@ -42,8 +42,8 @@
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Select model":
|
||||
- text: Select model
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
- button "Send message" [disabled]
|
||||
- text: 1 turns · 2 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 98% Input 15.8K tok · Output 135 tok
|
||||
|
||||
@@ -577,28 +577,29 @@ Source: [`packages/hooks/hooks-codex/src/index.ts:44`](../packages/hooks/hooks-c
|
||||
Requires: `agents` · `directoryPicker` · `llm` · `sessions` · `subagents` · `sessionQuery` · `tools` · `userInteraction` · `workspace`
|
||||
|
||||
```ts config-catalog
|
||||
/** Gateway plugin config: host-level agent routing and Workspace creation root. */
|
||||
export interface Config extends DefaultRouteSettings {
|
||||
/** Parent directory for name-created Workspaces; defaults to the Host cwd. */
|
||||
workspaceRoot?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The user-settable slice of the gateway config: the route a session starts
|
||||
* from when its own log names none. `workspaceRoot` is deliberately not part
|
||||
* of it — that is a launcher fact, not a preference.
|
||||
* Gateway plugin config: host-level agent routing and Workspace creation root.
|
||||
*
|
||||
* `reasoningEffort` is deliberately absent, so the section carries one field
|
||||
* the composition cannot. The seam resolves a section by MERGING the user
|
||||
* layer over the composition entry per field, and an absent key cannot
|
||||
* override a present one — so a composition-set effort would survive every
|
||||
* later switch to a model that has none, and strand it for the next session
|
||||
* to fail on. Effort is a per-model fact anyway: a deployment default belongs
|
||||
* on the adapter profile (`llm-pi-ai`'s `reasoning`, `llm-deepseek`'s own),
|
||||
* which resolves per model rather than per gateway.
|
||||
*/
|
||||
export interface DefaultRouteSettings {
|
||||
export interface Config {
|
||||
/** Default provider route for created agents. */
|
||||
provider: string
|
||||
/** Default model id. */
|
||||
model: string
|
||||
/** Default reasoning effort; absence preserves the adapter/provider default. */
|
||||
reasoningEffort?: string
|
||||
/** Parent directory for name-created Workspaces; defaults to the Host cwd. */
|
||||
workspaceRoot?: string
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/host/apiproxy/src/index.ts:64`](../packages/host/apiproxy/src/index.ts)
|
||||
Source: [`packages/host/apiproxy/src/index.ts:67`](../packages/host/apiproxy/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-host-directory-picker-browse`
|
||||
|
||||
|
||||
@@ -1975,6 +1975,9 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
models: request => ok(request, {
|
||||
current: modelTargets.get(request.payload.sessionId)
|
||||
?? { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
|
||||
// The fixture's routes all serve; a surface exercising the blocked
|
||||
// posture drives it through its own stub.
|
||||
routable: true,
|
||||
groups: fixtureModelGroups(),
|
||||
failures: [],
|
||||
}),
|
||||
|
||||
@@ -59,6 +59,7 @@ export class FakeApiClient implements IApiClient {
|
||||
|
||||
onModels: (payload: unknown) => Promise<RpcResponse<SessionModels>> = () => Promise.resolve(ok({
|
||||
current: { provider: 'deepseek-official', model: 'deepseek-chat' },
|
||||
routable: true,
|
||||
groups: [],
|
||||
failures: [],
|
||||
}))
|
||||
|
||||
@@ -73,6 +73,7 @@ export class FakeApiClient implements IApiClient {
|
||||
|
||||
onModels: (payload: unknown) => Promise<RpcResponse<SessionModels>> = () => Promise.resolve(ok({
|
||||
current: this.defaultModel,
|
||||
routable: true,
|
||||
groups: [{
|
||||
id: 'deepseek-official',
|
||||
name: 'DeepSeek',
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
|
||||
README.md: a75f25d8669cd688795842a655106e0e27bb7173
|
||||
README.zh.md: f0d744c31020730857d210d75749b907dbffca08
|
||||
README.md: 392f9956b33df88a5e9664a58de27d85fc0457d1
|
||||
README.zh.md: 6b0429a302475f84a7ce9b1cdc9fd47d90d6dba3
|
||||
|
||||
@@ -8,6 +8,8 @@ Compaction renders as one collapsed row at the checkpoint's flow position withou
|
||||
|
||||
The resident conversation shell survives no-session and session transitions. Without a current session it renders a disabled input bar; its root-scoped `conversation.hero.workspace` slot hosts the Workspace picker. Selecting a Workspace connects or reuses its Host-owned blank session and opens that session without replacing the shell. The root always owns the same scrollport and Hero/composer subtree; separate strict-session header and body outlets fill their regions when the first Session arrives, so the Workspace picker, scroll body, composer seat, and textarea retain their React and DOM identity. Blank sessions render the same composer body as active sessions, while the InputHub carries drafts across Workspace switches and mirrors them into the session store. In the active phase the session header shows only the current session title and view tabs as ordinary column chrome; fork lineage remains session data and is not projected into the header. Beneath it the scrollport (`data-conversation-scroll`) holds the flowing views and the sticky composer stack (stats dock + input docks + bar). That scrollport reserves its scrollbar gutter unconditionally, and a view opting into a composer overlay leaves it a scroll container, so the input card keeps one horizontal position whether or not the transcript scrolls and whichever view tab is shown ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md)). Wheel over the textarea chains: the capped draft scrolls locally until its edge, then forwards to that host.
|
||||
|
||||
Another plugin can make one session's composer inert through `ctx.conversation.blocks`: it sets a block carrying its own localized reason, and the bar renders the same disabled textarea with that reason as the placeholder — the no-workspace posture, reused. The push direction is the constraint, not a preference: the plugins that know a session cannot send (ui-model, when no adapter serves its route) already depend on this package, so this package cannot read them. A block is an affordance only; the Host refuses a prompt it cannot route regardless of what any client disables. The no-workspace state wins when both hold, because picking a workspace is the earlier prerequisite.
|
||||
|
||||
The view ring is a slot: the strict session-body registration declares the session-scoped `'conversation.view'` list in its `children` table, that body renders the active entry through its renderSlot share (`only: <active id>`), and view tabs project from registration options (`id`/`order`/`label`). The chat view is this package's own entry; plugins such as ui-trajectory contribute tabs through `ctx.slots.register`, and each view owns its chrome.
|
||||
|
||||
Approvals take over the composer through the chain this package declares: `ApprovalPanel` registers as a selector-routed `'conversation.composer'` entry (the ui-question pattern) and occupies the composer in place of the InputBar while an approval wait is pending (amber strip, justification headline, paired command line from the running call's args, one-shot refuse/allow). The `PendingApproval` domain face in `contract/slots.ts` owns the wire encoding — the `ApprovalResponsePayload` value with the audit correlation — over the runtime's `PendingWait` carrier; the broadcast `approval/resolved` frame settles the wait and restores the composer. The runtime manager projects every approval or question wait through `SessionSummary.pendingInteraction`, including sessions never instantiated; `ui-workspace` owns its sidebar presentation. Pending waits leave the message flow entirely: questions (ui-question) and approvals (ApprovalPanel) both answer through the composer takeover, so no display-only placeholder card remains. The composer's bottom-row Access seat mounts `PermissionSelect`, fed by the host-computed `permissions` projection through the standard-kit `useProjection` (key absence hides the chip); the chip opens a Menu-primitive dropdown whose kebab-case preset names render as title-case labels. Safe preset picks submit `/permission <preset>` immediately through the bar's injected `command` callback, while `danger-full-access` is presented as `Full access` and first opens an in-page Modal risk confirmation. The enabling action stays disabled until the user checks the acknowledgement; cancel, Escape, close, and mask click submit nothing.
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
|
||||
常驻会话壳会跨无会话与会话状态切换而保留。没有当前会话时,它会渲染禁用输入栏;其根作用域的 `conversation.hero.workspace` slot 承载 Workspace 选择器。选择 Workspace 会连接或复用由 Host 拥有的空白会话,并在不替换会话壳的情况下打开该会话。根组件始终拥有同一个滚动容器与 Hero/编辑器子树;首个会话到达时,彼此独立的严格会话页头和主体 outlet 只填入各自区域,因此 Workspace 选择器、滚动主体、编辑器 seat 与 textarea 都保留原有 React 和 DOM identity。空白会话与活跃会话渲染相同的输入区主体;InputHub 则在 Workspace 切换间携带草稿,并将草稿镜像到会话 store。活跃阶段,会话标题栏作为普通列 chrome,仅显示当前会话标题和视图标签;fork 谱系仍保留为会话数据,不投影到标题栏。其下滚动容器(`data-conversation-scroll`)承载流动排版的各视图与 sticky 编辑器栈(统计 dock+输入区 dock+输入栏)。该滚动容器无条件预留自己的滚动条槽,选用编辑器 overlay 的视图也仍把它保留为滚动容器,因此无论对话记录是否滚动、无论展示哪个视图标签,输入卡片都保持同一个横向位置([决策](../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md))。textarea 上的滚轮会链式处理:限高草稿先在本地滚动,到达边缘后再转交给该宿主。
|
||||
|
||||
别的插件可以经 `ctx.conversation.blocks` 让某个会话的编辑器变为惰性:它设置一个携带自己本地化理由的 block,输入栏就渲染同一个禁用的 textarea,并把该理由作为 placeholder——复用无 Workspace 时的那套姿态。推送方向是约束而非偏好:知道某会话发不出消息的插件(ui-model,在没有适配器服务其路由时)本就依赖本包,因此本包读不到它们。block 只是提示性设计;无论客户端禁用了什么,宿主都会拒绝一个它路由不了的 prompt。两者同时成立时以无 Workspace 姿态为准,因为选 Workspace 是更靠前的前提。
|
||||
|
||||
视图环是一个 slot:严格会话主体注册在 `children` 表中声明 Session scope 的 `'conversation.view'` 列表,并通过自身的 renderSlot share 渲染活跃配置项(`only: <active id>`);视图标签页则从注册选项(`id`/`order`/`label`)投影而来。聊天视图是该包自身的配置项;ui-trajectory 等插件通过 `ctx.slots.register` 贡献标签页,每个视图负责自己的 chrome。
|
||||
|
||||
会话页头会在标题旁声明并渲染 Session scope 的 `'conversation.session.header.actions'` 列表,使功能插件无需进入骨架即可贡献控件。编辑器链的 currency 包含当前对话 `session`;ui-subagent 会选取 one-shot 或 parent 不可用的已寻址会话,并按原因显示只读文案,而普通 InputBar 会让所有已寻址 child 仅保留 Send,因为继续执行服务不公开逐 Activation 取消操作,`session.cancel` 也会绕过其所有权。
|
||||
|
||||
@@ -15,6 +15,8 @@ import { resolveToolPath } from './contract/tool-call-model.ts'
|
||||
import { createChatStore } from './stores.ts'
|
||||
import { ConversationService } from './service.ts'
|
||||
import type { IConversation } from './service.ts'
|
||||
import { ComposerBlockRegistry } from './input/blocks.ts'
|
||||
import type { ComposerBlock } from './input/blocks.ts'
|
||||
import { InputHub } from './input/hub.ts'
|
||||
import { ComposerSubmissionPolicy } from './input/submission-policy.ts'
|
||||
import { InputBar } from './skeleton/InputBar.tsx'
|
||||
@@ -54,6 +56,11 @@ const ABSENT_NOTICES = {
|
||||
getSnapshot: (): InputNotice | null => null,
|
||||
subscribe: () => () => {},
|
||||
}
|
||||
/** No session, therefore nothing to block; same one-identity rule as above. */
|
||||
const ABSENT_BLOCK = {
|
||||
getSnapshot: (): ComposerBlock | undefined => undefined,
|
||||
subscribe: () => () => {},
|
||||
}
|
||||
const EMPTY_LEXICON: ReadonlyMap<'/' | '@', readonly string[]> = new Map()
|
||||
const ABSENT_LEXICON = {
|
||||
getSnapshot: () => EMPTY_LEXICON,
|
||||
@@ -133,6 +140,12 @@ export function apply(ctx: Context): void {
|
||||
// ctx.conversation.input by the service below sharing this one instance).
|
||||
const inputHub = new InputHub(ctx)
|
||||
|
||||
// The composer-block registry: a plugin that knows a session cannot send —
|
||||
// ui-model, when no adapter serves the session's route — raises a block
|
||||
// here, and the bar reads its own session's store. It cannot flow the other
|
||||
// way: this package must not import the plugins that would know.
|
||||
const composerBlocks = new ComposerBlockRegistry()
|
||||
|
||||
// Decision 19/20: the input machine feeds every session-scope slot
|
||||
// component through the standard provide channel — the 'input' hook plus
|
||||
// the two public actions. Materialization is the shell creation trigger
|
||||
@@ -167,6 +180,7 @@ export function apply(ctx: Context): void {
|
||||
'conversation.hero.workspace': { kind: 'single', scope: 'root' },
|
||||
},
|
||||
inject: (sessionId: SessionId | undefined): ConversationInjected => ({
|
||||
hooks: { composerBlock: sessionId === undefined ? ABSENT_BLOCK : composerBlocks.storeFor(sessionId) },
|
||||
selectWorkspace: async (workspaceId) => {
|
||||
const nextId = await workspaces.connectWorkspace(workspaceId)
|
||||
if (sessionId !== undefined && nextId !== sessionId) {
|
||||
@@ -351,7 +365,7 @@ export function apply(ctx: Context): void {
|
||||
// registers itself as `conversation` and lives on its own child fiber.
|
||||
// Presentation registrants depend directly on their slot declarations;
|
||||
// this service remains only where conversation actions are required.
|
||||
ctx.plugin(ConversationService, { input: inputHub })
|
||||
ctx.plugin(ConversationService, { input: inputHub, blocks: composerBlocks })
|
||||
|
||||
// The bash sample rides the same declaration seam, in third-party posture
|
||||
// (ToolRow-matching Bash · {description} chrome).
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
} from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { CommandNode, ConversationSnapshot, ObservableSnapshot, PendingInteraction, PendingWait, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
|
||||
import type { ComposerBlock } from '../input/blocks.ts'
|
||||
import type { ComposerKeyboard, EditSelection, InputActions, InputNotice, InputState } from '../input/contract.ts'
|
||||
import type { createChatStore } from '../stores.ts'
|
||||
import type { ComposerSubmitGesture, InputSubmitMode } from './composer-submission.ts'
|
||||
@@ -223,6 +224,12 @@ export interface ConversationInjected {
|
||||
* When a blank session is already current, carry its draft to the target.
|
||||
*/
|
||||
selectWorkspace: (workspaceId: WorkspaceId) => Promise<void>
|
||||
/**
|
||||
* Framework-bound sources. `composerBlock` is this session's block when a
|
||||
* plugin raised one; the reason is the blocker's own localized copy, which
|
||||
* the root renders as the inert composer's placeholder.
|
||||
*/
|
||||
hooks: { composerBlock: ObservableSnapshot<ComposerBlock | undefined> }
|
||||
}
|
||||
|
||||
/** Business callbacks injected into the strict Session body seat. */
|
||||
@@ -356,7 +363,7 @@ export type ConversationSlotProps =
|
||||
| 'conversation.input.left' | 'conversation.input.right'
|
||||
| 'conversation.hero.workspace'
|
||||
>
|
||||
& ConversationInjected
|
||||
& InjectFace<ConversationInjected>
|
||||
& PropsLocale<'conversation'>
|
||||
|
||||
/** Full strict-session body props: per-session store, view ring, and draft mirror. */
|
||||
|
||||
77
packages/client/ui-conversation/src/client/input/blocks.ts
Normal file
77
packages/client/ui-conversation/src/client/input/blocks.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Composer blocks: the one way another plugin stops a session's input.
|
||||
*
|
||||
* The composer cannot read the plugins that would know — the dependency runs
|
||||
* ui-model → ui-conversation, never back — so a blocker pushes here and the
|
||||
* bar reads its own session's store. A block carries the localized reason it
|
||||
* exists, because the plugin that raised it owns that copy; the composer only
|
||||
* knows how to render an inert textarea with a placeholder, exactly as it
|
||||
* already does for a session with no workspace.
|
||||
*
|
||||
* This is an affordance, not enforcement: the Host refuses a prompt it cannot
|
||||
* route regardless of what any client disables.
|
||||
*/
|
||||
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SessionId, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
/** Why one session's composer is inert. */
|
||||
export interface ComposerBlock {
|
||||
/**
|
||||
* Localized placeholder replacing the composer's own, owned by the plugin
|
||||
* that raised the block.
|
||||
*/
|
||||
readonly reason: string
|
||||
}
|
||||
|
||||
/** The registry face other plugins reach through `ctx.conversation.blocks`. */
|
||||
export interface ComposerBlocks {
|
||||
/**
|
||||
* Raise or clear this session's block. Idempotent: setting a block equal to
|
||||
* the current one, or clearing an absent one, notifies nobody.
|
||||
* @param sessionId - the session whose composer is affected.
|
||||
* @param block - the block to raise, or undefined to clear it.
|
||||
*/
|
||||
set(sessionId: SessionId, block: ComposerBlock | undefined): void
|
||||
/**
|
||||
* The store the composer subscribes to for one session. Created on first
|
||||
* read from either side, so a blocker may raise a block before the session's
|
||||
* composer mounts and the composer still sees it.
|
||||
* @param sessionId - the session to observe.
|
||||
* @returns that session's block store (undefined value = not blocked).
|
||||
*/
|
||||
storeFor(sessionId: SessionId): SnapshotStore<ComposerBlock | undefined>
|
||||
/**
|
||||
* Drop one session's store. The session scope's disposer calls this; a
|
||||
* blocker never needs to.
|
||||
* @param sessionId - the session being torn down.
|
||||
*/
|
||||
forget(sessionId: SessionId): void
|
||||
}
|
||||
|
||||
/** The per-session composer-block registry (one instance per plugin fiber). */
|
||||
export class ComposerBlockRegistry implements ComposerBlocks {
|
||||
private readonly stores = new Map<SessionId, SnapshotStore<ComposerBlock | undefined>>()
|
||||
|
||||
/** @inheritdoc */
|
||||
set(sessionId: SessionId, block: ComposerBlock | undefined): void {
|
||||
const store = this.storeFor(sessionId)
|
||||
const current = store.getSnapshot()
|
||||
if (current?.reason === block?.reason) return
|
||||
store.set(block)
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
storeFor(sessionId: SessionId): SnapshotStore<ComposerBlock | undefined> {
|
||||
const existing = this.stores.get(sessionId)
|
||||
if (existing !== undefined) return existing
|
||||
const created = createSnapshotStore<ComposerBlock | undefined>(undefined)
|
||||
this.stores.set(sessionId, created)
|
||||
return created
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
forget(sessionId: SessionId): void {
|
||||
this.stores.delete(sessionId)
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import type { Context } from 'cordis'
|
||||
// method) instead of the standalone helper.
|
||||
import type { ISessions, SessionFace, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { QueueAction, QueueItemId } from './contract/queue.ts'
|
||||
import type { ComposerBlocks } from './input/blocks.ts'
|
||||
import type { InputService } from './input/contract.ts'
|
||||
|
||||
/**
|
||||
@@ -24,6 +25,11 @@ import type { InputService } from './input/contract.ts'
|
||||
export interface IConversation {
|
||||
/** The per-session input machine registry (InputService face). */
|
||||
readonly input: InputService
|
||||
/**
|
||||
* The per-session composer-block registry: how a plugin the composer
|
||||
* cannot import makes a session's input inert with its own reason.
|
||||
*/
|
||||
readonly blocks: ComposerBlocks
|
||||
/**
|
||||
* Send a prompt into the caller scope's session (queued turn).
|
||||
* @param text - prompt text, sent verbatim as one text block.
|
||||
@@ -53,16 +59,20 @@ export interface IConversation {
|
||||
export class ConversationService extends Service implements IConversation {
|
||||
/** The per-session input machine registry (InputService face, design §5.2). */
|
||||
readonly input: InputService
|
||||
/** The per-session composer-block registry. */
|
||||
readonly blocks: ComposerBlocks
|
||||
|
||||
/**
|
||||
* @param ctx - owning root context (the plugin apply context; the service
|
||||
* registers itself and follows that fiber's lifetime).
|
||||
* @param config - carries the InputService instance constructed by the
|
||||
* plugin apply (the same InputHub the slot inject factories close over).
|
||||
* @param config - carries the InputService and composer-block registry
|
||||
* constructed by the plugin apply (the same instances the slot inject
|
||||
* factories close over).
|
||||
*/
|
||||
constructor(ctx: Context, config: { input: InputService }) {
|
||||
constructor(ctx: Context, config: { input: InputService; blocks: ComposerBlocks }) {
|
||||
super(ctx, 'conversation')
|
||||
this.input = config.input
|
||||
this.blocks = config.blocks
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -13,7 +13,7 @@ import css from './ConversationRoot.module.css'
|
||||
export type ConversationRootProps = ConversationSlotProps
|
||||
|
||||
export function ConversationRoot({
|
||||
sessionId, useSession, useSessions, useWorkspaces, useInput,
|
||||
sessionId, useSession, useSessions, useWorkspaces, useInput, useComposerBlock,
|
||||
renderSlot, renderSlotChain, selectWorkspace, t,
|
||||
}: ConversationRootProps) {
|
||||
const openState = useSession(s => s.openState)
|
||||
@@ -24,6 +24,9 @@ export function ConversationRoot({
|
||||
const cwd = useSessions(s => sessionId === undefined ? undefined : s.byId[sessionId]?.cwd)
|
||||
const summaryBlank = useSessions(s => sessionId === undefined ? undefined : s.byId[sessionId]?.blank)
|
||||
const workspaces = useWorkspaces(s => s)
|
||||
// A plugin this package cannot import (ui-model) says this session cannot
|
||||
// send; its reason is already localized by whoever raised it.
|
||||
const composerBlock = useComposerBlock(block => block)
|
||||
|
||||
const [pickerOpen, setPickerOpen] = useState(false)
|
||||
const [pendingWorkspaceId, setPendingWorkspaceId] = useState<WorkspaceId | undefined>()
|
||||
@@ -126,11 +129,17 @@ export function ConversationRoot({
|
||||
// bar is ONE session-maybe slot rendered unconditionally — inert is a prop,
|
||||
// not a different tree, so the textarea DOM survives the transition.
|
||||
const inert = sessionId === undefined || (hero && chipTitle === undefined)
|
||||
// A raised block is the same inert posture with the blocker's own reason:
|
||||
// one disabled textarea, never a second tree. The no-workspace state wins
|
||||
// when both hold — picking a workspace is the earlier prerequisite.
|
||||
const blocked = !inert && composerBlock !== undefined
|
||||
const inputBar = renderSlot('conversation.composer.bar', {
|
||||
variant: hero ? 'hero' : 'composer',
|
||||
...(inert
|
||||
? { disabled: true, placeholder: t('placeholder.workspace') }
|
||||
: hero ? { placeholder: t('placeholder.hero') } : {}),
|
||||
: blocked
|
||||
? { disabled: true, placeholder: composerBlock.reason }
|
||||
: hero ? { placeholder: t('placeholder.hero') } : {}),
|
||||
overlay: renderSlot('conversation.input.overlay', {}),
|
||||
leftItems: zone === undefined ? null : renderSlot('conversation.input.left', zone),
|
||||
rightItems: zone === undefined ? null : renderSlot('conversation.input.right', zone),
|
||||
|
||||
@@ -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')
|
||||
@@ -224,6 +226,7 @@ function mount(
|
||||
useSessions: bindSnapshotSelector(sessions),
|
||||
useWorkspaces: bindSnapshotSelector(workspaces),
|
||||
useProjection: (() => undefined),
|
||||
useComposerBlock: select => select(options.composerBlock),
|
||||
useInput,
|
||||
inputActions,
|
||||
renderSlot,
|
||||
@@ -248,6 +251,31 @@ describe('Hero chrome', () => {
|
||||
})
|
||||
|
||||
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()
|
||||
})
|
||||
|
||||
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')
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-model/README.md
|
||||
README.md: 5f9fc65939eb747d916fa5609423d3186d1fefde
|
||||
README.zh.md: 3ed8db3095d96e48813cf5b15a206ebf4c894950
|
||||
README.md: 5a6f998476629566d35af32efa5d8bc5072a872b
|
||||
README.zh.md: 2bb22c55f1ae5af59f21e254804329d288806a90
|
||||
|
||||
@@ -6,6 +6,8 @@ Model selection plugin, browser half: TWO entries over ONE per-session directory
|
||||
|
||||
The Host-reported provider/model/reasoning target is the single selection fact, but it is echoed only when the exact route remains in the advertised groups; removing that catalog row leaves the routable target intact while the trigger prompts `Select model`, no stale row is synthesized, and no Effort row is shown until the user picks an advertised model. Directory loads and selections share a generation counter so an older response never overwrites a newer one; a connection reset drops every resident projection and repulls the Host-restored target before display. Provider-local metadata failures list inline while usable groups stay selectable, and selection failures retain the prior target and directory.
|
||||
|
||||
When the Host reports that no adapter serves the session's route (`session.models.routable`), this plugin raises a composer block through `ctx.conversation.blocks` and the input goes inert with this plugin's own copy; recovering clears it without a reload. It follows `routable` and nothing else: a `null` — before the first load, or after one failed — never blocks, or a slow Host would lock a working composer, and catalog membership never blocks either, because a route serving a model it stopped advertising is missing from the groups yet perfectly usable. The trigger's own `Select model` fallback still covers that case, which is display, not a gate.
|
||||
|
||||
Directories are per-session, resolved lazily through `ctx.models.directoryFor(sessionId)`, and disposed with the session scope. Addressed subagent sessions expose neither entry, and their directory rejects loads, selections, and reconnect refreshes, because ordinary Agent-bound model RPCs would activate persisted child history outside the direct-parent continuation seam.
|
||||
|
||||
The `/client` export surface is the plugin body (`apply`/`inject`), `ModelService`, `ModelDirectory` with its state shape, and the seat's injected face type.
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
|
||||
Host 报告的提供方/模型/推理(reasoning)目标是唯一的选择事实,但只有当该精确路由仍在已公布分组中时才会回显;删除该目录行会保留仍可路由的目标,但触发器会提示 `Select model`,系统不会合成陈旧行,且在用户选择已公布的模型之前不会显示 Effort 行。目录加载与选择共享一个代次计数器,旧响应不会覆盖新结果;连接重置会丢弃所有常驻目录投影,并在显示前重新拉取 Host 恢复的目标。各提供方的元数据获取失败会内联列出,同时可用分组仍可选择;选择失败会保留先前的目标和目录。
|
||||
|
||||
当宿主报告没有适配器服务该会话的路由(`session.models.routable`)时,本插件经 `ctx.conversation.blocks` 抬起一个编辑器 block,输入框随之变为惰性并显示本插件自己的文案;恢复后无需重新加载即自动清除。它只跟随 `routable`:`null`(首次加载之前,或加载失败之后)绝不阻断,否则一个慢的宿主就会锁死一个本来能用的编辑器;目录成员关系同样不阻断,因为一条仍在服务、只是不再公布该模型的路由不在分组里,却完全可用。触发器自己的 `Select model` 回退仍然覆盖那种情形——那是显示,不是闸门。
|
||||
|
||||
目录按会话惰性解析(`ctx.models.directoryFor(sessionId)`),随会话作用域一并释放。已寻址 subagent 会话不公开任一入口,其目录会拒绝加载、选择与重新连接刷新,因为绑定到 agent(智能体)的普通模型 RPC 会在直接 parent 继续执行 seam 之外激活持久化 child 历史。
|
||||
|
||||
`/client` 导出面为插件本体(`apply`/`inject`)、`ModelService`、`ModelDirectory` 及其状态形状、slot 注入面类型。
|
||||
|
||||
@@ -15,6 +15,14 @@ import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
export interface ModelDirectoryState {
|
||||
/** Target the host reports for the next assembled step; null before the first load. */
|
||||
current: ModelTarget | null
|
||||
/**
|
||||
* Whether an adapter serves the current target's route, as the host reports
|
||||
* it — null before the first load, which is NOT the same as blocked. Read
|
||||
* this rather than "current matches no group": catalog membership is
|
||||
* advisory, so a route serving a model it stopped advertising is missing
|
||||
* from the groups yet perfectly usable.
|
||||
*/
|
||||
routable: boolean | null
|
||||
/** Successfully loaded provider groups (last good load). */
|
||||
groups: readonly ModelProviderGroup[]
|
||||
/** Provider-local failures from the last load; usable groups stay usable. */
|
||||
@@ -29,7 +37,7 @@ export interface ModelDirectoryState {
|
||||
export class ModelDirectory {
|
||||
/** The shared snapshot both entries render from (uSES-safe store). */
|
||||
readonly store: SnapshotStore<ModelDirectoryState> = createSnapshotStore<ModelDirectoryState>({
|
||||
current: null, groups: [], failures: [], status: 'idle', error: null,
|
||||
current: null, routable: null, groups: [], failures: [], status: 'idle', error: null,
|
||||
})
|
||||
|
||||
/** Latest operation wins; an older response never overwrites a newer one. */
|
||||
@@ -65,9 +73,10 @@ export class ModelDirectory {
|
||||
this.store.update((s) => { s.status = 'error'; s.error = `${result.error.code}: ${result.error.message}` })
|
||||
throw new Error(`session.models failed: ${result.error.code}: ${result.error.message}`)
|
||||
}
|
||||
const { current, groups, failures } = result.value
|
||||
const { current, routable, groups, failures } = result.value
|
||||
this.store.update((s) => {
|
||||
s.current = current
|
||||
s.routable = routable
|
||||
s.groups = groups
|
||||
s.failures = failures
|
||||
s.status = 'ready'
|
||||
@@ -102,7 +111,14 @@ export class ModelDirectory {
|
||||
this.store.update((s) => { s.status = 'error'; s.error = `${result.error.code}: ${result.error.message}` })
|
||||
throw new Error(`session.selectModel failed: ${result.error.code}: ${result.error.message}`)
|
||||
}
|
||||
this.store.update((s) => { s.current = result.value.selected; s.status = 'ready'; s.error = null })
|
||||
// The Host validated the route before accepting it, so a selection that
|
||||
// landed is by construction one it can serve.
|
||||
this.store.update((s) => {
|
||||
s.current = result.value.selected
|
||||
s.routable = true
|
||||
s.status = 'ready'
|
||||
s.error = null
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -115,6 +131,7 @@ export class ModelDirectory {
|
||||
++this.generation
|
||||
this.store.update((s) => {
|
||||
s.current = null
|
||||
s.routable = null
|
||||
s.groups = []
|
||||
s.failures = []
|
||||
s.status = 'idle'
|
||||
|
||||
@@ -105,14 +105,16 @@ export const inject = ['command', 'connection', 'locale', 'sessions', 'slots']
|
||||
* @param ctx - client root context.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
ctx.plugin(ModelService)
|
||||
|
||||
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-model: dictionaries')
|
||||
|
||||
// Non-slot faces (the command description, the popup option builder) read
|
||||
// through the bound translate; the seat component reads the standard seat.
|
||||
const t = ctx.locale.bind(NS)
|
||||
|
||||
// The composer-block reason is this plugin's own copy, read at raise time so
|
||||
// a locale change reaches the next publish.
|
||||
ctx.plugin(ModelService, { blockReason: () => t('blocked.composer') })
|
||||
|
||||
// Entry 1: the /model popupSelect over the shared directory. The command
|
||||
// description is registry-held text: it reads t() once at registration and
|
||||
// refreshes only on re-registration, not on locale change.
|
||||
|
||||
@@ -25,6 +25,7 @@ export const zh = {
|
||||
'action.reload': '重新加载',
|
||||
'warning.groupLoad': '{name} 加载失败:{message}',
|
||||
'empty.models': '没有可用的模型。',
|
||||
'blocked.composer': '当前模型不可用,请先选择模型',
|
||||
'empty.efforts': '当前模型未提供推理等级。',
|
||||
} satisfies Record<string, string>
|
||||
|
||||
@@ -48,5 +49,6 @@ export const en = {
|
||||
'action.reload': 'Reload',
|
||||
'warning.groupLoad': '{name} failed to load: {message}',
|
||||
'empty.models': 'No models available.',
|
||||
'blocked.composer': 'This model is unavailable — select one to continue',
|
||||
'empty.efforts': 'This model provides no reasoning effort levels.',
|
||||
} satisfies Record<ModelKey, string>
|
||||
|
||||
@@ -36,11 +36,16 @@ export class ModelService extends Service {
|
||||
|
||||
private readonly live: LiveState = { directories: new Map() }
|
||||
|
||||
/** Localized composer-block copy; this plugin owns the string it raises. */
|
||||
private readonly blockReason: () => string
|
||||
|
||||
/**
|
||||
* @param ctx - owning root context (the service registers itself as `models`).
|
||||
* @param config - the bound translator for this plugin's own dictionary.
|
||||
*/
|
||||
constructor(ctx: Context) {
|
||||
constructor(ctx: Context, config: { blockReason: () => string }) {
|
||||
super(ctx, 'models')
|
||||
this.blockReason = config.blockReason
|
||||
ctx.on('connection/reset', () => {
|
||||
for (const directory of this.live.directories.values()) directory.resetConnected()
|
||||
})
|
||||
@@ -74,6 +79,27 @@ export class ModelService extends Service {
|
||||
() => sessions.subagentAddress(sessionId) === undefined,
|
||||
)
|
||||
live.directories.set(sessionId, directory)
|
||||
// The composer cannot read this plugin (the dependency runs one way), so
|
||||
// the block is pushed: the Host says whether an adapter serves the
|
||||
// session's route, and only a definite `false` makes the input inert.
|
||||
// `null` — before the first load, or after one failed — must not, or a
|
||||
// slow or unreachable Host would lock a working composer.
|
||||
const conversation = this.ctx.get('conversation')
|
||||
if (conversation !== undefined) {
|
||||
const publish = (): void => {
|
||||
conversation.blocks.set(sessionId, directory.store.getSnapshot().routable === false
|
||||
? { reason: this.blockReason() }
|
||||
: undefined)
|
||||
}
|
||||
publish()
|
||||
actx.effect(() => {
|
||||
const stop = directory.store.subscribe(publish)
|
||||
return () => {
|
||||
stop()
|
||||
conversation.blocks.set(sessionId, undefined)
|
||||
}
|
||||
}, 'ui-model: composer block')
|
||||
}
|
||||
actx.effect(() => () => {
|
||||
directory.dispose()
|
||||
live.directories.delete(sessionId)
|
||||
|
||||
@@ -17,6 +17,7 @@ import type { ModelTarget } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { CommandContribution, SelectOption } from '@deepseek-ai/dsh-client-ui-command/client'
|
||||
import type { ModelSelectInjected } from '../src/client/slots.ts'
|
||||
import { apply, inject } from '../src/client/index.ts'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
|
||||
const sid = (k: string): SessionId => k as SessionId
|
||||
|
||||
@@ -59,7 +60,9 @@ async function bench() {
|
||||
ctx.provide('connection', { api: { sessions: {
|
||||
models: () => {
|
||||
calls.models += 1
|
||||
return Promise.resolve({ result: { ok: true as const, value: { current, groups: GROUPS, failures: [] } } })
|
||||
return Promise.resolve({
|
||||
result: { ok: true as const, value: { current, routable, groups: GROUPS, failures: [] } },
|
||||
})
|
||||
},
|
||||
selectModel: (payload: { provider: string; model: string; reasoningEffort?: string }) => {
|
||||
calls.select += 1
|
||||
@@ -73,6 +76,15 @@ async function bench() {
|
||||
return Promise.resolve({ result: { ok: true as const, value: { selected: current } } })
|
||||
},
|
||||
} } })
|
||||
// Whether the Host reports an adapter for the current route; the composer
|
||||
// block follows this, never catalog membership.
|
||||
let routable = true
|
||||
const blocks = new Map<SessionId, { reason: string } | undefined>()
|
||||
ctx.provide('conversation', {
|
||||
blocks: {
|
||||
set: (id: SessionId, block: { reason: string } | undefined) => { blocks.set(id, block) },
|
||||
},
|
||||
})
|
||||
let contribution: CommandContribution | undefined
|
||||
ctx.provide('command', {
|
||||
register(c: CommandContribution) {
|
||||
@@ -115,6 +127,8 @@ async function bench() {
|
||||
hostCurrent: () => current,
|
||||
setHostCurrent: (target: ModelTarget) => { current = target },
|
||||
address: (id: SessionId) => { addressed.add(id) },
|
||||
setRoutable: (next: boolean) => { routable = next },
|
||||
blockOf: (key: string) => blocks.get(sid(key)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,6 +231,63 @@ describe('ui-model dual entry', () => {
|
||||
expect(face2.directory).not.toBe(face1.directory)
|
||||
})
|
||||
|
||||
it('blocks the composer only once the Host reports the route unservable', async () => {
|
||||
const b = await bench()
|
||||
b.mint('s1')
|
||||
const face = b.seat().inject!(sid('s1'))
|
||||
|
||||
// Before the first load nothing is known. `null` is not `false`: a slow
|
||||
// or unreachable Host must never lock a working composer.
|
||||
expect(b.blockOf('s1')).toBeUndefined()
|
||||
face.load()
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(b.blockOf('s1')).toBeUndefined()
|
||||
|
||||
b.setRoutable(false)
|
||||
b.ctx.emit('models/changed')
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(b.blockOf('s1')?.reason).toBe(zh['blocked.composer'])
|
||||
|
||||
// Recovering clears it without a reload of the surface.
|
||||
b.setRoutable(true)
|
||||
b.ctx.emit('models/changed')
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(b.blockOf('s1')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('never blocks on catalog membership alone', async () => {
|
||||
const b = await bench()
|
||||
b.mint('s1')
|
||||
const face = b.seat().inject!(sid('s1'))
|
||||
// A model the route serves but no longer advertises: the seat prompts for
|
||||
// a selection, the composer stays usable. Blocking here would break a
|
||||
// supported configuration (a narrowed `models` list over a live route).
|
||||
b.setHostCurrent({ provider: 'deepseek-official', model: 'unlisted' })
|
||||
face.load()
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
const snapshot = face.directory.getSnapshot()
|
||||
expect(snapshot.groups.flatMap(group => group.models.map(model => model.id))).not.toContain('unlisted')
|
||||
expect(b.blockOf('s1')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('clears its block when the session scope goes', async () => {
|
||||
const b = await bench()
|
||||
const scope = b.mint('s1')
|
||||
b.setRoutable(false)
|
||||
const face = b.seat().inject!(sid('s1'))
|
||||
face.load()
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(b.blockOf('s1')).toBeDefined()
|
||||
|
||||
await scope.fiber.dispose()
|
||||
expect(b.blockOf('s1')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('an unknown session fails loud at the seat inject', async () => {
|
||||
const b = await bench()
|
||||
expect(() => b.seat().inject!(sid('ghost'))).toThrow(/resolved no scope/)
|
||||
|
||||
@@ -32,6 +32,7 @@ const reasoning = {
|
||||
function state(overrides: Partial<ModelDirectoryState> = {}): ModelDirectoryState {
|
||||
return {
|
||||
current: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
|
||||
routable: true,
|
||||
groups: [{
|
||||
id: 'deepseek-official',
|
||||
name: 'DeepSeek',
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-models/README.md
|
||||
README.md: fdd27478be25d4352462c2db0fc7eead1d1aee77
|
||||
README.zh.md: c0fd66b003593e7a535e28751f1f6d0ee80dfac1
|
||||
README.md: fe8e9851978ebd900fa43eb28f52a5a803501b63
|
||||
README.zh.md: c11ec125c6bf896c32db74161a263ee2b73c88be
|
||||
|
||||
@@ -16,7 +16,7 @@ A pi-ai profile's `models` list is edited on the card: one row per model showing
|
||||
|
||||
**Fetch available models** asks `llm.discoverModels` about the endpoint the form **currently shows**, including a base URL edited but not yet saved and a key typed but not yet stored, so adding a provider is one pass instead of save-then-return. The reply opens a picker rather than being written: candidates already configured start unchecked, so adopting a selection never overwrites a capacity the user corrected. A provider that cannot be interrogated is a detour, not a dead end — the adapter's own message appears beside the rows, which stay editable by hand.
|
||||
|
||||
**Add a custom provider** declares a route pi-ai does not ship. It is its own card rather than the editor with extra fields, because the route id is being chosen here and the settings address does not exist until it is: one `settings.mutate` sets the whole profile at `providers.<route>`, and the key travels separately through `credentials.set` under the same `<ROUTE>_API_KEY` derivation an existing provider uses. What a hand-declared route cannot default gates the create button — a unique **Provider ID**, an endpoint, a protocol, and at least one uniquely-identified model — so the failure names the field while the user is still looking at it. Capacities do not gate it: the adapter's fallbacks size a model the endpoint described by id alone, which is what most listings return. The protocol choices are read out of the namespace's own schema rather than a wire field or a constant, so they cannot drift from the ones the adapter accepts. The card records the conventional `apiKeyEnv` reference only when a key is typed, the same rule the editor applies, so a route declared for provider-native authentication is not born pointing at a reference nothing will ever set. The card offers the same provider-level reasoning effort the editor card does for this namespace, from one shared control: both write the same profile field, so a route declared without it would have gained the setting only on being reopened.
|
||||
**Add a custom provider** declares a route pi-ai does not ship. It is its own card rather than the editor with extra fields, because the route id is being chosen here and the settings address does not exist until it is: one `settings.mutate` sets the whole profile at `providers.<route>`, and the key travels separately through `credentials.set` under the same `<ROUTE>_API_KEY` derivation an existing provider uses. What a hand-declared route cannot default gates the create button — a unique **Provider ID**, an endpoint, a protocol, and at least one uniquely-identified model — so the failure names the field while the user is still looking at it. Capacities do not gate it: the adapter's fallbacks size a model the endpoint described by id alone, which is what most listings return. The protocol choices are read out of the namespace's own schema rather than a wire field or a constant, so they cannot drift from the ones the adapter accepts. The card records the conventional `apiKeyEnv` reference only when a key is typed, the same rule the editor applies, so a route declared for provider-native authentication is not born pointing at a reference nothing will ever set. When the profile write lands but the key write fails, the provider already exists: the card settles the fields describing it, retries the credential alone — re-running the profile write would carry the revision that write just superseded, so the Host would answer `settings-conflict` and the key could never be stored from here — and reports the created provider even if the user then cancels. The card offers the same provider-level reasoning effort the editor card does for this namespace, from one shared control: both write the same profile field, so a route declared without it would have gained the setting only on being reopened.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ pi-ai profile 的 `models` 列表就在卡片上编辑:一行一个模型,
|
||||
|
||||
**获取可用模型**会针对表单**当前显示**的端点调用 `llm.discoverModels`,包括已修改但尚未保存的 API 地址和已键入但尚未存储的密钥,因此新增一个提供方是一趟走完,而不是「先保存再回来」。回复会打开一个选择框而不是直接写入:已配置过的候选默认不勾选,因此采纳一次选择绝不会覆盖用户已更正的容量。无法被询问的提供方只是绕路而非死路——适配器自己的消息会显示在各行旁边,而这些行仍可手工编辑。
|
||||
|
||||
**添加自定义提供方**用来声明 pi-ai 未提供的路由。它是独立的一张卡片而非在编辑器上加字段,因为路由 id 正是在这里被*选定*的,而在选定之前 settings 地址并不存在:一次 `settings.mutate` 在 `providers.<route>` 上设置整个 profile,密钥则经 `credentials.set` 单独传递,使用与既有提供方相同的 `<ROUTE>_API_KEY` 派生。手工声明的路由无法默认的东西会门控创建按钮——唯一的 **Provider ID**、端点、协议,以及至少一个由唯一标识的模型——因此失败会在用户仍看着该字段时点名它。容量不参与门控:端点只按 id 描述的模型(这正是多数列表返回的形态)由适配器的回退值定尺寸。协议选项读自该 namespace 自己的 schema,而非某个协议字段或常量,因此它们不会与适配器实际接受的集合发生漂移。只有键入了密钥,这张卡片才记录约定的 `apiKeyEnv` 引用,与编辑器同一条规则,因此一条为提供方原生认证声明的路由不会一出生就指向一个永远不会被设置的引用。这张卡片提供与编辑器卡片在该 namespace 下相同的提供方级推理等级,两者共用同一个控件:它们写的是同一个 profile 字段,若声明时没有它,这个设置就会等到重新打开编辑时才凭空出现。
|
||||
**添加自定义提供方**用来声明 pi-ai 未提供的路由。它是独立的一张卡片而非在编辑器上加字段,因为路由 id 正是在这里被*选定*的,而在选定之前 settings 地址并不存在:一次 `settings.mutate` 在 `providers.<route>` 上设置整个 profile,密钥则经 `credentials.set` 单独传递,使用与既有提供方相同的 `<ROUTE>_API_KEY` 派生。手工声明的路由无法默认的东西会门控创建按钮——唯一的 **Provider ID**、端点、协议,以及至少一个由唯一标识的模型——因此失败会在用户仍看着该字段时点名它。容量不参与门控:端点只按 id 描述的模型(这正是多数列表返回的形态)由适配器的回退值定尺寸。协议选项读自该 namespace 自己的 schema,而非某个协议字段或常量,因此它们不会与适配器实际接受的集合发生漂移。只有键入了密钥,这张卡片才记录约定的 `apiKeyEnv` 引用,与编辑器同一条规则,因此一条为提供方原生认证声明的路由不会一出生就指向一个永远不会被设置的引用。当 profile 写入成功而密钥写入失败时,提供方其实已经存在:卡片会把描述它的字段定住,只重试凭据——再跑一次 profile 写入会带着刚被自己这次写入取代的 revision,宿主将以 `settings-conflict` 应答,密钥就再也无法从这里存下——并且即使用户随后取消,也照实报告提供方已创建。这张卡片提供与编辑器卡片在该 namespace 下相同的提供方级推理等级,两者共用同一个控件:它们写的是同一个 profile 字段,若声明时没有它,这个设置就会等到重新打开编辑时才凭空出现。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -74,7 +74,15 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode {
|
||||
const [models, setModels] = useState<readonly ModelDraft[]>([])
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [failure, setFailure] = useState<string | undefined>(undefined)
|
||||
/**
|
||||
* The profile write landed. Only the key write can still be outstanding, so
|
||||
* the fields that describe the provider are settled and the retry path is
|
||||
* the credential alone.
|
||||
*/
|
||||
const [committed, setCommitted] = useState(false)
|
||||
const disabled = props.readOnly || busy
|
||||
/** Everything but the key stops being editable once the provider exists. */
|
||||
const profileDisabled = disabled || committed
|
||||
|
||||
const routeInvalid = route.length > 0 && !ROUTE_PATTERN.test(route)
|
||||
const routeTaken = taken.includes(route)
|
||||
@@ -98,34 +106,42 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode {
|
||||
/** Perform the create, returning a failure message or undefined. */
|
||||
const createOnce = async (): Promise<string | undefined> => {
|
||||
const keyRef = deriveKeyRef(route)
|
||||
const storesKey = keyDraft.trim().length > 0
|
||||
const profile = {
|
||||
...displayName.length === 0 ? {} : { displayName },
|
||||
// The profile names the conventional reference only when this card is
|
||||
// about to store a key, matching the editor: a route declared with the
|
||||
// key left blank keeps its provider-native auth path (a credential
|
||||
// chain, ADC) instead of resolving a reference nothing ever sets.
|
||||
...storesKey ? { apiKeyEnv: keyRef } : {},
|
||||
api: protocol,
|
||||
baseURL,
|
||||
// Inherit is the field being absent, not an empty string: the schema
|
||||
// types it as an effort name, and an empty one would fail the write.
|
||||
...effort === undefined ? {} : { [EFFORT_FIELD['pi-ai']]: effort },
|
||||
models: models.map(model => ({ ...model })),
|
||||
const normalizedKey = keyDraft.trim()
|
||||
const storesKey = normalizedKey.length > 0
|
||||
if (!committed) {
|
||||
const profile = {
|
||||
...displayName.length === 0 ? {} : { displayName },
|
||||
// The profile names the conventional reference only when this card is
|
||||
// about to store a key, matching the editor: a route declared with the
|
||||
// key left blank keeps its provider-native auth path (a credential
|
||||
// chain, ADC) instead of resolving a reference nothing ever sets.
|
||||
...storesKey ? { apiKeyEnv: keyRef } : {},
|
||||
api: protocol,
|
||||
baseURL,
|
||||
// Inherit is the field being absent, not an empty string: the schema
|
||||
// types it as an effort name, and an empty one would fail the write.
|
||||
...effort === undefined ? {} : { [EFFORT_FIELD['pi-ai']]: effort },
|
||||
models: models.map(model => ({ ...model })),
|
||||
}
|
||||
const response = await api.settings.mutate({
|
||||
ns: NS,
|
||||
ops: [{ op: 'set', path: ['providers', route], value: profile }],
|
||||
// `taken` is a snapshot too, so the id check alone cannot see a route
|
||||
// declared after this card opened; the revision makes that race a
|
||||
// `settings-conflict` instead of a write over the other profile.
|
||||
expectedRevision: openedAt,
|
||||
})
|
||||
if (!response.result.ok) return response.result.error.message
|
||||
// The provider now exists. A retry after the key write below fails must
|
||||
// not re-run this mutate: the revision it holds is the one this write
|
||||
// just superseded, so the Host would answer `settings-conflict` and the
|
||||
// key could never be stored from this card at all.
|
||||
setCommitted(true)
|
||||
}
|
||||
const response = await api.settings.mutate({
|
||||
ns: NS,
|
||||
ops: [{ op: 'set', path: ['providers', route], value: profile }],
|
||||
// `taken` is a snapshot too, so the id check alone cannot see a route
|
||||
// declared after this card opened; the revision makes that race a
|
||||
// `settings-conflict` instead of a write over the other profile.
|
||||
expectedRevision: openedAt,
|
||||
})
|
||||
if (!response.result.ok) return response.result.error.message
|
||||
if (storesKey) {
|
||||
const stored = await api.credentials.set({ ref: keyRef, value: keyDraft })
|
||||
const stored = await api.credentials.set({ ref: keyRef, value: normalizedKey })
|
||||
// The profile landed; saying the key did not is the only honest report,
|
||||
// and the row is now editable so the key can be entered again there.
|
||||
// and the retry above now goes straight back to this write.
|
||||
if (!stored.result.ok) return stored.result.error.message
|
||||
}
|
||||
return undefined
|
||||
@@ -163,7 +179,7 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode {
|
||||
value={route}
|
||||
placeholder="acme-gateway"
|
||||
aria-label={t('customRoute')}
|
||||
disabled={disabled}
|
||||
disabled={profileDisabled}
|
||||
onChange={(event) => { setRoute(event.target.value) }}
|
||||
/>
|
||||
</div>
|
||||
@@ -178,7 +194,7 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode {
|
||||
value={displayName}
|
||||
placeholder={route.length === 0 ? t('customDisplayName') : route}
|
||||
aria-label={t('customDisplayName')}
|
||||
disabled={disabled}
|
||||
disabled={profileDisabled}
|
||||
onChange={(event) => { setDisplayName(event.target.value) }}
|
||||
/>
|
||||
</div>
|
||||
@@ -190,7 +206,7 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode {
|
||||
value={baseURL}
|
||||
placeholder="https://gateway.example/v1"
|
||||
aria-label={t('baseUrl')}
|
||||
disabled={disabled}
|
||||
disabled={profileDisabled}
|
||||
onChange={(event) => { setBaseURL(event.target.value) }}
|
||||
/>
|
||||
</div>
|
||||
@@ -200,7 +216,7 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode {
|
||||
className={styles['input']}
|
||||
value={protocol}
|
||||
aria-label={t('customApi')}
|
||||
disabled={disabled}
|
||||
disabled={profileDisabled}
|
||||
onChange={(event) => { setProtocol(event.target.value) }}
|
||||
>
|
||||
{protocols.map(choice => <option key={choice} value={choice}>{choice}</option>)}
|
||||
@@ -226,7 +242,7 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode {
|
||||
value={effort ?? ''}
|
||||
onChange={setEffort}
|
||||
t={t}
|
||||
disabled={disabled}
|
||||
disabled={profileDisabled}
|
||||
/>
|
||||
<ModelListEditor
|
||||
models={models}
|
||||
@@ -239,7 +255,7 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode {
|
||||
}}
|
||||
api={api}
|
||||
t={t}
|
||||
disabled={disabled}
|
||||
disabled={profileDisabled}
|
||||
/>
|
||||
{failure !== undefined ? <p className={styles['error']}>{failure}</p> : null}
|
||||
{/* Only the gates with something to say render; the route-id gate has its
|
||||
@@ -251,7 +267,7 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode {
|
||||
submitDisabled={disabled || !ready}
|
||||
submitLabel="create"
|
||||
submitBusyLabel="creating"
|
||||
onCancel={() => { props.onClose(false) }}
|
||||
onCancel={() => { props.onClose(committed) }}
|
||||
onSubmit={() => { void create() }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -650,8 +650,11 @@ describe('provider rows', () => {
|
||||
})
|
||||
|
||||
describe('hand-declared providers', () => {
|
||||
function mountCard(overrides: Partial<Parameters<typeof CustomProviderCard>[0]> = {}) {
|
||||
const scripted = scriptedFace()
|
||||
function mountCard(
|
||||
overrides: Partial<Parameters<typeof CustomProviderCard>[0]> = {},
|
||||
wire: Parameters<typeof scriptedFace>[0] = {},
|
||||
) {
|
||||
const scripted = scriptedFace(wire)
|
||||
const onClose = vi.fn()
|
||||
render(
|
||||
<CustomProviderCard
|
||||
@@ -736,6 +739,60 @@ describe('hand-declared providers', () => {
|
||||
expect(firstMutate(second.mutate).ops[0]).not.toHaveProperty('value.reasoning')
|
||||
})
|
||||
|
||||
it('retries only the key after the profile landed, and reports the provider on cancel', async () => {
|
||||
const set = vi.fn()
|
||||
.mockResolvedValueOnce(fail('credential store is read-only', 'credential-rejected'))
|
||||
.mockResolvedValueOnce(ok({}))
|
||||
const { mutate, onClose } = mountCard({}, { set })
|
||||
|
||||
fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } })
|
||||
fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://acme.test/v1' } })
|
||||
fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: ' gw-key ' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: en.addModel }))
|
||||
fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'm' } })
|
||||
fireEvent.click(screen.getByText(en.create))
|
||||
|
||||
// The profile landed; only the key failed. The card says so and stays open.
|
||||
await waitFor(() => { expect(screen.getByText('credential store is read-only')).toBeTruthy() })
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
expect(mutate).toHaveBeenCalledTimes(1)
|
||||
// The key is stored trimmed, matching the editor.
|
||||
expect(set).toHaveBeenNthCalledWith(1, { ref: 'ACME_API_KEY', value: 'gw-key' })
|
||||
|
||||
// The provider exists now, so the fields describing it are settled and
|
||||
// only the key can still be corrected.
|
||||
expect(screen.getByLabelText<HTMLInputElement>(en.customRoute).disabled).toBe(true)
|
||||
expect(screen.getByLabelText<HTMLInputElement>(en.baseUrl).disabled).toBe(true)
|
||||
expect(screen.getByLabelText<HTMLInputElement>(en.keyInput).disabled).toBe(false)
|
||||
|
||||
fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: 'gw-key-2' } })
|
||||
fireEvent.click(screen.getByText(en.create))
|
||||
await waitFor(() => { expect(onClose).toHaveBeenCalledWith(true) })
|
||||
// Re-running the profile write would carry the revision this card's own
|
||||
// first write superseded, so the Host would answer settings-conflict and
|
||||
// the key could never be stored from here at all.
|
||||
expect(mutate).toHaveBeenCalledTimes(1)
|
||||
expect(set).toHaveBeenNthCalledWith(2, { ref: 'ACME_API_KEY', value: 'gw-key-2' })
|
||||
})
|
||||
|
||||
it('reports the created provider when cancelled after its profile landed', async () => {
|
||||
const set = vi.fn().mockResolvedValue(fail('nope', 'credential-rejected'))
|
||||
const { onClose } = mountCard({}, { set })
|
||||
|
||||
fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } })
|
||||
fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://acme.test/v1' } })
|
||||
fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: 'gw-key' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: en.addModel }))
|
||||
fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'm' } })
|
||||
fireEvent.click(screen.getByText(en.create))
|
||||
await waitFor(() => { expect(screen.getByText('nope')).toBeTruthy() })
|
||||
|
||||
// Walking away leaves a real provider behind; reporting no change would
|
||||
// leave the page without the row it now has.
|
||||
fireEvent.click(screen.getByText(en.cancel))
|
||||
expect(onClose).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('names the blocked gate under the form, and nothing once it is satisfied', () => {
|
||||
mountCard()
|
||||
fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } })
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md
|
||||
README.md: 38f18995f2982db2c5a48971d7d044448e5adc8c
|
||||
README.zh.md: c444ed6b7485b6ddca059c5edf7f30828bd96ab7
|
||||
README.md: 9e01423a36803477cb07d944e058fc388b5e72fd
|
||||
README.zh.md: d4df79d7d8850c466f1ccc4c53097a15739013ea
|
||||
|
||||
@@ -10,7 +10,9 @@ The API gateway every client shape shares: the TS contract (`src/api/`, zero Nod
|
||||
|
||||
A session resolves its route from three tiers, re-read on every access rather than seeded once: a selection made in this process, else the session's own latest logged `request/header`, else this default. Re-reading is what makes both directions hold — a session that has run a turn derives its route from its log forever after, so changing the default never retargets it, while a session still blank (New Session reuses one rather than minting another) starts from a default saved after it was created.
|
||||
|
||||
`session.selectModel` records an accepted switch as the new default, which is how the default is chosen in practice: there is no separate gesture. The write replaces the section wholesale rather than merging, because switching to a model with no reasoning effort has to clear a stored one; a storage failure is logged without undoing the switch, which already applies to its own session. A deployment with no settings provider keeps the composition entry and a switch stays process-local.
|
||||
`session.selectModel` records an accepted switch as the new default, which is how the default is chosen in practice: there is no separate gesture. What it stores is the RESOLVED target, so an adapter-materialized default effort is pinned as the user saw it and a later adapter-default change does not silently move stored defaults. The write replaces the section wholesale rather than merging, because switching to a model with no reasoning effort has to clear a stored one; a storage failure is logged without undoing the switch, which already applies to its own session. A deployment with no settings provider keeps the composition entry and a switch stays process-local.
|
||||
|
||||
The section's `reasoningEffort` has no counterpart in the plugin config, deliberately: the seam merges the user layer over the composition entry per field, so an absent key cannot override a present one and a composition-set effort would survive every later switch to a model without one. A deployment default for effort belongs on the adapter profile, which resolves per model.
|
||||
|
||||
The stored route is not validated against the registry, in either direction. A default naming a route the Models page has since removed still reaches `session.models` as the session's `current` — matching no advertised group, which is precisely what makes a selector prompt for a replacement instead of naming a model the deployment cannot reach. Repairing it silently would also break the deliberate converse: an adapter may serve a model its catalog does not advertise.
|
||||
|
||||
@@ -30,7 +32,7 @@ Session titles ride the generic projection pair like every other domain — the
|
||||
|
||||
`session.fork` maps an optional event anchor to the first `turn/end` at or after it, letting a message action include that message's whole turn. An omitted or past-end anchor selects the last completed turn; an in-log anchor whose turn remains open returns `fork-unavailable` rather than clipping backward. The published child inherits the source's seeded history, cwd, latest logged provider/model/reasoning target, and lineage before joining the source Workspace. If Workspace attachment fails, `workspace-attach-failed` carries the already-published child id so clients can reconcile it. The [SessionStore fork decision](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md) owns the boundary rationale.
|
||||
|
||||
Session model routing is a session-domain contract. `session.models` returns the selected provider/model/reasoning target separately from provider-grouped advisory models, exact-route reasoning metadata, and provider-local lookup failures. The current target may be absent from the groups and is never injected as a synthetic row; clients can prompt for a replacement without turning the directory into a routing whitelist. `session.selectModel` validates the optional adapter-owned reasoning effort and replaces the complete target selected for the next prompt-assembly boundary. Catalog membership is not validation: an adapter may resolve an unlisted model, while an unavailable route or unsupported effort returns `model-unavailable`.
|
||||
Session model routing is a session-domain contract. `session.models` returns the selected provider/model/reasoning target separately from provider-grouped advisory models, exact-route reasoning metadata, and provider-local lookup failures. The current target may be absent from the groups and is never injected as a synthetic row; clients can prompt for a replacement without turning the directory into a routing whitelist. `session.selectModel` validates the optional adapter-owned reasoning effort and replaces the complete target selected for the next prompt-assembly boundary. Catalog membership is not validation: an adapter may resolve an unlisted model, while an unavailable route or unsupported effort returns `model-unavailable`. `session.models` additionally reports `routable`: whether an adapter currently serves the current target's route, which is deliberately NOT derivable from the groups — a route serving a model it stopped advertising is absent from them yet perfectly usable, while a route whose adapter is gone can serve nothing. `session.prompt` refuses on that same fact with `model-unavailable` rather than spending the pre-step path to fail inside an adapter; a client that disables its composer is an affordance, and this method stays callable regardless.
|
||||
|
||||
Pending queued input is a live control-plane contract, not conversation history. The gateway derives the complete `next-turn` queue from durable `agent/inbox/spliced` mutations and broadcasts authoritative `session/queue` snapshots after each change and on reconnect; pending `next-step` steering stays outside this Web projection. Within `next-step`, user-origin messages carry the `steering` placement while injected context (approval notices, task completion, attached snapshots) carries `context` and is not surfaced until claimed. The message-local `agent/inbox/inserted`, `claimed`, and `discarded` notifications remain available to lifecycle observers but do not build the queue view. `session.updateQueue` addresses one `MessageId`; edit and remove mutate the attached Agent through `Inbox.splice()`. A claim's pure deletion splice wins races before pre-step admission, so a later operation returns `queue-item-not-found`. `session.cancel` aborts only the active turn and preserves pending inbox work; after cancellation reaches quiescence and the closing turn flushes, AgentLoop claims the next waking message in FIFO order, and the browser never resends or promotes it. Queue operations never resume a cold session, and the client never infers retirement from turn or status events.
|
||||
|
||||
|
||||
@@ -10,7 +10,9 @@
|
||||
|
||||
会话按三级解析自己的路由,且每次读取都重新解析,而不是只在创建时种一次:本进程内的显式选择,其次是该会话自己最新记录的 `request/header`,最后才是这个默认值。重新解析正是让两个方向都成立的原因——已经跑过一轮的会话此后永远从自己的日志推导路由,改默认值不会重定向它;而仍然空白的会话(新建会话会复用一个,而不是再开一个)则会用上它创建之后才保存的默认值。
|
||||
|
||||
`session.selectModel` 会把被接受的切换记录为新的默认值,实践中默认值就是这样选定的,没有另一个单独的手势。写入是整段替换而非合并,因为切到一个不支持推理的模型必须清掉已存的等级;存储失败只记日志,不会撤销这次切换——它对自己所在的会话已经生效。没有设置提供方的部署保留组合条目,切换只停留在进程内。
|
||||
`session.selectModel` 会把被接受的切换记录为新的默认值,实践中默认值就是这样选定的,没有另一个单独的手势。它存下来的是**解析后**的目标,因此适配器实体化出来的默认推理等级会按用户当时看到的样子钉住,日后适配器改了自己的默认值也不会悄悄移动已存的默认路由。写入是整段替换而非合并,因为切到一个不支持推理的模型必须清掉已存的等级;存储失败只记日志,不会撤销这次切换——它对自己所在的会话已经生效。没有设置提供方的部署保留组合条目,切换只停留在进程内。
|
||||
|
||||
设置段里的 `reasoningEffort` 在插件配置中刻意没有对应字段:seam 是按字段把用户层合并到组合条目之上的,缺席的键覆盖不了存在的键,因此组合层设的推理等级会在此后每一次切到不支持推理的模型时继续存活。推理等级的部署级默认值属于适配器 profile,那里是按模型解析的。
|
||||
|
||||
存下来的路由不做注册表校验,两个方向都不做。默认值指向一个已在模型页删除的路由时,它照样作为会话的 `current` 送到 `session.models`——匹配不到任何已公布的分组,而这恰恰是让选择器提示重新选择、而不是显示一个部署根本够不着的模型的原因。静默修复它还会破坏刻意保留的反面情形:适配器可以服务一个自己目录未公布的模型。
|
||||
|
||||
@@ -30,7 +32,7 @@
|
||||
|
||||
`session.fork` 将可选事件锚点映射到该锚点处或其后的首个 `turn/end`,使消息操作可包含该消息所在的完整轮次。锚点省略或超过末尾时,选择最后一个已完成轮次;若锚点已在日志中,而其所在轮次仍开放,则返回 `fork-unavailable`,不会向较早位置裁剪。发布后的子会话会先继承源会话的种子历史、cwd、日志中最新的提供方/模型/推理(reasoning)目标及谱系,再加入源 Workspace。如果附加到 Workspace 失败,`workspace-attach-failed` 会携带已发布的子会话 id,供客户端对账。[SessionStore fork 决策](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md)给出边界设计的理由。
|
||||
|
||||
会话模型路由属于会话领域契约。`session.models` 将选中的提供方/模型/推理目标,与按提供方分组的建议性模型、精确路由推理元数据和逐提供方查询失败记录分开返回。当前目标可能不在这些分组中,也绝不会作为合成行注入;客户端可以提示用户选择替代目标,而无需把目录变成路由白名单。`session.selectModel` 校验由适配器持有的可选推理强度,并替换将在下一提示词组装边界使用的完整目标。目录成员关系不构成校验:适配器可以解析未列出的模型,而不可用路由或不受支持的推理强度会返回 `model-unavailable`。
|
||||
会话模型路由属于会话领域契约。`session.models` 将选中的提供方/模型/推理目标,与按提供方分组的建议性模型、精确路由推理元数据和逐提供方查询失败记录分开返回。当前目标可能不在这些分组中,也绝不会作为合成行注入;客户端可以提示用户选择替代目标,而无需把目录变成路由白名单。`session.selectModel` 校验由适配器持有的可选推理强度,并替换将在下一提示词组装边界使用的完整目标。目录成员关系不构成校验:适配器可以解析未列出的模型,而不可用路由或不受支持的推理强度会返回 `model-unavailable`。`session.models` 还会报告 `routable`:当前目标的路由是否有适配器在服务。这一点刻意不由分组推导——一条仍在服务、只是不再公布该模型的路由不在分组里,却完全可用;而适配器已经消失的路由什么都服务不了。`session.prompt` 依据同一个事实以 `model-unavailable` 拒绝,而不是把整条 pre-step 路径走完再在适配器内部失败;客户端禁用输入框只是提示性设计,这个方法始终可被调用。
|
||||
|
||||
待处理的 queued 输入属于实时控制平面契约,而非对话历史。网关根据持久 `agent/inbox/spliced` 变更派生完整的 `next-turn` 队列,并在每次变更后及重连时广播权威 `session/queue` 快照;待处理的 `next-step` steering(中途引导)不进入此 Web 投影。在 `next-step` 内,用户来源的消息携带 `steering` placement,而注入上下文(审批通知、任务完成、附加快照)携带 `context`,领取前不对外呈现。面向单条消息的 `agent/inbox/inserted`、`claimed` 与 `discarded` 通知仍供生命周期观察方使用,但不用于构建队列视图。`session.updateQueue` 通过 `MessageId` 寻址单个项;编辑和移除经已挂载 Agent 的 `Inbox.splice()` 修改队列。claim 的纯删除 splice 会在 pre-step 准入前赢得竞态,因此之后的操作返回 `queue-item-not-found`。`session.cancel` 仅中止活动轮次并保留待处理 inbox 工作;取消达到完全停稳且结束中的轮次完成 flush 后,AgentLoop 按 FIFO 顺序认领下一条可唤醒消息,浏览器绝不重发或提升它。队列操作绝不恢复冷会话,客户端也绝不根据轮次或状态事件推断某项已退出队列。
|
||||
|
||||
|
||||
@@ -74,6 +74,14 @@ import { openNativePath, openNativeTextFile } from './native-path-opener.ts'
|
||||
/** Page size when history is called without maxMessages. */
|
||||
const DEFAULT_MAX_MESSAGES = 50
|
||||
|
||||
/**
|
||||
* The settings namespace carrying the user's default route. Named for the
|
||||
* gateway rather than for the package, because this key is what a person reads
|
||||
* and writes in `settings.yaml`; the row id in a composition happens to match
|
||||
* but does not determine it.
|
||||
*/
|
||||
export const API_GATEWAY_SETTINGS_NAMESPACE = settingsNamespace('api-gateway')
|
||||
|
||||
/** Non-model settings namespaces intentionally served to the Web client. */
|
||||
const WEB_SETTINGS_NAMESPACES = ['permission'] as const
|
||||
|
||||
@@ -337,9 +345,11 @@ export interface ApiProxyDefaults {
|
||||
*/
|
||||
defaultTarget: () => AgentLlmTarget
|
||||
/**
|
||||
* Record a selection as the new default. Absent when the deployment stores
|
||||
* no user settings, in which case a switch stays process-local. A rejection
|
||||
* is reported and swallowed: the switch already applies to its own session,
|
||||
* Record a selection as the new default. Either absent, or a closure that
|
||||
* may itself decline — the gateway plugin always passes one, and it no-ops
|
||||
* when the deployment mounts no settings provider or when the write races
|
||||
* service teardown. A switch then stays process-local. A rejection is
|
||||
* reported and swallowed: the switch already applies to its own session,
|
||||
* and undoing it because storage failed would be the worse outcome.
|
||||
*/
|
||||
persistDefaultTarget?: (target: AgentLlmTarget) => Promise<void>
|
||||
@@ -1330,6 +1340,19 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an adapter currently serves this route, and therefore whether a
|
||||
* session pointed at it can start a turn. Catalog membership cannot answer
|
||||
* it: an adapter may serve a model its own catalog stopped advertising, so
|
||||
* a route missing from the groups is not the same as one nothing serves.
|
||||
* A composition with no llm registry at all cannot judge and says yes —
|
||||
* the dispatch it would have refused fails on its own terms.
|
||||
*/
|
||||
function routeServed(provider: string): boolean {
|
||||
const llm = ctx.get('llm')
|
||||
return llm === undefined || llm.listProviders().some(entry => entry.id === provider)
|
||||
}
|
||||
|
||||
/** Missing-service report shared by the settings domain (skills-domain stance). */
|
||||
function settingsAbsent(): RpcError {
|
||||
return { code: 'internal', message: 'settings service is absent: this deployment does not mount a settings provider (e.g. @deepseek-ai/dsh-settings-local) in its composition', details: {} }
|
||||
@@ -1700,7 +1723,8 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
if ('error' in found) return err(request, found.error)
|
||||
const current = targetFor(found.agent).current
|
||||
const { groups, failures } = await buildModelCatalog(ctx)
|
||||
return ok(request, { current: { ...current }, groups, failures })
|
||||
const routable = routeServed(current.provider)
|
||||
return ok(request, { current: { ...current }, routable, groups, failures })
|
||||
},
|
||||
|
||||
async selectModel(request) {
|
||||
@@ -1868,6 +1892,20 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
const found = await agentFor(sessionId)
|
||||
if ('error' in found) return err(request, found.error)
|
||||
const agent = found.agent
|
||||
// A route no adapter serves cannot start a turn, and letting it try
|
||||
// spends the whole pre-step path to fail inside the adapter with a
|
||||
// message about registration. Refusing here names the model the
|
||||
// session is pointed at while the draft is still in the composer.
|
||||
// This is the enforcement boundary: a client that disables its input
|
||||
// is an affordance, and this method stays callable regardless.
|
||||
const target = targetFor(agent).current
|
||||
if (!routeServed(target.provider)) {
|
||||
return err(request, {
|
||||
code: 'model-unavailable',
|
||||
message: `no adapter serves provider "${target.provider}"; select a model for this session`,
|
||||
details: { provider: target.provider, model: target.model },
|
||||
})
|
||||
}
|
||||
// The rpcId rides MessageSource into user/message (merge declaration in api/sessions.ts; provisional correlation).
|
||||
const source: MessageSource = { kind: 'user', rpcId: request.rpcId }
|
||||
try {
|
||||
@@ -2758,8 +2796,14 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
queue.push(frame({ type: 'host/settings-changed', ns: name }))
|
||||
// A provider's own settings carry its model catalog and endpoint,
|
||||
// so a change there invalidates the model list even when the route
|
||||
// set is untouched — `llm/adapters-updated` alone misses it.
|
||||
if (modelProviderNamespaces().has(name)) queue.push(frame({ type: 'host/models-changed' }))
|
||||
// set is untouched — `llm/adapters-updated` alone misses it. The
|
||||
// gateway's own section is the other such source: it names the
|
||||
// route every session with no logged one resolves to, so an
|
||||
// externally edited default (another tab, a hand-edited
|
||||
// settings.yaml) has to reach an open selector too.
|
||||
if (modelProviderNamespaces().has(name) || name === String(API_GATEWAY_SETTINGS_NAMESPACE)) {
|
||||
queue.push(frame({ type: 'host/models-changed' }))
|
||||
}
|
||||
}),
|
||||
ctx.on('credentials/updated', (ref) => {
|
||||
queue.push(frame({ type: 'host/credentials-changed', ref: String(ref) }))
|
||||
|
||||
@@ -225,6 +225,7 @@ export const sessionModelsRequestSchema = z.object({
|
||||
/** session.models response value. */
|
||||
export const sessionModelsValueSchema = z.object({
|
||||
current: modelTargetSchema,
|
||||
routable: z.boolean(),
|
||||
groups: z.array(modelProviderGroupSchema),
|
||||
failures: z.array(modelCatalogFailureSchema),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'session.models'>>>
|
||||
|
||||
@@ -117,6 +117,15 @@ export interface ModelCatalogFailure {
|
||||
export interface SessionModels {
|
||||
/** Target selected for the session's next assembled step. */
|
||||
current: ModelTarget
|
||||
/**
|
||||
* Whether an adapter currently serves `current.provider`, and therefore
|
||||
* whether this session can start a turn at all. Deliberately NOT derivable
|
||||
* from `groups`: catalog membership is advisory, so a route serving a model
|
||||
* it stopped advertising is absent from the groups yet perfectly usable,
|
||||
* while a route whose adapter is gone can serve nothing. A surface that
|
||||
* blocks input must read this rather than the groups.
|
||||
*/
|
||||
routable: boolean
|
||||
/** Successfully loaded provider groups. */
|
||||
groups: ModelProviderGroup[]
|
||||
/** Provider-local failures; successful groups remain usable. */
|
||||
|
||||
@@ -19,16 +19,16 @@ import { Context, Service } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type { AgentLlmTarget } from '@deepseek-ai/dsh-agent'
|
||||
import { ReasoningEffortId } from '@deepseek-ai/dsh-llm'
|
||||
import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import { installSettingsSection } from '@deepseek-ai/dsh-settings'
|
||||
import type { ApiProxy } from './api/index.ts'
|
||||
import { createApiProxy } from './api-proxy.ts'
|
||||
import { API_GATEWAY_SETTINGS_NAMESPACE, createApiProxy } from './api-proxy.ts'
|
||||
|
||||
export type * from './api/index.ts'
|
||||
export { RpcId } from './api/rpc.ts'
|
||||
export { toFetchHandler } from './fetch/handler.ts'
|
||||
export { AbstractApiClient, InProcessApiClient } from './fetch/client.ts'
|
||||
export type { IApiClient } from './fetch/client.ts'
|
||||
export { createApiProxy } from './api-proxy.ts'
|
||||
export { API_GATEWAY_SETTINGS_NAMESPACE, createApiProxy } from './api-proxy.ts'
|
||||
export type { ApiProxyDefaults } from './api-proxy.ts'
|
||||
|
||||
declare module 'cordis' {
|
||||
@@ -39,17 +39,9 @@ declare module 'cordis' {
|
||||
}
|
||||
|
||||
/**
|
||||
* The settings namespace carrying the user's default route. Named for the
|
||||
* gateway rather than for the package, because this key is what a person reads
|
||||
* and writes in `settings.yaml`; the row id in a composition happens to match
|
||||
* but does not determine it.
|
||||
*/
|
||||
export const API_GATEWAY_SETTINGS_NAMESPACE = settingsNamespace('api-gateway')
|
||||
|
||||
/**
|
||||
* The user-settable slice of the gateway config: the route a session starts
|
||||
* from when its own log names none. `workspaceRoot` is deliberately not part
|
||||
* of it — that is a launcher fact, not a preference.
|
||||
* The `api-gateway` settings section: the route a session starts from when its
|
||||
* own log names none. `workspaceRoot` is deliberately not part of it — that is
|
||||
* a launcher fact, not a preference.
|
||||
*/
|
||||
export interface DefaultRouteSettings {
|
||||
/** Default provider route for created agents. */
|
||||
@@ -60,29 +52,36 @@ export interface DefaultRouteSettings {
|
||||
reasoningEffort?: string
|
||||
}
|
||||
|
||||
/** Gateway plugin config: host-level agent routing and Workspace creation root. */
|
||||
export interface Config extends DefaultRouteSettings {
|
||||
/**
|
||||
* Gateway plugin config: host-level agent routing and Workspace creation root.
|
||||
*
|
||||
* `reasoningEffort` is deliberately absent, so the section carries one field
|
||||
* the composition cannot. The seam resolves a section by MERGING the user
|
||||
* layer over the composition entry per field, and an absent key cannot
|
||||
* override a present one — so a composition-set effort would survive every
|
||||
* later switch to a model that has none, and strand it for the next session
|
||||
* to fail on. Effort is a per-model fact anyway: a deployment default belongs
|
||||
* on the adapter profile (`llm-pi-ai`'s `reasoning`, `llm-deepseek`'s own),
|
||||
* which resolves per model rather than per gateway.
|
||||
*/
|
||||
export interface Config {
|
||||
/** Default provider route for created agents. */
|
||||
provider: string
|
||||
/** Default model id. */
|
||||
model: string
|
||||
/** Parent directory for name-created Workspaces; defaults to the Host cwd. */
|
||||
workspaceRoot?: string
|
||||
}
|
||||
|
||||
/** The config fields the settings section carries; the rest stay launcher-owned. */
|
||||
const DEFAULT_ROUTE_FIELDS = ['provider', 'model', 'reasoningEffort'] as const
|
||||
|
||||
/**
|
||||
* The settings section's schema, picked out of the plugin config rather than
|
||||
* restated. The config stays a plain literal because the configuration-catalog
|
||||
* generator reads it statically; picking from it is what keeps the section a
|
||||
* subset of it as both evolve.
|
||||
* @param config - the plugin config schema to pick from.
|
||||
* @returns the section schema over {@link DEFAULT_ROUTE_FIELDS}.
|
||||
* Schema of the `api-gateway` section, exported because it IS that section's
|
||||
* contract — the shape anything reading or writing `settings.yaml` addresses.
|
||||
*/
|
||||
function defaultRouteSchema(config: z<Config>): z<DefaultRouteSettings> {
|
||||
const fields = Object.fromEntries(
|
||||
DEFAULT_ROUTE_FIELDS.map(field => [field, config.dict?.[field]]),
|
||||
)
|
||||
return z.object(fields) as z<DefaultRouteSettings>
|
||||
}
|
||||
export const DEFAULT_ROUTE_SCHEMA: z<DefaultRouteSettings> = z.object({
|
||||
provider: z.string().required(),
|
||||
model: z.string().required(),
|
||||
reasoningEffort: z.string(),
|
||||
})
|
||||
|
||||
/** Project the stored/composed section onto the agent-facing target shape. */
|
||||
function routeTarget(settings: DefaultRouteSettings): AgentLlmTarget {
|
||||
@@ -109,7 +108,6 @@ export class ApiProxyService extends Service implements ApiProxy {
|
||||
static Config: z<Config> = z.object({
|
||||
provider: z.string().required(),
|
||||
model: z.string().required(),
|
||||
reasoningEffort: z.string(),
|
||||
workspaceRoot: z.string(),
|
||||
})
|
||||
|
||||
@@ -132,13 +130,9 @@ export class ApiProxyService extends Service implements ApiProxy {
|
||||
// The composition entry is the shipped default; the settings section
|
||||
// layers the user's own choice over it, and a deployment without a
|
||||
// settings provider simply keeps the entry.
|
||||
const entry: DefaultRouteSettings = {
|
||||
provider: config.provider,
|
||||
model: config.model,
|
||||
...config.reasoningEffort === undefined ? {} : { reasoningEffort: config.reasoningEffort },
|
||||
}
|
||||
const entry: DefaultRouteSettings = { provider: config.provider, model: config.model }
|
||||
let route: () => DefaultRouteSettings = () => entry
|
||||
installSettingsSection(ctx, API_GATEWAY_SETTINGS_NAMESPACE, defaultRouteSchema(ApiProxyService.Config), entry, {
|
||||
installSettingsSection(ctx, API_GATEWAY_SETTINGS_NAMESPACE, DEFAULT_ROUTE_SCHEMA, entry, {
|
||||
setSource: (current) => {
|
||||
route = current
|
||||
},
|
||||
@@ -150,8 +144,10 @@ export class ApiProxyService extends Service implements ApiProxy {
|
||||
defaultTarget: () => routeTarget(route()),
|
||||
// Wholesale, never a merge: switching to a model with no reasoning
|
||||
// effort must clear a stored one, and a merged patch would strand it
|
||||
// for the next session to fail on. The section holds no secrets, so
|
||||
// there is nothing a replace can collaterally drop.
|
||||
// for the next session to fail on. This clears it because the entry
|
||||
// below the user layer carries no effort to re-inherit — the reason
|
||||
// `Config` deliberately has no such field. The section holds no
|
||||
// secrets, so there is nothing a replace can collaterally drop.
|
||||
persistDefaultTarget: async (target) => {
|
||||
await ctx.get('settings')?.replace(API_GATEWAY_SETTINGS_NAMESPACE, target)
|
||||
},
|
||||
|
||||
@@ -22,7 +22,7 @@ import type { CredentialInfo, CredentialRef, ResolvedCredential } from '@deepsee
|
||||
import type { HostFrame } from '../src/api/index.ts'
|
||||
import type { RpcRequest, RpcResponse } from '../src/api/rpc.ts'
|
||||
import { RpcId } from '../src/api/rpc.ts'
|
||||
import { createApiProxy } from '../src/api-proxy.ts'
|
||||
import { API_GATEWAY_SETTINGS_NAMESPACE, createApiProxy } from '../src/api-proxy.ts'
|
||||
|
||||
const DEFAULTS = { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }
|
||||
|
||||
@@ -398,6 +398,25 @@ describe('settings domain', () => {
|
||||
expect(frames).toEqual([{ type: 'host/settings-changed', ns: 'permission' }])
|
||||
})
|
||||
|
||||
it('invalidates the model catalog when the gateway default route changes', async () => {
|
||||
const ctx = await harness()
|
||||
const route = ctx.settings.register(API_GATEWAY_SETTINGS_NAMESPACE, z.object({
|
||||
provider: z.string().required(),
|
||||
model: z.string().required(),
|
||||
}), { base: { provider: 'deepseek-official', model: 'deepseek-v4-flash' } })
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
// The gateway's own section names the route every session with no logged
|
||||
// one resolves to, so an externally edited default — another tab, a
|
||||
// hand-edited settings.yaml — has to reach an open selector as well.
|
||||
const frames = await collectHost(api, ['host/settings-changed', 'host/models-changed'], 2, async () => {
|
||||
await route.replace({ provider: 'deepseek-official', model: 'deepseek-reasoner' })
|
||||
})
|
||||
expect(frames).toEqual([
|
||||
{ type: 'host/settings-changed', ns: 'api-gateway' },
|
||||
{ type: 'host/models-changed' },
|
||||
])
|
||||
})
|
||||
|
||||
it('maps a stale expectedRevision to settings-conflict carrying both revisions', async () => {
|
||||
const ctx = await harness()
|
||||
ctx.settings.register(NS, AdapterConfig)
|
||||
|
||||
108
packages/host/apiproxy/tests/api-proxy-default-route.spec.ts
Normal file
108
packages/host/apiproxy/tests/api-proxy-default-route.spec.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* The `api-gateway` settings section over a REAL settings provider: the
|
||||
* composition entry as the base layer, the wholesale replace the gateway
|
||||
* persists with, and the fallback when the provider detaches. The other model
|
||||
* specs drive hand-rolled `defaultTarget`/`persistDefaultTarget` closures, so
|
||||
* this is the only place the layering itself is exercised.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { Settings, installSettingsSection } from '@deepseek-ai/dsh-settings'
|
||||
import type { SettingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import { API_GATEWAY_SETTINGS_NAMESPACE, DEFAULT_ROUTE_SCHEMA } from '../src/index.ts'
|
||||
import type { DefaultRouteSettings } from '../src/index.ts'
|
||||
|
||||
/** The smallest real provider: one in-memory document, always writable. */
|
||||
class MemorySettings extends Settings {
|
||||
doc: Record<string, unknown> = {}
|
||||
|
||||
get writable(): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
protected load(): Promise<Record<string, unknown>> {
|
||||
return Promise.resolve(structuredClone(this.doc))
|
||||
}
|
||||
|
||||
protected persist(ns: SettingsNamespace, section: Record<string, unknown>): Promise<void> {
|
||||
this.doc = { ...this.doc, [ns]: structuredClone(section) }
|
||||
return Promise.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
/** Mount the gateway's own section wiring over a live provider. */
|
||||
async function boot(entry: DefaultRouteSettings) {
|
||||
const ctx = new Context()
|
||||
const fiber = ctx.plugin(MemorySettings)
|
||||
await fiber.await()
|
||||
let route: () => DefaultRouteSettings = () => entry
|
||||
const consumer = ctx.plugin(function section(child: Context) {
|
||||
installSettingsSection(child, API_GATEWAY_SETTINGS_NAMESPACE, DEFAULT_ROUTE_SCHEMA, entry, {
|
||||
setSource: (current) => { route = current },
|
||||
onChange: () => {},
|
||||
})
|
||||
})
|
||||
await consumer.await()
|
||||
const settings = ctx.get('settings')
|
||||
if (settings === undefined) throw new Error('settings provider did not mount')
|
||||
return { ctx, fiber, consumer, settings, read: () => route() }
|
||||
}
|
||||
|
||||
describe('the api-gateway default-route section', () => {
|
||||
it('resolves the composition entry until the user layer overrides it', async () => {
|
||||
const bench = await boot({ provider: 'deepseek-official', model: 'deepseek-v4-flash' })
|
||||
expect(bench.read()).toEqual({ provider: 'deepseek-official', model: 'deepseek-v4-flash' })
|
||||
|
||||
await bench.settings.replace(API_GATEWAY_SETTINGS_NAMESPACE, {
|
||||
provider: 'acme-gateway', model: 'acme-large', reasoningEffort: 'high',
|
||||
})
|
||||
expect(bench.read()).toEqual({
|
||||
provider: 'acme-gateway', model: 'acme-large', reasoningEffort: 'high',
|
||||
})
|
||||
await bench.ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('clears a stored effort when the next switch has none', async () => {
|
||||
const bench = await boot({ provider: 'deepseek-official', model: 'deepseek-v4-flash' })
|
||||
await bench.settings.replace(API_GATEWAY_SETTINGS_NAMESPACE, {
|
||||
provider: 'acme-gateway', model: 'acme-large', reasoningEffort: 'high',
|
||||
})
|
||||
expect(bench.read().reasoningEffort).toBe('high')
|
||||
|
||||
// The whole reason the gateway persists with `replace` rather than a merge
|
||||
// patch — and the reason `Config` carries no effort for the base layer to
|
||||
// re-inherit here. A stranded effort would fail the next session's first
|
||||
// request against a model that does not support it.
|
||||
await bench.settings.replace(API_GATEWAY_SETTINGS_NAMESPACE, {
|
||||
provider: 'acme-gateway', model: 'acme-plain',
|
||||
})
|
||||
expect(bench.read()).toEqual({ provider: 'acme-gateway', model: 'acme-plain' })
|
||||
await bench.ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('layers a hand-written partial section over the entry', async () => {
|
||||
const bench = await boot({ provider: 'deepseek-official', model: 'deepseek-v4-flash' })
|
||||
// Someone editing settings.yaml by hand may name only the model. The
|
||||
// entry supplies the provider, which is what makes this legal — and is
|
||||
// exactly why an effort in the entry could never be cleared, so there
|
||||
// is none to inherit.
|
||||
await bench.settings.replace(API_GATEWAY_SETTINGS_NAMESPACE, { model: 'deepseek-reasoner' })
|
||||
expect(bench.read()).toEqual({ provider: 'deepseek-official', model: 'deepseek-reasoner' })
|
||||
await bench.ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('falls back to the composition entry when the provider detaches', async () => {
|
||||
const bench = await boot({ provider: 'deepseek-official', model: 'deepseek-v4-flash' })
|
||||
await bench.settings.replace(API_GATEWAY_SETTINGS_NAMESPACE, {
|
||||
provider: 'acme-gateway', model: 'acme-large',
|
||||
})
|
||||
expect(bench.read().provider).toBe('acme-gateway')
|
||||
|
||||
// A deployment that loses its settings provider keeps serving the route it
|
||||
// was composed with rather than the one it can no longer read.
|
||||
await bench.fiber.dispose()
|
||||
expect(bench.read()).toEqual({ provider: 'deepseek-official', model: 'deepseek-v4-flash' })
|
||||
await bench.ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
@@ -303,6 +303,37 @@ describe('Web session model selection', () => {
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('refuses a prompt no adapter can route, and reports it on the directory', async () => {
|
||||
const { ctx, sessionId } = await harness()
|
||||
const api = createApiProxy(ctx, {
|
||||
defaultTarget: () => ({ provider: 'deleted-gateway', model: 'deleted-model' }),
|
||||
cwd: '/tmp',
|
||||
workspaceRoot: '/tmp',
|
||||
})
|
||||
|
||||
// The client disabling its input is an affordance; this method stays
|
||||
// callable, so the refusal has to live here.
|
||||
const refused = await api.sessions.prompt(request({
|
||||
sessionId, mode: 'queue' as const, content: [{ type: 'text' as const, text: 'hi' }],
|
||||
}))
|
||||
expect(refused.result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'model-unavailable', details: { provider: 'deleted-gateway', model: 'deleted-model' } },
|
||||
})
|
||||
expect(expectValue(await api.sessions.models(request({ sessionId }))).routable).toBe(false)
|
||||
|
||||
// An advisory-unlisted model on a live route is NOT this: the route
|
||||
// serves it, so the prompt goes through and nothing blocks.
|
||||
expectValue(await api.sessions.selectModel(request({
|
||||
sessionId, provider: 'deepseek-official', model: 'unlisted-but-served',
|
||||
})))
|
||||
const catalog = expectValue(await api.sessions.models(request({ sessionId })))
|
||||
expect(catalog.routable).toBe(true)
|
||||
expect(catalog.groups.flatMap(group => group.models.map(model => model.id)))
|
||||
.not.toContain('unlisted-but-served')
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('serves a session and its catalog when the stored default names a route that is gone', async () => {
|
||||
const { ctx, sessionId } = await harness()
|
||||
const api = createApiProxy(ctx, {
|
||||
|
||||
@@ -45,6 +45,7 @@ function scriptedApi(overrides: {
|
||||
}),
|
||||
models: r => ok(r, {
|
||||
current: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
|
||||
routable: true,
|
||||
groups: [],
|
||||
failures: [],
|
||||
}),
|
||||
|
||||
@@ -64,6 +64,7 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
|
||||
ok: true,
|
||||
value: {
|
||||
current: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
|
||||
routable: true,
|
||||
groups: [],
|
||||
failures: [],
|
||||
},
|
||||
|
||||
@@ -197,6 +197,7 @@ describe('sessions domain schemas', () => {
|
||||
expect(sessionModelsRequestSchema.parse({ sessionId: 's1' }).sessionId).toBe('s1')
|
||||
expect(sessionModelsValueSchema.parse({
|
||||
current: { provider: 'deepseek-official', model: 'deepseek-v4-flash', reasoningEffort: 'max' },
|
||||
routable: true,
|
||||
groups: [{
|
||||
id: 'deepseek-official',
|
||||
name: 'DeepSeek',
|
||||
@@ -274,8 +275,10 @@ describe('sessions domain schemas', () => {
|
||||
describe('host domain schemas', () => {
|
||||
it('validates describe request/value', () => {
|
||||
expect(hostDescribeRequestSchema.parse({})).toEqual({})
|
||||
const value = hostDescribeValueSchema.parse({ version: '1', cwd: '/x', defaultTarget: () => ({ provider: 'p', model: 'm' }), attachedSessions: 2 })
|
||||
expect(value.attachedSessions).toBe(2)
|
||||
const value = hostDescribeValueSchema.parse({
|
||||
version: '1', cwd: '/x', provider: 'p', model: 'm', attachedSessions: 2,
|
||||
})
|
||||
expect(value).toMatchObject({ provider: 'p', model: 'm', attachedSessions: 2 })
|
||||
expect(hostDescribeValueSchema.parse({ version: '1', cwd: '/x', attachedSessions: 0 }).provider).toBeUndefined()
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user