Skip to content
Merged
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
10 changes: 10 additions & 0 deletions src/backend/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,11 @@
* workers) with `process.kill(-pid)` and leave no orphans.
*/

import { spawn, type ChildProcess } from "node:child_process";

Check warning on line 18 in src/backend/client.ts

View workflow job for this annotation

GitHub Actions / Build & Test (22.x)

Member 'ChildProcess' of the import declaration should be sorted alphabetically
import { createInterface } from "node:readline";
import process from "node:process";

import { log, warn } from "../utils.js";

Check warning on line 22 in src/backend/client.ts

View workflow job for this annotation

GitHub Actions / Build & Test (22.x)

Expected 'multiple' syntax before 'single' syntax
import type {
ZcodeEvent,
ZcodeInbound,
Expand Down Expand Up @@ -217,6 +217,16 @@
return this.serverRequests.splice(0, this.serverRequests.length);
}

/**
* Re-queue server→client requests that belong to a different session (prepended
* to preserve arrival order). Used by `handleServerRequests` to put back
* requests it popped but doesn't own.
*/
requeueServerRequests(reqs: ServerRequest[]): void {
if (reqs.length === 0) return;
this.serverRequests.unshift(...reqs);
}

/** Reply to a zcode server→client request with a result (id + result). */
sendReply(id: number, result: unknown): void {
const stdin = this.proc.stdin;
Expand Down
44 changes: 39 additions & 5 deletions src/handlers/server-requests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,17 @@ export function getPendingInteractions(server: ZcodeAcpServer): Map<string, Dedu
}

/**
* Drain and handle all pending zcode server→client requests. Returns true if
* any were handled (used by the turn loop to refresh the no-progress timer).
* Drain and handle pending zcode server→client requests for THIS session only.
* Returns true if any were handled (used by the turn loop to refresh the
* no-progress timer).
*
* The backend's `serverRequests` queue is shared across all sessions (a single
* subprocess serves them all). Without filtering, session A's turn loop could
* pop session B's permission request and forward it to A's client — the popup
* lands in the wrong session. When `turn` is available we filter by
* `params.sessionId` so each turn loop only consumes its own requests; others
* are re-queued for their owner. Without `turn` (tests / non-turn callers) we
* process everything (legacy behaviour).
*/
export async function handleServerRequests(
server: ZcodeAcpServer,
Expand All @@ -73,12 +82,37 @@ export async function handleServerRequests(
): Promise<boolean> {
const pending = getPendingInteractions(server);
let handled = false;
const mySid = turn?.zcodeSid;

for (;;) {
const req = backend.pollServerRequests().shift() ?? null;
if (!req) return handled;
const all = backend.pollServerRequests();
if (all.length === 0) return handled;
// Without a session filter (no turn), process everything — legacy path.
if (mySid === undefined) {
for (const req of all) {
handled = true;
await handleOne(server, backend, cx, acpSid, req, pending, turn);
}
continue;
}
// Pick the first request belonging to this session; put the rest back.
// Requests without a sessionId field are unrouteable — claim them here
// so they don't sit in the queue forever.
let mine: ServerRequest | undefined;
const others: ServerRequest[] = [];
for (const r of all) {
const sid = (r.params as { sessionId?: string }).sessionId;
if (!mine && (sid === undefined || sid === mySid)) {
mine = r;
} else {
others.push(r);
}
}
// Re-queue the ones that don't belong to this session (prepend to preserve order).
if (others.length > 0) backend.requeueServerRequests(others);
if (!mine) return handled;
handled = true;
await handleOne(server, backend, cx, acpSid, req, pending, turn);
await handleOne(server, backend, cx, acpSid, mine, pending, turn);
Comment on lines +98 to +115

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Instead of picking only the first matching request and requeueing the rest (including other requests that also belong to the current session), we can separate the polled requests into those belonging to the current session (mine) and those belonging to other sessions (others). This allows us to requeue only the non-matching requests once, and process all of our session's requests sequentially in a single iteration. This avoids unnecessary queue churn and repeated polling/requeueing of our own requests.

    // Separate requests belonging to this session from others.
    // Requests without a sessionId field are unrouteable — claim them here
    // so they don't sit in the queue forever.
    const mine: ServerRequest[] = [];
    const others: ServerRequest[] = [];
    for (const r of all) {
      const sid = (r.params as { sessionId?: string }).sessionId;
      if (sid === undefined || sid === mySid) {
        mine.push(r);
      } else {
        others.push(r);
      }
    }
    // Re-queue the ones that don't belong to this session (prepend to preserve order).
    if (others.length > 0) backend.requeueServerRequests(others);
    if (mine.length === 0) return handled;

    for (const req of mine) {
      handled = true;
      await handleOne(server, backend, cx, acpSid, req, pending, turn);
    }

}
}

Expand Down
38 changes: 28 additions & 10 deletions src/quota/format.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,27 @@
/**
* Quota result formatting — renders a {@link QuotaResult} as a multi-line
* plain-text card for the `agent_message_chunk` feedback.
* plain-text card inside a ```text fenced block.
*
* The whole card is wrapped in a fenced code block so the editor renders it in
* a monospace, bordered, copy-able frame. Monospace also normalises the block
* element widths (▓/░ are NOT marked East-Asian-Wide, but some proportional
* chat fonts stretch them; a code frame guarantees 1-cell-per-bar).
*
* The progress bar uses two block characters at {@link BAR_WIDTH}-cell width
* for a clean, precise fill (each cell = 10%):
* █ (U+2588, full) — used portion
* ░ (U+2591, light) — remaining portion
* for a clean, precise fill (each cell = 5%):
* █ (U+2588, full block) — used portion
* ░ (U+2591, light shade) — remaining portion
* Inside a monospace code block the full-block reads as a solid, high-impact
* fill for used quota, with the light shade marking the remainder.
*/

import type { QuotaItem, QuotaResult } from "./types.js";

/** Progress-bar cell count. Compact to fit chat-frame width without wrapping. */
const BAR_WIDTH = 10;
/**
* Progress-bar cell count. 20 cells = 5% granularity, fine enough to read
* real usage at a glance while still fitting a code-block frame on one line.
*/
const BAR_WIDTH = 20;

/** Filled / empty cell characters. */
const CHAR_FULL = "█";
Expand Down Expand Up @@ -108,19 +118,27 @@ const STATUS_MESSAGES: Record<Exclude<QuotaResult["kind"], "success">, string> =
};

