@tea-agent/loop-agent 0.42.0-next.4 → 0.42.0-next.6

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 (86) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/build-stamp.json +3 -3
  3. package/dist/executors/dag-pi-executor.js +1 -0
  4. package/dist/worker/console/chat/pi-runtime.js +26 -0
  5. package/dist/worker/console/chat/routes.js +60 -5
  6. package/dist/worker/console/chat/sdd-data-alignment.js +219 -0
  7. package/dist/worker/console/chat/session-catalog.js +2 -0
  8. package/dist/worker/console/chat/session-store.js +26 -0
  9. package/dist/worker/console/chat/shortcuts.js +18 -2
  10. package/dist/worker/console/server.js +1 -0
  11. package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-CeznJ4Iw.js → abnfDiagram-N423BO3Z-BJpsGgAv.js} +1 -1
  12. package/dist/worker/console/static/assets/{arc-B4u9u7SX.js → arc-DjMFPsEd.js} +1 -1
  13. package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-oghpT81p.js → architectureDiagram-T3A2C74G-DqdZlr1O.js} +1 -1
  14. package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-BPJ5Kvcj.js → blockDiagram-VBNYF7ZC-BmZnfFEO.js} +1 -1
  15. package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-CoIu69pd.js → c4Diagram-5PPSVZJV-3cKberpL.js} +1 -1
  16. package/dist/worker/console/static/assets/channel-DWYwciiv.js +1 -0
  17. package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-DtgiAsSc.js → chunk-2GRJ4B5K-q-0yjQ1h.js} +1 -1
  18. package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-YSu_EVSD.js → chunk-2Q5K7J3B-DnONBaDW.js} +1 -1
  19. package/dist/worker/console/static/assets/{chunk-5RXB4S5H-DaRO-NB6.js → chunk-5RXB4S5H-v3a6PCOS.js} +1 -1
  20. package/dist/worker/console/static/assets/{chunk-5VM5RSS4-Dh0ZKiGA.js → chunk-5VM5RSS4-D1kLrlU_.js} +1 -1
  21. package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-DOrvbR-H.js → chunk-6Q2QTUOP-BPelzihi.js} +1 -1
  22. package/dist/worker/console/static/assets/{chunk-GF5L2VYU-C3b22LN2.js → chunk-GF5L2VYU-DauNE69H.js} +1 -1
  23. package/dist/worker/console/static/assets/{chunk-JWPE2WC7-CXlqO8l6.js → chunk-JWPE2WC7-RIg5_bFJ.js} +1 -1
  24. package/dist/worker/console/static/assets/{chunk-KBJHAD2P-BblTBrDu.js → chunk-KBJHAD2P-BuILuoxs.js} +1 -1
  25. package/dist/worker/console/static/assets/{chunk-RYQCIY6F-DswqPALe.js → chunk-RYQCIY6F-VHORV5oW.js} +1 -1
  26. package/dist/worker/console/static/assets/{chunk-XXDRQBXY-CfTwVOlc.js → chunk-XXDRQBXY-BJYr4xHC.js} +1 -1
  27. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-D6jOf_l4.js +1 -0
  28. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-D6jOf_l4.js +1 -0
  29. package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-bLD8jVFo.js → cose-bilkent-JH36ORCC-BS6nuqQn.js} +1 -1
  30. package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-BvoG1fh5.js → cynefin-VYW2F7L2-BBhF0NAT.js} +1 -1
  31. package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-DEPAdJu3.js → cynefinDiagram-MW4NZA55-D1135Ifg.js} +1 -1
  32. package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-DmrNvBLy.js → dagre-VZM6K2ZE-vAOYPUWJ.js} +1 -1
  33. package/dist/worker/console/static/assets/{diagram-7IWD3JNH-CdwzIwvs.js → diagram-7IWD3JNH-DuaSPkfa.js} +1 -1
  34. package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-CJ3hj-md.js → diagram-B4RE2ZJO-mMvXvGU6.js} +1 -1
  35. package/dist/worker/console/static/assets/{diagram-LBJQPF4R-C6YJT2a4.js → diagram-LBJQPF4R-CDIEJtlL.js} +1 -1
  36. package/dist/worker/console/static/assets/{diagram-Q27KOJAE-C_LPfjhX.js → diagram-Q27KOJAE-Bi_6JvCW.js} +1 -1
  37. package/dist/worker/console/static/assets/{diagram-UB23O5K3-xvsSb5Ie.js → diagram-UB23O5K3-DAqj57yN.js} +1 -1
  38. package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-Rxp2tKcD.js → ebnfDiagram-BXEA7PRR-DfWaxNEo.js} +1 -1
  39. package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-DRyLYmZh.js → erDiagram-JOGREHBK-Bt7QqkNG.js} +1 -1
  40. package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-TLSU_D2B.js → flowDiagram-UKHOOZJN-CRRhQwT2.js} +1 -1
  41. package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-DGqeTvHC.js → ganttDiagram-PKOTCBZU-DAV1O5B6.js} +1 -1
  42. package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-BZxyH2wh.js → gitGraphDiagram-DS77QQ5N-0X2Qo4Lp.js} +1 -1
  43. package/dist/worker/console/static/assets/index-CVdddQsA.js +451 -0
  44. package/dist/worker/console/static/assets/index-DDQc5a50.css +1 -0
  45. package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-BsYtzx01.js → infoDiagram-6WML65LV-C5W5eILC.js} +1 -1
  46. package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-Qr-ezGv-.js → ishikawaDiagram-WSZJBQD7-5SfqU-Tb.js} +1 -1
  47. package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-DYbB3ibo.js → journeyDiagram-NVQOT4AX-CKDRcaKf.js} +1 -1
  48. package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-BHJHewZO.js → kanban-definition-27J2QSJJ-Cc7_TuSb.js} +1 -1
  49. package/dist/worker/console/static/assets/{linear-CmPIPmCG.js → linear-BcQYYmeT.js} +1 -1
  50. package/dist/worker/console/static/assets/{mermaid.core-F--C1uV0.js → mermaid.core-CwLZPtQo.js} +5 -5
  51. package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-YTXfL1st.js → mindmap-definition-FAOFIHXS-D29M025z.js} +1 -1
  52. package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-DdDMYXP9.js → pegDiagram-VL7TDLO6-CV-ZAWYe.js} +1 -1
  53. package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-C3nH4KVd.js → pieDiagram-7S7Q4E2Y-C6iPS_o5.js} +1 -1
  54. package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-kXyxjG7Y.js → quadrantDiagram-CIZ2JOQS-BFCD1sTg.js} +1 -1
  55. package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-KNzbl-7w.js → railroadDiagram-AXF67PYL-CztNj4O9.js} +1 -1
  56. package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-CSwI6dG0.js → requirementDiagram-LRYGKXZP-BWIELSNd.js} +1 -1
  57. package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-Bl1AyuXy.js → sankeyDiagram-W5VNT64P-CPtxrjJQ.js} +1 -1
  58. package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-2y4EmXQa.js → sequenceDiagram-SI44F4Z6-BEWVSFsu.js} +1 -1
  59. package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-E8v8YrMg.js → sizeCapture-X5ZJPWSS-DyhzoX_J.js} +1 -1
  60. package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-eGGlR856.js → stateDiagram-OKZ733FA-xLox0nu7.js} +1 -1
  61. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-DS5ZRvJG.js +1 -0
  62. package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-B7g7xc07.js → swimlanes-SLNWSIFB-CLC_ow9A.js} +2 -2
  63. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-Dvpxn-bf.js +8 -0
  64. package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-DR3VCHBG.js → timeline-definition-Z64GVDOM-DmJZ4uyY.js} +1 -1
  65. package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-LUvapZIy.js → vennDiagram-T6HMQDX7-DD7GGi9Z.js} +1 -1
  66. package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-C7GDcaXq.js → wardleyDiagram-T6FBY63Y-Bi8PQoGt.js} +1 -1
  67. package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-CbNAqoGK.js → xychartDiagram-ELKLHX3M-uKdAY8Dp.js} +1 -1
  68. package/dist/worker/console/static/index.html +2 -2
  69. package/dist/worker/console/static-src/operator-chat/useChatSessions.js +225 -21
  70. package/dist/worker/console/static-src/operator-chat/useComposer.js +5 -1
  71. package/dist/worker/console/workspace-context.js +4 -0
  72. package/dist/workflows/dag/backend-test-case-coverage-analysis.js +58 -15
  73. package/dist/workflows/dag/backend-test-plan-protocol.js +106 -0
  74. package/dist/workflows/dag/backend-test-scenario-param.js +16 -5
  75. package/dist/workflows/dag/backend-test-writer-completeness.js +42 -6
  76. package/dist/workflows/dag/init-hybrid.js +62 -22
  77. package/dist/workflows/dag/prompt.js +36 -2
  78. package/docs/templates/backend-test-dag.json +5 -5
  79. package/package.json +1 -1
  80. package/dist/worker/console/static/assets/channel-DmIXqJ6B.js +0 -1
  81. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-MFV5Qxb5.js +0 -1
  82. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-MFV5Qxb5.js +0 -1
  83. package/dist/worker/console/static/assets/index-C8V8K9b4.js +0 -451
  84. package/dist/worker/console/static/assets/index-Cnf_KxTQ.css +0 -1
  85. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-B8e4J3at.js +0 -1
  86. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-Do88I1Nb.js +0 -8
