Backup before continuing from Codex 5.2 session - User storage, compaction suggestions, streaming improvements
This commit is contained in:
@@ -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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user