From ad76ade6ab4e9589f121bcd2a2a5f1c755c74fb4 Mon Sep 17 00:00:00 2001 From: Gemini AI Date: Sat, 27 Dec 2025 10:40:45 +0400 Subject: [PATCH] Fix Native Mode Sessions: implemented fork, revert, and sync for native sessions --- .../src/server/routes/native-sessions.ts | 36 +++ packages/server/src/storage/session-store.ts | 48 ++++ packages/ui/src/lib/lite-mode.ts | 20 ++ packages/ui/src/stores/session-actions.ts | 125 +++++++-- packages/ui/src/stores/session-api.ts | 259 +++++++++++++----- 5 files changed, 405 insertions(+), 83 deletions(-) diff --git a/packages/server/src/server/routes/native-sessions.ts b/packages/server/src/server/routes/native-sessions.ts index 7f1ac83..673dc7e 100644 --- a/packages/server/src/server/routes/native-sessions.ts +++ b/packages/server/src/server/routes/native-sessions.ts @@ -105,6 +105,42 @@ export function registerNativeSessionsRoutes(app: FastifyInstance, deps: NativeS } }) + // Fork a session + app.post<{ + Params: { workspaceId: string; sessionId: string } + }>("/api/native/workspaces/:workspaceId/sessions/:sessionId/fork", async (request, reply) => { + try { + const session = await sessionManager.forkSession( + request.params.workspaceId, + request.params.sessionId + ) + return { session } + } catch (error) { + logger.error({ error }, "Failed to fork session") + reply.code(500) + return { error: "Failed to fork session" } + } + }) + + // Revert a session + app.post<{ + Params: { workspaceId: string; sessionId: string } + Body: { messageId?: string } + }>("/api/native/workspaces/:workspaceId/sessions/:sessionId/revert", async (request, reply) => { + try { + const session = await sessionManager.revert( + request.params.workspaceId, + request.params.sessionId, + request.body.messageId + ) + return { session } + } catch (error) { + logger.error({ error }, "Failed to revert session") + reply.code(500) + return { error: "Failed to revert session" } + } + }) + // Delete a session app.delete<{ Params: { workspaceId: string; sessionId: string } }>("/api/native/workspaces/:workspaceId/sessions/:sessionId", async (request, reply) => { try { diff --git a/packages/server/src/storage/session-store.ts b/packages/server/src/storage/session-store.ts index 3666bbd..a85b0a9 100644 --- a/packages/server/src/storage/session-store.ts +++ b/packages/server/src/storage/session-store.ts @@ -200,6 +200,54 @@ export class NativeSessionManager { return true } + async forkSession(workspaceId: string, sessionId: string): Promise { + const store = await this.loadStore(workspaceId) + const original = store.sessions[sessionId] + if (!original) throw new Error(`Session not found: ${sessionId}`) + + const now = Date.now() + const forked: Session = { + ...original, + id: ulid(), + title: original.title ? `${original.title} (fork)` : "Forked Session", + parentId: original.parentId || original.id, + createdAt: now, + updatedAt: now, + messageIds: [...original.messageIds], // Shallow copy of message IDs + } + + store.sessions[forked.id] = forked + await this.saveStore(workspaceId) + return forked + } + + async revert(workspaceId: string, sessionId: string, messageId?: string): Promise { + const store = await this.loadStore(workspaceId) + const session = store.sessions[sessionId] + if (!session) throw new Error(`Session not found: ${sessionId}`) + + if (!messageId) { + // Revert last message + if (session.messageIds.length > 0) { + const lastId = session.messageIds.pop() + if (lastId) delete store.messages[lastId] + } + } else { + // Revert to specific message + const index = session.messageIds.indexOf(messageId) + if (index !== -1) { + const toDelete = session.messageIds.splice(index + 1) + for (const id of toDelete) { + delete store.messages[id] + } + } + } + + session.updatedAt = Date.now() + await this.saveStore(workspaceId) + return session + } + // Message operations async getSessionMessages(workspaceId: string, sessionId: string): Promise { diff --git a/packages/ui/src/lib/lite-mode.ts b/packages/ui/src/lib/lite-mode.ts index 0469265..09ce5ce 100644 --- a/packages/ui/src/lib/lite-mode.ts +++ b/packages/ui/src/lib/lite-mode.ts @@ -138,6 +138,26 @@ export const nativeSessionApi = { return response.ok || response.status === 204 }, + async forkSession(workspaceId: string, sessionId: string): Promise { + const response = await fetch(`${CODENOMAD_API_BASE}/api/native/workspaces/${encodeURIComponent(workspaceId)}/sessions/${encodeURIComponent(sessionId)}/fork`, { + method: "POST" + }) + if (!response.ok) throw new Error("Failed to fork session") + const data = await response.json() + return data.session + }, + + async revertSession(workspaceId: string, sessionId: string, messageId?: string): Promise { + const response = await fetch(`${CODENOMAD_API_BASE}/api/native/workspaces/${encodeURIComponent(workspaceId)}/sessions/${encodeURIComponent(sessionId)}/revert`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ messageId }) + }) + if (!response.ok) throw new Error("Failed to revert session") + const data = await response.json() + return data.session + }, + async getMessages(workspaceId: string, sessionId: string): Promise { const response = await fetch(`${CODENOMAD_API_BASE}/api/native/workspaces/${encodeURIComponent(workspaceId)}/sessions/${encodeURIComponent(sessionId)}/messages`) if (!response.ok) throw new Error("Failed to get messages") diff --git a/packages/ui/src/stores/session-actions.ts b/packages/ui/src/stores/session-actions.ts index a9627a9..ad8f1a2 100644 --- a/packages/ui/src/stores/session-actions.ts +++ b/packages/ui/src/stores/session-actions.ts @@ -1940,6 +1940,11 @@ async function updateSessionAgent(instanceId: string, sessionId: string, agent: await setAgentModelPreference(instanceId, agent, nextModel) } + const isNative = instance.binaryPath === "__nomadarch_native__" + if (isNative) { + await nativeSessionApi.updateSession(instanceId, sessionId, { agent }) + } + if (shouldApplyModel) { updateSessionInfo(instanceId, sessionId) } @@ -1965,6 +1970,16 @@ async function updateSessionModel( current.model = model }) + const instance = instances().get(instanceId) + if (instance?.binaryPath === "__nomadarch_native__") { + await nativeSessionApi.updateSession(instanceId, sessionId, { + model: { + providerId: model.providerId, + modelId: model.modelId + } + }) + } + const propagateModel = (targetSessionId?: string | null) => { if (!targetSessionId || targetSessionId === sessionId) return withSession(instanceId, targetSessionId, (current) => { @@ -2014,16 +2029,31 @@ async function updateSessionModelForSession( current.model = model }) + const instance = instances().get(instanceId) + if (instance?.binaryPath === "__nomadarch_native__") { + await nativeSessionApi.updateSession(instanceId, sessionId, { + model: { + providerId: model.providerId, + modelId: model.modelId + } + }) + } + addRecentModelPreference(model) updateSessionInfo(instanceId, sessionId) } async function renameSession(instanceId: string, sessionId: string, nextTitle: string): Promise { const instance = instances().get(instanceId) - if (!instance || !instance.client) { + if (!instance) { throw new Error("Instance not ready") } + const isNative = instance.binaryPath === "__nomadarch_native__" + if (!isNative && !instance.client) { + throw new Error("Instance client not ready") + } + const session = sessions().get(instanceId)?.get(sessionId) if (!session) { throw new Error("Session not found") @@ -2034,10 +2064,14 @@ async function renameSession(instanceId: string, sessionId: string, nextTitle: s throw new Error("Session title is required") } - await instance.client.session.update({ - path: { id: sessionId }, - body: { title: trimmedTitle }, - }) + if (isNative) { + await nativeSessionApi.updateSession(instanceId, sessionId, { title: trimmedTitle }) + } else { + await instance.client!.session.update({ + path: { id: sessionId }, + body: { title: trimmedTitle }, + }) + } withSession(instanceId, sessionId, (current) => { current.title = trimmedTitle @@ -2049,19 +2083,28 @@ async function renameSession(instanceId: string, sessionId: string, nextTitle: s async function revertSession(instanceId: string, sessionId: string): Promise { const instance = instances().get(instanceId) - if (!instance || !instance.client) { + if (!instance) { throw new Error("Instance not ready") } + const isNative = instance.binaryPath === "__nomadarch_native__" + if (!isNative && !instance.client) { + throw new Error("Instance client not ready") + } + const session = sessions().get(instanceId)?.get(sessionId) if (!session) { throw new Error("Session not found") } try { - await instance.client.session.revert({ - path: { id: sessionId }, - }) + if (isNative) { + await nativeSessionApi.revertSession(instanceId, sessionId) + } else { + await instance.client!.session.revert({ + path: { id: sessionId }, + }) + } } catch (error) { log.error("Failed to revert session", error) throw error @@ -2070,30 +2113,76 @@ async function revertSession(instanceId: string, sessionId: string): Promise { const instance = instances().get(instanceId) - if (!instance || !instance.client) { + if (!instance) { throw new Error("Instance not ready") } + const isNative = instance.binaryPath === "__nomadarch_native__" + if (!isNative && !instance.client) { + throw new Error("Instance client not ready") + } + const session = sessions().get(instanceId)?.get(sessionId) if (!session) { throw new Error("Session not found") } try { - const response = await instance.client.session.fork({ - path: { id: sessionId }, - }) + let forkedId: string = "" + let forkedVersion: string = "0" + let forkedTime: any = { created: Date.now(), updated: Date.now() } + let forkedRevert: any = undefined - if (response.error) { - throw new Error(JSON.stringify(response.error) || "Failed to fork session") + if (isNative) { + const response = await nativeSessionApi.forkSession(instanceId, sessionId) + forkedId = response.id + forkedTime = { created: response.createdAt, updated: response.updatedAt } + } else { + const response = await instance.client!.session.fork({ + path: { id: sessionId }, + }) + if (!response.data) { + throw new Error("Failed to fork session: No data returned") + } + forkedId = response.data.id + forkedVersion = response.data.version + forkedTime = response.data.time + forkedRevert = response.data.revert + ? { + messageID: response.data.revert.messageID, + partID: response.data.revert.partID, + snapshot: response.data.revert.snapshot, + diff: response.data.revert.diff, + } + : undefined } - const newSessionId = response.data?.id - if (!newSessionId) { + if (!forkedId) { throw new Error("No session ID returned from fork operation") } - return newSessionId + const forkedSession: Session = { + id: forkedId, + instanceId, + title: session.title ? `${session.title} (fork)` : "Forked Session", + parentId: session.parentId || session.id, + agent: session.agent, + model: session.model, + skills: [...session.skills], + version: forkedVersion, + time: forkedTime, + revert: forkedRevert + } + + setSessions((prev) => { + const next = new Map(prev) + const instanceSessions = new Map(next.get(instanceId) || []) + instanceSessions.set(forkedSession.id, forkedSession) + next.set(instanceId, instanceSessions) + return next + }) + + return forkedId } catch (error) { log.error("Failed to fork session", error) throw error diff --git a/packages/ui/src/stores/session-api.ts b/packages/ui/src/stores/session-api.ts index c961cd5..b9b7c19 100644 --- a/packages/ui/src/stores/session-api.ts +++ b/packages/ui/src/stores/session-api.ts @@ -2,6 +2,7 @@ import type { Session, Provider, Model } from "../types/session" import type { Message } from "../types/message" import { instances } from "./instances" +import { nativeSessionApi } from "../lib/lite-mode" import { preferences, setAgentModelPreference, getAgentModelPreference } from "./preferences" import { setSessionCompactionState } from "./session-compaction" import { @@ -366,10 +367,16 @@ interface SessionForkResponse { async function fetchSessions(instanceId: string): Promise { const instance = instances().get(instanceId) - if (!instance || !instance.client) { + if (!instance) { throw new Error("Instance not ready") } + const isNative = instance.binaryPath === "__nomadarch_native__" + + if (!isNative && !instance.client) { + throw new Error("Instance client not ready") + } + setLoading((prev) => { const next = { ...prev } next.fetchingSessions.set(instanceId, true) @@ -377,13 +384,38 @@ async function fetchSessions(instanceId: string): Promise { }) try { - log.info("session.list", { instanceId }) - const response = await instance.client.session.list() + log.info("session.list", { instanceId, isNative }) + + let responseData: any[] = [] + + if (isNative) { + const nativeSessions = await nativeSessionApi.listSessions(instanceId) + responseData = nativeSessions.map(s => ({ + id: s.id, + title: s.title, + parentID: s.parentId, + version: "0", + time: { + created: s.createdAt, + updated: s.updatedAt + }, + model: s.model ? { + providerID: s.model.providerId, + modelID: s.model.modelId + } : undefined, + agent: s.agent + })) + } else { + const response = await instance.client!.session.list() + if (response.data && Array.isArray(response.data)) { + responseData = response.data + } + } const sessionMap = new Map() - if (!response.data || !Array.isArray(response.data)) { - return + if (responseData.length === 0 && !isNative) { + // In SDK mode we still check response.data for empty } const existingSessions = sessions().get(instanceId) @@ -394,13 +426,13 @@ async function fetchSessions(instanceId: string): Promise { const sessionTasks = instanceData.sessionTasks || {} const sessionSkills = instanceData.sessionSkills || {} - for (const apiSession of response.data) { + for (const apiSession of responseData) { const existingSession = existingSessions?.get(apiSession.id) const existingModel = existingSession?.model ?? { providerId: "", modelId: "" } const hasUserSelectedModel = existingModel.providerId && existingModel.modelId - const apiModel = (apiSession as any).model?.providerID && (apiSession as any).model?.modelID - ? { providerId: (apiSession as any).model.providerID, modelId: (apiSession as any).model.modelID } + const apiModel = apiSession.model?.providerID && apiSession.model?.modelID + ? { providerId: apiSession.model.providerID, modelId: apiSession.model.modelID } : { providerId: "", modelId: "" } sessionMap.set(apiSession.id, { @@ -408,7 +440,7 @@ async function fetchSessions(instanceId: string): Promise { instanceId, title: apiSession.title || "Untitled", parentId: apiSession.parentID || null, - agent: existingSession?.agent ?? (apiSession as any).agent ?? "", + agent: existingSession?.agent ?? apiSession.agent ?? "", model: hasUserSelectedModel ? existingModel : apiModel, version: apiSession.version, time: { @@ -475,10 +507,15 @@ async function createSession( options?: { skipAutoCleanup?: boolean }, ): Promise { const instance = instances().get(instanceId) - if (!instance || !instance.client) { + if (!instance) { throw new Error("Instance not ready") } + const isNative = instance.binaryPath === "__nomadarch_native__" + if (!isNative && !instance.client) { + throw new Error("Instance client not ready") + } + const instanceAgents = agents().get(instanceId) || [] const nonSubagents = instanceAgents.filter((a) => a.mode !== "subagent") const selectedAgent = agent || (nonSubagents.length > 0 ? nonSubagents[0].name : "") @@ -498,31 +535,57 @@ async function createSession( }) try { - log.info(`[HTTP] POST /session.create for instance ${instanceId}`) - const response = await instance.client.session.create() + log.info(`[HTTP] POST session create for instance ${instanceId}, isNative: ${isNative}`) - if (!response.data) { + let sessionData: any = null + + if (isNative) { + const native = await nativeSessionApi.createSession(instanceId, { + agent: selectedAgent, + model: sessionModel + }) + sessionData = { + id: native.id, + title: native.title || "New Session", + parentID: native.parentId, + version: "0", + time: { + created: native.createdAt, + updated: native.updatedAt + }, + agent: native.agent, + model: native.model ? { + providerID: native.model.providerId, + modelID: native.model.modelId + } : undefined + } + } else { + const response = await instance.client!.session.create() + sessionData = response.data + } + + if (!sessionData) { throw new Error("Failed to create session: No data returned") } const session: Session = { - id: response.data.id, + id: sessionData.id, instanceId, - title: response.data.title || "New Session", + title: sessionData.title || "New Session", parentId: null, agent: selectedAgent, model: sessionModel, skills: [], - version: response.data.version, + version: sessionData.version, time: { - ...response.data.time, + ...sessionData.time, }, - revert: response.data.revert + revert: sessionData.revert ? { - messageID: response.data.revert.messageID, - partID: response.data.revert.partID, - snapshot: response.data.revert.snapshot, - diff: response.data.revert.diff, + messageID: sessionData.revert.messageID, + partID: sessionData.revert.partID, + snapshot: sessionData.revert.snapshot, + diff: sessionData.revert.diff, } : undefined, } @@ -683,9 +746,10 @@ async function forkSession( async function deleteSession(instanceId: string, sessionId: string): Promise { const instance = instances().get(instanceId) - if (!instance || !instance.client) { - throw new Error("Instance not ready") - } + if (!instance) return + + const isNative = instance.binaryPath === "__nomadarch_native__" + if (!isNative && !instance.client) return setLoading((prev) => { const next = { ...prev } @@ -696,8 +760,13 @@ async function deleteSession(instanceId: string, sessionId: string): Promise { const next = new Map(prev) @@ -754,25 +823,42 @@ async function deleteSession(instanceId: string, sessionId: string): Promise { const instance = instances().get(instanceId) - if (!instance || !instance.client) { + if (!instance) { throw new Error("Instance not ready") } + const isNative = instance.binaryPath === "__nomadarch_native__" + if (!isNative && !instance.client) { + throw new Error("Instance client not ready") + } + 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) => ({ - name: agent.name, - description: agent.description || "", - mode: agent.mode, - model: agent.model?.modelID - ? { - providerId: agent.model.providerID || "", - modelId: agent.model.modelID, - } - : undefined, - })) + log.info("agents.list", { instanceId, isNative }) + + let agentList: any[] = [] + + if (isNative) { + // In native mode, we don't have agents from the SDK yet + // We can return a default agent or common agents + agentList = [{ + name: "Assistant", + description: "Native assistant agent", + mode: "native" + }] + } else { + const response = await instance.client!.agents.list() + agentList = (response.data || []).map((agent) => ({ + name: agent.name, + description: agent.description || "", + mode: agent.mode as "standard" | "subagent", + model: agent.model + ? { + providerId: agent.model.providerID || "", + modelId: agent.model.modelID, + } + : undefined, + })) + } const customAgents = getInstanceConfig(instanceId)?.customAgents ?? [] const customList = customAgents.map((agent) => ({ @@ -793,27 +879,43 @@ async function fetchAgents(instanceId: string): Promise { async function fetchProviders(instanceId: string): Promise { const instance = instances().get(instanceId) - if (!instance || !instance.client) { + if (!instance) { throw new Error("Instance not ready") } - try { - log.info(`[HTTP] GET /config.providers for instance ${instanceId}`) - const response = await instance.client.config.providers() - if (!response.data) return + const isNative = instance.binaryPath === "__nomadarch_native__" + if (!isNative && !instance.client) { + throw new Error("Instance client not ready") + } - const providerList = response.data.providers.map((provider) => ({ - id: provider.id, - name: provider.name, - defaultModelId: response.data?.default?.[provider.id], - models: Object.entries(provider.models).map(([id, model]) => ({ - id, - name: model.name, - providerId: provider.id, - limit: model.limit, - cost: model.cost, - })), - })) + try { + log.info("config.providers", { instanceId, isNative }) + + let providerList: any[] = [] + let defaultProviders: any = {} + + if (isNative) { + // For native mode, we mainly rely on extra providers + // but we could add "zen" (OpenCode Zen) if it's available via server API + providerList = [] + } else { + const response = await instance.client!.config.providers() + if (response.data) { + providerList = response.data.providers.map((provider) => ({ + id: provider.id, + name: provider.name, + defaultModelId: response.data?.default?.[provider.id], + models: Object.entries(provider.models).map(([id, model]) => ({ + id, + name: model.name, + providerId: provider.id, + limit: model.limit, + cost: model.cost, + })), + })) + defaultProviders = response.data.default || {} + } + } // Filter out Z.AI providers from SDK to use our custom routing with full message history const filteredBaseProviders = providerList.filter((provider) => @@ -859,10 +961,15 @@ async function loadMessages(instanceId: string, sessionId: string, force = false } const instance = instances().get(instanceId) - if (!instance || !instance.client) { + if (!instance) { throw new Error("Instance not ready") } + const isNative = instance.binaryPath === "__nomadarch_native__" + if (!isNative && !instance.client) { + throw new Error("Instance client not ready") + } + const instanceSessions = sessions().get(instanceId) const session = instanceSessions?.get(sessionId) if (!session) { @@ -878,15 +985,37 @@ async function loadMessages(instanceId: string, sessionId: string, force = false }) try { - log.info(`[HTTP] GET /session.${"messages"} for instance ${instanceId}`, { sessionId }) - const response = await instance.client.session["messages"]({ path: { id: sessionId } }) + log.info("session.getMessages", { instanceId, sessionId, isNative }) - if (!response.data || !Array.isArray(response.data)) { - return + let apiMessages: any[] = [] + let apiMessagesInfo: any = {} + + if (isNative) { + const nativeMessages = await nativeSessionApi.getMessages(instanceId, sessionId) + apiMessages = nativeMessages.map(m => ({ + id: m.id, + role: m.role, + content: m.content || "", + createdAt: m.createdAt, + status: m.status, + info: { + id: m.id, + role: m.role, + time: { created: m.createdAt }, + // Add other native message properties to info if needed for later processing + } + })) + } else { + const response = await instance.client!.session.getMessages({ path: { id: sessionId } }) + if (!response.data || !Array.isArray(response.data)) { + return + } + apiMessages = response.data || [] + apiMessagesInfo = (response as any).info || {} // Assuming 'info' might be on the response object itself for some cases } const messagesInfo = new Map() - const messages: Message[] = response.data.map((apiMessage: any) => { + const messages: Message[] = apiMessages.map((apiMessage: any) => { const info = apiMessage.info || apiMessage const role = info.role || "assistant" const messageId = info.id || String(Date.now())