@@ -6,6 +6,94 @@ import { confirmationToken } from "./format.js";
6
6
  import { upsertCompactionMessage } from "./compaction-message.js";
7
7
  import { describeMutationGateFailure, isMutationGateFailureCode, isStaleTokenFailure, } from "./mutation-gate.js";
8
8
  import { SessionTitleWatcher } from "./session-title-watcher.js";
9
+ /** Pure list mutations keep optimistic UI deterministic and easy to verify. */
10
+ export function insertOptimisticSession(list, row) {
11
+ return [row, ...list.filter((item) => item.sessionId !== row.sessionId)];
12
+ }
13
+ export function replaceOptimisticSession(list, temporaryId, durable) {
14
+ return [
15
+ durable,
16
+ ...list.filter((item) => item.sessionId !== durable.sessionId &&
17
+ (!temporaryId || item.sessionId !== temporaryId)),
18
+ ];
19
+ }
20
+ export function removeSessionById(list, sessionId) {
21
+ const index = list.findIndex((item) => item.sessionId === sessionId);
22
+ if (index < 0)
23
+ return { next: list, index: -1 };
24
+ return {
25
+ next: list.filter((item) => item.sessionId !== sessionId),
26
+ removed: list[index],
27
+ index,
28
+ };
29
+ }
30
+ export function restoreSessionAt(list, row, index) {
31
+ if (list.some((item) => item.sessionId === row.sessionId))
32
+ return list;
33
+ const next = [...list];
34
+ next.splice(Math.max(0, Math.min(index, next.length)), 0, row);
35
+ return next;
36
+ }
37
+ function sameQueueSummary(a, b) {
38
+ const left = a.deferredTurns ?? (a.deferredTurn ? [a.deferredTurn] : []);
39
+ const right = b.deferredTurns ?? (b.deferredTurn ? [b.deferredTurn] : []);
40
+ if (left.length !== right.length)
41
+ return false;
42
+ return left.every((item, index) => {
43
+ const other = right[index];
44
+ return Boolean(other &&
45
+ item.clientRequestId === other.clientRequestId &&
46
+ item.revision === other.revision &&
47
+ item.state === other.state &&
48
+ item.updatedAt === other.updatedAt &&
49
+ item.textPreview === other.textPreview &&
50
+ item.imageCount === other.imageCount &&
51
+ item.acceptedTurnId === other.acceptedTurnId &&
52
+ item.lastError?.code === other.lastError?.code &&
53
+ item.lastError?.message === other.lastError?.message);
54
+ });
55
+ }
56
+ function sameSessionSummary(a, b) {
57
+ return (a.sessionId === b.sessionId &&
58
+ a.workspaceId === b.workspaceId &&
59
+ a.state === b.state &&
60
+ a.title === b.title &&
61
+ a.displayTitle === b.displayTitle &&
62
+ a.updatedAt === b.updatedAt &&
63
+ a.activeTurnId === b.activeTurnId &&
64
+ a.turnPhase === b.turnPhase &&
65
+ a.model?.provider === b.model?.provider &&
66
+ a.model?.modelId === b.model?.modelId &&
67
+ a.thinkingLevel === b.thinkingLevel &&
68
+ a.isBlank === b.isBlank &&
69
+ a.optimisticState === b.optimisticState &&
70
+ sameQueueSummary(a, b));
71
+ }
72
+ /** Reuse unchanged row and list references across catalog refreshes. */
73
+ export function stabilizeSessionList(previous, next) {
74
+ const previousById = new Map(previous.map((item) => [item.sessionId, item]));
75
+ let changed = previous.length !== next.length;
76
+ const stabilized = next.map((item, index) => {
77
+ const existing = previousById.get(item.sessionId);
78
+ const value = existing && sameSessionSummary(existing, item) ? existing : item;
79
+ if (value !== previous[index])
80
+ changed = true;
81
+ return value;
82
+ });
83
+ return changed ? stabilized : previous;
84
+ }
85
+ /** Select a server-confirmed blank session owned by the requested workspace. */
86
+ export function findReusableBlankSession(list, workspaceId) {
87
+ const scope = workspaceId.trim();
88
+ if (!scope)
89
+ return undefined;
90
+ return list.find((item) => item.workspaceId === scope &&
91
+ item.state === "active" &&
92
+ item.isBlank === true &&
93
+ item.turnPhase === "idle" &&
94
+ !item.activeTurnId &&
95
+ !item.optimisticState);
96
+ }
9
97
  export async function createSessionAndPrimeTitleWatcher(input) {
10
98
  if (input.creatingRef?.current) {
11
99
  throw new Error("CREATE_SESSION_IN_FLIGHT");
@@ -23,7 +111,10 @@ export async function createSessionAndPrimeTitleWatcher(input) {
23
111
  "x-loop-console-confirmation": confirmationToken(),
24
112
  },
25
113
  credentials: "include",
26
- body: JSON.stringify(input.options ?? {}),
114
+ body: JSON.stringify((() => {
115
+ const { optimisticSessionId: _optimisticSessionId, ...body } = input.options ?? {};
116
+ return body;
117
+ })()),
27
118
  });
