@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
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Owns title-only SSE lifecycles. It is deliberately independent from the
3
+ * timeline SSE applier: title frames update the session index only.
4
+ */
5
+ export class SessionTitleWatcher {
6
+ options;
7
+ eligible = new Set();
8
+ controllers = new Map();
9
+ fetchFn;
10
+ backoffMs;
11
+ deadlineMs;
12
+ constructor(options) {
13
+ this.options = options;
14
+ this.fetchFn = options.fetchFn ?? fetch;
15
+ this.backoffMs = options.backoffMs ?? 1_000;
16
+ this.deadlineMs = options.deadlineMs ?? 30_000;
17
+ }
18
+ markEligible(sessionId) {
19
+ this.eligible.add(sessionId);
20
+ }
21
+ startIfEligible(sessionId) {
22
+ if (!this.eligible.delete(sessionId) || this.controllers.has(sessionId))
23
+ return;
24
+ const controller = new AbortController();
25
+ this.controllers.set(sessionId, controller);
26
+ void this.run(sessionId, controller);
27
+ }
28
+ cancel(sessionId) {
29
+ this.eligible.delete(sessionId);
30
+ this.controllers.get(sessionId)?.abort();
31
+ this.controllers.delete(sessionId);
32
+ }
33
+ reconcile(sessionIds) {
34
+ const current = new Set(sessionIds);
35
+ for (const sessionId of this.controllers.keys()) {
36
+ if (!current.has(sessionId))
37
+ this.cancel(sessionId);
38
+ }
39
+ }
40
+ dispose() {
41
+ for (const controller of this.controllers.values())
42
+ controller.abort();
43
+ this.controllers.clear();
44
+ this.eligible.clear();
45
+ }
46
+ async run(sessionId, controller) {
47
+ let lastEventId;
48
+ const seen = new Set();
49
+ let deadlineReached = false;
50
+ const deadline = window.setTimeout(() => {
51
+ deadlineReached = true;
52
+ controller.abort();
53
+ }, this.deadlineMs);
54
+ try {
55
+ while (!controller.signal.aborted) {
56
+ try {
57
+ const headers = { Accept: "text/event-stream" };
58
+ if (lastEventId)
59
+ headers["Last-Event-ID"] = lastEventId;
60
+ const response = await this.fetchFn(`${this.options.origin}/api/operator/v1/chat/sessions/${encodeURIComponent(sessionId)}/events`, { headers, credentials: "include", signal: controller.signal });
61
+ if (response.status === 404 || !response.body)
62
+ return;
63
+ if (!response.ok)
64
+ throw new Error(`HTTP ${response.status}`);
65
+ const reader = response.body.getReader();
66
+ const decoder = new TextDecoder();
67
+ let buffer = "";
68
+ for (;;) {
69
+ const { done, value } = await reader.read();
70
+ if (done || controller.signal.aborted)
71
+ break;
72
+ buffer += decoder.decode(value, { stream: true });
73
+ const blocks = buffer.split("\n\n");
74
+ buffer = blocks.pop() ?? "";
75
+ for (const block of blocks) {
76
+ const eventId = block.split("\n").find((line) => line.startsWith("id: "))?.slice(4);
77
+ if (eventId)
78
+ lastEventId = eventId;
79
+ if (!eventId || seen.has(eventId) || !block.includes("event: session_title"))
80
+ continue;
81
+ const data = block.split("\n").find((line) => line.startsWith("data: "))?.slice(6);
82
+ if (!data)
83
+ continue;
84
+ try {
85
+ const parsed = JSON.parse(data);
86
+ const title = parsed.data?.title?.trim();
87
+ if (!title)
88
+ continue;
89
+ seen.add(eventId);
90
+ if (!this.options.hasSession(sessionId))
91
+ return;
92
+ this.options.onTitle(sessionId, { title, automatic: Boolean(parsed.data?.automatic) });
93
+ if (parsed.data?.automatic)
94
+ return;
95
+ }
96
+ catch { /* malformed title frames are ignored */ }
97
+ }
98
+ }
99
+ }
100
+ catch {
101
+ if (controller.signal.aborted)
102
+ return;
103
+ }
104
+ if (!controller.signal.aborted)
105
+ await abortableSleep(this.backoffMs, controller.signal);
106
+ }
107
+ }
108
+ finally {
109
+ window.clearTimeout(deadline);
110
+ if (this.controllers.get(sessionId) === controller)
111
+ this.controllers.delete(sessionId);
112
+ if (deadlineReached || !this.options.hasSession(sessionId))
113
+ void this.options.refresh();
114
+ }
115
+ }
116
+ }
117
+ function abortableSleep(ms, signal) {
118
+ return new Promise((resolve) => {
119
+ const timer = window.setTimeout(done, ms);
120
+ const onAbort = () => done();
121
+ function done() {
122
+ window.clearTimeout(timer);
123
+ signal.removeEventListener("abort", onAbort);
124
+ resolve();
125
+ }
126
+ signal.addEventListener("abort", onAbort, { once: true });
127
+ });
128
+ }
@@ -0,0 +1,90 @@
1
+ /** Session/repo vertical split percent with versioned localStorage. */
2
+ export const SIDEBAR_SPLIT_STORAGE_KEY = "loop-console.operator-chat.sidebar-split.v1";
3
+ export const SIDEBAR_SPLIT_DEFAULT = 45;
4
+ export const SIDEBAR_SPLIT_MIN = 28;
5
+ export const SIDEBAR_SPLIT_MAX = 70;
6
+ export const SIDEBAR_SPLIT_STEP = 5;
7
+ export const SIDEBAR_SPLIT_STEP_LARGE = 10;
8
+ /** Clamp session share percent into the legal 28–70 band. */
9
+ export function clampSplit(percent) {
10
+ if (!Number.isFinite(percent))
11
+ return SIDEBAR_SPLIT_DEFAULT;
12
+ return Math.min(SIDEBAR_SPLIT_MAX, Math.max(SIDEBAR_SPLIT_MIN, Math.round(percent)));
13
+ }
14
+ /** Parse a stored value; only integers in 28–70 are accepted. */
15
+ export function parseStoredSplit(raw) {
16
+ if (raw == null || raw === "")
17
+ return null;
18
+ const n = Number(raw);
19
+ if (!Number.isFinite(n))
20
+ return null;
21
+ const rounded = Math.round(n);
22
+ if (rounded < SIDEBAR_SPLIT_MIN || rounded > SIDEBAR_SPLIT_MAX)
23
+ return null;
24
+ return rounded;
25
+ }
26
+ /** Load session percent or fall back to 45 on missing/corrupt storage. */
27
+ export function loadSidebarSplit(storage = defaultStorage()) {
28
+ if (!storage)
29
+ return SIDEBAR_SPLIT_DEFAULT;
30
+ try {
31
+ const parsed = parseStoredSplit(storage.getItem(SIDEBAR_SPLIT_STORAGE_KEY));
32
+ return parsed ?? SIDEBAR_SPLIT_DEFAULT;
33
+ }
34
+ catch {
35
+ return SIDEBAR_SPLIT_DEFAULT;
36
+ }
37
+ }
38
+ /** Persist a clamped session percent; ignore storage failures. */
39
+ export function saveSidebarSplit(percent, storage = defaultStorage()) {
40
+ const value = clampSplit(percent);
41
+ if (!storage)
42
+ return value;
43
+ try {
44
+ storage.setItem(SIDEBAR_SPLIT_STORAGE_KEY, String(value));
45
+ }
46
+ catch {
47
+ // localStorage may be unavailable; keep in-memory value.
48
+ }
49
+ return value;
50
+ }
51
+ /** Map keyboard keys on the separator to a next session percent. */
52
+ export function adjustSplitByKey(current, key, shiftKey = false) {
53
+ const base = clampSplit(current);
54
+ switch (key) {
55
+ case "ArrowUp":
56
+ return clampSplit(base - (shiftKey ? SIDEBAR_SPLIT_STEP_LARGE : SIDEBAR_SPLIT_STEP));
57
+ case "ArrowDown":
58
+ return clampSplit(base + (shiftKey ? SIDEBAR_SPLIT_STEP_LARGE : SIDEBAR_SPLIT_STEP));
59
+ case "Home":
60
+ return SIDEBAR_SPLIT_MIN;
61
+ case "End":
62
+ return SIDEBAR_SPLIT_MAX;
63
+ case "Enter":
64
+ return SIDEBAR_SPLIT_DEFAULT;
65
+ default:
66
+ return null;
67
+ }
68
+ }
69
+ /**
70
+ * Compute session percent from a pointer Y within a split track rect.
71
+ * top of track → min session share; bottom → max session share.
72
+ */
73
+ export function splitPercentFromPointer(clientY, trackTop, trackHeight) {
74
+ if (!Number.isFinite(trackHeight) || trackHeight <= 0) {
75
+ return SIDEBAR_SPLIT_DEFAULT;
76
+ }
77
+ const ratio = (clientY - trackTop) / trackHeight;
78
+ return clampSplit(ratio * 100);
79
+ }
80
+ function defaultStorage() {
81
+ try {
82
+ if (typeof globalThis === "undefined")
83
+ return null;
84
+ const storage = globalThis.localStorage;
85
+ return storage ?? null;
86
+ }
87
+ catch {
88
+ return null;
89
+ }
90
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Mutual-exclusion state for large spatial overlays in Operator Chat.
3
+ * At most one of: sidebar drawer, process, runtime context, tools.
4
+ * Small composer menus (model/thinking) are out of scope.
5
+ */
6
+ export function createSpatialOverlayState(active = "none") {
7
+ return { active };
8
+ }
9
+ /** Open one overlay; closes any other spatial overlay. */
10
+ export function openSpatialOverlay(_state, kind) {
11
+ return { active: kind };
12
+ }
13
+ /** Close the active overlay (or no-op when already none). */
14
+ export function closeSpatialOverlay(_state = { active: "none" }) {
15
+ return { active: "none" };
16
+ }
17
+ /** Close only when the named overlay is active. */
18
+ export function closeSpatialOverlayIf(state, kind) {
19
+ return state.active === kind ? { active: "none" } : state;
20
+ }
21
+ export function isSpatialOverlayOpen(state, kind) {
22
+ if (kind)
23
+ return state.active === kind;
24
+ return state.active !== "none";
25
+ }
26
+ /**
27
+ * Derive exclusive UI flags for the four large panels from a single source.
28
+ * Opening one forces the others closed.
29
+ */
30
+ export function spatialOverlayFlags(state) {
31
+ return {
32
+ sidebarDrawerOpen: state.active === "sidebar-drawer",
33
+ processOpen: state.active === "process",
34
+ runtimeContextOpen: state.active === "runtime-context",
35
+ toolsOpen: state.active === "tools",
36
+ };
37
+ }
@@ -4,6 +4,56 @@ import { hydrateSessionThread } from "../../chat/turn-process.js";
4
4
  import { ACTIVE_CHAT_SESSION_STORAGE_KEY } from "../../chat/workspace-landing.js";
5
5
  import { CHAT_SESSION_LIST_LIMIT } from "../chat-view-types.js";
6
6
  import { confirmationToken } from "./format.js";
7
+ import { SessionTitleWatcher } from "./session-title-watcher.js";
8
+ export async function createSessionAndPrimeTitleWatcher(input) {
9
+ if (input.creatingRef?.current) {
10
+ throw new Error("CREATE_SESSION_IN_FLIGHT");
11
+ }
12
+ if (input.creatingRef)
13
+ input.creatingRef.current = true;
14
+ // Synchronous busy feedback: callers set creatingSession in the same render
15
+ // turn via setCreatingSession before the network await below.
16
+ input.setCreatingSession?.(true);
17
+ try {
18
+ const res = await (input.fetchFn ?? fetch)(`${input.origin}/api/operator/v1/chat/sessions`, {
19
+ method: "POST",
20
+ headers: {
21
+ "Content-Type": "application/json",
22
+ "x-loop-console-confirmation": confirmationToken(),
23
+ },
24
+ credentials: "include",
25
+ body: JSON.stringify(input.options ?? {}),
26
+ });
27
+ if (!res.ok) {
28
+ const body = (await res.json().catch(() => ({})));
29
+ throw new Error(body.error?.message ?? `HTTP ${res.status}`);
30
+ }
31
+ const body = (await res.json());
32
+ if (!body.session)
33
+ throw new Error("创建会话响应缺少 durable session");
34
+ const durable = body.session;
35
+ const created = {
36
+ sessionId: durable.sessionId,
37
+ ...runtimeSelectionFromRecord(durable),
38
+ };
39
+ const nextList = [
40
+ durable,
41
+ ...input.sessionListRef.current.filter((item) => item.sessionId !== durable.sessionId),
42
+ ].slice(0, CHAT_SESSION_LIST_LIMIT);
43
+ // Commit ref → state → eligibility → deferred refresh. The ref assignment
44
+ // makes the durable record observable before React's state commit.
45
+ input.sessionListRef.current = nextList;
46
+ input.setSessionList(nextList);
47
+ input.titleWatcher?.markEligible(durable.sessionId);
48
+ void input.refreshSessionList();
49
+ return created;
50
+ }
51
+ finally {
52
+ if (input.creatingRef)
53
+ input.creatingRef.current = false;
54
+ input.setCreatingSession?.(false);
55
+ }
56
+ }
7
57
  /** Session lifecycle: list / activate / create / delete / rename-archive /
8
58
  * compact, plus browser-refresh recovery from the durable session store. */
9
59
  export function useChatSessions(params) {
@@ -12,17 +62,34 @@ export function useChatSessions(params) {
12
62
  const { setInput, setPendingImages } = composer;
13
63
  const { setPreviews, setActivePreviewPath } = browser;
14
64
  const [sessionList, setSessionList] = useState([]);
65
+ const sessionListRef = useRef([]);
66
+ sessionListRef.current = sessionList;
67
+ const titleWatcherRef = useRef(null);
68
+ // Shared busy gate for sidebar + Landing create entries (one render feedback).
69
+ const [creatingSession, setCreatingSession] = useState(false);
70
+ const creatingSessionRef = useRef(false);
15
71
  // Only the latest activate/create/restore intent may commit active-session
16
72
  // state. This prevents slow responses from an older selection overwriting a
17
73
  // newer user choice.
18
74
  const sessionSelectionVersionRef = useRef(0);
19
75
  const resetActiveProjection = useCallback(() => {
76
+ // M4.5/M5.2: selection is a hard ownership boundary. Abort every request
77
+ // owned by the old projection, then invalidate its async callbacks.
78
+ refs.sessionGenerationRef.current += 1;
79
+ refs.abortRef.current?.abort();
80
+ refs.reconnectAbortRef.current?.abort();
81
+ for (const controller of refs.sessionAbortControllersRef.current.values())
82
+ controller.abort();
83
+ refs.sessionAbortControllersRef.current.clear();
84
+ refs.reconnectAbortRef.current = null;
85
+ refs.eventsStreamStateRef.current = null;
20
86
  refs.streamingRef.current = false;
21
87
  refs.abortRef.current = null;
22
88
  refs.assistantIdRef.current = null;
23
89
  refs.lastEventIdRef.current = null;
24
90
  refs.seenEventIdsRef.current = new Set();
25
91
  refs.turnAssistantIdsRef.current = new Map();
92
+ refs.settledTurnIdsRef.current = new Set();
26
93
  refs.activeTurnIdRef.current = null;
27
94
  resetForNewSession();
28
95
  setInput("");
@@ -91,6 +158,7 @@ export function useChatSessions(params) {
91
158
  setError("删除会话失败");
92
159
  return;
93
160
  }
161
+ titleWatcherRef.current?.cancel(sessionId);
94
162
  if (refs.sessionRef.current?.sessionId === sessionId) {
95
163
  sessionSelectionVersionRef.current += 1;
96
164
  refs.sessionRef.current = null;
@@ -124,11 +192,13 @@ export function useChatSessions(params) {
124
192
  sessionId: body.session.sessionId,
125
193
  ...runtimeSelectionFromRecord(body.session),
126
194
  };
195
+ resetActiveProjection();
127
196
  refs.sessionRef.current = next;
128
197
  refs.streamingRef.current = Boolean(body.session.activeTurnId);
129
198
  refs.activeTurnIdRef.current = body.session.activeTurnId ?? null;
130
199
  refs.seenEventIdsRef.current = new Set();
131
200
  refs.turnAssistantIdsRef.current = new Map();
201
+ refs.settledTurnIdsRef.current = new Set();
132
202
  refs.lastEventIdRef.current = null;
133
203
  setSession(next);
134
204
  refs.assistantIdRef.current = null;
@@ -159,12 +229,39 @@ export function useChatSessions(params) {
159
229
  setPreviews,
160
230
  setInput,
161
231
  applyActivatedSession,
232
+ resetActiveProjection,
162
233
  setActivePreviewPath,
163
234
  setError,
164
235
  ]);
165
236
  useEffect(() => {
166
237
  void refreshSessionList();
167
238
  }, [refreshSessionList]);
239
+ if (!titleWatcherRef.current) {
240
+ titleWatcherRef.current = new SessionTitleWatcher({
241
+ origin,
242
+ hasSession: (sessionId) => sessionListRef.current.some((item) => item.sessionId === sessionId),
243
+ onTitle: (sessionId, event) => {
244
+ setSessionList((current) => current.map((item) => {
245
+ if (item.sessionId !== sessionId)
246
+ return item;
247
+ // A manually assigned title is authoritative over late automatic output.
248
+ if (event.automatic && item.title)
249
+ return item;
250
+ return event.automatic
251
+ ? { ...item, title: event.title }
252
+ : { ...item, displayTitle: event.title };
253
+ }));
254
+ },
255
+ refresh: refreshSessionList,
256
+ });
257
+ }
258
+ const watchSessionTitle = useCallback((sessionId) => titleWatcherRef.current?.startIfEligible(sessionId), []);
259
+ useEffect(() => {
260
+ titleWatcherRef.current?.reconcile(sessionList
261
+ .filter((item) => item.state !== "archived" && !item.title)
262
+ .map((item) => item.sessionId));
263
+ }, [sessionList]);
264
+ useEffect(() => () => titleWatcherRef.current?.dispose(), []);
168
265
  // Browser refresh recovery: retain only the durable session id locally, then
169
266
  // re-read messages and operation refs/facts from the server-owned stores.
170
267
  useEffect(() => {
@@ -189,6 +286,7 @@ export function useChatSessions(params) {
189
286
  sessionId: body.session.sessionId,
190
287
  ...runtimeSelectionFromRecord(body.session),
191
288
  };
289
+ refs.sessionGenerationRef.current += 1;
192
290
  refs.sessionRef.current = restored;
193
291
  // Do not seed Last-Event-ID here: session messages are restored
194
292
  // from the durable store, but tool/gate/operation projections live
@@ -197,6 +295,7 @@ export function useChatSessions(params) {
197
295
  refs.activeTurnIdRef.current = body.session.activeTurnId ?? null;
198
296
  refs.seenEventIdsRef.current = new Set();
199
297
  refs.turnAssistantIdsRef.current = new Map();
298
+ refs.settledTurnIdsRef.current = new Set();
200
299
  refs.lastEventIdRef.current = null;
201
300
  setSession(restored);
202
301
  setInput(body.session.composerDraft ?? "");
@@ -217,29 +316,26 @@ export function useChatSessions(params) {
217
316
  cancelled = true;
218
317
  };
219
318
  }, [origin, refs, setSession, setInput, setMessages]);
319
+ // The sole production create path delegates to the exported orchestration
320
+ // helper so its durable commit order is directly traceable and testable.
321
+ const createSessionAndPrimeTitleWatcherForHook = useCallback((options) => createSessionAndPrimeTitleWatcher({
322
+ origin,
323
+ sessionListRef,
324
+ setSessionList,
325
+ titleWatcher: titleWatcherRef.current,
326
+ refreshSessionList,
327
+ options,
328
+ setCreatingSession,
329
+ creatingRef: creatingSessionRef,
330
+ }), [origin, refreshSessionList]);
220
331
  const createSession = useCallback(async (options) => {
332
+ // Suppress concurrent create from any entry (sidebar / Landing / chip).
333
+ if (creatingSessionRef.current)
334
+ return null;
221
335
  const selectionVersion = ++sessionSelectionVersionRef.current;
222
336
  setError(null);
223
337
  try {
224
- const res = await fetch(`${origin}/api/operator/v1/chat/sessions`, {
225
- method: "POST",
226
- headers: {
227
- "Content-Type": "application/json",
228
- "x-loop-console-confirmation": confirmationToken(),
229
- },
230
- credentials: "include",
231
- body: JSON.stringify(options ?? {}),
232
- });
233
- if (!res.ok) {
234
- const body = (await res.json().catch(() => ({})));
235
- throw new Error(body.error?.message ?? `HTTP ${res.status}`);
236
- }
237
- const body = (await res.json());
238
- const created = {
239
- sessionId: body.sessionId,
240
- ...(body.model ? { model: body.model } : {}),
241
- };
242
- void refreshSessionList();
338
+ const created = await createSessionAndPrimeTitleWatcherForHook(options);
243
339
  if (selectionVersion !== sessionSelectionVersionRef.current)
244
340
  return null;
245
341
  resetActiveProjection();
@@ -254,16 +350,18 @@ export function useChatSessions(params) {
254
350
  catch (e) {
255
351
  if (selectionVersion !== sessionSelectionVersionRef.current)
256
352
  return null;
257
- setError(e instanceof Error ? e.message : String(e));
353
+ const message = e instanceof Error ? e.message : String(e);
354
+ if (message !== "CREATE_SESSION_IN_FLIGHT") {
355
+ setError(message);
356
+ }
258
357
  return null;
259
358
  }
260
359
  }, [
261
- origin,
262
360
  refs,
263
361
  setSession,
264
362
  setError,
265
363
  resetActiveProjection,
266
- refreshSessionList,
364
+ createSessionAndPrimeTitleWatcherForHook,
267
365
  ]);
268
366
  // Phase-1: fork / in-session branch / mainline switch UI deferred; API routes remain.
269
367
  const compactSession = useCallback(async () => {
@@ -302,6 +400,9 @@ export function useChatSessions(params) {
302
400
  return;
303
401
  }
304
402
  await refreshSessionList();
403
+ if (patch.state === "archived") {
404
+ titleWatcherRef.current?.cancel(sessionId);
405
+ }
305
406
  if (patch.state === "archived" &&
306
407
  refs.sessionRef.current?.sessionId === sessionId) {
307
408
  sessionSelectionVersionRef.current += 1;
@@ -313,11 +414,13 @@ export function useChatSessions(params) {
313
414
  };
314
415
  return {
315
416
  sessionList,
417
+ creatingSession,
316
418
  activateSession,
317
419
  refreshSessionList,
318
420
  deleteSession,
319
421
  createSession,
320
422
  updateSession,
423
+ watchSessionTitle,
321
424
  compactSession,
322
425
  };
323
426
  }