@stigmer/react 0.0.42 → 0.0.44

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 (91) hide show
  1. package/execution/ArtifactContentRenderer.d.ts +58 -0
  2. package/execution/ArtifactContentRenderer.d.ts.map +1 -0
  3. package/execution/ArtifactContentRenderer.js +163 -0
  4. package/execution/ArtifactContentRenderer.js.map +1 -0
  5. package/execution/ArtifactPreviewModal.d.ts +3 -2
  6. package/execution/ArtifactPreviewModal.d.ts.map +1 -1
  7. package/execution/ArtifactPreviewModal.js +11 -62
  8. package/execution/ArtifactPreviewModal.js.map +1 -1
  9. package/execution/ExecutionPhaseBadge.js +1 -1
  10. package/execution/ExecutionPhaseBadge.js.map +1 -1
  11. package/execution/MessageThread.d.ts.map +1 -1
  12. package/execution/MessageThread.js +21 -2
  13. package/execution/MessageThread.js.map +1 -1
  14. package/execution/SetupProgress.d.ts +35 -0
  15. package/execution/SetupProgress.d.ts.map +1 -0
  16. package/execution/SetupProgress.js +65 -0
  17. package/execution/SetupProgress.js.map +1 -0
  18. package/execution/ToolCallGroup.d.ts.map +1 -1
  19. package/execution/ToolCallGroup.js +9 -1
  20. package/execution/ToolCallGroup.js.map +1 -1
  21. package/execution/ToolCallItem.js +8 -3
  22. package/execution/ToolCallItem.js.map +1 -1
  23. package/execution/artifact-utils.d.ts +50 -0
  24. package/execution/artifact-utils.d.ts.map +1 -1
  25. package/execution/artifact-utils.js +67 -5
  26. package/execution/artifact-utils.js.map +1 -1
  27. package/execution/index.d.ts +6 -1
  28. package/execution/index.d.ts.map +1 -1
  29. package/execution/index.js +3 -1
  30. package/execution/index.js.map +1 -1
  31. package/index.d.ts +4 -4
  32. package/index.d.ts.map +1 -1
  33. package/index.js +2 -2
  34. package/index.js.map +1 -1
  35. package/internal/markdown-components.d.ts +10 -0
  36. package/internal/markdown-components.d.ts.map +1 -1
  37. package/internal/markdown-components.js +13 -0
  38. package/internal/markdown-components.js.map +1 -1
  39. package/mcp-server/ApprovalPolicyGeneratorPanel.d.ts +34 -0
  40. package/mcp-server/ApprovalPolicyGeneratorPanel.d.ts.map +1 -0
  41. package/mcp-server/ApprovalPolicyGeneratorPanel.js +55 -0
  42. package/mcp-server/ApprovalPolicyGeneratorPanel.js.map +1 -0
  43. package/mcp-server/McpServerDetailView.d.ts.map +1 -1
  44. package/mcp-server/McpServerDetailView.js +101 -2
  45. package/mcp-server/McpServerDetailView.js.map +1 -1
  46. package/mcp-server/index.d.ts +8 -0
  47. package/mcp-server/index.d.ts.map +1 -1
  48. package/mcp-server/index.js +4 -0
  49. package/mcp-server/index.js.map +1 -1
  50. package/mcp-server/useDiscoverCapabilities.d.ts +59 -0
  51. package/mcp-server/useDiscoverCapabilities.d.ts.map +1 -0
  52. package/mcp-server/useDiscoverCapabilities.js +77 -0
  53. package/mcp-server/useDiscoverCapabilities.js.map +1 -0
  54. package/mcp-server/useMcpServerCredentials.d.ts +63 -0
  55. package/mcp-server/useMcpServerCredentials.d.ts.map +1 -0
  56. package/mcp-server/useMcpServerCredentials.js +64 -0
  57. package/mcp-server/useMcpServerCredentials.js.map +1 -0
  58. package/mcp-server/useTriggerApprovalPolicySession.d.ts +42 -0
  59. package/mcp-server/useTriggerApprovalPolicySession.d.ts.map +1 -0
  60. package/mcp-server/useTriggerApprovalPolicySession.js +111 -0
  61. package/mcp-server/useTriggerApprovalPolicySession.js.map +1 -0
  62. package/package.json +4 -4
  63. package/session/__tests__/useSessionConversation.test.js +223 -2
  64. package/session/__tests__/useSessionConversation.test.js.map +1 -1
  65. package/session/useSessionConversation.d.ts +8 -1
  66. package/session/useSessionConversation.d.ts.map +1 -1
  67. package/session/useSessionConversation.js +77 -6
  68. package/session/useSessionConversation.js.map +1 -1
  69. package/skill/SkillDetailView.js +2 -2
  70. package/skill/SkillDetailView.js.map +1 -1
  71. package/src/execution/ArtifactContentRenderer.tsx +376 -0
  72. package/src/execution/ArtifactPreviewModal.tsx +22 -114
  73. package/src/execution/ExecutionPhaseBadge.tsx +1 -1
  74. package/src/execution/MessageThread.tsx +35 -3
  75. package/src/execution/SetupProgress.tsx +120 -0
  76. package/src/execution/ToolCallGroup.tsx +15 -1
  77. package/src/execution/ToolCallItem.tsx +10 -3
  78. package/src/execution/artifact-utils.ts +88 -4
  79. package/src/execution/index.ts +9 -0
  80. package/src/index.ts +16 -0
  81. package/src/internal/markdown-components.tsx +15 -0
  82. package/src/mcp-server/ApprovalPolicyGeneratorPanel.tsx +164 -0
  83. package/src/mcp-server/McpServerDetailView.tsx +428 -2
  84. package/src/mcp-server/index.ts +15 -0
  85. package/src/mcp-server/useDiscoverCapabilities.ts +117 -0
  86. package/src/mcp-server/useMcpServerCredentials.ts +108 -0
  87. package/src/mcp-server/useTriggerApprovalPolicySession.ts +161 -0
  88. package/src/session/__tests__/useSessionConversation.test.tsx +355 -2
  89. package/src/session/useSessionConversation.ts +104 -9
  90. package/src/skill/SkillDetailView.tsx +2 -2
  91. package/styles.css +1 -1
