From 04537aabc6e5ad72a783fadc5d883da2c299d496 Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Wed, 11 Nov 2026 20:06:53 +0000 Subject: [PATCH] fix(ai-vault): Session History Hand Off gives OpenCode sessions their full history (#41236) OpenCode, OpenCode 2 and Z Code keep every conversation in one shared database, so Session History could only hand off the row's preview messages. The session's own host now copies that one conversation into the per-chat hand-off snapshot the chat Hand Off already uses, and the dialog points the new agent at it. Older hosts, SSH rows and failed reads keep the preview. --- src/main/ai-vault/session-handoff-snapshot.ts | 73 +++++++++++++++++++ src/main/ipc/ai-vault/ai-vault-handoff.ts | 35 +++++++++ src/main/ipc/ai-vault/ai-vault-resume.ts | 5 ++ .../transcript-opencode-database.ts | 24 +++--- .../runtime/rpc/methods/history/ai-vault.ts | 8 ++ src/preload/api/ai-vault-api.ts | 6 ++ src/preload/api/ai-vault-bridge.ts | 3 + .../ai-vault/panel/AiVaultPanel.tsx | 2 +- .../ai-vault-session-continuation.ts | 54 ++++++++++++-- .../ai-vault-session-launch-actions.ts | 49 +++++++++---- .../ai-vault-session-launch-feedback.ts | 9 +++ .../src/web/preload-api/web-ai-vault-api.ts | 17 +++++ .../contracts/ai-vault-session-handoff.ts | 31 ++++++++ src/shared/rpc-contract/ai-vault-params.ts | 7 ++ .../rpc-params-catalog.generated.ts | 2 + 15 files changed, 292 insertions(+), 33 deletions(-) create mode 100644 src/main/ai-vault/session-handoff-snapshot.ts create mode 100644 src/main/ipc/ai-vault/ai-vault-handoff.ts create mode 100644 src/shared/ai-vault/contracts/ai-vault-session-handoff.ts diff --git a/src/main/ai-vault/session-handoff-snapshot.ts b/src/main/ai-vault/session-handoff-snapshot.ts new file mode 100644 index 00000000000..2849ea58bac --- /dev/null +++ b/src/main/ai-vault/session-handoff-snapshot.ts @@ -0,0 +1,73 @@ +import { + buildOpenCodeSqliteCandidatePath, + splitOpenCodeSqliteCandidate +} from '../../shared/opencode-sqlite-session-path' +import { + AI_VAULT_SESSION_HANDOFF_TIMEOUT_MS, + isAiVaultDatabaseAgent, + type AiVaultSessionHandoffArgs, + type AiVaultSessionHandoffResult +} from '../../shared/ai-vault/contracts/ai-vault-session-handoff' +import { formatWorkerTranscriptMessage } from '../../shared/worker-transcript-text' +import { isWslUncPath, toLinuxPath } from '../../shared/wsl-paths' +import { readOpenCodeNativeChatTranscriptFull } from '../native-chat/transcript-opencode' +import { writeAgentSessionContextSnapshot } from '../native-chat/agent-session-context-snapshots' +import { wakeNativeChatTranscriptDistro } from '../native-chat/native-chat-open-wake' +import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths' +import { wslDistroForPath } from '../wsl/filesystem/wsl-unc-access-gate' +import { getAiVaultWslScanHomeDirs } from './ai-vault-scan-roots' +import { openCodeWslPath } from './scanners/opencode/reader-transport/session-scanner-opencode-wsl-client' +import { configureOpenCodeTranscriptWslReaders } from '../native-chat/transcript-opencode-database' + +/** Copies one database-backed conversation into its own hand-off snapshot on this host, + * so the new agent never gets a path to the account-wide database. */ +export async function writeAiVaultSessionHandoffSnapshot( + args: Pick +): Promise { + if (!isAiVaultDatabaseAgent(args.agent)) { + return {} + } + // Rows carry `#` or the bare database plus sessionId; both must name a database file. + const candidate = + splitOpenCodeSqliteCandidate(args.filePath, args.agent) || !args.sessionId + ? args.filePath + : buildOpenCodeSqliteCandidatePath(args.filePath, args.sessionId) + const row = splitOpenCodeSqliteCandidate(candidate, args.agent) + if (!row) { + return {} + } + try { + // Hand-off is a user act: start a stopped distro before the gated database read. + await wakeNativeChatTranscriptDistro(wslDistroForPath(row.dbPath)) + // No one waits past the caller's budget, so stop reading then too. + const signal = AbortSignal.timeout(AI_VAULT_SESSION_HANDOFF_TIMEOUT_MS) + if (openCodeWslPath(row.dbPath)) { + // This process reads WSL databases through per-distro readers; keep every known distro's. + const homes = (await getAiVaultWslScanHomeDirs()).slice(0, 32) + await configureOpenCodeTranscriptWslReaders([...homes, row.dbPath], signal) + } + const read = await readOpenCodeNativeChatTranscriptFull( + row.sessionId, + { resolveDbPath: async () => row.dbPath }, + signal, + args.agent === 'zcode' ? 'zcode' : undefined + ) + if ('error' in read) { + return {} + } + const text = read.messages.map(formatWorkerTranscriptMessage).join('\n\n') + if (!text.trim()) { + return {} + } + const path = await writeAgentSessionContextSnapshot( + getProfileUserDataPath(), + `ai-vault:${candidate}`, + text + ) + // A WSL row hands off to a WSL workspace, which reads the Windows temp file through /mnt. + return { transcriptPath: isWslUncPath(row.dbPath) ? toLinuxPath(path) : path } + } catch (error) { + console.warn('[ai-vault] could not save the session for hand-off', error) + return {} + } +} diff --git a/src/main/ipc/ai-vault/ai-vault-handoff.ts b/src/main/ipc/ai-vault/ai-vault-handoff.ts new file mode 100644 index 00000000000..ac9f7f53b27 --- /dev/null +++ b/src/main/ipc/ai-vault/ai-vault-handoff.ts @@ -0,0 +1,35 @@ +import { isUnresolvedOwnerHostId, parseExecutionHostId } from '../../../shared/execution-host' +import { + AI_VAULT_SESSION_HANDOFF_TIMEOUT_MS, + readAiVaultSessionHandoffResult, + type AiVaultSessionHandoffArgs, + type AiVaultSessionHandoffResult +} from '../../../shared/ai-vault/contracts/ai-vault-session-handoff' +import { writeAiVaultSessionHandoffSnapshot } from '../../ai-vault/session-handoff-snapshot' +import { callRuntimeEnvironment } from '../runtime-environments/runtime-environment-transport-routing' +import { getRuntimeEnvironmentRegistryRoot } from '../runtime-environments/runtime-environment-registry-root' + +/** The snapshot is written on the session's own host, where the hand-off agent starts. */ +export async function prepareAiVaultSessionHandoff( + args: AiVaultSessionHandoffArgs +): Promise { + const host = parseExecutionHostId(args.executionHostId) + if (host?.kind === 'ssh') { + return {} + } + if (host?.kind !== 'runtime') { + return writeAiVaultSessionHandoffSnapshot(args) + } + if (isUnresolvedOwnerHostId(host.id)) { + return {} + } + // An older host answers method_not_found; any failure leaves the preview hand-off. + const response = await callRuntimeEnvironment( + getRuntimeEnvironmentRegistryRoot(), + host.environmentId, + 'aiVault.prepareSessionHandoff', + { agent: args.agent, filePath: args.filePath, sessionId: args.sessionId }, + AI_VAULT_SESSION_HANDOFF_TIMEOUT_MS + ).catch(() => null) + return readAiVaultSessionHandoffResult(response?.ok === true ? response.result : null) +} diff --git a/src/main/ipc/ai-vault/ai-vault-resume.ts b/src/main/ipc/ai-vault/ai-vault-resume.ts index 90ca4c7365e..3d556fbe14a 100644 --- a/src/main/ipc/ai-vault/ai-vault-resume.ts +++ b/src/main/ipc/ai-vault/ai-vault-resume.ts @@ -13,6 +13,8 @@ import { tryEnsureStructuredAgentSessionHostForResume } from '../../runtime/agen import { copyStructuredChatConversation } from '../../ai-vault/structured-chat-conversation-copy' import { wakeNativeChatTranscriptDistro } from '../../native-chat/native-chat-open-wake' import { wslDistroForPath } from '../../wsl/filesystem/wsl-unc-access-gate' +import { prepareAiVaultSessionHandoff } from './ai-vault-handoff' +import type { AiVaultSessionHandoffArgs } from '../../../shared/ai-vault/contracts/ai-vault-session-handoff' export type AiVaultResumeHandlerOptions = { ensureStructuredSessionOwnership?: () => Promise @@ -28,6 +30,9 @@ export function registerAiVaultResumeHandler(options: AiVaultResumeHandlerOption 'aiVault:prepareSessionResume', (_event, args: AiVaultPrepareSessionResumeRequest) => prepareAiVaultSessionResume(args, options) ) + ipcMain.handle('aiVault:prepareSessionHandoff', (_event, args: AiVaultSessionHandoffArgs) => + prepareAiVaultSessionHandoff(args) + ) } export async function prepareAiVaultSessionResume( diff --git a/src/main/native-chat/transcript-opencode-database.ts b/src/main/native-chat/transcript-opencode-database.ts index a896bc16db6..c063ad325f2 100644 --- a/src/main/native-chat/transcript-opencode-database.ts +++ b/src/main/native-chat/transcript-opencode-database.ts @@ -20,6 +20,16 @@ function zcodeTranscriptDatabasePaths(wslHomeDirs: readonly string[]): string[] return [homedir(), ...wslHomeDirs].map((home) => join(home, '.zcode', 'cli', 'db', 'db.sqlite')) } +/** Readers for every listed WSL root; the registry drops any distro left out. */ +export async function configureOpenCodeTranscriptWslReaders( + roots: readonly string[], + signal: AbortSignal +): Promise { + const readers = await waitForPromiseWithSignal(prepareOpenCodeWslReaders(roots), signal) + signal.throwIfAborted() + configureOpenCodeWslReaders(readers) +} + export async function discoverOpenCodeTranscriptDatabase( sessionId?: string, signal?: AbortSignal, @@ -98,12 +108,7 @@ export async function discoverOpenCodeTranscriptDatabase( await waitForPromiseWithSignal(getAiVaultWslScanHomeDirs(), boundedSignal) ).slice(0, 32) if (homes.length > 0) { - const readers = await waitForPromiseWithSignal( - prepareOpenCodeWslReaders(homes), - boundedSignal - ) - boundedSignal.throwIfAborted() - configureOpenCodeWslReaders(readers) + await configureOpenCodeTranscriptWslReaders(homes, boundedSignal) } const wsl = await findSession(zcodeTranscriptDatabasePaths(homes)) if (wsl) { @@ -141,12 +146,7 @@ export async function discoverOpenCodeTranscriptDatabase( const primaryWsl = primary.filter((path) => openCodeWslPath(path)) const readerRoots = [...new Set([...homes, ...primaryWsl])] if (readerRoots.length > 0) { - const readers = await waitForPromiseWithSignal( - prepareOpenCodeWslReaders(readerRoots), - boundedSignal - ) - boundedSignal.throwIfAborted() - configureOpenCodeWslReaders(readers) + await configureOpenCodeTranscriptWslReaders(readerRoots, boundedSignal) } const wsl = await findSession([...primaryWsl, ...sources.flat()]) if (wsl) { diff --git a/src/main/runtime/rpc/methods/history/ai-vault.ts b/src/main/runtime/rpc/methods/history/ai-vault.ts index 0ec0a41183c..0354adef06d 100644 --- a/src/main/runtime/rpc/methods/history/ai-vault.ts +++ b/src/main/runtime/rpc/methods/history/ai-vault.ts @@ -1,4 +1,5 @@ import { prepareStructuredChatCliFork } from '../../../../ai-vault/structured-chat-cli-fork' +import { writeAiVaultSessionHandoffSnapshot } from '../../../../ai-vault/session-handoff-snapshot' import { AiVaultSearchRequestSchema, AiVaultSearchStatusRequestSchema, @@ -33,6 +34,7 @@ import { import { AiVaultDeleteSessionParams, AiVaultListSessionsParams, + AiVaultPrepareSessionHandoffParams, AiVaultPrepareSessionResumeParams, AiVaultSessionTitlesParams } from '../../../../../shared/rpc-contract/ai-vault-params' @@ -164,6 +166,12 @@ export const AI_VAULT_METHODS = [ ) } }), + defineMethod({ + name: 'aiVault.prepareSessionHandoff', + permission: 'workspace', + params: AiVaultPrepareSessionHandoffParams, + handler: (params) => writeAiVaultSessionHandoffSnapshot(params) + }), defineMethod({ name: 'aiVault.deleteSession', permission: 'workspace', diff --git a/src/preload/api/ai-vault-api.ts b/src/preload/api/ai-vault-api.ts index 0df2a880e40..76716433c64 100644 --- a/src/preload/api/ai-vault-api.ts +++ b/src/preload/api/ai-vault-api.ts @@ -7,6 +7,10 @@ import type { AiVaultDeleteSessionArgs, AiVaultDeleteSessionResult } from '../../shared/ai-vault/contracts/ai-vault-session-deletion' +import type { + AiVaultSessionHandoffArgs, + AiVaultSessionHandoffResult +} from '../../shared/ai-vault/contracts/ai-vault-session-handoff' import type { AiVaultFirstUserPromptArgs, AiVaultFirstUserPromptResult, @@ -50,6 +54,8 @@ export type AiVaultApi = { prepareSessionResume: ( args: AiVaultPrepareSessionResumeRequest ) => Promise + /** Saves a database-backed session's full conversation on its own host for a hand-off. */ + prepareSessionHandoff: (args: AiVaultSessionHandoffArgs) => Promise listSubagentSessions: (args: AiVaultSubagentListArgs) => Promise getFirstUserPrompt: (args: AiVaultFirstUserPromptArgs) => Promise /** Moves a deletable session's transcript to the OS trash; local sessions only. */ diff --git a/src/preload/api/ai-vault-bridge.ts b/src/preload/api/ai-vault-bridge.ts index e12c807db8c..b772715009c 100644 --- a/src/preload/api/ai-vault-bridge.ts +++ b/src/preload/api/ai-vault-bridge.ts @@ -16,6 +16,7 @@ import type { } from '../../shared/ai-vault/contracts/ai-vault-types' import type { AiVaultSessionTitlesArgs } from '../../shared/ai-vault/contracts/ai-vault-session-title' import type { AiVaultPrepareSessionResumeRequest } from '../../shared/ai-vault/resume/ai-vault-resume-preparation' +import type { AiVaultSessionHandoffArgs } from '../../shared/ai-vault/contracts/ai-vault-session-handoff' import type { PreloadApi } from '../api-types' import { AI_VAULT_SCAN_PROGRESS_CHANNEL, @@ -54,6 +55,8 @@ export const aiVaultApi = { ipcRenderer.invoke('aiVault:cancelListSessions', args), prepareSessionResume: (args: AiVaultPrepareSessionResumeRequest) => ipcRenderer.invoke('aiVault:prepareSessionResume', args), + prepareSessionHandoff: (args: AiVaultSessionHandoffArgs) => + ipcRenderer.invoke('aiVault:prepareSessionHandoff', args), listSubagentSessions: (args: AiVaultSubagentListArgs) => ipcRenderer.invoke('aiVault:listSubagentSessions', args), getFirstUserPrompt: (args: AiVaultFirstUserPromptArgs) => diff --git a/src/renderer/src/components/right-sidebar/ai-vault/panel/AiVaultPanel.tsx b/src/renderer/src/components/right-sidebar/ai-vault/panel/AiVaultPanel.tsx index ccd8e6de7c6..cf75c4cdc29 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault/panel/AiVaultPanel.tsx +++ b/src/renderer/src/components/right-sidebar/ai-vault/panel/AiVaultPanel.tsx @@ -367,7 +367,7 @@ export default function AiVaultPanel({ /> )} - {launchActions.continuationRequest && ( + {launchActions.continuationOpen && ( message.text.trim())) + // A database row's host saves its conversation when Hand Off opens, even with no preview text. + (isAiVaultDatabaseAgent(session.agent) || + vaultTranscriptPath(session) || + session.previewMessages.some((message) => message.text.trim())) ) } -export function prepareAiVaultSessionContinuation(args: { +type AiVaultSessionContinuationTarget = { session: AiVaultSession targetWorktreeId: string targetWorkspacePath: string -}): AgentSessionContinuationRequest { +} + +/** A database row's host saves this one conversation first; null when nothing usable came back. */ +export async function loadDatabaseAiVaultSessionContinuation( + target: AiVaultSessionContinuationTarget +): Promise { + const { session } = target + const saved = await withTimeout( + window.api.aiVault.prepareSessionHandoff({ + agent: session.agent, + filePath: session.filePath, + sessionId: session.sessionId, + executionHostId: session.executionHostId + }), + AI_VAULT_SESSION_HANDOFF_TIMEOUT_MS, + {} + ) + const request = prepareAiVaultSessionContinuation({ + ...target, + savedTranscriptPath: saved.transcriptPath + }) + return buildAgentSessionContinuationPrompt(request.source, 'focused') ? request : null +} + +export function prepareAiVaultSessionContinuation( + args: AiVaultSessionContinuationTarget & { + /** This conversation's own hand-off snapshot, for database-backed rows. */ + savedTranscriptPath?: string + } +): AgentSessionContinuationRequest { const { session, targetWorktreeId, targetWorkspacePath } = args return { source: { @@ -23,7 +63,7 @@ export function prepareAiVaultSessionContinuation(args: { sourceAgent: session.agent, sourceTitle: session.title, sourceWorkingDirectory: session.cwd, - transcriptPath: vaultTranscriptPath(session), + transcriptPath: args.savedTranscriptPath ?? vaultTranscriptPath(session), // Use provider-authenticated prompts; preview user entries may contain tool results or injected skills. lastPrompt: session.lastUserPrompt ?? null, lastAssistantMessage: latestAssistantPreview(session) @@ -48,8 +88,8 @@ function previewTranscript(session: AiVaultSession): string { } function vaultTranscriptPath(session: AiVaultSession): string | null { - // OpenCode vault paths identify its database or storage metadata, not a full transcript. - if (session.agent === 'opencode' || session.agent === 'opencode2') { + // These rows name an account-wide database; handing that path over would expose every conversation in it. + if (isAiVaultDatabaseAgent(session.agent)) { return null } return session.filePath.trim() || null diff --git a/src/renderer/src/components/right-sidebar/ai-vault/session-actions/ai-vault-session-launch-actions.ts b/src/renderer/src/components/right-sidebar/ai-vault/session-actions/ai-vault-session-launch-actions.ts index e195184b22c..c5050e3b60b 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault/session-actions/ai-vault-session-launch-actions.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault/session-actions/ai-vault-session-launch-actions.ts @@ -1,8 +1,9 @@ import { + notifyAiVaultSessionNoContinuationContext, notifyAiVaultSessionPreparationFailure, resolveAiVaultSessionLaunchTargetOrNotify } from './ai-vault-session-launch-feedback' -import { useCallback, useState } from 'react' +import { useCallback, useRef, useState } from 'react' import { toast } from 'sonner' import { buildAiVaultResumeCopyCommandForWorktree, @@ -23,7 +24,11 @@ import { } from '@/lib/agent-session/resume/history/ai-vault-session-resume-preparation' import { translate } from '@/i18n/i18n' import { describeAiVaultCliForkFailure } from './ai-vault-session-cli-fork' -import { prepareAiVaultSessionContinuation } from './ai-vault-session-continuation' +import { + loadDatabaseAiVaultSessionContinuation, + prepareAiVaultSessionContinuation +} from './ai-vault-session-continuation' +import { isAiVaultDatabaseAgent } from '../../../../../../shared/ai-vault/contracts/ai-vault-session-handoff' import { activateAiVaultStructuredSession } from '@/lib/agent-session/open/activate-ai-vault-structured-session' import { adoptsCliConversations } from '../../../../../../shared/structured-agent-cli-conversation-adoption' @@ -54,8 +59,12 @@ export function useAiVaultSessionLaunchActions({ targetState: AiVaultSessionResumeTargetState agentCmdOverrides?: Partial> }) { - const [continuationRequest, setContinuationRequest] = - useState(null) + // An open dialog whose request is null is still saving the conversation on its host. + const [continuation, setContinuation] = useState<{ + request: AgentSessionContinuationRequest | null + } | null>(null) + // Only the latest Hand Off may fill or close the dialog. + const continuationAttempt = useRef(null) const buildResumeCommand = useCallback( (session: AiVaultSession, worktreeId?: string | null): string => @@ -245,20 +254,33 @@ export function useAiVaultSessionLaunchActions({ ) return } - setContinuationRequest( - prepareAiVaultSessionContinuation({ - session, - targetWorktreeId: targetId.worktreeId, - targetWorkspacePath - }) - ) + const target = { session, targetWorktreeId: targetId.worktreeId, targetWorkspacePath } + const attempt = {} + continuationAttempt.current = attempt + if (!isAiVaultDatabaseAgent(session.agent)) { + setContinuation({ request: prepareAiVaultSessionContinuation(target) }) + return + } + setContinuation({ request: null }) + void loadDatabaseAiVaultSessionContinuation(target).then((request) => { + if (continuationAttempt.current !== attempt) { + return + } + if (request) { + setContinuation({ request }) + return + } + setContinuation(null) + notifyAiVaultSessionNoContinuationContext() + }) }, [activeWorktree?.id, activeWorktreeId, targetState] ) const handleContinuationDialogOpenChange = useCallback((open: boolean): void => { if (!open) { - setContinuationRequest(null) + continuationAttempt.current = null + setContinuation(null) } }, []) @@ -269,7 +291,8 @@ export function useAiVaultSessionLaunchActions({ handleResumeInNewCli, handleResumeInNewChat, handleContinueInNewSession, - continuationRequest, + continuationOpen: continuation !== null, + continuationRequest: continuation?.request ?? null, handleContinuationDialogOpenChange } } diff --git a/src/renderer/src/components/right-sidebar/ai-vault/session-actions/ai-vault-session-launch-feedback.ts b/src/renderer/src/components/right-sidebar/ai-vault/session-actions/ai-vault-session-launch-feedback.ts index 8f881d4738b..3c581cd7dea 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault/session-actions/ai-vault-session-launch-feedback.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault/session-actions/ai-vault-session-launch-feedback.ts @@ -19,6 +19,15 @@ export function notifyAiVaultSessionQueued(agent: AiVaultAgent): void { ) } +export function notifyAiVaultSessionNoContinuationContext(): void { + toast.error( + translate( + 'components.agentSessionContinuation.noContext', + 'No session context is available to continue in a new session.' + ) + ) +} + export function notifyAiVaultSessionPreparationFailure( error: unknown, agent: AiVaultAgent, diff --git a/src/renderer/src/web/preload-api/web-ai-vault-api.ts b/src/renderer/src/web/preload-api/web-ai-vault-api.ts index a2359550941..c6ff4eaafdb 100644 --- a/src/renderer/src/web/preload-api/web-ai-vault-api.ts +++ b/src/renderer/src/web/preload-api/web-ai-vault-api.ts @@ -8,6 +8,11 @@ import type { AiVaultPrepareSessionResumeResult } from '../../../../shared/ai-vault/resume/ai-vault-resume-preparation' import type { AiVaultDeleteSessionArgs } from '../../../../shared/ai-vault/contracts/ai-vault-session-deletion' +import { + AI_VAULT_SESSION_HANDOFF_TIMEOUT_MS, + readAiVaultSessionHandoffResult, + type AiVaultSessionHandoffArgs +} from '../../../../shared/ai-vault/contracts/ai-vault-session-handoff' import type { AiVaultSessionTitlesArgs, AiVaultSessionTitlesResult @@ -105,6 +110,18 @@ export function createWebAiVaultApi(): NonNullable['aiVault' cancelListSessions: () => Promise.resolve(), prepareSessionResume: (args: AiVaultPrepareSessionResumeRequest) => callRuntimeResult('aiVault.prepareSessionResume', args), + // A browser reaches only its own paired runtime; an older one leaves the preview hand-off. + prepareSessionHandoff: async (args: AiVaultSessionHandoffArgs) => { + if (!addressesOwnRuntime(args.executionHostId)) { + return {} + } + const result = await callRuntimeResult( + 'aiVault.prepareSessionHandoff', + { agent: args.agent, filePath: args.filePath, sessionId: args.sessionId }, + AI_VAULT_SESSION_HANDOFF_TIMEOUT_MS + ).catch(() => null) + return readAiVaultSessionHandoffResult(result) + }, // Why: no server-side RPC for subagent transcript listing yet, so report an empty (not erroring) result. listSubagentSessions: () => Promise.resolve({ sessions: [], issues: [] }), // Why: full first-prompt re-parse is local-FS only; web/runtime falls back to preview text. diff --git a/src/shared/ai-vault/contracts/ai-vault-session-handoff.ts b/src/shared/ai-vault/contracts/ai-vault-session-handoff.ts new file mode 100644 index 00000000000..851b5121c30 --- /dev/null +++ b/src/shared/ai-vault/contracts/ai-vault-session-handoff.ts @@ -0,0 +1,31 @@ +import type { ExecutionHostId } from '../../execution-host' +import { resolveNativeChatTranscriptAgent } from '../../native-chat/native-chat-agent-support' +import type { AiVaultAgent } from './ai-vault-types' + +/** Agents whose rows name one conversation inside an account-wide database. */ +export function isAiVaultDatabaseAgent( + agent: AiVaultAgent +): agent is Extract { + return resolveNativeChatTranscriptAgent(agent) === 'opencode' +} + +/** How long a hand-off waits for the host's saved copy before using the preview. */ +export const AI_VAULT_SESSION_HANDOFF_TIMEOUT_MS = 30_000 + +export type AiVaultSessionHandoffArgs = { + agent: AiVaultAgent + /** The database path, or `#`. */ + filePath: string + sessionId?: string + executionHostId?: ExecutionHostId +} + +/** Absent `transcriptPath`: no saved copy (older host, SSH row, unreadable database); use the preview. */ +export type AiVaultSessionHandoffResult = { transcriptPath?: string } + +/** Reads a host's answer; anything but a non-empty path keeps the preview hand-off. */ +export function readAiVaultSessionHandoffResult(value: unknown): AiVaultSessionHandoffResult { + const transcriptPath = + value && typeof value === 'object' && 'transcriptPath' in value ? value.transcriptPath : null + return typeof transcriptPath === 'string' && transcriptPath ? { transcriptPath } : {} +} diff --git a/src/shared/rpc-contract/ai-vault-params.ts b/src/shared/rpc-contract/ai-vault-params.ts index 0c113761afb..4ef3f897ad1 100644 --- a/src/shared/rpc-contract/ai-vault-params.ts +++ b/src/shared/rpc-contract/ai-vault-params.ts @@ -99,3 +99,10 @@ export const AiVaultDeleteSessionParams = z.object({ sessionId: z.string().min(1).max(512).optional(), filePath: z.string().min(1).max(AI_VAULT_SCOPE_PATH_MAX_LENGTH) }) + +// Only the row identity travels; the host reads its own database and writes the snapshot locally. +export const AiVaultPrepareSessionHandoffParams = z.object({ + agent: z.enum(AI_VAULT_AGENTS), + sessionId: z.string().min(1).max(512).optional(), + filePath: z.string().min(1).max(AI_VAULT_SCOPE_PATH_MAX_LENGTH) +}) diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts index 789ed39392e..3d1414c1347 100644 --- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts +++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts @@ -87,6 +87,7 @@ import { ReadVisualParams } from './agent-session-visual-params' import { AiVaultDeleteSessionParams, AiVaultListSessionsParams, + AiVaultPrepareSessionHandoffParams, AiVaultPrepareSessionResumeParams, AiVaultSessionTitlesParams } from './ai-vault-params' @@ -887,6 +888,7 @@ export const RPC_PARAMS_BY_METHOD = { 'agentTeams.tmuxCompat': AgentTeamsTmuxCompat, 'aiVault.deleteSession': AiVaultDeleteSessionParams, 'aiVault.listSessions': AiVaultListSessionsParams, + 'aiVault.prepareSessionHandoff': AiVaultPrepareSessionHandoffParams, 'aiVault.prepareSessionResume': AiVaultPrepareSessionResumeParams, 'aiVault.resolveSessionTitles': AiVaultSessionTitlesParams, 'aiVault.searchSessions': AiVaultSearchRequestSchema,