diff --git a/internal/cloud/server-agent-chat.test.ts b/internal/cloud/server-agent-chat.test.ts index c01f0aca..a62ba32e 100644 --- a/internal/cloud/server-agent-chat.test.ts +++ b/internal/cloud/server-agent-chat.test.ts @@ -2781,6 +2781,68 @@ test("cloud api /v1/agent/chat/cancel reports unsupported lifecycle degradation" } }); +test("cloud api /v1/agent/chat/cancel rejects AgentRun trace scope mismatch", async () => { + const traceId = "trc_cancel_scope_mismatch"; + const codeAgentChatResults = new Map(); + const traceStore = createCodeAgentTraceStore(); + codeAgentChatResults.set(traceId, { + traceId, + status: "running", + conversationId: "cnv_cancel_scope", + sessionId: "ses_cancel_scope", + threadId: "thread-cancel-scope", + agentRun: { + runId: "run_cancel_scope", + commandId: "cmd_cancel_scope", + sessionId: "ses_agentrun_cancel_scope", + conversationId: "cnv_cancel_scope", + threadId: "thread-cancel-scope", + managerUrl: "http://127.0.0.1:65535", + status: "running", + commandState: "running" + } + }); + const server = createCloudApiServer({ + traceStore, + codeAgentChatResults, + env: { + PATH: process.env.PATH, + HWLAB_CODE_AGENT_ADAPTER: "agentrun-v01", + HWLAB_CODE_AGENT_PROVIDER: "agentrun-v01", + HWLAB_CODE_AGENT_AGENTRUN_ALLOW_NON_K3S_URL: "1" + } + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + + try { + const { port } = server.address(); + const response = await fetch(`http://127.0.0.1:${port}/v1/agent/chat/cancel`, { + method: "POST", + headers: { + "content-type": "application/json", + "x-trace-id": traceId + }, + body: JSON.stringify({ + conversationId: "cnv_cancel_scope_other", + sessionId: "ses_cancel_scope_other", + threadId: "thread-cancel-scope-other", + traceId + }) + }); + assert.equal(response.status, 409); + const payload = await response.json(); + assert.equal(payload.status, "blocked"); + assert.equal(payload.canceled, false); + assert.equal(payload.error.code, "cancel_scope_mismatch"); + assert.equal(codeAgentChatResults.get(traceId)?.status, "running"); + assert.ok(traceStore.snapshot(traceId)?.events?.some((event) => event.label === "agentrun:cancel:scope_mismatch")); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + } +}); + test("cloud api /v1/agent/chat reports Codex stdio blocker instead of structured skills fallback", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-no-skills-")); const skillsDir = path.join(root, "missing-skills"); diff --git a/internal/cloud/server-code-agent-http.ts b/internal/cloud/server-code-agent-http.ts index 7740bc74..2e4ced19 100644 --- a/internal/cloud/server-code-agent-http.ts +++ b/internal/cloud/server-code-agent-http.ts @@ -1298,6 +1298,30 @@ export async function handleCodeAgentCancelHttp(request, response, options) { const snapshot = traceId ? traceStore.snapshot(traceId) : null; const currentResult = traceId ? options.codeAgentChatResults?.get(traceId) ?? await loadPersistedAgentRunResult(traceId, options) : null; if (currentResult?.agentRun?.commandId) { + const mismatch = codeAgentCancelScopeMismatch(params, currentResult); + if (mismatch) { + traceStore.append(traceId, { + type: "cancel", + status: "blocked", + label: "agentrun:cancel:scope_mismatch", + errorCode: "cancel_scope_mismatch", + message: `Cancel request ${mismatch.field} does not match the trace owner; AgentRun cancel was not forwarded.`, + requested: mismatch.requested, + expected: mismatch.expected, + waitingFor: "cancel-scope", + valuesPrinted: false + }); + sendJson(response, 409, cancelBlockedPayload({ + code: "cancel_scope_mismatch", + message: `取消请求的 ${mismatch.field} 与 trace 归属不一致,已拒绝转发 AgentRun cancel,避免误取消其他 session。`, + traceId, + conversationId: safeConversationId(params.conversationId) || safeConversationId(currentResult.conversationId) || null, + sessionId: safeSessionId(params.sessionId) || safeSessionId(currentResult.sessionId) || null, + runnerTrace: traceStore.snapshot(traceId), + status: "blocked" + })); + return; + } const payload = await cancelAgentRunChatTurn({ traceId, currentResult, options, traceStore }); if (payload) { await recordCodeAgentSessionOwner({ payload, params: { ...params, traceId, ownerUserId: options.actor?.id, ownerRole: options.actor?.role }, options, status: "canceled" }); @@ -2171,6 +2195,36 @@ function isCodeAgentResultCanceled(result) { return result?.status === "canceled" || result?.canceled === true || result?.error?.code === "codex_stdio_canceled"; } +function codeAgentCancelScopeMismatch(params = {}, result = {}) { + return cancelScopeFieldMismatch("conversationId", safeConversationId(params.conversationId), cancelExpectedValues(safeConversationId, + result.conversationId, + result.agentRun?.conversationId, + result.session?.conversationId, + result.sessionReuse?.conversationId, + result.runnerTrace?.conversationId + )) ?? cancelScopeFieldMismatch("sessionId", safeSessionId(params.sessionId), cancelExpectedValues(safeSessionId, + result.sessionId, + result.session?.sessionId, + result.sessionReuse?.sessionId, + result.agentRun?.hwlabSessionId + )) ?? cancelScopeFieldMismatch("threadId", safeOpaqueId(params.threadId), cancelExpectedValues(safeOpaqueId, + result.threadId, + result.agentRun?.threadId, + result.session?.threadId, + result.sessionReuse?.threadId, + result.runnerTrace?.threadId + )); +} + +function cancelExpectedValues(normalize, ...values) { + return [...new Set(values.map((value) => normalize(value)).filter(Boolean))]; +} + +function cancelScopeFieldMismatch(field, requested, expectedValues) { + if (!requested || expectedValues.length === 0 || expectedValues.includes(requested)) return null; + return { field, requested, expected: expectedValues[0] }; +} + function cancelBlockedPayload({ code, message, diff --git a/web/hwlab-cloud-web/scripts/workbench-r2-session-parity.test.ts b/web/hwlab-cloud-web/scripts/workbench-r2-session-parity.test.ts index 61455566..01ac192f 100644 --- a/web/hwlab-cloud-web/scripts/workbench-r2-session-parity.test.ts +++ b/web/hwlab-cloud-web/scripts/workbench-r2-session-parity.test.ts @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import type { ChatMessage, ConversationRecord, WorkspaceRecord } from "../src/types/index.ts"; -import { defaultProviderProfileOptions, mergeActiveConversationActivity, mergeSelectedConversation, normalizeRecentDrafts, normalizeWorkbenchMessageTitle, providerProfileOptionsFromPayload, recordRecentDraft, resolveComposerState, resolveConversationSessionStatus, shouldApplyWorkspaceSnapshot, shouldShowSessionListLoading, sortSessionTabs, workspaceWithClearedActiveTrace } from "../src/stores/workbench-session.ts"; +import { defaultProviderProfileOptions, mergeActiveConversationActivity, mergeSelectedConversation, normalizeRecentDrafts, normalizeWorkbenchMessageTitle, providerProfileOptionsFromPayload, recordRecentDraft, resolveCancelableAgentMessage, resolveComposerState, resolveConversationSessionStatus, shouldApplyWorkspaceSnapshot, shouldShowSessionListLoading, sortSessionTabs, workspaceWithClearedActiveTrace } from "../src/stores/workbench-session.ts"; test("R2 composer ignores stale workspace activeTraceId without verified active message", () => { const workspace = workspaceRecord({ activeTraceId: "trc_stale", sessionStatus: "running" }); @@ -21,6 +21,27 @@ test("R2 composer steers only when an active agent message or status exists", () assert.equal(composer.targetTraceId, "trc_running"); }); +test("R2 composer cancel targets only the active conversation trace", () => { + const messages: ChatMessage[] = [ + agentMessage({ id: "msg_other", status: "running", traceId: "trc_other", conversationId: "cnv_other", sessionId: "ses_other", threadId: "thr_other" }), + agentMessage({ id: "msg_active", status: "running", traceId: "trc_active", conversationId: "cnv_active", sessionId: "ses_active", threadId: "thr_active" }) + ]; + assert.equal(resolveCancelableAgentMessage({ + messages, + activeConversationId: "cnv_active", + targetTraceId: "trc_active", + targetSessionId: "ses_active", + targetThreadId: "thr_active" + })?.traceId, "trc_active"); + assert.equal(resolveCancelableAgentMessage({ + messages, + activeConversationId: "cnv_active", + targetTraceId: "trc_other", + targetSessionId: "ses_active", + targetThreadId: "thr_active" + }), null); +}); + test("R2 composer never reuses a stale workspace session during routed selection", () => { const workspace = workspaceRecord({ activeTraceId: null, sessionStatus: "idle" }); const withoutTarget = resolveComposerState({ workspace, messages: [], conversations: [], activeConversationId: "cnv_route", chatPending: false }); diff --git a/web/hwlab-cloud-web/src/stores/workbench-session.ts b/web/hwlab-cloud-web/src/stores/workbench-session.ts index ce9476f4..88f7f1dd 100644 --- a/web/hwlab-cloud-web/src/stores/workbench-session.ts +++ b/web/hwlab-cloud-web/src/stores/workbench-session.ts @@ -74,6 +74,19 @@ export function resolveComposerState(input: { workspace: WorkspaceRecord | null; return { disabled: false, disabledReason: null, submitMode: "turn", route: "/v1/agent/chat", targetTraceId: null, conversationId, sessionId, threadId }; } +export function resolveCancelableAgentMessage(input: { messages: ChatMessage[]; activeConversationId: string | null; targetTraceId?: string | null; targetSessionId?: string | null; targetThreadId?: string | null }): ChatMessage | null { + const targetTraceId = firstNonEmptyString(input.targetTraceId); + if (!targetTraceId) return null; + for (const message of [...input.messages].reverse()) { + if (message.role !== "agent") continue; + if (!isActiveStatus(message.status) && !isActiveStatus(message.runnerTrace?.status)) continue; + if (firstNonEmptyString(message.traceId, message.runnerTrace?.traceId) !== targetTraceId) continue; + if (!messageBelongsToCancelTarget(message, input)) continue; + return message; + } + return null; +} + export function activeTraceIdFromWorkspace(workspace: WorkspaceRecord | null): string | null { return firstNonEmptyString(workspace?.activeTraceId, workspace?.workspace?.activeTraceId); } @@ -364,6 +377,20 @@ function findActiveAgentMessage(messages: ChatMessage[], activeConversationId: s return null; } +function messageBelongsToCancelTarget(message: ChatMessage, input: { activeConversationId: string | null; targetSessionId?: string | null; targetThreadId?: string | null }): boolean { + const activeConversationId = firstNonEmptyString(input.activeConversationId); + const messageConversationId = firstNonEmptyString(message.conversationId); + if (activeConversationId && messageConversationId) return messageConversationId === activeConversationId; + if (activeConversationId && !messageConversationId) { + const targetSessionId = firstNonEmptyString(input.targetSessionId); + const targetThreadId = firstNonEmptyString(input.targetThreadId); + const messageSessionId = firstNonEmptyString(message.sessionId, message.runnerTrace?.sessionId); + const messageThreadId = firstNonEmptyString(message.threadId, message.runnerTrace?.threadId); + return Boolean((targetSessionId && messageSessionId === targetSessionId) || (targetThreadId && messageThreadId === targetThreadId)); + } + return true; +} + function latestConversationMessage(messages: ChatMessage[], activeConversationId: string | null): ChatMessage | null { for (const message of [...messages].reverse()) { if (activeConversationId && message.conversationId && message.conversationId !== activeConversationId) continue; diff --git a/web/hwlab-cloud-web/src/stores/workbench.ts b/web/hwlab-cloud-web/src/stores/workbench.ts index 82fec6bd..54d8a9b3 100644 --- a/web/hwlab-cloud-web/src/stores/workbench.ts +++ b/web/hwlab-cloud-web/src/stores/workbench.ts @@ -4,7 +4,7 @@ import { api } from "@/api"; import { mergeRunnerTrace, snapshotToRunnerTrace, subscribeToTrace, type TraceSnapshot } from "@/composables/useTraceSubscription"; import type { AgentChatResponse, AgentChatResultResponse, AgentRunProvenance, ApiResult, ChatMessage, ConversationRecord, LiveSurface, ProviderProfile, TraceEvent, WorkspaceRecord } from "@/types"; import { DEFAULT_WORKBENCH_PROJECT_ID, firstNonEmptyString, nextProtocolId, normalizeWorkbenchConversationId, rememberWorkbenchProjectId, resolveInitialWorkbenchProjectId, workspaceProjectId } from "@/utils"; -import { RECENT_DRAFTS_STORAGE_KEY, activeTraceIdFromWorkspace, conversationDisplayUpdatedAt, defaultProviderProfileOptions, latestUserMessageAtFromMessages, mergeActiveConversationActivity, mergeSelectedConversation, normalizeRecentDrafts, normalizeWorkbenchMessageTitle, providerProfileOptionsFromPayload, recordRecentDraft, resolveComposerState, selectedConversationIdFromWorkspace, shouldApplyWorkspaceSnapshot, shouldShowSessionListLoading, sortSessionTabs, workspaceWithClearedActiveTrace, type DraftEntry, type ProviderProfileOption } from "./workbench-session"; +import { RECENT_DRAFTS_STORAGE_KEY, activeTraceIdFromWorkspace, conversationDisplayUpdatedAt, defaultProviderProfileOptions, latestUserMessageAtFromMessages, mergeActiveConversationActivity, mergeSelectedConversation, normalizeRecentDrafts, normalizeWorkbenchMessageTitle, providerProfileOptionsFromPayload, recordRecentDraft, resolveCancelableAgentMessage, resolveComposerState, selectedConversationIdFromWorkspace, shouldApplyWorkspaceSnapshot, shouldShowSessionListLoading, sortSessionTabs, workspaceWithClearedActiveTrace, type DraftEntry, type ProviderProfileOption } from "./workbench-session"; const DEFAULT_CODE_AGENT_TIMEOUT_MS = 1_800_000; const DEFAULT_GATEWAY_TIMEOUT_MS = 120_000; @@ -257,9 +257,14 @@ export const useWorkbenchStore = defineStore("workbench", () => { } async function cancelRunningTrace(): Promise { - const traceId = messages.value.find((message) => message.status === "running" && message.traceId)?.traceId; - if (!traceId) return; - const message = messages.value.find((item) => item.traceId === traceId && item.role === "agent"); + const target = composer.value; + const message = resolveCancelableAgentMessage({ + messages: messages.value, + activeConversationId: activeConversationId.value, + targetTraceId: target.targetTraceId, + targetSessionId: target.sessionId, + targetThreadId: target.threadId + }); if (message) await cancelAgentMessage(message); }