From bfb25af05af9806b0fb6da73dcb13e10ea614a05 Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Sun, 20 Sep 2026 03:34:52 +0800 Subject: [PATCH 1/9] feat(chat): inspect validated team evidence and send scoped feedback Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- apps/presentation/dashboard/src/data/chat.ts | 25 +++++- .../personal-workspace/goal-loopx-mode.css | 10 +++ .../personal-workspace/goal-loopx-mode.tsx | 9 +- .../personal-workspace/goal-team-evidence.tsx | 84 +++++++++++++++++ .../personal-workspace/goal-team-work.tsx | 32 ++++--- .../rfcs/loopx-overall-roadmap-v0.md | 7 ++ .../rfcs/loopx-overall-roadmap-v0.zh-CN.md | 6 +- docs/reference/local-delegation.md | 33 +++++-- examples/personal-workspace-browser-smoke.mjs | 3 +- .../personal-workspace-browser/fixture.mjs | 16 ++++ .../team-evidence.mjs | 89 +++++++++++++++++++ loopx/chat_loopx_mode.py | 20 +++-- loopx/web/chat/assets/index-7uKjmaHx.js | 20 ++++- tests/test_chat_team_evidence.py | 70 +++++++++++++++ 14 files changed, 388 insertions(+), 36 deletions(-) create mode 100644 apps/presentation/dashboard/src/features/personal-workspace/goal-team-evidence.tsx create mode 100644 examples/personal-workspace-browser/team-evidence.mjs create mode 100644 tests/test_chat_team_evidence.py diff --git a/apps/presentation/dashboard/src/data/chat.ts b/apps/presentation/dashboard/src/data/chat.ts index dd1f90d517..f98949008a 100644 --- a/apps/presentation/dashboard/src/data/chat.ts +++ b/apps/presentation/dashboard/src/data/chat.ts @@ -900,6 +900,27 @@ export type DelegationPreflight = { turn_eligible: boolean; acceptance_ready: boolean; turn_route: string; executor: {host: string; available: boolean | null; reason: string | null; profile: string | null}; }; +export type DelegationReadback = { + operation_id: string; request_id: string; agent_id: string; todo_id: string; + status: string; worker_active: boolean; recovery_required: boolean; + artifacts?: Array<{ref: string; sha256: string; text: string}>; error?: string; +}; +export function readLoopXTeamWork(sessionId: string, operationId: string) { + return requestJson(`/api/chat/sessions/${sessionId}/loopx`, { + method: "POST", body: JSON.stringify({operation: "read", operation_id: operationId}), + }); +} +// Keep inventory and selected-operation labels consistent; unknown states stay unknown. +export function delegationStateLabel(row: {status: string; worker_active?: boolean; recovery_required: boolean | null}, zh: boolean) { + if (row.status === "unavailable") return zh ? "无法核验" : "Unavailable"; + if (row.status === "accepted") return zh ? "已通过当前验收" : "Currently accepted"; + if (row.status === "rejected") return zh ? "未通过验收" : "Rejected"; + if (row.recovery_required) return zh ? "需要恢复原执行" : "Original execution needs recovery"; + if (row.status === "running" && row.worker_active) return zh ? "执行中" : "Executing"; + if (row.status === "turn_returned" && row.worker_active) return zh ? "正在验收" : "Validating"; + if (["prepared", "running", "turn_returned"].includes(row.status)) return zh ? "已派发,等待执行回读" : "Dispatched; awaiting execution readback"; + return zh ? "状态未知" : "Unknown state"; +} export function fetchLoopXTeamWork(sessionId: string, cursor?: string) { return requestJson(`/api/chat/sessions/${sessionId}/loopx`, { method: "POST", body: JSON.stringify({operation: "operations", limit: 10, ...(cursor ? {cursor} : {})}), @@ -915,9 +936,9 @@ export function updateLoopXMode(sessionId: string, operation: string, settings?: method: "POST", body: JSON.stringify({operation, operation_id: operationId, ...(settings ? {settings} : {})}), }); } -export function sendLoopXMessage(sessionId: string, message: string, deliveryMode: "queue" | "inbox" | "steer") { +export function sendLoopXMessage(sessionId: string, message: string, deliveryMode: "queue" | "inbox" | "steer", operationId: string = crypto.randomUUID()) { return requestJson<{ok: true; status: string; delivery_mode: string}>(`/api/chat/sessions/${sessionId}/loopx`, { - method: "POST", body: JSON.stringify({operation: "message", operation_id: crypto.randomUUID(), message, delivery_mode: deliveryMode}), + method: "POST", body: JSON.stringify({operation: "message", operation_id: operationId, message, delivery_mode: deliveryMode}), }); } 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 31769aa96d..2266e9e5ea 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 @@ -39,3 +39,13 @@ .goal-loopx-mode-settings { grid-template-columns: 1fr; } .goal-loopx-dialog-content { padding: 16px; } } + +.goal-team-evidence { margin-block: 16px; padding: 16px; border: 1px solid var(--pw-line, #ebebeb); border-radius: 12px; background: var(--pw-surface, #fff); } +.goal-team-evidence h3, .goal-team-evidence h4 { margin: 0; font-size: 13px; } +.goal-team-evidence article { margin-block: 16px; } +.goal-team-evidence pre { max-height: 320px; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; font: 12px/1.6 var(--font-mono, monospace); padding: 12px; border: 1px solid var(--pw-line, #ebebeb); border-radius: 6px; } +.goal-team-evidence label { display: grid; gap: 8px; font-weight: 500; } +.goal-team-evidence textarea { box-sizing: border-box; resize: vertical; min-height: 88px; width: 100%; padding: 8px; border: 1px solid var(--pw-line, #ebebeb); border-radius: 6px; background: var(--pw-bg, #fafafa); color: inherit; font: inherit; } +.goal-team-evidence textarea:focus-visible, .goal-team-evidence pre:focus-visible { outline: 2px solid #0070f3; outline-offset: 2px; } +.goal-team-control { border-top: 1px solid var(--pw-line, #ebebeb); padding-top: 16px; } +.goal-team-evidence [role="alert"] { color: var(--pw-red, #b42318); } diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-loopx-mode.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-loopx-mode.tsx index 088e20212d..d7bbe73ba0 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-loopx-mode.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-loopx-mode.tsx @@ -77,7 +77,7 @@ export function GoalLoopXMode({sessionId, onPrepare, onExecute, onChange}: { if (!sessionId) return; setBusy(true); setError(""); try {const result = await updateLoopXMode(sessionId, operation, operation === "configure" ? settings : undefined); - setSnapshot(result); onChange(result); setEditing(false); + setSnapshot(result); onChange(result); if (operation === "configure" || operation === "exit") setEditing(false); } catch (failure) {setError(failure instanceof Error ? failure.message : String(failure));} finally {setBusy(false);} } @@ -104,7 +104,12 @@ export function GoalLoopXMode({sessionId, onPrepare, onExecute, onChange}: { {panel ?

