diff --git a/docs/developers/daemon/00-index.md b/docs/developers/daemon/00-index.md index dc8fe01a51..b5e45d3f2c 100644 --- a/docs/developers/daemon/00-index.md +++ b/docs/developers/daemon/00-index.md @@ -85,7 +85,7 @@ Use these anchors when moving from the docs into the latest `main` code: | Surface | Implementation anchors | Primary docs | | ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | -| Bootstrap and HTTP assembly | `packages/cli/src/serve/run-qwen-serve.ts`, `server.ts`, `/demo` | [`02`](./02-serve-runtime.md), [`20`](./20-quickstart-operations.md) | +| Bootstrap and HTTP assembly | `packages/cli/src/serve/run-qwen-serve.ts`, `server.ts`, `/demo` | [`02`](./02-serve-runtime.md), [`20`](./20-quickstart-operations.md) | | ACP bridge and session multiplexing | `packages/acp-bridge/src/bridge.ts`, `packages/acp-bridge/src/bridgeTypes.ts`, `@qwen-code/acp-bridge` | [`03`](./03-acp-bridge.md), [`08`](./08-session-lifecycle.md) | | Permission mediation | `packages/acp-bridge/src/permissionMediator.ts`, `fromLoopback: boolean`, `policy.*` | [`04`](./04-permission-mediation.md), [`12`](./12-auth-security.md) | | MCP transport pool | `packages/core/src/tools/mcp-transport-pool.ts`, `mcp-pool-key.ts`, `pid-descendants.ts`, `session-mcp-view.ts`, `/mcp refresh`, `MCPCallInterruptedError` | [`05`](./05-mcp-transport-pool.md), [`06`](./06-mcp-budget-guardrails.md) | @@ -94,7 +94,7 @@ Use these anchors when moving from the docs into the latest `main` code: | Event schema and SSE writer | `packages/sdk-typescript/src/daemon/events.ts`, `packages/cli/src/serve/server.ts`, `formatSseFrame`, `packages/cli/src/acp-integration/session/emitters/ToolCallEmitter.ts`, `ToolCallEmitter.resolveToolProvenance`, `tool_call.provenance`, `serverId` | [`09`](./09-event-schema.md), [`10`](./10-event-bus.md) | | Event resync | `state_resync_required`, `awaitingResync`, `RESYNC_PASSTHROUGH_TYPES`, `asKnownDaemonEvent`, `unrecognizedKnownEventCount` | [`09`](./09-event-schema.md), [`10`](./10-event-bus.md) | | Capabilities | `packages/cli/src/serve/capabilities.ts`, `mcp_server_restart_refused.reason`, `MCP_RESTART_REFUSED_REASONS.has` | [`11`](./11-capabilities-versioning.md) | -| Auth and device flow | `packages/cli/src/serve/auth.ts`, `packages/cli/src/serve/auth/device-flow.ts` | [`12`](./12-auth-security.md) | +| Auth and device flow | `packages/cli/src/serve/auth.ts`, `packages/cli/src/serve/auth/device-flow.ts` | [`12`](./12-auth-security.md) | | TypeScript SDK daemon client | `packages/sdk-typescript/src/daemon/{DaemonClient,DaemonSessionClient,DaemonAuthFlow,sse,events,types}.ts`, `MCP_RESTART_DEFAULT_TIMEOUT_MS` | [`13`](./13-sdk-daemon-client.md) | | Shared UI transcript layer | `DaemonUiEventType`, `DaemonSessionProvider`, `packages/webui/src/daemon/` | [`13`](./13-sdk-daemon-client.md), [`14`](./14-cli-tui-adapter.md), [`../daemon-ui/README.md`](../daemon-ui/README.md) | | Channels and IDE adapters | `packages/channels/`, `packages/vscode-ide-companion/src/services/daemonIdeConnection.ts` | [`15`](./15-channel-adapters.md), [`16`](./16-vscode-ide-adapter.md) | @@ -125,7 +125,7 @@ Use these anchors when moving from the docs into the latest `main` code: | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | | HTTP routes | The route catalog lives in `qwen-serve-protocol.md`; this daemon set only references it and explains implementation ownership. | [`../qwen-serve-protocol.md`](../qwen-serve-protocol.md), [`20`](./20-quickstart-operations.md) | | Event schema | `EVENT_SCHEMA_VERSION = 1`; 43 known event types; id-less subscriber synthetic frames; `_meta.serverTimestamp` stamped at SSE write boundary. | [`09`](./09-event-schema.md), [`10`](./10-event-bus.md) | -| Capabilities | `SERVE_PROTOCOL_VERSION = 'v1'`; 66 registered tags; 10 conditional tags. | [`11`](./11-capabilities-versioning.md) | +| Capabilities | `SERVE_PROTOCOL_VERSION = 'v1'`; 67 registered tags; 10 conditional tags. | [`11`](./11-capabilities-versioning.md) | | Session shell | `POST /session/:id/shell` exists behind `--enable-session-shell`, bearer auth, and session-bound `X-Qwen-Client-Id`; capability tag is conditional. | [`11`](./11-capabilities-versioning.md), [`17`](./17-configuration.md), [`20`](./20-quickstart-operations.md) | | Rate limiting | Optional per-tier HTTP rate limit is exposed by CLI flags/env and conditional capability tag. | [`11`](./11-capabilities-versioning.md), [`17`](./17-configuration.md) | @@ -149,11 +149,11 @@ Use these anchors when moving from the docs into the latest `main` code: ### Historical or deprecated surfaces -| Surface | Status | -| ------------------------------------------------ | -------------------------------------------------------------------------------------------------------------- | -| `docs/developers/daemon-client-adapters/tui.md` | Historical draft for the old `DaemonTuiAdapter` spike; current shared UI transcript architecture is in doc 14. | +| Surface | Status | +| -------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | +| `docs/developers/daemon-client-adapters/tui.md` | Historical draft for the old `DaemonTuiAdapter` spike; current shared UI transcript architecture is in doc 14. | | `packages/cli/src/ui/daemon/daemon-tui-adapter.ts` | Legacy experimental adapter still in-tree. New shared UI work should prefer SDK `daemon/ui/*`. | -| `--no-http-bridge` | Accepted for compatibility but falls back to http-bridge and prints stderr. | +| `--no-http-bridge` | Accepted for compatibility but falls back to http-bridge and prints stderr. | ### Forward compatibility diff --git a/docs/developers/daemon/08-session-lifecycle.md b/docs/developers/daemon/08-session-lifecycle.md index 245d2fc700..e24b560de3 100644 --- a/docs/developers/daemon/08-session-lifecycle.md +++ b/docs/developers/daemon/08-session-lifecycle.md @@ -224,6 +224,14 @@ latencies, and file edit counts. `GET /session/:id/tasks` returns a background-task snapshot for agent tasks, shell tasks, monitor tasks, and their lifecycle states. +### Session LSP Status (`session_lsp` capability tag) + +`GET /session/:id/lsp` returns sanitized per-session LSP status for daemon +clients: enablement, aggregate server counts, unavailable/initialization state, +and per-server `name`, `status`, `languages`, `transport`, `command`, and +`error`. Disabled or unavailable LSP is represented as HTTP 200 status data, +not as a transport error. + ### Compacted Replay `POST /session/:id/load` now returns a `BridgeRestoredSession` that can include @@ -248,7 +256,7 @@ new session arrives. - `BridgeOptions.sessionScope` (default `'single'`; optional `'thread'`). - `BridgeOptions.initializeTimeoutMs` (default 10s) — ACP `initialize` handshake. - `BridgeOptions.channelIdleTimeoutMs` (default 0; reap the ACP child immediately). -- Capability tags: `session_create`, `session_scope_override`, `session_load`, `session_resume`, `unstable_session_resume` (deprecated alias), `session_list`, `session_close`, `session_metadata`, `session_set_model`, `client_identity`, `client_heartbeat`, `session_recap`, `session_btw`, `session_context_usage`, `session_tasks`, `session_stats`, `non_blocking_prompt`. +- Capability tags: `session_create`, `session_scope_override`, `session_load`, `session_resume`, `unstable_session_resume` (deprecated alias), `session_list`, `session_close`, `session_metadata`, `session_set_model`, `client_identity`, `client_heartbeat`, `session_recap`, `session_btw`, `session_context_usage`, `session_tasks`, `session_stats`, `session_lsp`, `non_blocking_prompt`. ## Caveats & Known Limits diff --git a/docs/developers/daemon/11-capabilities-versioning.md b/docs/developers/daemon/11-capabilities-versioning.md index 3e33da01bc..c0424fe1b1 100644 --- a/docs/developers/daemon/11-capabilities-versioning.md +++ b/docs/developers/daemon/11-capabilities-versioning.md @@ -85,11 +85,11 @@ The `Map` stores membership and predicate together. Adding a new conditional tag Baseline tags are not present in the `Map` and are advertised unconditionally. This is intentionally represented by absence rather than by a separate Set. -### 66 tags (v1, grouped by domain) +### 67 tags (v1, grouped by domain) Foundation: `health`, `capabilities`. -Sessions: `session_create`, `session_scope_override`, `session_load`, `session_resume`, `unstable_session_resume`, `session_list`, `session_prompt`, `session_cancel`, `session_events`, `session_set_model`, `session_close`, `session_metadata`, `session_context`, `session_context_usage`, `session_supported_commands`, `session_tasks`, `session_stats`, `session_approval_mode_control`, `session_recap`, `session_btw`, **`session_shell_command`** (conditional), `session_language`, `session_rewind`, `session_hooks`, `session_branch`. +Sessions: `session_create`, `session_scope_override`, `session_load`, `session_resume`, `unstable_session_resume`, `session_list`, `session_prompt`, `session_cancel`, `session_events`, `session_set_model`, `session_close`, `session_metadata`, `session_context`, `session_context_usage`, `session_supported_commands`, `session_tasks`, `session_stats`, `session_lsp`, `session_approval_mode_control`, `session_recap`, `session_btw`, **`session_shell_command`** (conditional), `session_language`, `session_rewind`, `session_hooks`, `session_branch`. Streaming: `slow_client_warning`, `typed_event_schema`. diff --git a/docs/developers/qwen-serve-protocol.md b/docs/developers/qwen-serve-protocol.md index f7d6cd608e..db28dd45c0 100644 --- a/docs/developers/qwen-serve-protocol.md +++ b/docs/developers/qwen-serve-protocol.md @@ -129,6 +129,7 @@ registry. Clients **must** gate UI off `features`, not off `mode` (per design 'workspace_agents', 'workspace_agent_generate', 'workspace_env', 'workspace_preflight', 'session_context', 'session_context_usage', 'session_supported_commands', 'session_tasks', 'session_stats', + 'session_lsp', 'session_close', 'session_metadata', 'mcp_guardrails', 'workspace_mcp_manage', 'mcp_guardrail_events', 'mcp_server_runtime_mutation', @@ -158,6 +159,8 @@ registry. Clients **must** gate UI off `features`, not off `mode` (per design `session_close` and `session_metadata` advertise `DELETE /session/:id` and `PATCH /session/:id/metadata`. Older daemons return `404`; pre-flight these tags before exposing close or rename affordances. +`session_lsp` advertises `GET /session/:id/lsp`, the read-only structured LSP status snapshot for daemon clients. Older daemons return `404`; pre-flight this tag before exposing remote LSP status. + `session_approval_mode_control`, `workspace_tool_toggle`, `workspace_init`, and `workspace_mcp_restart` (issue [#4175](https://github.com/QwenLM/qwen-code/issues/4175) PR 17) advertise the four mutation control routes documented under "Mutation: approval, tools, init, MCP restart" below. All four are strict-gated by the PR 15 mutation gate (a daemon configured without a bearer token rejects them with 401 `token_required`). Older daemons return `404`; pre-flight each tag before exposing the corresponding affordance. `mcp_guardrails` (issue [#4175](https://github.com/QwenLM/qwen-code/issues/4175) PR 14) covers the MCP budget surface: the `clientCount` / `clientBudget` / `budgetMode` / `budgets[]` fields on `GET /workspace/mcp`, the `disabledReason` field on per-server cells, and the `--mcp-client-budget` / `--mcp-budget-mode` CLI flags. Older daemons omit the new fields entirely; SDK clients pre-flight this tag before relying on `budgets[]` semantics. The registry descriptor also carries `modes: ['warn', 'enforce']` for future feature-modes exposure — for now, clients infer mode from the snapshot's `budgetMode` field. Server refusal under `enforce` mode is deterministic by `Object.entries(mcpServers)` declaration order; a future scope-precedence layer (if qwen-code adopts one) would shift this to "lowest-precedence first" to mirror claude-code's `plugin < user < project < local` convention. @@ -1003,6 +1006,43 @@ contains whitelisted metadata from the agent, shell, and monitor task registries; controllers, timers, offsets, pending messages, and raw registry objects are never exposed. +### `GET /session/:id/lsp` + +```json +{ + "v": 1, + "sessionId": "", + "workspaceCwd": "/canonical/path", + "enabled": true, + "configuredServers": 1, + "readyServers": 1, + "failedServers": 0, + "inProgressServers": 0, + "notStartedServers": 0, + "servers": [ + { + "name": "typescript", + "status": "READY", + "languages": ["typescript", "javascript"], + "transport": "stdio", + "command": "typescript-language-server" + } + ] +} +``` + +`status` is one of `NOT_STARTED`, `IN_PROGRESS`, `READY`, or `FAILED`. +Optional `error` is present on failed servers when available. Disabled LSP +(including bare mode) returns HTTP 200 with `enabled: false`, zero counts, and +`servers: []`. LSP enabled with no configured servers returns `enabled: true`, +`configuredServers: 0`, and `servers: []`. If initialization fails before the +client exists, the response may include `initializationError`; if a live client +cannot provide a snapshot, the response includes `statusUnavailable: true`. + +This route exposes only stable client-facing fields. It intentionally omits +debug internals such as process IDs, spawn args, stderr tails, root URIs, and +workspace-folder paths. + ### `POST /session` Spawn a new agent or attach to an existing one (under `sessionScope: 'single'`, the default). diff --git a/docs/users/qwen-serve.md b/docs/users/qwen-serve.md index 5178912f6f..727d98dda2 100644 --- a/docs/users/qwen-serve.md +++ b/docs/users/qwen-serve.md @@ -87,7 +87,11 @@ operators: `GET /daemon/status`, `GET /workspace/mcp`, `GET /workspace/skills`, `GET /workspace/providers`, `GET /workspace/env`, `GET /workspace/preflight`, `GET /session/:id/context`, `GET /session/:id/supported-commands`, and -`GET /session/:id/tasks`. +`GET /session/:id/tasks`, and `GET /session/:id/lsp`. + +`GET /session/:id/lsp` returns structured per-session LSP status. Start the +daemon with `--experimental-lsp` to enable LSP in spawned agent sessions; +otherwise the route returns `enabled: false` with no servers. `GET /daemon/status` is the consolidated troubleshooting snapshot. The default `detail=summary` reads only in-memory daemon state (sessions, permissions, diff --git a/integration-tests/cli/qwen-serve-routes.test.ts b/integration-tests/cli/qwen-serve-routes.test.ts index 6fb45cc4f7..06a9b5ea77 100644 --- a/integration-tests/cli/qwen-serve-routes.test.ts +++ b/integration-tests/cli/qwen-serve-routes.test.ts @@ -263,6 +263,7 @@ describe('qwen serve — capabilities envelope', () => { 'session_supported_commands', 'session_tasks', 'session_stats', + 'session_lsp', 'session_close', 'session_metadata', 'mcp_guardrails', diff --git a/packages/acp-bridge/src/bridge.test.ts b/packages/acp-bridge/src/bridge.test.ts index 7638795068..3dfe49fb8c 100644 --- a/packages/acp-bridge/src/bridge.test.ts +++ b/packages/acp-bridge/src/bridge.test.ts @@ -289,6 +289,28 @@ describe('createAcpSessionBridge', () => { tasks: [], }; } + if (method === 'qwen/status/session/lsp') { + return { + v: 1, + sessionId: params['sessionId'], + workspaceCwd: WS_A, + enabled: true, + configuredServers: 1, + readyServers: 1, + failedServers: 0, + inProgressServers: 0, + notStartedServers: 0, + servers: [ + { + name: 'typescript', + status: 'READY', + languages: ['typescript'], + transport: 'stdio', + command: 'typescript-language-server', + }, + ], + }; + } return { v: 1, sessionId: params['sessionId'], @@ -322,10 +344,24 @@ describe('createAcpSessionBridge', () => { sessionId: session.sessionId, tasks: [], }); + await expect( + bridge.getSessionLspStatus(session.sessionId), + ).resolves.toMatchObject({ + sessionId: session.sessionId, + enabled: true, + configuredServers: 1, + servers: [ + { + name: 'typescript', + status: 'READY', + }, + ], + }); expect(handles[0]?.agent.extMethodCalls.map((c) => c.method)).toEqual([ 'qwen/status/session/context', 'qwen/status/session/supported_commands', 'qwen/status/session/tasks', + 'qwen/status/session/lsp', ]); await bridge.shutdown(); @@ -511,6 +547,9 @@ describe('createAcpSessionBridge', () => { await expect( bridge.getSessionTasksStatus('missing'), ).rejects.toBeInstanceOf(SessionNotFoundError); + await expect(bridge.getSessionLspStatus('missing')).rejects.toBeInstanceOf( + SessionNotFoundError, + ); }); it('reuses an echoed daemon-issued client id on attach', async () => { diff --git a/packages/acp-bridge/src/bridge.ts b/packages/acp-bridge/src/bridge.ts index 24af31254a..1967863fba 100644 --- a/packages/acp-bridge/src/bridge.ts +++ b/packages/acp-bridge/src/bridge.ts @@ -43,6 +43,7 @@ import { STATUS_SCHEMA_VERSION, type ServeSessionStatsStatus, type ServeSessionContextStatus, + type ServeSessionLspStatus, type ServeSessionTasksStatus, } from './status.js'; import { @@ -3778,6 +3779,13 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { ); }, + async getSessionLspStatus(sessionId) { + return requestSessionStatus( + sessionId, + SERVE_STATUS_EXT_METHODS.sessionLspStatus, + ); + }, + async cancelSessionTask(sessionId, taskId, taskKind) { return requestSessionStatus<{ cancelled: boolean }>( sessionId, diff --git a/packages/acp-bridge/src/bridgeTypes.ts b/packages/acp-bridge/src/bridgeTypes.ts index 42429746a6..635656a162 100644 --- a/packages/acp-bridge/src/bridgeTypes.ts +++ b/packages/acp-bridge/src/bridgeTypes.ts @@ -20,6 +20,7 @@ import type { PermissionPolicy } from './permission.js'; import type { ServeSessionContextStatus, ServeSessionHooksStatus, + ServeSessionLspStatus, ServeSessionSupportedCommandsStatus, ServeSessionTasksStatus, ServeWorkspaceExtensionsStatus, @@ -469,6 +470,9 @@ export interface AcpSessionBridge { /** Read the live background task snapshot for a live session. */ getSessionTasksStatus(sessionId: string): Promise; + /** Read sanitized LSP server status for a live session. */ + getSessionLspStatus(sessionId: string): Promise; + /** Cancel a background task in a live session. */ cancelSessionTask( sessionId: string, diff --git a/packages/acp-bridge/src/spawnChannel.test.ts b/packages/acp-bridge/src/spawnChannel.test.ts index 6f4ee24ec6..b47d7fe5c8 100644 --- a/packages/acp-bridge/src/spawnChannel.test.ts +++ b/packages/acp-bridge/src/spawnChannel.test.ts @@ -109,6 +109,18 @@ describe('createSpawnChannelFactory env policy', () => { expect(spawnOptions?.env).not.toHaveProperty('QWEN_SERVER_TOKEN'); expect(spawnOptions?.env?.['QWEN_CODE_NO_RELAUNCH']).toBe('true'); }); + + it('passes optional child args after --acp', async () => { + mockSpawn.mockReturnValue(createFakeChildProcess()); + + const factory = createSpawnChannelFactory({ + extraArgs: ['--experimental-lsp'], + }); + await factory('/tmp/project'); + + const args = mockSpawn.mock.calls[0]?.[1] as string[] | undefined; + expect(args?.slice(-2)).toEqual(['--acp', '--experimental-lsp']); + }); }); describe('createStderrForwarder', () => { diff --git a/packages/acp-bridge/src/spawnChannel.ts b/packages/acp-bridge/src/spawnChannel.ts index 7887d5b683..9ef2a1dec3 100644 --- a/packages/acp-bridge/src/spawnChannel.ts +++ b/packages/acp-bridge/src/spawnChannel.ts @@ -98,6 +98,7 @@ export function createStderrForwarder(opts: StderrForwarderOptions): { export interface SpawnChannelFactoryOptions { onDiagnosticLine?: (line: string, level?: 'info' | 'warn' | 'error') => void; + extraArgs?: string[]; } /** @@ -130,7 +131,13 @@ export function createSpawnChannelFactory( ); const child = spawn( process.execPath, - [...execArgs, ...memoryArgs, cliEntry, '--acp'], + [ + ...execArgs, + ...memoryArgs, + cliEntry, + '--acp', + ...(options.extraArgs ?? []), + ], { cwd: workspaceCwd, stdio: ['pipe', 'pipe', 'pipe'], diff --git a/packages/acp-bridge/src/status.ts b/packages/acp-bridge/src/status.ts index 4bd933aa6c..d1a2442afd 100644 --- a/packages/acp-bridge/src/status.ts +++ b/packages/acp-bridge/src/status.ts @@ -108,6 +108,7 @@ export const SERVE_STATUS_EXT_METHODS = { sessionSupportedCommands: 'qwen/status/session/supported_commands', sessionTasks: 'qwen/status/session/tasks', sessionStats: 'qwen/status/session/stats', + sessionLspStatus: 'qwen/status/session/lsp', sessionRewindSnapshots: 'qwen/status/session/rewind_snapshots', workspaceHooks: 'qwen/status/workspace/hooks', sessionHooks: 'qwen/status/session/hooks', @@ -454,6 +455,30 @@ export interface ServeSessionSupportedCommandsStatus { availableSkills: string[]; } +export interface ServeLspServerStatus { + name: string; + status: 'NOT_STARTED' | 'IN_PROGRESS' | 'READY' | 'FAILED'; + languages: string[]; + transport?: string; + command?: string; + error?: string; +} + +export interface ServeSessionLspStatus { + v: typeof STATUS_SCHEMA_VERSION; + sessionId: string; + workspaceCwd: string; + enabled: boolean; + configuredServers: number; + readyServers: number; + failedServers: number; + inProgressServers: number; + notStartedServers: number; + statusUnavailable?: true; + initializationError?: string; + servers: ServeLspServerStatus[]; +} + export type ServeSessionTaskLifecycleStatus = | 'running' | 'paused' diff --git a/packages/cli/src/acp-integration/acpAgent.test.ts b/packages/cli/src/acp-integration/acpAgent.test.ts index d2c6f73ba6..cf522dbfd5 100644 --- a/packages/cli/src/acp-integration/acpAgent.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.test.ts @@ -1977,6 +1977,28 @@ describe('QwenAgent MCP SSE/HTTP support', () => { }, ]), }), + getLspStatusSnapshot: vi.fn().mockReturnValue({ + enabled: true, + configuredServers: 1, + readyServers: 1, + failedServers: 0, + inProgressServers: 0, + notStartedServers: 0, + servers: [ + { + name: 'typescript', + status: 'READY', + languages: ['typescript'], + transport: 'stdio', + command: 'typescript-language-server', + args: ['--stdio'], + pid: 1234, + stderrTail: 'hidden', + rootUri: 'file:///tmp', + workspaceFolder: '/tmp', + }, + ], + }), }); vi.mocked(buildAvailableCommandsSnapshot).mockResolvedValueOnce({ availableCommands: [ @@ -2018,6 +2040,12 @@ describe('QwenAgent MCP SSE/HTTP support', () => { SERVE_STATUS_EXT_METHODS.sessionContextUsage, { sessionId, detail: true }, ); + const lsp = await agent.extMethod( + SERVE_STATUS_EXT_METHODS.sessionLspStatus, + { + sessionId, + }, + ); expect(context).toMatchObject({ v: 1, @@ -2106,6 +2134,31 @@ describe('QwenAgent MCP SSE/HTTP support', () => { }, formattedText: expect.stringContaining('## Context Usage'), }); + expect(lsp).toEqual({ + v: 1, + sessionId, + workspaceCwd: '/tmp', + enabled: true, + configuredServers: 1, + readyServers: 1, + failedServers: 0, + inProgressServers: 0, + notStartedServers: 0, + servers: [ + { + name: 'typescript', + status: 'READY', + languages: ['typescript'], + transport: 'stdio', + command: 'typescript-language-server', + }, + ], + }); + expect(JSON.stringify(lsp)).not.toContain('--stdio'); + expect(JSON.stringify(lsp)).not.toContain('hidden'); + expect(JSON.stringify(lsp)).not.toContain('pid'); + expect(JSON.stringify(lsp)).not.toContain('rootUri'); + expect(JSON.stringify(lsp)).not.toContain('workspaceFolder'); expect(buildAvailableCommandsSnapshot).toHaveBeenCalledWith(innerConfig); dateNowSpy.mockRestore(); @@ -2113,6 +2166,198 @@ describe('QwenAgent MCP SSE/HTTP support', () => { await agentPromise; }); + it('status ext method returns disabled LSP status', async () => { + const sessionId = '11111111-1111-1111-1111-111111111111'; + const innerConfig = await setupSessionMocks(sessionId); + Object.assign(innerConfig, { + getLspStatusSnapshot: vi.fn().mockReturnValue({ + enabled: false, + configuredServers: 0, + readyServers: 0, + failedServers: 0, + inProgressServers: 0, + notStartedServers: 0, + servers: [], + }), + }); + + 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: [] }); + + await expect( + agent.extMethod(SERVE_STATUS_EXT_METHODS.sessionLspStatus, { + sessionId, + }), + ).resolves.toEqual({ + v: 1, + sessionId, + workspaceCwd: '/tmp', + enabled: false, + configuredServers: 0, + readyServers: 0, + failedServers: 0, + inProgressServers: 0, + notStartedServers: 0, + servers: [], + }); + await expect( + agent.extMethod(SERVE_STATUS_EXT_METHODS.sessionLspStatus, {}), + ).rejects.toThrow('Invalid or missing sessionId'); + + mockConnectionState.resolve(); + await agentPromise; + }); + + it('status ext method returns unavailable LSP status', async () => { + const sessionId = '11111111-1111-1111-1111-111111111111'; + const innerConfig = await setupSessionMocks(sessionId); + Object.assign(innerConfig, { + getLspStatusSnapshot: vi.fn().mockReturnValue({ + enabled: true, + configuredServers: 0, + readyServers: 0, + failedServers: 0, + inProgressServers: 0, + notStartedServers: 0, + servers: [], + statusUnavailable: true, + initializationError: 'client failed', + }), + }); + + 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: [] }); + + await expect( + agent.extMethod(SERVE_STATUS_EXT_METHODS.sessionLspStatus, { + sessionId, + }), + ).resolves.toEqual({ + v: 1, + sessionId, + workspaceCwd: '/tmp', + enabled: true, + configuredServers: 0, + readyServers: 0, + failedServers: 0, + inProgressServers: 0, + notStartedServers: 0, + statusUnavailable: true, + initializationError: 'client failed', + servers: [], + }); + + mockConnectionState.resolve(); + await agentPromise; + }); + + it('includes error field for FAILED server while stripping debug internals', async () => { + const sessionId = '11111111-1111-1111-1111-111111111111'; + const innerConfig = await setupSessionMocks(sessionId); + Object.assign(innerConfig, { + getLspStatusSnapshot: vi.fn().mockReturnValue({ + enabled: true, + configuredServers: 1, + readyServers: 0, + failedServers: 1, + inProgressServers: 0, + notStartedServers: 0, + servers: [ + { + name: 'typescript', + status: 'FAILED', + languages: ['typescript'], + transport: 'stdio', + command: 'typescript-language-server', + error: 'connection refused', + args: ['--stdio'], + pid: 5678, + stderrTail: 'ECONNREFUSED', + exitCode: 1, + rootUri: 'file:///tmp', + workspaceFolder: '/tmp', + }, + ], + }), + }); + + 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 lsp = await agent.extMethod( + SERVE_STATUS_EXT_METHODS.sessionLspStatus, + { sessionId }, + ); + + expect(lsp).toEqual({ + v: 1, + sessionId, + workspaceCwd: '/tmp', + enabled: true, + configuredServers: 1, + readyServers: 0, + failedServers: 1, + inProgressServers: 0, + notStartedServers: 0, + servers: [ + { + name: 'typescript', + status: 'FAILED', + languages: ['typescript'], + transport: 'stdio', + command: 'typescript-language-server', + error: 'connection refused', + }, + ], + }); + const lspStr = JSON.stringify(lsp); + expect(lspStr).not.toContain('--stdio'); + expect(lspStr).not.toContain('5678'); + expect(lspStr).not.toContain('ECONNREFUSED'); + expect(lspStr).not.toContain('exitCode'); + expect(lspStr).not.toContain('rootUri'); + expect(lspStr).not.toContain('workspaceFolder'); + + mockConnectionState.resolve(); + await agentPromise; + }); + it('launches fork agents with neutral history text', async () => { const sessionId = '11111111-1111-1111-1111-111111111111'; const innerConfig = await setupSessionMocks(sessionId); diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index 817f25b082..d201fd75e6 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -184,6 +184,7 @@ import { type ServePreflightKind, type ServeSessionContextStatus, type ServeSessionSupportedCommandsStatus, + type ServeSessionLspStatus, type ServeSessionTasksStatus, type ServeStatus, type ServeStatusCell, @@ -4293,6 +4294,35 @@ class QwenAgent implements Agent { return buildSessionTasksStatus(sessionId, session.getConfig()); } + private buildSessionLspStatus(sessionId: string): ServeSessionLspStatus { + const session = this.sessionOrThrow(sessionId); + const config = session.getConfig(); + const snapshot = config.getLspStatusSnapshot(); + return { + v: STATUS_SCHEMA_VERSION, + sessionId, + workspaceCwd: this.workspaceCwd(config), + enabled: snapshot.enabled, + configuredServers: snapshot.configuredServers, + readyServers: snapshot.readyServers, + failedServers: snapshot.failedServers, + inProgressServers: snapshot.inProgressServers, + notStartedServers: snapshot.notStartedServers, + ...(snapshot.statusUnavailable ? { statusUnavailable: true } : {}), + ...(snapshot.initializationError + ? { initializationError: snapshot.initializationError } + : {}), + servers: snapshot.servers.map((server) => ({ + name: server.name, + status: server.status, + languages: server.languages, + ...(server.transport ? { transport: server.transport } : {}), + ...(server.command ? { command: server.command } : {}), + ...(server.error ? { error: server.error } : {}), + })), + }; + } + private buildSessionStatsStatus(sessionId: string): ServeSessionStatsStatus { const session = this.sessionOrThrow(sessionId); const config = session.getConfig(); @@ -5065,6 +5095,19 @@ class QwenAgent implements Agent { unknown >; } + case SERVE_STATUS_EXT_METHODS.sessionLspStatus: { + const sessionId = params['sessionId']; + if (typeof sessionId !== 'string' || sessionId.length === 0) { + throw RequestError.invalidParams( + undefined, + 'Invalid or missing sessionId', + ); + } + return this.buildSessionLspStatus(sessionId) as unknown as Record< + string, + unknown + >; + } case SERVE_STATUS_EXT_METHODS.sessionStats: { const sessionId = params['sessionId']; if (typeof sessionId !== 'string' || sessionId.length === 0) { diff --git a/packages/cli/src/commands/serve.test.ts b/packages/cli/src/commands/serve.test.ts index fcee265635..328ee6f7d0 100644 --- a/packages/cli/src/commands/serve.test.ts +++ b/packages/cli/src/commands/serve.test.ts @@ -41,6 +41,11 @@ describe('serve command args', () => { expect(parsed['enable-session-shell']).toBe(false); }); + it('parses --experimental-lsp for daemon child opt-in', () => { + const parsed = buildParser().strict().parseSync('--experimental-lsp'); + expect(parsed['experimentalLsp']).toBe(true); + }); + it('parses --permission-response-timeout-ms as a number', () => { const parsed = buildParser().parseSync( '--permission-response-timeout-ms 60000', diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index b139621844..a5f22fc552 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -106,6 +106,7 @@ interface ServeArgs { 'rate-limit-mutation'?: number; 'rate-limit-read'?: number; 'rate-limit-window-ms'?: number; + experimentalLsp?: boolean; } export const serveCommand: CommandModule = { @@ -179,6 +180,12 @@ export const serveCommand: CommandModule = { description: 'Enable direct POST /session/:id/shell execution. Requires a bearer token and a session-bound client id on each call.', }) + .option('experimental-lsp', { + type: 'boolean', + default: false, + description: + 'Forward the experimental LSP opt-in to spawned agent sessions.', + }) .option('web', { type: 'boolean', default: true, @@ -516,6 +523,7 @@ export const serveCommand: CommandModule = { ...(rateLimitMutation !== undefined ? { rateLimitMutation } : {}), ...(rateLimitRead !== undefined ? { rateLimitRead } : {}), ...(rateLimitWindowMs !== undefined ? { rateLimitWindowMs } : {}), + ...(argv.experimentalLsp === true ? { experimentalLsp: true } : {}), }); // Open the Web Shell in a browser once the listener is up (best-effort; // never throws — see maybeOpenWebShellBrowser). diff --git a/packages/cli/src/serve/acp-http/dispatch.ts b/packages/cli/src/serve/acp-http/dispatch.ts index 1d2cb63984..075f395eb7 100644 --- a/packages/cli/src/serve/acp-http/dispatch.ts +++ b/packages/cli/src/serve/acp-http/dispatch.ts @@ -96,6 +96,7 @@ const ALL_QWEN_VENDOR_METHODS: readonly string[] = [ `${QWEN_METHOD_NS}session/detach`, `${QWEN_METHOD_NS}session/context_usage`, `${QWEN_METHOD_NS}session/tasks`, + `${QWEN_METHOD_NS}session/lsp`, // Wave 1: memory `${QWEN_METHOD_NS}workspace/memory`, `${QWEN_METHOD_NS}workspace/memory/write`, @@ -1407,6 +1408,14 @@ export class AcpDispatcher { return; } + case `${QWEN_METHOD_NS}session/lsp`: { + const sessionId = String(params['sessionId'] ?? ''); + if (!this.requireOwned(conn, sessionId, id)) return; + const result = await this.bridge.getSessionLspStatus(sessionId); + this.replyConn(conn, id, result as unknown); + return; + } + case `${QWEN_METHOD_NS}workspace/memory`: { const result = await collectWorkspaceMemoryStatus( this.boundWorkspace, diff --git a/packages/cli/src/serve/acp-http/index.ts b/packages/cli/src/serve/acp-http/index.ts index 97d93237e1..1959cc04cf 100644 --- a/packages/cli/src/serve/acp-http/index.ts +++ b/packages/cli/src/serve/acp-http/index.ts @@ -46,6 +46,7 @@ const WS_READ_METHODS = new Set([ '_qwen/session/supported_commands', '_qwen/session/context_usage', '_qwen/session/tasks', + '_qwen/session/lsp', '_qwen/workspace/mcp', '_qwen/workspace/skills', '_qwen/workspace/providers', diff --git a/packages/cli/src/serve/acp-http/transport.test.ts b/packages/cli/src/serve/acp-http/transport.test.ts index 466a45f230..0719c60b15 100644 --- a/packages/cli/src/serve/acp-http/transport.test.ts +++ b/packages/cli/src/serve/acp-http/transport.test.ts @@ -266,6 +266,20 @@ class FakeBridge { async getSessionTasksStatus(sessionId: string) { return { sessionId, tasks: [] }; } + async getSessionLspStatus(sessionId: string) { + return { + v: 1, + sessionId, + workspaceCwd: '/ws', + enabled: true, + configuredServers: 1, + readyServers: 1, + failedServers: 0, + inProgressServers: 0, + notStartedServers: 0, + servers: [], + }; + } async getWorkspaceToolsStatus() { return { v: 1, tools: [] }; } @@ -585,6 +599,18 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { ); }); + it('initialize advertises _qwen/session/lsp', async () => { + const { body } = await initializeRaw(); + const result = body['result'] as { + agentCapabilities: { + _meta: { qwen: { methods: string[] } }; + }; + }; + expect(result.agentCapabilities._meta.qwen.methods).toContain( + '_qwen/session/lsp', + ); + }); + it('session/new reply rides the connection-scoped stream', async () => { const connId = await initialize(); const connStream = await openStream(connId); @@ -2153,6 +2179,35 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { }); }); + it('_qwen/session/lsp returns status', async () => { + const connId = await initialize(); + const streamRes = openStream(connId); + await new Promise((r) => setTimeout(r, 30)); + await post(connId, { + jsonrpc: '2.0', + id: 99, + method: 'session/new', + params: {}, + }); + await new Promise((r) => setTimeout(r, 30)); + await post(connId, { + jsonrpc: '2.0', + id: 57, + method: '_qwen/session/lsp', + params: { sessionId: 'sess-1' }, + }); + const frames = await takeFrames(await streamRes, 2); + expect(frames[1]).toMatchObject({ + result: { + v: 1, + sessionId: 'sess-1', + enabled: true, + configuredServers: 1, + readyServers: 1, + }, + }); + }); + it('session methods reject unowned session', async () => { const connId = await initialize(); const streamRes = openStream(connId); diff --git a/packages/cli/src/serve/capabilities.ts b/packages/cli/src/serve/capabilities.ts index 8aaf85ce43..6ca96f8af4 100644 --- a/packages/cli/src/serve/capabilities.ts +++ b/packages/cli/src/serve/capabilities.ts @@ -81,6 +81,7 @@ export const SERVE_CAPABILITY_REGISTRY = { session_supported_commands: { since: 'v1' }, session_tasks: { since: 'v1' }, session_stats: { since: 'v1' }, + session_lsp: { since: 'v1' }, session_close: { since: 'v1' }, session_metadata: { since: 'v1' }, // Daemon supports the MCP client guardrail surface: an in-process diff --git a/packages/cli/src/serve/run-qwen-serve.test.ts b/packages/cli/src/serve/run-qwen-serve.test.ts index ddb744c35e..6ed0f1d68c 100644 --- a/packages/cli/src/serve/run-qwen-serve.test.ts +++ b/packages/cli/src/serve/run-qwen-serve.test.ts @@ -16,6 +16,24 @@ import { } from './run-qwen-serve.js'; import type { HttpAcpBridge } from './acp-session-bridge.js'; +const mockCreateSpawnChannelFactoryOptions = vi.hoisted( + () => [] as Array>, +); + +vi.mock('@qwen-code/acp-bridge/spawnChannel', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + createSpawnChannelFactory: vi.fn( + (options: Record = {}) => { + mockCreateSpawnChannelFactoryOptions.push(options); + return actual.createSpawnChannelFactory(options); + }, + ), + }; +}); + /** * #4297 fold-in 7 (deepseek S1, addresses #3262690842). Lock the * `context.fileName` extraction logic so a regression doesn't @@ -419,6 +437,7 @@ describe('runQwenServe Web Shell signals on RunHandle', () => { async function bootHandle(extra: { serveWebShell?: boolean; token?: string; + experimentalLsp?: boolean; }) { tmpDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'qws-ws-'))); return runQwenServe( @@ -460,4 +479,23 @@ describe('runQwenServe Web Shell signals on RunHandle', () => { await handle.close(); } }); + + it('passes --experimental-lsp to spawned ACP children only when opted in', async () => { + mockCreateSpawnChannelFactoryOptions.length = 0; + + const defaultHandle = await bootHandle({ serveWebShell: false }); + await defaultHandle.close(); + expect(mockCreateSpawnChannelFactoryOptions.at(-1)).not.toHaveProperty( + 'extraArgs', + ); + + const lspHandle = await bootHandle({ + serveWebShell: false, + experimentalLsp: true, + }); + await lspHandle.close(); + expect(mockCreateSpawnChannelFactoryOptions.at(-1)).toMatchObject({ + extraArgs: ['--experimental-lsp'], + }); + }); }); diff --git a/packages/cli/src/serve/run-qwen-serve.ts b/packages/cli/src/serve/run-qwen-serve.ts index 8aff353fbb..a34ce9962b 100644 --- a/packages/cli/src/serve/run-qwen-serve.ts +++ b/packages/cli/src/serve/run-qwen-serve.ts @@ -881,6 +881,9 @@ export async function runQwenServe( daemonLog.raw(line, level); const channelFactory = createSpawnChannelFactory({ onDiagnosticLine: diagnosticSink, + ...(opts.experimentalLsp === true + ? { extraArgs: ['--experimental-lsp'] } + : {}), }); const persistDisabledToolsFn = ( diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index a5c8c38afa..2b5642b94c 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -86,6 +86,7 @@ import type { ServeSessionContextStatus, ServeSessionContextUsageStatus, ServeSessionHooksStatus, + ServeSessionLspStatus, ServeSessionSupportedCommandsStatus, ServeSessionTasksStatus, ServeWorkspaceEnvStatus, @@ -172,6 +173,7 @@ const EXPECTED_STAGE1_FEATURES = [ 'session_supported_commands', 'session_tasks', 'session_stats', + 'session_lsp', 'session_close', 'session_metadata', // Issue #4175 PR 14. Always-on. Daemon supports the MCP client @@ -357,6 +359,7 @@ interface FakeBridgeOpts { sessionId: string, ) => Promise; sessionTasksImpl?: (sessionId: string) => Promise; + sessionLspImpl?: (sessionId: string) => Promise; cancelSessionTaskImpl?: ( sessionId: string, taskId: string, @@ -537,6 +540,7 @@ interface FakeBridge extends AcpSessionBridge { sessionContextUsageCalls: string[]; sessionSupportedCommandsCalls: string[]; sessionTasksCalls: string[]; + sessionLspCalls: string[]; cancelSessionTaskCalls: Array<{ sessionId: string; taskId: string; @@ -645,6 +649,7 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { const sessionContextCalls: string[] = []; const sessionSupportedCommandsCalls: string[] = []; const sessionTasksCalls: string[] = []; + const sessionLspCalls: string[] = []; const cancelSessionTaskCalls: FakeBridge['cancelSessionTaskCalls'] = []; const clearSessionGoalCalls: string[] = []; const sessionHooksCalls: string[] = []; @@ -818,6 +823,20 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { now: 1_700_000_000_000, tasks: [], })); + const sessionLspImpl = + opts.sessionLspImpl ?? + (async (sessionId) => ({ + v: 1 as const, + sessionId, + workspaceCwd: WS_BOUND, + enabled: false, + configuredServers: 0, + readyServers: 0, + failedServers: 0, + inProgressServers: 0, + notStartedServers: 0, + servers: [], + })); const cancelSessionTaskImpl = opts.cancelSessionTaskImpl ?? (async () => ({ cancelled: true })); const clearSessionGoalImpl = @@ -986,6 +1005,7 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { sessionContextUsageCalls, sessionSupportedCommandsCalls, sessionTasksCalls, + sessionLspCalls, cancelSessionTaskCalls, clearSessionGoalCalls, sessionHooksCalls, @@ -1177,6 +1197,10 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { sessionTasksCalls.push(sessionId); return sessionTasksImpl(sessionId); }, + async getSessionLspStatus(sessionId) { + sessionLspCalls.push(sessionId); + return sessionLspImpl(sessionId); + }, async cancelSessionTask(sessionId, taskId, taskKind) { cancelSessionTaskCalls.push({ sessionId, taskId, taskKind }); return cancelSessionTaskImpl(sessionId, taskId, taskKind); @@ -3902,10 +3926,31 @@ describe('createServeApp', () => { }, ], }; + const lsp: ServeSessionLspStatus = { + v: 1, + sessionId: 's-1', + workspaceCwd: WS_BOUND, + enabled: true, + configuredServers: 1, + readyServers: 1, + failedServers: 0, + inProgressServers: 0, + notStartedServers: 0, + servers: [ + { + name: 'typescript', + status: 'READY', + languages: ['typescript'], + transport: 'stdio', + command: 'typescript-language-server', + }, + ], + }; const bridge = fakeBridge({ sessionContextImpl: async () => context, sessionSupportedCommandsImpl: async () => commands, sessionTasksImpl: async () => tasks, + sessionLspImpl: async () => lsp, }); const app = createServeApp( { ...baseOpts, workspace: WS_BOUND }, @@ -3922,6 +3967,9 @@ describe('createServeApp', () => { const tasksRes = await request(app) .get('/session/s-1/tasks') .set('Host', `127.0.0.1:${baseOpts.port}`); + const lspRes = await request(app) + .get('/session/s-1/lsp') + .set('Host', `127.0.0.1:${baseOpts.port}`); expect(contextRes.status).toBe(200); expect(contextRes.body).toEqual(context); @@ -3929,9 +3977,12 @@ describe('createServeApp', () => { expect(commandsRes.body).toEqual(commands); expect(tasksRes.status).toBe(200); expect(tasksRes.body).toEqual(tasks); + expect(lspRes.status).toBe(200); + expect(lspRes.body).toEqual(lsp); expect(bridge.sessionContextCalls).toEqual(['s-1']); expect(bridge.sessionSupportedCommandsCalls).toEqual(['s-1']); expect(bridge.sessionTasksCalls).toEqual(['s-1']); + expect(bridge.sessionLspCalls).toEqual(['s-1']); }); it('returns session context-usage from the bridge', async () => { @@ -4035,6 +4086,9 @@ describe('createServeApp', () => { sessionTasksImpl: async (sessionId) => { throw new SessionNotFoundError(sessionId); }, + sessionLspImpl: async (sessionId) => { + throw new SessionNotFoundError(sessionId); + }, }); const app = createServeApp( { ...baseOpts, workspace: WS_BOUND }, @@ -4051,6 +4105,9 @@ describe('createServeApp', () => { const tasksRes = await request(app) .get('/session/missing/tasks') .set('Host', `127.0.0.1:${baseOpts.port}`); + const lspRes = await request(app) + .get('/session/missing/lsp') + .set('Host', `127.0.0.1:${baseOpts.port}`); expect(contextRes.status).toBe(404); expect(contextRes.body.sessionId).toBe('missing'); @@ -4058,6 +4115,8 @@ describe('createServeApp', () => { expect(commandsRes.body.sessionId).toBe('missing'); expect(tasksRes.status).toBe(404); expect(tasksRes.body.sessionId).toBe('missing'); + expect(lspRes.status).toBe(404); + expect(lspRes.body.sessionId).toBe('missing'); }); it('rejects task cancellation with invalid kind', async () => { diff --git a/packages/cli/src/serve/server.ts b/packages/cli/src/serve/server.ts index 308c41875c..4cba80e524 100644 --- a/packages/cli/src/serve/server.ts +++ b/packages/cli/src/serve/server.ts @@ -1016,6 +1016,7 @@ function advertisedMaxPendingPromptsPerSession( * - `GET /session/:id/context` * - `GET /session/:id/supported-commands` * - `GET /session/:id/tasks` + * - `GET /session/:id/lsp` * - `POST /session/:id/prompt` * - `POST /session/:id/cancel` * - `POST /session/:id/heartbeat` @@ -3117,6 +3118,19 @@ export function createServeApp( } }); + app.get('/session/:id/lsp', async (req, res) => { + const sessionId = requireSessionId(req, res); + if (sessionId === null) return; + try { + res.status(200).json(await bridge.getSessionLspStatus(sessionId)); + } catch (err) { + sendBridgeError(res, err, { + route: 'GET /session/:id/lsp', + sessionId, + }); + } + }); + // GET /session/:id/hooks — read-only session-scoped hook status. app.get('/session/:id/hooks', async (req, res) => { const sessionId = requireSessionId(req, res); diff --git a/packages/cli/src/serve/types.ts b/packages/cli/src/serve/types.ts index 2f801e1d15..5dd2e9dc8f 100644 --- a/packages/cli/src/serve/types.ts +++ b/packages/cli/src/serve/types.ts @@ -203,6 +203,8 @@ export interface ServeOptions { rateLimitRead?: number; /** Rate limit window duration in ms (default 60000). Requires --rate-limit. */ rateLimitWindowMs?: number; + /** Forward the experimental LSP opt-in to spawned ACP children. */ + experimentalLsp?: boolean; } /** diff --git a/packages/sdk-typescript/src/daemon/DaemonClient.ts b/packages/sdk-typescript/src/daemon/DaemonClient.ts index ef70778268..23c40ebf6b 100644 --- a/packages/sdk-typescript/src/daemon/DaemonClient.ts +++ b/packages/sdk-typescript/src/daemon/DaemonClient.ts @@ -32,6 +32,7 @@ import type { DaemonForkSessionResult, DaemonRestoredSession, DaemonSession, + DaemonSessionLspStatus, DaemonSessionSummary, DaemonSessionSupportedCommandsStatus, DaemonSessionStatsStatus, @@ -1361,6 +1362,22 @@ export class DaemonClient { ); } + async sessionLspStatus( + sessionId: string, + clientId?: string, + ): Promise { + return await this.fetchWithTimeout( + `${this.baseUrl}/session/${encodeURIComponent(sessionId)}/lsp`, + { headers: this.headers({}, clientId) }, + async (res) => { + if (!res.ok) { + throw await this.failOnError(res, 'GET /session/:id/lsp'); + } + return (await res.json()) as DaemonSessionLspStatus; + }, + ); + } + async sessionTaskCancel( sessionId: string, taskId: string, diff --git a/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts b/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts index 21064cc220..ce07492fa3 100644 --- a/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts +++ b/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts @@ -28,6 +28,7 @@ import type { DaemonShellCommandResult, DaemonSessionState, DaemonSession, + DaemonSessionLspStatus, DaemonSessionStatsStatus, DaemonSessionSupportedCommandsStatus, DaemonSessionTaskStatus, @@ -434,6 +435,10 @@ export class DaemonSessionClient { return await this.client.sessionTasks(this.sessionId, this.clientId); } + async lspStatus(): Promise { + return await this.client.sessionLspStatus(this.sessionId, this.clientId); + } + async cancelTask( taskId: string, kind: DaemonSessionTaskStatus['kind'], diff --git a/packages/sdk-typescript/src/daemon/acpRouteTable.ts b/packages/sdk-typescript/src/daemon/acpRouteTable.ts index c6fd68940d..432a93e6f4 100644 --- a/packages/sdk-typescript/src/daemon/acpRouteTable.ts +++ b/packages/sdk-typescript/src/daemon/acpRouteTable.ts @@ -296,6 +296,15 @@ export const ROUTE_TABLE: readonly RouteEntry[] = [ extractParams: (segs) => ({ sessionId: segs[0] }), }, }, + // GET /session/:id/lsp -> _qwen/session/lsp + { + httpMethod: 'GET', + pattern: /^\/session\/([^/]+)\/lsp$/, + mapping: { + method: '_qwen/session/lsp', + extractParams: (segs) => ({ sessionId: segs[0] }), + }, + }, // ---- Granular workspace routes (_qwen/workspace/*) --------------------- diff --git a/packages/sdk-typescript/src/daemon/index.ts b/packages/sdk-typescript/src/daemon/index.ts index 301c7f8ded..7d64e69fce 100644 --- a/packages/sdk-typescript/src/daemon/index.ts +++ b/packages/sdk-typescript/src/daemon/index.ts @@ -355,6 +355,8 @@ export type { DaemonSessionProcessTaskLifecycleStatus, DaemonSessionContextUsage, DaemonSessionContextUsageStatus, + DaemonLspServerStatus, + DaemonSessionLspStatus, DaemonSessionState, DaemonSessionSummary, DaemonSessionShellTaskStatus, diff --git a/packages/sdk-typescript/src/daemon/types.ts b/packages/sdk-typescript/src/daemon/types.ts index 54aa3ba027..4016e0159b 100644 --- a/packages/sdk-typescript/src/daemon/types.ts +++ b/packages/sdk-typescript/src/daemon/types.ts @@ -936,6 +936,30 @@ export interface DaemonSessionTasksStatus { tasks: DaemonSessionTaskStatus[]; } +export interface DaemonLspServerStatus { + name: string; + status: 'NOT_STARTED' | 'IN_PROGRESS' | 'READY' | 'FAILED'; + languages: string[]; + transport?: string; + command?: string; + error?: string; +} + +export interface DaemonSessionLspStatus { + v: 1; + sessionId: string; + workspaceCwd: string; + enabled: boolean; + configuredServers: number; + readyServers: number; + failedServers: number; + inProgressServers: number; + notStartedServers: number; + statusUnavailable?: true; + initializationError?: string; + servers: DaemonLspServerStatus[]; +} + export interface DaemonSessionStatsModelMetrics { api: { totalRequests: number; diff --git a/packages/sdk-typescript/src/index.ts b/packages/sdk-typescript/src/index.ts index c3f01dd460..818ae108b3 100644 --- a/packages/sdk-typescript/src/index.ts +++ b/packages/sdk-typescript/src/index.ts @@ -101,6 +101,8 @@ export { type DaemonSessionClosedReason, type DaemonSessionClientOptions, type DaemonSessionContextStatus, + type DaemonLspServerStatus, + type DaemonSessionLspStatus, type DaemonSessionAgentTaskStatus, type DaemonSessionMonitorTaskStatus, type DaemonSessionProcessTaskLifecycleStatus, diff --git a/packages/sdk-typescript/test/unit/DaemonClient.test.ts b/packages/sdk-typescript/test/unit/DaemonClient.test.ts index 4fd8a2c0bc..aa6f747172 100644 --- a/packages/sdk-typescript/test/unit/DaemonClient.test.ts +++ b/packages/sdk-typescript/test/unit/DaemonClient.test.ts @@ -21,6 +21,7 @@ import { import type { DaemonCapabilities, DaemonSessionContextStatus, + DaemonSessionLspStatus, DaemonSessionSupportedCommandsStatus, DaemonSessionTasksStatus, DaemonWorkspaceEnvStatus, @@ -538,6 +539,26 @@ describe('DaemonClient', () => { now: 1_700_000_000_000, tasks: [], }; + const lsp: DaemonSessionLspStatus = { + v: 1, + sessionId: 'with/slash', + workspaceCwd: '/work/a', + enabled: true, + configuredServers: 1, + readyServers: 1, + failedServers: 0, + inProgressServers: 0, + notStartedServers: 0, + servers: [ + { + name: 'typescript', + status: 'READY', + languages: ['typescript'], + transport: 'stdio', + command: 'typescript-language-server', + }, + ], + }; const { fetch, calls } = recordingFetch((req) => { if (req.url.endsWith('/session/with%2Fslash/context')) { return jsonResponse(200, context); @@ -548,6 +569,9 @@ describe('DaemonClient', () => { if (req.url.endsWith('/session/with%2Fslash/tasks')) { return jsonResponse(200, tasks); } + if (req.url.endsWith('/session/with%2Fslash/lsp')) { + return jsonResponse(200, lsp); + } return jsonResponse(500, { error: `unexpected ${req.url}` }); }); const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); @@ -561,15 +585,20 @@ describe('DaemonClient', () => { await expect( client.sessionTasks('with/slash', 'client-1'), ).resolves.toEqual(tasks); + await expect( + client.sessionLspStatus('with/slash', 'client-1'), + ).resolves.toEqual(lsp); expect(calls.map((c) => [c.method, c.url])).toEqual([ ['GET', 'http://daemon/session/with%2Fslash/context'], ['GET', 'http://daemon/session/with%2Fslash/supported-commands'], ['GET', 'http://daemon/session/with%2Fslash/tasks'], + ['GET', 'http://daemon/session/with%2Fslash/lsp'], ]); expect(calls.map((c) => c.headers['x-qwen-client-id'])).toEqual([ 'client-1', 'client-1', 'client-1', + 'client-1', ]); }); }); diff --git a/packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts b/packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts index 5c97818ee7..ccc6aa0cf4 100644 --- a/packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts +++ b/packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts @@ -459,6 +459,20 @@ describe('DaemonSessionClient', () => { tasks: [], }); } + if (req.url.endsWith('/session/s-1/lsp')) { + return jsonResponse(200, { + v: 1, + sessionId: 's-1', + workspaceCwd: '/work/a', + enabled: false, + configuredServers: 0, + readyServers: 0, + failedServers: 0, + inProgressServers: 0, + notStartedServers: 0, + servers: [], + }); + } if (req.url.endsWith('/session/s-1/cancel')) { return new Response(null, { status: 204 }); } @@ -524,6 +538,18 @@ describe('DaemonSessionClient', () => { now: 1_700_000_000_000, tasks: [], }); + await expect(session.lspStatus()).resolves.toEqual({ + v: 1, + sessionId: 's-1', + workspaceCwd: '/work/a', + enabled: false, + configuredServers: 0, + readyServers: 0, + failedServers: 0, + inProgressServers: 0, + notStartedServers: 0, + servers: [], + }); await expect(session.cancel()).resolves.toBeUndefined(); await expect( session.respondToPermission('req-1', { @@ -546,6 +572,7 @@ describe('DaemonSessionClient', () => { 'http://daemon/session/s-1/context', 'http://daemon/session/s-1/supported-commands', 'http://daemon/session/s-1/tasks', + 'http://daemon/session/s-1/lsp', 'http://daemon/session/s-1/cancel', 'http://daemon/permission/req-1', 'http://daemon/session/s-1/permission/req-2', @@ -564,6 +591,7 @@ describe('DaemonSessionClient', () => { 'client-1', 'client-1', 'client-1', + 'client-1', ]); }); diff --git a/packages/sdk-typescript/test/unit/acpRouteTable.test.ts b/packages/sdk-typescript/test/unit/acpRouteTable.test.ts index d73651e0ea..4f37a94db6 100644 --- a/packages/sdk-typescript/test/unit/acpRouteTable.test.ts +++ b/packages/sdk-typescript/test/unit/acpRouteTable.test.ts @@ -275,6 +275,12 @@ describe('acpRouteTable – matchRoute', () => { expect(result!.mapping.method).toBe('_qwen/session/tasks'); }); + it('GET /session/:id/lsp maps to _qwen/session/lsp', () => { + const result = matchRoute('/session/s18/lsp', 'GET'); + expect(result).not.toBeNull(); + expect(result!.mapping.method).toBe('_qwen/session/lsp'); + }); + // ---- Granular workspace routes ---------------------------------------- it('GET /workspace/mcp maps to _qwen/workspace/mcp', () => { diff --git a/packages/sdk-typescript/test/unit/daemon-public-surface.test.ts b/packages/sdk-typescript/test/unit/daemon-public-surface.test.ts index 22ec968631..3bd5a53f25 100644 --- a/packages/sdk-typescript/test/unit/daemon-public-surface.test.ts +++ b/packages/sdk-typescript/test/unit/daemon-public-surface.test.ts @@ -26,6 +26,7 @@ import type { DaemonModelSwitchedEvent, DaemonModelSwitchFailedData, DaemonModelSwitchFailedEvent, + DaemonLspServerStatus, DaemonPermissionOption, DaemonPermissionRequestData, DaemonPermissionRequestEvent, @@ -34,6 +35,7 @@ import type { DaemonRuntimeMcpAddRequest, DaemonRuntimeMcpAddResult, DaemonRuntimeMcpRemoveResult, + DaemonSessionLspStatus, DaemonSessionDiedData, DaemonSessionDiedEvent, DaemonSessionEvent, @@ -119,6 +121,8 @@ describe('public SDK entry — typed daemon event surface (#4217)', () => { // assertion a future barrel reshuffle could silently drop the // result type SDK consumers need to type `client.recapSession`. expectTypeOf().not.toBeNever(); + expectTypeOf().not.toBeNever(); + expectTypeOf().not.toBeNever(); }); it('exposes the PR 21 auth device-flow surface at the public entry', () => {