refactor(server): extract import session boundary (#854)
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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> = {},
|
||||
): 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",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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<typeof ImportAgentRequestMessageSchema>;
|
||||
|
||||
@@ -49,6 +56,23 @@ export interface ListImportableProviderSessionsResult {
|
||||
filteredAlreadyImportedCount: number;
|
||||
}
|
||||
|
||||
export interface ImportProviderSessionInput {
|
||||
request: NormalizedImportAgentRequest;
|
||||
agentManager: AgentManager;
|
||||
agentStorage: AgentStorage;
|
||||
workspaceGitService?: Pick<WorkspaceGitService, "resolveRepoRoot">;
|
||||
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<ImportProviderSessionResult> {
|
||||
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<AgentSessionConfig>) : 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<void> {
|
||||
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<WorkspaceGitService, "resolveRepoRoot">;
|
||||
paseoHome?: string;
|
||||
logger: Logger;
|
||||
scheduleAgentMetadataGeneration: typeof scheduleAgentMetadataGeneration;
|
||||
}): Promise<void> {
|
||||
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<AgentManager, "listAgents">,
|
||||
agentStorage: Pick<AgentStorage, "list">,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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<unknown>;
|
||||
handleCheckoutPushRequest(params: unknown): Promise<unknown>;
|
||||
handleCheckoutStatusRequest(params: unknown): Promise<unknown>;
|
||||
handleFetchRecentProviderSessions(params: unknown): Promise<unknown>;
|
||||
handleImportAgentRequest(params: unknown): Promise<unknown>;
|
||||
describeWorkspaceRecord(...args: unknown[]): Promise<WorkspaceDescriptorPayload>;
|
||||
describeWorkspaceRecordWithGitData(...args: unknown[]): Promise<WorkspaceDescriptorPayload>;
|
||||
handleValidateBranchRequest(params: unknown): Promise<unknown>;
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<AgentSessionConfig>) : 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<void> {
|
||||
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<SessionInboundMessage, { type: "refresh_agent_request" }>,
|
||||
): Promise<void> {
|
||||
|
||||
Reference in New Issue
Block a user