@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,198 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, mock } from "bun:test"
|
|
2
|
+
import { SessionManager } from "./session-manager"
|
|
3
|
+
|
|
4
|
+
// Minimal mock SDK that satisfies what SessionManager needs
|
|
5
|
+
function createMockSdk(sessionId: string) {
|
|
6
|
+
return {
|
|
7
|
+
session: {
|
|
8
|
+
create: mock(async () => ({
|
|
9
|
+
data: { id: sessionId, title: "", directory: "/tmp" },
|
|
10
|
+
})),
|
|
11
|
+
},
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe("SessionManager", () => {
|
|
16
|
+
let sm: SessionManager
|
|
17
|
+
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
sm = new SessionManager({ maxEntries: 3, ttlMs: 1000 })
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
test("getOrCreate creates new session on first access", async () => {
|
|
23
|
+
const sdk = createMockSdk("s1")
|
|
24
|
+
const entry = await sm.getOrCreate("chat:1", sdk as any)
|
|
25
|
+
expect(entry.sessionId).toBe("s1")
|
|
26
|
+
expect(sdk.session.create).toHaveBeenCalledTimes(1)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test("getOrCreate returns cached on second access", async () => {
|
|
30
|
+
const sdk = createMockSdk("s1")
|
|
31
|
+
await sm.getOrCreate("chat:1", sdk as any)
|
|
32
|
+
const entry = await sm.getOrCreate("chat:1", sdk as any)
|
|
33
|
+
expect(entry.sessionId).toBe("s1")
|
|
34
|
+
expect(sdk.session.create).toHaveBeenCalledTimes(1) // not called again
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test("get returns entry by chatKey", async () => {
|
|
38
|
+
const sdk = createMockSdk("s1")
|
|
39
|
+
await sm.getOrCreate("chat:1", sdk as any)
|
|
40
|
+
const entry = sm.get("chat:1")
|
|
41
|
+
expect(entry).toBeDefined()
|
|
42
|
+
expect(entry!.sessionId).toBe("s1")
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test("get returns undefined for unknown chatKey", () => {
|
|
46
|
+
expect(sm.get("unknown")).toBeUndefined()
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test("getBySessionId reverse lookup", async () => {
|
|
50
|
+
const sdk = createMockSdk("s1")
|
|
51
|
+
await sm.getOrCreate("chat:1", sdk as any)
|
|
52
|
+
const result = sm.getBySessionId("s1")
|
|
53
|
+
expect(result).toBeDefined()
|
|
54
|
+
expect(result!.chatKey).toBe("chat:1")
|
|
55
|
+
expect(result!.entry.sessionId).toBe("s1")
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test("getBySessionId returns undefined for unknown session", () => {
|
|
59
|
+
expect(sm.getBySessionId("unknown")).toBeUndefined()
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test("remove clears both maps", async () => {
|
|
63
|
+
const sdk = createMockSdk("s1")
|
|
64
|
+
await sm.getOrCreate("chat:1", sdk as any)
|
|
65
|
+
sm.remove("chat:1")
|
|
66
|
+
expect(sm.get("chat:1")).toBeUndefined()
|
|
67
|
+
expect(sm.getBySessionId("s1")).toBeUndefined()
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
test("evicts oldest when maxEntries exceeded", async () => {
|
|
71
|
+
for (let i = 1; i <= 4; i++) {
|
|
72
|
+
await sm.getOrCreate(`chat:${i}`, createMockSdk(`s${i}`) as any)
|
|
73
|
+
}
|
|
74
|
+
// chat:1 should be evicted (oldest, max is 3)
|
|
75
|
+
expect(sm.get("chat:1")).toBeUndefined()
|
|
76
|
+
expect(sm.getBySessionId("s1")).toBeUndefined()
|
|
77
|
+
// chat:4 should exist
|
|
78
|
+
expect(sm.get("chat:4")).toBeDefined()
|
|
79
|
+
expect(sm.get("chat:4")!.sessionId).toBe("s4")
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
test("getOrCreate refreshes access order (LRU)", async () => {
|
|
83
|
+
// Fill to capacity: 1, 2, 3
|
|
84
|
+
for (let i = 1; i <= 3; i++) {
|
|
85
|
+
await sm.getOrCreate(`chat:${i}`, createMockSdk(`s${i}`) as any)
|
|
86
|
+
}
|
|
87
|
+
// Access chat:1 again (refreshes it)
|
|
88
|
+
await sm.getOrCreate("chat:1", createMockSdk("s1-new") as any)
|
|
89
|
+
// Add chat:4 — should evict chat:2 (oldest unreferenced), not chat:1
|
|
90
|
+
await sm.getOrCreate("chat:4", createMockSdk("s4") as any)
|
|
91
|
+
expect(sm.get("chat:1")).toBeDefined()
|
|
92
|
+
expect(sm.get("chat:2")).toBeUndefined() // evicted
|
|
93
|
+
expect(sm.get("chat:4")).toBeDefined()
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
test("expired entries cleaned up by TTL", async () => {
|
|
97
|
+
await sm.getOrCreate("chat:1", createMockSdk("s1") as any)
|
|
98
|
+
// Wait for TTL to expire
|
|
99
|
+
await new Promise((r) => setTimeout(r, 1100))
|
|
100
|
+
sm.cleanup()
|
|
101
|
+
expect(sm.get("chat:1")).toBeUndefined()
|
|
102
|
+
expect(sm.getBySessionId("s1")).toBeUndefined()
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
test("set allows manual session binding", () => {
|
|
106
|
+
sm.set("chat:1", { sessionId: "manual-1", directory: "/tmp" })
|
|
107
|
+
const entry = sm.get("chat:1")
|
|
108
|
+
expect(entry).toBeDefined()
|
|
109
|
+
expect(entry!.sessionId).toBe("manual-1")
|
|
110
|
+
expect(sm.getBySessionId("manual-1")).toBeDefined()
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
test("set overwrites previous binding and cleans reverse map", async () => {
|
|
114
|
+
await sm.getOrCreate("chat:1", createMockSdk("s1") as any)
|
|
115
|
+
sm.set("chat:1", { sessionId: "s2", directory: "/tmp" })
|
|
116
|
+
expect(sm.get("chat:1")!.sessionId).toBe("s2")
|
|
117
|
+
expect(sm.getBySessionId("s1")).toBeUndefined() // old reverse map cleaned
|
|
118
|
+
expect(sm.getBySessionId("s2")).toBeDefined()
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
test("size tracks current entry count", async () => {
|
|
122
|
+
expect(sm.size).toBe(0)
|
|
123
|
+
await sm.getOrCreate("chat:1", createMockSdk("s1") as any)
|
|
124
|
+
expect(sm.size).toBe(1)
|
|
125
|
+
await sm.getOrCreate("chat:2", createMockSdk("s2") as any)
|
|
126
|
+
expect(sm.size).toBe(2)
|
|
127
|
+
sm.remove("chat:1")
|
|
128
|
+
expect(sm.size).toBe(1)
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
// --- Phase 4: restore() ---
|
|
132
|
+
|
|
133
|
+
function createRestoreSdk(sessions: Array<{ id: string; title: string; directory?: string; archived?: number }>) {
|
|
134
|
+
return {
|
|
135
|
+
session: {
|
|
136
|
+
create: mock(async () => ({ data: { id: "new", title: "", directory: "/tmp" } })),
|
|
137
|
+
list: mock(async () => ({
|
|
138
|
+
data: sessions.map((s) => ({
|
|
139
|
+
id: s.id,
|
|
140
|
+
title: s.title,
|
|
141
|
+
directory: s.directory ?? "/tmp",
|
|
142
|
+
time: { created: 1000, updated: 2000, archived: s.archived },
|
|
143
|
+
})),
|
|
144
|
+
})),
|
|
145
|
+
},
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
test("restore populates sessions matching 'Telegram {chatId}' pattern", async () => {
|
|
150
|
+
const sdk = createRestoreSdk([
|
|
151
|
+
{ id: "s1", title: "Telegram 12345" },
|
|
152
|
+
{ id: "s2", title: "Telegram 67890" },
|
|
153
|
+
])
|
|
154
|
+
const count = await sm.restore(sdk as any)
|
|
155
|
+
expect(count).toBe(2)
|
|
156
|
+
expect(sm.get("12345")?.sessionId).toBe("s1")
|
|
157
|
+
expect(sm.get("67890")?.sessionId).toBe("s2")
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
test("restore ignores archived sessions", async () => {
|
|
161
|
+
const sdk = createRestoreSdk([
|
|
162
|
+
{ id: "s1", title: "Telegram 12345", archived: 9999 },
|
|
163
|
+
])
|
|
164
|
+
const count = await sm.restore(sdk as any)
|
|
165
|
+
expect(count).toBe(0)
|
|
166
|
+
expect(sm.get("12345")).toBeUndefined()
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
test("restore ignores sessions without matching title pattern", async () => {
|
|
170
|
+
const sdk = createRestoreSdk([
|
|
171
|
+
{ id: "s1", title: "My Custom Session" },
|
|
172
|
+
{ id: "s2", title: "Telegram chat" },
|
|
173
|
+
{ id: "s3", title: "Telegram12345" },
|
|
174
|
+
])
|
|
175
|
+
const count = await sm.restore(sdk as any)
|
|
176
|
+
expect(count).toBe(0)
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
test("restore returns count of restored sessions", async () => {
|
|
180
|
+
const sdk = createRestoreSdk([
|
|
181
|
+
{ id: "s1", title: "Telegram 111" },
|
|
182
|
+
{ id: "s2", title: "Not matching" },
|
|
183
|
+
{ id: "s3", title: "Telegram 333" },
|
|
184
|
+
])
|
|
185
|
+
const count = await sm.restore(sdk as any)
|
|
186
|
+
expect(count).toBe(2)
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
test("restore does not overwrite existing mappings", async () => {
|
|
190
|
+
sm.set("12345", { sessionId: "existing", directory: "/existing" })
|
|
191
|
+
const sdk = createRestoreSdk([
|
|
192
|
+
{ id: "s1", title: "Telegram 12345" },
|
|
193
|
+
])
|
|
194
|
+
const count = await sm.restore(sdk as any)
|
|
195
|
+
expect(count).toBe(0)
|
|
196
|
+
expect(sm.get("12345")?.sessionId).toBe("existing")
|
|
197
|
+
})
|
|
198
|
+
})
|
package/src/session-manager.ts
CHANGED
|
@@ -105,6 +105,29 @@ export class SessionManager {
|
|
|
105
105
|
}
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
+
async restore(sdk: OpencodeClient): Promise<number> {
|
|
109
|
+
const result = await sdk.session.list()
|
|
110
|
+
const sessions = (result as any).data ?? []
|
|
111
|
+
let restored = 0
|
|
112
|
+
|
|
113
|
+
for (const session of sessions) {
|
|
114
|
+
if (session.time?.archived) continue
|
|
115
|
+
const match = session.title?.match(/^Telegram (\d+)$/)
|
|
116
|
+
if (!match) continue
|
|
117
|
+
|
|
118
|
+
const chatKey = match[1]
|
|
119
|
+
if (!this.get(chatKey)) {
|
|
120
|
+
this.set(chatKey, {
|
|
121
|
+
sessionId: session.id,
|
|
122
|
+
directory: session.directory ?? "",
|
|
123
|
+
})
|
|
124
|
+
restored++
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return restored
|
|
129
|
+
}
|
|
130
|
+
|
|
108
131
|
cleanup(): void {
|
|
109
132
|
const now = Date.now()
|
|
110
133
|
for (const [chatKey, entry] of this.map) {
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, mock } from "bun:test"
|
|
2
|
+
import { TurnManager } from "./turn-manager"
|
|
3
|
+
|
|
4
|
+
describe("TurnManager", () => {
|
|
5
|
+
let tm: TurnManager
|
|
6
|
+
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
tm = new TurnManager()
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
test("start creates a turn with AbortController", () => {
|
|
12
|
+
const turn = tm.start("s1", 12345)
|
|
13
|
+
expect(turn).toBeDefined()
|
|
14
|
+
expect(turn.sessionId).toBe("s1")
|
|
15
|
+
expect(turn.chatId).toBe(12345)
|
|
16
|
+
expect(turn.abortController).toBeDefined()
|
|
17
|
+
expect(turn.abortController.signal.aborted).toBe(false)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
test("get returns active turn", () => {
|
|
21
|
+
tm.start("s1", 12345)
|
|
22
|
+
const turn = tm.get("s1")
|
|
23
|
+
expect(turn).toBeDefined()
|
|
24
|
+
expect(turn!.sessionId).toBe("s1")
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test("get returns undefined for unknown session", () => {
|
|
28
|
+
expect(tm.get("unknown")).toBeUndefined()
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test("end aborts controller and removes entry", () => {
|
|
32
|
+
const turn = tm.start("s1", 12345)
|
|
33
|
+
tm.end("s1")
|
|
34
|
+
expect(turn.abortController.signal.aborted).toBe(true)
|
|
35
|
+
expect(tm.get("s1")).toBeUndefined()
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test("end clears all timers", () => {
|
|
39
|
+
const turn = tm.start("s1", 12345)
|
|
40
|
+
let timerFired = false
|
|
41
|
+
const timer = setTimeout(() => {
|
|
42
|
+
timerFired = true
|
|
43
|
+
}, 50)
|
|
44
|
+
tm.addTimer("s1", timer)
|
|
45
|
+
tm.end("s1")
|
|
46
|
+
// Wait a bit to confirm timer was cleared
|
|
47
|
+
return new Promise<void>((resolve) => {
|
|
48
|
+
setTimeout(() => {
|
|
49
|
+
expect(timerFired).toBe(false)
|
|
50
|
+
resolve()
|
|
51
|
+
}, 100)
|
|
52
|
+
})
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test("addTimer tracks timer in turn", () => {
|
|
56
|
+
tm.start("s1", 12345)
|
|
57
|
+
const timer = setTimeout(() => {}, 1000)
|
|
58
|
+
tm.addTimer("s1", timer)
|
|
59
|
+
// Just verify no throw — internals tracked
|
|
60
|
+
tm.end("s1") // cleans up
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test("addTimer is noop for unknown session", () => {
|
|
64
|
+
const timer = setTimeout(() => {}, 1000)
|
|
65
|
+
// Should not throw
|
|
66
|
+
tm.addTimer("unknown", timer)
|
|
67
|
+
clearTimeout(timer)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
test("abortAll cleans up all active turns", () => {
|
|
71
|
+
const t1 = tm.start("s1", 111)
|
|
72
|
+
const t2 = tm.start("s2", 222)
|
|
73
|
+
tm.abortAll()
|
|
74
|
+
expect(t1.abortController.signal.aborted).toBe(true)
|
|
75
|
+
expect(t2.abortController.signal.aborted).toBe(true)
|
|
76
|
+
expect(tm.get("s1")).toBeUndefined()
|
|
77
|
+
expect(tm.get("s2")).toBeUndefined()
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
test("start replaces existing turn for same session", () => {
|
|
81
|
+
const t1 = tm.start("s1", 12345)
|
|
82
|
+
const t2 = tm.start("s1", 12345)
|
|
83
|
+
// Old turn should be aborted
|
|
84
|
+
expect(t1.abortController.signal.aborted).toBe(true)
|
|
85
|
+
// New turn is active
|
|
86
|
+
expect(t2.abortController.signal.aborted).toBe(false)
|
|
87
|
+
expect(tm.get("s1")).toBe(t2)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
test("accumulatedText starts empty and can be set", () => {
|
|
91
|
+
const turn = tm.start("s1", 12345)
|
|
92
|
+
expect(turn.accumulatedText).toBe("")
|
|
93
|
+
turn.accumulatedText = "hello world"
|
|
94
|
+
expect(tm.get("s1")!.accumulatedText).toBe("hello world")
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
test("ended turn has no remaining references in manager", () => {
|
|
98
|
+
tm.start("s1", 12345)
|
|
99
|
+
tm.end("s1")
|
|
100
|
+
expect(tm.get("s1")).toBeUndefined()
|
|
101
|
+
expect(tm.size).toBe(0)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
test("size tracks active turn count", () => {
|
|
105
|
+
expect(tm.size).toBe(0)
|
|
106
|
+
tm.start("s1", 111)
|
|
107
|
+
expect(tm.size).toBe(1)
|
|
108
|
+
tm.start("s2", 222)
|
|
109
|
+
expect(tm.size).toBe(2)
|
|
110
|
+
tm.end("s1")
|
|
111
|
+
expect(tm.size).toBe(1)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
// --- Phase 3: draft + toolSuffix fields ---
|
|
115
|
+
|
|
116
|
+
test("new turn has toolSuffix='' and draft=null", () => {
|
|
117
|
+
const turn = tm.start("s1", 12345)
|
|
118
|
+
expect(turn.toolSuffix).toBe("")
|
|
119
|
+
expect(turn.draft).toBeNull()
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
test("draft can be set on turn and accessed", () => {
|
|
123
|
+
const turn = tm.start("s1", 12345)
|
|
124
|
+
const fakeDraft = { stop: mock(() => {}), getMessageId: () => 42, update: mock(async () => {}) }
|
|
125
|
+
turn.draft = fakeDraft
|
|
126
|
+
expect(tm.get("s1")!.draft).toBe(fakeDraft)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
test("end calls draft.stop() if draft exists", () => {
|
|
130
|
+
const turn = tm.start("s1", 12345)
|
|
131
|
+
const stopFn = mock(() => {})
|
|
132
|
+
turn.draft = { stop: stopFn, getMessageId: () => 42, update: async () => {} }
|
|
133
|
+
tm.end("s1")
|
|
134
|
+
expect(stopFn).toHaveBeenCalledTimes(1)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
// --- Phase 4: generation counter ---
|
|
138
|
+
|
|
139
|
+
test("first turn gets generation=1", () => {
|
|
140
|
+
const turn = tm.start("s1", 12345)
|
|
141
|
+
expect(turn.generation).toBe(1)
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
test("second turn for same session gets incremented generation", () => {
|
|
145
|
+
const t1 = tm.start("s1", 12345)
|
|
146
|
+
const t2 = tm.start("s1", 12345)
|
|
147
|
+
expect(t2.generation).toBe(t1.generation + 1)
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
test("different sessions get unique generation values", () => {
|
|
151
|
+
const t1 = tm.start("s1", 111)
|
|
152
|
+
const t2 = tm.start("s2", 222)
|
|
153
|
+
expect(t1.generation).not.toBe(t2.generation)
|
|
154
|
+
})
|
|
155
|
+
})
|
package/src/turn-manager.ts
CHANGED
|
@@ -17,10 +17,12 @@ export type ActiveTurn = {
|
|
|
17
17
|
toolSuffix: string
|
|
18
18
|
timers: Set<ReturnType<typeof setTimeout>>
|
|
19
19
|
draft: { stop(): void; getMessageId(): number | null; update(text: string): Promise<void> } | null
|
|
20
|
+
generation: number
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
export class TurnManager {
|
|
23
24
|
private active = new Map<string, ActiveTurn>()
|
|
25
|
+
private generationCounter = 0
|
|
24
26
|
|
|
25
27
|
start(sessionId: string, chatId: number): ActiveTurn {
|
|
26
28
|
// If there's an existing turn, end it first
|
|
@@ -29,6 +31,8 @@ export class TurnManager {
|
|
|
29
31
|
this.endTurn(existing)
|
|
30
32
|
}
|
|
31
33
|
|
|
34
|
+
this.generationCounter++
|
|
35
|
+
|
|
32
36
|
const turn: ActiveTurn = {
|
|
33
37
|
sessionId,
|
|
34
38
|
chatId,
|
|
@@ -37,6 +41,7 @@ export class TurnManager {
|
|
|
37
41
|
toolSuffix: "",
|
|
38
42
|
timers: new Set(),
|
|
39
43
|
draft: null,
|
|
44
|
+
generation: this.generationCounter,
|
|
40
45
|
}
|
|
41
46
|
|
|
42
47
|
this.active.set(sessionId, turn)
|
package/tsconfig.json
ADDED