From ab4525db5b96e48730adbda41003977b3bf04156 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 10 May 2026 12:08:14 +0800 Subject: [PATCH] refactor(server): extract import session boundary (#854) --- .../src/server/agent/create-agent-title.ts | 37 +++ .../src/server/agent/import-sessions.test.ts | 202 +++++++++++++- .../src/server/agent/import-sessions.ts | 167 ++++++++++- .../server/session.create-agent-title.test.ts | 2 +- packages/server/src/server/session.test.ts | 261 ------------------ packages/server/src/server/session.ts | 152 +--------- 6 files changed, 415 insertions(+), 406 deletions(-) create mode 100644 packages/server/src/server/agent/create-agent-title.ts diff --git a/packages/server/src/server/agent/create-agent-title.ts b/packages/server/src/server/agent/create-agent-title.ts new file mode 100644 index 000000000..71b0e66b1 --- /dev/null +++ b/packages/server/src/server/agent/create-agent-title.ts @@ -0,0 +1,37 @@ +import { MAX_EXPLICIT_AGENT_TITLE_CHARS } from "./agent-title-limits.js"; + +const MAX_INITIAL_AGENT_TITLE_CHARS = Math.min(60, MAX_EXPLICIT_AGENT_TITLE_CHARS); + +function deriveInitialAgentTitle(prompt: string): string | null { + const firstContentLine = prompt + .split(/\r?\n/) + .map((line) => line.trim()) + .find((line) => line.length > 0); + if (!firstContentLine) { + return null; + } + const normalized = firstContentLine.replace(/\s+/g, " ").trim(); + if (!normalized) { + return null; + } + const clamped = normalized.slice(0, MAX_INITIAL_AGENT_TITLE_CHARS).trim(); + return clamped.length > 0 ? clamped : null; +} + +export function resolveCreateAgentTitles(options: { + configTitle?: string | null; + initialPrompt?: string | null; +}): { explicitTitle: string | null; provisionalTitle: string | null } { + const explicitTitle = + typeof options.configTitle === "string" && options.configTitle.trim().length > 0 + ? options.configTitle.trim() + : null; + const trimmedPrompt = options.initialPrompt?.trim(); + const provisionalTitle = + explicitTitle ?? (trimmedPrompt ? deriveInitialAgentTitle(trimmedPrompt) : null); + + return { + explicitTitle, + provisionalTitle, + }; +} diff --git a/packages/server/src/server/agent/import-sessions.test.ts b/packages/server/src/server/agent/import-sessions.test.ts index 46888ac2d..20db2d9a4 100644 --- a/packages/server/src/server/agent/import-sessions.test.ts +++ b/packages/server/src/server/agent/import-sessions.test.ts @@ -1,14 +1,28 @@ -import { expect, test, vi } from "vitest"; +import { beforeEach, expect, test, vi } from "vitest"; import type { AgentManager, ManagedAgent } from "./agent-manager.js"; import type { AgentStorage, StoredAgentRecord } from "./agent-storage.js"; import type { FetchRecentProviderSessionsRequestMessage } from "../../shared/messages.js"; -import type { PersistedAgentDescriptor } from "./agent-sdk-types.js"; +import type { AgentTimelineItem, PersistedAgentDescriptor } from "./agent-sdk-types.js"; import { ImportSessionsRequestError, + importProviderSession, listImportableProviderSessions, normalizeImportAgentRequest, } from "./import-sessions.js"; +const TEST_CAPABILITIES = { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: false, + supportsMcpServers: false, + supportsReasoningStream: false, + supportsToolInvocations: true, +}; + +beforeEach(() => { + vi.clearAllMocks(); +}); + function makeDescriptor(args: { provider?: string; sessionId: string; @@ -40,6 +54,49 @@ function makeDescriptor(args: { }; } +function makeManagedAgent(args: { + id?: string; + provider?: string; + cwd: string; + sessionId: string; + nativeHandle?: string; + title?: string | null; +}): ManagedAgent { + const provider = args.provider ?? "codex"; + return { + id: args.id ?? "00000000-0000-4000-8000-000000000632", + provider, + cwd: args.cwd, + capabilities: TEST_CAPABILITIES, + config: { provider, cwd: args.cwd, title: args.title }, + createdAt: new Date("2026-04-30T00:00:00.000Z"), + updatedAt: new Date("2026-04-30T00:00:00.000Z"), + availableModes: [], + currentModeId: null, + pendingPermissions: new Map(), + bufferedPermissionResolutions: new Map(), + inFlightPermissionResponses: new Set(), + pendingReplacement: false, + persistence: { + provider, + sessionId: args.sessionId, + ...(args.nativeHandle ? { nativeHandle: args.nativeHandle } : {}), + metadata: { provider, cwd: args.cwd }, + }, + historyPrimed: true, + lastUserMessageAt: null, + attention: { requiresAttention: false }, + foregroundTurnWaiters: new Set(), + finalizedForegroundTurnIds: new Set(), + unsubscribeSession: null, + internal: false, + labels: {}, + lifecycle: "closed", + session: null, + activeForegroundTurnId: null, + } satisfies ManagedAgent; +} + function makeRequest( overrides: Partial = {}, ): FetchRecentProviderSessionsRequestMessage { @@ -263,3 +320,144 @@ test("normalizeImportAgentRequest accepts new and legacy import handle shapes", providerHandleId: "thread-2", }); }); + +test("importProviderSession resumes by provider handle, hydrates the timeline, and applies title metadata", async () => { + const cwd = "/tmp/imported-agent"; + const timeline: AgentTimelineItem[] = [ + { type: "user_message", text: "Trace recent provider sessions\n\nkeep it tight" }, + { type: "assistant_message", text: "I will inspect the provider listing." }, + ]; + const snapshot = makeManagedAgent({ + id: "00000000-0000-4000-8000-000000000633", + provider: "custom-codex", + cwd, + sessionId: "thread-imported", + nativeHandle: "provider-thread-imported", + title: null, + }); + const descriptor = makeDescriptor({ + provider: "custom-codex", + sessionId: "thread-imported", + nativeHandle: "provider-thread-imported", + cwd, + title: null, + firstPrompt: "Trace recent provider sessions", + lastActivityAt: "2026-04-30T00:00:00.000Z", + }); + const agentManager = { + findPersistedAgent: vi.fn().mockResolvedValue(descriptor), + resumeAgentFromPersistence: vi.fn().mockResolvedValue(snapshot), + hydrateTimelineFromProvider: vi.fn().mockResolvedValue(undefined), + getTimeline: vi.fn().mockReturnValue(timeline), + setTitle: vi.fn().mockResolvedValue(undefined), + notifyAgentState: vi.fn(), + } as unknown as AgentManager; + const agentStorage = { + list: vi.fn().mockResolvedValue([]), + get: vi.fn().mockResolvedValue(null), + } as unknown as AgentStorage; + const scheduleAgentMetadataGeneration = vi.fn(); + + const result = await importProviderSession({ + request: { + requestId: "import-thread", + provider: "custom-codex", + providerHandleId: "provider-thread-imported", + cwd, + }, + agentManager, + agentStorage, + logger: { warn: vi.fn(), error: vi.fn() } as never, + deps: { scheduleAgentMetadataGeneration }, + }); + + expect(agentManager.findPersistedAgent).toHaveBeenCalledWith( + "custom-codex", + "provider-thread-imported", + ); + expect(agentManager.resumeAgentFromPersistence).toHaveBeenCalledWith( + descriptor.persistence, + { cwd }, + undefined, + { labels: undefined }, + ); + expect(agentManager.hydrateTimelineFromProvider).toHaveBeenCalledWith(snapshot.id); + expect(agentManager.setTitle).toHaveBeenCalledWith(snapshot.id, "Trace recent provider sessions"); + expect(scheduleAgentMetadataGeneration).toHaveBeenCalledWith( + expect.objectContaining({ + agentManager, + agentId: snapshot.id, + cwd, + initialPrompt: "Trace recent provider sessions\n\nkeep it tight", + explicitTitle: null, + }), + ); + expect(result).toEqual({ snapshot, timelineSize: 2 }); +}); + +test("importProviderSession builds a fallback handle when a non-OpenCode provider has no descriptor", async () => { + const cwd = "/tmp/imported-agent"; + const snapshot = makeManagedAgent({ + provider: "codex", + cwd, + sessionId: "thread-imported", + nativeHandle: "thread-imported", + }); + const agentManager = { + findPersistedAgent: vi.fn().mockResolvedValue(null), + resumeAgentFromPersistence: vi.fn().mockResolvedValue(snapshot), + hydrateTimelineFromProvider: vi.fn().mockResolvedValue(undefined), + getTimeline: vi.fn().mockReturnValue([]), + setTitle: vi.fn().mockResolvedValue(undefined), + notifyAgentState: vi.fn(), + } as unknown as AgentManager; + const agentStorage = { + list: vi.fn().mockResolvedValue([]), + get: vi.fn().mockResolvedValue(null), + } as unknown as AgentStorage; + + await importProviderSession({ + request: { + requestId: "import-thread", + provider: "codex", + providerHandleId: "thread-imported", + cwd, + }, + agentManager, + agentStorage, + logger: { warn: vi.fn(), error: vi.fn() } as never, + }); + + expect(agentManager.resumeAgentFromPersistence).toHaveBeenCalledWith( + { + provider: "codex", + sessionId: "thread-imported", + nativeHandle: "thread-imported", + metadata: { provider: "codex", cwd }, + }, + { cwd }, + undefined, + { labels: undefined }, + ); +}); + +test("importProviderSession requires cwd for missing OpenCode descriptors", async () => { + const agentManager = { + findPersistedAgent: vi.fn().mockResolvedValue(null), + } as unknown as AgentManager; + + await expect( + importProviderSession({ + request: { + requestId: "import-thread", + provider: "opencode", + providerHandleId: "thread-imported", + }, + agentManager, + agentStorage: { list: vi.fn() } as unknown as AgentStorage, + logger: { warn: vi.fn(), error: vi.fn() } as never, + }), + ).rejects.toThrow( + "OpenCode sessions require --cwd when the session cannot be found in persisted agents", + ); +}); diff --git a/packages/server/src/server/agent/import-sessions.ts b/packages/server/src/server/agent/import-sessions.ts index 3317f6daf..fabb55cf3 100644 --- a/packages/server/src/server/agent/import-sessions.ts +++ b/packages/server/src/server/agent/import-sessions.ts @@ -1,18 +1,25 @@ import type { z } from "zod"; +import type { Logger } from "pino"; import type { ProviderDefinition } from "./provider-registry.js"; -import type { AgentManager } from "./agent-manager.js"; +import type { AgentManager, ManagedAgent } from "./agent-manager.js"; import type { AgentStorage, StoredAgentRecord } from "./agent-storage.js"; import type { AgentPersistenceHandle, AgentProvider, + AgentSessionConfig, + AgentTimelineItem, PersistedAgentDescriptor, } from "./agent-sdk-types.js"; +import { scheduleAgentMetadataGeneration } from "./agent-metadata-generator.js"; +import { resolveCreateAgentTitles } from "./create-agent-title.js"; +import { unarchiveAgentState } from "./agent-prompt.js"; import { toRecentProviderSessionDescriptorPayload } from "./agent-projections.js"; import type { FetchRecentProviderSessionsRequestMessage, ImportAgentRequestMessageSchema, RecentProviderSessionDescriptorPayload, } from "../../shared/messages.js"; +import type { WorkspaceGitService } from "../workspace-git-service.js"; type ImportAgentRequestMessage = z.infer; @@ -49,6 +56,23 @@ export interface ListImportableProviderSessionsResult { filteredAlreadyImportedCount: number; } +export interface ImportProviderSessionInput { + request: NormalizedImportAgentRequest; + agentManager: AgentManager; + agentStorage: AgentStorage; + workspaceGitService?: Pick; + paseoHome?: string; + logger: Logger; + deps?: { + scheduleAgentMetadataGeneration?: typeof scheduleAgentMetadataGeneration; + }; +} + +export interface ImportProviderSessionResult { + snapshot: ManagedAgent; + timelineSize: number; +} + // COMPAT(import-agent-request-v1): accept legacy {provider, sessionId} shape // alongside the new {providerId, providerHandleId} shape. Old clients // (< target daemon floor) send the legacy fields. Drop the fallbacks and the @@ -118,6 +142,99 @@ export async function listImportableProviderSessions( return { entries, filteredAlreadyImportedCount }; } +export async function importProviderSession( + input: ImportProviderSessionInput, +): Promise { + const { provider, providerHandleId, cwd, labels } = input.request; + const descriptor = await input.agentManager.findPersistedAgent(provider, providerHandleId); + if (!descriptor && provider === "opencode" && !cwd) { + throw new Error( + "OpenCode sessions require --cwd when the session cannot be found in persisted agents", + ); + } + + const handle = descriptor + ? applyImportCwdOverride(descriptor.persistence, cwd) + : buildImportPersistenceHandle({ provider, providerHandleId, cwd }); + const overrides = cwd ? ({ cwd } satisfies Partial) : undefined; + + await unarchiveAgentByHandle(input.agentStorage, input.agentManager, handle); + const snapshot = await input.agentManager.resumeAgentFromPersistence( + handle, + overrides, + undefined, + { + labels, + }, + ); + await unarchiveAgentState(input.agentStorage, input.agentManager, snapshot.id); + await input.agentManager.hydrateTimelineFromProvider(snapshot.id); + await applyImportedAgentTitle({ + snapshot, + agentManager: input.agentManager, + workspaceGitService: input.workspaceGitService, + paseoHome: input.paseoHome, + logger: input.logger, + scheduleAgentMetadataGeneration: + input.deps?.scheduleAgentMetadataGeneration ?? scheduleAgentMetadataGeneration, + }); + + return { + snapshot, + timelineSize: input.agentManager.getTimeline(snapshot.id).length, + }; +} + +async function unarchiveAgentByHandle( + agentStorage: AgentStorage, + agentManager: AgentManager, + handle: AgentPersistenceHandle, +): Promise { + const records = await agentStorage.list(); + const matched = records.find( + (record) => + record.persistence?.provider === handle.provider && + record.persistence?.sessionId === handle.sessionId, + ); + if (!matched) { + return; + } + await unarchiveAgentState(agentStorage, agentManager, matched.id); +} + +async function applyImportedAgentTitle(input: { + snapshot: ManagedAgent; + agentManager: AgentManager; + workspaceGitService?: Pick; + paseoHome?: string; + logger: Logger; + scheduleAgentMetadataGeneration: typeof scheduleAgentMetadataGeneration; +}): Promise { + const initialPrompt = getFirstUserMessageText(input.agentManager.getTimeline(input.snapshot.id)); + if (!initialPrompt) { + return; + } + + const { explicitTitle, provisionalTitle } = resolveCreateAgentTitles({ + configTitle: input.snapshot.config.title, + initialPrompt, + }); + if (!explicitTitle && provisionalTitle) { + await input.agentManager.setTitle(input.snapshot.id, provisionalTitle); + } + + input.scheduleAgentMetadataGeneration({ + agentManager: input.agentManager, + agentId: input.snapshot.id, + cwd: input.snapshot.cwd, + workspaceGitService: input.workspaceGitService, + initialPrompt, + explicitTitle, + paseoHome: input.paseoHome, + logger: input.logger, + }); +} + function parseRecentProviderSessionsSince(since: string | undefined): number | null { if (!since) { return null; @@ -129,6 +246,54 @@ function parseRecentProviderSessionsSince(since: string | undefined): number | n return timestamp; } +function buildImportPersistenceHandle(input: { + provider: AgentProvider; + providerHandleId: string; + cwd?: string; +}): AgentPersistenceHandle { + const cwd = input.cwd ?? process.cwd(); + return { + provider: input.provider, + sessionId: input.providerHandleId, + nativeHandle: input.providerHandleId, + metadata: { + provider: input.provider, + cwd, + }, + }; +} + +function applyImportCwdOverride( + handle: AgentPersistenceHandle, + cwd: string | undefined, +): AgentPersistenceHandle { + if (!cwd) { + return handle; + } + + return { + ...handle, + metadata: { + ...handle.metadata, + provider: handle.provider, + cwd, + }, + }; +} + +function getFirstUserMessageText(timeline: readonly AgentTimelineItem[]): string | null { + for (const item of timeline) { + if (item.type !== "user_message") { + continue; + } + const text = item.text.trim(); + if (text) { + return text; + } + } + return null; +} + async function collectImportedProviderSessionHandles( agentManager: Pick, agentStorage: Pick, diff --git a/packages/server/src/server/session.create-agent-title.test.ts b/packages/server/src/server/session.create-agent-title.test.ts index 710663745..0153dae39 100644 --- a/packages/server/src/server/session.create-agent-title.test.ts +++ b/packages/server/src/server/session.create-agent-title.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "vitest"; -import { resolveCreateAgentTitles } from "./session.js"; +import { resolveCreateAgentTitles } from "./agent/create-agent-title.js"; describe("resolveCreateAgentTitles", () => { test("derives a provisional title from prompt when explicit title is absent", () => { diff --git a/packages/server/src/server/session.test.ts b/packages/server/src/server/session.test.ts index f173b0d9e..68af31541 100644 --- a/packages/server/src/server/session.test.ts +++ b/packages/server/src/server/session.test.ts @@ -14,12 +14,9 @@ import type { AgentClient, AgentMode, AgentModelDefinition, - AgentTimelineItem, ListModesOptions, ListModelsOptions, - PersistedAgentDescriptor, } from "./agent/agent-sdk-types.js"; -import type { ManagedAgent } from "./agent/agent-manager.js"; import type { ProviderDefinition } from "./agent/provider-registry.js"; import { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js"; import type { SessionOptions } from "./session.js"; @@ -70,8 +67,6 @@ interface SessionHandlerInternals { handleCheckoutPullRequest(params: unknown): Promise; handleCheckoutPushRequest(params: unknown): Promise; handleCheckoutStatusRequest(params: unknown): Promise; - handleFetchRecentProviderSessions(params: unknown): Promise; - handleImportAgentRequest(params: unknown): Promise; describeWorkspaceRecord(...args: unknown[]): Promise; describeWorkspaceRecordWithGitData(...args: unknown[]): Promise; handleValidateBranchRequest(params: unknown): Promise; @@ -934,262 +929,6 @@ afterEach(() => { vi.clearAllMocks(); }); -describe("session agent import", () => { - test("filters already imported provider sessions by native handle", async () => { - const messages: unknown[] = []; - const cwd = "/tmp/import-filter"; - const importedDescriptor: PersistedAgentDescriptor = { - provider: "claude", - sessionId: "provider-session-imported", - cwd, - title: "Already imported", - lastActivityAt: new Date("2026-04-30T12:00:00.000Z"), - persistence: { - provider: "claude", - sessionId: "provider-session-imported", - nativeHandle: "provider-native-imported", - }, - timeline: [{ type: "user_message", text: "already imported" }], - }; - const visibleDescriptor: PersistedAgentDescriptor = { - provider: "claude", - sessionId: "provider-session-visible", - cwd, - title: "Still importable", - lastActivityAt: new Date("2026-04-30T11:00:00.000Z"), - persistence: { - provider: "claude", - sessionId: "provider-session-visible", - nativeHandle: "provider-native-visible", - }, - timeline: [{ type: "user_message", text: "still importable" }], - }; - const agentManager = { - listAgents: vi.fn(() => [ - { - provider: "claude", - persistence: { - provider: "claude", - sessionId: "stored-session-id", - nativeHandle: "provider-native-imported", - }, - } as ManagedAgent, - ]), - listImportablePersistedAgents: vi - .fn() - .mockResolvedValue([importedDescriptor, visibleDescriptor]), - subscribe: vi.fn(() => () => {}), - }; - const agentStorage = { - list: vi.fn().mockResolvedValue([]), - }; - const session = createSessionForTest({ messages }); - Object.assign(session, { agentManager, agentStorage }); - - await asSessionInternals(session).handleFetchRecentProviderSessions({ - type: "fetch_recent_provider_sessions_request", - requestId: "recent-provider-sessions", - cwd, - providers: ["claude"], - limit: 15, - }); - - expect(agentManager.listImportablePersistedAgents).toHaveBeenCalledWith({ - limit: 200, - providerFilter: new Set(["claude"]), - cwd, - }); - expect(messages).toContainEqual( - expect.objectContaining({ - type: "fetch_recent_provider_sessions_response", - payload: expect.objectContaining({ - requestId: "recent-provider-sessions", - filteredAlreadyImportedCount: 1, - entries: [ - expect.objectContaining({ - providerId: "claude", - providerHandleId: "provider-native-visible", - title: "Still importable", - }), - ], - }), - }), - ); - }); - - test("imports by provider handle id and hydrates title metadata from the first user message", async () => { - const messages: unknown[] = []; - const cwd = "/tmp/imported-agent"; - const timeline: AgentTimelineItem[] = [ - { type: "user_message", text: "Trace recent provider sessions\n\nkeep it tight" }, - { type: "assistant_message", text: "I will inspect the provider listing." }, - ]; - const snapshot = { - id: "00000000-0000-4000-8000-000000000633", - provider: "custom-codex", - cwd, - capabilities: TEST_CAPABILITIES, - config: { provider: "custom-codex", cwd }, - createdAt: new Date("2026-04-30T00:00:00.000Z"), - updatedAt: new Date("2026-04-30T00:00:00.000Z"), - availableModes: [], - currentModeId: null, - pendingPermissions: new Map(), - bufferedPermissionResolutions: new Map(), - inFlightPermissionResponses: new Set(), - pendingReplacement: false, - persistence: { - provider: "custom-codex", - sessionId: "thread-imported", - nativeHandle: "provider-thread-imported", - metadata: { provider: "custom-codex", cwd }, - }, - historyPrimed: true, - lastUserMessageAt: null, - attention: { requiresAttention: false }, - foregroundTurnWaiters: new Set(), - finalizedForegroundTurnIds: new Set(), - unsubscribeSession: null, - internal: false, - labels: {}, - lifecycle: "closed", - session: null, - activeForegroundTurnId: null, - } satisfies ManagedAgent; - const agentManager = { - listAgents: vi.fn(() => []), - subscribe: vi.fn(() => () => {}), - findPersistedAgent: vi.fn().mockResolvedValue({ - provider: "custom-codex", - sessionId: "thread-imported", - cwd, - title: null, - firstPromptPreview: "Trace recent provider sessions", - lastPromptPreview: "Trace recent provider sessions", - lastActivityAt: new Date("2026-04-30T00:00:00.000Z"), - persistence: snapshot.persistence, - }), - resumeAgentFromPersistence: vi.fn().mockResolvedValue(snapshot), - hydrateTimelineFromProvider: vi.fn().mockResolvedValue(undefined), - getTimeline: vi.fn().mockReturnValue(timeline), - setTitle: vi.fn().mockResolvedValue(undefined), - notifyAgentState: vi.fn(), - }; - const agentStorage = { - list: vi.fn().mockResolvedValue([]), - get: vi.fn().mockResolvedValue(null), - }; - const session = createSessionForTest({ messages }); - Object.assign(session, { agentManager, agentStorage }); - - await asSessionInternals(session).handleImportAgentRequest({ - type: "import_agent_request", - providerId: "custom-codex", - providerHandleId: "provider-thread-imported", - cwd, - requestId: "import-thread", - }); - - expect(agentManager.findPersistedAgent).toHaveBeenCalledWith( - "custom-codex", - "provider-thread-imported", - ); - expect(agentManager.hydrateTimelineFromProvider).toHaveBeenCalledWith(snapshot.id); - expect(agentManager.setTitle).toHaveBeenCalledWith( - snapshot.id, - "Trace recent provider sessions", - ); - expect(agentMetadataMocks.scheduleAgentMetadataGeneration).toHaveBeenCalledWith( - expect.objectContaining({ - agentManager, - agentId: snapshot.id, - cwd, - initialPrompt: "Trace recent provider sessions\n\nkeep it tight", - explicitTitle: null, - }), - ); - }); - - test("sets a provisional title and schedules auto-title generation from the first hydrated user message", async () => { - const messages: unknown[] = []; - const cwd = "/tmp/imported-agent"; - const timeline: AgentTimelineItem[] = [ - { type: "user_message", text: "Investigate flaky checkout status\n\ninclude logs" }, - { type: "assistant_message", text: "I will inspect the checkout flow." }, - ]; - const snapshot = { - id: "00000000-0000-4000-8000-000000000632", - provider: "codex", - cwd, - capabilities: TEST_CAPABILITIES, - config: { provider: "codex", cwd }, - createdAt: new Date("2026-04-30T00:00:00.000Z"), - updatedAt: new Date("2026-04-30T00:00:00.000Z"), - availableModes: [], - currentModeId: null, - pendingPermissions: new Map(), - bufferedPermissionResolutions: new Map(), - inFlightPermissionResponses: new Set(), - pendingReplacement: false, - persistence: { - provider: "codex", - sessionId: "thread-imported", - nativeHandle: "thread-imported", - metadata: { provider: "codex", cwd }, - }, - historyPrimed: true, - lastUserMessageAt: null, - attention: { requiresAttention: false }, - foregroundTurnWaiters: new Set(), - finalizedForegroundTurnIds: new Set(), - unsubscribeSession: null, - internal: false, - labels: {}, - lifecycle: "closed", - session: null, - activeForegroundTurnId: null, - } satisfies ManagedAgent; - const agentManager = { - listAgents: vi.fn(() => []), - subscribe: vi.fn(() => () => {}), - findPersistedAgent: vi.fn().mockResolvedValue(null), - resumeAgentFromPersistence: vi.fn().mockResolvedValue(snapshot), - hydrateTimelineFromProvider: vi.fn().mockResolvedValue(undefined), - getTimeline: vi.fn().mockReturnValue(timeline), - setTitle: vi.fn().mockResolvedValue(undefined), - notifyAgentState: vi.fn(), - }; - const agentStorage = { - list: vi.fn().mockResolvedValue([]), - get: vi.fn().mockResolvedValue(null), - }; - const session = createSessionForTest({ messages }); - Object.assign(session, { agentManager, agentStorage }); - - await asSessionInternals(session).handleImportAgentRequest({ - type: "import_agent_request", - provider: "codex", - sessionId: "thread-imported", - cwd, - requestId: "import-thread", - }); - - expect(agentManager.setTitle).toHaveBeenCalledWith( - snapshot.id, - "Investigate flaky checkout status", - ); - expect(agentMetadataMocks.scheduleAgentMetadataGeneration).toHaveBeenCalledWith( - expect.objectContaining({ - agentManager, - agentId: snapshot.id, - cwd, - initialPrompt: "Investigate flaky checkout status\n\ninclude logs", - explicitTitle: null, - }), - ); - }); -}); - describe("session provider refresh cwd routing", () => { test("routes no-cwd provider snapshot refreshes through settings refresh", async () => { const { diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 34ba31db3..ab4b1cd00 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -97,13 +97,13 @@ import type { ManagedAgent, } from "./agent/agent-manager.js"; import { scheduleAgentMetadataGeneration } from "./agent/agent-metadata-generator.js"; +import { resolveCreateAgentTitles } from "./agent/create-agent-title.js"; import { buildStoredAgentPayload, resolveEffectiveThinkingOptionId, resolveStoredAgentPayloadUpdatedAt, toAgentPayload, } from "./agent/agent-projections.js"; -import { MAX_EXPLICIT_AGENT_TITLE_CHARS } from "./agent/agent-title-limits.js"; import { appendTimelineItemIfAgentKnown, emitLiveTimelineItemIfAgentKnown, @@ -128,13 +128,13 @@ import type { AgentRunOptions, AgentSessionConfig, AgentStreamEvent, - AgentTimelineItem, ProviderSnapshotEntry, } from "./agent/agent-sdk-types.js"; import type { StoredAgentRecord } from "./agent/agent-storage.js"; import type { AgentStorage } from "./agent/agent-storage.js"; import { ImportSessionsRequestError, + importProviderSession, listImportableProviderSessions, normalizeImportAgentRequest, } from "./agent/import-sessions.js"; @@ -239,7 +239,6 @@ import { } from "./worktree-session.js"; import { toWorktreeWireError } from "./worktree-errors.js"; -const MAX_INITIAL_AGENT_TITLE_CHARS = Math.min(60, MAX_EXPLICIT_AGENT_TITLE_CHARS); const WORKSPACE_GIT_WATCH_REMOVED_STATE_KEY = "__removed__"; interface ResolveKnownProjectRootForConfigInput { @@ -396,53 +395,6 @@ function beginAgentDeleteIfSupported(agentStorage: AgentStorage, agentId: string } } -function deriveInitialAgentTitle(prompt: string): string | null { - const firstContentLine = prompt - .split(/\r?\n/) - .map((line) => line.trim()) - .find((line) => line.length > 0); - if (!firstContentLine) { - return null; - } - const normalized = firstContentLine.replace(/\s+/g, " ").trim(); - if (!normalized) { - return null; - } - const clamped = normalized.slice(0, MAX_INITIAL_AGENT_TITLE_CHARS).trim(); - return clamped.length > 0 ? clamped : null; -} - -export function resolveCreateAgentTitles(options: { - configTitle?: string | null; - initialPrompt?: string | null; -}): { explicitTitle: string | null; provisionalTitle: string | null } { - const explicitTitle = - typeof options.configTitle === "string" && options.configTitle.trim().length > 0 - ? options.configTitle.trim() - : null; - const trimmedPrompt = options.initialPrompt?.trim(); - const provisionalTitle = - explicitTitle ?? (trimmedPrompt ? deriveInitialAgentTitle(trimmedPrompt) : null); - - return { - explicitTitle, - provisionalTitle, - }; -} - -function getFirstUserMessageText(timeline: readonly AgentTimelineItem[]): string | null { - for (const item of timeline) { - if (item.type !== "user_message") { - continue; - } - const text = item.text.trim(); - if (text) { - return text; - } - } - return null; -} - const FETCH_AGENTS_SORT_KEYS = ["status_priority", "created_at", "updated_at", "title"] as const; export function resolveWaitForFinishError(options: { @@ -672,45 +624,6 @@ class VoiceFeatureUnavailableError extends Error { } } -interface BuildImportPersistenceHandleInput { - provider: AgentProvider; - providerHandleId: string; - cwd?: string; -} - -function buildImportPersistenceHandle( - input: BuildImportPersistenceHandleInput, -): AgentPersistenceHandle { - const cwd = input.cwd ?? process.cwd(); - return { - provider: input.provider, - sessionId: input.providerHandleId, - nativeHandle: input.providerHandleId, - metadata: { - provider: input.provider, - cwd, - }, - }; -} - -function applyImportCwdOverride( - handle: AgentPersistenceHandle, - cwd: string | undefined, -): AgentPersistenceHandle { - if (!cwd) { - return handle; - } - - return { - ...handle, - metadata: { - ...handle.metadata, - provider: handle.provider, - cwd, - }, - }; -} - function convertPCMToWavBuffer( pcmBuffer: Buffer, sampleRate: number, @@ -3199,39 +3112,22 @@ export class Session { }); return; } - const { provider, providerHandleId, cwd, labels, requestId } = normalized; + const { provider, providerHandleId, requestId } = normalized; this.sessionLogger.info( { providerHandleId, provider }, `Importing agent ${providerHandleId} (${provider})`, ); try { - const descriptor = await this.agentManager.findPersistedAgent(provider, providerHandleId); - if (!descriptor && provider === "opencode" && !cwd) { - throw new Error( - "OpenCode sessions require --cwd when the session cannot be found in persisted agents", - ); - } - - const handle = descriptor - ? applyImportCwdOverride(descriptor.persistence, cwd) - : buildImportPersistenceHandle({ provider, providerHandleId, cwd }); - const overrides = cwd ? ({ cwd } satisfies Partial) : undefined; - - await this.unarchiveAgentByHandle(handle); - const snapshot = await this.agentManager.resumeAgentFromPersistence( - handle, - overrides, - undefined, - { - labels, - }, - ); - await unarchiveAgentState(this.agentStorage, this.agentManager, snapshot.id); - await this.agentManager.hydrateTimelineFromProvider(snapshot.id); - await this.applyImportedAgentTitle(snapshot); + const { snapshot, timelineSize } = await importProviderSession({ + request: normalized, + agentManager: this.agentManager, + agentStorage: this.agentStorage, + workspaceGitService: this.workspaceGitService, + paseoHome: this.paseoHome, + logger: this.sessionLogger, + }); await this.forwardAgentUpdate(snapshot); - const timelineSize = this.agentManager.getTimeline(snapshot.id).length; const agentPayload = await this.buildAgentPayload(snapshot); this.emit({ type: "status", @@ -3266,32 +3162,6 @@ export class Session { } } - private async applyImportedAgentTitle(snapshot: ManagedAgent): Promise { - const initialPrompt = getFirstUserMessageText(this.agentManager.getTimeline(snapshot.id)); - if (!initialPrompt) { - return; - } - - const { explicitTitle, provisionalTitle } = resolveCreateAgentTitles({ - configTitle: snapshot.config.title, - initialPrompt, - }); - if (!explicitTitle && provisionalTitle) { - await this.agentManager.setTitle(snapshot.id, provisionalTitle); - } - - scheduleAgentMetadataGeneration({ - agentManager: this.agentManager, - agentId: snapshot.id, - cwd: snapshot.cwd, - workspaceGitService: this.workspaceGitService, - initialPrompt, - explicitTitle, - paseoHome: this.paseoHome, - logger: this.sessionLogger, - }); - } - private async handleRefreshAgentRequest( msg: Extract, ): Promise {