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

@@ -0,0 +1,273 @@
import assert from "node:assert/strict"
import { beforeEach, describe, it, mock } from "node:test"
import type { CompactionResult } from "../session-compaction.js"
import {
getCompactionConfig,
updateCompactionConfig,
undoCompaction,
rehydrateSession,
checkAndTriggerAutoCompact,
setSessionCompactionState,
getSessionCompactionState,
estimateTokenReduction,
executeCompactionWrapper,
} from "../session-compaction.js"
import type { CompactionEvent, StructuredSummary } from "../../lib/compaction-schema.js"
const MOCK_INSTANCE_ID = "test-instance-123"
const MOCK_SESSION_ID = "test-session-456"
const MOCK_MESSAGE_ID = "msg-789"
function createMockMessage(id: string, content: string = "Test message"): any {
return {
id,
sessionId: MOCK_SESSION_ID,
role: "user",
content,
status: "complete",
parts: [{ id: `part-${id}`, type: "text", text: content, sessionID: MOCK_SESSION_ID, messageID: id }],
createdAt: Date.now(),
updatedAt: Date.now(),
}
}
function createMockUsage(tokens: number = 10000): any {
return {
totalInputTokens: Math.floor(tokens * 0.7),
totalOutputTokens: Math.floor(tokens * 0.2),
totalReasoningTokens: Math.floor(tokens * 0.1),
}
}
describe("session compaction", () => {
beforeEach(() => {
updateCompactionConfig({
autoCompactEnabled: true,
autoCompactThreshold: 90,
compactPreserveWindow: 5000,
pruneReclaimThreshold: 10000,
userPreference: "auto",
undoRetentionWindow: 5,
})
})
describe("getCompactionConfig", () => {
it("returns default config", () => {
const config = getCompactionConfig()
assert.equal(typeof config.autoCompactEnabled, "boolean")
assert.equal(typeof config.autoCompactThreshold, "number")
assert.equal(typeof config.compactPreserveWindow, "number")
assert.equal(typeof config.pruneReclaimThreshold, "number")
assert.equal(typeof config.userPreference, "string")
assert.equal(typeof config.undoRetentionWindow, "number")
})
it("allows config updates", () => {
updateCompactionConfig({
autoCompactEnabled: false,
autoCompactThreshold: 80,
compactPreserveWindow: 4000,
pruneReclaimThreshold: 8000,
userPreference: "ask",
undoRetentionWindow: 10,
})
const config = getCompactionConfig()
assert.equal(config.autoCompactEnabled, false)
assert.equal(config.autoCompactThreshold, 80)
assert.equal(config.userPreference, "ask")
assert.equal(config.undoRetentionWindow, 10)
})
})
describe("setSessionCompactionState and getSessionCompactionState", () => {
it("tracks compaction state for sessions", () => {
setSessionCompactionState(MOCK_INSTANCE_ID, MOCK_SESSION_ID, true)
const isCompacting = getSessionCompactionState(MOCK_INSTANCE_ID, MOCK_SESSION_ID)
assert.ok(isCompacting)
})
it("returns undefined for unknown sessions", () => {
const isCompacting = getSessionCompactionState("unknown-instance", "unknown-session")
assert.equal(isCompacting, undefined)
})
it("clears compaction state", () => {
setSessionCompactionState(MOCK_INSTANCE_ID, MOCK_SESSION_ID, true)
setSessionCompactionState(MOCK_INSTANCE_ID, MOCK_SESSION_ID, false)
const isCompacting = getSessionCompactionState(MOCK_INSTANCE_ID, MOCK_SESSION_ID)
assert.ok(!isCompacting)
})
})
describe("estimateTokenReduction", () => {
it("calculates correct percentage reduction", () => {
const reduction = estimateTokenReduction(10000, 3000)
assert.equal(reduction, 70)
})
it("returns 0 when no reduction", () => {
const reduction = estimateTokenReduction(10000, 10000)
assert.equal(reduction, 0)
})
it("handles zero tokens", () => {
const reduction = estimateTokenReduction(0, 0)
assert.equal(reduction, 0)
})
it("caps at 100%", () => {
const reduction = estimateTokenReduction(10000, -5000)
assert.equal(reduction, 100)
})
it("handles small values", () => {
const reduction = estimateTokenReduction(100, 50)
assert.equal(reduction, 50)
})
})
describe("executeCompactionWrapper", () => {
it("compacts session successfully", async () => {
const mockStore = {
getSessionMessageIds: () => [MOCK_MESSAGE_ID],
getSessionUsage: () => createMockUsage(10000),
getMessage: (id: string) => createMockMessage(id, "Test content"),
upsertMessage: () => {},
setMessageInfo: () => {},
}
const getInstanceMock = mock.fn(() => mockStore)
const originalBus = (globalThis as any).messageStoreBus
;(globalThis as any).messageStoreBus = { getInstance: getInstanceMock }
const result = await executeCompactionWrapper(MOCK_INSTANCE_ID, MOCK_SESSION_ID, "compact")
assert.ok(result.success)
assert.equal(result.mode, "compact")
assert.ok(result.token_before > 0)
assert.ok(result.token_after >= 0)
assert.ok(result.token_reduction_pct >= 0)
assert.ok(result.human_summary.length > 0)
getInstanceMock.mock.restore()
if (originalBus) {
;(globalThis as any).messageStoreBus = originalBus
} else {
delete (globalThis as any).messageStoreBus
}
})
it("handles missing instance", async () => {
const getInstanceMock = mock.fn(() => null)
const originalBus = (globalThis as any).messageStoreBus
;(globalThis as any).messageStoreBus = { getInstance: getInstanceMock }
const result = await executeCompactionWrapper(MOCK_INSTANCE_ID, MOCK_SESSION_ID, "compact")
assert.ok(!result.success)
assert.equal(result.human_summary, "Instance not found")
getInstanceMock.mock.restore()
if (originalBus) {
;(globalThis as any).messageStoreBus = originalBus
} else {
delete (globalThis as any).messageStoreBus
}
})
it("handles prune mode", async () => {
const mockStore = {
getSessionMessageIds: () => [MOCK_MESSAGE_ID],
getSessionUsage: () => createMockUsage(10000),
getMessage: (id: string) => createMockMessage(id, "Test content"),
upsertMessage: () => {},
setMessageInfo: () => {},
}
const getInstanceMock = mock.fn(() => mockStore)
const originalBus = (globalThis as any).messageStoreBus
;(globalThis as any).messageStoreBus = { getInstance: getInstanceMock }
const result = await executeCompactionWrapper(MOCK_INSTANCE_ID, MOCK_SESSION_ID, "prune")
assert.ok(result.success)
assert.equal(result.mode, "prune")
getInstanceMock.mock.restore()
if (originalBus) {
;(globalThis as any).messageStoreBus = originalBus
} else {
delete (globalThis as any).messageStoreBus
}
})
})
describe("checkAndTriggerAutoCompact", () => {
it("does not trigger when user preference is never", async () => {
updateCompactionConfig({
autoCompactEnabled: true,
autoCompactThreshold: 90,
compactPreserveWindow: 5000,
pruneReclaimThreshold: 10000,
userPreference: "never",
undoRetentionWindow: 5,
})
const shouldCompact = await checkAndTriggerAutoCompact(MOCK_INSTANCE_ID, MOCK_SESSION_ID)
assert.ok(!shouldCompact)
})
it("returns false when no overflow", async () => {
const mockStore = {
getSessionUsage: () => createMockUsage(50000),
}
const getInstanceMock = mock.fn(() => mockStore)
const originalBus = (globalThis as any).messageStoreBus
;(globalThis as any).messageStoreBus = { getInstance: getInstanceMock }
const shouldCompact = await checkAndTriggerAutoCompact(MOCK_INSTANCE_ID, MOCK_SESSION_ID)
assert.ok(!shouldCompact)
getInstanceMock.mock.restore()
if (originalBus) {
;(globalThis as any).messageStoreBus = originalBus
} else {
delete (globalThis as any).messageStoreBus
}
})
it("triggers auto-compact when enabled", async () => {
updateCompactionConfig({
autoCompactEnabled: true,
autoCompactThreshold: 90,
compactPreserveWindow: 5000,
pruneReclaimThreshold: 10000,
userPreference: "auto",
undoRetentionWindow: 5,
})
const mockStore = {
getSessionUsage: () => createMockUsage(120000),
getSessionMessageIds: () => [MOCK_MESSAGE_ID],
getMessage: (id: string) => createMockMessage(id, "Test content"),
upsertMessage: () => {},
setMessageInfo: () => {},
}
const getInstanceMock = mock.fn(() => mockStore)
const originalBus = (globalThis as any).messageStoreBus
;(globalThis as any).messageStoreBus = { getInstance: getInstanceMock }
const shouldCompact = await checkAndTriggerAutoCompact(MOCK_INSTANCE_ID, MOCK_SESSION_ID)
assert.ok(shouldCompact)
getInstanceMock.mock.restore()
if (originalBus) {
;(globalThis as any).messageStoreBus = originalBus
} else {
delete (globalThis as any).messageStoreBus
}
})
})
})

