Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions plugins/agentbridge/server/bridge-server.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions plugins/agentbridge/server/daemon.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

33 changes: 33 additions & 0 deletions src/claude-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@
* (The old AGENTBRIDGE_MODE=pull delivery mode was removed: it could not wake
* an idle session, which silently broke the budget RESUME chain.)
*
* #223: the channel push is fire-and-forget and is silently dropped when the
* Claude session is idle, so the fallback catch never fires and the reply is
* lost. Opt-in AGENTBRIDGE_ALWAYS_QUEUE=1 additionally mirrors real Codex
* replies into the fallback queue, making get_messages a reliable pull path
* for idle sessions. Off by default → delivery behavior is unchanged.
*
* Emits:
* - "ready" () — MCP connected
* - "reply" (msg: BridgeMessage) — Claude used the reply tool
Expand Down Expand Up @@ -241,6 +247,27 @@ export class ClaudeAdapter extends EventEmitter {
const deliveryAttemptId = `codex_msg_${this.notificationIdPrefix}_${++this.notificationSeq}`;
const ts = new Date(message.timestamp).toISOString();

// #223: notifications/claude/channel is fire-and-forget and is silently
// dropped when the Claude session is idle, so the catch below never runs
// and the reply is lost with get_messages staying empty. With
// AGENTBRIDGE_ALWAYS_QUEUE=1, mirror real Codex replies (non-system ids)
// into the fallback queue as well, so get_messages is a reliable pull path
// even for idle sessions. Off by default → delivery behavior is unchanged.
//
// NOTE: system messages are identified by their id prefix ("system_*")
// because BridgeMessage has no structural kind/type field (both system and
// agent messages carry source="codex"). If a structural discriminator (e.g.
// a `kind` field) is added in the future, prefer it over the id prefix.
//
// SECURITY: with this flag on, the fallback queue retains real agent reply
// content until drained by get_messages. This does not introduce a new data
// class but extends the retention window — document accordingly.
const mirrorToQueue =
process.env.AGENTBRIDGE_ALWAYS_QUEUE === "1" &&
typeof message.id === "string" &&
!message.id.startsWith("system_");
let queuedInCatch = false;

try {
await this.server.notification({
method: "notifications/claude/channel",
Expand All @@ -265,6 +292,12 @@ export class ClaudeAdapter extends EventEmitter {
} catch (e: any) {
this.log(`Push notification failed: ${e.message}`);
this.queueFallbackMessage(message);
queuedInCatch = true;
}

if (mirrorToQueue && !queuedInCatch) {
this.queueFallbackMessage(message);
this.log(`Always-queue: mirrored ${message.id} to fallback queue (#223)`);
}
}

Expand Down
47 changes: 47 additions & 0 deletions src/unit-test/message-delivery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,53 @@ describe("Push-only delivery: AGENTBRIDGE_MODE is ignored", () => {
});
});

// #223: with AGENTBRIDGE_ALWAYS_QUEUE=1 the channel push still fires, but real
// Codex replies are ALSO mirrored into the fallback queue so get_messages is a
// reliable pull path even when the Claude session is idle and the fire-and-
// forget channel push is silently dropped.
describe("Message delivery: AGENTBRIDGE_ALWAYS_QUEUE (#223)", () => {
const origAlwaysQueue = process.env.AGENTBRIDGE_ALWAYS_QUEUE;
afterEach(() => {
if (origAlwaysQueue !== undefined) process.env.AGENTBRIDGE_ALWAYS_QUEUE = origAlwaysQueue;
else delete process.env.AGENTBRIDGE_ALWAYS_QUEUE;
});

test("mirrors a real Codex reply to the fallback queue while still pushing", async () => {
const adapter = createAdapter();
const notifications = withMockedChannel(adapter);
process.env.AGENTBRIDGE_ALWAYS_QUEUE = "1";
await adapter.pushNotification(makeBridgeMessage("codex reply", undefined, "msg_abc123"));
expect(notifications).toHaveLength(1); // channel push still happens
expect(adapter.pendingMessages).toHaveLength(1); // and it is drainable via get_messages
expect(adapter.pendingMessages[0].content).toBe("codex reply");
});

test("does not mirror system messages (system_ id prefix)", async () => {
const adapter = createAdapter();
withMockedChannel(adapter);
process.env.AGENTBRIDGE_ALWAYS_QUEUE = "1";
await adapter.pushNotification(makeBridgeMessage("turn started", undefined, "system_turn_started_1"));
expect(adapter.pendingMessages).toHaveLength(0);
});

test("does not double-queue when the channel push fails", async () => {
const adapter = createAdapter();
withMockedChannel(adapter, "fail");
process.env.AGENTBRIDGE_ALWAYS_QUEUE = "1";
await adapter.pushNotification(makeBridgeMessage("codex reply", undefined, "msg_def456"));
expect(adapter.pendingMessages).toHaveLength(1); // queued exactly once (catch path)
});

test("default (flag unset) keeps push-only behavior — nothing queued", async () => {
const adapter = createAdapter();
const notifications = withMockedChannel(adapter);
delete process.env.AGENTBRIDGE_ALWAYS_QUEUE;
await adapter.pushNotification(makeBridgeMessage("codex reply", undefined, "msg_ghi789"));
expect(notifications).toHaveLength(1);
expect(adapter.pendingMessages).toHaveLength(0);
});
});

describe("Message delivery: fallback queue", () => {
test("queueFallbackMessage adds message to pendingMessages", () => {
const adapter = createAdapter();
Expand Down
Loading