@dotdrelle/wiki-manager 0.9.3 → 0.11.0

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.
@@ -0,0 +1,93 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { assertContract, validateContract } from './schemas.js';
4
+
5
+ test('activity contract accepts the canonical v1 shape and extra fields', () => {
6
+ const activity = {
7
+ schemaVersion: '1',
8
+ id: 'job-1',
9
+ source: 'production',
10
+ kind: 'build',
11
+ label: 'Production build',
12
+ status: 'running',
13
+ progress: {
14
+ percent: 42,
15
+ stepId: 'draft',
16
+ parentActivityKey: 'production:parent',
17
+ detail: 'Drafting',
18
+ },
19
+ poll: null,
20
+ outputRefs: ['deliverables/report.md', { type: 'wiki_page', ref: 'Reports/Build' }],
21
+ agentSpecificField: true,
22
+ };
23
+
24
+ assert.equal(validateContract('activity', activity).ok, true);
25
+ assert.equal(assertContract('activity', activity), activity);
26
+ });
27
+
28
+ test('activity contract rejects missing canonical fields', () => {
29
+ const result = validateContract('activity', {
30
+ id: 'job-1',
31
+ source: 'production',
32
+ kind: 'build',
33
+ label: 'Production build',
34
+ status: 'running',
35
+ progress: {},
36
+ poll: null,
37
+ outputRefs: [],
38
+ });
39
+
40
+ assert.equal(result.ok, false);
41
+ assert.ok(result.errors.some((error) => error.includes('schemaVersion')));
42
+ });
43
+
44
+ test('agent event contract carries the unified audit identity fields', () => {
45
+ const event = {
46
+ id: 'event-1',
47
+ ts: new Date(0).toISOString(),
48
+ type: 'tool_call_result',
49
+ origin: 'runtime',
50
+ runId: 'run-1',
51
+ turnId: 'turn-1',
52
+ taskId: 'task-1',
53
+ workspace: 'juno',
54
+ payload: { toolCallId: 'call-1', ok: true },
55
+ };
56
+
57
+ assert.equal(validateContract('agentRunEvent', event).ok, true);
58
+ });
59
+
60
+ test('run and control request contracts reject empty run input but accept explicit controls', () => {
61
+ assert.equal(validateContract('runRequest', { input: 'Build docs', workspace: 'juno' }).ok, true);
62
+ assert.equal(validateContract('runRequest', { input: '' }).ok, false);
63
+ assert.equal(validateContract('controlMessage', { action: 'message', input: 'Ou en est le build ?', intent: 'observe' }).ok, true);
64
+ });
65
+
66
+ test('plan and patch contracts cover structured dependencies and output refs', () => {
67
+ const plan = [{
68
+ step: 1,
69
+ id: 'task-a',
70
+ description: 'Export',
71
+ status: 'pending',
72
+ dependsOn: [],
73
+ executor: 'cme.cme_export_run',
74
+ executorQuery: null,
75
+ outputRefs: ['raw/export.json'],
76
+ }];
77
+ const patch = {
78
+ targetRunId: 'run-1',
79
+ basePlanRevision: 4,
80
+ operations: [{
81
+ op: 'add_task',
82
+ task: {
83
+ id: 'task-b',
84
+ description: 'Build',
85
+ dependsOn: ['task-a'],
86
+ executorQuery: { capability: 'production build' },
87
+ },
88
+ }],
89
+ };
90
+
91
+ assert.equal(validateContract('plan', plan).ok, true);
92
+ assert.equal(validateContract('planPatch', patch).ok, true);
93
+ });
@@ -1,3 +1,5 @@
1
+ import { validateContractInDev } from '../contracts/schemas.js';
2
+
1
3
  export function parseJsonText(text) {
2
4
  try {
3
5
  return JSON.parse(text);
@@ -14,7 +16,7 @@ function terminalStatus(status) {
14
16
  return ['done', 'failed', 'cancelled', 'canceled', 'complete', 'completed', 'success', 'error'].includes(String(status ?? '').toLowerCase());
15
17
  }
16
18
 
17
- function activityKey(activity) {
19
+ export function activityKey(activity) {
18
20
  const id = activity?.id ?? activity?.jobId ?? activity?.job_id;
19
21
  const source = activity?.source ?? activity?.agent ?? activity?.poll?.server ?? 'mcp';
20
22
  return `${source}:${id ?? activity?.kind ?? activity?.label ?? 'activity'}`;
@@ -25,6 +27,10 @@ function normalizePlanSteps(steps) {
25
27
  return steps.map((s, i) => ({
26
28
  id: s != null && s.id != null ? String(s.id) : String(i + 1),
27
29
  label: s != null ? String(s.label ?? s.description ?? s.name ?? s.id ?? (i + 1)) : String(i + 1),
30
+ dependsOn: Array.isArray(s?.dependsOn) ? s.dependsOn.map(String) : [],
31
+ executor: s?.executor ?? null,
32
+ executorQuery: s?.executorQuery ?? null,
33
+ outputRefs: Array.isArray(s?.outputRefs) ? s.outputRefs.map(String) : [],
28
34
  }));
29
35
  }
30
36
 
@@ -62,9 +68,13 @@ export function normalizeActivity(activity, fallback = {}) {
62
68
  kind,
63
69
  step,
64
70
  ].filter(Boolean).join(' ');
71
+ const outputRefs = Array.isArray(activity.outputRefs)
72
+ ? activity.outputRefs.map((ref) => (ref && typeof ref === 'object' ? { ...ref } : String(ref)))
73
+ : [];
65
74
  const normalized = {
66
75
  key: null,
67
- id,
76
+ schemaVersion: String(activity.schemaVersion ?? '1'),
77
+ id: String(id ?? kind ?? 'activity'),
68
78
  source: String(source),
69
79
  kind: String(kind),
70
80
  label: String(label || `${source} ${kind}`),
@@ -79,12 +89,14 @@ export function normalizeActivity(activity, fallback = {}) {
79
89
  },
80
90
  plan: planSteps ? { steps: planSteps } : null,
81
91
  poll: normalizePoll(activity.poll ?? fallback.poll),
92
+ outputRefs,
82
93
  startedAt: activity.startedAt ?? fallback.startedAt ?? null,
83
94
  updatedAt: activity.updatedAt ?? new Date().toISOString(),
84
95
  error: activity.error ?? null,
85
96
  terminal: Boolean(activity.terminal ?? terminalStatus(status)),
86
97
  };
87
98
  normalized.key = activityKey(normalized);
99
+ validateContractInDev('activity', normalized);
88
100
  return normalized;
89
101
  }
90
102
 
@@ -11,9 +11,11 @@ test('normalizeActivity: plan.steps preserved with id and label', () => {
11
11
  plan: { steps: [{ id: 'extract', label: 'Extraction' }, { id: 'build', label: 'Build' }] },
12
12
  progress: {},
13
13
  });
14
+ assert.equal(a.schemaVersion, '1');
15
+ assert.deepEqual(a.outputRefs, []);
14
16
  assert.deepEqual(a.plan.steps, [
15
- { id: 'extract', label: 'Extraction' },
16
- { id: 'build', label: 'Build' },
17
+ { id: 'extract', label: 'Extraction', dependsOn: [], executor: null, executorQuery: null, outputRefs: [] },
18
+ { id: 'build', label: 'Build', dependsOn: [], executor: null, executorQuery: null, outputRefs: [] },
17
19
  ]);
18
20
  });
19
21
 
@@ -1,10 +1,19 @@
1
1
  import { normalizeActivity } from './activity.js';
2
2
  import { attachActivityToExistingPlan, syncActivitiesToPlan } from './plan.js';
3
+ import { applyPlanPatch, normalizePlanPatch, normalizePlanRevision, rebasePlanPatch } from './planPatch.js';
4
+ import { projectWorkflow } from './workflow.js';
5
+ import { validateContractInDev } from '../contracts/schemas.js';
3
6
 
4
7
  const SESSION_PROJECTION_EVENTS = new Set([
5
8
  'run_started',
6
9
  'plan_set',
7
10
  'plan_step_updated',
11
+ 'control_message_received',
12
+ 'plan_patch_proposed',
13
+ 'plan_patch_approved',
14
+ 'plan_patch_applied',
15
+ 'plan_patch_rebased',
16
+ 'plan_patch_rejected',
8
17
  'activity_upserted',
9
18
  'run_evaluated',
10
19
  'run_replanned',
@@ -26,6 +35,7 @@ const SESSION_PROJECTION_EVENTS = new Set([
26
35
  const PLAN_MUTATING_EVENTS = new Set([
27
36
  'run_started',
28
37
  'plan_set',
38
+ 'plan_patch_applied',
29
39
  'plan_step_updated',
30
40
  'activity_upserted',
31
41
  'run_done',
@@ -36,18 +46,20 @@ export function createAgentEvent(type, {
36
46
  payload = {},
37
47
  runId = null,
38
48
  turnId = null,
49
+ taskId = null,
39
50
  workspace = null,
40
51
  } = {}) {
41
- return {
52
+ return validateContractInDev('agentRunEvent', {
42
53
  id: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`,
43
54
  ts: new Date().toISOString(),
44
55
  type,
45
56
  origin,
46
57
  runId,
47
58
  turnId,
59
+ taskId,
48
60
  workspace,
49
61
  payload,
50
- };
62
+ });
51
63
  }
52
64
 
53
65
  export function dispatchAgentEvent(session, event) {
@@ -55,6 +67,7 @@ export function dispatchAgentEvent(session, event) {
55
67
  event.id && event.ts ? event : createAgentEvent(event.type, event),
56
68
  session,
57
69
  );
70
+ validateContractInDev('agentRunEvent', normalized);
58
71
  const tracksPlan = PLAN_MUTATING_EVENTS.has(normalized.type);
59
72
  const previousPlan = tracksPlan ? JSON.stringify(session.headlessPlan ?? null) : null;
60
73
  session.agentEvents ??= [];
@@ -79,6 +92,7 @@ function withSessionRunIdentity(event, session) {
79
92
  ...event,
80
93
  runId: event.runId ?? identity.runId ?? null,
81
94
  turnId: event.turnId ?? identity.turnId ?? null,
95
+ taskId: event.taskId ?? identity.taskId ?? null,
82
96
  workspace: event.workspace ?? identity.workspace ?? null,
83
97
  };
84
98
  }
@@ -103,6 +117,8 @@ function createProjectionState() {
103
117
  evaluation: null,
104
118
  replans: [],
105
119
  approvals: [],
120
+ planRevision: 0,
121
+ planPatches: [],
106
122
  controlQueue: [],
107
123
  summary: null,
108
124
  status: 'idle',
@@ -110,7 +126,7 @@ function createProjectionState() {
110
126
  }
111
127
 
112
128
  function publicProjection(state) {
113
- return {
129
+ const projection = {
114
130
  conversation: state.conversation.map((message) => ({ ...message })),
115
131
  chain: state.chain.map((step) => ({ ...step })),
116
132
  plan: state.plan ? state.plan.map((step) => ({ ...step })) : null,
@@ -119,16 +135,29 @@ function publicProjection(state) {
119
135
  evaluation: state.evaluation ? { ...state.evaluation } : null,
120
136
  replans: state.replans.map((replan) => ({ ...replan, plan: [...(replan.plan ?? [])] })),
121
137
  approvals: state.approvals.map((approval) => ({ ...approval })),
138
+ planRevision: state.planRevision,
139
+ planPatches: state.planPatches.map((patch) => ({
140
+ ...patch,
141
+ operations: (patch.operations ?? []).map((operation) => ({ ...operation })),
142
+ patch: patch.patch ? { ...patch.patch, operations: (patch.patch.operations ?? []).map((operation) => ({ ...operation })) } : null,
143
+ })),
122
144
  controlQueue: state.controlQueue.map((item) => ({ ...item })),
123
145
  summary: state.summary,
124
146
  status: state.status,
125
147
  };
148
+ return {
149
+ ...projection,
150
+ workflow: projectWorkflow(projection),
151
+ };
126
152
  }
127
153
 
128
154
  export function applyAgentProjectionToSession(session, projection) {
129
155
  session.headlessPlan = projection.plan ? projection.plan.map((step) => ({ ...step })) : null;
130
156
  session.activities = Object.fromEntries((projection.activities ?? []).map((activity) => [activity.key, { ...activity }]));
131
157
  session.controlQueue = (projection.controlQueue ?? []).map((item) => ({ ...item }));
158
+ session.planRevision = projection.planRevision ?? 0;
159
+ session.planPatches = (projection.planPatches ?? []).map((patch) => ({ ...patch }));
160
+ session.workflow = projection.workflow ? { ...projection.workflow } : null;
132
161
  const production = (projection.activities ?? []).filter((activity) => activity.source === 'production').at(-1);
133
162
  session.productionActivity = production ? {
134
163
  jobId: production.id,
@@ -143,13 +172,15 @@ function applyEvent(state, event) {
143
172
  switch (event.type) {
144
173
  case 'run_started':
145
174
  state.status = 'running';
146
- state.plan = defaultRunPlan();
175
+ state.plan = null;
147
176
  state.chain = [];
148
177
  state.activities = {};
149
178
  state.logs = [];
150
179
  state.evaluation = null;
151
180
  state.replans = [];
152
181
  state.approvals = [];
182
+ state.planRevision = 0;
183
+ state.planPatches = [];
153
184
  state.summary = null;
154
185
  return;
155
186
  case 'user_message':
@@ -179,10 +210,97 @@ function applyEvent(state, event) {
179
210
  return;
180
211
  case 'plan_set':
181
212
  state.plan = normalizePlan(event.payload?.steps, event.payload ?? {});
213
+ // A plan_set always replaces the plan wholesale (initial declaration,
214
+ // fallback extraction, or a full replan) — bump the revision so any
215
+ // patch proposed against the prior plan is detected as stale and
216
+ // rebased instead of silently applying against a structure that no
217
+ // longer matches what it was built for. An explicit payload.planRevision
218
+ // still wins, for callers that manage revisions themselves.
219
+ state.planRevision = event.payload?.planRevision != null
220
+ ? normalizePlanRevision(event.payload.planRevision)
221
+ : state.planRevision + 1;
182
222
  return;
183
223
  case 'plan_step_updated':
184
224
  updatePlanStep(state.plan, event.payload ?? {});
185
225
  return;
226
+ case 'control_message_received':
227
+ state.logs.push(`Control message: ${String(event.payload?.input ?? '')}`);
228
+ state.logs = state.logs.slice(-200);
229
+ return;
230
+ case 'plan_patch_proposed':
231
+ upsertPlanPatch(state, {
232
+ id: patchIdFromEvent(event),
233
+ status: 'proposed',
234
+ runId: event.runId ?? event.payload?.targetRunId ?? null,
235
+ workspace: event.workspace ?? null,
236
+ input: event.payload?.input ?? null,
237
+ patch: normalizePlanPatch(event.payload?.patch ?? {}, {
238
+ targetRunId: event.runId ?? event.payload?.targetRunId ?? null,
239
+ basePlanRevision: state.planRevision,
240
+ }),
241
+ createdAt: event.ts,
242
+ updatedAt: event.ts,
243
+ });
244
+ return;
245
+ case 'plan_patch_approved':
246
+ upsertPlanPatch(state, {
247
+ id: patchIdFromEvent(event),
248
+ status: 'approved',
249
+ approvedAt: event.ts,
250
+ updatedAt: event.ts,
251
+ });
252
+ return;
253
+ case 'plan_patch_rebased': {
254
+ const existing = state.planPatches.find((patch) => patch.id === patchIdFromEvent(event));
255
+ const rebased = normalizePlanPatch(event.payload?.patch ?? rebasePlanPatch(existing?.patch ?? {}, { currentRevision: state.planRevision }), {
256
+ targetRunId: event.runId ?? null,
257
+ basePlanRevision: state.planRevision,
258
+ });
259
+ upsertPlanPatch(state, {
260
+ id: patchIdFromEvent(event),
261
+ status: 'rebased',
262
+ patch: rebased,
263
+ updatedAt: event.ts,
264
+ });
265
+ return;
266
+ }
267
+ case 'plan_patch_applied': {
268
+ const patchId = patchIdFromEvent(event);
269
+ const patch = normalizePlanPatch(event.payload?.patch ?? {}, {
270
+ targetRunId: event.runId ?? event.payload?.targetRunId ?? null,
271
+ basePlanRevision: state.planRevision,
272
+ });
273
+ const applied = applyPlanPatch(state.plan, patch, { currentRevision: state.planRevision });
274
+ if (!applied.ok) {
275
+ upsertPlanPatch(state, {
276
+ id: patchId,
277
+ status: 'rejected',
278
+ rejectionReason: applied.reason,
279
+ currentRevision: applied.currentRevision,
280
+ updatedAt: event.ts,
281
+ });
282
+ return;
283
+ }
284
+ state.plan = applied.plan;
285
+ state.planRevision = applied.planRevision;
286
+ upsertPlanPatch(state, {
287
+ id: patchId,
288
+ status: 'applied',
289
+ patch,
290
+ appliedAt: event.ts,
291
+ planRevision: state.planRevision,
292
+ updatedAt: event.ts,
293
+ });
294
+ return;
295
+ }
296
+ case 'plan_patch_rejected':
297
+ upsertPlanPatch(state, {
298
+ id: patchIdFromEvent(event),
299
+ status: 'rejected',
300
+ rejectionReason: event.payload?.reason ?? null,
301
+ updatedAt: event.ts,
302
+ });
303
+ return;
186
304
  case 'run_summary':
187
305
  state.summary = String(event.payload?.content ?? '');
188
306
  if (state.summary) state.conversation.push({ role: 'assistant', content: state.summary });
@@ -288,6 +406,15 @@ function upsertControlItem(queue, next) {
288
406
  upsertById(queue, next);
289
407
  }
290
408
 
409
+ function upsertPlanPatch(state, next) {
410
+ if (!next.id) return;
411
+ upsertById(state.planPatches, next);
412
+ }
413
+
414
+ function patchIdFromEvent(event) {
415
+ return event.payload?.id ?? event.payload?.patchId ?? event.id;
416
+ }
417
+
291
418
  function finishControlByRun(queue, runId, status, finishedAt) {
292
419
  if (!runId) return;
293
420
  const item = queue.find((entry) => entry.runId === runId && entry.status === 'running');
@@ -368,6 +495,10 @@ function ensurePlanFromActivityProjection(state, activity) {
368
495
  id: step.id ?? null,
369
496
  description: step.label,
370
497
  status: 'pending',
498
+ dependsOn: Array.isArray(step.dependsOn) ? step.dependsOn.map(String) : [],
499
+ executor: step.executor ?? null,
500
+ executorQuery: step.executorQuery ?? null,
501
+ outputRefs: Array.isArray(step.outputRefs) ? step.outputRefs.map(String) : [],
371
502
  owner: 'activity',
372
503
  ownerActivityKey: activity.key,
373
504
  _activityKey: activity.key,
@@ -379,6 +510,10 @@ function ensurePlanFromActivityProjection(state, activity) {
379
510
  id: null,
380
511
  description: activity.label,
381
512
  status: 'pending',
513
+ dependsOn: [],
514
+ executor: null,
515
+ executorQuery: null,
516
+ outputRefs: [],
382
517
  owner: 'activity',
383
518
  ownerActivityKey: activity.key,
384
519
  _activityKey: activity.key,
@@ -389,33 +524,41 @@ function normalizePlan(steps, payload = {}) {
389
524
  if (!Array.isArray(steps)) return null;
390
525
  const owner = payload.owner ?? 'orchestrator';
391
526
  const ownerActivityKey = payload.ownerActivityKey ?? payload.activityKey ?? null;
392
- return steps.map((raw, i) => {
527
+ const plan = steps.map((raw, i) => {
393
528
  const item = typeof raw === 'string' ? { description: raw } : (raw ?? {});
394
529
  return {
395
530
  step: Number(item.step ?? i + 1),
396
531
  id: item.id ?? null,
397
532
  description: String(item.description ?? item.label ?? item.name ?? `Step ${i + 1}`),
398
533
  status: item.status ?? 'pending',
534
+ dependsOn: Array.isArray(item.dependsOn) ? item.dependsOn.map(String) : [],
535
+ executor: item.executor ?? null,
536
+ executorQuery: item.executorQuery ?? null,
537
+ outputRefs: Array.isArray(item.outputRefs) ? item.outputRefs.map(String) : [],
538
+ locks: Array.isArray(item.locks) ? item.locks.map(String) : item.locks ?? null,
539
+ writeLocks: Array.isArray(item.writeLocks) ? item.writeLocks.map(String) : item.writeLocks ?? null,
540
+ deliverableWrites: Array.isArray(item.deliverableWrites) ? item.deliverableWrites.map(String) : [],
541
+ wikiPageWrites: Array.isArray(item.wikiPageWrites) ? item.wikiPageWrites.map(String) : [],
542
+ workspaceWrite: item.workspaceWrite === true,
399
543
  owner: item.owner ?? owner,
400
544
  ownerActivityKey: item.ownerActivityKey ?? ownerActivityKey,
401
545
  _activityKey: item._activityKey ?? payload.activityKey ?? null,
402
546
  };
403
547
  });
404
- }
405
-
406
- function defaultRunPlan() {
407
- return [
408
- { step: 1, id: 'analyze', description: 'Analyze the request', status: 'running', owner: 'orchestrator', ownerActivityKey: null, _activityKey: null },
409
- { step: 2, id: 'execute', description: 'Execute the required actions', status: 'pending', owner: 'orchestrator', ownerActivityKey: null, _activityKey: null },
410
- { step: 3, id: 'verify', description: 'Verify the result', status: 'pending', owner: 'orchestrator', ownerActivityKey: null, _activityKey: null },
411
- ];
548
+ return validateContractInDev('plan', plan);
412
549
  }
413
550
 
414
551
  function updatePlanStep(plan, payload) {
415
552
  if (!plan) return;
416
- const step = plan.find((item) => item.step === Number(payload.step));
553
+ const requestedTaskId = payload.taskId ?? payload.id ?? payload.targetTaskId;
554
+ const step = requestedTaskId != null
555
+ ? plan.find((item) => String(item.id ?? item.step) === String(requestedTaskId))
556
+ : plan.find((item) => item.step === Number(payload.step));
417
557
  if (!step) return;
418
- step.status = payload.status === 'failed' ? 'failed' : payload.status === 'running' ? 'running' : 'done';
558
+ if (payload.status === 'failed') step.status = 'failed';
559
+ else if (payload.status === 'running') step.status = 'running';
560
+ else if (payload.status === 'cancelled') step.status = 'cancelled';
561
+ else step.status = 'done';
419
562
  if (payload.activityKey) step.activityKey = payload.activityKey;
420
563
  }
421
564
 
@@ -22,9 +22,7 @@ test('reduceAgentEvents: run_started clears stale plan', () => {
22
22
  }),
23
23
  createAgentEvent('run_started', { origin: 'user' }),
24
24
  ]);
25
- assert.equal(projection.plan.length, 3);
26
- assert.equal(projection.plan[0].description, 'Analyze the request');
27
- assert.equal(projection.plan[0].owner, 'orchestrator');
25
+ assert.equal(projection.plan, null);
28
26
  assert.equal(projection.activities.length, 0);
29
27
  assert.equal(projection.status, 'running');
30
28
  });
@@ -95,7 +93,7 @@ test('reduceAgentEvents: activity attaches to orchestrator plan without replacin
95
93
  assert.equal(projection.plan[0].ownerActivityKey, 'cme:export-1');
96
94
  });
97
95
 
98
- test('reduceAgentEvents: run activity attaches to execution step of default plan', () => {
96
+ test('reduceAgentEvents: run activity creates activity-owned plan when no explicit plan exists', () => {
99
97
  const projection = reduceAgentEvents([
100
98
  createAgentEvent('run_started', { origin: 'runtime' }),
101
99
  createAgentEvent('activity_upserted', {
@@ -112,28 +110,34 @@ test('reduceAgentEvents: run activity attaches to execution step of default plan
112
110
  }),
113
111
  ]);
114
112
 
115
- assert.equal(projection.plan[0].status, 'done');
116
- assert.equal(projection.plan[1].status, 'running');
117
- assert.equal(projection.plan[1].activityKey, 'production:build-1');
118
- assert.equal(projection.plan[2].status, 'pending');
113
+ assert.equal(projection.plan.length, 1);
114
+ assert.equal(projection.plan[0].description, 'Production build');
115
+ assert.equal(projection.plan[0].status, 'running');
116
+ assert.equal(projection.plan[0].owner, 'activity');
119
117
  });
120
118
 
121
119
  test('reduceAgentEvents: run_done finalizes running and pending plan steps', () => {
122
120
  const projection = reduceAgentEvents([
123
- createAgentEvent('run_started', { origin: 'runtime' }),
121
+ createAgentEvent('plan_set', {
122
+ origin: 'tool',
123
+ payload: { steps: ['Analyze', 'Execute'] },
124
+ }),
124
125
  createAgentEvent('run_done', { origin: 'runtime' }),
125
126
  ]);
126
127
 
127
- assert.deepEqual(projection.plan.map((step) => step.status), ['done', 'done', 'done']);
128
+ assert.deepEqual(projection.plan.map((step) => step.status), ['done', 'done']);
128
129
  assert.equal(projection.status, 'done');
129
130
  });
130
131
 
131
132
  test('dispatchAgentEvent: run_done finalizes session plan', () => {
132
133
  const session = {};
133
- dispatchAgentEvent(session, createAgentEvent('run_started', { origin: 'runtime' }));
134
+ dispatchAgentEvent(session, createAgentEvent('plan_set', {
135
+ origin: 'tool',
136
+ payload: { steps: ['Analyze', 'Execute'] },
137
+ }));
134
138
  dispatchAgentEvent(session, createAgentEvent('run_done', { origin: 'runtime' }));
135
139
 
136
- assert.deepEqual(session.headlessPlan.map((step) => step.status), ['done', 'done', 'done']);
140
+ assert.deepEqual(session.headlessPlan.map((step) => step.status), ['done', 'done']);
137
141
  assert.equal(session.agentProjection.status, 'done');
138
142
  });
139
143
 
@@ -304,3 +308,41 @@ test('dispatchAgentEvent: assistant deltas do not rewrite session plan or activi
304
308
  assert.equal(planUpdates, updatesAfterActivity);
305
309
  assert.equal(session.agentProjection.conversation.at(-1).content, 'bonjour');
306
310
  });
311
+
312
+ test('reduceAgentEvents: plan patches are proposed, approved and applied with revisions', () => {
313
+ // plan_set bumps planRevision (it wholesale-replaces the plan), so a patch
314
+ // proposed after it must target that new revision (1), not 0.
315
+ const patch = {
316
+ targetRunId: 'run-patch',
317
+ basePlanRevision: 1,
318
+ operations: [{ op: 'add_task', task: { id: 'task-b', description: 'B', dependsOn: ['task-a'] } }],
319
+ };
320
+ const projection = reduceAgentEvents([
321
+ createAgentEvent('run_started', { origin: 'runtime', runId: 'run-patch' }),
322
+ createAgentEvent('plan_set', {
323
+ origin: 'tool',
324
+ runId: 'run-patch',
325
+ payload: { steps: [{ id: 'task-a', description: 'A', status: 'done' }] },
326
+ }),
327
+ createAgentEvent('plan_patch_proposed', {
328
+ origin: 'runtime',
329
+ runId: 'run-patch',
330
+ payload: { id: 'patch-1', patch },
331
+ }),
332
+ createAgentEvent('plan_patch_approved', {
333
+ origin: 'runtime',
334
+ runId: 'run-patch',
335
+ payload: { patchId: 'patch-1' },
336
+ }),
337
+ createAgentEvent('plan_patch_applied', {
338
+ origin: 'runtime',
339
+ runId: 'run-patch',
340
+ payload: { patchId: 'patch-1', patch },
341
+ }),
342
+ ]);
343
+
344
+ assert.equal(projection.planRevision, 2);
345
+ assert.deepEqual(projection.plan.map((step) => step.id), ['task-a', 'task-b']);
346
+ assert.equal(projection.plan[1].status, 'pending');
347
+ assert.equal(projection.planPatches[0].status, 'applied');
348
+ });