/**
* Render a {@link QuotaResult} as a multi-line plain-text card.
* Render a {@link QuotaResult} as a multi-line plain-text card wrapped in a
* ```text fenced block.
*
* The fence makes the editor render the card in a monospace, bordered, copy-
* able frame — without it the proportional chat font mangles block-element
* widths (▓/░ render at different glyph advances and can look stretched), and
* the divider/bars lose their alignment.
*
* Success → header line + divider + one progress-bar line per item (plus
* indented per-model detail where present). Non-success kinds → a single
* explanatory line.
* explanatory line, unfenced (they are short prose, not a card).
*/
export function formatQuota(result: QuotaResult): string {
if (result.kind !== "success") {
return STATUS_MESSAGES[result.kind];
}

const header = `GLM Coding Plan${result.level ? ` · ${capitalise(result.level)}` : ""}`;
const divider = "─".repeat(50);
// Divider spans the longest line so the frame looks balanced; 34 ≈ label(5)
// + space(1) + bar(20) + spaces(2) + "NN%"(3) + trailing-room(3).
const divider = "─".repeat(34);
const body = result.items.flatMap(formatItem);
return [header, divider, ...body].join("\n");
return ["```text", header, divider, ...body, "```"].join("\n");
}
22 changes: 22 additions & 0 deletions tests/backend.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,4 +105,26 @@ describe("ZcodeBackend reader routing (unit)", () => {
expect(resp2.error?.message).toMatch(/reader exited/);
b.close();
});

