mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 02:17:31 +00:00
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.
This commit is contained in:
@@ -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<AiVaultSessionHandoffArgs, 'agent' | 'filePath' | 'sessionId'>
|
||||
): Promise<AiVaultSessionHandoffResult> {
|
||||
if (!isAiVaultDatabaseAgent(args.agent)) {
|
||||
return {}
|
||||
}
|
||||
// Rows carry `<db>#<session id>` 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 {}
|
||||
}
|
||||
}
|
||||
@@ -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<AiVaultSessionHandoffResult> {
|
||||
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)
|
||||
}
|
||||
@@ -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<void>
|
||||
@@ -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(
|
||||
|
||||
@@ -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<void> {
|
||||
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) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<AiVaultPrepareSessionResumeResult>
|
||||
/** Saves a database-backed session's full conversation on its own host for a hand-off. */
|
||||
prepareSessionHandoff: (args: AiVaultSessionHandoffArgs) => Promise<AiVaultSessionHandoffResult>
|
||||
listSubagentSessions: (args: AiVaultSubagentListArgs) => Promise<AiVaultSubagentListResult>
|
||||
getFirstUserPrompt: (args: AiVaultFirstUserPromptArgs) => Promise<AiVaultFirstUserPromptResult>
|
||||
/** Moves a deletable session's transcript to the OS trash; local sessions only. */
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -367,7 +367,7 @@ export default function AiVaultPanel({
|
||||
/>
|
||||
)}
|
||||
</AiVaultPanelSearch>
|
||||
{launchActions.continuationRequest && (
|
||||
{launchActions.continuationOpen && (
|
||||
<AgentSessionContinuationDialog
|
||||
open
|
||||
request={launchActions.continuationRequest}
|
||||
|
||||
+47
-7
@@ -1,5 +1,13 @@
|
||||
import type { AgentSessionContinuationRequest } from '@/lib/agent-session/context/agent-session-continuation'
|
||||
import {
|
||||
buildAgentSessionContinuationPrompt,
|
||||
type AgentSessionContinuationRequest
|
||||
} from '@/lib/agent-session/context/agent-session-continuation'
|
||||
import type { AiVaultSession } from '../../../../../../shared/ai-vault/contracts/ai-vault-types'
|
||||
import {
|
||||
AI_VAULT_SESSION_HANDOFF_TIMEOUT_MS,
|
||||
isAiVaultDatabaseAgent
|
||||
} from '../../../../../../shared/ai-vault/contracts/ai-vault-session-handoff'
|
||||
import { withTimeout } from '../../../../../../shared/promise-timeout-fallback'
|
||||
|
||||
export function canContinueAiVaultSessionInNewSession(
|
||||
session: AiVaultSession,
|
||||
@@ -7,15 +15,47 @@ export function canContinueAiVaultSessionInNewSession(
|
||||
): boolean {
|
||||
return Boolean(
|
||||
targetWorktreeId &&
|
||||
(vaultTranscriptPath(session) || session.previewMessages.some((message) => 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<AgentSessionContinuationRequest | null> {
|
||||
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
|
||||
|
||||
+36
-13
@@ -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<Record<AiVaultAgent, string | null>>
|
||||
}) {
|
||||
const [continuationRequest, setContinuationRequest] =
|
||||
useState<AgentSessionContinuationRequest | null>(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<object | null>(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
|
||||
}
|
||||
}
|
||||
|
||||
+9
@@ -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,
|
||||
|
||||
@@ -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<Partial<PreloadApi>['aiVault'
|
||||
cancelListSessions: () => Promise.resolve(),
|
||||
prepareSessionResume: (args: AiVaultPrepareSessionResumeRequest) =>
|
||||
callRuntimeResult<AiVaultPrepareSessionResumeResult>('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<unknown>(
|
||||
'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.
|
||||
|
||||
@@ -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<AiVaultAgent, 'opencode' | 'opencode2' | 'zcode'> {
|
||||
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 `<db>#<session id>`. */
|
||||
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 } : {}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user