From adbde3cf525f7210fb6e5aa31722d5b85a565ca0 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Tue, 10 Mar 2026 18:01:55 +0800 Subject: [PATCH] sandbox plan --- deps/db/src/logseq/db/frontend/property.cljs | 10 + deps/db/src/logseq/db/frontend/schema.cljs | 2 +- deps/workers/deps.edn | 3 +- .../docs/milestones/agents/00-index.md | 2 + ...26-plan-first-execution-and-post-review.md | 81 ++++++ deps/workers/package.json | 6 +- deps/workers/src/logseq/agents/do.cljs | 236 +++++++++++++++++- .../src/logseq/agents/runtime_provider.cljs | 1 - package.json | 4 +- scripts/clean-shadow-test-builds.js | 15 ++ shadow-cljs.edn | 3 +- src/main/frontend/components/agent_chat.cljs | 84 ++++++- src/main/frontend/handler/agent.cljs | 199 ++++++++++++++- src/main/frontend/worker/db/migrate.cljs | 4 +- .../frontend/components/agent_chat_test.cljs | 31 +++ src/test/frontend/handler/agent_test.cljs | 171 +++++++++++++ src/test/frontend/worker/migrate_test.cljs | 20 ++ 17 files changed, 845 insertions(+), 27 deletions(-) create mode 100644 deps/workers/docs/milestones/agents/26-m26-plan-first-execution-and-post-review.md create mode 100644 scripts/clean-shadow-test-builds.js create mode 100644 src/test/frontend/handler/agent_test.cljs diff --git a/deps/db/src/logseq/db/frontend/property.cljs b/deps/db/src/logseq/db/frontend/property.cljs index f2e7820c9a..851ac0e106 100644 --- a/deps/db/src/logseq/db/frontend/property.cljs +++ b/deps/db/src/logseq/db/frontend/property.cljs @@ -437,6 +437,16 @@ :public? false :hide? true} :queryable? false} + :logseq.property/agent-plan + {:title "Agent plan" + :schema {:type :default + :public? true} + :properties {:logseq.property/description "Stores the task plan markdown emitted at the start of an agent session."}} + :logseq.property/post-review + {:title "Post-review" + :schema {:type :default + :public? true} + :properties {:logseq.property/description "Stores the post-review markdown emitted when an agent session finishes."}} :logseq.property/git-repo {:title "Git Repo" :schema {:type :url diff --git a/deps/db/src/logseq/db/frontend/schema.cljs b/deps/db/src/logseq/db/frontend/schema.cljs index 08d4dab70e..ca12bfd85b 100644 --- a/deps/db/src/logseq/db/frontend/schema.cljs +++ b/deps/db/src/logseq/db/frontend/schema.cljs @@ -30,7 +30,7 @@ (map (juxt :major :minor) [(parse-schema-version x) (parse-schema-version y)]))) -(def version (parse-schema-version "65.29")) +(def version (parse-schema-version "65.30")) (defn major-version "Return a number. diff --git a/deps/workers/deps.edn b/deps/workers/deps.edn index 8915499b8d..a24475794e 100644 --- a/deps/workers/deps.edn +++ b/deps/workers/deps.edn @@ -1,4 +1,4 @@ -{:paths ["src" "test" "../../resources"] +{:paths ["src" "../../resources"] :deps {org.clojure/clojure {:mvn/version "1.11.1"} datascript/datascript {:git/url "https://github.com/logseq/datascript" @@ -19,6 +19,7 @@ org.clojars.knubie/cljs-run-test {:mvn/version "1.0.1"} tortue/spy {:mvn/version "2.14.0"}} :main-opts ["-m" "shadow.cljs.devtools.cli"]} + :test {:extra-paths ["test"]} :clj-kondo {:replace-deps {clj-kondo/clj-kondo {:mvn/version "2024.09.27"}} :main-opts ["-m" "clj-kondo.main"]}}} diff --git a/deps/workers/docs/milestones/agents/00-index.md b/deps/workers/docs/milestones/agents/00-index.md index 3de62ece69..19267a3423 100644 --- a/deps/workers/docs/milestones/agents/00-index.md +++ b/deps/workers/docs/milestones/agents/00-index.md @@ -24,3 +24,5 @@ Milestones are tracked as separate files in this folder: - `21-m21-store-snapshots-metadata-in-d1.md` - `23-m23-local-runner-via-tunnel.md` - `24-m24-e2b-sandbox-runtime-default.md` +- `25-m25-chatgpt-login-token-auth.md` +- `26-m26-plan-first-execution-and-post-review.md` diff --git a/deps/workers/docs/milestones/agents/26-m26-plan-first-execution-and-post-review.md b/deps/workers/docs/milestones/agents/26-m26-plan-first-execution-and-post-review.md new file mode 100644 index 0000000000..955eec01b2 --- /dev/null +++ b/deps/workers/docs/milestones/agents/26-m26-plan-first-execution-and-post-review.md @@ -0,0 +1,81 @@ +# M26: Plan-First Execution and Post-Review Persistence + +Status: Implemented +Target: Make every agent execution session start with a planning phase in the same session, persist the generated plan onto the task, create or update split subtasks in Logseq when the plan includes them, and persist a final post-review markdown artifact when the session completes. + +## Goal +Keep execution in a single agent session while forcing a plan-first workflow that writes structured planning and review artifacts back into Logseq. + +## Why M26 +- Execution should not start from an unstructured prompt with no persisted plan. +- Logseq needs a durable record of the agent's implementation plan, including any proposed split subtasks. +- Completed sessions should leave behind a reusable post-review artifact instead of requiring users to reconstruct what changed from the raw chat stream. + +## Implemented Flow +1. Task session creation still starts a normal execution session. +2. The initial task prompt now embeds an artifact protocol that requires: + - a `...` artifact before code changes + - continued implementation in the same session after the plan + - a `...` artifact when the task is finished +3. The frontend watches fetched and streamed session events. +4. When a plan artifact appears, Logseq stores: + - `Agent plan` + - generated child tasks when `subtasks` are present +5. When a post-review artifact appears, Logseq stores: + - `Post-review` + +## Current State +- Plan/review prompt protocol and event persistence: `src/main/frontend/handler/agent.cljs` +- Built-in task properties: + - `deps/db/src/logseq/db/frontend/property.cljs` +- Schema version bump: + - `deps/db/src/logseq/db/frontend/schema.cljs` +- Frontend migration wiring: + - `src/main/frontend/worker/db/migrate.cljs` +- Tests: + - `src/test/frontend/handler/agent_test.cljs` + - `src/test/frontend/worker/migrate_test.cljs` + +## Final Decisions + +### D1: Single Session +- Planning and execution happen in the same agent session. +- No separate planner session is introduced. + +### D2: Logseq Artifacts +- The persisted planning artifact is markdown stored on the task itself. +- Suggested split work is stored as child tasks under the parent task. +- The persisted review artifact is markdown stored on the task itself. + +### D3: Artifact Contract +- The planning artifact is emitted inside ``. +- The completion artifact is emitted inside ``. +- Artifact payloads are JSON so the frontend can extract them deterministically from streamed agent output. + +## Data Model +- `:logseq.property/agent-plan` +- `:logseq.property/post-review` + +## Out of Scope +- Server-side task graph mutation without a connected Logseq client. +- A separate planner-only mode or planner session type. + +## Validation +- Added migration coverage for the new built-in properties. +- Added frontend handler coverage for: + - plan-first prompt construction + - plan artifact persistence + - post-review artifact persistence +- Build verification completed with: + - `clojure -M:test compile test-no-worker` + +## Known Limitation +- The repo's JS test runner currently fails before executing frontend tests in this environment because the root Node runtime cannot resolve the `e2b` module. +- That issue is environmental and separate from the changed frontend/DB code. + +## Exit Criteria +1. Every execution prompt instructs the agent to emit a plan artifact before coding. +2. Logseq persists the plan markdown to the task. +3. Logseq creates or updates split subtasks when the plan includes them. +4. Logseq persists a final post-review markdown artifact when the session completes. +5. Planning and execution remain in the same session. diff --git a/deps/workers/package.json b/deps/workers/package.json index 182bcbcaf2..ecc46861cd 100644 --- a/deps/workers/package.json +++ b/deps/workers/package.json @@ -13,9 +13,9 @@ "migrate:local": "cd ./worker && wrangler d1 migrations apply DB --local", "migrate:staging": "cd ./worker && wrangler d1 migrations apply logseq-sync-graph-meta-staging --env staging --remote", "migrate:prod": "cd ./worker && wrangler d1 migrations apply logseq-sync-graphs-prod --env prod --remote", - "test:node-adapter": "clojure -M:cljs compile db-sync-test && node worker/dist/worker-test.js", - "test:worker-split": "clojure -M:cljs compile db-sync-worker-split-test && node worker/dist/worker-split-test.js", - "test": "clojure -M:cljs compile db-sync-test && node worker/dist/worker-test.js", + "test:node-adapter": "clojure -M:cljs:test compile db-sync-test && node worker/dist/worker-test.js", + "test:worker-split": "clojure -M:cljs:test compile db-sync-worker-split-test && node worker/dist/worker-split-test.js", + "test": "clojure -M:cljs:test compile db-sync-test && node worker/dist/worker-test.js", "clean": "rm -rf ./worker/dist/", "sentry:sourcemaps": "sentry-cli sourcemaps upload --release $SENTRY_RELEASE --rewrite --strip-prefix worker/dist/worker --url-prefix \"~/\" worker/dist/worker", "sentry:sourcemaps:agents": "sentry-cli sourcemaps upload --release $SENTRY_RELEASE --rewrite --strip-prefix worker/dist/agents --url-prefix \"~/\" worker/dist/agents", diff --git a/deps/workers/src/logseq/agents/do.cljs b/deps/workers/src/logseq/agents/do.cljs index 6ec71d8f7d..1378398746 100644 --- a/deps/workers/src/logseq/agents/do.cljs +++ b/deps/workers/src/logseq/agents/do.cljs @@ -24,6 +24,32 @@ (defn- sse-bytes [event] (.encode (js/TextEncoder.) (sse-encode event))) +(defn- make-event + [session-id type data ts] + {:event-id (str (random-uuid)) + :session-id session-id + :type type + :ts (or ts (common/now-ms)) + :data data}) + +(defn- acp-live-message-event + [session-id runtime-session-id turn text reasoning ts] + (let [text (some-> text str string/trim not-empty) + reasoning (some-> reasoning str string/trim not-empty) + parts (cond-> [] + (string? reasoning) (conj {:type "reasoning" :text reasoning}) + (string? text) (conj {:type "text" :text text}))] + (when (seq parts) + {:event-id (str "live-" runtime-session-id "-turn-" turn) + :session-id session-id + :type "item.completed" + :ts (or ts (common/now-ms)) + :data {:item_id (str runtime-session-id "-turn-" turn) + :item {:item_id (str runtime-session-id "-turn-" turn) + :kind "message" + :role "assistant" + :content parts}}}))) + (defn- clj value :keywordize-keys true)))) @@ -101,6 +127,52 @@ (:task session) {})) +(defn- sanitize-agent-for-public + [agent] + (if (map? agent) + (dissoc agent :api-token :auth-json :managed-auth) + agent)) + +(defn- sanitize-task-for-public + [task] + (if (map? task) + (update task :agent sanitize-agent-for-public) + task)) + +(defn- sanitize-event-data-for-public + [data] + (if (map? data) + (cond-> data + (contains? data :agent) (update :agent sanitize-agent-for-public) + (contains? data :task) (update :task sanitize-task-for-public)) + data)) + +(defn- sanitize-event-for-public + [event] + (if (map? event) + (update event :data sanitize-event-data-for-public) + event)) + +(defn- session-live-messages + [session] + (if (map? (:live-messages session)) + (:live-messages session) + {})) + +(defn- assoc-live-message + [session runtime-session-id event] + (assoc session :live-messages + (cond-> (session-live-messages session) + (and (string? runtime-session-id) (map? event)) + (assoc runtime-session-id event)))) + +(defn- dissoc-live-message + [session runtime-session-id] + (assoc session :live-messages + (if (string? runtime-session-id) + (dissoc (session-live-messages session) runtime-session-id) + (session-live-messages session)))) + (defn- runtime-snapshot-id [result] (some-> (:snapshot-id result) str string/trim not-empty)) @@ -220,6 +292,93 @@ (.catch (fn [_] (.delete streams key))))))))) +(defn- runtime-delta-event? + [event-type] + (and (string? event-type) + (or (= "item.delta" event-type) + (= "response.delta" event-type) + (string/ends-with? event-type ".delta")))) + +(defn- runtime-transient-event? + [event-type] + (contains? #{"item.started" + "response.started"} + event-type)) + +(defn- ensure-acp-turn-state! + [^js self runtime-session-id] + (let [store (or (.-acpTurnState self) + (let [m (js/Map.)] + (set! (.-acpTurnState self) m) + m)) + existing (.get store runtime-session-id)] + (if existing + existing + (let [state #js {:turn 1 + :text "" + :reasoning ""}] + (.set store runtime-session-id state) + state)))) + +(defn- remember-acp-runtime-session-id! + [^js self runtime-session-id] + (when (string? runtime-session-id) + (set! (.-lastAcpRuntimeSessionId self) runtime-session-id))) + +(defn- last-acp-runtime-session-id + [^js self] + (some-> (.-lastAcpRuntimeSessionId self) str string/trim not-empty)) + +(defn- clear-acp-turn-state! + [^js self runtime-session-id] + (when-let [store (.-acpTurnState self)] + (.delete store runtime-session-id)) + (when (= runtime-session-id (last-acp-runtime-session-id self)) + (set! (.-lastAcpRuntimeSessionId self) nil))) + +(defn- append-acp-text! + [^js self runtime-session-id field text] + (when (and (string? runtime-session-id) + (string? text) + (contains? #{"text" "reasoning"} field)) + (let [state (ensure-acp-turn-state! self runtime-session-id) + current (or (aget state field) "")] + (aset state field (str current text))))) + +(defn- acp-completed-message-event + [^js self session-id runtime-session-id ts] + (when (string? runtime-session-id) + (let [state (ensure-acp-turn-state! self runtime-session-id) + turn (or (aget state "turn") 1) + text (some-> (aget state "text") str string/trim not-empty) + reasoning (some-> (aget state "reasoning") str string/trim not-empty) + parts (cond-> [] + (string? reasoning) (conj {:type "reasoning" :text reasoning}) + (string? text) (conj {:type "text" :text text}))] + (when (seq parts) + (aset state "turn" (inc turn)) + (aset state "text" "") + (aset state "reasoning" "") + (make-event session-id + "item.completed" + {:item_id (str runtime-session-id "-turn-" turn) + :item {:item_id (str runtime-session-id "-turn-" turn) + :kind "message" + :role "assistant" + :content parts}} + ts))))) + +(defn- current-acp-live-message + [^js self session-id runtime-session-id ts] + (when (string? runtime-session-id) + (let [state (ensure-acp-turn-state! self runtime-session-id)] + (acp-live-message-event session-id + runtime-session-id + (or (aget state "turn") 1) + (aget state "text") + (aget state "reasoning") + ts)))) + (defn- event payload) {:type (or (:type payload) "agent.runtime") :data payload}) - event-type type] - (p/let [_ ( data :session-id str string/trim not-empty) + (some-> payload :params :sessionId str string/trim not-empty) + (last-acp-runtime-session-id self)) + update-kind (some-> data :update :sessionUpdate str string/trim not-empty) + stop-reason (some-> payload :result :stopReason str string/trim not-empty)] + (cond + (= "agent_message_chunk" update-kind) + (do + (remember-acp-runtime-session-id! self runtime-session-id) + (append-acp-text! self runtime-session-id "text" (some-> data :update :content :text)) + (broadcast-event! self live-event) + (p/let [latest-session ( data :update :content :text)) + (broadcast-event! self live-event) + (p/let [latest-session (> (session/filter-events combined {:since-ts since-ts :limit limit}) + (map sanitize-event-for-public) + vec)] (http/json-response :sessions/events {:events filtered})))))) (defn- handle-branches [^js self request] diff --git a/deps/workers/src/logseq/agents/runtime_provider.cljs b/deps/workers/src/logseq/agents/runtime_provider.cljs index 1600ec85b5..40600111a1 100644 --- a/deps/workers/src/logseq/agents/runtime_provider.cljs +++ b/deps/workers/src/logseq/agents/runtime_provider.cljs @@ -182,7 +182,6 @@ (when-let [dir (get-repo-dir session-id task provider)] (str "cd '" (escape-shell-single dir) "'")))) -;; FIXME: sandbox-agent 2.x.x changes session routes to opencode/session (defn- sandbox-agent-version [^js env] (or (env-str env "SANDBOX_AGENT_VERSION") diff --git a/package.json b/package.json index 2d46949173..8adfa295d3 100644 --- a/package.json +++ b/package.json @@ -97,9 +97,9 @@ "cljs:release-electron": "clojure -M:cljs release app db-worker inference-worker electron --debug && clojure -M:cljs release publishing", "cljs:release-app": "clojure -M:cljs release app db-worker inference-worker", "cljs:release-publishing": "clojure -M:cljs release app publishing", - "cljs:test": "clojure -M:test compile test", + "cljs:test": "node scripts/clean-shadow-test-builds.js && clojure -M:test compile test", "cljs:run-test": "node scripts/run-tests-with-dom-shim.js -r '^(?!logseq.sync.).*' -r '^(?!logseq.agents.).*' -e fix-me", - "cljs:test-no-worker": "clojure -M:test compile test-no-worker", + "cljs:test-no-worker": "node scripts/clean-shadow-test-builds.js && clojure -M:test compile test-no-worker", "cljs:run-test-no-worker": "node scripts/run-tests-no-worker-with-dom-shim.js", "cljs:dev-release-app": "clojure -M:cljs release app db-worker inference-worker --config-merge \"{:closure-defines {frontend.config/DEV-RELEASE true}}\"", "cljs:dev-release-electron": "clojure -M:cljs release app db-worker inference-worker electron --debug --config-merge \"{:closure-defines {frontend.config/DEV-RELEASE true}}\" && clojure -M:cljs release publishing", diff --git a/scripts/clean-shadow-test-builds.js b/scripts/clean-shadow-test-builds.js new file mode 100644 index 0000000000..abc51bbd1e --- /dev/null +++ b/scripts/clean-shadow-test-builds.js @@ -0,0 +1,15 @@ +const fs = require("fs"); +const path = require("path"); + +const repoRoot = path.resolve(__dirname, ".."); + +const targets = [ + path.join(repoRoot, ".shadow-cljs", "builds", "test"), + path.join(repoRoot, ".shadow-cljs", "builds", "test-no-worker"), + path.join(repoRoot, "static", "tests.js"), + path.join(repoRoot, "static", "tests-no-worker.js"), +]; + +for (const target of targets) { + fs.rmSync(target, { recursive: true, force: true }); +} diff --git a/shadow-cljs.edn b/shadow-cljs.edn index 2dbd54ff48..4d9f23dd02 100644 --- a/shadow-cljs.edn +++ b/shadow-cljs.edn @@ -178,6 +178,7 @@ :closure-defines {frontend.util/NODETEST true logseq.shui.util/NODETEST true} :devtools {:enabled false} + :build-options {:ns-exclude-regexp "^logseq\\.agents(\\.|$)"} ;; disable :static-fns to allow for with-redefs and repl development :compiler-options {:static-fns false} :main frontend.test.frontend-node-test-runner/main} @@ -187,7 +188,7 @@ :closure-defines {frontend.util/NODETEST true logseq.shui.util/NODETEST true} :devtools {:enabled false} - :build-options {:ns-exclude-regexp "^(frontend\\.worker|logseq\\.sync\\.worker)"} + :build-options {:ns-exclude-regexp "^(frontend\\.worker|logseq\\.sync\\.worker|logseq\\.agents(\\.|$))"} ;; disable :static-fns to allow for with-redefs and repl development :compiler-options {:static-fns false} :main frontend.test.frontend-node-test-runner/main} diff --git a/src/main/frontend/components/agent_chat.cljs b/src/main/frontend/components/agent_chat.cljs index 5d11b7ebc3..2509de9eca 100644 --- a/src/main/frontend/components/agent_chat.cljs +++ b/src/main/frontend/components/agent_chat.cljs @@ -38,28 +38,102 @@ (apply str) normalized-text))) +(defn- artifact-protocol-message? + [text] + (and (string? text) + (string/includes? text "You are executing a coding task in a single session.") + (string/includes? text "") + (string/includes? text ""))) + +(defn- leading-artifact-json-split + [text] + (let [text (or text "") + leading-space (count (or (re-find #"^\s*" text) "")) + len (count text)] + (when (and (< leading-space len) + (= \{ (nth text leading-space))) + (loop [idx leading-space + depth 0 + in-string? false + escaped? false] + (when (< idx len) + (let [ch (nth text idx)] + (cond + escaped? + (recur (inc idx) depth in-string? false) + + (= ch \\) + (recur (inc idx) depth in-string? true) + + (= ch \") + (recur (inc idx) depth (not in-string?) false) + + in-string? + (recur (inc idx) depth true false) + + (= ch \{) + (recur (inc idx) (inc depth) false false) + + (= ch \}) + (let [next-depth (dec depth)] + (if (zero? next-depth) + [(subs text leading-space (inc idx)) + (subs text (inc idx))] + (recur (inc idx) next-depth false false))) + + :else + (recur (inc idx) depth false false)))))))) + +(defn- artifact-json? + [text] + (let [parsed (chat-event/parse-json-safe text)] + (and (map? parsed) + (or (contains? parsed :planMarkdown) + (contains? parsed :reviewMarkdown) + (contains? parsed :subtasks))))) + +(defn- strip-leading-artifact-json + [text] + (if-let [[json-part remainder] (leading-artifact-json-split text)] + (if (artifact-json? json-part) + remainder + text) + text)) + +(defn- visible-chat-text + [text] + (when-let [text (normalized-text text)] + (when-not (artifact-protocol-message? text) + (-> text + (string/replace #"(?s)\s*.*?\s*" "") + (string/replace #"(?s)\s*.*?\s*" "") + strip-leading-artifact-json + normalized-text)))) + (defn- normalize-message-part [part] (when (map? part) (if (= "text" (:type part)) - (when-let [text (normalized-text (or (:text part) (:content part)))] + (when-let [text (visible-chat-text (or (:text part) (:content part)))] {:type "text" :text text}) part))) (defn- ui-message-text [message] - (or (text-from-content-parts (:parts message)) + (or (some-> (text-from-content-parts (:parts message)) + visible-chat-text) (let [content (:content message)] (cond (string? content) - (normalized-text content) + (visible-chat-text content) (seq content) - (text-from-content-parts content) + (some-> (text-from-content-parts content) + visible-chat-text) :else nil)) - (normalized-text (:text message)))) + (visible-chat-text (:text message)))) (defn- normalize-role [role] (cond diff --git a/src/main/frontend/handler/agent.cljs b/src/main/frontend/handler/agent.cljs index c9911bff36..a76bd86bb9 100644 --- a/src/main/frontend/handler/agent.cljs +++ b/src/main/frontend/handler/agent.cljs @@ -10,6 +10,7 @@ [frontend.handler.property :as property-handler] [frontend.handler.property.util :as pu] [frontend.handler.user :as user-handler] + [frontend.modules.agent-chat.event :as chat-event] [frontend.state :as state] [frontend.util :as util] [lambdaisland.glogi :as log] @@ -41,9 +42,12 @@ (declare (:provider agent) blank->nil string/lower-case))) +(defn- artifact-protocol-content + [content] + (str + "You are executing a coding task in a single session.\n" + "Before making code changes, output exactly one planning artifact using this XML tag:\n" + "{\"planMarkdown\":\"## Plan\\n- concise implementation plan\",\"subtasks\":[{\"title\":\"\",\"status\":\"todo\"},{\"title\":\"\",\"status\":\"doing\"}]}\n" + "If the task should be split, list the proposed smaller tasks in `subtasks` and summarize them in `planMarkdown`. Use an empty `subtasks` array when no split is needed.\n" + "Do not use placeholder titles like `subtask 1`, `subtask 2`, or similar template text.\n" + "After emitting , continue implementation in the same session without waiting for confirmation.\n" + "When the task is finished, output exactly one final post-review artifact:\n" + "{\"reviewMarkdown\":\"## Post-review\\n- what changed\\n- tests run\\n- risks\"}\n" + "Outside these XML tags, continue normal coding communication.\n\n" + "Task source:\n\n" + (or content ""))) + (defn- project-config ([project-page] (project-config project-page nil)) @@ -316,7 +335,8 @@ (build-session-body block nil)) ([block opts] (let [{:keys [block-uuid node-id node-title content attachments sandbox-checkpoint project agent]} (task-context block opts) - session-id (some-> block-uuid str)] + session-id (some-> block-uuid str) + content (artifact-protocol-content content)] (when (and session-id node-id (string? node-title) (string? content) (map? project) (map? agent)) (cond-> {:session-id session-id :node-id node-id @@ -472,6 +492,175 @@ task-sandbox-checkpoint-property (checkpoint->property-value checkpoint)))))))) +(defn- event-text + [event] + (let [payload (chat-event/unwrap-event-payload + {:data (if (map? (:data event)) (:data event) {})}) + item (:item payload)] + (or (chat-event/payload-text item) + (chat-event/payload-text payload) + (blank->nil (get-in event [:data :last-agent-message]))))) + +(defn- extract-tagged-json + [text tag] + (let [text (or text "") + pattern (js/RegExp. (str "<" tag ">\\s*([\\s\\S]*?)\\s*"))] + (when-let [match (.exec pattern text)] + (let [raw-json (aget match 1) + parsed (chat-event/parse-json-safe raw-json)] + (when (map? parsed) + parsed))))) + +(defn- normalize-plan-markdown + [payload] + (blank->nil (:planMarkdown payload))) + +(defn- normalize-post-review-markdown + [payload] + (blank->nil (:reviewMarkdown payload))) + +(defn- subtask-status-ident + [subtask] + (case (some-> (:status subtask) str string/lower-case) + "backlog" :logseq.property/status.backlog + "doing" :logseq.property/status.doing + "in-review" :logseq.property/status.in-review + "done" :logseq.property/status.done + "canceled" :logseq.property/status.canceled + :logseq.property/status.todo)) + +(defn- placeholder-subtask-title? + [title] + (let [title (some-> title blank->nil string/lower-case)] + (boolean + (and title + (or (re-matches #"subtask\s*\d+" title) + (re-matches #"task\s*\d+" title) + (re-matches #"step\s*\d+" title) + (string/includes? title "") + (string/includes? title "") + (string/includes? title "")))))) + +(defn- normalize-subtasks + [payload] + (->> (:subtasks payload) + (keep (fn [subtask] + (cond + (string? subtask) + (when-let [title (blank->nil subtask)] + (when-not (placeholder-subtask-title? title) + {:title title + :status :logseq.property/status.todo})) + + (map? subtask) + (when-let [title (or (blank->nil (:title subtask)) + (blank->nil (:name subtask)))] + (when-not (placeholder-subtask-title? title) + {:title title + :status (subtask-status-ident subtask)})) + + :else nil))) + (reduce (fn [acc {:keys [title] :as subtask}] + (assoc acc title subtask)) + {}) + vals + vec)) + +(defn- append-artifact-buffer! + [block-uuid text] + (let [buffer-key (str block-uuid)] + (get (swap! *artifact-buffers update buffer-key (fnil str "") (or text "")) + buffer-key))) + +(defn- artifact-text + [block event] + (let [block-uuid (:block/uuid block) + chunk-kind (chat-event/acp-runtime-update-kind event) + chunk-text (when (= "agent_message_chunk" chunk-kind) + (chat-event/acp-runtime-update-text event)) + stop-reason (chat-event/acp-runtime-stop-reason event) + message-complete? (or (contains? #{"item.completed" "response.completed" "session.completed"} (:type event)) + (string? stop-reason)) + event-text' (event-text event)] + (cond + (string? chunk-text) + (do + (append-artifact-buffer! block-uuid chunk-text) + nil) + + (and message-complete? + (string? event-text')) + (str (get @*artifact-buffers (str block-uuid) "") + event-text') + + message-complete? + (get @*artifact-buffers (str block-uuid) "") + + :else + nil))) + +(defn- child-block-with-title + [block title] + (some (fn [child] + (when (= title (:block/title child)) + child)) + (db/sort-by-order (:block/_parent (db/entity [:block/uuid (:block/uuid block)]))))) + +(defn- maybe-set-block-property! + [block-uuid property-id value] + (when-let [block (db/entity [:block/uuid block-uuid])] + (let [current (pu/get-block-property-value block property-id)] + (when (not= current value) + (property-handler/set-block-property! block-uuid property-id value))))) + +(defn- copy-parent-task-properties! + [parent-block child-uuid status-ident] + (when-let [project (:logseq.property/project parent-block)] + (maybe-set-block-property! child-uuid :logseq.property/project (:db/id project))) + (when-let [agent (:logseq.property/agent parent-block)] + (maybe-set-block-property! child-uuid :logseq.property/agent (:db/id agent))) + (maybe-set-block-property! child-uuid :block/tags :logseq.class/Task) + (maybe-set-block-property! child-uuid :logseq.property/status (or status-ident :logseq.property/status.todo))) + +(defn- <ensure-split-subtasks! + [block subtasks] + (reduce (fn [promise {:keys [title status]}] + (p/let [_ promise] + (if-let [existing (child-block-with-title block title)] + (do + (copy-parent-task-properties! block (:block/uuid existing) status) + nil) + (p/let [created (editor-handler/api-insert-new-block! title + {:block-uuid (:block/uuid block) + :edit-block? false})] + (copy-parent-task-properties! block (:block/uuid created) status) + nil)))) + (p/resolved nil) + subtasks)) + +(defn <sync-task-artifacts-from-event! + [block event] + (let [text (artifact-text block event) + plan-payload (extract-tagged-json text "logseq-plan") + review-payload (extract-tagged-json text "logseq-post-review") + block-uuid (:block/uuid block) + plan-md (normalize-plan-markdown plan-payload) + subtasks (when (map? plan-payload) + (normalize-subtasks plan-payload)) + post-review-md (normalize-post-review-markdown review-payload) + stop-reason (chat-event/acp-runtime-stop-reason event) + message-complete? (or (contains? #{"item.completed" "response.completed" "session.completed"} (:type event)) + (string? stop-reason))] + (p/let [_ (when (and block-uuid (string? plan-md)) + (maybe-set-block-property! block-uuid task-agent-plan-property plan-md)) + _ (when (and block-uuid (seq subtasks)) + (<ensure-split-subtasks! block subtasks)) + _ (when (and block-uuid (string? post-review-md)) + (maybe-set-block-property! block-uuid task-post-review-property post-review-md))] + (when message-complete? + (swap! *artifact-buffers dissoc (str block-uuid))) + nil))) + (defn- update-session! [block-uuid f] (state/update-state! :agent/sessions @@ -575,6 +764,11 @@ (p/then (fn [resp] (when (seq (:events resp)) (append-events! block-uuid (:events resp)) + (reduce (fn [promise event] + (p/let [_ promise] + (<sync-task-artifacts-from-event! block event))) + (p/resolved nil) + (:events resp)) (let [provider (some->> (:events resp) reverse (keep event-runtime-provider) @@ -635,6 +829,9 @@ [block-uuid event] (when-not (seen-event? block-uuid event) (append-events! block-uuid [event]) + (when-let [block (db/entity [:block/uuid block-uuid])] + (-> (<sync-task-artifacts-from-event! block event) + (.catch (fn [_] nil)))) (when-let [provider (event-runtime-provider event)] (update-session-state! block-uuid {:runtime-provider provider :terminal-enabled (runtime-provider-terminal-enabled? provider)})) diff --git a/src/main/frontend/worker/db/migrate.cljs b/src/main/frontend/worker/db/migrate.cljs index b0eded8f6b..ad98675e15 100644 --- a/src/main/frontend/worker/db/migrate.cljs +++ b/src/main/frontend/worker/db/migrate.cljs @@ -85,7 +85,9 @@ ["65.25" {:properties [:logseq.property/pr]}] ["65.27" {:properties [:logseq.property/agent-session-id]}] ["65.28" {:properties [:logseq.property/sandbox-checkpoint]}] - ["65.29" {:properties [:logseq.property/project-sandbox-docker-file]}]]) + ["65.29" {:properties [:logseq.property/project-sandbox-docker-file]}] + ["65.30" {:properties [:logseq.property/agent-plan + :logseq.property/post-review]}]]) (let [[major minor] (last (sort (map (comp (juxt :major :minor) db-schema/parse-schema-version first) schema-version->updates)))] diff --git a/src/test/frontend/components/agent_chat_test.cljs b/src/test/frontend/components/agent_chat_test.cljs index dea69cc1db..255d8e02dd 100644 --- a/src/test/frontend/components/agent_chat_test.cljs +++ b/src/test/frontend/components/agent_chat_test.cljs @@ -49,6 +49,37 @@ result (#'agent-chat/message->chat-message message)] (is (nil? result))))) +(deftest message->chat-message-hides-artifact-protocol-prompt-test + (testing "drops the hidden protocol prompt from chat UI" + (let [message {:id "m-protocol" + :role "user" + :parts [{:type "text" + :text (str + "You are executing a coding task in a single session.\n" + "<logseq-plan>{\"planMarkdown\":\"## Plan\"}</logseq-plan>\n" + "<logseq-post-review>{\"reviewMarkdown\":\"## Review\"}</logseq-post-review>")}]} + result (#'agent-chat/message->chat-message message)] + (is (nil? result))))) + +(deftest session->messages-strips-leading-artifact-json-from-assistant-text-test + (testing "keeps only the visible assistant narration after a hidden plan artifact" + (let [session {:events [{:type "item.completed" + :ts 1100 + :data {:item_id "itm-artifact" + :item {:item_id "itm-artifact" + :kind "message" + :role "assistant" + :content [{:type "text" + :text (str + "{\"planMarkdown\":\"## Plan\\n- Real plan\",\"subtasks\":[{\"title\":\"Build board\",\"status\":\"todo\"}]}" + "The workspace is minimal and I’m reading the app structure now.")}]}}}]} + messages (#'agent-chat/session->messages session {:block/uuid "b-artifact"})] + (is (= [{:id "itm-artifact" + :role "assistant" + :parts [{:type "text" + :text "The workspace is minimal and I’m reading the app structure now."}]}] + messages))))) + (deftest session->messages-keeps-reasoning-and-tool-parts-from-item-content-test (testing "maps item.content parts to AI element friendly message parts" (let [session {:events [{:type "item.completed" diff --git a/src/test/frontend/handler/agent_test.cljs b/src/test/frontend/handler/agent_test.cljs new file mode 100644 index 0000000000..09476f893f --- /dev/null +++ b/src/test/frontend/handler/agent_test.cljs @@ -0,0 +1,171 @@ +(ns frontend.handler.agent-test + (:require [cljs.test :refer [async deftest is testing use-fixtures]] + [frontend.db :as db] + [frontend.handler.agent :as agent-handler] + [frontend.handler.property.util :as pu] + [frontend.test.helper :as test-helper :include-macros true :refer [deftest-async]] + [promesa.core :as p])) + +(use-fixtures :each + {:before test-helper/start-test-db! + :after test-helper/destroy-test-db!}) + +(defn- setup-source-task! + [] + (test-helper/load-test-files + [{:page {:block/title "Planning"} + :blocks [{:block/title "Parent planning task" + :build/properties {:block/tags #{:logseq.class/Task}}}]}]) + {:source (test-helper/find-block-by-content "Parent planning task")}) + +(deftest build-session-body-includes-planning-workflow-test + (testing "execution prompts require plan and post-review artifacts in the same session" + (let [{:keys [source]} (setup-source-task!) + block-uuid (:block/uuid source) + body (with-redefs [agent-handler/task-context + (fn [_block _opts] + {:block-uuid block-uuid + :node-id (str block-uuid) + :node-title "Parent planning task" + :content "- Parent planning task" + :attachments [] + :sandbox-checkpoint nil + :project {:id "project-1" + :title "Demo Project" + :repo-url "https://github.com/logseq/logseq"} + :agent {:provider "codex"}})] + (agent-handler/build-session-body source))] + (is (string? (:content body))) + (is (re-find #"<logseq-plan>" (:content body))) + (is (re-find #"<logseq-post-review>" (:content body))) + (is (re-find #"continue implementation in the same session" (:content body))) + (is (re-find #"reviewMarkdown" (:content body))) + (is (not (re-find #"shouldSplit" (:content body))))))) + +(deftest-async sync-task-artifacts-from-event-test + (let [{:keys [source]} (setup-source-task!) + plan-event {:type "item.completed" + :data {:item {:kind "message" + :content [{:type "text" + :text (str + "Planning phase done.\n" + "<logseq-plan>{\"planMarkdown\":\"## Plan\\n- Inspect code\\n- Implement change\",\"subtasks\":[{\"title\":\"Extract artifact parser\",\"status\":\"todo\"},{\"title\":\"Persist post-review\",\"status\":\"doing\"}]}</logseq-plan>")}]}}} + review-event {:type "session.completed" + :data {:last-agent-message + (str + "Work complete.\n" + "<logseq-post-review>{\"reviewMarkdown\":\"## Post-review\\n- Tests passed\\n- Risk: parser relies on tagged artifacts\"}</logseq-post-review>")}}] + (p/let [_ (agent-handler/<sync-task-artifacts-from-event! source plan-event) + updated (db/entity [:block/uuid (:block/uuid source)]) + _ (is (= "## Plan\n- Inspect code\n- Implement change" + (pu/get-block-property-value updated :logseq.property/agent-plan))) + child-statuses (->> (:block/_parent updated) + (map (fn [child] + [(:block/title child) + (some-> (:logseq.property/status child) :db/ident)])) + (into {})) + _ (is (= :logseq.property/status.todo (get child-statuses "Extract artifact parser"))) + _ (is (= :logseq.property/status.doing (get child-statuses "Persist post-review"))) + _ (agent-handler/<sync-task-artifacts-from-event! source review-event) + updated (db/entity [:block/uuid (:block/uuid source)])] + (is (= "## Plan\n- Inspect code\n- Implement change" + (pu/get-block-property-value updated :logseq.property/agent-plan))) + (is (= "## Post-review\n- Tests passed\n- Risk: parser relies on tagged artifacts" + (pu/get-block-property-value updated :logseq.property/post-review)))))) + +(deftest chunked-plan-artifact-creates-subtasks-test + (async done + (let [{:keys [source]} (setup-source-task!) + block-uuid (:block/uuid source) + chunk-events [{:type "agent.runtime" + :data {:method "session/update" + :session-id "runtime-1" + :update {:sessionUpdate "agent_message_chunk" + :content {:type "text" + :text "<logseq-plan>{\"planMarkdown\":\"## Plan\\n- Split work\",\"subtasks\":[{\"title\":\"Build board\",\"status\":\"todo\"},{\"title\":\"Handle rotation\",\"status\":\"doing\"}]}"}}}} + {:type "agent.runtime" + :data {:method "session/update" + :session-id "runtime-1" + :update {:sessionUpdate "agent_message_chunk" + :content {:type "text" + :text "</logseq-plan>"}}}} + {:type "agent.runtime" + :data {:jsonrpc "2.0" + :id 4 + :result {:stopReason "end_turn"}}}]] + (-> (reduce (fn [promise event] + (p/let [_ promise] + (agent-handler/<sync-task-artifacts-from-event! source event))) + (p/resolved nil) + chunk-events) + (p/then (fn [_] + (let [updated (db/entity [:block/uuid block-uuid]) + child-statuses (->> (:block/_parent updated) + (map (fn [child] + [(:block/title child) + (some-> (:logseq.property/status child) :db/ident)])) + (into {}))] + (is (= "## Plan\n- Split work" + (pu/get-block-property-value updated :logseq.property/agent-plan))) + (is (= :logseq.property/status.todo (get child-statuses "Build board"))) + (is (= :logseq.property/status.doing (get child-statuses "Handle rotation")))))) + (p/catch (fn [error] + (is false (str error)))) + (p/finally (fn [] + (done))))))) + +(deftest chunked-plan-artifact-does-not-write-before-message-completes-test + (async done + (let [{:keys [source]} (setup-source-task!) + block-uuid (:block/uuid source) + partial-events [{:type "agent.runtime" + :data {:method "session/update" + :session-id "runtime-1" + :update {:sessionUpdate "agent_message_chunk" + :content {:type "text" + :text "<logseq-plan>{\"planMarkdown\":\"## Plan\\n- Split work\",\"subtasks\":[{\"title\":\"Build board\",\"status\":\"todo\"}]}"}}}} + {:type "agent.runtime" + :data {:method "session/update" + :session-id "runtime-1" + :update {:sessionUpdate "agent_message_chunk" + :content {:type "text" + :text "</logseq-plan>Trailing narrative that should not trigger writes yet."}}}}]] + (-> (reduce (fn [promise event] + (p/let [_ promise] + (agent-handler/<sync-task-artifacts-from-event! source event))) + (p/resolved nil) + partial-events) + (p/then (fn [_] + (let [updated (db/entity [:block/uuid block-uuid]) + child-titles (->> (:block/_parent updated) + (map :block/title) + set)] + (is (nil? (pu/get-block-property-value updated :logseq.property/agent-plan))) + (is (not (contains? child-titles "Build board")))))) + (p/catch (fn [error] + (is false (str error)))) + (p/finally (fn [] + (done))))))) + +(deftest placeholder-subtasks-are-ignored-test + (async done + (let [{:keys [source]} (setup-source-task!) + block-uuid (:block/uuid source) + event {:type "item.completed" + :data {:item {:kind "message" + :content [{:type "text" + :text "<logseq-plan>{\"planMarkdown\":\"## Plan\\n- Real task\",\"subtasks\":[{\"title\":\"subtask 1\",\"status\":\"todo\"},{\"title\":\"subtask 2\",\"status\":\"doing\"}]}</logseq-plan>"}]}}}] + (-> (agent-handler/<sync-task-artifacts-from-event! source event) + (p/then (fn [_] + (let [updated (db/entity [:block/uuid block-uuid]) + child-titles (->> (:block/_parent updated) + (map :block/title) + set)] + (is (= "## Plan\n- Real task" + (pu/get-block-property-value updated :logseq.property/agent-plan))) + (is (not (contains? child-titles "subtask 1"))) + (is (not (contains? child-titles "subtask 2")))))) + (p/catch (fn [error] + (is false (str error)))) + (p/finally (fn [] + (done))))))) diff --git a/src/test/frontend/worker/migrate_test.cljs b/src/test/frontend/worker/migrate_test.cljs index b3dfacc118..bb79180f2b 100644 --- a/src/test/frontend/worker/migrate_test.cljs +++ b/src/test/frontend/worker/migrate_test.cljs @@ -95,3 +95,23 @@ (:kv/value (d/entity @conn :logseq.kv/schema-version)))) (is (some? property)) (is (= :default (:logseq.property/type property))))) + +(deftest migrate-adds-planner-properties-builtin + (let [conn (db-test/create-conn) + property-idents [:logseq.property/agent-plan + :logseq.property/post-review] + _ (d/transact! conn [{:db/ident :logseq.kv/schema-version + :kv/value {:major 65 :minor 29}}]) + _ (doseq [property-ident property-idents + :let [existing-eid (d/entid @conn property-ident)] + :when existing-eid] + (d/transact! conn [[:db/retractEntity existing-eid]])) + _ (db-migrate/migrate conn :target-version "65.30") + agent-plan (d/entity @conn :logseq.property/agent-plan) + post-review (d/entity @conn :logseq.property/post-review)] + (is (= {:major 65 :minor 30} + (:kv/value (d/entity @conn :logseq.kv/schema-version)))) + (is (some? agent-plan)) + (is (some? post-review)) + (is (= :default (:logseq.property/type agent-plan))) + (is (= :default (:logseq.property/type post-review)))))