fix(ui): send parent messages while only subagents run (#125428)

This commit is contained in:
Josh Lehman
2026-08-18 00:10:05 -07:00
committed by GitHub
parent 6cc40431d1
commit 5b8dcf9d28
8 changed files with 60 additions and 17 deletions
+2 -2
View File
@@ -44,7 +44,7 @@ import {
QUEUED_MESSAGE_RETRY_CONFLICT_ERROR,
QUEUED_MESSAGE_REORDER_CONFLICT_ERROR,
} from "./queued-message-edit.ts";
import { hasAbortableSessionRun } from "./run-lifecycle.ts";
import { hasDirectSessionRun } from "./run-lifecycle.ts";
import {
OFFLINE_QUEUE_STORAGE_ERROR,
steerQueuedChatMessage as steerQueuedChatMessageLifecycle,
@@ -240,7 +240,7 @@ export async function retryQueuedChatMessage(host: ChatHost, id: string) {
setChatError(host, t("chat.sendErrors.steerRunNoLongerActive"));
return;
}
if (hasAbortableSessionRun(host)) {
if (hasDirectSessionRun(host)) {
const retry = updateQueuedMessage(host, id, (entry) => ({
...entry,
sendAttempts: 0,
+2 -2
View File
@@ -61,7 +61,7 @@ import { formatConnectError } from "./connect-error.ts";
import { readChatSessionProjectionScope, reduceChatSessionProjection } from "./history-merge.ts";
import { resetChatInputHistoryNavigation } from "./input-history.ts";
import { controlUiNowMs, roundedControlUiDurationMs } from "./performance.ts";
import { hasAbortableSessionRun, isChatBusy, reconcileChatRunLifecycle } from "./run-lifecycle.ts";
import { hasDirectSessionRun, isChatBusy, reconcileChatRunLifecycle } from "./run-lifecycle.ts";
import { resetChatScroll, scheduleChatScroll } from "./scroll.ts";
import {
formatTerminalChatSendAckError,
@@ -614,7 +614,7 @@ export async function deliverChatQueueItem(
if (
drainResult === undefined &&
routeVisible &&
(isChatBusy(host) || hasAbortableSessionRun(host))
(isChatBusy(host) || hasDirectSessionRun(host))
) {
const parked = finishChatDeliveryAdmission(
host,
+2 -2
View File
@@ -19,7 +19,7 @@ import { recordChatSendTiming, schedulePendingSendPaintTiming } from "./chat-sen
import { getPendingChatPickerPatch } from "./chat-session.ts";
import { storedChatOutboxScopeKey, type StoredChatOutboxScope } from "./composer-persistence.ts";
import { controlUiNowMs } from "./performance.ts";
import { hasAbortableSessionRun, isChatBusy } from "./run-lifecycle.ts";
import { hasDirectSessionRun, isChatBusy } from "./run-lifecycle.ts";
import { scheduleChatScroll } from "./scroll.ts";
import { OFFLINE_QUEUE_STORAGE_ERROR, surfaceChatDeliveryFailure } from "./steer-lifecycle.ts";
@@ -192,7 +192,7 @@ export function finishChatDeliveryAdmission(
}
return "pending";
}
if (routeVisible(current.agentId) && (isChatBusy(host) || hasAbortableSessionRun(host))) {
if (routeVisible(current.agentId) && (isChatBusy(host) || hasDirectSessionRun(host))) {
const parked = setState(host.connected && host.client ? "waiting-idle" : "waiting-reconnect");
if (!parked) {
setChatError(host, OFFLINE_QUEUE_STORAGE_ERROR);
+3 -2
View File
@@ -61,6 +61,7 @@ import { activeQueuedMessageEdit, retireEditedQueuedMessageSource } from "./queu
import {
handleAbortChat,
hasAbortableSessionRun,
hasDirectSessionRun,
isChatBusy,
isChatStopCommand,
} from "./run-lifecycle.ts";
@@ -584,7 +585,7 @@ export async function handleSendChat(
pending?.sendState === "waiting-idle" &&
host.sessionKey === submittedSessionKey &&
visibleSessionMatches(host, submittedSessionKey, pending.agentId) &&
(isChatBusy(host) || hasAbortableSessionRun(host));
(isChatBusy(host) || hasDirectSessionRun(host));
if (pendingBusySend) {
recordChatSendTiming(host, pending, "queued-busy", submittedAtMs);
// Only an explicit browser override replaces inherited Gateway policy.
@@ -596,7 +597,7 @@ export async function handleSendChat(
!skillWorkshopRevision &&
followUpMode !== "queue" &&
host.connected &&
hasAbortableSessionRun(host)
hasDirectSessionRun(host)
) {
void sendQueuedChatMessageWithQueueModeLifecycle(
host,
+28
View File
@@ -3873,6 +3873,34 @@ describe("handleSendChat", () => {
);
});
it("sends normally when only a descendant run is active", async () => {
const host = makeChatHost({
requestHandlers: {
"chat.send": { status: "started", runId: "new-parent-run" },
},
chatMessage: "start another parent turn",
chatRunId: null,
sessionKey: "agent:main:main",
sessionsResult: createSessionsResult([
row("agent:main:main", {
hasActiveRun: false,
hasActiveSubagentRun: true,
status: "done",
}),
]),
settings: { chatFollowUpMode: "steer" },
});
await handleSendChat(host);
await waitForFast(() => expect(host.request).toHaveBeenCalled());
const payload = findRequestPayload(host.request, "chat.send", "chat send payload");
expect(payload.message).toBe("start another parent turn");
expect(payload).not.toHaveProperty("queueMode");
expect(payload).not.toHaveProperty("expectedRunId");
expect(host.chatError).toBeNull();
});
it("keeps a steered message visible when only the session row reports an active run", async () => {
let wireRunId: unknown;
+2
View File
@@ -9,6 +9,7 @@ import {
CHAT_RUN_STATUS_TOAST_DURATION_MS,
handleAbortChat,
hasAbortableSessionRun,
hasDirectSessionRun,
reconcileChatRunFromCurrentSessionRow,
reconcileChatRunFromSessionRow,
reconcileChatRunLifecycle,
@@ -80,6 +81,7 @@ describe("handleAbortChat", () => {
]),
});
expect(hasDirectSessionRun(host)).toBe(false);
expect(hasAbortableSessionRun(host)).toBe(true);
await handleAbortChat(host);
+19 -7
View File
@@ -140,23 +140,35 @@ export function isChatBusy(host: { chatSending?: boolean; chatRunId?: string | n
return Boolean(host.chatSending || host.chatRunId);
}
export function hasAbortableSessionRun(host: {
type SessionRunHost = {
chatRunId?: string | null;
sessionKey: string;
sessionsResult?: SessionsListResult | null;
}): boolean {
if (host.chatRunId) {
return true;
}
};
export function hasDirectSessionRun(host: SessionRunHost): boolean {
return Boolean(
host.chatRunId ||
host.sessionsResult?.sessions.some(
(session) =>
areUiSessionKeysEquivalent(session.key, host.sessionKey) &&
(isSessionRunActive(session) || session.hasActiveSubagentRun === true),
areUiSessionKeysEquivalent(session.key, host.sessionKey) && isSessionRunActive(session),
),
);
}
export function hasAbortableSessionRun(host: SessionRunHost): boolean {
return (
hasDirectSessionRun(host) ||
Boolean(
host.sessionsResult?.sessions.some(
(session) =>
areUiSessionKeysEquivalent(session.key, host.sessionKey) &&
session.hasActiveSubagentRun === true,
),
)
);
}
export function isChatStopCommand(text: string) {
return CHAT_STOP_COMMANDS.has(normalizeLowercaseStringOrEmpty(text.trim()));
}
+2 -2
View File
@@ -41,7 +41,7 @@ import {
isQueuedMessageBeingEdited,
QUEUED_MESSAGE_STEER_CONFLICT_ERROR,
} from "./queued-message-edit.ts";
import { hasAbortableSessionRun } from "./run-lifecycle.ts";
import { hasDirectSessionRun } from "./run-lifecycle.ts";
import { scheduleChatScroll, type ChatScrollHost } from "./scroll.ts";
import { appendChatMessageToCache, readChatMessagesFromCache } from "./session-message-cache.ts";
import {
@@ -368,7 +368,7 @@ export async function sendQueuedChatMessageWithQueueMode(
queueMode: QueueMode | undefined,
dependencies: SteerSendDependencies,
): Promise<void> {
if (!host.connected || !hasAbortableSessionRun(host)) {
if (!host.connected || !hasDirectSessionRun(host)) {
return;
}
const isSteer = queueMode === "steer";