@dotdrelle/wiki-manager 0.15.96 → 0.15.98
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 +62 -5
- package/src/agent/graph.test.js +113 -1
- package/src/cli/wiki-manager.js +47 -33
- package/src/commands/slash.js +7 -2
- package/src/core/agentEvents.js +31 -1
- package/src/core/agentEvents.test.js +59 -0
- package/src/core/buildInfo.json +2 -2
- package/src/core/mcp.js +1 -1
- package/src/core/testGate.test.js +33 -0
- package/src/core/toolLoop.js +7 -1
- package/src/runtime/controlClassify.test.js +31 -0
- package/src/runtime/runner.js +13 -4
- package/src/runtime/runner.test.js +20 -0
- package/src/runtime/server.js +173 -35
- package/src/runtime/server.test.js +145 -14
- package/src/runtime/skillRun.js +2 -2
- package/src/runtime/skillRun.test.js +5 -3
- package/src/shell/openExternal.js +43 -0
- package/src/shell/repl.js +1 -1
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { classifyControlMessage } from './server.js';
|
|
4
|
+
|
|
5
|
+
const running = { running: true };
|
|
6
|
+
|
|
7
|
+
// A bare "yes" answers the runtime's own last prompt. Anything MORE than a
|
|
8
|
+
// bare yes is a request, and the rule used to be prefix-anchored only: every
|
|
9
|
+
// message merely STARTING on a yes was classified `observe` and answered with
|
|
10
|
+
// a status report, shadowing the modify_run and enqueue_run branches below it.
|
|
11
|
+
test('a bare confirmation during a run is a status check', async () => {
|
|
12
|
+
for (const input of ['oui', 'OK', 'vas-y', "d'accord", 'yes.', 'entendu !']) {
|
|
13
|
+
const result = await classifyControlMessage(input, running);
|
|
14
|
+
assert.equal(result.kind, 'observe', `expected observe for ${JSON.stringify(input)}`);
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test('a plan change that merely opens on a yes is still a plan change', async () => {
|
|
19
|
+
const result = await classifyControlMessage(
|
|
20
|
+
'oui, ajoute une étape de polish après le build',
|
|
21
|
+
running,
|
|
22
|
+
);
|
|
23
|
+
assert.equal(result.kind, 'modify_run');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('a new task that opens on a yes reaches the model classifier, not the status branch', async () => {
|
|
27
|
+
const result = await classifyControlMessage("vas-y lance l'export", running, {
|
|
28
|
+
llm: { complete: async () => 'action' },
|
|
29
|
+
});
|
|
30
|
+
assert.equal(result.kind, 'enqueue_run');
|
|
31
|
+
});
|
package/src/runtime/runner.js
CHANGED
|
@@ -9,7 +9,7 @@ import { createBudgetManager, BudgetExceededError } from '../orchestrator/budget
|
|
|
9
9
|
import { createDispatcher } from '../orchestrator/dispatcher.js';
|
|
10
10
|
import { approvalCovered, approvalRequestForTask } from '../orchestrator/approvalPolicy.js';
|
|
11
11
|
import { blockedByFailedDependency, tasksAwaitingApproval } from '../orchestrator/dependencyResolver.js';
|
|
12
|
-
import { isFailed, isPending, isSkipped, isSuccessful, isTerminal, isUnknownStatus } from '../orchestrator/taskStatuses.js';
|
|
12
|
+
import { isCancelled, isFailed, isPending, isSkipped, isSuccessful, isTerminal, isUnknownStatus } from '../orchestrator/taskStatuses.js';
|
|
13
13
|
import { assertValidatedFragment } from '../orchestrator/planValidator.js';
|
|
14
14
|
import { createResultAggregator } from '../orchestrator/resultAggregator.js';
|
|
15
15
|
import { describePlanConcurrency, drainActive, startReadyTasks } from '../orchestrator/scheduler.js';
|
|
@@ -308,6 +308,7 @@ export async function announceRunOutcome(session, { runId, ok, signal = null } =
|
|
|
308
308
|
if (plan.length === 0) return;
|
|
309
309
|
let failed = 0;
|
|
310
310
|
let cancelled = 0;
|
|
311
|
+
let skipped = 0;
|
|
311
312
|
let completed = 0;
|
|
312
313
|
let pending = 0;
|
|
313
314
|
let firstError = null;
|
|
@@ -319,8 +320,14 @@ export async function announceRunOutcome(session, { runId, ok, signal = null } =
|
|
|
319
320
|
step?.error?.message ?? step?.error?.code ?? step?.error
|
|
320
321
|
?? step?.result?.error?.message ?? step?.result?.error?.code ?? '',
|
|
321
322
|
).trim() || null;
|
|
322
|
-
} else if (
|
|
323
|
+
} else if (isCancelled(status)) {
|
|
323
324
|
cancelled += 1;
|
|
325
|
+
} else if (isSkipped(status)) {
|
|
326
|
+
// A chain step abandoned because an earlier required one failed. It fell
|
|
327
|
+
// through every bucket, so a 3-step chain that failed at step 1
|
|
328
|
+
// announced "0/3 réussie(s), 1 en erreur" and never mentioned the two
|
|
329
|
+
// steps nobody ran — precisely the silence the announce rule forbids.
|
|
330
|
+
skipped += 1;
|
|
324
331
|
} else if (isSuccessful(status)) {
|
|
325
332
|
completed += 1;
|
|
326
333
|
} else if (isPending(status) || !isTerminal(status)) {
|
|
@@ -333,13 +340,15 @@ export async function announceRunOutcome(session, { runId, ok, signal = null } =
|
|
|
333
340
|
}
|
|
334
341
|
}
|
|
335
342
|
const total = plan.length;
|
|
336
|
-
const finished = ok && failed === 0 && cancelled === 0 &&
|
|
343
|
+
const finished = ok && failed === 0 && cancelled === 0 && skipped === 0
|
|
344
|
+
&& pending === 0 && completed === total;
|
|
337
345
|
const factLine = finished
|
|
338
346
|
? `Plan terminé avec succès — ${completed}/${total} tâche(s) réussie(s).`
|
|
339
347
|
: `Plan non terminé — ${completed}/${total} tâche(s) réussie(s)` +
|
|
340
348
|
`${pending ? `, ${pending} en attente (approbation ou exécution)` : ''}` +
|
|
341
349
|
`${failed ? `, ${failed} en erreur` : ''}` +
|
|
342
|
-
`${cancelled ? `, ${cancelled} annulée(s)` : ''}
|
|
350
|
+
`${cancelled ? `, ${cancelled} annulée(s)` : ''}` +
|
|
351
|
+
`${skipped ? `, ${skipped} abandonnée(s) faute d'une étape précédente` : ''}.` +
|
|
343
352
|
`${firstError ? ` Première erreur : ${firstError}.` : ''}`;
|
|
344
353
|
let content = factLine;
|
|
345
354
|
const llm = session.llm;
|
|
@@ -1306,6 +1306,26 @@ test('announceRunOutcome never calls a plan with pending tasks a success', async
|
|
|
1306
1306
|
assert.doesNotMatch(message.payload.content, /succès/i);
|
|
1307
1307
|
});
|
|
1308
1308
|
|
|
1309
|
+
test('announceRunOutcome names the steps a failed chain abandoned', async () => {
|
|
1310
|
+
// `skipped` fell through every bucket: a 3-step chain failing at step 1
|
|
1311
|
+
// announced "0/3 réussie(s), 1 en erreur" and never mentioned the two steps
|
|
1312
|
+
// nobody ran. "When something is skipped, say so where the panels read."
|
|
1313
|
+
const session = {
|
|
1314
|
+
agentEvents: [],
|
|
1315
|
+
agentProjection: null,
|
|
1316
|
+
headlessPlan: [
|
|
1317
|
+
{ id: 'a', description: 'Sync', status: 'failed' },
|
|
1318
|
+
{ id: 'b', description: 'Ingest', status: 'skipped' },
|
|
1319
|
+
{ id: 'c', description: 'Build', status: 'skipped' },
|
|
1320
|
+
],
|
|
1321
|
+
};
|
|
1322
|
+
await announceRunOutcome(session, { runId: 'run-3', ok: false });
|
|
1323
|
+
const message = session.agentEvents.find((event) => event.type === 'assistant_message');
|
|
1324
|
+
assert.match(message.payload.content, /non terminé/i);
|
|
1325
|
+
assert.match(message.payload.content, /1 en erreur/);
|
|
1326
|
+
assert.match(message.payload.content, /2 abandonnée\(s\)/);
|
|
1327
|
+
});
|
|
1328
|
+
|
|
1309
1329
|
test('announceRunOutcome reports success only when every task finished', async () => {
|
|
1310
1330
|
const session = {
|
|
1311
1331
|
agentEvents: [],
|
package/src/runtime/server.js
CHANGED
|
@@ -7,6 +7,7 @@ import { validateContractInDev } from '../contracts/schemas.js';
|
|
|
7
7
|
import { runtimeTokenFromEnv } from './auth.js';
|
|
8
8
|
import { controlMessage } from './controlMessages.js';
|
|
9
9
|
import { tasksAwaitingApproval } from '../orchestrator/dependencyResolver.js';
|
|
10
|
+
import { isActive, isCancelled, isFailed, isSuccessful } from '../orchestrator/taskStatuses.js';
|
|
10
11
|
import { approvalClassForTask } from '../orchestrator/approvalPolicy.js';
|
|
11
12
|
import { RUNTIME_SHUTDOWN_ABORT_REASON } from '../orchestrator/dispatcher.js';
|
|
12
13
|
import { matchSkillInvocation } from '../core/skillInvocation.js';
|
|
@@ -502,11 +503,17 @@ export function startRuntimeServer({
|
|
|
502
503
|
}
|
|
503
504
|
if (request.method === 'POST' && url.pathname === '/turn') {
|
|
504
505
|
const { body, context } = await resolveBodyContext(request, url);
|
|
505
|
-
|
|
506
|
+
let input = String(body.input ?? body.prompt ?? '').trim();
|
|
506
507
|
if (!input) {
|
|
507
508
|
sendJson(response, 400, { error: 'Missing input.' });
|
|
508
509
|
return;
|
|
509
510
|
}
|
|
511
|
+
// What the reader actually typed. `input` below may be replaced by a
|
|
512
|
+
// system fact block for the model; the THREAD must still show the
|
|
513
|
+
// reader's own words — the replacement was persisted as the
|
|
514
|
+
// `user_message` and replayed as history on every later turn, which is
|
|
515
|
+
// exactly the "raw facts never enter the thread" rule it broke.
|
|
516
|
+
const displayInput = input;
|
|
510
517
|
// Read-only chat turns intentionally remain available while an agent
|
|
511
518
|
// run is active. Other interactive turns still become control
|
|
512
519
|
// messages so they cannot start a competing agent decision.
|
|
@@ -529,19 +536,15 @@ export function startRuntimeServer({
|
|
|
529
536
|
}
|
|
530
537
|
return;
|
|
531
538
|
}
|
|
532
|
-
// A run/job status question
|
|
533
|
-
// the
|
|
534
|
-
//
|
|
535
|
-
//
|
|
539
|
+
// A run/job status question must NEVER surface the raw system text in
|
|
540
|
+
// the thread: the runtime collects the facts and hands them to Donna,
|
|
541
|
+
// who synthesizes them in the session language. `readOnlyChat` so the
|
|
542
|
+
// turn is a conversation, not a control decision — and the facts are
|
|
543
|
+
// supplied here so the model cannot mistake the runtime run id for a
|
|
544
|
+
// production job id ("job not found").
|
|
536
545
|
if (asksForRunStatus(input)) {
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
accepted: true,
|
|
540
|
-
kind: 'observe',
|
|
541
|
-
...status,
|
|
542
|
-
explanation: explainControlState(status),
|
|
543
|
-
});
|
|
544
|
-
return;
|
|
546
|
+
input = runtimeStatusSynthesisPrompt(input, controlStatus(context, store));
|
|
547
|
+
readOnlyChat = true;
|
|
545
548
|
}
|
|
546
549
|
if (context.running && !readOnlyChat) {
|
|
547
550
|
// Agent-mode message while a run is active. Classify once: control
|
|
@@ -552,7 +555,13 @@ export function startRuntimeServer({
|
|
|
552
555
|
llm: context?.session?.llm,
|
|
553
556
|
session: context?.session,
|
|
554
557
|
});
|
|
555
|
-
if (classification.kind
|
|
558
|
+
if (classification.kind === 'observe') {
|
|
559
|
+
// An observation is system facts, not a control action: Donna gets
|
|
560
|
+
// them and answers in the session language, never a raw English
|
|
561
|
+
// line pushed into the thread.
|
|
562
|
+
input = runtimeStatusSynthesisPrompt(input, controlStatus(context, store));
|
|
563
|
+
readOnlyChat = true;
|
|
564
|
+
} else if (classification.kind !== 'converse') {
|
|
556
565
|
const result = await handleControlMessage(context, store, input, {
|
|
557
566
|
intent: body.intent,
|
|
558
567
|
startNextControlRequest,
|
|
@@ -561,8 +570,9 @@ export function startRuntimeServer({
|
|
|
561
570
|
});
|
|
562
571
|
sendJson(response, result.statusCode, result.body);
|
|
563
572
|
return;
|
|
573
|
+
} else {
|
|
574
|
+
readOnlyChat = true;
|
|
564
575
|
}
|
|
565
|
-
readOnlyChat = true;
|
|
566
576
|
}
|
|
567
577
|
if (typeof turn !== 'function') {
|
|
568
578
|
sendJson(response, 501, { error: 'Runtime interactive turns are unavailable.' });
|
|
@@ -580,7 +590,10 @@ export function startRuntimeServer({
|
|
|
580
590
|
llm: context?.session?.llm,
|
|
581
591
|
session: context?.session,
|
|
582
592
|
});
|
|
583
|
-
if (classification.kind
|
|
593
|
+
if (classification.kind === 'observe') {
|
|
594
|
+
input = runtimeStatusSynthesisPrompt(input, controlStatus(context, store));
|
|
595
|
+
readOnlyChat = true;
|
|
596
|
+
} else if (classification.kind !== 'converse') {
|
|
584
597
|
const result = await handleControlMessage(context, store, input, {
|
|
585
598
|
intent: body.intent,
|
|
586
599
|
startNextControlRequest,
|
|
@@ -596,7 +609,12 @@ export function startRuntimeServer({
|
|
|
596
609
|
return result.body;
|
|
597
610
|
}
|
|
598
611
|
}
|
|
599
|
-
return turn(context, {
|
|
612
|
+
return turn(context, {
|
|
613
|
+
...body,
|
|
614
|
+
input,
|
|
615
|
+
displayInput,
|
|
616
|
+
mode: readOnlyChat ? 'chat' : body.mode,
|
|
617
|
+
}, {
|
|
600
618
|
signal: controller.signal,
|
|
601
619
|
turnId,
|
|
602
620
|
});
|
|
@@ -867,12 +885,24 @@ export function startRuntimeServer({
|
|
|
867
885
|
if (context?.session) {
|
|
868
886
|
context.session._runSkillWithinRun = async (skillName, args = {}, metadata = {}) => {
|
|
869
887
|
const skill = findSkill(context.session, skillName);
|
|
870
|
-
if (!skill)
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
888
|
+
if (!skill) {
|
|
889
|
+
const available = listSkills(context.session).map((item) => item.name);
|
|
890
|
+
/*
|
|
891
|
+
A skill the model GUESSED is recoverable, not terminal: the observed
|
|
892
|
+
failure was `/diagnose` (leading slash copied from the catalogue) →
|
|
893
|
+
skill_not_found → the terminal path stripped the tools from the
|
|
894
|
+
synthesis turn → the model wrote `runtime__delegate{...}` as plain
|
|
895
|
+
text and the turn did nothing. An explicitly user-named missing skill
|
|
896
|
+
stays terminal: there is nothing to fall back to.
|
|
897
|
+
*/
|
|
898
|
+
return {
|
|
899
|
+
ok: false,
|
|
900
|
+
terminal: metadata.selectionKind === 'explicit_name',
|
|
901
|
+
code: 'skill_not_found',
|
|
902
|
+
message: `No skill named "${skillName}". Pass the exact name without a leading slash (${available.join(', ') || 'none'}), or delegate the objective with runtime__delegate.`,
|
|
903
|
+
availableSkills: available,
|
|
904
|
+
};
|
|
905
|
+
}
|
|
876
906
|
try {
|
|
877
907
|
const idempotencyKey = metadata.idempotencyKey
|
|
878
908
|
? String(metadata.idempotencyKey)
|
|
@@ -1161,22 +1191,109 @@ export function runtimeState(context, store, { workspace = null, session = null
|
|
|
1161
1191
|
// own history) so those replies surface. The log is a superset of the
|
|
1162
1192
|
// canonical run conversation, so run rendering is unaffected.
|
|
1163
1193
|
conversation: reduceAgentEvents(store.listEvents({ workspace })).conversation,
|
|
1164
|
-
|
|
1194
|
+
// `context.running` keeps the process alive while the scheduler waits for an
|
|
1195
|
+
// approval, but the run is then NOT running — the reducer already says
|
|
1196
|
+
// `pending_approval`. Prefer it over the blanket override.
|
|
1197
|
+
status: context?.running
|
|
1198
|
+
? (state.status === 'pending_approval' ? 'pending_approval' : 'running')
|
|
1199
|
+
: state.status ?? 'idle',
|
|
1165
1200
|
running: Boolean(context?.running),
|
|
1166
1201
|
runId: context?.currentRunId ?? state.runId ?? null,
|
|
1167
1202
|
workspace: context?.currentRunWorkspace ?? context?.workspace ?? state.workspace ?? workspace ?? null,
|
|
1168
1203
|
};
|
|
1169
1204
|
}
|
|
1170
1205
|
|
|
1206
|
+
// The live figures of the activity the run is on, in one line: percent, plan
|
|
1207
|
+
// step, build batch, instruction count and the stabilize counters. A status
|
|
1208
|
+
// that only named the current step could not tell 5% from 95%, nor what the
|
|
1209
|
+
// running batch had actually done.
|
|
1210
|
+
function describeActivityProgress(activity) {
|
|
1211
|
+
const progress = activity?.progress ?? {};
|
|
1212
|
+
const bits = [];
|
|
1213
|
+
if (Number.isFinite(Number(progress.percent))) bits.push(`${Number(progress.percent)}%`);
|
|
1214
|
+
if (progress.stepIndex != null && progress.stepTotal != null) bits.push(`step ${progress.stepIndex}/${progress.stepTotal}`);
|
|
1215
|
+
if (progress.batchIndex != null && progress.batchCount != null) bits.push(`batch ${Number(progress.batchIndex) + 1}/${progress.batchCount}`);
|
|
1216
|
+
if (progress.instructionCount != null) bits.push(`${progress.instructionCount} instruction${Number(progress.instructionCount) > 1 ? 's' : ''}`);
|
|
1217
|
+
const stabilize = [progress.stabilizeKept, progress.stabilizeMerged, progress.stabilizeInserted, progress.stabilizeRemoved];
|
|
1218
|
+
if (stabilize.some((value) => value != null)) {
|
|
1219
|
+
bits.push(`kept ${progress.stabilizeKept ?? 0}, merged ${progress.stabilizeMerged ?? 0}, inserted ${progress.stabilizeInserted ?? 0}, removed ${progress.stabilizeRemoved ?? 0}`);
|
|
1220
|
+
}
|
|
1221
|
+
const detail = progress.detail && !bits.includes(String(progress.detail)) ? String(progress.detail) : null;
|
|
1222
|
+
return { bits: bits.join(' · '), detail, label: activity?.label ?? null };
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
// The facts a status answer is built from, rendered server-side ONCE. A status
|
|
1226
|
+
// question is answered by Donna, never by pushing this text into the thread:
|
|
1227
|
+
// the runtime supplies the figures, she phrases them in the session language.
|
|
1228
|
+
function runtimeStatusFacts(status) {
|
|
1229
|
+
const plan = Array.isArray(status.plan) ? status.plan : [];
|
|
1230
|
+
const approvals = Array.isArray(status.approvals) ? status.approvals : [];
|
|
1231
|
+
const queue = Array.isArray(status.controlQueue) ? status.controlQueue : [];
|
|
1232
|
+
const activities = Array.isArray(status.activities)
|
|
1233
|
+
? status.activities
|
|
1234
|
+
: Object.values(status.activities ?? {});
|
|
1235
|
+
const lines = [
|
|
1236
|
+
`Runtime status: ${status.status ?? 'idle'}`,
|
|
1237
|
+
`Workspace: ${status.workspace ?? '-'}`,
|
|
1238
|
+
];
|
|
1239
|
+
if (status.runId) lines.push(`Run id: ${status.runId}`);
|
|
1240
|
+
for (const activity of activities.filter((entry) => !entry?.terminal).slice(0, 8)) {
|
|
1241
|
+
const info = describeActivityProgress(activity);
|
|
1242
|
+
const detail = [info.bits, info.detail].filter(Boolean).join(' · ');
|
|
1243
|
+
lines.push(
|
|
1244
|
+
`Activity: ${info.label ?? activity.label ?? activity.id ?? '-'} — ${activity.status ?? '-'}${detail ? ` (${detail})` : ''}`,
|
|
1245
|
+
);
|
|
1246
|
+
}
|
|
1247
|
+
for (const [index, step] of plan.slice(0, 60).entries()) {
|
|
1248
|
+
lines.push(`Task ${step.step ?? index + 1}: ${step.status ?? 'pending'} - ${step.description ?? step.label ?? step.id ?? 'step'}`);
|
|
1249
|
+
}
|
|
1250
|
+
for (const approval of approvals.filter((entry) => entry.status === 'pending_approval')) {
|
|
1251
|
+
lines.push(`Pending approval: ${approval.reason ?? approval.taskId ?? approval.id ?? '-'}`);
|
|
1252
|
+
}
|
|
1253
|
+
for (const item of queue.filter((entry) => entry.status === 'queued')) {
|
|
1254
|
+
lines.push(`Queued: ${item.label ?? item.input ?? item.id ?? '-'}`);
|
|
1255
|
+
}
|
|
1256
|
+
return lines.join('\n');
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
function runtimeStatusSynthesisPrompt(asked, status) {
|
|
1260
|
+
return [
|
|
1261
|
+
'The runtime facts below are the authoritative status the system just collected (this is a runtime run, not a production job — do not look up a job id).',
|
|
1262
|
+
`User question: ${asked}`,
|
|
1263
|
+
'Answer in the session language with a concise, natural status: name the requested target first, then progress, blockers (pending approvals), queued items and the next step.',
|
|
1264
|
+
'Keep every figure (percent, step, batch, instruction and stabilize counts) and every task status accurate; never invent, drop or round away a figure. Do not paste the fact block verbatim; summarize it into prose.',
|
|
1265
|
+
'',
|
|
1266
|
+
'Runtime facts:',
|
|
1267
|
+
runtimeStatusFacts(status),
|
|
1268
|
+
].join('\n');
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1171
1271
|
function explainControlState(status) {
|
|
1172
1272
|
const plan = Array.isArray(status.plan) ? status.plan : [];
|
|
1273
|
+
const activities = Array.isArray(status.activities)
|
|
1274
|
+
? status.activities
|
|
1275
|
+
: Object.values(status.activities ?? {});
|
|
1276
|
+
// A run whose only outstanding work is a human decision is not "running":
|
|
1277
|
+
// `status.running` mirrors the process, which stays alive while the scheduler
|
|
1278
|
+
// waits. Mirrors the reducer's rule — pending_approval, or a pending approval
|
|
1279
|
+
// with no step actually executing.
|
|
1280
|
+
const approvals = Array.isArray(status.approvals) ? status.approvals : [];
|
|
1281
|
+
const pendingApproval = approvals.find((approval) => approval.status === 'pending_approval');
|
|
1282
|
+
const awaitingApproval = pendingApproval
|
|
1283
|
+
&& (status.status === 'pending_approval' || !plan.some((step) => isActive(step?.status)));
|
|
1284
|
+
if (awaitingApproval) {
|
|
1285
|
+
return `Runtime is waiting for approval: ${pendingApproval.reason ?? pendingApproval.id}.`;
|
|
1286
|
+
}
|
|
1173
1287
|
if (status.running) {
|
|
1174
1288
|
const runningStep = plan.find((step) => step.status === 'running');
|
|
1289
|
+
const activity = activities.find((entry) => !entry?.terminal) ?? activities[0] ?? null;
|
|
1290
|
+
const info = activity ? describeActivityProgress(activity) : { bits: '', detail: null, label: null };
|
|
1291
|
+
const detailText = [info.bits, info.detail].filter(Boolean).join(' · ');
|
|
1292
|
+
const suffix = detailText ? ` (${detailText})` : '';
|
|
1175
1293
|
return runningStep
|
|
1176
|
-
? `Runtime run is active. Current step: ${runningStep.description ?? runningStep.label ?? runningStep.step}
|
|
1177
|
-
:
|
|
1294
|
+
? `Runtime run is active. Current step: ${runningStep.description ?? runningStep.label ?? runningStep.step}.${suffix}`
|
|
1295
|
+
: `Runtime run is active. No current plan step is available yet.${suffix}`;
|
|
1178
1296
|
}
|
|
1179
|
-
const pendingApproval = status.approvals.find((approval) => approval.status === 'pending_approval');
|
|
1180
1297
|
if (pendingApproval) {
|
|
1181
1298
|
return `Runtime is waiting for approval: ${pendingApproval.reason ?? pendingApproval.id}.`;
|
|
1182
1299
|
}
|
|
@@ -1187,6 +1304,15 @@ function explainControlState(status) {
|
|
|
1187
1304
|
if (plan.some((step) => step.status === 'pending')) {
|
|
1188
1305
|
return 'Runtime is idle with pending plan steps visible from the last run.';
|
|
1189
1306
|
}
|
|
1307
|
+
// Idle at the end of a run: say what the last run did, not just "idle" — that
|
|
1308
|
+
// is the question the operator actually asks when the thread goes quiet.
|
|
1309
|
+
const failed = plan.filter((step) => isFailed(step.status) || isCancelled(step.status)).length;
|
|
1310
|
+
const done = plan.filter((step) => isSuccessful(step.status)).length;
|
|
1311
|
+
if (plan.length > 0) {
|
|
1312
|
+
return failed > 0
|
|
1313
|
+
? `Runtime is idle. Last run: ${done}/${plan.length} task(s) succeeded, ${failed} failed or cancelled.`
|
|
1314
|
+
: `Runtime is idle. Last run: ${done}/${plan.length} task(s) succeeded.`;
|
|
1315
|
+
}
|
|
1190
1316
|
return 'Runtime is idle.';
|
|
1191
1317
|
}
|
|
1192
1318
|
|
|
@@ -1646,13 +1772,22 @@ function rejectPlanPatch(context, store, patchId, reason) {
|
|
|
1646
1772
|
};
|
|
1647
1773
|
}
|
|
1648
1774
|
|
|
1649
|
-
// A question about the run/job currently executing.
|
|
1650
|
-
// status word AND a run/job noun — so it never hijacks
|
|
1651
|
-
// X works" question. Such a question must be answered
|
|
1652
|
-
// left to the model, a runtime runId was mistaken for a
|
|
1653
|
-
// reported as "not found", and a read-only chat turn had
|
|
1775
|
+
// A question about the run/job currently executing. The free-text form is
|
|
1776
|
+
// deliberately narrow — a status word AND a run/job noun — so it never hijacks
|
|
1777
|
+
// an ordinary "explain how X works" question. Such a question must be answered
|
|
1778
|
+
// by the runtime itself: left to the model, a runtime runId was mistaken for a
|
|
1779
|
+
// production job id and reported as "not found", and a read-only chat turn had
|
|
1780
|
+
// no runtime status tool.
|
|
1781
|
+
//
|
|
1782
|
+
// The reserved built-in `/status` is ALWAYS a runtime status, in every surface:
|
|
1783
|
+
// `RESERVED_SLASH_COMMANDS` keeps the homonymous workspace skill out of
|
|
1784
|
+
// `matchSkillInvocation`, but without this branch `/turn` still handed the
|
|
1785
|
+
// literal command to the model, which ran the skill (English "status" output) or
|
|
1786
|
+
// an unrelated review instead of reporting anything. Serve types `/status` into
|
|
1787
|
+
// this endpoint; the ShellUI answers it locally.
|
|
1654
1788
|
function asksForRunStatus(input) {
|
|
1655
|
-
const text = String(input ?? '');
|
|
1789
|
+
const text = String(input ?? '').trim();
|
|
1790
|
+
if (/^\/status(?:\s|$)/i.test(text)) return true;
|
|
1656
1791
|
const statusWord = /\b(status|statut|progression|progress|avancement|o[uù] en est|o[uù] en sont)\b/i;
|
|
1657
1792
|
const runNoun = /\b(job|run|t[aâ]che|task|build|ingest|pipeline|export|polish|traitement)\b/i;
|
|
1658
1793
|
return statusWord.test(text) && runNoun.test(text);
|
|
@@ -1665,7 +1800,7 @@ function asksForRunStatus(input) {
|
|
|
1665
1800
|
// semantic judgement about the workspace's domain, so it is never a keyword
|
|
1666
1801
|
// list here — it goes to the model, bounded, and falls back to the choice menu
|
|
1667
1802
|
// (`ambiguous`) rather than guessing when no model is available.
|
|
1668
|
-
async function classifyControlMessage(input, status, { forcedIntent = null, llm = null, session = null } = {}) {
|
|
1803
|
+
export async function classifyControlMessage(input, status, { forcedIntent = null, llm = null, session = null } = {}) {
|
|
1669
1804
|
// Caller (the /control message route) already trims and rejects empty input.
|
|
1670
1805
|
const lower = String(input ?? '').toLowerCase();
|
|
1671
1806
|
const intent = forcedIntent ? String(forcedIntent).toLowerCase() : null;
|
|
@@ -1699,7 +1834,10 @@ async function classifyControlMessage(input, status, { forcedIntent = null, llm
|
|
|
1699
1834
|
// active, the only thing the runtime can act on is a status check: treating
|
|
1700
1835
|
// the word as ordinary conversation made the read-only chat fallback lecture
|
|
1701
1836
|
// the user about switching modes instead of answering.
|
|
1702
|
-
|
|
1837
|
+
// Anchored at BOTH ends: "oui" is a confirmation, "oui, ajoute une étape de
|
|
1838
|
+
// polish" is a plan change. Without the end anchor this branch shadowed
|
|
1839
|
+
// modify_run and enqueue_run for every message merely STARTING on a yes.
|
|
1840
|
+
if (status.running && /^\s*(oui|yes|yep|ok|okay|vas[- ]?y|d'accord|daccord|entendu)\s*[.!…]*\s*$/i.test(lower)) {
|
|
1703
1841
|
return { kind: 'observe', confidence: 0.7, reason: 'confirmation_of_runtime_prompt' };
|
|
1704
1842
|
}
|
|
1705
1843
|
if (status.running && /\b(ajoute|add|change|modifie|modify|remplace|replace|retire|remove|skip|ignore|apr[eè]s|before|after|chaque|each|plan|step|t[aâ]che)\b/i.test(lower)) {
|
|
@@ -1733,7 +1733,7 @@ test('POST /turn keeps informational skill and build questions conversational',
|
|
|
1733
1733
|
}
|
|
1734
1734
|
});
|
|
1735
1735
|
|
|
1736
|
-
test('POST /turn
|
|
1736
|
+
test('POST /turn hands a run status question to Donna with the runtime facts', async (t) => {
|
|
1737
1737
|
const session = { workspace: 'acme', controlQueue: [] };
|
|
1738
1738
|
const context = { workspace: 'acme', session, running: true, currentAbortController: null };
|
|
1739
1739
|
const status = {
|
|
@@ -1746,6 +1746,9 @@ test('POST /turn answers a run status question from the runtime instead of the m
|
|
|
1746
1746
|
conversation: [],
|
|
1747
1747
|
};
|
|
1748
1748
|
let turns = 0;
|
|
1749
|
+
let turnInput = '';
|
|
1750
|
+
let turnDisplayInput = '';
|
|
1751
|
+
let turnMode = null;
|
|
1749
1752
|
let handle;
|
|
1750
1753
|
try {
|
|
1751
1754
|
handle = await startRuntimeServer({
|
|
@@ -1753,24 +1756,95 @@ test('POST /turn answers a run status question from the runtime instead of the m
|
|
|
1753
1756
|
store: { dbPath: ':memory:', getState: () => status, listEvents: () => [] },
|
|
1754
1757
|
getContext: async () => context,
|
|
1755
1758
|
run: async () => new Promise(() => {}),
|
|
1756
|
-
turn: async () => {
|
|
1759
|
+
turn: async (_context, options) => {
|
|
1760
|
+
turns += 1;
|
|
1761
|
+
turnInput = options.input;
|
|
1762
|
+
turnDisplayInput = options.displayInput;
|
|
1763
|
+
turnMode = options.mode;
|
|
1764
|
+
return { ok: true };
|
|
1765
|
+
},
|
|
1757
1766
|
});
|
|
1758
1767
|
} catch (err) {
|
|
1759
1768
|
if (err?.code === 'EPERM') { t.skip('network listen is not permitted in this sandbox'); return; }
|
|
1760
1769
|
throw err;
|
|
1761
1770
|
}
|
|
1762
1771
|
try {
|
|
1763
|
-
//
|
|
1764
|
-
//
|
|
1772
|
+
// System facts never reach the thread as raw text: the runtime supplies
|
|
1773
|
+
// them to Donna, who synthesizes the answer. The facts also prevent the
|
|
1774
|
+
// model mistaking the runtime runId for a production job id.
|
|
1765
1775
|
const response = await fetch(`http://127.0.0.1:${handle.port}/turn?workspace=acme`, {
|
|
1766
1776
|
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
1767
1777
|
body: JSON.stringify({ input: 'donne le status du job en cours', mode: 'agent' }),
|
|
1768
1778
|
});
|
|
1769
1779
|
const body = await response.json();
|
|
1770
|
-
assert.equal(response.status,
|
|
1771
|
-
assert.equal(body.kind, '
|
|
1772
|
-
|
|
1773
|
-
|
|
1780
|
+
assert.equal(response.status, 202);
|
|
1781
|
+
assert.equal(body.kind, 'turn');
|
|
1782
|
+
// The turn is dispatched asynchronously after the 202.
|
|
1783
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
1784
|
+
assert.equal(turns, 1);
|
|
1785
|
+
assert.equal(turnMode, 'chat');
|
|
1786
|
+
assert.match(turnInput, /Build TechSections/);
|
|
1787
|
+
assert.match(turnInput, /runtime run, not a production job/i);
|
|
1788
|
+
// …and the THREAD still shows what the reader typed. `executeInteractiveTurn`
|
|
1789
|
+
// persists `displayInput` as the user_message; feeding it the fact block
|
|
1790
|
+
// put a raw English dump in the reader's own bubble and replayed it as
|
|
1791
|
+
// history on every later turn.
|
|
1792
|
+
assert.equal(turnDisplayInput, 'donne le status du job en cours');
|
|
1793
|
+
} finally {
|
|
1794
|
+
context.currentAbortController?.abort();
|
|
1795
|
+
await handle.close();
|
|
1796
|
+
}
|
|
1797
|
+
});
|
|
1798
|
+
|
|
1799
|
+
test('a run blocked on approval is described as waiting, not as running', async (t) => {
|
|
1800
|
+
const session = { workspace: 'acme', controlQueue: [] };
|
|
1801
|
+
const context = { workspace: 'acme', session, running: true, currentAbortController: null };
|
|
1802
|
+
const status = {
|
|
1803
|
+
status: 'pending_approval',
|
|
1804
|
+
running: true,
|
|
1805
|
+
plan: [{ step: 1, description: 'Rebuild the concepts', status: 'pending_approval' }],
|
|
1806
|
+
queue: [],
|
|
1807
|
+
controlQueue: [],
|
|
1808
|
+
approvals: [{ id: 'a1', status: 'pending_approval', reason: 'a mutating task needs approval' }],
|
|
1809
|
+
conversation: [],
|
|
1810
|
+
};
|
|
1811
|
+
let turns = 0;
|
|
1812
|
+
let turnInput = '';
|
|
1813
|
+
let handle;
|
|
1814
|
+
try {
|
|
1815
|
+
handle = await startRuntimeServer({
|
|
1816
|
+
host: '127.0.0.1', port: 0,
|
|
1817
|
+
store: { dbPath: ':memory:', getState: () => status, listEvents: () => [] },
|
|
1818
|
+
getContext: async () => context,
|
|
1819
|
+
run: async () => new Promise(() => {}),
|
|
1820
|
+
turn: async (_context, options) => { turns += 1; turnInput = options.input; return { ok: true }; },
|
|
1821
|
+
});
|
|
1822
|
+
} catch (err) {
|
|
1823
|
+
if (err?.code === 'EPERM') { t.skip('network listen is not permitted in this sandbox'); return; }
|
|
1824
|
+
throw err;
|
|
1825
|
+
}
|
|
1826
|
+
try {
|
|
1827
|
+
// The controller (`explainControlState`) must not call a pending approval
|
|
1828
|
+
// "running": the scheduler keeps `context.running` true while it waits.
|
|
1829
|
+
const control = await fetch(`http://127.0.0.1:${handle.port}/control?workspace=acme`, {
|
|
1830
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
1831
|
+
body: JSON.stringify({ action: 'explain' }),
|
|
1832
|
+
});
|
|
1833
|
+
const controlBody = await control.json();
|
|
1834
|
+
assert.match(controlBody.explanation, /waiting for approval/i);
|
|
1835
|
+
assert.doesNotMatch(controlBody.explanation, /is active/i);
|
|
1836
|
+
|
|
1837
|
+
// And the turn hands the same facts to Donna rather than dumping them.
|
|
1838
|
+
const response = await fetch(`http://127.0.0.1:${handle.port}/turn?workspace=acme`, {
|
|
1839
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
1840
|
+
body: JSON.stringify({ input: 'donne le status du run en cours', mode: 'agent' }),
|
|
1841
|
+
});
|
|
1842
|
+
const body = await response.json();
|
|
1843
|
+
assert.equal(response.status, 202);
|
|
1844
|
+
assert.equal(body.kind, 'turn');
|
|
1845
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
1846
|
+
assert.equal(turns, 1);
|
|
1847
|
+
assert.match(turnInput, /Pending approval: a mutating task needs approval/);
|
|
1774
1848
|
} finally {
|
|
1775
1849
|
context.currentAbortController?.abort();
|
|
1776
1850
|
await handle.close();
|
|
@@ -1790,6 +1864,7 @@ test('POST /turn treats a bare confirmation during a run as a status check', asy
|
|
|
1790
1864
|
conversation: [],
|
|
1791
1865
|
};
|
|
1792
1866
|
let turns = 0;
|
|
1867
|
+
let turnInput = '';
|
|
1793
1868
|
let handle;
|
|
1794
1869
|
try {
|
|
1795
1870
|
handle = await startRuntimeServer({
|
|
@@ -1797,23 +1872,79 @@ test('POST /turn treats a bare confirmation during a run as a status check', asy
|
|
|
1797
1872
|
store: { dbPath: ':memory:', getState: () => status, listEvents: () => [] },
|
|
1798
1873
|
getContext: async () => context,
|
|
1799
1874
|
run: async () => new Promise(() => {}),
|
|
1800
|
-
turn: async () => { turns += 1; return { ok: true }; },
|
|
1875
|
+
turn: async (_context, options) => { turns += 1; turnInput = options.input; return { ok: true }; },
|
|
1801
1876
|
});
|
|
1802
1877
|
} catch (err) {
|
|
1803
1878
|
if (err?.code === 'EPERM') { t.skip('network listen is not permitted in this sandbox'); return; }
|
|
1804
1879
|
throw err;
|
|
1805
1880
|
}
|
|
1806
1881
|
try {
|
|
1807
|
-
// "oui" answers the launch acknowledgement. It
|
|
1808
|
-
//
|
|
1882
|
+
// "oui" answers the launch acknowledgement. It is an observation, so it
|
|
1883
|
+
// reaches Donna with the runtime facts — not a deterministic English line
|
|
1884
|
+
// and not a read-only chat turn that lectures about switching modes.
|
|
1809
1885
|
const response = await fetch(`http://127.0.0.1:${handle.port}/turn?workspace=acme`, {
|
|
1810
1886
|
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
1811
1887
|
body: JSON.stringify({ input: 'oui', mode: 'agent' }),
|
|
1812
1888
|
});
|
|
1813
1889
|
const body = await response.json();
|
|
1814
|
-
assert.equal(response.status,
|
|
1815
|
-
assert.equal(body.kind, '
|
|
1816
|
-
|
|
1890
|
+
assert.equal(response.status, 202);
|
|
1891
|
+
assert.equal(body.kind, 'turn');
|
|
1892
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
1893
|
+
assert.equal(turns, 1);
|
|
1894
|
+
assert.match(turnInput, /Runtime facts:/);
|
|
1895
|
+
assert.match(turnInput, /Rebuild the wiki/);
|
|
1896
|
+
} finally {
|
|
1897
|
+
context.currentAbortController?.abort();
|
|
1898
|
+
await handle.close();
|
|
1899
|
+
}
|
|
1900
|
+
});
|
|
1901
|
+
|
|
1902
|
+
test('POST /turn answers the reserved /status command itself, never the homonymous skill', async (t) => {
|
|
1903
|
+
// A workspace skill named `status` exists precisely to prove the built-in
|
|
1904
|
+
// wins: `/status` was handed to the model, which ran that skill (English
|
|
1905
|
+
// output) or an unrelated review instead of reporting anything. Serve types
|
|
1906
|
+
// `/status` into /turn; only `/skills run status` may reach the skill.
|
|
1907
|
+
const root = mkdtempSync(join(tmpdir(), 'runtime-status-builtin-'));
|
|
1908
|
+
mkdirSync(join(root, '.wiki', 'skills'), { recursive: true });
|
|
1909
|
+
writeFileSync(join(root, '.wiki', 'skills', 'status.md'), '---\nname: status\n---\nInspect services.');
|
|
1910
|
+
const session = { workspace: 'acme', workspacePath: root, controlQueue: [] };
|
|
1911
|
+
const context = { workspace: 'acme', session, running: false, currentAbortController: null };
|
|
1912
|
+
const status = {
|
|
1913
|
+
status: 'idle',
|
|
1914
|
+
running: false,
|
|
1915
|
+
plan: [{ step: 1, description: 'Rebuild the wiki', status: 'done' }],
|
|
1916
|
+
queue: [],
|
|
1917
|
+
controlQueue: [],
|
|
1918
|
+
approvals: [],
|
|
1919
|
+
conversation: [],
|
|
1920
|
+
};
|
|
1921
|
+
let turns = 0;
|
|
1922
|
+
let turnInput = '';
|
|
1923
|
+
let handle;
|
|
1924
|
+
try {
|
|
1925
|
+
handle = await startRuntimeServer({
|
|
1926
|
+
host: '127.0.0.1', port: 0,
|
|
1927
|
+
store: { dbPath: ':memory:', getState: () => status, listEvents: () => [] },
|
|
1928
|
+
getContext: async () => context,
|
|
1929
|
+
run: async () => new Promise(() => {}),
|
|
1930
|
+
turn: async (_context, options) => { turns += 1; turnInput = options.input; return { ok: true }; },
|
|
1931
|
+
});
|
|
1932
|
+
} catch (err) {
|
|
1933
|
+
if (err?.code === 'EPERM') { t.skip('network listen is not permitted in this sandbox'); return; }
|
|
1934
|
+
throw err;
|
|
1935
|
+
}
|
|
1936
|
+
try {
|
|
1937
|
+
const response = await fetch(`http://127.0.0.1:${handle.port}/turn?workspace=acme`, {
|
|
1938
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
1939
|
+
body: JSON.stringify({ input: '/status', mode: 'agent' }),
|
|
1940
|
+
});
|
|
1941
|
+
const body = await response.json();
|
|
1942
|
+
assert.equal(response.status, 202);
|
|
1943
|
+
assert.equal(body.kind, 'turn');
|
|
1944
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
1945
|
+
assert.equal(turns, 1, 'the built-in status reaches Donna, never the homonymous skill');
|
|
1946
|
+
assert.match(turnInput, /Runtime facts:/);
|
|
1947
|
+
assert.doesNotMatch(turnInput, /Inspect services/);
|
|
1817
1948
|
} finally {
|
|
1818
1949
|
context.currentAbortController?.abort();
|
|
1819
1950
|
await handle.close();
|