@stigmer/react 3.0.6 → 3.0.7-dev.20260611143057

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 (94) hide show
  1. package/agent-instance/AgentInstanceDetailPanel.d.ts.map +1 -1
  2. package/agent-instance/AgentInstanceDetailPanel.js +2 -9
  3. package/agent-instance/AgentInstanceDetailPanel.js.map +1 -1
  4. package/agent-instance/AgentInstanceList.d.ts.map +1 -1
  5. package/agent-instance/AgentInstanceList.js +2 -9
  6. package/agent-instance/AgentInstanceList.js.map +1 -1
  7. package/agent-instance/CreateAgentInstanceDialog.d.ts.map +1 -1
  8. package/agent-instance/CreateAgentInstanceDialog.js +1 -1
  9. package/agent-instance/CreateAgentInstanceDialog.js.map +1 -1
  10. package/composer/SessionComposer.d.ts +14 -0
  11. package/composer/SessionComposer.d.ts.map +1 -1
  12. package/composer/SessionComposer.js +15 -9
  13. package/composer/SessionComposer.js.map +1 -1
  14. package/index.d.ts +1 -1
  15. package/index.d.ts.map +1 -1
  16. package/index.js.map +1 -1
  17. package/library/InstanceVisibilitySelector.d.ts +23 -9
  18. package/library/InstanceVisibilitySelector.d.ts.map +1 -1
  19. package/library/InstanceVisibilitySelector.js +14 -9
  20. package/library/InstanceVisibilitySelector.js.map +1 -1
  21. package/library/VisibilityOptionRow.d.ts +52 -0
  22. package/library/VisibilityOptionRow.d.ts.map +1 -0
  23. package/library/VisibilityOptionRow.js +92 -0
  24. package/library/VisibilityOptionRow.js.map +1 -0
  25. package/library/VisibilitySelector.d.ts +47 -24
  26. package/library/VisibilitySelector.d.ts.map +1 -1
  27. package/library/VisibilitySelector.js +137 -115
  28. package/library/VisibilitySelector.js.map +1 -1
  29. package/library/visibilityLevels.d.ts +25 -3
  30. package/library/visibilityLevels.d.ts.map +1 -1
  31. package/library/visibilityLevels.js +8 -2
  32. package/library/visibilityLevels.js.map +1 -1
  33. package/package.json +4 -4
  34. package/session/NewSessionViewer.d.ts +32 -1
  35. package/session/NewSessionViewer.d.ts.map +1 -1
  36. package/session/NewSessionViewer.js +20 -9
  37. package/session/NewSessionViewer.js.map +1 -1
  38. package/session/SessionViewer.d.ts +24 -1
  39. package/session/SessionViewer.d.ts.map +1 -1
  40. package/session/SessionViewer.js +18 -12
  41. package/session/SessionViewer.js.map +1 -1
  42. package/session/audience.d.ts +21 -0
  43. package/session/audience.d.ts.map +1 -0
  44. package/session/audience.js +2 -0
  45. package/session/audience.js.map +1 -0
  46. package/session/index.d.ts +2 -0
  47. package/session/index.d.ts.map +1 -1
  48. package/session/index.js.map +1 -1
  49. package/session/runtime-env.d.ts +47 -0
  50. package/session/runtime-env.d.ts.map +1 -0
  51. package/session/runtime-env.js +20 -0
  52. package/session/runtime-env.js.map +1 -0
  53. package/session/useNewSessionFlow.d.ts +25 -0
  54. package/session/useNewSessionFlow.d.ts.map +1 -1
  55. package/session/useNewSessionFlow.js +20 -8
  56. package/session/useNewSessionFlow.js.map +1 -1
  57. package/session/useSessionPageFlow.d.ts +27 -2
  58. package/session/useSessionPageFlow.d.ts.map +1 -1
  59. package/session/useSessionPageFlow.js +34 -13
  60. package/session/useSessionPageFlow.js.map +1 -1
  61. package/src/agent-instance/AgentInstanceDetailPanel.tsx +7 -27
  62. package/src/agent-instance/AgentInstanceList.tsx +7 -27
  63. package/src/agent-instance/CreateAgentInstanceDialog.tsx +1 -0
  64. package/src/composer/SessionComposer.tsx +30 -8
  65. package/src/composer/__tests__/SessionComposer-lockAgent.test.tsx +150 -0
  66. package/src/index.ts +2 -0
  67. package/src/library/InstanceVisibilitySelector.tsx +27 -9
  68. package/src/library/VisibilityOptionRow.tsx +244 -0
  69. package/src/library/VisibilitySelector.tsx +303 -260
  70. package/src/library/__tests__/VisibilitySelector.test.tsx +256 -0
  71. package/src/library/visibilityLevels.ts +35 -5
  72. package/src/session/NewSessionViewer.tsx +61 -12
  73. package/src/session/SessionViewer.tsx +51 -15
  74. package/src/session/__tests__/audienceWiring.test.tsx +274 -0
  75. package/src/session/__tests__/useNewSessionFlow.test.tsx +122 -0
  76. package/src/session/__tests__/useSessionPageFlow.runtimeEnv.test.tsx +170 -0
  77. package/src/session/audience.ts +20 -0
  78. package/src/session/index.ts +3 -0
  79. package/src/session/runtime-env.ts +57 -0
  80. package/src/session/useNewSessionFlow.ts +44 -9
  81. package/src/session/useSessionPageFlow.ts +65 -17
  82. package/src/workflow/instance/CreateWorkflowInstanceDialog.tsx +1 -0
  83. package/src/workflow/instance/WorkflowInstanceDetailPanel.tsx +7 -27
  84. package/src/workflow/instance/WorkflowInstanceList.tsx +7 -27
  85. package/styles.css +1 -1
  86. package/workflow/instance/CreateWorkflowInstanceDialog.d.ts.map +1 -1
  87. package/workflow/instance/CreateWorkflowInstanceDialog.js +1 -1
  88. package/workflow/instance/CreateWorkflowInstanceDialog.js.map +1 -1
  89. package/workflow/instance/WorkflowInstanceDetailPanel.d.ts.map +1 -1
  90. package/workflow/instance/WorkflowInstanceDetailPanel.js +2 -9
  91. package/workflow/instance/WorkflowInstanceDetailPanel.js.map +1 -1
  92. package/workflow/instance/WorkflowInstanceList.d.ts.map +1 -1
  93. package/workflow/instance/WorkflowInstanceList.js +2 -9
  94. package/workflow/instance/WorkflowInstanceList.js.map +1 -1
