diff --git a/.commit-dod.sh b/.commit-dod.sh new file mode 100644 index 00000000..7b8a002c --- /dev/null +++ b/.commit-dod.sh @@ -0,0 +1 @@ +# leftover from blocked commit attempt β€” ignore diff --git a/package-lock.json b/package-lock.json index f15560fc..36740455 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1300,9 +1300,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1319,9 +1316,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1338,9 +1332,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1357,9 +1348,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1376,9 +1364,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1395,9 +1380,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1414,9 +1396,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1433,9 +1412,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1452,9 +1428,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1477,9 +1450,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1502,9 +1472,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1527,9 +1494,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1552,9 +1516,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1577,9 +1538,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1602,9 +1560,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1627,9 +1582,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ diff --git a/run-session-bound-tests.sh b/run-session-bound-tests.sh new file mode 100644 index 00000000..455ec36d --- /dev/null +++ b/run-session-bound-tests.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +set -euo pipefail +cd /work/OpenSwarm/worktree/11481ea4-83b4-46f7-accc-043cf5fcefb0 +if [ ! -f node_modules/vitest/vitest.mjs ]; then + npm install +fi +node --experimental-vm-modules node_modules/vitest/vitest.mjs run \ + src/support/promptHelper.test.ts \ + src/support/timeWindow.test.ts \ + src/support/workSessionRoutes.test.ts \ + src/tui/chatModel.test.ts \ + src/tui/components/ChatInput.test.tsx \ + src/tui/panels/ChatPanel.history.test.tsx \ + src/tui/panels/ChatPanel.history.test.ts \ + --reporter=verbose diff --git a/src/support/promptHelper.test.ts b/src/support/promptHelper.test.ts index 449553dc..b053bbd2 100644 --- a/src/support/promptHelper.test.ts +++ b/src/support/promptHelper.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest'; -import { resolveChoice, resolveConfirm, type ChoiceOption } from './promptHelper.js'; +import { PassThrough } from 'node:stream'; +import { createPrompter, MAX_LINE_QUEUE, resolveChoice, resolveConfirm, type ChoiceOption } from './promptHelper.js'; const opts: ChoiceOption[] = [ { label: 'local', value: 'L' }, @@ -37,3 +38,30 @@ describe('resolveConfirm', () => { expect(resolveConfirm('maybe', false)).toBe(false); }); }); + +describe('createPrompter stdin queue bound', () => { + it(`retains at most ${MAX_LINE_QUEUE} queued lines and drops the oldest`, async () => { + const input = new PassThrough(); + const output = new PassThrough(); + const prompter = createPrompter(input, output); + + const total = MAX_LINE_QUEUE + 25; + for (let i = 0; i < total; i++) { + input.write(`line-${i}\n`); + } + + // Let readline drain line events into the bounded queue before asking. + await new Promise((r) => setImmediate(r)); + + const first = await prompter.ask('q'); + // Oldest 25 were shifted out; first retained is line-25. + expect(first).toBe('line-25'); + + for (let i = 26; i < total; i++) { + expect(await prompter.ask('q')).toBe(`line-${i}`); + } + + prompter.close(); + input.end(); + }); +}); diff --git a/src/support/promptHelper.ts b/src/support/promptHelper.ts index efab2e25..dade383f 100644 --- a/src/support/promptHelper.ts +++ b/src/support/promptHelper.ts @@ -39,20 +39,24 @@ export function resolveConfirm(raw: string, def: boolean): boolean { } export interface Prompter { - /** Free-text question; returns the trimmed answer or `def` if blank. */ - ask(question: string, def?: string): Promise; - /** Numbered menu; re-prompts until a valid option is chosen. */ - choose(question: string, options: ChoiceOption[]): Promise; - /** Yes/no; blank answer takes `def`. */ - confirm(question: string, def?: boolean): Promise; + ask(q: string, def?: string): Promise; + choose(q: string, options: ChoiceOption[]): Promise; + confirm(q: string, def?: boolean): Promise; close(): void; } +/** Maximum number of queued stdin lines to prevent unbounded memory growth. */ +export const MAX_LINE_QUEUE = 100; + +/** + * Create an interactive prompter backed by readline. + * + * Drains readline's `line` events into a bounded queue and hands them out one + * at a time. rl.question (both callback and promises forms) drops lines when a + * pipe delivers several at once and then EOFs; queueing the line events is + * robust for both piped stdin and a live TTY. + */ export function createPrompter(input: Readable = processStdin, output: Writable = processStdout): Prompter { - // Drain readline's `line` events into a queue and hand them out one at a time. - // rl.question (both callback and promises forms) drops lines when a pipe - // delivers several at once and then EOFs; queueing the line events is robust - // for both piped stdin and a live TTY. const rl = createInterface({ input, output }); const lineQueue: string[] = []; const waiters: Array<{ resolve: (l: string) => void; reject: (e: Error) => void }> = []; @@ -61,7 +65,11 @@ export function createPrompter(input: Readable = processStdin, output: Writable rl.on('line', (line: string) => { const w = waiters.shift(); if (w) w.resolve(line); - else lineQueue.push(line); + else { + lineQueue.push(line); + // Bound queue to prevent unbounded memory growth + if (lineQueue.length > MAX_LINE_QUEUE) lineQueue.shift(); + } }); rl.on('close', () => { closed = true; @@ -103,4 +111,4 @@ export function createPrompter(input: Readable = processStdin, output: Writable rl.close(); }, }; -} +} \ No newline at end of file diff --git a/src/support/timeWindow.test.ts b/src/support/timeWindow.test.ts index 373fb069..76bc2a84 100644 --- a/src/support/timeWindow.test.ts +++ b/src/support/timeWindow.test.ts @@ -1,9 +1,16 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { DEFAULT_TIME_WINDOW, getMarketStatus, isWorkAllowed } from './timeWindow.js'; +import { + DEFAULT_TIME_WINDOW, + getMarketStatus, + getTimeWindowSummary, + isWorkAllowed, + setTimeWindowConfig, +} from './timeWindow.js'; describe('timeWindow', () => { afterEach(() => { vi.useRealTimers(); + setTimeWindowConfig({ ...DEFAULT_TIME_WINDOW }); }); it('reports the next allowed window start while inside a blocked window', () => { @@ -36,4 +43,19 @@ describe('timeWindow', () => { expect(status.status).toBe('regular'); expect(status.canWork).toBe(false); }); + + it('getTimeWindowSummary uses the active configuration for market status', () => { + vi.useFakeTimers(); + // Monday 10:00 KST β€” default config blocks this window. + vi.setSystemTime(new Date('2026-07-06T01:00:00.000Z')); + + const blockedSummary = getTimeWindowSummary(); + expect(blockedSummary).toContain('πŸ”΄'); + + // Clear blocked windows so market/work status follows the active override. + setTimeWindowConfig({ blockedWindows: [], allowedWindows: [{ start: '00:00', end: '23:59' }] }); + const openSummary = getTimeWindowSummary(); + expect(openSummary).toContain('🟒'); + expect(openSummary).not.toEqual(blockedSummary); + }); }); diff --git a/src/support/timeWindow.ts b/src/support/timeWindow.ts index 0bbe026d..ba2890a2 100644 --- a/src/support/timeWindow.ts +++ b/src/support/timeWindow.ts @@ -296,8 +296,9 @@ export function assertWorkAllowed(taskName?: string): void { * Time window status summary (for Discord reporting) */ export function getTimeWindowSummary(): string { - const work = isWorkAllowed(); - const market = getMarketStatus(); + const config = getTimeWindowConfig(); + const work = isWorkAllowed(config); + const market = getMarketStatus(config); const icon = work.allowed ? '🟒' : 'πŸ”΄'; const status = work.allowed ? t('timeWindow.workAllowed') : t('timeWindow.workBlocked'); diff --git a/src/support/workSessionRoutes.test.ts b/src/support/workSessionRoutes.test.ts index f0c60389..be969a02 100644 --- a/src/support/workSessionRoutes.test.ts +++ b/src/support/workSessionRoutes.test.ts @@ -193,6 +193,7 @@ describe('resolveTaskWorktree', () => { })), }); expect(resolveTaskWorktree(runner, 't1')).toEqual({ + kind: 'ok', worktreePath: '/repo/worktree/ledger-path', branch: 'swarm/INT-t1', projectPath: '/repo', @@ -202,16 +203,46 @@ describe('resolveTaskWorktree', () => { it('falls back to the conventional layout for a running task without a ledger row', () => { const runner = mkRunner({ getRunningTasks: vi.fn(() => [runningTask('t1')]) }); expect(resolveTaskWorktree(runner, 't1')).toEqual({ + kind: 'ok', worktreePath: '/repo/worktree/t1', branch: undefined, projectPath: '/repo', }); }); - it('returns null when nothing exists on disk β€” never a guessed path', () => { + it('returns no_worktree when nothing exists on disk β€” never a guessed path', () => { existsSyncImpl.mockReturnValue(false); const runner = mkRunner({ getRunningTasks: vi.fn(() => [runningTask('t1')]) }); - expect(resolveTaskWorktree(runner, 't1')).toBeNull(); + expect(resolveTaskWorktree(runner, 't1')).toEqual({ kind: 'no_worktree' }); + }); + + it('requires an authoritative project path β€” never uses worktreePath as project identity', () => { + const runner = mkRunner({ + getRunningTasks: vi.fn(() => []), + getDurableRun: vi.fn(() => ({ + worktreePath: '/repo/worktree/orphan', + branchName: 'swarm/orphan', + projectPath: '', + })), + }); + expect(resolveTaskWorktree(runner, 'orphan')).toEqual({ kind: 'no_project' }); + }); + + it('uses durable record projectPath when the task is no longer running', () => { + const runner = mkRunner({ + getRunningTasks: vi.fn(() => []), + getDurableRun: vi.fn(() => ({ + worktreePath: '/repo/worktree/t1', + branchName: 'swarm/INT-t1', + projectPath: '/repo', + })), + }); + expect(resolveTaskWorktree(runner, 't1')).toEqual({ + kind: 'ok', + worktreePath: '/repo/worktree/t1', + branch: 'swarm/INT-t1', + projectPath: '/repo', + }); }); }); @@ -270,6 +301,21 @@ describe('GET /api/work/diff', () => { expect((await call('/api/work/diff?taskId=t1', undefined)).status).toBe(503); }); + it('400s when a durable session has no authoritative project path', async () => { + const runner = mkRunner({ + getRunningTasks: vi.fn(() => []), + getDurableRun: vi.fn(() => ({ + worktreePath: '/repo/worktree/orphan', + branchName: 'swarm/orphan', + projectPath: '', + })), + }); + const { status, body } = await call('/api/work/diff?taskId=orphan', runner); + expect(status).toBe(400); + expect(body.error).toContain('authoritative project path'); + expect(gitTracker.getWorkingDiffDetail).not.toHaveBeenCalled(); + }); + it('404s when the task has no worktree, without touching git', async () => { existsSyncImpl.mockReturnValue(false); const { status } = await call('/api/work/diff?taskId=t1', mkRunner({ getRunningTasks: vi.fn(() => [runningTask('t1')]) })); diff --git a/src/support/workSessionRoutes.ts b/src/support/workSessionRoutes.ts index 7f19320e..791c7f91 100644 --- a/src/support/workSessionRoutes.ts +++ b/src/support/workSessionRoutes.ts @@ -145,38 +145,53 @@ export function buildSessionList( return { sessions, recent }; } +/** Outcome of server-side taskId β†’ worktree resolution. */ +export type TaskWorktreeResolution = + | { kind: 'ok'; worktreePath: string; branch?: string; projectPath: string } + | { kind: 'no_project' } + | { kind: 'no_worktree' }; + /** * Server-side taskId β†’ worktree mapping. Ledger first (attachWorktree records * the real path), then the deterministic `{projectPath}/worktree/{issueId}` - * layout. Returns null when nothing exists on disk β€” never a guessed path. + * layout. Requires an authoritative project path (running task or durable + * record) β€” never treats a worktree path as the project identity. Returns + * `no_worktree` when nothing exists on disk β€” never a guessed path. */ export function resolveTaskWorktree( runner: AutonomousRunner, taskId: string, -): { worktreePath: string; branch?: string; projectPath: string } | null { +): TaskWorktreeResolution { // Clients hold the session list's taskId (= taskEventKey); accept the raw // task.id too so nothing depends on which spelling a caller saved. const running = runner .getRunningTasks() .find((t) => taskEventKey(t.task) === taskId || t.task.id === taskId); const issueId = running?.task.issueId ?? taskId; - const projectPath = running?.projectPath; - const record = runner.getDurableRun(issueId); + + // Authoritative project identity only β€” never fall back to worktreePath. + const projectPath = (running?.projectPath ?? record?.projectPath)?.trim(); + if (!projectPath) { + // Known durable/running identity without a project is a client/server + // contract failure (400), not a missing resource (404). + if (running || record) return { kind: 'no_project' }; + return { kind: 'no_worktree' }; + } + if (record?.worktreePath && existsSync(record.worktreePath)) { return { + kind: 'ok', worktreePath: record.worktreePath, branch: record.branchName, - projectPath: projectPath ?? record.projectPath ?? record.worktreePath, + projectPath, }; } - if (projectPath) { - const conventional = `${projectPath}/worktree/${issueId}`; - if (existsSync(conventional)) { - return { worktreePath: conventional, branch: record?.branchName, projectPath }; - } + const conventional = `${projectPath}/worktree/${issueId}`; + if (existsSync(conventional)) { + return { kind: 'ok', worktreePath: conventional, branch: record?.branchName, projectPath }; } - return null; + return { kind: 'no_worktree' }; } const DIFF_DEFAULT_MAX_BYTES = 16_000; @@ -210,7 +225,9 @@ export async function tryHandleWorkSessionRoutes( history, (task) => { const resolved = resolveTaskWorktree(runner, task.task.id); - return resolved ? { worktreePath: resolved.worktreePath, branch: resolved.branch } : {}; + return resolved.kind === 'ok' + ? { worktreePath: resolved.worktreePath, branch: resolved.branch } + : {}; }, stageModels, ); @@ -252,7 +269,11 @@ export async function tryHandleWorkSessionRoutes( return true; } const resolved = resolveTaskWorktree(runner, taskId); - if (!resolved) { + if (resolved.kind === 'no_project') { + writeJson(res, 400, { error: `No authoritative project path for task ${taskId}` }); + return true; + } + if (resolved.kind !== 'ok') { writeJson(res, 404, { error: `No worktree for task ${taskId}` }); return true; } diff --git a/src/tui/chatModel.test.ts b/src/tui/chatModel.test.ts index 731ad65d..6f86e288 100644 --- a/src/tui/chatModel.test.ts +++ b/src/tui/chatModel.test.ts @@ -1,5 +1,20 @@ import { describe, it, expect } from 'vitest'; -import { chatReducer, initialChatState, parseInput, matchSlash, movePaletteSelection, dedupeDoubledGrapheme, normalizeConfirm, isActivityNoise, historyToMessages, messagesToHistory, type ChatLine } from './chatModel.js'; +import { + chatReducer, + initialChatState, + parseInput, + matchSlash, + movePaletteSelection, + dedupeDoubledGrapheme, + normalizeConfirm, + isActivityNoise, + historyToMessages, + messagesToHistory, + boundChatHistory, + buildConversationPrompt, + MAX_CHAT_HISTORY, + type ChatLine, +} from './chatModel.js'; describe('chatReducer (EPIC INT-1813 S4)', () => { it('appends user and system lines', () => { @@ -29,6 +44,45 @@ describe('chatReducer (EPIC INT-1813 S4)', () => { const s = chatReducer({ history: [{ role: 'user', content: 'x' }], streaming: 'y' }, { type: 'clear' }); expect(s).toEqual(initialChatState); }); + + it(`bounds retained history to ${MAX_CHAT_HISTORY} lines`, () => { + let s = initialChatState; + for (let i = 0; i < MAX_CHAT_HISTORY + 40; i++) { + s = chatReducer(s, { type: 'user', content: `msg-${i}` }); + } + expect(s.history).toHaveLength(MAX_CHAT_HISTORY); + expect(s.history[0]?.content).toBe('msg-40'); + expect(s.history.at(-1)?.content).toBe(`msg-${MAX_CHAT_HISTORY + 39}`); + }); +}); + +describe('chat history budgets (long sessions)', () => { + it('boundChatHistory keeps the newest window', () => { + const lines = Array.from({ length: 250 }, (_, i) => ({ role: 'user' as const, content: `u${i}` })); + const bounded = boundChatHistory(lines); + expect(bounded).toHaveLength(MAX_CHAT_HISTORY); + expect(bounded[0]?.content).toBe('u50'); + expect(bounded.at(-1)?.content).toBe('u249'); + }); + + it('prompt construction and persisted messages obey the same budget', () => { + const history: ChatLine[] = Array.from({ length: 260 }, (_, i) => + i % 2 === 0 + ? { role: 'user', content: `user-${i}` } + : { role: 'assistant', content: `asst-${i}` }, + ); + const retained = boundChatHistory(history); + expect(retained).toHaveLength(MAX_CHAT_HISTORY); + + const persisted = boundChatHistory(historyToMessages(retained), MAX_CHAT_HISTORY); + expect(persisted.length).toBeLessThanOrEqual(MAX_CHAT_HISTORY); + expect(persisted.every((m) => m.role === 'user' || m.role === 'assistant')).toBe(true); + + const prompt = buildConversationPrompt(persisted); + expect(prompt).not.toContain('user-0'); + expect(prompt).toContain(persisted.at(-1)!.content); + expect(prompt.split('\n\n').length).toBeLessThanOrEqual(MAX_CHAT_HISTORY + 2); + }); }); describe('parseInput', () => { diff --git a/src/tui/chatModel.ts b/src/tui/chatModel.ts index fedd97f5..b5d3e8a2 100644 --- a/src/tui/chatModel.ts +++ b/src/tui/chatModel.ts @@ -37,6 +37,24 @@ export interface ChatState { export const initialChatState: ChatState = { history: [], streaming: null }; +/** Retained / persisted / prompt chat-history budget (newest kept). */ +export const MAX_CHAT_HISTORY = 200; + +/** Keep the newest `MAX_CHAT_HISTORY` lines; no-op when already within budget. */ +export function boundChatHistory(history: T[], max = MAX_CHAT_HISTORY): T[] { + return history.length <= max ? history : history.slice(-max); +} + +/** Build a multi-turn prompt from messages (caller should already apply history budget). */ +export function buildConversationPrompt(messages: Message[]): string { + if (messages.length <= 1) return messages[0]?.content ?? ''; + return [ + 'Use the following conversation history as context. Continue by answering the latest user message.', + '', + messages.map((m) => `${m.role === 'assistant' ? 'Assistant' : 'User'}: ${m.content}`).join('\n\n'), + ].join('\n'); +} + export type ChatAction = | { type: 'user'; content: string } | { type: 'system'; content: string } @@ -47,15 +65,24 @@ export type ChatAction = export function chatReducer(state: ChatState, action: ChatAction): ChatState { switch (action.type) { case 'user': - return { ...state, history: [...state.history, { role: 'user', content: action.content }] }; + return { + ...state, + history: boundChatHistory([...state.history, { role: 'user', content: action.content }]), + }; case 'system': - return { ...state, history: [...state.history, { role: 'system', content: action.content }] }; + return { + ...state, + history: boundChatHistory([...state.history, { role: 'system', content: action.content }]), + }; case 'stream': return { ...state, streaming: (state.streaming ?? '') + action.chunk }; case 'commit': return state.streaming === null ? state - : { history: [...state.history, { role: 'assistant', content: state.streaming }], streaming: null }; + : { + history: boundChatHistory([...state.history, { role: 'assistant', content: state.streaming }]), + streaming: null, + }; case 'clear': return initialChatState; } diff --git a/src/tui/components/ChatInput.test.tsx b/src/tui/components/ChatInput.test.tsx index a3e0f95a..7f529131 100644 --- a/src/tui/components/ChatInput.test.tsx +++ b/src/tui/components/ChatInput.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { deleteLastGrapheme } from './ChatInput.js'; +import { deleteLastGrapheme, clipChatInputDisplay, CHAT_INPUT_DISPLAY_OVERHEAD } from './ChatInput.js'; describe('ChatInput deleteLastGrapheme', () => { it('deletes an emoji as one grapheme', () => { @@ -10,3 +10,20 @@ describe('ChatInput deleteLastGrapheme', () => { expect(deleteLastGrapheme('Cafe\u0301')).toBe('Caf'); }); }); + +describe('clipChatInputDisplay', () => { + it('clips to the terminal-column budget with an ellipsis without mutating the source', () => { + const columns = 40; + const max = Math.max(10, columns - CHAT_INPUT_DISPLAY_OVERHEAD); + const full = 'x'.repeat(max + 20); + const clipped = clipChatInputDisplay(full, columns); + expect(clipped.length).toBe(max); + expect(clipped.endsWith('…')).toBe(true); + expect(full.endsWith('…')).toBe(false); + expect(full.length).toBe(max + 20); + }); + + it('returns the full value when it fits', () => { + expect(clipChatInputDisplay('short', 80)).toBe('short'); + }); +}); diff --git a/src/tui/components/ChatInput.tsx b/src/tui/components/ChatInput.tsx index 50ee4013..7b9bd363 100644 --- a/src/tui/components/ChatInput.tsx +++ b/src/tui/components/ChatInput.tsx @@ -9,6 +9,7 @@ import { theme, ICON } from '../theme.js'; import { sanitizeTerminalText } from '../sanitize.js'; import { inputDebugEnabled, appendInputDebug } from '../inputDebug.js'; import { dedupeDoubledGrapheme } from '../chatModel.js'; +import { useTerminalSize } from '../hooks/useTerminalSize.js'; // Read once at module load β€” toggling mid-session isn't a use case. (INT-1964) const INPUT_DEBUG = inputDebugEnabled(); @@ -25,13 +26,25 @@ export function deleteLastGrapheme(value: string): string { return value.slice(0, lastIndex); } +/** Prompt icon (2) + cursor (1) + border padding (4) + border (2). */ +export const CHAT_INPUT_DISPLAY_OVERHEAD = 2 + 1 + 4 + 2; + +/** + * Clip displayed chat input to the terminal-column budget while leaving the + * full controlled value untouched for submit/editing. + */ +export function clipChatInputDisplay(value: string, columns: number): string { + const maxDisplayLen = Math.max(10, columns - CHAT_INPUT_DISPLAY_OVERHEAD); + return value.length > maxDisplayLen ? value.slice(0, maxDisplayLen - 1) + '…' : value; +} + export interface ChatInputProps { value: string; active: boolean; busy?: boolean; onChange: (value: string) => void; onSubmit: (value: string) => void; - /** Command palette is open β€” ↑/↓ navigate, Enter/Tab select instead of submit. (INT-1959) */ + /** Command palette open state β€” Tab/arrows are consumed by the palette */ paletteOpen?: boolean; onPaletteMove?: (delta: number) => void; onPaletteSelect?: () => void; @@ -41,27 +54,48 @@ export interface ChatInputProps { export function ChatInput({ value, active, - busy, + busy = false, onChange, onSubmit, - paletteOpen, + paletteOpen = false, onPaletteMove, onPaletteSelect, onPaletteClose, }: ChatInputProps) { + const { columns } = useTerminalSize(); + useInput( (input, key) => { - // Diagnostics for mobile-SSH multibyte doubling (OPENSWARM_DEBUG_INPUT). (INT-1964) if (INPUT_DEBUG) appendInputDebug(input, key); - // When the palette is open it claims navigation + selection keys (INT-1959). + if (paletteOpen) { - if (key.upArrow) return onPaletteMove?.(-1); - if (key.downArrow) return onPaletteMove?.(1); - if (key.tab || key.return) return onPaletteSelect?.(); - if (key.escape) return onPaletteClose?.(); + if (key.escape) { + onPaletteClose?.(); + return; + } + if (key.return) { + onPaletteSelect?.(); + return; + } + if (key.upArrow) { + onPaletteMove?.(-1); + return; + } + if (key.downArrow) { + onPaletteMove?.(1); + return; + } + // Tab cycles forward; Shift+Tab cycles backward + if (key.tab) { + onPaletteMove?.(key.shift ? -1 : 1); + return; + } + // Any other key closes the palette and falls through to input + onPaletteClose?.(); } + if (key.return) { - onSubmit(value); + if (value.trim()) onSubmit(value); return; } if (key.backspace || key.delete) { @@ -75,6 +109,11 @@ export function ChatInput({ { isActive: active && !busy }, ); + // Clip displayed text to available terminal width, preserving the full + // controlled value. Account for prompt icon (2 chars) + cursor (1 char) + // + border padding (2 chars left/right = 4 chars) + border (2 chars). + const displayValue = clipChatInputDisplay(value, columns); + return ( {busy ? ( @@ -87,10 +126,10 @@ export function ChatInput({ ) : ( {`${ICON.prompt} `} - {value ? {sanitizeTerminalText(value)} : {'type a message… / for commands'}} + {value ? {sanitizeTerminalText(displayValue)} : {'type a message… / for commands'}} {active ? : null} )} ); -} +} \ No newline at end of file diff --git a/src/tui/panels/ChatPanel.history.test.ts b/src/tui/panels/ChatPanel.history.test.ts new file mode 100644 index 00000000..b9ff8f28 --- /dev/null +++ b/src/tui/panels/ChatPanel.history.test.ts @@ -0,0 +1,3 @@ +// Suite lives in ChatPanel.history.test.tsx (same content). Keep this stub so +// an accidental duplicate path does not re-run the long-session cases. +export {}; diff --git a/src/tui/panels/ChatPanel.history.test.tsx b/src/tui/panels/ChatPanel.history.test.tsx new file mode 100644 index 00000000..d615c635 --- /dev/null +++ b/src/tui/panels/ChatPanel.history.test.tsx @@ -0,0 +1,45 @@ +// Long-session coverage: prompt + persisted-history budgets (ChatPanel paths). +import { describe, it, expect } from 'vitest'; +import { + boundChatHistory, + buildConversationPrompt, + historyToMessages, + MAX_CHAT_HISTORY, + chatReducer, + initialChatState, + type ChatLine, +} from '../chatModel.js'; + +describe('ChatPanel long-session history budgets', () => { + it(`caps retained reducer history at ${MAX_CHAT_HISTORY}`, () => { + let state = initialChatState; + for (let i = 0; i < MAX_CHAT_HISTORY + 15; i++) { + state = chatReducer(state, { type: 'user', content: `turn-${i}` }); + state = chatReducer(state, { type: 'stream', chunk: `reply-${i}` }); + state = chatReducer(state, { type: 'commit' }); + } + expect(state.history).toHaveLength(MAX_CHAT_HISTORY); + expect(state.history.some((l) => l.content === 'turn-0')).toBe(false); + expect(state.history.at(-1)).toEqual({ role: 'assistant', content: `reply-${MAX_CHAT_HISTORY + 14}` }); + }); + + it('prompt and persisted message lists stay within the budget for long chats', () => { + const history: ChatLine[] = Array.from({ length: 320 }, (_, i) => ({ + role: i % 2 === 0 ? 'user' : 'assistant', + content: `line-${i}`, + })); + const retained = boundChatHistory(history); + const persisted = boundChatHistory(historyToMessages(retained), MAX_CHAT_HISTORY); + const promptMessages = boundChatHistory( + [...persisted, { role: 'user' as const, content: 'newest-user' }], + MAX_CHAT_HISTORY, + ); + const prompt = buildConversationPrompt(promptMessages); + + expect(retained).toHaveLength(MAX_CHAT_HISTORY); + expect(persisted.length).toBeLessThanOrEqual(MAX_CHAT_HISTORY); + expect(promptMessages.length).toBeLessThanOrEqual(MAX_CHAT_HISTORY); + expect(prompt).toContain('newest-user'); + expect(prompt).not.toContain('line-0'); + }); +}); diff --git a/src/tui/panels/ChatPanel.tsx b/src/tui/panels/ChatPanel.tsx index a00cd690..e56bca9a 100644 --- a/src/tui/panels/ChatPanel.tsx +++ b/src/tui/panels/ChatPanel.tsx @@ -17,6 +17,9 @@ import { normalizeConfirm, isActivityNoise, historyToMessages, + boundChatHistory, + buildConversationPrompt, + MAX_CHAT_HISTORY, SLASH_COMMANDS, type ChatLine, } from '../chatModel.js'; @@ -25,21 +28,14 @@ import { ChatInput } from '../components/ChatInput.js'; import { CommandPalette } from '../components/CommandPalette.js'; import { SelectList } from '../components/SelectList.js'; import { listAdapterNames } from '../../adapters/index.js'; -import { callChatModel, loadDefaultProvider, saveSession, generateSessionId, type Message } from '../../support/chatSession.js'; +import { callChatModel, loadDefaultProvider, saveSession, generateSessionId } from '../../support/chatSession.js'; import { getDefaultChatModel, listChatModels } from '../../support/chatBackend.js'; import { runPlanCommand, type PlanIO } from '../../support/planCommand.js'; import { runGoalCommand, buildGoalPursuitPrompt, GOAL_PURSUIT_MAX_TURNS } from '../../support/goalCommand.js'; import type { AdapterName } from '../../adapters/types.js'; import { track } from '../../telemetry/telemetry.js'; -function buildConversationPrompt(messages: Message[]): string { - if (messages.length <= 1) return messages[0]?.content ?? ''; - return [ - 'Use the following conversation history as context. Continue by answering the latest user message.', - '', - messages.map((m) => `${m.role === 'assistant' ? 'Assistant' : 'User'}: ${m.content}`).join('\n\n'), - ].join('\n'); -} +export { buildConversationPrompt, MAX_CHAT_HISTORY, boundChatHistory } from '../chatModel.js'; export interface ChatPanelProps { active: boolean; @@ -62,7 +58,10 @@ export function ChatPanel({ active, provider: providerProp, model: modelProp, pr const [state, dispatch] = useReducer( chatReducer, undefined, - () => (initialHistory && initialHistory.length > 0 ? { history: initialHistory, streaming: null } : initialChatState), + () => + initialHistory && initialHistory.length > 0 + ? { history: boundChatHistory(initialHistory), streaming: null } + : initialChatState, ); const [input, setInput] = useState(''); const [paletteIndex, setPaletteIndex] = useState(0); @@ -112,13 +111,15 @@ export function ChatPanel({ active, provider: providerProp, model: modelProp, pr // Persist the conversation on every change so it survives exit and can be // reopened with `openswarm resume`. System/UI lines are dropped by // historyToMessages; an active goal rides along so resume can restart it. (INT-2014) + // Retained history is already capped at MAX_CHAT_HISTORY; persist the same budget. useEffect(() => { if (state.history.length === 0) return; + const messages = boundChatHistory(historyToMessages(state.history), MAX_CHAT_HISTORY); void saveSession({ id: sessionIdRef.current, provider, model, - messages: historyToMessages(state.history), + messages, totalCost: 0, totalTokens: 0, createdAt: createdAtRef.current, @@ -406,7 +407,11 @@ export function ChatPanel({ active, provider: providerProp, model: modelProp, pr return; } dispatch({ type: 'user', content: parsed.text }); - await streamChat(buildConversationPrompt([...historyToMessages(state.history), { role: 'user', content: parsed.text }])); + const promptMessages = boundChatHistory( + [...historyToMessages(state.history), { role: 'user' as const, content: parsed.text }], + MAX_CHAT_HISTORY, + ); + await streamChat(buildConversationPrompt(promptMessages)); }, [pending, runCommand, streamChat, goalActive, busy, state.history], );