mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-08 19:19:42 +00:00
chore: rebase local deployment features onto hapi v0.30.3
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user