fix: clarify sessions_send delivery semantics

This commit is contained in:
Peter Steinberger
2026-01-10 00:32:20 +01:00
parent 96e17d407a
commit a25922a21f
7 changed files with 96 additions and 13 deletions

View File

@@ -98,6 +98,7 @@
- Control UI: add Docs link, remove chat composer divider, and add New session button.
- Control UI: link sessions list to chat view. (#471) — thanks @HazAT
- Sessions: support session `label` in store/list/UI and allow `sessions_send` lookup by label. (#570) — thanks @azade-c
- Sessions: clarify `sessions_send` delivery semantics, log announce failures, and enforce Discord request timeouts. (#507) — thanks @steipete
- Control UI: show/patch per-session reasoning level and render extracted reasoning in chat.
- Control UI: queue outgoing chat messages, add Enter-to-send, and show queued items. (#527) — thanks @YuriNachos
- Control UI: refactor chat layout with tool sidebar, grouped messages, and nav improvements. (#475) — thanks @rahthakor

View File

@@ -76,6 +76,7 @@ Behavior:
- `timeoutSeconds > 0`: wait up to N seconds for completion, then return `{ runId, status: "ok", reply }`.
- If wait times out: `{ runId, status: "timeout", error }`. Run continues; call `sessions_history` later.
- If the run fails: `{ runId, status: "error", error }`.
- Announce delivery runs after the primary run completes and is best-effort; `status: "ok"` does not guarantee the announce was delivered.
- Waits via gateway `agent.wait` (server-side) so reconnects don't drop the wait.
- Agent-to-agent message context is injected for the primary run.
- After the primary run completes, Clawdbot runs a **reply-back loop**:

View File

@@ -206,6 +206,7 @@ Notes:
- `main` is the canonical direct-chat key; global/unknown are hidden.
- `messageLimit > 0` fetches last N messages per session (tool messages filtered).
- `sessions_send` waits for final completion when `timeoutSeconds > 0`.
- Delivery/announce happens after completion and is best-effort; `status: "ok"` confirms the agent run finished, not that the announce was delivered.
- `sessions_spawn` starts a sub-agent run and posts an announce reply back to the requester chat.
- `sessions_spawn` is non-blocking and returns `status: "accepted"` immediately.
- `sessions_send` runs a replyback pingpong (reply `REPLY_SKIP` to stop; max turns via `session.agentToAgent.maxPingPongTurns`, 05).

View File

@@ -183,6 +183,7 @@
"@sinclair/typebox": "0.34.47"
},
"patchedDependencies": {
"@buape/carbon": "patches/@buape__carbon.patch",
"@mariozechner/pi-agent-core": "patches/@mariozechner__pi-agent-core.patch",
"@mariozechner/pi-coding-agent": "patches/@mariozechner__pi-coding-agent.patch",
"@mariozechner/pi-ai": "patches/@mariozechner__pi-ai.patch"
@@ -221,6 +222,7 @@
]
},
"patchedDependencies": {
"@buape/carbon": "patches/@buape__carbon.patch",
"@mariozechner/pi-agent-core": "patches/@mariozechner__pi-agent-core.patch",
"@mariozechner/pi-coding-agent": "patches/@mariozechner__pi-coding-agent.patch",
"@mariozechner/pi-ai": "patches/@mariozechner__pi-ai.patch",

View File

@@ -0,0 +1,53 @@
--- a/dist/src/classes/RequestClient.js
+++ b/dist/src/classes/RequestClient.js
@@ -118,6 +118,9 @@
}
}
this.abortController = new AbortController();
+ const timeoutMs = typeof this.options.timeout === "number" && this.options.timeout > 0
+ ? this.options.timeout
+ : undefined;
let body;
if (data?.body &&
typeof data.body === "object" &&
@@ -178,12 +181,26 @@
body = JSON.stringify(data.body);
}
}
- const response = await fetch(url, {
- method,
- headers,
- body,
- signal: this.abortController.signal
- });
+ let timeoutId;
+ if (timeoutMs !== undefined) {
+ timeoutId = setTimeout(() => {
+ this.abortController?.abort();
+ }, timeoutMs);
+ }
+ let response;
+ try {
+ response = await fetch(url, {
+ method,
+ headers,
+ body,
+ signal: this.abortController.signal
+ });
+ }
+ finally {
+ if (timeoutId) {
+ clearTimeout(timeoutId);
+ }
+ }
let rawBody = "";
let parsedBody;
try {
@@ -405,4 +422,4 @@
}
}
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, Math.max(ms, 0)));
-//# sourceMappingURL=RequestClient.js.map
\ No newline at end of file
+//# sourceMappingURL=RequestClient.js.map

