Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/acp-bridge/src/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -450,6 +450,7 @@ export interface ServeWorkspaceProvidersStatus {
initialized: boolean;
acpChannelLive?: boolean;
current?: ServeWorkspaceProviderCurrent;
approvalMode?: string;
providers: ServeWorkspaceProviderStatus[];
errors?: ServeStatusCell[];
}
Expand Down
45 changes: 45 additions & 0 deletions packages/cli/src/serve/workspace-providers-status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,13 @@ const coreMock = vi.hoisted(() => ({
throwModelsConfigError: false,
modelsConfigErrorMessage:
'Failed loading provider https://user:secret@broken.example/v1',
debugLogger: {
debug: vi.fn(),
error: vi.fn(),
info: vi.fn(),
isEnabled: vi.fn(() => false),
warn: vi.fn(),
},
}));

vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
Expand All @@ -30,6 +37,7 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
}
return {
...actual,
createDebugLogger: () => coreMock.debugLogger,
ModelsConfig: TestModelsConfig,
};
});
Expand Down Expand Up @@ -62,6 +70,7 @@ describe('createWorkspaceProvidersStatusProvider', () => {
coreMock.throwModelsConfigError = false;
coreMock.modelsConfigErrorMessage =
'Failed loading provider https://user:secret@broken.example/v1';
coreMock.debugLogger.warn.mockClear();
resetHomeEnvBootstrapForTesting();
});

Expand Down Expand Up @@ -122,6 +131,42 @@ describe('createWorkspaceProvidersStatusProvider', () => {
expect(second.current?.modelId).toBe('model-b(openai)');
});

it('returns the workspace approval mode', async () => {
const provider = createWorkspaceProvidersStatusProvider({ env: {} });
await writeUserSettings({
tools: { approvalMode: 'yolo' },
});

const result = await provider(workspace, false);

expect(result.approvalMode).toBe('yolo');
});

it('normalizes legacy workspace approval mode spelling', async () => {
const provider = createWorkspaceProvidersStatusProvider({ env: {} });
await writeUserSettings({
tools: { approvalMode: 'auto_edit' },
});

const result = await provider(workspace, false);

expect(result.approvalMode).toBe('auto-edit');
});

it('warns and falls back for an unknown workspace approval mode', async () => {
const provider = createWorkspaceProvidersStatusProvider({ env: {} });
await writeUserSettings({
tools: { approvalMode: 'auto-edt' },
});

const result = await provider(workspace, false);

expect(result.approvalMode).toBe('default');
expect(coreMock.debugLogger.warn).toHaveBeenCalledWith(
'[workspace-providers-status] unrecognized approvalMode "auto-edt", falling back to default',
);
});

