Skip to content

Commit a52b68c

Browse files
committed
Add first real chat integration-tests 🎉
1 parent 3c833c3 commit a52b68c

File tree

10 files changed

+290
-43
lines changed

10 files changed

+290
-43
lines changed

docs/configuration.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,8 @@ Example:
196196
interface Config {
197197
openaiApiKey?: string;
198198
anthropicApiKey?: string;
199+
openaiApiUrl?: string;
200+
anthropicApiUrl?: string;
199201
rules: [{path: string;}];
200202
commands: [{path: string;}];
201203
systemPromptTemplateFile?: string;
@@ -250,6 +252,8 @@ interface Config {
250252
{
251253
"openaiApiKey" : null,
252254
"anthropicApiKey" : null,
255+
"openaiApiUrl" : null,
256+
"anthropicApiUrl" : null,
253257
"rules" : [],
254258
"commands" : [],
255259
"nativeTools": {"filesystem": {"enabled": true},
@@ -259,8 +263,8 @@ interface Config {
259263
"toolCall": {
260264
"manualApproval": null,
261265
},
262-
"mcpTimeoutSeconds" : 10,
263-
"mcpServers" : [],
266+
"mcpTimeoutSeconds" : 60,
267+
"mcpServers" : {},
264268
"customProviders": {},
265269
"models": {},
266270
"ollama" : {
@@ -270,7 +274,7 @@ interface Config {
270274
"think": true
271275
},
272276
"chat" : {
273-
"welcomeMessage" : "Welcome to ECA! What you have in mind?\n\n"
277+
"welcomeMessage" : "Welcome to ECA!\n\nType '/' for commands\n\n"
274278
},
275279
"index" : {
276280
"ignoreFiles" : [ {

integration-test/entrypoint.clj

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
(ns entrypoint
22
(:require
33
[clojure.test :as t]
4-
[integration.eca :as eca]))
4+
[integration.eca :as eca]
5+
[llm-mock.server :as llm-mock.server]))
56

67
(def namespaces
78
'[integration.initialize-test
@@ -33,13 +34,17 @@
3334
(alter-var-root #'eca/*eca-binary-path* (constantly binary))
3435
(apply require namespaces)
3536

37+
(llm-mock.server/start!)
38+
3639
(let [timeout-minutes (if (re-find #"(?i)win|mac" (System/getProperty "os.name"))
3740
10 ;; win and mac ci runs take longer
3841
5)
3942
test-results (timeout (* timeout-minutes 60 1000)
4043
#(with-log-tail-report
4144
(apply t/run-tests namespaces)))]
4245

46+
(llm-mock.server/stop!)
47+
4348
(when (= test-results :timed-out)
4449
(println)
4550
(println (format "Timeout after %d minutes running integration tests!" timeout-minutes))

integration-test/integration/chat_openai_test.clj

Lines changed: 96 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -3,37 +3,109 @@
33
[clojure.test :refer [deftest is testing]]
44
[integration.eca :as eca]
55
[integration.fixture :as fixture]
6+
[llm-mock.mocks :as llm.mocks]
67
[matcher-combinators.matchers :as m]
78
[matcher-combinators.test :refer [match?]]))
89

910
(eca/clean-after-test)
1011

12+
(defn match-content [chat-id request-id role content]
13+
(is (match?
14+
{:chatId chat-id
15+
:requestId request-id
16+
:role role
17+
:content content}
18+
(eca/client-awaits-server-notification :chat/contentReceived))))
19+
1120
(deftest simple-text
1221
(eca/start-process!)
1322

1423
(eca/request! (fixture/initialize-request))
1524
(eca/notify! (fixture/initialized-notification))
16-
(testing "simple hello message with reply"
17-
(let [resp (eca/request! (fixture/chat-prompt-request
18-
{:request-id 0
19-
:message "Hello there!"}))
20-
chat-id (:chatId resp)]
21-
22-
(is (match?
23-
{:chatId (m/pred string?)
24-
:model "claude-sonnet-4-20250514"
25-
:status "success"}
26-
resp))
27-
28-
(is (match?
29-
{:chatId chat-id
30-
:requestId 0
31-
:role "user"
32-
:content {:type "text" :text "Hello there!\n"}}
33-
(eca/client-awaits-server-notification :chat/contentReceived)))
34-
(is (match?
35-
{:chatId chat-id
36-
:requestId 0
37-
:role "system"
38-
:content {:type "progress" :state "running" :text "Waiting model"}}
39-
(eca/client-awaits-server-notification :chat/contentReceived))))))
25+
(let [chat-id* (atom nil)]
26+
(testing "We send a simple hello message"
27+
(llm.mocks/set-case! :simple-text-0)
28+
(let [req-id 0
29+
resp (eca/request! (fixture/chat-prompt-request
30+
{:request-id req-id
31+
:model "gpt-5"
32+
:message "Tell me a joke!"}))
33+
chat-id (reset! chat-id* (:chatId resp))]
34+
35+
(is (match?
36+
{:chatId (m/pred string?)
37+
:model "gpt-5"
38+
:status "success"}
39+
resp))
40+
41+
(match-content chat-id req-id "user" {:type "text" :text "Tell me a joke!\n"})
42+
(match-content chat-id req-id "system" {:type "progress" :state "running" :text "Waiting model"})
43+
(match-content chat-id req-id "system" {:type "progress" :state "running" :text "Generating"})
44+
(match-content chat-id req-id "assistant" {:type "text" :text "Knock"})
45+
(match-content chat-id req-id "assistant" {:type "text" :text " knock!"})
46+
(match-content chat-id req-id "system" {:type "usage"
47+
:messageInputTokens 10
48+
:messageOutputTokens 20
49+
:sessionTokens 30
50+
:messageCost (m/pred string?)
51+
:sessionCost (m/pred string?)})
52+
(match-content chat-id req-id "system" {:type "progress" :state "finished"})))
53+
54+
(testing "We reply"
55+
(llm.mocks/set-case! :simple-text-1)
56+
(let [req-id 1
57+
resp (eca/request! (fixture/chat-prompt-request
58+
{:chat-id @chat-id*
59+
:request-id req-id
60+
:model "gpt-5"
61+
:message "Who's there?"}))
62+
chat-id @chat-id*]
63+
64+
(is (match?
65+
{:chatId (m/pred string?)
66+
:model "gpt-5"
67+
:status "success"}
68+
resp))
69+
70+
(match-content chat-id req-id "user" {:type "text" :text "Who's there?\n"})
71+
(match-content chat-id req-id "system" {:type "progress" :state "running" :text "Waiting model"})
72+
(match-content chat-id req-id "system" {:type "progress" :state "running" :text "Generating"})
73+
(match-content chat-id req-id "assistant" {:type "text" :text "Foo"})
74+
(match-content chat-id req-id "system" {:type "usage"
75+
:messageInputTokens 10
76+
:messageOutputTokens 5
77+
:sessionTokens 45
78+
:messageCost (m/pred string?)
79+
:sessionCost (m/pred string?)})
80+
(match-content chat-id req-id "system" {:type "progress" :state "finished"})))
81+
82+
(testing "model reply again keeping context"
83+
(llm.mocks/set-case! :simple-text-2)
84+
(let [req-id 2
85+
resp (eca/request! (fixture/chat-prompt-request
86+
{:chat-id @chat-id*
87+
:request-id req-id
88+
:model "gpt-5"
89+
:message "What foo?"}))
90+
chat-id @chat-id*]
91+
92+
(is (match?
93+
{:chatId (m/pred string?)
94+
:model "gpt-5"
95+
:status "success"}
96+
resp))
97+
98+
(match-content chat-id req-id "user" {:type "text" :text "What foo?\n"})
99+
(match-content chat-id req-id "system" {:type "progress" :state "running" :text "Waiting model"})
100+
(match-content chat-id req-id "system" {:type "progress" :state "running" :text "Generating"})
101+
(match-content chat-id req-id "assistant" {:type "text" :text "Foo"})
102+
(match-content chat-id req-id "assistant" {:type "text" :text " bar!"})
103+
(match-content chat-id req-id "assistant" {:type "text" :text "\n\n"})
104+
(match-content chat-id req-id "assistant" {:type "text" :text "Ha!"})
105+
(match-content chat-id req-id "system" {:type "usage"
106+
:messageInputTokens 5
107+
:messageOutputTokens 15
108+
:sessionTokens 65
109+
:messageCost (m/pred string?)
110+
:sessionCost (m/pred string?)})
111+
(match-content chat-id req-id "system" {:type "progress" :state "finished"})))))

integration-test/integration/fixture.clj

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
11
(ns integration.fixture
22
(:require
33
[clojure.java.io :as io]
4-
[integration.helper :as h]))
4+
[integration.helper :as h]
5+
[llm-mock.server :as llm-mock.server]))
56

6-
(def default-init-options {:pureConfig true})
7+
(def ^:private base-llm-mock-url
8+
(str "http://localhost:" llm-mock.server/port))
9+
10+
(def default-init-options {:pureConfig true
11+
:openaiApiUrl (str base-llm-mock-url "/openai")
12+
:anthropicApiUrl (str base-llm-mock-url "/anthropic")})
713

814
(defn initialize-request
915
([]
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
(ns llm-mock.anthropic
2+
(:require
3+
[cheshire.core :as json]
4+
[org.httpkit.server :as hk]))
5+
6+
(defn ^:private sse-send!
7+
"Send one SSE event line pair: event + data, followed by a blank line.
8+
Keep the connection open until the scenario finishes."
9+
[ch event m]
10+
(hk/send! ch (str "event: " event "\n") false)
11+
(hk/send! ch (str "data: " (json/generate-string m) "\n\n") false))
12+
13+
(defn ^:private anthropic-simple-text! [ch]
14+
;; Stream minimal text and then finish with usage via message_delta
15+
(sse-send! ch "content_block_delta"
16+
{:type "content_block_delta"
17+
:index 0
18+
:delta {:type "text_delta" :text "Hello"}})
19+
(sse-send! ch "content_block_delta"
20+
{:type "content_block_delta"
21+
:index 0
22+
:delta {:type "text_delta" :text " world!"}})
23+
(sse-send! ch "message_delta"
24+
{:type "message_delta"
25+
:delta {:stop_reason "end_turn"}
26+
:usage {:input_tokens 10
27+
:output_tokens 3}})
28+
(hk/close ch))
29+
30+
(defn handle-anthropic-messages [req]
31+
(hk/as-channel
32+
req
33+
{:on-open (fn [ch]
34+
(hk/send! ch {:status 200
35+
:headers {"Content-Type" "text/event-stream; charset=utf-8"
36+
"Cache-Control" "no-cache"
37+
"Connection" "keep-alive"}}
38+
false)
39+
(anthropic-simple-text! ch))}))
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
(ns llm-mock.mocks)
2+
3+
(def ^:dynamic *case* nil)
4+
5+
(defn set-case! [case]
6+
(alter-var-root #'*case* (constantly case)))
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
(ns llm-mock.openai
2+
(:require
3+
[cheshire.core :as json]
4+
[llm-mock.mocks :as llm.mocks]
5+
[org.httpkit.server :as hk]))
6+
7+
(defn ^:private sse-send!
8+
"Send one SSE event line pair: event + data, followed by a blank line.
9+
Keep the connection open until the scenario finishes."
10+
[ch event m]
11+
(hk/send! ch (str "event: " event "\n") false)
12+
(hk/send! ch (str "data: " (json/generate-string m) "\n\n") false))
13+
14+
(defn ^:private simple-text-0 [ch]
15+
(sse-send! ch "response.output_text.delta"
16+
{:type "response.output_text.delta" :delta "Knock"})
17+
(sse-send! ch "response.output_text.delta"
18+
{:type "response.output_text.delta" :delta " knock!"})
19+
(sse-send! ch "response.completed"
20+
{:type "response.completed"
21+
:response {:output []
22+
:usage {:input_tokens 10
23+
:output_tokens 20}
24+
:status "completed"}})
25+
(hk/close ch))
26+
27+
(defn ^:private simple-text-1 [ch]
28+
(sse-send! ch "response.output_text.delta"
29+
{:type "response.output_text.delta" :delta "Foo"})
30+
(sse-send! ch "response.completed"
31+
{:type "response.completed"
32+
:response {:output []
33+
:usage {:input_tokens 10
34+
:output_tokens 5}
35+
:status "completed"}})
36+
(hk/close ch))
37+
38+
(defn ^:private simple-text-2 [ch]
39+
(sse-send! ch "response.output_text.delta"
40+
{:type "response.output_text.delta" :delta "Foo"})
41+
(sse-send! ch "response.output_text.delta"
42+
{:type "response.output_text.delta" :delta " bar!"})
43+
(sse-send! ch "response.output_text.delta"
44+
{:type "response.output_text.delta" :delta "\n\n"})
45+
(sse-send! ch "response.output_text.delta"
46+
{:type "response.output_text.delta" :delta "Ha!"})
47+
(sse-send! ch "response.completed"
48+
{:type "response.completed"
49+
:response {:output []
50+
:usage {:input_tokens 5
51+
:output_tokens 15}
52+
:status "completed"}})
53+
(hk/close ch))
54+
55+
(defn handle-openai-responses [req]
56+
(hk/as-channel
57+
req
58+
{:on-open (fn [ch]
59+
;; initial SSE handshake
60+
(hk/send! ch {:status 200
61+
:headers {"Content-Type" "text/event-stream; charset=utf-8"
62+
"Cache-Control" "no-cache"
63+
"Connection" "keep-alive"}}
64+
false)
65+
(case llm.mocks/*case*
66+
:simple-text-0 (simple-text-0 ch)
67+
:simple-text-1 (simple-text-1 ch)
68+
:simple-text-2 (simple-text-2 ch)))}))
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
(ns llm-mock.server
2+
(:require
3+
[llm-mock.anthropic :as llm-mock.anthropic]
4+
[llm-mock.openai :as llm-mock.openai]
5+
[org.httpkit.server :as hk]))
6+
7+
(def port 6660)
8+
9+
(defonce ^:private server* (atom nil))
10+
11+
(defn ^:private app [req]
12+
(let [{:keys [request-method uri]} req]
13+
(cond
14+
(and (= :post request-method)
15+
(= uri "/openai/v1/responses"))
16+
(llm-mock.openai/handle-openai-responses req)
17+
18+
(and (= :post request-method)
19+
(= uri "/anthropic/v1/messages"))
20+
(llm-mock.anthropic/handle-anthropic-messages req)
21+
22+
:else {:status 404 :headers {} :body "not found"})))
23+
24+
(defn start! []
25+
(when-not @server*
26+
(println "Starting LLM mock server...")
27+
(reset! server* (hk/run-server app {:port port})))
28+
:started)
29+
30+
(defn stop! []
31+
(when-let [server @server*]
32+
(server :timeout 100)
33+
(reset! server* nil)
34+
:stopped))

src/eca/config.clj

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
(def initial-config
1919
{:openaiApiKey nil
2020
:anthropicApiKey nil
21+
:openaiApiUrl nil
22+
:anthropicApiUrl nil
2123
:rules []
2224
:commands []
2325
:nativeTools {:filesystem {:enabled true}

0 commit comments

Comments
 (0)