Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/presentation/dashboard/src/data/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<unknown>(url, {signal}));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<TaskRequestRead | null>(null);
const [repositoryCopyState, setRepositoryCopyState] = useState<"idle" | "copied" | "error">("idle");
const [runDrawerTab, setRunDrawerTab] = useState<"record" | "details">("record");
const [runActions, setRunActions] = useState<Record<string, RunActionStates>>({});
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -684,7 +694,7 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention
) : selection.item.detailMode ? (
<section className="personal-task-inspector-summary">
<p className="personal-proposal-explainer">{t(selection.item.detailMode === "missing_current" ? "drawer.taskReadbackUnavailable" : "drawer.taskRequestOnly")}</p>
<TaskRequest todo={selection.item} local={taskReadable} sourceNamespace={taskNamespace} />
<TaskRequest todo={selection.item} local={taskReadable} sourceNamespace={taskNamespace} onRead={setTaskRequestRead} />
</section>
) : (
<>
Expand All @@ -697,6 +707,7 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention
<span>{selection.item.taskClass === "advancement_task" ? t("drawer.taskAdvancement") : selection.item.taskClass ?? t("drawer.taskOrdinary")}</span>
</div>
<TaskRequest key={`${taskNamespace}:${selection.item.goalId}:${selection.item.todoId}`} todo={selection.item} local={taskReadable} sourceNamespace={taskNamespace}
onRead={setTaskRequestRead}
onSave={!readOnly && !todoCompleted ? callbacks.onSaveTodoRequirements : undefined} />
{selection.item.status === "blocked" ? <section aria-label={t("drawer.blockedReason")} className="personal-task-blocked-reason" data-recorded={selection.item.blockedReason ? "true" : "false"}>
<h4><AlertCircle size={14} />{t("drawer.blockedReason")}</h4>
Expand Down Expand Up @@ -788,13 +799,13 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention
<small>{t("drawer.taskCompletionProofHint")}</small></label>
</details>}
</div> : null}
{selection.item.evidence ? <section className="personal-detail-card" aria-label={t("drawer.evidence")}>
{taskEvidence ? <section className="personal-detail-card" aria-label={t("drawer.evidence")}>
<h4>{t("drawer.evidence")}</h4>
<MarkdownText text={selection.item.evidence} />
<MarkdownText text={taskEvidence} />
</section> : null}
{selection.item.note ? <details className="personal-detail-card" aria-label={t("drawer.notes")}>
{taskNote ? <details className="personal-detail-card" aria-label={t("drawer.notes")}>
<summary>{t("drawer.notes")}</summary>
<MarkdownText text={selection.item.note} />
<MarkdownText text={taskNote} />
</details> : null}
{todoCompleted ? <div className="personal-task-completed-note"><Check size={16} /><span><strong>{t("drawer.taskCompletedTitle")}</strong><small>{t("drawer.taskCompletedNote")}</small></span></div> : null}
</>
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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<ReturnType<typeof readTodoRequest>>}
);

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<RequestRead | null>(null);
const identity = taskRequestIdentity(todo, local, sourceNamespace);
const [read, setRead] = useState<TaskRequestRead | null>(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<WorkspaceActionPreview | null>(null);
Expand All @@ -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);
Expand All @@ -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
Expand All @@ -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) {
Expand Down
59 changes: 55 additions & 4 deletions examples/personal-workspace-browser/task-inspector-return.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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";
Expand All @@ -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;
}
Expand All @@ -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" });
}
Expand All @@ -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");
Expand Down Expand Up @@ -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." };
},
};
1 change: 1 addition & 0 deletions loopx/presentation/goal_source_routing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Loading
Loading