From cadaf2c835f908b4227df98ddbf5e6775b0f99ba Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 22 Jan 2026 10:21:50 +0000 Subject: [PATCH] feat: add sessions preview rpc and menu prewarm --- .../Sources/Clawdbot/GatewayConnection.swift | 25 ++ .../Clawdbot/MenuSessionsInjector.swift | 7 + .../Clawdbot/SessionMenuPreviewView.swift | 213 ++++++++++++++---- .../SessionMenuPreviewTests.swift | 20 +- .../Sources/ClawdbotChatUI/ChatModels.swift | 16 ++ src/gateway/protocol/index.ts | 7 + .../protocol/schema/protocol-schemas.ts | 2 + src/gateway/protocol/schema/sessions.ts | 9 + src/gateway/protocol/schema/types.ts | 2 + src/gateway/server-methods-list.ts | 1 + src/gateway/server-methods.ts | 1 + src/gateway/server-methods/sessions.ts | 73 ++++++ ...sessions.gateway-server-sessions-a.test.ts | 48 ++++ src/gateway/session-utils.fs.test.ts | 63 ++++++ src/gateway/session-utils.fs.ts | 201 +++++++++++++++++ src/gateway/session-utils.ts | 3 + src/gateway/session-utils.types.ts | 16 ++ 17 files changed, 650 insertions(+), 57 deletions(-) diff --git a/apps/macos/Sources/Clawdbot/GatewayConnection.swift b/apps/macos/Sources/Clawdbot/GatewayConnection.swift index 4a0234748..9feb98ba9 100644 --- a/apps/macos/Sources/Clawdbot/GatewayConnection.swift +++ b/apps/macos/Sources/Clawdbot/GatewayConnection.swift @@ -69,6 +69,7 @@ actor GatewayConnection { case channelsLogout = "channels.logout" case modelsList = "models.list" case chatHistory = "chat.history" + case sessionsPreview = "sessions.preview" case chatSend = "chat.send" case chatAbort = "chat.abort" case skillsStatus = "skills.status" @@ -540,6 +541,30 @@ extension GatewayConnection { return try await self.requestDecoded(method: .skillsUpdate, params: params) } + // MARK: - Sessions + + func sessionsPreview( + keys: [String], + limit: Int? = nil, + maxChars: Int? = nil, + timeoutMs: Int? = nil) async throws -> ClawdbotSessionsPreviewPayload + { + let resolvedKeys = keys + .map { self.canonicalizeSessionKey($0) } + .filter { !$0.isEmpty } + if resolvedKeys.isEmpty { + return ClawdbotSessionsPreviewPayload(ts: 0, previews: []) + } + var params: [String: AnyCodable] = ["keys": AnyCodable(resolvedKeys)] + if let limit { params["limit"] = AnyCodable(limit) } + if let maxChars { params["maxChars"] = AnyCodable(maxChars) } + let timeout = timeoutMs.map { Double($0) } + return try await self.requestDecoded( + method: .sessionsPreview, + params: params, + timeoutMs: timeout) + } + // MARK: - Chat func chatHistory( diff --git a/apps/macos/Sources/Clawdbot/MenuSessionsInjector.swift b/apps/macos/Sources/Clawdbot/MenuSessionsInjector.swift index b39138277..4b8854cda 100644 --- a/apps/macos/Sources/Clawdbot/MenuSessionsInjector.swift +++ b/apps/macos/Sources/Clawdbot/MenuSessionsInjector.swift @@ -188,6 +188,13 @@ extension MenuSessionsInjector { if rhs.key == mainKey { return false } return (lhs.updatedAt ?? .distantPast) > (rhs.updatedAt ?? .distantPast) } + if !rows.isEmpty { + let previewKeys = rows.prefix(20).map(\.key) + let task = Task { + await SessionMenuPreviewLoader.prewarm(sessionKeys: previewKeys, maxItems: 10) + } + self.previewTasks.append(task) + } let headerItem = NSMenuItem() headerItem.tag = self.tag diff --git a/apps/macos/Sources/Clawdbot/SessionMenuPreviewView.swift b/apps/macos/Sources/Clawdbot/SessionMenuPreviewView.swift index 88c75160e..e7d81659e 100644 --- a/apps/macos/Sources/Clawdbot/SessionMenuPreviewView.swift +++ b/apps/macos/Sources/Clawdbot/SessionMenuPreviewView.swift @@ -1,5 +1,6 @@ import ClawdbotChatUI import ClawdbotKit +import ClawdbotProtocol import OSLog import SwiftUI @@ -31,24 +32,24 @@ actor SessionPreviewCache { static let shared = SessionPreviewCache() private struct CacheEntry { - let items: [SessionPreviewItem] + let snapshot: SessionMenuPreviewSnapshot let updatedAt: Date } private var entries: [String: CacheEntry] = [:] - func cachedItems(for sessionKey: String, maxAge: TimeInterval) -> [SessionPreviewItem]? { + func cachedSnapshot(for sessionKey: String, maxAge: TimeInterval) -> SessionMenuPreviewSnapshot? { guard let entry = self.entries[sessionKey] else { return nil } guard Date().timeIntervalSince(entry.updatedAt) < maxAge else { return nil } - return entry.items + return entry.snapshot } - func store(items: [SessionPreviewItem], for sessionKey: String) { - self.entries[sessionKey] = CacheEntry(items: items, updatedAt: Date()) + func store(snapshot: SessionMenuPreviewSnapshot, for sessionKey: String) { + self.entries[sessionKey] = CacheEntry(snapshot: snapshot, updatedAt: Date()) } - func lastItems(for sessionKey: String) -> [SessionPreviewItem]? { - self.entries[sessionKey]?.items + func lastSnapshot(for sessionKey: String) -> SessionMenuPreviewSnapshot? { + self.entries[sessionKey]?.snapshot } } @@ -99,8 +100,12 @@ actor SessionPreviewLimiter { #if DEBUG extension SessionPreviewCache { - func _testSet(items: [SessionPreviewItem], for sessionKey: String, updatedAt: Date = Date()) { - self.entries[sessionKey] = CacheEntry(items: items, updatedAt: updatedAt) + func _testSet( + snapshot: SessionMenuPreviewSnapshot, + for sessionKey: String, + updatedAt: Date = Date()) + { + self.entries[sessionKey] = CacheEntry(snapshot: snapshot, updatedAt: updatedAt) } func _testReset() { @@ -219,50 +224,44 @@ enum SessionMenuPreviewLoader { private static let logger = Logger(subsystem: "com.clawdbot", category: "SessionPreview") private static let previewTimeoutSeconds: Double = 4 private static let cacheMaxAgeSeconds: TimeInterval = 30 + private static let previewMaxChars = 240 private struct PreviewTimeoutError: LocalizedError { var errorDescription: String? { "preview timeout" } } + static func prewarm(sessionKeys: [String], maxItems: Int) async { + let keys = self.uniqueKeys(sessionKeys) + guard !keys.isEmpty else { return } + do { + let payload = try await self.requestPreview(keys: keys, maxItems: maxItems) + await self.cache(payload: payload, maxItems: maxItems) + } catch { + if self.isUnknownMethodError(error) { return } + let errorDescription = String(describing: error) + Self.logger.debug( + "Session preview prewarm failed count=\(keys.count, privacy: .public) " + + "error=\(errorDescription, privacy: .public)") + } + } + static func load(sessionKey: String, maxItems: Int) async -> SessionMenuPreviewSnapshot { - if let cached = await SessionPreviewCache.shared.cachedItems(for: sessionKey, maxAge: cacheMaxAgeSeconds) { - return self.snapshot(from: cached) - } - - let isConnected = await MainActor.run { - if case .connected = ControlChannel.shared.state { return true } - return false - } - - guard isConnected else { - if let fallback = await SessionPreviewCache.shared.lastItems(for: sessionKey) { - return Self.snapshot(from: fallback) - } - return SessionMenuPreviewSnapshot(items: [], status: .error("Gateway disconnected")) + if let cached = await SessionPreviewCache.shared.cachedSnapshot( + for: sessionKey, + maxAge: cacheMaxAgeSeconds) + { + return cached } do { - let timeoutMs = Int(self.previewTimeoutSeconds * 1000) - let payload = try await SessionPreviewLimiter.shared.withPermit { - try await AsyncTimeout.withTimeout( - seconds: self.previewTimeoutSeconds, - onTimeout: { PreviewTimeoutError() }, - operation: { - try await GatewayConnection.shared.chatHistory( - sessionKey: sessionKey, - limit: self.previewLimit(for: maxItems), - timeoutMs: timeoutMs) - }) - } - let built = Self.previewItems(from: payload, maxItems: maxItems) - await SessionPreviewCache.shared.store(items: built, for: sessionKey) - return Self.snapshot(from: built) + let snapshot = try await self.fetchSnapshot(sessionKey: sessionKey, maxItems: maxItems) + await SessionPreviewCache.shared.store(snapshot: snapshot, for: sessionKey) + return snapshot } catch is CancellationError { return SessionMenuPreviewSnapshot(items: [], status: .loading) } catch { - let fallback = await SessionPreviewCache.shared.lastItems(for: sessionKey) - if let fallback { - return Self.snapshot(from: fallback) + if let fallback = await SessionPreviewCache.shared.lastSnapshot(for: sessionKey) { + return fallback } let errorDescription = String(describing: error) Self.logger.warning( @@ -272,18 +271,120 @@ enum SessionMenuPreviewLoader { } } + private static func fetchSnapshot(sessionKey: String, maxItems: Int) async throws -> SessionMenuPreviewSnapshot { + do { + let payload = try await self.requestPreview(keys: [sessionKey], maxItems: maxItems) + if let entry = payload.previews.first(where: { $0.key == sessionKey }) ?? payload.previews.first { + return self.snapshot(from: entry, maxItems: maxItems) + } + return SessionMenuPreviewSnapshot(items: [], status: .error("Preview unavailable")) + } catch { + if self.isUnknownMethodError(error) { + return try await self.fetchHistorySnapshot(sessionKey: sessionKey, maxItems: maxItems) + } + throw error + } + } + + private static func requestPreview( + keys: [String], + maxItems: Int) async throws -> ClawdbotSessionsPreviewPayload + { + let boundedItems = self.normalizeMaxItems(maxItems) + let timeoutMs = Int(self.previewTimeoutSeconds * 1000) + return try await SessionPreviewLimiter.shared.withPermit { + try await AsyncTimeout.withTimeout( + seconds: self.previewTimeoutSeconds, + onTimeout: { PreviewTimeoutError() }, + operation: { + try await GatewayConnection.shared.sessionsPreview( + keys: keys, + limit: boundedItems, + maxChars: self.previewMaxChars, + timeoutMs: timeoutMs) + }) + } + } + + private static func fetchHistorySnapshot( + sessionKey: String, + maxItems: Int) async throws -> SessionMenuPreviewSnapshot + { + let timeoutMs = Int(self.previewTimeoutSeconds * 1000) + let payload = try await SessionPreviewLimiter.shared.withPermit { + try await AsyncTimeout.withTimeout( + seconds: self.previewTimeoutSeconds, + onTimeout: { PreviewTimeoutError() }, + operation: { + try await GatewayConnection.shared.chatHistory( + sessionKey: sessionKey, + limit: self.previewLimit(for: maxItems), + timeoutMs: timeoutMs) + }) + } + let built = Self.previewItems(from: payload, maxItems: maxItems) + return Self.snapshot(from: built) + } + private static func snapshot(from items: [SessionPreviewItem]) -> SessionMenuPreviewSnapshot { SessionMenuPreviewSnapshot(items: items, status: items.isEmpty ? .empty : .ready) } + private static func snapshot( + from entry: ClawdbotSessionPreviewEntry, + maxItems: Int) -> SessionMenuPreviewSnapshot + { + let items = self.previewItems(from: entry, maxItems: maxItems) + let normalized = entry.status.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + switch normalized { + case "ok": + return SessionMenuPreviewSnapshot(items: items, status: items.isEmpty ? .empty : .ready) + case "empty": + return SessionMenuPreviewSnapshot(items: items, status: .empty) + case "missing": + return SessionMenuPreviewSnapshot(items: items, status: .error("Session missing")) + default: + return SessionMenuPreviewSnapshot(items: items, status: .error("Preview unavailable")) + } + } + + private static func cache(payload: ClawdbotSessionsPreviewPayload, maxItems: Int) async { + for entry in payload.previews { + let snapshot = self.snapshot(from: entry, maxItems: maxItems) + await SessionPreviewCache.shared.store(snapshot: snapshot, for: entry.key) + } + } + private static func previewLimit(for maxItems: Int) -> Int { - min(max(maxItems * 3, 20), 120) + let boundedItems = self.normalizeMaxItems(maxItems) + return min(max(boundedItems * 3, 20), 120) + } + + private static func normalizeMaxItems(_ maxItems: Int) -> Int { + max(1, min(maxItems, 50)) + } + + private static func previewItems( + from entry: ClawdbotSessionPreviewEntry, + maxItems: Int) -> [SessionPreviewItem] + { + let boundedItems = self.normalizeMaxItems(maxItems) + let built: [SessionPreviewItem] = entry.items.enumerated().compactMap { index, item in + let text = item.text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { return nil } + let role = self.previewRoleFromRaw(item.role) + return SessionPreviewItem(id: "\(entry.key)-\(index)", role: role, text: text) + } + + let trimmed = built.suffix(boundedItems) + return Array(trimmed.reversed()) } private static func previewItems( from payload: ClawdbotChatHistoryPayload, maxItems: Int) -> [SessionPreviewItem] { + let boundedItems = self.normalizeMaxItems(maxItems) let raw: [ClawdbotKit.AnyCodable] = payload.messages ?? [] let messages = self.decodeMessages(raw) let built = messages.compactMap { message -> SessionPreviewItem? in @@ -294,7 +395,7 @@ enum SessionMenuPreviewLoader { return SessionPreviewItem(id: id, role: role, text: text) } - let trimmed = built.suffix(maxItems) + let trimmed = built.suffix(boundedItems) return Array(trimmed.reversed()) } @@ -307,12 +408,16 @@ enum SessionMenuPreviewLoader { private static func previewRole(_ raw: String, isTool: Bool) -> PreviewRole { if isTool { return .tool } + return self.previewRoleFromRaw(raw) + } + + private static func previewRoleFromRaw(_ raw: String) -> PreviewRole { switch raw.lowercased() { - case "user": return .user - case "assistant": return .assistant - case "system": return .system - case "tool": return .tool - default: return .other + case "user": .user + case "assistant": .assistant + case "system": .system + case "tool": .tool + default: .other } } @@ -375,4 +480,16 @@ enum SessionMenuPreviewLoader { } return result } + + private static func uniqueKeys(_ keys: [String]) -> [String] { + let trimmed = keys.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + return self.dedupePreservingOrder(trimmed.filter { !$0.isEmpty }) + } + + private static func isUnknownMethodError(_ error: Error) -> Bool { + guard let response = error as? GatewayResponseError else { return false } + guard response.code == ErrorCode.invalidRequest.rawValue else { return false } + let message = response.message.lowercased() + return message.contains("unknown method") + } } diff --git a/apps/macos/Tests/ClawdbotIPCTests/SessionMenuPreviewTests.swift b/apps/macos/Tests/ClawdbotIPCTests/SessionMenuPreviewTests.swift index b1d7b462c..af25d5246 100644 --- a/apps/macos/Tests/ClawdbotIPCTests/SessionMenuPreviewTests.swift +++ b/apps/macos/Tests/ClawdbotIPCTests/SessionMenuPreviewTests.swift @@ -7,20 +7,22 @@ struct SessionMenuPreviewTests { @Test func loaderReturnsCachedItems() async { await SessionPreviewCache.shared._testReset() let items = [SessionPreviewItem(id: "1", role: .user, text: "Hi")] - await SessionPreviewCache.shared._testSet(items: items, for: "main") + let snapshot = SessionMenuPreviewSnapshot(items: items, status: .ready) + await SessionPreviewCache.shared._testSet(snapshot: snapshot, for: "main") - let snapshot = await SessionMenuPreviewLoader.load(sessionKey: "main", maxItems: 10) - #expect(snapshot.status == .ready) - #expect(snapshot.items.count == 1) - #expect(snapshot.items.first?.text == "Hi") + let loaded = await SessionMenuPreviewLoader.load(sessionKey: "main", maxItems: 10) + #expect(loaded.status == .ready) + #expect(loaded.items.count == 1) + #expect(loaded.items.first?.text == "Hi") } @Test func loaderReturnsEmptyWhenCachedEmpty() async { await SessionPreviewCache.shared._testReset() - await SessionPreviewCache.shared._testSet(items: [], for: "main") + let snapshot = SessionMenuPreviewSnapshot(items: [], status: .empty) + await SessionPreviewCache.shared._testSet(snapshot: snapshot, for: "main") - let snapshot = await SessionMenuPreviewLoader.load(sessionKey: "main", maxItems: 10) - #expect(snapshot.status == .empty) - #expect(snapshot.items.isEmpty) + let loaded = await SessionMenuPreviewLoader.load(sessionKey: "main", maxItems: 10) + #expect(loaded.status == .empty) + #expect(loaded.items.isEmpty) } } diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatModels.swift b/apps/shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatModels.swift index ab86f6b53..7b1c693a6 100644 --- a/apps/shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatModels.swift +++ b/apps/shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatModels.swift @@ -235,6 +235,22 @@ public struct ClawdbotChatHistoryPayload: Codable, Sendable { public let thinkingLevel: String? } +public struct ClawdbotSessionPreviewItem: Codable, Hashable, Sendable { + public let role: String + public let text: String +} + +public struct ClawdbotSessionPreviewEntry: Codable, Sendable { + public let key: String + public let status: String + public let items: [ClawdbotSessionPreviewItem] +} + +public struct ClawdbotSessionsPreviewPayload: Codable, Sendable { + public let ts: Int + public let previews: [ClawdbotSessionPreviewEntry] +} + public struct ClawdbotChatSendResponse: Codable, Sendable { public let runId: String public let status: String diff --git a/src/gateway/protocol/index.ts b/src/gateway/protocol/index.ts index d1656135c..6e5a862d1 100644 --- a/src/gateway/protocol/index.ts +++ b/src/gateway/protocol/index.ts @@ -140,6 +140,8 @@ import { SessionsListParamsSchema, type SessionsPatchParams, SessionsPatchParamsSchema, + type SessionsPreviewParams, + SessionsPreviewParamsSchema, type SessionsResetParams, SessionsResetParamsSchema, type SessionsResolveParams, @@ -229,6 +231,9 @@ export const validateNodeInvokeResultParams = ajv.compile(NodeEventParamsSchema); export const validateSessionsListParams = ajv.compile(SessionsListParamsSchema); +export const validateSessionsPreviewParams = ajv.compile( + SessionsPreviewParamsSchema, +); export const validateSessionsResolveParams = ajv.compile( SessionsResolveParamsSchema, ); @@ -376,6 +381,7 @@ export { NodeListParamsSchema, NodeInvokeParamsSchema, SessionsListParamsSchema, + SessionsPreviewParamsSchema, SessionsPatchParamsSchema, SessionsResetParamsSchema, SessionsDeleteParamsSchema, @@ -488,6 +494,7 @@ export type { NodeInvokeResultParams, NodeEventParams, SessionsListParams, + SessionsPreviewParams, SessionsResolveParams, SessionsPatchParams, SessionsResetParams, diff --git a/src/gateway/protocol/schema/protocol-schemas.ts b/src/gateway/protocol/schema/protocol-schemas.ts index 7e55d2075..e92f114e2 100644 --- a/src/gateway/protocol/schema/protocol-schemas.ts +++ b/src/gateway/protocol/schema/protocol-schemas.ts @@ -108,6 +108,7 @@ import { SessionsDeleteParamsSchema, SessionsListParamsSchema, SessionsPatchParamsSchema, + SessionsPreviewParamsSchema, SessionsResetParamsSchema, SessionsResolveParamsSchema, } from "./sessions.js"; @@ -155,6 +156,7 @@ export const ProtocolSchemas: Record = { NodeEventParams: NodeEventParamsSchema, NodeInvokeRequestEvent: NodeInvokeRequestEventSchema, SessionsListParams: SessionsListParamsSchema, + SessionsPreviewParams: SessionsPreviewParamsSchema, SessionsResolveParams: SessionsResolveParamsSchema, SessionsPatchParams: SessionsPatchParamsSchema, SessionsResetParams: SessionsResetParamsSchema, diff --git a/src/gateway/protocol/schema/sessions.ts b/src/gateway/protocol/schema/sessions.ts index 42fa83ff6..4b7e895c7 100644 --- a/src/gateway/protocol/schema/sessions.ts +++ b/src/gateway/protocol/schema/sessions.ts @@ -26,6 +26,15 @@ export const SessionsListParamsSchema = Type.Object( { additionalProperties: false }, ); +export const SessionsPreviewParamsSchema = Type.Object( + { + keys: Type.Array(NonEmptyString, { minItems: 1 }), + limit: Type.Optional(Type.Integer({ minimum: 1 })), + maxChars: Type.Optional(Type.Integer({ minimum: 20 })), + }, + { additionalProperties: false }, +); + export const SessionsResolveParamsSchema = Type.Object( { key: Type.Optional(NonEmptyString), diff --git a/src/gateway/protocol/schema/types.ts b/src/gateway/protocol/schema/types.ts index 164d6b902..696503721 100644 --- a/src/gateway/protocol/schema/types.ts +++ b/src/gateway/protocol/schema/types.ts @@ -101,6 +101,7 @@ import type { SessionsDeleteParamsSchema, SessionsListParamsSchema, SessionsPatchParamsSchema, + SessionsPreviewParamsSchema, SessionsResetParamsSchema, SessionsResolveParamsSchema, } from "./sessions.js"; @@ -144,6 +145,7 @@ export type NodeInvokeParams = Static; export type NodeInvokeResultParams = Static; export type NodeEventParams = Static; export type SessionsListParams = Static; +export type SessionsPreviewParams = Static; export type SessionsResolveParams = Static; export type SessionsPatchParams = Static; export type SessionsResetParams = Static; diff --git a/src/gateway/server-methods-list.ts b/src/gateway/server-methods-list.ts index b90643df8..b02902b5e 100644 --- a/src/gateway/server-methods-list.ts +++ b/src/gateway/server-methods-list.ts @@ -34,6 +34,7 @@ const BASE_METHODS = [ "voicewake.get", "voicewake.set", "sessions.list", + "sessions.preview", "sessions.patch", "sessions.reset", "sessions.delete", diff --git a/src/gateway/server-methods.ts b/src/gateway/server-methods.ts index 486bd249c..9651add19 100644 --- a/src/gateway/server-methods.ts +++ b/src/gateway/server-methods.ts @@ -59,6 +59,7 @@ const READ_METHODS = new Set([ "skills.status", "voicewake.get", "sessions.list", + "sessions.preview", "cron.list", "cron.status", "cron.runs", diff --git a/src/gateway/server-methods/sessions.ts b/src/gateway/server-methods/sessions.ts index 9d3752627..f31c726bb 100644 --- a/src/gateway/server-methods/sessions.ts +++ b/src/gateway/server-methods/sessions.ts @@ -6,6 +6,7 @@ import { stopSubagentsForRequester } from "../../auto-reply/reply/abort.js"; import { clearSessionQueues } from "../../auto-reply/reply/queue.js"; import { loadConfig } from "../../config/config.js"; import { + loadSessionStore, snapshotSessionOrigin, resolveMainSessionKey, type SessionEntry, @@ -19,6 +20,7 @@ import { validateSessionsDeleteParams, validateSessionsListParams, validateSessionsPatchParams, + validateSessionsPreviewParams, validateSessionsResetParams, validateSessionsResolveParams, } from "../protocol/index.js"; @@ -27,9 +29,12 @@ import { listSessionsFromStore, loadCombinedSessionStoreForGateway, loadSessionEntry, + readSessionPreviewItemsFromTranscript, resolveGatewaySessionStoreTarget, resolveSessionTranscriptCandidates, type SessionsPatchResult, + type SessionsPreviewEntry, + type SessionsPreviewResult, } from "../session-utils.js"; import { applySessionsPatchToStore } from "../sessions-patch.js"; import { resolveSessionKeyFromResolveParams } from "../sessions-resolve.js"; @@ -59,6 +64,74 @@ export const sessionsHandlers: GatewayRequestHandlers = { }); respond(true, result, undefined); }, + "sessions.preview": ({ params, respond }) => { + if (!validateSessionsPreviewParams(params)) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `invalid sessions.preview params: ${formatValidationErrors( + validateSessionsPreviewParams.errors, + )}`, + ), + ); + return; + } + const p = params as import("../protocol/index.js").SessionsPreviewParams; + const keysRaw = Array.isArray(p.keys) ? p.keys : []; + const keys = keysRaw + .map((key) => String(key ?? "").trim()) + .filter(Boolean) + .slice(0, 64); + const limit = + typeof p.limit === "number" && Number.isFinite(p.limit) ? Math.max(1, p.limit) : 12; + const maxChars = + typeof p.maxChars === "number" && Number.isFinite(p.maxChars) + ? Math.max(20, p.maxChars) + : 240; + + if (keys.length === 0) { + respond(true, { ts: Date.now(), previews: [] } satisfies SessionsPreviewResult, undefined); + return; + } + + const cfg = loadConfig(); + const storeCache = new Map>(); + const previews: SessionsPreviewEntry[] = []; + + for (const key of keys) { + try { + const target = resolveGatewaySessionStoreTarget({ cfg, key }); + const store = storeCache.get(target.storePath) ?? loadSessionStore(target.storePath); + storeCache.set(target.storePath, store); + const entry = + target.storeKeys.map((candidate) => store[candidate]).find(Boolean) ?? + store[target.canonicalKey]; + if (!entry?.sessionId) { + previews.push({ key, status: "missing", items: [] }); + continue; + } + const items = readSessionPreviewItemsFromTranscript( + entry.sessionId, + target.storePath, + entry.sessionFile, + target.agentId, + limit, + maxChars, + ); + previews.push({ + key, + status: items.length > 0 ? "ok" : "empty", + items, + }); + } catch { + previews.push({ key, status: "error", items: [] }); + } + } + + respond(true, { ts: Date.now(), previews } satisfies SessionsPreviewResult, undefined); + }, "sessions.resolve": ({ params, respond }) => { if (!validateSessionsResolveParams(params)) { respond( diff --git a/src/gateway/server.sessions.gateway-server-sessions-a.test.ts b/src/gateway/server.sessions.gateway-server-sessions-a.test.ts index abfcb44e7..95e91ef50 100644 --- a/src/gateway/server.sessions.gateway-server-sessions-a.test.ts +++ b/src/gateway/server.sessions.gateway-server-sessions-a.test.ts @@ -103,6 +103,7 @@ describe("gateway server sessions", () => { expect((hello as unknown as { features?: { methods?: string[] } }).features?.methods).toEqual( expect.arrayContaining([ "sessions.list", + "sessions.preview", "sessions.patch", "sessions.reset", "sessions.delete", @@ -338,6 +339,53 @@ describe("gateway server sessions", () => { await server.close(); }); + test("sessions.preview returns transcript previews", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-sessions-preview-")); + const storePath = path.join(dir, "sessions.json"); + testState.sessionStorePath = storePath; + const sessionId = "sess-preview"; + const transcriptPath = path.join(dir, `${sessionId}.jsonl`); + const lines = [ + JSON.stringify({ type: "session", version: 1, id: sessionId }), + JSON.stringify({ message: { role: "user", content: "Hello" } }), + JSON.stringify({ message: { role: "assistant", content: "Hi" } }), + JSON.stringify({ + message: { role: "assistant", content: [{ type: "toolcall", name: "weather" }] }, + }), + JSON.stringify({ message: { role: "assistant", content: "Forecast ready" } }), + ]; + await fs.writeFile(transcriptPath, lines.join("\n"), "utf-8"); + + await writeSessionStore({ + entries: { + main: { + sessionId, + updatedAt: Date.now(), + }, + }, + }); + + const { server, ws } = await startServerWithClient(); + await connectOk(ws); + const preview = await rpcReq<{ + previews: Array<{ + key: string; + status: string; + items: Array<{ role: string; text: string }>; + }>; + }>(ws, "sessions.preview", { keys: ["main"], limit: 3, maxChars: 120 }); + + expect(preview.ok).toBe(true); + const entry = preview.payload?.previews[0]; + expect(entry?.key).toBe("main"); + expect(entry?.status).toBe("ok"); + expect(entry?.items.map((item) => item.role)).toEqual(["assistant", "tool", "assistant"]); + expect(entry?.items[1]?.text).toContain("call weather"); + + ws.close(); + await server.close(); + }); + test("sessions.delete rejects main and aborts active runs", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-sessions-")); const storePath = path.join(dir, "sessions.json"); diff --git a/src/gateway/session-utils.fs.test.ts b/src/gateway/session-utils.fs.test.ts index 8e2ad67c6..a85dc9f69 100644 --- a/src/gateway/session-utils.fs.test.ts +++ b/src/gateway/session-utils.fs.test.ts @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, test } from "vitest"; import { readFirstUserMessageFromTranscript, readLastMessagePreviewFromTranscript, + readSessionPreviewItemsFromTranscript, } from "./session-utils.fs.js"; describe("readFirstUserMessageFromTranscript", () => { @@ -341,3 +342,65 @@ describe("readLastMessagePreviewFromTranscript", () => { expect(result).toBe("Valid UTF-8: 你好世界 🌍"); }); }); + +describe("readSessionPreviewItemsFromTranscript", () => { + let tmpDir: string; + let storePath: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-session-preview-test-")); + storePath = path.join(tmpDir, "sessions.json"); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + test("returns recent preview items with tool summary", () => { + const sessionId = "preview-session"; + const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); + const lines = [ + JSON.stringify({ type: "session", version: 1, id: sessionId }), + JSON.stringify({ message: { role: "user", content: "Hello" } }), + JSON.stringify({ message: { role: "assistant", content: "Hi" } }), + JSON.stringify({ + message: { role: "assistant", content: [{ type: "toolcall", name: "weather" }] }, + }), + JSON.stringify({ message: { role: "assistant", content: "Forecast ready" } }), + ]; + fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8"); + + const result = readSessionPreviewItemsFromTranscript( + sessionId, + storePath, + undefined, + undefined, + 3, + 120, + ); + + expect(result.map((item) => item.role)).toEqual(["assistant", "tool", "assistant"]); + expect(result[1]?.text).toContain("call weather"); + }); + + test("truncates preview text to max chars", () => { + const sessionId = "preview-truncate"; + const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); + const longText = "a".repeat(60); + const lines = [JSON.stringify({ message: { role: "assistant", content: longText } })]; + fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8"); + + const result = readSessionPreviewItemsFromTranscript( + sessionId, + storePath, + undefined, + undefined, + 1, + 24, + ); + + expect(result).toHaveLength(1); + expect(result[0]?.text.length).toBe(24); + expect(result[0]?.text.endsWith("...")).toBe(true); + }); +}); diff --git a/src/gateway/session-utils.fs.ts b/src/gateway/session-utils.fs.ts index eb8912359..d6453ace6 100644 --- a/src/gateway/session-utils.fs.ts +++ b/src/gateway/session-utils.fs.ts @@ -3,6 +3,8 @@ import os from "node:os"; import path from "node:path"; import { resolveSessionTranscriptPath } from "../config/sessions.js"; +import { stripEnvelope } from "./chat-sanitize.js"; +import type { SessionPreviewItem } from "./session-utils.types.js"; export function readSessionMessages( sessionId: string, @@ -189,3 +191,202 @@ export function readLastMessagePreviewFromTranscript( } return null; } + +const PREVIEW_READ_SIZES = [64 * 1024, 256 * 1024, 1024 * 1024]; +const PREVIEW_MAX_LINES = 200; + +type TranscriptContentEntry = { + type?: string; + text?: string; + name?: string; +}; + +type TranscriptPreviewMessage = { + role?: string; + content?: string | TranscriptContentEntry[]; + text?: string; + toolName?: string; + tool_name?: string; +}; + +function normalizeRole(role: string | undefined, isTool: boolean): SessionPreviewItem["role"] { + if (isTool) return "tool"; + switch ((role ?? "").toLowerCase()) { + case "user": + return "user"; + case "assistant": + return "assistant"; + case "system": + return "system"; + case "tool": + return "tool"; + default: + return "other"; + } +} + +function truncatePreviewText(text: string, maxChars: number): string { + if (maxChars <= 0 || text.length <= maxChars) return text; + if (maxChars <= 3) return text.slice(0, maxChars); + return `${text.slice(0, maxChars - 3)}...`; +} + +function extractPreviewText(message: TranscriptPreviewMessage): string | null { + if (typeof message.content === "string") { + const trimmed = message.content.trim(); + return trimmed ? trimmed : null; + } + if (Array.isArray(message.content)) { + const parts = message.content + .map((entry) => (typeof entry?.text === "string" ? entry.text : "")) + .filter((text) => text.trim().length > 0); + if (parts.length > 0) { + return parts.join("\n").trim(); + } + } + if (typeof message.text === "string") { + const trimmed = message.text.trim(); + return trimmed ? trimmed : null; + } + return null; +} + +function isToolCall(message: TranscriptPreviewMessage): boolean { + if (message.toolName || message.tool_name) return true; + if (!Array.isArray(message.content)) return false; + return message.content.some((entry) => { + if (entry?.name) return true; + const raw = typeof entry?.type === "string" ? entry.type.toLowerCase() : ""; + return raw === "toolcall" || raw === "tool_call"; + }); +} + +function extractToolNames(message: TranscriptPreviewMessage): string[] { + const names: string[] = []; + if (Array.isArray(message.content)) { + for (const entry of message.content) { + if (typeof entry?.name === "string" && entry.name.trim()) { + names.push(entry.name.trim()); + } + } + } + const toolName = typeof message.toolName === "string" ? message.toolName : message.tool_name; + if (typeof toolName === "string" && toolName.trim()) { + names.push(toolName.trim()); + } + return names; +} + +function extractMediaSummary(message: TranscriptPreviewMessage): string | null { + if (!Array.isArray(message.content)) return null; + for (const entry of message.content) { + const raw = typeof entry?.type === "string" ? entry.type.trim().toLowerCase() : ""; + if (!raw || raw === "text" || raw === "toolcall" || raw === "tool_call") continue; + return `[${raw}]`; + } + return null; +} + +function buildPreviewItems( + messages: TranscriptPreviewMessage[], + maxItems: number, + maxChars: number, +): SessionPreviewItem[] { + const items: SessionPreviewItem[] = []; + for (const message of messages) { + const toolCall = isToolCall(message); + const role = normalizeRole(message.role, toolCall); + let text = extractPreviewText(message); + if (!text) { + const toolNames = extractToolNames(message); + if (toolNames.length > 0) { + const shown = toolNames.slice(0, 2); + const overflow = toolNames.length - shown.length; + text = `call ${shown.join(", ")}`; + if (overflow > 0) text += ` +${overflow}`; + } + } + if (!text) { + text = extractMediaSummary(message); + } + if (!text) continue; + let trimmed = text.trim(); + if (!trimmed) continue; + if (role === "user") { + trimmed = stripEnvelope(trimmed); + } + trimmed = truncatePreviewText(trimmed, maxChars); + items.push({ role, text: trimmed }); + } + + if (items.length <= maxItems) return items; + return items.slice(-maxItems); +} + +function readRecentMessagesFromTranscript( + filePath: string, + maxMessages: number, + readBytes: number, +): TranscriptPreviewMessage[] { + let fd: number | null = null; + try { + fd = fs.openSync(filePath, "r"); + const stat = fs.fstatSync(fd); + const size = stat.size; + if (size === 0) return []; + + const readStart = Math.max(0, size - readBytes); + const readLen = Math.min(size, readBytes); + const buf = Buffer.alloc(readLen); + fs.readSync(fd, buf, 0, readLen, readStart); + + const chunk = buf.toString("utf-8"); + const lines = chunk.split(/\r?\n/).filter((l) => l.trim()); + const tailLines = lines.slice(-PREVIEW_MAX_LINES); + + const collected: TranscriptPreviewMessage[] = []; + for (let i = tailLines.length - 1; i >= 0; i--) { + const line = tailLines[i]; + try { + const parsed = JSON.parse(line); + const msg = parsed?.message as TranscriptPreviewMessage | undefined; + if (msg && typeof msg === "object") { + collected.push(msg); + if (collected.length >= maxMessages) break; + } + } catch { + // skip malformed lines + } + } + return collected.reverse(); + } catch { + return []; + } finally { + if (fd !== null) fs.closeSync(fd); + } +} + +export function readSessionPreviewItemsFromTranscript( + sessionId: string, + storePath: string | undefined, + sessionFile: string | undefined, + agentId: string | undefined, + maxItems: number, + maxChars: number, +): SessionPreviewItem[] { + const candidates = resolveSessionTranscriptCandidates(sessionId, storePath, sessionFile, agentId); + const filePath = candidates.find((p) => fs.existsSync(p)); + if (!filePath) return []; + + const boundedItems = Math.max(1, Math.min(maxItems, 50)); + const boundedChars = Math.max(20, Math.min(maxChars, 2000)); + + for (const readSize of PREVIEW_READ_SIZES) { + const messages = readRecentMessagesFromTranscript(filePath, boundedItems, readSize); + if (messages.length > 0 || readSize === PREVIEW_READ_SIZES[PREVIEW_READ_SIZES.length - 1]) { + return buildPreviewItems(messages, boundedItems, boundedChars); + } + } + + return []; +} diff --git a/src/gateway/session-utils.ts b/src/gateway/session-utils.ts index 1c5934aa5..c4046a08e 100644 --- a/src/gateway/session-utils.ts +++ b/src/gateway/session-utils.ts @@ -38,6 +38,7 @@ export { capArrayByJsonBytes, readFirstUserMessageFromTranscript, readLastMessagePreviewFromTranscript, + readSessionPreviewItemsFromTranscript, readSessionMessages, resolveSessionTranscriptCandidates, } from "./session-utils.fs.js"; @@ -47,6 +48,8 @@ export type { GatewaySessionsDefaults, SessionsListResult, SessionsPatchResult, + SessionsPreviewEntry, + SessionsPreviewResult, } from "./session-utils.types.js"; const DERIVED_TITLE_MAX_LEN = 60; diff --git a/src/gateway/session-utils.types.ts b/src/gateway/session-utils.types.ts index 99491a7f9..074e9eaa8 100644 --- a/src/gateway/session-utils.types.ts +++ b/src/gateway/session-utils.types.ts @@ -55,6 +55,22 @@ export type GatewayAgentRow = { }; }; +export type SessionPreviewItem = { + role: "user" | "assistant" | "tool" | "system" | "other"; + text: string; +}; + +export type SessionsPreviewEntry = { + key: string; + status: "ok" | "empty" | "missing" | "error"; + items: SessionPreviewItem[]; +}; + +export type SessionsPreviewResult = { + ts: number; + previews: SessionsPreviewEntry[]; +}; + export type SessionsListResult = { ts: number; path: string;