28
119
  if (!res.ok) {
29
120
  const body = (await res.json().catch(() => ({})));
@@ -35,7 +126,7 @@ export async function createSessionAndPrimeTitleWatcher(input) {
35
126
  const body = (await res.json());
36
127
  if (!body.session)
37
128
  throw new Error("创建会话响应缺少 durable session");
38
- const durable = body.session;
129
+ const durable = { ...body.session, isBlank: true };
39
130
  const created = {
40
131
  sessionId: durable.sessionId,
41
132
  ...(durable.workspaceId || input.options?.workspaceId
@@ -45,16 +136,12 @@ export async function createSessionAndPrimeTitleWatcher(input) {
45
136
  : {}),
46
137
  ...runtimeSelectionFromRecord(durable),
47
138
  };
48
- const nextList = [
49
- durable,
50
- ...input.sessionListRef.current.filter((item) => item.sessionId !== durable.sessionId),
51
- ];
139
+ const nextList = replaceOptimisticSession(input.sessionListRef.current, input.options?.optimisticSessionId, durable);
52
140
  // Commit ref → state → eligibility → deferred refresh. The ref assignment
53
141
  // makes the durable record observable before React's state commit.
54
142
  input.sessionListRef.current = nextList;
55
143
  input.setSessionList(nextList);
56
144
  input.titleWatcher?.markEligible(durable.sessionId);
57
- void input.refreshSessionList();
58
145
  return created;
59
146
  }
60
147
  finally {
@@ -90,6 +177,11 @@ export function useChatSessions(params) {
90
177
  }, [refs]);
91
178
  const [sessionList, setSessionList] = useState([]);
92
179
  const sessionListRef = useRef([]);
180
+ const pendingDeletedIdsRef = useRef(new Set());
181
+ const pendingCreatedRowsRef = useRef(new Map());
182
+ const sessionMutationVersionRef = useRef(0);
183
+ const deleteRequestVersionsRef = useRef(new Map());
184
+ const activateSessionRef = useRef(null);
93
185
  sessionListRef.current = sessionList;
94
186
  const titleWatcherRef = useRef(null);
95
187
  // Shared busy gate for sidebar + Landing create entries (one render feedback).
@@ -159,9 +251,13 @@ export function useChatSessions(params) {
159
251
  return;
160
252
  }
161
253
  const sessions = body.sessions ?? [];
254
+ const durable = sessions.filter((item) => !pendingDeletedIdsRef.current.has(item.sessionId));
255
+ const optimistic = [...pendingCreatedRowsRef.current.values()].filter((item) => !durable.some((session) => session.sessionId === item.sessionId));
162
256
  // Preserve every workspace's summaries. ChatWorkspaceSidebar applies the
163
257
  // visible recent-item limit independently inside each workspace group.
164
- setSessionList(sessions);
258
+ const next = stabilizeSessionList(sessionListRef.current, [...optimistic, ...durable]);
259
+ sessionListRef.current = next;
260
+ setSessionList(next);
165
261
  if (body.workspaceErrors?.length) {
166
262
  setError(`部分工作区的会话加载失败(${body.workspaceErrors.length} 个)`);
167
263
  }
@@ -171,6 +267,60 @@ export function useChatSessions(params) {
171
267
  }
172
268
  }, [origin, blocked, setError]);
