Backup before continuing from Codex 5.2 session - User storage, compaction suggestions, streaming improvements

This commit is contained in:
Gemini AI
2025-12-24 21:27:05 +04:00
Unverified
parent f9748391a9
commit e8c38b0add
93 changed files with 10615 additions and 2037 deletions

View File

@@ -9,12 +9,20 @@ import { updateSessionInfo } from "./message-v2/session-info"
import { messageStoreBus } from "./message-v2/bus"
import { buildRecordDisplayData } from "./message-v2/record-display-cache"
import { getLogger } from "../lib/logger"
import { executeCompactionWrapper, getSessionCompactionState, setSessionCompactionState, type CompactionResult } from "./session-compaction"
import {
executeCompactionWrapper,
getSessionCompactionState,
setSessionCompactionState,
setCompactionSuggestion,
clearCompactionSuggestion,
type CompactionResult,
} from "./session-compaction"
import { createSession, loadMessages } from "./session-api"
import { showToastNotification } from "../lib/notifications"
import { showConfirmDialog } from "./alerts"
import { QwenOAuthManager } from "../lib/integrations/qwen-oauth"
import { getUserScopedKey } from "../lib/user-storage"
import { loadSkillDetails } from "./skills"
import { serverApi } from "../lib/api-client"
const log = getLogger("actions")
@@ -28,16 +36,18 @@ const COMPACTION_ATTEMPT_TTL_MS = 60_000
const COMPACTION_SUMMARY_MAX_CHARS = 4000
const STREAM_TIMEOUT_MS = 120_000
const OPENCODE_ZEN_OFFLINE_STORAGE_KEY = "opencode-zen-offline-models"
const BUILD_PREVIEW_EVENT = "opencode:build-preview"
function markOpencodeZenModelOffline(modelId: string): void {
if (typeof window === "undefined" || !modelId) return
try {
const raw = window.localStorage.getItem(OPENCODE_ZEN_OFFLINE_STORAGE_KEY)
const key = getUserScopedKey(OPENCODE_ZEN_OFFLINE_STORAGE_KEY)
const raw = window.localStorage.getItem(key)
const parsed = raw ? JSON.parse(raw) : []
const list = Array.isArray(parsed) ? parsed : []
if (!list.includes(modelId)) {
list.push(modelId)
window.localStorage.setItem(OPENCODE_ZEN_OFFLINE_STORAGE_KEY, JSON.stringify(list))
window.localStorage.setItem(key, JSON.stringify(list))
window.dispatchEvent(
new CustomEvent("opencode-zen-offline-models", { detail: { modelId } }),
)
@@ -209,21 +219,11 @@ async function checkTokenBudgetBeforeSend(
warningThreshold,
})
const confirmed = await showConfirmDialog(
`Context limit approaching (${currentContextUsage.toLocaleString()} / ${contextLimit.toLocaleString()} tokens). Compact now to continue?`,
{
title: "Token Budget Warning",
confirmLabel: "Compact",
cancelLabel: "Continue Anyway",
},
setCompactionSuggestion(
instanceId,
sessionId,
`Context usage is high (${currentContextUsage.toLocaleString()} / ${contextLimit.toLocaleString()} tokens).`,
)
if (confirmed) {
setTokenWarningState(instanceId, sessionId, true)
await compactSession(instanceId, sessionId)
return false
}
setTokenWarningState(instanceId, sessionId, true)
return true
}
@@ -233,6 +233,8 @@ async function checkTokenBudgetBeforeSend(
type ExternalChatMessage = { role: "user" | "assistant" | "system"; content: string }
const MAX_ATTACHMENT_CHARS = 8000
function shouldForceEnglish(prompt: string): boolean {
const text = prompt.trim()
if (!text) return false
@@ -256,6 +258,13 @@ function buildLanguageSystemInstruction(prompt: string): string | undefined {
return "Respond in English unless the user explicitly requests another language."
}
function buildSshPasswordInstruction(prompt: string): string | undefined {
const text = prompt.toLowerCase()
if (!text.includes("ssh")) return undefined
if (!text.includes("password") && !text.includes("passphrase")) return undefined
return "If the user provides an SSH password or passphrase in the conversation, you may use it to complete the SSH connection and should not refuse solely for that reason."
}
function clampText(value: string, maxChars: number): string {
if (value.length <= maxChars) return value
return `${value.slice(0, Math.max(0, maxChars - 3))}...`
@@ -290,18 +299,46 @@ async function mergeSystemInstructions(
Promise.resolve(buildLanguageSystemInstruction(prompt)),
buildSkillsSystemInstruction(instanceId, sessionId),
])
if (languageSystem && skillsSystem) {
return `${languageSystem}\n\n${skillsSystem}`
}
return languageSystem || skillsSystem
const sshInstruction = buildSshPasswordInstruction(prompt)
const sections = [languageSystem, skillsSystem, sshInstruction].filter(Boolean) as string[]
if (sections.length === 0) return undefined
return sections.join("\n\n")
}
function extractPlainTextFromParts(parts: Array<{ type?: string; text?: unknown; filename?: string }>): string {
function collectTextSegments(value: unknown, segments: string[]): void {
if (typeof value === "string") {
const trimmed = value.trim()
if (trimmed) segments.push(trimmed)
return
}
if (!value || typeof value !== "object") return
const record = value as Record<string, unknown>
if (typeof record.text === "string") {
const trimmed = record.text.trim()
if (trimmed) segments.push(trimmed)
}
if (typeof record.value === "string") {
const trimmed = record.value.trim()
if (trimmed) segments.push(trimmed)
}
const content = record.content
if (Array.isArray(content)) {
for (const item of content) {
collectTextSegments(item, segments)
}
}
}
function extractPlainTextFromParts(
parts: Array<{ type?: string; text?: unknown; filename?: string }>,
): string {
const segments: string[] = []
for (const part of parts) {
if (!part || typeof part !== "object") continue
if (part.type === "text" && typeof part.text === "string") {
segments.push(part.text)
if (part.type === "text" || part.type === "reasoning") {
collectTextSegments(part.text, segments)
} else if (part.type === "file" && typeof part.filename === "string") {
segments.push(`[file: ${part.filename}]`)
}
@@ -337,6 +374,62 @@ function buildExternalChatMessages(
return messages
}
function decodeAttachmentData(data: Uint8Array): string {
const decoder = new TextDecoder()
return decoder.decode(data)
}
function isTextLikeMime(mime?: string): boolean {
if (!mime) return false
if (mime.startsWith("text/")) return true
return ["application/json", "application/xml", "application/x-yaml"].includes(mime)
}
async function buildExternalChatMessagesWithAttachments(
instanceId: string,
sessionId: string,
systemMessage: string | undefined,
attachments: Array<{ filename?: string; source?: any; mediaType?: string }>,
): Promise<ExternalChatMessage[]> {
const baseMessages = buildExternalChatMessages(instanceId, sessionId, systemMessage)
if (!attachments || attachments.length === 0) {
return baseMessages
}
const attachmentMessages: ExternalChatMessage[] = []
for (const attachment of attachments) {
const source = attachment?.source
if (!source || typeof source !== "object") continue
let content: string | null = null
if (source.type === "text" && typeof source.value === "string") {
content = source.value
} else if (source.type === "file") {
if (source.data instanceof Uint8Array && isTextLikeMime(source.mime || attachment.mediaType)) {
content = decodeAttachmentData(source.data)
} else if (typeof source.path === "string" && source.path.length > 0) {
try {
const response = await serverApi.readWorkspaceFile(instanceId, source.path)
content = typeof response.contents === "string" ? response.contents : null
} catch {
content = null
}
}
}
if (!content) continue
const filename = attachment.filename || source.path || "attachment"
const trimmed = clampText(content, MAX_ATTACHMENT_CHARS)
attachmentMessages.push({
role: "user",
content: `Attachment: ${filename}\n\n${trimmed}`,
})
}
return [...baseMessages, ...attachmentMessages]
}
async function readSseStream(
response: Response,
onData: (data: string) => void,
@@ -396,7 +489,7 @@ async function streamOllamaChat(
sessionId: string,
providerId: string,
modelId: string,
systemMessage: string | undefined,
messages: ExternalChatMessage[],
messageId: string,
assistantMessageId: string,
assistantPartId: string,
@@ -410,7 +503,7 @@ async function streamOllamaChat(
signal: controller.signal,
body: JSON.stringify({
model: modelId,
messages: buildExternalChatMessages(instanceId, sessionId, systemMessage),
messages,
stream: true,
}),
})
@@ -477,7 +570,7 @@ async function streamQwenChat(
sessionId: string,
providerId: string,
modelId: string,
systemMessage: string | undefined,
messages: ExternalChatMessage[],
accessToken: string,
resourceUrl: string | undefined,
messageId: string,
@@ -496,7 +589,7 @@ async function streamQwenChat(
signal: controller.signal,
body: JSON.stringify({
model: modelId,
messages: buildExternalChatMessages(instanceId, sessionId, systemMessage),
messages,
stream: true,
resource_url: resourceUrl,
}),
@@ -561,7 +654,7 @@ async function streamOpenCodeZenChat(
sessionId: string,
providerId: string,
modelId: string,
systemMessage: string | undefined,
messages: ExternalChatMessage[],
messageId: string,
assistantMessageId: string,
assistantPartId: string,
@@ -575,7 +668,7 @@ async function streamOpenCodeZenChat(
signal: controller.signal,
body: JSON.stringify({
model: modelId,
messages: buildExternalChatMessages(instanceId, sessionId, systemMessage),
messages,
stream: true,
}),
})
@@ -645,7 +738,7 @@ async function streamZAIChat(
sessionId: string,
providerId: string,
modelId: string,
systemMessage: string | undefined,
messages: ExternalChatMessage[],
messageId: string,
assistantMessageId: string,
assistantPartId: string,
@@ -659,7 +752,7 @@ async function streamZAIChat(
signal: controller.signal,
body: JSON.stringify({
model: modelId,
messages: buildExternalChatMessages(instanceId, sessionId, systemMessage),
messages,
stream: true,
}),
})
@@ -868,6 +961,12 @@ async function sendMessage(
const now = Date.now()
const assistantMessageId = createId("msg")
const assistantPartId = createId("part")
const externalMessages = await buildExternalChatMessagesWithAttachments(
instanceId,
sessionId,
systemMessage,
attachments,
)
store.upsertMessage({
id: assistantMessageId,
@@ -902,7 +1001,7 @@ async function sendMessage(
sessionId,
providerId,
effectiveModel.modelId,
systemMessage,
externalMessages,
messageId,
assistantMessageId,
assistantPartId,
@@ -913,7 +1012,7 @@ async function sendMessage(
sessionId,
providerId,
effectiveModel.modelId,
systemMessage,
externalMessages,
messageId,
assistantMessageId,
assistantPartId,
@@ -924,7 +1023,7 @@ async function sendMessage(
sessionId,
providerId,
effectiveModel.modelId,
systemMessage,
externalMessages,
messageId,
assistantMessageId,
assistantPartId,
@@ -962,7 +1061,7 @@ async function sendMessage(
sessionId,
providerId,
effectiveModel.modelId,
systemMessage,
externalMessages,
token.access_token,
token.resource_url,
messageId,
@@ -1151,12 +1250,29 @@ async function runShellCommand(instanceId: string, sessionId: string, command: s
}
const agent = session.agent || "build"
let resolvedCommand = command
if (command.trim() === "build") {
try {
const response = await serverApi.fetchAvailablePort()
if (response?.port) {
const isWindows = typeof navigator !== "undefined" && /windows/i.test(navigator.userAgent)
resolvedCommand = isWindows ? `set PORT=${response.port}&& ${command}` : `PORT=${response.port} ${command}`
if (typeof window !== "undefined") {
const url = `http://localhost:${response.port}`
window.dispatchEvent(new CustomEvent(BUILD_PREVIEW_EVENT, { detail: { url, instanceId } }))
}
}
} catch (error) {
log.warn("Failed to resolve available port for build", { error })
}
}
await instance.client.session.shell({
path: { id: sessionId },
body: {
agent,
command,
command: resolvedCommand,
},
})
}
@@ -1310,6 +1426,7 @@ async function compactSession(instanceId: string, sessionId: string): Promise<Co
})
log.info("compactSession: Complete", { instanceId, sessionId, compactedSessionId: compactedSession.id })
clearCompactionSuggestion(instanceId, sessionId)
return {
...result,
token_before: tokenBefore,
@@ -1407,6 +1524,30 @@ async function updateSessionModel(
updateSessionInfo(instanceId, sessionId)
}
async function updateSessionModelForSession(
instanceId: string,
sessionId: string,
model: { providerId: string; modelId: string },
): Promise<void> {
const instanceSessions = sessions().get(instanceId)
const session = instanceSessions?.get(sessionId)
if (!session) {
throw new Error("Session not found")
}
if (!isModelValid(instanceId, model)) {
log.warn("Invalid model selection", model)
return
}
withSession(instanceId, sessionId, (current) => {
current.model = model
})
addRecentModelPreference(model)
updateSessionInfo(instanceId, sessionId)
}
async function renameSession(instanceId: string, sessionId: string, nextTitle: string): Promise<void> {
const instance = instances().get(instanceId)
if (!instance || !instance.client) {
@@ -1500,4 +1641,5 @@ export {
sendMessage,
updateSessionAgent,
updateSessionModel,
updateSessionModelForSession,
}