@@ -0,0 +1,274 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import { render, screen, cleanup } from "@testing-library/react";
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // Wiring-contract tests for the `audience` preset on the session organisms.
6
+ //
7
+ // The composer and inspector are replaced with prop-capturing probes so the
8
+ // tests assert exactly what `audience="endUser"` maps to: a locked agent,
9
+ // unwired MCP/skill/session-variable pickers, and a read-only Setup tab.
10
+ // The molecules' own behavior is covered by their co-located tests.
11
+ // ---------------------------------------------------------------------------
12
+
13
+ type CapturedProps = Record<string, unknown>;
14
+
15
+ const composerProps: CapturedProps[] = [];
16
+ vi.mock("../../composer", async (importOriginal) => {
17
+ const actual = await importOriginal<typeof import("../../composer")>();
18
+ return {
19
+ ...actual,
20
+ SessionComposer: (props: CapturedProps) => {
21
+ composerProps.push(props);
22
+ return <div data-testid="composer-probe" />;
23
+ },
24
+ };
25
+ });
26
+
27
+ const inspectorProps: CapturedProps[] = [];
28
+ vi.mock("../inspector/SessionInspector", () => ({
29
+ SessionInspector: (props: CapturedProps) => {
30
+ inspectorProps.push(props);
31
+ return <div data-testid="inspector-probe" />;
32
+ },
33
+ }));
34
+
35
+ vi.mock("../../execution/MessageThread", () => ({
36
+ MessageThread: () => <div data-testid="thread-probe" />,
37
+ }));
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // Flow stubs — the organisms own these hooks; stub them to a loaded state.
41
+ // ---------------------------------------------------------------------------
42
+
43
+ const stubWorkspace = {
44
+ entries: [],
45
+ hasEntries: false,
46
+ toInput: vi.fn().mockReturnValue([]),
47
+ addGitRepo: vi.fn(),
48
+ addLocalPath: vi.fn(),
49
+ removeEntry: vi.fn(),
50
+ clear: vi.fn(),
51
+ };
52
+
53
+ const stubSessionVariables = { entries: [], isEmpty: true, clear: vi.fn() };
54
+
55
+ const stubNewSessionFlow = {
56
+ harness: "native" as const,
57
+ setHarness: vi.fn(),
58
+ modelId: undefined,
59
+ setModelId: vi.fn(),
60
+ agentRef: { org: "acme", slug: "support-bot" },
61
+ setAgentRef: vi.fn(),
62
+ resolution: null,
63
+ setResolution: vi.fn(),
64
+ mcpServerUsages: [],
65
+ setMcpServerUsages: vi.fn(),
66
+ skillRefs: [],
67
+ setSkillRefs: vi.fn(),
68
+ workspace: stubWorkspace,
69
+ sessionVariables: stubSessionVariables,
70
+ isSubmitting: false,
71
+ submitError: null,
72
+ submit: vi.fn(),
73
+ };
74
+ const mockUseNewSessionFlow = vi.fn((_options: unknown) => stubNewSessionFlow);
75
+ vi.mock("../useNewSessionFlow", () => ({
76
+ useNewSessionFlow: (options: unknown) => mockUseNewSessionFlow(options),
77
+ }));
78
+
79
+ const stubConv = {
80
+ session: { spec: {} },
81
+ isLoading: false,
82
+ loadError: null,
83
+ completedExecutions: [],
84
+ activeStreamExecution: null,
85
+ activePhase: null,
86
+ isStreaming: false,
87
+ isConnecting: false,
88
+ sendFollowUp: vi.fn(),
89
+ canSendFollowUp: true,
90
+ isSending: false,
91
+ sendError: null,
92
+ clearSendError: vi.fn(),
93
+ pendingUserMessage: null,
94
+ workspaceEntries: [],
95
+ mcpServerUsages: [],
96
+ skillRefs: [],
97
+ pendingApprovals: [],
98
+ submitApproval: vi.fn(),
99
+ submittingApprovalIds: new Set<string>(),
100
+ streamError: null,
101
+ reconnectStream: vi.fn(),
102
+ approvalError: null,
103
+ };
104
+
105
+ const stubSessionPageFlow = {
106
+ conv: stubConv,
107
+ harness: "native" as const,
108
+ executionTarget: undefined,
109
+ model: [undefined, vi.fn()] as const,
110
+ interactionMode: ["agent" as const, vi.fn()] as const,
111
+ agentRef: { org: "acme", slug: "support-bot" },
112
+ setAgentRef: vi.fn(),
113
+ resolution: null,
114
+ setResolution: vi.fn(),
115
+ isDefaultAgent: false,
116
+ mcpServerUsages: [],
117
+ setMcpServerUsages: vi.fn(),
118
+ skillRefs: [],
119
+ setSkillRefs: vi.fn(),
120
+ workspace: stubWorkspace,
121
+ sessionVariables: stubSessionVariables,
122
+ autoApproveAll: false,
123
+ setAutoApproveAll: vi.fn(),
124
+ submitApproval: vi.fn(),
125
+ handleSubmit: vi.fn(),
126
+ submitError: null as Error | null,
127
+ displayExecution: null,
128
+ allExecutions: [],
129
+ sandboxWorkspaceRoot: undefined,
130
+ };
131
+ const mockUseSessionPageFlow = vi.fn((_options: unknown) => stubSessionPageFlow);
132
+ vi.mock("../useSessionPageFlow", () => ({
133
+ useSessionPageFlow: (options: unknown) => mockUseSessionPageFlow(options),
134
+ }));
135
+
136
+ import { NewSessionViewer } from "../NewSessionViewer";
137
+ import { SessionViewer } from "../SessionViewer";
138
+
139
+ const AGENT_REF = { org: "acme", slug: "support-bot" };
140
+
141
+ function lastComposerProps(): CapturedProps {
142
+ expect(composerProps.length).toBeGreaterThan(0);
143
+ return composerProps.at(-1)!;
144
+ }
145
+
146
+ function lastSessionConfig(): { mutations?: unknown } {
147
+ expect(inspectorProps.length).toBeGreaterThan(0);
148
+ return inspectorProps.at(-1)!.sessionConfig as { mutations?: unknown };
149
+ }
150
+
151
+ beforeEach(() => {
152
+ composerProps.length = 0;
153
+ inspectorProps.length = 0;
154
+ stubSessionPageFlow.submitError = null;
155
+ });
156
+
157
+ afterEach(() => {
158
+ cleanup();
159
+ vi.clearAllMocks();
160
+ });
161
+
162
+ describe("NewSessionViewer — audience wiring", () => {
163
+ it("integrator (default): full configuration surface, agent unlocked", () => {
164
+ render(
165
+ <NewSessionViewer
166
+ org="acme"
167
+ onSessionCreated={vi.fn()}
168
+ initialAgentRef={AGENT_REF}
169
+ />,
170
+ );
171
+
172
+ const props = lastComposerProps();
173
+ expect(props.lockAgent).toBe(false);
174
+ expect(props.onMcpServerUsagesChange).toBeDefined();
175
+ expect(props.onSkillRefsChange).toBeDefined();
176
+ expect(props.sessionVariables).toBeDefined();
177
+ expect(lastSessionConfig().mutations).toBeDefined();
178
+ });
179
+
180
+ it("endUser with a pinned agent: locked agent, integrator pickers unwired", () => {
181
+ render(
182
+ <NewSessionViewer
183
+ org="acme"
184
+ onSessionCreated={vi.fn()}
185
+ audience="endUser"
186
+ initialAgentRef={AGENT_REF}
187
+ />,
188
+ );
189
+
190
+ const props = lastComposerProps();
191
+ expect(props.lockAgent).toBe(true);
192
+ expect(props.onMcpServerUsagesChange).toBeUndefined();
193
+ expect(props.onSkillRefsChange).toBeUndefined();
194
+ expect(props.sessionVariables).toBeUndefined();
195
+ // End-user controls survive the curation.
196
+ expect(props.showHarnessSelector).toBe(true);
197
+ expect(props.showInteractionModePicker).toBe(true);
198
+ // Setup tab is read-only: no remove affordances for pinned config.
199
+ expect(lastSessionConfig().mutations).toBeUndefined();
200
+ });
201
+
202
+ it("endUser without a pinned agent: pickers hidden but agent not locked", () => {
203
+ render(
204
+ <NewSessionViewer
205
+ org="acme"
206
+ onSessionCreated={vi.fn()}
207
+ audience="endUser"
208
+ />,
209
+ );
210
+
211
+ expect(lastComposerProps().lockAgent).toBe(false);
212
+ });
213
+
214
+ it("forwards getRuntimeEnv and defaultHarness to the flow", () => {
215
+ const getRuntimeEnv = vi.fn();
216
+ render(
217
+ <NewSessionViewer
218
+ org="acme"
219
+ onSessionCreated={vi.fn()}
220
+ getRuntimeEnv={getRuntimeEnv}
221
+ defaultHarness="cursor"
222
+ />,
223
+ );
224
+
225
+ expect(mockUseNewSessionFlow).toHaveBeenCalledWith(
226
+ expect.objectContaining({ getRuntimeEnv, defaultHarness: "cursor" }),
227
+ );
228
+ });
229
+ });
230
+
231
+ describe("SessionViewer — audience wiring", () => {
232
+ it("integrator (default): full configuration surface, agent unlocked", () => {
233
+ render(<SessionViewer sessionId="ses_1" org="acme" />);
234
+
235
+ const props = lastComposerProps();
236
+ expect(props.lockAgent).toBe(false);
237
+ expect(props.onMcpServerUsagesChange).toBeDefined();
238
+ expect(props.onSkillRefsChange).toBeDefined();
239
+ expect(props.sessionVariables).toBeDefined();
240
+ expect(lastSessionConfig().mutations).toBeDefined();
241
+ });
242
+
243
+ it("endUser: locked agent, integrator pickers unwired, read-only Setup tab", () => {
244
+ render(<SessionViewer sessionId="ses_1" org="acme" audience="endUser" />);
245
+
246
+ const props = lastComposerProps();
247
+ expect(props.lockAgent).toBe(true);
248
+ expect(props.onMcpServerUsagesChange).toBeUndefined();
249
+ expect(props.onSkillRefsChange).toBeUndefined();
250
+ expect(props.sessionVariables).toBeUndefined();
251
+ // End-user controls survive the curation.
252
+ expect(props.showInteractionModePicker).toBe(true);
253
+ expect(lastSessionConfig().mutations).toBeUndefined();
254
+ });
255
+
256
+ it("forwards getRuntimeEnv to the flow", () => {
257
+ const getRuntimeEnv = vi.fn();
258
+ render(
259
+ <SessionViewer sessionId="ses_1" org="acme" getRuntimeEnv={getRuntimeEnv} />,
260
+ );
261
+
262
+ expect(mockUseSessionPageFlow).toHaveBeenCalledWith(
263
+ expect.objectContaining({ getRuntimeEnv }),
264
+ );
265
+ });
266
+
267
+ it("renders the flow's submitError through the send-error banner", () => {
268
+ stubSessionPageFlow.submitError = new Error("token mint failed");
269
+ render(<SessionViewer sessionId="ses_1" org="acme" />);
270
+
271
+ const alert = screen.getByRole("alert");
272
+ expect(alert.textContent).toContain("token mint failed");
273
+ });
274
+ });
@@ -468,6 +468,128 @@ describe("useNewSessionFlow", () => {
468
468
  });
