Merge pull request #2347 from pikasTech/fix/1445-lane-a-web-tsc-gate
fix: harden Cloud Web tsc gate
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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<number>();
|
||||
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, "''");
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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<ChatMessage> & Pick<ChatMessage, "id" | "traceId">): 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<string, unknown> | null {
|
||||
return value && typeof value === "object" ? value as Record<string, unknown> : 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);
|
||||
|
||||
@@ -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> = T extends WorkbenchRealtimeRecoveryStep ? Omit<T, "authority" | "force"> : never;
|
||||
|
||||
export interface WorkbenchRealtimeRecoveryPlan {
|
||||
sessionId: string | null;
|
||||
traceId: string | null;
|
||||
@@ -94,7 +96,7 @@ export function planWorkbenchRealtimeRecovery(recovery: WorkbenchStreamTransport
|
||||
};
|
||||
}
|
||||
|
||||
function automaticRecoveryStep(step: Omit<WorkbenchRealtimeRecoveryStep, "authority" | "force">): WorkbenchRealtimeRecoveryStep {
|
||||
function automaticRecoveryStep(step: WorkbenchRealtimeRecoveryStepInput<WorkbenchRealtimeRecoveryStep>): WorkbenchRealtimeRecoveryStep {
|
||||
return { ...step, authority: "automatic-recovery", force: false } as WorkbenchRealtimeRecoveryStep;
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<ChatMessage> & Pick<ChatMessage, "id" | "traceId" | "sessionId">): 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);
|
||||
});
|
||||
|
||||
@@ -1442,6 +1442,14 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
||||
return messageHasTerminalResponse(message);
|
||||
}
|
||||
|
||||
function traceAuthorityIsSealed(turn: TurnStatusAuthority | undefined, message: ChatMessage | null): boolean {
|
||||
return Boolean((turn?.terminal === true || isTerminalMessageStatus(turn?.status)) && messageHasSealedTerminalResult(message));
|
||||
}
|
||||
|
||||
function messageIsSealedTerminal(message: ChatMessage | null): boolean {
|
||||
return messageHasSealedTerminalResult(message);
|
||||
}
|
||||
|
||||
function failTrace(traceId: string, message: string): void {
|
||||
markMessage(traceId, { status: "failed", text: message });
|
||||
chatPending.value = false;
|
||||
|
||||
Reference in New Issue
Block a user