@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.
Files changed (51) hide show
  1. package/package.json +2 -2
  2. package/src/activity/activityAggregator.js +3 -3
  3. package/src/activity/progressCalculator.js +3 -4
  4. package/src/agent/graph.js +40 -1
  5. package/src/cli/wiki-manager.js +64 -38
  6. package/src/commands/slash.js +47 -9
  7. package/src/core/activity.js +3 -2
  8. package/src/core/agentEvents.js +68 -11
  9. package/src/core/agentEvents.test.js +76 -0
  10. package/src/core/agentLoop.js +32 -6
  11. package/src/core/buildInfo.json +2 -2
  12. package/src/core/compose.js +32 -0
  13. package/src/core/dockerCompose.test.js +32 -0
  14. package/src/core/jobQueue.js +9 -0
  15. package/src/core/mcp.js +1 -1
  16. package/src/core/otherWorkspacesRunning.test.js +51 -0
  17. package/src/core/plan.js +3 -2
  18. package/src/core/planPatch.js +2 -1
  19. package/src/core/profileServiceStatus.test.js +26 -2
  20. package/src/core/toolLoop.js +32 -7
  21. package/src/core/toolLoop.test.js +95 -0
  22. package/src/core/wikiSetup.js +50 -7
  23. package/src/core/wikiWorkspace.test.js +30 -0
  24. package/src/core/wikirc.test.js +121 -2
  25. package/src/core/workflow.js +11 -2
  26. package/src/core/workspaceInherit.js +149 -0
  27. package/src/core/workspaceInherit.test.js +181 -0
  28. package/src/graph/graphVisibilityPolicy.js +2 -2
  29. package/src/orchestrator/agentRegistry.js +50 -0
  30. package/src/orchestrator/agentRegistry.test.js +76 -1
  31. package/src/orchestrator/approvalPolicy.js +2 -2
  32. package/src/orchestrator/dependencyResolver.js +52 -12
  33. package/src/orchestrator/dispatcher.js +2 -5
  34. package/src/orchestrator/planIntegrator.js +5 -5
  35. package/src/orchestrator/resultAggregator.js +2 -1
  36. package/src/orchestrator/scheduler.test.js +62 -1
  37. package/src/orchestrator/taskStatuses.js +99 -0
  38. package/src/orchestrator/taskStatuses.test.js +112 -0
  39. package/src/runtime/delegation.js +158 -0
  40. package/src/runtime/delegation.test.js +281 -0
  41. package/src/runtime/recoveryManager.js +2 -5
  42. package/src/runtime/recoveryManager.test.js +5 -1
  43. package/src/runtime/runner.js +129 -15
  44. package/src/runtime/runner.test.js +153 -6
  45. package/src/runtime/server.test.js +28 -0
  46. package/src/runtime/store.js +16 -1
  47. package/src/runtime/store.test.js +50 -0
  48. package/src/shell/repl.js +21 -11
  49. package/src/shell/setupWizardModality.test.js +55 -0
  50. package/src/shell/tui.tsx +29 -9
  51. package/wiki-workspace +10 -4
@@ -3,6 +3,11 @@ import { createAgentEvent, dispatchAgentEvent } from './agentEvents.js';
3
3
  import { activitySnapshot, newNonTerminalActivities } from './activity.js';
4
4
  import { formatCompletedActivities, formatPlanStatus } from './plan.js';
5
5
  import { formatReadyTaskPrompt, nextReadyPlanTask, readyPlanTasks, sanitizePlanForExecution } from './planPatch.js';
6
+ import { isActive, isPending, normalizeTaskStatus } from '../orchestrator/taskStatuses.js';
7
+
8
+ // Les deux formes d'attente d'un accord humain. Distinguées de `pending` :
9
+ // approuver ne les rend pas prêtes de la même manière.
10
+ const isPendingApproval = (status) => ['pending_approval', 'waiting_approval'].includes(normalizeTaskStatus(status));
6
11
 
