Skip to content

Commit b7fd920

Browse files
committed
feat(serve): add workspace tool toggle route (#4175 Wave 4 PR 17)
Adds POST /workspace/tools/:name/enable — strict-gated mutation route that toggles a tool name in the workspace's `tools.disabled` settings list. Pure file IO + workspace-scoped event fan-out; no ACP roundtrip. - Bridge `setWorkspaceToolEnabled(toolName, enabled, originatorClientId)` invokes the new `BridgeOptions.persistDisabledTools` callback. The default `runQwenServe` wires it to `loadSettings(workspace).setValue( 'tools.disabled', merged)` with a fresh load on each call so concurrent edits from other writers stay safe across the read/modify/write window - New private `broadcastWorkspaceEvent` helper fan-outs to every live session SSE bus, swallowing per-bus errors so a single torn-down session can't block its peers. Naming mirrors PR 21 #4255 (the post- PR-16 fold-in will collapse the two helpers) - Unknown tool names are accepted: the daemon has no authoritative tool registry to validate against (built-ins live inside the ACP child, MCP tools are discovered post-spawn). Pre-disabling a not-yet-installed MCP tool is a legitimate use case - Live ACP children retain already-registered tools — the toggle takes effect on the next ACP child spawn (`tools.disabled` is consulted at Config construction time, gated in ToolRegistry.registerTool by PR 17 commit 2) SDK additions: - `DaemonClient.setWorkspaceToolEnabled(toolName, enabled, clientId?)` with URL-encoded tool name - `DaemonToolToggleResult` + `DaemonToolToggledEvent` typed event, reducer integration on `DaemonSessionViewState` (`toolToggleCount` / `lastToolToggle`) - `asKnownDaemonEvent` runtime guard for `tool_toggled` AND `approval_mode_changed` (the latter was missed in commit 3 — without this entry the events were silently filed as `unrecognizedKnownEvent` by `reduceDaemonSessionEvent`, never reaching the typed reducer cases) New capability tag `workspace_tool_toggle` (always-on, since v1). 🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)
1 parent 9f243f4 commit b7fd920

13 files changed

Lines changed: 565 additions & 13 deletions

File tree

integration-tests/cli/qwen-serve-routes.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,7 @@ describe('qwen serve — capabilities envelope', () => {
225225
'workspace_file_read',
226226
// #4175 Wave 4 PR 17.
227227
'session_approval_mode_control',
228+
'workspace_tool_toggle',
228229
]);
229230
});
230231
});

packages/cli/src/serve/capabilities.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,14 @@ export const SERVE_CAPABILITY_REGISTRY = {
115115
// settings via the daemon's `loadedSettings` handle. SDK helper:
116116
// `DaemonClient.setSessionApprovalMode`.
117117
session_approval_mode_control: { since: 'v1' },
118+
// #4175 Wave 4 PR 17. `POST /workspace/tools/:name/enable` toggles a
119+
// tool name in the workspace's `tools.disabled` settings list. The
120+
// bridge writes the settings file directly (no ACP roundtrip) and
121+
// fan-outs a `tool_toggled` event to all live session SSE buses.
122+
// Already-registered tools in active sessions are NOT retroactively
123+
// unregistered — the toggle takes effect on the next ACP child spawn
124+
// (`tools.disabled` is consulted at `Config` construction time).
125+
workspace_tool_toggle: { since: 'v1' },
118126
// Issue #4175 PR 15. Daemon was booted with `--require-auth` (or
119127
// `requireAuth: true`), so even loopback callers must carry a bearer
120128
// token. Advertised CONDITIONALLY — only when the flag is on — so

packages/cli/src/serve/httpAcpBridge.test.ts

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4278,6 +4278,127 @@ describe('createHttpAcpBridge', () => {
42784278
});
42794279
});
42804280

