@stigmer/react 3.6.0 → 3.7.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.
Files changed (121) hide show
  1. package/billing/CreditLedgerTable.d.ts.map +1 -1
  2. package/billing/CreditLedgerTable.js +2 -7
  3. package/billing/CreditLedgerTable.js.map +1 -1
  4. package/composer/ComposerToolbar.d.ts +6 -1
  5. package/composer/ComposerToolbar.d.ts.map +1 -1
  6. package/composer/ComposerToolbar.js +2 -2
  7. package/composer/ComposerToolbar.js.map +1 -1
  8. package/composer/SessionComposer.d.ts +9 -0
  9. package/composer/SessionComposer.d.ts.map +1 -1
  10. package/composer/SessionComposer.js +13 -2
  11. package/composer/SessionComposer.js.map +1 -1
  12. package/execution/useCreateAgentExecution.d.ts +14 -0
  13. package/execution/useCreateAgentExecution.d.ts.map +1 -1
  14. package/execution/useCreateAgentExecution.js +5 -0
  15. package/execution/useCreateAgentExecution.js.map +1 -1
  16. package/index.d.ts +2 -2
  17. package/index.d.ts.map +1 -1
  18. package/index.js +6 -5
  19. package/index.js.map +1 -1
  20. package/internal/Pagination.d.ts +15 -0
  21. package/internal/Pagination.d.ts.map +1 -0
  22. package/internal/Pagination.js +20 -0
  23. package/internal/Pagination.js.map +1 -0
  24. package/models/ModelSelector.d.ts +26 -1
  25. package/models/ModelSelector.d.ts.map +1 -1
  26. package/models/ModelSelector.js +27 -6
  27. package/models/ModelSelector.js.map +1 -1
  28. package/models/index.d.ts +2 -0
  29. package/models/index.d.ts.map +1 -1
  30. package/models/index.js +1 -0
  31. package/models/index.js.map +1 -1
  32. package/models/registry.d.ts +10 -0
  33. package/models/registry.d.ts.map +1 -1
  34. package/models/registry.js +3 -0
  35. package/models/registry.js.map +1 -1
  36. package/models/service-tier.d.ts +27 -0
  37. package/models/service-tier.d.ts.map +1 -0
  38. package/models/service-tier.js +34 -0
  39. package/models/service-tier.js.map +1 -0
  40. package/package.json +4 -4
  41. package/schedule/ScheduleDetailView.d.ts +31 -8
  42. package/schedule/ScheduleDetailView.d.ts.map +1 -1
  43. package/schedule/ScheduleDetailView.js +441 -29
  44. package/schedule/ScheduleDetailView.js.map +1 -1
  45. package/schedule/ScheduleForm.d.ts +7 -4
  46. package/schedule/ScheduleForm.d.ts.map +1 -1
  47. package/schedule/ScheduleForm.js +93 -8
  48. package/schedule/ScheduleForm.js.map +1 -1
  49. package/schedule/ScheduleRunsTable.d.ts +72 -0
  50. package/schedule/ScheduleRunsTable.d.ts.map +1 -0
  51. package/schedule/ScheduleRunsTable.js +161 -0
  52. package/schedule/ScheduleRunsTable.js.map +1 -0
  53. package/schedule/index.d.ts +6 -0
  54. package/schedule/index.d.ts.map +1 -1
  55. package/schedule/index.js +3 -0
  56. package/schedule/index.js.map +1 -1
  57. package/schedule/useScheduleRuns.d.ts +44 -0
  58. package/schedule/useScheduleRuns.d.ts.map +1 -0
  59. package/schedule/useScheduleRuns.js +48 -0
  60. package/schedule/useScheduleRuns.js.map +1 -0
  61. package/schedule/useTriggerSchedule.d.ts +23 -10
  62. package/schedule/useTriggerSchedule.d.ts.map +1 -1
  63. package/schedule/useTriggerSchedule.js +28 -9
  64. package/schedule/useTriggerSchedule.js.map +1 -1
  65. package/schedule/useUpdateScheduleSpec.d.ts +49 -0
  66. package/schedule/useUpdateScheduleSpec.d.ts.map +1 -0
  67. package/schedule/useUpdateScheduleSpec.js +70 -0
  68. package/schedule/useUpdateScheduleSpec.js.map +1 -0
  69. package/session/useNewSessionFlow.d.ts.map +1 -1
  70. package/session/useNewSessionFlow.js +1 -0
  71. package/session/useNewSessionFlow.js.map +1 -1
  72. package/session/useSessionConversation.d.ts +11 -0
  73. package/session/useSessionConversation.d.ts.map +1 -1
  74. package/session/useSessionConversation.js +1 -0
  75. package/session/useSessionConversation.js.map +1 -1
  76. package/session/useSessionPageFlow.d.ts.map +1 -1
  77. package/session/useSessionPageFlow.js +1 -0
  78. package/session/useSessionPageFlow.js.map +1 -1
  79. package/src/billing/CreditLedgerTable.tsx +3 -42
  80. package/src/composer/ComposerToolbar.tsx +9 -0
  81. package/src/composer/SessionComposer.tsx +24 -1
  82. package/src/composer/__tests__/SessionComposer-serviceTier.test.tsx +156 -0
  83. package/src/execution/useCreateAgentExecution.ts +18 -0
  84. package/src/index.ts +9 -4
  85. package/src/internal/Pagination.tsx +72 -0
  86. package/src/models/ModelSelector.tsx +100 -3
  87. package/src/models/__tests__/ModelSelector-serviceTier.test.tsx +172 -0
  88. package/src/models/__tests__/useModelRegistry.test.tsx +15 -0
  89. package/src/models/index.ts +2 -0
  90. package/src/models/registry.ts +16 -0
  91. package/src/models/service-tier.ts +49 -0
  92. package/src/schedule/ScheduleDetailView.tsx +1179 -111
  93. package/src/schedule/ScheduleForm.tsx +217 -7
  94. package/src/schedule/ScheduleRunsTable.tsx +424 -0
  95. package/src/schedule/__tests__/ScheduleDetailView.test.tsx +431 -8
  96. package/src/schedule/__tests__/scheduleCreation.test.tsx +197 -0
  97. package/src/schedule/__tests__/scheduleHooks.test.tsx +94 -7
  98. package/src/schedule/index.ts +12 -0
  99. package/src/schedule/useScheduleRuns.ts +93 -0
  100. package/src/schedule/useTriggerSchedule.ts +39 -14
  101. package/src/schedule/useUpdateScheduleSpec.ts +108 -0
  102. package/src/session/useNewSessionFlow.ts +1 -0
  103. package/src/session/useSessionConversation.ts +12 -0
  104. package/src/session/useSessionPageFlow.ts +1 -0
  105. package/src/workflow/__tests__/inspector-forms.test.tsx +119 -2
  106. package/src/workflow/inspector/forms/AgentCallForm.tsx +260 -37
  107. package/src/workflow/inspector/tabs/RuntimeTab.tsx +72 -33
  108. package/src/workflow/starter-workflow-yaml.ts +2 -1
  109. package/styles.css +1 -1
  110. package/workflow/inspector/forms/AgentCallForm.d.ts +18 -2
  111. package/workflow/inspector/forms/AgentCallForm.d.ts.map +1 -1
  112. package/workflow/inspector/forms/AgentCallForm.js +87 -9
  113. package/workflow/inspector/forms/AgentCallForm.js.map +1 -1
  114. package/workflow/inspector/tabs/RuntimeTab.d.ts +2 -1
  115. package/workflow/inspector/tabs/RuntimeTab.d.ts.map +1 -1
  116. package/workflow/inspector/tabs/RuntimeTab.js +31 -5
  117. package/workflow/inspector/tabs/RuntimeTab.js.map +1 -1
  118. package/workflow/starter-workflow-yaml.d.ts +1 -1
  119. package/workflow/starter-workflow-yaml.d.ts.map +1 -1
  120. package/workflow/starter-workflow-yaml.js +2 -1
  121. package/workflow/starter-workflow-yaml.js.map +1 -1
