@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
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 onMars Tech
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# @onmars/lunar-core
|
|
2
|
+
|
|
3
|
+
Core runtime for [Lunar](https://github.com/onmars-tech/lunar) — types, config loader, session store, router, and plugin system.
|
|
4
|
+
|
|
5
|
+
This package is used internally by `@onmars/lunar-cli`. Install the CLI instead:
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
bun install -g @onmars/lunar-cli
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## License
|
|
12
|
+
|
|
13
|
+
MIT — [onMars Tech](https://github.com/onmars-tech)
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@onmars/lunar-core",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "src/index.ts",
|
|
6
|
+
"types": "src/index.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.ts",
|
|
9
|
+
"./lib/*": "./src/lib/*.ts"
|
|
10
|
+
},
|
|
11
|
+
"files": ["src/", "LICENSE"],
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"pino": "^10"
|
|
14
|
+
},
|
|
15
|
+
"description": "Core runtime for Lunar — types, config loader, session store, router, and plugin system",
|
|
16
|
+
"author": "onMars Tech",
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "https://github.com/onmars-tech/lunar",
|
|
21
|
+
"directory": "packages/core"
|
|
22
|
+
},
|
|
23
|
+
"homepage": "https://github.com/onmars-tech/lunar",
|
|
24
|
+
"bugs": "https://github.com/onmars-tech/lunar/issues",
|
|
25
|
+
"keywords": ["lunar", "ai", "chatbot", "bun", "runtime"],
|
|
26
|
+
"publishConfig": {
|
|
27
|
+
"access": "public"
|
|
28
|
+
},
|
|
29
|
+
"engines": {
|
|
30
|
+
"bun": ">=1.2"
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /clear Command — Test Suite
|
|
3
|
+
*
|
|
4
|
+
* Spec: clears the current session (Claude context, tokens, message history).
|
|
5
|
+
* The next query starts a fresh Claude Code session without --resume.
|
|
6
|
+
*
|
|
7
|
+
* Key contracts:
|
|
8
|
+
* - Default: runs memory hooks before clearing (graceful)
|
|
9
|
+
* - --hard: skips hooks, clears immediately
|
|
10
|
+
* - Shows previous usage stats in the response
|
|
11
|
+
* - Response confirms what was cleared
|
|
12
|
+
* - Works even when no prior session exists
|
|
13
|
+
*/
|
|
14
|
+
import { describe, expect, test } from 'bun:test'
|
|
15
|
+
import type { ClearResult, CommandContext } from '../lib/command-handler'
|
|
16
|
+
import { clearCommand } from '../lib/commands/clear'
|
|
17
|
+
import { SessionTracker } from '../lib/session-tracker'
|
|
18
|
+
|
|
19
|
+
function makeCtx(
|
|
20
|
+
overrides?: Partial<CommandContext> & {
|
|
21
|
+
clearResult?: ClearResult
|
|
22
|
+
},
|
|
23
|
+
): CommandContext {
|
|
24
|
+
const { clearResult, ...rest } = overrides ?? {}
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
sessionKey: 'test:channel-1',
|
|
28
|
+
message: {
|
|
29
|
+
id: 'msg-1',
|
|
30
|
+
channelId: 'channel-1',
|
|
31
|
+
sender: { id: 'user-1', name: 'Test User' },
|
|
32
|
+
text: '/clear',
|
|
33
|
+
timestamp: new Date(),
|
|
34
|
+
},
|
|
35
|
+
tracker: new SessionTracker(),
|
|
36
|
+
daemonStartedAt: Date.now(),
|
|
37
|
+
agent: { id: 'claude', name: 'Claude Code (CLI)' },
|
|
38
|
+
clearSession: async () => clearResult ?? { sessionCleared: true, hooksRan: false },
|
|
39
|
+
...rest,
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function makeCtxWithUsage(opts?: { clearResult?: ClearResult; moonName?: string }): CommandContext {
|
|
44
|
+
const tracker = new SessionTracker()
|
|
45
|
+
|
|
46
|
+
// Simulate a session with some usage
|
|
47
|
+
tracker.recordQuery('test:channel-1', {
|
|
48
|
+
inputTokens: 5000,
|
|
49
|
+
outputTokens: 2000,
|
|
50
|
+
cacheReadTokens: 150000,
|
|
51
|
+
cacheWriteTokens: 10000,
|
|
52
|
+
contextTokens: 165000,
|
|
53
|
+
model: 'claude-sonnet-4-5-20250514',
|
|
54
|
+
durationMs: 12000,
|
|
55
|
+
})
|
|
56
|
+
tracker.recordMessage('test:channel-1')
|
|
57
|
+
tracker.recordMessage('test:channel-1')
|
|
58
|
+
tracker.recordMessage('test:channel-1')
|
|
59
|
+
|
|
60
|
+
return makeCtx({
|
|
61
|
+
tracker,
|
|
62
|
+
clearResult: opts?.clearResult,
|
|
63
|
+
moon: opts?.moonName
|
|
64
|
+
? ({ name: opts.moonName, systemPrompt: '', files: {} } as any)
|
|
65
|
+
: undefined,
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ═══════════════════════════════════════════════════════════
|
|
70
|
+
// Basic behavior
|
|
71
|
+
// ═══════════════════════════════════════════════════════════
|
|
72
|
+
|
|
73
|
+
describe('/clear basic', () => {
|
|
74
|
+
test('returns a response (never null)', async () => {
|
|
75
|
+
const result = await clearCommand('', makeCtx())
|
|
76
|
+
expect(result).not.toBeNull()
|
|
77
|
+
expect(typeof result).toBe('string')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
test('response includes "Session Cleared"', async () => {
|
|
81
|
+
const result = await clearCommand('', makeCtx())
|
|
82
|
+
expect(result).toContain('Session Cleared')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
test('response includes "fresh session" indication', async () => {
|
|
86
|
+
const result = await clearCommand('', makeCtx())
|
|
87
|
+
expect(result).toContain('fresh session')
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
test('uses moon name in header', async () => {
|
|
91
|
+
const ctx = makeCtx({
|
|
92
|
+
moon: { name: 'Clawmos', systemPrompt: '', files: {} } as any,
|
|
93
|
+
})
|
|
94
|
+
const result = await clearCommand('', ctx)
|
|
95
|
+
expect(result).toContain('Clawmos')
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
test('defaults to "Lunar" when no moon', async () => {
|
|
99
|
+
const result = await clearCommand('', makeCtx())
|
|
100
|
+
expect(result).toContain('Lunar')
|
|
101
|
+
})
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
// ═══════════════════════════════════════════════════════════
|
|
105
|
+
// Usage display
|
|
106
|
+
// ═══════════════════════════════════════════════════════════
|
|
107
|
+
|
|
108
|
+
describe('/clear usage display', () => {
|
|
109
|
+
test('shows token count from previous session', async () => {
|
|
110
|
+
const result = await clearCommand('', makeCtxWithUsage())
|
|
111
|
+
// Total tokens: 5000 + 2000 + 150000 + 10000 = 167000 ≈ 167K
|
|
112
|
+
expect(result).toContain('167K')
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
test('shows message count', async () => {
|
|
116
|
+
const result = await clearCommand('', makeCtxWithUsage())
|
|
117
|
+
expect(result).toContain('3 messages')
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
test('shows turn count', async () => {
|
|
121
|
+
const result = await clearCommand('', makeCtxWithUsage())
|
|
122
|
+
expect(result).toContain('1 turn')
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
test('shows "no active session data" when no prior usage', async () => {
|
|
126
|
+
const result = await clearCommand('', makeCtx())
|
|
127
|
+
expect(result).toContain('no active session data')
|
|
128
|
+
})
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
// ═══════════════════════════════════════════════════════════
|
|
132
|
+
// Hooks behavior
|
|
133
|
+
// ═══════════════════════════════════════════════════════════
|
|
134
|
+
|
|
135
|
+
describe('/clear hooks', () => {
|
|
136
|
+
test('calls clearSession without skipHooks by default', async () => {
|
|
137
|
+
let capturedOpts: any = null
|
|
138
|
+
const ctx = makeCtx({
|
|
139
|
+
clearSession: async (opts) => {
|
|
140
|
+
capturedOpts = opts
|
|
141
|
+
return { sessionCleared: true, hooksRan: true }
|
|
142
|
+
},
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
await clearCommand('', ctx)
|
|
146
|
+
expect(capturedOpts).toEqual({ skipHooks: false })
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
test('shows hooks ran when result.hooksRan is true', async () => {
|
|
150
|
+
const ctx = makeCtxWithUsage({
|
|
151
|
+
clearResult: { sessionCleared: true, hooksRan: true },
|
|
152
|
+
})
|
|
153
|
+
const result = await clearCommand('', ctx)
|
|
154
|
+
expect(result).toContain('hooks ran')
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
test('shows "no hooks to run" when graceful but no hooks ran', async () => {
|
|
158
|
+
const ctx = makeCtxWithUsage({
|
|
159
|
+
clearResult: { sessionCleared: true, hooksRan: false },
|
|
160
|
+
})
|
|
161
|
+
const result = await clearCommand('', ctx)
|
|
162
|
+
expect(result).toContain('no hooks to run')
|
|
163
|
+
})
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
// ═══════════════════════════════════════════════════════════
|
|
167
|
+
// --hard flag
|
|
168
|
+
// ═══════════════════════════════════════════════════════════
|
|
169
|
+
|
|
170
|
+
describe('/clear --hard', () => {
|
|
171
|
+
test('passes skipHooks: true with --hard flag', async () => {
|
|
172
|
+
let capturedOpts: any = null
|
|
173
|
+
const ctx = makeCtx({
|
|
174
|
+
clearSession: async (opts) => {
|
|
175
|
+
capturedOpts = opts
|
|
176
|
+
return { sessionCleared: true, hooksRan: false }
|
|
177
|
+
},
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
await clearCommand('--hard', ctx)
|
|
181
|
+
expect(capturedOpts).toEqual({ skipHooks: true })
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
test('passes skipHooks: true with bare "hard" arg', async () => {
|
|
185
|
+
let capturedOpts: any = null
|
|
186
|
+
const ctx = makeCtx({
|
|
187
|
+
clearSession: async (opts) => {
|
|
188
|
+
capturedOpts = opts
|
|
189
|
+
return { sessionCleared: true, hooksRan: false }
|
|
190
|
+
},
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
await clearCommand('hard', ctx)
|
|
194
|
+
expect(capturedOpts).toEqual({ skipHooks: true })
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
test('shows "hooks skipped" in response', async () => {
|
|
198
|
+
const result = await clearCommand('--hard', makeCtx())
|
|
199
|
+
expect(result).toContain('hooks skipped')
|
|
200
|
+
expect(result).toContain('--hard')
|
|
201
|
+
})
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
// ═══════════════════════════════════════════════════════════
|
|
205
|
+
// Cost display
|
|
206
|
+
// ═══════════════════════════════════════════════════════════
|
|
207
|
+
|
|
208
|
+
describe('/clear cost', () => {
|
|
209
|
+
test('shows cost estimate when > 0', async () => {
|
|
210
|
+
const result = await clearCommand('', makeCtxWithUsage())
|
|
211
|
+
// Should show some cost (Sonnet pricing on 167K tokens)
|
|
212
|
+
expect(result).toMatch(/\$/)
|
|
213
|
+
})
|
|
214
|
+
})
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Command Handler — Test Suite
|
|
3
|
+
*
|
|
4
|
+
* Spec: intercepts slash commands before they reach the agent.
|
|
5
|
+
* Commands are processed at zero token cost with instant response.
|
|
6
|
+
*
|
|
7
|
+
* Key contracts:
|
|
8
|
+
* - Only messages starting with "/" are candidates
|
|
9
|
+
* - Only registered commands are handled
|
|
10
|
+
* - Unknown /commands pass through to agent (return null)
|
|
11
|
+
* - Args are parsed correctly (text after command name)
|
|
12
|
+
* - Commands can be async
|
|
13
|
+
*/
|
|
14
|
+
import { describe, expect, test } from 'bun:test'
|
|
15
|
+
import { type CommandContext, CommandHandler } from '../lib/command-handler'
|
|
16
|
+
import { SessionTracker } from '../lib/session-tracker'
|
|
17
|
+
|
|
18
|
+
function makeCtx(overrides?: Partial<CommandContext>): CommandContext {
|
|
19
|
+
return {
|
|
20
|
+
sessionKey: 'test:channel-1',
|
|
21
|
+
message: {
|
|
22
|
+
id: 'msg-1',
|
|
23
|
+
channelId: 'channel-1',
|
|
24
|
+
sender: { id: 'user-1', name: 'Test User' },
|
|
25
|
+
text: '/test',
|
|
26
|
+
timestamp: new Date(),
|
|
27
|
+
},
|
|
28
|
+
tracker: new SessionTracker(),
|
|
29
|
+
daemonStartedAt: Date.now(),
|
|
30
|
+
agent: { id: 'claude', name: 'Claude Code (CLI)' },
|
|
31
|
+
clearSession: async () => ({ sessionCleared: true, hooksRan: false }),
|
|
32
|
+
...overrides,
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ═══════════════════════════════════════════════════════════
|
|
37
|
+
// Command detection
|
|
38
|
+
// ═══════════════════════════════════════════════════════════
|
|
39
|
+
|
|
40
|
+
describe('isCommand', () => {
|
|
41
|
+
test('recognizes registered commands', () => {
|
|
42
|
+
const handler = new CommandHandler()
|
|
43
|
+
handler.register('status', 'Show status', () => 'ok')
|
|
44
|
+
|
|
45
|
+
expect(handler.isCommand('/status')).toBe(true)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
test('case insensitive', () => {
|
|
49
|
+
const handler = new CommandHandler()
|
|
50
|
+
handler.register('status', 'Show status', () => 'ok')
|
|
51
|
+
|
|
52
|
+
expect(handler.isCommand('/STATUS')).toBe(true)
|
|
53
|
+
expect(handler.isCommand('/Status')).toBe(true)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test('rejects unregistered commands', () => {
|
|
57
|
+
const handler = new CommandHandler()
|
|
58
|
+
handler.register('status', 'Show status', () => 'ok')
|
|
59
|
+
|
|
60
|
+
expect(handler.isCommand('/unknown')).toBe(false)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test('rejects non-slash messages', () => {
|
|
64
|
+
const handler = new CommandHandler()
|
|
65
|
+
handler.register('status', 'Show status', () => 'ok')
|
|
66
|
+
|
|
67
|
+
expect(handler.isCommand('hello')).toBe(false)
|
|
68
|
+
expect(handler.isCommand('status')).toBe(false)
|
|
69
|
+
expect(handler.isCommand('')).toBe(false)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test('handles whitespace', () => {
|
|
73
|
+
const handler = new CommandHandler()
|
|
74
|
+
handler.register('status', 'Show status', () => 'ok')
|
|
75
|
+
|
|
76
|
+
expect(handler.isCommand(' /status ')).toBe(true)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
test('handles command with args', () => {
|
|
80
|
+
const handler = new CommandHandler()
|
|
81
|
+
handler.register('status', 'Show status', () => 'ok')
|
|
82
|
+
|
|
83
|
+
expect(handler.isCommand('/status verbose')).toBe(true)
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
// ═══════════════════════════════════════════════════════════
|
|
88
|
+
// Command handling
|
|
89
|
+
// ═══════════════════════════════════════════════════════════
|
|
90
|
+
|
|
91
|
+
describe('handle', () => {
|
|
92
|
+
test('executes registered command', async () => {
|
|
93
|
+
const handler = new CommandHandler()
|
|
94
|
+
handler.register('status', 'Show status', () => 'Status: OK')
|
|
95
|
+
|
|
96
|
+
const result = await handler.handle('/status', makeCtx())
|
|
97
|
+
expect(result).toBe('Status: OK')
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
test('passes args to handler', async () => {
|
|
101
|
+
const handler = new CommandHandler()
|
|
102
|
+
handler.register('echo', 'Echo text', (args) => `Echo: ${args}`)
|
|
103
|
+
|
|
104
|
+
const result = await handler.handle('/echo hello world', makeCtx())
|
|
105
|
+
expect(result).toBe('Echo: hello world')
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
test('returns null for non-commands', async () => {
|
|
109
|
+
const handler = new CommandHandler()
|
|
110
|
+
handler.register('status', 'Show status', () => 'ok')
|
|
111
|
+
|
|
112
|
+
const result = await handler.handle('hello', makeCtx())
|
|
113
|
+
expect(result).toBeNull()
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
test('returns null for unregistered commands', async () => {
|
|
117
|
+
const handler = new CommandHandler()
|
|
118
|
+
handler.register('status', 'Show status', () => 'ok')
|
|
119
|
+
|
|
120
|
+
const result = await handler.handle('/unknown', makeCtx())
|
|
121
|
+
expect(result).toBeNull()
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
test('supports async handlers', async () => {
|
|
125
|
+
const handler = new CommandHandler()
|
|
126
|
+
handler.register('async', 'Async test', async () => {
|
|
127
|
+
await new Promise((resolve) => setTimeout(resolve, 1))
|
|
128
|
+
return 'async result'
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
const result = await handler.handle('/async', makeCtx())
|
|
132
|
+
expect(result).toBe('async result')
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
test('handler can return null to pass through', async () => {
|
|
136
|
+
const handler = new CommandHandler()
|
|
137
|
+
handler.register('maybe', 'Maybe handle', (args) => {
|
|
138
|
+
return args === 'yes' ? 'handled' : null
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
expect(await handler.handle('/maybe yes', makeCtx())).toBe('handled')
|
|
142
|
+
expect(await handler.handle('/maybe no', makeCtx())).toBeNull()
|
|
143
|
+
})
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
// ═══════════════════════════════════════════════════════════
|
|
147
|
+
// Registration and listing
|
|
148
|
+
// ═══════════════════════════════════════════════════════════
|
|
149
|
+
|
|
150
|
+
describe('register and list', () => {
|
|
151
|
+
test('list returns all registered commands', () => {
|
|
152
|
+
const handler = new CommandHandler()
|
|
153
|
+
handler.register('status', 'Show status', () => 'ok')
|
|
154
|
+
handler.register('help', 'Show help', () => 'ok')
|
|
155
|
+
|
|
156
|
+
const commands = handler.list()
|
|
157
|
+
expect(commands).toHaveLength(2)
|
|
158
|
+
expect(commands.find((c) => c.name === 'status')?.description).toBe('Show status')
|
|
159
|
+
expect(commands.find((c) => c.name === 'help')?.description).toBe('Show help')
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
test('register with leading / is normalized', () => {
|
|
163
|
+
const handler = new CommandHandler()
|
|
164
|
+
handler.register('/status', 'Show status', () => 'ok')
|
|
165
|
+
|
|
166
|
+
expect(handler.isCommand('/status')).toBe(true)
|
|
167
|
+
expect(handler.list()[0].name).toBe('status')
|
|
168
|
+
})
|
|
169
|
+
})
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /compact Command — Test Suite
|
|
3
|
+
*
|
|
4
|
+
* Spec: force context compaction (clears session with different wording).
|
|
5
|
+
*
|
|
6
|
+
* Key contracts:
|
|
7
|
+
* - Returns "Session Compacted" (not "Session Cleared")
|
|
8
|
+
* - Delegates to clearSession
|
|
9
|
+
* - Forwards --hard flag
|
|
10
|
+
* - Shows "fresh session" indication
|
|
11
|
+
*/
|
|
12
|
+
import { describe, expect, test } from 'bun:test'
|
|
13
|
+
import type { ClearResult, CommandContext } from '../lib/command-handler'
|
|
14
|
+
import { compactCommand } from '../lib/commands/compact'
|
|
15
|
+
import { SessionTracker } from '../lib/session-tracker'
|
|
16
|
+
|
|
17
|
+
function makeCtx(
|
|
18
|
+
overrides?: Partial<CommandContext> & {
|
|
19
|
+
clearResult?: ClearResult
|
|
20
|
+
},
|
|
21
|
+
): CommandContext {
|
|
22
|
+
const { clearResult, ...rest } = overrides ?? {}
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
sessionKey: 'test:channel-1',
|
|
26
|
+
message: {
|
|
27
|
+
id: 'msg-1',
|
|
28
|
+
channelId: 'channel-1',
|
|
29
|
+
sender: { id: 'user-1', name: 'Test User' },
|
|
30
|
+
text: '/compact',
|
|
31
|
+
timestamp: new Date(),
|
|
32
|
+
},
|
|
33
|
+
tracker: new SessionTracker(),
|
|
34
|
+
daemonStartedAt: Date.now(),
|
|
35
|
+
agent: { id: 'claude', name: 'Claude Code (CLI)' },
|
|
36
|
+
clearSession: async () => clearResult ?? { sessionCleared: true, hooksRan: false },
|
|
37
|
+
...rest,
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ═══════════════════════════════════════════════════════════
|
|
42
|
+
// Basic behavior
|
|
43
|
+
// ═══════════════════════════════════════════════════════════
|
|
44
|
+
|
|
45
|
+
describe('/compact basic', () => {
|
|
46
|
+
test('returns "Session Compacted" (not "Session Cleared")', async () => {
|
|
47
|
+
const result = await compactCommand('', makeCtx())
|
|
48
|
+
expect(result).toContain('Session Compacted')
|
|
49
|
+
expect(result).not.toContain('Session Cleared')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test('delegates to clearSession', async () => {
|
|
53
|
+
let clearCalled = false
|
|
54
|
+
const ctx = makeCtx({
|
|
55
|
+
clearSession: async () => {
|
|
56
|
+
clearCalled = true
|
|
57
|
+
return { sessionCleared: true, hooksRan: false }
|
|
58
|
+
},
|
|
59
|
+
})
|
|
60
|
+
await compactCommand('', ctx)
|
|
61
|
+
expect(clearCalled).toBe(true)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test('forwards --hard flag', async () => {
|
|
65
|
+
let capturedOpts: any = null
|
|
66
|
+
const ctx = makeCtx({
|
|
67
|
+
clearSession: async (opts) => {
|
|
68
|
+
capturedOpts = opts
|
|
69
|
+
return { sessionCleared: true, hooksRan: false }
|
|
70
|
+
},
|
|
71
|
+
})
|
|
72
|
+
await compactCommand('--hard', ctx)
|
|
73
|
+
expect(capturedOpts).toEqual({ skipHooks: true })
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test('shows "fresh session" indication', async () => {
|
|
77
|
+
const result = await compactCommand('', makeCtx())
|
|
78
|
+
expect(result).toContain('fresh session')
|
|
79
|
+
})
|
|
80
|
+
})
|