fix: wait for final agent response in sessions_send

This commit is contained in:
Peter Steinberger
2026-01-04 00:40:40 +01:00
parent e07fdd117d
commit 3bc24bf179
2 changed files with 75 additions and 182 deletions

View File

@@ -1,54 +1,10 @@
import { describe, expect, it, vi } from "vitest";
const callGatewayMock = vi.fn();
const nextRunId = "run-1";
const nextRunState: "done" | "error" = "done";
vi.mock("../gateway/call.js", () => ({
callGateway: (opts: unknown) => callGatewayMock(opts),
}));
vi.mock("../gateway/client.js", () => ({
GatewayClient: class {
private opts: {
onEvent?: (evt: {
event?: string;
payload?: {
runId?: string;
stream?: string;
data?: Record<string, unknown>;
};
}) => void;
};
constructor(opts: {
onEvent?: (evt: {
event?: string;
payload?: {
runId?: string;
stream?: string;
data?: Record<string, unknown>;
};
}) => void;
}) {
this.opts = opts;
}
start() {
setTimeout(() => {
this.opts.onEvent?.({
event: "agent",
payload: {
runId: nextRunId,
stream: "job",
data:
nextRunState === "error" ? { state: "error" } : { state: "done" },
},
});
}, 1);
}
stop() {}
},
}));
vi.mock("../config/config.js", () => ({
loadConfig: () => ({
session: { mainKey: "main", scope: "per-sender" },
@@ -60,6 +16,7 @@ import { createClawdisTools } from "./clawdis-tools.js";
describe("sessions tools", () => {
it("sessions_list filters kinds and includes messages", async () => {
callGatewayMock.mockReset();
callGatewayMock.mockImplementation(async (opts: unknown) => {
const request = opts as { method?: string };
if (request.method === "sessions.list") {
@@ -131,6 +88,7 @@ describe("sessions tools", () => {
});
it("sessions_history filters tool messages by default", async () => {
callGatewayMock.mockReset();
callGatewayMock.mockImplementation(async (opts: unknown) => {
const request = opts as { method?: string };
if (request.method === "chat.history") {
@@ -164,8 +122,11 @@ describe("sessions tools", () => {
});
it("sessions_send supports fire-and-forget and wait", async () => {
callGatewayMock.mockReset();
const calls: Array<{ method?: string; expectFinal?: boolean }> = [];
callGatewayMock.mockImplementation(async (opts: unknown) => {
const request = opts as { method?: string };
const request = opts as { method?: string; expectFinal?: boolean };
calls.push(request);
if (request.method === "agent") {
return { runId: "run-1", status: "accepted" };
}
@@ -203,5 +164,9 @@ describe("sessions tools", () => {
runId: "run-1",
reply: "done",
});
const agentCalls = calls.filter((call) => call.method === "agent");
expect(agentCalls[0]?.expectFinal).toBeUndefined();
expect(agentCalls[1]?.expectFinal).toBe(true);
});
});

View File

@@ -45,7 +45,6 @@ import {
type ClawdisConfig,
type DiscordActionConfig,
loadConfig,
resolveGatewayPort,
} from "../config/config.js";
import {
addRoleDiscord,
@@ -78,7 +77,6 @@ import {
unpinMessageDiscord,
} from "../discord/send.js";
import { callGateway } from "../gateway/call.js";
import { GatewayClient } from "../gateway/client.js";
import { detectMime, imageMimeFromFormat } from "../media/mime.js";
import { sanitizeToolResultImages } from "./tool-images.js";
@@ -339,88 +337,6 @@ function extractAssistantText(message: unknown): string | undefined {
return joined ? joined : undefined;
}
function resolveGatewayConnection(opts: GatewayCallOptions) {
const cfg = loadConfig();
const isRemoteMode = cfg.gateway?.mode === "remote";
const remote = isRemoteMode ? cfg.gateway?.remote : undefined;
const localPort = resolveGatewayPort(cfg);
const url =
normalizeKey(opts.gatewayUrl) ??
(typeof remote?.url === "string" && remote.url.trim()
? remote.url.trim()
: undefined) ??
`ws://127.0.0.1:${localPort}`;
const token =
normalizeKey(opts.gatewayToken) ??
(isRemoteMode
? normalizeKey(remote?.token)
: (normalizeKey(process.env.CLAWDIS_GATEWAY_TOKEN) ??
normalizeKey(cfg.gateway?.auth?.token)));
const password =
normalizeKey(process.env.CLAWDIS_GATEWAY_PASSWORD) ??
normalizeKey(remote?.password);
return { url, token, password };
}
async function waitForAgentCompletion(params: {
connection: ReturnType<typeof resolveGatewayConnection>;
runId: string;
timeoutMs: number;
}): Promise<{ status: "done" | "error" | "timeout"; error?: string }> {
const { connection, runId, timeoutMs } = params;
return await new Promise((resolve) => {
let settled = false;
const done = (status: "done" | "error" | "timeout", error?: string) => {
if (settled) return;
settled = true;
clearTimeout(timer);
client.stop();
resolve({ status, error });
};
const client = new GatewayClient({
url: connection.url,
token: connection.token,
password: connection.password,
clientName: "agent",
clientVersion: "dev",
platform: process.platform,
mode: "agent",
instanceId: crypto.randomUUID(),
onEvent: (evt) => {
if (evt.event !== "agent") return;
const payload = evt.payload as {
runId?: unknown;
stream?: unknown;
data?: Record<string, unknown>;
};
if (payload?.runId !== runId) return;
if (payload.stream !== "job") return;
const state = payload.data?.state;
if (state === "done") {
done("done");
return;
}
if (state === "error") {
done(
"error",
typeof payload.data?.error === "string"
? payload.data.error
: undefined,
);
}
},
onClose: (_code, _reason) => {
done("timeout");
},
});
const timer = setTimeout(() => done("timeout"), timeoutMs);
client.start();
});
}
async function imageResult(params: {
label: string;
path: string;
@@ -2775,19 +2691,51 @@ function createSessionsSendTool(): AnyAgentTool {
: 30;
const idempotencyKey = crypto.randomUUID();
let runId = idempotencyKey;
const displayKey = resolveDisplaySessionKey({
key: sessionKey,
alias,
mainKey,
});
const sendParams = {
message,
sessionKey: resolvedKey,
idempotencyKey,
deliver: false,
};
if (timeoutSeconds === 0) {
try {
const response = (await callGateway({
method: "agent",
params: sendParams,
timeoutMs: 10_000,
})) as { runId?: string };
if (typeof response?.runId === "string" && response.runId) {
runId = response.runId;
}
return jsonResult({
runId,
status: "accepted",
sessionKey: displayKey,
});
} catch (err) {
const message =
err instanceof Error ? err.message : String(err ?? "error");
return jsonResult({
runId,
status: "error",
error: message,
sessionKey: displayKey,
});
}
}
try {
const response = (await callGateway({
method: "agent",
params: {
message,
sessionKey: resolvedKey,
idempotencyKey,
deliver: false,
},
timeoutMs:
timeoutSeconds > 0
? Math.min(timeoutSeconds * 1000, 10_000)
: 10_000,
params: sendParams,
expectFinal: true,
timeoutMs: timeoutSeconds * 1000,
})) as { runId?: string; status?: string };
if (typeof response?.runId === "string" && response.runId) {
runId = response.runId;
@@ -2795,46 +2743,31 @@ function createSessionsSendTool(): AnyAgentTool {
} catch (err) {
const message =
err instanceof Error ? err.message : String(err ?? "error");
if (message.includes("gateway timeout")) {
try {
const cached = (await callGateway({
method: "agent",
params: sendParams,
timeoutMs: 5_000,
})) as { runId?: string };
if (typeof cached?.runId === "string" && cached.runId) {
runId = cached.runId;
}
} catch {
/* ignore */
}
return jsonResult({
runId,
status: "timeout",
error: message,
sessionKey: displayKey,
});
}
return jsonResult({
runId,
status: "error",
error: message,
sessionKey: resolveDisplaySessionKey({
key: sessionKey,
alias,
mainKey,
}),
});
}
if (timeoutSeconds === 0) {
return jsonResult({
runId,
status: "accepted",
sessionKey: resolveDisplaySessionKey({
key: sessionKey,
alias,
mainKey,
}),
});
}
const connection = resolveGatewayConnection({});
const wait = await waitForAgentCompletion({
connection,
runId,
timeoutMs: timeoutSeconds * 1000,
});
if (wait.status === "timeout") {
return jsonResult({
runId,
status: "timeout",
sessionKey: resolveDisplaySessionKey({
key: sessionKey,
alias,
mainKey,
}),
sessionKey: displayKey,
});
}
@@ -2851,14 +2784,9 @@ function createSessionsSendTool(): AnyAgentTool {
return jsonResult({
runId,
status: wait.status === "error" ? "error" : "ok",
error: wait.error,
status: "ok",
reply,
sessionKey: resolveDisplaySessionKey({
key: sessionKey,
alias,
mainKey,
}),
sessionKey: displayKey,
});
},
};