173
269
  const deleteSession = useCallback(async (sessionId) => {
270
+ if (pendingDeletedIdsRef.current.has(sessionId))
271
+ return false;
272
+ const requestId = ++sessionMutationVersionRef.current;
273
+ const currentList = sessionListRef.current;
274
+ const { next: removedList, removed, index: removedIndex } = removeSessionById(currentList, sessionId);
275
+ if (!removed || removedIndex < 0)
276
+ return false;
277
+ pendingDeletedIdsRef.current.add(sessionId);
278
+ deleteRequestVersionsRef.current.set(sessionId, requestId);
279
+ const activeSession = refs.sessionRef.current?.sessionId === sessionId
280
+ ? refs.sessionRef.current
281
+ : null;
282
+ const activeWorkspaceId = activeSession?.workspaceId ?? workspaceId;
283
+ const deletingRow = {
284
+ ...removed,
285
+ optimisticState: "deleting",
286
+ };
287
+ const deletingList = restoreSessionAt(removedList, deletingRow, removedIndex);
288
+ sessionListRef.current = deletingList;
289
+ setSessionList(deletingList);
290
+ window.setTimeout(() => {
291
+ if (!pendingDeletedIdsRef.current.has(sessionId) ||
292
+ deleteRequestVersionsRef.current.get(sessionId) !== requestId)
293
+ return;
294
+ const next = sessionListRef.current.filter((item) => item.sessionId !== sessionId);
295
+ sessionListRef.current = next;
296
+ setSessionList(next);
297
+ }, 150);
298
+ const restore = () => {
299
+ if (deleteRequestVersionsRef.current.get(sessionId) !== requestId)
300
+ return;
301
+ pendingDeletedIdsRef.current.delete(sessionId);
302
+ deleteRequestVersionsRef.current.delete(sessionId);
303
+ const restored = restoreSessionAt(sessionListRef.current, removed, removedIndex);
304
+ if (restored === sessionListRef.current)
305
+ return;
306
+ sessionListRef.current = restored;
307
+ setSessionList(restored);
308
+ };
309
+ let deletionSelectionVersion = null;
310
+ if (activeSession) {
311
+ deletionSelectionVersion = ++sessionSelectionVersionRef.current;
312
+ refs.sessionRef.current = null;
313
+ resetActiveProjection();
314
+ setSession(null);
315
+ forgetActiveChatSession(window.localStorage, activeWorkspaceId, sessionId);
316
+ }
317
+ const restoreActiveSession = async () => {
318
+ if (activeSession &&
319
+ deletionSelectionVersion === sessionSelectionVersionRef.current &&
320
+ !refs.sessionRef.current) {
321
+ await activateSessionRef.current?.(activeSession.sessionId, activeSession.workspaceId);
322
+ }
323
+ };
174
324
  try {
175
325
  // AC-002: delete follows the session's durable owner, not the
176
326
  // global active workspace.
@@ -184,22 +334,21 @@ export function useChatSessions(params) {
184
334
  },
185
335
  });
186
336
  if (!res.ok) {
337
+ restore();
338
+ await restoreActiveSession();
187
339
  setError("删除会话失败");
188
340
  return false;
189
341
  }
190
342
  titleWatcherRef.current?.cancel(sessionId);
191
- if (refs.sessionRef.current?.sessionId === sessionId) {
192
- const activeWorkspaceId = refs.sessionRef.current.workspaceId ?? workspaceId;
193
- sessionSelectionVersionRef.current += 1;
194
- refs.sessionRef.current = null;
195
- resetActiveProjection();
196
- setSession(null);
197
- forgetActiveChatSession(window.localStorage, activeWorkspaceId, sessionId);
198
- }
199
- await refreshSessionList();
343
+ void refreshSessionList().finally(() => {
344
+ pendingDeletedIdsRef.current.delete(sessionId);
345
+ deleteRequestVersionsRef.current.delete(sessionId);
346
+ });
200
347
  return true;
201
348
  }
202
349
  catch (error) {
350
+ restore();
351
+ await restoreActiveSession();
203
352
  setError(error instanceof Error ? error.message : String(error));
204
353
  return false;
205
354
  }
@@ -225,7 +374,7 @@ export function useChatSessions(params) {
225
374
  if (!res.ok || !body.session)
226
375
  throw new Error(body.error?.message ?? "会话不可用");
227
376
  if (selectionVersion !== sessionSelectionVersionRef.current)
228
- return;
377
+ return false;
229
378
  const next = {
230
379
  sessionId: body.session.sessionId,
231
380
  ...(body.session.workspaceId ? { workspaceId: body.session.workspaceId } : {}),
@@ -276,11 +425,13 @@ export function useChatSessions(params) {
276
425
  });
277
426
  rememberActiveChatSession(window.localStorage, body.session.workspaceId ?? ownerWorkspaceId ?? workspaceId, sessionId);
278
427
  setActivePreviewPath(null);
428
+ return true;
279
429
  }
