mirror of
https://github.com/stablyai/orca.git
synced 2026-09-30 08:03:12 +00:00
fix(ci): restore remote transcript and file compatibility
This commit is contained in:
@@ -7,6 +7,12 @@ import { startBrowserScreencast } from './browser-screencast-stream'
|
||||
|
||||
function createWebContents() {
|
||||
let attached = false
|
||||
const webContents = new EventEmitter() as EventEmitter & {
|
||||
isDestroyed: ReturnType<typeof vi.fn>
|
||||
getURL: ReturnType<typeof vi.fn>
|
||||
getTitle: ReturnType<typeof vi.fn>
|
||||
debugger: typeof debuggerApi
|
||||
}
|
||||
const debuggerApi = new EventEmitter() as EventEmitter & {
|
||||
isAttached: ReturnType<typeof vi.fn>
|
||||
attach: ReturnType<typeof vi.fn>
|
||||
@@ -21,7 +27,11 @@ function createWebContents() {
|
||||
attached = false
|
||||
})
|
||||
debuggerApi.sendCommand = vi.fn(async () => ({}))
|
||||
return { isDestroyed: vi.fn(() => false), debugger: debuggerApi }
|
||||
webContents.isDestroyed = vi.fn(() => false)
|
||||
webContents.getURL = vi.fn(() => 'https://example.test/')
|
||||
webContents.getTitle = vi.fn(() => 'Example')
|
||||
webContents.debugger = debuggerApi
|
||||
return webContents
|
||||
}
|
||||
|
||||
describe('browser screencast lifecycle', () => {
|
||||
|
||||
+1
-1
@@ -757,7 +757,6 @@ if (app.isPackaged && process.platform !== 'win32') {
|
||||
}
|
||||
configureDevUserDataPath(is.dev)
|
||||
configureOrcaUserDataPathEnv()
|
||||
installServeSupervisorDisconnectQuit(isServeMode)
|
||||
|
||||
// Why: just past createMainWindow's 10s ready-to-show fallback, so a window revealed that way still gets its tray icon.
|
||||
const TRAY_CREATE_FALLBACK_MS = 12_000
|
||||
@@ -938,6 +937,7 @@ if (hasSingleInstanceLock) {
|
||||
// timing, in particular not the pre-ready Keychain service-name resolution and
|
||||
// the app.setName ordering the userData captures below depend on.
|
||||
setAppEnvironment(new ElectronAppEnvironment())
|
||||
installServeSupervisorDisconnectQuit(isServeMode)
|
||||
setSecretStore(new ElectronSecretStore())
|
||||
// Why at process level, not per-window: pty.ts registers against injected surfaces so
|
||||
// it can load without electron, and an Electron main process always has ipcMain —
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import type { NativeChatMessage, NativeChatTurnLifecycle } from '../../shared/native-chat-types'
|
||||
import {
|
||||
boundaryFingerprint,
|
||||
readTranscriptFileVersion,
|
||||
transcriptFileVersionChanged,
|
||||
type TranscriptFileVersion
|
||||
} from './transcript-file-version'
|
||||
import { transcriptFileVersionChanged, type TranscriptFileVersion } from './transcript-file-version'
|
||||
import {
|
||||
createIncrementalTranscriptState,
|
||||
readIncrementalTranscriptMessages,
|
||||
@@ -19,9 +14,14 @@ import type {
|
||||
SubscribeNativeChatTranscriptArgs
|
||||
} from './transcript-watch-contract'
|
||||
import { createTranscriptWatchScheduler } from './transcript-watch-scheduler'
|
||||
import {
|
||||
createSourceAwareTranscriptNativeWatcher,
|
||||
probeTranscriptWatchFile,
|
||||
readTranscriptWatchBoundary,
|
||||
readTranscriptWatchFileVersion
|
||||
} from './transcript-watch-source-access'
|
||||
import { WslTranscriptFsError } from './wsl-transcript-fs-gate'
|
||||
import {
|
||||
createRunningGuardedTranscriptNativeWatcher,
|
||||
isWslTranscriptWatcherPath,
|
||||
transcriptWatcherPathIsRunning
|
||||
} from './wsl-transcript-watcher-running-guard'
|
||||
@@ -35,8 +35,15 @@ export async function installTranscriptWatcher(
|
||||
args: SubscribeNativeChatTranscriptArgs,
|
||||
signal?: AbortSignal
|
||||
): Promise<NativeChatTranscriptSubscription | null> {
|
||||
const fileSource = args.fileSource
|
||||
const isWslPath = isWslTranscriptWatcherPath(filePath)
|
||||
if (!(await transcriptWatcherPathIsInstallable(filePath, signal))) {
|
||||
if (fileSource) {
|
||||
try {
|
||||
await probeTranscriptWatchFile(filePath, fileSource, signal)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
} else if (!(await transcriptWatcherPathIsInstallable(filePath, signal))) {
|
||||
return null
|
||||
}
|
||||
const { onAppend, onInitialSnapshot, onReplace, initialLimit } = args
|
||||
@@ -82,7 +89,7 @@ export async function installTranscriptWatcher(
|
||||
(nextLifecycle) => {
|
||||
lifecycle = nextLifecycle
|
||||
},
|
||||
gateAbort.signal
|
||||
{ fileSource, signal: gateAbort.signal }
|
||||
)
|
||||
if (!closed && (remaining.length > 0 || lifecycle)) {
|
||||
onAppend(remaining, lifecycle)
|
||||
@@ -90,8 +97,17 @@ export async function installTranscriptWatcher(
|
||||
}
|
||||
|
||||
async function finishSuccessfulDrain(startVersion: TranscriptFileVersion): Promise<void> {
|
||||
watchedBoundary = await boundaryFingerprint(filePath, state.offset, gateAbort.signal)
|
||||
const completedVersion = await readTranscriptFileVersion(filePath, gateAbort.signal)
|
||||
watchedBoundary = await readTranscriptWatchBoundary(
|
||||
filePath,
|
||||
state.offset,
|
||||
fileSource,
|
||||
gateAbort.signal
|
||||
)
|
||||
const completedVersion = await readTranscriptWatchFileVersion(
|
||||
filePath,
|
||||
fileSource,
|
||||
gateAbort.signal
|
||||
)
|
||||
if (transcriptFileVersionChanged(completedVersion, startVersion)) {
|
||||
// Why: a write racing this drain needs another pass even when the reader
|
||||
// happened to reach its new EOF; timestamp-only rewrites may need replace.
|
||||
@@ -113,8 +129,13 @@ export async function installTranscriptWatcher(
|
||||
}
|
||||
|
||||
async function drainOnce(): Promise<void> {
|
||||
const current = await readTranscriptFileVersion(filePath, gateAbort.signal)
|
||||
const currentBoundary = await boundaryFingerprint(filePath, state.offset, gateAbort.signal)
|
||||
const current = await readTranscriptWatchFileVersion(filePath, fileSource, gateAbort.signal)
|
||||
const currentBoundary = await readTranscriptWatchBoundary(
|
||||
filePath,
|
||||
state.offset,
|
||||
fileSource,
|
||||
gateAbort.signal
|
||||
)
|
||||
if (closed) {
|
||||
return
|
||||
}
|
||||
@@ -149,6 +170,7 @@ export async function installTranscriptWatcher(
|
||||
false,
|
||||
undefined,
|
||||
decodeLifecycle,
|
||||
fileSource,
|
||||
gateAbort.signal
|
||||
)
|
||||
: null
|
||||
@@ -178,6 +200,7 @@ export async function installTranscriptWatcher(
|
||||
false,
|
||||
undefined,
|
||||
decodeLifecycle,
|
||||
fileSource,
|
||||
gateAbort.signal
|
||||
)
|
||||
: null
|
||||
@@ -208,7 +231,7 @@ export async function installTranscriptWatcher(
|
||||
(nextLifecycle) => {
|
||||
lifecycle = nextLifecycle
|
||||
},
|
||||
gateAbort.signal
|
||||
{ fileSource, signal: gateAbort.signal }
|
||||
)
|
||||
if (closed) {
|
||||
return
|
||||
@@ -271,7 +294,7 @@ export async function installTranscriptWatcher(
|
||||
return
|
||||
}
|
||||
try {
|
||||
const current = await readTranscriptFileVersion(filePath, gateAbort.signal)
|
||||
const current = await readTranscriptWatchFileVersion(filePath, fileSource, gateAbort.signal)
|
||||
if (closed) {
|
||||
return
|
||||
}
|
||||
@@ -292,8 +315,9 @@ export async function installTranscriptWatcher(
|
||||
drain: () => void drain(),
|
||||
reconcile: reconcileKnownRunning
|
||||
})
|
||||
const nativeWatcher = createRunningGuardedTranscriptNativeWatcher(
|
||||
const nativeWatcher = createSourceAwareTranscriptNativeWatcher(
|
||||
filePath,
|
||||
fileSource,
|
||||
() => scheduler.scheduleEventDrain(),
|
||||
scheduleRotationRetry
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createTranscriptNativeWatcher } from './transcript-native-watcher'
|
||||
import { createRunningGuardedTranscriptNativeWatcher } from './wsl-transcript-watcher-running-guard'
|
||||
import { readTranscriptBoundaryFingerprint } from './transcript-boundary-fingerprint'
|
||||
import type { TranscriptFileSource } from './transcript-file-source'
|
||||
import {
|
||||
@@ -49,7 +49,7 @@ export function createSourceAwareTranscriptNativeWatcher(
|
||||
onRetry: () => void
|
||||
) {
|
||||
return !fileSource || fileSource.supportsNativeWatch
|
||||
? createTranscriptNativeWatcher(filePath, onEvent, onRetry)
|
||||
? createRunningGuardedTranscriptNativeWatcher(filePath, onEvent, onRetry)
|
||||
: {
|
||||
bind: () => false,
|
||||
invalidate: () => {},
|
||||
|
||||
@@ -84,6 +84,7 @@ import {
|
||||
SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE
|
||||
} from '../providers/ssh-filesystem-dispatch'
|
||||
import type { FileReadLimits, FileStat, IFilesystemProvider } from '../providers/types'
|
||||
import { readSshFileExplorerChunk } from './ssh-file-explorer-chunk-read'
|
||||
import { FileReadCapExceededError } from '../ssh/ssh-filesystem-stream-reader'
|
||||
import {
|
||||
isWatcherProcessFailure,
|
||||
@@ -1835,10 +1836,9 @@ export class RuntimeFileCommands {
|
||||
if (fileStat.type === 'directory') {
|
||||
throw new Error('Cannot download a directory')
|
||||
}
|
||||
if (!provider.readFileChunk) {
|
||||
throw new Error('SSH runtime chunked download requires an updated Orca host')
|
||||
}
|
||||
return provider.readFileChunk(target.path, offset, length)
|
||||
return provider.readFileChunk
|
||||
? provider.readFileChunk(target.path, offset, length)
|
||||
: readSshFileExplorerChunk(provider, target.path, fileStat.size, offset, length)
|
||||
}
|
||||
|
||||
const filePath = await resolveAuthorizedPath(target.path, this.host.requireStore())
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
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))
|
||||
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))
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
export function windowForClient(
|
||||
messages: readonly NativeChatMessage[],
|
||||
clientKind: RpcContext['clientKind'],
|
||||
limit = MOBILE_NATIVE_CHAT_DEFAULT_WINDOW
|
||||
): NativeChatMessage[] {
|
||||
return windowTranscript(messages, limit).map((message) => sanitizeMessage(message, clientKind))
|
||||
}
|
||||
@@ -1,18 +1,19 @@
|
||||
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 { defineMethod, defineStreamingMethod, type RpcAnyMethod } from '../core'
|
||||
import { resolveNativeChatTranscriptSource } from '../native-chat-transcript-source'
|
||||
import { sanitizeNativeChatRpcImageBlock } from './native-chat-rpc-image-block'
|
||||
import {
|
||||
MOBILE_NATIVE_CHAT_DEFAULT_WINDOW,
|
||||
MOBILE_NATIVE_CHAT_MAX_WINDOW,
|
||||
sanitizeAppendForClient,
|
||||
windowForClient
|
||||
} from './native-chat-rpc-message-sanitizer'
|
||||
|
||||
// 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
|
||||
@@ -50,6 +51,7 @@ const NativeChatSession = z
|
||||
// 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(),
|
||||
capabilities: z.object({ transcriptPending: z.literal(1).optional() }).optional(),
|
||||
worktreeId: z.string().min(1).optional(),
|
||||
terminal: z.string().min(1).optional(),
|
||||
beforeOffset: z.number().int().nonnegative().optional()
|
||||
@@ -62,151 +64,6 @@ 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',
|
||||
@@ -297,6 +154,15 @@ export const NATIVE_CHAT_METHODS: readonly RpcAnyMethod[] = [
|
||||
...(lifecycle ? { lifecycle } : {})
|
||||
})
|
||||
},
|
||||
...(params.capabilities?.transcriptPending === 1
|
||||
? {
|
||||
onTranscriptPending: () => {
|
||||
if (!closed) {
|
||||
emit({ type: 'snapshot', messages: [], hasMore: false, pending: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
: {}),
|
||||
onReplace: (messages, hasMore, beforeOffset, lifecycle) => {
|
||||
if (closed) {
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user