diff --git a/packages/gateway-protocol/src/schema/chat-history-constants.ts b/packages/gateway-protocol/src/schema/chat-history-constants.ts new file mode 100644 index 000000000000..9926ff867ca7 --- /dev/null +++ b/packages/gateway-protocol/src/schema/chat-history-constants.ts @@ -0,0 +1,2 @@ +/** Largest history page accepted by the Gateway wire contract. */ +export const CHAT_HISTORY_MAX_ENTRIES = 1000; diff --git a/packages/gateway-protocol/src/schema/logs-chat.test.ts b/packages/gateway-protocol/src/schema/logs-chat.test.ts index 47c065fede8f..94211bb53429 100644 --- a/packages/gateway-protocol/src/schema/logs-chat.test.ts +++ b/packages/gateway-protocol/src/schema/logs-chat.test.ts @@ -1,7 +1,12 @@ // Gateway Protocol tests cover typed chat stream events. import { Value } from "typebox/value"; import { describe, expect, it } from "vitest"; -import { ChatEventSchema, ChatSendParamsSchema, ChatStatusEventSchema } from "./logs-chat.js"; +import { + ChatEventSchema, + ChatHistoryParamsSchema, + ChatSendParamsSchema, + ChatStatusEventSchema, +} from "./logs-chat.js"; const statusEvent = { runId: "run-1", @@ -11,6 +16,15 @@ const statusEvent = { phase: "preparing_context", } as const; +describe("ChatHistoryParamsSchema", () => { + it("accepts the history boundary and rejects larger requests", () => { + const request = { sessionKey: "agent:main:main" }; + + expect(Value.Check(ChatHistoryParamsSchema, { ...request, limit: 1000 })).toBe(true); + expect(Value.Check(ChatHistoryParamsSchema, { ...request, limit: 1001 })).toBe(false); + }); +}); + describe("ChatStatusEventSchema", () => { it("accepts closed startup phases through the chat event union", () => { expect(Value.Check(ChatStatusEventSchema, statusEvent)).toBe(true); diff --git a/packages/gateway-protocol/src/schema/logs-chat.ts b/packages/gateway-protocol/src/schema/logs-chat.ts index e70911f6143f..d37978fc9d68 100644 --- a/packages/gateway-protocol/src/schema/logs-chat.ts +++ b/packages/gateway-protocol/src/schema/logs-chat.ts @@ -1,6 +1,7 @@ // Gateway Protocol schema module defines protocol validation shapes. import type { Static } from "typebox"; import { Type } from "typebox"; +import { CHAT_HISTORY_MAX_ENTRIES } from "./chat-history-constants.js"; import { closedObject } from "./closed-object.js"; import { ChatSendSessionKeyString, InputProvenanceSchema, NonEmptyString } from "./primitives.js"; @@ -25,7 +26,7 @@ export const LogsTailResultSchema = closedObject({ export const ChatHistoryParamsSchema = closedObject({ sessionKey: NonEmptyString, agentId: Type.Optional(NonEmptyString), - limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 1000 })), + limit: Type.Optional(Type.Integer({ minimum: 1, maximum: CHAT_HISTORY_MAX_ENTRIES })), offset: Type.Optional(Type.Integer({ minimum: 0 })), messageId: Type.Optional(NonEmptyString), sessionId: Type.Optional(NonEmptyString), diff --git a/src/cli/program.smoke.test.ts b/src/cli/program.smoke.test.ts index 6c59fb1471c5..15c3c39812b1 100644 --- a/src/cli/program.smoke.test.ts +++ b/src/cli/program.smoke.test.ts @@ -58,9 +58,11 @@ describe("cli program (smoke)", () => { await runProgram(["tui", "--timeout-ms", "45000"]); const options = firstMockArg(runTui) as { timeoutMs?: number; + historyLimit?: number; forceProcessExitOnReturn?: boolean; }; expect(options?.timeoutMs).toBe(45000); + expect(options?.historyLimit).toBe(200); expect(options?.forceProcessExitOnReturn).toBe(true); }); @@ -92,6 +94,30 @@ describe("cli program (smoke)", () => { expect(runTui).not.toHaveBeenCalled(); }); + it("accepts the maximum Gateway tui history limit", async () => { + await runProgram(["tui", "--history-limit", "1000"]); + + expect(firstMockArg(runTui)).toMatchObject({ local: false, historyLimit: 1000 }); + }); + + it.each([ + { entryPoint: "tui --local", args: ["tui", "--local"] }, + { entryPoint: "terminal", args: ["terminal"] }, + { entryPoint: "chat", args: ["chat"] }, + ])("preserves oversized history limits for local $entryPoint", async ({ args }) => { + await runProgram([...args, "--history-limit", "1001"]); + + expect(firstMockArg(runTui)).toMatchObject({ local: true, historyLimit: 1001 }); + expect(runtime.error).not.toHaveBeenCalled(); + }); + + it("rejects tui history limits above the Gateway maximum", async () => { + await expect(runProgram(["tui", "--history-limit", "1001"])).rejects.toThrow("exit"); + + expect(runtime.error).toHaveBeenCalledWith("Error: --history-limit must be at most 1000."); + expect(runTui).not.toHaveBeenCalled(); + }); + it("runs setup wizard when wizard flags are present", async () => { await runProgram(["setup", "--remote-url", "ws://example"]); diff --git a/src/cli/tui-cli.ts b/src/cli/tui-cli.ts index 1ed0396dfd26..3187493b05c5 100644 --- a/src/cli/tui-cli.ts +++ b/src/cli/tui-cli.ts @@ -1,5 +1,6 @@ // Registers the terminal UI subcommand and normalizes its local-vs-gateway options. import type { Command } from "commander"; +import { CHAT_HISTORY_MAX_ENTRIES } from "../../packages/gateway-protocol/src/schema/chat-history-constants.js"; import { formatDocsLink } from "../../packages/terminal-core/src/links.js"; import { theme } from "../../packages/terminal-core/src/theme.js"; import { parseStrictPositiveInteger } from "../infra/parse-finite-number.js"; @@ -51,6 +52,9 @@ export function registerTuiCli(program: Command) { if (historyLimit === undefined) { throw new Error("--history-limit must be a positive integer."); } + if (!isLocal && historyLimit > CHAT_HISTORY_MAX_ENTRIES) { + throw new Error(`--history-limit must be at most ${CHAT_HISTORY_MAX_ENTRIES}.`); + } const { runTui } = await import("../tui/tui.js"); await runTui({ local: isLocal, diff --git a/src/gateway/server-methods/chat-history-handler.ts b/src/gateway/server-methods/chat-history-handler.ts index 6c29a8c80911..7a206d49d473 100644 --- a/src/gateway/server-methods/chat-history-handler.ts +++ b/src/gateway/server-methods/chat-history-handler.ts @@ -9,6 +9,7 @@ import { validateChatHistoryParams, validateChatMetadataParams, } from "../../../packages/gateway-protocol/src/index.js"; +import { CHAT_HISTORY_MAX_ENTRIES } from "../../../packages/gateway-protocol/src/schema/chat-history-constants.js"; import { listAgentIds, resolveDefaultAgentId, @@ -385,7 +386,7 @@ async function handleChatHistoryRequest({ requestedSessionId && requestedSessionId !== entry?.sessionId ? undefined : entry; const resolvedSessionModel = resolveSessionModelRef(cfg, entry, sessionAgentId); const requested = typeof limit === "number" ? limit : 200; - const max = Math.min(1000, requested); + const max = Math.min(CHAT_HISTORY_MAX_ENTRIES, requested); const maxHistoryBytes = getMaxChatHistoryMessagesBytes(); const effectiveMaxChars = resolveEffectiveChatHistoryMaxChars(cfg, maxChars); let historyPage: Awaited>; diff --git a/src/tui/embedded-backend.ts b/src/tui/embedded-backend.ts index e04b7457c471..aac0536ec528 100644 --- a/src/tui/embedded-backend.ts +++ b/src/tui/embedded-backend.ts @@ -1,6 +1,7 @@ // Implements the embedded backend used by local TUI sessions. import { randomUUID } from "node:crypto"; import type { SessionsPatchResult } from "../../packages/gateway-protocol/src/index.js"; +import { CHAT_HISTORY_MAX_ENTRIES } from "../../packages/gateway-protocol/src/schema/chat-history-constants.js"; import { agentCommandFromIngress } from "../agents/agent-command.js"; import { isAgentLifecycleYieldedWaiting } from "../agents/agent-lifecycle-parent-state.js"; import { @@ -621,7 +622,10 @@ export class EmbeddedTuiBackend implements TuiBackend { this.runtimePluginRegistry = runtimePluginsPrewarm.status === "warmed" ? runtimePluginsPrewarm.registry : undefined; const resolvedSessionModel = resolveSessionModelRef(cfg, entry, sessionAgentId); - const max = Math.min(1000, typeof opts.limit === "number" ? opts.limit : 200); + const max = Math.min( + CHAT_HISTORY_MAX_ENTRIES, + typeof opts.limit === "number" ? opts.limit : 200, + ); const maxHistoryBytes = getMaxChatHistoryMessagesBytes(); const effectiveMaxChars = resolveEffectiveChatHistoryMaxChars(cfg); const historyPage = await readChatHistoryPage({