From 95dce770a16e400cb1872b1f8a1d44b4809801cf Mon Sep 17 00:00:00 2001 From: William Wang Date: Wed, 8 Jul 2026 17:53:49 +0800 Subject: [PATCH 1/3] =?UTF-8?q?fix:=20route=20server=E2=86=92client=20requ?= =?UTF-8?q?ests=20by=20sessionId=20to=20prevent=20cross-session=20popup=20?= =?UTF-8?q?leaks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/backend/client.ts | 10 ++++++++ src/handlers/server-requests.ts | 44 +++++++++++++++++++++++++++++---- tests/backend.test.ts | 22 +++++++++++++++++ 3 files changed, 71 insertions(+), 5 deletions(-) diff --git a/src/backend/client.ts b/src/backend/client.ts index 73c1621..4af3d73 100644 --- a/src/backend/client.ts +++ b/src/backend/client.ts @@ -217,6 +217,16 @@ export class ZcodeBackend { 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; diff --git a/src/handlers/server-requests.ts b/src/handlers/server-requests.ts index 0029db8..8eca20b 100644 --- a/src/handlers/server-requests.ts +++ b/src/handlers/server-requests.ts @@ -61,8 +61,17 @@ export function getPendingInteractions(server: ZcodeAcpServer): Map { 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); } } diff --git a/tests/backend.test.ts b/tests/backend.test.ts index 0402ec0..c87477f 100644 --- a/tests/backend.test.ts +++ b/tests/backend.test.ts @@ -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(); + }); }); From a21a3419309443d6dec39cd723e9e740b4215d23 Mon Sep 17 00:00:00 2001 From: William Wang Date: Wed, 8 Jul 2026 18:35:44 +0800 Subject: [PATCH 2/3] refactor: shorten quota divider and balance progress-bar shades --- src/quota/format.ts | 13 ++++++++----- tests/quota.test.ts | 12 ++++++------ 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/src/quota/format.ts b/src/quota/format.ts index 4dfa6d3..c84f803 100644 --- a/src/quota/format.ts +++ b/src/quota/format.ts @@ -2,10 +2,13 @@ * Quota result formatting — renders a {@link QuotaResult} as a multi-line * plain-text card for the `agent_message_chunk` feedback. * - * The progress bar uses two block characters at {@link BAR_WIDTH}-cell width + * The progress bar uses two shade 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 + * ▓ (U+2593, dark shade) — used portion + * ░ (U+2591, light shade) — remaining portion + * The two shades are a natural pair (≈75% / ≈25% density), keeping the visual + * contrast between used and remaining portions even, instead of the lopsided + * look a full block (U+2588) next to a light shade produces. */ import type { QuotaItem, QuotaResult } from "./types.js"; @@ -14,7 +17,7 @@ import type { QuotaItem, QuotaResult } from "./types.js"; const BAR_WIDTH = 10; /** Filled / empty cell characters. */ -const CHAR_FULL = "█"; +const CHAR_FULL = "▓"; const CHAR_EMPTY = "░"; /** Pad a percent number to 2 chars (right-aligned). */ @@ -120,7 +123,7 @@ export function formatQuota(result: QuotaResult): string { } const header = `GLM Coding Plan${result.level ? ` · ${capitalise(result.level)}` : ""}`; - const divider = "─".repeat(50); + const divider = "─".repeat(24); const body = result.items.flatMap(formatItem); return [header, divider, ...body].join("\n"); } diff --git a/tests/quota.test.ts b/tests/quota.test.ts index 67e99f4..9994377 100644 --- a/tests/quota.test.ts +++ b/tests/quota.test.ts @@ -169,24 +169,24 @@ describe("renderBar", () => { }); it("100% → all full cells", () => { - expect(renderBar(100)).toBe("█".repeat(10)); + expect(renderBar(100)).toBe("▓".repeat(10)); }); it("50% → 5 full + 5 empty", () => { - expect(renderBar(50)).toBe("█".repeat(5) + "░".repeat(5)); + expect(renderBar(50)).toBe("▓".repeat(5) + "░".repeat(5)); }); it("rounds to the nearest cell (each cell = 10%)", () => { // 24% of 10 = 2.4 → rounds to 2 full. - expect(renderBar(24)).toBe("██" + "░".repeat(8)); + expect(renderBar(24)).toBe("▓▓" + "░".repeat(8)); // 5% of 10 = 0.5 → rounds to 1 full. - expect(renderBar(5)).toBe("█" + "░".repeat(9)); + expect(renderBar(5)).toBe("▓" + "░".repeat(9)); // 21% of 10 = 2.1 → rounds to 2 full. - expect(renderBar(21)).toBe("██" + "░".repeat(8)); + expect(renderBar(21)).toBe("▓▓" + "░".repeat(8)); }); it("clamps inputs outside [0, 100]", () => { - expect(renderBar(150)).toBe("█".repeat(10)); + expect(renderBar(150)).toBe("▓".repeat(10)); expect(renderBar(-10)).toBe("░".repeat(10)); }); }); From f384b517a091b37338990b0b3a8b1b67447173af Mon Sep 17 00:00:00 2001 From: William Wang Date: Wed, 8 Jul 2026 19:13:50 +0800 Subject: [PATCH 3/3] feat: wrap quota card in code block and double bar precision --- src/quota/format.ts | 43 ++++++++++++++++++++++++------------ tests/quota.test.ts | 54 ++++++++++++++++++++++++--------------------- 2 files changed, 58 insertions(+), 39 deletions(-) diff --git a/src/quota/format.ts b/src/quota/format.ts index c84f803..bdaaf26 100644 --- a/src/quota/format.ts +++ b/src/quota/format.ts @@ -1,23 +1,30 @@ /** * 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 progress bar uses two shade characters at {@link BAR_WIDTH}-cell width - * for a clean, precise fill (each cell = 10%): - * ▓ (U+2593, dark shade) — used portion + * 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 = 5%): + * █ (U+2588, full block) — used portion * ░ (U+2591, light shade) — remaining portion - * The two shades are a natural pair (≈75% / ≈25% density), keeping the visual - * contrast between used and remaining portions even, instead of the lopsided - * look a full block (U+2588) next to a light shade produces. + * 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 = "▓"; +const CHAR_FULL = "█"; const CHAR_EMPTY = "░"; /** Pad a percent number to 2 chars (right-aligned). */ @@ -111,11 +118,17 @@ const STATUS_MESSAGES: Record, 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") { @@ -123,7 +136,9 @@ export function formatQuota(result: QuotaResult): string { } const header = `GLM Coding Plan${result.level ? ` · ${capitalise(result.level)}` : ""}`; - const divider = "─".repeat(24); + // 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"); } diff --git a/tests/quota.test.ts b/tests/quota.test.ts index 9994377..a18cad9 100644 --- a/tests/quota.test.ts +++ b/tests/quota.test.ts @@ -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)); }); }); @@ -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)", () => {