@@ -0,0 +1,108 @@
1
+ "use client";
2
+
3
+ import { useCallback, useMemo } from "react";
4
+ import type { EnvVarInput } from "@stigmer/sdk";
5
+ import type { McpServer } from "@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/api_pb";
6
+ import { usePersonalEnvironment } from "../environment/usePersonalEnvironment";
7
+ import { diffEnvSpec } from "../environment/diffEnvSpec";
8
+ import type { EnvVarFormVariable } from "../environment/EnvVarForm";
9
+
10
+ export interface UseMcpServerCredentialsReturn {
11
+ /**
12
+ * Variables required by the MCP server that are missing from the
13
+ * user's personal environment. Empty when all variables are present
14
+ * or the server has no `env_spec`.
15
+ *
16
+ * Suitable as direct input to {@link EnvVarForm}.
17
+ */
18
+ readonly missingVariables: EnvVarFormVariable[];
19
+ /** `true` when all required credentials are available. */
20
+ readonly isReady: boolean;
21
+ /** `true` while the personal environment is being fetched. */
22
+ readonly isLoading: boolean;
23
+ /** Error from the personal environment fetch, or `null`. */
24
+ readonly error: Error | null;
25
+ /**
26
+ * Save the provided credentials to the user's personal environment.
27
+ * Creates the personal environment if it doesn't exist yet.
28
+ */
29
+ readonly saveCredentials: (
30
+ values: Record<string, EnvVarInput>,
31
+ ) => Promise<void>;
32
+ /** `true` while a save operation is in flight. */
33
+ readonly isSaving: boolean;
34
+ /** Re-check the personal environment. */
35
+ readonly refetch: () => void;
36
+ }
37
+
38
+ /**
39
+ * Checks the user's personal environment against an MCP server's
40
+ * `env_spec` and provides a mechanism to save missing credentials.
41
+ *
42
+ * Designed for the discovery flow on the MCP server detail page:
43
+ * before triggering discovery, the UI needs to ensure all required
44
+ * environment variables (API keys, tokens) are present. This hook
45
+ * computes the missing set and exposes `saveCredentials` to persist
46
+ * them.
47
+ *
48
+ * Unlike {@link useMcpServerSetup} which manages multi-server setup
49
+ * for session creation, this hook is scoped to a single server and
50
+ * always persists to the personal environment (no one-time option).
51
+ *
52
+ * Pass `null` for `mcpServer` while loading.
53
+ *
54
+ * @example
55
+ * ```tsx
56
+ * const { missingVariables, isReady, saveCredentials, isSaving } =
57
+ * useMcpServerCredentials("acme", mcpServer);
58
+ *
59
+ * if (!isReady) {
60
+ * return (
61
+ * <EnvVarForm
62
+ * variables={missingVariables}
63
+ * onSubmit={(values) => saveCredentials(values)}
64
+ * isSubmitting={isSaving}
65
+ * hideSaveToggle
66
+ * />
67
+ * );
68
+ * }
69
+ * ```
70
+ */
71
+ export function useMcpServerCredentials(
72
+ org: string | null,
73
+ mcpServer: McpServer | null,
74
+ ): UseMcpServerCredentialsReturn {
75
+ const personalEnv = usePersonalEnvironment(org);
76
+
77
+ const missingVariables = useMemo(() => {
78
+ if (!mcpServer) return [];
79
+ const envSpecData = mcpServer.spec?.envSpec?.data;
80
+ if (!envSpecData || Object.keys(envSpecData).length === 0) return [];
81
+
82
+ const existingKeys = new Set(
83
+ Object.keys(personalEnv.environment?.spec?.data ?? {}),
84
+ );
85
+ return diffEnvSpec(envSpecData, existingKeys);
86
+ }, [mcpServer, personalEnv.environment]);
87
+
88
+ const isReady =
89
+ !personalEnv.isLoading && missingVariables.length === 0;
90
+
91
+ const saveCredentials = useCallback(
92
+ async (values: Record<string, EnvVarInput>): Promise<void> => {
93
+ await personalEnv.getOrCreate();
94
+ await personalEnv.addVariables(values);
95
+ },
96
+ [personalEnv],
97
+ );
98
+
99
+ return {
100
+ missingVariables,
101
+ isReady,
102
+ isLoading: personalEnv.isLoading,
103
+ error: personalEnv.error,
104
+ saveCredentials,
105
+ isSaving: personalEnv.isMutating,
106
+ refetch: personalEnv.refetch,
107
+ };
108
+ }
@@ -0,0 +1,161 @@
1
+ "use client";
2
+
3
+ import { useCallback, useState } from "react";
4
+ import { create } from "@bufbuild/protobuf";
5
+ import { UploadAttachmentRequestSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/io_pb";
6
+ import { PENDING_SUBJECT } from "@stigmer/sdk";
7
+ import { useStigmer } from "../hooks";
8
+ import { toError } from "../internal/toError";
9
+
10
+ export interface TriggerApprovalPolicyResult {
11
+ readonly sessionId: string;
12
+ readonly executionId: string;
13
+ }
14
+
15
+ export interface UseTriggerApprovalPolicySessionReturn {
16
+ /**
17
+ * Create an agent session with the `mcp-server-creator` agent, attach the
18
+ * current MCP server YAML, and start an execution that generates approval
19
+ * policies for the discovered tools.
20
+ *
21
+ * @param mcpServerYaml - Serialized MCP server YAML to attach as input.
22
+ * @param org - Organization slug.
23
+ * @param mcpServerSlug - MCP server slug (for context in the prompt).
24
+ */
25
+ readonly trigger: (
26
+ mcpServerYaml: string,
27
+ org: string,
28
+ mcpServerSlug: string,
29
+ ) => Promise<TriggerApprovalPolicyResult>;
30
+ readonly isTriggering: boolean;
31
+ readonly error: Error | null;
32
+ readonly clearError: () => void;
33
+ readonly result: TriggerApprovalPolicyResult | null;
34
+ }
35
+
36
+ const MCP_SERVER_CREATOR_SLUG = "mcp-server-creator";
37
+ const MCP_SERVER_CREATOR_ORG = "stigmer";
38
+
39
+ /**
40
+ * Orchestration hook that auto-creates a session with the `mcp-server-creator`
41
+ * agent and starts an execution with a pre-filled prompt and YAML attachment.
42
+ *
43
+ * The prompt instructs the agent to read the attached YAML, query the
44
+ * discovered tools for this MCP server, classify each tool's risk level,
45
+ * and generate `default_tool_approvals` entries with appropriate approval
46
+ * messages. The agent applies the result using the `apply_mcp_server` tool.
47
+ *
48
+ * @example
49
+ * ```tsx
50
+ * const { trigger, isTriggering, result } = useTriggerApprovalPolicySession();
51
+ *
52
+ * async function handleGenerate() {
53
+ * const yaml = serializeMcpServer(mcpServer);
54
+ * await trigger(yaml, org, slug);
55
+ * // result.sessionId and result.executionId are now available
56
+ * }
57
+ * ```
58
+ */
59
+ export function useTriggerApprovalPolicySession(): UseTriggerApprovalPolicySessionReturn {
60
+ const stigmer = useStigmer();
61
+ const [isTriggering, setIsTriggering] = useState(false);
62
+ const [error, setError] = useState<Error | null>(null);
63
+ const [result, setResult] = useState<TriggerApprovalPolicyResult | null>(null);
64
+
65
+ const clearError = useCallback(() => setError(null), []);
66
+
67
+ const trigger = useCallback(
68
+ async (
69
+ mcpServerYaml: string,
70
+ org: string,
71
+ mcpServerSlug: string,
72
+ ): Promise<TriggerApprovalPolicyResult> => {
73
+ setIsTriggering(true);
74
+ setError(null);
75
+
76
+ try {
77
+ const agent = await stigmer.agent.getByReference({
78
+ org: MCP_SERVER_CREATOR_ORG,
79
+ slug: MCP_SERVER_CREATOR_SLUG,
80
+ });
81
+
82
+ const defaultInstanceId = agent.status?.defaultInstanceId;
83
+ if (!defaultInstanceId) {
84
+ throw new Error(
85
+ `Agent "${MCP_SERVER_CREATOR_ORG}/${MCP_SERVER_CREATOR_SLUG}" does not have a ` +
86
+ "default instance. Ensure the mcp-server-creator agent is properly set up.",
87
+ );
88
+ }
89
+
90
+ const session = await stigmer.session.create({
91
+ name: `approval-policy-${mcpServerSlug}-${Date.now()}`,
92
+ org,
93
+ subject: PENDING_SUBJECT,
94
+ agentInstanceId: defaultInstanceId,
95
+ });
96
+
97
+ const sessionId = session.metadata!.id;
98
+ const fileName = `${mcpServerSlug}.yaml`;
99
+
100
+ const attachment = await stigmer.agentExecution.uploadAttachment(
101
+ create(UploadAttachmentRequestSchema, {
102
+ filename: fileName,
103
+ content: new TextEncoder().encode(mcpServerYaml),
104
+ contentType: "application/x-yaml",
105
+ }),
106
+ );
107
+
108
+ const prompt = buildApprovalPolicyPrompt(mcpServerSlug, org);
109
+
110
+ const execution = await stigmer.agentExecution.create({
111
+ name: `gen-approval-policy-${Date.now()}`,
112
+ org,
113
+ sessionId,
114
+ message: prompt,
115
+ attachments: [
116
+ {
117
+ storageKey: attachment.storageKey,
118
+ filename: fileName,
119
+ },
120
+ ],
121
+ });
122
+
123
+ const triggerResult: TriggerApprovalPolicyResult = {
124
+ sessionId,
125
+ executionId: execution.metadata!.id,
126
+ };
127
+
128
+ setResult(triggerResult);
129
+ return triggerResult;
130
+ } catch (err) {
131
+ const wrapped = toError(err);
132
+ setError(wrapped);
133
+ throw wrapped;
134
+ } finally {
135
+ setIsTriggering(false);
136
+ }
137
+ },
138
+ [stigmer],
139
+ );
140
+
141
+ return { trigger, isTriggering, error, clearError, result };
142
+ }
143
+
144
+ function buildApprovalPolicyPrompt(slug: string, org: string): string {
145
+ return [
146
+ `Generate default tool approval policies for the MCP server "${org}/${slug}".`,
147
+ "",
148
+ "Instructions:",
149
+ "1. Read the attached YAML file — it contains the full MCP server definition.",
150
+ `2. Use the get_mcp_server tool to fetch the latest version of "${org}/${slug}" including its discovered capabilities.`,
151
+ "3. For each discovered tool, classify its risk level:",
152
+ " - **Low risk** (read-only queries, searches): No approval needed — do NOT add an entry.",
153
+ " - **Medium risk** (creates/modifies resources): Add an entry with a clear approval message.",
154
+ " - **High risk** (deletes, destructive operations): Add an entry with a detailed approval message including relevant parameter placeholders ({{args.field}}).",
155
+ "4. Generate the `default_tool_approvals` section in the YAML.",
156
+ "5. Apply the updated YAML using the apply_mcp_server tool.",
157
+ "",
158
+ "The approval message should be human-readable and explain what the tool will do,",
159
+ "using {{args.field}} placeholders for dynamic content where appropriate.",
160
+ ].join("\n");
161
+ }
@@ -1,4 +1,4 @@
1
- import { describe, it, expect, vi, beforeEach } from "vitest";
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
2
  import { renderHook, act, waitFor } from "@testing-library/react";
3
3
  import type { ReactNode } from "react";
4
4
  import { create } from "@bufbuild/protobuf";
@@ -7,7 +7,8 @@ import {
7
7
  AgentExecutionStatusSchema,
8
8
  type AgentExecution,
9
9
  } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
10
- import { ExecutionPhase } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
10
+ import { ApprovalAction, ExecutionPhase } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
11
+ import { PendingApprovalSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/approval_pb";
11
12
  import { SessionSchema, type Session } from "@stigmer/protos/ai/stigmer/agentic/session/v1/api_pb";
12
13
  import { ApiResourceMetadataSchema } from "@stigmer/protos/ai/stigmer/commons/apiresource/metadata_pb";
13
14
  import type { Stigmer } from "@stigmer/sdk";
@@ -33,6 +34,13 @@ function makeExecution(id: string, phase: ExecutionPhase): AgentExecution {
33
34
  return exec;
34
35
  }
35
36
 
37
+ function addPendingApproval(exec: AgentExecution, toolCallId: string) {
38
+ const pa = create(PendingApprovalSchema);
39
+ pa.toolCallId = toolCallId;
40
+ pa.toolName = "test_tool";
41
+ exec.status!.pendingApprovals.push(pa);
42
+ }
43
+
36
44
  /**
37
45
  * Creates a controllable async generator for streaming.
38
46
  */
@@ -302,4 +310,349 @@ describe("useSessionConversation", () => {
302
310
  expect(result.current.pendingUserMessage).toBeNull();
303
311
  });
304
312
  });
