chore: rebase local deployment features onto hapi v0.30.3

This commit is contained in:
2026-09-13 15:55:04 +08:00
parent 95b06bd502
commit cb0dbd7d22
210 changed files with 16517 additions and 2811 deletions
+92 -59
View File
@@ -24,9 +24,10 @@ import {
import { OpencodePermissionHandler } from './utils/permissionHandler';
import { getOpencodeNativeToolInstruction, PLAN_MODE_INSTRUCTION } from './utils/systemPrompt';
import { resolveThoughtLevelEffort } from './thoughtLevelEffort';
import { fetchOpenCodeReasoningEffortState, type OpenCodeReasoningEffortState } from './utils/opencodeVariants';
import type { AgentSessionConfigOptionDescriptor } from '@/agent/types';
type OpencodeRemoteLauncherOptions = {
onModelRollback?: (model: string | null) => void;
onReasoningEffortRollback?: (effort: string | null) => void;
// Called with `true` once the ACP backend + internal HTTP baseUrl are
// ready (so /compact can actually run) and with `false` whenever this
@@ -129,6 +130,7 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase {
private defaultBackendModel: string | null = null;
private currentBackendEffort: string | null = null;
private defaultBackendEffort: string | null = null;
private nativeReasoningEffortState: OpenCodeReasoningEffortState | null = null;
private setModelSupported: boolean | undefined = undefined;
private setEffortSupported: boolean | undefined = undefined;
private activeAcpSessionId: string | null = null;
@@ -251,33 +253,8 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase {
const thoughtLevelOption = backend.getThoughtLevelConfigOption?.(acpSessionId);
this.currentBackendEffort = thoughtLevelOption?.currentValue ?? null;
this.defaultBackendEffort = this.currentBackendEffort;
// The CLI may have been launched with an explicit --model that differs
// from the ACP session's own default. Apply it eagerly right here so
// the new model's thought_level config options are captured (via
// setModel's set_config_option round-trip) *before* the web UI's first
// effort-options poll — otherwise a variant-capable startup model looks
// unsupported until after the first turn. On failure just warn: the
// first batch's existing inline switch path retries the same model.
const requestedStartupModel = this.session.getModel?.();
if (
!this.shouldExit
&& typeof requestedStartupModel === 'string'
&& requestedStartupModel.length > 0
&& requestedStartupModel !== this.defaultBackendModel
&& typeof backend.setModel === 'function'
) {
try {
await backend.setModel(acpSessionId, requestedStartupModel, { flavor: 'opencode' });
this.currentBackendModel = requestedStartupModel;
// The lookup above ran before the switch — re-query so the
// seeded effort reflects the eagerly applied model.
const refreshedThoughtLevel = backend.getThoughtLevelConfigOption?.(acpSessionId);
this.currentBackendEffort = refreshedThoughtLevel?.currentValue ?? null;
this.defaultBackendEffort = this.currentBackendEffort;
} catch (error) {
logger.warn('[opencode-remote] Eager startup model application failed; first batch will retry inline', error);
}
if (!thoughtLevelOption) {
await this.refreshNativeReasoningEffortState(acpSessionId, this.currentBackendModel);
}
// Let the caller (runOpencode.ts) know native /compact can actually
@@ -329,7 +306,18 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase {
const targetModelId = requestedModel === null
? this.defaultBackendModel
: requestedModel ?? currentModelId;
if (!effortOption) {
if (effortOption) {
return {
success: true,
options: effortOption.options,
currentValue: effortOption.currentValue ?? null,
currentModelId,
targetModelId
};
}
await this.refreshNativeReasoningEffortState(acpSessionId, this.currentBackendModel);
const nativeOption = this.nativeReasoningEffortState?.option;
if (!nativeOption) {
return {
success: false,
error: 'OpenCode reasoning effort options are not available',
@@ -339,11 +327,8 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase {
}
return {
success: true,
options: effortOption.options,
currentValue: effortOption.currentValue ?? null,
// Lets the web client detect "options still belong to the
// previous model" while a requested switch has not been
// applied by the backend yet.
options: nativeOption.options,
currentValue: this.currentBackendEffort ?? nativeOption.currentValue ?? null,
currentModelId,
targetModelId
};
@@ -432,22 +417,13 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase {
this.currentBackendModel = requestedModel;
} else if (requestedModel && requestedModel !== this.currentBackendModel) {
if (!backend.setModel || this.setModelSupported === false) {
this.rollbackModel(batch, this.currentBackendModel);
batch.mode.model = this.currentBackendModel ?? undefined;
} else {
logger.debug(`[opencode-remote] Switching model inline: ${this.currentBackendModel} -> ${requestedModel}`);
try {
await backend.setModel(acpSessionId, requestedModel, { flavor: 'opencode' });
this.currentBackendModel = requestedModel;
this.setModelSupported = true;
// set_config_option("model") also switches the backend's
// effort currentValue — refresh both cached efforts so
// a subsequent request equal to the stale value still
// performs the round-trip instead of being skipped,
// and an unset effort falls back to the *new* model's
// default rather than reapplying the old model's.
const refreshedInlineEffort = backend.getThoughtLevelConfigOption?.(acpSessionId);
this.currentBackendEffort = refreshedInlineEffort?.currentValue ?? null;
this.defaultBackendEffort = this.currentBackendEffort;
// Reflect the resolved model back into the batch so
// downstream display logic sees the concrete id rather
// than a `null` placeholder.
@@ -469,20 +445,29 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase {
message: `Failed to switch model to ${requestedModel}. Continuing with ${this.currentBackendModel ?? '(default)'}.`
});
}
this.rollbackModel(batch, this.currentBackendModel);
batch.mode.model = this.currentBackendModel ?? undefined;
}
}
if (requestedModel === this.currentBackendModel) {
await this.refreshNativeReasoningEffortState(acpSessionId, this.currentBackendModel);
}
}
const requestedEffort = batch.mode.modelReasoningEffort ?? this.defaultBackendEffort;
if (requestedEffort && requestedEffort !== this.currentBackendEffort) {
const thoughtLevelOption = backend.getThoughtLevelConfigOption?.(acpSessionId);
if (!backend.setConfigOption || !thoughtLevelOption || this.setEffortSupported === false) {
const nativeEffortOption = this.nativeReasoningEffortState
&& this.nativeReasoningEffortState.modelId === this.currentBackendModel
? this.nativeReasoningEffortState.option
: undefined;
const effortOption: AgentSessionConfigOptionDescriptor | undefined = thoughtLevelOption ?? nativeEffortOption;
const nativeVariantSupported = Boolean(nativeEffortOption && backend.promptWithVariant);
if (!effortOption || (!backend.setConfigOption && !nativeVariantSupported) || this.setEffortSupported === false) {
this.rollbackReasoningEffort(batch, this.currentBackendEffort);
} else {
const resolvedEffort = resolveThoughtLevelEffort(
requestedEffort,
thoughtLevelOption,
effortOption,
this.currentBackendEffort ?? this.defaultBackendEffort
);
if (!resolvedEffort || resolvedEffort === this.currentBackendEffort) {
@@ -495,8 +480,17 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase {
} else {
logger.debug(`[opencode-remote] Switching effort inline: ${this.currentBackendEffort ?? '(default)'} -> ${resolvedEffort}`);
try {
await backend.setConfigOption(acpSessionId, thoughtLevelOption.id, resolvedEffort);
this.currentBackendEffort = resolvedEffort;
if (nativeVariantSupported) {
this.currentBackendEffort = resolvedEffort;
this.nativeReasoningEffortState = {
...this.nativeReasoningEffortState!,
currentValue: resolvedEffort,
option: { ...nativeEffortOption!, currentValue: resolvedEffort }
};
} else {
await backend.setConfigOption!(acpSessionId, effortOption.id, resolvedEffort);
this.currentBackendEffort = resolvedEffort;
}
this.setEffortSupported = true;
if (requestedEffort !== resolvedEffort) {
this.rollbackReasoningEffort(batch, resolvedEffort);
@@ -663,9 +657,26 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase {
session.onThinkingChange(true);
try {
await backend.prompt(acpSessionId, promptContent, (message: AgentMessage) => {
this.handleAgentMessage(message);
});
const nativeVariant = this.nativeReasoningEffortState
&& this.nativeReasoningEffortState.modelId === this.currentBackendModel
? this.currentBackendEffort
: null;
const nativeModel = splitProviderModel(this.currentBackendModel);
if (nativeVariant && nativeModel && backend.promptWithVariant && this.baseUrl) {
await backend.promptWithVariant(acpSessionId, promptContent, (message: AgentMessage) => {
this.handleAgentMessage(message);
}, {
baseUrl: this.baseUrl,
directory: session.path,
providerId: nativeModel.providerId,
modelId: nativeModel.modelId,
variant: nativeVariant
});
} else {
await backend.prompt(acpSessionId, promptContent, (message: AgentMessage) => {
this.handleAgentMessage(message);
});
}
void backend.refreshSessionInfo(acpSessionId, session.path);
} catch (error) {
logger.warn('[opencode-remote] prompt failed', error);
@@ -832,6 +843,35 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase {
this.surfaceAgentError(formatOpencodePromptError(error));
}
private async refreshNativeReasoningEffortState(
acpSessionId: string,
modelId: string | null
): Promise<void> {
const baseUrl = this.baseUrl;
if (!baseUrl || !modelId) {
this.nativeReasoningEffortState = null;
return;
}
const state = await fetchOpenCodeReasoningEffortState({
baseUrl,
directory: this.session.path,
sessionId: acpSessionId,
modelId
});
this.nativeReasoningEffortState = state;
if (!state) return;
// The native session is authoritative. A variant may have been
// changed in OpenCode itself between HAPI polls, so refresh both the
// current and launch-time fallback values from the live session.
this.currentBackendEffort = state.currentValue ?? state.option.options[0]?.value ?? null;
this.defaultBackendEffort = this.currentBackendEffort;
// A model switch can move from an ACP-only effort option to native
// variants (or vice versa); do not retain a previous capability probe.
this.setEffortSupported = undefined;
}
private surfaceAgentError(message: string): void {
this.session.sendAgentMessage({ type: 'error', message });
this.messageBuffer.addMessage(message, 'status');
@@ -859,13 +899,6 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase {
this.options.onReasoningEffortRollback?.(effort);
}
private rollbackModel(batch: { mode: OpencodeMode }, model: string | null): void {
batch.mode.model = model ?? undefined;
this.session.setModel(model);
this.session.pushKeepAlive();
this.options.onModelRollback?.(model);
}
/**
* Executes the /compact operation for a queued `operation:'compact'`
* batch. Reached only through the main dequeue loop (so it never runs
@@ -0,0 +1,74 @@
import { describe, expect, it, vi } from 'vitest';
import { fetchOpenCodeReasoningEffortState } from './opencodeVariants';
function response(payload: unknown, ok = true): Response {
return new Response(JSON.stringify(payload), {
status: ok ? 200 : 404,
headers: { 'content-type': 'application/json' }
});
}
describe('fetchOpenCodeReasoningEffortState', () => {
it('discovers native model variants and the live selected value', async () => {
const fetchImpl = vi.fn(async (url: string) => {
if (url.includes('/session/')) {
return response({
model: {
providerID: 'opencode-go',
id: 'deepseek-v4.1-flash',
variant: 'max'
}
});
}
return response({
all: [{
id: 'opencode-go',
models: {
'deepseek-v4.1-flash': {
variants: {
low: { reasoningEffort: 'low' },
high: { reasoningEffort: 'high' },
max: { reasoningEffort: 'max' }
}
}
}
}]
});
});
const state = await fetchOpenCodeReasoningEffortState({
baseUrl: 'http://127.0.0.1:1234',
directory: '/workspace',
sessionId: 'session-1',
fetchImpl
});
expect(state).toMatchObject({
modelId: 'opencode-go/deepseek-v4.1-flash',
currentValue: 'max',
option: {
category: 'thought_level',
options: [
{ value: 'low', name: 'Low' },
{ value: 'high', name: 'High' },
{ value: 'max', name: 'Max' }
]
}
});
expect(fetchImpl).toHaveBeenCalledTimes(2);
});
it('returns null when the selected model has no variants', async () => {
const fetchImpl = vi.fn(async (url: string) => url.includes('/session/')
? response({ model: { providerID: 'opencode-go', id: 'big-pickle' } })
: response({ all: [{ id: 'opencode-go', models: { 'big-pickle': { variants: {} } } }] }));
await expect(fetchOpenCodeReasoningEffortState({
baseUrl: 'http://127.0.0.1:1234',
directory: '/workspace',
sessionId: 'session-1',
fetchImpl
})).resolves.toBeNull();
});
});
+115
View File
@@ -0,0 +1,115 @@
import type { AgentSessionConfigOptionDescriptor } from '@/agent/types';
/** Minimal fetch signature used by the OpenCode variant discovery tests. */
export type OpenCodeVariantFetch = (url: string, init?: RequestInit) => Promise<Response>;
export type OpenCodeReasoningEffortState = {
modelId: string;
currentValue: string | null;
option: AgentSessionConfigOptionDescriptor;
};
type JsonRecord = Record<string, unknown>;
function isRecord(value: unknown): value is JsonRecord {
return typeof value === 'object' && value !== null;
}
function asString(value: unknown): string | null {
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
}
function splitModelId(value: string): { providerId: string; modelId: string } | null {
const separator = value.indexOf('/');
if (separator <= 0 || separator === value.length - 1) return null;
return {
providerId: value.slice(0, separator),
modelId: value.slice(separator + 1)
};
}
function displayName(value: string): string {
return value
.replace(/[-_]+/g, ' ')
.replace(/\b\w/g, (character) => character.toUpperCase());
}
function getProviders(payload: unknown): JsonRecord[] {
if (!isRecord(payload)) return [];
const providers = Array.isArray(payload.all)
? payload.all
: Array.isArray(payload.providers)
? payload.providers
: [];
return providers.filter(isRecord);
}
/**
* Reads OpenCode's native model variants.
*
* OpenCode exposes DeepSeek reasoning levels as model `variants` in its HTTP
* API. They are intentionally not inferred from the model name: providers can
* add/remove levels independently, and the ACP configOptions response does not
* currently include this information.
*/
export async function fetchOpenCodeReasoningEffortState(options: {
baseUrl: string;
directory: string;
sessionId: string;
modelId?: string | null;
fetchImpl?: OpenCodeVariantFetch;
}): Promise<OpenCodeReasoningEffortState | null> {
const fetchFn = options.fetchImpl ?? (fetch as OpenCodeVariantFetch);
const query = `?directory=${encodeURIComponent(options.directory)}`;
try {
const sessionResponse = await fetchFn(
`${options.baseUrl}/session/${encodeURIComponent(options.sessionId)}${query}`,
{ method: 'GET' }
);
if (!sessionResponse.ok) return null;
const sessionPayload: unknown = await sessionResponse.json().catch(() => null);
const sessionModel = isRecord(sessionPayload) && isRecord(sessionPayload.model)
? sessionPayload.model
: null;
const providerId = asString(sessionModel?.providerID)
?? (options.modelId ? splitModelId(options.modelId)?.providerId : null);
const modelId = asString(sessionModel?.id)
?? (options.modelId ? splitModelId(options.modelId)?.modelId : null);
if (!providerId || !modelId) return null;
const providersResponse = await fetchFn(`${options.baseUrl}/provider${query}`, { method: 'GET' });
if (!providersResponse.ok) return null;
const providersPayload: unknown = await providersResponse.json().catch(() => null);
const provider = getProviders(providersPayload).find((entry) => entry.id === providerId);
const models = provider && isRecord(provider.models) ? provider.models : null;
const model = models && isRecord(models[modelId]) ? models[modelId] : null;
const variants = model && isRecord(model.variants) ? model.variants : null;
if (!variants) return null;
const optionsList = Object.keys(variants)
.filter((value) => value.trim().length > 0)
.map((value) => ({ value, name: displayName(value) }));
if (optionsList.length === 0) return null;
const currentVariant = asString(sessionModel?.variant);
return {
modelId: `${providerId}/${modelId}`,
currentValue: currentVariant && optionsList.some((entry) => entry.value === currentVariant)
? currentVariant
: null,
option: {
id: `opencode/variant/${providerId}/${modelId}`,
category: 'thought_level',
currentValue: currentVariant ?? undefined,
options: optionsList
}
};
} catch {
// Discovery is supplementary. An older OpenCode build may not expose
// /provider or /session; the ACP thought-level path can still be used.
return null;
}
}