it("requeueServerRequests prepends so re-drained requests keep their order", () => {
const b = makeRoutingSubject();
b.route({
id: 5001,
method: "interaction/requestPermission",
params: { requestId: "r1", toolCallId: "t1", sessionId: "sess_a" },
});
b.route({
id: 5002,
method: "interaction/requestPermission",
params: { requestId: "r2", toolCallId: "t2", sessionId: "sess_b" },
});
// Drain both, then requeue the second (simulating a session filter put-back).
const all = b.pollServerRequests();
expect(all).toHaveLength(2);
b.requeueServerRequests([all[1]!]);
const remaining = b.pollServerRequests();
expect(remaining).toHaveLength(1);
expect((remaining[0]!.params as { sessionId: string }).sessionId).toBe("sess_b");
b.close();
});
});
54 changes: 29 additions & 25 deletions tests/quota.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,29 +165,29 @@ describe("parseQuotaEnvelope", () => {

describe("renderBar", () => {
it("0% → all empty cells", () => {
expect(renderBar(0)).toBe("░".repeat(10));
expect(renderBar(0)).toBe("░".repeat(20));
});

it("100% → all full cells", () => {
expect(renderBar(100)).toBe("█".repeat(10));
expect(renderBar(100)).toBe("█".repeat(20));
});

it("50% → 5 full + 5 empty", () => {
expect(renderBar(50)).toBe("█".repeat(5) + "░".repeat(5));
it("50% → 10 full + 10 empty", () => {
expect(renderBar(50)).toBe("█".repeat(10) + "░".repeat(10));
});

it("rounds to the nearest cell (each cell = 10%)", () => {
// 24% of 10 = 2.4 → rounds to 2 full.
expect(renderBar(24)).toBe("██" + "░".repeat(8));
// 5% of 10 = 0.5 → rounds to 1 full.
expect(renderBar(5)).toBe("█" + "░".repeat(9));
// 21% of 10 = 2.1 → rounds to 2 full.
expect(renderBar(21)).toBe("██" + "░".repeat(8));
it("rounds to the nearest cell (each cell = 5%)", () => {
// 24% of 20 = 4.8 → rounds to 5 full.
expect(renderBar(24)).toBe("█".repeat(5) + "░".repeat(15));
// 5% of 20 = 1 → 1 full.
expect(renderBar(5)).toBe("█" + "░".repeat(19));
// 21% of 20 = 4.2 → rounds to 4 full.
expect(renderBar(21)).toBe("█".repeat(4) + "░".repeat(16));
});

it("clamps inputs outside [0, 100]", () => {
expect(renderBar(150)).toBe("█".repeat(10));
expect(renderBar(-10)).toBe("░".repeat(10));
expect(renderBar(150)).toBe("█".repeat(20));
expect(renderBar(-10)).toBe("░".repeat(20));
});
});

Expand Down Expand Up @@ -221,21 +221,25 @@ describe("formatQuota", () => {
};
const out = formatQuota(result);
const lines = out.split("\n");
expect(lines[0]).toBe("GLM Coding Plan · Pro");
expect(lines[1]).toMatch(/^─+$/);
// The whole card is wrapped in a ```text fenced block so the editor
// renders it in a bordered, monospace, copy-able frame.
expect(lines[0]).toBe("```text");
expect(lines[1]).toBe("GLM Coding Plan · Pro");
expect(lines[2]).toMatch(/^─+$/);
// 5h line: percent + reset time, no "resets" word, no absolute counts.
expect(lines[2]).toContain("5h");
expect(lines[2]).toContain("5%");
expect(lines[2]).not.toContain("resets");
expect(lines[2]).not.toMatch(/\(\d+\/\d+\)/);
// MCP line: percent + absolute counts + reset time.
expect(lines[3]).toContain("MCP");
expect(lines[3]).toContain("24%");
expect(lines[3]).toContain("(237/1000)");
expect(lines[3]).toContain("5h");
expect(lines[3]).toContain("5%");
expect(lines[3]).not.toContain("resets");
expect(lines[3]).not.toMatch(/\(\d+\/\d+\)/);
// MCP line: percent + absolute counts + reset time.
expect(lines[4]).toContain("MCP");
expect(lines[4]).toContain("24%");
expect(lines[4]).toContain("(237/1000)");
expect(lines[4]).not.toContain("resets");
// Detail branches (now padded model codes).
expect(lines[4]).toMatch(/├ search-prime\s+\d+/);
expect(lines[5]).toMatch(/└ web-reader\s+\d+/);
expect(lines[5]).toMatch(/├ search-prime\s+\d+/);
expect(lines[6]).toMatch(/└ web-reader\s+\d+/);
expect(lines[lines.length - 1]).toBe("```");
});

it("omits absolute counts when the limit carries no counters (5h)", () => {
Expand Down
Loading