Files
orca/src/shared/grok-session-paths.test.ts
T
BingZandJinwoo Hong 96d1fa1d62 fix(grok): clipboard, native chat, hooks, sessions, ConPTY KKP (#7944)
* fix(grok): restore clipboard and native-chat parity

Grok CLI already supports argv prompts, OSC 52 copy, and image paste chips.
Orca was blocking those paths: stdin-after-start keystroke injection, OSC 52
writes default-off, image-attachment denylist, and native-chat allowlist.

- Launch Grok with positional argv prompts
- Default OSC 52 TUI clipboard writes on (still user-toggleable)
- Treat Grok as image-attachment capable
- Parse ~/.grok/.../chat_history.jsonl for native chat

OSC 52 clipboard *query* remains ignored by design (host clipboard exfil risk);
xAI docs only require OSC 52 write for remote copy.

* fix(grok): sync OSC 52 docs and locale catalog with default-on

Update terminalAllowOsc52Clipboard type docs for the true default, and
refresh locale strings so settings UI mentions Grok alongside other TUIs.

* fix(grok): tool hook matcher, StopFailure, previews, AskUser waiting

Grok tool-event matchers are real regexes; bare `*` failed as match-all.
Install `.*` for Pre/Post tool hooks, add StopFailure for API-error ends,
recognize Grok-native tool input keys, and map ask_user_question PreToolUse
to waiting with interactivePrompt (Kimi-style live card path).

* fix(grok): resolve chat_history under GROK_HOME and long-cwd layouts

Centralize Grok session path helpers so hooks and native-chat honor
GROK_HOME and find chat_history.jsonl by session id when the cwd group
is slug-encoded (encoded name > 255 bytes) instead of only
encodeURIComponent(cwd).

* fix(terminal): keep Kitty keyboard for Grok on Windows ConPTY

Local Windows ConPTY withholds KKP so CSI-u-blind CLIs (e.g. Antigravity)
keep Enter/nav working (#2434). Grok needs KKP for Ctrl+Enter interject and
modified-Enter newline chords; blanking the advertisement for Orca-launched
Grok left those actions broken.

- Prefer KKP when tuiAgent is grok despite ConPTY withhold
- Wire launchAgent from tab/startup into keyboard protocol options

* fix(grok): restore OSC52 default-off, split decoders, honor GROK_HOME hooks

- Keep terminalAllowOsc52Clipboard default false (clipboard exfil risk)
- Split transcript-line-decoders under max-lines without suppressions
- Install local Grok hooks under resolveGrokHomeDir() / GROK_HOME

* refactor(grok): share CLI home resolution

* fix(grok): harden terminal and native chat integration

* test(grok): align CI coverage with native chat support

---------

Co-authored-by: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com>
2026-07-10 13:16:55 -07:00

388 lines
14 KiB
TypeScript

import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import {
GROK_ENCODED_CWD_DIR_MAX_BYTES,
GROK_SESSION_SCAN_ACTIVE_ROOT_MAX,
GROK_SESSION_SCAN_QUEUE_MAX_ENTRIES,
buildGrokChatHistoryPathCandidates,
clearGrokSessionPathLookupCacheForTests,
findGrokChatHistoryBySessionId,
getCachedGrokChatHistoryBySessionId,
grokEncodedCwdDirName,
isGrokChatHistoryPath,
isSafeGrokSessionId,
resolveGrokChatHistoryPathSync,
resolveGrokHomeDir,
resolveGrokSessionsDir,
setGrokSessionDirectoryOpenerForTests,
setGrokSessionPathScannerForTests
} from './grok-session-paths'
const tempDirs: string[] = []
afterEach(() => {
clearGrokSessionPathLookupCacheForTests()
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true })
}
})
function makeRoot(): string {
const dir = mkdtempSync(join(tmpdir(), 'orca-grok-session-paths-'))
tempDirs.push(dir)
return dir
}
function deferred<T>(): {
promise: Promise<T>
resolve: (value: T) => void
reject: (error: Error) => void
} {
let resolvePromise: (value: T) => void = () => undefined
let rejectPromise: (error: Error) => void = () => undefined
const promise = new Promise<T>((resolve, reject) => {
resolvePromise = resolve
rejectPromise = reject
})
return { promise, resolve: resolvePromise, reject: rejectPromise }
}
describe('grok-session-paths', () => {
it('honors GROK_HOME for home and sessions roots', () => {
const root = makeRoot()
expect(resolveGrokHomeDir({ GROK_HOME: root }, '/unused')).toBe(root)
expect(resolveGrokSessionsDir({ GROK_HOME: root }, '/unused')).toBe(join(root, 'sessions'))
expect(resolveGrokHomeDir({}, '/home/ada')).toBe(join('/home/ada', '.grok'))
})
it('refuses to invent encodeURIComponent names longer than 255 bytes', () => {
const longCwd = `/${'a'.repeat(200)}/${'b'.repeat(200)}`
expect(Buffer.byteLength(encodeURIComponent(longCwd), 'utf8')).toBeGreaterThan(
GROK_ENCODED_CWD_DIR_MAX_BYTES
)
expect(grokEncodedCwdDirName(longCwd)).toBeNull()
expect(
buildGrokChatHistoryPathCandidates({
sessionId: 'sess-1',
cwd: longCwd,
sessionsDir: '/tmp/sessions'
})
).toEqual([])
})
it('rejects unsafe session ids and path-special cwd components', async () => {
const root = makeRoot()
const sessionsDir = join(root, 'sessions')
const invalidIds = ['../escape', 'nested/id', 'nested\\id', '.', '..', 'x'.repeat(129)]
for (const sessionId of invalidIds) {
expect(isSafeGrokSessionId(sessionId)).toBe(false)
expect(buildGrokChatHistoryPathCandidates({ sessionId, cwd: '/repo', sessionsDir })).toEqual(
[]
)
await expect(findGrokChatHistoryBySessionId(sessionsDir, sessionId)).resolves.toBeNull()
expect(isGrokChatHistoryPath('/repo/chat_history.jsonl', sessionId)).toBe(false)
}
expect(isSafeGrokSessionId('019e37f4-5135-7b63-a4ab-6d13aa6bf528')).toBe(true)
expect(grokEncodedCwdDirName('.')).toBeNull()
expect(grokEncodedCwdDirName('..')).toBeNull()
expect(
buildGrokChatHistoryPathCandidates({ sessionId: 'safe-id', cwd: '..', sessionsDir })
).toEqual([])
})
it('resolves via encodeURIComponent(cwd) when the short path exists', () => {
const root = makeRoot()
const sessionsDir = join(root, 'sessions')
const cwd = '/tmp/work'
const sessionId = 'sess-short'
const history = join(sessionsDir, encodeURIComponent(cwd), sessionId, 'chat_history.jsonl')
mkdirSync(dirname(history), { recursive: true })
writeFileSync(history, '{"type":"user"}\n')
expect(
resolveGrokChatHistoryPathSync({
sessionId,
cwd,
sessionsDir
})
).toBe(history)
})
it('does not synchronously discover a long-cwd slug group', () => {
const root = makeRoot()
const sessionsDir = join(root, 'sessions')
const sessionId = 'sess-long'
// Simulate Grok's slug+hash group directory (not encodeURIComponent of cwd).
const slugGroup = 'long-path-ab12cd34'
const history = join(sessionsDir, slugGroup, sessionId, 'chat_history.jsonl')
mkdirSync(dirname(history), { recursive: true })
writeFileSync(join(sessionsDir, slugGroup, '.cwd'), '/very/long/path\n')
writeFileSync(history, '{"type":"assistant","content":"hi"}\n')
expect(
resolveGrokChatHistoryPathSync({
sessionId,
cwd: `/${'x'.repeat(300)}`,
sessionsDir
})
).toBeNull()
})
it('finds the documented group/session layout asynchronously', async () => {
const root = makeRoot()
const sessionsDir = join(root, 'sessions')
const history = join(sessionsDir, 'slug-hash', 'sess-async', 'chat_history.jsonl')
mkdirSync(dirname(history), { recursive: true })
writeFileSync(history, '{}\n')
await expect(findGrokChatHistoryBySessionId(sessionsDir, 'sess-async')).resolves.toBe(history)
})
it('deduplicates concurrent async discovery', async () => {
const root = makeRoot()
const sessionsDir = join(root, 'sessions')
const history = join(sessionsDir, 'slug-hash', 'sess-deduped', 'chat_history.jsonl')
mkdirSync(dirname(history), { recursive: true })
writeFileSync(history, '{}\n')
const first = findGrokChatHistoryBySessionId(sessionsDir, 'sess-deduped')
const second = findGrokChatHistoryBySessionId(sessionsDir, 'sess-deduped')
expect(second).toBe(first)
await expect(first).resolves.toBe(history)
})
it('reuses a successful discovery from cache without another scan', async () => {
const root = makeRoot()
const sessionsDir = join(root, 'sessions')
const history = join(sessionsDir, 'slug-hash', 'sess-cached', 'chat_history.jsonl')
mkdirSync(dirname(history), { recursive: true })
writeFileSync(history, '{}\n')
await expect(findGrokChatHistoryBySessionId(sessionsDir, 'sess-cached')).resolves.toBe(history)
rmSync(join(sessionsDir, 'slug-hash'), { recursive: true, force: true })
expect(getCachedGrokChatHistoryBySessionId(sessionsDir, 'sess-cached')).toBe(history)
await expect(findGrokChatHistoryBySessionId(sessionsDir, 'sess-cached')).resolves.toBe(history)
})
it('does not descend below the documented group/session layout', async () => {
const root = makeRoot()
const sessionsDir = join(root, 'sessions')
const nestedDecoy = join(
sessionsDir,
'group',
'other-session',
'cache',
'sess-target',
'chat_history.jsonl'
)
mkdirSync(dirname(nestedDecoy), { recursive: true })
writeFileSync(nestedDecoy, '{}\n')
expect(
resolveGrokChatHistoryPathSync({
sessionId: 'sess-target',
cwd: '/group',
sessionsDir
})
).toBeNull()
await expect(findGrokChatHistoryBySessionId(sessionsDir, 'sess-target')).resolves.toBeNull()
})
it('applies the hard group-entry bound to the filesystem iteration subset', async () => {
const root = makeRoot()
const sessionsDir = join(root, 'sessions')
for (const group of ['a-group', 'b-group']) {
mkdirSync(join(sessionsDir, group), { recursive: true })
}
const beyondBound = join(sessionsDir, 'z-target-group', 'sess-bounded', 'chat_history.jsonl')
mkdirSync(dirname(beyondBound), { recursive: true })
writeFileSync(beyondBound, '{}\n')
setGrokSessionDirectoryOpenerForTests(async () => ({
async *[Symbol.asyncIterator]() {
for (const name of ['a-group', 'b-group', 'z-target-group']) {
yield {
name,
isDirectory: () => true,
isSymbolicLink: () => false
}
}
},
async close() {}
}))
await expect(findGrokChatHistoryBySessionId(sessionsDir, 'sess-bounded', 2)).resolves.toBeNull()
await expect(findGrokChatHistoryBySessionId(sessionsDir, 'sess-bounded', 3)).resolves.toBe(
beyondBound
)
})
it('stops the directory iterator after the exact eligible-entry cap and closes it', async () => {
let yielded = 0
let closed = 0
const entries = [
{ name: 'file', directory: false, symlink: false },
{ name: 'group-a', directory: true, symlink: false },
{ name: 'group-b', directory: true, symlink: false },
{ name: 'group-c', directory: true, symlink: false }
]
setGrokSessionDirectoryOpenerForTests(async () => ({
async *[Symbol.asyncIterator]() {
for (const entry of entries) {
yielded += 1
yield {
name: entry.name,
isDirectory: () => entry.directory,
isSymbolicLink: () => entry.symlink
}
}
},
async close() {
closed += 1
}
}))
await expect(
findGrokChatHistoryBySessionId('/missing/sessions', 'sess-iterator', 2)
).resolves.toBeNull()
expect(yielded).toBe(3)
expect(closed).toBe(1)
})
it('deduplicates the exact promise while a scanner is deferred', async () => {
const scan = deferred<string | null>()
let calls = 0
setGrokSessionPathScannerForTests(async () => {
calls += 1
return scan.promise
})
const first = findGrokChatHistoryBySessionId('/sessions/a', 'sess-same')
const second = findGrokChatHistoryBySessionId('/sessions/a', 'sess-same')
expect(second).toBe(first)
expect(calls).toBe(1)
scan.resolve(null)
await expect(first).resolves.toBeNull()
})
it('runs at most one scan per sessions root', async () => {
const scans = new Map<string, ReturnType<typeof deferred<string | null>>>()
const started: string[] = []
setGrokSessionPathScannerForTests(async (root, sessionId) => {
const key = `${root}:${sessionId}`
started.push(key)
const scan = deferred<string | null>()
scans.set(key, scan)
return scan.promise
})
const first = findGrokChatHistoryBySessionId('/sessions/root', 'sess-1')
const second = findGrokChatHistoryBySessionId('/sessions/root', 'sess-2')
expect(started).toEqual(['/sessions/root:sess-1'])
scans.get('/sessions/root:sess-1')?.resolve(null)
await first
expect(started).toEqual(['/sessions/root:sess-1', '/sessions/root:sess-2'])
scans.get('/sessions/root:sess-2')?.resolve(null)
await second
})
it('caps active roots globally and drains queued roots in FIFO order', async () => {
const scans = new Map<string, ReturnType<typeof deferred<string | null>>>()
const started: string[] = []
setGrokSessionPathScannerForTests(async (root) => {
started.push(root)
const scan = deferred<string | null>()
scans.set(root, scan)
return scan.promise
})
const roots = Array.from(
{ length: GROK_SESSION_SCAN_ACTIVE_ROOT_MAX + 2 },
(_, index) => `/sessions/root-${index}`
)
const lookups = roots.map((root, index) =>
findGrokChatHistoryBySessionId(root, `sess-${index}`)
)
expect(started).toEqual(roots.slice(0, GROK_SESSION_SCAN_ACTIVE_ROOT_MAX))
scans.get(roots[0])?.resolve(null)
await lookups[0]
expect(started.at(-1)).toBe(roots[GROK_SESSION_SCAN_ACTIVE_ROOT_MAX])
scans.get(roots[1])?.resolve(null)
await lookups[1]
expect(started.at(-1)).toBe(roots[GROK_SESSION_SCAN_ACTIVE_ROOT_MAX + 1])
for (const root of roots.slice(2)) {
scans.get(root)?.resolve(null)
}
await Promise.all(lookups)
})
it('resolves queue overflow to null and drains the bounded queue', async () => {
const firstScan = deferred<string | null>()
let calls = 0
setGrokSessionPathScannerForTests(async () => {
calls += 1
return calls === 1 ? firstScan.promise : null
})
const active = findGrokChatHistoryBySessionId('/sessions/overflow', 'sess-active')
const queued = Array.from({ length: GROK_SESSION_SCAN_QUEUE_MAX_ENTRIES }, (_, index) =>
findGrokChatHistoryBySessionId('/sessions/overflow', `sess-queued-${index}`)
)
const overflow = findGrokChatHistoryBySessionId('/sessions/overflow', 'sess-overflow')
await expect(overflow).resolves.toBeNull()
expect(calls).toBe(1)
firstScan.resolve(null)
await expect(active).resolves.toBeNull()
await expect(Promise.all(queued)).resolves.toEqual(
Array.from({ length: GROK_SESSION_SCAN_QUEUE_MAX_ENTRIES }, () => null)
)
expect(calls).toBe(1 + GROK_SESSION_SCAN_QUEUE_MAX_ENTRIES)
})
it('cleans up a rejected scan and starts the next queued lookup', async () => {
const firstScan = deferred<string | null>()
const started: string[] = []
setGrokSessionPathScannerForTests(async (_root, sessionId) => {
started.push(sessionId)
if (sessionId === 'sess-reject') {
return firstScan.promise
}
return null
})
const rejected = findGrokChatHistoryBySessionId('/sessions/reject', 'sess-reject')
const next = findGrokChatHistoryBySessionId('/sessions/reject', 'sess-next')
firstScan.reject(new Error('scanner failed'))
await expect(rejected).resolves.toBeNull()
await expect(next).resolves.toBeNull()
expect(started).toEqual(['sess-reject', 'sess-next'])
})
it('does not synchronously scan sessions when cwd is absent', async () => {
const root = makeRoot()
const sessionsDir = join(root, 'sessions')
const sessionId = 'sess-env'
const history = join(sessionsDir, encodeURIComponent('/repo'), sessionId, 'chat_history.jsonl')
mkdirSync(dirname(history), { recursive: true })
writeFileSync(history, '{}\n')
expect(
resolveGrokChatHistoryPathSync({
sessionId,
sessionsDir,
env: { GROK_HOME: root }
})
).toBeNull()
await expect(findGrokChatHistoryBySessionId(sessionsDir, sessionId)).resolves.toBe(history)
})
})