From 92a5f2228ed82f618f9b1c4a7cd8079572d20592 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sun, 8 Nov 2026 17:29:26 +0800 Subject: [PATCH] fix(workspace): read complete evidence from original Task sources Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- apps/presentation/dashboard/src/data/chat.ts | 1 + .../personal-workspace/context-drawer.tsx | 23 ++++++-- .../personal-workspace/task-request.tsx | 27 +++++---- .../task-inspector-return.mjs | 59 +++++++++++++++++-- loopx/presentation/goal_source_routing.py | 1 + .../presentation/test_linked_goal_sources.py | 48 ++++++++++++++- 6 files changed, 138 insertions(+), 21 deletions(-) diff --git a/apps/presentation/dashboard/src/data/chat.ts b/apps/presentation/dashboard/src/data/chat.ts index 933f241741..662623992a 100644 --- a/apps/presentation/dashboard/src/data/chat.ts +++ b/apps/presentation/dashboard/src/data/chat.ts @@ -657,6 +657,7 @@ export async function readTodoRequest(goalId: string, todoId: string, signal: Ab const result = z.object({ ok: z.literal(true), goal_id: z.string(), todo_id: z.string(), text: z.string(), status: z.string(), archive_state: z.string(), updated_at: z.string().nullable(), + evidence: z.string().nullable().optional(), note: z.string().nullable().optional(), review_basis: todoUpdateSourceBasisSchema.optional(), source_access: z.object({namespace: z.string()}).optional(), }).parse(await requestJson(url, {signal})); diff --git a/apps/presentation/dashboard/src/features/personal-workspace/context-drawer.tsx b/apps/presentation/dashboard/src/features/personal-workspace/context-drawer.tsx index 06ba3826b1..141179183a 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/context-drawer.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/context-drawer.tsx @@ -45,7 +45,7 @@ import type { LarkGoalConnection } from "../../data/chat"; import { localizedGoalState, localizedSessionStatus, useWorkspaceI18n } from "./i18n"; import { formatCostUsd, formatDurationMs, formatTokenCount, formatUsageValue, hasGoalUsage } from "./personal-workspace-model"; import { TeamPlanResult } from "./team-plan-result"; -import { TaskRequest } from "./task-request"; +import { TaskRequest, taskRequestIdentity, type TaskRequestRead } from "./task-request"; import { parseTodoResumeCondition } from "./todo-resume-condition"; import { MarkdownText } from "./markdown"; import { formatMonitorDate } from "./monitor-readback"; @@ -145,6 +145,7 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention const { locale, t } = useWorkspaceI18n(); const [correction, setCorrection] = useState(""); const [diagnosticsOpen, setDiagnosticsOpen] = useState(false); + const [taskRequestRead, setTaskRequestRead] = useState(null); const [repositoryCopyState, setRepositoryCopyState] = useState<"idle" | "copied" | "error">("idle"); const [runDrawerTab, setRunDrawerTab] = useState<"record" | "details">("record"); const [runActions, setRunActions] = useState>({}); @@ -355,6 +356,15 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention : selection.item.goalId; const taskNamespace = goals.find(goal => goal.goalId === goalId)?.sourceAccess?.namespace; const taskReadable = !readOnly || Boolean(taskNamespace); + const taskDetail = selection.kind === "todo" && taskRequestRead?.phase === "ready" + && taskRequestRead.identity === taskRequestIdentity(selection.item, taskReadable, taskNamespace) + ? taskRequestRead.detail : null; + // Missing fields retain compatibility with older/local request-only readers; + // an explicit null from the current source clears an outdated display hint. + const taskEvidence = taskDetail && "evidence" in taskDetail ? taskDetail.evidence + : selection.kind === "todo" ? selection.item.evidence : undefined; + const taskNote = taskDetail && "note" in taskDetail ? taskDetail.note + : selection.kind === "todo" ? selection.item.note : undefined; const contextLabel = selection.kind === "attention" ? selection.item.goalTitle ?? t("drawer.currentGoal") : selection.kind === "todo" ? selection.item.goalTitle : selection.kind === "run" ? selection.item.goalTitle @@ -684,7 +694,7 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention ) : selection.item.detailMode ? (

{t(selection.item.detailMode === "missing_current" ? "drawer.taskReadbackUnavailable" : "drawer.taskRequestOnly")}

- +
) : ( <> @@ -697,6 +707,7 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention {selection.item.taskClass === "advancement_task" ? t("drawer.taskAdvancement") : selection.item.taskClass ?? t("drawer.taskOrdinary")} {selection.item.status === "blocked" ?

{t("drawer.blockedReason")}

@@ -788,13 +799,13 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention {t("drawer.taskCompletionProofHint")} } : null} - {selection.item.evidence ?
+ {taskEvidence ?

{t("drawer.evidence")}

- +
: null} - {selection.item.note ?
+ {taskNote ?
{t("drawer.notes")} - +
: null} {todoCompleted ?
{t("drawer.taskCompletedTitle")}{t("drawer.taskCompletedNote")}
: null} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/task-request.tsx b/apps/presentation/dashboard/src/features/personal-workspace/task-request.tsx index 6205c7fb0a..a99525808f 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/task-request.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/task-request.tsx @@ -1,4 +1,4 @@ -import {useEffect, useId, useRef, useState} from "react"; +import {useCallback, useEffect, useId, useRef, useState} from "react"; import {compactPythonWhitespace, compactPythonTextLength} from "../../../../../../loopx/control_plane/coordination/python_whitespace.ts"; import {readTodoRequest} from "../../data/chat"; import type {TodoUpdateSourceBasis} from "../../data/action-source-basis"; @@ -7,18 +7,25 @@ import {MarkdownText} from "./markdown"; import type {PersonalWorkspaceCallbacks, WorkspaceActionPreview, WorkspaceTodo} from "./personal-workspace-model"; // Per-open read/draft state, never a second Task or execution owner. -type RequestRead = {identity: string} & ( +export type TaskRequestRead = {identity: string} & ( | {phase: "loading" | "error"; detail?: never} | {phase: "ready"; detail: Awaited>} ); -export function TaskRequest({todo, local, sourceNamespace, onSave}: {todo: WorkspaceTodo; local: boolean; sourceNamespace?: string; +export function taskRequestIdentity(todo: WorkspaceTodo, local: boolean, sourceNamespace?: string) { + return JSON.stringify([local, sourceNamespace, todo.goalId, todo.sourceTodoId, todo.requestText, + todo.status, todo.evidence, todo.note]); +} + +export function TaskRequest({todo, local, sourceNamespace, onSave, onRead}: {todo: WorkspaceTodo; local: boolean; sourceNamespace?: string; + onRead?: (read: TaskRequestRead) => void; onSave?: PersonalWorkspaceCallbacks["onSaveTodoRequirements"]}) { const {locale, t} = useWorkspaceI18n(); const countId = useId(); const sourceId = todo.sourceTodoId; - const identity = JSON.stringify([local, sourceNamespace, todo.goalId, sourceId, todo.requestText, todo.status]); - const [read, setRead] = useState(null); + const identity = taskRequestIdentity(todo, local, sourceNamespace); + const [read, setRead] = useState(null); + const publishRead = useCallback((value: TaskRequestRead) => {setRead(value); onRead?.(value);}, [onRead]); const [retry, setRetry] = useState(0); const [draft, setDraft] = useState<{text: string; basis: TodoUpdateSourceBasis} | null>(null); const [pending, setPending] = useState(null); @@ -32,10 +39,10 @@ export function TaskRequest({todo, local, sourceNamespace, onSave}: {todo: Works useEffect(() => { if (!local || !sourceId) return; const controller = new AbortController(); - setRead({identity, phase: "loading"}); + publishRead({identity, phase: "loading"}); void readTodoRequest(todo.goalId, sourceId, controller.signal, sourceNamespace).then(detail => { if (controller.signal.aborted) return; - setRead({identity, phase: "ready", detail}); + publishRead({identity, phase: "ready", detail}); if (rebaseRef.current && detail.review_basis) { const basis = detail.review_basis; setDraft(previous => previous ? {...previous, basis} : null); @@ -44,10 +51,10 @@ export function TaskRequest({todo, local, sourceNamespace, onSave}: {todo: Works } rebaseRef.current = false; }).catch(() => { - if (!controller.signal.aborted) setRead({identity, phase: "error"}); + if (!controller.signal.aborted) publishRead({identity, phase: "error"}); }); return () => controller.abort(); - }, [identity, local, retry, todo.goalId, sourceId, sourceNamespace]); + }, [identity, local, retry, todo.goalId, sourceId, sourceNamespace, publishRead]); const current = local && read?.identity === identity ? read : null; const detail = current?.phase === "ready" ? current.detail : null; const editable = local && onSave && todo.claimedBy && sourceId && detail?.review_basis @@ -68,7 +75,7 @@ export function TaskRequest({todo, local, sourceNamespace, onSave}: {todo: Works } const observed = await readTodoRequest(todo.goalId, sourceId, new AbortController().signal, sourceNamespace); if (!mounted.current) return; - setRead({identity, phase: "ready", detail: observed}); + publishRead({identity, phase: "ready", detail: observed}); if (observed.text !== text) throw new Error(t("drawer.requirementsReadbackChanged")); setDraft(null); setPending(null); setSaved(true); } catch (error) { diff --git a/examples/personal-workspace-browser/task-inspector-return.mjs b/examples/personal-workspace-browser/task-inspector-return.mjs index a6f22362c3..adb17f9d5a 100644 --- a/examples/personal-workspace-browser/task-inspector-return.mjs +++ b/examples/personal-workspace-browser/task-inspector-return.mjs @@ -14,12 +14,32 @@ export const taskInspectorReturnScenario = { const coverageEntries = []; const tail = " FINAL_ACCEPTANCE: every requirement is visible; stale or unavailable reads remain explicit."; const prefix = "[P0] Full queue follow-up. "; - const request = prefix + "A".repeat(988 - prefix.length - tail.length) + tail; + const request = prefix + "Read the original source, compare its accepted outcome with the retained reference, and preserve conditions and uncertainty. ".repeat(12).slice(0, 988 - prefix.length - tail.length) + tail; + const evidence = "Independent verification. The retained source contains the complete result and comparison conditions. " + + "The bounded list preview does not include the final reference, so opening this Task must read it from the same original source. " + + "Unicode evidence 证据🙂 remains intact. Retained baseline: reference-v2."; + const note = "Conditions and limitations. This synthetic example separates a reported result from independent confirmation. " + + "Missing measurements are still unknown; reading the Task does not launch an analysis or change its status. Attribution remains unknown."; for (const width of [1512, 390]) { + let metadata = {evidence, note}; const context = await openWorkspacePage(browser, url, { collectCoverage, viewport: { width, height: 844 }, - beforeGoto(api) { + async beforeGoto(api, page) { api.todoRequestTexts.set(JSON.stringify(["progress-projection", "todo-progress-full"]), request); + api.transformStatus = payload => { + const goal = payload.attention_queue.items.find(item => item.goal_id === "progress-projection"); + for (const item of goal?.agent_todos?.items ?? []) { + if (item.todo_id === "todo-progress-full") Object.assign(item, {evidence: evidence.slice(0, 100), note: note.slice(0, 100)}); + if (item.todo_id === "todo-progress-current") item.evidence = "Current verification"; + } + }; + await page.route("**/api/chat/todo/detail?*", async route => { + const query = new URL(route.request().url()).searchParams; + if (query.get("todo_id") !== "todo-progress-full") return route.fallback(); + api.todoRequestReads.push({goalId: query.get("goal_id"), todoId: query.get("todo_id")}); + await route.fulfill({json: {ok: true, goal_id: "progress-projection", todo_id: "todo-progress-full", + text: request, status: "open", archive_state: "active", updated_at: null, ...metadata}}); + }); }, }); const { page, api } = context; @@ -78,10 +98,37 @@ export const taskInspectorReturnScenario = { await fullOpener.click(); await waitForFull(); assert.equal(await body.innerText(), request, "A cold read restores every one of the 988 source characters beyond the 500-character status projection"); + const evidenceCard = drawer.getByRole("region", {name: "证据", exact: true}); + const notes = drawer.locator('details[aria-label="备注"]'); + assert.equal(await evidenceCard.locator(".personal-md").innerText(), evidence); + await notes.locator("summary").click(); + assert.equal(await notes.locator(".personal-md").innerText(), note); + assert.equal(api.todoRequestReads.filter(read => read.todoId === "todo-progress-full").length, 1, "Requirements, evidence and notes share one source read"); await page.screenshot({ path: resolve(outputDir, `task-request-full-${width}.png`), animations: "disabled" }); await page.keyboard.press("Escape"); await drawer.waitFor({ state: "hidden" }); + // Older readers omit metadata; explicit null from the current source + // must instead clear an outdated bounded hint. + for (const fields of [{}, {evidence: null, note: null}]) { + metadata = fields; + await fullOpener.click(); + await waitForFull(); + if ("evidence" in fields) { + assert.equal(await evidenceCard.count(), 0); + assert.equal(await notes.count(), 0); + } else { + assert.match(await evidenceCard.locator(".personal-md").innerText(), /^Independent verification/); + assert.doesNotMatch(await evidenceCard.innerText(), /Retained baseline/); + await notes.locator("summary").click(); + assert.match(await notes.locator(".personal-md").innerText(), /^Conditions and limitations/); + assert.doesNotMatch(await notes.innerText(), /Attribution remains unknown/); + } + await page.keyboard.press("Escape"); + await drawer.waitFor({state: "hidden"}); + } + metadata = {evidence, note}; + // A failed or mismatched read must discard an earlier successful body, // preserve the received summary, and offer a real retry in the drawer. let nextRead = "failed"; @@ -93,7 +140,7 @@ export const taskInspectorReturnScenario = { nextRead = null; if (outcome === "late") { await new Promise(resolveWait => { releaseLate = resolveWait; }); - await route.fulfill({ json: { ok: true, goal_id: "progress-projection", todo_id: "todo-progress-full", text: request, status: "open", archive_state: "active", updated_at: null } }); + await route.fulfill({ json: { ok: true, goal_id: "progress-projection", todo_id: "todo-progress-full", text: request, evidence, note, status: "open", archive_state: "active", updated_at: null } }); lateFinished(); return; } @@ -108,8 +155,10 @@ export const taskInspectorReturnScenario = { await retry.waitFor(); assert.equal((await body.innerText()).length, 500); assert.ok(!(await body.innerText()).includes("FINAL_ACCEPTANCE"), "A previous successful body is never reused after a failed exact read"); + assert.doesNotMatch(await drawer.innerText(), /Retained baseline|Attribution remains unknown/); await retry.click(); await waitForFull(); + assert.equal(await evidenceCard.locator(".personal-md").innerText(), evidence); await page.keyboard.press("Escape"); await drawer.waitFor({ state: "hidden" }); } @@ -124,6 +173,8 @@ export const taskInspectorReturnScenario = { releaseLate(); await finished; assert.equal(await body.innerText(), "Current Todo", "A late response from the closed Task cannot overwrite the next selection"); + assert.equal(await evidenceCard.locator(".personal-md").innerText(), "Current verification"); + assert.equal(await notes.count(), 0, "A late source note cannot appear under the next Task"); await page.keyboard.press("Escape"); await drawer.waitFor({ state: "hidden" }); assert.ok(api.todoRequestReads.every(read => read.todoId), "A display-only legacy identity must never be sent as an authority id"); @@ -274,6 +325,6 @@ export const taskInspectorReturnScenario = { await exited; await rm(root, {recursive:true,force:true}); } - return { coverageEntries, note: "The 988-character source survives a bounded 500-character status; failed/mismatched reads, retry and late selection responses are explicit on packaged desktop/390px. Pointer/Escape/Enter/close/More retain the opener with reduced motion. Editing uses a disposable real File authority and Chat HTTP owner: one save, retained concurrent-conflict draft and lost successful response recovery without another apply. Workspace navigation/status is synthetic. File/SQLite hard-lease semantics are qualified separately by HTTP+CLI tests; no active Goal or model execution." }; + return { coverageEntries, note: "The 988-character request plus complete evidence/notes share one read beyond bounded status hints; absent fields preserve legacy compatibility and explicit null clears stale hints. Failed/mismatched reads, retry and late selection responses are explicit on packaged desktop/390px. Pointer/Escape/Enter/close/More retain the opener with reduced motion. Editing uses a disposable real File authority and Chat HTTP owner: one save, retained concurrent-conflict draft and lost successful response recovery without another apply. Workspace navigation/status is synthetic. File/SQLite hard-lease semantics are qualified separately by HTTP+CLI tests; no active Goal or model execution." }; }, }; diff --git a/loopx/presentation/goal_source_routing.py b/loopx/presentation/goal_source_routing.py index 179d47d187..55c7d7071b 100644 --- a/loopx/presentation/goal_source_routing.py +++ b/loopx/presentation/goal_source_routing.py @@ -107,6 +107,7 @@ def _todo_detail(self): return self._send_json({"ok": True, "goal_id": goal_id, "todo_id": todo_id, "text": item["text"], "status": item["status"], + "evidence": item.get("evidence"), "note": item.get("note"), "archive_state": item.get("archive_state", "active"), "updated_at": item.get("updated_at"), "source_access": row["source_access"]}) except (ValueError, TypeError, KeyError): diff --git a/tests/presentation/test_linked_goal_sources.py b/tests/presentation/test_linked_goal_sources.py index d568779ad5..8a7a6d64da 100644 --- a/tests/presentation/test_linked_goal_sources.py +++ b/tests/presentation/test_linked_goal_sources.py @@ -25,7 +25,8 @@ from loopx.control_plane.status.goal_source import GoalSource -def native_source(w, name='original', task_text='Check the original question against its evidence', done=False): +def native_source(w, name='original', task_text='Check the original question against its evidence', done=False, + task_evidence=None, task_note=None): from loopx.control_plane.todos.active_state_todo_parser import parse_todo_source from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection from tests.control_plane.canonical_authority_fixture import initialize_canonical_authority @@ -44,6 +45,10 @@ def native_source(w, name='original', task_text='Check the original question aga rows, _, _ = parse_todo_source(state.read_text()) for todo in rows['agent']: todo['schema_version'] = 'todo_item_v0' + if task_evidence is not None: + todo['evidence'] = task_evidence + if task_note is not None: + todo['note'] = task_note initialize_canonical_authority(runtime, 'solver-goal', build_todo_runtime_shadow_projection( goal_id='solver-goal', todos=rows['agent'], handoff_mode='soft_claim'), state_path=state, provider='sqlite') for goal in (('research-owner', 'other-owner') if name == 'original' else ()): @@ -78,6 +83,47 @@ def request(path, body=None, origin=None): return raw, request, {path: path.read_bytes() for path in (registry, state, log)} +@pytest.mark.parametrize('done', [False, True]) +def test_linked_task_details_keep_complete_evidence_and_notes(workspace, done): + evidence = 'Independent verification. ' + '证据🙂 ' * 180 + ' Retained baseline: reference-v2.' + note = 'Conditions and limitations. ' + '完整备注 ' * 180 + ' Attribution remains unknown.' + raw, request, before = native_source(workspace, done=done, task_evidence=evidence, task_note=note) + request('/api/chat/goal-sources', {'parent_goal_id': 'research-owner', 'source': raw}) + namespace = GoalSource.parse(raw).namespace + query = {'goal_id': 'solver-goal', 'todo_id': 'todo_original', 'source_namespace': namespace} + endpoint = '/api/chat/todo/detail?' + detail = request(endpoint + urlencode(query)) + assert detail['evidence'] == evidence and detail['note'] == note + assert detail['status'] == ('done' if done else 'open') + assert detail['source_access']['namespace'] == namespace + assert 'review_basis' not in detail + + for changed in ({'source_namespace': 'f' * 64}, {'todo_id': 'todo_missing'}): + with pytest.raises(HTTPError) as refused: + request(endpoint + urlencode({**query, **changed})) + error = json.load(refused.value) + assert not error['ok'] and not {'text', 'evidence', 'note'} & error.keys() + + source_registry = Path(raw['registry']) + source_registry.unlink() + with pytest.raises(HTTPError) as unavailable: + request(endpoint + urlencode(query)) + assert unavailable.value.code == 503 + assert 'Retained baseline' not in unavailable.value.read().decode() + source_registry.write_bytes(before[source_registry]) + assert request(endpoint + urlencode(query))['evidence'] == evidence + assert all(path.read_bytes() == data for path, data in before.items()) + assert not workspace['count'].exists() + + +def test_linked_task_details_preserve_absent_metadata(workspace): + raw, request, _ = native_source(workspace) + request('/api/chat/goal-sources', {'parent_goal_id': 'research-owner', 'source': raw}) + detail = request('/api/chat/todo/detail?' + urlencode({ + 'goal_id': 'solver-goal', 'todo_id': 'todo_original', 'source_namespace': GoalSource.parse(raw).namespace})) + assert detail['evidence'] is None and detail['note'] is None + + @pytest.mark.parametrize("separate_chat_store", [False, True]) def test_app_parent_directory_reads_original_tasks_history_and_explicit_analysis(workspace, separate_chat_store): w = workspace