feat(web-shell): add host controls for model management (#12345)

* feat(web-shell): add host controls for model management

* refactor(web-shell): separate model policy from queue dispatch

* fix(web-shell): close model-management policy gaps from review (#12345)

- isModelSetupCommand now mirrors the daemon's tokenization and altNames,
  so /login, /connect and whitespace after the slash can no longer slip
  past a host that disabled model setup
- refuse host-disabled setup commands in submitUserMessageEdit before the
  rewind, closing the edit-and-resend entrance (and its retry arm)
- a refused side-task initial prompt is now terminal for the tab: the panel
  notifies the app, which drops it from the live tabs and the per-session
  bucket mirror instead of re-arming the toast on every revisit
- the mid-turn path checks the caller policy before reading or uploading
  annotated files, and a failed compensating attachment delete is surfaced
  instead of swallowed
- unmask the /auth suggestion-list assertion (it previously passed via
  hiddenSlashCommands, not the policy clause) and extend the interception
  tests to the alias inputs
- pin the App->SplitView and ArtifactPanel->SideTaskPanel policy hops
- e2e: tag the policy-enforcing cases @smoke so the PR gate runs them,
  keep evidence screenshots inside the git-ignored tree, and drop the
  request-level assertions the mock daemon's recorder cannot see (the
  /prompt half of the /auth refusal check is live and stays)

* fix(web-shell): let host override /auth before the model-setup refusal (#12345)

Address review feedback on the model-management controls:

- Run the policy guards after the host's documented onSlashCommand
  override (and below ChatPane's runtimeStopped fence) instead of above
  it, so an allowAdd:false host keeps its own /auth entrance; the
  refusal still lands ahead of hidden-command daemon forwarding.
- Classify typed commands by the resolved session command (exact name,
  then altNames, case-sensitively) instead of the bare lowercased name:
  a project/user command shadowing auth/connect/login stays runnable,
  and /AUTH reaches the daemon as prose. Guards and suggestion filters
  share the one predicate; an unloaded snapshot still fails closed.
- Refuse /btw side <setup command> before a side-task session is
  provisioned; the panel's initial-prompt guard remains the backstop.
- Scope the compensating attachment-delete failure report to the owning
  session and treat a fulfilled removed:false as a failure.
- Pin the refusal toast in the App-level tests, use the maintained
  promptRequests() matcher with a poll and a positive control in the e2e
  spec, and sync README + both design docs with the intercepted set, the
  ordering, and the inline-edit boundary row.

Co-authored-by: Qwen-Coder <[email protected]>

* fix(web-shell): handle model policy refusals consistently

* test(web-shell): run remaining model-management e2e arms at PR time

Co-authored-by: Qwen-Coder <[email protected]>

* fix(web-shell): fail closed on model setup when the ready snapshot has no auth entry (#12345)

The pane menu filter and the typed-submit guards classify through
isModelSetupCommand, which returned false when the command snapshot was
ready but carried no auth entry (slashCommands.disabled or the SSH policy
whitelist). The pinned local /auth entry stayed listed and dispatched with
no refusal. Treat an unresolved bare setup name on a ready snapshot as the
absent builtin and refuse it; a project/user shadow still resolves and
stays runnable.

Co-authored-by: Qwen-Coder <[email protected]>

---------

Co-authored-by: 铃蓝 <[email protected]>
Co-authored-by: qwen-code-dev-bot <[email protected]>
Co-authored-by: Qwen-Coder <[email protected]>
Co-authored-by: qwen-code-dev-bot <[email protected]>
Co-authored-by: qwen-code-ci-bot <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-09-23 19:13:06 +00:00
1 parent 61b4528530
commit 41f9a02efe
32 files changed
+2545 -114

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

+41
View File
@@ -0,0 +1,41 @@
# WebShell model management controls
[English](web-shell-model-management.md) | [简体中文](web-shell-model-management.zh-CN.md)
## Problem and scope
Embedded hosts may provision models externally while retaining native model selection. Settings visibility does not prevent accidental add/delete actions through other WebShell surfaces. Issue #12335 proposes optional instance-wide interaction controls; this is not authorization. Daemon APIs, SDK, CLI, file writes, and external provisioning remain unchanged.
## Design
Expose `modelManagement?: WebShellModelManagementOptions` with independent `allowAdd?` and `allowDelete?`, both defaulting to true. Export the type from the public entry. Normalize defaults and recognize setup commands in a small shared helper matching daemon tokenization and resolved command identity.
App hides add/delete UI, consumes a disabled `/auth` (or its `connect`/`login` aliases) after the host slash-command callback declines and before hidden-command forwarding, keyed on the resolved session command once its builtin-marked snapshot is available. Before that, the bare setup names are conservatively refused with the add-disabled notice. Project/user commands shadowing daemon-routed names remain runnable after metadata loads; App still owns the local `/auth` dialog route and gates that action directly. Host callback precedence applies to composer submissions, not inline edits or side-task initial prompts. The same refusal covers the inline message-edit path before any rewind, and the `/btw side` router checks the extracted question before provisioning a side-task session. App filters suggestions, closes an open setup dialog on restriction, and checks the latest policy in add/delete callbacks. AuthMessage checks again before installation. ModelManagementSection clears stale delete confirmation. SplitView and side-task panels forward the policy to ChatPane, which has its own command router and menu. Side tasks also check their initial prompt before sending it. A setup-like initial prompt waits for command metadata while additions are disabled, preserving the text; after five seconds a warning explains the delay. A late snapshot resumes classification, and a definite refusal clears both the live tab and its per-session mirror. Ordinary initial prompts do not wait. Queued browser dispatch checks a caller-supplied synchronous `getPromptDispatchError` callback at the submit/enqueue boundary, including after attachment preparation. The queue knows neither model policy nor `/auth`; its private rejection type distinguishes deliberate refusal from failed transmission. Refused queue items are removed without merging into a new composer draft, while attachment compensation still runs. Ordinary failures retain their existing draft recovery. The internal SettingsMessage data/handler bag is named `modelManagementSectionProps`, distinct from the public `modelManagement` policy. No generic SDK behavior changes.
Dynamic restrictions affect future browser requests. Already dispatched operations and daemon-owned queued prompts cannot be revoked by these props. Re-enabling a capability does not reopen a stale dialog. Model lists, current badges, switching, `/model`, context-window editing, and session `/delete` keep existing behavior. Settings exclusions compose independently.
## Why a single router guard is insufficient
| Boundary | Responsibility |
| ------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| App composer | Handles main/welcome input after the host callback declines and before hidden-command forwarding. |
| ChatPane composer | Owns split/side-task submission; its host callback is not App's complete command router. |
| Prepared prompt / retry | Rechecks the actual outgoing text after async preparation/session allocation; also covers `/plan` and retry paths. |
| Inline message edit | Rewinds and resends directly, without a composer submission. |
| Side-task initial prompt | Calls `actions.sendPrompt` directly, without a composer submission. |
| Browser queue dispatch | Rechecks after holding a prompt or preparing attachments, when the policy may have changed. |
| Setup/save and delete callbacks | Covers settings actions and callbacks retained across a policy update; does not revoke already-dispatched requests. |
These are entry/dispatch checks for one UI policy over `/auth` and its `connect`/`login` aliases, not six independent permission systems. Public options remain unchanged; internal naming and queue layering are implementation details.
## Validation
Test omitted/empty options and all four combinations; public forwarding; settings and command entry points; welcome and split panes; host callback and hidden-command precedence; dynamic dialogs and retained callbacks; queue dispatch with latest policy. Assert no forbidden install/delete request and retain selection/parameter-editing regressions. Use existing DOM tests and a browser harness with mocked daemon routes for UI evidence. Run affected tests, build/typecheck/bundle and preflight; report baseline failures and unavailable checks explicitly.
![Model management before restriction](assets/web-shell-model-management-before.png)
![Model management with add/delete disabled](assets/web-shell-model-management-after.png)
## Open questions
The public API remains subject to upstream review. Implementation is proceeding at the contributor's request while issue discussion remains open. No backend permission design is included.
@@ -0,0 +1,41 @@
# WebShell 模型管理控制
[English](web-shell-model-management.md) | [简体中文](web-shell-model-management.zh-CN.md)
## 问题与范围
嵌入宿主可能从外部配置模型,同时保留原生模型选择。设置显隐无法阻止其他 WebShell 入口中的误增删。Issue #12335 提议实例范围的可选交互控制;这不是鉴权。daemon API、SDK、CLI、文件写入和外部下发保持不变。
## 设计
公开 `modelManagement?: WebShellModelManagementOptions`,包含独立的 `allowAdd?` 和 `allowDelete?`,默认均为 true。从公共入口导出类型。使用小型共享 helper 归一化默认值,并按 daemon 的 token 切分和命令解析身份识别配置命令。
App 隐藏增删 UI,在宿主斜杠命令回调拒绝之后、隐藏命令转发之前消费被禁用的 `/auth`(含别名 `/connect`、`/login`);带内建标识的命令快照就绪后,拦截以会话命令解析结果为准。就绪前保守拒绝配置命令名称并提示新增已禁用。对于发送到 daemon 的命令,同名项目/用户命令在快照加载后仍可运行;App 本地 `/auth` 仍由配置弹框路由接管,直接检查该 UI 动作。宿主回调优先仅适用于输入框提交,不扩展到消息编辑和侧任务初始提示词。消息内联编辑路径在 rewind 之前同样拒绝,`/btw side` 路由在创建侧任务会话之前先检查提取出的问题。App 过滤建议,在收紧策略时关闭已开的配置弹框,并在增删回调读取最新策略。AuthMessage 在安装前再次检查。ModelManagementSection 清理旧删除确认。SplitView 和侧任务面板向具有独立命令路由和菜单的 ChatPane 透传策略。侧任务还会在发送初始提示词前检查策略。禁用新增时,疑似配置命令的初始提示词会保留并等待命令信息;五秒后提示仍未就绪,信息随后到达时重新判断。确定拒绝后清理活动 tab 和会话镜像,普通问题不等待命令信息。浏览器队列在 submit/enqueue 边界调用调用方提供的同步 `getPromptDispatchError` 校验,包括附件准备之后。队列不认识模型策略或 `/auth`;其内部拒绝类型区分主动拒绝与发送失败。拒绝的队列项被移除,不合并到用户正在编辑的新草稿;附件补偿清理继续执行。普通失败保留原有草稿恢复行为。SettingsMessage 内部的数据/回调集合命名为 `modelManagementSectionProps`,区别于公共 `modelManagement` 策略。不改变通用 SDK 行为。
动态限制影响之后发出的浏览器请求,不能撤销已发出的操作及 daemon 已接收的队列。恢复允许时不重新打开旧弹框。模型列表、当前标识、切换、`/model`、上下文窗口编辑和会话 `/delete` 保持原行为;设置排除策略独立组合。
## 为什么单一路由守卫不够
| 边界 | 职责 |
| ------------------- | ----------------------------------------------------------------- |
| App 输入框 | 在宿主回调拒绝之后、隐藏命令转发之前处理主窗口/欢迎页输入。 |
| ChatPane 输入框 | 拥有分屏/侧任务的提交路径;它的宿主回调不是 App 的完整命令路由。 |
| 准备后发送及重试 | 在异步准备和会话分配后检查最终发送文本,覆盖 `/plan` 转换和重试。 |
| 消息内联编辑 | 直接 rewind 并重发,不经过输入框提交。 |
| 侧任务初始提示词 | 直接调用 `actions.sendPrompt`,不经过输入框提交。 |
| 浏览器队列发送 | 等待队列或准备附件之后重新检查,此时策略可能已改变。 |
| 配置/保存和删除回调 | 覆盖设置动作及跨策略更新保留的回调;不撤销已发出的请求。 |
这些是同一 UI 策略针对 `/auth` 及其别名 `/connect`、`/login` 在入口/发送边界的检查,不是六套独立权限系统。公共选项保持不变;内部命名和队列分层属于实现细节。
## 验证
覆盖不传/空配置及四种组合、公共透传、设置与命令入口、欢迎页和分屏、宿主回调与隐藏命令优先级、动态弹框及旧回调、队列发送时最新策略。断言禁用时不发安装/删除请求,并保留选择与参数编辑回归。使用已有 DOM 测试及模拟 daemon 路由的浏览器 harness 提供 UI 证据。执行受影响测试、build/typecheck/bundle 和 preflight,如实报告基线失败及未完成项。
![限制前的模型管理](assets/web-shell-model-management-before.png)
![禁用新增和删除后的模型管理](assets/web-shell-model-management-after.png)
## 待讨论
公共 API 仍待上游评审。按贡献者明确要求,在 issue 讨论未结束时推进实现。本次不包含后端权限设计。
+20
View File
@@ -371,6 +371,7 @@ const projection = projectChatRecordsToDaemonTranscript(records);
| `lockWorkspaceCwd` | `string` | 锁定到指定工作区路径;未注册时自动持久注册,并隐藏其他工作区及添加、移除和选择入口 |
| `restartSseOnPrompt` | `boolean` | 每次 prompt 被 daemon 接收后重建存活 SSE 流;流断开时提交 prompt 总会立即重建(与此开关无关);默认关闭 |
| `settings` | `WebShellSettingsOptions` | 可选。控制原生 `/settings` 页面的呈现;见 [原生设置呈现](#原生设置呈现)。 |
| `modelManagement` | `WebShellModelManagementOptions` | 可选。控制 WebShell 内模型新增/删除交互,默认均允许;见 [模型增删交互](#模型增删交互)。 |
### Workspace 会话创建超时
@@ -419,6 +420,7 @@ daemon 参数的完整含义和配置方式见
| `onSessionArtifactsChange` | `(change: WebShellSessionArtifactsChange) => void` | Session Artifact 初始恢复或变化后返回当前完整快照与 turn 投影 |
| `onAssistantTurnSettled` | `(event: WebShellAssistantTurnSettledEvent) => void` | daemon 权威终态提交后触发;多个 provider 可能重复上报,宿主按 `(sessionId, promptId)` 去重 |
| `settings` | `WebShellSettingsOptions` | 可选。控制原生 `/settings` 页面的呈现;见 [原生设置呈现](#原生设置呈现)。 |
| `modelManagement` | `WebShellModelManagementOptions` | 可选。控制 WebShell 内模型新增/删除交互,默认均允许;见 [模型增删交互](#模型增删交互)。 |
宿主可以通过 `onContextUsageOpen?: (sessionId: string) => void` 接管上下文
详情的打开操作:
@@ -595,6 +597,24 @@ daemon 不净化它读到的文件。该配置只从 User / System / SystemDefau
设计与验证范围见 [English](../../docs/design/web-shell-settings-allowlists.md) / [简体中文](../../docs/design/web-shell-settings-allowlists.zh-CN.md)。
## 模型增删交互
宿主可以保留模型列表和切换,同时关闭 WebShell 内的新增与删除入口:
```tsx
<WebShellWithProviders
modelManagement={{ allowAdd: false, allowDelete: false }}
/>
```
公共类型 `WebShellModelManagementOptions` 的两个字段独立控制,省略均为 `true`。
`allowAdd: false` 隐藏新增按钮和 `/auth` 建议,并在主窗口、欢迎页、分屏和侧任务中拦截手动输入的 `/auth` 及其别名 `/connect`、`/login`。输入框提交时,拦截位于宿主 `onSlashCommand` 回调之后、隐藏命令转发之前,宿主回调返回 `true` 即可接管;消息编辑、重试和侧任务初始发送不调用该回调。命令快照就绪后,daemon 命令按解析身份判断,同名项目/用户命令可保留;App 本地 `/auth` 路由仍是配置弹框入口。快照未就绪时,输入框保守拒绝配置命令名称;侧任务保留这类初始提示词等待加载,五秒后提示等待状态,信息到达后重新检查。添加模型弹框无法打开或保存。
`allowDelete: false` 隐藏模型删除按钮并阻止删除动作。模型列表、当前标识、选择、`/model`、参数编辑及会话 `/delete` 保持原行为。
动态收紧策略会关闭相关弹框或确认,之后的浏览器队列发送读取最新策略。已交给 SDK/daemon 的请求不能由 props 撤销。恢复允许不会重新打开旧弹框。
这仅用于界面防误操作,不是安全权限。daemon API、CLI、配置文件写入和外部模型下发不受影响;`settings.excludeItems` 的呈现策略仍独立生效。
## Markdown 图表接入
`WebShell` 已内置 `markdown-chart` renderer 和 ECharts 运行时。宿主只需将
+506 -2
View File
@@ -39,7 +39,9 @@ import {
type SessionSourcesResult,
} from '@qwen-code/sdk/daemon';
import type { WebShellApi } from './App';
import type { ArtifactPanel } from './components/artifacts/ArtifactPanel';
import type { WebShellSettingsOptions } from './settings';
import type { WebShellModelManagementOptions } from './modelManagement';
import { DEFAULT_SESSION_ACTION_ITEMS } from './components/sidebar/WebShellSidebar';
import type { Message } from './adapters/types';
import type { TurnOutputOpenRequest } from './components/artifacts/TurnOutputs';
@@ -635,6 +637,9 @@ const {
streamingTailMessages: undefined as unknown[] | undefined,
queuedPromptHoldHistory: [] as boolean[],
queuedPromptWriteBlocked: false,
queuedPromptDispatchError: undefined as
| ((text: string) => string | undefined)
| undefined,
queuedPromptStreamingState: 'idle',
queuedPromptSessionHasActivePrompt: false,
chatEditorRenderCount: 0,
@@ -752,6 +757,10 @@ const {
latestSettingsState: null as {
settings: DaemonSettingDescriptor[];
} | null,
captureArtifactPanelOnly: false,
latestArtifactPanelProps: null as React.ComponentProps<
typeof ArtifactPanel
> | null,
latestSplitViewProps: null as {
registerContextUsageControls?: RegisterContextUsageControls;
onBeforeContextCompress?: (sessionId: string) => void;
@@ -764,6 +773,7 @@ const {
includeOtherWorkspaces?: boolean;
workspaceCwd?: string;
sessionWorkflowEnabled?: boolean;
modelManagement?: WebShellModelManagementOptions;
} | null,
latestSettingsInitialCategory: undefined as string | undefined,
latestSettingsPresentation: undefined as
@@ -837,6 +847,22 @@ const {
};
});
vi.mock('./components/artifacts/ArtifactPanel', async (importOriginal) => {
const actual =
await importOriginal<
typeof import('./components/artifacts/ArtifactPanel')
>();
return {
...actual,
ArtifactPanel: (props: React.ComponentProps<typeof ArtifactPanel>) => {
testState.latestArtifactPanelProps = props;
return testState.captureArtifactPanelOnly ? null : (
<actual.ArtifactPanel {...props} />
);
},
};
});
vi.mock('@qwen-code/web-shell/daemon-react-sdk', () => {
const ownerGuard = {
capture: (options?: { includeRecovery?: boolean }) => {
@@ -1008,11 +1034,13 @@ vi.mock('./hooks/useAnimationFrameValue', () => ({
vi.mock('./hooks/useQueuedPrompts', () => ({
useQueuedPrompts: (args: {
getPromptDispatchError?: (text: string) => string | undefined;
holdQueuedPromptsLocally?: boolean;
writeBlocked?: boolean;
streamingState: string;
sessionHasActivePrompt?: boolean;
}) => {
testState.queuedPromptDispatchError = args.getPromptDispatchError;
testState.queuedPromptWriteBlocked = args.writeBlocked === true;
testState.queuedPromptHoldHistory.push(
args.holdQueuedPromptsLocally === true,
@@ -1288,7 +1316,7 @@ vi.mock('./components/messages/SettingsMessage', async () => {
scope: 'user' | 'workspace',
) => void;
presentation?: WebShellSettingsOptions;
modelManagement?: {
modelManagementSectionProps?: {
busy?: boolean;
onAddModel?: () => void;
onSelectModel?: (modelId: string) => void;
@@ -1302,7 +1330,8 @@ vi.mock('./components/messages/SettingsMessage', async () => {
testState.latestSettingsState = props.settingsState;
testState.latestSettingsInitialCategory = props.initialCategory;
testState.latestSettingsPresentation = props.presentation;
testState.latestModelManagement = props.modelManagement ?? null;
testState.latestModelManagement =
props.modelManagementSectionProps ?? null;
return React.createElement(
'div',
{ 'data-testid': 'settings-message' },
@@ -10976,6 +11005,8 @@ beforeEach(() => {
testState.latestGoalsProps = null;
testState.latestWorkflowRunsProps = null;
testState.latestSplitViewProps = null;
testState.latestArtifactPanelProps = null;
testState.captureArtifactPanelOnly = false;
rawEnqueuePrompt.mockClear();
editorClear.mockClear();
editorCommit.mockClear();
@@ -16797,6 +16828,79 @@ describe('App session callbacks', () => {
},
);
it.each(['/auth', '/login', '/connect', '/ auth'])(
'refuses a host-disabled model setup edit (%s) before rewinding',
async (text) => {
const onToast = vi.fn();
renderApp({
language: 'en',
modelManagement: { allowAdd: false, allowDelete: false },
onToast,
});
await flush();
let accepted;
await act(async () => {
accepted = await submit(text);
});
expect(accepted).toBe(false);
expect(onToast).toHaveBeenCalledWith(
'info',
'Adding models is disabled by the host.',
);
expect(mockSessionActions.sendPrompt).not.toHaveBeenCalled();
expect(mockSessionActions.rewindSession).not.toHaveBeenCalled();
expect(
testState.latestMessageListProps?.failedPromptMessageId,
).toBeUndefined();
},
);
it('allows an ordinary inline edit while model setup is disabled', async () => {
const prepareSubmit = vi.fn().mockResolvedValue(undefined);
renderApp({ modelManagement: { allowAdd: false }, prepareSubmit });
await flush();
await act(async () => {
expect(await submit('ordinary edited question')).toBe(true);
});
expect(prepareSubmit).toHaveBeenCalledOnce();
expect(mockSessionActions.sendPrompt).toHaveBeenCalledWith(
'ordinary edited question',
expect.anything(),
);
});
it('refuses a failed model setup edit retry after the host tightens policy', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
mockSessionActions.sendPrompt.mockRejectedValueOnce(
new DaemonHttpError(413, {}, 'Prompt too large'),
);
const onToast = vi.fn();
const { rerender } = renderApp({ onToast });
await flush();
await act(async () => {
expect(await submit('/auth')).toBe(false);
});
rerender({ onToast });
await flush();
expect(testState.latestMessageListProps?.failedPromptMessageId).toBe(
'local-recovered-edit',
);
const retry = testState.latestMessageListProps?.onRetryFailedPrompt;
expect(retry).toBeTypeOf('function');
expect(mockSessionActions.sendPrompt).toHaveBeenCalledTimes(1);
rerender({ modelManagement: { allowAdd: false }, onToast });
await flush();
await act(async () => {
retry?.();
});
await flush();
expect(mockSessionActions.sendPrompt).toHaveBeenCalledTimes(1);
expect(onToast).toHaveBeenCalledWith(
'info',
'Adding models is disabled by the host.',
);
});
it.each(['snapshots', 'rewind'] as const)(
'cancels an old edit after switching sessions during %s',
async (stage) => {
@@ -28102,6 +28206,45 @@ describe('App session callbacks', () => {
expect(container.querySelector('button[title="Side task"]')).not.toBeNull();
});
it('refuses a host-disabled model setup side task before provisioning', async () => {
mockConnection.capabilities.features = ['session_side_task'];
const onToast = vi.fn();
const { container } = renderApp({
modelManagement: { allowAdd: false, allowDelete: false },
onToast,
});
await flush();
testState.prompt = '/btw side /auth';
await clickSubmit(container);
await flush();
expect(mockWorkspace.client.createSideTaskSession).not.toHaveBeenCalled();
expect(container.querySelector('button[title="Side task"]')).toBeNull();
expect(onToast).toHaveBeenCalledWith(
'info',
'Adding models is disabled by the host.',
);
});
it('allows an ordinary side task while model setup is disabled', async () => {
mockConnection.capabilities.features = ['session_side_task'];
const { container } = renderApp({ modelManagement: { allowAdd: false } });
await flush();
testState.prompt = '/btw side ordinary side question';
await clickSubmit(container);
await flush();
expect(mockWorkspace.client.createSideTaskSession).toHaveBeenCalledOnce();
expect(testState.latestArtifactPanelProps?.tabs).toEqual(
expect.arrayContaining([
expect.objectContaining({
kind: 'side_task',
initialPrompt: 'ordinary side question',
}),
]),
);
});
it('keeps /btw side as a lightweight question without the capability', async () => {
const { container } = renderApp();
await flush();
@@ -32691,6 +32834,74 @@ describe('App session callbacks', () => {
);
});
it('forwards model policy through the artifact panel', async () => {
mockConnection.capabilities.features = ['session_side_task'];
const shellRef = createRef<WebShellApi>();
const modelManagement = { allowAdd: false, allowDelete: true };
renderApp({ shellRef, modelManagement });
await flush();
act(() => {
shellRef.current?.createSideTask();
});
await flush();
expect(testState.latestArtifactPanelProps?.modelManagement).toEqual(
modelManagement,
);
});
it.each([false, true])(
'drops a refused side-task initial prompt from its session (away=%s)',
async (away) => {
mockConnection.capabilities.features = ['session_side_task'];
testState.captureArtifactPanelOnly = true;
const shellRef = createRef<WebShellApi>();
const { rerender } = renderApp({ shellRef });
await flush();
act(() => {
shellRef.current?.createSideTask('/auth');
});
await flush();
const panel = testState.latestArtifactPanelProps!;
const tab = panel.tabs.find((item) => item.kind === 'side_task');
expect(tab).toMatchObject({ initialPrompt: '/auth' });
const refuse = panel.onSideTaskInitialPromptRefused!;
expect(refuse).toBeTypeOf('function');
const switchSession = async (sessionId: string) => {
mockConnection.loadingTranscript = true;
mockConnection.sessionId = sessionId;
testState.ownerVersion += 1;
rerender({ shellRef, modelManagement: { allowAdd: false } });
await flush();
mockConnection.loadingTranscript = false;
rerender({ shellRef, modelManagement: { allowAdd: false } });
await flush();
};
if (away) await switchSession('session-2');
act(() => refuse(tab!.id));
await flush();
if (!away) {
expect(
testState.latestArtifactPanelProps?.tabs.find(
(item) => item.id === tab!.id,
),
).toMatchObject({ initialPrompt: undefined });
await switchSession('session-2');
}
await switchSession('session-1');
rerender({ shellRef, modelManagement: { allowAdd: true } });
await flush();
expect(
testState.latestArtifactPanelProps?.tabs.find(
(item) => item.id === tab!.id,
),
).toMatchObject({ initialPrompt: undefined });
testState.captureArtifactPanelOnly = false;
rerender({ shellRef, modelManagement: { allowAdd: true } });
await flush();
expect(mockSessionActions.sendPrompt).not.toHaveBeenCalled();
},
);
it('keeps an in-flight side task across a round-trip session switch', async () => {
mockConnection.capabilities.features = ['session_side_task'];
window.localStorage.setItem(
@@ -34019,6 +34230,22 @@ describe('App session callbacks', () => {
},
);
it('forwards the model management policy to split panes', async () => {
const { container } = renderApp({
modelManagement: { allowAdd: false, allowDelete: false },
});
await flush();
await act(async () => {
container
.querySelector<HTMLButtonElement>('[data-testid="open-split-view"]')
?.click();
});
expect(testState.latestSplitViewProps?.modelManagement).toEqual({
allowAdd: false,
allowDelete: false,
});
});
it.each([false, true])(
'does not rerender App for other split sessions (outer pending: %s)',
async (outerPending) => {
@@ -35431,6 +35658,283 @@ describe('App session callbacks', () => {
}
});
it.each([
{ sessionId: undefined, text: '/auth' },
{ sessionId: 's1', text: '/auth' },
// The daemon resolves altNames and tolerates whitespace after the slash;
// the interception must match that or these slip through to the daemon.
{ sessionId: undefined, text: '/login' },
{ sessionId: 's1', text: '/login' },
{ sessionId: undefined, text: '/connect' },
{ sessionId: 's1', text: '/connect' },
{ sessionId: undefined, text: '/ auth' },
{ sessionId: 's1', text: '/ auth' },
])(
'blocks host-disabled model setup in welcome/session (session $sessionId, input "$text") when the host callback declines',
async ({ sessionId, text }) => {
mockConnection.sessionId = sessionId;
const onToast = vi.fn();
const onSlashCommand = vi.fn(() => false);
// No hiddenSlashCommands pre-filter here: the allowAdd clause itself
// must be what removes /auth from the suggestion list.
const { container } = renderApp({
modelManagement: { allowAdd: false, allowDelete: false },
onSlashCommand,
onToast,
});
await flush();
expect(testState.latestChatEditorProps?.commands).not.toEqual(
expect.arrayContaining([expect.objectContaining({ name: 'auth' })]),
);
testState.prompt = text;
await clickSubmit(container);
await flush();
// The host's documented override runs before the refusal; '/ auth'
// never reaches it because the handler pattern needs a token right
// after the slash.
expect(onSlashCommand).toHaveBeenCalledTimes(text === '/ auth' ? 0 : 1);
expect(onToast).toHaveBeenCalledWith(
'info',
'Adding models is disabled by the host.',
);
expect(
container.querySelector('[data-testid="dialog-shell"]'),
).toBeNull();
expect(mockSessionActions.sendPrompt).not.toHaveBeenCalled();
expect(rawEnqueuePrompt).not.toHaveBeenCalled();
},
);
it('lets the host take over /auth when model setup is disabled', async () => {
const onToast = vi.fn();
const onSlashCommand = vi.fn(() => true);
const { container } = renderApp({
modelManagement: { allowAdd: false, allowDelete: false },
onSlashCommand,
onToast,
});
await flush();
testState.prompt = '/auth';
await clickSubmit(container);
await flush();
expect(onSlashCommand).toHaveBeenCalledWith({
command: 'auth',
args: '',
input: '/auth',
});
expect(onToast).not.toHaveBeenCalled();
expect(container.querySelector('[data-testid="dialog-shell"]')).toBeNull();
expect(mockSessionActions.sendPrompt).not.toHaveBeenCalled();
});
it('keeps a project command shadowing a setup alias runnable under allowAdd: false', async () => {
mockConnection.commands = [
{
name: 'auth',
description: 'Configure models',
source: 'builtin-command',
altNames: ['connect', 'login'],
},
{ name: 'login', description: 'Project login', source: 'project' },
];
const { container } = renderApp({
modelManagement: { allowAdd: false, allowDelete: false },
});
await flush();
// The shadowing project command stays advertised …
expect(testState.latestChatEditorProps?.commands).toEqual(
expect.arrayContaining([expect.objectContaining({ name: 'login' })]),
);
// … while the builtin auth entry it shadows part of stays hidden.
expect(testState.latestChatEditorProps?.commands).not.toEqual(
expect.arrayContaining([expect.objectContaining({ name: 'auth' })]),
);
testState.prompt = '/login staging';
await clickSubmit(container);
await flush();
expect(mockSessionActions.sendPrompt).toHaveBeenCalledWith(
'/login staging',
expect.anything(),
);
});
it.each(['project', 'missing'] as const)(
'does not latch an invisible auth dialog for %s auth identity',
async (identity) => {
mockConnection.commands =
identity === 'project'
? [
{ name: 'help', description: 'Help', source: 'builtin-command' },
{ name: 'auth', description: 'Project auth', source: 'project' },
]
: [{ name: 'help', description: 'Help', source: 'builtin-command' }];
const { container, rerender } = renderApp({
modelManagement: { allowAdd: false },
});
await flush();
testState.prompt = '/auth';
await clickSubmit(container);
await flush();
expect(
container.querySelector('[data-testid="dialog-shell"]'),
).toBeNull();
expect(testState.latestChatEditorProps?.disabled).toBeFalsy();
testState.prompt = 'ordinary follow-up';
await clickSubmit(container);
await flush();
expect(mockSessionActions.sendPrompt).toHaveBeenCalledWith(
'ordinary follow-up',
expect.anything(),
);
rerender({ modelManagement: { allowAdd: true } });
await flush();
expect(
container.querySelector('[data-testid="dialog-shell"]'),
).toBeNull();
},
);
it.each([undefined, 'session-1'])(
'refuses rewritten plan model setup before dispatch (session %s)',
async (sessionId) => {
mockConnection.sessionId = sessionId;
const onToast = vi.fn();
const { container } = renderApp({
modelManagement: { allowAdd: false },
onToast,
});
await flush();
testState.prompt = '/plan /auth';
await clickSubmit(container);
await flush();
expect(mockSessionActions.sendPrompt).not.toHaveBeenCalled();
expect(rawEnqueuePrompt).not.toHaveBeenCalled();
expect(onToast).toHaveBeenCalledWith(
'info',
'Adding models is disabled by the host.',
);
},
);
it('refuses rewritten plan model setup after policy tightens during mode change', async () => {
const modeChange = deferred<{ mode: string }>();
mockSessionActions.setApprovalMode.mockReturnValueOnce(modeChange.promise);
const onToast = vi.fn();
const { container, rerender } = renderApp({ onToast });
await flush();
testState.prompt = '/plan /auth';
await clickSubmit(container);
await flush();
expect(mockSessionActions.setApprovalMode).toHaveBeenCalledOnce();
rerender({ modelManagement: { allowAdd: false }, onToast });
await act(async () => {
modeChange.resolve({ mode: 'plan' });
await modeChange.promise;
});
await flush();
expect(mockSessionActions.sendPrompt).not.toHaveBeenCalled();
expect(onToast).toHaveBeenCalledWith(
'info',
'Adding models is disabled by the host.',
);
});
it('refuses prepared model setup using the latest host policy', async () => {
const prepared = deferred<{ prompt: string }>();
const prepareSubmit = vi.fn(() => prepared.promise);
const onToast = vi.fn();
const { container, rerender } = renderApp({ prepareSubmit, onToast });
await flush();
testState.prompt = 'ordinary input';
await clickSubmit(container);
expect(prepareSubmit).toHaveBeenCalledOnce();
rerender({ prepareSubmit, onToast, modelManagement: { allowAdd: false } });
await act(async () => {
prepared.resolve({ prompt: '/auth' });
await prepared.promise;
});
await flush();
expect(mockSessionActions.sendPrompt).not.toHaveBeenCalled();
expect(onToast).toHaveBeenCalledWith(
'info',
'Adding models is disabled by the host.',
);
});
it('preserves an explicit host auth menu command when model setup is disabled', async () => {
mockConnection.commands = [
{ name: 'help', description: 'Help', source: 'builtin-command' },
];
const onSlashCommand = vi.fn(() => true);
const { container } = renderApp({
modelManagement: { allowAdd: false },
additionalSlashCommands: [{ name: 'auth', description: 'Host auth' }],
onSlashCommand,
});
await flush();
expect(testState.latestChatEditorProps?.commands).toEqual(
expect.arrayContaining([expect.objectContaining({ name: 'auth' })]),
);
testState.prompt = '/auth';
await clickSubmit(container);
await flush();
expect(onSlashCommand).toHaveBeenCalledWith({
command: 'auth',
args: '',
input: '/auth',
});
expect(mockSessionActions.sendPrompt).not.toHaveBeenCalled();
expect(container.querySelector('[data-testid="dialog-shell"]')).toBeNull();
});
it('hides fallback local auth without a matching daemon builtin', async () => {
mockConnection.commands = [
{ name: 'help', description: 'Help', source: 'builtin-command' },
];
renderApp({ modelManagement: { allowAdd: false } });
await flush();
expect(testState.latestChatEditorProps?.commands).not.toEqual(
expect.arrayContaining([expect.objectContaining({ name: 'auth' })]),
);
});
it('applies current model-management policy to retained settings callbacks and closes auth', async () => {
const { container, rerender } = renderApp();
await flush();
testState.prompt = '/settings';
await clickSubmit(container);
await flush();
const oldActions = testState.latestModelManagement!;
const oldDispatchPolicy = testState.queuedPromptDispatchError!;
expect(oldDispatchPolicy('/auth')).toBeUndefined();
act(() => oldActions.onAddModel?.());
expect(
container.querySelector('[data-testid="dialog-shell"]'),
).not.toBeNull();
rerender({ modelManagement: { allowAdd: false, allowDelete: false } });
await flush();
expect(container.querySelector('[data-testid="dialog-shell"]')).toBeNull();
expect(oldDispatchPolicy('/auth')).toBe(
'Adding models is disabled by the host.',
);
expect(oldDispatchPolicy('/model')).toBeUndefined();
act(() => {
oldActions.onAddModel?.();
oldActions.onDeleteModel?.({ authType: 'openai', modelId: 'example' });
});
expect(container.querySelector('[data-testid="dialog-shell"]')).toBeNull();
expect(mockWorkspaceActions.deleteModel).not.toHaveBeenCalled();
rerender({ modelManagement: {} });
await flush();
expect(container.querySelector('[data-testid="dialog-shell"]')).toBeNull();
testState.prompt = '/auth';
await clickSubmit(container);
await flush();
expect(
container.querySelector('[data-testid="dialog-shell"]'),
).not.toBeNull();
});
it('forwards host settings filters to the settings page and updates them at runtime', async () => {
const settings: WebShellSettingsOptions = {
excludeItems: ['setting:fast-model', 'builtin:model-management'],
+126 -4
View File
@@ -1,3 +1,8 @@
import {
isModelSetupCommand,
resolveModelManagement,
type WebShellModelManagementOptions,
} from './modelManagement';
import './styles/globals.css';
import {
useMessageNavigation,
@@ -1175,6 +1180,8 @@ export type WebShellSlashCommandHandler = (
export interface WebShellProps {
/** Native settings-page presentation. Does not restrict commands or daemon access. */
settings?: WebShellSettingsOptions;
/** Model add/delete interactions across WebShell. Not a backend permission policy. */
modelManagement?: WebShellModelManagementOptions;
/** Host-specific label for the Ask User Question free-text choice. */
askUserFreeTextLabel?: string;
/** Called whenever the attached daemon session or workspace changes. */
@@ -3161,6 +3168,7 @@ export function App({
chatMaxWidth,
sidebar,
settings: settingsPresentation,
modelManagement,
header,
rightPanel,
environmentPanel,
@@ -3212,6 +3220,9 @@ export function App({
lockedWorkspaceCwd,
lockedWorkspaceCapability,
}: AppProps = {}) {
const modelManagementPolicy = resolveModelManagement(modelManagement);
const modelManagementRef = useRef(modelManagementPolicy);
modelManagementRef.current = modelManagementPolicy;
const navigation = useWebShellNavigation();
const navigationRef = useRef(navigation);
navigationRef.current = navigation;
@@ -5152,6 +5163,27 @@ export function App({
},
[],
);
// A policy-refused initial prompt must die with the refusal: drop it from the
// live tabs AND the per-session bucket mirror, or switching away and back
// re-arms the toast and a later policy relaxation replays the command.
const handleSideTaskInitialPromptRefused = useCallback((tabId: string) => {
setArtifactPanelTabs((tabs) =>
tabs.map((tab) =>
tab.id === tabId && tab.kind === 'side_task'
? { ...tab, initialPrompt: undefined }
: tab,
),
);
for (const state of artifactPanelStateBySessionRef.current.values()) {
if (!state.tabs.some((tab) => tab.id === tabId)) continue;
state.tabs = state.tabs.map((tab) =>
tab.id === tabId && tab.kind === 'side_task'
? { ...tab, initialPrompt: undefined }
: tab,
);
break;
}
}, []);
const openSideTask = useCallback(
(sideTask: SideTaskListItem) => {
const parentSessionId = connection.sessionId;
@@ -9667,6 +9699,9 @@ export function App({
const [showMemoryDialog, setShowMemoryDialog] = useState(false);
const [showAuthDialog, setShowAuthDialog] = useState(false);
const showAuthDialogRef = useRef(showAuthDialog);
useEffect(() => {
if (!modelManagementPolicy.allowAdd) setShowAuthDialog(false);
}, [modelManagementPolicy.allowAdd, showAuthDialog]);
const [memoryRefreshSignal, setMemoryRefreshSignal] = useState(0);
const [memoryAddSignal, setMemoryAddSignal] = useState(0);
const [externalInteractionBlockCount, setExternalInteractionBlockCount] =
@@ -10331,6 +10366,18 @@ export function App({
},
[pushToast, t],
);
const refuseModelSetup = useCallback(
(text: string) => {
if (
modelManagementRef.current.allowAdd ||
!isModelSetupCommand(text, connectionRef.current.commands)
)
return false;
pushToast('info', t('settings.models.addDisabled'));
return true;
},
[pushToast, t],
);
const sendPrompt = useCallback(
async (
text: string,
@@ -10483,6 +10530,10 @@ export function App({
restoreCancelledSubmitState();
return;
}
if (refuseModelSetup(preparedPrompt)) {
restoreCancelledSubmitState();
return;
}
opts?.onPreparedSubmit?.({
prompt: preparedPrompt,
inputAnnotations: preparedInputAnnotations,
@@ -10514,6 +10565,10 @@ export function App({
restoreCancelledSubmitState();
return;
}
if (refuseModelSetup(preparedPrompt)) {
restoreCancelledSubmitState();
return;
}
const sessionIdAfterEnsure =
connectionRef.current.sessionId ?? allocatedSessionId;
const allocatedOwner = allocatedSessionCatalogOwnerRef.current;
@@ -10663,6 +10718,7 @@ export function App({
finishPromptPreparation,
getComposerWorkspaceCwd,
reportError,
refuseModelSetup,
sessionCatalogController,
sessionActions,
sessionOwnerGuard,
@@ -10859,7 +10915,7 @@ export function App({
// mcpDialogMessage survives closing the Plugins panel; MCP surfaces are
// already blocked by activePanel below, so including it would lock chat.
showMemoryDialog ||
showAuthDialog ||
(modelManagementPolicy.allowAdd && showAuthDialog) ||
showAddWorkspaceDialog ||
scratchOutcomeUnknown !== 'clear' ||
externalInteractionBlockCount > 0 ||
@@ -10949,6 +11005,10 @@ export function App({
updateFailedPrompt(null);
return;
}
if (refuseModelSetup(failed.text)) {
updateFailedPrompt(null);
return;
}
const retryOwner = failed.owner;
if (!retryOwnerIsCurrent(retryOwner)) {
updateFailedPrompt(null);
@@ -11073,6 +11133,7 @@ export function App({
reportError,
restoreOrDeferCancelledRetry,
retryOwnerIsCurrent,
refuseModelSetup,
sendPrompt,
store,
t,
@@ -11102,6 +11163,11 @@ export function App({
editLastQueuedPrompt,
clearQueuedPrompts,
} = useQueuedPrompts({
getPromptDispatchError: (text) =>
!modelManagementRef.current.allowAdd &&
isModelSetupCommand(text, connectionRef.current.commands)
? t('settings.models.addDisabled')
: undefined,
connected,
writeBlocked:
sessionWriteBlocked ||
@@ -15059,6 +15125,13 @@ export function App({
pushToast('error', t('userMessage.editBusy'));
return false;
}
if (
!modelManagementRef.current.allowAdd &&
isModelSetupCommand(trimmed, connectionRef.current.commands)
) {
pushToast('info', t('settings.models.addDisabled'));
return false;
}
const sessionId = connectionRef.current.sessionId;
if (
unknownPromptAdmissionRef.current?.payloadAvailable &&
@@ -15481,11 +15554,21 @@ export function App({
) {
return false;
}
// The host's documented slash-command override runs first; only when
// it declines does the policy consume a model-setup command — still
// ahead of daemon dispatch through the hidden-command forward below.
if (
invokeSlashCommandHandler(text, onSlashCommandRef.current, reportError)
) {
return true;
}
if (
!modelManagementRef.current.allowAdd &&
isModelSetupCommand(text, connectionRef.current.commands)
) {
pushToast('info', t('settings.models.addDisabled'));
return true;
}
if (connectionRef.current.loadingTranscript) {
pushToast('warning', t('editor.sessionLoading'));
return false;
@@ -15981,6 +16064,10 @@ export function App({
return true;
}
if (cmd === 'auth') {
if (!modelManagementRef.current.allowAdd) {
pushToast('info', t('settings.models.addDisabled'));
return true;
}
// Take over only the surface this command owns: clearing an
// unrelated settings-launched key would disarm its exclusion
// force-close.
@@ -16089,6 +16176,7 @@ export function App({
currentModeRef.current === 'plan',
);
const { prompt } = operation;
if (prompt && refuseModelSetup(prompt)) return true;
if (prompt && commandBlocked) return blockCommand();
if (!connectionRef.current.sessionId) {
void setComposerMode(executionModeRef.current, operation.enabled);
@@ -16503,6 +16591,16 @@ export function App({
pushToast('error', t('btw.side.empty'));
return true;
}
// Refuse before a session is provisioned; the panel's
// initial-prompt guard stays as the backstop for tabs created
// before a policy flip.
if (
!modelManagementRef.current.allowAdd &&
isModelSetupCommand(question, connectionRef.current.commands)
) {
pushToast('info', t('settings.models.addDisabled'));
return true;
}
createSideTask(question);
return true;
}
@@ -16757,6 +16855,7 @@ export function App({
[
offerCapacityRecovery,
beginPromptPreparation,
refuseModelSetup,
sendPrompt,
sessionActions,
sessionOwnerGuard,
@@ -16967,6 +17066,11 @@ export function App({
const retryErrorIdentity = { block: currentRetryError };
const retrySessionId = connectionRef.current.sessionId;
const retryText = lastSubmittedPromptRef.current;
if (refuseModelSetup(retryText)) {
disarmSubmittedPromptRetry();
setShowRetryHint(false);
return;
}
const retryImages = lastSubmittedImagesRef.current;
const retryFiles = lastSubmittedFilesRef.current;
const retryInputAnnotations = lastSubmittedInputAnnotationsRef.current;
@@ -17116,11 +17220,13 @@ export function App({
}
}, [
connected,
disarmSubmittedPromptRetry,
pushToast,
reportError,
rearmFailedTurnErrorRetry,
restoreOrDeferCancelledRetry,
retryOwnerIsCurrent,
refuseModelSetup,
sendPrompt,
store,
t,
@@ -17456,6 +17562,7 @@ export function App({
const handleDeleteModel = useCallback(
(target: { authType: string; modelId: string; baseUrl?: string }) => {
if (!modelManagementRef.current.allowDelete) return;
const owner = sessionOwnerGuard.capture();
const modelActionToken = ++modelActionTokenRef.current;
setModelActionBusy(true);
@@ -17930,7 +18037,15 @@ export function App({
!NON_WORKSPACE_BLOCKED_COMMANDS.has(command.name.toLowerCase()),
)
.filter(
(command) => !hiddenCommands.has(normalizeHiddenCommand(command.name)),
(command) =>
!hiddenCommands.has(normalizeHiddenCommand(command.name)) &&
(modelManagementPolicy.allowAdd ||
!(
command.name === 'auth' && command.source === 'builtin-command'
) ||
additionalSlashCommands.some(
(entry) => entry.name === command.name,
)),
)
.map((command) => {
const skillKey = skillDescriptionKey(command.name);
@@ -17943,6 +18058,7 @@ export function App({
});
}, [
additionalSlashCommands,
modelManagementPolicy.allowAdd,
connection.commands,
connection.sessionId,
connection.skills,
@@ -18562,6 +18678,7 @@ export function App({
onCreateSideTaskSession: createSideTaskSession,
onSideTaskCreated: handleSideTaskCreated,
onSideTaskTitleChange: handleSideTaskTitleChange,
onSideTaskInitialPromptRefused: handleSideTaskInitialPromptRefused,
onNestedRightPanelOpen: handleTurnOutputOpen,
onNestedArtifactsChange: handlePaneArtifactsChange,
onOpenNestedSubagent: openSubagentPanelForSession,
@@ -18571,6 +18688,7 @@ export function App({
onOpenWorkflowAgent: openEnvironmentAgent,
onError: reportError,
sessionWorkflowEnabled,
modelManagement,
workflow: sessionWorkflowEnabled
? {
todos: sessionWorkflowTodos,
@@ -18840,13 +18958,14 @@ export function App({
}}
/>
)}
{showAuthDialog && (
{modelManagementPolicy.allowAdd && showAuthDialog && (
<DialogShell
title={t('auth.title')}
size="lg"
onClose={handleCloseAuthDialog}
>
<AuthMessage
allowAdd={modelManagementPolicy.allowAdd}
onMessage={(text, type = 'status') => {
store.dispatch([
type === 'error'
@@ -19644,7 +19763,8 @@ export function App({
connections={
standalone ? <DaemonConnectionsSettings /> : undefined
}
modelManagement={{
modelManagementSectionProps={{
...modelManagementPolicy,
providers: providersState.providers,
configurations: modelConfigurations.models,
onUpdateContextWindow: handleModelContextWindowUpdate,
@@ -19659,6 +19779,7 @@ export function App({
onSelectModel: handleModelSelect,
onDeleteModel: handleDeleteModel,
onAddModel: () => {
if (!modelManagementRef.current.allowAdd) return;
if (
!isItemVisible(
'builtin:model-management',
@@ -20135,6 +20256,7 @@ export function App({
belong to the outer session, not the panes). */}
<WebShellCustomizationProvider value={customization}>
<SplitView
modelManagement={modelManagement}
planControlVisible={visibleComposerToolbarActions.includes('plan')}
sessionIds={splitSessionIds}
onAssistantTurnSettled={onAssistantTurnSettled}
@@ -37,6 +37,9 @@ let connectionState: any;
let streamingStateValue: string;
let pendingPermission: any;
let sessionHasActivePromptValue: boolean;
let queuedPromptDispatchError:
| ((text: string) => string | undefined)
| undefined;
let queuedPromptStreamingState: string | undefined;
let queuedPromptSessionHasActivePrompt: boolean | undefined;
let latestOnSubmit:
@@ -163,9 +166,11 @@ vi.mock('../session-catalog/session-catalog-hooks', () => ({
vi.mock('../hooks/useQueuedPrompts', () => ({
useQueuedPrompts: (args: {
getPromptDispatchError?: (text: string) => string | undefined;
streamingState: string;
sessionHasActivePrompt?: boolean;
}) => {
queuedPromptDispatchError = args.getPromptDispatchError;
queuedPromptStreamingState = args.streamingState;
queuedPromptSessionHasActivePrompt = args.sessionHasActivePrompt;
return {
@@ -3094,6 +3099,107 @@ describe('ChatPane', () => {
]);
});
it('hides model setup dynamically while preserving model and session commands', () => {
connectionState.commands = [
{ name: 'auth', description: 'Configure models' },
{ name: 'model', description: 'Select model' },
{ name: 'delete', description: 'Delete session' },
];
render();
const names = () =>
latestChatEditorProps.commands.map(
(command: { name: string }) => command.name,
);
expect(names()).toContain('auth');
rerender({ modelManagement: { allowAdd: false } });
expect(names()).not.toContain('auth');
expect(names()).toContain('model');
expect(names()).toContain('delete');
rerender({ modelManagement: { allowDelete: false } });
expect(names()).toContain('auth');
});
it.each([false, true])(
'refuses model setup only after the host handler declines (busy=%s)',
(busy) => {
sessionHasActivePromptValue = busy;
const onSlashCommand = vi.fn(() => false);
const onImageIngestionNotice = vi.fn();
render({
modelManagement: { allowAdd: false },
onSlashCommand,
onImageIngestionNotice,
});
let accepted;
act(() => {
accepted = latestOnSubmit!('/auth');
});
expect(accepted).toBe(true);
expect(onSlashCommand).toHaveBeenCalledWith({
command: 'auth',
args: '',
input: '/auth',
});
expect(sendPrompt).not.toHaveBeenCalled();
expect(enqueuePrompt).not.toHaveBeenCalled();
expect(onImageIngestionNotice).toHaveBeenCalledWith(
'warning',
'Adding models is disabled by the host.',
);
},
);
it.each([false, true])(
'lets the host take over a disabled model setup command (busy=%s)',
(busy) => {
sessionHasActivePromptValue = busy;
const onSlashCommand = vi.fn(() => true);
const onImageIngestionNotice = vi.fn();
render({
modelManagement: { allowAdd: false },
onSlashCommand,
onImageIngestionNotice,
});
let accepted;
act(() => {
accepted = latestOnSubmit!('/auth');
});
expect(accepted).toBe(true);
expect(onSlashCommand).toHaveBeenCalledWith({
command: 'auth',
args: '',
input: '/auth',
});
expect(sendPrompt).not.toHaveBeenCalled();
expect(enqueuePrompt).not.toHaveBeenCalled();
expect(onImageIngestionNotice).not.toHaveBeenCalled();
},
);
it('uses current model management policy in a retained submit callback', () => {
const onSlashCommand = vi.fn(() => false);
render({ onSlashCommand });
const retainedSubmit = latestOnSubmit!;
const retainedDispatchPolicy = queuedPromptDispatchError!;
expect(retainedDispatchPolicy('/auth')).toBeUndefined();
rerender({ onSlashCommand, modelManagement: { allowAdd: false } });
expect(retainedDispatchPolicy('/auth')).toBe(
'Adding models is disabled by the host.',
);
expect(retainedDispatchPolicy('/model')).toBeUndefined();
act(() => {
expect(retainedSubmit('/auth')).toBe(true);
});
// The host saw the command first and declined; the policy refused it.
expect(onSlashCommand).toHaveBeenCalledWith({
command: 'auth',
args: '',
input: '/auth',
});
expect(sendPrompt).not.toHaveBeenCalled();
expect(enqueuePrompt).not.toHaveBeenCalled();
});
it("lists the pane session's own commands in the slash menu", () => {
connectionState.commands = [
{ name: 'clear', description: 'Clear', source: 'builtin-command' },
@@ -3745,3 +3851,88 @@ it('does not submit a deferred /plan prompt after the runtime stops', async () =
await act(async () => prepared.resolve({ mode: 'plan' }));
expect(sendPrompt).not.toHaveBeenCalled();
});
it.each(['/auth', '/login', '/connect'])(
'blocks rewritten /plan %s before changing mode',
(command) => {
const notice = vi.fn();
connectionState.commands = [
{
name: 'auth',
source: 'builtin-command',
altNames: ['login', 'connect'],
},
];
render({
modelManagement: { allowAdd: false },
onImageIngestionNotice: notice,
});
act(() => {
expect(latestOnSubmit!(`/plan ${command}`)).toBe(true);
});
expect(setApprovalMode).not.toHaveBeenCalled();
expect(sendPrompt).not.toHaveBeenCalled();
expect(notice).toHaveBeenCalledWith(
'warning',
'Adding models is disabled by the host.',
);
},
);
it('rechecks model policy after asynchronous plan preparation', async () => {
const prepared = deferred<{ mode: string }>();
setApprovalMode.mockReturnValueOnce(prepared.promise);
const notice = vi.fn();
render({ onImageIngestionNotice: notice });
act(() => {
latestOnSubmit!('/plan /auth');
});
expect(setApprovalMode).toHaveBeenCalledOnce();
rerender({
modelManagement: { allowAdd: false },
onImageIngestionNotice: notice,
});
await act(async () => {
prepared.resolve({ mode: 'yolo' });
});
expect(sendPrompt).not.toHaveBeenCalled();
expect(notice).toHaveBeenCalledWith(
'warning',
'Adding models is disabled by the host.',
);
});
it('preserves a stopped pane draft even when model setup is disabled', () => {
connectionState.runtimeStopped = true;
const notice = vi.fn();
render({
modelManagement: { allowAdd: false },
onImageIngestionNotice: notice,
});
act(() => {
expect(latestOnSubmit!('/auth')).toBe(false);
});
expect(notice).toHaveBeenCalledExactlyOnceWith(
'warning',
'This workspace was stopped to free ACP capacity. Resume this conversation when needed.',
);
expect(sendPrompt).not.toHaveBeenCalled();
});
it.each(['builtin-command', 'project', 'missing'])(
'uses loaded %s identity for the auth menu and dispatch',
async (source) => {
connectionState.commands = [
{ name: 'clear', source: 'builtin-command' },
// A ready snapshot without any auth entry (disabled list, SSH
// whitelist) must fail closed exactly like the builtin identity does.
...(source === 'missing' ? [] : [{ name: 'auth', source }]),
];
render({ modelManagement: { allowAdd: false } });
const names = latestChatEditorProps.commands.map(
(command: { name: string }) => command.name,
);
expect(names.includes('auth')).toBe(source === 'project');
await act(async () => {
latestOnSubmit!('/auth');
});
expect(sendPrompt).toHaveBeenCalledTimes(source === 'project' ? 1 : 0);
},
);
@@ -57,6 +57,11 @@ import type {
EditorHandle,
} from '../hooks/useComposerCore';
import { useQueuedPrompts } from '../hooks/useQueuedPrompts';
import {
isModelSetupCommand,
resolveModelManagement,
type WebShellModelManagementOptions,
} from '../modelManagement';
import { isAskUserPermission } from '../utils/askUserPermission';
import { isDaemonApprovalMode } from '../utils/sessionPreparation';
import { isVisibleComposerModel } from '../utils/composerModels';
@@ -222,6 +227,7 @@ export interface ChatPaneProps {
onImageIngestionNotice?: (tone: 'warning' | 'error', message: string) => void;
/** Host slash-command callback shared with the main chat composer. */
onSlashCommand?: WebShellSlashCommandHandler;
modelManagement?: WebShellModelManagementOptions;
onOpenGoals?: () => void;
onRightPanelOpen?: (request: TurnOutputOpenRequest) => void;
onOpenMonitor?: (
@@ -274,6 +280,7 @@ export function ChatPane({
onError,
onImageIngestionNotice,
onSlashCommand,
modelManagement,
onOpenGoals,
onRightPanelOpen,
onOpenMonitor,
@@ -578,6 +585,9 @@ export function ChatPane({
},
[onError],
);
const modelManagementPolicy = resolveModelManagement(modelManagement);
const modelManagementRef = useRef(modelManagementPolicy);
modelManagementRef.current = modelManagementPolicy;
const onSlashCommandRef = useRef(onSlashCommand);
onSlashCommandRef.current = onSlashCommand;
const pendingApproval = useMemo(
@@ -683,6 +693,11 @@ export function ChatPane({
editLastQueuedPrompt,
clearQueuedPrompts,
} = useQueuedPrompts({
getPromptDispatchError: (text) =>
!modelManagementRef.current.allowAdd &&
isModelSetupCommand(text, connectionRef.current.commands)
? t('settings.models.addDisabled')
: undefined,
connected: connection.status === 'connected',
writeBlocked: connection.runtimeStopped,
runtimeStopped: connection.runtimeStopped,
@@ -928,6 +943,16 @@ export function ChatPane({
) {
return true;
}
// Only when the host declines does the policy consume a model-setup
// command — below the runtimeStopped fence so a stopped runtime keeps
// the draft instead of toasting, and ahead of any daemon dispatch.
if (
!modelManagementRef.current.allowAdd &&
isModelSetupCommand(text, connectionRef.current.commands)
) {
onImageIngestionNotice?.('warning', t('settings.models.addDisabled'));
return true;
}
const planCommand = trimmed.match(/^\/plan(?:\s+(.*))?$/is);
const planOperation = planCommand
? parsePlanCommand(planCommand[1] ?? '', planMode)
@@ -958,6 +983,13 @@ export function ChatPane({
)
return false;
trimmed = planOperation.prompt;
if (
!modelManagementRef.current.allowAdd &&
isModelSetupCommand(trimmed, connectionRef.current.commands)
) {
onImageIngestionNotice?.('warning', t('settings.models.addDisabled'));
return true;
}
}
if (!planOperation && /^\/goal(?:\s|$)/i.test(trimmed)) {
// The same guard App.tsx applies before any slash handling: a control
@@ -1046,8 +1078,18 @@ export function ChatPane({
const admissionOwner = admissionOwnerRef.current;
let admissionStarted = false;
let admitted = false;
const submit = () =>
actions
const submit = () => {
if (
!modelManagementRef.current.allowAdd &&
isModelSetupCommand(trimmed, connectionRef.current.commands)
) {
onImageIngestionNotice?.(
'warning',
t('settings.models.addDisabled'),
);
return;
}
return actions
.sendPrompt(trimmed, {
submittedPrompt: text,
...(images && images.length ? { images } : {}),
@@ -1094,6 +1136,7 @@ export function ChatPane({
error,
);
});
};
if (planOperation) {
const owner = sessionOwnerGuard.capture();
planPreparationRef.current = owner;
@@ -1350,16 +1393,22 @@ export function ChatPane({
return localizeBuiltinDescriptions(
mergeCommands(connection.commands ?? [], getLocalCommands(t)),
t,
).map((command) => {
const skillKey = skillDescriptionKey(command.name);
if (!skillKey) return command;
return {
...command,
displayCategory: 'skill' as const,
description: t(skillKey),
};
});
}, [connection.commands, t]);
)
.filter(
(command) =>
modelManagementPolicy.allowAdd ||
!isModelSetupCommand(`/${command.name}`, connection.commands),
)
.map((command) => {
const skillKey = skillDescriptionKey(command.name);
if (!skillKey) return command;
return {
...command,
displayCategory: 'skill' as const,
description: t(skillKey),
};
});
}, [connection.commands, modelManagementPolicy.allowAdd, t]);
const skills = useMemo(() => {
const commandsByName = new Map(
commands.map((command) => [command.name.toLowerCase(), command]),
@@ -107,6 +107,7 @@ vi.mock('./ChatPane', () => ({
<div
data-testid="chat-pane"
data-plan-visible={String(props.planControlVisible)}
data-model-management={JSON.stringify(props.modelManagement)}
data-pane-workspace={props.workspaceCwd}
data-session-details={props.sessionSummary?.sessionId}
data-maximized={props.isMaximized ? 'true' : 'false'}
@@ -246,6 +247,18 @@ function openPicker(): void {
}
describe('SplitView', () => {
it('forwards model management policy to every pane', () => {
const modelManagement = { allowAdd: false, allowDelete: true };
render({ sessionIds: ['s1', 's2'], modelManagement });
const panes = container!.querySelectorAll('[data-testid="chat-pane"]');
expect(panes).toHaveLength(2);
for (const pane of panes) {
expect(pane.getAttribute('data-model-management')).toBe(
JSON.stringify(modelManagement),
);
}
});
it('keeps a newly added controlled pane active without relying on composer autofocus', () => {
function ControlledSplit() {
const [ids, setIds] = React.useState(['s1', 's2']);
@@ -23,6 +23,7 @@ import type {
DaemonWorkspaceCapability,
} from '@qwen-code/sdk/daemon';
import type { WebShellSlashCommandHandler } from '../App';
import type { WebShellModelManagementOptions } from '../modelManagement';
import type { RegisterContextUsageControls } from '../hooks/useContextUsageControls';
import { useI18n } from '../i18n';
import { ChatPane, type PaneHeaderActionsRenderer } from './ChatPane';
@@ -76,6 +77,7 @@ export interface SplitViewProps {
onError?: (error: unknown, fallback: string) => void;
onImageIngestionNotice?: (tone: 'warning' | 'error', message: string) => void;
onSlashCommand?: WebShellSlashCommandHandler;
modelManagement?: WebShellModelManagementOptions;
onOpenGoals?: () => void;
onRightPanelOpen?: (request: TurnOutputOpenRequest) => void;
onOpenMonitor?: (
@@ -131,6 +133,7 @@ export function SplitView({
onError,
onImageIngestionNotice,
onSlashCommand,
modelManagement,
onOpenGoals,
onRightPanelOpen,
onOpenMonitor,
@@ -669,6 +672,7 @@ export function SplitView({
onError={onError}
onImageIngestionNotice={onImageIngestionNotice}
onSlashCommand={onSlashCommand}
modelManagement={modelManagement}
onOpenGoals={onOpenGoals}
onRightPanelOpen={onRightPanelOpen}
onOpenMonitor={onOpenMonitor}
@@ -99,6 +99,16 @@ vi.mock('../terminal/TerminalPanel', () => ({
),
}));
const sideTaskPanelProps = vi.hoisted(() => ({
current: undefined as Record<string, unknown> | undefined,
}));
vi.mock('./SideTaskPanel', () => ({
SideTaskPanel: (props: Record<string, unknown>) => {
sideTaskPanelProps.current = props;
return <div data-testid="side-task-panel" />;
},
}));
const { ArtifactPanel } = await import('./ArtifactPanel');
type ArtifactPanelTab = Parameters<typeof ArtifactPanel>[0]['tabs'][number];
const { useArtifactWorkspaceTarget } = await import(
@@ -336,6 +346,7 @@ function scheduledTaskPanel(
afterEach(() => {
vi.unstubAllGlobals();
sideTaskPanelProps.current = undefined;
// The boundary matrix spies on DOMParser.prototype per row; restore it so
// the leak cannot skew call-count assertions in later tests.
vi.restoreAllMocks();
@@ -2061,6 +2072,52 @@ describe('ArtifactPanel add menu', () => {
expect(onCreateSideTask).toHaveBeenCalledOnce();
});
it('forwards model management policy and the refusal callback to the side task panel', () => {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
mounted.push({ root, container });
const modelManagement = { allowAdd: false, allowDelete: false };
const onSideTaskInitialPromptRefused = vi.fn();
act(() => {
root.render(
<I18nProvider language="en">
<ArtifactPanel
artifacts={[]}
tabs={[
{
id: 'side-task:1',
kind: 'side_task',
title: 'Side task',
parentSessionId: 'parent-session',
workspaceCwd: '/work/project',
sessionId: 'side-session-1',
},
]}
activeTabId="side-task:1"
reviewChanges={[]}
selectedReviewPath={null}
sideTaskAvailable
modelManagement={modelManagement}
onSideTaskInitialPromptRefused={onSideTaskInitialPromptRefused}
onSelectTab={() => {}}
onCloseTab={() => {}}
onOpenFilePreview={() => {}}
onClose={() => {}}
/>
</I18nProvider>,
);
});
expect(sideTaskPanelProps.current?.modelManagement).toEqual(
modelManagement,
);
expect(sideTaskPanelProps.current?.onInitialPromptRefused).toBe(
onSideTaskInitialPromptRefused,
);
});
it('creates a side task directly from the empty page when there is no history', () => {
const onCreateSideTask = vi.fn();
const container = document.createElement('div');
@@ -105,6 +105,7 @@ import { SubagentDetail } from './SubagentDetail';
import { AgentWorkflow } from './AgentWorkflow';
import type { EnvironmentAgentTask } from '../panels/EnvironmentPanel';
import { SideTaskPanel } from './SideTaskPanel';
import type { WebShellModelManagementOptions } from '../../modelManagement';
import { SessionWorkflowInspector } from '../workflow/SessionWorkflowInspector';
import type { SessionWorkflowProjection } from '../workflow/session-workflow-model';
import { TerminalPanel } from '../terminal/TerminalPanel';
@@ -423,6 +424,7 @@ interface ArtifactPanelProps {
title: string,
fromFirstPrompt?: boolean,
) => void;
onSideTaskInitialPromptRefused?: (tabId: string) => void;
onNestedRightPanelOpen?: (request: TurnOutputOpenRequest) => void;
onNestedArtifactsChange?: (
sessionId: string,
@@ -439,6 +441,7 @@ interface ArtifactPanelProps {
onOpenWorkflowAgent?: (task: EnvironmentAgentTask) => void;
onError?: (error: unknown, fallback: string) => void;
sessionWorkflowEnabled?: boolean;
modelManagement?: WebShellModelManagementOptions;
workflow?: {
todos: readonly TodoItem[];
tools: readonly ACPToolCall[];
@@ -492,6 +495,7 @@ export function ArtifactPanel({
onCreateSideTaskSession,
onSideTaskCreated,
onSideTaskTitleChange,
onSideTaskInitialPromptRefused,
onNestedRightPanelOpen,
onNestedArtifactsChange,
onOpenNestedSubagent,
@@ -501,6 +505,7 @@ export function ArtifactPanel({
onOpenWorkflowAgent,
onError,
sessionWorkflowEnabled,
modelManagement,
workflow,
onImageIngestionNotice,
deferSubagentMount = false,
@@ -1271,10 +1276,12 @@ export function ArtifactPanel({
}
onCreated={onSideTaskCreated ?? ignoreSideTaskCreated}
onTitleChange={onSideTaskTitleChange ?? ignoreSideTaskTitleChange}
onInitialPromptRefused={onSideTaskInitialPromptRefused}
onRightPanelOpen={onNestedRightPanelOpen}
onArtifactsChange={onNestedArtifactsChange}
onError={onError}
sessionWorkflowEnabled={sessionWorkflowEnabled}
modelManagement={modelManagement}
onImageIngestionNotice={onImageIngestionNotice}
/>
) : activeTab.kind === 'image' ? (
@@ -15,6 +15,7 @@ const {
catalogController,
} = vi.hoisted(() => ({
connection: {
commands: undefined as Array<{ name: string; source: string }> | undefined,
status: 'idle',
sessionId: undefined as string | undefined,
workspaceCwd: undefined as string | undefined,
@@ -117,6 +118,8 @@ afterEach(() => {
container?.remove();
container = null;
root = null;
connection.commands = undefined;
vi.useRealTimers();
connection.status = 'idle';
connection.sessionId = undefined;
connection.workspaceCwd = undefined;
@@ -374,6 +377,102 @@ it('renders a restored side task as a full chat pane', () => {
});
});
it('threads model management policy to its chat pane', () => {
connection.sessionId = 'side-session-1';
connection.status = 'connected';
transcript.blocks = [{ kind: 'user' }];
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
const modelManagement = { allowAdd: false, allowDelete: false };
act(() => {
renderSideTask({ modelManagement });
});
expect(latestChatPaneProps.current?.modelManagement).toEqual(modelManagement);
});
it('does not send a disabled model setup command as the initial side-task prompt', async () => {
connection.commands = [{ name: 'auth', source: 'builtin-command' }];
connection.sessionId = 'side-session-1';
connection.status = 'connected';
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
const onImageIngestionNotice = vi.fn();
const onInitialPromptRefused = vi.fn();
await act(async () => {
renderSideTask({
initialPrompt: '/auth',
modelManagement: { allowAdd: false },
onImageIngestionNotice,
onInitialPromptRefused,
});
await Promise.resolve();
});
expect(sendPrompt).not.toHaveBeenCalled();
expect(onImageIngestionNotice).toHaveBeenCalledTimes(1);
expect(onImageIngestionNotice).toHaveBeenCalledWith(
'warning',
'Adding models is disabled by the host.',
);
// The refusal must be terminal for the tab: the parent is told to drop the
// stored prompt, so a later remount neither re-toasts nor replays it.
expect(onInitialPromptRefused).toHaveBeenCalledWith(
'side-task:side-session-1',
);
await act(async () => root!.unmount());
root = createRoot(container);
await act(async () => {
renderSideTask({
initialPrompt: undefined,
modelManagement: { allowAdd: false },
onImageIngestionNotice,
onInitialPromptRefused,
});
await Promise.resolve();
});
expect(onImageIngestionNotice).toHaveBeenCalledTimes(1);
expect(sendPrompt).not.toHaveBeenCalled();
});
it('does not replay a refused initial prompt after the host re-allows adds', async () => {
connection.commands = [{ name: 'auth', source: 'builtin-command' }];
connection.sessionId = 'side-session-1';
connection.status = 'connected';
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
const onImageIngestionNotice = vi.fn();
const onInitialPromptRefused = vi.fn();
await act(async () => {
renderSideTask({
initialPrompt: '/auth',
modelManagement: { allowAdd: false },
onImageIngestionNotice,
onInitialPromptRefused,
});
await Promise.resolve();
});
expect(onInitialPromptRefused).toHaveBeenCalledWith(
'side-task:side-session-1',
);
expect(sendPrompt).not.toHaveBeenCalled();
await act(async () => root!.unmount());
root = createRoot(container);
await act(async () => {
renderSideTask({
initialPrompt: undefined,
modelManagement: { allowAdd: true },
onImageIngestionNotice,
onInitialPromptRefused,
});
await Promise.resolve();
});
expect(sendPrompt).not.toHaveBeenCalled();
});
it('threads sessionWorkflowEnabled to its chat pane', () => {
connection.sessionId = 'side-session-1';
connection.displayName = 'Investigate flaky tests';
@@ -492,6 +591,7 @@ it('sends the /btw question as the first side-task prompt', async () => {
await act(async () => {
renderSideTask({
initialPrompt: 'Explain the current implementation',
modelManagement: { allowAdd: false },
onTitleChange,
});
await Promise.resolve();
@@ -721,3 +821,83 @@ it('bounds first-prompt title retries and reports the final failure', async () =
);
expect(onError).toHaveBeenCalledWith(failure, 'Failed to name side task');
});
it('waits for the command snapshot before classifying an initial project auth command', async () => {
connection.sessionId = 'side-session-1';
connection.status = 'connected';
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
const props = {
initialPrompt: '/auth acme',
modelManagement: { allowAdd: false },
onInitialPromptRefused: vi.fn(),
};
await act(async () => {
renderSideTask(props);
});
expect(sendPrompt).not.toHaveBeenCalled();
expect(props.onInitialPromptRefused).not.toHaveBeenCalled();
connection.commands = [
{ name: 'clear', source: 'builtin-command' },
{ name: 'auth', source: 'project' },
];
await act(async () => {
renderSideTask(props);
});
expect(sendPrompt).toHaveBeenCalledExactlyOnceWith(
'/auth acme',
expect.anything(),
);
expect(props.onInitialPromptRefused).not.toHaveBeenCalled();
});
it.each([false, true])(
'keeps an unresolved initial prompt after the metadata timeout (intervening prompt=%s)',
async (interveningPrompt) => {
vi.useFakeTimers();
connection.sessionId = 'side-session-1';
connection.status = 'connected';
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
const props = {
initialPrompt: '/auth acme',
modelManagement: { allowAdd: false },
onInitialPromptRefused: vi.fn(),
onImageIngestionNotice: vi.fn(),
};
await act(async () => {
renderSideTask(props);
});
await act(async () => {
vi.advanceTimersByTime(4_999);
});
expect(props.onImageIngestionNotice).not.toHaveBeenCalled();
await act(async () => {
renderSideTask({ ...props });
vi.advanceTimersByTime(1);
});
expect(props.onImageIngestionNotice).toHaveBeenCalledExactlyOnceWith(
'warning',
'Command information is still unavailable. Your side-task prompt has been kept and will be checked when it loads.',
);
expect(props.onInitialPromptRefused).not.toHaveBeenCalled();
expect(sendPrompt).not.toHaveBeenCalled();
if (interveningPrompt)
transcript.blocks = [{ kind: 'user', text: 'another question' }];
connection.commands = [
{ name: 'clear', source: 'builtin-command' },
{ name: 'auth', source: 'project' },
];
await act(async () => {
renderSideTask(props);
});
expect(sendPrompt).toHaveBeenCalledTimes(interveningPrompt ? 0 : 1);
expect(props.onInitialPromptRefused).not.toHaveBeenCalled();
await act(async () => {
vi.advanceTimersByTime(10_000);
});
expect(props.onImageIngestionNotice).toHaveBeenCalledTimes(1);
},
);
@@ -14,6 +14,12 @@ import {
import type { TurnOutputOpenRequest } from './TurnOutputs';
import type { DaemonSessionArtifact } from '@qwen-code/sdk/daemon';
import { useI18n } from '../../i18n';
import {
isModelSetupCommand,
isModelCommandSnapshotReady,
resolveModelManagement,
type WebShellModelManagementOptions,
} from '../../modelManagement';
import { ChatPane } from '../ChatPane';
import { Button } from '../ui/button';
import { Spinner } from '../ui/spinner';
@@ -45,7 +51,10 @@ interface SideTaskPanelProps {
) => void;
onError?: (error: unknown, fallback: string) => void;
sessionWorkflowEnabled?: boolean;
modelManagement?: WebShellModelManagementOptions;
onImageIngestionNotice?: (tone: 'warning' | 'error', message: string) => void;
/** Policy refused the stored initial prompt; the parent drops it from the tab. */
onInitialPromptRefused?: (tabId: string) => void;
}
const FIRST_PROMPT_RENAME_ATTEMPTS = 3;
@@ -65,7 +74,9 @@ export function SideTaskPanel({
onArtifactsChange,
onError,
sessionWorkflowEnabled,
modelManagement,
onImageIngestionNotice,
onInitialPromptRefused,
}: SideTaskPanelProps) {
if (!sessionId) {
return (
@@ -103,7 +114,9 @@ export function SideTaskPanel({
onArtifactsChange={onArtifactsChange}
onError={onError}
sessionWorkflowEnabled={sessionWorkflowEnabled}
modelManagement={modelManagement}
onImageIngestionNotice={onImageIngestionNotice}
onInitialPromptRefused={onInitialPromptRefused}
/>
</DaemonSessionProvider>
);
@@ -207,7 +220,9 @@ function SideTaskSession({
onArtifactsChange,
onError,
sessionWorkflowEnabled,
modelManagement,
onImageIngestionNotice,
onInitialPromptRefused,
}: Omit<
SideTaskPanelProps,
'sessionId' | 'parentSessionId' | 'createSession' | 'onCreated'
@@ -295,11 +310,45 @@ function SideTaskSession({
],
);
const initialPromptSentRef = useRef(false);
const waitingForCommandSnapshot = Boolean(
initialPrompt?.trim() &&
restoredEmptySession &&
!initialPromptSentRef.current &&
!resolveModelManagement(modelManagement).allowAdd &&
!isModelCommandSnapshotReady(connection.commands) &&
isModelSetupCommand(initialPrompt),
);
const commandWaitNoticeRef = useRef(onImageIngestionNotice);
commandWaitNoticeRef.current = onImageIngestionNotice;
useEffect(() => {
if (!waitingForCommandSnapshot) return;
const timer = setTimeout(() => {
commandWaitNoticeRef.current?.(
'warning',
t('sideTask.commandsLoadingTimedOut'),
);
}, 5_000);
return () => clearTimeout(timer);
}, [waitingForCommandSnapshot, initialPrompt, connection.sessionId, t]);
useEffect(() => {
const prompt = initialPrompt?.trim();
if (!prompt || !restoredEmptySession || initialPromptSentRef.current)
if (
!prompt ||
!restoredEmptySession ||
initialPromptSentRef.current ||
waitingForCommandSnapshot
)
return;
initialPromptSentRef.current = true;
if (
!resolveModelManagement(modelManagement).allowAdd &&
isModelSetupCommand(prompt, connection.commands)
) {
onImageIngestionNotice?.('warning', t('settings.models.addDisabled'));
onInitialPromptRefused?.(tabId);
return;
}
actions
.sendPrompt(prompt, {
onAdmitted: () => {
@@ -319,13 +368,19 @@ function SideTaskSession({
}, [
actions,
catalogOwnerCwd,
connection.commands,
connection.sessionId,
initialPrompt,
modelManagement,
onImageIngestionNotice,
onInitialPromptRefused,
nameFromFirstPrompt,
onError,
restoredEmptySession,
waitingForCommandSnapshot,
sessionCatalogController,
t,
tabId,
]);
if (!connection.sessionId) {
@@ -348,6 +403,7 @@ function SideTaskSession({
onRightPanelOpen={onRightPanelOpen}
onPaneArtifactsChange={onArtifactsChange}
sessionWorkflowEnabled={sessionWorkflowEnabled}
modelManagement={modelManagement}
/>
);
}
@@ -89,6 +89,7 @@ async function install(
async function openAndSave(
setup?: (click: (text: string) => Promise<void>) => Promise<void>,
allowAdd = true,
) {
const onMessage = vi.fn();
const onClose = vi.fn();
@@ -98,7 +99,11 @@ async function openAndSave(
await act(async () => {
root?.render(
<I18nProvider language="en">
<AuthMessage onMessage={onMessage} onClose={onClose} />
<AuthMessage
onMessage={onMessage}
onClose={onClose}
allowAdd={allowAdd}
/>
</I18nProvider>,
);
await Promise.resolve();
@@ -546,3 +551,26 @@ describe('AuthMessage model configuration', () => {
);
});
});
describe('host model management', () => {
it('does not install when model addition is disabled', async () => {
await openAndSave(undefined, false);
expect(actions.installAuthProvider).not.toHaveBeenCalled();
});
it('checks the latest add policy after setup is already open', async () => {
await openAndSave(async () => {
await act(async () =>
root?.render(
<I18nProvider language="en">
<AuthMessage
allowAdd={false}
onMessage={vi.fn()}
onClose={vi.fn()}
/>
</I18nProvider>,
),
);
});
expect(actions.installAuthProvider).not.toHaveBeenCalled();
});
});
@@ -52,6 +52,7 @@ type AuthStep =
| 'advancedConfig';
interface AuthMessageProps {
allowAdd?: boolean;
onMessage: (text: string, type?: 'status' | 'error') => void;
onClose: () => void;
}
@@ -141,7 +142,13 @@ function normalizeModelIds(value: string): string[] {
];
}
export function AuthMessage({ onMessage, onClose }: AuthMessageProps) {
export function AuthMessage({
onMessage,
onClose,
allowAdd = true,
}: AuthMessageProps) {
const allowAddRef = useRef(allowAdd);
allowAddRef.current = allowAdd;
const { t } = useI18n();
const fieldId = useId();
const openExternalLink = useExternalLinkOpener();
@@ -423,7 +430,8 @@ export function AuthMessage({ onMessage, onClose }: AuthMessageProps) {
]);
const save = useCallback(() => {
if (!provider || saving || !validateAdvanced()) return;
if (!allowAddRef.current || !provider || saving || !validateAdvanced())
return;
const owner = ownerRef.current;
const operation = ++saveOperationRef.current;
const isCurrent = () =>
@@ -543,3 +543,60 @@ describe('ModelManagementSection', () => {
expect('baseUrl' in arg).toBe(false);
});
});
describe('host model management controls', () => {
it.each([
[true, true],
[true, false],
[false, true],
[false, false],
])(
'independently controls add=%s and delete=%s while retaining selection',
(allowAdd, allowDelete) => {
const { container, props } = renderSection({ allowAdd, allowDelete });
const buttons = Array.from(container.querySelectorAll('button'));
expect(
buttons.some((button) => button.textContent === '+ Add Model'),
).toBe(allowAdd);
expect(
buttons.some((button) =>
button.getAttribute('aria-label')?.startsWith('Delete '),
),
).toBe(allowDelete);
expect(container.textContent).toContain('GPT-4o');
const select = buttons.find(
(button) =>
button.getAttribute('aria-label')?.includes('DeepSeek V4') &&
!button.getAttribute('aria-label')?.startsWith('Delete'),
);
act(() => select!.click());
expect(props.onSelectModel).toHaveBeenCalledWith('deepseek-v4(openai)');
expect(props.onDeleteModel).not.toHaveBeenCalled();
},
);
it('clears an open delete confirmation when deletion is disabled', () => {
const { container, props } = renderSection();
const button = container.querySelector<HTMLButtonElement>(
'[aria-label="Delete GPT-4o"]',
)!;
act(() => button.click());
const { root } = mounted.at(-1)!;
const update = (allowDelete: boolean) =>
act(() =>
root.render(
<I18nProvider language="en">
<ModelManagementSection {...props} allowDelete={allowDelete} />
</I18nProvider>,
),
);
expect(
container.querySelector('[aria-label="Confirm GPT-4o"]'),
).not.toBeNull();
update(false);
expect(container.querySelector('[aria-label="Confirm GPT-4o"]')).toBeNull();
update(true);
expect(container.querySelector('[aria-label="Confirm GPT-4o"]')).toBeNull();
expect(props.onDeleteModel).not.toHaveBeenCalled();
});
});
@@ -1,3 +1,4 @@
import type { WebShellModelManagementOptions } from '../../modelManagement';
import { useEffect, useId, useState } from 'react';
import type {
DaemonModelConfiguration,
@@ -20,7 +21,7 @@ export interface ModelDeleteTarget {
baseUrl?: string;
}
export interface ModelManagementProps {
export interface ModelManagementProps extends WebShellModelManagementOptions {
providers: DaemonWorkspaceProviderStatus[];
configurations?: DaemonModelConfiguration[];
onUpdateContextWindow?: (
@@ -73,6 +74,8 @@ function findCurrentRowKey(
}
export function ModelManagementSection({
allowAdd = true,
allowDelete = true,
providers,
configurations = [],
onUpdateContextWindow,
@@ -87,6 +90,10 @@ export function ModelManagementSection({
const { t } = useI18n();
const [confirmKey, setConfirmKey] = useState<string | null>(null);
useEffect(() => {
if (!allowDelete) setConfirmKey(null);
}, [allowDelete]);
// Escape dismisses the inline delete confirmation — the conventional gesture,
// so keyboard users don't have to Tab to Cancel.
useEffect(() => {
@@ -144,14 +151,16 @@ export function ModelManagementSection({
<div className={styles.section} data-testid="model-management">
<div className={styles.header}>
<span className={styles.title}>{t('settings.models.title')}</span>
<button
type="button"
className={styles.addButton}
disabled={busy}
onClick={onAddModel}
>
{t('settings.models.add')}
</button>
{allowAdd && (
<button
type="button"
className={styles.addButton}
disabled={busy}
onClick={onAddModel}
>
{t('settings.models.add')}
</button>
)}
</div>
{error && <div className={styles.hint}>{error.message}</div>}
@@ -291,7 +300,8 @@ export function ModelManagementSection({
{t('settings.models.setCurrent')}
</button>
)}
{!model.isRuntime &&
{allowDelete &&
!model.isRuntime &&
(confirming ? (
<>
<button
@@ -300,6 +310,7 @@ export function ModelManagementSection({
disabled={busy}
aria-label={`${t('settings.models.confirmDelete')} ${modelLabel}`}
onClick={() => {
if (!allowDelete) return;
setConfirmKey(null);
onDeleteModel({
...(model.configurationKey
@@ -215,7 +215,7 @@ function renderPanel(
overrides: Partial<{
onSubDialog: (key: string, scope: 'workspace' | 'user') => void;
onThemeChange: (theme: 'dark' | 'light') => void;
modelManagement: ModelManagementProps;
modelManagementSectionProps: ModelManagementProps;
initialCategory: string;
presentation: WebShellSettingsOptions;
connections: ReactNode;
@@ -233,7 +233,7 @@ function renderPanel(
onSubDialog={overrides.onSubDialog ?? noop}
chatWidthMode="1000"
onChatWidthModeChange={noop}
modelManagement={overrides.modelManagement}
modelManagementSectionProps={overrides.modelManagementSectionProps}
connections={overrides.connections}
/>
</I18nProvider>,
@@ -740,7 +740,7 @@ describe('SettingsMessage user-scope editing', () => {
Promise.resolve({} as DaemonSettingUpdateResult),
);
const container = renderPanel(makeState([subDialogSetting()], setValue), {
modelManagement: makeModelManagement(),
modelManagementSectionProps: makeModelManagement(),
});
// Model is the only category, so it's active — the management block shows.
@@ -753,11 +753,11 @@ describe('SettingsMessage user-scope editing', () => {
expect(block?.parentElement?.className).toContain('mt-4');
});
it('keeps the model list and selection when ordinary Model fields are excluded', () => {
const modelManagement = makeModelManagement();
modelManagement.currentModelId = 'other';
modelManagement.providers[0]!.models[0]!.isCurrent = false;
const modelManagementSectionProps = makeModelManagement();
modelManagementSectionProps.currentModelId = 'other';
modelManagementSectionProps.providers[0]!.models[0]!.isCurrent = false;
const container = renderPanel(makeState([subDialogSetting()], vi.fn()), {
modelManagement,
modelManagementSectionProps,
presentation: { excludeItems: ['setting:fast-model'] },
});
expect(container.textContent).not.toContain('Fast Model');
@@ -768,14 +768,14 @@ describe('SettingsMessage user-scope editing', () => {
);
expect(modelButton).toBeTruthy();
act(() => modelButton!.click());
expect(modelManagement.onSelectModel).toHaveBeenCalledWith(
expect(modelManagementSectionProps.onSelectModel).toHaveBeenCalledWith(
'gpt-4o(openai)',
);
});
it('excludes the model block without hiding ordinary Model settings', () => {
const container = renderPanel(makeState([subDialogSetting()], vi.fn()), {
modelManagement: makeModelManagement(),
modelManagementSectionProps: makeModelManagement(),
presentation: { excludeItems: ['builtin:model-management'] },
});
expect(container.textContent).toContain('Fast Model');
@@ -825,7 +825,7 @@ describe('SettingsMessage user-scope editing', () => {
it('shows an empty state when every available item is excluded', () => {
const container = renderPanel(makeState([subDialogSetting()], vi.fn()), {
modelManagement: makeModelManagement(),
modelManagementSectionProps: makeModelManagement(),
presentation: { excludeItems: WEB_SHELL_SETTING_ITEM_IDS },
});
expect(container.querySelectorAll('nav button')).toHaveLength(0);
@@ -849,7 +849,7 @@ describe('SettingsMessage user-scope editing', () => {
expect(baseline.textContent).toContain('Test Flag');
const container = renderPanel(state, {
initialCategory: 'General',
modelManagement: makeModelManagement(),
modelManagementSectionProps: makeModelManagement(),
presentation: { includeItems: ['setting:fast-model'] },
});
const check = () => {
@@ -881,7 +881,7 @@ describe('SettingsMessage user-scope editing', () => {
const container = renderPanel(
makeState([boolSetting(), subDialogSetting()], vi.fn()),
{
modelManagement: makeModelManagement(),
modelManagementSectionProps: makeModelManagement(),
presentation,
},
);
@@ -933,7 +933,7 @@ describe('SettingsMessage user-scope editing', () => {
it('preserves default content and counts for an empty exclusion list', () => {
const state = makeState([subDialogSetting()], vi.fn());
const options = { modelManagement: makeModelManagement() };
const options = { modelManagementSectionProps: makeModelManagement() };
const baseline = renderPanel(state, options);
const empty = renderPanel(state, {
...options,
@@ -948,7 +948,7 @@ describe('SettingsMessage user-scope editing', () => {
it('keeps a model-only category when descriptors are absent', () => {
const container = renderPanel(makeState([], vi.fn()), {
initialCategory: 'Model',
modelManagement: makeModelManagement(),
modelManagementSectionProps: makeModelManagement(),
});
const block = container.querySelector('[data-testid="model-management"]');
expect(block).toBeTruthy();
@@ -971,7 +971,7 @@ describe('SettingsMessage user-scope editing', () => {
setValue: vi.fn(),
};
const container = renderPanel(state, {
modelManagement: makeModelManagement(),
modelManagementSectionProps: makeModelManagement(),
});
const navLabels = Array.from(container.querySelectorAll('nav button')).map(
(button) => button.textContent,
@@ -991,7 +991,7 @@ describe('SettingsMessage user-scope editing', () => {
};
const container = renderPanel(
makeState([subDialogSetting(), modelFallbacksSetting], vi.fn()),
{ modelManagement: makeModelManagement() },
{ modelManagementSectionProps: makeModelManagement() },
);
const modelNav = Array.from(container.querySelectorAll('nav button')).find(
(button) => button.textContent?.includes('Model'),
@@ -1003,7 +1003,10 @@ describe('SettingsMessage user-scope editing', () => {
it('confines the model-management block to the Model category', () => {
const container = renderPanel(
makeState([boolSetting(), subDialogSetting()], vi.fn()),
{ modelManagement: makeModelManagement(), initialCategory: 'Model' },
{
modelManagementSectionProps: makeModelManagement(),
initialCategory: 'Model',
},
);
expect(
container.querySelector('[data-testid="model-management"]'),
@@ -1053,7 +1056,7 @@ describe('SettingsMessage user-scope editing', () => {
syncLanguage: vi.fn(),
};
const container = renderPanel(makeState([], vi.fn(), liveSetup(false)), {
modelManagement: makeModelManagement(),
modelManagementSectionProps: makeModelManagement(),
presentation: { excludeItems: [id as WebShellSettingItemId] },
});
const navText = container.querySelector('nav')?.textContent ?? '';
@@ -90,7 +90,7 @@ interface SettingsMessageProps {
chatWidthMode: ChatWidthMode;
onChatWidthModeChange: (mode: ChatWidthMode) => void;
/** Model list/add/delete/select, rendered inside the Model category. */
modelManagement?: ModelManagementProps;
modelManagementSectionProps?: ModelManagementProps;
/** Browser-local remote computer catalog and connection controls. */
connections?: ReactNode;
embedded?: boolean;
@@ -436,7 +436,7 @@ export function SettingsMessage({
onThemeChange,
chatWidthMode,
onChatWidthModeChange,
modelManagement,
modelManagementSectionProps,
connections,
embedded = false,
initialCategory,
@@ -523,7 +523,7 @@ export function SettingsMessage({
items: [{ type: 'local-control' }],
});
}
if (modelManagement && !showInitialLoading) {
if (modelManagementSectionProps && !showInitialLoading) {
const model = groups.find((group) => group.id === 'Model');
if (model) model.items.push({ type: 'model-management' });
else
@@ -568,7 +568,7 @@ export function SettingsMessage({
settings,
t,
hasNotifications,
modelManagement,
modelManagementSectionProps,
connections,
presentation,
showInitialLoading,
@@ -1067,9 +1067,9 @@ export function SettingsMessage({
</CardContent>
</Card>
)}
{showModelManagement && modelManagement && (
{showModelManagement && modelManagementSectionProps && (
<div className={activeRows.length > 0 ? 'mt-4' : undefined}>
<ModelManagementSection {...modelManagement} />
<ModelManagementSection {...modelManagementSectionProps} />
</div>
)}
{showConnections && connections}
@@ -1,4 +1,4 @@
import React from 'react';
import React, { useEffect, useState } from 'react';
import ReactDOM from 'react-dom/client';
import '../styles/standalone.css';
import type { WebShellSettingItemId } from '../settings';
@@ -29,13 +29,35 @@ const includeItems = params.has('include')
? parseItems(params.get('include') ?? '')
: undefined;
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
function SettingsHarness() {
const [modelManagement, setModelManagement] = useState(() => ({
allowAdd: params.has('allowAdd')
? params.get('allowAdd') !== 'false'
: undefined,
allowDelete: params.has('allowDelete')
? params.get('allowDelete') !== 'false'
: undefined,
}));
useEffect(() => {
const update = (event: Event) => {
setModelManagement((event as CustomEvent<typeof modelManagement>).detail);
};
window.addEventListener('model-management-change', update);
return () => window.removeEventListener('model-management-change', update);
}, []);
return (
<WebShellWithProviders
baseUrl={window.location.origin}
sessionId={sessionId}
theme={theme}
settings={{ includeItems, excludeItems }}
modelManagement={modelManagement}
/>
);
}
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<SettingsHarness />
</React.StrictMode>,
);
@@ -21,6 +21,7 @@ import {
type WebShellDaemonScenario,
} from '../utils/mockDaemon';
import { FIXED_CAPTURE_TIME, VISUAL_VIEWPORT } from './constants';
import type { WebShellModelManagementOptions } from '../../modelManagement';
export type VisualTheme = 'dark' | 'light';
@@ -172,9 +173,9 @@ export async function gotoNewSession(
}
/**
* Navigate to the settings harness page, which maps `?exclude=` onto the
* shell's `settings` prop — the standalone entry never passes one, so the
* host-exclusion scenarios can only render through that page. Freezes the
* Navigate to the settings harness page, which maps query parameters onto
* the shell's settings exclusions and model management host props. These
* options are not exposed by the standalone entry. Freezes the
* clock before navigating like every other helper; the theme assertion keys
* on the shell's own surface because the harness paints the `<html>` theme
* class from the same `?theme=` param, where it cannot mislabel.
@@ -185,10 +186,14 @@ export async function gotoSettingsHarness(
daemon: MockDaemonController,
theme: VisualTheme,
exclude: readonly string[] = [],
modelManagement: WebShellModelManagementOptions = {},
): Promise<void> {
await freezeWallClock(page);
const params = new URLSearchParams({ theme, sessionId: scenario.sessionId });
if (exclude.length > 0) params.set('exclude', exclude.join(','));
for (const [key, value] of Object.entries(modelManagement)) {
if (value !== undefined) params.set(key, String(value));
}
await page.goto(`/e2e/settings-harness.html?${params.toString()}`);
await expect(
page.locator('[data-web-shell-root]:not([data-web-shell-gate])'),
@@ -1732,6 +1732,92 @@ for (const theme of THEMES) {
await captureScreenshot(page, `settings-panel-${theme}`);
});
test('settings panel with model management disabled', async ({
page,
}, testInfo) => {
const scenario = createSettingsPanelScenario(theme);
scenario.providers.providers.push({
kind: 'model_provider',
status: 'ok',
authType: 'openai',
current: false,
models: [
{
modelId: 'managed-test-model',
configurationKey: 'managed-test-key',
baseModelId: 'managed-test-model',
name: 'Managed Test Model',
isCurrent: false,
isRuntime: false,
},
],
});
const daemon = await installScenario(
page,
scenario,
resolveBaseURL(testInfo),
);
await page.route('**/workspace/models', async (route) => {
if (route.request().method() === 'GET') {
await route.fulfill({
json: {
models: [
{
key: 'managed-test-key',
authType: 'openai',
modelId: 'managed-test-model',
name: 'Managed Test Model',
purpose: 'chat',
contextWindowSize: 131072,
},
],
},
});
} else {
await route.fallback();
}
});
await gotoSettingsHarness(page, scenario, daemon, theme, [], {
allowAdd: false,
allowDelete: false,
});
await openSettingsPanel(page);
await page
.getByRole('navigation', { name: 'Settings' })
.getByRole('button', { name: /^Model/ })
.click();
const models = page.getByTestId('model-management');
await expect(
models.getByText('Managed Test Model', { exact: true }),
).toBeVisible();
await expect(
models.getByText('Qwen Test', { exact: true }),
).toBeVisible();
await expect(models.getByText('Current', { exact: true })).toBeVisible();
await expect(
models.getByRole('button', { name: '+ Add Model', exact: true }),
).toHaveCount(0);
await expect(
models.getByRole('button', { name: /^Delete / }),
).toHaveCount(0);
await expect(
models.getByRole('button', {
name: 'Set current Managed Test Model',
exact: true,
}),
).toBeVisible();
await expect(
models.getByRole('button', {
name: 'Edit context window Managed Test Model',
exact: true,
}),
).toBeVisible();
await captureScreenshot(
page,
`settings-panel-model-management-disabled-${theme}`,
);
});
test(`settings panel with host exclusions`, async ({ page }, testInfo) => {
const scenario = createSettingsPanelScenario(theme);
const daemon = await installScenario(
@@ -0,0 +1,262 @@
import { mkdir } from 'node:fs/promises';
import { resolve } from 'node:path';
import { expect, test, type Page, type TestInfo } from '@playwright/test';
import {
createWebShellDaemonScenario,
installMockDaemon,
} from './utils/mockDaemon';
import {
completeReplay,
fillComposer,
submitLocalCommand,
} from './visuals/harness';
async function openHarness(
page: Page,
testInfo: TestInfo,
policy: { allowAdd?: boolean; allowDelete?: boolean } = {},
) {
const scenario = createWebShellDaemonScenario();
scenario.providers.providers.push({
kind: 'model_provider',
status: 'ok',
authType: 'openai',
current: false,
models: [
{
modelId: 'managed-test-model',
configurationKey: 'managed-test-key',
baseModelId: 'managed-test-model',
name: 'Managed Test Model',
isCurrent: false,
isRuntime: false,
},
],
});
const daemon = await installMockDaemon(page, scenario, {
baseURL: String(testInfo.project.use.baseURL),
});
await page.route('**/workspace/models', async (route) => {
if (route.request().method() === 'GET')
await route.fulfill({
json: {
models: [
{
key: 'managed-test-key',
authType: 'openai',
modelId: 'managed-test-model',
name: 'Managed Test Model',
purpose: 'chat',
},
],
},
});
else await route.fallback();
});
await page.route('**/workspace/auth/providers', (route) =>
route.fulfill({
json: {
v: 1,
workspaceCwd: scenario.workspaceCwd,
providers: [],
groups: [],
},
}),
);
const params = new URLSearchParams({ sessionId: scenario.sessionId });
for (const [key, value] of Object.entries(policy))
params.set(key, String(value));
await page.goto(`/e2e/settings-harness.html?${params}`);
await completeReplay(page, daemon, scenario.sessionId);
return daemon;
}
async function openModels(page: Page) {
await submitLocalCommand(page, '/settings');
await page
.getByRole('navigation', { name: 'Settings' })
.getByRole('button', { name: /^Model/ })
.click();
await expect(page.getByTestId('model-management')).toContainText(
'Managed Test Model',
);
}
async function evidence(page: Page, name: string) {
const directory = resolve('../../.qwen/e2e-tests/model-management-evidence');
await mkdir(directory, { recursive: true });
await page.screenshot({
path: resolve(directory, `${name}.png`),
fullPage: true,
});
if (
process.env['QWEN_MODEL_MANAGEMENT_EVIDENCE'] === '1' &&
(name === 'default-model-controls' || name === 'disabled-model-controls')
) {
// Stays inside the git-ignored tree: refreshing the tracked
// docs/design/assets PNGs is a deliberate copy from here, never a test
// side effect.
const assets = resolve(directory, 'assets');
await mkdir(assets, { recursive: true });
await page.getByTestId('model-management').screenshot({
path: resolve(
assets,
`web-shell-model-management-${name === 'default-model-controls' ? 'before' : 'after'}.png`,
),
});
}
}
test('default host retains add and delete controls @smoke', async ({
page,
}, testInfo) => {
await openHarness(page, testInfo);
await openModels(page);
await expect(
page.getByRole('button', { name: '+ Add Model', exact: true }),
).toBeVisible();
await expect(
page.getByRole('button', {
name: 'Delete Managed Test Model',
exact: true,
}),
).toBeVisible();
await evidence(page, 'default-model-controls');
});
for (const allowAdd of [true, false]) {
for (const allowDelete of [true, false]) {
test(`host controls add=${allowAdd} delete=${allowDelete} @smoke`, async ({
page,
}, testInfo) => {
await openHarness(page, testInfo, {
allowAdd,
allowDelete,
});
await openModels(page);
await expect(
page.getByRole('button', { name: '+ Add Model', exact: true }),
).toHaveCount(allowAdd ? 1 : 0);
await expect(
page.getByRole('button', {
name: 'Delete Managed Test Model',
exact: true,
}),
).toHaveCount(allowDelete ? 1 : 0);
await expect(
page.getByRole('button', {
name: 'Edit context window Managed Test Model',
exact: true,
}),
).toBeVisible();
if (!allowAdd && !allowDelete)
await evidence(page, 'disabled-model-controls');
});
}
}
for (const allowAdd of [true, false]) {
test(`auth command respects add=${allowAdd} @smoke`, async ({
page,
}, testInfo) => {
const daemon = await openHarness(page, testInfo, { allowAdd });
await fillComposer(page, '/');
const menu = page.locator('[data-web-shell-slash-menu]');
await expect(menu).toBeVisible();
await expect(menu.getByText('/auth', { exact: true })).toHaveCount(
allowAdd ? 1 : 0,
);
await submitLocalCommand(page, '/auth');
const dialog = page.getByRole('dialog', { name: 'Connect a Provider' });
if (allowAdd) await expect(dialog).toBeVisible();
else {
await expect(
page.getByText('Adding models is disabled by the host.'),
).toBeVisible();
await expect(dialog).toHaveCount(0);
// /prompt is the only refusal-relevant route the mock daemon records;
// the /auth/provider half was decorative, so the request-level install
// guarantee is carried by AuthMessage.dom.test.tsx instead.
// Positive control: a plain prompt on this same arm must reach the
// recorder, then verify both its identity and the total request count.
await submitLocalCommand(page, 'plain text prompt');
await expect
.poll(() =>
daemon
.promptRequests()
.some(({ body }) =>
JSON.stringify(body).includes('plain text prompt'),
),
)
.toBe(true);
expect(daemon.promptRequests()).toHaveLength(1);
expect(JSON.stringify(daemon.promptRequests()[0]?.body)).toContain(
'plain text prompt',
);
await evidence(page, 'disabled-auth-command');
}
});
}
test('closing add permission dismisses an open provider dialog @smoke', async ({
page,
}, testInfo) => {
await openHarness(page, testInfo);
await submitLocalCommand(page, '/auth');
const dialog = page.getByRole('dialog', { name: 'Connect a Provider' });
await expect(dialog).toBeVisible();
await page.evaluate(() =>
window.dispatchEvent(
new CustomEvent('model-management-change', {
detail: { allowAdd: false, allowDelete: false },
}),
),
);
await expect(dialog).toHaveCount(0);
});
test('disabled model management preserves model switching @smoke', async ({
page,
}, testInfo) => {
const daemon = await openHarness(page, testInfo, {
allowAdd: false,
allowDelete: false,
});
await submitLocalCommand(page, '/model');
await expect(page.locator('[data-web-shell-model-dialog]')).toBeVisible();
await page
.locator('[data-web-shell-model-option][data-model-id="qwen-test-alt"]')
.click();
await expect(page.locator('[data-web-shell-model-dialog]')).toHaveCount(0);
await expect.poll(() => daemon.modelRequests().length).toBe(1);
});
test('closing deletion permission clears pending confirmation @smoke', async ({
page,
}, testInfo) => {
await openHarness(page, testInfo);
await openModels(page);
await page
.getByRole('button', { name: 'Delete Managed Test Model', exact: true })
.click();
const confirm = page.getByRole('button', {
name: /Confirm.*Managed Test Model/,
});
await expect(confirm).toBeVisible();
await page.evaluate(() =>
window.dispatchEvent(
new CustomEvent('model-management-change', {
detail: { allowAdd: false, allowDelete: false },
}),
),
);
await expect(confirm).toHaveCount(0);
await page.evaluate(() =>
window.dispatchEvent(
new CustomEvent('model-management-change', {
detail: { allowAdd: false, allowDelete: true },
}),
),
);
await expect(
page.getByRole('button', {
name: 'Delete Managed Test Model',
exact: true,
}),
).toBeVisible();
await expect(confirm).toHaveCount(0);
});
@@ -14,6 +14,7 @@ import {
} from './useQueuedPrompts';
import type { DaemonStreamingState } from '@qwen-code/web-shell/daemon-react-sdk';
import { DaemonHttpError } from '@qwen-code/sdk/daemon';
import { getTranslator } from '../i18n';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
@@ -90,6 +91,8 @@ vi.mock('@qwen-code/web-shell/daemon-react-sdk', async () => {
});
const CLIENT_ID = 'client-self';
const denySetup = (text: string) =>
text.trim() === '/auth' ? 'Model setup disabled' : undefined;
function deferred<T>() {
let resolve!: (value: T) => void;
@@ -100,6 +103,7 @@ function deferred<T>() {
}
interface HarnessOptions {
getPromptDispatchError?: (text: string) => string | undefined;
connected?: boolean;
writeBlocked?: boolean;
sessionId?: string;
@@ -113,7 +117,7 @@ interface HarnessOptions {
holdQueuedPromptsLocally?: boolean;
}
function createHarness() {
function createHarness(language?: 'en' | 'zh-CN') {
const container = document.createElement('div');
document.body.appendChild(container);
const root: Root = createRoot(container);
@@ -134,7 +138,9 @@ function createHarness() {
focus: vi.fn(),
};
const stableEditorRef = { current: stableEditor } as never;
const stableT = ((key: string) => key) as never;
const stableT = (
language ? getTranslator(language) : (key: string) => key
) as never;
const stableReportError = vi.fn();
const stableWorkspaceFileActions = {
stat: vi.fn(async () => ({
@@ -156,6 +162,7 @@ function createHarness() {
function TestComponent(opts: HarnessOptions) {
latest = useQueuedPrompts({
connected: opts.connected ?? true,
getPromptDispatchError: opts.getPromptDispatchError,
writeBlocked: opts.writeBlocked ?? false,
sessionId: opts.sessionId ?? 'session-a',
workspaceCwd: opts.workspaceCwd ?? '/workspace',
@@ -254,6 +261,403 @@ describe('useQueuedPrompts mid-turn reconciliation (session_mid_turn_message_que
sdkMock.pendingEvents = [];
});
it.each(['', 'summarize the log'])(
'discards a refused held command without changing draft %j',
async (draft) => {
const harness = createHarness();
try {
await harness.render({ holdQueuedPromptsLocally: true });
harness.editor.getText.mockReturnValue(draft);
act(() =>
harness
.result()
.enqueuePrompt(
'/auth',
[{ data: 'aGVsbG8=', media_type: 'image/png' }],
[{ name: 'notes.txt', text: 'notes', media_type: 'text/plain' }],
),
);
await harness.render({
streamingState: 'idle',
getPromptDispatchError: denySetup,
});
expect(sdkMock.actions.submitPrompt).not.toHaveBeenCalled();
expect(harness.reportError).toHaveBeenCalled();
expect(harness.editor.setText).not.toHaveBeenCalled();
expect(harness.editor.restoreImages).not.toHaveBeenCalled();
expect(harness.editor.restoreFiles).not.toHaveBeenCalled();
expect(harness.result().queuedPrompts).toEqual([]);
} finally {
await harness.dispose();
}
},
);
it('still restores ordinary failed submissions beside a live draft', async () => {
const harness = createHarness();
const image = { data: 'aGVsbG8=', media_type: 'image/png' };
try {
await harness.render({ holdQueuedPromptsLocally: true });
harness.editor.getText.mockReturnValue('summarize the log');
act(() => harness.result().enqueuePrompt('retry this', [image]));
sdkMock.actions.submitPrompt.mockRejectedValueOnce(
new Error('network failed'),
);
await harness.render({ streamingState: 'idle' });
expect(harness.editor.setText).toHaveBeenCalledWith(
'retry this\nsummarize the log',
);
expect(harness.editor.restoreImages).toHaveBeenCalledWith([image]);
expect(harness.reportError).toHaveBeenCalled();
} finally {
await harness.dispose();
}
});
it('checks an arbitrary caller policy through a retained enqueue callback', async () => {
const harness = createHarness();
try {
await harness.render({ streamingState: 'idle' });
const enqueue = harness.result().enqueuePrompt;
await harness.render({
streamingState: 'idle',
getPromptDispatchError: (text) =>
text === 'blocked prompt' ? 'Disabled by caller' : undefined,
});
await act(async () => {
enqueue('blocked prompt');
});
expect(sdkMock.actions.submitPrompt).not.toHaveBeenCalled();
expect(harness.reportError).toHaveBeenCalled();
} finally {
await harness.dispose();
}
});
it.each([false, true])(
'discards a refused mid-turn command without changing the live draft (session changed=%s)',
async (changeSession) => {
const uploaded = deferred<{
type: string;
attachmentId: string;
mimeType: string;
size: number;
}>();
sdkMock.actions.uploadAttachment.mockReturnValueOnce(uploaded.promise);
const removal = deferred<boolean>();
sdkMock.actions.removeAttachment.mockReturnValueOnce(removal.promise);
const harness = createHarness();
try {
await harness.render({});
harness.editor.getText.mockReturnValue('summarize the log');
const fileText = '@notes.txt';
await act(async () => {
harness
.result()
.enqueuePrompt(
fileText + ' /auth',
undefined,
undefined,
undefined,
[
{
type: 'reference',
start: 0,
end: fileText.length,
text: fileText,
reference: {
id: 'file:notes.txt',
kind: 'file',
value: 'notes.txt',
},
},
],
);
});
expect(sdkMock.actions.uploadAttachment).toHaveBeenCalledTimes(1);
await harness.render({ getPromptDispatchError: denySetup });
await act(async () => {
uploaded.resolve({
type: 'resource',
attachmentId: 'notes.txt',
mimeType: 'text/plain',
size: 5,
});
});
expect(sdkMock.actions.enqueueMidTurnMessage).not.toHaveBeenCalled();
expect(sdkMock.actions.removeAttachment).toHaveBeenCalledWith(
'notes.txt',
expect.anything(),
);
if (changeSession) {
await harness.render({
sessionId: 'session-b',
getPromptDispatchError: denySetup,
});
}
await act(async () => {
removal.resolve(true);
});
expect(harness.editor.setText).not.toHaveBeenCalled();
expect(harness.editor.restoreFiles).not.toHaveBeenCalled();
expect(harness.editor.restoreInputAnnotations).not.toHaveBeenCalled();
expect(harness.result().queuedPrompts).toEqual([]);
expect(harness.reportError).toHaveBeenCalled();
} finally {
await harness.dispose();
}
},
);
it('checks model policy before reading or uploading annotated files', async () => {
const harness = createHarness();
try {
await harness.render({ getPromptDispatchError: denySetup });
const fileText = '@notes.txt';
await act(async () => {
harness
.result()
.enqueuePrompt(fileText + ' /auth', undefined, undefined, undefined, [
{
type: 'reference',
start: 0,
end: fileText.length,
text: fileText,
reference: {
id: 'file:notes.txt',
kind: 'file',
value: 'notes.txt',
},
},
]);
await Promise.resolve();
});
expect(harness.workspaceFileActions.readFileBytes).not.toHaveBeenCalled();
expect(sdkMock.actions.uploadAttachment).not.toHaveBeenCalled();
expect(sdkMock.actions.enqueueMidTurnMessage).not.toHaveBeenCalled();
expect(sdkMock.actions.removeAttachment).not.toHaveBeenCalled();
expect(harness.reportError).toHaveBeenCalled();
} finally {
await harness.dispose();
}
});
it.each(['en', 'zh-CN'] as const)(
'reports a failed compensating attachment delete in %s',
async (language) => {
const uploaded = deferred<{
type: string;
attachmentId: string;
mimeType: string;
size: number;
}>();
sdkMock.actions.uploadAttachment.mockReturnValueOnce(uploaded.promise);
const harness = createHarness(language);
try {
await harness.render({});
const fileText = '@notes.txt';
await act(async () => {
harness
.result()
.enqueuePrompt(
fileText + ' /auth',
undefined,
undefined,
undefined,
[
{
type: 'reference',
start: 0,
end: fileText.length,
text: fileText,
reference: {
id: 'file:notes.txt',
kind: 'file',
value: 'notes.txt',
},
},
],
);
});
expect(sdkMock.actions.uploadAttachment).toHaveBeenCalledTimes(1);
const cleanupFailure = new Error('cleanup failed');
sdkMock.actions.removeAttachment.mockRejectedValue(cleanupFailure);
await harness.render({ getPromptDispatchError: denySetup });
await act(async () => {
uploaded.resolve({
type: 'resource',
attachmentId: 'notes.txt',
mimeType: 'text/plain',
size: 5,
});
});
expect(sdkMock.actions.enqueueMidTurnMessage).not.toHaveBeenCalled();
const localized = getTranslator(language)(
'queue.attachmentCleanupFailed',
);
const cleanupReport = harness.reportError.mock.calls.find(
([, fallback]) => fallback === localized,
)!;
expect(cleanupReport).toBeDefined();
// Both App and ChatPane prefer an Error's message to their fallback.
expect(cleanupReport[0]).toBeInstanceOf(Error);
expect(cleanupReport[0].message).toBe(localized);
expect(cleanupReport[0].cause).toBe(cleanupFailure);
} finally {
await harness.dispose();
}
},
);
it.each(['en', 'zh-CN'] as const)(
'reports a compensating delete the daemon refused in %s',
async (language) => {
const uploaded = deferred<{
type: string;
attachmentId: string;
mimeType: string;
size: number;
}>();
sdkMock.actions.uploadAttachment.mockReturnValueOnce(uploaded.promise);
const harness = createHarness(language);
try {
await harness.render({});
const fileText = '@notes.txt';
await act(async () => {
harness
.result()
.enqueuePrompt(
fileText + ' /auth',
undefined,
undefined,
undefined,
[
{
type: 'reference',
start: 0,
end: fileText.length,
text: fileText,
reference: {
id: 'file:notes.txt',
kind: 'file',
value: 'notes.txt',
},
},
],
);
});
expect(sdkMock.actions.uploadAttachment).toHaveBeenCalledTimes(1);
// The route answers 200 {removed:false} when the store refuses the
// unlink, so the client resolves false instead of rejecting.
sdkMock.actions.removeAttachment.mockResolvedValue(false);
await harness.render({ getPromptDispatchError: denySetup });
await act(async () => {
uploaded.resolve({
type: 'resource',
attachmentId: 'notes.txt',
mimeType: 'text/plain',
size: 5,
});
});
expect(sdkMock.actions.enqueueMidTurnMessage).not.toHaveBeenCalled();
const localized = getTranslator(language)(
'queue.attachmentCleanupFailed',
);
const cleanupReport = harness.reportError.mock.calls.find(
([, fallback]) => fallback === localized,
)!;
expect(cleanupReport).toBeDefined();
// Both App and ChatPane prefer an Error's message to their fallback.
expect(cleanupReport[0]).toBeInstanceOf(Error);
expect(cleanupReport[0].message).toBe(localized);
expect(cleanupReport[0].cause).toBe('removeAttachment returned false');
} finally {
await harness.dispose();
}
},
);
it('does not report a failed compensating delete after the session changed', async () => {
const uploaded = deferred<{
type: string;
attachmentId: string;
mimeType: string;
size: number;
}>();
sdkMock.actions.uploadAttachment.mockReturnValueOnce(uploaded.promise);
const harness = createHarness();
try {
await harness.render({});
const fileText = '@notes.txt';
await act(async () => {
harness
.result()
.enqueuePrompt(fileText + ' /auth', undefined, undefined, undefined, [
{
type: 'reference',
start: 0,
end: fileText.length,
text: fileText,
reference: {
id: 'file:notes.txt',
kind: 'file',
value: 'notes.txt',
},
},
]);
});
expect(sdkMock.actions.uploadAttachment).toHaveBeenCalledTimes(1);
sdkMock.actions.removeAttachment.mockRejectedValue(
new Error('cleanup failed'),
);
// The user switches away before the upload settles: the compensating
// delete still runs against the old session, but its failure belongs to
// that session, not to the one on screen.
await harness.render({ sessionId: 'session-b' });
await act(async () => {
uploaded.resolve({
type: 'resource',
attachmentId: 'notes.txt',
mimeType: 'text/plain',
size: 5,
});
});
expect(sdkMock.actions.enqueueMidTurnMessage).not.toHaveBeenCalled();
expect(sdkMock.actions.removeAttachment).toHaveBeenCalledWith(
'notes.txt',
expect.anything(),
);
expect(harness.reportError).not.toHaveBeenCalledWith(
expect.anything(),
'queue.attachmentCleanupFailed',
);
} finally {
await harness.dispose();
}
});
it.each(['/model', 'please explain /auth', '/authenticate'])(
'keeps unrelated queued prompts enabled: %s',
async (text) => {
const harness = createHarness();
try {
await harness.render({
streamingState: 'idle',
getPromptDispatchError: denySetup,
});
await act(async () => {
harness.result().enqueuePrompt(text);
});
expect(sdkMock.actions.submitPrompt).toHaveBeenCalledWith(
text,
expect.anything(),
);
} finally {
await harness.dispose();
}
},
);
it('does not restore a row from a snapshot older than its injection', async () => {
const harness = createHarness();
try {
@@ -8,6 +8,7 @@ import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
useSyncExternalStore,
@@ -55,6 +56,9 @@ interface RefBox<T> {
}
interface UseQueuedPromptsArgs {
/** Synchronous caller policy, checked again immediately before SDK dispatch.
* Return a message to reject locally; undefined permits dispatch. */
getPromptDispatchError?: (text: string) => string | undefined;
connected: boolean;
writeBlocked?: boolean;
runtimeStopped?: boolean;
@@ -94,6 +98,8 @@ interface UseQueuedPromptsArgs {
t: ReturnType<typeof getTranslator>;
}
class PromptDispatchBlockedError extends Error {}
const MAX_COMPLETED_PROMPT_IDS = 100;
function queueOwnerKey(
@@ -499,6 +505,7 @@ export interface UseQueuedPromptsResult {
}
export function useQueuedPrompts({
getPromptDispatchError,
connected,
writeBlocked = false,
runtimeStopped = false,
@@ -512,12 +519,34 @@ export function useQueuedPrompts({
streamingState,
sessionHasActivePrompt = false,
holdQueuedPromptsLocally = false,
sessionActions,
sessionActions: unguardedSessionActions,
store,
editorRef,
reportError,
t,
}: UseQueuedPromptsArgs): UseQueuedPromptsResult {
const dispatchPolicyRef = useRef(getPromptDispatchError);
dispatchPolicyRef.current = getPromptDispatchError;
const sessionActions = useMemo<DaemonSessionActions>(
() => ({
...unguardedSessionActions,
submitPrompt: (text, options) => {
const error = dispatchPolicyRef.current?.(text);
if (error !== undefined) {
return Promise.reject(new PromptDispatchBlockedError(error));
}
return unguardedSessionActions.submitPrompt(text, options);
},
enqueueMidTurnMessage: (text, options) => {
const error = dispatchPolicyRef.current?.(text);
if (error !== undefined) {
return Promise.reject(new PromptDispatchBlockedError(error));
}
return unguardedSessionActions.enqueueMidTurnMessage(text, options);
},
}),
[unguardedSessionActions],
);
const writeBlockedRef = useRef(writeBlocked);
writeBlockedRef.current = writeBlocked;
const sessionOwnerGuard = useDaemonSessionOwnerGuard();
@@ -2881,7 +2910,10 @@ export function useQueuedPrompts({
);
queuedPromptsRef.current = next;
setQueuedPrompts(next);
if (!admissionStarted) {
if (
!admissionStarted &&
!(error instanceof PromptDispatchBlockedError)
) {
restoreQueuedPromptsToEditor([prompt], targetSessionId);
}
// A message now visible in the transcript was admitted and started,
@@ -3112,7 +3144,7 @@ export function useQueuedPrompts({
let uploadedAttachmentReferences: DaemonSessionAttachmentReference[] =
[];
const removeUploadedAttachments = async () => {
await Promise.allSettled(
const removals = await Promise.allSettled(
uploadedAttachmentReferences.map((reference) =>
sessionActions.removeAttachment(reference.attachmentId, {
sessionId: targetSessionId,
@@ -3120,53 +3152,84 @@ export function useQueuedPrompts({
),
);
uploadedAttachmentReferences = [];
// A failed compensating delete leaves the refused prompt's bytes
// in the session attachment store; surface it instead of dropping.
// A fulfilled `false` — the daemon refused the unlink — counts too,
// even though it can also mean both copies were already gone.
const failedRemoval = removals.find(
(result) => result.status === 'rejected' || result.value === false,
);
// Every sibling report in this chain is gated on still owning the
// work; a toast about a session the user already left is noise.
if (failedRemoval && targetIsCurrent()) {
const message = t('queue.attachmentCleanupFailed');
reportError(
new Error(message, {
cause:
failedRemoval.status === 'rejected'
? failedRemoval.reason
: 'removeAttachment returned false',
}),
message,
);
}
};
void Promise.allSettled([
...imageList.map(
async (image) =>
await sessionActions.uploadAttachment(
{
data: image.data,
mimeType: image.media_type,
},
{ signal: abort.signal, sessionId: targetSessionId },
),
),
...fileList.map(
async (file) =>
await sessionActions.uploadAttachment(
{
name: file.name,
data: file.data,
text: file.text,
mimeType: file.media_type,
},
{ signal: abort.signal, sessionId: targetSessionId },
),
),
...annotatedFileList.map(async (file, index) => {
const filePath = annotated!.paths[index]!;
const data = await readWorkspaceFileAsBlob(
(path, options) =>
workspaceFileActions!.readFileBytes(path, options),
filePath,
file.media_type,
{
statFile: (path) => workspaceFileActions!.stat(path),
isCancelled: () => abort.signal.aborted,
maxBytes: MAX_FILE_ATTACHMENT_DATA_BYTES,
},
// Check the caller policy before paying for reads and uploads; the
// SDK wrapper still re-checks the latest policy at dispatch time.
void Promise.resolve()
.then(() => {
const blocked = dispatchPolicyRef.current?.(
annotated?.displayText ?? trimmed,
);
return await sessionActions.uploadAttachment(
{
name: file.name,
data,
mimeType: file.media_type,
},
{ signal: abort.signal, sessionId: targetSessionId },
);
}),
])
if (blocked !== undefined)
throw new PromptDispatchBlockedError(blocked);
return Promise.allSettled([
...imageList.map(
async (image) =>
await sessionActions.uploadAttachment(
{
data: image.data,
mimeType: image.media_type,
},
{ signal: abort.signal, sessionId: targetSessionId },
),
),
...fileList.map(
async (file) =>
await sessionActions.uploadAttachment(
{
name: file.name,
data: file.data,
text: file.text,
mimeType: file.media_type,
},
{ signal: abort.signal, sessionId: targetSessionId },
),
),
...annotatedFileList.map(async (file, index) => {
const filePath = annotated!.paths[index]!;
const data = await readWorkspaceFileAsBlob(
(path, options) =>
workspaceFileActions!.readFileBytes(path, options),
filePath,
file.media_type,
{
statFile: (path) => workspaceFileActions!.stat(path),
isCancelled: () => abort.signal.aborted,
maxBytes: MAX_FILE_ATTACHMENT_DATA_BYTES,
},
);
return await sessionActions.uploadAttachment(
{
name: file.name,
data,
mimeType: file.media_type,
},
{ signal: abort.signal, sessionId: targetSessionId },
);
}),
]);
})
.then(async (results) => {
uploadedAttachmentReferences = results.flatMap((result) =>
result.status === 'fulfilled' ? [result.value] : [],
@@ -3304,6 +3367,8 @@ export function useQueuedPrompts({
}
})
.catch(async (error: unknown) => {
if (error instanceof PromptDispatchBlockedError)
enqueueStarted = false;
if (!enqueueStarted) await removeUploadedAttachments();
if (!targetIsCurrent()) {
completionCallbacksRef.current.delete(midTurnMessageId);
@@ -3314,7 +3379,9 @@ export function useQueuedPrompts({
// return: restore it to the current editor instead of
// leaking it across the session switch.
if (pendingAdmissionStillOwned) {
restoreQueuedPromptsToEditor([restoreAdmission], undefined);
if (!(error instanceof PromptDispatchBlockedError)) {
restoreQueuedPromptsToEditor([restoreAdmission], undefined);
}
reportError(error, t('queue.queueFailed'));
}
}
@@ -3335,7 +3402,12 @@ export function useQueuedPrompts({
);
queuedPromptsRef.current = next;
setQueuedPrompts(next);
restoreQueuedPromptsToEditor([restoreAdmission], targetSessionId);
if (!(error instanceof PromptDispatchBlockedError)) {
restoreQueuedPromptsToEditor(
[restoreAdmission],
targetSessionId,
);
}
reportError(error, t('queue.queueFailed'));
return;
}
+8
View File
@@ -1598,6 +1598,8 @@ const EN: Messages = {
'attachment.loadingPreview': 'Loading preview...',
'sideTask.creating': 'Creating side task…',
'sideTask.createFailed': 'Failed to create side task',
'sideTask.commandsLoadingTimedOut':
'Command information is still unavailable. Your side-task prompt has been kept and will be checked when it loads.',
'sideTask.promptFailed': 'Failed to send the side-task question',
'sideTask.renameFailed': 'Failed to name side task',
'turnOutputs.preview': 'Preview',
@@ -1895,6 +1897,7 @@ const EN: Messages = {
: `${count} queued shell commands will not run.`;
},
'queue.queueFailed': 'Failed to queue message',
'queue.attachmentCleanupFailed': 'Failed to discard uploaded attachments',
'queue.deleteFailed': 'Failed to move message out of queue',
'queue.editFailed': 'Failed to edit queued message',
'queue.insertFailed': 'Failed to insert queued message',
@@ -3923,6 +3926,7 @@ const EN: Messages = {
'settings.models.context': (v) => `Context: ${v?.tokens ?? ''} tokens`,
'settings.models.credentialEnv': 'Key environment variable',
'settings.models.add': '+ Add Model',
'settings.models.addDisabled': 'Adding models is disabled by the host.',
'settings.models.setCurrent': 'Set current',
'settings.models.current': 'Current',
'settings.models.runtime': 'Runtime',
@@ -5535,6 +5539,8 @@ const ZH: Messages = {
'attachment.loadingPreview': '正在加载预览...',
'sideTask.creating': '正在创建侧边任务…',
'sideTask.createFailed': '创建侧边任务失败',
'sideTask.commandsLoadingTimedOut':
'命令信息尚未就绪。侧任务提示词已保留,将在信息加载后重新检查。',
'sideTask.promptFailed': '发送侧边任务问题失败',
'sideTask.renameFailed': '侧边任务命名失败',
'turnOutputs.preview': '预览',
@@ -5805,6 +5811,7 @@ const ZH: Messages = {
'queue.shellDropped': (v) =>
`${v?.count ?? 0} 条排队的 Shell 命令将不会执行。`,
'queue.queueFailed': '排队消息失败',
'queue.attachmentCleanupFailed': '清理已上传附件失败',
'queue.deleteFailed': '移出队列失败',
'queue.editFailed': '编辑排队消息失败',
'queue.insertFailed': '插入排队消息失败',
@@ -7680,6 +7687,7 @@ const ZH: Messages = {
'settings.models.context': (v) => `上下文:${v?.tokens ?? ''} Token`,
'settings.models.credentialEnv': '密钥环境变量',
'settings.models.add': '+ 增加模型',
'settings.models.addDisabled': '宿主已禁用添加模型。',
'settings.models.setCurrent': '设为当前',
'settings.models.current': '当前',
'settings.models.runtime': '运行时',
+6
View File
@@ -494,3 +494,9 @@ describe('WebShellWithProviders top-level boundary', () => {
expect(container.querySelector('[data-testid="app-ok"]')).toBeNull();
});
});
it('forwards model management through the public provider wrapper', async () => {
const modelManagement = { allowAdd: false, allowDelete: false };
render(<WebShellWithProviders modelManagement={modelManagement} />);
expect(appProps.at(-1)?.modelManagement).toBe(modelManagement);
});
+1
View File
@@ -321,6 +321,7 @@ export type {
WebShellSettingsOptions,
} from './settings';
export type { WebShellModelManagementOptions } from './modelManagement';
export type {
WebShellMessageNavigationRequest,
WebShellMessageNavigationResult,
@@ -0,0 +1,110 @@
import { describe, expect, it } from 'vitest';
import {
isModelCommandSnapshotReady,
isModelSetupCommand,
resolveModelManagement,
} from './modelManagement';
describe('model management policy', () => {
it('defaults omitted and empty options to allowing both actions', () => {
expect(resolveModelManagement()).toEqual({
allowAdd: true,
allowDelete: true,
});
expect(resolveModelManagement({})).toEqual(resolveModelManagement());
expect(resolveModelManagement({ allowAdd: false })).toEqual({
allowAdd: false,
allowDelete: true,
});
expect(resolveModelManagement({ allowDelete: false })).toEqual({
allowAdd: true,
allowDelete: false,
});
});
it.each([
'/auth',
'/auth custom',
'/auth\ncustom',
// The daemon resolves these to /auth: its altNames, and whitespace it
// tolerates between the slash and the token.
'/login',
'/connect',
'/ auth',
'/\tauth',
])('recognizes setup command %s', (text) => {
expect(isModelSetupCommand(text)).toBe(true);
});
it.each([
'/authenticate',
'/auth/path',
'explain /auth',
'/model',
'/delete',
// The daemon resolves names case-sensitively, so this reaches it as
// prose; folding case would discard ordinary text under a policy toast.
' /AUTH ',
])('preserves non-setup input %s', (text) => {
expect(isModelSetupCommand(text)).toBe(false);
});
const builtinAuth = {
name: 'auth',
source: 'builtin-command',
altNames: ['connect', 'login'],
};
it.each(['/auth', '/login', '/connect', '/ auth'])(
'blocks %s when the snapshot resolves it to the builtin auth command',
(text) => {
expect(isModelSetupCommand(text, [builtinAuth])).toBe(true);
},
);
it('keeps a host/project command shadowing a setup name runnable', () => {
const commands = [
builtinAuth,
{ name: 'login', source: 'project' },
{ name: 'connect', source: 'user' },
];
expect(isModelSetupCommand('/login staging', commands)).toBe(false);
expect(isModelSetupCommand('/connect', commands)).toBe(false);
expect(isModelSetupCommand('/auth', commands)).toBe(true);
});
it('keeps a project command named auth runnable when it replaces the builtin', () => {
const commands = [
{ name: 'clear', source: 'builtin-command' },
{ name: 'auth', source: 'project' },
];
expect(isModelSetupCommand('/auth acme', commands)).toBe(false);
});
it('fails closed when a ready snapshot carries no setup command at all', () => {
// slashCommands.disabled: ['auth'] or an SSH policy whitelist produce a
// builtin-marked snapshot without auth; with no project/user shadow the
// bare names have no daemon command to resolve to, so refuse them.
const commands = [{ name: 'clear', source: 'builtin-command' }];
expect(isModelSetupCommand('/auth', commands)).toBe(true);
expect(isModelSetupCommand('/login staging', commands)).toBe(true);
expect(isModelSetupCommand('/connect', commands)).toBe(true);
expect(isModelSetupCommand('/model', commands)).toBe(false);
expect(isModelSetupCommand('/unknown', commands)).toBe(false);
});
it('treats a snapshot without builtin entries as still loading', () => {
expect(
isModelSetupCommand('/auth', [{ name: 'auth', source: 'project' }]),
).toBe(true);
expect(isModelSetupCommand('/auth', [])).toBe(true);
});
});
it('recognizes only builtin-marked command snapshots as ready', () => {
expect(isModelCommandSnapshotReady()).toBe(false);
expect(isModelCommandSnapshotReady([])).toBe(false);
expect(
isModelCommandSnapshotReady([{ name: 'auth', source: 'project' }]),
).toBe(false);
expect(
isModelCommandSnapshotReady([
{ name: 'clear', source: 'builtin-command' },
{ name: 'auth', source: 'project' },
]),
).toBe(true);
});
@@ -0,0 +1,63 @@
/** WebShell interaction controls only; daemon APIs and external provisioning remain available. */
export interface WebShellModelManagementOptions {
/** Allow provider/model setup, including /auth. Defaults to true. */
allowAdd?: boolean;
/** Allow model deletion. Defaults to true. */
allowDelete?: boolean;
}
export function resolveModelManagement(
options?: WebShellModelManagementOptions,
): Required<WebShellModelManagementOptions> {
return {
allowAdd: options?.allowAdd ?? true,
allowDelete: options?.allowDelete ?? true,
};
}
// Mirrors the daemon's auth command (`authCommand.altNames`) and its
// tokenization (`query.trim().substring(1).trim().split(/\s+/)` in
// parseSlashCommand) so an alias or whitespace after the slash cannot slip
// past the host policy. Keep in sync with packages/cli authCommand.
const MODEL_SETUP_COMMAND_NAMES = new Set(['auth', 'connect', 'login']);
/** The command identity a session's command snapshot carries per entry. */
export interface ModelSetupCommandInfo {
name: string;
source?: string;
altNames?: readonly string[];
}
export function isModelCommandSnapshotReady(
commands?: readonly ModelSetupCommandInfo[],
): boolean {
return (
commands?.some((command) => command.source === 'builtin-command') ?? false
);
}
export function isModelSetupCommand(
input: string,
commands?: readonly ModelSetupCommandInfo[],
): boolean {
const trimmed = input.trim();
if (!trimmed.startsWith('/')) return false;
const firstToken = trimmed.slice(1).trimStart().split(/\s+/, 1)[0];
// With a fully loaded snapshot (a builtin entry marks it, per
// daemon/session/actions.ts), classify by the RESOLVED command exactly as
// the daemon does — exact name, then altNames, both case-sensitive
// (commands.ts findCommandByName): a project/user command named `auth`,
// `connect` or `login` replaces the builtin and must stay runnable. Until
// the snapshot loads, fail closed on the bare names.
if (commands && isModelCommandSnapshotReady(commands)) {
const resolved =
commands.find((command) => command.name === firstToken) ??
commands.find((command) => command.altNames?.includes(firstToken));
// A ready snapshot with no entry for a bare setup name means the daemon
// dropped the builtin (disabled list, SSH whitelist) and no project/user
// command shadows it — fail closed instead of dispatching past the host.
if (!resolved) return MODEL_SETUP_COMMAND_NAMES.has(firstToken);
return resolved.name === 'auth' && resolved.source === 'builtin-command';
}
return MODEL_SETUP_COMMAND_NAMES.has(firstToken);
}