Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
485 changes: 2 additions & 483 deletions .github/workflows/test.yml

Large diffs are not rendered by default.

18 changes: 17 additions & 1 deletion e2e/account-chooser.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,23 @@ const cardOf = (w: World, id: string) => paneOf(w, id).locator('[data-testid="li
/** Waits for the card that offers a way out of a usage limit. */
async function expectCard(w: World, id: string) {
const card = cardOf(w, id)
await expect(card).toBeVisible({ timeout: 60_000 })
try {
await expect(card).toBeVisible({ timeout: 60_000 })
} catch (e) {
// What main's look at the terminal says, a few times: which of live, turned and the status kept the card away.
const looks: unknown[] = []
for (let i = 0; i < 4; i++) {
looks.push(await w.page.evaluate(async (terminalId) => {
try { return await (window as unknown as { accounts: { limitLook: (id: string) => Promise<unknown> } }).accounts.limitLook(terminalId) } catch (err) { return String(err) }
}, id))
await w.page.waitForTimeout(2000)
}
const raw = await w.page.evaluate(async (terminalId) => {
try { return await (window as unknown as { termpolis: { readTerminalBuffer: (id: string, from: number) => Promise<unknown> } }).termpolis.readTerminalBuffer(terminalId, 0) } catch (err) { return String(err) }
}, id)
const st = await w.page.evaluate(() => { try { return (window as unknown as { __termpolis_test_state: () => { diag: unknown } }).__termpolis_test_state().diag } catch (err) { return String(err) } })
throw new Error(`${(e as Error).message}\nSTORE-DIAG ${JSON.stringify(st)}\nLIMIT-LOOKS ${JSON.stringify(looks)}`)
}
return card
}

Expand Down
15 changes: 13 additions & 2 deletions src/main/limitLook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,11 +72,22 @@ export { agentOfLaunch }
export function agentGone(io: SwitchIO, id: string, start: LiveStart, now: number, l: Look = look(io, id)): boolean {
if (start.ended) return true
if (start.agent && hintAtFoot(l, start.agent) && HINT_HEAD[start.agent].test(plain(io.outputFrom(id, start.at)))) return true
const fresh = now - (start.time ?? 0) < FOREGROUND_GRACE_MS
const from = indexOf(l, start.at)
// The replay is read only once a shell reported a prompt since the start: David's Git Bash, zsh
// and fish report none.
if (l.raw.slice(from).search(PROMPT_REPORT) >= 0 && sinceStarted(l.raw, from, false, io.size(id), start.commands).shell) return true
return now - (start.time ?? 0) >= FOREGROUND_GRACE_MS && io.atShell?.(id) === true
const since = l.raw.slice(from)
if (since.search(PROMPT_REPORT) >= 0 && !(fresh && quietUpToPrompt(since)) && sinceStarted(l.raw, from, false, io.size(id), start.commands).shell) return true
return !fresh && io.atShell?.(id) === true
}

/** Nothing printed between the start and the shell's last prompt report since: the shell printed one more prompt
* after the line was entered and before it echoed that line (Git Bash on Windows CI did), not an agent that ran and
* exited. A look in that moment latched the agent gone before it started; in the moment the shell takes to start
* the agent (FOREGROUND_GRACE_MS) it is no exit. */
function quietUpToPrompt(since: string): boolean {
const reports = [...since.matchAll(PROMPT_REPORT)]
return plain(since.slice(0, reports[reports.length - 1].index)).trim() === ''
}

/** Can this terminal ever show an agent's exit? A shell that reports its prompts, or a PTY whose foreground can be read
Expand Down
23 changes: 22 additions & 1 deletion src/main/mcpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -880,6 +880,26 @@ function agentCommands(command: string): string[] {
/** Whether `rows` show one of the commands, by its letters and digits. */
const showsCommand = (rows: readonly string[], commands: readonly string[]): boolean => commands.some((c) => bare(rows.join('')).includes(bare(c)))

/** How many of a command's letters and digits must end the rows before they read as its start (echoing): more than
* any folder a prompt ends in shares with a launch line by chance. */
const ECHO_HEAD = 16

/** Whether `rows` end in the start of one of the commands, of more than one word: the shell still echoing the line
* typed at its prompt. Git Bash on Windows echoes a long launch line (Termpolis's own, with its instructions) in
* pieces, and a look between two of them saw the prompt, part of the line and nothing else (the CI run of
* 2026-11-07: the agent read as gone before it started). */
function echoing(rows: readonly string[], commands: readonly string[]): boolean {
const text = bare(rows.join(''))
return commands.some((c) => {
const want = bare(c)
if (c.trim().split(/\s+/).length < 2 || want.length < ECHO_HEAD) return false
for (let at = text.indexOf(want.slice(0, ECHO_HEAD)); at >= 0; at = text.indexOf(want.slice(0, ECHO_HEAD), at + 1)) {
if (want.startsWith(text.slice(at))) return true
}
return false
})
}

/** What the output since an agent was started says, `from` on in `raw` (the shell's very first
* output when `whole`): whether its shell is back at its prompt (shell), and whether the agent drew
* its input box (started). The prompts that count are the ones printed after the command was typed:
Expand Down Expand Up @@ -907,7 +927,8 @@ export function sinceStarted(raw: string, from: number, whole: boolean, size: Sc
}
const switches = [...after.matchAll(ALT_SCREEN)]
const onAlt = switches.length > 0 && switches[switches.length - 1][1] === 'h'
return { shell: !onAlt && inputBoxRow(upTo(REPAINT)) < 0 && !showsCommand(upTo(WIPE), commands), started }
const shown = upTo(WIPE)
return { shell: !onAlt && inputBoxRow(upTo(REPAINT)) < 0 && !showsCommand(shown, commands) && !echoing(shown, commands), started }
}

/** What a terminal shows, and what the agent in it is doing; null for a terminal with no output.
Expand Down
17 changes: 14 additions & 3 deletions src/renderer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,12 @@ export default function App() {
swarmAgents: s.swarmAgents,
swarmCompletionSummary: s.swarmCompletionSummary,
swarmNotification: s.swarmNotification,
diag: {
accountSwitch: s.accountSwitch, limitBanners: s.limitBanners, relaunching: s.relaunching, viewMode: s.viewMode,
activeTerminalId: s.activeTerminalId, showSettings: s.showSettings, focused: document.hasFocus(),
view: s.accountsView && { chooser: s.accountsView.chooser, hidden: s.accountsView.hidden, accounts: s.accountsView.accounts.map((r) => ({ id: r.id, agent: r.agent, label: r.label })) },
trace: (window as any).__limitTrace ?? [],
},
}
}
// Bundled observability libs, exposed for e2e tests so they exercise
Expand Down Expand Up @@ -718,7 +724,7 @@ export default function App() {
lastDismissedPrompt.set(agent.terminalId, dismissKey)
// Its agent as main recorded run_command's line, else as its name says (R10).
const worker = store.terminals.find((t) => t.id === agent.terminalId)
const dismissChar = await replyToScreen(
const dismissChar = await replyToScreen(
output, agent.agentName,
() => mayTrustFolder(agent.terminalId), () => readScreen(agent.terminalId),
!hasManyAccounts(store.accountsView, worker?.swarmAgent ?? agentOfName(agent.agentName)),
Expand Down Expand Up @@ -825,10 +831,13 @@ export default function App() {
// replyToScreen reads the screen again first, so an answered dialog gets nothing.
lastDismissedPrompt.set(term.id, dismissKey)
const agent = terminalAgent(term)
const tr = ((window as any).__limitTrace ??= []) as unknown[]
const dismissChar = await replyToScreen(
output, term.name, () => mayTrustFolder(term.id), () => readScreen(term.id),
!hasManyAccounts(store.accountsView, agent),
)
tr.push({ at: Date.now(), id: term.id, agent, dismissChar, out: output.length })
if (tr.length > 200) tr.splice(0, tr.length - 200)
if (dismissChar) {
window.termpolis.writeToTerminal(term.id, dismissChar)
continue
Expand All @@ -839,10 +848,12 @@ export default function App() {
const looked = await watchTerminal(term.id, agent, store.accountsView, {
accounts: window.accounts,
now: Date.now,
onLimit: (note) => showLimit(note, {
screen: dismissKey,
onLimit: (note) => { tr.push({ at: Date.now(), onLimit: note.kind, ctx: note.ctx }); showLimit(note, {
store: useTerminalStore.getState, focused: () => document.hasFocus(), now: Date.now, newId: uuid, notify: osNotify,
}),
}) },
})
tr.push({ at: Date.now(), looked })
// A limit screen is still: a look that failed (main busy, an IPC error) must be tried again
// on the next tick, not passed by as a screen already judged.
if (!looked) lastDismissedPrompt.delete(term.id)
Expand Down
29 changes: 28 additions & 1 deletion src/renderer/src/lib/limitWatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,29 @@ export function noteLook(terminalId: string, agent: AccountAgent, look: LimitLoo
/** Terminals closed since the last tick: their stops go with them. */
export function pruneStops(open: ReadonlySet<string>): void {
for (const id of stops.keys()) if (!open.has(id)) stops.delete(id)
for (const id of unsure.keys()) if (!open.has(id)) unsure.delete(id)
}

/** How many more looks a still screen gets (2 s apart) while main has not counted a stop on it: main's word on the
* agent can change while the screen the loop keys on does not. Main judges its own parse of the screen, which can
* trail the output the loop reads, so the limit may show there only a look later; the prompt that led to the stop
* is a turn from the Enter, and a start main had not yet seen is recorded as the shell echoes the line, both
* possibly after the limit was drawn. A loop that judged that screen only once never opened the chooser (Windows
* CI, Codex, 2026-11-07). A new screen starts the count again. */
export const UNSETTLED_LOOKS = 10
const unsure = new Map<string, { screen: string | undefined; n: number }>()

/** Is this look, of a screen main counted no stop on, one to take again on the next tick, though the screen stays
* the same? */
function unsettled(terminalId: string, look: LimitLook, screen: string | undefined): boolean {
if (stopKind(look.status) !== null && look.live && look.turned) {
unsure.delete(terminalId)
return false
}
const last = unsure.get(terminalId)
const n = last && last.screen === screen ? last.n + 1 : 1
unsure.set(terminalId, { screen, n })
return n <= UNSETTLED_LOOKS
}

/** A banner's terminals that no longer show the stop it is about (the agent is at work again, or the
Expand Down Expand Up @@ -119,6 +142,7 @@ export function pruneChooser(s: { accountSwitch: ShowStore['accountSwitch']; ter

export function _resetLimitWatchForTest(): void {
stops.clear()
unsure.clear()
swarmOffer = null
}

Expand Down Expand Up @@ -312,6 +336,8 @@ export interface WatchDeps {
now: () => number
/** What follows a new stop: its record against the account, and what the user sees. */
onLimit: (note: Exclude<LimitNote, { kind: 'none' }>) => void
/** The screen the loop judged (its key): a new one starts the count of looks again (UNSETTLED_LOOKS). */
screen?: string
}

