@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.
- package/CHANGELOG.md +67 -16
- package/dist/application/task-lifecycle/advance.js +309 -9
- package/dist/application/task-lifecycle/gates.js +50 -0
- package/dist/application/task-lifecycle/observe.js +11 -2
- package/dist/application/task-lifecycle/plan-transitions.js +13 -21
- package/dist/commands/init-upgrade.js +32 -1
- package/dist/commands/init.js +94 -3
- package/dist/executors/dag-pi-executor.js +18 -3
- package/dist/executors/shell-executor.js +4 -91
- package/dist/executors/shell-write-guard.js +26 -8
- package/dist/shared/operator/capabilities.js +98 -44
- package/dist/shared/resilient-git.js +133 -0
- package/dist/task/source-prepare/artifact-meta.js +137 -0
- package/dist/task/source-prepare/index.js +2 -0
- package/dist/task/source-prepare/parse-intent.js +58 -10
- package/dist/task/source-prepare/prepare.js +229 -18
- package/dist/task/source-prepare/reference-integrity.js +18 -2
- package/dist/task/source-prepare/semantic-intake.js +404 -0
- package/dist/worker/console/app-data.js +2 -0
- package/dist/worker/console/chat/chat-event-store.js +190 -25
- package/dist/worker/console/chat/model-resolver.js +17 -0
- package/dist/worker/console/chat/pi-console-config.js +250 -32
- package/dist/worker/console/chat/pi-runtime.js +1007 -188
- package/dist/worker/console/chat/resource-loader.js +5 -4
- package/dist/worker/console/chat/routes.js +495 -157
- package/dist/worker/console/chat/runtime-context.js +48 -12
- package/dist/worker/console/chat/runtime-selection.js +59 -0
- package/dist/worker/console/chat/session-store.js +39 -0
- package/dist/worker/console/chat/shortcuts.js +1 -0
- package/dist/worker/console/chat/tool-adapter.js +9 -3
- package/dist/worker/console/chat/tools.js +5 -1
- package/dist/worker/console/dag-execution-receipt.js +380 -0
- package/dist/worker/console/interview/grill-me.js +7 -6
- package/dist/worker/console/operator-actions.js +785 -85
- package/dist/worker/console/prd-intake-bridge.js +393 -0
- package/dist/worker/console/recovery-cta.js +35 -6
- package/dist/worker/console/server.js +8 -15
- package/dist/worker/console/static/assets/index-BQkhJpV8.css +1 -0
- package/dist/worker/console/static/assets/index-BpuHmlSP.js +29 -0
- package/dist/worker/console/static/index.html +2 -2
- package/dist/worker/console/static-src/app/console-types.js +1 -0
- package/dist/worker/console/static-src/app/usePrdImport.js +2 -1
- package/dist/worker/console/static-src/app/useRecoveryActions.js +24 -1
- package/dist/worker/console/static-src/app/useRecoveryConsole.js +19 -1
- package/dist/worker/console/static-src/app/useTaskWizard.js +57 -2
- package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +45 -8
- package/dist/worker/console/static-src/operator-chat/landing-density.js +23 -0
- package/dist/worker/console/static-src/operator-chat/refs.js +9 -0
- package/dist/worker/console/static-src/operator-chat/runtime-snapshot-store.js +257 -0
- package/dist/worker/console/static-src/operator-chat/session-title-watcher.js +128 -0
- package/dist/worker/console/static-src/operator-chat/sidebar-split.js +90 -0
- package/dist/worker/console/static-src/operator-chat/spatial-overlay.js +37 -0
- package/dist/worker/console/static-src/operator-chat/useChatSessions.js +125 -22
- package/dist/worker/console/static-src/operator-chat/useChatStream.js +215 -184
- package/dist/worker/console/static-src/operator-chat/useChatThread.js +49 -5
- package/dist/worker/console/static-src/operator-chat/useComposer.js +17 -0
- package/dist/worker/console/static-src/operator-chat/useOverlayFocus.js +84 -0
- package/dist/worker/console/static-src/operator-chat/useRuntimeControls.js +225 -74
- package/dist/worker/console/static-src/operator-chat/useRuntimeSnapshot.js +196 -0
- package/dist/worker/console/static-src/operator-chat/useWorkspaceLayout.js +58 -0
- package/dist/worker/console/static-src/operator-chat/workspace-layout-mode.js +31 -0
- package/dist/worker/delivery/final-verification.js +13 -5
- package/dist/worker/delivery/package.js +31 -19
- package/dist/worker/delivery/verification-bundle.js +6 -4
- package/dist/worker/observability/read-model.js +3 -0
- package/dist/worker/observe/static/operator-chrome.css +5 -2
- package/dist/worker/observe/static/operator-chrome.js +6 -1
- package/dist/worker/observe/static/styles.css +39 -9
- package/dist/workflows/dag/backend-test-case-coverage-analysis.js +33 -462
- package/dist/workflows/dag/backend-test-case-manifest.js +0 -4
- package/dist/workflows/dag/backend-test-markdown-workflow.js +1 -25
- package/dist/workflows/dag/backend-test-module-stem.js +0 -5
- package/dist/workflows/dag/backend-test-pytest-collection.js +24 -345
- package/dist/workflows/dag/backend-test-scenario-param.js +82 -269
- package/dist/workflows/dag/backend-test-writer-completeness.js +16 -47
- package/dist/workflows/dag/dynamic-runtime/map.js +8 -24
- package/dist/workflows/dag/failure-category.js +3 -0
- package/dist/workflows/dag/frontend-worktree-diff.js +12 -27
- package/dist/workflows/dag/init-hybrid.js +49 -55
- package/dist/workflows/dag/node-execution.js +3 -2
- package/dist/workflows/dag/retry-policy.js +44 -11
- package/dist/workflows/dag/runner.js +6 -0
- package/dist/workflows/dag/scheduler.js +49 -1
- package/dist/workflows/dag/types.js +0 -7
- package/dist/workflows/dag/validate.js +16 -2
- package/dist/workflows/dag/workspace-checkpoint.js +8 -27
- package/docs/templates/agent-dag.schema.json +4 -4
- package/docs/templates/backend-test-dag.json +29 -32
- package/harness.json +1 -1
- package/package.json +1 -1
- package/skills/local-jacoco-coverage/SKILL.md +281 -0
- package/skills/local-jacoco-coverage/references/requirement-to-source-mapping.md +85 -0
- package/skills/local-jacoco-coverage/references/runtime-alignment.md +106 -0
- package/skills/local-jacoco-coverage/scripts/run-coverage-analysis.sh +148 -0
- package/skills/local-jacoco-coverage/scripts/start-jacoco-agent.sh +110 -0
- package/skills/loop-agent/references/command-reference.md +3 -1
- package/skills/loop-agent/references/source-and-plan-practice.md +13 -0
- package/skills/loop-agent/references/task-workflow.md +4 -0
- package/dist/worker/console/chat/instruction-skills.js +0 -217
- package/dist/worker/console/static/assets/index-CnUXAqxG.css +0 -1
- 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-
|
|
10
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
}, [
|
|
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
|
|
17
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
//
|
|
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
|
+
}
|