4281+
describe('setWorkspaceToolEnabled (#4175 Wave 4 PR 17)', () => {
4282+
it('throws when no persistDisabledTools callback is wired', async () => {
4283+
const bridge = makeBridge();
4284+
await expect(
4285+
bridge.setWorkspaceToolEnabled('Bash', false, undefined),
4286+
).rejects.toThrow(/persistDisabledTools/);
4287+
});
4288+
4289+
it('invokes the persist callback with the workspace + name + enabled flag', async () => {
4290+
const calls: Array<{
4291+
workspace: string;
4292+
toolName: string;
4293+
enabled: boolean;
4294+
}> = [];
4295+
const bridge = makeBridge({
4296+
persistDisabledTools: async (workspace, toolName, enabled) => {
4297+
calls.push({ workspace, toolName, enabled });
4298+
},
4299+
});
4300+
const result = await bridge.setWorkspaceToolEnabled(
4301+
'Bash',
4302+
false,
4303+
undefined,
4304+
);
4305+
expect(result).toEqual({ toolName: 'Bash', enabled: false });
4306+
expect(calls).toEqual([
4307+
{ workspace: WS_A, toolName: 'Bash', enabled: false },
4308+
]);
4309+
});
4310+
4311+
it('does NOT spawn an ACP child even when called repeatedly', async () => {
4312+
let factoryCalls = 0;
4313+
const bridge = makeBridge({
4314+
channelFactory: async () => {
4315+
factoryCalls += 1;
4316+
throw new Error('channel factory should not be invoked');
4317+
},
4318+
persistDisabledTools: async () => {},
4319+
});
4320+
await bridge.setWorkspaceToolEnabled('Bash', false, undefined);
4321+
await bridge.setWorkspaceToolEnabled('Read', true, undefined);
4322+
expect(factoryCalls).toBe(0);
4323+
});
4324+
4325+
it('fan-outs tool_toggled events to every live session bus', async () => {
4326+
const factory: ChannelFactory = async () => {
4327+
const { clientStream, agentStream } = createInMemoryChannel();
4328+
new AgentSideConnection(() => new FakeAgent() as Agent, agentStream);
4329+
return {
4330+
stream: clientStream,
4331+
exited: new Promise<
4332+
| { exitCode: number | null; signalCode: NodeJS.Signals | null }
4333+
| undefined
4334+
>(() => {}),
4335+
kill: async () => {},
4336+
killSync: () => {},
4337+
};
4338+
};
4339+
const bridge = makeBridge({
4340+
channelFactory: factory,
4341+
persistDisabledTools: async () => {},
4342+
});
4343+
// Two thread-scope sessions on the same workspace, so both
4344+
// entries live in the byId map and both should observe the
4345+
// workspace-scoped fan-out.
4346+
const a = await bridge.spawnOrAttach({
4347+
workspaceCwd: WS_A,
4348+
sessionScope: 'thread',
4349+
});
4350+
const b = await bridge.spawnOrAttach({
4351+
workspaceCwd: WS_A,
4352+
sessionScope: 'thread',
4353+
});
4354+
const aborts = [new AbortController(), new AbortController()];
4355+
const itA = bridge
4356+
.subscribeEvents(a.sessionId, { signal: aborts[0]!.signal })
4357+
[Symbol.asyncIterator]();
4358+
const itB = bridge
4359+
.subscribeEvents(b.sessionId, { signal: aborts[1]!.signal })
4360+
[Symbol.asyncIterator]();
4361+
await bridge.setWorkspaceToolEnabled('Bash', false, undefined);
4362+
const [evA, evB] = await Promise.all([itA.next(), itB.next()]);
4363+
expect(evA.value?.type).toBe('tool_toggled');
4364+
expect(evB.value?.type).toBe('tool_toggled');
4365+
expect(evA.value?.data).toEqual({ toolName: 'Bash', enabled: false });
4366+
expect(evB.value?.data).toEqual({ toolName: 'Bash', enabled: false });
4367+
aborts.forEach((a) => a.abort());
4368+
await bridge.shutdown();
4369+
});
4370+
4371+
it('stamps tool_toggled with the originator clientId when supplied', async () => {
4372+
const factory: ChannelFactory = async () => {
4373+
const { clientStream, agentStream } = createInMemoryChannel();
4374+
new AgentSideConnection(() => new FakeAgent() as Agent, agentStream);
4375+
return {
4376+
stream: clientStream,
4377+
exited: new Promise<
4378+
| { exitCode: number | null; signalCode: NodeJS.Signals | null }
4379+
| undefined
4380+
>(() => {}),
4381+
kill: async () => {},
4382+
killSync: () => {},
4383+
};
4384+
};
4385+
const bridge = makeBridge({
4386+
channelFactory: factory,
4387+
persistDisabledTools: async () => {},
4388+
});
4389+
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
4390+
const abort = new AbortController();
4391+
const it = bridge
4392+
.subscribeEvents(session.sessionId, { signal: abort.signal })
4393+
[Symbol.asyncIterator]();
4394+
await bridge.setWorkspaceToolEnabled('Bash', false, session.clientId);
4395+
const next = await it.next();
4396+
expect(next.value?.originatorClientId).toBe(session.clientId);
4397+
abort.abort();
4398+
await bridge.shutdown();
4399+
});
4400+
});
4401+
42814402
describe('subscribeEvents', () => {
42824403
it('throws SessionNotFoundError for unknown session ids', () => {
42834404
const bridge = makeBridge({

packages/cli/src/serve/httpAcpBridge.ts

Lines changed: 92 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -44,12 +44,8 @@ import {
4444
type ServeWorkspaceSkillsStatus,
4545
} from './status.js';
4646
import { buildEnvStatusFromProcess } from './envSnapshot.js';
47-
import type {
48-
ApprovalMode} from '@qwen-code/qwen-code-core';
49-
import {
50-
TrustGateError,
51-
canUseRipgrep,
52-
} from '@qwen-code/qwen-code-core';
47+
import type { ApprovalMode } from '@qwen-code/qwen-code-core';
48+
import { TrustGateError, canUseRipgrep } from '@qwen-code/qwen-code-core';
5349
import { getGitVersion, getNpmVersion } from '../utils/systemInfo.js';
5450
import type {
5551
CancelNotification,
@@ -462,6 +458,30 @@ export interface HttpAcpBridge {
462458
persisted: boolean;
463459
}>;
464460

461+
/**
462+
* Add or remove a tool name from the workspace's `tools.disabled`
463+
* settings list and fan-out a `tool_toggled` event to every live
464+
* session SSE bus. Does NOT consult the ACP child — settings are
465+
* file IO the daemon owns directly. Already-registered tools in
466+
* active sessions stay registered until the next ACP child spawn
467+
* (`tools.disabled` is consulted at `Config` construction time, so
468+
* the toggle takes effect on the next workspace-wide refresh).
469+
*
470+
* Unknown tool names are accepted: the daemon has no authoritative
471+
* tool registry to validate against (built-ins live inside the ACP
472+
* child, MCP tools are discovered post-spawn). Pre-disabling a
473+
* not-yet-installed MCP tool is a legitimate use case.
474+
*
475+
* Throws when the daemon was constructed without a
476+
* `persistDisabledTools` callback — direct embeds / tests must opt
477+
* in to write semantics.
478+
*/
479+
setWorkspaceToolEnabled(
480+
toolName: string,
481+
enabled: boolean,
482+
originatorClientId: string | undefined,
483+
): Promise<{ toolName: string; enabled: boolean }>;
484+
465485
/**
466486
* Kill the agent process for the session and remove it from the maps.
467487
* Used by the HTTP route layer to reap orphans created when a client
@@ -848,6 +868,23 @@ export interface BridgeOptions {
848868
boundWorkspace: string,
849869
mode: ApprovalMode,
850870
) => Promise<void>;
871+
/**
872+
* #4175 Wave 4 PR 17 — optional callback for mutating
873+
* `tools.disabled` in workspace settings. Invoked by
874+
* `setWorkspaceToolEnabled` to add (`enabled: false`) or remove
875+
* (`enabled: true`) `toolName` from the persisted disabled set.
876+
* The default `runQwenServe` wires this to a fresh
877+
* `loadSettings(boundWorkspace)` per call so concurrent edits from
878+
* other writers (CLI, another daemon, an editor) are picked up.
879+
* Bridge tests / embedded callers may omit it; without the hook
880+
* `setWorkspaceToolEnabled` throws a clear error rather than
881+
* silently dropping the write.
882+
*/
883+
persistDisabledTools?: (
884+
boundWorkspace: string,
885+
toolName: string,
886+
enabled: boolean,
887+
) => Promise<void>;
851888
}
852889

853890
/**
@@ -1639,6 +1676,7 @@ export function createHttpAcpBridge(opts: BridgeOptions): HttpAcpBridge {
16391676
}
16401677
const boundWorkspace = opts.boundWorkspace;
16411678
const persistApprovalMode = opts.persistApprovalMode;
1679+
const persistDisabledTools = opts.persistDisabledTools;
16421680

16431681
// #3803 §02 single-workspace model: the bridge hosts AT MOST one
16441682
// ATTACH-AVAILABLE channel and one default attach-target entry.
@@ -2421,6 +2459,32 @@ export function createHttpAcpBridge(opts: BridgeOptions): HttpAcpBridge {
24212459
return response as unknown as T;
24222460
};
24232461

2462+
/**
2463+
* Fan-out an event to every live session bus in the daemon. Used by
2464+
* workspace-scoped mutation routes (#4175 Wave 4 PR 17) — the
2465+
* `tool_toggled` and `mcp_server_restarted` events are not session-
2466+
* scoped, but every active SSE subscriber should still see them so
2467+
* cross-client UIs stay in sync. Errors from individual buses (e.g.
2468+
* already-closed) are swallowed — a single torn-down session must
2469+
* not block the broadcast to its peers.
2470+
*
2471+
* Named `broadcastWorkspaceEvent` to leave room for PR 16's
2472+
* forthcoming `publishWorkspaceEvent` — the post-merge fold-in
2473+
* collapses the two helpers (PR 21 #4255 follows the same naming
2474+
* convention to ease that consolidation).
2475+
*/
2476+
const broadcastWorkspaceEvent = (
2477+
envelope: Omit<BridgeEvent, 'id' | 'v'>,
2478+
): void => {
2479+
for (const entry of byId.values()) {
2480+
try {
2481+
entry.events.publish(envelope);
2482+
} catch {
2483+
/* bus closed for this session; skip */
2484+
}
2485+
}
2486+
};
2487+
24242488
const createSessionEntry = (
24252489
ci: ChannelInfo,
24262490
sessionId: string,
@@ -3629,6 +3693,28 @@ export function createHttpAcpBridge(opts: BridgeOptions): HttpAcpBridge {
36293693
};
36303694
},
36313695

3696+
async setWorkspaceToolEnabled(toolName, enabled, originatorClientId) {
3697+
// #4175 Wave 4 PR 17. Pure file IO + event fan-out — no ACP
3698+
// roundtrip. The settings file is the source of truth; live
3699+
// sessions retain their already-registered tools until the next
3700+
// ACP child spawn (when `tools.disabled` is consulted at Config
3701+
// construction time).
3702+
if (!persistDisabledTools) {
3703+
throw new Error(
3704+
'setWorkspaceToolEnabled requires `persistDisabledTools` in ' +
3705+
'BridgeOptions; runQwenServe wires the production callback. ' +
3706+
'Direct embeds and tests must opt in.',
3707+
);
3708+
}
3709+
await persistDisabledTools(boundWorkspace, toolName, enabled);
3710+
broadcastWorkspaceEvent({
3711+
type: 'tool_toggled',
3712+
data: { toolName, enabled },
3713+
...(originatorClientId ? { originatorClientId } : {}),
3714+
});
3715+
return { toolName, enabled };
3716+
},
3717+
36323718
async killSession(sessionId, opts) {
36333719
const entry = byId.get(sessionId);
36343720
if (!entry) return;

packages/cli/src/serve/runQwenServe.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,26 @@ export async function runQwenServe(
288288
const fresh = loadSettings(workspace);
289289
fresh.setValue(SettingScope.Workspace, 'tools.approvalMode', mode);
290290
},
291+
// #4175 Wave 4 PR 17: `POST /workspace/tools/:name/enable` writes
292+
// through this callback. Re-reads settings on each call (same
293+
// freshness rationale as `persistApprovalMode`) and merges into
294+
// the existing `tools.disabled` array — concurrent toggles from
295+
// other writers stay safe across the read/modify/write window.
296+
persistDisabledTools: async (workspace, toolName, enabled) => {
297+
const fresh = loadSettings(workspace);
298+
const merged = fresh.merged.tools?.disabled;
299+
const current = Array.isArray(merged)
300+
? merged.filter((v): v is string => typeof v === 'string')
301+
: [];
302+
const next = new Set(current);
303+
if (enabled) next.delete(toolName);
304+
else next.add(toolName);
305+
fresh.setValue(
306+
SettingScope.Workspace,
307+
'tools.disabled',
308+
[...next].sort(),
309+
);
310+
},
291311
});
292312
let actualPort = opts.port;
293313
// Pass the already-canonical `boundWorkspace` into `createServeApp`

0 commit comments

Comments
 (0)