diff --git a/web/hwlab-cloud-web/package.json b/web/hwlab-cloud-web/package.json index 837f5174..637c8799 100644 --- a/web/hwlab-cloud-web/package.json +++ b/web/hwlab-cloud-web/package.json @@ -6,7 +6,7 @@ "scripts": { "deps": "node ../../scripts/worktree-deps.mjs --web", "dev": "bun run deps --quiet && vite", - "check": "bun run deps --quiet && bun run scripts/check.ts && bun run scripts/tsc-check.ts && bun test scripts", + "check": "bun run deps --quiet && bun run scripts/tsc-check.ts && bun test scripts", "check:tsc": "bun run deps --quiet && bun run scripts/tsc-check.ts", "check:tsc-strict": "bun run deps --quiet && bun run scripts/tsc-check.ts --strict", "typecheck": "bun run deps --quiet && vue-tsc --noEmit", diff --git a/web/hwlab-cloud-web/scripts/tsc-check.ts b/web/hwlab-cloud-web/scripts/tsc-check.ts index bf2c69ec..8da002f8 100644 --- a/web/hwlab-cloud-web/scripts/tsc-check.ts +++ b/web/hwlab-cloud-web/scripts/tsc-check.ts @@ -4,41 +4,94 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); -const sourceFiles = collectSourceFiles(path.join(rootDir, "src")); +const explicitAnyLabel = "explicit " + "any"; +const sourceFiles = [ + ...collectSourceFiles(path.join(rootDir, "src")), + ...collectSourceFiles(path.join(rootDir, "scripts")) +]; let explicitAnyCount = 0; for (const file of sourceFiles) { const text = readFileSync(file, "utf8"); - const stripped = stripComments(text); - const matches = [ - ...stripped.matchAll(/:\s*any\b/gu), - ...stripped.matchAll(/<\s*any\s*>/gu), - ...stripped.matchAll(/as\s+any\b/gu), - ...stripped.matchAll(/Array\s*<\s*any\s*>/gu) - ]; - if (matches.length > 0) { - explicitAnyCount += matches.length; - console.error(` ${path.relative(rootDir, file)}: ${matches.length} explicit any usage(s)`); + const stripped = stripCommentsAndStrings(text); + const count = countExplicitAny(stripped); + if (count > 0) { + explicitAnyCount += count; + console.error(` ${path.relative(rootDir, file)}: ${count} ${explicitAnyLabel} usage(s)`); } } if (explicitAnyCount > 0) { - console.error(`hwlab-cloud-web tsc-check: ${explicitAnyCount} explicit any usage(s) in Vue source`); - process.exit(2); + console.error(`hwlab-cloud-web tsc-check: ${explicitAnyCount} ${explicitAnyLabel} usage(s) in Web source`); +} else { + console.log(`hwlab-cloud-web tsc-check: ${explicitAnyLabel} scan passed (0 usages)`); } +const sourceShapeStatus = runBunScript("source-shape", path.join(rootDir, "scripts/check.ts")); + const vueTscBin = resolveVueTscBin(rootDir); +let vueTscStatus = 0; if (!vueTscBin) { console.error("hwlab-cloud-web tsc-check: vue-tsc not found in ancestor node_modules/.bin or PATH"); - process.exit(2); + vueTscStatus = 2; +} else { + const result = spawnSync(vueTscBin, ["--noEmit", "--project", path.join(rootDir, "tsconfig.json")], { cwd: rootDir, encoding: "utf8" }); + if (result.stdout) process.stdout.write(result.stdout); + if (result.stderr) process.stderr.write(result.stderr); + if (result.error) console.error(`hwlab-cloud-web tsc-check: failed to start ${vueTscBin}: ${result.error.message}`); + vueTscStatus = result.status ?? (result.error ? 2 : 0); + if (vueTscStatus === 0) { + console.log("hwlab-cloud-web tsc-check: vue-tsc passed"); + } else { + console.error(`hwlab-cloud-web tsc-check: vue-tsc failed with exit ${result.status ?? "unknown"}`); + } } -const result = spawnSync(vueTscBin, ["--noEmit", "--project", path.join(rootDir, "tsconfig.json")], { cwd: rootDir, encoding: "utf8" }); -if (result.stdout) process.stdout.write(result.stdout); -if (result.stderr) process.stderr.write(result.stderr); -if (result.error) console.error(`hwlab-cloud-web tsc-check: failed to start ${vueTscBin}: ${result.error.message}`); -if (result.status !== 0) { - console.error(`hwlab-cloud-web tsc-check: vue-tsc failed with exit ${result.status ?? "unknown"}`); - process.exit(result.status ?? 2); + +if (explicitAnyCount > 0 || sourceShapeStatus !== 0 || vueTscStatus !== 0) { + console.error(`hwlab-cloud-web tsc-check: failed (source-shape=${sourceShapeStatus}, vue-tsc=${vueTscStatus}, ${explicitAnyLabel}=${explicitAnyCount})`); + process.exit(firstFailureStatus(sourceShapeStatus, vueTscStatus, explicitAnyCount > 0 ? 2 : 0)); +} +console.log(`hwlab-cloud-web tsc-check: passed (source-shape, vue-tsc, 0 ${explicitAnyLabel})`); + +function runBunScript(label: string, scriptPath: string): number { + const result = spawnSync(process.execPath, ["run", scriptPath], { cwd: rootDir, encoding: "utf8" }); + if (result.stdout) process.stdout.write(result.stdout); + if (result.stderr) process.stderr.write(result.stderr); + if (result.error) console.error(`hwlab-cloud-web tsc-check: failed to start ${label}: ${result.error.message}`); + const status = result.status ?? (result.error ? 2 : 0); + if (status === 0) console.log(`hwlab-cloud-web tsc-check: ${label} passed`); + else console.error(`hwlab-cloud-web tsc-check: ${label} failed with exit ${result.status ?? "unknown"}`); + return status; +} + +function firstFailureStatus(...statuses: number[]): number { + return statuses.find((status) => status !== 0) ?? 2; +} + +function explicitAnyPatterns(): RegExp[] { + const token = "a" + "ny"; + return [ + new RegExp(`:\\s*${token}\\b`, "gu"), + new RegExp(`\\bas\\s+${token}\\b`, "gu"), + new RegExp(`<[^>\\n]*\\b${token}\\b[^>\\n]*>`, "gu"), + new RegExp(`\\b${token}\\s*\\[\\s*\\]`, "gu"), + new RegExp(`\\b${token}\\s*(?:[|&;,)=]|$)`, "gu") + ]; +} + +function countExplicitAny(strippedSource: string): number { + const token = "a" + "ny"; + const tokenPattern = new RegExp(`\\b${token}\\b`, "gu"); + const positions = new Set(); + for (const pattern of explicitAnyPatterns()) { + for (const match of strippedSource.matchAll(pattern)) { + const index = match.index; + if (index === undefined) continue; + for (const tokenMatch of match[0].matchAll(tokenPattern)) { + if (tokenMatch.index !== undefined) positions.add(index + tokenMatch.index); + } + } + } + return positions.size; } -console.log("hwlab-cloud-web tsc-check: passed (strict Vue TS, 0 explicit any)"); function resolveVueTscBin(startDir: string): string | null { let current = startDir; @@ -76,6 +129,11 @@ function collectSourceFiles(dir: string): string[] { return out; } -function stripComments(text: string): string { - return text.replace(/\/\*[\s\S]*?\*\//gu, "").replace(/(^|[^:])\/\/.*$/gmu, "$1"); +function stripCommentsAndStrings(text: string): string { + return text + .replace(/\/\*[\s\S]*?\*\//gu, "") + .replace(/(^|[^:])\/\/.*$/gmu, "$1") + .replace(/`(?:\\[\s\S]|[^`\\])*`/gu, "``") + .replace(/"(?:\\.|[^"\\])*"/gu, '""') + .replace(/'(?:\\.|[^'\\])*'/gu, "''"); } diff --git a/web/hwlab-cloud-web/scripts/workbench-message-projection-runtime.test.ts b/web/hwlab-cloud-web/scripts/workbench-message-projection-runtime.test.ts index e905bc97..4533aff5 100644 --- a/web/hwlab-cloud-web/scripts/workbench-message-projection-runtime.test.ts +++ b/web/hwlab-cloud-web/scripts/workbench-message-projection-runtime.test.ts @@ -202,16 +202,22 @@ test("terminal seal blocks stale running snapshots after session restore", () => test("terminal projection page seal preserves final response object", () => { const previous = { + id: "msg_terminal_projection_previous", role: "agent", + title: "Code Agent", status: "completed", + createdAt: "2026-01-01T00:00:00.000Z", text: "final answer", finalResponse: { text: "final answer", status: "completed", sealed: true }, traceAutoLifecycle: "terminal", timing: { startedAt: "2026-01-01T00:00:00.000Z", finishedAt: "2026-01-01T00:00:05.000Z", durationMs: 5000, valuesRedacted: true } } as ChatMessage; const incoming = { + id: "msg_terminal_projection_incoming", role: "agent", + title: "Code Agent", status: "running", + createdAt: "2026-01-01T00:00:00.000Z", text: "", traceAutoLifecycle: "running", timing: { startedAt: "2026-01-01T00:00:00.000Z", valuesRedacted: true } diff --git a/web/hwlab-cloud-web/src/stores/workbench-message-projection-runtime.test.ts b/web/hwlab-cloud-web/src/stores/workbench-message-projection-runtime.test.ts index d78de6cb..f89f72fd 100644 --- a/web/hwlab-cloud-web/src/stores/workbench-message-projection-runtime.test.ts +++ b/web/hwlab-cloud-web/src/stores/workbench-message-projection-runtime.test.ts @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; import { test } from "bun:test"; +import type { AgentChatResultResponse, ChatMessage } from "../types"; import { messageHasTerminalResponse, messageNeedsTerminalDiagnostics, @@ -10,10 +11,29 @@ import { turnResultStatusForMerge } from "./workbench-message-projection-runtime"; +function agentMessage(input: Partial & Pick): ChatMessage { + return { + role: "agent", + title: "Code Agent", + text: "", + status: "running", + createdAt: "2026-07-01T00:00:00.000Z", + ...input + }; +} + +function turnResult(input: AgentChatResultResponse): AgentChatResultResponse { + return input; +} + +function record(value: unknown): Record | null { + return value && typeof value === "object" ? value as Record : null; +} + test("turn result merge keeps completed result without final response unsealed", () => { const traceId = "trc_frontend_terminal_without_final"; - const message = { id: "msg_frontend_terminal_without_final", role: "agent", status: "running", traceId } as any; - const result = { + const message = agentMessage({ id: "msg_frontend_terminal_without_final", status: "running", traceId }); + const result = turnResult({ traceId, status: "completed", terminal: true, @@ -26,7 +46,7 @@ test("turn result merge keeps completed result without final response unsealed", { seq: 2, type: "terminal_status", terminal: true, terminalStatus: "completed" } ] } - } as any; + }); assert.equal(turnResultStatusForMerge(result), "running"); assert.equal(turnResultIsTerminalForMerge(result), false); @@ -35,45 +55,45 @@ test("turn result merge keeps completed result without final response unsealed", test("turn result merge seals completed result with final response", () => { const traceId = "trc_frontend_terminal_with_final"; - const message = { id: "msg_frontend_terminal_with_final", role: "agent", status: "running", traceId } as any; - const result = { + const message = agentMessage({ id: "msg_frontend_terminal_with_final", status: "running", traceId }); + const result = turnResult({ traceId, status: "completed", terminal: true, finalResponse: { text: "final answer" } - } as any; + }); const patch = terminalMessagePatchFromTurnResult(message, result); assert.equal(turnResultStatusForMerge(result), "completed"); assert.equal(turnResultIsTerminalForMerge(result), true); assert.equal(patch?.status, "completed"); - assert.equal((patch?.finalResponse as any)?.text, "final answer"); + assert.equal(record(patch?.finalResponse)?.text, "final answer"); assert.equal(patch?.text, "final answer"); }); test("turn result merge seals failed result with failure final response", () => { const traceId = "trc_frontend_terminal_failed_with_body"; - const message = { id: "msg_frontend_terminal_failed_with_body", role: "agent", status: "running", traceId } as any; - const result = { + const message = agentMessage({ id: "msg_frontend_terminal_failed_with_body", status: "running", traceId }); + const result = turnResult({ traceId, status: "failed", terminal: true, finalResponse: { text: "Workbench terminal failed: provider-stream-disconnected", status: "failed" } - } as any; + }); const patch = terminalMessagePatchFromTurnResult(message, result); assert.equal(turnResultStatusForMerge(result), "failed"); assert.equal(turnResultIsTerminalForMerge(result), true); assert.equal(patch?.status, "failed"); - assert.equal((patch?.finalResponse as any)?.status, "failed"); - assert.equal((patch?.finalResponse as any)?.text, "Workbench terminal failed: provider-stream-disconnected"); + assert.equal(record(patch?.finalResponse)?.status, "failed"); + assert.equal(record(patch?.finalResponse)?.text, "Workbench terminal failed: provider-stream-disconnected"); assert.equal(patch?.text, "Workbench terminal failed: provider-stream-disconnected"); }); test("terminal response helpers treat failed body as sealed authority", () => { const traceId = "trc_frontend_terminal_failed_helper"; - const sealed = { id: "msg_frontend_terminal_failed_helper", role: "agent", status: "failed", traceId, text: "Workbench terminal failed: provider-stream-disconnected", finalResponse: { text: "Workbench terminal failed: provider-stream-disconnected" } } as any; - const unsealed = { id: "msg_frontend_terminal_failed_helper_unsealed", role: "agent", status: "failed", traceId } as any; + const sealed = agentMessage({ id: "msg_frontend_terminal_failed_helper", status: "failed", traceId, text: "Workbench terminal failed: provider-stream-disconnected", finalResponse: { text: "Workbench terminal failed: provider-stream-disconnected" } }); + const unsealed = agentMessage({ id: "msg_frontend_terminal_failed_helper_unsealed", status: "failed", traceId }); assert.equal(messageHasTerminalResponse(sealed), true); assert.equal(messageNeedsTerminalDiagnostics(sealed), false); diff --git a/web/hwlab-cloud-web/src/stores/workbench-realtime-plan.ts b/web/hwlab-cloud-web/src/stores/workbench-realtime-plan.ts index 164850b2..293c13f6 100644 --- a/web/hwlab-cloud-web/src/stores/workbench-realtime-plan.ts +++ b/web/hwlab-cloud-web/src/stores/workbench-realtime-plan.ts @@ -42,6 +42,8 @@ export type WorkbenchRealtimeRecoveryStep = | { type: "refresh-turn-status"; traceId: string; reason: string; force: false; authority: WorkbenchRealtimeRecoveryAuthority } | { type: "hydrate-trace-events"; traceId: string; reason: string; force: false; authority: WorkbenchRealtimeRecoveryAuthority }; +type WorkbenchRealtimeRecoveryStepInput = T extends WorkbenchRealtimeRecoveryStep ? Omit : never; + export interface WorkbenchRealtimeRecoveryPlan { sessionId: string | null; traceId: string | null; @@ -94,7 +96,7 @@ export function planWorkbenchRealtimeRecovery(recovery: WorkbenchStreamTransport }; } -function automaticRecoveryStep(step: Omit): WorkbenchRealtimeRecoveryStep { +function automaticRecoveryStep(step: WorkbenchRealtimeRecoveryStepInput): WorkbenchRealtimeRecoveryStep { return { ...step, authority: "automatic-recovery", force: false } as WorkbenchRealtimeRecoveryStep; } diff --git a/web/hwlab-cloud-web/src/stores/workbench-server-state.test.ts b/web/hwlab-cloud-web/src/stores/workbench-server-state.test.ts index c2e27a05..c3e4753f 100644 --- a/web/hwlab-cloud-web/src/stores/workbench-server-state.test.ts +++ b/web/hwlab-cloud-web/src/stores/workbench-server-state.test.ts @@ -39,6 +39,7 @@ test("server state reducer keeps completed message without final response unseal state = reduceWorkbenchServerState(state, { type: "session.messages", sessionId, messages: [agentMessage({ id: "msg_state_completed_without_final", status: "completed", traceId, sessionId, startedAt: "2026-07-01T00:00:00.000Z", finishedAt: "2026-07-01T00:00:02.000Z", durationMs: 2000 })] }); const [message] = selectActiveMessages(state, sessionId); + assert.ok(message); assert.equal(message.status, "running"); assert.equal(message.finishedAt, null); assert.equal(message.durationMs, null); @@ -54,6 +55,7 @@ test("server state reducer keeps failed message without terminal body unsealed", state = reduceWorkbenchServerState(state, { type: "session.messages", sessionId, messages: [agentMessage({ id: "msg_state_failed_without_body", status: "failed", traceId, sessionId, startedAt: "2026-07-01T00:00:00.000Z", finishedAt: "2026-07-01T00:00:02.000Z", durationMs: 2000 })] }); const [message] = selectActiveMessages(state, sessionId); + assert.ok(message); assert.equal(message.status, "running"); assert.equal(message.finishedAt, null); assert.equal(message.durationMs, null); @@ -70,6 +72,7 @@ test("server state reducer seals completed message when final response exists", state = reduceWorkbenchServerState(state, { type: "turn.status", turn: { traceId, status: "completed", running: false, terminal: true, sessionId, updatedAt: "2026-07-01T00:00:02.000Z" } }); const [message] = selectActiveMessages(state, sessionId); + assert.ok(message); assert.equal(message.status, "completed"); assert.equal(message.text, "final answer"); assert.equal(selectSessionStatusAuthority(state)[sessionId]?.status, "completed"); @@ -94,6 +97,7 @@ test("server state reducer keeps sealed terminal messages when a stale partial s const messages = selectActiveMessages(state, sessionId); assert.equal(messages.length, 4); const terminal = messages.find((message) => message.id === "msg_state_partial_agent_2"); + assert.ok(terminal); assert.equal(terminal.status, "completed"); assert.equal(terminal.text, "final answer"); assert.equal((terminal.finalResponse as { text?: string } | undefined)?.text, "final answer"); diff --git a/web/hwlab-cloud-web/src/stores/workbench-server-state.ts b/web/hwlab-cloud-web/src/stores/workbench-server-state.ts index 57cf46a8..3daf973b 100644 --- a/web/hwlab-cloud-web/src/stores/workbench-server-state.ts +++ b/web/hwlab-cloud-web/src/stores/workbench-server-state.ts @@ -213,7 +213,8 @@ function mergeMessageList(existing: ChatMessage[], incoming: ChatMessage[]): Cha const message = normalizeUnsealedTerminalMessage(rawMessage); const index = merged.findIndex((item) => messageMatchesSnapshot(item, message)); if (index >= 0) { - merged[index] = sealExistingMessageTiming(merged[index], message); + const existing = merged[index]; + merged[index] = existing ? sealExistingMessageTiming(existing, message) : message; } else { merged.push(message); } @@ -438,8 +439,8 @@ function sessionStatusAuthorityFromTurn(turn: TurnStatusAuthority): SessionStatu } function mergeSessionStatusAuthority(existing: SessionStatusAuthority | undefined, incoming: SessionStatusAuthority): SessionStatusAuthority { - if (isTerminalSessionStatusAuthority(existing) && !isTerminalSessionStatusAuthority(incoming) && isSameTraceAuthority(existing, incoming) && isRunningSessionStatus(incoming.status)) return existing; - if (isRunningSessionStatus(existing?.status) && isTerminalSessionStatusAuthority(incoming) && isDifferentTraceAuthority(existing, incoming)) return existing; + if (existing && isTerminalSessionStatusAuthority(existing) && !isTerminalSessionStatusAuthority(incoming) && isSameTraceAuthority(existing, incoming) && isRunningSessionStatus(incoming.status)) return existing; + if (existing && isRunningSessionStatus(existing.status) && isTerminalSessionStatusAuthority(incoming) && isDifferentTraceAuthority(existing, incoming)) return existing; return { ...(existing ?? {}), ...incoming, diff --git a/web/hwlab-cloud-web/src/stores/workbench-session.test.ts b/web/hwlab-cloud-web/src/stores/workbench-session.test.ts index c77a99f9..53d577e9 100644 --- a/web/hwlab-cloud-web/src/stores/workbench-session.test.ts +++ b/web/hwlab-cloud-web/src/stores/workbench-session.test.ts @@ -1,18 +1,35 @@ import assert from "node:assert/strict"; import { test } from "bun:test"; +import type { ChatMessage } from "../types"; import { resolveCancelableAgentMessage, resolveComposerState } from "./workbench-session"; +import type { TurnStatusAuthority } from "./workbench-session"; + +function agentMessage(input: Partial & Pick): ChatMessage { + return { + role: "agent", + title: "Code Agent", + text: "", + status: "running", + createdAt: "2026-07-01T00:00:00.000Z", + ...input + }; +} + +function turnStatus(input: TurnStatusAuthority): TurnStatusAuthority { + return input; +} test("composer treats completed message without final response as unsealed running turn", () => { const traceId = "trc_session_completed_without_final"; const sessionId = "ses_session_completed_without_final"; - const message = { id: "msg_session_completed_without_final", role: "agent", status: "completed", traceId, sessionId } as any; + const message = agentMessage({ id: "msg_session_completed_without_final", status: "completed", traceId, sessionId }); const composer = resolveComposerState({ messages: [message], activeSessionId: sessionId, chatPending: true, currentRequest: { traceId, sessionId, status: "running" }, - turnStatusAuthority: { [traceId]: { traceId, sessionId, status: "completed", terminal: true, running: false } as any } + turnStatusAuthority: { [traceId]: turnStatus({ traceId, sessionId, status: "completed", terminal: true, running: false }) } }); assert.equal(composer.submitMode, "steer"); assert.equal(composer.targetTraceId, traceId); @@ -21,13 +38,13 @@ test("composer treats completed message without final response as unsealed runni test("composer treats failed message without terminal body as unsealed running turn", () => { const traceId = "trc_session_failed_without_body"; const sessionId = "ses_session_failed_without_body"; - const message = { id: "msg_session_failed_without_body", role: "agent", status: "failed", traceId, sessionId } as any; + const message = agentMessage({ id: "msg_session_failed_without_body", status: "failed", traceId, sessionId }); const composer = resolveComposerState({ messages: [message], activeSessionId: sessionId, chatPending: true, currentRequest: { traceId, sessionId, status: "running" }, - turnStatusAuthority: { [traceId]: { traceId, sessionId, status: "failed", terminal: true, running: false } as any } + turnStatusAuthority: { [traceId]: turnStatus({ traceId, sessionId, status: "failed", terminal: true, running: false }) } }); assert.equal(composer.submitMode, "steer"); assert.equal(composer.targetTraceId, traceId); @@ -36,13 +53,13 @@ test("composer treats failed message without terminal body as unsealed running t test("composer treats failed message with terminal body as sealed terminal turn", () => { const traceId = "trc_session_failed_with_body"; const sessionId = "ses_session_failed_with_body"; - const message = { id: "msg_session_failed_with_body", role: "agent", status: "failed", traceId, sessionId, text: "Workbench terminal failed: provider-stream-disconnected" } as any; + const message = agentMessage({ id: "msg_session_failed_with_body", status: "failed", traceId, sessionId, text: "Workbench terminal failed: provider-stream-disconnected" }); const composer = resolveComposerState({ messages: [message], activeSessionId: sessionId, chatPending: true, currentRequest: { traceId, sessionId, status: "running" }, - turnStatusAuthority: { [traceId]: { traceId, sessionId, status: "failed", terminal: true, running: false } as any } + turnStatusAuthority: { [traceId]: turnStatus({ traceId, sessionId, status: "failed", terminal: true, running: false }) } }); assert.equal(composer.submitMode, "turn"); assert.equal(composer.targetTraceId, null); @@ -51,19 +68,19 @@ test("composer treats failed message with terminal body as sealed terminal turn" test("cancel target remains available for completed message until final response exists", () => { const traceId = "trc_cancel_completed_without_final"; const sessionId = "ses_cancel_completed_without_final"; - const unsealed = { id: "msg_cancel_completed_without_final", role: "agent", status: "completed", traceId, sessionId } as any; - const sealed = { ...unsealed, id: "msg_cancel_completed_with_final", text: "final answer" } as any; + const unsealed = agentMessage({ id: "msg_cancel_completed_without_final", status: "completed", traceId, sessionId }); + const sealed = agentMessage({ ...unsealed, id: "msg_cancel_completed_with_final", text: "final answer" }); - assert.equal(resolveCancelableAgentMessage({ messages: [unsealed], targetTraceId: traceId, targetSessionId: sessionId, turnStatusAuthority: { [traceId]: { traceId, sessionId, status: "running", running: true, terminal: false } as any } })?.id, unsealed.id); - assert.equal(resolveCancelableAgentMessage({ messages: [sealed], targetTraceId: traceId, targetSessionId: sessionId, turnStatusAuthority: { [traceId]: { traceId, sessionId, status: "running", running: true, terminal: false } as any } }), null); + assert.equal(resolveCancelableAgentMessage({ messages: [unsealed], targetTraceId: traceId, targetSessionId: sessionId, turnStatusAuthority: { [traceId]: turnStatus({ traceId, sessionId, status: "running", running: true, terminal: false }) } })?.id, unsealed.id); + assert.equal(resolveCancelableAgentMessage({ messages: [sealed], targetTraceId: traceId, targetSessionId: sessionId, turnStatusAuthority: { [traceId]: turnStatus({ traceId, sessionId, status: "running", running: true, terminal: false }) } }), null); }); test("cancel target remains available for failed message until terminal body exists", () => { const traceId = "trc_cancel_failed_without_body"; const sessionId = "ses_cancel_failed_without_body"; - const unsealed = { id: "msg_cancel_failed_without_body", role: "agent", status: "failed", traceId, sessionId } as any; - const sealed = { ...unsealed, id: "msg_cancel_failed_with_body", text: "Workbench terminal failed: provider-stream-disconnected" } as any; + const unsealed = agentMessage({ id: "msg_cancel_failed_without_body", status: "failed", traceId, sessionId }); + const sealed = agentMessage({ ...unsealed, id: "msg_cancel_failed_with_body", text: "Workbench terminal failed: provider-stream-disconnected" }); - assert.equal(resolveCancelableAgentMessage({ messages: [unsealed], targetTraceId: traceId, targetSessionId: sessionId, turnStatusAuthority: { [traceId]: { traceId, sessionId, status: "running", running: true, terminal: false } as any } })?.id, unsealed.id); - assert.equal(resolveCancelableAgentMessage({ messages: [sealed], targetTraceId: traceId, targetSessionId: sessionId, turnStatusAuthority: { [traceId]: { traceId, sessionId, status: "running", running: true, terminal: false } as any } }), null); + assert.equal(resolveCancelableAgentMessage({ messages: [unsealed], targetTraceId: traceId, targetSessionId: sessionId, turnStatusAuthority: { [traceId]: turnStatus({ traceId, sessionId, status: "running", running: true, terminal: false }) } })?.id, unsealed.id); + assert.equal(resolveCancelableAgentMessage({ messages: [sealed], targetTraceId: traceId, targetSessionId: sessionId, turnStatusAuthority: { [traceId]: turnStatus({ traceId, sessionId, status: "running", running: true, terminal: false }) } }), null); });