@tea-agent/loop-agent 0.33.7-beta.0 → 0.34.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/CHANGELOG.md +67 -16
  2. package/dist/application/task-lifecycle/advance.js +309 -9
  3. package/dist/application/task-lifecycle/gates.js +50 -0
  4. package/dist/application/task-lifecycle/observe.js +11 -2
  5. package/dist/application/task-lifecycle/plan-transitions.js +13 -21
  6. package/dist/commands/init-upgrade.js +32 -1
  7. package/dist/commands/init.js +94 -3
  8. package/dist/executors/dag-pi-executor.js +18 -3
  9. package/dist/executors/shell-executor.js +4 -91
  10. package/dist/executors/shell-write-guard.js +26 -8
  11. package/dist/shared/operator/capabilities.js +98 -44
  12. package/dist/shared/resilient-git.js +133 -0
  13. package/dist/task/source-prepare/artifact-meta.js +137 -0
  14. package/dist/task/source-prepare/index.js +2 -0
  15. package/dist/task/source-prepare/parse-intent.js +58 -10
  16. package/dist/task/source-prepare/prepare.js +229 -18
  17. package/dist/task/source-prepare/reference-integrity.js +18 -2
  18. package/dist/task/source-prepare/semantic-intake.js +404 -0
  19. package/dist/worker/console/app-data.js +2 -0
  20. package/dist/worker/console/chat/chat-event-store.js +190 -25
  21. package/dist/worker/console/chat/model-resolver.js +17 -0
  22. package/dist/worker/console/chat/pi-console-config.js +250 -32
  23. package/dist/worker/console/chat/pi-runtime.js +1007 -188
  24. package/dist/worker/console/chat/resource-loader.js +5 -4
  25. package/dist/worker/console/chat/routes.js +495 -157
  26. package/dist/worker/console/chat/runtime-context.js +48 -12
  27. package/dist/worker/console/chat/runtime-selection.js +59 -0
  28. package/dist/worker/console/chat/session-store.js +39 -0
  29. package/dist/worker/console/chat/shortcuts.js +1 -0
  30. package/dist/worker/console/chat/tool-adapter.js +9 -3
  31. package/dist/worker/console/chat/tools.js +5 -1
  32. package/dist/worker/console/dag-execution-receipt.js +380 -0
  33. package/dist/worker/console/interview/grill-me.js +7 -6
  34. package/dist/worker/console/operator-actions.js +785 -85
  35. package/dist/worker/console/prd-intake-bridge.js +393 -0
  36. package/dist/worker/console/recovery-cta.js +35 -6
  37. package/dist/worker/console/server.js +8 -15
  38. package/dist/worker/console/static/assets/index-BQkhJpV8.css +1 -0
  39. package/dist/worker/console/static/assets/index-BpuHmlSP.js +29 -0
  40. package/dist/worker/console/static/index.html +2 -2
  41. package/dist/worker/console/static-src/app/console-types.js +1 -0
  42. package/dist/worker/console/static-src/app/usePrdImport.js +2 -1
  43. package/dist/worker/console/static-src/app/useRecoveryActions.js +24 -1
  44. package/dist/worker/console/static-src/app/useRecoveryConsole.js +19 -1
  45. package/dist/worker/console/static-src/app/useTaskWizard.js +57 -2
  46. package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +45 -8
  47. package/dist/worker/console/static-src/operator-chat/landing-density.js +23 -0
  48. package/dist/worker/console/static-src/operator-chat/refs.js +9 -0
  49. package/dist/worker/console/static-src/operator-chat/runtime-snapshot-store.js +257 -0
  50. package/dist/worker/console/static-src/operator-chat/session-title-watcher.js +128 -0
  51. package/dist/worker/console/static-src/operator-chat/sidebar-split.js +90 -0
  52. package/dist/worker/console/static-src/operator-chat/spatial-overlay.js +37 -0
  53. package/dist/worker/console/static-src/operator-chat/useChatSessions.js +125 -22
  54. package/dist/worker/console/static-src/operator-chat/useChatStream.js +215 -184
  55. package/dist/worker/console/static-src/operator-chat/useChatThread.js +49 -5
  56. package/dist/worker/console/static-src/operator-chat/useComposer.js +17 -0
  57. package/dist/worker/console/static-src/operator-chat/useOverlayFocus.js +84 -0
  58. package/dist/worker/console/static-src/operator-chat/useRuntimeControls.js +225 -74
  59. package/dist/worker/console/static-src/operator-chat/useRuntimeSnapshot.js +196 -0
  60. package/dist/worker/console/static-src/operator-chat/useWorkspaceLayout.js +58 -0
  61. package/dist/worker/console/static-src/operator-chat/workspace-layout-mode.js +31 -0
  62. package/dist/worker/delivery/final-verification.js +13 -5
  63. package/dist/worker/delivery/package.js +31 -19
  64. package/dist/worker/delivery/verification-bundle.js +6 -4
  65. package/dist/worker/observability/read-model.js +3 -0
  66. package/dist/worker/observe/static/operator-chrome.css +5 -2
  67. package/dist/worker/observe/static/operator-chrome.js +6 -1
  68. package/dist/worker/observe/static/styles.css +39 -9
  69. package/dist/workflows/dag/backend-test-case-coverage-analysis.js +33 -462
  70. package/dist/workflows/dag/backend-test-case-manifest.js +0 -4
  71. package/dist/workflows/dag/backend-test-markdown-workflow.js +1 -25
  72. package/dist/workflows/dag/backend-test-module-stem.js +0 -5
  73. package/dist/workflows/dag/backend-test-pytest-collection.js +24 -345
  74. package/dist/workflows/dag/backend-test-scenario-param.js +82 -269
  75. package/dist/workflows/dag/backend-test-writer-completeness.js +16 -47
  76. package/dist/workflows/dag/dynamic-runtime/map.js +8 -24
  77. package/dist/workflows/dag/failure-category.js +3 -0
  78. package/dist/workflows/dag/frontend-worktree-diff.js +12 -27
  79. package/dist/workflows/dag/init-hybrid.js +49 -55
  80. package/dist/workflows/dag/node-execution.js +3 -2
  81. package/dist/workflows/dag/retry-policy.js +44 -11
  82. package/dist/workflows/dag/runner.js +6 -0
  83. package/dist/workflows/dag/scheduler.js +49 -1
  84. package/dist/workflows/dag/types.js +0 -7
  85. package/dist/workflows/dag/validate.js +16 -2
  86. package/dist/workflows/dag/workspace-checkpoint.js +8 -27
  87. package/docs/templates/agent-dag.schema.json +4 -4
  88. package/docs/templates/backend-test-dag.json +29 -32
  89. package/harness.json +1 -1
  90. package/package.json +1 -1
  91. package/skills/local-jacoco-coverage/SKILL.md +281 -0
  92. package/skills/local-jacoco-coverage/references/requirement-to-source-mapping.md +85 -0
  93. package/skills/local-jacoco-coverage/references/runtime-alignment.md +106 -0
  94. package/skills/local-jacoco-coverage/scripts/run-coverage-analysis.sh +148 -0
  95. package/skills/local-jacoco-coverage/scripts/start-jacoco-agent.sh +110 -0
  96. package/skills/loop-agent/references/command-reference.md +3 -1
  97. package/skills/loop-agent/references/source-and-plan-practice.md +13 -0
  98. package/skills/loop-agent/references/task-workflow.md +4 -0
  99. package/dist/worker/console/chat/instruction-skills.js +0 -217
  100. package/dist/worker/console/static/assets/index-CnUXAqxG.css +0 -1
  101. package/dist/worker/console/static/assets/index-CteJFFL2.js +0 -29
