mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
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:
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 |
@@ -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.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
## 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,如实报告基线失败及未完成项。
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
## 待讨论
|
||||
|
||||
公共 API 仍待上游评审。按贡献者明确要求,在 issue 讨论未结束时推进实现。本次不包含后端权限设计。
|
||||
@@ -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 运行时。宿主只需将
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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': '运行时',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user