Merge pull request #1334 from pikasTech/fix/1332-cancel-scope

fix: 限定 Code Agent cancel 目标归属
This commit is contained in:
Lyon
2026-06-16 15:42:53 +08:00
committed by GitHub
5 changed files with 174 additions and 5 deletions
+62
View File
@@ -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");
+54
View File
@@ -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,
@@ -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 });
@@ -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;
+9 -4
View File
@@ -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<void> {
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);
}