Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Resolve imported custom sessions from machine-owned configuration

Status: proposed
Translation: current
PR: https://github.com/LodyAI/Lody/pull/689

[中文](2026-09-14-imported-custom-session-launch.zh.md)

## Abstract

Imported custom ACP sessions retain their provider identity but not the agent configuration id that originally launched the provider. Session resume therefore cannot recover the custom command even when the same machine still has exactly one matching configuration. Resolve that unique match from the already-open target-machine Flock, while treating zero or multiple matches as unresolved so no arbitrary executable can be selected.

## Problem

External history import materializes a local session with `cliType`, `agentType`, and `externalHistory`, but no `agentConfigId`. Builtin and registry providers can reconstruct their executable from static provider metadata; a custom provider needs the machine-owned `customAcp`, environment, and runtime override fields. The launch resolver currently returns before consulting any agent configuration when the id is absent, so opening the imported session reaches resume without a custom command and fails with `session_restore_failed`.

History synchronization resolves its launch on the daemon under the target machine's authority. Session resume must preserve that boundary: imported metadata and control-plane callers must not supply an executable path.

## Decision

When an externally imported session has no `agentConfigId` and `cliType` is `custom`, scan the `agentConfig` family in the target machine's already-open Flock and match the session's `cliType` plus `agentType`:

- exactly one match supplies the current `customAcp`, environment, and runtime overrides;
- no match preserves the existing unresolved result;
- multiple matches are ambiguous and also remain unresolved;
- non-imported sessions, sessions with an explicit id, builtin and registry providers, and legacy per-session fields retain their existing resolution paths.

This intentionally follows current machine configuration, so updating the custom command updates later resumes without rewriting imported session metadata. It also avoids widening the session or control-plane schema with executable fields.

## Verification

The focused resolver suite covers a unique match, current-command updates, no match, ambiguity, non-imported legacy preservation, and the existing explicit-id behavior. A live third-party custom ACP process is not launched by this test.
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# 从机器自有配置解析导入的自定义会话启动参数

Status: proposed
Translation: current
PR: https://github.com/LodyAI/Lody/pull/689

[English](2026-09-14-imported-custom-session-launch.md)

## 摘要

导入的自定义 ACP 会话保留了 provider 身份,但没有保留最初启动 provider 的 agent 配置 ID。因此,即使同一台机器仍有且仅有一个匹配配置,会话恢复也无法取回自定义命令。解决方案是从已经打开的目标机器 Flock 中解析唯一匹配;若零个或多个配置匹配,则保持未解析状态,避免任意选择可执行程序。

## 问题

外部历史导入会创建一个带有 `cliType`、`agentType` 和 `externalHistory`,但没有 `agentConfigId` 的本地会话。Builtin 和 registry provider 可以从静态 provider 元数据重建可执行程序;custom provider 则需要机器自有的 `customAcp`、环境变量和 runtime override。当前启动解析器在缺少 ID 时直接返回,不会读取任何 agent 配置,因此打开导入会话时,恢复流程拿不到自定义命令并报 `session_restore_failed`。

历史同步在 daemon 中以目标机器为授权边界解析启动配置。会话恢复也必须保持该边界:导入元数据和 control-plane 调用方都不能提供可执行程序路径。

## 决策

当外部导入的会话没有 `agentConfigId` 且 `cliType` 为 `custom` 时,扫描已经打开的目标机器 Flock 中的 `agentConfig` family,并用会话的 `cliType` 与 `agentType` 匹配:

- 恰好一个匹配时,使用其当前 `customAcp`、环境变量与 runtime override;
- 没有匹配时,保留既有的未解析结果;
- 多个匹配时视为有歧义,同样保持未解析;
- 非导入会话、带明确 ID 的会话、builtin 与 registry provider,以及旧的逐会话字段继续沿用现有解析路径。

该方案有意读取当前机器配置,因此修改自定义命令后,后续恢复会直接使用新值,无需改写导入会话元数据。同时无需给会话或 control-plane schema 增加可执行程序字段。

## 验证

定向 resolver 测试覆盖唯一匹配、当前命令更新、无匹配、歧义、非导入会话的旧字段保留,以及既有的明确 ID 行为。测试没有启动真实的第三方 custom ACP 进程。
113 changes: 113 additions & 0 deletions apps/cli/src/session/session-launch-config-resolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,120 @@ const sessionMeta = (): SessionMeta =>
agentConfigId,
}) as SessionMeta;

const importedCustomSessionMeta = (): SessionMeta =>
({
id: sessionId,
machineId,
cliType: 'custom',
agentType: 'history-agent',
origin: 'external-acp',
}) as SessionMeta;

function customAgentConfig(input: {
id: string;
agentType?: string;
command: string;
}): Parameters<FakeMachineFlock['rows']['push']>[0] {
return {
key: machineFlockKeys.agentConfig(input.id as AgentConfigId),
value: {
id: input.id,
machineId,
name: input.id,
cliType: 'custom',
agentType: input.agentType ?? 'history-agent',
customAcp: { command: input.command },
env: { HISTORY_TOKEN: input.id },
prompt: '',
},
} as Parameters<FakeMachineFlock['rows']['push']>[0];
}

