From 283a135a31c91203878882d461b719502294dc2e Mon Sep 17 00:00:00 2001 From: doublepi123 Date: Thu, 15 Oct 2026 01:30:45 +0000 Subject: [PATCH 01/15] feat(providers): fetch a custom provider's models from /models The custom provider dialog only accepted hand-typed model ids, which is tedious and error-prone for gateways that proxy dozens of models. Add a "Fetch models" button that calls the OpenAI-compatible GET /models endpoint (with an optional API key, falling back to the stored one) and fills the model field, plus an Edit action so an existing custom provider's model list can be refreshed without removing it. --- .../andcode/di/ViewModelModule.kt | 8 ++ .../feature/settings/CustomProviderDialog.kt | 80 ++++++++++-- .../feature/settings/ProviderAuthUiModels.kt | 17 ++- .../settings/ProviderSettingsScreen.kt | 13 ++ .../feature/settings/SettingsViewModel.kt | 115 +++++++++++++++++- .../local/CustomProviderModelFetcher.kt | 81 ++++++++++++ .../yugahashimoto/andcode/ui/AndCodeApp.kt | 3 + .../andcode/ui/navigation/SettingsNavGraph.kt | 3 + app/src/main/res/values-ar/strings.xml | 16 +++ app/src/main/res/values-es/strings.xml | 13 ++ app/src/main/res/values-fr/strings.xml | 13 ++ app/src/main/res/values-ja/strings.xml | 11 ++ app/src/main/res/values-pt-rBR/strings.xml | 13 ++ app/src/main/res/values-ru/strings.xml | 14 +++ app/src/main/res/values-zh-rCN/strings.xml | 11 ++ app/src/main/res/values/strings.xml | 12 ++ .../settings/CustomProviderDialogStateTest.kt | 23 ++++ .../local/CustomProviderModelFetcherTest.kt | 82 +++++++++++++ 18 files changed, 513 insertions(+), 15 deletions(-) create mode 100644 app/src/main/java/com/yugahashimoto/andcode/runtime/local/CustomProviderModelFetcher.kt create mode 100644 app/src/test/java/com/yugahashimoto/andcode/feature/settings/CustomProviderDialogStateTest.kt create mode 100644 app/src/test/java/com/yugahashimoto/andcode/runtime/local/CustomProviderModelFetcherTest.kt diff --git a/app/src/main/java/com/yugahashimoto/andcode/di/ViewModelModule.kt b/app/src/main/java/com/yugahashimoto/andcode/di/ViewModelModule.kt index b17013820..3fb75b63a 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/di/ViewModelModule.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/di/ViewModelModule.kt @@ -5,6 +5,7 @@ import com.yugahashimoto.andcode.feature.activity.ActivityViewModel import com.yugahashimoto.andcode.feature.chat.ChatViewModel import com.yugahashimoto.andcode.feature.settings.SettingsViewModel import com.yugahashimoto.andcode.feature.workspace.WorkspaceViewModel +import com.yugahashimoto.andcode.runtime.local.CustomProviderModelFetcher import kotlinx.coroutines.flow.first import org.koin.android.ext.koin.androidContext import org.koin.core.module.dsl.viewModel @@ -53,6 +54,13 @@ val viewModelModule = androidContext().getString(com.yugahashimoto.andcode.R.string.provider_disconnect_rejected), customProviderInvalidMessage = androidContext().getString(com.yugahashimoto.andcode.R.string.provider_custom_invalid), + modelFetcher = CustomProviderModelFetcher(get()), + customProviderFetchNeedsBaseUrlMessage = + androidContext().getString(com.yugahashimoto.andcode.R.string.provider_custom_fetch_needs_base_url), + customProviderFetchEmptyMessage = + androidContext().getString(com.yugahashimoto.andcode.R.string.provider_custom_fetch_empty), + customProviderFetchFailedMessage = + androidContext().getString(com.yugahashimoto.andcode.R.string.provider_custom_fetch_failed), ) } diff --git a/app/src/main/java/com/yugahashimoto/andcode/feature/settings/CustomProviderDialog.kt b/app/src/main/java/com/yugahashimoto/andcode/feature/settings/CustomProviderDialog.kt index 23273f6dd..d0a8b3f8c 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/feature/settings/CustomProviderDialog.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/feature/settings/CustomProviderDialog.kt @@ -2,18 +2,27 @@ package com.yugahashimoto.andcode.feature.settings import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.unit.dp import com.yugahashimoto.andcode.R @@ -24,12 +33,20 @@ fun CustomProviderDialog( onNameChange: (String) -> Unit, onBaseUrlChange: (String) -> Unit, onModelsChange: (String) -> Unit, + onApiKeyChange: (String) -> Unit, + onFetchModels: () -> Unit, onSubmit: () -> Unit, onDismiss: () -> Unit, ) { AlertDialog( onDismissRequest = { if (!state.isSubmitting) onDismiss() }, - title = { Text(stringResource(R.string.provider_add_custom_title)) }, + title = { + Text( + stringResource( + if (state.editing) R.string.provider_edit_custom_title else R.string.provider_add_custom_title, + ), + ) + }, text = { Column( modifier = Modifier.verticalScroll(rememberScrollState()), @@ -46,7 +63,7 @@ fun CustomProviderDialog( label = { Text(stringResource(R.string.provider_custom_id_label)) }, placeholder = { Text(stringResource(R.string.provider_custom_id_hint)) }, singleLine = true, - enabled = !state.isSubmitting, + enabled = !state.isBusy && !state.editing, modifier = Modifier.fillMaxWidth(), ) OutlinedTextField( @@ -55,7 +72,7 @@ fun CustomProviderDialog( label = { Text(stringResource(R.string.provider_custom_name_label)) }, placeholder = { Text(stringResource(R.string.provider_custom_name_hint)) }, singleLine = true, - enabled = !state.isSubmitting, + enabled = !state.isBusy, modifier = Modifier.fillMaxWidth(), ) OutlinedTextField( @@ -64,16 +81,63 @@ fun CustomProviderDialog( label = { Text(stringResource(R.string.provider_custom_base_url_label)) }, placeholder = { Text(stringResource(R.string.provider_custom_base_url_hint)) }, singleLine = true, - enabled = !state.isSubmitting, + enabled = !state.isBusy, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), modifier = Modifier.fillMaxWidth(), ) + OutlinedTextField( + value = state.apiKey, + onValueChange = onApiKeyChange, + label = { Text(stringResource(R.string.api_key_hint)) }, + supportingText = { + Text( + stringResource( + if (state.hasStoredApiKey) { + R.string.provider_custom_api_key_stored_help + } else { + R.string.provider_custom_api_key_help + }, + ), + ) + }, + singleLine = true, + enabled = !state.isBusy, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + modifier = Modifier.fillMaxWidth(), + ) + Row( + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedButton(onClick = onFetchModels, enabled = !state.isBusy) { + Text(stringResource(R.string.provider_custom_fetch_models)) + } + if (state.isFetchingModels) { + CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(20.dp)) + } + } + state.fetchedModelCount?.let { count -> + Text( + pluralStringResource(R.plurals.provider_custom_fetch_success, count, count), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + ) + } + state.fetchError?.let { error -> + Text( + error, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } OutlinedTextField( value = state.models, onValueChange = onModelsChange, label = { Text(stringResource(R.string.provider_custom_models_label)) }, placeholder = { Text(stringResource(R.string.provider_custom_models_hint)) }, - singleLine = true, - enabled = !state.isSubmitting, + maxLines = 6, + enabled = !state.isBusy, modifier = Modifier.fillMaxWidth(), ) state.error?.let { error -> @@ -86,8 +150,8 @@ fun CustomProviderDialog( } }, confirmButton = { - Button(onClick = onSubmit, enabled = !state.isSubmitting) { - Text(stringResource(R.string.continue_label)) + Button(onClick = onSubmit, enabled = !state.isBusy) { + Text(stringResource(if (state.editing) R.string.save else R.string.continue_label)) } }, dismissButton = { diff --git a/app/src/main/java/com/yugahashimoto/andcode/feature/settings/ProviderAuthUiModels.kt b/app/src/main/java/com/yugahashimoto/andcode/feature/settings/ProviderAuthUiModels.kt index ee95645ba..d7ed131f9 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/feature/settings/ProviderAuthUiModels.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/feature/settings/ProviderAuthUiModels.kt @@ -42,6 +42,21 @@ data class CustomProviderDialogState( val name: String = "", val baseUrl: String = "", val models: String = "", + /** Only used to call `/models`; it is handed to the API-key step afterwards, never stored here. */ + val apiKey: String = "", + /** True when the dialog edits an already registered provider, whose id must not change. */ + val editing: Boolean = false, + /** Whether a stored credential exists for [id], so fetching works without re-typing the key. */ + val hasStoredApiKey: Boolean = false, + val isFetchingModels: Boolean = false, + val fetchedModelCount: Int? = null, + val fetchError: String? = null, val isSubmitting: Boolean = false, val error: String? = null, -) +) { + val isBusy: Boolean get() = isSubmitting || isFetchingModels +} + +/** Splits the comma- or newline-separated model field into distinct, trimmed ids. */ +internal fun parseModelIdList(raw: String): List = + raw.split(',', '\n').map(String::trim).filter(String::isNotEmpty).distinct() diff --git a/app/src/main/java/com/yugahashimoto/andcode/feature/settings/ProviderSettingsScreen.kt b/app/src/main/java/com/yugahashimoto/andcode/feature/settings/ProviderSettingsScreen.kt index 9b2ae68af..263bbd81e 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/feature/settings/ProviderSettingsScreen.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/feature/settings/ProviderSettingsScreen.kt @@ -66,6 +66,9 @@ fun ProviderSettingsScreen( onCustomProviderNameChange: (String) -> Unit, onCustomProviderBaseUrlChange: (String) -> Unit, onCustomProviderModelsChange: (String) -> Unit, + onCustomProviderApiKeyChange: (String) -> Unit, + onFetchCustomProviderModels: () -> Unit, + onEditCustomProvider: (String) -> Unit, onSubmitCustomProvider: () -> Unit, onDismissCustomProviderDialog: () -> Unit, onRemoveCustomProvider: (String) -> Unit, @@ -155,6 +158,7 @@ fun ProviderSettingsScreen( onConnect = { onOpenProviderAuth(provider.id) }, onDisconnect = { onDisconnectProvider(provider.id) }, onRemove = { onRemoveCustomProvider(provider.id) }, + onEdit = { onEditCustomProvider(provider.id) }, ) } } @@ -202,6 +206,8 @@ fun ProviderSettingsScreen( onNameChange = onCustomProviderNameChange, onBaseUrlChange = onCustomProviderBaseUrlChange, onModelsChange = onCustomProviderModelsChange, + onApiKeyChange = onCustomProviderApiKeyChange, + onFetchModels = onFetchCustomProviderModels, onSubmit = onSubmitCustomProvider, onDismiss = onDismissCustomProviderDialog, ) @@ -289,6 +295,7 @@ private fun ProviderRow( onConnect: () -> Unit, onDisconnect: () -> Unit, onRemove: () -> Unit, + onEdit: () -> Unit, ) { Column( modifier = @@ -346,6 +353,9 @@ private fun ProviderRow( } } if (isCustom) { + TextButton(onClick = onEdit) { + Text(stringResource(R.string.provider_custom_edit)) + } TextButton(onClick = onRemove) { Text(stringResource(R.string.provider_custom_remove)) } @@ -395,6 +405,9 @@ private fun ProviderSettingsScreenPreview() { onCustomProviderNameChange = {}, onCustomProviderBaseUrlChange = {}, onCustomProviderModelsChange = {}, + onCustomProviderApiKeyChange = {}, + onFetchCustomProviderModels = {}, + onEditCustomProvider = {}, onSubmitCustomProvider = {}, onDismissCustomProviderDialog = {}, onRemoveCustomProvider = {}, diff --git a/app/src/main/java/com/yugahashimoto/andcode/feature/settings/SettingsViewModel.kt b/app/src/main/java/com/yugahashimoto/andcode/feature/settings/SettingsViewModel.kt index 6b8e6121b..f7294c94c 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/feature/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/feature/settings/SettingsViewModel.kt @@ -25,6 +25,7 @@ import com.yugahashimoto.andcode.runtime.LocalAgent import com.yugahashimoto.andcode.runtime.RuntimeRegistry import com.yugahashimoto.andcode.runtime.RuntimeTarget import com.yugahashimoto.andcode.runtime.local.CustomProviderDefinition +import com.yugahashimoto.andcode.runtime.local.CustomProviderModelFetcher import com.yugahashimoto.andcode.runtime.local.CustomProviderStore import com.yugahashimoto.andcode.runtime.local.LocalProviderCredentialStore import com.yugahashimoto.andcode.runtime.local.toProviderConfig @@ -107,6 +108,10 @@ class SettingsViewModel( private val voskModels: VoskModelStore, private val providerDisconnectRejectedMessage: String = "Provider disconnect was not accepted", private val customProviderInvalidMessage: String = "Enter a provider id, base URL, and at least one model id", + private val modelFetcher: CustomProviderModelFetcher = CustomProviderModelFetcher(), + private val customProviderFetchNeedsBaseUrlMessage: String = "Enter a base URL first", + private val customProviderFetchEmptyMessage: String = "The endpoint returned no models", + private val customProviderFetchFailedMessage: String = "Could not fetch the model list", ) : ViewModel() { private val settingsTick = MutableStateFlow(0) private val oauthState = MutableStateFlow(OAuthState()) @@ -608,8 +613,32 @@ class SettingsViewModel( customProviderDialogState.value = CustomProviderDialogState() } + /** + * Opens the same dialog prefilled with a registered provider, so its model list can be changed + * (or re-fetched) without removing and re-adding it. The id stays fixed: it keys the stored + * credential and every saved model selection. + */ + fun openEditCustomProvider(providerId: String) { + val definition = customProviders.definitions().firstOrNull { it.id == providerId } ?: return + customProviderDialogState.value = + CustomProviderDialogState( + id = definition.id, + name = definition.name, + baseUrl = definition.baseUrl, + models = definition.models.joinToString(", "), + editing = true, + hasStoredApiKey = credentials.hasCredential(definition.id), + ) + } + fun updateCustomProviderId(value: String) { - customProviderDialogState.update { it?.copy(id = value, error = null) } + customProviderDialogState.update { + if (it == null || it.editing) { + it + } else { + it.copy(id = value, hasStoredApiKey = credentials.hasCredential(value), error = null) + } + } } fun updateCustomProviderName(value: String) { @@ -617,11 +646,60 @@ class SettingsViewModel( } fun updateCustomProviderBaseUrl(value: String) { - customProviderDialogState.update { it?.copy(baseUrl = value, error = null) } + customProviderDialogState.update { it?.copy(baseUrl = value, error = null, fetchError = null) } } fun updateCustomProviderModels(value: String) { - customProviderDialogState.update { it?.copy(models = value, error = null) } + customProviderDialogState.update { it?.copy(models = value, error = null, fetchedModelCount = null) } + } + + fun updateCustomProviderApiKey(value: String) { + customProviderDialogState.update { it?.copy(apiKey = value, error = null, fetchError = null) } + } + + /** + * Fills the model field from the endpoint's own `GET /models` listing, using the key + * typed into the dialog or, failing that, the one already stored for this provider id. + */ + fun fetchCustomProviderModels() { + val dialog = customProviderDialogState.value ?: return + if (dialog.isBusy || customProviderJob?.isActive == true) return + val baseUrl = dialog.baseUrl.trim() + if (baseUrl.isEmpty()) { + customProviderDialogState.update { it?.copy(fetchError = customProviderFetchNeedsBaseUrlMessage) } + return + } + val apiKey = dialog.apiKey.trim().ifEmpty { credentials.credentials()[dialog.id.trim()].orEmpty() } + customProviderJob = + viewModelScope.launch { + customProviderDialogState.update { + it?.copy(isFetchingModels = true, fetchError = null, fetchedModelCount = null, error = null) + } + runCatching { modelFetcher.fetch(baseUrl, apiKey) } + .onSuccess { models -> + customProviderJob = null + customProviderDialogState.update { + if (models.isEmpty()) { + it?.copy(isFetchingModels = false, fetchError = customProviderFetchEmptyMessage) + } else { + it?.copy( + isFetchingModels = false, + models = models.joinToString(", "), + fetchedModelCount = models.size, + ) + } + } + }.onFailure { error -> + customProviderJob = null + customProviderDialogState.update { + it?.copy( + isFetchingModels = false, + fetchError = + SecretRedaction.redact(error.message?.takeIf(String::isNotBlank) ?: customProviderFetchFailedMessage), + ) + } + } + } } fun dismissCustomProviderDialog() { @@ -637,15 +715,16 @@ class SettingsViewModel( */ fun submitCustomProvider() { val dialog = customProviderDialogState.value ?: return - if (dialog.isSubmitting || customProviderJob?.isActive == true) return + if (dialog.isBusy || customProviderJob?.isActive == true) return val target = providerTarget() ?: return val definition = CustomProviderDefinition( id = dialog.id.trim(), name = dialog.name.trim().ifEmpty { dialog.id.trim() }, baseUrl = dialog.baseUrl.trim(), - models = dialog.models.split(',').map(String::trim).filter(String::isNotEmpty).distinct(), + models = parseModelIdList(dialog.models), ) + val typedApiKey = dialog.apiKey.trim() if (definition.id.isEmpty() || definition.baseUrl.isEmpty() || definition.models.isEmpty()) { customProviderDialogState.update { it?.copy(error = customProviderInvalidMessage) } return @@ -666,7 +745,7 @@ class SettingsViewModel( catalog.refreshProvidersOnly() catalog.refresh() refreshProviderAuth() - openProviderAuth(definition.id) + finishCustomProviderSubmit(definition.id, dialog.editing, typedApiKey, target) }.onFailure { error -> customProviderJob = null customProviderDialogState.update { @@ -676,6 +755,30 @@ class SettingsViewModel( } } + private fun finishCustomProviderSubmit( + providerId: String, + editing: Boolean, + typedApiKey: String, + target: RuntimeTarget, + ) { + if (editing && typedApiKey.isEmpty() && credentials.hasCredential(providerId)) { + // Only the definition changed and the stored key still applies: skip the key dialog, + // but the runtime still has to reload to pick up the new model list. + settingsTick.update { it + 1 } + if (target.kind == BackendKind.LOCAL) onLocalRuntimeRestartNeeded?.invoke() + return + } + openProviderAuth(providerId) + if (typedApiKey.isEmpty()) return + // The key was already typed to fetch models; prefill it so the user just confirms instead + // of pasting it a second time. + val apiIndex = oauthState.value.dialog?.methods?.indexOfFirst { it.type == "api" } ?: -1 + if (apiIndex >= 0) { + selectProviderAuthMethod(apiIndex) + updateProviderApiKey(typedApiKey) + } + } + /** Forgets a custom provider entirely: its definition and its stored credential. */ fun removeCustomProvider(providerId: String) { customProviders.remove(providerId) diff --git a/app/src/main/java/com/yugahashimoto/andcode/runtime/local/CustomProviderModelFetcher.kt b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/CustomProviderModelFetcher.kt new file mode 100644 index 000000000..a33c2f6a7 --- /dev/null +++ b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/CustomProviderModelFetcher.kt @@ -0,0 +1,81 @@ +package com.yugahashimoto.andcode.runtime.local + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.OkHttpClient +import okhttp3.Request + +/** + * Lists the model ids an OpenAI-compatible endpoint serves, via its `GET /models`. + * + * Typing every model id of a custom provider by hand is error-prone, and gateways that proxy many + * vendors expose dozens of them. The response shape is OpenAI's `{"data":[{"id":"..."}]}`; a bare + * array and a `models` field are accepted too, since several self-hosted servers answer that way. + */ +class CustomProviderModelFetcher( + private val client: OkHttpClient = OkHttpClient(), + private val json: Json = Json { ignoreUnknownKeys = true }, +) { + suspend fun fetch( + baseUrl: String, + apiKey: String?, + ): List = + withContext(Dispatchers.IO) { + val url = modelsUrl(baseUrl) + val request = + Request.Builder() + .url(url) + .header("Accept", "application/json") + .apply { + apiKey?.trim()?.takeIf(String::isNotEmpty)?.let { header("Authorization", "Bearer $it") } + } + .get() + .build() + client.newCall(request).execute().use { response -> + check(response.isSuccessful) { "HTTP ${response.code} from $url" } + parseModelIds(response.body?.string().orEmpty(), json) + } + } + + companion object { + /** `/models`, tolerating a trailing slash; fails on anything that is not an http(s) URL. */ + fun modelsUrl(baseUrl: String): String { + val trimmed = baseUrl.trim().trimEnd('/') + requireNotNull(trimmed.toHttpUrlOrNull()) { "Invalid base URL: $baseUrl" } + return "$trimmed/models" + } + + fun parseModelIds( + body: String, + json: Json = Json { ignoreUnknownKeys = true }, + ): List { + val root = runCatching { json.parseToJsonElement(body) }.getOrElse { throw IllegalStateException("Response is not JSON", it) } + val entries: JsonArray = + when (root) { + is JsonArray -> root + is JsonObject -> (root["data"] as? JsonArray) ?: (root["models"] as? JsonArray) ?: JsonArray(emptyList()) + else -> JsonArray(emptyList()) + } + return entries + .mapNotNull(::modelId) + .map(String::trim) + .filter(String::isNotEmpty) + .distinct() + .sorted() + } + + private fun modelId(entry: JsonElement): String? = + when (entry) { + is JsonPrimitive -> entry.contentOrNull + is JsonObject -> (entry["id"] as? JsonPrimitive)?.contentOrNull ?: (entry["name"] as? JsonPrimitive)?.contentOrNull + else -> null + } + } +} diff --git a/app/src/main/java/com/yugahashimoto/andcode/ui/AndCodeApp.kt b/app/src/main/java/com/yugahashimoto/andcode/ui/AndCodeApp.kt index 9cfee9893..ba6e60f34 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/ui/AndCodeApp.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/ui/AndCodeApp.kt @@ -256,6 +256,9 @@ fun AndCodeApp( voskModels = app.voskModels, providerDisconnectRejectedMessage = context.getString(R.string.provider_disconnect_rejected), customProviderInvalidMessage = context.getString(R.string.provider_custom_invalid), + customProviderFetchNeedsBaseUrlMessage = context.getString(R.string.provider_custom_fetch_needs_base_url), + customProviderFetchEmptyMessage = context.getString(R.string.provider_custom_fetch_empty), + customProviderFetchFailedMessage = context.getString(R.string.provider_custom_fetch_failed), ) }, ) diff --git a/app/src/main/java/com/yugahashimoto/andcode/ui/navigation/SettingsNavGraph.kt b/app/src/main/java/com/yugahashimoto/andcode/ui/navigation/SettingsNavGraph.kt index 1104af3fd..f43ddcb97 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/ui/navigation/SettingsNavGraph.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/ui/navigation/SettingsNavGraph.kt @@ -454,6 +454,9 @@ fun NavGraphBuilder.settingsNavGraph( onCustomProviderNameChange = settingsViewModel::updateCustomProviderName, onCustomProviderBaseUrlChange = settingsViewModel::updateCustomProviderBaseUrl, onCustomProviderModelsChange = settingsViewModel::updateCustomProviderModels, + onCustomProviderApiKeyChange = settingsViewModel::updateCustomProviderApiKey, + onFetchCustomProviderModels = settingsViewModel::fetchCustomProviderModels, + onEditCustomProvider = settingsViewModel::openEditCustomProvider, onSubmitCustomProvider = settingsViewModel::submitCustomProvider, onDismissCustomProviderDialog = settingsViewModel::dismissCustomProviderDialog, onRemoveCustomProvider = settingsViewModel::removeCustomProvider, diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index ce5bd904e..deea8261c 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -198,6 +198,22 @@ مفصولة بفواصل، مثال: gpt-4o, gpt-4o-mini أدخل معرف الموفر وعنوان URL الأساسي ونموذجًا واحدًا على الأقل. إزالة + تعديل + تعديل المزوّد المخصّص + جلب النماذج + اختياري. يُستخدم لجلب النماذج من <base URL>/models، ويمكنك أيضًا إدخاله في الخطوة التالية. + اتركه فارغًا لاستخدام مفتاح API المحفوظ. + أدخل عنوان URL الأساسي أولًا. + لم تُرجع نقطة النهاية أي نماذج. + تعذّر جلب قائمة النماذج. + + تم جلب %1$d نموذج. احذف ما لا تحتاج إليه. + تم جلب نموذج واحد. احذفه إن لم تكن بحاجة إليه. + تم جلب نموذجين. احذف ما لا تحتاج إليه. + تم جلب %1$d نماذج. احذف ما لا تحتاج إليه. + تم جلب %1$d نموذجًا. احذف ما لا تحتاج إليه. + تم جلب %1$d نموذج. احذف ما لا تحتاج إليه. + متابعة أخرى (اكتب إجابتك) رفض الإجابة diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 20e1da151..99b2905d8 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -195,6 +195,19 @@ Separados por comas, ej. gpt-4o, gpt-4o-mini Ingresa un ID de proveedor, una URL base y al menos un ID de modelo. Eliminar + Editar + Editar proveedor personalizado + Obtener modelos + Opcional. Se usa para obtener los modelos de <base URL>/models; también puedes introducirla en el siguiente paso. + Déjalo vacío para usar la clave API guardada. + Introduce primero una URL base. + El endpoint no devolvió ningún modelo. + No se pudo obtener la lista de modelos. + + Se obtuvo %1$d modelo. Elimina los que no quieras. + Se obtuvieron %1$d modelos. Elimina los que no quieras. + Se obtuvieron %1$d modelos. Elimina los que no quieras. + Continuar Otro (escribe tu respuesta) No responder diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 4193c4ad7..1315e153b 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -195,6 +195,19 @@ Séparés par des virgules, ex. gpt-4o, gpt-4o-mini Saisissez un ID de fournisseur, une URL de base et au moins un ID de modèle. Supprimer + Modifier + Modifier le fournisseur personnalisé + Récupérer les modèles + Facultatif. Sert à récupérer les modèles depuis <base URL>/models ; vous pouvez aussi la saisir à l\'étape suivante. + Laissez vide pour utiliser la clé API enregistrée. + Saisissez d\'abord une URL de base. + Le point de terminaison n\'a renvoyé aucun modèle. + Impossible de récupérer la liste des modèles. + + %1$d modèle récupéré. Supprimez ceux dont vous n\'avez pas besoin. + %1$d modèles récupérés. Supprimez ceux dont vous n\'avez pas besoin. + %1$d modèles récupérés. Supprimez ceux dont vous n\'avez pas besoin. + Continuer Autre (saisissez votre réponse) Ne pas répondre diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 9dd253c46..aa88fd77c 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -193,6 +193,17 @@ カンマ区切り。例: gpt-4o, gpt-4o-mini プロバイダーID、ベースURL、モデルIDを少なくとも1つ入力してください。 削除 + 編集 + カスタムプロバイダーを編集 + モデル一覧を取得 + 任意。<base URL>/models からモデルを取得するために使います。次の手順で入力することもできます。 + 空欄のままにすると保存済みの API キーを使います。 + 先にベース URL を入力してください。 + エンドポイントがモデルを返しませんでした。 + モデル一覧を取得できませんでした。 + + %1$d 件のモデルを取得しました。不要なものは削除してください。 + 続ける その他(自由入力) 回答しない diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index d35373c48..d7f25c6ac 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -195,6 +195,19 @@ Separados por vírgula, ex. gpt-4o, gpt-4o-mini Informe um ID de provedor, uma URL base e pelo menos um ID de modelo. Remover + Editar + Editar provedor personalizado + Buscar modelos + Opcional. Usada para buscar os modelos em <base URL>/models; você também pode informá-la na próxima etapa. + Deixe em branco para usar a chave de API salva. + Informe primeiro uma URL base. + O endpoint não retornou nenhum modelo. + Não foi possível buscar a lista de modelos. + + %1$d modelo encontrado. Remova os que não quiser. + %1$d modelos encontrados. Remova os que não quiser. + %1$d modelos encontrados. Remova os que não quiser. + Continuar Outro (digite sua resposta) Não responder diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 1626bc403..8e8b30d89 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -196,6 +196,20 @@ Через запятую, напр. gpt-4o, gpt-4o-mini Введите ID провайдера, базовый URL и хотя бы один ID модели. Удалить + Изменить + Изменить пользовательского провайдера + Получить модели + Необязательно. Нужен для получения моделей из <base URL>/models; его также можно ввести на следующем шаге. + Оставьте пустым, чтобы использовать сохранённый API-ключ. + Сначала введите базовый URL. + Эндпоинт не вернул ни одной модели. + Не удалось получить список моделей. + + Получена %1$d модель. Удалите ненужные. + Получено %1$d модели. Удалите ненужные. + Получено %1$d моделей. Удалите ненужные. + Получено %1$d модели. Удалите ненужные. + Продолжить Другое (введите свой ответ) Не отвечать diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 56c2d2c23..99c9533b2 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -193,6 +193,17 @@ 用逗号分隔,例如 gpt-4o, gpt-4o-mini 请输入提供商 ID、基础 URL 以及至少一个模型 ID。 移除 + 编辑 + 编辑自定义提供商 + 获取模型列表 + 可选。用于从 <base URL>/models 获取模型;也可以在下一步再输入。 + 留空则使用已保存的 API 密钥。 + 请先输入基础 URL。 + 该端点没有返回任何模型。 + 无法获取模型列表。 + + 已获取 %1$d 个模型。可删除不需要的模型。 + 继续 其他(自行输入答案) 不回答 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9ded3562e..55e36391c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -180,6 +180,18 @@ Comma-separated, e.g. gpt-4o, gpt-4o-mini Enter a provider ID, base URL, and at least one model ID. Remove + Edit + Edit custom provider + Fetch models + Optional. Used to fetch models from <base URL>/models; you can also enter it in the next step. + Leave empty to use the saved API key. + Enter a base URL first. + The endpoint returned no models. + Could not fetch the model list. + + Fetched %1$d model. Remove any you don\'t want. + Fetched %1$d models. Remove any you don\'t want. + Continue Other (type your own answer) Decline to answer diff --git a/app/src/test/java/com/yugahashimoto/andcode/feature/settings/CustomProviderDialogStateTest.kt b/app/src/test/java/com/yugahashimoto/andcode/feature/settings/CustomProviderDialogStateTest.kt new file mode 100644 index 000000000..9d2f26631 --- /dev/null +++ b/app/src/test/java/com/yugahashimoto/andcode/feature/settings/CustomProviderDialogStateTest.kt @@ -0,0 +1,23 @@ +package com.yugahashimoto.andcode.feature.settings + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class CustomProviderDialogStateTest { + @Test + fun `parseModelIdList splits on commas and newlines and drops blanks and duplicates`() { + assertEquals( + listOf("gpt-4o", "gpt-4o-mini", "llama3"), + parseModelIdList(" gpt-4o, gpt-4o-mini\nllama3,,gpt-4o \n"), + ) + } + + @Test + fun `isBusy covers both fetching and submitting`() { + assertFalse(CustomProviderDialogState().isBusy) + assertTrue(CustomProviderDialogState(isFetchingModels = true).isBusy) + assertTrue(CustomProviderDialogState(isSubmitting = true).isBusy) + } +} diff --git a/app/src/test/java/com/yugahashimoto/andcode/runtime/local/CustomProviderModelFetcherTest.kt b/app/src/test/java/com/yugahashimoto/andcode/runtime/local/CustomProviderModelFetcherTest.kt new file mode 100644 index 000000000..f760283a0 --- /dev/null +++ b/app/src/test/java/com/yugahashimoto/andcode/runtime/local/CustomProviderModelFetcherTest.kt @@ -0,0 +1,82 @@ +package com.yugahashimoto.andcode.runtime.local + +import kotlinx.coroutines.runBlocking +import okhttp3.OkHttpClient +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class CustomProviderModelFetcherTest { + private lateinit var server: MockWebServer + private val fetcher = CustomProviderModelFetcher(OkHttpClient()) + + @Before + fun setUp() { + server = MockWebServer() + server.start() + } + + @After + fun tearDown() { + server.shutdown() + } + + @Test + fun `fetch reads OpenAI style data ids and sends the bearer key`() { + server.enqueue( + MockResponse().setBody("""{"object":"list","data":[{"id":"b-model"},{"id":"a-model"},{"id":"b-model"}]}"""), + ) + + val models = runBlocking { fetcher.fetch(server.url("/v1/").toString(), " sk-test-key ") } + + assertEquals(listOf("a-model", "b-model"), models) + val request = server.takeRequest() + assertEquals("/v1/models", request.path) + assertEquals("Bearer sk-test-key", request.getHeader("Authorization")) + } + + @Test + fun `fetch omits the authorization header without a key`() { + server.enqueue(MockResponse().setBody("""{"data":[{"id":"llama3"}]}""")) + + val models = runBlocking { fetcher.fetch(server.url("/v1").toString(), " ") } + + assertEquals(listOf("llama3"), models) + assertNull(server.takeRequest().getHeader("Authorization")) + } + + @Test + fun `fetch fails on an http error`() { + server.enqueue(MockResponse().setResponseCode(401).setBody("""{"error":"unauthorized"}""")) + + val error = + assertThrows(IllegalStateException::class.java) { + runBlocking { fetcher.fetch(server.url("/v1").toString(), "bad") } + } + assertTrue(error.message.orEmpty().contains("401")) + } + + @Test + fun `modelsUrl rejects a non http url`() { + assertThrows(IllegalArgumentException::class.java) { CustomProviderModelFetcher.modelsUrl("not a url") } + assertEquals("https://api.example.com/v1/models", CustomProviderModelFetcher.modelsUrl(" https://api.example.com/v1/ ")) + } + + @Test + fun `parseModelIds accepts bare arrays and models fields`() { + assertEquals(listOf("x", "y"), CustomProviderModelFetcher.parseModelIds("""["y","x"," "]""")) + assertEquals(listOf("m1"), CustomProviderModelFetcher.parseModelIds("""{"models":[{"name":"m1"}]}""")) + assertEquals(emptyList(), CustomProviderModelFetcher.parseModelIds("""{"data":"nope"}""")) + } + + @Test + fun `parseModelIds rejects non json`() { + assertThrows(IllegalStateException::class.java) { CustomProviderModelFetcher.parseModelIds("") } + } +} From c1c30cf5c0d3eb62b19b3dc2d4ef11ebc414f169 Mon Sep 17 00:00:00 2001 From: doublepi123 Date: Thu, 15 Oct 2026 01:47:52 +0000 Subject: [PATCH 02/15] fix(providers): address review on custom provider model fetch - keep parseModelIdList on one line for ktlint function-signature - rethrow CancellationException so a dismissed fetch cannot touch a newly opened dialog - only fall back to the stored key while editing that provider, so an existing id typed in add mode cannot leak its key to a new base URL - show the localized failure message, with the redacted detail below --- .../feature/settings/ProviderAuthUiModels.kt | 3 +-- .../feature/settings/SettingsViewModel.kt | 24 +++++++++++-------- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/com/yugahashimoto/andcode/feature/settings/ProviderAuthUiModels.kt b/app/src/main/java/com/yugahashimoto/andcode/feature/settings/ProviderAuthUiModels.kt index d7ed131f9..03ef6576a 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/feature/settings/ProviderAuthUiModels.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/feature/settings/ProviderAuthUiModels.kt @@ -58,5 +58,4 @@ data class CustomProviderDialogState( } /** Splits the comma- or newline-separated model field into distinct, trimmed ids. */ -internal fun parseModelIdList(raw: String): List = - raw.split(',', '\n').map(String::trim).filter(String::isNotEmpty).distinct() +internal fun parseModelIdList(raw: String): List = raw.split(',', '\n').map(String::trim).filter(String::isNotEmpty).distinct() diff --git a/app/src/main/java/com/yugahashimoto/andcode/feature/settings/SettingsViewModel.kt b/app/src/main/java/com/yugahashimoto/andcode/feature/settings/SettingsViewModel.kt index f7294c94c..bb30d24b5 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/feature/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/feature/settings/SettingsViewModel.kt @@ -29,6 +29,7 @@ import com.yugahashimoto.andcode.runtime.local.CustomProviderModelFetcher import com.yugahashimoto.andcode.runtime.local.CustomProviderStore import com.yugahashimoto.andcode.runtime.local.LocalProviderCredentialStore import com.yugahashimoto.andcode.runtime.local.toProviderConfig +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay @@ -632,13 +633,7 @@ class SettingsViewModel( } fun updateCustomProviderId(value: String) { - customProviderDialogState.update { - if (it == null || it.editing) { - it - } else { - it.copy(id = value, hasStoredApiKey = credentials.hasCredential(value), error = null) - } - } + customProviderDialogState.update { if (it == null || it.editing) it else it.copy(id = value, error = null) } } fun updateCustomProviderName(value: String) { @@ -669,7 +664,13 @@ class SettingsViewModel( customProviderDialogState.update { it?.copy(fetchError = customProviderFetchNeedsBaseUrlMessage) } return } - val apiKey = dialog.apiKey.trim().ifEmpty { credentials.credentials()[dialog.id.trim()].orEmpty() } + // The stored key only falls back while editing that very provider: in add mode the id is + // free text, and typing an existing id (say `openai`) would otherwise send that provider's + // saved key to whatever base URL was typed into the dialog. + val apiKey = + dialog.apiKey.trim().ifEmpty { + if (dialog.editing) credentials.credentials()[dialog.id.trim()].orEmpty() else "" + } customProviderJob = viewModelScope.launch { customProviderDialogState.update { @@ -690,12 +691,15 @@ class SettingsViewModel( } } }.onFailure { error -> + // A dismissed dialog cancels this job; its late failure must not land in a + // dialog opened afterwards, nor clear that dialog's own job reference. + if (error is CancellationException) throw error customProviderJob = null + val detail = error.message?.takeIf(String::isNotBlank)?.let(SecretRedaction::redact) customProviderDialogState.update { it?.copy( isFetchingModels = false, - fetchError = - SecretRedaction.redact(error.message?.takeIf(String::isNotBlank) ?: customProviderFetchFailedMessage), + fetchError = listOfNotNull(customProviderFetchFailedMessage, detail).joinToString("\n"), ) } } From 79edbd376d74c3d4316cabae31b505cee134c8a1 Mon Sep 17 00:00:00 2001 From: doublepi123 Date: Thu, 15 Oct 2026 01:48:47 +0000 Subject: [PATCH 03/15] fix(providers): rethrow cancellation in custom provider submit too --- .../andcode/feature/settings/SettingsViewModel.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/yugahashimoto/andcode/feature/settings/SettingsViewModel.kt b/app/src/main/java/com/yugahashimoto/andcode/feature/settings/SettingsViewModel.kt index bb30d24b5..89ce2c5b3 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/feature/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/feature/settings/SettingsViewModel.kt @@ -654,7 +654,7 @@ class SettingsViewModel( /** * Fills the model field from the endpoint's own `GET /models` listing, using the key - * typed into the dialog or, failing that, the one already stored for this provider id. + * typed into the dialog or, while editing a registered provider, the one already stored for it. */ fun fetchCustomProviderModels() { val dialog = customProviderDialogState.value ?: return @@ -751,6 +751,7 @@ class SettingsViewModel( refreshProviderAuth() finishCustomProviderSubmit(definition.id, dialog.editing, typedApiKey, target) }.onFailure { error -> + if (error is CancellationException) throw error customProviderJob = null customProviderDialogState.update { it?.copy(isSubmitting = false, error = error.message?.takeIf(String::isNotBlank) ?: customProviderInvalidMessage) From ae70a433de24a43c32ee70870a98cdf990a09d04 Mon Sep 17 00:00:00 2001 From: doublepi123 Date: Thu, 15 Oct 2026 02:14:45 +0000 Subject: [PATCH 04/15] fix(providers): preserve existing models during runtime startup --- .../runtime/local/CustomProviderStore.kt | 12 +++++- .../runtime/local/CustomProviderStoreTest.kt | 40 +++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/yugahashimoto/andcode/runtime/local/CustomProviderStore.kt b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/CustomProviderStore.kt index 426034255..071f0eb2c 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/runtime/local/CustomProviderStore.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/CustomProviderStore.kt @@ -84,7 +84,17 @@ class CustomProviderStore( val current = definitions() val currentIds = current.map { it.id }.toSet() (loadSyncedIds() - currentIds).forEach(providers::remove) - current.forEach { definition -> providers[definition.id] = definition.toProviderConfig() } + current.forEach { definition -> + val configured = definition.toProviderConfig() + val existingModels = (providers[definition.id] as? JsonObject)?.get("models") as? JsonObject + // Starting the runtime must not erase models added outside the app, or their custom + // limits/options. Stored definitions supply missing ids, not an authoritative allowlist. + providers[definition.id] = + JsonObject( + configured + + ("models" to JsonObject(configured["models"]!!.jsonObject + existingModels.orEmpty())), + ) + } val updatedRoot = JsonObject(existingRoot + ("provider" to JsonObject(providers))) if (updatedRoot != existingRoot) { diff --git a/app/src/test/java/com/yugahashimoto/andcode/runtime/local/CustomProviderStoreTest.kt b/app/src/test/java/com/yugahashimoto/andcode/runtime/local/CustomProviderStoreTest.kt index 51a8fdcd2..3d4ea6634 100644 --- a/app/src/test/java/com/yugahashimoto/andcode/runtime/local/CustomProviderStoreTest.kt +++ b/app/src/test/java/com/yugahashimoto/andcode/runtime/local/CustomProviderStoreTest.kt @@ -151,6 +151,46 @@ class CustomProviderStoreTest { assertEquals("not-json", configFile.readText()) } + @Test + fun `sync preserves existing model ids and metadata while adding missing stored models`() { + val rootfs = temp.newFolder("models-rootfs") + val configFile = + File(rootfs, "root/.config/opencode/opencode.json").apply { + parentFile.mkdirs() + writeText( + """ + { + "disabled_providers": ["opencode"], + "provider": { + "acme": { + "models": { + "manual": {"name": "Manual model", "options": {"temperature": 0.5}}, + "shared": {"name": "Custom name", "limit": {"context": 100000}} + } + } + } + } + """.trimIndent(), + ) + } + val before = Json.parseToJsonElement(configFile.readText()).jsonObject + val originalModels = before["provider"]!!.jsonObject["acme"]!!.jsonObject["models"]!!.jsonObject + val memory = memoryStore() + memory.store.upsert(CustomProviderDefinition("acme", "Acme", "https://acme.example/v1", listOf("shared", "new"))) + + memory.store.syncToRuntime(rootfs) + + val after = Json.parseToJsonElement(configFile.readText()).jsonObject + val models = after["provider"]!!.jsonObject["acme"]!!.jsonObject["models"]!!.jsonObject + assertEquals(setOf("manual", "shared", "new"), models.keys) + assertEquals(originalModels["manual"], models["manual"]) + assertEquals(originalModels["shared"], models["shared"]) + assertEquals(before["disabled_providers"], after["disabled_providers"]) + + memory.store.syncToRuntime(rootfs) + assertEquals(after, Json.parseToJsonElement(configFile.readText()).jsonObject) + } + private fun memoryStore(): MemoryProviderStore { val definitions = mutableListOf() val syncedIds = mutableSetOf() From a0341adb45e3770a59d47315606612454d4b9bee Mon Sep 17 00:00:00 2001 From: doublepi123 Date: Thu, 15 Oct 2026 03:06:50 +0000 Subject: [PATCH 05/15] fix(providers): reject scalar model-list responses --- .../andcode/runtime/local/CustomProviderModelFetcher.kt | 2 +- .../runtime/local/CustomProviderModelFetcherTest.kt | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/yugahashimoto/andcode/runtime/local/CustomProviderModelFetcher.kt b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/CustomProviderModelFetcher.kt index a33c2f6a7..66d8c37fa 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/runtime/local/CustomProviderModelFetcher.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/CustomProviderModelFetcher.kt @@ -61,7 +61,7 @@ class CustomProviderModelFetcher( when (root) { is JsonArray -> root is JsonObject -> (root["data"] as? JsonArray) ?: (root["models"] as? JsonArray) ?: JsonArray(emptyList()) - else -> JsonArray(emptyList()) + else -> throw IllegalStateException("Expected a model list object or array") } return entries .mapNotNull(::modelId) diff --git a/app/src/test/java/com/yugahashimoto/andcode/runtime/local/CustomProviderModelFetcherTest.kt b/app/src/test/java/com/yugahashimoto/andcode/runtime/local/CustomProviderModelFetcherTest.kt index f760283a0..d2629a630 100644 --- a/app/src/test/java/com/yugahashimoto/andcode/runtime/local/CustomProviderModelFetcherTest.kt +++ b/app/src/test/java/com/yugahashimoto/andcode/runtime/local/CustomProviderModelFetcherTest.kt @@ -79,4 +79,11 @@ class CustomProviderModelFetcherTest { fun `parseModelIds rejects non json`() { assertThrows(IllegalStateException::class.java) { CustomProviderModelFetcher.parseModelIds("") } } + + @Test + fun `parseModelIds rejects scalar responses rather than treating them as empty lists`() { + listOf("null", "true", "42", "\"error\"").forEach { body -> + assertThrows(IllegalStateException::class.java) { CustomProviderModelFetcher.parseModelIds(body) } + } + } } From a1e4aeb88c47f3dbd1dcb5c92540a1f5c24fa0a1 Mon Sep 17 00:00:00 2001 From: doublepi123 Date: Thu, 15 Oct 2026 06:08:13 +0000 Subject: [PATCH 06/15] feat(chat): offer plugin-defined primary agents in the mode picker The mode chip only listed build/plan, so primaries registered by OpenCode plugins (e.g. oh-my-opencode-slim's orchestrator) were unreachable from the app. List every non-hidden primary/all agent, and default to the agent the server sorts first (its default_agent) instead of hard-coding build. --- .../andcode/core/api/OpenCodeApiModels.kt | 17 ++++ .../data/settings/AppPreferencesRepository.kt | 7 +- .../feature/assistant/AssistantProfile.kt | 3 +- .../andcode/feature/chat/ChatHomeScreen.kt | 17 ++-- .../feature/settings/SettingsViewModel.kt | 3 +- .../core/api/OpenCodeAgentSelectionTest.kt | 77 +++++++++++++++++++ .../feature/assistant/AssistantProfileTest.kt | 19 +++++ 7 files changed, 132 insertions(+), 11 deletions(-) create mode 100644 app/src/test/java/com/yugahashimoto/andcode/core/api/OpenCodeAgentSelectionTest.kt diff --git a/app/src/main/java/com/yugahashimoto/andcode/core/api/OpenCodeApiModels.kt b/app/src/main/java/com/yugahashimoto/andcode/core/api/OpenCodeApiModels.kt index 6cc6922cb..3e0f36179 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/core/api/OpenCodeApiModels.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/core/api/OpenCodeApiModels.kt @@ -165,8 +165,25 @@ data class OpenCodeAgent( val description: String? = null, val mode: String? = null, val native: Boolean = false, + val hidden: Boolean = false, ) +/** + * The agents a user can start a chat with: OpenCode's primary (and `all`) agents, minus the hidden + * internal ones (compaction, title, summary). Plugin-defined primaries such as oh-my-opencode-slim's + * `orchestrator` are included. + * + * Server order is kept on purpose: OpenCode's `GET /agent` sorts its configured `default_agent` + * first (or `build` when none is set), so [defaultAgentName] follows the server's choice instead of + * hard-coding `build`. + */ +fun List.selectablePrimaryAgents(): List = + filter { (it.mode == null || it.mode == "primary" || it.mode == "all") && !it.hidden } + +/** The server's default agent: the first selectable primary it lists, falling back to `build`. */ +fun List.defaultAgentName(): String? = + selectablePrimaryAgents().firstOrNull()?.name ?: firstOrNull { it.name == "build" }?.name + @Serializable data class OpenCodeProject( val id: String, diff --git a/app/src/main/java/com/yugahashimoto/andcode/data/settings/AppPreferencesRepository.kt b/app/src/main/java/com/yugahashimoto/andcode/data/settings/AppPreferencesRepository.kt index 6beef9dc0..ac0c39531 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/data/settings/AppPreferencesRepository.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/data/settings/AppPreferencesRepository.kt @@ -2,6 +2,8 @@ package com.yugahashimoto.andcode.data.settings import com.yugahashimoto.andcode.core.api.OpenCodeAgent import com.yugahashimoto.andcode.core.api.ProviderCatalog +import com.yugahashimoto.andcode.core.api.defaultAgentName +import com.yugahashimoto.andcode.core.api.selectablePrimaryAgents import com.yugahashimoto.andcode.data.connection.SecureSettingsRepository import com.yugahashimoto.andcode.feature.assistant.TtsTuning import com.yugahashimoto.andcode.feature.wakeword.WakeWordGrammar @@ -144,11 +146,10 @@ class AppPreferencesRepository( ) { selectModel(reconciled.providerId, reconciled.modelId) } - val primaryAgents = agents.filter { it.mode == null || it.mode == "primary" } + val primaryAgents = agents.selectablePrimaryAgents() val agentId = current.agentId?.takeIf { selected -> primaryAgents.any { it.name == selected } } - ?: primaryAgents.firstOrNull { it.name == "build" }?.name - ?: primaryAgents.firstOrNull()?.name + ?: agents.defaultAgentName() if (agentId != current.agentId) { selectAgent(agentId) } diff --git a/app/src/main/java/com/yugahashimoto/andcode/feature/assistant/AssistantProfile.kt b/app/src/main/java/com/yugahashimoto/andcode/feature/assistant/AssistantProfile.kt index cce1cc72d..3a2232219 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/feature/assistant/AssistantProfile.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/feature/assistant/AssistantProfile.kt @@ -2,6 +2,7 @@ package com.yugahashimoto.andcode.feature.assistant import com.yugahashimoto.andcode.core.api.OpenCodeAgent import com.yugahashimoto.andcode.core.api.OpenCodeProvider +import com.yugahashimoto.andcode.core.api.defaultAgentName import com.yugahashimoto.andcode.data.connection.ConnectionProfile data class AssistantProfile( @@ -53,7 +54,7 @@ object AssistantProfileResolver { val agentId = profile.agentId ?.takeIf { configured -> agents.any { it.name == configured } } - ?: agents.firstOrNull { it.name == "build" }?.name + ?: agents.defaultAgentName() ?: agents.firstOrNull()?.name ResolvedAssistantProfile( diff --git a/app/src/main/java/com/yugahashimoto/andcode/feature/chat/ChatHomeScreen.kt b/app/src/main/java/com/yugahashimoto/andcode/feature/chat/ChatHomeScreen.kt index 673cfcc5e..84b64355e 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/feature/chat/ChatHomeScreen.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/feature/chat/ChatHomeScreen.kt @@ -131,6 +131,8 @@ import com.yugahashimoto.andcode.core.api.OpenCodeCommand import com.yugahashimoto.andcode.core.api.OpenCodeProvider import com.yugahashimoto.andcode.core.api.OpenCodeSkill import com.yugahashimoto.andcode.core.api.PromptAttachment +import com.yugahashimoto.andcode.core.api.defaultAgentName +import com.yugahashimoto.andcode.core.api.selectablePrimaryAgents import com.yugahashimoto.andcode.core.diagnostics.StallDiagnosis import com.yugahashimoto.andcode.core.diagnostics.StallReason import com.yugahashimoto.andcode.core.diagnostics.explain @@ -1864,15 +1866,18 @@ private fun ModeChip( onSelect: (String) -> Unit, ) { var expanded by remember { mutableStateOf(false) } - // OpenCode servers can report arbitrary custom agents alongside its two built-ins; this chip - // only offers build/plan to keep the picker to the two modes users actually switch between. - // Fall back to the full list if a server configuration omits both, rather than showing an - // unusable empty dropdown. - val selectableAgents = agents.filter { it.name == "build" || it.name == "plan" }.ifEmpty { agents } + // Every primary agent the server reports is offered, not just build/plan: plugins such as + // oh-my-opencode-slim register their own primary (`orchestrator`), and hiding it left users + // unable to reach it. `agents` is already narrowed to non-hidden primaries upstream, so + // subagents and OpenCode's internal compaction/title/summary agents never appear here. + val selectableAgents = agents.selectablePrimaryAgents() // The selection is remembered across runtimes, so an id this runtime does not offer would // otherwise label the chip with another agent's name. An empty list is not a licence to trust // it either: that is exactly the state a stopped runtime is in. - val label = selectedAgentId?.takeIf { id -> selectableAgents.any { it.name == id } } ?: "build" + val label = + selectedAgentId?.takeIf { id -> selectableAgents.any { it.name == id } } + ?: agents.defaultAgentName() + ?: "build" Box { Surface( modifier = diff --git a/app/src/main/java/com/yugahashimoto/andcode/feature/settings/SettingsViewModel.kt b/app/src/main/java/com/yugahashimoto/andcode/feature/settings/SettingsViewModel.kt index 6b8e6121b..d8e741171 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/feature/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/feature/settings/SettingsViewModel.kt @@ -6,6 +6,7 @@ import com.yugahashimoto.andcode.core.api.OpenCodeAgent import com.yugahashimoto.andcode.core.api.OpenCodeProvider import com.yugahashimoto.andcode.core.api.ProviderAuthMethod import com.yugahashimoto.andcode.core.api.ProviderCatalog +import com.yugahashimoto.andcode.core.api.selectablePrimaryAgents import com.yugahashimoto.andcode.core.security.SecretRedaction import com.yugahashimoto.andcode.data.connection.SecureSettingsRepository import com.yugahashimoto.andcode.data.repository.RuntimeCatalogRepository @@ -204,7 +205,7 @@ class SettingsViewModel( providers = core.runtime.providers.all.filter { it.id in chatConnected }, availableProviders = managed.all, connectedProviderIds = (managed.connected.toSet() + oauth.locallyConnected) - oauth.locallyDisconnected, - agents = core.runtime.agents.filter { it.mode == null || it.mode == "primary" }, + agents = core.runtime.agents.selectablePrimaryAgents(), providerId = core.preferences.providerId, modelId = core.preferences.modelId, agentId = core.preferences.agentId, diff --git a/app/src/test/java/com/yugahashimoto/andcode/core/api/OpenCodeAgentSelectionTest.kt b/app/src/test/java/com/yugahashimoto/andcode/core/api/OpenCodeAgentSelectionTest.kt new file mode 100644 index 000000000..cbb34e6f6 --- /dev/null +++ b/app/src/test/java/com/yugahashimoto/andcode/core/api/OpenCodeAgentSelectionTest.kt @@ -0,0 +1,77 @@ +package com.yugahashimoto.andcode.core.api + +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class OpenCodeAgentSelectionTest { + // The shape OpenCode's GET /agent returns with oh-my-opencode-slim installed and + // `default_agent: "orchestrator"`: the default first, then build, then the rest by name. + private val pluginAgents = + listOf( + OpenCodeAgent("orchestrator", mode = "primary"), + OpenCodeAgent("build", mode = "primary", native = true), + OpenCodeAgent("compaction", mode = "primary", native = true, hidden = true), + OpenCodeAgent("council", mode = "subagent"), + OpenCodeAgent("councillor-gpt", mode = "subagent", hidden = true), + OpenCodeAgent("fixer", mode = "subagent"), + OpenCodeAgent("plan", mode = "primary", native = true), + OpenCodeAgent("summary", mode = "primary", native = true, hidden = true), + OpenCodeAgent("title", mode = "primary", native = true, hidden = true), + ) + + @Test + fun `offers plugin primaries alongside build and plan, but not subagents or hidden agents`() { + assertEquals( + listOf("orchestrator", "build", "plan"), + pluginAgents.selectablePrimaryAgents().map { it.name }, + ) + } + + @Test + fun `offers agents whose mode is all or unreported`() { + val agents = + listOf( + OpenCodeAgent("build", mode = "primary"), + OpenCodeAgent("both", mode = "all"), + OpenCodeAgent("legacy"), + ) + + assertEquals(listOf("build", "both", "legacy"), agents.selectablePrimaryAgents().map { it.name }) + } + + @Test + fun `defaults to the agent the server lists first, which is its default_agent`() { + assertEquals("orchestrator", pluginAgents.defaultAgentName()) + } + + @Test + fun `defaults to build on a stock server`() { + val stock = + listOf( + OpenCodeAgent("build", mode = "primary", native = true), + OpenCodeAgent("plan", mode = "primary", native = true), + ) + + assertEquals("build", stock.defaultAgentName()) + } + + @Test + fun `has no default when the runtime reports no agents`() { + assertNull(emptyList().defaultAgentName()) + } + + @Test + fun `decodes the hidden flag and treats a missing one as visible`() { + val json = Json { ignoreUnknownKeys = true } + val agents = + json.decodeFromString>( + """[{"name":"title","mode":"primary","hidden":true,"native":true},{"name":"orchestrator","mode":"primary"}]""", + ) + + assertTrue(agents[0].hidden) + assertEquals(listOf("orchestrator"), agents.selectablePrimaryAgents().map { it.name }) + } +} diff --git a/app/src/test/java/com/yugahashimoto/andcode/feature/assistant/AssistantProfileTest.kt b/app/src/test/java/com/yugahashimoto/andcode/feature/assistant/AssistantProfileTest.kt index 6854f5238..bced598b8 100644 --- a/app/src/test/java/com/yugahashimoto/andcode/feature/assistant/AssistantProfileTest.kt +++ b/app/src/test/java/com/yugahashimoto/andcode/feature/assistant/AssistantProfileTest.kt @@ -42,6 +42,25 @@ class AssistantProfileTest { assertEquals("build", result.agentId) } + @Test + fun `missing agent follows the server's default_agent rather than build`() { + val result = + AssistantProfileResolver.resolve( + profile = AssistantProfile(backendId = "mac"), + connections = listOf(connection), + providers = listOf(provider), + defaultModels = mapOf("opencode" to "free"), + agents = + listOf( + OpenCodeAgent(name = "orchestrator", mode = "primary"), + OpenCodeAgent(name = "build", mode = "primary"), + OpenCodeAgent(name = "fixer", mode = "subagent"), + ), + ).getOrThrow() + + assertEquals("orchestrator", result.agentId) + } + @Test fun `deleted backend produces configuration error`() { val result = From 625c25955684d7e698d551ed658b7be155ed1c50 Mon Sep 17 00:00:00 2001 From: doublepi123 Date: Thu, 15 Oct 2026 06:10:33 +0000 Subject: [PATCH 07/15] refactor(chat): drop the unreachable build fallback in defaultAgentName --- .../com/yugahashimoto/andcode/core/api/OpenCodeApiModels.kt | 5 ++--- .../com/yugahashimoto/andcode/feature/chat/ChatHomeScreen.kt | 5 +++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/yugahashimoto/andcode/core/api/OpenCodeApiModels.kt b/app/src/main/java/com/yugahashimoto/andcode/core/api/OpenCodeApiModels.kt index 3e0f36179..c898e79ed 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/core/api/OpenCodeApiModels.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/core/api/OpenCodeApiModels.kt @@ -180,9 +180,8 @@ data class OpenCodeAgent( fun List.selectablePrimaryAgents(): List = filter { (it.mode == null || it.mode == "primary" || it.mode == "all") && !it.hidden } -/** The server's default agent: the first selectable primary it lists, falling back to `build`. */ -fun List.defaultAgentName(): String? = - selectablePrimaryAgents().firstOrNull()?.name ?: firstOrNull { it.name == "build" }?.name +/** The server's default agent: the first selectable primary it lists, or null when it offers none. */ +fun List.defaultAgentName(): String? = selectablePrimaryAgents().firstOrNull()?.name @Serializable data class OpenCodeProject( diff --git a/app/src/main/java/com/yugahashimoto/andcode/feature/chat/ChatHomeScreen.kt b/app/src/main/java/com/yugahashimoto/andcode/feature/chat/ChatHomeScreen.kt index 84b64355e..e3b7a1749 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/feature/chat/ChatHomeScreen.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/feature/chat/ChatHomeScreen.kt @@ -1868,8 +1868,9 @@ private fun ModeChip( var expanded by remember { mutableStateOf(false) } // Every primary agent the server reports is offered, not just build/plan: plugins such as // oh-my-opencode-slim register their own primary (`orchestrator`), and hiding it left users - // unable to reach it. `agents` is already narrowed to non-hidden primaries upstream, so - // subagents and OpenCode's internal compaction/title/summary agents never appear here. + // unable to reach it. `agents` is normally narrowed upstream already; filtering again keeps + // subagents and OpenCode's internal compaction/title/summary agents out even if a caller + // passes the raw list. val selectableAgents = agents.selectablePrimaryAgents() // The selection is remembered across runtimes, so an id this runtime does not offer would // otherwise label the chip with another agent's name. An empty list is not a licence to trust From ad830ddad2c8a485fb8a04cee6f896aeec462d27 Mon Sep 17 00:00:00 2001 From: Yu-ga <74749461+yuga-hashimoto@users.noreply.github.com> Date: Thu, 15 Oct 2026 13:55:48 +0000 Subject: [PATCH 08/15] feat: run guest startup commands on local runtime start Implements #379: a workspace can declare guest commands in .and-code/startup-commands.json and AndCode launches them detached whenever the local runtime is (re)started. Each entry runs in its own PRoot instance working in /workspace, so services neither block the start nor outlive the runtime (the managed-process sweep stops them). An optional per-entry checkCommand makes startup idempotent: exit 0 skips the command. Best-effort by design - a missing or malformed config, or a failing entry, never delays or aborts the runtime start. The PRoot argument list shared by the server launcher, the diagnostic command runner, and this new runner is extracted into prootCommandPrefix so the sandbox contract lives in one place. --- .../andcode/AndCodeApplication.kt | 9 + .../com/yugahashimoto/andcode/di/AppModule.kt | 9 + .../runtime/local/GuestProotCommand.kt | 39 +++ .../local/LocalRuntimeCommandRunner.kt | 32 +-- .../local/LocalRuntimeProcessLauncher.kt | 41 +-- .../runtime/local/StartupCommandsRunner.kt | 175 ++++++++++++ .../local/StartupCommandsRunnerTest.kt | 269 ++++++++++++++++++ 7 files changed, 520 insertions(+), 54 deletions(-) create mode 100644 app/src/main/java/com/yugahashimoto/andcode/runtime/local/GuestProotCommand.kt create mode 100644 app/src/main/java/com/yugahashimoto/andcode/runtime/local/StartupCommandsRunner.kt create mode 100644 app/src/test/java/com/yugahashimoto/andcode/runtime/local/StartupCommandsRunnerTest.kt diff --git a/app/src/main/java/com/yugahashimoto/andcode/AndCodeApplication.kt b/app/src/main/java/com/yugahashimoto/andcode/AndCodeApplication.kt index 0f14e8c75..9d3c0a588 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/AndCodeApplication.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/AndCodeApplication.kt @@ -74,6 +74,7 @@ import com.yugahashimoto.andcode.runtime.local.LocalRuntimeReleaseClient import com.yugahashimoto.andcode.runtime.local.LocalRuntimeServiceController import com.yugahashimoto.andcode.runtime.local.LocalRuntimeTarget import com.yugahashimoto.andcode.runtime.local.LocalRuntimeUpdater +import com.yugahashimoto.andcode.runtime.local.StartupCommandsRunner import com.yugahashimoto.andcode.runtime.local.SystemPromptStore import com.yugahashimoto.andcode.runtime.local.VerifiedRuntimeDownloader import com.yugahashimoto.andcode.runtime.local.applyOpenCodeSystemPrompt @@ -287,6 +288,13 @@ class AndCodeApplication : Application() { accessCoordinator = accessCoordinator, ) localRuntimeInstaller = installer + // Guest startup commands (issue #379): read from the workspace on every runtime start, + // launched detached so they can neither block nor fail the start itself. + val startupCommandsRunner = + StartupCommandsRunner( + runtimeDirectory = runtimeDirectory, + githubToken = { settings.githubToken }, + ) val launcher = LocalRuntimeProcessLauncher( runtimeDirectory = runtimeDirectory, @@ -300,6 +308,7 @@ class AndCodeApplication : Application() { if (settings.githubToken.isNullOrBlank()) helper.remove() else helper.install() } } + runCatching { startupCommandsRunner.schedule(installed) } }, ) commandRunner = diff --git a/app/src/main/java/com/yugahashimoto/andcode/di/AppModule.kt b/app/src/main/java/com/yugahashimoto/andcode/di/AppModule.kt index d8e1a094e..b5af1b2f6 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/di/AppModule.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/di/AppModule.kt @@ -32,6 +32,7 @@ import com.yugahashimoto.andcode.runtime.local.LocalRuntimeReleaseClient import com.yugahashimoto.andcode.runtime.local.LocalRuntimeServiceController import com.yugahashimoto.andcode.runtime.local.LocalRuntimeTarget import com.yugahashimoto.andcode.runtime.local.LocalRuntimeUpdater +import com.yugahashimoto.andcode.runtime.local.StartupCommandsRunner import com.yugahashimoto.andcode.runtime.local.VerifiedRuntimeDownloader import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -88,6 +89,13 @@ val appModule = val providerCredentials: LocalProviderCredentialStore = get() val customProviders: CustomProviderStore = get() val runtimeDirectory: File = get() + // Guest startup commands (issue #379): read from the workspace on every runtime start, + // launched detached so they can neither block nor fail the start itself. + val startupCommandsRunner = + StartupCommandsRunner( + runtimeDirectory = runtimeDirectory, + githubToken = { settings.githubToken }, + ) LocalRuntimeProcessLauncher( runtimeDirectory = runtimeDirectory, portProbe = LocalRuntimeManager::defaultPortProbe, @@ -100,6 +108,7 @@ val appModule = if (settings.githubToken.isNullOrBlank()) helper.remove() else helper.install() } } + runCatching { startupCommandsRunner.schedule(installed) } }, ) } diff --git a/app/src/main/java/com/yugahashimoto/andcode/runtime/local/GuestProotCommand.kt b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/GuestProotCommand.kt new file mode 100644 index 000000000..b3c9aeacc --- /dev/null +++ b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/GuestProotCommand.kt @@ -0,0 +1,39 @@ +package com.yugahashimoto.andcode.runtime.local + +import com.yugahashimoto.andcode.core.storage.DeviceStorage +import java.io.File + +/** + * The PRoot argument list every guest process shares: the embedded binary, its isolation flags, the + * rootfs, the fixed device binds, and - when [workspace] is given - the bind that mounts the guest + * workspace at `/workspace`. Callers append the working directory and the guest executable plus its + * arguments. + */ +internal fun prootCommandPrefix( + commandSuite: EmbeddedCommandSuite.Paths, + rootfs: File, + workspace: File? = null, +): List = + buildList { + add(commandSuite.proot.absolutePath) + add("--kill-on-exit") + add("--link2symlink") + add("-0") + add("-r") + add(rootfs.absolutePath) + add("-b") + add("/dev") + add("-b") + add("/proc") + add("-b") + add("/sys") + add("-b") + add("/system") + workspace?.let { + add("-b") + add("${it.absolutePath}:/workspace") + } + // Nothing while all-files access is ungranted, so the sandbox stays exactly as + // narrow as it was until the user opens the device up to it. + addAll(DeviceStorage.bindArguments()) + } diff --git a/app/src/main/java/com/yugahashimoto/andcode/runtime/local/LocalRuntimeCommandRunner.kt b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/LocalRuntimeCommandRunner.kt index 5ec5f8a30..d22cba26d 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/runtime/local/LocalRuntimeCommandRunner.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/LocalRuntimeCommandRunner.kt @@ -1,6 +1,5 @@ package com.yugahashimoto.andcode.runtime.local -import com.yugahashimoto.andcode.core.storage.DeviceStorage import java.io.File import java.util.concurrent.TimeUnit @@ -33,29 +32,14 @@ class LocalRuntimeCommandRunner( val outputFile = File.createTempFile("diagnostic-", ".log", File(runtimeDirectory, "logs").apply { mkdirs() }) try { val command = - buildList { - add(runtime.commandSuite.proot.absolutePath) - add("--kill-on-exit") - add("--link2symlink") - add("-0") - add("-r") - add(runtime.rootfs.absolutePath) - add("-b") - add("/dev") - add("-b") - add("/proc") - add("-b") - add("/sys") - add("-b") - add("/system") - // So a shell command can reach the device's files once the user allows it. - addAll(DeviceStorage.bindArguments()) - add("-w") - add("/root") - add("/bin/sh") - add("-lc") - add(commandText) - } + prootCommandPrefix(runtime.commandSuite, runtime.rootfs) + + listOf( + "-w", + "/root", + "/bin/sh", + "-lc", + commandText, + ) val process = ProcessBuilder(command) .redirectErrorStream(true) diff --git a/app/src/main/java/com/yugahashimoto/andcode/runtime/local/LocalRuntimeProcessLauncher.kt b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/LocalRuntimeProcessLauncher.kt index b5d3315eb..79691c6a3 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/runtime/local/LocalRuntimeProcessLauncher.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/LocalRuntimeProcessLauncher.kt @@ -1,6 +1,5 @@ package com.yugahashimoto.andcode.runtime.local -import com.yugahashimoto.andcode.core.storage.DeviceStorage import java.io.File import java.util.concurrent.TimeUnit @@ -56,35 +55,17 @@ class LocalRuntimeProcessLauncher( val prootTmp = File(runtimeDirectory, "proot-tmp").apply { mkdirs() } val command = - buildList { - add(suite.proot.absolutePath) - add("--kill-on-exit") - add("--link2symlink") - add("-0") - add("-r") - add(rootfs.absolutePath) - add("-b") - add("/dev") - add("-b") - add("/proc") - add("-b") - add("/sys") - add("-b") - add("/system") - add("-b") - add("${workspace.absolutePath}:/workspace") - // Nothing while all-files access is ungranted, so the sandbox stays exactly as - // narrow as it was until the user opens the device up to it. - addAll(DeviceStorage.bindArguments()) - add("-w") - add("/workspace") - add("/usr/local/bin/opencode") - add("serve") - add("--hostname") - add("127.0.0.1") - add("--port") - add(port.toString()) - } + prootCommandPrefix(suite, rootfs, workspace) + + listOf( + "-w", + "/workspace", + "/usr/local/bin/opencode", + "serve", + "--hostname", + "127.0.0.1", + "--port", + port.toString(), + ) val builder = ProcessBuilder(command) diff --git a/app/src/main/java/com/yugahashimoto/andcode/runtime/local/StartupCommandsRunner.kt b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/StartupCommandsRunner.kt new file mode 100644 index 000000000..269e5d073 --- /dev/null +++ b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/StartupCommandsRunner.kt @@ -0,0 +1,175 @@ +package com.yugahashimoto.andcode.runtime.local + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import java.io.File +import java.util.concurrent.TimeUnit + +/** + * Launches guest-side startup commands declared in the workspace's + * `.and-code/startup-commands.json` whenever the local runtime is (re)started (issue #379). + * + * The file is read on every [LocalRuntimeProcessLauncher.start]; each entry runs detached in its + * own PRoot instance with `/workspace` as the working directory, so a long-running service neither + * blocks the runtime start nor outlives it: the same managed-process sweep that stops the server + * matches every PRoot rooted in this runtime directory and stops the services too. + * + * Schema (`command` is the only required field): + * ```json + * { + * "commands": [ + * { + * "command": "npx @wonderwhy-er/desktop-commander@latest remote", + * "checkCommand": "pgrep -f desktop-commander" + * } + * ] + * } + * ``` + * A `checkCommand` that exits 0 within [checkTimeoutSeconds] skips its `command` - that is how an + * entry opts into idempotent startup instead of racing a second copy of an already-running service. + * + * Best-effort by design: a missing or malformed config runs nothing, a spawn failure affects only + * its own entry, and nothing here can delay or abort the runtime start. A command's output is + * appended to `logs/startup-command-.log`, truncated the same way the server log is. + */ +class StartupCommandsRunner( + private val runtimeDirectory: File, + private val workspaceDirectory: File = File(runtimeDirectory, "workspace"), + private val githubToken: () -> String? = { null }, + private val checkTimeoutSeconds: Long = 10L, + private val maxLogBytes: Long = 1_048_576L, + /** Overridable so a JVM test can supply a fake [Process] instead of a real guest command. */ + private val processStarter: ( + command: List, + environment: Map, + logFile: File, + ) -> Process = { command, environment, logFile -> + ProcessBuilder(command) + .directory(runtimeDirectory) + .redirectErrorStream(true) + .redirectOutput(ProcessBuilder.Redirect.appendTo(logFile)) + .apply { + environment().clear() + environment().putAll(environment) + } + .start() + }, +) { + private val json: Json = + Json { + ignoreUnknownKeys = true + isLenient = true + } + + init { + require(checkTimeoutSeconds > 0) + } + + /** + * Reads the config and launches the commands on a daemon thread. [schedule] runs on the + * runtime start path, so it must return immediately and must never throw - hence the work + * being off-thread and wrapped wholesale. + */ + fun schedule(installed: LocalRuntimeInstaller.InstalledRuntime) { + Thread({ runCatching { runAll(installed) } }, "startup-commands").apply { isDaemon = true }.start() + } + + /** Synchronous for tests; [schedule] is the production entry point. */ + internal fun runAll(installed: LocalRuntimeInstaller.InstalledRuntime) { + val config = readConfig() ?: return + val logs = File(runtimeDirectory, "logs").apply { mkdirs() } + config.commands.forEachIndexed { index, entry -> + val commandText = entry.command.trim() + if (commandText.isEmpty()) return@forEachIndexed + if (entry.checkCommand != null && alreadyRunning(installed, entry.checkCommand)) { + return@forEachIndexed + } + launchDetached(installed, commandText, File(logs, "$LOG_FILE_PREFIX-$index.log")) + } + } + + private fun readConfig(): StartupCommandsConfig? { + val file = File(workspaceDirectory, CONFIG_FILE_RELATIVE_PATH) + if (!file.isFile) return null + return runCatching { + json.decodeFromString(file.readText()) + }.getOrNull() + } + + /** True when the entry's check command exits 0 in time, i.e. the service is already up. */ + private fun alreadyRunning( + installed: LocalRuntimeInstaller.InstalledRuntime, + checkCommand: String, + ): Boolean { + val logs = File(runtimeDirectory, "logs").apply { mkdirs() } + val outputFile = File.createTempFile("startup-check-", ".log", logs) + val probe = + runCatching { + processStarter(guestCommand(installed, checkCommand), guestEnvironment(installed), outputFile) + }.getOrNull() + if (probe == null) { + outputFile.delete() + return false + } + return try { + val exited = probe.waitFor(checkTimeoutSeconds, TimeUnit.SECONDS) + if (!exited) probe.destroyForcibly() + exited && probe.exitValue() == 0 + } catch (error: InterruptedException) { + Thread.currentThread().interrupt() + probe.destroyForcibly() + false + } finally { + outputFile.delete() + } + } + + private fun launchDetached( + installed: LocalRuntimeInstaller.InstalledRuntime, + commandText: String, + logFile: File, + ) { + truncateLogFile(logFile, maxLogBytes) + // Deliberately not waited on and not tracked. The detached guest process is killed by the + // managed-runtime sweep on stop, which matches every PRoot rooted in this runtime + // directory, so there is no handle worth keeping. + runCatching { processStarter(guestCommand(installed, commandText), guestEnvironment(installed), logFile) } + } + + private fun guestCommand( + installed: LocalRuntimeInstaller.InstalledRuntime, + commandText: String, + ): List = + prootCommandPrefix(installed.commandSuite, installed.rootfs, workspaceDirectory) + + listOf( + "-w", + "/workspace", + "/bin/sh", + "-lc", + commandText, + ) + + private fun guestEnvironment(installed: LocalRuntimeInstaller.InstalledRuntime): Map = + localRuntimeEnvironment( + suiteEnvironment = installed.commandSuite.environment(), + prootTmp = File(runtimeDirectory, "proot-tmp").apply { mkdirs() }, + githubToken = githubToken(), + ) + + private companion object { + const val CONFIG_FILE_RELATIVE_PATH = ".and-code/startup-commands.json" + const val LOG_FILE_PREFIX = "startup-command" + } +} + +@Serializable +private data class StartupCommandsConfig( + @SerialName("commands") val commands: List = emptyList(), +) + +@Serializable +private data class StartupCommandEntry( + @SerialName("command") val command: String = "", + @SerialName("checkCommand") val checkCommand: String? = null, +) diff --git a/app/src/test/java/com/yugahashimoto/andcode/runtime/local/StartupCommandsRunnerTest.kt b/app/src/test/java/com/yugahashimoto/andcode/runtime/local/StartupCommandsRunnerTest.kt new file mode 100644 index 000000000..3eb0aeffc --- /dev/null +++ b/app/src/test/java/com/yugahashimoto/andcode/runtime/local/StartupCommandsRunnerTest.kt @@ -0,0 +1,269 @@ +package com.yugahashimoto.andcode.runtime.local + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File +import java.io.InputStream +import java.io.OutputStream +import java.util.concurrent.TimeUnit + +class StartupCommandsRunnerTest { + @get:Rule + val temporaryFolder = TemporaryFolder() + + private val launches = mutableListOf() + + /** Returns the process for a launch, or null to simulate a spawn failure. */ + private var nextProcess: (RecordedLaunch) -> Process? = { FakeProcess() } + + private lateinit var runtimeDirectory: File + + private lateinit var workspaceDirectory: File + + private fun setUpRunner(githubToken: String? = null): StartupCommandsRunner { + runtimeDirectory = temporaryFolder.newFolder("runtime") + workspaceDirectory = temporaryFolder.newFolder("workspace") + return StartupCommandsRunner( + runtimeDirectory = runtimeDirectory, + workspaceDirectory = workspaceDirectory, + githubToken = { githubToken }, + processStarter = { command, environment, logFile -> + val launch = RecordedLaunch(command, environment, logFile) + launches += launch + nextProcess(launch) ?: error("simulated spawn failure") + }, + ) + } + + private fun writeConfig(json: String) { + File(workspaceDirectory, ".and-code").mkdirs() + File(workspaceDirectory, ".and-code/startup-commands.json").writeText(json) + } + + @Test + fun `missing config runs nothing`() { + val runner = setUpRunner() + + runner.runAll(installedRuntime()) + + assertTrue(launches.isEmpty()) + } + + @Test + fun `commands run detached in the guest shell at the workspace`() { + val runner = setUpRunner() + writeConfig( + """{"commands":[{"command":"npx @wonderwhy-er/desktop-commander@latest remote"},""" + + """{"command":"python3 -m http.server 8000"}]}""", + ) + + runner.runAll(installedRuntime()) + + assertEquals(2, launches.size) + val first = launches[0] + assertEquals("/suite-proot", first.command.first()) + assertTrue("rootfs is the installed one", first.command.contains("/runtime-rootfs")) + assertTrue( + "workspace is bound into the guest", + first.command.contains("${workspaceDirectory.absolutePath}:/workspace"), + ) + assertEquals("-w", first.command[first.command.size - 5]) + assertEquals("/workspace", first.command[first.command.size - 4]) + assertEquals("/bin/sh", first.command[first.command.size - 3]) + assertEquals("-lc", first.command[first.command.size - 2]) + assertEquals("npx @wonderwhy-er/desktop-commander@latest remote", first.command.last()) + assertEquals( + File(runtimeDirectory, "logs/startup-command-0.log").absolutePath, + first.logFile.absolutePath, + ) + assertEquals( + File(runtimeDirectory, "logs/startup-command-1.log").absolutePath, + launches[1].logFile.absolutePath, + ) + } + + @Test + fun `guest environment is the shared local runtime environment`() { + val runner = setUpRunner(githubToken = "token-from-encrypted-store") + writeConfig("""{"commands":[{"command":"echo hi"}]}""") + + runner.runAll(installedRuntime()) + + val environment = launches.single().environment + assertEquals("/root", environment["HOME"]) + assertEquals("token-from-encrypted-store", environment["OPENCODE_GITHUB_TOKEN"]) + assertEquals( + File(runtimeDirectory, "proot-tmp").absolutePath, + environment["PROOT_TMP_DIR"], + ) + } + + @Test + fun `check command exiting zero skips its command`() { + val runner = setUpRunner() + writeConfig( + """{"commands":[{"command":"npx desktop-commander remote","checkCommand":"pgrep -f desktop-commander"},""" + + """{"command":"python3 -m http.server 8000"}]}""", + ) + nextProcess = { launch -> + if (launch.command.last() == "pgrep -f desktop-commander") FakeProcess(exitCode = 0) else FakeProcess() + } + + runner.runAll(installedRuntime()) + + assertFalse( + "the guarded command must not run", + launches.any { it.command.last() == "npx desktop-commander remote" }, + ) + assertEquals( + "the unguarded entry still runs", + listOf("pgrep -f desktop-commander", "python3 -m http.server 8000"), + launches.map { it.command.last() }, + ) + } + + @Test + fun `check command failing runs the command anyway`() { + val runner = setUpRunner() + writeConfig("""{"commands":[{"command":"npx desktop-commander remote","checkCommand":"pgrep -f desktop-commander"}]}""") + nextProcess = { launch -> + if (launch.command.last() == "pgrep -f desktop-commander") FakeProcess(exitCode = 1) else FakeProcess() + } + + runner.runAll(installedRuntime()) + + assertEquals( + listOf("pgrep -f desktop-commander", "npx desktop-commander remote"), + launches.map { it.command.last() }, + ) + } + + @Test + fun `check command that hangs is killed and its command still runs`() { + val runner = setUpRunner() + writeConfig("""{"commands":[{"command":"npx desktop-commander remote","checkCommand":"pgrep -f desktop-commander"}]}""") + var killed = false + nextProcess = { launch -> + if (launch.command.last() == "pgrep -f desktop-commander") { + FakeProcess(exitCode = 0, exitsInTime = false, onDestroyed = { killed = true }) + } else { + FakeProcess() + } + } + + runner.runAll(installedRuntime()) + + assertTrue("the hung probe must be killed", killed) + assertEquals("npx desktop-commander remote", launches.last().command.last()) + } + + @Test + fun `malformed config runs nothing and never throws`() { + val runner = setUpRunner() + writeConfig("""{"commands": [ {"command": """") + + runner.runAll(installedRuntime()) + + assertTrue(launches.isEmpty()) + } + + @Test + fun `blank command entries are skipped`() { + val runner = setUpRunner() + writeConfig("""{"commands":[{"command":" "},{"command":"python3 -m http.server 8000"}]}""") + + runner.runAll(installedRuntime()) + + assertEquals(1, launches.size) + assertEquals( + "the surviving entry keeps its own log slot", + File(runtimeDirectory, "logs/startup-command-1.log").absolutePath, + launches.single().logFile.absolutePath, + ) + } + + @Test + fun `one entry failing to spawn does not stop the others`() { + val runner = setUpRunner() + writeConfig("""{"commands":[{"command":"will-not-spawn"},{"command":"python3 -m http.server 8000"}]}""") + nextProcess = { launch -> + if (launch.command.last() == "will-not-spawn") null else FakeProcess() + } + + runner.runAll(installedRuntime()) + + assertEquals( + listOf("will-not-spawn", "python3 -m http.server 8000"), + launches.map { it.command.last() }, + ) + } + + @Test + fun `detached processes are not waited on`() { + val runner = setUpRunner() + writeConfig("""{"commands":[{"command":"python3 -m http.server 8000"}]}""") + var waited = false + nextProcess = { FakeProcess(onWaitFor = { waited = true }) } + + runner.runAll(installedRuntime()) + + assertFalse("a detached service must never block the caller", waited) + } + + private fun installedRuntime(): LocalRuntimeInstaller.InstalledRuntime = + LocalRuntimeInstaller.InstalledRuntime( + metadata = LocalRuntimeMetadata(version = "1.17.20", port = 4096, installedAt = 123), + commandSuite = + EmbeddedCommandSuite.Paths( + home = File("/suite-home"), + tmp = File("/suite-tmp"), + nativeLibraryDirectory = File("/suite-native"), + proot = File("/suite-proot"), + loader = File("/suite-loader"), + loader32 = File("/suite-loader32"), + ), + rootfs = File("/runtime-rootfs"), + openCode = null, + ) + + private data class RecordedLaunch( + val command: List, + val environment: Map, + val logFile: File, + ) + + private class FakeProcess( + private val exitCode: Int = 0, + private val exitsInTime: Boolean = true, + private val onDestroyed: () -> Unit = {}, + private val onWaitFor: () -> Unit = {}, + ) : Process() { + override fun getOutputStream(): OutputStream = error("unused") + + override fun getInputStream(): InputStream = error("unused") + + override fun getErrorStream(): InputStream = error("unused") + + override fun waitFor(): Int = exitCode + + override fun waitFor( + timeout: Long, + unit: TimeUnit, + ): Boolean { + onWaitFor() + return exitsInTime + } + + override fun exitValue(): Int = exitCode + + override fun destroy() { + onDestroyed() + } + + override fun destroyForcibly(): Process = apply { onDestroyed() } + } +} From 3cde2f9536ad63f0cd0137f758ae28e7f90e7d17 Mon Sep 17 00:00:00 2001 From: Yu-ga <74749461+yuga-hashimoto@users.noreply.github.com> Date: Thu, 15 Oct 2026 14:08:45 +0000 Subject: [PATCH 09/15] fix: treat a blank checkCommand as no check A whitespace-only checkCommand would probe '/bin/sh -lc ""', which always exits 0, and silently skip the command it guards. Blank now means unchecked, with a regression test. --- .../andcode/runtime/local/StartupCommandsRunner.kt | 5 ++++- .../runtime/local/StartupCommandsRunnerTest.kt | 11 +++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/yugahashimoto/andcode/runtime/local/StartupCommandsRunner.kt b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/StartupCommandsRunner.kt index 269e5d073..99ce4f56a 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/runtime/local/StartupCommandsRunner.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/StartupCommandsRunner.kt @@ -82,7 +82,10 @@ class StartupCommandsRunner( config.commands.forEachIndexed { index, entry -> val commandText = entry.command.trim() if (commandText.isEmpty()) return@forEachIndexed - if (entry.checkCommand != null && alreadyRunning(installed, entry.checkCommand)) { + // A blank checkCommand is "no check": probing `/bin/sh -lc ""` would always exit 0 + // and silently skip the command it guards. + val checkCommand = entry.checkCommand?.takeIf(String::isNotBlank) + if (checkCommand != null && alreadyRunning(installed, checkCommand)) { return@forEachIndexed } launchDetached(installed, commandText, File(logs, "$LOG_FILE_PREFIX-$index.log")) diff --git a/app/src/test/java/com/yugahashimoto/andcode/runtime/local/StartupCommandsRunnerTest.kt b/app/src/test/java/com/yugahashimoto/andcode/runtime/local/StartupCommandsRunnerTest.kt index 3eb0aeffc..a20cdb6c3 100644 --- a/app/src/test/java/com/yugahashimoto/andcode/runtime/local/StartupCommandsRunnerTest.kt +++ b/app/src/test/java/com/yugahashimoto/andcode/runtime/local/StartupCommandsRunnerTest.kt @@ -161,6 +161,17 @@ class StartupCommandsRunnerTest { assertEquals("npx desktop-commander remote", launches.last().command.last()) } + @Test + fun `blank check command is treated as no check and runs the command`() { + val runner = setUpRunner() + writeConfig("""{"commands":[{"command":"npx desktop-commander remote","checkCommand":" "}]}""") + + runner.runAll(installedRuntime()) + + // An empty-shell probe would always exit 0 and silently skip the guarded command. + assertEquals(listOf("npx desktop-commander remote"), launches.map { it.command.last() }) + } + @Test fun `malformed config runs nothing and never throws`() { val runner = setUpRunner() From e7c457863ee1959d0e45d03f2c54e2cf84cde17c Mon Sep 17 00:00:00 2001 From: lcy Date: Thu, 15 Oct 2026 22:07:10 +0800 Subject: [PATCH 10/15] feat(opencode): speak the OpenCode V2 server API alongside V1 OpenCode 2.x replaced the whole server contract: every route moved under /api, responses are wrapped in {data}, messages became a typed union, the event stream was rewritten into lifecycle events and questions became forms. A V1 client pointed at a V2 server only ever sees the SPA HTML. Connections now go through ProtocolDetectingOpenCodeApi, which probes GET /api/info and falls back to the unchanged V1 client when the server is not V2. The V2 client maps the new shapes onto the existing app DTOs and OpenCodeEvent stream, so the chat, activity and schedule layers keep working unmodified: - sessions/messages walk cursor pages newest-first; archive is emulated with session metadata because V2 has no archive field - prompts apply the chosen agent/model per prompt under a per-session lock - forms map to question cards (labels answered as option values) - the event translator keeps part ids aligned with the REST transcript, maps execution lifecycle to busy/idle/abort, and keeps long compactions alive for stall detection; reconnects reconcile pending permissions, forms and running sessions since V2 does not replay events - message delete and config editing are gated off on V2, where the server offers no equivalent OpenCodeV2LiveTest exercises the backend against a real server and only runs when OPENCODE_LIVE_URL is set. --- .../andcode/core/api/OpenCodeApi.kt | 198 ++ .../andcode/core/api/OpenCodeApiClient.kt | 146 +- .../andcode/core/api/OpenCodeV2ApiClient.kt | 1690 +++++++++++++++++ .../core/api/OpenCodeV2EventTranslator.kt | 449 +++++ .../andcode/core/api/OpenCodeV2Shared.kt | 100 + .../core/api/ProtocolDetectingOpenCodeApi.kt | 376 ++++ .../settings/ProviderSettingsScreen.kt | 12 +- .../feature/settings/ServerInfoScreen.kt | 2 +- .../feature/settings/ServerInfoViewModel.kt | 6 +- .../feature/settings/SettingsViewModel.kt | 8 + .../feature/workspace/WorkspaceViewModel.kt | 4 +- .../andcode/runtime/RuntimeCapabilities.kt | 6 + .../runtime/local/LocalOpenCodeBackend.kt | 11 +- .../runtime/local/LocalRuntimeTarget.kt | 25 +- .../runtime/remote/RemoteOpenCodeBackend.kt | 13 +- .../runtime/remote/RemoteRuntimeTarget.kt | 25 +- .../core/api/OpenCodeV2ApiClientTest.kt | 1136 +++++++++++ .../core/api/OpenCodeV2EventTranslatorTest.kt | 390 ++++ .../andcode/live/OpenCodeV2LiveTest.kt | 490 +++++ .../runtime/local/LocalOpenCodeBackendTest.kt | 11 +- .../remote/RemoteOpenCodeBackendTest.kt | 41 +- .../opencode-v2-events/execution_failed.jsonl | 8 + .../resources/opencode-v2-events/form.jsonl | 13 + .../opencode-v2-events/interrupted.jsonl | 9 + .../opencode-v2-events/permission.jsonl | 32 + .../opencode-v2-events/session_created.jsonl | 3 + .../opencode-v2-events/text_tools.jsonl | 30 + 27 files changed, 5107 insertions(+), 127 deletions(-) create mode 100644 app/src/main/java/com/yugahashimoto/andcode/core/api/OpenCodeApi.kt create mode 100644 app/src/main/java/com/yugahashimoto/andcode/core/api/OpenCodeV2ApiClient.kt create mode 100644 app/src/main/java/com/yugahashimoto/andcode/core/api/OpenCodeV2EventTranslator.kt create mode 100644 app/src/main/java/com/yugahashimoto/andcode/core/api/OpenCodeV2Shared.kt create mode 100644 app/src/main/java/com/yugahashimoto/andcode/core/api/ProtocolDetectingOpenCodeApi.kt create mode 100644 app/src/test/java/com/yugahashimoto/andcode/core/api/OpenCodeV2ApiClientTest.kt create mode 100644 app/src/test/java/com/yugahashimoto/andcode/core/api/OpenCodeV2EventTranslatorTest.kt create mode 100644 app/src/test/java/com/yugahashimoto/andcode/live/OpenCodeV2LiveTest.kt create mode 100644 app/src/test/resources/opencode-v2-events/execution_failed.jsonl create mode 100644 app/src/test/resources/opencode-v2-events/form.jsonl create mode 100644 app/src/test/resources/opencode-v2-events/interrupted.jsonl create mode 100644 app/src/test/resources/opencode-v2-events/permission.jsonl create mode 100644 app/src/test/resources/opencode-v2-events/session_created.jsonl create mode 100644 app/src/test/resources/opencode-v2-events/text_tools.jsonl diff --git a/app/src/main/java/com/yugahashimoto/andcode/core/api/OpenCodeApi.kt b/app/src/main/java/com/yugahashimoto/andcode/core/api/OpenCodeApi.kt new file mode 100644 index 000000000..37a0fcf9c --- /dev/null +++ b/app/src/main/java/com/yugahashimoto/andcode/core/api/OpenCodeApi.kt @@ -0,0 +1,198 @@ +package com.yugahashimoto.andcode.core.api + +import kotlinx.coroutines.flow.Flow +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject + +/** + * The OpenCode server surface AndCode uses, independent of which wire protocol answers it. + * + * [OpenCodeApiClient] speaks the V1 REST/SSE API and [OpenCodeV2ApiClient] speaks V2; both map their + * responses onto the same app DTOs so the backends and ViewModels above this line never branch on + * the protocol. Default arguments live here because Kotlin does not allow an override to redeclare + * them, and callers rely on the defaults through the interface type. + */ +interface OpenCodeApi { + suspend fun health(): OpenCodeHealth + + suspend fun sessions(directory: String? = null): List + + suspend fun session(sessionId: String): OpenCodeSession + + suspend fun createSession( + title: String? = null, + directory: String? = null, + ): OpenCodeSession + + suspend fun messages(sessionId: String): List + + suspend fun deleteMessage( + sessionId: String, + messageId: String, + ): Boolean + + suspend fun providers(): ProviderCatalog + + suspend fun agents(): List + + suspend fun providerAuthMethods(): Map> + + suspend fun authorizeProvider( + providerId: String, + methodIndex: Int, + inputs: Map = emptyMap(), + ): ProviderAuthAuthorization + + suspend fun setProviderApiKey( + providerId: String, + apiKey: String, + metadata: Map = emptyMap(), + ): Boolean + + suspend fun removeProviderAuth(providerId: String): Boolean + + suspend fun completeProviderOAuth( + providerId: String, + methodIndex: Int, + code: String?, + ): Boolean + + suspend fun projects(directory: String? = null): List + + suspend fun currentProject(directory: String? = null): OpenCodeProject + + suspend fun pathInfo(directory: String? = null): OpenCodePathInfo + + suspend fun files( + directory: String, + path: String, + ): List + + suspend fun fileContent( + directory: String, + path: String, + ): OpenCodeFileContent + + suspend fun fileStatus(directory: String): List + + suspend fun searchText( + directory: String, + pattern: String, + ): List + + suspend fun findFiles( + directory: String, + queryText: String, + includeDirectories: Boolean? = null, + type: String? = null, + limit: Int? = null, + ): List + + suspend fun vcsInfo(directory: String): OpenCodeVcsInfo + + suspend fun vcsStatus(directory: String): List + + suspend fun vcsDiff( + directory: String, + mode: String = "git", + context: Int? = null, + ): List + + suspend fun sessionDiff( + sessionId: String, + directory: String? = null, + messageId: String? = null, + ): List + + suspend fun sessionTodo( + sessionId: String, + directory: String? = null, + ): List + + suspend fun promptAsync( + sessionId: String, + request: PromptRequest, + ) + + suspend fun summarizeSession( + sessionId: String, + providerId: String, + modelId: String, + ): Boolean + + suspend fun abortSession(sessionId: String): Boolean + + suspend fun renameSession( + sessionId: String, + title: String, + directory: String? = null, + ): OpenCodeSession + + suspend fun deleteSession( + sessionId: String, + directory: String? = null, + ): Boolean + + suspend fun archiveSession( + sessionId: String, + directory: String? = null, + ): OpenCodeSession + + suspend fun mcpServers(): List + + suspend fun addMcpServer(body: JsonObject): McpServer + + suspend fun connectMcpServer(name: String): Boolean + + suspend fun disconnectMcpServer(name: String): Boolean + + suspend fun removeMcpAuth(name: String): McpAuthRemoval + + suspend fun mcpAuth(name: String): McpAuthStart + + suspend fun mcpAuthCallback( + name: String, + code: String, + ): McpAuthStatus + + suspend fun config(): JsonElement + + suspend fun updateConfig(patch: JsonObject): JsonElement + + suspend fun configProviders(): List + + suspend fun commands(): List + + suspend fun skills(): List + + suspend fun executeCommand( + sessionId: String, + command: String, + arguments: String = "", + agent: String? = null, + model: String? = null, + variant: String? = null, + ) + + suspend fun respondPermission( + sessionId: String, + permissionId: String, + response: String, + remember: Boolean = false, + ): Boolean + + suspend fun answerQuestion( + requestId: String, + answers: List>, + directory: String? = null, + ): Boolean + + suspend fun rejectQuestion( + requestId: String, + directory: String? = null, + ): Boolean + + suspend fun pendingQuestions(directory: String? = null): List + + fun events(): Flow +} diff --git a/app/src/main/java/com/yugahashimoto/andcode/core/api/OpenCodeApiClient.kt b/app/src/main/java/com/yugahashimoto/andcode/core/api/OpenCodeApiClient.kt index 0e97df1b6..b99505c62 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/core/api/OpenCodeApiClient.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/core/api/OpenCodeApiClient.kt @@ -40,7 +40,7 @@ class OpenCodeApiClient( private val httpClient: OkHttpClient = defaultHttpClient(profile), private val json: Json = defaultJson, private val eventParser: OpenCodeEventParser = OpenCodeEventParser(json), -) { +) : OpenCodeApi { // Resolved lazily: constructing a client must never throw. Clients are built while assembling // the runtime target list, which happens on the main thread at app start and whenever a // connection is saved, so an endpoint the current rules reject has to surface as a failed @@ -56,21 +56,21 @@ class OpenCodeApiClient( .callTimeout(PROVIDER_AUTH_TIMEOUT_MINUTES, TimeUnit.MINUTES) .build() - suspend fun health(): OpenCodeHealth = get("global/health") + override suspend fun health(): OpenCodeHealth = get("global/health") /** * Active (non-archived) sessions. The server keeps archived sessions in `GET /session` * forever — its only archive marker is `time.archived` on each entry — so the filter lives * here; every chat list the app shows is fed from this one method. */ - suspend fun sessions(directory: String? = null): List = + override suspend fun sessions(directory: String?): List = getList("session", query("directory" to directory)).filter { it.time.archived == null } - suspend fun session(sessionId: String): OpenCodeSession = get("session/${encodePath(sessionId)}") + override suspend fun session(sessionId: String): OpenCodeSession = get("session/${encodePath(sessionId)}") - suspend fun createSession( - title: String? = null, - directory: String? = null, + override suspend fun createSession( + title: String?, + directory: String?, ): OpenCodeSession { val body = buildJsonObject { @@ -83,7 +83,7 @@ class OpenCodeApiClient( ) } - suspend fun messages(sessionId: String): List = getList("session/${encodePath(sessionId)}/message") + override suspend fun messages(sessionId: String): List = getList("session/${encodePath(sessionId)}/message") /** * Permanently removes one message (and all of its parts) from a session, without reverting any @@ -91,26 +91,26 @@ class OpenCodeApiClient( * `DELETE /session/:id/message/:messageId` route documents. The server refuses this while the * session is busy, so callers must only delete out of an idle session. */ - suspend fun deleteMessage( + override suspend fun deleteMessage( sessionId: String, messageId: String, ): Boolean = delete("session/${encodePath(sessionId)}/message/${encodePath(messageId)}") - suspend fun providers(): ProviderCatalog = get("provider") + override suspend fun providers(): ProviderCatalog = get("provider") - suspend fun agents(): List = getList("agent") + override suspend fun agents(): List = getList("agent") - suspend fun providerAuthMethods(): Map> = + override suspend fun providerAuthMethods(): Map> = withContext(Dispatchers.IO) { execute(requestBuilder("provider/auth").get().build()) { body -> json.decodeFromString>>(body) } } - suspend fun authorizeProvider( + override suspend fun authorizeProvider( providerId: String, methodIndex: Int, - inputs: Map = emptyMap(), + inputs: Map, ): ProviderAuthAuthorization = post( "provider/${encodePath(providerId)}/oauth/authorize", @@ -127,10 +127,10 @@ class OpenCodeApiClient( }, ) - suspend fun setProviderApiKey( + override suspend fun setProviderApiKey( providerId: String, apiKey: String, - metadata: Map = emptyMap(), + metadata: Map, ): Boolean = put( "auth/${encodePath(providerId)}", @@ -148,9 +148,9 @@ class OpenCodeApiClient( }, ) - suspend fun removeProviderAuth(providerId: String): Boolean = delete("auth/${encodePath(providerId)}") + override suspend fun removeProviderAuth(providerId: String): Boolean = delete("auth/${encodePath(providerId)}") - suspend fun completeProviderOAuth( + override suspend fun completeProviderOAuth( providerId: String, methodIndex: Int, code: String?, @@ -170,18 +170,18 @@ class OpenCodeApiClient( } } - suspend fun projects(directory: String? = null): List = getList("project", query("directory" to directory)) + override suspend fun projects(directory: String?): List = getList("project", query("directory" to directory)) - suspend fun currentProject(directory: String? = null): OpenCodeProject = get("project/current", query("directory" to directory)) + override suspend fun currentProject(directory: String?): OpenCodeProject = get("project/current", query("directory" to directory)) - suspend fun pathInfo(directory: String? = null): OpenCodePathInfo = get("path", query("directory" to directory)) + override suspend fun pathInfo(directory: String?): OpenCodePathInfo = get("path", query("directory" to directory)) - suspend fun files( + override suspend fun files( directory: String, path: String, ): List = getList("file", query("directory" to directory, "path" to path)) - suspend fun fileContent( + override suspend fun fileContent( directory: String, path: String, ): OpenCodeFileContent = @@ -190,19 +190,19 @@ class OpenCodeApiClient( query("directory" to directory, "path" to path), ) - suspend fun fileStatus(directory: String): List = getList("file/status", query("directory" to directory)) + override suspend fun fileStatus(directory: String): List = getList("file/status", query("directory" to directory)) - suspend fun searchText( + override suspend fun searchText( directory: String, pattern: String, ): List = getList("find", query("directory" to directory, "pattern" to pattern)) - suspend fun findFiles( + override suspend fun findFiles( directory: String, queryText: String, - includeDirectories: Boolean? = null, - type: String? = null, - limit: Int? = null, + includeDirectories: Boolean?, + type: String?, + limit: Int?, ): List = getList( "find/file", @@ -215,40 +215,40 @@ class OpenCodeApiClient( ), ) - suspend fun vcsInfo(directory: String): OpenCodeVcsInfo = get("vcs", query("directory" to directory)) + override suspend fun vcsInfo(directory: String): OpenCodeVcsInfo = get("vcs", query("directory" to directory)) - suspend fun vcsStatus(directory: String): List = getList("vcs/status", query("directory" to directory)) + override suspend fun vcsStatus(directory: String): List = getList("vcs/status", query("directory" to directory)) - suspend fun vcsDiff( + override suspend fun vcsDiff( directory: String, - mode: String = "git", - context: Int? = null, + mode: String, + context: Int?, ): List = getList( "vcs/diff", query("directory" to directory, "mode" to mode, "context" to context?.toString()), ) - suspend fun sessionDiff( + override suspend fun sessionDiff( sessionId: String, - directory: String? = null, - messageId: String? = null, + directory: String?, + messageId: String?, ): List = getList( "session/${encodePath(sessionId)}/diff", query("directory" to directory, "messageID" to messageId), ) - suspend fun sessionTodo( + override suspend fun sessionTodo( sessionId: String, - directory: String? = null, + directory: String?, ): List = getList( "session/${encodePath(sessionId)}/todo", query("directory" to directory), ) - suspend fun promptAsync( + override suspend fun promptAsync( sessionId: String, request: PromptRequest, ) { @@ -293,7 +293,7 @@ class OpenCodeApiClient( postWithoutResponse("session/${encodePath(sessionId)}/prompt_async", body) } - suspend fun summarizeSession( + override suspend fun summarizeSession( sessionId: String, providerId: String, modelId: String, @@ -306,12 +306,12 @@ class OpenCodeApiClient( }, ) - suspend fun abortSession(sessionId: String): Boolean = post("session/${encodePath(sessionId)}/abort", JsonObject(emptyMap())) + override suspend fun abortSession(sessionId: String): Boolean = post("session/${encodePath(sessionId)}/abort", JsonObject(emptyMap())) - suspend fun renameSession( + override suspend fun renameSession( sessionId: String, title: String, - directory: String? = null, + directory: String?, ): OpenCodeSession { val body = buildJsonObject { put("title", title) } return patch( @@ -321,9 +321,9 @@ class OpenCodeApiClient( ) } - suspend fun deleteSession( + override suspend fun deleteSession( sessionId: String, - directory: String? = null, + directory: String?, ): Boolean = delete( "session/${encodePath(sessionId)}", @@ -336,9 +336,9 @@ class OpenCodeApiClient( * silently ignores that unknown key, so the call returned 200, archived nothing, and the * auto-archive settings never moved a single OpenCode chat out of the drawer. */ - suspend fun archiveSession( + override suspend fun archiveSession( sessionId: String, - directory: String? = null, + directory: String?, ): OpenCodeSession { val body = buildJsonObject { @@ -351,7 +351,7 @@ class OpenCodeApiClient( ) } - suspend fun mcpServers(): List = + override suspend fun mcpServers(): List = withContext(Dispatchers.IO) { execute(requestBuilder("mcp").get().build()) { body -> val root = json.parseToJsonElement(body).jsonObject @@ -376,22 +376,22 @@ class OpenCodeApiClient( } } - suspend fun addMcpServer(body: JsonObject): McpServer { + override suspend fun addMcpServer(body: JsonObject): McpServer { val statuses: Map = post("mcp", body) val name = (body["name"] as? JsonPrimitive)?.content ?: error("MCP server name is missing") val status = statuses[name] return McpServer(name = name, status = status?.status, error = status?.error) } - suspend fun connectMcpServer(name: String): Boolean = post("mcp/${encodePath(name)}/connect", JsonObject(emptyMap())) + override suspend fun connectMcpServer(name: String): Boolean = post("mcp/${encodePath(name)}/connect", JsonObject(emptyMap())) - suspend fun disconnectMcpServer(name: String): Boolean = post("mcp/${encodePath(name)}/disconnect", JsonObject(emptyMap())) + override suspend fun disconnectMcpServer(name: String): Boolean = post("mcp/${encodePath(name)}/disconnect", JsonObject(emptyMap())) - suspend fun removeMcpAuth(name: String): McpAuthRemoval = delete("mcp/${encodePath(name)}/auth") + override suspend fun removeMcpAuth(name: String): McpAuthRemoval = delete("mcp/${encodePath(name)}/auth") - suspend fun mcpAuth(name: String): McpAuthStart = post("mcp/${encodePath(name)}/auth", JsonObject(emptyMap())) + override suspend fun mcpAuth(name: String): McpAuthStart = post("mcp/${encodePath(name)}/auth", JsonObject(emptyMap())) - suspend fun mcpAuthCallback( + override suspend fun mcpAuthCallback( name: String, code: String, ): McpAuthStatus { @@ -399,14 +399,14 @@ class OpenCodeApiClient( return post("mcp/${encodePath(name)}/auth/callback", body) } - suspend fun config(): JsonElement = + override suspend fun config(): JsonElement = withContext(Dispatchers.IO) { execute(requestBuilder("config").get().build()) { body -> json.parseToJsonElement(body) } } - suspend fun updateConfig(patch: JsonObject): JsonElement = + override suspend fun updateConfig(patch: JsonObject): JsonElement = withContext(Dispatchers.IO) { val request = requestBuilder("config") @@ -417,19 +417,19 @@ class OpenCodeApiClient( } } - suspend fun configProviders(): List = getList("config/providers") + override suspend fun configProviders(): List = getList("config/providers") - suspend fun commands(): List = getList("command") + override suspend fun commands(): List = getList("command") - suspend fun skills(): List = getList("skill") + override suspend fun skills(): List = getList("skill") - suspend fun executeCommand( + override suspend fun executeCommand( sessionId: String, command: String, - arguments: String = "", - agent: String? = null, - model: String? = null, - variant: String? = null, + arguments: String, + agent: String?, + model: String?, + variant: String?, ) { val body = buildJsonObject { @@ -442,11 +442,11 @@ class OpenCodeApiClient( postWithoutResponse("session/${encodePath(sessionId)}/command", body) } - suspend fun respondPermission( + override suspend fun respondPermission( sessionId: String, permissionId: String, response: String, - remember: Boolean = false, + remember: Boolean, ): Boolean { val apiResponse = if (remember && response == "once") "always" else response val body = @@ -467,10 +467,10 @@ class OpenCodeApiClient( * other workspace is invisible without it and the reply comes back as * `QuestionNotFoundError`. Pass the directory the question was asked in. */ - suspend fun answerQuestion( + override suspend fun answerQuestion( requestId: String, answers: List>, - directory: String? = null, + directory: String?, ): Boolean { val body = buildJsonObject { @@ -492,9 +492,9 @@ class OpenCodeApiClient( } /** Declines a question outright, which fails the waiting tool call without killing the turn. */ - suspend fun rejectQuestion( + override suspend fun rejectQuestion( requestId: String, - directory: String? = null, + directory: String?, ): Boolean = post( "question/${encodePath(requestId)}/reject", @@ -508,7 +508,7 @@ class OpenCodeApiClient( * opened after the fact — has to be recovered here or the turn stays blocked with nothing on * screen to unblock it. */ - suspend fun pendingQuestions(directory: String? = null): List = + override suspend fun pendingQuestions(directory: String?): List = getList("question", query("directory" to directory)) .map { request -> request.copy(directory = request.directory ?: directory) } @@ -520,7 +520,7 @@ class OpenCodeApiClient( * forever on a request that never reaches the client. Subscribe to the cross-instance * `/global/event` stream instead, and fall back only for servers that predate it. */ - fun events(): Flow = + override fun events(): Flow = flow { emitAll(singleEventStream(eventPath)) }.retryWhen { cause, attempt -> if ( eventPath == GLOBAL_EVENT_PATH && diff --git a/app/src/main/java/com/yugahashimoto/andcode/core/api/OpenCodeV2ApiClient.kt b/app/src/main/java/com/yugahashimoto/andcode/core/api/OpenCodeV2ApiClient.kt new file mode 100644 index 000000000..f15738e59 --- /dev/null +++ b/app/src/main/java/com/yugahashimoto/andcode/core/api/OpenCodeV2ApiClient.kt @@ -0,0 +1,1690 @@ +package com.yugahashimoto.andcode.core.api + +import com.yugahashimoto.andcode.core.security.OpenCodeUrl +import com.yugahashimoto.andcode.data.connection.ConnectionProfile +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.buffer +import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.retryWhen +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.longOrNull +import kotlinx.serialization.json.put +import okhttp3.Credentials +import okhttp3.HttpUrl +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.toRequestBody +import java.io.IOException +import java.net.URLEncoder +import java.util.Base64 +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.TimeUnit + +/** + * Client for the OpenCode V2 HTTP/SSE API (under the `api/` prefix). Every response is mapped onto the existing app + * DTOs, so the backends and ViewModels above [OpenCodeApi] are protocol-agnostic. + * + * V2 differs from V1 in ways this client hides: responses are wrapped in `{data}`, sessions are + * cursor-paged, model and agent are set by separate calls instead of the prompt body, questions are + * "forms", and the message transcript is a union of typed rows rather than role/parts pairs. + */ +class OpenCodeV2ApiClient( + private val profile: ConnectionProfile, + private val httpClient: OkHttpClient = OpenCodeApiClient.defaultHttpClient(profile), + private val json: Json = OpenCodeApiClient.defaultJson, + private val formRegistry: OpenCodeV2FormRegistry = OpenCodeV2FormRegistry(), +) : OpenCodeApi { + // Resolved lazily for the same reason as the V1 client: construction must never throw. + private val baseUrl: HttpUrl by lazy { OpenCodeUrl.normalize(profile.baseUrl).getOrThrow() } + + private val providerAuthHttpClient: OkHttpClient = + httpClient.newBuilder() + .readTimeout(PROVIDER_AUTH_TIMEOUT_MINUTES, TimeUnit.MINUTES) + .callTimeout(PROVIDER_AUTH_TIMEOUT_MINUTES, TimeUnit.MINUTES) + .build() + + /** + * Serialises "apply agent and model, then send the prompt" per session. V2 takes the agent and + * model as separate calls, so two concurrent prompts on one session must not interleave: the + * second could otherwise switch the model under the first's prompt. + * + * There is deliberately no cache of the last applied selection. The session can be switched by + * another client (the web UI, another phone), and a cache would then skip applying the user's + * choice. The calls are cheap, so every prompt re-applies what it asks for. + */ + private val sessionLocks = ConcurrentHashMap() + + /** Provider id to the auth methods offered to the user, in the order the UI indexes them. */ + private val integrationMethods = ConcurrentHashMap>() + + /** Provider id to the OAuth attempt started for it; the completion call needs the attempt id. */ + private val oauthAttempts = ConcurrentHashMap() + + override suspend fun health(): OpenCodeHealth = + withContext(Dispatchers.IO) { + execute(requestBuilder("api/info").get().build()) { body -> + val version = + json.parseToJsonElement(body).jsonObject.textOf("version") + ?: error("OpenCode V2 info response had no version") + OpenCodeHealth(healthy = true, version = version) + } + } + + /** + * Active (non-archived) sessions. V2 cannot store `time.archived`, so archiving writes a timestamp + * into `metadata` instead, and this filter reads both markers. + */ + override suspend fun sessions(directory: String?): List = + pagedList( + SESSIONS_PATH, + query("directory" to directory, "order" to "desc", "limit" to PAGE_LIMIT), + ).map { it.toSession() }.filter { it.time.archived == null } + + override suspend fun session(sessionId: String): OpenCodeSession = rawSession(sessionId).toSession() + + override suspend fun createSession( + title: String?, + directory: String?, + ): OpenCodeSession { + val body = + buildJsonObject { + title?.takeIf { it.isNotBlank() }?.let { put("title", it) } + directory?.takeIf { it.isNotBlank() }?.let { put("location", buildJsonObject { put("directory", it) }) } + } + return postData(SESSIONS_PATH, body).toSession() + } + + override suspend fun messages(sessionId: String): List = + messagePages(sessionId) + .mapNotNull { it as? JsonObject } + .flatMap { it.toMessages(sessionId, json) } + + /** + * V2 has no message delete. The app gates the action with `editMessages = false` on V2 targets, + * so this is only reached if a caller ignores that capability. + */ + override suspend fun deleteMessage( + sessionId: String, + messageId: String, + ): Boolean = throw UnsupportedOperationException("OpenCode V2 cannot delete messages") + + /** + * Instantiated providers (those with a model) plus every integration that can be authenticated. + * The settings screen lists `all` to offer credentials, so an integration such as OpenAI must + * appear even before it has any models. Models are only attached to providers that have them, + * and the chat picker skips providers with no models, so the extra entries stay out of it. + * + * `connected` is the set the chat may use: providers with an active integration connection, or + * with an enabled provider activation, and a provider that has models. Integrations that merely + * have an authentication method are not connected. + */ + override suspend fun providers(): ProviderCatalog { + val providers = getData>("api/provider") + val models = getData>("api/model").filter { it.enabled != false } + val integrations = runCatching { integrations() }.getOrDefault(emptyList()) + // The default is advisory: a server with no usable default must still produce a catalog. + val defaultModel = + try { + getData("api/model/default") + } catch (error: OpenCodeApiException) { + null + } + + val modelsByProvider = models.groupBy { it.providerID } + val integrationsById = integrations.associateBy { it.id } + // Providers and models come first; authenticable integrations are appended after them. + val providerIds = + ( + providers.map { it.id } + + modelsByProvider.keys.filterNot { id -> providers.any { it.id == id } } + + integrations.filter { it.methods.any { method -> method.isSupportedAuthMethod() } }.map { it.id } + ).distinct() + val all = + providerIds.map { id -> + OpenCodeProvider( + id = id, + name = providers.firstOrNull { it.id == id }?.name ?: integrationsById[id]?.name ?: id, + models = + modelsByProvider[id].orEmpty().associate { model -> + model.id to model.toOpenCodeModel() + }, + ) + } + val enabledProviders = providers.filter { it.activation == "enabled" }.map { it.id } + val connectedByIntegration = integrations.filter { it.connections.isNotEmpty() }.map { it.id } + return ProviderCatalog( + all = all, + default = defaultModel?.let { mapOf(it.providerID to it.id) }.orEmpty(), + connected = (enabledProviders + connectedByIntegration).distinct(), + ) + } + + override suspend fun agents(): List = + getData>("api/agent").map { agent -> + OpenCodeAgent( + name = agent.id, + description = agent.description, + mode = agent.mode, + hidden = agent.hidden, + ) + } + + /** + * Maps the integration's key and OAuth methods to the V1 shape, with each method's form fields + * as prompts. Env, command and external methods have no equivalent in the settings UI and are + * left out, so a method's list position is the index the UI passes back to [authorizeProvider]. + * A method whose form cannot be represented is left out too, rather than offered half-working. + */ + override suspend fun providerAuthMethods(): Map> { + val result = LinkedHashMap>() + integrations().forEach { integration -> + val methods = integration.methods.filter { it.isSupportedAuthMethod() && it.representable(json) } + integrationMethods[integration.id] = methods + if (methods.isNotEmpty()) { + result[integration.id] = methods.map { it.toAuthMethod(json) } + } + } + return result + } + + override suspend fun authorizeProvider( + providerId: String, + methodIndex: Int, + inputs: Map, + ): ProviderAuthAuthorization { + val method = authMethodAt(providerId, methodIndex) + val methodId = method.id ?: error("OpenCode OAuth method has no id") + require(method.type == "oauth") { "OpenCode auth method $methodIndex is not OAuth" } + val attempt = + postData( + "api/integration/${encodePath(providerId)}/connect/oauth", + buildJsonObject { + put("methodID", methodId) + answerFor(method.form, inputs)?.let { put("answer", it) } + }, + ) + oauthAttempts[providerId] = attempt.attemptID + return ProviderAuthAuthorization(url = attempt.url, method = attempt.mode, instructions = attempt.instructions) + } + + /** + * Connects a key. The dialog's collected prompt inputs travel as the form `answer`, which is how + * V2 receives per-provider settings such as a base URL. The API key itself is the `key`. + */ + override suspend fun setProviderApiKey( + providerId: String, + apiKey: String, + metadata: Map, + ): Boolean { + val method = integrationMethods[providerId]?.firstOrNull { it.type == "key" } + val body = + buildJsonObject { + put("key", apiKey) + answerFor(method?.form, metadata)?.let { put("answer", it) } + } + sendVoid( + requestBuilder("api/integration/${encodePath(providerId)}/connect/key").post(jsonBody(body)), + ) + return true + } + + /** + * V2 credentials are listed globally, so removal finds every credential that belongs to the + * provider and deletes each one. + */ + override suspend fun removeProviderAuth(providerId: String): Boolean { + getData>("api/credential") + .filter { it.integrationID == providerId } + .forEach { credential -> sendVoid(requestBuilder("api/credential/${encodePath(credential.id)}").delete()) } + return true + } + + override suspend fun completeProviderOAuth( + providerId: String, + methodIndex: Int, + code: String?, + ): Boolean { + val attemptId = + oauthAttempts[providerId] ?: throw IllegalStateException("OpenCode OAuth attempt was not started") + val body = + buildJsonObject { + code?.takeIf { it.isNotBlank() }?.let { put("code", it) } + } + sendVoid( + requestBuilder("api/integration/${encodePath(providerId)}/connect/oauth/${encodePath(attemptId)}/complete") + .post(jsonBody(body)), + providerAuthHttpClient, + ) + oauthAttempts.remove(providerId) + return true + } + + override suspend fun projects(directory: String?): List = + getData>("api/project").map { project -> + OpenCodeProject(id = project.id, worktree = project.canonical.orEmpty(), name = project.name) + } + + override suspend fun currentProject(directory: String?): OpenCodeProject { + val location = getData("api/location", locationQuery(directory)) + val project = location.project + return OpenCodeProject( + id = project?.id ?: "global", + worktree = project?.canonical ?: location.directory, + ) + } + + override suspend fun pathInfo(directory: String?): OpenCodePathInfo { + val location = getData("api/location", locationQuery(directory)) + return OpenCodePathInfo( + home = "", + state = "", + config = "", + worktree = location.project?.canonical ?: location.directory, + directory = location.directory, + ) + } + + override suspend fun files( + directory: String, + path: String, + ): List { + val entries = + getData>( + "api/fs/list", + locationQuery(directory) + query("path" to path.ifBlank { "." }), + ) + return entries.map { entry -> + val relative = entry.path.trimEnd('/') + OpenCodeFileNode( + name = relative.substringAfterLast('/'), + path = relative, + absolute = "${directory.trimEnd('/')}/$relative", + type = entry.type, + ) + } + } + + /** + * V2 serves file bodies raw, not in a JSON envelope. Text is returned as text; anything else is + * base64-encoded with `type = "binary"`, which is what the code viewer already checks for. + */ + override suspend fun fileContent( + directory: String, + path: String, + ): OpenCodeFileContent = + withContext(Dispatchers.IO) { + val request = + requestBuilder("api/fs/read/${encodeRelativePath(path)}", locationQuery(directory)).get().build() + httpClient.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + throw OpenCodeApiException( + statusCode = response.code, + message = "OpenCode request failed (HTTP ${response.code})", + ) + } + val bytes = response.body?.bytes() ?: ByteArray(0) + val mime = response.header("Content-Type")?.substringBefore(';')?.trim()?.lowercase() + if (isTextual(mime, bytes)) { + OpenCodeFileContent(type = "text", content = String(bytes, Charsets.UTF_8), mimeType = mime) + } else { + OpenCodeFileContent( + type = "binary", + content = Base64.getEncoder().encodeToString(bytes), + encoding = "base64", + mimeType = mime, + ) + } + } + } + + override suspend fun fileStatus(directory: String): List = + getData>("api/vcs/status", locationQuery(directory)).map { it.toFileChange() } + + /** + * V2 has no grep endpoint. Callers already wrap this in `runCatching`, so the explorer shows + * file matches without text matches. + */ + override suspend fun searchText( + directory: String, + pattern: String, + ): List = throw UnsupportedOperationException("OpenCode V2 has no text search") + + override suspend fun findFiles( + directory: String, + queryText: String, + includeDirectories: Boolean?, + type: String?, + limit: Int?, + ): List { + // V1 `dirs=false` meant files only; V2 has no such flag, so it maps to a type filter. + val effectiveType = type ?: if (includeDirectories == false) "file" else null + return getData>( + "api/fs/find", + locationQuery(directory) + + query( + "query" to queryText, + "type" to effectiveType, + "limit" to limit?.toString(), + ), + ).map { it.path } + } + + override suspend fun vcsInfo(directory: String): OpenCodeVcsInfo { + val info = getData("api/vcs", locationQuery(directory)) + // The default branch is a second call, so a failure here must not hide the branch. + val base = + try { + getData("api/vcs/base", locationQuery(directory)).let { element -> + (element as? JsonObject)?.let { json.decodeFromJsonElement(it) } + } + } catch (error: OpenCodeApiException) { + null + } + return OpenCodeVcsInfo( + branch = info.branch?.current, + defaultBranch = info.branch?.defaultBranch ?: base?.name, + ) + } + + override suspend fun vcsStatus(directory: String): List = + getData>("api/vcs/status", locationQuery(directory)).map { it.toFileChange() } + + /** V1 `git` was the whole-tree diff, which is V2 `working`. `context` has no V2 equivalent. */ + override suspend fun vcsDiff( + directory: String, + mode: String, + context: Int?, + ): List = + getData>( + "api/vcs/diff", + locationQuery(directory) + query("mode" to v2DiffMode(mode)), + ).map { it.toFileChange() } + + override suspend fun sessionDiff( + sessionId: String, + directory: String?, + messageId: String?, + ): List = getData>("api/session/${encodePath(sessionId)}/diff").map { it.toFileChange() } + + /** V2 has no todo endpoint or event; todos are only visible inside tool output. */ + override suspend fun sessionTodo( + sessionId: String, + directory: String?, + ): List = emptyList() + + /** + * Agent and model are applied through their own calls, and only when they differ from what this + * session last received. The prompt itself then carries the text and attachments, with + * `resume` set from [PromptRequest.noReply]. + */ + override suspend fun promptAsync( + sessionId: String, + request: PromptRequest, + ) { + sessionLocks.getOrPut(sessionId) { Mutex() }.withLock { + request.agent?.takeIf { it.isNotBlank() }?.let { applyAgent(sessionId, it) } + applyModel(sessionId, request.providerId, request.modelId, request.variant) + sendPrompt(sessionId, request) + } + } + + private suspend fun sendPrompt( + sessionId: String, + request: PromptRequest, + ) { + val body = + buildJsonObject { + put("text", request.text) + if (request.attachments.isNotEmpty()) { + put( + "files", + buildJsonArray { + request.attachments.forEach { attachment -> + add( + buildJsonObject { + put("uri", attachment.url) + put("name", attachment.filename) + put( + "mention", + buildJsonObject { + put("start", 0) + put("end", 0) + put("text", "@${attachment.filename}") + }, + ) + }, + ) + } + }, + ) + } + put("resume", !request.noReply) + } + sendVoid(requestBuilder("api/session/${encodePath(sessionId)}/prompt").post(jsonBody(body))) + } + + /** V2 compacts with the session's current model, so [providerId] and [modelId] are not sent. */ + override suspend fun summarizeSession( + sessionId: String, + providerId: String, + modelId: String, + ): Boolean { + sendVoid(requestBuilder("api/session/${encodePath(sessionId)}/compact").post(jsonBody(JsonObject(emptyMap())))) + return true + } + + /** Returns the server's `interrupted` flag; V2 reports `true` even for an idle session. */ + override suspend fun abortSession(sessionId: String): Boolean = + withContext(Dispatchers.IO) { + execute( + requestBuilder("api/session/${encodePath(sessionId)}/interrupt") + .post(jsonBody(JsonObject(emptyMap()))) + .build(), + ) { body -> + (json.parseToJsonElement(body).jsonObject["interrupted"] as? JsonPrimitive)?.booleanOrNull ?: true + } + } + + override suspend fun renameSession( + sessionId: String, + title: String, + directory: String?, + ): OpenCodeSession { + sendVoid( + requestBuilder("api/session/${encodePath(sessionId)}") + .patch(jsonBody(buildJsonObject { put("title", title) })), + ) + return session(sessionId) + } + + override suspend fun deleteSession( + sessionId: String, + directory: String?, + ): Boolean { + sendVoid(requestBuilder("api/session/${encodePath(sessionId)}").delete()) + return true + } + + /** + * V2 silently ignores `time.archived` on PATCH, so archiving writes a timestamp into `metadata` + * while keeping the existing entries. [sessions] reads that key back as the archive marker. + */ + override suspend fun archiveSession( + sessionId: String, + directory: String?, + ): OpenCodeSession { + val current = rawSession(sessionId) + val metadata = + JsonObject( + (current.metadata ?: emptyMap()) + + (ARCHIVED_METADATA_KEY to JsonPrimitive(System.currentTimeMillis())), + ) + sendVoid( + requestBuilder("api/session/${encodePath(sessionId)}") + .patch(jsonBody(buildJsonObject { put("metadata", metadata) })), + ) + return session(sessionId) + } + + override suspend fun mcpServers(): List = + getData>("api/mcp").map { server -> + val status = server.status as? JsonObject + McpServer( + name = server.name, + status = status?.textOf("status") ?: (server.status as? JsonPrimitive)?.contentOrNull, + error = status?.textOf("error"), + ) + } + + /** V2 registers an MCP server with a PUT of its `config`; the V1 body carried `name` next to it. */ + override suspend fun addMcpServer(body: JsonObject): McpServer { + val name = (body["name"] as? JsonPrimitive)?.contentOrNull ?: error("MCP server name is missing") + val config = body["config"] ?: JsonObject(body.filterKeys { it != "name" }) + sendVoid( + requestBuilder("api/experimental/mcp/${encodePath(name)}") + .put(jsonBody(buildJsonObject { put("config", config) })), + ) + return McpServer(name = name) + } + + override suspend fun connectMcpServer(name: String): Boolean { + sendVoid(requestBuilder("api/experimental/mcp/${encodePath(name)}/connect").post(jsonBody(JsonObject(emptyMap())))) + return true + } + + override suspend fun disconnectMcpServer(name: String): Boolean { + sendVoid(requestBuilder("api/experimental/mcp/${encodePath(name)}/disconnect").post(jsonBody(JsonObject(emptyMap())))) + return true + } + + override suspend fun removeMcpAuth(name: String): McpAuthRemoval = throw UnsupportedOperationException(MCP_AUTH_UNSUPPORTED) + + override suspend fun mcpAuth(name: String): McpAuthStart = throw UnsupportedOperationException(MCP_AUTH_UNSUPPORTED) + + override suspend fun mcpAuthCallback( + name: String, + code: String, + ): McpAuthStatus = throw UnsupportedOperationException(MCP_AUTH_UNSUPPORTED) + + /** + * V2 returns configuration as a list of documents, not one object. The documents are merged + * shallowly in order, so a later document overrides an earlier one, and only `info` is kept. + */ + override suspend fun config(): JsonElement { + val documents = getData("api/config") + val merged = linkedMapOf() + (documents as? JsonArray).orEmpty().forEach { document -> + val entry = document as? JsonObject ?: return@forEach + if (entry.textOf("type") == "document") { + (entry["info"] as? JsonObject)?.let { merged.putAll(it) } + } + } + return JsonObject(merged) + } + + /** + * V2 accepts only `{shell}` on its config patch endpoint, so a general config write (custom + * providers, arbitrary keys) cannot be applied remotely. Failing here, with a message the + * settings screens can show, is safer than sending a patch that the server silently drops. + */ + override suspend fun updateConfig(patch: JsonObject): JsonElement = + throw UnsupportedOperationException( + "This OpenCode server is V2, which only accepts shell settings over the API. " + + "Edit opencode.json on the server to change this configuration.", + ) + + override suspend fun configProviders(): List { + val catalog = providers() + return catalog.all.map { provider -> + ConfiguredProvider( + id = provider.id, + name = provider.name, + defaultModel = catalog.default[provider.id], + connected = provider.id in catalog.connected, + ) + } + } + + override suspend fun commands(): List = + getData>("api/command").map { OpenCodeCommand(name = it.name, description = it.description) } + + override suspend fun skills(): List = + getData>("api/skill").map { OpenCodeSkill(name = it.id, description = it.description) } + + /** Applies the agent and model first, since V2 commands carry neither. [model] is `provider/model`. */ + override suspend fun executeCommand( + sessionId: String, + command: String, + arguments: String, + agent: String?, + model: String?, + variant: String?, + ) { + sessionLocks.getOrPut(sessionId) { Mutex() }.withLock { + agent?.takeIf { it.isNotBlank() }?.let { applyAgent(sessionId, it) } + val providerAndModel = model?.split("/", limit = 2) + if (providerAndModel?.size == 2) { + applyModel(sessionId, providerAndModel[0], providerAndModel[1], variant) + } + sendVoid( + requestBuilder("api/session/${encodePath(sessionId)}/command").post( + jsonBody( + buildJsonObject { + put("name", command) + put("text", arguments) + }, + ), + ), + ) + } + } + + /** V1 `always` is requested by [remember]; the V2 decision enum takes the same values. */ + override suspend fun respondPermission( + sessionId: String, + permissionId: String, + response: String, + remember: Boolean, + ): Boolean { + val decision = if (remember && response == "once") "always" else response + sendVoid( + requestBuilder("api/session/${encodePath(sessionId)}/permission/${encodePath(permissionId)}/reply") + .post(jsonBody(buildJsonObject { put("decision", decision) })), + ) + return true + } + + /** + * Questions are V2 forms. The answer list is positional: one entry per field, and an empty entry + * leaves that field out so the server can apply its own required check. + */ + override suspend fun answerQuestion( + requestId: String, + answers: List>, + directory: String?, + ): Boolean { + val form = form(requestId, directory) + val answer = + buildJsonObject { + form.fields.forEachIndexed { index, field -> + val values = answers.getOrNull(index).orEmpty() + if (values.isNotEmpty()) put(field.key, field.answerValue(values)) + } + } + sendVoid( + requestBuilder("api/session/${encodePath(form.sessionId)}/form/${encodePath(form.id)}/reply") + .post(jsonBody(buildJsonObject { put("answer", answer) })), + ) + formRegistry.forget(form.id) + return true + } + + override suspend fun rejectQuestion( + requestId: String, + directory: String?, + ): Boolean { + val form = form(requestId, directory) + sendVoid(requestBuilder("api/session/${encodePath(form.sessionId)}/form/${encodePath(form.id)}").delete()) + formRegistry.forget(form.id) + return true + } + + override suspend fun pendingQuestions(directory: String?): List = + pendingForms(directory).map { it.toQuestionRequest(directory) } + + /** + * Streams `api/event`. A V2 stream has no `event:` or `id:` lines and no replay, so a reconnect + * relies on the caller re-reading the transcript. Retry rules match the V1 client. + */ + override fun events(): Flow = + flow { emitAll(singleEventStream()) }.retryWhen { cause, attempt -> + val retryable = cause !is OpenCodeApiException || cause.statusCode >= 500 + if (!retryable) return@retryWhen false + delay(backoffMillis(attempt)) + true + } + + /** + * One SSE connection. Each `data:` payload goes through a translator created for this connection + * only, because the translator keeps per-stream state such as text ordinals and part ids. + */ + private fun singleEventStream(): Flow = + channelFlow { + val eventClient = + httpClient.newBuilder() + .readTimeout(0, TimeUnit.MILLISECONDS) + .build() + val request = + requestBuilder(EVENT_PATH) + .header("Accept", "text/event-stream") + .header("Cache-Control", "no-cache") + .get() + .build() + val call = eventClient.newCall(request) + val translator = OpenCodeV2EventTranslator(json, formRegistry) + val readerJob = + launch(Dispatchers.IO) { + try { + call.execute().use { response -> + if (!response.isSuccessful) { + throw OpenCodeApiException( + statusCode = response.code, + message = "OpenCode event stream failed (HTTP ${response.code})", + ) + } + val body = requireNotNull(response.body) { "OpenCode event stream had no body" } + body.source().use { source -> + val data = StringBuilder() + while (isActive) { + val line = source.readUtf8Line() ?: break + when { + line.isEmpty() -> { + if (data.isNotEmpty()) { + val events = translator.translate(data.toString()) + events.forEach { send(it) } + // A V2 stream has no replay, so state that began before this connection + // is pulled in once, right after the server confirms the connection. + if (events.any { it == OpenCodeEvent.ServerConnected }) { + reconcile(translator) { send(it) } + } + data.setLength(0) + } + } + line.startsWith("data:") -> { + if (data.isNotEmpty()) data.append('\n') + data.append(line.removePrefix("data:").removePrefix(" ")) + } + } + } + if (data.isNotEmpty()) translator.translate(data.toString()).forEach { send(it) } + } + throw IOException("OpenCode event stream closed") + } + } catch (cancellation: CancellationException) { + throw cancellation + } catch (error: Throwable) { + close(error) + } + } + awaitClose { + call.cancel() + readerJob.cancel() + } + }.buffer(EVENT_BUFFER_CAPACITY) + + private suspend fun rawSession(sessionId: String): V2SessionInfo = getData("api/session/${encodePath(sessionId)}") + + /** + * Re-emits state that a fresh SSE connection cannot replay: pending permissions, pending forms + * (questions), and sessions that are still running. Each step is independent and failures are + * swallowed: reconciliation is best-effort, and it must never end the live stream. + * + * Running sessions also get their in-flight tool calls seeded into [translator], so that the + * next `tool.progress` or `tool.success` keeps the call's name and input instead of blanking them. + */ + private suspend fun reconcile( + translator: OpenCodeV2EventTranslator, + emit: suspend (OpenCodeEvent) -> Unit, + ) { + runCatching { + getData>("api/permission/request").forEach { request -> + emit(OpenCodeEvent.PermissionAsked(request.toPermissionRequest())) + } + } + runCatching { + pendingForms(null).forEach { form -> + emit(OpenCodeEvent.QuestionAsked(form.toQuestionRequest(null))) + } + } + runCatching { + val active = getData>("api/session/active") + active.keys.forEach { sessionId -> + emit(OpenCodeEvent.SessionStatusChanged(sessionId, "busy")) + runCatching { seedRunningTools(sessionId, translator) } + } + } + } + + /** Seeds the calls of a running session's latest assistant turn; older turns are already settled. */ + private suspend fun seedRunningTools( + sessionId: String, + translator: OpenCodeV2EventTranslator, + ) { + val latest = + execute( + requestBuilder( + "api/session/${encodePath(sessionId)}/message", + query("order" to "desc", "limit" to "20"), + ).get().build(), + ) { body -> json.parseToJsonElement(body).jsonObject.arrOf("data").orEmpty() } + latest.mapNotNull { it as? JsonObject } + .firstOrNull { it.textOf("type") == "assistant" } + ?.arrOf("content") + ?.forEach { element -> + val item = element as? JsonObject ?: return@forEach + if (item.textOf("type") != "tool") return@forEach + val callId = item.textOf("id") ?: return@forEach + val input = (item["state"] as? JsonObject)?.get("input") + translator.seedTool(callId, item.textOf("name").orEmpty(), input) + } + } + + /** Looks a form up in the cache first, then in the server's pending list, which covers a form seen before this process started. */ + private suspend fun form( + formId: String, + directory: String?, + ): V2Form = + formRegistry.get(formId) + ?: pendingForms(directory).firstOrNull { it.id == formId } + ?: throw OpenCodeApiException(statusCode = 404, message = "OpenCode question was not found") + + private suspend fun pendingForms(directory: String?): List = + getData>("api/form", locationQuery(directory)).onEach(formRegistry::remember) + + private suspend fun applyAgent( + sessionId: String, + agent: String, + ) { + sendVoid( + requestBuilder("api/session/${encodePath(sessionId)}/agent") + .post(jsonBody(buildJsonObject { put("agent", agent) })), + ) + } + + private suspend fun applyModel( + sessionId: String, + providerId: String?, + modelId: String?, + variant: String?, + ) { + if (providerId.isNullOrBlank() || modelId.isNullOrBlank()) return + val body = + buildJsonObject { + put( + "model", + buildJsonObject { + put("id", modelId) + put("providerID", providerId) + variant?.takeIf { it.isNotBlank() }?.let { put("variant", it) } + }, + ) + } + sendVoid(requestBuilder("api/session/${encodePath(sessionId)}/model").post(jsonBody(body))) + } + + /** Every integration the server knows about. Read fresh each time; the list is small and changes when credentials do. */ + private suspend fun integrations(): List = getData("api/integration") + + private suspend fun authMethodAt( + providerId: String, + methodIndex: Int, + ): V2IntegrationMethod { + val methods = + integrationMethods[providerId] + ?: integrations() + .firstOrNull { it.id == providerId } + ?.let { integration -> integration.methods.filter { it.isSupportedAuthMethod() && it.representable(json) } } + ?.also { integrationMethods[providerId] = it } + ?: throw IllegalArgumentException("OpenCode provider $providerId has no auth methods") + return methods.getOrNull(methodIndex) + ?: throw IllegalArgumentException("OpenCode provider $providerId has no auth method $methodIndex") + } + + private suspend inline fun getData( + path: String, + query: List> = emptyList(), + ): T = + withContext(Dispatchers.IO) { + execute(requestBuilder(path, query).get().build()) { body -> + json.decodeFromJsonElement(unwrapData(json.parseToJsonElement(body))) + } + } + + private suspend inline fun postData( + path: String, + body: JsonObject, + query: List> = emptyList(), + ): T = + withContext(Dispatchers.IO) { + execute(requestBuilder(path, query).post(jsonBody(body)).build()) { raw -> + json.decodeFromJsonElement(unwrapData(json.parseToJsonElement(raw))) + } + } + + private suspend inline fun pagedList( + path: String, + query: List> = emptyList(), + ): List = pagedElements(path, query).map { json.decodeFromJsonElement(it) } + + /** + * Reads every page of a cursor-paged list, newest first, and returns the records in the order + * they were read. Callers that need chronological order reverse the result themselves. + * + * The list is walked newest-first on purpose: when the hard cap is reached, the records that are + * kept are the ones the user is looking at, not the oldest history. A cursor that repeats is a + * server loop, so it stops the walk instead of spinning until the cap. + */ + private suspend fun pagedElements( + path: String, + query: List>, + ): List = + withContext(Dispatchers.IO) { + val items = mutableListOf() + val seenCursors = HashSet() + var cursor: String? = null + var pages = 0 + do { + // The cursor already carries the walk's direction; the server rejects a later page + // that names `order` again (400 InvalidCursorError), so it only goes on the first. + val pageQuery = + if (cursor == null) query else query.filterNot { it.first == "order" } + ("cursor" to cursor) + val root = + execute(requestBuilder(path, pageQuery).get().build()) { body -> + json.parseToJsonElement(body).jsonObject + } + root.arrOf("data")?.let { items.addAll(it) } + val next = root.objOf("cursor")?.textOf("next") + cursor = next?.takeIf { seenCursors.add(it) } + pages++ + } while (cursor != null && pages < MAX_PAGES) + items + } + + /** + * Messages newest-first (so a cap keeps the recent turns), then reversed into chronological order + * for the transcript. The reversal is applied to the whole walk, never to a single page, so the + * order across page boundaries is correct too. + */ + private suspend fun messagePages(sessionId: String): List = + pagedElements( + "api/session/${encodePath(sessionId)}/message", + query("order" to "desc", "limit" to PAGE_LIMIT), + ).asReversed() + + private suspend fun sendVoid( + builder: Request.Builder, + client: OkHttpClient = httpClient, + ) { + withContext(Dispatchers.IO) { + execute(builder.build(), client) { } + } + } + + private fun execute( + request: Request, + client: OkHttpClient = httpClient, + parse: (String) -> T, + ): T { + client.newCall(request).execute().use { response -> + val bodyText = response.body?.string().orEmpty() + if (!response.isSuccessful) { + throw OpenCodeApiException( + statusCode = response.code, + message = formatHttpError(response.code, bodyText, request.isSensitive()), + ) + } + return parse(bodyText) + } + } + + private fun formatHttpError( + statusCode: Int, + body: String, + sensitive: Boolean, + ): String { + if (sensitive || statusCode == 401 || statusCode == 403) { + return "OpenCode request failed (HTTP $statusCode)" + } + val snippet = + body + .lineSequence() + .map { it.trim() } + .filter { it.isNotEmpty() } + .take(3) + .joinToString(" ") + .take(MAX_ERROR_BODY_CHARS) + return if (snippet.isBlank()) { + "OpenCode request failed (HTTP $statusCode)" + } else { + "OpenCode request failed (HTTP $statusCode): $snippet" + } + } + + private fun requestBuilder( + path: String, + queryParameters: List> = emptyList(), + ): Request.Builder { + val resolved = + baseUrl.resolve(path.removePrefix("/")) + ?: throw IllegalArgumentException("Invalid OpenCode API path") + val url = + resolved.newBuilder().apply { + queryParameters.forEach { (name, value) -> addQueryParameter(name, value) } + }.build() + return Request.Builder() + .url(url) + .header("Accept", "application/json") + .apply { + // V2 accepts only the username `opencode`; a profile username is ignored on purpose. + profile.password?.takeIf { it.isNotBlank() }?.let { password -> + header("Authorization", Credentials.basic(USERNAME, password)) + } + } + } + + private fun jsonBody(body: JsonElement): RequestBody = body.toString().toRequestBody(JSON_MEDIA_TYPE) + + private fun Request.isSensitive(): Boolean { + val path = url.encodedPath + return path.startsWith("/api/integration") || path.startsWith("/api/credential") + } + + private fun query(vararg parameters: Pair): List> = + parameters.mapNotNull { (name, value) -> + value?.takeIf { it.isNotBlank() }?.let { name to it } + } + + private fun locationQuery(directory: String?): List> = query("location[directory]" to directory) + + private fun encodePath(value: String): String = value.replace("/", "%2F").replace("?", "%3F").replace("#", "%23") + + private fun encodeRelativePath(path: String): String = + path.trim('/').split('/').joinToString("/") { segment -> + URLEncoder.encode(segment, Charsets.UTF_8.name()).replace("+", "%20") + } + + private fun v2DiffMode(mode: String): String = if (mode in V2_DIFF_MODES) mode else "working" + + private fun isTextual( + mime: String?, + bytes: ByteArray, + ): Boolean { + // Without a usable type, a NUL byte is the only cheap evidence that the body is binary. + if (mime == null || mime == "application/octet-stream") return bytes.none { it == 0.toByte() } + return mime.startsWith("text/") || mime in TEXT_MIME_TYPES || mime.endsWith("+json") || mime.endsWith("+xml") + } + + private fun backoffMillis(attempt: Long): Long = (500L * (1L shl attempt.toInt().coerceAtMost(5))).coerceAtMost(15_000L) + + companion object { + /** V2 ignores any other username, so the Basic auth identity is fixed. */ + internal const val USERNAME = "opencode" + private const val SESSIONS_PATH = "api/session" + private const val EVENT_PATH = "api/event" + private const val PAGE_LIMIT = "200" + + /** 100 pages of 200 is 20,000 records: generous for a chat, and still a bound on a broken cursor. */ + private const val MAX_PAGES = 100 + private const val MAX_ERROR_BODY_CHARS = 240 + private const val EVENT_BUFFER_CAPACITY = 512 + private const val PROVIDER_AUTH_TIMEOUT_MINUTES = 6L + private const val MCP_AUTH_UNSUPPORTED = "OpenCode V2 does not expose MCP OAuth over REST" + private val JSON_MEDIA_TYPE = "application/json; charset=utf-8".toMediaType() + private val V2_DIFF_MODES = setOf("working", "branch", "committed") + private val TEXT_MIME_TYPES = + setOf( + "application/json", + "application/xml", + "application/javascript", + "application/typescript", + "application/yaml", + "application/toml", + "application/x-sh", + "application/sql", + ) + } +} + +/** Metadata key that carries the archive timestamp, since V2 cannot write `time.archived`. */ +internal const val ARCHIVED_METADATA_KEY = "andcode.archivedAt" + +private fun unwrapData(element: JsonElement): JsonElement = (element as? JsonObject)?.get("data") ?: element + +private fun JsonObject.textOf(key: String): String? = (this[key] as? JsonPrimitive)?.contentOrNull + +private fun JsonObject.longOf(key: String): Long? = (this[key] as? JsonPrimitive)?.longOrNull + +private fun JsonObject.objOf(key: String): JsonObject? = this[key] as? JsonObject + +private fun JsonObject.arrOf(key: String): JsonArray? = this[key] as? JsonArray + +private fun V2IntegrationMethod.isSupportedAuthMethod(): Boolean = type == "key" || type == "oauth" + +/** + * True when the settings dialog can collect this method's form. Fields the dialog has no control + * for (boolean, number, multiselect, external links) and conditions it cannot express make the + * whole method unrepresentable, so it is left out instead of being offered half-working. + */ +private fun V2IntegrationMethod.representable(json: Json): Boolean { + val fields = authFieldsOrNull(json) ?: return false + return fields.all { it.isRepresentable() } +} + +/** + * Decodes the form fields. Returns null when any field fails to decode, so a method whose form is + * unreadable is treated as unrepresentable rather than offered with its fields silently dropped. + */ +private fun V2IntegrationMethod.authFieldsOrNull(json: Json): List? { + val decoded = form.orEmpty().map { element -> runCatching { json.decodeFromJsonElement(element) }.getOrNull() } + return if (decoded.any { it == null }) null else decoded.filterNotNull() +} + +private fun V2IntegrationMethod.authFields(json: Json): List = authFieldsOrNull(json).orEmpty() + +private fun V2AuthField.isRepresentable(): Boolean { + val controlSupported = type == "string" && !(hidden ?: false) + // The dialog's `when` supports a single equality test; anything more is not representable. + val conditionSupported = `when`.size <= 1 && `when`.all { it.op == "eq" && it.value != null } + return controlSupported && conditionSupported +} + +private fun V2IntegrationMethod.toAuthMethod(json: Json): ProviderAuthMethod { + val prompts = authFields(json).map { it.toPrompt() } + return if (type == "oauth") { + ProviderAuthMethod(type = "oauth", label = label ?: "OAuth", prompts = prompts) + } else { + ProviderAuthMethod(type = "api", label = label ?: "API key", prompts = prompts) + } +} + +/** A `select` when the field has options, otherwise a free `text` prompt, which is what the dialog renders. */ +private fun V2AuthField.toPrompt(): ProviderAuthPrompt = + ProviderAuthPrompt( + type = if (options.isNotEmpty()) "select" else "text", + key = key, + message = description ?: title ?: key, + placeholder = placeholder, + options = options.map { ProviderAuthOption(label = it.label ?: it.value, value = it.value, hint = it.description) }, + whenCondition = `when`.singleOrNull()?.let { ProviderAuthWhen(key = it.key, op = it.op, value = it.value.toDisplayString()) }, + ) + +/** + * Builds the form `answer` for a method from the dialog's inputs. Only keys the method declares + * are sent, so a stray input cannot reach the server. Returns null when there is nothing to send. + */ +private fun answerFor( + form: List?, + inputs: Map, +): JsonObject? { + val keys = form.orEmpty().mapNotNull { element -> (element as? JsonObject)?.textOf("key") }.toSet() + val answer = inputs.filterKeys { it in keys }.filterValues { it.isNotBlank() } + if (answer.isEmpty()) return null + return JsonObject(answer.mapValues { (_, value) -> JsonPrimitive(value) }) +} + +private fun JsonElement?.toDisplayString(): String = (this as? JsonPrimitive)?.contentOrNull.orEmpty() + +private fun V2SessionInfo.toSession(): OpenCodeSession { + val archivedAt = metadata?.get(ARCHIVED_METADATA_KEY)?.let { (it as? JsonPrimitive)?.longOrNull } + return OpenCodeSession( + id = id, + slug = slug, + projectId = projectID, + parentId = parentID, + directory = location?.directory, + title = title.orEmpty(), + version = version, + time = time.copy(archived = time.archived ?: archivedAt), + tokens = tokens, + ) +} + +private fun V2Model.toOpenCodeModel(): OpenCodeModel = + OpenCodeModel( + id = id, + providerId = providerID, + name = name ?: id, + status = status, + limit = limit, + variants = variants.associate { variant -> variant.id to (variant.settings ?: JsonObject(emptyMap())) }, + ) + +private fun V2FileChange.toFileChange(): OpenCodeFileChange = + OpenCodeFileChange( + file = file, + patch = patch, + additions = additions, + deletions = deletions, + status = status, + ) + +/** + * Converts the app's answer strings into the value the form expects. The UI answers with option + * *labels*, while the server stores option *values*, so a label that names an option is replaced + * by that option's value. Free text that matches no option passes through unchanged, which is what + * a custom answer needs. + */ +private fun V2FormField.answerValue(values: List): JsonElement { + fun optionValue(answer: String): String = options.firstOrNull { it.label == answer || it.value == answer }?.value ?: answer + + val first = values.first() + return when (type) { + "multiselect" -> JsonArray(values.map { JsonPrimitive(optionValue(it)) }) + "boolean" -> JsonPrimitive(first.equals("yes", ignoreCase = true) || first.equals("true", ignoreCase = true)) + "number" -> + JsonPrimitive(first.trim().toDoubleOrNull() ?: throw IllegalArgumentException("Expected a number for $key")) + "integer" -> + JsonPrimitive(first.trim().toLongOrNull() ?: throw IllegalArgumentException("Expected an integer for $key")) + else -> JsonPrimitive(optionValue(first)) + } +} + +/** + * Maps one row of the V2 transcript onto the V1 message shape. The row types are the V2 union + * `Session.Message.Info`. Only the rows that are part of the visible conversation are mapped; + * synthetic, system, skill, compaction, agent/model selection and location rows are skipped. + */ +private fun JsonObject.toMessages( + sessionId: String, + json: Json, +): List { + val id = textOf("id") ?: return emptyList() + val time = objOf("time") + val created = time?.longOf("created") ?: 0L + return when (textOf("type")) { + "user" -> listOf(toUserMessage(id, sessionId, created)) + "assistant" -> listOf(toAssistantMessage(id, sessionId, time, json)) + "idle" -> listOfNotNull(toIdleMessage(id, sessionId, created)) + "shell" -> listOf(toShellMessage(id, sessionId, created)) + else -> emptyList() + } +} + +private fun JsonObject.toUserMessage( + id: String, + sessionId: String, + created: Long, +): OpenCodeMessage { + val parts = mutableListOf() + textOf("text")?.takeIf { it.isNotEmpty() }?.let { text -> + parts += + OpenCodePart( + id = OpenCodeV2Ids.textPartId(id, 0), + sessionId = sessionId, + messageId = id, + type = "text", + text = text, + ) + } + arrOf("files")?.forEachIndexed { index, element -> + val file = element as? JsonObject ?: return@forEachIndexed + val mime = file.textOf("mime") ?: "application/octet-stream" + val data = file.textOf("data") + val url = if (data != null) "data:$mime;base64,$data" else file.objOf("source")?.textOf("uri") + parts += + OpenCodePart( + id = "$id-file-$index", + sessionId = sessionId, + messageId = id, + type = "file", + mime = mime, + filename = file.textOf("name"), + url = url, + ) + } + return OpenCodeMessage( + info = + OpenCodeMessageInfo( + id = id, + sessionId = sessionId, + role = "user", + time = OpenCodeTime(created = created), + ), + parts = parts, + ) +} + +private fun JsonObject.toAssistantMessage( + id: String, + sessionId: String, + time: JsonObject?, + json: Json, +): OpenCodeMessage { + val parts = mutableListOf() + var textOrdinal = 0 + var reasoningOrdinal = 0 + arrOf("content")?.forEach { element -> + val item = element as? JsonObject ?: return@forEach + when (item.textOf("type")) { + "reasoning" -> + parts += + OpenCodePart( + id = OpenCodeV2Ids.reasoningPartId(id, reasoningOrdinal++), + sessionId = sessionId, + messageId = id, + type = "reasoning", + text = item.textOf("text"), + ) + "text" -> + parts += + OpenCodePart( + id = OpenCodeV2Ids.textPartId(id, textOrdinal++), + sessionId = sessionId, + messageId = id, + type = "text", + text = item.textOf("text"), + ) + "tool" -> item.toToolPart(id, sessionId)?.let { parts += it } + } + } + // Tokens are informational; a malformed block must not hide the whole assistant turn. + val tokens = objOf("tokens")?.let { runCatching { json.decodeFromJsonElement(it) }.getOrNull() } + return OpenCodeMessage( + info = + OpenCodeMessageInfo( + id = id, + sessionId = sessionId, + role = "assistant", + time = OpenCodeTime(created = time?.longOf("created") ?: 0L, completed = time?.longOf("completed")), + agent = textOf("agent"), + model = + objOf("model")?.let { model -> + OpenCodeModelReference( + providerId = model.textOf("providerID").orEmpty(), + modelId = model.textOf("id").orEmpty(), + ) + }, + tokens = tokens, + error = objOf("error")?.toMessageError(), + ), + parts = parts, + ) +} + +/** V2 tool states are `streaming`, `running`, `completed` and `error`. Only `streaming` is renamed; the rest match V1. */ +private fun JsonObject.toToolPart( + messageId: String, + sessionId: String, +): OpenCodePart? { + val callId = textOf("id") ?: return null + val state = objOf("state") ?: JsonObject(emptyMap()) + val output = + state.arrOf("content") + ?.mapNotNull { (it as? JsonObject)?.takeIf { part -> part.textOf("type") == "text" }?.textOf("text") } + ?.joinToString("") + val error = + (state["error"] as? JsonObject)?.textOf("message") + ?: (state["error"] as? JsonPrimitive)?.contentOrNull + val mappedState = linkedMapOf() + mappedState["status"] = JsonPrimitive(mapToolStatus(state.textOf("status"))) + state["input"]?.let { mappedState["input"] = it } + output?.takeIf { it.isNotEmpty() }?.let { mappedState["output"] = JsonPrimitive(it) } + error?.let { mappedState["error"] = JsonPrimitive(it) } + state["metadata"]?.let { mappedState["metadata"] = it } + state.textOf("title")?.let { mappedState["title"] = JsonPrimitive(it) } + return OpenCodePart( + id = callId, + sessionId = sessionId, + messageId = messageId, + type = "tool", + tool = textOf("name"), + callID = callId, + state = mappedState, + ) +} + +private fun mapToolStatus(status: String?): String = + when (status) { + "streaming" -> "pending" + else -> status ?: "pending" + } + +/** + * An idle row records how the turn ended. `succeeded` is the normal case and adds nothing to the + * transcript. `failed` becomes an error message, and `interrupted` becomes an abort, which is not a + * failure and is shown as one. + */ +private fun JsonObject.toIdleMessage( + id: String, + sessionId: String, + created: Long, +): OpenCodeMessage? { + val error = + when (textOf("outcome")) { + // A failure that the server recorded as an abort is a stop the user asked for, not an error. + "failed" -> objOf("error")?.toMessageError() ?: OpenCodeMessageError(name = "UnknownError") + "interrupted" -> OpenCodeMessageError(name = ABORT_ERROR_NAME) + else -> return null + } + return OpenCodeMessage( + info = + OpenCodeMessageInfo( + id = id, + sessionId = sessionId, + role = "assistant", + time = OpenCodeTime(created = created), + error = error, + ), + ) +} + +private fun JsonObject.toShellMessage( + id: String, + sessionId: String, + created: Long, +): OpenCodeMessage { + val status = textOf("status") + val exit = longOf("exit") + val toolStatus = + when { + status == "running" -> "running" + status == "exited" && (exit == null || exit == 0L) -> "completed" + else -> "error" + } + val callId = textOf("shellID") ?: id + val state = linkedMapOf() + state["status"] = JsonPrimitive(toolStatus) + textOf("command")?.let { state["input"] = JsonObject(mapOf("command" to JsonPrimitive(it))) } + objOf("output")?.textOf("output")?.let { state["output"] = JsonPrimitive(it) } + if (toolStatus == "error") state["error"] = JsonPrimitive("Shell command ended with status ${status ?: "unknown"}") + return OpenCodeMessage( + info = + OpenCodeMessageInfo( + id = id, + sessionId = sessionId, + role = "assistant", + time = OpenCodeTime(created = created), + ), + parts = + listOf( + OpenCodePart( + id = callId, + sessionId = sessionId, + messageId = id, + type = "tool", + tool = "shell", + callID = callId, + state = state, + ), + ), + ) +} + +/** + * V2 names an aborted run `aborted` (for example a compaction cancelled by an interrupt). The app's + * abort check matches on [ABORT_ERROR_NAME], so the name is translated here, or the stop would show + * as a failure. + */ +private fun JsonObject.toMessageError(): OpenCodeMessageError { + val type = textOf("type") + return OpenCodeMessageError( + name = if (type == "aborted") ABORT_ERROR_NAME else type, + data = textOf("message")?.let { mapOf("message" to JsonPrimitive(it)) }, + ) +} + +private const val ABORT_ERROR_NAME = "MessageAbortedError" + +@Serializable +private data class V2Location( + val directory: String? = null, +) + +@Serializable +private data class V2LocationInfo( + val directory: String, + val project: V2LocationProject? = null, +) + +@Serializable +private data class V2LocationProject( + val id: String? = null, + val canonical: String? = null, +) + +@Serializable +private data class V2SessionInfo( + val id: String, + val slug: String? = null, + @SerialName("projectID") val projectID: String? = null, + @SerialName("parentID") val parentID: String? = null, + val title: String? = null, + val version: String? = null, + val location: V2Location? = null, + val time: OpenCodeTime = OpenCodeTime(), + val tokens: OpenCodeSessionTokens? = null, + val metadata: JsonObject? = null, +) + +@Serializable +private data class V2Provider( + val id: String, + val name: String? = null, + val activation: String? = null, +) + +@Serializable +private data class V2Model( + val id: String, + @SerialName("providerID") val providerID: String, + val name: String? = null, + val status: String? = null, + val enabled: Boolean? = null, + val limit: OpenCodeModelLimit? = null, + val variants: List = emptyList(), +) + +@Serializable +private data class V2Variant( + val id: String, + val settings: JsonElement? = null, +) + +@Serializable +private data class V2Agent( + val id: String, + val description: String? = null, + val mode: String? = null, + val hidden: Boolean = false, +) + +@Serializable +private data class V2Integration( + val id: String, + val name: String? = null, + val methods: List = emptyList(), + val connections: List = emptyList(), +) + +@Serializable +private data class V2IntegrationMethod( + val type: String, + val id: String? = null, + val label: String? = null, + val form: List? = null, +) + +/** One field of an integration's `form`; only the parts the settings dialog can represent are read. */ +@Serializable +private data class V2AuthField( + val key: String, + val type: String, + val title: String? = null, + val description: String? = null, + val placeholder: String? = null, + val hidden: Boolean? = null, + val required: Boolean? = null, + val options: List = emptyList(), + val `when`: List = emptyList(), +) + +@Serializable +private data class V2AuthWhen( + val key: String, + val op: String, + val value: JsonElement? = null, +) + +@Serializable +private data class V2OAuthAttempt( + val attemptID: String, + val url: String, + val instructions: String = "", + val mode: String = "auto", +) + +@Serializable +private data class V2Credential( + val id: String, + @SerialName("integrationID") val integrationID: String, +) + +@Serializable +private data class V2Project( + val id: String, + val canonical: String? = null, + val name: String? = null, +) + +@Serializable +private data class V2FsEntry( + val path: String, + val type: String, +) + +@Serializable +private data class V2FileChange( + val file: String? = null, + val patch: String? = null, + val additions: Double = 0.0, + val deletions: Double = 0.0, + val status: String? = null, +) + +@Serializable +private data class V2VcsBranch( + val current: String? = null, + @SerialName("default") val defaultBranch: String? = null, +) + +@Serializable +private data class V2VcsInfo( + val branch: V2VcsBranch? = null, +) + +@Serializable +private data class V2VcsBase( + val name: String, +) + +@Serializable +private data class V2Command( + val name: String, + val description: String? = null, +) + +@Serializable +private data class V2Skill( + val id: String, + val description: String? = null, +) + +/** `Permission.Request` as V2 sends it. `action` is the tool name; `resources` are the things it touches. */ +@Serializable +private data class V2PermissionRequest( + val id: String, + @SerialName("sessionID") val sessionId: String, + val action: String, + val resources: List = emptyList(), + val save: List = emptyList(), +) + +/** Maps a V2 permission onto the V1 request the approval UI already renders. */ +private fun V2PermissionRequest.toPermissionRequest(): PermissionRequest = + PermissionRequest( + id = id, + sessionId = sessionId, + permission = action, + patterns = resources, + ) + +@Serializable +private data class V2McpServer( + val name: String, + val status: JsonElement? = null, +) diff --git a/app/src/main/java/com/yugahashimoto/andcode/core/api/OpenCodeV2EventTranslator.kt b/app/src/main/java/com/yugahashimoto/andcode/core/api/OpenCodeV2EventTranslator.kt new file mode 100644 index 000000000..5a630ae26 --- /dev/null +++ b/app/src/main/java/com/yugahashimoto/andcode/core/api/OpenCodeV2EventTranslator.kt @@ -0,0 +1,449 @@ +package com.yugahashimoto.andcode.core.api + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.intOrNull + +/** + * Turns one V2 SSE `data:` payload into the V1-shaped [OpenCodeEvent]s the chat, activity log and + * schedule watcher already understand, so none of them need to know which protocol is on the wire. + * + * V2 has no `message.part.*` snapshots of its own: it streams `session.text.*`, `session.tool.*` and + * friends per assistant step. The translator rebuilds the V1 part snapshots from those, and it uses + * [OpenCodeV2Ids] for part ids so that a streamed part and the same part reloaded from + * `GET api/session/{id}/message` are one row, not two. + * + * Stateful: tool calls are keyed by call id across several events, so one instance must serve one + * SSE connection only. Never throws: a known type with a broken body becomes [OpenCodeEvent.Unknown], + * and a payload that is not a typed JSON object becomes an empty list. + */ +class OpenCodeV2EventTranslator( + private val json: Json, + private val formRegistry: OpenCodeV2FormRegistry, + /** Epoch-ms source; injectable so the liveness throttle is testable without sleeping. */ + private val clock: () -> Long = System::currentTimeMillis, +) { + /** Tool calls seen so far, so that later events for the same call keep its name, input and metadata. */ + private val toolCalls = HashMap() + + /** When each session last emitted a liveness delta; see [livenessThrottled]. */ + private val lastLivenessAt = HashMap() + + fun translate(rawData: String): List { + val envelope = runCatching { json.parseToJsonElement(rawData) as? JsonObject }.getOrNull() ?: return emptyList() + val type = envelope.string("type") ?: return emptyList() + return runCatching { translateEvent(type, envelope, rawData) } + .getOrElse { listOf(OpenCodeEvent.Unknown(type, rawData)) } + } + + private fun translateEvent( + type: String, + envelope: JsonObject, + rawData: String, + ): List { + val data = envelope.objectOrNull("data") ?: JsonObject(emptyMap()) + val envelopeDirectory = envelope.objectOrNull("location")?.string("directory") + return when (type) { + "server.connected" -> listOf(OpenCodeEvent.ServerConnected) + // The step snapshot is the only place V2 names the role of an assistant message; the chat + // needs that role to tell the server's echo of the user's own prompt apart from the reply. + "session.step.started" -> { + val model = data.objectOrNull("model") + listOf( + OpenCodeEvent.MessageUpdated( + OpenCodeMessageInfo( + id = data.required("assistantMessageID"), + sessionId = data.required("sessionID"), + role = "assistant", + agent = data.string("agent"), + model = + model?.let { + OpenCodeModelReference( + providerId = it.required("providerID"), + modelId = it.required("id"), + ) + }, + ), + ), + ) + } + "session.step.ended" -> + listOf( + OpenCodeEvent.MessageUpdated( + OpenCodeMessageInfo( + id = data.required("assistantMessageID"), + sessionId = data.required("sessionID"), + role = "assistant", + tokens = + data["tokens"]?.let { json.decodeFromJsonElement(OpenCodeSessionTokens.serializer(), it) }, + ), + ), + ) + // step.streamed repeats per LLM chunk and usage.updated per step; during a long generation with + // no visible text they are the only sign the run is alive, so they count as liveness too. + "session.step.streamed", "session.usage.updated" -> + listOfNotNull(livenessThrottled(data.required("sessionID"))) + "session.step.failed" -> { + val error = data.objectOrNull("error") + listOf( + OpenCodeEvent.MessageUpdated( + OpenCodeMessageInfo( + id = data.required("assistantMessageID"), + sessionId = data.required("sessionID"), + role = "assistant", + error = + OpenCodeMessageError( + name = mappedErrorName(error), + data = error?.string("message")?.let { mapOf("message" to JsonPrimitive(it)) }, + ), + ), + ), + ) + } + // Text and reasoning share one shape: a blank `started` snapshot, deltas, then the full + // `ended` text, which replaces whatever the deltas accumulated. + "session.text.started" -> listOf(partStarted(data, "text", OpenCodeV2Ids::textPartId)) + "session.text.delta" -> listOf(partDelta(data, "text", OpenCodeV2Ids::textPartId)) + "session.text.ended" -> listOf(partEnded(data, "text", OpenCodeV2Ids::textPartId)) + "session.reasoning.started" -> listOf(partStarted(data, "reasoning", OpenCodeV2Ids::reasoningPartId)) + "session.reasoning.delta" -> listOf(partDelta(data, "reasoning", OpenCodeV2Ids::reasoningPartId)) + "session.reasoning.ended" -> listOf(partEnded(data, "reasoning", OpenCodeV2Ids::reasoningPartId)) + "session.tool.input.started" -> { + val callId = data.required("id") + val name = data.string("name") + toolCalls[callId] = ToolCallState(name = name) + listOf(toolUpdate(data, callId, name, status = "pending")) + } + // The argument text is complete here, so the card can show the command before the tool runs. + "session.tool.input.ended" -> { + val callId = data.required("id") + val call = toolCalls.getOrPut(callId) { ToolCallState(name = null) } + call.input = data.string("text")?.let { runCatching { json.parseToJsonElement(it) }.getOrNull() } ?: call.input + listOf(toolUpdate(data, callId, call.name, status = "pending", call = call)) + } + "session.tool.called" -> { + val callId = data.required("id") + val call = toolCalls.getOrPut(callId) { ToolCallState(name = null) } + call.input = data["input"] ?: call.input + listOf(toolUpdate(data, callId, call.name, status = "running", call = call)) + } + "session.tool.progress" -> { + val callId = data.required("id") + val call = toolCalls.getOrPut(callId) { ToolCallState(name = null) } + call.metadata = data["metadata"] ?: call.metadata + listOf(toolUpdate(data, callId, call.name, status = "running", call = call)) + } + "session.tool.success" -> { + val callId = data.required("id") + val call = toolCalls.remove(callId) ?: ToolCallState(name = null) + listOf( + toolUpdate( + data, + callId, + call.name, + status = "completed", + call = call, + extra = + listOfNotNull( + joinedText(data["content"])?.let { "output" to JsonPrimitive(it) }, + (data["metadata"] ?: call.metadata)?.let { "metadata" to it }, + ).toMap(), + ), + ) + } + "session.tool.failed" -> { + val callId = data.required("id") + val call = toolCalls.remove(callId) ?: ToolCallState(name = null) + val message = data.objectOrNull("error")?.string("message") + listOf( + toolUpdate( + data, + callId, + call.name, + status = "error", + call = call, + extra = listOfNotNull(message?.let { "error" to JsonPrimitive(it) }).toMap(), + ), + ) + } + "session.execution.started" -> + listOf(OpenCodeEvent.SessionStatusChanged(data.required("sessionID"), "busy")) + // A manual compaction is a run of its own with no text to show, so it is announced as busy + // like any run. Its deltas are the only sign it is alive, and they are sent as liveness only + // (see [livenessDelta]): the chat ignores them, the stall watchdog counts them. + "session.compaction.started" -> listOf(OpenCodeEvent.SessionStatusChanged(data.required("sessionID"), "busy")) + "session.compaction.delta" -> listOfNotNull(livenessThrottled(data.required("sessionID"))) + // Nothing extra: compaction failure and success are followed by session.execution.* which settles idle. + "session.compaction.failed" -> emptyList() + // Emitted as SessionIdle rather than SessionStatusChanged("idle"): the schedule watcher + // completes a run only on SessionIdle, and the activity repository fires its idle + // notification from it too. + "session.execution.succeeded" -> listOf(OpenCodeEvent.SessionIdle(data.required("sessionID"))) + // An interruption is a stop, not a completion. V1 reports a stop as a MessageAbortedError + // session.error followed by idle; the activity repository mutes the session on that error, + // so the idle does not raise a "completed" notification. V2 sends no error here, so one + // is synthesised to get the same treatment, whichever device asked for the stop. + "session.execution.interrupted" -> { + val sessionId = data.required("sessionID") + listOf( + OpenCodeEvent.SessionError(sessionId, message = null, name = ABORT_ERROR_NAME), + OpenCodeEvent.SessionIdle(sessionId), + ) + } + "session.execution.failed" -> { + val sessionId = data.required("sessionID") + val error = data.objectOrNull("error") + // The error comes before the idle: the activity repository mutes the session on the error, + // which stops the idle that follows from raising a completion notification for a failed run. + listOf( + OpenCodeEvent.SessionError(sessionId, error?.string("message"), mappedErrorName(error)), + OpenCodeEvent.SessionIdle(sessionId), + ) + } + "permission.asked" -> + listOf( + OpenCodeEvent.PermissionAsked( + PermissionRequest( + id = data.required("id"), + sessionId = data.required("sessionID"), + permission = data.required("action"), + patterns = data.stringList("resources"), + metadata = + listOfNotNull( + data["save"]?.let { "save" to it }, + data["source"]?.let { "source" to it }, + ).toMap(), + ), + ), + ) + "permission.replied" -> + listOf( + OpenCodeEvent.PermissionReplied( + sessionId = data.required("sessionID"), + requestId = data.required("requestID"), + ), + ) + // Remembered before it is announced, so the answer can be posted without a second lookup. + // V2 forms replace V1 questions, so they reach the UI as questions. + "form.created" -> { + val form = json.decodeFromJsonElement(V2Form.serializer(), data.getValue("form")) + formRegistry.remember(form) + listOf(OpenCodeEvent.QuestionAsked(form.toQuestionRequest(directory = envelopeDirectory))) + } + // No event on the chat side removes a card that was answered or cancelled elsewhere, and V1 + // has the same gap for questions. Forgetting the form keeps the registry from growing. + "form.replied", "form.cancelled" -> { + formRegistry.forget(data.required("id")) + emptyList() + } + // `session.created` and `session.forked` are dropped on purpose. Neither names a subagent's + // parent: `parentID` on a fork is the session it was forked FROM, which is not the parent the + // activity repository needs. Emitting it as SessionCreated would cache the source as a + // subagent parent and mute the wrong session. The repository's REST lookup + // (`GET api/session/{id}`) is the one reliable source of a parent, so it is left to do that. + "session.created", "session.forked" -> emptyList() + in IgnoredTypes -> emptyList() + else -> listOf(OpenCodeEvent.Unknown(type, rawData)) + } + } + + /** + * A liveness-only [OpenCodeEvent.MessagePartDelta] for [sessionId], at most one per + * [LIVENESS_INTERVAL_MS]. The stream can emit `step.streamed` many times a second; the stall + * watchdog only needs a heartbeat, and each delta costs a state update in the repository. + * + * The delta names a field the chat does not render, so it changes nothing on screen. It is a + * delta rather than a status change because the watchdog only counts message, part, permission, + * question, idle and error events as progress (see `provesRunProgress`). + */ + private fun livenessThrottled(sessionId: String): OpenCodeEvent? { + val now = clock() + val last = lastLivenessAt[sessionId] + if (last != null && now - last < LIVENESS_INTERVAL_MS) return null + lastLivenessAt[sessionId] = now + return OpenCodeEvent.MessagePartDelta( + sessionId = sessionId, + messageId = "", + partId = "", + field = LIVENESS_FIELD, + delta = "", + ) + } + + private fun partStarted( + data: JsonObject, + type: String, + partId: (String, Int) -> String, + ): OpenCodeEvent { + val messageId = data.required("assistantMessageID") + return OpenCodeEvent.MessagePartUpdated( + OpenCodePart( + id = partId(messageId, data.ordinal()), + sessionId = data.required("sessionID"), + messageId = messageId, + type = type, + text = "", + ), + ) + } + + private fun partDelta( + data: JsonObject, + field: String, + partId: (String, Int) -> String, + ): OpenCodeEvent { + val messageId = data.required("assistantMessageID") + return OpenCodeEvent.MessagePartDelta( + sessionId = data.required("sessionID"), + messageId = messageId, + partId = partId(messageId, data.ordinal()), + field = field, + delta = data.required("delta"), + ) + } + + private fun partEnded( + data: JsonObject, + type: String, + partId: (String, Int) -> String, + ): OpenCodeEvent { + val messageId = data.required("assistantMessageID") + return OpenCodeEvent.MessagePartUpdated( + OpenCodePart( + id = partId(messageId, data.ordinal()), + sessionId = data.required("sessionID"), + messageId = messageId, + type = type, + text = data.required("text"), + ), + ) + } + + /** + * A tool part snapshot. The part id is the call id, which is also what the V2 message mapper uses, + * so a streamed tool and its reloaded copy are the same row. + */ + private fun toolUpdate( + data: JsonObject, + callId: String, + name: String?, + status: String, + call: ToolCallState? = null, + extra: Map = emptyMap(), + ): OpenCodeEvent { + val state = linkedMapOf("status" to JsonPrimitive(status)) + call?.input?.let { state["input"] = it } + call?.metadata?.let { state["metadata"] = it } + state.putAll(extra) + return OpenCodeEvent.MessagePartUpdated( + OpenCodePart( + id = callId, + sessionId = data.required("sessionID"), + messageId = data.required("assistantMessageID"), + type = "tool", + tool = name, + callID = callId, + state = state, + ), + ) + } + + /** Facts about one tool call that arrive on different events. */ + private class ToolCallState( + val name: String?, + var input: JsonElement? = null, + var metadata: JsonElement? = null, + ) + + /** + * Seeds a tool call the client already knows about, so that later `tool.progress` / `tool.success` + * events after a reconnect keep the name and input. Without it, a reconnected stream would rebuild + * the part with a null tool name and no input, which overwrites what the chat already shows. + */ + fun seedTool( + callId: String, + name: String, + input: JsonElement?, + ) { + toolCalls[callId] = ToolCallState(name = name, input = input) + } + + private companion object { + /** Minimum gap between two liveness deltas for one session. */ + const val LIVENESS_INTERVAL_MS = 5_000L + + /** Field name of the liveness-only delta. Not `text` or `reasoning`, so the chat never renders it. */ + const val LIVENESS_FIELD = "liveness" + + /** + * Events that carry nothing the app acts on and that repeat often, mostly once per step or per + * shell command. They are dropped rather than sent as [OpenCodeEvent.Unknown], because every + * Unknown is written to the activity log as "Unsupported event". Rename and move are here too: + * they carry a partial session, and mapping them to SessionUpdated would overwrite a known + * parent with null in the activity repository. + */ + val IgnoredTypes = + setOf( + "session.inbox.enqueued", + "session.inbox.delivered", + "session.instructions.updated", + "session.shell.started", + "session.shell.ended", + "session.viewed", + "session.renamed", + "session.moved", + "session.deleted", + "session.agent.selected", + "session.model.selected", + "shell.created", + "shell.exited", + "project.updated", + "vcs.branch.updated", + "agent.updated", + "model.updated", + "provider.updated", + "integration.updated", + "skill.updated", + "command.updated", + "reference.updated", + "plugin.updated", + "websearch.updated", + ) + } +} + +private fun JsonObject.string(key: String): String? = (this[key] as? JsonPrimitive)?.takeIf { it.isString }?.content + +private fun JsonObject.objectOrNull(key: String): JsonObject? = this[key] as? JsonObject + +/** A string field that every mapped event needs; a missing one fails the mapping and becomes Unknown. */ +private fun JsonObject.required(key: String): String = + string(key) ?: throw IllegalArgumentException("V2 event is missing string field '$key'") + +private fun JsonObject.ordinal(): Int = (this["ordinal"] as? JsonPrimitive)?.intOrNull ?: 0 + +private fun JsonObject.stringList(key: String): List = + (this[key] as? JsonArray) + ?.mapNotNull { (it as? JsonPrimitive)?.takeIf { primitive -> primitive.isString }?.content } + .orEmpty() + +/** + * The error name the app should see for a V2 error: a cancelled run becomes the V1 abort name, so it + * is treated as a decision rather than a failure everywhere the name is checked. + */ +private fun mappedErrorName(error: JsonObject?): String? { + val type = error?.string("type") ?: return null + return if (type == V2_ABORTED_ERROR_TYPE) ABORT_ERROR_NAME else type +} + +private const val ABORT_ERROR_NAME = "MessageAbortedError" +private const val V2_ABORTED_ERROR_TYPE = "aborted" + +/** Joins the text items of a tool result; other content kinds carry nothing the chat renders. */ +private fun joinedText(content: JsonElement?): String? = + (content as? JsonArray) + ?.mapNotNull { item -> (item as? JsonObject)?.takeIf { it.string("type") == "text" }?.string("text") } + ?.joinToString("") diff --git a/app/src/main/java/com/yugahashimoto/andcode/core/api/OpenCodeV2Shared.kt b/app/src/main/java/com/yugahashimoto/andcode/core/api/OpenCodeV2Shared.kt new file mode 100644 index 000000000..748523a0a --- /dev/null +++ b/app/src/main/java/com/yugahashimoto/andcode/core/api/OpenCodeV2Shared.kt @@ -0,0 +1,100 @@ +package com.yugahashimoto.andcode.core.api + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Part ids shared by the V2 REST mapper and the V2 event translator. The translator and the + * message loader must agree on these, otherwise a streamed part and the persisted copy of the same + * part would be two different rows in the chat. + */ +object OpenCodeV2Ids { + fun textPartId( + messageId: String, + ordinal: Int, + ) = "$messageId-text-$ordinal" + + fun reasoningPartId( + messageId: String, + ordinal: Int, + ) = "$messageId-reasoning-$ordinal" +} + +/** A selectable value of a V2 form field (`Form.Info` option). */ +@Serializable +data class V2FormOption( + val value: String, + val label: String? = null, + val description: String? = null, +) + +/** One question inside a V2 form. [type] is the raw V2 field type: string, multiselect, boolean, number, integer. */ +@Serializable +data class V2FormField( + val key: String, + val type: String, + val title: String? = null, + val description: String? = null, + val options: List = emptyList(), + val custom: Boolean? = null, + val placeholder: String? = null, + val required: Boolean? = null, +) + +/** A V2 form: the replacement for V1 question requests. */ +@Serializable +data class V2Form( + val id: String, + @SerialName("sessionID") val sessionId: String, + val title: String? = null, + val fields: List = emptyList(), +) + +/** + * Maps a V2 form onto the app's existing [QuestionRequest], so the question UI works unchanged. + * Each field becomes one prompt; the prompt text prefers the field's description, since that is + * the sentence a user reads, and falls back to the title or key. + */ +fun V2Form.toQuestionRequest(directory: String?): QuestionRequest = + QuestionRequest( + id = id, + sessionId = sessionId, + questions = + fields.map { field -> + QuestionPrompt( + question = field.description ?: field.title ?: field.key, + header = field.title, + options = + field.options.map { option -> + QuestionOption( + label = option.label ?: option.value, + description = option.description, + ) + }, + placeholder = field.placeholder, + multiple = field.type == "multiselect", + custom = field.custom ?: field.options.isEmpty(), + ) + }, + directory = directory, + ) + +/** + * Remembers forms seen on the event stream or from `GET api/form`, so an answer can be posted + * without a second lookup. The translator writes here from the SSE thread while the UI thread + * reads, hence the lock. + */ +class OpenCodeV2FormRegistry { + private val lock = Any() + private val forms = HashMap() + + fun remember(form: V2Form) { + synchronized(lock) { forms[form.id] = form } + } + + fun get(id: String): V2Form? = synchronized(lock) { forms[id] } + + fun forget(id: String) { + synchronized(lock) { forms.remove(id) } + } +} diff --git a/app/src/main/java/com/yugahashimoto/andcode/core/api/ProtocolDetectingOpenCodeApi.kt b/app/src/main/java/com/yugahashimoto/andcode/core/api/ProtocolDetectingOpenCodeApi.kt new file mode 100644 index 000000000..ab0671bb6 --- /dev/null +++ b/app/src/main/java/com/yugahashimoto/andcode/core/api/ProtocolDetectingOpenCodeApi.kt @@ -0,0 +1,376 @@ +package com.yugahashimoto.andcode.core.api + +import com.yugahashimoto.andcode.core.security.OpenCodeUrl +import com.yugahashimoto.andcode.data.connection.ConnectionProfile +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import okhttp3.Credentials +import okhttp3.HttpUrl +import okhttp3.OkHttpClient +import okhttp3.Request + +/** Which HTTP API a server speaks. V2 servers answer under the `api/` prefix; V1 servers answer the legacy routes. */ +enum class OpenCodeProtocol { V1, V2 } + +/** + * Routes every [OpenCodeApi] call to the V1 or V2 client, detected from the server itself. + * + * Detection happens in [health], which always re-probes so that connecting to a server that was + * upgraded (or replaced) picks up the new protocol. Other calls reuse the last answer and probe only + * when nothing is known yet. A probe that fails at the transport level is rethrown and not cached, + * so a flaky network cannot pin the wrong protocol for the rest of the session. + */ +class ProtocolDetectingOpenCodeApi( + private val profile: ConnectionProfile, + private val httpClient: OkHttpClient = OpenCodeApiClient.defaultHttpClient(profile), + private val json: Json = OpenCodeApiClient.defaultJson, +) : OpenCodeApi { + // Resolved lazily so that constructing the detector never throws, matching the V1 client. + private val baseUrl: HttpUrl by lazy { OpenCodeUrl.normalize(profile.baseUrl).getOrThrow() } + + private val v1: OpenCodeApiClient = OpenCodeApiClient(profile, httpClient, json) + private val v2: OpenCodeV2ApiClient = OpenCodeV2ApiClient(profile, httpClient, json) + + private val detectedState = MutableStateFlow(null) + + /** Serialises probes so a slower, older probe cannot overwrite the result of a newer one. */ + private val probeLock = Mutex() + + /** The protocol the last probe found, or null before the first probe. Read-only for capability gating. */ + val protocol: OpenCodeProtocol? + get() = detectedState.value + + /** Emits the protocol whenever a probe changes it, so event streams can follow an upgrade. */ + private val protocolFlow: StateFlow = detectedState.asStateFlow() + + override suspend fun health(): OpenCodeHealth { + // Health always re-probes: connecting is the moment a server upgrade should be noticed. + return when (val probe = probeAndRemember()) { + is Probe.V2 -> OpenCodeHealth(healthy = true, version = probe.version) + // The V2 auth failure is reported as a 401, the same as the V1 client reports one. + Probe.V2Unauthorized -> throw OpenCodeApiException(statusCode = 401, message = "OpenCode request failed (HTTP 401)") + // The V1 verdict already came from `global/health`; its body is the health, so it is not fetched twice. + is Probe.Legacy -> probe.health + } + } + + override suspend fun sessions(directory: String?): List = current().sessions(directory) + + override suspend fun session(sessionId: String): OpenCodeSession = current().session(sessionId) + + override suspend fun createSession( + title: String?, + directory: String?, + ): OpenCodeSession = current().createSession(title, directory) + + override suspend fun messages(sessionId: String): List = current().messages(sessionId) + + override suspend fun deleteMessage( + sessionId: String, + messageId: String, + ): Boolean = current().deleteMessage(sessionId, messageId) + + override suspend fun providers(): ProviderCatalog = current().providers() + + override suspend fun agents(): List = current().agents() + + override suspend fun providerAuthMethods(): Map> = current().providerAuthMethods() + + override suspend fun authorizeProvider( + providerId: String, + methodIndex: Int, + inputs: Map, + ): ProviderAuthAuthorization = current().authorizeProvider(providerId, methodIndex, inputs) + + override suspend fun setProviderApiKey( + providerId: String, + apiKey: String, + metadata: Map, + ): Boolean = current().setProviderApiKey(providerId, apiKey, metadata) + + override suspend fun removeProviderAuth(providerId: String): Boolean = current().removeProviderAuth(providerId) + + override suspend fun completeProviderOAuth( + providerId: String, + methodIndex: Int, + code: String?, + ): Boolean = current().completeProviderOAuth(providerId, methodIndex, code) + + override suspend fun projects(directory: String?): List = current().projects(directory) + + override suspend fun currentProject(directory: String?): OpenCodeProject = current().currentProject(directory) + + override suspend fun pathInfo(directory: String?): OpenCodePathInfo = current().pathInfo(directory) + + override suspend fun files( + directory: String, + path: String, + ): List = current().files(directory, path) + + override suspend fun fileContent( + directory: String, + path: String, + ): OpenCodeFileContent = current().fileContent(directory, path) + + override suspend fun fileStatus(directory: String): List = current().fileStatus(directory) + + override suspend fun searchText( + directory: String, + pattern: String, + ): List = current().searchText(directory, pattern) + + override suspend fun findFiles( + directory: String, + queryText: String, + includeDirectories: Boolean?, + type: String?, + limit: Int?, + ): List = current().findFiles(directory, queryText, includeDirectories, type, limit) + + override suspend fun vcsInfo(directory: String): OpenCodeVcsInfo = current().vcsInfo(directory) + + override suspend fun vcsStatus(directory: String): List = current().vcsStatus(directory) + + override suspend fun vcsDiff( + directory: String, + mode: String, + context: Int?, + ): List = current().vcsDiff(directory, mode, context) + + override suspend fun sessionDiff( + sessionId: String, + directory: String?, + messageId: String?, + ): List = current().sessionDiff(sessionId, directory, messageId) + + override suspend fun sessionTodo( + sessionId: String, + directory: String?, + ): List = current().sessionTodo(sessionId, directory) + + override suspend fun promptAsync( + sessionId: String, + request: PromptRequest, + ) = current().promptAsync(sessionId, request) + + override suspend fun summarizeSession( + sessionId: String, + providerId: String, + modelId: String, + ): Boolean = current().summarizeSession(sessionId, providerId, modelId) + + override suspend fun abortSession(sessionId: String): Boolean = current().abortSession(sessionId) + + override suspend fun renameSession( + sessionId: String, + title: String, + directory: String?, + ): OpenCodeSession = current().renameSession(sessionId, title, directory) + + override suspend fun deleteSession( + sessionId: String, + directory: String?, + ): Boolean = current().deleteSession(sessionId, directory) + + override suspend fun archiveSession( + sessionId: String, + directory: String?, + ): OpenCodeSession = current().archiveSession(sessionId, directory) + + override suspend fun mcpServers(): List = current().mcpServers() + + override suspend fun addMcpServer(body: JsonObject): McpServer = current().addMcpServer(body) + + override suspend fun connectMcpServer(name: String): Boolean = current().connectMcpServer(name) + + override suspend fun disconnectMcpServer(name: String): Boolean = current().disconnectMcpServer(name) + + override suspend fun removeMcpAuth(name: String): McpAuthRemoval = current().removeMcpAuth(name) + + override suspend fun mcpAuth(name: String): McpAuthStart = current().mcpAuth(name) + + override suspend fun mcpAuthCallback( + name: String, + code: String, + ): McpAuthStatus = current().mcpAuthCallback(name, code) + + override suspend fun config(): kotlinx.serialization.json.JsonElement = current().config() + + override suspend fun updateConfig(patch: JsonObject): kotlinx.serialization.json.JsonElement = current().updateConfig(patch) + + override suspend fun configProviders(): List = current().configProviders() + + override suspend fun commands(): List = current().commands() + + override suspend fun skills(): List = current().skills() + + override suspend fun executeCommand( + sessionId: String, + command: String, + arguments: String, + agent: String?, + model: String?, + variant: String?, + ) = current().executeCommand(sessionId, command, arguments, agent, model, variant) + + override suspend fun respondPermission( + sessionId: String, + permissionId: String, + response: String, + remember: Boolean, + ): Boolean = current().respondPermission(sessionId, permissionId, response, remember) + + override suspend fun answerQuestion( + requestId: String, + answers: List>, + directory: String?, + ): Boolean = current().answerQuestion(requestId, answers, directory) + + override suspend fun rejectQuestion( + requestId: String, + directory: String?, + ): Boolean = current().rejectQuestion(requestId, directory) + + override suspend fun pendingQuestions(directory: String?): List = current().pendingQuestions(directory) + + /** + * Event streams follow the protocol: when a probe changes it (a V1 server upgraded to V2, or + * the reverse), the running stream is cancelled and restarted on the new client. Without this a + * V1 stream kept retrying `/global/event` forever against a server that no longer speaks V1. + */ + override fun events(): Flow = + flow { + if (detectedState.value == null) probeAndRemember() + emitAll( + protocolFlow.filterNotNull().distinctUntilChanged().flatMapLatest { protocol -> + api(protocol).events() + }, + ) + } + + /** Uses the known protocol, or probes once when none is known yet. */ + private suspend fun current(): OpenCodeApi { + val known = detectedState.value ?: probeAndRemember().protocolOrV1() + return api(known) + } + + private fun api(protocol: OpenCodeProtocol): OpenCodeApi = + when (protocol) { + OpenCodeProtocol.V1 -> v1 + OpenCodeProtocol.V2 -> v2 + } + + /** + * Probes under [probeLock] and publishes the result. Only a conclusive answer is published; a + * transient failure (network, 5xx, 408, 429) is thrown and leaves the previous answer intact. + */ + private suspend fun probeAndRemember(): Probe = + probeLock.withLock { + val probe = probe() + detectedState.value = probe.protocolOrV1() + probe + } + + private fun Probe.protocolOrV1(): OpenCodeProtocol = + when (this) { + is Probe.V2, Probe.V2Unauthorized -> OpenCodeProtocol.V2 + is Probe.Legacy -> OpenCodeProtocol.V1 + } + + /** + * Decides the protocol. A JSON body with a string `version` on `GET api/info` is the V2 + * signature, and so is a 401 whose body carries the V2 `UnauthorizedError` tag: the server is + * V2 and only the credentials are wrong. + * + * V1 servers answer `api/info` with the single-page-app HTML, which is not conclusive by itself. + * V1 is only confirmed when `global/health` succeeds, so an outage or proxy error cannot be + * mistaken for a V1 server. Transient statuses (5xx, 408, 429) are thrown and not cached. + */ + private suspend fun probe(): Probe = + withContext(Dispatchers.IO) { + val info = fetchStatus("api/info") + when { + info.code in 200..299 -> versionOf(info.body)?.let { Probe.V2(it) } ?: confirmLegacy() + info.code == 401 && isUnauthorizedTag(info.body) -> Probe.V2Unauthorized + info.code == 401 || info.code == 403 || info.code == 404 || info.code in 300..399 -> confirmLegacy() + else -> throw OpenCodeApiException(statusCode = info.code, message = "OpenCode probe failed (HTTP ${info.code})") + } + } + + /** + * Confirms a V1 server with `global/health`; any non-success is not a V1 verdict. The body is + * kept so that [health] can return it without a second request. + */ + private fun confirmLegacy(): Probe { + val response = fetchStatus("global/health") + if (response.code !in 200..299) { + throw OpenCodeApiException(statusCode = response.code, message = "OpenCode probe failed (HTTP ${response.code})") + } + val health = + runCatching { json.decodeFromString(response.body) } + .getOrElse { throw OpenCodeApiException(statusCode = 200, message = "OpenCode V1 health response was not JSON") } + return Probe.Legacy(health) + } + + private fun fetchStatus(path: String): ProbeResponse { + val url = baseUrl.resolve(path) ?: throw IllegalArgumentException("Invalid OpenCode API path") + val request = + Request.Builder() + .url(url) + .header("Accept", "application/json") + .apply { + profile.password?.takeIf { it.isNotBlank() }?.let { password -> + header("Authorization", Credentials.basic(OpenCodeV2ApiClient.USERNAME, password)) + } + } + .build() + httpClient.newCall(request).execute().use { response -> + return ProbeResponse(response.code, response.body?.string().orEmpty()) + } + } + + private class ProbeResponse(val code: Int, val body: String) + + private fun versionOf(body: String): String? = + runCatching { + (probeJson.parseToJsonElement(body) as? JsonObject) + ?.get("version") + ?.let { (it as? JsonPrimitive)?.takeIf { primitive -> primitive.isString }?.content } + }.getOrNull() + + private fun isUnauthorizedTag(body: String): Boolean = + runCatching { + (probeJson.parseToJsonElement(body) as? JsonObject)?.get("_tag")?.let { tag -> + (tag as? JsonPrimitive)?.content == UNAUTHORIZED_TAG + } == true + }.getOrDefault(false) + + private sealed interface Probe { + data class V2(val version: String) : Probe + + data object V2Unauthorized : Probe + + data class Legacy(val health: OpenCodeHealth) : Probe + } + + private companion object { + const val UNAUTHORIZED_TAG = "UnauthorizedError" + + /** Strict parsing, so an HTML page is never accepted as JSON by the lenient default. */ + val probeJson: Json = Json { ignoreUnknownKeys = true } + } +} diff --git a/app/src/main/java/com/yugahashimoto/andcode/feature/settings/ProviderSettingsScreen.kt b/app/src/main/java/com/yugahashimoto/andcode/feature/settings/ProviderSettingsScreen.kt index 9b2ae68af..bdd46db89 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/feature/settings/ProviderSettingsScreen.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/feature/settings/ProviderSettingsScreen.kt @@ -114,11 +114,13 @@ fun ProviderSettingsScreen( shape = RoundedCornerShape(14.dp), ) - OutlinedButton( - onClick = onOpenAddCustomProvider, - modifier = Modifier.fillMaxWidth(), - ) { - Text(stringResource(R.string.provider_add_custom)) + if (state.customProvidersEditable) { + OutlinedButton( + onClick = onOpenAddCustomProvider, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.provider_add_custom)) + } } val filtered = diff --git a/app/src/main/java/com/yugahashimoto/andcode/feature/settings/ServerInfoScreen.kt b/app/src/main/java/com/yugahashimoto/andcode/feature/settings/ServerInfoScreen.kt index 44908ebdd..2c4f5d0c7 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/feature/settings/ServerInfoScreen.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/feature/settings/ServerInfoScreen.kt @@ -186,7 +186,7 @@ private fun ConfigTab( Icon(Icons.Default.Check, contentDescription = stringResource(R.string.save)) } } - } else { + } else if (state.configEditable) { IconButton(onClick = viewModel::startEditConfig) { Icon(Icons.Default.Edit, contentDescription = stringResource(R.string.server_info_edit_config)) } diff --git a/app/src/main/java/com/yugahashimoto/andcode/feature/settings/ServerInfoViewModel.kt b/app/src/main/java/com/yugahashimoto/andcode/feature/settings/ServerInfoViewModel.kt index 3b8b836f7..9dc1d62cf 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/feature/settings/ServerInfoViewModel.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/feature/settings/ServerInfoViewModel.kt @@ -24,6 +24,8 @@ data class ServerInfoUiState( val configEditDraft: String? = null, val isSaving: Boolean = false, val saveSuccess: Boolean = false, + /** False when the runtime cannot take config writes (OpenCode V2); the editor is then not offered. */ + val configEditable: Boolean = true, ) class ServerInfoViewModel( @@ -43,8 +45,9 @@ class ServerInfoViewModel( fun refresh() { val backend = registry.selected.value ?: return + val editable = runCatching { registry.selected.value?.capabilities?.configEditing }.getOrNull() ?: false viewModelScope.launch { - _state.update { it.copy(isLoading = true, error = null) } + _state.update { it.copy(isLoading = true, error = null, configEditable = editable) } runCatching { val config = backend.config() val providers = runCatching { backend.configProviders() }.getOrDefault(emptyList()) @@ -69,6 +72,7 @@ class ServerInfoViewModel( } fun startEditConfig() { + if (!_state.value.configEditable) return _state.update { it.copy(configEditDraft = it.configJson) } } diff --git a/app/src/main/java/com/yugahashimoto/andcode/feature/settings/SettingsViewModel.kt b/app/src/main/java/com/yugahashimoto/andcode/feature/settings/SettingsViewModel.kt index 6b8e6121b..9a09d11e6 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/feature/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/feature/settings/SettingsViewModel.kt @@ -86,6 +86,11 @@ data class SettingsUiState( val providerAuthNotice: ProviderAuthNotice? = null, val customProviderIds: Set = emptySet(), val customProviderDialog: CustomProviderDialogState? = null, + /** + * False when the OpenCode runtime cannot take custom provider definitions over the API (V2). + * The add button is hidden rather than offering a write that the server would reject. + */ + val customProvidersEditable: Boolean = true, val favoriteModelKeys: Set = emptySet(), val recentModelKeys: List = emptyList(), val hiddenModelKeys: Set = emptySet(), @@ -239,6 +244,7 @@ class SettingsViewModel( providerAuthNotice = oauth.notice, customProviderIds = customProviders.definitions().map { it.id }.toSet(), customProviderDialog = customProviderDialog, + customProvidersEditable = providerTarget()?.capabilities?.configEditing ?: true, favoriteModelKeys = core.preferences.favoriteModelKeys, recentModelKeys = core.preferences.recentModelKeys, hiddenModelKeys = core.preferences.hiddenModelKeys, @@ -605,6 +611,8 @@ class SettingsViewModel( /** Opens the draft dialog for registering an OpenAI-compatible provider OpenCode's catalogue doesn't offer. */ fun openAddCustomProvider() { + // Guarded here as well as in the UI, so no other entry point can open a dialog that cannot save. + if (providerTarget()?.capabilities?.configEditing == false) return customProviderDialogState.value = CustomProviderDialogState() } diff --git a/app/src/main/java/com/yugahashimoto/andcode/feature/workspace/WorkspaceViewModel.kt b/app/src/main/java/com/yugahashimoto/andcode/feature/workspace/WorkspaceViewModel.kt index d9d724488..2413adf94 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/feature/workspace/WorkspaceViewModel.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/feature/workspace/WorkspaceViewModel.kt @@ -2,8 +2,8 @@ package com.yugahashimoto.andcode.feature.workspace import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.yugahashimoto.andcode.core.api.OpenCodeApiClient import com.yugahashimoto.andcode.core.api.OpenCodeHealth +import com.yugahashimoto.andcode.core.api.ProtocolDetectingOpenCodeApi import com.yugahashimoto.andcode.core.storage.DeviceStorage import com.yugahashimoto.andcode.data.connection.ConnectionProfile import com.yugahashimoto.andcode.data.connection.SecureSettingsRepository @@ -330,7 +330,7 @@ class WorkspaceViewModel( if (!form.canSave) { return Result.failure(IllegalArgumentException(incompleteConnectionMessage)) } - return runCatching { OpenCodeApiClient(form.toProfile()).health() } + return runCatching { ProtocolDetectingOpenCodeApi(form.toProfile()).health() } } /** [agents] is the setup guide's selection; every other caller means OpenCode alone. */ diff --git a/app/src/main/java/com/yugahashimoto/andcode/runtime/RuntimeCapabilities.kt b/app/src/main/java/com/yugahashimoto/andcode/runtime/RuntimeCapabilities.kt index 7c494f673..408937cdb 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/runtime/RuntimeCapabilities.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/runtime/RuntimeCapabilities.kt @@ -49,4 +49,10 @@ data class RuntimeCapabilities( * Antigravity has no diff implementation at all, so this stays false there. */ val diffCapable: Boolean = false, + /** + * True when the runtime accepts general configuration writes (custom providers, the raw config + * editor). OpenCode V2 only takes shell settings over the API, so its targets set this false and + * the settings screens hide those entry points rather than letting the write fail. + */ + val configEditing: Boolean = false, ) diff --git a/app/src/main/java/com/yugahashimoto/andcode/runtime/local/LocalOpenCodeBackend.kt b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/LocalOpenCodeBackend.kt index b5b850d3c..b51abd071 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/runtime/local/LocalOpenCodeBackend.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/LocalOpenCodeBackend.kt @@ -15,12 +15,14 @@ import com.yugahashimoto.andcode.core.api.OpenCodeHealth import com.yugahashimoto.andcode.core.api.OpenCodeMessage import com.yugahashimoto.andcode.core.api.OpenCodePathInfo import com.yugahashimoto.andcode.core.api.OpenCodeProject +import com.yugahashimoto.andcode.core.api.OpenCodeProtocol import com.yugahashimoto.andcode.core.api.OpenCodeSearchMatch import com.yugahashimoto.andcode.core.api.OpenCodeSession import com.yugahashimoto.andcode.core.api.OpenCodeSkill import com.yugahashimoto.andcode.core.api.OpenCodeTodo import com.yugahashimoto.andcode.core.api.OpenCodeVcsInfo import com.yugahashimoto.andcode.core.api.PromptRequest +import com.yugahashimoto.andcode.core.api.ProtocolDetectingOpenCodeApi import com.yugahashimoto.andcode.core.api.ProviderAuthAuthorization import com.yugahashimoto.andcode.core.api.ProviderAuthMethod import com.yugahashimoto.andcode.core.api.ProviderCatalog @@ -38,7 +40,7 @@ class LocalOpenCodeBackend( private val portProvider: () -> Int?, private val passwordProvider: () -> String? = { null }, private val backendFactory: (ConnectionProfile) -> RemoteOpenCodeBackend = { profile -> - RemoteOpenCodeBackend(profile) + RemoteOpenCodeBackend(profile, ProtocolDetectingOpenCodeApi(profile)) }, ) : OpenCodeBackend { constructor(runtimeManager: LocalRuntimeManager) : this( @@ -83,6 +85,13 @@ class LocalOpenCodeBackend( cached = null } + /** + * Protocol of the current local server, or null when the runtime is not installed yet or has + * not been probed. Never throws, because capability checks run on the UI path. + */ + val protocol: OpenCodeProtocol? + get() = runCatching { delegate().protocol }.getOrNull() + override suspend fun health(): OpenCodeHealth = delegate().health() override suspend fun listSessions(directory: String?): List = delegate().listSessions(directory) diff --git a/app/src/main/java/com/yugahashimoto/andcode/runtime/local/LocalRuntimeTarget.kt b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/LocalRuntimeTarget.kt index d93ee2fe5..0b66f2e41 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/runtime/local/LocalRuntimeTarget.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/LocalRuntimeTarget.kt @@ -9,6 +9,7 @@ import com.yugahashimoto.andcode.core.api.OpenCodeHealth import com.yugahashimoto.andcode.core.api.OpenCodeMessage import com.yugahashimoto.andcode.core.api.OpenCodePathInfo import com.yugahashimoto.andcode.core.api.OpenCodeProject +import com.yugahashimoto.andcode.core.api.OpenCodeProtocol import com.yugahashimoto.andcode.core.api.OpenCodeSearchMatch import com.yugahashimoto.andcode.core.api.OpenCodeSession import com.yugahashimoto.andcode.core.api.OpenCodeTodo @@ -49,14 +50,22 @@ class LocalRuntimeTarget( override val agent: LocalAgent = LocalAgent.OPEN_CODE override val type: RuntimeType = RuntimeType.LOCAL override val kind: BackendKind = BackendKind.LOCAL - override val capabilities = - RuntimeCapabilities( - permissions = true, - providerModelList = true, - abortsBeforeInterrupt = true, - editMessages = true, - diffCapable = true, - ) + + /** + * The local runtime is pinned to a V1 OpenCode build, so this stays `true` in practice. It still + * reads the detected protocol, so a V2 server behind the local port gets the same gating as a + * remote one. [LocalOpenCodeBackend.protocol] is null until the runtime is installed. + */ + override val capabilities: RuntimeCapabilities + get() = + RuntimeCapabilities( + permissions = true, + providerModelList = true, + abortsBeforeInterrupt = true, + editMessages = backend.protocol != OpenCodeProtocol.V2, + diffCapable = true, + configEditing = backend.protocol != OpenCodeProtocol.V2, + ) private val mutableState = MutableStateFlow(mapStatus(runtimeManager.status())) override val state: StateFlow = mutableState.asStateFlow() diff --git a/app/src/main/java/com/yugahashimoto/andcode/runtime/remote/RemoteOpenCodeBackend.kt b/app/src/main/java/com/yugahashimoto/andcode/runtime/remote/RemoteOpenCodeBackend.kt index 062299d05..b66462a85 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/runtime/remote/RemoteOpenCodeBackend.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/runtime/remote/RemoteOpenCodeBackend.kt @@ -6,7 +6,7 @@ import com.yugahashimoto.andcode.core.api.McpAuthStart import com.yugahashimoto.andcode.core.api.McpAuthStatus import com.yugahashimoto.andcode.core.api.McpServer import com.yugahashimoto.andcode.core.api.OpenCodeAgent -import com.yugahashimoto.andcode.core.api.OpenCodeApiClient +import com.yugahashimoto.andcode.core.api.OpenCodeApi import com.yugahashimoto.andcode.core.api.OpenCodeCommand import com.yugahashimoto.andcode.core.api.OpenCodeEvent import com.yugahashimoto.andcode.core.api.OpenCodeFileChange @@ -16,12 +16,14 @@ import com.yugahashimoto.andcode.core.api.OpenCodeHealth import com.yugahashimoto.andcode.core.api.OpenCodeMessage import com.yugahashimoto.andcode.core.api.OpenCodePathInfo import com.yugahashimoto.andcode.core.api.OpenCodeProject +import com.yugahashimoto.andcode.core.api.OpenCodeProtocol import com.yugahashimoto.andcode.core.api.OpenCodeSearchMatch import com.yugahashimoto.andcode.core.api.OpenCodeSession import com.yugahashimoto.andcode.core.api.OpenCodeSkill import com.yugahashimoto.andcode.core.api.OpenCodeTodo import com.yugahashimoto.andcode.core.api.OpenCodeVcsInfo import com.yugahashimoto.andcode.core.api.PromptRequest +import com.yugahashimoto.andcode.core.api.ProtocolDetectingOpenCodeApi import com.yugahashimoto.andcode.core.api.ProviderAuthAuthorization import com.yugahashimoto.andcode.core.api.ProviderAuthMethod import com.yugahashimoto.andcode.core.api.ProviderCatalog @@ -36,12 +38,19 @@ import kotlinx.serialization.json.JsonObject class RemoteOpenCodeBackend( private val profile: ConnectionProfile, - private val client: OpenCodeApiClient = OpenCodeApiClient(profile), + private val client: OpenCodeApi = ProtocolDetectingOpenCodeApi(profile), ) : OpenCodeBackend { override val id: String = profile.id override val displayName: String = profile.name override val kind: BackendKind = BackendKind.REMOTE + /** + * The wire protocol the server was detected to speak, or null before the first probe. Targets + * read this to gate capabilities; a client that is not auto-detecting reports null. + */ + val protocol: OpenCodeProtocol? + get() = (client as? ProtocolDetectingOpenCodeApi)?.protocol + override suspend fun health(): OpenCodeHealth = client.health() override suspend fun listSessions(directory: String?): List = client.sessions(directory) diff --git a/app/src/main/java/com/yugahashimoto/andcode/runtime/remote/RemoteRuntimeTarget.kt b/app/src/main/java/com/yugahashimoto/andcode/runtime/remote/RemoteRuntimeTarget.kt index 1e11dcda1..920b09c44 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/runtime/remote/RemoteRuntimeTarget.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/runtime/remote/RemoteRuntimeTarget.kt @@ -9,6 +9,7 @@ import com.yugahashimoto.andcode.core.api.OpenCodeHealth import com.yugahashimoto.andcode.core.api.OpenCodeMessage import com.yugahashimoto.andcode.core.api.OpenCodePathInfo import com.yugahashimoto.andcode.core.api.OpenCodeProject +import com.yugahashimoto.andcode.core.api.OpenCodeProtocol import com.yugahashimoto.andcode.core.api.OpenCodeSearchMatch import com.yugahashimoto.andcode.core.api.OpenCodeSession import com.yugahashimoto.andcode.core.api.OpenCodeTodo @@ -42,14 +43,22 @@ class RemoteRuntimeTarget( override val displayName: String = profile.name override val type: RuntimeType = RuntimeType.REMOTE override val kind: BackendKind = BackendKind.REMOTE - override val capabilities = - RuntimeCapabilities( - permissions = true, - providerModelList = true, - abortsBeforeInterrupt = true, - editMessages = true, - diffCapable = true, - ) + + /** + * Read on each access because the protocol is only known after the first probe. V2 has no + * message delete, so the edit action is hidden there; V1 keeps the original set. + */ + override val capabilities: RuntimeCapabilities + get() = + RuntimeCapabilities( + permissions = true, + providerModelList = true, + abortsBeforeInterrupt = true, + editMessages = backend.protocol != OpenCodeProtocol.V2, + diffCapable = true, + // V1 accepts config patches; V2 does not, so its config entry points stay hidden. + configEditing = backend.protocol != OpenCodeProtocol.V2, + ) private val mutableState = MutableStateFlow(RuntimeState.Disconnected) override val state: StateFlow = mutableState.asStateFlow() diff --git a/app/src/test/java/com/yugahashimoto/andcode/core/api/OpenCodeV2ApiClientTest.kt b/app/src/test/java/com/yugahashimoto/andcode/core/api/OpenCodeV2ApiClientTest.kt new file mode 100644 index 000000000..42f8df72e --- /dev/null +++ b/app/src/test/java/com/yugahashimoto/andcode/core/api/OpenCodeV2ApiClientTest.kt @@ -0,0 +1,1136 @@ +package com.yugahashimoto.andcode.core.api + +import com.yugahashimoto.andcode.data.connection.ConnectionProfile +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import okhttp3.Credentials +import okhttp3.OkHttpClient +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Before +import org.junit.Test + +/** + * Pins the V2 REST mapping against response bodies captured from a live OpenCode 2.0.28 server + * (`raw/` samples), so a schema drift shows up here instead of as a silently empty chat. + */ +class OpenCodeV2ApiClientTest { + private lateinit var server: MockWebServer + + @Before + fun setUp() { + server = MockWebServer() + server.start() + } + + @After + fun tearDown() { + server.shutdown() + } + + private fun profile(password: String? = "secret") = + ConnectionProfile( + id = "test", + name = "Test", + baseUrl = server.url("/").toString(), + username = "opencode", + password = password, + allowInsecureLan = true, + ) + + private fun client(password: String? = "secret"): OpenCodeV2ApiClient = OpenCodeV2ApiClient(profile(password), OkHttpClient()) + + private fun json(body: String) = MockResponse().setHeader("Content-Type", "application/json").setBody(body) + + private fun noContent() = MockResponse().setResponseCode(204) + + private fun JsonObject.str(key: String) = this[key]!!.jsonPrimitive.content + + // ---- probe (ProtocolDetectingOpenCodeApi) ---- + + @Test + fun `probe detects V2 from api info JSON and uses the V2 client`() = + runBlocking { + server.enqueue(json(V2_INFO)) + val detector = ProtocolDetectingOpenCodeApi(profile()) + + val health = detector.health() + + assertEquals("2.0.28", health.version) + assertTrue(health.healthy) + assertEquals(OpenCodeProtocol.V2, detector.protocol) + val request = server.takeRequest() + assertEquals("/api/info", request.path) + assertEquals(Credentials.basic("opencode", "secret"), request.getHeader("Authorization")) + } + + @Test + fun `probe treats a V2 401 JSON body as V2 and surfaces a 401`() = + runBlocking { + server.enqueue( + MockResponse() + .setResponseCode(401) + .setHeader("Content-Type", "application/json") + .setBody("""{"_tag":"UnauthorizedError","message":"Authentication required"}"""), + ) + val detector = ProtocolDetectingOpenCodeApi(profile(password = "wrong")) + + try { + detector.health() + fail("expected 401") + } catch (error: OpenCodeApiException) { + assertEquals(401, error.statusCode) + } + assertEquals(OpenCodeProtocol.V2, detector.protocol) + } + + @Test + fun `probe falls back to V1 when api info returns the HTML shell`() = + runBlocking { + server.enqueue(MockResponse().setHeader("Content-Type", "text/html").setBody("spa")) + server.enqueue(json("""{"healthy":true,"version":"1.18.5"}""")) + val detector = ProtocolDetectingOpenCodeApi(profile()) + + val health = detector.health() + + assertEquals("1.18.5", health.version) + assertEquals(OpenCodeProtocol.V1, detector.protocol) + assertEquals("/api/info", server.takeRequest().path) + assertEquals("/global/health", server.takeRequest().path) + } + + @Test + fun `probe does not cache a transport failure`() = + runBlocking { + server.enqueue(MockResponse().setSocketPolicy(okhttp3.mockwebserver.SocketPolicy.DISCONNECT_AT_START)) + server.enqueue(json(V2_INFO)) + val detector = ProtocolDetectingOpenCodeApi(profile()) + + val failed = runCatching { detector.health() }.exceptionOrNull() + + assertTrue(failed is java.io.IOException) + assertNull("a transport failure must not pin a protocol", detector.protocol) + assertEquals("2.0.28", detector.health().version) + assertEquals(OpenCodeProtocol.V2, detector.protocol) + } + + @Test + fun `a 5xx from api info neither decides the protocol nor is cached`() = + runBlocking { + server.enqueue(MockResponse().setResponseCode(503).setBody("upstream")) + server.enqueue(json(V2_INFO)) + val detector = ProtocolDetectingOpenCodeApi(profile()) + + val failed = runCatching { detector.health() }.exceptionOrNull() + + assertTrue(failed is OpenCodeApiException) + assertEquals(503, (failed as OpenCodeApiException).statusCode) + assertNull("a 5xx must not classify the server as V1", detector.protocol) + assertEquals("2.0.28", detector.health().version) + } + + @Test + fun `V1 is only confirmed when global health succeeds`() = + runBlocking { + server.enqueue(MockResponse().setHeader("Content-Type", "text/html").setBody("")) + server.enqueue(MockResponse().setResponseCode(502)) + val detector = ProtocolDetectingOpenCodeApi(profile()) + + val failed = runCatching { detector.health() }.exceptionOrNull() + + assertTrue(failed is OpenCodeApiException) + assertNull("an unhealthy global/health is not a V1 verdict", detector.protocol) + } + + @Test + fun `v1 health is fetched once, using the global health body from the probe`() = + runBlocking { + server.enqueue(MockResponse().setHeader("Content-Type", "text/html").setBody("")) + server.enqueue(json("""{"healthy":true,"version":"1.18.5"}""")) + val detector = ProtocolDetectingOpenCodeApi(profile()) + + assertEquals("1.18.5", detector.health().version) + + assertEquals(2, server.requestCount) + } + + // ---- sessions ---- + + @Test + fun `lists sessions across pages with directory query and maps location and parent`() = + runBlocking { + server.enqueue(json(SESSION_PAGE_1)) + server.enqueue(json(SESSION_PAGE_2)) + val client = client() + + val sessions = client.sessions("/repo") + + assertEquals(listOf("ses_child", "ses_root"), sessions.map { it.id }) + val child = sessions.first { it.id == "ses_child" } + assertEquals("ses_root", child.parentId) + assertEquals("/repo", child.directory) + assertEquals("global", child.projectId) + assertEquals(1792059546296L, child.time.created) + + val first = server.takeRequest() + assertEquals("/api/session?directory=%2Frepo&order=desc&limit=200", first.path) + val second = server.takeRequest() + assertTrue(second.path!!.contains("cursor=CURSOR_PAGE_2")) + // The live server answers 400 InvalidCursorError when a cursor page repeats `order`. + assertFalse(second.path!!.contains("order=")) + } + + @Test + fun `session list hides sessions archived through metadata`() = + runBlocking { + server.enqueue(json(SESSION_ARCHIVED)) + val client = client() + + val sessions = client.sessions() + + assertEquals(listOf("ses_active"), sessions.map { it.id }) + } + + @Test + fun `archive writes metadata and keeps existing entries`() = + runBlocking { + server.enqueue(json(SESSION_WITH_METADATA)) + server.enqueue(noContent()) + server.enqueue(json(SESSION_ONE_ARCHIVED)) + val client = client() + + val archived = client.archiveSession("ses_one") + + assertNotNull(archived.time.archived) + server.takeRequest() + val patch = server.takeRequest() + assertEquals("PATCH", patch.method) + val body = Json.parseToJsonElement(patch.body.readUtf8()).jsonObject + val metadata = body["metadata"]!!.jsonObject + assertEquals("keep", metadata["existing"]!!.jsonPrimitive.content) + assertTrue(metadata[ARCHIVED_METADATA_KEY]!!.jsonPrimitive.content.toLong() > 0) + assertNull(body["time"]) + } + + @Test + fun `create session sends the location directory in the body`() = + runBlocking { + server.enqueue(json(SESSION_ONE)) + val client = client() + + val created = client.createSession(title = "contract-A", directory = "/work") + + assertEquals("ses_one", created.id) + val request = server.takeRequest() + assertEquals("POST", request.method) + assertEquals("/api/session", request.path) + val body = Json.parseToJsonElement(request.body.readUtf8()).jsonObject + assertEquals("contract-A", body.str("title")) + assertEquals("/work", body["location"]!!.jsonObject.str("directory")) + } + + @Test + fun `session by id unwraps data`() = + runBlocking { + server.enqueue(json(SESSION_ONE)) + + val session = client().session("ses_one") + + assertEquals("contract-A", session.title) + assertEquals("/work", session.directory) + assertEquals("/api/session/ses_one", server.takeRequest().path) + } + + @Test + fun `rename patches title then reads the session back`() = + runBlocking { + server.enqueue(noContent()) + server.enqueue(json(SESSION_ONE)) + + client().renameSession("ses_one", "Renamed") + + val patch = server.takeRequest() + assertEquals("PATCH", patch.method) + assertEquals("Renamed", Json.parseToJsonElement(patch.body.readUtf8()).jsonObject.str("title")) + assertEquals("GET", server.takeRequest().method) + } + + @Test + fun `delete session returns true on 204`() = + runBlocking { + server.enqueue(noContent()) + + assertTrue(client().deleteSession("ses_one")) + assertEquals("DELETE", server.takeRequest().method) + } + + // ---- messages ---- + + @Test + fun `maps user with file, assistant reasoning text and tool, idle failed and interrupted, shell`() = + runBlocking { + // The fixture is written chronologically; V2 serves newest-first, so reverse the rows on the wire. + val newestFirst = + Json.parseToJsonElement(MESSAGES_MIXED).jsonObject["data"]!!.jsonArray.reversed() + server.enqueue(json(buildJsonObject { put("data", JsonArray(newestFirst)) }.toString())) + val messages = client().messages("ses_one") + + // Skipped: agentSelected and succeeded-idle rows. + assertEquals(listOf("msg_user", "msg_assistant", "msg_failed", "msg_interrupted", "msg_shell"), messages.map { it.info.id }) + + val user = messages[0] + assertEquals("user", user.info.role) + assertEquals(OpenCodeV2Ids.textPartId("msg_user", 0), user.parts[0].id) + assertEquals("hello", user.text) + val file = user.parts[1] + assertEquals("file", file.type) + assertEquals("data:image/png;base64,AAAA", file.url) + assertEquals("red.png", file.filename) + + val assistant = messages[1] + assertEquals("assistant", assistant.info.role) + assertEquals("build", assistant.info.agent) + assertEquals("cat-jam-free", assistant.info.model!!.modelId) + assertEquals("opencode", assistant.info.model!!.providerId) + assertEquals(OpenCodeV2Ids.reasoningPartId("msg_assistant", 0), assistant.parts[0].id) + assertEquals("reasoning", assistant.parts[0].type) + assertEquals(OpenCodeV2Ids.textPartId("msg_assistant", 0), assistant.parts[1].id) + assertEquals("DONE", assistant.parts[1].text) + val tool = assistant.parts[2] + assertEquals("tool", tool.type) + assertEquals("shell", tool.tool) + assertEquals("call-1", tool.callID) + assertEquals("completed", tool.state!!["status"]!!.jsonPrimitive.content) + assertEquals("README.md\n", tool.state!!["output"]!!.jsonPrimitive.content) + assertEquals(38, assistant.info.tokens!!.input.toInt()) + + val failed = messages[2].info + assertEquals("ApiError", failed.error!!.name) + assertEquals("upstream down", failed.error!!.message) + assertTrue(messages[2].parts.isEmpty()) + + assertEquals("MessageAbortedError", messages[3].info.error!!.name) + assertTrue(messages[3].info.error!!.isAbort) + + val shell = messages[4] + assertEquals("shell", shell.parts.single().tool) + assertEquals("pwd", shell.parts.single().state!!["input"]!!.jsonObject.str("command")) + assertEquals("/work\n", shell.parts.single().state!!["output"]!!.jsonPrimitive.content) + } + + @Test + fun `streaming tool state is reported as pending`() = + runBlocking { + server.enqueue(json(MESSAGE_STREAMING_TOOL)) + + val tool = client().messages("ses_one").single().parts.single() + + assertEquals("pending", tool.state!!["status"]!!.jsonPrimitive.content) + } + + @Test + fun `messages follows the cursor newest first and returns chronological order`() = + runBlocking { + server.enqueue(json(MESSAGE_PAGE_1)) + server.enqueue(json(MESSAGE_PAGE_2)) + + val messages = client().messages("ses_one") + + assertEquals(listOf("msg_a", "msg_b"), messages.map { it.info.id }) + val first = server.takeRequest() + assertTrue(first.path!!.contains("order=desc")) + val second = server.takeRequest().path!! + assertTrue(second.contains("cursor=MSG_CURSOR")) + assertFalse(second.contains("order=")) + } + + @Test + fun `a repeated cursor stops the walk instead of looping`() = + runBlocking { + // The server keeps returning the same cursor; the walk must stop after the repeat. + server.enqueue(json("""{"data":[{"id":"msg_x","time":{"created":1},"type":"user","text":"x"}],"cursor":{"next":"LOOP"}}""")) + server.enqueue(json("""{"data":[{"id":"msg_y","time":{"created":2},"type":"user","text":"y"}],"cursor":{"next":"LOOP"}}""")) + + val messages = client().messages("ses_one") + + assertEquals(2, server.requestCount) + // Page 1 (msg_x) is the newer record, so chronological order is msg_y then msg_x. + assertEquals(listOf("msg_y", "msg_x"), messages.map { it.info.id }) + } + + @Test + fun `when the page cap is reached the newest records are kept`() = + runBlocking { + // Every page advances the cursor, so only the cap ends the walk. The walk is newest-first, so + // page 0 (msg_0) is the newest record; it must survive the cap and be last in chronological order. + val pages = 100 + repeat(pages) { index -> + server.enqueue( + json( + """{"data":[{"id":"msg_$index","time":{"created":$index},"type":"user","text":"t"}],""" + + """"cursor":{"next":"C$index"}}""", + ), + ) + } + + val messages = client().messages("ses_one") + + assertEquals(pages, server.requestCount) + assertEquals("msg_0", messages.last().info.id) + assertEquals("msg_${pages - 1}", messages.first().info.id) + } + + @Test + fun `delete message is unsupported on V2`() { + val failure = runCatching { runBlocking { client().deleteMessage("ses_one", "msg_a") } }.exceptionOrNull() + + assertTrue(failure is UnsupportedOperationException) + } + + // ---- prompt flow ---- + + @Test + fun `every prompt re-applies agent and model so an external switch cannot be skipped`() = + runBlocking { + // Each prompt sends agent, model, then prompt, even when the selection matches the previous one. + repeat(2) { + server.enqueue(noContent()) // agent + server.enqueue(noContent()) // model + server.enqueue(json(PROMPT_ACCEPTED)) // prompt + } + val client = client() + + client.promptAsync("ses_one", PromptRequest(text = "hi", providerId = "opencode", modelId = "cat-jam-free", agent = "build")) + client.promptAsync("ses_one", PromptRequest(text = "again", providerId = "opencode", modelId = "cat-jam-free", agent = "build")) + + assertEquals("/api/session/ses_one/agent", server.takeRequest().path) + val model = server.takeRequest() + assertEquals("/api/session/ses_one/model", model.path) + val modelBody = Json.parseToJsonElement(model.body.readUtf8()).jsonObject["model"]!!.jsonObject + assertEquals("cat-jam-free", modelBody.str("id")) + assertEquals("opencode", modelBody.str("providerID")) + + val prompt = server.takeRequest() + assertEquals("/api/session/ses_one/prompt", prompt.path) + val promptBody = Json.parseToJsonElement(prompt.body.readUtf8()).jsonObject + assertEquals("hi", promptBody.str("text")) + assertTrue(promptBody["resume"]!!.jsonPrimitive.content.toBoolean()) + assertNull(promptBody["model"]) + assertNull(promptBody["agent"]) + + // The second prompt applies agent and model again, because another client could have changed them. + assertEquals("/api/session/ses_one/agent", server.takeRequest().path) + assertEquals("/api/session/ses_one/model", server.takeRequest().path) + assertEquals("/api/session/ses_one/prompt", server.takeRequest().path) + } + + @Test + fun `prompt sends attachments as files with mention and noReply as resume false`() = + runBlocking { + server.enqueue(json(PROMPT_ACCEPTED)) + val client = client() + + client.promptAsync( + "ses_one", + PromptRequest( + text = "see", + attachments = listOf(PromptAttachment(filename = "x.png", mime = "image/png", url = "file:///a/x.png")), + noReply = true, + ), + ) + + val body = Json.parseToJsonElement(server.takeRequest().body.readUtf8()).jsonObject + val file = body["files"]!!.jsonArray.single().jsonObject + assertEquals("file:///a/x.png", file.str("uri")) + assertEquals("x.png", file.str("name")) + assertEquals("@x.png", file.getValue("mention").jsonObject.str("text")) + assertEquals(false, body["resume"]!!.jsonPrimitive.content.toBoolean()) + } + + @Test + fun `abort posts interrupt and reads interrupted flag`() = + runBlocking { + server.enqueue(json("""{"interrupted":true}""")) + + assertTrue(client().abortSession("ses_one")) + assertEquals("/api/session/ses_one/interrupt", server.takeRequest().path) + } + + @Test + fun `concurrent prompts on one session never interleave agent model and prompt`() = + runBlocking { + // Two prompts race: the lock must keep each prompt's agent, model and prompt calls together. + repeat(2) { + server.enqueue(noContent()) + server.enqueue(noContent()) + server.enqueue(json(PROMPT_ACCEPTED)) + } + val client = client() + val request = PromptRequest(text = "x", providerId = "opencode", modelId = "m", agent = "build") + + coroutineScope { + awaitAll( + async { client.promptAsync("ses_one", request) }, + async { client.promptAsync("ses_one", request.copy(text = "y")) }, + ) + } + + // Two prompts make six requests: each block of three is one prompt, with no other call between. + val paths = List(6) { server.takeRequest().path } + paths.chunked(3).forEach { block -> + assertEquals( + listOf("/api/session/ses_one/agent", "/api/session/ses_one/model", "/api/session/ses_one/prompt"), + block, + ) + } + } + + @Test + fun `provider catalog includes authenticable integrations without models and marks connections`() = + runBlocking { + // Request order inside providers(): provider, model, integration, then the default model. + server.enqueue(json(PROVIDER_LIST)) + server.enqueue(json(MODEL_LIST)) + server.enqueue(json(INTEGRATION_LIST)) + server.enqueue(json(MODEL_DEFAULT)) + + val catalog = client().providers() + + val openai = catalog.all.single { it.id == "302ai" } + assertEquals("302.AI", openai.name) + assertTrue("an integration with no models must not gain any", openai.models.isEmpty()) + // 302ai has no connections, so it is listed for credentials but is not connected to chat. + assertTrue(catalog.all.any { it.id == "302ai" }) + assertTrue("302ai" !in catalog.connected) + } + + @Test + fun `key connect sends the dialog prompt inputs as the form answer`() = + runBlocking { + server.enqueue(json(INTEGRATION_WITH_FORM)) + server.enqueue(noContent()) + val client = client() + client.providerAuthMethods() + server.takeRequest() + + client.setProviderApiKey("custom", "sk-x", mapOf("baseUrl" to "https://api.example.test")) + + val body = Json.parseToJsonElement(server.takeRequest().body.readUtf8()).jsonObject + assertEquals("sk-x", body.str("key")) + assertEquals("https://api.example.test", body["answer"]!!.jsonObject.str("baseUrl")) + } + + @Test + fun `integration methods with unrepresentable forms are left out of auth`() = + runBlocking { + server.enqueue(json(INTEGRATION_WITH_UNREPRESENTABLE)) + + val methods = client().providerAuthMethods() + + assertNull("a boolean-field method cannot be shown, so the provider offers no auth", methods["bool-only"]) + } + + @Test + fun `form prompt answer is sent as the option value, not the label`() = + runBlocking { + val registry = OpenCodeV2FormRegistry() + registry.remember( + V2Form( + id = "frm_v", + sessionId = "ses_one", + fields = + listOf( + V2FormField( + key = "q0", + type = "string", + options = listOf(V2FormOption(value = "red-value", label = "Red")), + ), + ), + ), + ) + server.enqueue(noContent()) + val client = OpenCodeV2ApiClient(profile(), OkHttpClient(), formRegistry = registry) + + client.answerQuestion("frm_v", listOf(listOf("Red")), null) + + // The form is cached, so the answer is the only request. + val answer = Json.parseToJsonElement(server.takeRequest().body.readUtf8()).jsonObject["answer"]!!.jsonObject + assertEquals("red-value", answer["q0"]!!.jsonPrimitive.content) + } + + @Test + fun `custom free text passes through an answer unchanged`() = + runBlocking { + val registry = OpenCodeV2FormRegistry() + registry.remember( + V2Form( + id = "frm_c", + sessionId = "ses_one", + fields = listOf(V2FormField(key = "q0", type = "string", options = listOf(V2FormOption(value = "a")))), + ), + ) + server.enqueue(noContent()) + val client = OpenCodeV2ApiClient(profile(), OkHttpClient(), formRegistry = registry) + + client.answerQuestion("frm_c", listOf(listOf("something else")), null) + + // The form is cached, so the answer is the only request. + val answer = Json.parseToJsonElement(server.takeRequest().body.readUtf8()).jsonObject["answer"]!!.jsonObject + assertEquals("something else", answer["q0"]!!.jsonPrimitive.content) + } + + @Test + fun `aborted error type maps to the abort name so it is not shown as a failure`() = + runBlocking { + server.enqueue(json(MESSAGE_ABORTED_ERROR)) + + val message = client().messages("ses_one").single() + + assertEquals("MessageAbortedError", message.info.error!!.name) + assertTrue(message.info.error!!.isAbort) + } + + @Test + fun `update config fails with a readable message on V2`() { + val failure = + runCatching { runBlocking { client().updateConfig(JsonObject(emptyMap())) } }.exceptionOrNull() + + assertTrue(failure is UnsupportedOperationException) + assertTrue(failure!!.message!!.contains("shell settings")) + } + + @Test + fun `summarize posts compact`() = + runBlocking { + server.enqueue(json("""{"data":{"id":"msg_c","type":"compaction"}}""")) + + assertTrue(client().summarizeSession("ses_one", "opencode", "cat-jam-free")) + assertEquals("/api/session/ses_one/compact", server.takeRequest().path) + } + + // ---- permissions and forms ---- + + @Test + fun `permission reply posts decision and maps remember once to always`() = + runBlocking { + server.enqueue(noContent()) + server.enqueue(noContent()) + + client().respondPermission("ses_one", "per_1", "once", remember = false) + client().respondPermission("ses_one", "per_2", "once", remember = true) + + val first = server.takeRequest() + assertEquals("/api/session/ses_one/permission/per_1/reply", first.path) + assertEquals("once", Json.parseToJsonElement(first.body.readUtf8()).jsonObject.str("decision")) + assertEquals("always", Json.parseToJsonElement(server.takeRequest().body.readUtf8()).jsonObject.run { str("decision") }) + } + + @Test + fun `answer form posts flat answer map keyed by field`() = + runBlocking { + server.enqueue(json(FORM_LIST)) + server.enqueue(noContent()) + val client = client() + + assertTrue(client.answerQuestion("frm_1", listOf(listOf("Blue")), "/work")) + + val list = server.takeRequest() + assertEquals("/api/form?location%5Bdirectory%5D=%2Fwork", list.path) + val reply = server.takeRequest() + assertEquals("/api/session/ses_one/form/frm_1/reply", reply.path) + val answer = Json.parseToJsonElement(reply.body.readUtf8()).jsonObject["answer"]!!.jsonObject + assertEquals("Blue", answer["q0"]!!.jsonPrimitive.content) + } + + @Test + fun `answer uses cached form and multiselect as an array`() = + runBlocking { + val registry = OpenCodeV2FormRegistry() + registry.remember( + V2Form( + id = "frm_m", + sessionId = "ses_one", + fields = listOf(V2FormField(key = "tags", type = "multiselect", options = listOf(V2FormOption("a")))), + ), + ) + server.enqueue(noContent()) + val client = OpenCodeV2ApiClient(profile(), OkHttpClient(), formRegistry = registry) + + client.answerQuestion("frm_m", listOf(listOf("a", "b")), null) + + val reply = server.takeRequest() + assertEquals("/api/session/ses_one/form/frm_m/reply", reply.path) + val tags = Json.parseToJsonElement(reply.body.readUtf8()).jsonObject["answer"]!!.jsonObject["tags"]!!.jsonArray + assertEquals(listOf("a", "b"), tags.map { it.jsonPrimitive.content }) + } + + @Test + fun `reject form deletes it by session and form id`() = + runBlocking { + server.enqueue(json(FORM_LIST)) + server.enqueue(noContent()) + + assertTrue(client().rejectQuestion("frm_1", "/work")) + + server.takeRequest() + val delete = server.takeRequest() + assertEquals("DELETE", delete.method) + assertEquals("/api/session/ses_one/form/frm_1", delete.path) + } + + @Test + fun `pending questions map forms to question requests`() = + runBlocking { + server.enqueue(json(FORM_LIST)) + + val requests = client().pendingQuestions("/work") + + val request = requests.single() + assertEquals("frm_1", request.id) + assertEquals("ses_one", request.sessionId) + assertEquals("/work", request.directory) + val prompt = request.questions.single() + assertEquals("Which color do you prefer?", prompt.question) + assertEquals("Color preference", prompt.header) + assertEquals(listOf("Red", "Blue"), prompt.options.map { it.label }) + } + + // ---- providers ---- + + @Test + fun `provider catalog groups models by provider and reads the default`() = + runBlocking { + // Request order inside providers(): provider, model, integration, then the default model. + server.enqueue(json(PROVIDER_LIST)) + server.enqueue(json(MODEL_LIST)) + server.enqueue(json(INTEGRATION_LIST)) + server.enqueue(json(MODEL_DEFAULT)) + + val catalog = client().providers() + + val opencode = catalog.all.single { it.id == "opencode" } + assertEquals("OpenCode", opencode.name) + assertEquals(setOf("cat-jam-free", "longcat-2.5-preview-free"), opencode.models.keys) + assertEquals(listOf("low", "medium"), opencode.models.getValue("cat-jam-free").variants.keys.toList().take(2)) + assertEquals(mapOf("opencode" to "cat-jam-free"), catalog.default) + assertEquals(listOf("opencode"), catalog.connected) + assertTrue(catalog.all.none { provider -> provider.models.containsKey("disabled-model") }) + } + + @Test + fun `agents map id to name and keep mode and hidden`() = + runBlocking { + server.enqueue(json(AGENT_LIST)) + + val agents = client().agents() + + assertEquals(listOf("build", "compaction"), agents.map { it.name }) + assertEquals("primary", agents[0].mode) + assertTrue(agents[1].hidden) + } + + @Test + fun `provider auth lists key and oauth methods and sends the key`() = + runBlocking { + server.enqueue(json(INTEGRATION_LIST)) + server.enqueue(noContent()) + val client = client() + + val methods = client.providerAuthMethods() + + assertEquals("API key", methods.getValue("302ai").single().label) + assertEquals("oauth", methods.getValue("opencode").last().type) + assertEquals("/api/integration", server.takeRequest().path) + assertTrue(client.setProviderApiKey("302ai", "sk-test")) + // Key-based connect is on the integration endpoint. + val connect = server.takeRequest() + assertEquals("/api/integration/302ai/connect/key", connect.path) + assertEquals("sk-test", Json.parseToJsonElement(connect.body.readUtf8()).jsonObject.str("key")) + } + + @Test + fun `remove provider auth deletes every credential of that integration`() = + runBlocking { + server.enqueue(json(CREDENTIAL_LIST)) + server.enqueue(noContent()) + server.enqueue(noContent()) + + assertTrue(client().removeProviderAuth("302ai")) + + server.takeRequest() + assertEquals("/api/credential/cred_1", server.takeRequest().path) + assertEquals("/api/credential/cred_2", server.takeRequest().path) + } + + @Test + fun `oauth authorize posts the method id and remembers the attempt for completion`() = + runBlocking { + server.enqueue(json(INTEGRATION_LIST)) + server.enqueue(json(OAUTH_ATTEMPT)) + server.enqueue(noContent()) + val client = client() + client.providerAuthMethods() + assertEquals("/api/integration", server.takeRequest().path) + + val auth = client.authorizeProvider("opencode", methodIndex = 1) + + assertEquals("https://example.test/auth", auth.url) + val authorize = server.takeRequest() + assertEquals("/api/integration/opencode/connect/oauth", authorize.path) + assertEquals("device", Json.parseToJsonElement(authorize.body.readUtf8()).jsonObject.str("methodID")) + + assertTrue(client.completeProviderOAuth("opencode", methodIndex = 1, code = null)) + assertEquals("/api/integration/opencode/connect/oauth/att_1/complete", server.takeRequest().path) + } + + @Test + fun `projects and location map to the V1 shapes`() = + runBlocking { + server.enqueue(json(PROJECT_LIST)) + server.enqueue(json(LOCATION)) + server.enqueue(json(LOCATION)) + val client = client() + + val projects = client.projects() + val current = client.currentProject("/work") + val path = client.pathInfo("/work") + + assertEquals("global", projects.single().id) + assertEquals("/work", current.worktree) + assertEquals("/work", path.directory) + assertEquals("/api/project", server.takeRequest().path) + assertEquals("location%5Bdirectory%5D=%2Fwork", server.takeRequest().path!!.substringAfter("?")) + } + + // ---- files, vcs ---- + + @Test + fun `file content reads the raw body as text`() = + runBlocking { + server.enqueue(MockResponse().setHeader("Content-Type", "text/markdown").setBody("hello\n")) + + val content = client().fileContent("/work", "README.md") + + assertEquals("text", content.type) + assertEquals("hello\n", content.content) + assertEquals("/api/fs/read/README.md?location%5Bdirectory%5D=%2Fwork", server.takeRequest().path) + } + + @Test + fun `file content marks binary bodies as base64`() = + runBlocking { + server.enqueue(MockResponse().setHeader("Content-Type", "image/png").setBody(okio.Buffer().write(byteArrayOf(1, 2, 3)))) + + val content = client().fileContent("/work", "logo.png") + + assertEquals("binary", content.type) + assertEquals("base64", content.encoding) + assertEquals("AQID", content.content) + } + + @Test + fun `file list maps entries to nodes`() = + runBlocking { + server.enqueue(json(FS_LIST)) + + val nodes = client().files("/work", "") + + assertEquals(listOf(".git", "README.md"), nodes.map { it.name }) + assertEquals("directory", nodes[0].type) + assertEquals("/work/README.md", nodes[1].absolute) + } + + @Test + fun `vcs info reads the current branch`() = + runBlocking { + server.enqueue(json(VCS_INFO)) + server.enqueue(json("""{"data":{"name":"main","ref":"origin/main","source":"default"}}""")) + + val info = client().vcsInfo("/work") + + assertEquals("master", info.branch) + assertEquals("main", info.defaultBranch) + } + + @Test + fun `vcs diff maps the V1 git mode to working`() = + runBlocking { + server.enqueue(json(VCS_DIFF)) + + val changes = client().vcsDiff("/work", mode = "git") + + assertEquals("README.md", changes.single().displayPath) + assertEquals("working", server.takeRequest().requestUrl!!.queryParameter("mode")) + } + + @Test + fun `find files filters by type`() = + runBlocking { + server.enqueue(json(FS_FIND)) + + val paths = client().findFiles("/work", "README", includeDirectories = false) + + assertEquals(listOf("README.md"), paths) + val request = server.takeRequest() + assertEquals("file", request.requestUrl!!.queryParameter("type")) + assertEquals("README", request.requestUrl!!.queryParameter("query")) + } + + @Test + fun `session todo is always empty on V2`() = + runBlocking { + assertEquals(emptyList(), client().sessionTodo("ses_one")) + assertEquals(0, server.requestCount) + } + + // ---- misc, auth ---- + + @Test + fun `basic auth always uses username opencode`() = + runBlocking { + server.enqueue(json(SESSION_ONE)) + val client = + OpenCodeV2ApiClient( + profile().copy(username = "someone-else"), + OkHttpClient(), + ) + + client.session("ses_one") + + assertEquals(Credentials.basic("opencode", "secret"), server.takeRequest().getHeader("Authorization")) + } + + @Test + fun `config merges document info objects`() = + runBlocking { + server.enqueue(json(CONFIG_DOCUMENTS)) + + val config = client().config().jsonObject + + assertEquals("ask", config.getValue("permissions").jsonArray.first().jsonObject.str("effect")) + assertEquals("https://opencode.ai/config.json", config.str("\$schema")) + } + + @Test + fun `events stream parses data lines through the translator`() = + runBlocking { + server.enqueue( + MockResponse() + .setHeader("Content-Type", "text/event-stream") + .setBody( + "data: {\"id\":\"evt_1\",\"type\":\"server.connected\",\"data\":{}}\n\n" + + "data: {\"id\":\"evt_2\",\"type\":\"session.idle\",\"data\":{}}\n\n", + ), + ) + // server.connected triggers reconciliation: permissions, forms and active sessions, all empty here. + server.enqueue(json("""{"data":[]}""")) + server.enqueue(json("""{"data":[]}""")) + server.enqueue(json("""{"data":{}}""")) + val client = client() + + val first = withTimeout(10_000) { client.events().first() } + + assertEquals(OpenCodeEvent.ServerConnected, first) + assertEquals("/api/event", server.takeRequest().path) + } + + @Test + fun `reconnect replays pending permissions forms and busy sessions after server connected`() = + runBlocking { + server.enqueue( + MockResponse() + .setHeader("Content-Type", "text/event-stream") + .setBody("data: {\"id\":\"evt_1\",\"type\":\"server.connected\",\"data\":{}}\n\n"), + ) + server.enqueue(json("""{"data":[{"id":"per_1","sessionID":"ses_one","action":"shell","resources":["pwd"]}]}""")) + server.enqueue(json(FORM_LIST)) + server.enqueue(json("""{"data":{"ses_one":{"type":"running"}}}""")) + server.enqueue(json(MESSAGE_RUNNING_TOOL)) + val client = client() + + val events = withTimeout(10_000) { client.events().take(4).toList() } + + assertEquals(OpenCodeEvent.ServerConnected, events[0]) + val permission = events[1] as OpenCodeEvent.PermissionAsked + assertEquals("per_1", permission.request.id) + assertEquals("shell", permission.request.permission) + assertTrue(events[2] is OpenCodeEvent.QuestionAsked) + assertEquals(OpenCodeEvent.SessionStatusChanged("ses_one", "busy"), events[3]) + } + + @Test + fun `a failing reconcile step does not end the stream`() = + runBlocking { + server.enqueue( + MockResponse() + .setHeader("Content-Type", "text/event-stream") + .setBody("data: {\"id\":\"evt_1\",\"type\":\"server.connected\",\"data\":{}}\n\n"), + ) + server.enqueue(MockResponse().setResponseCode(500)) // permissions fail + server.enqueue(json("""{"data":[]}""")) // forms + server.enqueue(json("""{"data":{}}""")) // active + val client = client() + + // take(1) cancels the stream once ServerConnected arrives, so the failing reconcile steps never block the test. + val events = withTimeout(10_000) { client.events().take(1).toList() } + + assertEquals(listOf(OpenCodeEvent.ServerConnected), events) + } + + private companion object { + const val ARCHIVED_METADATA_KEY = "andcode.archivedAt" + + val MESSAGE_RUNNING_TOOL = + """{"data":[{"id":"msg_r","time":{"created":1},"type":"assistant","content":[""" + + """{"type":"tool","id":"call-r","name":"shell","executed":false,"state":{"status":"running","input":{"command":"pwd"}}}]}]}""" + + val V2_INFO = """{"version":"2.0.28","pid":1,"urls":["http://127.0.0.1"],"paths":{"tmp":"/tmp"},"capabilities":{}}""" + + val SESSION_ONE = + """{"data":{"id":"ses_one","projectID":"global","cost":0,"tokens":{"input":0,"output":0,"reasoning":0,""" + + """"cache":{"read":0,"write":0}},"time":{"created":1792059536138,"updated":1792059536138},""" + + """"title":"contract-A","location":{"directory":"/work"}}}""" + + val SESSION_WITH_METADATA = + """{"data":{"id":"ses_one","projectID":"global","time":{"created":1,"updated":2},"title":"contract-A",""" + + """"location":{"directory":"/work"},"metadata":{"existing":"keep"}}}""" + + val SESSION_ONE_ARCHIVED = + """{"data":{"id":"ses_one","projectID":"global","time":{"created":1,"updated":2},"title":"contract-A",""" + + """"location":{"directory":"/work"},"metadata":{"existing":"keep","andcode.archivedAt":9}}}""" + + val SESSION_PAGE_1 = + """{"data":[{"id":"ses_child","parentID":"ses_root","projectID":"global","time":{"created":1792059546296,""" + + """"updated":1792059546296},"title":"child","location":{"directory":"/repo"}}],""" + + """"cursor":{"previous":null,"next":"CURSOR_PAGE_2"}}""" + + val SESSION_PAGE_2 = + """{"data":[{"id":"ses_root","projectID":"global","time":{"created":1,"updated":1},"title":"root",""" + + """"location":{"directory":"/repo"}}],"cursor":{"previous":null,"next":null}}""" + + val SESSION_ARCHIVED = + """{"data":[{"id":"ses_active","projectID":"global","time":{"created":1,"updated":1},"title":"a",""" + + """"location":{"directory":"/repo"}},{"id":"ses_gone","projectID":"global","time":{"created":1,"updated":1},""" + + """"title":"b","location":{"directory":"/repo"},"metadata":{"andcode.archivedAt":5}}],""" + + """"cursor":{"previous":null,"next":null}}""" + + val MESSAGES_MIXED = + """{"data":[""" + + """{"id":"msg_user","time":{"created":10},"type":"user","text":"hello","files":[{"data":"AAAA","mime":"image/png",""" + + """"source":{"type":"uri","uri":"file:///x/red.png"},"name":"red.png","mention":{"start":0,"end":0,"text":"@red.png"}}]},""" + + """{"id":"msg_sel","time":{"created":11},"type":"agentSelected","agent":"build"},""" + + """{"id":"msg_assistant","time":{"created":12,"completed":13},"type":"assistant","agent":"build",""" + + """"model":{"id":"cat-jam-free","providerID":"opencode"},"content":[""" + + """{"type":"reasoning","text":"thinking"},{"type":"text","text":"DONE"},""" + + """{"type":"tool","id":"call-1","name":"shell","executed":false,"state":{"status":"completed","input":{"command":"ls"},""" + + """"content":[{"type":"text","text":"README.md\n"}],"metadata":{"exit":0}}}],""" + + """"tokens":{"input":38,"output":2,"reasoning":1,"cache":{"read":0,"write":0}}},""" + + """{"id":"msg_failed","time":{"created":14},"type":"idle","outcome":"failed","error":{"type":"ApiError","message":"upstream down"}},""" + + """{"id":"msg_interrupted","time":{"created":15},"type":"idle","outcome":"interrupted"},""" + + """{"id":"msg_ok","time":{"created":16},"type":"idle","outcome":"succeeded"},""" + + """{"id":"msg_shell","time":{"created":17},"type":"shell","shellID":"sh_1","command":"pwd","status":"exited","exit":0,""" + + """"output":{"output":"/work\n","cursor":0,"size":5,"truncated":false}}]}""" + + val MESSAGE_STREAMING_TOOL = + """{"data":[{"id":"msg_s","time":{"created":1},"type":"assistant","content":[""" + + """{"type":"tool","id":"call-s","name":"shell","executed":false,"state":{"status":"streaming","input":{}}}]}]}""" + + // The server pages newest-first, so page 1 holds the most recent record. + val MESSAGE_PAGE_1 = + """{"data":[{"id":"msg_b","time":{"created":2},"type":"user","text":"b"}],"cursor":{"previous":null,"next":"MSG_CURSOR"}}""" + + val MESSAGE_PAGE_2 = + """{"data":[{"id":"msg_a","time":{"created":1},"type":"user","text":"a"}],"cursor":{"previous":null,"next":null}}""" + + val PROMPT_ACCEPTED = """{"data":{"id":"msg_p","sessionID":"ses_one","time":{"created":1},"type":"user","payload":{"text":"hi"},"delivery":"steer"}}""" + + val FORM_LIST = + """{"data":[{"id":"frm_1","sessionID":"ses_one","title":"Questions","fields":[{"key":"q0","title":"Color preference",""" + + """"description":"Which color do you prefer?","type":"string","options":[{"value":"Red","label":"Red"},""" + + """{"value":"Blue","label":"Blue"}],"custom":true}]}]}""" + + val PROVIDER_LIST = + """{"data":[{"id":"opencode","integrationID":"opencode","name":"OpenCode","activation":"enabled"}]}""" + + val MODEL_LIST = + """{"data":[{"id":"cat-jam-free","modelID":"cat-jam-free","providerID":"opencode","name":"Cat Jam Free",""" + + """"variants":[{"id":"low","settings":{"reasoningEffort":"low"}},{"id":"medium","settings":{"reasoningEffort":"medium"}}],""" + + """"status":"active","enabled":true,"limit":{"context":262144,"output":131072}},""" + + """{"id":"longcat-2.5-preview-free","modelID":"longcat-2.5-preview-free","providerID":"opencode",""" + + """"name":"LongCat","variants":[],"status":"active","enabled":true},""" + + """{"id":"disabled-model","modelID":"disabled-model","providerID":"opencode","name":"Off","enabled":false}]}""" + + val MODEL_DEFAULT = + """{"data":{"id":"cat-jam-free","modelID":"cat-jam-free","providerID":"opencode","name":"Cat Jam Free","variants":[]}}""" + + val AGENT_LIST = + """{"data":[{"id":"build","name":"Build","description":"The default agent.","mode":"primary","hidden":false,"permissions":[]},""" + + """{"id":"compaction","name":"Compaction","description":"internal","mode":"primary","hidden":true,"permissions":[]}]}""" + + val INTEGRATION_LIST = + """{"data":[{"id":"302ai","name":"302.AI","methods":[{"type":"key"},{"type":"env","names":["302AI_API_KEY"]}],"connections":[]},""" + + """{"id":"opencode","name":"OpenCode Console","methods":[{"type":"key","label":"API key (service account)"},""" + + """{"id":"device","type":"oauth","label":"OpenCode Console account"}],"connections":[]}]}""" + + // A key method whose form asks for a base URL (a text field the dialog can render). + val INTEGRATION_WITH_FORM = + """{"data":[{"id":"custom","name":"Custom","methods":[{"type":"key","label":"API key","form":[""" + + """{"key":"baseUrl","type":"string","title":"Base URL","description":"Endpoint","placeholder":"https://"}]}],""" + + """"connections":[]}]}""" + + // A method with a boolean field: the dialog cannot render it, so the whole method must be dropped. + val INTEGRATION_WITH_UNREPRESENTABLE = + """{"data":[{"id":"bool-only","name":"Bool","methods":[{"type":"key","form":[""" + + """{"key":"enabled","type":"boolean"}]}],"connections":[]}]}""" + + val MESSAGE_ABORTED_ERROR = + """{"data":[{"id":"msg_err","time":{"created":1},"type":"assistant","content":[],""" + + """"error":{"type":"aborted","message":"stopped"}}]}""" + + val CREDENTIAL_LIST = + """{"data":[{"id":"cred_1","integrationID":"302ai","label":"a","active":true,"value":{"type":"key","key":"x"}},""" + + """{"id":"cred_2","integrationID":"302ai","label":"b","active":false,"value":{"type":"key","key":"y"}},""" + + """{"id":"cred_3","integrationID":"other","label":"c","active":true,"value":{"type":"key","key":"z"}}]}""" + + val OAUTH_ATTEMPT = + """{"data":{"attemptID":"att_1","url":"https://example.test/auth","instructions":"Enter the code","mode":"auto",""" + + """"time":{"created":1,"expires":2}}}""" + + val PROJECT_LIST = """[{"id":"global","worktree":"/work","canonical":"/work"}]""" + + val LOCATION = """{"location":{"directory":"/work"},"data":{"directory":"/work","project":{"id":"global","canonical":"/work"}}}""" + + val VCS_INFO = """{"data":{"provider":"git","branch":{"current":"master"}}}""" + + val VCS_DIFF = + """{"data":[{"file":"README.md","patch":"diff --git a/README.md b/README.md\n","additions":1,"deletions":0,"status":"added"}]}""" + + val FS_LIST = """{"data":[{"path":".git/","type":"directory"},{"path":"README.md","type":"file"}]}""" + + val FS_FIND = """{"data":[{"path":"README.md","type":"file"}]}""" + + val CONFIG_DOCUMENTS = + """[{"type":"document","path":"/cfg/opencode.json","info":{"${'$'}schema":"https://opencode.ai/config.json",""" + + """"permissions":[{"action":"shell","resource":"*","effect":"ask"}]}},{"type":"directory","path":"/cfg"}]""" + } +} diff --git a/app/src/test/java/com/yugahashimoto/andcode/core/api/OpenCodeV2EventTranslatorTest.kt b/app/src/test/java/com/yugahashimoto/andcode/core/api/OpenCodeV2EventTranslatorTest.kt new file mode 100644 index 000000000..2215d5e52 --- /dev/null +++ b/app/src/test/java/com/yugahashimoto/andcode/core/api/OpenCodeV2EventTranslatorTest.kt @@ -0,0 +1,390 @@ +package com.yugahashimoto.andcode.core.api + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Replays the fixtures captured from a live OpenCode V2 server (v2.0.28), one `data:` line per + * event, so the mapping is checked against the real wire shapes rather than hand-written ones. + */ +class OpenCodeV2EventTranslatorTest { + private val formRegistry = OpenCodeV2FormRegistry() + + /** Fixed clock: the replays never cross the liveness interval, so the throttle is deterministic. */ + private var now = 0L + private val translator = + OpenCodeV2EventTranslator(Json { ignoreUnknownKeys = true }, formRegistry, clock = { now }) + + private val session = "ses_ec0ed8d01ffeMCMAnsvqKvcZlP" + private val firstAssistant = "msg_13f12d484001x1GrZf4FsEpZFE" + private val secondAssistant = "msg_13f12d8e7001yF1vGKVpI8umGb" + private val shellCall = "call-e449b4e6-9d1e-499b-9677-454de839b8ad" + + /** The execution-failed fixture was captured in its own session (turn 7), not the main one. */ + private val failureSession = "ses_ec0e9d9daffeZbW7wReVzRsWUp" + private val failedAssistant = "msg_13f162a5c001TPmshXTmXrp2M3" + + @Test + fun `text and reasoning turn maps to V1 part snapshots and deltas`() { + val events = replay("text_tools.jsonl") + + assertEquals(OpenCodeEvent.ServerConnected, events.first()) + assertEquals(OpenCodeEvent.SessionStatusChanged(session, "busy"), events[1]) + assertEquals(OpenCodeEvent.SessionIdle(session), events.last()) + + val steps = events.filterIsInstance() + assertEquals(listOf(firstAssistant, secondAssistant), steps.map { it.info.id }.distinct()) + assertTrue(steps.all { it.info.role == "assistant" && it.info.sessionId == session }) + assertEquals(OpenCodeModelReference("opencode", "cat-jam-free"), steps.first().info.model) + assertEquals("build", steps.first().info.agent) + // Tokens arrive on step.ended, the first snapshot that carries them. + assertEquals(2408L, steps.first { it.info.tokens != null }.info.tokens?.input) + + // The blank started snapshot is followed by the full ended text, which replaces the deltas. + val textSnapshots = + events.filterIsInstance() + .filter { it.part.id == "$secondAssistant-text-0" } + assertEquals(listOf("", "DONE"), textSnapshots.map { it.part.text }) + assertEquals( + OpenCodeEvent.MessagePartDelta(session, secondAssistant, "$secondAssistant-text-0", "text", "DONE"), + events.filterIsInstance().last { it.field == "text" }, + ) + + val reasoningDeltas = events.filterIsInstance().filter { it.field == "reasoning" } + assertEquals(2, reasoningDeltas.size) + assertEquals("$firstAssistant-reasoning-0", reasoningDeltas.first().partId) + + // The reasoning part's ended snapshot carries the full text under the reasoning type. + val reasoningEnded = + events.filterIsInstance() + .first { it.part.id == "$firstAssistant-reasoning-0" && it.part.text?.isNotEmpty() == true } + assertEquals("reasoning", reasoningEnded.part.type) + assertTrue(reasoningEnded.part.text!!.startsWith("The user wants me to run `ls`")) + } + + @Test + fun `shell tool turn keeps name and input across pending running and completed`() { + val toolParts = + replay("text_tools.jsonl") + .filterIsInstance() + .filter { it.part.type == "tool" } + .map { it.part } + + assertEquals( + listOf("pending", "pending", "running", "running", "completed"), + toolParts.map { it.state?.get("status")?.jsonPrimitive?.content }, + ) + // The tool part id is the call id, the same id the V2 message mapper uses. + assertTrue(toolParts.all { it.id == shellCall && it.callID == shellCall && it.tool == "shell" }) + assertTrue(toolParts.all { it.messageId == firstAssistant && it.sessionId == session }) + + val input = toolParts[1].state?.get("input") as JsonObject + assertEquals("ls", input["command"]?.jsonPrimitive?.content) + // The later running update still carries the input that arrived before it. + assertEquals(input, toolParts[2].state?.get("input")) + + val completed = toolParts.last().state!! + assertEquals("README.md\n", completed["output"]?.jsonPrimitive?.content) + assertNotNull(completed["metadata"]) + } + + @Test + fun `permission ask and reply map to V1 permission events`() { + val events = replay("permission.jsonl") + + val asked = events.filterIsInstance().single().request + assertEquals("per_13f133279001Ah85Mwz5YJSy1r", asked.id) + assertEquals(session, asked.sessionId) + assertEquals("shell", asked.permission) + assertEquals(listOf("pwd"), asked.patterns) + assertEquals(setOf("save", "source"), asked.metadata.keys) + + assertEquals( + OpenCodeEvent.PermissionReplied(session, "per_13f133279001Ah85Mwz5YJSy1r"), + events.filterIsInstance().single(), + ) + } + + @Test + fun `form created becomes a question with the field options and directory`() { + val events = replay("form.jsonl") + + val question = events.filterIsInstance().single().request + assertEquals("frm_13f137bc90017JjU2piWI5BVi6", question.id) + assertEquals(session, question.sessionId) + assertEquals("/tmp/opencode/v2contract/srv/work", question.directory) + assertEquals(1, question.questions.size) + + val prompt = question.questions.single() + assertEquals("Which color do you prefer?", prompt.question) + assertEquals("Color preference", prompt.header) + assertEquals(listOf("Red", "Blue"), prompt.options.map { it.label }) + assertTrue(prompt.custom) + assertTrue(!prompt.multiple) + + // Remembered so the answer can be posted without another lookup. + assertEquals(session, formRegistry.get("frm_13f137bc90017JjU2piWI5BVi6")?.sessionId) + } + + @Test + fun `form replied and cancelled forget the form and emit nothing`() { + formRegistry.remember(V2Form(id = "frm_1", sessionId = session, fields = emptyList())) + + val replied = + """{"id":"evt_1","type":"form.replied","location":{"directory":"/w"},"data":{"id":"frm_1","sessionID":"$session","answer":{"q0":"Red"}}}""" + assertEquals(emptyList(), translator.translate(replied)) + assertNull(formRegistry.get("frm_1")) + + val cancelled = """{"id":"evt_2","type":"form.cancelled","data":{"id":"frm_2","sessionID":"$session"}}""" + assertEquals(emptyList(), translator.translate(cancelled)) + } + + @Test + fun `failed execution emits the error then idle`() { + val events = replay("execution_failed.jsonl") + + val error = events.filterIsInstance().single() + assertEquals(failureSession, error.sessionId) + assertEquals("provider.invalid-request", error.name) + assertTrue(error.message!!.startsWith("Upstream request failed: [invalid_parameter] Image too small")) + assertTrue(!error.isAbort) + + val failureIndex = events.indexOf(error) + assertEquals(OpenCodeEvent.SessionIdle(failureSession), events[failureIndex + 1]) + assertEquals(OpenCodeEvent.SessionIdle(failureSession), events.last()) + + // The step failure also reaches the message as an error, so the chat can attach it to the reply. + val stepFailure = events.filterIsInstance().last().info + assertEquals(failedAssistant, stepFailure.id) + assertEquals("provider.invalid-request", stepFailure.error?.name) + } + + /** + * Item (a): a stop from any device is reported the way V1 reports one: a MessageAbortedError + * session error (which mutes the completion notification) followed by idle. Also item (c): the + * compaction in this capture is liveness, not transcript text. + */ + @Test + fun `interrupted run is an abort error then idle and compaction is liveness only`() { + now = 0L + val events = replay("interrupted.jsonl") + + // Real order: execution started (busy), compaction started (busy), first delta (liveness), + // second delta (inside the 5s throttle, dropped), compaction failed (nothing), + // execution interrupted (abort error then idle). + assertEquals( + listOf( + OpenCodeEvent.ServerConnected, + OpenCodeEvent.SessionStatusChanged(session, "busy"), + OpenCodeEvent.SessionStatusChanged(session, "busy"), + livenessDelta(), + OpenCodeEvent.SessionError(session, message = null, name = "MessageAbortedError"), + OpenCodeEvent.SessionIdle(session), + ), + events, + ) + assertTrue((events[4] as OpenCodeEvent.SessionError).isAbort) + } + + @Test + fun `interrupted without an error still reports an abort so it is not a completion`() { + val events = + translator.translate( + """{"id":"e1","type":"session.execution.interrupted","data":{"sessionID":"$session","reason":"user"}}""", + ) + assertEquals( + listOf( + OpenCodeEvent.SessionError(session, message = null, name = "MessageAbortedError"), + OpenCodeEvent.SessionIdle(session), + ), + events, + ) + } + + @Test + fun `aborted error type is mapped to the V1 abort name on step and execution failures`() { + val step = + translator.translate( + """{"id":"e1","type":"session.step.failed","data":{"sessionID":"$session","assistantMessageID":"$firstAssistant","error":{"type":"aborted","message":"Stopped"}}}""", + ).single() as OpenCodeEvent.MessageUpdated + assertEquals("MessageAbortedError", step.info.error?.name) + assertTrue(step.info.error!!.isAbort) + + val execution = + translator.translate( + """{"id":"e2","type":"session.execution.failed","data":{"sessionID":"$session","error":{"type":"aborted","message":"Stopped"}}}""", + ) + assertEquals( + listOf( + OpenCodeEvent.SessionError(session, "Stopped", "MessageAbortedError"), + OpenCodeEvent.SessionIdle(session), + ), + execution, + ) + } + + /** Item (b): a fork's parentID is its source, so the translator must not announce it as a parent. */ + @Test + fun `session forked and session created emit nothing so the REST parent lookup decides`() { + assertEquals(emptyList(), replay("session_created.jsonl")) + } + + @Test + fun `session renamed and moved are dropped like other chatter`() { + assertEquals( + emptyList(), + translator.translate("""{"id":"evt_1","type":"session.renamed","data":{"sessionID":"$session","title":"x"}}"""), + ) + assertEquals( + emptyList(), + translator.translate( + """{"id":"evt_2","type":"session.moved","data":{"sessionID":"$session","location":{"directory":"/w2"}}}""", + ), + ) + } + + @Test + fun `compaction start is busy and its deltas are throttled liveness`() { + now = 0L + assertEquals( + OpenCodeEvent.SessionStatusChanged(session, "busy"), + translator.translate( + """{"id":"e1","type":"session.compaction.started","data":{"sessionID":"$session","reason":"manual"}}""", + ).single(), + ) + + val delta = """{"id":"e2","type":"session.compaction.delta","data":{"sessionID":"$session","text":"## Objective"}}""" + assertEquals(listOf(livenessDelta()), translator.translate(delta)) + now = 4_999L + assertEquals(emptyList(), translator.translate(delta)) + now = 5_000L + assertEquals(listOf(livenessDelta()), translator.translate(delta)) + } + + @Test + fun `compaction failure emits nothing because execution settles the run`() { + assertEquals( + emptyList(), + translator.translate( + """{"id":"e1","type":"session.compaction.failed","data":{"sessionID":"$session","reason":"manual","error":{"type":"aborted","message":"Compaction cancelled"}}}""", + ), + ) + } + + @Test + fun `long generation without visible text keeps the session alive via throttled liveness`() { + now = 0L + val streamed = """{"id":"e1","type":"session.step.streamed","data":{"sessionID":"$session","assistantMessageID":"$firstAssistant"}}""" + val usage = """{"id":"e2","type":"session.usage.updated","data":{"sessionID":"$session","cost":0,"tokens":{}}}""" + + assertEquals(listOf(livenessDelta()), translator.translate(streamed)) + now = 1_000L + assertEquals(emptyList(), translator.translate(streamed)) + now = 2_000L + assertEquals(emptyList(), translator.translate(usage)) + now = 5_000L + assertEquals(listOf(livenessDelta()), translator.translate(usage)) + } + + @Test + fun `replayed turn never emits a delta on a field the chat would render`() { + now = 0L + val events = replay("text_tools.jsonl") + // Every delta is either visible text/reasoning or the liveness marker the chat ignores. + val deltas = events.filterIsInstance() + assertTrue(deltas.all { it.field == "text" || it.field == "reasoning" || it.field == "liveness" }) + assertTrue(deltas.filter { it.field == "liveness" }.all { it.delta.isEmpty() && it.sessionId == session }) + } + + @Test + fun `seeded tool keeps its name and input across a reconnect`() { + val input = Json.parseToJsonElement("""{"command":"ls"}""") + translator.seedTool(shellCall, "shell", input) + + val progress = + translator.translate( + """{"id":"e1","type":"session.tool.progress","data":{"sessionID":"$session","assistantMessageID":"$firstAssistant","id":"$shellCall","metadata":{"shellID":"sh_1"}}}""", + ).single() as OpenCodeEvent.MessagePartUpdated + + assertEquals("shell", progress.part.tool) + assertEquals(input, progress.part.state?.get("input")) + assertEquals("running", progress.part.state?.get("status")?.jsonPrimitive?.content) + } + + @Test + fun `tool failure keeps the call name and reports the message`() { + translator.translate( + """{"id":"e1","type":"session.tool.input.started","data":{"sessionID":"$session","assistantMessageID":"$firstAssistant","id":"$shellCall","name":"shell"}}""", + ) + val failed = + translator.translate( + """{"id":"e2","type":"session.tool.failed","data":{"sessionID":"$session","assistantMessageID":"$firstAssistant","id":"$shellCall","error":{"type":"tool.execution","message":"user cancelled"},"executed":false}}""", + ).single() as OpenCodeEvent.MessagePartUpdated + + assertEquals("shell", failed.part.tool) + assertEquals("error", failed.part.state?.get("status")?.jsonPrimitive?.content) + assertEquals("user cancelled", failed.part.state?.get("error")?.jsonPrimitive?.content) + } + + @Test + fun `todowrite input passes through unchanged`() { + val input = """{"todos":[{"content":"Ship","status":"pending","priority":"high"}]}""" + translator.translate( + """{"id":"e1","type":"session.tool.input.started","data":{"sessionID":"$session","assistantMessageID":"$firstAssistant","id":"call-todo","name":"todowrite"}}""", + ) + val called = + translator.translate( + """{"id":"e2","type":"session.tool.called","data":{"sessionID":"$session","assistantMessageID":"$firstAssistant","id":"call-todo","input":$input,"executed":false}}""", + ).single() as OpenCodeEvent.MessagePartUpdated + + assertEquals("todowrite", called.part.tool) + assertEquals(Json.parseToJsonElement(input), called.part.state?.get("input")) + } + + @Test + fun `malformed json yields nothing`() { + assertEquals(emptyList(), translator.translate("not json")) + assertEquals(emptyList(), translator.translate("{")) + assertEquals(emptyList(), translator.translate("""{"data":{}}""")) + assertEquals(emptyList(), translator.translate("[1,2]")) + } + + @Test + fun `known type with a broken body becomes unknown`() { + val raw = """{"type":"session.text.delta","data":{"sessionID":"s"}}""" + assertEquals(listOf(OpenCodeEvent.Unknown("session.text.delta", raw)), translator.translate(raw)) + } + + @Test + fun `unrecognised type becomes unknown`() { + val raw = """{"type":"future.thing","data":{}}""" + assertEquals(listOf(OpenCodeEvent.Unknown("future.thing", raw)), translator.translate(raw)) + } + + @Test + fun `part ids match the V2 message mapper scheme`() { + val delta = + translator.translate( + """{"type":"session.text.delta","data":{"sessionID":"$session","assistantMessageID":"$firstAssistant","ordinal":2,"delta":"x"}}""", + ).single() as OpenCodeEvent.MessagePartDelta + + assertEquals(OpenCodeV2Ids.textPartId(firstAssistant, 2), delta.partId) + } + + /** The liveness-only delta the translator emits for [session]; see livenessThrottled. */ + private fun livenessDelta() = + OpenCodeEvent.MessagePartDelta(sessionId = session, messageId = "", partId = "", field = "liveness", delta = "") + + private fun replay(resource: String): List { + val stream = javaClass.classLoader!!.getResourceAsStream("opencode-v2-events/$resource")!! + return stream.bufferedReader().useLines { lines -> + lines.filter { it.isNotBlank() }.flatMap { translator.translate(it) }.toList() + } + } +} diff --git a/app/src/test/java/com/yugahashimoto/andcode/live/OpenCodeV2LiveTest.kt b/app/src/test/java/com/yugahashimoto/andcode/live/OpenCodeV2LiveTest.kt new file mode 100644 index 000000000..8f7a47363 --- /dev/null +++ b/app/src/test/java/com/yugahashimoto/andcode/live/OpenCodeV2LiveTest.kt @@ -0,0 +1,490 @@ +package com.yugahashimoto.andcode.live + +import com.yugahashimoto.andcode.core.api.OpenCodeApiException +import com.yugahashimoto.andcode.core.api.OpenCodeEvent +import com.yugahashimoto.andcode.core.api.OpenCodeEvent.QuestionAsked +import com.yugahashimoto.andcode.core.api.OpenCodePart +import com.yugahashimoto.andcode.core.api.OpenCodeProtocol +import com.yugahashimoto.andcode.core.api.PromptRequest +import com.yugahashimoto.andcode.core.api.sessionIdOrNull +import com.yugahashimoto.andcode.data.connection.ConnectionProfile +import com.yugahashimoto.andcode.runtime.PermissionResponse +import com.yugahashimoto.andcode.runtime.remote.RemoteOpenCodeBackend +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Assume +import org.junit.Before +import org.junit.Test +import java.util.concurrent.CopyOnWriteArrayList +import com.yugahashimoto.andcode.core.api.OpenCodeEvent.MessagePartDelta as PartDelta +import com.yugahashimoto.andcode.core.api.OpenCodeEvent.MessagePartUpdated as PartUpdated +import com.yugahashimoto.andcode.core.api.OpenCodeEvent.PermissionAsked as PermAsked +import com.yugahashimoto.andcode.core.api.OpenCodeEvent.PermissionReplied as PermReplied +import com.yugahashimoto.andcode.core.api.OpenCodeEvent.SessionError as SessErr +import com.yugahashimoto.andcode.core.api.OpenCodeEvent.SessionIdle as SessIdle +import com.yugahashimoto.andcode.core.api.OpenCodeEvent.SessionStatusChanged as SessStatus + +/** + * End-to-end test of the V2 client through the public [RemoteOpenCodeBackend] surface, against a + * REAL OpenCode v2 server. Skipped unless `OPENCODE_LIVE_URL` is set, so CI never runs it. + * + * Environment: + * - OPENCODE_LIVE_URL server base URL, e.g. http://127.0.0.1:17895 (required) + * - OPENCODE_LIVE_DIR workspace directory as the SERVER sees it (required) + * - OPENCODE_LIVE_PASSWORD server password (username is fixed to `opencode` by the V2 server) + * - OPENCODE_LIVE_PROVIDER default `opencode` + * - OPENCODE_LIVE_MODEL default `cat-jam-free` + * - OPENCODE_LIVE_VERSION expected server version, default `2.0.28` + * + * The server is expected to have `"permission": {"shell": "ask"}` in its config. The turn scenario + * approves shell requests automatically; the permission scenario answers them explicitly. + */ +class OpenCodeV2LiveTest { + private var url = "" + private var password: String? = null + private var dir = "" + private var providerId = "opencode" + private var modelId = "cat-jam-free" + private var expectedVersion = "2.0.28" + + @Before + fun readLiveEnvironment() { + url = env("OPENCODE_LIVE_URL").orEmpty() + Assume.assumeTrue("OPENCODE_LIVE_URL is not set; live OpenCode V2 test skipped", url.isNotBlank()) + dir = env("OPENCODE_LIVE_DIR").orEmpty() + Assume.assumeTrue("OPENCODE_LIVE_DIR is not set; live OpenCode V2 test skipped", dir.isNotBlank()) + password = env("OPENCODE_LIVE_PASSWORD") + providerId = env("OPENCODE_LIVE_PROVIDER") ?: providerId + modelId = env("OPENCODE_LIVE_MODEL") ?: modelId + expectedVersion = env("OPENCODE_LIVE_VERSION") ?: expectedVersion + } + + // 1. health and authentication ------------------------------------------------------------- + + @Test + fun `health reports version 2 and wrong password is a 401`() = + runBlocking { + withTimeout(WITHIN_MS) { + val good = RemoteOpenCodeBackend(profile(password)) + assertEquals(null, good.protocol) + val health = good.health() + assertTrue(health.healthy) + assertEquals(expectedVersion, health.version) + assertEquals(OpenCodeProtocol.V2, good.protocol) + + val bad = RemoteOpenCodeBackend(profile("definitely-wrong-password")) + val error = + try { + bad.health() + null + } catch (expected: OpenCodeApiException) { + expected + } + assertNotNull("wrong password must not succeed", error) + assertEquals(401, error!!.statusCode) + assertEquals(OpenCodeProtocol.V2, bad.protocol) + } + } + + // 2. session lifecycle ----------------------------------------------------------------------- + + @Test + fun `session create list rename archive and delete`() = + runBlocking { + withTimeout(WITHIN_MS) { + val backend = RemoteOpenCodeBackend(profile(password)) + backend.health() + val title = "live-a-${System.currentTimeMillis()}" + val a = backend.createSession(title, dir) + assertTrue("created session must be listed", backend.listSessions(dir).any { it.id == a.id }) + + val renamed = backend.renameSession(a.id, "$title-renamed") + assertEquals("$title-renamed", renamed.title) + assertEquals("$title-renamed", backend.session(a.id).title) + + val b = backend.createSession("live-b-$title", dir) + assertTrue(backend.deleteSession(b.id)) + assertFalse("deleted session must not be listed", backend.listSessions(dir).any { it.id == b.id }) + val gone = + try { + backend.session(b.id) + null + } catch (expected: OpenCodeApiException) { + expected + } + assertEquals("deleted session must be 404", 404, gone?.statusCode) + + val archived = backend.archiveSession(a.id) + assertNotNull("archive must set time.archived on the returned session", archived.time.archived) + assertFalse( + "archived session must disappear from listSessions", + backend.listSessions(dir).any { it.id == a.id }, + ) + } + } + + // 3. read-only catalog and workspace -------------------------------------------------------- + + @Test + fun `catalog providers agents commands skills config files and vcs`() = + runBlocking { + withTimeout(WITHIN_MS) { + val backend = RemoteOpenCodeBackend(profile(password)) + backend.health() + + val catalog = backend.listProviders() + val provider = catalog.all.firstOrNull { it.id == providerId } + assertNotNull("provider $providerId missing from ${catalog.all.map { it.id }}", provider) + assertTrue("model $modelId missing", provider!!.models.containsKey(modelId)) + assertNotNull("no default model for $providerId", catalog.default[providerId]) + + assertTrue("agent build missing", backend.listAgents().any { it.name == "build" }) + assertTrue("commands empty", backend.commands().isNotEmpty()) + assertTrue("skills empty", backend.skills().isNotEmpty()) + + val config = backend.config() + assertTrue("config must be a merged object: $config", config is JsonObject) + // V2 keeps the document's own keys under `info`; the merged view must carry the `shell: ask` rule. + assertTrue("config must carry the shell ask rule from opencode.json: $config", config.toString().contains("\"shell\"")) + + val files = backend.listFiles(dir, ".") + assertTrue("README.md missing from $files", files.any { it.name == "README.md" && it.type == "file" }) + assertEquals("hello\n", backend.readFile(dir, "README.md").content) + + assertNotNull("vcs branch missing", backend.vcsInfo(dir).branch) + } + } + + // 4. real turn: streaming, tool, reload ----------------------------------------------------- + + @Test + fun `real turn streams text and shell tool then reloads without duplicate parts`() = + runBlocking { + modelRetry("turn") { attempt -> + val prompt = if (attempt == 1) TURN_PROMPT else TURN_PROMPT_REPHRASED + live(autoApprove = true) { live -> + val sid = live.backend.createSession("live-turn", dir).id + val mark = live.mark() + live.backend.sendMessage(sid, promptFor(prompt)) + live.awaitOrFail("SessionIdle", TURN_MS, mark) { it is SessIdle && it.sessionId == sid } + live.failOnProviderError(sid, mark) + + val streamed = live.eventsFor(sid, mark) + assertTrue("no busy status", streamed.any { it is SessStatus && it.status == "busy" }) + assertTrue("no text streamed", streamed.any { it.carriesText() }) + val tools = streamed.filterIsInstance().map { it.part }.filter { it.type == "tool" } + if (tools.none { it.tool == "shell" }) throw ModelFlake("model did not call the shell tool") + assertTrue("shell tool never completed", tools.any { it.tool == "shell" && statusOf(it) == "completed" }) + + val messages = live.backend.listMessages(sid) + assertEquals( + "prompt must be stored exactly once", + 1, + messages.count { it.info.role == "user" && it.text == prompt }, + ) + val reloadedParts = messages.flatMap { it.parts } + val reloadedIds = reloadedParts.mapNotNull { it.id } + assertEquals("duplicate part ids after reload", reloadedIds.size, reloadedIds.toSet().size) + + val streamedIds = + streamed.mapNotNull { event -> + when (event) { + is PartUpdated -> event.part.id?.takeIf { event.part.type == "text" || event.part.type == "tool" } + is PartDelta -> event.partId.takeIf { event.field == "text" } + else -> null + } + }.toSet() + val missing = streamedIds - reloadedIds.toSet() + assertTrue("streamed part ids not found after reload: $missing", missing.isEmpty()) + + val reloadedTool = reloadedParts.firstOrNull { it.type == "tool" && it.tool == "shell" } + assertNotNull("shell tool part missing after reload", reloadedTool) + assertEquals("completed", statusOf(reloadedTool!!)) + + val finalText = assistantText(live.backend, sid) + if (!finalText.contains("DONE", ignoreCase = true)) throw ModelFlake("reply lacked DONE: '$finalText'") + } + } + } + + // 5. permission round trip --------------------------------------------------------------- + + @Test + fun `shell permission asks and a once reply resumes the turn`() = + runBlocking { + modelRetry("permission") { attempt -> + val prompt = if (attempt == 1) PERMISSION_PROMPT else PERMISSION_PROMPT_REPHRASED + live(autoApprove = false) { live -> + val sid = live.backend.createSession("live-permission", dir).id + val mark = live.mark() + live.backend.sendMessage(sid, promptFor(prompt)) + val asked = + live.awaitOrNull(TURN_MS, mark) { it is PermAsked && it.request.sessionId == sid } as? PermAsked + if (asked == null) { + if (live.eventsFor(sid, mark).none { it is PartUpdated && it.part.type == "tool" }) { + throw ModelFlake("model did not call the shell tool, so no permission was asked") + } + fail("a tool ran but no permission was asked") + } + val request = asked!!.request + assertEquals("shell", request.permission) + assertTrue(live.backend.respondToPermission(sid, request.id, PermissionResponse.ONCE, remember = false)) + + live.awaitOrFail("PermissionReplied", WITHIN_SHORT_MS, mark) { + it is PermReplied && it.requestId == request.id && it.sessionId == sid + } + live.awaitOrFail("SessionIdle", TURN_MS, mark) { it is SessIdle && it.sessionId == sid } + live.failOnProviderError(sid, mark) + assertTrue( + "shell tool did not complete after approval", + live.eventsFor(sid, mark).filterIsInstance() + .any { it.part.tool == "shell" && statusOf(it.part) == "completed" }, + ) + } + } + } + + // 6. question form ------------------------------------------------------------------------ + + @Test + fun `question form is listed, answered and resolves the turn`() = + runBlocking { + modelRetry("question") { attempt -> + val prompt = if (attempt == 1) QUESTION_PROMPT else QUESTION_PROMPT_REPHRASED + live { live -> + val sid = live.backend.createSession("live-question", dir).id + val mark = live.mark() + live.backend.sendMessage(sid, promptFor(prompt)) + val asked = live.awaitOrNull(TURN_MS, mark) { it is QuestionAsked && it.request.sessionId == sid } as? QuestionAsked + if (asked == null) throw ModelFlake("model did not use the question tool") + val request = asked.request + val labels = request.questions.flatMap { question -> question.options.map { it.label } } + assertTrue("options Red and Blue missing, got $labels", labels.containsAll(listOf("Red", "Blue"))) + + val pending = live.backend.pendingQuestions(dir) + assertTrue("pending question ${request.id} missing: ${pending.map { it.id }}", pending.any { it.id == request.id }) + + assertTrue(live.backend.answerQuestion(request.id, listOf(listOf("Blue")), dir)) + live.awaitOrFail("SessionIdle", TURN_MS, mark) { it is SessIdle && it.sessionId == sid } + live.failOnProviderError(sid, mark) + + assertFalse( + "answered question must no longer be pending", + live.backend.pendingQuestions(dir).any { it.id == request.id }, + ) + val text = assistantText(live.backend, sid) + if (!text.contains("Blue", ignoreCase = true)) throw ModelFlake("final text does not mention Blue: '$text'") + } + } + } + + // 7. abort ---------------------------------------------------------------------------------- + + @Test + fun `abort stops a long turn and leaves no non-abort error`() = + runBlocking { + modelRetry("abort") { + // Auto-approve: the model may still reach for a tool, and an unanswered permission would block the turn. + live(autoApprove = true) { live -> + val sid = live.backend.createSession("live-abort", dir).id + val mark = live.mark() + live.backend.sendMessage(sid, promptFor(ABORT_PROMPT)) + // Abort as soon as the turn is busy: the free model can finish a short reply in a few seconds. + live.awaitOrFail( + "SessionStatusChanged busy", + WITHIN_MS, + mark, + ) { it is SessStatus && it.sessionId == sid && it.status == "busy" } + if (live.eventsFor(sid, mark).any { + it is SessIdle + } + ) { + throw ModelFlake("turn finished before abort was sent: ${live.errorDetail(sid, mark)}") + } + + val abortMark = live.mark() + assertTrue(live.backend.abortSession(sid)) + live.awaitOrFail("SessionIdle after abort", WITHIN_SHORT_MS, abortMark) { it is SessIdle && it.sessionId == sid } + + val failures = live.eventsFor(sid, mark).filterIsInstance().filterNot { it.isAbort } + if (failures.isNotEmpty()) throw ModelFlake("non-abort SessionError around abort: $failures") + } + } + } + + // 8. summarize ------------------------------------------------------------------------------ + + @Test + fun `summarize compacts a session that has a turn`() = + runBlocking { + modelRetry("summarize") { + live { live -> + val sid = live.backend.createSession("live-summarize", dir).id + val mark = live.mark() + live.backend.sendMessage(sid, promptFor("Reply with the single word OK.")) + live.awaitOrFail("SessionIdle", TURN_MS, mark) { it is SessIdle && it.sessionId == sid } + live.failOnProviderError(sid, mark) + assertTrue(live.backend.summarizeSession(sid, providerId, modelId)) + } + } + } + + // ------------------------------------------------------------------------------------------ + + private fun env(name: String): String? = System.getenv(name)?.trim()?.takeIf { it.isNotEmpty() } + + private fun profile(pw: String?) = ConnectionProfile(name = "live", baseUrl = url, password = pw?.takeIf { it.isNotBlank() }) + + private fun promptFor(text: String) = PromptRequest(text = text, providerId = providerId, modelId = modelId, agent = "build") + + private suspend fun assistantText( + backend: RemoteOpenCodeBackend, + sid: String, + ): String = + backend.listMessages(sid) + .filter { it.info.role == "assistant" } + .joinToString("\n") { it.text } + + private fun statusOf(part: OpenCodePart): String? = ((part.state?.get("status")) as? JsonPrimitive)?.contentOrNull + + private fun OpenCodeEvent.carriesText(): Boolean = + when (this) { + is PartUpdated -> part.type == "text" + is PartDelta -> field == "text" + else -> false + } + + /** Runs [block] with a live event recorder; the recorder is cancelled when the block ends. */ + private suspend fun live( + autoApprove: Boolean = false, + block: suspend (Live) -> T, + ): T { + val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + try { + val live = Live(RemoteOpenCodeBackend(profile(password)), scope, autoApprove) + live.start() + // The first SSE frame proves the stream is open; the channel buffers it until we read it. + live.awaitOrFail("server.connected", WITHIN_SHORT_MS, 0) { it == OpenCodeEvent.ServerConnected } + return block(live) + } finally { + scope.cancel() + } + } + + /** + * Retries a model-driven scenario once. Only [ModelFlake] (the model or provider misbehaved) + * triggers the retry; client bugs fail on the first attempt. + */ + private suspend fun modelRetry( + name: String, + block: suspend (attempt: Int) -> T, + ): T = + try { + block(1) + } catch (flake: ModelFlake) { + println("LIVE-FLAKE[$name] attempt 1: ${flake.message}; retrying once") + block(2) + } + + private class ModelFlake(message: String) : AssertionError(message) + + private inner class Live( + val backend: RemoteOpenCodeBackend, + private val scope: CoroutineScope, + private val autoApprove: Boolean, + ) { + val events = CopyOnWriteArrayList() + + fun start() { + scope.launch { + backend.events().collect { event -> + events += event + if (autoApprove && event is PermAsked) { + val request = event.request + scope.launch { + backend.respondToPermission(request.sessionId, request.id, PermissionResponse.ONCE, remember = false) + } + } + } + } + } + + fun mark(): Int = events.size + + fun eventsFor( + sid: String, + from: Int, + ): List = events.drop(from).filter { it.sessionIdOrNull() == sid } + + suspend fun awaitOrNull( + timeoutMs: Long, + from: Int, + predicate: (OpenCodeEvent) -> Boolean, + ): OpenCodeEvent? = + withTimeoutOrNull(timeoutMs) { + var found: OpenCodeEvent? = null + while (found == null) { + found = events.drop(from).firstOrNull(predicate) + if (found == null) delay(POLL_MS) + } + found + } + + suspend fun awaitOrFail( + what: String, + timeoutMs: Long, + from: Int, + predicate: (OpenCodeEvent) -> Boolean, + ): OpenCodeEvent = + awaitOrNull(timeoutMs, from, predicate) + ?: throw AssertionError( + "timed out after ${timeoutMs}ms waiting for $what; last events: ${events.takeLast(15).map { it::class.simpleName }}", + ) + + /** Name and message of the session's errors, for flake diagnostics. */ + fun errorDetail( + sid: String, + from: Int, + ): String = + eventsFor(sid, from).filterIsInstance().joinToString { "${it.name}: ${it.message}" }.ifEmpty { "no session error" } + + /** A non-abort session error means the provider or model failed; the chat must still have settled. */ + fun failOnProviderError( + sid: String, + from: Int, + ) { + val error = eventsFor(sid, from).filterIsInstance().firstOrNull { !it.isAbort } + if (error != null) throw ModelFlake("session error ${error.name}: ${error.message}") + } + } + + private companion object { + const val POLL_MS = 100L + const val WITHIN_SHORT_MS = 30_000L + const val WITHIN_MS = 60_000L + const val TURN_MS = 120_000L + + const val TURN_PROMPT = "Run `ls` using the shell tool, then reply with the single word DONE." + const val TURN_PROMPT_REPHRASED = "Call the shell tool with the command `ls`. After the tool returns, reply with only the word DONE." + const val PERMISSION_PROMPT = "Run `pwd` using the shell tool, then reply with the single word DONE." + const val PERMISSION_PROMPT_REPHRASED = "Use the shell tool to run `pwd`. Then reply with only the word DONE." + const val QUESTION_PROMPT = "Use the question tool to ask me whether I prefer Red or Blue (options Red, Blue). Then reply with my answer." + const val QUESTION_PROMPT_REPHRASED = "Ask me with the question tool: Red or Blue? Options: Red, Blue. Then reply with my answer." + const val ABORT_PROMPT = "Count slowly from 1 to 200, one number per line" + } +} diff --git a/app/src/test/java/com/yugahashimoto/andcode/runtime/local/LocalOpenCodeBackendTest.kt b/app/src/test/java/com/yugahashimoto/andcode/runtime/local/LocalOpenCodeBackendTest.kt index 6f50c7877..429b64f26 100644 --- a/app/src/test/java/com/yugahashimoto/andcode/runtime/local/LocalOpenCodeBackendTest.kt +++ b/app/src/test/java/com/yugahashimoto/andcode/runtime/local/LocalOpenCodeBackendTest.kt @@ -1,5 +1,6 @@ package com.yugahashimoto.andcode.runtime.local +import com.yugahashimoto.andcode.core.api.OpenCodeApiClient import com.yugahashimoto.andcode.data.connection.ConnectionProfile import com.yugahashimoto.andcode.runtime.remote.RemoteOpenCodeBackend import kotlinx.coroutines.runBlocking @@ -65,9 +66,8 @@ class LocalOpenCodeBackendTest { LocalOpenCodeBackend( portProvider = { server.port }, backendFactory = { profile -> - RemoteOpenCodeBackend( - profile.copy(baseUrl = server.url("/").toString()), - ) + val pinned = profile.copy(baseUrl = server.url("/").toString()) + RemoteOpenCodeBackend(pinned, OpenCodeApiClient(pinned)) }, ) @@ -114,9 +114,8 @@ class LocalOpenCodeBackendTest { portProvider = { server.port }, passwordProvider = { "local-server-secret" }, backendFactory = { profile -> - RemoteOpenCodeBackend( - profile.copy(baseUrl = server.url("/").toString()), - ) + val pinned = profile.copy(baseUrl = server.url("/").toString()) + RemoteOpenCodeBackend(pinned, OpenCodeApiClient(pinned)) }, ) diff --git a/app/src/test/java/com/yugahashimoto/andcode/runtime/remote/RemoteOpenCodeBackendTest.kt b/app/src/test/java/com/yugahashimoto/andcode/runtime/remote/RemoteOpenCodeBackendTest.kt index c3272837f..071588a5a 100644 --- a/app/src/test/java/com/yugahashimoto/andcode/runtime/remote/RemoteOpenCodeBackendTest.kt +++ b/app/src/test/java/com/yugahashimoto/andcode/runtime/remote/RemoteOpenCodeBackendTest.kt @@ -1,5 +1,6 @@ package com.yugahashimoto.andcode.runtime.remote +import com.yugahashimoto.andcode.core.api.OpenCodeApiClient import com.yugahashimoto.andcode.core.api.PromptRequest import com.yugahashimoto.andcode.data.connection.ConnectionProfile import com.yugahashimoto.andcode.runtime.PermissionResponse @@ -34,17 +35,18 @@ class RemoteOpenCodeBackendTest { server.enqueue(MockResponse().setResponseCode(204)) server.enqueue(MockResponse().setBody("true")) - val backend = - RemoteOpenCodeBackend( - ConnectionProfile( - id = "mac", - name = "Mac mini", - baseUrl = server.url("/").toString(), - username = "opencode", - password = "pw", - allowInsecureLan = true, - ), + val profile = + ConnectionProfile( + id = "mac", + name = "Mac mini", + baseUrl = server.url("/").toString(), + username = "opencode", + password = "pw", + allowInsecureLan = true, ) + // Pinned to the V1 client: these queued responses are V1 routes, and auto-detection would + // spend the first one on the `api/info` probe. + val backend = RemoteOpenCodeBackend(profile, OpenCodeApiClient(profile)) assertEquals("1.17.20", backend.health().version) val session = backend.createSession("Mobile") @@ -65,17 +67,16 @@ class RemoteOpenCodeBackendTest { runBlocking { server.enqueue(MockResponse().setBody("true")) - val backend = - RemoteOpenCodeBackend( - ConnectionProfile( - id = "mac", - name = "Mac mini", - baseUrl = server.url("/").toString(), - username = "opencode", - password = "pw", - allowInsecureLan = true, - ), + val profile = + ConnectionProfile( + id = "mac", + name = "Mac mini", + baseUrl = server.url("/").toString(), + username = "opencode", + password = "pw", + allowInsecureLan = true, ) + val backend = RemoteOpenCodeBackend(profile, OpenCodeApiClient(profile)) assertTrue(backend.answerQuestion("q-1", listOf(listOf("src"), listOf("docs", "tests")), "/workspace/repo")) diff --git a/app/src/test/resources/opencode-v2-events/execution_failed.jsonl b/app/src/test/resources/opencode-v2-events/execution_failed.jsonl new file mode 100644 index 000000000..8fea13702 --- /dev/null +++ b/app/src/test/resources/opencode-v2-events/execution_failed.jsonl @@ -0,0 +1,8 @@ +{"id":"evt_13f16264d0010Za8tbc2Esgd1A","type":"server.connected","data":{}} +{"id":"evt_13f162a460017uCuXwEDkAqYCE","created":1792059779654,"type":"session.inbox.enqueued","location":{"directory":"/tmp/opencode/v2contract/srv/work"},"data":{"inboxID":"msg_13f162a43001jRtQf3FxDGcvj4","sessionID":"ses_ec0e9d9daffeZbW7wReVzRsWUp","item":{"type":"user","payload":{"text":"Describe the attached image in one word.","files":[{"data":"iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAAAF0lEQVR4nGP4z8BAEiJN9aiGUQ1DSgMAkPn/Afnh+ngAAAAASUVORK5CYII=","mime":"image/png","source":{"type":"uri","uri":"file:///tmp/opencode/v2contract/srv/work/red16.png"},"name":"red16.png","mention":{"start":0,"end":0,"text":"@red16.png"}}]},"delivery":"steer"}},"durable":{"aggregateID":"ses_ec0e9d9daffeZbW7wReVzRsWUp","seq":1,"version":1}} +{"id":"evt_13f162a480012kZdL1Aq1B7b7g","created":1792059779656,"type":"session.execution.started","data":{"sessionID":"ses_ec0e9d9daffeZbW7wReVzRsWUp"},"durable":{"aggregateID":"ses_ec0e9d9daffeZbW7wReVzRsWUp","seq":2,"version":1}} +{"id":"evt_13f162a550016S9ClvVAZDgV5M","created":1792059779669,"metadata":{"instructions":{"initial":true}},"type":"session.instructions.updated","location":{"directory":"/tmp/opencode/v2contract/srv/work"},"data":{"sessionID":"ses_ec0e9d9daffeZbW7wReVzRsWUp","delta":{"core/codemode":"e6428fd7742161f6c3a6c86e53cbbe2c6e4dcc462bbf1a1a07bc4fcbeb9e0b0b","core/skill-guidance":"8394362d28c82fa4d13c7a9f5568c05680f6687e07db8f426a0e3c92f3027dc9","core/date":"48132497c246933e1d249a09a5e47370df18972b0c1d92c44d41fc82b9993096","core/environment":"f1f3d86cb898fbccfe9df6fb2daa89c200526fbb66c9d863c9516464da61b705"}},"durable":{"aggregateID":"ses_ec0e9d9daffeZbW7wReVzRsWUp","seq":3,"version":2}} +{"id":"evt_13f162a57001qsbnjtfdkD9pOp","created":1792059779671,"type":"session.inbox.delivered","location":{"directory":"/tmp/opencode/v2contract/srv/work"},"data":{"sessionID":"ses_ec0e9d9daffeZbW7wReVzRsWUp","inboxID":"msg_13f162a43001jRtQf3FxDGcvj4"},"durable":{"aggregateID":"ses_ec0e9d9daffeZbW7wReVzRsWUp","seq":4,"version":1}} +{"id":"evt_13f162e10001til5lpq0K868rT","created":1792059780624,"type":"session.step.started","location":{"directory":"/tmp/opencode/v2contract/srv/work"},"data":{"sessionID":"ses_ec0e9d9daffeZbW7wReVzRsWUp","agent":"build","model":{"id":"longcat-2.5-preview-free","providerID":"opencode","variant":"default"},"assistantMessageID":"msg_13f162a5c001TPmshXTmXrp2M3","snapshot":"5f8b5d95a6c05e9ae0b11e243f18fe2796bacfa4","started":1792059779683},"durable":{"aggregateID":"ses_ec0e9d9daffeZbW7wReVzRsWUp","seq":5,"version":1}} +{"id":"evt_13f162e16001mbU8iTJIsPP5ZN","created":1792059780630,"type":"session.step.failed","location":{"directory":"/tmp/opencode/v2contract/srv/work"},"data":{"sessionID":"ses_ec0e9d9daffeZbW7wReVzRsWUp","assistantMessageID":"msg_13f162a5c001TPmshXTmXrp2M3","error":{"type":"provider.invalid-request","message":"Upstream request failed: [invalid_parameter] Image too small (area=256px), minimum required area: >256px","status":400,"response":{"body":"{\"error\":{\"type\":\"invalid_request_error\",\"message\":\"Upstream request failed: [invalid_parameter] Image too small (area=256px), minimum required area: >256px\"}}"}},"snapshot":"5f8b5d95a6c05e9ae0b11e243f18fe2796bacfa4","files":[]},"durable":{"aggregateID":"ses_ec0e9d9daffeZbW7wReVzRsWUp","seq":6,"version":1}} +{"id":"evt_13f162e180018T80l2u4kTm8fi","created":1792059780632,"type":"session.execution.failed","data":{"sessionID":"ses_ec0e9d9daffeZbW7wReVzRsWUp","error":{"type":"provider.invalid-request","message":"Upstream request failed: [invalid_parameter] Image too small (area=256px), minimum required area: >256px","status":400,"response":{"body":"{\"error\":{\"type\":\"invalid_request_error\",\"message\":\"Upstream request failed: [invalid_parameter] Image too small (area=256px), minimum required area: >256px\"}}"}}},"durable":{"aggregateID":"ses_ec0e9d9daffeZbW7wReVzRsWUp","seq":7,"version":1}} diff --git a/app/src/test/resources/opencode-v2-events/form.jsonl b/app/src/test/resources/opencode-v2-events/form.jsonl new file mode 100644 index 000000000..038f2d61d --- /dev/null +++ b/app/src/test/resources/opencode-v2-events/form.jsonl @@ -0,0 +1,13 @@ +{"id":"evt_13f1373f20018DVP2XNJWz2OTJ","type":"server.connected","data":{}} +{"id":"evt_13f1377e5001kZ9UvTUaGJAxE7","created":1792059602917,"type":"session.inbox.enqueued","location":{"directory":"/tmp/opencode/v2contract/srv/work"},"data":{"inboxID":"msg_13f1377e4001wixCYkGWqukhnI","sessionID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","item":{"type":"user","payload":{"text":"Use the question tool to ask me which color I prefer, with options red and blue. Wait for my answer, then reply DONE."},"delivery":"steer"}},"durable":{"aggregateID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","seq":44,"version":1}} +{"id":"evt_13f1377e8001jtVIV8XSTpIOMq","created":1792059602920,"type":"session.execution.started","data":{"sessionID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP"},"durable":{"aggregateID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","seq":45,"version":1}} +{"id":"evt_13f1377f20015XMhGejxRF4wUn","created":1792059602930,"type":"session.inbox.delivered","location":{"directory":"/tmp/opencode/v2contract/srv/work"},"data":{"sessionID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","inboxID":"msg_13f1377e4001wixCYkGWqukhnI"},"durable":{"aggregateID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","seq":46,"version":1}} +{"id":"evt_13f137ac3001piwmvUZdQxsQs8","created":1792059603651,"type":"session.step.started","location":{"directory":"/tmp/opencode/v2contract/srv/work"},"data":{"sessionID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","agent":"build","model":{"id":"cat-jam-free","providerID":"opencode"},"assistantMessageID":"msg_13f1377fa001e22issMLuEVyUJ","snapshot":"853694aae8816094a0d875fee7ea26278dbf5d0f","started":1792059602951},"durable":{"aggregateID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","seq":47,"version":1}} +{"id":"evt_13f137ac6001UqySKvPR2U0uBt","created":1792059603654,"type":"session.reasoning.started","location":{"directory":"/tmp/opencode/v2contract/srv/work"},"data":{"sessionID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","assistantMessageID":"msg_13f1377fa001e22issMLuEVyUJ","ordinal":0,"state":{"reasoningField":"reasoning_content"}},"durable":{"aggregateID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","seq":48,"version":1}} +{"id":"evt_13f137b2c001BMfveW0QhsKSBa","created":1792059603756,"type":"session.reasoning.delta","location":{"directory":"/tmp/opencode/v2contract/srv/work"},"data":{"sessionID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","assistantMessageID":"msg_13f1377fa001e22issMLuEVyUJ","ordinal":0,"delta":"The user wants me to use the question tool to ask which color they prefer, with options red and blue. Then wait for answer, then reply DONE. Let me invoke the question tool."}} +{"id":"evt_13f137b38001lMmdYYyV3rrMkW","created":1792059603768,"type":"session.tool.input.started","location":{"directory":"/tmp/opencode/v2contract/srv/work"},"data":{"sessionID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","assistantMessageID":"msg_13f1377fa001e22issMLuEVyUJ","id":"call-f2f3e1a5-f73d-433d-9b5b-84077d216e14","name":"question"},"durable":{"aggregateID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","seq":49,"version":1}} +{"id":"evt_13f137bc0001E9vZyLr3v6FVhT","created":1792059603904,"type":"session.reasoning.ended","location":{"directory":"/tmp/opencode/v2contract/srv/work"},"data":{"sessionID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","assistantMessageID":"msg_13f1377fa001e22issMLuEVyUJ","ordinal":0,"text":"The user wants me to use the question tool to ask which color they prefer, with options red and blue. Then wait for answer, then reply DONE. Let me invoke the question tool.","state":{"reasoningField":"reasoning_content"}},"durable":{"aggregateID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","seq":50,"version":1}} +{"id":"evt_13f137bc2001Jo6sYeQw2Ynh0X","created":1792059603906,"type":"session.tool.input.ended","location":{"directory":"/tmp/opencode/v2contract/srv/work"},"data":{"sessionID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","assistantMessageID":"msg_13f1377fa001e22issMLuEVyUJ","id":"call-f2f3e1a5-f73d-433d-9b5b-84077d216e14","text":"{\"questions\": [{\"question\": \"Which color do you prefer?\", \"header\": \"Color preference\", \"options\": [{\"label\": \"Red\", \"description\": \"The color red\"}, {\"label\": \"Blue\", \"description\": \"The color blue\"}]}]}"},"durable":{"aggregateID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","seq":51,"version":1}} +{"id":"evt_13f137bc4001xiz1x8jkTCGUHR","created":1792059603908,"type":"session.tool.called","location":{"directory":"/tmp/opencode/v2contract/srv/work"},"data":{"sessionID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","assistantMessageID":"msg_13f1377fa001e22issMLuEVyUJ","id":"call-f2f3e1a5-f73d-433d-9b5b-84077d216e14","input":{"questions":[{"question":"Which color do you prefer?","header":"Color preference","options":[{"label":"Red","description":"The color red"},{"label":"Blue","description":"The color blue"}]}]},"executed":false},"durable":{"aggregateID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","seq":52,"version":1}} +{"id":"evt_13f137bc60014pk3plAJGqlHaM","created":1792059603910,"type":"session.step.streamed","location":{"directory":"/tmp/opencode/v2contract/srv/work"},"data":{"sessionID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","assistantMessageID":"msg_13f1377fa001e22issMLuEVyUJ"},"durable":{"aggregateID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","seq":53,"version":1}} +{"id":"evt_13f137bc9002Xg15laSqQR9p0x","created":1792059603913,"type":"form.created","location":{"directory":"/tmp/opencode/v2contract/srv/work"},"data":{"form":{"id":"frm_13f137bc90017JjU2piWI5BVi6","sessionID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","title":"Questions","metadata":{"kind":"question","tool":{"messageID":"msg_13f1377fa001e22issMLuEVyUJ","id":"call-f2f3e1a5-f73d-433d-9b5b-84077d216e14"}},"fields":[{"key":"q0","title":"Color preference","description":"Which color do you prefer?","type":"string","options":[{"value":"Red","label":"Red","description":"The color red"},{"value":"Blue","label":"Blue","description":"The color blue"}],"custom":true}]}}} diff --git a/app/src/test/resources/opencode-v2-events/interrupted.jsonl b/app/src/test/resources/opencode-v2-events/interrupted.jsonl new file mode 100644 index 000000000..be5a71ed3 --- /dev/null +++ b/app/src/test/resources/opencode-v2-events/interrupted.jsonl @@ -0,0 +1,9 @@ +{"id":"evt_13f13fbfe001cWz0Dp4EQDubeD","type":"server.connected","data":{}} +{"id":"evt_13f13fff70017eaq4elh5MzEUS","created":1792059637751,"type":"session.inbox.enqueued","location":{"directory":"/tmp/opencode/v2contract/srv/work"},"data":{"inboxID":"msg_13f13fff6001qllW8421DzIn4f","sessionID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","item":{"type":"user","payload":{"text":"Write a long essay of at least 800 words about the history of the Unix shell. Do not use tools."},"delivery":"steer"}},"durable":{"aggregateID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","seq":73,"version":1}} +{"id":"evt_13f13fff9001QocCue5f2MBRIw","created":1792059637753,"type":"session.execution.started","data":{"sessionID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP"},"durable":{"aggregateID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","seq":74,"version":1}} +{"id":"evt_13f13fffd001QXezBrwacS4sxa","created":1792059637757,"type":"session.inbox.delivered","location":{"directory":"/tmp/opencode/v2contract/srv/work"},"data":{"sessionID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","inboxID":"msg_13f13ec0b001lM8j7gcBG1FLsw"},"durable":{"aggregateID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","seq":75,"version":1}} +{"id":"evt_13f13fffd002irr6ByJdWIiTZ5","created":1792059637757,"type":"session.compaction.started","location":{"directory":"/tmp/opencode/v2contract/srv/work"},"data":{"sessionID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","reason":"manual","recent":"","inputID":"msg_13f13ec0b001lM8j7gcBG1FLsw"},"durable":{"aggregateID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","seq":76,"version":1}} +{"id":"evt_13f140f64001QqGbB93L93n52G","created":1792059641700,"type":"session.compaction.delta","location":{"directory":"/tmp/opencode/v2contract/srv/work"},"data":{"sessionID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","text":"## Objective"}} +{"id":"evt_13f140f980018uN7sZAkCBBbpZ","created":1792059641752,"type":"session.compaction.delta","location":{"directory":"/tmp/opencode/v2contract/srv/work"},"data":{"sessionID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","text":"\n- Simple task"}} +{"id":"evt_13f140fb5001Nxe7zDbr2le6Oj","created":1792059641781,"type":"session.compaction.failed","location":{"directory":"/tmp/opencode/v2contract/srv/work"},"data":{"sessionID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","reason":"manual","error":{"type":"aborted","message":"Compaction cancelled"},"inputID":"msg_13f13ec0b001lM8j7gcBG1FLsw"},"durable":{"aggregateID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","seq":77,"version":1}} +{"id":"evt_13f140fb8001JBS1N4Cuf930ys","created":1792059641784,"type":"session.execution.interrupted","data":{"sessionID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","reason":"user"},"durable":{"aggregateID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","seq":78,"version":1}} diff --git a/app/src/test/resources/opencode-v2-events/permission.jsonl b/app/src/test/resources/opencode-v2-events/permission.jsonl new file mode 100644 index 000000000..98c6f8e7f --- /dev/null +++ b/app/src/test/resources/opencode-v2-events/permission.jsonl @@ -0,0 +1,32 @@ +{"id":"evt_13f132b06001ygn2hvdKoVlte4","type":"server.connected","data":{}} +{"id":"evt_13f132efb00138WIO32fZt8Y5r","created":1792059584251,"type":"session.inbox.enqueued","location":{"directory":"/tmp/opencode/v2contract/srv/work"},"data":{"inboxID":"msg_13f132efa001flEP2hpHyW2Pqn","sessionID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","item":{"type":"user","payload":{"text":"Run `pwd` using the bash tool, then reply DONE."},"delivery":"steer"}},"durable":{"aggregateID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","seq":24,"version":1}} +{"id":"evt_13f132efe0013ig1Y36QBqQ8Pn","created":1792059584254,"type":"project.updated","location":{"directory":"/tmp/opencode/v2contract/srv/work"},"data":{"id":"global","canonical":"/tmp/opencode/v2contract/srv/work","vcs":"git","time":{"created":1792059522101,"updated":1792059522101,"active":1792059584254},"sandboxes":[]}} +{"id":"evt_13f132efe002zMambfPVYAWOXx","created":1792059584254,"type":"session.execution.started","data":{"sessionID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP"},"durable":{"aggregateID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","seq":25,"version":1}} +{"id":"evt_13f132f08001HRw6z0QLs7uGhl","created":1792059584264,"type":"session.inbox.delivered","location":{"directory":"/tmp/opencode/v2contract/srv/work"},"data":{"sessionID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","inboxID":"msg_13f132efa001flEP2hpHyW2Pqn"},"durable":{"aggregateID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","seq":26,"version":1}} +{"id":"evt_13f13322a001ikdgeIDcTwyaHm","created":1792059585066,"type":"session.step.started","location":{"directory":"/tmp/opencode/v2contract/srv/work"},"data":{"sessionID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","agent":"build","model":{"id":"cat-jam-free","providerID":"opencode"},"assistantMessageID":"msg_13f132f0c001VdmLdxoKtXlaM2","snapshot":"853694aae8816094a0d875fee7ea26278dbf5d0f","started":1792059584275},"durable":{"aggregateID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","seq":27,"version":1}} +{"id":"evt_13f13322d001ZXgWdJ9E0ma3bo","created":1792059585069,"type":"session.reasoning.started","location":{"directory":"/tmp/opencode/v2contract/srv/work"},"data":{"sessionID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","assistantMessageID":"msg_13f132f0c001VdmLdxoKtXlaM2","ordinal":0,"state":{"reasoningField":"reasoning_content"}},"durable":{"aggregateID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","seq":28,"version":1}} +{"id":"evt_13f13325600141uVrTNoEVfcqj","created":1792059585110,"type":"session.reasoning.delta","location":{"directory":"/tmp/opencode/v2contract/srv/work"},"data":{"sessionID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","assistantMessageID":"msg_13f132f0c001VdmLdxoKtXlaM2","ordinal":0,"delta":"Simple task: run pwd using bash tool, then reply DONE."}} +{"id":"evt_13f1332570016OnyRqBMBLli1W","created":1792059585111,"type":"session.tool.input.started","location":{"directory":"/tmp/opencode/v2contract/srv/work"},"data":{"sessionID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","assistantMessageID":"msg_13f132f0c001VdmLdxoKtXlaM2","id":"call-a60a53b9-f071-4a6b-aa5a-9d4cff48148e","name":"shell"},"durable":{"aggregateID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","seq":29,"version":1}} +{"id":"evt_13f133270001U96dafhFUF2J3G","created":1792059585136,"type":"session.reasoning.ended","location":{"directory":"/tmp/opencode/v2contract/srv/work"},"data":{"sessionID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","assistantMessageID":"msg_13f132f0c001VdmLdxoKtXlaM2","ordinal":0,"text":"Simple task: run pwd using bash tool, then reply DONE.","state":{"reasoningField":"reasoning_content"}},"durable":{"aggregateID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","seq":30,"version":1}} +{"id":"evt_13f133272001Xo3gnxtJek87VD","created":1792059585138,"type":"session.tool.input.ended","location":{"directory":"/tmp/opencode/v2contract/srv/work"},"data":{"sessionID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","assistantMessageID":"msg_13f132f0c001VdmLdxoKtXlaM2","id":"call-a60a53b9-f071-4a6b-aa5a-9d4cff48148e","text":"{\"command\": \"pwd\"}"},"durable":{"aggregateID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","seq":31,"version":1}} +{"id":"evt_13f1332730016rf76UNI3Ht068","created":1792059585139,"type":"session.tool.called","location":{"directory":"/tmp/opencode/v2contract/srv/work"},"data":{"sessionID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","assistantMessageID":"msg_13f132f0c001VdmLdxoKtXlaM2","id":"call-a60a53b9-f071-4a6b-aa5a-9d4cff48148e","input":{"command":"pwd"},"executed":false},"durable":{"aggregateID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","seq":32,"version":1}} +{"id":"evt_13f133275001QxwPIzVvr6WVD0","created":1792059585141,"type":"session.step.streamed","location":{"directory":"/tmp/opencode/v2contract/srv/work"},"data":{"sessionID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","assistantMessageID":"msg_13f132f0c001VdmLdxoKtXlaM2"},"durable":{"aggregateID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","seq":33,"version":1}} +{"id":"evt_13f133279002iv1Xs51j64brgJ","created":1792059585145,"type":"permission.asked","location":{"directory":"/tmp/opencode/v2contract/srv/work"},"data":{"id":"per_13f133279001Ah85Mwz5YJSy1r","sessionID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","action":"shell","resources":["pwd"],"save":["pwd *"],"source":{"type":"tool","messageID":"msg_13f132f0c001VdmLdxoKtXlaM2","id":"call-a60a53b9-f071-4a6b-aa5a-9d4cff48148e"}}} +{"id":"evt_13f134d37001ShHoi8RcHFOvoX","created":1792059591991,"type":"permission.replied","location":{"directory":"/tmp/opencode/v2contract/srv/work"},"data":{"sessionID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","requestID":"per_13f133279001Ah85Mwz5YJSy1r","reply":"once"}} +{"id":"evt_13f134d3d001irzHGM9YMPBZHG","created":1792059591997,"type":"shell.created","location":{"directory":"/tmp/opencode/v2contract/srv/work"},"data":{"info":{"id":"sh_13f134d390018zA2ITnDWm6TX8","status":"running","command":"pwd","cwd":"/tmp/opencode/v2contract/srv/work","shell":"/bin/bash","file":"/tmp/opencode/v2contract/srv/data/opencode/shell/global/sh_13f134d390018zA2ITnDWm6TX8.out","metadata":{"sessionID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP"},"time":{"started":1792059591993}}}} +{"id":"evt_13f134d3e001VRIyzWIySmURjJ","created":1792059591998,"type":"session.tool.progress","location":{"directory":"/tmp/opencode/v2contract/srv/work"},"data":{"sessionID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","assistantMessageID":"msg_13f132f0c001VdmLdxoKtXlaM2","id":"call-a60a53b9-f071-4a6b-aa5a-9d4cff48148e","metadata":{"shellID":"sh_13f134d390018zA2ITnDWm6TX8"}}} +{"id":"evt_13f134d44001IlLgWUaH0tc3qG","created":1792059592004,"type":"shell.exited","location":{"directory":"/tmp/opencode/v2contract/srv/work"},"data":{"id":"sh_13f134d390018zA2ITnDWm6TX8","exit":0,"status":"exited"}} +{"id":"evt_13f134d47001X6zENDCsiFYZ8F","created":1792059592007,"type":"session.tool.success","location":{"directory":"/tmp/opencode/v2contract/srv/work"},"data":{"sessionID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","assistantMessageID":"msg_13f132f0c001VdmLdxoKtXlaM2","id":"call-a60a53b9-f071-4a6b-aa5a-9d4cff48148e","content":[{"type":"text","text":"/tmp/opencode/v2contract/srv/work\n"}],"metadata":{"status":"completed","truncated":false,"exit":0},"executed":false},"durable":{"aggregateID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","seq":34,"version":2}} +{"id":"evt_13f134d57001FRigAAwv5CD4ev","created":1792059592023,"type":"session.step.ended","location":{"directory":"/tmp/opencode/v2contract/srv/work"},"data":{"sessionID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","assistantMessageID":"msg_13f132f0c001VdmLdxoKtXlaM2","finish":"tool-calls","rawFinish":"tool_calls","cost":0,"tokens":{"input":88,"output":13,"reasoning":21,"cache":{"read":4672,"write":0}},"snapshot":"853694aae8816094a0d875fee7ea26278dbf5d0f","files":[]},"durable":{"aggregateID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","seq":35,"version":1}} +{"id":"evt_13f134d5b001glcofEgk5SU0xY","created":1792059592027,"type":"session.usage.updated","data":{"sessionID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","cost":0,"tokens":{"input":2600,"output":37,"reasoning":76,"cache":{"read":11520,"write":0}}}} +{"id":"evt_13f13505f001g8QMIhwRreQix1","created":1792059592799,"type":"session.step.started","location":{"directory":"/tmp/opencode/v2contract/srv/work"},"data":{"sessionID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","agent":"build","model":{"id":"cat-jam-free","providerID":"opencode"},"assistantMessageID":"msg_13f134d6a001yl87y4VpQ3lZ0v","snapshot":"853694aae8816094a0d875fee7ea26278dbf5d0f","started":1792059592060},"durable":{"aggregateID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","seq":36,"version":1}} +{"id":"evt_13f1350620014MHX6cBtTCiomy","created":1792059592802,"type":"session.reasoning.started","location":{"directory":"/tmp/opencode/v2contract/srv/work"},"data":{"sessionID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","assistantMessageID":"msg_13f134d6a001yl87y4VpQ3lZ0v","ordinal":0,"state":{"reasoningField":"reasoning_content"}},"durable":{"aggregateID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","seq":37,"version":1}} +{"id":"evt_13f1350720019WG3iYYR7LJo2P","created":1792059592818,"type":"session.reasoning.delta","location":{"directory":"/tmp/opencode/v2contract/srv/work"},"data":{"sessionID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","assistantMessageID":"msg_13f134d6a001yl87y4VpQ3lZ0v","ordinal":0,"delta":"Now reply DONE."}} +{"id":"evt_13f135072002EznWYBazarhP5g","created":1792059592818,"type":"session.text.started","location":{"directory":"/tmp/opencode/v2contract/srv/work"},"data":{"sessionID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","assistantMessageID":"msg_13f134d6a001yl87y4VpQ3lZ0v","ordinal":0},"durable":{"aggregateID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","seq":38,"version":1}} +{"id":"evt_13f135075001KejSELFMZgErFV","created":1792059592821,"type":"session.reasoning.ended","location":{"directory":"/tmp/opencode/v2contract/srv/work"},"data":{"sessionID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","assistantMessageID":"msg_13f134d6a001yl87y4VpQ3lZ0v","ordinal":0,"text":"Now reply DONE.","state":{"reasoningField":"reasoning_content"}},"durable":{"aggregateID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","seq":39,"version":1}} +{"id":"evt_13f135077001uOjPUuPE31Hq4S","created":1792059592823,"type":"session.text.delta","location":{"directory":"/tmp/opencode/v2contract/srv/work"},"data":{"sessionID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","assistantMessageID":"msg_13f134d6a001yl87y4VpQ3lZ0v","ordinal":0,"delta":"DONE"}} +{"id":"evt_13f135077002N6LnNauyxPQ66r","created":1792059592823,"type":"session.text.ended","location":{"directory":"/tmp/opencode/v2contract/srv/work"},"data":{"sessionID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","assistantMessageID":"msg_13f134d6a001yl87y4VpQ3lZ0v","ordinal":0,"text":"DONE"},"durable":{"aggregateID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","seq":40,"version":1}} +{"id":"evt_13f135079001iok72RGaITFjTa","created":1792059592825,"type":"session.step.streamed","location":{"directory":"/tmp/opencode/v2contract/srv/work"},"data":{"sessionID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","assistantMessageID":"msg_13f134d6a001yl87y4VpQ3lZ0v"},"durable":{"aggregateID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","seq":41,"version":1}} +{"id":"evt_13f135080001XENTSS9vcjwL3G","created":1792059592833,"type":"session.step.ended","location":{"directory":"/tmp/opencode/v2contract/srv/work"},"data":{"sessionID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","assistantMessageID":"msg_13f134d6a001yl87y4VpQ3lZ0v","finish":"stop","rawFinish":"stop","cost":0,"tokens":{"input":79,"output":3,"reasoning":12,"cache":{"read":4736,"write":0}},"snapshot":"853694aae8816094a0d875fee7ea26278dbf5d0f","files":[]},"durable":{"aggregateID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","seq":42,"version":1}} +{"id":"evt_13f1350830017yOpP0av8ASG0f","created":1792059592835,"type":"session.usage.updated","data":{"sessionID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","cost":0,"tokens":{"input":2679,"output":40,"reasoning":88,"cache":{"read":16256,"write":0}}}} +{"id":"evt_13f135083002hFeoaTcvgdaIvf","created":1792059592835,"type":"session.execution.succeeded","data":{"sessionID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP"},"durable":{"aggregateID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","seq":43,"version":1}} diff --git a/app/src/test/resources/opencode-v2-events/session_created.jsonl b/app/src/test/resources/opencode-v2-events/session_created.jsonl new file mode 100644 index 000000000..b174d72b3 --- /dev/null +++ b/app/src/test/resources/opencode-v2-events/session_created.jsonl @@ -0,0 +1,3 @@ +{"id":"evt_13f16de50001dKdrt6XJ0apo9S","created":1792059825744,"type":"session.created","location":{"directory":"/tmp/opencode/v2contract/srv/work"},"data":{"sessionID":"ses_ec0e921bcffeiCIh4PAAL3RtAt","slug":"witty-orchid","version":"2.0.28","projectID":"global","location":{"directory":"/tmp/opencode/v2contract/srv/work"},"subpath":"","title":"to-fork-src"},"durable":{"aggregateID":"ses_ec0e921bcffeiCIh4PAAL3RtAt","seq":0,"version":1}} +{"id":"evt_13f16de35001EbIOlrApx7ftXM","created":1792059825717,"type":"session.forked","location":{"directory":"/tmp/opencode/v2contract/srv/work"},"data":{"sessionID":"ses_ec0e921ccffeIhcMV8aRX0rZ30","parentID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","boundary":{"type":"through","messageID":"msg_13f16de27001TZLc3c4e2fR1aj"},"instructions":{"core/date":"48132497c246933e1d249a09a5e47370df18972b0c1d92c44d41fc82b9993096","core/environment":"f1f3d86cb898fbccfe9df6fb2daa89c200526fbb66c9d863c9516464da61b705"},"instructionEntries":[]},"durable":{"aggregateID":"ses_ec0e921ccffeIhcMV8aRX0rZ30","seq":0,"version":2}} +{"id":"evt_13f1701ac002XMHkVCGyPlF98C","created":1792059834796,"type":"session.forked","location":{"directory":"/tmp/opencode/v2contract/srv/work"},"data":{"sessionID":"ses_ec0e8fe53ffeaO79myJzu4Cmx6","parentID":"ses_ec0e921bcffeiCIh4PAAL3RtAt","boundary":{"type":"through","messageID":"msg_13f16e401001T3YRIFdJns3JVP"},"instructions":{"core/codemode":"e6428fd7742161f6c3a6c86e53cbbe2c6e4dcc462bbf1a1a07bc4fcbeb9e0b0b","core/skill-guidance":"8394362d28c82fa4d13c7a9f5568c05680f6687e07db8f426a0e3c92f3027dc9","core/date":"48132497c246933e1d249a09a5e47370df18972b0c1d92c44d41fc82b9993096","core/environment":"f1f3d86cb898fbccfe9df6fb2daa89c200526fbb66c9d863c9516464da61b705"},"instructionEntries":[]},"durable":{"aggregateID":"ses_ec0e8fe53ffeaO79myJzu4Cmx6","seq":0,"version":2}} diff --git a/app/src/test/resources/opencode-v2-events/text_tools.jsonl b/app/src/test/resources/opencode-v2-events/text_tools.jsonl new file mode 100644 index 000000000..f7b285224 --- /dev/null +++ b/app/src/test/resources/opencode-v2-events/text_tools.jsonl @@ -0,0 +1,30 @@ +{"id":"evt_13f12d073001AHeR1mXWiZt4Yk","type":"server.connected","data":{}} +{"id":"evt_13f12d46a001WwfXGFjXDLX626","created":1792059561066,"type":"session.inbox.enqueued","location":{"directory":"/tmp/opencode/v2contract/srv/work"},"data":{"inboxID":"msg_13f12d4670015wn5VajrrCi0bI","sessionID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","item":{"type":"user","payload":{"text":"Run `ls` using the bash tool, then reply with the single word DONE."},"delivery":"steer"}},"durable":{"aggregateID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","seq":3,"version":1}} +{"id":"evt_13f12d46d001salHzlurleu1Fn","created":1792059561069,"type":"session.execution.started","data":{"sessionID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP"},"durable":{"aggregateID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","seq":4,"version":1}} +{"id":"evt_13f12d479001NPJvt59E4c3FDV","created":1792059561081,"metadata":{"instructions":{"initial":true}},"type":"session.instructions.updated","location":{"directory":"/tmp/opencode/v2contract/srv/work"},"data":{"sessionID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","delta":{"core/codemode":"e6428fd7742161f6c3a6c86e53cbbe2c6e4dcc462bbf1a1a07bc4fcbeb9e0b0b","core/skill-guidance":"8394362d28c82fa4d13c7a9f5568c05680f6687e07db8f426a0e3c92f3027dc9","core/date":"48132497c246933e1d249a09a5e47370df18972b0c1d92c44d41fc82b9993096","core/environment":"f1f3d86cb898fbccfe9df6fb2daa89c200526fbb66c9d863c9516464da61b705"}},"durable":{"aggregateID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","seq":5,"version":2}} +{"id":"evt_13f12d47b001SIHmpk2VvJ80ng","created":1792059561083,"type":"session.inbox.delivered","location":{"directory":"/tmp/opencode/v2contract/srv/work"},"data":{"sessionID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","inboxID":"msg_13f12d4670015wn5VajrrCi0bI"},"durable":{"aggregateID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","seq":6,"version":1}} +{"id":"evt_13f12d86f0013J2mKj4V43v8uM","created":1792059562095,"type":"session.step.started","location":{"directory":"/tmp/opencode/v2contract/srv/work"},"data":{"sessionID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","agent":"build","model":{"id":"cat-jam-free","providerID":"opencode"},"assistantMessageID":"msg_13f12d484001x1GrZf4FsEpZFE","snapshot":"853694aae8816094a0d875fee7ea26278dbf5d0f","started":1792059561101},"durable":{"aggregateID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","seq":7,"version":1}} +{"id":"evt_13f12d872001jPX9K7h184iYFi","created":1792059562098,"type":"session.reasoning.started","location":{"directory":"/tmp/opencode/v2contract/srv/work"},"data":{"sessionID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","assistantMessageID":"msg_13f12d484001x1GrZf4FsEpZFE","ordinal":0,"state":{"reasoningField":"reasoning_content"}},"durable":{"aggregateID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","seq":8,"version":1}} +{"id":"evt_13f12d8a8001OBK5tcUZGZxsoA","created":1792059562152,"type":"session.reasoning.delta","location":{"directory":"/tmp/opencode/v2contract/srv/work"},"data":{"sessionID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","assistantMessageID":"msg_13f12d484001x1GrZf4FsEpZFE","ordinal":0,"delta":"The user wants me to run `ls` using the bash tool (shell), then reply with the single word DONE. Let me do that."}} +{"id":"evt_13f12d8a80024CK14O38LkhAz7","created":1792059562152,"type":"session.tool.input.started","location":{"directory":"/tmp/opencode/v2contract/srv/work"},"data":{"sessionID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","assistantMessageID":"msg_13f12d484001x1GrZf4FsEpZFE","id":"call-e449b4e6-9d1e-499b-9677-454de839b8ad","name":"shell"},"durable":{"aggregateID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","seq":9,"version":1}} +{"id":"evt_13f12d8ad001m8OQvHn1JEOMNQ","created":1792059562157,"type":"session.reasoning.ended","location":{"directory":"/tmp/opencode/v2contract/srv/work"},"data":{"sessionID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","assistantMessageID":"msg_13f12d484001x1GrZf4FsEpZFE","ordinal":0,"text":"The user wants me to run `ls` using the bash tool (shell), then reply with the single word DONE. Let me do that.","state":{"reasoningField":"reasoning_content"}},"durable":{"aggregateID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","seq":10,"version":1}} +{"id":"evt_13f12d8af001NHpEa1beZpFg3d","created":1792059562159,"type":"session.tool.input.ended","location":{"directory":"/tmp/opencode/v2contract/srv/work"},"data":{"sessionID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","assistantMessageID":"msg_13f12d484001x1GrZf4FsEpZFE","id":"call-e449b4e6-9d1e-499b-9677-454de839b8ad","text":"{\"command\": \"ls\"}"},"durable":{"aggregateID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","seq":11,"version":1}} +{"id":"evt_13f12d8b1001D0a90iqA7Dp8U8","created":1792059562161,"type":"session.tool.called","location":{"directory":"/tmp/opencode/v2contract/srv/work"},"data":{"sessionID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","assistantMessageID":"msg_13f12d484001x1GrZf4FsEpZFE","id":"call-e449b4e6-9d1e-499b-9677-454de839b8ad","input":{"command":"ls"},"executed":false},"durable":{"aggregateID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","seq":12,"version":1}} +{"id":"evt_13f12d8b3001OLYTzH3cGWw3bm","created":1792059562163,"type":"session.step.streamed","location":{"directory":"/tmp/opencode/v2contract/srv/work"},"data":{"sessionID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","assistantMessageID":"msg_13f12d484001x1GrZf4FsEpZFE"},"durable":{"aggregateID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","seq":13,"version":1}} +{"id":"evt_13f12d8cd0017J7ijewtV2Izm6","created":1792059562189,"type":"shell.created","location":{"directory":"/tmp/opencode/v2contract/srv/work"},"data":{"info":{"id":"sh_13f12d8cb001gTHNrUWoM5QWwO","status":"running","command":"ls","cwd":"/tmp/opencode/v2contract/srv/work","shell":"/bin/bash","file":"/tmp/opencode/v2contract/srv/data/opencode/shell/global/sh_13f12d8cb001gTHNrUWoM5QWwO.out","metadata":{"sessionID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP"},"time":{"started":1792059562187}}}} +{"id":"evt_13f12d8ce001F8hnYUG2OqhHUv","created":1792059562190,"type":"session.tool.progress","location":{"directory":"/tmp/opencode/v2contract/srv/work"},"data":{"sessionID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","assistantMessageID":"msg_13f12d484001x1GrZf4FsEpZFE","id":"call-e449b4e6-9d1e-499b-9677-454de839b8ad","metadata":{"shellID":"sh_13f12d8cb001gTHNrUWoM5QWwO"}}} +{"id":"evt_13f12d8d2001ljnDBhV7qaab6j","created":1792059562194,"type":"shell.exited","location":{"directory":"/tmp/opencode/v2contract/srv/work"},"data":{"id":"sh_13f12d8cb001gTHNrUWoM5QWwO","exit":0,"status":"exited"}} +{"id":"evt_13f12d8d30014QrTS4qrPXYkaH","created":1792059562195,"type":"session.tool.success","location":{"directory":"/tmp/opencode/v2contract/srv/work"},"data":{"sessionID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","assistantMessageID":"msg_13f12d484001x1GrZf4FsEpZFE","id":"call-e449b4e6-9d1e-499b-9677-454de839b8ad","content":[{"type":"text","text":"README.md\n"}],"metadata":{"status":"completed","truncated":false,"exit":0},"executed":false},"durable":{"aggregateID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","seq":14,"version":2}} +{"id":"evt_13f12d8db001jlQNYJXbuwdnpE","created":1792059562203,"type":"session.step.ended","location":{"directory":"/tmp/opencode/v2contract/srv/work"},"data":{"sessionID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","assistantMessageID":"msg_13f12d484001x1GrZf4FsEpZFE","finish":"tool-calls","rawFinish":"tool_calls","cost":0,"tokens":{"input":2408,"output":15,"reasoning":35,"cache":{"read":2240,"write":0}},"snapshot":"853694aae8816094a0d875fee7ea26278dbf5d0f","files":[]},"durable":{"aggregateID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","seq":15,"version":1}} +{"id":"evt_13f12d8de0011GsUAcJTmoUVrH","created":1792059562206,"type":"session.usage.updated","data":{"sessionID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","cost":0,"tokens":{"input":2408,"output":15,"reasoning":35,"cache":{"read":2240,"write":0}}}} +{"id":"evt_13f12db9a001pN33nxQGibPvHJ","created":1792059562906,"type":"session.step.started","location":{"directory":"/tmp/opencode/v2contract/srv/work"},"data":{"sessionID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","agent":"build","model":{"id":"cat-jam-free","providerID":"opencode"},"assistantMessageID":"msg_13f12d8e7001yF1vGKVpI8umGb","snapshot":"853694aae8816094a0d875fee7ea26278dbf5d0f","started":1792059562224},"durable":{"aggregateID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","seq":16,"version":1}} +{"id":"evt_13f12db9c0019Iem2wTOibNeQr","created":1792059562908,"type":"session.reasoning.started","location":{"directory":"/tmp/opencode/v2contract/srv/work"},"data":{"sessionID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","assistantMessageID":"msg_13f12d8e7001yF1vGKVpI8umGb","ordinal":0,"state":{"reasoningField":"reasoning_content"}},"durable":{"aggregateID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","seq":17,"version":1}} +{"id":"evt_13f12dbb4001yBgwLt46dw3jQC","created":1792059562932,"type":"session.reasoning.delta","location":{"directory":"/tmp/opencode/v2contract/srv/work"},"data":{"sessionID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","assistantMessageID":"msg_13f12d8e7001yF1vGKVpI8umGb","ordinal":0,"delta":"The ls command returned README.md. Now I need to reply with the single word DONE."}} +{"id":"evt_13f12dbb40020ABUpbuycMIZtT","created":1792059562932,"type":"session.text.started","location":{"directory":"/tmp/opencode/v2contract/srv/work"},"data":{"sessionID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","assistantMessageID":"msg_13f12d8e7001yF1vGKVpI8umGb","ordinal":0},"durable":{"aggregateID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","seq":18,"version":1}} +{"id":"evt_13f12dbb8001TOn1t0hCKcApwV","created":1792059562936,"type":"session.reasoning.ended","location":{"directory":"/tmp/opencode/v2contract/srv/work"},"data":{"sessionID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","assistantMessageID":"msg_13f12d8e7001yF1vGKVpI8umGb","ordinal":0,"text":"The ls command returned README.md. Now I need to reply with the single word DONE.","state":{"reasoningField":"reasoning_content"}},"durable":{"aggregateID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","seq":19,"version":1}} +{"id":"evt_13f12dbba0016k2orpJS3qPFAq","created":1792059562938,"type":"session.text.delta","location":{"directory":"/tmp/opencode/v2contract/srv/work"},"data":{"sessionID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","assistantMessageID":"msg_13f12d8e7001yF1vGKVpI8umGb","ordinal":0,"delta":"DONE"}} +{"id":"evt_13f12dbba0024t03VXUa9HpNnX","created":1792059562938,"type":"session.text.ended","location":{"directory":"/tmp/opencode/v2contract/srv/work"},"data":{"sessionID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","assistantMessageID":"msg_13f12d8e7001yF1vGKVpI8umGb","ordinal":0,"text":"DONE"},"durable":{"aggregateID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","seq":20,"version":1}} +{"id":"evt_13f12dbbc001UqHoP9qHWTPl7z","created":1792059562940,"type":"session.step.streamed","location":{"directory":"/tmp/opencode/v2contract/srv/work"},"data":{"sessionID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","assistantMessageID":"msg_13f12d8e7001yF1vGKVpI8umGb"},"durable":{"aggregateID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","seq":21,"version":1}} +{"id":"evt_13f12dbc3001uyAKQg5ZAaphYa","created":1792059562947,"type":"session.step.ended","location":{"directory":"/tmp/opencode/v2contract/srv/work"},"data":{"sessionID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","assistantMessageID":"msg_13f12d8e7001yF1vGKVpI8umGb","finish":"stop","rawFinish":"stop","cost":0,"tokens":{"input":104,"output":9,"reasoning":20,"cache":{"read":4608,"write":0}},"snapshot":"853694aae8816094a0d875fee7ea26278dbf5d0f","files":[]},"durable":{"aggregateID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","seq":22,"version":1}} +{"id":"evt_13f12dbc5001GcmhD8C85G4wM3","created":1792059562949,"type":"session.usage.updated","data":{"sessionID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","cost":0,"tokens":{"input":2512,"output":24,"reasoning":55,"cache":{"read":6848,"write":0}}}} +{"id":"evt_13f12dbc6001nCldhbNHFFGDgD","created":1792059562950,"type":"session.execution.succeeded","data":{"sessionID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP"},"durable":{"aggregateID":"ses_ec0ed8d01ffeMCMAnsvqKvcZlP","seq":23,"version":1}} From c3eef85b3f61b0ff605a88898bb089d6df1e5cd8 Mon Sep 17 00:00:00 2001 From: lcy Date: Thu, 15 Oct 2026 22:15:28 +0800 Subject: [PATCH 11/15] fix(opencode): confirm V1 servers with the profile's username The protocol probe authenticated both api/info and the V1 global/health confirmation as V2's fixed 'opencode' user, so a V1 server running with a custom OPENCODE_SERVER_USERNAME could no longer be reached. Only api/info keeps the fixed user now. LocalOpenCodeBackend.protocol also stops building a delegate: it is read from Compose on the main thread and building one reads from disk. --- .../core/api/ProtocolDetectingOpenCodeApi.kt | 15 +++++-- .../runtime/local/LocalOpenCodeBackend.kt | 5 ++- .../core/api/OpenCodeV2ApiClientTest.kt | 41 +++++++++++++++---- 3 files changed, 47 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/com/yugahashimoto/andcode/core/api/ProtocolDetectingOpenCodeApi.kt b/app/src/main/java/com/yugahashimoto/andcode/core/api/ProtocolDetectingOpenCodeApi.kt index ab0671bb6..f9f867982 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/core/api/ProtocolDetectingOpenCodeApi.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/core/api/ProtocolDetectingOpenCodeApi.kt @@ -316,7 +316,9 @@ class ProtocolDetectingOpenCodeApi( * kept so that [health] can return it without a second request. */ private fun confirmLegacy(): Probe { - val response = fetchStatus("global/health") + // A V1 server may run with a custom OPENCODE_SERVER_USERNAME, which the V1 client honours, + // so the V1 confirmation authenticates as the profile does rather than as V2's fixed user. + val response = fetchStatus("global/health", username = profile.username.ifBlank { OpenCodeV2ApiClient.USERNAME }) if (response.code !in 200..299) { throw OpenCodeApiException(statusCode = response.code, message = "OpenCode probe failed (HTTP ${response.code})") } @@ -326,7 +328,14 @@ class ProtocolDetectingOpenCodeApi( return Probe.Legacy(health) } - private fun fetchStatus(path: String): ProbeResponse { + /** + * [username] defaults to V2's fixed `opencode` user: V2 rejects any other name, so `api/info` + * must use it even when the profile carries a V1 custom username. + */ + private fun fetchStatus( + path: String, + username: String = OpenCodeV2ApiClient.USERNAME, + ): ProbeResponse { val url = baseUrl.resolve(path) ?: throw IllegalArgumentException("Invalid OpenCode API path") val request = Request.Builder() @@ -334,7 +343,7 @@ class ProtocolDetectingOpenCodeApi( .header("Accept", "application/json") .apply { profile.password?.takeIf { it.isNotBlank() }?.let { password -> - header("Authorization", Credentials.basic(OpenCodeV2ApiClient.USERNAME, password)) + header("Authorization", Credentials.basic(username, password)) } } .build() diff --git a/app/src/main/java/com/yugahashimoto/andcode/runtime/local/LocalOpenCodeBackend.kt b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/LocalOpenCodeBackend.kt index b51abd071..d9e96cf56 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/runtime/local/LocalOpenCodeBackend.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/LocalOpenCodeBackend.kt @@ -87,10 +87,11 @@ class LocalOpenCodeBackend( /** * Protocol of the current local server, or null when the runtime is not installed yet or has - * not been probed. Never throws, because capability checks run on the UI path. + * not been probed. Reads only the already-built delegate: capability checks run from Compose on + * the main thread, and building a delegate reads runtime metadata and the password from disk. */ val protocol: OpenCodeProtocol? - get() = runCatching { delegate().protocol }.getOrNull() + get() = cached?.backend?.protocol override suspend fun health(): OpenCodeHealth = delegate().health() diff --git a/app/src/test/java/com/yugahashimoto/andcode/core/api/OpenCodeV2ApiClientTest.kt b/app/src/test/java/com/yugahashimoto/andcode/core/api/OpenCodeV2ApiClientTest.kt index 42f8df72e..7183279a1 100644 --- a/app/src/test/java/com/yugahashimoto/andcode/core/api/OpenCodeV2ApiClientTest.kt +++ b/app/src/test/java/com/yugahashimoto/andcode/core/api/OpenCodeV2ApiClientTest.kt @@ -48,15 +48,17 @@ class OpenCodeV2ApiClientTest { server.shutdown() } - private fun profile(password: String? = "secret") = - ConnectionProfile( - id = "test", - name = "Test", - baseUrl = server.url("/").toString(), - username = "opencode", - password = password, - allowInsecureLan = true, - ) + private fun profile( + password: String? = "secret", + username: String = "opencode", + ) = ConnectionProfile( + id = "test", + name = "Test", + baseUrl = server.url("/").toString(), + username = username, + password = password, + allowInsecureLan = true, + ) private fun client(password: String? = "secret"): OpenCodeV2ApiClient = OpenCodeV2ApiClient(profile(password), OkHttpClient()) @@ -104,6 +106,27 @@ class OpenCodeV2ApiClientTest { assertEquals(OpenCodeProtocol.V2, detector.protocol) } + @Test + fun `probe confirms a V1 server that uses a custom username`() = + runBlocking { + // A V1 server with OPENCODE_SERVER_USERNAME=alice answers 401 to V2's fixed user and + // only accepts alice on global/health. + server.enqueue(MockResponse().setResponseCode(401).setBody("Unauthorized")) + server.enqueue(json("""{"healthy":true,"version":"1.18.5"}""")) + val detector = ProtocolDetectingOpenCodeApi(profile(username = "alice")) + + val health = detector.health() + + assertEquals("1.18.5", health.version) + assertEquals(OpenCodeProtocol.V1, detector.protocol) + val info = server.takeRequest() + assertEquals("/api/info", info.path) + assertEquals(Credentials.basic("opencode", "secret"), info.getHeader("Authorization")) + val legacy = server.takeRequest() + assertEquals("/global/health", legacy.path) + assertEquals(Credentials.basic("alice", "secret"), legacy.getHeader("Authorization")) + } + @Test fun `probe falls back to V1 when api info returns the HTML shell`() = runBlocking { From 5a62783e7bc7b0a657794a16de7cb3d46fcc2af9 Mon Sep 17 00:00:00 2001 From: lcy Date: Thu, 15 Oct 2026 23:00:44 +0800 Subject: [PATCH 12/15] fix(opencode): keep the V2 agent mapping independent of hidden agents OpenCodeAgent has no hidden flag on this base yet, so the V2 client only maps id, description and mode; the V2 DTO still decodes hidden for when the flag lands. --- .../com/yugahashimoto/andcode/core/api/OpenCodeV2ApiClient.kt | 1 - .../yugahashimoto/andcode/core/api/OpenCodeV2ApiClientTest.kt | 3 +-- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/app/src/main/java/com/yugahashimoto/andcode/core/api/OpenCodeV2ApiClient.kt b/app/src/main/java/com/yugahashimoto/andcode/core/api/OpenCodeV2ApiClient.kt index f15738e59..af7be4247 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/core/api/OpenCodeV2ApiClient.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/core/api/OpenCodeV2ApiClient.kt @@ -190,7 +190,6 @@ class OpenCodeV2ApiClient( name = agent.id, description = agent.description, mode = agent.mode, - hidden = agent.hidden, ) } diff --git a/app/src/test/java/com/yugahashimoto/andcode/core/api/OpenCodeV2ApiClientTest.kt b/app/src/test/java/com/yugahashimoto/andcode/core/api/OpenCodeV2ApiClientTest.kt index 7183279a1..c894bc377 100644 --- a/app/src/test/java/com/yugahashimoto/andcode/core/api/OpenCodeV2ApiClientTest.kt +++ b/app/src/test/java/com/yugahashimoto/andcode/core/api/OpenCodeV2ApiClientTest.kt @@ -761,7 +761,7 @@ class OpenCodeV2ApiClientTest { } @Test - fun `agents map id to name and keep mode and hidden`() = + fun `agents map id to name and keep mode`() = runBlocking { server.enqueue(json(AGENT_LIST)) @@ -769,7 +769,6 @@ class OpenCodeV2ApiClientTest { assertEquals(listOf("build", "compaction"), agents.map { it.name }) assertEquals("primary", agents[0].mode) - assertTrue(agents[1].hidden) } @Test From 48259771d9cd40f2db7fa0dd409da9604ba9c77d Mon Sep 17 00:00:00 2001 From: lcy Date: Thu, 15 Oct 2026 23:19:42 +0800 Subject: [PATCH 13/15] fix(opencode): keep proxied V1 servers reachable and gate V2 MCP OAuth Older V1 servers, and V1 builds without an embedded web UI, proxy unknown paths such as api/info to app.opencode.ai, so that probe can fail with a 5xx or a timeout while the server itself is healthy. Those failures now still try global/health, and only a 1.x version there is accepted as V1, so a V2 server's transient errors are never cached as V1. Also: - drop V2's hidden internal agents (compaction, title, summary) from the agent list - release a session's prompt lock when the session is deleted - add an mcpOAuth capability so the MCP screen hides authenticate and remove-auth on V2, which has no MCP OAuth endpoints --- .../andcode/core/api/OpenCodeV2ApiClient.kt | 8 +- .../core/api/ProtocolDetectingOpenCodeApi.kt | 28 +++++- .../andcode/feature/settings/McpViewModel.kt | 12 ++- .../andcode/runtime/RuntimeCapabilities.kt | 7 ++ .../runtime/local/LocalRuntimeTarget.kt | 1 + .../runtime/remote/RemoteRuntimeTarget.kt | 1 + .../core/api/OpenCodeV2ApiClientTest.kt | 36 ++++++- .../RemoteRuntimeTargetCapabilitiesTest.kt | 98 +++++++++++++++++++ 8 files changed, 183 insertions(+), 8 deletions(-) create mode 100644 app/src/test/java/com/yugahashimoto/andcode/runtime/remote/RemoteRuntimeTargetCapabilitiesTest.kt diff --git a/app/src/main/java/com/yugahashimoto/andcode/core/api/OpenCodeV2ApiClient.kt b/app/src/main/java/com/yugahashimoto/andcode/core/api/OpenCodeV2ApiClient.kt index af7be4247..acccd4bf6 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/core/api/OpenCodeV2ApiClient.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/core/api/OpenCodeV2ApiClient.kt @@ -184,8 +184,13 @@ class OpenCodeV2ApiClient( ) } + /** + * Internal V2 agents (compaction, title, summary) are reported as `mode: primary` with + * `hidden: true`; the app's agent model has no hidden flag yet, so they are dropped here to + * keep them out of the mode picker and settings. + */ override suspend fun agents(): List = - getData>("api/agent").map { agent -> + getData>("api/agent").filterNot { it.hidden }.map { agent -> OpenCodeAgent( name = agent.id, description = agent.description, @@ -526,6 +531,7 @@ class OpenCodeV2ApiClient( directory: String?, ): Boolean { sendVoid(requestBuilder("api/session/${encodePath(sessionId)}").delete()) + sessionLocks.remove(sessionId) return true } diff --git a/app/src/main/java/com/yugahashimoto/andcode/core/api/ProtocolDetectingOpenCodeApi.kt b/app/src/main/java/com/yugahashimoto/andcode/core/api/ProtocolDetectingOpenCodeApi.kt index f9f867982..435725183 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/core/api/ProtocolDetectingOpenCodeApi.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/core/api/ProtocolDetectingOpenCodeApi.kt @@ -22,6 +22,7 @@ import okhttp3.Credentials import okhttp3.HttpUrl import okhttp3.OkHttpClient import okhttp3.Request +import java.io.IOException /** Which HTTP API a server speaks. V2 servers answer under the `api/` prefix; V1 servers answer the legacy routes. */ enum class OpenCodeProtocol { V1, V2 } @@ -298,24 +299,43 @@ class ProtocolDetectingOpenCodeApi( * * V1 servers answer `api/info` with the single-page-app HTML, which is not conclusive by itself. * V1 is only confirmed when `global/health` succeeds, so an outage or proxy error cannot be - * mistaken for a V1 server. Transient statuses (5xx, 408, 429) are thrown and not cached. + * mistaken for a V1 server. + * + * Older V1 servers, and V1 builds without an embedded web UI, proxy unknown paths such as + * `api/info` to app.opencode.ai, so a 5xx or a timeout there can be a perfectly healthy V1 + * server whose upstream web host is unreachable. Those failures still try `global/health`, but + * only a 1.x version on it is accepted as V1: a V2 server answers that path with HTML, so its + * transient failures keep throwing and are never cached as V1. */ private suspend fun probe(): Probe = withContext(Dispatchers.IO) { - val info = fetchStatus("api/info") + val info = + try { + fetchStatus("api/info") + } catch (error: IOException) { + return@withContext confirmLegacyMajorOne() ?: throw error + } when { info.code in 200..299 -> versionOf(info.body)?.let { Probe.V2(it) } ?: confirmLegacy() info.code == 401 && isUnauthorizedTag(info.body) -> Probe.V2Unauthorized info.code == 401 || info.code == 403 || info.code == 404 || info.code in 300..399 -> confirmLegacy() - else -> throw OpenCodeApiException(statusCode = info.code, message = "OpenCode probe failed (HTTP ${info.code})") + else -> + confirmLegacyMajorOne() + ?: throw OpenCodeApiException(statusCode = info.code, message = "OpenCode probe failed (HTTP ${info.code})") } } + /** [confirmLegacy] for an inconclusive `api/info`: a 1.x server, or null when that is not proven. */ + private fun confirmLegacyMajorOne(): Probe.Legacy? = + runCatching { confirmLegacy() } + .getOrNull() + ?.takeIf { legacy -> legacy.health.version.trim().removePrefix("v").substringBefore('.') == "1" } + /** * Confirms a V1 server with `global/health`; any non-success is not a V1 verdict. The body is * kept so that [health] can return it without a second request. */ - private fun confirmLegacy(): Probe { + private fun confirmLegacy(): Probe.Legacy { // A V1 server may run with a custom OPENCODE_SERVER_USERNAME, which the V1 client honours, // so the V1 confirmation authenticates as the profile does rather than as V2's fixed user. val response = fetchStatus("global/health", username = profile.username.ifBlank { OpenCodeV2ApiClient.USERNAME }) diff --git a/app/src/main/java/com/yugahashimoto/andcode/feature/settings/McpViewModel.kt b/app/src/main/java/com/yugahashimoto/andcode/feature/settings/McpViewModel.kt index a7bad0d09..4c1ee78f0 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/feature/settings/McpViewModel.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/feature/settings/McpViewModel.kt @@ -6,6 +6,7 @@ import com.yugahashimoto.andcode.core.api.McpServer import com.yugahashimoto.andcode.runtime.LocalAgent import com.yugahashimoto.andcode.runtime.OpenCodeBackend import com.yugahashimoto.andcode.runtime.RuntimeRegistry +import com.yugahashimoto.andcode.runtime.RuntimeTarget import com.yugahashimoto.andcode.runtime.local.ClaudeCodeTarget import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -77,7 +78,16 @@ class McpViewModel( _state.update { it.copy(isLoading = true, error = null) } runCatching { backend.mcpServers() } .onSuccess { servers -> - _state.update { it.copy(servers = servers, isLoading = false) } + // Read after the list call: that call is what lets a remote target detect its + // protocol, and an OpenCode V2 server has no MCP OAuth endpoints to offer. + val oauthOffered = (backend as? RuntimeTarget)?.capabilities?.mcpOAuth != false + _state.update { + it.copy( + servers = servers, + isLoading = false, + supportsOAuth = agent == LocalAgent.OPEN_CODE && oauthOffered, + ) + } } .onFailure { e -> _state.update { it.copy(error = e.message, isLoading = false) } diff --git a/app/src/main/java/com/yugahashimoto/andcode/runtime/RuntimeCapabilities.kt b/app/src/main/java/com/yugahashimoto/andcode/runtime/RuntimeCapabilities.kt index 408937cdb..3b141d0d1 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/runtime/RuntimeCapabilities.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/runtime/RuntimeCapabilities.kt @@ -55,4 +55,11 @@ data class RuntimeCapabilities( * the settings screens hide those entry points rather than letting the write fail. */ val configEditing: Boolean = false, + /** + * False when the runtime is known to offer no MCP OAuth endpoints. OpenCode V2 handles MCP + * sign-in outside its REST API, so its targets turn this off and the MCP screen hides the + * authenticate/remove-auth actions. Defaults to true so runtimes that never report it keep the + * per-agent decision the MCP screen already makes. + */ + val mcpOAuth: Boolean = true, ) diff --git a/app/src/main/java/com/yugahashimoto/andcode/runtime/local/LocalRuntimeTarget.kt b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/LocalRuntimeTarget.kt index 0b66f2e41..7a9c58131 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/runtime/local/LocalRuntimeTarget.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/LocalRuntimeTarget.kt @@ -65,6 +65,7 @@ class LocalRuntimeTarget( editMessages = backend.protocol != OpenCodeProtocol.V2, diffCapable = true, configEditing = backend.protocol != OpenCodeProtocol.V2, + mcpOAuth = backend.protocol != OpenCodeProtocol.V2, ) private val mutableState = MutableStateFlow(mapStatus(runtimeManager.status())) diff --git a/app/src/main/java/com/yugahashimoto/andcode/runtime/remote/RemoteRuntimeTarget.kt b/app/src/main/java/com/yugahashimoto/andcode/runtime/remote/RemoteRuntimeTarget.kt index 920b09c44..d32cccd50 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/runtime/remote/RemoteRuntimeTarget.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/runtime/remote/RemoteRuntimeTarget.kt @@ -58,6 +58,7 @@ class RemoteRuntimeTarget( diffCapable = true, // V1 accepts config patches; V2 does not, so its config entry points stay hidden. configEditing = backend.protocol != OpenCodeProtocol.V2, + mcpOAuth = backend.protocol != OpenCodeProtocol.V2, ) private val mutableState = MutableStateFlow(RuntimeState.Disconnected) diff --git a/app/src/test/java/com/yugahashimoto/andcode/core/api/OpenCodeV2ApiClientTest.kt b/app/src/test/java/com/yugahashimoto/andcode/core/api/OpenCodeV2ApiClientTest.kt index c894bc377..1d2daf03b 100644 --- a/app/src/test/java/com/yugahashimoto/andcode/core/api/OpenCodeV2ApiClientTest.kt +++ b/app/src/test/java/com/yugahashimoto/andcode/core/api/OpenCodeV2ApiClientTest.kt @@ -145,6 +145,8 @@ class OpenCodeV2ApiClientTest { @Test fun `probe does not cache a transport failure`() = runBlocking { + server.enqueue(MockResponse().setSocketPolicy(okhttp3.mockwebserver.SocketPolicy.DISCONNECT_AT_START)) + // The legacy fallback after a transport failure finds no V1 server either. server.enqueue(MockResponse().setSocketPolicy(okhttp3.mockwebserver.SocketPolicy.DISCONNECT_AT_START)) server.enqueue(json(V2_INFO)) val detector = ProtocolDetectingOpenCodeApi(profile()) @@ -161,6 +163,8 @@ class OpenCodeV2ApiClientTest { fun `a 5xx from api info neither decides the protocol nor is cached`() = runBlocking { server.enqueue(MockResponse().setResponseCode(503).setBody("upstream")) + // A V2 server answers the legacy health path with its SPA shell, not V1 JSON. + server.enqueue(MockResponse().setHeader("Content-Type", "text/html").setBody("")) server.enqueue(json(V2_INFO)) val detector = ProtocolDetectingOpenCodeApi(profile()) @@ -172,6 +176,34 @@ class OpenCodeV2ApiClientTest { assertEquals("2.0.28", detector.health().version) } + @Test + fun `a V1 server whose api info proxy fails is still confirmed through global health`() = + runBlocking { + // Older V1 servers proxy unknown paths to app.opencode.ai; when that host is + // unreachable, api/info is a 500 even though the server itself is healthy. + server.enqueue(MockResponse().setResponseCode(500).setBody("proxy error")) + server.enqueue(json("""{"healthy":true,"version":"1.2.0"}""")) + val detector = ProtocolDetectingOpenCodeApi(profile()) + + val health = detector.health() + + assertEquals("1.2.0", health.version) + assertEquals(OpenCodeProtocol.V1, detector.protocol) + } + + @Test + fun `a 5xx from api info with a non 1 x global health is not taken as V1`() = + runBlocking { + server.enqueue(MockResponse().setResponseCode(500)) + server.enqueue(json("""{"healthy":true,"version":"2.0.28"}""")) + val detector = ProtocolDetectingOpenCodeApi(profile()) + + val failed = runCatching { detector.health() }.exceptionOrNull() + + assertEquals(500, (failed as OpenCodeApiException).statusCode) + assertNull(detector.protocol) + } + @Test fun `V1 is only confirmed when global health succeeds`() = runBlocking { @@ -761,13 +793,13 @@ class OpenCodeV2ApiClientTest { } @Test - fun `agents map id to name and keep mode`() = + fun `agents map id to name, keep mode and drop hidden agents`() = runBlocking { server.enqueue(json(AGENT_LIST)) val agents = client().agents() - assertEquals(listOf("build", "compaction"), agents.map { it.name }) + assertEquals(listOf("build"), agents.map { it.name }) assertEquals("primary", agents[0].mode) } diff --git a/app/src/test/java/com/yugahashimoto/andcode/runtime/remote/RemoteRuntimeTargetCapabilitiesTest.kt b/app/src/test/java/com/yugahashimoto/andcode/runtime/remote/RemoteRuntimeTargetCapabilitiesTest.kt new file mode 100644 index 000000000..0ff5f025d --- /dev/null +++ b/app/src/test/java/com/yugahashimoto/andcode/runtime/remote/RemoteRuntimeTargetCapabilitiesTest.kt @@ -0,0 +1,98 @@ +package com.yugahashimoto.andcode.runtime.remote + +import com.yugahashimoto.andcode.data.connection.ConnectionProfile +import com.yugahashimoto.andcode.feature.settings.McpViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class RemoteRuntimeTargetCapabilitiesTest { + private lateinit var server: MockWebServer + + @Before + fun setUp() { + server = MockWebServer() + server.start() + } + + @After + fun tearDown() { + server.shutdown() + } + + private fun target() = + RemoteRuntimeTarget( + ConnectionProfile( + id = "remote", + name = "Remote", + baseUrl = server.url("/").toString(), + username = "opencode", + password = "pw", + allowInsecureLan = true, + ), + ) + + @Test + fun `a V1 server keeps message edits, config editing and MCP OAuth`() = + runBlocking { + server.enqueue(MockResponse().setHeader("Content-Type", "text/html").setBody("")) + server.enqueue(MockResponse().setBody("""{"healthy":true,"version":"1.18.5"}""")) + val target = target() + + target.connect().getOrThrow() + + val capabilities = target.capabilities + assertTrue(capabilities.editMessages) + assertTrue(capabilities.configEditing) + assertTrue(capabilities.mcpOAuth) + } + + @Test + fun `a V2 server turns off what its API does not offer`() = + runBlocking { + server.enqueue(MockResponse().setBody("""{"version":"2.0.28","pid":1,"urls":[],"paths":{},"capabilities":{}}""")) + val target = target() + + target.connect().getOrThrow() + + val capabilities = target.capabilities + assertFalse(capabilities.editMessages) + assertFalse(capabilities.configEditing) + assertFalse(capabilities.mcpOAuth) + // What V2 does support is unchanged. + assertTrue(capabilities.permissions) + assertTrue(capabilities.diffCapable) + } + + @Test + fun `the MCP screen hides OAuth actions on a V2 server`() { + // The view model loads on the main dispatcher; run it eagerly on this thread. + Dispatchers.setMain(Dispatchers.Unconfined) + try { + server.enqueue(MockResponse().setBody("""{"version":"2.0.28","pid":1,"urls":[],"paths":{},"capabilities":{}}""")) + server.enqueue(MockResponse().setBody("""{"data":[]}""")) + val target = target() + + val viewModel = McpViewModel({ target }) + val deadline = System.currentTimeMillis() + 5_000 + while (viewModel.state.value.isLoading || server.requestCount < 2) { + check(System.currentTimeMillis() < deadline) { "MCP list never loaded" } + Thread.sleep(10) + } + + assertFalse(viewModel.state.value.supportsOAuth) + } finally { + Dispatchers.resetMain() + } + } +} From 655b843c89b360a57ae3d5914f94935a9f8a82e6 Mon Sep 17 00:00:00 2001 From: Yu-ga <74749461+yuga-hashimoto@users.noreply.github.com> Date: Fri, 16 Oct 2026 14:33:33 +0000 Subject: [PATCH 14/15] feat(chat): show live token speed beside the context meter --- .../andcode/feature/chat/ChatHomeScreen.kt | 19 +++++++++++++++++++ app/src/main/res/values-ar/strings.xml | 1 + app/src/main/res/values-es/strings.xml | 1 + app/src/main/res/values-fr/strings.xml | 1 + app/src/main/res/values-ja/strings.xml | 1 + app/src/main/res/values-pt-rBR/strings.xml | 1 + app/src/main/res/values-ru/strings.xml | 1 + app/src/main/res/values-zh-rCN/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + 9 files changed, 27 insertions(+) diff --git a/app/src/main/java/com/yugahashimoto/andcode/feature/chat/ChatHomeScreen.kt b/app/src/main/java/com/yugahashimoto/andcode/feature/chat/ChatHomeScreen.kt index e3b7a1749..130b68a1a 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/feature/chat/ChatHomeScreen.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/feature/chat/ChatHomeScreen.kt @@ -672,6 +672,7 @@ fun ChatHomeScreen( .firstOrNull { it.id == selectedProviderId } ?.models?.get(selectedModelId) ?.limit?.context ?: 0L, + tokensPerSecond = state.connectionQuality?.tokensPerSecond ?: 0.0, showSlashCommands = showSlashCommands, slashCommands = state.slashCommands, slashSkills = state.slashSkills, @@ -1257,6 +1258,7 @@ private fun ChatComposer( enterToSend: Boolean, contextTokensUsed: Long, contextLimit: Long, + tokensPerSecond: Double, showSlashCommands: Boolean, slashCommands: List, slashSkills: List, @@ -1630,6 +1632,9 @@ private fun ChatComposer( contextLimit = contextLimit, ) } + if (isRunning && tokensPerSecond > 0.0) { + CompactTokenSpeed(tokensPerSecond) + } } } } @@ -2167,6 +2172,20 @@ private fun formatTokenCount(tokens: Long): String = else -> tokens.toString() } +@Composable +private fun CompactTokenSpeed(tokensPerSecond: Double) { + Text( + text = stringResource(R.string.chat_token_speed, formatTokenRate(tokensPerSecond)), + style = MaterialTheme.typography.labelSmall, + fontSize = 9.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 2.dp), + ) +} + +private fun formatTokenRate(tokensPerSecond: Double): String = + if (tokensPerSecond >= 10.0) "%.0f".format(tokensPerSecond) else "%.1f".format(tokensPerSecond) + @Preview(showBackground = true) @Composable private fun ChatHomeScreenEmptyPreview() { diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index deea8261c..b4a06946e 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -486,6 +486,7 @@ تخطيط قبول تلقائي تلقائي + %1$s tok/s عمليات GitHub Git غير متصل الاتصال بـ GitHub diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 99b2905d8..c9002a930 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -480,6 +480,7 @@ Planificar Aceptación automática Auto + %1$s tok/s Operaciones Git de GitHub No conectado Conectar GitHub diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 1315e153b..7794e2f48 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -480,6 +480,7 @@ Planifier Acceptation automatique Auto + %1$s tok/s Opérations Git GitHub Non connecté Connecter GitHub diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index aa88fd77c..fc319ecdb 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -478,6 +478,7 @@ 計画 自動承認 自動 + %1$s tok/s GitHub Git 操作 未接続 GitHubに接続 diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index d7f25c6ac..567c4d4e5 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -480,6 +480,7 @@ Planejar Aceitação automática Auto + %1$s tok/s Operações Git do GitHub Não conectado Conectar GitHub diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 8e8b30d89..c54572b78 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -482,6 +482,7 @@ План Автоприём Авто + %1$s tok/s Операции GitHub Git Не подключено Подключить GitHub diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 99c9533b2..47bc074cb 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -476,6 +476,7 @@ 计划 自动接受 自动 + %1$s tok/s GitHub Git 操作 未连接 连接 GitHub diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 55e36391c..3ca369747 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -466,6 +466,7 @@ Plan Auto accept Auto + %1$s tok/s GitHub Git operations Not connected Connect GitHub From 223972d10606e314836bea1b592eaf5badb771d0 Mon Sep 17 00:00:00 2001 From: Yu-ga <74749461+yuga-hashimoto@users.noreply.github.com> Date: Fri, 16 Oct 2026 14:40:31 +0000 Subject: [PATCH 15/15] fix(chat): avoid showing a rounded-down 0.0 tok/s --- .../com/yugahashimoto/andcode/feature/chat/ChatHomeScreen.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/yugahashimoto/andcode/feature/chat/ChatHomeScreen.kt b/app/src/main/java/com/yugahashimoto/andcode/feature/chat/ChatHomeScreen.kt index 130b68a1a..1d30bfc12 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/feature/chat/ChatHomeScreen.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/feature/chat/ChatHomeScreen.kt @@ -1632,7 +1632,7 @@ private fun ChatComposer( contextLimit = contextLimit, ) } - if (isRunning && tokensPerSecond > 0.0) { + if (isRunning && tokensPerSecond >= 0.05) { CompactTokenSpeed(tokensPerSecond) } }