clanka 0.2.72 → 0.3.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.
@@ -0,0 +1,288 @@
1
+ import { assert, describe, it } from "@effect/vitest"
2
+ import * as Effect from "effect/Effect"
3
+ import * as Fiber from "effect/Fiber"
4
+ import * as Layer from "effect/Layer"
5
+ import * as Option from "effect/Option"
6
+ import * as Stream from "effect/Stream"
7
+ import * as KeyValueStore from "effect/unstable/persistence/KeyValueStore"
8
+ import * as LanguageModel from "effect/unstable/ai/LanguageModel"
9
+ import * as Model from "effect/unstable/ai/Model"
10
+ import type * as Response from "effect/unstable/ai/Response"
11
+ import * as Acp from "./Acp.ts"
12
+ import * as Agent from "./Agent.ts"
13
+ import * as AgentExecutor from "./AgentExecutor.ts"
14
+
15
+ type Message = {
16
+ readonly id?: number
17
+ readonly method?: string
18
+ readonly result?: any
19
+ readonly error?: { readonly code: number; readonly message: string }
20
+ readonly params?: any
21
+ }
22
+
23
+ const capabilities = new AgentExecutor.Capabilities({
24
+ toolsDts: "",
25
+ agentsMd: Option.none(),
26
+ supportsSearch: false,
27
+ })
28
+
29
+ const executor = AgentExecutor.AgentExecutor.of({
30
+ capabilities: Effect.succeed(capabilities),
31
+ execute: () => Stream.make("script output"),
32
+ executeUnsafe: () => Effect.die("executeUnsafe not implemented"),
33
+ })
34
+
35
+ const makeServer = (
36
+ streamText: Parameters<typeof LanguageModel.make>[0]["streamText"],
37
+ ) =>
38
+ Effect.gen(function* () {
39
+ const sent: Array<Message> = []
40
+ const languageModel = yield* LanguageModel.make({
41
+ generateText: () => Effect.succeed([]),
42
+ streamText,
43
+ })
44
+ const modelLayer = Layer.mergeAll(
45
+ Layer.succeed(LanguageModel.LanguageModel, languageModel),
46
+ Layer.succeed(Model.ProviderName, "test"),
47
+ Layer.succeed(Model.ModelName, "model"),
48
+ )
49
+ const server = yield* Acp.make({
50
+ version: "test",
51
+ defaultModel: "test/model",
52
+ send: (message) =>
53
+ Effect.sync(() => {
54
+ sent.push(message as Message)
55
+ }),
56
+ makeAgent: () =>
57
+ Agent.make.pipe(
58
+ Effect.provideService(AgentExecutor.AgentExecutor, executor),
59
+ ),
60
+ makeModel: (modelId) =>
61
+ modelId === "test/model"
62
+ ? Option.some(
63
+ Layer.merge(modelLayer, Agent.layerSubagentModel(modelLayer)),
64
+ )
65
+ : Option.none(),
66
+ })
67
+ const request = (id: number, method: string, params?: unknown) =>
68
+ server
69
+ .handle(JSON.stringify({ jsonrpc: "2.0", id, method, params }))
70
+ .pipe(Effect.map(() => sent.find((m) => m.id === id)!))
71
+ const notify = (method: string, params?: unknown) =>
72
+ server.handle(JSON.stringify({ jsonrpc: "2.0", method, params }))
73
+ const updates = (sessionId: string) =>
74
+ sent
75
+ .filter(
76
+ (m) =>
77
+ m.method === "session/update" && m.params.sessionId === sessionId,
78
+ )
79
+ .map((m) => m.params.update)
80
+ return { sent, request, notify, updates }
81
+ })
82
+
83
+ const parts = (
84
+ ...items: ReadonlyArray<Response.StreamPartEncoded>
85
+ ): Stream.Stream<Response.StreamPartEncoded> => Stream.fromIterable(items)
86
+
87
+ const assistantSays = (text: string) =>
88
+ parts(
89
+ { type: "text-start", id: "1" },
90
+ { type: "text-delta", id: "1", delta: text },
91
+ { type: "text-end", id: "1" },
92
+ )
93
+
94
+ const withStore = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
95
+ effect.pipe(
96
+ Effect.provide(
97
+ Layer.merge(
98
+ KeyValueStore.layerMemory,
99
+ Agent.ConversationMode.layer(true),
100
+ ),
101
+ ),
102
+ )
103
+
104
+ describe("Acp", () => {
105
+ it.effect("runs a prompt turn", () =>
106
+ withStore(
107
+ Effect.scoped(
108
+ Effect.gen(function* () {
109
+ const server = yield* makeServer(() => assistantSays("hi there"))
110
+
111
+ const init = yield* server.request(1, "initialize", {
112
+ protocolVersion: 1,
113
+ })
114
+ assert.strictEqual(init.result.protocolVersion, 1)
115
+ assert.strictEqual(init.result.agentCapabilities.loadSession, true)
116
+
117
+ const created = yield* server.request(2, "session/new", {
118
+ cwd: "/tmp",
119
+ mcpServers: [],
120
+ })
121
+ const sessionId: string = created.result.sessionId
122
+ assert.strictEqual(created.result.models.currentModelId, "test/model")
123
+
124
+ const done = yield* server.request(3, "session/prompt", {
125
+ sessionId,
126
+ prompt: [{ type: "text", text: "hello" }],
127
+ })
128
+ assert.deepStrictEqual(done.result, { stopReason: "end_turn" })
129
+
130
+ const updates = server.updates(sessionId)
131
+ assert.deepStrictEqual(
132
+ updates.filter((u) => u.sessionUpdate === "agent_thought_chunk"),
133
+ [
134
+ {
135
+ sessionUpdate: "agent_thought_chunk",
136
+ content: { type: "text", text: "hi there" },
137
+ },
138
+ ],
139
+ )
140
+ assert.deepStrictEqual(updates.at(-1), {
141
+ sessionUpdate: "agent_message_chunk",
142
+ content: { type: "text", text: "hi there" },
143
+ })
144
+ }),
145
+ ),
146
+ ),
147
+ )
148
+
149
+ it.effect("reports script execution as tool calls", () =>
150
+ withStore(
151
+ Effect.scoped(
152
+ Effect.gen(function* () {
153
+ let calls = 0
154
+ const server = yield* makeServer(() =>
155
+ calls++ === 0
156
+ ? parts({
157
+ type: "tool-call",
158
+ id: "call-1",
159
+ name: "execute",
160
+ params: { script: "console.log(1)" },
161
+ })
162
+ : assistantSays("done"),
163
+ )
164
+ const created = yield* server.request(1, "session/new", {
165
+ cwd: "/tmp",
166
+ })
167
+ const sessionId: string = created.result.sessionId
168
+ yield* server.request(2, "session/prompt", {
169
+ sessionId,
170
+ prompt: [{ type: "text", text: "run it" }],
171
+ })
172
+ const tools = server
173
+ .updates(sessionId)
174
+ .filter((u) => u.sessionUpdate.startsWith("tool_call"))
175
+ assert.strictEqual(tools.length, 2)
176
+ assert.deepStrictEqual(tools[0].rawInput, {
177
+ script: "console.log(1)",
178
+ })
179
+ assert.strictEqual(tools[0].status, "in_progress")
180
+ assert.strictEqual(tools[1].toolCallId, tools[0].toolCallId)
181
+ assert.strictEqual(tools[1].status, "completed")
182
+ assert.deepStrictEqual(tools[1].rawOutput, {
183
+ output: "script output",
184
+ })
185
+ }),
186
+ ),
187
+ ),
188
+ )
189
+
190
+ it.effect("loads a persisted session into a new server", () =>
191
+ withStore(
192
+ Effect.gen(function* () {
193
+ const sessionId = yield* Effect.scoped(
194
+ Effect.gen(function* () {
195
+ const server = yield* makeServer(() => assistantSays("first"))
196
+ const created = yield* server.request(1, "session/new", {
197
+ cwd: "/tmp",
198
+ })
199
+ yield* server.request(2, "session/prompt", {
200
+ sessionId: created.result.sessionId,
201
+ prompt: [{ type: "text", text: "hello" }],
202
+ })
203
+ return created.result.sessionId as string
204
+ }),
205
+ )
206
+
207
+ yield* Effect.scoped(
208
+ Effect.gen(function* () {
209
+ const server = yield* makeServer(() => assistantSays("second"))
210
+ const loaded = yield* server.request(1, "session/load", {
211
+ sessionId,
212
+ cwd: "/tmp",
213
+ mcpServers: [],
214
+ })
215
+ assert.strictEqual(
216
+ loaded.result.models.currentModelId,
217
+ "test/model",
218
+ )
219
+ assert.deepStrictEqual(
220
+ server.updates(sessionId).map((u) => u.sessionUpdate),
221
+ ["user_message_chunk", "agent_message_chunk"],
222
+ )
223
+
224
+ const missing = yield* server.request(2, "session/load", {
225
+ sessionId: "nope",
226
+ cwd: "/tmp",
227
+ })
228
+ assert.strictEqual(missing.error!.code, -32602)
229
+ assert.match(missing.error!.message, /Session not found/)
230
+ }),
231
+ )
232
+ }),
233
+ ),
234
+ )
235
+
236
+ it.effect("rejects unknown models and methods", () =>
237
+ withStore(
238
+ Effect.scoped(
239
+ Effect.gen(function* () {
240
+ const server = yield* makeServer(() => assistantSays("x"))
241
+ const created = yield* server.request(1, "session/new", {
242
+ cwd: "/tmp",
243
+ model: "unknown/model",
244
+ })
245
+ assert.strictEqual(created.error!.code, -32602)
246
+
247
+ const ok = yield* server.request(2, "session/new", { cwd: "/tmp" })
248
+ const set = yield* server.request(3, "session/set_model", {
249
+ sessionId: ok.result.sessionId,
250
+ modelId: "unknown/model",
251
+ })
252
+ assert.strictEqual(set.error!.code, -32602)
253
+
254
+ const missing = yield* server.request(4, "session/nope", {})
255
+ assert.strictEqual(missing.error!.code, -32601)
256
+ }),
257
+ ),
258
+ ),
259
+ )
260
+
261
+ it.live("cancels a running prompt", () =>
262
+ withStore(
263
+ Effect.scoped(
264
+ Effect.gen(function* () {
265
+ const server = yield* makeServer(() =>
266
+ parts({ type: "text-start", id: "1" }).pipe(
267
+ Stream.concat(Stream.never),
268
+ ),
269
+ )
270
+ const created = yield* server.request(1, "session/new", {
271
+ cwd: "/tmp",
272
+ })
273
+ const sessionId: string = created.result.sessionId
274
+ const prompt = yield* server
275
+ .request(2, "session/prompt", {
276
+ sessionId,
277
+ prompt: [{ type: "text", text: "hello" }],
278
+ })
279
+ .pipe(Effect.forkScoped)
280
+ yield* Effect.sleep(10)
281
+ yield* server.notify("session/cancel", { sessionId })
282
+ const done = yield* Fiber.join(prompt)
283
+ assert.deepStrictEqual(done.result, { stopReason: "cancelled" })
284
+ }),
285
+ ),
286
+ ),
287
+ )
288
+ })