fix(opencode): switch session models without restart
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
# Task: Implement session model parity and partner hot reload
|
||||
|
||||
## Identity
|
||||
|
||||
- Task ID: 20260820-session-model-agent-hotfix-6e4c9a2f
|
||||
- Mode: Feature
|
||||
- Branch: codex/20260820-session-model-agent-hotfix-6e4c9a2f-session-model-agent-hotfix
|
||||
- Worktree: D:\Datas\OthersProjects\makelore-session-model-agent-hotfix-6e4c9a2f
|
||||
- Base commit: 1c6b00443631ad41929e79943bbea9bc7126b563
|
||||
- Owner: codex
|
||||
- Status: Ready for Integration
|
||||
|
||||
## Scope
|
||||
|
||||
- Make the AI programming chat model selector and `/models` / `/model` commands switch the active OpenCode Session model through OpenCode's native session API.
|
||||
- Preserve the Session-selected model across later prompts, project commands, compaction, Session switching, and Session-list hydration without rewriting partner or Provider configuration.
|
||||
- Allow a newly created unique-id project Agent to execute in the current fresh runtime generation once the live Agent registry exposes that id.
|
||||
- Keep same-id Agent edits, deletion/recreation, attached or unknown runtime generations, and missing live ids fail-closed until a fresh generation rollover.
|
||||
- Add focused unit and Electron E2E coverage and synchronize README product behavior.
|
||||
|
||||
## Intent And Constraints
|
||||
|
||||
- Page model switching must have the same OpenCode Session semantics as the runtime model command and must not trigger runtime restart, reload, dispose, or Provider persistence.
|
||||
- Partner `model` remains the default for a new Session; the active model of an existing Session is owned by OpenCode.
|
||||
- Ordinary prompts and project commands must not resend a model override that would undo a Session switch.
|
||||
- A live same-id registry entry is not evidence that edited content was reloaded. Hot acceptance is restricted to an id never seen in the current generation; removed ids remain known and cannot be recreated hot.
|
||||
- Preserve manager-to-project lock ordering, bounded runtime acceptance, attached/unknown fail-closed behavior, and all existing project Agent prompt/template structures.
|
||||
- Work remained isolated in the claimed feature worktree; canonical `.project-docs` files were not modified.
|
||||
|
||||
## Outcome
|
||||
|
||||
- Added an OpenCode client adapter for `POST /api/session/{sessionID}/model`, a Main-owned Host API route, and Renderer Store state/action for per-Session model switching.
|
||||
- Replaced the read-only composer model label with a Session selector. The selector and `/models` / `/model` share one action; successful switches update only Session state and can be hydrated from OpenCode Session metadata.
|
||||
- Removed automatic model overrides from ordinary prompt and project-command submissions. Compaction and attachment capability projection now use the active Session model.
|
||||
- Changed project Agent readiness from a whole-manifest execution gate to per-Agent desired/applied tracking. Existing unchanged Agents remain usable when a peer is added, and a new unique id becomes usable only after it appears in the live registry.
|
||||
- Preserved sticky pending behavior for same-id edits, missing live Agents, attached/unknown generations, and delete/recreate of an id. A new fresh runtime generation remains the authoritative reset.
|
||||
- Updated README and the multi-chat Electron E2E fixture to document and exercise Session switching without prompt model overwrite.
|
||||
|
||||
## Verification
|
||||
|
||||
- `corepack pnpm run typecheck` — passed.
|
||||
- `corepack pnpm run lint:check` — passed with 0 errors and 6 pre-existing warnings in unrelated files.
|
||||
- Focused unit suite for Agent runtime, routes, client, Store, command registry/hook, and ChatPanel — 405 tests passed.
|
||||
- `corepack pnpm test` — 176 files, 2068 tests passed.
|
||||
- `corepack pnpm run build:vite` — passed; only existing chunk-size/dynamic-import warnings were reported.
|
||||
- `corepack pnpm run test:e2e -- tests/e2e/opencode-multichat-runtime.spec.ts` — 1 Electron E2E passed.
|
||||
- `git diff --check` — passed; only configured LF-to-CRLF notices were emitted.
|
||||
|
||||
## Follow-ups
|
||||
|
||||
- Integration should reconcile the canonical OpenCode Agent readiness and Session model ownership descriptions listed below.
|
||||
|
||||
## Promotion Candidates
|
||||
|
||||
### Per-Agent runtime acceptance
|
||||
|
||||
- Target canonical documents: `.project-docs/20-architecture/module-map.md` and `.project-docs/20-architecture/data-flow.md`.
|
||||
- Proposal: replace the whole-manifest-only Agent readiness description with per-Agent desired/applied state. A unique id first seen in an owned fresh generation may be accepted after live registry discovery; same-id edits and delete/recreate remain pending until generation rollover.
|
||||
- Evidence: `electron/opencode/project-agent-runtime.ts`, `tests/unit/project-agent-runtime.test.ts`, and the Agent readiness cases in `tests/unit/opencode-routes.test.ts`.
|
||||
- Future impact: future Agent configuration work must preserve the distinction between identity discovery and same-id content reload, and must not restore a whole-manifest gate that blocks existing Agents when adding a peer.
|
||||
- Semantic conflicts: canonical docs currently describe a whole desired/applied fingerprint and state that live id alone cannot prove content. The proposal refines this by using first-seen unique identity as the safe hot-add boundary while retaining the same-id restriction.
|
||||
- Human confirmation required: no; this behavior was explicitly requested and is covered by regression tests.
|
||||
|
||||
### OpenCode Session model ownership
|
||||
|
||||
- Target canonical documents: `.project-docs/20-architecture/module-map.md`, `.project-docs/20-architecture/data-flow.md`, and `.project-docs/40-domain/business-rules.md`.
|
||||
- Proposal: document partner model as a new-Session default and OpenCode Session model as the authority for subsequent turns. Page selection and `/models` / `/model` call the native Session switch endpoint and ordinary prompts/commands omit model overrides.
|
||||
- Evidence: `electron/opencode/client.ts`, `electron/api/routes/opencode.ts`, `src/stores/opencode.ts`, `src/pages/Chat/OpencodeChatPanel.tsx`, focused unit tests, and `tests/e2e/opencode-multichat-runtime.spec.ts`.
|
||||
- Future impact: Provider configuration and partner maintenance must remain separate from transient per-Session model selection; future prompt paths must not reintroduce implicit model fallback overrides.
|
||||
- Semantic conflicts: none known beyond superseding the previous chat-composer read-only model behavior.
|
||||
- Human confirmation required: no; the user explicitly approved parity with OpenCode model switching.
|
||||
@@ -121,7 +121,7 @@ Windows 打包脚本会先准备目标架构所需的 Python、uv 与 OpenCode
|
||||
### 项目伙伴与会话
|
||||
|
||||
- 新项目默认没有伙伴或 Agent;用户在项目内手动创建伙伴时必须填写名称、预设头像或本地头像、职责和精确的 `provider/model`。本地头像会自动裁剪为 256×256,并优先压缩为 WebP 后随项目配置保存;提示词与 Skill 属于后置高级设置。
|
||||
- 项目配置页的新增与已有伙伴维护统一使用居中弹窗;编辑保存先更新当前页面草稿,点击底部“保存项目配置”后统一持久化。模型资源抽屉只展示已配置模型、提供方和文本/多模态能力,不提供项目级选择;模型切换只能在伙伴维护弹窗中完成,聊天输入区仅展示当前伙伴模型。
|
||||
- 项目配置页的新增与已有伙伴维护统一使用居中弹窗;编辑保存先更新当前页面草稿,点击底部“保存项目配置”后统一持久化。模型资源抽屉只展示已配置模型、提供方和文本/多模态能力,不提供项目级选择。伙伴模型是新会话默认值;聊天输入区的模型选择器以及 `/models`、`/model` 只切换当前 OpenCode Session 的后续轮次,不改写伙伴或 Provider 配置,也不要求重启运行时。
|
||||
- 读取旧项目时会把仍缺少模型的伙伴从兼容保留的 `defaultModel` 自动迁移到伙伴自身配置,之后运行时只认伙伴模型。
|
||||
- 技能资源入口使用扳手图标;点击已安装 Skill 后先展示其目录结构,再展示主文件 `SKILL.md` 原文,并支持返回技能列表。
|
||||
- 一个伙伴可以拥有多条互相独立的 OpenCode Session;伙伴、会话、归档时间、置顶和未读数分别由项目配置与 `.niancode/conversations.json` 保存。
|
||||
@@ -129,7 +129,7 @@ Windows 打包脚本会先准备目标架构所需的 Python、uv 与 OpenCode
|
||||
- 会话历史按需加载:项目事件流重连只恢复当前选中或仍有活动任务的会话,旧会话在用户打开时从 OpenCode runtime 完整读取;Renderer 最多保留 8 个非活动会话的消息缓存,淘汰只影响内存副本,不删除或改写任何服务端历史。连接稳定时以事件流为准,只有断流才启用低频 REST 回退,并对同一会话的并发历史读取去重。
|
||||
- 创建伙伴后先进入伙伴对话,首条消息发送时才懒创建 OpenCode Session;新会话从干净上下文开始,标题从“新对话”在首条消息发送后自动生成,也支持手动重命名。
|
||||
- Makelore 在应用侧按 Session 独立提交、跟踪和隔离运行状态,不使用“当前对话正在回复”的全局界面锁;同一 Session 的后续消息仍按顺序排队。共享 runtime/provider 是否真正并发执行不同 Session 尚未经过自动化真实运行 smoke 验证,运行时仍可能自行串行、限流或拒绝请求。
|
||||
- 已在当前 fresh OpenCode 运行代际加载且通过 live Agent registry 校验的项目伙伴,会按 Session 独立提交和跟踪回复。若共享 runtime/provider 未确认新 Session 已开始,该 Session 会在 10 秒后单独终止并提示手动重试,不会自动重发或改写其他 Session 状态。运行期间新建、编辑或删除伙伴只更新项目配置与生成文件,不会自动重启、reload 或 dispose 共享运行时,以免打断其他 Session;这类变更会保持“等待运行时重新加载”,用户需等当前回复完成后手动重启运行时再发送。
|
||||
- 已在当前 fresh OpenCode 运行代际加载且通过 live Agent registry 校验的项目伙伴,会按 Session 独立提交和跟踪回复。若共享 runtime/provider 未确认新 Session 已开始,该 Session 会在 10 秒后单独终止并提示手动重试,不会自动重发或改写其他 Session 状态。运行期间创建唯一 ID 的新伙伴时,只要 OpenCode live Agent registry 已发现该 ID,就可在同一运行代际直接使用;已有伙伴的同 ID 内容编辑以及删除、重建仍保持“等待运行时重新加载”,用户需等当前回复完成后手动重启运行时。上述配置变更都不会自动 restart、reload 或 dispose 共享运行时,以免打断其他 Session。
|
||||
- 归档伙伴或单个会话前必须确认;归档会停止对应运行、保留历史,并可从列表底部恢复。未读按会话记录,打开一个会话只清除它自己的未读数。
|
||||
- OpenCode 的问题在当前会话内联处理;项目对话权限默认自动允许,遗留的待处理权限也会自动批准;上下文压缩在聊天时间线实际发生的位置显示为独立事件:自动压缩使用“正在优化对话”/“已优化对话”文案,手动执行 `/compact` 使用“正在压缩上下文”/“已压缩上下文”文案。压缩进行中使用弱化动画,完成后变为静态状态并保留在原位置;触发阈值与同一 Session 的消息排队语义保持不变。
|
||||
|
||||
|
||||
@@ -1527,6 +1527,36 @@ export async function handleOpencodeRoutes(
|
||||
const sessionSummarizeMatch = url.pathname.match(
|
||||
/^\/api\/opencode\/sessions\/([^/]+)\/summarize$/,
|
||||
);
|
||||
const sessionModelMatch = url.pathname.match(
|
||||
/^\/api\/opencode\/sessions\/([^/]+)\/model$/,
|
||||
);
|
||||
if (sessionModelMatch && req.method === 'POST') {
|
||||
try {
|
||||
await coordinateRuntimeExecution(res, ctx, async (signal, _markPending, response) => {
|
||||
signal.throwIfAborted();
|
||||
const body = await parseJsonBody<{ model?: unknown }>(req);
|
||||
const model = parseRuntimePromptModel(body.model);
|
||||
if (!model) {
|
||||
sendJson(response, 400, { success: false, error: 'Invalid model reference' });
|
||||
return;
|
||||
}
|
||||
const client = await createClientForActiveProject(response, ctx, { signal });
|
||||
signal.throwIfAborted();
|
||||
if (!client) return;
|
||||
const sessionID = decodeURIComponent(sessionModelMatch[1]);
|
||||
await client.switchSessionModel(sessionID, model, { signal });
|
||||
signal.throwIfAborted();
|
||||
sendJson(response, 200, {
|
||||
success: true,
|
||||
model: `${model.providerID}/${model.modelID}`,
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (sessionSummarizeMatch && req.method === 'POST') {
|
||||
try {
|
||||
await coordinateRuntimeExecution(res, ctx, async (signal, markPending, response) => {
|
||||
@@ -1985,7 +2015,7 @@ export async function handleOpencodeRoutes(
|
||||
sendJson(response, 400, { success: false, error: 'Invalid model reference' });
|
||||
return;
|
||||
}
|
||||
const model = explicitModel ?? await resolveRuntimePromptModel(runtimeConfigSummary);
|
||||
const model = explicitModel;
|
||||
signal.throwIfAborted();
|
||||
const files = normalizePromptFileParts(body.files);
|
||||
const userContext = typeof body.userContext === 'string' ? body.userContext.trim().slice(0, 2_000) : '';
|
||||
|
||||
@@ -271,6 +271,16 @@ export function createOpencodeClient(options: OpencodeClientOptions) {
|
||||
body: JSON.stringify(payload),
|
||||
signal: options?.signal,
|
||||
}),
|
||||
switchSessionModel: (
|
||||
sessionID: string,
|
||||
model: OpencodeModelReference,
|
||||
options?: OpencodeRuntimeRequestOptions,
|
||||
): Promise<void> =>
|
||||
request<void>(`/api/session/${encodeURIComponent(sessionID)}/model`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ model }),
|
||||
signal: options?.signal,
|
||||
}),
|
||||
executeSessionCommand: (
|
||||
sessionID: string,
|
||||
payload: ExecuteOpencodeSessionCommandInput,
|
||||
|
||||
@@ -24,6 +24,12 @@ interface ProjectAgentRuntimeState {
|
||||
desiredFingerprint: string;
|
||||
appliedFingerprint: string | null;
|
||||
bootstrapCandidateFingerprint: string | null;
|
||||
desiredAgentHashes: Map<string, string>;
|
||||
desiredAgentDefinitions: Map<string, string>;
|
||||
appliedAgentHashes: Map<string, string>;
|
||||
bootstrapCandidateAgentHashes: Map<string, string> | null;
|
||||
hotAddCandidateAgentIds: Set<string>;
|
||||
knownAgentIds: Set<string>;
|
||||
}
|
||||
|
||||
export interface ProjectAgentRuntimeSnapshot extends ProjectAgentRuntimeState {
|
||||
@@ -56,6 +62,59 @@ function canonicalProjectPath(projectPath: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
function buildProjectAgentHashes(config: ProjectConfig): Map<string, string> {
|
||||
return new Map(buildProjectAgentManifest(config).entries.map((entry) => [
|
||||
path.posix.basename(entry.relativePath, '.md'),
|
||||
entry.contentHash,
|
||||
]));
|
||||
}
|
||||
|
||||
function buildProjectAgentDefinitions(config: ProjectConfig): Map<string, string> {
|
||||
return new Map(config.agents.map((agent) => [agent.id, JSON.stringify(agent)]));
|
||||
}
|
||||
|
||||
function appliedManifestMatchesDesired(state: ProjectAgentRuntimeState): boolean {
|
||||
return [...state.desiredAgentHashes].every(
|
||||
([agentId, desiredHash]) => state.appliedAgentHashes.get(agentId) === desiredHash,
|
||||
);
|
||||
}
|
||||
|
||||
function refreshAppliedFingerprint(state: ProjectAgentRuntimeState): void {
|
||||
state.appliedFingerprint = appliedManifestMatchesDesired(state)
|
||||
? state.desiredFingerprint
|
||||
: null;
|
||||
}
|
||||
|
||||
function createRuntimeState(
|
||||
manager: ProjectAgentRuntimeManager,
|
||||
config: ProjectConfig,
|
||||
options: { forcePending?: boolean } = {},
|
||||
): ProjectAgentRuntimeState {
|
||||
const runtimeGeneration = manager.getRuntimeGeneration?.() ?? 0;
|
||||
const provenance: RuntimeGenerationProvenance = manager.getRuntimeGenerationProvenance?.() ?? 'unknown';
|
||||
const manifest = buildProjectAgentManifest(config);
|
||||
const desiredAgentHashes = buildProjectAgentHashes(config);
|
||||
const desiredAgentDefinitions = buildProjectAgentDefinitions(config);
|
||||
const authoritativeFresh = provenance === 'fresh' && !options.forcePending;
|
||||
return {
|
||||
runtimeGeneration,
|
||||
runtimeGenerationProvenance: provenance,
|
||||
desiredFingerprint: manifest.fingerprint,
|
||||
appliedFingerprint: authoritativeFresh ? manifest.fingerprint : null,
|
||||
bootstrapCandidateFingerprint: provenance === 'starting' && !options.forcePending
|
||||
? manifest.fingerprint
|
||||
: null,
|
||||
desiredAgentHashes,
|
||||
desiredAgentDefinitions,
|
||||
appliedAgentHashes: authoritativeFresh ? new Map(desiredAgentHashes) : new Map(),
|
||||
bootstrapCandidateAgentHashes: provenance === 'starting' && !options.forcePending
|
||||
? new Map(desiredAgentHashes)
|
||||
: null,
|
||||
hotAddCandidateAgentIds: new Set(),
|
||||
knownAgentIds: new Set(desiredAgentHashes.keys()),
|
||||
};
|
||||
}
|
||||
|
||||
function getManagerMap<T>(registry: WeakMap<object, Map<string, T>>, manager: object): Map<string, T> {
|
||||
let entries = registry.get(manager);
|
||||
if (!entries) {
|
||||
@@ -109,28 +168,55 @@ function observeState(
|
||||
const states = getManagerMap(runtimeStates, manager as object);
|
||||
const runtimeGeneration = manager.getRuntimeGeneration?.() ?? 0;
|
||||
const provenance: RuntimeGenerationProvenance = manager.getRuntimeGenerationProvenance?.() ?? 'unknown';
|
||||
const desiredFingerprint = buildProjectAgentManifest(config).fingerprint;
|
||||
const desiredManifest = buildProjectAgentManifest(config);
|
||||
const desiredAgentHashes = buildProjectAgentHashes(config);
|
||||
const desiredAgentDefinitions = buildProjectAgentDefinitions(config);
|
||||
const current = states.get(canonicalPath);
|
||||
if (!current || current.runtimeGeneration !== runtimeGeneration) {
|
||||
const next = {
|
||||
runtimeGeneration,
|
||||
runtimeGenerationProvenance: provenance,
|
||||
desiredFingerprint,
|
||||
appliedFingerprint: provenance === 'fresh' ? desiredFingerprint : null,
|
||||
bootstrapCandidateFingerprint: provenance === 'starting' ? desiredFingerprint : null,
|
||||
};
|
||||
const next = createRuntimeState(manager, config);
|
||||
states.set(canonicalPath, next);
|
||||
return next;
|
||||
}
|
||||
current.desiredFingerprint = desiredFingerprint;
|
||||
for (const [agentId, desiredHash] of desiredAgentHashes) {
|
||||
const previousDesiredHash = current.desiredAgentHashes.get(agentId);
|
||||
if (
|
||||
current.desiredAgentDefinitions.get(agentId) === desiredAgentDefinitions.get(agentId)
|
||||
&& previousDesiredHash
|
||||
&& current.appliedAgentHashes.get(agentId) === previousDesiredHash
|
||||
) {
|
||||
current.appliedAgentHashes.set(agentId, desiredHash);
|
||||
}
|
||||
if (
|
||||
!current.knownAgentIds.has(agentId)
|
||||
&& (provenance === 'fresh' || provenance === 'starting')
|
||||
) {
|
||||
current.hotAddCandidateAgentIds.add(agentId);
|
||||
}
|
||||
current.knownAgentIds.add(agentId);
|
||||
}
|
||||
for (const agentId of current.hotAddCandidateAgentIds) {
|
||||
if (!desiredAgentHashes.has(agentId)) current.hotAddCandidateAgentIds.delete(agentId);
|
||||
}
|
||||
for (const agentId of current.desiredAgentHashes.keys()) {
|
||||
if (desiredAgentHashes.has(agentId)) continue;
|
||||
current.appliedAgentHashes.delete(agentId);
|
||||
current.bootstrapCandidateAgentHashes?.delete(agentId);
|
||||
}
|
||||
current.desiredFingerprint = desiredManifest.fingerprint;
|
||||
current.desiredAgentHashes = desiredAgentHashes;
|
||||
current.desiredAgentDefinitions = desiredAgentDefinitions;
|
||||
if (provenance !== 'fresh') {
|
||||
current.appliedFingerprint = null;
|
||||
} else if (current.runtimeGenerationProvenance === 'starting') {
|
||||
current.appliedFingerprint = current.bootstrapCandidateFingerprint === desiredFingerprint
|
||||
? desiredFingerprint
|
||||
: null;
|
||||
const bootstrapHashes = current.bootstrapCandidateAgentHashes ?? new Map();
|
||||
current.appliedAgentHashes = new Map([...bootstrapHashes].filter(
|
||||
([agentId, hash]) => desiredAgentHashes.get(agentId) === hash,
|
||||
));
|
||||
refreshAppliedFingerprint(current);
|
||||
} else if (current.runtimeGenerationProvenance !== 'fresh') {
|
||||
current.appliedFingerprint = null;
|
||||
} else {
|
||||
refreshAppliedFingerprint(current);
|
||||
}
|
||||
current.runtimeGenerationProvenance = provenance;
|
||||
return current;
|
||||
@@ -171,19 +257,19 @@ export async function observeProjectAgentRuntimeGeneration(
|
||||
const runtimeGeneration = manager.getRuntimeGeneration?.() ?? 0;
|
||||
const provenance: RuntimeGenerationProvenance = manager.getRuntimeGenerationProvenance?.() ?? 'unknown';
|
||||
if (current.runtimeGeneration !== runtimeGeneration) {
|
||||
current.runtimeGeneration = runtimeGeneration;
|
||||
current.runtimeGenerationProvenance = provenance;
|
||||
current.appliedFingerprint = provenance === 'fresh' ? current.desiredFingerprint : null;
|
||||
current.bootstrapCandidateFingerprint = provenance === 'starting'
|
||||
? current.desiredFingerprint
|
||||
: null;
|
||||
const config = await loadValidProjectConfig(canonicalPath);
|
||||
const next = createRuntimeState(manager, config);
|
||||
states.set(canonicalPath, next);
|
||||
return { projectPath: canonicalPath, ...next };
|
||||
} else if (provenance !== 'fresh') {
|
||||
current.appliedFingerprint = null;
|
||||
current.runtimeGenerationProvenance = provenance;
|
||||
} else if (current.runtimeGenerationProvenance === 'starting') {
|
||||
current.appliedFingerprint = current.bootstrapCandidateFingerprint === current.desiredFingerprint
|
||||
? current.desiredFingerprint
|
||||
: null;
|
||||
const bootstrapHashes = current.bootstrapCandidateAgentHashes ?? new Map();
|
||||
current.appliedAgentHashes = new Map([...bootstrapHashes].filter(
|
||||
([agentId, hash]) => current.desiredAgentHashes.get(agentId) === hash,
|
||||
));
|
||||
refreshAppliedFingerprint(current);
|
||||
current.runtimeGenerationProvenance = provenance;
|
||||
} else if (current.runtimeGenerationProvenance !== 'fresh') {
|
||||
current.appliedFingerprint = null;
|
||||
@@ -200,13 +286,7 @@ export async function markProjectAgentRuntimePending(
|
||||
): Promise<ProjectAgentRuntimeSnapshot> {
|
||||
const canonicalPath = canonicalProjectPath(projectPath);
|
||||
return await withProjectRuntimeLock(manager as object, canonicalPath, async () => {
|
||||
const state = {
|
||||
runtimeGeneration: manager.getRuntimeGeneration?.() ?? 0,
|
||||
runtimeGenerationProvenance: manager.getRuntimeGenerationProvenance?.() ?? 'unknown',
|
||||
desiredFingerprint: buildProjectAgentManifest(config).fingerprint,
|
||||
appliedFingerprint: null,
|
||||
bootstrapCandidateFingerprint: null,
|
||||
};
|
||||
const state = createRuntimeState(manager, config, { forcePending: true });
|
||||
getManagerMap(runtimeStates, manager as object).set(canonicalPath, state);
|
||||
return { projectPath: canonicalPath, ...state };
|
||||
});
|
||||
@@ -224,13 +304,7 @@ export async function mutateProjectAgentRuntime<T>(
|
||||
observeState(manager, canonicalPath, result.previousConfig);
|
||||
observeState(manager, canonicalPath, result.config);
|
||||
} else {
|
||||
const state: ProjectAgentRuntimeState = {
|
||||
runtimeGeneration: manager.getRuntimeGeneration?.() ?? 0,
|
||||
runtimeGenerationProvenance: manager.getRuntimeGenerationProvenance?.() ?? 'unknown',
|
||||
desiredFingerprint: buildProjectAgentManifest(result.config).fingerprint,
|
||||
appliedFingerprint: null,
|
||||
bootstrapCandidateFingerprint: null,
|
||||
};
|
||||
const state = createRuntimeState(manager, result.config, { forcePending: true });
|
||||
getManagerMap(runtimeStates, manager as object).set(canonicalPath, state);
|
||||
}
|
||||
return result.value;
|
||||
@@ -278,20 +352,32 @@ export async function acceptProjectAgentRuntime<T>(
|
||||
const config = await loadValidProjectConfig(canonicalPath);
|
||||
signal?.throwIfAborted();
|
||||
const state = observeState(manager, canonicalPath, config);
|
||||
const configuredAgentIds = config.agents.map((agent) => agent.id);
|
||||
if (
|
||||
!configuredAgentIds.includes(selectedAgentId)
|
||||
|| state.appliedFingerprint !== state.desiredFingerprint
|
||||
) {
|
||||
const desiredHash = state.desiredAgentHashes.get(selectedAgentId);
|
||||
if (!desiredHash) {
|
||||
return { ready: false, runtimeGeneration: state.runtimeGeneration };
|
||||
}
|
||||
const alreadyApplied = state.runtimeGenerationProvenance === 'fresh'
|
||||
&& state.appliedAgentHashes.get(selectedAgentId) === desiredHash;
|
||||
const hotAddCandidate = state.runtimeGenerationProvenance === 'fresh'
|
||||
&& state.hotAddCandidateAgentIds.has(selectedAgentId);
|
||||
if (!alreadyApplied && !hotAddCandidate) {
|
||||
return { ready: false, runtimeGeneration: state.runtimeGeneration };
|
||||
}
|
||||
|
||||
signal?.throwIfAborted();
|
||||
const liveIds = liveAgentIds(await awaitAbortable(client.listAgents({ signal }), signal));
|
||||
if (configuredAgentIds.some((agentId) => !liveIds.has(agentId))) {
|
||||
state.appliedFingerprint = null;
|
||||
if (!liveIds.has(selectedAgentId)) {
|
||||
if (alreadyApplied) {
|
||||
state.appliedAgentHashes.delete(selectedAgentId);
|
||||
refreshAppliedFingerprint(state);
|
||||
}
|
||||
return { ready: false, runtimeGeneration: state.runtimeGeneration };
|
||||
}
|
||||
if (hotAddCandidate) {
|
||||
state.appliedAgentHashes.set(selectedAgentId, desiredHash);
|
||||
state.hotAddCandidateAgentIds.delete(selectedAgentId);
|
||||
refreshAppliedFingerprint(state);
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
const value = await awaitAbortable(accept(), signal);
|
||||
return {
|
||||
|
||||
@@ -863,6 +863,8 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
|
||||
const abortSession = useOpencodeStore((state) => state.abortSession);
|
||||
const deleteOpencodeSession = useOpencodeStore((state) => state.deleteSession);
|
||||
const sendSessionMessage = useOpencodeStore((state) => state.sendSessionMessage);
|
||||
const sessionModelBySessionId = useOpencodeStore((state) => state.sessionModelBySessionId);
|
||||
const switchSessionModel = useOpencodeStore((state) => state.switchSessionModel);
|
||||
const cancelQueuedSessionPrompt = useOpencodeStore((state) => state.cancelQueuedSessionPrompt);
|
||||
const renameSession = useOpencodeStore((state) => state.renameSession);
|
||||
const shareSession = useOpencodeStore((state) => state.shareSession);
|
||||
@@ -951,6 +953,7 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
|
||||
const seenCompactionIdsBySessionRef = useRef(new Map<string, Set<string>>());
|
||||
const lastCompactionAnnouncementSessionRef = useRef<string | null>(null);
|
||||
const composerAttachmentInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const modelSelectorRef = useRef<HTMLSelectElement | null>(null);
|
||||
const composerStopArmTimerRef = useRef<number | null>(null);
|
||||
const userModelConfigSyncRef = useRef<Promise<boolean> | null>(null);
|
||||
const localProxyModelConfigSyncedRef = useRef(false);
|
||||
@@ -1040,6 +1043,21 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
|
||||
() => buildConfiguredModelOptions(providerAccounts, providerStatuses, providerDefaultAccountId),
|
||||
[providerAccounts, providerDefaultAccountId, providerStatuses],
|
||||
);
|
||||
const activeSessionModel = selectedSessionId
|
||||
? sessionModelBySessionId[selectedSessionId] ?? selectedProjectAgent?.model ?? ''
|
||||
: selectedProjectAgent?.model ?? '';
|
||||
const selectableChatModelOptions = useMemo(() => {
|
||||
if (!activeSessionModel || configuredChatModelOptions.some((option) => option.modelRef === activeSessionModel)) {
|
||||
return configuredChatModelOptions;
|
||||
}
|
||||
return [{
|
||||
modelRef: activeSessionModel,
|
||||
label: formatModelRefLabel(activeSessionModel),
|
||||
runtimeProviderKey: activeSessionModel.split('/')[0] ?? '',
|
||||
accountId: '',
|
||||
capability: 'text' as const,
|
||||
}, ...configuredChatModelOptions];
|
||||
}, [activeSessionModel, configuredChatModelOptions]);
|
||||
const canStopSession = canUseSessions && Boolean(selectedSessionId) && sessionBusy && composerStopArmed;
|
||||
const canUseVoiceInput = canUseSessions
|
||||
&& Boolean(selectedSessionId || selectedAgentId)
|
||||
@@ -1128,7 +1146,7 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
|
||||
hasUndoableUserMessage: Boolean(undoableUserMessage),
|
||||
runtimeReady,
|
||||
busy: sessionBusy || selectedSessionRunning,
|
||||
hasModel: Boolean(selectedProjectAgent?.model?.trim()),
|
||||
hasModel: Boolean(activeSessionModel.trim()),
|
||||
shared: Boolean(currentSession?.share?.url),
|
||||
reverted: Boolean(currentSession?.revert?.messageID),
|
||||
shareEnabled: cachedCommandCatalog?.shareEnabled ?? true,
|
||||
@@ -1596,10 +1614,10 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
|
||||
|
||||
const ensureChatModelConfigured = useCallback(async () => {
|
||||
if (!(await refreshWorksSquareUserModelConfigIfNeeded())) return false;
|
||||
if (selectedProjectAgent?.model?.trim()) return true;
|
||||
if (activeSessionModel.trim()) return true;
|
||||
setModelConfigPromptOpen(true);
|
||||
return false;
|
||||
}, [refreshWorksSquareUserModelConfigIfNeeded, selectedProjectAgent?.model]);
|
||||
}, [activeSessionModel, refreshWorksSquareUserModelConfigIfNeeded]);
|
||||
|
||||
const openSubscriptionUpgrade = useCallback(() => {
|
||||
void openWorksSquareSubscriptionUpgrade().catch(() => undefined);
|
||||
@@ -1644,6 +1662,9 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
|
||||
return;
|
||||
}
|
||||
const configuredAgentModel = submissionAgent.model?.trim();
|
||||
const submissionModel = submissionSessionId
|
||||
? useOpencodeStore.getState().sessionModelBySessionId[submissionSessionId] ?? configuredAgentModel
|
||||
: configuredAgentModel;
|
||||
if (!(await refreshWorksSquareUserModelConfigIfNeeded())) return;
|
||||
if (!sessionStillMatches()) return;
|
||||
const latestProviderState = useProviderStore.getState();
|
||||
@@ -1652,7 +1673,7 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
|
||||
latestProviderState.statuses,
|
||||
latestProviderState.defaultAccountId,
|
||||
);
|
||||
if (!configuredAgentModel || !latestConfiguredChatModelOptions.some((option) => option.modelRef === configuredAgentModel)) {
|
||||
if (!submissionModel || !latestConfiguredChatModelOptions.some((option) => option.modelRef === submissionModel)) {
|
||||
setModelConfigPromptOpen(true);
|
||||
toast.error('这个伙伴的模型不可用,请先更新伙伴配置。');
|
||||
return;
|
||||
@@ -1723,7 +1744,6 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
|
||||
await sendSessionMessage(submissionSessionId, nextDraft, {
|
||||
attachedFiles,
|
||||
agentId: submissionAgent.id,
|
||||
model: configuredAgentModel,
|
||||
onHostAccepted,
|
||||
});
|
||||
} catch {
|
||||
@@ -2085,6 +2105,32 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
|
||||
});
|
||||
}, []);
|
||||
|
||||
const openSessionModelSelector = useCallback(() => {
|
||||
if (!selectedSessionId) throw new Error('请先选择会话');
|
||||
const selector = modelSelectorRef.current;
|
||||
if (!selector) throw new Error('当前没有可用的模型选择器');
|
||||
selector.focus();
|
||||
try {
|
||||
selector.showPicker?.();
|
||||
} catch {
|
||||
// Focus is the supported fallback when the platform cannot open a native picker programmatically.
|
||||
}
|
||||
}, [selectedSessionId]);
|
||||
|
||||
const handleSessionModelChange = useCallback(async (event: ChangeEvent<HTMLSelectElement>) => {
|
||||
const sessionId = selectedSessionId;
|
||||
const model = event.target.value;
|
||||
if (!sessionId || !model || model === activeSessionModel) return;
|
||||
try {
|
||||
await switchSessionModel(sessionId, model);
|
||||
toast.success(`当前会话已切换到 ${formatModelRefLabel(model)}`);
|
||||
} catch (switchError) {
|
||||
toast.error('模型切换失败', {
|
||||
description: normalizeCommandError(switchError),
|
||||
});
|
||||
}
|
||||
}, [activeSessionModel, selectedSessionId, switchSessionModel]);
|
||||
|
||||
const runCompact = useCallback(async () => {
|
||||
if (!(await ensureChatModelConfigured())) {
|
||||
throw new Error('模型未就绪,请完成配置或手动重启运行时后重试。');
|
||||
@@ -2092,12 +2138,12 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
|
||||
const owner = submissionSessionRef.current;
|
||||
const sessionId = owner.id;
|
||||
if (!sessionId) throw new Error('请先选择会话');
|
||||
await compactSession(sessionId, selectedProjectAgent?.model ?? undefined);
|
||||
await compactSession(sessionId, activeSessionModel || undefined);
|
||||
assertSubmissionSessionOwner(
|
||||
owner,
|
||||
submissionSessionRef.current,
|
||||
);
|
||||
}, [compactSession, ensureChatModelConfigured, selectedProjectAgent?.model]);
|
||||
}, [activeSessionModel, compactSession, ensureChatModelConfigured]);
|
||||
|
||||
const runShare = useCallback(async () => {
|
||||
const owner = submissionSessionRef.current;
|
||||
@@ -2281,13 +2327,11 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
|
||||
return { status: 'cancelled' };
|
||||
}
|
||||
const parts = buildProjectCommandPartsFromAttachedFiles(attachedFiles);
|
||||
const model = selectedProjectAgent?.model;
|
||||
const agent = selectedProjectAgent?.id ?? currentSession?.agent;
|
||||
await executeProjectCommand(submissionSessionId, {
|
||||
command: rawName,
|
||||
arguments: rawArguments,
|
||||
...(agent ? { agent } : {}),
|
||||
...(model ? { model } : {}),
|
||||
parts,
|
||||
});
|
||||
if (!sameSubmissionSessionOwner(
|
||||
@@ -2303,12 +2347,12 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
|
||||
ensureChatModelConfigured,
|
||||
executeProjectCommand,
|
||||
selectedProjectAgent?.id,
|
||||
selectedProjectAgent?.model,
|
||||
]);
|
||||
|
||||
const commandActions = useMemo<ChatCommandActions>(() => ({
|
||||
rename: runRename,
|
||||
timeline: runTimeline,
|
||||
models: openSessionModelSelector,
|
||||
compact: runCompact,
|
||||
share: runShare,
|
||||
unshare: runUnshare,
|
||||
@@ -2329,6 +2373,7 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
|
||||
runRename,
|
||||
runShare,
|
||||
runTimeline,
|
||||
openSessionModelSelector,
|
||||
runUndo,
|
||||
runUnshare,
|
||||
toggleThinking,
|
||||
@@ -2964,7 +3009,7 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
|
||||
<ComposerAttachmentCard
|
||||
key={attachment.id}
|
||||
attachment={attachment}
|
||||
modelRef={selectedProjectAgent?.model}
|
||||
modelRef={activeSessionModel}
|
||||
onSelectVariant={(variant) => (
|
||||
composer.selectVariant(attachment.id, variant)
|
||||
)}
|
||||
@@ -3106,17 +3151,34 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
|
||||
</div>
|
||||
)}
|
||||
<div data-testid="opencode-composer-right-actions" className="flex min-w-0 items-center gap-0.5">
|
||||
{selectedAgentId ? (
|
||||
<span
|
||||
data-testid="opencode-current-agent-model"
|
||||
className={cn(
|
||||
'shrink-0 whitespace-nowrap px-2 text-xs font-medium text-muted-foreground',
|
||||
!selectedProjectAgent?.model && 'text-destructive',
|
||||
)}
|
||||
title={selectedProjectAgent?.model ?? '未配置模型'}
|
||||
>
|
||||
{selectedProjectAgent?.model ? formatModelRefLabel(selectedProjectAgent.model) : '未配置模型'}
|
||||
</span>
|
||||
{selectedSessionId || selectedAgentId ? (
|
||||
<label className="relative min-w-0 shrink">
|
||||
<span className="sr-only">当前会话模型</span>
|
||||
<select
|
||||
ref={modelSelectorRef}
|
||||
data-testid="opencode-model-selector-trigger"
|
||||
aria-label="当前会话模型"
|
||||
className={cn(
|
||||
'h-8 max-w-44 appearance-none truncate rounded-md border border-transparent bg-transparent py-1 pl-2 pr-7 text-xs font-medium text-muted-foreground outline-none transition-colors hover:border-foreground/10 hover:bg-surface-subtle focus:border-foreground/15 focus:bg-surface-subtle',
|
||||
!activeSessionModel && 'text-destructive',
|
||||
)}
|
||||
value={activeSessionModel}
|
||||
disabled={!selectedSessionId || loading || selectableChatModelOptions.length === 0}
|
||||
onChange={(event) => void handleSessionModelChange(event)}
|
||||
title={activeSessionModel || '未配置模型'}
|
||||
>
|
||||
{!activeSessionModel ? <option value="">未配置模型</option> : null}
|
||||
{selectableChatModelOptions.map((option) => (
|
||||
<option key={option.modelRef} value={option.modelRef}>
|
||||
{formatModelRefLabel(option.modelRef)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute right-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground"
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export type BuiltinChatCommandName =
|
||||
| 'share' | 'rename' | 'timeline' | 'compact' | 'unshare'
|
||||
| 'share' | 'rename' | 'timeline' | 'models' | 'compact' | 'unshare'
|
||||
| 'undo' | 'redo' | 'timestamps' | 'thinking' | 'process' | 'copy' | 'export';
|
||||
|
||||
export interface ProjectCommandInfo {
|
||||
@@ -75,6 +75,7 @@ export const BUILTIN_CHAT_COMMANDS: readonly ChatCommandDefinition[] = [
|
||||
builtin('share', [], '分享会话', '创建或复制分享链接', undefined, 'share-first'),
|
||||
builtin('rename', [], '重命名会话', '修改当前会话标题', '[title]', 'none'),
|
||||
builtin('timeline', [], '消息时间线', '跳转到一条已加载消息', undefined, 'none'),
|
||||
builtin('models', ['model'], '切换模型', '切换当前会话后续轮次使用的模型', undefined, 'none'),
|
||||
builtin('compact', ['summarize'], '压缩会话', '使用当前模型总结上下文', undefined, 'none'),
|
||||
builtin('unshare', [], '撤销分享', '移除公开分享链接', undefined, 'always'),
|
||||
builtin('undo', [], '撤销上一轮', '回退最近用户消息并恢复输入', undefined, 'always'),
|
||||
|
||||
@@ -29,6 +29,7 @@ export interface ChatCommandKeyEvent {
|
||||
export interface ChatCommandActions {
|
||||
rename: (title: string) => Promise<void>;
|
||||
timeline: (messageId: string) => Promise<void> | void;
|
||||
models: () => Promise<void> | void;
|
||||
compact: () => Promise<void>;
|
||||
share: () => Promise<void>;
|
||||
unshare: () => Promise<void>;
|
||||
@@ -267,6 +268,15 @@ export function useChatCommands({
|
||||
dialogOriginRef.current = origin;
|
||||
setDialog({ kind: 'timeline', originatingDraft });
|
||||
return true;
|
||||
case 'models':
|
||||
await actions.models();
|
||||
clearIfOriginating(
|
||||
setDraft,
|
||||
originatingDraft,
|
||||
origin,
|
||||
currentOriginRef,
|
||||
);
|
||||
return true;
|
||||
case 'compact':
|
||||
await actions.compact();
|
||||
clearIfOriginating(
|
||||
|
||||
@@ -115,6 +115,12 @@ interface OpencodeSessionMessageActionResponse {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface OpencodeSessionModelActionResponse {
|
||||
success: boolean;
|
||||
model?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface OpencodeSessionStatusActionResponse {
|
||||
statuses?: OpencodeSessionStatusMap;
|
||||
error?: string;
|
||||
@@ -182,6 +188,7 @@ interface OpencodeState {
|
||||
sessions: OpencodeSession[];
|
||||
sessionsByProjectId: Record<string, OpencodeSession[]>;
|
||||
selectedSessionId: string | null;
|
||||
sessionModelBySessionId: Record<string, string>;
|
||||
sessionStatuses: OpencodeSessionStatusMap;
|
||||
sessionTranscriptBySessionId: Record<string, OpencodeSessionTranscriptState>;
|
||||
projectEventStreamState: 'closed' | 'connecting' | 'connected' | 'reconnecting';
|
||||
@@ -239,6 +246,7 @@ interface OpencodeState {
|
||||
loadSessionStatuses: () => Promise<OpencodeSessionStatusMap>;
|
||||
loadSessionMessages: (sessionId: string) => Promise<RawMessage[]>;
|
||||
sendSessionMessage: (sessionId: string, text: string, options?: SendSessionMessageOptions) => Promise<RawMessage[]>;
|
||||
switchSessionModel: (sessionId: string, model: string) => Promise<void>;
|
||||
compactSession: (sessionId: string, model?: string) => Promise<RawMessage[]>;
|
||||
executeProjectCommand: (
|
||||
sessionId: string,
|
||||
@@ -1893,6 +1901,26 @@ function deriveRevertedSessionIds(sessions: readonly OpencodeSession[]): Record<
|
||||
return revertedSessionIds;
|
||||
}
|
||||
|
||||
function getSessionModelRef(session: OpencodeSession): string | null {
|
||||
const providerID = session.model?.providerID?.trim();
|
||||
const modelID = (session.model?.modelID ?? session.model?.id)?.trim();
|
||||
return providerID && modelID ? `${providerID}/${modelID}` : null;
|
||||
}
|
||||
|
||||
function reconcileSessionModelRefs(
|
||||
sessions: readonly OpencodeSession[],
|
||||
current: Readonly<Record<string, string>>,
|
||||
): Record<string, string> {
|
||||
const models: Record<string, string> = {};
|
||||
for (const session of sessions) {
|
||||
const sessionId = getSessionId(session);
|
||||
if (!sessionId) continue;
|
||||
const model = getSessionModelRef(session) ?? current[sessionId];
|
||||
if (model) models[sessionId] = model;
|
||||
}
|
||||
return models;
|
||||
}
|
||||
|
||||
function resetProjectScopedState(sessions: OpencodeSession[] = []) {
|
||||
closeActiveSessionEventSource();
|
||||
sessionDiffLoadSequence += 1;
|
||||
@@ -1900,6 +1928,7 @@ function resetProjectScopedState(sessions: OpencodeSession[] = []) {
|
||||
return {
|
||||
sessions,
|
||||
selectedSessionId: null,
|
||||
sessionModelBySessionId: {},
|
||||
sessionStatuses: {},
|
||||
sessionTranscriptBySessionId: {},
|
||||
projectEventStreamState: 'closed' as const,
|
||||
@@ -3556,6 +3585,7 @@ export const useOpencodeStore = create<OpencodeState>((set, get) => ({
|
||||
sessions: [],
|
||||
sessionsByProjectId: {},
|
||||
selectedSessionId: null,
|
||||
sessionModelBySessionId: {},
|
||||
sessionStatuses: {},
|
||||
sessionTranscriptBySessionId: {},
|
||||
projectEventStreamState: 'closed',
|
||||
@@ -3755,6 +3785,10 @@ export const useOpencodeStore = create<OpencodeState>((set, get) => ({
|
||||
...(requestStillActive
|
||||
? {
|
||||
sessions,
|
||||
sessionModelBySessionId: reconcileSessionModelRefs(
|
||||
sessions,
|
||||
state.sessionModelBySessionId,
|
||||
),
|
||||
revertedSessionIds: deriveRevertedSessionIds(sessions),
|
||||
}
|
||||
: {}),
|
||||
@@ -3987,6 +4021,7 @@ export const useOpencodeStore = create<OpencodeState>((set, get) => ({
|
||||
Object.entries(state.sessionStatuses).filter(([key]) => key !== sessionId),
|
||||
),
|
||||
selectedSessionId: deletingSelected ? null : selectedSessionId,
|
||||
sessionModelBySessionId: withoutKey(state.sessionModelBySessionId, sessionId),
|
||||
sessionMessages: deletingSelected ? [] : state.sessionMessages,
|
||||
sessionMessagesBySessionId: withoutKey(state.sessionMessagesBySessionId, sessionId),
|
||||
sessionTranscriptBySessionId: withoutKey(state.sessionTranscriptBySessionId, sessionId),
|
||||
@@ -4621,6 +4656,25 @@ export const useOpencodeStore = create<OpencodeState>((set, get) => ({
|
||||
return await runSessionPrompt(set, get, sessionId, trimmedText, optimisticUserMessage, options);
|
||||
},
|
||||
|
||||
async switchSessionModel(sessionId, model) {
|
||||
const response = await hostApiFetch<OpencodeSessionModelActionResponse>(
|
||||
`/api/opencode/sessions/${encodeURIComponent(sessionId)}/model`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ model }),
|
||||
},
|
||||
);
|
||||
if (!response.success) {
|
||||
throw new Error(response.error || 'Session model switch failed');
|
||||
}
|
||||
set((state) => ({
|
||||
sessionModelBySessionId: {
|
||||
...state.sessionModelBySessionId,
|
||||
[sessionId]: response.model ?? model,
|
||||
},
|
||||
}));
|
||||
},
|
||||
|
||||
async compactSession(sessionId, model) {
|
||||
if (isSessionRunning(get(), sessionId)) throw new Error('Session is busy');
|
||||
try {
|
||||
|
||||
@@ -123,6 +123,11 @@ export interface OpencodeSession extends Record<string, unknown> {
|
||||
id?: string;
|
||||
sessionID?: string;
|
||||
agent?: string | null;
|
||||
model?: {
|
||||
providerID?: string;
|
||||
modelID?: string;
|
||||
id?: string;
|
||||
};
|
||||
title?: string;
|
||||
name?: string;
|
||||
summary?: string;
|
||||
|
||||
@@ -105,8 +105,20 @@ async function installMultichatHost(electronApp: ElectronApplication): Promise<v
|
||||
updatedAt: now,
|
||||
};
|
||||
const sessions = [
|
||||
{ id: sessionAId, title: 'Session A', agent: agent.id, time: { updated: 2 } },
|
||||
{ id: sessionBId, title: 'Session B', agent: agent.id, time: { updated: 1 } },
|
||||
{
|
||||
id: sessionAId,
|
||||
title: 'Session A',
|
||||
agent: agent.id,
|
||||
model: { providerID: 'niancode-user-models', id: 'qwen3.7-plus' },
|
||||
time: { updated: 2 },
|
||||
},
|
||||
{
|
||||
id: sessionBId,
|
||||
title: 'Session B',
|
||||
agent: agent.id,
|
||||
model: { providerID: 'niancode-user-models', id: 'qwen3.7-plus' },
|
||||
time: { updated: 1 },
|
||||
},
|
||||
];
|
||||
const conversationState = {
|
||||
schemaVersion: 1,
|
||||
@@ -194,6 +206,7 @@ async function installMultichatHost(electronApp: ElectronApplication): Promise<v
|
||||
label: 'Makelore Models',
|
||||
authMode: 'api_key',
|
||||
model: 'qwen3.7-plus',
|
||||
fallbackModels: ['deepseek-chat'],
|
||||
enabled: true,
|
||||
isDefault: true,
|
||||
createdAt: now,
|
||||
@@ -227,6 +240,13 @@ async function installMultichatHost(electronApp: ElectronApplication): Promise<v
|
||||
state.sessionABusy = true;
|
||||
return respond({ success: true }, 202);
|
||||
}
|
||||
if (
|
||||
(path === `/api/opencode/sessions/${sessionAId}/model`
|
||||
|| path === `/api/opencode/sessions/${sessionBId}/model`)
|
||||
&& method === 'POST'
|
||||
) {
|
||||
return respond({ success: true, model: body?.model });
|
||||
}
|
||||
if (path === `/api/opencode/sessions/${sessionBId}/messages` && method === 'POST') {
|
||||
return respond({
|
||||
success: false,
|
||||
@@ -297,6 +317,13 @@ test('keeps Session A running when Session B is terminally rejected by Agent pre
|
||||
|
||||
await sessionA.click();
|
||||
await expect(sessionA).toHaveAttribute('aria-current', 'page');
|
||||
const modelSelector = page.getByTestId('opencode-model-selector-trigger');
|
||||
await expect(modelSelector).toHaveValue('niancode-user-models/qwen3.7-plus');
|
||||
await modelSelector.selectOption('niancode-user-models/deepseek-chat');
|
||||
await expect(modelSelector).toHaveValue('niancode-user-models/deepseek-chat');
|
||||
await expect.poll(async () => (await capturedRequests(electronApp)).map(
|
||||
(request) => `${request.method} ${request.path}`,
|
||||
)).toContain(`POST /api/opencode/sessions/${SESSION_A_ID}/model`);
|
||||
await submitComposer(page, 'Keep Session A running');
|
||||
await expect.poll(async () => [...new Set((await capturedRequests(electronApp))
|
||||
.filter((request) => request.path.includes('/messages'))
|
||||
@@ -336,4 +363,6 @@ test('keeps Session A running when Session B is terminally rejected by Agent pre
|
||||
));
|
||||
expect(promptPosts.filter((request) => request.path.includes(SESSION_A_ID))).toHaveLength(1);
|
||||
expect(promptPosts.filter((request) => request.path.includes(SESSION_B_ID))).toHaveLength(1);
|
||||
expect(promptPosts.find((request) => request.path.includes(SESSION_A_ID))?.body)
|
||||
.not.toHaveProperty('model');
|
||||
});
|
||||
|
||||
@@ -29,10 +29,11 @@ function context(overrides: Partial<ChatCommandContext> = {}): ChatCommandContex
|
||||
describe('chat command registry', () => {
|
||||
it('defines the approved built-ins and aliases', () => {
|
||||
expect(BUILTIN_CHAT_COMMANDS.map((command) => command.name)).toEqual([
|
||||
'share', 'rename', 'timeline', 'compact', 'unshare',
|
||||
'share', 'rename', 'timeline', 'models', 'compact', 'unshare',
|
||||
'undo', 'redo', 'timestamps', 'thinking', 'process', 'copy', 'export',
|
||||
]);
|
||||
const catalog = mergeChatCommandCatalog([]);
|
||||
expect(resolveCommandName('model', catalog)?.name).toBe('models');
|
||||
expect(resolveCommandName('summarize', catalog)?.name).toBe('compact');
|
||||
expect(resolveCommandName('toggle-timestamps', catalog)?.name).toBe('timestamps');
|
||||
expect(resolveCommandName('toggle-thinking', catalog)?.name).toBe('thinking');
|
||||
|
||||
@@ -2597,7 +2597,6 @@ describe('OpencodeChatPanel', () => {
|
||||
body: JSON.stringify({
|
||||
text: 'Use the local proxy',
|
||||
agent: 'manual-agent-1',
|
||||
model: 'niancode-user-models/deepseek-chat',
|
||||
}),
|
||||
}));
|
||||
expect(hostApiFetchMock.mock.calls.some(([path]) => path === '/api/provider-accounts/import-user-model-config')).toBe(true);
|
||||
@@ -2727,7 +2726,7 @@ describe('OpencodeChatPanel', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('shows the selected partner model as read-only in the composer', async () => {
|
||||
it('switches the current Session model without rewriting the partner provider configuration', async () => {
|
||||
const activeProject = {
|
||||
id: 'prj_1',
|
||||
path: 'D:/repo/packages/ui',
|
||||
@@ -2834,6 +2833,10 @@ describe('OpencodeChatPanel', () => {
|
||||
if (path === '/api/opencode/sessions/ses_1/todos') {
|
||||
return { todos: [] };
|
||||
}
|
||||
if (path === '/api/opencode/sessions/ses_1/model' && init?.method === 'POST') {
|
||||
const payload = JSON.parse(String(init.body)) as { model: string };
|
||||
return { success: true, model: payload.model };
|
||||
}
|
||||
if (path === '/api/provider-accounts/niancode-user-models' && init?.method === 'PUT') {
|
||||
const payload = JSON.parse(String(init.body)) as { updates: Partial<typeof accountState> };
|
||||
accountState = { ...accountState, ...payload.updates };
|
||||
@@ -2856,14 +2859,30 @@ describe('OpencodeChatPanel', () => {
|
||||
|
||||
render(<OpencodeChatPanel variant="main" />);
|
||||
|
||||
const currentModel = await screen.findByTestId('opencode-current-agent-model');
|
||||
expect(currentModel).toHaveTextContent('deepseek-chat');
|
||||
expect(currentModel).not.toHaveTextContent('当前模型:');
|
||||
expect(currentModel).toHaveClass('shrink-0', 'whitespace-nowrap');
|
||||
expect(currentModel).not.toHaveClass('max-w-[200px]', 'truncate');
|
||||
expect(currentModel.tagName).toBe('SPAN');
|
||||
expect(screen.queryByTestId('opencode-model-selector-trigger')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('opencode-model-selector-menu')).not.toBeInTheDocument();
|
||||
const modelSelector = await screen.findByTestId('opencode-model-selector-trigger');
|
||||
expect(modelSelector).toHaveValue('niancode-user-models/deepseek-chat');
|
||||
|
||||
fireEvent.change(modelSelector, {
|
||||
target: { value: 'niancode-user-models/qwen3.6-plus' },
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(modelSelector).toHaveValue('niancode-user-models/qwen3.6-plus');
|
||||
});
|
||||
expect(hostApiFetchMock).toHaveBeenCalledWith(
|
||||
'/api/opencode/sessions/ses_1/model',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ model: 'niancode-user-models/qwen3.6-plus' }),
|
||||
},
|
||||
);
|
||||
|
||||
const textarea = screen.getByRole('textbox');
|
||||
fireEvent.change(textarea, { target: { value: '/model' } });
|
||||
fireEvent.submit(screen.getByTestId('opencode-message-composer'));
|
||||
await waitFor(() => expect(modelSelector).toHaveFocus());
|
||||
|
||||
expect(useProjectConfigStore.getState().configsByProjectId.prj_1?.agents[0]?.model)
|
||||
.toBe('niancode-user-models/deepseek-chat');
|
||||
expect(hostApiFetchMock).not.toHaveBeenCalledWith('/api/provider-accounts/niancode-user-models', expect.objectContaining({ method: 'PUT' }));
|
||||
});
|
||||
|
||||
@@ -5427,7 +5446,7 @@ describe('OpencodeChatPanel', () => {
|
||||
expect(screen.queryByRole('button', { name: '跳到最新消息' })).not.toBeInTheDocument();
|
||||
expect(hostApiFetchMock).toHaveBeenCalledWith('/api/opencode/sessions/ses_1/messages', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ text: 'Ship it', agent: 'manual-agent-1', model: 'openai/gpt-5' }),
|
||||
body: JSON.stringify({ text: 'Ship it', agent: 'manual-agent-1' }),
|
||||
});
|
||||
|
||||
expect(screen.queryByRole('button', { name: '刷新对话' })).not.toBeInTheDocument();
|
||||
@@ -5501,7 +5520,7 @@ describe('OpencodeChatPanel', () => {
|
||||
|
||||
await waitFor(() => expect(hostApiFetchMock).toHaveBeenCalledWith('/api/opencode/sessions/ses_1/messages', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ text: 'Ship it', agent: 'manual-agent-1', model: 'openai/gpt-5' }),
|
||||
body: JSON.stringify({ text: 'Ship it', agent: 'manual-agent-1' }),
|
||||
}));
|
||||
});
|
||||
|
||||
@@ -6411,8 +6430,8 @@ describe('OpencodeChatPanel', () => {
|
||||
expect(postedBodies[0]).toMatchObject({
|
||||
text: 'Stale generation',
|
||||
agent: 'product-planning',
|
||||
model: 'openai/gpt-5',
|
||||
});
|
||||
expect(postedBodies[0]).not.toHaveProperty('model');
|
||||
});
|
||||
|
||||
it('does not clear a draft changed X to Y to X while the POST is pending', async () => {
|
||||
@@ -6587,9 +6606,7 @@ describe('OpencodeChatPanel', () => {
|
||||
expect(posted.command).toBe('Review');
|
||||
expect(posted.arguments).toBe(' staged changes ');
|
||||
expect(posted.agent).toBe('game-development');
|
||||
expect(posted.model).toBe(
|
||||
'openai/gpt-5',
|
||||
);
|
||||
expect(posted).not.toHaveProperty('model');
|
||||
expect(posted.parts).toEqual([
|
||||
{
|
||||
type: 'text',
|
||||
|
||||
@@ -169,6 +169,35 @@ describe('opencode client', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('switches the model used by subsequent turns through the OpenCode session API', async () => {
|
||||
const fetchImpl = vi.fn(async () => new Response(null, { status: 204 }));
|
||||
const client = createOpencodeClient({
|
||||
baseUrl: 'http://127.0.0.1:4096',
|
||||
directory: 'D:/work/app',
|
||||
fetchImpl,
|
||||
});
|
||||
const controller = new AbortController();
|
||||
|
||||
await client.switchSessionModel('ses_1', {
|
||||
providerID: 'niancode-user-models',
|
||||
modelID: 'qwen3.7-plus',
|
||||
}, { signal: controller.signal });
|
||||
|
||||
expect(fetchImpl).toHaveBeenCalledWith(
|
||||
'http://127.0.0.1:4096/api/session/ses_1/model?directory=D%3A%2Fwork%2Fapp',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
model: {
|
||||
providerID: 'niancode-user-models',
|
||||
modelID: 'qwen3.7-plus',
|
||||
},
|
||||
}),
|
||||
signal: controller.signal,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('posts a text message to a session scoped to the selected folder', async () => {
|
||||
const fetchImpl = vi.fn(async () => {
|
||||
return new Response(JSON.stringify({ id: 'msg_1', role: 'user' }), {
|
||||
|
||||
@@ -568,6 +568,32 @@ describe('opencode host api routes', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('switches the active session model without mutating or restarting runtime configuration', async () => {
|
||||
const switchSessionModel = vi.fn(async () => undefined);
|
||||
createOpencodeClientMock.mockReturnValue({ switchSessionModel });
|
||||
const context = runningProjectContext();
|
||||
const response = createResponse();
|
||||
|
||||
const handled = await handleOpencodeRoutes(
|
||||
createRequest('POST', { model: 'niancode-user-models/qwen3.7-plus' }),
|
||||
response.res,
|
||||
new URL('http://127.0.0.1/api/opencode/sessions/ses_1/model'),
|
||||
context,
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(switchSessionModel).toHaveBeenCalledWith(
|
||||
'ses_1',
|
||||
{
|
||||
providerID: 'niancode-user-models',
|
||||
modelID: 'qwen3.7-plus',
|
||||
},
|
||||
{ signal: expect.any(AbortSignal) },
|
||||
);
|
||||
expect(buildConfigSummaryMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['mini_game', ['game.json', 'game.js']],
|
||||
['mini_program', ['app.json', 'pages/index/index.js']],
|
||||
@@ -2227,6 +2253,88 @@ description: Browser debugging.
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps existing Agents usable and accepts a newly added unique-id Agent once it is live', async () => {
|
||||
const projectPath = await mkdtemp(join(tmpdir(), 'makelore-agent-hot-add-'));
|
||||
try {
|
||||
const originalAgent = createConfiguredAgent();
|
||||
const originalConfig = await createConfiguredAgentProject(projectPath, originalAgent);
|
||||
const addedAgent = createConfiguredAgent({
|
||||
id: 'agent-new-partner',
|
||||
roleName: '新伙伴',
|
||||
name: '小新',
|
||||
createdAt: '2026-07-11T01:00:00.000Z',
|
||||
updatedAt: '2026-07-11T01:00:00.000Z',
|
||||
});
|
||||
const project = { id: 'prj_1', path: projectPath, name: 'agent-hot-add' };
|
||||
const context = {
|
||||
opencodeManager: {
|
||||
getStatus: () => ({ state: 'running', port: 4096, url: 'http://127.0.0.1:4096' }),
|
||||
getRuntimeGeneration: () => 1,
|
||||
getRuntimeGenerationProvenance: () => 'fresh',
|
||||
},
|
||||
opencodeProjectStore: {
|
||||
listProjects: vi.fn(async () => [project]),
|
||||
getActiveProject: vi.fn(async () => project),
|
||||
},
|
||||
} as never;
|
||||
|
||||
const updateResponse = createResponse();
|
||||
await handleOpencodeRoutes(
|
||||
createRequest('PUT', {
|
||||
projectId: project.id,
|
||||
config: { ...originalConfig, agents: [originalAgent, addedAgent] },
|
||||
}),
|
||||
updateResponse.res,
|
||||
new URL('http://127.0.0.1/api/opencode/projects/config'),
|
||||
context,
|
||||
);
|
||||
expect(updateResponse.statusCode).toBe(200);
|
||||
|
||||
const promptSessionAsync = vi.fn(async () => undefined);
|
||||
const listAgents = vi.fn(async () => [{ id: originalAgent.id }]);
|
||||
createOpencodeClientMock.mockReturnValue({ listAgents, promptSessionAsync });
|
||||
const existingPromptResponse = createResponse();
|
||||
await handleOpencodeRoutes(
|
||||
createRequest('POST', { text: 'Keep working', agent: originalAgent.id }),
|
||||
existingPromptResponse.res,
|
||||
new URL('http://127.0.0.1/api/opencode/sessions/ses_existing/messages'),
|
||||
context,
|
||||
);
|
||||
expect(existingPromptResponse.statusCode).toBe(202);
|
||||
|
||||
const pendingPromptResponse = createResponse();
|
||||
await handleOpencodeRoutes(
|
||||
createRequest('POST', { text: 'Wait for discovery', agent: addedAgent.id }),
|
||||
pendingPromptResponse.res,
|
||||
new URL('http://127.0.0.1/api/opencode/sessions/ses_new_pending/messages'),
|
||||
context,
|
||||
);
|
||||
expect(pendingPromptResponse.statusCode).toBe(409);
|
||||
expect(pendingPromptResponse.json()).toMatchObject({
|
||||
code: 'OPENCODE_AGENT_REGISTRY_PENDING',
|
||||
promptSent: false,
|
||||
});
|
||||
|
||||
listAgents.mockResolvedValue([
|
||||
{ id: originalAgent.id },
|
||||
{ id: addedAgent.id },
|
||||
]);
|
||||
const promptResponse = createResponse();
|
||||
await handleOpencodeRoutes(
|
||||
createRequest('POST', { text: 'Start immediately', agent: addedAgent.id }),
|
||||
promptResponse.res,
|
||||
new URL('http://127.0.0.1/api/opencode/sessions/ses_new_partner/messages'),
|
||||
context,
|
||||
);
|
||||
|
||||
expect(promptResponse.statusCode).toBe(202);
|
||||
expect(listAgents).toHaveBeenCalledTimes(3);
|
||||
expect(promptSessionAsync).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
await rm(projectPath, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps a same-id live Agent pending when the runtime generation was attached', async () => {
|
||||
const projectPath = await mkdtemp(join(tmpdir(), 'makelore-agent-attached-route-'));
|
||||
try {
|
||||
@@ -2814,7 +2922,7 @@ description: Browser debugging.
|
||||
expect(response.json()).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it('forces async prompts to use the current runtime model instead of the stale session model', async () => {
|
||||
it('preserves the OpenCode Session model when a prompt has no explicit model override', async () => {
|
||||
const response = createResponse();
|
||||
const promptSessionAsync = vi.fn(async () => undefined);
|
||||
createOpencodeClientMock.mockReturnValue({ promptSessionAsync });
|
||||
@@ -2847,10 +2955,6 @@ description: Browser debugging.
|
||||
expect(handled).toBe(true);
|
||||
expect(promptSessionAsync).toHaveBeenCalledWith('ses_1', {
|
||||
text: 'Ship it',
|
||||
model: {
|
||||
providerID: 'niancode-user-models',
|
||||
modelID: 'deepseek-chat',
|
||||
},
|
||||
}, { signal: expect.any(AbortSignal) });
|
||||
expect(response.statusCode).toBe(202);
|
||||
});
|
||||
|
||||
@@ -80,6 +80,7 @@ describe('opencode store', () => {
|
||||
sessions: [],
|
||||
sessionsByProjectId: {},
|
||||
selectedSessionId: null,
|
||||
sessionModelBySessionId: {},
|
||||
sessionStatuses: {},
|
||||
projectEventStreamState: 'closed',
|
||||
sessionMessages: [],
|
||||
@@ -5469,6 +5470,54 @@ describe('opencode store', () => {
|
||||
expect(source.close).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('switches and remembers the model selected for one OpenCode session', async () => {
|
||||
hostApiFetchMock.mockResolvedValue({
|
||||
success: true,
|
||||
model: 'niancode-user-models/qwen3.7-plus',
|
||||
});
|
||||
|
||||
await useOpencodeStore.getState().switchSessionModel(
|
||||
'ses_1',
|
||||
'niancode-user-models/qwen3.7-plus',
|
||||
);
|
||||
|
||||
expect(hostApiFetchMock).toHaveBeenCalledWith(
|
||||
'/api/opencode/sessions/ses_1/model',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ model: 'niancode-user-models/qwen3.7-plus' }),
|
||||
},
|
||||
);
|
||||
expect(useOpencodeStore.getState().sessionModelBySessionId).toEqual({
|
||||
ses_1: 'niancode-user-models/qwen3.7-plus',
|
||||
});
|
||||
});
|
||||
|
||||
it('hydrates the current model owned by OpenCode when sessions are reloaded', async () => {
|
||||
useOpencodeStore.setState({
|
||||
activeProject: {
|
||||
id: 'prj_1',
|
||||
path: 'D:/repo/one',
|
||||
name: 'one',
|
||||
},
|
||||
});
|
||||
hostApiFetchMock.mockResolvedValue({
|
||||
sessions: [{
|
||||
id: 'ses_1',
|
||||
model: {
|
||||
providerID: 'niancode-user-models',
|
||||
id: 'qwen3.7-plus',
|
||||
},
|
||||
}],
|
||||
});
|
||||
|
||||
await useOpencodeStore.getState().loadSessions();
|
||||
|
||||
expect(useOpencodeStore.getState().sessionModelBySessionId).toEqual({
|
||||
ses_1: 'niancode-user-models/qwen3.7-plus',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps one manual compaction identity and releases queued prompts only after idle', async () => {
|
||||
const compactSource = new MockEventSource('/api/opencode/events?sessionId=ses_compact_lifecycle');
|
||||
const promptSource = new MockEventSource('/api/opencode/events?sessionId=ses_compact_lifecycle');
|
||||
|
||||
@@ -231,6 +231,62 @@ describe('project Agent runtime readiness', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps a deleted and recreated same-id Agent pending until generation rollover', async () => {
|
||||
const projectPath = await mkdtemp(path.join(tmpdir(), 'makelore-agent-recreated-same-id-'));
|
||||
try {
|
||||
const initial = await createInitialProjectConfig(projectPath);
|
||||
const agent = createAgent();
|
||||
const original = await writeProjectConfig(projectPath, {
|
||||
...initial,
|
||||
initialized: true,
|
||||
agents: [agent],
|
||||
});
|
||||
let runtimeGeneration = 1;
|
||||
const manager = {
|
||||
getRuntimeGeneration: () => runtimeGeneration,
|
||||
getRuntimeGenerationProvenance: () => 'fresh' as const,
|
||||
};
|
||||
const listAgents = vi.fn(async () => [{ id: agent.id }]);
|
||||
|
||||
await expect(preflightProjectAgentRuntime(
|
||||
manager,
|
||||
projectPath,
|
||||
{ listAgents },
|
||||
agent.id,
|
||||
)).resolves.toEqual({ ready: true, runtimeGeneration: 1 });
|
||||
|
||||
const removed = await writeProjectConfig(projectPath, {
|
||||
...original,
|
||||
agents: [],
|
||||
});
|
||||
await observeProjectAgentRuntime(manager, projectPath, removed);
|
||||
const recreated = await writeProjectConfig(projectPath, {
|
||||
...removed,
|
||||
agents: [agent],
|
||||
});
|
||||
await observeProjectAgentRuntime(manager, projectPath, recreated);
|
||||
listAgents.mockClear();
|
||||
|
||||
await expect(preflightProjectAgentRuntime(
|
||||
manager,
|
||||
projectPath,
|
||||
{ listAgents },
|
||||
agent.id,
|
||||
)).resolves.toEqual({ ready: false, runtimeGeneration: 1 });
|
||||
expect(listAgents).not.toHaveBeenCalled();
|
||||
|
||||
runtimeGeneration = 2;
|
||||
await expect(preflightProjectAgentRuntime(
|
||||
manager,
|
||||
projectPath,
|
||||
{ listAgents },
|
||||
agent.id,
|
||||
)).resolves.toEqual({ ready: true, runtimeGeneration: 2 });
|
||||
} finally {
|
||||
await rm(projectPath, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('never treats an attached runtime generation as an authoritative Agent baseline', async () => {
|
||||
const projectPath = await mkdtemp(path.join(tmpdir(), 'makelore-agent-attached-runtime-'));
|
||||
try {
|
||||
|
||||
@@ -50,6 +50,7 @@ function renderCommandHook(
|
||||
const actions = {
|
||||
rename: vi.fn<ChatCommandActions['rename']>().mockResolvedValue(undefined),
|
||||
timeline: vi.fn<ChatCommandActions['timeline']>(),
|
||||
models: vi.fn<ChatCommandActions['models']>(),
|
||||
compact: vi.fn<ChatCommandActions['compact']>().mockResolvedValue(undefined),
|
||||
share: vi.fn<ChatCommandActions['share']>().mockImplementation(async () => {
|
||||
context.shared = true;
|
||||
@@ -138,6 +139,17 @@ function renderCommandHook(
|
||||
}
|
||||
|
||||
describe('useChatCommands', () => {
|
||||
it('opens the shared model selector for both /models and /model', async () => {
|
||||
const fixture = renderCommandHook('/models');
|
||||
|
||||
await act(async () => fixture.result.current.tryExecuteDraft());
|
||||
expect(fixture.actions.models).toHaveBeenCalledOnce();
|
||||
expect(fixture.getDraft()).toBe('');
|
||||
|
||||
await fixture.run('/model');
|
||||
expect(fixture.actions.models).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('fills a palette selection and executes it only on the next Enter', async () => {
|
||||
const fixture = renderCommandHook('/comp');
|
||||
act(() => fixture.result.current.handleKeyDown(keyEvent('Enter')));
|
||||
|
||||
Reference in New Issue
Block a user