@dotdrelle/wiki-manager 0.15.40 → 0.15.42
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 +3 -3
- package/src/activity/progressCalculator.js +3 -4
- package/src/agent/graph.js +40 -1
- package/src/cli/wiki-manager.js +64 -38
- package/src/commands/slash.js +47 -9
- package/src/core/activity.js +3 -2
- package/src/core/agentEvents.js +68 -11
- package/src/core/agentEvents.test.js +76 -0
- package/src/core/agentLoop.js +32 -6
- package/src/core/buildInfo.json +2 -2
- package/src/core/compose.js +32 -0
- package/src/core/dockerCompose.test.js +32 -0
- package/src/core/jobQueue.js +9 -0
- package/src/core/mcp.js +1 -1
- package/src/core/otherWorkspacesRunning.test.js +51 -0
- package/src/core/plan.js +3 -2
- package/src/core/planPatch.js +2 -1
- package/src/core/profileServiceStatus.test.js +26 -2
- package/src/core/toolLoop.js +32 -7
- package/src/core/toolLoop.test.js +95 -0
- package/src/core/wikiSetup.js +50 -7
- package/src/core/wikiWorkspace.test.js +30 -0
- package/src/core/wikirc.test.js +121 -2
- package/src/core/workflow.js +11 -2
- package/src/core/workspaceInherit.js +149 -0
- package/src/core/workspaceInherit.test.js +181 -0
- package/src/graph/graphVisibilityPolicy.js +2 -2
- package/src/orchestrator/agentRegistry.js +50 -0
- package/src/orchestrator/agentRegistry.test.js +76 -1
- package/src/orchestrator/approvalPolicy.js +2 -2
- package/src/orchestrator/dependencyResolver.js +52 -12
- package/src/orchestrator/dispatcher.js +2 -5
- package/src/orchestrator/planIntegrator.js +5 -5
- package/src/orchestrator/resultAggregator.js +2 -1
- package/src/orchestrator/scheduler.test.js +62 -1
- package/src/orchestrator/taskStatuses.js +99 -0
- package/src/orchestrator/taskStatuses.test.js +112 -0
- package/src/runtime/delegation.js +158 -0
- package/src/runtime/delegation.test.js +281 -0
- package/src/runtime/recoveryManager.js +2 -5
- package/src/runtime/recoveryManager.test.js +5 -1
- package/src/runtime/runner.js +129 -15
- package/src/runtime/runner.test.js +153 -6
- package/src/runtime/server.test.js +28 -0
- package/src/runtime/store.js +16 -1
- package/src/runtime/store.test.js +50 -0
- package/src/shell/repl.js +21 -11
- package/src/shell/setupWizardModality.test.js +55 -0
- package/src/shell/tui.tsx +29 -9
- package/wiki-workspace +10 -4
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import assert from 'node:assert/strict';
|
|
2
2
|
import test from 'node:test';
|
|
3
|
-
import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
|
|
4
|
-
import {
|
|
3
|
+
import { createAgentEvent, dispatchAgentEvent, reduceAgentEvents } from '../core/agentEvents.js';
|
|
4
|
+
import { tasksAwaitingApproval } from '../orchestrator/dependencyResolver.js';
|
|
5
|
+
import { isTerminal } from '../orchestrator/taskStatuses.js';
|
|
6
|
+
import { readyPlanTasks } from '../core/planPatch.js';
|
|
7
|
+
import { skipImpossibleTasks, structuredPlanEvaluation, ensurePlanProjection, evaluateRuntimeRun, finishRuntimeRun, materializeTaskInputs, replanRuntimeRun, runRuntimeAgenticWorkflow, runRuntimeParallelPlan, shouldUseParallelScheduler } from './runner.js';
|
|
5
8
|
|
|
6
9
|
test('ensurePlanProjection re-projects when the chained plan changes shape (step 2)', () => {
|
|
7
10
|
const session = { agentEvents: [], agentProjection: null };
|
|
@@ -821,7 +824,17 @@ test('runRuntimeParallelPlan fails cleanly when scheduler budget is exceeded', a
|
|
|
821
824
|
assert.equal(session.headlessPlan[0].status, 'cancelled');
|
|
822
825
|
});
|
|
823
826
|
|
|
824
|
-
|
|
827
|
+
/*
|
|
828
|
+
Ce test gardait l'ancien comportement : la tâche bloquée derrière un échec
|
|
829
|
+
laissait le run « stalled ». Ne pas attendre d'approbation était déjà acquis,
|
|
830
|
+
mais s'arrêter là déclenchait une replanification et laissait le run vivant.
|
|
831
|
+
|
|
832
|
+
Cas observé le 2026-08-04 (workspace juno) : dix fichiers à ingérer, neuf
|
|
833
|
+
réussis, un en échec sur du JSON malformé — le run n'est jamais retombé.
|
|
834
|
+
Une tâche qui ne deviendra jamais exécutable est donc marquée `skipped` avec
|
|
835
|
+
le nom de la dépendance fautive, et le run se termine sur un résultat partiel.
|
|
836
|
+
*/
|
|
837
|
+
test('runRuntimeParallelPlan skips work stuck behind a failed dependency instead of stalling', async () => {
|
|
825
838
|
const session = {
|
|
826
839
|
workspace: 'demo-workspace',
|
|
827
840
|
mcp: { tools: {} },
|
|
@@ -844,11 +857,17 @@ test('runRuntimeParallelPlan does not wait for approval behind a failed dependen
|
|
|
844
857
|
{ runId: 'run-failed-dependency', timeoutMs: 1000, maxTurns: 1 },
|
|
845
858
|
);
|
|
846
859
|
|
|
847
|
-
|
|
848
|
-
assert.
|
|
849
|
-
assert.equal(
|
|
860
|
+
// Plus de blocage : le run retombe au lieu de rester actif indéfiniment.
|
|
861
|
+
assert.notEqual(result.stalled, true);
|
|
862
|
+
assert.equal(session.headlessPlan[1].status, 'skipped');
|
|
863
|
+
assert.equal(session.headlessPlan[1].error?.code, 'dependency_failed');
|
|
864
|
+
// Le motif nomme la dépendance fautive : sans lui, l'utilisateur voit une
|
|
865
|
+
// tâche disparaître sans savoir pourquoi.
|
|
866
|
+
assert.match(session.headlessPlan[1].error.message, /\ba\b/);
|
|
850
867
|
assert.equal(session.agentEvents.some((event) => event.type === 'assistant_message'
|
|
851
868
|
&& /Approbation requise/.test(event.payload?.content ?? '')), false);
|
|
869
|
+
assert.equal(session.agentEvents.some((event) => event.type === 'plan_step_updated'
|
|
870
|
+
&& event.payload?.status === 'skipped'), true);
|
|
852
871
|
});
|
|
853
872
|
|
|
854
873
|
test('runRuntimeParallelPlan retries a retryable task on a fallback agent', async () => {
|
|
@@ -1083,3 +1102,131 @@ test('runtime runs are seeded with the chat that preceded them', async () => {
|
|
|
1083
1102
|
assert.equal(clipped.length, 1);
|
|
1084
1103
|
assert.equal(clipped[0].content.length, 2000);
|
|
1085
1104
|
});
|
|
1105
|
+
|
|
1106
|
+
test('skipImpossibleTasks propage un échec jusqu’au point fixe', () => {
|
|
1107
|
+
/*
|
|
1108
|
+
Marquer les seules tâches directement bloquées laissait un résidu : A en
|
|
1109
|
+
échec rend B impossible, mais C dépend de B et serait resté en attente
|
|
1110
|
+
d'une tâche désormais terminale sans succès. La propagation doit descendre
|
|
1111
|
+
toute la chaîne en une fois.
|
|
1112
|
+
*/
|
|
1113
|
+
const session = { workspace: 'demo-workspace', agentEvents: [] };
|
|
1114
|
+
const steps = [
|
|
1115
|
+
{ id: 'a', step: 1, status: 'failed' },
|
|
1116
|
+
{ id: 'b', step: 2, status: 'pending', dependsOn: ['a'] },
|
|
1117
|
+
{ id: 'c', step: 3, status: 'pending', dependsOn: ['b'] },
|
|
1118
|
+
{ id: 'independent', step: 4, status: 'pending', dependsOn: [] },
|
|
1119
|
+
];
|
|
1120
|
+
// Le plan passe par la projection, comme en production : chaque dépêche
|
|
1121
|
+
// remplace session.headlessPlan, et c'est précisément ce qui rendait une
|
|
1122
|
+
// référence capturée orpheline.
|
|
1123
|
+
dispatchAgentEvent(session, createAgentEvent('plan_set', { origin: 'tool', payload: { steps } }));
|
|
1124
|
+
|
|
1125
|
+
const skipped = skipImpossibleTasks(session, 'run-fixpoint');
|
|
1126
|
+
|
|
1127
|
+
assert.equal(skipped, 2);
|
|
1128
|
+
assert.equal(session.headlessPlan[1].status, 'skipped');
|
|
1129
|
+
assert.equal(session.headlessPlan[2].status, 'skipped');
|
|
1130
|
+
// La branche indépendante n'est pas touchée : c'est tout l'objet du
|
|
1131
|
+
// correctif, laisser finir ce qui peut finir.
|
|
1132
|
+
assert.equal(session.headlessPlan[3].status, 'pending');
|
|
1133
|
+
assert.equal(session.agentEvents.filter((event) => event.payload?.status === 'skipped').length, 2);
|
|
1134
|
+
});
|
|
1135
|
+
|
|
1136
|
+
test('skipImpossibleTasks s’arrête au lieu de tourner sans rien changer', () => {
|
|
1137
|
+
// Sans garde-fou, une incohérence de statut se paierait en boucle infinie,
|
|
1138
|
+
// c'est-à-dire en run figé — exactement ce que l'on répare.
|
|
1139
|
+
const session = { workspace: 'demo-workspace', agentEvents: [] };
|
|
1140
|
+
dispatchAgentEvent(session, createAgentEvent('plan_set', {
|
|
1141
|
+
origin: 'tool',
|
|
1142
|
+
payload: {
|
|
1143
|
+
steps: [
|
|
1144
|
+
{ id: 'a', step: 1, status: 'failed' },
|
|
1145
|
+
{ id: 'b', step: 2, status: 'skipped', dependsOn: ['a'] },
|
|
1146
|
+
],
|
|
1147
|
+
},
|
|
1148
|
+
}));
|
|
1149
|
+
const eventsBefore = session.agentEvents.length;
|
|
1150
|
+
|
|
1151
|
+
assert.equal(skipImpossibleTasks(session, 'run-guard', { maxPasses: 3 }), 0);
|
|
1152
|
+
assert.equal(session.agentEvents.length, eventsBefore);
|
|
1153
|
+
});
|
|
1154
|
+
|
|
1155
|
+
test('structuredPlanEvaluation compte séparément, sans fraction trompeuse', () => {
|
|
1156
|
+
const task = (id, status) => ({
|
|
1157
|
+
id, status, label: `Task ${id}`, requiredCapability: 'knowledge.update', operation: 'ingest',
|
|
1158
|
+
});
|
|
1159
|
+
|
|
1160
|
+
const allDone = structuredPlanEvaluation([task('a', 'done'), task('b', 'succeeded')]);
|
|
1161
|
+
assert.equal(allDone.ok, true);
|
|
1162
|
+
|
|
1163
|
+
const partial = structuredPlanEvaluation([
|
|
1164
|
+
task('a', 'done'), task('b', 'failed'), task('c', 'skipped'),
|
|
1165
|
+
]);
|
|
1166
|
+
assert.equal(partial.ok, false);
|
|
1167
|
+
// Trois compteurs qui s'additionnent, pas un « 1/3 » dont le dénominateur
|
|
1168
|
+
// mêlerait tâches métier et étapes techniques.
|
|
1169
|
+
assert.match(partial.reason, /1 réussie\(s\)/);
|
|
1170
|
+
assert.match(partial.reason, /1 en échec/);
|
|
1171
|
+
assert.match(partial.reason, /1 ignorée\(s\)/);
|
|
1172
|
+
assert.doesNotMatch(partial.reason, /\d+\/\d+/);
|
|
1173
|
+
|
|
1174
|
+
// Uniquement des ignorées : jamais un succès.
|
|
1175
|
+
const onlySkipped = structuredPlanEvaluation([task('a', 'skipped')]);
|
|
1176
|
+
assert.equal(onlySkipped.ok, false);
|
|
1177
|
+
|
|
1178
|
+
// Succès + ignorée sans échec : toujours pas un succès.
|
|
1179
|
+
const doneAndSkipped = structuredPlanEvaluation([task('a', 'done'), task('b', 'skipped')]);
|
|
1180
|
+
assert.equal(doneAndSkipped.ok, false);
|
|
1181
|
+
|
|
1182
|
+
// Statut actif ou inconnu : incomplet, jamais réussi.
|
|
1183
|
+
for (const status of ['running', 'brouette']) {
|
|
1184
|
+
const pending = structuredPlanEvaluation([task('a', 'done'), task('b', status)]);
|
|
1185
|
+
assert.equal(pending.ok, false, status);
|
|
1186
|
+
assert.match(pending.reason, /non terminée/);
|
|
1187
|
+
}
|
|
1188
|
+
});
|
|
1189
|
+
|
|
1190
|
+
test('une tâche ignorée est terminale pour la reprise et pour les approbations', () => {
|
|
1191
|
+
/*
|
|
1192
|
+
Vérifications transversales sur le nouveau statut : il ne suffit pas que
|
|
1193
|
+
l'ordonnanceur le comprenne. S'il n'est pas terminal pour la reprise, le
|
|
1194
|
+
redémarrage réattache un run fantôme ; s'il n'est pas terminal pour la
|
|
1195
|
+
politique d'approbation, on demande un accord pour une tâche qui ne sera
|
|
1196
|
+
jamais exécutée.
|
|
1197
|
+
*/
|
|
1198
|
+
const skippedTask = {
|
|
1199
|
+
id: 'ingest-b',
|
|
1200
|
+
status: 'skipped',
|
|
1201
|
+
requiresApproval: true,
|
|
1202
|
+
requiredCapability: 'knowledge.update',
|
|
1203
|
+
operation: 'ingest_apply',
|
|
1204
|
+
};
|
|
1205
|
+
|
|
1206
|
+
assert.equal(isTerminal(skippedTask.status), true);
|
|
1207
|
+
assert.deepEqual(tasksAwaitingApproval([skippedTask], { approvals: [] }), []);
|
|
1208
|
+
assert.deepEqual(readyPlanTasks([skippedTask]).map((task) => task.id), []);
|
|
1209
|
+
});
|
|
1210
|
+
|
|
1211
|
+
test('rejouer les événements redonne exactement les mêmes statuts', () => {
|
|
1212
|
+
// La projection est reconstruite au démarrage à partir du journal : si le
|
|
1213
|
+
// rejeu ne rendait pas le même état, une tâche ignorée pourrait réapparaître
|
|
1214
|
+
// en attente et relancer un travail déjà abandonné.
|
|
1215
|
+
const events = [
|
|
1216
|
+
createAgentEvent('plan_set', { origin: 'tool', payload: { steps: ['A', 'B'] } }),
|
|
1217
|
+
createAgentEvent('plan_step_updated', { origin: 'runtime', payload: { step: 1, status: 'failed' } }),
|
|
1218
|
+
createAgentEvent('plan_step_updated', {
|
|
1219
|
+
origin: 'runtime',
|
|
1220
|
+
payload: { step: 2, status: 'skipped', reason: 'dependency_failed:A' },
|
|
1221
|
+
}),
|
|
1222
|
+
];
|
|
1223
|
+
|
|
1224
|
+
const first = reduceAgentEvents(events);
|
|
1225
|
+
const replayed = reduceAgentEvents(events);
|
|
1226
|
+
|
|
1227
|
+
assert.deepEqual(
|
|
1228
|
+
replayed.plan.map((step) => step.status),
|
|
1229
|
+
first.plan.map((step) => step.status),
|
|
1230
|
+
);
|
|
1231
|
+
assert.deepEqual(replayed.plan.map((step) => step.status), ['failed', 'skipped']);
|
|
1232
|
+
});
|
|
@@ -76,6 +76,34 @@ test('interactive turns publish a fallback assistant message exactly once', () =
|
|
|
76
76
|
assert.equal(published[0].payload.content, 'Réponse concise.');
|
|
77
77
|
});
|
|
78
78
|
|
|
79
|
+
test('a turn that produces nothing still ends with an assistant message', () => {
|
|
80
|
+
// C'est la condition de fin que serve attend : la bulle « Request received ·
|
|
81
|
+
// Donna is preparing… » n'est retirée qu'à l'arrivée d'un message assistant
|
|
82
|
+
// non vide. Sans message, le point d'attente tournait indéfiniment, sans
|
|
83
|
+
// erreur nulle part et sans autre issue qu'un rechargement de page.
|
|
84
|
+
const published = [];
|
|
85
|
+
const session = { agentEvents: [], _onAgentEvent: (event) => published.push(event) };
|
|
86
|
+
|
|
87
|
+
assert.equal(ensureInteractiveAssistantMessage(session, '', { turnId: 'turn-1', workspace: 'demo' }), true);
|
|
88
|
+
|
|
89
|
+
assert.equal(published.length, 1);
|
|
90
|
+
assert.equal(published[0].type, 'assistant_message');
|
|
91
|
+
assert.match(published[0].payload.content, /No answer was produced/);
|
|
92
|
+
// Le message doit dire quoi faire, pas seulement constater.
|
|
93
|
+
assert.match(published[0].payload.content, /\/agent/);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('a turn whose loop already answered does not answer twice', () => {
|
|
97
|
+
const published = [];
|
|
98
|
+
const session = {
|
|
99
|
+
agentEvents: [{ type: 'assistant_message' }],
|
|
100
|
+
_onAgentEvent: (event) => published.push(event),
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
assert.equal(ensureInteractiveAssistantMessage(session, '', { turnId: 'turn-1' }), false);
|
|
104
|
+
assert.equal(published.length, 0);
|
|
105
|
+
});
|
|
106
|
+
|
|
79
107
|
test('runtime state rebuilds interactive conversation from persisted events', () => {
|
|
80
108
|
const user = {
|
|
81
109
|
...createAgentEvent('user_message', { origin: 'runtime_turn', turnId: 'turn-1', workspace: 'demo', payload: { content: 'Bonjour' } }),
|
package/src/runtime/store.js
CHANGED
|
@@ -1,3 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @statuses-vocabulary
|
|
3
|
+
*
|
|
4
|
+
* RUN lifecycle statuses, not task statuses: a run can be `interrupted`,
|
|
5
|
+
* a task never is.
|
|
6
|
+
*
|
|
7
|
+
* Declared here rather than in a central exception list so the waiver
|
|
8
|
+
* travels with the code it excuses (see orchestrator/taskStatuses.test.js).
|
|
9
|
+
*/
|
|
1
10
|
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
2
11
|
import { dirname, join, resolve } from 'node:path';
|
|
3
12
|
import { DatabaseSync } from 'node:sqlite';
|
|
@@ -6,6 +15,8 @@ import { defaultRuntimeStateDir } from '../core/env.js';
|
|
|
6
15
|
import { projectQueue } from '../core/jobQueue.js';
|
|
7
16
|
import { projectWorkflow } from '../core/workflow.js';
|
|
8
17
|
import { createCapabilityRegistry } from '../orchestrator/capabilityRegistry.js';
|
|
18
|
+
import { isSuccessful } from '../orchestrator/taskStatuses.js';
|
|
19
|
+
import { markPersistedAgentsStale } from '../orchestrator/agentRegistry.js';
|
|
9
20
|
|
|
10
21
|
export { defaultRuntimeStateDir };
|
|
11
22
|
|
|
@@ -1121,7 +1132,7 @@ export function openRuntimeStore({ stateDir = defaultRuntimeStateDir(), fileName
|
|
|
1121
1132
|
|
|
1122
1133
|
function attemptStatusForResult(status, eventType) {
|
|
1123
1134
|
const normalized = String(status ?? '').toLowerCase();
|
|
1124
|
-
if (
|
|
1135
|
+
if (isSuccessful(normalized)) return 'done';
|
|
1125
1136
|
if (['cancelled', 'canceled'].includes(normalized)) return 'cancelled';
|
|
1126
1137
|
if (eventType === 'task.failed') return 'failed';
|
|
1127
1138
|
return normalized || 'finished';
|
|
@@ -1265,6 +1276,10 @@ export function openRuntimeStore({ stateDir = defaultRuntimeStateDir(), fileName
|
|
|
1265
1276
|
function hydrateSession(session, { workspace = null } = {}) {
|
|
1266
1277
|
const projection = replayEvents(session, { workspace });
|
|
1267
1278
|
applyAgentProjectionToSession(session, projection);
|
|
1279
|
+
// Le journal restitue les agents avec la santé qu'ils avaient à l'écriture
|
|
1280
|
+
// de l'événement. Les reprendre tels quels les faisait réapparaître
|
|
1281
|
+
// « disponibles » après un redémarrage, endpoint éteint compris.
|
|
1282
|
+
markPersistedAgentsStale(session);
|
|
1268
1283
|
session.jobQueue = listQueue({ workspace });
|
|
1269
1284
|
return projection;
|
|
1270
1285
|
}
|
|
@@ -6,6 +6,7 @@ import { DatabaseSync } from 'node:sqlite';
|
|
|
6
6
|
import test from 'node:test';
|
|
7
7
|
import { conversationEventSequences, createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
|
|
8
8
|
import { openRuntimeStore } from './store.js';
|
|
9
|
+
import { capabilityRegistryForSession } from '../orchestrator/capabilityRegistry.js';
|
|
9
10
|
|
|
10
11
|
function runtimeStateDir() {
|
|
11
12
|
return join(mkdtempSync(join(tmpdir(), 'wiki-manager-runtime-')), '.wiki', 'runtime');
|
|
@@ -1106,3 +1107,52 @@ test('deleteEventsAfter removes bookkeeping only for runs entirely produced by t
|
|
|
1106
1107
|
assert.equal(store.db.prepare('SELECT COUNT(*) AS n FROM task_attempts').get().n, 1);
|
|
1107
1108
|
store.close();
|
|
1108
1109
|
});
|
|
1110
|
+
|
|
1111
|
+
test('un agent restauré par hydrateSession n’est plus routable tant qu’aucun scan n’a réussi', () => {
|
|
1112
|
+
/*
|
|
1113
|
+
Validation à chaud du 2026-08-04 : après redémarrage, `cme-main` restait
|
|
1114
|
+
`available` et `external-source.export` gardait un fournisseur, alors que
|
|
1115
|
+
l'endpoint CME était arrêté. Le test précédent construisait `session.agents`
|
|
1116
|
+
à la main et ratait donc le vrai chemin — celui où l'état vient du journal.
|
|
1117
|
+
|
|
1118
|
+
On hydrate ici depuis de véritables événements persistés, comme au
|
|
1119
|
+
démarrage : hydrate → invalidate → discover, sans scan entre les deux
|
|
1120
|
+
premiers.
|
|
1121
|
+
*/
|
|
1122
|
+
const store = openRuntimeStore({ stateDir: mkdtempSync(join(tmpdir(), 'wiki-manager-agent-staleness-')) });
|
|
1123
|
+
const writer = { agentEvents: [], workspace: 'juno' };
|
|
1124
|
+
dispatchAgentEvent(writer, createAgentEvent('agent.registered', {
|
|
1125
|
+
origin: 'runtime',
|
|
1126
|
+
workspace: 'juno',
|
|
1127
|
+
payload: {
|
|
1128
|
+
agent: {
|
|
1129
|
+
agentInstanceId: 'cme-main',
|
|
1130
|
+
serverName: 'cme',
|
|
1131
|
+
health: 'available',
|
|
1132
|
+
description: {
|
|
1133
|
+
agentType: 'cme',
|
|
1134
|
+
contractVersion: '1',
|
|
1135
|
+
agentInstanceId: 'cme-main',
|
|
1136
|
+
capabilities: [{ id: 'external-source.export', version: '1' }],
|
|
1137
|
+
},
|
|
1138
|
+
},
|
|
1139
|
+
},
|
|
1140
|
+
}));
|
|
1141
|
+
for (const event of writer.agentEvents) store.persistEvent(event);
|
|
1142
|
+
|
|
1143
|
+
// Redémarrage : une session neuve, aucun scan encore effectué.
|
|
1144
|
+
const rebooted = { agentEvents: [], workspace: 'juno' };
|
|
1145
|
+
store.hydrateSession(rebooted, { workspace: 'juno' });
|
|
1146
|
+
|
|
1147
|
+
const restored = [...(rebooted.agents ?? []), ...(rebooted.agentRegistrySnapshot ?? [])];
|
|
1148
|
+
assert.ok(restored.length > 0, 'the agent must be restored, only not trusted');
|
|
1149
|
+
for (const agent of restored) {
|
|
1150
|
+
assert.equal(agent.health, 'unknown');
|
|
1151
|
+
assert.equal(agent.stale, true);
|
|
1152
|
+
}
|
|
1153
|
+
// Le seul point qui compte vraiment : plus aucun fournisseur sélectionnable.
|
|
1154
|
+
assert.deepEqual(
|
|
1155
|
+
capabilityRegistryForSession(rebooted).providersFor('external-source.export'),
|
|
1156
|
+
[],
|
|
1157
|
+
);
|
|
1158
|
+
});
|
package/src/shell/repl.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isTerminal } from '../orchestrator/taskStatuses.js';
|
|
1
2
|
import { createInterface } from 'node:readline';
|
|
2
3
|
import { emitKeypressEvents } from 'node:readline';
|
|
3
4
|
import { Transform } from 'node:stream';
|
|
@@ -344,7 +345,7 @@ function completionValuesFor(parts, inputBuffer, session) {
|
|
|
344
345
|
// Vocabulaire de l'opérateur uniquement : `serviceChoices()` écarte les noms
|
|
345
346
|
// Compose bruts que les alias désignent déjà. Ils restent tapables.
|
|
346
347
|
if (command === '/start' && tokenIndex === 1) return ['all', 'agents', 'services', ...serviceChoices(), ...togglableAgents()];
|
|
347
|
-
if (command === '/stop' && tokenIndex === 1) return ['all', 'agents', 'services', ...serviceChoices(), ...togglableAgents()];
|
|
348
|
+
if (command === '/stop' && tokenIndex === 1) return ['all', 'everything', 'agents', 'services', ...serviceChoices(), ...togglableAgents()];
|
|
348
349
|
if (command === '/logs' && tokenIndex === 1) return ['all', ...serviceChoices()];
|
|
349
350
|
return [];
|
|
350
351
|
}
|
|
@@ -582,7 +583,8 @@ export function completionDescription(value, parts) {
|
|
|
582
583
|
return serviceDescription(value) ?? 'Start this Docker Compose service.';
|
|
583
584
|
}
|
|
584
585
|
if (command === '/stop') {
|
|
585
|
-
if (value === 'all') return '
|
|
586
|
+
if (value === 'all') return 'This workspace, agents included — unless another workspace still needs them.';
|
|
587
|
+
if (value === 'everything') return 'This workspace AND the shared agents, even if other workspaces are using them.';
|
|
586
588
|
if (value === 'agents' || value === 'agent') return 'External agents only.';
|
|
587
589
|
if (value === 'services') return 'Workspace services only.';
|
|
588
590
|
if (togglableAgents().includes(value)) return `Stop only the ${value} agent, leaving the other agents running.`;
|
|
@@ -1006,7 +1008,7 @@ function productionActivityFromPayload(payload) {
|
|
|
1006
1008
|
jobId: jobId ?? null,
|
|
1007
1009
|
status,
|
|
1008
1010
|
label: detail ? `Production: ${detail}` : `Production: ${status}`,
|
|
1009
|
-
terminal:
|
|
1011
|
+
terminal: isTerminal(status),
|
|
1010
1012
|
updatedAt: new Date().toISOString(),
|
|
1011
1013
|
};
|
|
1012
1014
|
}
|
|
@@ -1027,7 +1029,7 @@ export function applyRuntimeStateToShellSession(session, state) {
|
|
|
1027
1029
|
const displayState = sanitizeRuntimeStateForDisplay(state);
|
|
1028
1030
|
const terminalStateDismissed = session._dismissedTerminalRunId
|
|
1029
1031
|
&& state.runId === session._dismissedTerminalRunId
|
|
1030
|
-
&&
|
|
1032
|
+
&& isTerminal(displayState.status);
|
|
1031
1033
|
session._lastRuntimeRunId = state.runId ?? session._lastRuntimeRunId ?? null;
|
|
1032
1034
|
session.agentProjection = {
|
|
1033
1035
|
conversation: Array.isArray(displayState.conversation) ? displayState.conversation.map((message) => ({ ...message })) : [],
|
|
@@ -1324,9 +1326,7 @@ async function runAgentTurn(input, {
|
|
|
1324
1326
|
session.packageJson = session.packageJson ?? {};
|
|
1325
1327
|
if (
|
|
1326
1328
|
session._lastRuntimeRunId
|
|
1327
|
-
&&
|
|
1328
|
-
String(session.agentProjection?.status ?? '').toLowerCase(),
|
|
1329
|
-
)
|
|
1329
|
+
&& isTerminal(session.agentProjection?.status)
|
|
1330
1330
|
) {
|
|
1331
1331
|
session._dismissedTerminalRunId = session._lastRuntimeRunId;
|
|
1332
1332
|
}
|
|
@@ -1472,7 +1472,7 @@ async function runAgentTurn(input, {
|
|
|
1472
1472
|
// unitary actions only — it never plans or delegates, which is why the
|
|
1473
1473
|
// orchestration tools are filtered out upstream. maxToolIterations caps the
|
|
1474
1474
|
// loop.
|
|
1475
|
-
async function runChatToolLoop({ input, session, history, donnaMessage, onUpdate, onStep, allowedTools, openWikiPages, contextMessages = [] }) {
|
|
1475
|
+
async function runChatToolLoop({ input, session, history, donnaMessage, onUpdate, onStep, onTextDelta, onTextReset, allowedTools, openWikiPages, contextMessages = [] }) {
|
|
1476
1476
|
const allowed = new Set(allowedTools.map((item) => item.function.name));
|
|
1477
1477
|
// /chat only supplies the policy; the loop mechanic lives in runBoundedToolLoop.
|
|
1478
1478
|
// executeCall enforces the allow-list and turns each call into a text result;
|
|
@@ -1504,6 +1504,8 @@ async function runChatToolLoop({ input, session, history, donnaMessage, onUpdate
|
|
|
1504
1504
|
maxIterations: Math.min(8, Number(session?.chatAccess?.maxToolIterations) || 4),
|
|
1505
1505
|
signal: session._abortSignal,
|
|
1506
1506
|
onStep: (i, cap) => onStep?.(`Chat: consulting… [${i}/${cap}]`),
|
|
1507
|
+
onTextDelta,
|
|
1508
|
+
onTextReset,
|
|
1507
1509
|
});
|
|
1508
1510
|
donnaMessage.content = capped
|
|
1509
1511
|
? 'Je n’ai pas pu conclure dans la limite d’itérations du mode chat. Passe en /agent si besoin.'
|
|
@@ -1575,7 +1577,15 @@ async function runDirectChatTurn(input, { session, onUpdate, onStep }) {
|
|
|
1575
1577
|
// implementation of chat access; it just returns the final text instead of
|
|
1576
1578
|
// driving a live repl bubble. The caller must have seeded session.chatAccess
|
|
1577
1579
|
// (and session.mcp) so chatAllowedTools can resolve the allow-listed tools.
|
|
1578
|
-
|
|
1580
|
+
/**
|
|
1581
|
+
* @param onTextDelta fragments de la réponse au fil de la génération. Fourni
|
|
1582
|
+
* par le tour runtime, qui les publie en `assistant_delta` : le réducteur
|
|
1583
|
+
* fait grandir la dernière entrée de conversation, et les deux interfaces
|
|
1584
|
+
* affichent la réponse en train de s'écrire au lieu d'un point d'attente.
|
|
1585
|
+
* @param onTextReset fragments à jeter (itération qui s'est terminée par des
|
|
1586
|
+
* appels d'outils plutôt que par une réponse).
|
|
1587
|
+
*/
|
|
1588
|
+
export async function runHeadlessChatTurn(session, input, { history = [], onStep, onTextDelta, onTextReset, openWikiPages, openWikiPage } = {}) {
|
|
1579
1589
|
const donnaMessage = { role: 'donna', content: '' };
|
|
1580
1590
|
const allowedTools = chatAllowedTools(session);
|
|
1581
1591
|
// Keep the singular option as a compatibility input for older serve builds.
|
|
@@ -1591,7 +1601,7 @@ export async function runHeadlessChatTurn(session, input, { history = [], onStep
|
|
|
1591
1601
|
];
|
|
1592
1602
|
const canUseTools = allowedTools.length > 0 && typeof session.llm?.completeWithTools === 'function';
|
|
1593
1603
|
if (canUseTools) {
|
|
1594
|
-
await runChatToolLoop({ input, session, history, donnaMessage, onStep, allowedTools, openWikiPages: selectedPages, contextMessages });
|
|
1604
|
+
await runChatToolLoop({ input, session, history, donnaMessage, onStep, onTextDelta, onTextReset, allowedTools, openWikiPages: selectedPages, contextMessages });
|
|
1595
1605
|
return donnaMessage.content;
|
|
1596
1606
|
}
|
|
1597
1607
|
if (typeof session.llm?.stream === 'function') {
|
|
@@ -1602,7 +1612,7 @@ export async function runHeadlessChatTurn(session, input, { history = [], onStep
|
|
|
1602
1612
|
signal: session._abortSignal,
|
|
1603
1613
|
})) {
|
|
1604
1614
|
const clean = stripDsmlArtifacts(delta);
|
|
1605
|
-
if (clean) content += clean;
|
|
1615
|
+
if (clean) { content += clean; onTextDelta?.(clean); }
|
|
1606
1616
|
}
|
|
1607
1617
|
return stripDsmlArtifacts(content).trimEnd() || formatLlmUnavailableMessage('flux vide');
|
|
1608
1618
|
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
|
|
6
|
+
// The shell renders in a terminal, so there is no DOM to drive and no
|
|
7
|
+
// typechecker on these .tsx files (bun strips the types at run time). Source
|
|
8
|
+
// assertions are the only mechanized guard available here — they are narrow on
|
|
9
|
+
// purpose: each one names the exact invariant that broke.
|
|
10
|
+
const tuiPath = fileURLToPath(new URL('./tui.tsx', import.meta.url));
|
|
11
|
+
|
|
12
|
+
function tuiSource() {
|
|
13
|
+
return readFileSync(tuiPath, 'utf8');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
test('the chat composer loses focus while a modal is open', () => {
|
|
17
|
+
const source = tuiSource();
|
|
18
|
+
|
|
19
|
+
// The setup wizard used to leave `chatFocused` true underneath itself, so
|
|
20
|
+
// every keystroke answering a wizard question was ALSO typed into the chat
|
|
21
|
+
// input at the bottom of the window — visible, and submitted on Enter.
|
|
22
|
+
assert.match(
|
|
23
|
+
source,
|
|
24
|
+
/chatFocused=\{!state\.activeEditor\(\) && screen\(\) === 'main'\}/,
|
|
25
|
+
'chatFocused must exclude every non-main screen, not just the file editor',
|
|
26
|
+
);
|
|
27
|
+
assert.doesNotMatch(
|
|
28
|
+
source,
|
|
29
|
+
/chatFocused=\{!state\.activeEditor\(\)\}/,
|
|
30
|
+
'the editor-only form of chatFocused lets wizard keystrokes reach the composer',
|
|
31
|
+
);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('the setup wizard renders over an opaque backdrop', () => {
|
|
35
|
+
const source = tuiSource();
|
|
36
|
+
const setupBranch = source.slice(source.indexOf("{screen() === 'setup' ?"));
|
|
37
|
+
|
|
38
|
+
assert.ok(setupBranch, "the setup branch must exist in tui.tsx");
|
|
39
|
+
// Without a full-bleed backdrop the wizard floats over a live two-pane
|
|
40
|
+
// layout and the panes show through around its border.
|
|
41
|
+
assert.match(setupBranch, /position="absolute"[\s\S]{0,200}backgroundColor="#0B0D12"/);
|
|
42
|
+
assert.match(setupBranch, /width=\{dimensions\(\)\.width\}[\s\S]{0,120}height=\{dimensions\(\)\.height\}/);
|
|
43
|
+
// The backdrop must sit under the wizard dialog (zIndex 40 in SetupWizard).
|
|
44
|
+
const zIndex = setupBranch.match(/zIndex=\{(\d+)\}/);
|
|
45
|
+
assert.ok(zIndex, 'the backdrop must declare a zIndex');
|
|
46
|
+
assert.ok(Number(zIndex[1]) < 40, 'the backdrop must sit below the wizard dialog');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('the global key handler ignores every screen but main', () => {
|
|
50
|
+
const source = tuiSource();
|
|
51
|
+
|
|
52
|
+
// Belt to the chatFocused braces: even if a future modal forgets the prop,
|
|
53
|
+
// the shared shortcuts (history, slash completion, Esc) stay out of it.
|
|
54
|
+
assert.match(source, /if \(screen\(\) !== 'main'\) return;/);
|
|
55
|
+
});
|
package/src/shell/tui.tsx
CHANGED
|
@@ -381,7 +381,11 @@ function App(props: {
|
|
|
381
381
|
input={state.input()}
|
|
382
382
|
busy={state.busy()}
|
|
383
383
|
chatMode={state.chatMode()}
|
|
384
|
-
|
|
384
|
+
// The composer must lose focus for EVERY modal, not just the file
|
|
385
|
+
// editor. While the setup wizard was open the input stayed focused
|
|
386
|
+
// underneath it, so answering a wizard question also typed into the
|
|
387
|
+
// chat box at the bottom of the window.
|
|
388
|
+
chatFocused={!state.activeEditor() && screen() === 'main'}
|
|
385
389
|
setInput={state.setInput}
|
|
386
390
|
submit={submit}
|
|
387
391
|
conversationRows={conversationRows()}
|
|
@@ -433,17 +437,33 @@ function App(props: {
|
|
|
433
437
|
onSave={state.saveEditor}
|
|
434
438
|
onCancel={state.closeEditor}
|
|
435
439
|
/>
|
|
440
|
+
{/*
|
|
441
|
+
Opaque backdrop, same treatment WizardApp gives the standalone
|
|
442
|
+
wizard. Without it the dialog floated over a live two-pane layout:
|
|
443
|
+
the panes showed through around its border and the whole thing read
|
|
444
|
+
as one garbled screen rather than as a modal.
|
|
445
|
+
*/}
|
|
436
446
|
{screen() === 'setup' ? (
|
|
437
|
-
<
|
|
438
|
-
|
|
439
|
-
|
|
447
|
+
<box
|
|
448
|
+
position="absolute"
|
|
449
|
+
left={0}
|
|
450
|
+
top={0}
|
|
440
451
|
width={dimensions().width}
|
|
441
452
|
height={dimensions().height}
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
453
|
+
zIndex={39}
|
|
454
|
+
backgroundColor="#0B0D12"
|
|
455
|
+
>
|
|
456
|
+
<SetupWizard
|
|
457
|
+
mode="setup"
|
|
458
|
+
session={state.session}
|
|
459
|
+
width={dimensions().width}
|
|
460
|
+
height={dimensions().height}
|
|
461
|
+
initialRoute="workspace-name"
|
|
462
|
+
closeOnDone
|
|
463
|
+
onComplete={closeSetup}
|
|
464
|
+
onClose={closeSetup}
|
|
465
|
+
/>
|
|
466
|
+
</box>
|
|
447
467
|
) : null}
|
|
448
468
|
</box>
|
|
449
469
|
}
|
package/wiki-workspace
CHANGED
|
@@ -70,13 +70,19 @@ parse_global_options() {
|
|
|
70
70
|
export REQUESTS_CA_BUNDLE="$CACERT_PATH"
|
|
71
71
|
export CURL_CA_BUNDLE="$CACERT_PATH"
|
|
72
72
|
fi
|
|
73
|
-
|
|
73
|
+
# `${arr[@]}` on an EMPTY array is an unbound-variable error under `set -u`
|
|
74
|
+
# in bash <= 4.3 — which includes the /bin/bash 3.2 macOS still ships. Every
|
|
75
|
+
# optional array in this script therefore goes through the `${arr[@]+…}`
|
|
76
|
+
# guard (see compose_env_args, override_args, only_services…); these two were
|
|
77
|
+
# missed, so any invocation without a global option died on this line before
|
|
78
|
+
# reaching its subcommand.
|
|
79
|
+
set -- ${parsed[@]+"${parsed[@]}"}
|
|
74
80
|
PARSED_ARGS=("$@")
|
|
75
81
|
}
|
|
76
82
|
|
|
77
83
|
PARSED_ARGS=()
|
|
78
84
|
parse_global_options "$@"
|
|
79
|
-
set -- "${PARSED_ARGS[@]}"
|
|
85
|
+
set -- ${PARSED_ARGS[@]+"${PARSED_ARGS[@]}"}
|
|
80
86
|
|
|
81
87
|
usage() {
|
|
82
88
|
cat <<'EOF'
|
|
@@ -338,7 +344,7 @@ agents_compose() {
|
|
|
338
344
|
logs)
|
|
339
345
|
local log_args=()
|
|
340
346
|
read_lines_into_array log_args logs_args "$@"
|
|
341
|
-
_agents_dc logs "${log_args[@]}"
|
|
347
|
+
_agents_dc logs ${log_args[@]+"${log_args[@]}"}
|
|
342
348
|
;;
|
|
343
349
|
pull)
|
|
344
350
|
[[ $# -eq 0 ]] || die "agents pull does not take arguments"
|
|
@@ -1452,7 +1458,7 @@ main() {
|
|
|
1452
1458
|
logs)
|
|
1453
1459
|
local log_args=()
|
|
1454
1460
|
read_lines_into_array log_args logs_args "$@"
|
|
1455
|
-
compose_for_workspace "$workspace" logs "${log_args[@]}" serve mcp-http production-mcp
|
|
1461
|
+
compose_for_workspace "$workspace" logs ${log_args[@]+"${log_args[@]}"} serve mcp-http production-mcp
|
|
1456
1462
|
;;
|
|
1457
1463
|
serve)
|
|
1458
1464
|
local open_browser=0
|