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
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
- **工具权限审批** — WeChat 弹权限卡片,支持 `once` / `always` / `reject` 三选一;`/auto-permission` 可切换自动接收模式;30 分钟软超时自动 reject
- **静默模式** — 启用 `/silent`(别名 `/sl`)后,一轮 turn 中只发送最终文本回复;推理、工具摘要、增量文本在 turn 结束前隐藏。Question / Permission 请求不受影响;设置独立且跨重启持久化
- **跨会话通知** — 其他 session 的 question/permission/error/completion 事件推送到微信;切换到有 pending 的会话自动弹出卡片
- **卡住 / 异常自动提示** — Agent 5 分钟无响应(卡住)自动结束本轮并微信提示;SSE 事件连接中断超过 1 分钟、微信消息通道连续失败或登录过期、以及恢复时均自动微信通知,无需打开终端观察
- **二维码登录** — 终端渲染二维码,扫码登录微信
- **OpenCode Server** — 基于 HTTP API,不再需要 ACP 子进程

Expand Down Expand Up @@ -217,9 +218,13 @@ export WECHAT_OPENCODE_STARTUP_TIMEOUT_MS=600000 # 10 分钟(极慢网络)

### 消息计数(`/next`)

微信限制 bot 连续发送 10 条消息,超出后剩余消息会缓存,不再继续发送。

| 命令 | 说明 |
|------|------|
| `/next` | 微信限制连续发送 10 条消息,超出后需用户回复才能继续。发送 `/next` 重置计数,不转发给 Agent |
| `/next` | 立即发送缓存的剩余消息并重置计数,不转发给 Agent |

**自动续发(无需手动 `/next`)**:默认开启。超出 10 条限制后,缓存的剩余消息会在约 60 秒后自动发送,无需用户任何操作;若一次缓存超过 10 条,会自动分轮续发直到发完。通过配置 `wechat.autoFlushMs`(毫秒)调整间隔,设为 `0` 关闭(恢复纯手动 `/next` 行为)。

### LLM 问答(`/reject-question`)

Expand Down
166 changes: 166 additions & 0 deletions src/__tests__/test-auto-flush.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
/**
* Tests for the auto-flush ("auto /next") feature.
*
* When the bridge hits WeChat's 10-consecutive-message send limit, the
* remaining outbound segments are cached in `pendingOutbound`. Without
* auto-flush they are only delivered on the next user message or an
* explicit `/next`. With `wechat.autoFlushMs` > 0, the bridge arms a
* timer on the first cache hit and flushes the cached messages
* automatically once the delay elapses; if the limit still holds
* (more than 10 segments cached), the timer re-arms for another round.
*
* The methods under test are private — TS `private` is compile-time
* only, so the compiled dist/ methods are reachable at runtime. We
* drive the real instance with fake timers and stub the outbound send
* path (`flushPending` / `sendReplyImpl`) so no network is touched.
*
* Run via `npm test` (requires `npm run build` first to produce dist/).
*/
import { describe, test, expect, vi } from "vitest";
import { WeChatOpencodeBridge } from "../../dist/src/bridge.js";
import { defaultConfig } from "../../dist/src/config.js";

function makeBridge(overrides = {}) {
const config = defaultConfig();
Object.assign(config.wechat, overrides);
const bridge = new WeChatOpencodeBridge(config, () => {});
return bridge;
}

