@dotdrelle/wiki-manager 0.14.12 → 0.14.16
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/.env.example +11 -0
- package/docker-compose.yml +1 -1
- package/package.json +1 -1
- package/src/activity/activityAggregator.js +43 -13
- package/src/activity/activityAggregator.test.js +51 -2
- package/src/agent/graph.js +79 -11
- package/src/agent/graph.test.js +43 -3
- package/src/cli/wiki-manager.js +44 -3
- package/src/commands/slash.js +10 -3
- package/src/commands/slash.test.js +24 -0
- package/src/core/activity.js +4 -0
- package/src/core/activity.test.js +31 -0
- package/src/core/agentEvents.js +11 -2
- package/src/core/buildInfo.json +2 -2
- package/src/core/dockerCompose.test.js +4 -0
- package/src/core/env.js +16 -4
- package/src/core/env.test.js +3 -0
- package/src/core/mcp.js +1 -1
- package/src/core/wikiSetup.js +43 -1
- package/src/core/wikiWorkspace.test.js +20 -0
- package/src/core/workspaces.js +10 -2
- package/src/orchestrator/dependencyResolver.js +19 -1
- package/src/orchestrator/objectiveResolver.js +24 -0
- package/src/orchestrator/objectiveResolver.test.js +23 -1
- package/src/orchestrator/scheduler.test.js +22 -1
- package/src/runtime/auth.test.js +65 -1
- package/src/runtime/client.js +4 -0
- package/src/runtime/donna-contract.test.js +2 -0
- package/src/runtime/lifecycle.js +21 -12
- package/src/runtime/runner.js +130 -17
- package/src/runtime/runner.test.js +62 -1
- package/src/runtime/server.js +13 -2
- package/src/runtime/server.test.js +30 -1
- package/src/shell/FileEditorDialog.tsx +2 -2
- package/src/shell/LeftPane.tsx +60 -15
- package/src/shell/RightPane.tsx +147 -54
- package/src/shell/StartupScreen.tsx +3 -7
- package/src/shell/renderer.ts +1 -0
- package/src/shell/repl.js +7 -81
- package/src/shell/repl.test.js +128 -38
- package/src/shell/tui.tsx +59 -64
- package/src/shell/useSession.ts +45 -6
- package/wiki-workspace +36 -3
package/src/runtime/runner.js
CHANGED
|
@@ -7,6 +7,8 @@ import { createAssignmentManager } from '../orchestrator/assignmentManager.js';
|
|
|
7
7
|
import { createAttemptManager } from '../orchestrator/attemptManager.js';
|
|
8
8
|
import { createBudgetManager, BudgetExceededError } from '../orchestrator/budgetManager.js';
|
|
9
9
|
import { createDispatcher } from '../orchestrator/dispatcher.js';
|
|
10
|
+
import { approvalRequestForTask } from '../orchestrator/approvalPolicy.js';
|
|
11
|
+
import { PENDING_STATUSES, tasksAwaitingApproval } from '../orchestrator/dependencyResolver.js';
|
|
10
12
|
import { assertValidatedFragment } from '../orchestrator/planValidator.js';
|
|
11
13
|
import { createResultAggregator } from '../orchestrator/resultAggregator.js';
|
|
12
14
|
import { drainActive, resolvePlanConcurrency, startReadyTasks } from '../orchestrator/scheduler.js';
|
|
@@ -139,9 +141,14 @@ export async function runRuntimeAgenticWorkflow(agent, session, input, {
|
|
|
139
141
|
// loop would re-ingest this run's own turns and duplicate them.
|
|
140
142
|
const runConversationSeed = conversationSeed(session, currentInput);
|
|
141
143
|
|
|
144
|
+
// The conversational loop path ends with the agent's own natural-language
|
|
145
|
+
// reply; the deterministic parallel scheduler has no agent voice, so only
|
|
146
|
+
// that path gets a synthesized outcome summary (announceRunOutcome).
|
|
147
|
+
let usedParallelScheduler = false;
|
|
142
148
|
while (true) {
|
|
143
149
|
sanitizeSessionPlanForExecution(session, runId);
|
|
144
|
-
|
|
150
|
+
usedParallelScheduler = shouldUseParallelScheduler(session.headlessPlan);
|
|
151
|
+
const result = usedParallelScheduler
|
|
145
152
|
? await runRuntimeParallelPlan(agent, session, input, {
|
|
146
153
|
signal,
|
|
147
154
|
timeoutMs,
|
|
@@ -188,6 +195,7 @@ export async function runRuntimeAgenticWorkflow(agent, session, input, {
|
|
|
188
195
|
emitRuntimeLog(session, 'runtime: run ended by user cancellation (no replan)');
|
|
189
196
|
return { ok: false, result, cancelled: true };
|
|
190
197
|
}
|
|
198
|
+
if (usedParallelScheduler) await announceRunOutcome(session, { runId, ok: false, signal });
|
|
191
199
|
dispatchAgentEvent(session, createAgentEvent('run_error', {
|
|
192
200
|
origin: 'runtime',
|
|
193
201
|
runId,
|
|
@@ -267,6 +275,7 @@ export async function runRuntimeAgenticWorkflow(agent, session, input, {
|
|
|
267
275
|
}
|
|
268
276
|
}
|
|
269
277
|
|
|
278
|
+
if (usedParallelScheduler) await announceRunOutcome(session, { runId, ok: true, signal });
|
|
270
279
|
dispatchAgentEvent(session, createAgentEvent('run_done', {
|
|
271
280
|
origin: 'runtime',
|
|
272
281
|
runId,
|
|
@@ -276,6 +285,62 @@ export async function runRuntimeAgenticWorkflow(agent, session, input, {
|
|
|
276
285
|
}
|
|
277
286
|
}
|
|
278
287
|
|
|
288
|
+
// Emit ONE natural-language Donna message summarizing how the run finished,
|
|
289
|
+
// instead of the client streaming a per-job line for every task. Uses the
|
|
290
|
+
// workspace LLM to phrase it, degrading to a plain templated fact line if the
|
|
291
|
+
// LLM is unavailable or errors — the run must never block on this summary.
|
|
292
|
+
async function announceRunOutcome(session, { runId, ok, signal = null } = {}) {
|
|
293
|
+
const plan = Array.isArray(session.headlessPlan) ? session.headlessPlan : [];
|
|
294
|
+
if (plan.length === 0) return;
|
|
295
|
+
let failed = 0;
|
|
296
|
+
let cancelled = 0;
|
|
297
|
+
let completed = 0;
|
|
298
|
+
let firstError = null;
|
|
299
|
+
for (const step of plan) {
|
|
300
|
+
const status = String(step?.status ?? '').toLowerCase();
|
|
301
|
+
if (['failed', 'error', 'stalled'].includes(status)) {
|
|
302
|
+
failed += 1;
|
|
303
|
+
firstError ??= String(
|
|
304
|
+
step?.error?.message ?? step?.error?.code ?? step?.error
|
|
305
|
+
?? step?.result?.error?.message ?? step?.result?.error?.code ?? '',
|
|
306
|
+
).trim() || null;
|
|
307
|
+
} else if (['cancelled', 'canceled'].includes(status)) {
|
|
308
|
+
cancelled += 1;
|
|
309
|
+
} else if (['done', 'complete', 'completed', 'success', 'succeeded'].includes(status)) {
|
|
310
|
+
completed += 1;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
const total = plan.length;
|
|
314
|
+
const factLine = ok && failed === 0
|
|
315
|
+
? `Plan terminé avec succès — ${completed}/${total} tâche(s) réussie(s).`
|
|
316
|
+
: `Plan terminé en erreur — ${completed}/${total} tâche(s) réussie(s), ${failed} en erreur${cancelled ? `, ${cancelled} annulée(s)` : ''}.${firstError ? ` Première erreur : ${firstError}.` : ''}`;
|
|
317
|
+
let content = factLine;
|
|
318
|
+
const llm = session.llm;
|
|
319
|
+
if (llm && typeof llm.completeWithTools === 'function') {
|
|
320
|
+
try {
|
|
321
|
+
const result = await llm.completeWithTools({
|
|
322
|
+
system: [
|
|
323
|
+
'You are Donna, an orchestration assistant reporting a run result to the user.',
|
|
324
|
+
'Rephrase the outcome facts in ONE short, natural sentence, in the same language as the facts.',
|
|
325
|
+
'No lists, no headers, no raw job ids — just a concise human summary.',
|
|
326
|
+
].join('\n'),
|
|
327
|
+
tools: [],
|
|
328
|
+
messages: [{ role: 'user', content: `Run outcome facts:\n${factLine}` }],
|
|
329
|
+
signal,
|
|
330
|
+
});
|
|
331
|
+
const phrased = String(result?.content ?? '').trim();
|
|
332
|
+
if (phrased) content = phrased;
|
|
333
|
+
} catch {
|
|
334
|
+
// Degrade to the templated fact line — never fail the run on the summary.
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
dispatchAgentEvent(session, createAgentEvent('assistant_message', {
|
|
338
|
+
origin: 'runtime',
|
|
339
|
+
runId,
|
|
340
|
+
payload: { content },
|
|
341
|
+
}));
|
|
342
|
+
}
|
|
343
|
+
|
|
279
344
|
export async function runRuntimeParallelPlan(agent, session, input, {
|
|
280
345
|
signal = null,
|
|
281
346
|
timeoutMs,
|
|
@@ -328,7 +393,6 @@ export async function runRuntimeParallelPlan(agent, session, input, {
|
|
|
328
393
|
sanitizeSessionPlanForExecution(session, runId);
|
|
329
394
|
ensurePlanProjection(session, runId);
|
|
330
395
|
emitRuntimeLog(session, `scheduler: parallel plan enabled (concurrency ${limit})`);
|
|
331
|
-
let approvalNoticeSent = false;
|
|
332
396
|
// Interactive approvals do NOT expire: the user has /approve, "valide
|
|
333
397
|
// tout", /cancel and /run kill — an arbitrary timer only created mystery
|
|
334
398
|
// failures. A deadline exists only when explicitly configured (headless
|
|
@@ -431,26 +495,60 @@ export async function runRuntimeParallelPlan(agent, session, input, {
|
|
|
431
495
|
emitRuntimeLog(session, `scheduler: budget exceeded (${exceeded.reason})`);
|
|
432
496
|
return { ok: false, budgetExceeded: true, reason: exceeded.reason, budget: exceeded, completed: sessionActivities(session), failures };
|
|
433
497
|
}
|
|
434
|
-
|
|
498
|
+
// Only wait for a human when approval is the sole remaining blocker.
|
|
499
|
+
// A task whose dependency failed cannot become runnable by approving
|
|
500
|
+
// it; treating it as an approval wait leaves the run alive forever.
|
|
501
|
+
const approvalContext = {
|
|
502
|
+
runId,
|
|
503
|
+
workspace: session.workspace ?? null,
|
|
504
|
+
planRevision: session.planRevision ?? session.agentProjection?.planRevision ?? null,
|
|
505
|
+
tasks: session.headlessPlan ?? [],
|
|
506
|
+
};
|
|
507
|
+
// One snapshot of the approvals list, reused for both the
|
|
508
|
+
// awaiting-approval computation and the per-task dedup below so the two
|
|
509
|
+
// can never diverge mid-iteration.
|
|
510
|
+
const approvals = session.agentProjection?.approvals ?? session.approvals ?? [];
|
|
511
|
+
const needingApproval = tasksAwaitingApproval(approvalContext, { approvals });
|
|
435
512
|
if (needingApproval.length > 0) {
|
|
436
513
|
// The plan is only blocked on a HUMAN decision — wait for it
|
|
437
514
|
// (bounded) instead of declaring the run stalled. Announce once in
|
|
438
515
|
// the chat: users cannot approve what they never saw asked.
|
|
439
|
-
|
|
440
|
-
|
|
516
|
+
const newlyRequested = [];
|
|
517
|
+
for (const task of needingApproval) {
|
|
518
|
+
const taskId = String(task.id ?? task.taskId ?? task.step ?? '');
|
|
519
|
+
const alreadyRequested = approvals.some((approval) =>
|
|
520
|
+
approval.status === 'pending_approval'
|
|
521
|
+
&& String(approval.taskId ?? approval.itemId ?? '') === taskId
|
|
522
|
+
&& Number(approval.planRevision ?? approvalContext.planRevision) === Number(approvalContext.planRevision));
|
|
523
|
+
if (alreadyRequested) continue;
|
|
524
|
+
newlyRequested.push(task);
|
|
525
|
+
const request = approvalRequestForTask(task, {
|
|
526
|
+
runId,
|
|
527
|
+
workspaceId: session.workspace ?? null,
|
|
528
|
+
planRevision: approvalContext.planRevision,
|
|
529
|
+
});
|
|
530
|
+
dispatchAgentEvent(session, createAgentEvent('approval.requested', {
|
|
531
|
+
origin: 'runtime',
|
|
532
|
+
runId,
|
|
533
|
+
taskId: request.taskId,
|
|
534
|
+
workspace: session.workspace ?? null,
|
|
535
|
+
payload: request,
|
|
536
|
+
}));
|
|
537
|
+
}
|
|
538
|
+
if (newlyRequested.length > 0) {
|
|
441
539
|
dispatchAgentEvent(session, createAgentEvent('assistant_message', {
|
|
442
540
|
origin: 'runtime',
|
|
443
541
|
runId,
|
|
444
542
|
payload: {
|
|
445
543
|
content: [
|
|
446
|
-
`⏸ Approbation requise avant exécution : ${
|
|
447
|
-
...
|
|
448
|
-
|
|
544
|
+
`⏸ Approbation requise avant exécution : ${newlyRequested.length} tâche(s) mutante(s) en attente.`,
|
|
545
|
+
...newlyRequested.slice(0, 5).map((step) => ` - ${step.description ?? step.id}`),
|
|
546
|
+
newlyRequested.length > 5 ? ` … et ${newlyRequested.length - 5} autre(s).` : null,
|
|
449
547
|
'Réponds « valide tout » (ou tape /approve) pour lancer, « annule » pour abandonner.',
|
|
450
548
|
].filter(Boolean).join('\n'),
|
|
451
549
|
},
|
|
452
550
|
}));
|
|
453
|
-
emitRuntimeLog(session, `scheduler: waiting for approval (${
|
|
551
|
+
emitRuntimeLog(session, `scheduler: waiting for approval (${newlyRequested.length} new task(s))`);
|
|
454
552
|
}
|
|
455
553
|
if (Date.now() < approvalDeadline) {
|
|
456
554
|
await new Promise((resolveDelay) => setTimeout(resolveDelay, 500));
|
|
@@ -469,7 +567,9 @@ export async function runRuntimeParallelPlan(agent, session, input, {
|
|
|
469
567
|
}));
|
|
470
568
|
return { ok: false, stalled: true, reason: 'awaiting_approval', completed: sessionActivities(session), failures };
|
|
471
569
|
}
|
|
472
|
-
|
|
570
|
+
// Any genuine approval-only block returned above. Remaining tasks are
|
|
571
|
+
// unschedulable for another reason (most commonly a failed dependency).
|
|
572
|
+
const reason = 'no_ready_plan_task';
|
|
473
573
|
emitRuntimeLog(session, `scheduler: stalled (${reason})`);
|
|
474
574
|
return { ok: false, stalled: true, reason, completed: sessionActivities(session), failures };
|
|
475
575
|
}
|
|
@@ -585,11 +685,7 @@ function abortCancelledActiveTasks(session, active) {
|
|
|
585
685
|
}
|
|
586
686
|
|
|
587
687
|
function pendingSchedulerStatus(status) {
|
|
588
|
-
return
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
function approvalWaitingStatus(status) {
|
|
592
|
-
return ['pending_approval', 'waiting_approval'].includes(String(status ?? ''));
|
|
688
|
+
return PENDING_STATUSES.has(String(status ?? ''));
|
|
593
689
|
}
|
|
594
690
|
|
|
595
691
|
// Scope the evaluator/replanner's view of "completed" activities to the
|
|
@@ -607,8 +703,25 @@ function failedTaskActivities(session) {
|
|
|
607
703
|
return failedKeys.size > 0 ? all.filter((activity) => failedKeys.has(activity.key)) : all;
|
|
608
704
|
}
|
|
609
705
|
|
|
610
|
-
|
|
611
|
-
|
|
706
|
+
// Identity of a plan as displayed: the ordered list of task ids. Two plans
|
|
707
|
+
// with the same tasks in the same order are the same plan for projection
|
|
708
|
+
// purposes (statuses update through plan_step_updated, not a re-projection).
|
|
709
|
+
function planIdentitySignature(plan) {
|
|
710
|
+
return Array.isArray(plan) ? plan.map(planTaskId).join('|') : '';
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
export function ensurePlanProjection(session, runId) {
|
|
714
|
+
if (!session.headlessPlan) return;
|
|
715
|
+
// Re-emit plan_set not only when the projection has no plan yet, but also
|
|
716
|
+
// when the session plan has CHANGED shape since the last projection. A
|
|
717
|
+
// chained run (e.g. /skill pipeline) sets a fresh headlessPlan for step 2
|
|
718
|
+
// while the projection still holds step 1's plan; the old guard
|
|
719
|
+
// (`agentProjection?.plan` truthy → skip) then left the UI stuck on the
|
|
720
|
+
// step 1 plan. Compare task identities and re-project when they differ.
|
|
721
|
+
const projected = session.agentProjection?.plan ?? null;
|
|
722
|
+
if (projected && planIdentitySignature(projected) === planIdentitySignature(session.headlessPlan)) {
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
612
725
|
dispatchAgentEvent(session, createAgentEvent('plan_set', {
|
|
613
726
|
origin: 'runtime',
|
|
614
727
|
runId,
|
|
@@ -1,7 +1,38 @@
|
|
|
1
1
|
import assert from 'node:assert/strict';
|
|
2
2
|
import test from 'node:test';
|
|
3
3
|
import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
|
|
4
|
-
import { evaluateRuntimeRun, finishRuntimeRun, materializeTaskInputs, replanRuntimeRun, runRuntimeAgenticWorkflow, runRuntimeParallelPlan, shouldUseParallelScheduler } from './runner.js';
|
|
4
|
+
import { ensurePlanProjection, evaluateRuntimeRun, finishRuntimeRun, materializeTaskInputs, replanRuntimeRun, runRuntimeAgenticWorkflow, runRuntimeParallelPlan, shouldUseParallelScheduler } from './runner.js';
|
|
5
|
+
|
|
6
|
+
test('ensurePlanProjection re-projects when the chained plan changes shape (step 2)', () => {
|
|
7
|
+
const session = { agentEvents: [], agentProjection: null };
|
|
8
|
+
// Step 1: a fresh plan is projected.
|
|
9
|
+
session.headlessPlan = [{ id: 'a', description: 'ingest', status: 'pending' }];
|
|
10
|
+
ensurePlanProjection(session, 'run-1');
|
|
11
|
+
assert.deepEqual(
|
|
12
|
+
(session.agentProjection?.plan ?? []).map((step) => step.id),
|
|
13
|
+
['a'],
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
// Step 1 completes in place — same plan shape, no re-projection needed.
|
|
17
|
+
session.headlessPlan = [{ id: 'a', description: 'ingest', status: 'done' }];
|
|
18
|
+
const eventsAfterStep1 = session.agentEvents.length;
|
|
19
|
+
ensurePlanProjection(session, 'run-1');
|
|
20
|
+
assert.equal(session.agentEvents.length, eventsAfterStep1, 'unchanged shape must not re-emit plan_set');
|
|
21
|
+
|
|
22
|
+
// Step 2 adds a task — the projection must now reflect the new plan.
|
|
23
|
+
session.headlessPlan = [
|
|
24
|
+
{ id: 'a', description: 'ingest', status: 'done' },
|
|
25
|
+
{ id: 'b', description: 'build', status: 'pending' },
|
|
26
|
+
];
|
|
27
|
+
ensurePlanProjection(session, 'run-1');
|
|
28
|
+
assert.deepEqual(
|
|
29
|
+
(session.agentProjection?.plan ?? []).map((step) => step.id),
|
|
30
|
+
['a', 'b'],
|
|
31
|
+
'step 2 plan must be projected instead of staying on step 1',
|
|
32
|
+
);
|
|
33
|
+
// Completed step 1 status is preserved through the re-projection.
|
|
34
|
+
assert.equal(session.agentProjection.plan.find((step) => step.id === 'a').status, 'done');
|
|
35
|
+
});
|
|
5
36
|
|
|
6
37
|
test('validated tasks waiting for approval stay in the scheduler instead of falling back to Donna', () => {
|
|
7
38
|
assert.equal(shouldUseParallelScheduler([
|
|
@@ -781,6 +812,36 @@ test('runRuntimeParallelPlan fails cleanly when scheduler budget is exceeded', a
|
|
|
781
812
|
assert.equal(session.headlessPlan[0].status, 'cancelled');
|
|
782
813
|
});
|
|
783
814
|
|
|
815
|
+
test('runRuntimeParallelPlan does not wait for approval behind a failed dependency', async () => {
|
|
816
|
+
const session = {
|
|
817
|
+
workspace: 'demo-workspace',
|
|
818
|
+
mcp: { tools: {} },
|
|
819
|
+
agentEvents: [],
|
|
820
|
+
headlessPlan: [
|
|
821
|
+
{ ...plannedDoctorTask('a', []), status: 'failed' },
|
|
822
|
+
{
|
|
823
|
+
...plannedDoctorTask('b', ['a']),
|
|
824
|
+
status: 'waiting_approval',
|
|
825
|
+
requiresApproval: true,
|
|
826
|
+
approvalClass: 'mutation',
|
|
827
|
+
},
|
|
828
|
+
],
|
|
829
|
+
};
|
|
830
|
+
|
|
831
|
+
const result = await runRuntimeParallelPlan(
|
|
832
|
+
{ invoke: async () => assert.fail('blocked work must not invoke an agent') },
|
|
833
|
+
session,
|
|
834
|
+
'Apply after failed plan',
|
|
835
|
+
{ runId: 'run-failed-dependency', timeoutMs: 1000, maxTurns: 1 },
|
|
836
|
+
);
|
|
837
|
+
|
|
838
|
+
assert.equal(result.ok, false);
|
|
839
|
+
assert.equal(result.stalled, true);
|
|
840
|
+
assert.equal(result.reason, 'no_ready_plan_task');
|
|
841
|
+
assert.equal(session.agentEvents.some((event) => event.type === 'assistant_message'
|
|
842
|
+
&& /Approbation requise/.test(event.payload?.content ?? '')), false);
|
|
843
|
+
});
|
|
844
|
+
|
|
784
845
|
test('runRuntimeParallelPlan retries a retryable task on a fallback agent', async () => {
|
|
785
846
|
const executeServers = [];
|
|
786
847
|
const session = {
|
package/src/runtime/server.js
CHANGED
|
@@ -6,6 +6,8 @@ import { normalizePlanPatch, rebasePlanPatch } from '../core/planPatch.js';
|
|
|
6
6
|
import { validateContractInDev } from '../contracts/schemas.js';
|
|
7
7
|
import { runtimeTokenFromEnv } from './auth.js';
|
|
8
8
|
import { controlMessage } from './controlMessages.js';
|
|
9
|
+
import { tasksAwaitingApproval } from '../orchestrator/dependencyResolver.js';
|
|
10
|
+
import { approvalClassForTask } from '../orchestrator/approvalPolicy.js';
|
|
9
11
|
|
|
10
12
|
export function startRuntimeServer({
|
|
11
13
|
host = '127.0.0.1',
|
|
@@ -679,10 +681,19 @@ function readOnlyControlResponse(kind, classification, status, explanation, { ac
|
|
|
679
681
|
};
|
|
680
682
|
}
|
|
681
683
|
|
|
682
|
-
function approvalRequestFromStatus(status) {
|
|
684
|
+
export function approvalRequestFromStatus(status) {
|
|
683
685
|
const runId = status.runId ?? status.runs?.find((run) => run.status === 'running' || run.status === 'pending_approval')?.id ?? null;
|
|
684
686
|
const pending = (status.approvals ?? []).filter((approval) => approval.status === 'pending_approval');
|
|
685
|
-
const
|
|
687
|
+
const waitingTasks = tasksAwaitingApproval({
|
|
688
|
+
runId,
|
|
689
|
+
workspace: status.workspace ?? null,
|
|
690
|
+
planRevision: status.planRevision ?? null,
|
|
691
|
+
tasks: status.plan ?? [],
|
|
692
|
+
}, { approvals: status.approvals ?? [] });
|
|
693
|
+
const classes = [...new Set([
|
|
694
|
+
...pending.flatMap((approval) => readOptionalList(approval.approvalClasses ?? approval.approvalClass)),
|
|
695
|
+
...waitingTasks.map((task) => approvalClassForTask(task)),
|
|
696
|
+
].filter(Boolean))];
|
|
686
697
|
return {
|
|
687
698
|
workspace: status.workspace ?? null,
|
|
688
699
|
workspaceId: status.workspace ?? null,
|
|
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict';
|
|
|
2
2
|
import test from 'node:test';
|
|
3
3
|
import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
|
|
4
4
|
import { createInteractiveSession, ensureInteractiveAssistantMessage } from '../cli/wiki-manager.js';
|
|
5
|
-
import { runtimeState, startRuntimeServer as startRuntimeServerImpl } from './server.js';
|
|
5
|
+
import { approvalRequestFromStatus, runtimeState, startRuntimeServer as startRuntimeServerImpl } from './server.js';
|
|
6
6
|
|
|
7
7
|
// Most server tests exercise endpoint behavior rather than authentication. Keep
|
|
8
8
|
// them independent from a developer's WIKI_MANAGER_RUNTIME_TOKEN environment;
|
|
@@ -11,6 +11,35 @@ function startRuntimeServer(options) {
|
|
|
11
11
|
return startRuntimeServerImpl({ token: '', ...options });
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
test('approval fallback derives classes from waiting tasks when the approval queue is missing', () => {
|
|
15
|
+
const request = approvalRequestFromStatus({
|
|
16
|
+
workspace: 'acme',
|
|
17
|
+
runId: 'run-1',
|
|
18
|
+
planRevision: 4,
|
|
19
|
+
approvals: [],
|
|
20
|
+
plan: [
|
|
21
|
+
{ id: 'apply', status: 'waiting_approval', requiresApproval: true, approvalClass: 'mutation' },
|
|
22
|
+
{ id: 'export', status: 'waiting_approval', requiresApproval: true, approvalClass: 'external-write' },
|
|
23
|
+
],
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
assert.deepEqual(request.approvalClasses, ['mutation', 'external-write']);
|
|
27
|
+
assert.equal(request.scope, 'run');
|
|
28
|
+
assert.equal(request.planRevision, 4);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('approval fallback excludes tasks blocked by failed dependencies', () => {
|
|
32
|
+
const request = approvalRequestFromStatus({
|
|
33
|
+
workspace: 'acme', runId: 'run-1', planRevision: 4, approvals: [],
|
|
34
|
+
plan: [
|
|
35
|
+
{ id: 'failed-source', status: 'failed' },
|
|
36
|
+
{ id: 'blocked-write', status: 'waiting_approval', requiresApproval: true, approvalClass: 'external-write', dependsOn: ['failed-source'] },
|
|
37
|
+
],
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
assert.deepEqual(request.approvalClasses, ['default']);
|
|
41
|
+
});
|
|
42
|
+
|
|
14
43
|
test('interactive runtime sessions isolate canonical run state', () => {
|
|
15
44
|
const mcp = { wiki: { status: 'connected' } };
|
|
16
45
|
const session = createInteractiveSession({ session: {
|
|
@@ -83,9 +83,9 @@ export function FileEditorDialog(props: {
|
|
|
83
83
|
{(message) => <text height={1} fg="#F87171">{message()}</text>}
|
|
84
84
|
</Show>
|
|
85
85
|
<box height={1} flexDirection="row">
|
|
86
|
-
<text fg="#111318" bg="#8BD5CA"> Save Ctrl+S </text>
|
|
86
|
+
<text fg="#111318" bg="#8BD5CA" onMouseUp={save}> Save Ctrl+S </text>
|
|
87
87
|
<text fg="#7F8C8D"> </text>
|
|
88
|
-
<text fg="#D6DEE8" bg="#4B5563"> Cancel Esc </text>
|
|
88
|
+
<text fg="#D6DEE8" bg="#4B5563" onMouseUp={props.onCancel}> Cancel Esc </text>
|
|
89
89
|
</box>
|
|
90
90
|
</box>
|
|
91
91
|
)}
|
package/src/shell/LeftPane.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/** @jsxImportSource @opentui/solid */
|
|
2
2
|
import { For, createEffect, createMemo, createSignal, onCleanup } from 'solid-js';
|
|
3
|
+
import { StyledText, bg as styledBg, fg as styledFg, link as styledLink } from '@opentui/core';
|
|
3
4
|
import { colorForRenderedLine, helpCommandParts, keyValueParts, renderPlainMarkdown } from './renderer';
|
|
4
5
|
import { httpLinkParts, wrapHttpLinks } from './externalLinks.js';
|
|
5
6
|
|
|
@@ -49,8 +50,20 @@ function wrapLine(line: string, width: number) {
|
|
|
49
50
|
}
|
|
50
51
|
|
|
51
52
|
type Segment = { text: string; color: string; width?: number; bg?: string; url?: string };
|
|
53
|
+
|
|
54
|
+
function styledSegments(segments: Segment[]) {
|
|
55
|
+
return new StyledText(segments.map((segment) => {
|
|
56
|
+
const text = segment.width ? segment.text.padEnd(segment.width) : segment.text;
|
|
57
|
+
let chunk = styledFg(segment.color)(text);
|
|
58
|
+
if (segment.bg) chunk = styledBg(segment.bg)(chunk);
|
|
59
|
+
if (segment.url) chunk = styledLink(segment.url)(chunk);
|
|
60
|
+
return chunk;
|
|
61
|
+
}));
|
|
62
|
+
}
|
|
52
63
|
type RenderedLine = {
|
|
53
64
|
segments: Segment[];
|
|
65
|
+
role?: string;
|
|
66
|
+
userBubbleWidth?: number;
|
|
54
67
|
status?: boolean;
|
|
55
68
|
statusLeft?: string;
|
|
56
69
|
statusRight?: string;
|
|
@@ -376,19 +389,35 @@ function statusColumns(line: string): { left: string; right: string } {
|
|
|
376
389
|
return { left: left || ' ', right };
|
|
377
390
|
}
|
|
378
391
|
|
|
392
|
+
// Consecutive messages that render under the same header ([shell]/[user]/
|
|
393
|
+
// [donna]) are grouped: only the first one shows the label + separator, so a
|
|
394
|
+
// run of same-type messages doesn't repeat the header on every entry.
|
|
395
|
+
function headerGroupKey(role: string) {
|
|
396
|
+
return isDonnaRole(role) ? 'donna' : roleLabel(role);
|
|
397
|
+
}
|
|
398
|
+
|
|
379
399
|
function conversationLines(messages: Array<{ role: string; content: string }>, columns: number): RenderedLine[] {
|
|
380
|
-
return messages.flatMap((message) => {
|
|
400
|
+
return messages.flatMap((message, index) => {
|
|
381
401
|
const raw = String(message.content || '');
|
|
402
|
+
const previous = index > 0 ? messages[index - 1] : null;
|
|
403
|
+
const showHeader = !previous || headerGroupKey(previous.role) !== headerGroupKey(message.role);
|
|
404
|
+
const headerLines: RenderedLine[] = showHeader
|
|
405
|
+
? [
|
|
406
|
+
{ segments: messageHeaderSegments(message.role, columns), copyContent: raw },
|
|
407
|
+
{ segments: [{ text: ' ', color: '#D6DEE8' }] },
|
|
408
|
+
]
|
|
409
|
+
: [];
|
|
410
|
+
const contentColumns = message.role === 'user' ? Math.max(12, Math.floor(columns * 0.6) - 2) : columns;
|
|
382
411
|
if (isStatusOutput(message)) {
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
{ segments: [{ text: ' ', color: '#D6DEE8' }] },
|
|
412
|
+
const statusLines: RenderedLine[] = [
|
|
413
|
+
...headerLines,
|
|
386
414
|
...raw.split('\n').map((line) => {
|
|
387
415
|
const { left, right } = statusColumns(line || ' ');
|
|
388
416
|
return { status: true, statusLeft: left, statusRight: right, segments: [] };
|
|
389
417
|
}),
|
|
390
418
|
{ segments: [{ text: ' ', color: '#D6DEE8' }] },
|
|
391
419
|
];
|
|
420
|
+
return statusLines.map((line) => ({ ...line, role: message.role }));
|
|
392
421
|
}
|
|
393
422
|
let inFence = false;
|
|
394
423
|
const lines: Array<{ text: string; isCode: boolean }> = [];
|
|
@@ -399,12 +428,19 @@ function conversationLines(messages: Array<{ role: string; content: string }>, c
|
|
|
399
428
|
if (/^\s*(`{3,}|~{3,})/.test(line)) { inFence = !inFence; continue; }
|
|
400
429
|
lines.push({ text: line, isCode: inFence });
|
|
401
430
|
}
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
431
|
+
const renderedBody = renderMarkdownLines(lines, message.role, contentColumns);
|
|
432
|
+
const userBubbleWidth = message.role === 'user'
|
|
433
|
+
? Math.min(
|
|
434
|
+
Math.max(1, ...renderedBody.map((line) => line.segments.reduce((sum, segment) => sum + segment.text.length, 0))) + 2,
|
|
435
|
+
Math.max(12, Math.floor(columns * 0.6)),
|
|
436
|
+
)
|
|
437
|
+
: undefined;
|
|
438
|
+
const bodyLines: RenderedLine[] = [
|
|
439
|
+
...headerLines,
|
|
440
|
+
...renderedBody.map((line) => ({ ...line, userBubbleWidth })),
|
|
406
441
|
{ segments: [{ text: ' ', color: '#D6DEE8' }] },
|
|
407
442
|
];
|
|
443
|
+
return bodyLines.map((line) => ({ ...line, role: message.role }));
|
|
408
444
|
});
|
|
409
445
|
}
|
|
410
446
|
|
|
@@ -462,6 +498,21 @@ export function ConversationView(props: {
|
|
|
462
498
|
</For>
|
|
463
499
|
<text fg="#4B5563" content={COPY_BTN} onMouseUp={() => props.onCopy?.(line.copyContent!)} />
|
|
464
500
|
</box>
|
|
501
|
+
) : line.role === 'user' && line.userBubbleWidth ? (
|
|
502
|
+
<box height={1} flexDirection="row" overflow="hidden">
|
|
503
|
+
<box flexGrow={1} />
|
|
504
|
+
<box
|
|
505
|
+
width={line.userBubbleWidth}
|
|
506
|
+
height={1}
|
|
507
|
+
flexDirection="row"
|
|
508
|
+
flexShrink={1}
|
|
509
|
+
justifyContent="flex-start"
|
|
510
|
+
paddingX={1}
|
|
511
|
+
overflow="hidden"
|
|
512
|
+
>
|
|
513
|
+
<text>{styledSegments(line.segments)}</text>
|
|
514
|
+
</box>
|
|
515
|
+
</box>
|
|
465
516
|
) : line.status ? (
|
|
466
517
|
<box height={1} flexDirection="row" gap={2} overflow="hidden">
|
|
467
518
|
<box
|
|
@@ -499,13 +550,7 @@ export function ConversationView(props: {
|
|
|
499
550
|
</box>
|
|
500
551
|
) : (
|
|
501
552
|
<box height={1} flexDirection="row" overflow="hidden">
|
|
502
|
-
<
|
|
503
|
-
{(seg: Segment) => (
|
|
504
|
-
<text width={seg.width} fg={seg.color} bg={seg.bg}>
|
|
505
|
-
{seg.url ? <a href={seg.url}>{seg.text}</a> : seg.text}
|
|
506
|
-
</text>
|
|
507
|
-
)}
|
|
508
|
-
</For>
|
|
553
|
+
<text>{styledSegments(line.segments)}</text>
|
|
509
554
|
</box>
|
|
510
555
|
)
|
|
511
556
|
)}
|