@stigmer/react 0.2.3 → 0.3.1
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.
- package/composer/ComposerToolbar.d.ts +5 -1
- package/composer/ComposerToolbar.d.ts.map +1 -1
- package/composer/ComposerToolbar.js +6 -3
- package/composer/ComposerToolbar.js.map +1 -1
- package/composer/SessionComposer.d.ts +17 -1
- package/composer/SessionComposer.d.ts.map +1 -1
- package/composer/SessionComposer.js +10 -3
- package/composer/SessionComposer.js.map +1 -1
- package/execution/MessageEntry.d.ts +3 -1
- package/execution/MessageEntry.d.ts.map +1 -1
- package/execution/MessageEntry.js +30 -1
- package/execution/MessageEntry.js.map +1 -1
- package/index.d.ts +3 -3
- package/index.d.ts.map +1 -1
- package/index.js +2 -2
- package/index.js.map +1 -1
- package/models/HarnessSelector.d.ts +41 -0
- package/models/HarnessSelector.d.ts.map +1 -0
- package/models/HarnessSelector.js +74 -0
- package/models/HarnessSelector.js.map +1 -0
- package/models/ModelSelector.d.ts +26 -16
- package/models/ModelSelector.d.ts.map +1 -1
- package/models/ModelSelector.js +128 -48
- package/models/ModelSelector.js.map +1 -1
- package/models/__tests__/HarnessSelector.test.d.ts +2 -0
- package/models/__tests__/HarnessSelector.test.d.ts.map +1 -0
- package/models/__tests__/HarnessSelector.test.js +160 -0
- package/models/__tests__/HarnessSelector.test.js.map +1 -0
- package/models/__tests__/harness.test.d.ts +2 -0
- package/models/__tests__/harness.test.d.ts.map +1 -0
- package/models/__tests__/harness.test.js +50 -0
- package/models/__tests__/harness.test.js.map +1 -0
- package/models/__tests__/useModelRegistry.test.d.ts +2 -0
- package/models/__tests__/useModelRegistry.test.d.ts.map +1 -0
- package/models/__tests__/useModelRegistry.test.js +148 -0
- package/models/__tests__/useModelRegistry.test.js.map +1 -0
- package/models/harness.d.ts +21 -0
- package/models/harness.d.ts.map +1 -0
- package/models/harness.js +34 -0
- package/models/harness.js.map +1 -0
- package/models/index.d.ts +7 -2
- package/models/index.d.ts.map +1 -1
- package/models/index.js +3 -1
- package/models/index.js.map +1 -1
- package/models/registry.d.ts +53 -13
- package/models/registry.d.ts.map +1 -1
- package/models/registry.js +51 -40
- package/models/registry.js.map +1 -1
- package/models/useModelRegistry.d.ts +39 -19
- package/models/useModelRegistry.d.ts.map +1 -1
- package/models/useModelRegistry.js +45 -23
- package/models/useModelRegistry.js.map +1 -1
- package/package.json +4 -4
- package/runner/RunnerListPanel.js +2 -1
- package/runner/RunnerListPanel.js.map +1 -1
- package/runner/__tests__/phase.test.js +6 -2
- package/runner/__tests__/phase.test.js.map +1 -1
- package/runner/phase.d.ts +9 -7
- package/runner/phase.d.ts.map +1 -1
- package/runner/phase.js +18 -12
- package/runner/phase.js.map +1 -1
- package/session/__tests__/useCreateSession.test.d.ts +2 -0
- package/session/__tests__/useCreateSession.test.d.ts.map +1 -0
- package/session/__tests__/useCreateSession.test.js +232 -0
- package/session/__tests__/useCreateSession.test.js.map +1 -0
- package/session/__tests__/useNewSessionFlow.test.d.ts +2 -0
- package/session/__tests__/useNewSessionFlow.test.d.ts.map +1 -0
- package/session/__tests__/useNewSessionFlow.test.js +199 -0
- package/session/__tests__/useNewSessionFlow.test.js.map +1 -0
- package/session/__tests__/useSessionConversation.test.js +37 -0
- package/session/__tests__/useSessionConversation.test.js.map +1 -1
- package/session/index.d.ts +1 -1
- package/session/index.d.ts.map +1 -1
- package/session/useCreateSession.d.ts +8 -0
- package/session/useCreateSession.d.ts.map +1 -1
- package/session/useCreateSession.js +2 -0
- package/session/useCreateSession.js.map +1 -1
- package/session/useNewSessionFlow.d.ts +6 -1
- package/session/useNewSessionFlow.d.ts.map +1 -1
- package/session/useNewSessionFlow.js +34 -8
- package/session/useNewSessionFlow.js.map +1 -1
- package/session/usePersistedModel.d.ts +16 -1
- package/session/usePersistedModel.d.ts.map +1 -1
- package/session/usePersistedModel.js +15 -6
- package/session/usePersistedModel.js.map +1 -1
- package/session/useSessionConversation.d.ts.map +1 -1
- package/session/useSessionConversation.js +6 -1
- package/session/useSessionConversation.js.map +1 -1
- package/session/useSessionPageFlow.d.ts +11 -0
- package/session/useSessionPageFlow.d.ts.map +1 -1
- package/session/useSessionPageFlow.js +11 -2
- package/session/useSessionPageFlow.js.map +1 -1
- package/src/composer/ComposerToolbar.tsx +24 -1
- package/src/composer/SessionComposer.tsx +35 -1
- package/src/execution/MessageEntry.tsx +134 -1
- package/src/index.ts +15 -1
- package/src/models/HarnessSelector.tsx +130 -0
- package/src/models/ModelSelector.tsx +285 -81
- package/src/models/__tests__/HarnessSelector.test.tsx +190 -0
- package/src/models/__tests__/harness.test.ts +66 -0
- package/src/models/__tests__/useModelRegistry.test.tsx +209 -0
- package/src/models/harness.ts +45 -0
- package/src/models/index.ts +7 -2
- package/src/models/registry.ts +122 -50
- package/src/models/useModelRegistry.ts +74 -24
- package/src/runner/RunnerListPanel.tsx +13 -5
- package/src/runner/__tests__/phase.test.ts +6 -2
- package/src/runner/phase.ts +18 -12
- package/src/session/__tests__/useCreateSession.test.tsx +296 -0
- package/src/session/__tests__/useNewSessionFlow.test.tsx +258 -0
- package/src/session/__tests__/useSessionConversation.test.tsx +53 -0
- package/src/session/index.ts +1 -1
- package/src/session/useCreateSession.ts +9 -0
- package/src/session/useNewSessionFlow.ts +46 -9
- package/src/session/usePersistedModel.ts +30 -6
- package/src/session/useSessionConversation.ts +6 -1
- package/src/session/useSessionPageFlow.ts +26 -2
- package/styles.css +1 -1
|
@@ -3,8 +3,11 @@ import { RunnerPhase } from "@stigmer/protos/ai/stigmer/agentic/runner/v1/enum_p
|
|
|
3
3
|
import { isTransitionalPhase, isActivePhase } from "../phase";
|
|
4
4
|
|
|
5
5
|
describe("isTransitionalPhase", () => {
|
|
6
|
-
it(
|
|
7
|
-
|
|
6
|
+
it.each([
|
|
7
|
+
["PENDING", RunnerPhase.PENDING],
|
|
8
|
+
["STARTING", RunnerPhase.STARTING],
|
|
9
|
+
] as const)("returns true for %s", (_label, phase) => {
|
|
10
|
+
expect(isTransitionalPhase(phase)).toBe(true);
|
|
8
11
|
});
|
|
9
12
|
|
|
10
13
|
it.each([
|
|
@@ -21,6 +24,7 @@ describe("isTransitionalPhase", () => {
|
|
|
21
24
|
const allPhases = [
|
|
22
25
|
RunnerPhase.READY,
|
|
23
26
|
RunnerPhase.BUSY,
|
|
27
|
+
RunnerPhase.STARTING,
|
|
24
28
|
RunnerPhase.PENDING,
|
|
25
29
|
RunnerPhase.STOPPED,
|
|
26
30
|
RunnerPhase.FAILED,
|
package/src/runner/phase.ts
CHANGED
|
@@ -9,15 +9,17 @@ import { RunnerPhase } from "@stigmer/protos/ai/stigmer/agentic/runner/v1/enum_p
|
|
|
9
9
|
export const PHASE_SORT_ORDER: Record<RunnerPhase, number> = {
|
|
10
10
|
[RunnerPhase.READY]: 0,
|
|
11
11
|
[RunnerPhase.BUSY]: 1,
|
|
12
|
-
[RunnerPhase.
|
|
13
|
-
[RunnerPhase.
|
|
14
|
-
[RunnerPhase.
|
|
15
|
-
[RunnerPhase.
|
|
12
|
+
[RunnerPhase.STARTING]: 2,
|
|
13
|
+
[RunnerPhase.PENDING]: 3,
|
|
14
|
+
[RunnerPhase.STOPPED]: 4,
|
|
15
|
+
[RunnerPhase.FAILED]: 5,
|
|
16
|
+
[RunnerPhase.UNSPECIFIED]: 6,
|
|
16
17
|
};
|
|
17
18
|
|
|
18
19
|
const LABELS: Record<RunnerPhase, string> = {
|
|
19
20
|
[RunnerPhase.READY]: "Ready",
|
|
20
21
|
[RunnerPhase.BUSY]: "Busy",
|
|
22
|
+
[RunnerPhase.STARTING]: "Starting",
|
|
21
23
|
[RunnerPhase.PENDING]: "Pending",
|
|
22
24
|
[RunnerPhase.STOPPED]: "Stopped",
|
|
23
25
|
[RunnerPhase.FAILED]: "Failed",
|
|
@@ -37,9 +39,10 @@ export function phaseLabel(phase: RunnerPhase): string {
|
|
|
37
39
|
/**
|
|
38
40
|
* Tailwind `bg-*` class for the small colored dot indicator.
|
|
39
41
|
*
|
|
40
|
-
* - Ready
|
|
41
|
-
* - Busy
|
|
42
|
-
* -
|
|
42
|
+
* - Ready → `bg-success` (green)
|
|
43
|
+
* - Busy → `bg-warning` (amber)
|
|
44
|
+
* - Starting → `bg-primary` (blue)
|
|
45
|
+
* - Others → `bg-muted-foreground` (neutral)
|
|
43
46
|
*/
|
|
44
47
|
export function phaseDotColor(phase: RunnerPhase): string {
|
|
45
48
|
switch (phase) {
|
|
@@ -47,6 +50,8 @@ export function phaseDotColor(phase: RunnerPhase): string {
|
|
|
47
50
|
return "bg-success";
|
|
48
51
|
case RunnerPhase.BUSY:
|
|
49
52
|
return "bg-warning";
|
|
53
|
+
case RunnerPhase.STARTING:
|
|
54
|
+
return "bg-primary";
|
|
50
55
|
default:
|
|
51
56
|
return "bg-muted-foreground";
|
|
52
57
|
}
|
|
@@ -65,14 +70,15 @@ export function isActivePhase(phase: RunnerPhase): boolean {
|
|
|
65
70
|
* Whether the runner is in a transitional phase whose status is
|
|
66
71
|
* expected to change soon without user action.
|
|
67
72
|
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
73
|
+
* `PENDING` — resource created, no process launched yet.
|
|
74
|
+
* `STARTING` — process launched, runtime bootstrapping in progress.
|
|
75
|
+
*
|
|
76
|
+
* This is distinct from {@link isActivePhase} (READY | BUSY) where
|
|
77
|
+
* the runner is stable and accepting work.
|
|
72
78
|
*
|
|
73
79
|
* Useful for driving conditional polling: poll while transitional
|
|
74
80
|
* runners exist, stop when all runners reach a stable state.
|
|
75
81
|
*/
|
|
76
82
|
export function isTransitionalPhase(phase: RunnerPhase): boolean {
|
|
77
|
-
return phase === RunnerPhase.PENDING;
|
|
83
|
+
return phase === RunnerPhase.PENDING || phase === RunnerPhase.STARTING;
|
|
78
84
|
}
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { renderHook, act } from "@testing-library/react";
|
|
3
|
+
import type { ReactNode } from "react";
|
|
4
|
+
import { Harness } from "@stigmer/protos/ai/stigmer/agentic/session/v1/enum_pb";
|
|
5
|
+
import type { Stigmer } from "@stigmer/sdk";
|
|
6
|
+
import { StigmerContext } from "../../context";
|
|
7
|
+
import { useCreateSession } from "../useCreateSession";
|
|
8
|
+
|
|
9
|
+
function buildMockClient(overrides: {
|
|
10
|
+
sessionCreate?: ReturnType<typeof vi.fn>;
|
|
11
|
+
agentGetByReference?: ReturnType<typeof vi.fn>;
|
|
12
|
+
} = {}) {
|
|
13
|
+
return {
|
|
14
|
+
session: {
|
|
15
|
+
create: overrides.sessionCreate ?? vi.fn(),
|
|
16
|
+
},
|
|
17
|
+
agent: {
|
|
18
|
+
getByReference: overrides.agentGetByReference ?? vi.fn(),
|
|
19
|
+
},
|
|
20
|
+
} as unknown as Stigmer;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function makeWrapper(client: Stigmer) {
|
|
24
|
+
return ({ children }: { children: ReactNode }) => (
|
|
25
|
+
<StigmerContext.Provider value={client}>{children}</StigmerContext.Provider>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function fakeSessionResponse(sessionId: string) {
|
|
30
|
+
return { metadata: { id: sessionId } };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe("useCreateSession", () => {
|
|
34
|
+
let sessionCreateMock: ReturnType<typeof vi.fn>;
|
|
35
|
+
let agentGetByRefMock: ReturnType<typeof vi.fn>;
|
|
36
|
+
let client: Stigmer;
|
|
37
|
+
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
sessionCreateMock = vi.fn();
|
|
40
|
+
agentGetByRefMock = vi.fn();
|
|
41
|
+
client = buildMockClient({
|
|
42
|
+
sessionCreate: sessionCreateMock,
|
|
43
|
+
agentGetByReference: agentGetByRefMock,
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
afterEach(() => {
|
|
48
|
+
vi.restoreAllMocks();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("starts in idle state", () => {
|
|
52
|
+
const { result } = renderHook(() => useCreateSession(), {
|
|
53
|
+
wrapper: makeWrapper(client),
|
|
54
|
+
});
|
|
55
|
+
expect(result.current.isCreating).toBe(false);
|
|
56
|
+
expect(result.current.error).toBeNull();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("agentInstanceId path", () => {
|
|
60
|
+
it("creates a session with the given instance ID", async () => {
|
|
61
|
+
sessionCreateMock.mockResolvedValueOnce(fakeSessionResponse("sess-1"));
|
|
62
|
+
|
|
63
|
+
const { result } = renderHook(() => useCreateSession(), {
|
|
64
|
+
wrapper: makeWrapper(client),
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
let outcome: { sessionId: string } | undefined;
|
|
68
|
+
await act(async () => {
|
|
69
|
+
outcome = await result.current.create({
|
|
70
|
+
org: "acme",
|
|
71
|
+
agentInstanceId: "inst-abc",
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
expect(outcome!.sessionId).toBe("sess-1");
|
|
76
|
+
expect(sessionCreateMock).toHaveBeenCalledOnce();
|
|
77
|
+
expect(sessionCreateMock.mock.calls[0][0]).toMatchObject({
|
|
78
|
+
org: "acme",
|
|
79
|
+
agentInstanceId: "inst-abc",
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe("agentRef path", () => {
|
|
85
|
+
it("resolves the agent reference to its default instance", async () => {
|
|
86
|
+
agentGetByRefMock.mockResolvedValueOnce({
|
|
87
|
+
status: { defaultInstanceId: "inst-resolved" },
|
|
88
|
+
});
|
|
89
|
+
sessionCreateMock.mockResolvedValueOnce(fakeSessionResponse("sess-2"));
|
|
90
|
+
|
|
91
|
+
const { result } = renderHook(() => useCreateSession(), {
|
|
92
|
+
wrapper: makeWrapper(client),
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
await act(async () => {
|
|
96
|
+
await result.current.create({
|
|
97
|
+
org: "acme",
|
|
98
|
+
agentRef: { org: "acme", slug: "my-agent" },
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
expect(agentGetByRefMock).toHaveBeenCalledWith({
|
|
103
|
+
org: "acme",
|
|
104
|
+
slug: "my-agent",
|
|
105
|
+
});
|
|
106
|
+
expect(sessionCreateMock.mock.calls[0][0]).toMatchObject({
|
|
107
|
+
agentInstanceId: "inst-resolved",
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("throws when agent has no default instance", async () => {
|
|
112
|
+
agentGetByRefMock.mockResolvedValueOnce({ status: {} });
|
|
113
|
+
|
|
114
|
+
const { result } = renderHook(() => useCreateSession(), {
|
|
115
|
+
wrapper: makeWrapper(client),
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
await act(async () => {
|
|
119
|
+
await expect(
|
|
120
|
+
result.current.create({
|
|
121
|
+
org: "acme",
|
|
122
|
+
agentRef: { org: "acme", slug: "no-instance" },
|
|
123
|
+
}),
|
|
124
|
+
).rejects.toThrow("does not have a default instance");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
expect(result.current.error).not.toBeNull();
|
|
128
|
+
expect(result.current.error!.message).toContain(
|
|
129
|
+
"does not have a default instance",
|
|
130
|
+
);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe("harness proto conversion", () => {
|
|
135
|
+
it("passes Harness.CURSOR when harness is cursor", async () => {
|
|
136
|
+
sessionCreateMock.mockResolvedValueOnce(fakeSessionResponse("sess-h1"));
|
|
137
|
+
|
|
138
|
+
const { result } = renderHook(() => useCreateSession(), {
|
|
139
|
+
wrapper: makeWrapper(client),
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
await act(async () => {
|
|
143
|
+
await result.current.create({
|
|
144
|
+
org: "acme",
|
|
145
|
+
agentInstanceId: "inst-1",
|
|
146
|
+
harness: "cursor",
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
expect(sessionCreateMock.mock.calls[0][0].harness).toBe(Harness.CURSOR);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("passes Harness.NATIVE when harness is native", async () => {
|
|
154
|
+
sessionCreateMock.mockResolvedValueOnce(fakeSessionResponse("sess-h2"));
|
|
155
|
+
|
|
156
|
+
const { result } = renderHook(() => useCreateSession(), {
|
|
157
|
+
wrapper: makeWrapper(client),
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
await act(async () => {
|
|
161
|
+
await result.current.create({
|
|
162
|
+
org: "acme",
|
|
163
|
+
agentInstanceId: "inst-1",
|
|
164
|
+
harness: "native",
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
expect(sessionCreateMock.mock.calls[0][0].harness).toBe(Harness.NATIVE);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("passes undefined when harness is omitted", async () => {
|
|
172
|
+
sessionCreateMock.mockResolvedValueOnce(fakeSessionResponse("sess-h3"));
|
|
173
|
+
|
|
174
|
+
const { result } = renderHook(() => useCreateSession(), {
|
|
175
|
+
wrapper: makeWrapper(client),
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
await act(async () => {
|
|
179
|
+
await result.current.create({
|
|
180
|
+
org: "acme",
|
|
181
|
+
agentInstanceId: "inst-1",
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
expect(sessionCreateMock.mock.calls[0][0].harness).toBeUndefined();
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
describe("loading state lifecycle", () => {
|
|
190
|
+
it("isCreating is true during the RPC and false after", async () => {
|
|
191
|
+
let resolveCreate!: (v: unknown) => void;
|
|
192
|
+
sessionCreateMock.mockReturnValueOnce(
|
|
193
|
+
new Promise((r) => { resolveCreate = r; }),
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
const { result } = renderHook(() => useCreateSession(), {
|
|
197
|
+
wrapper: makeWrapper(client),
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
let createPromise: Promise<unknown>;
|
|
201
|
+
act(() => {
|
|
202
|
+
createPromise = result.current.create({
|
|
203
|
+
org: "acme",
|
|
204
|
+
agentInstanceId: "inst-1",
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
expect(result.current.isCreating).toBe(true);
|
|
209
|
+
|
|
210
|
+
await act(async () => {
|
|
211
|
+
resolveCreate(fakeSessionResponse("sess-lc"));
|
|
212
|
+
await createPromise;
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
expect(result.current.isCreating).toBe(false);
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
describe("error handling", () => {
|
|
220
|
+
it("sets error on RPC failure and resets isCreating", async () => {
|
|
221
|
+
sessionCreateMock.mockRejectedValueOnce(new Error("network timeout"));
|
|
222
|
+
|
|
223
|
+
const { result } = renderHook(() => useCreateSession(), {
|
|
224
|
+
wrapper: makeWrapper(client),
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
await act(async () => {
|
|
228
|
+
await expect(
|
|
229
|
+
result.current.create({
|
|
230
|
+
org: "acme",
|
|
231
|
+
agentInstanceId: "inst-1",
|
|
232
|
+
}),
|
|
233
|
+
).rejects.toThrow("network timeout");
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
expect(result.current.isCreating).toBe(false);
|
|
237
|
+
expect(result.current.error).toBeInstanceOf(Error);
|
|
238
|
+
expect(result.current.error!.message).toBe("network timeout");
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("clearError resets error to null", async () => {
|
|
242
|
+
sessionCreateMock.mockRejectedValueOnce(new Error("fail"));
|
|
243
|
+
|
|
244
|
+
const { result } = renderHook(() => useCreateSession(), {
|
|
245
|
+
wrapper: makeWrapper(client),
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
await act(async () => {
|
|
249
|
+
try {
|
|
250
|
+
await result.current.create({
|
|
251
|
+
org: "acme",
|
|
252
|
+
agentInstanceId: "inst-1",
|
|
253
|
+
});
|
|
254
|
+
} catch { /* expected */ }
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
expect(result.current.error).not.toBeNull();
|
|
258
|
+
|
|
259
|
+
act(() => {
|
|
260
|
+
result.current.clearError();
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
expect(result.current.error).toBeNull();
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("clears previous error on new create attempt", async () => {
|
|
267
|
+
sessionCreateMock
|
|
268
|
+
.mockRejectedValueOnce(new Error("first fail"))
|
|
269
|
+
.mockResolvedValueOnce(fakeSessionResponse("sess-retry"));
|
|
270
|
+
|
|
271
|
+
const { result } = renderHook(() => useCreateSession(), {
|
|
272
|
+
wrapper: makeWrapper(client),
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
await act(async () => {
|
|
276
|
+
try {
|
|
277
|
+
await result.current.create({
|
|
278
|
+
org: "acme",
|
|
279
|
+
agentInstanceId: "inst-1",
|
|
280
|
+
});
|
|
281
|
+
} catch { /* expected */ }
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
expect(result.current.error).not.toBeNull();
|
|
285
|
+
|
|
286
|
+
await act(async () => {
|
|
287
|
+
await result.current.create({
|
|
288
|
+
org: "acme",
|
|
289
|
+
agentInstanceId: "inst-1",
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
expect(result.current.error).toBeNull();
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
});
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { renderHook, act } from "@testing-library/react";
|
|
3
|
+
import { DEFAULT_MODEL_ID, DEFAULT_CURSOR_MODEL_ID } from "../../models/registry";
|
|
4
|
+
import type { UseCreateSessionReturn } from "../useCreateSession";
|
|
5
|
+
|
|
6
|
+
const mockCreateSession = vi.fn<UseCreateSessionReturn["create"]>();
|
|
7
|
+
vi.mock("../useCreateSession", () => ({
|
|
8
|
+
useCreateSession: () => ({
|
|
9
|
+
create: mockCreateSession,
|
|
10
|
+
isCreating: false,
|
|
11
|
+
error: null,
|
|
12
|
+
clearError: vi.fn(),
|
|
13
|
+
}),
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
const mockCreateExecution = vi.fn();
|
|
17
|
+
vi.mock("../../execution/useCreateAgentExecution", () => ({
|
|
18
|
+
useCreateAgentExecution: () => ({
|
|
19
|
+
create: mockCreateExecution,
|
|
20
|
+
isCreating: false,
|
|
21
|
+
error: null,
|
|
22
|
+
clearError: vi.fn(),
|
|
23
|
+
}),
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
const mockDefaultAgent = {
|
|
27
|
+
agent: null as { status?: { defaultInstanceId?: string } } | null,
|
|
28
|
+
isLoading: false,
|
|
29
|
+
error: null,
|
|
30
|
+
refetch: vi.fn(),
|
|
31
|
+
};
|
|
32
|
+
vi.mock("../../agent", () => ({
|
|
33
|
+
useDefaultAgent: () => mockDefaultAgent,
|
|
34
|
+
}));
|
|
35
|
+
|
|
36
|
+
const mockWorkspace = {
|
|
37
|
+
entries: [],
|
|
38
|
+
hasEntries: false,
|
|
39
|
+
toInput: vi.fn().mockReturnValue([]),
|
|
40
|
+
addGitRepo: vi.fn(),
|
|
41
|
+
addLocalPath: vi.fn(),
|
|
42
|
+
removeEntry: vi.fn(),
|
|
43
|
+
clear: vi.fn(),
|
|
44
|
+
};
|
|
45
|
+
vi.mock("../../workspace", () => ({
|
|
46
|
+
useWorkspaceEntries: () => mockWorkspace,
|
|
47
|
+
}));
|
|
48
|
+
|
|
49
|
+
const mockSessionVariables = {
|
|
50
|
+
variables: [],
|
|
51
|
+
hasVariables: false,
|
|
52
|
+
setVariable: vi.fn(),
|
|
53
|
+
removeVariable: vi.fn(),
|
|
54
|
+
clear: vi.fn(),
|
|
55
|
+
toMap: vi.fn().mockReturnValue(new Map()),
|
|
56
|
+
};
|
|
57
|
+
vi.mock("../../execution/useSessionVariables", () => ({
|
|
58
|
+
useSessionVariables: () => mockSessionVariables,
|
|
59
|
+
}));
|
|
60
|
+
|
|
61
|
+
import { useNewSessionFlow } from "../useNewSessionFlow";
|
|
62
|
+
|
|
63
|
+
const STORAGE_KEY_HARNESS = "stigmer:session:harness";
|
|
64
|
+
const STORAGE_KEY_MODEL_NATIVE = "stigmer:session:model";
|
|
65
|
+
const STORAGE_KEY_MODEL_CURSOR = "stigmer:session:model:cursor";
|
|
66
|
+
|
|
67
|
+
function defaultOptions() {
|
|
68
|
+
return {
|
|
69
|
+
org: "acme",
|
|
70
|
+
onSessionCreated: vi.fn(),
|
|
71
|
+
onError: vi.fn(),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
describe("useNewSessionFlow", () => {
|
|
76
|
+
beforeEach(() => {
|
|
77
|
+
localStorage.clear();
|
|
78
|
+
mockDefaultAgent.agent = {
|
|
79
|
+
status: { defaultInstanceId: "default-inst" },
|
|
80
|
+
};
|
|
81
|
+
mockDefaultAgent.isLoading = false;
|
|
82
|
+
mockDefaultAgent.error = null;
|
|
83
|
+
mockCreateSession.mockResolvedValue({ sessionId: "sess-new" });
|
|
84
|
+
mockCreateExecution.mockResolvedValue({});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
afterEach(() => {
|
|
88
|
+
vi.restoreAllMocks();
|
|
89
|
+
localStorage.clear();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe("harness state", () => {
|
|
93
|
+
it("defaults to native when localStorage is empty", () => {
|
|
94
|
+
const { result } = renderHook(() => useNewSessionFlow(defaultOptions()));
|
|
95
|
+
expect(result.current.harness).toBe("native");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("restores cursor harness from localStorage", () => {
|
|
99
|
+
localStorage.setItem(STORAGE_KEY_HARNESS, "cursor");
|
|
100
|
+
const { result } = renderHook(() => useNewSessionFlow(defaultOptions()));
|
|
101
|
+
expect(result.current.harness).toBe("cursor");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("falls back to native for unknown localStorage values", () => {
|
|
105
|
+
localStorage.setItem(STORAGE_KEY_HARNESS, "unknown-value");
|
|
106
|
+
const { result } = renderHook(() => useNewSessionFlow(defaultOptions()));
|
|
107
|
+
expect(result.current.harness).toBe("native");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("persists harness to localStorage on change", () => {
|
|
111
|
+
const { result } = renderHook(() => useNewSessionFlow(defaultOptions()));
|
|
112
|
+
|
|
113
|
+
act(() => result.current.setHarness("cursor"));
|
|
114
|
+
|
|
115
|
+
expect(localStorage.getItem(STORAGE_KEY_HARNESS)).toBe("cursor");
|
|
116
|
+
expect(result.current.harness).toBe("cursor");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("persists native harness to localStorage", () => {
|
|
120
|
+
localStorage.setItem(STORAGE_KEY_HARNESS, "cursor");
|
|
121
|
+
const { result } = renderHook(() => useNewSessionFlow(defaultOptions()));
|
|
122
|
+
|
|
123
|
+
act(() => result.current.setHarness("native"));
|
|
124
|
+
|
|
125
|
+
expect(localStorage.getItem(STORAGE_KEY_HARNESS)).toBe("native");
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe("per-harness model persistence", () => {
|
|
130
|
+
it("uses separate storage keys for native and cursor models", () => {
|
|
131
|
+
const { result } = renderHook(() => useNewSessionFlow(defaultOptions()));
|
|
132
|
+
|
|
133
|
+
act(() => result.current.setModelId(DEFAULT_MODEL_ID));
|
|
134
|
+
|
|
135
|
+
expect(localStorage.getItem(STORAGE_KEY_MODEL_NATIVE)).toBe(
|
|
136
|
+
DEFAULT_MODEL_ID,
|
|
137
|
+
);
|
|
138
|
+
expect(localStorage.getItem(STORAGE_KEY_MODEL_CURSOR)).toBeNull();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("persists cursor model to cursor-specific key", () => {
|
|
142
|
+
localStorage.setItem(STORAGE_KEY_HARNESS, "cursor");
|
|
143
|
+
const { result } = renderHook(() => useNewSessionFlow(defaultOptions()));
|
|
144
|
+
|
|
145
|
+
act(() => result.current.setModelId(DEFAULT_CURSOR_MODEL_ID));
|
|
146
|
+
|
|
147
|
+
expect(localStorage.getItem(STORAGE_KEY_MODEL_CURSOR)).toBe(
|
|
148
|
+
DEFAULT_CURSOR_MODEL_ID,
|
|
149
|
+
);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("restores per-harness model when switching harness", () => {
|
|
153
|
+
localStorage.setItem(STORAGE_KEY_MODEL_CURSOR, DEFAULT_CURSOR_MODEL_ID);
|
|
154
|
+
|
|
155
|
+
const { result } = renderHook(() => useNewSessionFlow(defaultOptions()));
|
|
156
|
+
|
|
157
|
+
act(() => result.current.setHarness("cursor"));
|
|
158
|
+
|
|
159
|
+
expect(result.current.modelId).toBe(DEFAULT_CURSOR_MODEL_ID);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("clears modelId when switching to a harness with no stored model", () => {
|
|
163
|
+
const { result } = renderHook(() => useNewSessionFlow(defaultOptions()));
|
|
164
|
+
|
|
165
|
+
act(() => result.current.setModelId(DEFAULT_MODEL_ID));
|
|
166
|
+
expect(result.current.modelId).toBe(DEFAULT_MODEL_ID);
|
|
167
|
+
|
|
168
|
+
act(() => result.current.setHarness("cursor"));
|
|
169
|
+
|
|
170
|
+
// No stored cursor model → modelId should be undefined
|
|
171
|
+
// (unless the model happens to be valid in the cursor registry)
|
|
172
|
+
if (result.current.modelId !== undefined) {
|
|
173
|
+
// If it has a value, it must be valid for cursor harness
|
|
174
|
+
expect(result.current.modelId).toBe(
|
|
175
|
+
localStorage.getItem(STORAGE_KEY_MODEL_CURSOR),
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("invalidates modelId when it is not in the active harness registry", () => {
|
|
181
|
+
const { result } = renderHook(() => useNewSessionFlow(defaultOptions()));
|
|
182
|
+
|
|
183
|
+
// Set a native-only model
|
|
184
|
+
act(() => result.current.setModelId(DEFAULT_MODEL_ID));
|
|
185
|
+
expect(result.current.modelId).toBe(DEFAULT_MODEL_ID);
|
|
186
|
+
|
|
187
|
+
// Switch to cursor → native model should be invalidated
|
|
188
|
+
act(() => result.current.setHarness("cursor"));
|
|
189
|
+
|
|
190
|
+
// DEFAULT_MODEL_ID (anthropic) is not in cursor registry
|
|
191
|
+
expect(result.current.modelId).not.toBe(DEFAULT_MODEL_ID);
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
describe("submit with harness", () => {
|
|
196
|
+
it("passes harness field to createSession", async () => {
|
|
197
|
+
const opts = defaultOptions();
|
|
198
|
+
const { result } = renderHook(() => useNewSessionFlow(opts));
|
|
199
|
+
|
|
200
|
+
await act(async () => {
|
|
201
|
+
await result.current.submit("Hello");
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
expect(mockCreateSession).toHaveBeenCalledOnce();
|
|
205
|
+
const sessionInput = mockCreateSession.mock.calls[0][0];
|
|
206
|
+
expect(sessionInput.harness).toBe("native");
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("passes cursor harness to createSession after switching", async () => {
|
|
210
|
+
const opts = defaultOptions();
|
|
211
|
+
const { result } = renderHook(() => useNewSessionFlow(opts));
|
|
212
|
+
|
|
213
|
+
act(() => result.current.setHarness("cursor"));
|
|
214
|
+
|
|
215
|
+
await act(async () => {
|
|
216
|
+
await result.current.submit("Hello");
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
const sessionInput = mockCreateSession.mock.calls[0][0];
|
|
220
|
+
expect(sessionInput.harness).toBe("cursor");
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("calls onSessionCreated on success", async () => {
|
|
224
|
+
const opts = defaultOptions();
|
|
225
|
+
const { result } = renderHook(() => useNewSessionFlow(opts));
|
|
226
|
+
|
|
227
|
+
await act(async () => {
|
|
228
|
+
await result.current.submit("Hello");
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
expect(opts.onSessionCreated).toHaveBeenCalledWith("sess-new");
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("sets submitError and calls onError on failure", async () => {
|
|
235
|
+
mockCreateSession.mockRejectedValueOnce(new Error("RPC fail"));
|
|
236
|
+
const opts = defaultOptions();
|
|
237
|
+
const { result } = renderHook(() => useNewSessionFlow(opts));
|
|
238
|
+
|
|
239
|
+
await act(async () => {
|
|
240
|
+
await result.current.submit("Hello");
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
expect(result.current.submitError).not.toBeNull();
|
|
244
|
+
expect(opts.onError).toHaveBeenCalled();
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("resets isSubmitting after completion", async () => {
|
|
248
|
+
const opts = defaultOptions();
|
|
249
|
+
const { result } = renderHook(() => useNewSessionFlow(opts));
|
|
250
|
+
|
|
251
|
+
await act(async () => {
|
|
252
|
+
await result.current.submit("Hello");
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
expect(result.current.isSubmitting).toBe(false);
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
});
|
|
@@ -302,4 +302,57 @@ describe("useSessionConversation", () => {
|
|
|
302
302
|
expect(result.current.pendingUserMessage).toBeNull();
|
|
303
303
|
});
|
|
304
304
|
});
|
|
305
|
+
|
|
306
|
+
it("full follow-up lifecycle: active execution completes → canSendFollowUp → sendFollowUp succeeds", async () => {
|
|
307
|
+
// Start with one IN_PROGRESS execution (simulates a Cursor execution running)
|
|
308
|
+
const activeExec = makeExecution("exec-1", ExecutionPhase.EXECUTION_IN_PROGRESS);
|
|
309
|
+
methods.listBySession.mockResolvedValue({ entries: [activeExec] });
|
|
310
|
+
|
|
311
|
+
const execStream = createControllableStream<AgentExecution>();
|
|
312
|
+
methods.subscribe.mockReturnValue(execStream.generator);
|
|
313
|
+
|
|
314
|
+
const { result } = renderHook(
|
|
315
|
+
() => useSessionConversation("session-1", "org"),
|
|
316
|
+
{ wrapper: createWrapper(mockStigmer) },
|
|
317
|
+
);
|
|
318
|
+
|
|
319
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
320
|
+
|
|
321
|
+
// Initially canSendFollowUp should be false (execution is active)
|
|
322
|
+
expect(result.current.canSendFollowUp).toBe(false);
|
|
323
|
+
|
|
324
|
+
// Stream delivers EXECUTION_COMPLETED — simulating Cursor run finishing
|
|
325
|
+
const completedExec = makeExecution("exec-1", ExecutionPhase.EXECUTION_COMPLETED);
|
|
326
|
+
act(() => {
|
|
327
|
+
execStream.push(completedExec);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
// After stream completes, the hook should refetch executions.
|
|
331
|
+
// Mock the refetch to return the completed execution.
|
|
332
|
+
methods.listBySession.mockResolvedValue({ entries: [completedExec] });
|
|
333
|
+
|
|
334
|
+
// Wait for canSendFollowUp to become true
|
|
335
|
+
await waitFor(() => {
|
|
336
|
+
expect(result.current.canSendFollowUp).toBe(true);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
// Now send a follow-up message
|
|
340
|
+
const followUpExec = makeExecution("exec-2", ExecutionPhase.EXECUTION_PENDING);
|
|
341
|
+
followUpExec.metadata!.id = "exec-2";
|
|
342
|
+
methods.executionCreate.mockResolvedValue(followUpExec);
|
|
343
|
+
|
|
344
|
+
await act(async () => {
|
|
345
|
+
await result.current.sendFollowUp("Follow-up message", { modelName: "default" });
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
// Verify the create call was made with the correct session and message
|
|
349
|
+
expect(methods.executionCreate).toHaveBeenCalledWith(
|
|
350
|
+
expect.objectContaining({
|
|
351
|
+
org: "org",
|
|
352
|
+
sessionId: "session-1",
|
|
353
|
+
message: "Follow-up message",
|
|
354
|
+
executionConfig: expect.objectContaining({ modelName: "default" }),
|
|
355
|
+
}),
|
|
356
|
+
);
|
|
357
|
+
});
|
|
305
358
|
});
|