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*" tag ">"))]
+ (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- > (: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])]
+ (-> (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"
+ "{\"planMarkdown\":\"## Plan\"}\n"
+ "{\"reviewMarkdown\":\"## 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 #"" (:content body)))
+ (is (re-find #"" (: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"
+ "{\"planMarkdown\":\"## Plan\\n- Inspect code\\n- Implement change\",\"subtasks\":[{\"title\":\"Extract artifact parser\",\"status\":\"todo\"},{\"title\":\"Persist post-review\",\"status\":\"doing\"}]}")}]}}}
+ review-event {:type "session.completed"
+ :data {:last-agent-message
+ (str
+ "Work complete.\n"
+ "{\"reviewMarkdown\":\"## Post-review\\n- Tests passed\\n- Risk: parser relies on tagged artifacts\"}")}}]
+ (p/let [_ (agent-handler/> (: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/{\"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 ""}}}}
+ {:type "agent.runtime"
+ :data {:jsonrpc "2.0"
+ :id 4
+ :result {:stopReason "end_turn"}}}]]
+ (-> (reduce (fn [promise event]
+ (p/let [_ promise]
+ (agent-handler/> (: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 "{\"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 "Trailing narrative that should not trigger writes yet."}}}}]]
+ (-> (reduce (fn [promise event]
+ (p/let [_ promise]
+ (agent-handler/> (: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 "{\"planMarkdown\":\"## Plan\\n- Real task\",\"subtasks\":[{\"title\":\"subtask 1\",\"status\":\"todo\"},{\"title\":\"subtask 2\",\"status\":\"doing\"}]}"}]}}}]
+ (-> (agent-handler/> (: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)))))