fix(onboard): scope hatch timeout to initial turn (#130010)

Amp-Thread-ID: https://ampcode.com/threads/T-01a021f6-157a-707a-aab4-df4ff9e0ae98

Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
Peter Steinberger
2026-08-26 03:19:04 -07:00
committed by GitHub
parent 49d916e8e9
commit 869d2073f6
6 changed files with 33 additions and 11 deletions
+19 -2
View File
@@ -24,7 +24,7 @@ import {
type TuiPendingSubmit,
} from "./tui-submit-state.js";
import { createEditorSubmitHandler, createSubmitBurstCoalescer } from "./tui-submit.js";
import type { SessionInfo } from "./tui-types.js";
import type { SessionInfo, TuiOptions } from "./tui-types.js";
type LoadHistoryMock = ReturnType<typeof vi.fn> & (() => Promise<void>);
type RunAuthFlow = NonNullable<Parameters<typeof createCommandHandlers>[0]["runAuthFlow"]>;
@@ -115,7 +115,7 @@ function createHarness(params?: {
activeChatRunId?: string | null;
pendingSubmit?: TuiPendingSubmit | null;
activityStatus?: string;
opts?: { local?: boolean };
opts?: Pick<TuiOptions, "local" | "timeoutMs">;
currentSessionId?: string | null;
sessionGeneration?: number;
currentAgentId?: string;
@@ -582,6 +582,23 @@ describe("tui command handlers", () => {
expect(requestRender).toHaveBeenCalled();
});
it("scopes an explicit timeout override to one message", async () => {
const { handleCommand, sendMessage, sendChat, state } = createHarness();
await sendMessage("automatic hatch", 300_000);
state.pendingSubmit = null;
await handleCommand("later interactive turn");
expect(sendChat).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ message: "automatic hatch", timeoutMs: 300_000 }),
);
expect(sendChat).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ message: "later interactive turn", timeoutMs: undefined }),
);
});
it("projects the canonical pending user before chat.send is acknowledged", async () => {
const deferred = createDeferred<{ runId: string }>();
const sendChat = vi.fn(() => deferred.promise);
+2 -2
View File
@@ -886,7 +886,7 @@ export function createCommandHandlers(context: CommandHandlerContext) {
tui.requestRender();
};
const sendMessage = async (text: string) => {
const sendMessage = async (text: string, timeoutMs = opts.timeoutMs) => {
const admission = resolveMessageAdmission(text);
if (admission.status === "blocked") {
reportBlockedMessageSubmit(text, admission);
@@ -942,7 +942,7 @@ export function createCommandHandlers(context: CommandHandlerContext) {
message: text,
thinking: opts.thinking,
deliver: deliverDefault,
timeoutMs: opts.timeoutMs,
timeoutMs,
runId,
});
const acceptedRunId = sendResult.runId || runId;
+2
View File
@@ -17,6 +17,8 @@ export type TuiOptions = {
timeoutMs?: number;
historyLimit?: number;
message?: string;
/** Overrides timeoutMs only for the message sent automatically at startup. */
initialMessageTimeoutMs?: number;
/**
* Internal CLI guard: after the standalone TUI returns, force the child
* process out if imported runtime handles keep the event loop alive.
+1 -1
View File
@@ -1858,7 +1858,7 @@ async function runTuiUnlocked(opts: RunTuiOptions): Promise<TuiResult> {
scheduleDynamicSlashCommandsRefresh();
if (!state.autoMessageSent && autoMessage) {
state.autoMessageSent = true;
await sendMessage(autoMessage);
await sendMessage(autoMessage, opts.initialMessageTimeoutMs);
if (!ownsConnection()) {
return;
}
+8 -5
View File
@@ -604,7 +604,7 @@ describe("finalizeSetupWizard", () => {
local: true,
deliver: false,
message: undefined,
timeoutMs: 300_000,
initialMessageTimeoutMs: 300_000,
});
});
@@ -669,6 +669,8 @@ describe("finalizeSetupWizard", () => {
const tuiOptions = runTui.mock.calls.at(-1)?.[0] as Record<string, unknown>;
expect(tuiOptions).not.toHaveProperty("url");
expect(tuiOptions).not.toHaveProperty("token");
expect(tuiOptions).toMatchObject({ initialMessageTimeoutMs: 300_000 });
expect(tuiOptions).not.toHaveProperty("timeoutMs");
});
it.each([
@@ -867,7 +869,7 @@ describe("finalizeSetupWizard", () => {
local: true,
deliver: false,
message: "Wake up, my friend!",
timeoutMs: 300_000,
initialMessageTimeoutMs: 300_000,
});
});
@@ -1026,7 +1028,7 @@ describe("finalizeSetupWizard", () => {
local: true,
deliver: false,
message: undefined,
timeoutMs: 300_000,
initialMessageTimeoutMs: 300_000,
});
});
@@ -1073,7 +1075,7 @@ describe("finalizeSetupWizard", () => {
local: true,
deliver: false,
message: "醒醒,我的朋友!",
timeoutMs: 300_000,
initialMessageTimeoutMs: 300_000,
});
} finally {
if (previousLocale === undefined) {
@@ -2013,9 +2015,10 @@ describe("finalizeSetupWizard", () => {
},
deliver: false,
message: undefined,
timeoutMs: 300_000,
initialMessageTimeoutMs: 300_000,
}),
);
expect(runTui.mock.calls.at(-1)?.[0]).not.toHaveProperty("timeoutMs");
expect(sessionGateway.close).toHaveBeenCalledWith({ reason: "onboarding tui exited" });
expect(cancelProcessExitAfterTuiReturn).toHaveBeenCalledWith(setupCleanupExitTimer);
expect(scheduleProcessExitAfterTuiReturn).toHaveBeenCalledTimes(2);
+1 -1
View File
@@ -1038,7 +1038,7 @@ export async function finalizeSetupWizard(
message: shouldSeedBootstrapHatch
? t("wizard.finalize.bootstrapHatchMessage")
: undefined,
timeoutMs: HATCH_TUI_TIMEOUT_MS,
initialMessageTimeoutMs: HATCH_TUI_TIMEOUT_MS,
});
} finally {
restoreTerminalState("post-setup tui", { resumeStdinIfPaused: false });