@gethmy/mcp 2.0.0 → 2.1.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/README.md +6 -1
- package/dist/cli.js +711 -59
- package/dist/index.js +5 -3
- package/dist/lib/__tests__/active-learning.test.js +386 -0
- package/dist/lib/__tests__/agent-performance-profiles.test.js +325 -0
- package/dist/lib/__tests__/auto-session.test.js +661 -0
- package/dist/lib/__tests__/context-assembly.test.js +362 -0
- package/dist/lib/__tests__/graph-expansion.test.js +150 -0
- package/dist/lib/__tests__/integration-memory-crud.test.js +797 -0
- package/dist/lib/__tests__/integration-memory-system.test.js +281 -0
- package/dist/lib/__tests__/lifecycle-maintenance.test.js +207 -0
- package/dist/lib/__tests__/pattern-detection.test.js +295 -0
- package/dist/lib/__tests__/prompt-builder.test.js +418 -0
- package/dist/lib/active-learning.js +878 -0
- package/dist/lib/api-client.js +550 -0
- package/dist/lib/auto-session.js +173 -0
- package/dist/lib/cli.js +127 -0
- package/dist/lib/config.js +205 -0
- package/dist/lib/consolidation.js +243 -0
- package/dist/lib/context-assembly.js +606 -0
- package/dist/lib/graph-expansion.js +163 -0
- package/dist/lib/http.js +174 -0
- package/dist/lib/index.js +7 -0
- package/dist/lib/lifecycle-maintenance.js +88 -0
- package/dist/lib/prompt-builder.js +483 -0
- package/dist/lib/remote.js +166 -0
- package/dist/lib/server.js +3132 -0
- package/dist/lib/tui/agents.js +116 -0
- package/dist/lib/tui/docs.js +744 -0
- package/dist/lib/tui/setup.js +1068 -0
- package/dist/lib/tui/theme.js +95 -0
- package/dist/lib/tui/writer.js +200 -0
- package/package.json +15 -6
- package/src/__tests__/active-learning.test.ts +483 -0
- package/src/__tests__/agent-performance-profiles.test.ts +468 -0
- package/src/__tests__/auto-session.test.ts +912 -0
- package/src/__tests__/context-assembly.test.ts +506 -0
- package/src/__tests__/graph-expansion.test.ts +285 -0
- package/src/__tests__/integration-memory-crud.test.ts +948 -0
- package/src/__tests__/integration-memory-system.test.ts +321 -0
- package/src/__tests__/lifecycle-maintenance.test.ts +238 -0
- package/src/__tests__/pattern-detection.test.ts +438 -0
- package/src/__tests__/prompt-builder.test.ts +505 -0
- package/src/active-learning.ts +1227 -0
- package/src/api-client.ts +969 -0
- package/src/auto-session.ts +218 -0
- package/src/cli.ts +166 -0
- package/src/config.ts +285 -0
- package/src/consolidation.ts +314 -0
- package/src/context-assembly.ts +842 -0
- package/src/graph-expansion.ts +234 -0
- package/src/http.ts +265 -0
- package/src/index.ts +8 -0
- package/src/lifecycle-maintenance.ts +120 -0
- package/src/prompt-builder.ts +681 -0
- package/src/remote.ts +227 -0
- package/src/server.ts +3858 -0
- package/src/tui/agents.ts +154 -0
- package/src/tui/docs.ts +863 -0
- package/src/tui/setup.ts +1281 -0
- package/src/tui/theme.ts +114 -0
- package/src/tui/writer.ts +260 -0
|
@@ -0,0 +1,661 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the Auto-Session tracking system.
|
|
3
|
+
*
|
|
4
|
+
* Run with: bun test packages/mcp-server/src/__tests__/auto-session.test.ts
|
|
5
|
+
*/
|
|
6
|
+
import { afterEach, describe, expect, mock, test } from "bun:test";
|
|
7
|
+
import { AUTO_START_TRIGGERS, checkInactivity, destroyAutoSession, getActiveSessions, INACTIVITY_TIMEOUT_MS, initAutoSession, markExplicit, shutdownAllSessions, trackActivity, untrack, } from "../auto-session.js";
|
|
8
|
+
function makeMockClient() {
|
|
9
|
+
return {
|
|
10
|
+
startAgentSession: mock(async () => ({ session: { id: "sess-1" } })),
|
|
11
|
+
endAgentSession: mock(async () => ({ session: { id: "sess-1" } })),
|
|
12
|
+
getCard: mock(async () => ({
|
|
13
|
+
card: { id: "card-1", title: "Test", labels: [], subtasks: [] },
|
|
14
|
+
})),
|
|
15
|
+
getAgentSession: mock(async () => ({ session: null })),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
const CARD_A = "aaaaaaaa-1111-1111-1111-111111111111";
|
|
19
|
+
const CARD_B = "bbbbbbbb-2222-2222-2222-222222222222";
|
|
20
|
+
const CARD_C = "cccccccc-3333-3333-3333-333333333333";
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
destroyAutoSession();
|
|
23
|
+
});
|
|
24
|
+
// ─── Basic auto-start ──────────────────────────────────────────────
|
|
25
|
+
describe("auto-start", () => {
|
|
26
|
+
test("triggers session on autoStart=true with cardId", async () => {
|
|
27
|
+
const client = makeMockClient();
|
|
28
|
+
initAutoSession(mock(async () => { }), () => client);
|
|
29
|
+
await trackActivity(CARD_A, { autoStart: true, client: client });
|
|
30
|
+
expect(client.startAgentSession).toHaveBeenCalledTimes(1);
|
|
31
|
+
expect(client.startAgentSession).toHaveBeenCalledWith(CARD_A, {
|
|
32
|
+
agentIdentifier: "auto",
|
|
33
|
+
agentName: "Auto-detected Agent",
|
|
34
|
+
status: "working",
|
|
35
|
+
});
|
|
36
|
+
expect(getActiveSessions().size).toBe(1);
|
|
37
|
+
const session = getActiveSessions().get(CARD_A);
|
|
38
|
+
expect(session).toBeDefined();
|
|
39
|
+
expect(session.isExplicit).toBe(false);
|
|
40
|
+
expect(session.agentIdentifier).toBe("auto");
|
|
41
|
+
expect(session.agentName).toBe("Auto-detected Agent");
|
|
42
|
+
});
|
|
43
|
+
test("does NOT trigger on autoStart=false", async () => {
|
|
44
|
+
const client = makeMockClient();
|
|
45
|
+
initAutoSession(mock(async () => { }), () => client);
|
|
46
|
+
await trackActivity(CARD_A, { autoStart: false, client: client });
|
|
47
|
+
expect(client.startAgentSession).not.toHaveBeenCalled();
|
|
48
|
+
expect(getActiveSessions().size).toBe(0);
|
|
49
|
+
});
|
|
50
|
+
test("does NOT trigger when options is undefined", async () => {
|
|
51
|
+
const client = makeMockClient();
|
|
52
|
+
initAutoSession(mock(async () => { }), () => client);
|
|
53
|
+
await trackActivity(CARD_A);
|
|
54
|
+
expect(client.startAgentSession).not.toHaveBeenCalled();
|
|
55
|
+
expect(getActiveSessions().size).toBe(0);
|
|
56
|
+
});
|
|
57
|
+
test("does NOT trigger when no client is available", async () => {
|
|
58
|
+
// initAutoSession not called — no clientGetter
|
|
59
|
+
await trackActivity(CARD_A, { autoStart: true });
|
|
60
|
+
expect(getActiveSessions().size).toBe(0);
|
|
61
|
+
});
|
|
62
|
+
test("still tracks locally when startAgentSession API throws", async () => {
|
|
63
|
+
const client = makeMockClient();
|
|
64
|
+
client.startAgentSession = mock(async () => {
|
|
65
|
+
throw new Error("Session already exists");
|
|
66
|
+
});
|
|
67
|
+
initAutoSession(mock(async () => { }), () => client);
|
|
68
|
+
await trackActivity(CARD_A, { autoStart: true, client: client });
|
|
69
|
+
// Should still be tracked despite API error
|
|
70
|
+
expect(getActiveSessions().size).toBe(1);
|
|
71
|
+
expect(getActiveSessions().get(CARD_A).agentIdentifier).toBe("auto");
|
|
72
|
+
});
|
|
73
|
+
test("uses clientGetter when no client in options", async () => {
|
|
74
|
+
const client = makeMockClient();
|
|
75
|
+
initAutoSession(mock(async () => { }), () => client);
|
|
76
|
+
await trackActivity(CARD_A, { autoStart: true });
|
|
77
|
+
expect(client.startAgentSession).toHaveBeenCalledTimes(1);
|
|
78
|
+
expect(getActiveSessions().size).toBe(1);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
// ─── Activity tracking ─────────────────────────────────────────────
|
|
82
|
+
describe("activity tracking", () => {
|
|
83
|
+
test("repeated activity updates lastActivityAt without re-starting", async () => {
|
|
84
|
+
const client = makeMockClient();
|
|
85
|
+
initAutoSession(mock(async () => { }), () => client);
|
|
86
|
+
await trackActivity(CARD_A, { autoStart: true, client: client });
|
|
87
|
+
const firstActivity = getActiveSessions().get(CARD_A).lastActivityAt;
|
|
88
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
89
|
+
await trackActivity(CARD_A, { autoStart: true, client: client });
|
|
90
|
+
expect(client.startAgentSession).toHaveBeenCalledTimes(1);
|
|
91
|
+
expect(getActiveSessions().get(CARD_A).lastActivityAt).toBeGreaterThan(firstActivity);
|
|
92
|
+
});
|
|
93
|
+
test("activity on existing session works even without autoStart flag", async () => {
|
|
94
|
+
const client = makeMockClient();
|
|
95
|
+
initAutoSession(mock(async () => { }), () => client);
|
|
96
|
+
await trackActivity(CARD_A, { autoStart: true, client: client });
|
|
97
|
+
const firstActivity = getActiveSessions().get(CARD_A).lastActivityAt;
|
|
98
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
99
|
+
// Second call with autoStart=false — should still update timestamp
|
|
100
|
+
await trackActivity(CARD_A, { autoStart: false });
|
|
101
|
+
expect(getActiveSessions().get(CARD_A).lastActivityAt).toBeGreaterThan(firstActivity);
|
|
102
|
+
});
|
|
103
|
+
test("activity on existing explicit session updates timestamp", async () => {
|
|
104
|
+
const client = makeMockClient();
|
|
105
|
+
initAutoSession(mock(async () => { }), () => client);
|
|
106
|
+
markExplicit(CARD_A);
|
|
107
|
+
const firstActivity = getActiveSessions().get(CARD_A).lastActivityAt;
|
|
108
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
109
|
+
await trackActivity(CARD_A, { autoStart: true, client: client });
|
|
110
|
+
expect(getActiveSessions().get(CARD_A).lastActivityAt).toBeGreaterThan(firstActivity);
|
|
111
|
+
// Should NOT have called startAgentSession (session already exists)
|
|
112
|
+
expect(client.startAgentSession).not.toHaveBeenCalled();
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
// ─── Card switching ─────────────────────────────────────────────────
|
|
116
|
+
describe("card switching", () => {
|
|
117
|
+
test("switching cards auto-ends previous auto-session", async () => {
|
|
118
|
+
const client = makeMockClient();
|
|
119
|
+
const endCb = mock(async () => { });
|
|
120
|
+
initAutoSession(endCb, () => client);
|
|
121
|
+
await trackActivity(CARD_A, { autoStart: true, client: client });
|
|
122
|
+
expect(getActiveSessions().size).toBe(1);
|
|
123
|
+
await trackActivity(CARD_B, { autoStart: true, client: client });
|
|
124
|
+
expect(client.endAgentSession).toHaveBeenCalledWith(CARD_A, {
|
|
125
|
+
status: "completed",
|
|
126
|
+
});
|
|
127
|
+
expect(endCb).toHaveBeenCalledWith(client, CARD_A, "completed");
|
|
128
|
+
expect(getActiveSessions().has(CARD_A)).toBe(false);
|
|
129
|
+
expect(getActiveSessions().has(CARD_B)).toBe(true);
|
|
130
|
+
});
|
|
131
|
+
test("explicit sessions are NOT auto-ended by card switching", async () => {
|
|
132
|
+
const client = makeMockClient();
|
|
133
|
+
const endCb = mock(async () => { });
|
|
134
|
+
initAutoSession(endCb, () => client);
|
|
135
|
+
await trackActivity(CARD_A, { autoStart: true, client: client });
|
|
136
|
+
markExplicit(CARD_A);
|
|
137
|
+
await trackActivity(CARD_B, { autoStart: true, client: client });
|
|
138
|
+
// Card A should still be tracked (explicit)
|
|
139
|
+
expect(getActiveSessions().has(CARD_A)).toBe(true);
|
|
140
|
+
expect(getActiveSessions().has(CARD_B)).toBe(true);
|
|
141
|
+
expect(client.endAgentSession).not.toHaveBeenCalled();
|
|
142
|
+
expect(endCb).not.toHaveBeenCalled();
|
|
143
|
+
});
|
|
144
|
+
test("switching from card A to B to C ends A then B correctly", async () => {
|
|
145
|
+
const client = makeMockClient();
|
|
146
|
+
const endCb = mock(async () => { });
|
|
147
|
+
initAutoSession(endCb, () => client);
|
|
148
|
+
await trackActivity(CARD_A, { autoStart: true, client: client });
|
|
149
|
+
await trackActivity(CARD_B, { autoStart: true, client: client });
|
|
150
|
+
// A should be ended, B should be active
|
|
151
|
+
expect(client.endAgentSession).toHaveBeenCalledWith(CARD_A, {
|
|
152
|
+
status: "completed",
|
|
153
|
+
});
|
|
154
|
+
expect(getActiveSessions().has(CARD_A)).toBe(false);
|
|
155
|
+
expect(getActiveSessions().has(CARD_B)).toBe(true);
|
|
156
|
+
client.endAgentSession.mockClear();
|
|
157
|
+
endCb.mockClear();
|
|
158
|
+
await trackActivity(CARD_C, { autoStart: true, client: client });
|
|
159
|
+
// B should be ended, C should be active
|
|
160
|
+
expect(client.endAgentSession).toHaveBeenCalledWith(CARD_B, {
|
|
161
|
+
status: "completed",
|
|
162
|
+
});
|
|
163
|
+
expect(getActiveSessions().has(CARD_B)).toBe(false);
|
|
164
|
+
expect(getActiveSessions().has(CARD_C)).toBe(true);
|
|
165
|
+
});
|
|
166
|
+
test("card switch with endAgentSession API error still cleans up tracking", async () => {
|
|
167
|
+
const client = makeMockClient();
|
|
168
|
+
client.endAgentSession = mock(async () => {
|
|
169
|
+
throw new Error("API unavailable");
|
|
170
|
+
});
|
|
171
|
+
const endCb = mock(async () => { });
|
|
172
|
+
initAutoSession(endCb, () => client);
|
|
173
|
+
await trackActivity(CARD_A, { autoStart: true, client: client });
|
|
174
|
+
await trackActivity(CARD_B, { autoStart: true, client: client });
|
|
175
|
+
// Card A should still be removed from tracking despite API error
|
|
176
|
+
expect(getActiveSessions().has(CARD_A)).toBe(false);
|
|
177
|
+
expect(getActiveSessions().has(CARD_B)).toBe(true);
|
|
178
|
+
// Callback should still run even though endAgentSession threw
|
|
179
|
+
expect(endCb).toHaveBeenCalledWith(client, CARD_A, "completed");
|
|
180
|
+
});
|
|
181
|
+
test("card switch with endCallback error still cleans up tracking", async () => {
|
|
182
|
+
const client = makeMockClient();
|
|
183
|
+
const endCb = mock(async () => {
|
|
184
|
+
throw new Error("Pipeline failed");
|
|
185
|
+
});
|
|
186
|
+
initAutoSession(endCb, () => client);
|
|
187
|
+
await trackActivity(CARD_A, { autoStart: true, client: client });
|
|
188
|
+
await trackActivity(CARD_B, { autoStart: true, client: client });
|
|
189
|
+
// Card A should be cleaned up despite callback error
|
|
190
|
+
expect(getActiveSessions().has(CARD_A)).toBe(false);
|
|
191
|
+
expect(getActiveSessions().has(CARD_B)).toBe(true);
|
|
192
|
+
expect(client.endAgentSession).toHaveBeenCalledWith(CARD_A, {
|
|
193
|
+
status: "completed",
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
test("card switch with explicit session on A only ends non-explicit sessions", async () => {
|
|
197
|
+
const client = makeMockClient();
|
|
198
|
+
const endCb = mock(async () => { });
|
|
199
|
+
initAutoSession(endCb, () => client);
|
|
200
|
+
// A is explicit, B is auto
|
|
201
|
+
markExplicit(CARD_A);
|
|
202
|
+
await trackActivity(CARD_B, { autoStart: true, client: client });
|
|
203
|
+
// Now switch to C — only B should be ended, A stays
|
|
204
|
+
await trackActivity(CARD_C, { autoStart: true, client: client });
|
|
205
|
+
expect(getActiveSessions().has(CARD_A)).toBe(true); // explicit, untouched
|
|
206
|
+
expect(getActiveSessions().has(CARD_B)).toBe(false); // auto-ended
|
|
207
|
+
expect(getActiveSessions().has(CARD_C)).toBe(true); // new auto
|
|
208
|
+
expect(client.endAgentSession).toHaveBeenCalledWith(CARD_B, {
|
|
209
|
+
status: "completed",
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
// ─── Inactivity timeout ─────────────────────────────────────────────
|
|
214
|
+
describe("inactivity timeout", () => {
|
|
215
|
+
test("checkInactivity ends sessions past the timeout", async () => {
|
|
216
|
+
const client = makeMockClient();
|
|
217
|
+
const endCb = mock(async () => { });
|
|
218
|
+
initAutoSession(endCb, () => client);
|
|
219
|
+
// Manually add a session with old lastActivityAt
|
|
220
|
+
getActiveSessions().set(CARD_A, {
|
|
221
|
+
cardId: CARD_A,
|
|
222
|
+
startedAt: Date.now() - INACTIVITY_TIMEOUT_MS - 1000,
|
|
223
|
+
lastActivityAt: Date.now() - INACTIVITY_TIMEOUT_MS - 1000,
|
|
224
|
+
isExplicit: false,
|
|
225
|
+
agentIdentifier: "auto",
|
|
226
|
+
agentName: "Auto-detected Agent",
|
|
227
|
+
});
|
|
228
|
+
checkInactivity();
|
|
229
|
+
// autoEndSession is fire-and-forget in checkInactivity, wait for it
|
|
230
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
231
|
+
expect(client.endAgentSession).toHaveBeenCalledWith(CARD_A, {
|
|
232
|
+
status: "completed",
|
|
233
|
+
});
|
|
234
|
+
expect(getActiveSessions().has(CARD_A)).toBe(false);
|
|
235
|
+
});
|
|
236
|
+
test("checkInactivity does NOT end sessions within timeout", () => {
|
|
237
|
+
const client = makeMockClient();
|
|
238
|
+
initAutoSession(mock(async () => { }), () => client);
|
|
239
|
+
getActiveSessions().set(CARD_A, {
|
|
240
|
+
cardId: CARD_A,
|
|
241
|
+
startedAt: Date.now(),
|
|
242
|
+
lastActivityAt: Date.now(), // just now — well within timeout
|
|
243
|
+
isExplicit: false,
|
|
244
|
+
agentIdentifier: "auto",
|
|
245
|
+
agentName: "Auto-detected Agent",
|
|
246
|
+
});
|
|
247
|
+
checkInactivity();
|
|
248
|
+
expect(client.endAgentSession).not.toHaveBeenCalled();
|
|
249
|
+
expect(getActiveSessions().has(CARD_A)).toBe(true);
|
|
250
|
+
});
|
|
251
|
+
test("checkInactivity skips explicit sessions even if timed out", () => {
|
|
252
|
+
const client = makeMockClient();
|
|
253
|
+
initAutoSession(mock(async () => { }), () => client);
|
|
254
|
+
getActiveSessions().set(CARD_A, {
|
|
255
|
+
cardId: CARD_A,
|
|
256
|
+
startedAt: Date.now() - INACTIVITY_TIMEOUT_MS - 60000,
|
|
257
|
+
lastActivityAt: Date.now() - INACTIVITY_TIMEOUT_MS - 60000,
|
|
258
|
+
isExplicit: true,
|
|
259
|
+
agentIdentifier: "explicit",
|
|
260
|
+
agentName: "Explicit Agent",
|
|
261
|
+
});
|
|
262
|
+
checkInactivity();
|
|
263
|
+
expect(client.endAgentSession).not.toHaveBeenCalled();
|
|
264
|
+
expect(getActiveSessions().has(CARD_A)).toBe(true);
|
|
265
|
+
});
|
|
266
|
+
test("checkInactivity handles mix of timed-out and active sessions", async () => {
|
|
267
|
+
const client = makeMockClient();
|
|
268
|
+
const endCb = mock(async () => { });
|
|
269
|
+
initAutoSession(endCb, () => client);
|
|
270
|
+
// CARD_A — timed out
|
|
271
|
+
getActiveSessions().set(CARD_A, {
|
|
272
|
+
cardId: CARD_A,
|
|
273
|
+
startedAt: Date.now() - INACTIVITY_TIMEOUT_MS - 5000,
|
|
274
|
+
lastActivityAt: Date.now() - INACTIVITY_TIMEOUT_MS - 5000,
|
|
275
|
+
isExplicit: false,
|
|
276
|
+
agentIdentifier: "auto",
|
|
277
|
+
agentName: "Auto-detected Agent",
|
|
278
|
+
});
|
|
279
|
+
// CARD_B — still active
|
|
280
|
+
getActiveSessions().set(CARD_B, {
|
|
281
|
+
cardId: CARD_B,
|
|
282
|
+
startedAt: Date.now() - 1000,
|
|
283
|
+
lastActivityAt: Date.now() - 1000,
|
|
284
|
+
isExplicit: false,
|
|
285
|
+
agentIdentifier: "auto",
|
|
286
|
+
agentName: "Auto-detected Agent",
|
|
287
|
+
});
|
|
288
|
+
// CARD_C — timed out but explicit
|
|
289
|
+
getActiveSessions().set(CARD_C, {
|
|
290
|
+
cardId: CARD_C,
|
|
291
|
+
startedAt: Date.now() - INACTIVITY_TIMEOUT_MS - 5000,
|
|
292
|
+
lastActivityAt: Date.now() - INACTIVITY_TIMEOUT_MS - 5000,
|
|
293
|
+
isExplicit: true,
|
|
294
|
+
agentIdentifier: "explicit",
|
|
295
|
+
agentName: "Explicit Agent",
|
|
296
|
+
});
|
|
297
|
+
checkInactivity();
|
|
298
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
299
|
+
// Only CARD_A should be ended
|
|
300
|
+
expect(getActiveSessions().has(CARD_A)).toBe(false);
|
|
301
|
+
expect(getActiveSessions().has(CARD_B)).toBe(true);
|
|
302
|
+
expect(getActiveSessions().has(CARD_C)).toBe(true);
|
|
303
|
+
expect(client.endAgentSession).toHaveBeenCalledTimes(1);
|
|
304
|
+
expect(client.endAgentSession).toHaveBeenCalledWith(CARD_A, {
|
|
305
|
+
status: "completed",
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
test("checkInactivity is a no-op when no client is available", () => {
|
|
309
|
+
// No initAutoSession — clientGetter is null
|
|
310
|
+
getActiveSessions().set(CARD_A, {
|
|
311
|
+
cardId: CARD_A,
|
|
312
|
+
startedAt: 0,
|
|
313
|
+
lastActivityAt: 0,
|
|
314
|
+
isExplicit: false,
|
|
315
|
+
agentIdentifier: "auto",
|
|
316
|
+
agentName: "Auto-detected Agent",
|
|
317
|
+
});
|
|
318
|
+
// Should not throw
|
|
319
|
+
checkInactivity();
|
|
320
|
+
// Session should still be there (can't end without client)
|
|
321
|
+
expect(getActiveSessions().has(CARD_A)).toBe(true);
|
|
322
|
+
});
|
|
323
|
+
test("activity resets the inactivity timer", async () => {
|
|
324
|
+
const client = makeMockClient();
|
|
325
|
+
const endCb = mock(async () => { });
|
|
326
|
+
initAutoSession(endCb, () => client);
|
|
327
|
+
// Start with old lastActivityAt
|
|
328
|
+
getActiveSessions().set(CARD_A, {
|
|
329
|
+
cardId: CARD_A,
|
|
330
|
+
startedAt: Date.now() - INACTIVITY_TIMEOUT_MS - 5000,
|
|
331
|
+
lastActivityAt: Date.now() - INACTIVITY_TIMEOUT_MS - 5000,
|
|
332
|
+
isExplicit: false,
|
|
333
|
+
agentIdentifier: "auto",
|
|
334
|
+
agentName: "Auto-detected Agent",
|
|
335
|
+
});
|
|
336
|
+
// Refresh activity
|
|
337
|
+
await trackActivity(CARD_A, { autoStart: false });
|
|
338
|
+
// Now check — should NOT be timed out
|
|
339
|
+
checkInactivity();
|
|
340
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
341
|
+
expect(getActiveSessions().has(CARD_A)).toBe(true);
|
|
342
|
+
expect(client.endAgentSession).not.toHaveBeenCalled();
|
|
343
|
+
});
|
|
344
|
+
});
|
|
345
|
+
// ─── markExplicit ───────────────────────────────────────────────────
|
|
346
|
+
describe("markExplicit", () => {
|
|
347
|
+
test("marks existing auto-session as explicit", async () => {
|
|
348
|
+
const client = makeMockClient();
|
|
349
|
+
initAutoSession(mock(async () => { }), () => client);
|
|
350
|
+
await trackActivity(CARD_A, { autoStart: true, client: client });
|
|
351
|
+
expect(getActiveSessions().get(CARD_A).isExplicit).toBe(false);
|
|
352
|
+
markExplicit(CARD_A);
|
|
353
|
+
expect(getActiveSessions().get(CARD_A).isExplicit).toBe(true);
|
|
354
|
+
// agentIdentifier should remain "auto" since it was auto-started
|
|
355
|
+
expect(getActiveSessions().get(CARD_A).agentIdentifier).toBe("auto");
|
|
356
|
+
});
|
|
357
|
+
test("creates new tracking entry for unknown card", () => {
|
|
358
|
+
initAutoSession(mock(async () => { }), () => makeMockClient());
|
|
359
|
+
markExplicit(CARD_A);
|
|
360
|
+
expect(getActiveSessions().has(CARD_A)).toBe(true);
|
|
361
|
+
expect(getActiveSessions().get(CARD_A).isExplicit).toBe(true);
|
|
362
|
+
expect(getActiveSessions().get(CARD_A).agentIdentifier).toBe("explicit");
|
|
363
|
+
});
|
|
364
|
+
test("double markExplicit is idempotent", async () => {
|
|
365
|
+
const client = makeMockClient();
|
|
366
|
+
initAutoSession(mock(async () => { }), () => client);
|
|
367
|
+
await trackActivity(CARD_A, { autoStart: true, client: client });
|
|
368
|
+
markExplicit(CARD_A);
|
|
369
|
+
markExplicit(CARD_A);
|
|
370
|
+
expect(getActiveSessions().get(CARD_A).isExplicit).toBe(true);
|
|
371
|
+
expect(getActiveSessions().size).toBe(1);
|
|
372
|
+
});
|
|
373
|
+
});
|
|
374
|
+
// ─── untrack ────────────────────────────────────────────────────────
|
|
375
|
+
describe("untrack", () => {
|
|
376
|
+
test("removes session from tracking", async () => {
|
|
377
|
+
const client = makeMockClient();
|
|
378
|
+
initAutoSession(mock(async () => { }), () => client);
|
|
379
|
+
await trackActivity(CARD_A, { autoStart: true, client: client });
|
|
380
|
+
expect(getActiveSessions().has(CARD_A)).toBe(true);
|
|
381
|
+
untrack(CARD_A);
|
|
382
|
+
expect(getActiveSessions().has(CARD_A)).toBe(false);
|
|
383
|
+
});
|
|
384
|
+
test("is a no-op for non-existent cardId", () => {
|
|
385
|
+
initAutoSession(mock(async () => { }), () => makeMockClient());
|
|
386
|
+
// Should not throw
|
|
387
|
+
untrack(CARD_A);
|
|
388
|
+
expect(getActiveSessions().size).toBe(0);
|
|
389
|
+
});
|
|
390
|
+
test("removes explicit sessions too", () => {
|
|
391
|
+
initAutoSession(mock(async () => { }), () => makeMockClient());
|
|
392
|
+
markExplicit(CARD_A);
|
|
393
|
+
expect(getActiveSessions().has(CARD_A)).toBe(true);
|
|
394
|
+
untrack(CARD_A);
|
|
395
|
+
expect(getActiveSessions().has(CARD_A)).toBe(false);
|
|
396
|
+
});
|
|
397
|
+
});
|
|
398
|
+
// ─── shutdown ───────────────────────────────────────────────────────
|
|
399
|
+
describe("shutdown", () => {
|
|
400
|
+
test("ends all active sessions with paused status", async () => {
|
|
401
|
+
const client = makeMockClient();
|
|
402
|
+
const endCb = mock(async () => { });
|
|
403
|
+
initAutoSession(endCb, () => client);
|
|
404
|
+
getActiveSessions().set(CARD_A, {
|
|
405
|
+
cardId: CARD_A,
|
|
406
|
+
startedAt: Date.now(),
|
|
407
|
+
lastActivityAt: Date.now(),
|
|
408
|
+
isExplicit: false,
|
|
409
|
+
agentIdentifier: "auto",
|
|
410
|
+
agentName: "Auto-detected Agent",
|
|
411
|
+
});
|
|
412
|
+
await shutdownAllSessions();
|
|
413
|
+
expect(client.endAgentSession).toHaveBeenCalledWith(CARD_A, {
|
|
414
|
+
status: "paused",
|
|
415
|
+
});
|
|
416
|
+
expect(endCb).toHaveBeenCalledWith(client, CARD_A, "paused");
|
|
417
|
+
expect(getActiveSessions().size).toBe(0);
|
|
418
|
+
});
|
|
419
|
+
test("ends multiple sessions including explicit ones", async () => {
|
|
420
|
+
const client = makeMockClient();
|
|
421
|
+
const endCb = mock(async () => { });
|
|
422
|
+
initAutoSession(endCb, () => client);
|
|
423
|
+
getActiveSessions().set(CARD_A, {
|
|
424
|
+
cardId: CARD_A,
|
|
425
|
+
startedAt: Date.now(),
|
|
426
|
+
lastActivityAt: Date.now(),
|
|
427
|
+
isExplicit: false,
|
|
428
|
+
agentIdentifier: "auto",
|
|
429
|
+
agentName: "Auto-detected Agent",
|
|
430
|
+
});
|
|
431
|
+
getActiveSessions().set(CARD_B, {
|
|
432
|
+
cardId: CARD_B,
|
|
433
|
+
startedAt: Date.now(),
|
|
434
|
+
lastActivityAt: Date.now(),
|
|
435
|
+
isExplicit: true,
|
|
436
|
+
agentIdentifier: "explicit",
|
|
437
|
+
agentName: "Explicit Agent",
|
|
438
|
+
});
|
|
439
|
+
await shutdownAllSessions();
|
|
440
|
+
// Both should be ended on shutdown (even explicit)
|
|
441
|
+
expect(client.endAgentSession).toHaveBeenCalledTimes(2);
|
|
442
|
+
expect(endCb).toHaveBeenCalledTimes(2);
|
|
443
|
+
expect(getActiveSessions().size).toBe(0);
|
|
444
|
+
});
|
|
445
|
+
test("is a no-op when no client is available", async () => {
|
|
446
|
+
// No initAutoSession called
|
|
447
|
+
getActiveSessions().set(CARD_A, {
|
|
448
|
+
cardId: CARD_A,
|
|
449
|
+
startedAt: Date.now(),
|
|
450
|
+
lastActivityAt: Date.now(),
|
|
451
|
+
isExplicit: false,
|
|
452
|
+
agentIdentifier: "auto",
|
|
453
|
+
agentName: "Auto-detected Agent",
|
|
454
|
+
});
|
|
455
|
+
// Should not throw
|
|
456
|
+
await shutdownAllSessions();
|
|
457
|
+
// Session still tracked (couldn't end without client)
|
|
458
|
+
expect(getActiveSessions().has(CARD_A)).toBe(true);
|
|
459
|
+
});
|
|
460
|
+
test("is resilient when endAgentSession throws for some sessions", async () => {
|
|
461
|
+
let callCount = 0;
|
|
462
|
+
const client = makeMockClient();
|
|
463
|
+
client.endAgentSession = mock(async () => {
|
|
464
|
+
callCount++;
|
|
465
|
+
if (callCount === 1)
|
|
466
|
+
throw new Error("Network error");
|
|
467
|
+
return { session: { id: "sess-1" } };
|
|
468
|
+
});
|
|
469
|
+
const endCb = mock(async () => { });
|
|
470
|
+
initAutoSession(endCb, () => client);
|
|
471
|
+
getActiveSessions().set(CARD_A, {
|
|
472
|
+
cardId: CARD_A,
|
|
473
|
+
startedAt: Date.now(),
|
|
474
|
+
lastActivityAt: Date.now(),
|
|
475
|
+
isExplicit: false,
|
|
476
|
+
agentIdentifier: "auto",
|
|
477
|
+
agentName: "Auto-detected Agent",
|
|
478
|
+
});
|
|
479
|
+
getActiveSessions().set(CARD_B, {
|
|
480
|
+
cardId: CARD_B,
|
|
481
|
+
startedAt: Date.now(),
|
|
482
|
+
lastActivityAt: Date.now(),
|
|
483
|
+
isExplicit: false,
|
|
484
|
+
agentIdentifier: "auto",
|
|
485
|
+
agentName: "Auto-detected Agent",
|
|
486
|
+
});
|
|
487
|
+
await shutdownAllSessions();
|
|
488
|
+
// Both should be removed from tracking despite first API error
|
|
489
|
+
expect(getActiveSessions().size).toBe(0);
|
|
490
|
+
// Callback should still be called for both
|
|
491
|
+
expect(endCb).toHaveBeenCalledTimes(2);
|
|
492
|
+
});
|
|
493
|
+
test("empty sessions map is a no-op", async () => {
|
|
494
|
+
const client = makeMockClient();
|
|
495
|
+
initAutoSession(mock(async () => { }), () => client);
|
|
496
|
+
await shutdownAllSessions();
|
|
497
|
+
expect(client.endAgentSession).not.toHaveBeenCalled();
|
|
498
|
+
});
|
|
499
|
+
});
|
|
500
|
+
// ─── destroyAutoSession ─────────────────────────────────────────────
|
|
501
|
+
describe("destroyAutoSession", () => {
|
|
502
|
+
test("clears all state", async () => {
|
|
503
|
+
const client = makeMockClient();
|
|
504
|
+
initAutoSession(mock(async () => { }), () => client);
|
|
505
|
+
await trackActivity(CARD_A, { autoStart: true, client: client });
|
|
506
|
+
expect(getActiveSessions().size).toBe(1);
|
|
507
|
+
destroyAutoSession();
|
|
508
|
+
expect(getActiveSessions().size).toBe(0);
|
|
509
|
+
});
|
|
510
|
+
test("calling destroyAutoSession twice is safe", () => {
|
|
511
|
+
initAutoSession(mock(async () => { }), () => makeMockClient());
|
|
512
|
+
destroyAutoSession();
|
|
513
|
+
destroyAutoSession();
|
|
514
|
+
expect(getActiveSessions().size).toBe(0);
|
|
515
|
+
});
|
|
516
|
+
test("re-initialization after destroy works", async () => {
|
|
517
|
+
const client = makeMockClient();
|
|
518
|
+
initAutoSession(mock(async () => { }), () => client);
|
|
519
|
+
await trackActivity(CARD_A, { autoStart: true, client: client });
|
|
520
|
+
destroyAutoSession();
|
|
521
|
+
// Re-init
|
|
522
|
+
const client2 = makeMockClient();
|
|
523
|
+
initAutoSession(mock(async () => { }), () => client2);
|
|
524
|
+
await trackActivity(CARD_B, { autoStart: true, client: client2 });
|
|
525
|
+
expect(getActiveSessions().size).toBe(1);
|
|
526
|
+
expect(getActiveSessions().has(CARD_B)).toBe(true);
|
|
527
|
+
expect(client2.startAgentSession).toHaveBeenCalledTimes(1);
|
|
528
|
+
});
|
|
529
|
+
});
|
|
530
|
+
// ─── AUTO_START_TRIGGERS ────────────────────────────────────────────
|
|
531
|
+
describe("AUTO_START_TRIGGERS", () => {
|
|
532
|
+
test("contains all card-mutating tools", () => {
|
|
533
|
+
const expected = [
|
|
534
|
+
"harmony_generate_prompt",
|
|
535
|
+
"harmony_update_card",
|
|
536
|
+
"harmony_move_card",
|
|
537
|
+
"harmony_create_subtask",
|
|
538
|
+
"harmony_toggle_subtask",
|
|
539
|
+
"harmony_add_label_to_card",
|
|
540
|
+
"harmony_remove_label_from_card",
|
|
541
|
+
];
|
|
542
|
+
for (const tool of expected) {
|
|
543
|
+
expect(AUTO_START_TRIGGERS.has(tool)).toBe(true);
|
|
544
|
+
}
|
|
545
|
+
expect(AUTO_START_TRIGGERS.size).toBe(expected.length);
|
|
546
|
+
});
|
|
547
|
+
test("does NOT contain read-only tools", () => {
|
|
548
|
+
const readOnly = [
|
|
549
|
+
"harmony_get_card",
|
|
550
|
+
"harmony_get_card_by_short_id",
|
|
551
|
+
"harmony_get_board",
|
|
552
|
+
"harmony_search_cards",
|
|
553
|
+
"harmony_get_agent_session",
|
|
554
|
+
"harmony_get_card_links",
|
|
555
|
+
"harmony_list_workspaces",
|
|
556
|
+
"harmony_list_projects",
|
|
557
|
+
"harmony_get_context",
|
|
558
|
+
];
|
|
559
|
+
for (const tool of readOnly) {
|
|
560
|
+
expect(AUTO_START_TRIGGERS.has(tool)).toBe(false);
|
|
561
|
+
}
|
|
562
|
+
});
|
|
563
|
+
test("does NOT contain session management tools", () => {
|
|
564
|
+
expect(AUTO_START_TRIGGERS.has("harmony_start_agent_session")).toBe(false);
|
|
565
|
+
expect(AUTO_START_TRIGGERS.has("harmony_end_agent_session")).toBe(false);
|
|
566
|
+
expect(AUTO_START_TRIGGERS.has("harmony_update_agent_progress")).toBe(false);
|
|
567
|
+
});
|
|
568
|
+
});
|
|
569
|
+
// ─── INACTIVITY_TIMEOUT_MS ──────────────────────────────────────────
|
|
570
|
+
describe("INACTIVITY_TIMEOUT_MS", () => {
|
|
571
|
+
test("is 10 minutes", () => {
|
|
572
|
+
expect(INACTIVITY_TIMEOUT_MS).toBe(10 * 60 * 1000);
|
|
573
|
+
});
|
|
574
|
+
});
|
|
575
|
+
// ─── Edge cases / race conditions ───────────────────────────────────
|
|
576
|
+
describe("edge cases", () => {
|
|
577
|
+
test("trackActivity before initAutoSession uses client from options", async () => {
|
|
578
|
+
// No initAutoSession called
|
|
579
|
+
const client = makeMockClient();
|
|
580
|
+
await trackActivity(CARD_A, { autoStart: true, client: client });
|
|
581
|
+
expect(client.startAgentSession).toHaveBeenCalledTimes(1);
|
|
582
|
+
expect(getActiveSessions().size).toBe(1);
|
|
583
|
+
});
|
|
584
|
+
test("re-initialization clears old interval timer", async () => {
|
|
585
|
+
const client1 = makeMockClient();
|
|
586
|
+
const endCb1 = mock(async () => { });
|
|
587
|
+
initAutoSession(endCb1, () => client1);
|
|
588
|
+
// Re-initialize
|
|
589
|
+
const client2 = makeMockClient();
|
|
590
|
+
const endCb2 = mock(async () => { });
|
|
591
|
+
initAutoSession(endCb2, () => client2);
|
|
592
|
+
// Add stale session and trigger check
|
|
593
|
+
getActiveSessions().set(CARD_A, {
|
|
594
|
+
cardId: CARD_A,
|
|
595
|
+
startedAt: 0,
|
|
596
|
+
lastActivityAt: 0,
|
|
597
|
+
isExplicit: false,
|
|
598
|
+
agentIdentifier: "auto",
|
|
599
|
+
agentName: "Auto-detected Agent",
|
|
600
|
+
});
|
|
601
|
+
checkInactivity();
|
|
602
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
603
|
+
// Should use client2, not client1
|
|
604
|
+
expect(client2.endAgentSession).toHaveBeenCalledTimes(1);
|
|
605
|
+
expect(client1.endAgentSession).not.toHaveBeenCalled();
|
|
606
|
+
});
|
|
607
|
+
test("endCallback throwing does not prevent session removal", async () => {
|
|
608
|
+
const client = makeMockClient();
|
|
609
|
+
const endCb = mock(async () => {
|
|
610
|
+
throw new Error("Callback crash");
|
|
611
|
+
});
|
|
612
|
+
initAutoSession(endCb, () => client);
|
|
613
|
+
getActiveSessions().set(CARD_A, {
|
|
614
|
+
cardId: CARD_A,
|
|
615
|
+
startedAt: 0,
|
|
616
|
+
lastActivityAt: 0,
|
|
617
|
+
isExplicit: false,
|
|
618
|
+
agentIdentifier: "auto",
|
|
619
|
+
agentName: "Auto-detected Agent",
|
|
620
|
+
});
|
|
621
|
+
checkInactivity();
|
|
622
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
623
|
+
// Session should be removed despite callback error
|
|
624
|
+
expect(getActiveSessions().has(CARD_A)).toBe(false);
|
|
625
|
+
});
|
|
626
|
+
test("both endAgentSession and endCallback throwing still cleans up", async () => {
|
|
627
|
+
const client = makeMockClient();
|
|
628
|
+
client.endAgentSession = mock(async () => {
|
|
629
|
+
throw new Error("API down");
|
|
630
|
+
});
|
|
631
|
+
const endCb = mock(async () => {
|
|
632
|
+
throw new Error("Pipeline crash");
|
|
633
|
+
});
|
|
634
|
+
initAutoSession(endCb, () => client);
|
|
635
|
+
await trackActivity(CARD_A, { autoStart: true, client: client });
|
|
636
|
+
await trackActivity(CARD_B, { autoStart: true, client: client });
|
|
637
|
+
// Despite both throwing, tracking should be clean
|
|
638
|
+
expect(getActiveSessions().has(CARD_A)).toBe(false);
|
|
639
|
+
expect(getActiveSessions().has(CARD_B)).toBe(true);
|
|
640
|
+
});
|
|
641
|
+
test("concurrent auto-start on same card only starts once", async () => {
|
|
642
|
+
const client = makeMockClient();
|
|
643
|
+
// Slow startAgentSession to create a window for concurrent calls
|
|
644
|
+
client.startAgentSession = mock(async () => new Promise((resolve) => setTimeout(() => resolve({ session: { id: "sess-1" } }), 20)));
|
|
645
|
+
initAutoSession(mock(async () => { }), () => client);
|
|
646
|
+
// Fire two concurrent auto-starts for the same card
|
|
647
|
+
const p1 = trackActivity(CARD_A, {
|
|
648
|
+
autoStart: true,
|
|
649
|
+
client: client,
|
|
650
|
+
});
|
|
651
|
+
const p2 = trackActivity(CARD_A, {
|
|
652
|
+
autoStart: true,
|
|
653
|
+
client: client,
|
|
654
|
+
});
|
|
655
|
+
await Promise.all([p1, p2]);
|
|
656
|
+
// First call creates session, second call hits early return (existing)
|
|
657
|
+
// but due to async, both might race. At minimum, session should exist once.
|
|
658
|
+
expect(getActiveSessions().size).toBe(1);
|
|
659
|
+
expect(getActiveSessions().has(CARD_A)).toBe(true);
|
|
660
|
+
});
|
|
661
|
+
});
|