@pedrohnas/opencode-telegram 0.1.0 → 1.2.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/.env.example +17 -0
- package/bunfig.toml +2 -0
- package/docs/PROGRESS.md +327 -0
- package/docs/mapping.md +326 -0
- package/docs/plans/phase-1.md +176 -0
- package/docs/plans/phase-2.md +235 -0
- package/docs/plans/phase-3.md +485 -0
- package/docs/plans/phase-4.md +566 -0
- package/docs/spec.md +2055 -0
- package/e2e/client.ts +24 -0
- package/e2e/helpers.ts +119 -0
- package/e2e/phase-0.test.ts +30 -0
- package/e2e/phase-1.test.ts +48 -0
- package/e2e/phase-2.test.ts +54 -0
- package/e2e/phase-3.test.ts +142 -0
- package/e2e/phase-4.test.ts +96 -0
- package/e2e/runner.ts +145 -0
- package/package.json +14 -12
- package/scripts/gen-session.ts +49 -0
- package/src/bot.test.ts +301 -0
- package/src/bot.ts +91 -0
- package/src/config.test.ts +130 -0
- package/src/config.ts +15 -0
- package/src/event-bus.test.ts +175 -0
- package/src/handlers/allowlist.test.ts +60 -0
- package/src/handlers/allowlist.ts +33 -0
- package/src/handlers/cancel.test.ts +105 -0
- package/src/handlers/permissions.test.ts +72 -0
- package/src/handlers/questions.test.ts +107 -0
- package/src/handlers/sessions.test.ts +479 -0
- package/src/handlers/sessions.ts +202 -0
- package/src/handlers/typing.test.ts +60 -0
- package/src/index.ts +26 -0
- package/src/pending-requests.test.ts +64 -0
- package/src/send/chunker.test.ts +74 -0
- package/src/send/draft-stream.test.ts +229 -0
- package/src/send/format.test.ts +143 -0
- package/src/send/tool-progress.test.ts +70 -0
- package/src/session-manager.test.ts +198 -0
- package/src/session-manager.ts +23 -0
- package/src/turn-manager.test.ts +155 -0
- package/src/turn-manager.ts +5 -0
- package/tsconfig.json +9 -0
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
import { describe, test, expect, mock, beforeEach } from "bun:test"
|
|
2
|
+
import {
|
|
3
|
+
formatSessionList,
|
|
4
|
+
formatSessionInfo,
|
|
5
|
+
formatHistory,
|
|
6
|
+
parseSessionCallback,
|
|
7
|
+
handleList,
|
|
8
|
+
handleRename,
|
|
9
|
+
handleDelete,
|
|
10
|
+
handleInfo,
|
|
11
|
+
handleHistory,
|
|
12
|
+
handleSummarize,
|
|
13
|
+
} from "./sessions"
|
|
14
|
+
import { SessionManager } from "../session-manager"
|
|
15
|
+
|
|
16
|
+
// --- Mock SDK factory ---
|
|
17
|
+
|
|
18
|
+
function createMockSdk(overrides: {
|
|
19
|
+
list?: any[]
|
|
20
|
+
get?: any
|
|
21
|
+
messages?: any[]
|
|
22
|
+
update?: any
|
|
23
|
+
delete?: any
|
|
24
|
+
summarize?: any
|
|
25
|
+
} = {}) {
|
|
26
|
+
return {
|
|
27
|
+
session: {
|
|
28
|
+
list: mock(async () => ({ data: overrides.list ?? [] })),
|
|
29
|
+
get: mock(async () => ({ data: overrides.get ?? {} })),
|
|
30
|
+
messages: mock(async () => ({ data: overrides.messages ?? [] })),
|
|
31
|
+
update: mock(async () => ({ data: overrides.update ?? {} })),
|
|
32
|
+
delete: mock(async () => ({ data: overrides.delete ?? true })),
|
|
33
|
+
summarize: mock(async () => ({ data: overrides.summarize ?? true })),
|
|
34
|
+
create: mock(async () => ({ data: { id: "new", title: "", directory: "/tmp" } })),
|
|
35
|
+
},
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// --- Helper: make session objects ---
|
|
40
|
+
|
|
41
|
+
function makeSession(overrides: Partial<{
|
|
42
|
+
id: string
|
|
43
|
+
title: string
|
|
44
|
+
directory: string
|
|
45
|
+
time: { created: number; updated: number; compacting?: number }
|
|
46
|
+
archived: boolean
|
|
47
|
+
}> = {}) {
|
|
48
|
+
return {
|
|
49
|
+
id: overrides.id ?? "sess-1",
|
|
50
|
+
title: overrides.title ?? "Test Session",
|
|
51
|
+
directory: overrides.directory ?? "/tmp",
|
|
52
|
+
projectID: "proj-1",
|
|
53
|
+
version: "1",
|
|
54
|
+
time: overrides.time ?? {
|
|
55
|
+
created: 1700000000000,
|
|
56
|
+
updated: 1700001000000,
|
|
57
|
+
},
|
|
58
|
+
...(overrides.archived ? { time: { ...overrides.time, created: 1700000000000, updated: 1700001000000, archived: 9999 } } : {}),
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function makeArchivedSession(id: string, title: string) {
|
|
63
|
+
return {
|
|
64
|
+
id,
|
|
65
|
+
title,
|
|
66
|
+
directory: "/tmp",
|
|
67
|
+
projectID: "proj-1",
|
|
68
|
+
version: "1",
|
|
69
|
+
time: {
|
|
70
|
+
created: 1700000000000,
|
|
71
|
+
updated: 1700001000000,
|
|
72
|
+
archived: 9999,
|
|
73
|
+
},
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ============================================================
|
|
78
|
+
// formatSessionList
|
|
79
|
+
// ============================================================
|
|
80
|
+
|
|
81
|
+
describe("formatSessionList", () => {
|
|
82
|
+
test("formats sessions as inline keyboard rows (title + date)", () => {
|
|
83
|
+
const sessions = [
|
|
84
|
+
makeSession({ id: "s1", title: "My Session", time: { created: 1700000000000, updated: 1700001000000 } }),
|
|
85
|
+
]
|
|
86
|
+
const result = formatSessionList(sessions)
|
|
87
|
+
expect(result.text).toContain("Select a session")
|
|
88
|
+
expect(result.reply_markup.inline_keyboard).toHaveLength(1)
|
|
89
|
+
const btn = result.reply_markup.inline_keyboard[0][0]
|
|
90
|
+
expect(btn.text).toContain("My Session")
|
|
91
|
+
expect(btn.callback_data).toBe("sess:s1")
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
test("filters out archived sessions", () => {
|
|
95
|
+
const sessions = [
|
|
96
|
+
makeSession({ id: "s1", title: "Active" }),
|
|
97
|
+
makeArchivedSession("s2", "Archived"),
|
|
98
|
+
]
|
|
99
|
+
const result = formatSessionList(sessions)
|
|
100
|
+
expect(result.reply_markup.inline_keyboard).toHaveLength(1)
|
|
101
|
+
expect(result.reply_markup.inline_keyboard[0][0].callback_data).toBe("sess:s1")
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
test("sorts by updated desc", () => {
|
|
105
|
+
const sessions = [
|
|
106
|
+
makeSession({ id: "old", title: "Old", time: { created: 1000, updated: 1000 } }),
|
|
107
|
+
makeSession({ id: "new", title: "New", time: { created: 2000, updated: 3000 } }),
|
|
108
|
+
]
|
|
109
|
+
const result = formatSessionList(sessions)
|
|
110
|
+
// "New" should be first (updated: 3000 > 1000)
|
|
111
|
+
expect(result.reply_markup.inline_keyboard[0][0].callback_data).toBe("sess:new")
|
|
112
|
+
expect(result.reply_markup.inline_keyboard[1][0].callback_data).toBe("sess:old")
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
test("limits to 10 sessions", () => {
|
|
116
|
+
const sessions = Array.from({ length: 15 }, (_, i) =>
|
|
117
|
+
makeSession({ id: `s${i}`, title: `Session ${i}`, time: { created: 1000, updated: 2000 + i } }),
|
|
118
|
+
)
|
|
119
|
+
const result = formatSessionList(sessions)
|
|
120
|
+
expect(result.reply_markup.inline_keyboard).toHaveLength(10)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
test("empty list returns 'No sessions found.' text", () => {
|
|
124
|
+
const result = formatSessionList([])
|
|
125
|
+
expect(result.text).toBe("No sessions found.")
|
|
126
|
+
expect(result.reply_markup.inline_keyboard).toHaveLength(0)
|
|
127
|
+
})
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
// ============================================================
|
|
131
|
+
// parseSessionCallback
|
|
132
|
+
// ============================================================
|
|
133
|
+
|
|
134
|
+
describe("parseSessionCallback", () => {
|
|
135
|
+
test("parses 'sess:abc123' correctly", () => {
|
|
136
|
+
const result = parseSessionCallback("sess:abc123")
|
|
137
|
+
expect(result).toEqual({ sessionPrefix: "abc123" })
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
test("returns null for non-sess prefix", () => {
|
|
141
|
+
expect(parseSessionCallback("invalid")).toBeNull()
|
|
142
|
+
expect(parseSessionCallback("perm:once:123")).toBeNull()
|
|
143
|
+
expect(parseSessionCallback("q:123:0")).toBeNull()
|
|
144
|
+
})
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
// ============================================================
|
|
148
|
+
// handleList
|
|
149
|
+
// ============================================================
|
|
150
|
+
|
|
151
|
+
describe("handleList", () => {
|
|
152
|
+
test("returns formatted session list from SDK", async () => {
|
|
153
|
+
const sdk = createMockSdk({
|
|
154
|
+
list: [
|
|
155
|
+
makeSession({ id: "s1", title: "Session One" }),
|
|
156
|
+
makeSession({ id: "s2", title: "Session Two" }),
|
|
157
|
+
],
|
|
158
|
+
})
|
|
159
|
+
const result = await handleList({ sdk: sdk as any })
|
|
160
|
+
expect(result.text).toContain("Select a session")
|
|
161
|
+
expect(result.reply_markup.inline_keyboard).toHaveLength(2)
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
test("returns 'No sessions found.' when list is empty", async () => {
|
|
165
|
+
const sdk = createMockSdk({ list: [] })
|
|
166
|
+
const result = await handleList({ sdk: sdk as any })
|
|
167
|
+
expect(result.text).toBe("No sessions found.")
|
|
168
|
+
})
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
// ============================================================
|
|
172
|
+
// handleRename
|
|
173
|
+
// ============================================================
|
|
174
|
+
|
|
175
|
+
describe("handleRename", () => {
|
|
176
|
+
let sm: SessionManager
|
|
177
|
+
|
|
178
|
+
beforeEach(() => {
|
|
179
|
+
sm = new SessionManager({ maxEntries: 10, ttlMs: 60000 })
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
test("returns 'No active session.' when no session mapped", async () => {
|
|
183
|
+
const sdk = createMockSdk()
|
|
184
|
+
const result = await handleRename({
|
|
185
|
+
chatKey: "123",
|
|
186
|
+
title: "New Title",
|
|
187
|
+
sdk: sdk as any,
|
|
188
|
+
sessionManager: sm,
|
|
189
|
+
})
|
|
190
|
+
expect(result).toBe("No active session.")
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
test("returns 'Session renamed to: {title}' on success", async () => {
|
|
194
|
+
sm.set("123", { sessionId: "s1", directory: "/tmp" })
|
|
195
|
+
const sdk = createMockSdk()
|
|
196
|
+
const result = await handleRename({
|
|
197
|
+
chatKey: "123",
|
|
198
|
+
title: "My New Title",
|
|
199
|
+
sdk: sdk as any,
|
|
200
|
+
sessionManager: sm,
|
|
201
|
+
})
|
|
202
|
+
expect(result).toBe("Session renamed to: My New Title")
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
test("calls sdk.session.update with correct params", async () => {
|
|
206
|
+
sm.set("123", { sessionId: "s1", directory: "/tmp" })
|
|
207
|
+
const sdk = createMockSdk()
|
|
208
|
+
await handleRename({
|
|
209
|
+
chatKey: "123",
|
|
210
|
+
title: "Updated Title",
|
|
211
|
+
sdk: sdk as any,
|
|
212
|
+
sessionManager: sm,
|
|
213
|
+
})
|
|
214
|
+
expect(sdk.session.update).toHaveBeenCalledTimes(1)
|
|
215
|
+
const call = (sdk.session.update as any).mock.calls[0][0]
|
|
216
|
+
expect(call.sessionID).toBe("s1")
|
|
217
|
+
expect(call.title).toBe("Updated Title")
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
test("returns usage message when title is empty", async () => {
|
|
221
|
+
sm.set("123", { sessionId: "s1", directory: "/tmp" })
|
|
222
|
+
const sdk = createMockSdk()
|
|
223
|
+
const result = await handleRename({
|
|
224
|
+
chatKey: "123",
|
|
225
|
+
title: "",
|
|
226
|
+
sdk: sdk as any,
|
|
227
|
+
sessionManager: sm,
|
|
228
|
+
})
|
|
229
|
+
expect(result).toBe("Usage: /rename <title>")
|
|
230
|
+
})
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
// ============================================================
|
|
234
|
+
// handleDelete
|
|
235
|
+
// ============================================================
|
|
236
|
+
|
|
237
|
+
describe("handleDelete", () => {
|
|
238
|
+
let sm: SessionManager
|
|
239
|
+
|
|
240
|
+
beforeEach(() => {
|
|
241
|
+
sm = new SessionManager({ maxEntries: 10, ttlMs: 60000 })
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
test("returns 'No active session.' when no session mapped", async () => {
|
|
245
|
+
const sdk = createMockSdk()
|
|
246
|
+
const result = await handleDelete({
|
|
247
|
+
chatKey: "123",
|
|
248
|
+
sdk: sdk as any,
|
|
249
|
+
sessionManager: sm,
|
|
250
|
+
})
|
|
251
|
+
expect(result).toBe("No active session.")
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
test("returns 'Session deleted.' on success", async () => {
|
|
255
|
+
sm.set("123", { sessionId: "s1", directory: "/tmp" })
|
|
256
|
+
const sdk = createMockSdk()
|
|
257
|
+
const result = await handleDelete({
|
|
258
|
+
chatKey: "123",
|
|
259
|
+
sdk: sdk as any,
|
|
260
|
+
sessionManager: sm,
|
|
261
|
+
})
|
|
262
|
+
expect(result).toBe("Session deleted.")
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
test("calls sdk.session.delete then sessionManager.remove", async () => {
|
|
266
|
+
sm.set("123", { sessionId: "s1", directory: "/tmp" })
|
|
267
|
+
const sdk = createMockSdk()
|
|
268
|
+
await handleDelete({
|
|
269
|
+
chatKey: "123",
|
|
270
|
+
sdk: sdk as any,
|
|
271
|
+
sessionManager: sm,
|
|
272
|
+
})
|
|
273
|
+
expect(sdk.session.delete).toHaveBeenCalledTimes(1)
|
|
274
|
+
const call = (sdk.session.delete as any).mock.calls[0][0]
|
|
275
|
+
expect(call.sessionID).toBe("s1")
|
|
276
|
+
// SessionManager mapping should be removed
|
|
277
|
+
expect(sm.get("123")).toBeUndefined()
|
|
278
|
+
})
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
// ============================================================
|
|
282
|
+
// handleInfo
|
|
283
|
+
// ============================================================
|
|
284
|
+
|
|
285
|
+
describe("handleInfo", () => {
|
|
286
|
+
let sm: SessionManager
|
|
287
|
+
|
|
288
|
+
beforeEach(() => {
|
|
289
|
+
sm = new SessionManager({ maxEntries: 10, ttlMs: 60000 })
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
test("returns 'No active session.' when no session mapped", async () => {
|
|
293
|
+
const sdk = createMockSdk()
|
|
294
|
+
const result = await handleInfo({
|
|
295
|
+
chatKey: "123",
|
|
296
|
+
sdk: sdk as any,
|
|
297
|
+
sessionManager: sm,
|
|
298
|
+
})
|
|
299
|
+
expect(result).toBe("No active session.")
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
test("returns formatted session info on success", async () => {
|
|
303
|
+
sm.set("123", { sessionId: "s1", directory: "/tmp" })
|
|
304
|
+
const sdk = createMockSdk({
|
|
305
|
+
get: {
|
|
306
|
+
id: "s1",
|
|
307
|
+
title: "My Session",
|
|
308
|
+
directory: "/home/project",
|
|
309
|
+
time: { created: 1700000000000, updated: 1700001000000 },
|
|
310
|
+
},
|
|
311
|
+
})
|
|
312
|
+
const result = await handleInfo({
|
|
313
|
+
chatKey: "123",
|
|
314
|
+
sdk: sdk as any,
|
|
315
|
+
sessionManager: sm,
|
|
316
|
+
})
|
|
317
|
+
expect(result).toContain("My Session")
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
test("includes title, created date, updated date", async () => {
|
|
321
|
+
sm.set("123", { sessionId: "s1", directory: "/tmp" })
|
|
322
|
+
const sdk = createMockSdk({
|
|
323
|
+
get: {
|
|
324
|
+
id: "s1",
|
|
325
|
+
title: "Test",
|
|
326
|
+
directory: "/home",
|
|
327
|
+
time: { created: 1700000000000, updated: 1700001000000 },
|
|
328
|
+
},
|
|
329
|
+
})
|
|
330
|
+
const result = await handleInfo({
|
|
331
|
+
chatKey: "123",
|
|
332
|
+
sdk: sdk as any,
|
|
333
|
+
sessionManager: sm,
|
|
334
|
+
})
|
|
335
|
+
expect(result).toContain("Test")
|
|
336
|
+
// Should contain some date representation
|
|
337
|
+
expect(result).toMatch(/\d{4}/) // year somewhere
|
|
338
|
+
})
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
// ============================================================
|
|
342
|
+
// formatSessionInfo
|
|
343
|
+
// ============================================================
|
|
344
|
+
|
|
345
|
+
describe("formatSessionInfo", () => {
|
|
346
|
+
test("formats session with title and dates", () => {
|
|
347
|
+
const session = {
|
|
348
|
+
id: "s1",
|
|
349
|
+
title: "My Session",
|
|
350
|
+
directory: "/home/project",
|
|
351
|
+
time: { created: 1700000000000, updated: 1700001000000 },
|
|
352
|
+
}
|
|
353
|
+
const result = formatSessionInfo(session as any)
|
|
354
|
+
expect(result).toContain("My Session")
|
|
355
|
+
expect(result).toContain("/home/project")
|
|
356
|
+
})
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
// ============================================================
|
|
360
|
+
// handleHistory
|
|
361
|
+
// ============================================================
|
|
362
|
+
|
|
363
|
+
describe("handleHistory", () => {
|
|
364
|
+
let sm: SessionManager
|
|
365
|
+
|
|
366
|
+
beforeEach(() => {
|
|
367
|
+
sm = new SessionManager({ maxEntries: 10, ttlMs: 60000 })
|
|
368
|
+
})
|
|
369
|
+
|
|
370
|
+
test("returns 'No active session.' when no session mapped", async () => {
|
|
371
|
+
const sdk = createMockSdk()
|
|
372
|
+
const result = await handleHistory({
|
|
373
|
+
chatKey: "123",
|
|
374
|
+
sdk: sdk as any,
|
|
375
|
+
sessionManager: sm,
|
|
376
|
+
})
|
|
377
|
+
expect(result).toBe("No active session.")
|
|
378
|
+
})
|
|
379
|
+
|
|
380
|
+
test("returns formatted message history", async () => {
|
|
381
|
+
sm.set("123", { sessionId: "s1", directory: "/tmp" })
|
|
382
|
+
const sdk = createMockSdk({
|
|
383
|
+
messages: [
|
|
384
|
+
{
|
|
385
|
+
info: { id: "m1", role: "user", time: { created: 1000 } },
|
|
386
|
+
parts: [{ type: "text", text: "Hello" }],
|
|
387
|
+
},
|
|
388
|
+
{
|
|
389
|
+
info: { id: "m2", role: "assistant", time: { created: 2000 } },
|
|
390
|
+
parts: [{ type: "text", text: "Hi there!" }],
|
|
391
|
+
},
|
|
392
|
+
],
|
|
393
|
+
})
|
|
394
|
+
const result = await handleHistory({
|
|
395
|
+
chatKey: "123",
|
|
396
|
+
sdk: sdk as any,
|
|
397
|
+
sessionManager: sm,
|
|
398
|
+
})
|
|
399
|
+
expect(result).toContain("Hello")
|
|
400
|
+
expect(result).toContain("Hi there!")
|
|
401
|
+
})
|
|
402
|
+
|
|
403
|
+
test("truncates long messages", async () => {
|
|
404
|
+
sm.set("123", { sessionId: "s1", directory: "/tmp" })
|
|
405
|
+
const longText = "A".repeat(1000)
|
|
406
|
+
const sdk = createMockSdk({
|
|
407
|
+
messages: [
|
|
408
|
+
{
|
|
409
|
+
info: { id: "m1", role: "user", time: { created: 1000 } },
|
|
410
|
+
parts: [{ type: "text", text: longText }],
|
|
411
|
+
},
|
|
412
|
+
],
|
|
413
|
+
})
|
|
414
|
+
const result = await handleHistory({
|
|
415
|
+
chatKey: "123",
|
|
416
|
+
sdk: sdk as any,
|
|
417
|
+
sessionManager: sm,
|
|
418
|
+
})
|
|
419
|
+
// Should be truncated — result shorter than original text
|
|
420
|
+
expect(result.length).toBeLessThan(longText.length)
|
|
421
|
+
})
|
|
422
|
+
|
|
423
|
+
test("returns 'No messages yet.' when history is empty", async () => {
|
|
424
|
+
sm.set("123", { sessionId: "s1", directory: "/tmp" })
|
|
425
|
+
const sdk = createMockSdk({ messages: [] })
|
|
426
|
+
const result = await handleHistory({
|
|
427
|
+
chatKey: "123",
|
|
428
|
+
sdk: sdk as any,
|
|
429
|
+
sessionManager: sm,
|
|
430
|
+
})
|
|
431
|
+
expect(result).toBe("No messages yet.")
|
|
432
|
+
})
|
|
433
|
+
})
|
|
434
|
+
|
|
435
|
+
// ============================================================
|
|
436
|
+
// formatHistory
|
|
437
|
+
// ============================================================
|
|
438
|
+
|
|
439
|
+
describe("formatHistory", () => {
|
|
440
|
+
test("formats messages as role: text", () => {
|
|
441
|
+
const messages = [
|
|
442
|
+
{
|
|
443
|
+
info: { id: "m1", role: "user" as const, time: { created: 1000 } },
|
|
444
|
+
parts: [{ type: "text" as const, text: "Hello" }],
|
|
445
|
+
},
|
|
446
|
+
{
|
|
447
|
+
info: { id: "m2", role: "assistant" as const, time: { created: 2000 } },
|
|
448
|
+
parts: [{ type: "text" as const, text: "World" }],
|
|
449
|
+
},
|
|
450
|
+
]
|
|
451
|
+
const result = formatHistory(messages)
|
|
452
|
+
expect(result).toContain("user")
|
|
453
|
+
expect(result).toContain("Hello")
|
|
454
|
+
expect(result).toContain("assistant")
|
|
455
|
+
expect(result).toContain("World")
|
|
456
|
+
})
|
|
457
|
+
})
|
|
458
|
+
|
|
459
|
+
// ============================================================
|
|
460
|
+
// handleSummarize
|
|
461
|
+
// ============================================================
|
|
462
|
+
|
|
463
|
+
describe("handleSummarize", () => {
|
|
464
|
+
let sm: SessionManager
|
|
465
|
+
|
|
466
|
+
beforeEach(() => {
|
|
467
|
+
sm = new SessionManager({ maxEntries: 10, ttlMs: 60000 })
|
|
468
|
+
})
|
|
469
|
+
|
|
470
|
+
test("returns 'No active session.' when no session mapped", async () => {
|
|
471
|
+
const sdk = createMockSdk()
|
|
472
|
+
const result = await handleSummarize({
|
|
473
|
+
chatKey: "123",
|
|
474
|
+
sdk: sdk as any,
|
|
475
|
+
sessionManager: sm,
|
|
476
|
+
})
|
|
477
|
+
expect(result).toBe("No active session.")
|
|
478
|
+
})
|
|
479
|
+
})
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session command handlers — pure functions + thin async handlers.
|
|
3
|
+
*
|
|
4
|
+
* Exports:
|
|
5
|
+
* - formatSessionList(sessions) — format sessions as inline keyboard
|
|
6
|
+
* - formatSessionInfo(session) — format session info text
|
|
7
|
+
* - formatHistory(messages) — format message history
|
|
8
|
+
* - parseSessionCallback(data) — parse "sess:" callback data
|
|
9
|
+
* - handleList(params) — fetch + format session list
|
|
10
|
+
* - handleRename(params) — rename current session
|
|
11
|
+
* - handleDelete(params) — delete current session
|
|
12
|
+
* - handleInfo(params) — show session info
|
|
13
|
+
* - handleHistory(params) — show recent messages
|
|
14
|
+
* - handleSummarize(params) — summarize current session
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { OpencodeClient } from "@opencode-ai/sdk/v2"
|
|
18
|
+
import type { SessionManager } from "../session-manager"
|
|
19
|
+
|
|
20
|
+
// --- Pure formatting functions ---
|
|
21
|
+
|
|
22
|
+
export function formatSessionList(sessions: any[]): {
|
|
23
|
+
text: string
|
|
24
|
+
reply_markup: { inline_keyboard: any[][] }
|
|
25
|
+
} {
|
|
26
|
+
// Filter out archived sessions
|
|
27
|
+
const active = sessions.filter((s) => !s.time?.archived)
|
|
28
|
+
|
|
29
|
+
if (active.length === 0) {
|
|
30
|
+
return { text: "No sessions found.", reply_markup: { inline_keyboard: [] } }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Sort by updated desc
|
|
34
|
+
const sorted = [...active].sort(
|
|
35
|
+
(a, b) => (b.time?.updated ?? 0) - (a.time?.updated ?? 0),
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
// Limit to 10
|
|
39
|
+
const limited = sorted.slice(0, 10)
|
|
40
|
+
|
|
41
|
+
const rows = limited.map((s) => {
|
|
42
|
+
const date = new Date(s.time?.updated ?? 0).toLocaleDateString()
|
|
43
|
+
const title = s.title || s.id.slice(0, 8)
|
|
44
|
+
return [
|
|
45
|
+
{
|
|
46
|
+
text: `${title} (${date})`,
|
|
47
|
+
callback_data: `sess:${s.id.slice(0, 20)}`,
|
|
48
|
+
},
|
|
49
|
+
]
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
text: "Select a session:",
|
|
54
|
+
reply_markup: { inline_keyboard: rows },
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function formatSessionInfo(session: any): string {
|
|
59
|
+
const title = session.title || session.id
|
|
60
|
+
const dir = session.directory || "—"
|
|
61
|
+
const created = session.time?.created
|
|
62
|
+
? new Date(session.time.created).toLocaleString()
|
|
63
|
+
: "—"
|
|
64
|
+
const updated = session.time?.updated
|
|
65
|
+
? new Date(session.time.updated).toLocaleString()
|
|
66
|
+
: "—"
|
|
67
|
+
|
|
68
|
+
return [
|
|
69
|
+
`Session: ${title}`,
|
|
70
|
+
`Directory: ${dir}`,
|
|
71
|
+
`Created: ${created}`,
|
|
72
|
+
`Updated: ${updated}`,
|
|
73
|
+
].join("\n")
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function formatHistory(
|
|
77
|
+
messages: Array<{ info: any; parts: any[] }>,
|
|
78
|
+
): string {
|
|
79
|
+
if (messages.length === 0) return "No messages yet."
|
|
80
|
+
|
|
81
|
+
// Take last 10 messages
|
|
82
|
+
const recent = messages.slice(-10)
|
|
83
|
+
|
|
84
|
+
return recent
|
|
85
|
+
.map((m) => {
|
|
86
|
+
const role = m.info.role ?? "unknown"
|
|
87
|
+
const textParts = m.parts.filter((p: any) => p.type === "text")
|
|
88
|
+
const text = textParts.map((p: any) => p.text).join("") || "(no text)"
|
|
89
|
+
// Truncate long messages
|
|
90
|
+
const truncated = text.length > 200 ? text.slice(0, 200) + "..." : text
|
|
91
|
+
return `${role}: ${truncated}`
|
|
92
|
+
})
|
|
93
|
+
.join("\n\n")
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function parseSessionCallback(
|
|
97
|
+
data: string,
|
|
98
|
+
): { sessionPrefix: string } | null {
|
|
99
|
+
if (!data.startsWith("sess:")) return null
|
|
100
|
+
const prefix = data.slice(5)
|
|
101
|
+
if (!prefix) return null
|
|
102
|
+
return { sessionPrefix: prefix }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// --- Async handlers ---
|
|
106
|
+
|
|
107
|
+
export async function handleList(params: {
|
|
108
|
+
sdk: OpencodeClient
|
|
109
|
+
}): Promise<{ text: string; reply_markup: { inline_keyboard: any[][] } }> {
|
|
110
|
+
const result = await params.sdk.session.list()
|
|
111
|
+
const sessions = (result as any).data ?? []
|
|
112
|
+
return formatSessionList(sessions)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export async function handleRename(params: {
|
|
116
|
+
chatKey: string
|
|
117
|
+
title: string
|
|
118
|
+
sdk: OpencodeClient
|
|
119
|
+
sessionManager: SessionManager
|
|
120
|
+
}): Promise<string> {
|
|
121
|
+
const { chatKey, title, sdk, sessionManager } = params
|
|
122
|
+
|
|
123
|
+
if (!title.trim()) {
|
|
124
|
+
return "Usage: /rename <title>"
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const entry = sessionManager.get(chatKey)
|
|
128
|
+
if (!entry) return "No active session."
|
|
129
|
+
|
|
130
|
+
await sdk.session.update({
|
|
131
|
+
sessionID: entry.sessionId,
|
|
132
|
+
title,
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
return `Session renamed to: ${title}`
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export async function handleDelete(params: {
|
|
139
|
+
chatKey: string
|
|
140
|
+
sdk: OpencodeClient
|
|
141
|
+
sessionManager: SessionManager
|
|
142
|
+
}): Promise<string> {
|
|
143
|
+
const { chatKey, sdk, sessionManager } = params
|
|
144
|
+
const entry = sessionManager.get(chatKey)
|
|
145
|
+
if (!entry) return "No active session."
|
|
146
|
+
|
|
147
|
+
await sdk.session.delete({
|
|
148
|
+
sessionID: entry.sessionId,
|
|
149
|
+
})
|
|
150
|
+
sessionManager.remove(chatKey)
|
|
151
|
+
|
|
152
|
+
return "Session deleted."
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export async function handleInfo(params: {
|
|
156
|
+
chatKey: string
|
|
157
|
+
sdk: OpencodeClient
|
|
158
|
+
sessionManager: SessionManager
|
|
159
|
+
}): Promise<string> {
|
|
160
|
+
const { chatKey, sdk, sessionManager } = params
|
|
161
|
+
const entry = sessionManager.get(chatKey)
|
|
162
|
+
if (!entry) return "No active session."
|
|
163
|
+
|
|
164
|
+
const result = await sdk.session.get({
|
|
165
|
+
sessionID: entry.sessionId,
|
|
166
|
+
})
|
|
167
|
+
const session = (result as any).data
|
|
168
|
+
if (!session) return "Session not found."
|
|
169
|
+
|
|
170
|
+
return formatSessionInfo(session)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export async function handleHistory(params: {
|
|
174
|
+
chatKey: string
|
|
175
|
+
sdk: OpencodeClient
|
|
176
|
+
sessionManager: SessionManager
|
|
177
|
+
}): Promise<string> {
|
|
178
|
+
const { chatKey, sdk, sessionManager } = params
|
|
179
|
+
const entry = sessionManager.get(chatKey)
|
|
180
|
+
if (!entry) return "No active session."
|
|
181
|
+
|
|
182
|
+
const result = await sdk.session.messages({
|
|
183
|
+
sessionID: entry.sessionId,
|
|
184
|
+
})
|
|
185
|
+
const messages = (result as any).data ?? []
|
|
186
|
+
|
|
187
|
+
if (messages.length === 0) return "No messages yet."
|
|
188
|
+
|
|
189
|
+
return formatHistory(messages)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export async function handleSummarize(params: {
|
|
193
|
+
chatKey: string
|
|
194
|
+
sdk: OpencodeClient
|
|
195
|
+
sessionManager: SessionManager
|
|
196
|
+
}): Promise<string> {
|
|
197
|
+
const { chatKey, sessionManager } = params
|
|
198
|
+
const entry = sessionManager.get(chatKey)
|
|
199
|
+
if (!entry) return "No active session."
|
|
200
|
+
|
|
201
|
+
return "Use /history to see recent messages, or ask the AI to summarize the conversation."
|
|
202
|
+
}
|