View File

@@ -10,6 +10,7 @@ const DEFAULT_INSTANCE_DATA: InstanceData = {
agentModelSelections: {},
sessionTasks: {},
sessionSkills: {},
customAgents: [],
}
const [instanceDataMap, setInstanceDataMap] = createSignal<Map<string, InstanceData>>(new Map())
@@ -24,6 +25,7 @@ function cloneInstanceData(data?: InstanceData | null): InstanceData {
agentModelSelections: { ...(source.agentModelSelections ?? {}) },
sessionTasks: { ...(source.sessionTasks ?? {}) },
sessionSkills: { ...(source.sessionSkills ?? {}) },
customAgents: Array.isArray(source.customAgents) ? [...source.customAgents] : [],
}
}

View File

@@ -87,3 +87,20 @@ class MessageStoreBus {
}
export const messageStoreBus = new MessageStoreBus()
export async function archiveMessages(instanceId: string, sessionId: string, keepLastN: number = 2): Promise<void> {
const store = messageStoreBus.getInstance(instanceId)
if (!store) return
const messageIds = store.getSessionMessageIds(sessionId)
if (messageIds.length <= keepLastN) return
const messagesToArchive = messageIds.slice(0, -keepLastN)
const archiveId = `archived_${sessionId}_${Date.now()}`
for (const messageId of messagesToArchive) {
store.setMessageInfo(messageId, { archived: true } as any)
}
log.info("Archived messages", { instanceId, sessionId, count: messagesToArchive.length, archiveId })
}