describe("auto-flush (auto /next)", () => {
test("does not arm a timer when autoFlushMs is 0 (disabled)", async () => {
vi.useFakeTimers();
try {
const bridge = makeBridge({ autoFlushMs: 0 });
bridge.pendingOutbound = [{ kind: "text", text: "cached", contextToken: "ctx-1" }];

const flushSpy = vi.spyOn(bridge, "flushPending").mockResolvedValue(undefined);
bridge.scheduleAutoFlush("ctx-1");
await vi.advanceTimersByTimeAsync(60_000);

expect(flushSpy).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
}
});

test("does not arm a timer when nothing is cached", async () => {
vi.useFakeTimers();
try {
const bridge = makeBridge({ autoFlushMs: 60_000 });
const flushSpy = vi.spyOn(bridge, "flushPending").mockResolvedValue(undefined);

bridge.scheduleAutoFlush("ctx-1");
await vi.advanceTimersByTimeAsync(60_000);

expect(flushSpy).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
}
});

test("flushes cached messages once the delay elapses", async () => {
vi.useFakeTimers();
try {
const bridge = makeBridge({ autoFlushMs: 60_000 });
bridge.pendingOutbound = [{ kind: "text", text: "cached", contextToken: "ctx-1" }];
const flushSpy = vi.spyOn(bridge, "flushPending").mockResolvedValue(undefined);

bridge.scheduleAutoFlush("ctx-1");
expect(bridge.autoFlushTimer).not.toBeNull();

await vi.advanceTimersByTimeAsync(59_000);
expect(flushSpy).not.toHaveBeenCalled();

await vi.advanceTimersByTimeAsync(2_000);
expect(flushSpy).toHaveBeenCalledTimes(1);
expect(flushSpy).toHaveBeenCalledWith("ctx-1");
} finally {
vi.useRealTimers();
}
});

test("re-arms for another round when messages remain cached after a flush", async () => {
vi.useFakeTimers();
try {
const bridge = makeBridge({ autoFlushMs: 60_000 });
bridge.pendingOutbound = [{ kind: "text", text: "cached", contextToken: "ctx-1" }];
vi.spyOn(bridge, "flushPending").mockImplementation(async () => {
// Simulate the 10-msg limit still holding: messages stay cached.
bridge.pendingOutbound = [{ kind: "text", text: "still-cached", contextToken: "ctx-1" }];
});

bridge.scheduleAutoFlush("ctx-1");
await vi.advanceTimersByTimeAsync(60_000);
expect(bridge.autoFlushTimer).not.toBeNull();

await vi.advanceTimersByTimeAsync(60_000);
expect(bridge.pendingOutbound).toHaveLength(1);
} finally {
vi.useRealTimers();
}
});

test("does not flush while an in-flight outbound send is still running", async () => {
vi.useFakeTimers();
try {
const bridge = makeBridge({ autoFlushMs: 60_000 });
bridge.pendingOutbound = [{ kind: "text", text: "cached", contextToken: "ctx-1" }];
const flushSpy = vi.spyOn(bridge, "flushPending").mockResolvedValue(undefined);

// Simulate an agent reply in flight on the same contextToken.
let releaseInflight = () => {};
const inflight = new Promise((resolve) => {
releaseInflight = () => resolve(undefined);
});
bridge.outboundQueue.set("ctx-1", inflight);

bridge.scheduleAutoFlush("ctx-1");
await vi.advanceTimersByTimeAsync(60_000);
expect(flushSpy).not.toHaveBeenCalled();

releaseInflight();
await vi.advanceTimersByTimeAsync(0);
expect(flushSpy).toHaveBeenCalledTimes(1);
} finally {
vi.useRealTimers();
}
});

test("cache hits after the first timer is pending do not double-arm", async () => {
vi.useFakeTimers();
try {
const bridge = makeBridge({ autoFlushMs: 60_000 });
bridge.pendingOutbound = [{ kind: "text", text: "cached", contextToken: "ctx-1" }];
const flushSpy = vi.spyOn(bridge, "flushPending").mockResolvedValue(undefined);

bridge.scheduleAutoFlush("ctx-1");
const timer = bridge.autoFlushTimer;
bridge.scheduleAutoFlush("ctx-1"); // second cache hit
expect(bridge.autoFlushTimer).toBe(timer);

await vi.advanceTimersByTimeAsync(60_000);
expect(flushSpy).toHaveBeenCalledTimes(1);
} finally {
vi.useRealTimers();
}
});

test("stop() clears a pending auto-flush timer", async () => {
vi.useFakeTimers();
try {
const bridge = makeBridge({ autoFlushMs: 60_000 });
bridge.pendingOutbound = [{ kind: "text", text: "cached", contextToken: "ctx-1" }];
const flushSpy = vi.spyOn(bridge, "flushPending").mockResolvedValue(undefined);

bridge.scheduleAutoFlush("ctx-1");
expect(bridge.autoFlushTimer).not.toBeNull();

await bridge.stop();
await vi.advanceTimersByTimeAsync(60_000);
expect(flushSpy).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
}
});
});
Loading