Skip to content
Merged
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
16 changes: 16 additions & 0 deletions apps/desktop/src/main/browser-agent/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/main/browser-agent/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3646,6 +3646,7 @@ function closeLiveTabs(): void {
currentScope.automationActive = false
currentScope.automationNeedsAttention = false
currentScope.visibleTabUserSelected = false
currentScope.userIntervention = null
clearFocusedBrowserTab()
}

Expand Down
21 changes: 21 additions & 0 deletions apps/desktop/src/main/local-filesystem.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`
Expand Down
6 changes: 3 additions & 3 deletions apps/desktop/src/main/local-filesystem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
32 changes: 32 additions & 0 deletions apps/desktop/src/main/terminal/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
})
})
19 changes: 11 additions & 8 deletions apps/desktop/src/main/terminal/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -527,7 +527,7 @@ export class TerminalSession {
async pressKeys(keys: TerminalControlKey[], signal?: AbortSignal): Promise<void> {
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])
}
Expand All @@ -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<void> {
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<void> {
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)
}
}

Expand Down
20 changes: 20 additions & 0 deletions packages/desktop-bridge/src/local-filesystem-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
})
3 changes: 2 additions & 1 deletion packages/desktop-bridge/src/local-filesystem-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -217,7 +218,7 @@ async function grep(
args: Record<string, unknown>
): Promise<Record<string, unknown>> {
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
Expand Down
12 changes: 1 addition & 11 deletions packages/sim-cli/src/config/profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
*
Expand Down
9 changes: 9 additions & 0 deletions packages/utils/src/string.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
projectEscapedMarkdownForSearch,
sanitizeForJsonb,
sanitizeValueForJsonb,
stripTrailingSlashes,
truncateAtCodePoint,
} from './string.js'

Expand Down Expand Up @@ -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)
Expand Down
11 changes: 11 additions & 0 deletions packages/utils/src/string.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading