@tea-agent/loop-agent 0.33.4 → 0.33.5

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 CHANGED
@@ -2,6 +2,34 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.33.5] - 2026-08-11
6
+
7
+ ### 重点更新
8
+
9
+ - 全面加固 Operator Chat 流式响应的执行可靠性,对齐 pi-web 四层保障机制,彻底解决重连后界面无限加载、空占位气泡卡死等问题
10
+ - 优化前端测试的目标 URL 解析机制,改为从运行时上下文精准解析,并丰富执行报告的细节展示
11
+ - 修复测试生成与执行环境中的多处转义错误,提升复杂脚本生成的稳定性
12
+
13
+ ### 新增
14
+
15
+ - 新增轻量级会话状态轮询接口(GET /sessions/:id/state),用于快速获取真实的活跃任务状态
16
+ - 前端测试执行报告新增更丰富的细节展示,并支持从运行时上下文中精准解析目标 URL
17
+
18
+ ### 改进
19
+
20
+ - Operator Chat 客户端现支持每 15 秒及页面可见性变化时定期对账服务端真实状态,当服务端已空闲而客户端仍在加载时自动强制结束
21
+ - 优化重连时的回放保护机制:仅当回放事件的任务 ID 与服务端真实活跃 ID 匹配时才继续流式响应,防止已结束的任务被错误唤醒
22
+ - 前端测试默认短链失败用例现仅重跑 1 次,而阻断性审查节点分支默认重跑 2 次,显式配置仍可覆盖默认值
23
+
24
+ ### 修复
25
+
26
+ - 修复 Operator Chat 在中断或失败时未写入持久结束事件,导致重连回放卡在开始阶段、界面无限加载的问题
27
+ - 修复对话中断或结束时残留空白占位气泡和卡死省略号的问题,现自动清理未产生内容的消息
28
+ - 修复前端测试重跑选择器生成的 Shell JavaScript 丢失正则反斜杠和换行转义,导致报语法错误的问题
29
+ - 修复前端测试环境预检脚本将 URL 尾部反引号匹配规则展开为裸 Shell 反引号,导致命令替换未闭合的问题
30
+ - 修复后端测试偶尔按优先级生成单一模块文件的问题,现对非法模块命名执行严格阻断
31
+ - 修复后端测试分片完整性校验误判合法测试字符串中未配对分隔符为脚本截断的问题
32
+
5
33
  ## [0.33.4] - 2026-08-10
6
34
 
7
35
  ### 重点更新
@@ -324,25 +324,47 @@ export async function writePiExecutorArtifacts(artifactsDir, input) {
324
324
  await writeTextArtifactFile(summaryPath, buildPiResultSummaryMarkdown(input));
325
325
  return { promptPath, summaryPath };
326
326
  }
