From 7fb201f60f5feb65e26fe79c1ea899c97f26091f Mon Sep 17 00:00:00 2001 From: Neil Date: Wed, 23 Sep 2026 13:28:55 -0700 Subject: [PATCH] fix(jcode): stop the OSC color skip from crashing every pane connect Co-authored-by: czzczz --- .../ipc/pty-spawn-env-terminal-basics.test.ts | 8 +- src/main/jcode/daemon-prewarm.ts | 27 ++++--- src/main/jcode/hook-gate-script.test.ts | 2 +- src/main/jcode/hook-service.test.ts | 2 +- ...y-connection-hidden-atlas-recovery.test.ts | 41 +++++----- .../pty-connection/pane-agent-identity.ts | 33 ++++---- .../pane-launch-agent-candidate.test.ts | 75 +++++++++++++++++++ .../pane-launch-agent-candidate.ts | 60 +++++++++++++++ .../pty-connection/pty-input-recovery.ts | 2 +- .../terminal-capability-replies.test.ts | 2 +- .../terminal-capability-replies.ts | 12 +-- .../providers/jcode-tool-fields.ts | 2 +- src/shared/commit-message-agent-output.ts | 12 ++- src/shared/jcode-session-files.test.ts | 2 +- src/shared/jcode-session-files.ts | 31 ++++---- 15 files changed, 231 insertions(+), 80 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/pane-launch-agent-candidate.test.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/pane-launch-agent-candidate.ts diff --git a/src/main/ipc/pty-spawn-env-terminal-basics.test.ts b/src/main/ipc/pty-spawn-env-terminal-basics.test.ts index 33a99723986..224238bff9a 100644 --- a/src/main/ipc/pty-spawn-env-terminal-basics.test.ts +++ b/src/main/ipc/pty-spawn-env-terminal-basics.test.ts @@ -345,6 +345,7 @@ describe('registerPtyHandlers', () => { const tabId = 'tab-1' const paneKey = makePaneKey(tabId, leafId) handlers.clear() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the suite's mock BrowserWindow, widened the same way every other registerPtyHandlers call in this file does; the handler only touches webContents.send. registerPtyHandlers(mainWindow as never) await handlers.get('pty:spawn')!(null, { cols: 80, @@ -354,12 +355,13 @@ describe('registerPtyHandlers', () => { leafId, worktreeId: 'wt-1' }) - const spawnOptions = spawnMock.mock.calls.at(-1)![2] as { env: Record } + const spawnedEnv: Record = + spawnMock.mock.calls.at(-1)?.[2]?.env ?? {} // Why: buildJcodeRuntimeDirEnv intentionally omits the var on win32. if (shouldInjectJcodeRuntimeDir(process.platform)) { - expect(spawnOptions.env.JCODE_RUNTIME_DIR).toBe(buildJcodeRuntimeDir(paneKey)) + expect(spawnedEnv.JCODE_RUNTIME_DIR).toBe(buildJcodeRuntimeDir(paneKey)) } else { - expect(spawnOptions.env.JCODE_RUNTIME_DIR).toBeUndefined() + expect(spawnedEnv.JCODE_RUNTIME_DIR).toBeUndefined() } }) it('strips inherited Claude child-session stamps from a local spawn env', async () => { diff --git a/src/main/jcode/daemon-prewarm.ts b/src/main/jcode/daemon-prewarm.ts index 4566e13584a..b7c7e67ff86 100644 --- a/src/main/jcode/daemon-prewarm.ts +++ b/src/main/jcode/daemon-prewarm.ts @@ -16,19 +16,26 @@ export function resetJcodeDaemonPrewarmForTests(): void { prewarmedRuntimeDirs.clear() } +/** The runtime dir to warm, or null when this pane is not a local jcode launch. */ +export function resolveJcodePrewarmRuntimeDir(args: { + launchAgent?: string + runtimeDir?: string + platform?: NodeJS.Platform +}): string | null { + // Why non-Windows only: the runtime dir is a unix-socket directory, and Orca + // only stamps it off Windows (see shouldInjectJcodeRuntimeDir). + if (args.launchAgent !== 'jcode' || (args.platform ?? process.platform) === 'win32') { + return null + } + return args.runtimeDir !== undefined && args.runtimeDir.length > 0 ? args.runtimeDir : null +} + export function shouldPrewarmJcodeDaemon(args: { launchAgent?: string runtimeDir?: string platform?: NodeJS.Platform }): boolean { - // Why non-Windows only: the runtime dir is a unix-socket directory, and Orca - // only stamps it off Windows (see shouldInjectJcodeRuntimeDir). - return ( - args.launchAgent === 'jcode' && - typeof args.runtimeDir === 'string' && - args.runtimeDir.length > 0 && - (args.platform ?? process.platform) !== 'win32' - ) + return resolveJcodePrewarmRuntimeDir(args) !== null } /** @@ -45,10 +52,10 @@ export function prewarmJcodeDaemon(args: { env?: Record platform?: NodeJS.Platform }): boolean { - if (!shouldPrewarmJcodeDaemon(args) || prewarmedRuntimeDirs.has(args.runtimeDir as string)) { + const runtimeDir = resolveJcodePrewarmRuntimeDir(args) + if (runtimeDir === null || prewarmedRuntimeDirs.has(runtimeDir)) { return false } - const runtimeDir = args.runtimeDir as string prewarmedRuntimeDirs.add(runtimeDir) try { const child = spawnProcess({ diff --git a/src/main/jcode/hook-gate-script.test.ts b/src/main/jcode/hook-gate-script.test.ts index 31e539f5e0e..78865e8b061 100644 --- a/src/main/jcode/hook-gate-script.test.ts +++ b/src/main/jcode/hook-gate-script.test.ts @@ -7,7 +7,7 @@ import { dirname, join } from 'node:path' const { homedirMock } = vi.hoisted(() => ({ homedirMock: vi.fn<() => string>() })) vi.mock('os', async () => { - const actual = (await vi.importActual('os')) as Record + const actual = await vi.importActual('os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() return { ...actual, homedir: homedirMock } }) diff --git a/src/main/jcode/hook-service.test.ts b/src/main/jcode/hook-service.test.ts index 24ccb6f175a..86c9f90e52c 100644 --- a/src/main/jcode/hook-service.test.ts +++ b/src/main/jcode/hook-service.test.ts @@ -8,7 +8,7 @@ const { homedirMock } = vi.hoisted(() => ({ })) vi.mock('os', async () => { - const actual = (await vi.importActual('os')) as Record + const actual = await vi.importActual('os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() return { ...actual, homedir: homedirMock } }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-hidden-atlas-recovery.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-hidden-atlas-recovery.test.ts index 2be9ad15c5a..e0778fdffb7 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-hidden-atlas-recovery.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-hidden-atlas-recovery.test.ts @@ -134,6 +134,19 @@ function expectNoGlobalAtlasRecovery(): void { expect(resetAndRefreshAllTerminalWebglAtlases).not.toHaveBeenCalled() } +// Why: connectPanePty takes the real pane/manager/deps types and every test in this file +// passes structural fakes. Funnelling the widening through one helper keeps the casts in +// a single reviewable place instead of three per test. +function connectFakePane( + connect: (pane: never, manager: never, deps: never) => { dispose: () => void }, + pane: unknown, + manager: unknown, + deps: Parameters[0] +): { dispose: () => void } { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the three values are this suite's structural fakes for pane/manager/deps; connectPanePty only reads the fields they define. + return connect(pane as never, manager as never, createDeps(deps) as never) +} + describe('connectPanePty', () => { beforeEach(async () => { vi.resetModules() @@ -552,24 +565,18 @@ describe('connectPanePty', () => { }) transportFactoryQueue.push(transport) - const pane = createPane(1) - const manager = createManager(1) - const binding = connectPanePty( - pane as never, - manager as never, - createDeps({ - isVisibleRef: { current: false }, - startup: { - command: 'jcode', - launchAgent: 'jcode', - telemetry: { - agent_kind: 'jcode', - launch_source: 'tab_bar_quick_launch', - request_kind: 'new' - } + const binding = connectFakePane(connectPanePty, createPane(1), createManager(1), { + isVisibleRef: { current: false }, + startup: { + command: 'jcode', + launchAgent: 'jcode', + telemetry: { + agent_kind: 'jcode', + launch_source: 'tab_bar_quick_launch', + request_kind: 'new' } - }) as never - ) + } + }) await flushAsyncTicks(6) expect(capturedDataCallback.current).not.toBeNull() diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pane-agent-identity.ts b/src/renderer/src/components/terminal-pane/pty-connection/pane-agent-identity.ts index 77193db96ce..6116047a75e 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/pane-agent-identity.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/pane-agent-identity.ts @@ -17,6 +17,11 @@ import { resolveCommittedTitleAgentType } from '@/lib/pane-agent-evidence' import type { TuiAgent } from '../../../../../shared/tui-agent' import { isTuiAgent, TUI_AGENT_CONFIG } from '../../../../../shared/tui-agent-config' +import { + paneShouldAnswerOscColorQueries, + resolvePaneLaunchAgentCandidate, + type PaneLaunchAgentPaneSlice +} from './pane-launch-agent-candidate' import type { ConnectPanePtySession } from './connect-pane-pty-session' /** Pane agent identity, foreground-agent sampling, and command lifecycle handling. */ @@ -28,22 +33,18 @@ export function installPaneAgentIdentity(session: ConnectPanePtySession): void { const entry = state.agentStatusByPaneKey[session.cacheKey] return entry?.state !== 'done' && Boolean(agentTypeToIconAgent(entry?.agentType)) } - // Why: one ladder for both launch-agent signals; a second copy could drift. + // Why the shared module: the same ladder answers the 133;D guard, the visible-pane + // resampler, and the OSC color-reply skip, and it is the one piece of this installer + // that is a pure rule worth testing on its own. + const panePane = (): PaneLaunchAgentPaneSlice => ({ + worktreeId: session.deps.worktreeId, + tabId: session.deps.tabId, + paneKey: session.cacheKey, + startup: session.paneStartup + }) const resolveLaunchAgentCandidate = ( state: ReturnType - ): string | undefined => { - const tab = (state.tabsByWorktree[session.deps.worktreeId] ?? []).find( - (candidate) => candidate.id === session.deps.tabId - ) - const registeredLaunchAgent = - state.agentLaunchConfigByPaneKey[session.cacheKey]?.identity.agentType - return ( - tab?.launchAgent ?? - session.paneStartup?.launchAgent ?? - session.paneStartup?.initialAgentStatus?.agent ?? - (isTuiAgent(registeredLaunchAgent) ? registeredLaunchAgent : undefined) - ) - } + ): string | undefined => resolvePaneLaunchAgentCandidate(state, panePane()) session.paneExpectsLaunchAgent = (state: ReturnType): boolean => Boolean(resolveLaunchAgentCandidate(state)) // Why: the concrete TUI agent a fresh spawn is expected to launch, used to seed @@ -57,14 +58,14 @@ export function installPaneAgentIdentity(session: ConnectPanePtySession): void { // composer is ready and render the reply as pre-typed text (the main-side // startup ingress already skips it, the renderer must skip the answer too). session.shouldAnswerPaneOscColorQueries = (): boolean => - session.resolveExpectedLaunchTuiAgent() !== 'jcode' + paneShouldAnswerOscColorQueries(useAppStore.getState(), panePane()) // Why: a launched/hook-known agent pane must confirm — not trust — a 133;D so a // full-screen agent's leaked nested-shell 133;D can't clear its tab identity, // even on a restore where no command-start read has recorded evidence yet. session.paneHasKnownAgentIdentity = (): boolean => { const state = useAppStore.getState() const registeredLaunchAgent = - state.agentLaunchConfigByPaneKey[session.cacheKey]?.identity.agentType + state.agentLaunchConfigByPaneKey[session.cacheKey]?.identity?.agentType return ( Boolean(state.paneForegroundAgentByPaneKey[session.cacheKey]?.agent) || session.paneHasLiveHookAgentIcon(state) || diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pane-launch-agent-candidate.test.ts b/src/renderer/src/components/terminal-pane/pty-connection/pane-launch-agent-candidate.test.ts new file mode 100644 index 00000000000..815d9de0c4f --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/pane-launch-agent-candidate.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest' +import { + paneShouldAnswerOscColorQueries, + resolvePaneLaunchAgentCandidate, + resolvePaneLaunchTuiAgent, + type PaneLaunchAgentPaneSlice, + type PaneLaunchAgentStoreSlice +} from './pane-launch-agent-candidate' + +const PANE: PaneLaunchAgentPaneSlice = { + worktreeId: 'wt-1', + tabId: 'tab-1', + paneKey: 'tab-1:leaf-1' +} + +function store(overrides: Partial = {}): PaneLaunchAgentStoreSlice { + return { tabsByWorktree: {}, agentLaunchConfigByPaneKey: {}, ...overrides } +} + +describe('pane launch-agent candidate', () => { + it('survives a launch config registered before its identity lands', () => { + // Why: the OSC color-reply skip made this ladder run on EVERY pane connect, and + // reading through a missing `identity` threw "Cannot read properties of undefined + // (reading 'agentType')" — 24 failures across 8 connectPanePty suites. + const state = store({ agentLaunchConfigByPaneKey: { 'tab-1:leaf-1': {} } }) + expect(resolvePaneLaunchAgentCandidate(state, PANE)).toBeUndefined() + expect(resolvePaneLaunchTuiAgent(state, PANE)).toBeNull() + expect(paneShouldAnswerOscColorQueries(state, PANE)).toBe(true) + }) + + it('prefers the tab launch agent over every later signal', () => { + const state = store({ tabsByWorktree: { 'wt-1': [{ id: 'tab-1', launchAgent: 'jcode' }] } }) + const pane = { ...PANE, startup: { launchAgent: 'claude' } } + expect(resolvePaneLaunchTuiAgent(state, pane)).toBe('jcode') + }) + + it('falls back through startup, then initial status, then the registered config', () => { + expect(resolvePaneLaunchTuiAgent(store(), { ...PANE, startup: { launchAgent: 'jcode' } })).toBe( + 'jcode' + ) + expect( + resolvePaneLaunchTuiAgent(store(), { + ...PANE, + startup: { initialAgentStatus: { agent: 'jcode' } } + }) + ).toBe('jcode') + expect( + resolvePaneLaunchTuiAgent( + store({ + agentLaunchConfigByPaneKey: { 'tab-1:leaf-1': { identity: { agentType: 'jcode' } } } + }), + PANE + ) + ).toBe('jcode') + }) + + it('ignores a registered agent name that is not a known TUI agent', () => { + const state = store({ + agentLaunchConfigByPaneKey: { 'tab-1:leaf-1': { identity: { agentType: 'not-an-agent' } } } + }) + expect(resolvePaneLaunchAgentCandidate(state, PANE)).toBeUndefined() + }) + + it('skips the OSC color answer for jcode and no one else', () => { + for (const [launchAgent, answers] of [ + ['jcode', false], + ['claude', true], + ['codex', true], + [undefined, true] + ] as const) { + const state = store({ tabsByWorktree: { 'wt-1': [{ id: 'tab-1', launchAgent }] } }) + expect(paneShouldAnswerOscColorQueries(state, PANE)).toBe(answers) + } + }) +}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pane-launch-agent-candidate.ts b/src/renderer/src/components/terminal-pane/pty-connection/pane-launch-agent-candidate.ts new file mode 100644 index 00000000000..a8525470c46 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/pane-launch-agent-candidate.ts @@ -0,0 +1,60 @@ +import { isTuiAgent } from '../../../../../shared/tui-agent-config' +import type { TuiAgent } from '../../../../../shared/tui-agent' + +/** The store slice the launch-agent ladder reads. */ +export type PaneLaunchAgentStoreSlice = { + tabsByWorktree: Record + agentLaunchConfigByPaneKey: Record +} + +export type PaneLaunchAgentPaneSlice = { + worktreeId: string + tabId: string + paneKey: string + startup?: { launchAgent?: string; initialAgentStatus?: { agent?: string } } +} + +/** + * The launch agent a pane is expected to run, from the first signal that has one. + * + * Why one ladder: the 133;D confirmation guard, the visible-pane resampler, and the + * OSC color-reply skip all key off "what agent is this pane", and a second copy would + * drift. Why `identity` is optional: a launch config is registered before its identity + * lands, and reading through it unguarded threw on every pane connect once the + * color-reply skip started calling this on all of them. + */ +export function resolvePaneLaunchAgentCandidate( + state: PaneLaunchAgentStoreSlice, + pane: PaneLaunchAgentPaneSlice +): string | undefined { + const tab = (state.tabsByWorktree[pane.worktreeId] ?? []).find( + (candidate) => candidate.id === pane.tabId + ) + const registeredLaunchAgent = state.agentLaunchConfigByPaneKey[pane.paneKey]?.identity?.agentType + return ( + tab?.launchAgent ?? + pane.startup?.launchAgent ?? + pane.startup?.initialAgentStatus?.agent ?? + (isTuiAgent(registeredLaunchAgent) ? registeredLaunchAgent : undefined) + ) +} + +/** The same ladder, narrowed to a recognized TUI agent. */ +export function resolvePaneLaunchTuiAgent( + state: PaneLaunchAgentStoreSlice, + pane: PaneLaunchAgentPaneSlice +): TuiAgent | null { + const candidate = resolvePaneLaunchAgentCandidate(state, pane) + return isTuiAgent(candidate) ? candidate : null +} + +// Why: jcode paints its own theme and fires its OSC 10/11 burst before its TUI input +// loop is ready, so the cooked reply lands in the composer as pre-typed text (the same +// class as #12112, which fixed opencode). The main-side startup ingress already skips +// it; the renderer's capability handlers must skip the answer too. +export function paneShouldAnswerOscColorQueries( + state: PaneLaunchAgentStoreSlice, + pane: PaneLaunchAgentPaneSlice +): boolean { + return resolvePaneLaunchTuiAgent(state, pane) !== 'jcode' +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pty-input-recovery.ts b/src/renderer/src/components/terminal-pane/pty-connection/pty-input-recovery.ts index bedd9d0994f..aa226a3a78e 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/pty-input-recovery.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/pty-input-recovery.ts @@ -215,7 +215,7 @@ export function installPtyInputRecovery(session: ConnectPanePtySession): void { sixelSupported: () => resolveTerminalInlineImagesEnabled(useAppStore.getState().settings?.terminalInlineImages) && terminalRendersInlineImages(session.pane.terminal), - skipOscColorQueryReplies: !session.shouldAnswerPaneOscColorQueries(), + skipOscColorQueryReplies: () => !session.shouldAnswerPaneOscColorQueries(), ...(session.isNativeWindowsConpty ? { da1Response: CONPTY_DA1_RESPONSE } : {}) }) session.respondToTerminalPixelSizeQueries = createTerminalPixelSizeQueryResponder( diff --git a/src/renderer/src/components/terminal-pane/terminal-capability-replies.test.ts b/src/renderer/src/components/terminal-pane/terminal-capability-replies.test.ts index e5fc4db5a4b..bc59ffcd4a4 100644 --- a/src/renderer/src/components/terminal-pane/terminal-capability-replies.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-capability-replies.test.ts @@ -173,7 +173,7 @@ describe('installTerminalCapabilityReplyHandlers', () => { parser: term.parser, sendInput, isReplaying: () => false, - skipOscColorQueryReplies: true + skipOscColorQueryReplies: () => true }) try { diff --git a/src/renderer/src/components/terminal-pane/terminal-capability-replies.ts b/src/renderer/src/components/terminal-pane/terminal-capability-replies.ts index ebb5464629f..504c5067f17 100644 --- a/src/renderer/src/components/terminal-pane/terminal-capability-replies.ts +++ b/src/renderer/src/components/terminal-pane/terminal-capability-replies.ts @@ -22,9 +22,11 @@ type TerminalCapabilityRepliesDeps = { da1Response?: string // Resolved per query so a live inline-images toggle changes what the next DA1 advertises. sixelSupported?: () => boolean - /** Why: jcode themes itself; answering its OSC color burst can land before its - * composer is ready and render the reply as pre-typed text. */ - skipOscColorQueryReplies?: boolean + /** Why a thunk, like sixelSupported: the pane's launch agent may not be registered + * in the store yet when the handlers are installed, so a boolean captured here + * answers jcode's burst anyway. Why skip at all: jcode themes itself, and the reply + * can land before its composer is ready and render as pre-typed text. */ + skipOscColorQueryReplies?: () => boolean } // Adds Sixel to a DA1 response so DA1-detecting image tools emit Sixel; idempotent. @@ -168,7 +170,7 @@ export function installTerminalCapabilityReplyHandlers( if (!slots) { return false } - if (deps.isReplaying() || deps.skipOscColorQueryReplies === true) { + if (deps.isReplaying() || deps.skipOscColorQueryReplies?.() === true) { return true } return sendTerminalOscColorQueryRepliesForSlots(slots, deps.terminal, deps.sendInput) @@ -181,7 +183,7 @@ export function installTerminalCapabilityReplyHandlers( if (!slots) { return false } - if (deps.isReplaying() || deps.skipOscColorQueryReplies === true) { + if (deps.isReplaying() || deps.skipOscColorQueryReplies?.() === true) { return true } return sendTerminalOscColorQueryRepliesForSlots(slots, deps.terminal, deps.sendInput) diff --git a/src/shared/agent-hook-listener/providers/jcode-tool-fields.ts b/src/shared/agent-hook-listener/providers/jcode-tool-fields.ts index be1e794e95c..5c4392a83ab 100644 --- a/src/shared/agent-hook-listener/providers/jcode-tool-fields.ts +++ b/src/shared/agent-hook-listener/providers/jcode-tool-fields.ts @@ -39,7 +39,7 @@ function readJcodeIntent(toolInput: unknown): string | undefined { if (typeof toolInput !== 'object' || toolInput === null) { return undefined } - const intent = (toolInput as Record).intent + const intent: unknown = Reflect.get(toolInput, 'intent') return typeof intent === 'string' && intent.trim().length > 0 ? intent : undefined } diff --git a/src/shared/commit-message-agent-output.ts b/src/shared/commit-message-agent-output.ts index c22acece489..7101ec5280a 100644 --- a/src/shared/commit-message-agent-output.ts +++ b/src/shared/commit-message-agent-output.ts @@ -11,13 +11,11 @@ export function cleanGeneratedCommitMessage(raw: string): string { // answer field when the output is such an envelope. if (text.startsWith('{')) { try { - const parsed = JSON.parse(text) as unknown - if ( - typeof parsed === 'object' && - parsed !== null && - typeof (parsed as Record).text === 'string' - ) { - text = (parsed as Record).text.trim() + const parsed: unknown = JSON.parse(text) + const answer: unknown = + typeof parsed === 'object' && parsed !== null ? Reflect.get(parsed, 'text') : undefined + if (typeof answer === 'string') { + text = answer.trim() } } catch { // not a JSON envelope; fall through to the plain-text cleanup diff --git a/src/shared/jcode-session-files.test.ts b/src/shared/jcode-session-files.test.ts index 80a983199e1..6cd520af4b2 100644 --- a/src/shared/jcode-session-files.test.ts +++ b/src/shared/jcode-session-files.test.ts @@ -8,7 +8,7 @@ const { homedirMock } = vi.hoisted(() => ({ })) vi.mock('os', async () => { - const actual = (await vi.importActual('os')) as Record + const actual = await vi.importActual('os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() return { ...actual, homedir: homedirMock } }) diff --git a/src/shared/jcode-session-files.ts b/src/shared/jcode-session-files.ts index 5c848afc78d..73c5eeb04a9 100644 --- a/src/shared/jcode-session-files.ts +++ b/src/shared/jcode-session-files.ts @@ -14,6 +14,10 @@ const JCODE_JOURNAL_CHUNK_BYTES = 64 * 1024 const JCODE_JSON_DOC_MAX_PARSE_BYTES = 8 * 1024 * 1024 const EMPTY_REGION = Buffer.alloc(0) +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + export function resolveJcodeSessionsDir( env: NodeJS.ProcessEnv = process.env, homeDir: string = homedir() @@ -58,11 +62,8 @@ function messageText(message: Record): string | null { parts.push(block) continue } - if (block && typeof block === 'object') { - const text = (block as Record).text - if (typeof text === 'string') { - parts.push(text) - } + if (isRecord(block) && typeof block.text === 'string') { + parts.push(block.text) } } const joined = parts.join(' ').replace(/\s+/g, ' ').trim() @@ -99,19 +100,18 @@ function readLastUserMessageFromJournalLines( } catch { continue } - if (typeof entry !== 'object' || entry === null) { + if (!isRecord(entry)) { continue } - const appendMessages = (entry as Record).append_messages + const appendMessages = entry.append_messages if (!Array.isArray(appendMessages)) { continue } for (let messageIndex = appendMessages.length - 1; messageIndex >= 0; messageIndex -= 1) { - const message = appendMessages[messageIndex] - if (typeof message !== 'object' || message === null) { + const record = appendMessages[messageIndex] + if (!isRecord(record)) { continue } - const record = message as Record if (record.role !== 'user' || isInjectedContextMessage(record)) { continue } @@ -214,20 +214,19 @@ function readLastUserMessageFromJson( return null } try { - const parsed = JSON.parse(readFileSync(jsonPath, 'utf8')) as unknown - if (typeof parsed !== 'object' || parsed === null) { + const parsed: unknown = JSON.parse(readFileSync(jsonPath, 'utf8')) + if (!isRecord(parsed)) { return null } - const messages = (parsed as Record).messages + const messages = parsed.messages if (!Array.isArray(messages)) { return null } for (let index = messages.length - 1; index >= 0; index -= 1) { - const message = messages[index] - if (typeof message !== 'object' || message === null) { + const record = messages[index] + if (!isRecord(record)) { continue } - const record = message as Record if (record.role !== 'user' || isInjectedContextMessage(record)) { continue }