@tea-agent/loop-agent 0.33.7-beta.0 → 0.34.0
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 +38 -17
- package/dist/application/task-lifecycle/advance.js +254 -4
- package/dist/application/task-lifecycle/gates.js +50 -0
- package/dist/application/task-lifecycle/observe.js +11 -2
- package/dist/commands/init-upgrade.js +32 -1
- package/dist/commands/init.js +94 -3
- package/dist/executors/shell-executor.js +4 -91
- package/dist/executors/shell-write-guard.js +26 -8
- package/dist/shared/operator/capabilities.js +72 -42
- 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 +180 -16
- 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/operator-actions.js +559 -68
- 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-CMHovlqG.js +32 -0
- package/dist/worker/console/static/index.html +2 -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/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/frontend-worktree-diff.js +12 -27
- package/dist/workflows/dag/init-hybrid.js +34 -46
- package/dist/workflows/dag/types.js +0 -7
- package/dist/workflows/dag/workspace-checkpoint.js +8 -27
- 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
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { useEffect, useRef } from "react";
|
|
2
|
+
const FOCUSABLE = 'a[href],button:not([disabled]),textarea:not([disabled]),input:not([disabled]),select:not([disabled]),[tabindex]:not([tabindex="-1"])';
|
|
3
|
+
/**
|
|
4
|
+
* Trap focus inside an open overlay root; restore focus to the prior trigger
|
|
5
|
+
* on close. Escape is handled by the caller (or optional onEscape).
|
|
6
|
+
*
|
|
7
|
+
* Restore contract: open true→false and unmount-while-open share a single
|
|
8
|
+
* restore path (cleanup). A wasOpen flag prevents double restore when the
|
|
9
|
+
* transition effect re-runs after cleanup.
|
|
10
|
+
*/
|
|
11
|
+
export function useOverlayFocus(options) {
|
|
12
|
+
const { open, containerRef, triggerRef, onEscape } = options;
|
|
13
|
+
const previousFocusRef = useRef(null);
|
|
14
|
+
const wasOpenRef = useRef(false);
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
if (open) {
|
|
17
|
+
if (!wasOpenRef.current) {
|
|
18
|
+
const active = document.activeElement;
|
|
19
|
+
previousFocusRef.current =
|
|
20
|
+
triggerRef?.current ??
|
|
21
|
+
(active instanceof HTMLElement ? active : null);
|
|
22
|
+
const root = containerRef.current;
|
|
23
|
+
if (root) {
|
|
24
|
+
const first = root.querySelector(FOCUSABLE);
|
|
25
|
+
(first ?? root).focus?.();
|
|
26
|
+
}
|
|
27
|
+
wasOpenRef.current = true;
|
|
28
|
+
}
|
|
29
|
+
// Single restore path for true→false (cleanup before next effect)
|
|
30
|
+
// and for unmount-while-open (cleanup on unmount).
|
|
31
|
+
return () => {
|
|
32
|
+
if (!wasOpenRef.current)
|
|
33
|
+
return;
|
|
34
|
+
const restore = triggerRef?.current ?? previousFocusRef.current;
|
|
35
|
+
if (restore && document.contains(restore)) {
|
|
36
|
+
restore.focus();
|
|
37
|
+
}
|
|
38
|
+
previousFocusRef.current = null;
|
|
39
|
+
wasOpenRef.current = false;
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
// open === false: ensure flag is clear; restore already handled by
|
|
43
|
+
// the previous open-effect cleanup when transitioning true→false.
|
|
44
|
+
wasOpenRef.current = false;
|
|
45
|
+
return undefined;
|
|
46
|
+
}, [open, containerRef, triggerRef]);
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
if (!open)
|
|
49
|
+
return;
|
|
50
|
+
const root = containerRef.current;
|
|
51
|
+
if (!root)
|
|
52
|
+
return;
|
|
53
|
+
const onKeyDown = (event) => {
|
|
54
|
+
if (event.key === "Escape") {
|
|
55
|
+
event.preventDefault();
|
|
56
|
+
onEscape?.();
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (event.key !== "Tab")
|
|
60
|
+
return;
|
|
61
|
+
const nodes = Array.from(root.querySelectorAll(FOCUSABLE)).filter((el) => !el.hasAttribute("disabled") && el.tabIndex !== -1);
|
|
62
|
+
if (nodes.length === 0) {
|
|
63
|
+
event.preventDefault();
|
|
64
|
+
root.focus?.();
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const first = nodes[0];
|
|
68
|
+
const last = nodes[nodes.length - 1];
|
|
69
|
+
const active = document.activeElement;
|
|
70
|
+
if (event.shiftKey) {
|
|
71
|
+
if (active === first || !root.contains(active)) {
|
|
72
|
+
event.preventDefault();
|
|
73
|
+
last.focus();
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
else if (active === last || !root.contains(active)) {
|
|
77
|
+
event.preventDefault();
|
|
78
|
+
first.focus();
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
document.addEventListener("keydown", onKeyDown);
|
|
82
|
+
return () => document.removeEventListener("keydown", onKeyDown);
|
|
83
|
+
}, [open, containerRef, onEscape]);
|
|
84
|
+
}
|
|
@@ -1,19 +1,44 @@
|
|
|
1
1
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
-
import { formatModelRef, mergeSessionRuntime,
|
|
2
|
+
import { formatModelRef, mergeSessionRuntime, sessionRuntimeMatches, } from "../../chat/runtime-selection.js";
|
|
3
3
|
import { confirmationToken } from "./format.js";
|
|
4
|
+
import { mergeRuntimeContextWithSnapshot, runtimeSelectionFromSnapshot, } from "./runtime-snapshot-store.js";
|
|
4
5
|
import { buildToolCatalog } from "./tools-catalog.js";
|
|
6
|
+
import { adoptMutationSnapshot, dispatchRuntimeSnapshot, getRuntimeSnapshotState, refreshRuntimeSnapshot, settleMutationLifecycle, useRuntimeSnapshot, } from "./useRuntimeSnapshot.js";
|
|
7
|
+
/**
|
|
8
|
+
* Monotonic mutation sequence shared across switchRuntime calls (RF-04). Each
|
|
9
|
+
* mutation captures its own token; only the LATEST started token may settle
|
|
10
|
+
* mutationBusy/switching, so a slow earlier response can never clear busy
|
|
11
|
+
* while a newer mutation is still in flight.
|
|
12
|
+
*/
|
|
13
|
+
let mutationSeq = 0;
|
|
5
14
|
/** Capabilities catalog, model / thinking-level switcher, runtime-context
|
|
6
15
|
* popover and the outside-click dismissal for both transient menus. */
|
|
7
16
|
export function useRuntimeControls(params) {
|
|
8
17
|
const { origin, session, setSession, refs, setError } = params;
|
|
9
18
|
const [capabilities, setCapabilities] = useState(null);
|
|
10
19
|
const [models, setModels] = useState([]);
|
|
20
|
+
const [modelsStatus, setModelsStatus] = useState("idle");
|
|
21
|
+
const [modelsError, setModelsError] = useState(null);
|
|
11
22
|
const [thinkingLevels, setThinkingLevels] = useState([]);
|
|
12
23
|
const [runtimeContext, setRuntimeContext] = useState(null);
|
|
24
|
+
const [runtimeContextError, setRuntimeContextError] = useState(null);
|
|
25
|
+
const [switching, setSwitching] = useState(false);
|
|
13
26
|
const [toolsOpen, setToolsOpen] = useState(false);
|
|
14
27
|
const [runtimeContextOpen, setRuntimeContextOpen] = useState(false);
|
|
15
28
|
const toolsMenuRef = useRef(null);
|
|
16
29
|
const runtimeContextMenuRef = useRef(null);
|
|
30
|
+
const switchTokenRef = useRef(0);
|
|
31
|
+
// RF3-02: a session switch immediately abandons the in-flight mutation
|
|
32
|
+
// lifecycle — the local token is invalidated and switching cleared, so a
|
|
33
|
+
// late response from the previous session can never settle the new
|
|
34
|
+
// session's busy/switching. (The store side is reset synchronously by
|
|
35
|
+
// attachRuntimeSnapshotController's mutation-reset.)
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
switchTokenRef.current = -1;
|
|
38
|
+
setSwitching(false);
|
|
39
|
+
}, [session?.sessionId, setSwitching]);
|
|
40
|
+
const snapshotState = useRuntimeSnapshot();
|
|
41
|
+
const snapshot = snapshotState.snapshot;
|
|
17
42
|
const loadCapabilities = useCallback(async () => {
|
|
18
43
|
try {
|
|
19
44
|
const res = await fetch(`${origin}/api/operator/v1/chat/capabilities`);
|
|
@@ -29,106 +54,202 @@ export function useRuntimeControls(params) {
|
|
|
29
54
|
useEffect(() => {
|
|
30
55
|
void loadCapabilities();
|
|
31
56
|
}, [loadCapabilities]);
|
|
32
|
-
|
|
57
|
+
// Models + thinking levels: AC-01/AC-06 — the GET may reopen a historical
|
|
58
|
+
// session server-side; a failure is a STRUCTURED error state (with retry),
|
|
59
|
+
// never silently folded into an empty list.
|
|
60
|
+
const loadModels = useCallback(async () => {
|
|
33
61
|
if (!session) {
|
|
34
62
|
setModels([]);
|
|
35
|
-
|
|
63
|
+
setModelsStatus("idle");
|
|
36
64
|
return;
|
|
37
65
|
}
|
|
38
66
|
const sessionId = session.sessionId;
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
67
|
+
setModelsStatus("loading");
|
|
68
|
+
setModelsError(null);
|
|
69
|
+
try {
|
|
70
|
+
const res = await fetch(`${origin}/api/operator/v1/chat/sessions/${encodeURIComponent(sessionId)}/models`, { credentials: "include" });
|
|
71
|
+
const body = (await res.json().catch(() => null));
|
|
43
72
|
// Guard against a stale response landing after the user switched/
|
|
44
73
|
// created/forked a session: only adopt models for the session we
|
|
45
74
|
// actually requested them for.
|
|
46
|
-
if (
|
|
75
|
+
if (refs.sessionRef.current?.sessionId !== sessionId)
|
|
76
|
+
return;
|
|
77
|
+
if (!res.ok || !body) {
|
|
78
|
+
setModelsStatus("error");
|
|
79
|
+
setModelsError(body
|
|
80
|
+
?.error?.message ?? `模型列表读取失败(HTTP ${res.status})`);
|
|
47
81
|
return;
|
|
82
|
+
}
|
|
48
83
|
setModels(body.models ?? []);
|
|
49
84
|
setThinkingLevels(body.thinkingLevels ?? []);
|
|
50
|
-
|
|
85
|
+
setModelsStatus("ready");
|
|
86
|
+
}
|
|
87
|
+
catch (reason) {
|
|
88
|
+
if (refs.sessionRef.current?.sessionId !== sessionId)
|
|
89
|
+
return;
|
|
90
|
+
setModelsStatus("error");
|
|
91
|
+
setModelsError(reason instanceof Error ? reason.message : String(reason));
|
|
92
|
+
}
|
|
93
|
+
}, [origin, session, refs]);
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
void loadModels();
|
|
96
|
+
}, [loadModels]);
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
if (!session) {
|
|
99
|
+
setRuntimeContext(null);
|
|
100
|
+
setRuntimeContextError(null);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const sessionId = session.sessionId;
|
|
104
|
+
const base = `${origin}/api/operator/v1/chat/sessions/${encodeURIComponent(sessionId)}`;
|
|
105
|
+
setRuntimeContextError(null);
|
|
51
106
|
void fetch(`${base}/runtime-context`, { credentials: "include" })
|
|
52
|
-
.then((r) =>
|
|
53
|
-
.
|
|
107
|
+
.then(async (r) => {
|
|
108
|
+
const body = await r.json().catch(() => null);
|
|
54
109
|
// Stale-response guard: a slow runtime-context reply for session A
|
|
55
110
|
// must not overwrite the context/session state of the now-current
|
|
56
111
|
// session B.
|
|
57
112
|
if (!body?.context || refs.sessionRef.current?.sessionId !== sessionId)
|
|
58
113
|
return;
|
|
59
|
-
|
|
114
|
+
// RF5: the response body NEVER writes model/thinkingLevel — it
|
|
115
|
+
// carries no revision semantics, so a late reply for the same
|
|
116
|
+
// session could hold a stale selection. Selection is derived only
|
|
117
|
+
// from the LATEST adopted snapshot, read HERE at .then time (right
|
|
118
|
+
// before the setRuntimeContext functional updater runs) via the
|
|
119
|
+
// production read-only accessor getRuntimeSnapshotState() — the
|
|
120
|
+
// only immediate authority when the store has already adopted a
|
|
121
|
+
// new revision but this component has not rerendered yet. A
|
|
122
|
+
// render-synchronized value (snapshotRef) could still be the old
|
|
123
|
+
// revision in that window, so it is removed entirely.
|
|
124
|
+
// systemPrompt/resources/suffix/readOnly still come from the
|
|
125
|
+
// response. Zero session writes and zero store writes: the
|
|
126
|
+
// snapshot hydration effect never re-runs and the menu/popover
|
|
127
|
+
// selection never jumps back.
|
|
60
128
|
const ctx = body.context;
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
if (sessionRuntimeMatches(current, patch))
|
|
69
|
-
return current;
|
|
70
|
-
const merged = mergeSessionRuntime(current, patch);
|
|
71
|
-
refs.sessionRef.current = merged;
|
|
72
|
-
return merged;
|
|
73
|
-
});
|
|
74
|
-
});
|
|
75
|
-
}, [origin, session?.sessionId, refs, setSession]);
|
|
76
|
-
const switchRuntime = useCallback(async (body) => {
|
|
77
|
-
if (!session)
|
|
78
|
-
return;
|
|
79
|
-
// Capture the target session so a slow model/thinking response cannot
|
|
80
|
-
// write its result into a different (newly created/forked/switched)
|
|
81
|
-
// session the user has since moved to.
|
|
82
|
-
const targetSessionId = session.sessionId;
|
|
83
|
-
const res = await fetch(`${origin}/api/operator/v1/chat/sessions/${encodeURIComponent(session.sessionId)}/model`, {
|
|
84
|
-
method: "POST",
|
|
85
|
-
credentials: "include",
|
|
86
|
-
headers: {
|
|
87
|
-
"content-type": "application/json",
|
|
88
|
-
"x-loop-console-confirmation": confirmationToken(),
|
|
89
|
-
},
|
|
90
|
-
body: JSON.stringify(body),
|
|
129
|
+
const adoptedSnapshot = getRuntimeSnapshotState().snapshot;
|
|
130
|
+
setRuntimeContext((current) => mergeRuntimeContextWithSnapshot(current, ctx, adoptedSnapshot, sessionId));
|
|
131
|
+
})
|
|
132
|
+
.catch(() => {
|
|
133
|
+
if (refs.sessionRef.current?.sessionId !== sessionId)
|
|
134
|
+
return;
|
|
135
|
+
setRuntimeContextError("注入上下文读取失败,请重试");
|
|
91
136
|
});
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
// Drop the result entirely if the active session changed mid-flight.
|
|
102
|
-
if (refs.sessionRef.current?.sessionId !== targetSessionId)
|
|
137
|
+
}, [origin, session?.sessionId, refs]);
|
|
138
|
+
// AC-01/RF3-01: hydrate the Composer from the unified snapshot — real
|
|
139
|
+
// model and thinking selection; `auto` displays the Pi actual effective
|
|
140
|
+
// level. Derived session/runtimeContext state ONLY comes from a snapshot
|
|
141
|
+
// the reducer guards accepted: the mutation success branch never writes
|
|
142
|
+
// session/runtimeContext directly, so this effect re-runs on adoption and
|
|
143
|
+
// is a no-op for rejected/missing snapshots (zero derived writes).
|
|
144
|
+
useEffect(() => {
|
|
145
|
+
if (!snapshot || !session || snapshot.sessionId !== session.sessionId)
|
|
103
146
|
return;
|
|
147
|
+
const patch = runtimeSelectionFromSnapshot(snapshot);
|
|
104
148
|
setSession((current) => {
|
|
105
|
-
if (!current || current.sessionId !==
|
|
149
|
+
if (!current || current.sessionId !== snapshot.sessionId)
|
|
106
150
|
return current;
|
|
107
|
-
const patch = runtimeSelectionFromRecord(payload.session);
|
|
108
151
|
if (sessionRuntimeMatches(current, patch))
|
|
109
152
|
return current;
|
|
110
153
|
const merged = mergeSessionRuntime(current, patch);
|
|
111
154
|
refs.sessionRef.current = merged;
|
|
112
155
|
return merged;
|
|
113
156
|
});
|
|
114
|
-
setRuntimeContext((current) =>
|
|
115
|
-
|
|
157
|
+
setRuntimeContext((current) => {
|
|
158
|
+
if (!current)
|
|
159
|
+
return current;
|
|
160
|
+
const next = {
|
|
116
161
|
...current,
|
|
117
|
-
...(
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
provider: payload.session.modelProvider,
|
|
121
|
-
modelId: payload.session.modelId,
|
|
122
|
-
},
|
|
123
|
-
}
|
|
162
|
+
...(patch.model ? { model: patch.model } : {}),
|
|
163
|
+
...(patch.thinkingLevel
|
|
164
|
+
? { thinkingLevel: patch.thinkingLevel }
|
|
124
165
|
: {}),
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
166
|
+
};
|
|
167
|
+
if (next.model?.provider === current.model?.provider &&
|
|
168
|
+
next.model?.modelId === current.model?.modelId &&
|
|
169
|
+
next.thinkingLevel === current.thinkingLevel) {
|
|
170
|
+
return current;
|
|
171
|
+
}
|
|
172
|
+
return next;
|
|
173
|
+
});
|
|
174
|
+
}, [snapshot, session?.sessionId, refs, setSession, setRuntimeContext]);
|
|
175
|
+
const switchRuntime = useCallback(async (body) => {
|
|
176
|
+
if (!session)
|
|
177
|
+
return false;
|
|
178
|
+
// Capture the target session so a slow model/thinking response cannot
|
|
179
|
+
// write its result into a different (newly created/forked/switched)
|
|
180
|
+
// session the user has since moved to.
|
|
181
|
+
const targetSessionId = session.sessionId;
|
|
182
|
+
// RF-04: token the mutation start; only the latest token's settle
|
|
183
|
+
// clears mutationBusy/switching (stale earlier settles can't win).
|
|
184
|
+
const token = ++mutationSeq;
|
|
185
|
+
switchTokenRef.current = token;
|
|
186
|
+
dispatchRuntimeSnapshot({ type: "mutation-start", token });
|
|
187
|
+
setSwitching(true);
|
|
188
|
+
try {
|
|
189
|
+
const res = await fetch(`${origin}/api/operator/v1/chat/sessions/${encodeURIComponent(session.sessionId)}/model`, {
|
|
190
|
+
method: "POST",
|
|
191
|
+
credentials: "include",
|
|
192
|
+
headers: {
|
|
193
|
+
"content-type": "application/json",
|
|
194
|
+
"x-loop-console-confirmation": confirmationToken(),
|
|
195
|
+
},
|
|
196
|
+
body: JSON.stringify(body),
|
|
197
|
+
});
|
|
198
|
+
const payload = (await res.json().catch(() => ({})));
|
|
199
|
+
// RF2-02: an out-of-order response (its token is no longer the
|
|
200
|
+
// latest started mutation) is dropped ENTIRELY on BOTH the success
|
|
201
|
+
// and the failure branch: no setSession/setRuntimeContext, no
|
|
202
|
+
// mutation-adopt, no busy settle, no refresh — the newest mutation
|
|
203
|
+
// owns the UI and a slow earlier reply can never overwrite it.
|
|
204
|
+
if (switchTokenRef.current !== token)
|
|
205
|
+
return false;
|
|
206
|
+
if (!res.ok) {
|
|
207
|
+
// RF2-01: a non-2xx response (e.g. 503 PI_RUNTIME_UNAVAILABLE —
|
|
208
|
+
// the mutation already applied server-side but the snapshot
|
|
209
|
+
// projection failed) for the still-current session shows the
|
|
210
|
+
// structured error, then triggers a REAL GET
|
|
211
|
+
// /runtime-snapshot so the store converges on the server
|
|
212
|
+
// selection via the load-success revision guard. When the
|
|
213
|
+
// session switched or the response is stale: no error, no
|
|
214
|
+
// refresh, no state writes.
|
|
215
|
+
if (refs.sessionRef.current?.sessionId === targetSessionId) {
|
|
216
|
+
setError(payload.error?.message ??
|
|
217
|
+
`模型或思考等级切换失败(HTTP ${res.status})`);
|
|
218
|
+
void refreshRuntimeSnapshot();
|
|
219
|
+
}
|
|
220
|
+
return false;
|
|
130
221
|
}
|
|
131
|
-
|
|
222
|
+
if (!payload.session)
|
|
223
|
+
return false;
|
|
224
|
+
// Drop the result entirely if the active session changed mid-flight.
|
|
225
|
+
if (refs.sessionRef.current?.sessionId !== targetSessionId)
|
|
226
|
+
return false;
|
|
227
|
+
// RF3-01: derived session/runtimeContext state is ONLY written from
|
|
228
|
+
// a snapshot the reducer's three guards (latest mutationToken,
|
|
229
|
+
// current sessionId, monotonic revision) accepted — no direct
|
|
230
|
+
// setSession/setRuntimeContext here. A missing snapshot returns
|
|
231
|
+
// false with zero derived writes; on adoption the hydrate effect
|
|
232
|
+
// derives session + runtimeContext from the adopted snapshot.
|
|
233
|
+
return adoptMutationSnapshot(token, targetSessionId, payload.snapshot);
|
|
234
|
+
}
|
|
235
|
+
catch (reason) {
|
|
236
|
+
// RF2-02: stale out-of-order network failures are dropped too —
|
|
237
|
+
// no error, no refresh, no state writes for an expired token.
|
|
238
|
+
if (switchTokenRef.current !== token)
|
|
239
|
+
return false;
|
|
240
|
+
if (refs.sessionRef.current?.sessionId === targetSessionId)
|
|
241
|
+
setError(reason instanceof Error ? reason.message : String(reason));
|
|
242
|
+
return false;
|
|
243
|
+
}
|
|
244
|
+
finally {
|
|
245
|
+
// RF3-02: settle only when the target session is still active and
|
|
246
|
+
// the token is still the latest — a late old-session response
|
|
247
|
+
// neither dispatches an effective settle nor clears the current
|
|
248
|
+
// busy/switching.
|
|
249
|
+
const settled = settleMutationLifecycle(token, targetSessionId, refs);
|
|
250
|
+
if (settled && switchTokenRef.current === token)
|
|
251
|
+
setSwitching(false);
|
|
252
|
+
}
|
|
132
253
|
}, [origin, session, refs, setSession, setError]);
|
|
133
254
|
// Close tools catalog on outside click / Escape.
|
|
134
255
|
useEffect(() => {
|
|
@@ -179,18 +300,46 @@ export function useRuntimeControls(params) {
|
|
|
179
300
|
const toolCatalog = useMemo(() => buildToolCatalog(capabilities), [
|
|
180
301
|
capabilities,
|
|
181
302
|
]);
|
|
303
|
+
// Snapshot is the single source of truth when ready; the /models fetch
|
|
304
|
+
// result is the fallback (both hit the same backend, never fake-empty).
|
|
305
|
+
const effectiveModels = snapshot
|
|
306
|
+
? (snapshot.models ?? []).map((model) => ({
|
|
307
|
+
provider: model.provider,
|
|
308
|
+
id: model.id,
|
|
309
|
+
...(model.name ? { name: model.name } : {}),
|
|
310
|
+
...(model.hasCredentials !== undefined
|
|
311
|
+
? { hasCredentials: model.hasCredentials }
|
|
312
|
+
: {}),
|
|
313
|
+
}))
|
|
314
|
+
: models;
|
|
315
|
+
const effectiveThinkingLevels = snapshot?.thinkingLevels?.length
|
|
316
|
+
? snapshot.thinkingLevels.map((option) => option.value)
|
|
317
|
+
: thinkingLevels;
|
|
182
318
|
const activeModel = session?.model ?? runtimeContext?.model;
|
|
183
319
|
const activeModelRef = activeModel
|
|
184
320
|
? formatModelRef(activeModel.provider, activeModel.modelId)
|
|
185
321
|
: "";
|
|
186
|
-
|
|
322
|
+
// AC-01/AC-02: thinking selection shows "auto" when no explicit override,
|
|
323
|
+
// with the Pi effective level available separately for the badge.
|
|
324
|
+
const activeThinkingLevel = snapshot?.selection?.thinkingSelection ?? session?.thinkingLevel ?? "";
|
|
325
|
+
const effectiveThinkingLevel = snapshot?.selection?.effectiveThinkingLevel ??
|
|
326
|
+
session?.thinkingLevel ??
|
|
327
|
+
undefined;
|
|
187
328
|
const activeModelListed = !activeModelRef ||
|
|
188
|
-
|
|
329
|
+
effectiveModels.some((model) => formatModelRef(model.provider, model.id) === activeModelRef);
|
|
189
330
|
return {
|
|
190
331
|
capabilities,
|
|
191
|
-
models,
|
|
192
|
-
|
|
332
|
+
models: effectiveModels,
|
|
333
|
+
modelsStatus,
|
|
334
|
+
modelsError,
|
|
335
|
+
onRetryModels: () => void loadModels(),
|
|
336
|
+
thinkingLevels: effectiveThinkingLevels,
|
|
193
337
|
runtimeContext,
|
|
338
|
+
runtimeContextError,
|
|
339
|
+
onRetryRuntimeContext: () => {
|
|
340
|
+
setRuntimeContextError(null);
|
|
341
|
+
void refreshRuntimeSnapshot();
|
|
342
|
+
},
|
|
194
343
|
toolsOpen,
|
|
195
344
|
setToolsOpen,
|
|
196
345
|
runtimeContextOpen,
|
|
@@ -198,10 +347,12 @@ export function useRuntimeControls(params) {
|
|
|
198
347
|
toolsMenuRef,
|
|
199
348
|
runtimeContextMenuRef,
|
|
200
349
|
switchRuntime,
|
|
350
|
+
switching,
|
|
201
351
|
toolCatalog,
|
|
202
352
|
activeModel,
|
|
203
353
|
activeModelRef,
|
|
204
354
|
activeThinkingLevel,
|
|
355
|
+
effectiveThinkingLevel,
|
|
205
356
|
activeModelListed,
|
|
206
357
|
};
|
|
207
358
|
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Operator Chat — runtime snapshot React binding (design §2.2 / AC-05/06).
|
|
3
|
+
*
|
|
4
|
+
* All seams that need the snapshot (ChatComposer /reload paths, resource
|
|
5
|
+
* panels, runtime-context popover) live in writable files that do NOT share a
|
|
6
|
+
* common React tree root (composer vs sidebar). This module therefore owns a
|
|
7
|
+
* single module-level store plus a controller attached by `useComposer` (which
|
|
8
|
+
* already holds origin + sessionId + refs). Components read the shared state
|
|
9
|
+
* with `useRuntimeSnapshot()` and trigger reloads with
|
|
10
|
+
* `reloadRuntimeSnapshot()` — every submit path shares the SAME state machine.
|
|
11
|
+
*/
|
|
12
|
+
import { useSyncExternalStore } from "react";
|
|
13
|
+
import { confirmationToken } from "./format.js";
|
|
14
|
+
import { createRuntimeSnapshotStore, parseRuntimeSnapshotError, } from "./runtime-snapshot-store.js";
|
|
15
|
+
const store = createRuntimeSnapshotStore();
|
|
16
|
+
let controller;
|
|
17
|
+
function snapshotUrl(sessionId) {
|
|
18
|
+
return `/api/operator/v1/chat/sessions/${encodeURIComponent(sessionId)}`;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Bind the current session to the shared store. Called from `useComposer` on
|
|
22
|
+
* every session change; switching sessions reloads the snapshot, clearing the
|
|
23
|
+
* session detaches cleanly. Stale replies are dropped by the sessionId guard.
|
|
24
|
+
*/
|
|
25
|
+
export function attachRuntimeSnapshotController(input) {
|
|
26
|
+
const previous = controller;
|
|
27
|
+
controller = input;
|
|
28
|
+
if (previous && previous.sessionId === input.sessionId && previous.origin === input.origin) {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
// RF3-02: a session switch immediately resets the mutation lifecycle so the
|
|
32
|
+
// new session never inherits the old session's mutationBusy/token. Stale
|
|
33
|
+
// adopt/settle actions for the old session become no-ops via the token +
|
|
34
|
+
// sessionId guards in the reducer.
|
|
35
|
+
if (!previous || previous.sessionId !== input.sessionId) {
|
|
36
|
+
store.dispatch({ type: "mutation-reset" });
|
|
37
|
+
}
|
|
38
|
+
if (!input.sessionId) {
|
|
39
|
+
store.dispatch({ type: "reset" });
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
store.dispatch({ type: "load-start", sessionId: input.sessionId });
|
|
43
|
+
void fetchSnapshot(input.sessionId, input.origin);
|
|
44
|
+
}
|
|
45
|
+
async function fetchSnapshot(sessionId, origin) {
|
|
46
|
+
try {
|
|
47
|
+
const res = await fetch(`${origin}${snapshotUrl(sessionId)}/runtime-snapshot`, { credentials: "include" });
|
|
48
|
+
const body = (await res.json().catch(() => ({})));
|
|
49
|
+
if (controller?.sessionId !== sessionId)
|
|
50
|
+
return; // stale session reply
|
|
51
|
+
if (!res.ok) {
|
|
52
|
+
store.dispatch({
|
|
53
|
+
type: "load-failure",
|
|
54
|
+
sessionId,
|
|
55
|
+
error: { ...parseRuntimeSnapshotError(body), status: res.status },
|
|
56
|
+
});
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (!body.snapshot) {
|
|
60
|
+
store.dispatch({
|
|
61
|
+
type: "load-failure",
|
|
62
|
+
sessionId,
|
|
63
|
+
error: { status: res.status, message: "runtime snapshot missing in response" },
|
|
64
|
+
});
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
store.dispatch({ type: "load-success", sessionId, snapshot: body.snapshot });
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
if (controller?.sessionId !== sessionId)
|
|
71
|
+
return;
|
|
72
|
+
store.dispatch({
|
|
73
|
+
type: "load-failure",
|
|
74
|
+
sessionId,
|
|
75
|
+
error: { message: error instanceof Error ? error.message : String(error) },
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* POST /sessions/:id/reload (AC-05). Single-flight: a reload already in
|
|
81
|
+
* progress is NOT queued (AC-06). Returns true only on success.
|
|
82
|
+
*/
|
|
83
|
+
export async function reloadRuntimeSnapshot() {
|
|
84
|
+
const ctrl = controller;
|
|
85
|
+
if (!ctrl?.sessionId)
|
|
86
|
+
return false;
|
|
87
|
+
if (store.getState().reloading)
|
|
88
|
+
return false; // busy reload: no silent queue
|
|
89
|
+
const sessionId = ctrl.sessionId;
|
|
90
|
+
store.dispatch({ type: "reload-start" });
|
|
91
|
+
try {
|
|
92
|
+
const res = await fetch(`${ctrl.origin}${snapshotUrl(sessionId)}/reload`, {
|
|
93
|
+
method: "POST",
|
|
94
|
+
credentials: "include",
|
|
95
|
+
headers: {
|
|
96
|
+
"content-type": "application/json",
|
|
97
|
+
"x-loop-console-confirmation": confirmationToken(),
|
|
98
|
+
},
|
|
99
|
+
body: "{}",
|
|
100
|
+
});
|
|
101
|
+
const body = (await res.json().catch(() => ({})));
|
|
102
|
+
if (controller?.sessionId !== sessionId)
|
|
103
|
+
return false;
|
|
104
|
+
if (!res.ok) {
|
|
105
|
+
store.dispatch({
|
|
106
|
+
type: "reload-failure",
|
|
107
|
+
error: { ...parseRuntimeSnapshotError(body), status: res.status },
|
|
108
|
+
});
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
if (!body.snapshot) {
|
|
112
|
+
store.dispatch({
|
|
113
|
+
type: "reload-failure",
|
|
114
|
+
error: { status: res.status, message: "reload succeeded but snapshot missing" },
|
|
115
|
+
});
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
store.dispatch({ type: "reload-success", snapshot: body.snapshot });
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
if (controller?.sessionId !== sessionId)
|
|
123
|
+
return false;
|
|
124
|
+
store.dispatch({
|
|
125
|
+
type: "reload-failure",
|
|
126
|
+
error: { message: error instanceof Error ? error.message : String(error) },
|
|
127
|
+
});
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Plain snapshot re-read (no Pi reload). Used after model/thinking mutations
|
|
133
|
+
* so the store atomically adopts the fresh revision.
|
|
134
|
+
*/
|
|
135
|
+
export async function refreshRuntimeSnapshot() {
|
|
136
|
+
const ctrl = controller;
|
|
137
|
+
if (!ctrl?.sessionId)
|
|
138
|
+
return false;
|
|
139
|
+
const sessionId = ctrl.sessionId;
|
|
140
|
+
store.dispatch({ type: "load-start", sessionId });
|
|
141
|
+
void fetchSnapshot(sessionId, ctrl.origin);
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* RF3-01: production success-branch seam — `useRuntimeControls.switchRuntime`
|
|
146
|
+
* calls this INSTEAD of writing session/runtimeContext directly. Returns true
|
|
147
|
+
* only when the reducer's three guards (latest mutationToken, current
|
|
148
|
+
* sessionId, monotonic revision) actually adopted the snapshot; a missing
|
|
149
|
+
* snapshot is rejected up-front with zero derived writes. Adoption never
|
|
150
|
+
* settles mutationBusy — `settleMutationLifecycle` owns that.
|
|
151
|
+
*/
|
|
152
|
+
export function adoptMutationSnapshot(token, sessionId, snapshot) {
|
|
153
|
+
if (!snapshot)
|
|
154
|
+
return false;
|
|
155
|
+
store.dispatch({ type: "mutation-adopt", token, sessionId, snapshot });
|
|
156
|
+
// The reducer adopts by atomic replacement: the current snapshot IS the
|
|
157
|
+
// proposed snapshot only when all three guards passed.
|
|
158
|
+
return store.getState().snapshot === snapshot;
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* RF3-02: production finally-branch seam — settles the mutation lifecycle only
|
|
162
|
+
* when the target session is STILL the active session AND the token is still
|
|
163
|
+
* the latest. A late response from a previous session (or a superseded token)
|
|
164
|
+
* returns false without dispatching, so it can neither clear the current
|
|
165
|
+
* busy/switching nor settle a lifecycle the session switch already reset.
|
|
166
|
+
*/
|
|
167
|
+
export function settleMutationLifecycle(token, targetSessionId, refs) {
|
|
168
|
+
if (refs.sessionRef.current?.sessionId !== targetSessionId)
|
|
169
|
+
return false;
|
|
170
|
+
if (store.getState().mutationToken !== token)
|
|
171
|
+
return false;
|
|
172
|
+
store.dispatch({ type: "mutation-settle", token });
|
|
173
|
+
return true;
|
|
174
|
+
}
|
|
175
|
+
/** Subscribe the current component to the shared snapshot state. */
|
|
176
|
+
export function useRuntimeSnapshot() {
|
|
177
|
+
return useSyncExternalStore(store.subscribe, store.getState);
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Dispatch an action into the shared snapshot store. Controller-side seams
|
|
181
|
+
* (e.g. the mutation token lifecycle in useRuntimeControls) drive the same
|
|
182
|
+
* state machine the components read, keeping every consumer consistent.
|
|
183
|
+
*/
|
|
184
|
+
export function dispatchRuntimeSnapshot(action) {
|
|
185
|
+
store.dispatch(action);
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* RF5: production read-only accessor for the shared store — the only
|
|
189
|
+
* immediate authority for the currently adopted snapshot inside asynchronous
|
|
190
|
+
* callbacks (e.g. the /runtime-context response .then handler), even when the
|
|
191
|
+
* subscribing component has not rerendered yet. Read-only: it never bypasses
|
|
192
|
+
* the reducer, never writes state, and never notifies subscribers.
|
|
193
|
+
*/
|
|
194
|
+
export function getRuntimeSnapshotState() {
|
|
195
|
+
return store.getState();
|
|
196
|
+
}
|