diff --git a/apps/presentation/dashboard/package.json b/apps/presentation/dashboard/package.json index 7412a42580..8d18b1086e 100644 --- a/apps/presentation/dashboard/package.json +++ b/apps/presentation/dashboard/package.json @@ -38,10 +38,10 @@ "smoke:home-route": "rm -rf /tmp/loopx-home-route-smoke && tsc --ignoreConfig --target ES2022 --module CommonJS --moduleResolution Node --ignoreDeprecations 6.0 --skipLibCheck --strict --outDir /tmp/loopx-home-route-smoke smoke/home-route-smoke.ts && node /tmp/loopx-home-route-smoke/home-route-smoke.js", "smoke:delegation-preflight": "rm -rf node_modules/.cache/loopx-delegation-preflight-smoke && tsc --ignoreConfig --target ES2022 --module NodeNext --moduleResolution NodeNext --jsx react-jsx --skipLibCheck --strict --rootDir . --outDir node_modules/.cache/loopx-delegation-preflight-smoke smoke/delegation-preflight-smoke.tsx src/data/delegation-preflight.ts src/features/personal-workspace/delegation-preflight-status.tsx && node node_modules/.cache/loopx-delegation-preflight-smoke/smoke/delegation-preflight-smoke.js", "smoke:delegation-preflight-browser": "node --experimental-strip-types smoke/delegation-preflight-browser-smoke.mjs", - "smoke:personal-workspace": "npm run smoke:conversation-history-order && npm run smoke:goal-order && npm run smoke:goal-activity && npm run smoke:proposal-recency && node src/features/personal-workspace/conversation-order.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/steering-recovery.test.mjs && npm run smoke:delegation-preflight && node src/features/personal-workspace/workspace-theme.test.mjs && node src/features/personal-workspace/font-token.test.mjs && node src/features/personal-workspace/personal-workspace-contract.test.mjs && node ../../../examples/personal-workspace-browser-smoke.mjs", + "smoke:personal-workspace": "npm run smoke:conversation-history-order && npm run smoke:goal-order && npm run smoke:goal-activity && npm run smoke:proposal-recency && node src/features/personal-workspace/conversation-order.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/steering-recovery.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/goal-suggestions.test.mjs && npm run smoke:delegation-preflight && node src/features/personal-workspace/workspace-theme.test.mjs && node src/features/personal-workspace/font-token.test.mjs && node src/features/personal-workspace/personal-workspace-contract.test.mjs && node ../../../examples/personal-workspace-browser-smoke.mjs", "smoke:workspace-locale": "LOOPX_PERSONAL_WORKSPACE_SCENARIO=workspace-locale node ../../../examples/personal-workspace-browser-smoke.mjs", - "smoke:personal-workspace-packaged": "npm run smoke:conversation-history-order && npm run smoke:goal-order && npm run smoke:goal-activity && npm run smoke:proposal-recency && node src/features/personal-workspace/conversation-order.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/steering-recovery.test.mjs && npm run smoke:delegation-preflight && node src/features/personal-workspace/workspace-theme.test.mjs && node src/features/personal-workspace/font-token.test.mjs && node src/features/personal-workspace/personal-workspace-contract.test.mjs && node ../../../examples/personal-workspace-browser/server-startup.test.mjs && LOOPX_PERSONAL_WORKSPACE_PACKAGED=1 LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node ../../../examples/personal-workspace-browser-smoke.mjs && LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node smoke/team-evidence-return-smoke.mjs && LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node smoke/team-feedback-recovery-smoke.mjs && LOOPX_PERSONAL_WORKSPACE_PACKAGED=1 LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node smoke/goal-workspace-craft-browser-smoke.mjs", - "smoke:personal-workspace-packaged:catalog": "npm run smoke:conversation-history-order && npm run smoke:goal-order && npm run smoke:goal-activity && npm run smoke:proposal-recency && node src/features/personal-workspace/conversation-order.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/steering-recovery.test.mjs && npm run smoke:delegation-preflight && node src/features/personal-workspace/workspace-theme.test.mjs && node src/features/personal-workspace/font-token.test.mjs && node src/features/personal-workspace/personal-workspace-contract.test.mjs && node ../../../examples/personal-workspace-browser/server-startup.test.mjs && LOOPX_PERSONAL_WORKSPACE_PACKAGED=1 LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node ../../../examples/personal-workspace-browser-smoke.mjs", + "smoke:personal-workspace-packaged": "npm run smoke:conversation-history-order && npm run smoke:goal-order && npm run smoke:goal-activity && npm run smoke:proposal-recency && node src/features/personal-workspace/conversation-order.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/steering-recovery.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/goal-suggestions.test.mjs && npm run smoke:delegation-preflight && node src/features/personal-workspace/workspace-theme.test.mjs && node src/features/personal-workspace/font-token.test.mjs && node src/features/personal-workspace/personal-workspace-contract.test.mjs && node ../../../examples/personal-workspace-browser/server-startup.test.mjs && LOOPX_PERSONAL_WORKSPACE_PACKAGED=1 LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node ../../../examples/personal-workspace-browser-smoke.mjs && LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node smoke/team-evidence-return-smoke.mjs && LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node smoke/team-feedback-recovery-smoke.mjs && LOOPX_PERSONAL_WORKSPACE_PACKAGED=1 LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node smoke/goal-workspace-craft-browser-smoke.mjs", + "smoke:personal-workspace-packaged:catalog": "npm run smoke:conversation-history-order && npm run smoke:goal-order && npm run smoke:goal-activity && npm run smoke:proposal-recency && node src/features/personal-workspace/conversation-order.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/steering-recovery.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/goal-suggestions.test.mjs && npm run smoke:delegation-preflight && node src/features/personal-workspace/workspace-theme.test.mjs && node src/features/personal-workspace/font-token.test.mjs && node src/features/personal-workspace/personal-workspace-contract.test.mjs && node ../../../examples/personal-workspace-browser/server-startup.test.mjs && LOOPX_PERSONAL_WORKSPACE_PACKAGED=1 LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node ../../../examples/personal-workspace-browser-smoke.mjs", "smoke:personal-workspace-packaged:team": "LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node smoke/team-evidence-return-smoke.mjs && LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node smoke/team-feedback-recovery-smoke.mjs", "smoke:personal-workspace-packaged:craft": "LOOPX_PERSONAL_WORKSPACE_PACKAGED=1 LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node smoke/goal-workspace-craft-browser-smoke.mjs", "smoke:todo-resume-condition": "rm -rf /tmp/loopx-todo-resume-condition-smoke && tsc --ignoreConfig --target ES2022 --module commonjs --moduleResolution node --ignoreDeprecations 6.0 --skipLibCheck --strict --outDir /tmp/loopx-todo-resume-condition-smoke smoke/todo-resume-condition-smoke.ts src/features/personal-workspace/todo-resume-condition.ts && node /tmp/loopx-todo-resume-condition-smoke/smoke/todo-resume-condition-smoke.js", diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-overview.css b/apps/presentation/dashboard/src/features/personal-workspace/goal-overview.css index adef69d7d3..152b13c87d 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-overview.css +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-overview.css @@ -72,8 +72,12 @@ .personal-composer-folded .personal-composer-tools > summary { padding-inline: 8px; border-radius: 6px; list-style: none; } .personal-composer-folded .personal-composer-tools > summary::-webkit-details-marker { display: none; } .personal-composer-folded .personal-composer-tools > summary:hover { background: var(--pw-hover); color: var(--pw-text); } -.personal-composer-folded .personal-composer-tools .personal-quick-prompts { position: absolute; right: 0; bottom: calc(100% + 8px); z-index: 20; display: grid; gap: 4px; width: max-content; max-width: min(320px, calc(100vw - 32px)); padding: 6px; border: 1px solid var(--pw-line); border-radius: 12px; background: var(--pw-card); box-shadow: 0 12px 32px rgb(0 0 0 / 8%), 0 1px 2px rgb(0 0 0 / 6%); animation: goal-panel-enter var(--motion-base) var(--ease-out); } -.personal-composer-folded .personal-quick-prompts button { justify-content: flex-start; } +.personal-composer-folded .personal-composer-tools > summary { display: block; max-width: min(60vw, 420px); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +/* The folded composer's rows pop up as one panel, so a derived row above the shipped prompts never widens the trigger. */ +.personal-composer-folded .personal-composer-tools .personal-composer-tools-panel { position: absolute; right: 0; bottom: calc(100% + 8px); z-index: 20; display: grid; gap: 6px; width: max-content; max-width: min(320px, calc(100vw - 32px)); padding: 6px; border: 1px solid var(--pw-line); border-radius: 12px; background: var(--pw-card); box-shadow: 0 12px 32px rgb(0 0 0 / 8%), 0 1px 2px rgb(0 0 0 / 6%); animation: goal-panel-enter var(--motion-base) var(--ease-out); } +.personal-composer-folded .personal-composer-tools .personal-quick-prompts, .personal-composer-folded .personal-composer-tools .personal-goal-suggestions { display: grid; gap: 4px; margin: 0; } +.personal-composer-folded .personal-composer-tools .personal-goal-suggestions { padding-bottom: 6px; border-bottom: 1px solid var(--pw-line); } +.personal-composer-folded .personal-quick-prompts button, .personal-composer-folded .personal-goal-suggestions button { justify-content: flex-start; } .personal-composer-pill { display: flex; align-items: center; gap: 10px; flex: 0 1 560px; min-width: 0; min-height: 44px; padding: 0 16px; border: 1px solid var(--pw-line-strong); border-radius: 16px; background: var(--pw-card); color: var(--pw-faint); cursor: text; font: inherit; font-size: var(--text-body); line-height: var(--leading-body); text-align: left; transition: border-color var(--motion-fast) var(--ease-standard), color var(--motion-fast) var(--ease-standard); animation: goal-panel-enter var(--motion-section) var(--ease-out); } .personal-composer-pill span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .personal-composer-pill:hover { border-color: var(--pw-muted); color: var(--pw-muted); } @@ -87,7 +91,7 @@ @keyframes goal-panel-enter { from { opacity: 0; transform: translateY(4px); } } @media (prefers-reduced-motion: reduce) { - .personal-goal-view-panel:not([hidden]), .personal-composer-pill, .personal-composer-folded .personal-quick-prompts { animation: none; } + .personal-goal-view-panel:not([hidden]), .personal-composer-pill, .personal-composer-folded .personal-composer-tools-panel { animation: none; } .goal-overview-criteria li, .goal-overview-attention li, .goal-overview-run svg, .goal-overview-attention li svg:last-child { transition: none; } } diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-suggestions.test.mjs b/apps/presentation/dashboard/src/features/personal-workspace/goal-suggestions.test.mjs new file mode 100644 index 0000000000..1d41fb6086 --- /dev/null +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-suggestions.test.mjs @@ -0,0 +1,72 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { deriveGoalSuggestions, goalSuggestionLimit } from "./goal-suggestions.ts"; + +const classes = (overrides = {}) => ({ running_observed: 0, needs_you: 0, waiting_dependency: 0, open: 0, blocked: 0, deferred: 0, done: 0, ...overrides }); +const digest = ({ activation = "active", open = 0, done = 0, blocked = 0, blockedIds = [], waiting = 0, optional = 0, listed = waiting + optional, oldest = null, needsYouUnavailable = false, coverage = "complete", projectedOpen = open } = {}) => ({ + lifecycle: { activation, finishingTurn: false }, + work: { open, done, total: coverage === "complete" ? open + done : null, classes: classes({ open, blocked, done }), rows: [], groups: [], + rail: { needsYou: [], open: [], blocked: blockedIds, done: [] }, coverage, listedOpen: open, projectedOpen }, + needsYou: needsYouUnavailable ? { availability: "unavailable" } : { availability: "ok", blocking: waiting, optional, coverage, listed, oldestWaitingSince: oldest }, +}); +const todos = [{ todoId: "t1", text: "Backfill March exports" }, { todoId: "t2", text: " Reconcile totals " }, { todoId: "t3", text: "Third" }]; + +test("a stopped Goal invites nothing", () => { + assert.deepEqual(deriveGoalSuggestions({ digest: digest({ activation: "stopped", open: 3, blocked: 1, waiting: 2 }), todos }), []); +}); + +test("decisions waiting on the owner come first and carry their count and age", () => { + const [first] = deriveGoalSuggestions({ digest: digest({ open: 4, blocked: 1, blockedIds: ["t1"], waiting: 2, oldest: "2026-10-15T08:00:00Z" }), todos }); + assert.deepEqual(first, { kind: "open_decisions", count: 2, oldestWaitingSince: "2026-10-15T08:00:00Z" }); +}); + +test("blocked work names at most two real task titles from the rail", () => { + const [blocked] = deriveGoalSuggestions({ digest: digest({ open: 3, blocked: 3, blockedIds: ["t2", "missing", "t1", "t3"] }), todos }); + assert.deepEqual(blocked, { kind: "explain_blocked", count: 3, titles: ["Reconcile totals", "Backfill March exports"] }); +}); + +test("a failing acceptance criterion is cited by its own description", () => { + const [failing] = deriveGoalSuggestions({ digest: digest({ open: 1 }), todos, criteria: [ + { description: "Totals reconcile", state: "accepted" }, { description: "Backfill matches CSV", state: "failed" }, { description: "Runbook approved", state: "failed" }, + ] }); + assert.deepEqual(failing, { kind: "explain_failed_criterion", criterion: "Backfill matches CSV", failedCount: 2 }); +}); + +test("idle requires an observed idle executor, open work, and nothing waiting on the owner", () => { + assert.deepEqual(deriveGoalSuggestions({ digest: digest({ open: 2 }), todos, execution: { kind: "idle" } }), [{ kind: "continue_idle", open: 2 }]); + assert.deepEqual(deriveGoalSuggestions({ digest: digest({ open: 2 }), todos, execution: { kind: "unknown" } }), [], "unknown is not idle"); + assert.deepEqual(deriveGoalSuggestions({ digest: digest({ open: 2 }), todos, execution: { kind: "running" } }), []); + assert.deepEqual(deriveGoalSuggestions({ digest: digest({ open: 2, waiting: 1 }), todos, execution: { kind: "idle" } }).map((row) => row.kind), ["open_decisions"], "the owner is the blocker, not idleness"); +}); + +test("a finished Goal points at its results only when nothing waits or blocks", () => { + assert.deepEqual(deriveGoalSuggestions({ digest: digest({ open: 0, done: 5 }), todos }), [{ kind: "review_results", done: 5 }]); + assert.deepEqual(deriveGoalSuggestions({ digest: digest({ open: 0, done: 5, blocked: 1, blockedIds: ["t1"] }), todos }).map((row) => row.kind), ["explain_blocked"]); + assert.deepEqual(deriveGoalSuggestions({ digest: digest({ open: 0, done: 0 }), todos }), [], "an empty Goal suggests nothing"); +}); + +test("needs-you unavailable or unknown is not zero: no decisions, no idle, no finish", () => { + assert.deepEqual(deriveGoalSuggestions({ digest: digest({ open: 1, needsYouUnavailable: true }), todos, execution: { kind: "idle" } }), [], "unavailable attention cannot prove nothing waits"); + assert.deepEqual(deriveGoalSuggestions({ digest: digest({ open: 1, waiting: null, listed: 0 }), todos, execution: { kind: "idle" } }), [], "an unknown blocking count is not zero"); + assert.deepEqual(deriveGoalSuggestions({ digest: digest({ open: 0, done: 4, waiting: null, listed: 0 }), todos }), [], "an unknown blocking count never finishes a Goal"); +}); + +test("an optional user action is listed but is not a decision", () => { + assert.deepEqual(deriveGoalSuggestions({ digest: digest({ open: 2, waiting: 0, optional: 1 }), todos, execution: { kind: "idle" } }), [{ kind: "continue_idle", open: 2 }]); + const [first] = deriveGoalSuggestions({ digest: digest({ open: 2, waiting: 2, optional: 3, listed: 1 }), todos }); + assert.deepEqual(first, { kind: "open_decisions", count: 2, oldestWaitingSince: null }, "the owner's blocking count, not the listed items, is the decision count"); +}); + +test("a partial listing never claims the Goal is finished and counts projected open work", () => { + assert.deepEqual(deriveGoalSuggestions({ digest: digest({ open: 0, done: 3, coverage: "partial", projectedOpen: 5 }), todos }), [], "zero listed items of five open is not finished"); + assert.deepEqual(deriveGoalSuggestions({ digest: digest({ open: 0, done: 3, coverage: "unknown", projectedOpen: null }), todos }), [], "unknown coverage is not finished"); + assert.deepEqual(deriveGoalSuggestions({ digest: digest({ open: 1, done: 3, coverage: "partial", projectedOpen: 5 }), todos, execution: { kind: "idle" } }), [{ kind: "continue_idle", open: 5 }], "idle continues the projected open work"); +}); + +test("at most three suggestions, in owner priority order", () => { + const rows = deriveGoalSuggestions({ digest: digest({ open: 2, blocked: 1, blockedIds: ["t1"], waiting: 1 }), todos, execution: { kind: "idle" }, + criteria: [{ description: "X", state: "failed" }] }); + assert.equal(rows.length, goalSuggestionLimit); + assert.deepEqual(rows.map((row) => row.kind), ["open_decisions", "explain_blocked", "explain_failed_criterion"]); +}); diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-suggestions.ts b/apps/presentation/dashboard/src/features/personal-workspace/goal-suggestions.ts new file mode 100644 index 0000000000..c180aaef42 --- /dev/null +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-suggestions.ts @@ -0,0 +1,66 @@ +import type { GoalWorkspaceDigest } from "../../../../../../loopx/control_plane/presentation/goal_workspace_digest.js"; + +/** + * Suggestions derived from a Goal's current projection. + * + * Each suggestion cites a fact the digest, the Todo list, the execution + * observation or the acceptance observation already states; nothing is + * inferred from prose or counts the owner cannot see. A stopped Goal invites + * nothing (Resume is the masthead's control), and an unknown execution state + * is never read as idle. Order is the owner's priority: a decision that waits + * on them, then work that cannot move, then a failing target, then idleness, + * then a finished Goal. + */ + +export const goalSuggestionLimit = 3; + +export type GoalSuggestion = + | { kind: "open_decisions"; count: number; oldestWaitingSince: string | null } + | { kind: "explain_blocked"; count: number; titles: string[] } + | { kind: "explain_failed_criterion"; criterion: string; failedCount: number } + | { kind: "continue_idle"; open: number } + | { kind: "review_results"; done: number }; + +export type GoalSuggestionInput = { + digest: Pick; + todos: readonly { todoId: string; text: string }[]; + execution?: { kind: "running" | "idle" | "unknown" } | null; + criteria?: readonly { description: string; state: string }[] | null; +}; + +export function deriveGoalSuggestions(input: GoalSuggestionInput): GoalSuggestion[] { + const { digest } = input; + if (digest.lifecycle.activation === "stopped") return []; + const suggestions: GoalSuggestion[] = []; + // Decisions are what the attention owner classifies as waiting on the owner. + // An optional item is not a decision, and an unknown count is not zero. + const needsYou = digest.needsYou.availability === "ok" ? digest.needsYou : null; + const waiting = needsYou?.blocking ?? null; + if (waiting !== null && waiting > 0) { + suggestions.push({ kind: "open_decisions", count: waiting, oldestWaitingSince: needsYou?.oldestWaitingSince ?? null }); + } + const blocked = digest.work.classes.blocked; + if (blocked > 0) { + const text = new Map(input.todos.map((todo) => [todo.todoId, todo.text.trim()])); + const titles = digest.work.rail.blocked.map((id) => text.get(id) ?? "").filter(Boolean).slice(0, 2); + suggestions.push({ kind: "explain_blocked", count: blocked, titles }); + } + const failed = (input.criteria ?? []).filter((criterion) => criterion.state === "failed"); + if (failed.length > 0) { + suggestions.push({ kind: "explain_failed_criterion", criterion: failed[0].description, failedCount: failed.length }); + } + // Idle needs an observed idle executor, open work and a known absence of + // decisions; the owner being the blocker is not idleness. The count is the + // owner's projected open work when it is known, never only the listed part. + const openWork = digest.work.projectedOpen ?? digest.work.open; + if (input.execution?.kind === "idle" && openWork > 0 && waiting === 0) { + suggestions.push({ kind: "continue_idle", open: openWork }); + } + // "All done" is a completeness claim: it needs the owner's complete work + // coverage with a known zero open count, not an empty listing of a partial one. + const finished = digest.work.coverage === "complete" && digest.work.projectedOpen === 0 && digest.work.open === 0; + if (finished && (digest.work.done ?? 0) > 0 && waiting === 0 && blocked === 0) { + suggestions.push({ kind: "review_results", done: digest.work.done ?? 0 }); + } + return suggestions.slice(0, goalSuggestionLimit); +} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx index 4db768d258..1d5184bf84 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx @@ -209,6 +209,16 @@ const en = { "composer.monitor": "Configure scheduled check", "composer.nextAction": "Ask what’s next", "composer.nextActionPrompt": "What should I do next?", + "suggestions.row": "Suggested from this Goal's current state", + "suggestions.summary": "Suggestions · {first}", + "suggestions.openDecisions": "Handle {count} decision(s) waiting on you", + "suggestions.explainBlocked": "Explain {count} blocked task(s)", + "suggestions.explainBlockedPrompt": "In this Goal, {count} task(s) are blocked: {titles}. Explain what blocks each one and what would unblock it.", + "suggestions.explainFailedCriterion": "Why “{criterion}” fails", + "suggestions.explainFailedCriterionPrompt": "The acceptance criterion “{criterion}” currently fails. Explain why, what fix is needed, and how it will be re-verified.", + "suggestions.continueIdle": "Nothing is running · continue {count} open task(s)", + "suggestions.continueIdlePrompt": "No work is running in this Goal and {count} task(s) are open. Continue with the one most worth doing now and say why.", + "suggestions.reviewResults": "All {count} task(s) done · review the results", "composer.send": "Send", "composer.sendMessage": "Send a message to LoopX", "composer.sendMessageHint": "Send message", @@ -1716,6 +1726,16 @@ const zhCN: Record = { "composer.monitor": "配置定时检查", "composer.nextAction": "询问下一步", "composer.nextActionPrompt": "我现在该做什么?", + "suggestions.row": "根据这个 Goal 的当前状态建议", + "suggestions.summary": "快捷提问 · {first}", + "suggestions.openDecisions": "先处理 {count} 项等你决定的事项", + "suggestions.explainBlocked": "解释 {count} 项受阻任务", + "suggestions.explainBlockedPrompt": "这个 Goal 里有 {count} 项任务受阻:{titles}。请逐项说明阻塞原因和解除条件。", + "suggestions.explainFailedCriterion": "为什么「{criterion}」未通过", + "suggestions.explainFailedCriterionPrompt": "验收条件「{criterion}」当前未通过。请说明原因、需要的修复,以及如何重新验证。", + "suggestions.continueIdle": "没有在运行的工作 · 继续推进 {count} 项未完成任务", + "suggestions.continueIdlePrompt": "这个 Goal 当前没有运行中的工作,还有 {count} 项未完成。请继续推进现在最值得做的一项,并说明理由。", + "suggestions.reviewResults": "{count} 项任务全部完成 · 查看成果", "composer.send": "发送", "composer.sendMessage": "向 LoopX 发送消息", "composer.sendMessageHint": "发送消息", diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-contract.test.mjs b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-contract.test.mjs index 88560f1ae8..b6c2f3ba8a 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-contract.test.mjs +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-contract.test.mjs @@ -600,6 +600,22 @@ for (const key of ["home.firstGoal.title", "home.firstGoal.lead", "home.firstGoa assert.equal((i18n.match(new RegExp(`"${key.replace(/\./g, "\\.")}":`, "g")) ?? []).length, 2, `${key} exists in both locales`); } +// State-aware suggestions cite digest, Todo, execution and acceptance facts; +// they never replace the shipped steward prompt row, and a stopped Goal or an +// unknown execution state produces none. +const goalSuggestions = source("./goal-suggestions.ts"); +assert.match(goalSuggestions, /if \(digest\.lifecycle\.activation === "stopped"\) return \[\];/, "A stopped Goal invites nothing"); +assert.match(goalSuggestions, /input\.execution\?\.kind === "idle"/, "Idle needs an observed idle executor, never unknown"); +assert.match(goalSuggestions, /const waiting = needsYou\?\.blocking \?\? null;/, "Decisions are the attention owner's blocking count; optional items and unknown counts are never decisions"); +assert.doesNotMatch(goalSuggestions, /needsYou\.listed/, "The listed attention count is never read as the decision count"); +assert.match(goalSuggestions, /digest\.work\.coverage === "complete" && digest\.work\.projectedOpen === 0 && digest\.work\.open === 0/, "All done needs complete work coverage with a known zero open count"); +assert.match(page, /deriveGoalSuggestions\(\{\s*digest: workspaceDigest,/, "The page derives suggestions from the shared digest"); +assert.match(page, /className="personal-goal-suggestions"/, "Derived suggestions render in their own row"); +assert.match(page, /
\n\s* + ))} +
+ ) : null} {selectedGoal ? (
@@ -2130,6 +2183,7 @@ export function PersonalWorkspacePage({
)} + : null; const taskBoard = (active: boolean) => selectedGoal ? ( summary small { color: var(--pw-blue-ink); font-size: inherit; } .personal-quick-prompts { display: flex; gap: 8px; margin-bottom: 9px; flex-wrap: wrap; } .personal-quick-prompts button { display: inline-flex; align-items: center; gap: 6px; min-height: 32px; padding: 5px 12px; border: 1px solid var(--pw-line-strong); border-radius: 99px; background: #fff; color: var(--pw-muted); font-size: var(--text-body); line-height: var(--leading-body); cursor: pointer; transition: all .14s ease; } .personal-quick-prompts button:hover { border-color: var(--pw-blue); color: var(--pw-blue-ink); } diff --git a/docs/assets/personal-workspace/goal-suggestions-row.png b/docs/assets/personal-workspace/goal-suggestions-row.png new file mode 100644 index 0000000000..3516ec315d Binary files /dev/null and b/docs/assets/personal-workspace/goal-suggestions-row.png differ diff --git a/docs/guides/personal-workspace-user-guide.md b/docs/guides/personal-workspace-user-guide.md index f0f95c9fbf..ec38357d75 100644 --- a/docs/guides/personal-workspace-user-guide.md +++ b/docs/guides/personal-workspace-user-guide.md @@ -264,6 +264,28 @@ execution permissions. --- +### 4.1.1 状态感知的快捷建议 / State-aware suggestions + +![根据 Goal 当前状态推导的建议行](../assets/personal-workspace/goal-suggestions-row.png) + +Goal 对话页的「快捷提问」折叠行,除了固定的几条问题外,会根据这个 Goal 的当前投影给出至多三条 +建议,每条只引用投影里的真实事实:有事项等你决定时是「先处理 N 项等你决定的事项」,点击直接打开 +概览页的需要你栏;有受阻任务时是「解释 N 项受阻任务」,点击把这些任务的标题带进问题立即发送; +验收条件未通过时是「为什么「…」未通过」;观察到没有运行中的工作且仍有未完成任务时是「继续推进」; +任务全部完成时是「查看成果」。「等你决定」只计需要你介入的事项,可选提醒会列出但不计入; +「查看成果」只在工作列表完整且已知没有未完成任务时出现,只列出一部分的投影不会被当作全部完成。 +已停止的 Goal 不给建议;执行状态未知时不会假装空闲。折叠时摘要行会带出第一条建议,便于一眼看到。 + +The "Suggestions" disclosure on a Goal's Chat view adds up to three suggestions derived from the +Goal's current projection, each citing a real fact: decisions waiting on you (opens the overview's +needs-you rail), blocked tasks (sends a question naming those tasks), a failing acceptance +criterion, no observed running work with open tasks, or all tasks done. "Decisions" counts only +items that wait on you; an optional reminder is listed but not counted. "All done" appears only +when the work list is complete and the owner's open count is known to be zero; a partial listing is +never read as finished. A stopped Goal gets no +suggestion and unknown execution is never read as idle. The collapsed summary line shows the first +suggestion. + ### 4.2 Goal 概览与交付依据 / Goal overview 手机默认折叠目标与验收条件,可随时展开;顶部「全部工作」打开侧栏,Escape 关闭并返回入口。 diff --git a/examples/personal-workspace-browser-smoke.mjs b/examples/personal-workspace-browser-smoke.mjs index 4c5efde221..52981e51db 100644 --- a/examples/personal-workspace-browser-smoke.mjs +++ b/examples/personal-workspace-browser-smoke.mjs @@ -6,6 +6,8 @@ import {agentModelSelectionScenario} from "./personal-workspace-browser/agent-mo import {goalWorkspaceM2Scenario} from "./personal-workspace-browser/goal-workspace-m2.mjs"; import {goalWorkspaceSelectionScenario} from "./personal-workspace-browser/goal-workspace-selection.mjs"; import {firstGoalHomeScenario} from "./personal-workspace-browser/first-goal-home.mjs"; +import {goalSuggestionsScenario} from "./personal-workspace-browser/goal-suggestions.mjs"; +import {goalSuggestionsLiveScenario} from "./personal-workspace-browser/goal-suggestions-live.mjs"; import { replanCadenceScenario } from "./personal-workspace-browser/replan-cadence.mjs"; import {nativeChildActivityScenario} from "./personal-workspace-browser/native-child-activity.mjs"; import {privateStewardScopeScenario} from "./personal-workspace-browser/private-steward-scope.mjs"; @@ -111,6 +113,8 @@ scenarioCatalog.push(goalWorkspaceM2Scenario); scenarioCatalog.push(goalWorkspaceCreateScenario); scenarioCatalog.push(goalWorkspaceSelectionScenario); scenarioCatalog.push(firstGoalHomeScenario); +scenarioCatalog.push(goalSuggestionsScenario); +scenarioCatalog.push(goalSuggestionsLiveScenario); const requestedScenario = process.env.LOOPX_PERSONAL_WORKSPACE_SCENARIO; scenarioCatalog.push(agentModelSelectionScenario); const scenarios = requestedScenario diff --git a/examples/personal-workspace-browser/goal-suggestions-live.mjs b/examples/personal-workspace-browser/goal-suggestions-live.mjs new file mode 100644 index 0000000000..11d80b8ebc --- /dev/null +++ b/examples/personal-workspace-browser/goal-suggestions-live.mjs @@ -0,0 +1,97 @@ +import assert from "node:assert/strict"; +import { execFile, spawn } from "node:child_process"; +import { once } from "node:events"; +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { createInterface } from "node:readline"; +import { promisify } from "node:util"; +import { resolveTestPython } from "../../scripts/test-python.mjs"; +import { outputDir, repoRoot } from "./fixture.mjs"; +import { fetchWorkspaceBackend, openWorkspacePage } from "./scenario-context.mjs"; + +// End to end against the real service: a disposable real Chat/Todo owner +// serves status.json. An optional user action raised through the CLI is +// listed but never becomes a "decision" suggestion; a real user gate does, +// counted as the typed attention lane counts it, and acting on it opens the +// overview that shows the gate. Only the status path is proxied. + +const goalId = "progress-projection"; +const optionalText = "Consider renaming the export folder"; +const gateText = "Approve the restated FY2023 operating cash flow"; + +export const goalSuggestionsLiveScenario = { + id: "goal-suggestions-live", + async run({ browser, collectCoverage, url }) { + const root = await mkdtemp(join(tmpdir(), "loopx-suggest-live-")); + const python = resolveTestPython({ repoRoot }); + const child = spawn(python, ["-u", resolve(repoRoot, "examples/personal-workspace-browser/goal-workspace-todo-backend.py"), root], { cwd: repoRoot, env: { ...process.env, PYTHONPATH: repoRoot }, stdio: ["ignore", "pipe", "pipe"] }); + const closed = once(child, "close"); + const lines = createInterface({ input: child.stdout }); + let diagnostics = "", ready, session; + child.stderr.on("data", (chunk) => { diagnostics = (diagnostics + chunk).slice(-4000); }); + const cli = (...args) => promisify(execFile)(python, ["-c", "from loopx.entrypoint import main; main()", "--format", "json", "--registry", ready.registry, "todo", ...args], { cwd: repoRoot, env: { ...process.env, PYTHONPATH: repoRoot } }).then(({ stdout }) => JSON.parse(stdout)); + try { + ready = await Promise.race([once(lines, "line").then(([line]) => JSON.parse(line)), closed.then(() => { throw new Error(`todo backend exited: ${diagnostics}`); })]); + const backend = `http://127.0.0.1:${ready.port}`; + session = await openWorkspacePage(browser, url, { collectCoverage, viewport: { width: 1440, height: 900 }, beforeGoto: async (_api, page) => { + // The document URL also ends in "status.json" (its statusUrl query), so match the path, not a glob. + await page.route((target) => target.pathname === "/status.json", async (route) => { + try { const response = await fetchWorkspaceBackend(route, backend); await route.fulfill({ response }); } + catch { await route.abort().catch(() => {}); } + }); + } }); + const { page } = session; + const summary = page.locator(".personal-composer-tools > summary"); + const openConversation = async () => { + await page.locator(".personal-goal-row").first().locator(".personal-goal-link").click(); + await page.getByTestId("goal-vitals").waitFor({ state: "visible", timeout: 20_000 }); + await page.getByRole("navigation", { name: "Goal 视图" }).getByRole("button", { name: "对话", exact: true }).click(); + await summary.waitFor({ state: "visible", timeout: 20_000 }); + }; + const home = () => page.getByRole("button", { name: /^LoopX 管家/ }).first().click(); + const refresh = () => page.getByRole("button", { name: "刷新状态", exact: true }).click(); + + // An optional user action is listed by the real owner but is not a decision. + assert.equal((await cli("add", "--goal-id", goalId, "--role", "user", "--task-class", "user_action", "--text", optionalText)).ok, true); + await openConversation(); + await page.waitForTimeout(1_000); + const before = (await summary.innerText()).trim(); + assert.doesNotMatch(before, /等你决定/, `an optional user action is not a decision suggestion: ${before}`); + await page.getByRole("navigation", { name: "Goal 视图" }).getByRole("button", { name: "概览", exact: true }).click(); + const vitals = (await page.getByTestId("goal-vitals").innerText()).replace(/\s+/g, " "); + assert.match(vitals, /0 等你决定|可选/, `the lane itself shows the item as optional: ${vitals}`); + await home(); + + // A real gate is a decision; the count is the lane's own count. + assert.equal((await cli("add", "--goal-id", goalId, "--role", "user", "--task-class", "user_gate", "--text", gateText)).ok, true); + await refresh(); + await openConversation(); + await summary.filter({ hasText: /等你决定/ }).waitFor({ state: "visible", timeout: 20_000 }); + const after = (await summary.innerText()).trim(); + const count = after.match(/先处理 (\d+) 项等你决定的事项/); + assert.ok(count, `the first suggestion is the decision: ${after}`); + await page.getByRole("navigation", { name: "Goal 视图" }).getByRole("button", { name: "概览", exact: true }).click(); + const lane = (await page.getByTestId("goal-vitals").innerText()).replace(/\s+/g, " ").match(/(\d+) 等你决定/); + assert.ok(lane, "the lane counts the gate"); + assert.equal(count[1], lane[1], `the suggestion count equals the lane's count (${lane[1]})`); + await page.getByRole("navigation", { name: "Goal 视图" }).getByRole("button", { name: "对话", exact: true }).click(); + await summary.waitFor({ state: "visible" }); + await summary.click(); + const row = page.getByTestId("goal-suggestions"); + await row.waitFor({ state: "visible", timeout: 10_000 }); + await page.screenshot({ path: resolve(outputDir, "goal-suggestions-live.png"), animations: "disabled" }); + await row.locator('button[data-suggestion="open_decisions"]').click(); + await page.getByTestId("goal-vitals").waitFor({ state: "visible", timeout: 10_000 }); + await page.locator(".goal-overview").getByText(gateText).first().waitFor({ state: "visible", timeout: 10_000 }); + return { coverageEntries: await session.close(), note: "real Todo owner and status; CLI user_action stays optional, user_gate becomes the decision suggestion" }; + } catch (error) { + if (diagnostics) process.stderr.write(diagnostics); + await session?.close().catch(() => {}); + throw error; + } finally { + lines.close(); + if (child.exitCode === null && child.signalCode === null) { child.kill("SIGTERM"); const kill = setTimeout(() => child.kill("SIGKILL"), 5000); try { await closed; } finally { clearTimeout(kill); } } + } + }, +}; diff --git a/examples/personal-workspace-browser/goal-suggestions.mjs b/examples/personal-workspace-browser/goal-suggestions.mjs new file mode 100644 index 0000000000..edf0cefc65 --- /dev/null +++ b/examples/personal-workspace-browser/goal-suggestions.mjs @@ -0,0 +1,150 @@ +import assert from "node:assert/strict"; +import { resolve } from "node:path"; +import { outputDir } from "./fixture.mjs"; +import { openWorkspacePage } from "./scenario-context.mjs"; + +// The Goal composer's suggestions are derived from the Goal's own projection. +// The fixture's first Goal carries blocked advancement tasks and, once a gate +// is added, a decision waiting on the owner; each suggestion must cite those +// facts and either act in place or send a question that names them. A stopped +// Goal gets no derived suggestion, and the shipped steward prompt row stays. + +const gateText = "Approve the restated FY2023 operating cash flow"; + +function addGate(fixture) { + const first = fixture.attention_queue.items[0]; + first.waiting_on = "user_or_controller"; + first.user_todos ??= { items: [], open_count: 0, source_section: "User Todo", total_count: 0 }; + first.user_todos.items.push({ done: false, status: "open", goal_id: first.goal_id, index: first.user_todos.items.length, role: "user", task_class: "user_gate", todo_id: "gate-restatement", text: gateText, updated_at: "2026-10-15T08:00:00Z" }); + first.user_todos.open_count = first.user_todos.items.filter((todo) => !todo.done).length; + first.user_todos.total_count = first.user_todos.items.length; +} + +// An optional user action (no gate) is listed but is not a decision, and a +// partial projection (five open by the owner's count, none listed) is never +// "all done": both are legal states the shared overview already shows truthfully. +function optionalAndPartial(fixture) { + const first = fixture.attention_queue.items[0]; + // Nothing waits on the owner: the Goal waits on its Agent, and the only user item is optional. + first.waiting_on = "codex"; + first.user_todos = { items: [{ done: false, status: "open", goal_id: first.goal_id, index: 0, role: "user", task_class: "user_action", todo_id: "optional-rename", text: "Consider renaming the export folder", updated_at: "2026-10-15T08:00:00Z" }], open_count: 1, source_section: "User Todo", total_count: 1 }; + if (first.project_asset?.user_todos) first.project_asset.user_todos = { ...first.project_asset.user_todos, items: first.user_todos.items, open: 1, done: 0, total: 1 }; + // The global index lists the same Agent items; drop them so nothing is listed for this Goal. + if (fixture.todo_index?.items) { + fixture.todo_index.items = fixture.todo_index.items.filter((item) => !(item.goal_id === first.goal_id && item.role === "agent")); + fixture.todo_index.total_count = fixture.todo_index.items.length; + } + // Agent rows contribute their current Todo to the Goal's list; none is current here. + for (const agent of fixture.agent_management_projection?.agents ?? []) { + if (agent.current_todo?.goal_id === first.goal_id) agent.current_todo = null; + } + // Every queue item of this Goal carries the same emptied listing and owner counts. + const groups = fixture.attention_queue.items.filter((item) => item.goal_id === first.goal_id).flatMap((item) => [item.agent_todos, item.project_asset?.agent_todos]); + for (const group of groups) { + if (!group) continue; + // No listed items at all (every list the projection carries), while the owner still counts five open. + for (const [key, value] of Object.entries(group)) if (Array.isArray(value)) group[key] = []; + if ("open_count" in group) group.open_count = 5; + if ("open" in group) group.open = 5; + if ("done_count" in group) group.done_count = 3; + if ("done" in group) group.done = 3; + if ("total_count" in group) group.total_count = 8; + if ("total" in group) group.total = 8; + } +} + +export const goalSuggestionsScenario = { + id: "goal-suggestions", + async run({ browser, collectCoverage, url }) { + const session = await openWorkspacePage(browser, url, { collectCoverage, viewport: { width: 1440, height: 900 }, + beforeGoto: (api) => { api.transformStatus = addGate; } }); + const { api, page } = session; + try { + await page.locator(".personal-goal-row").first().locator(".personal-goal-link").click(); + await page.getByTestId("goal-vitals").waitFor({ state: "visible", timeout: 15_000 }); + await page.getByRole("navigation", { name: "Goal 视图" }).getByRole("button", { name: "对话", exact: true }).click(); + const tools = page.locator(".personal-composer-tools"); + const summary = tools.locator("> summary"); + await summary.waitFor({ state: "visible", timeout: 15_000 }); + await page.screenshot({ path: resolve(outputDir, "goal-suggestions-collapsed.png"), animations: "disabled" }); + assert.match(await summary.innerText(), /快捷提问 · 先处理 \d+ 项等你决定的事项/, "The collapsed summary carries the first suggestion"); + await summary.click(); + const row = page.getByTestId("goal-suggestions"); + await row.waitFor({ state: "visible" }); + const labels = (await row.locator("button").allInnerTexts()).map((label) => label.trim()); + assert.ok(labels.length >= 2 && labels.length <= 3, `two or three suggestions, got ${labels.join(" / ")}`); + assert.match(labels[0], /^先处理 \d+ 项等你决定的事项$/); + assert.match(labels[1], /^解释 \d+ 项受阻任务$/); + const stewardRow = page.locator(".personal-quick-prompts").first(); + assert.match(await stewardRow.innerText(), /询问下一步[\s\S]*看阻塞[\s\S]*查证据/, "The shipped steward prompt row stays below"); + await page.screenshot({ path: resolve(outputDir, "goal-suggestions-row.png"), animations: "disabled" }); + + // Decisions: acting in place opens the overview with its owner rail. + await row.locator('button[data-suggestion="open_decisions"]').click(); + await page.getByTestId("goal-vitals").waitFor({ state: "visible" }); + const overview = page.locator(".goal-overview, [data-testid=\"goal-overview\"]").first(); + await overview.waitFor({ state: "visible", timeout: 10_000 }); + assert.match(await page.locator("main, .personal-channel-body, body").first().innerText(), new RegExp(gateText.slice(0, 20)), "The overview shows the waiting decision"); + await page.screenshot({ path: resolve(outputDir, "goal-suggestions-decisions.png"), animations: "disabled" }); + await page.getByRole("navigation", { name: "Goal 视图" }).getByRole("button", { name: "对话", exact: true }).click(); + await summary.waitFor({ state: "visible", timeout: 15_000 }); + if (!(await row.isVisible())) await summary.click(); + + // Blocked work: the question names the real blocked tasks and sends immediately. + await row.locator('button[data-suggestion="explain_blocked"]').click(); + let turn; + for (let attempt = 0; attempt < 60 && !turn; attempt += 1) { + turn = api.turnRequests.find((request) => /受阻:/.test(request.message)); + if (!turn) await page.waitForTimeout(50); + } + assert.ok(turn, "Explaining blocked work sends a Turn"); + assert.match(turn.message, /这个 Goal 里有 \d+ 项任务受阻:「[^」]+」/, "The question quotes a real blocked task title"); + assert.ok(/Rerun SkillsBench|Run the next SkillsBench|Run the SkillsBench/.test(turn.message), `names a fixture task, got: ${turn.message.slice(0, 160)}`); + assert.equal(await page.getByLabel("向 LoopX 发送消息").inputValue(), "", "A suggestion sends immediately and leaves no draft"); + + // A stopped Goal invites nothing derived; the steward row remains. + const stoppedGoals = page.locator(".personal-stopped-goals"); + if (await stoppedGoals.count()) await stoppedGoals.locator("summary").click(); + const stopped = stoppedGoals.locator(".personal-goal-row").first(); + if (await stopped.count()) { + await stopped.locator(".personal-goal-link").click(); + await page.getByRole("navigation", { name: "Goal 视图" }).getByRole("button", { name: "对话", exact: true }).click(); + await summary.waitFor({ state: "visible" }); + assert.equal((await summary.innerText()).trim(), "快捷提问", "A stopped Goal shows no derived suggestion"); + await summary.click(); + assert.equal(await page.getByTestId("goal-suggestions").count(), 0); + await page.locator(".personal-quick-prompts").first().waitFor({ state: "visible" }); + } + const coverageEntries = await session.close(); + + // Counterexamples: optional attention and a partial listing suggest neither a decision nor completion. + const second = await openWorkspacePage(browser, url, { collectCoverage, viewport: { width: 1440, height: 900 }, + beforeGoto: (secondApi) => { secondApi.transformStatus = optionalAndPartial; } }); + try { + const { page: other } = second; + await other.locator(".personal-goal-row").first().locator(".personal-goal-link").click(); + await other.getByTestId("goal-vitals").waitFor({ state: "visible", timeout: 15_000 }); + const vitals = (await other.getByTestId("goal-vitals").innerText()).replace(/\s+/g, " "); + assert.match(vitals, /0 等你决定|可选/, `the lane shows the item as optional: ${vitals}`); + assert.match(vitals, /已列出 5 项中的 0 项/, `the lane shows the partial listing truthfully: ${vitals}; listed: ${(await other.locator(".goal-overview").innerText().catch(() => "")).replace(/\s+/g, " ").slice(0, 400)}`); + await other.getByRole("navigation", { name: "Goal 视图" }).getByRole("button", { name: "对话", exact: true }).click(); + const otherSummary = other.locator(".personal-composer-tools > summary"); + await otherSummary.waitFor({ state: "visible", timeout: 15_000 }); + await other.waitForTimeout(500); + const text = (await otherSummary.innerText()).trim(); + assert.doesNotMatch(text, /等你决定/, `an optional user action is not a decision: ${text}`); + assert.doesNotMatch(text, /全部完成|查看成果/, `five open by the owner's count with none listed is not finished: ${text}`); + await otherSummary.click(); + const labels = (await other.getByTestId("goal-suggestions").locator("button").allInnerTexts().catch(() => [])).join(" / "); + assert.doesNotMatch(labels, /等你决定|全部完成|查看成果/, `no derived decision or completion: ${labels}`); + return { coverageEntries: [...coverageEntries, ...await second.close()] }; + } catch (error) { + await second.close().catch(() => {}); + throw error; + } + } catch (error) { + await session.close().catch(() => {}); + throw error; + } + }, +};