diff --git a/apps/presentation/dashboard/src/features/personal-workspace/channel-timeline.tsx b/apps/presentation/dashboard/src/features/personal-workspace/channel-timeline.tsx index 4b8ddccf27..4bae190b7c 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/channel-timeline.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/channel-timeline.tsx @@ -1,3 +1,4 @@ +import {Fragment} from "react"; import { CollaborationCard } from "./collaboration-card"; import { Activity, Bot, Sparkles } from "lucide-react"; @@ -8,16 +9,21 @@ import { RunRow } from "./cards/run-row"; import { ScheduleRow } from "./cards/schedule-row"; import { useWorkspaceI18n } from "./i18n"; import { ReturnDeliveryStatus } from "./return-delivery-status"; +import {ManagerTeamResult} from "./manager-team-result"; import type { WorkspaceDrawerSelection, WorkspaceGoal, WorkspaceTimelineItem } from "./personal-workspace-model"; export function ChannelTimeline({ items, onSelect, selectedGoal, + showManagerTeamResults = false, + onOpenGoalEvidence, }: { items: WorkspaceTimelineItem[]; onSelect: (selection: WorkspaceDrawerSelection) => void; selectedGoal: WorkspaceGoal | null; + showManagerTeamResults?: boolean; + onOpenGoalEvidence?: (goalId: string) => void; }) { const { locale, t } = useWorkspaceI18n(); if (items.length === 0) { @@ -74,12 +80,17 @@ export function ChannelTimeline({ return onSelect({ item: item.schedule, kind: "schedule" })} schedule={item.schedule} />; } if (item.kind === "proposal") { + const appliedTeamPlan = item.proposal.actionKind === "team.plan" && item.proposal.status === "applied"; return ( - + {showManagerTeamResults && onOpenGoalEvidence && appliedTeamPlan + && item.proposal.goalId && item.proposal.teamPlanTodoIds?.length + ? + : null} ); } return ( diff --git a/apps/presentation/dashboard/src/features/personal-workspace/manager-team-result.tsx b/apps/presentation/dashboard/src/features/personal-workspace/manager-team-result.tsx new file mode 100644 index 0000000000..8249354adb --- /dev/null +++ b/apps/presentation/dashboard/src/features/personal-workspace/manager-team-result.tsx @@ -0,0 +1,197 @@ +import {useEffect, useState} from "react"; +import {ChatApiError, fetchChatSessions, fetchLoopXMode, fetchLoopXTeamWork, readLoopXTeamWork} from "../../data/chat"; +import {TeamArtifactReport, isMarkdownArtifact, type TeamArtifact} from "./team-artifact-content"; + +type Readback = {kind: "waiting" | "unavailable" | "multiple"} | { + kind: "adopted"; artifact: TeamArtifact; agentId: string; +}; + +/** + * The Todo identities a Goal conversation itself reports work for, or the + * coordinator bindings it was configured with. A conversation can only own + * delegation work that its own mode names, so this is the relevance index for a + * plan: one Goal holds many conversations and most of them are unrelated. + */ +type SessionWorkIndex = {sessionId: string; todoIds: Set}; +type GoalWorkIndex = {readAt: number; sessions: SessionWorkIndex[]; unclassified: boolean}; + +const WORK_INDEX_WINDOW_MS = 30_000; +/** Bounded read: more related conversations than a plan can have lanes means the + * readback cannot be attributed, so it withholds instead of guessing. */ +const RELATED_SESSION_LIMIT = 8; +const workIndexes = new Map(); +const workIndexReads = new Map>(); + +/** A 4xx is the server declining this conversation's team readback: without a + * coordinator identity it cannot own delegation work, so it is unrelated rather + * than unreadable. Anything else leaves the conversation unclassified. */ +function declinedByServer(error: unknown): boolean { + const status = (error as ChatApiError | undefined)?.payload?.http_status; + return typeof status === "number" && status >= 400 && status < 500; +} + +async function collectGoalWorkIndex(goalId: string): Promise { + const listed = await fetchChatSessions({goalId, channelId: `goal.${goalId}`}); + const sessions: SessionWorkIndex[] = []; + let unclassified = false; + for (let offset = 0; offset < listed.sessions.length; offset += 8) { + const batch = listed.sessions.slice(offset, offset + 8); + const modes = await Promise.allSettled(batch.map(session => fetchLoopXMode(session.session_id))); + modes.forEach((mode, index) => { + if (mode.status === "rejected") { + unclassified ||= !declinedByServer(mode.reason); + return; + } + if (!mode.value.settings.agent_id) return; + const todoIds = new Set(); + for (const row of [...mode.value.deliveries, ...mode.value.members]) { + if (row.todo_id) todoIds.add(row.todo_id); + } + if (todoIds.size) sessions.push({sessionId: batch[index].session_id, todoIds}); + }); + } + return {readAt: Date.now(), sessions, unclassified}; +} + +/** + * Read every Goal conversation's work index at most once per window, and share + * that one read with every applied card instead of rescanning the Goal per card. + * An explicit refresh re-reads: the owner asked for the current state. + */ +async function readGoalWorkIndex(goalId: string, force: boolean): Promise { + const running = workIndexReads.get(goalId); + if (running) return running; + const cached = workIndexes.get(goalId); + if (!force && cached && Date.now() - cached.readAt < WORK_INDEX_WINDOW_MS) return cached; + const read = collectGoalWorkIndex(goalId) + .then(index => {workIndexes.set(goalId, index); return index;}) + .finally(() => {workIndexReads.delete(goalId);}); + workIndexReads.set(goalId, read); + return read; +} + +/** A plan links to work through the Todo identities written by its apply receipt. */ +async function readAdoptedResult(goalId: string, todoIds: Set, force: boolean): Promise { + let index: GoalWorkIndex; + try { + index = await readGoalWorkIndex(goalId, force); + } catch { /* The Goal's conversations could not be read at all. */ + return {kind: "unavailable"}; + } + // Discovery is bound to the receipt's own Todo identities: only a conversation + // that names one of them can hold this plan's work. Unrelated conversations — + // ordinary or coordinator — never enter the budget and never withdraw a report. + const related = index.sessions.filter(session => + [...session.todoIds].some(todoId => todoIds.has(todoId))); + if (index.unclassified || related.length > RELATED_SESSION_LIMIT) { + // A conversation that could not be classified may still name this plan's + // Todos, and an unattributable readback must not claim a verified result. + return {kind: "unavailable"}; + } + let incomplete = false; + let unavailableAdoption = false; + let remainingPages = 8; + const adopted = new Map>(); + for (const session of related) { + let cursor: string | undefined; + do { + if (!remainingPages--) return {kind: "unavailable"}; + try { + const page = await fetchLoopXTeamWork(session.sessionId, cursor); + incomplete ||= !page.page_readback_complete; + for (const row of page.items) { + if (!row.operation_id || !row.todo_id || !todoIds.has(row.todo_id) || row.status !== "accepted") continue; + const source = await readLoopXTeamWork(session.sessionId, row.operation_id); + if (source.operation_id !== row.operation_id || source.todo_id !== row.todo_id + || source.status !== "accepted" || source.recovery_required || source.error) { + incomplete = true; + continue; + } + for (const adoption of source.adoptions ?? []) { + if (adoption.state !== "current") { + unavailableAdoption = true; + continue; + } + if (!adoption.source_artifacts.length || !adoption.source_artifacts.every(version => + source.artifacts?.some(item => item.ref === version.ref && item.sha256 === version.sha256))) { + unavailableAdoption = true; + continue; + } + let verified = false; + try { + const consumer = await readLoopXTeamWork(session.sessionId, adoption.consumer_operation_id); + const artifact = consumer.artifacts?.find(item => + adoption.consumer_artifacts.some(version => version.ref === item.ref && version.sha256 === item.sha256) + && isMarkdownArtifact(item.ref)) + ?? consumer.artifacts?.find(item => + adoption.consumer_artifacts.some(version => version.ref === item.ref && version.sha256 === item.sha256)); + if (consumer.operation_id === adoption.consumer_operation_id + && consumer.request_id === adoption.consumer_request_id + && consumer.agent_id === adoption.consumer_agent_id + && consumer.todo_id === adoption.consumer_todo_id + && consumer.status === "accepted" && !consumer.recovery_required && !consumer.error && artifact) { + const key = `${session.sessionId}:${consumer.operation_id}`; + const earlier = adopted.get(key); + if (!earlier || (!isMarkdownArtifact(earlier.artifact.ref) && isMarkdownArtifact(artifact.ref))) { + adopted.set(key, {kind: "adopted", artifact, agentId: adoption.consumer_agent_id}); + } + verified = true; + } + } catch { /* The recorded adoption is not a readable conclusion. */ } + if (!verified) unavailableAdoption = true; + } + } + cursor = page.has_more ? page.next_cursor ?? undefined : undefined; + if (page.has_more && !cursor) incomplete = true; + } catch { + incomplete = true; + break; + } + } while (cursor); + } + if (incomplete || unavailableAdoption) return {kind: "unavailable"}; + if (adopted.size > 1) return {kind: "multiple"}; + return adopted.values().next().value ?? {kind: "waiting"}; +} + +/** Return only an accepted, currently adopted report to the manager conversation. */ +export function ManagerTeamResult({goalId, todoIds, zh, onOpenGoalEvidence}: { + goalId: string; todoIds: string[]; zh: boolean; onOpenGoalEvidence: (goalId: string) => void; +}) { + const [result, setResult] = useState<{key: string; readback: Readback} | null>(null); + const [request, setRequest] = useState({count: 0, force: false}); + const todoKey = [...todoIds].sort().join(","); + // A new read must withdraw the previous accepted report immediately. The + // request can be slow or fail after its source acceptance has changed. + const key = `${goalId}:${todoKey}:${request.count}`; + useEffect(() => { + if (!goalId || !todoKey) return; + let cancelled = false; + void readAdoptedResult(goalId, new Set(todoKey.split(",")), request.force) + .then(value => {if (!cancelled) setResult({key, readback: value});}) + .catch(() => {if (!cancelled) setResult({key, readback: {kind: "unavailable"}});}); + return () => {cancelled = true;}; + }, [goalId, todoKey, key, request.force]); + useEffect(() => { + const timer = window.setInterval(() => { + if (!document.hidden) setRequest(previous => ({count: previous.count + 1, force: false})); + }, 30_000); + return () => window.clearInterval(timer); + }, []); + const readback = result?.key === key ? result.readback : null; + if (!goalId || !todoKey) return null; + return
+ {!readback ?

{zh ? "正在核验团队结果…" : "Verifying team result…"}

: readback.kind === "adopted" ? <> +
{zh ? "团队验收结果" : "Team result"}{goalId} · {readback.agentId}
+ + :

{readback.kind === "unavailable" + ? (zh ? "团队结果或采用证据无法核验,请到 Goal 查看版本关系。" : "Team result or adoption evidence cannot be verified; inspect versions in the Goal.") + : readback.kind === "multiple" + ? (zh ? "有多个已验收的下游结果,请到 Goal 选择要采用的结论。" : "Multiple downstream results are accepted; choose the conclusion in the Goal.") + : (zh ? "团队任务已分配,尚无可核验的已采用结果。" : "Team work is assigned; no verifiable adopted result yet.")}

} +
+ + +
+
; +} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts index ad09f9b843..4f25e30864 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts @@ -253,6 +253,7 @@ export type WorkspaceActionPreview = { teamPlanOutcome?: TeamPlanAppliedOutcome; teamPlanAssignments?: Array<{ laneId: string; agentId: string; task: string }>; teamPlanGapLanes?: Array<{ laneId: string; agentId: string; reasonCode: string; task?: string }>; + teamPlanTodoIds?: string[]; title: string; sourceRequest?: WorkspaceActionPreviewRequest; workspaceCandidates?: Array<{ label: string; workspaceRef: string }>; diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx index d13a58fe66..a5709c5643 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx @@ -4,7 +4,7 @@ import { isStaleActionFailure, } from "../../../../../../loopx/control_plane/presentation/action_review_plan.js"; import { refreshAttention } from "./attention-details"; -import { teamPlanAssignments, teamPlanAppliedLine, teamPlanAppliedOutcome, teamPlanFields, teamPlanGoalId, teamPlanLaneCount, teamPlanReceiptGapLanes } from "./team-plan-preview"; +import { teamPlanAssignments, teamPlanAppliedLine, teamPlanAppliedOutcome, teamPlanFields, teamPlanGoalId, teamPlanLaneCount, teamPlanReceiptGapLanes, teamPlanTodoIds } from "./team-plan-preview"; import { useEffect, useMemo, useRef, useState, type ClipboardEvent as ReactClipboardEvent } from "react"; import { AlertCircle, Bot, CalendarClock, FileText, ListPlus, MessageCircleQuestion, Paperclip, Plus, RefreshCw, Send, X } from "lucide-react"; @@ -635,6 +635,7 @@ function workspaceProposal(proposal: TypedActionProposal, t: WorkspaceTranslate) : proposalStatus(proposal.status), teamPlanOutcome: proposal.action_kind === "team.plan" ? teamPlanAppliedOutcome(proposal.receipt) ?? undefined : undefined, teamPlanAssignments: proposal.action_kind === "team.plan" ? teamPlanAssignments(proposal.receipt, proposal.normalized_parameters) : undefined, + teamPlanTodoIds: proposal.action_kind === "team.plan" ? teamPlanTodoIds(proposal.receipt) : undefined, teamPlanGapLanes: proposal.action_kind === "team.plan" ? teamPlanReceiptGapLanes(proposal.receipt, proposal.normalized_parameters) : undefined, @@ -1102,6 +1103,7 @@ export function PersonalWorkspacePage({ const restoreable = stored .filter((proposal) => ["preview_ready", "gated", "deferred", "applying"].includes(proposal.status) || compileActionReviewPlan(proposal).retryOriginal === true + || (proposal.action_kind === "team.plan" && proposal.status === "applied") || (proposal.action_kind === "operation.execute" && proposal.status === "applied")) .map((proposal) => workspaceProposal(proposal, t)); const restored = Object.fromEntries(restoreable.map((proposal) => [proposal.previewId, proposal])); @@ -1890,7 +1892,7 @@ export function PersonalWorkspacePage({ drawer={drawerSelection ? setSelection({ kind: "attention", item })} callbacks={effectiveDrawerCallbacks} goalNotifications={model.goalNotifications ?? []} goals={workspaceGoals} inspectorExpanded={taskInspectorExpanded} larkConnections={readOnly ? [] : larkConnections} onClose={() => { if (drawerSelection.kind === "proposal" && ["applied", "rejected"].includes(drawerSelection.item.status) - && !(drawerSelection.item.actionKind === "heartbeat.bind" && drawerSelection.item.status === "applied")) { + && !(drawerSelection.item.status === "applied" && ["heartbeat.bind", "team.plan"].includes(drawerSelection.item.actionKind))) { setProposals((current) => { const next = { ...current }; delete next[drawerSelection.item.previewId]; @@ -2009,7 +2011,8 @@ export function PersonalWorkspacePage({ ) : !managerChatOpen ? ( void callbacks.onRefresh?.()} onSelectGoal={selectGoal} systemHealth={model.systemHealth} /> ) : ( - + { selectGoal(goalId); openGoalConversation(); }} /> )}
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css index b103a2c23a..60f94c348e 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css @@ -672,6 +672,15 @@ .personal-proposal-row.is-applied { border-color: #b5ddcc; background: #f3fbf7; } .personal-proposal-row.is-error, .personal-proposal-row.is-stale { border-color: #efc3c3; background: #fff7f7; } .personal-proposal-row.is-gated { border-color: #ead39c; background: #fffaf0; } +.personal-manager-team-result { min-width: 0; margin: -4px 0 8px 14px; padding: 14px 16px; border-left: 2px solid #8aaba0; background: var(--pw-surface, #fff); } +.personal-manager-team-result > header { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 4px 12px; margin-bottom: 10px; } +.personal-manager-team-result > header strong { font-size: 13px; } +.personal-manager-team-result > header small { color: var(--pw-muted); font-size: 11px; } +.personal-manager-team-result > p { margin: 0 0 8px; color: var(--pw-muted); font-size: 12px; } +.personal-manager-team-result-actions { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 8px; } +.personal-manager-team-result-actions button { padding: 4px 0; border: 0; background: none; color: var(--pw-link, #0070f3); cursor: pointer; font-size: 12px; } +.personal-manager-team-result .goal-team-report { max-height: 280px; overflow: auto; } +.personal-manager-team-result .goal-team-artifact { min-width: 0; } .personal-gated-summary { border: 1px solid #ead39c; border-radius: 14px; background: #fffaf0; } .personal-gated-summary > summary { display: flex; align-items: center; gap: 9px; padding: 12px 14px; color: #6d5620; cursor: pointer; list-style: none; } .personal-gated-summary > summary::-webkit-details-marker { display: none; } diff --git a/apps/presentation/dashboard/src/features/personal-workspace/team-plan-preview.ts b/apps/presentation/dashboard/src/features/personal-workspace/team-plan-preview.ts index a3a9b2d3e2..6bcda61ba5 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/team-plan-preview.ts +++ b/apps/presentation/dashboard/src/features/personal-workspace/team-plan-preview.ts @@ -104,6 +104,13 @@ export type TeamPlanGapLane = { laneId: string; agentId: string; reasonCode: str export type TeamPlanAssignment = { laneId: string; agentId: string; task: string }; +/** Only canonical Todo identities from the apply receipt can link later work to this plan. */ +export function teamPlanTodoIds(receipt: unknown): string[] { + const ids = asRecord(asRecord(receipt).resource_ids).lane_todo_ids; + return Array.isArray(ids) ? ids.filter((id): id is string => + typeof id === "string" && /^todo_[a-f0-9]{12}$/.test(id)) : []; +} + /** Receipt membership owns the result; the admitted preview only supplies task labels. */ export function teamPlanAssignments(receipt: unknown, parameters: Record): TeamPlanAssignment[] { const record = asRecord(receipt); diff --git a/examples/personal-workspace-browser/fixture.mjs b/examples/personal-workspace-browser/fixture.mjs index 67140eb7cd..8c89072e0c 100644 --- a/examples/personal-workspace-browser/fixture.mjs +++ b/examples/personal-workspace-browser/fixture.mjs @@ -1486,8 +1486,15 @@ export async function installApi(page, { goalSubagentConfigurationEnabled = true deliveries: [], ingress: [], }; + loopxModes.set(sessionId, current); if (request.method() === "GET") { - await route.fulfill({ contentType: "application/json", json: current, status: 200 }); + // A conversation's own mode is its work index: the Todos it dispatched + // work for and the coordinator bindings it was configured with. Only + // these Todo identities can make a Goal conversation relevant. + const deliveries = current.fixturePlanTodoId + ? [{operation_id: "accepted-analysis", agent_id: "local-analyst", todo_id: current.fixturePlanTodoId, status: "accepted"}] + : current.deliveries; + await route.fulfill({ contentType: "application/json", json: {...current, deliveries}, status: 200 }); return; } const body = request.postDataJSON(); @@ -1499,6 +1506,7 @@ export async function installApi(page, { goalSubagentConfigurationEnabled = true return; } if (body.operation === "read") { + if (current.fixtureTeamReadDelayMs) await new Promise(resolveWait => setTimeout(resolveWait, current.fixtureTeamReadDelayMs)); if (body.operation_id === "accepted-synthesis") { await route.fulfill({json: {ok: true, operation_id: body.operation_id, request_id: "request-synthesis", agent_id: "synthesizer", todo_id: "todo_synthesis", status: "accepted", worker_active: false, @@ -1511,12 +1519,13 @@ export async function installApi(page, { goalSubagentConfigurationEnabled = true await route.fulfill({status: 409, json: {ok: false, error: "delegation artifact unavailable"}}); } else { await route.fulfill({json: {ok: true, operation_id: body.operation_id, request_id: "request-analysis", - agent_id: "local-analyst", todo_id: "todo_analysis", status: "accepted", worker_active: false, + agent_id: "local-analyst", todo_id: current.fixturePlanTodoId ?? "todo_analysis", status: "accepted", worker_active: false, recovery_required: false, ...(current.fixtureAdoptionState ? {adoptions: [{requester_agent_id: "lead", consumer_operation_id: "accepted-synthesis", 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)}], state: current.fixtureAdoptionState}]} : {}), + consumer_artifacts: [{ref: "synthesis.json", sha256: "e".repeat(64)}, + ...(current.fixturePlanTodoId ? [{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"}]}}); @@ -1529,13 +1538,24 @@ export async function installApi(page, { goalSubagentConfigurationEnabled = true return; } if (body.operation === "operations") { + if (!current.settings.agent_id) { + await route.fulfill({status: 400, json: {ok: false, error: "configure a coordinator identity first"}}); + return; + } + if (current.fixtureTeamInventoryError) { + await route.fulfill({status: 503, json: {ok: false, error: "delegation inventory unavailable"}}); + return; + } const items = body.cursor ? [{record_id: "c".repeat(64), operation_id: "needs-recovery", agent_id: "cloud-reviewer", todo_id: "todo_review", status: "running", worker_active: false, recovery_required: true}] : [{record_id: "a".repeat(64), operation_id: "accepted-analysis", agent_id: "local-analyst", - todo_id: "todo_analysis", status: "accepted", worker_active: false, recovery_required: false, + todo_id: current.fixturePlanTodoId ?? "todo_analysis", status: "accepted", worker_active: false, recovery_required: false, artifacts: [{ref: "report.json", sha256: "d".repeat(64)}, {ref: "report.md", sha256: "9".repeat(64)}]}, - {record_id: "b".repeat(64), operation_id: "stale-output", status: "unavailable", recovery_required: null}]; - await route.fulfill({json: {items, has_more: !body.cursor, next_cursor: body.cursor ? null : "b".repeat(64), page_readback_complete: Boolean(body.cursor)}}); + ...(!current.fixturePlanTodoId || current.fixtureInventoryGap + ? [{record_id: "b".repeat(64), operation_id: "stale-output", status: "unavailable", recovery_required: null}] + : [])]; + await route.fulfill({json: {items, has_more: !body.cursor, next_cursor: body.cursor ? null : "b".repeat(64), + page_readback_complete: Boolean(body.cursor || (current.fixturePlanTodoId && !current.fixtureInventoryGap))}}); return; } if (body.operation === "inspect") { diff --git a/examples/personal-workspace-browser/team-plan.mjs b/examples/personal-workspace-browser/team-plan.mjs index 1fd6d2f491..8685d5f0fa 100644 --- a/examples/personal-workspace-browser/team-plan.mjs +++ b/examples/personal-workspace-browser/team-plan.mjs @@ -102,7 +102,7 @@ function managerTeamPlanProposal() { lanes: [ ...parameters.plan.lanes, { - lane_id: "lane_manager", + lane_id: "a1a1a1a1a1a1", agent_id: "agent-manager", acceptance: "the manager lane reports its receipt", staffing: "ready", @@ -259,6 +259,101 @@ export const teamPlanScenario = { check(api.actionApplies.filter((id) => id === MANAGER_PROPOSAL_ID).length === 2, "retry uses the same proposal identity"); check(api.durableWriteCount === 2, "recovery does not create another assignment"); check((await drawer.innerText()).includes("待安排 · 尚未加入此目标"), "recovery preserves the original unassigned work"); + const resultCard = page.locator(".personal-proposal-row", { hasText: "已恢复原分配结果" }); + await drawer.locator(".personal-drawer-close").click(); + await resultCard.waitFor({ state: "visible" }); + check((await resultCard.innerText()).includes("已恢复原分配结果"), "closing details keeps the assignment result in the original conversation"); + check(!(await resultCard.innerText()).includes("team.plan"), "the applied card uses a user-facing label instead of a protocol kind"); + await context.checkpointCoverage(); + await page.reload({ waitUntil: "networkidle" }); + await page.getByTestId("personal-goal-home").waitFor({ state: "visible" }); + await page.locator(".personal-manager-link").first().click(); + await page.getByRole("navigation", { name: "管家视图" }).getByRole("button", { name: /^(Chat|对话)$/ }).click(); + await resultCard.waitFor({ state: "visible" }); + await page.screenshot({ + path: resolve(outputDir, "team-plan-result-returned.png"), + fullPage: false, + animations: "disabled", + }); + await resultCard.click(); + await drawer.getByRole("heading", { name: "已恢复原分配结果", exact: true }).waitFor(); + check(api.actionApplies.filter((id) => id === MANAGER_PROPOSAL_ID).length === 2, "reload reads back the result without reapplying the team plan"); + check(api.durableWriteCount === 2, "reopening the assignment result writes no work"); + await drawer.locator(".personal-drawer-close").click(); + const managerResult = page.getByRole("region", {name: "团队结果回到管家"}); + await managerResult.getByText("团队任务已分配,尚无可核验的已采用结果。").waitFor(); + check(await managerResult.getByRole("table").count() === 0, "an accepted result from another Todo is never returned to the manager"); + const goalSession = [...page.__loopxRuntime.sessions.values()].find(session => session.channel_id === `goal.${GOAL_ID}`); + check(Boolean(goalSession), "the original Goal conversation has a session for result readback"); + let goalSessionId = ""; + if (goalSession) { + goalSessionId = goalSession.session_id; + const mode = page.__loopxRuntime.loopxModes.get(goalSession.session_id); + check(Boolean(mode), "the Goal session exposes a complete LoopX mode readback"); + mode.settings.agent_id = "lead"; + mode.fixturePlanTodoId = "todo_a1a1a1a1a1a1"; + mode.fixtureAdoptionState = "current"; + // Unrelated ordinary sessions are common in a long-lived Goal. Their + // team API rejects operations, and even nine such sessions must not + // hide this coordinator's accepted, currently adopted result. + for (let index = 0; index < 9; index += 1) { + const sessionId = `session-ordinary-${index}`; + page.__loopxRuntime.sessions.set(sessionId, { + ...goalSession, session_id: sessionId, agent_id: `ordinary-${index}`, + }); + } + // Other coordinator conversations of the same Goal are just as unrelated: + // their own work index names other Todos, never this plan's. + for (let index = 0; index < 9; index += 1) { + const sessionId = `session-coordinator-${index}`; + page.__loopxRuntime.sessions.set(sessionId, { + ...goalSession, session_id: sessionId, agent_id: `coordinator-${index}`, + }); + page.__loopxRuntime.loopxModes.set(sessionId, { + ...mode, session_id: sessionId, + settings: {...mode.settings, agent_id: `coordinator-${index}`}, + fixturePlanTodoId: null, + deliveries: [{operation_id: `other-${index}`, agent_id: "other-agent", + todo_id: `todo_other_${index}`, status: "accepted"}], + }); + } + await managerResult.getByRole("button", {name: "刷新结果"}).click(); + await managerResult.getByRole("table").waitFor(); + check((await managerResult.innerText()).includes("Reviewed cash allocation"), "the accepted adopted report returns inside the original manager conversation"); + check(!api.loopxModeRequests.some(request => request.operation === "operations" + && /^session-(ordinary|coordinator)-/.test(request.sessionId)), "unrelated Goal conversations are not queried for delegation operations"); + check(await managerResult.getByLabel("证据内容: report.md").count() === 1, "the adopted Markdown report is preferred over machine JSON"); + await page.screenshot({path: resolve(outputDir, "team-plan-manager-adopted-result.png"), fullPage: false, animations: "disabled"}); + await page.setViewportSize({width: 390, height: 844}); + check(await managerResult.evaluate(element => element.scrollWidth <= element.clientWidth), "the returned report remains readable on mobile"); + await page.screenshot({path: resolve(outputDir, "team-plan-manager-adopted-result-mobile.png"), fullPage: false, animations: "disabled"}); + await page.setViewportSize({width: 1512, height: 982}); + mode.fixtureInventoryGap = true; + await managerResult.getByRole("button", {name: "刷新结果"}).click(); + await managerResult.getByText("团队结果或采用证据无法核验,请到 Goal 查看版本关系。").waitFor(); + check(await managerResult.getByRole("table").count() === 0, "an unreadable earlier inventory page withholds the adopted report"); + mode.fixtureInventoryGap = false; + // The related conversation's own inventory being unreadable is different + // from an unrelated conversation existing: this must withdraw the report. + mode.fixtureTeamInventoryError = true; + await managerResult.getByRole("button", {name: "刷新结果"}).click(); + await managerResult.getByText("团队结果或采用证据无法核验,请到 Goal 查看版本关系。").waitFor(); + check(await managerResult.getByRole("table").count() === 0, "an unreadable inventory for the related conversation withholds the report"); + mode.fixtureTeamInventoryError = false; + mode.fixtureAdoptionState = "unavailable"; + mode.fixtureTeamReadDelayMs = 1000; + await managerResult.getByRole("button", {name: "刷新结果"}).click(); + await managerResult.getByText("正在核验团队结果…").waitFor({state: "visible", timeout: 500}); + check(await managerResult.getByRole("table").count() === 0, "refresh immediately withdraws the old accepted report while the new read is pending"); + await managerResult.getByText("团队结果或采用证据无法核验,请到 Goal 查看版本关系。").waitFor(); + mode.fixtureTeamReadDelayMs = 0; + check(await managerResult.getByRole("table").count() === 0, "unavailable adoption immediately withdraws the formerly visible report"); + check(api.durableWriteCount === 2, "result readback does not create another assignment or turn"); + await managerResult.getByRole("button", {name: "查看证据与任务"}).click(); + const goalChatTab = page.getByRole("navigation", {name: "Goal 视图"}).getByRole("button", {name: "对话", exact: true}); + await goalChatTab.waitFor({state: "visible"}); + check(await goalChatTab.getAttribute("aria-current") === "page", "the result offers a direct path to Goal conversation evidence and intervention"); + } // The harness collects both uncaught page errors and console errors; a // dev-server resource status is not a client-side exception, so only the // former is a failure here. @@ -267,9 +362,14 @@ export const teamPlanScenario = { scriptErrors.length === 0, `no client-side exception was raised (${scriptErrors.join(" | ")})`, ); + const injected = [ + `503 ${new URL(url).origin}/api/actions/${MANAGER_PROPOSAL_ID}/apply`, + `503 ${new URL(url).origin}/api/chat/sessions/${goalSessionId}/loopx`, + ]; check( - failedResponses.length === 1 && failedResponses[0].includes(`503 ${new URL(url).origin}/api/actions/${MANAGER_PROPOSAL_ID}/apply`), - `only the injected lost response failed (${failedResponses.join(" | ")})`, + failedResponses.length === injected.length + && failedResponses.every(response => injected.includes(response)), + `only the injected failures were observed (${failedResponses.join(" | ")})`, ); } finally { await context.close();