Merge pull request #1334 from pikasTech/fix/1332-cancel-scope
fix: 限定 Code Agent cancel 目标归属
This commit is contained in:
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user