@dotdrelle/wiki-manager 0.12.0 → 0.12.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/agent/graph.js +36 -8
- package/src/agent/graph.test.js +47 -0
- package/src/contracts/schemas.test.js +2 -2
- package/src/core/mcp.js +30 -3
- package/src/core/mcp.test.js +69 -1
- package/src/runtime/controlMessages.js +43 -0
- package/src/runtime/controlMessages.test.js +21 -0
- package/src/runtime/donna-contract.test.js +3 -3
- package/src/runtime/server.js +7 -6
- package/src/runtime/server.test.js +61 -61
- package/src/runtime/store.test.js +24 -24
- package/src/shell/repl.js +3 -2
- package/src/shell/repl.test.js +1 -1
- package/src/shell/useAgent.ts +5 -1
- package/src/shell/useSession.ts +37 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotdrelle/wiki-manager",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.1",
|
|
4
4
|
"description": "Agentic shell and orchestration cockpit for llm-wiki workspaces.",
|
|
5
5
|
"license": "PolyForm-Noncommercial-1.0.0",
|
|
6
6
|
"author": "dotrelle",
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
},
|
|
12
12
|
"scripts": {
|
|
13
13
|
"start": "bun ./bin/wiki-manager.js",
|
|
14
|
-
"test": "node --test src/agent/graph.test.js src/contracts/schemas.test.js src/core/activity.test.js src/core/agentEvents.test.js src/core/runtimeLog.test.js src/activity/activityAggregator.test.js src/graph/runGraphProjector.test.js src/core/workflow.test.js src/core/planPatch.test.js src/core/agentLoop.test.js src/core/plan.test.js src/core/mcp.test.js src/core/documentIntake.test.js src/core/dockerCompose.test.js src/core/wikiWorkspace.test.js src/core/wikirc.test.js src/core/modelFetch.test.js src/core/startupCheck.test.js src/core/queueStore.test.js src/orchestrator/agentRegistry.test.js src/orchestrator/capabilityRegistry.test.js src/orchestrator/capabilityResolver.test.js src/orchestrator/planValidator.test.js src/orchestrator/planIntegrator.test.js src/orchestrator/scheduler.test.js src/orchestrator/attemptManager.test.js src/orchestrator/resultAggregator.test.js src/orchestrator/approvalPolicy.test.js src/commands/slash.test.js src/shell/repl.test.js src/runtime/store.test.js src/runtime/recoveryManager.test.js src/runtime/server.test.js src/runtime/supervisor.test.js src/runtime/runner.test.js src/runtime/runner.e2e.test.js src/runtime/donna-contract.test.js src/runtime/auth.test.js",
|
|
14
|
+
"test": "node --test src/agent/graph.test.js src/contracts/schemas.test.js src/core/activity.test.js src/core/agentEvents.test.js src/core/runtimeLog.test.js src/activity/activityAggregator.test.js src/graph/runGraphProjector.test.js src/core/workflow.test.js src/core/planPatch.test.js src/core/agentLoop.test.js src/core/plan.test.js src/core/mcp.test.js src/core/documentIntake.test.js src/core/dockerCompose.test.js src/core/wikiWorkspace.test.js src/core/wikirc.test.js src/core/modelFetch.test.js src/core/startupCheck.test.js src/core/queueStore.test.js src/orchestrator/agentRegistry.test.js src/orchestrator/capabilityRegistry.test.js src/orchestrator/capabilityResolver.test.js src/orchestrator/planValidator.test.js src/orchestrator/planIntegrator.test.js src/orchestrator/scheduler.test.js src/orchestrator/attemptManager.test.js src/orchestrator/resultAggregator.test.js src/orchestrator/approvalPolicy.test.js src/commands/slash.test.js src/shell/repl.test.js src/runtime/store.test.js src/runtime/controlMessages.test.js src/runtime/recoveryManager.test.js src/runtime/server.test.js src/runtime/supervisor.test.js src/runtime/runner.test.js src/runtime/runner.e2e.test.js src/runtime/donna-contract.test.js src/runtime/auth.test.js",
|
|
15
15
|
"check-versions": "node scripts/check-versions.js",
|
|
16
16
|
"prepack": "node scripts/check-versions.js",
|
|
17
17
|
"prepublishOnly": "node scripts/check-versions.js",
|
package/src/agent/graph.js
CHANGED
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
callMcpTool,
|
|
6
6
|
formatMcpToolResult,
|
|
7
7
|
formatMcpToolsForAgent,
|
|
8
|
-
|
|
8
|
+
resolveToolCallName,
|
|
9
9
|
} from '../core/mcp.js';
|
|
10
10
|
import { formatSkillsForAgent, readOptionalText } from '../core/skills.js';
|
|
11
11
|
import { handleSlashCommand } from '../commands/slash.js';
|
|
@@ -18,6 +18,14 @@ const MAX_TOOL_ITERATIONS = 80;
|
|
|
18
18
|
const MAX_SPINNER_ARG_LENGTH = 96;
|
|
19
19
|
const MAX_PROFILE_CHARS = 4000;
|
|
20
20
|
|
|
21
|
+
// Pseudo-servers handled directly by the tool executor (not present in
|
|
22
|
+
// session.mcp). Listed so unqualified names like "plan_set" resolve the same
|
|
23
|
+
// way as MCP tools in resolveToolCallName.
|
|
24
|
+
const INTERNAL_TOOL_SERVERS = {
|
|
25
|
+
wiki: ['plan_set', 'plan_done'],
|
|
26
|
+
shell: ['run_command', 'read_command', 'profile_update'],
|
|
27
|
+
};
|
|
28
|
+
|
|
21
29
|
const AGENT_SLASH_COMMANDS = new Set([
|
|
22
30
|
'help',
|
|
23
31
|
'version',
|
|
@@ -562,11 +570,11 @@ export function buildAgentSystemPrompt(state) {
|
|
|
562
570
|
].filter(Boolean).join('\n'),
|
|
563
571
|
'For service actions, recommend only available service primitives from Available primitives, with the exact service name when the primitive supports one.',
|
|
564
572
|
'Disambiguate export requests carefully.',
|
|
565
|
-
'Confluence/CME/source export means exporting external Confluence sources into raw/untracked: use cme MCP tools (`
|
|
566
|
-
'Wiki/deliverable/publication export means exporting generated deliverables from the wiki: use production MCP tools (`
|
|
567
|
-
'For ingest/build/export/polish/pipeline workflows, use production MCP tools. Do not route these through direct /wiki shortcuts. To chain multiple sequential steps (e.g. build then polish), always use a single
|
|
573
|
+
'Confluence/CME/source export means exporting external Confluence sources into raw/untracked: use cme MCP tools (`cme__cme_export_run`, then `cme__cme_export_status`). Never use production `type=export` for Confluence source export.',
|
|
574
|
+
'Wiki/deliverable/publication export means exporting generated deliverables from the wiki: use production MCP tools (`production__production_start_job` with `type:"export"` or pipeline steps). Require the deliverable path when exporting deliverables.',
|
|
575
|
+
'For ingest/build/export/polish/pipeline workflows, use production MCP tools. Do not route these through direct /wiki shortcuts. To chain multiple sequential steps (e.g. build then polish), always use a single production__production_start_job call with type="pipeline" and steps=["build","polish"] — never start them as separate jobs: the first job is asynchronous and the second would run before it completes. For existing deliverables where content stability matters, pass stabilize:true so the build step preserves unchanged sections; keep polish in the pipeline when publication output is requested. Do not ask the user to confirm between steps; start the pipeline call directly.',
|
|
568
576
|
'Long-running MCP jobs: do not call the same status tool more than once consecutively. When chaining jobs sequentially: (1) start the job, report job/activity id and status; (2) check status once — if done, proceed to the next step immediately; (3) if still running, report status, list the remaining steps, and return control; (4) when re-invoked, check status first, then proceed. Do not spin-poll (status → status → status with no new action between). The shell activity panel monitors non-terminal jobs automatically.',
|
|
569
|
-
'If
|
|
577
|
+
'If production__production_start_job is returned as queued/waiting by the manager, report that it is waiting in the local queue and return control. Do not continue as if the production job has started.',
|
|
570
578
|
'For diagnostics, use /wiki run doctor when the user asks for doctor. Use /workspace init <name> [path] for low-level non-interactive workspace creation. In the interactive TUI, /new <name> opens the setup wizard. Use /wiki for index, or /wiki run index through the explicit backup hatch. Use /wiki run init only for explicit current-workspace llm-wiki init.',
|
|
571
579
|
'If an action requires tools or skills not available yet, explain the limitation and name the expected primitive.',
|
|
572
580
|
workspaceProfile
|
|
@@ -771,11 +779,19 @@ export function createAgentGraph(options = {}) {
|
|
|
771
779
|
const toolResultMessages = [];
|
|
772
780
|
|
|
773
781
|
for (const call of toolCalls) {
|
|
774
|
-
const
|
|
782
|
+
const resolved = resolveToolCallName(state.session.mcp, call.function.name, INTERNAL_TOOL_SERVERS);
|
|
783
|
+
const { server, tool } = resolved;
|
|
775
784
|
const argsSummary = summarizeToolArguments(call.function.arguments);
|
|
776
785
|
const isInternalWikiTool = server === 'wiki' && (tool === 'plan_set' || tool === 'plan_done');
|
|
777
786
|
const serverLabel = server === 'shell' ? 'Shell' : isInternalWikiTool ? 'Plan' : 'MCP';
|
|
778
|
-
const toolName = `${server}.${tool}
|
|
787
|
+
const toolName = server ? `${server}.${tool}` : call.function.name;
|
|
788
|
+
if (resolved.normalized) {
|
|
789
|
+
// Keep normalizations visible: the defensive routing must not hide
|
|
790
|
+
// prompt/skill regressions that reintroduce unqualified names.
|
|
791
|
+
state.session._onStep?.(
|
|
792
|
+
`tool name normalized: ${call.function.name} -> ${server}__${tool}`,
|
|
793
|
+
);
|
|
794
|
+
}
|
|
779
795
|
state.session._onStep?.(
|
|
780
796
|
`[${state.toolIterations}/${MAX_TOOL_ITERATIONS}] ${serverLabel} ${toolName}${argsSummary ? ` (${argsSummary})` : ''}`,
|
|
781
797
|
);
|
|
@@ -796,6 +812,18 @@ export function createAgentGraph(options = {}) {
|
|
|
796
812
|
let resultText;
|
|
797
813
|
let ok = true;
|
|
798
814
|
try {
|
|
815
|
+
if (!server) {
|
|
816
|
+
if (resolved.candidates.length > 1) {
|
|
817
|
+
throw new Error(
|
|
818
|
+
`Ambiguous unqualified tool name "${call.function.name}": several connected servers expose it. `
|
|
819
|
+
+ `Use the <server>__<tool> form: ${resolved.candidates.map((s) => `${s}__${tool}`).join(', ')}.`,
|
|
820
|
+
);
|
|
821
|
+
}
|
|
822
|
+
throw new Error(
|
|
823
|
+
`Unqualified tool call name "${call.function.name}". Tool calls must use the <server>__<tool> `
|
|
824
|
+
+ `naming convention (e.g. production__production_start_job); no connected server exposes a tool named "${tool}".`,
|
|
825
|
+
);
|
|
826
|
+
}
|
|
799
827
|
let args = JSON.parse(call.function.arguments ?? '{}');
|
|
800
828
|
if (server === 'production' && tool === 'production_start_job' && state.session.workspace && !args.callerLabel) {
|
|
801
829
|
args = { ...args, callerLabel: `${state.session.workspace}/wiki-manager` };
|
|
@@ -869,7 +897,7 @@ export function createAgentGraph(options = {}) {
|
|
|
869
897
|
err.name === 'ApprovalError'
|
|
870
898
|
) throw err;
|
|
871
899
|
ok = false;
|
|
872
|
-
resultText = `Error [${
|
|
900
|
+
resultText = `Error [${toolName}]: ${err instanceof Error ? err.message : String(err)}`;
|
|
873
901
|
if (minimalPlanActive && state.session.headlessPlan?.[0]?._activityKey === null) {
|
|
874
902
|
emitAgentEvent(state.session, 'plan_step_updated', 'tool', { step: 1, status: 'failed' });
|
|
875
903
|
}
|
package/src/agent/graph.test.js
CHANGED
|
@@ -387,3 +387,50 @@ test('buildAgentSystemPrompt forbids inventing slash commands or arguments', ()
|
|
|
387
387
|
assert.doesNotMatch(prompt, /executorQuery/);
|
|
388
388
|
assert.doesNotMatch(prompt, /executor:"/);
|
|
389
389
|
});
|
|
390
|
+
|
|
391
|
+
// Guard: the system prompt must never show a connected tool's bare name
|
|
392
|
+
// outside its qualified server__tool form. Bare mentions are what teach the
|
|
393
|
+
// model to emit unqualified tool calls (the cme_status incident). The bare
|
|
394
|
+
// name list comes from the session's declared servers, never from a manual
|
|
395
|
+
// list (amendment A6). New prompt text or injected skill descriptions that
|
|
396
|
+
// reintroduce a bare name must fail here.
|
|
397
|
+
test('buildAgentSystemPrompt contains no unqualified tool names for connected servers', () => {
|
|
398
|
+
const session = sessionBase({
|
|
399
|
+
mcp: {
|
|
400
|
+
production: {
|
|
401
|
+
status: 'connected',
|
|
402
|
+
tools: [
|
|
403
|
+
{ name: 'production_start_job' }, { name: 'production_job_status' },
|
|
404
|
+
{ name: 'production_job_logs' }, { name: 'production_cancel_job' },
|
|
405
|
+
{ name: 'production_list_jobs' }, { name: 'production_list_templates' },
|
|
406
|
+
{ name: 'production_status' }, { name: 'agent_describe' },
|
|
407
|
+
{ name: 'agent_plan' }, { name: 'agent_execute' },
|
|
408
|
+
{ name: 'agent_status' }, { name: 'agent_cancel' },
|
|
409
|
+
],
|
|
410
|
+
},
|
|
411
|
+
cme: {
|
|
412
|
+
status: 'connected',
|
|
413
|
+
tools: [
|
|
414
|
+
{ name: 'cme_status' }, { name: 'cme_setup' },
|
|
415
|
+
{ name: 'cme_sources_list' }, { name: 'cme_source_add' },
|
|
416
|
+
{ name: 'cme_source_remove' }, { name: 'cme_export_run' },
|
|
417
|
+
{ name: 'cme_export_status' }, { name: 'cme_export_cancel' },
|
|
418
|
+
{ name: 'agent_describe' }, { name: 'agent_execute' },
|
|
419
|
+
{ name: 'agent_status' }, { name: 'agent_cancel' },
|
|
420
|
+
],
|
|
421
|
+
},
|
|
422
|
+
},
|
|
423
|
+
});
|
|
424
|
+
const prompt = buildAgentSystemPrompt({ session });
|
|
425
|
+
const offenders = [];
|
|
426
|
+
for (const [serverName, value] of Object.entries(session.mcp)) {
|
|
427
|
+
for (const tool of value.tools) {
|
|
428
|
+
// A bare occurrence is the tool name not embedded in a wider
|
|
429
|
+
// identifier: `production__production_start_job` does not match
|
|
430
|
+
// because the inner occurrence is preceded by `_`.
|
|
431
|
+
const bare = new RegExp(`(?<![\\w])${tool.name}(?![\\w])`);
|
|
432
|
+
if (bare.test(prompt)) offenders.push(`${serverName}:${tool.name}`);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
assert.deepEqual(offenders, [], `Unqualified tool names found in system prompt: ${offenders.join(', ')}`);
|
|
436
|
+
});
|
|
@@ -50,7 +50,7 @@ test('agent event contract carries the unified audit identity fields', () => {
|
|
|
50
50
|
runId: 'run-1',
|
|
51
51
|
turnId: 'turn-1',
|
|
52
52
|
taskId: 'task-1',
|
|
53
|
-
workspace: '
|
|
53
|
+
workspace: 'acme',
|
|
54
54
|
payload: { toolCallId: 'call-1', ok: true },
|
|
55
55
|
};
|
|
56
56
|
|
|
@@ -58,7 +58,7 @@ test('agent event contract carries the unified audit identity fields', () => {
|
|
|
58
58
|
});
|
|
59
59
|
|
|
60
60
|
test('run and control request contracts reject empty run input but accept explicit controls', () => {
|
|
61
|
-
assert.equal(validateContract('runRequest', { input: 'Build docs', workspace: '
|
|
61
|
+
assert.equal(validateContract('runRequest', { input: 'Build docs', workspace: 'acme' }).ok, true);
|
|
62
62
|
assert.equal(validateContract('runRequest', { input: '' }).ok, false);
|
|
63
63
|
assert.equal(validateContract('controlMessage', { action: 'message', input: 'Ou en est le build ?', intent: 'observe' }).ok, true);
|
|
64
64
|
});
|
package/src/core/mcp.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs';
|
|
2
2
|
import { managerEnvFile, managerMcpEndpointsFile, readEnvFile } from './env.js';
|
|
3
3
|
|
|
4
|
-
const WIKI_MANAGER_VERSION = '0.12.
|
|
4
|
+
const WIKI_MANAGER_VERSION = '0.12.1';
|
|
5
5
|
|
|
6
6
|
function envValue(key) {
|
|
7
7
|
const filePath = managerEnvFile();
|
|
@@ -174,7 +174,7 @@ function clarifyToolDescription(serverName, toolName, description) {
|
|
|
174
174
|
if (serverName === 'production' && toolName === 'production_start_job') {
|
|
175
175
|
return compactDescription([
|
|
176
176
|
base,
|
|
177
|
-
'Production export means wiki deliverable/publication export only. Do not use type=export for Confluence/CME/source export; use
|
|
177
|
+
'Production export means wiki deliverable/publication export only. Do not use type=export for Confluence/CME/source export; use cme__cme_export_run instead.',
|
|
178
178
|
].filter(Boolean).join(' '));
|
|
179
179
|
}
|
|
180
180
|
return base;
|
|
@@ -469,7 +469,9 @@ export function formatMcpToolsForAgent(mcpStatus) {
|
|
|
469
469
|
sections.push(`${name}: connected, tools not discovered yet`);
|
|
470
470
|
continue;
|
|
471
471
|
}
|
|
472
|
-
|
|
472
|
+
// Always advertise the qualified call name (server__tool): showing bare
|
|
473
|
+
// tool names here is what teaches the model to emit unqualified calls.
|
|
474
|
+
sections.push(`${name}: ${tools.map((tool) => `${name}__${tool.name}`).join(', ')}`);
|
|
473
475
|
}
|
|
474
476
|
return sections.length > 0 ? sections.join('\n') : 'No connected MCP tools discovered yet.';
|
|
475
477
|
}
|
|
@@ -498,6 +500,31 @@ export function parseToolCallName(name) {
|
|
|
498
500
|
return { server: name.slice(0, sep), tool: name.slice(sep + 2) };
|
|
499
501
|
}
|
|
500
502
|
|
|
503
|
+
// Deterministic recovery for unqualified tool-call names emitted by the LLM
|
|
504
|
+
// (e.g. "cme_status" instead of "cme__cme_status"). Exact-name match only:
|
|
505
|
+
// if exactly one connected server (or extra pseudo-server) exposes the bare
|
|
506
|
+
// tool name, route to it and report `normalized: true`; otherwise return
|
|
507
|
+
// `server: null` with the list of candidate servers so the caller can raise
|
|
508
|
+
// an explicit error. This is name normalization, never fuzzy matching — do
|
|
509
|
+
// not extend it to description/similarity-based selection (plan directeur
|
|
510
|
+
// §20 forbids that).
|
|
511
|
+
export function resolveToolCallName(mcpStatus, name, extraServers = {}) {
|
|
512
|
+
const parsed = parseToolCallName(name);
|
|
513
|
+
if (parsed.server) return { ...parsed, normalized: false, candidates: [] };
|
|
514
|
+
const candidates = [];
|
|
515
|
+
for (const [serverName, toolNames] of Object.entries(extraServers)) {
|
|
516
|
+
if (toolNames.includes(parsed.tool)) candidates.push(serverName);
|
|
517
|
+
}
|
|
518
|
+
for (const [serverName, value] of Object.entries(mcpStatus ?? {})) {
|
|
519
|
+
if (value.status !== 'connected') continue;
|
|
520
|
+
if ((value.tools ?? []).some((tool) => tool.name === parsed.tool)) candidates.push(serverName);
|
|
521
|
+
}
|
|
522
|
+
if (candidates.length === 1) {
|
|
523
|
+
return { server: candidates[0], tool: parsed.tool, normalized: true, candidates };
|
|
524
|
+
}
|
|
525
|
+
return { server: null, tool: parsed.tool, normalized: false, candidates };
|
|
526
|
+
}
|
|
527
|
+
|
|
501
528
|
export function mcpStatusMarker(status) {
|
|
502
529
|
if (status === 'connected') return '●';
|
|
503
530
|
if (status === 'configured') return '◐';
|
package/src/core/mcp.test.js
CHANGED
|
@@ -3,7 +3,75 @@ import assert from 'node:assert/strict';
|
|
|
3
3
|
import { mkdtemp, writeFile } from 'node:fs/promises';
|
|
4
4
|
import os from 'node:os';
|
|
5
5
|
import path from 'node:path';
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
buildMcpStatus,
|
|
8
|
+
callMcpTool,
|
|
9
|
+
discoverMcpTools,
|
|
10
|
+
formatMcpToolsForAgent,
|
|
11
|
+
resolveRetryPolicy,
|
|
12
|
+
resolveToolCallName,
|
|
13
|
+
} from './mcp.js';
|
|
14
|
+
|
|
15
|
+
const resolveFixtureStatus = {
|
|
16
|
+
production: {
|
|
17
|
+
status: 'connected',
|
|
18
|
+
tools: [{ name: 'production_start_job' }, { name: 'agent_status' }],
|
|
19
|
+
},
|
|
20
|
+
cme: {
|
|
21
|
+
status: 'connected',
|
|
22
|
+
tools: [{ name: 'cme_status' }, { name: 'agent_status' }],
|
|
23
|
+
},
|
|
24
|
+
documents: {
|
|
25
|
+
status: 'configured', // not connected: must never be a candidate
|
|
26
|
+
tools: [{ name: 'cme_status' }],
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
test('resolveToolCallName passes qualified names through untouched', () => {
|
|
31
|
+
const resolved = resolveToolCallName(resolveFixtureStatus, 'cme__cme_status');
|
|
32
|
+
assert.deepEqual(
|
|
33
|
+
{ server: resolved.server, tool: resolved.tool, normalized: resolved.normalized },
|
|
34
|
+
{ server: 'cme', tool: 'cme_status', normalized: false },
|
|
35
|
+
);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('resolveToolCallName normalizes a bare name with exactly one connected match', () => {
|
|
39
|
+
const resolved = resolveToolCallName(resolveFixtureStatus, 'cme_status');
|
|
40
|
+
assert.deepEqual(
|
|
41
|
+
{ server: resolved.server, tool: resolved.tool, normalized: resolved.normalized },
|
|
42
|
+
{ server: 'cme', tool: 'cme_status', normalized: true },
|
|
43
|
+
);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('resolveToolCallName refuses ambiguous bare names and reports candidates', () => {
|
|
47
|
+
const resolved = resolveToolCallName(resolveFixtureStatus, 'agent_status');
|
|
48
|
+
assert.equal(resolved.server, null);
|
|
49
|
+
assert.equal(resolved.normalized, false);
|
|
50
|
+
assert.deepEqual([...resolved.candidates].sort(), ['cme', 'production']);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('resolveToolCallName returns no server for unknown bare names', () => {
|
|
54
|
+
const resolved = resolveToolCallName(resolveFixtureStatus, 'does_not_exist');
|
|
55
|
+
assert.equal(resolved.server, null);
|
|
56
|
+
assert.deepEqual(resolved.candidates, []);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('resolveToolCallName resolves internal pseudo-server tools via extraServers', () => {
|
|
60
|
+
const resolved = resolveToolCallName(resolveFixtureStatus, 'plan_set', { wiki: ['plan_set', 'plan_done'] });
|
|
61
|
+
assert.deepEqual(
|
|
62
|
+
{ server: resolved.server, tool: resolved.tool, normalized: resolved.normalized },
|
|
63
|
+
{ server: 'wiki', tool: 'plan_set', normalized: true },
|
|
64
|
+
);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('formatMcpToolsForAgent advertises qualified server__tool names only', () => {
|
|
68
|
+
const listing = formatMcpToolsForAgent(resolveFixtureStatus);
|
|
69
|
+
assert.match(listing, /cme__cme_status/);
|
|
70
|
+
assert.match(listing, /production__production_start_job/);
|
|
71
|
+
// No bare tool name outside a qualified form.
|
|
72
|
+
assert.doesNotMatch(listing, /(?<![\w])cme_status(?![\w])/);
|
|
73
|
+
assert.doesNotMatch(listing, /(?<![\w])production_start_job(?![\w])/);
|
|
74
|
+
});
|
|
7
75
|
|
|
8
76
|
test('buildMcpStatus reads external MCP endpoints from mcp.endpoints.json', async () => {
|
|
9
77
|
const originalCwd = process.cwd();
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Deterministic, localized messages for the runtime control lane.
|
|
2
|
+
//
|
|
3
|
+
// Control-lane acknowledgements (run queued, ambiguous input, conversation
|
|
4
|
+
// fallback…) are intentionally NOT generated by Donna: spending an LLM turn
|
|
5
|
+
// to say "your request is queued" would reintroduce exactly the per-message
|
|
6
|
+
// cost the orchestration refactor removed. But they are user-facing, so they
|
|
7
|
+
// must follow the session's configured reply language. This catalog is the
|
|
8
|
+
// single source for those strings — never hardcode a control-lane message in
|
|
9
|
+
// the shell or the server directly.
|
|
10
|
+
|
|
11
|
+
const CONTROL_MESSAGES = {
|
|
12
|
+
queued_for_future_run: {
|
|
13
|
+
en: 'Request added to the queue — it will start automatically after the current run.',
|
|
14
|
+
fr: 'Demande ajoutée à la file — elle démarrera automatiquement à la fin du run en cours.',
|
|
15
|
+
},
|
|
16
|
+
plan_patch_proposed: {
|
|
17
|
+
en: 'Plan patch proposed. Approve it explicitly to apply it to the active plan.',
|
|
18
|
+
fr: 'Modification de plan proposée. Approuvez-la explicitement pour l’appliquer au plan actif.',
|
|
19
|
+
},
|
|
20
|
+
ambiguous_control: {
|
|
21
|
+
en: 'The runtime cannot safely classify this message.',
|
|
22
|
+
fr: 'Le runtime ne peut pas classer ce message de façon sûre.',
|
|
23
|
+
},
|
|
24
|
+
converse_while_running: {
|
|
25
|
+
en: 'Runtime run is still active. This message was treated as conversation and did not create a queued run.',
|
|
26
|
+
fr: 'Un run est toujours actif. Ce message a été traité comme conversation et n’a pas créé de run en file.',
|
|
27
|
+
},
|
|
28
|
+
converse_while_idle: {
|
|
29
|
+
en: 'Runtime is idle. This message was treated as conversation and did not create a run.',
|
|
30
|
+
fr: 'Le runtime est inactif. Ce message a été traité comme conversation et n’a pas créé de run.',
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export function controlLanguage(session) {
|
|
35
|
+
const raw = String(session?.language ?? 'en').toLowerCase();
|
|
36
|
+
return raw.startsWith('fr') ? 'fr' : 'en';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function controlMessage(session, key) {
|
|
40
|
+
const entry = CONTROL_MESSAGES[key];
|
|
41
|
+
if (!entry) throw new Error(`Unknown control message key: ${key}`);
|
|
42
|
+
return entry[controlLanguage(session)] ?? entry.en;
|
|
43
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { controlLanguage, controlMessage } from './controlMessages.js';
|
|
4
|
+
|
|
5
|
+
test('controlLanguage maps fr locales to fr and everything else to en', () => {
|
|
6
|
+
assert.equal(controlLanguage({ language: 'fr-FR' }), 'fr');
|
|
7
|
+
assert.equal(controlLanguage({ language: 'fr' }), 'fr');
|
|
8
|
+
assert.equal(controlLanguage({ language: 'en-US' }), 'en');
|
|
9
|
+
assert.equal(controlLanguage({ language: null }), 'en');
|
|
10
|
+
assert.equal(controlLanguage(null), 'en');
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test('controlMessage returns the localized queued acknowledgement', () => {
|
|
14
|
+
assert.match(controlMessage({ language: 'fr-FR' }, 'queued_for_future_run'), /ajoutée à la file/);
|
|
15
|
+
assert.match(controlMessage({ language: 'en-US' }, 'queued_for_future_run'), /added to the queue/);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test('controlMessage falls back to en for unknown locales and throws on unknown keys', () => {
|
|
19
|
+
assert.match(controlMessage({ language: 'de-DE' }, 'queued_for_future_run'), /added to the queue/);
|
|
20
|
+
assert.throws(() => controlMessage({ language: 'fr-FR' }, 'nope'), /Unknown control message key/);
|
|
21
|
+
});
|
|
@@ -531,7 +531,7 @@ function toolResult(payload) {
|
|
|
531
531
|
}
|
|
532
532
|
|
|
533
533
|
test('Recipe #7 — "où en es-tu ?" during an active run: status in conversation, run continues, no new run', async (t) => {
|
|
534
|
-
const session = { workspace: '
|
|
534
|
+
const session = { workspace: 'acme', controlQueue: [] };
|
|
535
535
|
let runCount = 0;
|
|
536
536
|
let handle;
|
|
537
537
|
try {
|
|
@@ -550,7 +550,7 @@ test('Recipe #7 — "où en es-tu ?" during an active run: status in conversatio
|
|
|
550
550
|
listEvents: () => [],
|
|
551
551
|
},
|
|
552
552
|
getContext: async () => ({
|
|
553
|
-
workspace: '
|
|
553
|
+
workspace: 'acme',
|
|
554
554
|
session,
|
|
555
555
|
running: true,
|
|
556
556
|
currentAbortController: new AbortController(),
|
|
@@ -566,7 +566,7 @@ test('Recipe #7 — "où en es-tu ?" during an active run: status in conversatio
|
|
|
566
566
|
}
|
|
567
567
|
|
|
568
568
|
try {
|
|
569
|
-
const response = await fetch(`http://127.0.0.1:${handle.port}/control?workspace=
|
|
569
|
+
const response = await fetch(`http://127.0.0.1:${handle.port}/control?workspace=acme`, {
|
|
570
570
|
method: 'POST',
|
|
571
571
|
headers: { 'Content-Type': 'application/json' },
|
|
572
572
|
body: JSON.stringify({ action: 'message', input: 'où en es-tu ?' }),
|
package/src/runtime/server.js
CHANGED
|
@@ -5,6 +5,7 @@ import { activeCacertPath } from '../core/cacert.js';
|
|
|
5
5
|
import { normalizePlanPatch, rebasePlanPatch } from '../core/planPatch.js';
|
|
6
6
|
import { validateContractInDev } from '../contracts/schemas.js';
|
|
7
7
|
import { runtimeTokenFromEnv } from './auth.js';
|
|
8
|
+
import { controlMessage } from './controlMessages.js';
|
|
8
9
|
|
|
9
10
|
export function startRuntimeServer({
|
|
10
11
|
host = '127.0.0.1',
|
|
@@ -248,7 +249,7 @@ export function startRuntimeServer({
|
|
|
248
249
|
kind: 'enqueue_run',
|
|
249
250
|
item,
|
|
250
251
|
...controlStatus(context, store),
|
|
251
|
-
explanation:
|
|
252
|
+
explanation: controlMessage(context?.session, 'queued_for_future_run'),
|
|
252
253
|
});
|
|
253
254
|
return;
|
|
254
255
|
}
|
|
@@ -522,7 +523,7 @@ async function handleControlMessage(context, store, input, { intent = null, star
|
|
|
522
523
|
classification,
|
|
523
524
|
proposal,
|
|
524
525
|
...controlStatus(context, store),
|
|
525
|
-
explanation:
|
|
526
|
+
explanation: controlMessage(context?.session, 'plan_patch_proposed'),
|
|
526
527
|
},
|
|
527
528
|
};
|
|
528
529
|
}
|
|
@@ -540,12 +541,12 @@ async function handleControlMessage(context, store, input, { intent = null, star
|
|
|
540
541
|
classification,
|
|
541
542
|
item,
|
|
542
543
|
...controlStatus(context, store),
|
|
543
|
-
explanation:
|
|
544
|
+
explanation: controlMessage(context?.session, 'queued_for_future_run'),
|
|
544
545
|
},
|
|
545
546
|
};
|
|
546
547
|
}
|
|
547
548
|
if (classification.kind === 'ambiguous') {
|
|
548
|
-
return readOnlyControlResponse('ambiguous', classification, status,
|
|
549
|
+
return readOnlyControlResponse('ambiguous', classification, status, controlMessage(context?.session, 'ambiguous_control'), {
|
|
549
550
|
accepted: false,
|
|
550
551
|
extra: {
|
|
551
552
|
choices: [
|
|
@@ -557,8 +558,8 @@ async function handleControlMessage(context, store, input, { intent = null, star
|
|
|
557
558
|
});
|
|
558
559
|
}
|
|
559
560
|
return readOnlyControlResponse('converse', classification, status, status.running
|
|
560
|
-
?
|
|
561
|
-
:
|
|
561
|
+
? controlMessage(context?.session, 'converse_while_running')
|
|
562
|
+
: controlMessage(context?.session, 'converse_while_idle'));
|
|
562
563
|
}
|
|
563
564
|
|
|
564
565
|
function enqueueControlRequest(context, input) {
|
|
@@ -271,7 +271,7 @@ test('runtime server returns the accepted run id and passes it to the runner', a
|
|
|
271
271
|
const response = await fetch(`http://127.0.0.1:${handle.port}/run`, {
|
|
272
272
|
method: 'POST',
|
|
273
273
|
headers: { 'Content-Type': 'application/json' },
|
|
274
|
-
body: JSON.stringify({ input: 'build', workspace: '
|
|
274
|
+
body: JSON.stringify({ input: 'build', workspace: 'acme', evaluate: false, replans: 1 }),
|
|
275
275
|
});
|
|
276
276
|
assert.equal(response.status, 202);
|
|
277
277
|
const body = await response.json();
|
|
@@ -279,7 +279,7 @@ test('runtime server returns the accepted run id and passes it to the runner', a
|
|
|
279
279
|
assert.match(body.runId, /^[0-9a-f-]{36}$/);
|
|
280
280
|
await new Promise((resolve) => setImmediate(resolve));
|
|
281
281
|
assert.equal(receivedBody.runId, body.runId);
|
|
282
|
-
assert.equal(receivedBody.workspace, '
|
|
282
|
+
assert.equal(receivedBody.workspace, 'acme');
|
|
283
283
|
assert.equal(receivedBody.evaluate, false);
|
|
284
284
|
assert.equal(receivedBody.replans, 1);
|
|
285
285
|
} finally {
|
|
@@ -290,8 +290,8 @@ test('runtime server returns the accepted run id and passes it to the runner', a
|
|
|
290
290
|
test('runtime server state exposes active run identity while running', async (t) => {
|
|
291
291
|
let releaseRun;
|
|
292
292
|
const context = {
|
|
293
|
-
workspace: '
|
|
294
|
-
session: { workspace: '
|
|
293
|
+
workspace: 'acme',
|
|
294
|
+
session: { workspace: 'acme' },
|
|
295
295
|
running: false,
|
|
296
296
|
currentAbortController: null,
|
|
297
297
|
currentRunId: null,
|
|
@@ -321,18 +321,18 @@ test('runtime server state exposes active run identity while running', async (t)
|
|
|
321
321
|
}
|
|
322
322
|
|
|
323
323
|
try {
|
|
324
|
-
const runResponse = await fetch(`http://127.0.0.1:${handle.port}/run?workspace=
|
|
324
|
+
const runResponse = await fetch(`http://127.0.0.1:${handle.port}/run?workspace=acme`, {
|
|
325
325
|
method: 'POST',
|
|
326
326
|
headers: { 'Content-Type': 'application/json' },
|
|
327
327
|
body: JSON.stringify({ input: 'build' }),
|
|
328
328
|
});
|
|
329
329
|
acceptedRun = await runResponse.json();
|
|
330
|
-
const stateResponse = await fetch(`http://127.0.0.1:${handle.port}/state?workspace=
|
|
330
|
+
const stateResponse = await fetch(`http://127.0.0.1:${handle.port}/state?workspace=acme`);
|
|
331
331
|
const state = await stateResponse.json();
|
|
332
332
|
assert.equal(state.status, 'running');
|
|
333
333
|
assert.equal(state.running, true);
|
|
334
334
|
assert.equal(state.runId, acceptedRun.runId);
|
|
335
|
-
assert.equal(state.workspace, '
|
|
335
|
+
assert.equal(state.workspace, 'acme');
|
|
336
336
|
} finally {
|
|
337
337
|
releaseRun?.();
|
|
338
338
|
await handle.close();
|
|
@@ -381,8 +381,8 @@ test('runtime server isolates active runs by workspace', async (t) => {
|
|
|
381
381
|
|
|
382
382
|
try {
|
|
383
383
|
const url = `http://127.0.0.1:${handle.port}/run`;
|
|
384
|
-
const [
|
|
385
|
-
fetch(`${url}?workspace=
|
|
384
|
+
const [acme, docs] = await Promise.all([
|
|
385
|
+
fetch(`${url}?workspace=acme`, {
|
|
386
386
|
method: 'POST',
|
|
387
387
|
headers: { 'Content-Type': 'application/json' },
|
|
388
388
|
body: JSON.stringify({ input: 'first' }),
|
|
@@ -393,18 +393,18 @@ test('runtime server isolates active runs by workspace', async (t) => {
|
|
|
393
393
|
body: JSON.stringify({ input: 'second' }),
|
|
394
394
|
}),
|
|
395
395
|
]);
|
|
396
|
-
assert.deepEqual([
|
|
396
|
+
assert.deepEqual([acme.status, docs.status], [202, 202]);
|
|
397
397
|
|
|
398
|
-
const queued = await fetch(`${url}?workspace=
|
|
398
|
+
const queued = await fetch(`${url}?workspace=acme`, {
|
|
399
399
|
method: 'POST',
|
|
400
400
|
headers: { 'Content-Type': 'application/json' },
|
|
401
401
|
body: JSON.stringify({ input: 'third' }),
|
|
402
402
|
});
|
|
403
403
|
assert.equal(queued.status, 202);
|
|
404
404
|
assert.equal((await queued.json()).queued, true);
|
|
405
|
-
assert.deepEqual(runWorkspaces.sort(), ['
|
|
405
|
+
assert.deepEqual(runWorkspaces.sort(), ['acme', 'docs']);
|
|
406
406
|
} finally {
|
|
407
|
-
releases.get('
|
|
407
|
+
releases.get('acme')?.();
|
|
408
408
|
releases.get('docs')?.();
|
|
409
409
|
await handle.close();
|
|
410
410
|
}
|
|
@@ -446,10 +446,10 @@ test('runtime server filters state and events by workspace', async (t) => {
|
|
|
446
446
|
}
|
|
447
447
|
|
|
448
448
|
try {
|
|
449
|
-
const state = await fetch(`http://127.0.0.1:${handle.port}/state?workspace=
|
|
449
|
+
const state = await fetch(`http://127.0.0.1:${handle.port}/state?workspace=acme`);
|
|
450
450
|
assert.equal(state.status, 200);
|
|
451
|
-
assert.equal((await state.json()).workspace, '
|
|
452
|
-
assert.equal(stateWorkspace, '
|
|
451
|
+
assert.equal((await state.json()).workspace, 'acme');
|
|
452
|
+
assert.equal(stateWorkspace, 'acme');
|
|
453
453
|
|
|
454
454
|
const events = await fetch(`http://127.0.0.1:${handle.port}/events?workspace=docs`);
|
|
455
455
|
assert.equal(events.status, 200);
|
|
@@ -533,15 +533,15 @@ test('runtime server exposes manual resume endpoint', async (t) => {
|
|
|
533
533
|
}
|
|
534
534
|
|
|
535
535
|
try {
|
|
536
|
-
const response = await fetch(`http://127.0.0.1:${handle.port}/resume?workspace=
|
|
536
|
+
const response = await fetch(`http://127.0.0.1:${handle.port}/resume?workspace=acme`, {
|
|
537
537
|
method: 'POST',
|
|
538
538
|
});
|
|
539
539
|
assert.equal(response.status, 202);
|
|
540
|
-
assert.equal(resumedWorkspace, '
|
|
540
|
+
assert.equal(resumedWorkspace, 'acme');
|
|
541
541
|
assert.deepEqual(await response.json(), {
|
|
542
542
|
resumed: 1,
|
|
543
543
|
interrupted: 0,
|
|
544
|
-
workspaces: [{ workspace: '
|
|
544
|
+
workspaces: [{ workspace: 'acme', resumed: true }],
|
|
545
545
|
});
|
|
546
546
|
} finally {
|
|
547
547
|
await handle.close();
|
|
@@ -575,13 +575,13 @@ test('runtime server exposes approval endpoint', async (t) => {
|
|
|
575
575
|
}
|
|
576
576
|
|
|
577
577
|
try {
|
|
578
|
-
const response = await fetch(`http://127.0.0.1:${handle.port}/approve?workspace=
|
|
578
|
+
const response = await fetch(`http://127.0.0.1:${handle.port}/approve?workspace=acme&runId=run-1&itemId=item-1`, {
|
|
579
579
|
method: 'POST',
|
|
580
580
|
});
|
|
581
581
|
assert.equal(response.status, 202);
|
|
582
582
|
assert.deepEqual(approved, {
|
|
583
|
-
workspace: '
|
|
584
|
-
workspaceId: '
|
|
583
|
+
workspace: 'acme',
|
|
584
|
+
workspaceId: 'acme',
|
|
585
585
|
runId: 'run-1',
|
|
586
586
|
itemId: 'item-1',
|
|
587
587
|
approvalId: null,
|
|
@@ -599,8 +599,8 @@ test('runtime server exposes approval endpoint', async (t) => {
|
|
|
599
599
|
|
|
600
600
|
test('runtime server exposes control status and explanation', async (t) => {
|
|
601
601
|
const session = {
|
|
602
|
-
workspace: '
|
|
603
|
-
controlQueue: [{ id: 'control-1', workspace: '
|
|
602
|
+
workspace: 'acme',
|
|
603
|
+
controlQueue: [{ id: 'control-1', workspace: 'acme', status: 'queued', input: 'later' }],
|
|
604
604
|
};
|
|
605
605
|
let handle;
|
|
606
606
|
try {
|
|
@@ -620,7 +620,7 @@ test('runtime server exposes control status and explanation', async (t) => {
|
|
|
620
620
|
listEvents: () => [],
|
|
621
621
|
},
|
|
622
622
|
getContext: async () => ({
|
|
623
|
-
workspace: '
|
|
623
|
+
workspace: 'acme',
|
|
624
624
|
session,
|
|
625
625
|
running: false,
|
|
626
626
|
currentAbortController: null,
|
|
@@ -636,14 +636,14 @@ test('runtime server exposes control status and explanation', async (t) => {
|
|
|
636
636
|
}
|
|
637
637
|
|
|
638
638
|
try {
|
|
639
|
-
const status = await fetch(`http://127.0.0.1:${handle.port}/control?workspace=
|
|
639
|
+
const status = await fetch(`http://127.0.0.1:${handle.port}/control?workspace=acme`);
|
|
640
640
|
assert.equal(status.status, 200);
|
|
641
641
|
const statusBody = await status.json();
|
|
642
642
|
assert.equal(statusBody.status, 'idle');
|
|
643
643
|
assert.equal(statusBody.running, false);
|
|
644
644
|
assert.equal(statusBody.controlQueue[0].id, 'control-1');
|
|
645
645
|
|
|
646
|
-
const explain = await fetch(`http://127.0.0.1:${handle.port}/control?workspace=
|
|
646
|
+
const explain = await fetch(`http://127.0.0.1:${handle.port}/control?workspace=acme`, {
|
|
647
647
|
method: 'POST',
|
|
648
648
|
headers: { 'Content-Type': 'application/json' },
|
|
649
649
|
body: JSON.stringify({ action: 'explain' }),
|
|
@@ -657,7 +657,7 @@ test('runtime server exposes control status and explanation', async (t) => {
|
|
|
657
657
|
|
|
658
658
|
test('runtime server control enqueue emits events but does not patch an active plan or start a run', async (t) => {
|
|
659
659
|
const session = {
|
|
660
|
-
workspace: '
|
|
660
|
+
workspace: 'acme',
|
|
661
661
|
controlQueue: [],
|
|
662
662
|
};
|
|
663
663
|
const events = [];
|
|
@@ -681,7 +681,7 @@ test('runtime server control enqueue emits events but does not patch an active p
|
|
|
681
681
|
listEvents: () => [],
|
|
682
682
|
},
|
|
683
683
|
getContext: async () => ({
|
|
684
|
-
workspace: '
|
|
684
|
+
workspace: 'acme',
|
|
685
685
|
session,
|
|
686
686
|
running: true,
|
|
687
687
|
currentAbortController: new AbortController(),
|
|
@@ -697,7 +697,7 @@ test('runtime server control enqueue emits events but does not patch an active p
|
|
|
697
697
|
}
|
|
698
698
|
|
|
699
699
|
try {
|
|
700
|
-
const response = await fetch(`http://127.0.0.1:${handle.port}/control?workspace=
|
|
700
|
+
const response = await fetch(`http://127.0.0.1:${handle.port}/control?workspace=acme`, {
|
|
701
701
|
method: 'POST',
|
|
702
702
|
headers: { 'Content-Type': 'application/json' },
|
|
703
703
|
body: JSON.stringify({ action: 'enqueue', input: 'run this after current work' }),
|
|
@@ -718,7 +718,7 @@ test('runtime server control enqueue emits events but does not patch an active p
|
|
|
718
718
|
|
|
719
719
|
test('runtime server control message observes an active run without enqueueing', async (t) => {
|
|
720
720
|
const session = {
|
|
721
|
-
workspace: '
|
|
721
|
+
workspace: 'acme',
|
|
722
722
|
controlQueue: [],
|
|
723
723
|
};
|
|
724
724
|
let runCount = 0;
|
|
@@ -739,7 +739,7 @@ test('runtime server control message observes an active run without enqueueing',
|
|
|
739
739
|
listEvents: () => [],
|
|
740
740
|
},
|
|
741
741
|
getContext: async () => ({
|
|
742
|
-
workspace: '
|
|
742
|
+
workspace: 'acme',
|
|
743
743
|
session,
|
|
744
744
|
running: true,
|
|
745
745
|
currentAbortController: new AbortController(),
|
|
@@ -755,7 +755,7 @@ test('runtime server control message observes an active run without enqueueing',
|
|
|
755
755
|
}
|
|
756
756
|
|
|
757
757
|
try {
|
|
758
|
-
const response = await fetch(`http://127.0.0.1:${handle.port}/control?workspace=
|
|
758
|
+
const response = await fetch(`http://127.0.0.1:${handle.port}/control?workspace=acme`, {
|
|
759
759
|
method: 'POST',
|
|
760
760
|
headers: { 'Content-Type': 'application/json' },
|
|
761
761
|
body: JSON.stringify({ action: 'message', input: 'Où en est le build ?' }),
|
|
@@ -773,7 +773,7 @@ test('runtime server control message observes an active run without enqueueing',
|
|
|
773
773
|
|
|
774
774
|
test('runtime server control message handles approve and cancel intents during an active run', async (t) => {
|
|
775
775
|
const session = {
|
|
776
|
-
workspace: '
|
|
776
|
+
workspace: 'acme',
|
|
777
777
|
controlQueue: [],
|
|
778
778
|
};
|
|
779
779
|
const abortController = new AbortController();
|
|
@@ -795,7 +795,7 @@ test('runtime server control message handles approve and cancel intents during a
|
|
|
795
795
|
listEvents: () => [],
|
|
796
796
|
},
|
|
797
797
|
getContext: async () => ({
|
|
798
|
-
workspace: '
|
|
798
|
+
workspace: 'acme',
|
|
799
799
|
session,
|
|
800
800
|
running: true,
|
|
801
801
|
currentAbortController: abortController,
|
|
@@ -812,7 +812,7 @@ test('runtime server control message handles approve and cancel intents during a
|
|
|
812
812
|
}
|
|
813
813
|
|
|
814
814
|
try {
|
|
815
|
-
const approveResponse = await fetch(`http://127.0.0.1:${handle.port}/control?workspace=
|
|
815
|
+
const approveResponse = await fetch(`http://127.0.0.1:${handle.port}/control?workspace=acme`, {
|
|
816
816
|
method: 'POST',
|
|
817
817
|
headers: { 'Content-Type': 'application/json' },
|
|
818
818
|
body: JSON.stringify({ action: 'message', input: 'valide tout' }),
|
|
@@ -820,7 +820,7 @@ test('runtime server control message handles approve and cancel intents during a
|
|
|
820
820
|
assert.equal(approveResponse.status, 200);
|
|
821
821
|
assert.equal((await approveResponse.json()).kind, 'approve');
|
|
822
822
|
|
|
823
|
-
const cancelResponse = await fetch(`http://127.0.0.1:${handle.port}/control?workspace=
|
|
823
|
+
const cancelResponse = await fetch(`http://127.0.0.1:${handle.port}/control?workspace=acme`, {
|
|
824
824
|
method: 'POST',
|
|
825
825
|
headers: { 'Content-Type': 'application/json' },
|
|
826
826
|
body: JSON.stringify({ action: 'message', input: 'annule le run' }),
|
|
@@ -837,18 +837,18 @@ test('runtime server control message handles approve and cancel intents during a
|
|
|
837
837
|
|
|
838
838
|
test('runtime server control message records active plan mutation as a proposal', async (t) => {
|
|
839
839
|
const session = {
|
|
840
|
-
workspace: '
|
|
840
|
+
workspace: 'acme',
|
|
841
841
|
controlQueue: [],
|
|
842
842
|
};
|
|
843
843
|
dispatchAgentEvent(session, createAgentEvent('run_started', {
|
|
844
844
|
origin: 'runtime',
|
|
845
845
|
runId: 'run-mutate',
|
|
846
|
-
workspace: '
|
|
846
|
+
workspace: 'acme',
|
|
847
847
|
}));
|
|
848
848
|
dispatchAgentEvent(session, createAgentEvent('plan_set', {
|
|
849
849
|
origin: 'tool',
|
|
850
850
|
runId: 'run-mutate',
|
|
851
|
-
workspace: '
|
|
851
|
+
workspace: 'acme',
|
|
852
852
|
payload: {
|
|
853
853
|
steps: [{ step: 1, id: 'generate', description: 'Generate', status: 'running' }],
|
|
854
854
|
},
|
|
@@ -865,13 +865,13 @@ test('runtime server control message records active plan mutation as a proposal'
|
|
|
865
865
|
...session.agentProjection,
|
|
866
866
|
status: session.agentProjection?.status ?? 'running',
|
|
867
867
|
queue: [],
|
|
868
|
-
runs: [{ id: 'run-mutate', workspace: '
|
|
868
|
+
runs: [{ id: 'run-mutate', workspace: 'acme', status: 'running' }],
|
|
869
869
|
runId: 'run-mutate',
|
|
870
870
|
}),
|
|
871
871
|
listEvents: () => [],
|
|
872
872
|
},
|
|
873
873
|
getContext: async () => ({
|
|
874
|
-
workspace: '
|
|
874
|
+
workspace: 'acme',
|
|
875
875
|
session,
|
|
876
876
|
running: true,
|
|
877
877
|
currentAbortController: new AbortController(),
|
|
@@ -888,7 +888,7 @@ test('runtime server control message records active plan mutation as a proposal'
|
|
|
888
888
|
}
|
|
889
889
|
|
|
890
890
|
try {
|
|
891
|
-
const response = await fetch(`http://127.0.0.1:${handle.port}/control?workspace=
|
|
891
|
+
const response = await fetch(`http://127.0.0.1:${handle.port}/control?workspace=acme`, {
|
|
892
892
|
method: 'POST',
|
|
893
893
|
headers: { 'Content-Type': 'application/json' },
|
|
894
894
|
body: JSON.stringify({ action: 'message', input: 'Ajoute un envoi après chaque génération' }),
|
|
@@ -901,7 +901,7 @@ test('runtime server control message records active plan mutation as a proposal'
|
|
|
901
901
|
assert.equal(session.agentProjection.planPatches[0].status, 'proposed');
|
|
902
902
|
assert.ok(session.agentEvents.some((event) => event.type === 'control_message_received'));
|
|
903
903
|
assert.ok(session.agentEvents.some((event) => event.type === 'plan_patch_proposed'));
|
|
904
|
-
const approveResponse = await fetch(`http://127.0.0.1:${handle.port}/control?workspace=
|
|
904
|
+
const approveResponse = await fetch(`http://127.0.0.1:${handle.port}/control?workspace=acme`, {
|
|
905
905
|
method: 'POST',
|
|
906
906
|
headers: { 'Content-Type': 'application/json' },
|
|
907
907
|
body: JSON.stringify({ action: 'approve_patch', patchId: body.proposal.id }),
|
|
@@ -924,7 +924,7 @@ test('runtime server control message records active plan mutation as a proposal'
|
|
|
924
924
|
|
|
925
925
|
test('runtime server control message reports ambiguity without starting a run', async (t) => {
|
|
926
926
|
const session = {
|
|
927
|
-
workspace: '
|
|
927
|
+
workspace: 'acme',
|
|
928
928
|
controlQueue: [],
|
|
929
929
|
};
|
|
930
930
|
let runCount = 0;
|
|
@@ -945,7 +945,7 @@ test('runtime server control message reports ambiguity without starting a run',
|
|
|
945
945
|
listEvents: () => [],
|
|
946
946
|
},
|
|
947
947
|
getContext: async () => ({
|
|
948
|
-
workspace: '
|
|
948
|
+
workspace: 'acme',
|
|
949
949
|
session,
|
|
950
950
|
running: true,
|
|
951
951
|
currentAbortController: new AbortController(),
|
|
@@ -961,7 +961,7 @@ test('runtime server control message reports ambiguity without starting a run',
|
|
|
961
961
|
}
|
|
962
962
|
|
|
963
963
|
try {
|
|
964
|
-
const response = await fetch(`http://127.0.0.1:${handle.port}/control?workspace=
|
|
964
|
+
const response = await fetch(`http://127.0.0.1:${handle.port}/control?workspace=acme`, {
|
|
965
965
|
method: 'POST',
|
|
966
966
|
headers: { 'Content-Type': 'application/json' },
|
|
967
967
|
body: JSON.stringify({ action: 'message', input: 'Lance aussi la publication' }),
|
|
@@ -979,7 +979,7 @@ test('runtime server control message reports ambiguity without starting a run',
|
|
|
979
979
|
|
|
980
980
|
test('runtime server drains queued control requests when idle', async (t) => {
|
|
981
981
|
const session = {
|
|
982
|
-
workspace: '
|
|
982
|
+
workspace: 'acme',
|
|
983
983
|
controlQueue: [],
|
|
984
984
|
};
|
|
985
985
|
const events = [];
|
|
@@ -1003,7 +1003,7 @@ test('runtime server drains queued control requests when idle', async (t) => {
|
|
|
1003
1003
|
listEvents: () => [],
|
|
1004
1004
|
},
|
|
1005
1005
|
getContext: async () => ({
|
|
1006
|
-
workspace: '
|
|
1006
|
+
workspace: 'acme',
|
|
1007
1007
|
session,
|
|
1008
1008
|
running: false,
|
|
1009
1009
|
currentAbortController: null,
|
|
@@ -1021,7 +1021,7 @@ test('runtime server drains queued control requests when idle', async (t) => {
|
|
|
1021
1021
|
}
|
|
1022
1022
|
|
|
1023
1023
|
try {
|
|
1024
|
-
const response = await fetch(`http://127.0.0.1:${handle.port}/control?workspace=
|
|
1024
|
+
const response = await fetch(`http://127.0.0.1:${handle.port}/control?workspace=acme`, {
|
|
1025
1025
|
method: 'POST',
|
|
1026
1026
|
headers: { 'Content-Type': 'application/json' },
|
|
1027
1027
|
body: JSON.stringify({ action: 'enqueue', input: 'run from control queue' }),
|
|
@@ -1029,7 +1029,7 @@ test('runtime server drains queued control requests when idle', async (t) => {
|
|
|
1029
1029
|
assert.equal(response.status, 202);
|
|
1030
1030
|
await new Promise((resolve) => setImmediate(resolve));
|
|
1031
1031
|
assert.equal(receivedBody.input, 'run from control queue');
|
|
1032
|
-
assert.equal(receivedBody.workspace, '
|
|
1032
|
+
assert.equal(receivedBody.workspace, 'acme');
|
|
1033
1033
|
assert.match(receivedBody.runId, /^[0-9a-f-]{36}$/);
|
|
1034
1034
|
assert.equal(session.controlQueue[0].status, 'running');
|
|
1035
1035
|
assert.equal(session.controlQueue[0].runId, receivedBody.runId);
|
|
@@ -1041,13 +1041,13 @@ test('runtime server drains queued control requests when idle', async (t) => {
|
|
|
1041
1041
|
|
|
1042
1042
|
test('runtime server handle drains a pre-existing hydrated control request', async (t) => {
|
|
1043
1043
|
const session = {
|
|
1044
|
-
workspace: '
|
|
1045
|
-
controlQueue: [{ id: 'control-existing', workspace: '
|
|
1044
|
+
workspace: 'acme',
|
|
1045
|
+
controlQueue: [{ id: 'control-existing', workspace: 'acme', status: 'queued', input: 'resume queued control' }],
|
|
1046
1046
|
};
|
|
1047
1047
|
const events = [];
|
|
1048
1048
|
session._onAgentEvent = (event) => events.push(event);
|
|
1049
1049
|
const context = {
|
|
1050
|
-
workspace: '
|
|
1050
|
+
workspace: 'acme',
|
|
1051
1051
|
session,
|
|
1052
1052
|
running: false,
|
|
1053
1053
|
currentAbortController: null,
|
|
@@ -1089,8 +1089,8 @@ test('runtime server handle drains a pre-existing hydrated control request', asy
|
|
|
1089
1089
|
|
|
1090
1090
|
test('runtime server exposes config profile list and switch endpoints', async (t) => {
|
|
1091
1091
|
const context = {
|
|
1092
|
-
workspace: '
|
|
1093
|
-
session: { workspace: '
|
|
1092
|
+
workspace: 'acme',
|
|
1093
|
+
session: { workspace: 'acme', wikirc: { profile: 'default' } },
|
|
1094
1094
|
running: false,
|
|
1095
1095
|
currentAbortController: null,
|
|
1096
1096
|
};
|
|
@@ -1123,11 +1123,11 @@ test('runtime server exposes config profile list and switch endpoints', async (t
|
|
|
1123
1123
|
}
|
|
1124
1124
|
|
|
1125
1125
|
try {
|
|
1126
|
-
const profiles = await fetch(`http://127.0.0.1:${handle.port}/config/profiles?workspace=
|
|
1126
|
+
const profiles = await fetch(`http://127.0.0.1:${handle.port}/config/profiles?workspace=acme`);
|
|
1127
1127
|
assert.equal(profiles.status, 200);
|
|
1128
1128
|
assert.deepEqual(await profiles.json(), { profiles: ['default', 'vpn'], active: 'default' });
|
|
1129
1129
|
|
|
1130
|
-
const use = await fetch(`http://127.0.0.1:${handle.port}/config/use?workspace=
|
|
1130
|
+
const use = await fetch(`http://127.0.0.1:${handle.port}/config/use?workspace=acme`, {
|
|
1131
1131
|
method: 'POST',
|
|
1132
1132
|
headers: { 'Content-Type': 'application/json' },
|
|
1133
1133
|
body: JSON.stringify({ profile: 'vpn' }),
|
|
@@ -1152,8 +1152,8 @@ test('runtime server rejects config switching while a run is active', async (t)
|
|
|
1152
1152
|
listEvents: () => [],
|
|
1153
1153
|
},
|
|
1154
1154
|
getContext: async () => ({
|
|
1155
|
-
workspace: '
|
|
1156
|
-
session: { workspace: '
|
|
1155
|
+
workspace: 'acme',
|
|
1156
|
+
session: { workspace: 'acme' },
|
|
1157
1157
|
running: true,
|
|
1158
1158
|
currentAbortController: null,
|
|
1159
1159
|
}),
|
|
@@ -1169,7 +1169,7 @@ test('runtime server rejects config switching while a run is active', async (t)
|
|
|
1169
1169
|
}
|
|
1170
1170
|
|
|
1171
1171
|
try {
|
|
1172
|
-
const response = await fetch(`http://127.0.0.1:${handle.port}/config/use?workspace=
|
|
1172
|
+
const response = await fetch(`http://127.0.0.1:${handle.port}/config/use?workspace=acme`, {
|
|
1173
1173
|
method: 'POST',
|
|
1174
1174
|
headers: { 'Content-Type': 'application/json' },
|
|
1175
1175
|
body: JSON.stringify({ profile: 'vpn' }),
|
|
@@ -281,8 +281,8 @@ test('runtime store persists duplicate events idempotently', () => {
|
|
|
281
281
|
origin: 'test',
|
|
282
282
|
runId: 'run-1',
|
|
283
283
|
turnId: 'run-1:turn-0',
|
|
284
|
-
workspace: '
|
|
285
|
-
payload: { input: 'build wiki', workspace: '
|
|
284
|
+
workspace: 'acme',
|
|
285
|
+
payload: { input: 'build wiki', workspace: 'acme' },
|
|
286
286
|
});
|
|
287
287
|
|
|
288
288
|
store.persistEvent(event);
|
|
@@ -292,7 +292,7 @@ test('runtime store persists duplicate events idempotently', () => {
|
|
|
292
292
|
assert.equal(store.listEvents()[0].sequence, 1);
|
|
293
293
|
assert.equal(store.getState().eventsCursor, 1);
|
|
294
294
|
assert.equal(store.listRuns()[0].id, 'run-1');
|
|
295
|
-
assert.equal(store.listRuns()[0].workspace, '
|
|
295
|
+
assert.equal(store.listRuns()[0].workspace, 'acme');
|
|
296
296
|
assert.equal(store.listRuns()[0].status, 'running');
|
|
297
297
|
store.close();
|
|
298
298
|
});
|
|
@@ -590,19 +590,19 @@ test('runtime store persists cancelled run status', () => {
|
|
|
590
590
|
origin: 'test',
|
|
591
591
|
runId: 'run-1',
|
|
592
592
|
turnId: 'run-1:turn-0',
|
|
593
|
-
workspace: '
|
|
594
|
-
payload: { input: 'build wiki', workspace: '
|
|
593
|
+
workspace: 'acme',
|
|
594
|
+
payload: { input: 'build wiki', workspace: 'acme' },
|
|
595
595
|
}));
|
|
596
596
|
store.persistEvent(createAgentEvent('run_cancelled', {
|
|
597
597
|
origin: 'test',
|
|
598
598
|
runId: 'run-1',
|
|
599
599
|
turnId: 'run-1:turn-1',
|
|
600
|
-
workspace: '
|
|
601
|
-
payload: { runId: 'run-1', workspace: '
|
|
600
|
+
workspace: 'acme',
|
|
601
|
+
payload: { runId: 'run-1', workspace: 'acme' },
|
|
602
602
|
}));
|
|
603
603
|
|
|
604
604
|
assert.equal(store.listRuns()[0].status, 'cancelled');
|
|
605
|
-
assert.equal(store.listRuns()[0].workspace, '
|
|
605
|
+
assert.equal(store.listRuns()[0].workspace, 'acme');
|
|
606
606
|
store.close();
|
|
607
607
|
});
|
|
608
608
|
|
|
@@ -724,10 +724,10 @@ test('runtime store filters events runs and queue by workspace', () => {
|
|
|
724
724
|
const store = openRuntimeStore({ stateDir });
|
|
725
725
|
store.persistEvent(createAgentEvent('run_started', {
|
|
726
726
|
origin: 'test',
|
|
727
|
-
runId: 'run-
|
|
728
|
-
turnId: 'run-
|
|
729
|
-
workspace: '
|
|
730
|
-
payload: { input: '
|
|
727
|
+
runId: 'run-acme',
|
|
728
|
+
turnId: 'run-acme:turn-0',
|
|
729
|
+
workspace: 'acme',
|
|
730
|
+
payload: { input: 'acme', workspace: 'acme' },
|
|
731
731
|
}));
|
|
732
732
|
store.persistEvent(createAgentEvent('run_started', {
|
|
733
733
|
origin: 'test',
|
|
@@ -736,12 +736,12 @@ test('runtime store filters events runs and queue by workspace', () => {
|
|
|
736
736
|
workspace: 'docs',
|
|
737
737
|
payload: { input: 'docs', workspace: 'docs' },
|
|
738
738
|
}));
|
|
739
|
-
store.saveQueue([{ id: 'q-
|
|
739
|
+
store.saveQueue([{ id: 'q-acme', workspace: 'acme', status: 'waiting' }], { workspace: 'acme' });
|
|
740
740
|
store.saveQueue([{ id: 'q-docs', workspace: 'docs', status: 'waiting' }], { workspace: 'docs' });
|
|
741
741
|
|
|
742
|
-
assert.deepEqual(store.listEvents({ workspace: '
|
|
742
|
+
assert.deepEqual(store.listEvents({ workspace: 'acme' }).map((event) => event.runId), ['run-acme']);
|
|
743
743
|
assert.deepEqual(store.listRuns({ workspace: 'docs' }).map((run) => run.id), ['run-docs']);
|
|
744
|
-
assert.deepEqual(store.listQueue({ workspace: '
|
|
744
|
+
assert.deepEqual(store.listQueue({ workspace: 'acme' }).map((item) => item.id), ['q-acme']);
|
|
745
745
|
|
|
746
746
|
const session = { activities: {}, headlessPlan: null };
|
|
747
747
|
store.hydrateSession(session, { workspace: 'docs' });
|
|
@@ -794,8 +794,8 @@ test('runtime store persists and replays control queue events without mixing MCP
|
|
|
794
794
|
}));
|
|
795
795
|
store.persistEvent(createAgentEvent('control_enqueued', {
|
|
796
796
|
origin: 'runtime',
|
|
797
|
-
workspace: '
|
|
798
|
-
payload: { id: 'control-
|
|
797
|
+
workspace: 'acme',
|
|
798
|
+
payload: { id: 'control-acme', workspace: 'acme', input: 'other' },
|
|
799
799
|
}));
|
|
800
800
|
store.close();
|
|
801
801
|
|
|
@@ -925,10 +925,10 @@ test('runtime store identifies recoverable workspaces and interrupts stale runs'
|
|
|
925
925
|
const stateDir = mkdtempSync(join(tmpdir(), 'wiki-manager-runtime-'));
|
|
926
926
|
const store = openRuntimeStore({ stateDir });
|
|
927
927
|
store.persistRun({
|
|
928
|
-
id: 'run-
|
|
929
|
-
workspace: '
|
|
928
|
+
id: 'run-acme',
|
|
929
|
+
workspace: 'acme',
|
|
930
930
|
status: 'running',
|
|
931
|
-
input: 'build
|
|
931
|
+
input: 'build acme',
|
|
932
932
|
});
|
|
933
933
|
store.persistRun({
|
|
934
934
|
id: 'run-done',
|
|
@@ -938,9 +938,9 @@ test('runtime store identifies recoverable workspaces and interrupts stale runs'
|
|
|
938
938
|
});
|
|
939
939
|
store.saveQueue([{ id: 'q-docs', workspace: 'docs', status: 'waiting' }], { workspace: 'docs' });
|
|
940
940
|
|
|
941
|
-
assert.deepEqual(store.listRecoverableWorkspaces(), ['
|
|
942
|
-
assert.deepEqual(store.listRecoverableRuns({ workspace: '
|
|
943
|
-
assert.equal(store.interruptRuns({ workspace: '
|
|
944
|
-
assert.equal(store.listRuns({ workspace: '
|
|
941
|
+
assert.deepEqual(store.listRecoverableWorkspaces(), ['acme', 'docs']);
|
|
942
|
+
assert.deepEqual(store.listRecoverableRuns({ workspace: 'acme' }).map((run) => run.id), ['run-acme']);
|
|
943
|
+
assert.equal(store.interruptRuns({ workspace: 'acme' }), 1);
|
|
944
|
+
assert.equal(store.listRuns({ workspace: 'acme' })[0].status, 'interrupted');
|
|
945
945
|
store.close();
|
|
946
946
|
});
|
package/src/shell/repl.js
CHANGED
|
@@ -1580,14 +1580,15 @@ async function runTuiShell({ agent, packageJson, session, runtime = null }) {
|
|
|
1580
1580
|
if (outcome.kind === 'accepted') {
|
|
1581
1581
|
activityLines = [...activityLines, 'runtime: run accepted'].slice(-LOWER_DETAIL_ROWS);
|
|
1582
1582
|
} else if (outcome.kind === 'queued') {
|
|
1583
|
-
|
|
1583
|
+
// Server-localized acknowledgement (src/runtime/controlMessages.js).
|
|
1584
|
+
conversationMessages(session).push({ role: 'command', content: String(outcome.result?.explanation ?? 'Request added to the queue.') });
|
|
1584
1585
|
activityLines = [...activityLines, 'runtime: control queued'].slice(-LOWER_DETAIL_ROWS);
|
|
1585
1586
|
} else if (outcome.kind === 'observe' || outcome.kind === 'converse' || outcome.kind === 'mutate') {
|
|
1586
1587
|
const explanation = outcome.result?.explanation ?? 'Runtime control message accepted.';
|
|
1587
1588
|
conversationMessages(session).push({ role: 'command', content: explanation });
|
|
1588
1589
|
activityLines = [...activityLines, `runtime: ${outcome.kind}`].slice(-LOWER_DETAIL_ROWS);
|
|
1589
1590
|
} else if (outcome.kind === 'ambiguous') {
|
|
1590
|
-
conversationMessages(session).push({ role: 'command', content: 'Runtime could not classify that message. Use /queue for a future run, or ask/status more explicitly.' });
|
|
1591
|
+
conversationMessages(session).push({ role: 'command', content: String(outcome.result?.explanation ?? 'Runtime could not classify that message. Use /queue for a future run, or ask/status more explicitly.') });
|
|
1591
1592
|
activityLines = [...activityLines, 'runtime: ambiguous control'].slice(-LOWER_DETAIL_ROWS);
|
|
1592
1593
|
} else {
|
|
1593
1594
|
conversationMessages(session).push({ role: 'command', content: `Runtime error: ${outcome.message}` });
|
package/src/shell/repl.test.js
CHANGED
|
@@ -183,7 +183,7 @@ test('agent mode without runtime records a visible error instead of falling back
|
|
|
183
183
|
|
|
184
184
|
test('runtime status exposes the disconnected reason', () => {
|
|
185
185
|
assert.equal(
|
|
186
|
-
runtimeStatusLine({ error: 'token mismatch' }, { workspace: '
|
|
186
|
+
runtimeStatusLine({ error: 'token mismatch' }, { workspace: 'acme' }),
|
|
187
187
|
'runtime: disconnected: token mismatch',
|
|
188
188
|
);
|
|
189
189
|
assert.equal(
|
package/src/shell/useAgent.ts
CHANGED
|
@@ -31,7 +31,11 @@ export function useAgent(props: { agent: unknown; packageJson: Record<string, un
|
|
|
31
31
|
props.onRuntimeAccepted?.();
|
|
32
32
|
props.addLog('runtime: run accepted');
|
|
33
33
|
} else if (outcome.kind === 'queued') {
|
|
34
|
-
|
|
34
|
+
// The server localizes control-lane acknowledgements from the
|
|
35
|
+
// session language (src/runtime/controlMessages.js) — always prefer
|
|
36
|
+
// its explanation over a local hardcoded string.
|
|
37
|
+
const explanation = (outcome as any).result?.explanation ?? 'Request added to the queue.';
|
|
38
|
+
conversationMessages(props.session).push({ role: 'command', content: String(explanation) });
|
|
35
39
|
props.addLog('runtime: control queued');
|
|
36
40
|
} else {
|
|
37
41
|
conversationMessages(props.session).push({ role: 'command', content: `Runtime error: ${outcome.message}` });
|
package/src/shell/useSession.ts
CHANGED
|
@@ -224,36 +224,64 @@ export function useSession(props: { agent: unknown; packageJson: Record<string,
|
|
|
224
224
|
? lastVisibleActivities.map((activity) => ({ ...activity }))
|
|
225
225
|
: current;
|
|
226
226
|
});
|
|
227
|
+
// Control-lane run requests (user messages queued while the runtime is
|
|
228
|
+
// busy) live in state.controlQueue, a separate lane from the MCP job
|
|
229
|
+
// queue. They must appear in the Queue panel too — otherwise a queued run
|
|
230
|
+
// request is invisible (Queue (0)) even though it is registered and will
|
|
231
|
+
// start automatically.
|
|
232
|
+
const controlQueueItems = createMemo(() => {
|
|
233
|
+
version();
|
|
234
|
+
const controlQueue = runtimeState()?.controlQueue;
|
|
235
|
+
if (!Array.isArray(controlQueue)) return [] as any[];
|
|
236
|
+
return controlQueue
|
|
237
|
+
.filter((item: any) => ['queued', 'running', 'starting'].includes(String(item.status ?? '').toLowerCase()))
|
|
238
|
+
.map((item: any) => ({
|
|
239
|
+
...item,
|
|
240
|
+
id: item.id,
|
|
241
|
+
label: `run: ${String(item.input ?? '').slice(0, 64)}`,
|
|
242
|
+
status: item.status,
|
|
243
|
+
_runtime: true,
|
|
244
|
+
_control: true,
|
|
245
|
+
}));
|
|
246
|
+
});
|
|
227
247
|
const queueItems = createMemo(() => {
|
|
228
248
|
version();
|
|
249
|
+
const control = controlQueueItems();
|
|
229
250
|
const workflowQueue = nonEmptyRuntimeArray(runtimeState()?.workflow?.nodes?.filter((node: any) => node.type === 'queue'));
|
|
230
251
|
if (workflowQueue) {
|
|
231
|
-
return workflowQueue.map((node: any) => ({ ...(node.raw ?? {}), id: node.itemId ?? node.id, label: node.label, status: node.status, _runtime: true, _workflow: true }));
|
|
252
|
+
return [...control, ...workflowQueue.map((node: any) => ({ ...(node.raw ?? {}), id: node.itemId ?? node.id, label: node.label, status: node.status, _runtime: true, _workflow: true }))];
|
|
232
253
|
}
|
|
233
254
|
const runtimeQueue = nonEmptyRuntimeArray(runtimeState()?.queue);
|
|
234
|
-
if (runtimeQueue) return runtimeQueue.map((item: any) => ({ ...item, _runtime: true }));
|
|
235
|
-
return
|
|
236
|
-
|
|
255
|
+
if (runtimeQueue) return [...control, ...runtimeQueue.map((item: any) => ({ ...item, _runtime: true }))];
|
|
256
|
+
return [
|
|
257
|
+
...control,
|
|
258
|
+
...projectQueue((session as any).headlessPlan, (session as any).jobQueue ?? [], { workspace: (session as any).workspace ?? null })
|
|
259
|
+
.map((item: any) => ({ ...item })),
|
|
260
|
+
];
|
|
237
261
|
});
|
|
238
262
|
const queueInfo = createMemo(() => {
|
|
239
263
|
version();
|
|
264
|
+
const control = controlQueueItems();
|
|
265
|
+
const controlActive = control.length;
|
|
266
|
+
const controlCurrent = control.filter((item: any) => ['starting', 'running'].includes(String(item.status ?? '').toLowerCase())).length;
|
|
240
267
|
const workflowQueue = runtimeState()?.workflow?.nodes?.filter((node: any) => node.type === 'queue');
|
|
241
268
|
if (Array.isArray(workflowQueue)) {
|
|
242
269
|
return {
|
|
243
|
-
active: workflowQueue.filter((item: any) => ['waiting', 'starting', 'running', 'queued', 'pending', 'pending_approval'].includes(String(item.status ?? '').toLowerCase())).length,
|
|
244
|
-
current: workflowQueue.filter((item: any) => ['starting', 'running'].includes(String(item.status ?? '').toLowerCase())).length,
|
|
270
|
+
active: controlActive + workflowQueue.filter((item: any) => ['waiting', 'starting', 'running', 'queued', 'pending', 'pending_approval'].includes(String(item.status ?? '').toLowerCase())).length,
|
|
271
|
+
current: controlCurrent + workflowQueue.filter((item: any) => ['starting', 'running'].includes(String(item.status ?? '').toLowerCase())).length,
|
|
245
272
|
frozen: 0,
|
|
246
273
|
};
|
|
247
274
|
}
|
|
248
275
|
const runtimeQueue = runtimeState()?.queue;
|
|
249
276
|
if (Array.isArray(runtimeQueue)) {
|
|
250
277
|
return {
|
|
251
|
-
active: runtimeQueue.filter((item: any) => ['waiting', 'starting', 'running', 'queued', 'pending', 'pending_approval'].includes(String(item.status ?? '').toLowerCase())).length,
|
|
252
|
-
current: runtimeQueue.filter((item: any) => ['starting', 'running'].includes(String(item.status ?? '').toLowerCase())).length,
|
|
278
|
+
active: controlActive + runtimeQueue.filter((item: any) => ['waiting', 'starting', 'running', 'queued', 'pending', 'pending_approval'].includes(String(item.status ?? '').toLowerCase())).length,
|
|
279
|
+
current: controlCurrent + runtimeQueue.filter((item: any) => ['starting', 'running'].includes(String(item.status ?? '').toLowerCase())).length,
|
|
253
280
|
frozen: 0,
|
|
254
281
|
};
|
|
255
282
|
}
|
|
256
|
-
|
|
283
|
+
const base = queueCounts(session);
|
|
284
|
+
return { ...base, active: (base.active ?? 0) + controlActive, current: (base.current ?? 0) + controlCurrent };
|
|
257
285
|
});
|
|
258
286
|
const plan = createMemo(() => {
|
|
259
287
|
version();
|