@stigmer/react 3.1.3 → 3.1.4

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 (151) hide show
  1. package/composer/SessionComposer.d.ts +31 -0
  2. package/composer/SessionComposer.d.ts.map +1 -1
  3. package/composer/SessionComposer.js +12 -4
  4. package/composer/SessionComposer.js.map +1 -1
  5. package/execution/ArtifactApplyButton.d.ts +25 -0
  6. package/execution/ArtifactApplyButton.d.ts.map +1 -0
  7. package/execution/ArtifactApplyButton.js +21 -0
  8. package/execution/ArtifactApplyButton.js.map +1 -0
  9. package/execution/ArtifactContentBody.d.ts +36 -0
  10. package/execution/ArtifactContentBody.d.ts.map +1 -0
  11. package/execution/ArtifactContentBody.js +62 -0
  12. package/execution/ArtifactContentBody.js.map +1 -0
  13. package/execution/ArtifactDocument.d.ts +48 -0
  14. package/execution/ArtifactDocument.d.ts.map +1 -0
  15. package/execution/ArtifactDocument.js +89 -0
  16. package/execution/ArtifactDocument.js.map +1 -0
  17. package/execution/ArtifactPreviewModal.d.ts +4 -4
  18. package/execution/ArtifactPreviewModal.d.ts.map +1 -1
  19. package/execution/ArtifactPreviewModal.js +15 -142
  20. package/execution/ArtifactPreviewModal.js.map +1 -1
  21. package/execution/ArtifactRow.d.ts +49 -0
  22. package/execution/ArtifactRow.d.ts.map +1 -0
  23. package/execution/ArtifactRow.js +56 -0
  24. package/execution/ArtifactRow.js.map +1 -0
  25. package/execution/ArtifactsWidget.d.ts +6 -9
  26. package/execution/ArtifactsWidget.d.ts.map +1 -1
  27. package/execution/ArtifactsWidget.js +15 -19
  28. package/execution/ArtifactsWidget.js.map +1 -1
  29. package/execution/index.d.ts +8 -2
  30. package/execution/index.d.ts.map +1 -1
  31. package/execution/index.js +4 -1
  32. package/execution/index.js.map +1 -1
  33. package/execution/useArtifactContent.d.ts +6 -1
  34. package/execution/useArtifactContent.d.ts.map +1 -1
  35. package/execution/useArtifactContent.js +7 -2
  36. package/execution/useArtifactContent.js.map +1 -1
  37. package/execution/useArtifactInspection.d.ts +85 -0
  38. package/execution/useArtifactInspection.d.ts.map +1 -0
  39. package/execution/useArtifactInspection.js +143 -0
  40. package/execution/useArtifactInspection.js.map +1 -0
  41. package/execution/useCreateAgentExecution.d.ts +11 -0
  42. package/execution/useCreateAgentExecution.d.ts.map +1 -1
  43. package/execution/useCreateAgentExecution.js +1 -0
  44. package/execution/useCreateAgentExecution.js.map +1 -1
  45. package/index.d.ts +3 -3
  46. package/index.d.ts.map +1 -1
  47. package/index.js +2 -2
  48. package/index.js.map +1 -1
  49. package/internal/MermaidDiagram.d.ts +47 -0
  50. package/internal/MermaidDiagram.d.ts.map +1 -0
  51. package/internal/MermaidDiagram.js +118 -0
  52. package/internal/MermaidDiagram.js.map +1 -0
  53. package/internal/markdown-components.d.ts.map +1 -1
  54. package/internal/markdown-components.js +34 -0
  55. package/internal/markdown-components.js.map +1 -1
  56. package/internal/mermaid-loader.d.ts +26 -0
  57. package/internal/mermaid-loader.d.ts.map +1 -0
  58. package/internal/mermaid-loader.js +34 -0
  59. package/internal/mermaid-loader.js.map +1 -0
  60. package/internal/mermaid-theme-css.d.ts +41 -0
  61. package/internal/mermaid-theme-css.d.ts.map +1 -0
  62. package/internal/mermaid-theme-css.js +179 -0
  63. package/internal/mermaid-theme-css.js.map +1 -0
  64. package/internal/slug.d.ts +9 -5
  65. package/internal/slug.d.ts.map +1 -1
  66. package/internal/slug.js +10 -6
  67. package/internal/slug.js.map +1 -1
  68. package/package.json +5 -4
  69. package/session/SessionViewer.d.ts.map +1 -1
  70. package/session/SessionViewer.js +91 -18
  71. package/session/SessionViewer.js.map +1 -1
  72. package/session/artifact-document.d.ts +13 -0
  73. package/session/artifact-document.d.ts.map +1 -0
  74. package/session/artifact-document.js +22 -0
  75. package/session/artifact-document.js.map +1 -0
  76. package/session/facets/ArtifactsTab.d.ts +30 -4
  77. package/session/facets/ArtifactsTab.d.ts.map +1 -1
  78. package/session/facets/ArtifactsTab.js +32 -10
  79. package/session/facets/ArtifactsTab.js.map +1 -1
  80. package/session/index.d.ts +2 -1
  81. package/session/index.d.ts.map +1 -1
  82. package/session/index.js +4 -1
  83. package/session/index.js.map +1 -1
  84. package/session/useSessionArtifacts.d.ts +17 -0
  85. package/session/useSessionArtifacts.d.ts.map +1 -1
  86. package/session/useSessionArtifacts.js +16 -5
  87. package/session/useSessionArtifacts.js.map +1 -1
  88. package/session/useSessionConversation.d.ts +17 -1
  89. package/session/useSessionConversation.d.ts.map +1 -1
  90. package/session/useSessionConversation.js +32 -4
  91. package/session/useSessionConversation.js.map +1 -1
  92. package/session/useSessionPageFlow.d.ts.map +1 -1
  93. package/session/useSessionPageFlow.js +1 -0
  94. package/session/useSessionPageFlow.js.map +1 -1
  95. package/session/useSessionPanel.d.ts +20 -0
  96. package/session/useSessionPanel.d.ts.map +1 -1
  97. package/session/useSessionPanel.js +13 -0
  98. package/session/useSessionPanel.js.map +1 -1
  99. package/session/useSessionRailViews.d.ts +13 -1
  100. package/session/useSessionRailViews.d.ts.map +1 -1
  101. package/session/useSessionRailViews.js +5 -3
  102. package/session/useSessionRailViews.js.map +1 -1
  103. package/src/composer/SessionComposer.tsx +69 -1
  104. package/src/composer/__tests__/SessionComposer-editing.test.tsx +128 -0
  105. package/src/execution/ArtifactApplyButton.tsx +79 -0
  106. package/src/execution/ArtifactContentBody.tsx +255 -0
  107. package/src/execution/ArtifactDocument.tsx +344 -0
  108. package/src/execution/ArtifactPreviewModal.tsx +39 -405
  109. package/src/execution/ArtifactRow.tsx +165 -0
  110. package/src/execution/ArtifactsWidget.tsx +24 -33
  111. package/src/execution/__tests__/ArtifactContentBody.test.tsx +131 -0
  112. package/src/execution/__tests__/ArtifactDocument.test.tsx +100 -0
  113. package/src/execution/__tests__/ArtifactRow.test.tsx +139 -0
  114. package/src/execution/__tests__/ArtifactsWidget.test.tsx +78 -0
  115. package/src/execution/__tests__/message-entry.test.tsx +50 -2
  116. package/src/execution/__tests__/useArtifactInspection.test.tsx +151 -0
  117. package/src/execution/__tests__/useCreateAgentExecution.test.tsx +35 -0
  118. package/src/execution/index.ts +14 -2
  119. package/src/execution/useArtifactContent.ts +7 -0
  120. package/src/execution/useArtifactInspection.ts +252 -0
  121. package/src/execution/useCreateAgentExecution.ts +12 -0
  122. package/src/index.ts +11 -2
  123. package/src/internal/MermaidDiagram.tsx +164 -0
  124. package/src/internal/__tests__/a11y/mermaid-theme.a11y.test.tsx +122 -0
  125. package/src/internal/__tests__/markdown-components.test.tsx +93 -1
  126. package/src/internal/__tests__/mermaid-diagram.test.tsx +282 -0
  127. package/src/internal/markdown-components.tsx +36 -1
  128. package/src/internal/mermaid-loader.ts +44 -0
  129. package/src/internal/mermaid-theme-css.ts +178 -0
  130. package/src/internal/slug.ts +10 -6
  131. package/src/session/SessionViewer.tsx +177 -33
  132. package/src/session/__tests__/SessionViewer-artifactTabs.test.tsx +210 -0
  133. package/src/session/__tests__/SessionViewer-editResubmit.test.tsx +233 -0
  134. package/src/session/__tests__/useSessionArtifacts.test.ts +57 -0
  135. package/src/session/__tests__/useSessionConversation.test.tsx +172 -1
  136. package/src/session/__tests__/useSessionPanel.test.ts +70 -1
  137. package/src/session/artifact-document.ts +23 -0
  138. package/src/session/facets/ArtifactsTab.tsx +66 -18
  139. package/src/session/facets/__tests__/ArtifactsTab.test.tsx +111 -47
  140. package/src/session/index.ts +5 -1
  141. package/src/session/useSessionArtifacts.ts +16 -5
  142. package/src/session/useSessionConversation.ts +48 -6
  143. package/src/session/useSessionPageFlow.ts +1 -0
  144. package/src/session/useSessionPanel.ts +41 -0
  145. package/src/session/useSessionRailViews.tsx +21 -1
  146. package/styles.css +1 -1
  147. package/execution/ArtifactCard.d.ts +0 -81
  148. package/execution/ArtifactCard.d.ts.map +0 -1
  149. package/execution/ArtifactCard.js +0 -130
  150. package/execution/ArtifactCard.js.map +0 -1
  151. package/src/execution/ArtifactCard.tsx +0 -320
