@onmars/lunar-core 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.
- package/LICENSE +21 -0
- package/README.md +13 -0
- package/package.json +32 -0
- package/src/__tests__/clear-command.test.ts +214 -0
- package/src/__tests__/command-handler.test.ts +169 -0
- package/src/__tests__/compact-command.test.ts +80 -0
- package/src/__tests__/config-command.test.ts +240 -0
- package/src/__tests__/config-loader.test.ts +1512 -0
- package/src/__tests__/config.test.ts +429 -0
- package/src/__tests__/cron-command.test.ts +418 -0
- package/src/__tests__/cron-parser.test.ts +259 -0
- package/src/__tests__/daemon.test.ts +346 -0
- package/src/__tests__/dedup.test.ts +404 -0
- package/src/__tests__/e2e-sanitization.ts +168 -0
- package/src/__tests__/e2e-skill-loader.test.ts +176 -0
- package/src/__tests__/fixtures/AGENTS.md +4 -0
- package/src/__tests__/fixtures/IDENTITY.md +2 -0
- package/src/__tests__/fixtures/SOUL.md +3 -0
- package/src/__tests__/fixtures/moons/athena/IDENTITY.md +2 -0
- package/src/__tests__/fixtures/moons/athena/SOUL.md +3 -0
- package/src/__tests__/fixtures/moons/hermes/SOUL.md +3 -0
- package/src/__tests__/fixtures/skills/brain/SKILL.md +6 -0
- package/src/__tests__/fixtures/skills/empty/SKILL.md +3 -0
- package/src/__tests__/fixtures/skills/multiline/SKILL.md +7 -0
- package/src/__tests__/fixtures/skills/no-desc/SKILL.md +5 -0
- package/src/__tests__/fixtures/skills/notion/SKILL.md +6 -0
- package/src/__tests__/fixtures/skills/quoted/SKILL.md +6 -0
- package/src/__tests__/hook-runner.test.ts +1689 -0
- package/src/__tests__/input-sanitization.test.ts +367 -0
- package/src/__tests__/logger.test.ts +163 -0
- package/src/__tests__/memory-orchestrator.test.ts +552 -0
- package/src/__tests__/model-catalog.test.ts +215 -0
- package/src/__tests__/model-command.test.ts +185 -0
- package/src/__tests__/moon-loader.test.ts +398 -0
- package/src/__tests__/ping-command.test.ts +85 -0
- package/src/__tests__/plugin.test.ts +258 -0
- package/src/__tests__/remind-command.test.ts +368 -0
- package/src/__tests__/reset-command.test.ts +92 -0
- package/src/__tests__/router.test.ts +1246 -0
- package/src/__tests__/scheduler.test.ts +469 -0
- package/src/__tests__/security.test.ts +214 -0
- package/src/__tests__/session-meta.test.ts +101 -0
- package/src/__tests__/session-tracker.test.ts +389 -0
- package/src/__tests__/session.test.ts +241 -0
- package/src/__tests__/skill-loader.test.ts +153 -0
- package/src/__tests__/status-command.test.ts +153 -0
- package/src/__tests__/stop-command.test.ts +60 -0
- package/src/__tests__/think-command.test.ts +146 -0
- package/src/__tests__/usage-api.test.ts +222 -0
- package/src/__tests__/usage-command-api-fail.test.ts +48 -0
- package/src/__tests__/usage-command-no-oauth.test.ts +48 -0
- package/src/__tests__/usage-command.test.ts +173 -0
- package/src/__tests__/whoami-command.test.ts +124 -0
- package/src/index.ts +122 -0
- package/src/lib/command-handler.ts +135 -0
- package/src/lib/commands/clear.ts +69 -0
- package/src/lib/commands/compact.ts +14 -0
- package/src/lib/commands/config-show.ts +49 -0
- package/src/lib/commands/cron.ts +118 -0
- package/src/lib/commands/help.ts +26 -0
- package/src/lib/commands/model.ts +71 -0
- package/src/lib/commands/ping.ts +24 -0
- package/src/lib/commands/remind.ts +75 -0
- package/src/lib/commands/status.ts +118 -0
- package/src/lib/commands/stop.ts +18 -0
- package/src/lib/commands/think.ts +42 -0
- package/src/lib/commands/usage.ts +56 -0
- package/src/lib/commands/whoami.ts +23 -0
- package/src/lib/config-loader.ts +1449 -0
- package/src/lib/config.ts +202 -0
- package/src/lib/cron-parser.ts +388 -0
- package/src/lib/daemon.ts +216 -0
- package/src/lib/dedup.ts +414 -0
- package/src/lib/hook-runner.ts +1270 -0
- package/src/lib/logger.ts +55 -0
- package/src/lib/memory-orchestrator.ts +415 -0
- package/src/lib/model-catalog.ts +240 -0
- package/src/lib/moon-loader.ts +291 -0
- package/src/lib/plugin.ts +148 -0
- package/src/lib/router.ts +1135 -0
- package/src/lib/scheduler.ts +422 -0
- package/src/lib/security.ts +259 -0
- package/src/lib/session-tracker.ts +222 -0
- package/src/lib/session.ts +158 -0
- package/src/lib/skill-loader.ts +166 -0
- package/src/lib/usage-api.ts +145 -0
- package/src/types/agent.ts +86 -0
- package/src/types/channel.ts +93 -0
- package/src/types/index.ts +32 -0
- package/src/types/memory.ts +92 -0
- package/src/types/moon.ts +56 -0
- package/src/types/voice.ts +74 -0
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* # PluginRegistry — Functional Specification
|
|
3
|
+
*
|
|
4
|
+
* Central registry for all Lunar plugins: channels, agents, memory, TTS, STT.
|
|
5
|
+
*
|
|
6
|
+
* ## Design principles:
|
|
7
|
+
* - **Register → Get pattern**: register a plugin instance, retrieve it later by id.
|
|
8
|
+
* - **First-agent-wins default**: the first registered agent becomes the default
|
|
9
|
+
* unless explicitly overridden with `registerAgent(agent, true)`.
|
|
10
|
+
* - **Strict uniqueness**: duplicate ids throw immediately (fail fast, not silently overwrite).
|
|
11
|
+
* - **Lifecycle management**: `initAll()` connects everything, `destroyAll()` tears down.
|
|
12
|
+
* Individual failures in initAll don't block others (Promise.allSettled).
|
|
13
|
+
* - **Empty is valid**: empty registry works fine — no plugins means no-ops.
|
|
14
|
+
*
|
|
15
|
+
* ## Plugin types:
|
|
16
|
+
* | Type | Register | Get | List |
|
|
17
|
+
* |---------|------------------|-----------------|-----------------|
|
|
18
|
+
* | Channel | registerChannel | getChannel(id) | listChannels() |
|
|
19
|
+
* | Agent | registerAgent | getAgent(id?) | listAgents() |
|
|
20
|
+
* | Memory | registerMemory | getMemory(id) | listMemory() |
|
|
21
|
+
*/
|
|
22
|
+
import { beforeEach, describe, expect, it } from 'bun:test'
|
|
23
|
+
import { PluginRegistry } from '../lib/plugin'
|
|
24
|
+
import type { Agent } from '../types/agent'
|
|
25
|
+
import type { Channel } from '../types/channel'
|
|
26
|
+
import type { MemoryProvider } from '../types/memory'
|
|
27
|
+
|
|
28
|
+
// ─── Mock factories ────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
function createMockChannel(id: string): Channel {
|
|
31
|
+
return {
|
|
32
|
+
id,
|
|
33
|
+
name: `Mock ${id}`,
|
|
34
|
+
connect: async () => {},
|
|
35
|
+
disconnect: async () => {},
|
|
36
|
+
isConnected: () => false,
|
|
37
|
+
send: async () => undefined,
|
|
38
|
+
sendTyping: async () => {},
|
|
39
|
+
onMessage: () => {},
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function createMockAgent(id: string): Agent {
|
|
44
|
+
return {
|
|
45
|
+
id,
|
|
46
|
+
name: `Mock ${id}`,
|
|
47
|
+
init: async () => {},
|
|
48
|
+
destroy: async () => {},
|
|
49
|
+
query: async function* () {
|
|
50
|
+
yield { type: 'text' as const, content: 'test' }
|
|
51
|
+
yield { type: 'done' as const, sessionId: 'test-session' }
|
|
52
|
+
},
|
|
53
|
+
health: async () => ({ ok: true }),
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function createMockMemory(id: string): MemoryProvider {
|
|
58
|
+
return {
|
|
59
|
+
id,
|
|
60
|
+
name: `Mock ${id}`,
|
|
61
|
+
init: async () => {},
|
|
62
|
+
destroy: async () => {},
|
|
63
|
+
recall: async () => [],
|
|
64
|
+
save: async () => {},
|
|
65
|
+
health: async () => ({ ok: true }),
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
70
|
+
|
|
71
|
+
describe('PluginRegistry', () => {
|
|
72
|
+
let registry: PluginRegistry
|
|
73
|
+
|
|
74
|
+
beforeEach(() => {
|
|
75
|
+
registry = new PluginRegistry()
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
// ─── Channel Registration ──────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
describe('channels — platform adapters (Discord, Telegram, etc.)', () => {
|
|
81
|
+
it('registers and lists a channel by id', () => {
|
|
82
|
+
registry.registerChannel(createMockChannel('discord'))
|
|
83
|
+
expect(registry.listChannels()).toEqual(['discord'])
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('throws on duplicate channel id (fail-fast)', () => {
|
|
87
|
+
registry.registerChannel(createMockChannel('discord'))
|
|
88
|
+
expect(() => registry.registerChannel(createMockChannel('discord'))).toThrow(
|
|
89
|
+
"Channel 'discord' already registered",
|
|
90
|
+
)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('supports multiple simultaneous channels', () => {
|
|
94
|
+
registry.registerChannel(createMockChannel('discord'))
|
|
95
|
+
registry.registerChannel(createMockChannel('telegram'))
|
|
96
|
+
expect(registry.listChannels()).toEqual(['discord', 'telegram'])
|
|
97
|
+
})
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
// ─── Agent Registration ────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
describe('agents — LLM backends (Claude, GPT, etc.)', () => {
|
|
103
|
+
it('registers and lists an agent', () => {
|
|
104
|
+
registry.registerAgent(createMockAgent('claude'))
|
|
105
|
+
expect(registry.listAgents()).toEqual(['claude'])
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('first registered agent becomes the default', () => {
|
|
109
|
+
registry.registerAgent(createMockAgent('claude'))
|
|
110
|
+
expect(registry.getAgent()!.id).toBe('claude')
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('explicit default=true overrides first-wins', () => {
|
|
114
|
+
registry.registerAgent(createMockAgent('gpt'))
|
|
115
|
+
registry.registerAgent(createMockAgent('claude'), true)
|
|
116
|
+
expect(registry.getAgent()!.id).toBe('claude')
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('throws on duplicate agent id (fail-fast)', () => {
|
|
120
|
+
registry.registerAgent(createMockAgent('claude'))
|
|
121
|
+
expect(() => registry.registerAgent(createMockAgent('claude'))).toThrow(
|
|
122
|
+
"Agent 'claude' already registered",
|
|
123
|
+
)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('retrieves specific agent by id', () => {
|
|
127
|
+
registry.registerAgent(createMockAgent('claude'))
|
|
128
|
+
registry.registerAgent(createMockAgent('gpt'))
|
|
129
|
+
|
|
130
|
+
expect(registry.getAgent('gpt')!.id).toBe('gpt')
|
|
131
|
+
expect(registry.getAgent('claude')!.id).toBe('claude')
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('returns undefined for unknown agent id', () => {
|
|
135
|
+
expect(registry.getAgent('nonexistent')).toBeUndefined()
|
|
136
|
+
})
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
// ─── Memory Registration ───────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
describe('memory — semantic memory backends (Brain, Mem0, etc.)', () => {
|
|
142
|
+
it('registers and retrieves memory provider', () => {
|
|
143
|
+
registry.registerMemory(createMockMemory('brain'))
|
|
144
|
+
expect(registry.getMemory('brain')!.id).toBe('brain')
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('returns undefined for unknown provider id', () => {
|
|
148
|
+
expect(registry.getMemory('nonexistent')).toBeUndefined()
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('supports multiple concurrent providers', () => {
|
|
152
|
+
registry.registerMemory(createMockMemory('brain'))
|
|
153
|
+
registry.registerMemory(createMockMemory('engram'))
|
|
154
|
+
expect(registry.listMemory()).toEqual(['brain', 'engram'])
|
|
155
|
+
})
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
// ─── Lifecycle ─────────────────────────────────────────────────
|
|
159
|
+
|
|
160
|
+
describe('lifecycle — initAll / destroyAll', () => {
|
|
161
|
+
it('initAll initializes channels, agents, and memory', async () => {
|
|
162
|
+
const inited = { channel: false, agent: false, memory: false }
|
|
163
|
+
|
|
164
|
+
const channel = createMockChannel('discord')
|
|
165
|
+
channel.connect = async () => {
|
|
166
|
+
inited.channel = true
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const agent = createMockAgent('claude')
|
|
170
|
+
agent.init = async () => {
|
|
171
|
+
inited.agent = true
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const memory = createMockMemory('brain')
|
|
175
|
+
memory.init = async () => {
|
|
176
|
+
inited.memory = true
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
registry.registerChannel(channel)
|
|
180
|
+
registry.registerAgent(agent)
|
|
181
|
+
registry.registerMemory(memory)
|
|
182
|
+
|
|
183
|
+
await registry.initAll()
|
|
184
|
+
|
|
185
|
+
expect(inited.channel).toBe(true)
|
|
186
|
+
expect(inited.agent).toBe(true)
|
|
187
|
+
expect(inited.memory).toBe(true)
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it('initAll continues even when one plugin fails (resilient)', async () => {
|
|
191
|
+
const channel = createMockChannel('discord')
|
|
192
|
+
channel.connect = async () => {
|
|
193
|
+
throw new Error('Connect failed')
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const agent = createMockAgent('claude')
|
|
197
|
+
let agentInited = false
|
|
198
|
+
agent.init = async () => {
|
|
199
|
+
agentInited = true
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
registry.registerChannel(channel)
|
|
203
|
+
registry.registerAgent(agent)
|
|
204
|
+
|
|
205
|
+
// Should NOT throw — uses Promise.allSettled internally
|
|
206
|
+
await registry.initAll()
|
|
207
|
+
expect(agentInited).toBe(true)
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
it('destroyAll tears down all plugins', async () => {
|
|
211
|
+
const destroyed = { channel: false, agent: false, memory: false }
|
|
212
|
+
|
|
213
|
+
const channel = createMockChannel('discord')
|
|
214
|
+
channel.disconnect = async () => {
|
|
215
|
+
destroyed.channel = true
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const agent = createMockAgent('claude')
|
|
219
|
+
agent.destroy = async () => {
|
|
220
|
+
destroyed.agent = true
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const memory = createMockMemory('brain')
|
|
224
|
+
memory.destroy = async () => {
|
|
225
|
+
destroyed.memory = true
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
registry.registerChannel(channel)
|
|
229
|
+
registry.registerAgent(agent)
|
|
230
|
+
registry.registerMemory(memory)
|
|
231
|
+
|
|
232
|
+
await registry.destroyAll()
|
|
233
|
+
|
|
234
|
+
expect(destroyed.channel).toBe(true)
|
|
235
|
+
expect(destroyed.agent).toBe(true)
|
|
236
|
+
expect(destroyed.memory).toBe(true)
|
|
237
|
+
})
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
// ─── Empty state ───────────────────────────────────────────────
|
|
241
|
+
|
|
242
|
+
describe('empty registry — valid zero-plugin state', () => {
|
|
243
|
+
it('all lists return empty arrays', () => {
|
|
244
|
+
expect(registry.listChannels()).toEqual([])
|
|
245
|
+
expect(registry.listAgents()).toEqual([])
|
|
246
|
+
expect(registry.listMemory()).toEqual([])
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
it('getAgent returns undefined (no default)', () => {
|
|
250
|
+
expect(registry.getAgent()).toBeUndefined()
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
it('lifecycle methods are no-ops on empty registry', async () => {
|
|
254
|
+
await registry.initAll()
|
|
255
|
+
await registry.destroyAll()
|
|
256
|
+
})
|
|
257
|
+
})
|
|
258
|
+
})
|
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /remind Command — Test Suite
|
|
3
|
+
*
|
|
4
|
+
* Key contracts:
|
|
5
|
+
* - Parses relative time: "in 15m", "in 2h"
|
|
6
|
+
* - Parses absolute time: "14:30" (HH:MM today or tomorrow)
|
|
7
|
+
* - Parses ISO-8601: "2026-03-01T15:30", "2026-03-01T15:30Z"
|
|
8
|
+
* - Creates a one-shot scheduler job with kind: 'at'
|
|
9
|
+
* - Returns usage help when no args
|
|
10
|
+
* - Returns error when scheduler is not available
|
|
11
|
+
* - Returns error for invalid time formats
|
|
12
|
+
* - Uses channel persona name as channel, defaults to 'orbit'
|
|
13
|
+
*/
|
|
14
|
+
import { beforeEach, describe, expect, mock, test } from 'bun:test'
|
|
15
|
+
import type { CommandContext } from '../lib/command-handler'
|
|
16
|
+
import { remindCommand } from '../lib/commands/remind'
|
|
17
|
+
import type { Job } from '../lib/scheduler'
|
|
18
|
+
import { SessionTracker } from '../lib/session-tracker'
|
|
19
|
+
|
|
20
|
+
// ═══════════════════════════════════════════════════════════
|
|
21
|
+
// Mock store and scheduler
|
|
22
|
+
// ═══════════════════════════════════════════════════════════
|
|
23
|
+
|
|
24
|
+
function makeStore() {
|
|
25
|
+
return {
|
|
26
|
+
add: mock((_job: Job) => {}),
|
|
27
|
+
list: mock(() => []),
|
|
28
|
+
remove: mock(() => false),
|
|
29
|
+
get: mock(() => null),
|
|
30
|
+
enable: mock(() => {}),
|
|
31
|
+
disable: mock(() => {}),
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function makeScheduler() {
|
|
36
|
+
const store = makeStore()
|
|
37
|
+
return {
|
|
38
|
+
scheduler: { getStore: () => store },
|
|
39
|
+
store,
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function makeCtx(overrides?: Partial<CommandContext>): CommandContext {
|
|
44
|
+
return {
|
|
45
|
+
sessionKey: 'test:channel-1',
|
|
46
|
+
message: {
|
|
47
|
+
id: 'msg-1',
|
|
48
|
+
channelId: 'channel-1',
|
|
49
|
+
sender: { id: 'user-1', name: 'Test User' },
|
|
50
|
+
text: '/remind',
|
|
51
|
+
timestamp: new Date(),
|
|
52
|
+
},
|
|
53
|
+
tracker: new SessionTracker(),
|
|
54
|
+
daemonStartedAt: Date.now(),
|
|
55
|
+
agent: { id: 'claude', name: 'Claude Code (CLI)' },
|
|
56
|
+
clearSession: async () => ({ sessionCleared: true, hooksRan: false }),
|
|
57
|
+
...overrides,
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ═══════════════════════════════════════════════════════════
|
|
62
|
+
// No scheduler
|
|
63
|
+
// ═══════════════════════════════════════════════════════════
|
|
64
|
+
|
|
65
|
+
describe('/remind — no scheduler', () => {
|
|
66
|
+
test('returns error when scheduler is not available', async () => {
|
|
67
|
+
const result = await remindCommand('in 15m test', makeCtx({ scheduler: undefined }))
|
|
68
|
+
expect(result).toContain('Scheduler is not enabled')
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
// ═══════════════════════════════════════════════════════════
|
|
73
|
+
// Usage / empty args
|
|
74
|
+
// ═══════════════════════════════════════════════════════════
|
|
75
|
+
|
|
76
|
+
describe('/remind usage', () => {
|
|
77
|
+
test('returns usage when no args', async () => {
|
|
78
|
+
const { scheduler } = makeScheduler()
|
|
79
|
+
const result = await remindCommand('', makeCtx({ scheduler: scheduler as any }))
|
|
80
|
+
expect(result).toContain('Usage')
|
|
81
|
+
expect(result).toContain('/remind')
|
|
82
|
+
expect(result).toContain('in 15m')
|
|
83
|
+
expect(result).toContain('in 2h')
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
test('returns usage when only whitespace', async () => {
|
|
87
|
+
const { scheduler } = makeScheduler()
|
|
88
|
+
const result = await remindCommand(' ', makeCtx({ scheduler: scheduler as any }))
|
|
89
|
+
expect(result).toContain('Usage')
|
|
90
|
+
})
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
// ═══════════════════════════════════════════════════════════
|
|
94
|
+
// Invalid format
|
|
95
|
+
// ═══════════════════════════════════════════════════════════
|
|
96
|
+
|
|
97
|
+
describe('/remind invalid format', () => {
|
|
98
|
+
test('rejects unparseable time format', async () => {
|
|
99
|
+
const { scheduler } = makeScheduler()
|
|
100
|
+
const result = await remindCommand(
|
|
101
|
+
'tomorrow Do something',
|
|
102
|
+
makeCtx({ scheduler: scheduler as any }),
|
|
103
|
+
)
|
|
104
|
+
expect(result).toContain('Invalid format')
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
test('rejects time without message', async () => {
|
|
108
|
+
const { scheduler } = makeScheduler()
|
|
109
|
+
const result = await remindCommand('in 15m', makeCtx({ scheduler: scheduler as any }))
|
|
110
|
+
expect(result).toContain('Invalid format')
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
test('rejects "in" without valid duration', async () => {
|
|
114
|
+
const { scheduler } = makeScheduler()
|
|
115
|
+
const result = await remindCommand('in abc Do thing', makeCtx({ scheduler: scheduler as any }))
|
|
116
|
+
expect(result).toContain('Invalid format')
|
|
117
|
+
})
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
// ═══════════════════════════════════════════════════════════
|
|
121
|
+
// Relative time: "in Xm" / "in Xh"
|
|
122
|
+
// ═══════════════════════════════════════════════════════════
|
|
123
|
+
|
|
124
|
+
describe('/remind relative time', () => {
|
|
125
|
+
test('parses "in 15m" and creates job', async () => {
|
|
126
|
+
const { scheduler, store } = makeScheduler()
|
|
127
|
+
const before = Date.now()
|
|
128
|
+
const result = await remindCommand(
|
|
129
|
+
'in 15m Turn off oven',
|
|
130
|
+
makeCtx({ scheduler: scheduler as any }),
|
|
131
|
+
)
|
|
132
|
+
const after = Date.now()
|
|
133
|
+
|
|
134
|
+
expect(result).toContain('Reminder set')
|
|
135
|
+
expect(store.add).toHaveBeenCalledTimes(1)
|
|
136
|
+
|
|
137
|
+
const job = (store.add as any).mock.calls[0][0] as Job
|
|
138
|
+
expect(job.schedule.kind).toBe('at')
|
|
139
|
+
expect(job.payload).toEqual({ kind: 'message', text: 'Turn off oven' })
|
|
140
|
+
expect(job.oneShot).toBe(true)
|
|
141
|
+
expect(job.enabled).toBe(true)
|
|
142
|
+
|
|
143
|
+
// Verify the scheduled time is ~15 minutes in the future
|
|
144
|
+
const atTime = new Date((job.schedule as any).at).getTime()
|
|
145
|
+
const expectedMin = before + 15 * 60_000
|
|
146
|
+
const expectedMax = after + 15 * 60_000
|
|
147
|
+
expect(atTime).toBeGreaterThanOrEqual(expectedMin - 100)
|
|
148
|
+
expect(atTime).toBeLessThanOrEqual(expectedMax + 100)
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
test('parses "in 2h" and creates job', async () => {
|
|
152
|
+
const { scheduler, store } = makeScheduler()
|
|
153
|
+
const before = Date.now()
|
|
154
|
+
const result = await remindCommand(
|
|
155
|
+
'in 2h Check email',
|
|
156
|
+
makeCtx({ scheduler: scheduler as any }),
|
|
157
|
+
)
|
|
158
|
+
const after = Date.now()
|
|
159
|
+
|
|
160
|
+
expect(result).toContain('Reminder set')
|
|
161
|
+
expect(store.add).toHaveBeenCalledTimes(1)
|
|
162
|
+
|
|
163
|
+
const job = (store.add as any).mock.calls[0][0] as Job
|
|
164
|
+
const atTime = new Date((job.schedule as any).at).getTime()
|
|
165
|
+
const expectedMin = before + 2 * 3600_000
|
|
166
|
+
const expectedMax = after + 2 * 3600_000
|
|
167
|
+
expect(atTime).toBeGreaterThanOrEqual(expectedMin - 100)
|
|
168
|
+
expect(atTime).toBeLessThanOrEqual(expectedMax + 100)
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
test('parses "in 1m" for short durations', async () => {
|
|
172
|
+
const { scheduler, store } = makeScheduler()
|
|
173
|
+
const before = Date.now()
|
|
174
|
+
await remindCommand('in 1m Quick task', makeCtx({ scheduler: scheduler as any }))
|
|
175
|
+
|
|
176
|
+
const job = (store.add as any).mock.calls[0][0] as Job
|
|
177
|
+
const atTime = new Date((job.schedule as any).at).getTime()
|
|
178
|
+
expect(atTime).toBeGreaterThanOrEqual(before + 60_000 - 100)
|
|
179
|
+
})
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
// ═══════════════════════════════════════════════════════════
|
|
183
|
+
// Absolute time: HH:MM
|
|
184
|
+
// ═══════════════════════════════════════════════════════════
|
|
185
|
+
|
|
186
|
+
describe('/remind absolute time HH:MM', () => {
|
|
187
|
+
test('parses "14:30" format and creates job', async () => {
|
|
188
|
+
const { scheduler, store } = makeScheduler()
|
|
189
|
+
const result = await remindCommand(
|
|
190
|
+
'14:30 Daily summary',
|
|
191
|
+
makeCtx({ scheduler: scheduler as any }),
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
expect(result).toContain('Reminder set')
|
|
195
|
+
expect(store.add).toHaveBeenCalledTimes(1)
|
|
196
|
+
|
|
197
|
+
const job = (store.add as any).mock.calls[0][0] as Job
|
|
198
|
+
expect(job.schedule.kind).toBe('at')
|
|
199
|
+
expect(job.payload).toEqual({ kind: 'message', text: 'Daily summary' })
|
|
200
|
+
expect(job.oneShot).toBe(true)
|
|
201
|
+
|
|
202
|
+
// The scheduled time should have hours=14, minutes=30
|
|
203
|
+
const atTime = new Date((job.schedule as any).at)
|
|
204
|
+
// Either today or tomorrow depending on current time
|
|
205
|
+
expect(atTime.getHours()).toBe(14)
|
|
206
|
+
expect(atTime.getMinutes()).toBe(30)
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
test('parses "09:00" with leading zero', async () => {
|
|
210
|
+
const { scheduler, store } = makeScheduler()
|
|
211
|
+
await remindCommand('09:00 Morning standup', makeCtx({ scheduler: scheduler as any }))
|
|
212
|
+
|
|
213
|
+
const job = (store.add as any).mock.calls[0][0] as Job
|
|
214
|
+
const atTime = new Date((job.schedule as any).at)
|
|
215
|
+
expect(atTime.getHours()).toBe(9)
|
|
216
|
+
expect(atTime.getMinutes()).toBe(0)
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
test('if time already passed today, schedules for tomorrow', async () => {
|
|
220
|
+
const { scheduler, store } = makeScheduler()
|
|
221
|
+
// Use 00:01 which has almost certainly already passed (test runs during the day)
|
|
222
|
+
await remindCommand('00:01 Midnight check', makeCtx({ scheduler: scheduler as any }))
|
|
223
|
+
|
|
224
|
+
const job = (store.add as any).mock.calls[0][0] as Job
|
|
225
|
+
const atTime = new Date((job.schedule as any).at)
|
|
226
|
+
// Should be in the future
|
|
227
|
+
expect(atTime.getTime()).toBeGreaterThan(Date.now())
|
|
228
|
+
})
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
// ═══════════════════════════════════════════════════════════
|
|
232
|
+
// ISO-8601 time
|
|
233
|
+
// ═══════════════════════════════════════════════════════════
|
|
234
|
+
|
|
235
|
+
describe('/remind ISO-8601 time', () => {
|
|
236
|
+
test('parses ISO-8601 date without timezone', async () => {
|
|
237
|
+
const { scheduler, store } = makeScheduler()
|
|
238
|
+
const result = await remindCommand(
|
|
239
|
+
'2026-12-25T10:00 Christmas check',
|
|
240
|
+
makeCtx({ scheduler: scheduler as any }),
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
expect(result).toContain('Reminder set')
|
|
244
|
+
expect(store.add).toHaveBeenCalledTimes(1)
|
|
245
|
+
|
|
246
|
+
const job = (store.add as any).mock.calls[0][0] as Job
|
|
247
|
+
expect(job.schedule.kind).toBe('at')
|
|
248
|
+
const atTime = new Date((job.schedule as any).at)
|
|
249
|
+
expect(atTime.getFullYear()).toBe(2026)
|
|
250
|
+
expect(atTime.getMonth()).toBe(11) // December = 11
|
|
251
|
+
expect(atTime.getDate()).toBe(25)
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
test('parses ISO-8601 date with Z timezone', async () => {
|
|
255
|
+
const { scheduler, store } = makeScheduler()
|
|
256
|
+
const result = await remindCommand(
|
|
257
|
+
'2026-06-15T08:30Z Morning meeting',
|
|
258
|
+
makeCtx({ scheduler: scheduler as any }),
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
expect(result).toContain('Reminder set')
|
|
262
|
+
const job = (store.add as any).mock.calls[0][0] as Job
|
|
263
|
+
const atTime = new Date((job.schedule as any).at)
|
|
264
|
+
expect(atTime.getUTCHours()).toBe(8)
|
|
265
|
+
expect(atTime.getUTCMinutes()).toBe(30)
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
test('parses ISO-8601 date with offset timezone', async () => {
|
|
269
|
+
const { scheduler, store } = makeScheduler()
|
|
270
|
+
const result = await remindCommand(
|
|
271
|
+
'2026-06-15T08:30+05:00 Meeting',
|
|
272
|
+
makeCtx({ scheduler: scheduler as any }),
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
expect(result).toContain('Reminder set')
|
|
276
|
+
const job = (store.add as any).mock.calls[0][0] as Job
|
|
277
|
+
const atTime = new Date((job.schedule as any).at)
|
|
278
|
+
// 08:30+05:00 = 03:30 UTC
|
|
279
|
+
expect(atTime.getUTCHours()).toBe(3)
|
|
280
|
+
expect(atTime.getUTCMinutes()).toBe(30)
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
test('parses ISO-8601 with seconds', async () => {
|
|
284
|
+
const { scheduler, store } = makeScheduler()
|
|
285
|
+
const result = await remindCommand(
|
|
286
|
+
'2026-06-15T08:30:45Z Precise time',
|
|
287
|
+
makeCtx({ scheduler: scheduler as any }),
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
expect(result).toContain('Reminder set')
|
|
291
|
+
const job = (store.add as any).mock.calls[0][0] as Job
|
|
292
|
+
const atTime = new Date((job.schedule as any).at)
|
|
293
|
+
expect(atTime.getUTCSeconds()).toBe(45)
|
|
294
|
+
})
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
// ═══════════════════════════════════════════════════════════
|
|
298
|
+
// Job structure
|
|
299
|
+
// ═══════════════════════════════════════════════════════════
|
|
300
|
+
|
|
301
|
+
describe('/remind job structure', () => {
|
|
302
|
+
test('uses channel persona name when available', async () => {
|
|
303
|
+
const { scheduler, store } = makeScheduler()
|
|
304
|
+
await remindCommand(
|
|
305
|
+
'in 10m Test',
|
|
306
|
+
makeCtx({
|
|
307
|
+
scheduler: scheduler as any,
|
|
308
|
+
channelPersona: { name: 'research' } as any,
|
|
309
|
+
}),
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
const job = (store.add as any).mock.calls[0][0] as Job
|
|
313
|
+
expect(job.channel).toBe('research')
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
test('defaults channel to "orbit" when no persona', async () => {
|
|
317
|
+
const { scheduler, store } = makeScheduler()
|
|
318
|
+
await remindCommand(
|
|
319
|
+
'in 10m Test',
|
|
320
|
+
makeCtx({
|
|
321
|
+
scheduler: scheduler as any,
|
|
322
|
+
channelPersona: undefined,
|
|
323
|
+
}),
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
const job = (store.add as any).mock.calls[0][0] as Job
|
|
327
|
+
expect(job.channel).toBe('orbit')
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
test('job is marked oneShot', async () => {
|
|
331
|
+
const { scheduler, store } = makeScheduler()
|
|
332
|
+
await remindCommand('in 5m Check', makeCtx({ scheduler: scheduler as any }))
|
|
333
|
+
|
|
334
|
+
const job = (store.add as any).mock.calls[0][0] as Job
|
|
335
|
+
expect(job.oneShot).toBe(true)
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
test('job has valid UUID id', async () => {
|
|
339
|
+
const { scheduler, store } = makeScheduler()
|
|
340
|
+
await remindCommand('in 5m Check', makeCtx({ scheduler: scheduler as any }))
|
|
341
|
+
|
|
342
|
+
const job = (store.add as any).mock.calls[0][0] as Job
|
|
343
|
+
// UUID format: 8-4-4-4-12
|
|
344
|
+
expect(job.id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/)
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
test('job schedule is ISO string', async () => {
|
|
348
|
+
const { scheduler, store } = makeScheduler()
|
|
349
|
+
await remindCommand('in 5m Check', makeCtx({ scheduler: scheduler as any }))
|
|
350
|
+
|
|
351
|
+
const job = (store.add as any).mock.calls[0][0] as Job
|
|
352
|
+
const at = (job.schedule as any).at
|
|
353
|
+
expect(typeof at).toBe('string')
|
|
354
|
+
// Should be a valid ISO date string
|
|
355
|
+
expect(new Date(at).toISOString()).toBe(at)
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
test('message text is trimmed', async () => {
|
|
359
|
+
const { scheduler, store } = makeScheduler()
|
|
360
|
+
await remindCommand('in 5m Some spaced message ', makeCtx({ scheduler: scheduler as any }))
|
|
361
|
+
|
|
362
|
+
const job = (store.add as any).mock.calls[0][0] as Job
|
|
363
|
+
const text = (job.payload as any).text
|
|
364
|
+
// Should be trimmed
|
|
365
|
+
expect(text).not.toMatch(/^\s/)
|
|
366
|
+
expect(text).not.toMatch(/\s$/)
|
|
367
|
+
})
|
|
368
|
+
})
|