469
469
  });
470
470
 
471
+ describe("host runtime env (getRuntimeEnv)", () => {
472
+ it("merges host env into the first execution, host wins on collisions", async () => {
473
+ const getRuntimeEnv = vi.fn().mockResolvedValue({
474
+ PLATFORM_TOKEN: { value: "fresh-token", isSecret: true },
475
+ });
476
+ const opts = { ...defaultOptions(), getRuntimeEnv };
477
+ const { result } = renderHook(() => useNewSessionFlow(opts), { wrapper: createWrapper() });
478
+
479
+ await act(async () => {
480
+ await result.current.submit("Hello", undefined, {
481
+ runtimeEnv: {
482
+ PLATFORM_TOKEN: { value: "stale-token", isSecret: true },
483
+ USER_VAR: { value: "kept" },
484
+ },
485
+ });
486
+ });
487
+
488
+ expect(getRuntimeEnv).toHaveBeenCalledTimes(1);
489
+ const execInput = mockCreateExecution.mock.calls[0][0];
490
+ expect(execInput.runtimeEnv).toEqual({
491
+ PLATFORM_TOKEN: { value: "fresh-token", isSecret: true },
492
+ USER_VAR: { value: "kept" },
493
+ });
494
+ });
495
+
496
+ it("evaluates the provider fresh on every submission", async () => {
497
+ let mint = 0;
498
+ const getRuntimeEnv = vi.fn(() => ({
499
+ TOKEN: { value: `token-${++mint}` },
500
+ }));
501
+ const opts = { ...defaultOptions(), getRuntimeEnv };
502
+ const { result } = renderHook(() => useNewSessionFlow(opts), { wrapper: createWrapper() });
503
+
504
+ await act(async () => {
505
+ await result.current.submit("First");
506
+ });
507
+ await act(async () => {
508
+ await result.current.submit("Second");
509
+ });
510
+
511
+ expect(getRuntimeEnv).toHaveBeenCalledTimes(2);
512
+ expect(mockCreateExecution.mock.calls[0][0].runtimeEnv).toEqual({
513
+ TOKEN: { value: "token-1" },
514
+ });
515
+ expect(mockCreateExecution.mock.calls[1][0].runtimeEnv).toEqual({
516
+ TOKEN: { value: "token-2" },
517
+ });
518
+ });
519
+
520
+ it("aborts before session creation when the provider throws", async () => {
521
+ const getRuntimeEnv = vi.fn().mockRejectedValue(new Error("token mint failed"));
522
+ const opts = { ...defaultOptions(), getRuntimeEnv };
523
+ const { result } = renderHook(() => useNewSessionFlow(opts), { wrapper: createWrapper() });
524
+
525
+ await act(async () => {
526
+ await result.current.submit("Hello");
527
+ });
528
+
529
+ // No orphan session, no execution — the failure is fully pre-flight.
530
+ expect(mockCreateSession).not.toHaveBeenCalled();
531
+ expect(mockCreateExecution).not.toHaveBeenCalled();
532
+ expect(result.current.submitError).toContain("token mint failed");
533
+ expect(opts.onError).toHaveBeenCalled();
534
+ expect(result.current.isSubmitting).toBe(false);
535
+ });
536
+
537
+ it("passes composer env through untouched when no provider is configured", async () => {
538
+ const opts = defaultOptions();
539
+ const { result } = renderHook(() => useNewSessionFlow(opts), { wrapper: createWrapper() });
540
+
541
+ await act(async () => {
542
+ await result.current.submit("Hello", undefined, {
543
+ runtimeEnv: { USER_VAR: { value: "composer-only" } },
544
+ });
545
+ });
546
+
547
+ expect(mockCreateExecution.mock.calls[0][0].runtimeEnv).toEqual({
548
+ USER_VAR: { value: "composer-only" },
549
+ });
550
+ });
551
+ });
552
+
553
+ describe("defaultHarness", () => {
554
+ it("seeds the embedder default when no harness is stored", () => {
555
+ const opts = { ...defaultOptions(), defaultHarness: "cursor" as const };
556
+ const { result } = renderHook(() => useNewSessionFlow(opts), { wrapper: createWrapper() });
557
+
558
+ expect(result.current.harness).toBe("cursor");
559
+ });
560
+
561
+ it("stored user choice outranks the embedder default", () => {
562
+ localStorage.setItem(STORAGE_KEY_HARNESS, "native");
563
+ const opts = { ...defaultOptions(), defaultHarness: "cursor" as const };
564
+ const { result } = renderHook(() => useNewSessionFlow(opts), { wrapper: createWrapper() });
565
+
566
+ expect(result.current.harness).toBe("native");
567
+ });
568
+
569
+ it("does not persist the seeded default — only explicit choices", () => {
570
+ const opts = { ...defaultOptions(), defaultHarness: "cursor" as const };
571
+ const { result } = renderHook(() => useNewSessionFlow(opts), { wrapper: createWrapper() });
572
+
573
+ // Seeding must not masquerade as a user choice, otherwise the
574
+ // embedder default would stop applying after the first visit.
575
+ expect(localStorage.getItem(STORAGE_KEY_HARNESS)).toBeNull();
576
+
577
+ act(() => result.current.setHarness("native"));
578
+ expect(localStorage.getItem(STORAGE_KEY_HARNESS)).toBe("native");
579
+ });
580
+
581
+ it("submits sessions with the seeded default harness", async () => {
582
+ const opts = { ...defaultOptions(), defaultHarness: "cursor" as const };
583
+ const { result } = renderHook(() => useNewSessionFlow(opts), { wrapper: createWrapper() });
584
+
585
+ await act(async () => {
586
+ await result.current.submit("Hello");
587
+ });
588
+
589
+ expect(mockCreateSession.mock.calls[0][0].harness).toBe("cursor");
590
+ });
591
+ });
592
+
471
593
  describe("submit while default agent is loading", () => {
472
594
  it("awaits default agent and creates session when fetch resolves", async () => {
473
595
  const resolvedAgent = { status: { defaultInstanceId: "awaited-inst" } };
@@ -0,0 +1,170 @@
1
+ import { describe, it, expect, vi, afterEach } from "vitest";
2
+ import { renderHook, act } from "@testing-library/react";
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // Mocks — useSessionPageFlow composes many hooks; we stub them to isolate the
6
+ // host runtime-env behavior (per-follow-up evaluation, host-wins merge, and
7
+ // fail-fast into submitError before any optimistic UI).
8
+ // ---------------------------------------------------------------------------
9
+
10
+ const mockSendFollowUp = vi.fn();
11
+
12
+ const mockConv = {
13
+ session: { spec: {} },
14
+ isLoading: false,
15
+ completedExecutions: [] as unknown[],
16
+ activeStreamExecution: null,
17
+ workspaceEntries: [] as unknown[],
18
+ submitApproval: vi.fn(),
19
+ sendFollowUp: mockSendFollowUp,
20
+ };
21
+ vi.mock("../useSessionConversation", () => ({
22
+ useSessionConversation: () => mockConv,
23
+ }));
24
+
25
+ vi.mock("../../hooks", () => ({
26
+ useStigmer: () => ({ agent: { getByReference: vi.fn() } }),
27
+ }));
28
+
29
+ vi.mock("../../agent", () => ({
30
+ useDefaultAgent: () => ({ agent: null, isLoading: false, error: null }),
31
+ }));
32
+
33
+ const mockWorkspace = {
34
+ entries: [],
35
+ hasEntries: false,
36
+ toInput: vi.fn().mockReturnValue([]),
37
+ addGitRepo: vi.fn(),
38
+ addLocalPath: vi.fn(),
39
+ removeEntry: vi.fn(),
40
+ clear: vi.fn(),
41
+ };
42
+ vi.mock("../../workspace", () => ({
43
+ useWorkspaceEntries: () => mockWorkspace,
44
+ }));
45
+
46
+ const mockSessionVariables = {
47
+ variables: [],
48
+ isEmpty: true,
49
+ clear: vi.fn(),
50
+ };
51
+ vi.mock("../../execution/useSessionVariables", () => ({
52
+ useSessionVariables: () => mockSessionVariables,
53
+ }));
54
+
55
+ vi.mock("../usePersistedModel", () => ({
56
+ usePersistedModel: () => ["model-x", vi.fn()] as const,
57
+ }));
58
+
59
+ vi.mock("../useAgentRefFromSession", () => ({
60
+ useAgentRefFromSession: () => ({ agentRef: null }),
61
+ }));
62
+
63
+ import { useSessionPageFlow } from "../useSessionPageFlow";
64
+
65
+ const OPTS = { sessionId: "ses_1", org: "acme" };
66
+
67
+ describe("useSessionPageFlow — host runtime env (getRuntimeEnv)", () => {
68
+ afterEach(() => {
69
+ vi.clearAllMocks();
70
+ });
71
+
72
+ it("merges host env into follow-ups, host wins on collisions", async () => {
73
+ const getRuntimeEnv = vi.fn().mockResolvedValue({
74
+ PLATFORM_TOKEN: { value: "fresh-token", isSecret: true },
75
+ });
76
+ const { result } = renderHook(() =>
77
+ useSessionPageFlow({ ...OPTS, getRuntimeEnv }),
78
+ );
79
+
80
+ await act(async () => {
81
+ await result.current.handleSubmit("follow up", undefined, {
82
+ runtimeEnv: {
83
+ PLATFORM_TOKEN: { value: "stale-token", isSecret: true },
84
+ USER_VAR: { value: "kept" },
85
+ },
86
+ });
87
+ });
88
+
89
+ expect(mockSendFollowUp).toHaveBeenCalledTimes(1);
90
+ expect(mockSendFollowUp.mock.calls[0][1].runtimeEnv).toEqual({
91
+ PLATFORM_TOKEN: { value: "fresh-token", isSecret: true },
92
+ USER_VAR: { value: "kept" },
93
+ });
94
+ });
95
+
96
+ it("evaluates the provider fresh on every follow-up", async () => {
97
+ let mint = 0;
98
+ const getRuntimeEnv = vi.fn(() => ({ TOKEN: { value: `token-${++mint}` } }));
99
+ const { result } = renderHook(() =>
100
+ useSessionPageFlow({ ...OPTS, getRuntimeEnv }),
101
+ );
102
+
103
+ await act(async () => {
104
+ await result.current.handleSubmit("first");
105
+ });
106
+ await act(async () => {
107
+ await result.current.handleSubmit("second");
108
+ });
109
+
110
+ expect(getRuntimeEnv).toHaveBeenCalledTimes(2);
111
+ expect(mockSendFollowUp.mock.calls[0][1].runtimeEnv).toEqual({
112
+ TOKEN: { value: "token-1" },
113
+ });
114
+ expect(mockSendFollowUp.mock.calls[1][1].runtimeEnv).toEqual({
115
+ TOKEN: { value: "token-2" },
116
+ });
117
+ });
118
+
119
+ it("blocks the send and sets submitError when the provider throws", async () => {
120
+ const getRuntimeEnv = vi.fn().mockRejectedValue(new Error("token mint failed"));
121
+ const { result } = renderHook(() =>
122
+ useSessionPageFlow({ ...OPTS, getRuntimeEnv }),
123
+ );
124
+
125
+ await act(async () => {
126
+ await result.current.handleSubmit("follow up");
127
+ });
128
+
129
+ // Nothing was sent: no optimistic message, no session-variable clear.
130
+ expect(mockSendFollowUp).not.toHaveBeenCalled();
131
+ expect(mockSessionVariables.clear).not.toHaveBeenCalled();
132
+ expect(result.current.submitError).toBeInstanceOf(Error);
133
+ expect(result.current.submitError?.message).toBe("token mint failed");
134
+ });
135
+
136
+ it("clears submitError at the start of the next submission", async () => {
137
+ const getRuntimeEnv = vi
138
+ .fn()
139
+ .mockRejectedValueOnce(new Error("transient failure"))
140
+ .mockResolvedValue({ TOKEN: { value: "ok" } });
141
+ const { result } = renderHook(() =>
142
+ useSessionPageFlow({ ...OPTS, getRuntimeEnv }),
143
+ );
144
+
145
+ await act(async () => {
146
+ await result.current.handleSubmit("fails");
147
+ });
148
+ expect(result.current.submitError).not.toBeNull();
149
+
150
+ await act(async () => {
151
+ await result.current.handleSubmit("succeeds");
152
+ });
153
+ expect(result.current.submitError).toBeNull();
154
+ expect(mockSendFollowUp).toHaveBeenCalledTimes(1);
155
+ });
156
+
157
+ it("passes composer env through untouched when no provider is configured", async () => {
158
+ const { result } = renderHook(() => useSessionPageFlow(OPTS));
159
+
160
+ await act(async () => {
161
+ await result.current.handleSubmit("follow up", undefined, {
162
+ runtimeEnv: { USER_VAR: { value: "composer-only" } },
163
+ });
164
+ });
165
+
166
+ expect(mockSendFollowUp.mock.calls[0][1].runtimeEnv).toEqual({
167
+ USER_VAR: { value: "composer-only" },
168
+ });
169
+ });
170
+ });
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Who the session organisms (`SessionViewer` / `NewSessionViewer`) are
3
+ * presented to.
4
+ *
5
+ * - `"integrator"` (default) — the full configuration surface: agent
6
+ * picker, MCP servers, skills, and session variables. The presentation
7
+ * used by the Stigmer Console, where the person at the keyboard is
8
+ * composing the session's configuration.
9
+ * - `"endUser"` — a curated, product-embedded chat. The agent (and its
10
+ * MCP servers, skills, and identity) is configured upstream by the
11
+ * embedding platform; the end user chats, picks a model, toggles
12
+ * Agent/Plan mode, and attaches workspaces, but never reconfigures
13
+ * the agent. The organisms lock the agent and hide the integrator
14
+ * pickers, in both the composer and the inspector's Setup tab.
15
+ *
16
+ * A preset rather than individual flags: "end user" is a product intent,
17
+ * and keeping it in one place lets the SDK evolve what that presentation
18
+ * means without breaking embedders.
19
+ */
20
+ export type SessionAudience = "integrator" | "endUser";
@@ -1,6 +1,9 @@
1
1
  export { toProtoExecutionTarget, fromProtoExecutionTarget } from "./execution-target";
2
2
  export type { ExecutionTargetOption } from "./execution-target";
3
3
 
4
+ export type { RuntimeEnvProvider } from "./runtime-env";
5
+ export type { SessionAudience } from "./audience";
6
+
4
7
  export { useCreateSession } from "./useCreateSession";
5
8
  export type {
6
9
  SharedSessionFields,
@@ -0,0 +1,57 @@
1
+ import type { EnvVarInput } from "@stigmer/sdk";
2
+
3
+ /**
4
+ * Host-app callback that supplies environment variables for a single
5
+ * execution.
6
+ *
7
+ * Invoked once per execution, at submit time — never cached — so
8
+ * short-lived credentials (e.g. a freshly minted platform token scoped
9
+ * to the signed-in user) are always current when the execution starts.
10
+ * May return the variables synchronously or via a promise.
11
+ *
12
+ * Host-provided values take precedence over composer-collected env on
13
+ * key collisions: the host injects identity material, and a stale or
14
+ * user-supplied value must never shadow it.
15
+ *
16
+ * If the provider throws (or rejects), the submission is aborted and
17
+ * the error surfaces through the owning flow's error channel — an
18
+ * execution never runs with missing or stale credentials.
19
+ *
20
+ * @example
21
+ * ```tsx
22
+ * <SessionViewer
23
+ * sessionId={id}
24
+ * org={org}
25
+ * getRuntimeEnv={async () => ({
26
+ * PLATFORM_TOKEN: { value: await mintShortLivedToken(), isSecret: true },
27
+ * PLATFORM_ORG: { value: activeOrg },
28
+ * })}
29
+ * />
30
+ * ```
31
+ */
32
+ export type RuntimeEnvProvider = () =>
33
+ | Promise<Record<string, EnvVarInput>>
34
+ | Record<string, EnvVarInput>;
35
+
36
+ /**
37
+ * Resolves the effective `runtimeEnv` for one execution by merging
38
+ * composer-collected env with host-provided env from a
39
+ * {@link RuntimeEnvProvider}.
40
+ *
41
+ * Host values win on key collisions (see {@link RuntimeEnvProvider}).
42
+ * Returns `undefined` when neither source contributes any variables,
43
+ * so callers can pass the result straight to execution creation.
44
+ *
45
+ * Provider errors are intentionally not caught here: callers must
46
+ * treat a failure as fatal for the submission. Callers without a
47
+ * provider should pass the composer env through directly rather than
48
+ * paying this function's await.
49
+ */
50
+ export async function resolveExecutionRuntimeEnv(
51
+ getRuntimeEnv: RuntimeEnvProvider,
52
+ composerEnv: Record<string, EnvVarInput> | undefined,
53
+ ): Promise<Record<string, EnvVarInput> | undefined> {
54
+ const hostEnv = await getRuntimeEnv();
55
+ const merged = { ...composerEnv, ...hostEnv };
56
+ return Object.keys(merged).length > 0 ? merged : undefined;
57
+ }