/**
Expand All @@ -330,7 +356,8 @@ export async function watchTerminal(terminalId: string, agent: AccountAgent, vie
return false
}
const note = noteLook(terminalId, agent, look, deps.now())
if (note.kind === 'none') return true
if (note.kind === 'none') return !unsettled(terminalId, look, deps.screen)
unsure.delete(terminalId)
const row = note.ctx.accountId ? view?.accounts.find((r) => r.id === note.ctx.accountId) : undefined
if (note.kind === 'limit' && row) {
const email = row.lastEmail ?? (agent === 'agy' ? row.email : undefined)
Expand Down
46 changes: 46 additions & 0 deletions tests/electron/limitLook.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -374,3 +374,49 @@ describe('where a turn began (R4): an old limit is not the answer to it', () =>
expect(noteEnteredLine(io, 't1', undefined, '/resume', 77, 1234)).toBeNull()
})
})

describe('a long launch line Git Bash on Windows echoes late (the CI run of 2026-11-07)', () => {
const LINE = `unset CODEX_HOME && codex -c "developer_instructions='${'Termpolis project memory: saved background context exists. '.repeat(12)}'"`
const report = `${E}]7;file://host/tmp/project\x07`
const prompt = `${E}[32m\r\nrunner@host ${E}[35mUCRT64 ${E}[33m/tmp/project${E}[m\r\n$ `
/** Bash at its prompt when the line is entered (at), one more prompt it prints before the echo, then the echo. */
const BEFORE = `${report}${prompt}${E}[?2004h`
const AFTER = `${E}[?2004l${report}${prompt}`
function io(out: string): SwitchIO {
return {
output: () => ({ raw: out, end: out.length }),
outputFrom: (_id, from) => out.slice(from),
end: () => out.length,
size: () => ({ cols: 98, rows: 36 }),
write: () => undefined,
sleep: async () => undefined,
now: () => 0,
}
}
const start: LiveStart = { at: BEFORE.length, commands: [LINE], agent: 'codex', time: 0 }

