From b14eeda471af0eb4ad6c10137b5bede1384febbe Mon Sep 17 00:00:00 2001 From: Jubarte Date: Fri, 9 Oct 2026 17:42:07 -0300 Subject: [PATCH 1/4] perf: optimize agent projections, mobile --- .../companion/MobileStoragePerformanceTest.kt | 115 ++++++++ .../companion/ApprovalActionReceiver.kt | 7 +- .../slop/duckweed/companion/MainActivity.kt | 62 ++-- .../slop/duckweed/companion/MessageStore.kt | 124 +++++--- .../duckweed/companion/NotificationTools.kt | 6 +- .../duckweed/companion/ReadActionReceiver.kt | 16 +- .../slop/duckweed/companion/ReadSyncWorker.kt | 41 +-- .../slop/duckweed/companion/WorkspaceStore.kt | 19 +- docs/performance-audit.md | 81 ++++++ docs/usage-cost-audit.md | 38 +++ relay/src/index.ts | 12 +- relay/test/relay.vitest.ts | 48 +++ scripts/benchmark-agent-projections.ts | 157 ++++++++++ scripts/benchmark-mobile-utf8.ts | 47 +++ scripts/mobile-pairing-continuity.test.mjs | 2 +- src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 4 + src-tauri/src/agent_activity.rs | 128 +++++--- src-tauri/src/agent_proc.rs | 30 +- src-tauri/src/agent_sessions.rs | 3 +- src-tauri/src/codex_transport.rs | 86 +++++- src-tauri/src/discord_presence.rs | 37 ++- src-tauri/src/fs.rs | 124 ++++++-- src-tauri/src/git.rs | 76 ++++- src-tauri/src/main.rs | 2 +- src-tauri/src/mobile_push.rs | 184 ++++++++++-- src-tauri/src/ports.rs | 273 ++++++++++++++++-- src-tauri/src/pty.rs | 91 ++++-- src-tauri/src/shells.rs | 17 +- src-tauri/src/usage/mod.rs | 117 +++++++- src-tauri/src/usage/pricing.rs | 31 +- src/App.tsx | 24 +- src/components/agent/AgentSurface.tsx | 16 +- .../agent/official/OfficialShared.test.tsx | 58 ++++ .../agent/official/OfficialShared.tsx | 39 ++- .../agent/provider/CursorExperience.tsx | 6 +- .../agent/provider/OpenCodeExperience.tsx | 4 +- .../agent/subagents/SubagentBoard.tsx | 26 +- .../agent/subagents/SubagentUiContext.tsx | 50 +++- src/lib/agents/adapters/codex.test.ts | 25 +- src/lib/agents/adapters/codex.ts | 9 +- src/lib/agents/subagents.test.ts | 75 +++++ src/lib/agents/subagents.ts | 57 +++- src/lib/blocks.test.ts | 113 +++++++- src/lib/blocks.ts | 27 +- src/lib/frames.test.ts | 29 ++ src/lib/frames.ts | 16 +- src/lib/highlight.test.ts | 19 ++ src/lib/highlight.ts | 35 ++- src/lib/mobileWorkspace.test.ts | 16 + src/lib/mobileWorkspace.ts | 57 +++- src/lib/terminals.ts | 9 +- .../codex-transport-integration.py | 33 ++- 53 files changed, 2290 insertions(+), 432 deletions(-) create mode 100644 android/app/src/androidTest/java/dev/slop/duckweed/companion/MobileStoragePerformanceTest.kt create mode 100644 docs/performance-audit.md create mode 100644 scripts/benchmark-agent-projections.ts create mode 100644 scripts/benchmark-mobile-utf8.ts diff --git a/android/app/src/androidTest/java/dev/slop/duckweed/companion/MobileStoragePerformanceTest.kt b/android/app/src/androidTest/java/dev/slop/duckweed/companion/MobileStoragePerformanceTest.kt new file mode 100644 index 00000000..09229f69 --- /dev/null +++ b/android/app/src/androidTest/java/dev/slop/duckweed/companion/MobileStoragePerformanceTest.kt @@ -0,0 +1,115 @@ +package dev.slop.duckweed.companion + +import android.content.ContentValues +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +/** Checks repeated sync writes and encrypted reads against real Android storage. */ +@RunWith(AndroidJUnit4::class) +class MobileStoragePerformanceTest { + private val context get() = InstrumentationRegistry.getInstrumentation().targetContext + private val pairId = "storage-regression" + private val terminalId = "terminal" + private val workspaceStore get() = WorkspaceStore(context) + + @Before + fun reset() { + context.deleteDatabase("duckweed-messages.db") + workspaceStore.remove(pairId) + workspaceStore.remove("other-storage-regression") + } + + private fun snapshot() = WorkspaceSnapshot( + pairId, 500L, listOf(RemoteProject( + "project", "Storage regression", "H:/project", null, + terminals = listOf(RemoteTerminal( + terminalId, "Codex", "PowerShell", "Codex", null, "idle", + mode = "conversation", completionSeq = 7L, unreadOnDesktop = true, + conversation = listOf( + RemoteConversationMessage("user", 100L, "user", "Prompt"), + RemoteConversationMessage("older", 200L, "assistant", "Older response"), + RemoteConversationMessage("latest", 300L, "assistant", "Latest response"), + RemoteConversationMessage("stream", 400L, "assistant", "Still streaming", streaming = true), + ), + )), + )), + ) + + @Test + fun repeatedSnapshotsDoNotRewriteHistoryAndStillRepairLegacyRouting() { + val value = snapshot() + val latestId = "workspace:$pairId:$terminalId:latest" + MessageStore(context).use { store -> + assertEquals(emptyList(), store.putSyncedConversation(value)) + assertEquals(3, store.conversation(pairId, terminalId).size) + assertNull(store.message(latestId)?.readAt) + assertEquals(200L, store.message("workspace:$pairId:$terminalId:older")?.readAt) + val database = store.writableDatabase + val encrypted = database.rawQuery("SELECT encrypted_payload FROM messages WHERE id = ?", arrayOf(latestId)) + .use { it.moveToFirst(); it.getString(0) } + database.execSQL("CREATE TABLE routing_writes (count INTEGER NOT NULL)") + database.execSQL("INSERT INTO routing_writes VALUES (0)") + database.execSQL("CREATE TRIGGER count_routing_writes AFTER UPDATE OF pair_id, terminal_id, kind ON messages BEGIN UPDATE routing_writes SET count = count + 1; END") + repeat(4) { store.putSyncedConversation(value) } + val writes = database.rawQuery("SELECT count FROM routing_writes", null) + .use { it.moveToFirst(); it.getInt(0) } + assertEquals("Unchanged snapshot history should require no routing writes", 0, writes) + val repeated = database.rawQuery("SELECT encrypted_payload FROM messages WHERE id = ?", arrayOf(latestId)) + .use { it.moveToFirst(); it.getString(0) } + assertEquals(encrypted, repeated) + + store.markRead(latestId, 600L) + database.update("messages", ContentValues().apply { + putNull("pair_id"); putNull("terminal_id"); putNull("kind") + }, "id = ?", arrayOf(latestId)) + database.execSQL("UPDATE routing_writes SET count = 0") + store.putSyncedConversation(value) + val repaired = store.message(latestId)!! + assertEquals(600L, repaired.readAt) + assertEquals(pairId, store.conversation(pairId, terminalId).last().pairId) + assertEquals(1, database.rawQuery("SELECT count FROM routing_writes", null) + .use { it.moveToFirst(); it.getInt(0) }) + + val desktopRead = value.copy(projects = value.projects.map { project -> + project.copy(terminals = project.terminals.map { it.copy(unreadOnDesktop = false, readCompletionSeq = 7L) }) + }) + store.putSyncedConversation(desktopRead) + assertTrue(store.unreadConversationKeys().isEmpty()) + } + } + + @Test + fun approvalRefreshSelectsOnlyActiveAttentionMessagesIncludingLegacyRows() { + MessageStore(context).use { store -> + fun put(id: String, kind: String) = store.put(CompletionRecord( + id, 100L, "Codex", "Storage regression", kind, "Response", null, + pairId = pairId, terminalId = terminalId, + )) + put("active-attention", "attention") + put("inactive-attention", "attention") + put("active-completion", "completed") + put("legacy-attention", "attention") + store.writableDatabase.update("messages", ContentValues().apply { putNull("kind") }, "id = ?", arrayOf("legacy-attention")) + val active = setOf("active-attention".hashCode(), "active-completion".hashCode(), "legacy-attention".hashCode()) + assertEquals(setOf("active-attention", "legacy-attention"), store.activeAttentionMessages(active).map { it.id }.toSet()) + assertTrue(store.activeAttentionMessages(emptySet()).isEmpty()) + } + } + + @Test + fun singleWorkspaceLookupPreservesHeartbeatAndDoesNotReadOtherPairings() { + val value = snapshot() + workspaceStore.put(value, 700L) + workspaceStore.put(value.copy(pairId = "other-storage-regression", updatedAt = 900L), 900L) + workspaceStore.markPresence(pairId, 1_000L) + assertEquals(workspaceStore.all().first { it.pairId == pairId }, workspaceStore.get(pairId)) + assertEquals(1_000L, workspaceStore.get(pairId)?.lastSeenAt) + assertNull(workspaceStore.get("missing-storage-regression")) + workspaceStore.remove(pairId) + assertNull(workspaceStore.get(pairId)) + } +} diff --git a/android/app/src/main/java/dev/slop/duckweed/companion/ApprovalActionReceiver.kt b/android/app/src/main/java/dev/slop/duckweed/companion/ApprovalActionReceiver.kt index 6c8c9155..93fe60b0 100644 --- a/android/app/src/main/java/dev/slop/duckweed/companion/ApprovalActionReceiver.kt +++ b/android/app/src/main/java/dev/slop/duckweed/companion/ApprovalActionReceiver.kt @@ -22,8 +22,7 @@ class ApprovalActionReceiver : BroadcastReceiver() { val result = runCatching { val credentials = SecretStore.load(context, pairId) ?: error("This desktop is no longer paired.") - val permission = WorkspaceStore(context).all() - .firstOrNull { it.pairId == pairId } + val permission = WorkspaceStore(context).get(pairId) ?.projects ?.asSequence() ?.flatMap { it.terminals.asSequence() } @@ -42,12 +41,12 @@ class ApprovalActionReceiver : BroadcastReceiver() { } Handler(Looper.getMainLooper()).post { result.onSuccess { - MessageStore(context).markRead(messageId) + MessageStore(context).use { it.markRead(messageId) } NotificationManagerCompat.from(context).cancel(messageId.hashCode()) Toast.makeText(context, "Decision sent securely", Toast.LENGTH_SHORT).show() NotificationTools.announceChanged(context) }.onFailure { error -> - MessageStore(context).response(messageId)?.let { NotificationTools.show(context, it) } + MessageStore(context).use { it.response(messageId) }?.let { NotificationTools.show(context, it) } Toast.makeText( context, error.message ?: "Could not send this decision. Try again.", diff --git a/android/app/src/main/java/dev/slop/duckweed/companion/MainActivity.kt b/android/app/src/main/java/dev/slop/duckweed/companion/MainActivity.kt index 51ef97ec..73ab6118 100644 --- a/android/app/src/main/java/dev/slop/duckweed/companion/MainActivity.kt +++ b/android/app/src/main/java/dev/slop/duckweed/companion/MainActivity.kt @@ -243,7 +243,7 @@ class MainActivity : AppCompatActivity() { if (granted) { showPendingNotifications() } else { - MessageStore(this).dismissPendingNotifications() + MessageStore(this).use { it.dismissPendingNotifications() } } } @@ -313,7 +313,7 @@ class MainActivity : AppCompatActivity() { draftStore = DraftStore(this) pendingActionStore = PendingMobileActionStore(this) pendingMobileActions = pendingActionStore.all() - storageExecutor.execute { MessageStore(this).recoverInterruptedSends() } + storageExecutor.execute { MessageStore(this).use { it.recoverInterruptedSends() } } ReadSyncScheduler.enqueue(this) pairingStatus = findViewById(R.id.pairing_status) @@ -880,9 +880,10 @@ class MainActivity : AppCompatActivity() { if (enabled == notificationsAreActive()) return@setOnCheckedChangeListener if (!enabled) { NotificationPreference.setEnabled(this, false) - val store = MessageStore(this) - store.dismissPendingNotifications() - NotificationTools.cancel(this, store.latest()) + MessageStore(this).use { store -> + store.dismissPendingNotifications() + NotificationTools.cancel(this, store.latest()) + } return@setOnCheckedChangeListener } if (needsNotificationPermission()) { @@ -1004,7 +1005,7 @@ class MainActivity : AppCompatActivity() { private fun requestNotificationPermissionIfEnabled() { if (!NotificationPreference.isEnabled(this)) { - MessageStore(this).dismissPendingNotifications() + MessageStore(this).use { it.dismissPendingNotifications() } return } if (needsNotificationPermission()) { @@ -1217,7 +1218,7 @@ class MainActivity : AppCompatActivity() { .onSuccess { SecretStore.remove(this, pairing.pairId) WorkspaceStore(this).remove(pairing.pairId) - MessageStore(this).discardReadSyncs(pairing.pairId) + MessageStore(this).use { it.discardReadSyncs(pairing.pairId) } PendingMobileActionStore(this).removePair(pairing.pairId) } .onFailure { failures += it.message ?: pairing.pairId } @@ -1342,12 +1343,13 @@ class MainActivity : AppCompatActivity() { .map { Pair(snapshot.pairId, it.id) } } }.toSet() - val store = MessageStore(this) - Triple( - snapshots, - store.latestForOpenAgents(openAgentTerminals, 50), - store.unreadConversationKeys(), - ) + MessageStore(this).use { store -> + Triple( + snapshots, + store.latestForOpenAgents(openAgentTerminals, 50), + store.unreadConversationKeys(), + ) + } } runOnUiThread { remoteStateLoading = false @@ -1554,16 +1556,14 @@ class MainActivity : AppCompatActivity() { val pairId = message.pairId val terminalId = message.terminalId if (pairId != null && terminalId != null) { - val cleared = MessageStore(this).markConversationRead( - pairId, - terminalId, - message.completionSeq, - ) + val cleared = MessageStore(this).use { + it.markConversationRead(pairId, terminalId, message.completionSeq) + } NotificationTools.cancelIds(this, cleared) ReadSyncScheduler.enqueue(this) messageAdapter.markConversationRead(pairId, terminalId) } else { - MessageStore(this).markRead(message.id) + MessageStore(this).use { it.markRead(message.id) } messageAdapter.markRead(message.id) } selectedTarget = null @@ -3224,7 +3224,7 @@ class MainActivity : AppCompatActivity() { sentAt, ) }.onSuccess { - MessageStore(this).updateOutgoingState(commandId, "sent") + MessageStore(this).use { it.updateOutgoingState(commandId, "sent") } runOnUiThread { if (isFinishing || isDestroyed) return@runOnUiThread outgoingMessages[commandId] = outgoing.copy(deliveryState = "sent") @@ -3241,11 +3241,9 @@ class MainActivity : AppCompatActivity() { ) } }.onFailure { error -> - MessageStore(this).updateOutgoingState( - commandId, - "failed", - error.message ?: "Could not send this message.", - ) + MessageStore(this).use { + it.updateOutgoingState(commandId, "failed", error.message ?: "Could not send this message.") + } runOnUiThread { if (isFinishing || isDestroyed) return@runOnUiThread outgoingMessages[commandId] = outgoing.copy( @@ -3276,7 +3274,7 @@ class MainActivity : AppCompatActivity() { refreshConversation(reloadHistory = false) commandExecutor.execute { runCatching { - MessageStore(this).updateOutgoingState(message.id, "sending") + MessageStore(this).use { it.updateOutgoingState(message.id, "sending") } val credentials = SecretStore.load(this, pairId) ?: error("Desktop pairing is no longer available. Pair this desktop again.") RelayClient.sendCommand( @@ -3289,7 +3287,7 @@ class MainActivity : AppCompatActivity() { System.currentTimeMillis(), ) }.onSuccess { - MessageStore(this).updateOutgoingState(message.id, "sent") + MessageStore(this).use { it.updateOutgoingState(message.id, "sent") } runOnUiThread { if (isFinishing || isDestroyed) return@runOnUiThread outgoingMessages[message.id] = message.copy(deliveryState = "sent", deliveryError = null) @@ -3303,11 +3301,9 @@ class MainActivity : AppCompatActivity() { ) } }.onFailure { error -> - MessageStore(this).updateOutgoingState( - message.id, - "failed", - error.message ?: "Could not send this message.", - ) + MessageStore(this).use { + it.updateOutgoingState(message.id, "failed", error.message ?: "Could not send this message.") + } runOnUiThread { if (isFinishing || isDestroyed) return@runOnUiThread outgoingMessages[message.id] = message.copy(deliveryState = "failed", deliveryError = error.message) @@ -3419,7 +3415,7 @@ class MainActivity : AppCompatActivity() { private fun openIntentResponse() { if (isAppLockEnabled() && !appUnlocked) return val messageId = intent.getStringExtra("message_id") ?: return - val message = MessageStore(this).response(messageId) ?: return + val message = MessageStore(this).use { it.response(messageId) } ?: return intent.removeExtra("message_id") navigateToPage(Page.ACTIVITY) openResponse(message) diff --git a/android/app/src/main/java/dev/slop/duckweed/companion/MessageStore.kt b/android/app/src/main/java/dev/slop/duckweed/companion/MessageStore.kt index 7d74e493..e3b012de 100644 --- a/android/app/src/main/java/dev/slop/duckweed/companion/MessageStore.kt +++ b/android/app/src/main/java/dev/slop/duckweed/companion/MessageStore.kt @@ -288,6 +288,25 @@ class MessageStore(context: Context) : SQLiteOpenHelper(context, "duckweed-messa arrayOf("completed", "attention"), ).filter { it.kind == "completed" || it.kind == "attention" } + fun activeAttentionMessages(notificationIds: Set): List { + if (notificationIds.isEmpty()) return emptyList() + val ids = mutableListOf() + // NotificationManager exposes integer hashes. Resolve only matching ids + // from routing metadata before decrypting their response payloads. + readableDatabase.query( + "messages", arrayOf("id"), "kind IN (?, ?) OR kind IS NULL", + arrayOf("completed", "attention"), null, null, "sent_at DESC", "500", + ).use { cursor -> + while (cursor.moveToNext()) { + val id = cursor.getString(0) + if (id.hashCode() in notificationIds) ids += id + } + } + if (ids.isEmpty()) return emptyList() + return read("id IN (${ids.joinToString(",") { "?" }})", ids.toTypedArray()) + .filter { it.kind == "attention" } + } + fun latestForOpenAgents( openTerminals: Set>, limit: Int = 50, @@ -399,59 +418,69 @@ class MessageStore(context: Context) : SQLiteOpenHelper(context, "duckweed-messa val clearedNotificationIds = mutableListOf() database.beginTransaction() try { + // Snapshot tails repeat on every update. Read their small routing + // metadata once so unchanged history requires no writes or encryption. + val storedRoutes = syncedMessageRouting(database) snapshot.projects.forEach { project -> project.terminals.forEach { terminal -> val mobileReadAt = conversationReadAt(database, snapshot.pairId, terminal.id) + val mobileReadSeq = conversationReadSeq(database, snapshot.pairId, terminal.id) val latestAssistantId = terminal.conversation .lastOrNull { it.role == "assistant" && !it.streaming } ?.id terminal.conversation.filterNot { it.streaming }.forEach { message -> val id = "workspace:${snapshot.pairId}:${terminal.id}:${message.id}" - val unread = MobileSyncPolicy.isSyncedMessageUnread( - terminal.unreadOnDesktop, - message.id == latestAssistantId, - message.sentAt, - mobileReadAt, - terminal.completionSeq, - conversationReadSeq(database, snapshot.pairId, terminal.id), - ) - val record = CompletionRecord( - id = id, - pairId = snapshot.pairId, - sentAt = message.sentAt, - agent = terminal.agent ?: "Agent", - project = project.name, - projectId = project.id, - terminalId = terminal.id, - terminalTitle = terminal.title, - kind = if (message.role == "user") "user" else "completed", - response = message.text, - durationMs = null, - soundCue = null, - workspace = null, - readAt = message.sentAt.takeUnless { unread }, - unreadOnDesktop = terminal.unreadOnDesktop, - completionSeq = terminal.completionSeq, - ) - if (!recordExists(database, id)) { + val kind = if (message.role == "user") "user" else "completed" + val existing = storedRoutes[id] + if (existing == null) { + val unread = MobileSyncPolicy.isSyncedMessageUnread( + terminal.unreadOnDesktop, + message.id == latestAssistantId, + message.sentAt, + mobileReadAt, + terminal.completionSeq, + mobileReadSeq, + ) + val record = CompletionRecord( + id = id, + pairId = snapshot.pairId, + sentAt = message.sentAt, + agent = terminal.agent ?: "Agent", + project = project.name, + projectId = project.id, + terminalId = terminal.id, + terminalTitle = terminal.title, + kind = kind, + response = message.text, + durationMs = null, + soundCue = null, + workspace = null, + readAt = message.sentAt.takeUnless { unread }, + unreadOnDesktop = terminal.unreadOnDesktop, + completionSeq = terminal.completionSeq, + ) database.insertWithOnConflict( "messages", null, valuesFor(record, message.sentAt, record.readAt), SQLiteDatabase.CONFLICT_IGNORE, ) + storedRoutes[id] = StoredMessageRouting(snapshot.pairId, terminal.id, kind) + } else if (existing.pairId != snapshot.pairId || existing.terminalId != terminal.id || existing.kind != kind) { + // Repair legacy routing columns without rewriting the + // payload or changing existing read/notification state. + database.update( + "messages", + ContentValues().apply { + put("pair_id", snapshot.pairId) + put("terminal_id", terminal.id) + put("kind", kind) + }, + "id = ?", + arrayOf(id), + ) + storedRoutes[id] = StoredMessageRouting(snapshot.pairId, terminal.id, kind) } - // Records created by older app versions did not have routing columns. - database.update( - "messages", - ContentValues().apply { - put("pair_id", snapshot.pairId) - put("terminal_id", terminal.id) - put("kind", record.kind) - }, - "id = ?", - arrayOf(id), - ) } if (terminal.unreadOnDesktop == false || terminal.readCompletionSeq != null) { val through = if (terminal.unreadOnDesktop == false) snapshot.updatedAt else 0L @@ -480,6 +509,25 @@ class MessageStore(context: Context) : SQLiteOpenHelper(context, "duckweed-messa return clearedNotificationIds } + private data class StoredMessageRouting(val pairId: String?, val terminalId: String?, val kind: String?) + + private fun syncedMessageRouting(database: SQLiteDatabase): MutableMap { + val routing = mutableMapOf() + database.query( + "messages", arrayOf("id", "pair_id", "terminal_id", "kind"), + "id LIKE ?", arrayOf("workspace:%"), null, null, null, + ).use { cursor -> + while (cursor.moveToNext()) { + routing[cursor.getString(0)] = StoredMessageRouting( + if (cursor.isNull(1)) null else cursor.getString(1), + if (cursor.isNull(2)) null else cursor.getString(2), + if (cursor.isNull(3)) null else cursor.getString(3), + ) + } + } + return routing + } + private fun read(selection: String?, selectionArgs: Array? = null): List { val messages = mutableListOf() readableDatabase.query( diff --git a/android/app/src/main/java/dev/slop/duckweed/companion/NotificationTools.kt b/android/app/src/main/java/dev/slop/duckweed/companion/NotificationTools.kt index 6ef5c311..094b4949 100644 --- a/android/app/src/main/java/dev/slop/duckweed/companion/NotificationTools.kt +++ b/android/app/src/main/java/dev/slop/duckweed/companion/NotificationTools.kt @@ -172,8 +172,7 @@ object NotificationTools { if (message.kind != "attention") return emptyList() val pairId = message.pairId ?: return emptyList() val terminalId = message.terminalId ?: return emptyList() - val target = WorkspaceStore(context).all() - .firstOrNull { it.pairId == pairId } + val target = WorkspaceStore(context).get(pairId) ?.projects ?.asSequence() ?.mapNotNull { project -> @@ -230,8 +229,7 @@ object NotificationTools { .map { it.id } .toSet() if (activeIds.isEmpty()) return - MessageStore(context).use { it.latest() } - .filter { it.kind == "attention" && it.id.hashCode() in activeIds } + MessageStore(context).use { it.activeAttentionMessages(activeIds) } .forEach { show(context, it) } } diff --git a/android/app/src/main/java/dev/slop/duckweed/companion/ReadActionReceiver.kt b/android/app/src/main/java/dev/slop/duckweed/companion/ReadActionReceiver.kt index a68a582d..f2aa319f 100644 --- a/android/app/src/main/java/dev/slop/duckweed/companion/ReadActionReceiver.kt +++ b/android/app/src/main/java/dev/slop/duckweed/companion/ReadActionReceiver.kt @@ -14,16 +14,12 @@ class ReadActionReceiver : BroadcastReceiver() { val pendingResult = goAsync() Thread({ val result = runCatching { - val store = MessageStore(context) - val message = store.response(messageId) ?: error("This response is no longer available.") - val pairId = message.pairId ?: error("This response is not linked to a desktop.") - val terminalId = message.terminalId ?: error("This response is not linked to a terminal.") - val cleared = store.markConversationRead( - pairId, - terminalId, - message.completionSeq, - at = message.sentAt, - ) + val cleared = MessageStore(context).use { store -> + val message = store.response(messageId) ?: error("This response is no longer available.") + val pairId = message.pairId ?: error("This response is not linked to a desktop.") + val terminalId = message.terminalId ?: error("This response is not linked to a terminal.") + store.markConversationRead(pairId, terminalId, message.completionSeq, at = message.sentAt) + } NotificationTools.cancelIds(context, cleared.ifEmpty { listOf(messageId) }) ReadSyncScheduler.enqueue(context) NotificationTools.announceChanged(context) diff --git a/android/app/src/main/java/dev/slop/duckweed/companion/ReadSyncWorker.kt b/android/app/src/main/java/dev/slop/duckweed/companion/ReadSyncWorker.kt index 7ea01135..26c441a3 100644 --- a/android/app/src/main/java/dev/slop/duckweed/companion/ReadSyncWorker.kt +++ b/android/app/src/main/java/dev/slop/duckweed/companion/ReadSyncWorker.kt @@ -13,28 +13,29 @@ import androidx.work.OutOfQuotaPolicy class ReadSyncWorker(context: Context, parameters: WorkerParameters) : Worker(context, parameters) { override fun doWork(): Result { - val store = MessageStore(applicationContext) - var retry = false - store.pendingReadSyncs().forEach { sync -> - val credentials = SecretStore.load(applicationContext, sync.pairId) - if (credentials == null) { - retry = true - return@forEach - } - runCatching { - RelayClient.sendRead( - credentials, - sync.terminalId, - sync.completionSeq, - sync.commandId, - ) - }.onSuccess { - store.completeReadSync(sync) - }.onFailure { - retry = true + return MessageStore(applicationContext).use { store -> + var retry = false + store.pendingReadSyncs().forEach { sync -> + val credentials = SecretStore.load(applicationContext, sync.pairId) + if (credentials == null) { + retry = true + return@forEach + } + runCatching { + RelayClient.sendRead( + credentials, + sync.terminalId, + sync.completionSeq, + sync.commandId, + ) + }.onSuccess { + store.completeReadSync(sync) + }.onFailure { + retry = true + } } + if (retry) Result.retry() else Result.success() } - return if (retry) Result.retry() else Result.success() } } diff --git a/android/app/src/main/java/dev/slop/duckweed/companion/WorkspaceStore.kt b/android/app/src/main/java/dev/slop/duckweed/companion/WorkspaceStore.kt index b972649c..c5f1cb66 100644 --- a/android/app/src/main/java/dev/slop/duckweed/companion/WorkspaceStore.kt +++ b/android/app/src/main/java/dev/slop/duckweed/companion/WorkspaceStore.kt @@ -189,10 +189,8 @@ class WorkspaceStore(private val context: Context) { return true } - fun all(): List = preferences.all.values.mapNotNull { stored -> - val encrypted = stored as? String ?: return@mapNotNull null - runCatching { - val json = JSONObject(String(SecretStore.decryptLocal(encrypted), Charsets.UTF_8)) + private fun decode(stored: String): WorkspaceSnapshot? = runCatching { + val json = JSONObject(String(SecretStore.decryptLocal(stored), Charsets.UTF_8)) val projectsJson = json.optJSONArray("projects") ?: JSONArray() WorkspaceSnapshot( pairId = json.getString("pairId"), @@ -277,9 +275,18 @@ class WorkspaceStore(private val context: Context) { }, ) }.getOrNull() - }.map { snapshot -> + + private fun withPresence(snapshot: WorkspaceSnapshot): WorkspaceSnapshot = snapshot.copy(presenceAt = maxOf(snapshot.lastSeenAt, presence.getLong(snapshot.pairId, 0L))) - }.sortedByDescending { it.updatedAt } + + fun get(pairId: String): WorkspaceSnapshot? = + preferences.getString(pairId, null)?.let(::decode)?.let(::withPresence) + + fun all(): List = preferences.all.values.asSequence() + .mapNotNull { stored -> (stored as? String)?.let(::decode) } + .map(::withPresence) + .sortedByDescending { it.updatedAt } + .toList() fun markPresence(pairId: String, at: Long): Boolean = synchronized(PRESENCE_LOCK) { markPresenceLocked(pairId, at) } diff --git a/docs/performance-audit.md b/docs/performance-audit.md new file mode 100644 index 00000000..105ce950 --- /dev/null +++ b/docs/performance-audit.md @@ -0,0 +1,81 @@ +# Performance audit + +Date: 2026-10-09. Audited baseline: `5070dcbacebea673fae1f607afd9c0255df02981`. + +Six parallel agents reviewed the terminal frontend, agent presentation, filesystem/Git/usage backend, process/protocol runtime, peripheral services, and Android/mobile/relay. The coordinating agent reviewed application scheduling, persistence, tools, and mobile snapshot generation, integrated the changes, and ran the full desktop validation. + +The changes reduce unnecessary work, allocations, native threads, and resource retention while preserving existing interaction, transcript content, terminal bytes, colors, and synchronization contracts. No CSS, layout, assets, feature switches, or history limits were changed. Desktop production CSS files retained their exact baseline build hashes (`App-hTd7VhcN.css` and `index-CrdWaOca.css`). This is style preservation evidence; it does not substitute for an exhaustive interactive visual comparison of every screen. + +## Implemented changes + +| Area | Problem | Change and preservation checks | +| --- | --- | --- | +| Git | One OS thread per changed file; duplicated diff bodies | At most eight line-count workers, preserving file order and binary/missing-file rules. Parsed hunks move into the result. Existing real-repository diff tests pass. | +| Explorer and search | Repeated lowercase allocations, prefix rescans, result clones, and a two-pipe Git deadlock | Cache sorting keys, count UTF-16 match columns incrementally, stop superseded searches sooner, move results, and feed Git stdin while draining stdout. Unicode columns and 8,000 ignored names are covered. | +| Usage and sessions | Large temporary index serialization and metadata calls inside sorting | Stream the index through a buffered writer with the same atomic replacement, read timestamps once per session file, and clone only the selected shell. | +| Terminal transport | Byte arrays serialized to JSON and full batches cloned; large buffers retained in recycling | Send actual raw binary Tauri responses, move output batches, and recycle only read-sized chunks. Tests assert exact binary bytes and bounded recycler capacity. | +| Terminal frontend | Regex/span work on plain output, repeated block pruning, linear pointer lookup, retained callbacks/timers | Add equivalent processing fast paths, prune only after marker disposal, use binary lookup, and clear closed-session resources. Palette, split-frame markers, real xterm trims, and late delivery are covered. | +| Agent presentation | Repeated subagent projection and context churn on every stream delta | Weak caches preserve immutable summaries, rosters, and settled groups. Separate activity membership from clocks, stabilize callbacks, and avoid redundant state updates. Tests cover cache invalidation and benchmark output matches baseline structurally. | +| Agent runtime | Duplicate JSON parsing and whole transcript-delta allocations | Parse shared Codex frames once and consume complete log lines with a reusable buffer. Partial offsets and intact large lines are covered. | +| CLI capture | Waiting for exit before draining stdout/stderr can fill pipes and time out | Drain both pipes while a cancellable watchdog supplies the existing deadline. Tests cover 256 KiB on each pipe and timeout cleanup. | +| Ports | Spawning netstat repeatedly; repeated process-table ancestry passes | Read native Windows IPv4/IPv6 TCP tables with CLI fallback, and traverse an indexed ownership graph. Tests verify real sockets, address/port/PID decoding, nested roots, and cycles. | +| Background services | Discord wakes every 250 ms while waiting; mobile delay creates a sleeping thread per message | Park/unpark Discord until its unchanged deadline or stop. Use cancellable async mobile timers and start blocking network work only when due. Cancellation and replacement races are tested. | +| Mobile workspace | Expensive projections without a paired recipient and quadratic Unicode truncation | Skip projections until paired, react to native pairing/removal changes across windows/processes, and truncate in linear time. Native ticks retain the 1.2-second cadence when paired. 51,486 comparisons matched baseline text output. | +| Relay | Inline inbox materializes 100 large ciphertext rows although only eight are returned | Fetch nine rows for eight deliveries plus `hasMore`; id-only requests fetch no ciphertext. Response limits, priority, expiry, and boundary behavior are tested. | +| Android | Repeated per-message SQL work/decryption and SQLite helpers left open | Read routing metadata once, skip unchanged updates, decrypt active notification candidates, query the needed workspace, and close helpers on success and failure. Unit tests pass; new device tests compile. | + +Other reviewed paths already use bounded streaming, lazy loading, cached catalogs, or action-triggered work. They were left intact where no safe, evidence-backed improvement was identified. + +## Measurements + +These measurements cover particular algorithms or data-materialization paths on this Windows machine. They are not whole-application CPU/RAM percentages. Timing depends on workload and concurrent machine load. + +| Workload | Before | After | +| --- | --- | --- | +| Git line count, 500 files, median of seven warm paired runs | 37.674 ms, 500 workers | 12.861 ms, at most eight workers | +| Session file sorting on a shuffled 500-file fixture | 9,956 metadata calls | 500 metadata calls, same order | +| Agent projections, 250 historical delegated turns and 200 updates, three-sample median | 1,637.9 ms | 46.1 ms | +| Terminal token highlighting, 1,000 reads with 20 log lines per read | 842.14 ms | 616.41 ms | +| Highlight-disabled path, 50,000 reads | 84.69 ms | 5.77 ms | +| Plain frame dispatch, 100,000 reads of 640 bytes | 43.94 ms | 5.89 ms | +| Synthetic complete-line reader, 64 MiB input, temporary heap excluding input | 64 MiB, 80 ms | 8.5 KiB, 29 ms | +| Windows TCP table acquisition, median of 20 samples | 34.746 ms, IPv4 netstat | 0.235 ms, native IPv4/IPv6 | +| Local SQLite inline results, 100 maximum-size ciphertexts | 32,024,757 allocated bytes | 2,882,430 allocated bytes | +| UTF-8 prefix truncation, 140,000-byte mixed text to a 16,000-byte budget, median of 15 paired runs | 366.408 ms | 0.025 ms | +| UTF-8 tail truncation, same fixture | 384.093 ms | 0.202 ms | + +The agent benchmark reused all 49,750 settled group references and 199/199 unchanged fleet/roster arrays. Weak keys permit collection when their transcript items are released. The UTF-8 benchmark also checks malformed UTF-16 and every integer byte boundary for deterministic random text. + +Reproducible comparison scripts: + +```powershell +bun scripts/benchmark-agent-projections.ts --baseline 5070dcbacebea673fae1f607afd9c0255df02981 +bun scripts/benchmark-mobile-utf8.ts +``` + +## Validation + +| Check | Result | +| --- | --- | +| `bun test` | 1,223 passed, zero failed, 94 files | +| `bun run typecheck` | Passed | +| `bun run build` | Passed | +| `bun run build:mobile` | Passed | +| `cargo test --manifest-path src-tauri/Cargo.toml` | 263 passed, zero failed, 11 ignored | +| Relay `npm test` | Nine passed | +| Relay `npm run build` | TypeScript and Wrangler deployment dry run passed | +| Android `:app:testDebugUnitTest` | 70 passed across 17 suites | +| Android `:app:compileDebugAndroidTestKotlin` | Passed, including new storage regression tests | +| `git diff --check` | Passed | + +Desktop test logs are in `artifacts/performance-frontend-tests.log` and `artifacts/performance-rust-tests.log`. Android unit results are in `android/app/build/reports/tests/testDebugUnitTest/index.html`. + +Device instrumentation was not executed because the existing Android emulator is offline. The Rust ignored cases include isolated child-process fixtures and opt-in environment/internet integrations; passing results above do not count them. The initial baseline had a WebSocket-stop failure that passed both targeted reruns and the final full suite without increasing its timeout. Native backend checks were run on Windows; macOS and Linux need their normal CI builds. No exhaustive live-app interaction or end-to-end phone/relay session was recorded, and no whole-process working-set or CPU percentage is claimed. + +Concurrent pricing, Codex adapter, usage-index migration, audit-document, and CLIProxy integration edits from other work were preserved. They are not attributed to this performance task, although consolidated checks ran against the shared working tree. + +## Remaining opportunities + +Codex usage parsing deliberately replays changed rollouts to recover billing/model/cumulative context. Incremental continuation would require a persisted state migration with fork/archive deduplication coverage. The agent recovery checkpoint still serializes full snapshots at its existing cadence. Both paths are correctness-sensitive and were kept intact. + +The desktop bundle still emits Vite's existing large-chunk warning. Changing code loading and introducing virtualization could reduce startup or very-long-transcript costs, but would need separate interactive validation for lazy-load boundaries, selection, scrolling, and recovery. No feature, output, or history was removed to meet a resource target. diff --git a/docs/usage-cost-audit.md b/docs/usage-cost-audit.md index a387f3ca..01f8d5bf 100644 --- a/docs/usage-cost-audit.md +++ b/docs/usage-cost-audit.md @@ -48,3 +48,41 @@ Validated with `cargo test --manifest-path src-tauri/Cargo.toml usage:: -- --qui Rates are built-in list-price estimates, with user overrides; they are not an invoice reconciliation or a historical tariff database. Separate tool fees, taxes, regional premiums, negotiated rates, and subscription credits are not reconstructed. Unknown models remain flagged as unpriced. Missing service tiers use standard rates instead of applying today's configuration to old usage. Incomplete logs cannot recover missing calls. Fork histories with rewritten event identities are not covered by the exact-event duplicate guarantee. Official references: [OpenAI pricing](https://developers.openai.com/api/docs/pricing), [GPT-5.1-Codex Mini](https://developers.openai.com/api/docs/models/gpt-5.1-codex-mini), [Anthropic pricing](https://platform.claude.com/docs/en/about-claude/pricing), and [Gemini pricing](https://ai.google.dev/gemini-api/docs/pricing). + + +## CLIProxy Codex correction, October 9, 2026 + +A read-only audit of 29,535 Codex usage events in the October session directory +found 2,547 repeated cumulative snapshots. The existing history parser skips +those snapshots, and the remaining cumulative changes agreed with the recorded +per-request deltas. The local index included 27,973 `gpt-6.1-sol` requests, but +that model had no built-in rate, so its estimated API token cost was zero. +These counts describe the files inspected at audit time, not a provider invoice. + +Corrections: + +- Register `gpt-6.1-sol` separately from `gpt-6-sol`: $2 input, $0.10 cached + input, $2.50 cache writes and $10 output per million tokens at standard + short-context rates. Apply the published long-context and service-tier + conditions per request. Provider prefixes and dated model IDs work with the + same entry, and user rate overrides retain precedence. +- Read inclusive `inputTokens` directly in the conversation adapter. Adding + `cachedInputTokens` again overstated conversation totals. Output already + includes reasoning, so it also remains a single inclusive count. +- Calculate context occupancy from `last.totalTokens`, rather than cumulative + lifetime tokens. Leave it unknown when the latest request count is absent. +- Upgrade the index to version 5. Existing indexes rebuild on the next scan + so GPT-6.1 requests recover their per-request long-context conditions. + +The installed Codex app-server and CLIProxy were exercised against an isolated +HTTP/SSE upstream with dummy keys. A response with 1,000 input tokens (800 +cached) and 100 output tokens (40 reasoning) stayed intact in live notifications +and persisted rollouts through resume, steering, interruption and continuation. +Its normalized total is 1,100 tokens and standard API estimate is $0.00148. +Regression fixtures cover repeated snapshots, append scans, archive copies, +warm index reuse, reload, index upgrade, Fast and long-context pricing, and +agreement between daily, model and overall costs. + +Rates were checked against [official OpenAI API pricing](https://developers.openai.com/api/docs/pricing). +CLIProxy subscription-backed requests still show estimated API token value; +the proxy does not turn that estimate into an actual per-request cash charge. diff --git a/relay/src/index.ts b/relay/src/index.ts index af6c43d4..0712a388 100644 --- a/relay/src/index.ts +++ b/relay/src/index.ts @@ -398,22 +398,24 @@ export async function handleRequest(request: Request, env: Env, push: PushSender if (request.method === "GET" && parts.length === 4 && parts[3] === "messages") { const found = await requireReceive(env, pairId, request); if (!found) return fail(401, "invalid receiver credential"); + const inline = new URL(request.url).searchParams.get("inline") === "1"; + // Inline recovery returns eight payloads. Read just one extra row to + // determine hasMore instead of materializing up to 100 large ciphertexts. + // Id-only recovery never needs to read the encrypted payload columns. const pending = await env.DB.prepare(` - SELECT message_id, sent_at, payload_nonce, payload_ciphertext, collapse_key + SELECT message_id, sent_at${inline ? ", payload_nonce, payload_ciphertext" : ""} FROM messages WHERE pair_id = ? AND expires_at > ? ORDER BY CASE WHEN collapse_key LIKE 'workspace:%' THEN 0 ELSE 1 END, created_at DESC - LIMIT 100 - `).bind(pairId, Date.now()).all<{ + LIMIT ? + `).bind(pairId, Date.now(), inline ? 9 : 100).all<{ message_id: string; sent_at: number; payload_nonce: string; payload_ciphertext: string; - collapse_key: string | null; }>(); // Foreground clients download the newest workspace in the same request. // Bound the batch so an offline week's completions cannot monopolize sync. - const inline = new URL(request.url).searchParams.get("inline") === "1"; const messages = inline ? pending.results.slice(0, 8) : pending.results; return response({ messages: messages.map((message) => ({ diff --git a/relay/test/relay.vitest.ts b/relay/test/relay.vitest.ts index 04963c1b..e68b627b 100644 --- a/relay/test/relay.vitest.ts +++ b/relay/test/relay.vitest.ts @@ -233,6 +233,54 @@ describe("encrypted notification relay", () => { expect((await handleRequest(request(`/v1/pairings/${pairId}/messages?inline=1`, "GET", sendToken), env, accept)).status).toBe(401); }); + it("bounds inline recovery, preserves workspace priority, and omits ciphertext from id-only recovery", async () => { + const pairId = "81000000-0000-4000-8000-000000000001"; + const receiveToken = "x".repeat(43); + const now = Date.now(); + await env.DB.prepare(` + INSERT INTO pairings (pair_id, registration_token_hash, send_token_hash, created_at, receive_token_hash) + VALUES (?, ?, ?, ?, ?) + `).bind(pairId, "unused", "unused", now, await hash(receiveToken)).run(); + const payload = { nonce: "Y".repeat(16), ciphertext: "Z".repeat(10_000) }; + const workspaceId = "82000000-0000-4000-8000-000000000000"; + const id = (index: number) => `82000000-0000-4000-8000-${String(index).padStart(12, "0")}`; + await env.DB.batch(Array.from({ length: 105 }, (_, index) => env.DB.prepare(` + INSERT INTO messages (pair_id, message_id, payload_nonce, payload_ciphertext, sent_at, created_at, expires_at, collapse_key) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `).bind(pairId, id(index), payload.nonce, payload.ciphertext, now + index, now + index, now + 60_000, + index === 0 ? `workspace:${pairId}` : null))); + + const queries: string[] = []; + const measuredEnv: Env = { ...env, DB: { + prepare(query: string) { queries.push(query); return env.DB.prepare(query); }, + } as D1Database }; + const recover = async () => { + const result = await handleRequest(request(`/v1/pairings/${pairId}/messages?inline=1`, "GET", receiveToken), measuredEnv); + return result.json() as Promise<{ messages: Array<{ messageId: string; payload: typeof payload }>; hasMore: boolean }>; + }; + const inline = await recover(); + expect(inline.messages.map(message => message.messageId)).toEqual([workspaceId, ...[104, 103, 102, 101, 100, 99, 98].map(id)]); + expect(inline.messages.every(message => message.payload.ciphertext === payload.ciphertext)).toBe(true); + expect(inline.hasMore).toBe(true); + + queries.length = 0; + const opaque = await handleRequest(request(`/v1/pairings/${pairId}/messages`, "GET", receiveToken), measuredEnv); + const opaqueBody = await opaque.json() as { messages: Array<{ messageId: string; sentAt: number }> }; + expect(opaqueBody.messages).toHaveLength(100); + expect(opaqueBody.messages[0]).toEqual({ messageId: workspaceId, sentAt: now }); + expect(JSON.stringify(opaqueBody)).not.toContain("payload"); + // The id-only route must avoid bringing encrypted transcripts into Worker memory. + expect(queries.find(query => query.includes("FROM messages"))).not.toContain("payload_ciphertext"); + + await env.DB.prepare("DELETE FROM messages WHERE pair_id = ? AND message_id != ? AND created_at < ?") + .bind(pairId, workspaceId, now + 98).run(); + const exactBatch = await recover(); + expect(exactBatch.messages).toEqual(inline.messages); + expect(exactBatch.hasMore).toBe(false); + await env.DB.prepare("UPDATE messages SET expires_at = ? WHERE pair_id = ?").bind(now - 1, pairId).run(); + expect(await recover()).toEqual({ messages: [], hasMore: false }); + }); + it("relays encrypted phone commands back to the paired desktop", async () => { const pairId = "50000000-0000-4000-8000-000000000005"; const commandId = "60000000-0000-4000-8000-000000000006"; diff --git a/scripts/benchmark-agent-projections.ts b/scripts/benchmark-agent-projections.ts new file mode 100644 index 00000000..983ec814 --- /dev/null +++ b/scripts/benchmark-agent-projections.ts @@ -0,0 +1,157 @@ +/** + * Reproduce projection cost during a parent response stream. + * + * bun scripts/benchmark-agent-projections.ts + * bun scripts/benchmark-agent-projections.ts --baseline HEAD + * + * Measures these pure projections, not whole-app CPU, RAM, or frame rate. + * Baseline modules come from Git and use current unchanged dependencies. + */ +import { deepStrictEqual } from "node:assert"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { basename, dirname, join, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +import type { AgentItem } from "../src/lib/agents/types"; +import { subagentRosters, subagentsForTurn } from "../src/lib/agents/subagents"; +import { activityGroups } from "../src/components/agent/official/OfficialShared"; + +type Projections = { + subagentRosters: typeof subagentRosters; + subagentsForTurn: typeof subagentsForTurn; + activityGroups: typeof activityGroups; +}; + +const workspace = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const HISTORICAL_TURNS = 250; +const STREAM_UPDATES = 200; +const SAMPLES = 3; + +function workload(): AgentItem[] { + const items: AgentItem[] = []; + for (let index = 0; index < HISTORICAL_TURNS; index += 1) { + items.push({ kind: "user", id: `u-${index}`, at: index, text: "Inspect the code" }); + items.push({ + kind: "tool", tool: "task", name: "Agent", id: `t-${index}`, + callId: `c-${index}`, at: index, title: "Inspect", status: "done", + command: null, output: "Completed inspection with details\n".repeat(300), changes: [], + }); + items.push({ + kind: "assistant", id: `a-${index}`, at: index, + text: "Inspection complete", streaming: false, + }); + } + items.push({ kind: "user", id: "u-live", at: 1_000, text: "Continue" }); + items.push({ + kind: "tool", tool: "read", name: "Read", id: "t-live", callId: "c-live", + at: 1_000, title: "Read", status: "done", command: null, output: "", changes: [], + }); + items.push({ + kind: "assistant", id: "a-live", at: 1_000, text: "Streaming response", streaming: true, + }); + return items; +} + +function sample(projections: Projections) { + const history = workload(); + let previousGroups: ReturnType | undefined; + let previousRosters: ReturnType | undefined; + let previousFleet: ReturnType | undefined; + let stableHistoricalGroups = 0; + let stableRosters = 0; + let stableFleet = 0; + const started = performance.now(); + for (let index = 0; index < STREAM_UPDATES; index += 1) { + const items = history.slice(); + const live = items[items.length - 1]; + if (live.kind !== "assistant") throw new Error("Expected live assistant workload"); + items[items.length - 1] = { ...live, text: `Streaming response ${index}` }; + const groups = projections.activityGroups(items); + const rosters = projections.subagentRosters(items); + const fleet = projections.subagentsForTurn(items); + if (previousGroups) { + for (let turn = 0; turn < HISTORICAL_TURNS; turn += 1) { + stableHistoricalGroups += Number(groups[turn] === previousGroups[turn]); + } + } + if (previousRosters) stableRosters += Number(rosters === previousRosters); + if (previousFleet) stableFleet += Number(fleet === previousFleet); + previousGroups = groups; + previousRosters = rosters; + previousFleet = fleet; + } + return { elapsedMs: performance.now() - started, stableHistoricalGroups, stableRosters, stableFleet }; +} + +function measure(projections: Projections) { + const samples = Array.from({ length: SAMPLES }, () => sample(projections)); + const times = samples.map((result) => result.elapsedMs).sort((a, b) => a - b); + return { + medianMs: times[Math.floor(times.length / 2)], + sampleTimesMs: samples.map((result) => result.elapsedMs), + reuse: { + historicalGroups: samples[0].stableHistoricalGroups, + possibleHistoricalGroups: HISTORICAL_TURNS * (STREAM_UPDATES - 1), + rosterArrays: samples[0].stableRosters, + fleetArrays: samples[0].stableFleet, + possibleArrayReuses: STREAM_UPDATES - 1, + }, + }; +} + +async function baselineModule(relativePath: string, reference: string, directory: string) { + const result = Bun.spawnSync(["git", "show", `${reference}:${relativePath}`], { + cwd: workspace, stdout: "pipe", stderr: "pipe", + }); + if (result.exitCode !== 0) throw new Error(result.stderr.toString()); + const originalDirectory = dirname(resolve(workspace, relativePath)); + const transpiler = new Bun.Transpiler({ loader: relativePath.endsWith(".tsx") ? "tsx" : "ts" }); + const source = transpiler.transformSync(result.stdout.toString()).replace( + /(\bfrom\s+|\bimport\s*)(["'])([^"']+)\2/g, + (_match, prefix: string, _quote: string, specifier: string) => { + const target = specifier.startsWith(".") + ? pathToFileURL(resolve(originalDirectory, specifier)).href + : import.meta.resolve(specifier); + return `${prefix}${JSON.stringify(target)}`; + }, + ); + const path = join(directory, basename(relativePath).replace(/\.tsx?$/, ".mjs")); + await writeFile(path, source, "utf8"); + return import(pathToFileURL(path).href); +} + +const baselineIndex = Bun.argv.indexOf("--baseline"); +const reference = baselineIndex >= 0 ? Bun.argv[baselineIndex + 1] : undefined; +if (baselineIndex >= 0 && !reference) throw new Error("--baseline requires a Git reference"); +let baseline: ReturnType | undefined; +if (reference) { + const temporaryRoot = resolve(tmpdir()); + const directory = await mkdtemp(join(temporaryRoot, "duckweed-agent-benchmark-")); + try { + const [workers, timeline] = await Promise.all([ + baselineModule("src/lib/agents/subagents.ts", reference, directory), + baselineModule("src/components/agent/official/OfficialShared.tsx", reference, directory), + ]); + const comparisonItems = workload(); + deepStrictEqual(workers.subagentRosters(comparisonItems), subagentRosters(comparisonItems)); + deepStrictEqual(workers.subagentsForTurn(comparisonItems), subagentsForTurn(comparisonItems)); + deepStrictEqual(timeline.activityGroups(comparisonItems), activityGroups(comparisonItems)); + baseline = measure({ ...workers, activityGroups: timeline.activityGroups }); + } finally { + if (dirname(directory) !== temporaryRoot || !basename(directory).startsWith("duckweed-agent-benchmark-")) { + throw new Error("Unexpected benchmark temporary directory"); + } + await rm(directory, { recursive: true, force: true }); + } +} +const current = measure({ subagentRosters, subagentsForTurn, activityGroups }); +console.log(JSON.stringify({ + scope: "Pure agent transcript/subagent projections during parent-only streaming", + workload: { historicalDelegatedTurns: HISTORICAL_TURNS, streamUpdates: STREAM_UPDATES, outputLinesPerTask: 300, samples: SAMPLES }, + baselineReference: reference ?? null, + baselineOutputParity: reference ? "passed" : "not requested", + baseline, + current, + timeReductionPercent: baseline ? (1 - current.medianMs / baseline.medianMs) * 100 : undefined, +}, null, 2)); diff --git a/scripts/benchmark-mobile-utf8.ts b/scripts/benchmark-mobile-utf8.ts new file mode 100644 index 00000000..59483b24 --- /dev/null +++ b/scripts/benchmark-mobile-utf8.ts @@ -0,0 +1,47 @@ +import { execFileSync } from "node:child_process"; +import { strict as assert } from "node:assert"; +import { truncateUtf8, truncateUtf8Tail, utf8ByteLength } from "../src/lib/mobileWorkspace"; + +// The audited revision is fixed so reruns still compare the same algorithms. +const revision = process.argv[2] ?? "5070dcbacebea673fae1f607afd9c0255df02981"; +const original = execFileSync("git", ["show", `${revision}:src/lib/mobileWorkspace.ts`], { encoding: "utf8" }); +const fragment = original.slice(original.indexOf("export function truncateUtf8("), original.indexOf("export interface MobileTerminalActivity")); +const transpiled = new Bun.Transpiler({ loader: "ts" }).transformSync(fragment.replaceAll("export ", "")); +const before = new Function("utf8ByteLength", `${transpiled}; return { truncateUtf8, truncateUtf8Tail };`)(utf8ByteLength) as { + truncateUtf8: typeof truncateUtf8; + truncateUtf8Tail: typeof truncateUtf8Tail; +}; + +let seed = 123456789; +const random = () => (seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0); +const alphabet = ["A", "\n", "\0", "\u00e9", "\u6f22", "e\u0301", "\ud83e\udd86", "\ud800", "\udc00"]; +let comparisons = 0; +for (let sample = 0; sample < 300; sample += 1) { + const value = Array.from({ length: random() % 70 }, () => alphabet[random() % alphabet.length]).join(""); + for (let budget = 0; budget <= utf8ByteLength(value) + 5; budget += 1) { + assert.equal(truncateUtf8(value, budget), before.truncateUtf8(value, budget), `prefix sample ${sample}, budget ${budget}`); + assert.equal(truncateUtf8Tail(value, budget), before.truncateUtf8Tail(value, budget), `tail sample ${sample}, budget ${budget}`); + comparisons += 2; + } +} + +const value = "\u6f22\ud83e\udd86".repeat(20_000); +const budget = 16_000; +const iterations = 15; +const median = (samples: number[]) => [...samples].sort((a, b) => a - b)[Math.floor(samples.length / 2)]; +const time = (fn: typeof truncateUtf8) => { + const start = performance.now(); + const result = fn(value, budget); + assert(utf8ByteLength(result) <= budget); + return performance.now() - start; +}; +const measure = (old: typeof truncateUtf8, current: typeof truncateUtf8) => { + time(old); time(current); + const prior: number[] = [], next: number[] = []; + for (let index = 0; index < iterations; index += 1) { + if (index % 2) { next.push(time(current)); prior.push(time(old)); } + else { prior.push(time(old)); next.push(time(current)); } + } + return { beforeMedianMs: median(prior), afterMedianMs: median(next), speedup: median(prior) / median(next) }; +}; +console.log(JSON.stringify({ revision, comparisons, workload: { utf16Units: value.length, utf8Bytes: utf8ByteLength(value), budget, pairedIterations: iterations }, prefix: measure(before.truncateUtf8, truncateUtf8), tail: measure(before.truncateUtf8Tail, truncateUtf8Tail) }, null, 2)); diff --git a/scripts/mobile-pairing-continuity.test.mjs b/scripts/mobile-pairing-continuity.test.mjs index f623a47b..3180e253 100644 --- a/scripts/mobile-pairing-continuity.test.mjs +++ b/scripts/mobile-pairing-continuity.test.mjs @@ -165,7 +165,7 @@ describe("mobile pairing continuity", () => { expect(desktop).toContain("mobileScheduleCompletion("); expect(desktop).not.toContain("mobileCompletionTimers"); expect(ipc).toContain('invoke("mobile_schedule_completion"'); - expect(native).toContain("std::thread::sleep(Duration::from_millis(delay_ms))"); + expect(native).toContain("tokio::time::sleep(Duration::from_millis(delay_ms)).await"); expect(native).toContain("cancel_terminal(&terminal_id)"); expect(native).toContain("cancel_selected()"); expect(main).toContain("mobile_push::mobile_schedule_completion"); diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 9400b071..ea5af9de 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1217,6 +1217,7 @@ dependencies = [ "tauri-plugin-single-instance", "tauri-plugin-updater", "tauri-plugin-window-state", + "tokio", "tungstenite", "uuid 1.24.0", "webview2-com", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index aa13754a..9478e266 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -10,6 +10,8 @@ tauri-build = { version = "2", features = [] } [dependencies] tauri = { version = "2", features = [] } +# Delayed mobile notifications share Tauri's runtime instead of sleeping OS threads. +tokio = { version = "1", features = ["time"] } tauri-plugin-clipboard-manager = "2" tauri-plugin-dialog = "2" tauri-plugin-process = "2" @@ -58,6 +60,8 @@ rusqlite = { version = "0.32", features = ["bundled"] } [target.'cfg(windows)'.dependencies] windows-sys = { version = "0.59", features = [ "Win32_Foundation", + "Win32_NetworkManagement_IpHelper", + "Win32_Networking_WinSock", "Win32_Storage_FileSystem", "Win32_System_Diagnostics_ToolHelp", "Win32_System_Power", diff --git a/src-tauri/src/agent_activity.rs b/src-tauri/src/agent_activity.rs index 1cf13d61..e6c78e72 100644 --- a/src-tauri/src/agent_activity.rs +++ b/src-tauri/src/agent_activity.rs @@ -15,7 +15,7 @@ use std::collections::{HashMap, HashSet}; use std::fs::{self, File, OpenOptions}; -use std::io::{BufRead, BufReader, Read, Seek, SeekFrom}; +use std::io::{BufRead, BufReader, Seek, SeekFrom}; use std::path::{Path, PathBuf}; use std::sync::Mutex; use std::time::{Duration, Instant, SystemTime}; @@ -277,13 +277,7 @@ impl AgentActivityManager { .collect(); for (id, agent) in ready { pending.remove(&id); - let _ = app.emit( - "agent:complete", - CompletionPayload { - id, - agent, - }, - ); + let _ = app.emit("agent:complete", CompletionPayload { id, agent }); } // Drop hooks for panes that were unwatched or rebound mid-quiet. pending.retain(|id, hook| { @@ -679,20 +673,37 @@ fn read_appended_lines(path: &Path, offset: &mut u64) -> Vec> { if len == *offset || file.seek(SeekFrom::Start(*offset)).is_err() { return Vec::new(); } - let mut bytes = Vec::new(); - if file.read_to_end(&mut bytes).is_err() { - return Vec::new(); + let mut lines = Vec::new(); + for_each_complete_line(&mut BufReader::new(file), offset, |line| { + lines.push(line.to_vec()); + }); + lines +} + +/// Consume one complete appended line at a time. Holding an entire transcript +/// delta duplicates all of its text before classification, even though only +/// the final working/completed signal is retained. A partial final line stays +/// at the current offset so a later poll reads it once its newline arrives. +fn for_each_complete_line( + reader: &mut R, + offset: &mut u64, + mut on_line: impl FnMut(&[u8]), +) { + let mut line = Vec::new(); + loop { + line.clear(); + let Ok(read) = reader.read_until(b'\n', &mut line) else { + break; + }; + if read == 0 || line.last() != Some(&b'\n') { + break; + } + *offset += read as u64; + line.pop(); + if !line.is_empty() { + on_line(&line); + } } - let Some(last_newline) = bytes.iter().rposition(|byte| *byte == b'\n') else { - return Vec::new(); - }; - let complete = &bytes[..=last_newline]; - *offset += complete.len() as u64; - complete - .split(|byte| *byte == b'\n') - .filter(|line| !line.is_empty()) - .map(<[u8]>::to_vec) - .collect() } fn parse_hook_event(line: &[u8]) -> Option<(&str, &str)> { @@ -1069,26 +1080,13 @@ fn ingest_session_lines(watch: &mut Watch) -> LineSignal { return LineSignal::None; } - let mut bytes = Vec::new(); - if file.read_to_end(&mut bytes).is_err() { - return LineSignal::None; - } - let Some(last_newline) = bytes.iter().rposition(|byte| *byte == b'\n') else { - return LineSignal::None; - }; - let complete = &bytes[..=last_newline]; - watch.offset += complete.len() as u64; - let mut last = LineSignal::None; - for line in complete - .split(|byte| *byte == b'\n') - .filter(|line| !line.is_empty()) - { + for_each_complete_line(&mut BufReader::new(file), &mut watch.offset, |line| { let next = classify_session_line(&watch.agent, line); if next != LineSignal::None { last = next; } - } + }); last } @@ -1129,9 +1127,7 @@ fn classify_claude(value: &Value) -> LineSignal { match value.get("type").and_then(Value::as_str) { // Claude Code writes this once the full turn is done (including tool // loops). Prefer it over intermediate assistant stop_reason records. - Some("system") - if value.get("subtype").and_then(Value::as_str) == Some("turn_duration") => - { + Some("system") if value.get("subtype").and_then(Value::as_str) == Some("turn_duration") => { LineSignal::Completed } Some("assistant") => { @@ -1217,7 +1213,10 @@ fn classify_grok(value: &Value) -> LineSignal { Some("turn_completed") => { // Background Task tool completions are tagged as turn_completed // with a synthetic prompt id while the parent turn continues. - let prompt_id = update.get("prompt_id").and_then(Value::as_str).unwrap_or(""); + let prompt_id = update + .get("prompt_id") + .and_then(Value::as_str) + .unwrap_or(""); // Synthetic ids look like `task-completed-call--N`. if prompt_id.starts_with("task-completed-call-") { LineSignal::None @@ -1240,11 +1239,12 @@ fn is_completion_line(agent: &str, line: &[u8]) -> bool { #[cfg(test)] mod tests { use super::{ - apply_line_signal, claude_project_slug, classify_session_line, discover_session_in_home, - discovery_roots, gemini_defaults, ingest_session_lines, initial_offset, - install_antigravity_plugin, install_copilot_hook, install_opencode_plugin, + apply_line_signal, classify_session_line, claude_project_slug, discover_session_in_home, + discovery_roots, for_each_complete_line, gemini_defaults, ingest_session_lines, + initial_offset, install_antigravity_plugin, install_copilot_hook, install_opencode_plugin, is_completion_line, nearest_new_session, parse_hook_event, qwen_defaults, - read_appended_lines, write_hook_script, LineSignal, SessionCandidate, Watch, START_TOLERANCE, + read_appended_lines, write_hook_script, LineSignal, SessionCandidate, Watch, + START_TOLERANCE, }; use chrono::{DateTime, Utc}; use rusqlite::{params, Connection}; @@ -1448,8 +1448,8 @@ mod tests { drop(connection); let watch = bare_watch("codex", None, 0); - let found = discover_session_in_home(&watch, &Default::default(), &home) - .map(|(path, _)| path); + let found = + discover_session_in_home(&watch, &Default::default(), &home).map(|(path, _)| path); assert_eq!(found.as_deref(), Some(rollout.as_path())); fs::remove_dir_all(home).unwrap(); @@ -1506,7 +1506,11 @@ mod tests { strong: true, pending_complete: None, }; - assert_eq!(ingest_session_lines(&mut watch), LineSignal::Completed, "{agent}"); + assert_eq!( + ingest_session_lines(&mut watch), + LineSignal::Completed, + "{agent}" + ); } fs::remove_dir_all(dir).unwrap(); @@ -1647,6 +1651,36 @@ mod tests { .is_some_and(|command| command.ends_with(" qwen"))); } + #[test] + fn appended_line_stream_preserves_bytes_and_partial_offsets() { + use std::io::{BufReader, Cursor}; + + let input = b"first\r\n\n\xf0\x9f\x8c\xb1\npartial"; + let mut reader = BufReader::with_capacity(3, Cursor::new(input)); + let mut offset = 17; + let mut lines = Vec::new(); + for_each_complete_line(&mut reader, &mut offset, |line| lines.push(line.to_vec())); + assert_eq!( + lines, + vec![b"first\r".to_vec(), b"\xf0\x9f\x8c\xb1".to_vec()] + ); + assert_eq!(offset, 17 + 7 + 1 + 5); + } + + #[test] + fn appended_line_stream_keeps_a_large_line_intact() { + use std::io::{BufReader, Cursor}; + + let mut input = vec![b'x'; 2 * 1024 * 1024]; + input.extend_from_slice(b"\nend\n"); + let mut reader = BufReader::with_capacity(1024, Cursor::new(&input)); + let mut offset = 0; + let mut lengths = Vec::new(); + for_each_complete_line(&mut reader, &mut offset, |line| lengths.push(line.len())); + assert_eq!(lengths, vec![2 * 1024 * 1024, 3]); + assert_eq!(offset, input.len() as u64); + } + #[test] fn hook_event_reader_waits_for_complete_lines() { let dir = diff --git a/src-tauri/src/agent_proc.rs b/src-tauri/src/agent_proc.rs index 40a71cbb..4313dabd 100644 --- a/src-tauri/src/agent_proc.rs +++ b/src-tauri/src/agent_proc.rs @@ -124,10 +124,14 @@ impl CodexOwnership { } } - fn received(&mut self, line: &str) { + /// Update ownership and report account changes from the same parse. Large + /// transcript responses should not be deserialized again just to inspect + /// their top-level method. + fn received(&mut self, line: &str) -> bool { let Ok(frame) = serde_json::from_str::(line) else { - return; + return false; }; + let account_updated = frame["method"] == "account/updated"; if let Some(id) = frame.get("id") { let key = id.to_string(); self.closing.remove(&key); @@ -157,7 +161,7 @@ impl CodexOwnership { } } } - return; + return account_updated; } let method = frame["method"].as_str().unwrap_or_default(); let params = &frame["params"]; @@ -195,6 +199,7 @@ impl CodexOwnership { } } } + account_updated } fn shutdown_requests(&self) -> Vec { @@ -754,13 +759,9 @@ pub fn start( tungstenite::Message::Text(line) => { { let mut process = process.lock().unwrap(); - process.ownership.received(&line); - if let Ok(frame) = serde_json::from_str::(&line) - { - if frame["method"] == "account/updated" { - if let Some(service) = &process.service { - service.acknowledge(); - } + if process.ownership.received(&line) { + if let Some(service) = &process.service { + service.acknowledge(); } } } @@ -928,6 +929,15 @@ fn trimmed_line(buffer: &[u8], limit: usize) -> String { mod tests { use super::*; + #[test] + fn account_changes_are_reported_without_affecting_ownership() { + let mut owner = CodexOwnership::default(); + assert!(owner.received(r#"{"method":"account/updated","params":{"authMode":"chatgpt"}}"#)); + assert!(!owner.received(r#"{"method":"account/rateLimits/updated","params":{}}"#)); + assert!(!owner.received("invalid json")); + assert!(owner.threads.is_empty()); + } + #[test] fn shared_shutdown_owns_only_subscribed_threads_and_their_children() { let mut owner = CodexOwnership::default(); diff --git a/src-tauri/src/agent_sessions.rs b/src-tauri/src/agent_sessions.rs index e63fae52..f58acb3e 100644 --- a/src-tauri/src/agent_sessions.rs +++ b/src-tauri/src/agent_sessions.rs @@ -490,7 +490,8 @@ fn claude_sessions(home: &Path, cwd: &Path) -> Vec { } } - files.sort_by_key(|path| std::cmp::Reverse(modified_millis(path))); + // Metadata lookups must happen once per path, not once per comparison. + files.sort_by_cached_key(|path| std::cmp::Reverse(modified_millis(path))); files.truncate(MAX_RESULTS); files diff --git a/src-tauri/src/codex_transport.rs b/src-tauri/src/codex_transport.rs index b5719fa5..ab854ea5 100644 --- a/src-tauri/src/codex_transport.rs +++ b/src-tauri/src/codex_transport.rs @@ -9,7 +9,7 @@ use std::path::{Path, PathBuf}; use std::process::{Child, ChildStdin, ChildStdout, Output, Stdio}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{mpsc, Arc, Mutex}; -use std::time::{Duration, Instant}; +use std::time::Duration; use tungstenite::protocol::{Role, WebSocketConfig}; use tungstenite::{Message, WebSocket}; @@ -440,23 +440,41 @@ fn command_output( .stdout(Stdio::piped()) .stderr(Stdio::piped()); hide_console(&mut command); + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + command.process_group(0); + } let mut child = command.spawn().map_err(|error| error.to_string())?; - let deadline = Instant::now() + timeout; - loop { - if child - .try_wait() - .map_err(|error| error.to_string())? - .is_some() - { - return child.wait_with_output().map_err(|error| error.to_string()); - } - if Instant::now() >= deadline { - kill_tree(child.id()); + let pid = child.id(); + let (done, wait) = mpsc::channel(); + let watchdog = match std::thread::Builder::new() + .name("codex-command-timeout".into()) + .spawn(move || { + if matches!(wait.recv_timeout(timeout), Err(mpsc::RecvTimeoutError::Timeout)) { + kill_tree(pid); + true + } else { + false + } + }) { + Ok(watchdog) => watchdog, + Err(error) => { + kill_tree(pid); + let _ = child.kill(); let _ = child.wait(); - return Err(format!("Codex {action} timed out.")); + return Err(error.to_string()); } - std::thread::sleep(Duration::from_millis(25)); + }; + // Drain both pipes while the process runs. Waiting for exit first can + // deadlock a command as soon as either OS pipe buffer fills. The watchdog + // supplies the deadline without repeatedly polling the process handle. + let output = child.wait_with_output(); + let _ = done.send(()); + if watchdog.join().unwrap_or(false) { + return Err(format!("Codex {action} timed out.")); } + output.map_err(|error| error.to_string()) } /// Old Codex builds keep their stdio server. Supported builds use the same @@ -588,6 +606,46 @@ pub fn oversized_reply(line: &str, limit: usize) -> Option { mod tests { use super::*; + /// An isolated child process for command capture and timeout tests. + #[test] + #[ignore] + fn command_output_helper() { + let Some(mode) = std::env::var_os("DUCKWEED_COMMAND_OUTPUT_HELPER") else { + return; + }; + if mode == "timeout" { + std::thread::sleep(Duration::from_secs(30)); + return; + } + std::io::stdout().write_all(&vec![0xff; 256 * 1024]).unwrap(); + std::io::stdout().flush().unwrap(); + std::io::stderr().write_all(&vec![0xfe; 256 * 1024]).unwrap(); + std::io::stderr().flush().unwrap(); + } + + fn output_helper_command(mode: &str) -> std::process::Command { + let mut command = std::process::Command::new(std::env::current_exe().unwrap()); + command.args(["--exact", "codex_transport::tests::command_output_helper", "--ignored", "--nocapture"]) + .env("DUCKWEED_COMMAND_OUTPUT_HELPER", mode); + command + } + + #[test] + fn command_capture_drains_both_pipes_before_the_child_exits() { + let output = command_output(output_helper_command("output"), Duration::from_secs(5), "fixture").unwrap(); + assert!(output.status.success()); + assert_eq!(output.stdout.iter().filter(|byte| **byte == 0xff).count(), 256 * 1024); + assert_eq!(output.stderr.iter().filter(|byte| **byte == 0xfe).count(), 256 * 1024); + } + + #[test] + fn command_capture_keeps_its_timeout_and_reaps_the_child() { + let started = std::time::Instant::now(); + let result = command_output(output_helper_command("timeout"), Duration::from_millis(100), "fixture"); + assert_eq!(result.unwrap_err(), "Codex fixture timed out."); + assert!(started.elapsed() < Duration::from_secs(3)); + } + #[test] fn account_rotation_and_native_renewal_have_different_revisions() { use serde_json::json; diff --git a/src-tauri/src/discord_presence.rs b/src-tauri/src/discord_presence.rs index 106c3621..88e4ba84 100644 --- a/src-tauri/src/discord_presence.rs +++ b/src-tauri/src/discord_presence.rs @@ -12,7 +12,6 @@ const LARGE_IMAGE: &str = "duckweed"; const DOWNLOAD_URL: &str = "https://github.com/MusicMaster4/Duckweed/releases/latest"; const UPDATE_INTERVAL: Duration = Duration::from_secs(5 * 60); const RECONNECT_INTERVAL: Duration = Duration::from_secs(15); -const STOP_POLL_INTERVAL: Duration = Duration::from_millis(250); /// Owns Duckweed's connection to the local Discord client. /// @@ -43,6 +42,7 @@ impl DiscordPresence { self.stop.store(true, Ordering::Release); if let Ok(mut worker) = self.worker.lock() { if let Some(worker) = worker.take() { + worker.thread().unpark(); let _ = worker.join(); } } @@ -54,6 +54,7 @@ impl Drop for DiscordPresence { self.stop.store(true, Ordering::Release); if let Ok(worker) = self.worker.get_mut() { if let Some(worker) = worker.take() { + worker.thread().unpark(); let _ = worker.join(); } } @@ -123,11 +124,15 @@ fn agent_count_label(count: usize) -> String { } fn wait_or_stop(stop: &AtomicBool, duration: Duration) { - let mut remaining = duration; - while !stop.load(Ordering::Acquire) && !remaining.is_zero() { - let sleep_for = remaining.min(STOP_POLL_INTERVAL); - thread::sleep(sleep_for); - remaining = remaining.saturating_sub(sleep_for); + let deadline = std::time::Instant::now() + duration; + while !stop.load(Ordering::Acquire) { + let remaining = deadline.saturating_duration_since(std::time::Instant::now()); + if remaining.is_zero() { + break; + } + // A stop request unparks this thread immediately. Spurious wakes still + // wait until the original refresh deadline instead of sending early. + thread::park_timeout(remaining); } } @@ -159,11 +164,29 @@ mod tests { assert_eq!(UPDATE_INTERVAL, Duration::from_secs(300)); } + #[test] + fn stop_interrupts_a_parked_presence_wait() { + let stop = Arc::new(AtomicBool::new(false)); + let worker_stop = Arc::clone(&stop); + let (ready, waiting) = std::sync::mpsc::channel(); + let (done, finished) = std::sync::mpsc::channel(); + let worker = thread::spawn(move || { + ready.send(()).unwrap(); + wait_or_stop(&worker_stop, UPDATE_INTERVAL); + done.send(()).unwrap(); + }); + waiting.recv_timeout(Duration::from_secs(1)).unwrap(); + stop.store(true, Ordering::Release); + worker.thread().unpark(); + finished.recv_timeout(Duration::from_secs(1)).unwrap(); + worker.join().unwrap(); + } + #[test] fn stopped_wait_returns_without_sleeping_for_the_update_interval() { let stop = AtomicBool::new(true); let before = std::time::Instant::now(); wait_or_stop(&stop, UPDATE_INTERVAL); - assert!(before.elapsed() < STOP_POLL_INTERVAL); + assert!(before.elapsed() < Duration::from_millis(250)); } } diff --git a/src-tauri/src/fs.rs b/src-tauri/src/fs.rs index f1047b05..9e9c738f 100644 --- a/src-tauri/src/fs.rs +++ b/src-tauri/src/fs.rs @@ -6,7 +6,7 @@ //! explorer can show a file in a popup without shelling out. use std::collections::{HashMap, HashSet, VecDeque}; -use std::io::Write; +use std::io::{BufWriter, Write}; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use std::sync::atomic::{AtomicU64, Ordering}; @@ -268,11 +268,26 @@ pub fn search_projects( .replace('\\', "/"); let path = entry.path().to_string_lossy().to_string(); for (line_index, line_with_newline) in text.split_inclusive('\n').enumerate() { + if !current.is_current(generation) || truncated.load(Ordering::Relaxed) != 0 { + return WalkState::Quit; + } let line_text = line_with_newline.trim_end_matches(['\r', '\n']); + let mut previous_end = 0; + let mut previous_column = 0; for found in matcher.find_iter(line_text) { - let column = line_text[..found.start()].encode_utf16().count(); + if !current.is_current(generation) { + return WalkState::Quit; + } + // Matches do not overlap. Count each preceding character + // once even when one long line contains thousands of hits. + let column = previous_column + + line_text[previous_end..found.start()] + .encode_utf16() + .count(); let (preview, preview_column, match_length) = bounded_search_preview(line_text, found.start(), found.end()); + previous_end = found.end(); + previous_column = column + match_length; let mut guard = matches .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); @@ -299,17 +314,18 @@ pub fn search_projects( } let cancelled = !current.is_current(generation); - let mut found = matches - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .clone(); - found.sort_by(|a, b| { - a.project_name - .to_lowercase() - .cmp(&b.project_name.to_lowercase()) - .then_with(|| a.relative.to_lowercase().cmp(&b.relative.to_lowercase())) - .then_with(|| a.line.cmp(&b.line)) - .then_with(|| a.column.cmp(&b.column)) + let mut found = std::mem::take( + &mut *matches + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()), + ); + found.sort_by_cached_key(|found| { + ( + found.project_name.to_lowercase(), + found.relative.to_lowercase(), + found.line, + found.column, + ) }); Ok(SearchResponse { matches: found, @@ -389,7 +405,7 @@ pub fn workspace_paths(path: &str) -> Result, String> { } } - files.sort_by(|a, b| a.relative.to_lowercase().cmp(&b.relative.to_lowercase())); + files.sort_by_cached_key(|file| file.relative.to_lowercase()); Ok(files) } @@ -420,14 +436,23 @@ fn ignored_names(dir: &Path, names: &[String]) -> HashSet { }; // Feeding the names through stdin rather than argv: a folder can hold tens // of thousands of entries, well past the command-line length limit. - if let Some(mut stdin) = child.stdin.take() { - for name in names { - if writeln!(stdin, "{name}").is_err() { - break; - } + let stdin = child.stdin.take(); + // Read stdout while stdin is fed: a large ignored directory can fill both + // pipes and deadlock if the entire input is written before output is read. + let output = std::thread::scope(|scope| { + if let Some(stdin) = stdin { + scope.spawn(move || { + let mut stdin = BufWriter::new(stdin); + for name in names { + if writeln!(stdin, "{name}").is_err() { + break; + } + } + }); } - } - let Ok(out) = child.wait_with_output() else { + child.wait_with_output() + }); + let Ok(out) = output else { return HashSet::new(); }; // Exit code 1 just means nothing matched; stdout is empty and that is fine. @@ -466,11 +491,7 @@ pub fn list_dir(path: &str) -> Result, String> { }) .collect(); - entries.sort_by(|a, b| { - b.is_dir - .cmp(&a.is_dir) - .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())) - }); + entries.sort_by_cached_key(|entry| (!entry.is_dir, entry.name.to_lowercase())); Ok(entries) } @@ -682,6 +703,32 @@ mod tests { std::fs::remove_dir_all(outside).unwrap(); } + #[test] + fn ignored_names_handles_input_and_output_larger_than_pipe_buffers() { + let root = temporary_root("large-ignored-directory"); + std::fs::create_dir_all(&root).unwrap(); + let mut git = Command::new("git"); + git.arg("-C").arg(&root).args(["init", "--quiet"]); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + git.creation_flags(0x0800_0000); + } + assert!(git.output().unwrap().status.success()); + std::fs::write(root.join(".gitignore"), "*.tmp\n").unwrap(); + let names: Vec<_> = (0..8_000) + .map(|index| format!("{}-{index}.tmp", "ignored".repeat(10))) + .collect(); + let ignored = ignored_names(&root, &names); + assert_eq!(ignored.len(), names.len()); + assert!(names.iter().all(|name| ignored.contains(name))); + std::fs::remove_file(root.join(".gitignore")).unwrap(); + std::fs::remove_file(root.join(".git/config")).unwrap(); + // No Git objects are written by this test, so the temporary repository + // has no read-only files on Windows. + std::fs::remove_dir_all(root).unwrap(); + } + #[test] fn workspace_index_returns_relative_files_and_skips_dependencies() { let unique = SystemTime::now() @@ -746,6 +793,31 @@ mod tests { std::fs::remove_dir_all(root).unwrap(); } + #[test] + fn project_search_preserves_columns_for_multiple_matches_after_unicode() { + let root = temporary_root("unicode-search-columns"); + std::fs::create_dir_all(&root).unwrap(); + std::fs::write( + root.join("matches.txt"), + "\u{1f600}needle\u{e9} needle\u{1f600}needle\n", + ) + .unwrap(); + let response = search_projects( + vec![SearchProject { + path: root.to_string_lossy().to_string(), + name: "Unicode project".into(), + }], + "needle".into(), + 1, + Arc::new(SearchGeneration::default()), + ) + .unwrap(); + let columns: Vec<_> = response.matches.iter().map(|found| found.column).collect(); + assert_eq!(columns, [2, 10, 18]); + assert!(response.matches.iter().all(|found| found.match_length == 6)); + std::fs::remove_dir_all(root).unwrap(); + } + #[test] fn search_preview_bounds_context_while_preserving_the_match_offset() { let prefix = "x".repeat(MAX_SEARCH_PREVIEW_CONTEXT_CHARS + 20); diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs index ac951851..bc1babd7 100644 --- a/src-tauri/src/git.rs +++ b/src-tauri/src/git.rs @@ -486,16 +486,31 @@ fn count_lines_many(root: &Path, names: &[String]) -> Vec { .collect(); } + // A thread per path reserves one stack per file and can exhaust the OS + // on large diffs. Keep disk reads parallel with a fixed-size worker set. + let workers = thread::available_parallelism() + .map(|available| available.get().min(8)) + .unwrap_or(2) + .min(names.len()); + let chunk_size = names.len().div_ceil(workers); + let mut counts = vec![0; names.len()]; thread::scope(|scope| { let handles: Vec<_> = names - .iter() - .map(|name| scope.spawn(move || count_lines(&root.join(name)))) + .chunks(chunk_size) + .zip(counts.chunks_mut(chunk_size)) + .map(|(names, counts)| { + scope.spawn(move || { + for (name, count) in names.iter().zip(counts) { + *count = count_lines(&root.join(name)); + } + }) + }) .collect(); - handles - .into_iter() - .map(|handle| handle.join().unwrap_or(0)) - .collect() - }) + for handle in handles { + let _ = handle.join(); + } + }); + counts } /// Every uncommitted change under `root`, at `context` lines of context. @@ -507,7 +522,13 @@ fn collect(root: &Path, only: Option<&str>, context: u32) -> Result, context: u32) -> Result, context: u32) -> Result = names + .iter() + .map(|name| count_lines(&root.join(name))) + .collect(); + assert_eq!(count_lines_many(&root, &names), sequential); + assert_eq!(&sequential[96..], &[0, 2, 0, 0]); + assert!(count_lines_many(&root, &[]).is_empty()); + std::fs::remove_dir_all(root).unwrap(); + } + #[test] fn diff_reports_every_kind_of_change_in_a_real_repo() { let repo = TempRepo::new("kinds"); diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index ce9ceb46..68f62d06 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -408,7 +408,7 @@ async fn git_file_diff(path: String, file: String) -> Result { async fn pty_spawn( app: AppHandle, manager: State<'_, PtyManager>, - on_data: Channel>, + on_data: Channel, id: String, cwd: Option, shell: Option, diff --git a/src-tauri/src/mobile_push.rs b/src-tauri/src/mobile_push.rs index fceb81fc..e82d6f3b 100644 --- a/src-tauri/src/mobile_push.rs +++ b/src-tauri/src/mobile_push.rs @@ -649,6 +649,24 @@ fn read_state(path: &Path) -> StoredState { .unwrap_or_default() } +fn stored_pairing_status(path: &Path) -> Option { + match std::fs::read(path) { + Ok(bytes) => serde_json::from_slice::(&bytes) + .ok() + .map(|state| !state.devices.is_empty()), + // A new installation has no state file. During another process's save, + // its temporary file means the missing final file is only transitional. + Err(error) + if error.kind() == std::io::ErrorKind::NotFound + && !path.with_extension("json.tmp").exists() => + { + Some(false) + } + // Preserve the last known pairing through damaged JSON or transient IO. + Err(_) => None, + } +} + fn save_state(path: &Path, state: &StoredState) -> Result<(), String> { let parent = path.parent().ok_or("mobile settings path has no parent")?; std::fs::create_dir_all(parent).map_err(|error| error.to_string())?; @@ -1395,6 +1413,16 @@ struct ScheduledCompletion { terminal_id: String, selected: bool, cancelled: Arc, + timer: Option, +} + +impl ScheduledCompletion { + fn cancel(&self) { + self.cancelled.store(true, Ordering::Release); + if let Some(timer) = &self.timer { + timer.abort(); + } + } } #[derive(Default)] @@ -1411,26 +1439,44 @@ impl ScheduledCompletionRegistry { terminal_id, selected, cancelled: cancelled.clone(), + timer: None, }, ) { - previous.cancelled.store(true, Ordering::Release); + previous.cancel(); } cancelled } + fn attach_timer( + &mut self, + key: &str, + cancelled: &Arc, + timer: tokio::task::AbortHandle, + ) { + if let Some(completion) = self.pending.get_mut(key).filter(|completion| { + Arc::ptr_eq(&completion.cancelled, cancelled) + && !completion.cancelled.load(Ordering::Acquire) + }) { + completion.timer = Some(timer); + } else { + // Cancellation or replacement can win before the timer is attached. + timer.abort(); + } + } + fn cancel_terminal(&mut self, terminal_id: &str) { self.pending.retain(|_, completion| { if completion.terminal_id != terminal_id { return true; } - completion.cancelled.store(true, Ordering::Release); + completion.cancel(); false }); } fn cancel_key(&mut self, key: &str) { if let Some(completion) = self.pending.remove(key) { - completion.cancelled.store(true, Ordering::Release); + completion.cancel(); } } @@ -1439,7 +1485,7 @@ impl ScheduledCompletionRegistry { if !completion.selected { return true; } - completion.cancelled.store(true, Ordering::Release); + completion.cancel(); false }); } @@ -1477,11 +1523,28 @@ pub fn start_presence_monitor(app: AppHandle) -> std::io::Result<()> { let tick_app = app.clone(); std::thread::Builder::new() .name("mobile-sync-tick".into()) - .spawn(move || loop { - // Native events keep input and queued workspace updates moving - // while Windows throttles a minimized WebView's JavaScript timers. - let _ = tick_app.emit("mobile:sync-tick", ()); - std::thread::sleep(Duration::from_millis(1_200)); + .spawn(move || { + let mut paired = None; + loop { + // Re-read the stored list to notice pairing from another window + // or process on the next tick, without querying credentials. + let current = state_path(&tick_app).ok().and_then(|path| { + let _guard = FILE_LOCK.get_or_init(|| Mutex::new(())).lock().unwrap(); + stored_pairing_status(&path) + }); + if let Some(current) = current { + if paired != Some(current) { + paired = Some(current); + let _ = tick_app.emit("mobile:devices-changed", current); + } + } + // Native events keep input and queued workspace updates moving + // while Windows throttles a minimized WebView's JavaScript timers. + if paired == Some(true) { + let _ = tick_app.emit("mobile:sync-tick", ()); + } + std::thread::sleep(Duration::from_millis(1_200)); + } })?; std::thread::Builder::new() .name("mobile-presence".into()) @@ -1641,23 +1704,22 @@ fn schedule_completion( .clone() .filter(|id| !id.trim().is_empty()) .ok_or("scheduled mobile completion requires a terminal")?; - let cancelled = - scheduled_completions() + let mut registry = scheduled_completions().lock().unwrap(); + let cancelled = registry.insert(key.clone(), terminal_id.clone(), selected); + let timer_cancelled = cancelled.clone(); + let timer_key = key.clone(); + let timer = tauri::async_runtime::spawn(async move { + tokio::time::sleep(Duration::from_millis(delay_ms)).await; + let active = scheduled_completions() .lock() .unwrap() - .insert(key.clone(), terminal_id.clone(), selected); - let thread_key = key.clone(); - std::thread::Builder::new() - .name("mobile-completion-delay".into()) - .spawn(move || { - std::thread::sleep(Duration::from_millis(delay_ms)); - let active = scheduled_completions() - .lock() - .unwrap() - .take_if_active(&thread_key, &cancelled); - if !active { - return; - } + .take_if_active(&timer_key, &timer_cancelled); + if !active { + return; + } + // HTTP and credential-store access stay off the shared async threads. + // Blocking work starts only after the cancellable delay has elapsed. + let _ = tauri::async_runtime::spawn_blocking(move || { let result = match send_blocking(&app, message) { Ok(result) => result, Err(error) => SendResult { @@ -1669,7 +1731,7 @@ fn schedule_completion( let _ = app.emit( "mobile:completion-delivered", ScheduledCompletionDelivery { - key: thread_key, + key: timer_key, terminal_id, selected, completed_at, @@ -1677,11 +1739,10 @@ fn schedule_completion( }, ); }) - .map(|_| ()) - .map_err(|error| { - scheduled_completions().lock().unwrap().pending.remove(&key); - error.to_string() - }) + .await; + }); + registry.attach_timer(&key, &cancelled, timer.inner().abort_handle()); + Ok(()) } async fn blocking(job: F) -> Result @@ -1804,8 +1865,8 @@ pub async fn mobile_send_test(app: AppHandle) -> Result { mod tests { use super::{ bounded_preview_plaintext, client, decrypt, encrypt, encrypt_with_nonce, - encrypted_ciphertext_len, pairing_is_gone, pairing_proof, truncate_utf8, - workspace_collapse_key, PlainRemoteCommand, ScheduledCompletionRegistry, + encrypted_ciphertext_len, pairing_is_gone, pairing_proof, stored_pairing_status, + truncate_utf8, workspace_collapse_key, PlainRemoteCommand, ScheduledCompletionRegistry, WorkspaceUsageLimit, MAX_PREVIEW_CIPHERTEXT, MAX_WORKSPACE_PLAINTEXT_BYTES, }; @@ -1833,6 +1894,65 @@ mod tests { assert!(registry.take_if_active("untouched", &untouched)); } + #[test] + fn replaced_completion_cannot_take_or_cancel_the_new_generation() { + let mut registry = ScheduledCompletionRegistry::default(); + let previous = registry.insert("same".into(), "term-1".into(), false); + let current = registry.insert("same".into(), "term-1".into(), false); + assert!(previous.load(std::sync::atomic::Ordering::Acquire)); + assert!(!registry.take_if_active("same", &previous)); + assert!(registry.take_if_active("same", ¤t)); + } + + #[test] + fn cancelled_delay_releases_its_captured_payload_immediately() { + struct Payload(std::sync::mpsc::Sender<()>); + impl Drop for Payload { + fn drop(&mut self) { + let _ = self.0.send(()); + } + } + let (released, received) = std::sync::mpsc::channel(); + let payload = Payload(released); + let timer = tauri::async_runtime::spawn(async move { + let _payload = payload; + tokio::time::sleep(std::time::Duration::from_secs(300)).await; + }); + let mut registry = ScheduledCompletionRegistry::default(); + let cancelled = registry.insert("delay".into(), "term-1".into(), false); + registry.attach_timer("delay", &cancelled, timer.inner().abort_handle()); + registry.cancel_terminal("term-1"); + received + .recv_timeout(std::time::Duration::from_secs(1)) + .unwrap(); + assert!(!registry.take_if_active("delay", &cancelled)); + } + + #[test] + fn stored_pairing_changes_preserve_last_known_status_on_transient_errors() { + let directory = + std::env::temp_dir().join(format!("duckweed-mobile-pairing-{}", uuid::Uuid::new_v4())); + std::fs::create_dir(&directory).unwrap(); + let path = directory.join("mobile.json"); + assert_eq!(stored_pairing_status(&path), Some(false)); + std::fs::write(path.with_extension("json.tmp"), "{}").unwrap(); + assert_eq!(stored_pairing_status(&path), None); + std::fs::remove_file(path.with_extension("json.tmp")).unwrap(); + std::fs::write( + &path, + r#"{"devices":[{"id":"phone","name":"Phone","pairedAt":1}]}"#, + ) + .unwrap(); + assert_eq!(stored_pairing_status(&path), Some(true)); + std::fs::write(&path, "damaged").unwrap(); + assert_eq!(stored_pairing_status(&path), None); + assert_eq!(stored_pairing_status(&directory), None); + std::fs::write(&path, r#"{"devices":[]}"#).unwrap(); + assert_eq!(stored_pairing_status(&path), Some(false)); + std::fs::remove_file(path).unwrap(); + std::fs::remove_dir(directory).unwrap(); + } + #[test] fn workspace_usage_limits_keep_the_desktop_forecast() { let limit: WorkspaceUsageLimit = serde_json::from_str( diff --git a/src-tauri/src/ports.rs b/src-tauri/src/ports.rs index 962c1bfa..fae51527 100644 --- a/src-tauri/src/ports.rs +++ b/src-tauri/src/ports.rs @@ -3,7 +3,7 @@ //! Ownership is derived from process ancestry. A PID must descend from a live //! PTY shell or headless agent before it can be listed, stopped, or tunneled. -use std::collections::{HashMap, HashSet}; +use std::collections::{HashMap, HashSet, VecDeque}; use std::io::{self, BufRead, BufReader, Read, Write}; use std::net::{IpAddr, Shutdown, SocketAddr, TcpListener, TcpStream}; use std::path::{Path, PathBuf}; @@ -772,7 +772,9 @@ fn spawn_tunnel_supervisor( { let mut runtime = tunnel.runtime.lock().unwrap(); runtime.status = ForwardStatus::Reconnecting; - runtime.warning = Some("The provider changed the public address. Verifying the new link...".into()); + runtime.warning = Some( + "The provider changed the public address. Verifying the new link...".into(), + ); } if wait_for_reachable_public_url(&url, &tunnel.stop).is_ok() { if tunnel.stop.load(Ordering::Acquire) { @@ -834,7 +836,11 @@ fn spawn_tunnel_supervisor( }) } -fn start_public_tunnel(proxy_port: u16, tools_dir: &Path, stop: &AtomicBool) -> Result { +fn start_public_tunnel( + proxy_port: u16, + tools_dir: &Path, + stop: &AtomicBool, +) -> Result { if stop.load(Ordering::Acquire) { return Err("Public sharing was stopped".into()); } @@ -877,7 +883,11 @@ fn start_public_tunnel(proxy_port: u16, tools_dir: &Path, stop: &AtomicBool) -> } } -fn start_ngrok_tunnel(ngrok: PathBuf, proxy_port: u16, stop: &AtomicBool) -> Result { +fn start_ngrok_tunnel( + ngrok: PathBuf, + proxy_port: u16, + stop: &AtomicBool, +) -> Result { let origin = format!("http://127.0.0.1:{proxy_port}"); let mut command = Command::new(ngrok); command.args(["http", &origin, "--log", "stdout", "--log-format", "json"]); @@ -931,7 +941,10 @@ fn probe_public_url(client: &reqwest::blocking::Client, url: &str) -> Result<(), if body == "duckweed-ready" { Ok(()) } else { - Err("the provider returned a browser warning or another page instead of the shared server".into()) + Err( + "the provider returned a browser warning or another page instead of the shared server" + .into(), + ) } } @@ -1011,7 +1024,11 @@ fn lookup_public_dns(host: &str) -> PublicDns { } } -fn start_ssh_tunnel(proxy_port: u16, tools_dir: &Path, stop: &AtomicBool) -> Result { +fn start_ssh_tunnel( + proxy_port: u16, + tools_dir: &Path, + stop: &AtomicBool, +) -> Result { let ssh = executable_on_path("ssh").ok_or_else(|| "OpenSSH is not installed".to_string())?; std::fs::create_dir_all(tools_dir).map_err(err)?; let known_hosts = tools_dir.join("public-tunnel-known-hosts"); @@ -1047,7 +1064,11 @@ fn start_verified_tunnel(command: Command, stop: &AtomicBool) -> Result, stop: &AtomicBool) -> Result { +fn wait_for_tunnel_url( + child: &mut Child, + lines: &mpsc::Receiver, + stop: &AtomicBool, +) -> Result { let deadline = std::time::Instant::now() + Duration::from_secs(35); let mut public_url = None; let mut recent = Vec::new(); @@ -1290,22 +1315,25 @@ fn owner_map_from_roots( ) -> HashMap { let mut owners: HashMap = roots.into_iter().collect(); - // Process snapshots are not guaranteed to be parent-first. Repeated passes - // propagate each known root through the full descendant tree. - loop { - let mut changed = false; - for process in processes { - if owners.contains_key(&process.pid) { - continue; - } - if let Some(owner) = owners.get(&process.ppid).cloned() { - owners.insert(process.pid, owner); - changed = true; + // Index each parent once, then visit only descendants of managed roots. + // Already-owned roots are never overwritten when one managed process is + // nested inside another, and the visited map also bounds malformed cycles. + let mut children: HashMap> = HashMap::new(); + for process in processes { + children.entry(process.ppid).or_default().push(process.pid); + } + let mut pending: VecDeque = owners.keys().copied().collect(); + while let Some(parent) = pending.pop_front() { + let Some(descendants) = children.get(&parent) else { + continue; + }; + let owner = owners[&parent].clone(); + for &pid in descendants { + if let std::collections::hash_map::Entry::Vacant(entry) = owners.entry(pid) { + entry.insert(owner.clone()); + pending.push_back(pid); } } - if !changed { - break; - } } owners } @@ -1515,6 +1543,121 @@ fn kill_process_tree(pid: u32) -> Result<(), String> { #[cfg(windows)] fn platform_listeners() -> Vec { + // Read both address families in-process. Retain the CLI fallback if the + // native table cannot be read, so discovery still recovers on API errors. + windows_native_listeners().unwrap_or_else(|_| windows_netstat_listeners()) +} + +#[cfg(windows)] +fn windows_tcp_table(family: u32) -> io::Result> { + use windows_sys::Win32::Foundation::{ERROR_INSUFFICIENT_BUFFER, NO_ERROR}; + use windows_sys::Win32::NetworkManagement::IpHelper::{ + GetExtendedTcpTable, TCP_TABLE_OWNER_PID_LISTENER, + }; + let mut bytes = 0_u32; + // The first call asks Windows for the required allocation size. + let status = unsafe { + GetExtendedTcpTable( + std::ptr::null_mut(), + &mut bytes, + 0, + family, + TCP_TABLE_OWNER_PID_LISTENER, + 0, + ) + }; + if status != ERROR_INSUFFICIENT_BUFFER && status != NO_ERROR { + return Err(io::Error::from_raw_os_error(status as i32)); + } + // u32 storage provides the alignment required by both native row layouts. + let mut table = Vec::::new(); + for _ in 0..4 { + table.resize((bytes as usize).div_ceil(std::mem::size_of::()), 0); + let mut capacity = (table.len() * std::mem::size_of::()) as u32; + let status = unsafe { + GetExtendedTcpTable( + table.as_mut_ptr().cast(), + &mut capacity, + 0, + family, + TCP_TABLE_OWNER_PID_LISTENER, + 0, + ) + }; + if status == NO_ERROR { + return Ok(table); + } + if status != ERROR_INSUFFICIENT_BUFFER { + return Err(io::Error::from_raw_os_error(status as i32)); + } + // Listeners may be added between the size query and the actual read. + bytes = capacity; + } + Err(io::Error::other("the TCP listener table kept growing")) +} + +#[cfg(windows)] +fn windows_native_listeners() -> io::Result> { + use windows_sys::Win32::NetworkManagement::IpHelper::{ + MIB_TCP6ROW_OWNER_PID, MIB_TCP6TABLE_OWNER_PID, MIB_TCPROW_OWNER_PID, + MIB_TCPTABLE_OWNER_PID, MIB_TCP_STATE_LISTEN, + }; + use windows_sys::Win32::Networking::WinSock::{AF_INET, AF_INET6}; + let mut listeners = Vec::new(); + for family in [AF_INET, AF_INET6] { + let table = windows_tcp_table(family as u32)?; + let count = *table + .first() + .ok_or_else(|| io::Error::other("empty TCP table"))? as usize; + let (offset, row_size) = if family == AF_INET { + ( + std::mem::offset_of!(MIB_TCPTABLE_OWNER_PID, table), + std::mem::size_of::(), + ) + } else { + ( + std::mem::offset_of!(MIB_TCP6TABLE_OWNER_PID, table), + std::mem::size_of::(), + ) + }; + let end = count + .checked_mul(row_size) + .and_then(|size| offset.checked_add(size)); + if end.is_none_or(|end| end > table.len() * std::mem::size_of::()) { + return Err(io::Error::other("truncated TCP listener table")); + } + for index in 0..count { + // Bounds were checked against the native entry count above. The + // Windows structs contain only integer fields, so every bit pattern + // is valid; read_unaligned also avoids layout/alignment assumptions. + let row = unsafe { table.as_ptr().cast::().add(offset + index * row_size) }; + let (state, address, port, pid) = if family == AF_INET { + let row = unsafe { row.cast::().read_unaligned() }; + ( + row.dwState, + std::net::Ipv4Addr::from(row.dwLocalAddr.to_ne_bytes()).to_string(), + u16::from_be(row.dwLocalPort as u16), + row.dwOwningPid, + ) + } else { + let row = unsafe { row.cast::().read_unaligned() }; + ( + row.dwState, + std::net::Ipv6Addr::from(row.ucLocalAddr).to_string(), + u16::from_be(row.dwLocalPort as u16), + row.dwOwningPid, + ) + }; + if state == MIB_TCP_STATE_LISTEN as u32 { + listeners.push(Listener { address, port, pid }); + } + } + } + Ok(listeners) +} + +#[cfg(windows)] +fn windows_netstat_listeners() -> Vec { let mut command = Command::new("netstat"); command.args(["-ano", "-p", "tcp"]); hide_console(&mut command); @@ -1708,6 +1851,30 @@ mod tests { assert_eq!((ports[1].port, ports[1].pid), (5173, 99)); } + #[cfg(windows)] + #[test] + fn native_windows_discovery_includes_live_ipv4_and_ipv6_listener_owners() { + let ipv4 = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let ipv6 = TcpListener::bind(("::1", 0)).unwrap(); + let pid = std::process::id(); + let listeners = windows_native_listeners().unwrap(); + for (listener, address) in [(&ipv4, "127.0.0.1"), (&ipv6, "::1")] { + let port = listener.local_addr().unwrap().port(); + assert!( + listeners.iter().any(|entry| { + entry.pid == pid && entry.port == port && entry.address == address + }), + "missing native listener {address}:{port} for PID {pid}" + ); + } + let netstat = windows_netstat_listeners(); + // The legacy `netstat -p tcp` fallback only reports IPv4 on Windows. + let port = ipv4.local_addr().unwrap().port(); + assert!(netstat + .iter() + .any(|entry| entry.pid == pid && entry.port == port)); + } + #[test] fn parses_linux_ss_listener() { let raw = "LISTEN 0 511 127.0.0.1:3000 0.0.0.0:* users:((\"node\",pid=321,fd=21))"; @@ -2098,7 +2265,12 @@ mod tests { if let Some(worker) = self.1.take() { let _ = worker.join(); } } } - let worker = spawn_tunnel_supervisor(tunnel.clone(), proxy_port, tools, Duration::from_millis(500)); + let worker = spawn_tunnel_supervisor( + tunnel.clone(), + proxy_port, + tools, + Duration::from_millis(500), + ); let _supervisor = SupervisorGuard(tunnel.clone(), Some(worker)); events.send(format!("Your tunnel is {replacement_url}")).unwrap(); let deadline = std::time::Instant::now() + Duration::from_secs(15); @@ -2321,6 +2493,61 @@ mod tests { assert!(!owners.contains_key(&90)); } + #[test] + fn ownership_preserves_nested_managed_roots_and_terminates_on_cycles() { + let processes = [ + ProcessInfo { + pid: 11, + ppid: 10, + name: "agent".into(), + }, + ProcessInfo { + pid: 12, + ppid: 11, + name: "node".into(), + }, + ProcessInfo { + pid: 10, + ppid: 12, + name: "shell".into(), + }, + ProcessInfo { + pid: 90, + ppid: 91, + name: "unmanaged".into(), + }, + ProcessInfo { + pid: 91, + ppid: 90, + name: "unmanaged".into(), + }, + ]; + let owners = owner_map_from_roots( + &processes, + [ + ( + 10, + Owner { + id: "terminal".into(), + kind: "terminal", + }, + ), + ( + 11, + Owner { + id: "agent".into(), + kind: "agent", + }, + ), + ], + ); + assert_eq!(owners[&10].id, "terminal"); + assert_eq!(owners[&11].id, "agent"); + assert_eq!(owners[&12].id, "agent"); + assert!(!owners.contains_key(&90)); + assert!(!owners.contains_key(&91)); + } + #[test] fn hides_antigravity_cli_internal_listeners() { assert!(is_internal_cli_listener("agy.exe")); diff --git a/src-tauri/src/pty.rs b/src-tauri/src/pty.rs index b4883dc9..6f85a42d 100644 --- a/src-tauri/src/pty.rs +++ b/src-tauri/src/pty.rs @@ -11,7 +11,10 @@ use std::time::{Duration, Instant}; use portable_pty::{native_pty_system, ChildKiller, CommandBuilder, MasterPty, PtySize}; use serde::Serialize; -use tauri::{ipc::Channel, AppHandle, Emitter, Manager}; +use tauri::{ + ipc::{Channel, Response}, + AppHandle, Emitter, Manager, +}; use crate::process_tree; use crate::shells; @@ -167,13 +170,7 @@ impl PtyManager { .lock() .unwrap() .iter() - .filter_map(|(id, session)| { - session - .lock() - .unwrap() - .pid - .map(|pid| (id.clone(), pid)) - }) + .filter_map(|(id, session)| session.lock().unwrap().pid.map(|pid| (id.clone(), pid))) .collect() } @@ -257,7 +254,7 @@ pub fn start_busy_monitor(app: AppHandle) -> Result<(), String> { pub fn spawn( app: &AppHandle, manager: &PtyManager, - on_data: Channel>, + on_data: Channel, id: String, cwd: Option, shell_id: Option, @@ -332,7 +329,11 @@ pub fn spawn( .and_then(|values| values.get("ZDOTDIR")) .filter(|value| !value.is_empty()) .cloned() - .or_else(|| std::env::var("ZDOTDIR").ok().filter(|value| !value.is_empty())); + .or_else(|| { + std::env::var("ZDOTDIR") + .ok() + .filter(|value| !value.is_empty()) + }); if let Some(env) = env { for (k, v) in env { cmd.env(k, v); @@ -424,21 +425,20 @@ pub fn spawn( break; } match rx.recv_timeout(deadline - now) { - Ok(mut more) => { + Ok(more) => { batch.extend_from_slice(&more); - more.clear(); - more.resize(READ_BUF, 0); - let _ = recycle_tx.try_send(more); + recycle_read_buffer(more, &recycle_tx); } Err(_) => break, } } - if on_data.send(batch.clone()).is_err() { + // Response keeps bytes raw. A Vec response would encode + // every byte as a JSON number before the webview decoded it. + // Move the aggregate into IPC rather than cloning it; only + // fixed-size reader chunks return to the recycle pool. + if on_data.send(Response::new(batch)).is_err() { break; } - batch.clear(); - batch.resize(READ_BUF, 0); - let _ = recycle_tx.try_send(batch); } }) .map_err(err)?; @@ -466,6 +466,61 @@ pub fn spawn( }) } +/// Keep only read-sized buffers in the pool. Aggregated output is owned by IPC +/// and can be much larger than a reader chunk. +fn recycle_read_buffer(mut buffer: Vec, recycle: &std::sync::mpsc::SyncSender>) { + buffer.clear(); + let _ = recycle.try_send(buffer); +} + +#[cfg(test)] +mod tests { + use super::*; + use tauri::ipc::{InvokeResponseBody, IpcResponse}; + + #[test] + fn terminal_output_uses_raw_binary_without_changing_bytes() { + let bytes = b"\x1b[31mhello\r\n\xf0\x9f\x8c\xb1\0".to_vec(); + let (tx, rx) = std::sync::mpsc::channel(); + let channel = Channel::::new(move |body| { + tx.send(body).unwrap(); + Ok(()) + }); + channel.send(Response::new(bytes.clone())).unwrap(); + match rx.recv().unwrap() { + InvokeResponseBody::Raw(received) => assert_eq!(received, bytes), + InvokeResponseBody::Json(_) => panic!("terminal output must be binary"), + } + assert!(matches!( + Response::new(Vec::::new()).body().unwrap(), + InvokeResponseBody::Raw(_) + )); + } + + #[test] + fn reader_recycling_does_not_retain_aggregated_output() { + let (tx, rx) = std::sync::mpsc::sync_channel(OUTPUT_QUEUE_CHUNKS); + let mut batch = vec![b'a'; READ_BUF]; + for _ in 1..(EMIT_MAX / READ_BUF) { + let chunk = vec![b'b'; READ_BUF]; + batch.extend_from_slice(&chunk); + recycle_read_buffer(chunk, &tx); + } + assert_eq!(batch.len(), EMIT_MAX); + let recycled: Vec<_> = rx.try_iter().collect(); + assert!(recycled + .iter() + .all(|buffer| buffer.is_empty() && buffer.capacity() == READ_BUF)); + assert_eq!(recycled.len(), EMIT_MAX / READ_BUF - 1); + let InvokeResponseBody::Raw(sent) = Response::new(batch).body().unwrap() else { + panic!("terminal output must be binary"); + }; + assert_eq!(sent.len(), EMIT_MAX); + assert_eq!(&sent[..READ_BUF], vec![b'a'; READ_BUF]); + assert!(sent[READ_BUF..].iter().all(|byte| *byte == b'b')); + } +} + fn home_dir() -> Option { #[cfg(windows)] { diff --git a/src-tauri/src/shells.rs b/src-tauri/src/shells.rs index 2ec640e5..539e061b 100644 --- a/src-tauri/src/shells.rs +++ b/src-tauri/src/shells.rs @@ -146,14 +146,18 @@ fn discover_shells() -> Vec { /// several panes opened together share the same result. A newly installed /// shell becomes available after Duckweed restarts, just like a PATH change. pub fn available_shells() -> Vec { + shell_catalog().to_vec() +} + +fn shell_catalog() -> &'static [ShellInfo] { static SHELLS: OnceLock> = OnceLock::new(); - SHELLS.get_or_init(discover_shells).clone() + SHELLS.get_or_init(discover_shells) } /// The shell we spawn when the frontend does not ask for anything specific. pub fn default_shell() -> ShellInfo { - if let Some(first) = available_shells().into_iter().next() { - return first; + if let Some(first) = shell_catalog().first() { + return first.clone(); } // Last-resort fallbacks so we always have something to spawn. @@ -181,9 +185,10 @@ pub fn default_shell() -> ShellInfo { pub fn resolve_shell(id: Option<&str>) -> ShellInfo { match id { None => default_shell(), - Some(id) => available_shells() - .into_iter() - .find(|s| s.id == id) + Some(id) => shell_catalog() + .iter() + .find(|shell| shell.id == id) + .cloned() .unwrap_or_else(default_shell), } } diff --git a/src-tauri/src/usage/mod.rs b/src-tauri/src/usage/mod.rs index f068c550..9005b0a4 100644 --- a/src-tauri/src/usage/mod.rs +++ b/src-tauri/src/usage/mod.rs @@ -25,6 +25,7 @@ pub mod quota; pub mod sources; use std::collections::{HashMap, HashSet}; +use std::io::{BufWriter, Write}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Mutex; @@ -44,7 +45,7 @@ const DAY_MS: i64 = 24 * 60 * 60 * 1000; /// How far back the duty cycle looks. Kept inside [`RECENT_DAYS`] so every row /// it reads still has five-minute resolution rather than a collapsed day. const DUTY_DAYS: i64 = 7; -const INDEX_VERSION: u32 = 4; +const INDEX_VERSION: u32 = 5; // ---------------------------------------------------------------- tokens @@ -815,11 +816,17 @@ fn save_index(path: &Path, index: &Index) -> Result<(), String> { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent).map_err(|error| error.to_string())?; } - let body = serde_json::to_vec(index).map_err(|error| error.to_string())?; // Write beside the target and rename, so a crash mid-write cannot leave a // truncated index that the next launch would silently treat as empty. + // Stream through a small buffer instead of allocating a second complete + // copy of a potentially multi-megabyte index. let temp = path.with_extension("json.tmp"); - std::fs::write(&temp, body).map_err(|error| error.to_string())?; + { + let file = std::fs::File::create(&temp).map_err(|error| error.to_string())?; + let mut writer = BufWriter::new(file); + serde_json::to_writer(&mut writer, index).map_err(|error| error.to_string())?; + writer.flush().map_err(|error| error.to_string())?; + } std::fs::rename(&temp, path).map_err(|error| error.to_string()) } @@ -1069,6 +1076,110 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn cliproxy_codex_usage_is_priced_after_append_reload_and_index_upgrade() { + use std::io::Write; + let dir = + std::env::temp_dir().join(format!("duckweed-cliproxy-usage-{}", uuid::Uuid::new_v4())); + let home = dir.join("home"); + let sessions = home.join(".codex/sessions"); + std::fs::create_dir_all(&sessions).unwrap(); + let path = sessions.join("rollout.jsonl"); + let index_path = dir.join("index.json"); + let now = chrono::Utc::now(); + let meta = + serde_json::json!({"type":"session_meta","payload":{"model_provider":"cliproxy"}}); + let context = serde_json::json!({"type":"turn_context","payload":{"model":"gpt-6.1-sol"}}); + let event = |seconds, + input, + cached, + output, + reasoning, + total_input, + total_cached, + total_output, + total_reasoning| { + serde_json::json!({"timestamp":(now - chrono::Duration::seconds(seconds)).to_rfc3339(), + "type":"event_msg","payload":{"type":"token_count","info":{ + "last_token_usage":{"input_tokens":input,"cached_input_tokens":cached, + "output_tokens":output,"reasoning_output_tokens":reasoning,"total_tokens":input + output}, + "total_token_usage":{"input_tokens":total_input,"cached_input_tokens":total_cached, + "output_tokens":total_output,"reasoning_output_tokens":total_reasoning,"total_tokens":total_input + total_output} + }}}) + }; + let first = event(2, 1000, 800, 100, 40, 1000, 800, 100, 40); + std::fs::write(&path, format!("{meta}\n{context}\n{first}\n{first}\n")).unwrap(); + let state = UsageState::default(); + let query = Query { + days: 7, + refresh: true, + }; + let overrides = Overrides::new(); + let read = |state: &UsageState| { + scan(&home, &index_path, &overrides, state, &query, &|_, _| {}).unwrap() + }; + let cold = read(&state); + assert_eq!(cold.totals.tokens.total(), 1100); + assert_eq!(cold.totals.tokens.input, 200); + assert_eq!(cold.totals.tokens.cache_read, 800); + assert_eq!(cold.totals.tokens.output, 60); + assert_eq!(cold.totals.tokens.reasoning, 40); + assert!(cold.unpriced.is_empty()); + assert!((cold.totals.cost - 0.00148).abs() < 1e-12); + assert_eq!(read(&state).scan.files_read, 0); + let fast = serde_json::json!({"type":"turn_context","payload":{"model":"gpt-6.1-sol","service_tier":"fast"}}); + let standard = serde_json::json!({"type":"turn_context","payload":{"model":"gpt-6.1-sol","service_tier":"default"}}); + let second = event(1, 1000, 800, 100, 40, 2000, 1600, 200, 80); + let long = event(0, 300000, 200000, 1000, 400, 302000, 201600, 1200, 480); + let mut file = std::fs::OpenOptions::new() + .append(true) + .open(&path) + .unwrap(); + writeln!( + file, + "{first}\n{fast}\n{second}\n{standard}\n{long}\n{long}" + ) + .unwrap(); + drop(file); + let assert_totals = |snapshot: Snapshot| { + assert_eq!(snapshot.totals.requests, 3); + assert_eq!(snapshot.totals.tokens.total(), 303200); + assert!(snapshot.unpriced.is_empty()); + assert!((snapshot.totals.cost - 0.45944).abs() < 1e-12); + assert!( + (snapshot.days.iter().map(|d| d.totals.cost).sum::() - 0.45944).abs() < 1e-12 + ); + let model = snapshot + .models + .iter() + .find(|m| m.model == "gpt-6.1-sol") + .unwrap(); + assert!(model.priced); + assert!((model.totals.cost - 0.45944).abs() < 1e-12); + }; + assert_totals(read(&state)); + let archive = home.join(".codex/archived_sessions"); + std::fs::create_dir_all(&archive).unwrap(); + std::fs::copy(&path, archive.join("copy.jsonl")).unwrap(); + assert_totals(read(&state)); + let reloaded = read(&UsageState::default()); + assert_eq!(reloaded.scan.files_read, 0); + assert_totals(reloaded); + // Old indexes lack GPT-6.1's per-request long-context condition. + let mut old = load_index(&index_path); + old.version = INDEX_VERSION - 1; + for entry in old.files.values_mut() { + for row in &mut entry.rows { + row.pricing.long = false; + } + } + save_index(&index_path, &old).unwrap(); + let upgraded = read(&UsageState::default()); + assert_eq!(upgraded.scan.files_read, 2); + assert_totals(upgraded); + std::fs::remove_dir_all(dir).unwrap(); + } + #[test] fn codex_sums_account_histories_without_rebilling_copies_or_mixing_tiers() { let dir = diff --git a/src-tauri/src/usage/pricing.rs b/src-tauri/src/usage/pricing.rs index 890876d6..afe80703 100644 --- a/src-tauri/src/usage/pricing.rs +++ b/src-tauri/src/usage/pricing.rs @@ -24,7 +24,7 @@ pub struct Context { impl Context { pub fn for_request(model: &str, tier: &str, tokens: &crate::usage::Tokens) -> Self { let model = normalize(model); - let long_capable = ["gpt-6-astra", "gpt-6-sol", "gpt-6-luna"] + let long_capable = ["gpt-6-astra", "gpt-6.1-sol", "gpt-6-sol", "gpt-6-luna"] .iter() .any(|name| model.starts_with(name)) || model.starts_with("gpt-5.6-") @@ -148,8 +148,9 @@ static TABLE: &[Entry] = &[ e("claude-3-haiku", 0.25, 1.25), e("claude-haiku", 1.0, 5.0), // ---- OpenAI / Codex ---- - // https://developers.openai.com/api/docs/pricing (2026-09-24). + // https://developers.openai.com/api/docs/pricing (2026-10-09). e("gpt-6-astra", 10.0, 50.0), + e("gpt-6.1-sol", 2.0, 10.0), e("gpt-6-sol", 2.0, 10.0), e("gpt-6-luna", 0.1, 0.5), e("gpt-5.6-sol", 4.0, 20.0), @@ -268,6 +269,7 @@ pub fn lookup(model: &str, overrides: &Overrides) -> (Rates, bool) { let mut rates = Rates::new(input, output); rates.cache_read = match fragment { "claude-fable-5-1" | "claude-mythos-5-1" => input * 0.025, + "gpt-6.1-sol" => input * 0.05, "gpt-4.1" | "gpt-4.1-mini" | "o3" | "o4-mini" | "codex-mini" => input * 0.25, "gpt-4o" | "gpt-4o-mini" | "o3-mini" => input * 0.5, "gpt-5.5-pro" | "gpt-5.4-pro" | "gpt-5.2-pro" => 0.0, @@ -338,6 +340,7 @@ mod tests { for (model, input, output) in [ ("gpt-5.1-codex-mini", 0.25, 2.0), ("gpt-6-astra", 10.0, 50.0), + ("gpt-6.1-sol", 2.0, 10.0), ("gpt-6-sol", 2.0, 10.0), ("gpt-6-luna", 0.1, 0.5), ("gpt-5.6-sol", 4.0, 20.0), @@ -374,7 +377,7 @@ mod tests { } #[test] - fn gpt_6_sol_and_luna_apply_cache_tier_and_long_context_rates() { + fn gpt_6_and_6_1_apply_cache_tier_and_long_context_rates() { let tokens = crate::usage::Tokens { input: 100_000, cache_read: 180_000, @@ -384,6 +387,7 @@ mod tests { ..Default::default() }; for (model, expected_standard, expected_fast, expected_batch) in [ + ("gpt-6.1-sol", 0.686, 1.372, 0.343), ("gpt-6-sol", 0.722, 1.444, 0.361), ("gpt-6-luna", 0.0361, 0.0722, 0.01805), ] { @@ -406,6 +410,27 @@ mod tests { } } + #[test] + fn gpt_6_1_sol_uses_its_own_cached_input_price_and_allows_overrides() { + let model = "openai/gpt-6.1-sol-2026-09-28"; + let (rates, known) = lookup(model, &Overrides::new()); + assert!(known); + assert_eq!(rates.cache_read, 0.1); + assert_eq!(lookup("gpt-6-sol", &Overrides::new()).0.cache_read, 0.2); + assert!(!lookup("gpt-6.2-sol", &Overrides::new()).1); + let tokens = crate::usage::Tokens { + input: 200, + cache_read: 800, + output: 60, + reasoning: 40, + ..Default::default() + }; + assert!((rates.cost(&tokens) - 0.00148).abs() < 1e-12); + let custom = Rates::new(3.0, 12.0); + let overrides = Overrides::from([(normalize(model), custom)]); + assert_eq!(lookup(model, &overrides).0, custom); + } + #[test] fn strips_provider_prefixes() { assert_eq!( diff --git a/src/App.tsx b/src/App.tsx index b9f633f5..12c00788 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1099,7 +1099,8 @@ export default function App() { let hasPairedDevice = false; const publish = (urgent = false) => { - if (stopped) return; + // An unpaired desktop has no recipient for this expensive projection. + if (stopped || !hasPairedDevice) return; if (timer) window.clearTimeout(timer); timer = 0; if (publishing) { @@ -1270,7 +1271,7 @@ export default function App() { }; const schedule = (delay = 250) => { - if (stopped) return; + if (stopped || !hasPairedDevice) return; if (publishing) { publishQueued = true; publishDelay = Math.max(publishDelay, delay); @@ -1320,8 +1321,25 @@ export default function App() { // animation frames are paused by a minimized desktop window. const offTurnEnds = agentSessions.subscribeTurnEnd(() => publish(true)); const syncTick = listen("mobile:sync-tick", () => { + if (stopped) return; + // Native ticks are emitted only for a currently paired workspace, which + // also catches phones paired from another desktop window or process. + if (!hasPairedDevice) paired(); if (timer || publishQueued) publish(true); }); + const deviceChanges = listen("mobile:devices-changed", ({ payload }) => { + if (stopped) return; + if (payload) { + if (!hasPairedDevice) paired(); + } else { + hasPairedDevice = false; + if (timer) window.clearTimeout(timer); + timer = 0; + publishQueued = false; + urgentPublishQueued = false; + mobileWorkspaceSnapshotRef.current = ""; + } + }); // Keep phone meters on the same live provider reading as the desktop Usage // panel, not the snapshot from when the pairing was first opened. const usagePoll = window.setInterval(() => { @@ -1331,6 +1349,7 @@ export default function App() { if (stopped || status.devices.length === 0) return; hasPairedDevice = true; refreshUsageLimits(); + schedule(); }).catch((error) => { if (!stopped) console.error("mobile usage limits status", error); }); @@ -1345,6 +1364,7 @@ export default function App() { window.removeEventListener("duckweed:mobile-read", read); offTurnEnds(); void syncTick.then((off) => off()); + void deviceChanges.then((off) => off()); offAgents(); offTerminals.forEach((off) => off()); offTerminalOutput.forEach((off) => off()); diff --git a/src/components/agent/AgentSurface.tsx b/src/components/agent/AgentSurface.tsx index cffe5b60..9495f7f9 100644 --- a/src/components/agent/AgentSurface.tsx +++ b/src/components/agent/AgentSurface.tsx @@ -182,9 +182,9 @@ export function AgentSurface({ }, []); const inspectSubagent = useCallback((callId: string) => { - const subagent = subagentForCallId(items, callId); + const subagent = subagentForCallId(agents.get(termId)?.items ?? EMPTY_ITEMS, callId); void agents.inspectSubagent(termId, callId, subagent?.threadId ?? null); - }, [items, termId]); + }, [termId]); const leaveFocus = useCallback(() => { setMultiPane(false); @@ -359,7 +359,9 @@ export function AgentSurface({ useEffect(() => { const available = new Set(fleet.map((subagent) => subagent.callId)); - setMultiPaneCallIds((current) => current.filter((callId) => available.has(callId))); + setMultiPaneCallIds((current) => current.every((callId) => available.has(callId)) + ? current + : current.filter((callId) => available.has(callId))); if (fleet.length === 0) { setSubagentNavigatorOpen(false); setMultiPane(false); @@ -415,10 +417,10 @@ export function AgentSurface({ peekSubagent, ]); + const childrenLive = fleet.some( + (subagent) => subagent.status === "running" || subagent.status === "pending", + ); useEffect(() => { - const childrenLive = fleet.some( - (subagent) => subagent.status === "running" || subagent.status === "pending", - ); if ( (session?.status !== "working" || session.workStartedAt === null) && !childrenLive @@ -428,7 +430,7 @@ export function AgentSurface({ setClockNow(Date.now()); const timer = window.setInterval(() => setClockNow(Date.now()), 1_000); return () => window.clearInterval(timer); - }, [fleet, session?.status, session?.workStartedAt]); + }, [childrenLive, session?.status, session?.workStartedAt]); useEffect(() => { if (!workflow || !workflowComplete) { diff --git a/src/components/agent/official/OfficialShared.test.tsx b/src/components/agent/official/OfficialShared.test.tsx index 189d3464..d6fcd0c1 100644 --- a/src/components/agent/official/OfficialShared.test.tsx +++ b/src/components/agent/official/OfficialShared.test.tsx @@ -1279,3 +1279,61 @@ describe("official agent presentation", () => { expect(html).toContain("agent-activity-cluster"); }); }); + + +describe("activity group projection reuse", () => { + test("keeps settled groups stable across a later streaming response", () => { + const user: AgentItem = { kind: "user", id: "projection-user", at: 1, text: "Inspect" }; + const thought: AgentItem = { + kind: "thinking", id: "projection-thought", at: 2, text: "Inspecting", streaming: false, + }; + const answer: AgentItem = { + kind: "assistant", id: "projection-answer", at: 3, text: "Complete", streaming: false, + }; + const nextUser: AgentItem = { kind: "user", id: "projection-next", at: 4, text: "Continue" }; + const items = [user, thought, answer, nextUser]; + const previous = activityGroups(items); + for (let index = 0; index < 25; index += 1) { + const streamed: AgentItem[] = [...items, { + kind: "assistant", id: "projection-live", at: 5, + text: `Progress ${index}`, streaming: true, + }]; + const next = activityGroups(streamed); + expect(next[0]).toBe(previous[0]); + expect(next[0].activities).toBe(previous[0].activities); + expect(next[0].answerId).toBe(answer.id); + } + }); + + test("updates group metadata and contents when the phase changes", () => { + const thought: AgentItem = { + kind: "thinking", id: "projection-phase", at: 1, text: "Inspecting", streaming: false, + }; + const answer: AgentItem = { + kind: "assistant", id: "projection-comment", at: 2, text: "Progress", streaming: false, + }; + const laterThought: AgentItem = { + kind: "thinking", id: "projection-later", at: 3, text: "Checking", streaming: true, + }; + const unanswered = activityGroups([thought])[0]; + const answered = activityGroups([thought, answer])[0]; + expect(answered).not.toBe(unanswered); + expect(answered.answerId).toBe(answer.id); + const replaced = activityGroups([thought, answer, laterThought]); + expect(replaced[0]).not.toBe(answered); + expect(replaced[0].answerId).toBeNull(); + expect(replaced[0].replacedByCommentId).toBe(answer.id); + const updated: AgentItem = { ...laterThought, text: "Done", streaming: false }; + const refreshed = activityGroups([thought, answer, updated]); + expect(refreshed[0]).toBe(replaced[0]); + expect(refreshed[1]).not.toBe(replaced[1]); + expect(refreshed[1].activities).toEqual([updated]); + const shifted = activityGroups([ + { kind: "notice", id: "projection-notice", at: 0, text: "Resumed", tone: "info" }, + thought, answer, updated, + ]); + expect(shifted[0]).not.toBe(refreshed[0]); + expect(shifted[0].firstIndex).toBe(1); + expect(activityGroups([thought])[0]).toEqual(unanswered); + }); +}); diff --git a/src/components/agent/official/OfficialShared.tsx b/src/components/agent/official/OfficialShared.tsx index df1daaef..ce596552 100644 --- a/src/components/agent/official/OfficialShared.tsx +++ b/src/components/agent/official/OfficialShared.tsx @@ -18,7 +18,7 @@ import { AgentMessageText } from "../AgentMessageText"; import { MessageCopyButton } from "../MessageCopyButton"; import { AgentProviderIcon } from "../AgentProviderIcon"; import { SubagentBoardAnchor } from "../subagents/SubagentBoard"; -import { useSubagentUi } from "../subagents/SubagentUiContext"; +import { useSubagentActivityUi } from "../subagents/SubagentUiContext"; import { toolLoadingPhase } from "../toolLoadingPhase"; import { preparingMessageFor, thinkingHeadlineFor } from "./preparingMessages"; import { thinkingPulsePatternFor } from "./thinkingPulsePatterns"; @@ -626,7 +626,7 @@ export const ToolActivity = memo(function ToolActivity({ compact?: boolean; expandSubagentLocally?: boolean; }) { - const { absorbedCallIds, peekedCallId, peekSubagent } = useSubagentUi(); + const { absorbedCallIds, peekedCallId, peekSubagent } = useSubagentActivityUi(); const isSubagent = item.tool === "task"; const absorbed = isSubagent && !expandSubagentLocally && absorbedCallIds.has(item.callId); const hasOutput = item.output.trim().length > 0; @@ -1013,7 +1013,7 @@ export const ActivityHistory = memo(function ActivityHistory({ */ clusterId: string; }) { - const { absorbedCallIds, rosterAnchorIds } = useSubagentUi(); + const { absorbedCallIds, rosterAnchorIds } = useSubagentActivityUi(); const thoughts = activities.filter( (item): item is ThinkingItem => item.kind === "thinking", ); @@ -1209,6 +1209,10 @@ export function activityClusterHiddenByComment( ); } +// Settled phases remain stable while a later response streams. Weak keys avoid +// retaining a closed conversation just to cache its presentation objects. +const activityGroupCache = new WeakMap(); + /** * Keep reasoning/tool activity scoped to the user turn and the assistant * comment that preceded it. A comment followed by more work starts a fresh @@ -1219,24 +1223,32 @@ export function activityGroups(items: AgentItem[]): ActivityGroup[] { let turnStart = 0; const collectTurn = (start: number, end: number) => { - let activity: Array<{ - item: ToolItem | Extract; - index: number; - }> = []; + let activity: ActivityGroup["activities"] = []; + let firstIndex = -1; const flush = ( answerId: string | null, replacedByCommentId: string | null = null, ) => { if (!activity.length) return; - groups.push({ - firstId: activity[0].item.id, - firstIndex: activity[0].index, - activities: activity.map(({ item }) => item), + const first = activity[0]; + const previous = activityGroupCache.get(first); + const unchanged = previous && previous.firstIndex === firstIndex && + previous.answerId === answerId && + previous.replacedByCommentId === replacedByCommentId && + previous.activities.length === activity.length && + previous.activities.every((item, index) => item === activity[index]); + const group = unchanged ? previous : { + firstId: first.id, + firstIndex, + activities: activity, answerId, replacedByCommentId, - }); + }; + activityGroupCache.set(first, group); + groups.push(group); activity = []; + firstIndex = -1; }; let lastActivityIndex = -1; @@ -1251,7 +1263,8 @@ export function activityGroups(items: AgentItem[]): ActivityGroup[] { for (let index = start; index < end; index += 1) { const item = items[index]; if (item.kind === "thinking" || item.kind === "tool") { - activity.push({ item, index }); + if (!activity.length) firstIndex = index; + activity.push(item); continue; } if (item.kind !== "assistant" || !activity.length) continue; diff --git a/src/components/agent/provider/CursorExperience.tsx b/src/components/agent/provider/CursorExperience.tsx index 19646c8a..d36ee6a7 100644 --- a/src/components/agent/provider/CursorExperience.tsx +++ b/src/components/agent/provider/CursorExperience.tsx @@ -6,7 +6,7 @@ import { AgentImageAttachments } from "../AgentImageAttachments"; import { AgentMessageText } from "../AgentMessageText"; import { MessageCopyButton } from "../MessageCopyButton"; import { SubagentBoardAnchor, SubagentBoardForActivities } from "../subagents/SubagentBoard"; -import { useSubagentUi } from "../subagents/SubagentUiContext"; +import { useSubagentActivityUi } from "../subagents/SubagentUiContext"; import { toolLoadingPhase, type ToolLoadingPhase } from "../toolLoadingPhase"; import { ActivityHistory, @@ -171,7 +171,7 @@ function CursorTracker({ plan }: { plan: PlanSummary }) { * its own live marker, and the sub-agent's output folded behind it. */ function CursorSubagent({ item, elapsed }: { item: ToolItem; elapsed: string | null }) { - const { absorbedCallIds, rosterAnchorIds, peekedCallId, peekSubagent } = useSubagentUi(); + const { absorbedCallIds, rosterAnchorIds, peekedCallId, peekSubagent } = useSubagentActivityUi(); const loadingPhase = toolLoadingPhase(item.status); if (rosterAnchorIds.has(item.id)) return ; if (absorbedCallIds.has(item.callId)) return null; @@ -362,7 +362,7 @@ const CursorNode = memo(function CursorNode({ }); export function CursorExperience({ session, items, className }: ProviderExperienceProps) { - const { peekSubagent } = useSubagentUi(); + const { peekSubagent } = useSubagentActivityUi(); const list = items ?? session.items; const plan = useMemo(() => planSummary(list), [list]); const activity = useMemo(() => activitySummary(list), [list]); diff --git a/src/components/agent/provider/OpenCodeExperience.tsx b/src/components/agent/provider/OpenCodeExperience.tsx index e9545918..59fa401a 100644 --- a/src/components/agent/provider/OpenCodeExperience.tsx +++ b/src/components/agent/provider/OpenCodeExperience.tsx @@ -5,7 +5,7 @@ import { AgentImageAttachments } from "../AgentImageAttachments"; import { AgentMessageText } from "../AgentMessageText"; import { MessageCopyButton } from "../MessageCopyButton"; import { SubagentBoardAnchor, SubagentBoardForActivities } from "../subagents/SubagentBoard"; -import { useSubagentUi } from "../subagents/SubagentUiContext"; +import { useSubagentActivityUi } from "../subagents/SubagentUiContext"; import { toolLoadingPhase, type ToolLoadingPhase } from "../toolLoadingPhase"; import { ActivityHistory, @@ -255,7 +255,7 @@ function OpenCodePlan({ steps }: { steps: AgentPlanStep[] }) { /** A turn OpenCode handed to another agent — its `task` calls. */ function OpenCodeSubagent({ item, elapsed }: { item: ToolItem; elapsed: string | null }) { - const { absorbedCallIds, rosterAnchorIds, peekedCallId, peekSubagent } = useSubagentUi(); + const { absorbedCallIds, rosterAnchorIds, peekedCallId, peekSubagent } = useSubagentActivityUi(); const loadingPhase = toolLoadingPhase(item.status); if (rosterAnchorIds.has(item.id)) return ; if (absorbedCallIds.has(item.callId)) return null; diff --git a/src/components/agent/subagents/SubagentBoard.tsx b/src/components/agent/subagents/SubagentBoard.tsx index bacfa6f7..e2f9f5a4 100644 --- a/src/components/agent/subagents/SubagentBoard.tsx +++ b/src/components/agent/subagents/SubagentBoard.tsx @@ -1,4 +1,4 @@ -import type { ReactNode } from "react"; +import { memo, useCallback, type ReactNode } from "react"; import { rosterForAnchor, @@ -14,7 +14,7 @@ import { type SubagentSummary, } from "../../../lib/agents/subagents"; import type { AgentId, AgentItem } from "../../../lib/agents/types"; -import { useSubagentUi } from "./SubagentUiContext"; +import { useSubagentActivityUi, useSubagentUi } from "./SubagentUiContext"; function PeekPreview({ subagent }: { subagent: SubagentSummary }) { const tools = subagentPeekTools(subagent); @@ -94,7 +94,7 @@ function RosterRow({ ); } -export function SubagentBoard({ +export const SubagentBoard = memo(function SubagentBoard({ agent, roster, now, @@ -146,7 +146,14 @@ export function SubagentBoard({ )} ); -} +}, (previous, next) => { + if (previous.agent !== next.agent || previous.roster !== next.roster || + previous.peekedCallId !== next.peekedCallId || previous.onPeek !== next.onPeek || + previous.onOpen !== next.onOpen) return false; + return previous.now === next.now || !next.roster.subagents.some( + (subagent) => subagent.status === "running" || subagent.status === "pending", + ); +}); /** Renders the roster whose first task lives in this activity cluster. */ export function SubagentBoardAnchor({ itemId }: { itemId: string }) { @@ -159,6 +166,10 @@ export function SubagentBoardAnchor({ itemId }: { itemId: string }) { openSubagent, closePeek, } = useSubagentUi(); + const togglePeek = useCallback((callId: string) => { + if (peekedCallId === callId) closePeek(); + else peekSubagent(callId); + }, [closePeek, peekedCallId, peekSubagent]); const roster = rosterForAnchor(rosters, itemId); if (!agent || !roster) return null; @@ -168,10 +179,7 @@ export function SubagentBoardAnchor({ itemId }: { itemId: string }) { roster={roster} now={now} peekedCallId={peekedCallId} - onPeek={(callId) => { - if (peekedCallId === callId) closePeek(); - else peekSubagent(callId); - }} + onPeek={togglePeek} onOpen={openSubagent} /> ); @@ -185,7 +193,7 @@ export function SubagentBoardForActivities({ activities: AgentItem[]; wrap?: (board: ReactNode) => ReactNode; }) { - const { rosterAnchorIds } = useSubagentUi(); + const { rosterAnchorIds } = useSubagentActivityUi(); const rosterAnchorId = activities.find( (item) => item.kind === "tool" && rosterAnchorIds.has(item.id), )?.id; diff --git a/src/components/agent/subagents/SubagentUiContext.tsx b/src/components/agent/subagents/SubagentUiContext.tsx index 77d7cfdc..7bbe91a1 100644 --- a/src/components/agent/subagents/SubagentUiContext.tsx +++ b/src/components/agent/subagents/SubagentUiContext.tsx @@ -1,4 +1,4 @@ -import { createContext, useContext, useMemo, type ReactNode } from "react"; +import { createContext, useContext, useMemo, useRef, type ReactNode } from "react"; import type { SubagentRoster } from "../../../lib/agents/subagents"; import type { AgentId } from "../../../lib/agents/types"; @@ -32,6 +32,9 @@ const EMPTY_SUBAGENT_UI: SubagentUiValue = { }; const SubagentUiContext = createContext(EMPTY_SUBAGENT_UI); +type SubagentActivityUiValue = Pick; +const SubagentActivityUiContext = createContext(EMPTY_SUBAGENT_UI); export function SubagentUiProvider({ agent, @@ -56,17 +59,27 @@ export function SubagentUiProvider({ onLeaveFocus: () => void; children: ReactNode; }) { - const absorbedCallIds = useMemo(() => { - const ids = new Set(); + const membershipRef = useRef({ + absorbedCallIds: EMPTY_SUBAGENT_UI.absorbedCallIds, + rosterAnchorIds: EMPTY_SUBAGENT_UI.rosterAnchorIds, + }); + const { absorbedCallIds, rosterAnchorIds } = useMemo(() => { + const absorbedCallIds = new Set(); + const rosterAnchorIds = new Set(); for (const roster of rosters) { - for (const subagent of roster.subagents) ids.add(subagent.callId); + rosterAnchorIds.add(roster.anchorItemId); + for (const subagent of roster.subagents) absorbedCallIds.add(subagent.callId); } - return ids; + const previous = membershipRef.current; + if (previous.absorbedCallIds.size === absorbedCallIds.size && + previous.rosterAnchorIds.size === rosterAnchorIds.size && + [...absorbedCallIds].every((id) => previous.absorbedCallIds.has(id)) && + [...rosterAnchorIds].every((id) => previous.rosterAnchorIds.has(id))) { + return previous; + } + membershipRef.current = { absorbedCallIds, rosterAnchorIds }; + return membershipRef.current; }, [rosters]); - const rosterAnchorIds = useMemo( - () => new Set(rosters.map((roster) => roster.anchorItemId)), - [rosters], - ); const value = useMemo( () => ({ agent, @@ -96,11 +109,28 @@ export function SubagentUiProvider({ ], ); + // Transcript chrome only needs membership and peek interaction. Clock ticks + // and child output updates belong to the boards, not every historical tool. + const activityValue = useMemo(() => ({ + absorbedCallIds, + rosterAnchorIds, + peekedCallId, + peekSubagent: onPeek, + }), [absorbedCallIds, rosterAnchorIds, peekedCallId, onPeek]); + return ( - {children} + + + {children} + + ); } export function useSubagentUi(): SubagentUiValue { return useContext(SubagentUiContext); } + +export function useSubagentActivityUi(): SubagentActivityUiValue { + return useContext(SubagentActivityUiContext); +} diff --git a/src/lib/agents/adapters/codex.test.ts b/src/lib/agents/adapters/codex.test.ts index 49e02f6a..e823c618 100644 --- a/src/lib/agents/adapters/codex.test.ts +++ b/src/lib/agents/adapters/codex.test.ts @@ -2567,7 +2567,7 @@ describe("codex adapter", () => { ]); }); - test("reads token usage and how much context it has eaten", async () => { + test("counts cached input once and uses the last request for context occupancy", async () => { const h = harness(); await h.handshake(); h.notify("thread/tokenUsage/updated", { @@ -2576,17 +2576,36 @@ describe("codex adapter", () => { tokenUsage: { modelContextWindow: 1000, last: { inputTokens: 10, cachedInputTokens: 0, outputTokens: 5, reasoningOutputTokens: 0, totalTokens: 15 }, - total: { inputTokens: 100, cachedInputTokens: 150, outputTokens: 50, reasoningOutputTokens: 20, totalTokens: 300 }, + total: { inputTokens: 250, cachedInputTokens: 150, outputTokens: 50, reasoningOutputTokens: 20, totalTokens: 300 }, }, }); expect(h.state().usage).toMatchObject({ inputTokens: 250, outputTokens: 50, - contextUsed: 0.3, + contextUsed: 0.015, }); }); + test("repeated usage snapshots replace totals and missing request usage leaves context unknown", async () => { + const h = harness(); + await h.handshake(); + const tokenUsage = { + modelContextWindow: 1000, + last: { inputTokens: 250, cachedInputTokens: 150, outputTokens: 50, totalTokens: 300 }, + total: { inputTokens: 2500, cachedInputTokens: 1500, outputTokens: 500, totalTokens: 3000 }, + }; + for (let i = 0; i < 2; i++) { + h.notify("thread/tokenUsage/updated", { threadId: "thread_1", tokenUsage }); + } + expect(h.state().usage).toMatchObject({ inputTokens: 2500, outputTokens: 500, contextUsed: 0.3 }); + h.notify("thread/tokenUsage/updated", { + threadId: "thread_1", + tokenUsage: { total: tokenUsage.total, modelContextWindow: 1000 }, + }); + expect(h.state().usage).toMatchObject({ inputTokens: 2500, outputTokens: 500, contextUsed: null }); + }); + test("asks before running a command, and forwards the decision", async () => { const h = harness(); await h.handshake(); diff --git a/src/lib/agents/adapters/codex.ts b/src/lib/agents/adapters/codex.ts index 6eea2373..fb6f5afd 100644 --- a/src/lib/agents/adapters/codex.ts +++ b/src/lib/agents/adapters/codex.ts @@ -2498,16 +2498,19 @@ export function createCodexAdapter(options: CodexAdapterOptions = {}): AgentAdap case "thread/tokenUsage/updated": { const usage = asRecord(params.tokenUsage); const total = asRecord(usage?.total); + const last = asRecord(usage?.last); const window = usage?.modelContextWindow; const number = (value: unknown) => (typeof value === "number" ? value : 0); ctx.emit({ type: "usage", usage: { - inputTokens: number(total?.inputTokens) + number(total?.cachedInputTokens), + // Cached input is a subset of inputTokens in Codex Responses usage. + inputTokens: number(total?.inputTokens), outputTokens: number(total?.outputTokens), contextUsed: - typeof window === "number" && window > 0 - ? Math.min(1, number(total?.totalTokens) / window) + // Context occupancy describes the latest request, not lifetime usage. + typeof window === "number" && window > 0 && typeof last?.totalTokens === "number" + ? Math.min(1, Math.max(0, last.totalTokens) / window) : null, }, }); diff --git a/src/lib/agents/subagents.test.ts b/src/lib/agents/subagents.test.ts index 6f40d45a..cb7148c1 100644 --- a/src/lib/agents/subagents.test.ts +++ b/src/lib/agents/subagents.test.ts @@ -358,3 +358,78 @@ describe("peek and focus fields", () => { expect(visible.rows.length + visible.hiddenCompleted).toBe(subagents.length); }); }); + + +describe("subagent projection reuse", () => { + test("does not reread settled output while a parent response streams", () => { + const user: AgentItem = { kind: "user", id: "cache-user", at: 1, text: "Inspect" }; + const child = task("cache-child", "done"); + let outputReads = 0; + Object.defineProperty(child, "output", { + get() { + outputReads += 1; + return "Completed inspection\n".repeat(500); + }, + }); + const items: AgentItem[] = [user, child]; + const fleet = subagentsForTurn(items); + const rosters = subagentRosters(items); + const readsAfterProjection = outputReads; + for (let index = 0; index < 25; index += 1) { + const streamed: AgentItem[] = [...items, { + kind: "assistant", id: "cache-answer", at: 3, + text: `Progress ${index}`, streaming: true, + }]; + expect(subagentsForTurn(streamed)).toBe(fleet); + expect(subagentRosters(streamed)).toBe(rosters); + expect(rosters[0].subagents[0]).toBe(fleet[0]); + } + expect(outputReads).toBe(readsAfterProjection); + expect(fleet[0].activity).toBe("Completed inspection"); + }); + + test("invalidates only the worker and roster whose immutable item changes", () => { + const user: AgentItem = { kind: "user", id: "cache-update-user", at: 1, text: "Inspect" }; + const one = task("cache-one", "done", { output: "Old result" }); + const two = task("cache-two", "running", { output: "Reading fixtures" }); + const items: AgentItem[] = [user, one, two]; + const fleet = subagentsForTurn(items); + const rosters = subagentRosters(items); + const updated: AgentItem[] = [user, one, { + ...two, status: "done", output: "Reviewed all fixtures", + subagent: { threadId: "updated-thread", label: "Fixture review" }, + }]; + const nextFleet = subagentsForTurn(updated); + const nextRosters = subagentRosters(updated); + expect(nextFleet).not.toBe(fleet); + expect(nextFleet[0]).toBe(fleet[0]); + expect(nextFleet[1]).not.toBe(fleet[1]); + expect(nextFleet[1]).toMatchObject({ + status: "done", activity: "Reviewed all fixtures", + threadId: "updated-thread", label: "Fixture review", + }); + expect(nextRosters).not.toBe(rosters); + expect(nextRosters[0].subagents[0]).toBe(rosters[0].subagents[0]); + expect(nextRosters[0].subagents[1]).toBe(nextFleet[1]); + expect(subagentsForTurn(items)[1]).toBe(fleet[1]); + }); + + test("keeps historical rosters stable and removes workers without stale cache entries", () => { + const oldUser: AgentItem = { kind: "user", id: "cache-old-user", at: 1, text: "First" }; + const liveUser: AgentItem = { kind: "user", id: "cache-live-user", at: 2, text: "Second" }; + const old = task("cache-old", "done"); + const live = task("cache-live", "running"); + const items: AgentItem[] = [oldUser, old, liveUser, live]; + const rosters = subagentRosters(items); + const updated = [oldUser, old, liveUser, { ...live, output: "Current result" }]; + const next = subagentRosters(updated); + expect(next[0]).toBe(rosters[0]); + expect(next[1]).not.toBe(rosters[1]); + const removed = [oldUser, old, liveUser]; + expect(subagentsForTurn(removed)).toEqual([]); + expect(subagentRosters(removed)).toEqual([rosters[0]]); + expect(subagentRosters(items)).toEqual(rosters); + expect(subagentsForTurn([liveUser])).toBe(subagentsForTurn([])); + expect(subagentRosters([liveUser])).toBe(subagentRosters([])); + }); +}); diff --git a/src/lib/agents/subagents.ts b/src/lib/agents/subagents.ts index 1491440b..88c45ed5 100644 --- a/src/lib/agents/subagents.ts +++ b/src/lib/agents/subagents.ts @@ -83,7 +83,22 @@ function firstSentence(text: string, limit = 120): string { return compact(sentence, limit); } +// Reducer items are immutable. Weak keys release projections with their transcript. +const summaryCache = new WeakMap(); +const fleetCache = new WeakMap(); +const rosterCache = new WeakMap(); +const rostersCache = new WeakMap(); +const EMPTY_SUMMARIES: SubagentSummary[] = []; +const EMPTY_ROSTERS: SubagentRoster[] = []; + +function sameEntries(previous: T[] | undefined, next: T[]): boolean { + return previous !== undefined && previous.length === next.length && + previous.every((entry, index) => entry === next[index]); +} + function summaryFor(item: ToolItem): SubagentSummary { + const cached = summaryCache.get(item); + if (cached) return cached; const meta: SubagentMeta = item.subagent ?? {}; const role = clean(meta.role); const prompt = clean(meta.prompt); @@ -99,7 +114,7 @@ function summaryFor(item: ToolItem): SubagentSummary { ? "Delegated work failed" : "Delegated work completed"); - return { + const summary: SubagentSummary = { id: clean(meta.threadId) ?? item.callId, callId: item.callId, label: compact(label, 80), @@ -116,6 +131,8 @@ function summaryFor(item: ToolItem): SubagentSummary { startedAt: item.at, item, }; + summaryCache.set(item, summary); + return summary; } function currentTurnStart(items: AgentItem[]): number { @@ -136,14 +153,20 @@ function currentTurnStart(items: AgentItem[]): number { export function subagentsForTurn(items: AgentItem[]): SubagentSummary[] { const turnStart = currentTurnStart(items); - return items - .filter( - (item, index): item is ToolItem => - item.kind === "tool" && - item.tool === "task" && - (index > turnStart || item.status === "running" || item.status === "pending"), - ) - .map(summaryFor); + const summaries: SubagentSummary[] = []; + for (let index = 0; index < items.length; index += 1) { + const item = items[index]; + if (item.kind === "tool" && item.tool === "task" && + (index > turnStart || item.status === "running" || item.status === "pending")) { + summaries.push(summaryFor(item)); + } + } + if (!summaries.length) return EMPTY_SUMMARIES; + const anchor = items[turnStart] ?? items[0]; + const previous = fleetCache.get(anchor); + if (sameEntries(previous, summaries)) return previous!; + fleetCache.set(anchor, summaries); + return summaries; } /** @@ -170,11 +193,19 @@ export function subagentRosters(items: AgentItem[]): SubagentRoster[] { if (item.kind === "tool" && item.tool === "task") tasks.push(item); } if (!tasks.length) continue; - rosters.push({ - anchorItemId: tasks[0].id, - subagents: tasks.map(summaryFor), - }); + const subagents = tasks.map(summaryFor); + const previous = rosterCache.get(tasks[0]); + const roster = previous && sameEntries(previous.subagents, subagents) + ? previous + : { anchorItemId: tasks[0].id, subagents }; + rosterCache.set(tasks[0], roster); + rosters.push(roster); } + if (!rosters.length) return EMPTY_ROSTERS; + const anchor = items[0]; + const previous = rostersCache.get(anchor); + if (sameEntries(previous, rosters)) return previous!; + rostersCache.set(anchor, rosters); return rosters; } diff --git a/src/lib/blocks.test.ts b/src/lib/blocks.test.ts index 8c441c85..4dbaff76 100644 --- a/src/lib/blocks.test.ts +++ b/src/lib/blocks.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test"; +import { Terminal } from "@xterm/xterm"; import { BlockTracker, @@ -240,7 +241,7 @@ describe("BlockTracker.open OSC 133 anchoring", () => { type Row = { text: string; isWrapped?: boolean }; function openableTracker(rows: Record, cursorLine: number) { - const markers: { line: number; isDisposed: boolean }[] = []; + const markers: { line: number; isDisposed: boolean; dispose(): void }[] = []; const term = { buffer: { active: { @@ -260,7 +261,22 @@ describe("BlockTracker.open OSC 133 anchoring", () => { }, registerMarker(offset: number) { const line = this.buffer.active.baseY + this.buffer.active.cursorY + offset; - const m = { line, isDisposed: false }; + const callbacks = new Set<() => void>(); + const m = { + line, + isDisposed: false, + onDispose(callback: () => void) { + callbacks.add(callback); + return { dispose() { callbacks.delete(callback); } }; + }, + dispose() { + if (this.isDisposed) return; + this.isDisposed = true; + this.line = -1; + for (const callback of callbacks) callback(); + callbacks.clear(); + }, + }; markers.push(m); return m; }, @@ -270,12 +286,13 @@ describe("BlockTracker.open OSC 133 anchoring", () => { term, blocks: [], nextId: 1, + needsPrune: false, + onMarkerDisposed() { + (tracker as unknown as { needsPrune: boolean }).needsPrune = true; + }, selectedId: null, selectOverlay: { hidden: true }, visibleBlocks: new Set(), - prune() { - // no-op for these unit tests - }, scheduleLayout() { // no-op for these unit tests }, @@ -342,6 +359,48 @@ describe("BlockTracker.open OSC 133 anchoring", () => { expect(blocks[0].start.line).toBe(5); }); + test("retains history until xterm disposes a marker, then prunes its selection", () => { + const { tracker, markers, term } = openableTracker({ + 0: { text: "PS H:\\proj> first" }, + 3: { text: "PS H:\\proj> second" }, + }, 0); + tracker.open("first", 1000); + term.buffer.active.cursorY = 3; + term.buffer.active.length = 4; + tracker.open("second", 2000); + const state = tracker as unknown as { blocks: CommandBlock[]; selectedId: number | null }; + const initialHistory = state.blocks; + expect(tracker.ids()).toEqual([1, 2]); + expect(tracker.ids()).toEqual([1, 2]); + expect(state.blocks).toBe(initialHistory); + + state.selectedId = 1; + markers[0].dispose(); + expect(tracker.ids()).toEqual([2]); + expect(tracker.selectedBlockId()).toBeNull(); + const retainedHistory = state.blocks; + expect(tracker.ids()).toEqual([2]); + expect(state.blocks).toBe(retainedHistory); + }); + + test("prunes command blocks when the real xterm scrollback trims their markers", async () => { + const term = new Terminal({ allowProposedApi: true, cols: 40, rows: 2, scrollback: 2 }); + const { tracker } = openableTracker({}, 0); + Object.assign(tracker as object, { term }); + const write = (text: string) => new Promise((resolve) => term.write(text, resolve)); + try { + await write("PS> first"); + tracker.open("first", 1000); + await write("\r\noutput\r\nPS> second"); + tracker.open("second", 2000); + expect(tracker.ids()).toEqual([1, 2]); + await write("\r\nresult".repeat(8)); + expect(tracker.ids()).toEqual([]); + } finally { + term.dispose(); + } + }); + test("keeps repeated composer submissions as separate blocks", () => { const rows: Record = { 2: { text: "PS H:\\proj> npm run dev" }, @@ -427,3 +486,47 @@ describe("wrappedCommandEnd", () => { expect(wrappedCommandEnd((y) => y === 6, 5, 9)).toBe(6); }); }); + + +describe("BlockTracker pointer lookup", () => { + function trackerWithBlocks(starts: number[]) { + let markerReads = 0; + const blocks = starts.map((line, index) => ({ + id: index + 1, + command: `command ${index + 1}`, + start: { + get line() { markerReads += 1; return line; }, + isDisposed: false, + }, + })); + const tracker = Object.create(BlockTracker.prototype) as BlockTracker; + Object.assign(tracker as object, { + needsPrune: false, + blocks, + term: { + buffer: { active: { + baseY: starts[starts.length - 1] ?? 0, + cursorY: 1, + getLine() { return { isWrapped: false, translateToString() { return "output"; } }; }, + } }, + }, + }); + return { tracker, blocks, reads: () => markerReads }; + } + + test("finds early scrollback blocks with logarithmic marker reads", () => { + const { tracker, blocks, reads } = trackerWithBlocks(Array.from({ length: 20_000 }, (_, i) => i * 3)); + expect(tracker.atLine(32)).toBe(blocks[10] as unknown as CommandBlock); + expect(reads()).toBeLessThan(25); + }); + + test("keeps newest ownership when successive commands begin on the same line", () => { + const { tracker, blocks } = trackerWithBlocks([0, 3, 3, 6]); + expect(tracker.atLine(-1)).toBeNull(); + expect(tracker.atLine(2)).toBe(blocks[0] as unknown as CommandBlock); + expect(tracker.atLine(3)).toBe(blocks[2] as unknown as CommandBlock); + expect(tracker.atLine(5)).toBe(blocks[2] as unknown as CommandBlock); + expect(tracker.atLine(7)).toBe(blocks[3] as unknown as CommandBlock); + expect(tracker.atLine(8)).toBeNull(); + }); +}); diff --git a/src/lib/blocks.ts b/src/lib/blocks.ts index 3536dbc6..c9c72cae 100644 --- a/src/lib/blocks.ts +++ b/src/lib/blocks.ts @@ -43,6 +43,9 @@ export interface CommandBlock { export class BlockTracker { private blocks: CommandBlock[] = []; + /** xterm marks trimmed/reflowed markers disposed and emits onDispose. */ + private needsPrune = false; + private readonly onMarkerDisposed = () => { this.needsPrune = true; }; private nextId = 1; private press: { x: number; y: number; line: number } | null = null; private selectedId: number | null = null; @@ -200,6 +203,7 @@ export class BlockTracker { } const start = this.term.registerMarker(startLine - cursorLine); if (!start) return; + start.onDispose(this.onMarkerDisposed); const block: CommandBlock = { id: this.nextId++, @@ -238,6 +242,7 @@ export class BlockTracker { clear(): void { for (const b of this.blocks) disposeBlock(b); this.blocks = []; + this.needsPrune = false; this.visibleBlocks.clear(); this.press = null; this.selectedId = null; @@ -250,6 +255,7 @@ export class BlockTracker { } dispose(): void { + this.setActive(false); this.clear(); this.promptCover.remove(); this.selectFill.remove(); @@ -733,13 +739,18 @@ export class BlockTracker { /** Find the block that owns an absolute buffer line. */ atLine(line: number): CommandBlock | null { this.prune(); - for (let i = this.blocks.length - 1; i >= 0; i--) { - const block = this.blocks[i]; - const range = this.rangeAt(i); - if (!range) continue; - if (line >= range.start && line <= range.end) return block; + // Starts stay ordered as xterm reflows/scrolls. Only the newest block + // beginning at or before this line can own it, including coincident starts. + let low = 0; + let high = this.blocks.length; + while (low < high) { + const mid = (low + high) >>> 1; + if (this.blocks[mid].start.line <= line) low = mid + 1; + else high = mid; } - return null; + const index = low - 1; + const range = this.rangeAt(index); + return range && line >= range.start && line <= range.end ? this.blocks[index] : null; } /** @@ -979,6 +990,9 @@ export class BlockTracker { } private prune(): void { + // Most layouts and pointer moves do not trim history. Avoid copying and + // visiting all blocks until xterm actually disposes one of their markers. + if (!this.needsPrune) return; this.blocks = this.blocks.filter((b) => { if (!b.start.isDisposed && b.start.line >= 0) return true; disposeBlock(b); @@ -986,6 +1000,7 @@ export class BlockTracker { if (this.selectedId === b.id) this.selectedId = null; return false; }); + this.needsPrune = false; } private handleMouseDown(e: MouseEvent): void { diff --git a/src/lib/frames.test.ts b/src/lib/frames.test.ts index 5a5a0f74..2a9b7617 100644 --- a/src/lib/frames.test.ts +++ b/src/lib/frames.test.ts @@ -33,6 +33,35 @@ describe("synchronized terminal frames", () => { frames.dispose(); }); + test("preserves ordinary chunks and a marker completed by a text-only read", () => { + const writes = []; + const frames = createFrameBuffer((write) => writes.push(write)); + frames.push("plain\r\n"); + frames.push(""); + frames.push(`${OPEN}body${ESC}[?20`); + frames.push("26l"); + frames.push("after\r\n"); + expect(writes).toEqual([ + { text: "plain\r\n", synchronized: false, complete: true }, + { text: `${OPEN}body${CLOSE}`, synchronized: true, complete: true }, + { text: "after\r\n", synchronized: false, complete: true }, + ]); + frames.dispose(); + }); + + test("disposal drops held output and ignores late PTY deliveries", () => { + const writes = []; + const frames = createFrameBuffer((write) => writes.push(write)); + frames.push(`${OPEN}held${ESC}[?20`); + expect(frames.isFraming()).toBe(true); + frames.dispose(); + expect(frames.isFraming()).toBe(false); + frames.flush(); + frames.push(`26lafter${OPEN}late${CLOSE}`); + frames.dispose(); + expect(writes).toEqual([]); + }); + test("keeps a safety-flushed frame logically open", () => { const writes = []; const frames = createFrameBuffer((write) => writes.push(write)); diff --git a/src/lib/frames.ts b/src/lib/frames.ts index bf80dce7..9d5b363f 100644 --- a/src/lib/frames.ts +++ b/src/lib/frames.ts @@ -153,6 +153,7 @@ export function createFrameBuffer(write: (chunk: FrameWrite) => void): FrameBuff let frame = ""; let open = false; let timer: ReturnType | undefined; + let disposed = false; function disarm(): void { if (timer === undefined) return; @@ -170,6 +171,13 @@ export function createFrameBuffer(write: (chunk: FrameWrite) => void): FrameBuff } function push(chunk: string): void { + if (disposed || !chunk) return; + // Ordinary shell output needs neither escape-tail parsing nor a fresh + // private-mode regex. Deliver the original string without assembling it. + if (!open && !carry && !chunk.includes(ESC)) { + write({ text: chunk, synchronized: false, complete: true }); + return; + } let text = carry + chunk; carry = ""; @@ -249,6 +257,12 @@ export function createFrameBuffer(write: (chunk: FrameWrite) => void): FrameBuff push, isFraming: () => open, flush, - dispose: disarm, + dispose() { + disarm(); + disposed = true; + carry = ""; + frame = ""; + open = false; + }, }; } diff --git a/src/lib/highlight.test.ts b/src/lib/highlight.test.ts index d222f9d7..96636497 100644 --- a/src/lib/highlight.test.ts +++ b/src/lib/highlight.test.ts @@ -22,6 +22,25 @@ describe("terminal output syntax highlighting", () => { expect(output).toContain(paint(highlightColors.number, "42")); }); + test("preserves the palette for every token alternative and alias", () => { + const tokens = [ + ["url", "https://example.com/42"], + ["string", '\"Error 42\"'], + ["path", "C:\\demo\\config.json"], + ["path", "/tmp/config.json"], + ["error", "Error"], + ["warn", "warning"], + ["ok", "success"], + ["number", "true"], + ["muted", "abcdef0123456789"], + ["flag", "--verbose"], + ["number", "42"], + ] as const; + const source = tokens.map(([, token]) => token).join(" "); + const expected = tokens.map(([kind, token]) => paint(highlightColors[kind], token)).join(" "); + expect(createHighlighter()(source)).toBe(expected); + }); + test("handles Windows PowerShell ConPTY output with reset and native colour in the same read", async () => { // Captured from portable-pty / ConPTY with Write-Output and Write-Host. const highlighter = createHighlighter(); diff --git a/src/lib/highlight.ts b/src/lib/highlight.ts index 0312a672..f22eed18 100644 --- a/src/lib/highlight.ts +++ b/src/lib/highlight.ts @@ -124,18 +124,22 @@ function highlightLine(line: string): string { } TOKENS.lastIndex = 0; - const painted = body.replace(TOKENS, (match, ...rest) => { - const groups = rest[rest.length - 1] as Record; - for (const kind of Object.keys(groups)) { - if (groups[kind] === undefined) continue; - // Group names carry the colour. `winpath` aliases `path`, and a trailing - // digit (`number1`) marks an extra alternative sharing an earlier colour — - // regexes reject duplicate group names, so the suffix is how they coexist. - const key = (kind === "winpath" ? "path" : kind.replace(/\d+$/, "")) as - keyof typeof highlightColors; - return paint(key, match); - } - return match; + // Captures follow TOKENS' alternatives. Reading them directly avoids a + // rest array, Object.keys and alias regex for every token in build output. + const painted = body.replace(TOKENS, ( + match, url, quoted, winpath, path, error, warn, ok, literal, muted, flag, number, + ) => { + const kind = url !== undefined ? "url" + : quoted !== undefined ? "string" + : winpath !== undefined || path !== undefined ? "path" + : error !== undefined ? "error" + : warn !== undefined ? "warn" + : ok !== undefined ? "ok" + : literal !== undefined || number !== undefined ? "number" + : muted !== undefined ? "muted" + : flag !== undefined ? "flag" + : null; + return kind === null ? match : paint(kind, match); }); return head + painted; @@ -224,6 +228,13 @@ export function createHighlighter() { return function process(chunk: string, enabled = true): string { if (!chunk) return chunk; + // Most reads contain only text. With no unfinished escape, no styles or + // screen modes can change, so skip span objects and both scanning passes. + if (!pending && !chunk.includes(ESC)) { + return enabled && !ownsScreen() && styles.size === 0 && !/\r(?!\n)|\x08/.test(chunk) + ? highlightText(chunk) + : chunk; + } const spans: { text: string; sequence?: string }[] = []; let at = 0; if (pending) { diff --git a/src/lib/mobileWorkspace.test.ts b/src/lib/mobileWorkspace.test.ts index afd13fec..15ab14ef 100644 --- a/src/lib/mobileWorkspace.test.ts +++ b/src/lib/mobileWorkspace.test.ts @@ -50,6 +50,22 @@ describe("mobile workspace payload bounds", () => { expect(truncateUtf8Tail("too long", 1)).toBe(""); }); + test("preserves truncation output at every mixed Unicode byte boundary", () => { + const value = "A\u00e9\u6f22\ud83e\udd86Z"; + const prefixes = ["", "A", "A", "A\u00e9", "A\u00e9", "A\u00e9", "A\u00e9\u6f22", "A\u00e9\u6f22", "A\u00e9\u6f22", "A\u00e9\u6f22\u2026", "A\u00e9\u6f22\ud83e\udd86", value]; + const tails = ["", "", "", "", "\u2026\n", "\u2026\nZ", "\u2026\nZ", "\u2026\nZ", "\u2026\nZ", "\u2026\n\ud83e\udd86Z", "\u2026\n\ud83e\udd86Z", value]; + for (let budget = 0; budget < prefixes.length; budget += 1) { + expect(truncateUtf8(value, budget)).toBe(prefixes[budget]); + expect(truncateUtf8Tail(value, budget)).toBe(tails[budget]); + } + }); + + test("keeps the established handling of malformed UTF-16 text", () => { + expect(truncateUtf8("A\ud800\ud800ZZ", 7)).toBe("A\u2026"); + expect(truncateUtf8Tail("old output \udc00\udc00\udc00\u6f22\ud83e\udd86\u00e9\n\u00e9", 25)) + .toBe("\u2026\n\udc00\udc00\u6f22\ud83e\udd86\u00e9\n\u00e9"); + }); + test("fits the serialized snapshot, including JSON escaping", () => { const snapshot: MobileWorkspaceSnapshot = { projects: [{ diff --git a/src/lib/mobileWorkspace.ts b/src/lib/mobileWorkspace.ts index def38c01..5dbb8f94 100644 --- a/src/lib/mobileWorkspace.ts +++ b/src/lib/mobileWorkspace.ts @@ -44,23 +44,40 @@ export function utf8ByteLength(value: string): number { return encoder.encode(value).byteLength; } +/** UTF-8 width, including TextEncoder's replacement for unpaired surrogates. */ +function codePointBytes(value: string, index: number): number { + const code = value.charCodeAt(index); + if (code < 0x80) return 1; + if (code < 0x800) return 2; + if (code >= 0xd800 && code <= 0xdbff) { + const next = value.charCodeAt(index + 1); + if (next >= 0xdc00 && next <= 0xdfff) return 4; + } + return 3; +} + /** Truncate at a UTF-8 boundary without splitting a surrogate pair. */ export function truncateUtf8(value: string, maxBytes: number): string { if (maxBytes <= 0) return ""; - if (utf8ByteLength(value) <= maxBytes) return value; + let end = 0; + let bytes = 0; + while (end < value.length) { + const width = codePointBytes(value, end); + if (bytes + width > maxBytes) break; + bytes += width; + end += width === 4 ? 2 : 1; + } + if (end === value.length) return value; - const marker = "…"; - const markerBytes = utf8ByteLength(marker); - let end = Math.min(value.length, maxBytes); - while (end > 0 && utf8ByteLength(value.slice(0, end)) > maxBytes) end -= 1; - while (end > 0 && end < value.length && /[\uD800-\uDBFF]/.test(value[end - 1])) { + // Keep the same boundary behavior for a dangling high surrogate. + while (end > 0) { + const last = value.charCodeAt(end - 1); + if (last < 0xd800 || last > 0xdbff) break; end -= 1; + bytes -= 3; } - const prefix = value.slice(0, end); - return markerBytes <= maxBytes && utf8ByteLength(prefix) + markerBytes <= maxBytes - ? `${prefix}${marker}` - : prefix; + return bytes + 3 <= maxBytes ? `${prefix}\u2026` : prefix; } /** Keep the newest terminal screen/scrollback without cutting UTF-8 text. */ @@ -69,15 +86,23 @@ export function truncateUtf8Tail(value: string, maxBytes: number): string { if (utf8ByteLength(value) <= maxBytes) return value; const marker = "\u2026\n"; - const markerBytes = utf8ByteLength(marker); + const markerBytes = 4; if (maxBytes < markerBytes) return ""; const contentBudget = maxBytes - markerBytes; - let start = Math.max(0, value.length - contentBudget); - while (start < value.length && utf8ByteLength(value.slice(start)) > contentBudget) { - start += 1; + let start = value.length; + let bytes = 0; + while (start > 0) { + const index = start - 1; + // When extending a suffix across a surrogate pair, its low surrogate + // already contributed three replacement bytes. The complete pair adds one. + const pointBytes = codePointBytes(value, index); + const width = pointBytes === 4 ? 1 : pointBytes; + if (bytes + width > contentBudget) break; + bytes += width; + start = index; } - // A JS index can land between a UTF-16 surrogate pair. - if (start < value.length && /[\uDC00-\uDFFF]/.test(value[start] ?? "")) start += 1; + const first = value.charCodeAt(start); + if (first >= 0xdc00 && first <= 0xdfff) start += 1; return `${marker}${value.slice(start)}`; } diff --git a/src/lib/terminals.ts b/src/lib/terminals.ts index 453f31e4..b2cdebd8 100644 --- a/src/lib/terminals.ts +++ b/src/lib/terminals.ts @@ -980,7 +980,7 @@ function paintVisualCursor(session: Session): void { * status/footer position. */ function scheduleVisualCursor(session: Session, forceSettle = false): void { - if (!session.cursorFocused || !session.container || session.agentUi) { + if (session.editorMode || !session.cursorFocused || !session.container || session.agentUi) { hideVisualCursor(session); return; } @@ -1969,15 +1969,20 @@ export function dispose(id: string): void { const session = sessions.get(id); if (!session) return; session.observer?.disconnect(); + session.observer = null; + session.container = null; session.frames.dispose(); session.blocks.dispose(); clearTyping(session); hideVisualCursor(session); inputFocusers.delete(id); inputPasters.delete(id); - if (session.agent && TAURI_RUNTIME) void agentUnwatch(id); + unbindAgent(session); agentSessions.stop(id); for (const off of session.unlisten) off(); + // Tauri retains the channel callback until the native PTY closes. Replace + // its closure so late output cannot retain or write to a disposed terminal. + if (session.dataChannel) session.dataChannel.onmessage = () => {}; session.dataChannel = null; if (TAURI_RUNTIME) void ptyKill(id); session.term.dispose(); diff --git a/tools/cliproxy-dashboard/codex-transport-integration.py b/tools/cliproxy-dashboard/codex-transport-integration.py index 0cbf719a..4280d0fd 100644 --- a/tools/cliproxy-dashboard/codex-transport-integration.py +++ b/tools/cliproxy-dashboard/codex-transport-integration.py @@ -25,6 +25,10 @@ pending_requests = queue.Queue() requests = [] shutdown = threading.Event() +fixture_usage = {"input_tokens": 1000, "input_tokens_details": {"cached_tokens": 800}, + "output_tokens": 100, "output_tokens_details": {"reasoning_tokens": 40}, "total_tokens": 1100} +fixture_last = {"inputTokens": 1000, "cachedInputTokens": 800, "cacheWriteInputTokens": 0, + "outputTokens": 100, "reasoningOutputTokens": 40, "totalTokens": 1100} class Upstream(BaseHTTPRequestHandler): @@ -45,7 +49,7 @@ def do_POST(self): item = {"id": "fixture-message-" + str(len(requests)), "type": "message", "role": "assistant", "status": "completed", "content": [{"type": "output_text", "text": "HTTP_STREAM_OK", "annotations": []}]} response = {"id": response_id, "object": "response", "status": "completed", "model": "gpt-6.1-sol", "output": [item], - "usage": {"input_tokens": 1, "output_tokens": 1, "total_tokens": 2}} + "usage": fixture_usage} self.send_response(200) self.send_header("Content-Type", "text/event-stream") self.end_headers() @@ -55,7 +59,7 @@ def emit(frame): self.wfile.flush() try: - emit({"type": "response.created", "response": {**response, "status": "in_progress", "output": []}}) + emit({"type": "response.created", "response": {**response, "status": "in_progress", "output": [], "usage": None}}) if "WAIT_FOR_CONTROL" in text and "FINISH_HTTP" not in text: release = threading.Event() pending_requests.put(release) @@ -220,10 +224,33 @@ def png_chunk(kind, data): assert requests and all("websocket" not in request for request in requests), requests if large_history: assert max(request["bytes"] for request in requests) > 28 * 1024 * 1024, "Large history was not sent to the provider: " + json.dumps(requests) + # Verify the real proxy preserves the Responses subsets and the installed + # app-server exposes inclusive input/output both live and in its rollouts. + usage_frames = [f["params"]["tokenUsage"] for f in frames if f.get("method") == "thread/tokenUsage/updated"] + assert usage_frames, "Codex did not report token usage" + for usage in usage_frames: + assert usage["last"] == fixture_last, usage + total = usage["total"] + calls = total["inputTokens"] // 1000 + assert calls > 0 and total == {k: v * calls for k, v in fixture_last.items()}, usage + rollout_events = [] + for path in (codex_home / "sessions").rglob("*.jsonl"): + for line in path.read_text(encoding="utf-8").splitlines(): + if '"token_count"' not in line: + continue + info = json.loads(line).get("payload", {}).get("info") + if info: + rollout_events.append(info) + assert rollout_events, "Codex did not persist usage" + for info in rollout_events: + last = info["last_token_usage"] + assert last["input_tokens"] == 1000 and last["cached_input_tokens"] == 800, info + assert last["output_tokens"] == 100 and last["reasoning_output_tokens"] == 40, info + assert last["total_tokens"] == 1100, info assert all(frame.get("method") != "error" for frame in frames), frames print(json.dumps({"codexHttpStreaming": "passed", "reloadExistingThread": "not-run" if large_history else "passed", "largeHistory": "passed" if large_history else "not-run", "steering": "passed", - "interrupt": "passed", "continueAfterInterrupt": "passed", "providerRequests": len(requests), + "interrupt": "passed", "continueAfterInterrupt": "passed", "usagePreserved": "passed", "providerRequests": len(requests), "largestRequestBytes": max(request.get("bytes", 0) for request in requests), "codexBinary": str(codex_binary)})) finally: From eeeaf0516b42e4ab1204e5676a4b041cf86890f7 Mon Sep 17 00:00:00 2001 From: Jubarte Date: Sat, 10 Oct 2026 08:16:26 -0300 Subject: [PATCH 2/4] feat: update --- .../agent/official/ChatGPTExperience.tsx | 4 +- .../agent/official/OfficialShared.test.tsx | 13 ++++ src/lib/agents/adapters/codex.test.ts | 73 +++++++++++++++++++ src/lib/agents/adapters/codex.ts | 51 ++++++++++++- 4 files changed, 139 insertions(+), 2 deletions(-) diff --git a/src/components/agent/official/ChatGPTExperience.tsx b/src/components/agent/official/ChatGPTExperience.tsx index 5287c8d8..68331ada 100644 --- a/src/components/agent/official/ChatGPTExperience.tsx +++ b/src/components/agent/official/ChatGPTExperience.tsx @@ -57,6 +57,7 @@ export function ChatGPTExperience(props: ExperienceProps) { const hasActivityAfterPrompt = transcriptItems.slice(latestUserIndex + 1).some((item) => { if (item.kind === "user") return false; if (item.kind === "assistant") return Boolean(item.text.trim()); + if (item.kind === "notice" && item.tone === "error") return true; return item.kind === "thinking" || item.kind === "tool" || item.kind === "plan"; }); const needsEmptyLiveTrace = @@ -69,7 +70,8 @@ export function ChatGPTExperience(props: ExperienceProps) { if ( item.kind === "assistant" || item.kind === "thinking" || - item.kind === "tool" + item.kind === "tool" || + (item.kind === "notice" && item.tone === "error") ) { latestLiveContent = item; break; diff --git a/src/components/agent/official/OfficialShared.test.tsx b/src/components/agent/official/OfficialShared.test.tsx index d6fcd0c1..9d3bd641 100644 --- a/src/components/agent/official/OfficialShared.test.tsx +++ b/src/components/agent/official/OfficialShared.test.tsx @@ -131,6 +131,19 @@ describe("official agent presentation", () => { resetPreparingMessageAssignmentsForTests(); }); + test("shows Codex retry errors instead of an empty Thinking placeholder", () => { + const prompt: AgentItem = { kind: "user", id: "prompt", at: 1, text: "Investigate the issue" }; + const error: AgentItem = { kind: "notice", id: "retry", at: 3, tone: "error", + text: "Reconnecting... 1/5\n429 Too Many Requests. Waiting for the provider's retry deadline." }; + for (const previous of [[], [{ kind: "assistant", id: "update", at: 2, + text: "I will investigate. ".repeat(20), streaming: false } as AgentItem]]) { + const html = renderAgentActivity("codex", [prompt, ...previous, error]); + expect(html).toContain("429 Too Many Requests"); + expect(html).not.toContain('role="timer"'); + expect(html).not.toContain("agent-activity-pulse"); + } + }); + test("formats the live activity clock without noisy milliseconds", () => { expect(formatActivityElapsed(-1)).toBe("0s"); expect(formatActivityElapsed(59_999)).toBe("59s"); diff --git a/src/lib/agents/adapters/codex.test.ts b/src/lib/agents/adapters/codex.test.ts index e823c618..40fdd7a5 100644 --- a/src/lib/agents/adapters/codex.test.ts +++ b/src/lib/agents/adapters/codex.test.ts @@ -247,6 +247,79 @@ describe("codex adapter", () => { expect(h.state().items.at(-1)).toMatchObject({ kind: "notice", text: expect.stringContaining("401 Unauthorized") }); }); + test("shows a retrying 429 without losing the active turn's Stop target", async () => { + const h = harness(); + await h.handshake(); + h.adapter.prompt({ text: "hello", images: [] }, h.ctx); + h.notify("turn/started", { threadId: "thread_1", turn: { id: "limited-turn", status: "inProgress" } }); + h.notify("error", { + threadId: "thread_1", turnId: "limited-turn", willRetry: true, + error: { message: "Reconnecting... 1/5", additionalDetails: "Exceeded retry limit, last status: 429 Too Many Requests" }, + }); + expect(h.state()).toMatchObject({ status: "working", authenticationRequired: false }); + expect(h.state().items.at(-1)).toMatchObject({ kind: "notice", tone: "error", + text: expect.stringContaining("waiting for the provider's retry deadline") }); + expect(h.state().items.at(-1)).toMatchObject({ text: expect.stringContaining("429 Too Many Requests") }); + h.adapter.interrupt(h.ctx); + const stop = h.sent.findLast((frame) => frame.method === "turn/interrupt")!; + expect(stop).toMatchObject({ params: { threadId: "thread_1", turnId: "limited-turn" } }); + h.feed({ id: stop.id, result: {} }); + const clean = h.sent.findLast((frame) => frame.method === "thread/backgroundTerminals/clean")!; + h.feed({ id: clean.id, result: {} }); + for (let step = 0; step < 8; step += 1) await Promise.resolve(); + expect(h.state().status).toBe("idle"); + await h.adapter.dispose?.(h.ctx, { preserveWork: true }); + }); + + test("settles a non-retrying error even without a turn/completed notification", async () => { + const h = harness(); + await h.handshake(); + h.adapter.prompt({ text: "hello", images: [] }, h.ctx); + h.notify("turn/started", { threadId: "thread_1", turn: { id: "failed-turn", status: "inProgress" } }); + h.notify("error", { threadId: "thread_1", turnId: "failed-turn", willRetry: false, + error: { message: "The usage limit has been reached" } }); + expect(h.state().status).toBe("idle"); + expect(h.state().items.at(-1)).toMatchObject({ kind: "notice", tone: "error", text: "The usage limit has been reached" }); + // Delayed provider output cannot revive a definitively failed turn. + h.notify("item/agentMessage/delta", { threadId: "thread_1", turnId: "failed-turn", itemId: "late", delta: "Late output" }); + expect(h.state().items.some((item) => item.kind === "assistant")).toBe(false); + await h.adapter.dispose?.(h.ctx, { preserveWork: true }); + }); + + test("ignores retry errors belonging to another thread or a retired turn", async () => { + const h = harness(); + await h.handshake(); + h.notify("turn/started", { threadId: "thread_1", turn: { id: "old-turn", status: "inProgress" } }); + h.notify("turn/completed", { threadId: "thread_1", turn: { id: "old-turn", status: "completed" } }); + h.notify("turn/started", { threadId: "thread_1", turn: { id: "new-turn", status: "inProgress" } }); + const before = h.events.length; + for (const [threadId, turnId] of [["foreign-thread", "foreign-turn"], ["thread_1", "old-turn"]]) { + h.notify("error", { threadId, turnId, willRetry: false, error: { message: "429 Too Many Requests" } }); + } + expect(h.events).toHaveLength(before); + expect(h.state().status).toBe("working"); + await h.adapter.dispose?.(h.ctx, { preserveWork: true }); + }); + + test("routes a child's retry notice into its transcript without settling the parent", async () => { + const h = harness(); + await h.handshake(); + h.notify("turn/started", { threadId: "thread_1", turn: { id: "parent-turn", status: "inProgress" } }); + h.notify("item/started", { threadId: "thread_1", item: { + id: "child-activity", type: "subAgentActivity", kind: "started", + agentThreadId: "limited-child", agentPath: "/root/reviewer", + } }); + h.notify("turn/started", { threadId: "limited-child", turn: { id: "child-turn", status: "inProgress" } }); + h.notify("error", { threadId: "limited-child", turnId: "child-turn", willRetry: true, + error: { message: "Reconnecting... 1/5", codexErrorInfo: { httpConnectionFailed: { httpStatusCode: 429 } } } }); + const tool = h.state().items.find((item) => item.kind === "tool" && item.subagent?.threadId === "limited-child"); + expect(tool?.kind === "tool" && tool.subagent?.items?.at(-1)).toMatchObject({ kind: "notice", tone: "error", + text: expect.stringContaining("waiting for the provider's retry deadline") }); + expect(h.state().status).toBe("working"); + expect(h.state().items.some((item) => item.kind === "notice")).toBe(false); + await h.adapter.dispose?.(h.ctx, { preserveWork: true }); + }); + test("adopts a CLI login after starting signed out without reinitializing", async () => { const h = harness(); h.adapter.start(h.ctx); diff --git a/src/lib/agents/adapters/codex.ts b/src/lib/agents/adapters/codex.ts index fb6f5afd..53b28a62 100644 --- a/src/lib/agents/adapters/codex.ts +++ b/src/lib/agents/adapters/codex.ts @@ -193,6 +193,20 @@ function numberOr(value: unknown, fallback: number): number { return typeof value === "number" && Number.isFinite(value) ? value : fallback; } +function codexErrorDetail(params: Record): string { + const error = asRecord(params.error); + const detail = [asString(error?.message), asString(error?.additionalDetails)] + .filter((part): part is string => Boolean(part)) + .join("\n"); + const info = asRecord(error?.codexErrorInfo); + const rateLimited = /\b429\b|\brate.?limit|\bmodel_cooldown\b/i.test(detail) || + error?.codexErrorInfo === "usageLimitExceeded" || + Object.values(info ?? {}).some((value) => asRecord(value)?.httpStatusCode === 429); + return params.willRetry === true && rateLimited + ? `${detail || "Codex is rate limited (HTTP 429)."}\nCodex is waiting for the provider's retry deadline. Stop this turn to cancel the wait.` + : detail || "Codex reported an error."; +} + function readGoal(value: unknown): CodexGoal | null { const goal = asRecord(value); const objective = asString(goal?.objective); @@ -1366,6 +1380,16 @@ export function createCodexAdapter(options: CodexAdapterOptions = {}): AgentAdap const side = sideThreads.get(sideThreadId); if (!side) return; switch (method) { + case "error": { + const detail = codexErrorDetail(params); + if (params.willRetry === true) { + side.sideQuestion.answer = detail; + emitSide(side, ctx); + } else { + finishSide(sideThreadId, "error", ctx, detail); + } + return; + } case "turn/started": side.currentTurnId = asString(asRecord(params.turn)?.id); return; @@ -2321,6 +2345,19 @@ export function createCodexAdapter(options: CodexAdapterOptions = {}): AgentAdap rootTurnStatusConfirmed = true; } switch (method) { + case "error": { + if (stoppedByUser || rootTurnSignalIsStale(notificationTurnId)) return; + ctx.emit({ type: "notice", tone: "error", text: codexErrorDetail(params) }); + // Retry-After can be hours. Keep Stop and steering attached to the + // provider-owned turn, but make its wait visible instead of hiding it. + if (params.willRetry === false) { + rememberRootTurnFinished(notificationTurnId ?? currentTurnId); + cancelPendingRootCompletion(); + settleRootTurn(notificationTurnId); + ctx.emit({ type: "turn-end" }); + } + return; + } case "thread/goal/updated": { const goal = readGoal(params.goal); currentGoal = goal; @@ -2592,6 +2629,18 @@ export function createCodexAdapter(options: CodexAdapterOptions = {}): AgentAdap ): void { const nested = childContext(childThreadId, ctx); switch (method) { + case "error": { + const child = childFor(childThreadId); + const errorTurnId = asString(params.turnId); + if (errorTurnId && child.currentTurnId && errorTurnId !== child.currentTurnId) return; + const detail = codexErrorDetail(params); + emitChild(childThreadId, { type: "notice", tone: "error", text: detail }, ctx); + if (params.willRetry === false) { + child.currentTurnId = null; + emitChild(childThreadId, { type: "status", status: "error", error: detail }, ctx); + } + return; + } case "thread/started": { const thread = asRecord(params.thread); if (thread) adoptChildThread(thread, ctx, false, true); @@ -3489,8 +3538,8 @@ export function createCodexAdapter(options: CodexAdapterOptions = {}): AgentAdap accountSignedIn = cachedSignedIn; ctx.emit({ type: "notice", tone: "error", text: detail || "Codex authentication failed." }); ctx.emit({ type: "status", status: "idle" }); + return; } - return; } if (frame.id !== undefined) { From 1c8ec877223f04f1bee5c350536f0e1fe62b91dd Mon Sep 17 00:00:00 2001 From: Jubarte Date: Sat, 10 Oct 2026 10:05:14 -0300 Subject: [PATCH 3/4] fix: prevent Codex proxy pipe deadlocks during WebSocket frame handling --- src-tauri/src/codex_transport.rs | 152 ++++++++++++++++++++++++++++--- src/lib/agents/session.test.ts | 58 ++++++++++++ src/lib/agents/session.ts | 8 +- 3 files changed, 202 insertions(+), 16 deletions(-) diff --git a/src-tauri/src/codex_transport.rs b/src-tauri/src/codex_transport.rs index ab854ea5..06603c5e 100644 --- a/src-tauri/src/codex_transport.rs +++ b/src-tauri/src/codex_transport.rs @@ -6,7 +6,7 @@ use base64::Engine; use sha2::{Digest, Sha256}; use std::io::{self, Read, Write}; use std::path::{Path, PathBuf}; -use std::process::{Child, ChildStdin, ChildStdout, Output, Stdio}; +use std::process::{Child, ChildStdout, Output, Stdio}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{mpsc, Arc, Mutex}; use std::time::Duration; @@ -16,14 +16,60 @@ use tungstenite::{Message, WebSocket}; use crate::agent_proc::{build_command, hide_console, kill_tree, AgentSpawnOptions}; -type SharedStdin = Arc>; +struct PipeWrite { + bytes: Vec, + done: Option>>, +} + +/// The reader must keep draining stdout even while stdin is full. In +/// particular, tungstenite writes automatic Pong/Close frames from read(). +/// Sharing a blocking stdin mutex with it creates a duplex pipe deadlock. +/// One worker serializes whole writes; only application sends await delivery. +#[derive(Clone)] +struct ProxyInput(mpsc::SyncSender); + +impl ProxyInput { + fn new(mut stdin: impl Write + Send + 'static) -> io::Result { + let (send, receive) = mpsc::sync_channel::(16); + std::thread::Builder::new().name("codex-proxy-input".into()).spawn(move || { + while let Ok(write) = receive.recv() { + let result = stdin.write_all(&write.bytes).and_then(|()| stdin.flush()); + let failed = result.is_err(); + if let Some(done) = write.done { let _ = done.send(result); } + if failed { break; } + } + })?; + Ok(Self(send)) + } + + fn control(&self, bytes: &[u8]) -> io::Result { + self.0.try_send(PipeWrite { bytes: bytes.to_vec(), done: None }) + .map_err(|error| match error { + mpsc::TrySendError::Full(_) => io::Error::new(io::ErrorKind::ConnectionAborted, "Codex proxy control queue is full"), + mpsc::TrySendError::Disconnected(_) => Self::closed(), + })?; + Ok(bytes.len()) + } + + fn write(&self, bytes: &[u8]) -> io::Result { + let (done, wait) = mpsc::channel(); + self.0.send(PipeWrite { bytes: bytes.to_vec(), done: Some(done) }) + .map_err(|_| Self::closed())?; + wait.recv().map_err(|_| Self::closed())??; + Ok(bytes.len()) + } + + fn closed() -> io::Error { + io::Error::new(io::ErrorKind::BrokenPipe, "Codex proxy input closed") + } +} pub struct ProxyReader { stdout: ChildStdout, - stdin: SharedStdin, + stdin: ProxyInput, } -pub struct ProxyWriter(SharedStdin); +pub struct ProxyWriter(ProxyInput); impl Read for ProxyReader { fn read(&mut self, bytes: &mut [u8]) -> io::Result { @@ -42,21 +88,21 @@ impl Read for ProxyWriter { impl Write for ProxyReader { fn write(&mut self, bytes: &[u8]) -> io::Result { - self.stdin.lock().unwrap().write_all(bytes)?; - Ok(bytes.len()) + self.stdin.control(bytes) } fn flush(&mut self) -> io::Result<()> { - self.stdin.lock().unwrap().flush() + // Control frames are flushed by the worker without blocking reads. + Ok(()) } } impl Write for ProxyWriter { fn write(&mut self, bytes: &[u8]) -> io::Result { - self.0.lock().unwrap().write_all(bytes)?; - Ok(bytes.len()) + self.0.write(bytes) } fn flush(&mut self) -> io::Result<()> { - self.0.lock().unwrap().flush() + // write() already waits for the worker's flush. + Ok(()) } } @@ -527,9 +573,14 @@ fn proxy_connection( command.process_group(0); } let mut child = command.spawn().map_err(|error| error.to_string())?; - let stdin = Arc::new(Mutex::new( - child.stdin.take().ok_or("Codex proxy has no stdin")?, - )); + let stdin = match ProxyInput::new(child.stdin.take().ok_or("Codex proxy has no stdin")?) { + Ok(stdin) => stdin, + Err(error) => { + kill_tree(child.id()); + let _ = child.wait(); + return Err(format!("Could not start Codex proxy input: {error}")); + } + }; let stream = ProxyReader { stdout: child.stdout.take().ok_or("Codex proxy has no stdout")?, stdin: stdin.clone(), @@ -606,6 +657,81 @@ pub fn oversized_reply(line: &str, limit: usize) -> Option { mod tests { use super::*; + struct BlockedPipe { + entered: mpsc::Sender<()>, + release: mpsc::Receiver<()>, + writes: Arc>>>, + fail: bool, + } + + impl Write for BlockedPipe { + fn write(&mut self, bytes: &[u8]) -> io::Result { + if self.writes.lock().unwrap().is_empty() { + let _ = self.entered.send(()); + self.release.recv().unwrap(); + } + if self.fail { return Err(io::Error::new(io::ErrorKind::BrokenPipe, "fixture closed")); } + self.writes.lock().unwrap().push(bytes.to_vec()); + Ok(bytes.len()) + } + fn flush(&mut self) -> io::Result<()> { Ok(()) } + } + + #[test] + fn control_frames_do_not_block_on_full_stdin_and_writes_stay_ordered() { + let (entered, wait) = mpsc::channel(); + let (release, blocked) = mpsc::channel(); + let writes = Arc::new(Mutex::new(Vec::new())); + let input = ProxyInput::new(BlockedPipe { entered, release: blocked, writes: writes.clone(), fail: false }).unwrap(); + let sender = input.clone(); + let (done, sent) = mpsc::channel(); + let writer = std::thread::spawn(move || { done.send(sender.write(b"application frame")).unwrap(); }); + wait.recv_timeout(Duration::from_secs(2)).unwrap(); + // This is what tungstenite's reader does when a Ping arrives while an + // application frame is blocked. The read loop must remain free to read. + let reader_input = input.clone(); + let (done, control) = mpsc::channel(); + let reader = std::thread::spawn(move || { done.send(reader_input.control(b"pong frame")).unwrap(); }); + let control_result = control.recv_timeout(Duration::from_secs(2)); + let application_pending = sent.try_recv().is_err(); + release.send(()).unwrap(); + writer.join().unwrap(); + reader.join().unwrap(); + input.write(b"barrier").unwrap(); + assert!(control_result.unwrap().is_ok(), "a control frame blocked the reader"); + assert!(application_pending, "application delivery was acknowledged before writing"); + assert_eq!(*writes.lock().unwrap(), vec![b"application frame".to_vec(), b"pong frame".to_vec(), b"barrier".to_vec()]); + } + + #[test] + fn control_backlog_is_bounded_without_waiting_for_stdin() { + let (entered, wait) = mpsc::channel(); + let (release, blocked) = mpsc::channel(); + let input = ProxyInput::new(BlockedPipe { entered, release: blocked, + writes: Arc::new(Mutex::new(Vec::new())), fail: false }).unwrap(); + input.control(b"first").unwrap(); + wait.recv_timeout(Duration::from_secs(2)).unwrap(); + for _ in 0..16 { input.control(b"pong").unwrap(); } + let result = input.control(b"overflow"); + release.send(()).unwrap(); + assert_eq!(result.unwrap_err().kind(), io::ErrorKind::ConnectionAborted); + } + + #[test] + fn a_failed_pipe_rejects_application_delivery_and_future_control_frames() { + let (entered, wait) = mpsc::channel(); + let (release, blocked) = mpsc::channel(); + let input = ProxyInput::new(BlockedPipe { entered, release: blocked, + writes: Arc::new(Mutex::new(Vec::new())), fail: true }).unwrap(); + let sender = input.clone(); + let writer = std::thread::spawn(move || sender.write(b"application")); + wait.recv_timeout(Duration::from_secs(2)).unwrap(); + release.send(()).unwrap(); + assert_eq!(writer.join().unwrap().unwrap_err().kind(), io::ErrorKind::BrokenPipe); + assert_eq!(input.write(b"later").unwrap_err().kind(), io::ErrorKind::BrokenPipe); + assert_eq!(input.control(b"pong").unwrap_err().kind(), io::ErrorKind::BrokenPipe); + } + /// An isolated child process for command capture and timeout tests. #[test] #[ignore] diff --git a/src/lib/agents/session.test.ts b/src/lib/agents/session.test.ts index b9e0d7f5..7965f495 100644 --- a/src/lib/agents/session.test.ts +++ b/src/lib/agents/session.test.ts @@ -262,6 +262,64 @@ describe("Custom agent UI sessions", () => { expect(session.get(termId)?.status).toBe("idle"); }); + test.each(["turn/start", "thread/backgroundTerminals/list"])("a stalled %s reconnects without interrupting work or replaying input", async (method) => { + const termId = `stalled-${method}`; + const threadId = "stalled-thread"; + await session.start(termId, codexLaunch, "H:/project"); + await codexHandshake(threadId); + if (method !== "turn/start") { + session.submit(termId, "Accepted work"); + await flush(); + const turn = sent.map(rpc).findLast((message) => message.method === "turn/start"); + feed({ id: turn?.id, result: { turn: { id: "live-turn" } } }); + await flush(); + } + const oldSink = frameSink; + let release!: () => void; + const blocked = new Promise((resolve) => { release = resolve; }); + sendOverride = async (line) => { if (rpc(line).method === method) await blocked; }; + // Fire the real writer watchdog deterministically, without a ten-second sleep. + const originalTimeout = globalThis.setTimeout; + let expire!: () => void; + globalThis.setTimeout = ((callback: () => void, delay?: number, ...args: unknown[]) => { + if (delay === 10_000) expire = callback; + return originalTimeout(callback, delay, ...args); + }) as typeof setTimeout; + try { + if (method === "turn/start") session.submit(termId, "Unconfirmed prompt", [image]); + else void session.refreshTasks(termId); + await flush(); + } finally { globalThis.setTimeout = originalTimeout; } + expect(expire).toBeDefined(); + session.setDraft(termId, "Newer draft"); + expire(); + await flush(); + expect(session.get(termId)?.status).toBe("starting"); + oldSink?.({ kind: "exit", code: 1, reconnect: true }); + sendOverride = null; + await new Promise((resolve) => setTimeout(resolve, 10)); + await codexHandshake("provisional-thread"); + const resume = sent.map(rpc).findLast((message) => message.method === "thread/resume"); + expect(resume?.params).toMatchObject({ threadId }); + feed({ id: resume?.id, result: { thread: { id: threadId, + activeTurnId: "live-turn", turns: [{ id: "live-turn", status: "inProgress", items: [] }], + } } }); + await flush(); + release(); + await flush(); + oldSink?.({ kind: "exit", code: 1, reconnect: true }); + expect(session.get(termId)?.status).toBe("working"); + expect(session.get(termId)?.error).toBeNull(); + expect(session.getDraft(termId)).toBe("Newer draft"); + if (method === "turn/start") { + expect(session.get(termId)?.pending).toEqual([expect.objectContaining({ text: "Unconfirmed prompt", images: [image] })]); + } + expect(sent.map(rpc).filter((message) => message.method === "turn/start")).toHaveLength(1); + expect(sent.map(rpc).some((message) => ["turn/interrupt", "thread/goal/set", "thread/backgroundTerminals/clean"].includes(String(message.method)))).toBe(false); + expect(nativeStopModes.length).toBeGreaterThan(0); + expect(nativeStopModes.every(Boolean)).toBe(true); + }); + test("a pipe closing during initialization cannot leave a stale fatal state", async () => { const termId = "initialization-pipe-loss"; let failed = false; diff --git a/src/lib/agents/session.ts b/src/lib/agents/session.ts index 08451a46..65c6ab06 100644 --- a/src/lib/agents/session.ts +++ b/src/lib/agents/session.ts @@ -1240,9 +1240,11 @@ export async function start( (error) => { if (session.disposed) return; session.queuePaused = true; - if (error.code === "duckweed_connection_closed" && reconnect(session)) return; - failConnection(session, error.message, - session.state.agent === "codex" && error.code === "duckweed_connection_closed"); + // A write deadline means delivery is uncertain, not that the daemon's + // work failed. Replace only the proxy, just as for a closed pipe. + const recoverable = error.code === "duckweed_connection_closed" || error.code === "duckweed_send_timeout"; + if (recoverable && reconnect(session)) return; + failConnection(session, error.message, session.state.agent === "codex"); }, ); const session: Session = { From d4dc23f0113278f3f033d9874c71f158fc7ac804 Mon Sep 17 00:00:00 2001 From: Jubarte Date: Sat, 10 Oct 2026 10:31:42 -0300 Subject: [PATCH 4/4] fix: suppress resume notices during automatic session recovery --- src/lib/agents/events.test.ts | 10 ++++++++++ src/lib/agents/events.ts | 4 ++-- src/lib/agents/session.test.ts | 5 +++++ src/lib/agents/session.ts | 15 ++++++++------- 4 files changed, 25 insertions(+), 9 deletions(-) diff --git a/src/lib/agents/events.test.ts b/src/lib/agents/events.test.ts index 7399bd11..058606c1 100644 --- a/src/lib/agents/events.test.ts +++ b/src/lib/agents/events.test.ts @@ -269,6 +269,16 @@ describe("resumed", () => { ]); }); + test("automatic recovery adopts the resumed session without adding a notice", () => { + const before = blank(); + const state = applyEvent(before, { + type: "resumed", sessionId: "abc-123", title: "", silent: true, + }); + expect(state.sessionId).toBe("abc-123"); + expect(state.started).toBe(true); + expect(state.items).toBe(before.items); + }); + test("still says something when the store had no title", () => { const state = applyEvent(blank(), { type: "resumed", sessionId: "abc-123", title: "" }); expect(state.items[0]).toMatchObject({ diff --git a/src/lib/agents/events.ts b/src/lib/agents/events.ts index 94aca3d4..87d332c1 100644 --- a/src/lib/agents/events.ts +++ b/src/lib/agents/events.ts @@ -117,7 +117,7 @@ export type AgentEvent = * reads as a continuation rather than a first turn, whether or not the * agent could replay the history behind it. */ - | { type: "resumed"; sessionId: string; title: string } + | { type: "resumed"; sessionId: string; title: string; silent?: boolean } | { type: "permission"; permission: AgentPermission | null } | { type: "extensions"; @@ -696,7 +696,7 @@ function reduceEvent(state: AgentSessionState, event: AgentEvent): AgentSessionS // The empty state offers to start something new; a resumed pane is // the opposite of that even before its first reply lands. started: true, - items: [ + items: event.silent ? state.items : [ ...state.items, { kind: "notice", diff --git a/src/lib/agents/session.test.ts b/src/lib/agents/session.test.ts index 7965f495..bd099a0e 100644 --- a/src/lib/agents/session.test.ts +++ b/src/lib/agents/session.test.ts @@ -255,6 +255,7 @@ describe("Custom agent UI sessions", () => { await flush(); expect(session.get(termId)?.status).toBe("idle"); expect(session.get(termId)?.error).toBeNull(); + expect(session.get(termId)?.items.filter((item) => item.kind === "notice")).toEqual([]); expect(session.getDraft(termId)).toBe("Keep this prompt"); expect(session.getDraftImages(termId)).toEqual([image]); expect(sent.map(rpc).filter((message) => message.method === "turn/start")).toHaveLength(1); @@ -382,6 +383,7 @@ describe("Custom agent UI sessions", () => { }] } } }); await flush(); expect(session.get(termId)?.status).toBe("working"); + expect(session.get(termId)?.items.filter((item) => item.kind === "notice")).toEqual([]); expect(session.getDraft(termId)).toBe("Newer draft"); expect(session.getDraftImages(termId)).toEqual([image]); expect(session.get(termId)?.pending).toEqual([expect.objectContaining({ text: "Queued follow-up", images: [image] })]); @@ -682,6 +684,9 @@ describe("Custom agent UI sessions", () => { })), } } }); await resuming; + expect(session.get(termId)?.items.filter((item) => item.kind === "notice")).toEqual([ + expect.objectContaining({ text: "Resumed the previous session" }), + ]); expect(session.localPromptHistory(termId)).toEqual([...new Set(prompts)]); } session.submit(termId, "New follow-up"); diff --git a/src/lib/agents/session.ts b/src/lib/agents/session.ts index 65c6ab06..71642828 100644 --- a/src/lib/agents/session.ts +++ b/src/lib/agents/session.ts @@ -115,7 +115,7 @@ interface Session { * user may have asked for it (`--continue`) before there was a protocol to * ask on. */ - pendingResume: { id: string; title: string } | null; + pendingResume: { id: string; title: string; silent?: boolean } | null; restoring: boolean; recovery: AgentRecovery | null; /** Coalesces streamed deltas into one notification per frame. */ @@ -976,7 +976,7 @@ function emitNow(session: Session, event: AgentEvent): void { const wanted = session.pendingResume; session.pendingResume = null; notify(session); - void applyResume(session, wanted.id, wanted.title); + void applyResume(session, wanted.id, wanted.title, wanted.silent); return; } @@ -1221,8 +1221,9 @@ export async function start( // the conversation the user meant to continue. It can also let an opening // prompt start on the blank thread before a late resume swaps `threadId` // underneath the active turn. - const startupResume: { id: string; title: string } | null = adapter.resume && launch.resumeId - ? { id: launch.resumeId, title: "" } + // Automatic recovery reattaches the existing conversation without a user resume notice. + const startupResume: Session["pendingResume"] = adapter.resume && launch.resumeId + ? { id: launch.resumeId, title: "", silent: !!recovery } : null; const startupResumeLookup = adapter.resume && launch.resume && !startupResume @@ -1403,7 +1404,7 @@ export async function start( await agentProcStop(termId).catch(() => {}); return null; } - if (found) session.pendingResume = { id: found.id, title: found.title }; + if (found) session.pendingResume = { id: found.id, title: found.title, silent: !!recovery }; } adapter.start(session.context); @@ -1869,7 +1870,7 @@ export function configure( * Hand a stored conversation to a running agent, in whatever way it accepts * one. Emits the transcript marker only once the agent has taken it. */ -async function applyResume(session: Session, sessionId: string, title: string): Promise { +async function applyResume(session: Session, sessionId: string, title: string, silent = false): Promise { cancelCapacityReply(session); // Rejoining is an explicit request. Idle history stays silent because its // working -> idle transition occurs under `loadingHistory`; a live turn @@ -1914,7 +1915,7 @@ async function applyResume(session: Session, sessionId: string, title: string): .map((entry) => ({ ...entry.prompt, id: entry.id })) }; session.restoring = false; session.recovery = null; - emit(session, { type: "resumed", sessionId, title }); + emit(session, { type: "resumed", sessionId, title, silent }); } else failed(); }