mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 00:03:47 +00:00
fix(native-chat): route transcripts by PTY provenance
This commit is contained in:
@@ -100,12 +100,15 @@ function createSender(id: number): SenderHarness {
|
||||
}
|
||||
}
|
||||
|
||||
function subscribe(sender: SenderHarness['sender'], subscriptionId: string): void {
|
||||
function subscribe(sender: SenderHarness['sender'], subscriptionId: string, ptyId?: string): void {
|
||||
const listener = listeners.get('nativeChat:subscribe')
|
||||
if (!listener) {
|
||||
throw new Error('subscribe listener not registered')
|
||||
}
|
||||
listener({ sender }, { subscriptionId, agent: 'claude', sessionId: `session-${subscriptionId}` })
|
||||
listener(
|
||||
{ sender },
|
||||
{ subscriptionId, agent: 'claude', sessionId: `session-${subscriptionId}`, ptyId }
|
||||
)
|
||||
}
|
||||
|
||||
type InitialSnapshotCallback = (
|
||||
@@ -116,6 +119,42 @@ type InitialSnapshotCallback = (
|
||||
lifecycle?: NativeChatTurnLifecycle
|
||||
) => void
|
||||
|
||||
it('resolves PTY provenance before installing the desktop watcher', async () => {
|
||||
clearNativeChatSubscriptions()
|
||||
handlers.clear()
|
||||
listeners.clear()
|
||||
const resolveTranscriptHost = vi.fn(() => ({ kind: 'wsl' as const, distro: 'Ubuntu' }))
|
||||
registerNativeChatHandlers({ resolveTranscriptHost })
|
||||
subscribeTranscript.mockResolvedValue({ unsubscribe: vi.fn(), watching: true })
|
||||
const sender = createSender(99)
|
||||
|
||||
subscribe(sender.sender, 'pty-route', 'pty-1')
|
||||
await vi.waitFor(() => expect(subscribeTranscript).toHaveBeenCalledOnce())
|
||||
|
||||
expect(resolveTranscriptHost).toHaveBeenCalledWith('pty-1')
|
||||
expect(subscribeTranscript.mock.calls[0]?.[0]).toEqual(
|
||||
expect.objectContaining({ transcriptHost: { kind: 'wsl', distro: 'Ubuntu' } })
|
||||
)
|
||||
})
|
||||
|
||||
it('fails closed without installing a watcher for a missing PTY identity', async () => {
|
||||
clearNativeChatSubscriptions()
|
||||
handlers.clear()
|
||||
listeners.clear()
|
||||
registerNativeChatHandlers({ resolveTranscriptHost: () => null })
|
||||
const sender = createSender(100)
|
||||
|
||||
subscribe(sender.sender, 'missing-route', 'missing-pty')
|
||||
await vi.waitFor(() => expect(sender.sender.send).toHaveBeenCalledOnce())
|
||||
|
||||
expect(subscribeTranscript).not.toHaveBeenCalled()
|
||||
expect(sender.sender.send.mock.calls[0]?.[1]).toEqual(
|
||||
expect.objectContaining({
|
||||
frame: expect.objectContaining({ error: 'Transcript unavailable' })
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
// The onInitialSnapshot callback the handler passed into the Nth subscribeTranscript
|
||||
// call; transcript-watch fires it during setup, so tests invoke it directly.
|
||||
function initialSnapshot(callIndex: number): InitialSnapshotCallback {
|
||||
|
||||
@@ -54,13 +54,17 @@ async function waitFor(predicate: () => boolean, timeoutMs = 2000): Promise<void
|
||||
}
|
||||
}
|
||||
|
||||
async function invokeReadSession(args: {
|
||||
agent: string
|
||||
sessionId: string
|
||||
limit?: number
|
||||
transcriptPath?: string
|
||||
}): Promise<unknown> {
|
||||
registerNativeChatHandlers()
|
||||
async function invokeReadSession(
|
||||
args: {
|
||||
agent: string
|
||||
sessionId: string
|
||||
limit?: number
|
||||
transcriptPath?: string
|
||||
ptyId?: string
|
||||
},
|
||||
deps?: Parameters<typeof registerNativeChatHandlers>[0]
|
||||
): Promise<unknown> {
|
||||
registerNativeChatHandlers(deps)
|
||||
const handler = handlers.get('nativeChat:readSession')
|
||||
if (!handler) {
|
||||
throw new Error('handler not registered')
|
||||
@@ -69,6 +73,26 @@ async function invokeReadSession(args: {
|
||||
}
|
||||
|
||||
describe('nativeChat:readSession handler', () => {
|
||||
it('asks the PTY owner for transcript provenance', async () => {
|
||||
const resolveTranscriptHost = vi.fn(() => ({ kind: 'host' as const }))
|
||||
|
||||
await invokeReadSession(
|
||||
{ agent: 'claude', sessionId: 'missing-session', ptyId: 'pty-1' },
|
||||
{ resolveTranscriptHost }
|
||||
)
|
||||
|
||||
expect(resolveTranscriptHost).toHaveBeenCalledWith('pty-1')
|
||||
})
|
||||
|
||||
it('fails closed when a supplied PTY identity is missing', async () => {
|
||||
const result = (await invokeReadSession(
|
||||
{ agent: 'claude', sessionId: 'same-id', ptyId: 'missing-pty' },
|
||||
{ resolveTranscriptHost: () => null }
|
||||
)) as { error?: string; notFound?: true }
|
||||
|
||||
expect(result).toEqual({ error: 'Transcript unavailable' })
|
||||
})
|
||||
|
||||
it('preserves notFound so a just-created session stays in retry/loading', async () => {
|
||||
const result = (await invokeReadSession({
|
||||
agent: 'claude',
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
type NativeChatTranscriptSubscription,
|
||||
type SubscribeNativeChatTranscriptArgs
|
||||
} from '../native-chat/transcript-watch'
|
||||
import type { NativeChatTranscriptHost } from '../native-chat/transcript-host'
|
||||
|
||||
// Re-export so existing test imports of `clearNativeChatTranscriptCache` from
|
||||
// this module keep working after the cache moved to transcript-read-cache.ts.
|
||||
@@ -26,20 +27,41 @@ export type NativeChatReadSessionArgs = {
|
||||
/** Authoritative transcript path from the agent hook (providerSession), used to
|
||||
* locate the file when the session id no longer names it (recent Claude Code). */
|
||||
transcriptPath?: string
|
||||
/** PTY whose host owns this transcript. */
|
||||
ptyId?: string
|
||||
}
|
||||
|
||||
type NativeChatHandlerDeps = {
|
||||
resolveTranscriptHost?: (ptyId: string) => NativeChatTranscriptHost | null | undefined
|
||||
}
|
||||
|
||||
function requestedTranscriptHost(
|
||||
ptyId: string | undefined,
|
||||
deps: NativeChatHandlerDeps
|
||||
): NativeChatTranscriptHost | null | undefined {
|
||||
return ptyId ? deps.resolveTranscriptHost?.(ptyId) : undefined
|
||||
}
|
||||
|
||||
// Why: render and parse only the recent window so long transcripts do not stall
|
||||
// either the main process or the message list. Pagination raises this limit.
|
||||
const DESKTOP_READ_WINDOW = 300
|
||||
|
||||
async function readSession(args: NativeChatReadSessionArgs): Promise<ReadTranscriptResult> {
|
||||
async function readSession(
|
||||
args: NativeChatReadSessionArgs,
|
||||
deps: NativeChatHandlerDeps
|
||||
): Promise<ReadTranscriptResult> {
|
||||
const { agent, sessionId } = args
|
||||
// Clamp to a positive window; default to the desktop window for the first page.
|
||||
const limit = args.limit && args.limit > 0 ? Math.floor(args.limit) : DESKTOP_READ_WINDOW
|
||||
const transcriptHost = requestedTranscriptHost(args.ptyId, deps)
|
||||
if (transcriptHost === null) {
|
||||
return { error: 'Transcript unavailable' }
|
||||
}
|
||||
return readNativeChatTranscriptTail({
|
||||
agent,
|
||||
sessionId,
|
||||
transcriptPath: args.transcriptPath,
|
||||
transcriptHost,
|
||||
limit
|
||||
})
|
||||
}
|
||||
@@ -52,6 +74,7 @@ export type NativeChatSubscribeArgs = {
|
||||
sessionId: string
|
||||
/** Authoritative transcript path from the agent hook (providerSession). */
|
||||
transcriptPath?: string
|
||||
ptyId?: string
|
||||
limit?: number
|
||||
}
|
||||
|
||||
@@ -165,7 +188,11 @@ function takePendingSubscription(
|
||||
return true
|
||||
}
|
||||
|
||||
async function handleSubscribe(event: IpcMainEvent, args: NativeChatSubscribeArgs): Promise<void> {
|
||||
async function handleSubscribe(
|
||||
event: IpcMainEvent,
|
||||
args: NativeChatSubscribeArgs,
|
||||
deps: NativeChatHandlerDeps
|
||||
): Promise<void> {
|
||||
const sender = event.sender
|
||||
if (sender.isDestroyed()) {
|
||||
return
|
||||
@@ -175,11 +202,26 @@ async function handleSubscribe(event: IpcMainEvent, args: NativeChatSubscribeArg
|
||||
// Replace any prior subscription under the same id (session change/resubscribe).
|
||||
const pending = beginPendingSubscription(sender.id, subscriptionId)
|
||||
registerSenderCleanup(sender)
|
||||
const transcriptHost = requestedTranscriptHost(args.ptyId, deps)
|
||||
if (transcriptHost === null) {
|
||||
takePendingSubscription(sender.id, subscriptionId, pending)
|
||||
sender.send('nativeChat:appended', {
|
||||
subscriptionId,
|
||||
frame: {
|
||||
type: 'snapshot',
|
||||
messages: [],
|
||||
hasMore: false,
|
||||
error: 'Transcript unavailable'
|
||||
}
|
||||
} satisfies NativeChatAppendedPayload)
|
||||
return
|
||||
}
|
||||
|
||||
const subscribeArgs: SubscribeNativeChatTranscriptArgs = {
|
||||
agent,
|
||||
sessionId,
|
||||
transcriptPath,
|
||||
transcriptHost,
|
||||
initialLimit: limit,
|
||||
onInitialSnapshot: (messages, hasMore, _beforeOffset, error, lifecycle) => {
|
||||
if (sender.isDestroyed()) {
|
||||
@@ -287,12 +329,12 @@ export function _getNativeChatPendingSubscriptionCountForTest(): number {
|
||||
return count
|
||||
}
|
||||
|
||||
export function registerNativeChatHandlers(): void {
|
||||
export function registerNativeChatHandlers(deps: NativeChatHandlerDeps = {}): void {
|
||||
ipcMain.handle('nativeChat:readSession', (_event, args: NativeChatReadSessionArgs) =>
|
||||
readSession(args)
|
||||
readSession(args, deps)
|
||||
)
|
||||
ipcMain.on('nativeChat:subscribe', (event, args: NativeChatSubscribeArgs) => {
|
||||
void handleSubscribe(event, args)
|
||||
void handleSubscribe(event, args, deps)
|
||||
})
|
||||
ipcMain.on('nativeChat:unsubscribe', (event, args: { subscriptionId: string }) => {
|
||||
teardownSubscription(event.sender.id, args.subscriptionId)
|
||||
|
||||
@@ -227,7 +227,9 @@ export function registerCoreHandlers(
|
||||
prepareRuntimeSessionResume: async (environmentId, args) =>
|
||||
prepareRuntimeAiVaultSessionResume(app.getPath('userData'), environmentId, args)
|
||||
})
|
||||
registerNativeChatHandlers()
|
||||
registerNativeChatHandlers({
|
||||
resolveTranscriptHost: (ptyId) => runtime.resolveNativeChatTranscriptHost(ptyId)
|
||||
})
|
||||
registerClipboardHandlers(store)
|
||||
registerUpdaterHandlers(store)
|
||||
registerSpeechHandlers(store)
|
||||
|
||||
@@ -6,7 +6,9 @@ import {
|
||||
resetHostReadableTranscriptPathCacheForTests,
|
||||
toHostReadableTranscriptPath,
|
||||
wslClaudeProjectsDirs,
|
||||
wslCodexSessionsDirs
|
||||
wslClaudeProjectsDirsForDistro,
|
||||
wslCodexSessionsDirs,
|
||||
wslCodexSessionsDirsForDistro
|
||||
} from './host-readable-transcript-path'
|
||||
|
||||
const UBUNTU_HOME = '\\\\wsl.localhost\\Ubuntu\\home\\ada'
|
||||
@@ -53,6 +55,100 @@ describe('toHostReadableTranscriptPath', () => {
|
||||
).resolves.toBe(ROLLOUT_UNC)
|
||||
})
|
||||
|
||||
it('translates through only the authoritative WSL distro', async () => {
|
||||
const listWslHomeDirs = vi.fn(async () => [DEBIAN_HOME])
|
||||
const pathExists = vi.fn(async (candidate: string) => candidate === ROLLOUT_UNC)
|
||||
|
||||
await expect(
|
||||
toHostReadableTranscriptPath(ROLLOUT_LINUX, {
|
||||
platform: 'win32',
|
||||
transcriptHost: { kind: 'wsl', distro: 'Ubuntu' },
|
||||
listWslHomeDirs,
|
||||
pathExists
|
||||
})
|
||||
).resolves.toBe(ROLLOUT_UNC)
|
||||
|
||||
expect(listWslHomeDirs).not.toHaveBeenCalled()
|
||||
expect(pathExists).toHaveBeenCalledTimes(1)
|
||||
expect(pathExists).toHaveBeenCalledWith(ROLLOUT_UNC)
|
||||
})
|
||||
|
||||
it('does not reinterpret a guest path when the PTY belongs to the host', async () => {
|
||||
const listWslHomeDirs = vi.fn(async () => [UBUNTU_HOME])
|
||||
const pathExists = vi.fn(async () => true)
|
||||
|
||||
await expect(
|
||||
toHostReadableTranscriptPath(ROLLOUT_LINUX, {
|
||||
platform: 'win32',
|
||||
transcriptHost: { kind: 'host' },
|
||||
listWslHomeDirs,
|
||||
pathExists
|
||||
})
|
||||
).resolves.toBeNull()
|
||||
|
||||
expect(listWslHomeDirs).not.toHaveBeenCalled()
|
||||
expect(pathExists).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects exact paths that contradict authoritative provenance', async () => {
|
||||
const pathExists = vi.fn(async () => true)
|
||||
|
||||
await expect(
|
||||
toHostReadableTranscriptPath(ROLLOUT_UNC, {
|
||||
platform: 'win32',
|
||||
transcriptHost: { kind: 'host' },
|
||||
pathExists
|
||||
})
|
||||
).resolves.toBeNull()
|
||||
await expect(
|
||||
toHostReadableTranscriptPath(ROLLOUT_UNC, {
|
||||
platform: 'win32',
|
||||
transcriptHost: { kind: 'wsl', distro: 'Debian' },
|
||||
pathExists
|
||||
})
|
||||
).resolves.toBeNull()
|
||||
await expect(
|
||||
toHostReadableTranscriptPath('C:\\Users\\ada\\rollout.jsonl', {
|
||||
platform: 'win32',
|
||||
transcriptHost: { kind: 'wsl', distro: 'Ubuntu' },
|
||||
pathExists
|
||||
})
|
||||
).resolves.toBeNull()
|
||||
|
||||
expect(pathExists).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('classifies extended WSL UNC paths before authoritative probing', async () => {
|
||||
const extendedUnc =
|
||||
'\\\\?\\UNC\\wsl.localhost\\Ubuntu\\home\\ada\\.codex\\sessions\\rollout.jsonl'
|
||||
const pathExists = vi.fn(async () => true)
|
||||
|
||||
await expect(
|
||||
toHostReadableTranscriptPath(extendedUnc, {
|
||||
platform: 'win32',
|
||||
transcriptHost: { kind: 'host' },
|
||||
pathExists
|
||||
})
|
||||
).resolves.toBeNull()
|
||||
await expect(
|
||||
toHostReadableTranscriptPath(extendedUnc, {
|
||||
platform: 'win32',
|
||||
transcriptHost: { kind: 'wsl', distro: 'Debian' },
|
||||
pathExists
|
||||
})
|
||||
).resolves.toBeNull()
|
||||
await expect(
|
||||
toHostReadableTranscriptPath(extendedUnc, {
|
||||
platform: 'win32',
|
||||
transcriptHost: { kind: 'wsl', distro: 'Ubuntu' },
|
||||
pathExists
|
||||
})
|
||||
).resolves.toBe(extendedUnc)
|
||||
|
||||
expect(pathExists).toHaveBeenCalledOnce()
|
||||
expect(pathExists).toHaveBeenCalledWith(extendedUnc)
|
||||
})
|
||||
|
||||
it('never probes the bare guest path on Windows', async () => {
|
||||
// Why: Win32 resolves `/home/...` against the current drive (`C:\home\...`),
|
||||
// so probing first could bind chat to a local look-alike file.
|
||||
@@ -177,6 +273,57 @@ describe('wslCodexSessionsDirs', () => {
|
||||
).resolves.toEqual([])
|
||||
})
|
||||
|
||||
it('loads only the requested distro for targeted resolution', async () => {
|
||||
const getWslHomeDir = vi.fn(async () => UBUNTU_HOME)
|
||||
|
||||
await expect(
|
||||
wslCodexSessionsDirsForDistro('Ubuntu', { platform: 'win32', getWslHomeDir })
|
||||
).resolves.toEqual([
|
||||
`${UBUNTU_HOME}\\.local\\share\\orca\\codex-runtime-home\\home\\sessions`,
|
||||
`${UBUNTU_HOME}\\.codex\\sessions`
|
||||
])
|
||||
expect(getWslHomeDir).toHaveBeenCalledWith('Ubuntu')
|
||||
})
|
||||
|
||||
it('coalesces concurrent targeted home probes across agents', async () => {
|
||||
let finish!: (home: string | null) => void
|
||||
const pending = new Promise<string | null>((resolve) => (finish = resolve))
|
||||
const getWslHomeDir = vi.fn(() => pending)
|
||||
|
||||
const codex = wslCodexSessionsDirsForDistro('Ubuntu', {
|
||||
platform: 'win32',
|
||||
getWslHomeDir
|
||||
})
|
||||
const claude = wslClaudeProjectsDirsForDistro('ubuntu', {
|
||||
platform: 'win32',
|
||||
getWslHomeDir
|
||||
})
|
||||
|
||||
expect(getWslHomeDir).toHaveBeenCalledTimes(1)
|
||||
finish(UBUNTU_HOME)
|
||||
await expect(codex).resolves.toHaveLength(2)
|
||||
await expect(claude).resolves.toEqual([`${UBUNTU_HOME}\\.claude\\projects`])
|
||||
})
|
||||
|
||||
it('negatively caches a failed targeted home probe for the retry window', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const getWslHomeDir = vi.fn(async () => null)
|
||||
const load = (): Promise<string[]> =>
|
||||
wslCodexSessionsDirsForDistro('Ubuntu', { platform: 'win32', getWslHomeDir })
|
||||
|
||||
await expect(load()).resolves.toEqual([])
|
||||
await expect(load()).resolves.toEqual([])
|
||||
expect(getWslHomeDir).toHaveBeenCalledTimes(1)
|
||||
|
||||
vi.setSystemTime(Date.now() + 30_001)
|
||||
await expect(load()).resolves.toEqual([])
|
||||
expect(getWslHomeDir).toHaveBeenCalledTimes(2)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('lists the managed and system Codex roots per distro home', async () => {
|
||||
await expect(
|
||||
wslCodexSessionsDirs({ platform: 'win32', listWslHomeDirs: async () => [UBUNTU_HOME] })
|
||||
@@ -202,4 +349,13 @@ describe('wslClaudeProjectsDirs', () => {
|
||||
})
|
||||
).resolves.toEqual([`${UBUNTU_HOME}\\.claude\\projects`, `${DEBIAN_HOME}\\.claude\\projects`])
|
||||
})
|
||||
|
||||
it('loads only the requested distro for targeted resolution', async () => {
|
||||
const getWslHomeDir = vi.fn(async () => DEBIAN_HOME)
|
||||
|
||||
await expect(
|
||||
wslClaudeProjectsDirsForDistro('Debian', { platform: 'win32', getWslHomeDir })
|
||||
).resolves.toEqual([`${DEBIAN_HOME}\\.claude\\projects`])
|
||||
expect(getWslHomeDir).toHaveBeenCalledWith('Debian')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,6 +5,7 @@ import { isWslUncPath, parseWslUncPath, toWindowsWslPath } from '../../shared/ws
|
||||
import { WSL_CODEX_RUNTIME_HOME_SEGMENTS } from '../pty/codex-home-wsl-env'
|
||||
import { getWslHomeAsync, listWslDistrosAsync } from '../wsl'
|
||||
import { runWslTranscriptFsTask } from './wsl-transcript-fs-gate'
|
||||
import type { NativeChatTranscriptHost } from './transcript-host'
|
||||
|
||||
/**
|
||||
* True for guest-absolute Linux paths that Win32 cannot open as-is.
|
||||
@@ -34,8 +35,11 @@ export type HostReadableTranscriptPathDeps = {
|
||||
platform?: NodeJS.Platform
|
||||
pathExists?: (path: string) => Promise<boolean>
|
||||
signal?: AbortSignal
|
||||
/** PTY-owned execution host. Undefined preserves legacy discovery. */
|
||||
transcriptHost?: NativeChatTranscriptHost
|
||||
/** Each installed WSL distro's `$HOME` as a Windows UNC path. */
|
||||
listWslHomeDirs?: () => Promise<string[]>
|
||||
getWslHomeDir?: (distro: string) => Promise<string | null>
|
||||
}
|
||||
|
||||
// Why: candidates are `\\wsl.localhost` UNC paths served over 9P. A sync probe
|
||||
@@ -69,6 +73,8 @@ const WSL_HOME_DIRS_TTL_MS = 5 * 60_000
|
||||
let cachedWslHomeDirs: string[] | null = null
|
||||
let cachedWslHomeDirsExpiresAt = 0
|
||||
let inflightWslHomeDirs: Promise<string[]> | null = null
|
||||
const cachedWslHomeByDistro = new Map<string, { home: string | null; expiresAt: number }>()
|
||||
const inflightWslHomeByDistro = new Map<string, Promise<string | null>>()
|
||||
|
||||
async function defaultListWslHomeDirs(): Promise<string[]> {
|
||||
const homes = await Promise.all(
|
||||
@@ -100,6 +106,8 @@ export function resetHostReadableTranscriptPathCacheForTests(): void {
|
||||
cachedWslHomeDirs = null
|
||||
cachedWslHomeDirsExpiresAt = 0
|
||||
inflightWslHomeDirs = null
|
||||
cachedWslHomeByDistro.clear()
|
||||
inflightWslHomeByDistro.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -126,6 +134,21 @@ export async function toHostReadableTranscriptPath(
|
||||
const pathExists =
|
||||
deps.pathExists ?? ((candidate: string) => pathExistsAsync(candidate, deps.signal))
|
||||
const platform = deps.platform ?? process.platform
|
||||
if (platform === 'win32' && deps.transcriptHost) {
|
||||
const unc = parseWslUncPath(path)
|
||||
if (unc) {
|
||||
if (
|
||||
deps.transcriptHost.kind === 'host' ||
|
||||
unc.distro.toLowerCase() !== deps.transcriptHost.distro.trim().toLowerCase()
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return (await pathExists(path)) ? path : null
|
||||
}
|
||||
if (deps.transcriptHost.kind === 'wsl' && !needsWslHostTranslation(path, platform)) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
// Why: classify BEFORE probing — Win32 resolves a bare `/home/…` against the
|
||||
// current drive (`C:\home\…`), so a probe first could bind chat to a local
|
||||
// look-alike file instead of the real WSL transcript.
|
||||
@@ -133,6 +156,18 @@ export async function toHostReadableTranscriptPath(
|
||||
return (await pathExists(path)) ? path : null
|
||||
}
|
||||
|
||||
if (deps.transcriptHost?.kind === 'host') {
|
||||
return null
|
||||
}
|
||||
if (deps.transcriptHost?.kind === 'wsl') {
|
||||
const distro = deps.transcriptHost.distro.trim()
|
||||
if (!distro) {
|
||||
return null
|
||||
}
|
||||
const uncPath = toWindowsWslPath(path, distro)
|
||||
return (await pathExists(uncPath)) ? uncPath : null
|
||||
}
|
||||
|
||||
const homeDirs = await wslHomeDirs(deps.listWslHomeDirs ?? defaultListWslHomeDirs)
|
||||
// Sequential on purpose: the ranked order picks the owning distro, and probing
|
||||
// every distro at once would fan out 9P calls to ones the user left stopped.
|
||||
@@ -182,6 +217,20 @@ export async function wslCodexSessionsDirs(
|
||||
])
|
||||
}
|
||||
|
||||
/** Codex roots for one PTY-proven WSL distro, without enumerating others. */
|
||||
export async function wslCodexSessionsDirsForDistro(
|
||||
distro: string,
|
||||
deps: Pick<HostReadableTranscriptPathDeps, 'platform' | 'getWslHomeDir'> = {}
|
||||
): Promise<string[]> {
|
||||
const home = await wslHomeDirForDistro(distro, deps)
|
||||
return home
|
||||
? [
|
||||
joinUnderWslHome(home, ...WSL_CODEX_RUNTIME_HOME_SEGMENTS, 'sessions'),
|
||||
joinUnderWslHome(home, '.codex', 'sessions')
|
||||
]
|
||||
: []
|
||||
}
|
||||
|
||||
/** Each WSL distro's Claude projects root, searched after the host root misses. */
|
||||
export async function wslClaudeProjectsDirs(
|
||||
deps: Pick<HostReadableTranscriptPathDeps, 'platform' | 'listWslHomeDirs'> = {}
|
||||
@@ -194,6 +243,50 @@ export async function wslClaudeProjectsDirs(
|
||||
return homeDirs.map((home) => joinUnderWslHome(home, '.claude', 'projects'))
|
||||
}
|
||||
|
||||
/** Claude projects root for one PTY-proven WSL distro. */
|
||||
export async function wslClaudeProjectsDirsForDistro(
|
||||
distro: string,
|
||||
deps: Pick<HostReadableTranscriptPathDeps, 'platform' | 'getWslHomeDir'> = {}
|
||||
): Promise<string[]> {
|
||||
const home = await wslHomeDirForDistro(distro, deps)
|
||||
return home ? [joinUnderWslHome(home, '.claude', 'projects')] : []
|
||||
}
|
||||
|
||||
async function wslHomeDirForDistro(
|
||||
distro: string,
|
||||
deps: Pick<HostReadableTranscriptPathDeps, 'platform' | 'getWslHomeDir'>
|
||||
): Promise<string | null> {
|
||||
if ((deps.platform ?? process.platform) !== 'win32') {
|
||||
return null
|
||||
}
|
||||
const normalizedDistro = distro.trim()
|
||||
if (!normalizedDistro) {
|
||||
return null
|
||||
}
|
||||
const key = normalizedDistro.toLowerCase()
|
||||
const cached = cachedWslHomeByDistro.get(key)
|
||||
if (cached && Date.now() < cached.expiresAt) {
|
||||
return cached.home
|
||||
}
|
||||
const inflight = inflightWslHomeByDistro.get(key)
|
||||
if (inflight) {
|
||||
return inflight
|
||||
}
|
||||
const load = deps.getWslHomeDir ?? getWslHomeAsync
|
||||
const request = load(normalizedDistro)
|
||||
.catch(() => null)
|
||||
.then((home) => {
|
||||
cachedWslHomeByDistro.set(key, {
|
||||
home,
|
||||
expiresAt: Date.now() + (home ? WSL_HOME_DIRS_TTL_MS : WSL_HOME_DIRS_EMPTY_RETRY_MS)
|
||||
})
|
||||
return home
|
||||
})
|
||||
.finally(() => inflightWslHomeByDistro.delete(key))
|
||||
inflightWslHomeByDistro.set(key, request)
|
||||
return request
|
||||
}
|
||||
|
||||
// Why: node:path.join is posix-flavoured off Windows and would mangle the
|
||||
// `\\wsl.localhost\` share prefix these roots must keep.
|
||||
function joinUnderWslHome(home: string, ...segments: string[]): string {
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { toHostReadableTranscriptPath } from './host-readable-transcript-path'
|
||||
|
||||
const GUEST_PATH = '/home/ada/.codex/sessions/rollout.jsonl'
|
||||
const DEBIAN_PATH = '\\\\wsl.localhost\\Debian\\home\\ada\\.codex\\sessions\\rollout.jsonl'
|
||||
const UBUNTU_PATH = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.codex\\sessions\\rollout.jsonl'
|
||||
const HOMES = ['\\\\wsl.localhost\\Debian\\home\\ada', '\\\\wsl.localhost\\Ubuntu\\home\\ada']
|
||||
|
||||
describe('native-chat PTY provenance oracle', () => {
|
||||
it('routes known WSL provenance directly to its owning distro', async () => {
|
||||
const enumerate = vi.fn(async () => HOMES)
|
||||
const probe = vi.fn(async () => true)
|
||||
|
||||
await expect(
|
||||
toHostReadableTranscriptPath(GUEST_PATH, {
|
||||
platform: 'win32',
|
||||
transcriptHost: { kind: 'wsl', distro: 'Ubuntu' },
|
||||
listWslHomeDirs: enumerate,
|
||||
pathExists: probe
|
||||
})
|
||||
).resolves.toBe(UBUNTU_PATH)
|
||||
expect(enumerate).not.toHaveBeenCalled()
|
||||
expect(probe).toHaveBeenCalledWith(UBUNTU_PATH)
|
||||
})
|
||||
|
||||
it('keeps host provenance out of WSL', async () => {
|
||||
const enumerate = vi.fn(async () => HOMES)
|
||||
const probe = vi.fn(async () => true)
|
||||
|
||||
await expect(
|
||||
toHostReadableTranscriptPath(GUEST_PATH, {
|
||||
platform: 'win32',
|
||||
transcriptHost: { kind: 'host' },
|
||||
listWslHomeDirs: enumerate,
|
||||
pathExists: probe
|
||||
})
|
||||
).resolves.toBeNull()
|
||||
expect(enumerate).not.toHaveBeenCalled()
|
||||
expect(probe).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('preserves broad recovery only when provenance is unavailable', async () => {
|
||||
await expect(
|
||||
toHostReadableTranscriptPath(GUEST_PATH, {
|
||||
platform: 'win32',
|
||||
listWslHomeDirs: async () => HOMES,
|
||||
pathExists: async () => true
|
||||
})
|
||||
).resolves.toBe(DEBIAN_PATH)
|
||||
})
|
||||
|
||||
it('rejects a WSL device path for host provenance before probing', async () => {
|
||||
const extended = '\\\\?\\UNC\\wsl.localhost\\Ubuntu\\home\\ada\\.codex\\sessions\\rollout.jsonl'
|
||||
const probe = vi.fn(async () => true)
|
||||
|
||||
await expect(
|
||||
toHostReadableTranscriptPath(extended, {
|
||||
platform: 'win32',
|
||||
transcriptHost: { kind: 'host' },
|
||||
pathExists: probe
|
||||
})
|
||||
).resolves.toBeNull()
|
||||
expect(probe).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -92,6 +92,79 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('resolveSessionFilePath on a Windows host with WSL', () => {
|
||||
it('routes an exact hook path through only the authoritative PTY distro', async () => {
|
||||
await expect(
|
||||
resolveSessionFilePath('codex', 'ignored', {
|
||||
transcriptPath: ROLLOUT_LINUX,
|
||||
transcriptHost: { kind: 'wsl', distro: 'Ubuntu' }
|
||||
})
|
||||
).resolves.toBe(ROLLOUT_UNC)
|
||||
|
||||
expect(vi.mocked(listWslDistrosAsync)).not.toHaveBeenCalled()
|
||||
expect(vi.mocked(getWslHomeAsync)).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not search WSL when authoritative PTY provenance says host', async () => {
|
||||
const options = { transcriptHost: { kind: 'host' as const } }
|
||||
|
||||
await expect(resolveSessionFilePath('claude', 'missing', options)).resolves.toBeNull()
|
||||
|
||||
expect(scanned.dirs).toHaveLength(1)
|
||||
expect(scanned.dirs[0]).not.toMatch(/^\\\\wsl/)
|
||||
expect(vi.mocked(listWslDistrosAsync)).not.toHaveBeenCalled()
|
||||
expect(vi.mocked(getWslHomeAsync)).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('searches only the authoritative PTY distro for a WSL Claude session', async () => {
|
||||
vi.mocked(listWslDistrosAsync).mockResolvedValueOnce(['Missing', 'Ubuntu'])
|
||||
scanned.wslClaudeTranscriptDir = WSL_CLAUDE_PROJECTS_DIR
|
||||
const options = { transcriptHost: { kind: 'wsl' as const, distro: 'Ubuntu' } }
|
||||
|
||||
await expect(resolveSessionFilePath('claude', 'claude-wsl-sess', options)).resolves.toBe(
|
||||
WSL_CLAUDE_TRANSCRIPT
|
||||
)
|
||||
|
||||
expect(scanned.dirs).toEqual([WSL_CLAUDE_PROJECTS_DIR])
|
||||
expect(vi.mocked(listWslDistrosAsync)).not.toHaveBeenCalled()
|
||||
expect(vi.mocked(getWslHomeAsync)).toHaveBeenCalledWith('Ubuntu')
|
||||
})
|
||||
|
||||
it('applies authoritative PTY routing to Codex without broad discovery', async () => {
|
||||
await expect(
|
||||
resolveSessionFilePath('codex', 'missing', {
|
||||
transcriptHost: { kind: 'wsl', distro: 'Ubuntu' }
|
||||
})
|
||||
).resolves.toBeNull()
|
||||
|
||||
expect(scanned.dirs).toEqual([
|
||||
`${UBUNTU_HOME}\\.local\\share\\orca\\codex-runtime-home\\home\\sessions`,
|
||||
`${UBUNTU_HOME}\\.codex\\sessions`
|
||||
])
|
||||
expect(vi.mocked(listWslDistrosAsync)).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not search WSL for a host-owned Codex miss', async () => {
|
||||
await expect(
|
||||
resolveSessionFilePath('codex', 'missing', { transcriptHost: { kind: 'host' } })
|
||||
).resolves.toBeNull()
|
||||
|
||||
expect(scanned.dirs.some((dir) => dir.startsWith('\\\\wsl.localhost\\'))).toBe(false)
|
||||
expect(vi.mocked(listWslDistrosAsync)).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['grok', 'omp'] as const)(
|
||||
'does not bind a host %s transcript to a WSL-owned PTY',
|
||||
async (agent) => {
|
||||
await expect(
|
||||
resolveSessionFilePath(agent, 'same-id', {
|
||||
transcriptHost: { kind: 'wsl', distro: 'Ubuntu' }
|
||||
})
|
||||
).resolves.toBeNull()
|
||||
|
||||
expect(vi.mocked(listWslDistrosAsync)).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it('resolves a Claude transcript from WSL when no hook path is known', async () => {
|
||||
scanned.wslClaudeTranscriptDir = WSL_CLAUDE_PROJECTS_DIR
|
||||
|
||||
|
||||
@@ -14,10 +14,13 @@ import {
|
||||
import {
|
||||
toHostReadableTranscriptPath,
|
||||
wslClaudeProjectsDirs,
|
||||
wslCodexSessionsDirs
|
||||
wslClaudeProjectsDirsForDistro,
|
||||
wslCodexSessionsDirs,
|
||||
wslCodexSessionsDirsForDistro
|
||||
} from './host-readable-transcript-path'
|
||||
import { findWslCodexSessionPath } from './wsl-codex-session-path-scan'
|
||||
import { findWslSessionPath } from './wsl-session-path-scan'
|
||||
import type { NativeChatTranscriptHost } from './transcript-host'
|
||||
|
||||
// Why: these mirror the path constants in ai-vault/session-scanner.ts. Reads
|
||||
// run in the main process against the runtime's own home directory; over SSH
|
||||
@@ -73,6 +76,8 @@ export type ResolveSessionFileOptions = {
|
||||
* directly — recent Claude Code names the transcript with a UUID that differs
|
||||
* from the hook session_id, so the id-based glob below would miss it. */
|
||||
transcriptPath?: string
|
||||
/** PTY-owned execution host. Undefined preserves legacy discovery. */
|
||||
transcriptHost?: NativeChatTranscriptHost
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -102,7 +107,10 @@ export async function resolveSessionFilePath(
|
||||
// stale/missing paths fall through to the id-based search.
|
||||
const hookPath = options.transcriptPath?.trim()
|
||||
if (hookPath && extname(hookPath) === '.jsonl') {
|
||||
const hostReadable = await toHostReadableTranscriptPath(hookPath, { signal })
|
||||
const hostReadable = await toHostReadableTranscriptPath(hookPath, {
|
||||
signal,
|
||||
transcriptHost: options.transcriptHost
|
||||
})
|
||||
if (hostReadable) {
|
||||
return hostReadable
|
||||
}
|
||||
@@ -115,33 +123,63 @@ export async function resolveSessionFilePath(
|
||||
|
||||
if (transcriptAgent === 'claude') {
|
||||
const overrideDir = options.claudeProjectsDir
|
||||
if (!overrideDir && options.transcriptHost?.kind === 'wsl') {
|
||||
const projectsDirs = await wslClaudeProjectsDirsForDistro(options.transcriptHost.distro)
|
||||
signal?.throwIfAborted()
|
||||
return resolveClaudeSessionFileInDirs(trimmedId, projectsDirs, signal)
|
||||
}
|
||||
return resolveClaudeSessionFile(
|
||||
trimmedId,
|
||||
overrideDir ?? claudeProjectsDir(),
|
||||
overrideDir ? undefined : wslClaudeProjectsDirs,
|
||||
overrideDir || options.transcriptHost?.kind === 'host' ? undefined : wslClaudeProjectsDirs,
|
||||
signal
|
||||
)
|
||||
}
|
||||
if (transcriptAgent === 'codex') {
|
||||
const overrideDirs = options.codexSessionsDirs
|
||||
if (!overrideDirs && options.transcriptHost?.kind === 'wsl') {
|
||||
const sessionsDirs = await wslCodexSessionsDirsForDistro(options.transcriptHost.distro)
|
||||
signal?.throwIfAborted()
|
||||
return findCodexRolloutInDirs(trimmedId, sessionsDirs, signal)
|
||||
}
|
||||
return resolveCodexSessionFile(
|
||||
trimmedId,
|
||||
overrideDirs ?? codexSessionsDirs(),
|
||||
// Why: enumerating WSL homes spawns wsl.exe per distro, which boots ones the
|
||||
// user left stopped. Only pay that after this host's own Codex roots miss.
|
||||
overrideDirs ? undefined : wslCodexSessionsDirs,
|
||||
overrideDirs || options.transcriptHost?.kind === 'host' ? undefined : wslCodexSessionsDirs,
|
||||
signal
|
||||
)
|
||||
}
|
||||
if (transcriptAgent === 'grok') {
|
||||
if (options.transcriptHost?.kind === 'wsl') {
|
||||
return null
|
||||
}
|
||||
return resolveGrokSessionFile(trimmedId, options.grokSessionsDir ?? grokSessionsDir(), signal)
|
||||
}
|
||||
if (transcriptAgent === 'omp') {
|
||||
if (options.transcriptHost?.kind === 'wsl') {
|
||||
return null
|
||||
}
|
||||
return resolveOmpSessionFile(trimmedId, options.ompSessionsDir ?? ompSessionsDir(), signal)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function resolveClaudeSessionFileInDirs(
|
||||
sessionId: string,
|
||||
projectsDirs: string[],
|
||||
signal?: AbortSignal
|
||||
): Promise<string | null> {
|
||||
for (const projectsDir of projectsDirs) {
|
||||
const hit = await findClaudeTranscript(projectsDir, sessionId, signal)
|
||||
if (hit) {
|
||||
return hit
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function resolveClaudeSessionFile(
|
||||
sessionId: string,
|
||||
projectsDir: string,
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export type NativeChatTranscriptHost = { kind: 'host' } | { kind: 'wsl'; distro: string }
|
||||
@@ -75,12 +75,17 @@ describe('native chat transcript resolve polling', () => {
|
||||
agent: 'codex',
|
||||
sessionId: 'session-id',
|
||||
transcriptPath: '/home/ada/.codex/sessions/rollout-session-id.jsonl',
|
||||
transcriptHost: { kind: 'wsl', distro: 'Ubuntu' },
|
||||
resolvePollIntervalMs: 10,
|
||||
onAppend: () => {}
|
||||
})
|
||||
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
expect(mocks.toHostReadable).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.toHostReadable).toHaveBeenCalledWith(
|
||||
'/home/ada/.codex/sessions/rollout-session-id.jsonl',
|
||||
expect.objectContaining({ transcriptHost: { kind: 'wsl', distro: 'Ubuntu' } })
|
||||
)
|
||||
expect(mocks.install.mock.calls.some(([filePath]) => String(filePath).startsWith('/'))).toBe(
|
||||
false
|
||||
)
|
||||
|
||||
@@ -122,7 +122,8 @@ function subscribeViaResolvePoll(
|
||||
// the current drive (`C:\home\…`) and bind chat to a look-alike file.
|
||||
lastWslTranslateAt = Date.now()
|
||||
hostReadableExactPath = await toHostReadableTranscriptPath(exactPath, {
|
||||
signal: resolveController.signal
|
||||
signal: resolveController.signal,
|
||||
transcriptHost: args.transcriptHost
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10983,6 +10983,37 @@ describe('OrcaRuntimeService', () => {
|
||||
expect(cwds.get('pty-debian')).toBe('\\\\wsl.localhost\\Debian\\home\\me\\repo')
|
||||
})
|
||||
|
||||
it('resolves native-chat transcript hosts from authoritative PTY context', () => {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
runtime.preparePtyExecutionContext('pty-ubuntu', 'Ubuntu', { resetIncarnation: true })
|
||||
runtime.registerPty('pty-ubuntu', TEST_WORKTREE_ID, null, undefined, true)
|
||||
runtime.registerPty('pty-host', TEST_WORKTREE_ID, null, undefined, false)
|
||||
runtime.registerPty('pty-unknown', TEST_WORKTREE_ID)
|
||||
runtime.registerPty('pty-ssh', TEST_WORKTREE_ID, 'ssh-1', undefined, false)
|
||||
|
||||
expect(runtime.resolveNativeChatTranscriptHost('pty-ubuntu')).toEqual({
|
||||
kind: 'wsl',
|
||||
distro: 'Ubuntu'
|
||||
})
|
||||
expect(runtime.resolveNativeChatTranscriptHost('pty-host')).toEqual({ kind: 'host' })
|
||||
expect(runtime.resolveNativeChatTranscriptHost('pty-unknown')).toBeUndefined()
|
||||
expect(runtime.resolveNativeChatTranscriptHost('pty-ssh')).toBeUndefined()
|
||||
expect(runtime.resolveNativeChatTranscriptHost('missing')).toBeNull()
|
||||
})
|
||||
|
||||
it('resolves paired-runtime terminal handles before transcript provenance', () => {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
const handle = runtime.preAllocateHandleForPty('pty-ubuntu')
|
||||
runtime.preparePtyExecutionContext('pty-ubuntu', 'Ubuntu', { resetIncarnation: true })
|
||||
runtime.registerPty('pty-ubuntu', TEST_WORKTREE_ID, null, undefined, true)
|
||||
|
||||
expect(runtime.resolveNativeChatTranscriptHostForHandle(handle)).toEqual({
|
||||
kind: 'wsl',
|
||||
distro: 'Ubuntu'
|
||||
})
|
||||
expect(runtime.resolveNativeChatTranscriptHostForHandle('term_missing')).toBeNull()
|
||||
})
|
||||
|
||||
it('does not retain WSL context when a PTY id is reused', () => {
|
||||
setPlatform('win32')
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
|
||||
@@ -1062,6 +1062,7 @@ import {
|
||||
resolveNestedRepoSelection
|
||||
} from '../project-groups/nested-repo-import'
|
||||
import { createNestedRepoImportTargetResolver } from '../project-groups/nested-repo-import-target'
|
||||
import type { NativeChatTranscriptHost } from '../native-chat/transcript-host'
|
||||
|
||||
function sanitizeNestedRepoRuntimeImportError(context: string, error: unknown): string {
|
||||
console.warn(`[project-groups] ${context}`, error)
|
||||
@@ -3392,6 +3393,28 @@ export class OrcaRuntimeService {
|
||||
return this.getLocalProviderFn ? this.getLocalProviderFn() : null
|
||||
}
|
||||
|
||||
resolveNativeChatTranscriptHost(ptyId: string): NativeChatTranscriptHost | null | undefined {
|
||||
const pty = this.ptysById.get(ptyId)
|
||||
const distro = this.wslDistroByPtyId.get(ptyId) ?? pty?.wslDistro ?? null
|
||||
if (distro) {
|
||||
return { kind: 'wsl', distro }
|
||||
}
|
||||
if (pty?.connectionId === null && pty.isWsl === false) {
|
||||
return { kind: 'host' }
|
||||
}
|
||||
return pty ? undefined : null
|
||||
}
|
||||
|
||||
resolveNativeChatTranscriptHostForHandle(
|
||||
handle: string
|
||||
): NativeChatTranscriptHost | null | undefined {
|
||||
const ptyId =
|
||||
this.getLivePtyForHandle(handle)?.pty.ptyId ??
|
||||
this.resolveLiveLeafForHandle(handle)?.ptyId ??
|
||||
null
|
||||
return ptyId ? this.resolveNativeChatTranscriptHost(ptyId) : null
|
||||
}
|
||||
|
||||
private async stopPtysForDestructiveWorktreeRemoval(
|
||||
worktreeId: string,
|
||||
options: { connectionId?: string; allowUnverifiedStop?: boolean } = {}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import type { NativeChatBlock, NativeChatMessage } from '../../../../shared/native-chat-types'
|
||||
import type { RpcContext } from '../core'
|
||||
import { sanitizeNativeChatRpcImageBlock } from './native-chat-rpc-image-block'
|
||||
|
||||
export const MOBILE_NATIVE_CHAT_DEFAULT_WINDOW = 40
|
||||
export const MOBILE_NATIVE_CHAT_MAX_WINDOW = 2000
|
||||
const MOBILE_BLOCK_CHAR_CAP = 4000
|
||||
const MOBILE_TEXT_BLOCK_CHAR_CAP = 64_000
|
||||
const MOBILE_TOOL_INPUT_ITEMS_CAP = 20
|
||||
const MOBILE_TOOL_INPUT_NODE_CAP = 100
|
||||
const TRUNCATION_MARKER = '\n… (truncated)'
|
||||
|
||||
function clip(text: string, cap: number): string {
|
||||
return text.length > cap ? text.slice(0, cap) + TRUNCATION_MARKER : text
|
||||
}
|
||||
|
||||
function sanitizeBlock(
|
||||
block: NativeChatBlock,
|
||||
clientKind: RpcContext['clientKind']
|
||||
): NativeChatBlock {
|
||||
if (block.type === 'image-ref') {
|
||||
return sanitizeNativeChatRpcImageBlock(block)
|
||||
}
|
||||
if (clientKind !== 'mobile') {
|
||||
return block
|
||||
}
|
||||
if (block.type === 'text') {
|
||||
return block.text.length > MOBILE_TEXT_BLOCK_CHAR_CAP
|
||||
? { ...block, text: clip(block.text, MOBILE_TEXT_BLOCK_CHAR_CAP) }
|
||||
: block
|
||||
}
|
||||
if (block.type === 'tool-result') {
|
||||
return block.output.length > MOBILE_BLOCK_CHAR_CAP
|
||||
? { ...block, output: clip(block.output, MOBILE_BLOCK_CHAR_CAP) }
|
||||
: block
|
||||
}
|
||||
if (block.type === 'tool-call') {
|
||||
const budget = { remaining: MOBILE_BLOCK_CHAR_CAP, nodes: MOBILE_TOOL_INPUT_NODE_CAP }
|
||||
return { ...block, input: sanitizeToolInput(block.input, budget, 0) }
|
||||
}
|
||||
return block
|
||||
}
|
||||
|
||||
function sanitizeToolInput(
|
||||
value: unknown,
|
||||
budget: { remaining: number; nodes: number },
|
||||
depth: number
|
||||
): unknown {
|
||||
budget.nodes--
|
||||
if (budget.nodes < 0 || budget.remaining <= 0) {
|
||||
return '… (truncated)'
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const length = Math.min(value.length, budget.remaining)
|
||||
budget.remaining -= length
|
||||
return length < value.length ? `${value.slice(0, length)}… (truncated)` : value
|
||||
}
|
||||
if (!value || typeof value !== 'object' || depth >= 5) {
|
||||
return value && typeof value === 'object' ? '… (truncated)' : value
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
const result = value
|
||||
.slice(0, MOBILE_TOOL_INPUT_ITEMS_CAP)
|
||||
.map((item) => sanitizeToolInput(item, budget, depth + 1))
|
||||
if (value.length > MOBILE_TOOL_INPUT_ITEMS_CAP) {
|
||||
result.push('… (truncated)')
|
||||
}
|
||||
return result
|
||||
}
|
||||
const result: Record<string, unknown> = {}
|
||||
let count = 0
|
||||
for (const key in value) {
|
||||
if (!Object.hasOwn(value, key)) {
|
||||
continue
|
||||
}
|
||||
if (count >= MOBILE_TOOL_INPUT_ITEMS_CAP || budget.remaining <= 0) {
|
||||
result['…'] = 'truncated'
|
||||
break
|
||||
}
|
||||
let boundedKey = key.slice(0, Math.min(key.length, budget.remaining, 128))
|
||||
// Why: sibling keys sharing a truncated prefix must not silently collapse.
|
||||
if (Object.hasOwn(result, boundedKey)) {
|
||||
boundedKey = `${boundedKey}~${count}`
|
||||
}
|
||||
budget.remaining -= boundedKey.length
|
||||
result[boundedKey] = sanitizeToolInput(
|
||||
(value as Record<string, unknown>)[key],
|
||||
budget,
|
||||
depth + 1
|
||||
)
|
||||
count++
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function sanitizeMessage(
|
||||
message: NativeChatMessage,
|
||||
clientKind: RpcContext['clientKind']
|
||||
): NativeChatMessage {
|
||||
return { ...message, blocks: message.blocks.map((block) => sanitizeBlock(block, clientKind)) }
|
||||
}
|
||||
|
||||
export function sanitizeAppendForClient(
|
||||
messages: readonly NativeChatMessage[],
|
||||
clientKind: RpcContext['clientKind']
|
||||
): NativeChatMessage[] {
|
||||
return messages.map((message) => sanitizeMessage(message, clientKind))
|
||||
}
|
||||
|
||||
export function windowForClient(
|
||||
messages: readonly NativeChatMessage[],
|
||||
clientKind: RpcContext['clientKind'],
|
||||
limit = MOBILE_NATIVE_CHAT_DEFAULT_WINDOW
|
||||
): NativeChatMessage[] {
|
||||
const window = Math.min(Math.max(limit, 1), MOBILE_NATIVE_CHAT_MAX_WINDOW)
|
||||
const windowed = messages.length > window ? messages.slice(-window) : messages.slice()
|
||||
return windowed.map((message) => sanitizeMessage(message, clientKind))
|
||||
}
|
||||
@@ -21,9 +21,13 @@ const cachedResult = vi.hoisted(() => ({
|
||||
}
|
||||
}
|
||||
}))
|
||||
const tailRead = vi.hoisted(() => ({ signal: undefined as AbortSignal | undefined }))
|
||||
const tailRead = vi.hoisted(() => ({
|
||||
args: undefined as undefined | { transcriptHost?: unknown },
|
||||
signal: undefined as AbortSignal | undefined
|
||||
}))
|
||||
const watcher = vi.hoisted(() => ({
|
||||
args: null as null | {
|
||||
transcriptHost?: unknown
|
||||
onInitialSnapshot?: (
|
||||
messages: NativeChatMessage[],
|
||||
hasMore: boolean,
|
||||
@@ -63,7 +67,12 @@ const watcher = vi.hoisted(() => ({
|
||||
unsubscribe: vi.fn()
|
||||
}))
|
||||
vi.mock('../../../native-chat/transcript-watch', () => ({
|
||||
readNativeChatTranscriptTail: ({ limit }: { limit: number }, signal?: AbortSignal) => {
|
||||
readNativeChatTranscriptTail: (
|
||||
args: { limit: number; transcriptHost?: unknown },
|
||||
signal?: AbortSignal
|
||||
) => {
|
||||
const { limit } = args
|
||||
tailRead.args = args
|
||||
tailRead.signal = signal
|
||||
const messages = cachedResult.value.messages
|
||||
return Promise.resolve({
|
||||
@@ -140,7 +149,12 @@ function streamingContext(clientKind: RpcContext['clientKind']): RpcContext {
|
||||
}
|
||||
|
||||
function ctxWith(clientKind: RpcContext['clientKind']): RpcContext {
|
||||
return { runtime: {} as RpcContext['runtime'], clientKind }
|
||||
return {
|
||||
runtime: {
|
||||
resolveNativeChatTranscriptHostForHandle: vi.fn()
|
||||
} as unknown as RpcContext['runtime'],
|
||||
clientKind
|
||||
}
|
||||
}
|
||||
|
||||
function firstOutput(result: unknown): string {
|
||||
@@ -165,6 +179,27 @@ describe('nativeChat.readSession clientKind truncation gating', () => {
|
||||
expect(tailRead.signal).toBe(controller.signal)
|
||||
})
|
||||
|
||||
it('resolves terminal-handle provenance on the transcript-owning runtime', async () => {
|
||||
const context = ctxWith('runtime')
|
||||
const resolveHost = vi
|
||||
.mocked(context.runtime.resolveNativeChatTranscriptHostForHandle)
|
||||
.mockReturnValue({ kind: 'wsl', distro: 'Ubuntu' })
|
||||
|
||||
await readSessionHandler()({ agent: 'claude', sessionId: 's', ptyId: 'term-1' }, context)
|
||||
|
||||
expect(resolveHost).toHaveBeenCalledWith('term-1')
|
||||
expect(tailRead.args?.transcriptHost).toEqual({ kind: 'wsl', distro: 'Ubuntu' })
|
||||
})
|
||||
|
||||
it('fails closed when a supplied terminal handle is stale', async () => {
|
||||
const context = ctxWith('runtime')
|
||||
vi.mocked(context.runtime.resolveNativeChatTranscriptHostForHandle).mockReturnValue(null)
|
||||
|
||||
await expect(
|
||||
readSessionHandler()({ agent: 'claude', sessionId: 'same-id', ptyId: 'term-stale' }, context)
|
||||
).resolves.toEqual({ error: 'Transcript unavailable' })
|
||||
})
|
||||
|
||||
it('clips oversized tool output for mobile clients', async () => {
|
||||
cachedResult.value = { messages: [makeMessage(OVERSIZED)] }
|
||||
const result = await readSessionHandler()(
|
||||
@@ -416,6 +451,39 @@ describe('nativeChat.readSession clientKind truncation gating', () => {
|
||||
})
|
||||
|
||||
describe('nativeChat.subscribe initial snapshot', () => {
|
||||
it('uses the same PTY provenance for the live watcher', async () => {
|
||||
const context = streamingContext('runtime')
|
||||
const resolveHost = vi.fn(() => ({ kind: 'wsl' as const, distro: 'Ubuntu' }))
|
||||
context.runtime.resolveNativeChatTranscriptHostForHandle = resolveHost
|
||||
|
||||
await subscribeHandler()(
|
||||
{ agent: 'claude', sessionId: 's', subscriptionId: 'sub-pty', ptyId: 'term-1' },
|
||||
context,
|
||||
vi.fn()
|
||||
)
|
||||
|
||||
expect(resolveHost).toHaveBeenCalledWith('term-1')
|
||||
expect(activeWatcherArgs().transcriptHost).toEqual({ kind: 'wsl', distro: 'Ubuntu' })
|
||||
})
|
||||
|
||||
it('emits an error without watching when a terminal handle is stale', async () => {
|
||||
const context = streamingContext('runtime')
|
||||
context.runtime.resolveNativeChatTranscriptHostForHandle = vi.fn(() => null)
|
||||
const emitted: unknown[] = []
|
||||
watcher.args = null
|
||||
|
||||
await subscribeHandler()(
|
||||
{ agent: 'claude', sessionId: 'same-id', ptyId: 'term-stale' },
|
||||
context,
|
||||
(value) => emitted.push(value)
|
||||
)
|
||||
|
||||
expect(watcher.args).toBeNull()
|
||||
expect(emitted).toEqual([
|
||||
expect.objectContaining({ type: 'snapshot', error: 'Transcript unavailable' })
|
||||
])
|
||||
})
|
||||
|
||||
it('preserves long assistant text across mobile stream frame types', async () => {
|
||||
watcher.watching = true
|
||||
watcher.args = null
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import { z } from 'zod'
|
||||
import type {
|
||||
NativeChatBlock,
|
||||
NativeChatMessage,
|
||||
AgentType
|
||||
} from '../../../../shared/native-chat-types'
|
||||
import type { AgentType } from '../../../../shared/native-chat-types'
|
||||
import {
|
||||
readNativeChatTranscriptTail,
|
||||
subscribeNativeChatTranscript,
|
||||
type NativeChatTranscriptSubscription,
|
||||
type SubscribeNativeChatTranscriptArgs
|
||||
} from '../../../native-chat/transcript-watch'
|
||||
import { defineMethod, defineStreamingMethod, type RpcAnyMethod, type RpcContext } from '../core'
|
||||
import { sanitizeNativeChatRpcImageBlock } from './native-chat-rpc-image-block'
|
||||
import { defineMethod, defineStreamingMethod, type RpcAnyMethod } from '../core'
|
||||
import {
|
||||
MOBILE_NATIVE_CHAT_DEFAULT_WINDOW,
|
||||
MOBILE_NATIVE_CHAT_MAX_WINDOW,
|
||||
sanitizeAppendForClient,
|
||||
windowForClient
|
||||
} from './native-chat-rpc-payload'
|
||||
|
||||
// Why: native chat renders an agent's own transcript (Claude/Codex JSONL). The
|
||||
// desktop reaches the readers via Electron IPC; mobile/web clients reach the
|
||||
@@ -48,6 +49,9 @@ const NativeChatSession = z.object({
|
||||
// locate the file directly when the session id no longer names it (recent
|
||||
// Claude Code). Optional for back-compat with older clients.
|
||||
transcriptPath: z.string().min(1).optional(),
|
||||
// Optional for mixed-version compatibility; the host, not the client,
|
||||
// resolves this PTY identity into filesystem provenance.
|
||||
ptyId: z.string().min(1).optional(),
|
||||
beforeOffset: z.number().int().nonnegative().optional()
|
||||
})
|
||||
|
||||
@@ -55,162 +59,24 @@ const NativeChatUnsubscribe = z.object({
|
||||
subscriptionId: z.string().min(1).optional()
|
||||
})
|
||||
|
||||
// Why: a long agent session can hold thousands of turns (with full tool I/O).
|
||||
// Shipping all of them over the paired connection and rendering them at once
|
||||
// freezes the mobile app, so the runtime RPC windows to the most recent slice —
|
||||
// the conversation tail is what the chat view shows first. The desktop IPC path
|
||||
// is unaffected (it reads locally with a virtualized list).
|
||||
// Small first page for a fast initial paint; the client raises `limit` to load
|
||||
// older history as the user scrolls back.
|
||||
const MOBILE_NATIVE_CHAT_DEFAULT_WINDOW = 40
|
||||
const MOBILE_NATIVE_CHAT_MAX_WINDOW = 2000
|
||||
// Why: a single tool result (a big file read, a long diff) can be hundreds of KB.
|
||||
// The mobile view only previews tool block bodies, so truncate them on the wire
|
||||
// to keep the payload small; the marker tells the user content was clipped.
|
||||
const MOBILE_BLOCK_CHAR_CAP = 4000
|
||||
// Why: text blocks are the message body itself, rendered in full by the chat
|
||||
// view — a preview-sized cap cut long assistant replies mid-sentence with no way
|
||||
// to read on (STA-3230). Keep only a generous safety ceiling: a transcript
|
||||
// record can legally reach 2MB, and shipping that much markdown in one block
|
||||
// would freeze the phone.
|
||||
const MOBILE_TEXT_BLOCK_CHAR_CAP = 64_000
|
||||
const MOBILE_TOOL_INPUT_ITEMS_CAP = 20
|
||||
const MOBILE_TOOL_INPUT_NODE_CAP = 100
|
||||
const TRUNCATION_MARKER = '\n… (truncated)'
|
||||
|
||||
function clip(text: string, cap: number): string {
|
||||
return text.length > cap ? text.slice(0, cap) + TRUNCATION_MARKER : text
|
||||
}
|
||||
|
||||
function sanitizeBlock(
|
||||
block: NativeChatBlock,
|
||||
clientKind: RpcContext['clientKind']
|
||||
): NativeChatBlock {
|
||||
if (block.type === 'image-ref') {
|
||||
return sanitizeNativeChatRpcImageBlock(block)
|
||||
}
|
||||
if (clientKind !== 'mobile') {
|
||||
return block
|
||||
}
|
||||
if (block.type === 'text') {
|
||||
return block.text.length > MOBILE_TEXT_BLOCK_CHAR_CAP
|
||||
? { ...block, text: clip(block.text, MOBILE_TEXT_BLOCK_CHAR_CAP) }
|
||||
: block
|
||||
}
|
||||
if (block.type === 'tool-result') {
|
||||
return block.output.length > MOBILE_BLOCK_CHAR_CAP
|
||||
? { ...block, output: clip(block.output, MOBILE_BLOCK_CHAR_CAP) }
|
||||
: block
|
||||
}
|
||||
if (block.type === 'tool-call') {
|
||||
const budget = { remaining: MOBILE_BLOCK_CHAR_CAP, nodes: MOBILE_TOOL_INPUT_NODE_CAP }
|
||||
return { ...block, input: sanitizeToolInput(block.input, budget, 0) }
|
||||
}
|
||||
return block
|
||||
}
|
||||
|
||||
function sanitizeToolInput(
|
||||
value: unknown,
|
||||
budget: { remaining: number; nodes: number },
|
||||
depth: number
|
||||
): unknown {
|
||||
budget.nodes--
|
||||
if (budget.nodes < 0 || budget.remaining <= 0) {
|
||||
return '… (truncated)'
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const length = Math.min(value.length, budget.remaining)
|
||||
budget.remaining -= length
|
||||
return length < value.length ? `${value.slice(0, length)}… (truncated)` : value
|
||||
}
|
||||
if (!value || typeof value !== 'object' || depth >= 5) {
|
||||
return value && typeof value === 'object' ? '… (truncated)' : value
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
const result = value
|
||||
.slice(0, MOBILE_TOOL_INPUT_ITEMS_CAP)
|
||||
.map((item) => sanitizeToolInput(item, budget, depth + 1))
|
||||
if (value.length > MOBILE_TOOL_INPUT_ITEMS_CAP) {
|
||||
result.push('… (truncated)')
|
||||
}
|
||||
return result
|
||||
}
|
||||
const result: Record<string, unknown> = {}
|
||||
let count = 0
|
||||
for (const key in value) {
|
||||
if (!Object.hasOwn(value, key)) {
|
||||
continue
|
||||
}
|
||||
if (count >= MOBILE_TOOL_INPUT_ITEMS_CAP || budget.remaining <= 0) {
|
||||
result['…'] = 'truncated'
|
||||
break
|
||||
}
|
||||
let boundedKey = key.slice(0, Math.min(key.length, budget.remaining, 128))
|
||||
// Why: sibling keys sharing a >=128-char (or budget-truncated) prefix collapse
|
||||
// to the same bounded key; suffix collisions so neither field is silently lost.
|
||||
if (Object.hasOwn(result, boundedKey)) {
|
||||
boundedKey = `${boundedKey}~${count}`
|
||||
}
|
||||
budget.remaining -= boundedKey.length
|
||||
result[boundedKey] = sanitizeToolInput(
|
||||
(value as Record<string, unknown>)[key],
|
||||
budget,
|
||||
depth + 1
|
||||
)
|
||||
count++
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function sanitizeMessage(
|
||||
message: NativeChatMessage,
|
||||
clientKind: RpcContext['clientKind']
|
||||
): NativeChatMessage {
|
||||
return { ...message, blocks: message.blocks.map((block) => sanitizeBlock(block, clientKind)) }
|
||||
}
|
||||
|
||||
function sanitizeAppendForClient(
|
||||
messages: readonly NativeChatMessage[],
|
||||
clientKind: RpcContext['clientKind']
|
||||
): NativeChatMessage[] {
|
||||
return messages.map((message) => sanitizeMessage(message, clientKind))
|
||||
}
|
||||
|
||||
/** Window a transcript to its most recent `limit` messages so a long session
|
||||
* can't freeze the client. Windowing by count applies to ALL RPC clients —
|
||||
* shipping thousands of turns over the paired link is bad for web and mobile
|
||||
* alike. Char-clipping (the mobile-only payload diet) is applied separately. */
|
||||
function windowTranscript(
|
||||
messages: readonly NativeChatMessage[],
|
||||
limit = MOBILE_NATIVE_CHAT_DEFAULT_WINDOW
|
||||
): NativeChatMessage[] {
|
||||
const window = Math.min(Math.max(limit, 1), MOBILE_NATIVE_CHAT_MAX_WINDOW)
|
||||
return messages.length > window ? messages.slice(-window) : messages.slice()
|
||||
}
|
||||
|
||||
/** Apply the windowed slice and keep inline image bytes off every RPC transport.
|
||||
* Mobile clients additionally receive bounded text and tool bodies; runtime
|
||||
* clients keep those bodies intact. */
|
||||
function windowForClient(
|
||||
messages: readonly NativeChatMessage[],
|
||||
clientKind: RpcContext['clientKind'],
|
||||
limit = MOBILE_NATIVE_CHAT_DEFAULT_WINDOW
|
||||
): NativeChatMessage[] {
|
||||
const windowed = windowTranscript(messages, limit)
|
||||
return windowed.map((message) => sanitizeMessage(message, clientKind))
|
||||
}
|
||||
|
||||
export const NATIVE_CHAT_METHODS: readonly RpcAnyMethod[] = [
|
||||
defineMethod({
|
||||
name: 'nativeChat.readSession',
|
||||
params: NativeChatSession,
|
||||
handler: async (params, { clientKind, signal }) => {
|
||||
handler: async (params, { runtime, clientKind, signal }) => {
|
||||
const limit = params.limit ?? MOBILE_NATIVE_CHAT_DEFAULT_WINDOW
|
||||
const transcriptHost = params.ptyId
|
||||
? runtime.resolveNativeChatTranscriptHostForHandle(params.ptyId)
|
||||
: undefined
|
||||
if (transcriptHost === null) {
|
||||
return { error: 'Transcript unavailable' }
|
||||
}
|
||||
const result = await readNativeChatTranscriptTail(
|
||||
{
|
||||
agent: params.agent,
|
||||
sessionId: params.sessionId,
|
||||
transcriptPath: params.transcriptPath,
|
||||
transcriptHost,
|
||||
limit,
|
||||
beforeOffset: params.beforeOffset
|
||||
},
|
||||
@@ -246,6 +112,18 @@ export const NATIVE_CHAT_METHODS: readonly RpcAnyMethod[] = [
|
||||
const cleanupToken = params.subscriptionId ?? `${params.agent}:${params.sessionId}`
|
||||
const subscriptionId = `nativeChat:${connectionId ?? 'local'}:${cleanupToken}`
|
||||
const limit = params.limit ?? MOBILE_NATIVE_CHAT_DEFAULT_WINDOW
|
||||
const transcriptHost = params.ptyId
|
||||
? runtime.resolveNativeChatTranscriptHostForHandle(params.ptyId)
|
||||
: undefined
|
||||
if (transcriptHost === null) {
|
||||
emit({
|
||||
type: 'snapshot',
|
||||
messages: [],
|
||||
hasMore: false,
|
||||
error: 'Transcript unavailable'
|
||||
})
|
||||
return
|
||||
}
|
||||
const cleanup = (): void => {
|
||||
if (closed) {
|
||||
return
|
||||
@@ -272,6 +150,7 @@ export const NATIVE_CHAT_METHODS: readonly RpcAnyMethod[] = [
|
||||
agent: params.agent,
|
||||
sessionId: params.sessionId,
|
||||
transcriptPath: params.transcriptPath,
|
||||
transcriptHost,
|
||||
initialLimit: limit,
|
||||
onInitialSnapshot: (messages, hasMore, beforeOffset, error, lifecycle) => {
|
||||
if (closed) {
|
||||
|
||||
@@ -911,6 +911,8 @@ export type NativeChatSubscribeArgs = {
|
||||
sessionId: string
|
||||
/** Authoritative transcript path from the agent hook (providerSession). */
|
||||
transcriptPath?: string
|
||||
/** PTY identity resolved to transcript provenance by its owning host. */
|
||||
ptyId?: string
|
||||
/** First snapshot size; later readSession calls grow this for pagination. */
|
||||
limit?: number
|
||||
}
|
||||
@@ -922,7 +924,8 @@ export type NativeChatApi = {
|
||||
agent: AgentType,
|
||||
sessionId: string,
|
||||
limit?: number,
|
||||
transcriptPath?: string
|
||||
transcriptPath?: string,
|
||||
ptyId?: string
|
||||
) => Promise<NativeChatReadSessionResult>
|
||||
/** Live-tail a transcript. The first frame is a bounded race-safe snapshot;
|
||||
* later frames contain only newly appended messages. */
|
||||
|
||||
+10
-2
@@ -4276,9 +4276,16 @@ const api = {
|
||||
agent: AgentType,
|
||||
sessionId: string,
|
||||
limit?: number,
|
||||
transcriptPath?: string
|
||||
transcriptPath?: string,
|
||||
ptyId?: string
|
||||
): Promise<NativeChatReadSessionResult> =>
|
||||
ipcRenderer.invoke('nativeChat:readSession', { agent, sessionId, limit, transcriptPath }),
|
||||
ipcRenderer.invoke('nativeChat:readSession', {
|
||||
agent,
|
||||
sessionId,
|
||||
limit,
|
||||
transcriptPath,
|
||||
...(ptyId ? { ptyId } : {})
|
||||
}),
|
||||
/** Start live tailing; onAppended fires with only newly-appended messages. Returns an unsubscribe fn that closes the watcher. */
|
||||
subscribe: (
|
||||
args: {
|
||||
@@ -4286,6 +4293,7 @@ const api = {
|
||||
agent: AgentType
|
||||
sessionId: string
|
||||
transcriptPath?: string
|
||||
ptyId?: string
|
||||
limit?: number
|
||||
},
|
||||
onFrame: (frame: NativeChatSubscriptionFrame) => void
|
||||
|
||||
@@ -127,6 +127,7 @@ function NativeChatResolvedView({
|
||||
agent,
|
||||
sessionId,
|
||||
transcriptPath,
|
||||
ptyId: targetPtyId,
|
||||
runtimeEnvironmentId,
|
||||
enabled: isVisible
|
||||
})
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
RUNTIME_NATIVE_CHAT_RECONNECT_MS,
|
||||
toRuntimeNativeChatErrorMessage
|
||||
} from './native-chat-session-transport'
|
||||
import { toRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream'
|
||||
|
||||
const nativeChatReadSession = vi.fn()
|
||||
const nativeChatSubscribe = vi.fn()
|
||||
@@ -66,6 +67,15 @@ describe('getNativeChatSessionTransport — selection', () => {
|
||||
expect(runtimeEnvironmentsCall).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('forwards PTY provenance identity through the local adapter', async () => {
|
||||
nativeChatReadSession.mockResolvedValue({ messages: [] })
|
||||
const transport = getNativeChatSessionTransport(null)
|
||||
|
||||
await transport.readSession('claude', 'sess-1', 40, undefined, 'pty-1')
|
||||
|
||||
expect(nativeChatReadSession).toHaveBeenCalledWith('claude', 'sess-1', 40, undefined, 'pty-1')
|
||||
})
|
||||
|
||||
it('returns the runtime adapter for an owner on the desktop client', async () => {
|
||||
markRuntimeEnvironmentCompatible(ENV)
|
||||
runtimeEnvironmentsCall.mockResolvedValue(okEnvelope({ messages: [] }))
|
||||
@@ -82,6 +92,47 @@ describe('getNativeChatSessionTransport — selection', () => {
|
||||
expect(nativeChatReadSession).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('forwards PTY provenance identity to the owning runtime', async () => {
|
||||
markRuntimeEnvironmentCompatible(ENV)
|
||||
runtimeEnvironmentsCall.mockResolvedValue(okEnvelope({ messages: [] }))
|
||||
const transport = getNativeChatSessionTransport(ENV)
|
||||
|
||||
await transport.readSession(
|
||||
'claude',
|
||||
'sess-1',
|
||||
40,
|
||||
undefined,
|
||||
toRemoteRuntimePtyId('pty-host-1', ENV)
|
||||
)
|
||||
|
||||
expect(runtimeEnvironmentsCall).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
params: expect.objectContaining({ ptyId: 'pty-host-1' })
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('routes a scoped PTY to its encoded owner during an owner flip', async () => {
|
||||
markRuntimeEnvironmentCompatible('env-a')
|
||||
runtimeEnvironmentsCall.mockResolvedValue(okEnvelope({ messages: [] }))
|
||||
const transport = getNativeChatSessionTransport('env-b')
|
||||
|
||||
await transport.readSession(
|
||||
'claude',
|
||||
'sess-1',
|
||||
40,
|
||||
undefined,
|
||||
toRemoteRuntimePtyId('term-a', 'env-a')
|
||||
)
|
||||
|
||||
expect(runtimeEnvironmentsCall).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
selector: 'env-a',
|
||||
params: expect.objectContaining({ ptyId: 'term-a' })
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('validates lifecycle metadata on runtime read responses', async () => {
|
||||
markRuntimeEnvironmentCompatible(ENV)
|
||||
const lifecycle = { state: 'completed', turnId: 'turn-1', timestamp: 42 } as const
|
||||
@@ -206,6 +257,51 @@ describe('runtime subscribe', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('forwards PTY provenance identity to the runtime stream', async () => {
|
||||
markRuntimeEnvironmentCompatible(ENV)
|
||||
stubSubscribe()
|
||||
const transport = getNativeChatSessionTransport(ENV)
|
||||
|
||||
transport.subscribe(
|
||||
{
|
||||
subscriptionId: 's-pty',
|
||||
agent: 'claude',
|
||||
sessionId: 'sess-1',
|
||||
ptyId: toRemoteRuntimePtyId('pty-host-1', ENV)
|
||||
},
|
||||
vi.fn()
|
||||
)
|
||||
await Promise.resolve()
|
||||
|
||||
expect(runtimeEnvironmentsSubscribe).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
params: expect.objectContaining({ ptyId: 'pty-host-1' })
|
||||
}),
|
||||
expect.any(Object)
|
||||
)
|
||||
})
|
||||
|
||||
it('subscribes against the scoped PTY owner during an owner flip', async () => {
|
||||
stubSubscribe()
|
||||
const transport = getNativeChatSessionTransport('env-b')
|
||||
|
||||
transport.subscribe(
|
||||
{
|
||||
subscriptionId: 's-owner-flip',
|
||||
agent: 'claude',
|
||||
sessionId: 'sess-1',
|
||||
ptyId: toRemoteRuntimePtyId('term-a', 'env-a')
|
||||
},
|
||||
vi.fn()
|
||||
)
|
||||
await Promise.resolve()
|
||||
|
||||
expect(runtimeEnvironmentsSubscribe).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ selector: 'env-a' }),
|
||||
expect.any(Object)
|
||||
)
|
||||
})
|
||||
|
||||
it('validates lifecycle metadata on runtime stream frames', async () => {
|
||||
markRuntimeEnvironmentCompatible(ENV)
|
||||
const { deliver } = stubSubscribe()
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
import type { NativeChatApi, NativeChatAppendedMessages } from '../../../../preload/api-types'
|
||||
import { isWebClientLocation } from '@/lib/web-client-location'
|
||||
import {
|
||||
callRuntimeRpc,
|
||||
RuntimeRpcCallError,
|
||||
type RuntimeClientTarget
|
||||
} from '@/runtime/runtime-rpc-client'
|
||||
import { callRuntimeRpc, RuntimeRpcCallError } from '@/runtime/runtime-rpc-client'
|
||||
import { isRuntimeCompatBlockError } from '@/runtime/runtime-protocol-compat'
|
||||
import {
|
||||
parseRuntimeNativeChatReadSessionResult,
|
||||
parseRuntimeNativeChatTurnLifecycle,
|
||||
RUNTIME_NATIVE_CHAT_READ_ERROR
|
||||
} from './native-chat-runtime-contract'
|
||||
import { parseRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream'
|
||||
|
||||
/** The read/subscribe surface the live-session hook needs, decoupled from where
|
||||
* the transcript actually lives. Same shape as `window.api.nativeChat`, so the
|
||||
@@ -43,21 +40,39 @@ export function toRuntimeNativeChatErrorMessage(err: unknown): string {
|
||||
* using this adapter (R3). Preserves whatever `subscribe` returns (sync fn on
|
||||
* desktop, promise on the web bridge) — the hook's teardown handles both (R6). */
|
||||
const localNativeChatTransport: NativeChatSessionTransport = {
|
||||
readSession: (agent, sessionId, limit, transcriptPath) =>
|
||||
window.api.nativeChat.readSession(agent, sessionId, limit, transcriptPath),
|
||||
readSession: (agent, sessionId, limit, transcriptPath, ptyId) =>
|
||||
ptyId
|
||||
? window.api.nativeChat.readSession(agent, sessionId, limit, transcriptPath, ptyId)
|
||||
: window.api.nativeChat.readSession(agent, sessionId, limit, transcriptPath),
|
||||
subscribe: (args, onFrame) => window.api.nativeChat.subscribe(args, onFrame)
|
||||
}
|
||||
|
||||
function createRuntimeNativeChatTransport(environmentId: string): NativeChatSessionTransport {
|
||||
const target: RuntimeClientTarget = { kind: 'environment', environmentId }
|
||||
const route = (
|
||||
ptyId: string | undefined
|
||||
): { target: { kind: 'environment'; environmentId: string }; hostPtyId?: string } => {
|
||||
const parsed = ptyId ? parseRemoteRuntimePtyId(ptyId) : null
|
||||
const targetEnvironmentId = parsed?.environmentId?.trim() || environmentId
|
||||
return {
|
||||
target: { kind: 'environment', environmentId: targetEnvironmentId },
|
||||
...(ptyId ? { hostPtyId: parsed?.handle || ptyId } : {})
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
readSession: async (agent, sessionId, limit, transcriptPath) => {
|
||||
readSession: async (agent, sessionId, limit, transcriptPath, ptyId) => {
|
||||
try {
|
||||
const { target, hostPtyId } = route(ptyId)
|
||||
const result = await callRuntimeRpc<unknown>(
|
||||
target,
|
||||
'nativeChat.readSession',
|
||||
{ agent, sessionId, limit, transcriptPath },
|
||||
{
|
||||
agent,
|
||||
sessionId,
|
||||
limit,
|
||||
transcriptPath,
|
||||
...(hostPtyId ? { ptyId: hostPtyId } : {})
|
||||
},
|
||||
{ timeoutMs: 15_000 }
|
||||
)
|
||||
return parseRuntimeNativeChatReadSessionResult(result)
|
||||
@@ -66,7 +81,9 @@ function createRuntimeNativeChatTransport(environmentId: string): NativeChatSess
|
||||
}
|
||||
},
|
||||
subscribe: (args, onFrame) => {
|
||||
const { subscriptionId, agent, sessionId, transcriptPath, limit } = args
|
||||
const { subscriptionId, agent, sessionId, transcriptPath, ptyId, limit } = args
|
||||
const { target, hostPtyId } = route(ptyId)
|
||||
const targetEnvironmentId = target.environmentId
|
||||
let cancelled = false
|
||||
let receivedInitial = false
|
||||
let handleUnsubscribe: (() => void) | null = null
|
||||
@@ -103,9 +120,16 @@ function createRuntimeNativeChatTransport(environmentId: string): NativeChatSess
|
||||
void window.api.runtimeEnvironments
|
||||
.subscribe(
|
||||
{
|
||||
selector: environmentId,
|
||||
selector: targetEnvironmentId,
|
||||
method: 'nativeChat.subscribe',
|
||||
params: { subscriptionId, agent, sessionId, transcriptPath, limit },
|
||||
params: {
|
||||
subscriptionId,
|
||||
agent,
|
||||
sessionId,
|
||||
transcriptPath,
|
||||
...(hostPtyId ? { ptyId: hostPtyId } : {}),
|
||||
limit
|
||||
},
|
||||
timeoutMs: 15_000
|
||||
},
|
||||
{
|
||||
|
||||
@@ -137,6 +137,41 @@ describe('useNativeChatLiveSession — transport routing', () => {
|
||||
expect(transport.subscribe).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('rebinds reads and subscriptions when the owning PTY changes', async () => {
|
||||
const root = await render({
|
||||
paneKey: PANE,
|
||||
agent: AGENT,
|
||||
sessionId: SESSION,
|
||||
ptyId: 'pty-1',
|
||||
runtimeEnvironmentId: 'env-1'
|
||||
})
|
||||
const transport = getMockTransport('env-1')
|
||||
|
||||
expect(transport.readSession).toHaveBeenLastCalledWith(
|
||||
AGENT,
|
||||
SESSION,
|
||||
NATIVE_CHAT_INITIAL_LIMIT,
|
||||
undefined,
|
||||
'pty-1'
|
||||
)
|
||||
expect(transport.subscribe.mock.calls[0]?.[0]).toEqual(
|
||||
expect.objectContaining({ ptyId: 'pty-1' })
|
||||
)
|
||||
|
||||
await rerender(root, {
|
||||
paneKey: PANE,
|
||||
agent: AGENT,
|
||||
sessionId: SESSION,
|
||||
ptyId: 'pty-2',
|
||||
runtimeEnvironmentId: 'env-1'
|
||||
})
|
||||
|
||||
expect(transport.unsubscribe).toHaveBeenCalledOnce()
|
||||
expect(transport.subscribe.mock.calls[1]?.[0]).toEqual(
|
||||
expect.objectContaining({ ptyId: 'pty-2' })
|
||||
)
|
||||
})
|
||||
|
||||
it('re-subscribes against the new owner on an owner flip (R5)', async () => {
|
||||
const root = await render({
|
||||
paneKey: PANE,
|
||||
|
||||
@@ -31,6 +31,8 @@ export type UseNativeChatLiveSessionArgs = {
|
||||
sessionId: string | null
|
||||
/** Authoritative transcript path from the hook, preferred over reconstructing it from sessionId. Null when not reported. */
|
||||
transcriptPath?: string | null
|
||||
/** PTY identity used by the owning host to resolve transcript provenance. */
|
||||
ptyId?: string | null
|
||||
/** Runtime owner (Model B): non-null routes read/subscribe to the remote host; null keeps the local IPC path. */
|
||||
runtimeEnvironmentId?: string | null
|
||||
/** False suspends transcript IO while retaining the last committed session. */
|
||||
@@ -122,7 +124,8 @@ export function useNativeChatLiveSession(
|
||||
runtimeEnvironmentId ?? null,
|
||||
agent,
|
||||
sessionId,
|
||||
transcriptPath ?? null
|
||||
transcriptPath ?? null,
|
||||
args.ptyId ?? null
|
||||
])
|
||||
const retainedSourceKeyRef = useRef(sourceKey)
|
||||
|
||||
@@ -175,7 +178,13 @@ export function useNativeChatLiveSession(
|
||||
return
|
||||
}
|
||||
void transport
|
||||
.readSession(agent, activeSessionId, limitRef.current, transcriptPath ?? undefined)
|
||||
.readSession(
|
||||
agent,
|
||||
activeSessionId,
|
||||
limitRef.current,
|
||||
transcriptPath ?? undefined,
|
||||
args.ptyId ?? undefined
|
||||
)
|
||||
.then((result) => {
|
||||
if (cancelled || !latestEnabled.current || frameArrived) {
|
||||
return
|
||||
@@ -211,6 +220,7 @@ export function useNativeChatLiveSession(
|
||||
agent,
|
||||
sessionId,
|
||||
transcriptPath: transcriptPath ?? undefined,
|
||||
ptyId: args.ptyId ?? undefined,
|
||||
limit: limitRef.current
|
||||
},
|
||||
(frame) => {
|
||||
@@ -246,7 +256,16 @@ export function useNativeChatLiveSession(
|
||||
closeStream()
|
||||
}
|
||||
// `transport` identity changes on an owner flip, re-running this effect to re-subscribe against the new host.
|
||||
}, [agent, enabled, sessionId, sourceKey, transcriptPath, transport, transcriptLifecycleControl])
|
||||
}, [
|
||||
agent,
|
||||
enabled,
|
||||
args.ptyId,
|
||||
sessionId,
|
||||
sourceKey,
|
||||
transcriptPath,
|
||||
transport,
|
||||
transcriptLifecycleControl
|
||||
])
|
||||
|
||||
const loadEarlier = useCallback(() => {
|
||||
if (
|
||||
@@ -263,7 +282,13 @@ export function useNativeChatLiveSession(
|
||||
const lifecycleRevision = transcriptLifecycleControl.revision()
|
||||
setLoadingEarlier(true)
|
||||
void transport
|
||||
.readSession(agent, sessionId, nextLimit, transcriptPath ?? undefined)
|
||||
.readSession(
|
||||
agent,
|
||||
sessionId,
|
||||
nextLimit,
|
||||
transcriptPath ?? undefined,
|
||||
args.ptyId ?? undefined
|
||||
)
|
||||
.then((result) => {
|
||||
// Ignore a stale resolve from a swapped session or flipped owner — either would paint the wrong host's history.
|
||||
if (
|
||||
@@ -296,6 +321,7 @@ export function useNativeChatLiveSession(
|
||||
agent,
|
||||
sessionId,
|
||||
transcriptPath,
|
||||
args.ptyId,
|
||||
transport,
|
||||
hasMore,
|
||||
loadingEarlier,
|
||||
|
||||
@@ -19,7 +19,8 @@ export function useNativeChatRetainedSession(
|
||||
args.runtimeEnvironmentId ?? null,
|
||||
args.agent,
|
||||
args.sessionId,
|
||||
args.transcriptPath ?? null
|
||||
args.transcriptPath ?? null,
|
||||
args.ptyId ?? null
|
||||
])
|
||||
const activeIdentityRef = useRef(identity)
|
||||
const retentionRef = useRef(createNativeChatTranscriptRetention())
|
||||
|
||||
@@ -1536,6 +1536,62 @@ describe('web native chat preload API', () => {
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('sends the host PTY handle instead of the client-scoped runtime id', async () => {
|
||||
const requests: { method: string; params: unknown }[] = []
|
||||
vi.doMock('./web-runtime-client', () => ({
|
||||
WebRuntimeClient: class {
|
||||
call(method: string, params: unknown): Promise<RuntimeRpcResponse<unknown>> {
|
||||
requests.push({ method, params })
|
||||
return Promise.resolve({
|
||||
id: 'read-pty',
|
||||
ok: true,
|
||||
result: { messages: [] },
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})
|
||||
}
|
||||
|
||||
close(): void {}
|
||||
}
|
||||
}))
|
||||
|
||||
const globals = installBrowserGlobals('Linux')
|
||||
writeStoredRuntimeEnvironment(globals.storage)
|
||||
const { installWebPreloadApi } = await import('./web-preload-api')
|
||||
installWebPreloadApi()
|
||||
|
||||
await globals.window.api.nativeChat.readSession(
|
||||
'claude',
|
||||
'session-1',
|
||||
40,
|
||||
undefined,
|
||||
'remote:web-env-1@@pty-host-1'
|
||||
)
|
||||
|
||||
expect(requests).toEqual([
|
||||
{
|
||||
method: 'nativeChat.readSession',
|
||||
params: expect.objectContaining({ ptyId: 'pty-host-1' })
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('fails closed when a scoped PTY names another runtime owner', async () => {
|
||||
const globals = installBrowserGlobals('Linux')
|
||||
writeStoredRuntimeEnvironment(globals.storage, 'web-env-1')
|
||||
const { installWebPreloadApi } = await import('./web-preload-api')
|
||||
installWebPreloadApi()
|
||||
|
||||
await expect(
|
||||
globals.window.api.nativeChat.readSession(
|
||||
'claude',
|
||||
'same-id',
|
||||
40,
|
||||
undefined,
|
||||
'remote:other-env@@term-1'
|
||||
)
|
||||
).rejects.toThrow('Unknown Orca runtime environment: other-env')
|
||||
})
|
||||
})
|
||||
|
||||
describe('web MiniMax preload API', () => {
|
||||
|
||||
@@ -77,6 +77,7 @@ import {
|
||||
} from '../../../shared/execution-host'
|
||||
import { toRuntimeWorktreeSelector } from '../runtime/runtime-worktree-selector'
|
||||
import { callAbortableRuntimeEnvironment } from '../runtime/abortable-runtime-environment-call'
|
||||
import { parseRemoteRuntimePtyId } from '../runtime/runtime-terminal-stream'
|
||||
import { normalizeDisabledTuiAgents } from '../../../shared/tui-agent-selection'
|
||||
import {
|
||||
normalizeTuiAgentArgsRecord,
|
||||
@@ -1242,15 +1243,27 @@ function createWebKeybindingsApi(): WebKeybindingsApi {
|
||||
// Why: web has no IPC for native-chat transcripts, so route readSession/subscribe through runtime RPC (as mobile does).
|
||||
function createNativeChatApi(): NativeChatApi {
|
||||
return {
|
||||
readSession: async (agent, sessionId, limit, transcriptPath) =>
|
||||
parseRuntimeNativeChatReadSessionResult(
|
||||
await callRuntimeResult<unknown>('nativeChat.readSession', {
|
||||
readSession: async (agent, sessionId, limit, transcriptPath, ptyId) => {
|
||||
const parsedPty = ptyId ? parseRemoteRuntimePtyId(ptyId) : null
|
||||
const environment = parsedPty?.environmentId
|
||||
? resolveEnvironment(parsedPty.environmentId)
|
||||
: requireActiveEnvironment()
|
||||
const response = await callEnvironmentEnvelope<unknown>(
|
||||
environment.id,
|
||||
'nativeChat.readSession',
|
||||
{
|
||||
agent,
|
||||
sessionId,
|
||||
limit,
|
||||
transcriptPath
|
||||
})
|
||||
),
|
||||
transcriptPath,
|
||||
...(ptyId ? { ptyId: parsedPty?.handle || ptyId } : {})
|
||||
}
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(response.error.message)
|
||||
}
|
||||
return parseRuntimeNativeChatReadSessionResult(response.result)
|
||||
},
|
||||
subscribe: (args, onFrame) => {
|
||||
// No paired runtime yet: return a no-op teardown so the chat view mounts cleanly; only the not-paired case is swallowed.
|
||||
const environment = requireActiveEnvironmentOrNull()
|
||||
@@ -1266,10 +1279,14 @@ function createNativeChatApi(): NativeChatApi {
|
||||
})
|
||||
return () => {}
|
||||
}
|
||||
const parsedPty = args.ptyId ? parseRemoteRuntimePtyId(args.ptyId) : null
|
||||
const targetEnvironment = parsedPty?.environmentId
|
||||
? resolveEnvironment(parsedPty.environmentId)
|
||||
: environment
|
||||
let handle: { unsubscribe: () => void } | null = null
|
||||
let cancelled = false
|
||||
let receivedInitial = false
|
||||
void getClientForEnvironment(environment)
|
||||
void getClientForEnvironment(targetEnvironment)
|
||||
.subscribe(
|
||||
'nativeChat.subscribe',
|
||||
{
|
||||
@@ -1277,6 +1294,7 @@ function createNativeChatApi(): NativeChatApi {
|
||||
sessionId: args.sessionId,
|
||||
subscriptionId: args.subscriptionId,
|
||||
transcriptPath: args.transcriptPath,
|
||||
...(args.ptyId ? { ptyId: parsedPty?.handle || args.ptyId } : {}),
|
||||
limit: args.limit
|
||||
},
|
||||
{
|
||||
|
||||
@@ -16,6 +16,10 @@ describe('wsl path helpers', () => {
|
||||
distro: 'Debian',
|
||||
linuxPath: '/home/jin'
|
||||
})
|
||||
expect(parseWslUncPath('\\\\?\\UNC\\wsl.localhost\\Ubuntu\\home\\jin')).toEqual({
|
||||
distro: 'Ubuntu',
|
||||
linuxPath: '/home/jin'
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects ordinary Windows and POSIX paths', () => {
|
||||
|
||||
@@ -5,7 +5,7 @@ export type WslUncPathInfo = {
|
||||
|
||||
export function parseWslUncPath(path: string): WslUncPathInfo | null {
|
||||
const normalized = path.replace(/\\/g, '/')
|
||||
const match = normalized.match(/^\/\/(wsl\.localhost|wsl\$)\/([^/]+)(\/.*)?$/i)
|
||||
const match = normalized.match(/^(?:\/\/|\/\/\?\/UNC\/)(wsl\.localhost|wsl\$)\/([^/]+)(\/.*)?$/i)
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user