327
- async function resolveControllerOwnedFrontendBaseUrl(runDir) {
328
- const capabilityPath = path.join(runDir, "preflight-frontend-browser-tool-shell", "frontend-browser-capability.json");
329
- let capability;
327
+ async function resolveValidatedFrontendBaseUrlFromContext(workspaceRoot, runDir) {
330
328
  try {
331
- capability = JSON.parse(await readFile(capabilityPath, "utf8"));
329
+ const capability = JSON.parse(await readFile(path.join(runDir, "preflight-frontend-browser-tool-shell", "frontend-browser-capability.json"), "utf8"));
330
+ if (capability.status !== "ready" ||
331
+ capability.structuredToolName !== "playwright_cli") {
332
+ throw new Error("invalid capability artifact");
333
+ }
334
+ }
335
+ catch {
336
+ throw new Error("browser-command-capability-unavailable: missing or invalid frontend browser capability artifact");
337
+ }
338
+ const contextPath = path.join(workspaceRoot, "testcase", "frontend", "rag", "context.md");
339
+ let markdown;
340
+ try {
341
+ markdown = await readFile(contextPath, "utf8");
332
342
  }
333
343
  catch {
334
- throw new Error("browser-command-capability-unavailable: missing controller-owned frontend browser capability artifact");
344
+ throw new Error("browser-command-capability-unavailable: missing frontend context.md after environment preflight");
345
+ }
346
+ const patterns = [
347
+ /baseUrl\s*[:=]\s*["'`]?((?:https?):\/\/[^\s"'`<>]+)/i,
348
+ /base[-_ ]url\s*[:=]\s*["'`]?((?:https?):\/\/[^\s"'`<>]+)/i,
349
+ /playwright-cli open --browser=chrome\s+((?:https?):\/\/[^\s"'`<>]+)/i,
350
+ ];
351
+ let rawBaseUrl;
352
+ for (const pattern of patterns) {
353
+ const match = markdown.match(pattern);
354
+ if (match?.[1]) {
355
+ rawBaseUrl = match[1].replace(/[)\]},.;]+$/, "");
356
+ break;
357
+ }
335
358
  }
336
- if (capability.status !== "ready" ||
337
- typeof capability.baseUrl !== "string") {
338
- throw new Error("browser-command-capability-unavailable: invalid controller-owned frontend browser capability artifact");
359
+ if (!rawBaseUrl || !/environmentProbe\s*[:=]\s*reachable/i.test(markdown)) {
360
+ throw new Error("browser-command-capability-unavailable: frontend context URL is missing or not environment-validated");
339
361
  }
340
362
  let parsed;
341
363
  try {
342
- parsed = new URL(capability.baseUrl);
364
+ parsed = new URL(rawBaseUrl);
343
365
  }
344
366
  catch {
345
- throw new Error("browser-command-capability-unavailable: invalid controller-owned frontend browser origin");
367
+ throw new Error("browser-command-capability-unavailable: invalid frontend context origin");
346
368
  }
347
369
  if ((parsed.protocol !== "http:" && parsed.protocol !== "https:") ||
348
370
  parsed.username ||
@@ -350,7 +372,7 @@ async function resolveControllerOwnedFrontendBaseUrl(runDir) {
350
372
  parsed.search ||
351
373
  parsed.hash ||
352
374
  /(?:^|\.)(?:www\.)?[^.]*(?:prod|production)/i.test(parsed.hostname)) {
353
- throw new Error("browser-command-capability-unavailable: unsafe controller-owned frontend browser origin");
375
+ throw new Error("browser-command-capability-unavailable: unsafe frontend context origin");
354
376
  }
355
377
  return parsed.toString();
356
378
  }
@@ -466,7 +488,7 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
466
488
  nodeId: input.task.id,
467
489
  caseId,
468
490
  evidenceDir,
469
- baseUrl: await resolveControllerOwnedFrontendBaseUrl(meta.runDir),
491
+ baseUrl: await resolveValidatedFrontendBaseUrlFromContext(input.cwd, meta.runDir),
470
492
  inputRoot: "testcase/frontend/fixtures",
471
493
  };
472
494
  // Cleanup must be confirmed before a new case; otherwise default-session
@@ -1,5 +1,4 @@
1
1
  import { spawn } from "node:child_process";
2
- import { createHash } from "node:crypto";
3
2
  import { appendFileSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
4
3
  import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
5
4
  import path from "node:path";
@@ -30,9 +29,9 @@ import { formatTraceabilityGateStdout, materializeBackendTestCaseManifest, runBa
30
29
  import { materializeBackendTestExecutionContract } from "../workflows/dag/backend-test-execution-contract.js";
31
30
  import { analyzeBackendTestCaseCoverage, analyzeBackendTestMarkdownPytestCorrespondence, materializeBackendTestCaseManifestFromFacts, } from "../workflows/dag/backend-test-case-coverage-analysis.js";
32
31
  import { materializeBackendTestResultFromPytestHtml, materializeBackendTestResultFromRunDir, parsePytestHtmlReport, } from "../workflows/dag/backend-test-result-contract.js";
33
- import { collectBackendTestHumanCaseCatalog, collectBackendTestMappedPytestScripts, resolveBackendTestMappedPytestScripts, collectJacocoCoverage, hasBlockingBackendMarkdownSafetyFindings, inspectBackendTestEnvironment, requiredBackendMarkdownCaseAcIds, renderBackendTestFacts, renderBackendTestHtml, renderBackendTestL5Dashboard, redactBackendTestOutput, validateBackendMarkdownCases, validateBackendMarkdownTraceability, writeRunReport, } from "../workflows/dag/backend-test-markdown-workflow.js";
32
+ import { collectBackendTestHumanCaseCatalog, collectBackendTestMappedPytestScripts, resolveBackendTestMappedPytestScripts, collectJacocoCoverage, hasBlockingBackendMarkdownSafetyFindings, hasBlockingBackendMarkdownModuleStemFindings, inspectBackendTestEnvironment, requiredBackendMarkdownCaseAcIds, renderBackendTestFacts, renderBackendTestHtml, renderBackendTestL5Dashboard, redactBackendTestOutput, validateBackendMarkdownCases, validateBackendMarkdownTraceability, writeRunReport, } from "../workflows/dag/backend-test-markdown-workflow.js";
34
33
  import { applyDeterministicScenarioParamRepairs, assessBackendScenarioParamConsistency, classifyBackendTestFailureWithScenarioParam, readBackendScenarioParamFacts, renderBackendTestFailureAnalysis, writeBackendScenarioParamArtifacts, writeScenarioParamRepairAudit, } from "../workflows/dag/backend-test-scenario-param.js";
35
- import { assessBackendPytestCollection, assessMissingBackendPytestScripts, assertBackendTestExecutionReadinessFresh, buildBackendPytestAssetInventory, materializeBackendTestExecutionReadiness, materializeEffectiveBackendPytestCollection, readBackendPytestCollectionFacts, readBackendTestExecutionReadiness, writeBackendPytestCollectionArtifacts, } from "../workflows/dag/backend-test-pytest-collection.js";
34
+ import { assessBackendPytestCollection, assessMissingBackendPytestScripts, assessPriorityOnlyBackendPytestModules, assertBackendTestExecutionReadinessFresh, buildBackendPytestAssetInventory, materializeBackendTestExecutionReadiness, materializeEffectiveBackendPytestCollection, readBackendPytestCollectionFacts, readBackendTestExecutionReadiness, writeBackendPytestCollectionArtifacts, } from "../workflows/dag/backend-test-pytest-collection.js";
36
35
  import { computeL5ReportMetrics } from "../workflows/dag/l5-report-metrics.js";
37
36
  import { buildBackendTestCanonicalResultFromInitialShellSnippet, materializeBackendTestClassification, } from "../workflows/dag/backend-test-classification-contract.js";
38
37
  import { backendTestSemanticReviewSchema, materializeBackendTestSemanticReview, } from "../workflows/dag/backend-test-semantic-review-contract.js";
@@ -40,7 +39,6 @@ import { pathsChangedDuringRun, readGitStatusPorcelain, snapshotGitStatusPorcela
40
39
  import { parseAndValidateFinalWriteSetApproval, } from "../workflows/dag/node-execution.js";
41
40
  import { buildShellProcessEnv } from "./shell-verification.js";
42
41
  import { readRunState } from "../workflows/dag/run-store.js";
43
- import { resolveDagTaskSourcePath } from "../task/dag-source-paths.js";
44
42
  import { readProjectGovernanceContext } from "../workflows/dag/project-governance-context.js";
45
43
  import { assertMavenPlanFresh, MavenPlanStaleError, } from "../verification/maven/index.js";
46
44
  async function runBackendScenarioParamPreflight(input) {
@@ -197,56 +195,6 @@ export function resolveShellCwd(root, requestedCwd) {
197
195
  }
198
196
  return resolved;
199
197
  }
200
- async function resolveControllerFrontendBaseUrlForPreflight(input) {
201
- const binding = input.spec.sourceBinding;
202
- const candidates = (binding?.sources ?? []).filter((source) => /(?:^|\/)config\.md$/i.test(source.path));
203
- let rawBaseUrl = "http://localhost:5173";
204
- let baseUrlSource = "default-localhost-5173";
205
- let baseUrlSourceSha256;
206
- for (const source of candidates) {
207
- const absolutePath = resolveDagTaskSourcePath({
208
- workspaceRoot: input.workspaceRoot,
209
- taskId: binding.taskId,
210
- sourcePath: source.path,
211
- });
212
- const markdown = await readFile(absolutePath, "utf8");
213
- const actualSha256 = createHash("sha256").update(markdown).digest("hex");
214
- if (actualSha256 !== source.sha256) {
215
- throw new Error(`browser-command-capability-unavailable: stale frontend source ${source.path}`);
216
- }
217
- const match = markdown.match(/(?:frontend[_-]?base[_-]?url|base[_-]?url|base[- ]url)\s*[:=]\s*["'`]?((?:https?):\/\/[^\s"'`<>]+)/i);
218
- if (!match?.[1]) {
219
- if (/(?:frontend[_-]?base[_-]?url|base[_-]?url|base[- ]url)\s*[:=]/i.test(markdown)) {
220
- throw new Error(`browser-command-capability-unavailable: invalid controller baseUrl from ${source.path}`);
221
- }
222
- continue;
223
- }
224
- rawBaseUrl = match[1].replace(/[)\]},.;]+$/, "");
225
- baseUrlSource = source.path;
226
- baseUrlSourceSha256 = source.sha256;
227
- break;
228
- }
229
- let parsed;
230
- try {
231
- parsed = new URL(rawBaseUrl);
232
- }
233
- catch {
234
- throw new Error(`browser-command-capability-unavailable: invalid controller baseUrl from ${baseUrlSource}`);
235
- }
236
- if ((parsed.protocol !== "http:" && parsed.protocol !== "https:") ||
237
- parsed.username ||
238
- parsed.password ||
239
- parsed.search ||
240
- parsed.hash ||
241
- /(?:^|\.)(?:www\.)?[^.]*(?:prod|production)/i.test(parsed.hostname)) {
242
- throw new Error(`browser-command-capability-unavailable: unsafe controller baseUrl from ${baseUrlSource}`);
243
- }
244
- return {
245
- baseUrl: parsed.toString(),
246
- baseUrlSource,
247
- ...(baseUrlSourceSha256 ? { baseUrlSourceSha256 } : {}),
248
- };
249
- }
250
198
  async function resolveJsonArtifactSourceNodeId(input) {
251
199
  const candidates = [
252
200
  input.fromNodeId,
@@ -598,6 +546,15 @@ async function executeBackendTestPipeline(input, meta) {
598
546
  durationMs: Date.now() - started,
599
547
  };
600
548
  }
549
+ if (hasBlockingBackendMarkdownModuleStemFindings(report)) {
550
+ return {
551
+ ok: false,
552
+ stdout: outputs.join("\n"),
553
+ stderr: "backend Markdown module stem gate blocked: priority-only-module-stem",
554
+ failureCategory: "invalid-output",
555
+ durationMs: Date.now() - started,
556
+ };
557
+ }
601
558
  const sourceBinding = meta.spec.sourceBinding;
602
559
  if (!sourceBinding) {
603
560
  const coverage = "# Backend Test Case Coverage Analysis\n\n## Status\n\nUNAVAILABLE\n\n## Findings\n\n- Coverage analysis requires spec.sourceBinding; node 4 and node 7 fail closed on the same task/source binding contract.\n";
@@ -651,7 +608,16 @@ async function executeBackendTestPipeline(input, meta) {
651
608
  else if (pipeline === "markdown-collection-assess") {
652
609
  const resolution = await resolveBackendTestMappedPytestScripts(input.cwd);
653
610
  let facts;
654
- if (resolution.missingScripts.length > 0) {
611
+ const preflightInventory = await buildBackendPytestAssetInventory(input.cwd, resolution.existingScripts, { requireMappedScripts: false });
612
+ const priorityOnlyFacts = assessPriorityOnlyBackendPytestModules({
613
+ phase: "initial",
614
+ mappedScripts: resolution.expectedScripts,
615
+ inventory: preflightInventory,
616
+ });
617
+ if (priorityOnlyFacts) {
618
+ facts = priorityOnlyFacts;
619
+ }
620
+ else if (resolution.missingScripts.length > 0) {
655
621
  const inventory = await buildBackendPytestAssetInventory(input.cwd, resolution.existingScripts, { requireMappedScripts: false });
656
622
  facts = assessMissingBackendPytestScripts({
657
623
  mappedScripts: resolution.expectedScripts,
@@ -2138,17 +2104,15 @@ export async function executeDagShellNode(input, meta) {
2138
2104
  if (!sdkCapability.ok) {
2139
2105
  throw new Error(`browser-command-capability-unavailable: ${sdkCapability.detail}`);
2140
2106
  }
2141
- const controllerOrigin = await resolveControllerFrontendBaseUrlForPreflight({
2142
- workspaceRoot: input.cwd,
2143
- spec: meta.spec,
2144
- });
2145
2107
  const toolProbe = await createPlaywrightCliTool({
2146
2108
  repoRoot: input.cwd,
2147
2109
  runDir: meta.runDir,
2148
2110
  nodeId: input.task.id,
2149
2111
  caseId: "frontend-browser-preflight",
2150
2112
  evidenceDir: "testcase/frontend/evidence/frontend-browser-preflight",
2151
- baseUrl: controllerOrigin.baseUrl,
2113
+ // Capability-only registration probe. The tested URL is selected later by
2114
+ // retrieve-frontend-test-context-pi and validated by the environment shell.
2115
+ baseUrl: "http://localhost:5173/",
2152
2116
  inputRoot: "testcase/frontend/fixtures",
2153
2117
  });
2154
2118
  if (toolProbe.name !== "playwright_cli") {
@@ -2165,7 +2129,7 @@ export async function executeDagShellNode(input, meta) {
2165
2129
  const help = `${result.stdout}\n${result.stderr}`;
2166
2130
  if (result.exitCode !== 0 ||
2167
2131
  result.timedOut ||
2168
- !["open", "close", "snapshot", "click"].every((command) => new RegExp(`\\b${command}\\b`, "i").test(help))) {
2132
+ !["open", "close", "find", "snapshot", "click"].every((command) => new RegExp(`\\b${command}\\b`, "i").test(help))) {
2169
2133
  throw new Error("playwright-cli-contract-incompatible");
2170
2134
  }
2171
2135
  const capability = {
@@ -2175,11 +2139,7 @@ export async function executeDagShellNode(input, meta) {
2175
2139
  launcher: "verified-js-entry",
2176
2140
  sdkCustomToolCapability: true,
2177
2141
  structuredToolName: toolProbe.name,
2178
- baseUrl: controllerOrigin.baseUrl,
2179
- baseUrlSource: controllerOrigin.baseUrlSource,
2180
- ...(controllerOrigin.baseUrlSourceSha256
2181
- ? { baseUrlSourceSha256: controllerOrigin.baseUrlSourceSha256 }
2182
- : {}),
2142
+ targetUrlAuthority: "retrieve-context-and-environment-probe",
2183
2143
  };
2184
2144
  await writeDagNodeJsonArtifact(meta.runDir, input.task.id, "frontend-browser-capability.json", capability);
2185
2145
  return {
@@ -85,8 +85,8 @@ export const frontendTestConfigSchema = z.object({
85
85
  strictOutcomeGate: z.boolean().optional(),
86
86
  /**
87
87
  * Maximum rerun attempts for blocked or missing-result frontend cases.
88
- * Defaults to 2; the static graph always carries a single rerun select+map
89
- * pair and bounds candidates within that pair via this value.
88
+ * Defaults to 1 for the lean off/advisory topology and 2 for the full
89
+ * reviewMode=blocking topology. An explicit value overrides either default.
90
90
  */
91
91
  maxRerunAttempts: z.number().int().min(0).max(4).optional(),
92
92
  /**
@@ -41,7 +41,8 @@ function controllerSummary(operation) {
41
41
  const record = identity;
42
42
  const fingerprint = typeof record.fingerprint === "string"
43
43
  ? record.fingerprint
44
- : typeof record.packageFingerprint?.value === "string"
44
+ : typeof record.packageFingerprint
45
+ ?.value === "string"
45
46
  ? String(record.packageFingerprint.value)
46
47
  : undefined;
47
48
  const label = [record.packageName, record.packageVersion, fingerprint]
@@ -61,7 +62,12 @@ export function projectOperationForChat(operation) {
61
62
  const workerRunId = safeIdentity(params.workerRunId);
62
63
  const controller = controllerSummary(operation);
63
64
  const previewHash = createHash("sha256")
64
- .update(JSON.stringify({ stdoutPreview, stderrPreview, summaryPreview, state: operation.state }))
65
+ .update(JSON.stringify({
66
+ stdoutPreview,
67
+ stderrPreview,
68
+ summaryPreview,
69
+ state: operation.state,
70
+ }))
65
71
  .digest("hex");
66
72
  return {
67
73
  schemaVersion: 1,
@@ -79,25 +85,34 @@ export function projectOperationForChat(operation) {
79
85
  ...(stdoutPreview ? { stdoutPreview } : {}),
80
86
  ...(stderrPreview ? { stderrPreview } : {}),
81
87
  ...(summaryPreview ? { summaryPreview } : {}),
82
- ...(operation.errorCode ? { errorCode: safeIdentity(operation.errorCode) } : {}),
88
+ ...(operation.errorCode
89
+ ? { errorCode: safeIdentity(operation.errorCode) }
90
+ : {}),
83
91
  previewHash,
84
92
  };
85
93
  }
86
94
  export function projectCompactSnapshot(value) {
87
- const record = value && typeof value === "object" ? value : {};
95
+ const record = value && typeof value === "object"
96
+ ? value
97
+ : {};
88
98
  const summary = boundedRedactedText(record.summary, 12_000) ?? "Context compacted.";
89
99
  const firstKeptEntryId = safeIdentity(record.firstKeptEntryId);
90
- const tokensBefore = typeof record.tokensBefore === "number" && Number.isFinite(record.tokensBefore)
100
+ const tokensBefore = typeof record.tokensBefore === "number" &&
101
+ Number.isFinite(record.tokensBefore)
91
102
  ? Math.max(0, Math.floor(record.tokensBefore))
92
103
  : undefined;
93
- const details = record.details === undefined ? undefined : boundedRedactedJson(record.details).preview;
104
+ const details = record.details === undefined
105
+ ? undefined
106
+ : boundedRedactedJson(record.details).preview;
94
107
  return {
95
108
  schemaVersion: 1,
96
109
  summary,
97
110
  ...(firstKeptEntryId ? { firstKeptEntryId } : {}),
98
111
  ...(tokensBefore !== undefined ? { tokensBefore } : {}),
99
112
  ...(details ? { details } : {}),
100
- previewHash: createHash("sha256").update(JSON.stringify({ summary, firstKeptEntryId, tokensBefore, details })).digest("hex"),
113
+ previewHash: createHash("sha256")
114
+ .update(JSON.stringify({ summary, firstKeptEntryId, tokensBefore, details }))
115
+ .digest("hex"),
101
116
  };
102
117
  }
103
118
  function boundedRedactedJson(value) {
@@ -145,7 +160,12 @@ export function projectOperationEventSummary(event) {
145
160
  ...(messagePreview ? { messagePreview } : {}),
146
161
  ...(dataPreview ? { dataPreview } : {}),
147
162
  previewHash: createHash("sha256")
148
- .update(JSON.stringify({ messagePreview, dataPreview, state: event.state, kind: event.kind }))
163
+ .update(JSON.stringify({
164
+ messagePreview,
165
+ dataPreview,
166
+ state: event.state,
167
+ kind: event.kind,
168
+ }))
149
169
  .digest("hex"),
150
170
  };
151
171
  }
@@ -166,16 +186,17 @@ export function createChatOperationLinker(options) {
166
186
  if (event.kind !== "operation-ref")
167
187
  return false;
168
188
  const data = event.data;
169
- return event.turnId === input.turnId &&
189
+ return (event.turnId === input.turnId &&
170
190
  data.operationId === input.operationId &&
171
- data.toolCallId === input.toolCallId;
191
+ data.toolCallId === input.toolCallId);
172
192
  });
173
193
  }
174
194
  async function syncFact(input) {
175
195
  const operation = await options.operations.get(input.operationId);
176
196
  if (!operation)
177
197
  return;
178
- const focused = isTerminalOperationState(operation.state) || operation.state === "needs-reconcile";
198
+ const focused = isTerminalOperationState(operation.state) ||
199
+ operation.state === "needs-reconcile";
179
200
  if (!focused)
180
201
  return;
181
202
  const projection = projectOperationForChat(operation);
@@ -184,14 +205,16 @@ export function createChatOperationLinker(options) {
184
205
  : operation.state === "failed" || operation.state === "timed-out"
185
206
  ? "failed"
186
207
  : "terminal";
187
- const duplicate = options.chatEvents.snapshot(input.sessionId).some((event) => {
208
+ const duplicate = options.chatEvents
209
+ .snapshot(input.sessionId)
210
+ .some((event) => {
188
211
  if (event.kind !== "operation-status")
189
212
  return false;
190
213
  const data = event.data;
191
- return data.operationId === input.operationId &&
214
+ return (data.operationId === input.operationId &&
192
215
  data.closureKind === closureKind &&
193
216
  data.operation?.state === projection.state &&
194
- data.operation?.previewHash === projection.previewHash;
217
+ data.operation?.previewHash === projection.previewHash);
195
218
  });
196
219
  if (!duplicate) {
197
220
  options.chatEvents.append(input.sessionId, input.turnId, {
@@ -237,7 +260,9 @@ export function createChatOperationLinker(options) {
237
260
  return {
238
261
  link,
239
262
  async recoverSession(sessionId) {
240
- const refs = options.chatEvents.snapshot(sessionId).filter((event) => event.kind === "operation-ref");
263
+ const refs = options.chatEvents
264
+ .snapshot(sessionId)
265
+ .filter((event) => event.kind === "operation-ref");
241
266
  for (const event of refs) {
242
267
  const data = event.data;
243
268
  if (!data.operationId || !data.toolCallId)
@@ -339,8 +364,14 @@ export function createChatEventStore(options) {
339
364
  return {
340
365
  forkContextRefs(input) {
341
366
  const allowed = new Set([
342
- "operation-ref", "operation-status", "draft", "assessment",
343
- "taskKind-confirmed", "human-gate-card", "compact", "artifact-ref",
367
+ "operation-ref",
368
+ "operation-status",
369
+ "draft",
370
+ "assessment",
371
+ "taskKind-confirmed",
372
+ "human-gate-card",
373
+ "compact",
374
+ "artifact-ref",
344
375
  ]);
345
376
  let copiedCount = 0;
346
377
  for (const event of this.snapshot(input.sourceSessionId)) {
@@ -351,7 +382,10 @@ export function createChatEventStore(options) {
351
382
  delete data.humanGateToken;
352
383
  delete data.dagBytesPath;
353
384
  }
354
- this.append(input.targetSessionId, input.targetTurnId, { kind: event.kind, data: data });
385
+ this.append(input.targetSessionId, input.targetTurnId, {
386
+ kind: event.kind,
387
+ data: data,
388
+ });
355
389
  copiedCount += 1;
356
390
  }
357
391
  return { copiedCount };
@@ -402,6 +436,11 @@ export function createChatEventStore(options) {
402
436
  const turn = ring.turns.find((candidate) => candidate.state === "queued" || candidate.state === "running");
403
437
  return turn ? { ...turn } : undefined;
404
438
  },
439
+ latestTurn(sessionId) {
440
+ const ring = ringFor(sessionId);
441
+ const turn = ring.turns.at(-1);
442
+ return turn ? { ...turn } : undefined;
443
+ },
405
444
  append(sessionId, turnId, partial) {
406
445
  const ring = ringFor(sessionId);
407
446
  const seq = ring.nextSeq++;