313
+
314
+ // -------------------------------------------------------------------------
315
+ // Task 3: Exponential backoff polling for missing approval data
316
+ // -------------------------------------------------------------------------
317
+
318
+ describe("approval poll backoff", () => {
319
+ beforeEach(() => {
320
+ vi.useFakeTimers();
321
+ });
322
+
323
+ afterEach(() => {
324
+ vi.useRealTimers();
325
+ });
326
+
327
+ async function renderWaitingHook(
328
+ m: MockMethods,
329
+ client: Stigmer,
330
+ exec: AgentExecution,
331
+ ) {
332
+ m.listBySession.mockResolvedValue({ entries: [exec] });
333
+ const pollStream = createControllableStream<AgentExecution>();
334
+ m.subscribe.mockReturnValue(pollStream.generator);
335
+
336
+ const hook = renderHook(
337
+ () => useSessionConversation("session-1", "org"),
338
+ { wrapper: createWrapper(client) },
339
+ );
340
+
341
+ await act(async () => {});
342
+ act(() => {
343
+ pollStream.push(exec);
344
+ });
345
+ await act(async () => {});
346
+
347
+ return { ...hook, pollStream };
348
+ }
349
+
350
+ it("polls with exponential backoff when WAITING_FOR_APPROVAL with empty raw approvals", async () => {
351
+ const exec = makeExecution(
352
+ "e1",
353
+ ExecutionPhase.EXECUTION_WAITING_FOR_APPROVAL,
354
+ );
355
+
356
+ const { result } = await renderWaitingHook(methods, mockStigmer, exec);
357
+
358
+ expect(result.current.activePhase).toBe(
359
+ ExecutionPhase.EXECUTION_WAITING_FOR_APPROVAL,
360
+ );
361
+ expect(result.current.pendingApprovals).toHaveLength(0);
362
+
363
+ const baseline = methods.listBySession.mock.calls.length;
364
+
365
+ await act(async () => {
366
+ vi.advanceTimersByTime(3000);
367
+ });
368
+ expect(methods.listBySession.mock.calls.length).toBe(baseline + 1);
369
+
370
+ await act(async () => {
371
+ vi.advanceTimersByTime(6000);
372
+ });
373
+ expect(methods.listBySession.mock.calls.length).toBe(baseline + 2);
374
+
375
+ await act(async () => {
376
+ vi.advanceTimersByTime(12000);
377
+ });
378
+ expect(methods.listBySession.mock.calls.length).toBe(baseline + 3);
379
+ });
380
+
381
+ it("stops polling when raw approvals arrive via stream", async () => {
382
+ const exec = makeExecution(
383
+ "e1",
384
+ ExecutionPhase.EXECUTION_WAITING_FOR_APPROVAL,
385
+ );
386
+
387
+ const { result, pollStream } = await renderWaitingHook(
388
+ methods,
389
+ mockStigmer,
390
+ exec,
391
+ );
392
+
393
+ const baseline = methods.listBySession.mock.calls.length;
394
+
395
+ await act(async () => {
396
+ vi.advanceTimersByTime(3000);
397
+ });
398
+ expect(methods.listBySession.mock.calls.length).toBe(baseline + 1);
399
+
400
+ const updated = makeExecution(
401
+ "e1",
402
+ ExecutionPhase.EXECUTION_WAITING_FOR_APPROVAL,
403
+ );
404
+ addPendingApproval(updated, "tc-1");
405
+ act(() => {
406
+ pollStream.push(updated);
407
+ });
408
+ await act(async () => {});
409
+
410
+ expect(result.current.pendingApprovals).toHaveLength(1);
411
+
412
+ const afterArrival = methods.listBySession.mock.calls.length;
413
+
414
+ await act(async () => {
415
+ vi.advanceTimersByTime(60000);
416
+ });
417
+ expect(methods.listBySession.mock.calls.length).toBe(afterArrival);
418
+ });
419
+
420
+ it("stops polling when phase transitions away", async () => {
421
+ const exec = makeExecution(
422
+ "e1",
423
+ ExecutionPhase.EXECUTION_WAITING_FOR_APPROVAL,
424
+ );
425
+
426
+ const { pollStream } = await renderWaitingHook(
427
+ methods,
428
+ mockStigmer,
429
+ exec,
430
+ );
431
+
432
+ const completed = makeExecution(
433
+ "e1",
434
+ ExecutionPhase.EXECUTION_COMPLETED,
435
+ );
436
+ act(() => {
437
+ pollStream.push(completed);
438
+ });
439
+ await act(async () => {});
440
+
441
+ const afterTransition = methods.listBySession.mock.calls.length;
442
+
443
+ await act(async () => {
444
+ vi.advanceTimersByTime(60000);
445
+ });
446
+ // Terminal-phase effect fires one refetch; no further polling.
447
+ expect(methods.listBySession.mock.calls.length).toBeLessThanOrEqual(
448
+ afterTransition + 1,
449
+ );
450
+ });
451
+
452
+ it("does not poll when raw approvals exist but are all dismissed", async () => {
453
+ const exec = makeExecution(
454
+ "e1",
455
+ ExecutionPhase.EXECUTION_WAITING_FOR_APPROVAL,
456
+ );
457
+ addPendingApproval(exec, "tc-1");
458
+
459
+ const { result } = await renderWaitingHook(methods, mockStigmer, exec);
460
+
461
+ expect(result.current.pendingApprovals).toHaveLength(1);
462
+
463
+ await act(async () => {
464
+ await result.current.submitApproval("tc-1", ApprovalAction.APPROVE);
465
+ });
466
+ expect(result.current.pendingApprovals).toHaveLength(0);
467
+
468
+ const baseline = methods.listBySession.mock.calls.length;
469
+
470
+ // At 3s (poll initial delay) — should NOT fire because raw approvals
471
+ // are non-empty; only the staleness mechanism handles this case.
472
+ await act(async () => {
473
+ vi.advanceTimersByTime(3000);
474
+ });
475
+ expect(methods.listBySession.mock.calls.length).toBe(baseline);
476
+ });
477
+
478
+ it("cleans up timeout on unmount", async () => {
479
+ const exec = makeExecution(
480
+ "e1",
481
+ ExecutionPhase.EXECUTION_WAITING_FOR_APPROVAL,
482
+ );
483
+
484
+ const { unmount } = await renderWaitingHook(methods, mockStigmer, exec);
485
+
486
+ const baseline = methods.listBySession.mock.calls.length;
487
+
488
+ unmount();
489
+
490
+ await act(async () => {
491
+ vi.advanceTimersByTime(60000);
492
+ });
493
+ expect(methods.listBySession.mock.calls.length).toBe(baseline);
494
+ });
495
+ });
496
+
497
+ // -------------------------------------------------------------------------
498
+ // Task 4: Staleness detection for optimistic dismissals
499
+ // -------------------------------------------------------------------------
500
+
501
+ describe("staleness detection", () => {
502
+ beforeEach(() => {
503
+ vi.useFakeTimers();
504
+ });
505
+
506
+ afterEach(() => {
507
+ vi.useRealTimers();
508
+ });
509
+
510
+ async function renderWithApproval(
511
+ m: MockMethods,
512
+ client: Stigmer,
513
+ ) {
514
+ const exec = makeExecution(
515
+ "e1",
516
+ ExecutionPhase.EXECUTION_WAITING_FOR_APPROVAL,
517
+ );
518
+ addPendingApproval(exec, "tc-1");
519
+ m.listBySession.mockResolvedValue({ entries: [exec] });
520
+
521
+ const testStream = createControllableStream<AgentExecution>();
522
+ m.subscribe.mockReturnValue(testStream.generator);
523
+
524
+ const hook = renderHook(
525
+ () => useSessionConversation("session-1", "org"),
526
+ { wrapper: createWrapper(client) },
527
+ );
528
+
529
+ await act(async () => {});
530
+ act(() => {
531
+ testStream.push(exec);
532
+ });
533
+ await act(async () => {});
534
+
535
+ return { ...hook, testStream, exec };
536
+ }
537
+
538
+ it("reappears dismissed approval card after staleness threshold", async () => {
539
+ const { result } = await renderWithApproval(methods, mockStigmer);
540
+
541
+ expect(result.current.pendingApprovals).toHaveLength(1);
542
+
543
+ await act(async () => {
544
+ await result.current.submitApproval("tc-1", ApprovalAction.APPROVE);
545
+ });
546
+ expect(result.current.pendingApprovals).toHaveLength(0);
547
+ expect(result.current.dismissedApprovalIds.has("tc-1")).toBe(true);
548
+
549
+ // Before threshold: card stays hidden (15s covers interval ticks at 5s, 10s, 15s)
550
+ await act(async () => {
551
+ vi.advanceTimersByTime(15000);
552
+ });
553
+ expect(result.current.pendingApprovals).toHaveLength(0);
554
+
555
+ // Past threshold: interval fires at 20s, age > 15s → stale → card reappears
556
+ await act(async () => {
557
+ vi.advanceTimersByTime(5000);
558
+ });
559
+ expect(result.current.pendingApprovals).toHaveLength(1);
560
+ expect(result.current.dismissedApprovalIds.has("tc-1")).toBe(false);
561
+ });
562
+
563
+ it("does not run staleness check when phase is not WAITING_FOR_APPROVAL", async () => {
564
+ const exec = makeExecution(
565
+ "e1",
566
+ ExecutionPhase.EXECUTION_IN_PROGRESS,
567
+ );
568
+ addPendingApproval(exec, "tc-1");
569
+ methods.listBySession.mockResolvedValue({ entries: [exec] });
570
+
571
+ const testStream = createControllableStream<AgentExecution>();
572
+ methods.subscribe.mockReturnValue(testStream.generator);
573
+
574
+ const { result } = renderHook(
575
+ () => useSessionConversation("session-1", "org"),
576
+ { wrapper: createWrapper(mockStigmer) },
577
+ );
578
+
579
+ await act(async () => {});
580
+ act(() => {
581
+ testStream.push(exec);
582
+ });
583
+ await act(async () => {});
584
+
585
+ await act(async () => {
586
+ await result.current.submitApproval("tc-1", ApprovalAction.APPROVE);
587
+ });
588
+ expect(result.current.dismissedApprovalIds.has("tc-1")).toBe(true);
589
+
590
+ // Advance well past threshold — staleness detection should not run
591
+ await act(async () => {
592
+ vi.advanceTimersByTime(60000);
593
+ });
594
+ expect(result.current.dismissedApprovalIds.has("tc-1")).toBe(true);
595
+ });
596
+
597
+ it("triggers refetch when stale entries are detected", async () => {
598
+ const { result } = await renderWithApproval(methods, mockStigmer);
599
+
600
+ await act(async () => {
601
+ await result.current.submitApproval("tc-1", ApprovalAction.APPROVE);
602
+ });
603
+
604
+ const baseline = methods.listBySession.mock.calls.length;
605
+
606
+ // Advance to 20s — staleness fires, should call refetch
607
+ await act(async () => {
608
+ vi.advanceTimersByTime(20000);
609
+ });
610
+ expect(methods.listBySession.mock.calls.length).toBeGreaterThan(
611
+ baseline,
612
+ );
613
+ });
614
+
615
+ it("dismissedApprovalIds remains a ReadonlySet<string>", async () => {
616
+ const { result } = await renderWithApproval(methods, mockStigmer);
617
+
618
+ await act(async () => {
619
+ await result.current.submitApproval("tc-1", ApprovalAction.APPROVE);
620
+ });
621
+
622
+ const ids = result.current.dismissedApprovalIds;
623
+ expect(ids).toBeInstanceOf(Set);
624
+ expect(ids.has("tc-1")).toBe(true);
625
+ expect(typeof ids.has).toBe("function");
626
+ // Verify it behaves as a Set (not a Map)
627
+ expect([...ids]).toEqual(["tc-1"]);
628
+ });
629
+
630
+ it("resets dismissed state on new execution", async () => {
631
+ const { result, testStream } = await renderWithApproval(
632
+ methods,
633
+ mockStigmer,
634
+ );
635
+
636
+ await act(async () => {
637
+ await result.current.submitApproval("tc-1", ApprovalAction.APPROVE);
638
+ });
639
+ expect(result.current.dismissedApprovalIds.size).toBe(1);
640
+
641
+ // Stream delivers terminal phase
642
+ const completed = makeExecution(
643
+ "e1",
644
+ ExecutionPhase.EXECUTION_COMPLETED,
645
+ );
646
+ // Update mock so the refetch sees the completed execution — this
647
+ // causes listActiveId to clear, which changes activeExecutionId
648
+ // and triggers the dismissed-state reset effect.
649
+ methods.listBySession.mockResolvedValue({ entries: [completed] });
650
+ act(() => {
651
+ testStream.push(completed);
652
+ });
653
+ await act(async () => {});
654
+
655
+ expect(result.current.dismissedApprovalIds.size).toBe(0);
656
+ });
657
+ });
305
658
  });