@@ -6,8 +6,8 @@
6
6
  <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
7
7
  <link rel="stylesheet" href="/inspect/operator-chrome.css" />
8
8
  <title>Loop 操作台 · Operator Console</title>
9
- <script type="module" crossorigin src="/assets/index-CteJFFL2.js"></script>
10
- <link rel="stylesheet" crossorigin href="/assets/index-CnUXAqxG.css">
9
+ <script type="module" crossorigin src="/assets/index-BpuHmlSP.js"></script>
10
+ <link rel="stylesheet" crossorigin href="/assets/index-BQkhJpV8.css">
11
11
  </head>
12
12
  <body>
13
13
  <div id="root"></div>
@@ -107,6 +107,7 @@ export async function postOperator(action, actionParams = {}, clientRequestId) {
107
107
  export const LONG_RUNNING_ACTIONS = new Set([
108
108
  "runDag",
109
109
  "dagRerun",
110
+ "dagReconcileRun",
110
111
  "standaloneTaskRerun",
111
112
  ]);
112
113
  export function formatDagRerunIneligible(plan) {
@@ -3,7 +3,8 @@ import { composePrdDocuments, importPrdFiles, validatePrdFileList, } from "../pr
3
3
  /** PRD multi-file import: drag/drop + selection, primary-volume selection,
4
4
  * reorder/remove and composed-text regeneration with dirty tracking. */
5
5
  export function usePrdImport() {
6
- const [prdText, setPrdText] = useState("# 需求\n\n实现 Loop Operator Console Happy Path:Interview 契约落地 DAG confirm → run。\n");
6
+ // Empty placeholder do not prefill Console Happy Path sample text (design 2026-08-11).
7
+ const [prdText, setPrdText] = useState("");
7
8
  const [prdDocuments, setPrdDocuments] = useState([]);
8
9
  const [prdPrimaryIndex, setPrdPrimaryIndex] = useState(0);
9
10
  const [prdComposedDirty, setPrdComposedDirty] = useState(false);
@@ -234,7 +234,30 @@ export function useRecoveryActions(params) {
234
234
  setOpError("对账恢复需要 DAG 运行 ID");
235
235
  return;
236
236
  }
237
- setOpMessage(`${cta.label}:请经 CLI ${cta.commandHint ?? ""}`);
237
+ // Default supersede: orphan/interrupted runs should not be resumed.
238
+ // The browser Human Gate binds the destructive action + reason; the CLI
239
+ // still fail-closes when runner is not proven dead/stopped.
240
+ const actionParams = {
241
+ runId,
242
+ reason,
243
+ reconcileAction: "supersede",
244
+ };
245
+ const prep = await runAction("prepareMutationGate", {
246
+ action: "dagReconcileRun",
247
+ actionParams,
248
+ });
249
+ if (!isActionSuccess(prep))
250
+ return;
251
+ const receipt = (prep?.result ?? prep);
252
+ if (!receipt.receiptId || !receipt.humanGateToken) {
253
+ setOpError("prepareMutationGate 未返回可消费的 Human Gate receipt");
254
+ return;
255
+ }
256
+ await runAction("dagReconcileRun", {
257
+ ...actionParams,
258
+ confirmationId: receipt.receiptId,
259
+ humanGateToken: receipt.humanGateToken,
260
+ }, { clientRequestId });
238
261
  return;
239
262
  }
240
263
  setOpMessage(`${cta.label}:请经 CLI — ${cta.commandHint ?? ""}`);
@@ -21,6 +21,7 @@ export function useRecoveryConsole(params) {
21
21
  const [recoveryRunsLoading, setRecoveryRunsLoading] = useState(false);
22
22
  const [recoveryNodesLoading, setRecoveryNodesLoading] = useState(false);
23
23
  const [recoveryRunStatus, setRecoveryRunStatus] = useState(null);
24
+ const [recoveryFailureCategory, setRecoveryFailureCategory] = useState(null);
24
25
  const [manualRunId, setManualRunId] = useState(false);
25
26
  const [manualNodeId, setManualNodeId] = useState(false);
26
27
  const [recoveryReport, setRecoveryReport] = useState(null);
@@ -122,6 +123,7 @@ export function useRecoveryConsole(params) {
122
123
  if (!id) {
123
124
  setRecoveryNodes([]);
124
125
  setRecoveryRunStatus(null);
126
+ setRecoveryFailureCategory(null);
125
127
  return;
126
128
  }
127
129
  setRecoveryNodesLoading(true);
@@ -132,10 +134,16 @@ export function useRecoveryConsole(params) {
132
134
  if (!res.ok) {
133
135
  setRecoveryNodes([]);
134
136
  setRecoveryRunStatus(null);
137
+ setRecoveryFailureCategory(null);
135
138
  return;
136
139
  }
137
140
  const body = (await res.json());
138
- setRecoveryRunStatus(typeof body.status === "string" ? body.status : null);
141
+ // Prefer adaptive effectiveStatus (running-suspected-stall / interrupted)
142
+ // over raw state.status so Recovery CTAs match doctor/liveness facts.
143
+ const status = (typeof body.effectiveStatus === "string" && body.effectiveStatus) ||
144
+ (typeof body.status === "string" && body.status) ||
145
+ null;
146
+ setRecoveryRunStatus(status);
139
147
  const rawNodes = Array.isArray(body.nodes) ? body.nodes : [];
140
148
  const nodes = sortRecoveryNodeCandidates(rawNodes
141
149
  .map((n) => {
@@ -151,10 +159,19 @@ export function useRecoveryConsole(params) {
151
159
  })
152
160
  .filter((n) => n !== null));
153
161
  setRecoveryNodes(nodes);
162
+ // Surface controller-interrupted (or other) from node records when run-level
163
+ // failureCategory is absent — needed for S6 interrupt CTA classification.
164
+ const runFailure = (typeof body.failureCategory === "string" && body.failureCategory) ||
165
+ undefined;
166
+ const nodeFailure = rawNodes.find((n) => typeof n.failureCategory === "string" &&
167
+ n.failureCategory &&
168
+ n.failureCategory !== "success")?.failureCategory;
169
+ setRecoveryFailureCategory(runFailure ?? nodeFailure ?? null);
154
170
  }
155
171
  catch {
156
172
  setRecoveryNodes([]);
157
173
  setRecoveryRunStatus(null);
174
+ setRecoveryFailureCategory(null);
158
175
  }
159
176
  finally {
160
177
  setRecoveryNodesLoading(false);
@@ -210,6 +227,7 @@ export function useRecoveryConsole(params) {
210
227
  operationState: recoveryRunStatus ? null : lastOpState,
211
228
  errorCode: recoveryRunStatus ? null : opErrorCode,
212
229
  dagStatus: recoveryRunStatus,
230
+ failureCategory: recoveryFailureCategory,
213
231
  });
214
232
  const recoveryCtas = useMemo(() => consoleRecoveryCtas(recoveryFact), [recoveryFact]);
215
233
  const recoveryLanes = useMemo(() => partitionRecoveryCtas(recoveryCtas), [recoveryCtas]);
@@ -10,6 +10,9 @@ export function useTaskWizard(params) {
10
10
  const [taskId, setTaskId] = useState("");
11
11
  const [taskTitle, setTaskTitle] = useState("");
12
12
  const [taskKind, setTaskKind] = useState("standard");
13
+ const [allowedPathsText, setAllowedPathsText] = useState("");
14
+ const [forbiddenPathsText, setForbiddenPathsText] = useState("");
15
+ const [verifyCommandsText, setVerifyCommandsText] = useState("");
13
16
  const workflowMeta = WORKFLOW_OPTIONS.find((w) => w.kind === taskKind) ?? WORKFLOW_OPTIONS[0];
14
17
  const [sessionId, setSessionId] = useState(null);
15
18
  const [question, setQuestion] = useState(null);
@@ -166,14 +169,26 @@ export function useTaskWizard(params) {
166
169
  return;
167
170
  void runContractPreflight(false);
168
171
  }, [step, taskId, assessmentId, runContractPreflight]);
172
+ const splitBoundaryList = useCallback((text) => {
173
+ return text
174
+ .split(/[\n,;,;]+/)
175
+ .map((s) => s.trim())
176
+ .filter(Boolean);
177
+ }, []);
169
178
  const importPrdAndCreate = useCallback(async () => {
170
179
  const docsPayload = prd.prdDocuments.length > 0
171
180
  ? ensureUniqueDocumentNames(prd.prdDocuments)
172
181
  : undefined;
182
+ const allowedPaths = splitBoundaryList(allowedPathsText);
183
+ const forbiddenPaths = splitBoundaryList(forbiddenPathsText);
184
+ const verifyCommands = splitBoundaryList(verifyCommandsText);
173
185
  const actionParams = {
174
186
  content: prd.prdText,
175
187
  taskKind,
176
188
  ...(docsPayload ? { documents: docsPayload } : {}),
189
+ ...(allowedPaths.length > 0 ? { allowedPaths } : {}),
190
+ ...(forbiddenPaths.length > 0 ? { forbiddenPaths } : {}),
191
+ ...(verifyCommands.length > 0 ? { verifyCommands } : {}),
177
192
  };
178
193
  if (!operatorRequestFits("bootstrapFromPrd", actionParams)) {
179
194
  prd.setPrdFileError(operatorRequestTooLargeMessage());
@@ -187,9 +202,33 @@ export function useTaskWizard(params) {
187
202
  setTaskId(result.taskId);
188
203
  if (result.title)
189
204
  setTaskTitle(result.title);
190
- setOpMessage(`已创建任务「${result.title ?? ""}」(${result.taskId ?? ""}),来源:${result.identitySource ?? "prd"}`);
205
+ const parseLabel = result.parseStatus ?? "pending";
206
+ const objectiveHint = result.draftPreview?.objective?.trim();
207
+ const gapCount = Array.isArray(result.gaps) ? result.gaps.length : 0;
208
+ const semanticNote = result.semanticIntake?.applied
209
+ ? "semantic"
210
+ : (result.semanticIntake?.code ?? "no-semantic");
211
+ setOpMessage([
212
+ `已创建任务「${result.title ?? ""}」(${result.taskId ?? ""})`,
213
+ `来源:${result.identitySource ?? "prd"}`,
214
+ `解析:${parseLabel}(${semanticNote})`,
215
+ objectiveHint ? `目标:${objectiveHint.slice(0, 120)}` : null,
216
+ gapCount > 0 ? `缺口 ${gapCount} 项待确认` : null,
217
+ ]
218
+ .filter(Boolean)
219
+ .join(" · "));
191
220
  setStep("interview");
192
- }, [prd, runAction, setOpMessage, setStep, taskKind]);
221
+ }, [
222
+ allowedPathsText,
223
+ forbiddenPathsText,
224
+ prd,
225
+ runAction,
226
+ setOpMessage,
227
+ setStep,
228
+ splitBoundaryList,
229
+ taskKind,
230
+ verifyCommandsText,
231
+ ]);
193
232
  const wizardSteps = [
194
233
  { id: "setup", label: "就绪检查" },
195
234
  { id: "import-prd", label: "导入需求" },
@@ -205,6 +244,22 @@ export function useTaskWizard(params) {
205
244
  taskTitle,
206
245
  taskKind,
207
246
  setTaskKind,
247
+ engineering: {
248
+ allowedPathsText,
249
+ forbiddenPathsText,
250
+ verifyCommandsText,
251
+ },
252
+ setEngineering: (patch) => {
253
+ if (patch.allowedPathsText !== undefined) {
254
+ setAllowedPathsText(patch.allowedPathsText);
255
+ }
256
+ if (patch.forbiddenPathsText !== undefined) {
257
+ setForbiddenPathsText(patch.forbiddenPathsText);
258
+ }
259
+ if (patch.verifyCommandsText !== undefined) {
260
+ setVerifyCommandsText(patch.verifyCommandsText);
261
+ }
262
+ },
208
263
  workflowMeta,
209
264
  sessionId,
210
265
  question,
@@ -9,20 +9,47 @@ import { requestId } from "./format.js";
9
9
  * original request would have. `assistantId` is the in-flight assistant
10
10
  * message id to patch for streaming text; reconnect creates one if none. */
11
11
  export function createChatSseEventApplier(deps) {
12
- const { refs, setError, setProcessOpen, setInterview, setHumanGateCards, setArtifactCards, setStreaming, setStreamingAssistantId, setUnread, setOperationCards, setUsage, setMessages, setToolCalls, } = deps;
12
+ const { refs, onReconcile, setError, setProcessOpen, setInterview, setHumanGateCards, setArtifactCards, setStreaming, setStreamingAssistantId, setUnread, setOperationCards, setUsage, setMessages, setToolCalls, } = deps;
13
13
  return (eventName, data, assistantId, options) => {
14
+ if (options?.generation !== undefined &&
15
+ refs.sessionGenerationRef.current !== options.generation)
16
+ return;
14
17
  const replay = options?.replay === true;
18
+ if (eventName === "stream_ready")
19
+ return;
15
20
  if (eventName === "reconcile") {
16
- // Cursor aged out of the server ring: history may be incomplete.
17
- // Surface a non-fatal notice rather than silently rendering a gap.
21
+ // Cursor aged out of the server ring (or the turn changed under us):
22
+ // retained history may be incomplete. Surface recovery via the callback
23
+ // (reload canonical facts -> new cursor -> reconnect) instead of a fatal
24
+ // global error; the snapshot replay frames that follow are still applied
25
+ // as ordinary blocks (deduped by eventId, replay text skipped).
18
26
  const reason = typeof data.reason === "string" ? data.reason : "cursor_expired";
19
- setError(`会话事件窗口已过期(${reason}),建议刷新历史或新建会话。`);
27
+ onReconcile?.({
28
+ reason,
29
+ ...(typeof data.minSeq === "number" ? { minSeq: data.minSeq } : {}),
30
+ ...(typeof data.epoch === "string" ? { epoch: data.epoch } : {}),
31
+ ...(typeof data.latestTurnId === "string"
32
+ ? { latestTurnId: data.latestTurnId }
33
+ : {}),
34
+ ...(typeof data.latestEventId === "string"
35
+ ? { latestEventId: data.latestEventId }
36
+ : data.latestEventId === null
37
+ ? { latestEventId: null }
38
+ : {}),
39
+ });
20
40
  return;
21
41
  }
22
42
  const payload = data.data && typeof data.data === "object"
23
43
  ? data.data
24
44
  : data;
25
45
  const turnId = typeof data.turnId === "string" ? data.turnId : undefined;
46
+ if (turnId &&
47
+ (eventName === "agent_start" || eventName === "message_update") &&
48
+ refs.settledTurnIdsRef.current.has(turnId)) {
49
+ // A delayed start/text frame from a completed turn must not create a
50
+ // ghost bubble or set streaming again (M5.4).
51
+ return;
52
+ }
26
53
  let resolvedAssistantId = assistantId;
27
54
  if (turnId) {
28
55
  const mapped = refs.turnAssistantIdsRef.current.get(turnId);
@@ -102,16 +129,26 @@ export function createChatSseEventApplier(deps) {
102
129
  turnId !== refs.activeTurnIdRef.current) {
103
130
  return;
104
131
  }
105
- if (turnId)
132
+ if (turnId) {
133
+ refs.settledTurnIdsRef.current.delete(turnId);
106
134
  refs.activeTurnIdRef.current = turnId;
135
+ }
136
+ refs.streamingRef.current = true;
107
137
  setStreaming(true);
108
138
  setStreamingAssistantId(resolvedAssistantId);
109
139
  return;
110
140
  }
111
141
  if (eventName === "agent_settled" || eventName === "agent_end") {
112
- // Both are terminal: agent_end = SDK run finished, agent_settled =
113
- // post-turn continuation idle. Clear streaming and drop an empty
114
- // placeholder bubble so a settled turn never shows a stuck "…" row.
142
+ if (turnId)
143
+ refs.settledTurnIdsRef.current.add(turnId);
144
+ // `agent_end` means the SDK run ended, but the Console keeps the only
145
+ // GET stream through an idle grace for agent_settled/state reconciliation
146
+ // (M4.4). It must not prematurely close the visible turn.
147
+ if (eventName === "agent_end")
148
+ return;
149
+ if (turnId === refs.activeTurnIdRef.current)
150
+ refs.activeTurnIdRef.current = null;
151
+ refs.streamingRef.current = false;
115
152
  setStreaming(false);
116
153
  setStreamingAssistantId(null);
117
154
  if (eventName === "agent_settled" &&
@@ -0,0 +1,23 @@
1
+ /** Landing density resolved from measured empty-container height. */
2
+ /** Height thresholds in CSS px against `.oc-messages.is-empty`. */
3
+ export const LANDING_DENSITY_THRESHOLDS = {
4
+ comfortableMin: 620,
5
+ compactMin: 480,
6
+ };
7
+ /**
8
+ * Resolve Landing visual density from empty-container block size.
9
+ * comfortable >=620, compact 480-619, extra-compact <480.
10
+ */
11
+ export function resolveLandingDensity(heightPx) {
12
+ if (!Number.isFinite(heightPx) || heightPx < 0)
13
+ return "extra-compact";
14
+ if (heightPx >= LANDING_DENSITY_THRESHOLDS.comfortableMin)
15
+ return "comfortable";
16
+ if (heightPx >= LANDING_DENSITY_THRESHOLDS.compactMin)
17
+ return "compact";
18
+ return "extra-compact";
19
+ }
20
+ /** CSS class suffix for density attribute binding. */
21
+ export function landingDensityClass(density) {
22
+ return `is-landing-${density}`;
23
+ }
@@ -7,6 +7,9 @@ export function useChatRefs() {
7
7
  const inlineStreamSessionIdsRef = useRef(new Set());
8
8
  const assistantIdRef = useRef(null);
9
9
  const lastEventIdRef = useRef(null);
10
+ const sessionGenerationRef = useRef(0);
11
+ const eventsStreamStateRef = useRef(null);
12
+ const settledTurnIdsRef = useRef(new Set());
10
13
  const seenEventIdsRef = useRef(new Set());
11
14
  const turnAssistantIdsRef = useRef(new Map());
12
15
  const reconnectAbortRef = useRef(null);
@@ -24,6 +27,9 @@ export function useChatRefs() {
24
27
  inlineStreamSessionIdsRef,
25
28
  assistantIdRef,
26
29
  lastEventIdRef,
30
+ sessionGenerationRef,
31
+ eventsStreamStateRef,
32
+ settledTurnIdsRef,
27
33
  seenEventIdsRef,
28
34
  turnAssistantIdsRef,
29
35
  reconnectAbortRef,
@@ -39,6 +45,9 @@ export function useChatRefs() {
39
45
  inlineStreamSessionIdsRef,
40
46
  assistantIdRef,
41
47
  lastEventIdRef,
48
+ sessionGenerationRef,
49
+ eventsStreamStateRef,
50
+ settledTurnIdsRef,
42
51
  seenEventIdsRef,
43
52
  turnAssistantIdsRef,
44
53
  reconnectAbortRef,
@@ -0,0 +1,257 @@
1
+ /**
2
+ * Operator Chat — runtime snapshot store (design §2.2 / AC-04/05/06).
3
+ *
4
+ * A pure, framework-free state machine for the unified Live Runtime Snapshot.
5
+ * The browser treats the current Pi session as the single source of truth:
6
+ *
7
+ * status: idle → loading → ready | error
8
+ * reload: reloading (single-flight; no silent queueing) → success | failure
9
+ *
10
+ * `createRuntimeSnapshotStore` is intentionally side-effect free so it can be
11
+ * unit-tested in a plain Node environment; the React binding (fetch + attach)
12
+ * lives in `useRuntimeSnapshot.ts`.
13
+ */
14
+ export const runtimeSnapshotInitialState = {
15
+ status: "idle",
16
+ snapshot: null,
17
+ error: null,
18
+ reloading: false,
19
+ reloadError: null,
20
+ mutationBusy: false,
21
+ mutationToken: null,
22
+ };
23
+ /** Parse a failed HTTP body into a structured snapshot error (never empty). */
24
+ export function parseRuntimeSnapshotError(body) {
25
+ if (!body || typeof body !== "object") {
26
+ return { message: "runtime snapshot request failed" };
27
+ }
28
+ const record = body;
29
+ const err = record.error;
30
+ if (err && typeof err === "object") {
31
+ const e = err;
32
+ return {
33
+ ...(typeof e.code === "string" && e.code ? { code: e.code } : {}),
34
+ ...(typeof record.status === "number" ? { status: record.status } : {}),
35
+ message: typeof e.message === "string" && e.message
36
+ ? e.message
37
+ : "runtime snapshot request failed",
38
+ };
39
+ }
40
+ return {
41
+ ...(typeof record.status === "number" ? { status: record.status } : {}),
42
+ message: typeof record === "object" && "message" in record &&
43
+ typeof record.message === "string" &&
44
+ record.message
45
+ ? record.message
46
+ : "runtime snapshot request failed",
47
+ };
48
+ }
49
+ export function runtimeSnapshotReducer(state, action) {
50
+ switch (action.type) {
51
+ case "load-start":
52
+ // Stale replies are dropped by the fetch layer (sessionId guard); the
53
+ // reducer stays a deterministic state machine. The previous snapshot
54
+ // remains visible during a background refresh.
55
+ return {
56
+ ...state,
57
+ sessionId: action.sessionId,
58
+ status: "loading",
59
+ error: null,
60
+ };
61
+ case "load-success":
62
+ // RF-04: monotonic revision guard — a stale (lower-revision) reply for
63
+ // the current session must never overwrite a newer snapshot, so a slow
64
+ // refresh cannot reorder-overwrite the current session selection. The
65
+ // fetch layer already drops replies for other sessions (sessionId guard).
66
+ if (state.snapshot &&
67
+ state.sessionId === action.sessionId &&
68
+ action.snapshot.revision < state.snapshot.revision) {
69
+ return state;
70
+ }
71
+ return {
72
+ ...state,
73
+ sessionId: action.sessionId,
74
+ status: "ready",
75
+ snapshot: action.snapshot,
76
+ error: null,
77
+ reloadError: null,
78
+ reloading: false,
79
+ };
80
+ case "load-failure":
81
+ // AC-06: a failed load is a STRUCTURED error state — never folded
82
+ // into a fake empty snapshot.
83
+ return {
84
+ ...state,
85
+ sessionId: action.sessionId,
86
+ status: "error",
87
+ error: action.error,
88
+ snapshot: null,
89
+ };
90
+ case "reload-start":
91
+ // Single-flight: a reload already in progress is NOT queued (AC-06).
92
+ if (state.reloading)
93
+ return state;
94
+ return {
95
+ ...state,
96
+ reloading: true,
97
+ reloadError: null,
98
+ ...(state.status === "error" ? { error: null } : {}),
99
+ };
100
+ case "reload-success":
101
+ // Atomic replacement: the new snapshot supersedes the old one and
102
+ // every open panel reads the same revision.
103
+ return {
104
+ ...state,
105
+ status: "ready",
106
+ snapshot: action.snapshot,
107
+ reloading: false,
108
+ reloadError: null,
109
+ error: null,
110
+ sessionId: action.snapshot.sessionId || state.sessionId,
111
+ };
112
+ case "reload-failure":
113
+ return {
114
+ ...state,
115
+ reloading: false,
116
+ reloadError: action.error,
117
+ };
118
+ case "mutation-start":
119
+ return {
120
+ ...state,
121
+ mutationBusy: true,
122
+ mutationToken: action.token,
123
+ };
124
+ case "mutation-adopt":
125
+ // RF2-02: atomic adoption of a mutation response snapshot. Three
126
+ // guards are evaluated BEFORE any write:
127
+ // - mutationToken: only the LATEST mutation may adopt — a stale
128
+ // out-of-order response is dropped entirely;
129
+ // - sessionId: never adopt a mutation response into a state that
130
+ // belongs to a different session;
131
+ // - revision monotonic: equal/higher revisions adopt, lower reject
132
+ // (a stale reply can never reorder-overwrite the selection).
133
+ // The action NEVER settles mutationBusy (mutation-settle owns that)
134
+ // and never passes through an intermediate stale/loading state.
135
+ if (state.mutationToken !== action.token)
136
+ return state;
137
+ if (state.sessionId !== undefined &&
138
+ state.sessionId !== action.sessionId)
139
+ return state;
140
+ if (state.snapshot &&
141
+ action.snapshot.revision < state.snapshot.revision)
142
+ return state;
143
+ return {
144
+ ...state,
145
+ sessionId: action.sessionId,
146
+ status: "ready",
147
+ snapshot: action.snapshot,
148
+ error: null,
149
+ reloadError: null,
150
+ reloading: false,
151
+ };
152
+ case "mutation-settle":
153
+ // RF-04: only the LATEST request token settles the busy state; a
154
+ // stale (earlier) settle must not clear busy while a newer mutation
155
+ // is still in flight.
156
+ if (state.mutationToken !== action.token)
157
+ return state;
158
+ return {
159
+ ...state,
160
+ mutationBusy: false,
161
+ mutationToken: null,
162
+ };
163
+ case "mutation-reset":
164
+ // RF3-02: a session switch immediately abandons the in-flight
165
+ // mutation lifecycle — the new session must not inherit the old
166
+ // session's busy state. The adopted snapshot/status stay untouched
167
+ // (a load-start for the new session follows); stale adopt/settle
168
+ // actions then become no-ops via the token + sessionId guards.
169
+ return {
170
+ ...state,
171
+ mutationBusy: false,
172
+ mutationToken: null,
173
+ };
174
+ case "reset":
175
+ return { ...runtimeSnapshotInitialState };
176
+ default:
177
+ return state;
178
+ }
179
+ }
180
+ /**
181
+ * Derive the session/runtimeContext selection patch from an ADOPTED snapshot
182
+ * (RF3-01). The hydrate effect in `useRuntimeControls` feeds this patch into
183
+ * `mergeSessionRuntime` / `runtimeContext`, so derived state only ever comes
184
+ * from a snapshot the reducer guards accepted — the mutation success branch
185
+ * never writes session/runtimeContext directly. Missing fields are omitted;
186
+ * `auto` resolves to the Pi effective thinking level.
187
+ */
188
+ export function runtimeSelectionFromSnapshot(snapshot) {
189
+ const selection = snapshot.selection;
190
+ return {
191
+ ...(selection.model
192
+ ? {
193
+ model: {
194
+ provider: selection.model.provider,
195
+ modelId: selection.model.id,
196
+ },
197
+ }
198
+ : {}),
199
+ ...(selection.thinkingSelection === "auto"
200
+ ? selection.effectiveThinkingLevel
201
+ ? { thinkingLevel: selection.effectiveThinkingLevel }
202
+ : {}
203
+ : { thinkingLevel: selection.thinkingSelection }),
204
+ };
205
+ }
206
+ /**
207
+ * RF4: merge an incoming `/runtime-context` response with the ADOPTED snapshot
208
+ * (coordination seam genuinely called by the production `useRuntimeControls`
209
+ * context hydration effect; tests import this same symbol — no copied logic).
210
+ *
211
+ * Content fields (systemPrompt/resources/suffix/readOnly) always come from the
212
+ * response. model/thinkingLevel NEVER come from the response body — the
213
+ * backend returns them without revision semantics, so a late reply for the
214
+ * same session could carry a stale selection. Selection is derived exclusively
215
+ * from the adopted snapshot when it matches the current session (same
216
+ * derivation as the snapshot hydration effect: `runtimeSelectionFromSnapshot`,
217
+ * `auto` resolves to the Pi effective level); otherwise the current derived
218
+ * values are preserved, or omitted when absent. The store is never written
219
+ * here, so the snapshot hydration effect never re-runs.
220
+ */
221
+ export function mergeRuntimeContextWithSnapshot(current, context, snapshot, sessionId) {
222
+ const selection = snapshot && snapshot.sessionId === sessionId
223
+ ? runtimeSelectionFromSnapshot(snapshot)
224
+ : {};
225
+ const model = selection.model ?? current?.model;
226
+ const thinkingLevel = selection.thinkingLevel ?? current?.thinkingLevel;
227
+ return {
228
+ readOnly: context.readOnly,
229
+ systemPrompt: context.systemPrompt,
230
+ resources: context.resources,
231
+ suffix: context.suffix,
232
+ ...(model ? { model } : {}),
233
+ ...(thinkingLevel ? { thinkingLevel } : {}),
234
+ };
235
+ }
236
+ /** Create a fresh snapshot store (framework-free; React binding adds fetch). */
237
+ export function createRuntimeSnapshotStore(initial = runtimeSnapshotInitialState) {
238
+ let state = initial;
239
+ const listeners = new Set();
240
+ return {
241
+ getState: () => state,
242
+ dispatch: (action) => {
243
+ state = runtimeSnapshotReducer(state, action);
244
+ for (const listener of listeners)
245
+ listener();
246
+ return state;
247
+ },
248
+ subscribe: (listener) => {
249
+ listeners.add(listener);
250
+ return () => listeners.delete(listener);
251
+ },
252
+ };
253
+ }
254
+ /** True when the given error is the busy 409 (PI_SESSION_BUSY). */
255
+ export function isSnapshotBusyError(error) {
256
+ return error?.code === "PI_SESSION_BUSY";
257
+ }