diff --git a/apps/presentation/dashboard/smoke/team-adoption-http-fixture.py b/apps/presentation/dashboard/smoke/team-adoption-http-fixture.py
new file mode 100644
index 0000000000..9f33bff49b
--- /dev/null
+++ b/apps/presentation/dashboard/smoke/team-adoption-http-fixture.py
@@ -0,0 +1,101 @@
+"""Disposable real delegation/HTTP/SQLite owner; scripted host, no paid model."""
+from __future__ import annotations
+
+import json
+from pathlib import Path
+import sys
+import tempfile
+import threading
+from types import SimpleNamespace
+
+import pytest
+
+REPO = Path(__file__).resolve().parents[4]
+sys.path.insert(0, str(REPO))
+sys.path.insert(0, str(REPO / "tests"))
+
+def main() -> None:
+ from loopx.chat_runtime import ChatRuntimeController
+ from loopx.chat_server import ChatHTTPServer, ChatRequestHandler
+ from loopx.chat_store import ChatSessionStore
+ from test_local_delegation import brief, service, wait
+
+ with tempfile.TemporaryDirectory(prefix="loopx-team-adoption-") as directory:
+ patches = pytest.MonkeyPatch()
+ finalizers = []
+ server = None
+ try:
+ root, runner = service.__wrapped__(Path(directory), SimpleNamespace(
+ param="sqlite", addfinalizer=finalizers.append), patches)
+ config = json.loads(runner.config.read_text())
+ config["bindings"].append({**config["bindings"][0], "id": "synthesis", "agent_id": "reviewer",
+ "todo_id": "todo_reviewer-corrected", "workspace": str(root / "reviewer/corrected"),
+ "output_refs": ["output.json", "report.md"]})
+ runner.config.write_text(json.dumps(config))
+ runner.start("analysis", "analysis-1", brief())
+ source = wait(runner)
+ artifact = source["artifacts"][0]
+ input_file = root / "reviewer/corrected/accepted-input.json"
+ input_file.write_text(artifact["text"])
+ # Authored synthetic companion text; the real acceptance rule still
+ # checks JSON semantics, while artifact readback checks both versions.
+ (root / "reviewer/corrected/report.md").write_text(
+ "# Corrected evidence\n\nThe current-period normalized value is 25. "
+ "Different periods remain incomparable; the repost adds no independent source.\n")
+ runner.start("synthesis", "synthesis-1", {**brief(), "inputs": [{
+ "ref": "accepted-input.json", "description": "Accepted input", "sha256": artifact["sha256"],
+ "delegation": {"operation_id": "analysis-1", "ref": artifact["ref"], "relation": "uses"}}]})
+ assert wait(runner, "synthesis-1")["status"] == "accepted"
+ repo = root / "project"
+ executor = repo / ".loopx/config/delegations.json"
+ executor.parent.mkdir(parents=True)
+ executor.write_bytes(runner.config.read_bytes())
+ registry = json.loads(runner.registry.read_text())
+ registry["goals"][0]["spawn_policy"] = {"mode": "multi_subagent", "allowed": True,
+ "max_children": 2, "execution_config": ".loopx/config/delegations.json"}
+ runner.registry.write_text(json.dumps(registry))
+ store = ChatSessionStore(runner.root)
+ runtime = ChatRuntimeController(store=store, codex_bin="unused", registry_path=runner.registry)
+ store.create_session(goal_id=runner.goal_id, agent_id="codex", channel_id="goal." + runner.goal_id,
+ session_id="adoption", upstream_thread_id="fixture", upstream_mode="chat", adapter_kind="codex_app_server")
+ runtime.loopx_mode.apply("adoption", {"operation": "configure", "settings": {
+ "agent_id": "lead", "token_budget": 1000}}, work_dir=repo, objective="Read accepted work")
+ mode = store.load_session("adoption")["loopx_mode"]
+ store.update_session("adoption", loopx_mode={**mode, "enabled": True, "paused": True})
+ initial_session = store.load_session("adoption")
+ server = ChatHTTPServer(("127.0.0.1", 0), ChatRequestHandler)
+ server.verbose = False
+ server.registry_path, server.chat_store, server.runtime_controller = runner.registry, store, runtime
+ server.runtime_root_override, server.scan_roots, server.limit = str(runner.root), [], 20
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
+ thread.start()
+ print(json.dumps({"origin": f"http://127.0.0.1:{server.server_port}"}), flush=True)
+ for command in sys.stdin:
+ operation = command.strip()
+ if operation == "withdraw":
+ input_file.write_text("{}")
+ print(json.dumps({"withdrawn": True}), flush=True)
+ elif operation == "restore":
+ input_file.write_text(artifact["text"])
+ print(json.dumps({"restored": True}), flush=True)
+ elif operation == "inspect":
+ observed = runner.read("analysis-1")
+ session = store.load_session("adoption")
+ print(json.dumps({"result": observed, "session_unchanged": session == initial_session,
+ "coordinator_paused": session["loopx_mode"]["paused"] is True,
+ "host_invocations": [(root / workspace / "host-invocations").read_text()
+ for workspace in ("analyst/initial", "reviewer/corrected")]}), flush=True)
+ else:
+ raise ValueError("Unknown fixture command")
+ finally:
+ if server:
+ server.shutdown()
+ server.server_close()
+ thread.join(timeout=5)
+ for cleanup in finalizers:
+ cleanup()
+ patches.undo()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/apps/presentation/dashboard/smoke/team-adoption-recovery-smoke.mjs b/apps/presentation/dashboard/smoke/team-adoption-recovery-smoke.mjs
new file mode 100644
index 0000000000..267cfbc097
--- /dev/null
+++ b/apps/presentation/dashboard/smoke/team-adoption-recovery-smoke.mjs
@@ -0,0 +1,113 @@
+// Packaged report interaction + real scoped HTTP/delegation/SQLite adoption.
+// Navigation is synthetic; no acceptance/adoption rule or result is mocked.
+import assert from "node:assert/strict";
+import {spawn} from "node:child_process";
+import {once} from "node:events";
+import {createInterface} from "node:readline";
+import {mkdir} from "node:fs/promises";
+import {resolve} from "node:path";
+import {resolveTestPython} from "../../../../scripts/test-python.mjs";
+import {launchBrowser, loadPlaywright, waitForHttp} from "../../../../examples/dashboard-browser-smoke-support.mjs";
+
+process.env.LOOPX_PERSONAL_WORKSPACE_PACKAGED = "1";
+const {repoRoot, outputDir, port, startServer} = await import("../../../../examples/personal-workspace-browser/fixture.mjs");
+const {openWorkspacePage} = await import("../../../../examples/personal-workspace-browser/scenario-context.mjs");
+const fixture = spawn(resolveTestPython({repoRoot}), ["-u", "apps/presentation/dashboard/smoke/team-adoption-http-fixture.py"],
+ {cwd: repoRoot, stdio: ["pipe", "pipe", "pipe"]});
+const exited = once(fixture, "exit");
+const lines = createInterface({input: fixture.stdout});
+const iterator = lines[Symbol.asyncIterator]();
+let stderr = "";
+fixture.stderr.on("data", chunk => {stderr += String(chunk);});
+async function next() {const line = await iterator.next(); assert.equal(line.done, false, stderr); return JSON.parse(line.value);}
+async function command(value) {fixture.stdin.write(`${value}\n`); return next();}
+let server, browser, workspace;
+try {
+ const {origin} = await next();
+ server = await startServer();
+ const url = `http://127.0.0.1:${port}/chat/?statusUrl=/status.json`;
+ await waitForHttp(url);
+ browser = await launchBrowser(loadPlaywright().chromium);
+ workspace = await openWorkspacePage({newPage: options => browser.newPage({locale: "zh-CN", ...options})}, url);
+ const {page, api} = workspace;
+ await page.locator(".personal-goal-link", {hasText: "Product Release"}).click();
+ await page.getByRole("navigation", {name: "Goal 视图"}).getByRole("button", {name: "对话", exact: true}).click();
+ await page.getByRole("button", {name: "开启 LoopX 模式", exact: true}).click();
+ await page.getByLabel("已注册的协调身份").selectOption("lead");
+ await page.getByLabel("协调员总 token 额度").fill("100000");
+ await page.getByRole("button", {name: "保存设置", exact: true}).click();
+ const configured = api.loopxModeRequests.findLast(row => row.operation === "configure");
+ const mode = page.__loopxRuntime.loopxModes.get(configured.sessionId);
+ const writes = [];
+ let loseAck = true;
+ await page.route("**/api/chat/sessions/*/loopx", async route => {
+ const body = route.request().method() === "POST" ? route.request().postDataJSON() : {};
+ if (!["read", "operations", "adopt"].includes(body.operation)) return route.fallback();
+ const response = await route.fetch({url: `${origin}/api/chat/sessions/adoption/loopx`,
+ headers: {...route.request().headers(), origin}});
+ if (body.operation === "adopt") {
+ writes.push(body);
+ if (loseAck) {
+ assert.equal(response.status(), 200, await response.text());
+ loseAck = false;
+ return route.fulfill({status: 503, json: {error: "Synthetic acknowledgement loss after real commit"}});
+ }
+ }
+ return route.fulfill({response});
+ });
+ Object.assign(mode, {enabled: true, paused: true, active_turn_id: null, native: {status: "paused", tokenBudget: 100000}});
+ const results = page.getByRole("region", {name: "团队成果", exact: true});
+ await results.getByRole("button", {name: "analyst · output.json", exact: true}).click();
+ await results.locator(".goal-team-result-reader > details > summary").click();
+ const adoption = results.locator(".goal-team-adoption");
+ await adoption.locator("summary").click();
+ await adoption.getByRole("button", {name: "查找使用此版本的后续结果", exact: true}).click();
+ await adoption.getByLabel("后续结果", {exact: true}).waitFor();
+ assert.equal(writes.length, 0);
+ assert.equal((await command("inspect")).result.adoptions, undefined);
+ await adoption.getByRole("button", {name: "阅读后续结果", exact: true}).click();
+ await adoption.getByLabel("证据内容: report.md").waitFor();
+ assert.equal(writes.length, 0);
+ const confirm = adoption.getByRole("button", {name: "确认采用于此结果", exact: true});
+ await confirm.focus(); await page.keyboard.press("Enter");
+ await adoption.getByText("采用已记录,指定版本与后续结果当前有效。", {exact: true}).waitFor();
+ assert.equal(writes.length, 1, "Lost acknowledgement reconciles the actual committed receipt without another write");
+ assert.deepEqual(writes[0], {operation: "adopt", operation_id: "analysis-1", consumer_operation_id: "synthesis-1"});
+ const recorded = await command("inspect");
+ assert.equal(recorded.result.adoptions.length, 1);
+ assert.equal(recorded.result.adoptions[0].state, "current");
+ assert.equal(recorded.coordinator_paused, true, "The real session remains paused, as shown in the report");
+ assert.ok(recorded.session_unchanged, "No native lifecycle, mode, wake or extra coordinator Turn changed");
+ assert.deepEqual(recorded.host_invocations, ["1", "1"]);
+ await mkdir(outputDir, {recursive: true});
+ await adoption.getByText("采用已记录,指定版本与后续结果当前有效。", {exact: true}).scrollIntoViewIfNeeded();
+ await page.screenshot({path: resolve(outputDir, "team-adoption-owner-desktop.png"), animations: "disabled"});
+ await page.setViewportSize({width: 390, height: 844}); await page.emulateMedia({reducedMotion: "reduce"});
+ assert.ok(await adoption.evaluate(el => el.scrollWidth <= el.clientWidth));
+ await adoption.getByText("采用已记录,指定版本与后续结果当前有效。", {exact: true}).scrollIntoViewIfNeeded();
+ await page.screenshot({path: resolve(outputDir, "team-adoption-owner-mobile.png"), animations: "disabled"});
+ // Current source still renders, while a real changed receiver input revokes adoption.
+ await command("withdraw");
+ await results.getByRole("button", {name: "刷新成果", exact: true}).click();
+ await results.locator(".goal-team-result-reader > details > summary").click();
+ await results.getByText("采用证据已失效或无法核验", {exact: true}).waitFor();
+ assert.equal(await adoption.getByText("采用已记录,指定版本与后续结果当前有效。", {exact: true}).count(), 0);
+ await adoption.locator("summary").click();
+ await adoption.getByRole("button", {name: "查找使用此版本的后续结果", exact: true}).click();
+ await adoption.getByText("已检查的工作中没有可核验的使用结果。", {exact: true}).waitFor();
+ const refused = await fetch(`${origin}/api/chat/sessions/adoption/loopx`, {method: "POST", headers: {"Content-Type": "application/json", Origin: origin}, body: JSON.stringify(writes[0])});
+ assert.equal(refused.status, 409, await refused.text());
+ await command("restore");
+ await results.getByRole("button", {name: "刷新成果", exact: true}).click();
+ await results.locator(".goal-team-result-reader > details > summary").click();
+ await results.getByText("已记录采用 · 后续结果验收有效", {exact: true}).waitFor();
+ assert.equal(writes.length, 1, "Readback recovery never records a new adoption");
+ assert.equal(api.turnRequests.length, 0);
+ console.log("team-adoption-recovery: passed (packaged report, real HTTP/SQLite/checks/receipts, explicit decision, lost ACK, exact use withdrawal/recovery, no dispatch, mobile/keyboard)");
+} catch (error) {
+ if (workspace) console.error({body: (await workspace.page.locator("body").innerText()).slice(-3000), errors: workspace.errors});
+ throw error;
+} finally {
+ await workspace?.close(); await browser?.close(); server?.kill("SIGTERM");
+ fixture.stdin.end(); if (fixture.exitCode === null) await exited; lines.close();
+}
diff --git a/apps/presentation/dashboard/src/data/chat.ts b/apps/presentation/dashboard/src/data/chat.ts
index d466fb71b8..8f3c5aa4e2 100644
--- a/apps/presentation/dashboard/src/data/chat.ts
+++ b/apps/presentation/dashboard/src/data/chat.ts
@@ -1153,6 +1153,12 @@ export function readLoopXTeamWork(sessionId: string, operationId: string) {
method: "POST", body: JSON.stringify({operation: "read", operation_id: operationId}),
});
}
+export function adoptLoopXTeamWork(sessionId: string, operationId: string, consumerOperationId: string) {
+ return requestJson(`/api/chat/sessions/${sessionId}/loopx`, {
+ method: "POST", body: JSON.stringify({operation: "adopt", operation_id: operationId,
+ consumer_operation_id: consumerOperationId}),
+ });
+}
export type ManagedGoalResultRow = {
todo_id: string; title: string; producer_agent_id: string; sha256: string;
content_type: string; size_bytes: number; completed_at?: string | null;
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-loopx-mode.css b/apps/presentation/dashboard/src/features/personal-workspace/goal-loopx-mode.css
index 6c414cd891..0f1489d022 100644
--- a/apps/presentation/dashboard/src/features/personal-workspace/goal-loopx-mode.css
+++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-loopx-mode.css
@@ -91,6 +91,10 @@ li[data-check="blocked"] .goal-team-check-pill { border-color: transparent; back
.goal-team-lineage ul { list-style: none; padding: 0; margin: 12px 0; }
.goal-team-lineage li { display: grid; gap: 8px; padding: 0 0 16px 16px; border-left: 2px solid var(--pw-line, #ebebeb); overflow-wrap: anywhere; }
.goal-team-lineage button { justify-self: start; max-width: 100%; overflow-wrap: anywhere; text-align: start; }
+.goal-team-adoption { margin-top: 16px; }
+.goal-team-adoption label { display: block; margin-block: 12px; }
+.goal-team-adoption select { display: block; max-width: 100%; margin-top: 6px; }
+.goal-team-adoption > button { margin-block: 6px; }
.goal-team-episode { margin-block: 20px; border: 1px solid var(--pw-line, #ebebeb); border-radius: 10px; padding: 16px; background: var(--pw-bg, #fafafa); }
.goal-team-episode-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
.goal-team-episode > p { color: var(--pw-muted, #666); line-height: 1.5; }
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-team-adoption.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-team-adoption.tsx
new file mode 100644
index 0000000000..d8661a6071
--- /dev/null
+++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-team-adoption.tsx
@@ -0,0 +1,168 @@
+import {useEffect, useRef, useState} from "react";
+import {adoptLoopXTeamWork, fetchLoopXTeamWork, readLoopXTeamWork, type DelegationReadback} from "../../data/chat";
+import {isMarkdownArtifact, TeamArtifactReport} from "./team-artifact-content";
+
+const accepted = (row: DelegationReadback) => row.status === "accepted"
+ && !row.error && !row.recovery_required && Boolean(row.artifacts?.length);
+const sameResult = (expected: DelegationReadback, observed: DelegationReadback) => accepted(observed)
+ && expected.operation_id === observed.operation_id && expected.request_id === observed.request_id
+ && expected.agent_id === observed.agent_id && expected.todo_id === observed.todo_id
+ && expected.artifacts?.length === observed.artifacts?.length
+ && expected.artifacts?.every(row => observed.artifacts?.some(item => item.ref === row.ref && item.sha256 === row.sha256));
+const uses = (source: DelegationReadback, consumer: DelegationReadback) => accepted(consumer)
+ && consumer.operation_id !== source.operation_id && consumer.dependencies?.every(link => link.state === "current")
+ && consumer.dependencies?.some(link =>
+ link.relation === "uses" && link.state === "current" && link.operation_id === source.operation_id
+ && source.artifacts?.some(row => row.ref === link.ref && row.sha256 === link.sha256));
+const hasReceipt = (source: DelegationReadback, consumer: DelegationReadback) => source.adoptions?.some(row =>
+ row.state === "current" && row.consumer_operation_id === consumer.operation_id
+ && row.consumer_request_id === consumer.request_id && row.consumer_agent_id === consumer.agent_id
+ && row.consumer_todo_id === consumer.todo_id && row.source_artifacts.length > 0
+ && row.source_artifacts.every(item => source.artifacts?.some(artifact => item.ref === artifact.ref && item.sha256 === artifact.sha256)
+ && consumer.dependencies?.some(link => link.relation === "uses" && link.state === "current"
+ && link.operation_id === source.operation_id && link.ref === item.ref && link.sha256 === item.sha256))
+ && row.consumer_artifacts.length === consumer.artifacts?.length
+ && consumer.artifacts?.every(item => row.consumer_artifacts.some(artifact => item.ref === artifact.ref && item.sha256 === artifact.sha256)));
+
+/** Explicit owner decision through the existing requester-scoped adoption owner.
+ * Discovery/read/reload never writes; the backend rechecks every confirmation. */
+export function GoalTeamAdoption({sessionId, result, zh, onObservation}: {
+ sessionId: string; result: DelegationReadback; zh: boolean;
+ onObservation: (result: DelegationReadback | null) => void;
+}) {
+ const [candidates, setCandidates] = useState([]);
+ const [selected, setSelected] = useState("");
+ const [consumer, setConsumer] = useState(null);
+ const [artifactRef, setArtifactRef] = useState("");
+ const [cursor, setCursor] = useState(null);
+ const [searched, setSearched] = useState(false);
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState("");
+ const [incomplete, setIncomplete] = useState(false);
+ const [uncertain, setUncertain] = useState(false);
+ const generation = useRef(0);
+ const inspectedPages = useRef(0);
+ const versionKey = JSON.stringify(result.artifacts?.map(({ref, sha256}) => ({ref, sha256})));
+ useEffect(() => {
+ generation.current++; inspectedPages.current = 0;
+ setCandidates([]); setSelected(""); setConsumer(null); setArtifactRef(""); setCursor(null);
+ setSearched(false); setBusy(false); setError(""); setIncomplete(false); setUncertain(false);
+ return () => {generation.current++;};
+ }, [sessionId, result.operation_id, result.request_id, versionKey]);
+ const unavailable = zh ? "指定版本或后续结果无法核验;请重新读取证据。" : "The referenced version or downstream result cannot be verified; recheck the evidence.";
+ async function discover(next = false) {
+ const current = ++generation.current;
+ const previous = next ? candidates : [];
+ if (!next) inspectedPages.current = 0;
+ setBusy(true); setError(""); setConsumer(null); setUncertain(false);
+ if (!next) {setCandidates([]); setSelected(""); setSearched(false); setIncomplete(false);}
+ try {
+ const page = await fetchLoopXTeamWork(sessionId, next && cursor ? cursor : undefined);
+ if (current !== generation.current) return;
+ if (page.has_more && (!page.next_cursor || (next && cursor && page.next_cursor <= cursor))) {
+ throw new Error(zh ? "执行分页无法继续核验。" : "Execution paging cannot be verified.");
+ }
+ const matches: DelegationReadback[] = [];
+ let missing = !page.page_readback_complete;
+ // One bounded page at a time. A newer scope cancels queued reads.
+ for (const row of page.items) {
+ if (current !== generation.current) return;
+ if (!row.operation_id || row.operation_id === result.operation_id || row.status !== "accepted" || row.recovery_required) continue;
+ try {
+ const value = await readLoopXTeamWork(sessionId, row.operation_id);
+ if (value.operation_id === row.operation_id && uses(result, value)) matches.push(value);
+ } catch {missing = true;}
+ }
+ if (current !== generation.current) return;
+ const values = [...new Map([...previous, ...matches].map(row => [row.operation_id, row])).values()];
+ inspectedPages.current++;
+ setCandidates(values); setSelected(values[0]?.operation_id ?? ""); setSearched(true);
+ setCursor(page.has_more ? page.next_cursor : null); setIncomplete(old => old || missing);
+ } catch (failure) {
+ if (current === generation.current) {
+ setCandidates([]); setSelected(""); setCursor(null); setSearched(false);
+ setError(failure instanceof Error ? failure.message : String(failure));
+ }
+ } finally {if (current === generation.current) setBusy(false);}
+ }
+ async function read() {
+ const current = ++generation.current;
+ const expected = candidates.find(row => row.operation_id === selected);
+ setBusy(true); setError(""); setConsumer(null); setUncertain(false);
+ try {
+ const source = await readLoopXTeamWork(sessionId, result.operation_id).catch(() => null);
+ if (current !== generation.current) return;
+ if (!source || !sameResult(result, source)) {onObservation(null); return;}
+ onObservation(source);
+ const downstream = await readLoopXTeamWork(sessionId, selected);
+ if (current !== generation.current) return;
+ if (!expected || !sameResult(expected, downstream) || !uses(source, downstream)) throw new Error(unavailable);
+ setConsumer(downstream);
+ } catch (failure) {
+ if (current === generation.current) setError(failure instanceof Error ? failure.message : String(failure));
+ } finally {if (current === generation.current) setBusy(false);}
+ }
+ async function confirm() {
+ if (!consumer) return;
+ const current = ++generation.current;
+ setBusy(true); setError("");
+ try {
+ let source: DelegationReadback;
+ try {source = await adoptLoopXTeamWork(sessionId, result.operation_id, consumer.operation_id);}
+ catch (failure) {
+ // An acknowledgement may be lost after commit. Reconcile once without
+ // another write; a later retry retains this same source/consumer pair.
+ source = await readLoopXTeamWork(sessionId, result.operation_id);
+ if (!hasReceipt(source, consumer)) throw failure;
+ }
+ if (current !== generation.current) return;
+ if (!sameResult(result, source)) {onObservation(null); return;}
+ onObservation(source);
+ if (!hasReceipt(source, consumer)) throw new Error(unavailable);
+ setUncertain(false);
+ } catch (failure) {
+ if (current === generation.current) {
+ setUncertain(true);
+ setError((zh ? "采用结果尚未核实。重试将核验并记录同一版本与后续结果,不会重新派工。"
+ : "Adoption remains unconfirmed. Retry checks and records the same version and downstream result without dispatching work.")
+ + " " + (failure instanceof Error ? failure.message : String(failure)));
+ }
+ } finally {if (current === generation.current) setBusy(false);}
+ }
+ if (!accepted(result)) return null;
+ const recorded = consumer && hasReceipt(result, consumer) && !uncertain;
+ const artifact = consumer?.artifacts?.find(row => row.ref === artifactRef)
+ ?? consumer?.artifacts?.find(row => isMarkdownArtifact(row.ref)) ?? consumer?.artifacts?.[0];
+ return
+ {zh ? "确认采用于后续结果" : "Confirm adoption into a downstream result"}
+ {zh ? "先阅读使用此版本的已验收后续结果,再由此对话的协调身份记录采用。不会启动任务或补足独立验收证据。"
+ : "Read an accepted downstream result using this version, then record adoption as this conversation's coordinator. This starts no work and supplies no independent-verifier evidence."}
+ void discover()}>{zh ? "查找使用此版本的后续结果" : "Find downstream results using this version"}
+ {busy ? {zh ? "正在核验指定版本与后续结果…" : "Checking exact versions and downstream results…"}
: null}
+ {incomplete ? {zh ? "部分执行无法核验;列表不代表全部后续结果。" : "Some executions cannot be verified; this list does not represent all downstream results."}
: null}
+ {searched && !candidates.length ? {zh ? "已检查的工作中没有可核验的使用结果。" : "No verifiable uses result was found in the inspected work."}
: null}
+ {cursor && inspectedPages.current < 10 ? void discover(true)}>{zh ? "继续查找下一页" : "Search the next page"} : null}
+ {cursor && inspectedPages.current >= 10 ? {zh ? "本次已检查十页;仍有未检查工作,请核实原请求。" : "Ten pages inspected; more work remains uninspected. Reconcile the original request."}
: null}
+ {candidates.length ? <>
+ {zh ? "后续结果" : "Downstream result"}
+ {setSelected(event.target.value); setConsumer(null); setError("");}}>
+ {candidates.map(row => {row.agent_id} · {row.operation_id} )}
+
+
+ void read()}>{zh ? "阅读后续结果" : "Read downstream result"}
+ > : null}
+ {consumer ? <>
+ {artifact ? : null}
+ {consumer.artifacts && consumer.artifacts.length > 1 ? {zh ? "后续结果的其他产物" : "Other downstream artifacts"}
+ setArtifactRef(event.target.value)}>
+ {consumer.artifacts.map(row => {row.ref} )}
+
+ : null}
+ {recorded ? {zh ? "采用已记录,指定版本与后续结果当前有效。" : "Adoption recorded; the referenced version and downstream result are current."}
+ : void confirm()}>{uncertain
+ ? (zh ? "重试同一采用决定" : "Retry this adoption decision") : (zh ? "确认采用于此结果" : "Confirm adoption into this result")} }
+ > : null}
+ {error ? {error}
: null}
+ ;
+}
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-team-evidence.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-team-evidence.tsx
index 1f1b928182..b0b7ba6a13 100644
--- a/apps/presentation/dashboard/src/features/personal-workspace/goal-team-evidence.tsx
+++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-team-evidence.tsx
@@ -105,6 +105,10 @@ export function GoalTeamEvidence({sessionId, operationId, zh, canMessage, ingres
&& checkedOutputs?.length && checkedOutputs.length === result?.artifacts?.length
&& new Set(checkedOutputs.map(row => row.ref)).size === checkedOutputs.length
&& checkedOutputs.every(row => result?.artifacts?.some(artifact => artifact.ref === row.ref && artifact.sha256 === row.sha256)));
+ function observe(value: DelegationReadback | null) {
+ setResult(value); setObservedAt(value ? new Date().toLocaleTimeString() : "");
+ setError(value ? "" : (zh ? "关联执行或版本已变化;请重新读取证据。" : "A linked execution or version changed; recheck the evidence."));
+ }
return
{zh ? "执行证据" : "Execution evidence"}
void read()}>{zh ? "重新读取证据" : "Recheck evidence"}
@@ -113,14 +117,11 @@ export function GoalTeamEvidence({sessionId, operationId, zh, canMessage, ingres
{result ? <>
{result.agent_id} · {delegationStateLabel(result, zh)} {" · "}{observedAt}
{zh ? "按需读取的当前观察,不是持续在线状态;验收不代表协调员已采用。" : "An on-demand observation, not continuous liveness; acceptance does not establish coordinator adoption."}
- {
- setResult(value); setObservedAt(value ? new Date().toLocaleTimeString() : "");
- setError(value ? "" : (zh ? "关联执行或版本已变化;请重新读取证据。" : "A linked execution or version changed; recheck the evidence."));
- }}/>
+
{hasCorrectionPath ? {zh ? "版本与采用关系详情" : "Version and adoption details"}
-
- : }
+
+ : }
{result.error ? {result.error}
: null}
{result.status === "accepted" && !result.error && !result.recovery_required ?
{zh ? "本次验收依据" : "Current validation basis"}
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-team-lineage.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-team-lineage.tsx
index 8cf9ecdc56..42e4029a35 100644
--- a/apps/presentation/dashboard/src/features/personal-workspace/goal-team-lineage.tsx
+++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-team-lineage.tsx
@@ -1,8 +1,10 @@
import type {DelegationReadback} from "../../data/chat";
+import {GoalTeamAdoption} from "./goal-team-adoption";
/** Request intent, accepted output and requester decision retain distinct strength. */
-export function GoalTeamLineage({result, zh, onInspect}: {
+export function GoalTeamLineage({result, zh, onInspect, sessionId, onObservation}: {
result: DelegationReadback; zh: boolean; onInspect: (operationId: string) => void;
+ sessionId: string; onObservation: (result: DelegationReadback | null) => void;
}) {
const relations = zh ? {responds_to: "回应此版本", revises: "修订此版本", uses: "使用此版本"}
: {responds_to: "Respond to this version", revises: "Revise this version", uses: "Use this version"};
@@ -31,5 +33,6 @@ export function GoalTeamLineage({result, zh, onInspect}: {
{row.consumer_artifacts.map(artifact => {zh ? "结果" : "Result"}: {artifact.ref} · sha256:{artifact.sha256})}
)} : {zh ? "尚无请求方采用记录。" : "No requester adoption is recorded."}
}
+
;
}
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-team-results.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-team-results.tsx
index dc2ab35fa1..337619b0fd 100644
--- a/apps/presentation/dashboard/src/features/personal-workspace/goal-team-results.tsx
+++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-team-results.tsx
@@ -10,7 +10,7 @@ const readableRows = (page: DelegationInventory) => page.items.filter(row =>
const AUTO_DISCOVERY_PAGES = 3;
const MAX_INSPECTED_PAGES = 10;
-/** Read-only entry in the original conversation, using the same scoped delegation API. */
+/** Original-conversation result reader and explicit requester adoption. */
export function GoalTeamResults({sessionId, zh, refreshKey}: {sessionId: string; zh: boolean; refreshKey: string}) {
const [pages, setPages] = useState([]);
const [selection, setSelection] = useState<{result: DelegationReadback; artifact: TeamArtifact} | null>(null);
@@ -121,7 +121,12 @@ export function GoalTeamResults({sessionId, zh, refreshKey}: {sessionId: string;
: (zh ? `已检查 ${pages.length} 页;还有未检查的工作。` : `${pages.length} pages inspected; more work remains unseen.`)} : null}
: null}
{selection ?
- {adoptionSummary} void read(operationId)}/>
+ {adoptionSummary} void read(operationId)}
+ sessionId={sessionId} onObservation={value => {
+ const artifact = value?.artifacts?.find(row => row.ref === selection.artifact.ref && row.sha256 === selection.artifact.sha256);
+ if (value && artifact) setSelection({result: value, artifact});
+ else {setSelection(null); setError(changed);}
+ }}/>
{selection.result.artifacts && selection.result.artifacts.length > 1 ? {zh ? "其他产物" : "Other artifacts"} {const artifact = selection.result.artifacts!.find(row => row.ref === event.target.value); if (artifact) {
diff --git a/docs/architecture/rfcs/live-team-workspace-v0.md b/docs/architecture/rfcs/live-team-workspace-v0.md
index 5ec4731a70..1646a2015a 100644
--- a/docs/architecture/rfcs/live-team-workspace-v0.md
+++ b/docs/architecture/rfcs/live-team-workspace-v0.md
@@ -316,6 +316,14 @@ records native Codex MCP execution and a real-model correction/acceptance/adopti
qualification. This retires the earlier host-approval blocker; it does not prove
the released first-use journey, two continuing cycles or observer comprehension.
Reuse the existing version-bound readback, feedback and scoped pause owners.
+The report details now expose the existing requester adoption operation: inspect
+an accepted downstream result using the exact displayed version, then explicitly
+record the decision through the configured coordinator. Discovery and reading
+remain read-only; lost acknowledgement is reconciled against the durable receipt,
+and changed receiver inputs withdraw current adoption. This completes an App
+operation over already accepted work, not the live L1 qualification or an
+independent-verifier receipt. The real correction, verifier and requester-use
+acceptance above remains required.
The presentation slice in #4828 returns readable reports to the original Goal
conversation and compares an explicitly selected dependency with the accepted
output, preferring the same artifact name/type. The referenced artifact and hash
diff --git a/docs/assets/personal-workspace/team-adoption-confirmed-desktop.png b/docs/assets/personal-workspace/team-adoption-confirmed-desktop.png
new file mode 100644
index 0000000000..b53473b4a1
Binary files /dev/null and b/docs/assets/personal-workspace/team-adoption-confirmed-desktop.png differ
diff --git a/docs/assets/personal-workspace/team-adoption-confirmed-mobile.png b/docs/assets/personal-workspace/team-adoption-confirmed-mobile.png
new file mode 100644
index 0000000000..90dfdd7086
Binary files /dev/null and b/docs/assets/personal-workspace/team-adoption-confirmed-mobile.png differ
diff --git a/docs/reference/local-delegation.md b/docs/reference/local-delegation.md
index 0a8b746426..ec42822553 100644
--- a/docs/reference/local-delegation.md
+++ b/docs/reference/local-delegation.md
@@ -953,6 +953,32 @@ Validation stability is scoped to this task, its current claim/lifecycle, select
A revision can have current task acceptance and valid requester adoption while independent-verifier evidence is missing. These are distinct facts. The correction path explicitly says **Independent verification · evidence not provided**; neither `responds_to`, a reviewer's name nor a successful validator is an exact-version independent-verifier receipt.
+In the original Goal conversation or Team execution report, expand **Confirm
+adoption into a downstream result**. Find an accepted result using the displayed
+version, read it, then explicitly confirm adoption. Discovery and reading never
+record the decision. The operation reuses `delegation adopt` with the configured
+coordinator's existing grants; it rechecks current task acceptance, receiver
+inputs and output versions. It starts no task, resumes no paused coordinator,
+and supplies no independent-verifier evidence. A lost acknowledgement triggers
+one readback; an unresolved response keeps the same source/consumer pair for an
+explicit retry. Reload reads the durable receipt. Changed input or revoked
+bindings withdraw current adoption or reject confirmation.
+
+中文:在原 Goal 对话或团队执行的报告详情展开「确认采用于后续结果」,查找使用
+当前版本的已验收结果,阅读后显式确认。查找、阅读、刷新均不自动采用;复用既有
+协调身份、grant 和 `delegation adopt`,重新核验输入与产物版本,不启动任务或恢复
+暂停的协调员。丢失回执先读回;仍未确认时仅重试同一版本与结果。输入变化或绑定
+撤销会使采用失效或拒绝确认,采用不补足独立验收。
+
+These packaged desktop and 390px views use synthetic workspace navigation with
+the real isolated SQLite/HTTP adoption owner. The same journey verifies lost
+acknowledgement recovery and withdrawal after a receiver input changes. It
+does not qualify live-model correction or the installed native App.
+
+
+
+
+
The following packaged transport-fixture views show current check records and their withdrawal. A separate isolated production SQLite/HTTP/CLI journey exercises the same reader with actual host checks, missing output and explicit restoration. All data is synthetic; these checks do not qualify a live-model correction or the installed native App.

diff --git a/examples/personal-workspace-browser/fixture.mjs b/examples/personal-workspace-browser/fixture.mjs
index 55ff73ac3d..49edce4a48 100644
--- a/examples/personal-workspace-browser/fixture.mjs
+++ b/examples/personal-workspace-browser/fixture.mjs
@@ -1644,7 +1644,13 @@ export async function installApi(page, { goalSubagentConfigurationEnabled = true
await route.fulfill({json: paused});
return;
}
- if (body.operation === "read") {
+ if (body.operation === "adopt") {
+ if (body.operation_id !== "accepted-analysis" || body.consumer_operation_id !== "accepted-synthesis") {
+ return route.fulfill({status: 409, json: {error: "Fixture adoption target unavailable"}});
+ }
+ current.fixtureAdoptionState = "current";
+ }
+ if (body.operation === "read" || body.operation === "adopt") {
if (current.fixtureTeamReadDelayMs) await new Promise(resolveWait => setTimeout(resolveWait, current.fixtureTeamReadDelayMs));
if (current.fixtureCorrectionEpisode && body.operation_id === "original-analysis") {
await route.fulfill({json: {ok: true, operation_id: body.operation_id, request_id: "request-original",
@@ -1679,7 +1685,7 @@ export async function installApi(page, { goalSubagentConfigurationEnabled = true
consumer_request_id: "request-synthesis", consumer_agent_id: "synthesizer", consumer_todo_id: "todo_synthesis",
source_artifacts: [{ref: "report.json", sha256: "d".repeat(64)}],
consumer_artifacts: [{ref: "synthesis.json", sha256: "e".repeat(64)},
- ...(current.fixturePlanTodoId ? [{ref: "report.md", sha256: "8".repeat(64)}] : [])], state: current.fixtureAdoptionState}]} : {}),
+ {ref: "report.md", sha256: "8".repeat(64)}], state: current.fixtureAdoptionState}]} : {}),
artifacts: [{ref: "report.json", sha256: "d".repeat(64),
text: '{"cash_flow":75,"note":""}'},
{ref: "report.md", sha256: "9".repeat(64), text: "# Cash allocation\n\n| Measure | Value |\n|---|---:|\n| Free cash | 75 |\n\n[Source](https://example.org/report)\n"}]}});
diff --git a/examples/personal-workspace-browser/team-evidence.mjs b/examples/personal-workspace-browser/team-evidence.mjs
index 2efa27dab2..509f83d925 100644
--- a/examples/personal-workspace-browser/team-evidence.mjs
+++ b/examples/personal-workspace-browser/team-evidence.mjs
@@ -269,6 +269,82 @@ export const teamEvidenceScenario = {
await content.waitFor();
assert.equal(api.turnRequests.length, 0, "Evidence reading must not start a model");
await page.screenshot({path: resolve(outputDir, "team-evidence-desktop.png"), animations: "disabled"});
+ // Reading a consumer is not adoption. Only the explicit owner decision
+ // writes; a lost post-commit acknowledgement reconciles without rewriting.
+ delete mode.fixtureAdoptionState;
+ await evidence.getByRole("button", {name: "重新读取证据", exact: true}).click();
+ await content.waitFor();
+ await evidence.locator(".goal-team-lineage-detail > summary").click();
+ const adoption = evidence.locator(".goal-team-adoption");
+ await adoption.locator("summary").click();
+ const consumerInventory = async route => {
+ const body = route.request().method() === "POST" ? route.request().postDataJSON() : {};
+ if (body.operation !== "operations") return route.fallback();
+ return route.fulfill({json: {items: [{record_id: "b".repeat(64), operation_id: "accepted-synthesis",
+ agent_id: "synthesizer", status: "accepted", recovery_required: false}],
+ has_more: false, next_cursor: null, page_readback_complete: true}});
+ };
+ await page.route("**/api/chat/sessions/*/loopx", consumerInventory);
+ await adoption.getByRole("button", {name: "查找使用此版本的后续结果", exact: true}).click();
+ await adoption.getByLabel("后续结果", {exact: true}).waitFor();
+ assert.equal(api.loopxModeRequests.filter(row => row.operation === "adopt").length, 0);
+ await adoption.getByRole("button", {name: "阅读后续结果", exact: true}).click();
+ await adoption.getByLabel("证据内容: report.md", {exact: true}).waitFor();
+ assert.equal(api.loopxModeRequests.filter(row => row.operation === "adopt").length, 0);
+ const lostAdoptionAck = async route => {
+ const body = route.request().method() === "POST" ? route.request().postDataJSON() : {};
+ if (body.operation !== "adopt") return route.fallback();
+ api.loopxModeRequests.push({sessionId: configured.sessionId, ...body});
+ mode.fixtureAdoptionState = "current";
+ return route.fulfill({status: 503, json: {error: "Fixture acknowledgement lost after commit"}});
+ };
+ await page.route("**/api/chat/sessions/*/loopx", lostAdoptionAck);
+ const confirmAdoption = adoption.getByRole("button", {name: "确认采用于此结果", exact: true});
+ await confirmAdoption.focus(); await page.keyboard.press("Enter");
+ await adoption.getByText("采用已记录,指定版本与后续结果当前有效。", {exact: true}).waitFor();
+ const decisions = api.loopxModeRequests.filter(row => row.operation === "adopt");
+ assert.equal(decisions.length, 1, "Post-commit reconciliation sends no duplicate decision");
+ assert.deepEqual({operation_id: decisions[0].operation_id, consumer_operation_id: decisions[0].consumer_operation_id},
+ {operation_id: "accepted-analysis", consumer_operation_id: "accepted-synthesis"});
+ await adoption.scrollIntoViewIfNeeded();
+ await page.screenshot({path: resolve(outputDir, "team-adoption-desktop.png"), animations: "disabled"});
+ await page.setViewportSize({width: 390, height: 844});
+ await page.emulateMedia({reducedMotion: "reduce"});
+ assert(await adoption.evaluate(el => el.scrollWidth <= el.clientWidth), "Adoption stays readable at 390px");
+ await page.screenshot({path: resolve(outputDir, "team-adoption-mobile.png"), animations: "disabled"});
+ await page.setViewportSize({width: 1512, height: 982});
+ await page.unroute("**/api/chat/sessions/*/loopx", lostAdoptionAck);
+ // An unavailable reconciliation keeps the reviewed pair and requires an
+ // explicit retry; it cannot silently select or dispatch different work.
+ delete mode.fixtureAdoptionState;
+ await adoption.getByRole("button", {name: "查找使用此版本的后续结果", exact: true}).click();
+ await adoption.getByRole("button", {name: "阅读后续结果", exact: true}).click();
+ await adoption.getByLabel("证据内容: report.md", {exact: true}).waitFor();
+ const unavailableReconciliation = async route => {
+ const body = route.request().method() === "POST" ? route.request().postDataJSON() : {};
+ if (body.operation !== "read" || body.operation_id !== "accepted-analysis") return route.fallback();
+ return route.fulfill({status: 503, json: {error: "Fixture readback unavailable"}});
+ };
+ await page.route("**/api/chat/sessions/*/loopx", lostAdoptionAck);
+ await page.route("**/api/chat/sessions/*/loopx", unavailableReconciliation);
+ await adoption.getByRole("button", {name: "确认采用于此结果", exact: true}).click();
+ await adoption.getByRole("button", {name: "重试同一采用决定", exact: true}).waitFor();
+ assert.equal(await adoption.getByLabel("后续结果", {exact: true}).isDisabled(), true);
+ assert.equal(await adoption.getByRole("button", {name: "查找使用此版本的后续结果", exact: true}).isDisabled(), true);
+ assert.equal(api.loopxModeRequests.filter(row => row.operation === "adopt").length, 2);
+ await page.unroute("**/api/chat/sessions/*/loopx", unavailableReconciliation);
+ await page.unroute("**/api/chat/sessions/*/loopx", lostAdoptionAck);
+ await adoption.getByRole("button", {name: "重试同一采用决定", exact: true}).click();
+ await adoption.getByText("采用已记录,指定版本与后续结果当前有效。", {exact: true}).waitFor();
+ const retriedDecisions = api.loopxModeRequests.filter(row => row.operation === "adopt");
+ assert.equal(retriedDecisions.length, 3);
+ assert.deepEqual(retriedDecisions[2], retriedDecisions[1], "Retry retains the exact reviewed adoption pair");
+ mode.fixtureAdoptionState = "unavailable";
+ await adoption.getByRole("button", {name: "查找使用此版本的后续结果", exact: true}).click();
+ await adoption.getByText("已检查的工作中没有可核验的使用结果。", {exact: true}).waitFor();
+ assert.equal(await adoption.getByRole("button", {name: "确认采用于此结果", exact: true}).count(), 0);
+ assert.equal(api.loopxModeRequests.filter(row => row.operation === "adopt").length, 3);
+ await page.unroute("**/api/chat/sessions/*/loopx", consumerInventory);
// Lose the first acknowledgement; retry must preserve identity and content.
let first = true;
await page.route("**/api/chat/sessions/*/loopx", async route => {
diff --git a/loopx/chat_loopx_mode.py b/loopx/chat_loopx_mode.py
index b9d3ef1202..b6e9a717a2 100644
--- a/loopx/chat_loopx_mode.py
+++ b/loopx/chat_loopx_mode.py
@@ -304,6 +304,26 @@ def read_team(self, session_id, body):
def apply(self, session_id, body, *, work_dir, objective):
if body.get("operation") in {"inspect", "operations", "read"}:
return self.read_team(session_id, body)
+ if body.get("operation") == "adopt":
+ if set(body) != {"operation", "operation_id", "consumer_operation_id"}:
+ raise ValueError("adopt requires source and consumer operation ids only")
+ from .control_plane.collaboration.peers import require_operation_id
+
+ operation_id = require_operation_id(body["operation_id"])
+ consumer_id = require_operation_id(body["consumer_operation_id"])
+ with self._lock(session_id):
+ session = self._session(session_id)
+ settings = (session.get("loopx_mode") or {}).get("settings") or {}
+ service, _, _, _ = self._execution(session, settings)
+ goal = self._goal(session)
+ effect_runtime_result("collaboration.chat_mode", {
+ "session": session, "origin": "web", "operation": "adopt",
+ "settings": settings,
+ "registered_agents": registered_agent_ids_for_goal(goal),
+ "goal_active": goal_accepts_collaboration(goal),
+ "execution_binding_valid": True,
+ })
+ return {"ok": True, **service.adopt_result(operation_id, consumer_id)}
if set(body) - {
"operation",
"settings",
diff --git a/loopx/control_plane/collaboration/chat_mode.ts b/loopx/control_plane/collaboration/chat_mode.ts
index b29be6a356..25c4d69550 100644
--- a/loopx/control_plane/collaboration/chat_mode.ts
+++ b/loopx/control_plane/collaboration/chat_mode.ts
@@ -14,7 +14,7 @@ function requireThat(ok: unknown, message: string): asserts ok {
export function planChatMode(input: JsonObject): JsonObject {
const session = requireJsonObject(input.session, "conversation session");
const operation = input.operation;
- requireThat(["configure", "start", "resume", "pause", "exit", "message", "wake"].includes(String(operation)), "unsupported conversation operation");
+ requireThat(["configure", "start", "resume", "pause", "exit", "message", "wake", "adopt"].includes(String(operation)), "unsupported conversation operation");
const localOwner = resolveConversationScope(session).kind === "owner_goal"
&& session.session_mode !== "attached_host" && session.agent_id === "codex";
// External inbox returns have their own exact audience owner. A recorded
@@ -43,6 +43,15 @@ export function planChatMode(input: JsonObject): JsonObject {
return {operation, enabled: operation !== "exit"};
}
requireThat(input.goal_active === true, "Goal is stopped or unavailable");
+ if (operation === "adopt") {
+ requireThat(typeof settings.agent_id === "string" && Array.isArray(input.registered_agents)
+ && input.registered_agents.includes(settings.agent_id), "select a registered coordinator identity");
+ requireThat(input.execution_binding_valid === true, "configure the coordinator's authorized execution bindings first");
+ // An owner decision over completed work does not resume execution, consume
+ // a model allowance or observe a wake. The delegation owner rechecks both
+ // results and their exact uses input before recording the decision.
+ return {operation};
+ }
requireThat(!session.active_turn_id, "wait for the current conversation turn before changing execution");
requireThat(Number.isSafeInteger(settings.token_budget) && Number(settings.token_budget) > 0
&& Number(settings.token_budget) <= 2147483647, "set a positive coordinator token allowance");
diff --git a/tests/control_plane_ts/chat_mode.test.ts b/tests/control_plane_ts/chat_mode.test.ts
index 0b6d46a1df..639543b499 100644
--- a/tests/control_plane_ts/chat_mode.test.ts
+++ b/tests/control_plane_ts/chat_mode.test.ts
@@ -22,6 +22,21 @@ test("execution is explicit and preserves native lifecycle/allowance", () => {
assert.throws(() => planChatMode({...input, operation: "resume", native: {status: "active"}}), /paused/);
});
+test("owner adoption records completed work without execution or allowance changes", () => {
+ const adoption = {...input, operation: "adopt", settings: {agent_id: "lead"}};
+ for (const loopx_mode of [{enabled: false}, {enabled: true, paused: true}, {enabled: true}]) {
+ assert.deepEqual(planChatMode({...adoption, session: {...session, loopx_mode, active_turn_id: "running"}}),
+ {operation: "adopt"});
+ }
+ assert.throws(() => planChatMode({...adoption, goal_active: false}), /stopped/);
+ assert.throws(() => planChatMode({...adoption, execution_binding_valid: false}), /bindings/);
+ assert.throws(() => planChatMode({...adoption, registered_agents: []}), /registered/);
+ for (const change of [{channel_id: "manager"}, {session_mode: "attached_host"}, {agent_id: "other"}]) {
+ assert.throws(() => planChatMode({...adoption, session: {...session, ...change}}), /local managed/);
+ }
+ assert.throws(() => planChatMode({...adoption, origin: "host"}), /local managed/);
+});
+
test("neither registration nor a role name grants execution", () => {
for (const changes of [
{origin: "external"}, {goal_active: false}, {execution_binding_valid: false},
diff --git a/tests/test_chat_team_evidence.py b/tests/test_chat_team_evidence.py
index 5c7248f7fb..75afb10ad7 100644
--- a/tests/test_chat_team_evidence.py
+++ b/tests/test_chat_team_evidence.py
@@ -3,6 +3,8 @@
import shutil
import subprocess
import sys
+import http.client
+import threading
import pytest
@@ -64,6 +66,7 @@ def read(body=None):
assert observed["artifacts"][0]["text"] == (root / "analyst/initial/output.json").read_text()
assert store.load_session(sid) == before
assert (root / "analyst/initial/host-invocations").read_text() == "1"
+
for body in [
{"operation": "read"}, {"operation": "read", "operation_id": ["analysis-1"]},
{"operation": "read", "operation_id": "../analysis-1"},
@@ -82,3 +85,103 @@ def read(body=None):
with pytest.raises(ValueError, match="bindings changed"):
read()
assert (root / "analyst/initial/host-invocations").read_text() == "1"
+
+
+def test_owner_http_adopts_exact_result_without_starting_or_resuming_work(service):
+ from loopx.chat_server import ChatHTTPServer, ChatRequestHandler
+
+ root, runner = service
+ config = json.loads(runner.config.read_text())
+ config["bindings"].append({**config["bindings"][0], "id": "synthesis", "agent_id": "reviewer",
+ "todo_id": "todo_reviewer-corrected", "workspace": str(root / "reviewer/corrected")})
+ runner.config.write_text(json.dumps(config))
+ runner.start("analysis", "analysis-1", brief())
+ source = wait(runner)
+ artifact = source["artifacts"][0]
+ input_file = root / "reviewer/corrected/accepted-input.json"
+ input_file.write_text(artifact["text"])
+ runner.start("synthesis", "synthesis-1", {**brief(), "inputs": [{
+ "ref": "accepted-input.json", "description": "Accepted input", "sha256": artifact["sha256"],
+ "delegation": {"operation_id": "analysis-1", "ref": artifact["ref"], "relation": "uses"}}]})
+ consumer = wait(runner, "synthesis-1")
+ assert consumer["status"] == "accepted"
+ repo = root / "project"
+ executor = repo / ".loopx/config/delegations.json"
+ executor.parent.mkdir(parents=True)
+ executor.write_bytes(runner.config.read_bytes())
+ registry = json.loads(runner.registry.read_text())
+ registry["goals"][0]["spawn_policy"] = {"mode": "multi_subagent", "allowed": True,
+ "max_children": 2, "execution_config": ".loopx/config/delegations.json"}
+ runner.registry.write_text(json.dumps(registry))
+ store = ChatSessionStore(runner.root)
+ controller = ChatRuntimeController(store=store, codex_bin="unused", registry_path=runner.registry)
+ sid = store.create_session(goal_id=runner.goal_id, agent_id="codex",
+ channel_id="goal." + runner.goal_id, upstream_thread_id="fixture",
+ upstream_mode="chat", adapter_kind="codex_app_server")["session_id"]
+ controller.loopx_mode.apply(sid, {"operation": "configure", "settings": {
+ "agent_id": "lead", "token_budget": 1000}}, work_dir=repo, objective="Read completed work")
+ # Pausing execution does not prevent this explicit decision on completed work.
+ mode = store.load_session(sid)["loopx_mode"]
+ store.update_session(sid, loopx_mode={**mode, "enabled": True, "paused": True})
+ before = store.load_session(sid)
+ server = ChatHTTPServer(("127.0.0.1", 0), ChatRequestHandler)
+ server.verbose = False
+ server.registry_path, server.chat_store, server.runtime_controller = runner.registry, store, controller
+ server.runtime_root_override, server.scan_roots, server.limit = str(runner.root), [], 20
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
+ thread.start()
+
+ def request(body, *, origin=None):
+ connection = http.client.HTTPConnection(*server.server_address, timeout=60)
+ try:
+ connection.request("POST", f"/api/chat/sessions/{sid}/loopx", json.dumps(body), {
+ "Content-Type": "application/json",
+ "Origin": origin or f"http://127.0.0.1:{server.server_port}"})
+ response = connection.getresponse()
+ return response.status, json.loads(response.read())
+ finally:
+ connection.close()
+
+ adoption = {"operation": "adopt", "operation_id": "analysis-1", "consumer_operation_id": "synthesis-1"}
+ try:
+ assert "adoptions" not in request({"operation": "read", "operation_id": "analysis-1"})[1]
+ for body in [
+ {**adoption, "agent_id": "reviewer"}, {**adoption, "settings": {"agent_id": "reviewer"}},
+ {**adoption, "consumer_operation_id": "analysis-1"},
+ {**adoption, "consumer_operation_id": "../synthesis-1"},
+ {"operation": "adopt", "operation_id": "analysis-1"},
+ ]:
+ assert request(body)[0] == 409
+ assert request(adoption, origin="https://untrusted.example")[0] == 403
+ input_file.write_text("{}")
+ assert request(adoption)[0] == 409
+ assert "adoptions" not in request({"operation": "read", "operation_id": "analysis-1"})[1]
+ input_file.write_text(artifact["text"])
+ registry["goals"][0]["status"] = "stopped"
+ runner.registry.write_text(json.dumps(registry))
+ assert request(adoption)[0] == 409
+ registry["goals"][0].pop("status")
+ runner.registry.write_text(json.dumps(registry))
+ status, recorded = request(adoption)
+ assert status == 200, recorded
+ assert len(recorded["adoptions"]) == 1
+ receipt = recorded["adoptions"][0]
+ assert receipt["state"] == "current" and receipt["requester_agent_id"] == "lead"
+ assert receipt["source_artifacts"] == [{"ref": artifact["ref"], "sha256": artifact["sha256"]}]
+ assert receipt["consumer_artifacts"] == [{"ref": row["ref"], "sha256": row["sha256"]}
+ for row in consumer["artifacts"]]
+ assert request(adoption)[1]["adoptions"] == recorded["adoptions"]
+ cli = subprocess.run([sys.executable, "-m", "loopx.cli", "--format", "json", "--registry", str(runner.registry),
+ "--runtime-root", str(runner.root), "delegation", "read", "--goal-id", runner.goal_id,
+ "--agent-id", "lead", "--execution-config", str(executor), "--operation-id", "analysis-1"],
+ capture_output=True, text=True, check=True)
+ assert json.loads(cli.stdout)["adoptions"] == recorded["adoptions"]
+ assert store.load_session(sid) == before
+ for workspace in ("analyst/initial", "reviewer/corrected"):
+ assert (root / workspace / "host-invocations").read_text() == "1"
+ executor.write_text(json.dumps({"schema_version": "loopx_local_delegation_v0", "bindings": []}))
+ assert request(adoption)[0] == 409
+ finally:
+ server.shutdown()
+ server.server_close()
+ thread.join(timeout=10)