fix(opencode): switch session models without restart

This commit is contained in:
2026-08-20 22:38:18 +08:00
parent 1c6b004436
commit c0163bc507
18 changed files with 718 additions and 92 deletions

View File

@@ -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.

View File

@@ -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 的消息排队语义保持不变。

View File

@@ -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) : '';

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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"

View File

@@ -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'),

View File

@@ -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(

View File

@@ -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 {

View File

@@ -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;

View File

@@ -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');
});

View File

@@ -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');

View File

@@ -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',

View File

@@ -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' }), {

View File

@@ -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);
});

View File

@@ -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');

View File

@@ -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 {

View File

@@ -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')));