fix(gateway): harden chat abort semantics

This commit is contained in:
Peter Steinberger
2026-01-10 17:23:16 +01:00
parent 84d64f9395
commit a1533a17f7
12 changed files with 456 additions and 111 deletions

View File

@@ -2,12 +2,17 @@ import { randomUUID } from "node:crypto";
import { resolveThinkingDefault } from "../../agents/model-selection.js";
import { resolveAgentTimeoutMs } from "../../agents/timeout.js";
import { isAbortTrigger } from "../../auto-reply/reply/abort.js";
import { agentCommand } from "../../commands/agent.js";
import { mergeSessionEntry, saveSessionStore } from "../../config/sessions.js";
import { registerAgentRunContext } from "../../infra/agent-events.js";
import { defaultRuntime } from "../../runtime.js";
import { resolveSendPolicy } from "../../sessions/send-policy.js";
import {
abortChatRunById,
abortChatRunsForSessionKey,
isChatStopCommandText,
resolveChatRunExpiresAtMs,
} from "../chat-abort.js";
import { buildMessageWithAttachments } from "../chat-attachments.js";
import {
ErrorCodes,
@@ -97,11 +102,32 @@ export const chatHandlers: GatewayRequestHandlers = {
}
const { sessionKey, runId } = params as {
sessionKey: string;
runId: string;
runId?: string;
};
const ops = {
chatAbortControllers: context.chatAbortControllers,
chatRunBuffers: context.chatRunBuffers,
chatDeltaSentAt: context.chatDeltaSentAt,
chatAbortedRuns: context.chatAbortedRuns,
removeChatRun: context.removeChatRun,
agentRunSeq: context.agentRunSeq,
broadcast: context.broadcast,
bridgeSendToSession: context.bridgeSendToSession,
};
if (!runId) {
const res = abortChatRunsForSessionKey(ops, {
sessionKey,
stopReason: "rpc",
});
respond(true, { ok: true, aborted: res.aborted, runIds: res.runIds });
return;
}
const active = context.chatAbortControllers.get(runId);
if (!active) {
respond(true, { ok: true, aborted: false });
respond(true, { ok: true, aborted: false, runIds: [] });
return;
}
if (active.sessionKey !== sessionKey) {
@@ -116,21 +142,16 @@ export const chatHandlers: GatewayRequestHandlers = {
return;
}
active.controller.abort();
context.chatAbortControllers.delete(runId);
context.chatRunBuffers.delete(runId);
context.chatDeltaSentAt.delete(runId);
context.removeChatRun(runId, runId, sessionKey);
const payload = {
const res = abortChatRunById(ops, {
runId,
sessionKey,
seq: (context.agentRunSeq.get(runId) ?? 0) + 1,
state: "aborted" as const,
};
context.broadcast("chat", payload);
context.bridgeSendToSession(sessionKey, "chat", payload);
respond(true, { ok: true, aborted: true });
stopReason: "rpc",
});
respond(true, {
ok: true,
aborted: res.aborted,
runIds: res.aborted ? [runId] : [],
});
},
"chat.send": async ({ params, respond, context }) => {
if (!validateChatSendParams(params)) {
@@ -158,12 +179,7 @@ export const chatHandlers: GatewayRequestHandlers = {
timeoutMs?: number;
idempotencyKey: string;
};
const stopCommand = (() => {
const msg = p.message.trim();
if (!msg) return false;
const normalized = msg.toLowerCase();
return normalized === "/stop" || isAbortTrigger(msg);
})();
const stopCommand = isChatStopCommandText(p.message);
const normalizedAttachments =
p.attachments?.map((a) => ({
type: typeof a?.type === "string" ? a.type : undefined,
@@ -231,29 +247,20 @@ export const chatHandlers: GatewayRequestHandlers = {
}
if (stopCommand) {
const runIds: string[] = [];
for (const [runId, active] of context.chatAbortControllers) {
if (active.sessionKey !== p.sessionKey) continue;
active.controller.abort();
context.chatAbortControllers.delete(runId);
context.chatRunBuffers.delete(runId);
context.chatDeltaSentAt.delete(runId);
context.removeChatRun(runId, runId, p.sessionKey);
const payload = {
runId,
sessionKey: p.sessionKey,
seq: (context.agentRunSeq.get(runId) ?? 0) + 1,
state: "aborted" as const,
};
context.broadcast("chat", payload);
context.bridgeSendToSession(p.sessionKey, "chat", payload);
runIds.push(runId);
}
respond(true, {
ok: true,
aborted: runIds.length > 0,
runIds,
});
const res = abortChatRunsForSessionKey(
{
chatAbortControllers: context.chatAbortControllers,
chatRunBuffers: context.chatRunBuffers,
chatDeltaSentAt: context.chatDeltaSentAt,
chatAbortedRuns: context.chatAbortedRuns,
removeChatRun: context.removeChatRun,
agentRunSeq: context.agentRunSeq,
broadcast: context.broadcast,
bridgeSendToSession: context.bridgeSendToSession,
},
{ sessionKey: p.sessionKey, stopReason: "stop" },
);
respond(true, { ok: true, aborted: res.aborted, runIds: res.runIds });
return;
}
@@ -282,6 +289,8 @@ export const chatHandlers: GatewayRequestHandlers = {
controller: abortController,
sessionId,
sessionKey: p.sessionKey,
startedAtMs: now,
expiresAtMs: resolveChatRunExpiresAtMs({ now, timeoutMs }),
});
context.addChatRun(clientRunId, {
sessionKey: p.sessionKey,

View File

@@ -4,6 +4,7 @@ import type { HealthSummary } from "../../commands/health.js";
import type { CronService } from "../../cron/service.js";
import type { startNodeBridgeServer } from "../../infra/bridge/server.js";
import type { WizardSession } from "../../wizard/session.js";
import type { ChatAbortControllerEntry } from "../chat-abort.js";
import type {
ConnectParams,
ErrorShape,
@@ -49,10 +50,8 @@ export type GatewayRequestContext = {
) => void;
hasConnectedMobileNode: () => boolean;
agentRunSeq: Map<string, number>;
chatAbortControllers: Map<
string,
{ controller: AbortController; sessionId: string; sessionKey: string }
>;
chatAbortControllers: Map<string, ChatAbortControllerEntry>;
chatAbortedRuns: Map<string, number>;
chatRunBuffers: Map<string, string>;
chatDeltaSentAt: Map<string, number>;
addChatRun: (