View File

@@ -243,7 +243,11 @@ describe("sessions tools", () => {
message: "ping",
timeoutSeconds: 0,
});
expect(fire.details).toMatchObject({ status: "accepted", runId: "run-1" });
expect(fire.details).toMatchObject({
status: "accepted",
runId: "run-1",
delivery: { status: "pending", mode: "announce" },
});
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => setTimeout(resolve, 0));
@@ -256,6 +260,7 @@ describe("sessions tools", () => {
expect(waited.details).toMatchObject({
status: "ok",
reply: "done",
delivery: { status: "pending", mode: "announce" },
});
expect(typeof (waited.details as { runId?: string }).runId).toBe("string");
await new Promise((resolve) => setTimeout(resolve, 0));

View File

@@ -4,6 +4,8 @@ import { Type } from "@sinclair/typebox";
import { loadConfig } from "../../config/config.js";
import { callGateway } from "../../gateway/call.js";
import { formatErrorMessage } from "../../infra/errors.js";
import { createSubsystemLogger } from "../../logging.js";
import {
isSubagentSessionKey,
normalizeAgentId,
@@ -35,6 +37,8 @@ import {
resolvePingPongTurns,
} from "./sessions-send-helpers.js";
const log = createSubsystemLogger("agents/sessions-send");
const SessionsSendToolSchema = Type.Object({
sessionKey: Type.Optional(Type.String()),
label: Type.Optional(
@@ -308,11 +312,13 @@ export function createSessionsSendTool(opts?: {
const requesterSessionKey = opts?.agentSessionKey;
const requesterProvider = opts?.agentProvider;
const maxPingPongTurns = resolvePingPongTurns(cfg);
const delivery = { status: "pending", mode: "announce" as const };
const runAgentToAgentFlow = async (
roundOneReply?: string,
runInfo?: { runId: string },
) => {
const runContextId = runInfo?.runId ?? runId;
try {
let primaryReply = roundOneReply;
let latestReply = roundOneReply;
@@ -400,20 +406,32 @@ export function createSessionsSendTool(opts?: {
announceReply.trim() &&
!isAnnounceSkip(announceReply)
) {
await callGateway({
method: "send",
params: {
to: announceTarget.to,
message: announceReply.trim(),
try {
await callGateway({
method: "send",
params: {
to: announceTarget.to,
message: announceReply.trim(),
provider: announceTarget.provider,
accountId: announceTarget.accountId,
idempotencyKey: crypto.randomUUID(),
},
timeoutMs: 10_000,
});
} catch (err) {
log.warn("sessions_send announce delivery failed", {
runId: runContextId,
provider: announceTarget.provider,
accountId: announceTarget.accountId,
idempotencyKey: crypto.randomUUID(),
},
timeoutMs: 10_000,
});
to: announceTarget.to,
error: formatErrorMessage(err),
});
}
}
} catch {
// Best-effort follow-ups; ignore failures to avoid breaking the caller response.
} catch (err) {
log.warn("sessions_send announce flow failed", {
runId: runContextId,
error: formatErrorMessage(err),
});
}
};
@@ -432,6 +450,7 @@ export function createSessionsSendTool(opts?: {
runId,
status: "accepted",
sessionKey: displayKey,
delivery,
});
} catch (err) {
const messageText =
@@ -535,6 +554,7 @@ export function createSessionsSendTool(opts?: {
status: "ok",
reply,
sessionKey: displayKey,
delivery,
});
},
};