280
430
  catch (error) {
281
431
  if (selectionVersion !== sessionSelectionVersionRef.current)
282
- return;
432
+ return false;
283
433
  setError(error instanceof Error ? error.message : String(error));
434
+ return false;
284
435
  }
285
436
  }, [
286
437
  origin,
@@ -296,6 +447,7 @@ export function useChatSessions(params) {
296
447
  sessionOwnerQuery,
297
448
  workspaceId,
298
449
  ]);
450
+ activateSessionRef.current = activateSession;
299
451
  useEffect(() => {
300
452
  void refreshSessionList();
301
453
  }, [refreshSessionList]);
@@ -437,15 +589,38 @@ export function useChatSessions(params) {
437
589
  setError("创建会话需要选择一个有效的工作区(不会回退默认工作区)");
438
590
  return Promise.reject(new Error("WORKSPACE_REQUIRED"));
439
591
  }
592
+ const optimisticSessionId = `optimistic-chat-${Date.now()}-${Math.random().toString(16).slice(2)}`;
593
+ const now = new Date().toISOString();
594
+ const optimisticRow = {
595
+ sessionId: optimisticSessionId,
596
+ workspaceId: scope,
597
+ state: "active",
598
+ updatedAt: now,
599
+ turnPhase: "idle",
600
+ optimisticState: "creating",
601
+ };
602
+ pendingCreatedRowsRef.current.set(optimisticSessionId, optimisticRow);
603
+ const optimisticList = insertOptimisticSession(sessionListRef.current, optimisticRow);
604
+ sessionListRef.current = optimisticList;
605
+ setSessionList(optimisticList);
440
606
  return createSessionAndPrimeTitleWatcher({
441
607
  origin,
442
608
  sessionListRef,
443
609
  setSessionList,
444
610
  titleWatcher: titleWatcherRef.current,
445
611
  refreshSessionList,
446
- options: { ...options, workspaceId: scope },
612
+ options: { ...options, workspaceId: scope, optimisticSessionId },
447
613
  setCreatingSession,
448
614
  creatingRef: creatingSessionRef,
615
+ }).then((created) => {
616
+ pendingCreatedRowsRef.current.delete(optimisticSessionId);
617
+ return created;
618
+ }).catch((error) => {
619
+ pendingCreatedRowsRef.current.delete(optimisticSessionId);
620
+ const cleaned = sessionListRef.current.filter((item) => item.sessionId !== optimisticSessionId);
621
+ sessionListRef.current = cleaned;
622
+ setSessionList(cleaned);
623
+ throw error;
449
624
  });
450
625
  }, [origin, refreshSessionList, workspaceId, setError]);
