diff --git a/PRIVACY.md b/PRIVACY.md index cb2af0908..cd32c25f7 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -116,29 +116,37 @@ settings to remove these mounts. AndCode uses **Firebase Crashlytics** (a Google service) for crash and non-fatal error reporting in release builds (`CrashReporter` in `core/diagnostics`). It: +- Is **disabled by default** and shares the "Anonymous usage analytics & crash reports" opt-in in + Settings. Nothing is collected until the user turns that setting on (in debug builds collection is + always off regardless of the setting). - Sends the app version, build type, and OS version as custom keys that `CrashReporter.install()` sets explicitly. - Sends crash stack traces and short diagnostic log lines. Custom log lines and custom-key values that AndCode's own code passes through `CrashReporter.log()`/`recordException()` are run through redaction (`SecretRedaction`, `CrashReportSanitizer`) that strips *patterns resembling* API keys, - tokens, `Authorization`/`Cookie` headers, and passwords before they are attached to a report, and - truncates length. **This is a best-effort pattern match, not a guarantee that every possible - credential shape is caught** — for example, some provider key formats not covered by the current - patterns, or a key with no recognizable label, could slip through if it ever ended up in a log - line. Fatal crashes and their exception messages/stack traces that Crashlytics' own SDK collects - automatically (rather than through `CrashReporter`) are **not** run through this redaction, since - they are captured by Crashlytics itself before AndCode's code runs. + tokens, `Authorization`/`Cookie` headers, quoted credential fields, URL credentials, and + passwords before they are attached to a report, and truncates length. **This is a best-effort + pattern match, not a guarantee that every possible credential shape is caught** — for example, + some provider key formats not covered by the current patterns, or a key with no recognizable + label, could slip through if it ever ended up in a log line. Fatal crashes and their exception + messages/stack traces that Crashlytics' own SDK collects automatically (rather than through + `CrashReporter`) are **not** run through this redaction, since they are captured by Crashlytics + itself before AndCode's code runs. +- Does **not** receive a runtime/provider's raw error text. A session failure is reported as a fixed + category made only of the runtime id and an identifier-shaped error type (for example + `MessageAbortedError`); the free-form message a provider returns — which can quote prompts, model + output, file paths and hosts — is shown on-device only. - The Crashlytics SDK also automatically collects standard device/app/installation diagnostics independent of anything AndCode's code sends explicitly — for example, device model, OS version, app version, a Firebase installation identifier, and free/total storage and RAM at crash time. See Firebase's own documentation for the current complete list, since AndCode's code does not control this baseline collection. -- Is disabled in debug builds (`setCrashlyticsCollectionEnabled(!BuildConfig.DEBUG)`). -Firebase Analytics is available for anonymous app-open/session and runtime outcome metrics, but is -**disabled by default** and is only enabled after the user opts in. It does not receive prompts, -source files, model responses, tokens, or workspace paths. The opt-in choice is stored locally and -can be withdrawn in Settings. +Firebase Analytics is available for anonymous app-open/session and runtime outcome metrics, and is +governed by the same **disabled-by-default** opt-in as crash reporting. It does not receive prompts, +source files, model responses, tokens, or workspace paths, and the advertising ID is not collected +(`google_analytics_adid_collection_enabled=false`). The opt-in choice is stored locally and can be +withdrawn in Settings. Crashlytics data is governed by Google's and Firebase's own privacy terms; see [THIRD_PARTY_SERVICES.md](THIRD_PARTY_SERVICES.md). diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index e4a528b22..b2ef055bb 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -21,12 +21,12 @@ BSD-3-Clause text used by `libandroid-shmem` is bundled verbatim, unmodified, in | Package | Version | License (verified against upstream source, not guessed) | Copyright | Corresponding source (content-addressed) | |---|---|---|---|---| -| `proot` | 5.1.107.96 | [GPL-2.0](THIRD_PARTY_LICENSES/GPL-2.0.txt), per [`TERMUX_PKG_LICENSE`](runtime_tools/termux-packaging-recipes/proot.build.sh) | The PRoot contributors | Upstream source archive `v5.1.107.96.zip`, SHA-256 `75f654fe60dea92dabff2bf083ae8bfe4f91baa6a1a374786a6bf391015eebaa` (from [github.com/termux/proot](https://github.com/termux/proot/archive/v5.1.107.96.zip), tagged release, not `master`) | +| `proot` | 5.1.107.97 | [GPL-2.0](THIRD_PARTY_LICENSES/GPL-2.0.txt), per [`TERMUX_PKG_LICENSE`](runtime_tools/termux-packaging-recipes/proot.build.sh) | The PRoot contributors | Upstream source archive `v5.1.107.97.zip`, SHA-256 `badd014e6868c0d57b5eae6db08afc284b77f85990c71665d1659e121482df26` (from [github.com/termux/proot](https://github.com/termux/proot/archive/v5.1.107.97.zip), tagged release, not `master`) | | `libandroid-shmem` | 0.7 | [BSD-3-Clause](THIRD_PARTY_LICENSES/BSD-3-Clause-libandroid-shmem.txt), per [`TERMUX_PKG_LICENSE`](runtime_tools/termux-packaging-recipes/libandroid-shmem.build.sh) and the project's own `LICENSE` file | Copyright (c) 2013 Sergii Pylypenko; Copyright (c) 2017 Fredrik Fornwall | Upstream source archive `v0.7.tar.gz`, SHA-256 `1e5ff8459bc0a8c229dd8a94b27d119987e09ef3414331c2b5ebfff20b98e867` (from [github.com/termux/libandroid-shmem](https://github.com/termux/libandroid-shmem/archive/refs/tags/v0.7.tar.gz), tagged release, not `master`) | | `libtalloc` | 2.5.0 | [LGPL-3.0-or-later](THIRD_PARTY_LICENSES/LGPL-3.0.txt) for the actual runtime library. Termux's own packaging metadata tags the *package* `GPL-3.0` (a coarser, package-level tag), but the shared library source itself (`talloc.c`/`talloc.h`, the only files that become `libtalloc.a`/`libtalloc.so`) carries its own header: *"the following LGPL license applies to the talloc library. This does NOT imply that all of Samba is released under the LGPL"* — version 3 or later. Resolved; no longer `REQUIRES_LICENSE_REVIEW`. | Copyright (C) Andrew Tridgell 2004; Copyright (C) Stefan Metzmacher 2006 | Upstream source archive `talloc-2.5.0.tar.gz`, SHA-256 `912afa237510ae542a7733998eb18a12bcda35ab6729c8e2ddb43e8d0ebab007` (from [samba.org/ftp/talloc](https://www.samba.org/ftp/talloc/talloc-2.5.0.tar.gz), versioned release path, not a mutable branch) | The fdroid flavor compiles all three from the pinned `TermuxPackages` commit, which can lag the -mirror by a version or two (currently `proot` 5.1.107.94, `libtalloc` 2.4.3, `libandroid-shmem` 0.7). +mirror by a few versions (currently `proot` 5.1.107.94, `libtalloc` 2.4.3, `libandroid-shmem` 0.7). **Packaging recipes, mirrored (not just linked):** the exact `TERMUX_PKG_*` build recipe used for each package above — the thing that actually produces the `.deb` pinned by hash in diff --git a/THIRD_PARTY_SERVICES.md b/THIRD_PARTY_SERVICES.md index e5eba6a2d..d284e0caf 100644 --- a/THIRD_PARTY_SERVICES.md +++ b/THIRD_PARTY_SERVICES.md @@ -18,7 +18,7 @@ Claude Code, Antigravity, and OpenCode specifically. | **Alpine Linux** (`dl-cdn.alpinelinux.org`) | Minimal Linux root filesystem the on-device runtime runs in | N/A (unauthenticated download) | The app downloads the official Alpine minirootfs archive over HTTPS | N/A | No | [Alpine Linux license terms](https://alpinelinux.org/) (per-package) | Delete the local runtime | Independent open-source project | | **Debian** (`deb.debian.org`, `security.debian.org`) | Bookworm root filesystem used only for the glibc-dependent Antigravity CLI | N/A (unauthenticated `apt` mirrors) | `apt` inside the Debian rootfs fetches packages over the configured mirrors | N/A | No | [Debian's licenses](https://www.debian.org/legal/) (per-package) | Delete the local runtime | Independent open-source project | | **Termux package mirror** (`packages.termux.dev`) | Source of the `proot`, `libandroid-shmem`, and `libtalloc` binaries bundled into the APK at build time | N/A (build-time download, pinned by hash) | Build machine only, not the end-user's device at runtime | N/A | No | Upstream licenses vary — see [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) | N/A (bundled at build time) | Independent open-source project | -| **Firebase Crashlytics** (Google) | Crash and non-fatal error reporting in release builds | N/A (app-level Firebase project, no end-user login) | The app sends crash reports and small diagnostic breadcrumbs directly to Firebase; custom log lines/keys AndCode's own code adds are redacted on a best-effort basis (see PRIVACY.md §7), but automatic fatal-crash collection by the Crashlytics SDK itself is not routed through that redaction | N/A (no user credential; app-level Firebase config) | This *is* a Google-operated collection service, not an AndCode server | [Firebase Terms of Service](https://firebase.google.com/terms) / [Google Privacy Policy](https://policies.google.com/privacy) | Disabled automatically in debug builds; release collection follows the app's diagnostics policy | Independent Google service; not affiliated with the AI-agent integrations above | +| **Firebase Crashlytics** (Google) | Crash and non-fatal error reporting in release builds | N/A (app-level Firebase project, no end-user login) | The app sends crash reports and small diagnostic breadcrumbs directly to Firebase; custom log lines/keys AndCode's own code adds are redacted on a best-effort basis (see PRIVACY.md §7), but automatic fatal-crash collection by the Crashlytics SDK itself is not routed through that redaction. Runtime session errors are reported as a fixed category only (runtime id + error type), never the provider's raw message | N/A (no user credential; app-level Firebase config) | This *is* a Google-operated collection service, not an AndCode server | [Firebase Terms of Service](https://firebase.google.com/terms) / [Google Privacy Policy](https://policies.google.com/privacy) | Disabled by default (and always in debug builds); enable or withdraw consent in Settings | Independent Google service; not affiliated with the AI-agent integrations above | | **Firebase Analytics** (Google) | Opt-in anonymous app-open/session and runtime outcome metrics | N/A (app-level Firebase project, no end-user login) | The Firebase SDK sends telemetry directly to Firebase; AndCode excludes prompts, code, responses, tokens, and workspace paths | N/A (no user credential; app-level Firebase config) | This *is* a Google-operated collection service, not an AndCode server | [Firebase Terms of Service](https://firebase.google.com/terms) / [Google Privacy Policy](https://policies.google.com/privacy) | Disabled by default; enable or withdraw consent in Settings | Independent Google service; not affiliated with the AI-agent integrations above | ## OpenCode credential paths diff --git a/app/src/fdroid/java/com/yugahashimoto/andcode/core/diagnostics/CrashReporter.kt b/app/src/fdroid/java/com/yugahashimoto/andcode/core/diagnostics/CrashReporter.kt index f6a12d784..a2e1cf177 100644 --- a/app/src/fdroid/java/com/yugahashimoto/andcode/core/diagnostics/CrashReporter.kt +++ b/app/src/fdroid/java/com/yugahashimoto/andcode/core/diagnostics/CrashReporter.kt @@ -10,6 +10,8 @@ package com.yugahashimoto.andcode.core.diagnostics object CrashReporter { fun install() = Unit + fun setEnabled(enabled: Boolean) = Unit + fun log(message: String) = Unit fun recordException( @@ -17,4 +19,9 @@ object CrashReporter { message: String? = null, customKeys: Map = emptyMap(), ) = Unit + + fun recordRuntimeSessionError( + runtimeId: String, + errorType: String?, + ) = Unit } diff --git a/app/src/github/java/com/yugahashimoto/andcode/core/diagnostics/CrashReporter.kt b/app/src/github/java/com/yugahashimoto/andcode/core/diagnostics/CrashReporter.kt index 002aa0288..60d461aac 100644 --- a/app/src/github/java/com/yugahashimoto/andcode/core/diagnostics/CrashReporter.kt +++ b/app/src/github/java/com/yugahashimoto/andcode/core/diagnostics/CrashReporter.kt @@ -14,7 +14,13 @@ object CrashReporter { @Volatile private var crashlytics: FirebaseCrashlytics? = null - /** Initializes Crashlytics and records only non-sensitive app metadata. */ + /** + * Initializes Crashlytics but leaves collection off until [setEnabled] opts it in. + * + * Crash reporting follows the same explicit user opt-in as Analytics: nothing leaves the device + * until the user allows it. The manifest default is off too, so no report can escape before this + * runs. + */ fun install() { val client = runCatching { FirebaseCrashlytics.getInstance() } @@ -22,7 +28,7 @@ object CrashReporter { ?: return crashlytics = client runCatching { - client.setCrashlyticsCollectionEnabled(!com.yugahashimoto.andcode.BuildConfig.DEBUG) + client.setCrashlyticsCollectionEnabled(false) client.setCustomKey("app_version", com.yugahashimoto.andcode.BuildConfig.VERSION_NAME) client.setCustomKey( "build_type", @@ -32,6 +38,13 @@ object CrashReporter { } } + /** Turns collection on or off with the user's analytics/crash-reporting opt-in. */ + fun setEnabled(enabled: Boolean) { + runCatching { + crashlytics?.setCrashlyticsCollectionEnabled(enabled && !com.yugahashimoto.andcode.BuildConfig.DEBUG) + } + } + fun log(message: String) { crashlytics?.let { client -> runCatching { client.log(CrashReportSanitizer.message(message)) } @@ -55,4 +68,22 @@ object CrashReporter { client.recordException(error) } } + + /** + * Reports a runtime session failure as a fixed category only. + * + * The provider's raw error text is intentionally not sent: it is free-form and can carry prompt + * content, model output, file paths and hosts. The on-device notification still shows it. + */ + fun recordRuntimeSessionError( + runtimeId: String, + errorType: String?, + ) { + val client = crashlytics ?: return + val category = CrashReportSanitizer.errorCategory(runtimeId, errorType) + runCatching { + client.log(category) + client.recordException(IllegalStateException(category)) + } + } } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 147af901a..54446cceb 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -31,6 +31,13 @@ + + @@ -62,9 +69,21 @@ android:networkSecurityConfig="@xml/network_security_config" tools:targetApi="34"> + + + + onSessionError = { sessionId, message, errorType, runtimeId -> AnalyticsReporter.recordRuntimeSessionError() - CrashReporter.recordException( - error = IllegalStateException(SecretRedaction.redact(message ?: "Runtime session failed")), - message = "Runtime session error", - customKeys = mapOf("session_id" to (sessionId ?: "unknown")), - ) + // Report only the runtime id and error type: the raw message can echo prompts, + // model output, file paths and hosts, and stays on-device in the notification. + CrashReporter.recordRuntimeSessionError(runtimeId, errorType) notifications.notifySessionError(sessionId, message, runtimeId) }, onQuestionAsked = { request, title, runtimeId -> diff --git a/app/src/main/java/com/yugahashimoto/andcode/core/api/OpenCodeApiModels.kt b/app/src/main/java/com/yugahashimoto/andcode/core/api/OpenCodeApiModels.kt index c898e79ed..2dd44cb36 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/core/api/OpenCodeApiModels.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/core/api/OpenCodeApiModels.kt @@ -137,6 +137,26 @@ data class OpenCodeModel( val status: String? = null, val limit: OpenCodeModelLimit? = null, val variants: Map = emptyMap(), + /** + * Codex only: the service-tier id that means "Fast" for this model (the app-server's `priority` + * tier, or a legacy `fast`), or null when the model offers no fast mode. Named so no OpenCode + * payload carries the key; left serialized so the disk catalogue cache keeps it. + */ + val codexFastServiceTier: String? = null, + /** Codex only: the server's description of the fast tier, shown beside the fast-mode toggle. */ + val codexFastDescription: String? = null, + /** Codex only: true when the server defaults this model to the fast tier. */ + val codexFastByDefault: Boolean = false, + /** + * Codex only: the model's `defaultReasoningEffort`. Sent explicitly on every turn, because the + * app-server applies `effort` to subsequent turns too and omitting it would inherit the previous one. + */ + val codexDefaultEffort: String? = null, + /** + * Codex only: the model's `defaultServiceTier`, or null for standard speed. Sent explicitly on + * every turn for the same reason as [codexDefaultEffort]. + */ + val codexDefaultServiceTier: String? = null, ) @Serializable @@ -165,7 +185,12 @@ data class OpenCodeAgent( val description: String? = null, val mode: String? = null, val native: Boolean = false, - val hidden: Boolean = false, + /** + * Nullable on purpose: OpenCode sends `"hidden": null` for plugin agents (oh-my-opencode-slim's + * `orchestrator`, `fixer`, ...), and a non-null Boolean made the whole `GET /agent` list fail to + * decode, leaving the mode picker with nothing to offer. Only `true` hides an agent. + */ + val hidden: Boolean? = null, ) /** @@ -178,7 +203,7 @@ data class OpenCodeAgent( * hard-coding `build`. */ fun List.selectablePrimaryAgents(): List = - filter { (it.mode == null || it.mode == "primary" || it.mode == "all") && !it.hidden } + filter { (it.mode == null || it.mode == "primary" || it.mode == "all") && it.hidden != true } /** The server's default agent: the first selectable primary it lists, or null when it offers none. */ fun List.defaultAgentName(): String? = selectablePrimaryAgents().firstOrNull()?.name @@ -295,6 +320,11 @@ data class PromptRequest( val variant: String? = null, val attachments: List = emptyList(), val noReply: Boolean = false, + /** + * Codex only: the service tier for this turn. Null leaves the choice to Codex; `"default"` + * forces standard speed. Built field by field for OpenCode, so it is never sent to that server. + */ + val serviceTier: String? = null, ) @Serializable @@ -302,6 +332,11 @@ data class PromptAttachment( val filename: String, val mime: String, val url: String, + /** + * Guest path of a copy of this file inside the workspace, set when the file's MIME type cannot + * be sent as a model file part. The agent reads it from disk instead; [url] is blank then. + */ + val workspacePath: String? = null, ) @Serializable diff --git a/app/src/main/java/com/yugahashimoto/andcode/core/api/OpenCodeV2ApiClient.kt b/app/src/main/java/com/yugahashimoto/andcode/core/api/OpenCodeV2ApiClient.kt index acccd4bf6..3f5df52f3 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/core/api/OpenCodeV2ApiClient.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/core/api/OpenCodeV2ApiClient.kt @@ -186,11 +186,12 @@ class OpenCodeV2ApiClient( /** * Internal V2 agents (compaction, title, summary) are reported as `mode: primary` with - * `hidden: true`; the app's agent model has no hidden flag yet, so they are dropped here to - * keep them out of the mode picker and settings. + * `hidden: true`. V2 does not copy the flag onto [OpenCodeAgent]; it drops those agents here so + * they stay out of the mode picker and settings. Only `true` hides: a missing or `null` flag + * (as plugin agents send it) is visible. */ override suspend fun agents(): List = - getData>("api/agent").filterNot { it.hidden }.map { agent -> + getData>("api/agent").filterNot { it.hidden == true }.map { agent -> OpenCodeAgent( name = agent.id, description = agent.description, @@ -1565,7 +1566,8 @@ private data class V2Agent( val id: String, val description: String? = null, val mode: String? = null, - val hidden: Boolean = false, + /** Nullable: an explicit `null` must decode as visible rather than fail the whole list. */ + val hidden: Boolean? = null, ) @Serializable diff --git a/app/src/main/java/com/yugahashimoto/andcode/core/diagnostics/CrashReportSanitizer.kt b/app/src/main/java/com/yugahashimoto/andcode/core/diagnostics/CrashReportSanitizer.kt index 4ce5ab435..6ca45122f 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/core/diagnostics/CrashReportSanitizer.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/core/diagnostics/CrashReportSanitizer.kt @@ -6,10 +6,36 @@ internal object CrashReportSanitizer { private const val MAX_LOG_CHARS = 1_000 private const val MAX_KEY_CHARS = 40 private const val MAX_VALUE_CHARS = 100 + private const val MAX_TYPE_CHARS = 64 fun message(value: String): String = SecretRedaction.redact(value.replace(Regex("\\s+"), " ").trim()).take(MAX_LOG_CHARS) fun key(value: String): String = value.replace(Regex("[^A-Za-z0-9_]"), "_").take(MAX_KEY_CHARS).ifBlank { "key" } fun value(value: String): String = message(value).take(MAX_VALUE_CHARS) + + /** + * A fixed, non-free-form failure category for a runtime error report. + * + * Only the runtime id and a charset-limited error type are kept. The runtime's own error text is + * deliberately dropped: it can quote prompts, model output, file paths and hosts, none of which + * belong in an off-device report. + */ + fun errorCategory( + runtimeId: String, + errorType: String?, + ): String { + // The provider supplies the type, so treat it as untrusted: only an identifier-shaped value + // survives. Anything carrying spaces, punctuation, paths or hosts collapses to "unknown" + // instead of being reported. + val type = + errorType + ?.take(MAX_TYPE_CHARS) + ?.takeIf { SAFE_TYPE.matches(it) } + ?: "unknown" + val runtime = runtimeId.replace(Regex("[^A-Za-z0-9_-]"), "_").take(MAX_KEY_CHARS).ifBlank { "runtime" } + return "Runtime session error: $runtime/$type" + } + + private val SAFE_TYPE = Regex("[A-Za-z][A-Za-z0-9_]{0,63}") } diff --git a/app/src/main/java/com/yugahashimoto/andcode/core/security/SecretRedaction.kt b/app/src/main/java/com/yugahashimoto/andcode/core/security/SecretRedaction.kt index 8972c43c2..05cc17832 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/core/security/SecretRedaction.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/core/security/SecretRedaction.kt @@ -15,18 +15,29 @@ object SecretRedaction { private val PATTERNS = listOf( Regex("(?i)bearer\\s+[A-Za-z0-9._~+/-]{8,}=*") to "Bearer ", - Regex("(?i)authorization\\s*[:=]\\s*\\S+") to "authorization=", - Regex("(?i)cookie\\s*[:=]\\s*\\S+") to "cookie=", + // Matches the scheme *and* the credential after it, so `Authorization: Basic ` + // cannot leave the Base64 blob behind after only the scheme word is consumed. + Regex("(?i)\\bauthorization\\s*[:=]\\s*[^\\s,;}]+(?:\\s+[^\\s,;}]+)?") to "authorization=", + // The whole Cookie line, not just the first pair: `a=1; session=...` would otherwise + // keep the session cookie in the clear. + Regex("(?i)\\bcookie\\s*[:=][^\\r\\n]*") to "cookie=", Regex("sk-ant-[A-Za-z0-9_-]{10,}") to "sk-ant-", // Covers plain OpenAI-style keys (sk-) as well as the newer, hyphenated // sk-proj-/sk-svcacct-/sk-admin- project-scoped key formats, which a body limited to // [A-Za-z0-9] would otherwise leave almost entirely unredacted. Regex("sk-[A-Za-z0-9_-]{10,}") to "sk-", Regex("gh[pousr]_[A-Za-z0-9]{20,}") to "gh_", - Regex("(?i)\\bpassword\\s*[:=]\\s*\\S+") to "password=", - Regex("(?i)\\bapi[_-]?key\\s*[:=]\\s*\\S+") to "api_key=", - Regex("(?i)\\baccess[_-]?token\\s*[:=]\\s*\\S+") to "access_token=", - Regex("(?i)\\btoken\\s*[:=]\\s*\\S+") to "token=", + // Any `key=value` / `"key": "value"` shape whose key names a credential. The key prefix + // allows `_`/`-` so env-style names (`ANTHROPIC_API_KEY`, `refresh_token`) match despite + // the `\b` boundary an underscore would otherwise break, and the optional quotes cover + // quoted JSON keys (`{"api_key":"..."}`) that the old unquoted patterns missed. + Regex( + "(?i)[A-Za-z0-9_-]*(?:api[_-]?key|access[_-]?token|refresh[_-]?token|auth[_-]?token|" + + "client[_-]?secret|secret|token|password)[\"']?\\s*[:=]\\s*[\"']?[^\\s,;}\"']+", + ) to "", + // URL userinfo (`https://user:pass@host/...`) has to be stripped before the query rule, + // which otherwise leaves the credentials sitting in front of the host. + Regex("(https?://)[^/@\\s:]+:[^/@\\s]+@") to "$1@", // Any http(s) URL with a query string, even one not routed through redactUrlQuery() // explicitly - the query is where an OAuth code/state/token parameter would live. Regex("(https?://[^\\s?#]+)\\?[^\\s]*") to "$1?", diff --git a/app/src/main/java/com/yugahashimoto/andcode/data/connection/SecureSettingsRepository.kt b/app/src/main/java/com/yugahashimoto/andcode/data/connection/SecureSettingsRepository.kt index 2422c3d10..4fa91afb4 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/data/connection/SecureSettingsRepository.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/data/connection/SecureSettingsRepository.kt @@ -409,6 +409,18 @@ class SecureSettingsRepository(context: Context) : RuntimeConnectionStore, Unrea get() = preferences.getBoolean(KEY_AUTO_EXPAND_REASONING, false) set(value) = preferences.edit().putBoolean(KEY_AUTO_EXPAND_REASONING, value).apply() + /** + * Codex fast mode: null follows each model's own default, true or false is an explicit choice. + * Stored as a removable key so "never chosen" and "chosen" stay distinct across restarts. + */ + var codexFastMode: Boolean? + get() = if (preferences.contains(KEY_CODEX_FAST_MODE)) preferences.getBoolean(KEY_CODEX_FAST_MODE, false) else null + set(value) { + val editor = preferences.edit() + if (value == null) editor.remove(KEY_CODEX_FAST_MODE) else editor.putBoolean(KEY_CODEX_FAST_MODE, value) + editor.apply() + } + var sendBehavior: String get() = preferences.getString(KEY_SEND_BEHAVIOR, "interrupt") ?: "interrupt" set(value) = preferences.edit().putString(KEY_SEND_BEHAVIOR, value).apply() @@ -592,6 +604,7 @@ class SecureSettingsRepository(context: Context) : RuntimeConnectionStore, Unrea private const val KEY_SYNTAX_THEME = "syntax_theme" private const val KEY_TOOL_CALL_DETAIL_LEVEL = "tool_call_detail_level" private const val KEY_AUTO_EXPAND_REASONING = "auto_expand_reasoning" + private const val KEY_CODEX_FAST_MODE = "codex_fast_mode" private const val KEY_SEND_BEHAVIOR = "send_behavior" private const val KEY_ENTER_TO_SEND = "enter_to_send" private const val KEY_SIDEBAR_GROUPING = "sidebar_grouping" diff --git a/app/src/main/java/com/yugahashimoto/andcode/data/repository/RuntimeActivityRepository.kt b/app/src/main/java/com/yugahashimoto/andcode/data/repository/RuntimeActivityRepository.kt index 74df8a319..7b384306f 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/data/repository/RuntimeActivityRepository.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/data/repository/RuntimeActivityRepository.kt @@ -65,7 +65,12 @@ class RuntimeActivityRepository( private val onPermissionAsked: ((PermissionRequest, String?, String) -> Unit)? = null, private val onPermissionResolved: ((String) -> Unit)? = null, private val onSessionIdle: ((String, String?, String) -> Unit)? = null, - private val onSessionError: ((String?, String?, String) -> Unit)? = null, + /** + * Reports a session failure: `(sessionId, message, errorType, runtimeId)`. [errorType] is the + * runtime's own error name where it has one (OpenCode's `ProviderAuthError`, …), and is what + * crash reporting classifies on - the free-form [message] never leaves the device. + */ + private val onSessionError: ((String?, String?, String?, String) -> Unit)? = null, private val onQuestionAsked: ((QuestionRequest, String?, String) -> Unit)? = null, /** * Reports a run that has stopped producing events, with the runtime's answer for why. Also @@ -590,7 +595,7 @@ class RuntimeActivityRepository( // settle their own state; there is nothing to log or notify here. if (event.isAbort) return appendLog(messages.eventError, event.message, event.sessionId) - onSessionError?.invoke(event.sessionId, event.message, target.id) + onSessionError?.invoke(event.sessionId, event.message, event.name, target.id) } is OpenCodeEvent.QuestionAsked -> { activateSession(target, event.request.sessionId, force = true) diff --git a/app/src/main/java/com/yugahashimoto/andcode/data/repository/RuntimeCatalogRepository.kt b/app/src/main/java/com/yugahashimoto/andcode/data/repository/RuntimeCatalogRepository.kt index 1e13080be..e6a3c9867 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/data/repository/RuntimeCatalogRepository.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/data/repository/RuntimeCatalogRepository.kt @@ -4,11 +4,13 @@ import com.yugahashimoto.andcode.core.api.OpenCodeAgent import com.yugahashimoto.andcode.core.api.OpenCodeHealth import com.yugahashimoto.andcode.core.api.OpenCodeSession import com.yugahashimoto.andcode.core.api.ProviderCatalog +import com.yugahashimoto.andcode.core.api.selectablePrimaryAgents import com.yugahashimoto.andcode.runtime.LocalAgent import com.yugahashimoto.andcode.runtime.RuntimeRegistry import com.yugahashimoto.andcode.runtime.RuntimeTarget import com.yugahashimoto.andcode.runtime.WorkspaceRef import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow @@ -37,6 +39,14 @@ data class RuntimeCatalogState( val workspaces: List = emptyList(), val isRefreshing: Boolean = false, val error: String? = null, + /** The line of [error] that is about agents, so a later successful agent read can drop it. */ + val agentsError: String? = null, + /** + * True while the agent list is being read and none is on screen yet: a local OpenCode installing + * its plugins can take tens of seconds on its first `GET /agent`, and the mode chip shows this + * instead of looking dead. + */ + val agentsLoading: Boolean = false, ) class RuntimeCatalogRepository( @@ -59,6 +69,13 @@ class RuntimeCatalogRepository( val state: StateFlow = mutableState.asStateFlow() private val refreshMutex = Mutex() + /** + * The one agent retry in flight and the runtime it is for, so repeated taps on an empty mode + * chip do not stack polls. Guarded by [agentRetryLock]: taps (main) and load (IO) both start it. + */ + private var agentRetry: Pair? = null + private val agentRetryLock = Any() + private val mutableAllSessions = MutableStateFlow>(emptyList()) /** @@ -165,9 +182,14 @@ class RuntimeCatalogRepository( // as the fetch took — seconds, while a stopped runtime is retried — and OpenCode // models appeared under Claude Code. if (current.runtime?.id != target.id) { - RuntimeCatalogState(runtime = target, isRefreshing = true) + RuntimeCatalogState(runtime = target, isRefreshing = true, agentsLoading = true) } else { - current.copy(runtime = target, isRefreshing = true, error = null) + current.copy( + runtime = target, + isRefreshing = true, + error = null, + agentsLoading = !current.agents.hasSelectableAgent(), + ) } } @@ -234,18 +256,111 @@ class RuntimeCatalogRepository( } } } - val errors = catalog.failures(messages) - mutableState.value = + // Like the providers above: a read that failed or offered no agent to pick must not blank + // a list this runtime already showed, or a flaky `GET /agent` empties a working picker. + val loadedAgents = catalog.agents.getOrNull() + // Read and written in one update: a background agent retry can land between this load's + // fetch and its write, and taking the list from before that would put an empty one back. + mutableState.update { current -> + val agentsToUse = + if (loadedAgents.hasSelectableAgent()) { + loadedAgents.orEmpty() + } else { + current.agentsFor(target).ifEmpty { loadedAgents.orEmpty() } + } + val agentsFound = agentsToUse.hasSelectableAgent() + // A list kept on screen - from an earlier load or a retry that already succeeded - + // carries no failure of its own, so neither the chip nor the "Agents: ..." line in + // the catalogue error reports this read's failure. + val readFailure = catalog.agentsFailure(messages) + val agentsFailure = readFailure.takeUnless { agentsFound && !loadedAgents.hasSelectableAgent() } + val errors = catalog.failures(messages).filterNot { agentsFailure == null && it == readFailure } RuntimeCatalogState( runtime = target, health = connection.getOrNull(), sessions = catalog.sessions.getOrDefault(emptyList()), providers = providersToUse, - agents = catalog.agents.getOrDefault(emptyList()), + agents = agentsToUse, workspaces = catalog.workspaces.getOrDefault(emptyList()), isRefreshing = false, error = errors.takeIf { it.isNotEmpty() }?.joinToString("\n"), + agentsError = agentsFailure, + // Still loading when nothing selectable came back: the retry below takes over. + agentsLoading = !agentsFound, ) + } + } + // Outside the lock: a local OpenCode installing its plugins can take tens of seconds per + // `GET /agent`, and the rest of the catalogue - and every other refresh - must not wait on it. + if (!mutableState.value.agentsFor(target).hasSelectableAgent()) refreshAgents() + } + + /** + * The runtime's agents, retried a few times while the answer fails or offers nothing to pick. + * + * A local OpenCode with plugins (oh-my-opencode-slim, opencode-mem, ...) installs them on its + * first `GET /agent` after a start, which can take tens of seconds or fail outright. That one + * answer used to be final until something else refreshed the catalogue, so the agent chip kept + * showing "build" over an empty menu that nothing would ever refill. + */ + private suspend fun loadAgents(target: RuntimeTarget): Result> { + var result = runCatching { target.listAgents() } + repeat(AGENT_RETRIES) { + if (result.getOrNull().hasSelectableAgent() || registry.selected.value?.id != target.id) return result + delay(AGENT_RETRY_DELAY_MILLIS) + result = runCatching { target.listAgents() } + } + return result + } + + /** + * Re-reads the agent list in the background until the runtime offers one to pick, for a load + * whose `GET /agent` failed or for a mode chip tapped while empty. + * + * Only one retry runs at a time. A runtime that never connected gets a full [refresh] instead, + * so the chip does not fill in agents over an otherwise empty, errored catalogue. The rest of the + * catalogue is left alone, and a failed retry keeps whatever agents are already on screen. + */ + fun refreshAgents() { + val target = registry.selected.value ?: return + val current = mutableState.value + if (current.runtime?.id == target.id && current.health == null) { + // Not connected yet. A load still in flight will retry the agents itself once it + // connects; a load that gave up gets a full refresh, not a poll of a dead server. + if (!current.isRefreshing) { + // Disables the "tap to retry" item straight away, so repeated taps cannot queue one + // full reconnect each behind the refresh lock before the load gets to run. + mutableState.update { if (it.runtime?.id == target.id) it.copy(agentsLoading = true) else it } + refresh() + } + return + } + synchronized(agentRetryLock) { + val running = agentRetry + if (running != null && running.second.isActive) { + // Still polling this runtime: let it finish. A retry left over from the runtime the + // user switched away from would only give up, so it must not block this one's. + if (running.first == target.id) return + running.second.cancel() + } + mutableState.update { + if (it.runtime?.id == target.id && !it.agents.hasSelectableAgent()) it.copy(agentsLoading = true) else it + } + agentRetry = + target.id to + scope.launch { + val list = loadAgents(target).getOrNull()?.takeIf { it.hasSelectableAgent() } + if (registry.selected.value?.id != target.id) return@launch + mutableState.update { + when { + it.runtime?.id != target.id -> it + list != null -> it.withAgents(list) + // A load still in flight for this runtime keeps the chip loading: it reads the + // agents again itself, and a "tap to retry" now would only race it. + else -> it.copy(agentsLoading = it.isRefreshing) + } + } + } } } @@ -255,11 +370,20 @@ class RuntimeCatalogRepository( val agents: Result>, val workspaces: Result>, ) { + /** + * One line, so [withAgents] can find it again in [RuntimeCatalogState.error]: decoding errors + * carry the JSON input on a second line, which a line-by-line removal would never match. + */ + fun agentsFailure(messages: RuntimeCatalogMessages): String? = + agents.exceptionOrNull()?.let { + messages.agents(it.safeMessage(messages.connectionFailed).replace(Regex("\\s*\\n\\s*"), " ")) + } + fun failures(messages: RuntimeCatalogMessages): List = listOfNotNull( sessions.exceptionOrNull()?.let { messages.sessions(it.safeMessage(messages.connectionFailed)) }, providers.exceptionOrNull()?.let { messages.providers(it.safeMessage(messages.connectionFailed)) }, - agents.exceptionOrNull()?.let { messages.agents(it.safeMessage(messages.connectionFailed)) }, + agentsFailure(messages), workspaces.exceptionOrNull()?.let { messages.workspaces(it.safeMessage(messages.connectionFailed)) }, ) } @@ -267,6 +391,8 @@ class RuntimeCatalogRepository( private companion object { const val CONNECT_RETRIES = 6 const val CONNECT_RETRY_DELAY_MILLIS = 2_500L + const val AGENT_RETRIES = 4 + const val AGENT_RETRY_DELAY_MILLIS = 3_000L } } @@ -299,3 +425,17 @@ internal fun shouldReplaceProviderCatalog( current: ProviderCatalog, fresh: ProviderCatalog, ): Boolean = fresh.connected.isNotEmpty() || current.connected.isEmpty() + +/** The agents on screen, but only while the state still belongs to [target]; see [providersFor]. */ +private fun RuntimeCatalogState.agentsFor(target: RuntimeTarget): List = + if (runtime?.id == target.id) agents else emptyList() + +/** Whether this answer offers at least one agent the mode chip can show. */ +private fun List?.hasSelectableAgent(): Boolean = this?.selectablePrimaryAgents()?.isNotEmpty() == true + +/** This state with [agents] in place, and the earlier agents failure removed from [error]. */ +private fun RuntimeCatalogState.withAgents(agents: List): RuntimeCatalogState { + val remaining = + error?.lineSequence()?.filterNot { it == agentsError }?.joinToString("\n")?.takeIf { it.isNotBlank() } + return copy(agents = agents, error = remaining, agentsError = null, agentsLoading = false) +} diff --git a/app/src/main/java/com/yugahashimoto/andcode/data/settings/AppPreferencesRepository.kt b/app/src/main/java/com/yugahashimoto/andcode/data/settings/AppPreferencesRepository.kt index ac0c39531..08be2a757 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/data/settings/AppPreferencesRepository.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/data/settings/AppPreferencesRepository.kt @@ -43,6 +43,8 @@ data class AppPreferences( val syntaxTheme: String = "one-dark", val toolCallDetailLevel: String = "detailed", val autoExpandReasoning: Boolean = false, + /** Codex fast mode: null follows each model's default, otherwise the user's explicit choice. */ + val codexFastMode: Boolean? = null, val sendBehavior: String = "interrupt", val enterToSend: Boolean = false, val sidebarGrouping: String = "project", @@ -93,6 +95,7 @@ class AppPreferencesRepository( syntaxTheme = settings.syntaxTheme, toolCallDetailLevel = settings.toolCallDetailLevel, autoExpandReasoning = settings.autoExpandReasoning, + codexFastMode = settings.codexFastMode, sendBehavior = settings.sendBehavior, enterToSend = settings.enterToSend, sidebarGrouping = settings.sidebarGrouping, @@ -302,6 +305,11 @@ class AppPreferencesRepository( mutableState.update { it.copy(autoExpandReasoning = enabled) } } + fun setCodexFastMode(enabled: Boolean?) { + settings.codexFastMode = enabled + mutableState.update { it.copy(codexFastMode = enabled) } + } + fun setSendBehavior(behavior: String) { settings.sendBehavior = behavior mutableState.update { it.copy(sendBehavior = behavior) } @@ -335,6 +343,7 @@ class AppPreferencesRepository( fun setAnalyticsEnabled(enabled: Boolean) { settings.analyticsEnabled = enabled com.yugahashimoto.andcode.core.diagnostics.AnalyticsReporter.setEnabled(enabled) + com.yugahashimoto.andcode.core.diagnostics.CrashReporter.setEnabled(enabled) mutableState.update { it.copy(analyticsEnabled = enabled) } } diff --git a/app/src/main/java/com/yugahashimoto/andcode/di/AppModule.kt b/app/src/main/java/com/yugahashimoto/andcode/di/AppModule.kt index b5af1b2f6..7e0a386df 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/di/AppModule.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/di/AppModule.kt @@ -216,7 +216,7 @@ val appModule = onSessionIdle = { sessionId, title, runtimeId -> notifications.notifySessionComplete(sessionId, title, runtimeId) }, - onSessionError = { sessionId, message, runtimeId -> + onSessionError = { sessionId, message, _, runtimeId -> notifications.notifySessionError(sessionId, message, runtimeId) }, onQuestionAsked = { request, title, runtimeId -> diff --git a/app/src/main/java/com/yugahashimoto/andcode/di/ViewModelModule.kt b/app/src/main/java/com/yugahashimoto/andcode/di/ViewModelModule.kt index 3fb75b63a..af461c427 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/di/ViewModelModule.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/di/ViewModelModule.kt @@ -1,6 +1,8 @@ package com.yugahashimoto.andcode.di import com.yugahashimoto.andcode.AndCodeApplication +import com.yugahashimoto.andcode.data.connection.SecureSettingsRepository +import com.yugahashimoto.andcode.data.settings.AppPreferencesRepository import com.yugahashimoto.andcode.feature.activity.ActivityViewModel import com.yugahashimoto.andcode.feature.chat.ChatViewModel import com.yugahashimoto.andcode.feature.settings.SettingsViewModel @@ -17,15 +19,24 @@ val viewModelModule = viewModel { val app = androidContext().applicationContext as AndCodeApplication + val preferences: AppPreferencesRepository = get() + val settings: SecureSettingsRepository = get() ChatViewModel( draftRepo = get(), pullRequestStatuses = get(), + // Same Codex fast-mode wiring as the factory in ui/AndCodeApp.kt; keep the two in step. + onCodexFastModeChanged = preferences::setCodexFastMode, + initialCodexFastMode = settings.codexFastMode, // Kept in step with the hand-rolled factory in ui/AndCodeApp.kt: both construction // paths have to park the connection probe and stall watchdog while the app is // backgrounded, or whichever one the screen happens to use decides whether those // 30-second loops keep polling out of sight. awaitForeground = { app.appForeground.foreground.first { visible -> visible } }, videoNotSupportedMessage = androidContext().getString(com.yugahashimoto.andcode.R.string.error_video_not_supported), + workspaceAttachmentNoteTemplate = + androidContext().getString( + com.yugahashimoto.andcode.R.string.chat_workspace_attachment_note, + ), ) } diff --git a/app/src/main/java/com/yugahashimoto/andcode/feature/assistant/AndCodeVoiceSession.kt b/app/src/main/java/com/yugahashimoto/andcode/feature/assistant/AndCodeVoiceSession.kt index 224e97ffd..0d79652ae 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/feature/assistant/AndCodeVoiceSession.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/feature/assistant/AndCodeVoiceSession.kt @@ -517,7 +517,7 @@ private fun VoiceAssistantSurface( ) { Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { Column(modifier = Modifier.weight(1f)) { - Text("AndCode", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold) + Text(stringResource(R.string.app_name), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold) Text(stringResource(R.string.home_assistant), color = MaterialTheme.colorScheme.onSurfaceVariant) } IconButton(onClick = onClose) { diff --git a/app/src/main/java/com/yugahashimoto/andcode/feature/chat/ChatHomeScreen.kt b/app/src/main/java/com/yugahashimoto/andcode/feature/chat/ChatHomeScreen.kt index 1d30bfc12..5d590f7e4 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/feature/chat/ChatHomeScreen.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/feature/chat/ChatHomeScreen.kt @@ -41,6 +41,7 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.toggleable import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardActions @@ -53,6 +54,7 @@ import androidx.compose.material.icons.automirrored.filled.Send import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.ArrowDownward import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material.icons.filled.Bolt import androidx.compose.material.icons.filled.CameraAlt import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.Close @@ -112,6 +114,9 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.TextStyle @@ -128,6 +133,7 @@ import androidx.core.content.ContextCompat import com.yugahashimoto.andcode.R import com.yugahashimoto.andcode.core.api.OpenCodeAgent import com.yugahashimoto.andcode.core.api.OpenCodeCommand +import com.yugahashimoto.andcode.core.api.OpenCodeModel import com.yugahashimoto.andcode.core.api.OpenCodeProvider import com.yugahashimoto.andcode.core.api.OpenCodeSkill import com.yugahashimoto.andcode.core.api.PromptAttachment @@ -152,6 +158,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlinx.serialization.json.JsonPrimitive /** What the long-press action sheet offers for one message: its id, its text to copy, and whether "Edit & resend" applies. */ private data class MessageActionTarget( @@ -176,8 +183,16 @@ fun ChatHomeScreen( onSelectRuntime: (String) -> Unit, onSelectModel: (String, String) -> Unit, onSelectAgent: (String) -> Unit, + /** Asks the runtime for its agents again; the mode chip calls it when its list is empty. */ + onRefreshAgents: () -> Unit = {}, + agentsLoading: Boolean = false, selectedVariant: String? = null, onSelectVariant: (String?) -> Unit = {}, + /** + * Turns Codex's fast service tier on or off. The chip for it shows only while + * [ChatUiState.fastModeAvailable], i.e. for a Codex model that lists the tier. + */ + onToggleFastMode: (Boolean) -> Unit = {}, attachments: List = emptyList(), onAttach: () -> Unit = {}, onRemoveAttachment: (Int) -> Unit = {}, @@ -644,6 +659,8 @@ fun ChatHomeScreen( agents = agents, selectedAgentId = selectedAgentId, onSelectAgent = onSelectAgent, + onRefreshAgents = onRefreshAgents, + agentsLoading = agentsLoading, thinkingOptions = providers .firstOrNull { it.id == selectedProviderId } @@ -651,6 +668,10 @@ fun ChatHomeScreen( ?.variants?.keys?.toList() ?: emptyList(), selectedVariant = state.selectedVariant, onSelectVariant = onSelectVariant, + fastModeAvailable = state.fastModeAvailable, + fastModeEnabled = state.fastModeEnabled, + fastModeDescription = state.fastModeDescription, + onToggleFastMode = onToggleFastMode, attachments = state.attachments, onAttach = onAttach, onRemoveAttachment = onRemoveAttachment, @@ -1238,9 +1259,15 @@ private fun ChatComposer( agents: List, selectedAgentId: String?, onSelectAgent: (String) -> Unit, + onRefreshAgents: () -> Unit, + agentsLoading: Boolean = false, thinkingOptions: List, selectedVariant: String?, onSelectVariant: (String?) -> Unit, + fastModeAvailable: Boolean, + fastModeEnabled: Boolean, + fastModeDescription: String?, + onToggleFastMode: (Boolean) -> Unit, attachments: List, onAttach: () -> Unit, onRemoveAttachment: (Int) -> Unit, @@ -1601,6 +1628,8 @@ private fun ChatComposer( agents = agents, selectedAgentId = selectedAgentId, onSelect = onSelectAgent, + onRefresh = onRefreshAgents, + loading = agentsLoading, ) // Auto-accept answers permission prompts, so it only means something on a runtime // that asks for permission. Antigravity decides permissions per invocation from its @@ -1619,6 +1648,15 @@ private fun ChatComposer( onSelect = onSelectVariant, ) } + // Right after the effort chip: both decide how the model runs the next turn, so they + // read as a pair. Only a Codex model with a fast tier offers it. + if (fastModeAvailable) { + FastModeChip( + enabled = fastModeEnabled, + description = fastModeDescription, + onToggle = onToggleFastMode, + ) + } if (systemPromptPresets.isNotEmpty()) { SystemPromptChip( presets = systemPromptPresets, @@ -1722,6 +1760,66 @@ private fun ThinkingChip( } } +/** + * Turns Codex's fast service tier on or off for the turns that follow. + * + * A plain on/off pill in the same tokens as the thinking and auto-accept chips, so it reads as one + * more setting on the row rather than a new kind of control. It carries switch semantics rather + * than a click, so a screen reader also says whether it is on. The server's own wording for the + * tier ("1.5x speed, increased usage") goes to the screen reader: a phone-width row has no room + * for it. + */ +@Composable +private fun FastModeChip( + enabled: Boolean, + description: String?, + onToggle: (Boolean) -> Unit, +) { + val containerColor = + if (enabled) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surfaceVariant + } + val contentColor = + if (enabled) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + Surface( + modifier = + Modifier + .clip(RoundedCornerShape(100.dp)) + .toggleable(value = enabled, role = Role.Switch, onValueChange = onToggle) + .testTag("chat-fast-mode"), + shape = RoundedCornerShape(100.dp), + color = containerColor, + contentColor = contentColor, + ) { + Row( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Icon( + Icons.Default.Bolt, + contentDescription = + description + ?.takeIf { it.isNotBlank() } + ?.let { stringResource(R.string.cd_fast_mode_with_description, it) } + ?: stringResource(R.string.cd_fast_mode), + modifier = Modifier.size(14.dp), + ) + Text( + stringResource(R.string.chat_fast_mode), + style = MaterialTheme.typography.labelMedium, + maxLines = 1, + ) + } + } +} + /** * Switches the system-prompt preset this chat sends with, without leaving it. * @@ -1800,20 +1898,26 @@ private fun SystemPromptChip( /** * A model variant's display label for the thinking chip. * - * Claude Code and Antigravity both offer reasoning-effort slugs ("low", "medium", "high", plus - * Claude Code's own "xhigh" and "max"); those get their proper names ("Extra High", "Max") instead - * of the raw slug, which used to make the CLI's highest level ("max") look like just another word - * next to "xhigh" rather than the new top tier it is. Any other provider's variant id - OpenCode's - * own models offer different ones - falls back to capitalizing the raw value, as before. + * Claude Code, Antigravity and Codex all offer reasoning-effort slugs ("low", "medium", "high", + * plus Claude Code's and Codex's "xhigh" and "max"); those get their proper names ("Extra High", + * "Max") instead of the raw slug, which used to make the CLI's highest level ("max") look like just + * another word next to "xhigh" rather than the new top tier it is. Codex adds "ultra" above "max" + * and "minimal" and "none" below "low"; "none" reads "Off", since the chip then names thinking + * that is switched off, and a bare "None" would look like no choice was made. Any other provider's + * variant id - OpenCode's own models offer different ones - falls back to capitalizing the raw + * value, as before. */ @Composable private fun thinkingLevelLabel(level: String): String = when (level.lowercase()) { + "none" -> stringResource(R.string.chat_thinking_level_none) + "minimal" -> stringResource(R.string.chat_thinking_level_minimal) "low" -> stringResource(R.string.chat_thinking_level_low) "medium" -> stringResource(R.string.chat_thinking_level_medium) "high" -> stringResource(R.string.chat_thinking_level_high) "xhigh" -> stringResource(R.string.chat_thinking_level_xhigh) "max" -> stringResource(R.string.chat_thinking_level_max) + "ultra" -> stringResource(R.string.chat_thinking_level_ultra) else -> level.replaceFirstChar { it.uppercase() } } @@ -1869,6 +1973,8 @@ private fun ModeChip( agents: List, selectedAgentId: String?, onSelect: (String) -> Unit, + onRefresh: () -> Unit = {}, + loading: Boolean = false, ) { var expanded by remember { mutableStateOf(false) } // Every primary agent the server reports is offered, not just build/plan: plugins such as @@ -1889,7 +1995,13 @@ private fun ModeChip( modifier = Modifier .widthIn(max = 92.dp) - .clickable(onClick = { expanded = true }), + .clickable( + onClick = { + expanded = true + if (selectableAgents.isEmpty() && !loading) onRefresh() + }, + ) + .testTag("chat-mode-chip"), shape = RoundedCornerShape(100.dp), color = MaterialTheme.colorScheme.surfaceVariant, contentColor = MaterialTheme.colorScheme.onSurfaceVariant, @@ -1899,11 +2011,20 @@ private fun ModeChip( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp), ) { - Icon( - Icons.Outlined.Shield, - contentDescription = stringResource(R.string.cd_agent_mode), - modifier = Modifier.size(14.dp), - ) + if (loading && selectableAgents.isEmpty()) { + val loadingDescription = stringResource(R.string.chat_agents_loading) + CircularProgressIndicator( + modifier = Modifier.size(14.dp).semantics { contentDescription = loadingDescription }, + color = MaterialTheme.colorScheme.onSurfaceVariant, + strokeWidth = 1.5.dp, + ) + } else { + Icon( + Icons.Outlined.Shield, + contentDescription = stringResource(R.string.cd_agent_mode), + modifier = Modifier.size(14.dp), + ) + } Text( label, style = MaterialTheme.typography.labelMedium, @@ -1918,6 +2039,20 @@ private fun ModeChip( } } DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + if (selectableAgents.isEmpty()) { + DropdownMenuItem( + text = { + Text( + stringResource( + if (loading) R.string.chat_agents_loading else R.string.chat_agents_load_failed, + ), + ) + }, + onClick = onRefresh, + enabled = !loading, + modifier = if (loading) Modifier.testTag("chat-mode-loading") else Modifier, + ) + } selectableAgents.forEach { agent -> DropdownMenuItem( text = { Text(agent.name) }, @@ -2215,3 +2350,62 @@ private fun ChatHomeScreenEmptyPreview() { ) } } + +/** + * A Codex model with the full effort range and its fast tier switched on, at phone width, to check + * that the effort and Fast chips sit side by side and the row still fits. + */ +@Preview(showBackground = true, widthDp = 360) +@Composable +private fun ChatHomeScreenCodexFastPreview() { + val efforts = listOf("none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra") + AndCodeTheme { + ChatHomeScreen( + state = + ChatUiState( + backendName = "Codex", + isConnected = true, + selectedVariant = "ultra", + fastModeAvailable = true, + fastModeEnabled = true, + fastModeDescription = "1.5x speed, increased usage", + ), + providers = + listOf( + OpenCodeProvider( + id = "openai", + name = "OpenAI (Codex)", + models = + mapOf( + "gpt-6-astra" to + OpenCodeModel( + id = "gpt-6-astra", + providerId = "openai", + name = "GPT-6-Astra", + variants = efforts.associateWith { JsonPrimitive(it) }, + ), + ), + ), + ), + agents = emptyList(), + selectedProviderId = "openai", + selectedModelId = "gpt-6-astra", + selectedAgentId = null, + runtimeTargets = emptyList(), + selectedRuntimeId = null, + onSelectRuntime = {}, + onSelectModel = { _, _ -> }, + onSelectAgent = {}, + onSelectQuestionAnswer = { _, _, _ -> }, + onSubmitQuestion = {}, + onSendMessage = {}, + onPermission = { _, _, _ -> }, + onAbort = {}, + onMic = {}, + onNewChat = {}, + onOpenLocalSetup = {}, + onOpenRemoteSetup = {}, + onOpenDrawer = {}, + ) + } +} diff --git a/app/src/main/java/com/yugahashimoto/andcode/feature/chat/ChatViewModel.kt b/app/src/main/java/com/yugahashimoto/andcode/feature/chat/ChatViewModel.kt index fc9c0290e..0f9b61222 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/feature/chat/ChatViewModel.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/feature/chat/ChatViewModel.kt @@ -508,6 +508,15 @@ data class ChatUiState( val autoAcceptPermissions: Boolean = false, val contextTokensUsed: Long = 0L, val selectedVariant: String? = null, + /** + * Whether the selected model offers Codex's fast service tier. Only Codex sets this, and only + * for a model that lists the tier, so the toggle is hidden everywhere else. + */ + val fastModeAvailable: Boolean = false, + /** Effective fast-mode state: the user's choice, or the model's own default when there is none. */ + val fastModeEnabled: Boolean = false, + /** The server's own description of the fast tier, read out by screen readers on the Fast chip. */ + val fastModeDescription: String? = null, val attachments: List = emptyList(), val imagePreviews: List = emptyList(), val selectedProviderId: String? = null, @@ -548,6 +557,24 @@ data class ChatUiState( val patchDiff: PatchDiffState? = null, ) +/** + * The `serviceTier` a prompt carries for a model whose fast tier is [fastTier], given the user's + * [fastMode] choice: null when the model has no fast tier or the choice still follows the model's + * default (Codex decides), the tier when fast mode is on, and `"default"` when it was turned off on a + * model that would otherwise run fast. + */ +internal fun codexServiceTier( + fastTier: String?, + fastMode: Boolean?, +): String? { + fastTier ?: return null + return when (fastMode) { + null -> null + true -> fastTier + false -> "default" + } +} + class ChatViewModel( private val backend: OpenCodeBackend? = null, private val eventFlow: Flow? = null, @@ -602,6 +629,17 @@ class ChatViewModel( */ private val videoNotSupportedMessage: String = "Video files cannot be sent directly. Remove the video and attach images instead.", + /** + * Template (one `%s`) telling the agent where a non-sendable attachment was copied inside the + * workspace. Injected the same way as [videoNotSupportedMessage]; the app passes the string + * resource (kept English-only, since it is a prompt fragment for the model) while tests keep + * the same default. + */ + private val workspaceAttachmentNoteTemplate: String = "[Attached file available in the workspace: %s]", + /** Persists the Codex fast-mode choice (null = follow the model default). */ + private val onCodexFastModeChanged: (Boolean?) -> Unit = {}, + /** The user's saved Codex fast-mode choice, read when the ViewModel starts. */ + private val initialCodexFastMode: Boolean? = null, ) : ViewModel() { private val _uiState = MutableStateFlow( @@ -643,6 +681,15 @@ class ChatViewModel( private var eventJob: Job? = null private var tts: TextToSpeech? = null private var contextLimit: Long = 0L + + /** Codex fast-mode user choice; null follows the selected model's own default. */ + private var codexFastMode: Boolean? = initialCodexFastMode + + /** The selected model's Codex fast tier id, or null when that model offers no fast mode. */ + private var selectedFastServiceTier: String? = null + + /** Whether the selected model's Codex fast tier is on by default. */ + private var selectedFastByDefault: Boolean = false private var recoveringTransiently = false private val streamedParts = mutableMapOf>() @@ -860,8 +907,13 @@ class ChatViewModel( modelId: String?, agentId: String?, contextLimit: Long = 0L, + fastServiceTier: String? = null, + fastByDefault: Boolean = false, + fastDescription: String? = null, ) { this.contextLimit = contextLimit + selectedFastServiceTier = fastServiceTier + selectedFastByDefault = fastByDefault _uiState.update { val modelChanged = providerId != it.selectedProviderId || modelId != it.selectedModelId it.copy( @@ -874,10 +926,42 @@ class ChatViewModel( // variant straight off whatever provider set it last, for any model whose id has no // effort baked in. selectedVariant = if (modelChanged) null else it.selectedVariant, + fastModeDescription = fastDescription, + ) + } + refreshFastMode() + } + + /** + * Derives the three fast-mode fields from the model and the user's choice. Fast mode exists only + * for a model that lists a fast tier, so every other model reads as unavailable. + */ + private fun refreshFastMode() { + val available = selectedFastServiceTier != null + _uiState.update { + it.copy( + fastModeAvailable = available, + fastModeEnabled = available && (codexFastMode ?: selectedFastByDefault), ) } } + /** + * Sets the Codex fast-mode choice and persists it. Passing null returns to the model's default. + */ + fun setFastMode(enabled: Boolean?) { + codexFastMode = enabled + onCodexFastModeChanged(enabled) + refreshFastMode() + } + + /** + * The `serviceTier` for a prompt on the selected model: null when fast mode is unavailable or + * still follows the default (Codex then chooses), the model's fast tier when the user turned it + * on, and `"default"` when they turned it off on a model that would otherwise be fast. + */ + private fun serviceTierForPrompt(): String? = codexServiceTier(selectedFastServiceTier, codexFastMode) + fun setAutoAcceptPermissions(enabled: Boolean) { _uiState.update { it.copy(autoAcceptPermissions = enabled) } } @@ -1299,6 +1383,22 @@ class ChatViewModel( _uiState.update { it.copy(editDraft = null) } } + /** + * Appends a reference for every [PromptAttachment] that only exists as a workspace file, so the + * agent knows to read it from disk. Returns [text] unchanged when there is nothing to reference. + */ + private fun appendWorkspaceAttachmentRefs( + text: String, + attachments: List, + ): String { + if (attachments.isEmpty()) return text + val refs = + attachments.joinToString("\n") { attachment -> + workspaceAttachmentNoteTemplate.format(attachment.workspacePath.orEmpty()) + } + return if (text.isBlank()) refs else "$text\n\n$refs" + } + fun sendMessage(text: String) { val normalized = text.trim() val pendingAttachments = _uiState.value.attachments @@ -1320,6 +1420,12 @@ class ChatViewModel( _uiState.update { it.copy(error = videoNotSupportedMessage) } return } + // Attachments whose MIME a provider would reject as a file part were copied into the + // workspace at attach time. They stay visible as chips but must not reach the runtime: the + // outgoing text points the agent at the on-disk copy instead. + val (modelAttachments, workspaceAttachments) = + pendingAttachments.partition { it.workspacePath.isNullOrBlank() } + val outgoingText = appendWorkspaceAttachmentRefs(normalized, workspaceAttachments) val currentBackend = backend if (currentBackend == null) { _uiState.update { it.copy(error = "OpenCode connection is not configured") } @@ -1336,19 +1442,19 @@ class ChatViewModel( if (!_uiState.value.isConnected) { offlineMessageQueue.update { - it + QueuedPrompt(normalized, pendingAttachments, _uiState.value.imagePreviews) + it + QueuedPrompt(outgoingText, modelAttachments, _uiState.value.imagePreviews) } val userMessage = ChatMessage( isUser = true, - parts = listOf(ChatPart.Text(id = UUID.randomUUID().toString(), text = normalized)), + parts = listOf(ChatPart.Text(id = UUID.randomUUID().toString(), text = outgoingText)), attachments = pendingAttachments, imagePreviews = _uiState.value.imagePreviews, ) _uiState.update { it.copy( messages = it.messages + userMessage, - offlineQueue = it.offlineQueue + normalized, + offlineQueue = it.offlineQueue + outgoingText, isOfflineQueued = true, attachments = emptyList(), imagePreviews = emptyList(), @@ -1363,7 +1469,7 @@ class ChatViewModel( val mustQueue = (currentBackend as? RuntimeTarget)?.capabilities?.forcesQueue == true if ((_sendBehavior.value == "queue" || mustQueue) && _uiState.value.isRunning) { messageQueue.update { - it + QueuedPrompt(normalized, pendingAttachments, _uiState.value.imagePreviews) + it + QueuedPrompt(outgoingText, modelAttachments, _uiState.value.imagePreviews) } _uiState.update { it.copy(attachments = emptyList(), imagePreviews = emptyList()) } return @@ -1374,7 +1480,7 @@ class ChatViewModel( ChatMessage( isUser = true, parts = - normalized.takeIf { it.isNotEmpty() }?.let { + outgoingText.takeIf { it.isNotEmpty() }?.let { listOf(ChatPart.Text(id = UUID.randomUUID().toString(), text = it)) }.orEmpty(), attachments = pendingAttachments, @@ -1436,12 +1542,13 @@ class ChatViewModel( currentBackend.sendMessage( targetSessionId, PromptRequest( - text = normalized, + text = outgoingText, providerId = _uiState.value.selectedProviderId, modelId = _uiState.value.selectedModelId, agent = _uiState.value.selectedAgentId, variant = _uiState.value.selectedVariant, - attachments = pendingAttachments, + attachments = modelAttachments, + serviceTier = serviceTierForPrompt(), ), ) runCatching { currentBackend.session(targetSessionId).title } diff --git a/app/src/main/java/com/yugahashimoto/andcode/feature/chat/ModelAndRuntimePickerSheet.kt b/app/src/main/java/com/yugahashimoto/andcode/feature/chat/ModelAndRuntimePickerSheet.kt index 8648b1835..72772d6f2 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/feature/chat/ModelAndRuntimePickerSheet.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/feature/chat/ModelAndRuntimePickerSheet.kt @@ -14,6 +14,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Bolt import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.Psychology @@ -408,6 +409,16 @@ private fun ModelRow( modifier = Modifier.size(14.dp), ) } + // The same kind of hint for a Codex model with a fast tier, so the composer's Fast + // chip is not a surprise that only shows up after the model is picked. + if (model.codexFastServiceTier != null) { + Icon( + imageVector = Icons.Default.Bolt, + contentDescription = stringResource(R.string.cd_fast_mode), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(14.dp), + ) + } } Text( model.id, 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 e6c43af34..22623a018 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 @@ -51,6 +51,8 @@ data class SettingsUiState( val availableProviders: List = emptyList(), val connectedProviderIds: Set = emptySet(), val agents: List = emptyList(), + /** The selected runtime is still reading its agents; see [RuntimeCatalogState.agentsLoading]. */ + val agentsLoading: Boolean = false, val providerId: String? = null, val modelId: String? = null, val agentId: String? = null, @@ -217,6 +219,7 @@ class SettingsViewModel( availableProviders = managed.all, connectedProviderIds = (managed.connected.toSet() + oauth.locallyConnected) - oauth.locallyDisconnected, agents = core.runtime.agents.selectablePrimaryAgents(), + agentsLoading = core.runtime.agentsLoading, providerId = core.preferences.providerId, modelId = core.preferences.modelId, agentId = core.preferences.agentId, diff --git a/app/src/main/java/com/yugahashimoto/andcode/runtime/local/AntigravityRuntime.kt b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/AntigravityRuntime.kt index 3da6304f1..b14a05c2d 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/runtime/local/AntigravityRuntime.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/AntigravityRuntime.kt @@ -386,7 +386,7 @@ class AntigravityRuntime( val removed = records.remove(sessionId) != null messages.remove(sessionId) val safeSession = sessionId.replace(Regex("[^A-Za-z0-9._-]"), "_") - File(runtimeDirectory, "workspace/.andcode-attachments/$safeSession").deleteRecursively() + File(runtimeDirectory, "workspace/$ATTACHMENT_DIRECTORY/$safeSession").deleteRecursively() persist() return removed } @@ -574,7 +574,7 @@ internal fun prepareAntigravityPrompt( if (attachments.isEmpty()) return prompt val safeSession = sessionId.replace(Regex("[^A-Za-z0-9._-]"), "_") val attachmentDir = - File(runtimeDirectory, "workspace/.andcode-attachments/$safeSession/$turn").apply { + File(runtimeDirectory, "workspace/$ATTACHMENT_DIRECTORY/$safeSession/$turn").apply { check(mkdirs() || isDirectory) { "Cannot create Antigravity attachment directory" } } val references = @@ -591,7 +591,7 @@ internal fun prepareAntigravityPrompt( val safeName = attachment.filename.replace(Regex("[^A-Za-z0-9._-]"), "_").ifBlank { "attachment-$index" } val file = File(attachmentDir, "$index-$safeName") file.writeBytes(Base64.getDecoder().decode(encoded)) - "@/workspace/.andcode-attachments/$safeSession/$turn/${file.name}" + "@/workspace/$ATTACHMENT_DIRECTORY/$safeSession/$turn/${file.name}" } return (references + prompt).joinToString("\n") } diff --git a/app/src/main/java/com/yugahashimoto/andcode/runtime/local/AttachmentImporter.kt b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/AttachmentImporter.kt index 6a5e5ae0b..5a9956376 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/runtime/local/AttachmentImporter.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/AttachmentImporter.kt @@ -9,6 +9,7 @@ import android.util.Base64 import android.util.Log import com.yugahashimoto.andcode.core.api.PromptAttachment import java.io.ByteArrayOutputStream +import java.io.File class AttachmentImporter( private val context: Context, @@ -47,6 +48,30 @@ class AttachmentImporter( ) } + /** + * Writes [attachment]'s decoded bytes into `[workspaceDir]/.andcode-attachments` and returns + * the destination file. + * + * Used for MIME types a provider would reject as a file part (see [AttachmentSupport]): the file + * lands in the workspace so the agent can read it with its own file tools instead. + */ + fun writeToWorkspace( + attachment: PromptAttachment, + workspaceDir: File, + ): File { + val encoded = attachment.url.substringAfter(";base64,", missingDelimiterValue = "") + check(attachment.url.startsWith("data:") && encoded.isNotEmpty()) { + "Attachment has no inline content to copy" + } + val directory = + File(workspaceDir, ATTACHMENT_DIRECTORY).apply { + check(mkdirs() || isDirectory) { "Cannot create attachment directory" } + } + val destination = uniqueDestination(directory, attachment.filename) + destination.writeBytes(Base64.decode(encoded, Base64.DEFAULT)) + return destination + } + fun import( bitmap: Bitmap, filename: String = "image-${System.currentTimeMillis()}.jpg", @@ -129,4 +154,24 @@ class AttachmentImporter( }.getOrNull() private fun sanitize(name: String): String = name.replace(Regex("[^a-zA-Z0-9._-]"), "_").ifBlank { "attachment" } + + private fun uniqueDestination( + directory: File, + rawName: String, + ): File { + val name = sanitize(rawName) + if (!File(directory, name).exists()) return File(directory, name) + val dot = name.lastIndexOf('.') + val stem = if (dot > 0) name.substring(0, dot) else name + val extension = if (dot > 0) name.substring(dot) else "" + var index = 1 + while (true) { + val candidate = File(directory, "$stem-$index$extension") + if (!candidate.exists()) return candidate + index++ + } + } } + +/** Workspace subdirectory unsendable attachments are copied into. */ +internal const val ATTACHMENT_DIRECTORY = ".andcode-attachments" diff --git a/app/src/main/java/com/yugahashimoto/andcode/runtime/local/AttachmentSupport.kt b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/AttachmentSupport.kt new file mode 100644 index 000000000..f921e87b5 --- /dev/null +++ b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/AttachmentSupport.kt @@ -0,0 +1,56 @@ +package com.yugahashimoto.andcode.runtime.local + +import java.io.File + +/** + * Which attachment MIME types a runtime can carry as an ordinary model file part, and how a guest + * `/workspace` path maps onto the on-device runtime directory. + * + * Providers reject file parts outside the small set of types they actually read (for example + * `'file part media type application/zip' functionality not supported`). Anything else is copied + * into the workspace and referenced by path, which every coding-agent runtime can read with its own + * file tools. Video is not listed: [AttachmentImporter] converts it to image frames before it ever + * reaches here. + */ +object AttachmentSupport { + private const val PDF_MIME = "application/pdf" + private const val WORKSPACE_ROOT = "/workspace" + + fun isModelSendable(mime: String): Boolean = mime.startsWith("image/", ignoreCase = true) || mime.equals(PDF_MIME, ignoreCase = true) + + /** + * Host directory for a guest workspace path such as `/workspace/my-project`, or null when the + * path is not under `/workspace` (a remote runtime's own filesystem, for instance). + */ + fun hostWorkspace( + guestPath: String?, + runtimeWorkspaceRoot: File, + ): File? { + val path = guestPath?.trimEnd('/')?.takeIf { it.isNotBlank() } ?: return null + if (path != WORKSPACE_ROOT && !path.startsWith("$WORKSPACE_ROOT/")) return null + val relative = path.removePrefix(WORKSPACE_ROOT).trim('/') + if (relative.isEmpty()) return runtimeWorkspaceRoot + val candidate = File(runtimeWorkspaceRoot, relative) + // A session's directory is attacker-influenced, so re-check the resolved path stays inside + // the local runtime directory: `..` segments must not copy the file elsewhere on the device. + return runCatching { + val root = runtimeWorkspaceRoot.canonicalPath.trimEnd(File.separatorChar) + val resolved = candidate.canonicalPath + candidate.takeIf { resolved == root || resolved.startsWith("$root${File.separator}") } + }.getOrNull() + } + + /** + * Guest path for a host file inside the local runtime workspace, or null when the file is not + * under it - the inverse of [hostWorkspace], used to tell the agent where a copied file landed. + */ + fun guestWorkspacePath( + hostFile: File, + runtimeWorkspaceRoot: File, + ): String? { + val root = runtimeWorkspaceRoot.absolutePath.trimEnd('/') + val path = hostFile.absolutePath + if (path != root && !path.startsWith("$root/")) return null + return WORKSPACE_ROOT + path.removePrefix(root) + } +} diff --git a/app/src/main/java/com/yugahashimoto/andcode/runtime/local/CodexModels.kt b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/CodexModels.kt index 0e39cdf7d..3a85a49b4 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/runtime/local/CodexModels.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/CodexModels.kt @@ -5,8 +5,10 @@ import com.yugahashimoto.andcode.core.api.OpenCodeModel import com.yugahashimoto.andcode.core.api.OpenCodeProvider import com.yugahashimoto.andcode.core.api.ProviderCatalog 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.contentOrNull /** @@ -17,6 +19,9 @@ import kotlinx.serialization.json.contentOrNull object CodexModels { const val PROVIDER_ID = "openai" + /** Service-tier ids that mean "fast" when the server does not name the tier "Fast". */ + private val FAST_TIER_IDS = setOf("priority", "fast") + fun catalog( modelListResult: JsonObject, connected: Boolean, @@ -26,7 +31,19 @@ object CodexModels { entries.mapNotNull { entry -> val model = entry as? JsonObject ?: return@mapNotNull null val id = model.string("id") ?: return@mapNotNull null - id to OpenCodeModel(id = id, providerId = PROVIDER_ID, name = model.string("displayName") ?: id) + val fastTier = fastServiceTier(model) + id to + OpenCodeModel( + id = id, + providerId = PROVIDER_ID, + name = model.string("displayName") ?: id, + variants = reasoningEfforts(model), + codexFastServiceTier = fastTier?.first, + codexFastDescription = fastTier?.second, + codexFastByDefault = fastTier != null && model.string("defaultServiceTier") == fastTier.first, + codexDefaultEffort = model.string("defaultReasoningEffort")?.takeIf(String::isNotBlank), + codexDefaultServiceTier = model.string("defaultServiceTier")?.takeIf(String::isNotBlank), + ) }.toMap() val provider = OpenCodeProvider(id = PROVIDER_ID, name = "OpenAI (Codex)", models = models) return ProviderCatalog( @@ -35,8 +52,54 @@ object CodexModels { ) } + /** + * The id of the model Codex itself would use when none is named: the one `model/list` marks + * `isDefault`, else the first listed. Null when the list is empty. + */ + fun defaultModelId(modelListResult: JsonObject): String? { + val entries = (modelListResult["data"] as? JsonArray)?.mapNotNull { it as? JsonObject } ?: return null + val marked = entries.firstOrNull { (it["isDefault"] as? JsonPrimitive)?.booleanOrNull == true } + return (marked ?: entries.firstOrNull())?.string("id") + } + /** Codex has one agent - there is no separate plan/build split the way OpenCode's catalogue has. */ fun agents(): List = listOf(OpenCodeAgent(name = "codex", description = "Codex", mode = "primary", native = true)) + /** + * Reasoning efforts in the server's own order, keyed by the effort id the chat's thinking chip + * sends back as the turn's `effort`. Same shape as [ClaudeModels]' variants: the value is the + * id itself, so the chip reads the key and nothing else. + */ + private fun reasoningEfforts(model: JsonObject): Map { + val efforts = model["supportedReasoningEfforts"] as? JsonArray ?: return emptyMap() + return efforts.mapNotNull { entry -> + val effort = (entry as? JsonObject)?.string("reasoningEffort")?.takeIf(String::isNotBlank) + effort?.let { it to JsonPrimitive(it) } + }.toMap() + } + + /** + * The `(id, description)` of the fast service tier, or null when the model offers none. The tier + * is found by its name ("Fast") or its id (`priority`, `fast`); the deprecated + * `additionalSpeedTiers: ["fast"]` is the fallback for an older server that lists no tiers. + */ + private fun fastServiceTier(model: JsonObject): Pair? { + val tiers = model["serviceTiers"] as? JsonArray + val tier = + tiers?.firstNotNullOfOrNull { entry -> + val tierObject = entry as? JsonObject ?: return@firstNotNullOfOrNull null + val id = tierObject.string("id") ?: return@firstNotNullOfOrNull null + val name = tierObject.string("name") + if (name.equals("Fast", ignoreCase = true) || id in FAST_TIER_IDS) { + id to tierObject.string("description") + } else { + null + } + } + if (tier != null) return tier + val legacy = (model["additionalSpeedTiers"] as? JsonArray)?.any { (it as? JsonPrimitive)?.contentOrNull == "fast" } == true + return if (legacy) "fast" to null else null + } + private fun JsonObject.string(key: String): String? = (this[key] as? JsonPrimitive)?.contentOrNull } diff --git a/app/src/main/java/com/yugahashimoto/andcode/runtime/local/CodexRuntime.kt b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/CodexRuntime.kt index 87b4339f8..efb5aae4f 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/runtime/local/CodexRuntime.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/CodexRuntime.kt @@ -3,6 +3,7 @@ package com.yugahashimoto.andcode.runtime.local import com.yugahashimoto.andcode.core.api.McpServer import com.yugahashimoto.andcode.core.api.OpenCodeEvent import com.yugahashimoto.andcode.core.api.OpenCodeMessage +import com.yugahashimoto.andcode.core.api.OpenCodeModel import com.yugahashimoto.andcode.core.api.OpenCodeSession import com.yugahashimoto.andcode.core.api.OpenCodeTime import com.yugahashimoto.andcode.core.api.PermissionRequest @@ -369,14 +370,11 @@ class CodexRuntime( sessionId: String, prompt: String, model: String?, + effort: String? = null, + serviceTier: String? = null, attachments: List = emptyList(), ) { - val params = - buildJsonObject { - put("threadId", JsonPrimitive(sessionId)) - put("input", codexTurnInput(prompt, attachments)) - model?.takeIf(String::isNotBlank)?.let { put("model", JsonPrimitive(it)) } - } + val params = codexTurnStartParams(sessionId, prompt, model, effort, serviceTier, attachments) runCatching { call("turn/start", params) } .onSuccess { result -> // Recorded by turn id, not just session id, so onServerEnded can evict exactly this @@ -671,6 +669,62 @@ internal fun parseVersionLine(line: String): String = line.trim().removePrefix(" */ internal fun hasSignedInAccount(accountRead: JsonObject): Boolean = accountRead["account"] is JsonObject +/** The explicit `effort` and `serviceTier` one turn is sent with. `effort` is null only when unknown. */ +internal data class CodexTurnSettings( + val effort: String?, + val serviceTier: String, +) + +/** + * Resolves what one turn is sent with. The app-server applies `effort` and `serviceTier` to later + * turns too, so an omitted value would inherit the previous turn's (the last model's effort, or a + * fast tier left on after switching to a model without one). Every turn therefore states both: + * + * - [variant] (the chat's effort choice) wins; otherwise the model's own default effort. Omitted + * only when neither is known. + * - [requestedTier] wins when set, including an explicit `"default"`; otherwise the model's default + * tier, and `"default"` (standard speed) when the model names none. + * + * A null [model] means the catalogue has no entry for the model, so there is no default to fall back + * on: the tier is `"default"` and the effort is left to the request alone. + */ +internal fun resolveCodexTurnSettings( + variant: String?, + requestedTier: String?, + model: OpenCodeModel?, +): CodexTurnSettings = + CodexTurnSettings( + effort = variant?.takeIf(String::isNotBlank) ?: model?.codexDefaultEffort?.takeIf(String::isNotBlank), + serviceTier = + requestedTier?.takeIf(String::isNotBlank) + ?: model?.codexDefaultServiceTier?.takeIf(String::isNotBlank) + ?: DEFAULT_SERVICE_TIER, + ) + +/** The app-server's explicit "standard speed" tier, which turns off a fast tier left on earlier. */ +internal const val DEFAULT_SERVICE_TIER = "default" + +/** + * The `turn/start` params for one prompt. [effort] and [serviceTier] are sent only when non-blank: + * callers that want the explicit-every-turn behaviour resolve them with [resolveCodexTurnSettings] + * first, so this builder just writes what it is given. + */ +internal fun codexTurnStartParams( + sessionId: String, + prompt: String, + model: String?, + effort: String?, + serviceTier: String?, + attachments: List, +): JsonObject = + buildJsonObject { + put("threadId", JsonPrimitive(sessionId)) + put("input", codexTurnInput(prompt, attachments)) + model?.takeIf(String::isNotBlank)?.let { put("model", JsonPrimitive(it)) } + effort?.takeIf(String::isNotBlank)?.let { put("effort", JsonPrimitive(it)) } + serviceTier?.takeIf(String::isNotBlank)?.let { put("serviceTier", JsonPrimitive(it)) } + } + /** * The `turn/start` `input` array for [prompt] plus any image [attachments]. * diff --git a/app/src/main/java/com/yugahashimoto/andcode/runtime/local/CodexTarget.kt b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/CodexTarget.kt index 38f0e653b..0f744e9b9 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/runtime/local/CodexTarget.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/CodexTarget.kt @@ -7,6 +7,7 @@ import com.yugahashimoto.andcode.core.api.OpenCodeFileContent import com.yugahashimoto.andcode.core.api.OpenCodeFileNode import com.yugahashimoto.andcode.core.api.OpenCodeHealth import com.yugahashimoto.andcode.core.api.OpenCodeMessage +import com.yugahashimoto.andcode.core.api.OpenCodeModel import com.yugahashimoto.andcode.core.api.OpenCodeSearchMatch import com.yugahashimoto.andcode.core.api.OpenCodeSession import com.yugahashimoto.andcode.core.api.PromptRequest @@ -120,15 +121,32 @@ class CodexTarget( override suspend fun listMessages(sessionId: String): List = withContext(Dispatchers.IO) { runtime.listMessages(sessionId) } + /** Each model's defaults from the last catalogue this target built, so a turn can state them. */ + @Volatile private var knownModels: Map = emptyMap() + + /** The `isDefault` model from the last `model/list`, for a turn that names no model. */ + @Volatile private var knownDefaultModelId: String? = null + + /** Whether [sendMessage] already re-read `model/list` successfully for a model it did not know. */ + @Volatile private var modelsRefreshed = false + override suspend fun listProviders(): ProviderCatalog = withContext(Dispatchers.IO) { // Two independent app-server round trips: run them concurrently rather than doubling // the latency of a call this app makes on every runtime selection and catalogue refresh. val models = async { runtime.modelList() } val signedIn = async { runtime.isSignedIn() } - CodexModels.catalog(models.await(), connected = signedIn.await()) + val listing = models.await() + rememberModels(listing) + CodexModels.catalog(listing, connected = signedIn.await()) } + private fun rememberModels(listing: JsonObject) { + val catalog = CodexModels.catalog(listing, connected = false) + knownModels = catalog.all.single().models + knownDefaultModelId = CodexModels.defaultModelId(listing) + } + override suspend fun listAgents(): List = CodexModels.agents() // Index 0 is the ChatGPT browser sign-in, 1 the API key: authorizeProvider and setProviderApiKey @@ -229,9 +247,31 @@ class CodexTarget( sessionId: String, request: PromptRequest, ) { - withContext(Dispatchers.IO) { runtime.send(sessionId, request.text, request.modelId, request.attachments) } + withContext(Dispatchers.IO) { + val requestedModelId = request.modelId?.takeIf(String::isNotBlank) + var model = resolveKnownModel(requestedModelId) + if (model == null && !modelsRefreshed) { + // Once per target: the catalogue may simply not have been built yet (a turn sent + // before the picker loaded), and that read also fills in the default model id. Only a + // successful read counts, so a server still starting up is asked again next turn. + runCatching { rememberModels(runtime.modelList()) }.onSuccess { modelsRefreshed = true } + model = resolveKnownModel(requestedModelId) + } + val settings = resolveCodexTurnSettings(request.variant, request.serviceTier, model) + runtime.send( + sessionId = sessionId, + prompt = request.text, + model = request.modelId, + effort = settings.effort, + serviceTier = settings.serviceTier, + attachments = request.attachments, + ) + } } + /** The cached model for [modelId], or for Codex's default model when the turn names none. */ + private fun resolveKnownModel(modelId: String?): OpenCodeModel? = (modelId ?: knownDefaultModelId)?.let(knownModels::get) + override suspend fun abortSession(sessionId: String): Boolean = withContext(Dispatchers.IO) { runtime.abort(sessionId) } override suspend fun deleteSession(sessionId: String): Boolean = withContext(Dispatchers.IO) { runtime.deleteSession(sessionId) } diff --git a/app/src/main/java/com/yugahashimoto/andcode/ui/AndCodeApp.kt b/app/src/main/java/com/yugahashimoto/andcode/ui/AndCodeApp.kt index ba6e60f34..ca67efde7 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/ui/AndCodeApp.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/ui/AndCodeApp.kt @@ -310,6 +310,8 @@ fun AndCodeApp( onApplySystemPrompt = { sessionId, presetId -> app.claudeCodeController.selectSystemPrompt(presetId, sessionId) }, + onCodexFastModeChanged = app.preferences::setCodexFastMode, + initialCodexFastMode = app.settings.codexFastMode, onSessionAborted = { sessionId -> app.activityRepository.markSessionAborted(sessionId) // Stopping the run answers the "this run has gone quiet" notice. @@ -343,6 +345,7 @@ fun AndCodeApp( // foreground again. awaitForeground = { app.appForeground.foreground.first { visible -> visible } }, videoNotSupportedMessage = context.getString(R.string.error_video_not_supported), + workspaceAttachmentNoteTemplate = context.getString(R.string.chat_workspace_attachment_note), ) }, ) @@ -509,26 +512,61 @@ fun AndCodeApp( runCatching { withContext(Dispatchers.IO) { val importer = com.yugahashimoto.andcode.runtime.local.AttachmentImporter(context) + val runtimeWorkspaceRoot = java.io.File(context.filesDir, "runtime/workspace") + // Only the on-device runtime mounts /workspace onto a local directory. A + // remote runtime may also spell its workspace /workspace, but there is no + // local mount to copy into, so its attachments are always sent as-is. + val workspaceDir = + if (selectedRuntime?.kind == com.yugahashimoto.andcode.runtime.BackendKind.LOCAL) { + com.yugahashimoto.andcode.runtime.local.AttachmentSupport.hostWorkspace( + chatState.sessionDirectory ?: chatState.selectedWorkspacePath, + runtimeWorkspaceRoot, + ) + } else { + null + } importer.importAll(uri).map { attachment -> - val preview = - if (com.yugahashimoto.andcode.runtime.local.VideoAttachmentHelper.isImageMime( - attachment.mime, - ) - ) { - runCatching { - val base64 = - attachment.url.substringAfter("base64,", missingDelimiterValue = "") - if (base64.isEmpty()) return@runCatching null - val bytes = android.util.Base64.decode(base64, android.util.Base64.DEFAULT) - com.yugahashimoto.andcode.feature.chat.decodeSampledBitmap( - bytes, - com.yugahashimoto.andcode.feature.chat.COMPOSER_PREVIEW_MAX_DIMENSION, + // A MIME the provider rejects as a file part (zip, binaries, ...): drop a + // copy into the workspace and let the agent read it from disk. If the + // copy fails, fall back to sending the attachment as before. + val workspacePath = + workspaceDir + ?.takeIf { + !com.yugahashimoto.andcode.runtime.local.AttachmentSupport.isModelSendable( + attachment.mime, ) - }.getOrNull() - } else { - null - } - attachment to preview + }?.let { directory -> + runCatching { importer.writeToWorkspace(attachment, directory) } + .getOrNull() + ?.let { + com.yugahashimoto.andcode.runtime.local.AttachmentSupport + .guestWorkspacePath(it, runtimeWorkspaceRoot) + } + } + if (workspacePath != null) { + attachment.copy(url = "", workspacePath = workspacePath) to null + } else { + val preview = + if (com.yugahashimoto.andcode.runtime.local.VideoAttachmentHelper.isImageMime( + attachment.mime, + ) + ) { + runCatching { + val base64 = + attachment.url.substringAfter("base64,", missingDelimiterValue = "") + if (base64.isEmpty()) return@runCatching null + val bytes = + android.util.Base64.decode(base64, android.util.Base64.DEFAULT) + com.yugahashimoto.andcode.feature.chat.decodeSampledBitmap( + bytes, + com.yugahashimoto.andcode.feature.chat.COMPOSER_PREVIEW_MAX_DIMENSION, + ) + }.getOrNull() + } else { + null + } + attachment to preview + } } } }.onSuccess { attachments -> @@ -614,6 +652,9 @@ fun AndCodeApp( preferences.modelId?.takeIf { model != null }, preferences.agentId?.takeIf { id -> settingsState.agents.any { it.name == id } }, model?.limit?.context ?: 0L, + fastServiceTier = model?.codexFastServiceTier, + fastByDefault = model?.codexFastByDefault == true, + fastDescription = model?.codexFastDescription, ) } @@ -1062,6 +1103,7 @@ fun AndCodeApp( state = chatState, providers = settingsState.providers, agents = settingsState.agents, + agentsLoading = settingsState.agentsLoading, selectedProviderId = chatState.selectedProviderId ?: fallbackProviderId, selectedModelId = chatState.selectedModelId ?: fallbackModelId, selectedAgentId = chatState.selectedAgentId ?: settingsState.agentId, @@ -1129,8 +1171,10 @@ fun AndCodeApp( }, onSelectModel = settingsViewModel::selectModel, onSelectAgent = settingsViewModel::selectAgent, + onRefreshAgents = app.catalogRepository::refreshAgents, selectedVariant = chatState.selectedVariant, onSelectVariant = chatViewModel::selectVariant, + onToggleFastMode = chatViewModel::setFastMode, onAttach = { attachmentLauncher.launch("*/*") }, onRemoveAttachment = chatViewModel::removeAttachment, onImageAttachment = { bitmap -> diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index b4a06946e..0312e33bf 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -481,9 +481,15 @@ مرتفع مرتفع جدًا الحد الأقصى + إيقاف + الحد الأدنى + فائق + سريع الموجّه بناء تخطيط + جارٍ تحميل الوكلاء… + تعذّر تحميل الوكلاء. انقر لإعادة المحاولة. قبول تلقائي تلقائي %1$s tok/s @@ -759,6 +765,8 @@ Camera Gallery Thinking + الوضع السريع + الوضع السريع: %1$s موجّه النظام Agent mode Updating diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index c9002a930..e7ea20b82 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -475,9 +475,15 @@ Alto Muy alto Máximo + Desactivado + Mínimo + Ultra + Rápido Prompt Construir Planificar + Cargando agentes… + No se pudieron cargar los agentes. Toca para reintentar. Aceptación automática Auto %1$s tok/s @@ -750,6 +756,8 @@ Camera Gallery Thinking + Modo rápido + Modo rápido: %1$s Prompt del sistema Agent mode Updating diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 7794e2f48..3c2a841c4 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -475,9 +475,15 @@ Élevé Très élevé Maximum + Désactivé + Minimum + Ultra + Rapide Invite Construire Planifier + Chargement des agents… + Impossible de charger les agents. Touchez pour réessayer. Acceptation automatique Auto %1$s tok/s @@ -750,6 +756,8 @@ Camera Gallery Thinking + Mode rapide + Mode rapide : %1$s Invite système Agent mode Updating diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index fc319ecdb..29825d59b 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -473,9 +473,15 @@ 高 最高 Max + オフ + 最低 + Ultra + 高速 プロンプト ビルド 計画 + エージェントを読み込み中… + エージェントを読み込めませんでした。タップして再試行。 自動承認 自動 %1$s tok/s @@ -757,6 +763,8 @@ カメラ ギャラリー 思考中 + 高速モード + 高速モード: %1$s システムプロンプト エージェントモード 更新中 diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 567c4d4e5..9bad4d1dc 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -475,9 +475,15 @@ Alto Extra alto Máximo + Desativado + Mínimo + Ultra + Rápido Prompt Construir Planejar + Carregando agentes… + Não foi possível carregar os agentes. Toque para tentar novamente. Aceitação automática Auto %1$s tok/s @@ -750,6 +756,8 @@ Camera Gallery Thinking + Modo rápido + Modo rápido: %1$s Prompt do sistema Agent mode Updating diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index c54572b78..a6750f3eb 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -477,9 +477,15 @@ Высокий Очень высокий Максимум + Выкл. + Минимум + Ультра + Быстрый Промпт Сборка План + Загрузка агентов… + Не удалось загрузить агентов. Нажмите, чтобы повторить. Автоприём Авто %1$s tok/s @@ -753,6 +759,8 @@ Camera Gallery Thinking + Быстрый режим + Быстрый режим: %1$s Системный промпт Agent mode Updating diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 47bc074cb..f6d7a6d23 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -471,9 +471,15 @@ 高 超高 最高 + 关闭 + 极低 + 极致 + 快速 提示词 构建 计划 + 正在加载智能体… + 无法加载智能体,点按重试。 自动接受 自动 %1$s tok/s @@ -744,6 +750,8 @@ Camera Gallery Thinking + 快速模式 + 快速模式:%1$s 系统提示词 Agent mode Updating diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3ca369747..182b6f184 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -461,9 +461,15 @@ High Extra High Max + Off + Minimal + Ultra + Fast Prompt Build Plan + Loading agents… + Couldn\'t load agents. Tap to retry. Auto accept Auto %1$s tok/s @@ -572,7 +578,7 @@ Remote connection Workspace Notifications - Anonymous usage analytics + Anonymous usage analytics & crash reports Stop idle runtime Restarts automatically when a schedule runs or you open the app, but not automatically after a device restart Privacy & data @@ -748,6 +754,8 @@ Camera Gallery Thinking + Fast mode + Fast mode: %1$s System prompt Agent mode Updating @@ -850,6 +858,7 @@ Gallery Could not attach file Video files cannot be sent directly. Remove the video and attach images instead. + [Attached file available in the workspace: %1$s] Queued diff --git a/app/src/test/java/com/yugahashimoto/andcode/core/api/OpenCodeAgentSelectionTest.kt b/app/src/test/java/com/yugahashimoto/andcode/core/api/OpenCodeAgentSelectionTest.kt index cbb34e6f6..fc3ff1815 100644 --- a/app/src/test/java/com/yugahashimoto/andcode/core/api/OpenCodeAgentSelectionTest.kt +++ b/app/src/test/java/com/yugahashimoto/andcode/core/api/OpenCodeAgentSelectionTest.kt @@ -3,7 +3,6 @@ package com.yugahashimoto.andcode.core.api import kotlinx.serialization.json.Json import org.junit.Assert.assertEquals import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue import org.junit.Test class OpenCodeAgentSelectionTest { @@ -71,7 +70,28 @@ class OpenCodeAgentSelectionTest { """[{"name":"title","mode":"primary","hidden":true,"native":true},{"name":"orchestrator","mode":"primary"}]""", ) - assertTrue(agents[0].hidden) + assertEquals(true, agents[0].hidden) assertEquals(listOf("orchestrator"), agents.selectablePrimaryAgents().map { it.name }) } + + @Test + fun `decodes plugin agents that send an explicit null hidden flag`() { + // Shape of OpenCode 1.19.3's GET /agent with oh-my-opencode-slim installed: plugin agents + // carry "hidden": null, which used to fail decoding of the whole list. + val agents = + OpenCodeApiClient.defaultJson.decodeFromString>( + """ + [ + {"name":"orchestrator","mode":"primary","native":false,"hidden":null,"variant":"high"}, + {"name":"build","description":"Default agent","mode":"primary","native":true}, + {"name":"compaction","mode":"primary","native":true,"hidden":true}, + {"name":"fixer","description":"Fast implementation","mode":"subagent","native":false,"hidden":null}, + {"name":"plan","description":"Plan mode","mode":"primary","native":true} + ] + """.trimIndent(), + ) + + assertEquals(listOf("orchestrator", "build", "plan"), agents.selectablePrimaryAgents().map { it.name }) + assertEquals("orchestrator", agents.defaultAgentName()) + } } diff --git a/app/src/test/java/com/yugahashimoto/andcode/core/api/OpenCodeV2ApiClientTest.kt b/app/src/test/java/com/yugahashimoto/andcode/core/api/OpenCodeV2ApiClientTest.kt index 1d2daf03b..af68371f5 100644 --- a/app/src/test/java/com/yugahashimoto/andcode/core/api/OpenCodeV2ApiClientTest.kt +++ b/app/src/test/java/com/yugahashimoto/andcode/core/api/OpenCodeV2ApiClientTest.kt @@ -799,7 +799,7 @@ class OpenCodeV2ApiClientTest { val agents = client().agents() - assertEquals(listOf("build"), agents.map { it.name }) + assertEquals(listOf("build", "orchestrator"), agents.map { it.name }) assertEquals("primary", agents[0].mode) } @@ -1139,6 +1139,7 @@ class OpenCodeV2ApiClientTest { val AGENT_LIST = """{"data":[{"id":"build","name":"Build","description":"The default agent.","mode":"primary","hidden":false,"permissions":[]},""" + + """{"id":"orchestrator","name":"Orchestrator","mode":"primary","hidden":null,"permissions":[]},""" + """{"id":"compaction","name":"Compaction","description":"internal","mode":"primary","hidden":true,"permissions":[]}]}""" val INTEGRATION_LIST = diff --git a/app/src/test/java/com/yugahashimoto/andcode/core/diagnostics/CrashReportSanitizerTest.kt b/app/src/test/java/com/yugahashimoto/andcode/core/diagnostics/CrashReportSanitizerTest.kt index 861d653e4..88645a548 100644 --- a/app/src/test/java/com/yugahashimoto/andcode/core/diagnostics/CrashReportSanitizerTest.kt +++ b/app/src/test/java/com/yugahashimoto/andcode/core/diagnostics/CrashReportSanitizerTest.kt @@ -19,4 +19,25 @@ class CrashReportSanitizerTest { assertEquals("value", CrashReportSanitizer.value(" value ")) assertEquals(100, CrashReportSanitizer.value("x".repeat(200)).length) } + + @Test + fun `error category keeps only the runtime id and a limited error type`() { + assertEquals( + "Runtime session error: opencode-local/ProviderAuthError", + CrashReportSanitizer.errorCategory("opencode-local", "ProviderAuthError"), + ) + } + + @Test + fun `error category falls back to unknown for a missing or unusable type`() { + assertEquals("Runtime session error: opencode-local/unknown", CrashReportSanitizer.errorCategory("opencode-local", null)) + assertEquals( + "Runtime session error: opencode-local/unknown", + CrashReportSanitizer.errorCategory("opencode-local", "ProviderAuthError: /home/user/prompt.txt"), + ) + assertEquals( + "Runtime session error: opencode-local/unknown", + CrashReportSanitizer.errorCategory("opencode-local", "https://host.example.com/secret"), + ) + } } diff --git a/app/src/test/java/com/yugahashimoto/andcode/core/security/SecretRedactionTest.kt b/app/src/test/java/com/yugahashimoto/andcode/core/security/SecretRedactionTest.kt index 5de89466c..c508fdcea 100644 --- a/app/src/test/java/com/yugahashimoto/andcode/core/security/SecretRedactionTest.kt +++ b/app/src/test/java/com/yugahashimoto/andcode/core/security/SecretRedactionTest.kt @@ -42,6 +42,38 @@ class SecretRedactionTest { assertFalse(redacted.contains("Sup3rSecret!")) } + @Test + fun `redacts the credential behind a Basic authorization header`() { + val redacted = SecretRedaction.redact("Authorization: Basic dXNlcjpzZWNyZXQ=") + assertFalse(redacted.contains("dXNlcjpzZWNyZXQ=")) + } + + @Test + fun `redacts every pair of a cookie header`() { + val redacted = SecretRedaction.redact("Cookie: a=1; session=abc123def456") + assertFalse(redacted.contains("abc123def456")) + } + + @Test + fun `redacts a quoted json credential field`() { + val redacted = SecretRedaction.redact("""{"api_key":"sk-live-0123456789"}""") + assertFalse(redacted.contains("sk-live-0123456789")) + } + + @Test + fun `redacts env-style api key and refresh token names`() { + val api = SecretRedaction.redact("ANTHROPIC_API_KEY=sk-ant-secret-value-123456") + assertFalse(api.contains("sk-ant-secret-value-123456")) + val token = SecretRedaction.redact("refresh_token=rt_0123456789abcdef") + assertFalse(token.contains("rt_0123456789abcdef")) + } + + @Test + fun `redacts credentials embedded in a url userinfo`() { + val redacted = SecretRedaction.redact("fetching https://alice:hunter2@example.com/callback") + assertFalse(redacted.contains("hunter2")) + } + @Test fun `leaves ordinary text untouched`() { assertEquals("Runtime session failed", SecretRedaction.redact("Runtime session failed")) diff --git a/app/src/test/java/com/yugahashimoto/andcode/data/repository/RuntimeActivityRepositoryTest.kt b/app/src/test/java/com/yugahashimoto/andcode/data/repository/RuntimeActivityRepositoryTest.kt index 0b4c7c445..a75f0261b 100644 --- a/app/src/test/java/com/yugahashimoto/andcode/data/repository/RuntimeActivityRepositoryTest.kt +++ b/app/src/test/java/com/yugahashimoto/andcode/data/repository/RuntimeActivityRepositoryTest.kt @@ -1366,7 +1366,7 @@ class RuntimeActivityRepositoryTest { registry = registry, scope = scope, onSessionStalled = { sessionId, _, _, _ -> stalled += sessionId }, - onSessionError = { sessionId, _, _ -> failed += sessionId.orEmpty() }, + onSessionError = { sessionId, _, _, _ -> failed += sessionId.orEmpty() }, stallThresholdMillis = 1_000L, stallCheckIntervalMillis = 100L, stallSettleAfterMillis = 60_000L, diff --git a/app/src/test/java/com/yugahashimoto/andcode/data/repository/RuntimeCatalogRepositoryTest.kt b/app/src/test/java/com/yugahashimoto/andcode/data/repository/RuntimeCatalogRepositoryTest.kt index d157a8895..4247b1cab 100644 --- a/app/src/test/java/com/yugahashimoto/andcode/data/repository/RuntimeCatalogRepositoryTest.kt +++ b/app/src/test/java/com/yugahashimoto/andcode/data/repository/RuntimeCatalogRepositoryTest.kt @@ -271,6 +271,167 @@ class RuntimeCatalogRepositoryTest { connected = emptyList(), ) + @Test + fun `agents that fail on the first read are retried until the runtime answers`() = + runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val target = FakeTarget("mac") + // A local OpenCode installing its plugins: the first GET /agent fails (with a two-line + // message, as a decoding error has), the next is empty. + target.agentAnswers.add { error("Unexpected JSON token\nJSON input: [{\"hidden\":null}]") } + target.agentAnswers.add { emptyList() } + val registry = + RuntimeRegistry( + store = FakeStore(selectedRuntimeId = "mac"), + localTarget = FakeTarget("local-android", RuntimeType.LOCAL), + remoteFactory = { target }, + ) + val repository = RuntimeCatalogRepository(registry = registry, scope = TestScope(dispatcher)) + + advanceUntilIdle() + + // The load publishes after one read; the retry runs after it, outside the refresh lock. + assertEquals(listOf("build"), repository.state.value.agents.map { it.name }) + assertEquals(3, target.agentCalls) + assertFalse(repository.state.value.isRefreshing) + // The load's "Agents: ..." failure no longer applies once the retry got them. + assertNull(repository.state.value.error) + } + + @Test + fun `an agent retry for a runtime that never connected refreshes the whole catalogue`() = + runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val target = FakeTarget("mac", connectError = IllegalStateException("offline")) + val registry = + RuntimeRegistry( + store = FakeStore(selectedRuntimeId = "mac"), + localTarget = FakeTarget("local-android", RuntimeType.LOCAL), + remoteFactory = { target }, + ) + val repository = RuntimeCatalogRepository(registry = registry, scope = TestScope(dispatcher)) + advanceUntilIdle() + assertNull(repository.state.value.health) + val callsBefore = target.agentCalls + + repository.refreshAgents() + advanceUntilIdle() + + // A full refresh retried the connection instead of asking a dead server for agents. + assertEquals(callsBefore, target.agentCalls) + assertEquals("offline", repository.state.value.error) + } + + @Test + fun `refreshing agents fills an empty picker without touching the rest of the catalogue`() = + runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val target = FakeTarget("mac") + // The load's own read plus its background retry (1 + 5 reads) all fail. + repeat(6) { target.agentAnswers.add { error("plugin load failed") } } + val registry = + RuntimeRegistry( + store = FakeStore(selectedRuntimeId = "mac"), + localTarget = FakeTarget("local-android", RuntimeType.LOCAL), + remoteFactory = { target }, + ) + val repository = RuntimeCatalogRepository(registry = registry, scope = TestScope(dispatcher)) + advanceUntilIdle() + assertTrue(repository.state.value.agents.isEmpty()) + assertEquals(6, target.agentCalls) + val sessionsBefore = repository.state.value.sessions + + repository.refreshAgents() + advanceUntilIdle() + + assertEquals(listOf("build"), repository.state.value.agents.map { it.name }) + assertEquals(sessionsBefore, repository.state.value.sessions) + } + + @Test + fun `a refresh whose agent read fails keeps the agents already on screen`() = + runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val target = FakeTarget("mac") + val registry = + RuntimeRegistry( + store = FakeStore(selectedRuntimeId = "mac"), + localTarget = FakeTarget("local-android", RuntimeType.LOCAL), + remoteFactory = { target }, + ) + val repository = RuntimeCatalogRepository(registry = registry, scope = TestScope(dispatcher)) + advanceUntilIdle() + assertEquals(listOf("build"), repository.state.value.agents.map { it.name }) + + // Every read of the next refresh fails, retries included. + repeat(10) { target.agentAnswers.add { error("plugin load failed") } } + repository.refresh() + advanceUntilIdle() + + assertEquals(listOf("build"), repository.state.value.agents.map { it.name }) + // The kept list is not this read's: no agents failure on the chip or in the catalogue error. + assertNull(repository.state.value.agentsError) + assertNull(repository.state.value.error) + } + + @Test + fun `repeated agent refreshes run a single retry at a time`() = + runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val target = FakeTarget("mac") + // The load and its background retry give up empty-handed (1 + 5 failed reads). + repeat(6) { target.agentAnswers.add { error("plugin load failed") } } + val registry = + RuntimeRegistry( + store = FakeStore(selectedRuntimeId = "mac"), + localTarget = FakeTarget("local-android", RuntimeType.LOCAL), + remoteFactory = { target }, + ) + val repository = RuntimeCatalogRepository(registry = registry, scope = TestScope(dispatcher)) + advanceUntilIdle() + val callsBefore = target.agentCalls + + assertTrue(repository.state.value.agents.isEmpty()) + + repeat(5) { repository.refreshAgents() } + advanceUntilIdle() + + assertEquals(listOf("build"), repository.state.value.agents.map { it.name }) + assertEquals(callsBefore + 1, target.agentCalls) + } + + @Test + fun `agents read as loading until they arrive, and stop loading once retries give up`() = + runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val target = FakeTarget("mac") + // The load's read and the whole background retry (1 + 5 reads) fail. + repeat(6) { target.agentAnswers.add { error("plugin load failed") } } + val registry = + RuntimeRegistry( + store = FakeStore(selectedRuntimeId = "mac"), + localTarget = FakeTarget("local-android", RuntimeType.LOCAL), + remoteFactory = { target }, + ) + val repository = RuntimeCatalogRepository(registry = registry, scope = TestScope(dispatcher)) + + // The load's own read has failed and published; the background retry is still to come, + // so the chip has nothing to pick yet and reads as loading. + testScheduler.runCurrent() + assertTrue(repository.state.value.agentsLoading) + + advanceUntilIdle() + assertTrue(repository.state.value.agents.isEmpty()) + assertFalse(repository.state.value.agentsLoading) + + // A tap on the empty chip marks it loading straight away, before the retry has run. + repository.refreshAgents() + assertTrue(repository.state.value.agentsLoading) + advanceUntilIdle() + assertEquals(listOf("build"), repository.state.value.agents.map { it.name }) + assertFalse(repository.state.value.agentsLoading) + } + private class FakeStore( profiles: List = listOf(profileStatic("mac")), override var selectedRuntimeId: String? = null, @@ -375,7 +536,14 @@ class RuntimeCatalogRepositoryTest { ) } - override suspend fun listAgents(): List = listOf(OpenCodeAgent("build", mode = "primary")) + /** Answers for successive [listAgents] calls; once used up, the default single `build`. */ + val agentAnswers = ArrayDeque<() -> List>() + var agentCalls = 0 + + override suspend fun listAgents(): List { + agentCalls++ + return agentAnswers.removeFirstOrNull()?.invoke() ?: listOf(OpenCodeAgent("build", mode = "primary")) + } override suspend fun listWorkspaces(): List = listOf( diff --git a/app/src/test/java/com/yugahashimoto/andcode/feature/chat/ChatViewModelTest.kt b/app/src/test/java/com/yugahashimoto/andcode/feature/chat/ChatViewModelTest.kt index a7839aa32..6cf5154c4 100644 --- a/app/src/test/java/com/yugahashimoto/andcode/feature/chat/ChatViewModelTest.kt +++ b/app/src/test/java/com/yugahashimoto/andcode/feature/chat/ChatViewModelTest.kt @@ -1829,6 +1829,110 @@ class ChatViewModelTest { assertEquals("high", viewModel.uiState.value.selectedVariant) } + @Test + fun `a model with a fast tier shows fast mode off when the user has no choice`() = + runTest(dispatcher) { + val viewModel = ChatViewModel(FakeBackend()) + advanceUntilIdle() + + viewModel.selectConfiguration("openai", "gpt-6-astra", agentId = null, fastServiceTier = "priority") + + assertTrue(viewModel.uiState.value.fastModeAvailable) + assertFalse(viewModel.uiState.value.fastModeEnabled) + } + + @Test + fun `a model that defaults to fast reads as enabled until the user turns it off`() = + runTest(dispatcher) { + val viewModel = ChatViewModel(FakeBackend()) + advanceUntilIdle() + + viewModel.selectConfiguration( + "openai", + "gpt-6-astra", + agentId = null, + fastServiceTier = "priority", + fastByDefault = true, + ) + assertTrue(viewModel.uiState.value.fastModeEnabled) + + viewModel.setFastMode(false) + assertFalse(viewModel.uiState.value.fastModeEnabled) + } + + @Test + fun `a model without a fast tier reports fast mode unavailable whatever the choice`() = + runTest(dispatcher) { + val viewModel = ChatViewModel(FakeBackend()) + advanceUntilIdle() + + viewModel.setFastMode(true) + viewModel.selectConfiguration("openai", "plain", agentId = null) + + assertFalse(viewModel.uiState.value.fastModeAvailable) + assertFalse(viewModel.uiState.value.fastModeEnabled) + } + + @Test + fun `setting fast mode invokes the persistence callback`() = + runTest(dispatcher) { + val saved = mutableListOf() + val viewModel = ChatViewModel(FakeBackend(), onCodexFastModeChanged = { saved += it }) + advanceUntilIdle() + + viewModel.setFastMode(true) + viewModel.setFastMode(null) + + assertEquals(listOf(true, null), saved) + } + + @Test + fun `a saved fast-mode choice is applied when the view model starts`() = + runTest(dispatcher) { + val viewModel = ChatViewModel(FakeBackend(), initialCodexFastMode = false) + advanceUntilIdle() + + viewModel.selectConfiguration("openai", "gpt-6-astra", agentId = null, fastServiceTier = "priority", fastByDefault = true) + + assertFalse(viewModel.uiState.value.fastModeEnabled) + } + + @Test + fun `a prompt carries the service tier the fast choice maps to`() = + runTest(dispatcher) { + val backend = FakeBackend() + val viewModel = ChatViewModel(backend) + advanceUntilIdle() + viewModel.selectConfiguration("openai", "gpt-6-astra", agentId = null, fastServiceTier = "priority") + + viewModel.setFastMode(true) + viewModel.sendMessage("on") + advanceUntilIdle() + viewModel.setFastMode(false) + viewModel.sendMessage("off") + advanceUntilIdle() + viewModel.setFastMode(null) + viewModel.sendMessage("follow") + advanceUntilIdle() + + assertEquals(listOf("priority", "default", null), backend.sentPrompts.map { it.second.serviceTier }) + } + + @Test + fun `a model without a fast tier never sends a service tier`() = + runTest(dispatcher) { + val backend = FakeBackend() + val viewModel = ChatViewModel(backend) + advanceUntilIdle() + viewModel.selectConfiguration("claude-code", "sonnet", agentId = null) + + viewModel.setFastMode(true) + viewModel.sendMessage("hello") + advanceUntilIdle() + + assertNull(backend.sentPrompts.single().second.serviceTier) + } + @Test fun `dismissing the todo bar records the entry so it stays closed`() = runTest(dispatcher) { @@ -1903,6 +2007,56 @@ class ChatViewModelTest { ), ) + /** + * A file the provider rejects as a file part is carried as a workspace copy: it stays a visible + * chip but must not reach the runtime as an attachment, and the prompt points at its path. + */ + @Test + fun `a workspace-backed attachment is referenced by path instead of sent as a file part`() = + runTest(dispatcher) { + val backend = FakeBackend() + val viewModel = + ChatViewModel(backend, workspaceAttachmentNoteTemplate = "[file: %s]") + advanceUntilIdle() + viewModel.addAttachment( + PromptAttachment( + filename = "archive.zip", + mime = "application/zip", + url = "", + workspacePath = "/workspace/proj/.andcode-attachments/archive.zip", + ), + ) + + viewModel.sendMessage("please read this") + advanceUntilIdle() + + val request = backend.sentPrompts.single().second + assertTrue(request.attachments.isEmpty()) + assertEquals( + "please read this\n\n[file: /workspace/proj/.andcode-attachments/archive.zip]", + request.text, + ) + } + + @Test + fun `a sendable attachment is still sent as a file part without a workspace note`() = + runTest(dispatcher) { + val backend = FakeBackend() + val viewModel = + ChatViewModel(backend, workspaceAttachmentNoteTemplate = "[file: %s]") + advanceUntilIdle() + viewModel.addAttachment( + PromptAttachment(filename = "photo.png", mime = "image/png", url = "data:image/png;base64,AAAA"), + ) + + viewModel.sendMessage("look") + advanceUntilIdle() + + val request = backend.sentPrompts.single().second + assertEquals(listOf("photo.png"), request.attachments.map { it.filename }) + assertEquals("look", request.text) + } + private class InMemoryDraftStore : DraftStore { private val drafts = mutableMapOf() diff --git a/app/src/test/java/com/yugahashimoto/andcode/feature/chat/CodexServiceTierTest.kt b/app/src/test/java/com/yugahashimoto/andcode/feature/chat/CodexServiceTierTest.kt new file mode 100644 index 000000000..a5cf670bd --- /dev/null +++ b/app/src/test/java/com/yugahashimoto/andcode/feature/chat/CodexServiceTierTest.kt @@ -0,0 +1,33 @@ +package com.yugahashimoto.andcode.feature.chat + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * The chat's fast-mode mapping ([codexServiceTier]): the user's choice becomes the `serviceTier` a + * prompt carries. A null choice sends nothing, so the Codex layer falls back to the model's own + * default; `"default"` is the explicit standard-speed request. + */ +class CodexServiceTierTest { + @Test + fun `a model without a fast tier never sends one`() { + assertEquals(null, codexServiceTier(fastTier = null, fastMode = true)) + assertEquals(null, codexServiceTier(fastTier = null, fastMode = false)) + assertEquals(null, codexServiceTier(fastTier = null, fastMode = null)) + } + + @Test + fun `following the model default sends nothing`() { + assertEquals(null, codexServiceTier(fastTier = "priority", fastMode = null)) + } + + @Test + fun `turning fast mode on sends the model's fast tier`() { + assertEquals("priority", codexServiceTier(fastTier = "priority", fastMode = true)) + } + + @Test + fun `turning fast mode off sends default`() { + assertEquals("default", codexServiceTier(fastTier = "priority", fastMode = false)) + } +} diff --git a/app/src/test/java/com/yugahashimoto/andcode/runtime/local/AttachmentSupportTest.kt b/app/src/test/java/com/yugahashimoto/andcode/runtime/local/AttachmentSupportTest.kt new file mode 100644 index 000000000..08c27d7fd --- /dev/null +++ b/app/src/test/java/com/yugahashimoto/andcode/runtime/local/AttachmentSupportTest.kt @@ -0,0 +1,48 @@ +package com.yugahashimoto.andcode.runtime.local + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File + +class AttachmentSupportTest { + @Test + fun `only images and pdf are model-sendable`() { + assertTrue(AttachmentSupport.isModelSendable("image/png")) + assertTrue(AttachmentSupport.isModelSendable("image/jpeg")) + assertTrue(AttachmentSupport.isModelSendable("application/pdf")) + assertFalse(AttachmentSupport.isModelSendable("application/zip")) + assertFalse(AttachmentSupport.isModelSendable("text/plain")) + assertFalse(AttachmentSupport.isModelSendable("video/mp4")) + } + + @Test + fun `host workspace maps guest paths under slash-workspace`() { + val root = File("/data/app/runtime/workspace") + assertEquals(root, AttachmentSupport.hostWorkspace("/workspace", root)) + assertEquals(File(root, "proj"), AttachmentSupport.hostWorkspace("/workspace/proj", root)) + assertEquals(File(root, "proj"), AttachmentSupport.hostWorkspace("/workspace/proj/", root)) + assertNull(AttachmentSupport.hostWorkspace("/home/remote/proj", root)) + assertNull(AttachmentSupport.hostWorkspace(null, root)) + } + + @Test + fun `host workspace rejects a path that escapes the root`() { + val root = File("/data/app/runtime/workspace") + assertNull(AttachmentSupport.hostWorkspace("/workspace/../etc", root)) + assertNull(AttachmentSupport.hostWorkspace("/workspace/proj/../../etc", root)) + } + + @Test + fun `guest workspace path is the inverse of host mapping`() { + val root = File("/data/app/runtime/workspace") + val file = File(root, "proj/.andcode-attachments/archive.zip") + assertEquals( + "/workspace/proj/.andcode-attachments/archive.zip", + AttachmentSupport.guestWorkspacePath(file, root), + ) + assertNull(AttachmentSupport.guestWorkspacePath(File("/tmp/archive.zip"), root)) + } +} diff --git a/app/src/test/java/com/yugahashimoto/andcode/runtime/local/CodexModelsTest.kt b/app/src/test/java/com/yugahashimoto/andcode/runtime/local/CodexModelsTest.kt index 98ced9079..52de29775 100644 --- a/app/src/test/java/com/yugahashimoto/andcode/runtime/local/CodexModelsTest.kt +++ b/app/src/test/java/com/yugahashimoto/andcode/runtime/local/CodexModelsTest.kt @@ -3,6 +3,8 @@ package com.yugahashimoto.andcode.runtime.local import kotlinx.serialization.json.Json import kotlinx.serialization.json.jsonObject import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test @@ -31,4 +33,103 @@ class CodexModelsTest { assertTrue(catalog.connected.isEmpty()) } + + /** Shape copied from a live `codex app-server` 0.163.0 `model/list`. */ + private val effortAndFastJson = + """{"data":[{"id":"gpt-6-astra","model":"gpt-6-astra","displayName":"GPT-6-Astra", + "supportedReasoningEfforts":[ + {"reasoningEffort":"low","description":"Fast responses"}, + {"reasoningEffort":"medium","description":"Balanced"}, + {"reasoningEffort":"high","description":"Thorough"}, + {"reasoningEffort":"xhigh","description":"Deepest"}, + {"reasoningEffort":"max","description":"Maximum"}, + {"reasoningEffort":"ultra","description":"Ultra"}], + "defaultReasoningEffort":"medium", + "serviceTiers":[{"id":"priority","name":"Fast","description":"1.5x speed, increased usage"}], + "defaultServiceTier":"priority", + "additionalSpeedTiers":["fast"]}]}""" + + @Test + fun `reasoning efforts become variants in the server's order`() { + val catalog = CodexModels.catalog(json.parseToJsonElement(effortAndFastJson).jsonObject, connected = true) + + val model = catalog.all.single().models.getValue("gpt-6-astra") + assertEquals(listOf("low", "medium", "high", "xhigh", "max", "ultra"), model.variants.keys.toList()) + assertEquals("low", (model.variants.getValue("low") as kotlinx.serialization.json.JsonPrimitive).content) + } + + @Test + fun `a model with no reasoning efforts has no variants`() { + val catalog = CodexModels.catalog(json.parseToJsonElement(modelListJson).jsonObject, connected = true) + + assertTrue(catalog.all.single().models.getValue("gpt-6-astra").variants.isEmpty()) + } + + @Test + fun `the priority tier named Fast is the fast tier and is on by default`() { + val catalog = CodexModels.catalog(json.parseToJsonElement(effortAndFastJson).jsonObject, connected = true) + + val model = catalog.all.single().models.getValue("gpt-6-astra") + assertEquals("priority", model.codexFastServiceTier) + assertEquals("1.5x speed, increased usage", model.codexFastDescription) + assertTrue(model.codexFastByDefault) + } + + @Test + fun `a model that does not default to fast has the tier but is not fast by default`() { + val notDefault = effortAndFastJson.replace(""""defaultServiceTier":"priority",""", """"defaultServiceTier":null,""") + val catalog = CodexModels.catalog(json.parseToJsonElement(notDefault).jsonObject, connected = true) + + val model = catalog.all.single().models.getValue("gpt-6-astra") + assertEquals("priority", model.codexFastServiceTier) + assertFalse(model.codexFastByDefault) + } + + @Test + fun `the model's default effort and default tier are recorded`() { + val catalog = CodexModels.catalog(json.parseToJsonElement(effortAndFastJson).jsonObject, connected = true) + + val model = catalog.all.single().models.getValue("gpt-6-astra") + assertEquals("medium", model.codexDefaultEffort) + assertEquals("priority", model.codexDefaultServiceTier) + } + + @Test + fun `a model that names no default tier records none`() { + val catalog = CodexModels.catalog(json.parseToJsonElement(modelListJson).jsonObject, connected = true) + + val model = catalog.all.single().models.getValue("gpt-6-astra") + assertNull(model.codexDefaultEffort) + assertNull(model.codexDefaultServiceTier) + } + + @Test + fun `the default model is the one marked isDefault, else the first`() { + val marked = + """{"data":[{"id":"a"},{"id":"b","isDefault":true},{"id":"c"}]}""" + val unmarked = """{"data":[{"id":"a"},{"id":"b"}]}""" + + assertEquals("b", CodexModels.defaultModelId(json.parseToJsonElement(marked).jsonObject)) + assertEquals("a", CodexModels.defaultModelId(json.parseToJsonElement(unmarked).jsonObject)) + assertNull(CodexModels.defaultModelId(json.parseToJsonElement("""{"data":[]}""").jsonObject)) + } + + @Test + fun `a model without a fast tier has no fast fields`() { + val catalog = CodexModels.catalog(json.parseToJsonElement(modelListJson).jsonObject, connected = true) + + val model = catalog.all.single().models.getValue("gpt-6-astra") + assertNull(model.codexFastServiceTier) + assertNull(model.codexFastDescription) + assertFalse(model.codexFastByDefault) + } + + @Test + fun `the legacy additionalSpeedTiers fast entry is used when no tier is listed`() { + val legacy = + """{"data":[{"id":"m","displayName":"M","additionalSpeedTiers":["fast"]}]}""" + val catalog = CodexModels.catalog(json.parseToJsonElement(legacy).jsonObject, connected = true) + + assertEquals("fast", catalog.all.single().models.getValue("m").codexFastServiceTier) + } } diff --git a/app/src/test/java/com/yugahashimoto/andcode/runtime/local/CodexTurnStartParamsTest.kt b/app/src/test/java/com/yugahashimoto/andcode/runtime/local/CodexTurnStartParamsTest.kt new file mode 100644 index 000000000..951f5550a --- /dev/null +++ b/app/src/test/java/com/yugahashimoto/andcode/runtime/local/CodexTurnStartParamsTest.kt @@ -0,0 +1,123 @@ +package com.yugahashimoto.andcode.runtime.local + +import com.yugahashimoto.andcode.core.api.OpenCodeModel +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Pins the `turn/start` params [codexTurnStartParams] builds and the per-turn resolution + * [resolveCodexTurnSettings]. The builder writes only the values it is given: a blank or null + * `effort` or `serviceTier` is omitted. Callers resolve first, so every real turn states both. + */ +class CodexTurnStartParamsTest { + private fun params( + effort: String? = null, + serviceTier: String? = null, + model: String? = "gpt-6-astra", + ): JsonObject = codexTurnStartParams("thr_1", "hello", model, effort, serviceTier, emptyList()) + + @Test + fun `effort and service tier are sent when set`() { + val result = params(effort = "high", serviceTier = "priority") + + assertEquals("high", result.getValue("effort").jsonPrimitive.content) + assertEquals("priority", result.getValue("serviceTier").jsonPrimitive.content) + } + + @Test + fun `effort and service tier are omitted when unset`() { + val result = params() + + assertFalse(result.containsKey("effort")) + assertFalse(result.containsKey("serviceTier")) + } + + @Test + fun `blank effort and service tier are omitted`() { + val result = params(effort = " ", serviceTier = "") + + assertFalse(result.containsKey("effort")) + assertFalse(result.containsKey("serviceTier")) + } + + @Test + fun `default is passed through to force standard speed`() { + val result = params(serviceTier = "default") + + assertEquals("default", result.getValue("serviceTier").jsonPrimitive.content) + } + + @Test + fun `the thread, input and model are always present`() { + val result = params(effort = "low") + + assertEquals("thr_1", result.getValue("threadId").jsonPrimitive.content) + assertTrue(result.containsKey("input")) + assertEquals("gpt-6-astra", result.getValue("model").jsonPrimitive.content) + } + + @Test + fun `a blank model is omitted like the other optional fields`() { + val result = params(model = " ") + + assertFalse(result.containsKey("model")) + } + + private val model = + OpenCodeModel( + id = "gpt-6-astra", + providerId = CodexModels.PROVIDER_ID, + codexDefaultEffort = "medium", + codexDefaultServiceTier = "priority", + ) + + @Test + fun `no variant falls back to the model's default effort`() { + assertEquals("medium", resolveCodexTurnSettings(null, null, model).effort) + } + + @Test + fun `an explicit variant wins over the model default`() { + assertEquals("ultra", resolveCodexTurnSettings("ultra", null, model).effort) + } + + @Test + fun `no requested tier falls back to the model's default tier`() { + assertEquals("priority", resolveCodexTurnSettings(null, null, model).serviceTier) + } + + @Test + fun `no requested tier and no model default sends default`() { + val plain = OpenCodeModel(id = "plain", providerId = CodexModels.PROVIDER_ID) + + assertEquals(DEFAULT_SERVICE_TIER, resolveCodexTurnSettings(null, null, plain).serviceTier) + assertNull(resolveCodexTurnSettings(null, null, plain).effort) + } + + @Test + fun `an explicit default or tier passes through unchanged`() { + assertEquals("default", resolveCodexTurnSettings(null, "default", model).serviceTier) + assertEquals("priority", resolveCodexTurnSettings(null, "priority", model).serviceTier) + } + + @Test + fun `an unknown model omits the effort and sends default`() { + val settings = resolveCodexTurnSettings(null, null, null) + + assertNull(settings.effort) + assertEquals("default", settings.serviceTier) + } + + @Test + fun `an unknown model still honours an explicit variant and tier`() { + val settings = resolveCodexTurnSettings("high", "priority", null) + + assertEquals("high", settings.effort) + assertEquals("priority", settings.serviceTier) + } +} diff --git a/docs/CODEX.md b/docs/CODEX.md index f39b8f9a4..6b12a8f82 100644 --- a/docs/CODEX.md +++ b/docs/CODEX.md @@ -209,6 +209,42 @@ non-functional, second sandboxing layer. live, signed-in account. `CodexItemParser` maps the image back out of the echoed `userMessage` content into a `file` part, so the attachment shows in the transcript and survives a reload. +## Reasoning effort and Fast mode + +Both come from `model/list` (verified live against `codex-cli 0.163.0`; a Codex version whose models +omit these fields simply shows no effort or fast control for them): + +- **Reasoning effort**: each entry of `supportedReasoningEfforts` becomes a model variant, in the + server's order, so the chat's thinking chip lists them. Efforts seen so far: `low`, `medium`, `high`, + `xhigh`, `max`, `ultra`, plus `none` and `minimal` documented by the protocol. Each has a chip label + (`none` reads "Off"); an effort the app does not know yet shows its raw id capitalized. Fast mode is + the bolt "Fast" chip beside it, shown only for a model with a fast tier; the tier's server description + is its screen-reader text. +- **Fast mode**: a model whose `serviceTiers` lists the tier named "Fast" (id `priority`), or the + deprecated `additionalSpeedTiers: ["fast"]`, gets `codexFastServiceTier`. The user's global choice + (`codexFastMode`, persisted; unset follows the model) maps to a tier as `codexServiceTier`: unset + requests nothing, on requests the tier id, off requests `"default"` for standard speed. Some models + default to the fast tier (`defaultServiceTier`), which the UI state reports as on until turned off. +- **Every turn states its settings.** The app-server applies `effort` and `serviceTier` to subsequent + turns as well as the current one, so an omitted value would inherit the previous turn's: a `high` left + on after going back to the default, an `ultra` carried onto a different model, or a fast tier kept + after switching to a model without one. `CodexTarget.sendMessage` therefore always sends both, resolved + by `resolveCodexTurnSettings`: + - `effort` is the chat's chosen variant, else the model's `defaultReasoningEffort` + (`codexDefaultEffort`). It is omitted only when neither is known, in which case the thread keeps + the previous turn's effort (every model in 0.163.0's `model/list` has a default, so this is rare). + - `serviceTier` is the requested tier, else the model's `defaultServiceTier` (`codexDefaultServiceTier`), + else `"default"`. + - The model is the one named on the request, else the one `model/list` marks `isDefault` (first listed + if none is marked). Its defaults come from the catalogue the target last built; a model it has not + seen triggers a best-effort `model/list` re-read, retried on later turns until one read succeeds. + If the model is still unknown, the turn sends `serviceTier: "default"` and omits `effort`. + - `isDefault: boolean` is part of the generated `v2/Model.ts` in both 0.155.1 and 0.163.0. + +The fast fields are named `codexFast*` so they cannot collide with an OpenCode key, and they stay +serialized so the disk catalogue cache keeps them. `serviceTier` on `PromptRequest` is built field by +field for OpenCode and never sent there. + ## Testing `CodexJsonRpcClientTest`, `CodexItemParserTest` and `CodexModelsTest` use JSON fixtures taken diff --git a/runtime_tools/termux-packaging-recipes/proot.build.sh b/runtime_tools/termux-packaging-recipes/proot.build.sh index ae054d3b7..f74db8527 100644 --- a/runtime_tools/termux-packaging-recipes/proot.build.sh +++ b/runtime_tools/termux-packaging-recipes/proot.build.sh @@ -1,6 +1,6 @@ # Mirrored verbatim from https://github.com/termux/termux-packages/blob/master/packages/proot/build.sh -# Retrieved: 2026-10-01, at commit de39661946f7e8175b5dd0755121fa28cb0aebd1 ("bump(main/proot): -# 5.1.107.96"). Mirrored here (rather than only linked) because the upstream +# Retrieved: 2026-10-18, at master commit 6be420e7536d1e228f3ffee2eb74ca2736a73fc1 (packages/proot/ +# build.sh at 5.1.107.97). Mirrored here (rather than only linked) because the upstream # 'master' branch is mutable and would otherwise be an unstable reference for the exact # packaging recipe used to build the binaries pinned by SHA-256 in # runtime_tools/termux_assets.lock.json. @@ -10,9 +10,9 @@ TERMUX_PKG_HOMEPAGE=https://proot-me.github.io/ TERMUX_PKG_DESCRIPTION="Emulate chroot, bind mount and binfmt_misc for non-root users" TERMUX_PKG_LICENSE="GPL-2.0" TERMUX_PKG_MAINTAINER="Michal Bednarski @michalbednarski" -TERMUX_PKG_VERSION="5.1.107.96" +TERMUX_PKG_VERSION="5.1.107.97" TERMUX_PKG_SRCURL=https://github.com/termux/proot/archive/v${TERMUX_PKG_VERSION}.zip -TERMUX_PKG_SHA256=75f654fe60dea92dabff2bf083ae8bfe4f91baa6a1a374786a6bf391015eebaa +TERMUX_PKG_SHA256=badd014e6868c0d57b5eae6db08afc284b77f85990c71665d1659e121482df26 TERMUX_PKG_AUTO_UPDATE=true TERMUX_PKG_UPDATE_TAG_TYPE="newest-tag" TERMUX_PKG_DEPENDS="libandroid-shmem, libtalloc" diff --git a/runtime_tools/termux_assets.lock.json b/runtime_tools/termux_assets.lock.json index 2ebc63c78..cba72c1c0 100644 --- a/runtime_tools/termux_assets.lock.json +++ b/runtime_tools/termux_assets.lock.json @@ -7,10 +7,10 @@ "libandroid-shmem", "libtalloc" ], - "filename": "pool/main/p/proot/proot_5.1.107.96_aarch64.deb", + "filename": "pool/main/p/proot/proot_5.1.107.97_aarch64.deb", "name": "proot", - "sha256": "8199dca06dccb693ec09fb1759e3e1ad08b4863f0c11c612f89c20bd9ecdc1a0", - "version": "5.1.107.96" + "sha256": "9a368d879876b1458b03d3936f7b75f94875eca9ece4c5870acf24993648fd94", + "version": "5.1.107.97" }, { "depends": [], @@ -36,10 +36,10 @@ "libandroid-shmem", "libtalloc" ], - "filename": "pool/main/p/proot/proot_5.1.107.96_x86_64.deb", + "filename": "pool/main/p/proot/proot_5.1.107.97_x86_64.deb", "name": "proot", - "sha256": "77ea45540071ca543adda2b51aca2bc3761c52904d013fd0890682288eff9455", - "version": "5.1.107.96" + "sha256": "bd1795306f42d2d0e0ea73930f942744f77dfdfabcb6514ba52e5531f415b3ea", + "version": "5.1.107.97" }, { "depends": [],