instar 0.1.0

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 (115) hide show
  1. package/.claude/settings.local.json +7 -0
  2. package/.claude/skills/setup-wizard/skill.md +343 -0
  3. package/.github/workflows/ci.yml +78 -0
  4. package/CLAUDE.md +82 -0
  5. package/README.md +194 -0
  6. package/dist/cli.d.ts +18 -0
  7. package/dist/cli.js +141 -0
  8. package/dist/commands/init.d.ts +40 -0
  9. package/dist/commands/init.js +568 -0
  10. package/dist/commands/job.d.ts +20 -0
  11. package/dist/commands/job.js +84 -0
  12. package/dist/commands/server.d.ts +19 -0
  13. package/dist/commands/server.js +273 -0
  14. package/dist/commands/setup.d.ts +24 -0
  15. package/dist/commands/setup.js +865 -0
  16. package/dist/commands/status.d.ts +11 -0
  17. package/dist/commands/status.js +114 -0
  18. package/dist/commands/user.d.ts +17 -0
  19. package/dist/commands/user.js +53 -0
  20. package/dist/core/Config.d.ts +16 -0
  21. package/dist/core/Config.js +144 -0
  22. package/dist/core/Prerequisites.d.ts +28 -0
  23. package/dist/core/Prerequisites.js +159 -0
  24. package/dist/core/RelationshipManager.d.ts +73 -0
  25. package/dist/core/RelationshipManager.js +318 -0
  26. package/dist/core/SessionManager.d.ts +89 -0
  27. package/dist/core/SessionManager.js +326 -0
  28. package/dist/core/StateManager.d.ts +28 -0
  29. package/dist/core/StateManager.js +96 -0
  30. package/dist/core/types.d.ts +279 -0
  31. package/dist/core/types.js +8 -0
  32. package/dist/index.d.ts +18 -0
  33. package/dist/index.js +23 -0
  34. package/dist/messaging/TelegramAdapter.d.ts +73 -0
  35. package/dist/messaging/TelegramAdapter.js +288 -0
  36. package/dist/monitoring/HealthChecker.d.ts +38 -0
  37. package/dist/monitoring/HealthChecker.js +148 -0
  38. package/dist/scaffold/bootstrap.d.ts +21 -0
  39. package/dist/scaffold/bootstrap.js +110 -0
  40. package/dist/scaffold/templates.d.ts +34 -0
  41. package/dist/scaffold/templates.js +187 -0
  42. package/dist/scheduler/JobLoader.d.ts +18 -0
  43. package/dist/scheduler/JobLoader.js +70 -0
  44. package/dist/scheduler/JobScheduler.d.ts +111 -0
  45. package/dist/scheduler/JobScheduler.js +402 -0
  46. package/dist/server/AgentServer.d.ts +40 -0
  47. package/dist/server/AgentServer.js +73 -0
  48. package/dist/server/middleware.d.ts +12 -0
  49. package/dist/server/middleware.js +50 -0
  50. package/dist/server/routes.d.ts +25 -0
  51. package/dist/server/routes.js +224 -0
  52. package/dist/users/UserManager.d.ts +45 -0
  53. package/dist/users/UserManager.js +113 -0
  54. package/docs/dawn-audit-report.md +412 -0
  55. package/docs/positioning-vs-openclaw.md +246 -0
  56. package/package.json +52 -0
  57. package/src/cli.ts +169 -0
  58. package/src/commands/init.ts +654 -0
  59. package/src/commands/job.ts +110 -0
  60. package/src/commands/server.ts +325 -0
  61. package/src/commands/setup.ts +958 -0
  62. package/src/commands/status.ts +125 -0
  63. package/src/commands/user.ts +71 -0
  64. package/src/core/Config.ts +161 -0
  65. package/src/core/Prerequisites.ts +187 -0
  66. package/src/core/RelationshipManager.ts +366 -0
  67. package/src/core/SessionManager.ts +385 -0
  68. package/src/core/StateManager.ts +121 -0
  69. package/src/core/types.ts +320 -0
  70. package/src/index.ts +58 -0
  71. package/src/messaging/TelegramAdapter.ts +365 -0
  72. package/src/monitoring/HealthChecker.ts +172 -0
  73. package/src/scaffold/bootstrap.ts +122 -0
  74. package/src/scaffold/templates.ts +204 -0
  75. package/src/scheduler/JobLoader.ts +85 -0
  76. package/src/scheduler/JobScheduler.ts +476 -0
  77. package/src/server/AgentServer.ts +93 -0
  78. package/src/server/middleware.ts +58 -0
  79. package/src/server/routes.ts +278 -0
  80. package/src/templates/default-jobs.json +47 -0
  81. package/src/templates/hooks/compaction-recovery.sh +23 -0
  82. package/src/templates/hooks/dangerous-command-guard.sh +35 -0
  83. package/src/templates/hooks/grounding-before-messaging.sh +22 -0
  84. package/src/templates/hooks/session-start.sh +37 -0
  85. package/src/templates/hooks/settings-template.json +45 -0
  86. package/src/templates/scripts/health-watchdog.sh +63 -0
  87. package/src/templates/scripts/telegram-reply.sh +54 -0
  88. package/src/users/UserManager.ts +129 -0
  89. package/tests/e2e/lifecycle.test.ts +376 -0
  90. package/tests/fixtures/test-repo/CLAUDE.md +3 -0
  91. package/tests/fixtures/test-repo/README.md +1 -0
  92. package/tests/helpers/setup.ts +209 -0
  93. package/tests/integration/fresh-install.test.ts +218 -0
  94. package/tests/integration/scheduler-basic.test.ts +109 -0
  95. package/tests/integration/server-full.test.ts +284 -0
  96. package/tests/integration/session-lifecycle.test.ts +181 -0
  97. package/tests/unit/Config.test.ts +22 -0
  98. package/tests/unit/HealthChecker.test.ts +168 -0
  99. package/tests/unit/JobLoader.test.ts +151 -0
  100. package/tests/unit/JobScheduler.test.ts +267 -0
  101. package/tests/unit/Prerequisites.test.ts +59 -0
  102. package/tests/unit/RelationshipManager.test.ts +345 -0
  103. package/tests/unit/StateManager.test.ts +143 -0
  104. package/tests/unit/TelegramAdapter.test.ts +165 -0
  105. package/tests/unit/UserManager.test.ts +131 -0
  106. package/tests/unit/bootstrap.test.ts +28 -0
  107. package/tests/unit/commands.test.ts +138 -0
  108. package/tests/unit/middleware.test.ts +92 -0
  109. package/tests/unit/relationship-routes.test.ts +131 -0
  110. package/tests/unit/scaffold-templates.test.ts +132 -0
  111. package/tests/unit/server.test.ts +163 -0
  112. package/tsconfig.json +20 -0
  113. package/vitest.config.ts +9 -0
  114. package/vitest.e2e.config.ts +9 -0
  115. package/vitest.integration.config.ts +9 -0
