diff --git a/packages/server/src/server/session.test.ts b/packages/server/src/server/session.test.ts index 046f2b1e8..62cd98148 100644 --- a/packages/server/src/server/session.test.ts +++ b/packages/server/src/server/session.test.ts @@ -12,9 +12,11 @@ import type { AgentClient, AgentMode, AgentModelDefinition, + AgentTimelineItem, ListModesOptions, ListModelsOptions, } 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"; @@ -27,6 +29,7 @@ interface SessionHandlerInternals { handleCheckoutPullRequest(params: unknown): Promise; handleCheckoutPushRequest(params: unknown): Promise; handleCheckoutStatusRequest(params: unknown): Promise; + handleImportAgentRequest(params: unknown): Promise; describeWorkspaceRecord(...args: unknown[]): Promise; describeWorkspaceRecordWithGitData(...args: unknown[]): Promise; handleValidateBranchRequest(params: unknown): Promise; @@ -63,6 +66,10 @@ const agentResponseMocks = vi.hoisted(() => ({ generateStructuredAgentResponseWithFallback: vi.fn(), })); +const agentMetadataMocks = vi.hoisted(() => ({ + scheduleAgentMetadataGeneration: vi.fn(), +})); + const spawnMocks = vi.hoisted(() => ({ execCommand: vi.fn(), spawnWorkspaceScript: vi.fn(), @@ -172,6 +179,14 @@ vi.mock("./agent/agent-response-loop.js", async (importOriginal) => { }; }); +vi.mock("./agent/agent-metadata-generator.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + scheduleAgentMetadataGeneration: agentMetadataMocks.scheduleAgentMetadataGeneration, + }; +}); + vi.mock("./worktree-bootstrap.js", async (importOriginal) => { const actual = await importOriginal(); return { @@ -570,6 +585,87 @@ afterEach(() => { vi.clearAllMocks(); }); +describe("session agent import", () => { + 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 PR status payload normalization", () => { test("includes repository identity fields on the wire", () => { const payload = normalizeCheckoutPrStatusPayload({ diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index cf7d3c026..ff02930b2 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -133,6 +133,7 @@ import type { AgentRunOptions, AgentSessionConfig, AgentStreamEvent, + AgentTimelineItem, ProviderSnapshotEntry, } from "./agent/agent-sdk-types.js"; import type { StoredAgentRecord } from "./agent/agent-storage.js"; @@ -417,6 +418,19 @@ export function resolveCreateAgentTitles(options: { }; } +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; +} + function parseFetchWorkspacesCursorSort(raw: unknown[]): FetchWorkspacesRequestSort[] { const cursorSort: FetchWorkspacesRequestSort[] = []; for (const item of raw) { @@ -3329,6 +3343,7 @@ export class Session { ); await unarchiveAgentState(this.agentStorage, this.agentManager, snapshot.id); await this.agentManager.hydrateTimelineFromProvider(snapshot.id); + await this.applyImportedAgentTitle(snapshot); await this.forwardAgentUpdate(snapshot); const timelineSize = this.agentManager.getTimeline(snapshot.id).length; const agentPayload = await this.buildAgentPayload(snapshot); @@ -3365,6 +3380,31 @@ 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, + initialPrompt, + explicitTitle, + paseoHome: this.paseoHome, + logger: this.sessionLogger, + }); + } + private async handleRefreshAgentRequest( msg: Extract, ): Promise { diff --git a/packages/server/src/server/test-utils/fake-agent-client.ts b/packages/server/src/server/test-utils/fake-agent-client.ts index cf0161f7d..e9e149949 100644 --- a/packages/server/src/server/test-utils/fake-agent-client.ts +++ b/packages/server/src/server/test-utils/fake-agent-client.ts @@ -418,7 +418,7 @@ class FakeAgentSession implements AgentSession { private async emitSlashCommandTurn(slashCommand: { commandName: string; - args: string; + args?: string; }): Promise { const threadStarted: AgentStreamEvent = { type: "thread_started",