diff --git a/docs/developers/qwen-serve-protocol.md b/docs/developers/qwen-serve-protocol.md index 01833d570d9..6756a5cb48e 100644 --- a/docs/developers/qwen-serve-protocol.md +++ b/docs/developers/qwen-serve-protocol.md @@ -97,7 +97,8 @@ registry. Clients **must** gate UI off `features`, not off `mode` (per design 'session_list', 'session_prompt', 'session_cancel', 'session_events', 'slow_client_warning', 'typed_event_schema', 'session_set_model', 'client_identity', 'client_heartbeat', - 'session_permission_vote', 'permission_vote', + 'session_permission_vote', 'permission_vote', 'workspace_mcp', 'workspace_skills', + 'workspace_providers', 'session_context', 'session_supported_commands', 'session_close', 'session_metadata'] ``` @@ -159,6 +160,174 @@ Stable contract: when `v` increments the frame layout has changed in a backwards > **`workspaceCwd`** is the canonical absolute path this daemon binds to (#3803 §02 — 1 daemon = 1 workspace). Use it to (a) detect mismatch before posting `/session` and (b) omit `cwd` on `POST /session` (the route falls back to this path). Multi-workspace deployments expose multiple daemons on different ports, each with its own `workspaceCwd`. Additive to v=1: pre-§02 v=1 daemons omit the field — clients that target older builds should null-check before consuming it. +### Read-only runtime status routes + +These routes report daemon-side runtime snapshots. They are additive v1 routes, +do not mutate state, and do not change the serve protocol version. Workspace +status routes intentionally do **not** start the ACP child process just because +a client polls a GET route: if the daemon is idle, they return +`initialized: false` with an empty snapshot. Session status routes require a +live session and use the standard `404 SessionNotFoundError` shape for unknown +ids. + +Capability tags: + +- `workspace_mcp` → `GET /workspace/mcp` +- `workspace_skills` → `GET /workspace/skills` +- `workspace_providers` → `GET /workspace/providers` +- `session_context` → `GET /session/:id/context` +- `session_supported_commands` → `GET /session/:id/supported-commands` + +Common status cell: + +```ts +type DaemonStatus = + | 'ok' + | 'warning' + | 'error' + | 'disabled' + | 'not_started' + | 'unknown'; + +interface DaemonStatusCell { + kind: string; + status: DaemonStatus; + error?: string; + errorKind?: string; + hint?: string; +} +``` + +Status payloads never expose MCP env values, headers, OAuth/service-account +details, provider API keys, provider `baseUrl` / `envKey`, skill body, skill +filesystem paths, or hook definitions. + +### `GET /workspace/mcp` + +```json +{ + "v": 1, + "workspaceCwd": "/canonical/path", + "initialized": true, + "discoveryState": "completed", + "servers": [ + { + "kind": "mcp_server", + "status": "ok", + "name": "docs", + "mcpStatus": "connected", + "transport": "stdio", + "disabled": false, + "description": "Documentation server", + "extensionName": "docs-ext" + } + ] +} +``` + +`discoveryState` is one of `not_started`, `in_progress`, or `completed`. +`transport` is one of `stdio`, `sse`, `http`, `websocket`, `sdk`, or +`unknown`. `errors` is omitted when discovery succeeds. + +### `GET /workspace/skills` + +```json +{ + "v": 1, + "workspaceCwd": "/canonical/path", + "initialized": true, + "skills": [ + { + "kind": "skill", + "status": "ok", + "name": "review", + "description": "Review code", + "level": "project", + "modelInvocable": true, + "argumentHint": "[path]" + } + ] +} +``` + +`level` is one of `project`, `user`, `extension`, or `bundled`. `errors` is +omitted when discovery succeeds. + +### `GET /workspace/providers` + +```json +{ + "v": 1, + "workspaceCwd": "/canonical/path", + "initialized": true, + "current": { "authType": "qwen", "modelId": "qwen3(qwen)" }, + "providers": [ + { + "kind": "model_provider", + "status": "ok", + "authType": "qwen", + "current": true, + "models": [ + { + "modelId": "qwen3(qwen)", + "baseModelId": "qwen3", + "name": "Qwen 3", + "description": null, + "contextLimit": 4096, + "isCurrent": true, + "isRuntime": false + } + ] + } + ] +} +``` + +Models are grouped by auth type. Provider connection diagnostics and environment +preflight checks are intentionally out of scope here; deeper preflight/env +checks belong to a later daemon status wave. `errors` is omitted when snapshot +construction succeeds. + +### `GET /session/:id/context` + +```json +{ + "v": 1, + "sessionId": "", + "workspaceCwd": "/canonical/path", + "state": { + "models": {}, + "modes": {}, + "configOptions": [] + } +} +``` + +`state` mirrors the same ACP model/mode/config-option shapes used by +`POST /session`, `POST /session/:id/load`, and `POST /session/:id/resume`. + +### `GET /session/:id/supported-commands` + +```json +{ + "v": 1, + "sessionId": "", + "availableCommands": [ + { + "name": "init", + "description": "Initialize the project", + "input": null, + "_meta": { "source": "builtin" } + } + ], + "availableSkills": ["review"] +} +``` + +`availableCommands` is the same command snapshot used by the +`available_commands_update` SSE notification. `availableSkills` lists skill +names only; clients must not expect skill bodies or paths over this route. + ### `POST /session` Spawn a new agent or attach to an existing one (under `sessionScope: 'single'`, the default). @@ -552,6 +721,7 @@ The connection then closes. | `packages/cli/src/serve/server.ts` | Express routes + middleware | | `packages/cli/src/serve/auth.ts` | bearer + Host allowlist + CORS deny | | `packages/cli/src/serve/httpAcpBridge.ts` | spawn-or-attach + per-session FIFO + permission registry | +| `packages/cli/src/serve/status.ts` | read-only daemon status wire types + ACP ext method names | | `packages/cli/src/serve/eventBus.ts` | bounded async queue + replay ring | | `packages/sdk-typescript/src/daemon/DaemonClient.ts` | TS client | | `packages/sdk-typescript/src/daemon/sse.ts` | EventSource frame parser | diff --git a/docs/users/qwen-serve.md b/docs/users/qwen-serve.md index 5e3d303310a..2ed63eccf78 100644 --- a/docs/users/qwen-serve.md +++ b/docs/users/qwen-serve.md @@ -38,6 +38,12 @@ curl http://127.0.0.1:4170/capabilities The `workspaceCwd` field surfaces the bound workspace so clients can pre-flight check + omit `cwd` on `POST /session`. +The daemon also exposes read-only runtime snapshots for client UIs: +`GET /workspace/mcp`, `GET /workspace/skills`, `GET /workspace/providers`, +`GET /session/:id/context`, and `GET /session/:id/supported-commands`. The +workspace routes report the live daemon runtime and do not start the ACP child +when idle; an idle daemon returns `initialized: false` with an empty snapshot. + ### 3. Open a session ```bash diff --git a/packages/cli/src/acp-integration/acpAgent.test.ts b/packages/cli/src/acp-integration/acpAgent.test.ts index 45f25783ac6..5ff50f1fb6a 100644 --- a/packages/cli/src/acp-integration/acpAgent.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.test.ts @@ -93,12 +93,24 @@ vi.mock('@qwen-code/qwen-code-core', () => ({ clearCachedCredentialFile: vi.fn(), QwenOAuth2Event: {}, qwenOAuth2Events: { on: vi.fn(), off: vi.fn() }, + MCPDiscoveryState: { + NOT_STARTED: 'not_started', + IN_PROGRESS: 'in_progress', + COMPLETED: 'completed', + }, + MCPServerStatus: { + DISCONNECTED: 'disconnected', + CONNECTING: 'connecting', + CONNECTED: 'connected', + }, + getMCPDiscoveryState: vi.fn().mockReturnValue('completed'), + getMCPServerStatus: vi.fn().mockReturnValue('connected'), MCPServerConfig: vi.fn().mockImplementation((...args: unknown[]) => ({ _args: args, })), SessionService: vi.fn(), SESSION_TITLE_MAX_LENGTH: 200, - tokenLimit: vi.fn(), + tokenLimit: vi.fn().mockReturnValue(128_000), SessionStartSource: { Startup: 'startup', Resume: 'resume', @@ -131,9 +143,20 @@ vi.mock('../config/settings.js', () => ({ loadSettings: vi.fn(), })); vi.mock('../config/config.js', () => ({ loadCliConfig: vi.fn() })); -vi.mock('./session/Session.js', () => ({ Session: vi.fn() })); +vi.mock('./session/Session.js', () => ({ + Session: vi.fn(), + buildAvailableCommandsSnapshot: vi.fn().mockResolvedValue({ + availableCommands: [], + availableSkills: [], + }), +})); vi.mock('../utils/acpModelUtils.js', () => ({ - formatAcpModelId: vi.fn(), + formatAcpModelId: vi.fn( + (modelId: string, authType: string) => `${modelId}(${authType})`, + ), + parseAcpBaseModelId: vi.fn((modelId: string) => + modelId.replace(/\([^)]+\)$/, ''), + ), })); import { @@ -149,12 +172,18 @@ import { SessionEndReason, MCPServerConfig, SessionService, + MCPDiscoveryState, + MCPServerStatus, + getMCPDiscoveryState, + getMCPServerStatus, + tokenLimit, } from '@qwen-code/qwen-code-core'; import type { McpServer } from '@agentclientprotocol/sdk'; import { AgentSideConnection } from '@agentclientprotocol/sdk'; import { loadSettings } from '../config/settings.js'; import { loadCliConfig } from '../config/config.js'; -import { Session } from './session/Session.js'; +import { Session, buildAvailableCommandsSnapshot } from './session/Session.js'; +import { SERVE_STATUS_EXT_METHODS } from '../serve/status.js'; describe('runAcpAgent shutdown cleanup', () => { let processExitSpy: MockInstance; @@ -761,6 +790,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => { }), refreshAuth: vi.fn().mockResolvedValue(undefined), getModel: vi.fn().mockReturnValue('m'), + getTargetDir: vi.fn().mockReturnValue('/tmp'), getContentGeneratorConfig: vi.fn().mockReturnValue({}), getAvailableModels: vi.fn().mockReturnValue([]), getModes: vi.fn().mockReturnValue([]), @@ -817,6 +847,431 @@ describe('QwenAgent MCP SSE/HTTP support', () => { return innerConfig; } + it('status ext methods expose workspace snapshots without secrets', async () => { + vi.mocked(getMCPDiscoveryState).mockReturnValue( + MCPDiscoveryState.COMPLETED, + ); + vi.mocked(getMCPServerStatus).mockImplementation((name: string) => + name === 'disabled' + ? MCPServerStatus.DISCONNECTED + : MCPServerStatus.CONNECTED, + ); + const listSkills = vi.fn().mockResolvedValue([ + { + name: 'review', + description: 'Review code', + level: 'project', + argumentHint: '[path]', + disableModelInvocation: false, + body: 'secret skill body', + filePath: '/secret/SKILL.md', + skillRoot: '/secret', + hooks: { pre: ['secret-hook'] }, + }, + ]); + mockConfig = { + ...mockConfig, + getTargetDir: vi.fn().mockReturnValue('/work/status'), + getMcpServers: vi.fn().mockReturnValue({ + docs: { + command: 'node', + args: ['server.js'], + env: { TOKEN: 'secret-token' }, + description: 'Docs server', + extensionName: 'docs-ext', + }, + remote: { + httpUrl: 'https://example.com/mcp', + headers: { Authorization: 'Bearer secret' }, + }, + disabled: { + command: 'node', + args: ['disabled.js'], + }, + malformed: { + command: 'node', + description: 123, + extensionName: { name: 'bad-ext' }, + }, + }), + isMcpServerDisabled: vi + .fn() + .mockImplementation((name: string) => name === 'disabled'), + getSkillManager: vi.fn().mockReturnValue({ listSkills }), + getAuthType: vi.fn().mockReturnValue('qwen'), + getAllConfiguredModels: vi.fn().mockReturnValue([ + { + id: 'qwen-plus', + label: 'Qwen Plus', + description: 'General coding model', + authType: 'qwen', + contextWindowSize: 65_536, + baseUrl: 'https://secret.example.com', + envKey: 'DASHSCOPE_API_KEY', + }, + ]), + getActiveRuntimeModelSnapshot: vi.fn().mockReturnValue(undefined), + getModel: vi.fn().mockReturnValue('qwen-plus'), + } as unknown as Config; + + const agentPromise = runAcpAgent( + mockConfig, + makeSessionSettings(), + mockArgv, + ); + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }) as AgentLike; + + const mcp = await agent.extMethod( + SERVE_STATUS_EXT_METHODS.workspaceMcp, + {}, + ); + const skills = await agent.extMethod( + SERVE_STATUS_EXT_METHODS.workspaceSkills, + {}, + ); + const providers = await agent.extMethod( + SERVE_STATUS_EXT_METHODS.workspaceProviders, + {}, + ); + + expect(mcp).toMatchObject({ + v: 1, + workspaceCwd: '/work/status', + initialized: true, + discoveryState: 'completed', + servers: [ + { + kind: 'mcp_server', + status: 'ok', + name: 'docs', + mcpStatus: 'connected', + transport: 'stdio', + disabled: false, + description: 'Docs server', + extensionName: 'docs-ext', + }, + { + kind: 'mcp_server', + status: 'ok', + name: 'remote', + mcpStatus: 'connected', + transport: 'http', + disabled: false, + }, + { + kind: 'mcp_server', + status: 'disabled', + name: 'disabled', + mcpStatus: 'disconnected', + transport: 'stdio', + disabled: true, + }, + { + kind: 'mcp_server', + status: 'ok', + name: 'malformed', + mcpStatus: 'connected', + transport: 'stdio', + disabled: false, + }, + ], + }); + expect(JSON.stringify(mcp)).not.toContain('secret-token'); + expect(JSON.stringify(mcp)).not.toContain('Authorization'); + expect(JSON.stringify(mcp)).not.toContain('bad-ext'); + + expect(skills).toMatchObject({ + v: 1, + workspaceCwd: '/work/status', + initialized: true, + skills: [ + { + kind: 'skill', + status: 'ok', + name: 'review', + description: 'Review code', + level: 'project', + argumentHint: '[path]', + modelInvocable: true, + }, + ], + }); + expect(JSON.stringify(skills)).not.toContain('secret skill body'); + expect(JSON.stringify(skills)).not.toContain('/secret'); + expect(JSON.stringify(skills)).not.toContain('secret-hook'); + + expect(providers).toMatchObject({ + v: 1, + workspaceCwd: '/work/status', + initialized: true, + current: { authType: 'qwen', modelId: 'qwen-plus(qwen)' }, + providers: [ + { + kind: 'model_provider', + status: 'ok', + authType: 'qwen', + current: true, + models: [ + { + modelId: 'qwen-plus(qwen)', + baseModelId: 'qwen-plus', + name: 'Qwen Plus', + description: 'General coding model', + contextLimit: 65_536, + isCurrent: true, + isRuntime: false, + }, + ], + }, + ], + }); + expect(JSON.stringify(providers)).not.toContain('secret.example.com'); + expect(JSON.stringify(providers)).not.toContain('DASHSCOPE_API_KEY'); + + mockConnectionState.resolve(); + await agentPromise; + }); + + it('status ext methods return error cells when workspace snapshots fail', async () => { + mockConfig = { + ...mockConfig, + getTargetDir: vi.fn().mockReturnValue('/work/status'), + getMcpServers: vi.fn(() => { + throw new Error('broken mcp config'); + }), + getAuthType: vi.fn().mockReturnValue('qwen'), + getActiveRuntimeModelSnapshot: vi.fn().mockReturnValue(undefined), + getModel: vi.fn().mockReturnValue('qwen-plus'), + getAllConfiguredModels: vi.fn(() => { + throw new Error('broken provider config'); + }), + } as unknown as Config; + + const agentPromise = runAcpAgent( + mockConfig, + makeSessionSettings(), + mockArgv, + ); + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }) as AgentLike; + + await expect( + agent.extMethod(SERVE_STATUS_EXT_METHODS.workspaceMcp, {}), + ).resolves.toMatchObject({ + v: 1, + workspaceCwd: '/work/status', + initialized: true, + servers: [], + errors: [{ kind: 'mcp', status: 'error', error: 'broken mcp config' }], + }); + await expect( + agent.extMethod(SERVE_STATUS_EXT_METHODS.workspaceProviders, {}), + ).resolves.toMatchObject({ + v: 1, + workspaceCwd: '/work/status', + initialized: true, + providers: [], + errors: [ + { + kind: 'providers', + status: 'error', + error: 'broken provider config', + }, + ], + }); + + mockConnectionState.resolve(); + await agentPromise; + }); + + it('provider status marks current only for matching models', async () => { + mockConfig = { + ...mockConfig, + getTargetDir: vi.fn().mockReturnValue('/work/status'), + getAuthType: vi.fn().mockReturnValue('qwen'), + getActiveRuntimeModelSnapshot: vi.fn().mockReturnValue(undefined), + getModel: vi.fn().mockReturnValue('missing-model'), + getAllConfiguredModels: vi.fn().mockReturnValue([ + { + id: 'qwen-plus', + label: 'Qwen Plus', + authType: 'qwen', + }, + ]), + } as unknown as Config; + + const agentPromise = runAcpAgent( + mockConfig, + makeSessionSettings(), + mockArgv, + ); + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }) as AgentLike; + + await expect( + agent.extMethod(SERVE_STATUS_EXT_METHODS.workspaceProviders, {}), + ).resolves.toMatchObject({ + current: { authType: 'qwen', modelId: 'missing-model(qwen)' }, + providers: [ + { + authType: 'qwen', + current: false, + models: [ + { + modelId: 'qwen-plus(qwen)', + baseModelId: 'qwen-plus', + contextLimit: 128_000, + isCurrent: false, + }, + ], + }, + ], + }); + + mockConnectionState.resolve(); + await agentPromise; + }); + + it('provider status uses runtime model ids for base id and token limit', async () => { + mockConfig = { + ...mockConfig, + getTargetDir: vi.fn().mockReturnValue('/work/status'), + getAuthType: vi.fn().mockReturnValue('qwen'), + getActiveRuntimeModelSnapshot: vi.fn().mockReturnValue({ + id: 'runtime-qwen-plus', + authType: 'qwen', + }), + getModel: vi.fn().mockReturnValue('qwen-plus'), + getAllConfiguredModels: vi.fn().mockReturnValue([ + { + id: 'qwen-plus', + runtimeSnapshotId: 'runtime-qwen-plus', + label: 'Runtime Qwen Plus', + authType: 'qwen', + isRuntimeModel: true, + }, + ]), + } as unknown as Config; + + const agentPromise = runAcpAgent( + mockConfig, + makeSessionSettings(), + mockArgv, + ); + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }) as AgentLike; + + await expect( + agent.extMethod(SERVE_STATUS_EXT_METHODS.workspaceProviders, {}), + ).resolves.toMatchObject({ + current: { authType: 'qwen', modelId: 'runtime-qwen-plus(qwen)' }, + providers: [ + { + authType: 'qwen', + current: true, + models: [ + { + modelId: 'runtime-qwen-plus(qwen)', + baseModelId: 'runtime-qwen-plus', + contextLimit: 128_000, + isCurrent: true, + isRuntime: true, + }, + ], + }, + ], + }); + expect(vi.mocked(tokenLimit)).toHaveBeenCalledWith('runtime-qwen-plus'); + + mockConnectionState.resolve(); + await agentPromise; + }); + + it('status ext methods expose live session context and supported commands', async () => { + const sessionId = '11111111-1111-1111-1111-111111111111'; + const innerConfig = await setupSessionMocks(sessionId); + vi.mocked(buildAvailableCommandsSnapshot).mockResolvedValueOnce({ + availableCommands: [ + { + name: 'init', + description: 'Initialize', + input: null, + }, + ], + availableSkills: ['review'], + }); + + const agentPromise = runAcpAgent( + mockConfig, + makeSessionSettings(), + mockArgv, + ); + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }) as AgentLike; + + await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + const context = await agent.extMethod( + SERVE_STATUS_EXT_METHODS.sessionContext, + { sessionId }, + ); + const supportedCommands = await agent.extMethod( + SERVE_STATUS_EXT_METHODS.sessionSupportedCommands, + { sessionId }, + ); + + expect(context).toMatchObject({ + v: 1, + sessionId, + workspaceCwd: '/tmp', + state: { + models: { currentModelId: 'm(api-key)', availableModels: [] }, + modes: { currentModeId: 'default', availableModes: [] }, + }, + }); + expect(supportedCommands).toEqual({ + v: 1, + sessionId, + availableCommands: [ + { + name: 'init', + description: 'Initialize', + input: null, + }, + ], + availableSkills: ['review'], + }); + expect(buildAvailableCommandsSnapshot).toHaveBeenCalledWith(innerConfig); + + mockConnectionState.resolve(); + await agentPromise; + }); + it('newSession with SSE MCP server creates MCPServerConfig with url', async () => { await setupSessionMocks('session-sse'); diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index 926f1ec1b1b..202246ac5c4 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -16,6 +16,10 @@ import { SessionService, SESSION_TITLE_MAX_LENGTH, tokenLimit, + getMCPDiscoveryState, + getMCPServerStatus, + MCPDiscoveryState, + MCPServerStatus, type Config, type ConversationRecord, type DeviceAuthorizationData, @@ -69,10 +73,31 @@ import type { ApprovalModeValue } from './session/types.js'; import { z } from 'zod'; import type { CliArgs } from '../config/config.js'; import { loadCliConfig } from '../config/config.js'; -import { Session } from './session/Session.js'; -import { formatAcpModelId } from '../utils/acpModelUtils.js'; +import { Session, buildAvailableCommandsSnapshot } from './session/Session.js'; +import { + formatAcpModelId, + parseAcpBaseModelId, +} from '../utils/acpModelUtils.js'; import { runWithAcpRuntimeOutputDir } from './runtimeOutputDirContext.js'; import { runExitCleanup } from '../utils/cleanup.js'; +import { + STATUS_SCHEMA_VERSION, + SERVE_STATUS_EXT_METHODS, + type ServeMcpDiscoveryState, + type ServeMcpServerRuntimeStatus, + type ServeMcpTransport, + type ServeSessionContextStatus, + type ServeSessionSupportedCommandsStatus, + type ServeStatus, + type ServeStatusCell, + type ServeWorkspaceMcpServerStatus, + type ServeWorkspaceMcpStatus, + type ServeWorkspaceProviderModel, + type ServeWorkspaceProviderStatus, + type ServeWorkspaceProvidersStatus, + type ServeWorkspaceSkillStatus, + type ServeWorkspaceSkillsStatus, +} from '../serve/status.js'; const debugLogger = createDebugLogger('ACP_AGENT'); @@ -530,6 +555,327 @@ class QwenAgent implements Agent { await session.cancelPendingPrompt(); } + private workspaceCwd(config: Config): string { + return config.getTargetDir(); + } + + private safeWorkspaceCwd(config: Config): string { + try { + return this.workspaceCwd(config); + } catch { + return ''; + } + } + + private mcpTransport(server: unknown): ServeMcpTransport { + if ( + server && + typeof server === 'object' && + 'type' in server && + (server as { type?: unknown }).type === 'sdk' + ) { + return 'sdk'; + } + if ( + server && + typeof server === 'object' && + typeof (server as { httpUrl?: unknown }).httpUrl === 'string' + ) { + return 'http'; + } + if ( + server && + typeof server === 'object' && + typeof (server as { url?: unknown }).url === 'string' + ) { + return 'sse'; + } + if ( + server && + typeof server === 'object' && + typeof (server as { tcp?: unknown }).tcp === 'string' + ) { + return 'websocket'; + } + if ( + server && + typeof server === 'object' && + typeof (server as { command?: unknown }).command === 'string' + ) { + return 'stdio'; + } + return 'unknown'; + } + + private mcpStatus(status: MCPServerStatus): ServeMcpServerRuntimeStatus { + switch (status) { + case MCPServerStatus.CONNECTED: + return 'connected'; + case MCPServerStatus.CONNECTING: + return 'connecting'; + case MCPServerStatus.DISCONNECTED: + default: + return 'disconnected'; + } + } + + private mcpCellStatus( + status: MCPServerStatus, + disabled: boolean, + ): ServeStatus { + if (disabled) return 'disabled'; + switch (status) { + case MCPServerStatus.CONNECTED: + return 'ok'; + case MCPServerStatus.CONNECTING: + return 'warning'; + case MCPServerStatus.DISCONNECTED: + default: + return 'error'; + } + } + + private discoveryState(): ServeMcpDiscoveryState { + const state = getMCPDiscoveryState(); + switch (state) { + case MCPDiscoveryState.IN_PROGRESS: + return 'in_progress'; + case MCPDiscoveryState.COMPLETED: + return 'completed'; + case MCPDiscoveryState.NOT_STARTED: + default: + return 'not_started'; + } + } + + private buildWorkspaceMcpStatus(config: Config): ServeWorkspaceMcpStatus { + try { + const workspaceCwd = this.workspaceCwd(config); + const servers = config.getMcpServers() ?? {}; + return { + v: STATUS_SCHEMA_VERSION, + workspaceCwd, + initialized: true, + discoveryState: this.discoveryState(), + servers: Object.entries(servers).map(([name, server]) => { + const disabled = config.isMcpServerDisabled(name); + const rawStatus = getMCPServerStatus(name); + const out: ServeWorkspaceMcpServerStatus = { + kind: 'mcp_server', + status: this.mcpCellStatus(rawStatus, disabled), + name, + mcpStatus: this.mcpStatus(rawStatus), + transport: this.mcpTransport(server), + disabled, + }; + const description = + server && typeof server === 'object' + ? (server as { description?: unknown }).description + : undefined; + const extensionName = + server && typeof server === 'object' + ? (server as { extensionName?: unknown }).extensionName + : undefined; + if (typeof description === 'string') { + out.description = description; + } + if (typeof extensionName === 'string') { + out.extensionName = extensionName; + } + return out; + }), + }; + } catch (error) { + return { + v: STATUS_SCHEMA_VERSION, + workspaceCwd: this.safeWorkspaceCwd(config), + initialized: true, + servers: [], + errors: [this.errorCell('mcp', error)], + }; + } + } + + private errorCell(kind: string, error: unknown): ServeStatusCell { + return { + kind, + status: 'error', + error: error instanceof Error ? error.message : String(error), + }; + } + + private async buildWorkspaceSkillsStatus( + config: Config, + ): Promise { + const skillManager = config.getSkillManager(); + if (!skillManager) { + return { + v: STATUS_SCHEMA_VERSION, + workspaceCwd: this.workspaceCwd(config), + initialized: true, + skills: [], + }; + } + + try { + const skills = await skillManager.listSkills(); + return { + v: STATUS_SCHEMA_VERSION, + workspaceCwd: this.workspaceCwd(config), + initialized: true, + skills: skills.map((skill): ServeWorkspaceSkillStatus => { + const modelInvocable = skill.disableModelInvocation !== true; + return { + kind: 'skill', + status: modelInvocable ? 'ok' : 'disabled', + name: skill.name, + description: skill.description, + level: skill.level, + modelInvocable, + ...(skill.argumentHint ? { argumentHint: skill.argumentHint } : {}), + ...(skill.model ? { model: skill.model } : {}), + ...(skill.extensionName + ? { extensionName: skill.extensionName } + : {}), + }; + }), + }; + } catch (error) { + return { + v: STATUS_SCHEMA_VERSION, + workspaceCwd: this.workspaceCwd(config), + initialized: true, + skills: [], + errors: [this.errorCell('skills', error)], + }; + } + } + + private buildWorkspaceProvidersStatus( + config: Config, + ): ServeWorkspaceProvidersStatus { + try { + const workspaceCwd = this.workspaceCwd(config); + const currentAuthType = config.getAuthType?.(); + const activeRuntimeSnapshot = config.getActiveRuntimeModelSnapshot?.(); + const currentModelId = activeRuntimeSnapshot + ? activeRuntimeSnapshot.id + : (config.getModel() || '').trim(); + const hasCurrentModel = currentModelId.length > 0; + const currentAuth = activeRuntimeSnapshot?.authType ?? currentAuthType; + const currentAcpModelId = + hasCurrentModel && currentAuth + ? formatAcpModelId(currentModelId, currentAuth) + : currentModelId || undefined; + const providers = new Map(); + + for (const model of config.getAllConfiguredModels()) { + const authType = String(model.authType); + let provider = providers.get(authType); + if (!provider) { + provider = { + kind: 'model_provider', + status: 'ok', + authType, + current: false, + models: [], + }; + providers.set(authType, provider); + } + + const effectiveModelId = + model.isRuntimeModel && model.runtimeSnapshotId + ? model.runtimeSnapshotId + : model.id; + const modelId = formatAcpModelId(effectiveModelId, model.authType); + const isCurrent = + currentAuth === model.authType && + hasCurrentModel && + (currentModelId === effectiveModelId || + currentModelId === model.id || + currentAcpModelId === modelId); + const providerModel: ServeWorkspaceProviderModel = { + modelId, + baseModelId: parseAcpBaseModelId(effectiveModelId), + name: model.label, + ...(model.description !== undefined + ? { description: model.description } + : {}), + contextLimit: model.contextWindowSize ?? tokenLimit(effectiveModelId), + isCurrent, + isRuntime: model.isRuntimeModel === true, + }; + provider.models.push(providerModel); + if (isCurrent) provider.current = true; + } + + return { + v: STATUS_SCHEMA_VERSION, + workspaceCwd, + initialized: true, + ...(currentAuth || currentAcpModelId + ? { + current: { + ...(currentAuth ? { authType: String(currentAuth) } : {}), + ...(currentAcpModelId ? { modelId: currentAcpModelId } : {}), + }, + } + : {}), + providers: [...providers.values()], + }; + } catch (error) { + return { + v: STATUS_SCHEMA_VERSION, + workspaceCwd: this.safeWorkspaceCwd(config), + initialized: true, + providers: [], + errors: [this.errorCell('providers', error)], + }; + } + } + + private sessionOrThrow(sessionId: string): Session { + const session = this.sessions.get(sessionId); + if (!session) { + throw RequestError.invalidParams( + undefined, + `Session not found for id: ${sessionId}`, + ); + } + return session; + } + + private buildSessionContextStatus( + sessionId: string, + ): ServeSessionContextStatus { + const session = this.sessionOrThrow(sessionId); + const config = session.getConfig(); + return { + v: STATUS_SCHEMA_VERSION, + sessionId, + workspaceCwd: this.workspaceCwd(config), + state: { + models: this.buildAvailableModels(config), + modes: this.buildModesData(config), + configOptions: this.buildConfigOptions(config), + }, + }; + } + + private async buildSessionSupportedCommandsStatus( + sessionId: string, + ): Promise { + const session = this.sessionOrThrow(sessionId); + const { availableCommands, availableSkills } = + await buildAvailableCommandsSnapshot(session.getConfig()); + return { + v: STATUS_SCHEMA_VERSION, + sessionId, + availableCommands, + availableSkills: availableSkills ?? [], + }; + } + async extMethod( method: string, params: Record, @@ -538,6 +884,44 @@ class QwenAgent implements Agent { const SESSION_ID_RE = /^[0-9a-fA-F-]{32,36}$/; switch (method) { + case SERVE_STATUS_EXT_METHODS.workspaceMcp: + return this.buildWorkspaceMcpStatus(this.config) as unknown as Record< + string, + unknown + >; + case SERVE_STATUS_EXT_METHODS.workspaceSkills: + return (await this.buildWorkspaceSkillsStatus( + this.config, + )) as unknown as Record; + case SERVE_STATUS_EXT_METHODS.workspaceProviders: + return this.buildWorkspaceProvidersStatus( + this.config, + ) as unknown as Record; + case SERVE_STATUS_EXT_METHODS.sessionContext: { + const sessionId = params['sessionId']; + if (typeof sessionId !== 'string' || sessionId.length === 0) { + throw RequestError.invalidParams( + undefined, + 'Invalid or missing sessionId', + ); + } + return this.buildSessionContextStatus(sessionId) as unknown as Record< + string, + unknown + >; + } + case SERVE_STATUS_EXT_METHODS.sessionSupportedCommands: { + const sessionId = params['sessionId']; + if (typeof sessionId !== 'string' || sessionId.length === 0) { + throw RequestError.invalidParams( + undefined, + 'Invalid or missing sessionId', + ); + } + return (await this.buildSessionSupportedCommandsStatus( + sessionId, + )) as unknown as Record; + } case 'deleteSession': { const sessionId = params['sessionId'] as string; if (!sessionId || !SESSION_ID_RE.test(sessionId)) { diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index 8a47c8ced6f..a42a70a047d 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -187,6 +187,56 @@ function isUserPromptRecord(record: ChatRecord): boolean { ); } +export interface AvailableCommandsSnapshot { + availableCommands: AvailableCommand[]; + availableSkills?: string[]; +} + +export async function buildAvailableCommandsSnapshot( + config: Config, + abortSignal: AbortSignal = AbortSignal.timeout(10_000), +): Promise { + const slashCommands = await getAvailableCommands(config, abortSignal, 'acp'); + + const availableCommands: AvailableCommand[] = slashCommands.map((cmd) => { + const acceptsInput = + cmd.acceptsInput ?? + (cmd.kind !== CommandKind.BUILT_IN || + cmd.completion != null || + cmd.argumentHint != null || + (cmd.subCommands != null && cmd.subCommands.length > 0)); + return { + name: cmd.name, + description: cmd.description, + input: acceptsInput ? { hint: cmd.argumentHint ?? '' } : null, + _meta: { + argumentHint: cmd.argumentHint, + source: cmd.source, + sourceLabel: cmd.sourceLabel, + supportedModes: getEffectiveSupportedModes(cmd), + subcommands: getCommandSubcommandNames(cmd), + modelInvocable: cmd.modelInvocable === true, + }, + }; + }); + + let availableSkills: string[] | undefined; + try { + const skillManager = config.getSkillManager(); + if (skillManager) { + const skills = await skillManager.listSkills(); + availableSkills = skills.map((skill) => skill.name); + } + } catch (error) { + debugLogger.error('Error loading available skills:', error); + } + + return { + availableCommands, + ...(availableSkills !== undefined ? { availableSkills } : {}), + }; +} + /** * Session represents an active conversation session with the AI model. * It uses modular components for consistent event emission: @@ -1434,65 +1484,14 @@ export class Session implements SessionContext { } async sendAvailableCommandsUpdate(): Promise { - const abortController = new AbortController(); try { - // Load commands available in ACP mode - const slashCommands = await getAvailableCommands( - this.config, - abortController.signal, - 'acp', - ); - - // Convert SlashCommand[] to AvailableCommand[] format for ACP protocol. - // Commands that accept arguments get input: { hint } so the client can - // let users type arguments before submitting. Commands with no argument - // support get input: null so the client auto-submits them on selection. - // - // acceptsInput is determined by: - // 1. cmd.acceptsInput, if explicitly set (true or false overrides - // inference) - // 2. Otherwise, a command accepts arguments when any of: - // - it is not a BUILT_IN command (skills, file commands, etc.) - // - it has a completion function - // - it declares an argumentHint - // - it has subCommands - const availableCommands: AvailableCommand[] = slashCommands.map((cmd) => { - const acceptsInput = - cmd.acceptsInput ?? - (cmd.kind !== CommandKind.BUILT_IN || - cmd.completion != null || - cmd.argumentHint != null || - (cmd.subCommands != null && cmd.subCommands.length > 0)); - return { - name: cmd.name, - description: cmd.description, - input: acceptsInput ? { hint: cmd.argumentHint ?? '' } : null, - _meta: { - argumentHint: cmd.argumentHint, - source: cmd.source, - sourceLabel: cmd.sourceLabel, - supportedModes: getEffectiveSupportedModes(cmd), - subcommands: getCommandSubcommandNames(cmd), - modelInvocable: cmd.modelInvocable === true, - }, - }; - }); - - let availableSkills: string[] | undefined; - try { - const skillManager = this.config.getSkillManager(); - if (skillManager) { - const skills = await skillManager.listSkills(); - availableSkills = skills.map((skill) => skill.name); - } - } catch (error) { - debugLogger.error('Error loading available skills:', error); - } + const { availableCommands, availableSkills } = + await buildAvailableCommandsSnapshot(this.config); const update: SessionUpdate = { sessionUpdate: 'available_commands_update', availableCommands, - ...(availableSkills + ...(availableSkills !== undefined ? { _meta: { availableSkills, diff --git a/packages/cli/src/serve/capabilities.ts b/packages/cli/src/serve/capabilities.ts index 4eaee003f36..addb21cea3e 100644 --- a/packages/cli/src/serve/capabilities.ts +++ b/packages/cli/src/serve/capabilities.ts @@ -51,6 +51,11 @@ export const SERVE_CAPABILITY_REGISTRY = { client_heartbeat: { since: 'v1' }, session_permission_vote: { since: 'v1' }, permission_vote: { since: 'v1' }, + workspace_mcp: { since: 'v1' }, + workspace_skills: { since: 'v1' }, + workspace_providers: { since: 'v1' }, + session_context: { since: 'v1' }, + session_supported_commands: { since: 'v1' }, session_close: { since: 'v1' }, session_metadata: { since: 'v1' }, // Issue #4175 PR 15. Daemon was booted with `--require-auth` (or diff --git a/packages/cli/src/serve/httpAcpBridge.test.ts b/packages/cli/src/serve/httpAcpBridge.test.ts index 35ba1e8558a..97cfcf99a28 100644 --- a/packages/cli/src/serve/httpAcpBridge.test.ts +++ b/packages/cli/src/serve/httpAcpBridge.test.ts @@ -107,6 +107,11 @@ interface FakeAgentOpts { p: ResumeSessionRequest, self: FakeAgent, ) => Promise | ResumeSessionResponse; + extMethodImpl?: ( + method: string, + params: Record, + self: FakeAgent, + ) => Promise> | Record; } class FakeAgent implements Agent { @@ -115,6 +120,8 @@ class FakeAgent implements Agent { resumeSessionCalls: ResumeSessionRequest[] = []; promptCalls: PromptRequest[] = []; cancelCalls: CancelNotification[] = []; + extMethodCalls: Array<{ method: string; params: Record }> = + []; constructor(private readonly opts: FakeAgentOpts = {}) {} async initialize(_p: InitializeRequest): Promise { @@ -184,6 +191,16 @@ class FakeAgent implements Agent { ): Promise { throw new Error('not implemented in test fake'); } + async extMethod( + method: string, + params: Record, + ): Promise> { + this.extMethodCalls.push({ method, params }); + if (this.opts.extMethodImpl) { + return this.opts.extMethodImpl(method, params, this); + } + return {}; + } } interface ChannelHandle { @@ -349,6 +366,160 @@ describe('createHttpAcpBridge', () => { await bridge.shutdown(); }); + it('does not spawn a channel for idle workspace status snapshots', async () => { + const handles: ChannelHandle[] = []; + const bridge = makeBridge({ + channelFactory: async () => { + const h = makeChannel(); + handles.push(h); + return h.channel; + }, + }); + + await expect(bridge.getWorkspaceMcpStatus()).resolves.toMatchObject({ + v: 1, + workspaceCwd: WS_A, + initialized: false, + servers: [], + }); + await expect(bridge.getWorkspaceSkillsStatus()).resolves.toMatchObject({ + v: 1, + workspaceCwd: WS_A, + initialized: false, + skills: [], + }); + await expect(bridge.getWorkspaceProvidersStatus()).resolves.toMatchObject({ + v: 1, + workspaceCwd: WS_A, + initialized: false, + providers: [], + }); + expect(handles).toHaveLength(0); + }); + + it('requests workspace status through the existing ACP channel', async () => { + const handles: ChannelHandle[] = []; + const bridge = makeBridge({ + channelFactory: async () => { + const h = makeChannel({ + extMethodImpl: (method) => { + if (method === 'qwen/status/workspace/mcp') { + return { + v: 1, + workspaceCwd: WS_A, + initialized: true, + servers: [], + }; + } + if (method === 'qwen/status/workspace/skills') { + return { + v: 1, + workspaceCwd: WS_A, + initialized: true, + skills: [], + }; + } + return { + v: 1, + workspaceCwd: WS_A, + initialized: true, + providers: [], + }; + }, + }); + handles.push(h); + return h.channel; + }, + }); + + await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + await expect(bridge.getWorkspaceMcpStatus()).resolves.toMatchObject({ + initialized: true, + }); + await expect(bridge.getWorkspaceSkillsStatus()).resolves.toMatchObject({ + initialized: true, + }); + await expect(bridge.getWorkspaceProvidersStatus()).resolves.toMatchObject({ + initialized: true, + }); + + expect(handles).toHaveLength(1); + expect(handles[0]?.agent.extMethodCalls.map((c) => c.method)).toEqual([ + 'qwen/status/workspace/mcp', + 'qwen/status/workspace/skills', + 'qwen/status/workspace/providers', + ]); + expect(handles[0]?.agent.extMethodCalls.map((c) => c.params)).toEqual([ + { cwd: WS_A }, + { cwd: WS_A }, + { cwd: WS_A }, + ]); + + await bridge.shutdown(); + }); + + it('requests session status through the existing ACP channel', async () => { + const handles: ChannelHandle[] = []; + const bridge = makeBridge({ + channelFactory: async () => { + const h = makeChannel({ + extMethodImpl: (method, params) => { + if (method === 'qwen/status/session/context') { + return { + v: 1, + sessionId: params['sessionId'], + workspaceCwd: WS_A, + state: {}, + }; + } + return { + v: 1, + sessionId: params['sessionId'], + availableCommands: [], + availableSkills: [], + }; + }, + }); + handles.push(h); + return h.channel; + }, + }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + + await expect( + bridge.getSessionContextStatus(session.sessionId), + ).resolves.toMatchObject({ + sessionId: session.sessionId, + state: {}, + }); + await expect( + bridge.getSessionSupportedCommandsStatus(session.sessionId), + ).resolves.toMatchObject({ + sessionId: session.sessionId, + availableCommands: [], + availableSkills: [], + }); + expect(handles[0]?.agent.extMethodCalls.map((c) => c.method)).toEqual([ + 'qwen/status/session/context', + 'qwen/status/session/supported_commands', + ]); + + await bridge.shutdown(); + }); + + it('rejects session status requests for unknown sessions', async () => { + const bridge = makeBridge({ + channelFactory: async () => makeChannel().channel, + }); + + await expect( + bridge.getSessionContextStatus('missing'), + ).rejects.toBeInstanceOf(SessionNotFoundError); + await expect( + bridge.getSessionSupportedCommandsStatus('missing'), + ).rejects.toBeInstanceOf(SessionNotFoundError); + }); + it('reuses an echoed daemon-issued client id on attach', async () => { const handles: ChannelHandle[] = []; const factory: ChannelFactory = async () => { diff --git a/packages/cli/src/serve/httpAcpBridge.ts b/packages/cli/src/serve/httpAcpBridge.ts index 66dfbf674cc..4b255cd6e7c 100644 --- a/packages/cli/src/serve/httpAcpBridge.ts +++ b/packages/cli/src/serve/httpAcpBridge.ts @@ -21,6 +21,17 @@ import { type BridgeEvent, type SubscribeOptions, } from './eventBus.js'; +import { + SERVE_STATUS_EXT_METHODS, + createIdleWorkspaceMcpStatus, + createIdleWorkspaceProvidersStatus, + createIdleWorkspaceSkillsStatus, + type ServeSessionContextStatus, + type ServeSessionSupportedCommandsStatus, + type ServeWorkspaceMcpStatus, + type ServeWorkspaceProvidersStatus, + type ServeWorkspaceSkillsStatus, +} from './status.js'; import type { CancelNotification, Client, @@ -324,6 +335,34 @@ export interface HttpAcpBridge { */ getHeartbeatState(sessionId: string): BridgeHeartbeatState | undefined; + /** + * Read daemon-runtime MCP status for the bound workspace. Does not spawn an + * ACP child when the daemon is idle; idle daemons return initialized:false. + */ + getWorkspaceMcpStatus(): Promise; + + /** + * Read daemon-runtime skill status for the bound workspace. Does not spawn an + * ACP child when the daemon is idle; idle daemons return initialized:false. + */ + getWorkspaceSkillsStatus(): Promise; + + /** + * Read daemon-runtime model-provider status for the bound workspace. Does + * not spawn an ACP child when the daemon is idle. + */ + getWorkspaceProvidersStatus(): Promise; + + /** Read the current ACP context/config state for a live session. */ + getSessionContextStatus( + sessionId: string, + ): Promise; + + /** Read slash-command/skill command availability for a live session. */ + getSessionSupportedCommandsStatus( + sessionId: string, + ): Promise; + /** * Switch the active model service for a session. Forwards through ACP's * (currently unstable) `unstable_setSessionModel` and broadcasts a @@ -724,6 +763,12 @@ interface ChannelInfo { * restore fails while another is still healthy. */ pendingRestoreIds: Set; + /** + * Cached channel-close race for workspace-scoped status requests. Workspace + * status can be polled frequently by dashboards, so keep one promise per + * channel instead of attaching a new `.then()` to `channel.exited` per poll. + */ + statusClosedReject?: Promise; /** * MUST be set to `true` synchronously by any teardown path BEFORE * awaiting `channel.kill()`. `ensureChannel` treats a dying channel @@ -2175,6 +2220,66 @@ export function createHttpAcpBridge(opts: BridgeOptions): HttpAcpBridge { return workspaceKey; }; + const liveChannelInfo = (): ChannelInfo | undefined => { + if (!channelInfo || channelInfo.isDying) return undefined; + return channelInfo; + }; + + const channelInfoForEntry = ( + entry: SessionEntry, + ): ChannelInfo | undefined => { + if (channelInfo?.channel === entry.channel) return channelInfo; + for (const info of aliveChannels) { + if (info.channel === entry.channel) return info; + } + return undefined; + }; + + const getChannelClosedReject = (info: ChannelInfo): Promise => { + if (!info.statusClosedReject) { + info.statusClosedReject = info.channel.exited.then(() => { + throw new Error('agent channel closed mid-request (workspace status)'); + }); + } + return info.statusClosedReject; + }; + + const requestWorkspaceStatus = async ( + method: string, + idle: () => T, + ): Promise => { + const info = liveChannelInfo(); + if (!info) return idle(); + const response = await withTimeout( + Promise.race([ + info.connection.extMethod(method, { cwd: boundWorkspace }), + getChannelClosedReject(info), + ]), + initTimeoutMs, + method, + ); + return response as unknown as T; + }; + + const requestSessionStatus = async ( + sessionId: string, + method: string, + ): Promise => { + const entry = byId.get(sessionId); + if (!entry) throw new SessionNotFoundError(sessionId); + const info = channelInfoForEntry(entry); + if (!info || info.isDying) throw new SessionNotFoundError(sessionId); + const response = await Promise.race([ + withTimeout( + entry.connection.extMethod(method, { sessionId }), + initTimeoutMs, + method, + ), + getTransportClosedReject(entry), + ]); + return response as unknown as T; + }; + const createSessionEntry = ( ci: ChannelInfo, sessionId: string, @@ -3062,6 +3167,40 @@ export function createHttpAcpBridge(opts: BridgeOptions): HttpAcpBridge { }; }, + async getWorkspaceMcpStatus() { + return requestWorkspaceStatus(SERVE_STATUS_EXT_METHODS.workspaceMcp, () => + createIdleWorkspaceMcpStatus(boundWorkspace), + ); + }, + + async getWorkspaceSkillsStatus() { + return requestWorkspaceStatus( + SERVE_STATUS_EXT_METHODS.workspaceSkills, + () => createIdleWorkspaceSkillsStatus(boundWorkspace), + ); + }, + + async getWorkspaceProvidersStatus() { + return requestWorkspaceStatus( + SERVE_STATUS_EXT_METHODS.workspaceProviders, + () => createIdleWorkspaceProvidersStatus(boundWorkspace), + ); + }, + + async getSessionContextStatus(sessionId) { + return requestSessionStatus( + sessionId, + SERVE_STATUS_EXT_METHODS.sessionContext, + ); + }, + + async getSessionSupportedCommandsStatus(sessionId) { + return requestSessionStatus( + sessionId, + SERVE_STATUS_EXT_METHODS.sessionSupportedCommands, + ); + }, + async setSessionModel(sessionId, req, context) { const entry = byId.get(sessionId); if (!entry) throw new SessionNotFoundError(sessionId); diff --git a/packages/cli/src/serve/index.ts b/packages/cli/src/serve/index.ts index 162b529aca9..96aefa98446 100644 --- a/packages/cli/src/serve/index.ts +++ b/packages/cli/src/serve/index.ts @@ -34,6 +34,29 @@ export { type ServeProtocolVersion, type ServeProtocolVersions, } from './capabilities.js'; +export { + SERVE_STATUS_EXT_METHODS, + STATUS_SCHEMA_VERSION, + createIdleWorkspaceMcpStatus, + createIdleWorkspaceProvidersStatus, + createIdleWorkspaceSkillsStatus, + type ServeMcpDiscoveryState, + type ServeMcpServerRuntimeStatus, + type ServeMcpTransport, + type ServeSessionContextStatus, + type ServeSessionSupportedCommandsStatus, + type ServeSkillLevel, + type ServeStatus, + type ServeStatusCell, + type ServeWorkspaceMcpServerStatus, + type ServeWorkspaceMcpStatus, + type ServeWorkspaceProviderCurrent, + type ServeWorkspaceProviderModel, + type ServeWorkspaceProviderStatus, + type ServeWorkspaceProvidersStatus, + type ServeWorkspaceSkillStatus, + type ServeWorkspaceSkillsStatus, +} from './status.js'; export { bearerAuth, createMutationGate, diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index 6e1e66a8fef..1dad374d59c 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -49,6 +49,13 @@ import { type SessionMetadataUpdate, } from './httpAcpBridge.js'; import type { BridgeEvent, SubscribeOptions } from './eventBus.js'; +import type { + ServeSessionContextStatus, + ServeSessionSupportedCommandsStatus, + ServeWorkspaceMcpStatus, + ServeWorkspaceProvidersStatus, + ServeWorkspaceSkillsStatus, +} from './status.js'; import { CAPABILITIES_SCHEMA_VERSION, type ServeOptions } from './types.js'; const baseOpts: ServeOptions = { @@ -84,6 +91,11 @@ const EXPECTED_STAGE1_FEATURES = [ 'client_heartbeat', 'session_permission_vote', 'permission_vote', + 'workspace_mcp', + 'workspace_skills', + 'workspace_providers', + 'session_context', + 'session_supported_commands', 'session_close', 'session_metadata', ] as const; @@ -134,6 +146,15 @@ interface FakeBridgeOpts { context?: BridgeClientRequestContext, ) => boolean; listImpl?: (workspaceCwd: string) => BridgeSessionSummary[]; + workspaceMcpImpl?: () => Promise; + workspaceSkillsImpl?: () => Promise; + workspaceProvidersImpl?: () => Promise; + sessionContextImpl?: ( + sessionId: string, + ) => Promise; + sessionSupportedCommandsImpl?: ( + sessionId: string, + ) => Promise; setModelImpl?: ( sessionId: string, req: SetSessionModelRequest, @@ -187,6 +208,11 @@ interface FakeBridge extends HttpAcpBridge { context?: BridgeClientRequestContext; }>; listCalls: string[]; + workspaceMcpCalls: number; + workspaceSkillsCalls: number; + workspaceProvidersCalls: number; + sessionContextCalls: string[]; + sessionSupportedCommandsCalls: string[]; setModelCalls: Array<{ sessionId: string; req: SetSessionModelRequest; @@ -223,6 +249,11 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { const permissionVotes: FakeBridge['permissionVotes'] = []; const sessionPermissionVotes: FakeBridge['sessionPermissionVotes'] = []; const listCalls: string[] = []; + let workspaceMcpCalls = 0; + let workspaceSkillsCalls = 0; + let workspaceProvidersCalls = 0; + const sessionContextCalls: string[] = []; + const sessionSupportedCommandsCalls: string[] = []; const setModelCalls: FakeBridge['setModelCalls'] = []; const closeCalls: FakeBridge['closeCalls'] = []; const updateMetadataCalls: FakeBridge['updateMetadataCalls'] = []; @@ -261,6 +292,47 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { const respondImpl = opts.respondImpl ?? (() => true); const sessionRespondImpl = opts.sessionRespondImpl ?? (() => true); const listImpl = opts.listImpl ?? (() => []); + const workspaceMcpImpl = + opts.workspaceMcpImpl ?? + (async () => ({ + v: 1 as const, + workspaceCwd: WS_BOUND, + initialized: false, + discoveryState: 'not_started' as const, + servers: [], + })); + const workspaceSkillsImpl = + opts.workspaceSkillsImpl ?? + (async () => ({ + v: 1 as const, + workspaceCwd: WS_BOUND, + initialized: false, + skills: [], + })); + const workspaceProvidersImpl = + opts.workspaceProvidersImpl ?? + (async () => ({ + v: 1 as const, + workspaceCwd: WS_BOUND, + initialized: false, + providers: [], + })); + const sessionContextImpl = + opts.sessionContextImpl ?? + (async (sessionId) => ({ + v: 1 as const, + sessionId, + workspaceCwd: WS_BOUND, + state: {}, + })); + const sessionSupportedCommandsImpl = + opts.sessionSupportedCommandsImpl ?? + (async (sessionId) => ({ + v: 1 as const, + sessionId, + availableCommands: [], + availableSkills: [], + })); const setModelImpl = opts.setModelImpl ?? (async () => ({})); const closeImpl = opts.closeImpl ?? (async () => {}); const updateMetadataImpl = @@ -294,6 +366,8 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { permissionVotes, sessionPermissionVotes, listCalls, + sessionContextCalls, + sessionSupportedCommandsCalls, setModelCalls, closeCalls, updateMetadataCalls, @@ -302,6 +376,15 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { get shutdownCalls() { return shutdownCalls; }, + get workspaceMcpCalls() { + return workspaceMcpCalls; + }, + get workspaceSkillsCalls() { + return workspaceSkillsCalls; + }, + get workspaceProvidersCalls() { + return workspaceProvidersCalls; + }, get sessionCount() { return calls.length; }, @@ -371,6 +454,26 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { listCalls.push(workspaceCwd); return listImpl(workspaceCwd); }, + async getWorkspaceMcpStatus() { + workspaceMcpCalls += 1; + return workspaceMcpImpl(); + }, + async getWorkspaceSkillsStatus() { + workspaceSkillsCalls += 1; + return workspaceSkillsImpl(); + }, + async getWorkspaceProvidersStatus() { + workspaceProvidersCalls += 1; + return workspaceProvidersImpl(); + }, + async getSessionContextStatus(sessionId) { + sessionContextCalls.push(sessionId); + return sessionContextImpl(sessionId); + }, + async getSessionSupportedCommandsStatus(sessionId) { + sessionSupportedCommandsCalls.push(sessionId); + return sessionSupportedCommandsImpl(sessionId); + }, async setSessionModel(sessionId, req, context) { setModelCalls.push({ sessionId, req, ...(context ? { context } : {}) }); return setModelImpl(sessionId, req, context); @@ -592,6 +695,180 @@ describe('createServeApp', () => { }); }); + describe('read-only status routes', () => { + it('returns workspace MCP status from the bridge', async () => { + const payload: ServeWorkspaceMcpStatus = { + v: 1, + workspaceCwd: WS_BOUND, + initialized: true, + discoveryState: 'completed', + servers: [ + { + kind: 'mcp_server', + status: 'ok', + name: 'docs', + mcpStatus: 'connected', + transport: 'stdio', + disabled: false, + description: 'Docs server', + }, + ], + }; + const bridge = fakeBridge({ workspaceMcpImpl: async () => payload }); + const app = createServeApp( + { ...baseOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + const res = await request(app) + .get('/workspace/mcp') + .set('Host', `127.0.0.1:${baseOpts.port}`); + + expect(res.status).toBe(200); + expect(res.body).toEqual(payload); + expect(bridge.workspaceMcpCalls).toBe(1); + }); + + it('returns workspace skills and providers status from the bridge', async () => { + const skills: ServeWorkspaceSkillsStatus = { + v: 1, + workspaceCwd: WS_BOUND, + initialized: true, + skills: [ + { + kind: 'skill', + status: 'ok', + name: 'review', + description: 'Review code', + level: 'project', + modelInvocable: true, + }, + ], + }; + const providers: ServeWorkspaceProvidersStatus = { + v: 1, + workspaceCwd: WS_BOUND, + initialized: true, + current: { authType: 'qwen', modelId: 'qwen3(qwen)' }, + providers: [ + { + kind: 'model_provider', + status: 'ok', + authType: 'qwen', + current: true, + models: [ + { + modelId: 'qwen3(qwen)', + baseModelId: 'qwen3', + name: 'Qwen 3', + description: null, + contextLimit: 4096, + isCurrent: true, + isRuntime: false, + }, + ], + }, + ], + }; + const bridge = fakeBridge({ + workspaceSkillsImpl: async () => skills, + workspaceProvidersImpl: async () => providers, + }); + const app = createServeApp( + { ...baseOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + + const skillsRes = await request(app) + .get('/workspace/skills') + .set('Host', `127.0.0.1:${baseOpts.port}`); + const providersRes = await request(app) + .get('/workspace/providers') + .set('Host', `127.0.0.1:${baseOpts.port}`); + + expect(skillsRes.status).toBe(200); + expect(skillsRes.body).toEqual(skills); + expect(providersRes.status).toBe(200); + expect(providersRes.body).toEqual(providers); + expect(bridge.workspaceSkillsCalls).toBe(1); + expect(bridge.workspaceProvidersCalls).toBe(1); + }); + + it('returns session context and supported commands from the bridge', async () => { + const context: ServeSessionContextStatus = { + v: 1, + sessionId: 's-1', + workspaceCwd: WS_BOUND, + state: { models: { currentModelId: 'qwen3' } }, + }; + const commands: ServeSessionSupportedCommandsStatus = { + v: 1, + sessionId: 's-1', + availableCommands: [ + { + name: 'init', + description: 'Initialize', + input: null, + _meta: { source: 'builtin' }, + }, + ], + availableSkills: ['review'], + }; + const bridge = fakeBridge({ + sessionContextImpl: async () => context, + sessionSupportedCommandsImpl: async () => commands, + }); + const app = createServeApp( + { ...baseOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + + const contextRes = await request(app) + .get('/session/s-1/context') + .set('Host', `127.0.0.1:${baseOpts.port}`); + const commandsRes = await request(app) + .get('/session/s-1/supported-commands') + .set('Host', `127.0.0.1:${baseOpts.port}`); + + expect(contextRes.status).toBe(200); + expect(contextRes.body).toEqual(context); + expect(commandsRes.status).toBe(200); + expect(commandsRes.body).toEqual(commands); + expect(bridge.sessionContextCalls).toEqual(['s-1']); + expect(bridge.sessionSupportedCommandsCalls).toEqual(['s-1']); + }); + + it('maps missing sessions on read-only session routes to 404', async () => { + const bridge = fakeBridge({ + sessionContextImpl: async (sessionId) => { + throw new SessionNotFoundError(sessionId); + }, + sessionSupportedCommandsImpl: async (sessionId) => { + throw new SessionNotFoundError(sessionId); + }, + }); + const app = createServeApp( + { ...baseOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + + const contextRes = await request(app) + .get('/session/missing/context') + .set('Host', `127.0.0.1:${baseOpts.port}`); + const commandsRes = await request(app) + .get('/session/missing/supported-commands') + .set('Host', `127.0.0.1:${baseOpts.port}`); + + expect(contextRes.status).toBe(404); + expect(contextRes.body.sessionId).toBe('missing'); + expect(commandsRes.status).toBe(404); + expect(commandsRes.body.sessionId).toBe('missing'); + }); + }); + describe('host allowlist (loopback bind)', () => { it('rejects requests with an unrelated Host header', async () => { const app = createServeApp(baseOpts); diff --git a/packages/cli/src/serve/server.ts b/packages/cli/src/serve/server.ts index 8369beee6b6..1b7d045b91f 100644 --- a/packages/cli/src/serve/server.ts +++ b/packages/cli/src/serve/server.ts @@ -70,10 +70,15 @@ export interface ServeAppDeps { * Stage 1 routes shipped (matches §04 of issue #3803): * - `GET /health` * - `GET /capabilities` + * - `GET /workspace/mcp` + * - `GET /workspace/skills` + * - `GET /workspace/providers` * - `POST /session` * - `POST /session/:id/load` * - `POST /session/:id/resume` * - `GET /workspace/:id/sessions` + * - `GET /session/:id/context` + * - `GET /session/:id/supported-commands` * - `POST /session/:id/prompt` * - `POST /session/:id/cancel` * - `POST /session/:id/heartbeat` @@ -259,6 +264,30 @@ export function createServeApp( res.status(200).json(envelope); }); + app.get('/workspace/mcp', async (_req, res) => { + try { + res.status(200).json(await bridge.getWorkspaceMcpStatus()); + } catch (err) { + sendBridgeError(res, err, { route: 'GET /workspace/mcp' }); + } + }); + + app.get('/workspace/skills', async (_req, res) => { + try { + res.status(200).json(await bridge.getWorkspaceSkillsStatus()); + } catch (err) { + sendBridgeError(res, err, { route: 'GET /workspace/skills' }); + } + }); + + app.get('/workspace/providers', async (_req, res) => { + try { + res.status(200).json(await bridge.getWorkspaceProvidersStatus()); + } catch (err) { + sendBridgeError(res, err, { route: 'GET /workspace/providers' }); + } + }); + app.post('/session', mutate(), async (req, res) => { const body = safeBody(req); // #3803 §02: 1 daemon = 1 workspace. Three input shapes: @@ -467,6 +496,44 @@ export function createServeApp( app.post('/session/:id/load', mutate(), restoreSessionHandler('load')); app.post('/session/:id/resume', mutate(), restoreSessionHandler('resume')); + app.get('/session/:id/context', async (req, res) => { + const sessionId = req.params['id']; + if (!sessionId) { + res + .status(400) + .json({ error: '`sessionId` route parameter is required' }); + return; + } + try { + res.status(200).json(await bridge.getSessionContextStatus(sessionId)); + } catch (err) { + sendBridgeError(res, err, { + route: 'GET /session/:id/context', + sessionId, + }); + } + }); + + app.get('/session/:id/supported-commands', async (req, res) => { + const sessionId = req.params['id']; + if (!sessionId) { + res + .status(400) + .json({ error: '`sessionId` route parameter is required' }); + return; + } + try { + res + .status(200) + .json(await bridge.getSessionSupportedCommandsStatus(sessionId)); + } catch (err) { + sendBridgeError(res, err, { + route: 'GET /session/:id/supported-commands', + sessionId, + }); + } + }); + app.post('/session/:id/prompt', mutate(), async (req, res) => { const sessionId = req.params['id']; const body = safeBody(req); diff --git a/packages/cli/src/serve/status.ts b/packages/cli/src/serve/status.ts new file mode 100644 index 00000000000..a3eccaff8e4 --- /dev/null +++ b/packages/cli/src/serve/status.ts @@ -0,0 +1,175 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { AvailableCommand } from '@agentclientprotocol/sdk'; + +export const STATUS_SCHEMA_VERSION = 1 as const; + +export const SERVE_STATUS_EXT_METHODS = { + workspaceMcp: 'qwen/status/workspace/mcp', + workspaceSkills: 'qwen/status/workspace/skills', + workspaceProviders: 'qwen/status/workspace/providers', + sessionContext: 'qwen/status/session/context', + sessionSupportedCommands: 'qwen/status/session/supported_commands', +} as const; + +export type ServeStatus = + | 'ok' + | 'warning' + | 'error' + | 'disabled' + | 'not_started' + | 'unknown'; + +export interface ServeStatusCell { + kind: string; + status: ServeStatus; + error?: string; + errorKind?: string; + hint?: string; +} + +export type ServeMcpDiscoveryState = + | 'not_started' + | 'in_progress' + | 'completed'; + +export type ServeMcpServerRuntimeStatus = + | 'connected' + | 'connecting' + | 'disconnected'; + +export type ServeMcpTransport = + | 'stdio' + | 'sse' + | 'http' + | 'websocket' + | 'sdk' + | 'unknown'; + +export interface ServeWorkspaceMcpServerStatus extends ServeStatusCell { + kind: 'mcp_server'; + name: string; + mcpStatus?: ServeMcpServerRuntimeStatus; + transport: ServeMcpTransport; + disabled: boolean; + description?: string; + extensionName?: string; +} + +export interface ServeWorkspaceMcpStatus { + v: typeof STATUS_SCHEMA_VERSION; + workspaceCwd: string; + initialized: boolean; + discoveryState?: ServeMcpDiscoveryState; + servers: ServeWorkspaceMcpServerStatus[]; + errors?: ServeStatusCell[]; +} + +export type ServeSkillLevel = 'project' | 'user' | 'extension' | 'bundled'; + +export interface ServeWorkspaceSkillStatus extends ServeStatusCell { + kind: 'skill'; + name: string; + description: string; + level: ServeSkillLevel; + modelInvocable: boolean; + argumentHint?: string; + model?: string; + extensionName?: string; +} + +export interface ServeWorkspaceSkillsStatus { + v: typeof STATUS_SCHEMA_VERSION; + workspaceCwd: string; + initialized: boolean; + skills: ServeWorkspaceSkillStatus[]; + errors?: ServeStatusCell[]; +} + +export interface ServeWorkspaceProviderCurrent { + authType?: string; + modelId?: string; +} + +export interface ServeWorkspaceProviderModel { + modelId: string; + baseModelId: string; + name: string; + description?: string | null; + contextLimit?: number; + isCurrent: boolean; + isRuntime: boolean; +} + +export interface ServeWorkspaceProviderStatus extends ServeStatusCell { + kind: 'model_provider'; + authType: string; + current: boolean; + models: ServeWorkspaceProviderModel[]; +} + +export interface ServeWorkspaceProvidersStatus { + v: typeof STATUS_SCHEMA_VERSION; + workspaceCwd: string; + initialized: boolean; + current?: ServeWorkspaceProviderCurrent; + providers: ServeWorkspaceProviderStatus[]; + errors?: ServeStatusCell[]; +} + +export interface ServeSessionContextStatus { + v: typeof STATUS_SCHEMA_VERSION; + sessionId: string; + workspaceCwd: string; + state: { + models?: unknown; + modes?: unknown; + configOptions?: unknown[] | null; + [key: string]: unknown; + }; +} + +export interface ServeSessionSupportedCommandsStatus { + v: typeof STATUS_SCHEMA_VERSION; + sessionId: string; + availableCommands: AvailableCommand[]; + availableSkills: string[]; +} + +export function createIdleWorkspaceMcpStatus( + workspaceCwd: string, +): ServeWorkspaceMcpStatus { + return { + v: STATUS_SCHEMA_VERSION, + workspaceCwd, + initialized: false, + discoveryState: 'not_started', + servers: [], + }; +} + +export function createIdleWorkspaceSkillsStatus( + workspaceCwd: string, +): ServeWorkspaceSkillsStatus { + return { + v: STATUS_SCHEMA_VERSION, + workspaceCwd, + initialized: false, + skills: [], + }; +} + +export function createIdleWorkspaceProvidersStatus( + workspaceCwd: string, +): ServeWorkspaceProvidersStatus { + return { + v: STATUS_SCHEMA_VERSION, + workspaceCwd, + initialized: false, + providers: [], + }; +} diff --git a/packages/sdk-typescript/src/daemon/DaemonClient.ts b/packages/sdk-typescript/src/daemon/DaemonClient.ts index 2917fb76429..5ae8a27890d 100644 --- a/packages/sdk-typescript/src/daemon/DaemonClient.ts +++ b/packages/sdk-typescript/src/daemon/DaemonClient.ts @@ -8,9 +8,14 @@ import { parseSseStream } from './sse.js'; import type { DaemonCapabilities, DaemonEvent, + DaemonSessionContextStatus, DaemonRestoredSession, DaemonSession, DaemonSessionSummary, + DaemonSessionSupportedCommandsStatus, + DaemonWorkspaceMcpStatus, + DaemonWorkspaceProvidersStatus, + DaemonWorkspaceSkillsStatus, HeartbeatResult, PermissionResponse, PromptContentBlock, @@ -46,8 +51,8 @@ export interface DaemonClientOptions { /** * Per-call request timeout in milliseconds. Applied to short-lived * methods (`health`, `capabilities`, `createOrAttachSession`, - * `listWorkspaceSessions`, `setSessionModel`, `cancel`, - * `respondToPermission`) so an unresponsive daemon doesn't block + * `listWorkspaceSessions`, read-only status routes, `setSessionModel`, + * `cancel`, `respondToPermission`) so an unresponsive daemon doesn't block * callers indefinitely. **NOT** applied to `prompt()` — model + tool * turns can take minutes, so prompt explicitly bypasses * `fetchTimeoutMs`; cancellation is via the optional `signal` arg. @@ -298,6 +303,43 @@ export class DaemonClient { ); } + async workspaceMcp(): Promise { + return await this.fetchWithTimeout( + `${this.baseUrl}/workspace/mcp`, + { headers: this.headers() }, + async (res) => { + if (!res.ok) throw await this.failOnError(res, 'GET /workspace/mcp'); + return (await res.json()) as DaemonWorkspaceMcpStatus; + }, + ); + } + + async workspaceSkills(): Promise { + return await this.fetchWithTimeout( + `${this.baseUrl}/workspace/skills`, + { headers: this.headers() }, + async (res) => { + if (!res.ok) { + throw await this.failOnError(res, 'GET /workspace/skills'); + } + return (await res.json()) as DaemonWorkspaceSkillsStatus; + }, + ); + } + + async workspaceProviders(): Promise { + return await this.fetchWithTimeout( + `${this.baseUrl}/workspace/providers`, + { headers: this.headers() }, + async (res) => { + if (!res.ok) { + throw await this.failOnError(res, 'GET /workspace/providers'); + } + return (await res.json()) as DaemonWorkspaceProvidersStatus; + }, + ); + } + // -- Sessions ---------------------------------------------------------- async createOrAttachSession( @@ -380,6 +422,41 @@ export class DaemonClient { return this.restoreSession('resume', sessionId, req, clientId); } + async sessionContext( + sessionId: string, + clientId?: string, + ): Promise { + return await this.fetchWithTimeout( + `${this.baseUrl}/session/${encodeURIComponent(sessionId)}/context`, + { headers: this.headers({}, clientId) }, + async (res) => { + if (!res.ok) { + throw await this.failOnError(res, 'GET /session/:id/context'); + } + return (await res.json()) as DaemonSessionContextStatus; + }, + ); + } + + async sessionSupportedCommands( + sessionId: string, + clientId?: string, + ): Promise { + return await this.fetchWithTimeout( + `${this.baseUrl}/session/${encodeURIComponent(sessionId)}/supported-commands`, + { headers: this.headers({}, clientId) }, + async (res) => { + if (!res.ok) { + throw await this.failOnError( + res, + 'GET /session/:id/supported-commands', + ); + } + return (await res.json()) as DaemonSessionSupportedCommandsStatus; + }, + ); + } + /** * Shared transport for `loadSession` / `resumeSession`. Both routes * share an identical wire shape (POST /session/:id/{load|resume} diff --git a/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts b/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts index 6e88630abdc..07303a6877c 100644 --- a/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts +++ b/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts @@ -13,8 +13,10 @@ import { } from './DaemonClient.js'; import type { DaemonEvent, + DaemonSessionContextStatus, DaemonSessionState, DaemonSession, + DaemonSessionSupportedCommandsStatus, HeartbeatResult, PermissionResponse, PromptResult, @@ -196,6 +198,17 @@ export class DaemonSessionClient { ); } + async context(): Promise { + return await this.client.sessionContext(this.sessionId, this.clientId); + } + + async supportedCommands(): Promise { + return await this.client.sessionSupportedCommands( + this.sessionId, + this.clientId, + ); + } + async respondToPermission( requestId: string, response: PermissionResponse, diff --git a/packages/sdk-typescript/src/daemon/index.ts b/packages/sdk-typescript/src/daemon/index.ts index e8fb4699f32..841f3236928 100644 --- a/packages/sdk-typescript/src/daemon/index.ts +++ b/packages/sdk-typescript/src/daemon/index.ts @@ -64,14 +64,31 @@ export type { KnownDaemonEvent, } from './events.js'; export type { + DaemonAvailableCommand, DaemonCapabilities, DaemonEvent, + DaemonMcpDiscoveryState, + DaemonMcpServerRuntimeStatus, + DaemonMcpTransport, DaemonMode, DaemonProtocolVersions, DaemonRestoredSession, DaemonSession, + DaemonSessionContextStatus, DaemonSessionState, DaemonSessionSummary, + DaemonSessionSupportedCommandsStatus, + DaemonSkillLevel, + DaemonStatus, + DaemonStatusCell, + DaemonWorkspaceMcpServerStatus, + DaemonWorkspaceMcpStatus, + DaemonWorkspaceProviderCurrent, + DaemonWorkspaceProviderModel, + DaemonWorkspaceProviderStatus, + DaemonWorkspaceProvidersStatus, + DaemonWorkspaceSkillStatus, + DaemonWorkspaceSkillsStatus, HeartbeatResult, PermissionOutcome, PermissionOutcomeCancelled, diff --git a/packages/sdk-typescript/src/daemon/types.ts b/packages/sdk-typescript/src/daemon/types.ts index ffcd0208f23..f57bc10399f 100644 --- a/packages/sdk-typescript/src/daemon/types.ts +++ b/packages/sdk-typescript/src/daemon/types.ts @@ -169,6 +169,132 @@ export interface SessionMetadataResult { displayName?: string; } +export type DaemonStatus = + | 'ok' + | 'warning' + | 'error' + | 'disabled' + | 'not_started' + | 'unknown'; + +export interface DaemonStatusCell { + kind: string; + status: DaemonStatus; + error?: string; + errorKind?: string; + hint?: string; +} + +export type DaemonMcpDiscoveryState = + | 'not_started' + | 'in_progress' + | 'completed'; + +export type DaemonMcpServerRuntimeStatus = + | 'connected' + | 'connecting' + | 'disconnected'; + +export type DaemonMcpTransport = + | 'stdio' + | 'sse' + | 'http' + | 'websocket' + | 'sdk' + | 'unknown'; + +export interface DaemonWorkspaceMcpServerStatus extends DaemonStatusCell { + kind: 'mcp_server'; + name: string; + mcpStatus?: DaemonMcpServerRuntimeStatus; + transport: DaemonMcpTransport; + disabled: boolean; + description?: string; + extensionName?: string; +} + +export interface DaemonWorkspaceMcpStatus { + v: 1; + workspaceCwd: string; + initialized: boolean; + discoveryState?: DaemonMcpDiscoveryState; + servers: DaemonWorkspaceMcpServerStatus[]; + errors?: DaemonStatusCell[]; +} + +export type DaemonSkillLevel = 'project' | 'user' | 'extension' | 'bundled'; + +export interface DaemonWorkspaceSkillStatus extends DaemonStatusCell { + kind: 'skill'; + name: string; + description: string; + level: DaemonSkillLevel; + modelInvocable: boolean; + argumentHint?: string; + model?: string; + extensionName?: string; +} + +export interface DaemonWorkspaceSkillsStatus { + v: 1; + workspaceCwd: string; + initialized: boolean; + skills: DaemonWorkspaceSkillStatus[]; + errors?: DaemonStatusCell[]; +} + +export interface DaemonWorkspaceProviderCurrent { + authType?: string; + modelId?: string; +} + +export interface DaemonWorkspaceProviderModel { + modelId: string; + baseModelId: string; + name: string; + description?: string | null; + contextLimit?: number; + isCurrent: boolean; + isRuntime: boolean; +} + +export interface DaemonWorkspaceProviderStatus extends DaemonStatusCell { + kind: 'model_provider'; + authType: string; + current: boolean; + models: DaemonWorkspaceProviderModel[]; +} + +export interface DaemonWorkspaceProvidersStatus { + v: 1; + workspaceCwd: string; + initialized: boolean; + current?: DaemonWorkspaceProviderCurrent; + providers: DaemonWorkspaceProviderStatus[]; + errors?: DaemonStatusCell[]; +} + +export interface DaemonSessionContextStatus { + v: 1; + sessionId: string; + workspaceCwd: string; + state: DaemonSessionState; +} + +export interface DaemonAvailableCommand { + name: string; + description?: string; + input: { hint: string } | null; + _meta?: Record | null; +} + +export interface DaemonSessionSupportedCommandsStatus { + v: 1; + sessionId: string; + availableCommands: DaemonAvailableCommand[]; + availableSkills: string[]; +} + /** Returned from `POST /session/:id/model`. ACP currently allows an opaque body. */ export interface SetModelResult { [key: string]: unknown; diff --git a/packages/sdk-typescript/src/index.ts b/packages/sdk-typescript/src/index.ts index f4440f140d5..d94169e12e4 100644 --- a/packages/sdk-typescript/src/index.ts +++ b/packages/sdk-typescript/src/index.ts @@ -19,6 +19,7 @@ export { requireWorkspaceCwd, SseFramingError, type CreateSessionRequest, + type DaemonAvailableCommand, type DaemonCapabilities, type DaemonClientEvictedData, type DaemonClientEvictedEvent, @@ -27,6 +28,9 @@ export { type DaemonEvent, type DaemonEventEnvelope, type DaemonKnownEventType, + type DaemonMcpDiscoveryState, + type DaemonMcpServerRuntimeStatus, + type DaemonMcpTransport, type DaemonMode, type DaemonModelSwitchedData, type DaemonModelSwitchedEvent, @@ -44,12 +48,17 @@ export { type DaemonSession, type DaemonSessionClosedReason, type DaemonSessionClientOptions, + type DaemonSessionContextStatus, type DaemonSessionDiedData, type DaemonSessionDiedEvent, type DaemonSessionEvent, type DaemonSessionSubscribeOptions, type DaemonSessionState, type DaemonSessionSummary, + type DaemonSessionSupportedCommandsStatus, + type DaemonSkillLevel, + type DaemonStatus, + type DaemonStatusCell, type DaemonSessionUpdateData, type DaemonSessionUpdateEvent, type DaemonSessionViewState, @@ -58,6 +67,14 @@ export { type DaemonStreamErrorData, type DaemonStreamErrorEvent, type DaemonStreamLifecycleEvent, + type DaemonWorkspaceMcpServerStatus, + type DaemonWorkspaceMcpStatus, + type DaemonWorkspaceProviderCurrent, + type DaemonWorkspaceProviderModel, + type DaemonWorkspaceProviderStatus, + type DaemonWorkspaceProvidersStatus, + type DaemonWorkspaceSkillStatus, + type DaemonWorkspaceSkillsStatus, type HeartbeatResult, type KnownDaemonEvent, type PermissionOutcome, diff --git a/packages/sdk-typescript/test/unit/DaemonClient.test.ts b/packages/sdk-typescript/test/unit/DaemonClient.test.ts index 5f821a68c5e..e2da27df759 100644 --- a/packages/sdk-typescript/test/unit/DaemonClient.test.ts +++ b/packages/sdk-typescript/test/unit/DaemonClient.test.ts @@ -15,7 +15,14 @@ import { DaemonCapabilityMissingError, requireWorkspaceCwd, } from '../../src/daemon/types.js'; -import type { DaemonCapabilities } from '../../src/daemon/types.js'; +import type { + DaemonCapabilities, + DaemonSessionContextStatus, + DaemonSessionSupportedCommandsStatus, + DaemonWorkspaceMcpStatus, + DaemonWorkspaceProvidersStatus, + DaemonWorkspaceSkillsStatus, +} from '../../src/daemon/types.js'; function jsonResponse(status: number, body: unknown): Response { return new Response(JSON.stringify(body), { @@ -130,6 +137,133 @@ describe('DaemonClient', () => { }); }); + describe('read-only status routes', () => { + it('GETs workspace status routes and returns payloads unchanged', async () => { + const mcp: DaemonWorkspaceMcpStatus = { + v: 1, + workspaceCwd: '/work/a', + initialized: true, + discoveryState: 'completed', + servers: [ + { + kind: 'mcp_server', + status: 'ok', + name: 'docs', + mcpStatus: 'connected', + transport: 'stdio', + disabled: false, + }, + ], + }; + const skills: DaemonWorkspaceSkillsStatus = { + v: 1, + workspaceCwd: '/work/a', + initialized: true, + skills: [ + { + kind: 'skill', + status: 'ok', + name: 'review', + description: 'Review code', + level: 'project', + modelInvocable: true, + }, + ], + }; + const providers: DaemonWorkspaceProvidersStatus = { + v: 1, + workspaceCwd: '/work/a', + initialized: true, + current: { authType: 'qwen', modelId: 'qwen3(qwen)' }, + providers: [ + { + kind: 'model_provider', + status: 'ok', + authType: 'qwen', + current: true, + models: [ + { + modelId: 'qwen3(qwen)', + baseModelId: 'qwen3', + name: 'Qwen 3', + description: null, + contextLimit: 4096, + isCurrent: true, + isRuntime: false, + }, + ], + }, + ], + }; + const { fetch, calls } = recordingFetch((req) => { + if (req.url.endsWith('/workspace/mcp')) return jsonResponse(200, mcp); + if (req.url.endsWith('/workspace/skills')) { + return jsonResponse(200, skills); + } + if (req.url.endsWith('/workspace/providers')) { + return jsonResponse(200, providers); + } + return jsonResponse(500, { error: `unexpected ${req.url}` }); + }); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + + await expect(client.workspaceMcp()).resolves.toEqual(mcp); + await expect(client.workspaceSkills()).resolves.toEqual(skills); + await expect(client.workspaceProviders()).resolves.toEqual(providers); + expect(calls.map((c) => [c.method, c.url])).toEqual([ + ['GET', 'http://daemon/workspace/mcp'], + ['GET', 'http://daemon/workspace/skills'], + ['GET', 'http://daemon/workspace/providers'], + ]); + }); + + it('GETs session status routes with encoded session ids', async () => { + const context: DaemonSessionContextStatus = { + v: 1, + sessionId: 'with/slash', + workspaceCwd: '/work/a', + state: { models: { currentModelId: 'qwen3' } }, + }; + const supportedCommands: DaemonSessionSupportedCommandsStatus = { + v: 1, + sessionId: 'with/slash', + availableCommands: [ + { + name: 'init', + description: 'Initialize', + input: null, + }, + ], + availableSkills: ['review'], + }; + const { fetch, calls } = recordingFetch((req) => { + if (req.url.endsWith('/session/with%2Fslash/context')) { + return jsonResponse(200, context); + } + if (req.url.endsWith('/session/with%2Fslash/supported-commands')) { + return jsonResponse(200, supportedCommands); + } + return jsonResponse(500, { error: `unexpected ${req.url}` }); + }); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + + await expect( + client.sessionContext('with/slash', 'client-1'), + ).resolves.toEqual(context); + await expect( + client.sessionSupportedCommands('with/slash', 'client-1'), + ).resolves.toEqual(supportedCommands); + expect(calls.map((c) => [c.method, c.url])).toEqual([ + ['GET', 'http://daemon/session/with%2Fslash/context'], + ['GET', 'http://daemon/session/with%2Fslash/supported-commands'], + ]); + expect(calls.map((c) => c.headers['x-qwen-client-id'])).toEqual([ + 'client-1', + 'client-1', + ]); + }); + }); + describe('bearer auth', () => { it('attaches Authorization: Bearer when token is set', async () => { const { fetch, calls } = recordingFetch(() => diff --git a/packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts b/packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts index 2b1be88bba3..1146e3e19a9 100644 --- a/packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts +++ b/packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts @@ -320,6 +320,28 @@ describe('DaemonSessionClient', () => { if (req.url.endsWith('/session/s-1/model')) { return jsonResponse(200, { modelId: 'qwen3-coder' }); } + if (req.url.endsWith('/session/s-1/context')) { + return jsonResponse(200, { + v: 1, + sessionId: 's-1', + workspaceCwd: '/work/a', + state: { models: { currentModelId: 'qwen3-coder' } }, + }); + } + if (req.url.endsWith('/session/s-1/supported-commands')) { + return jsonResponse(200, { + v: 1, + sessionId: 's-1', + availableCommands: [ + { + name: 'init', + description: 'Initialize', + input: null, + }, + ], + availableSkills: ['review'], + }); + } if (req.url.endsWith('/session/s-1/cancel')) { return new Response(null, { status: 204 }); } @@ -361,6 +383,24 @@ describe('DaemonSessionClient', () => { await expect(session.setModel('qwen3-coder')).resolves.toEqual({ modelId: 'qwen3-coder', }); + await expect(session.context()).resolves.toEqual({ + v: 1, + sessionId: 's-1', + workspaceCwd: '/work/a', + state: { models: { currentModelId: 'qwen3-coder' } }, + }); + await expect(session.supportedCommands()).resolves.toEqual({ + v: 1, + sessionId: 's-1', + availableCommands: [ + { + name: 'init', + description: 'Initialize', + input: null, + }, + ], + availableSkills: ['review'], + }); await expect(session.cancel()).resolves.toBeUndefined(); await expect( session.respondToPermission('req-1', { @@ -380,6 +420,8 @@ describe('DaemonSessionClient', () => { expect(calls.map((c) => c.url)).toEqual([ 'http://daemon/session/s-1/prompt', 'http://daemon/session/s-1/model', + 'http://daemon/session/s-1/context', + 'http://daemon/session/s-1/supported-commands', 'http://daemon/session/s-1/cancel', 'http://daemon/permission/req-1', 'http://daemon/session/s-1/permission/req-2', @@ -395,6 +437,8 @@ describe('DaemonSessionClient', () => { 'client-1', 'client-1', 'client-1', + 'client-1', + 'client-1', ]); });