451
626
  const createSession = useCallback(async (options) => {
@@ -456,11 +631,36 @@ export function useChatSessions(params) {
456
631
  return null; // AC-R5-002: unknown workspace — no POST
457
632
  const selectionVersion = ++sessionSelectionVersionRef.current;
458
633
  setError(null);
634
+ const previousSession = refs.sessionRef.current;
635
+ const scope = options?.workspaceId?.trim() ?? workspaceId?.trim();
636
+ if (!scope) {
637
+ setError("创建会话需要选择一个有效的工作区(不会回退默认工作区)");
638
+ return null;
639
+ }
640
+ // A caller-provided snapshot is an explicit new-session boundary; reusing
641
+ // an existing shell would silently drop that context suffix.
642
+ const reusable = options?.snapshot
643
+ ? undefined
644
+ : findReusableBlankSession(sessionListRef.current, scope);
645
+ if (reusable) {
646
+ if (previousSession?.sessionId === reusable.sessionId)
647
+ return previousSession;
648
+ const activated = await activateSession(reusable.sessionId, reusable.workspaceId);
649
+ if (!activated)
650
+ return null;
651
+ return {
652
+ sessionId: reusable.sessionId,
653
+ ...(reusable.workspaceId ? { workspaceId: reusable.workspaceId } : {}),
654
+ ...runtimeSelectionFromRecord(reusable),
655
+ };
656
+ }
459
657
  try {
658
+ resetActiveProjection({ keepStreams: true });
659
+ refs.sessionRef.current = null;
660
+ setSession(null);
460
661
  const created = await createSessionAndPrimeTitleWatcherForHook(options);
461
662
  if (selectionVersion !== sessionSelectionVersionRef.current)
462
663
  return null;
463
- resetActiveProjection({ keepStreams: true });
464
664
  // Set ref synchronously BEFORE setState so a chained
465
665
  // createSession().then(sendPrompt) reads the new session even
466
666
  // though sendPrompt's closure captured the stale (null) value.
@@ -472,6 +672,9 @@ export function useChatSessions(params) {
472
672
  catch (e) {
473
673
  if (selectionVersion !== sessionSelectionVersionRef.current)
474
674
  return null;
675
+ if (previousSession) {
676
+ await activateSession(previousSession.sessionId, previousSession.workspaceId);
677
+ }
475
678
  const message = e instanceof Error ? e.message : String(e);
476
679
  if (message !== "CREATE_SESSION_IN_FLIGHT") {
477
680
  setError(message);
@@ -484,6 +687,7 @@ export function useChatSessions(params) {
484
687
  setError,
485
688
  resetActiveProjection,
486
689
  createSessionAndPrimeTitleWatcherForHook,
690
+ activateSession,
487
691
  blocked,
488
692
  workspaceId,
489
693
  ]);
@@ -28,6 +28,7 @@ export function useComposer(params) {
28
28
  }, []);
29
29
  const [shortcutIndex, setShortcutIndex] = useState(0);
30
30
  const [dynamicCommands, setDynamicCommands] = useState([]);
31
+ const [commandCapabilities, setCommandCapabilities] = useState({ sddBingo: false });
31
32
  const [dynamicCommandsLoading, setDynamicCommandsLoading] = useState(false);
32
33
  const [dynamicCommandsError, setDynamicCommandsError] = useState(null);
33
34
  const [commandsEpoch, setCommandsEpoch] = useState(0);
@@ -74,6 +75,7 @@ export function useComposer(params) {
74
75
  commandsSessionRef.current = sessionId;
75
76
  commandsGenerationRef.current += 1;
76
77
  setDynamicCommands([]);
78
+ setCommandCapabilities({ sddBingo: false });
77
79
  setDynamicCommandsError(null);
78
80
  setDynamicCommandsLoading(false);
79
81
  }, [sessionId]);
@@ -122,6 +124,7 @@ export function useComposer(params) {
122
124
  if (!isCurrent())
123
125
  return;
124
126
  setDynamicCommands(items);
127
+ setCommandCapabilities({ sddBingo: body?.capabilities?.sddBingo === true });
125
128
  // Compact server fail-soft / partial-source warning; local Operator/Web still merge.
126
129
  const warning = typeof body?.warning === "string" ? body.warning.trim() : "";
127
130
  setDynamicCommandsError(warning ? warning.slice(0, 160) : null);
@@ -131,6 +134,7 @@ export function useComposer(params) {
131
134
  return;
132
135
  setDynamicCommandsError("动态命令加载失败");
133
136
  setDynamicCommands([]);
137
+ setCommandCapabilities({ sddBingo: false });
134
138
  }
135
139
  finally {
136
140
  if (isCurrent()) {
@@ -227,7 +231,7 @@ export function useComposer(params) {
227
231
  }, 400);
228
232
  return () => window.clearTimeout(timer);
229
233
  }, [origin, sessionId, input]);
230
- const mergedCommands = useMemo(() => mergeSlashCommands(dynamicCommands), [dynamicCommands]);
234
+ const mergedCommands = useMemo(() => mergeSlashCommands(dynamicCommands, commandCapabilities), [dynamicCommands, commandCapabilities]);
231
235
  const filteredShortcuts = useMemo(() => input.startsWith("/")
232
236
  ? filterSlashCommands(mergedCommands, input)
233
237
  : [], [input, mergedCommands]);
@@ -32,6 +32,7 @@ import { ChatSessionStore } from "./chat/session-store.js";
32
32
  import { createChatEventStore, createChatOperationLinker, } from "./chat/chat-event-store.js";
33
33
  import { createChatTurnExecutionRegistry } from "./chat/turn-execution-registry.js";
34
34
  import { ConsolePiRuntime } from "./chat/pi-runtime.js";
35
+ import { AimaxAuthReader, SddDataAlignmentService } from "./chat/sdd-data-alignment.js";
35
36
  import { UserQuestionRegistry } from "./chat/user-questions.js";
36
37
  import { TodoListRegistry } from "./chat/todos.js";
37
38
  import { GoalRegistry } from "./chat/goals.js";
@@ -253,6 +254,7 @@ export async function createWorkspaceContext(record, deps) {
253
254
  }
254
255
  },
255
256
  });
257
+ const sddDataAlignment = new SddDataAlignmentService(chatStore, new AimaxAuthReader(), deps.fetchImpl);
256
258
  const chatEventEpoch = randomBytes(12).toString("hex");
257
259
  const chatEvents = createChatEventStore({
258
260
  appData,
@@ -291,6 +293,7 @@ export async function createWorkspaceContext(record, deps) {
291
293
  questionRegistry,
292
294
  todoRegistry,
293
295
  goalRegistry,
296
+ sddDataAlignment,
294
297
  });
295
298
  let chatRouteDeps;
296
299
  const goalRoundDriver = new GoalRoundDriver((sessionId) => driveGoalRound(chatRouteDeps, sessionId));
@@ -302,6 +305,7 @@ export async function createWorkspaceContext(record, deps) {
302
305
  questionRegistry,
303
306
  todoRegistry,
304
307
  goalRegistry,
308
+ sddDataAlignment,
305
309
  goalRoundDriver,
306
310
  operationLinker: chatOperationLinker,
307
311
  appData,
@@ -1463,7 +1463,7 @@ for top in ast.walk(tree):
1463
1463
  returned_names.add(node.value.id)
1464
1464
  if isinstance(node,ast.Call):
1465
1465
  for keyword in node.keywords:
1466
- if keyword.arg in {'payload','body','request_body','request_json','dto','json','data','json_body'} and isinstance(keyword.value,ast.Name) and keyword.value.id in positional:
1466
+ if keyword.arg in {'payload','body','request_body','request_json','dto','json','data','json_body','params','query','query_params'} and isinstance(keyword.value,ast.Name) and keyword.value.id in positional:
1467
1467
  transport.append((keyword.value.id,positional.index(keyword.value.id)-call_offset))
1468
1468
  if isinstance(node.func,ast.Attribute) and node.func.attr.lower() in {'post','put','patch'} and len(node.args) >= 2:
1469
1469
  candidate=node.args[1]
@@ -1484,7 +1484,7 @@ for top in ast.walk(tree):
1484
1484
  for node in ast.walk(top):
1485
1485
  if not isinstance(node,ast.Call): continue
1486
1486
  for keyword in node.keywords:
1487
- if keyword.arg in {'payload','body','request_body','request_json','dto','json','data','json_body'} and isinstance(keyword.value,ast.Name) and keyword.value.id in assigned:
1487
+ if keyword.arg in {'payload','body','request_body','request_json','dto','json','data','json_body','params','query','query_params'} and isinstance(keyword.value,ast.Name) and keyword.value.id in assigned:
1488
1488
  transport_shapes.append(assigned[keyword.value.id])
1489
1489
  if candidates: FUNCTION_PAYLOADS[top.name]=candidates
1490
1490
  if transport_shapes: FUNCTION_TRANSPORT_SHAPES[top.name]=transport_shapes
@@ -1540,8 +1540,9 @@ def literal(node):
1540
1540
  return repr(node.value)
1541
1541
  return None
1542
1542
 
1543
- def collect_dict(node, prefix, paths, values):
1543
+ def collect_dict(node, prefix, paths, values, parameters=None):
1544
1544
  if not isinstance(node, ast.Dict): return
1545
+ parameters = parameters or {}
1545
1546
  for key,value in zip(node.keys,node.values):
1546
1547
  if not isinstance(key, ast.Constant) or not isinstance(key.value,str): continue
1547
1548
  # Ignore writer helper/meta keys that are not DTO request fields.
@@ -1551,12 +1552,16 @@ def collect_dict(node, prefix, paths, values):
1551
1552
  paths.add(current)
1552
1553
  lv=literal(value)
1553
1554
  if lv is not None: values.setdefault(current,set()).add(lv)
1554
- if isinstance(value, ast.Dict): collect_dict(value,current,paths,values)
1555
+ elif isinstance(value, ast.Name) and value.id in parameters:
1556
+ for bound in parameters[value.id]:
1557
+ bound_lv=literal(bound)
1558
+ if bound_lv is not None: values.setdefault(current,set()).add(bound_lv)
1559
+ if isinstance(value, ast.Dict): collect_dict(value,current,paths,values,parameters)
1555
1560
  elif isinstance(value,(ast.List,ast.Tuple)):
1556
1561
  for item in value.elts:
1557
- if isinstance(item,ast.Dict): collect_dict(item,current,paths,values)
1562
+ if isinstance(item,ast.Dict): collect_dict(item,current,paths,values,parameters)
1558
1563
 
1559
- PAYLOAD_NAMES={'payload','body','request_body','request_json','dto','json','data','json_body'}
1564
+ PAYLOAD_NAMES={'payload','body','request_body','request_json','dto','json','data','json_body','params','query','query_params'}
1560
1565
 
1561
1566
  def shape_only(node):
1562
1567
  if isinstance(node,ast.Dict):
@@ -1637,6 +1642,7 @@ def decorator_payload_nodes(parameters,assigned):
1637
1642
 
1638
1643
  def decorator_payload_nodes_by_tp(fn,assigned):
1639
1644
  result={}
1645
+ function_payload_nodes=payload_nodes(fn)
1640
1646
  for dec in fn.decorator_list:
1641
1647
  if not isinstance(dec,ast.Call) or not isinstance(dec.func,ast.Attribute) or dec.func.attr != 'parametrize' or len(dec.args) < 2: continue
1642
1648
  if not isinstance(dec.args[0],ast.Constant) or not isinstance(dec.args[0].value,str): continue
@@ -1653,9 +1659,11 @@ def decorator_payload_nodes_by_tp(fn,assigned):
1653
1659
  parameters={}
1654
1660
  for index,name in enumerate(names):
1655
1661
  if index < len(node.args): parameters.setdefault(name,[]).append(node.args[index])
1656
- payload_nodes=decorator_payload_nodes(parameters,assigned)
1662
+ payload_nodes_for_tp=decorator_payload_nodes(parameters,assigned)
1663
+ if not payload_nodes_for_tp:
1664
+ payload_nodes_for_tp=function_payload_nodes
1657
1665
  paths=set(); values={}
1658
- for payload_node in payload_nodes: collect_dict(payload_node,'',paths,values)
1666
+ for payload_node in payload_nodes_for_tp: collect_dict(payload_node,'',paths,values,parameters)
1659
1667
  result[tp]={key:sorted(items) for key,items in sorted(values.items())}
1660
1668
  return result
1661
1669
 
@@ -1729,8 +1737,8 @@ for fn in ast.walk(tree):
1729
1737
  if isinstance(fn,(ast.FunctionDef,ast.AsyncFunctionDef)) and fn.name.startswith('test_'):
1730
1738
  paths=set(); values={}
1731
1739
  fallback_paths=set(); fallback_values={}
1732
- for node in payload_nodes(fn): collect_dict(node,'',paths,values)
1733
- for node in fallback_payload_nodes(fn): collect_dict(node,'',fallback_paths,fallback_values)
1740
+ for node in payload_nodes(fn): collect_dict(node,'',paths,values,{})
1741
+ for node in fallback_payload_nodes(fn): collect_dict(node,'',fallback_paths,fallback_values,{})
1734
1742
  out[fn.name]={
1735
1743
  'paths':sorted(paths),
1736
1744
  'values':{key:sorted(items) for key,items in sorted(values.items())},
@@ -1782,14 +1790,39 @@ function metadataTestPoints(region, label) {
1782
1790
  const match = new RegExp(`^\\s*${escaped}\\s*:\\s*(.*)$`, "mi").exec(region);
1783
1791
  return orderedUnique(match?.[1]?.match(/\bTP-[A-Z0-9]+(?:-[A-Z0-9]+)*\b/g) ?? []);
1784
1792
  }
1793
+ function isPytestDecoratorOrContinuationLine(line) {
1794
+ const trimmed = line.trim();
1795
+ if (!trimmed)
1796
+ return false;
1797
+ if (trimmed.startsWith("#"))
1798
+ return true;
1799
+ if (trimmed.startsWith("@"))
1800
+ return true;
1801
+ // Multi-line decorator argument lists: @pytest.mark.parametrize(\n "x", [\n pytest.param(..., id="TP-...")\n ]\n)
1802
+ return /^[\]\),]/.test(trimmed) || /[\(\[{,]$/.test(trimmed) || /\bpytest\.param\s*\(/.test(trimmed);
1803
+ }
1785
1804
  function pytestFunctionRegionStart(source, functionIndex) {
1786
1805
  let regionStart = source.lastIndexOf("\n", functionIndex - 1) + 1;
1787
1806
  let cursor = regionStart;
1807
+ let blankRun = 0;
1788
1808
  while (cursor > 0) {
1789
1809
  const previousEnd = cursor - 1;
1790
1810
  const previousStart = source.lastIndexOf("\n", previousEnd - 1) + 1;
1791
1811
  const previous = source.slice(previousStart, previousEnd).replace(/\r$/, "");
1792
- if (!previous.trim())
1812
+ const trimmed = previous.trim();
1813
+ if (!trimmed) {
1814
+ blankRun += 1;
1815
+ // Keep walking across a small blank gap so a decorator is still bound to
1816
+ // the following test when writers insert one empty line before `def`.
1817
+ if (blankRun > 2)
1818
+ break;
1819
+ cursor = previousStart;
1820
+ continue;
1821
+ }
1822
+ blankRun = 0;
1823
+ if (/^(?:async\s+)?def\s+/.test(trimmed) || /^(?:async\s+)?class\s+/.test(trimmed))
1824
+ break;
1825
+ if (!isPytestDecoratorOrContinuationLine(previous))
1793
1826
  break;
1794
1827
  regionStart = previousStart;
1795
1828
  cursor = previousStart;
@@ -1819,12 +1852,14 @@ function pytestFunctionBodyEnd(source, signatureEnd, functionIndent) {
1819
1852
  }
1820
1853
  function pytestParameterCollections(source) {
1821
1854
  const collections = new Map();
1822
- for (const match of source.matchAll(/^(_?[A-Z][A-Z0-9_]*)\s*=\s*\[(?![^\r\n]*\])([\s\S]*?)^\]/gm)) {
1855
+ for (const match of source.matchAll(/^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*\[(?![^\r\n]*\])([\s\S]*?)^\]/gm)) {
1823
1856
  const explicitIds = [...match[2].matchAll(/\bid\s*=\s*["'](TP-[A-Z0-9-]+)["']/g)].map((item) => item[1]);
1824
1857
  const factoryIds = explicitIds.length === 0
1825
1858
  ? [...match[2].matchAll(/["'](TP-[A-Z0-9-]+)["']/g)].map((item) => item[1])
1826
1859
  : [];
1827
- collections.set(match[1], orderedUnique([...explicitIds, ...factoryIds]));
1860
+ const ids = orderedUnique([...explicitIds, ...factoryIds]);
1861
+ if (ids.length > 0)
1862
+ collections.set(match[1], ids);
1828
1863
  }
1829
1864
  return collections;
1830
1865
  }
@@ -1886,6 +1921,12 @@ function normalizePythonLiteralValue(value) {
1886
1921
  if ((trimmed.startsWith("'") && trimmed.endsWith("'")) || (trimmed.startsWith('"') && trimmed.endsWith('"'))) {
1887
1922
  return trimmed.slice(1, -1);
1888
1923
  }
1924
+ if (trimmed === "True")
1925
+ return "true";
1926
+ if (trimmed === "False")
1927
+ return "false";
1928
+ if (trimmed === "None")
1929
+ return "null";
1889
1930
  return trimmed;
1890
1931
  }
1891
1932
  function intentionalInvalidEnumVariant(input) {
@@ -1930,13 +1971,15 @@ function assessPayloadContract(testCase, refs) {
1930
1971
  : [];
1931
1972
  const enumMismatches = [];
1932
1973
  for (const [field, allowed] of Object.entries(testCase.payloadContract.enumValues)) {
1933
- const observed = orderedUnique(refs.flatMap((item) => (useFallback ? item.fallbackPayloadValues[field] : item.payloadValues[field]) ?? []));
1974
+ const aggregated = orderedUnique(refs.flatMap((item) => (useFallback ? item.fallbackPayloadValues[field] : item.payloadValues[field]) ?? []));
1975
+ const byTestPoint = orderedUnique(refs.flatMap((item) => Object.values(item.payloadValuesByTestPoint).flatMap((values) => values[field] ?? [])));
1976
+ const observed = orderedUnique([...aggregated, ...byTestPoint]);
1934
1977
  for (const rawValue of observed) {
1935
1978
  const value = normalizePythonLiteralValue(rawValue);
1936
1979
  if (allowed.includes(value))
1937
1980
  continue;
1938
1981
  const associatedTestPoints = orderedUnique(refs.flatMap((item) => Object.entries(item.payloadValuesByTestPoint)
1939
- .filter(([, values]) => (values[field] ?? []).includes(rawValue))
1982
+ .filter(([, values]) => (values[field] ?? []).some((itemValue) => itemValue === rawValue || normalizePythonLiteralValue(itemValue) === value))
1940
1983
  .map(([testPoint]) => testPoint)));
1941
1984
  const intentionallyInvalid = associatedTestPoints.length > 0 && associatedTestPoints.every((testPoint) => intentionalInvalidEnumVariant({ testCase, testPoint, field, observedValue: rawValue, allowed }));
1942
1985
  if (!intentionallyInvalid)