@@ -0,0 +1,345 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { RelationshipManager } from '../../src/core/RelationshipManager.js';
6
+ import type { RelationshipManagerConfig, UserChannel } from '../../src/core/types.js';
7
+
8
+ describe('RelationshipManager', () => {
9
+ let tmpDir: string;
10
+ let config: RelationshipManagerConfig;
11
+ let manager: RelationshipManager;
12
+
13
+ beforeEach(() => {
14
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'instar-rel-test-'));
15
+ config = {
16
+ relationshipsDir: path.join(tmpDir, 'relationships'),
17
+ maxRecentInteractions: 20,
18
+ };
19
+ manager = new RelationshipManager(config);
20
+ });
21
+
22
+ afterEach(() => {
23
+ fs.rmSync(tmpDir, { recursive: true, force: true });
24
+ });
25
+
26
+ describe('findOrCreate', () => {
27
+ it('creates a new relationship', () => {
28
+ const channel: UserChannel = { type: 'telegram', identifier: '12345' };
29
+ const record = manager.findOrCreate('Alice', channel);
30
+
31
+ expect(record.name).toBe('Alice');
32
+ expect(record.id).toBeTruthy();
33
+ expect(record.channels).toHaveLength(1);
34
+ expect(record.channels[0]).toEqual(channel);
35
+ expect(record.interactionCount).toBe(0);
36
+ expect(record.significance).toBe(1);
37
+ });
38
+
39
+ it('returns existing relationship for same channel', () => {
40
+ const channel: UserChannel = { type: 'telegram', identifier: '12345' };
41
+ const first = manager.findOrCreate('Alice', channel);
42
+ const second = manager.findOrCreate('Alice', channel);
43
+
44
+ expect(first.id).toBe(second.id);
45
+ });
46
+
47
+ it('creates separate relationships for different channels', () => {
48
+ const ch1: UserChannel = { type: 'telegram', identifier: '111' };
49
+ const ch2: UserChannel = { type: 'email', identifier: 'bob@test.com' };
50
+
51
+ const r1 = manager.findOrCreate('Alice', ch1);
52
+ const r2 = manager.findOrCreate('Bob', ch2);
53
+
54
+ expect(r1.id).not.toBe(r2.id);
55
+ });
56
+
57
+ it('persists to disk', () => {
58
+ const channel: UserChannel = { type: 'telegram', identifier: '12345' };
59
+ const record = manager.findOrCreate('Alice', channel);
60
+
61
+ const filePath = path.join(config.relationshipsDir, `${record.id}.json`);
62
+ expect(fs.existsSync(filePath)).toBe(true);
63
+
64
+ const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
65
+ expect(data.name).toBe('Alice');
66
+ });
67
+
68
+ it('survives reload from disk', () => {
69
+ const channel: UserChannel = { type: 'telegram', identifier: '12345' };
70
+ const original = manager.findOrCreate('Alice', channel);
71
+
72
+ // Create a new manager pointing at the same dir
73
+ const manager2 = new RelationshipManager(config);
74
+ const loaded = manager2.resolveByChannel(channel);
75
+
76
+ expect(loaded).not.toBeNull();
77
+ expect(loaded!.id).toBe(original.id);
78
+ expect(loaded!.name).toBe('Alice');
79
+ });
80
+ });
81
+
82
+ describe('resolveByChannel', () => {
83
+ it('returns null for unknown channel', () => {
84
+ const result = manager.resolveByChannel({ type: 'telegram', identifier: '99999' });
85
+ expect(result).toBeNull();
86
+ });
87
+
88
+ it('resolves known channel', () => {
89
+ const channel: UserChannel = { type: 'email', identifier: 'test@example.com' };
90
+ const created = manager.findOrCreate('Test User', channel);
91
+ const resolved = manager.resolveByChannel(channel);
92
+
93
+ expect(resolved).not.toBeNull();
94
+ expect(resolved!.id).toBe(created.id);
95
+ });
96
+ });
97
+
98
+ describe('recordInteraction', () => {
99
+ it('increments interaction count and updates recency', () => {
100
+ const channel: UserChannel = { type: 'telegram', identifier: '12345' };
101
+ const record = manager.findOrCreate('Alice', channel);
102
+
103
+ manager.recordInteraction(record.id, {
104
+ timestamp: new Date().toISOString(),
105
+ channel: 'telegram',
106
+ summary: 'Discussed project setup',
107
+ topics: ['onboarding', 'architecture'],
108
+ });
109
+
110
+ const updated = manager.get(record.id)!;
111
+ expect(updated.interactionCount).toBe(1);
112
+ expect(updated.recentInteractions).toHaveLength(1);
113
+ expect(updated.themes).toContain('onboarding');
114
+ expect(updated.themes).toContain('architecture');
115
+ });
116
+
117
+ it('caps recent interactions at max', () => {
118
+ const channel: UserChannel = { type: 'telegram', identifier: '12345' };
119
+ const record = manager.findOrCreate('Alice', channel);
120
+
121
+ // Record more than maxRecentInteractions
122
+ for (let i = 0; i < 25; i++) {
123
+ manager.recordInteraction(record.id, {
124
+ timestamp: new Date(Date.now() + i * 1000).toISOString(),
125
+ channel: 'telegram',
126
+ summary: `Interaction ${i}`,
127
+ });
128
+ }
129
+
130
+ const updated = manager.get(record.id)!;
131
+ expect(updated.recentInteractions.length).toBeLessThanOrEqual(config.maxRecentInteractions);
132
+ expect(updated.interactionCount).toBe(25);
133
+ });
134
+
135
+ it('ignores unknown relationship id', () => {
136
+ // Should not throw
137
+ manager.recordInteraction('nonexistent-id', {
138
+ timestamp: new Date().toISOString(),
139
+ channel: 'telegram',
140
+ summary: 'Should be ignored',
141
+ });
142
+ });
143
+
144
+ it('auto-derives significance from interactions', () => {
145
+ const channel: UserChannel = { type: 'telegram', identifier: '12345' };
146
+ const record = manager.findOrCreate('Alice', channel);
147
+
148
+ // Record many interactions with varied topics
149
+ for (let i = 0; i < 20; i++) {
150
+ manager.recordInteraction(record.id, {
151
+ timestamp: new Date().toISOString(),
152
+ channel: 'telegram',
153
+ summary: `Discussion ${i}`,
154
+ topics: [`topic-${i}`],
155
+ });
156
+ }
157
+
158
+ const updated = manager.get(record.id)!;
159
+ // 20 interactions (3pts) + recent (3pts) + 20 themes (3pts) = 9
160
+ expect(updated.significance).toBeGreaterThanOrEqual(7);
161
+ });
162
+ });
163
+
164
+ describe('updateNotes', () => {
165
+ it('updates notes on a relationship', () => {
166
+ const channel: UserChannel = { type: 'telegram', identifier: '12345' };
167
+ const record = manager.findOrCreate('Alice', channel);
168
+
169
+ manager.updateNotes(record.id, 'Very thoughtful conversationalist');
170
+ const updated = manager.get(record.id)!;
171
+ expect(updated.notes).toBe('Very thoughtful conversationalist');
172
+ });
173
+ });
174
+
175
+ describe('updateArcSummary', () => {
176
+ it('updates arc summary on a relationship', () => {
177
+ const channel: UserChannel = { type: 'telegram', identifier: '12345' };
178
+ const record = manager.findOrCreate('Alice', channel);
179
+
180
+ manager.updateArcSummary(record.id, 'Started as curious user, became collaborator');
181
+ const updated = manager.get(record.id)!;
182
+ expect(updated.arcSummary).toBe('Started as curious user, became collaborator');
183
+ });
184
+ });
185
+
186
+ describe('linkChannel', () => {
187
+ it('adds a new channel to an existing relationship', () => {
188
+ const ch1: UserChannel = { type: 'telegram', identifier: '12345' };
189
+ const record = manager.findOrCreate('Alice', ch1);
190
+
191
+ const ch2: UserChannel = { type: 'email', identifier: 'alice@example.com' };
192
+ manager.linkChannel(record.id, ch2);
193
+
194
+ const updated = manager.get(record.id)!;
195
+ expect(updated.channels).toHaveLength(2);
196
+
197
+ // Should be resolvable by either channel
198
+ expect(manager.resolveByChannel(ch1)!.id).toBe(record.id);
199
+ expect(manager.resolveByChannel(ch2)!.id).toBe(record.id);
200
+ });
201
+
202
+ it('does not duplicate existing channels', () => {
203
+ const channel: UserChannel = { type: 'telegram', identifier: '12345' };
204
+ const record = manager.findOrCreate('Alice', channel);
205
+
206
+ manager.linkChannel(record.id, channel);
207
+ const updated = manager.get(record.id)!;
208
+ expect(updated.channels).toHaveLength(1);
209
+ });
210
+ });
211
+
212
+ describe('mergeRelationships', () => {
213
+ it('merges two relationship records', () => {
214
+ const ch1: UserChannel = { type: 'telegram', identifier: '111' };
215
+ const ch2: UserChannel = { type: 'email', identifier: 'alice@test.com' };
216
+
217
+ const r1 = manager.findOrCreate('Alice (Telegram)', ch1);
218
+ const r2 = manager.findOrCreate('Alice (Email)', ch2);
219
+
220
+ // Add interactions to both
221
+ manager.recordInteraction(r1.id, {
222
+ timestamp: new Date(Date.now() - 10000).toISOString(),
223
+ channel: 'telegram',
224
+ summary: 'Chat on Telegram',
225
+ topics: ['ai'],
226
+ });
227
+ manager.recordInteraction(r2.id, {
228
+ timestamp: new Date().toISOString(),
229
+ channel: 'email',
230
+ summary: 'Email exchange',
231
+ topics: ['philosophy'],
232
+ });
233
+
234
+ // Merge r2 into r1
235
+ manager.mergeRelationships(r1.id, r2.id);
236
+
237
+ const merged = manager.get(r1.id)!;
238
+ expect(merged.channels).toHaveLength(2);
239
+ expect(merged.interactionCount).toBe(2);
240
+ expect(merged.themes).toContain('ai');
241
+ expect(merged.themes).toContain('philosophy');
242
+
243
+ // r2 should be gone
244
+ expect(manager.get(r2.id)).toBeNull();
245
+
246
+ // Both channels should resolve to r1
247
+ expect(manager.resolveByChannel(ch1)!.id).toBe(r1.id);
248
+ expect(manager.resolveByChannel(ch2)!.id).toBe(r1.id);
249
+ });
250
+ });
251
+
252
+ describe('getContextForPerson', () => {
253
+ it('returns null for unknown id', () => {
254
+ expect(manager.getContextForPerson('nonexistent')).toBeNull();
255
+ });
256
+
257
+ it('generates XML context block', () => {
258
+ const channel: UserChannel = { type: 'telegram', identifier: '12345' };
259
+ const record = manager.findOrCreate('Alice', channel);
260
+ manager.updateNotes(record.id, 'Test note');
261
+
262
+ manager.recordInteraction(record.id, {
263
+ timestamp: new Date().toISOString(),
264
+ channel: 'telegram',
265
+ summary: 'Discussed testing',
266
+ topics: ['testing'],
267
+ });
268
+
269
+ const context = manager.getContextForPerson(record.id)!;
270
+ expect(context).toContain('<relationship_context person="Alice">');
271
+ expect(context).toContain('</relationship_context>');
272
+ expect(context).toContain('Name: Alice');
273
+ expect(context).toContain('Key themes: testing');
274
+ expect(context).toContain('Notes: Test note');
275
+ expect(context).toContain('Discussed testing');
276
+ });
277
+ });
278
+
279
+ describe('getAll', () => {
280
+ it('returns empty array when no relationships', () => {
281
+ expect(manager.getAll()).toEqual([]);
282
+ });
283
+
284
+ it('sorts by significance by default', () => {
285
+ const ch1: UserChannel = { type: 'telegram', identifier: '111' };
286
+ const ch2: UserChannel = { type: 'telegram', identifier: '222' };
287
+
288
+ const r1 = manager.findOrCreate('Low', ch1);
289
+ const r2 = manager.findOrCreate('High', ch2);
290
+
291
+ // Give r2 more interactions to boost significance
292
+ for (let i = 0; i < 10; i++) {
293
+ manager.recordInteraction(r2.id, {
294
+ timestamp: new Date().toISOString(),
295
+ channel: 'telegram',
296
+ summary: `Discussion ${i}`,
297
+ topics: [`topic-${i}`],
298
+ });
299
+ }
300
+
301
+ const all = manager.getAll();
302
+ expect(all[0].id).toBe(r2.id);
303
+ });
304
+
305
+ it('sorts by name when requested', () => {
306
+ manager.findOrCreate('Charlie', { type: 'telegram', identifier: '3' });
307
+ manager.findOrCreate('Alice', { type: 'telegram', identifier: '1' });
308
+ manager.findOrCreate('Bob', { type: 'telegram', identifier: '2' });
309
+
310
+ const byName = manager.getAll('name');
311
+ expect(byName.map(r => r.name)).toEqual(['Alice', 'Bob', 'Charlie']);
312
+ });
313
+ });
314
+
315
+ describe('getStaleRelationships', () => {
316
+ it('finds relationships older than threshold with sufficient significance', () => {
317
+ const channel: UserChannel = { type: 'telegram', identifier: '12345' };
318
+ const record = manager.findOrCreate('Alice', channel);
319
+
320
+ // Manually boost significance and set old last interaction
321
+ for (let i = 0; i < 6; i++) {
322
+ manager.recordInteraction(record.id, {
323
+ timestamp: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(), // 30 days ago
324
+ channel: 'telegram',
325
+ summary: `Old discussion ${i}`,
326
+ topics: [`topic-${i}`],
327
+ });
328
+ }
329
+
330
+ const stale = manager.getStaleRelationships(14);
331
+ expect(stale.length).toBeGreaterThanOrEqual(1);
332
+ expect(stale[0].id).toBe(record.id);
333
+ });
334
+
335
+ it('excludes low-significance relationships', () => {
336
+ const channel: UserChannel = { type: 'telegram', identifier: '12345' };
337
+ manager.findOrCreate('Stranger', channel);
338
+ // No interactions → significance stays at 1
339
+
340
+ const stale = manager.getStaleRelationships(0); // Any age
341
+ // Significance < 3 should be excluded
342
+ expect(stale).toHaveLength(0);
343
+ });
344
+ });
345
+ });
@@ -0,0 +1,143 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import os from 'node:os';
5
+ import { StateManager } from '../../src/core/StateManager.js';
6
+ import type { Session, ActivityEvent } from '../../src/core/types.js';
7
+
8
+ describe('StateManager', () => {
9
+ let tmpDir: string;
10
+ let state: StateManager;
11
+
12
+ beforeEach(() => {
13
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'instar-test-'));
14
+ // Create required subdirectories
15
+ fs.mkdirSync(path.join(tmpDir, 'state', 'sessions'), { recursive: true });
16
+ fs.mkdirSync(path.join(tmpDir, 'state', 'jobs'), { recursive: true });
17
+ fs.mkdirSync(path.join(tmpDir, 'logs'), { recursive: true });
18
+ state = new StateManager(tmpDir);
19
+ });
20
+
21
+ afterEach(() => {
22
+ fs.rmSync(tmpDir, { recursive: true, force: true });
23
+ });
24
+
25
+ describe('Session State', () => {
26
+ const makeSession = (overrides?: Partial<Session>): Session => ({
27
+ id: 'test-123',
28
+ name: 'test-session',
29
+ status: 'running',
30
+ tmuxSession: 'project-test-session',
31
+ startedAt: new Date().toISOString(),
32
+ ...overrides,
33
+ });
34
+
35
+ it('saves and retrieves a session', () => {
36
+ const session = makeSession();
37
+ state.saveSession(session);
38
+
39
+ const retrieved = state.getSession('test-123');
40
+ expect(retrieved).toEqual(session);
41
+ });
42
+
43
+ it('returns null for unknown session', () => {
44
+ expect(state.getSession('nonexistent')).toBeNull();
45
+ });
46
+
47
+ it('lists sessions by status', () => {
48
+ state.saveSession(makeSession({ id: 'a', status: 'running' }));
49
+ state.saveSession(makeSession({ id: 'b', status: 'completed' }));
50
+ state.saveSession(makeSession({ id: 'c', status: 'running' }));
51
+
52
+ const running = state.listSessions({ status: 'running' });
53
+ expect(running).toHaveLength(2);
54
+ expect(running.map(s => s.id).sort()).toEqual(['a', 'c']);
55
+ });
56
+
57
+ it('lists all sessions without filter', () => {
58
+ state.saveSession(makeSession({ id: 'a', status: 'running' }));
59
+ state.saveSession(makeSession({ id: 'b', status: 'completed' }));
60
+
61
+ const all = state.listSessions();
62
+ expect(all).toHaveLength(2);
63
+ });
64
+ });
65
+
66
+ describe('Job State', () => {
67
+ it('saves and retrieves job state', () => {
68
+ const jobState = {
69
+ slug: 'email-check',
70
+ lastRun: new Date().toISOString(),
71
+ lastResult: 'success' as const,
72
+ consecutiveFailures: 0,
73
+ };
74
+
75
+ state.saveJobState(jobState);
76
+ const retrieved = state.getJobState('email-check');
77
+ expect(retrieved).toEqual(jobState);
78
+ });
79
+
80
+ it('returns null for unknown job', () => {
81
+ expect(state.getJobState('nonexistent')).toBeNull();
82
+ });
83
+ });
84
+
85
+ describe('Activity Events', () => {
86
+ it('appends and queries events', () => {
87
+ const event: ActivityEvent = {
88
+ type: 'session_start',
89
+ summary: 'Started email check job',
90
+ sessionId: 'test-123',
91
+ timestamp: new Date().toISOString(),
92
+ };
93
+
94
+ state.appendEvent(event);
95
+ state.appendEvent({ ...event, type: 'session_end', summary: 'Finished' });
96
+
97
+ const events = state.queryEvents({});
98
+ expect(events).toHaveLength(2);
99
+ });
100
+
101
+ it('filters events by type', () => {
102
+ state.appendEvent({
103
+ type: 'session_start',
104
+ summary: 'Start',
105
+ timestamp: new Date().toISOString(),
106
+ });
107
+ state.appendEvent({
108
+ type: 'job_complete',
109
+ summary: 'Done',
110
+ timestamp: new Date().toISOString(),
111
+ });
112
+
113
+ const starts = state.queryEvents({ type: 'session_start' });
114
+ expect(starts).toHaveLength(1);
115
+ expect(starts[0].type).toBe('session_start');
116
+ });
117
+
118
+ it('respects limit', () => {
119
+ for (let i = 0; i < 10; i++) {
120
+ state.appendEvent({
121
+ type: 'test',
122
+ summary: `Event ${i}`,
123
+ timestamp: new Date().toISOString(),
124
+ });
125
+ }
126
+
127
+ const events = state.queryEvents({ limit: 3 });
128
+ expect(events).toHaveLength(3);
129
+ });
130
+ });
131
+
132
+ describe('Generic Key-Value', () => {
133
+ it('stores and retrieves values', () => {
134
+ state.set('test-key', { foo: 'bar', count: 42 });
135
+ const value = state.get<{ foo: string; count: number }>('test-key');
136
+ expect(value).toEqual({ foo: 'bar', count: 42 });
137
+ });
138
+
139
+ it('returns null for missing keys', () => {
140
+ expect(state.get('missing')).toBeNull();
141
+ });
142
+ });
143
+ });
@@ -0,0 +1,165 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { TelegramAdapter } from '../../src/messaging/TelegramAdapter.js';
6
+
7
+ describe('TelegramAdapter', () => {
8
+ let adapter: TelegramAdapter;
9
+ let tmpDir: string;
10
+
11
+ beforeEach(() => {
12
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'instar-telegram-test-'));
13
+ adapter = new TelegramAdapter({
14
+ token: 'test-token-123',
15
+ chatId: '-100123456',
16
+ pollIntervalMs: 100,
17
+ }, tmpDir);
18
+ });
19
+
20
+ afterEach(async () => {
21
+ await adapter.stop();
22
+ fs.rmSync(tmpDir, { recursive: true, force: true });
23
+ });
24
+
25
+ it('has correct platform name', () => {
26
+ expect(adapter.platform).toBe('telegram');
27
+ });
28
+
29
+ it('sends messages via API', async () => {
30
+ // Mock fetch
31
+ const mockFetch = vi.fn().mockResolvedValue({
32
+ ok: true,
33
+ json: async () => ({ ok: true, result: { message_id: 1 } }),
34
+ });
35
+ vi.stubGlobal('fetch', mockFetch);
36
+
37
+ await adapter.send({
38
+ userId: 'test-user',
39
+ content: 'Hello from test',
40
+ channel: { type: 'telegram', identifier: '42' },
41
+ });
42
+
43
+ expect(mockFetch).toHaveBeenCalledOnce();
44
+ const [url, options] = mockFetch.mock.calls[0];
45
+ expect(url).toContain('bottest-token-123/sendMessage');
46
+ const body = JSON.parse(options.body);
47
+ expect(body.text).toBe('Hello from test');
48
+ expect(body.chat_id).toBe('-100123456');
49
+ expect(body.message_thread_id).toBe(42);
50
+
51
+ vi.unstubAllGlobals();
52
+ });
53
+
54
+ it('sends without topic when no channel specified', async () => {
55
+ const mockFetch = vi.fn().mockResolvedValue({
56
+ ok: true,
57
+ json: async () => ({ ok: true, result: { message_id: 1 } }),
58
+ });
59
+ vi.stubGlobal('fetch', mockFetch);
60
+
61
+ await adapter.send({
62
+ userId: 'test-user',
63
+ content: 'No topic',
64
+ });
65
+
66
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body);
67
+ expect(body.message_thread_id).toBeUndefined();
68
+
69
+ vi.unstubAllGlobals();
70
+ });
71
+
72
+ it('throws on API error', async () => {
73
+ const mockFetch = vi.fn().mockResolvedValue({
74
+ ok: false,
75
+ status: 401,
76
+ text: async () => 'Unauthorized',
77
+ });
78
+ vi.stubGlobal('fetch', mockFetch);
79
+
80
+ await expect(adapter.send({
81
+ userId: 'test-user',
82
+ content: 'fail',
83
+ })).rejects.toThrow('Telegram API error (401)');
84
+
85
+ vi.unstubAllGlobals();
86
+ });
87
+
88
+ it('registers message handler', () => {
89
+ const handler = vi.fn();
90
+ adapter.onMessage(handler);
91
+ // Handler is stored internally — tested via polling behavior
92
+ expect(handler).not.toHaveBeenCalled();
93
+ });
94
+
95
+ it('resolveUser returns null (defers to UserManager)', async () => {
96
+ const result = await adapter.resolveUser('12345');
97
+ expect(result).toBeNull();
98
+ });
99
+
100
+ it('parses incoming messages from polling', async () => {
101
+ const received: any[] = [];
102
+ adapter.onMessage(async (msg) => {
103
+ received.push(msg);
104
+ });
105
+
106
+ // Mock fetch for getUpdates then sendMessage
107
+ let callCount = 0;
108
+ const mockFetch = vi.fn().mockImplementation(async (url: string) => {
109
+ callCount++;
110
+ if (url.includes('getUpdates') && callCount === 1) {
111
+ return {
112
+ ok: true,
113
+ json: async () => ({
114
+ ok: true,
115
+ result: [{
116
+ update_id: 100,
117
+ message: {
118
+ message_id: 42,
119
+ from: { id: 12345, first_name: 'Test', username: 'testuser' },
120
+ chat: { id: -100123456 },
121
+ message_thread_id: 99,
122
+ text: 'Hello world',
123
+ date: Math.floor(Date.now() / 1000),
124
+ },
125
+ }],
126
+ }),
127
+ };
128
+ }
129
+ // Subsequent polls return empty
130
+ return {
131
+ ok: true,
132
+ json: async () => ({ ok: true, result: [] }),
133
+ };
134
+ });
135
+ vi.stubGlobal('fetch', mockFetch);
136
+
137
+ await adapter.start();
138
+ await new Promise(r => setTimeout(r, 300));
139
+ await adapter.stop();
140
+
141
+ expect(received).toHaveLength(1);
142
+ expect(received[0].content).toBe('Hello world');
143
+ expect(received[0].channel.type).toBe('telegram');
144
+ expect(received[0].channel.identifier).toBe('99');
145
+ expect(received[0].metadata.username).toBe('testuser');
146
+
147
+ vi.unstubAllGlobals();
148
+ });
149
+
150
+ it('start is idempotent', async () => {
151
+ const mockFetch = vi.fn().mockResolvedValue({
152
+ ok: true,
153
+ json: async () => ({ ok: true, result: [] }),
154
+ });
155
+ vi.stubGlobal('fetch', mockFetch);
156
+
157
+ await adapter.start();
158
+ await adapter.start(); // Second call should be no-op
159
+
160
+ await new Promise(r => setTimeout(r, 50));
161
+ await adapter.stop();
162
+
163
+ vi.unstubAllGlobals();
164
+ });
165
+ });