7
12
  export function abortError(message = 'Agent run cancelled.') {
8
13
  const err = new Error(message);
@@ -111,19 +116,40 @@ export async function runAgenticLoop(agent, session, initialInput, {
111
116
  // integrated agent_plan fragment. Prose stays prose.
112
117
  sanitizeSessionPlan(session, { runId });
113
118
 
119
+ /*
120
+ Une décision prise arrête la conversation.
121
+
122
+ Dès qu'un fragment structuré est intégré pendant ce tour — Donna vient de
123
+ déléguer —, la boucle conversationnelle n'a plus rien à décider : le plan
124
+ existe, validé par l'agent qui l'exécutera. Elle rendait pourtant la main
125
+ au modèle, parce que le seul critère de bascule était « au moins deux
126
+ tâches PRÊTES » et qu'un plan intégralement en attente d'approbation n'en
127
+ compte aucune. Le modèle repartait donc, l'évaluateur jugeait le plan
128
+ incomplet, le replanificateur relançait une délégation : cinq tâches,
129
+ puis dix, puis quinze, à l'identique.
130
+
131
+ L'attente d'approbation et l'exécution appartiennent au planificateur
132
+ parallèle, qui sait faire les deux. On lui rend la main tout de suite.
133
+ */
134
+ if (parallelHandoff && session._structuredPlanIntegrated) {
135
+ session._structuredPlanIntegrated = false;
136
+ return { ok: true, handoff: true };
137
+ }
138
+
114
139
  const newPending = newNonTerminalActivities(snapshot, session);
115
140
  if (newPending.length === 0) {
116
- // pending_approval is unfinished work too (a request_approval patch op
117
- // sets it) — it must not be treated as "nothing left to do" just
118
- // because no step is literally 'pending' anymore.
119
- const pending = (session.headlessPlan ?? []).filter((step) => step.status === 'pending' || step.status === 'pending_approval');
141
+ // Toute attente est du travail inachevé — approbation comprise. La liste
142
+ // énumérait `pending` et `pending_approval` mais oubliait
143
+ // `waiting_approval`, que produit justement l'intégration d'un fragment
144
+ // délégué : un plan entier en attente se lisait « plus rien à faire ».
145
+ const pending = (session.headlessPlan ?? []).filter((step) => isPending(step.status));
120
146
  const pendingSteps = readyPlanTasks(session.headlessPlan);
121
147
  if (pending.length === 0) {
122
148
  onComplete?.();
123
149
  return { ok: true };
124
150
  }
125
151
  if (pendingSteps.length === 0) {
126
- const reason = pending.every((step) => step.status === 'pending_approval') ? 'awaiting_approval' : 'no_ready_plan_task';
152
+ const reason = pending.every((step) => isPendingApproval(step.status)) ? 'awaiting_approval' : 'no_ready_plan_task';
127
153
  onPendingSteps?.({ pendingSteps: pending, blocked: true });
128
154
  return { ok: false, stalled: true, reason };
129
155
  }
@@ -150,7 +176,7 @@ export async function runAgenticLoop(agent, session, initialInput, {
150
176
  const summary = formatCompletedActivities(completed);
151
177
  onActivitiesCompleted?.({ completed, summary });
152
178
  const unfinished = (session.headlessPlan ?? []).some((step) =>
153
- ['pending', 'pending_approval', 'running', 'starting', 'queued'].includes(String(step.status ?? '').toLowerCase()));
179
+ isPending(step.status) || isActive(step.status));
154
180
  if (deterministicTerminalSummary && !unfinished) {
155
181
  return { ok: true, completed, summary, deterministicSummary: true };
156
182
  }
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "0.15.40",
3
- "commit": "49ea8ff"
2
+ "version": "0.15.42",
3
+ "commit": "d0180aa"
4
4
  }
@@ -321,6 +321,38 @@ export async function serviceStates(session) {
321
321
  return states;
322
322
  }
323
323
 
324
+ /**
325
+ * Names of OTHER registered workspaces that still have containers running.
326
+ *
327
+ * The external agents (cme, documents, connectors, mailer) are a single
328
+ * stack shared by every workspace — one Compose project, one set of
329
+ * containers. `/stop all` took them down unconditionally, so stopping one
330
+ * workspace silently cut the agents out from under all the others.
331
+ *
332
+ * Best effort by design: a workspace whose Compose project cannot be queried
333
+ * (registry entry pointing at a path that no longer exists, docker refusing)
334
+ * is reported as NOT running. Erring the other way would make the agents
335
+ * impossible to stop as soon as one stale registry entry existed.
336
+ */
337
+ export async function otherWorkspacesRunning(session, workspaces = []) {
338
+ const current = session?.workspace ?? null;
339
+ const others = workspaces.filter((workspace) => workspace?.name && workspace.name !== current);
340
+ const results = await Promise.all(others.map(async (workspace) => {
341
+ try {
342
+ const states = await serviceStates({
343
+ workspace: workspace.name,
344
+ workspacePath: workspace.workspacePath,
345
+ workspaceEnvFile: workspace.envFile,
346
+ workspaceEnv: workspace.env,
347
+ });
348
+ return Object.values(states).some((state) => state.running) ? workspace.name : null;
349
+ } catch {
350
+ return null;
351
+ }
352
+ }));
353
+ return results.filter(Boolean);
354
+ }
355
+
324
356
  export async function missingServiceImages(service) {
325
357
  const aliases = serviceAliases();
326
358
  const targets = service ? (aliases[service] ?? [service]) : COMPOSE_SERVICES;
@@ -150,3 +150,35 @@ test('missing-image checks ignore agents behind inactive Compose profiles', () =
150
150
  ['example/cme:latest', 'example/connectors:latest'],
151
151
  );
152
152
  });
153
+
154
+ // Trois variables DOCUMENT_* pour deux conteneurs, chacun n'en déclarant que
155
+ // deux : la question « à quoi ça sert » revient à chaque lecture. Ce test fige
156
+ // le partage pour que la doc reste vraie.
157
+ test('the document handoff keeps input shared and the rest separate', async () => {
158
+ const workspace = YAML.parse(await readFile(new URL('../../docker-compose.yml', import.meta.url), 'utf8'));
159
+ const agents = YAML.parse(await readFile(new URL('../../agents.docker-compose.yml', import.meta.url), 'utf8'));
160
+ const envOf = (service) => Object.fromEntries(
161
+ (service.environment ?? []).map((entry) => String(entry).split('=', 2)),
162
+ );
163
+
164
+ const serve = envOf(workspace.services.serve);
165
+ const documents = envOf(agents.services.documents);
166
+
167
+ // `input` est le point de passage : serve y écrit, l'agent y lit.
168
+ assert.equal(serve.DOCUMENT_INPUT_DIR, '/documents/input');
169
+ assert.equal(documents.DOCUMENT_INPUT_DIR, '/documents/input');
170
+ assert.ok(workspace.services.serve.volumes.some((v) => String(v).endsWith(':/documents/input')));
171
+ assert.ok(agents.services.documents.volumes.some((v) => String(v).endsWith(':/documents/input')));
172
+
173
+ // Le manifeste des téléversements n'appartient qu'à serve ; la sortie de
174
+ // conversion n'appartient qu'à l'agent. Déclarer l'un chez l'autre laisserait
175
+ // croire à un partage qui n'existe pas.
176
+ assert.equal(serve.DOCUMENT_UPLOADS_DIR, '/documents/uploads');
177
+ assert.equal(documents.DOCUMENT_UPLOADS_DIR, undefined);
178
+ assert.equal(documents.DOCUMENT_OUTPUT_DIR, '/documents/output');
179
+ assert.equal(serve.DOCUMENT_OUTPUT_DIR, undefined);
180
+
181
+ // Le plafond est vérifié des deux côtés, donc déclaré des deux côtés.
182
+ assert.ok(serve.DOCUMENT_MAX_UPLOAD_BYTES);
183
+ assert.ok(documents.DOCUMENT_MAX_UPLOAD_BYTES);
184
+ });
@@ -1,3 +1,12 @@
1
+ /**
2
+ * @statuses-vocabulary
3
+ *
4
+ * JOB queue statuses. A queued job has its own lifecycle, independent of
5
+ * the task that may have enqueued it.
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 { randomUUID } from 'node:crypto';
2
11
  import { isAbsolute, relative, resolve, sep } from 'node:path';
3
12
  import { extractActivity, parseJsonText, sessionActivities } from './activity.js';
package/src/core/mcp.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
2
  import { managerEnvFile, managerMcpEndpointsFile, readEnvFile } from './env.js';
3
3
 
4
- const WIKI_MANAGER_VERSION = '0.15.40';
4
+ const WIKI_MANAGER_VERSION = '0.15.42';
5
5
 
6
6
  function envValue(key) {
7
7
  const filePath = managerEnvFile();
@@ -0,0 +1,51 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { otherWorkspacesRunning } from './compose.js';
7
+
8
+ // `otherWorkspacesRunning` shells out to `docker compose ps` per workspace.
9
+ // Docker is not available in the test environment, so every probe throws —
10
+ // which is itself the behaviour worth pinning: a workspace that cannot be
11
+ // queried must read as NOT running, or one stale registry entry would make
12
+ // the shared agents impossible to stop for good.
13
+
14
+ function workspaceEntry(root, name) {
15
+ const registryPath = join(root, name);
16
+ mkdirSync(registryPath, { recursive: true });
17
+ const envFile = join(registryPath, '.env');
18
+ writeFileSync(envFile, `WORKSPACE_NAME=${name}\nWIKI_WORKSPACE_PATH=${registryPath}\n`, 'utf8');
19
+ return {
20
+ name,
21
+ registryPath,
22
+ envFile,
23
+ workspacePath: registryPath,
24
+ env: { WORKSPACE_NAME: name, WIKI_WORKSPACE_PATH: registryPath },
25
+ };
26
+ }
27
+
28
+ test('the current workspace is never counted as another one', async () => {
29
+ const root = mkdtempSync(join(tmpdir(), 'ws-running-self-'));
30
+ const acpi = workspaceEntry(root, 'acpi');
31
+
32
+ const busy = await otherWorkspacesRunning({ workspace: 'acpi' }, [acpi]);
33
+
34
+ assert.deepEqual(busy, [], 'stopping a workspace must not be blocked by itself');
35
+ });
36
+
37
+ test('an unqueryable workspace does not hold the shared agents hostage', async () => {
38
+ const root = mkdtempSync(join(tmpdir(), 'ws-running-unknown-'));
39
+ const workspaces = [workspaceEntry(root, 'acpi'), workspaceEntry(root, 'stale')];
40
+
41
+ const busy = await otherWorkspacesRunning({ workspace: 'acpi' }, workspaces);
42
+
43
+ assert.deepEqual(busy, []);
44
+ });
45
+
46
+ test('a single workspace, or none at all, never blocks', async () => {
47
+ assert.deepEqual(await otherWorkspacesRunning({ workspace: 'acpi' }, []), []);
48
+ assert.deepEqual(await otherWorkspacesRunning({}, []), []);
49
+ // Entries without a name are registry noise, not workspaces.
50
+ assert.deepEqual(await otherWorkspacesRunning({ workspace: 'acpi' }, [{}, null]), []);
51
+ });
package/src/core/plan.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { isUnsuccessfulTerminal } from '../orchestrator/taskStatuses.js';
1
2
  export function ensurePlanFromActivity(session, activity) {
2
3
  if (!activity) return;
3
4
  const actKey = activity.key ?? null;
@@ -57,7 +58,7 @@ export function syncActivitiesToPlan(plan, activities) {
57
58
  const contractTaskPlan = isContractTaskPlan(plan);
58
59
  for (const activity of activities ?? []) {
59
60
  const terminal = Boolean(activity.terminal);
60
- const failed = ['failed', 'error', 'cancelled', 'canceled'].includes(String(activity.status).toLowerCase());
61
+ const failed = isUnsuccessfulTerminal(activity.status);
61
62
  const actKey = activity.key ?? activity.id ?? activity.jobId ?? null;
62
63
  const structuredMatch = findMatchingPlanStepByStructure(plan, activity);
63
64
  const matched = structuredMatch ?? findMatchingPlanStep(plan, activity);
@@ -244,7 +245,7 @@ export function attachActivityToExistingPlan(plan, activity) {
244
245
  if (!matched) return;
245
246
  matched.activityKey = actKey;
246
247
  if (!matched.ownerActivityKey) matched.ownerActivityKey = actKey;
247
- const failed = ['failed', 'error', 'cancelled', 'canceled'].includes(String(activity.status).toLowerCase());
248
+ const failed = isUnsuccessfulTerminal(activity.status);
248
249
  if (activity.terminal) {
249
250
  matched.status = failed ? 'failed' : 'done';
250
251
  return;
@@ -1,4 +1,5 @@
1
1
  import { validateContractInDev } from '../contracts/schemas.js';
2
+ import { isUnsuccessfulTerminal } from '../orchestrator/taskStatuses.js';
2
3
 
3
4
  const PATCH_OPS = new Set([
4
5
  'add_task',
@@ -164,7 +165,7 @@ export function sanitizePlanForExecution(plan) {
164
165
  const done = new Set(tasks.filter((task) => task.status === 'done').map(taskId));
165
166
  const terminalBlocked = new Set(
166
167
  tasks
167
- .filter((task) => ['failed', 'cancelled', 'canceled'].includes(String(task.status).toLowerCase()))
168
+ .filter((task) => isUnsuccessfulTerminal(task.status))
168
169
  .map(taskId),
169
170
  );
170
171
  const hasReady = pending.some((task) => task.dependsOn.every((dep) => done.has(String(dep))));
@@ -82,8 +82,32 @@ test('"all" means the same thing to start and to stop', () => {
82
82
  // `/start all` démarrait les agents, `/stop all` ne les arrêtait pas : la
83
83
  // séquence start/stop ne revenait donc pas à l'état de départ.
84
84
  assert.match(slash, /const startsAgents = service === 'all';/);
85
- assert.match(slash, /const stopsAgents = service === 'all';/);
86
- assert.match(slash, /if \(stopsAgents\) \{\s*\n\s*const agentsResult = await runAgentCommand\(stopAgents, 'stop'\);/);
85
+ assert.match(slash, /const stopsAgents = service === 'all' \|\| stopsEverything;/);
86
+ });
87
+
88
+ test('stopping one workspace never takes the shared agents from another', () => {
89
+ const slash = readFileSync(fileURLToPath(new URL('../commands/slash.js', import.meta.url)), 'utf8');
90
+ // Les agents externes sont UNE pile partagée. `/stop all` les arrêtait sans
91
+ // condition : couper un workspace coupait tous les autres, qui ne voyaient
92
+ // qu'une panne.
93
+ assert.match(slash, /await otherWorkspacesRunning\(context\.session, listWorkspaces\(\)\)/);
94
+ assert.match(slash, /if \(busy\.length > 0\) \{/);
95
+ // Le renoncement doit se dire : un saut silencieux ressemble exactement au
96
+ // bug d'origine.
97
+ assert.match(slash, /agents left running for \$\{busy\.join\(', '\)\}/);
98
+ assert.match(slash, /use \/stop everything to stop them anyway/);
99
+ // `everything` court-circuite la garde, et c'est son seul intérêt.
100
+ assert.match(slash, /const busy = stopsEverything\s*\n\s*\? \[\]/);
101
+ });
102
+
103
+ test('the everything escape hatch is discoverable', () => {
104
+ const slash = readFileSync(fileURLToPath(new URL('../commands/slash.js', import.meta.url)), 'utf8');
105
+ const repl = readFileSync(fileURLToPath(new URL('../shell/repl.js', import.meta.url)), 'utf8');
106
+ // Une porte de sortie qui n'apparaît ni dans l'aide ni dans la complétion
107
+ // n'existe pas pour l'opérateur qui en a besoin.
108
+ assert.match(slash, /\/stop \[all\|everything\|service\|agents\]/);
109
+ assert.match(repl, /command === '\/stop' && tokenIndex === 1\) return \['all', 'everything'/);
110
+ assert.match(repl, /if \(value === 'everything'\)/);
87
111
  });
88
112
 
89
113
  test('the addressable agents are read from the compose file, never hard-coded', async () => {
@@ -12,6 +12,13 @@
12
12
  //
13
13
  // `executeCall` may throw to abort the whole loop (e.g. an AbortError on
14
14
  // cancel); anything it returns is treated as the tool result for that call.
15
+ /**
16
+ * @param onTextDelta appelé au fil de la génération. Une itération qui finit
17
+ * par des appels d'outils ne produit pas de réponse lisible : ses fragments
18
+ * sont donc rejetés a posteriori via `onTextReset`, pour ne pas afficher un
19
+ * raisonnement intermédiaire que le tour suivant remplacera.
20
+ * @param onTextReset appelé quand les fragments déjà émis sont à jeter.
21
+ */
15
22
  export async function runBoundedToolLoop({
16
23
  llm,
17
24
  system,
@@ -21,19 +28,37 @@ export async function runBoundedToolLoop({
21
28
  maxIterations = 4,
22
29
  signal,
23
30
  onStep,
31
+ onTextDelta,
32
+ onTextReset,
24
33
  } = {}) {
25
34
  const cap = Math.max(1, Math.floor(maxIterations) || 1);
26
35
  const convo = [...(messages ?? [])];
36
+ // `streamWithTools` accumule les appels d'outils exactement comme
37
+ // `completeWithTools` et renvoie la même forme : le seul écart est qu'il
38
+ // livre le texte au fil de l'eau. Sans lui, la réponse finale n'apparaissait
39
+ // qu'une fois complète — le tour paraissait figé pendant toute sa durée.
40
+ const canStream = typeof onTextDelta === 'function' && typeof llm?.streamWithTools === 'function';
27
41
  for (let i = 0; i < cap; i += 1) {
28
42
  onStep?.(i + 1, cap);
29
- const result = await llm.completeWithTools({
30
- system,
31
- tools,
32
- messages: convo,
33
- toolChoice: 'auto',
34
- signal,
35
- });
43
+ let streamedText = false;
44
+ const result = canStream
45
+ ? await llm.streamWithTools({
46
+ system,
47
+ tools,
48
+ messages: convo,
49
+ toolChoice: 'auto',
50
+ onTextDelta: (delta) => { streamedText = true; onTextDelta(delta); },
51
+ signal,
52
+ })
53
+ : await llm.completeWithTools({
54
+ system,
55
+ tools,
56
+ messages: convo,
57
+ toolChoice: 'auto',
58
+ signal,
59
+ });
36
60
  const calls = result?.tool_calls ?? [];
61
+ if (calls.length > 0 && streamedText) onTextReset?.();
37
62
  if (calls.length === 0) {
38
63
  return {
39
64
  content: result?.content ?? result?.message?.content ?? '',
@@ -86,3 +86,98 @@ test('propagates an abort thrown by executeCall', async () => {
86
86
  /aborted/,
87
87
  );
88
88
  });
89
+
90
+ // Le tour paraissait figé pendant toute sa durée : la réponse n'apparaissait
91
+ // qu'une fois complète, parce que la boucle n'appelait que completeWithTools.
92
+ test('streams the answer when the caller asks for deltas', async () => {
93
+ const deltas = [];
94
+ const llm = {
95
+ async completeWithTools() { throw new Error('streamWithTools devait être préféré'); },
96
+ async streamWithTools({ onTextDelta }) {
97
+ onTextDelta('Le wiki ');
98
+ onTextDelta('contient 12 pages.');
99
+ return { content: 'Le wiki contient 12 pages.', tool_calls: [] };
100
+ },
101
+ };
102
+
103
+ const out = await runBoundedToolLoop({
104
+ llm,
105
+ tools: [],
106
+ executeCall: async () => 'unused',
107
+ onTextDelta: (delta) => deltas.push(delta),
108
+ });
109
+
110
+ assert.deepEqual(deltas, ['Le wiki ', 'contient 12 pages.']);
111
+ assert.equal(out.content, 'Le wiki contient 12 pages.');
112
+ });
113
+
114
+ test('keeps the non-streaming path when no delta callback is given', async () => {
115
+ let streamed = false;
116
+ const llm = {
117
+ async completeWithTools() { return { content: 'plain', tool_calls: [] }; },
118
+ async streamWithTools() { streamed = true; return { content: 'streamed', tool_calls: [] }; },
119
+ };
120
+
121
+ const out = await runBoundedToolLoop({ llm, tools: [], executeCall: async () => 'unused' });
122
+
123
+ assert.equal(streamed, false, 'sans onTextDelta, rien ne doit changer');
124
+ assert.equal(out.content, 'plain');
125
+ });
126
+
127
+ test('discards text emitted by an iteration that ends in tool calls', async () => {
128
+ // Un modèle peut écrire un raisonnement puis décider d'appeler un outil. Ce
129
+ // texte est remplacé par le tour suivant : le laisser afficherait des
130
+ // paragraphes qui disparaissent, pire que pas de streaming du tout.
131
+ let round = 0;
132
+ const deltas = [];
133
+ let resets = 0;
134
+ const llm = {
135
+ async streamWithTools({ onTextDelta }) {
136
+ round += 1;
137
+ if (round === 1) {
138
+ onTextDelta('Je vais regarder…');
139
+ const calls = [toolCall('c1', 'wiki__wiki_list_pages')];
140
+ return { message: { role: 'assistant', content: '', tool_calls: calls }, tool_calls: calls };
141
+ }
142
+ onTextDelta('12 pages.');
143
+ return { content: '12 pages.', tool_calls: [] };
144
+ },
145
+ };
146
+
147
+ const out = await runBoundedToolLoop({
148
+ llm,
149
+ tools: [],
150
+ executeCall: async () => 'ok',
151
+ onTextDelta: (delta) => deltas.push(delta),
152
+ onTextReset: () => { resets += 1; },
153
+ });
154
+
155
+ assert.deepEqual(deltas, ['Je vais regarder…', '12 pages.']);
156
+ assert.equal(resets, 1, 'le texte intermédiaire doit être annulé, une fois');
157
+ assert.equal(out.content, '12 pages.');
158
+ });
159
+
160
+ test('never asks to discard text that was never emitted', async () => {
161
+ let resets = 0;
162
+ let round = 0;
163
+ const llm = {
164
+ async streamWithTools() {
165
+ round += 1;
166
+ if (round === 1) {
167
+ const calls = [toolCall('c1', 'wiki__wiki_list_pages')];
168
+ return { message: { role: 'assistant', content: '', tool_calls: calls }, tool_calls: calls };
169
+ }
170
+ return { content: 'fini', tool_calls: [] };
171
+ },
172
+ };
173
+
174
+ await runBoundedToolLoop({
175
+ llm,
176
+ tools: [],
177
+ executeCall: async () => 'ok',
178
+ onTextDelta: () => {},
179
+ onTextReset: () => { resets += 1; },
180
+ });
181
+
182
+ assert.equal(resets, 0);
183
+ });
@@ -5,7 +5,8 @@ import { dirname, join } from 'node:path';
5
5
  import { promisify } from 'node:util';
6
6
  import YAML from 'yaml';
7
7
  import { checkMissingDockerImages } from './dockerImages.js';
8
- import { patchWikircProfile } from './wikirc.js';
8
+ import { loadWikircProfile, patchWikircProfile } from './wikirc.js';
9
+ import { buildInheritedWikircPatch, copyCmeCredentials } from './workspaceInherit.js';
9
10
  import { resolveAgentsComposeContext } from './agentsCompose.js';
10
11
  import { managerEnvFile, managerMcpEndpointsFile, resolveAgentsDataDir } from './env.js';
11
12
  import { createWorkspace, findWorkspace, isValidWorkspaceName, listWorkspaces, managerRoot, workspacesDir } from './workspaces.js';
@@ -208,20 +209,62 @@ export async function refreshRunningContainers(options = {}) {
208
209
  return { skipped: false, refreshed, errors };
209
210
  }
210
211
 
211
- export async function createNewWorkspace(name, targetPath) {
212
+ export async function createNewWorkspace(name, targetPath, options = {}) {
212
213
  try {
213
214
  const output = await createWorkspace(name, targetPath, { timeout: 600_000 });
214
- const workspace = finalizeCreatedWorkspace(name);
215
- return { output, workspace };
215
+ const { workspace, inherited } = await finalizeCreatedWorkspace(name, options);
216
+ return { output, workspace, inherited };
216
217
  } catch (err) {
217
218
  throw wrapDockerError(err);
218
219
  }
219
220
  }
220
221
 
221
- export function finalizeCreatedWorkspace(name) {
222
+ /**
223
+ * @param options.inheritFrom name of the workspace to copy LLM config and CME
224
+ * credentials from — normally the session's current workspace. Omitted, or
225
+ * unknown, means "scaffold defaults only", the previous behaviour.
226
+ */
227
+ export async function finalizeCreatedWorkspace(name, options = {}) {
222
228
  const workspace = findWorkspace(name);
223
- if (workspace) initializeWorkspaceWikirc(workspace);
224
- return workspace;
229
+ if (!workspace) return { workspace: null, inherited: [] };
230
+ // Order matters: the access key is this workspace's own credential and must
231
+ // land before anything else touches the file.
232
+ initializeWorkspaceWikirc(workspace);
233
+ const inherited = await inheritWorkspaceSetup(workspace, options.inheritFrom ?? null);
234
+ return { workspace, inherited };
235
+ }
236
+
237
+ /**
238
+ * Carry an existing workspace's proven setup over to a new one. Best effort by
239
+ * design: a workspace that exists and works must never fail to be created
240
+ * because the workspace next door has an unreadable config.
241
+ */
242
+ export async function inheritWorkspaceSetup(workspace, sourceName) {
243
+ if (!workspace?.workspacePath || !sourceName || sourceName === workspace.name) return [];
244
+ const source = findWorkspace(sourceName);
245
+ if (!source?.workspacePath) return [];
246
+
247
+ const inherited = [];
248
+ try {
249
+ const { config: sourceConfig } = loadWikircProfile(source.workspacePath, 'default');
250
+ const { config: targetConfig } = loadWikircProfile(workspace.workspacePath, 'default');
251
+ const { patch, inherited: keys } = buildInheritedWikircPatch(sourceConfig, targetConfig);
252
+ if (keys.length > 0) {
253
+ patchWikircProfile(workspace.workspacePath, 'default', patch);
254
+ inherited.push(...keys);
255
+ }
256
+ } catch {
257
+ // Unreadable or absent profile on either side — nothing to inherit.
258
+ }
259
+
260
+ try {
261
+ const copied = await copyCmeCredentials(resolveAgentsDataDir(), source.name, workspace.name);
262
+ if (copied) inherited.push('cme.app_data.json');
263
+ } catch {
264
+ // Credentials are a convenience, not a prerequisite.
265
+ }
266
+
267
+ return inherited;
225
268
  }
226
269
 
227
270
  export function initializeWorkspaceWikirc(workspace) {
@@ -105,3 +105,33 @@ test('container refresh pulls and renews only services that are already running'
105
105
  assert.match(script, /refresh_running_services 'No running external agent containers to refresh' 'Refreshed running agents: %s' _agents_dc/);
106
106
  assert.match(script, /refresh_running_services "No running workspace containers to refresh: \$workspace" "Refreshed running workspace containers: \$workspace \(%s\)" compose_for_workspace "\$workspace"/);
107
107
  });
108
+
109
+ // `${arr[@]}` on an empty array is an unbound-variable error under `set -u` in
110
+ // bash <= 4.3, and macOS still ships /bin/bash 3.2. `parse_global_options`
111
+ // tripped on it, so `wiki-workspace` with no global option died before reaching
112
+ // any subcommand.
113
+ //
114
+ // The execution check below cannot catch a regression on a CI running bash 5,
115
+ // where an empty array is legal — hence the source assertion that every
116
+ // optional array keeps the `${arr[@]+"${arr[@]}"}` guard. Both are needed.
117
+ test('optional arrays keep the empty-array guard required by bash 3.2', async () => {
118
+ const script = await readFile(new URL('../../wiki-workspace', import.meta.url), 'utf8');
119
+
120
+ for (const name of ['parsed', 'PARSED_ARGS', 'log_args']) {
121
+ const unguarded = new RegExp(String.raw`(?<!\+)"\$\{${name}\[@\]\}"`);
122
+ assert.doesNotMatch(script, unguarded, `${name}[@] must use the \${name[@]+"..."} guard`);
123
+ }
124
+ assert.match(script, /set -- \$\{parsed\[@\]\+"\$\{parsed\[@\]\}"\}/);
125
+ assert.match(script, /set -- \$\{PARSED_ARGS\[@\]\+"\$\{PARSED_ARGS\[@\]\}"\}/);
126
+ });
127
+
128
+ test('wiki-workspace reaches its usage text with no arguments', async () => {
129
+ const { spawnSync } = await import('node:child_process');
130
+ const scriptPath = new URL('../../wiki-workspace', import.meta.url).pathname;
131
+
132
+ const result = spawnSync('bash', [scriptPath], { encoding: 'utf8' });
133
+
134
+ assert.doesNotMatch(`${result.stdout}${result.stderr}`, /unbound variable/);
135
+ assert.match(result.stdout, /^Usage:/);
136
+ assert.equal(result.status, 2);
137
+ });