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..f9f867982 --- /dev/null +++ b/app/src/main/java/com/yugahashimoto/andcode/core/api/ProtocolDetectingOpenCodeApi.kt @@ -0,0 +1,385 @@ +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 { + // 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})") + } + val health = + runCatching { json.decodeFromString(response.body) } + .getOrElse { throw OpenCodeApiException(statusCode = 200, message = "OpenCode V1 health response was not JSON") } + return Probe.Legacy(health) + } + + /** + * [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() + .url(url) + .header("Accept", "application/json") + .apply { + profile.password?.takeIf { it.isNotBlank() }?.let { password -> + header("Authorization", Credentials.basic(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 d8e741171..48b3f2b31 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 @@ -87,6 +87,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(), @@ -240,6 +245,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, @@ -606,6 +612,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..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 @@ -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,14 @@ class LocalOpenCodeBackend( cached = null } + /** + * Protocol of the current local server, or null when the runtime is not installed yet or has + * 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() = cached?.backend?.protocol + 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..7183279a1 --- /dev/null +++ b/app/src/test/java/com/yugahashimoto/andcode/core/api/OpenCodeV2ApiClientTest.kt @@ -0,0 +1,1159 @@ +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", + 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()) + + 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 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 { + 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}}