{panel === "team" ? (zh ? "团队执行情况" : "Team execution") : (zh ? "运行设置" : "Execution settings")}

{error || readError ?

{error || readError}

: null} - {panel === "team" && sessionId ? : null} + {panel === "team" && sessionId && !readError ? : null} + {panel === "team" ?
+ +

{snapshot?.paused ? (active ? (zh ? "已暂停后续调度,等待当前协调轮次停止回读。" : "Further dispatch paused; awaiting coordinator turn stop readback.") : (zh ? "协调员已暂停。" : "Coordinator paused.")) : null} + {zh ? "此操作不会停止已派发成员;成员状态以上次执行回读为准。当前入口不支持停止整个团队。" : "This does not stop dispatched members; their states are last-read observations. Whole-team stop is unavailable here."}

+
: null} {editing ?
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 new file mode 100644 index 0000000000..69bf911833 --- /dev/null +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-team-evidence.tsx @@ -0,0 +1,84 @@ +import {useEffect, useRef, useState} from "react"; +import {delegationStateLabel, readLoopXTeamWork, sendLoopXMessage, type DelegationReadback, type LoopXModeSnapshot} from "../../data/chat"; + +/** Owner-only artifact readback. Text is evidence, never rendered as executable markup. */ +export function GoalTeamEvidence({sessionId, operationId, zh, canMessage, ingress}: { + sessionId: string; operationId: string; zh: boolean; canMessage: boolean; + ingress: LoopXModeSnapshot["ingress"]; +}) { + const [result, setResult] = useState(null); + const [observedAt, setObservedAt] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const [message, setMessage] = useState(""); + const [sending, setSending] = useState(false); + const [sendError, setSendError] = useState(""); + const [receipt, setReceipt] = useState<{id: string; status: string} | null>(null); + const submission = useRef<{id: string; text: string} | null>(null); + const generation = useRef(0); + useEffect(() => { + void read(); + return () => {generation.current++;}; + }, [sessionId, operationId]); + async function read() { + const current = ++generation.current; + setResult(null); setObservedAt(""); setError(""); setBusy(true); + try { + const value = await readLoopXTeamWork(sessionId, operationId); + if (current === generation.current) { + setResult(value); setObservedAt(new Date().toLocaleTimeString()); + } + } catch (failure) { + if (current === generation.current) setError(failure instanceof Error ? failure.message : String(failure)); + } finally {if (current === generation.current) setBusy(false);} + } + async function send() { + if ((!result || !message.trim()) && !submission.current) return; + // A retry after transport failure must carry the original identity and exact evidence. + const attempt = submission.current ?? {id: crypto.randomUUID(), text: [ + `${zh ? "针对团队执行" : "Regarding team execution"}: ${operationId}`, + ...(result?.artifacts ?? []).map(artifact => `${artifact.ref} · sha256:${artifact.sha256}`), + message.trim(), + ].join("\n")}; + submission.current = attempt; + setSending(true); setSendError(""); + try { + const value = await sendLoopXMessage(sessionId, attempt.text, "inbox", attempt.id); + setReceipt({id: attempt.id, status: value.status}); + } catch (failure) {setSendError(failure instanceof Error ? failure.message : String(failure));} + finally {setSending(false);} + } + const status = receipt ? ingress.find(row => row.client_ingress_id === receipt.id)?.status ?? receipt.status : ""; + const receiptLabel = status === "pending" ? (zh ? "已进入协调员收件箱,等待读取" : "In the coordinator inbox; awaiting read") + : status === "delivered" ? (zh ? "已交给协调员;尚无应用回执" : "Delivered to coordinator; application not confirmed") + : (zh ? "投递状态待核实" : "Delivery requires reconciliation"); + return
+

{zh ? "执行证据" : "Execution evidence"}

+
+ {operationId} + {busy ?

{zh ? "正在核验绑定、验收与文件…" : "Checking bindings, acceptance and files…"}

: null} + {error ?

{zh ? "无法核验,已清除上次证据。" : "Cannot verify; previous evidence cleared."} {error}

: null} + {result ? <> +

{result.agent_id} · {delegationStateLabel(result, zh)}{" · "}{observedAt}

+

{zh ? "按需读取的当前观察,不是持续在线状态;验收不代表协调员已采用。" : "An on-demand observation, not continuous liveness; acceptance does not establish coordinator adoption."}

+ {result.error ?

{result.error}

: null} + {result.status === "accepted" && result.artifacts?.length ? result.artifacts.map(artifact =>
+

{artifact.ref}

+
{artifact.text}
+
{zh ? "版本与来源标识" : "Version and source identifiers"} + sha256:{artifact.sha256}{result.request_id}{result.todo_id} +
+
) :

{zh ? "本次读取没有可展示的已验收产物。" : "No accepted artifact is available in this readback."}

} +
{event.preventDefault(); void send();}}> +