feat: publish Codex 429 auto-continue plugin
This commit is contained in:
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "codex-429-auto-continue",
|
||||||
|
"interface": {
|
||||||
|
"displayName": "Codex 429 Auto Continue"
|
||||||
|
},
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "codex-429-auto-continue",
|
||||||
|
"source": {
|
||||||
|
"source": "local",
|
||||||
|
"path": "./plugins/codex-429-auto-continue"
|
||||||
|
},
|
||||||
|
"policy": {
|
||||||
|
"installation": "AVAILABLE",
|
||||||
|
"authentication": "ON_INSTALL"
|
||||||
|
},
|
||||||
|
"category": "Productivity"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules/
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to this project are documented in this file.
|
||||||
|
|
||||||
|
## 0.1.0 - 2026-09-11
|
||||||
|
|
||||||
|
- Detect structured Codex HTTP 429 task failures.
|
||||||
|
- Continue the original task after a fixed 3-second wait.
|
||||||
|
- Prefer native continuation and fall back to sending `继续`.
|
||||||
|
- Support new, reopened, and currently loaded tasks.
|
||||||
|
- Prevent duplicate per-task watchers and false positives from ordinary text.
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Contributing / 参与贡献
|
||||||
|
|
||||||
|
Use Node.js 20 or newer. Keep changes focused on reliable Codex 429 recovery and add a regression test for behavior changes.
|
||||||
|
|
||||||
|
使用 Node.js 20 或更高版本。修改应聚焦于 Codex 429 续跑,并为行为变更补充回归测试。
|
||||||
|
|
||||||
|
Run the test suite before submitting a change:
|
||||||
|
|
||||||
|
提交修改前请运行:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not commit task transcripts, plugin data, tokens, pipe names, or other machine-specific information.
|
||||||
|
|
||||||
|
请勿提交任务会话记录、插件数据、令牌、管道名称或其他机器相关信息。
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 cneicy
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# Codex 429 Auto Continue
|
||||||
|
|
||||||
|
[简体中文](README.zh-CN.md)
|
||||||
|
|
||||||
|
A Codex Desktop plugin that continues a task after Codex exhausts its built-in retries with HTTP 429.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Detects only structured `response_too_many_failed_attempts` failures with HTTP status 429.
|
||||||
|
- Waits exactly 3 seconds for every continuation attempt, with at most four plugin-initiated attempts in one failure chain.
|
||||||
|
- Uses a native retry or continue tool when Codex exposes one; otherwise sends only `继续` to the original task.
|
||||||
|
- Covers new and reopened tasks through `SessionStart` and backfills all currently loaded tasks through `UserPromptSubmit`.
|
||||||
|
- Cancels its pending continuation when Codex or the user has already started another turn.
|
||||||
|
- Uses one watcher per task to avoid duplicate continuations.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Codex Desktop on Windows.
|
||||||
|
- Node.js available as `node` on `PATH`.
|
||||||
|
|
||||||
|
The plugin currently depends on Codex Desktop's internal local task-tool pipe. That interface is not a stable public API and may require compatibility updates after a Codex release.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
codex plugin marketplace add https://git.crash.work/cneicy/codex-429-auto-continue.git
|
||||||
|
codex plugin add codex-429-auto-continue@codex-429-auto-continue
|
||||||
|
```
|
||||||
|
|
||||||
|
Start or reopen a task after installation. If Codex asks you to review the hook, approve it from `/hooks`.
|
||||||
|
|
||||||
|
## Behavior
|
||||||
|
|
||||||
|
The watcher reads new records from the task transcript. After a structured 429 task failure, it waits 3 seconds and checks whether a new turn has already started. If not, it attempts native continuation and falls back to sending `继续`.
|
||||||
|
|
||||||
|
A successful task completion resets the failure chain. The chain also resets after 15 minutes without a plugin-initiated continuation. A watcher exits when its task ends or after 24 hours without transcript activity.
|
||||||
|
|
||||||
|
Prompt text, assistant text, and tool output that merely contain `429` do not trigger the plugin.
|
||||||
|
|
||||||
|
## Local Data
|
||||||
|
|
||||||
|
Per-task JSONL event logs and lock files are stored under:
|
||||||
|
|
||||||
|
```text
|
||||||
|
%USERPROFILE%\.codex\plugin-data\codex-429-auto-continue
|
||||||
|
```
|
||||||
|
|
||||||
|
The plugin does not make its own network requests. It reads local Codex transcripts and communicates with the local Codex Desktop task pipe.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
The installable plugin is under `plugins/codex-429-auto-continue`. The marketplace manifest is `.agents/plugins/marketplace.json`.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
[MIT](LICENSE)
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# Codex 429 自动继续
|
||||||
|
|
||||||
|
[English](README.md)
|
||||||
|
|
||||||
|
这是一个 Codex Desktop 插件。当 Codex 内置重试耗尽并以 HTTP 429 中断任务时,插件会自动继续原任务。
|
||||||
|
|
||||||
|
## 功能
|
||||||
|
|
||||||
|
- 只识别结构化的 `response_too_many_failed_attempts` HTTP 429 失败记录。
|
||||||
|
- 每次续跑统一等待 3 秒;同一失败链最多由插件发起四次续跑。
|
||||||
|
- Codex 提供原生重试或继续工具时优先使用;否则只向原任务发送“继续”。
|
||||||
|
- 通过 `SessionStart` 支持新建和重新打开的任务,通过 `UserPromptSubmit` 为安装前已经加载的任务补启动监听。
|
||||||
|
- 如果 Codex 或用户已经开始新一轮任务,取消当前等待中的续跑。
|
||||||
|
- 每个任务只运行一个监听进程,避免重复续跑。
|
||||||
|
|
||||||
|
## 运行要求
|
||||||
|
|
||||||
|
- Windows 版 Codex Desktop。
|
||||||
|
- `PATH` 中可以直接调用 `node`。
|
||||||
|
|
||||||
|
插件目前依赖 Codex Desktop 的本地内部任务工具管道。这个接口不是稳定的公开 API,Codex 更新后可能需要同步适配。
|
||||||
|
|
||||||
|
## 安装
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
codex plugin marketplace add https://git.crash.work/cneicy/codex-429-auto-continue.git
|
||||||
|
codex plugin add codex-429-auto-continue@codex-429-auto-continue
|
||||||
|
```
|
||||||
|
|
||||||
|
安装后新建或重新打开一个任务。如果 Codex 要求审查 hook,请在 `/hooks` 中批准。
|
||||||
|
|
||||||
|
## 工作方式
|
||||||
|
|
||||||
|
监听进程持续读取任务会话记录。发现结构化 429 失败后,先等待 3 秒,再检查是否已经开始新一轮任务;如果没有,就优先调用原生继续能力,不可用时发送“继续”。
|
||||||
|
|
||||||
|
任务成功完成后会重置失败链;连续 15 分钟没有由插件发起续跑也会重置。任务结束或会话记录连续 24 小时没有变化时,监听进程退出。
|
||||||
|
|
||||||
|
用户提示、助手回复或工具输出中仅仅出现 `429` 字样,不会触发插件。
|
||||||
|
|
||||||
|
## 本地数据
|
||||||
|
|
||||||
|
每个任务的 JSONL 事件日志和锁文件保存在:
|
||||||
|
|
||||||
|
```text
|
||||||
|
%USERPROFILE%\.codex\plugin-data\codex-429-auto-continue
|
||||||
|
```
|
||||||
|
|
||||||
|
插件不会自行发起网络请求。它只读取本地 Codex 会话记录,并通过本地 Codex Desktop 任务工具管道继续任务。
|
||||||
|
|
||||||
|
## 开发
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
可安装插件位于 `plugins/codex-429-auto-continue`,marketplace 清单位于 `.agents/plugins/marketplace.json`。
|
||||||
|
|
||||||
|
## 许可证
|
||||||
|
|
||||||
|
[MIT](LICENSE)
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
# Security Policy / 安全策略
|
||||||
|
|
||||||
|
## Reporting / 报告方式
|
||||||
|
|
||||||
|
Do not publish tokens, Codex transcripts, session identifiers, or local pipe details in a public issue. Report security concerns privately to `im@crash.work` with reproduction steps and the affected version.
|
||||||
|
|
||||||
|
请勿在公开 Issue 中发布令牌、Codex 会话记录、会话 ID 或本地管道信息。请将复现步骤和受影响版本通过 `im@crash.work` 私下报告。
|
||||||
|
|
||||||
|
Only the latest released version receives security fixes.
|
||||||
|
|
||||||
|
仅当前最新发布版本提供安全修复。
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "codex-429-auto-continue",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "Automatically continue Codex Desktop tasks after exhausted HTTP 429 retries.",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"test": "node --test plugins/codex-429-auto-continue/tests/hook.test.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"license": "MIT"
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "codex-429-auto-continue",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Automatically continues a Codex desktop task after an exhausted HTTP 429 retry cycle.",
|
||||||
|
"author": {
|
||||||
|
"name": "im"
|
||||||
|
},
|
||||||
|
"interface": {
|
||||||
|
"displayName": "Codex 429 Auto Continue",
|
||||||
|
"shortDescription": "Resume interrupted tasks after Codex HTTP 429 errors.",
|
||||||
|
"longDescription": "Detects a structured HTTP 429 task failure, waits 3 seconds, and continues the original Codex desktop task.",
|
||||||
|
"developerName": "im",
|
||||||
|
"category": "Productivity",
|
||||||
|
"capabilities": [
|
||||||
|
"Automation"
|
||||||
|
],
|
||||||
|
"defaultPrompt": "Show whether Codex 429 Auto Continue is installed."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Codex 429 Auto Continue
|
||||||
|
|
||||||
|
When a Codex task ends with the structured `response_too_many_failed_attempts` HTTP 429 error, this plugin continues the same task through the Codex desktop app. It prefers a native retry/continue tool when Codex exposes one and otherwise sends `继续`.
|
||||||
|
|
||||||
|
The plugin starts one watcher per loaded session. `SessionStart` covers new and reopened sessions, while `UserPromptSubmit` backfills watchers for sessions that were already loaded when the plugin was installed. It gives Codex's built-in retry a chance to start first; when no new task has started, every continuation attempt waits exactly 3 seconds. A successful completion or 15 minutes without a plugin-initiated retry resets the chain. Prompt text, assistant text, and tool output containing `429` do not trigger it.
|
||||||
|
|
||||||
|
The watcher requires `node` on `PATH` and the Codex desktop app to remain open. Continuations appear in the original task. The watcher exits on `SessionEnd` or after 24 hours without transcript activity. Detection and delivery events are recorded under the plugin data directory as per-session JSONL logs.
|
||||||
|
|
||||||
|
After installation, start a new task and review the plugin hook once in `/hooks` when Codex asks you to trust it.
|
||||||
@@ -0,0 +1,523 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import { appendFile, mkdir, open, readFile, readdir, rename, stat, unlink, writeFile } from "node:fs/promises";
|
||||||
|
import net from "node:net";
|
||||||
|
import { homedir } from "node:os";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
export const RETRY_DELAYS_MS = [3_000, 3_000, 3_000, 3_000];
|
||||||
|
const RETRY_RESET_MS = 15 * 60_000;
|
||||||
|
const WATCH_IDLE_MS = 24 * 60 * 60_000;
|
||||||
|
const POLL_MS = 1_500;
|
||||||
|
const TRANSCRIPT_TAIL_BYTES = 2 * 1024 * 1024;
|
||||||
|
const PIPE_TIMEOUT_MS = 5_000;
|
||||||
|
const THREAD_LIST_LIMIT = 50;
|
||||||
|
const CONTINUATION_PROMPT = "继续";
|
||||||
|
const NATIVE_CONTINUATION_TOOL_NAMES = [
|
||||||
|
"retry_turn",
|
||||||
|
"continue_thread",
|
||||||
|
"retry_thread",
|
||||||
|
"resume_thread",
|
||||||
|
];
|
||||||
|
|
||||||
|
function pluginDataDirectory() {
|
||||||
|
return process.env.PLUGIN_DATA
|
||||||
|
|| join(homedir(), ".codex", "plugin-data", "codex-429-auto-continue");
|
||||||
|
}
|
||||||
|
|
||||||
|
function sessionKey(sessionId) {
|
||||||
|
return createHash("sha256").update(sessionId).digest("hex").slice(0, 24);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sessionPaths(sessionId) {
|
||||||
|
const root = pluginDataDirectory();
|
||||||
|
const key = sessionKey(sessionId);
|
||||||
|
return {
|
||||||
|
lock: join(root, `${key}.lock.json`),
|
||||||
|
log: join(root, `${key}.log.jsonl`),
|
||||||
|
stop: join(root, `${key}.stop`),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readHookInput() {
|
||||||
|
let input = "";
|
||||||
|
for await (const chunk of process.stdin) input += chunk;
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(input);
|
||||||
|
return payload && typeof payload === "object" ? payload : {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function is429Record(record) {
|
||||||
|
const payload = record?.type === "event_msg" ? record.payload : null;
|
||||||
|
if (payload?.type !== "task_complete" || typeof payload.error !== "object") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = payload.error?.codex_error_info
|
||||||
|
?.response_too_many_failed_attempts?.http_status_code;
|
||||||
|
if (status === 429) return true;
|
||||||
|
|
||||||
|
const message = payload.error?.message;
|
||||||
|
return typeof message === "string"
|
||||||
|
&& /exceeded retry limit/i.test(message)
|
||||||
|
&& /\b429\b/.test(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSuccessfulCompletion(record) {
|
||||||
|
const payload = record?.type === "event_msg" ? record.payload : null;
|
||||||
|
return payload?.type === "task_complete" && !payload.error;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasTaskStartedAfter(records, failedRecord) {
|
||||||
|
return records.some((record) => {
|
||||||
|
if (record?.type !== "event_msg" || record.payload?.type !== "task_started") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (Number.isFinite(record.ordinal) && Number.isFinite(failedRecord?.ordinal)) {
|
||||||
|
return record.ordinal > failedRecord.ordinal;
|
||||||
|
}
|
||||||
|
return record.payload.turn_id !== failedRecord?.payload?.turn_id;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readTranscriptTail(path) {
|
||||||
|
const metadata = await stat(path);
|
||||||
|
const start = Math.max(0, metadata.size - TRANSCRIPT_TAIL_BYTES);
|
||||||
|
const handle = await open(path, "r");
|
||||||
|
try {
|
||||||
|
const buffer = Buffer.alloc(metadata.size - start);
|
||||||
|
await handle.read(buffer, 0, buffer.length, start);
|
||||||
|
let text = buffer.toString("utf8");
|
||||||
|
if (start > 0) {
|
||||||
|
const firstNewline = text.indexOf("\n");
|
||||||
|
text = firstNewline >= 0 ? text.slice(firstNewline + 1) : "";
|
||||||
|
}
|
||||||
|
return text.split(/\r?\n/).filter(Boolean).flatMap((line) => {
|
||||||
|
try {
|
||||||
|
return [JSON.parse(line)];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await handle.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readNewRecords(path, cursor) {
|
||||||
|
const metadata = await stat(path);
|
||||||
|
if (metadata.size < cursor.offset) {
|
||||||
|
cursor.offset = 0;
|
||||||
|
cursor.carry = "";
|
||||||
|
}
|
||||||
|
if (metadata.size === cursor.offset) return [];
|
||||||
|
|
||||||
|
const handle = await open(path, "r");
|
||||||
|
try {
|
||||||
|
const buffer = Buffer.alloc(metadata.size - cursor.offset);
|
||||||
|
await handle.read(buffer, 0, buffer.length, cursor.offset);
|
||||||
|
cursor.offset = metadata.size;
|
||||||
|
const parts = `${cursor.carry}${buffer.toString("utf8")}`.split(/\r?\n/);
|
||||||
|
cursor.carry = parts.pop() ?? "";
|
||||||
|
return parts.flatMap((line) => {
|
||||||
|
if (!line) return [];
|
||||||
|
try {
|
||||||
|
return [JSON.parse(line)];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await handle.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pause(milliseconds) {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processIsAlive(pid) {
|
||||||
|
if (!Number.isInteger(pid) || pid <= 0) return false;
|
||||||
|
try {
|
||||||
|
process.kill(pid, 0);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function acquireLock(path) {
|
||||||
|
await mkdir(dirname(path), { recursive: true });
|
||||||
|
try {
|
||||||
|
const existing = JSON.parse(await readFile(path, "utf8"));
|
||||||
|
if (await processIsAlive(existing.pid)) return false;
|
||||||
|
await unlink(path).catch(() => {});
|
||||||
|
} catch {
|
||||||
|
await unlink(path).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const handle = await open(path, "wx");
|
||||||
|
await handle.writeFile(`${JSON.stringify({ pid: process.pid, startedAt: Date.now() })}\n`);
|
||||||
|
await handle.close();
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeFrame(message) {
|
||||||
|
const payload = Buffer.from(JSON.stringify(message), "utf8");
|
||||||
|
const frame = Buffer.alloc(payload.length + 4);
|
||||||
|
frame.writeUInt32LE(payload.length, 0);
|
||||||
|
payload.copy(frame, 4);
|
||||||
|
return frame;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pipeRequest(pipePath, method, params, timeoutMs = PIPE_TIMEOUT_MS) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const socket = net.createConnection(pipePath);
|
||||||
|
let settled = false;
|
||||||
|
let pending = Buffer.alloc(0);
|
||||||
|
const finish = (error, result) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
socket.destroy();
|
||||||
|
if (error) reject(error);
|
||||||
|
else resolve(result);
|
||||||
|
};
|
||||||
|
const timer = setTimeout(() => finish(new Error("Codex desktop pipe timed out")), timeoutMs);
|
||||||
|
socket.once("connect", () => {
|
||||||
|
socket.write(encodeFrame({ id: 1, jsonrpc: "2.0", method, params }));
|
||||||
|
});
|
||||||
|
socket.on("data", (chunk) => {
|
||||||
|
pending = Buffer.concat([pending, chunk]);
|
||||||
|
if (pending.length < 4) return;
|
||||||
|
const length = pending.readUInt32LE(0);
|
||||||
|
if (pending.length < length + 4) return;
|
||||||
|
try {
|
||||||
|
const response = JSON.parse(pending.subarray(4, length + 4).toString("utf8"));
|
||||||
|
if (response.error) finish(new Error(response.error.message));
|
||||||
|
else finish(null, response.result);
|
||||||
|
} catch (error) {
|
||||||
|
finish(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
socket.once("error", (error) => finish(error));
|
||||||
|
socket.once("close", () => {
|
||||||
|
if (!settled) finish(new Error("Codex desktop pipe closed"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseToolText(result) {
|
||||||
|
const text = result?.contentItems?.find((item) => item?.type === "inputText")?.text;
|
||||||
|
if (typeof text !== "string") return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function threadListContains(result, sessionId) {
|
||||||
|
const parsed = parseToolText(result);
|
||||||
|
if (!parsed || typeof parsed !== "object") return false;
|
||||||
|
return [...(parsed.pinnedThreads ?? []), ...(parsed.threads ?? [])]
|
||||||
|
.some((thread) => thread?.id === sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadedThreadIds(result) {
|
||||||
|
const parsed = parseToolText(result);
|
||||||
|
if (!parsed || typeof parsed !== "object") return [];
|
||||||
|
return [...(parsed.pinnedThreads ?? []), ...(parsed.threads ?? [])]
|
||||||
|
.filter((thread) => {
|
||||||
|
const status = typeof thread?.status === "string"
|
||||||
|
? thread.status
|
||||||
|
: thread?.status?.type;
|
||||||
|
return thread?.id && status !== "notLoaded";
|
||||||
|
})
|
||||||
|
.map((thread) => thread.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pipeCandidates() {
|
||||||
|
const configured = process.env.CODEX_APP_TOOLS_PIPE_PATH?.trim();
|
||||||
|
const candidates = configured ? [configured] : [];
|
||||||
|
if (process.platform !== "win32") return candidates;
|
||||||
|
const names = await readdir("\\\\.\\pipe\\").catch(() => []);
|
||||||
|
for (const name of names) {
|
||||||
|
if (/^codex-browser-use-/i.test(name)) {
|
||||||
|
candidates.push(`\\\\.\\pipe\\${name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...new Set(candidates)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function toolCallParams(input, tool, args) {
|
||||||
|
const callId = `codex-429-${Date.now()}-${process.pid}`;
|
||||||
|
return {
|
||||||
|
arguments: args,
|
||||||
|
callId,
|
||||||
|
namespace: "codex_app",
|
||||||
|
threadId: input.sessionId,
|
||||||
|
tool,
|
||||||
|
turnId: input.failedTurnId || callId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadedDesktopSessionIds(input, dependencies = {}) {
|
||||||
|
const listCandidates = dependencies.pipeCandidates ?? pipeCandidates;
|
||||||
|
const request = dependencies.pipeRequest ?? pipeRequest;
|
||||||
|
for (const pipePath of await listCandidates()) {
|
||||||
|
try {
|
||||||
|
await request(pipePath, "tools/list", { threadStartKind: "all" });
|
||||||
|
const listed = await request(pipePath, "tools/call", toolCallParams(input, "list_threads", {
|
||||||
|
limit: THREAD_LIST_LIMIT,
|
||||||
|
}));
|
||||||
|
if (listed?.success) return loadedThreadIds(listed);
|
||||||
|
} catch {
|
||||||
|
// Other Codex pipes do not expose app task tools.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findSessionTranscripts(sessionIds, root = join(homedir(), ".codex", "sessions")) {
|
||||||
|
const wanted = new Set(sessionIds);
|
||||||
|
const found = new Map();
|
||||||
|
async function visit(directory) {
|
||||||
|
const entries = await readdir(directory, { withFileTypes: true }).catch(() => []);
|
||||||
|
await Promise.all(entries.map(async (entry) => {
|
||||||
|
const path = join(directory, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
await visit(path);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!entry.isFile() || !entry.name.endsWith(".jsonl")) return;
|
||||||
|
const sessionId = [...wanted].find((id) => entry.name.includes(id));
|
||||||
|
if (!sessionId) return;
|
||||||
|
const modifiedAt = (await stat(path)).mtimeMs;
|
||||||
|
if (!found.has(sessionId) || found.get(sessionId).modifiedAt < modifiedAt) {
|
||||||
|
found.set(sessionId, { modifiedAt, path });
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
await visit(root);
|
||||||
|
return new Map([...found].map(([sessionId, value]) => [sessionId, value.path]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function nativeContinuationArguments(tool, input) {
|
||||||
|
const schema = tool?.inputSchema;
|
||||||
|
const properties = schema?.properties ?? {};
|
||||||
|
const args = {};
|
||||||
|
if (properties.threadId) args.threadId = input.sessionId;
|
||||||
|
if (properties.thread_id) args.thread_id = input.sessionId;
|
||||||
|
if (properties.turnId && input.failedTurnId) args.turnId = input.failedTurnId;
|
||||||
|
if (properties.turn_id && input.failedTurnId) args.turn_id = input.failedTurnId;
|
||||||
|
const required = Array.isArray(schema?.required) ? schema.required : [];
|
||||||
|
return required.every((name) => Object.hasOwn(args, name)) ? args : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendDesktopContinuation(input, dependencies = {}) {
|
||||||
|
const listCandidates = dependencies.pipeCandidates ?? pipeCandidates;
|
||||||
|
const request = dependencies.pipeRequest ?? pipeRequest;
|
||||||
|
const candidates = await listCandidates();
|
||||||
|
let lastError = new Error("No Codex desktop task pipe was found");
|
||||||
|
|
||||||
|
for (const pipePath of candidates) {
|
||||||
|
try {
|
||||||
|
const catalog = await request(pipePath, "tools/list", { threadStartKind: "all" });
|
||||||
|
const tools = Array.isArray(catalog?.tools) ? catalog.tools : [];
|
||||||
|
const listed = await request(pipePath, "tools/call", toolCallParams(input, "list_threads", { limit: THREAD_LIST_LIMIT }));
|
||||||
|
if (!listed?.success || !threadListContains(listed, input.sessionId)) continue;
|
||||||
|
|
||||||
|
for (const toolName of NATIVE_CONTINUATION_TOOL_NAMES) {
|
||||||
|
const tool = tools.find((candidate) => candidate?.name === toolName);
|
||||||
|
const args = nativeContinuationArguments(tool, input);
|
||||||
|
if (!tool || args == null) continue;
|
||||||
|
try {
|
||||||
|
const resumed = await request(pipePath, "tools/call", toolCallParams(input, tool.name, args));
|
||||||
|
if (resumed?.success) {
|
||||||
|
return { delivery: "native", pipePath, result: parseToolText(resumed) };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fall back to the normal Codex message path below.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sent = await request(pipePath, "tools/call", toolCallParams(input, "send_message_to_thread", {
|
||||||
|
prompt: CONTINUATION_PROMPT,
|
||||||
|
threadId: input.sessionId,
|
||||||
|
}));
|
||||||
|
if (!sent?.success) throw new Error("Codex desktop rejected the continuation message");
|
||||||
|
return { delivery: "message", pipePath, result: parseToolText(sent) };
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastError;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function shouldResumeAfterDelay(failedRecord, transcriptPath, delay, dependencies = {}) {
|
||||||
|
const sleep = dependencies.pause ?? pause;
|
||||||
|
const readTail = dependencies.readTranscriptTail ?? readTranscriptTail;
|
||||||
|
await sleep(delay);
|
||||||
|
const currentRecords = await readTail(transcriptPath);
|
||||||
|
return !hasTaskStartedAfter(currentRecords, failedRecord);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function watch(input) {
|
||||||
|
const paths = sessionPaths(input.sessionId);
|
||||||
|
if (!(await acquireLock(paths.lock))) return;
|
||||||
|
await unlink(paths.stop).catch(() => {});
|
||||||
|
|
||||||
|
const log = async (event, details = {}) => {
|
||||||
|
await appendFile(paths.log, `${JSON.stringify({
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
event,
|
||||||
|
sessionId: input.sessionId,
|
||||||
|
...details,
|
||||||
|
})}\n`, "utf8").catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
|
let attempts = 0;
|
||||||
|
let lastResumeAt = 0;
|
||||||
|
let lastActivityAt = Date.now();
|
||||||
|
const cursor = { offset: 0, carry: "" };
|
||||||
|
try {
|
||||||
|
cursor.offset = (await stat(input.transcriptPath)).size;
|
||||||
|
await log("watcher_started", { transcriptPath: input.transcriptPath });
|
||||||
|
while (Date.now() - lastActivityAt < WATCH_IDLE_MS) {
|
||||||
|
if (await stat(paths.stop).then(() => true).catch(() => false)) break;
|
||||||
|
const records = await readNewRecords(input.transcriptPath, cursor).catch(() => []);
|
||||||
|
if (records.length === 0) {
|
||||||
|
await pause(POLL_MS);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
lastActivityAt = Date.now();
|
||||||
|
|
||||||
|
for (const record of records) {
|
||||||
|
if (isSuccessfulCompletion(record)) {
|
||||||
|
attempts = 0;
|
||||||
|
lastResumeAt = 0;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!is429Record(record)) continue;
|
||||||
|
await log("429_detected", { turnId: record.payload?.turn_id });
|
||||||
|
if (lastResumeAt && Date.now() - lastResumeAt >= RETRY_RESET_MS) attempts = 0;
|
||||||
|
if (attempts >= RETRY_DELAYS_MS.length) continue;
|
||||||
|
|
||||||
|
for (let deliveryAttempt = attempts; deliveryAttempt < RETRY_DELAYS_MS.length; deliveryAttempt += 1) {
|
||||||
|
const delay = RETRY_DELAYS_MS[deliveryAttempt];
|
||||||
|
await log("waiting", { delayMs: delay, turnId: record.payload?.turn_id });
|
||||||
|
if (!(await shouldResumeAfterDelay(record, input.transcriptPath, delay))) {
|
||||||
|
await log("cancelled_new_task_started", { turnId: record.payload?.turn_id });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const sent = await sendDesktopContinuation({
|
||||||
|
...input,
|
||||||
|
failedTurnId: record.payload?.turn_id,
|
||||||
|
});
|
||||||
|
attempts += 1;
|
||||||
|
lastResumeAt = Date.now();
|
||||||
|
await log("desktop_continue_sent", {
|
||||||
|
delivery: sent.delivery,
|
||||||
|
pipePath: sent.pipePath,
|
||||||
|
turnId: record.payload?.turn_id,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
} catch (error) {
|
||||||
|
await log("desktop_continue_failed", {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
turnId: record.payload?.turn_id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await log("watcher_stopped");
|
||||||
|
await unlink(paths.lock).catch(() => {});
|
||||||
|
await unlink(paths.stop).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function signalStop(input) {
|
||||||
|
if (!input.session_id) return;
|
||||||
|
const path = sessionPaths(input.session_id).stop;
|
||||||
|
await mkdir(dirname(path), { recursive: true });
|
||||||
|
const temporary = `${path}.${process.pid}.tmp`;
|
||||||
|
await writeFile(temporary, "stop\n", "utf8");
|
||||||
|
await rename(temporary, path);
|
||||||
|
}
|
||||||
|
|
||||||
|
function launchWatcher(input) {
|
||||||
|
const environment = {
|
||||||
|
...process.env,
|
||||||
|
CODEX_429_SESSION_ID: input.session_id,
|
||||||
|
CODEX_429_TRANSCRIPT_PATH: input.transcript_path,
|
||||||
|
CODEX_429_CWD: input.cwd || "",
|
||||||
|
CODEX_429_MODEL: input.model || "",
|
||||||
|
};
|
||||||
|
const child = spawn(process.execPath, [fileURLToPath(import.meta.url), "watch"], {
|
||||||
|
detached: true,
|
||||||
|
env: environment,
|
||||||
|
stdio: "ignore",
|
||||||
|
windowsHide: true,
|
||||||
|
});
|
||||||
|
child.unref();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function launchLoadedSessionWatchers(input) {
|
||||||
|
const sessionIds = new Set([input.session_id]);
|
||||||
|
const loaded = await loadedDesktopSessionIds({ sessionId: input.session_id });
|
||||||
|
for (const sessionId of loaded) sessionIds.add(sessionId);
|
||||||
|
const transcripts = await findSessionTranscripts(sessionIds);
|
||||||
|
if (input.transcript_path) transcripts.set(input.session_id, input.transcript_path);
|
||||||
|
|
||||||
|
for (const [sessionId, transcriptPath] of transcripts) {
|
||||||
|
launchWatcher({
|
||||||
|
session_id: sessionId,
|
||||||
|
transcript_path: transcriptPath,
|
||||||
|
cwd: sessionId === input.session_id ? input.cwd : "",
|
||||||
|
model: sessionId === input.session_id ? input.model : "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const mode = process.argv[2] || "start";
|
||||||
|
if (mode === "watch") {
|
||||||
|
await watch({
|
||||||
|
sessionId: process.env.CODEX_429_SESSION_ID,
|
||||||
|
transcriptPath: process.env.CODEX_429_TRANSCRIPT_PATH,
|
||||||
|
cwd: process.env.CODEX_429_CWD,
|
||||||
|
model: process.env.CODEX_429_MODEL,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const input = await readHookInput();
|
||||||
|
if (process.env.CODEX_429_AUTO_CONTINUE_CHILD === "1") return;
|
||||||
|
if (mode === "stop") {
|
||||||
|
await signalStop(input);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ((input.hook_event_name === "SessionStart" || input.hook_event_name === "UserPromptSubmit")
|
||||||
|
&& input.session_id
|
||||||
|
&& input.transcript_path) {
|
||||||
|
await launchLoadedSessionWatchers(input);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
||||||
|
await main();
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"description": "Watch a Codex task and resume it after a structured HTTP 429 failure.",
|
||||||
|
"hooks": {
|
||||||
|
"SessionStart": [
|
||||||
|
{
|
||||||
|
"matcher": "startup|resume",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "node \"${PLUGIN_ROOT}/hooks/codex-429-auto-continue.mjs\"",
|
||||||
|
"timeout": 5,
|
||||||
|
"statusMessage": "Starting the Codex 429 watcher"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"UserPromptSubmit": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "node \"${PLUGIN_ROOT}/hooks/codex-429-auto-continue.mjs\"",
|
||||||
|
"timeout": 5
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"SessionEnd": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "node \"${PLUGIN_ROOT}/hooks/codex-429-auto-continue.mjs\" stop",
|
||||||
|
"timeout": 3
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import {
|
||||||
|
hasTaskStartedAfter,
|
||||||
|
is429Record,
|
||||||
|
isSuccessfulCompletion,
|
||||||
|
loadedThreadIds,
|
||||||
|
RETRY_DELAYS_MS,
|
||||||
|
sendDesktopContinuation,
|
||||||
|
shouldResumeAfterDelay,
|
||||||
|
threadListContains,
|
||||||
|
} from "../hooks/codex-429-auto-continue.mjs";
|
||||||
|
|
||||||
|
const failed = {
|
||||||
|
ordinal: 10,
|
||||||
|
type: "event_msg",
|
||||||
|
payload: {
|
||||||
|
type: "task_complete",
|
||||||
|
turn_id: "turn-1",
|
||||||
|
error: {
|
||||||
|
message: "exceeded retry limit, last status: 429 Too Many Requests",
|
||||||
|
codex_error_info: {
|
||||||
|
response_too_many_failed_attempts: { http_status_code: 429 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
test("recognizes only a structured 429 task failure", () => {
|
||||||
|
assert.equal(is429Record(failed), true);
|
||||||
|
assert.equal(is429Record({ type: "message", message: failed.payload.error.message }), false);
|
||||||
|
assert.equal(is429Record({ type: "event_msg", payload: { type: "task_complete" } }), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("recognizes a successful task completion", () => {
|
||||||
|
assert.equal(isSuccessfulCompletion({
|
||||||
|
type: "event_msg",
|
||||||
|
payload: { type: "task_complete", last_agent_message: "done" },
|
||||||
|
}), true);
|
||||||
|
assert.equal(isSuccessfulCompletion(failed), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("detects Codex or user retry activity after a failure", () => {
|
||||||
|
assert.equal(hasTaskStartedAfter([{
|
||||||
|
ordinal: 11,
|
||||||
|
type: "event_msg",
|
||||||
|
payload: { type: "task_started", turn_id: "turn-2" },
|
||||||
|
}], failed), true);
|
||||||
|
assert.equal(hasTaskStartedAfter([{
|
||||||
|
ordinal: 9,
|
||||||
|
type: "event_msg",
|
||||||
|
payload: { type: "task_started", turn_id: "turn-0" },
|
||||||
|
}], failed), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("starts the retry chain after three seconds", async () => {
|
||||||
|
assert.deepEqual(RETRY_DELAYS_MS, [3_000, 3_000, 3_000, 3_000]);
|
||||||
|
let waited;
|
||||||
|
const shouldResume = await shouldResumeAfterDelay(failed, "transcript.jsonl", RETRY_DELAYS_MS[0], {
|
||||||
|
pause: async (delay) => { waited = delay; },
|
||||||
|
readTranscriptTail: async () => [failed],
|
||||||
|
});
|
||||||
|
assert.equal(waited, 3_000);
|
||||||
|
assert.equal(shouldResume, true);
|
||||||
|
|
||||||
|
const builtInRetryWon = await shouldResumeAfterDelay(failed, "transcript.jsonl", 3_000, {
|
||||||
|
pause: async () => {},
|
||||||
|
readTranscriptTail: async () => [failed, {
|
||||||
|
ordinal: 11,
|
||||||
|
type: "event_msg",
|
||||||
|
payload: { type: "task_started", turn_id: "turn-2" },
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
assert.equal(builtInRetryWon, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("finds a target task in a Codex desktop tool result", () => {
|
||||||
|
const result = {
|
||||||
|
contentItems: [{
|
||||||
|
type: "inputText",
|
||||||
|
text: JSON.stringify({ pinnedThreads: [], threads: [{ id: "session-1" }] }),
|
||||||
|
}],
|
||||||
|
success: true,
|
||||||
|
};
|
||||||
|
assert.equal(threadListContains(result, "session-1"), true);
|
||||||
|
assert.equal(threadListContains(result, "session-2"), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("selects existing loaded tasks for watcher backfill", () => {
|
||||||
|
const result = {
|
||||||
|
contentItems: [{
|
||||||
|
type: "inputText",
|
||||||
|
text: JSON.stringify({
|
||||||
|
pinnedThreads: [{ id: "active-1", status: "active" }],
|
||||||
|
threads: [
|
||||||
|
{ id: "idle-1", status: "idle" },
|
||||||
|
{ id: "failed-1", status: "systemError" },
|
||||||
|
{ id: "closed-1", status: "notLoaded" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
}],
|
||||||
|
success: true,
|
||||||
|
};
|
||||||
|
assert.deepEqual(loadedThreadIds(result), ["active-1", "idle-1", "failed-1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sends continuation through the desktop pipe that owns the task", async () => {
|
||||||
|
const calls = [];
|
||||||
|
const result = await sendDesktopContinuation({ sessionId: "session-1", failedTurnId: "turn-1" }, {
|
||||||
|
pipeCandidates: async () => ["wrong-pipe", "right-pipe"],
|
||||||
|
pipeRequest: async (pipePath, method, params) => {
|
||||||
|
calls.push({ pipePath, method, params });
|
||||||
|
if (method === "tools/list") {
|
||||||
|
if (pipePath === "wrong-pipe") throw new Error("wrong pipe");
|
||||||
|
return { tools: [{ name: "list_threads" }, { name: "send_message_to_thread" }] };
|
||||||
|
}
|
||||||
|
if (params.tool === "list_threads") {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
contentItems: [{
|
||||||
|
type: "inputText",
|
||||||
|
text: JSON.stringify({
|
||||||
|
pinnedThreads: [],
|
||||||
|
threads: pipePath === "right-pipe" ? [{ id: "session-1" }] : [],
|
||||||
|
}),
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { success: true, contentItems: [{ type: "inputText", text: "{}" }] };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.pipePath, "right-pipe");
|
||||||
|
const listed = calls.find((call) => call.params.tool === "list_threads");
|
||||||
|
assert.equal(listed.params.arguments.limit, 50);
|
||||||
|
const sent = calls.find((call) => call.params.tool === "send_message_to_thread");
|
||||||
|
assert.equal(sent.pipePath, "right-pipe");
|
||||||
|
assert.equal(sent.params.arguments.threadId, "session-1");
|
||||||
|
assert.equal(sent.params.arguments.prompt, "继续");
|
||||||
|
assert.equal(result.delivery, "message");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("prefers a native continuation tool when Codex exposes one", async () => {
|
||||||
|
const calls = [];
|
||||||
|
const result = await sendDesktopContinuation({ sessionId: "session-1", failedTurnId: "turn-1" }, {
|
||||||
|
pipeCandidates: async () => ["app-pipe"],
|
||||||
|
pipeRequest: async (pipePath, method, params) => {
|
||||||
|
calls.push({ pipePath, method, params });
|
||||||
|
if (method === "tools/list") {
|
||||||
|
return {
|
||||||
|
tools: [
|
||||||
|
{ name: "list_threads" },
|
||||||
|
{
|
||||||
|
name: "retry_turn",
|
||||||
|
inputSchema: {
|
||||||
|
properties: { threadId: {}, turnId: {} },
|
||||||
|
required: ["threadId", "turnId"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ name: "send_message_to_thread" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (params.tool === "list_threads") {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
contentItems: [{
|
||||||
|
type: "inputText",
|
||||||
|
text: JSON.stringify({ pinnedThreads: [], threads: [{ id: "session-1" }] }),
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { success: true, contentItems: [{ type: "inputText", text: "{}" }] };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.delivery, "native");
|
||||||
|
const nativeCall = calls.find((call) => call.params?.tool === "retry_turn");
|
||||||
|
assert.deepEqual(nativeCall.params.arguments, { threadId: "session-1", turnId: "turn-1" });
|
||||||
|
assert.equal(calls.some((call) => call.params?.tool === "send_message_to_thread"), false);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user