From fb0125e5532cbfccb34178c1c843e55b580e9170 Mon Sep 17 00:00:00 2001 From: guoyb Date: Mon, 22 Jun 2026 16:10:26 +0800 Subject: [PATCH] Show Codex quota notice once on stop Jieli-Thread: https://jieli.app/threads/T-019eee5b-3a20-7e30-8fb4-d53b650a3dae --- plugins/codex/.codex-plugin/plugin.json | 2 +- plugins/codex/scripts/jieli_node.mjs | 18 ++++++++--- plugins/codex/tests/runtime-node.test.mjs | 38 +++++++++++++++++++++++ 3 files changed, 53 insertions(+), 5 deletions(-) diff --git a/plugins/codex/.codex-plugin/plugin.json b/plugins/codex/.codex-plugin/plugin.json index e170f1a..d3ad5c1 100644 --- a/plugins/codex/.codex-plugin/plugin.json +++ b/plugins/codex/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "jieli", - "version": "0.1.25", + "version": "0.1.26", "description": "Sync Codex sessions to Jieli threads and attach Jieli thread trailers to Codex-created commits.", "author": { "name": "Jieli" diff --git a/plugins/codex/scripts/jieli_node.mjs b/plugins/codex/scripts/jieli_node.mjs index c0914c6..4f6595b 100644 --- a/plugins/codex/scripts/jieli_node.mjs +++ b/plugins/codex/scripts/jieli_node.mjs @@ -27,6 +27,7 @@ const TRANSCRIPT_FLUSH_TIMEOUT_MS = 1500; const ATTACHMENT_CACHE_FILE = "codex-attachments.json"; const SESSION_MAPPING_FILE = "codex-sessions.json"; const HANDOFF_CONTEXT_FILE = "codex-handoff-context.json"; +const QUOTA_NOTICE_FILE = "quota-notices.json"; const TOOL_OUTPUT_MAX_CHARS = 20000; const SETTINGS_FILE_NAME = "settings.json"; const TRAILER_KEY = "Jieli-Thread"; @@ -253,10 +254,18 @@ function buildMissingConfigHookResponse(trigger, missing) { }; } -function buildQuotaExceededHookResponse(trigger, error) { - if (String(trigger || "").toLowerCase() !== "sessionstart") return {}; +function buildQuotaExceededHookResponse(trigger, error, sessionId = "") { + if (String(trigger || "").toLowerCase() !== "stop") return {}; const message = formatError(error); if (!/\b409\b/.test(message) || !/quota/i.test(message)) return {}; + const cleanSessionId = String(sessionId || "").trim(); + if (!cleanSessionId) return {}; + const path = join(homeDir(), ".jieli", QUOTA_NOTICE_FILE); + const notices = readJson(path, {}); + const key = `${PROVIDER}|${cleanSessionId}|message-quota-exceeded`; + if (notices[key]) return {}; + notices[key] = new Date().toISOString(); + writeJsonAtomic(path, notices); return { continue: true, systemMessage: @@ -389,8 +398,9 @@ function loadHookStdin() { async function syncMain(args) { const opts = parseArgs(args, { boolean: new Set(["jieli-hook"]) }); + let hookData = {}; try { - const hookData = loadHookStdin(); + hookData = loadHookStdin(); hookData.trigger = opts.trigger || ""; writeHandoffContext(hookData); captureBaselineFromHook(hookData, opts.trigger || ""); @@ -424,7 +434,7 @@ async function syncMain(args) { releaseSyncLock(lock); } } catch (error) { - const response = buildQuotaExceededHookResponse(opts.trigger || "", error); + const response = buildQuotaExceededHookResponse(opts.trigger || "", error, hookData.session_id || ""); if (Object.keys(response).length) console.log(JSON.stringify(response)); logHookError(`sync ${opts.trigger || ""}: ${formatError(error)}`); } diff --git a/plugins/codex/tests/runtime-node.test.mjs b/plugins/codex/tests/runtime-node.test.mjs index 08c3821..84fc4b3 100644 --- a/plugins/codex/tests/runtime-node.test.mjs +++ b/plugins/codex/tests/runtime-node.test.mjs @@ -665,6 +665,44 @@ test("configuration, upload, lock, session mapping, and missing transcript behav assert.throws(() => statSync(join(transcriptHome, ".jieli", "hooks.log"))); }); +test("sync CLI reports message quota exceeded only once per session on stop", async () => { + const home = makeTempDir(); + const transcript = join(home, "sessions", "quota-session.jsonl"); + mkdirSync(dirname(transcript), { recursive: true }); + writeJsonl(transcript, [ + { type: "session_meta", payload: { id: "codex-quota", cwd: "/Users/alice/work/jieli", git: { branch: "main" } } }, + { type: "response_item", payload: { type: "message", role: "user", content: [{ type: "input_text", text: "sync me" }] } }, + { type: "response_item", payload: { type: "message", role: "assistant", content: [{ type: "output_text", text: "ok" }] } }, + ]); + const { server } = createMockJieliServer({ uploadStatus: 409, uploadResponse: { success: false, message: "message quota exceeded", data: null } }); + const baseUrl = await listen(server); + const env = { HOME: home, PATH: process.env.PATH, JIELI_API_KEY: "secret", JIELI_BASE_URL: baseUrl }; + try { + const sessionStart = await runNode([join(pluginRoot, "scripts", "sync.mjs"), "--trigger", "sessionstart", "--jieli-hook"], { + input: JSON.stringify({ transcript_path: transcript, session_id: "codex-quota", cwd: "/Users/alice/work/jieli" }), + env, + }); + assert.equal(sessionStart.status, 0); + assert.equal(sessionStart.stdout, ""); + + const firstStop = await runNode([join(pluginRoot, "scripts", "sync.mjs"), "--trigger", "stop", "--jieli-hook"], { + input: JSON.stringify({ transcript_path: transcript, session_id: "codex-quota", cwd: "/Users/alice/work/jieli" }), + env, + }); + assert.equal(firstStop.status, 0); + assert.match(JSON.parse(firstStop.stdout).systemMessage, /message quota exceeded/); + + const secondStop = await runNode([join(pluginRoot, "scripts", "sync.mjs"), "--trigger", "stop", "--jieli-hook"], { + input: JSON.stringify({ transcript_path: transcript, session_id: "codex-quota", cwd: "/Users/alice/work/jieli" }), + env, + }); + assert.equal(secondStop.status, 0); + assert.equal(secondStop.stdout, ""); + } finally { + await close(server); + } +}); + test("read-thread and find-threads helpers validate ids, shape requests, truncate output, and format markdown", async () => { assert.throws(() => runtime.validateThreadId("https://jieli.example.test/threads/T-1"), /provider thread id/); assert.throws(() => runtime.validateThreadId("T-1.md"), /without .md/);