describe('resolveSessionLaunchConfig', () => {
it('uses the unique current custom config for an imported session without a config id', () => {
const flock = new FakeMachineFlock();
const config = customAgentConfig({ id: 'custom-1', command: 'history-agent-v1' });
flock.rows.push(config);

expect(
readMachineSessionLaunchSnapshotFromFlock({
flock,
sessionId,
sessionMeta: importedCustomSessionMeta(),
}).resolution
).toEqual({
source: 'agent-config',
config: {
customAcp: { command: 'history-agent-v1' },
env: { HISTORY_TOKEN: 'custom-1' },
},
});

config.value = {
...(config.value as object),
customAcp: { command: 'history-agent-v2' },
};
expect(
readMachineSessionLaunchSnapshotFromFlock({
flock,
sessionId,
sessionMeta: importedCustomSessionMeta(),
}).resolution.config?.customAcp
).toEqual({ command: 'history-agent-v2' });
});

it('fails closed when an imported custom session has no matching config', () => {
const flock = new FakeMachineFlock();
flock.rows.push(
customAgentConfig({ id: 'other-agent', agentType: 'other', command: 'other-agent' })
);

expect(
readMachineSessionLaunchSnapshotFromFlock({
flock,
sessionId,
sessionMeta: importedCustomSessionMeta(),
}).resolution
).toEqual({ source: 'none', config: undefined });
});

it('fails closed when multiple configs match an imported custom session', () => {
const flock = new FakeMachineFlock();
flock.rows.push(
customAgentConfig({ id: 'custom-1', command: 'history-agent-v1' }),
customAgentConfig({ id: 'custom-2', command: 'history-agent-v2' })
);

expect(
readMachineSessionLaunchSnapshotFromFlock({
flock,
sessionId,
sessionMeta: importedCustomSessionMeta(),
}).resolution
).toEqual({ source: 'none', config: undefined });
});

it('preserves legacy launch fields for a non-imported custom session without a config id', () => {
const flock = new FakeMachineFlock();
flock.rows.push(customAgentConfig({ id: 'custom-1', command: 'current-command' }));
const meta = {
...importedCustomSessionMeta(),
origin: 'lody',
customAcp: { command: 'legacy-command' },
env: { HISTORY_TOKEN: 'legacy' },
} as SessionMeta;

expect(
readMachineSessionLaunchSnapshotFromFlock({ flock, sessionId, sessionMeta: meta }).resolution
).toEqual({
source: 'legacy-session',
config: {
customAcp: { command: 'legacy-command' },
env: { HISTORY_TOKEN: 'legacy' },
},
});
});

it('reads the current launch config synchronously from an existing Flock handle', () => {
const flock = new FakeMachineFlock();
flock.rows.push({
Expand Down
36 changes: 29 additions & 7 deletions apps/cli/src/session/session-launch-config-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,26 @@ export type MachineSessionLaunchSnapshot = {
resolution: SessionLaunchConfigResolution;
};

function resolveImportedCustomAgentConfig(
sessionMeta: SessionMeta | undefined,
agentConfigs: Record<AgentConfigId, AgentConfigMeta>
): AgentConfigMeta | null {
if (
sessionMeta?.agentConfigId ||
sessionMeta?.origin !== 'external-acp' ||
sessionMeta.cliType !== 'custom'
) {
return null;
}
const matches = Object.values(agentConfigs).filter(
(config) =>
config.machineId === sessionMeta.machineId &&
config.cliType === sessionMeta.cliType &&
config.agentType === sessionMeta.agentType
);
return matches.length === 1 ? (matches[0] ?? null) : null;
}

function resolveSessionLaunchConfigFromSources(input: {
legacy: SessionLaunchConfig | undefined;
agentConfig: AgentConfigLaunchFields | null | undefined;
Expand Down Expand Up @@ -84,14 +104,20 @@ export function readMachineSessionLaunchSnapshotFromFlock(input: {
machineFlockKeys.sessionLaunchConfig(input.sessionId),
...(agentConfigId ? [machineFlockKeys.agentConfig(agentConfigId)] : []),
],
...(!agentConfigId &&
input.sessionMeta?.origin === 'external-acp' &&
input.sessionMeta.cliType === 'custom'
? { families: ['agentConfig'] as const }
: {}),
});
const legacy = mergeSessionLaunchConfig(
getMachineFlockSessionLaunchConfig(rows, input.sessionId),
metaLegacy
);
const agentConfigs = getMachineFlockAgentConfigs(rows);
const agentConfig = agentConfigId
? (getMachineFlockAgentConfigs(rows)[agentConfigId] ?? null)
: null;
? (agentConfigs[agentConfigId] ?? null)
: resolveImportedCustomAgentConfig(input.sessionMeta, agentConfigs);
return {
legacy,
agentConfig,
Expand Down Expand Up @@ -177,11 +203,7 @@ export async function resolveSessionLaunchConfig(input: {
};
}

if (!input.sessionMeta?.agentConfigId) {
return snapshot.resolution;
}

if (snapshot.agentConfig) {
if (!input.sessionMeta?.agentConfigId || snapshot.agentConfig) {
return snapshot.resolution;
}

Expand Down