Compare commits

...
Author SHA1 Message Date
paseo-bot[bot] 3c32a43abc fix(client): await handshake before checking import mode support 2026-10-16 21:18:25 +02:00
paseo-bot[bot] 96dd11a053 fix(client): require host support for import modes
Older daemons discard unknown import fields. Reject explicit mode selection before sending the import unless the host advertises support, while preserving imports that omit a mode.
2026-10-16 21:12:12 +02:00
paseo-bot[bot]andKeith Kowal ac65d19254 fix(cli): select permission mode before imported agents run
Carry an explicit import mode into both fresh sessions and archived restores. Update the last observed mode on restore so it cannot override the requested mode.

Co-authored-by: Keith Kowal <keithpk+paseo-dev@kowal.me>
2026-10-16 21:04:30 +02:00
Keith Kowal (paseo-dev) a295eaaf72 Merge remote-tracking branch 'upstream/main' into fix/import-mode
# Conflicts:
#	packages/server/src/server/agent/agent-manager.ts
2026-09-14 17:27:15 -07:00
Keith Kowal c18f13b47c refactor(server): name import parameter types 2026-09-11 15:19:08 -07:00
Keith Kowal 89cb1bf34d fix(cli): support mode when importing agents 2026-09-11 14:57:01 -07:00
12 changed files with 260 additions and 107 deletions
+5
View File
@@ -46,6 +46,11 @@ describe("canonical CLI surface", () => {
expect(help).toContain("--forge <forge>"); expect(help).toContain("--forge <forge>");
}); });
it("offers provider mode selection when importing an agent", () => {
const importCommand = createCli().commands.find((command) => command.name() === "import");
expect(importCommand?.helpInformation()).toContain("--mode <mode>");
});
it("uses --no-wait for execution, --internal for visibility, and reserves detach for ownership", () => { it("uses --no-wait for execution, --internal for visibility, and reserves detach for ownership", () => {
const run = createCli().commands.find((command) => command.name() === "run"); const run = createCli().commands.find((command) => command.name() === "run");
const help = run?.helpInformation(); const help = run?.helpInformation();
@@ -11,6 +11,7 @@ export function addImportOptions(cmd: Command): Command {
.argument("<id>", "Provider session/thread ID to import") .argument("<id>", "Provider session/thread ID to import")
.requiredOption("--provider <provider>", "Agent provider id") .requiredOption("--provider <provider>", "Agent provider id")
.option("--cwd <path>", "Working directory for providers that require it") .option("--cwd <path>", "Working directory for providers that require it")
.option("--mode <mode>", "Provider-specific mode (e.g., plan, default, bypassPermissions)")
.option( .option(
"--label <key=value>", "--label <key=value>",
"Add label(s) to the agent (can be used multiple times)", "Add label(s) to the agent (can be used multiple times)",
@@ -22,6 +23,7 @@ export function addImportOptions(cmd: Command): Command {
export interface AgentImportOptions extends CommandOptions { export interface AgentImportOptions extends CommandOptions {
provider?: string; provider?: string;
cwd?: string; cwd?: string;
mode?: string;
label?: string[]; label?: string[];
host?: string; host?: string;
} }
@@ -119,6 +121,7 @@ export async function runImportCommand(
provider, provider,
sessionId, sessionId,
cwd, cwd,
...(options.mode ? { modeId: options.mode } : {}),
...(Object.keys(labels).length > 0 ? { labels } : {}), ...(Object.keys(labels).length > 0 ? { labels } : {}),
}); });
+135 -83
View File
@@ -5381,99 +5381,151 @@ test("fetches scoped recent provider sessions", async () => {
}); });
}); });
test("imports an agent by provider handle id", async () => { test.each([undefined, false])(
const logger = createMockLogger(); "rejects explicit import modes on hosts without support (%s)",
const mock = createMockTransport(); async (supported) => {
const transport = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_import_mode_compat",
logger: createMockLogger(),
reconnect: { enabled: false },
transportFactory: () => transport.transport,
});
clients.push(client);
const connecting = client.connect();
transport.triggerOpen({ features: { importSessionMode: supported } });
await connecting;
const client = new DaemonClient({ const importing = client.importAgent({
url: "ws://test", provider: "claude",
clientId: "clsk_unit_test", sessionId: "native-session",
logger, modeId: "auto",
reconnect: { enabled: false }, });
transportFactory: () => mock.transport, void importing.catch(() => undefined);
}); expect(transport.sent).toHaveLength(0);
clients.push(client); await expect(importing).rejects.toThrow(
"Update the host to select a mode when importing an agent.",
);
},
);
const connectPromise = client.connect(); test.each([
mock.triggerOpen(); ["full-access", true, true],
await connectPromise; [undefined, undefined, true],
["full-access", true, false],
] as const)(
"imports an agent by provider handle id with mode %s",
async (modeId, supported, connectedBeforeImport) => {
const logger = createMockLogger();
const mock = createMockTransport();
const promise = client.importAgent({ const client = new DaemonClient({
providerId: "custom-codex", url: "ws://test",
providerHandleId: "thread-1", clientId: "clsk_unit_test",
cwd: "/tmp/repo", logger,
}); reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
expect(mock.sent).toHaveLength(1); const connectPromise = client.connect();
const request = JSON.parse(String(mock.sent[0])) as { if (connectedBeforeImport) {
type: "session"; mock.triggerOpen({ features: { importSessionMode: supported } });
message: { await connectPromise;
type: "import_agent_request"; }
requestId: string;
providerId?: string; const promise = client.importAgent({
providerHandleId?: string; providerId: "custom-codex",
sessionId?: string; providerHandleId: "thread-1",
cwd?: string; cwd: "/tmp/repo",
modeId,
});
let importError: unknown;
void promise.catch((error) => {
importError = error;
});
if (!connectedBeforeImport) {
await Promise.resolve();
expect(importError).toBeUndefined();
expect(mock.sent).toHaveLength(0);
mock.triggerOpen({ features: { importSessionMode: supported } });
await connectPromise;
}
await vi.waitFor(() => expect(mock.sent).toHaveLength(1));
const request = JSON.parse(String(mock.sent[0])) as {
type: "session";
message: {
type: "import_agent_request";
requestId: string;
providerId?: string;
providerHandleId?: string;
sessionId?: string;
cwd?: string;
modeId?: string;
};
}; };
}; expect(request.message).toMatchObject({
expect(request.message).toMatchObject({ type: "import_agent_request",
type: "import_agent_request", providerId: "custom-codex",
providerId: "custom-codex", providerHandleId: "thread-1",
providerHandleId: "thread-1", cwd: "/tmp/repo",
cwd: "/tmp/repo", });
}); expect(request.message.modeId).toBe(modeId);
expect(request.message).not.toHaveProperty("sessionId"); expect(request.message).not.toHaveProperty("sessionId");
mock.triggerMessage( mock.triggerMessage(
wrapSessionMessage({ wrapSessionMessage({
type: "status", type: "status",
payload: { payload: {
status: "agent_resumed", status: "agent_resumed",
requestId: request.message.requestId, requestId: request.message.requestId,
agentId: "agent-1", agentId: "agent-1",
timelineSize: 0, timelineSize: 0,
agent: { agent: {
id: "agent-1", id: "agent-1",
provider: "custom-codex",
cwd: "/tmp/repo",
model: null,
features: [],
thinkingOptionId: null,
effectiveThinkingOptionId: null,
createdAt: "2026-04-30T00:00:00.000Z",
updatedAt: "2026-04-30T00:00:00.000Z",
lastUserMessageAt: null,
status: "idle",
capabilities: {
supportsStreaming: false,
supportsSessionPersistence: false,
supportsDynamicModes: false,
supportsMcpServers: false,
supportsReasoningStream: false,
supportsToolInvocations: false,
},
currentModeId: null,
availableModes: [],
pendingPermissions: [],
persistence: {
provider: "custom-codex", provider: "custom-codex",
sessionId: "thread-1", cwd: "/tmp/repo",
nativeHandle: "thread-1", model: null,
features: [],
thinkingOptionId: null,
effectiveThinkingOptionId: null,
createdAt: "2026-04-30T00:00:00.000Z",
updatedAt: "2026-04-30T00:00:00.000Z",
lastUserMessageAt: null,
status: "idle",
capabilities: {
supportsStreaming: false,
supportsSessionPersistence: false,
supportsDynamicModes: false,
supportsMcpServers: false,
supportsReasoningStream: false,
supportsToolInvocations: false,
},
currentModeId: null,
availableModes: [],
pendingPermissions: [],
persistence: {
provider: "custom-codex",
sessionId: "thread-1",
nativeHandle: "thread-1",
},
title: null,
labels: {},
requiresAttention: false,
attentionReason: null,
}, },
title: null,
labels: {},
requiresAttention: false,
attentionReason: null,
}, },
}, }),
}), );
);
await expect(promise).resolves.toMatchObject({ await expect(promise).resolves.toMatchObject({
id: "agent-1", id: "agent-1",
provider: "custom-codex", provider: "custom-codex",
}); });
}); },
);
test("uses server-provided dictation finish timeout budget", async () => { test("uses server-provided dictation finish timeout budget", async () => {
useHeartbeatClock(); useHeartbeatClock();
+9
View File
@@ -202,6 +202,7 @@ interface ImportAgentInputBase {
cwd?: string; cwd?: string;
workspaceId?: string; workspaceId?: string;
labels?: Record<string, string>; labels?: Record<string, string>;
modeId?: string;
} }
export type ImportAgentInput = export type ImportAgentInput =
@@ -3210,6 +3211,13 @@ export class DaemonClient {
} }
async importAgent(input: ImportAgentInput): Promise<AgentSnapshotPayload> { async importAgent(input: ImportAgentInput): Promise<AgentSnapshotPayload> {
// COMPAT(importSessionMode): added after v0.11.4, remove gate after 2027-04-16.
if (input.modeId) {
await this.whenConnected();
if (this.lastServerInfoMessage?.features?.importSessionMode !== true) {
throw new Error("Update the host to select a mode when importing an agent.");
}
}
const requestId = this.createRequestId(); const requestId = this.createRequestId();
const message = SessionInboundMessageSchema.parse({ const message = SessionInboundMessageSchema.parse({
type: "import_agent_request", type: "import_agent_request",
@@ -3220,6 +3228,7 @@ export class DaemonClient {
...(input.cwd ? { cwd: input.cwd } : {}), ...(input.cwd ? { cwd: input.cwd } : {}),
...(input.workspaceId ? { workspaceId: input.workspaceId } : {}), ...(input.workspaceId ? { workspaceId: input.workspaceId } : {}),
...(input.labels && Object.keys(input.labels).length > 0 ? { labels: input.labels } : {}), ...(input.labels && Object.keys(input.labels).length > 0 ? { labels: input.labels } : {}),
...(input.modeId ? { modeId: input.modeId } : {}),
}); });
const status = await this.sendRequest({ const status = await this.sendRequest({
+3
View File
@@ -1831,6 +1831,7 @@ export const ImportAgentRequestMessageSchema = z.object({
cwd: z.string().optional(), cwd: z.string().optional(),
workspaceId: z.string().optional(), workspaceId: z.string().optional(),
labels: z.record(z.string(), z.string()).optional(), labels: z.record(z.string(), z.string()).optional(),
modeId: z.string().optional(),
requestId: z.string(), requestId: z.string(),
}); });
@@ -3714,6 +3715,8 @@ export const ServerInfoStatusPayloadSchema = z
hopliteProvider: z.boolean().optional(), hopliteProvider: z.boolean().optional(),
// COMPAT(importSessionWorkspaceTarget): added in v0.1.110, remove gate after 2027-01-16. // COMPAT(importSessionWorkspaceTarget): added in v0.1.110, remove gate after 2027-01-16.
importSessionWorkspaceTarget: z.boolean().optional(), importSessionWorkspaceTarget: z.boolean().optional(),
// COMPAT(importSessionMode): added after v0.11.4, remove gate after 2027-04-16.
importSessionMode: z.boolean().optional(),
// COMPAT(importSessionSearch): added in v0.8.0, remove gate after 2027-03-02. // COMPAT(importSessionSearch): added in v0.8.0, remove gate after 2027-03-02.
importSessionSearch: z.boolean().optional(), importSessionSearch: z.boolean().optional(),
// COMPAT(forgeProviders): added in v0.2.0-beta.1. Drop the gate after // COMPAT(forgeProviders): added in v0.2.0-beta.1. Drop the gate after
@@ -411,6 +411,7 @@ describe("workspace message schemas", () => {
providerId: "custom-codex", providerId: "custom-codex",
providerHandleId: "thread-1", providerHandleId: "thread-1",
cwd: "/tmp/repo", cwd: "/tmp/repo",
modeId: "full-access",
}); });
const legacyRequest = SessionInboundMessageSchema.parse({ const legacyRequest = SessionInboundMessageSchema.parse({
type: "import_agent_request", type: "import_agent_request",
@@ -426,6 +427,7 @@ describe("workspace message schemas", () => {
providerId: "custom-codex", providerId: "custom-codex",
providerHandleId: "thread-1", providerHandleId: "thread-1",
cwd: "/tmp/repo", cwd: "/tmp/repo",
modeId: "full-access",
}); });
expect(legacyRequest).toEqual({ expect(legacyRequest).toEqual({
type: "import_agent_request", type: "import_agent_request",
@@ -4219,6 +4219,7 @@ test("importProviderSession imports the selected session without listing and pub
class ImportClient extends TestAgentClient { class ImportClient extends TestAgentClient {
listCalls = 0; listCalls = 0;
importInput: unknown = null; importInput: unknown = null;
importConfig: AgentSessionConfig | null = null;
importLaunchContext: AgentLaunchContext | undefined; importLaunchContext: AgentLaunchContext | undefined;
async listImportableSessions() { async listImportableSessions() {
@@ -4228,10 +4229,11 @@ test("importProviderSession imports the selected session without listing and pub
async importSession(input: ImportProviderSessionInput, context: ImportProviderSessionContext) { async importSession(input: ImportProviderSessionInput, context: ImportProviderSessionContext) {
this.importInput = input; this.importInput = input;
this.importConfig = context.storedConfig;
this.importLaunchContext = context.launchContext; this.importLaunchContext = context.launchContext;
return { return {
session, session,
config: { provider: "codex" as const, cwd: workdir }, config: context.storedConfig,
persistence: { persistence: {
provider: "codex" as const, provider: "codex" as const,
sessionId: input.providerHandleId, sessionId: input.providerHandleId,
@@ -4304,10 +4306,12 @@ test("importProviderSession imports the selected session without listing and pub
providerHandleId: "thread-selected", providerHandleId: "thread-selected",
cwd: workdir, cwd: workdir,
workspaceId: "ws-imported", workspaceId: "ws-imported",
modeId: "full-access",
}); });
expect(client.listCalls).toBe(0); expect(client.listCalls).toBe(0);
expect(client.importInput).toEqual({ providerHandleId: "thread-selected", cwd: workdir }); expect(client.importInput).toEqual({ providerHandleId: "thread-selected", cwd: workdir });
expect(client.importConfig?.modeId).toBe("full-access");
expect(client.importLaunchContext).toEqual({ expect(client.importLaunchContext).toEqual({
agentId: imported.id, agentId: imported.id,
env: { env: {
@@ -4316,6 +4320,7 @@ test("importProviderSession imports the selected session without listing and pub
}, },
}); });
expect(imported.lifecycle).toBe("idle"); expect(imported.lifecycle).toBe("idle");
expect(imported.config.modeId).toBe("full-access");
expect(imported.historyPrimed).toBe(true); expect(imported.historyPrimed).toBe(true);
expect(manager.getTimeline(imported.id)).toEqual([ expect(manager.getTimeline(imported.id)).toEqual([
{ type: "user_message", text: "Trace provider imports" }, { type: "user_message", text: "Trace provider imports" },
@@ -8640,6 +8645,7 @@ test("unarchiveSnapshot unarchives native provider storage before clearing archi
const unarchived = await manager.unarchiveSnapshot(agent.id, { const unarchived = await manager.unarchiveSnapshot(agent.id, {
workspaceId: "ws-restored", workspaceId: "ws-restored",
labels: { [PARENT_AGENT_ID_LABEL]: null, source: "reimport" }, labels: { [PARENT_AGENT_ID_LABEL]: null, source: "reimport" },
modeId: "full-access",
}); });
const stored = await storage.get(agent.id); const stored = await storage.get(agent.id);
@@ -8650,8 +8656,52 @@ test("unarchiveSnapshot unarchives native provider storage before clearing archi
expect(stored?.archivedAt).toBeNull(); expect(stored?.archivedAt).toBeNull();
expect(stored?.workspaceId).toBe("ws-restored"); expect(stored?.workspaceId).toBe("ws-restored");
expect(stored?.labels).toEqual({ retained: "yes", source: "reimport" }); expect(stored?.labels).toEqual({ retained: "yes", source: "reimport" });
expect(stored?.config?.modeId).toBe("full-access");
}); });
test.each(["full-access", undefined])(
"unarchiveSnapshot resumes with the requested mode %s or the last observed mode",
async (modeId) => {
const workdir = mkdtempSync(join(tmpdir(), "agent-unarchive-mode-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
class ModeClient extends TestAgentClient {
override async resumeSession(
_handle: AgentPersistenceHandle,
config?: Partial<AgentSessionConfig>,
): Promise<AgentSession> {
return new (class extends TestAgentSession {
override async getCurrentMode() {
return config?.modeId ?? null;
}
})({ provider: "codex", cwd: workdir, ...config });
}
}
const manager = new AgentManager({
clients: { codex: new ModeClient() },
registry: storage,
logger,
});
try {
const agent = await manager.createAgent(
{ provider: "codex", cwd: workdir, modeId: "default" },
undefined,
{ workspaceId: undefined },
);
await manager.archiveAgent(agent.id);
await manager.unarchiveSnapshot(agent.id, { modeId });
const restored = await ensureAgentLoaded(agent.id, {
agentManager: manager,
agentStorage: storage,
logger,
});
expect(restored.currentModeId).toBe(modeId ?? "default");
} finally {
for (const agent of manager.listAgents()) await manager.closeAgent(agent.id);
rmSync(workdir, { recursive: true, force: true });
}
},
);
test("unarchiveSnapshotByHandle unarchives native provider storage for the matched snapshot", async () => { test("unarchiveSnapshotByHandle unarchives native provider storage for the matched snapshot", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-native-unarchive-handle-")); const workdir = mkdtempSync(join(tmpdir(), "agent-manager-native-unarchive-handle-"));
const storagePath = join(workdir, "agents"); const storagePath = join(workdir, "agents");
@@ -182,6 +182,21 @@ interface TimeoutOptions {
onLateError?: (error: unknown) => void; onLateError?: (error: unknown) => void;
} }
interface ImportProviderSessionInput {
provider: AgentProvider;
providerHandleId: string;
cwd: string;
workspaceId: string;
labels?: Record<string, string>;
modeId?: string;
}
interface UnarchiveSnapshotUpdates {
workspaceId?: string;
labels?: AgentLabelPatch;
modeId?: string;
}
function formatProviderList(providers: readonly string[]): string { function formatProviderList(providers: readonly string[]): string {
return providers.length > 0 ? providers.join(", ") : "none"; return providers.length > 0 ? providers.join(", ") : "none";
} }
@@ -1445,23 +1460,13 @@ export class AgentManager {
}); });
} }
importProviderSession(input: { importProviderSession(input: ImportProviderSessionInput): Promise<ManagedAgent> {
provider: AgentProvider;
providerHandleId: string;
cwd: string;
workspaceId: string;
labels?: Record<string, string>;
}): Promise<ManagedAgent> {
return this.trackAgentRegistrationOperation(this.importProviderSessionInternal(input)); return this.trackAgentRegistrationOperation(this.importProviderSessionInternal(input));
} }
private async importProviderSessionInternal(input: { private async importProviderSessionInternal(
provider: AgentProvider; input: ImportProviderSessionInput,
providerHandleId: string; ): Promise<ManagedAgent> {
cwd: string;
workspaceId: string;
labels?: Record<string, string>;
}): Promise<ManagedAgent> {
this.assertAcceptingAgentRegistrations(); this.assertAcceptingAgentRegistrations();
const resolvedAgentId = validateAgentId(this.idFactory(), "importProviderSession"); const resolvedAgentId = validateAgentId(this.idFactory(), "importProviderSession");
this.requireEnabledProvider(input.provider); this.requireEnabledProvider(input.provider);
@@ -1475,6 +1480,7 @@ export class AgentManager {
{ {
provider: input.provider, provider: input.provider,
cwd: input.cwd, cwd: input.cwd,
modeId: input.modeId,
}, },
resolvedAgentId, resolvedAgentId,
); );
@@ -2307,10 +2313,7 @@ export class AgentManager {
return nextRecord; return nextRecord;
} }
async unarchiveSnapshot( async unarchiveSnapshot(agentId: string, updates?: UnarchiveSnapshotUpdates): Promise<boolean> {
agentId: string,
updates?: { workspaceId?: string; labels?: AgentLabelPatch },
): Promise<boolean> {
return this.runLifecycleMutation(agentId, () => return this.runLifecycleMutation(agentId, () =>
this.unarchiveSnapshotUnlocked(agentId, updates), this.unarchiveSnapshotUnlocked(agentId, updates),
); );
@@ -2318,7 +2321,7 @@ export class AgentManager {
private async unarchiveSnapshotUnlocked( private async unarchiveSnapshotUnlocked(
agentId: string, agentId: string,
updates?: { workspaceId?: string; labels?: AgentLabelPatch }, updates?: UnarchiveSnapshotUpdates,
): Promise<boolean> { ): Promise<boolean> {
const registry = this.requireRegistry(); const registry = this.requireRegistry();
const record = await registry.get(agentId); const record = await registry.get(agentId);
@@ -2335,6 +2338,9 @@ export class AgentManager {
...record, ...record,
...(updates?.workspaceId ? { workspaceId: updates.workspaceId } : {}), ...(updates?.workspaceId ? { workspaceId: updates.workspaceId } : {}),
...(updates?.labels ? { labels: applyLabelPatch(record.labels, updates.labels) } : {}), ...(updates?.labels ? { labels: applyLabelPatch(record.labels, updates.labels) } : {}),
...(updates?.modeId
? { config: { ...record.config, modeId: updates.modeId }, lastModeId: updates.modeId }
: {}),
archivedAt: null, archivedAt: null,
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
}); });
@@ -204,7 +204,7 @@ export async function unarchiveAgentState(
_agentStorage: AgentStorage, _agentStorage: AgentStorage,
agentManager: AgentUnarchiveController, agentManager: AgentUnarchiveController,
agentId: string, agentId: string,
updates?: { workspaceId?: string; labels?: Record<string, string | null> }, updates?: { workspaceId?: string; labels?: Record<string, string | null>; modeId?: string },
): Promise<boolean> { ): Promise<boolean> {
const unarchived = await agentManager.unarchiveSnapshot(agentId, updates); const unarchived = await agentManager.unarchiveSnapshot(agentId, updates);
if (!unarchived) return false; if (!unarchived) return false;
@@ -558,11 +558,13 @@ test("normalizeImportAgentRequest accepts new and legacy import handle shapes",
requestId: "new-shape", requestId: "new-shape",
providerId: "custom-codex", providerId: "custom-codex",
providerHandleId: "thread-1", providerHandleId: "thread-1",
modeId: "full-access",
}), }),
).toEqual({ ).toEqual({
requestId: "new-shape", requestId: "new-shape",
provider: "custom-codex", provider: "custom-codex",
providerHandleId: "thread-1", providerHandleId: "thread-1",
modeId: "full-access",
}); });
expect( expect(
@@ -633,7 +635,11 @@ class ProviderImportHarness {
}, },
unarchiveSnapshot: async ( unarchiveSnapshot: async (
agentId: string, agentId: string,
updates?: { workspaceId?: string; labels?: Record<string, string | null> }, updates?: {
workspaceId?: string;
labels?: Record<string, string | null>;
modeId?: string;
},
) => { ) => {
if (this.unarchiveWait) { if (this.unarchiveWait) {
await this.unarchiveWait; await this.unarchiveWait;
@@ -654,6 +660,7 @@ class ProviderImportHarness {
...record, ...record,
workspaceId: updates?.workspaceId ?? record.workspaceId, workspaceId: updates?.workspaceId ?? record.workspaceId,
labels, labels,
config: updates?.modeId ? { ...record.config, modeId: updates.modeId } : record.config,
archivedAt: null, archivedAt: null,
}); });
return true; return true;
@@ -735,7 +742,12 @@ class ProviderImportHarness {
}; };
} }
import(input: { providerHandleId: string; cwd?: string; labels?: Record<string, string> }) { import(input: {
providerHandleId: string;
cwd?: string;
labels?: Record<string, string>;
modeId?: string;
}) {
return importProviderSession({ return importProviderSession({
request: { request: {
requestId: "import-thread", requestId: "import-thread",
@@ -743,6 +755,7 @@ class ProviderImportHarness {
providerHandleId: input.providerHandleId, providerHandleId: input.providerHandleId,
cwd: input.cwd, cwd: input.cwd,
labels: input.labels, labels: input.labels,
modeId: input.modeId,
}, },
workspaceProvisioning: createImportWorkspace("ws-restored"), workspaceProvisioning: createImportWorkspace("ws-restored"),
agentManager: this.manager, agentManager: this.manager,
@@ -752,7 +765,7 @@ class ProviderImportHarness {
} }
} }
test("importProviderSession uses the provider import path with the requested labels", async () => { test("importProviderSession uses the provider import path with the requested labels and mode", async () => {
const harness = await ProviderImportHarness.create(); const harness = await ProviderImportHarness.create();
harness.timeline = [ harness.timeline = [
{ type: "user_message", text: "Trace recent provider sessions" }, { type: "user_message", text: "Trace recent provider sessions" },
@@ -763,6 +776,7 @@ test("importProviderSession uses the provider import path with the requested lab
providerHandleId: "thread-imported", providerHandleId: "thread-imported",
cwd: "/tmp/imported-agent", cwd: "/tmp/imported-agent",
labels: { source: "import" }, labels: { source: "import" },
modeId: "full-access",
}); });
expect(harness.freshImports).toEqual([ expect(harness.freshImports).toEqual([
@@ -772,6 +786,7 @@ test("importProviderSession uses the provider import path with the requested lab
cwd: "/tmp/imported-agent", cwd: "/tmp/imported-agent",
workspaceId: "ws-restored", workspaceId: "ws-restored",
labels: { source: "import" }, labels: { source: "import" },
modeId: "full-access",
}, },
]); ]);
expect(result).toEqual({ expect(result).toEqual({
@@ -813,6 +828,7 @@ test("importProviderSession restores an archived session as the same standalone
providerHandleId: "thread-archived", providerHandleId: "thread-archived",
cwd: harness.snapshot.cwd, cwd: harness.snapshot.cwd,
labels: { source: "reimport" }, labels: { source: "reimport" },
modeId: "full-access",
}); });
expect(result).toEqual({ expect(result).toEqual({
@@ -824,6 +840,7 @@ test("importProviderSession restores an archived session as the same standalone
id: harness.snapshot.id, id: harness.snapshot.id,
workspaceId: "ws-restored", workspaceId: "ws-restored",
labels: { existing: "label", source: "reimport" }, labels: { existing: "label", source: "reimport" },
config: { modeId: "full-access" },
archivedAt: null, archivedAt: null,
}); });
expect((await harness.storage.get(harness.snapshot.id))?.labels).not.toHaveProperty( expect((await harness.storage.get(harness.snapshot.id))?.labels).not.toHaveProperty(
@@ -48,6 +48,7 @@ export interface NormalizedImportAgentRequest {
cwd?: string; cwd?: string;
workspaceId?: string; workspaceId?: string;
labels?: Record<string, string>; labels?: Record<string, string>;
modeId?: string;
requestId: string; requestId: string;
} }
@@ -112,6 +113,7 @@ export function normalizeImportAgentRequest(
cwd: msg.cwd, cwd: msg.cwd,
workspaceId: msg.workspaceId, workspaceId: msg.workspaceId,
labels: msg.labels, labels: msg.labels,
...(msg.modeId ? { modeId: msg.modeId } : {}),
requestId: msg.requestId, requestId: msg.requestId,
}; };
} }
@@ -230,6 +232,7 @@ async function importProviderSessionNow(
await unarchiveAgentState(input.agentStorage, input.agentManager, archivedRecord.id, { await unarchiveAgentState(input.agentStorage, input.agentManager, archivedRecord.id, {
workspaceId, workspaceId,
labels: Object.keys(labelPatch).length > 0 ? labelPatch : undefined, labels: Object.keys(labelPatch).length > 0 ? labelPatch : undefined,
modeId: input.request.modeId,
}); });
try { try {
const snapshot = await ensureAgentLoaded(archivedRecord.id, { const snapshot = await ensureAgentLoaded(archivedRecord.id, {
@@ -253,6 +256,7 @@ async function importProviderSessionNow(
cwd, cwd,
workspaceId, workspaceId,
labels, labels,
...(input.request.modeId ? { modeId: input.request.modeId } : {}),
}); });
await unarchiveAgentState(input.agentStorage, input.agentManager, snapshot.id); await unarchiveAgentState(input.agentStorage, input.agentManager, snapshot.id);
@@ -1918,6 +1918,8 @@ export class VoiceAssistantWebSocketServer {
hopliteProvider: this.providerSnapshotManager.supportsConfigRequiredProvider("hoplite"), hopliteProvider: this.providerSnapshotManager.supportsConfigRequiredProvider("hoplite"),
// COMPAT(importSessionWorkspaceTarget): added in v0.1.110, remove gate after 2027-01-16. // COMPAT(importSessionWorkspaceTarget): added in v0.1.110, remove gate after 2027-01-16.
importSessionWorkspaceTarget: true, importSessionWorkspaceTarget: true,
// COMPAT(importSessionMode): added after v0.11.4, remove gate after 2027-04-16.
importSessionMode: true,
// COMPAT(importSessionSearch): added in v0.7.3, remove gate after 2027-03-02. // COMPAT(importSessionSearch): added in v0.7.3, remove gate after 2027-03-02.
importSessionSearch: true, importSessionSearch: true,
// COMPAT(forgeProviders): added in v0.2.0-beta.1. Drop the gate after // COMPAT(forgeProviders): added in v0.2.0-beta.1. Drop the gate after