diff --git a/deps/workers/shadow-cljs.edn b/deps/workers/shadow-cljs.edn index b5d7ffa377..91ba2c70f0 100644 --- a/deps/workers/shadow-cljs.edn +++ b/deps/workers/shadow-cljs.edn @@ -48,6 +48,11 @@ :ns-regexp "logseq\\.agents\\.e2b-runtime-provider-test$" :devtools {:enabled false} :main logseq.agents.e2b-runtime-provider-test-runner/main} + :agents-runtime-transport-test {:target :node-test + :output-to "worker/dist/agents-runtime-transport-test.js" + :ns-regexp "logseq\\.agents\\.(sandbox-test|runtime-provider-test)$" + :devtools {:enabled false} + :main logseq.agents.runtime-transport-test-runner/main} :agents-m25-managed-auth-test {:target :node-test :output-to "worker/dist/agents-m25-managed-auth-test.js" :ns-regexp "logseq\\.agents\\.m25-managed-auth-test$" diff --git a/deps/workers/src/logseq/agents/do.cljs b/deps/workers/src/logseq/agents/do.cljs index b0801eb491..6a50db82d2 100644 --- a/deps/workers/src/logseq/agents/do.cljs +++ b/deps/workers/src/logseq/agents/do.cljs @@ -4,6 +4,7 @@ [logseq.agents.checkpoint-store :as checkpoint-store] [logseq.agents.runner-store :as runner-store] [logseq.agents.runtime-provider :as runtime-provider] + [logseq.agents.sandbox :as sandbox] [logseq.agents.session :as session] [logseq.agents.source-control :as source-control] [logseq.sync.common :as common] @@ -597,9 +598,12 @@ (defn- event payload) + {:type (or (:type payload) "agent.runtime") + :data payload}) + event-type type] (p/let [_ ( ( ( base str string/trim not-empty) @@ -15,23 +16,8 @@ (re-find local-host-re base) (str "http://" base) :else (str "https://" base)))) -(defn sessions-base-url [base] - (str (normalize-base-url base) "/v1/sessions")) - -(defn session-url [base session-id] - (str (sessions-base-url base) "/" session-id)) - -(defn messages-url [base session-id] - (str (session-url base session-id) "/messages")) - -(defn messages-stream-url [base session-id] - (str (session-url base session-id) "/messages/stream")) - -(defn events-sse-url [base session-id] - (str (session-url base session-id) "/events/sse")) - -(defn terminate-url [base session-id] - (str (session-url base session-id) "/terminate")) +(defn acp-server-url [base server-id] + (str (normalize-base-url base) "/v1/acp/" (js/encodeURIComponent (or server-id "")))) (defn exec-command-url [base] (str (normalize-base-url base) "/v1/commands/exec")) @@ -48,6 +34,7 @@ (def ^:private agent-aliases {"claude-code" "claude" "claude_code" "claude" + "chatgpt" "codex" "open-code" "opencode" "open_code" "opencode"}) @@ -75,102 +62,211 @@ (.then (.json resp) #(js->clj % :keywordize-keys true)) (js/Promise.resolve fallback)))) +(defn- rpc-request [id method params] + (cond-> {:jsonrpc "2.0" + :id id + :method method} + (some? params) (assoc :params params))) + +(defn- rpc-response-result [json] + (cond + (contains? json :error) + (throw (ex-info "sandbox ACP request failed" + {:response json})) + + (contains? json :result) + (:result json) + + :else json)) + +(defn- build-url + ([base server-id] + (build-url base server-id nil)) + ([base server-id query] + (let [url (js/URL. (acp-server-url base server-id))] + (when (map? query) + (doseq [[k v] query] + (when (and (string? k) (some? v)) + (.set (.-searchParams url) k (str v))))) + (.toString url)))) + +(defn- permission-mode->mode-id [agent permission-mode] + (case (normalize-agent-id agent) + "codex" (case permission-mode + "read-only" "read-only" + "default" "read-only" + "bypass" "auto" + "auto" "auto" + "full-access" "full-access" + nil) + "amp" (case permission-mode + "bypass" "bypass" + "default" "default" + nil) + "claude" (case permission-mode + "acceptedits" "acceptEdits" + "accept-edits" "acceptEdits" + "plan" "plan" + "bypass" "bypassPermissions" + "default" "default" + nil) + nil)) + +(defn session-mode-id [payload] + (let [agent (:agent payload) + agent-mode (some-> (or (:agent-mode payload) (:agentMode payload)) + str string/trim not-empty) + permission-mode (some-> (or (:permission-mode payload) (:permissionMode payload)) + str string/lower-case string/trim not-empty)] + (or (case (normalize-agent-id agent) + "opencode" agent-mode + "claude" (permission-mode->mode-id agent permission-mode) + "codex" (permission-mode->mode-id agent permission-mode) + "amp" (permission-mode->mode-id agent permission-mode) + agent-mode) + (permission-mode->mode-id agent permission-mode)))) + +(defn acp-envelope->event [payload] + (let [method (:method payload) + params (:params payload)] + (cond + (= "session/update" method) + {:type "agent.runtime" + :data {:method method + :session-id (:sessionId params) + :update (:update params)}} + + :else nil))) + (defn {:agent (normalize-agent-id agent)} - (string? agent-mode) (assoc :agentMode agent-mode) - (string? permission-mode) (assoc :permissionMode permission-mode)) - req (json-request (session-url base session-id) "POST" headers body)] - (p/let [resp (js/fetch req) - status (.-status resp) - json (parse-json-or-default resp {})] - (if (<= 200 status 299) - (assoc json :session-id session-id) - (throw (ex-info "sandbox create-session failed" - {:status status - :session-id session-id - :response json}))))))) - -(defn {:message (:message message)} - (string? (:kind message)) (assoc :kind (:kind message))) - req (json-request (messages-url base session-id) "POST" headers body)] + body (rpc-request 4 "session/prompt" + {:sessionId remote-session-id + :prompt [{:type "text" + :text (:message message)}]}) + req (json-request (build-url base server-id) "POST" headers body)] (p/let [resp (js/fetch req) status (.-status resp)] - (if (<= 200 status 299) - true + (if-not (<= 200 status 299) (throw (ex-info "sandbox send-message failed" {:status status - :session-id session-id}))))))) + :server-id server-id + :session-id remote-session-id})) + (p/let [json (parse-json-or-default resp {})] + (rpc-response-result json))))))) (defn (.text (.clone request)) + (.then (fn [body-text] + (swap! calls conj {:type :fetch + :url (.-url request) + :method (.-method request) + :auth (.get (.-headers request) "authorization") + :body (js->clj (js/JSON.parse body-text) + :keywordize-keys true)}) + (let [result (case (get-in @calls [(dec (count @calls)) :body :method]) + "initialize" #js {:protocolVersion "0.1.0"} + "session/new" #js {:sessionId "remote-e2b-1"} + "session/set_mode" #js {} + #js {:ok true})] + (js/Response. + (js/JSON.stringify #js {:jsonrpc "2.0" + :id (get-in @calls [(dec (count @calls)) :body :id]) + :result result}) + #js {:status 200 + :headers #js {"content-type" "application/json"}}))))))) (-> (runtime-provider/ (.text (.clone request)) + (.then (fn [body-text] + (swap! calls conj {:url (fetch-url request) + :method (fetch-method request init) + :auth (.get (.-headers request) "authorization") + :cf-id (.get (.-headers request) "CF-Access-Client-Id") + :cf-secret (.get (.-headers request) "CF-Access-Client-Secret") + :body (js->clj (js/JSON.parse body-text) + :keywordize-keys true)}) + (let [result (case (get-in @calls [(dec (count @calls)) :body :method]) + "initialize" #js {:protocolVersion "0.1.0"} + "session/new" #js {:sessionId "remote-local-1"} + "session/set_mode" #js {} + #js {:ok true})] + (js/Response. + (js/JSON.stringify #js {:jsonrpc "2.0" + :id (get-in @calls [(dec (count @calls)) :body :id]) + :result result}) + #js {:status 200 :headers #js {"content-type" "application/json"}}))))))) (-> (runtime-provider/ (.text (.clone request)) (.then (fn [body-text] - (swap! payloads conj (js->clj (js/JSON.parse body-text) - :keywordize-keys true)) - (js/Response. - (js/JSON.stringify #js {:ok true}) - #js {:status 200 - :headers #js {"content-type" "application/json"}})))))) + (swap! calls conj {:url (.-url request) + :method (.-method request) + :body (js->clj (js/JSON.parse body-text) + :keywordize-keys true)}) + (let [method (:method (last @calls)) + result (case method + "initialize" #js {:protocolVersion "0.1.0"} + "session/new" #js {:sessionId "remote-sess-1"} + "session/set_mode" #js {} + #js {:ok true})] + (js/Response. + (js/JSON.stringify #js {:jsonrpc "2.0" + :id (get-in @calls [(dec (count @calls)) :body :id]) + :result result}) + #js {:status 200 + :headers #js {"content-type" "application/json"}}))))))) (-> (sandbox/ (.text (.clone request)) + (.then (fn [body-text] + (swap! requests conj {:url (.-url request) + :method (.-method request) + :body (js->clj (js/JSON.parse body-text) + :keywordize-keys true)}) + (js/Response. + (js/JSON.stringify #js {:jsonrpc "2.0" + :id (get-in @requests [(dec (count @requests)) :body :id]) + :result #js {:stopReason "end_turn"}}) + #js {:status 200 + :headers #js {"content-type" "application/json"}})))))) + (-> (sandbox/event + {:jsonrpc "2.0" + :id 3 + :result {:stopReason "end_turn"}}))) + (is (nil? (sandbox/acp-envelope->event + {:jsonrpc "2.0" + :id 4 + :result {:stopReason "cancelled"}}))) + (is (= {:type "agent.runtime" + :data {:method "session/update" + :session-id "remote-sess-1" + :update {:sessionUpdate "agent_message_chunk" + :content "hello"}}} + (sandbox/acp-envelope->event + {:jsonrpc "2.0" + :method "session/update" + :params {:sessionId "remote-sess-1" + :update {:sessionUpdate "agent_message_chunk" + :content "hello"}}}))) + (is (nil? (sandbox/acp-envelope->event + {:jsonrpc "2.0" + :id 1 + :result {:sessionId "remote-sess-1"}}))))) diff --git a/messages-ignores-non-chat-acp-runtime-events-test b/messages-ignores-non-chat-acp-runtime-events-test new file mode 100644 index 0000000000..461cd887a9 --- /dev/null +++ b/messages-ignores-non-chat-acp-runtime-events-test @@ -0,0 +1,8 @@ +:run-background-task :logseq.db.common.entity-plus/reset-immutable-entities-cache! +:run-background-task :frontend.background-tasks/sync-to-worker-network-online-status +:run-background-task :frontend.components.rtc.indicator/update-accumulated-download-logs +:run-background-task :frontend.components.rtc.indicator/update-accumulated-upload-logs +:run-background-task :frontend.worker.embedding/subscribe-state + +Ran 0 tests containing 0 assertions. +0 failures, 0 errors. diff --git a/messages-includes-acp-agent-message-chunks-test b/messages-includes-acp-agent-message-chunks-test new file mode 100644 index 0000000000..461cd887a9 --- /dev/null +++ b/messages-includes-acp-agent-message-chunks-test @@ -0,0 +1,8 @@ +:run-background-task :logseq.db.common.entity-plus/reset-immutable-entities-cache! +:run-background-task :frontend.background-tasks/sync-to-worker-network-online-status +:run-background-task :frontend.components.rtc.indicator/update-accumulated-download-logs +:run-background-task :frontend.components.rtc.indicator/update-accumulated-upload-logs +:run-background-task :frontend.worker.embedding/subscribe-state + +Ran 0 tests containing 0 assertions. +0 failures, 0 errors. diff --git a/messages-splits-acp-turns-by-end-turn-result-test b/messages-splits-acp-turns-by-end-turn-result-test new file mode 100644 index 0000000000..461cd887a9 --- /dev/null +++ b/messages-splits-acp-turns-by-end-turn-result-test @@ -0,0 +1,8 @@ +:run-background-task :logseq.db.common.entity-plus/reset-immutable-entities-cache! +:run-background-task :frontend.background-tasks/sync-to-worker-network-online-status +:run-background-task :frontend.components.rtc.indicator/update-accumulated-download-logs +:run-background-task :frontend.components.rtc.indicator/update-accumulated-upload-logs +:run-background-task :frontend.worker.embedding/subscribe-state + +Ran 0 tests containing 0 assertions. +0 failures, 0 errors. diff --git a/src/main/frontend/components/agent_chat.cljs b/src/main/frontend/components/agent_chat.cljs index 8ff39a6f49..1d55a9932c 100644 --- a/src/main/frontend/components/agent_chat.cljs +++ b/src/main/frontend/components/agent_chat.cljs @@ -82,6 +82,49 @@ (when (string? (:output_text payload)) (:output_text payload)) (when (string? (:raw payload)) (:raw payload)))) +(defn- acp-runtime-update + [event] + (let [data (when (map? (:data event)) (:data event))] + (when (= "session/update" (:method data)) + (let [update (:update data)] + (when (map? update) + update))))) + +(defn- acp-runtime-update-kind + [event] + (some-> (acp-runtime-update event) + :sessionUpdate + str + string/trim + not-empty)) + +(defn- acp-runtime-session-id + [event] + (some-> (when (map? (:data event)) (:data event)) + :session-id + str + string/trim + not-empty)) + +(defn- acp-runtime-update-text + [event] + (let [update (acp-runtime-update event) + content (:content update)] + (or (when (map? content) + (or (some-> (:text content) str) + (some-> (:delta content) str))) + (some-> (:text update) str) + (some-> (:delta update) str)))) + +(defn- acp-runtime-stop-reason + [event] + (some-> (when (map? (:data event)) (:data event)) + :result + :stopReason + str + string/trim + not-empty)) + (defn- agent-title [agent-value] (cond @@ -96,7 +139,10 @@ base (let [acc (atom {:items {} :order [] :item-kind-by-id {} - :tool-call-id-by-item-id {}})] + :tool-call-id-by-item-id {} + :acp-turn-by-runtime {} + :acp-active-item-by-runtime {} + :last-acp-runtime-session-id nil})] (letfn [(known-item-kind [item-id] (get-in @acc [:item-kind-by-id item-id])) (remember-item-kind! [item-id item-kind] @@ -231,6 +277,20 @@ (:output part) "") delta))))))) + (current-acp-item-id [runtime-session-id] + (get-in @acc [:acp-active-item-by-runtime runtime-session-id])) + (ensure-acp-item-id! [runtime-session-id] + (when (string? runtime-session-id) + (swap! acc assoc :last-acp-runtime-session-id runtime-session-id) + (or (current-acp-item-id runtime-session-id) + (let [turn (or (get-in @acc [:acp-turn-by-runtime runtime-session-id]) 1) + item-id (str runtime-session-id "-turn-" turn)] + (swap! acc assoc-in [:acp-active-item-by-runtime runtime-session-id] item-id) + item-id)))) + (advance-acp-turn! [runtime-session-id] + (when (string? runtime-session-id) + (swap! acc update-in [:acp-turn-by-runtime runtime-session-id] (fnil inc 1)) + (swap! acc update :acp-active-item-by-runtime dissoc runtime-session-id))) (process-content-part! [item-id role part] (let [part-kind (chat-event/content-part-kind part)] (cond @@ -253,71 +313,92 @@ :else nil))) (process-event! [event] (let [event-type (:type event) - payload (chat-event/unwrap-event-payload event) - payload (if (map? payload) payload {}) - item (if (map? (:item payload)) (:item payload) {}) - item-id (chat-event/event-item-id event payload item) - item-kind (or (chat-event/event-item-kind payload item) - (known-item-kind item-id)) - role (chat-role (or (:role item) - (:role payload) - (when (= "audit.log" event-type) - (role-from-kind (:kind payload) "user")) - (:kind payload) - "assistant")) - merged (merge payload item) - delta (payload-delta payload) - known-tool-call? (string? (known-tool-call-id item-id)) - tool-result-like? (or (= "tool-result" item-kind) - known-tool-call?)] - (when (string? item-id) - (remember-item-kind! item-id item-kind) - (case event-type - "item.started" - (when (= "tool-result" item-kind) - (set-tool-input! item-id role merged)) + runtime-update-kind (acp-runtime-update-kind event)] + (cond + (and (= "agent.runtime" event-type) + (= "agent_message_chunk" runtime-update-kind)) + (when-let [item-id (ensure-acp-item-id! (acp-runtime-session-id event))] + (append-text-part! item-id "assistant" (acp-runtime-update-text event))) - ("item.completed" "response.completed") - (if (seq (:content item)) - (doseq [part (:content item)] - (process-content-part! item-id role part)) - (cond - (= "reasoning" item-kind) - (set-reasoning-part! item-id role (or (chat-event/payload-text item) - (chat-event/payload-text payload))) + (and (= "agent.runtime" event-type) + (= "agent_thought_chunk" runtime-update-kind)) + (when-let [item-id (ensure-acp-item-id! (acp-runtime-session-id event))] + (append-reasoning-part! item-id "assistant" (acp-runtime-update-text event))) - (= "tool-call" item-kind) - (set-tool-input! item-id role merged) + (and (= "agent.runtime" event-type) + (string? (acp-runtime-stop-reason event))) + (advance-acp-turn! (or (acp-runtime-session-id event) + (:last-acp-runtime-session-id @acc))) - (= "tool-result" item-kind) - (set-tool-output! item-id role merged) + (= "agent.runtime" event-type) + nil - (= "status" item-kind) - (when-let [error-message (chat-event/status-error-message merged)] - (set-text-part! item-id "assistant" error-message)) + :else + (let [payload (chat-event/unwrap-event-payload event) + payload (if (map? payload) payload {}) + item (if (map? (:item payload)) (:item payload) {}) + item-id (chat-event/event-item-id event payload item) + item-kind (or (chat-event/event-item-kind payload item) + (known-item-kind item-id)) + role (chat-role (or (:role item) + (:role payload) + (when (= "audit.log" event-type) + (role-from-kind (:kind payload) "user")) + (:kind payload) + "assistant")) + merged (merge payload item) + delta (payload-delta payload) + known-tool-call? (string? (known-tool-call-id item-id)) + tool-result-like? (or (= "tool-result" item-kind) + known-tool-call?)] + (when (string? item-id) + (remember-item-kind! item-id item-kind) + (case event-type + "item.started" + (when (= "tool-result" item-kind) + (set-tool-input! item-id role merged)) - :else - (set-text-part! item-id role (or (chat-event/payload-text item) - (chat-event/payload-text payload))))) + ("item.completed" "response.completed") + (if (seq (:content item)) + (doseq [part (:content item)] + (process-content-part! item-id role part)) + (cond + (= "reasoning" item-kind) + (set-reasoning-part! item-id role (or (chat-event/payload-text item) + (chat-event/payload-text payload))) - "audit.log" - (set-text-part! item-id "user" (chat-event/payload-text payload)) + (= "tool-call" item-kind) + (set-tool-input! item-id role merged) - nil) + (= "tool-result" item-kind) + (set-tool-output! item-id role merged) - (when (chat-event/delta-event? event-type) - (cond - (= "reasoning" item-kind) - (append-reasoning-part! item-id role delta) + (= "status" item-kind) + (when-let [error-message (chat-event/status-error-message merged)] + (set-text-part! item-id "assistant" error-message)) - (= "tool-call" item-kind) - (set-tool-input! item-id role merged) + :else + (set-text-part! item-id role (or (chat-event/payload-text item) + (chat-event/payload-text payload))))) - tool-result-like? - (append-tool-output-delta! item-id role merged delta) + "audit.log" + (set-text-part! item-id "user" (chat-event/payload-text payload)) - :else - (append-text-part! item-id role delta))))))] + nil) + + (when (chat-event/delta-event? event-type) + (cond + (= "reasoning" item-kind) + (append-reasoning-part! item-id role delta) + + (= "tool-call" item-kind) + (set-tool-input! item-id role merged) + + tool-result-like? + (append-tool-output-delta! item-id role merged delta) + + :else + (append-text-part! item-id role delta))))))))] (doseq [event events] (process-event! event)) (->> (:order @acc) diff --git a/src/main/frontend/handler/agent_chat_transport.cljs b/src/main/frontend/handler/agent_chat_transport.cljs index 2cde408f16..2db3ec79aa 100644 --- a/src/main/frontend/handler/agent_chat_transport.cljs +++ b/src/main/frontend/handler/agent_chat_transport.cljs @@ -17,6 +17,12 @@ (let [trimmed (string/trim value)] (when-not (string/blank? trimmed) trimmed)))) +(defn- non-blank-str-preserve + [value] + (when (string? value) + (when-not (string/blank? value) + value))) + (defn- read-field [x k] (cond @@ -159,6 +165,31 @@ [event-type] (contains? #{"session.completed" "session.failed" "session.canceled"} event-type)) +(defn- acp-runtime-update + [event] + (let [data (if (map? (:data event)) (:data event) {})] + (when (= "session/update" (:method data)) + (let [update (:update data)] + (when (map? update) + update))))) + +(defn- acp-runtime-update-kind + [event] + (some-> (acp-runtime-update event) + :sessionUpdate + str + string/trim + not-empty)) + +(defn- acp-update-text + [update] + (let [content (:content update)] + (or (when (map? content) + (or (non-blank-str-preserve (:text content)) + (non-blank-str-preserve (:delta content)))) + (non-blank-str-preserve (:text update)) + (non-blank-str-preserve (:delta update))))) + (defn- ^:large-vars/cleanup-todo start-stream-consumer! [{:keys [response writer start-ts idle-timeout-ms abort-signal]}] (let [reader (.getReader (.-body response)) @@ -328,6 +359,15 @@ [{:type "text-delta" :id text-part-id :delta delta}]))))) + (acp-text-delta-chunks! [delta] + (let [delta (non-blank-str-preserve delta)] + (if-not (string? delta) + [] + (vec (concat (start-chunks!) + (text-start-chunks!) + [{:type "text-delta" + :id text-part-id + :delta delta}]))))) (event-chunks-for-content-part! [item-id idx part] (let [part-kind (chat-event/content-part-kind part) nested-item-id (str (or item-id "item") "-" idx)] @@ -424,22 +464,32 @@ ( (write-chunks! writer [{:type "error" :errorText (runtime-error-message event)}]) (.finally (fn [] (finish!)))) + (and (= event-type "agent.runtime") + (= "agent_message_chunk" runtime-update-kind)) + ( (if (= event-type "session.completed") (js/Promise.resolve nil) @@ -447,77 +497,78 @@ :errorText event-type}])) (.finally (fn [] (finish!)))) - (= event-type "item.started") - (let [item-kind (or (chat-event/event-item-kind payload item) - (known-item-kind item-id)) - merged (merge (if (map? payload) payload {}) - (if (map? item) item {})) - _ (remember-item-kind! item-id item-kind) - chunks (cond - (= "tool-result" item-kind) - (vec (concat (start-chunks!) - (tool-input-available-once-chunks! item-id merged))) - - :else - [])] - ( ( (messages-includes-acp-agent-message-chunks-test + (testing "maps ACP agent.runtime session/update message chunks into assistant text" + (let [session {:events [{:type "agent.runtime" + :ts 1100 + :data {:method "session/update" + :session-id "runtime-1" + :update {:sessionUpdate "agent_message_chunk" + :content {:type "text" + :text "Received"}}}} + {:type "agent.runtime" + :ts 1110 + :data {:method "session/update" + :session-id "runtime-1" + :update {:sessionUpdate "agent_message_chunk" + :content {:type "text" + :text " test"}}}}]} + messages (#'agent-chat/session->messages session {:block/uuid "b5"})] + (is (= [{:id "runtime-1" + :role "assistant" + :parts [{:type "text" + :text "Received test"}]}] + messages))))) + +(deftest session->messages-ignores-non-chat-acp-runtime-events-test + (testing "does not render ACP setup/config events as chat messages" + (let [session {:events [{:type "agent.runtime" + :ts 1100 + :data {:id 1 + :jsonrpc "2.0" + :result {:protocolVersion 1}}} + {:type "agent.runtime" + :ts 1110 + :data {:method "session/update" + :session-id "runtime-1" + :update {:sessionUpdate "config_option_update" + :configOptions [{:id "mode" + :currentValue "auto"}]}}} + {:type "agent.runtime" + :ts 1120 + :data {:method "session/update" + :session-id "runtime-1" + :update {:sessionUpdate "usage_update" + :used 42}}}]} + messages (#'agent-chat/session->messages session {:block/uuid "b6"})] + (is (= [] messages))))) + +(deftest session->messages-splits-acp-turns-by-end-turn-result-test + (testing "separates assistant replies across ACP turns in the same runtime session" + (let [session {:events [{:type "audit.log" + :ts 1000 + :data {:event "user-message" + :kind "user" + :by "u1" + :message "first"}} + {:type "agent.runtime" + :ts 1100 + :data {:method "session/update" + :session-id "runtime-1" + :update {:sessionUpdate "agent_message_chunk" + :content {:type "text" + :text "First"}}}} + {:type "agent.runtime" + :ts 1110 + :session-id "do-session-1" + :data {:id 4 + :jsonrpc "2.0" + :result {:stopReason "end_turn"}}} + {:type "audit.log" + :ts 1200 + :data {:event "user-message" + :kind "user" + :by "u1" + :message "second"}} + {:type "agent.runtime" + :ts 1300 + :data {:method "session/update" + :session-id "runtime-1" + :update {:sessionUpdate "agent_message_chunk" + :content {:type "text" + :text "Second"}}}} + {:type "agent.runtime" + :ts 1310 + :session-id "do-session-1" + :data {:id 5 + :jsonrpc "2.0" + :result {:stopReason "end_turn"}}}]} + messages (#'agent-chat/session->messages session {:block/uuid "b7"})] + (is (= [{:id "task-b7" + :role "user" + :parts [{:type "text" :text "first"}]} + {:id "runtime-1-turn-1" + :role "assistant" + :parts [{:type "text" :text "First"}]} + {:id "runtime-1-turn-2" + :role "assistant" + :parts [{:type "text" :text "Second"}]}] + messages))))) + (deftest session-messages-need-sync-detects-content-growth-without-clobbering-optimistic-ui-test (testing "session updates with richer content should sync even when count is unchanged" (let [f (some-> (resolve 'frontend.components.agent-chat/session-messages-need-sync?) diff --git a/src/test/frontend/handler/agent_chat_transport_test.cljs b/src/test/frontend/handler/agent_chat_transport_test.cljs index 5d207ea7c5..c6fba1cc90 100644 --- a/src/test/frontend/handler/agent_chat_transport_test.cljs +++ b/src/test/frontend/handler/agent_chat_transport_test.cljs @@ -102,6 +102,149 @@ (is false (str "unexpected error: " error)) (done))))))) +(deftest send-messages-streams-acp-agent-message-chunks-test + (async done + (let [fetch-fn (fn [url init] + (let [method (or (aget init "method") "GET")] + (cond + (and (= "POST" method) + (= "http://db-sync.local/sessions/sess-1/messages" url)) + (js/Promise.resolve + (js/Response. + (js/JSON.stringify #js {:ok true}) + #js {:status 200 + :headers #js {"content-type" "application/json"}})) + + (and (= "GET" method) + (= "http://db-sync.local/sessions/sess-1/stream" url)) + (js/Promise.resolve + (sse-response + [{:type "agent.runtime" + :ts 1100 + :data {:method "session/update" + :session-id "runtime-1" + :update {:sessionUpdate "agent_message_chunk" + :content {:type "text" + :text "Received"}}}} + {:type "agent.runtime" + :ts 1200 + :data {:method "session/update" + :session-id "runtime-1" + :update {:sessionUpdate "agent_message_chunk" + :content {:type "text" + :text " test"}}}} + {:type "agent.runtime" + :ts 1300 + :data {:id 4 + :jsonrpc "2.0" + :result {:stopReason "end_turn"}}}])) + + :else + (js/Promise.resolve + (js/Response. + (js/JSON.stringify #js {:error "unexpected request"}) + #js {:status 500 + :headers #js {"content-type" "application/json"}}))))) + transport (agent-chat-transport/make-transport + {:base "http://db-sync.local" + :session-id "sess-1" + :fetch-fn fetch-fn + :now-fn (fn [] 1000) + :idle-timeout-ms 50})] + (-> (.sendMessages transport + #js {:chatId "sess-1" + :trigger "submit-message" + :messageId nil + :messages #js [#js {:id "user-1" + :role "user" + :parts #js [#js {:type "text" + :text "hello from ui"}]}]}) + (.then > chunks + (filter #(= "text-delta" (:type %))) + (map :delta) + vec)] + (is (= ["start" + "start-step" + "text-start" + "text-delta" + "text-delta" + "text-end" + "finish-step" + "finish"] + types)) + (is (= ["Received" " test"] deltas)) + (done)))) + (.catch (fn [error] + (is false (str "unexpected error: " error)) + (done))))))) + +(deftest send-messages-ignores-acp-setup-and-config-runtime-events-test + (async done + (let [fetch-fn (fn [url init] + (let [method (or (aget init "method") "GET")] + (cond + (and (= "POST" method) + (= "http://db-sync.local/sessions/sess-1/messages" url)) + (js/Promise.resolve + (js/Response. + (js/JSON.stringify #js {:ok true}) + #js {:status 200 + :headers #js {"content-type" "application/json"}})) + + (and (= "GET" method) + (= "http://db-sync.local/sessions/sess-1/stream" url)) + (js/Promise.resolve + (sse-response + [{:type "agent.runtime" + :ts 1100 + :data {:id 1 + :jsonrpc "2.0" + :result {:protocolVersion 1}}} + {:type "agent.runtime" + :ts 1200 + :data {:method "session/update" + :session-id "runtime-1" + :update {:sessionUpdate "config_option_update" + :configOptions [{:id "mode" + :currentValue "auto"}]}}} + {:type "agent.runtime" + :ts 1300 + :data {:method "session/update" + :session-id "runtime-1" + :update {:sessionUpdate "usage_update" + :used 42}}}])) + + :else + (js/Promise.resolve + (js/Response. + (js/JSON.stringify #js {:error "unexpected request"}) + #js {:status 500 + :headers #js {"content-type" "application/json"}}))))) + transport (agent-chat-transport/make-transport + {:base "http://db-sync.local" + :session-id "sess-1" + :fetch-fn fetch-fn + :now-fn (fn [] 1000) + :idle-timeout-ms 50})] + (-> (.sendMessages transport + #js {:chatId "sess-1" + :trigger "submit-message" + :messageId nil + :messages #js [#js {:id "user-1" + :role "user" + :parts #js [#js {:type "text" + :text "hello from ui"}]}]}) + (.then