View File

@@ -41,7 +41,7 @@ function ensureVisibilityEffect() {
if (!activeToast || activeToastVersion !== release.version) {
dismissActiveToast()
activeToast = showToastNotification({
title: `CodeNomad ${release.version}`,
title: `NomadArch ${release.version}`,
message: release.channel === "dev" ? "Dev release build available." : "New stable build on GitHub.",
variant: "info",
duration: Number.POSITIVE_INFINITY,

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,
}

View File

@@ -33,6 +33,7 @@ import { messageStoreBus } from "./message-v2/bus"
import { clearCacheForSession } from "../lib/global-cache"
import { getLogger } from "../lib/logger"
import { showToastNotification } from "../lib/notifications"
import { getUserScopedKey } from "../lib/user-storage"
const log = getLogger("api")
@@ -147,7 +148,7 @@ function getStoredQwenToken():
| null {
if (typeof window === "undefined") return null
try {
const raw = window.localStorage.getItem("qwen_oauth_token")
const raw = window.localStorage.getItem(getUserScopedKey("qwen_oauth_token"))
if (!raw) return null
return JSON.parse(raw)
} catch {
@@ -689,6 +690,7 @@ async function fetchAgents(instanceId: string): Promise<void> {
}
try {
await ensureInstanceConfigLoaded(instanceId)
log.info(`[HTTP] GET /app.agents for instance ${instanceId}`)
const response = await instance.client.app.agents()
const agentList = (response.data ?? []).map((agent) => ({
@@ -703,9 +705,16 @@ async function fetchAgents(instanceId: string): Promise<void> {
: undefined,
}))
const customAgents = getInstanceConfig(instanceId)?.customAgents ?? []
const customList = customAgents.map((agent) => ({
name: agent.name,
description: agent.description || "",
mode: "custom",
}))
setAgents((prev) => {
const next = new Map(prev)
next.set(instanceId, agentList)
next.set(instanceId, [...agentList, ...customList])
return next
})
} catch (error) {

File diff suppressed because it is too large Load Diff

View File

@@ -19,12 +19,13 @@ import { getLogger } from "../lib/logger"
import { showToastNotification, ToastVariant } from "../lib/notifications"
import { instances, addPermissionToQueue, removePermissionFromQueue, sendPermissionResponse } from "./instances"
import { getSoloState, incrementStep, popFromTaskQueue, setActiveTaskId } from "./solo-store"
import { sendMessage } from "./session-actions"
import { sendMessage, consumeTokenWarningSuppression, consumeCompactionSuppression, updateSessionModel } from "./session-actions"
import { showAlertDialog } from "./alerts"
import { sessions, setSessions, withSession } from "./session-state"
import { normalizeMessagePart } from "./message-v2/normalizers"
import { updateSessionInfo } from "./message-v2/session-info"
import { addTaskMessage, replaceTaskMessageId } from "./task-actions"
import { checkAndTriggerAutoCompact, getSessionCompactionState, setCompactionSuggestion } from "./session-compaction"
const log = getLogger("sse")
import { loadMessages } from "./session-api"
@@ -39,6 +40,7 @@ import {
} from "./message-v2/bridge"
import { messageStoreBus } from "./message-v2/bus"
import type { InstanceMessageStore } from "./message-v2/instance-store"
import { getDefaultModel } from "./session-models"
interface TuiToastEvent {
type: "tui.toast.show"
@@ -232,6 +234,16 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes
upsertMessageInfoV2(instanceId, info, { status, bumpRevision: true })
updateSessionInfo(instanceId, sessionId)
checkAndTriggerAutoCompact(instanceId, sessionId)
.then((shouldCompact) => {
if (!shouldCompact) return
if (getSessionCompactionState(instanceId, sessionId)) return
setCompactionSuggestion(instanceId, sessionId, "Context usage is high. Compact to continue.")
})
.catch((err) => {
log.error("Failed to check and trigger auto-compact", err)
})
}
}
@@ -389,6 +401,21 @@ function handleSessionCompacted(instanceId: string, event: EventSessionCompacted
})
}
function isContextLengthError(error: any): boolean {
if (!error) return false
const errorMessage = error.data?.message || error.message || ""
return (
errorMessage.includes("maximum context length") ||
errorMessage.includes("context_length_exceeded") ||
errorMessage.includes("token count exceeds") ||
errorMessage.includes("token limit")
)
}
function isUnsupportedModelMessage(message: string): boolean {
return /model\s+.+\s+not supported/i.test(message)
}
function handleSessionError(instanceId: string, event: EventSessionError): void {
const error = event.properties?.error
log.error(`[SSE] Session error:`, error)
@@ -406,18 +433,73 @@ function handleSessionError(instanceId: string, event: EventSessionError): void
// Autonomous error recovery for SOLO
const solo = getSoloState(instanceId)
const sessionId = (event.properties as any)?.sessionID
if (solo.isAutonomous && sessionId && solo.currentStep < solo.maxSteps) {
log.info(`[SOLO] Session error in autonomous mode, prompting fix: ${message}`)
incrementStep(instanceId)
sendMessage(instanceId, sessionId, `I encountered an error: "${message}". Please analyze the cause and provide a fix.`, [], solo.activeTaskId || undefined).catch((err) => {
log.error("[SOLO] Failed to send error recovery message", err)
})
} else {
showAlertDialog(`Error: ${message}`, {
title: "Session error",
variant: "error",
})
return
}
// Check if this is a context length error
if (isContextLengthError(error)) {
if (sessionId && consumeCompactionSuppression(instanceId, sessionId)) {
showAlertDialog("Compaction failed because the model context limit was exceeded. Reduce context or switch to a larger context model, then try compact again.", {
title: "Compaction failed",
variant: "error",
})
return
}
if (sessionId && consumeTokenWarningSuppression(instanceId, sessionId)) {
showToastNotification({
title: "Context limit exceeded",
message: "Compaction is required before continuing.",
variant: "warning",
duration: 7000,
})
return
}
log.info("Context length error detected; suggesting compaction", { instanceId, sessionId })
if (sessionId) {
setCompactionSuggestion(instanceId, sessionId, "Context limit exceeded. Compact to continue.")
showToastNotification({
title: "Compaction required",
message: "Click Compact to continue this session.",
variant: "warning",
duration: 8000,
})
} else {
showAlertDialog(`Error: ${message}`, {
title: "Session error",
variant: "error",
})
}
return
}
if (sessionId && isUnsupportedModelMessage(message)) {
showToastNotification({
title: "Model not supported",
message: "Selected model is not supported by this provider. Reverting to a default model.",
variant: "warning",
duration: 8000,
})
const sessionRecord = sessions().get(instanceId)?.get(sessionId)
getDefaultModel(instanceId, sessionRecord?.agent)
.then((fallback) => updateSessionModel(instanceId, sessionId, fallback))
.catch((err) => log.error("Failed to restore default model after unsupported model error", err))
return
}
// Default error handling
showAlertDialog(`Error: ${message}`, {
title: "Session error",
variant: "error",
})
}
function handleMessageRemoved(instanceId: string, event: MessageRemovedEvent): void {

View File

@@ -2,7 +2,7 @@ import type { Session, SessionStatus } from "../types/session"
import type { MessageInfo } from "../types/message"
import type { MessageRecord } from "./message-v2/types"
import { sessions } from "./sessions"
import { isSessionCompactionActive } from "./session-compaction"
import { getSessionCompactionState } from "./session-compaction"
import { messageStoreBus } from "./message-v2/bus"
function getSession(instanceId: string, sessionId: string): Session | null {
@@ -120,7 +120,7 @@ export function getSessionStatus(instanceId: string, sessionId: string): Session
const store = messageStoreBus.getOrCreate(instanceId)
if (isSessionCompactionActive(instanceId, sessionId) || isSessionCompacting(session)) {
if (getSessionCompactionState(instanceId, sessionId) || isSessionCompacting(session)) {
return "compacting"
}

View File

@@ -1,7 +1,8 @@
import { withSession } from "./session-state"
import { sessions, withSession } from "./session-state"
import { Task, TaskStatus } from "../types/session"
import { nanoid } from "nanoid"
import { forkSession } from "./session-api"
import { createSession } from "./session-api"
import { showToastNotification } from "../lib/notifications"
export function setActiveTask(instanceId: string, sessionId: string, taskId: string | undefined): void {
withSession(instanceId, sessionId, (session) => {
@@ -18,13 +19,32 @@ export async function addTask(
console.log("[task-actions] addTask started", { instanceId, sessionId, title, taskId: id });
let taskSessionId: string | undefined
const parentSession = sessions().get(instanceId)?.get(sessionId)
const parentAgent = parentSession?.agent || ""
const parentModel = parentSession?.model
try {
console.log("[task-actions] forking session...");
const forked = await forkSession(instanceId, sessionId)
taskSessionId = forked.id
console.log("[task-actions] fork successful", { taskSessionId });
console.log("[task-actions] creating new task session...");
const created = await createSession(instanceId, parentAgent || undefined, { skipAutoCleanup: true })
taskSessionId = created.id
withSession(instanceId, taskSessionId, (taskSession) => {
taskSession.parentId = sessionId
if (parentAgent) {
taskSession.agent = parentAgent
}
if (parentModel?.providerId && parentModel?.modelId) {
taskSession.model = { ...parentModel }
}
})
console.log("[task-actions] task session created", { taskSessionId });
} catch (error) {
console.error("[task-actions] Failed to fork session for task", error)
console.error("[task-actions] Failed to create session for task", error)
showToastNotification({
title: "Task session unavailable",
message: "Continuing in the current session.",
variant: "warning",
duration: 5000,
})
taskSessionId = undefined
}
const newTask: Task = {
@@ -34,6 +54,7 @@ export async function addTask(
timestamp: Date.now(),
messageIds: [],
taskSessionId,
archived: false,
}
withSession(instanceId, sessionId, (session) => {
@@ -161,3 +182,15 @@ export function removeTask(instanceId: string, sessionId: string, taskId: string
}
})
}
export function archiveTask(instanceId: string, sessionId: string, taskId: string): void {
withSession(instanceId, sessionId, (session) => {
if (!session.tasks) return
session.tasks = session.tasks.map((task) =>
task.id === taskId ? { ...task, archived: true } : task,
)
if (session.activeTaskId === taskId) {
session.activeTaskId = undefined
}
})
}

View File

@@ -4,6 +4,7 @@ const [hasInstances, setHasInstances] = createSignal(false)
const [selectedFolder, setSelectedFolder] = createSignal<string | null>(null)
const [isSelectingFolder, setIsSelectingFolder] = createSignal(false)
const [showFolderSelection, setShowFolderSelection] = createSignal(false)
const [showFolderSelectionOnStart, setShowFolderSelectionOnStart] = createSignal(true)
const [instanceTabOrder, setInstanceTabOrder] = createSignal<string[]>([])
const [sessionTabOrder, setSessionTabOrder] = createSignal<Map<string, string[]>>(new Map())
@@ -29,6 +30,8 @@ export {
setIsSelectingFolder,
showFolderSelection,
setShowFolderSelection,
showFolderSelectionOnStart,
setShowFolderSelectionOnStart,
instanceTabOrder,
setInstanceTabOrder,
sessionTabOrder,

View File

@@ -0,0 +1,89 @@
import { createSignal } from "solid-js"
import { getLogger } from "../lib/logger"
export interface UserAccount {
id: string
name: string
isGuest?: boolean
}
const log = getLogger("users")
const [users, setUsers] = createSignal<UserAccount[]>([])
const [activeUser, setActiveUserSignal] = createSignal<UserAccount | null>(null)
const [loadingUsers, setLoadingUsers] = createSignal(false)
function getElectronApi() {
return typeof window !== "undefined" ? window.electronAPI : undefined
}
async function refreshUsers(): Promise<void> {
const api = getElectronApi()
if (!api?.listUsers) return
setLoadingUsers(true)
try {
const list = await api.listUsers()
setUsers(list ?? [])
const active = api.getActiveUser ? await api.getActiveUser() : null
setActiveUserSignal(active ?? null)
} catch (error) {
log.warn("Failed to load users", error)
} finally {
setLoadingUsers(false)
}
}
async function createUser(name: string, password: string): Promise<UserAccount | null> {
const api = getElectronApi()
if (!api?.createUser) return null
const user = await api.createUser({ name, password })
await refreshUsers()
return user ?? null
}
async function updateUser(id: string, updates: { name?: string; password?: string }): Promise<UserAccount | null> {
const api = getElectronApi()
if (!api?.updateUser) return null
const user = await api.updateUser({ id, ...updates })
await refreshUsers()
return user ?? null
}
async function deleteUser(id: string): Promise<void> {
const api = getElectronApi()
if (!api?.deleteUser) return
await api.deleteUser({ id })
await refreshUsers()
}
async function loginUser(id: string, password?: string): Promise<boolean> {
const api = getElectronApi()
if (!api?.loginUser) return false
const result = await api.loginUser({ id, password })
if (result?.success) {
setActiveUserSignal(result.user ?? null)
await refreshUsers()
return true
}
return false
}
async function createGuest(): Promise<UserAccount | null> {
const api = getElectronApi()
if (!api?.createGuest) return null
const user = await api.createGuest()
await refreshUsers()
return user ?? null
}
export {
users,
activeUser,
loadingUsers,
refreshUsers,
createUser,
updateUser,
deleteUser,
loginUser,
createGuest,
}