From efc121080a356af731613d5998005ca15699cbee Mon Sep 17 00:00:00 2001 From: Day Dreamer <87640074+HugoXK@users.noreply.github.com> Date: Thu, 29 Oct 2026 10:53:21 +0800 Subject: [PATCH] test(chat): pin a human line aborting a foreground wait older than a minute MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A human line posted while a foreground tool or job wait is open is queued and claimed only after the wait ends, so a long bash or job wait holds it — the case the owner called stuck (#1509). The owner's decision: only a wait that has already lasted more than 60 seconds is aborted, the way Stop aborts it (agent.cancel with keepInbox), so the new line is claimed at once. This commit pins the behaviour with three guards: a wait younger than a minute keeps running, a running turn with no open call is left alone, and a turn already waiting on the person (ask_user) is not aborted under them. Red at this tip: the abort is missing, so the first test fails on "the foreground wait that has lasted over a minute is aborted". Docs-not-needed: this is glue on the DSH session-event facts FlyBot already reads (flybot-feed already times tool calls from them); no subsystem boundary, public API or ownership described in docs/subsystems changes. --- core/test/wait-interrupt.test.js | 149 +++++++++++++++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 core/test/wait-interrupt.test.js diff --git a/core/test/wait-interrupt.test.js b/core/test/wait-interrupt.test.js new file mode 100644 index 000000000..38ce7c2c6 --- /dev/null +++ b/core/test/wait-interrupt.test.js @@ -0,0 +1,149 @@ +import assert from 'node:assert/strict' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { test } from 'node:test' +import { createRuntime } from '../index.js' +import { fakeContext } from './fake-ctx.js' + +// A human line posted to a Bot's own chat while a foreground tool or job wait is open +// (#1509). The composer queues the line, so it is claimed only after the wait ends — and a +// wait on a long `bash`/job is exactly the case the owner called stuck. Once that wait has +// lasted over a minute the turn is aborted the way Stop aborts it (`keepInbox`), and the +// line is queued behind the abort so its wake opens the next turn with the line in it. +const WAIT_MS = 60_000 + +function start() { + const home = mkdtempSync(join(tmpdir(), 'ds-bot-wait-interrupt-')) + const ctx = fakeContext() + // cancel and prompt in arrival order, so a test can pin which one came first. + const log = [] + const prompts = [] + const cancelled = [] + const agentOf = (sessionId) => { + const agent = { + session: { id: sessionId }, + status: 'running', + followup() {}, + steer() {}, + inject() {}, + cancel(cause, options) { + log.push('cancel') + cancelled.push({ sessionId, cause, options }) + }, + inbox: { nextStep: [], nextTurn: [], remove() {} }, + } + return agent + } + const agents = new Map() + Object.assign(ctx, { + llm: { + listProviders: () => [{ id: 'deepseek', name: 'DeepSeek' }], + listModels: async () => [{ id: 'deepseek-v4-flash' }], + resolveModelInfo: async () => ({}), + }, + workspaceRegistry: { + get: () => undefined, + resolveByPath: async () => undefined, + create: async (path) => ({ id: 'w1', path }), + archiveSession: async () => {}, + }, + sessionController: { + create: async () => ({ sessionId: 's1' }), + resolveAgent: async (sessionId) => { + if (!agents.has(sessionId)) agents.set(sessionId, agentOf(sessionId)) + return { agent: agents.get(sessionId) } + }, + selectModel: async () => {}, + prompt: async (request) => { + log.push('prompt') + prompts.push({ sessionId: request.sessionId, mode: request.mode, text: request.content.map(part => part.text).join('') }) + return { accepted: true } + }, + page: async () => ({ events: [] }), + }, + sessionTitle: { rename: async () => {} }, + sessions: { flush: async () => {} }, + }) + let route + const register = ctx.connection.fetch.register + ctx.connection.fetch.register = (options) => { + route = options + return register(options) + } + const rt = createRuntime(ctx, { home }) + const call = async (endpoint, payload) => { + const response = await route.fetch(new Request('http://localhost/api/bot', { method: 'POST', body: JSON.stringify({ endpoint, payload }) })) + return response.json() + } + let seq = 0 + // What DSH publishes for a running Session: a turn, then the tool call it is blocked on. + const emit = (sessionId, type, data, time) => ctx.emitArgs('session/event', { id: sessionId }, { type, seq: ++seq, time, data }) + const stop = async () => { + await ctx.dispose() + rmSync(home, { recursive: true, force: true, maxRetries: 5 }) + } + return { call, ctx, emit, log, prompts, cancelled, rt, stop } +} + +// A turn opened `startedAgoMs` ago whose `tool` call is still open (no `tool/result` yet). +function openWait(emit, sessionId, { tool = 'bash', startedAgoMs } = {}) { + emit(sessionId, 'turn/start', { turn: 1 }, Date.now() - startedAgoMs) + emit(sessionId, 'tool/call', { callId: 'call-1', name: tool, arguments: '{"command":"sleep 600"}' }, Date.now() - startedAgoMs) +} + +test('#1509: a human line on a wait older than a minute aborts it the way Stop does, keeping the line', async (t) => { + const { call, emit, log, prompts, cancelled, rt, stop } = start() + t.after(stop) + await rt.load() + const chief = (await call('state', {})).value.mainBotId + openWait(emit, rt.chatOf(chief), { startedAgoMs: WAIT_MS + 5_000 }) + + assert.equal((await call('send', { sessionId: chief, text: 'stop waiting and read this' })).ok, true) + + assert.equal(cancelled.length, 1, 'the foreground wait that has lasted over a minute is aborted') + assert.deepEqual(cancelled[0].cause, { kind: 'user' }, 'the abort is the user Stop path, not a new cancellation') + assert.deepEqual(cancelled[0].options, { keepInbox: true }, 'the human line must survive the abort') + assert.deepEqual(log, ['cancel', 'prompt'], 'the wait is aborted first, so the queued line\'s own wake opens the next turn') + assert.deepEqual(prompts.map(entry => entry.mode), ['queue'], 'the line still lands as a queued prompt, exactly as before') +}) + +test('#1509: a wait younger than a minute keeps running, and the line queues behind it', async (t) => { + const { call, emit, prompts, cancelled, rt, stop } = start() + t.after(stop) + await rt.load() + const chief = (await call('state', {})).value.mainBotId + openWait(emit, rt.chatOf(chief), { startedAgoMs: WAIT_MS - 5_000 }) + + assert.equal((await call('send', { sessionId: chief, text: 'when you get to it' })).ok, true) + + assert.deepEqual(cancelled, [], 'a command that is still quick is not killed mid-flight') + assert.equal(prompts.length, 1, 'the line waits its turn as before') +}) + +test('#1509: a running turn with no open tool call is left alone', async (t) => { + const { call, emit, prompts, cancelled, rt, stop } = start() + t.after(stop) + await rt.load() + const chief = (await call('state', {})).value.mainBotId + const chat = rt.chatOf(chief) + emit(chat, 'turn/start', { turn: 1 }, Date.now() - 10 * WAIT_MS) + + assert.equal((await call('send', { sessionId: chief, text: 'plus one more thing' })).ok, true) + + assert.deepEqual(cancelled, [], 'the model is thinking, not blocked on a wait: nothing is aborted') + assert.equal(prompts.length, 1) +}) + +test('#1509: a question the Bot is already waiting on is not a stuck wait', async (t) => { + const { call, emit, prompts, cancelled, rt, stop } = start() + t.after(stop) + await rt.load() + const chief = (await call('state', {})).value.mainBotId + openWait(emit, rt.chatOf(chief), { tool: 'ask_user', startedAgoMs: WAIT_MS * 3 }) + + assert.equal((await call('send', { sessionId: chief, text: 'hold on' })).ok, true) + + assert.deepEqual(cancelled, [], 'a turn that already waits for the person is not aborted under them') + assert.equal(prompts.length, 1) +})