diff --git a/apps/desktop/src/main/browser-agent/session.test.ts b/apps/desktop/src/main/browser-agent/session.test.ts index 07f34f26609..10cc182f244 100644 --- a/apps/desktop/src/main/browser-agent/session.test.ts +++ b/apps/desktop/src/main/browser-agent/session.test.ts @@ -1071,6 +1071,22 @@ describe('browser-agent session', () => { expect(session.listTabs()).toHaveLength(1) }) + it('forgets the user input marker when the session closes, so a reused tab id starts clean', () => { + const agent = session.ensureAutomationTab() + const keyDown = (agent.view as unknown as MockView).webContents.on.mock.calls.find( + ([eventName]) => eventName === 'before-input-event' + )?.[1] + if (typeof keyDown !== 'function') throw new Error('no before-input-event listener bound') + keyDown({ preventDefault: vi.fn() }, { type: 'keyDown', isAutoRepeat: false }) + expect(session.msSinceUserIntervention()).not.toBeNull() + + session.closeSession() + const reopened = session.ensureAutomationTab() + + expect(reopened.id).toBe(agent.id) + expect(session.msSinceUserIntervention()).toBeNull() + }) + it('opens, switches, and closes tabs with stable ids', () => { const first = session.ensureTab() const second = session.addTab() diff --git a/apps/desktop/src/main/browser-agent/session.ts b/apps/desktop/src/main/browser-agent/session.ts index 8cc86bd9b0b..e02e79bc899 100644 --- a/apps/desktop/src/main/browser-agent/session.ts +++ b/apps/desktop/src/main/browser-agent/session.ts @@ -3646,6 +3646,7 @@ function closeLiveTabs(): void { currentScope.automationActive = false currentScope.automationNeedsAttention = false currentScope.visibleTabUserSelected = false + currentScope.userIntervention = null clearFocusedBrowserTab() } diff --git a/apps/desktop/src/main/local-filesystem.test.ts b/apps/desktop/src/main/local-filesystem.test.ts index 2a98ddf8326..f4d0b5a35a6 100644 --- a/apps/desktop/src/main/local-filesystem.test.ts +++ b/apps/desktop/src/main/local-filesystem.test.ts @@ -107,6 +107,27 @@ describe('LocalFilesystemService', () => { }) }) + it('trims long runs of slashes in grep and glob paths in linear time', async () => { + const granted = await mount(service) + const slashes = `${'/'.repeat(200_000)}x` + const startedAt = performance.now() + + expect( + service.isAuthorizedClientToolRequest( + { operation: 'grep', uri: granted.uri, pattern: 'TODO', requestId: 'grep-tool' }, + { toolName: 'grep', args: { path: slashes, pattern: 'TODO' } } + ) + ).toBe(false) + await service.handle({ + operation: 'glob', + uri: granted.uri, + pattern: '**/*.ts', + pathPrefix: slashes, + }) + + expect(performance.now() - startedAt).toBeLessThan(1000) + }) + it('binds privileged client reads and searches to server-persisted tool args', async () => { const granted = await mount(service) const vfsRoot = `user-local/${encodeURIComponent(granted.name)}--${granted.id}` diff --git a/apps/desktop/src/main/local-filesystem.ts b/apps/desktop/src/main/local-filesystem.ts index 5e12f7060ee..a83abf50881 100644 --- a/apps/desktop/src/main/local-filesystem.ts +++ b/apps/desktop/src/main/local-filesystem.ts @@ -19,7 +19,7 @@ import { } from '@sim/desktop-bridge/local-filesystem-limits' import { generateId } from '@sim/utils/id' import { isRecordLike } from '@sim/utils/object' -import { escapeRegExp, truncate } from '@sim/utils/string' +import { escapeRegExp, stripTrailingSlashes, truncate } from '@sim/utils/string' import { app, dialog, shell } from 'electron' import micromatch from 'micromatch' import safeRegex from 'safe-regex2' @@ -556,7 +556,7 @@ export class LocalFilesystemService { // request carrying them is the renderer searching for something the // model did not ask for, or hiding results it believes are complete. if (request.query !== undefined || request.include !== undefined) return false - const rawPath = typeof args.path === 'string' ? args.path.replace(/\/+$/, '') : '' + const rawPath = typeof args.path === 'string' ? stripTrailingSlashes(args.path) : '' const uriAllowed = rawPath === 'user-local' ? [...this.mounts.values()].some((mount) => request.uri === mount.uri) @@ -1008,7 +1008,7 @@ export class LocalFilesystemService { if (rawPathPrefix !== undefined && typeof rawPathPrefix !== 'string') { throw new LocalFilesystemError('INVALID_REQUEST', 'pathPrefix must be a string.') } - const pathPrefix = typeof rawPathPrefix === 'string' ? rawPathPrefix.replace(/\/+$/, '') : '' + const pathPrefix = typeof rawPathPrefix === 'string' ? stripTrailingSlashes(rawPathPrefix) : '' const matcher = compileGlob(pattern) const resolvedPath = await this.resolveUri(uri) const baseStat = await stat(resolvedPath.realPath) diff --git a/apps/desktop/src/main/terminal/session.test.ts b/apps/desktop/src/main/terminal/session.test.ts index 45262298490..ce2725af3ae 100644 --- a/apps/desktop/src/main/terminal/session.test.ts +++ b/apps/desktop/src/main/terminal/session.test.ts @@ -188,4 +188,36 @@ describe('TerminalSession command lifecycle', () => { session.dispose() } }) + + it('ends the wait between keystrokes on Stop while the program keeps redrawing', async () => { + vi.useFakeTimers() + const session = TerminalSession.create({ + terminalId: 'terminal-redraw', + cwd: '/tmp', + cols: 80, + rows: 24, + callbacks: { onData: () => {}, onState: () => {}, onCommand: () => {}, onExit: () => {} }, + }) + const redraw = setInterval(() => ptyStub.dataHandler?.('frame'), 20) + try { + const writesBefore = ptyStub.writes.length + const stop = new AbortController() + let settled = false + const typing = session.type('first\nsecond', stop.signal).then(() => { + settled = true + }) + await vi.advanceTimersByTimeAsync(300) + expect(settled).toBe(false) + + stop.abort() + await vi.advanceTimersByTimeAsync(1) + expect(settled).toBe(true) + await typing + + expect(ptyStub.writes.slice(writesBefore)).toEqual(['first']) + } finally { + clearInterval(redraw) + session.dispose() + } + }) }) diff --git a/apps/desktop/src/main/terminal/session.ts b/apps/desktop/src/main/terminal/session.ts index 4dd33ad7f43..00f816f6ff6 100644 --- a/apps/desktop/src/main/terminal/session.ts +++ b/apps/desktop/src/main/terminal/session.ts @@ -23,7 +23,7 @@ import { type TerminalRunResult, type TerminalTabState, } from '@sim/terminal-protocol' -import { sleep } from '@sim/utils/helpers' +import { interruptibleSleep } from '@sim/utils/helpers' import { Terminal as HeadlessTerminal, type IBuffer, @@ -527,7 +527,7 @@ export class TerminalSession { async pressKeys(keys: TerminalControlKey[], signal?: AbortSignal): Promise { for (let index = 0; index < keys.length; index += 1) { if (this.disposed || signal?.aborted) return - if (index > 0) await this.settleBetweenKeystrokes() + if (index > 0) await this.settleBetweenKeystrokes(signal) if (signal?.aborted) return this.sendKey(keys[index]) } @@ -543,21 +543,24 @@ export class TerminalSession { const chunks = toInputChunks(text) for (let index = 0; index < chunks.length; index += 1) { if (this.disposed || signal?.aborted) return - if (index > 0) await this.settleBetweenKeystrokes() + if (index > 0) await this.settleBetweenKeystrokes(signal) if (signal?.aborted) return this.write(chunks[index]) } } - /** Holds a gap, then lets any resulting redraw finish before the next write. */ - private async settleBetweenKeystrokes(): Promise { - await sleep(KEYSTROKE_GAP_MS) + /** + * Holds a gap, then lets any resulting redraw finish before the next write. + * Ends early on Stop, so a program that redraws constantly cannot hold it. + */ + private async settleBetweenKeystrokes(signal?: AbortSignal): Promise { + await interruptibleSleep(KEYSTROKE_GAP_MS, signal) const deadline = Date.now() + KEYSTROKE_SETTLE_MAX_MS - while (!this.disposed) { + while (!this.disposed && !signal?.aborted) { const quietFor = Date.now() - this.lastOutputAt const remaining = Math.min(KEYSTROKE_GAP_MS - quietFor, deadline - Date.now()) if (remaining <= 0) return - await sleep(remaining) + await interruptibleSleep(remaining, signal) } } diff --git a/packages/desktop-bridge/src/local-filesystem-tools.test.ts b/packages/desktop-bridge/src/local-filesystem-tools.test.ts index 122ec3e6bad..e1fe500cc5e 100644 --- a/packages/desktop-bridge/src/local-filesystem-tools.test.ts +++ b/packages/desktop-bridge/src/local-filesystem-tools.test.ts @@ -78,4 +78,24 @@ describe('user-local grep', () => { runUserLocalFilesystemTool('call-1', 'grep', args, context(false)) ).resolves.not.toHaveProperty('truncated') }) + + it('treats a trailing slash on the path as the same folder', async () => { + await expect( + runUserLocalFilesystemTool( + 'call-1', + 'grep', + { ...args, path: 'user-local//' }, + context(false) + ) + ).resolves.toEqual(await runUserLocalFilesystemTool('call-1', 'grep', args, context(false))) + }) + + it('trims a long run of slashes that stops short of the end in linear time', async () => { + const path = `${'/'.repeat(200_000)}x` + const startedAt = performance.now() + await expect( + runUserLocalFilesystemTool('call-1', 'grep', { ...args, path }, context(false)) + ).rejects.toThrow() + expect(performance.now() - startedAt).toBeLessThan(1000) + }) }) diff --git a/packages/desktop-bridge/src/local-filesystem-tools.ts b/packages/desktop-bridge/src/local-filesystem-tools.ts index 084d3dc0a95..eb649b6bd46 100644 --- a/packages/desktop-bridge/src/local-filesystem-tools.ts +++ b/packages/desktop-bridge/src/local-filesystem-tools.ts @@ -3,6 +3,7 @@ * local filesystem service. The chat view runs them through the preload bridge; the desktop's * background executor runs them in-process. Both get the same paths and result shapes. */ +import { stripTrailingSlashes } from '@sim/utils/string' import micromatch from 'micromatch' import type { LocalFilesystemData, @@ -217,7 +218,7 @@ async function grep( args: Record ): Promise> { const pattern = requiredString(args, 'pattern') - const path = requiredString(args, 'path').replace(/\/+$/, '') + const path = stripTrailingSlashes(requiredString(args, 'path')) const outputMode = args.output_mode === 'files_with_matches' || args.output_mode === 'count' ? args.output_mode diff --git a/packages/sim-cli/src/config/profile.ts b/packages/sim-cli/src/config/profile.ts index 7e8ca957fdb..135372e851e 100644 --- a/packages/sim-cli/src/config/profile.ts +++ b/packages/sim-cli/src/config/profile.ts @@ -10,6 +10,7 @@ import { writeFileSync, } from 'node:fs' import { dirname } from 'node:path' +import { stripTrailingSlashes } from '@sim/utils/string' import { lock } from 'proper-lockfile' import { embeddedProfile } from '../embed-context' import { @@ -583,17 +584,6 @@ export function deleteProfile(profile: string): { config: boolean; credentials: return { config, credentials } } -/** - * Removes every trailing `/`. A backward scan rather than `/\/+$/`: that regex - * restarts at each `/` in a long run that does not reach the end, so it is - * quadratic in the run length. - */ -function stripTrailingSlashes(value: string): string { - let end = value.length - while (end > 0 && value.charCodeAt(end - 1) === 0x2f) end-- - return value.slice(0, end) -} - /** * Validates an endpoint and strips its trailing slashes. * diff --git a/packages/utils/src/string.test.ts b/packages/utils/src/string.test.ts index 2c8f5df4a0a..5b6242d53b7 100644 --- a/packages/utils/src/string.test.ts +++ b/packages/utils/src/string.test.ts @@ -6,6 +6,7 @@ import { projectEscapedMarkdownForSearch, sanitizeForJsonb, sanitizeValueForJsonb, + stripTrailingSlashes, truncateAtCodePoint, } from './string.js' @@ -85,6 +86,14 @@ describe('escapeRegExp', () => { }) }) +describe('stripTrailingSlashes', () => { + it('removes only the trailing run', () => { + expect(stripTrailingSlashes('/a//b///')).toBe('/a//b') + expect(stripTrailingSlashes('///')).toBe('') + expect(stripTrailingSlashes('a')).toBe('a') + }) +}) + describe('compareStrings', () => { it('sorts uppercase before lowercase, unlike localeCompare', () => { expect(compareStrings('Z', 'a')).toBe(-1) diff --git a/packages/utils/src/string.ts b/packages/utils/src/string.ts index 83343a07d46..f5542f122a0 100644 --- a/packages/utils/src/string.ts +++ b/packages/utils/src/string.ts @@ -371,6 +371,17 @@ export function escapeRegExp(value: string): string { return value.replace(REGEX_METACHARACTERS, '\\$&') } +/** + * Removes every trailing `/`. A backward scan rather than `/\/+$/`: that regex + * restarts at each `/` in a long run that does not reach the end, so it is + * quadratic in the run length. + */ +export function stripTrailingSlashes(value: string): string { + let end = value.length + while (end > 0 && value.charCodeAt(end - 1) === 0x2f) end-- + return value.slice(0, end) +} + /** * Escapes the SQL LIKE/ILIKE metacharacters `%`, `_`, and `\` in `value` so * each matches itself. Postgres uses `\` as the default LIKE escape character,