mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-09 17:19:02 +00:00
feat(serve): Add remote LSP status route (#5741)
This commit is contained in:
parent
8eb5770812
commit
c9b5c99e44
35 changed files with 769 additions and 12 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
||||
|
|
|
|||
|
|
@ -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": "<sid>",
|
||||
"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).
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -263,6 +263,7 @@ describe('qwen serve — capabilities envelope', () => {
|
|||
'session_supported_commands',
|
||||
'session_tasks',
|
||||
'session_stats',
|
||||
'session_lsp',
|
||||
'session_close',
|
||||
'session_metadata',
|
||||
'mcp_guardrails',
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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<ServeSessionLspStatus>(
|
||||
sessionId,
|
||||
SERVE_STATUS_EXT_METHODS.sessionLspStatus,
|
||||
);
|
||||
},
|
||||
|
||||
async cancelSessionTask(sessionId, taskId, taskKind) {
|
||||
return requestSessionStatus<{ cancelled: boolean }>(
|
||||
sessionId,
|
||||
|
|
|
|||
|
|
@ -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<ServeSessionTasksStatus>;
|
||||
|
||||
/** Read sanitized LSP server status for a live session. */
|
||||
getSessionLspStatus(sessionId: string): Promise<ServeSessionLspStatus>;
|
||||
|
||||
/** Cancel a background task in a live session. */
|
||||
cancelSessionTask(
|
||||
sessionId: string,
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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<unknown, ServeArgs> = {
|
||||
|
|
@ -179,6 +180,12 @@ export const serveCommand: CommandModule<unknown, ServeArgs> = {
|
|||
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<unknown, ServeArgs> = {
|
|||
...(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).
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -16,6 +16,24 @@ import {
|
|||
} from './run-qwen-serve.js';
|
||||
import type { HttpAcpBridge } from './acp-session-bridge.js';
|
||||
|
||||
const mockCreateSpawnChannelFactoryOptions = vi.hoisted(
|
||||
() => [] as Array<Record<string, unknown>>,
|
||||
);
|
||||
|
||||
vi.mock('@qwen-code/acp-bridge/spawnChannel', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@qwen-code/acp-bridge/spawnChannel')>();
|
||||
return {
|
||||
...actual,
|
||||
createSpawnChannelFactory: vi.fn(
|
||||
(options: Record<string, unknown> = {}) => {
|
||||
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'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -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<ServeSessionSupportedCommandsStatus>;
|
||||
sessionTasksImpl?: (sessionId: string) => Promise<ServeSessionTasksStatus>;
|
||||
sessionLspImpl?: (sessionId: string) => Promise<ServeSessionLspStatus>;
|
||||
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 () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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<DaemonSessionLspStatus> {
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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<DaemonSessionLspStatus> {
|
||||
return await this.client.sessionLspStatus(this.sessionId, this.clientId);
|
||||
}
|
||||
|
||||
async cancelTask(
|
||||
taskId: string,
|
||||
kind: DaemonSessionTaskStatus['kind'],
|
||||
|
|
|
|||
|
|
@ -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/*) ---------------------
|
||||
|
||||
|
|
|
|||
|
|
@ -355,6 +355,8 @@ export type {
|
|||
DaemonSessionProcessTaskLifecycleStatus,
|
||||
DaemonSessionContextUsage,
|
||||
DaemonSessionContextUsageStatus,
|
||||
DaemonLspServerStatus,
|
||||
DaemonSessionLspStatus,
|
||||
DaemonSessionState,
|
||||
DaemonSessionSummary,
|
||||
DaemonSessionShellTaskStatus,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -101,6 +101,8 @@ export {
|
|||
type DaemonSessionClosedReason,
|
||||
type DaemonSessionClientOptions,
|
||||
type DaemonSessionContextStatus,
|
||||
type DaemonLspServerStatus,
|
||||
type DaemonSessionLspStatus,
|
||||
type DaemonSessionAgentTaskStatus,
|
||||
type DaemonSessionMonitorTaskStatus,
|
||||
type DaemonSessionProcessTaskLifecycleStatus,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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<DaemonSessionRecapResult>().not.toBeNever();
|
||||
expectTypeOf<DaemonLspServerStatus>().not.toBeNever();
|
||||
expectTypeOf<DaemonSessionLspStatus>().not.toBeNever();
|
||||
});
|
||||
|
||||
it('exposes the PR 21 auth device-flow surface at the public entry', () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue