fix(feishu): renew the reply wait window while the turn is active

A long-running Harness task was falsely reported as MODEL_REPLY_TIMEOUT
after the fixed 10-minute deadline even though the backend was still
working. Replace the hard wall-clock deadline with an activity-based
stall window: the wait renews whenever the turn produces new events, the
control ownership is active, or (mid-turn with no new events) Harness
still reports the session as running. Only a genuine stall with no
progress for the whole timeoutMs window fails fast.

This is the shared harness-client layer, exercised end-to-end by the
Feishu channel (which already exposes HARNESS_REPLY_TIMEOUT_MS); other
channels inherit the same fix.

Adds two regression tests: a long-running turn with periodic activity
completes instead of timing out, and a genuinely stalled turn still
fails with harness-reply-timeout.
This commit is contained in:
C3H3-AI 2026-09-01 22:24:33 +08:00
parent c2be2389b0
commit 955c2217fd
3 changed files with 132 additions and 18 deletions

View file

@ -974,3 +974,87 @@ test('HarnessReplyTracker keeps every frame of a batched turn in order', () => {
]);
assert.equal(tracker.answer, '先创建再观察:');
});
test('a turn that keeps producing events is not falsely reported as MODEL_REPLY_TIMEOUT', async () => {
const client = new HarnessClient({
baseUrl: 'http://127.0.0.1:3080',
workspace: '/tmp/dsh-feishu-workspace',
});
client.ensureRunning = async () => undefined;
let promptRpcId;
let prompted = false;
let seq = 0;
const startedAt = Date.now();
client.rpc = async (method, _payload, _timeoutMs, options) => {
if (method === 'session.history') {
if (!prompted) return { events: [] };
const events = [];
if (!promptRpcId) promptRpcId = options?.rpcId;
// The turn "works" for ~250ms: each poll appends a fresh chunk so the
// seq keeps advancing (the renewal signal). After the window it ends.
const elapsed = Date.now() - startedAt;
if (elapsed < 250) {
events.push(
{ event: { type: 'turn/start', seq: ++seq, data: { turn: 1 } } },
{ event: { type: 'user/message', seq: ++seq, data: { turn: 1, source: { rpcId: promptRpcId } } } },
{ event: { type: 'assistant/chunk', seq: ++seq, data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: `chunk-${seq}` } } } },
);
} else {
events.push(
{ event: { type: 'turn/start', seq: ++seq, data: { turn: 1 } } },
{ event: { type: 'user/message', seq: ++seq, data: { turn: 1, source: { rpcId: promptRpcId } } } },
{ event: { type: 'assistant/message', seq: ++seq, data: { turn: 1, step: 1, message: { content: [{ type: 'text', text: '最终结果' }] } } } },
{ event: { type: 'turn/end', seq: ++seq, data: { turn: 1, reason: { kind: 'completed' } } } },
);
}
return { events };
}
if (method === 'session.prompt') {
prompted = true;
promptRpcId = options.rpcId;
return {};
}
if (method === 'session.list') {
return { items: [{ sessionId: 'session-renewal', running: true }] };
}
throw new Error(`unexpected rpc ${method}`);
};
// timeoutMs is tiny (100ms) but the turn keeps producing events, so the
// stall window keeps renewing and the ask must complete instead of timing out.
const answer = await client.ask('session-renewal', 'long task', { timeoutMs: 100 });
assert.equal(answer, '最终结果');
});
test('a turn with no activity and no running session still times out', async () => {
const client = new HarnessClient({
baseUrl: 'http://127.0.0.1:3080',
workspace: '/tmp/dsh-feishu-workspace',
});
client.ensureRunning = async () => undefined;
let promptRpcId;
let prompted = false;
client.rpc = async (method, _payload, _timeoutMs, options) => {
if (method === 'session.history') {
if (!prompted) return { events: [] };
// The turn started but never emits another event, and the session is not
// reported as running: a genuine stall.
return { events: [] };
}
if (method === 'session.prompt') {
prompted = true;
promptRpcId = options.rpcId;
return {};
}
if (method === 'session.list') {
return { items: [{ sessionId: 'session-stall', running: false }] };
}
throw new Error(`unexpected rpc ${method}`);
};
await assert.rejects(
client.ask('session-stall', 'never runs', { timeoutMs: 120 }),
(error) => error?.code === 'harness-reply-timeout',
'a genuinely stalled turn must still fail with MODEL_REPLY_TIMEOUT',
);
});