mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
fix(channels): Make same-chat delivery session-aware (#10145)
Co-authored-by: Qwen-Coder <[email protected]>
This commit is contained in:
1 parent
de7e9b56bb
commit
5e9c24cf56
13 files changed
+810
-119
No files matched your search
@@ -0,0 +1,78 @@
|
||||
# Channel Session-Aware Delivery
|
||||
|
||||
## Problem
|
||||
|
||||
Channel sessions are already independent, but a few adapters keep delivery-only
|
||||
state at chat or process scope. When two sessions run in the same chat, a later
|
||||
message can replace the reply target of an earlier QQ response, one completed
|
||||
Weixin session can clear another session's typing indicator, and the plugin
|
||||
example can associate overlapping responses with the wrong inbound message.
|
||||
|
||||
## Scope
|
||||
|
||||
This change makes existing same-chat delivery state session-aware. It does not
|
||||
add named sessions, task selection, session persistence, result labels, or
|
||||
worktree creation. It does not change `ChannelBase` or the public channel
|
||||
adapter interface.
|
||||
|
||||
`ChannelOutputSegmentContext` already carries the originating session, run,
|
||||
target, and message ID. `ChannelBase` also retains the active prompt's message
|
||||
ID until response delivery completes. Adapters can therefore preserve the
|
||||
origin without introducing another shared delivery abstraction.
|
||||
|
||||
## QQ
|
||||
|
||||
QQ passive replies require the original inbound `msg_id`, and streaming blocks
|
||||
for that message share a `msg_seq` counter. The adapter keeps a bounded
|
||||
in-memory context for every recently accepted message while retaining the
|
||||
persisted per-chat latest message as a compatibility pointer.
|
||||
|
||||
Prompt output uses the message ID from the active prompt or output segment.
|
||||
Streaming state copies that reply context so delayed flushes and retries do not
|
||||
depend on mutable chat state. Replies produced while handling an inbound
|
||||
command use async-local inbound context. Background and cron delivery is
|
||||
explicitly active and never borrows the latest inbound message.
|
||||
|
||||
An expired or missing explicit context falls back to active delivery, never to
|
||||
a different message in the same chat. Sequence counters remain isolated by
|
||||
message ID and are reclaimed with the message context's existing five-minute
|
||||
TTL. The persisted schema is unchanged; restore keeps sequence counters only
|
||||
for restored valid reply contexts.
|
||||
|
||||
## Weixin
|
||||
|
||||
Weixin typing ownership is tracked as `chatId -> sessionId -> startedAt`.
|
||||
Starting the first session enables typing and starts one chat-level keepalive.
|
||||
Additional sessions only add owners. A terminal event removes its own owner,
|
||||
and only the last owner disables typing.
|
||||
|
||||
The existing generation guard continues to reject stale asynchronous typing
|
||||
results. The keepalive backstop expires individual sessions rather than the
|
||||
whole chat, so an old wedged session cannot clear a newer session's indicator.
|
||||
Session death removes only that session; disconnect clears all state.
|
||||
|
||||
## Plugin example
|
||||
|
||||
The example replaces its process-global pending message ID with async-local
|
||||
inbound context for command replies and uses output-segment or active-prompt
|
||||
message IDs for agent output. This demonstrates the same correlation contract
|
||||
to third-party adapter authors without changing the protocol.
|
||||
|
||||
## Failure semantics
|
||||
|
||||
- A missing or expired QQ passive context uses active delivery and remains
|
||||
subject to the existing active-message policy.
|
||||
- A failed QQ passive attempt rolls back only its original message's sequence
|
||||
before the existing active fallback.
|
||||
- A failed Weixin typing request does not discard live owners; the bounded
|
||||
keepalive retries while at least one owner remains.
|
||||
- Cleanup on session death, group removal, and disconnect cannot remove state
|
||||
owned by another live session.
|
||||
|
||||
## Verification
|
||||
|
||||
Focused tests cover two sessions in one chat, delayed streaming flushes,
|
||||
independent QQ sequence rollback, expired reply contexts, background delivery,
|
||||
Weixin first-owner/last-owner transitions, stale async typing results,
|
||||
per-session backstop expiry, and overlapping plugin messages. Package tests are
|
||||
followed by the repository build, typecheck, and lint checks.
|
||||
@@ -21,6 +21,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc --build",
|
||||
"test": "vitest run",
|
||||
"prepublishOnly": "npm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { Envelope } from '@qwen-code/channel-base';
|
||||
|
||||
const baseHandleInbound = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@qwen-code/channel-base', () => ({
|
||||
ChannelBase: class {
|
||||
protected name: string;
|
||||
|
||||
constructor(name: string) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
protected handleInbound(envelope: Envelope): Promise<void> {
|
||||
return baseHandleInbound.call(this, envelope) as Promise<void>;
|
||||
}
|
||||
|
||||
protected getResponseMessageId(): string | undefined {
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('ws', () => ({
|
||||
default: class MockWebSocket {
|
||||
static OPEN = 1;
|
||||
},
|
||||
}));
|
||||
|
||||
import { MockPluginChannel } from './MockPluginChannel.js';
|
||||
|
||||
function deferredPromise() {
|
||||
let resolve!: () => void;
|
||||
const promise = new Promise<void>((res) => {
|
||||
resolve = res;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function envelope(messageId: string): Envelope {
|
||||
return {
|
||||
channelName: 'mock',
|
||||
senderId: `sender-${messageId}`,
|
||||
senderName: messageId,
|
||||
chatId: 'shared-chat',
|
||||
text: messageId,
|
||||
messageId,
|
||||
isGroup: false,
|
||||
isMentioned: false,
|
||||
isReplyToBot: false,
|
||||
};
|
||||
}
|
||||
|
||||
function createChannel() {
|
||||
const channel = new MockPluginChannel(
|
||||
'mock',
|
||||
{
|
||||
type: 'mock',
|
||||
token: 'test-token',
|
||||
serverWsUrl: 'ws://example.test',
|
||||
senderPolicy: 'open',
|
||||
allowedUsers: [],
|
||||
sessionScope: 'user',
|
||||
cwd: process.cwd(),
|
||||
groupPolicy: 'disabled',
|
||||
dmPolicy: 'open',
|
||||
groups: {},
|
||||
},
|
||||
new EventEmitter() as never,
|
||||
);
|
||||
const send = vi.fn();
|
||||
(channel as unknown as { ws: { readyState: number; send: typeof send } }).ws =
|
||||
{ readyState: 1, send };
|
||||
return { channel, send };
|
||||
}
|
||||
|
||||
describe('MockPluginChannel message correlation', () => {
|
||||
beforeEach(() => {
|
||||
baseHandleInbound.mockReset();
|
||||
});
|
||||
|
||||
it('keeps overlapping inbound command replies bound to their own messages', async () => {
|
||||
const { channel, send } = createChannel();
|
||||
const first = deferredPromise();
|
||||
const second = deferredPromise();
|
||||
baseHandleInbound.mockImplementation(async function (
|
||||
this: MockPluginChannel,
|
||||
inbound: Envelope,
|
||||
) {
|
||||
await (inbound.messageId === 'msg-a' ? first.promise : second.promise);
|
||||
await this.sendMessage(inbound.chatId, `reply-${inbound.messageId}`);
|
||||
});
|
||||
|
||||
const pendingA = channel.handleInbound(envelope('msg-a'));
|
||||
const pendingB = channel.handleInbound(envelope('msg-b'));
|
||||
second.resolve();
|
||||
await pendingB;
|
||||
first.resolve();
|
||||
await pendingA;
|
||||
|
||||
expect(send.mock.calls.map(([frame]) => JSON.parse(String(frame)))).toEqual(
|
||||
[
|
||||
{
|
||||
type: 'outbound',
|
||||
messageId: 'msg-b',
|
||||
chatId: 'shared-chat',
|
||||
text: 'reply-msg-b',
|
||||
},
|
||||
{
|
||||
type: 'outbound',
|
||||
messageId: 'msg-a',
|
||||
chatId: 'shared-chat',
|
||||
text: 'reply-msg-a',
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
it('uses output segment message IDs for chunks and final responses', async () => {
|
||||
const { channel, send } = createChannel();
|
||||
const output = channel as unknown as {
|
||||
onResponseChunk: (
|
||||
chatId: string,
|
||||
chunk: string,
|
||||
sessionId: string,
|
||||
segment: { messageId: string },
|
||||
) => void;
|
||||
onResponseComplete: (
|
||||
chatId: string,
|
||||
text: string,
|
||||
sessionId: string,
|
||||
segment: { messageId: string },
|
||||
) => Promise<void>;
|
||||
};
|
||||
|
||||
output.onResponseChunk('shared-chat', 'chunk-a', 'session-a', {
|
||||
messageId: 'msg-a',
|
||||
});
|
||||
await output.onResponseComplete('shared-chat', 'final-b', 'session-b', {
|
||||
messageId: 'msg-b',
|
||||
});
|
||||
|
||||
expect(send.mock.calls.map(([frame]) => JSON.parse(String(frame)))).toEqual(
|
||||
[
|
||||
{
|
||||
type: 'chunk',
|
||||
messageId: 'msg-a',
|
||||
chatId: 'shared-chat',
|
||||
text: 'chunk-a',
|
||||
},
|
||||
{
|
||||
type: 'outbound',
|
||||
messageId: 'msg-b',
|
||||
chatId: 'shared-chat',
|
||||
text: 'final-b',
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -4,8 +4,10 @@ import type {
|
||||
ChannelBaseOptions,
|
||||
Envelope,
|
||||
ChannelAgentBridge,
|
||||
ChannelOutputSegmentContext,
|
||||
} from '@qwen-code/channel-base';
|
||||
import WebSocket from 'ws';
|
||||
import { AsyncLocalStorage } from 'node:async_hooks';
|
||||
import type {
|
||||
InboundMessage,
|
||||
OutboundMessage,
|
||||
@@ -19,7 +21,7 @@ export interface MockPluginConfig extends ChannelConfig {
|
||||
export class MockPluginChannel extends ChannelBase {
|
||||
private ws: WebSocket | null = null;
|
||||
private serverWsUrl: string;
|
||||
private pendingMessageId: string | undefined;
|
||||
private inboundMessage = new AsyncLocalStorage<{ messageId?: string }>();
|
||||
|
||||
constructor(
|
||||
name: string,
|
||||
@@ -83,13 +85,15 @@ export class MockPluginChannel extends ChannelBase {
|
||||
protected override onResponseChunk(
|
||||
chatId: string,
|
||||
chunk: string,
|
||||
_sessionId: string,
|
||||
sessionId: string,
|
||||
segment?: ChannelOutputSegmentContext,
|
||||
): void {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
|
||||
|
||||
const msg: ChunkMessage = {
|
||||
type: 'chunk',
|
||||
messageId: this.pendingMessageId || 'unknown',
|
||||
messageId:
|
||||
segment?.messageId ?? this.getResponseMessageId(sessionId) ?? 'unknown',
|
||||
chatId,
|
||||
text: chunk,
|
||||
};
|
||||
@@ -99,19 +103,28 @@ export class MockPluginChannel extends ChannelBase {
|
||||
protected override async onResponseComplete(
|
||||
chatId: string,
|
||||
fullText: string,
|
||||
_sessionId: string,
|
||||
sessionId: string,
|
||||
segment?: ChannelOutputSegmentContext,
|
||||
): Promise<void> {
|
||||
await this.sendMessage(chatId, fullText);
|
||||
this.sendOutbound(
|
||||
chatId,
|
||||
fullText,
|
||||
segment?.messageId ?? this.getResponseMessageId(sessionId),
|
||||
);
|
||||
}
|
||||
|
||||
async sendMessage(chatId: string, text: string): Promise<void> {
|
||||
this.sendOutbound(chatId, text, this.inboundMessage.getStore()?.messageId);
|
||||
}
|
||||
|
||||
private sendOutbound(chatId: string, text: string, messageId?: string): void {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
|
||||
const outbound: OutboundMessage = {
|
||||
type: 'outbound',
|
||||
messageId: this.pendingMessageId || 'unknown',
|
||||
messageId: messageId ?? 'unknown',
|
||||
chatId,
|
||||
text,
|
||||
};
|
||||
@@ -127,11 +140,8 @@ export class MockPluginChannel extends ChannelBase {
|
||||
}
|
||||
|
||||
override async handleInbound(envelope: Envelope): Promise<void> {
|
||||
this.pendingMessageId = envelope.messageId;
|
||||
try {
|
||||
await super.handleInbound(envelope);
|
||||
} finally {
|
||||
this.pendingMessageId = undefined;
|
||||
}
|
||||
await this.inboundMessage.run({ messageId: envelope.messageId }, async () =>
|
||||
super.handleInbound(envelope),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,6 @@
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist"],
|
||||
"exclude": ["node_modules", "dist", "src/**/*.test.ts"],
|
||||
"references": [{ "path": "../base" }]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import path from 'node:path';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['src/**/*.test.ts'],
|
||||
globals: true,
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@qwen-code/channel-base': path.resolve(
|
||||
__dirname,
|
||||
'../base/src/index.ts',
|
||||
),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -25,9 +25,12 @@ import type {
|
||||
ChannelConfig,
|
||||
ChannelBaseOptions,
|
||||
ChannelAgentBridge,
|
||||
ChannelOutputSegmentContext,
|
||||
Envelope,
|
||||
ToolCallEvent,
|
||||
} from '@qwen-code/channel-base';
|
||||
import WebSocket from 'ws';
|
||||
import { AsyncLocalStorage } from 'node:async_hooks';
|
||||
import {
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
@@ -78,6 +81,20 @@ export class DeliveryError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
interface QQReplyContext {
|
||||
chatId: string;
|
||||
msgId: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
interface QQStreamState {
|
||||
chatId: string;
|
||||
buffer: string;
|
||||
timer: ReturnType<typeof setTimeout> | null;
|
||||
retryCount: number;
|
||||
replyContext?: QQReplyContext;
|
||||
}
|
||||
|
||||
/** Validate chatId to prevent SSRF when constructing URLs. */
|
||||
export function isValidChatId(id: string): boolean {
|
||||
return /^[A-Za-z0-9_-]+$/.test(id) && id.length <= 128;
|
||||
@@ -126,6 +143,8 @@ export class QQChannel extends ChannelBase {
|
||||
/** Track the latest user messageId per chatId for proper reply (msg_id). */
|
||||
private replyMsgId: Map<string, { msgId: string; timestamp: number }> =
|
||||
new Map();
|
||||
private replyContextByMessageId = new Map<string, QQReplyContext>();
|
||||
private inboundReplyContext = new AsyncLocalStorage<QQReplyContext>();
|
||||
/** msg_seq counter per user messageId, for multi-block streaming. */
|
||||
private msgSeqMap: Map<string, number> = new Map();
|
||||
/** Periodic cleanup timer for expired replyMsgId entries. */
|
||||
@@ -200,15 +219,7 @@ export class QQChannel extends ChannelBase {
|
||||
* - flushedSessions tracks already-sent sessions to skip final fullText
|
||||
*/
|
||||
// ── Streaming state ───────────────────────────────────────────
|
||||
private streamState: Map<
|
||||
string,
|
||||
{
|
||||
chatId: string;
|
||||
buffer: string;
|
||||
timer: ReturnType<typeof setTimeout> | null;
|
||||
retryCount: number;
|
||||
}
|
||||
> = new Map();
|
||||
private streamState = new Map<string, QQStreamState>();
|
||||
private flushingSessions: Set<string> = new Set();
|
||||
private pendingStreamDelete: Set<string> = new Set();
|
||||
private _reconnectId: number = 0;
|
||||
@@ -348,7 +359,7 @@ export class QQChannel extends ChannelBase {
|
||||
if (toFlush) {
|
||||
const target = this.router.getTarget(sessionId);
|
||||
if (target) {
|
||||
this.sendMessage(target.chatId, toFlush)
|
||||
this.sendMessageWithReplyContext(target.chatId, toFlush)
|
||||
.then(() => {
|
||||
if (!entry!.buffer && this.cronBuffer.get(sessionId) === entry)
|
||||
this.cronBuffer.delete(sessionId);
|
||||
@@ -388,7 +399,7 @@ export class QQChannel extends ChannelBase {
|
||||
this.cronBuffer.delete(sessionId);
|
||||
return;
|
||||
}
|
||||
this.sendMessage(retryTarget.chatId, toFlush)
|
||||
this.sendMessageWithReplyContext(retryTarget.chatId, toFlush)
|
||||
.then(() => {
|
||||
entry!.pendingRetry = '';
|
||||
if (
|
||||
@@ -441,7 +452,10 @@ export class QQChannel extends ChannelBase {
|
||||
this.cronBuffer.delete(sessionId);
|
||||
return;
|
||||
}
|
||||
this.sendMessage(retryTarget2.chatId, toFlush)
|
||||
this.sendMessageWithReplyContext(
|
||||
retryTarget2.chatId,
|
||||
toFlush,
|
||||
)
|
||||
.then(() => {
|
||||
entry!.pendingRetry = '';
|
||||
if (
|
||||
@@ -620,7 +634,48 @@ export class QQChannel extends ChannelBase {
|
||||
}
|
||||
}
|
||||
|
||||
override async handleInbound(envelope: Envelope): Promise<void> {
|
||||
const context = envelope.messageId
|
||||
? this.replyContextByMessageId.get(envelope.messageId)
|
||||
: undefined;
|
||||
if (!context || context.chatId !== envelope.chatId) {
|
||||
await super.handleInbound(envelope);
|
||||
return;
|
||||
}
|
||||
await this.inboundReplyContext.run(context, () =>
|
||||
super.handleInbound(envelope),
|
||||
);
|
||||
}
|
||||
|
||||
async sendMessage(chatId: string, text: string): Promise<void> {
|
||||
const inboundContext = this.inboundReplyContext.getStore();
|
||||
const latest = this.replyMsgId.get(chatId);
|
||||
const replyContext =
|
||||
inboundContext?.chatId === chatId
|
||||
? inboundContext
|
||||
: latest
|
||||
? { chatId, ...latest }
|
||||
: undefined;
|
||||
await this.sendMessageWithReplyContext(chatId, text, replyContext);
|
||||
}
|
||||
|
||||
protected override async sendResponseMessage(
|
||||
chatId: string,
|
||||
text: string,
|
||||
sessionId: string,
|
||||
): Promise<void> {
|
||||
const messageId = this.getResponseMessageId(sessionId);
|
||||
const replyContext = messageId
|
||||
? this.replyContextByMessageId.get(messageId)
|
||||
: undefined;
|
||||
await this.sendMessageWithReplyContext(chatId, text, replyContext);
|
||||
}
|
||||
|
||||
private async sendMessageWithReplyContext(
|
||||
chatId: string,
|
||||
text: string,
|
||||
replyContext?: QQReplyContext,
|
||||
): Promise<void> {
|
||||
// <noreply> suppression
|
||||
if (text.trim() === '<noreply>') {
|
||||
process.stderr.write(
|
||||
@@ -632,8 +687,7 @@ export class QQChannel extends ChannelBase {
|
||||
const route = await this.resolveRoute(chatId);
|
||||
if (!route) return;
|
||||
|
||||
// Look up reply context with TTL check
|
||||
const entry = this.replyMsgId.get(chatId);
|
||||
const entry = replyContext?.chatId === chatId ? replyContext : undefined;
|
||||
const msgId =
|
||||
entry && Date.now() - entry.timestamp < QQChannel.REPLY_MSG_ID_TTL_MS
|
||||
? entry.msgId
|
||||
@@ -642,8 +696,7 @@ export class QQChannel extends ChannelBase {
|
||||
process.stderr.write(
|
||||
`[QQ:${this.name}] replyMsgId entry expired for ${sanitizeLogText(chatId, 64)}, reply context expired, sending without msg_id\n`,
|
||||
);
|
||||
this.msgSeqMap.delete(entry.msgId);
|
||||
this.replyMsgId.delete(chatId);
|
||||
this.deleteReplyContext(entry);
|
||||
this.saveQQState();
|
||||
}
|
||||
|
||||
@@ -970,6 +1023,7 @@ export class QQChannel extends ChannelBase {
|
||||
this.detachCronHandler();
|
||||
this.chatTypeMap.clear();
|
||||
this.replyMsgId.clear();
|
||||
this.replyContextByMessageId.clear();
|
||||
this.msgSeqMap.clear();
|
||||
this.botOpenIdByGroup.clear();
|
||||
this.warnedSenderOpenIds.clear();
|
||||
@@ -1021,15 +1075,22 @@ export class QQChannel extends ChannelBase {
|
||||
chatId: string,
|
||||
chunk: string,
|
||||
sessionId: string,
|
||||
segment?: ChannelOutputSegmentContext,
|
||||
): void {
|
||||
if (this.blockStreaming) return;
|
||||
let state = this.streamState.get(sessionId);
|
||||
if (!state) {
|
||||
state = { chatId, buffer: chunk, timer: null, retryCount: 0 } as {
|
||||
chatId: string;
|
||||
buffer: string;
|
||||
timer: ReturnType<typeof setTimeout> | null;
|
||||
retryCount: number;
|
||||
const messageId =
|
||||
segment?.messageId ?? this.getResponseMessageId(sessionId);
|
||||
const replyContext = messageId
|
||||
? this.replyContextByMessageId.get(messageId)
|
||||
: undefined;
|
||||
state = {
|
||||
chatId,
|
||||
buffer: chunk,
|
||||
timer: null,
|
||||
retryCount: 0,
|
||||
...(replyContext ? { replyContext } : {}),
|
||||
};
|
||||
this.streamState.set(sessionId, state);
|
||||
} else {
|
||||
@@ -1099,19 +1160,14 @@ export class QQChannel extends ChannelBase {
|
||||
private flushAndTrack(
|
||||
sessionId: string,
|
||||
buffer: string,
|
||||
state: {
|
||||
chatId: string;
|
||||
buffer: string;
|
||||
timer: ReturnType<typeof setTimeout> | null;
|
||||
retryCount: number;
|
||||
},
|
||||
state: QQStreamState,
|
||||
logLabel: string,
|
||||
): void {
|
||||
this.flushingSessions.add(sessionId);
|
||||
// sendMessage throws DeliveryError for delivery failures.
|
||||
// RETRY_EXHAUSTED, ACTIVE_MSG_DISABLED, and FALLBACK_FAILED are
|
||||
// permanent. RATE_LIMITED is transient and falls through to re-buffer/retry.
|
||||
this.sendMessage(state.chatId, buffer)
|
||||
this.sendMessageWithReplyContext(state.chatId, buffer, state.replyContext)
|
||||
.then(() => {
|
||||
// #3: Guard — if session died during in-flight send, touch nothing
|
||||
const current = this.streamState.get(sessionId);
|
||||
@@ -1479,6 +1535,8 @@ export class QQChannel extends ChannelBase {
|
||||
(o['msgId'] as string).length <= 128 &&
|
||||
typeof o['timestamp'] === 'number' &&
|
||||
Number.isFinite(o['timestamp']) &&
|
||||
o['timestamp'] >=
|
||||
Date.now() - QQChannel.REPLY_MSG_ID_TTL_MS &&
|
||||
o['timestamp'] <= Date.now() + QQChannel.REPLY_MSG_ID_TTL_MS
|
||||
);
|
||||
})
|
||||
@@ -1496,6 +1554,12 @@ export class QQChannel extends ChannelBase {
|
||||
);
|
||||
}
|
||||
}
|
||||
this.replyContextByMessageId = new Map(
|
||||
Array.from(this.replyMsgId, ([chatId, entry]) => [
|
||||
entry.msgId,
|
||||
{ chatId, ...entry },
|
||||
]),
|
||||
);
|
||||
if (raw.msgSeqMap) {
|
||||
const arr = raw.msgSeqMap as Array<[string, unknown]>;
|
||||
const totalRaw = Array.isArray(arr) ? arr.length : 0;
|
||||
@@ -1517,6 +1581,11 @@ export class QQChannel extends ChannelBase {
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const msgId of this.msgSeqMap.keys()) {
|
||||
if (!this.replyContextByMessageId.has(msgId)) {
|
||||
this.msgSeqMap.delete(msgId);
|
||||
}
|
||||
}
|
||||
if (raw.groupActiveMsgEnabled) {
|
||||
const arr = raw.groupActiveMsgEnabled as Array<[string, unknown]>;
|
||||
const totalRaw = Array.isArray(arr) ? arr.length : 0;
|
||||
@@ -1652,19 +1721,21 @@ export class QQChannel extends ChannelBase {
|
||||
|
||||
// ── ReplyMsgId helpers ────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Set replyMsgId for a chat, cleaning up the previous entry's msgSeqMap
|
||||
* to prevent orphaned entries accumulating over time.
|
||||
*/
|
||||
private setReplyMsgId(chatId: string, msgId: string): void {
|
||||
const oldEntry = this.replyMsgId.get(chatId);
|
||||
if (oldEntry && oldEntry.msgId !== msgId) {
|
||||
this.msgSeqMap.delete(oldEntry.msgId);
|
||||
}
|
||||
this.replyMsgId.set(chatId, { msgId, timestamp: Date.now() });
|
||||
const timestamp = Date.now();
|
||||
this.replyMsgId.set(chatId, { msgId, timestamp });
|
||||
this.replyContextByMessageId.set(msgId, { chatId, msgId, timestamp });
|
||||
this.saveQQState();
|
||||
}
|
||||
|
||||
private deleteReplyContext(context: QQReplyContext): void {
|
||||
this.replyContextByMessageId.delete(context.msgId);
|
||||
this.msgSeqMap.delete(context.msgId);
|
||||
if (this.replyMsgId.get(context.chatId)?.msgId === context.msgId) {
|
||||
this.replyMsgId.delete(context.chatId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start periodic cleanup of expired replyMsgId entries.
|
||||
* Evicts entries older than 5 minutes every 60 seconds, and cascades
|
||||
@@ -1675,6 +1746,12 @@ export class QQChannel extends ChannelBase {
|
||||
this.replyMsgIdCleanupTimer = setInterval(() => {
|
||||
const cutoff = Date.now() - QQChannel.REPLY_MSG_ID_TTL_MS;
|
||||
let dirty = false;
|
||||
for (const context of this.replyContextByMessageId.values()) {
|
||||
if (context.timestamp < cutoff) {
|
||||
this.deleteReplyContext(context);
|
||||
dirty = true;
|
||||
}
|
||||
}
|
||||
for (const [chatId, entry] of this.replyMsgId) {
|
||||
if (entry.timestamp < cutoff) {
|
||||
this.msgSeqMap.delete(entry.msgId);
|
||||
@@ -2796,6 +2873,12 @@ export class QQChannel extends ChannelBase {
|
||||
const replyEntry = this.replyMsgId.get(groupId);
|
||||
if (replyEntry) this.msgSeqMap.delete(replyEntry.msgId);
|
||||
this.replyMsgId.delete(groupId);
|
||||
for (const context of this.replyContextByMessageId.values()) {
|
||||
if (context.chatId === groupId) {
|
||||
this.replyContextByMessageId.delete(context.msgId);
|
||||
this.msgSeqMap.delete(context.msgId);
|
||||
}
|
||||
}
|
||||
this.botOpenIdByGroup.delete(groupId);
|
||||
this._lastKeywordNoMatchLog.delete(groupId);
|
||||
// Clean up cron buffers targeting this group (always, regardless of config flag)
|
||||
|
||||
@@ -54,6 +54,9 @@ vi.mock('@qwen-code/channel-base', () => ({
|
||||
protected handleInbound(_env: unknown): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
protected getResponseMessageId(_sessionId: string): string | undefined {
|
||||
return undefined;
|
||||
}
|
||||
protected onSessionDied(_sessionId: string): void {}
|
||||
},
|
||||
SessionRouter: class {
|
||||
|
||||
@@ -289,6 +289,7 @@ describe('restoreQQState', () => {
|
||||
|
||||
it('restores msgSeqMap from disk', () => {
|
||||
fsStore[statePath] = JSON.stringify({
|
||||
replyMsgId: [['u1', 'msg_abc']],
|
||||
msgSeqMap: [['msg_abc', 5]],
|
||||
});
|
||||
const ch = makeChannel();
|
||||
@@ -299,6 +300,23 @@ describe('restoreQQState', () => {
|
||||
expect(msgSeqMap.get('msg_abc')).toBe(5);
|
||||
});
|
||||
|
||||
it('discards sequence counters without a restored reply context', () => {
|
||||
fsStore[statePath] = JSON.stringify({
|
||||
replyMsgId: [['u1', 'msg-current']],
|
||||
msgSeqMap: [
|
||||
['msg-current', 2],
|
||||
['msg-orphan', 7],
|
||||
],
|
||||
});
|
||||
const ch = makeChannel();
|
||||
(ch as unknown as { restoreQQState: () => boolean }).restoreQQState();
|
||||
|
||||
const msgSeqMap = (ch as unknown as { msgSeqMap: Map<string, number> })
|
||||
.msgSeqMap;
|
||||
expect(msgSeqMap.get('msg-current')).toBe(2);
|
||||
expect(msgSeqMap.has('msg-orphan')).toBe(false);
|
||||
});
|
||||
|
||||
it('restores groupActiveMsgEnabled from disk', () => {
|
||||
fsStore[statePath] = JSON.stringify({
|
||||
groupActiveMsgEnabled: [
|
||||
@@ -362,6 +380,7 @@ describe('restoreQQState', () => {
|
||||
|
||||
it('filters invalid msgSeqMap values (negative or non-number)', () => {
|
||||
fsStore[statePath] = JSON.stringify({
|
||||
replyMsgId: [['u1', 'a']],
|
||||
msgSeqMap: [
|
||||
['a', 3],
|
||||
['b', -1],
|
||||
|
||||
@@ -3,6 +3,7 @@ import { join } from 'node:path';
|
||||
import type {
|
||||
ChannelAgentBridge,
|
||||
ChannelTaskLifecycleEvent,
|
||||
Envelope,
|
||||
} from '@qwen-code/channel-base';
|
||||
import { isValidChatId, DeliveryError } from './QQChannel.js';
|
||||
|
||||
@@ -12,6 +13,7 @@ const {
|
||||
mockFetchGatewayUrl,
|
||||
MockWebSocket,
|
||||
mockWebSockets,
|
||||
mockBaseHandleInbound,
|
||||
} = vi.hoisted(() => {
|
||||
const mockWebSockets: unknown[] = [];
|
||||
|
||||
@@ -47,6 +49,7 @@ const {
|
||||
mockSendQQMessage: vi.fn(),
|
||||
mockFetchAccessToken: vi.fn(),
|
||||
mockFetchGatewayUrl: vi.fn(),
|
||||
mockBaseHandleInbound: vi.fn(() => Promise.resolve()),
|
||||
MockWebSocket,
|
||||
mockWebSockets,
|
||||
};
|
||||
@@ -105,8 +108,11 @@ vi.mock('@qwen-code/channel-base', async () => {
|
||||
this.router = (options?.['router'] as Record<string, unknown>) ?? {};
|
||||
this.baseOptions = options ?? ({} as Record<string, unknown>);
|
||||
}
|
||||
protected handleInbound(_env: unknown): Promise<void> {
|
||||
return Promise.resolve();
|
||||
protected handleInbound(env: unknown): Promise<void> {
|
||||
return mockBaseHandleInbound(env) as Promise<void>;
|
||||
}
|
||||
protected getResponseMessageId(_sessionId: string): string | undefined {
|
||||
return undefined;
|
||||
}
|
||||
protected onTaskLifecycle(_event: unknown): void {}
|
||||
},
|
||||
@@ -141,6 +147,14 @@ function mockResponse(
|
||||
return { ok, status, text: async () => body };
|
||||
}
|
||||
|
||||
function deferredPromise() {
|
||||
let resolve!: () => void;
|
||||
const promise = new Promise<void>((res) => {
|
||||
resolve = res;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
describe('isValidChatId', () => {
|
||||
it('accepts alphanumeric IDs', () => {
|
||||
expect(isValidChatId('abc123')).toBe(true);
|
||||
@@ -839,6 +853,108 @@ describe('sendMessage', () => {
|
||||
saveSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('does not borrow the latest reply context for a background response', async () => {
|
||||
const ch = makeChannel({ chatType: 'c2c', replyMsgId: 'msg-latest' });
|
||||
const channel = ch as unknown as {
|
||||
sendResponseMessage: (
|
||||
chatId: string,
|
||||
text: string,
|
||||
sessionId: string,
|
||||
) => Promise<void>;
|
||||
};
|
||||
|
||||
await channel.sendResponseMessage(
|
||||
'test-chat-id',
|
||||
'background',
|
||||
'session-background',
|
||||
);
|
||||
|
||||
expect(mockSendQQMessage.mock.calls[0][3]).toEqual({
|
||||
msg_type: 2,
|
||||
markdown: { content: 'background' },
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps overlapping inbound replies bound to their own messages', async () => {
|
||||
const ch = makeChannel({ chatType: 'c2c' });
|
||||
const channel = ch as unknown as {
|
||||
setReplyMsgId: (chatId: string, messageId: string) => void;
|
||||
};
|
||||
channel.setReplyMsgId('test-chat-id', 'msg-a');
|
||||
channel.setReplyMsgId('test-chat-id', 'msg-b');
|
||||
const first = deferredPromise();
|
||||
const second = deferredPromise();
|
||||
mockBaseHandleInbound
|
||||
.mockImplementationOnce(async (inbound: Envelope) => {
|
||||
await first.promise;
|
||||
await ch.sendMessage(inbound.chatId, 'reply-a');
|
||||
})
|
||||
.mockImplementationOnce(async (inbound: Envelope) => {
|
||||
await second.promise;
|
||||
await ch.sendMessage(inbound.chatId, 'reply-b');
|
||||
});
|
||||
const base = {
|
||||
channelName: 'test-bot',
|
||||
senderId: 'sender',
|
||||
senderName: 'sender',
|
||||
chatId: 'test-chat-id',
|
||||
text: 'prompt',
|
||||
isGroup: false,
|
||||
isMentioned: false,
|
||||
isReplyToBot: false,
|
||||
};
|
||||
|
||||
const pendingA = ch.handleInbound({ ...base, messageId: 'msg-a' });
|
||||
const pendingB = ch.handleInbound({ ...base, messageId: 'msg-b' });
|
||||
second.resolve();
|
||||
await pendingB;
|
||||
first.resolve();
|
||||
await pendingA;
|
||||
|
||||
expect(mockSendQQMessage.mock.calls[0][3]).toMatchObject({
|
||||
msg_id: 'msg-b',
|
||||
msg_seq: 1,
|
||||
});
|
||||
expect(mockSendQQMessage.mock.calls[1][3]).toMatchObject({
|
||||
msg_id: 'msg-a',
|
||||
msg_seq: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not replace an expired session reply with a newer chat reply', async () => {
|
||||
const ch = makeChannel({ chatType: 'c2c', replyMsgId: 'msg-newer' });
|
||||
const channel = ch as unknown as {
|
||||
getResponseMessageId: (sessionId: string) => string | undefined;
|
||||
replyContextByMessageId: Map<
|
||||
string,
|
||||
{ chatId: string; msgId: string; timestamp: number }
|
||||
>;
|
||||
sendResponseMessage: (
|
||||
chatId: string,
|
||||
text: string,
|
||||
sessionId: string,
|
||||
) => Promise<void>;
|
||||
};
|
||||
channel.getResponseMessageId = () => 'msg-expired';
|
||||
channel.replyContextByMessageId.set('msg-expired', {
|
||||
chatId: 'test-chat-id',
|
||||
msgId: 'msg-expired',
|
||||
timestamp: Date.now() - 300_001,
|
||||
});
|
||||
|
||||
await channel.sendResponseMessage(
|
||||
'test-chat-id',
|
||||
'late response',
|
||||
'session-old',
|
||||
);
|
||||
|
||||
expect(mockSendQQMessage.mock.calls[0][3]).toEqual({
|
||||
msg_type: 2,
|
||||
markdown: { content: 'late response' },
|
||||
});
|
||||
expect(channel.replyContextByMessageId.has('msg-expired')).toBe(false);
|
||||
});
|
||||
|
||||
it('sends single request even for long text (no splitting)', async () => {
|
||||
const ch = makeChannel({ chatType: 'c2c', replyMsgId: 'msg-789' });
|
||||
const text = 'a'.repeat(4500);
|
||||
@@ -1160,7 +1276,7 @@ describe('setReplyMsgId', () => {
|
||||
return ch;
|
||||
}
|
||||
|
||||
it('cleans up old msgSeqMap entry when setting new replyMsgId for same chatId', () => {
|
||||
it('retains the previous message sequence until its reply context expires', () => {
|
||||
const ch = makeChannel();
|
||||
const chp = ch as unknown as Record<string, unknown>;
|
||||
|
||||
@@ -1182,9 +1298,14 @@ describe('setReplyMsgId', () => {
|
||||
'new-msg-id',
|
||||
);
|
||||
|
||||
expect(msgSeqMap.has('old-msg-id')).toBe(false);
|
||||
expect(msgSeqMap.get('old-msg-id')).toBe(5);
|
||||
expect(msgSeqMap.get('other-msg-id')).toBe(10);
|
||||
expect(replyMsgId.get('test-chat-id')!.msgId).toBe('new-msg-id');
|
||||
const replyContexts = chp['replyContextByMessageId'] as Map<
|
||||
string,
|
||||
{ chatId: string; msgId: string; timestamp: number }
|
||||
>;
|
||||
expect(replyContexts.get('new-msg-id')?.chatId).toBe('test-chat-id');
|
||||
});
|
||||
|
||||
it('does nothing when chatId has no prior replyMsgId', () => {
|
||||
@@ -1556,6 +1677,10 @@ describe('restoreQQState validation filters', () => {
|
||||
vi.mocked(existsSync).mockReturnValue(true);
|
||||
vi.mocked(readFileSync).mockReturnValue(
|
||||
JSON.stringify({
|
||||
replyMsgId: [
|
||||
['chat-a', 'a'],
|
||||
['chat-b', 'b'],
|
||||
],
|
||||
msgSeqMap: [
|
||||
['a', 0],
|
||||
['b', 42],
|
||||
@@ -1590,7 +1715,7 @@ describe('restoreQQState validation filters', () => {
|
||||
// Use raw JSON string: JSON.stringify(Infinity) → "null", which
|
||||
// bypasses Number.isSafeInteger (caught by typeof check instead).
|
||||
vi.mocked(readFileSync).mockReturnValue(
|
||||
'{"msgSeqMap":[["a",1.5],["b",9007199254740992],["c",1e999],["d",-1e999],["e",42],["f",0]]}',
|
||||
'{"replyMsgId":[["chat-e","e"],["chat-f","f"]],"msgSeqMap":[["a",1.5],["b",9007199254740992],["c",1e999],["d",-1e999],["e",42],["f",0]]}',
|
||||
);
|
||||
|
||||
const ch = makeChannel();
|
||||
@@ -1852,6 +1977,36 @@ describe('replyMsgId cleanup timer', () => {
|
||||
ch.disconnect();
|
||||
});
|
||||
|
||||
it('reclaims an older same-chat context without deleting the latest one', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
|
||||
const ch = makeChannel();
|
||||
const chp = ch as unknown as Record<string, unknown>;
|
||||
const setReplyMsgId = chp['setReplyMsgId'] as (
|
||||
chatId: string,
|
||||
msgId: string,
|
||||
) => void;
|
||||
setReplyMsgId.call(ch, 'shared-chat', 'msg-old');
|
||||
const msgSeqMap = chp['msgSeqMap'] as Map<string, number>;
|
||||
msgSeqMap.set('msg-old', 3);
|
||||
|
||||
vi.advanceTimersByTime(60_000);
|
||||
setReplyMsgId.call(ch, 'shared-chat', 'msg-latest');
|
||||
msgSeqMap.set('msg-latest', 1);
|
||||
(chp['startReplyMsgIdCleanup'] as () => void).call(ch);
|
||||
vi.advanceTimersByTime(300_000);
|
||||
|
||||
const replyContexts = chp['replyContextByMessageId'] as Map<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(replyContexts.has('msg-old')).toBe(false);
|
||||
expect(msgSeqMap.has('msg-old')).toBe(false);
|
||||
expect(replyContexts.has('msg-latest')).toBe(true);
|
||||
expect(msgSeqMap.get('msg-latest')).toBe(1);
|
||||
ch.disconnect();
|
||||
});
|
||||
|
||||
it('does not call saveQQState when no entries are evicted', () => {
|
||||
vi.useFakeTimers();
|
||||
const ch = makeChannel();
|
||||
|
||||
@@ -52,16 +52,23 @@ vi.mock('@qwen-code/channel-base', () => ({
|
||||
protected handleInbound(_env: unknown): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
protected getResponseMessageId(_sessionId: string): string | undefined {
|
||||
return undefined;
|
||||
}
|
||||
protected async onResponseComplete(
|
||||
_chatId: string,
|
||||
fullText: string,
|
||||
_sessionId: string,
|
||||
sessionId: string,
|
||||
): Promise<void> {
|
||||
await (
|
||||
this as unknown as {
|
||||
sendMessage: (c: string, t: string) => Promise<void>;
|
||||
sendResponseMessage: (
|
||||
c: string,
|
||||
t: string,
|
||||
s: string,
|
||||
) => Promise<void>;
|
||||
}
|
||||
).sendMessage(_chatId, fullText);
|
||||
).sendResponseMessage(_chatId, fullText, sessionId);
|
||||
}
|
||||
onSessionDied(_sessionId: string): void {
|
||||
// no-op in mock; overridden by QQChannel
|
||||
@@ -135,12 +142,23 @@ function onResponseChunk(
|
||||
chatId: string,
|
||||
chunk: string,
|
||||
sessionId: string,
|
||||
messageId?: string,
|
||||
) {
|
||||
return (
|
||||
ch as unknown as {
|
||||
onResponseChunk: (c: string, h: string, s: string) => void;
|
||||
onResponseChunk: (
|
||||
c: string,
|
||||
h: string,
|
||||
s: string,
|
||||
segment?: { messageId?: string },
|
||||
) => void;
|
||||
}
|
||||
).onResponseChunk(chatId, chunk, sessionId);
|
||||
).onResponseChunk(
|
||||
chatId,
|
||||
chunk,
|
||||
sessionId,
|
||||
messageId ? { messageId } : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
function onResponseComplete(
|
||||
@@ -333,6 +351,52 @@ describe('idle-flush timer', () => {
|
||||
await drain();
|
||||
expect(mockSendQQMessage).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('keeps same-chat streams bound to their originating messages', async () => {
|
||||
const ch = makeChannel();
|
||||
const replyContexts = (
|
||||
ch as unknown as {
|
||||
replyContextByMessageId: Map<
|
||||
string,
|
||||
{ chatId: string; msgId: string; timestamp: number }
|
||||
>;
|
||||
}
|
||||
).replyContextByMessageId;
|
||||
const timestamp = Date.now();
|
||||
replyContexts.set('msg-a', {
|
||||
chatId: 'test-chat',
|
||||
msgId: 'msg-a',
|
||||
timestamp,
|
||||
});
|
||||
replyContexts.set('msg-b', {
|
||||
chatId: 'test-chat',
|
||||
msgId: 'msg-b',
|
||||
timestamp,
|
||||
});
|
||||
|
||||
onResponseChunk(ch, 'test-chat', 'a-1', 'session-a', 'msg-a');
|
||||
onResponseChunk(ch, 'test-chat', 'b-1', 'session-b', 'msg-b');
|
||||
vi.advanceTimersByTime(2000);
|
||||
await drain();
|
||||
|
||||
expect(mockSendQQMessage).toHaveBeenCalledTimes(2);
|
||||
expect(mockSendQQMessage.mock.calls[0][3]).toMatchObject({
|
||||
msg_id: 'msg-a',
|
||||
msg_seq: 1,
|
||||
});
|
||||
expect(mockSendQQMessage.mock.calls[1][3]).toMatchObject({
|
||||
msg_id: 'msg-b',
|
||||
msg_seq: 1,
|
||||
});
|
||||
|
||||
onResponseChunk(ch, 'test-chat', 'a-2', 'session-a', 'msg-a');
|
||||
vi.advanceTimersByTime(2000);
|
||||
await drain();
|
||||
expect(mockSendQQMessage.mock.calls[2][3]).toMatchObject({
|
||||
msg_id: 'msg-a',
|
||||
msg_seq: 2,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('onToolCall flush', () => {
|
||||
|
||||
@@ -109,12 +109,14 @@ describe('WeixinChannel', () => {
|
||||
expect(setTyping).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('clears failed start typing state so a later started event can retry', async () => {
|
||||
it('retries a failed initial typing request while the session is active', async () => {
|
||||
const channel = createChannel();
|
||||
const chatId = 'user-retry';
|
||||
const activeTypingChats = (
|
||||
channel as unknown as { activeTypingChats: Set<string> }
|
||||
).activeTypingChats;
|
||||
const activeTypingSessions = (
|
||||
channel as unknown as {
|
||||
activeTypingSessions: Map<string, Map<string, number>>;
|
||||
}
|
||||
).activeTypingSessions;
|
||||
|
||||
apiMocks.getConfig.mockResolvedValue({ typing_ticket: 'ticket-1' });
|
||||
apiMocks.sendTyping
|
||||
@@ -134,17 +136,48 @@ describe('WeixinChannel', () => {
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(apiMocks.sendTyping).toHaveBeenCalledTimes(1);
|
||||
expect(activeTypingChats.has(chatId)).toBe(false);
|
||||
expect(activeTypingSessions.get(chatId)?.has('session-2')).toBe(true);
|
||||
});
|
||||
|
||||
channel.emitLifecycle({ ...baseEvent, type: 'started' });
|
||||
|
||||
await vi.advanceTimersByTimeAsync(4000);
|
||||
await vi.waitFor(() => {
|
||||
expect(apiMocks.sendTyping).toHaveBeenCalledTimes(2);
|
||||
expect(activeTypingChats.has(chatId)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps typing until every session in the same chat terminates', () => {
|
||||
const channel = createChannel();
|
||||
const setTyping = vi.fn().mockResolvedValue(true);
|
||||
(channel as unknown as { setTyping: typeof setTyping }).setTyping =
|
||||
setTyping;
|
||||
const base = {
|
||||
channelName: 'weixin',
|
||||
chatId: 'shared-chat',
|
||||
messageId: 'message',
|
||||
identity: { id: 'channel:weixin', displayName: 'weixin' },
|
||||
memoryScope: { namespace: 'channel:weixin', mode: 'metadata-only' },
|
||||
} satisfies Omit<LifecycleBase, 'sessionId'>;
|
||||
|
||||
channel.emitLifecycle({ ...base, sessionId: 'session-a', type: 'started' });
|
||||
channel.emitLifecycle({ ...base, sessionId: 'session-b', type: 'started' });
|
||||
channel.emitLifecycle({
|
||||
...base,
|
||||
sessionId: 'session-a',
|
||||
type: 'completed',
|
||||
});
|
||||
|
||||
expect(setTyping).toHaveBeenCalledTimes(1);
|
||||
expect(setTyping).toHaveBeenLastCalledWith('shared-chat', true);
|
||||
|
||||
channel.emitLifecycle({
|
||||
...base,
|
||||
sessionId: 'session-b',
|
||||
type: 'completed',
|
||||
});
|
||||
expect(setTyping).toHaveBeenCalledTimes(2);
|
||||
expect(setTyping).toHaveBeenLastCalledWith('shared-chat', false);
|
||||
});
|
||||
|
||||
it('stops typing again when a late lifecycle start resolves after terminal cleanup', async () => {
|
||||
const channel = createChannel();
|
||||
const start = deferredPromise<boolean>();
|
||||
@@ -183,9 +216,11 @@ describe('WeixinChannel', () => {
|
||||
const setTyping = vi.fn().mockResolvedValue(true);
|
||||
(channel as unknown as { setTyping: typeof setTyping }).setTyping =
|
||||
setTyping;
|
||||
const activeTypingChats = (
|
||||
channel as unknown as { activeTypingChats: Set<string> }
|
||||
).activeTypingChats;
|
||||
const activeTypingSessions = (
|
||||
channel as unknown as {
|
||||
activeTypingSessions: Map<string, Map<string, number>>;
|
||||
}
|
||||
).activeTypingSessions;
|
||||
|
||||
channel.emitLifecycle({
|
||||
type: 'started',
|
||||
@@ -196,11 +231,11 @@ describe('WeixinChannel', () => {
|
||||
identity: { id: 'channel:weixin', displayName: 'weixin' },
|
||||
memoryScope: { namespace: 'channel:weixin', mode: 'metadata-only' },
|
||||
});
|
||||
expect(activeTypingChats.has('user-disconnect')).toBe(true);
|
||||
expect(activeTypingSessions.has('user-disconnect')).toBe(true);
|
||||
|
||||
channel.disconnect();
|
||||
|
||||
expect(activeTypingChats.has('user-disconnect')).toBe(false);
|
||||
expect(activeTypingSessions.has('user-disconnect')).toBe(false);
|
||||
});
|
||||
|
||||
describe('typing keepalive', () => {
|
||||
@@ -246,7 +281,7 @@ describe('WeixinChannel', () => {
|
||||
expect(setTyping).toHaveBeenCalledTimes(5);
|
||||
});
|
||||
|
||||
it('does not arm the keepalive when the initial TYPING fails', async () => {
|
||||
it('retries through keepalive when the initial TYPING fails', async () => {
|
||||
const channel = createChannel();
|
||||
const setTyping = vi.fn().mockResolvedValue(false);
|
||||
installSetTypingMock(channel, setTyping);
|
||||
@@ -255,7 +290,7 @@ describe('WeixinChannel', () => {
|
||||
channel.emitLifecycle({ ...base, type: 'started' });
|
||||
await vi.advanceTimersByTimeAsync(20000);
|
||||
|
||||
expect(setTyping).toHaveBeenCalledTimes(1);
|
||||
expect(setTyping).toHaveBeenCalledTimes(6);
|
||||
expect(setTyping).toHaveBeenLastCalledWith('user-keepalive-fail', true);
|
||||
});
|
||||
|
||||
@@ -464,6 +499,53 @@ describe('WeixinChannel', () => {
|
||||
expect(setTyping).toHaveBeenCalledTimes(callsAtBackstop);
|
||||
});
|
||||
|
||||
it('expires an old session without cancelling a newer session in the same chat', async () => {
|
||||
const channel = createChannel();
|
||||
const setTyping = vi.fn().mockResolvedValue(true);
|
||||
installSetTypingMock(channel, setTyping);
|
||||
const oldSession = keepaliveEvent('user-shared', 'session-old');
|
||||
const newSession = keepaliveEvent('user-shared', 'session-new');
|
||||
|
||||
channel.emitLifecycle({ ...oldSession, type: 'started' });
|
||||
await vi.advanceTimersByTimeAsync(TYPING_KEEPALIVE_MAX_MS - 4000);
|
||||
channel.emitLifecycle({ ...newSession, type: 'started' });
|
||||
|
||||
await vi.advanceTimersByTimeAsync(4000);
|
||||
expect(setTyping).toHaveBeenLastCalledWith('user-shared', true);
|
||||
|
||||
const activeTypingSessions = (
|
||||
channel as unknown as {
|
||||
activeTypingSessions: Map<string, Map<string, number>>;
|
||||
}
|
||||
).activeTypingSessions;
|
||||
expect(activeTypingSessions.get('user-shared')?.has('session-old')).toBe(
|
||||
false,
|
||||
);
|
||||
expect(activeTypingSessions.get('user-shared')?.has('session-new')).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
channel.emitLifecycle({ ...newSession, type: 'completed' });
|
||||
expect(setTyping).toHaveBeenLastCalledWith('user-shared', false);
|
||||
});
|
||||
|
||||
it('removes only the dead session from a shared chat', () => {
|
||||
const channel = createChannel();
|
||||
const setTyping = vi.fn().mockResolvedValue(true);
|
||||
installSetTypingMock(channel, setTyping);
|
||||
const sessionA = keepaliveEvent('user-session-died', 'session-a');
|
||||
const sessionB = keepaliveEvent('user-session-died', 'session-b');
|
||||
|
||||
channel.emitLifecycle({ ...sessionA, type: 'started' });
|
||||
channel.emitLifecycle({ ...sessionB, type: 'started' });
|
||||
channel.onSessionDied('session-a');
|
||||
expect(setTyping).toHaveBeenCalledTimes(1);
|
||||
|
||||
channel.onSessionDied('session-b');
|
||||
expect(setTyping).toHaveBeenCalledTimes(2);
|
||||
expect(setTyping).toHaveBeenLastCalledWith('user-session-died', false);
|
||||
});
|
||||
|
||||
it('ignores a stale failed initial TYPING from a previous turn', async () => {
|
||||
const channel = createChannel();
|
||||
const stale = deferredPromise<boolean>();
|
||||
@@ -594,13 +676,13 @@ describe('WeixinChannel', () => {
|
||||
const priv = channel as unknown as {
|
||||
typingKeepaliveIntervals: Map<string, unknown>;
|
||||
typingKeepaliveInFlight: Set<string>;
|
||||
typingKeepaliveArmedAt: Map<string, number>;
|
||||
typingGenerations: Map<string, number>;
|
||||
activeTypingSessions: Map<string, Map<string, number>>;
|
||||
};
|
||||
expect(priv.typingKeepaliveIntervals.size).toBe(0);
|
||||
expect(priv.typingKeepaliveInFlight.size).toBe(0);
|
||||
expect(priv.typingKeepaliveArmedAt.size).toBe(0);
|
||||
expect(priv.typingGenerations.size).toBe(0);
|
||||
expect(priv.activeTypingSessions.size).toBe(0);
|
||||
|
||||
// A fresh turn on the reused adapter must not be reaped early by
|
||||
// stale backstop state: it should refresh, not CANCEL.
|
||||
|
||||
@@ -52,13 +52,12 @@ function escapeRegex(s: string): string {
|
||||
|
||||
export class WeixinChannel extends ChannelBase {
|
||||
private abortController: AbortController | null = null;
|
||||
private activeTypingChats = new Set<string>();
|
||||
private activeTypingSessions = new Map<string, Map<string, number>>();
|
||||
private typingKeepaliveIntervals = new Map<
|
||||
string,
|
||||
ReturnType<typeof setInterval>
|
||||
>();
|
||||
private typingKeepaliveInFlight = new Set<string>();
|
||||
private typingKeepaliveArmedAt = new Map<string, number>();
|
||||
/**
|
||||
* Per-chat generation token; stale setTyping continuations bail out.
|
||||
* Entries are reclaimed only by `disconnect()` — deleting them in
|
||||
@@ -162,21 +161,21 @@ export class WeixinChannel extends ChannelBase {
|
||||
);
|
||||
}
|
||||
|
||||
protected override onPromptStart(chatId: string): void {
|
||||
this.startTyping(chatId);
|
||||
protected override onPromptStart(chatId: string, sessionId: string): void {
|
||||
this.startTyping(chatId, sessionId);
|
||||
}
|
||||
|
||||
protected override onPromptEnd(chatId: string): void {
|
||||
this.stopTyping(chatId);
|
||||
protected override onPromptEnd(chatId: string, sessionId: string): void {
|
||||
this.stopTyping(chatId, sessionId);
|
||||
}
|
||||
|
||||
protected override onTaskLifecycle(event: ChannelTaskLifecycleEvent): void {
|
||||
if (event.type === 'started') {
|
||||
this.startTyping(event.chatId);
|
||||
this.startTyping(event.chatId, event.sessionId);
|
||||
return;
|
||||
}
|
||||
if (isTerminalTaskLifecycleType(event.type)) {
|
||||
this.stopTyping(event.chatId);
|
||||
this.stopTyping(event.chatId, event.sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -326,14 +325,19 @@ export class WeixinChannel extends ChannelBase {
|
||||
}
|
||||
this.typingKeepaliveIntervals.clear();
|
||||
this.typingKeepaliveInFlight.clear();
|
||||
this.typingKeepaliveArmedAt.clear();
|
||||
this.typingGenerations.clear();
|
||||
this.activeTypingChats.clear();
|
||||
this.activeTypingSessions.clear();
|
||||
}
|
||||
|
||||
private startTyping(chatId: string): void {
|
||||
if (this.activeTypingChats.has(chatId)) return;
|
||||
this.activeTypingChats.add(chatId);
|
||||
private startTyping(chatId: string, sessionId: string): void {
|
||||
const sessions =
|
||||
this.activeTypingSessions.get(chatId) ?? new Map<string, number>();
|
||||
if (sessions.has(sessionId)) return;
|
||||
const firstSession = sessions.size === 0;
|
||||
sessions.set(sessionId, Date.now());
|
||||
this.activeTypingSessions.set(chatId, sessions);
|
||||
if (!firstSession) return;
|
||||
|
||||
const controller = this.abortController;
|
||||
const generation = (this.typingGenerations.get(chatId) ?? 0) + 1;
|
||||
this.typingGenerations.set(chatId, generation);
|
||||
@@ -347,69 +351,75 @@ export class WeixinChannel extends ChannelBase {
|
||||
// must not touch the live turn's typing state. A late success still
|
||||
// re-set the server-side indicator after our CANCEL, so compensate —
|
||||
// but only while the chat is idle; if a successor turn is live
|
||||
// (activeTypingChats re-added), an unconditional CANCEL here would
|
||||
// (activeTypingSessions re-added), an unconditional CANCEL here would
|
||||
// blank the successor's indicator mid-turn. A late failure is
|
||||
// harmless and must not delete live state.
|
||||
if (generation !== this.typingGenerations.get(chatId)) {
|
||||
if (started && !this.activeTypingChats.has(chatId)) {
|
||||
if (started && !this.activeTypingSessions.has(chatId)) {
|
||||
void this.setTyping(chatId, false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!started) {
|
||||
this.activeTypingChats.delete(chatId);
|
||||
return;
|
||||
}
|
||||
if (!this.activeTypingChats.has(chatId)) {
|
||||
if (started && !this.activeTypingSessions.has(chatId)) {
|
||||
void this.setTyping(chatId, false);
|
||||
return;
|
||||
}
|
||||
this.startTypingKeepalive(chatId);
|
||||
if (this.activeTypingSessions.has(chatId)) {
|
||||
this.startTypingKeepalive(chatId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private stopTyping(chatId: string): void {
|
||||
private stopTyping(chatId: string, sessionId: string): void {
|
||||
const sessions = this.activeTypingSessions.get(chatId);
|
||||
if (!sessions?.delete(sessionId)) return;
|
||||
if (sessions.size > 0) return;
|
||||
this.activeTypingSessions.delete(chatId);
|
||||
this.stopTypingChat(chatId);
|
||||
}
|
||||
|
||||
private stopTypingChat(chatId: string): void {
|
||||
// Invalidate any in-flight initial TYPING of a previous turn.
|
||||
this.typingGenerations.set(
|
||||
chatId,
|
||||
(this.typingGenerations.get(chatId) ?? 0) + 1,
|
||||
);
|
||||
this.stopTypingKeepalive(chatId);
|
||||
if (!this.activeTypingChats.delete(chatId)) return;
|
||||
void this.setTyping(chatId, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes TYPING periodically while the chat stays in
|
||||
* `activeTypingChats`, so the indicator survives long turns. The interval
|
||||
* is only armed after the initial TYPING is confirmed, and each tick
|
||||
* self-reaps once the chat is no longer typing or the backstop
|
||||
* (`TYPING_KEEPALIVE_MAX_MS`) elapses, so a missed terminal event cannot
|
||||
* keep it alive indefinitely.
|
||||
* active sessions, so the indicator survives long turns. Each tick expires
|
||||
* only sessions that exceeded the backstop; newer sessions in the same chat
|
||||
* keep ownership.
|
||||
*/
|
||||
private startTypingKeepalive(chatId: string): void {
|
||||
if (this.typingKeepaliveIntervals.has(chatId)) return;
|
||||
this.typingKeepaliveArmedAt.set(chatId, Date.now());
|
||||
this.typingKeepaliveIntervals.set(
|
||||
chatId,
|
||||
setInterval(() => {
|
||||
if (!this.activeTypingChats.has(chatId)) {
|
||||
const sessions = this.activeTypingSessions.get(chatId);
|
||||
if (!sessions) {
|
||||
this.stopTypingKeepalive(chatId);
|
||||
return;
|
||||
}
|
||||
const armedAt = this.typingKeepaliveArmedAt.get(chatId);
|
||||
if (
|
||||
armedAt !== undefined &&
|
||||
Date.now() - armedAt >= TYPING_KEEPALIVE_MAX_MS
|
||||
) {
|
||||
// Wedged turn — bound the leak and clear the indicator. The
|
||||
// backstop firing is the only observable signal that a turn never
|
||||
// emitted a terminal event, so leave a log line tying the dropped
|
||||
// indicator to the wedged chat.
|
||||
const now = Date.now();
|
||||
let expiredSessions = 0;
|
||||
for (const [sessionId, startedAt] of sessions) {
|
||||
if (now - startedAt >= TYPING_KEEPALIVE_MAX_MS) {
|
||||
sessions.delete(sessionId);
|
||||
expiredSessions++;
|
||||
}
|
||||
}
|
||||
if (expiredSessions > 0) {
|
||||
process.stderr.write(
|
||||
`[Weixin:${this.name}] Typing keepalive backstop (${TYPING_KEEPALIVE_MAX_MS}ms) elapsed for chat ${chatId} without a terminal event; clearing the indicator\n`,
|
||||
`[Weixin:${this.name}] Typing keepalive backstop (${TYPING_KEEPALIVE_MAX_MS}ms) elapsed for ${expiredSessions} session(s) in chat ${chatId}\n`,
|
||||
);
|
||||
this.stopTyping(chatId);
|
||||
}
|
||||
if (sessions.size === 0) {
|
||||
this.activeTypingSessions.delete(chatId);
|
||||
this.stopTypingChat(chatId);
|
||||
return;
|
||||
}
|
||||
// Don't overlap keepalive requests for the same chat.
|
||||
@@ -427,7 +437,6 @@ export class WeixinChannel extends ChannelBase {
|
||||
if (interval === undefined) return;
|
||||
clearInterval(interval);
|
||||
this.typingKeepaliveIntervals.delete(chatId);
|
||||
this.typingKeepaliveArmedAt.delete(chatId);
|
||||
// A refresh in flight from the ending turn would otherwise keep the flag
|
||||
// set until it settles (up to the post() timeout), and every keepalive
|
||||
// tick of the successor turn would early-return on the dedup check —
|
||||
@@ -436,6 +445,15 @@ export class WeixinChannel extends ChannelBase {
|
||||
this.typingKeepaliveInFlight.delete(chatId);
|
||||
}
|
||||
|
||||
override onSessionDied(sessionId: string): void {
|
||||
for (const [chatId, sessions] of this.activeTypingSessions) {
|
||||
if (sessions.has(sessionId)) {
|
||||
this.stopTyping(chatId, sessionId);
|
||||
}
|
||||
}
|
||||
super.onSessionDied(sessionId);
|
||||
}
|
||||
|
||||
private async setTyping(userId: string, typing: boolean): Promise<boolean> {
|
||||
try {
|
||||
let ticket = typingTickets.get(userId);
|
||||
|
||||
Reference in new issue
Block a user