feat: expose bot task progress
This commit is contained in:
+22
-1
@@ -1,7 +1,9 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawn } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import type { RequestPermissionRequest } from "@agentclientprotocol/sdk";
|
||||
import { decidePermission } from "../src/acp/client.js";
|
||||
import { AcpClient, decidePermission } from "../src/acp/client.js";
|
||||
|
||||
const request = (rawInput: unknown, overrides: Partial<RequestPermissionRequest["toolCall"]> = {}): RequestPermissionRequest => ({
|
||||
sessionId: "session",
|
||||
@@ -30,3 +32,22 @@ test("permission deny and allowlist fail closed", () => {
|
||||
assert.deepEqual(decidePermission(request("git status", { name: undefined, title: "bash git status" }), unanchored).outcome, { outcome: "selected", optionId: "reject" });
|
||||
assert.deepEqual(decidePermission(request({ command: "git status", env: { PATH: "/tmp" } }), unanchored).outcome, { outcome: "selected", optionId: "reject" });
|
||||
});
|
||||
|
||||
test("reports activity for every update from the active session", async () => {
|
||||
const child = spawn(process.execPath, [path.resolve("test/fixtures/fake-acp-agent.mjs")], { stdio: ["pipe", "pipe", "pipe"] });
|
||||
let activities = 0;
|
||||
const client = new AcpClient(child, {
|
||||
initializeTimeoutMs: 1_000,
|
||||
policy: { mode: "deny", allowedTools: [], allowedCommandPatterns: [] },
|
||||
onSessionActivity: () => { activities++; }
|
||||
});
|
||||
try {
|
||||
await client.initialize();
|
||||
await client.newSession(path.resolve("."));
|
||||
assert.equal(await client.prompt("activity"), "firstsecond");
|
||||
assert.equal(activities, 2);
|
||||
} finally {
|
||||
client.close();
|
||||
child.kill("SIGTERM");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -95,3 +95,28 @@ test("cancel reaches hanging prompt, reset unbinds, and fingerprint changes sess
|
||||
assert.notEqual(second.text.split(":")[1], firstSession);
|
||||
await changed.manager.shutdown(); await changed.store.close();
|
||||
});
|
||||
|
||||
test("status reports initializing, processing, running time, and update activity", async () => {
|
||||
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gori-acp-status-")); const state = path.join(dir, "state.json"); const log = path.join(dir, "fake.log");
|
||||
const current = await runtime(state, log, "test", { FAKE_ACP_BOOTSTRAP_DELAY_MS: "200" });
|
||||
const first = current.manager.prompt({ platform: "qq", chatId: "chat", userId: "user", text: "ready" });
|
||||
for (let count = 0; count < 50 && current.manager.status("qq", "chat").phase !== "initializing"; count++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
assert.deepEqual(current.manager.status("qq", "chat"), {
|
||||
bot: "test-bot", agent: "fake", workspace: path.resolve("."), persisted: false, running: true,
|
||||
phase: "initializing", runningSeconds: 0, idleSeconds: 0
|
||||
});
|
||||
await first;
|
||||
|
||||
const activity = current.manager.prompt({ platform: "qq", chatId: "chat", userId: "user", text: "activity" });
|
||||
await new Promise((resolve) => setTimeout(resolve, 1_100));
|
||||
const processing = current.manager.status("qq", "chat");
|
||||
assert.equal(processing.phase, "processing");
|
||||
assert.equal(processing.running, true);
|
||||
assert.equal(processing.runningSeconds, 1);
|
||||
assert.equal(processing.idleSeconds, 0);
|
||||
await activity;
|
||||
assert.equal(current.manager.status("qq", "chat").phase, "idle");
|
||||
await current.manager.shutdown(); await current.store.close();
|
||||
});
|
||||
|
||||
Vendored
+10
@@ -58,6 +58,16 @@ const app = acp.agent({ name: "fake-acp-agent" })
|
||||
pending.delete(params.sessionId);
|
||||
return { stopReason: "cancelled" };
|
||||
}
|
||||
if (text === "activity") {
|
||||
await update(client, params.sessionId, "first");
|
||||
await new Promise((resolve) => setTimeout(resolve, 600));
|
||||
await update(client, params.sessionId, "second");
|
||||
await new Promise((resolve) => setTimeout(resolve, 700));
|
||||
return { stopReason: "end_turn" };
|
||||
}
|
||||
if (text.includes("Initialize this ACP session") && process.env.FAKE_ACP_BOOTSTRAP_DELAY_MS) {
|
||||
await new Promise((resolve) => setTimeout(resolve, Number(process.env.FAKE_ACP_BOOTSTRAP_DELAY_MS)));
|
||||
}
|
||||
await update(client, params.sessionId, text.includes("Initialize this ACP session") ? "READY" : `reply:${params.sessionId}:${text}`);
|
||||
return { stopReason: "end_turn" };
|
||||
})
|
||||
|
||||
+133
-12
@@ -3,36 +3,157 @@ import test from "node:test";
|
||||
import type { ConversationRuntime } from "../src/acp/types.js";
|
||||
import type { PlatformAdapter } from "../src/core/adapter.js";
|
||||
import { Gateway } from "../src/core/gateway.js";
|
||||
import type { IncomingMessage } from "../src/core/types.js";
|
||||
import type { IncomingMessage, OutgoingMessage } from "../src/core/types.js";
|
||||
|
||||
class FakeRuntime implements ConversationRuntime {
|
||||
prompts = 0; cancelled = 0; resets = 0; release?: () => void;
|
||||
async prompt() { this.prompts++; await new Promise<void>((resolve) => { this.release = resolve; }); return { text: "done", botId: "test-bot", agentId: "kimi" }; }
|
||||
async cancel() { this.cancelled++; this.release?.(); return true; }
|
||||
prompts = 0; cancelled = 0; resets = 0; promptError?: Error;
|
||||
private readonly releases: Array<() => void> = [];
|
||||
async prompt() {
|
||||
this.prompts++;
|
||||
if (this.promptError) throw this.promptError;
|
||||
await new Promise<void>((resolve) => { this.releases.push(resolve); });
|
||||
return { text: "done", botId: "test-bot", agentId: "kimi" };
|
||||
}
|
||||
async cancel() { this.cancelled++; this.releaseNext(); return true; }
|
||||
async reset() { this.resets++; }
|
||||
status() { return { bot: "test-bot", agent: "kimi", workspace: "/tmp", running: Boolean(this.release) }; }
|
||||
status() { return { bot: "test-bot", agent: "kimi", workspace: "/tmp", running: this.releases.length > 0, phase: "processing" }; }
|
||||
stats() { return { activeWorkers: 0, inFlight: 0, crashes: 0, persistedBindings: 0 }; }
|
||||
async shutdown() {}
|
||||
releaseNext() { this.releases.shift()?.(); }
|
||||
}
|
||||
|
||||
const policy = { allowedUsers: [] as string[], allowedChats: [] as string[], requireMentionInGroup: false };
|
||||
const adapter: PlatformAdapter = { name: "test", async handleWebhook() { return {}; }, async sendMessage() {} };
|
||||
const message = (text: string): IncomingMessage => ({ platform: "qq", chatId: "chat", userId: "user", text });
|
||||
const waitFor = async (condition: () => boolean) => {
|
||||
for (let count = 0; count < 50 && !condition(); count++) await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
assert.equal(condition(), true);
|
||||
};
|
||||
|
||||
test("cancel bypasses the chat lock and new resets", async () => {
|
||||
const runtime = new FakeRuntime(); const gateway = new Gateway(policy, runtime);
|
||||
function recordingAdapter(sent: string[] = []): PlatformAdapter {
|
||||
return { name: "test", async handleWebhook() { return {}; }, async sendMessage(outgoing) { sent.push(outgoing.text); } };
|
||||
}
|
||||
|
||||
test("status, cancel, and help bypass the chat lock while new remains serial", async () => {
|
||||
const runtime = new FakeRuntime(); const gateway = new Gateway(policy, runtime); const adapter = recordingAdapter();
|
||||
const turn = gateway.receive(message("work"), adapter, { synchronous: true });
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
await waitFor(() => runtime.prompts === 1);
|
||||
|
||||
const status = await gateway.receive(message("/status"), adapter, { synchronous: true });
|
||||
assert.match(status.reply || "", /gatewayRunning=true/);
|
||||
assert.match(status.reply || "", /queued=0/);
|
||||
assert.match((await gateway.receive(message("/help"), adapter, { synchronous: true })).reply || "", /\/status/);
|
||||
const cancelled = await gateway.receive(message("/cancel"), adapter, { synchronous: true });
|
||||
assert.equal(cancelled.reply, "Cancellation requested.");
|
||||
await turn;
|
||||
const reset = await gateway.receive(message("/new"), adapter, { synchronous: true });
|
||||
assert.match(reset.reply || "", /new native ACP session/);
|
||||
|
||||
const second = gateway.receive(message("work"), adapter, { synchronous: true });
|
||||
await waitFor(() => runtime.prompts === 2);
|
||||
const reset = gateway.receive(message("/new"), adapter, { synchronous: true });
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
assert.equal(runtime.resets, 0);
|
||||
runtime.releaseNext();
|
||||
await second;
|
||||
await reset;
|
||||
assert.equal(runtime.resets, 1);
|
||||
});
|
||||
|
||||
test("asynchronous messages acknowledge processing and queue state", async () => {
|
||||
const runtime = new FakeRuntime(); const gateway = new Gateway(policy, runtime); const sent: string[] = []; const adapter = recordingAdapter(sent);
|
||||
const first = gateway.receive(message("one"), adapter);
|
||||
await waitFor(() => runtime.prompts === 1);
|
||||
const second = gateway.receive(message("two"), adapter);
|
||||
await waitFor(() => sent.length >= 2);
|
||||
assert.deepEqual(sent.slice(0, 2), [
|
||||
"已收到,正在处理。可随时发送 /status 查看状态。",
|
||||
"已收到,已排队。可随时发送 /status 查看状态。"
|
||||
]);
|
||||
const status = await gateway.receive(message("/status"), adapter, { synchronous: true });
|
||||
assert.match(status.reply || "", /queued=1/);
|
||||
assert.match(status.reply || "", /gatewayRunningSeconds=\d+/);
|
||||
runtime.releaseNext();
|
||||
await waitFor(() => runtime.prompts === 2);
|
||||
runtime.releaseNext();
|
||||
await Promise.all([first, second]);
|
||||
});
|
||||
|
||||
test("prompt waits for acknowledgement and final reply uses sequence two", async () => {
|
||||
const runtime = new FakeRuntime(); const gateway = new Gateway(policy, runtime); const sent: OutgoingMessage[] = [];
|
||||
let releaseAcknowledgement!: () => void;
|
||||
const acknowledgement = new Promise<void>((resolve) => { releaseAcknowledgement = resolve; });
|
||||
const adapter: PlatformAdapter = {
|
||||
name: "test", async handleWebhook() { return {}; },
|
||||
async sendMessage(outgoing) {
|
||||
sent.push(outgoing);
|
||||
if (outgoing.replySequence === 1) await acknowledgement;
|
||||
}
|
||||
};
|
||||
const turn = gateway.receive({ ...message("work"), messageId: "message-1" }, adapter);
|
||||
await waitFor(() => sent.length === 1);
|
||||
assert.equal(runtime.prompts, 0);
|
||||
assert.equal(sent[0].replySequence, 1);
|
||||
releaseAcknowledgement();
|
||||
await waitFor(() => runtime.prompts === 1);
|
||||
runtime.releaseNext();
|
||||
await turn;
|
||||
assert.deepEqual(sent.map((outgoing) => [outgoing.text, outgoing.replyTo, outgoing.replySequence]), [
|
||||
["已收到,正在处理。可随时发送 /status 查看状态。", "message-1", 1],
|
||||
["done", "message-1", 2]
|
||||
]);
|
||||
});
|
||||
|
||||
test("acknowledgement failure is logged safely and does not block work", async () => {
|
||||
const runtime = new FakeRuntime(); const gateway = new Gateway(policy, runtime); const errors: string[] = [];
|
||||
let sends = 0;
|
||||
const adapter: PlatformAdapter = {
|
||||
name: "test", async handleWebhook() { return {}; },
|
||||
async sendMessage() { if (++sends === 1) throw new Error("sensitive provider response"); }
|
||||
};
|
||||
const originalError = console.error;
|
||||
console.error = (...args: unknown[]) => { errors.push(args.map(String).join(" ")); };
|
||||
try {
|
||||
const turn = gateway.receive(message("work"), adapter);
|
||||
await waitFor(() => runtime.prompts === 1);
|
||||
runtime.releaseNext();
|
||||
assert.equal((await turn).reply, "done");
|
||||
} finally {
|
||||
console.error = originalError;
|
||||
}
|
||||
assert.deepEqual(errors, ["Gateway acknowledgement send failed (platform=qq)"]);
|
||||
});
|
||||
|
||||
test("asynchronous prompt errors reply with sequence two", async () => {
|
||||
const runtime = new FakeRuntime(); runtime.promptError = new Error("failed");
|
||||
const gateway = new Gateway(policy, runtime); const sent: OutgoingMessage[] = [];
|
||||
const adapter: PlatformAdapter = {
|
||||
name: "test", async handleWebhook() { return {}; }, async sendMessage(outgoing) { sent.push(outgoing); }
|
||||
};
|
||||
const result = await gateway.receive(message("work"), adapter);
|
||||
assert.equal(result.ok, false);
|
||||
assert.deepEqual(sent.map((outgoing) => outgoing.replySequence), [1, 2]);
|
||||
});
|
||||
|
||||
test("single asynchronous command replies with sequence one", async () => {
|
||||
const runtime = new FakeRuntime(); const gateway = new Gateway(policy, runtime); const sent: OutgoingMessage[] = [];
|
||||
const adapter: PlatformAdapter = {
|
||||
name: "test", async handleWebhook() { return {}; }, async sendMessage(outgoing) { sent.push(outgoing); }
|
||||
};
|
||||
await gateway.receive(message("/help"), adapter);
|
||||
assert.equal(sent.length, 1);
|
||||
assert.equal(sent[0].replySequence, 1);
|
||||
});
|
||||
|
||||
test("synchronous messages do not send acknowledgements", async () => {
|
||||
const runtime = new FakeRuntime(); const gateway = new Gateway(policy, runtime); const sent: string[] = []; const adapter = recordingAdapter(sent);
|
||||
const turn = gateway.receive(message("work"), adapter, { synchronous: true });
|
||||
await waitFor(() => runtime.prompts === 1);
|
||||
assert.deepEqual(sent, []);
|
||||
runtime.releaseNext();
|
||||
await turn;
|
||||
assert.deepEqual(sent, []);
|
||||
});
|
||||
|
||||
test("fixed Bot status and retired role commands never reach ACP", async () => {
|
||||
const runtime = new FakeRuntime(); const gateway = new Gateway(policy, runtime);
|
||||
const runtime = new FakeRuntime(); const gateway = new Gateway(policy, runtime); const adapter = recordingAdapter();
|
||||
for (const command of ["/roles", "/role ops", "/agents", "/agent ops"]) {
|
||||
assert.match((await gateway.receive(message(command), adapter, { synchronous: true })).reply || "", /removed in Config v3/);
|
||||
}
|
||||
|
||||
+11
-4
@@ -20,16 +20,23 @@ test("normalizes GROUP and C2C author openid while ACK remains immediate", async
|
||||
assert.equal(received[1].chatId, "user:u2"); assert.equal(received[1].userId, "u2");
|
||||
});
|
||||
|
||||
test("sendMessage uses nested author.user_openid for C2C endpoint", async () => {
|
||||
const original = globalThis.fetch; const urls: string[] = [];
|
||||
globalThis.fetch = (async (input: string | URL | Request) => {
|
||||
test("sendMessage uses C2C endpoint and forwards reply sequences as msg_seq", async () => {
|
||||
const original = globalThis.fetch; const urls: string[] = []; const bodies: Array<Record<string, unknown>> = [];
|
||||
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
|
||||
urls.push(String(input));
|
||||
if (String(input).includes("getAppAccessToken")) return new Response(JSON.stringify({ access_token: "token", expires_in: 7200 }), { status: 200 });
|
||||
bodies.push(JSON.parse(String(init?.body)) as Record<string, unknown>);
|
||||
return new Response(JSON.stringify({ id: "sent" }), { status: 200 });
|
||||
}) as typeof fetch;
|
||||
try {
|
||||
const adapter = new QqAdapter(config, { receive: async () => ({ ok: true }) } as never);
|
||||
await adapter.sendMessage({ target: { platform: "qq", chatId: "user:u2", raw: { author: { user_openid: "u2" } } }, text: "reply", replyTo: "m2" });
|
||||
const target = { platform: "qq", chatId: "user:u2", raw: { author: { user_openid: "u2" } } };
|
||||
await adapter.sendMessage({ target, text: "ack", replyTo: "m2", replySequence: 1 });
|
||||
await adapter.sendMessage({ target, text: "reply", replyTo: "m2", replySequence: 2 });
|
||||
assert.ok(urls.some((url) => url.endsWith("/v2/users/u2/messages")));
|
||||
assert.deepEqual(bodies, [
|
||||
{ content: "ack", msg_id: "m2", msg_seq: 1 },
|
||||
{ content: "reply", msg_id: "m2", msg_seq: 2 }
|
||||
]);
|
||||
} finally { globalThis.fetch = original; }
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user