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>");
});
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", () => {
const run = createCli().commands.find((command) => command.name() === "run");
const help = run?.helpInformation();
@@ -11,6 +11,7 @@ export function addImportOptions(cmd: Command): Command {
.argument("<id>", "Provider session/thread ID to import")
.requiredOption("--provider <provider>", "Agent provider id")
.option("--cwd <path>", "Working directory for providers that require it")
.option("--mode <mode>", "Provider-specific mode (e.g., plan, default, bypassPermissions)")
.option(
"--label <key=value>",
"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 {
provider?: string;
cwd?: string;
mode?: string;
label?: string[];
host?: string;
}
@@ -119,6 +121,7 @@ export async function runImportCommand(
provider,
sessionId,
cwd,
...(options.mode ? { modeId: options.mode } : {}),
...(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 () => {
const logger = createMockLogger();
const mock = createMockTransport();
test.each([undefined, false])(
"rejects explicit import modes on hosts without support (%s)",
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({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const importing = client.importAgent({
provider: "claude",
sessionId: "native-session",
modeId: "auto",
});
void importing.catch(() => undefined);
expect(transport.sent).toHaveLength(0);
await expect(importing).rejects.toThrow(
"Update the host to select a mode when importing an agent.",
);
},
);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
test.each([
["full-access", true, true],
[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({
providerId: "custom-codex",
providerHandleId: "thread-1",
cwd: "/tmp/repo",
});
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
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;
const connectPromise = client.connect();
if (connectedBeforeImport) {
mock.triggerOpen({ features: { importSessionMode: supported } });
await connectPromise;
}
const promise = client.importAgent({
providerId: "custom-codex",
providerHandleId: "thread-1",
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({
type: "import_agent_request",
providerId: "custom-codex",
providerHandleId: "thread-1",
cwd: "/tmp/repo",
});
expect(request.message).not.toHaveProperty("sessionId");
expect(request.message).toMatchObject({
type: "import_agent_request",
providerId: "custom-codex",
providerHandleId: "thread-1",
cwd: "/tmp/repo",
});
expect(request.message.modeId).toBe(modeId);
expect(request.message).not.toHaveProperty("sessionId");
mock.triggerMessage(
wrapSessionMessage({
type: "status",
payload: {
status: "agent_resumed",
requestId: request.message.requestId,
agentId: "agent-1",
timelineSize: 0,
agent: {
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: {
mock.triggerMessage(
wrapSessionMessage({
type: "status",
payload: {
status: "agent_resumed",
requestId: request.message.requestId,
agentId: "agent-1",
timelineSize: 0,
agent: {
id: "agent-1",
provider: "custom-codex",
sessionId: "thread-1",
nativeHandle: "thread-1",
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",
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({
id: "agent-1",
provider: "custom-codex",
});
});
await expect(promise).resolves.toMatchObject({
id: "agent-1",
provider: "custom-codex",
});
},
);
test("uses server-provided dictation finish timeout budget", async () => {
useHeartbeatClock();
+9
View File
@@ -202,6 +202,7 @@ interface ImportAgentInputBase {
cwd?: string;
workspaceId?: string;
labels?: Record<string, string>;
modeId?: string;
}
export type ImportAgentInput =
@@ -3210,6 +3211,13 @@ export class DaemonClient {
}
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 message = SessionInboundMessageSchema.parse({
type: "import_agent_request",
@@ -3220,6 +3228,7 @@ export class DaemonClient {
...(input.cwd ? { cwd: input.cwd } : {}),
...(input.workspaceId ? { workspaceId: input.workspaceId } : {}),
...(input.labels && Object.keys(input.labels).length > 0 ? { labels: input.labels } : {}),
...(input.modeId ? { modeId: input.modeId } : {}),
});
const status = await this.sendRequest({
+3
View File
@@ -1831,6 +1831,7 @@ export const ImportAgentRequestMessageSchema = z.object({
cwd: z.string().optional(),
workspaceId: z.string().optional(),
labels: z.record(z.string(), z.string()).optional(),
modeId: z.string().optional(),
requestId: z.string(),
});
@@ -3714,6 +3715,8 @@ export const ServerInfoStatusPayloadSchema = z
hopliteProvider: z.boolean().optional(),
// COMPAT(importSessionWorkspaceTarget): added in v0.1.110, remove gate after 2027-01-16.
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.
importSessionSearch: z.boolean().optional(),
// COMPAT(forgeProviders): added in v0.2.0-beta.1. Drop the gate after
@@ -411,6 +411,7 @@ describe("workspace message schemas", () => {
providerId: "custom-codex",
providerHandleId: "thread-1",
cwd: "/tmp/repo",
modeId: "full-access",
});
const legacyRequest = SessionInboundMessageSchema.parse({
type: "import_agent_request",
@@ -426,6 +427,7 @@ describe("workspace message schemas", () => {
providerId: "custom-codex",
providerHandleId: "thread-1",
cwd: "/tmp/repo",
modeId: "full-access",
});
expect(legacyRequest).toEqual({
type: "import_agent_request",
@@ -4219,6 +4219,7 @@ test("importProviderSession imports the selected session without listing and pub
class ImportClient extends TestAgentClient {
listCalls = 0;
importInput: unknown = null;
importConfig: AgentSessionConfig | null = null;
importLaunchContext: AgentLaunchContext | undefined;
async listImportableSessions() {
@@ -4228,10 +4229,11 @@ test("importProviderSession imports the selected session without listing and pub
async importSession(input: ImportProviderSessionInput, context: ImportProviderSessionContext) {
this.importInput = input;
this.importConfig = context.storedConfig;
this.importLaunchContext = context.launchContext;
return {
session,
config: { provider: "codex" as const, cwd: workdir },
config: context.storedConfig,
persistence: {
provider: "codex" as const,
sessionId: input.providerHandleId,
@@ -4304,10 +4306,12 @@ test("importProviderSession imports the selected session without listing and pub
providerHandleId: "thread-selected",
cwd: workdir,
workspaceId: "ws-imported",
modeId: "full-access",
});
expect(client.listCalls).toBe(0);
expect(client.importInput).toEqual({ providerHandleId: "thread-selected", cwd: workdir });
expect(client.importConfig?.modeId).toBe("full-access");
expect(client.importLaunchContext).toEqual({
agentId: imported.id,
env: {
@@ -4316,6 +4320,7 @@ test("importProviderSession imports the selected session without listing and pub
},
});
expect(imported.lifecycle).toBe("idle");
expect(imported.config.modeId).toBe("full-access");
expect(imported.historyPrimed).toBe(true);
expect(manager.getTimeline(imported.id)).toEqual([
{ 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, {
workspaceId: "ws-restored",
labels: { [PARENT_AGENT_ID_LABEL]: null, source: "reimport" },
modeId: "full-access",
});
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?.workspaceId).toBe("ws-restored");
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 () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-native-unarchive-handle-"));
const storagePath = join(workdir, "agents");
@@ -182,6 +182,21 @@ interface TimeoutOptions {
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 {
return providers.length > 0 ? providers.join(", ") : "none";
}
@@ -1445,23 +1460,13 @@ export class AgentManager {
});
}
importProviderSession(input: {
provider: AgentProvider;
providerHandleId: string;
cwd: string;
workspaceId: string;
labels?: Record<string, string>;
}): Promise<ManagedAgent> {
importProviderSession(input: ImportProviderSessionInput): Promise<ManagedAgent> {
return this.trackAgentRegistrationOperation(this.importProviderSessionInternal(input));
}
private async importProviderSessionInternal(input: {
provider: AgentProvider;
providerHandleId: string;
cwd: string;
workspaceId: string;
labels?: Record<string, string>;
}): Promise<ManagedAgent> {
private async importProviderSessionInternal(
input: ImportProviderSessionInput,
): Promise<ManagedAgent> {
this.assertAcceptingAgentRegistrations();
const resolvedAgentId = validateAgentId(this.idFactory(), "importProviderSession");
this.requireEnabledProvider(input.provider);
@@ -1475,6 +1480,7 @@ export class AgentManager {
{
provider: input.provider,
cwd: input.cwd,
modeId: input.modeId,
},
resolvedAgentId,
);
@@ -2307,10 +2313,7 @@ export class AgentManager {
return nextRecord;
}
async unarchiveSnapshot(
agentId: string,
updates?: { workspaceId?: string; labels?: AgentLabelPatch },
): Promise<boolean> {
async unarchiveSnapshot(agentId: string, updates?: UnarchiveSnapshotUpdates): Promise<boolean> {
return this.runLifecycleMutation(agentId, () =>
this.unarchiveSnapshotUnlocked(agentId, updates),
);
@@ -2318,7 +2321,7 @@ export class AgentManager {
private async unarchiveSnapshotUnlocked(
agentId: string,
updates?: { workspaceId?: string; labels?: AgentLabelPatch },
updates?: UnarchiveSnapshotUpdates,
): Promise<boolean> {
const registry = this.requireRegistry();
const record = await registry.get(agentId);
@@ -2335,6 +2338,9 @@ export class AgentManager {
...record,
...(updates?.workspaceId ? { workspaceId: updates.workspaceId } : {}),
...(updates?.labels ? { labels: applyLabelPatch(record.labels, updates.labels) } : {}),
...(updates?.modeId
? { config: { ...record.config, modeId: updates.modeId }, lastModeId: updates.modeId }
: {}),
archivedAt: null,
updatedAt: new Date().toISOString(),
});
@@ -204,7 +204,7 @@ export async function unarchiveAgentState(
_agentStorage: AgentStorage,
agentManager: AgentUnarchiveController,
agentId: string,
updates?: { workspaceId?: string; labels?: Record<string, string | null> },
updates?: { workspaceId?: string; labels?: Record<string, string | null>; modeId?: string },
): Promise<boolean> {
const unarchived = await agentManager.unarchiveSnapshot(agentId, updates);
if (!unarchived) return false;
@@ -558,11 +558,13 @@ test("normalizeImportAgentRequest accepts new and legacy import handle shapes",
requestId: "new-shape",
providerId: "custom-codex",
providerHandleId: "thread-1",
modeId: "full-access",
}),
).toEqual({
requestId: "new-shape",
provider: "custom-codex",
providerHandleId: "thread-1",
modeId: "full-access",
});
expect(
@@ -633,7 +635,11 @@ class ProviderImportHarness {
},
unarchiveSnapshot: async (
agentId: string,
updates?: { workspaceId?: string; labels?: Record<string, string | null> },
updates?: {
workspaceId?: string;
labels?: Record<string, string | null>;
modeId?: string;
},
) => {
if (this.unarchiveWait) {
await this.unarchiveWait;
@@ -654,6 +660,7 @@ class ProviderImportHarness {
...record,
workspaceId: updates?.workspaceId ?? record.workspaceId,
labels,
config: updates?.modeId ? { ...record.config, modeId: updates.modeId } : record.config,
archivedAt: null,
});
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({
request: {
requestId: "import-thread",
@@ -743,6 +755,7 @@ class ProviderImportHarness {
providerHandleId: input.providerHandleId,
cwd: input.cwd,
labels: input.labels,
modeId: input.modeId,
},
workspaceProvisioning: createImportWorkspace("ws-restored"),
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();
harness.timeline = [
{ 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",
cwd: "/tmp/imported-agent",
labels: { source: "import" },
modeId: "full-access",
});
expect(harness.freshImports).toEqual([
@@ -772,6 +786,7 @@ test("importProviderSession uses the provider import path with the requested lab
cwd: "/tmp/imported-agent",
workspaceId: "ws-restored",
labels: { source: "import" },
modeId: "full-access",
},
]);
expect(result).toEqual({
@@ -813,6 +828,7 @@ test("importProviderSession restores an archived session as the same standalone
providerHandleId: "thread-archived",
cwd: harness.snapshot.cwd,
labels: { source: "reimport" },
modeId: "full-access",
});
expect(result).toEqual({
@@ -824,6 +840,7 @@ test("importProviderSession restores an archived session as the same standalone
id: harness.snapshot.id,
workspaceId: "ws-restored",
labels: { existing: "label", source: "reimport" },
config: { modeId: "full-access" },
archivedAt: null,
});
expect((await harness.storage.get(harness.snapshot.id))?.labels).not.toHaveProperty(
@@ -48,6 +48,7 @@ export interface NormalizedImportAgentRequest {
cwd?: string;
workspaceId?: string;
labels?: Record<string, string>;
modeId?: string;
requestId: string;
}
@@ -112,6 +113,7 @@ export function normalizeImportAgentRequest(
cwd: msg.cwd,
workspaceId: msg.workspaceId,
labels: msg.labels,
...(msg.modeId ? { modeId: msg.modeId } : {}),
requestId: msg.requestId,
};
}
@@ -230,6 +232,7 @@ async function importProviderSessionNow(
await unarchiveAgentState(input.agentStorage, input.agentManager, archivedRecord.id, {
workspaceId,
labels: Object.keys(labelPatch).length > 0 ? labelPatch : undefined,
modeId: input.request.modeId,
});
try {
const snapshot = await ensureAgentLoaded(archivedRecord.id, {
@@ -253,6 +256,7 @@ async function importProviderSessionNow(
cwd,
workspaceId,
labels,
...(input.request.modeId ? { modeId: input.request.modeId } : {}),
});
await unarchiveAgentState(input.agentStorage, input.agentManager, snapshot.id);
@@ -1918,6 +1918,8 @@ export class VoiceAssistantWebSocketServer {
hopliteProvider: this.providerSnapshotManager.supportsConfigRequiredProvider("hoplite"),
// COMPAT(importSessionWorkspaceTarget): added in v0.1.110, remove gate after 2027-01-16.
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.
importSessionSearch: true,
// COMPAT(forgeProviders): added in v0.2.0-beta.1. Drop the gate after