fix(ui): never auto-send the message queue into a streaming turn (#2642)

The queue auto-send gate treated a missing session status entry as idle,
but the server's /session/status map only lists busy/retry sessions — a
missed busy event leaves no entry while a turn is still streaming. On a
client that missed the busy event (e.g. mobile reconnect window), queued
follow-ups were dispatched into the running turn; OpenCode then merged
both prompts into one model response instead of serializing them.

Extract resolveQueuedSessionStatusType and mirror useSessionActivity's
fallback: a trailing in-flight assistant message means the session is
still busy, so the queue waits for the real idle edge. Subscribe the
effect to messages so the queue drains as soon as the turn completes
even when status events were missed.
This commit is contained in:
Serhii Dziupin
2026-08-06 22:19:50 +03:00
committed by GitHub
parent 4bb89bf6a6
commit fe331c093b
2 changed files with 99 additions and 4 deletions
@@ -1,6 +1,8 @@
import { beforeEach, describe, expect, mock, test } from 'bun:test';
import type { Agent } from '@opencode-ai/sdk/v2';
import type { Agent, Message } from '@opencode-ai/sdk/v2';
import type { QueuedMessage } from '../stores/messageQueueStore';
import { ChildStoreManager } from '@/sync/child-store';
import { setSyncRefs } from '@/sync/sync-refs';
let visibleAgents: Agent[] = [];
const sendMessageCalls: unknown[][] = [];
@@ -32,6 +34,7 @@ import {
createQueuedAutoSendRetryScheduler,
getQueuedAutoSendRetryDelayMs,
isQueuedAutoSendBackedOff,
resolveQueuedSessionStatusType,
sendQueuedAutoSendPayload,
shouldDispatchQueuedAutoSend,
} from './useQueuedMessageAutoSend';
@@ -119,6 +122,59 @@ describe('queued auto-send retry backoff', () => {
});
});
describe('resolveQueuedSessionStatusType', () => {
const DIRECTORY = '/repo';
const assistantMessage = (id: string, completed?: number): Message => ({
id,
role: 'assistant',
sessionID: 'ses_1',
time: { created: 1, ...(completed !== undefined ? { completed } : {}) },
} as Message);
let childStores: ChildStoreManager;
beforeEach(() => {
childStores = new ChildStoreManager();
const store = childStores.ensureChild(DIRECTORY, { bootstrap: false });
store.setState({ status: 'complete', session_status: {}, message: {} });
setSyncRefs({} as never, childStores, DIRECTORY);
});
test('treats a session with an in-flight assistant turn as busy even when the status entry is missing', () => {
// The server status map only lists busy/retry sessions, so a missed busy
// event leaves NO status entry while the turn is still streaming. The
// queue gate must not read that absence as idle: queued prompts would be
// dispatched into the running turn and merged into one model response.
childStores.ensureChild(DIRECTORY, { bootstrap: false }).setState({
message: { ses_1: [assistantMessage('msg_streaming')] },
});
expect(resolveQueuedSessionStatusType('ses_1', DIRECTORY)).toBe('busy');
});
test('resolves an explicit busy or retry status entry', () => {
const store = childStores.ensureChild(DIRECTORY, { bootstrap: false });
store.setState({ session_status: { ses_1: { type: 'busy' } } });
expect(resolveQueuedSessionStatusType('ses_1', DIRECTORY)).toBe('busy');
store.setState({ session_status: { ses_1: { type: 'retry', attempt: 2, message: 'boom', next: 30 } } });
expect(resolveQueuedSessionStatusType('ses_1', DIRECTORY)).toBe('retry');
});
test('resolves idle when the trailing assistant message has completed', () => {
const store = childStores.ensureChild(DIRECTORY, { bootstrap: false });
store.setState({ message: { ses_1: [assistantMessage('msg_done', 5)] } });
expect(resolveQueuedSessionStatusType('ses_1', DIRECTORY)).toBe('idle');
});
test('resolves an explicit idle entry and unknown sessions as idle', () => {
const store = childStores.ensureChild(DIRECTORY, { bootstrap: false });
store.setState({ session_status: { ses_1: { type: 'idle' } } });
expect(resolveQueuedSessionStatusType('ses_1', DIRECTORY)).toBe('idle');
expect(resolveQueuedSessionStatusType('ses_unknown', DIRECTORY)).toBe('idle');
});
});
describe('buildQueuedAutoSendPayload', () => {
beforeEach(() => {
visibleAgents = [];