@@ -0,0 +1,210 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import { render, screen, cleanup, act, fireEvent } from "@testing-library/react";
3
+ import { create } from "@bufbuild/protobuf";
4
+ import { AgentExecutionSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
5
+ import { ExecutionArtifactSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/artifact_pb";
6
+ import {
7
+ ExecutionArtifactKind,
8
+ ExecutionPhase,
9
+ } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
10
+ import { ARTIFACT_DOCUMENT_ENTRY_ID } from "../artifact-document";
11
+ import { artifactKey, type SessionArtifactEntry } from "../useSessionArtifacts";
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Wiring-contract test for the Artifacts facet's open/pin gestures. The
15
+ // `onOpenArtifact` (single-click preview) and `onActivateArtifact` (double-click
16
+ // pin) callbacks must thread from SessionViewer, through the intermediate
17
+ // SessionPanelRegion, into useSessionRailViews' <ArtifactsTab>, and — when
18
+ // invoked — land as an artifact virtual-document tab on the editors store.
19
+ // WorkspaceSurface renders as a prop-capturing probe; the store + panel-region
20
+ // wire between them is the subject under test. Tab MECHANICS (preview-slot
21
+ // reuse, pin promotion) live in workspace-editors-store.test.ts and are not
22
+ // re-tested here.
23
+ // ---------------------------------------------------------------------------
24
+
25
+ type CapturedProps = Record<string, unknown>;
26
+
27
+ const threadProps: CapturedProps[] = [];
28
+ vi.mock("../../execution/MessageThread", () => ({
29
+ MessageThread: (props: CapturedProps) => {
30
+ threadProps.push(props);
31
+ return <div data-testid="thread-probe" />;
32
+ },
33
+ }));
34
+
35
+ const surfaceProps: CapturedProps[] = [];
36
+ vi.mock("../../workspace/WorkspaceSurface", () => ({
37
+ WorkspaceSurface: (props: CapturedProps) => {
38
+ surfaceProps.push(props);
39
+ return <div data-testid="surface-probe" />;
40
+ },
41
+ }));
42
+
43
+ vi.mock("../../composer", async (importOriginal) => {
44
+ const actual = await importOriginal<typeof import("../../composer")>();
45
+ return {
46
+ ...actual,
47
+ SessionComposer: () => <div data-testid="composer-probe" />,
48
+ };
49
+ });
50
+
51
+ const artifact = create(ExecutionArtifactSchema, {
52
+ name: "agent.yaml",
53
+ kind: ExecutionArtifactKind.FILE,
54
+ sizeBytes: 512n,
55
+ sandboxPath: ".stigmer/agent.yaml",
56
+ storageKey: "artifacts/aex_1/agent.yaml",
57
+ });
58
+
59
+ const artifactExecution = create(AgentExecutionSchema, {
60
+ metadata: { id: "aex_1" },
61
+ status: {
62
+ phase: ExecutionPhase.EXECUTION_COMPLETED,
63
+ artifacts: [artifact],
64
+ },
65
+ });
66
+
67
+ const entry: SessionArtifactEntry = {
68
+ artifact,
69
+ executionId: "aex_1",
70
+ isTerminal: true,
71
+ hasNameCollision: false,
72
+ };
73
+
74
+ const stubWorkspace = {
75
+ entries: [],
76
+ hasEntries: false,
77
+ toInput: vi.fn().mockReturnValue([]),
78
+ addGitRepo: vi.fn(),
79
+ addLocalPath: vi.fn(),
80
+ remove: vi.fn(),
81
+ clear: vi.fn(),
82
+ clearLocal: vi.fn(),
83
+ };
84
+
85
+ const stubConv = {
86
+ session: { spec: {} },
87
+ isLoading: false,
88
+ loadError: null,
89
+ completedExecutions: [artifactExecution],
90
+ activeStreamExecution: null,
91
+ pendingUserMessage: null,
92
+ workspaceEntries: [],
93
+ fileChangeSets: [],
94
+ submitFileDecision: vi.fn(),
95
+ submittingFileDecisionKeys: new Set<string>(),
96
+ fileDecisionErrors: new Map<string, Error>(),
97
+ sendError: null,
98
+ stopError: null,
99
+ streamError: null,
100
+ reconnectStream: vi.fn(),
101
+ submittingApprovalIds: new Set<string>(),
102
+ approvalErrors: new Map<string, Error>(),
103
+ isStoppable: false,
104
+ isStopping: false,
105
+ isSending: false,
106
+ canSendFollowUp: true,
107
+ isReconnecting: false,
108
+ connectTimedOut: false,
109
+ isSlow: false,
110
+ stop: vi.fn(),
111
+ retryLastSend: vi.fn(),
112
+ };
113
+
114
+ const stubSessionPageFlow = {
115
+ conv: stubConv,
116
+ harness: "native" as const,
117
+ executionTarget: undefined,
118
+ model: [undefined, vi.fn()] as const,
119
+ interactionMode: ["agent" as const, vi.fn()] as const,
120
+ agentRef: { org: "acme", slug: "support-bot" },
121
+ setAgentRef: vi.fn(),
122
+ resolution: null,
123
+ setResolution: vi.fn(),
124
+ isDefaultAgent: false,
125
+ mcpServerUsages: [],
126
+ setMcpServerUsages: vi.fn(),
127
+ skillRefs: [],
128
+ setSkillRefs: vi.fn(),
129
+ workspace: stubWorkspace,
130
+ sessionVariables: { entries: [], isEmpty: true, clear: vi.fn() },
131
+ autoApproveAll: false,
132
+ setAutoApproveAll: vi.fn(),
133
+ handleSubmit: vi.fn(),
134
+ submitError: null as Error | null,
135
+ displayExecution: artifactExecution,
136
+ allExecutions: [artifactExecution],
137
+ sandboxWorkspaceRoot: "/home/daytona/workspace",
138
+ };
139
+ vi.mock("../useSessionPageFlow", () => ({
140
+ useSessionPageFlow: () => stubSessionPageFlow,
141
+ }));
142
+
143
+ vi.mock("../../hooks", () => ({
144
+ useStigmer: () => ({
145
+ agentExecution: {
146
+ uploadAttachment: vi.fn(),
147
+ getArtifactContent: vi.fn(),
148
+ },
149
+ }),
150
+ }));
151
+
152
+ import { SessionViewer } from "../SessionViewer";
153
+
154
+ /** The latest editors array the (mocked) WorkspaceSurface received. */
155
+ function latestEditors(): ReadonlyArray<{
156
+ entryId: string;
157
+ path: string;
158
+ preview: boolean;
159
+ }> {
160
+ return (surfaceProps.at(-1)?.editors ?? []) as never;
161
+ }
162
+
163
+ /** Read a callback off the rail-view <ArtifactsTab> element the surface received. */
164
+ function artifactsTabCallback(
165
+ name: "onOpenArtifact" | "onActivateArtifact",
166
+ ): (entry: SessionArtifactEntry) => void {
167
+ const views = surfaceProps.at(-1)?.extraViews as
168
+ | ReadonlyArray<{ id: string; content: { props: CapturedProps } }>
169
+ | undefined;
170
+ const artifactsView = views?.find((v) => v.id === "artifacts");
171
+ if (!artifactsView) throw new Error("Artifacts rail view not present");
172
+ return artifactsView.content.props[name] as (e: SessionArtifactEntry) => void;
173
+ }
174
+
175
+ beforeEach(() => {
176
+ threadProps.length = 0;
177
+ surfaceProps.length = 0;
178
+ });
179
+
180
+ afterEach(() => {
181
+ cleanup();
182
+ vi.clearAllMocks();
183
+ });
184
+
185
+ describe("SessionViewer — Artifacts facet open/pin wiring", () => {
186
+ it("threads onOpenArtifact + onActivateArtifact into the Artifacts rail view", () => {
187
+ render(<SessionViewer sessionId="ses_1" org="acme" />);
188
+ fireEvent.click(screen.getByRole("button", { name: "Show panel" }));
189
+
190
+ expect(typeof artifactsTabCallback("onOpenArtifact")).toBe("function");
191
+ expect(typeof artifactsTabCallback("onActivateArtifact")).toBe("function");
192
+ });
193
+
194
+ it("opening an artifact lands a PREVIEW tab; activating it pins the tab", () => {
195
+ render(<SessionViewer sessionId="ses_1" org="acme" />);
196
+ fireEvent.click(screen.getByRole("button", { name: "Show panel" }));
197
+
198
+ // Single-click preview → one preview tab keyed by the artifact identity.
199
+ act(() => artifactsTabCallback("onOpenArtifact")(entry));
200
+ expect(latestEditors()).toEqual([
201
+ { entryId: ARTIFACT_DOCUMENT_ENTRY_ID, path: artifactKey(artifact), preview: true },
202
+ ]);
203
+
204
+ // Double-click pin → the same tab, now persistent.
205
+ act(() => artifactsTabCallback("onActivateArtifact")(entry));
206
+ expect(latestEditors()).toEqual([
207
+ { entryId: ARTIFACT_DOCUMENT_ENTRY_ID, path: artifactKey(artifact), preview: false },
208
+ ]);
209
+ });
210
+ });
@@ -0,0 +1,233 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import { render, cleanup, act } from "@testing-library/react";
3
+ import { forwardRef, useImperativeHandle } from "react";
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // Wiring-contract test for edit-and-resubmit (stigmer/stigmer#181): the
7
+ // onEditMessage SessionViewer hands to MessageThread stops the in-flight
8
+ // turn, prefills the composer, and enters editing mode; submitting while
9
+ // editing attaches the superseded execution's id to the submit context;
10
+ // cancelling drops the link so the next send appends normally. MessageThread
11
+ // and SessionComposer render as prop-capturing probes; the viewer's editing
12
+ // state machine is the subject under test.
13
+ // ---------------------------------------------------------------------------
14
+
15
+ type CapturedProps = Record<string, unknown>;
16
+
17
+ const threadProps: CapturedProps[] = [];
18
+ vi.mock("../../execution/MessageThread", () => ({
19
+ MessageThread: (props: CapturedProps) => {
20
+ threadProps.push(props);
21
+ return <div data-testid="thread-probe" />;
22
+ },
23
+ }));
24
+
25
+ vi.mock("../../workspace/WorkspaceSurface", () => ({
26
+ WorkspaceSurface: () => <div data-testid="surface-probe" />,
27
+ }));
28
+
29
+ const composerProps: CapturedProps[] = [];
30
+ const composerSetMessage = vi.fn();
31
+ const composerFocus = vi.fn();
32
+ vi.mock("../../composer", async (importOriginal) => {
33
+ const actual = await importOriginal<typeof import("../../composer")>();
34
+ const ComposerProbe = forwardRef<unknown, CapturedProps>(
35
+ function ComposerProbe(props, ref) {
36
+ useImperativeHandle(ref, () => ({
37
+ setMessage: composerSetMessage,
38
+ focus: composerFocus,
39
+ submit: vi.fn(),
40
+ }));
41
+ composerProps.push(props);
42
+ return <div data-testid="composer-probe" />;
43
+ },
44
+ );
45
+ return { ...actual, SessionComposer: ComposerProbe };
46
+ });
47
+
48
+ const stubConv = {
49
+ session: { spec: {} },
50
+ isLoading: false,
51
+ loadError: null,
52
+ completedExecutions: [],
53
+ activeStreamExecution: { metadata: { id: "exec-old" } },
54
+ pendingUserMessage: null,
55
+ workspaceEntries: [],
56
+ fileChangeSets: [],
57
+ submitFileDecision: vi.fn(),
58
+ submittingFileDecisionKeys: new Set<string>(),
59
+ fileDecisionErrors: new Map<string, Error>(),
60
+ sendError: null,
61
+ stopError: null,
62
+ streamError: null,
63
+ reconnectStream: vi.fn(),
64
+ submittingApprovalIds: new Set<string>(),
65
+ approvalErrors: new Map<string, Error>(),
66
+ isStoppable: true,
67
+ isStopping: false,
68
+ isSending: false,
69
+ canSendFollowUp: false,
70
+ isReconnecting: false,
71
+ connectTimedOut: false,
72
+ isSlow: false,
73
+ stop: vi.fn().mockResolvedValue(undefined),
74
+ retryLastSend: vi.fn(),
75
+ };
76
+
77
+ const stubSessionPageFlow = {
78
+ conv: stubConv,
79
+ harness: "native" as const,
80
+ executionTarget: undefined,
81
+ model: [undefined, vi.fn()] as const,
82
+ interactionMode: ["agent" as const, vi.fn()] as const,
83
+ agentRef: { org: "acme", slug: "support-bot" },
84
+ setAgentRef: vi.fn(),
85
+ resolution: null,
86
+ setResolution: vi.fn(),
87
+ isDefaultAgent: false,
88
+ mcpServerUsages: [],
89
+ setMcpServerUsages: vi.fn(),
90
+ skillRefs: [],
91
+ setSkillRefs: vi.fn(),
92
+ workspace: {
93
+ entries: [],
94
+ hasEntries: false,
95
+ toInput: vi.fn().mockReturnValue([]),
96
+ addGitRepo: vi.fn(),
97
+ addLocalPath: vi.fn(),
98
+ remove: vi.fn(),
99
+ clear: vi.fn(),
100
+ clearLocal: vi.fn(),
101
+ },
102
+ sessionVariables: { entries: [], isEmpty: true, clear: vi.fn() },
103
+ autoApproveAll: false,
104
+ setAutoApproveAll: vi.fn(),
105
+ handleSubmit: vi.fn(),
106
+ submitError: null as Error | null,
107
+ displayExecution: null,
108
+ allExecutions: [],
109
+ sandboxWorkspaceRoot: "/home/daytona/workspace",
110
+ };
111
+ vi.mock("../useSessionPageFlow", () => ({
112
+ useSessionPageFlow: () => stubSessionPageFlow,
113
+ }));
114
+
115
+ vi.mock("../../hooks", () => ({
116
+ useStigmer: () => ({
117
+ agentExecution: {
118
+ uploadAttachment: vi.fn(),
119
+ getArtifactContent: vi.fn(),
120
+ },
121
+ }),
122
+ }));
123
+
124
+ import { SessionViewer } from "../SessionViewer";
125
+
126
+ /** The onEditMessage handler threaded down to the (mocked) MessageThread. */
127
+ function capturedOnEditMessage(): ((text: string) => void) | undefined {
128
+ return threadProps.at(-1)?.onEditMessage as
129
+ | ((text: string) => void)
130
+ | undefined;
131
+ }
132
+
133
+ /** The latest props the (mocked) SessionComposer received. */
134
+ function latestComposerProps(): CapturedProps {
135
+ return composerProps.at(-1) ?? {};
136
+ }
137
+
138
+ function submitViaComposer(message: string): void {
139
+ const onSubmit = latestComposerProps().onSubmit as (
140
+ m: string,
141
+ model?: string,
142
+ context?: Record<string, unknown>,
143
+ ) => void;
144
+ onSubmit(message, undefined, { interactionMode: "agent" });
145
+ }
146
+
147
+ beforeEach(() => {
148
+ threadProps.length = 0;
149
+ composerProps.length = 0;
150
+ });
151
+
152
+ afterEach(() => {
153
+ cleanup();
154
+ vi.clearAllMocks();
155
+ });
156
+
157
+ describe("SessionViewer — edit-and-resubmit wiring", () => {
158
+ it("wires onEditMessage to the thread while the turn is stoppable", () => {
159
+ render(<SessionViewer sessionId="ses_1" org="acme" />);
160
+ expect(typeof capturedOnEditMessage()).toBe("function");
161
+ expect(latestComposerProps().isEditing).toBe(false);
162
+ });
163
+
164
+ it("Edit stops the turn, prefills the composer, and enters editing mode", () => {
165
+ render(<SessionViewer sessionId="ses_1" org="acme" />);
166
+
167
+ act(() => {
168
+ capturedOnEditMessage()!("original message");
169
+ });
170
+
171
+ expect(stubConv.stop).toHaveBeenCalledTimes(1);
172
+ expect(composerSetMessage).toHaveBeenCalledWith("original message");
173
+ expect(composerFocus).toHaveBeenCalledTimes(1);
174
+ expect(latestComposerProps().isEditing).toBe(true);
175
+ });
176
+
177
+ it("submitting while editing attaches the supersede link and exits editing", () => {
178
+ render(<SessionViewer sessionId="ses_1" org="acme" />);
179
+
180
+ act(() => {
181
+ capturedOnEditMessage()!("original message");
182
+ });
183
+ act(() => {
184
+ submitViaComposer("corrected message");
185
+ });
186
+
187
+ expect(stubSessionPageFlow.handleSubmit).toHaveBeenCalledTimes(1);
188
+ const [message, , context] =
189
+ stubSessionPageFlow.handleSubmit.mock.calls[0];
190
+ expect(message).toBe("corrected message");
191
+ expect(context).toMatchObject({
192
+ supersedesExecutionId: "exec-old",
193
+ // Composer-provided context fields survive the merge.
194
+ interactionMode: "agent",
195
+ });
196
+ expect(latestComposerProps().isEditing).toBe(false);
197
+ });
198
+
199
+ it("cancelling the edit clears the composer and drops the supersede link", () => {
200
+ render(<SessionViewer sessionId="ses_1" org="acme" />);
201
+
202
+ act(() => {
203
+ capturedOnEditMessage()!("original message");
204
+ });
205
+ expect(latestComposerProps().isEditing).toBe(true);
206
+
207
+ act(() => {
208
+ (latestComposerProps().onCancelEdit as () => void)();
209
+ });
210
+ expect(latestComposerProps().isEditing).toBe(false);
211
+ expect(composerSetMessage).toHaveBeenLastCalledWith("");
212
+
213
+ // A subsequent unrelated send must NOT carry the supersede link.
214
+ act(() => {
215
+ submitViaComposer("unrelated new message");
216
+ });
217
+ const [, , context] = stubSessionPageFlow.handleSubmit.mock.calls[0];
218
+ expect(context).not.toHaveProperty("supersedesExecutionId");
219
+ });
220
+
221
+ it("ordinary sends pass the composer context through untouched", () => {
222
+ render(<SessionViewer sessionId="ses_1" org="acme" />);
223
+
224
+ act(() => {
225
+ submitViaComposer("plain message");
226
+ });
227
+
228
+ const [message, , context] =
229
+ stubSessionPageFlow.handleSubmit.mock.calls[0];
230
+ expect(message).toBe("plain message");
231
+ expect(context).toEqual({ interactionMode: "agent" });
232
+ });
233
+ });
@@ -0,0 +1,57 @@
1
+ import { describe, it, expect, afterEach } from "vitest";
2
+ import { renderHook, cleanup } from "@testing-library/react";
3
+ import { create } from "@bufbuild/protobuf";
4
+ import { AgentExecutionSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
5
+ import { ExecutionArtifactSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/artifact_pb";
6
+ import {
7
+ ExecutionArtifactKind,
8
+ ExecutionPhase,
9
+ } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
10
+ import { useSessionArtifacts, artifactKey } from "../useSessionArtifacts";
11
+
12
+ function artifact(opts: { name: string; sandboxPath?: string }) {
13
+ return create(ExecutionArtifactSchema, {
14
+ name: opts.name,
15
+ kind: ExecutionArtifactKind.FILE,
16
+ sizeBytes: 8n,
17
+ ...(opts.sandboxPath ? { sandboxPath: opts.sandboxPath } : {}),
18
+ storageKey: `artifacts/aex/${opts.name}`,
19
+ });
20
+ }
21
+
22
+ afterEach(cleanup);
23
+
24
+ describe("artifactKey", () => {
25
+ it("uses sandbox_path when present (its filesystem identity)", () => {
26
+ expect(artifactKey(artifact({ name: "a.md", sandboxPath: "/w/dir/a.md" }))).toBe(
27
+ "/w/dir/a.md",
28
+ );
29
+ });
30
+
31
+ it("falls back to name for artifacts without a sandbox path", () => {
32
+ expect(artifactKey(artifact({ name: "a.md" }))).toBe("a.md");
33
+ });
34
+
35
+ it("is the key useSessionArtifacts dedups on (same key → latest wins)", () => {
36
+ const older = create(AgentExecutionSchema, {
37
+ metadata: { id: "aex_1" },
38
+ status: {
39
+ phase: ExecutionPhase.EXECUTION_COMPLETED,
40
+ artifacts: [artifact({ name: "a.md", sandboxPath: "/w/a.md" })],
41
+ },
42
+ });
43
+ const newer = create(AgentExecutionSchema, {
44
+ metadata: { id: "aex_2" },
45
+ status: {
46
+ phase: ExecutionPhase.EXECUTION_COMPLETED,
47
+ artifacts: [artifact({ name: "a.md", sandboxPath: "/w/a.md" })],
48
+ },
49
+ });
50
+
51
+ const { result } = renderHook(() => useSessionArtifacts([older, newer]));
52
+ expect(result.current.artifacts).toHaveLength(1);
53
+ // The later execution's version wins for a shared key.
54
+ expect(result.current.artifacts[0].executionId).toBe("aex_2");
55
+ expect(artifactKey(result.current.artifacts[0].artifact)).toBe("/w/a.md");
56
+ });
57
+ });
@@ -7,6 +7,7 @@ import {
7
7
  AgentExecutionStatusSchema,
8
8
  type AgentExecution,
9
9
  } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
10
+ import { AgentExecutionSpecSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/spec_pb";
10
11
  import { ExecutionPhase } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_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";
@@ -25,7 +26,11 @@ function makeSession(id: string): Session {
25
26
  return session;
26
27
  }
27
28
 
28
- function makeExecution(id: string, phase: ExecutionPhase): AgentExecution {
29
+ function makeExecution(
30
+ id: string,
31
+ phase: ExecutionPhase,
32
+ opts?: { supersedes?: string },
33
+ ): AgentExecution {
29
34
  const exec = create(AgentExecutionSchema);
30
35
  const metadata = create(ApiResourceMetadataSchema);
31
36
  metadata.id = id;
@@ -33,6 +38,11 @@ function makeExecution(id: string, phase: ExecutionPhase): AgentExecution {
33
38
  const status = create(AgentExecutionStatusSchema);
34
39
  status.phase = phase;
35
40
  exec.status = status;
41
+ if (opts?.supersedes) {
42
+ const spec = create(AgentExecutionSpecSchema);
43
+ spec.supersedesExecutionId = opts.supersedes;
44
+ exec.spec = spec;
45
+ }
36
46
  return exec;
37
47
  }
38
48
 
@@ -549,6 +559,167 @@ describe("useSessionConversation", () => {
549
559
  });
550
560
  });
551
561
 
562
+ describe("useSessionConversation — supersede filtering (edit-and-resubmit)", () => {
563
+ let methods: MockMethods;
564
+ let mockStigmer: Stigmer;
565
+ let stream: ReturnType<typeof createControllableStream<AgentExecution>>;
566
+
567
+ beforeEach(() => {
568
+ stream = createControllableStream<AgentExecution>();
569
+ methods = {
570
+ sessionGet: vi.fn().mockResolvedValue(makeSession("session-1")),
571
+ listBySession: vi.fn().mockResolvedValue({ entries: [] }),
572
+ executionCreate: vi.fn(),
573
+ subscribe: vi.fn().mockReturnValue(stream.generator),
574
+ submitApproval: vi.fn().mockResolvedValue({}),
575
+ };
576
+ mockStigmer = createMockStigmer(methods);
577
+ });
578
+
579
+ it("hides a superseded execution from completedExecutions", async () => {
580
+ const cancelled = makeExecution("e1", ExecutionPhase.EXECUTION_CANCELLED);
581
+ const successor = makeExecution("e2", ExecutionPhase.EXECUTION_COMPLETED, {
582
+ supersedes: "e1",
583
+ });
584
+ methods.listBySession.mockResolvedValue({
585
+ entries: [cancelled, successor],
586
+ });
587
+
588
+ const { result } = renderHook(
589
+ () => useSessionConversation("session-1", "org"),
590
+ { wrapper: createWrapper(mockStigmer) },
591
+ );
592
+
593
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
594
+ expect(result.current.completedExecutions).toHaveLength(1);
595
+ expect(result.current.completedExecutions[0]).toBe(successor);
596
+ });
597
+
598
+ it("hides every link of a chained supersede (A <- B <- C)", async () => {
599
+ const a = makeExecution("e1", ExecutionPhase.EXECUTION_CANCELLED);
600
+ const b = makeExecution("e2", ExecutionPhase.EXECUTION_CANCELLED, {
601
+ supersedes: "e1",
602
+ });
603
+ const c = makeExecution("e3", ExecutionPhase.EXECUTION_COMPLETED, {
604
+ supersedes: "e2",
605
+ });
606
+ methods.listBySession.mockResolvedValue({ entries: [a, b, c] });
607
+
608
+ const { result } = renderHook(
609
+ () => useSessionConversation("session-1", "org"),
610
+ { wrapper: createWrapper(mockStigmer) },
611
+ );
612
+
613
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
614
+ expect(result.current.completedExecutions).toHaveLength(1);
615
+ expect(result.current.completedExecutions[0]).toBe(c);
616
+ });
617
+
618
+ it("filters via the streaming successor's link before the list refetch delivers it", async () => {
619
+ // The stopped turn is still the only listed execution; its successor
620
+ // exists only as the live stream — the supersede link must be read off
621
+ // the stream copy for the old turn to disappear immediately.
622
+ const stopped = makeExecution("e1", ExecutionPhase.EXECUTION_IN_PROGRESS);
623
+ methods.listBySession.mockResolvedValue({ entries: [stopped] });
624
+
625
+ const successorStream = createControllableStream<AgentExecution>();
626
+ methods.subscribe.mockReturnValue(successorStream.generator);
627
+ methods.executionCreate.mockResolvedValue(
628
+ makeExecution("e2", ExecutionPhase.EXECUTION_PENDING),
629
+ );
630
+
631
+ const { result } = renderHook(
632
+ () => useSessionConversation("session-1", "org"),
633
+ { wrapper: createWrapper(mockStigmer) },
634
+ );
635
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
636
+
637
+ await act(async () => {
638
+ await result.current.sendFollowUp("corrected message", {
639
+ supersedesExecutionId: "e1",
640
+ });
641
+ });
642
+
643
+ act(() => {
644
+ successorStream.push(
645
+ makeExecution("e2", ExecutionPhase.EXECUTION_IN_PROGRESS, {
646
+ supersedes: "e1",
647
+ }),
648
+ );
649
+ });
650
+
651
+ await waitFor(() => {
652
+ expect(result.current.completedExecutions).toHaveLength(0);
653
+ });
654
+ });
655
+
656
+ it("treats a dangling supersede link as a no-op", async () => {
657
+ const completed = makeExecution("e1", ExecutionPhase.EXECUTION_COMPLETED);
658
+ const successor = makeExecution("e2", ExecutionPhase.EXECUTION_COMPLETED, {
659
+ supersedes: "does-not-exist",
660
+ });
661
+ methods.listBySession.mockResolvedValue({
662
+ entries: [completed, successor],
663
+ });
664
+
665
+ const { result } = renderHook(
666
+ () => useSessionConversation("session-1", "org"),
667
+ { wrapper: createWrapper(mockStigmer) },
668
+ );
669
+
670
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
671
+ expect(result.current.completedExecutions).toHaveLength(2);
672
+ });
673
+
674
+ it("sendFollowUp forwards supersedesExecutionId to execution creation", async () => {
675
+ methods.listBySession.mockResolvedValue({ entries: [] });
676
+ methods.executionCreate.mockResolvedValue(
677
+ makeExecution("e2", ExecutionPhase.EXECUTION_PENDING),
678
+ );
679
+
680
+ const { result } = renderHook(
681
+ () => useSessionConversation("session-1", "org"),
682
+ { wrapper: createWrapper(mockStigmer) },
683
+ );
684
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
685
+
686
+ await act(async () => {
687
+ await result.current.sendFollowUp("corrected message", {
688
+ supersedesExecutionId: "e1",
689
+ });
690
+ });
691
+
692
+ expect(methods.executionCreate).toHaveBeenCalledWith(
693
+ expect.objectContaining({
694
+ message: "corrected message",
695
+ supersedesExecutionId: "e1",
696
+ }),
697
+ );
698
+ });
699
+
700
+ it("keeps active-id resolution on the raw list when the superseded turn is still winding down", async () => {
701
+ // e1 was cancelled but has not reached a terminal phase yet; e2 (its
702
+ // successor) is the active turn. The display filter hides e1, but the
703
+ // active execution must still resolve to e2 from the UNfiltered list.
704
+ const windingDown = makeExecution("e1", ExecutionPhase.EXECUTION_IN_PROGRESS);
705
+ const successor = makeExecution("e2", ExecutionPhase.EXECUTION_IN_PROGRESS, {
706
+ supersedes: "e1",
707
+ });
708
+ methods.listBySession.mockResolvedValue({
709
+ entries: [windingDown, successor],
710
+ });
711
+
712
+ const { result } = renderHook(
713
+ () => useSessionConversation("session-1", "org"),
714
+ { wrapper: createWrapper(mockStigmer) },
715
+ );
716
+
717
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
718
+ expect(result.current.activeStreamExecution?.metadata?.id).toBe("e2");
719
+ expect(result.current.completedExecutions).toHaveLength(0);
720
+ });
721
+ });
722
+
552
723
  describe("useSessionConversation — local runner worker lifecycle", () => {
553
724
  let methods: MockMethods;
554
725
  let mockStigmer: Stigmer;