it('marks only the model matching persisted model.baseUrl as current', async () => {
const provider = createWorkspaceProvidersStatusProvider({ env: {} });
await writeUserSettings({
Expand Down
25 changes: 25 additions & 0 deletions packages/cli/src/serve/workspace-providers-status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
*/

import {
ApprovalMode,
APPROVAL_MODES,
createDebugLogger,
ModelsConfig,
resolveProviderProtocol,
tokenLimit,
Expand All @@ -30,6 +33,8 @@ import {
sanitizeProviderBaseUrl,
} from '../utils/acpModelUtils.js';

const debugLogger = createDebugLogger('WORKSPACE_PROVIDERS_STATUS');

export type WorkspaceProvidersStatusProvider = (
workspaceCwd: string,
acpChannelLive: boolean,
Expand Down Expand Up @@ -97,6 +102,7 @@ function buildWorkspaceProvidersStatus(
typeof settings.fastModel === 'string' && settings.fastModel.length > 0
? settings.fastModel
: undefined;
const approvalMode = resolveApprovalMode(settings);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] This uses the raw merged tools.approvalMode from settings, but session creation normally ignores that setting in --safe/--bare modes and downgrades unsafe modes for untrusted folders. The deferred web-shell path copies this value into connection.currentMode and then applies it with setApprovalMode() before the first prompt, so a workspace setting like yolo can re-enable a mode that the normal session config would have forced back to default. Please return the daemon's effective approval mode with the same safety/trust precedence as session config, or omit it until a real session context is available.

— gpt-5 via Qwen Code /review

const providers = new Map<string, ServeWorkspaceProviderStatus>();
const explicitModelBaseUrls = buildExplicitModelBaseUrls(
settings.modelProviders,
Expand Down Expand Up @@ -169,6 +175,7 @@ function buildWorkspaceProvidersStatus(
initialized: true,
acpChannelLive,
...(current ? { current } : {}),
approvalMode,
Comment thread
ytahdn marked this conversation as resolved.
providers: [...providers.values()],
...(resolvedCliConfig.warnings.length > 0
? {
Expand Down Expand Up @@ -200,6 +207,24 @@ function buildWorkspaceProvidersStatus(
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The resolveApprovalMode helper and its surface in ServeWorkspaceProvidersStatus are orthogonal to the session lifecycle refactor that is the main focus of this PR.

Consider splitting this into a separate small PR. Benefits:

  • Easier to bisect if a regression is found in either the session logic or the status API.
  • The workspace-providers-status.test.ts additions (45 lines) are self-contained and land cleanly on their own.
  • Reduces the scope of the session lifecycle PR, which is already 2200+ lines.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the scope callout. I am leaving approvalMode in this PR for now because the current session-preparation flow reads the daemon-provided approval mode as part of first-prompt setup, and splitting it out now would add another branch dependency/rebase step to an already active lifecycle PR. I am not resolving this thread since it is a scope suggestion rather than a code fix in this follow-up.

}

function resolveApprovalMode(settings: Settings): ApprovalMode {
const value = settings.tools?.approvalMode;
if (typeof value !== 'string') return ApprovalMode.DEFAULT;

const normalized = value.trim().toLowerCase().replaceAll('_', '-');
const mode = normalized === 'autoedit' ? ApprovalMode.AUTO_EDIT : normalized;
if ((APPROVAL_MODES as readonly string[]).includes(mode)) {
return mode as ApprovalMode;
}

if (value.trim().length > 0) {
debugLogger.warn(
`[workspace-providers-status] unrecognized approvalMode "${value}", falling back to default`,
);
}
return ApprovalMode.DEFAULT;
Comment thread
ytahdn marked this conversation as resolved.
}

function isMainSelectableModel(model: {
fastOnly?: boolean;
voiceOnly?: boolean;
Expand Down
22 changes: 22 additions & 0 deletions packages/sdk-typescript/src/daemon/DaemonClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2717,6 +2717,28 @@ export class DaemonClient {
);
}

async detachSession(sessionId: string, clientId?: string): Promise<void> {
if (!clientId) return;
return await this.fetchWithTimeout(
`${this.baseUrl}/session/${encodeURIComponent(sessionId)}/detach`,
{
method: 'POST',
headers: this.headers({}, clientId),
},
async (res) => {
if (res.status === 204 || res.status === 404) {
Comment thread
ytahdn marked this conversation as resolved.
try {
await res.body?.cancel();
} catch {
/* body already consumed or no body */
}
return;
}
throw await this.failOnError(res, 'POST /session/:id/detach');
},
);
}

async deleteSessionsData(
sessionIds: string[],
clientId?: string,
Expand Down
4 changes: 4 additions & 0 deletions packages/sdk-typescript/src/daemon/DaemonSessionClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -590,6 +590,10 @@ export class DaemonSessionClient {
return await this.client.closeSession(this.sessionId, this.clientId);
}

async detach(): Promise<void> {
return await this.client.detachSession(this.sessionId, this.clientId);
}

async updateMetadata(metadata: {
displayName?: string;
}): Promise<SessionMetadataResult> {
Expand Down
1 change: 1 addition & 0 deletions packages/sdk-typescript/src/daemon/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -501,6 +501,7 @@ export interface DaemonWorkspaceProvidersStatus {
initialized: boolean;
acpChannelLive?: boolean;
current?: DaemonWorkspaceProviderCurrent;
approvalMode?: DaemonApprovalMode;
providers: DaemonWorkspaceProviderStatus[];
errors?: DaemonStatusCell[];
}
Expand Down
28 changes: 14 additions & 14 deletions packages/web-shell/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ export function QwenCodePanel() {
<WebShellWithProviders
baseUrl="http://127.0.0.1:4170"
token="your-bearer-token"
initialSessionId="838e1811-9f84-4848-9915-d9a7f01ff5c6"
sessionId="838e1811-9f84-4848-9915-d9a7f01ff5c6"
onSessionIdChange={(sessionId) => {
console.log('current session:', sessionId);
}}
Expand All @@ -69,7 +69,7 @@ import { WebShell } from '@qwen-code/web-shell';
export function App() {
return (
<DaemonWorkspaceProvider baseUrl="http://127.0.0.1:4170" token="...">
<DaemonSessionProvider initialSessionId="...">
<DaemonSessionProvider sessionId="...">
<ChatPanel />
<WebShell theme="dark" language="zh-CN" />
</DaemonSessionProvider>
Expand All @@ -87,21 +87,21 @@ export function App() {

包含 `WebShell` 的所有 Props,加上 Provider 配置:

| 属性 | 类型 | 说明 |
| ------------------ | -------- | ---------------------------------------------------- |
| `baseUrl` | `string` | daemon API 地址,未传时使用 `window.location.origin` |
| `token` | `string` | daemon API Bearer token |
| `initialSessionId` | `string` | 初始要连接的 session id |
| 属性 | 类型 | 说明 |
| ----------- | -------- | ---------------------------------------------------- |
| `baseUrl` | `string` | daemon API 地址,未传时使用 `window.location.origin` |
| `token` | `string` | daemon API Bearer token |
| `sessionId` | `string` | 要连接的 session id;未传或 `undefined` 时保持空页面 |

### WebShell

| 属性 | 类型 | 说明 |
| ------------------- | -------------------------------------- | --------------------------------- |
| `onSessionIdChange` | `(sessionId: string) => void` | 当前 session id 变化时触发 |
| `theme` | `'dark' \| 'light'` | UI 主题,默认 `dark` |
| `onThemeChange` | `(theme: WebShellTheme) => void` | `/theme` 命令切换主题后触发 |
| `language` | `'en' \| 'zh-CN' \| 'zh' \| 'zh-cn'` | UI 语言 |
| `onLanguageChange` | `(language: WebShellLanguage) => void` | `/language ui` 切换 UI 语言后触发 |
| 属性 | 类型 | 说明 |
| ------------------- | ------------------------------------------ | --------------------------------- |
| `onSessionIdChange` | `(sessionId: string \| undefined) => void` | 当前 session id 变化或清空时触发 |
| `theme` | `'dark' \| 'light'` | UI 主题,默认 `dark` |
| `onThemeChange` | `(theme: WebShellTheme) => void` | `/theme` 命令切换主题后触发 |
| `language` | `'en' \| 'zh-CN' \| 'zh' \| 'zh-cn'` | UI 语言 |
| `onLanguageChange` | `(language: WebShellLanguage) => void` | `/language ui` 切换 UI 语言后触发 |

## 架构说明

Expand Down
Loading
Loading