@dotdrelle/wiki-manager 0.12.0 → 0.12.7
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/activity/activityAggregator.js +34 -3
- package/src/activity/activityAggregator.test.js +32 -0
- package/src/agent/graph.js +307 -29
- package/src/agent/graph.test.js +404 -1
- package/src/cli/wiki-manager.js +24 -1
- package/src/commands/slash.js +74 -1
- package/src/commands/slash.test.js +36 -0
- package/src/contracts/schemas.test.js +2 -2
- package/src/core/activity.js +4 -0
- package/src/core/activity.test.js +9 -1
- package/src/core/agentEvents.js +30 -0
- package/src/core/agentEvents.test.js +42 -1
- package/src/core/buildInfo.js +58 -0
- package/src/core/buildInfo.json +4 -0
- package/src/core/buildInfo.test.js +14 -0
- package/src/core/mcp.js +50 -3
- package/src/core/mcp.test.js +94 -1
- package/src/core/runtimeLog.js +6 -1
- package/src/core/runtimeLog.test.js +3 -1
- package/src/runtime/client.js +22 -0
- 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/recoveryManager.js +54 -0
- package/src/runtime/recoveryManager.test.js +73 -0
- package/src/runtime/runner.js +49 -13
- package/src/runtime/runner.test.js +196 -0
- package/src/runtime/server.js +73 -13
- package/src/runtime/server.test.js +284 -67
- package/src/runtime/store.js +48 -3
- package/src/runtime/store.test.js +56 -24
- package/src/runtime/supervisor.js +77 -1
- package/src/shell/RightPane.tsx +124 -31
- package/src/shell/SetupWizard.tsx +13 -1
- package/src/shell/repl.js +81 -11
- package/src/shell/repl.test.js +115 -2
- package/src/shell/tui.tsx +4 -1
- package/src/shell/useAgent.ts +37 -4
- package/src/shell/useSession.ts +51 -10
package/src/shell/repl.js
CHANGED
|
@@ -5,7 +5,7 @@ import { execFileSync } from 'node:child_process';
|
|
|
5
5
|
import { stdin as input, stdout as output } from 'node:process';
|
|
6
6
|
import { marked } from 'marked';
|
|
7
7
|
import { markedTerminal } from 'marked-terminal';
|
|
8
|
-
import { buildAgentSystemPrompt, formatLlmUnavailableMessage } from '../agent/graph.js';
|
|
8
|
+
import { buildAgentSystemPrompt, classifyAgentInput, formatLlmUnavailableMessage } from '../agent/graph.js';
|
|
9
9
|
import { handleSlashCommand } from '../commands/slash.js';
|
|
10
10
|
import { serviceDescription, serviceNames as composeServiceNames } from '../core/compose.js';
|
|
11
11
|
import { extractActivity, parseJsonText, sessionActivities } from '../core/activity.js';
|
|
@@ -16,6 +16,7 @@ import { listSkills } from '../core/skills.js';
|
|
|
16
16
|
import { listWikircProfiles } from '../core/wikirc.js';
|
|
17
17
|
import { listWorkspaces } from '../core/workspaces.js';
|
|
18
18
|
import { fetchRuntimeState, postRuntimeApprove, postRuntimeCancel, postRuntimeControl, postRuntimeRun, postRuntimeShutdown, streamRuntimeEvents } from '../runtime/client.js';
|
|
19
|
+
import { versionWithBuild } from '../core/buildInfo.js';
|
|
19
20
|
|
|
20
21
|
marked.use(markedTerminal());
|
|
21
22
|
// marked-terminal's text renderer extracts token.text (raw string) instead of
|
|
@@ -74,6 +75,7 @@ const COMMAND_COMPLETION_DESCRIPTIONS = {
|
|
|
74
75
|
'/chat': 'Switch free text to direct LLM chat without tools.',
|
|
75
76
|
'/agent': 'Switch free text to the LangGraph agent with tools.',
|
|
76
77
|
'/openui': 'Open the workspace web UI in the browser.',
|
|
78
|
+
'/run': 'Inspect, cancel, or kill runtime runs.',
|
|
77
79
|
'/approve': 'Approve a pending runtime run or tool.',
|
|
78
80
|
};
|
|
79
81
|
|
|
@@ -89,6 +91,9 @@ const SUBCOMMAND_COMPLETION_DESCRIPTIONS = {
|
|
|
89
91
|
'/upload:convert': 'Convert one stored upload or all pending uploads.',
|
|
90
92
|
'/uploads:clean': 'Clean old stored document uploads.',
|
|
91
93
|
'/uploads:list': 'List uploaded documents.',
|
|
94
|
+
'/run:status': 'Show runtime run status.',
|
|
95
|
+
'/run:cancel': 'Cancel the active runtime run.',
|
|
96
|
+
'/run:kill': 'Hard-kill runtime run(s).',
|
|
92
97
|
'/queue': 'Inspect or cancel queued MCP jobs.',
|
|
93
98
|
'/queue:cancel': 'Cancel a queued or running queue item.',
|
|
94
99
|
'/queue:clear': 'Clear finished queue items.',
|
|
@@ -136,7 +141,7 @@ export function createSession() {
|
|
|
136
141
|
wikircConfig: null,
|
|
137
142
|
language: null,
|
|
138
143
|
mcp: null,
|
|
139
|
-
commands: ['help', 'version', 'exit', 'workspace', 'new', 'use', 'config', 'status', 'services', 'start', 'stop', 'logs', 'mcp', 'wiki', 'skills', 'upload', 'uploads', 'clear', 'chat', 'agent', 'openui', 'queue', 'approve'],
|
|
144
|
+
commands: ['help', 'version', 'exit', 'workspace', 'new', 'use', 'config', 'status', 'services', 'start', 'stop', 'logs', 'mcp', 'wiki', 'skills', 'upload', 'uploads', 'clear', 'chat', 'agent', 'openui', 'run', 'queue', 'approve'],
|
|
140
145
|
chatMode: true,
|
|
141
146
|
llm: null,
|
|
142
147
|
activities: {},
|
|
@@ -241,6 +246,7 @@ function completionValuesFor(parts, inputBuffer, session) {
|
|
|
241
246
|
if (command === '/upload' && parts[1] === 'convert' && tokenIndex === 2) return ['pending'];
|
|
242
247
|
if (command === '/uploads' && tokenIndex === 1) return ['clean', 'list'];
|
|
243
248
|
if (command === '/uploads' && previousToken === 'clean') return ['--older-than'];
|
|
249
|
+
if (command === '/run' && tokenIndex === 1) return ['status', 'cancel', 'kill'];
|
|
244
250
|
if (command === '/queue' && tokenIndex === 1) return ['cancel', 'clear'];
|
|
245
251
|
if (command === '/queue' && previousToken === 'cancel') {
|
|
246
252
|
return (session.jobQueue ?? [])
|
|
@@ -786,7 +792,10 @@ export function applyRuntimeStateToShellSession(session, state) {
|
|
|
786
792
|
session.activities = Object.fromEntries(
|
|
787
793
|
session.agentProjection.activities.map((activity) => [activity.key, { ...activity }]),
|
|
788
794
|
);
|
|
789
|
-
|
|
795
|
+
// Runtime queue items replace the local jobQueue wholesale on every sync.
|
|
796
|
+
// Tag their origin so /queue cancel can refuse to fake-cancel them locally
|
|
797
|
+
// (a local status flip would be silently reverted by the next SSE sync).
|
|
798
|
+
if (Array.isArray(state.queue)) session.jobQueue = state.queue.map((item) => ({ ...item, origin: 'runtime' }));
|
|
790
799
|
const production = session.agentProjection.activities.filter((activity) => activity.source === 'production').at(-1);
|
|
791
800
|
if (production) {
|
|
792
801
|
session.productionActivity = {
|
|
@@ -803,14 +812,54 @@ export function applyRuntimeStateToShellSession(session, state) {
|
|
|
803
812
|
// Submits a prompt to the shared runtime. If the workspace is already busy
|
|
804
813
|
// (HTTP 409 from POST /run), route the input through the runtime control lane
|
|
805
814
|
// so status questions and plan-change proposals do not become future runs.
|
|
815
|
+
// A plain question or small talk must never start a runtime run (nor be
|
|
816
|
+
// enqueued as a future one): it only needs an answer. Route converse/observe
|
|
817
|
+
// to the local agent EVEN during an active run — the chat is supposed to stay
|
|
818
|
+
// available, and the graph already restricts tools to read-only in that case.
|
|
819
|
+
// Actions/cancels/approvals still go to the runtime.
|
|
820
|
+
export function shouldHandleFreeTextLocally(line, session, { llmAvailable = Boolean(session?.llm) } = {}) {
|
|
821
|
+
const classification = classifyAgentInput(line, session);
|
|
822
|
+
// cancel intents included: Donna interprets "supprime le job et la queue"
|
|
823
|
+
// and calls runtime__kill / runtime__cancel herself — no hardcoded regex
|
|
824
|
+
// deciding between soft and hard stop. The control lane remains the
|
|
825
|
+
// deterministic fallback when the local LLM is down.
|
|
826
|
+
if (!['converse', 'observe', 'cancel', 'approve', 'enqueue_run', 'ambiguous'].includes(classification.kind)) return { local: false, classification };
|
|
827
|
+
if (!llmAvailable) return { local: false, classification, fallbackReason: 'local LLM unavailable' };
|
|
828
|
+
return { local: true, classification };
|
|
829
|
+
}
|
|
830
|
+
|
|
806
831
|
export async function submitRuntimeRun(line, { runtime, session }) {
|
|
807
832
|
const workspace = session.workspace ?? null;
|
|
808
833
|
try {
|
|
834
|
+
if (runtimeRunActive(session)) {
|
|
835
|
+
// POST /run during an active run blindly ENQUEUES a future run server-
|
|
836
|
+
// side (202 queued) — the 409→control fallback below never fires, so
|
|
837
|
+
// "stop le job" / "annule" ended up in the queue instead of being
|
|
838
|
+
// classified. Send an explicit control message: the server classifies
|
|
839
|
+
// it (cancel aborts the run, approve grants, modify proposes a patch).
|
|
840
|
+
const result = await postRuntimeControl('message', { url: runtime.url, workspace, input: line });
|
|
841
|
+
// The client-side "run active" flag can be STALE (projection still says
|
|
842
|
+
// running right after a run ended). The server's answer carries the
|
|
843
|
+
// truth: if the runtime is actually idle and only produced a canned
|
|
844
|
+
// read-only reply ("Runtime is idle."), the user's input was meant to
|
|
845
|
+
// START a run — fall through to POST /run instead of dead-ending.
|
|
846
|
+
const idleCannedReply = result?.running === false
|
|
847
|
+
&& ['converse', 'observe', 'ambiguous'].includes(String(result?.kind ?? ''));
|
|
848
|
+
if (!idleCannedReply) {
|
|
849
|
+
return { kind: result?.kind ?? 'control', result };
|
|
850
|
+
}
|
|
851
|
+
}
|
|
809
852
|
const result = await postRuntimeRun(line, { url: runtime.url, workspace });
|
|
810
853
|
if (result?.queued || result?.kind === 'enqueue_run' || result?.kind === 'enqueue') {
|
|
811
854
|
return { kind: 'queued', result };
|
|
812
855
|
}
|
|
813
|
-
|
|
856
|
+
if (result?.kind && !result?.runId) {
|
|
857
|
+
// The server was actually running (stale idle flag client-side) and
|
|
858
|
+
// classified our input as a control message — surface its kind and
|
|
859
|
+
// localized explanation instead of pretending a run was accepted.
|
|
860
|
+
return { kind: result.kind, result };
|
|
861
|
+
}
|
|
862
|
+
return { kind: 'accepted', result };
|
|
814
863
|
} catch (err) {
|
|
815
864
|
if (err?.status !== 409) {
|
|
816
865
|
return { kind: 'error', message: err instanceof Error ? err.message : String(err) };
|
|
@@ -876,7 +925,7 @@ function renderScreen({ packageJson, session, messages, inputBuffer, busy = fals
|
|
|
876
925
|
const prompt = promptFor(session);
|
|
877
926
|
const title = '';
|
|
878
927
|
const context = [
|
|
879
|
-
`wiki-manager ${packageJson
|
|
928
|
+
`wiki-manager ${versionWithBuild(packageJson)}`,
|
|
880
929
|
session.workspace ? session.workspace : 'no workspace',
|
|
881
930
|
session.wikirc?.profile ? session.wikirc.profile : 'no wikirc',
|
|
882
931
|
session.language ? session.language : 'no language',
|
|
@@ -1164,7 +1213,7 @@ function directChatUnavailableText(session) {
|
|
|
1164
1213
|
return 'Direct chat unavailable: no streaming LLM configured.';
|
|
1165
1214
|
}
|
|
1166
1215
|
|
|
1167
|
-
export async function runLine(line, { agent, packageJson, session, onUpdate, onStep, chatMode = session.chatMode ?? true }) {
|
|
1216
|
+
export async function runLine(line, { agent, packageJson, session, onUpdate, onStep, chatMode = session.chatMode ?? true, runtime = null }) {
|
|
1168
1217
|
const trimmed = stripHtml(line).trim();
|
|
1169
1218
|
if (!trimmed) return { exit: false };
|
|
1170
1219
|
|
|
@@ -1175,7 +1224,7 @@ export async function runLine(line, { agent, packageJson, session, onUpdate, onS
|
|
|
1175
1224
|
|
|
1176
1225
|
if (trimmed.startsWith('/')) {
|
|
1177
1226
|
onStep?.(`Shell: ${trimmed}`);
|
|
1178
|
-
const result = await handleSlashCommand(trimmed, { packageJson, session, onStep });
|
|
1227
|
+
const result = await handleSlashCommand(trimmed, { packageJson, session, onStep, runtime });
|
|
1179
1228
|
const messages = conversationMessages(session);
|
|
1180
1229
|
const handoffRawToAgent = Boolean(result.rawOutput && result.agentTrigger && agent);
|
|
1181
1230
|
if (result.output) {
|
|
@@ -1574,21 +1623,39 @@ async function runTuiShell({ agent, packageJson, session, runtime = null }) {
|
|
|
1574
1623
|
syncRuntimeState();
|
|
1575
1624
|
}
|
|
1576
1625
|
} else {
|
|
1577
|
-
|
|
1626
|
+
const freeTextRouting = (runtime?.url && !session.chatMode && !line.trim().startsWith('/'))
|
|
1627
|
+
? shouldHandleFreeTextLocally(line, session)
|
|
1628
|
+
: null;
|
|
1629
|
+
if (freeTextRouting?.local) {
|
|
1630
|
+
// Question/small talk → local agent (read tools), no runtime run.
|
|
1631
|
+
activityLines = [...activityLines, `agent: ${freeTextRouting.classification.kind} handled locally`].slice(-LOWER_DETAIL_ROWS);
|
|
1632
|
+
const result = await runLine(line, { agent, packageJson, session, onUpdate: rerender, onStep, runtime });
|
|
1633
|
+
done = result.exit;
|
|
1634
|
+
aborted = result.aborted ?? false;
|
|
1635
|
+
} else if (runtime?.url && !session.chatMode && !line.trim().startsWith('/')) {
|
|
1636
|
+
if (freeTextRouting?.fallbackReason) {
|
|
1637
|
+
activityLines = [...activityLines, `runtime: ${freeTextRouting.fallbackReason}, routing to runtime run`].slice(-LOWER_DETAIL_ROWS);
|
|
1638
|
+
}
|
|
1578
1639
|
conversationMessages(session).push({ role: 'user', content: line });
|
|
1579
1640
|
const outcome = await submitRuntimeRun(line, { runtime, session });
|
|
1580
1641
|
if (outcome.kind === 'accepted') {
|
|
1581
1642
|
activityLines = [...activityLines, 'runtime: run accepted'].slice(-LOWER_DETAIL_ROWS);
|
|
1582
1643
|
} else if (outcome.kind === 'queued') {
|
|
1583
|
-
|
|
1644
|
+
// Server-localized acknowledgement (src/runtime/controlMessages.js).
|
|
1645
|
+
conversationMessages(session).push({ role: 'command', content: String(outcome.result?.explanation ?? 'Request added to the queue.') });
|
|
1584
1646
|
activityLines = [...activityLines, 'runtime: control queued'].slice(-LOWER_DETAIL_ROWS);
|
|
1585
1647
|
} else if (outcome.kind === 'observe' || outcome.kind === 'converse' || outcome.kind === 'mutate') {
|
|
1586
1648
|
const explanation = outcome.result?.explanation ?? 'Runtime control message accepted.';
|
|
1587
1649
|
conversationMessages(session).push({ role: 'command', content: explanation });
|
|
1588
1650
|
activityLines = [...activityLines, `runtime: ${outcome.kind}`].slice(-LOWER_DETAIL_ROWS);
|
|
1589
1651
|
} 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.' });
|
|
1652
|
+
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
1653
|
activityLines = [...activityLines, 'runtime: ambiguous control'].slice(-LOWER_DETAIL_ROWS);
|
|
1654
|
+
} else if (outcome.result?.explanation) {
|
|
1655
|
+
// cancel / approve / modify_run and any future control kinds:
|
|
1656
|
+
// always surface the server's localized explanation.
|
|
1657
|
+
conversationMessages(session).push({ role: 'command', content: String(outcome.result.explanation) });
|
|
1658
|
+
activityLines = [...activityLines, `runtime: ${outcome.kind}`].slice(-LOWER_DETAIL_ROWS);
|
|
1592
1659
|
} else {
|
|
1593
1660
|
conversationMessages(session).push({ role: 'command', content: `Runtime error: ${outcome.message}` });
|
|
1594
1661
|
activityLines = [...activityLines, `runtime error: ${outcome.message}`].slice(-LOWER_DETAIL_ROWS);
|
|
@@ -1598,7 +1665,7 @@ async function runTuiShell({ agent, packageJson, session, runtime = null }) {
|
|
|
1598
1665
|
const message = recordRuntimeUnavailableAgentInput(session, line, runtime);
|
|
1599
1666
|
activityLines = [...activityLines, message ?? 'runtime: disconnected'].slice(-LOWER_DETAIL_ROWS);
|
|
1600
1667
|
} else {
|
|
1601
|
-
const result = await runLine(line, { agent, packageJson, session, onUpdate: rerender, onStep });
|
|
1668
|
+
const result = await runLine(line, { agent, packageJson, session, onUpdate: rerender, onStep, runtime });
|
|
1602
1669
|
done = result.exit;
|
|
1603
1670
|
aborted = result.aborted ?? false;
|
|
1604
1671
|
}
|
|
@@ -1717,6 +1784,9 @@ async function runTuiShell({ agent, packageJson, session, runtime = null }) {
|
|
|
1717
1784
|
|
|
1718
1785
|
export async function runShell({ agent, packageJson, runtime = null }) {
|
|
1719
1786
|
const session = createSession();
|
|
1787
|
+
// Donna's runtime control tools (runtime__kill/cancel/status) read the
|
|
1788
|
+
// endpoint from the session.
|
|
1789
|
+
session.runtime = runtime;
|
|
1720
1790
|
if (!input.isTTY || !output.isTTY) {
|
|
1721
1791
|
await runPipeShell({ agent, packageJson, session });
|
|
1722
1792
|
return;
|
package/src/shell/repl.test.js
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
runLine,
|
|
12
12
|
runtimeStatusLine,
|
|
13
13
|
runtimeUnavailableAgentMessage,
|
|
14
|
+
shouldHandleFreeTextLocally,
|
|
14
15
|
submitRuntimeRun,
|
|
15
16
|
} from './repl.js';
|
|
16
17
|
|
|
@@ -81,7 +82,9 @@ test('submitRuntimeRun reports acceptance without throwing', async () => {
|
|
|
81
82
|
try {
|
|
82
83
|
const session = createSession();
|
|
83
84
|
const outcome = await submitRuntimeRun('build the doc', { runtime: { url: 'http://runtime.test' }, session });
|
|
84
|
-
|
|
85
|
+
// The accepted payload is passed through so callers can surface the runId
|
|
86
|
+
// in the chat (immediate feedback that the run started).
|
|
87
|
+
assert.deepEqual(outcome, { kind: 'accepted', result: { accepted: true, runId: 'run-1' } });
|
|
85
88
|
} finally {
|
|
86
89
|
restore();
|
|
87
90
|
}
|
|
@@ -183,7 +186,7 @@ test('agent mode without runtime records a visible error instead of falling back
|
|
|
183
186
|
|
|
184
187
|
test('runtime status exposes the disconnected reason', () => {
|
|
185
188
|
assert.equal(
|
|
186
|
-
runtimeStatusLine({ error: 'token mismatch' }, { workspace: '
|
|
189
|
+
runtimeStatusLine({ error: 'token mismatch' }, { workspace: 'acme' }),
|
|
187
190
|
'runtime: disconnected: token mismatch',
|
|
188
191
|
);
|
|
189
192
|
assert.equal(
|
|
@@ -191,3 +194,113 @@ test('runtime status exposes the disconnected reason', () => {
|
|
|
191
194
|
'⚠ Runtime indisponible : token mismatch — /agent désactivé, /chat reste possible',
|
|
192
195
|
);
|
|
193
196
|
});
|
|
197
|
+
|
|
198
|
+
test('/run kill posts to the runtime kill endpoint', async () => {
|
|
199
|
+
let calledUrl = null;
|
|
200
|
+
const restore = stubFetch(async (url) => {
|
|
201
|
+
calledUrl = new URL(String(url));
|
|
202
|
+
return jsonResponse(202, { killed: true, runs: 1, tasks: 2 });
|
|
203
|
+
});
|
|
204
|
+
try {
|
|
205
|
+
const session = createSession();
|
|
206
|
+
session.workspace = 'docs';
|
|
207
|
+
|
|
208
|
+
const result = await runLine('/run kill', {
|
|
209
|
+
agent: null,
|
|
210
|
+
packageJson: { version: 'test' },
|
|
211
|
+
session,
|
|
212
|
+
runtime: { url: 'http://runtime.test' },
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
assert.equal(result.exit, false);
|
|
216
|
+
assert.equal(calledUrl.pathname, '/kill');
|
|
217
|
+
assert.equal(calledUrl.searchParams.get('workspace'), 'docs');
|
|
218
|
+
assert.match(conversationMessages(session).at(-1).content, /Runtime kill requested: 1 run, 2 tasks cancelled/);
|
|
219
|
+
} finally {
|
|
220
|
+
restore();
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test('/queue cancel on a runtime workflow id points to run cancellation commands', async () => {
|
|
225
|
+
const session = createSession();
|
|
226
|
+
session.workspace = 'docs';
|
|
227
|
+
session.jobQueue = [];
|
|
228
|
+
session.workflow = {
|
|
229
|
+
nodes: [{ id: 'task:runtime-a', type: 'task', label: 'Runtime task', status: 'pending' }],
|
|
230
|
+
relations: [],
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
const result = await runLine('/queue cancel task:runtime-a', {
|
|
234
|
+
agent: null,
|
|
235
|
+
packageJson: { version: 'test' },
|
|
236
|
+
session,
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
assert.equal(result.exit, false);
|
|
240
|
+
assert.match(conversationMessages(session).at(-1).content, /Item géré par le runtime/);
|
|
241
|
+
assert.match(conversationMessages(session).at(-1).content, /\/run kill/);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test('free text routing keeps questions local and sends actions to the runtime', () => {
|
|
245
|
+
const session = createSession();
|
|
246
|
+
session.llm = { completeWithTools: () => {} };
|
|
247
|
+
|
|
248
|
+
// The original incident: a config question must never start a run.
|
|
249
|
+
const question = shouldHandleFreeTextLocally('donne moi la config du cme', session);
|
|
250
|
+
assert.equal(question.local, true);
|
|
251
|
+
assert.equal(question.classification.kind, 'observe');
|
|
252
|
+
|
|
253
|
+
const smallTalk = shouldHandleFreeTextLocally('bonjour', session);
|
|
254
|
+
assert.equal(smallTalk.local, true);
|
|
255
|
+
|
|
256
|
+
const action = shouldHandleFreeTextLocally('lance le pipeline complet', session);
|
|
257
|
+
assert.equal(action.local, false);
|
|
258
|
+
assert.equal(action.classification.kind, 'start_run');
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test('free text routing keeps questions local even during an active run', () => {
|
|
262
|
+
// The chat must stay available during a run: a status question or small
|
|
263
|
+
// talk answered locally (read-only tools) — never enqueued as a future run.
|
|
264
|
+
const session = createSession();
|
|
265
|
+
session.llm = { completeWithTools: () => {} };
|
|
266
|
+
session.agentProjection = { status: 'running', activities: [], conversation: [] };
|
|
267
|
+
assert.equal(shouldHandleFreeTextLocally('où en est le run', session).local, true);
|
|
268
|
+
assert.equal(shouldHandleFreeTextLocally('salut', session).local, true);
|
|
269
|
+
// Cancel intents are handled by Donna locally (runtime__kill/cancel tools);
|
|
270
|
+
// approvals stay on the deterministic control lane.
|
|
271
|
+
assert.equal(shouldHandleFreeTextLocally('stop le job', session).local, true);
|
|
272
|
+
assert.equal(shouldHandleFreeTextLocally('supprime le job et la queue', session).local, true);
|
|
273
|
+
// Approvals and "later" requests too: Donna owns runtime__approve and
|
|
274
|
+
// runtime__enqueue. Only plan modifications and new runs bypass her.
|
|
275
|
+
assert.equal(shouldHandleFreeTextLocally('approuve le run', session).local, true);
|
|
276
|
+
assert.equal(shouldHandleFreeTextLocally('fais le build plus tard', session).local, true);
|
|
277
|
+
|
|
278
|
+
const offline = createSession();
|
|
279
|
+
offline.llm = null;
|
|
280
|
+
const fallback = shouldHandleFreeTextLocally('donne moi la config du cme', offline);
|
|
281
|
+
assert.equal(fallback.local, false);
|
|
282
|
+
assert.match(fallback.fallbackReason ?? '', /LLM unavailable/);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test('submitRuntimeRun sends a control message instead of /run while a run is active', async () => {
|
|
286
|
+
const paths = [];
|
|
287
|
+
const restore = stubFetch(async (url) => {
|
|
288
|
+
paths.push(pathOf(url));
|
|
289
|
+
return jsonResponse(200, {
|
|
290
|
+
accepted: true,
|
|
291
|
+
kind: 'cancel',
|
|
292
|
+
classification: { kind: 'cancel' },
|
|
293
|
+
explanation: 'Runtime cancellation requested.',
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
try {
|
|
297
|
+
const session = createSession();
|
|
298
|
+
session.agentProjection = { status: 'running', activities: [], conversation: [] };
|
|
299
|
+
const outcome = await submitRuntimeRun('stop le job', { runtime: { url: 'http://runtime.test' }, session });
|
|
300
|
+
assert.deepEqual(paths, ['/control'], 'active run must use the control lane, not POST /run');
|
|
301
|
+
assert.equal(outcome.kind, 'cancel');
|
|
302
|
+
assert.match(outcome.result?.explanation ?? '', /cancellation requested/i);
|
|
303
|
+
} finally {
|
|
304
|
+
restore();
|
|
305
|
+
}
|
|
306
|
+
});
|
package/src/shell/tui.tsx
CHANGED
|
@@ -126,7 +126,10 @@ function App(props: {
|
|
|
126
126
|
const conversationRows = createMemo(() => Math.max(4, dimensions().height - 5 - chatInputHeight()));
|
|
127
127
|
const rightColumns = createMemo(() => {
|
|
128
128
|
const width = dimensions().width;
|
|
129
|
-
|
|
129
|
+
// 38% / cap 56 (was 32% / cap 44): the Plan/Activity/Logs panes carry job
|
|
130
|
+
// labels, file names and error messages — 40 columns truncated everything
|
|
131
|
+
// into unreadable stubs.
|
|
132
|
+
return Math.max(30, Math.min(56, Math.floor(width * 0.38)));
|
|
130
133
|
});
|
|
131
134
|
const leftColumns = createMemo(() => Math.max(32, dimensions().width - rightColumns() - 1));
|
|
132
135
|
const conversationColumns = createMemo(() => {
|
package/src/shell/useAgent.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createSignal } from 'solid-js';
|
|
2
2
|
import { postRuntimeCancel } from '../runtime/client.js';
|
|
3
|
-
import { conversationMessages, recordRuntimeUnavailableAgentInput, runLine, submitRuntimeRun } from './repl.js';
|
|
3
|
+
import { conversationMessages, recordRuntimeUnavailableAgentInput, runLine, shouldHandleFreeTextLocally, submitRuntimeRun } from './repl.js';
|
|
4
4
|
|
|
5
5
|
export function useAgent(props: { agent: unknown; packageJson: Record<string, unknown>; session: Record<string, any>; chatMode: () => boolean; runtimeUrl?: string | null; runtimeUnavailableReason?: string | null; refresh: () => void; addLog: (line: string) => void; onRuntimeAccepted?: () => void }) {
|
|
6
6
|
const [busy, setBusy] = createSignal(false);
|
|
@@ -18,7 +18,17 @@ export function useAgent(props: { agent: unknown; packageJson: Record<string, un
|
|
|
18
18
|
props.addLog(`input: ${trimmed}`);
|
|
19
19
|
|
|
20
20
|
try {
|
|
21
|
-
|
|
21
|
+
// Questions and small talk are answered by the local agent: the reply
|
|
22
|
+
// shows up in the chat immediately and no runtime run (plan, SQLite
|
|
23
|
+
// state, replans) is created for a sentence that only needed an answer.
|
|
24
|
+
// Actions still go to the runtime below.
|
|
25
|
+
const freeTextRouting = (props.runtimeUrl && !props.chatMode() && !trimmed.startsWith('/'))
|
|
26
|
+
? shouldHandleFreeTextLocally(trimmed, props.session)
|
|
27
|
+
: null;
|
|
28
|
+
if (props.runtimeUrl && !props.chatMode() && !trimmed.startsWith('/') && !freeTextRouting?.local) {
|
|
29
|
+
if (freeTextRouting?.fallbackReason) {
|
|
30
|
+
props.addLog(`runtime: ${freeTextRouting.fallbackReason}, routing to runtime run`);
|
|
31
|
+
}
|
|
22
32
|
// Marked _pending so mergeRuntimeConversation (useSession.ts) can
|
|
23
33
|
// confirm this exact entry instead of pushing a second copy once the
|
|
24
34
|
// same user message comes back from the runtime's own /state.
|
|
@@ -29,10 +39,26 @@ export function useAgent(props: { agent: unknown; packageJson: Record<string, un
|
|
|
29
39
|
});
|
|
30
40
|
if (outcome.kind === 'accepted') {
|
|
31
41
|
props.onRuntimeAccepted?.();
|
|
42
|
+
const runId = (outcome as any).result?.runId ?? null;
|
|
43
|
+
// Light, immediate feedback in the chat: without it an accepted run
|
|
44
|
+
// is invisible until its first activity lands in the side panels.
|
|
45
|
+
conversationMessages(props.session).push({
|
|
46
|
+
role: 'command',
|
|
47
|
+
content: `▶ Run accepté${runId ? ` (${String(runId).slice(0, 8)})` : ''} — progression dans le panneau Activity, réponse ici à la fin du run.`,
|
|
48
|
+
});
|
|
32
49
|
props.addLog('runtime: run accepted');
|
|
33
50
|
} else if (outcome.kind === 'queued') {
|
|
34
|
-
|
|
51
|
+
// The server localizes control-lane acknowledgements from the
|
|
52
|
+
// session language (src/runtime/controlMessages.js) — always prefer
|
|
53
|
+
// its explanation over a local hardcoded string.
|
|
54
|
+
const explanation = (outcome as any).result?.explanation ?? 'Request added to the queue.';
|
|
55
|
+
conversationMessages(props.session).push({ role: 'command', content: String(explanation) });
|
|
35
56
|
props.addLog('runtime: control queued');
|
|
57
|
+
} else if ((outcome as any).result?.explanation) {
|
|
58
|
+
// Control-lane kinds (cancel / approve / observe / modify_run…):
|
|
59
|
+
// surface the server's localized explanation instead of an error.
|
|
60
|
+
conversationMessages(props.session).push({ role: 'command', content: String((outcome as any).result.explanation) });
|
|
61
|
+
props.addLog(`runtime: ${outcome.kind}`);
|
|
36
62
|
} else {
|
|
37
63
|
conversationMessages(props.session).push({ role: 'command', content: `Runtime error: ${outcome.message}` });
|
|
38
64
|
props.addLog(`runtime error: ${outcome.message}`);
|
|
@@ -40,7 +66,7 @@ export function useAgent(props: { agent: unknown; packageJson: Record<string, un
|
|
|
40
66
|
props.refresh();
|
|
41
67
|
return { exit: false, runtime: true };
|
|
42
68
|
}
|
|
43
|
-
if (!props.chatMode() && !trimmed.startsWith('/')) {
|
|
69
|
+
if (!props.chatMode() && !trimmed.startsWith('/') && !freeTextRouting?.local) {
|
|
44
70
|
const message = recordRuntimeUnavailableAgentInput(props.session, trimmed, {
|
|
45
71
|
error: props.runtimeUnavailableReason ?? 'runtime introuvable',
|
|
46
72
|
});
|
|
@@ -48,6 +74,9 @@ export function useAgent(props: { agent: unknown; packageJson: Record<string, un
|
|
|
48
74
|
props.refresh();
|
|
49
75
|
return { exit: false, runtimeUnavailable: true };
|
|
50
76
|
}
|
|
77
|
+
if (freeTextRouting?.local) {
|
|
78
|
+
props.addLog(`agent: ${freeTextRouting.classification.kind} handled locally`);
|
|
79
|
+
}
|
|
51
80
|
const result = await runLine(trimmed, {
|
|
52
81
|
agent: props.agent,
|
|
53
82
|
packageJson: props.packageJson,
|
|
@@ -55,6 +84,10 @@ export function useAgent(props: { agent: unknown; packageJson: Record<string, un
|
|
|
55
84
|
onUpdate: props.refresh,
|
|
56
85
|
onStep: props.addLog,
|
|
57
86
|
chatMode: props.chatMode(),
|
|
87
|
+
// Without this, slash commands that target the runtime (/run kill,
|
|
88
|
+
// /run cancel, /run status) reported "Runtime unavailable" in the
|
|
89
|
+
// TUI even while the status bar showed "runtime: connected".
|
|
90
|
+
runtime: props.runtimeUrl ? { url: props.runtimeUrl } : null,
|
|
58
91
|
});
|
|
59
92
|
props.refresh();
|
|
60
93
|
return result;
|
package/src/shell/useSession.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { writeFileSync } from 'node:fs';
|
|
|
2
2
|
import { isAbsolute, relative, resolve, sep } from 'node:path';
|
|
3
3
|
import { createEffect, createMemo, createSignal, onCleanup } from 'solid-js';
|
|
4
4
|
import { formatMcpToolResult, callMcpTool } from '../core/mcp.js';
|
|
5
|
+
import { versionWithBuild } from '../core/buildInfo.js';
|
|
5
6
|
import { extractActivity, parseJsonText, sessionActivities } from '../core/activity.js';
|
|
6
7
|
import { formatPlanStatus, formatCompletedActivities, formatPlanStep } from '../core/plan.js';
|
|
7
8
|
import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
|
|
@@ -47,6 +48,12 @@ function buildContinuationPrompt(session: any, completed: any[]): string {
|
|
|
47
48
|
|
|
48
49
|
export function useSession(props: { agent: unknown; packageJson: Record<string, unknown>; runtime?: any }) {
|
|
49
50
|
const session = createSession();
|
|
51
|
+
// Donna's runtime__kill/cancel/status/approve/enqueue tools (graph.js)
|
|
52
|
+
// read session.runtime.url directly — without this the OpenTUI session
|
|
53
|
+
// never had it set (only the legacy runShell path did), so those tools
|
|
54
|
+
// reported "Runtime not connected" even with the runtime up and the
|
|
55
|
+
// status bar showing "connected".
|
|
56
|
+
(session as any).runtime = props.runtime ?? null;
|
|
50
57
|
const [version, setVersion] = createSignal(0);
|
|
51
58
|
const [logs, setLogs] = createSignal<string[]>([]);
|
|
52
59
|
const [runtimeState, setRuntimeState] = createSignal<any | null>(null);
|
|
@@ -139,7 +146,7 @@ export function useSession(props: { agent: unknown; packageJson: Record<string,
|
|
|
139
146
|
const statusLine = createMemo(() => {
|
|
140
147
|
version();
|
|
141
148
|
return [
|
|
142
|
-
`wiki-manager ${props.packageJson
|
|
149
|
+
`wiki-manager ${versionWithBuild(props.packageJson)}`.trim(),
|
|
143
150
|
session.workspace ? session.workspace : 'no workspace',
|
|
144
151
|
session.wikirc?.profile ? session.wikirc.profile : 'no wikirc',
|
|
145
152
|
session.language ? session.language : 'no language',
|
|
@@ -224,36 +231,64 @@ export function useSession(props: { agent: unknown; packageJson: Record<string,
|
|
|
224
231
|
? lastVisibleActivities.map((activity) => ({ ...activity }))
|
|
225
232
|
: current;
|
|
226
233
|
});
|
|
234
|
+
// Control-lane run requests (user messages queued while the runtime is
|
|
235
|
+
// busy) live in state.controlQueue, a separate lane from the MCP job
|
|
236
|
+
// queue. They must appear in the Queue panel too — otherwise a queued run
|
|
237
|
+
// request is invisible (Queue (0)) even though it is registered and will
|
|
238
|
+
// start automatically.
|
|
239
|
+
const controlQueueItems = createMemo(() => {
|
|
240
|
+
version();
|
|
241
|
+
const controlQueue = runtimeState()?.controlQueue;
|
|
242
|
+
if (!Array.isArray(controlQueue)) return [] as any[];
|
|
243
|
+
return controlQueue
|
|
244
|
+
.filter((item: any) => ['queued', 'running', 'starting'].includes(String(item.status ?? '').toLowerCase()))
|
|
245
|
+
.map((item: any) => ({
|
|
246
|
+
...item,
|
|
247
|
+
id: item.id,
|
|
248
|
+
label: `run: ${String(item.input ?? '').slice(0, 64)}`,
|
|
249
|
+
status: item.status,
|
|
250
|
+
_runtime: true,
|
|
251
|
+
_control: true,
|
|
252
|
+
}));
|
|
253
|
+
});
|
|
227
254
|
const queueItems = createMemo(() => {
|
|
228
255
|
version();
|
|
256
|
+
const control = controlQueueItems();
|
|
229
257
|
const workflowQueue = nonEmptyRuntimeArray(runtimeState()?.workflow?.nodes?.filter((node: any) => node.type === 'queue'));
|
|
230
258
|
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 }));
|
|
259
|
+
return [...control, ...workflowQueue.map((node: any) => ({ ...(node.raw ?? {}), id: node.itemId ?? node.id, label: node.label, status: node.status, _runtime: true, _workflow: true }))];
|
|
232
260
|
}
|
|
233
261
|
const runtimeQueue = nonEmptyRuntimeArray(runtimeState()?.queue);
|
|
234
|
-
if (runtimeQueue) return runtimeQueue.map((item: any) => ({ ...item, _runtime: true }));
|
|
235
|
-
return
|
|
236
|
-
|
|
262
|
+
if (runtimeQueue) return [...control, ...runtimeQueue.map((item: any) => ({ ...item, _runtime: true }))];
|
|
263
|
+
return [
|
|
264
|
+
...control,
|
|
265
|
+
...projectQueue((session as any).headlessPlan, (session as any).jobQueue ?? [], { workspace: (session as any).workspace ?? null })
|
|
266
|
+
.map((item: any) => ({ ...item })),
|
|
267
|
+
];
|
|
237
268
|
});
|
|
238
269
|
const queueInfo = createMemo(() => {
|
|
239
270
|
version();
|
|
271
|
+
const control = controlQueueItems();
|
|
272
|
+
const controlActive = control.length;
|
|
273
|
+
const controlCurrent = control.filter((item: any) => ['starting', 'running'].includes(String(item.status ?? '').toLowerCase())).length;
|
|
240
274
|
const workflowQueue = runtimeState()?.workflow?.nodes?.filter((node: any) => node.type === 'queue');
|
|
241
275
|
if (Array.isArray(workflowQueue)) {
|
|
242
276
|
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,
|
|
277
|
+
active: controlActive + workflowQueue.filter((item: any) => ['waiting', 'starting', 'running', 'queued', 'pending', 'pending_approval'].includes(String(item.status ?? '').toLowerCase())).length,
|
|
278
|
+
current: controlCurrent + workflowQueue.filter((item: any) => ['starting', 'running'].includes(String(item.status ?? '').toLowerCase())).length,
|
|
245
279
|
frozen: 0,
|
|
246
280
|
};
|
|
247
281
|
}
|
|
248
282
|
const runtimeQueue = runtimeState()?.queue;
|
|
249
283
|
if (Array.isArray(runtimeQueue)) {
|
|
250
284
|
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,
|
|
285
|
+
active: controlActive + runtimeQueue.filter((item: any) => ['waiting', 'starting', 'running', 'queued', 'pending', 'pending_approval'].includes(String(item.status ?? '').toLowerCase())).length,
|
|
286
|
+
current: controlCurrent + runtimeQueue.filter((item: any) => ['starting', 'running'].includes(String(item.status ?? '').toLowerCase())).length,
|
|
253
287
|
frozen: 0,
|
|
254
288
|
};
|
|
255
289
|
}
|
|
256
|
-
|
|
290
|
+
const base = queueCounts(session);
|
|
291
|
+
return { ...base, active: (base.active ?? 0) + controlActive, current: (base.current ?? 0) + controlCurrent };
|
|
257
292
|
});
|
|
258
293
|
const plan = createMemo(() => {
|
|
259
294
|
version();
|
|
@@ -309,6 +344,12 @@ export function useSession(props: { agent: unknown; packageJson: Record<string,
|
|
|
309
344
|
const target = conversationMessages(session);
|
|
310
345
|
const backed = runtimeConversationRefsByWorkspace.get(workspace) ?? [];
|
|
311
346
|
runtimeConversationRefsByWorkspace.set(workspace, backed);
|
|
347
|
+
// The merge is index-aligned with the runtime conversation array. If that
|
|
348
|
+
// array got SHORTER (runtime restart, projection reset), keeping stale
|
|
349
|
+
// refs would make every new runtime entry silently overwrite an old
|
|
350
|
+
// backed entry instead of appearing in the chat. Drop the refs and let
|
|
351
|
+
// the new conversation append cleanly.
|
|
352
|
+
if (runtimeConversation.length < backed.length) backed.length = 0;
|
|
312
353
|
if (backed.length === 0 && target.length === 0 && !runtimeConversation.some((message: any) => message?._pending)) {
|
|
313
354
|
backed.push(...runtimeConversation.map((message: any) => ({
|
|
314
355
|
role: message.role === 'assistant' ? 'donna' : message.role,
|