it('is no exit at the prompt printed before the echo, in the moment the shell takes to start the agent', () => {
expect(agentGone(io(BEFORE + AFTER), 't', start, FOREGROUND_GRACE_MS - 1)).toBe(false)
// Past it, a prompt and nothing more is the shell back: the line never ran.
expect(agentGone(io(BEFORE + AFTER), 't', start, FOREGROUND_GRACE_MS)).toBe(true)
})

it('is no exit with the line half echoed, at any moment', () => {
for (const cut of [40, 100, LINE.length - 5]) {
expect(agentGone(io(BEFORE + AFTER + LINE.slice(0, cut)), 't', start, FOREGROUND_GRACE_MS * 10)).toBe(false)
}
expect(agentGone(io(BEFORE + AFTER + LINE + '\r\n'), 't', start, FOREGROUND_GRACE_MS * 10)).toBe(false)
})

it('still reads an agent that printed something and exited in that moment as gone', () => {
expect(agentGone(io(`${BEFORE}${LINE}\r\nError: no such model${AFTER}`), 't', start, 0)).toBe(true)
})

it('reads a prompt that only ends like the line, or like a line too short to tell, as the shell back', () => {
expect(agentGone(io(BEFORE + AFTER + LINE.slice(0, 20)), 't', start, FOREGROUND_GRACE_MS)).toBe(true)
const brief: LiveStart = { ...start, commands: ['codex -m o3'] }
expect(agentGone(io(BEFORE + AFTER + 'codex -m'), 't', brief, FOREGROUND_GRACE_MS)).toBe(true)
// The line's head twice, the second time not followed by the rest of it.
expect(agentGone(io(BEFORE + AFTER + LINE.slice(0, 40) + ' ' + LINE.slice(0, 30) + 'zzz'), 't', start, FOREGROUND_GRACE_MS)).toBe(true)
})
})
51 changes: 48 additions & 3 deletions tests/lib/limitWatch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
_resetLimitWatchForTest, bannerText, decideLimit, isTerminalVisible, noteLook,
noteSwarmStop, osNotify, pruneBanners, pruneChooser, pruneStops, sameGroup, setSwarmStopOffer, showLimit, watchTerminal,
noteSwarmStop, osNotify, pruneBanners, pruneChooser, pruneStops, sameGroup, setSwarmStopOffer, showLimit, UNSETTLED_LOOKS, watchTerminal,
} from '../../src/renderer/src/lib/limitWatch'
import type { DecideEnv, LimitBanner, LimitContext, LimitNote, ShowStore } from '../../src/renderer/src/lib/limitWatch'
import type { LimitLook } from '../../src/shared/accountLimits'
Expand Down Expand Up @@ -444,16 +444,61 @@ describe('watchTerminal: the view the next chooser reads (round 1)', () => {
describe('watchTerminal: whether the look was answered (round 1)', () => {
const lookApi = (limitLook: () => Promise<unknown>) => ({ limitLook: vi.fn(limitLook as never), noteLimit: vi.fn(async () => ({ success: true })) })

it('says true for a look main answered, whatever it saw, and false for one that failed', async () => {
it('says true for a stop main counted, and false for a look that failed or a screen it may not have caught up with', async () => {
const idle = lookApi(async () => ({ success: true, data: look({ status: IDLE }) }))
expect(await watchTerminal('t1', 'claude', null, { accounts: idle, now: () => 1, onLimit: vi.fn() })).toBe(true)
expect(await watchTerminal('t1', 'claude', null, { accounts: idle, now: () => 1, onLimit: vi.fn() })).toBe(false)
const stop = lookApi(async () => ({ success: true, data: look() }))
expect(await watchTerminal('t1', 'claude', view([A, B]), { accounts: stop, now: () => Date.parse('2026-10-23T18:00:00Z'), onLimit: vi.fn() })).toBe(true)
expect(await watchTerminal('t1', 'claude', null, { accounts: lookApi(async () => ({ success: false, error: 'busy' })), now: () => 1, onLimit: vi.fn() })).toBe(false)
expect(await watchTerminal('t1', 'claude', null, { accounts: lookApi(async () => { throw new Error('gone') }), now: () => 1, onLimit: vi.fn() })).toBe(false)
})
})

describe('watchTerminal: a still limit screen main did not count yet (Windows CI, 2026-11-07)', () => {
const api = (data: () => LimitLook) => ({ limitLook: vi.fn(async () => ({ success: true, data: data() }) as never), noteLimit: vi.fn(async () => ({ success: true })) })

it('is looked at again until main counts it, then the chooser opens once', async () => {
let now = look({ turned: false })
const accounts = api(() => now)
const onLimit = vi.fn()
const deps = { accounts, now: () => Date.parse('2026-10-23T18:00:00Z'), onLimit }
expect(await watchTerminal('t1', 'claude', view([A, B]), deps)).toBe(false)
now = look({ live: false })
expect(await watchTerminal('t1', 'claude', view([A, B]), deps)).toBe(false)
now = look()
expect(await watchTerminal('t1', 'claude', view([A, B]), deps)).toBe(true)
expect(onLimit).toHaveBeenCalledTimes(1)
})

it('is looked at again while main still shows no stop on a screen that already holds the limit, its parse behind', async () => {
let now = look({ status: IDLE })
const accounts = api(() => now)
const onLimit = vi.fn()
const deps = { accounts, now: () => Date.parse('2026-10-23T18:00:00Z'), onLimit, screen: 'k1' }
expect(await watchTerminal('t1', 'claude', view([A, B]), deps)).toBe(false)
now = look()
expect(await watchTerminal('t1', 'claude', view([A, B]), deps)).toBe(true)
expect(onLimit).toHaveBeenCalledTimes(1)
})

it('gives up after a few looks of one screen, a stop main never counts (the last frame of an agent that exited)', async () => {
const accounts = api(() => look({ live: false }))
const deps = { accounts, now: () => 1, onLimit: vi.fn(), screen: 'k1' }
for (let i = 0; i < UNSETTLED_LOOKS; i++) expect(await watchTerminal('t1', 'claude', null, deps)).toBe(false)
expect(await watchTerminal('t1', 'claude', null, deps)).toBe(true)
// A screen with no stop is judged a few times too, and passed by after.
const idle = { ...deps, accounts: api(() => look({ status: IDLE })) }
expect(await watchTerminal('t1', 'claude', null, idle)).toBe(true)
// A new screen starts the count again; so does a closed terminal; so does a counted stop.
expect(await watchTerminal('t1', 'claude', null, { ...idle, screen: 'k2' })).toBe(false)
pruneStops(new Set())
for (let i = 0; i < UNSETTLED_LOOKS; i++) expect(await watchTerminal('t1', 'claude', null, idle)).toBe(false)
expect(await watchTerminal('t1', 'claude', null, { ...idle, accounts: api(() => look()) })).toBe(true)
expect(await watchTerminal('t1', 'claude', null, idle)).toBe(false)
expect(deps.onLimit).toHaveBeenCalledTimes(1)
})
})

describe('the one-line banners (round 1)', () => {
const env = (over: Partial<DecideEnv> = {}): DecideEnv => ({ view: view([A]), dialog: null, banners: [], mayOpen: true, newId: () => 'b9', ...over })
const limit = (c: LimitContext): Exclude<LimitNote, { kind: 'none' }> => ({ kind: 'limit', ctx: c })
Expand Down
Loading