@@ -4,7 +4,15 @@ import type { ReactNode } from "react";
4
4
  import { clone, create } from "@bufbuild/protobuf";
5
5
  import { timestampFromDate } from "@bufbuild/protobuf/wkt";
6
6
  import { ScheduleSchema, type Schedule } from "@stigmer/protos/ai/stigmer/agentic/schedule/v1/api_pb";
7
+ import {
8
+ ScheduleTriggerResultSchema,
9
+ ScheduleRunListSchema,
10
+ ScheduleRunSchema,
11
+ ScheduleRunOutcome,
12
+ ScheduleRunOrigin,
13
+ } from "@stigmer/protos/ai/stigmer/agentic/schedule/v1/io_pb";
7
14
  import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
15
+ import { Harness } from "@stigmer/protos/ai/stigmer/agentic/session/v1/enum_pb";
8
16
  import { StigmerContext } from "../../context";
9
17
  import { FetchCacheContext } from "../../internal/FetchCacheProvider";
10
18
  import { ScheduleDetailView } from "../ScheduleDetailView";
@@ -32,6 +40,15 @@ function makeSchedule(overrides?: {
32
40
  enabled?: boolean;
33
41
  pausedReason?: string;
34
42
  lastExecutionId?: string;
43
+ cron?: string;
44
+ consecutiveFailures?: number;
45
+ environmentRefs?: readonly { org: string; slug: string }[];
46
+ harness?: Harness;
47
+ runConfig?: {
48
+ modelName?: string;
49
+ maxCostUsd?: number;
50
+ maxToolRounds?: number;
51
+ };
35
52
  }): Schedule {
36
53
  return create(ScheduleSchema, {
37
54
  apiVersion: "agentic.stigmer.ai/v1",
@@ -43,7 +60,7 @@ function makeSchedule(overrides?: {
43
60
  org: "isc",
44
61
  },
45
62
  spec: {
46
- cron: "0 9 * * *",
63
+ cron: overrides?.cron ?? "0 9 * * *",
47
64
  timeZone: "Asia/Kolkata",
48
65
  enabled: overrides?.enabled ?? true,
49
66
  target: {
@@ -51,6 +68,15 @@ function makeSchedule(overrides?: {
51
68
  value: {
52
69
  agentRef: { kind: ApiResourceKind.agent, org: "isc", slug: "fee-reminder" },
53
70
  message: "Send today's fee reminders.",
71
+ environmentRefs: (overrides?.environmentRefs ?? []).map((r) => ({
72
+ kind: ApiResourceKind.environment,
73
+ org: r.org,
74
+ slug: r.slug,
75
+ })),
76
+ ...(overrides?.harness !== undefined
77
+ ? { harness: overrides.harness }
78
+ : {}),
79
+ ...(overrides?.runConfig ? { runConfig: overrides.runConfig } : {}),
54
80
  },
55
81
  },
56
82
  },
@@ -58,7 +84,8 @@ function makeSchedule(overrides?: {
58
84
  nextFireAt: timestampFromDate(new Date(NOW.getTime() + 3 * 3_600_000)),
59
85
  lastFireAt: timestampFromDate(new Date(NOW.getTime() - 5 * 60_000)),
60
86
  lastExecutionId: overrides?.lastExecutionId ?? "aex_01run",
61
- consecutiveFailures: overrides?.pausedReason ? 5 : 0,
87
+ consecutiveFailures:
88
+ overrides?.consecutiveFailures ?? (overrides?.pausedReason ? 5 : 0),
62
89
  pausedReason: overrides?.pausedReason ?? "",
63
90
  },
64
91
  });
@@ -69,9 +96,12 @@ interface MockClient {
69
96
  getByReference: ReturnType<typeof vi.fn>;
70
97
  resume: ReturnType<typeof vi.fn>;
71
98
  trigger: ReturnType<typeof vi.fn>;
99
+ listRuns: ReturnType<typeof vi.fn>;
72
100
  delete: ReturnType<typeof vi.fn>;
73
101
  };
74
102
  manifest: { apply: ReturnType<typeof vi.fn> };
103
+ // The environments inline editor's picker lists the org's environments.
104
+ environment: { list: ReturnType<typeof vi.fn> };
75
105
  }
76
106
 
77
107
  function makeClient(schedule: Schedule): MockClient {
@@ -79,7 +109,18 @@ function makeClient(schedule: Schedule): MockClient {
79
109
  schedule: {
80
110
  getByReference: vi.fn().mockResolvedValue(schedule),
81
111
  resume: vi.fn().mockResolvedValue(schedule),
82
- trigger: vi.fn().mockResolvedValue(schedule),
112
+ // The synchronous trigger answers with the run's real outcome
113
+ // (DD-017 D-6) — a started run by default.
114
+ trigger: vi.fn().mockResolvedValue(
115
+ create(ScheduleTriggerResultSchema, {
116
+ outcome: ScheduleRunOutcome.STARTED,
117
+ executionId: "aex_01triggered",
118
+ schedule,
119
+ }),
120
+ ),
121
+ listRuns: vi.fn().mockResolvedValue(
122
+ create(ScheduleRunListSchema, { items: [], totalCount: 0 }),
123
+ ),
83
124
  delete: vi.fn().mockResolvedValue(schedule),
84
125
  },
85
126
  manifest: {
@@ -93,6 +134,9 @@ function makeClient(schedule: Schedule): MockClient {
93
134
  message: doc.message,
94
135
  })),
95
136
  },
137
+ environment: {
138
+ list: vi.fn().mockResolvedValue({ items: [], totalCount: 0 }),
139
+ },
96
140
  };
97
141
  }
98
142
 
@@ -125,8 +169,10 @@ describe("ScheduleDetailView", () => {
125
169
  renderView(makeClient(makeSchedule()));
126
170
 
127
171
  await screen.findByRole("heading", { name: "daily-fee-reminders" });
172
+ // The cadence humanizes into plain English (the stored time zone is
173
+ // part of the sentence); the raw cron stays as the precise record.
174
+ expect(screen.getByText("Every day at 09:00 (Asia/Kolkata)")).toBeTruthy();
128
175
  expect(screen.getByText("0 9 * * *")).toBeTruthy();
129
- expect(screen.getByText("Asia/Kolkata")).toBeTruthy();
130
176
  expect(screen.getByText("isc/fee-reminder")).toBeTruthy();
131
177
  expect(screen.getByText("Send today's fee reminders.")).toBeTruthy();
132
178
  expect(screen.getByText("in 3h")).toBeTruthy();
@@ -203,12 +249,68 @@ describe("ScheduleDetailView", () => {
203
249
  );
204
250
  });
205
251
 
206
- it("disables Run now when the schedule cannot fire", async () => {
207
- renderView(makeClient(makeSchedule({ enabled: false })));
252
+ it("offers 'Enable & run now' on a disabled schedule (DD-017 D-5)", async () => {
253
+ const client = makeClient(makeSchedule({ enabled: false }));
254
+ renderView(client);
208
255
 
209
256
  await screen.findByText("Schedule is disabled");
210
- const runButton = screen.getByRole("button", { name: "Run now" });
211
- expect((runButton as HTMLButtonElement).disabled).toBe(true);
257
+ // No plain "Run now": a disabled schedule cannot fire server-side, so
258
+ // the staged-test flow is one click that enables THEN fires.
259
+ expect(screen.queryByRole("button", { name: "Run now" })).toBeNull();
260
+ const enableAndRun = screen.getByRole("button", { name: "Enable & run now" });
261
+ expect((enableAndRun as HTMLButtonElement).disabled).toBe(false);
262
+
263
+ fireEvent.click(enableAndRun);
264
+ await screen.findByText("Enable and run this schedule now?");
265
+ fireEvent.click(screen.getByText("Enable & run"));
266
+
267
+ // Enable (a full-proto re-apply) THEN fire.
268
+ await waitFor(() => expect(client.manifest.apply).toHaveBeenCalledOnce());
269
+ const doc = client.manifest.apply.mock.calls[0][0] as { message: Schedule };
270
+ expect(doc.message.spec?.enabled).toBe(true);
271
+ await waitFor(() =>
272
+ expect(client.schedule.trigger).toHaveBeenCalledWith("sch_01example"),
273
+ );
274
+ });
275
+
276
+ it("navigates to the execution on a started run", async () => {
277
+ const onNavigateToExecution = vi.fn();
278
+ const client = makeClient(makeSchedule());
279
+ renderView(client, { onNavigateToExecution });
280
+
281
+ await screen.findByRole("heading", { name: "daily-fee-reminders" });
282
+ fireEvent.click(screen.getByRole("button", { name: "Run now" }));
283
+ await screen.findByText("Run this schedule now?");
284
+ fireEvent.click(screen.getByText("Start run"));
285
+
286
+ await waitFor(() =>
287
+ expect(onNavigateToExecution).toHaveBeenCalledWith("aex_01triggered"),
288
+ );
289
+ });
290
+
291
+ it("renders the run history from the fire ledger", async () => {
292
+ const client = makeClient(makeSchedule());
293
+ client.schedule.listRuns.mockResolvedValue(
294
+ create(ScheduleRunListSchema, {
295
+ totalCount: 1,
296
+ items: [
297
+ create(ScheduleRunSchema, {
298
+ scheduleId: "sch_01example",
299
+ origin: ScheduleRunOrigin.CRON,
300
+ outcome: ScheduleRunOutcome.REFUSED,
301
+ reason:
302
+ "run refused: MCP server 'isc-gym' requires environment variable 'ISC_MCP_SHARED_SECRET'",
303
+ nominalFireTime: timestampFromDate(new Date(NOW.getTime() - 60_000)),
304
+ }),
305
+ ],
306
+ }),
307
+ );
308
+ renderView(client);
309
+
310
+ await screen.findByRole("heading", { name: "daily-fee-reminders" });
311
+ // The refusal reason — invisible before the ledger — is now on screen.
312
+ await screen.findByText(/ISC_MCP_SHARED_SECRET/);
313
+ expect(screen.getByText("Refused")).toBeTruthy();
212
314
  });
213
315
 
214
316
  it("navigates through the callback seams (DD-004)", async () => {
@@ -257,4 +359,325 @@ describe("ScheduleDetailView", () => {
257
359
 
258
360
  expect(schedule.spec?.enabled).toBe(original.spec?.enabled);
259
361
  });
362
+
363
+ // -------------------------------------------------------------------------
364
+ // Cadence humanization
365
+ // -------------------------------------------------------------------------
366
+
367
+ it("falls back to the raw cron for expressions outside the builder's shapes", async () => {
368
+ renderView(makeClient(makeSchedule({ cron: "*/5 9-17 * * 1-5" })));
369
+
370
+ await screen.findByRole("heading", { name: "daily-fee-reminders" });
371
+ // Unrecognized shape: the raw expression IS the primary display —
372
+ // no invented sentence — with the time zone shown alongside.
373
+ expect(screen.getByText("*/5 9-17 * * 1-5")).toBeTruthy();
374
+ expect(screen.getByText("Asia/Kolkata")).toBeTruthy();
375
+ expect(screen.queryByText(/^Every /)).toBeNull();
376
+ });
377
+
378
+ // -------------------------------------------------------------------------
379
+ // Failure streak
380
+ // -------------------------------------------------------------------------
381
+
382
+ it("renders a plain 0 for a healthy failure streak", async () => {
383
+ renderView(makeClient(makeSchedule()));
384
+
385
+ await screen.findByRole("heading", { name: "daily-fee-reminders" });
386
+ expect(screen.getByText("Failure streak")).toBeTruthy();
387
+ expect(screen.getByText("0")).toBeTruthy();
388
+ expect(screen.queryByText(/consecutive failed/)).toBeNull();
389
+ });
390
+
391
+ it("explains a non-zero failure streak in plain language", async () => {
392
+ renderView(makeClient(makeSchedule({ consecutiveFailures: 2 })));
393
+
394
+ await screen.findByRole("heading", { name: "daily-fee-reminders" });
395
+ expect(screen.getByText("2 consecutive failed runs")).toBeTruthy();
396
+ // The copy stays qualitative: the pause threshold is server config
397
+ // the API does not expose, so no hardcoded denominator.
398
+ expect(
399
+ screen.getByText(/pauses the schedule automatically/),
400
+ ).toBeTruthy();
401
+ expect(screen.getByText(/Manual runs never count/)).toBeTruthy();
402
+ });
403
+
404
+ // -------------------------------------------------------------------------
405
+ // Tabs — Overview and the paginated Runs tab
406
+ // -------------------------------------------------------------------------
407
+
408
+ it("splits into Overview and Runs tabs, with the run count as badge", async () => {
409
+ const client = makeClient(makeSchedule());
410
+ client.schedule.listRuns.mockResolvedValue(
411
+ create(ScheduleRunListSchema, {
412
+ totalCount: 12,
413
+ items: [
414
+ create(ScheduleRunSchema, {
415
+ scheduleId: "sch_01example",
416
+ origin: ScheduleRunOrigin.CRON,
417
+ outcome: ScheduleRunOutcome.COMPLETED,
418
+ nominalFireTime: timestampFromDate(new Date(NOW.getTime() - 60_000)),
419
+ }),
420
+ ],
421
+ }),
422
+ );
423
+ renderView(client);
424
+
425
+ await screen.findByRole("heading", { name: "daily-fee-reminders" });
426
+ // The badge fills in once the (separate) runs fetch resolves.
427
+ await waitFor(() => {
428
+ const tabs = screen.getAllByRole("tab");
429
+ expect(tabs.map((t) => t.textContent)).toEqual(["Overview", "Runs12"]);
430
+ });
431
+ // Overview is the default: definition on screen, no full table.
432
+ expect(screen.getByText("Target agent")).toBeTruthy();
433
+
434
+ fireEvent.click(screen.getByRole("tab", { name: /Runs/ }));
435
+ await screen.findByRole("table", { name: "Run history" });
436
+ expect(screen.queryByText("Target agent")).toBeNull();
437
+ });
438
+
439
+ it("pages through the run history (the hook's pagination, finally used)", async () => {
440
+ const client = makeClient(makeSchedule());
441
+ client.schedule.listRuns.mockImplementation(
442
+ async (req: { pageInfo?: { num: number; size: number } }) =>
443
+ create(ScheduleRunListSchema, {
444
+ totalCount: 30,
445
+ items: Array.from(
446
+ { length: Math.min(req.pageInfo?.size ?? 25, 30) },
447
+ (_, i) =>
448
+ create(ScheduleRunSchema, {
449
+ scheduleId: "sch_01example",
450
+ origin: ScheduleRunOrigin.CRON,
451
+ outcome: ScheduleRunOutcome.COMPLETED,
452
+ nominalFireTime: timestampFromDate(
453
+ new Date(NOW.getTime() - (i + 1) * 60_000),
454
+ ),
455
+ }),
456
+ ),
457
+ }),
458
+ );
459
+ renderView(client);
460
+
461
+ await screen.findByRole("heading", { name: "daily-fee-reminders" });
462
+ fireEvent.click(screen.getByRole("tab", { name: /Runs/ }));
463
+ await screen.findByText("Page 1 of 2");
464
+
465
+ fireEvent.click(screen.getByRole("button", { name: "Next" }));
466
+ await waitFor(() => {
467
+ const pageTwoCall = client.schedule.listRuns.mock.calls.find((call) => {
468
+ const req = call[0] as { pageInfo?: { num: number; size: number } };
469
+ return req.pageInfo?.num === 2 && req.pageInfo?.size === 25;
470
+ });
471
+ expect(pageTwoCall).toBeTruthy();
472
+ });
473
+ await screen.findByText("Page 2 of 2");
474
+ });
475
+
476
+ it("links the Overview recent-runs strip to the Runs tab", async () => {
477
+ const client = makeClient(makeSchedule());
478
+ client.schedule.listRuns.mockImplementation(
479
+ async (req: { pageInfo?: { num: number; size: number } }) =>
480
+ create(ScheduleRunListSchema, {
481
+ totalCount: 12,
482
+ items: Array.from(
483
+ { length: Math.min(req.pageInfo?.size ?? 25, 12) },
484
+ (_, i) =>
485
+ create(ScheduleRunSchema, {
486
+ scheduleId: "sch_01example",
487
+ origin: ScheduleRunOrigin.CRON,
488
+ outcome: ScheduleRunOutcome.COMPLETED,
489
+ nominalFireTime: timestampFromDate(
490
+ new Date(NOW.getTime() - (i + 1) * 60_000),
491
+ ),
492
+ }),
493
+ ),
494
+ }),
495
+ );
496
+ renderView(client);
497
+
498
+ await screen.findByRole("heading", { name: "daily-fee-reminders" });
499
+ fireEvent.click(
500
+ await screen.findByRole("button", { name: "View all 12 runs" }),
501
+ );
502
+ await screen.findByRole("table", { name: "Run history" });
503
+ });
504
+
505
+ // -------------------------------------------------------------------------
506
+ // Inline editing — every save re-applies the FULL proto (never a
507
+ // down-converted input, which would silently wipe fields)
508
+ // -------------------------------------------------------------------------
509
+
510
+ it("does not offer inline editing without the editable prop", async () => {
511
+ renderView(makeClient(makeSchedule()));
512
+
513
+ await screen.findByRole("heading", { name: "daily-fee-reminders" });
514
+ fireEvent.click(screen.getByText("Send today's fee reminders."));
515
+ expect(screen.queryByRole("textbox")).toBeNull();
516
+ expect(screen.queryByRole("button", { name: "Edit cadence" })).toBeNull();
517
+ });
518
+
519
+ it("never offers an edit affordance for the immutable target agent", async () => {
520
+ renderView(makeClient(makeSchedule()), { editable: true });
521
+
522
+ await screen.findByRole("heading", { name: "daily-fee-reminders" });
523
+ // The mutable fields have editors; the server-immutable target does not.
524
+ expect(screen.getByRole("button", { name: "Edit cadence" })).toBeTruthy();
525
+ expect(
526
+ screen.queryByRole("button", { name: /edit target/i }),
527
+ ).toBeNull();
528
+ });
529
+
530
+ it("saves an edited message by re-applying the full proto", async () => {
531
+ const client = makeClient(makeSchedule());
532
+ renderView(client, { editable: true });
533
+
534
+ await screen.findByRole("heading", { name: "daily-fee-reminders" });
535
+ fireEvent.click(screen.getByText("Send today's fee reminders."));
536
+ const textarea = await screen.findByRole("textbox");
537
+ fireEvent.change(textarea, {
538
+ target: { value: "Send this week's fee reminders." },
539
+ });
540
+ fireEvent.click(screen.getByRole("button", { name: "Save" }));
541
+
542
+ await waitFor(() => expect(client.manifest.apply).toHaveBeenCalledOnce());
543
+ const doc = client.manifest.apply.mock.calls[0][0] as { message: Schedule };
544
+ const target =
545
+ doc.message.spec?.target?.case === "agent"
546
+ ? doc.message.spec.target.value
547
+ : undefined;
548
+ expect(target?.message).toBe("Send this week's fee reminders.");
549
+ // Full-proto write: everything the editor did not touch survives.
550
+ expect(doc.message.metadata?.id).toBe("sch_01example");
551
+ expect(doc.message.spec?.cron).toBe("0 9 * * *");
552
+ expect(doc.message.spec?.enabled).toBe(true);
553
+ expect(target?.agentRef?.slug).toBe("fee-reminder");
554
+ });
555
+
556
+ it("saves an edited cadence through the custom-cron escape hatch", async () => {
557
+ const client = makeClient(makeSchedule());
558
+ renderView(client, { editable: true });
559
+
560
+ await screen.findByRole("heading", { name: "daily-fee-reminders" });
561
+ fireEvent.click(screen.getByRole("button", { name: "Edit cadence" }));
562
+ // The stored cron round-trips into the preset picker; Custom
563
+ // prefills from it rather than starting blank.
564
+ fireEvent.click(screen.getByRole("radio", { name: "Custom cron" }));
565
+ fireEvent.change(screen.getByLabelText("Cron expression"), {
566
+ target: { value: "0 9 * * 1-5" },
567
+ });
568
+ fireEvent.click(screen.getByRole("button", { name: "Save" }));
569
+
570
+ await waitFor(() => expect(client.manifest.apply).toHaveBeenCalledOnce());
571
+ const doc = client.manifest.apply.mock.calls[0][0] as { message: Schedule };
572
+ expect(doc.message.spec?.cron).toBe("0 9 * * 1-5");
573
+ // The time zone rides along with the cadence editor, unchanged here.
574
+ expect(doc.message.spec?.timeZone).toBe("Asia/Kolkata");
575
+ });
576
+
577
+ it("saves an edited budget, preserving the model and the API-only tool rounds", async () => {
578
+ const client = makeClient(
579
+ makeSchedule({
580
+ harness: Harness.CURSOR,
581
+ runConfig: { modelName: "composer-2", maxToolRounds: 15 },
582
+ }),
583
+ );
584
+ renderView(client, { editable: true });
585
+
586
+ await screen.findByRole("heading", { name: "daily-fee-reminders" });
587
+ fireEvent.click(screen.getByRole("button", { name: "Edit budget" }));
588
+ fireEvent.change(screen.getByLabelText("Budget per run (USD)"), {
589
+ target: { value: "2.5" },
590
+ });
591
+ fireEvent.click(screen.getByRole("button", { name: "Save" }));
592
+
593
+ await waitFor(() => expect(client.manifest.apply).toHaveBeenCalledOnce());
594
+ const doc = client.manifest.apply.mock.calls[0][0] as { message: Schedule };
595
+ const target =
596
+ doc.message.spec?.target?.case === "agent"
597
+ ? doc.message.spec.target.value
598
+ : undefined;
599
+ expect(target?.runConfig?.maxCostUsd).toBe(2.5);
600
+ // The budget editor owns exactly one field — everything else the
601
+ // run config stores survives the save.
602
+ expect(target?.runConfig?.modelName).toBe("composer-2");
603
+ expect(target?.runConfig?.maxToolRounds).toBe(15);
604
+ expect(target?.harness).toBe(Harness.CURSOR);
605
+ });
606
+
607
+ it("resetting engine & model unpins both, preserving the budget", async () => {
608
+ const client = makeClient(
609
+ makeSchedule({
610
+ harness: Harness.CURSOR,
611
+ runConfig: { modelName: "composer-2", maxCostUsd: 2.5 },
612
+ }),
613
+ );
614
+ renderView(client, { editable: true });
615
+
616
+ await screen.findByRole("heading", { name: "daily-fee-reminders" });
617
+ // The read view names the pinned engine and model.
618
+ expect(screen.getByText("Cursor · composer-2")).toBeTruthy();
619
+
620
+ fireEvent.click(
621
+ screen.getByRole("button", { name: "Edit engine and model" }),
622
+ );
623
+ fireEvent.click(
624
+ screen.getByRole("button", { name: "Reset to platform default" }),
625
+ );
626
+ fireEvent.click(screen.getByRole("button", { name: "Save" }));
627
+
628
+ await waitFor(() => expect(client.manifest.apply).toHaveBeenCalledOnce());
629
+ const doc = client.manifest.apply.mock.calls[0][0] as { message: Schedule };
630
+ const target =
631
+ doc.message.spec?.target?.case === "agent"
632
+ ? doc.message.spec.target.value
633
+ : undefined;
634
+ // Model and harness are one atomic choice: unpinning the model
635
+ // unpins the engine with it (the platform defaults apply again)...
636
+ expect(target?.harness).toBe(Harness.UNSPECIFIED);
637
+ expect(target?.runConfig?.modelName).toBe("");
638
+ // ...while the budget the editor does not own survives.
639
+ expect(target?.runConfig?.maxCostUsd).toBe(2.5);
640
+ });
641
+
642
+ it("round-trips environment references with the environment kind stamped", async () => {
643
+ const client = makeClient(
644
+ makeSchedule({
645
+ environmentRefs: [{ org: "isc", slug: "isc-mcp-credentials" }],
646
+ }),
647
+ );
648
+ renderView(client, { editable: true });
649
+
650
+ await screen.findByRole("heading", { name: "daily-fee-reminders" });
651
+ fireEvent.click(screen.getByRole("button", { name: "Edit environments" }));
652
+ fireEvent.click(screen.getByRole("button", { name: "Save" }));
653
+
654
+ await waitFor(() => expect(client.manifest.apply).toHaveBeenCalledOnce());
655
+ const doc = client.manifest.apply.mock.calls[0][0] as { message: Schedule };
656
+ const target =
657
+ doc.message.spec?.target?.case === "agent"
658
+ ? doc.message.spec.target.value
659
+ : undefined;
660
+ expect(target?.environmentRefs).toHaveLength(1);
661
+ expect(target?.environmentRefs?.[0].slug).toBe("isc-mcp-credentials");
662
+ expect(target?.environmentRefs?.[0].kind).toBe(
663
+ ApiResourceKind.environment,
664
+ );
665
+ });
666
+
667
+ it("keeps the editor open with the server's message on a failed save", async () => {
668
+ const client = makeClient(makeSchedule());
669
+ client.manifest.apply.mockRejectedValueOnce(
670
+ new Error("spec.cron must have exactly 5 fields"),
671
+ );
672
+ renderView(client, { editable: true });
673
+
674
+ await screen.findByRole("heading", { name: "daily-fee-reminders" });
675
+ fireEvent.click(screen.getByRole("button", { name: "Edit cadence" }));
676
+ fireEvent.click(screen.getByRole("button", { name: "Save" }));
677
+
678
+ // The server's message lands next to the field; the editor stays
679
+ // open so the draft is not lost (DD-006).
680
+ await screen.findByText(/must have exactly 5 fields/);
681
+ expect(screen.getByRole("button", { name: "Save" })).toBeTruthy();
682
+ });
260
683
  });