sandbox plan

This commit is contained in:
Tienson Qin
2026-03-10 18:01:55 +08:00
parent 450e7e9a6b
commit adbde3cf52
17 changed files with 845 additions and 27 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -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"]}}}

View File

@@ -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`

View File

@@ -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 `<logseq-plan>...</logseq-plan>` artifact before code changes
- continued implementation in the same session after the plan
- a `<logseq-post-review>...</logseq-post-review>` 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 `<logseq-plan>`.
- The completion artifact is emitted inside `<logseq-post-review>`.
- 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.

View File

@@ -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",

View File

@@ -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- <storage-get [storage key]
(p/let [value (.get storage key)]
(when value (js->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- <append-event! [^js self event-opts]
(p/let [session (<get-session self)]
(if (nil? session)
@@ -635,14 +794,66 @@
(let [{:keys [type data]} (or (sandbox/acp-envelope->event payload)
{:type (or (:type payload) "agent.runtime")
:data payload})
event-type type]
(p/let [_ (<append-event! self {:type event-type
:data data
:ts (common/now-ms)})]
(when (= "session.completed" event-type)
(<checkpoint-and-terminate-completed-runtime! self session-id))
(when (= "session.canceled" event-type)
(<terminate-runtime-on-status! self session-id "canceled")))))))
event-type type
ts (common/now-ms)
live-event (make-event session-id event-type data ts)
runtime-session-id (or (some-> 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 (<get-session self)
live-message (current-acp-live-message self session-id runtime-session-id ts)]
(when (and (map? latest-session)
(string? runtime-session-id)
(map? live-message))
(<save-session! self (assoc-live-message latest-session runtime-session-id live-message)))))
(= "agent_thought_chunk" update-kind)
(do
(remember-acp-runtime-session-id! self runtime-session-id)
(append-acp-text! self runtime-session-id "reasoning" (some-> data :update :content :text))
(broadcast-event! self live-event)
(p/let [latest-session (<get-session self)
live-message (current-acp-live-message self session-id runtime-session-id ts)]
(when (and (map? latest-session)
(string? runtime-session-id)
(map? live-message))
(<save-session! self (assoc-live-message latest-session runtime-session-id live-message)))))
(string? stop-reason)
(let [completed-event (acp-completed-message-event self session-id runtime-session-id ts)]
(broadcast-event! self live-event)
(p/let [_ (when (map? completed-event)
(<append-event! self completed-event))
latest-session (<get-session self)]
(when (and (map? latest-session)
(string? runtime-session-id))
(<save-session! self (dissoc-live-message latest-session runtime-session-id)))
nil))
(runtime-delta-event? event-type)
(broadcast-event! self live-event)
(runtime-transient-event? event-type)
(broadcast-event! self live-event)
:else
(p/let [_ (<append-event! self {:type event-type
:data data
:ts ts})]
(when (= "session.completed" event-type)
(clear-acp-turn-state! self runtime-session-id)
(<checkpoint-and-terminate-completed-runtime! self session-id))
(when (= "session.canceled" event-type)
(clear-acp-turn-state! self runtime-session-id)
(<terminate-runtime-on-status! self session-id "canceled"))))))))
(defn- <consume-events-stream! [^js self session-id runtime on-ready]
(let [provider (runtime-provider/resolve-provider (.-env self) runtime)]
@@ -1007,7 +1218,7 @@
(session/append-event session [] {:type "session.created"
:data {:requested-by user-id
:project (:project task)
:agent (:agent task)}
:agent (sanitize-agent-for-public (:agent task))}
:ts now})]
(p/let [_ (<put-session! self session)
_ (<put-events! self events)
@@ -1037,7 +1248,7 @@
:status (:status session)
:runtime-provider (session-runtime-provider session)
:terminal-enabled (session-terminal-enabled? session)
:task (:task session)
:task (sanitize-task-for-public (:task session))
:audit (:audit session)
:created-at (:created-at session)
:updated-at (:updated-at session)}))))
@@ -1589,7 +1800,10 @@
(if (nil? session)
(http/not-found)
(p/let [events (<get-events self)
filtered (session/filter-events events {:since-ts since-ts :limit limit})]
combined (into (vec events) (vals (session-live-messages session)))
filtered (->> (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]

View File

@@ -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")

View File

@@ -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",

View File

@@ -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 });
}

View File

@@ -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}

View File

@@ -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 "<logseq-plan>")
(string/includes? text "<logseq-post-review>")))
(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)<logseq-plan>\s*.*?\s*</logseq-plan>" "")
(string/replace #"(?s)<logseq-post-review>\s*.*?\s*</logseq-post-review>" "")
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

View File

@@ -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 <start-session!)
(def ^:private task-agent-plan-property :logseq.property/agent-plan)
(def ^:private task-sandbox-checkpoint-property :logseq.property/sandbox-checkpoint)
(def ^:private task-session-id-property :logseq.property/agent-session-id)
(def ^:private task-pr-property :logseq.property/pr)
(def ^:private task-post-review-property :logseq.property/post-review)
(defonce ^:private *artifact-buffers (atom {}))
(defn- normalize-sandbox-checkpoint
[checkpoint]
@@ -79,6 +83,21 @@
[agent]
(= "codex" (some-> (: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"
"<logseq-plan>{\"planMarkdown\":\"## Plan\\n- concise implementation plan\",\"subtasks\":[{\"title\":\"<meaningful subtask title>\",\"status\":\"todo\"},{\"title\":\"<another meaningful subtask title>\",\"status\":\"doing\"}]}</logseq-plan>\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 <logseq-plan>, continue implementation in the same session without waiting for confirmation.\n"
"When the task is finished, output exactly one final post-review artifact:\n"
"<logseq-post-review>{\"reviewMarkdown\":\"## Post-review\\n- what changed\\n- tests run\\n- risks\"}</logseq-post-review>\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 "<meaningful subtask title>")
(string/includes? title "<another meaningful subtask title>")
(string/includes? title "<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)}))

View File

@@ -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)))]

View File

@@ -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 Im 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 Im 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"

View File

@@ -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)))))))

View File

@@ -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)))))