fix: abort embedded prompts on cancel

This commit is contained in:
Peter Steinberger
2026-01-18 05:17:28 +00:00
parent 89c5185f1c
commit 016693a1f5
16 changed files with 128 additions and 44 deletions

View File

@@ -15,6 +15,9 @@ Docs: https://docs.clawd.bot
## 2026.1.18-2
### Fixes
- Tests: stabilize plugin SDK resolution and embedded agent timeouts.
## 2026.1.17-6
### Changes

View File

@@ -146,7 +146,7 @@ const readSessionMessages = async (sessionFile: string) => {
};
describe("runEmbeddedPiAgent", () => {
it("appends new user + assistant after existing transcript entries", async () => {
it("appends new user + assistant after existing transcript entries", { timeout: 90_000 }, async () => {
const { SessionManager } = await import("@mariozechner/pi-coding-agent");
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-agent-"));
@@ -271,7 +271,7 @@ describe("runEmbeddedPiAgent", () => {
expect(firstAssistantIndex).toBeGreaterThan(firstUserIndex);
expect(secondUserIndex).toBeGreaterThan(firstAssistantIndex);
expect(secondAssistantIndex).toBeGreaterThan(secondUserIndex);
}, 20_000);
}, 90_000);
it("repairs orphaned user messages and continues", async () => {
const { SessionManager } = await import("@mariozechner/pi-coding-agent");

View File

@@ -192,7 +192,7 @@ describe("runEmbeddedPiAgent", () => {
await expect(fs.stat(path.join(agentDir, "models.json"))).resolves.toBeTruthy();
});
it("persists the first user message before assistant output", { timeout: 45_000 }, async () => {
it("persists the first user message before assistant output", { timeout: 60_000 }, async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-agent-"));
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-workspace-"));
const sessionFile = path.join(workspaceDir, "session.jsonl");

View File

@@ -359,6 +359,33 @@ export async function runEmbeddedAttempt(
runAbortController.abort();
void activeSession.abort();
};
const abortable = <T>(promise: Promise<T>): Promise<T> => {
const signal = runAbortController.signal;
if (signal.aborted) {
const err = new Error("aborted");
(err as { name?: string }).name = "AbortError";
return Promise.reject(err);
}
return new Promise<T>((resolve, reject) => {
const onAbort = () => {
const err = new Error("aborted");
(err as { name?: string }).name = "AbortError";
signal.removeEventListener("abort", onAbort);
reject(err);
};
signal.addEventListener("abort", onAbort, { once: true });
promise.then(
(value) => {
signal.removeEventListener("abort", onAbort);
resolve(value);
},
(err) => {
signal.removeEventListener("abort", onAbort);
reject(err);
},
);
});
};
const subscription = subscribeEmbeddedPiSession({
session: activeSession,
@@ -454,7 +481,7 @@ export async function runEmbeddedAttempt(
}
try {
await activeSession.prompt(params.prompt, { images: params.images });
await abortable(activeSession.prompt(params.prompt, { images: params.images }));
} catch (err) {
promptError = err;
} finally {

View File

@@ -172,6 +172,10 @@ vi.mock("../agents/skills-status.js", () => ({
buildWorkspaceSkillStatus: () => ({ skills: [] }),
}));
vi.mock("../plugins/loader.js", () => ({
loadClawdbotPlugins: () => ({ plugins: [], diagnostics: [] }),
}));
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal();
return {
@@ -248,7 +252,7 @@ vi.mock("../telegram/pairing-store.js", () => ({
vi.mock("../pairing/pairing-store.js", () => ({
readChannelAllowFromStore: vi.fn().mockResolvedValue([]),
upsertChannelPairingRequest: vi.fn().mockResolvedValue({ code: "", created: false }),
upsertChannelPairingRequest: vi.fn().mockResolvedValue({ code: "000000", created: false }),
}));
vi.mock("../telegram/token.js", () => ({
@@ -374,5 +378,5 @@ describe("doctor command", () => {
expect(runLegacyStateMigrations).toHaveBeenCalledTimes(1);
expect(confirm).not.toHaveBeenCalled();
}, 20_000);
}, 30_000);
});

View File

@@ -172,6 +172,10 @@ vi.mock("../agents/skills-status.js", () => ({
buildWorkspaceSkillStatus: () => ({ skills: [] }),
}));
vi.mock("../plugins/loader.js", () => ({
loadClawdbotPlugins: () => ({ plugins: [], diagnostics: [] }),
}));
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal();
return {
@@ -248,7 +252,7 @@ vi.mock("../telegram/pairing-store.js", () => ({
vi.mock("../pairing/pairing-store.js", () => ({
readChannelAllowFromStore: vi.fn().mockResolvedValue([]),
upsertChannelPairingRequest: vi.fn().mockResolvedValue({ code: "", created: false }),
upsertChannelPairingRequest: vi.fn().mockResolvedValue({ code: "000000", created: false }),
}));
vi.mock("../telegram/token.js", () => ({
@@ -362,7 +366,7 @@ describe("doctor command", () => {
expect(written.routing).toBeUndefined();
});
it("migrates legacy gateway services", async () => {
it("migrates legacy gateway services", { timeout: 30_000 }, async () => {
readConfigFileSnapshot.mockResolvedValue({
path: "/tmp/clawdbot.json",
exists: true,

View File

@@ -172,6 +172,10 @@ vi.mock("../agents/skills-status.js", () => ({
buildWorkspaceSkillStatus: () => ({ skills: [] }),
}));
vi.mock("../plugins/loader.js", () => ({
loadClawdbotPlugins: () => ({ plugins: [], diagnostics: [] }),
}));
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal();
return {
@@ -248,7 +252,7 @@ vi.mock("../telegram/pairing-store.js", () => ({
vi.mock("../pairing/pairing-store.js", () => ({
readChannelAllowFromStore: vi.fn().mockResolvedValue([]),
upsertChannelPairingRequest: vi.fn().mockResolvedValue({ code: "", created: false }),
upsertChannelPairingRequest: vi.fn().mockResolvedValue({ code: "000000", created: false }),
}));
vi.mock("../telegram/token.js", () => ({
@@ -375,7 +379,7 @@ describe("doctor command", () => {
expect(runLegacyStateMigrations).toHaveBeenCalledTimes(1);
expect(confirm).not.toHaveBeenCalled();
}, 20_000);
}, 30_000);
it("skips gateway restarts in non-interactive mode", async () => {
readConfigFileSnapshot.mockResolvedValue({
@@ -448,5 +452,5 @@ describe("doctor command", () => {
const profiles = (written.auth as { profiles: Record<string, unknown> }).profiles;
expect(profiles["anthropic:me@example.com"]).toBeTruthy();
expect(profiles["anthropic:default"]).toBeUndefined();
}, 20_000);
}, 30_000);
});

View File

@@ -172,6 +172,10 @@ vi.mock("../agents/skills-status.js", () => ({
buildWorkspaceSkillStatus: () => ({ skills: [] }),
}));
vi.mock("../plugins/loader.js", () => ({
loadClawdbotPlugins: () => ({ plugins: [], diagnostics: [] }),
}));
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal();
return {
@@ -248,7 +252,7 @@ vi.mock("../telegram/pairing-store.js", () => ({
vi.mock("../pairing/pairing-store.js", () => ({
readChannelAllowFromStore: vi.fn().mockResolvedValue([]),
upsertChannelPairingRequest: vi.fn().mockResolvedValue({ code: "", created: false }),
upsertChannelPairingRequest: vi.fn().mockResolvedValue({ code: "000000", created: false }),
}));
vi.mock("../telegram/token.js", () => ({
@@ -373,7 +377,7 @@ describe("doctor command", () => {
);
}),
).toBe(true);
}, 20_000);
}, 30_000);
it("warns when extra workspace directories exist", async () => {
readConfigFileSnapshot.mockResolvedValue({

View File

@@ -172,6 +172,10 @@ vi.mock("../agents/skills-status.js", () => ({
buildWorkspaceSkillStatus: () => ({ skills: [] }),
}));
vi.mock("../plugins/loader.js", () => ({
loadClawdbotPlugins: () => ({ plugins: [], diagnostics: [] }),
}));
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal();
return {
@@ -248,7 +252,7 @@ vi.mock("../telegram/pairing-store.js", () => ({
vi.mock("../pairing/pairing-store.js", () => ({
readChannelAllowFromStore: vi.fn().mockResolvedValue([]),
upsertChannelPairingRequest: vi.fn().mockResolvedValue({ code: "", created: false }),
upsertChannelPairingRequest: vi.fn().mockResolvedValue({ code: "000000", created: false }),
}));
vi.mock("../telegram/token.js", () => ({
@@ -348,7 +352,7 @@ describe("doctor command", () => {
const stateNote = note.mock.calls.find((call) => call[1] === "State integrity");
expect(stateNote).toBeTruthy();
expect(String(stateNote?.[0])).toContain("CRITICAL");
}, 20_000);
}, 30_000);
it("warns about opencode provider overrides", async () => {
readConfigFileSnapshot.mockResolvedValue({

View File

@@ -252,7 +252,7 @@ async function connectClient(params: { url: string; token: string }) {
}
describe("gateway (mock openai): tool calling", () => {
it("runs a Read tool call end-to-end via gateway agent loop", async () => {
it("runs a Read tool call end-to-end via gateway agent loop", { timeout: 90_000 }, async () => {
const prev = {
home: process.env.HOME,
configPath: process.env.CLAWDBOT_CONFIG_PATH,

View File

@@ -268,5 +268,5 @@ describe("gateway wizard (e2e)", () => {
process.env.CLAWDBOT_SKIP_CRON = prev.skipCron;
process.env.CLAWDBOT_SKIP_CANVAS_HOST = prev.skipCanvas;
}
}, 60_000);
}, 90_000);
});

View File

@@ -14,10 +14,10 @@ import {
installGatewayTestHooks();
describe("gateway server auth/connect", () => {
test("closes silent handshakes after timeout", { timeout: 15_000 }, async () => {
test("closes silent handshakes after timeout", { timeout: 30_000 }, async () => {
const { server, ws } = await startServerWithClient();
const closed = await new Promise<boolean>((resolve) => {
const timer = setTimeout(() => resolve(false), 12_000);
const timer = setTimeout(() => resolve(false), 25_000);
ws.once("close", () => {
clearTimeout(timer);
resolve(true);

View File

@@ -16,7 +16,7 @@ import {
installGatewayTestHooks();
describe("gateway server health/presence", () => {
test("connect + health + presence + status succeed", { timeout: 8000 }, async () => {
test("connect + health + presence + status succeed", { timeout: 20_000 }, async () => {
const { server, ws } = await startServerWithClient();
await connectOk(ws);

View File

@@ -46,7 +46,7 @@ describe("gateway server misc", () => {
}
});
test("send dedupes by idempotencyKey", { timeout: 8000 }, async () => {
test("send dedupes by idempotencyKey", { timeout: 20_000 }, async () => {
const { server, ws } = await startServerWithClient();
await connectOk(ws);

View File

@@ -64,7 +64,7 @@ describe("gateway server models + voicewake", () => {
test(
"voicewake.get returns defaults and voicewake.set broadcasts",
{ timeout: 15_000 },
{ timeout: 30_000 },
async () => {
const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-home-"));
const restoreHome = setTempHome(homeDir);

View File

@@ -1,5 +1,39 @@
import type { LogLevel } from "../../logging/levels.js";
type ShouldLogVerbose = typeof import("../../globals.js").shouldLogVerbose;
type DispatchReplyWithBufferedBlockDispatcher =
typeof import("../../auto-reply/reply/provider-dispatcher.js").dispatchReplyWithBufferedBlockDispatcher;
type CreateReplyDispatcherWithTyping =
typeof import("../../auto-reply/reply/reply-dispatcher.js").createReplyDispatcherWithTyping;
type ResolveEffectiveMessagesConfig =
typeof import("../../agents/identity.js").resolveEffectiveMessagesConfig;
type ResolveHumanDelayConfig = typeof import("../../agents/identity.js").resolveHumanDelayConfig;
type ResolveAgentRoute = typeof import("../../routing/resolve-route.js").resolveAgentRoute;
type BuildPairingReply = typeof import("../../pairing/pairing-messages.js").buildPairingReply;
type ReadChannelAllowFromStore =
typeof import("../../pairing/pairing-store.js").readChannelAllowFromStore;
type UpsertChannelPairingRequest =
typeof import("../../pairing/pairing-store.js").upsertChannelPairingRequest;
type FetchRemoteMedia = typeof import("../../media/fetch.js").fetchRemoteMedia;
type SaveMediaBuffer = typeof import("../../media/store.js").saveMediaBuffer;
type BuildMentionRegexes = typeof import("../../auto-reply/reply/mentions.js").buildMentionRegexes;
type MatchesMentionPatterns =
typeof import("../../auto-reply/reply/mentions.js").matchesMentionPatterns;
type ResolveChannelGroupPolicy =
typeof import("../../config/group-policy.js").resolveChannelGroupPolicy;
type ResolveChannelGroupRequireMention =
typeof import("../../config/group-policy.js").resolveChannelGroupRequireMention;
type CreateInboundDebouncer =
typeof import("../../auto-reply/inbound-debounce.js").createInboundDebouncer;
type ResolveInboundDebounceMs =
typeof import("../../auto-reply/inbound-debounce.js").resolveInboundDebounceMs;
type ResolveCommandAuthorizedFromAuthorizers =
typeof import("../../channels/command-gating.js").resolveCommandAuthorizedFromAuthorizers;
type ResolveTextChunkLimit = typeof import("../../auto-reply/chunk.js").resolveTextChunkLimit;
type ChunkMarkdownText = typeof import("../../auto-reply/chunk.js").chunkMarkdownText;
type HasControlCommand = typeof import("../../auto-reply/command-detection.js").hasControlCommand;
type ResolveStateDir = typeof import("../../config/paths.js").resolveStateDir;
export type RuntimeLogger = {
debug?: (message: string) => void;
info: (message: string) => void;
@@ -11,52 +45,52 @@ export type PluginRuntime = {
version: string;
channel: {
text: {
chunkMarkdownText: typeof import("../../auto-reply/chunk.js").chunkMarkdownText;
resolveTextChunkLimit: typeof import("../../auto-reply/chunk.js").resolveTextChunkLimit;
hasControlCommand: typeof import("../../auto-reply/command-detection.js").hasControlCommand;
chunkMarkdownText: ChunkMarkdownText;
resolveTextChunkLimit: ResolveTextChunkLimit;
hasControlCommand: HasControlCommand;
};
reply: {
dispatchReplyWithBufferedBlockDispatcher: typeof import("../../auto-reply/reply/provider-dispatcher.js").dispatchReplyWithBufferedBlockDispatcher;
createReplyDispatcherWithTyping: typeof import("../../auto-reply/reply/reply-dispatcher.js").createReplyDispatcherWithTyping;
resolveEffectiveMessagesConfig: typeof import("../../agents/identity.js").resolveEffectiveMessagesConfig;
resolveHumanDelayConfig: typeof import("../../agents/identity.js").resolveHumanDelayConfig;
dispatchReplyWithBufferedBlockDispatcher: DispatchReplyWithBufferedBlockDispatcher;
createReplyDispatcherWithTyping: CreateReplyDispatcherWithTyping;
resolveEffectiveMessagesConfig: ResolveEffectiveMessagesConfig;
resolveHumanDelayConfig: ResolveHumanDelayConfig;
};
routing: {
resolveAgentRoute: typeof import("../../routing/resolve-route.js").resolveAgentRoute;
resolveAgentRoute: ResolveAgentRoute;
};
pairing: {
buildPairingReply: typeof import("../../pairing/pairing-messages.js").buildPairingReply;
readAllowFromStore: typeof import("../../pairing/pairing-store.js").readChannelAllowFromStore;
upsertPairingRequest: typeof import("../../pairing/pairing-store.js").upsertChannelPairingRequest;
buildPairingReply: BuildPairingReply;
readAllowFromStore: ReadChannelAllowFromStore;
upsertPairingRequest: UpsertChannelPairingRequest;
};
media: {
fetchRemoteMedia: typeof import("../../media/fetch.js").fetchRemoteMedia;
saveMediaBuffer: typeof import("../../media/store.js").saveMediaBuffer;
fetchRemoteMedia: FetchRemoteMedia;
saveMediaBuffer: SaveMediaBuffer;
};
mentions: {
buildMentionRegexes: typeof import("../../auto-reply/reply/mentions.js").buildMentionRegexes;
matchesMentionPatterns: typeof import("../../auto-reply/reply/mentions.js").matchesMentionPatterns;
buildMentionRegexes: BuildMentionRegexes;
matchesMentionPatterns: MatchesMentionPatterns;
};
groups: {
resolveGroupPolicy: typeof import("../../config/group-policy.js").resolveChannelGroupPolicy;
resolveRequireMention: typeof import("../../config/group-policy.js").resolveChannelGroupRequireMention;
resolveGroupPolicy: ResolveChannelGroupPolicy;
resolveRequireMention: ResolveChannelGroupRequireMention;
};
debounce: {
createInboundDebouncer: typeof import("../../auto-reply/inbound-debounce.js").createInboundDebouncer;
resolveInboundDebounceMs: typeof import("../../auto-reply/inbound-debounce.js").resolveInboundDebounceMs;
createInboundDebouncer: CreateInboundDebouncer;
resolveInboundDebounceMs: ResolveInboundDebounceMs;
};
commands: {
resolveCommandAuthorizedFromAuthorizers: typeof import("../../channels/command-gating.js").resolveCommandAuthorizedFromAuthorizers;
resolveCommandAuthorizedFromAuthorizers: ResolveCommandAuthorizedFromAuthorizers;
};
};
logging: {
shouldLogVerbose: typeof import("../../globals.js").shouldLogVerbose;
shouldLogVerbose: ShouldLogVerbose;
getChildLogger: (
bindings?: Record<string, unknown>,
opts?: { level?: LogLevel },
) => RuntimeLogger;
};
state: {
resolveStateDir: typeof import("../../config/paths.js").resolveStateDir;
resolveStateDir: ResolveStateDir;
};
};