From 27f257a9d1a96f31f44ebcd337cbee733d18a637 Mon Sep 17 00:00:00 2001 From: wgqqqqq Date: Thu, 24 Sep 2026 16:28:50 +0800 Subject: [PATCH 1/5] feat(mobile): add persistent thread goals across remote clients Expose the host's persistent Goal to Android, iOS, and HarmonyOS through a negotiated `thread_goal_v1` capability, so a phone can read, start, edit, pause, resume, or clear the goal of the session it is driving. - host: add `RemoteCommand::ThreadGoal` / `RemoteResponse::ThreadGoal` plus `RemoteGoalAction`, route them through `RemoteCommandRuntimeHost` with an explicit unsupported default, and implement them in the core runtime host. Goal operations resolve the session's own workspace binding and storage; no controller path participates. - host: abandon a held interrupted turn when a goal is started, edited, or resumed, otherwise the goal's steering turn stays queued behind recovery. Re-delivering the same active objective no longer resets usage. - shared: add the goal command/state contract, capability gating, stale-response fencing, and a foreground snapshot refresh. - android/ios/harmonyos: add the goal strip, `/goal` composer commands, and platform strings, with a HarmonyOS preview and focused tests. Co-authored-by: OpenBitFun <318544290+bitfun-ai@users.noreply.github.com> --- src/apps/mobile/README.md | 37 ++ src/apps/mobile/android/AGENTS.md | 5 + .../mobile/app/ThreadGoalPanelTest.kt | 104 ++++ .../mobile/app/ui/chat/ComposerBar.kt | 29 +- .../mobile/app/ui/chat/ConversationView.kt | 3 + .../mobile/app/ui/chat/ThreadGoalPanel.kt | 122 ++++ .../app/src/main/res/values-zh/strings.xml | 36 ++ .../app/src/main/res/values/strings.xml | 36 ++ .../components/mobile-components.json | 7 + .../preview/generated/mobile-design-data.js | 34 ++ src/apps/mobile/harmonyos/AGENTS.md | 6 + src/apps/mobile/harmonyos/build-profile.json5 | 15 + .../main/ets/entryability/EntryAbility.ets | 6 +- .../entry/src/main/ets/i18n/EnUsMessages.ets | 36 ++ .../entry/src/main/ets/i18n/ZhCnMessages.ets | 36 ++ .../entry/src/main/ets/model/RemoteModels.ets | 2 + .../entry/src/main/ets/model/ThreadGoal.ets | 42 ++ .../ets/pages/actions/ConversationIntent.ets | 8 + .../actions/ConversationIntentDispatcher.ets | 5 + .../main/ets/pages/components/ComposerBar.ets | 64 +- .../ets/pages/components/ConversationView.ets | 5 + .../ets/pages/components/ThreadGoalPanel.ets | 152 +++++ .../ets/pages/preview/MobileDesignGallery.ets | 5 +- .../ets/pages/preview/ThreadGoalPreview.ets | 36 ++ .../main/ets/pages/runtime/AppRootRuntime.ets | 3 + .../runtime/AppRootRuntimeComposition.ets | 2 + .../ets/pages/state/ConversationViewState.ets | 3 + .../main/ets/pages/state/RemotePageState.ets | 2 + .../viewmodel/ConversationController.ets | 4 + .../pages/viewmodel/RemoteGoalController.ets | 88 +++ .../viewmodel/RemoteTranscriptController.ets | 16 + .../ets/services/RemoteSessionManager.ets | 15 + .../tools/tests/thread-goal.test.cjs | 90 +++ .../Features/Chat/ComposerBar.swift | 29 +- .../Features/Chat/ConversationHeader.swift | 137 +++++ .../MobileAppModel+RemoteSession.swift | 5 + .../Infrastructure/MobileAppModel.swift | 1 + .../Infrastructure/MobileCoreAdapter.swift | 3 + .../Resources/Localizable.xcstrings | 576 ++++++++++++++++++ .../feature/session/RemoteSessionStore.kt | 90 +++ .../feature/session/RemoteSessionUiState.kt | 15 + .../core/feature/session/ThreadGoalUiState.kt | 35 ++ .../feature/session/RemoteSessionStoreTest.kt | 107 ++++ .../feature/session/ThreadGoalCommandTest.kt | 14 + .../mobile/core/protocol/RemoteCommand.kt | 2 + .../mobile/core/protocol/RemoteGoal.kt | 21 + .../src/agentic/coordination/scheduler.rs | 97 +++ .../service/remote_connect/remote_server.rs | 7 + .../core/src/service_agent_runtime.rs | 118 ++++ .../services/services-integrations/AGENTS.md | 6 + .../src/remote_connect.rs | 56 ++ 51 files changed, 2339 insertions(+), 34 deletions(-) create mode 100644 src/apps/mobile/android/app/src/androidTest/kotlin/com/openbitfun/mobile/app/ThreadGoalPanelTest.kt create mode 100644 src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/chat/ThreadGoalPanel.kt create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/model/ThreadGoal.ets create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ThreadGoalPanel.ets create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/ThreadGoalPreview.ets create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteGoalController.ets create mode 100644 src/apps/mobile/harmonyos/tools/tests/thread-goal.test.cjs create mode 100644 src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/ThreadGoalUiState.kt create mode 100644 src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/session/ThreadGoalCommandTest.kt create mode 100644 src/apps/mobile/shared/core-protocol/src/commonMain/kotlin/com/openbitfun/mobile/core/protocol/RemoteGoal.kt diff --git a/src/apps/mobile/README.md b/src/apps/mobile/README.md index 3dc586d385..8e4c748395 100644 --- a/src/apps/mobile/README.md +++ b/src/apps/mobile/README.md @@ -152,3 +152,40 @@ Account sign-in on Android, iOS and HarmonyOS opens the shared authorization pag with separate GitHub and email-code options. Email users need no password and are not automatically linked to GitHub users. Sign in with the same method and account on the phone and the controlled desktop/CLI. + +## Persistent goals + +Android, iOS, and HarmonyOS expose **Set goal** in the remote composer **+** menu. +An existing goal appears as a compact strip above the text input. Tap it to read the host's goal, token usage and status, start or edit +an objective, pause automatic continuation, resume a paused/blocked/usage-limited +goal, or clear it. Pausing a goal does not cancel the current turn; use Stop for +that. Budget-limited and completed goals can be edited, but cannot be resumed. + +The composer accepts `/goal `, `/goal`, `/goal edit`, `/goal pause`, +`/goal resume`, and `/goal clear`. Bare `/goal` and `/goal edit` open goal management (the creation editor when no goal exists). +Goal commands do not accept attachments and do not become ordinary chat messages. +A newer draft entered while a command is pending is retained. + +The controlled Desktop or CLI must advertise `thread_goal_v1` in its live +workspace capabilities. Unsupported hosts show an upgrade message. Clients use +`thread_goal` with a session ID and an explicit action; the host resolves that +session's workspace and storage, including SSH workspace bindings. No +controller filesystem path participates in goal operations. Goal execution and +persistence stay on the host when the phone disconnects. Opening a connected conversation reads +the current snapshot; the goal strip refreshes every five seconds while foregrounded, +even with details closed. Returning to the foreground reloads it. Failed changes +retain the last confirmed state. Switching sessions/devices discards late replies. + +Focused checks: + +```bash +# From shared/ +./gradlew :core-feature:jvmTest --tests '*RemoteSessionStoreTest.goal*' --tests '*ThreadGoalCommandTest*' +# From the repository root +node --test src/apps/mobile/harmonyos/tools/tests/thread-goal.test.cjs +``` + +The HarmonyOS `thread-goal` / `thread-goal-dark` native preview exercises the +production panel using in-memory state and no host requests. Android's +`ThreadGoalPanelTest` checks pause/resume and unsupported presentation in an +isolated Compose activity. Neither replaces a live remote-host acceptance test. diff --git a/src/apps/mobile/android/AGENTS.md b/src/apps/mobile/android/AGENTS.md index 5b5ba284ed..d9fd5d874b 100644 --- a/src/apps/mobile/android/AGENTS.md +++ b/src/apps/mobile/android/AGENTS.md @@ -68,3 +68,8 @@ Run instrumented Gradle tests on a dedicated test emulator, not an authenticated manual-acceptance device. The test runner can uninstall the target application after the suite and remove its local account state. Use `adb install -r` for manual acceptance updates to preserve that state. + +For the native Goal panel, use `:app:connectedDebugAndroidTest +-Pandroid.testInstrumentationRunnerArguments.class=com.openbitfun.mobile.app.ThreadGoalPanelTest` +on a dedicated test emulator. Shared command/state coverage is documented in the +mobile README. diff --git a/src/apps/mobile/android/app/src/androidTest/kotlin/com/openbitfun/mobile/app/ThreadGoalPanelTest.kt b/src/apps/mobile/android/app/src/androidTest/kotlin/com/openbitfun/mobile/app/ThreadGoalPanelTest.kt new file mode 100644 index 0000000000..c084d9b83f --- /dev/null +++ b/src/apps/mobile/android/app/src/androidTest/kotlin/com/openbitfun/mobile/app/ThreadGoalPanelTest.kt @@ -0,0 +1,104 @@ +package com.openbitfun.mobile.app + +import android.graphics.Bitmap +import androidx.compose.runtime.* +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.test.platform.app.InstrumentationRegistry +import com.openbitfun.mobile.app.ui.chat.ThreadGoalPanel +import com.openbitfun.mobile.app.ui.theme.OpenBitFunTheme +import com.openbitfun.mobile.core.feature.session.* +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import java.io.File + +class ThreadGoalPanelTest { + @get:Rule val compose = createComposeRule() + @Test fun activeGoalCanPauseAndResume() = verify(false) + @Test fun darkGoalCanPauseAndResume() = verify(true) + private fun verify(dark: Boolean) { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val actions = mutableListOf() + compose.setContent { + var state by remember { mutableStateOf(ThreadGoalUiState("s").copy(visible = true, loaded = true, objective = "Verify mobile goals", status = "active", tokensUsed = 1200)) } + OpenBitFunTheme(dark = dark) { + ThreadGoalPanel(state, "s", true) { intent -> + val goal = intent as RemoteSessionIntent.Goal + actions += goal.action + if (goal.action == ThreadGoalAction.PAUSE) state = state.copy(status = "paused") + if (goal.action == ThreadGoalAction.RESUME) state = state.copy(status = "active") + } + } + } + compose.onNodeWithText(context.getString(R.string.goal_pause)).performClick() + compose.onAllNodesWithText(context.getString(R.string.goal_paused)).onLast().assertIsDisplayed() + compose.onNodeWithText(context.getString(R.string.goal_resume)).performClick() + compose.onAllNodesWithText(context.getString(R.string.goal_active)).onLast().assertIsDisplayed() + assertEquals(listOf(ThreadGoalAction.PAUSE, ThreadGoalAction.RESUME), actions.filter { it != ThreadGoalAction.READ }) + compose.waitForIdle() + InstrumentationRegistry.getInstrumentation().uiAutomation.takeScreenshot().let { bitmap -> + File(context.cacheDir, "goal-${if (dark) "dark" else "light"}.png").outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) } + } + } + @Test fun editCancelPreservesHostGoalAndCloseKeepsStrip() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val actions = mutableListOf() + var state by mutableStateOf(ThreadGoalUiState("s").copy(visible = true, loaded = true, + objective = "Keep typing", status = "active")) + compose.setContent { OpenBitFunTheme(dark = false) { + ThreadGoalPanel(state, "s", true) { + val action = (it as RemoteSessionIntent.Goal).action + actions += action + if (action == ThreadGoalAction.CLOSE) state = state.copy(visible = false) + } + } } + compose.onNodeWithText(context.getString(R.string.goal_modify)).performClick() + compose.runOnIdle { state = state.copy(busy = true) } + compose.onNode(hasSetTextAction()).assertIsEnabled().performTextReplacement("Unsaved draft") + compose.runOnIdle { state = state.copy(busy = false) } + compose.onNodeWithText(context.getString(R.string.goal_cancel)).performClick() + compose.onNodeWithText("Unsaved draft").assertDoesNotExist() + compose.onNodeWithText(context.getString(R.string.goal_close)).performClick() + compose.onNodeWithText("Keep typing").assertIsDisplayed() + assertEquals(false, actions.contains(ThreadGoalAction.EDIT)) + } + @Test fun clearingRequiresConfirmation() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val actions = mutableListOf() + compose.setContent { OpenBitFunTheme(dark = false) { + ThreadGoalPanel(ThreadGoalUiState("s").copy(visible = true, loaded = true, + objective = "Keep this goal", status = "active"), "s", true) { + actions += (it as RemoteSessionIntent.Goal).action + } + } } + compose.onNodeWithText(context.getString(R.string.goal_clear)).performClick() + assertEquals(false, actions.contains(ThreadGoalAction.CLEAR)) + compose.onNodeWithText(context.getString(R.string.goal_clearhint)).assertIsDisplayed() + compose.onNodeWithText(context.getString(R.string.goal_clear)).performClick() + assertEquals(true, actions.contains(ThreadGoalAction.CLEAR)) + } + @Test fun creatingGoalReturnsToComposerStrip() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + var state by mutableStateOf(ThreadGoalUiState("s").copy(visible = true, loaded = true)) + compose.setContent { OpenBitFunTheme(dark = false) { + ThreadGoalPanel(state, "s", true) { + val goal = it as RemoteSessionIntent.Goal + if (goal.action == ThreadGoalAction.START) state = state.copy(objective = goal.objective, status = "active") + if (goal.action == ThreadGoalAction.CLOSE) state = state.copy(visible = false) + } + } } + compose.onNode(hasSetTextAction()).performTextReplacement("Ship the aligned UI") + compose.onAllNodesWithText(context.getString(R.string.goal_set)).onLast().performClick() + compose.onNodeWithText("Ship the aligned UI").assertIsDisplayed() + compose.onNodeWithText(context.getString(R.string.goal_close)).assertDoesNotExist() + } + @Test fun unsupportedHostExplainsWhyWithoutOfferingMutations() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + compose.setContent { OpenBitFunTheme(dark = false) { + ThreadGoalPanel(ThreadGoalUiState("s").copy(visible = true, failure = ThreadGoalFailure.UNSUPPORTED), "s", true) {} + } } + compose.onNodeWithText(context.getString(R.string.goal_unsupported)).assertIsDisplayed() + compose.onNodeWithText(context.getString(R.string.goal_start)).assertDoesNotExist() + } +} diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/chat/ComposerBar.kt b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/chat/ComposerBar.kt index dca0b67895..aeefe213ac 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/chat/ComposerBar.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/chat/ComposerBar.kt @@ -56,6 +56,7 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role @@ -122,6 +123,9 @@ private val ExpandedActionRowHeight = MobileDesignGeometry.ComposerExpandedActio */ @Composable internal fun ComposerBar( + goalContent: @Composable () -> Unit = {}, + onGoal: (() -> Unit)? = null, + hasGoal: Boolean = false, draft: String, images: List, busy: Boolean, @@ -180,7 +184,7 @@ internal fun ComposerBar( easing = OpenBitFunEaseOut, ) val radius by animateDpAsState( - if (expanded || images.isNotEmpty()) { + if (expanded || images.isNotEmpty() || hasGoal) { MobileDesignGeometry.ComposerExpandedRadius } else { MobileDesignGeometry.ComposerCollapsedRadius @@ -189,7 +193,7 @@ internal fun ComposerBar( label = "composer-radius", ) val contentTopPadding by animateDpAsState( - if (expanded) 4.dp else 0.dp, + if (expanded || hasGoal) 4.dp else 0.dp, structureSpec, label = "composer-top-padding", ) @@ -246,6 +250,7 @@ internal fun ComposerBar( ), ), ) { + goalContent() if (capabilities.supportsAttachments && images.isNotEmpty()) { AttachmentStrip( images = images, @@ -264,11 +269,11 @@ internal fun ComposerBar( // While expanded both side controls move to the row below, // so the field gets the full width for what is being typed. AnimatedVisibility( - visible = !expanded && capabilities.supportsAttachments && capabilities.showAddButton, + visible = !expanded && ((capabilities.supportsAttachments && capabilities.showAddButton) || onGoal != null), enter = compactControlEnter, exit = compactControlExit, ) { - AddButton( + AddButton(onGoal = onGoal, hasGoal = hasGoal, enabled = !busy && images.size < MAX_COMPOSER_IMAGES, onClick = onAttach, ) @@ -313,8 +318,8 @@ internal fun ComposerBar( .height(ExpandedActionRowHeight) .padding(start = 2.dp), ) { - if (capabilities.supportsAttachments && capabilities.showAddButton) { - AddButton( + if ((capabilities.supportsAttachments && capabilities.showAddButton) || onGoal != null) { + AddButton(onGoal = onGoal, hasGoal = hasGoal, enabled = !busy && images.size < MAX_COMPOSER_IMAGES, onClick = onAttach, ) @@ -410,19 +415,25 @@ private fun ComposerField( /** The attachment control: a plain glyph, sized to match the primary action. */ @Composable -private fun AddButton(enabled: Boolean, onClick: () -> Unit) { +private fun AddButton(enabled: Boolean, onClick: () -> Unit, onGoal: (() -> Unit)? = null, hasGoal: Boolean = false) { + val keyboard = LocalSoftwareKeyboardController.current + var expanded by remember { mutableStateOf(false) } Box( contentAlignment = Alignment.Center, modifier = Modifier .size(ActionSize) .clip(CircleShape) - .clickable(role = Role.Button, enabled = enabled, onClick = onClick), + .clickable(role = Role.Button, enabled = enabled || onGoal != null, onClick = { if (onGoal != null) expanded = true else onClick() }), ) { + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + androidx.compose.material3.DropdownMenuItem(text = { Text(stringResource(R.string.message_attach_image)) }, enabled = enabled, onClick = { expanded = false; onClick() }) + androidx.compose.material3.DropdownMenuItem(text = { Text(stringResource(if (hasGoal) R.string.goal_manage else R.string.goal_set)) }, onClick = { expanded = false; keyboard?.hide(); onGoal?.invoke() }) + } Icon( painterResource(R.drawable.ic_symbol_plus), contentDescription = stringResource(R.string.message_attach_image), tint = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.size(22.dp).alpha(if (enabled) 1f else DimmedAlpha), + modifier = Modifier.size(22.dp).alpha(if (enabled || onGoal != null) 1f else DimmedAlpha), ) } } diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/chat/ConversationView.kt b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/chat/ConversationView.kt index 594ee827f8..ce52c941c3 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/chat/ConversationView.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/chat/ConversationView.kt @@ -344,6 +344,9 @@ internal fun ConversationView( } } ComposerBar( + goalContent = { ThreadGoalPanel(state.threadGoal, sessionId, phase == ConnectionPhase.CONNECTED, onIntent) }, + onGoal = { onIntent(RemoteSessionIntent.Goal(sessionId, com.openbitfun.mobile.core.feature.session.ThreadGoalAction.OPEN)) }, + hasGoal = state.threadGoal.sessionId == sessionId && state.threadGoal.objective != null, draft = draft, images = images, // An empty session id would send nowhere, so it reads as busy. diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/chat/ThreadGoalPanel.kt b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/chat/ThreadGoalPanel.kt new file mode 100644 index 0000000000..31b3b1f804 --- /dev/null +++ b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/chat/ThreadGoalPanel.kt @@ -0,0 +1,122 @@ +package com.openbitfun.mobile.app.ui.chat + +import androidx.compose.runtime.* +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import com.openbitfun.mobile.app.R +import com.openbitfun.mobile.core.feature.session.* + +@Composable +internal fun ThreadGoalPanel(state: ThreadGoalUiState, sessionId: String, connected: Boolean, onIntent: (RemoteSessionIntent) -> Unit) { + val current = state.takeIf { it.sessionId == sessionId } ?: ThreadGoalUiState(sessionId) + fun action(value: ThreadGoalAction, objective: String? = null) = onIntent(RemoteSessionIntent.Goal(sessionId, value, objective)) + LaunchedEffect(sessionId, connected) { if (connected && sessionId.isNotEmpty()) action(ThreadGoalAction.READ) } + var editing by remember(sessionId, current.visible) { mutableStateOf(false) } + var clearing by remember(sessionId, current.visible) { mutableStateOf(false) } + var saving by remember(sessionId) { mutableStateOf(false) } + var objective by remember(sessionId, current.visible) { mutableStateOf("") } + LaunchedEffect(current.busy, current.failure, saving) { + if (saving && !current.busy) { + if (current.failure == null) { editing = false; action(ThreadGoalAction.CLOSE) } + saving = false + } + } + val colors = MaterialTheme.colorScheme + val status = stringResource(when (current.status) { + "active" -> R.string.goal_active + "paused" -> R.string.goal_paused + "blocked" -> R.string.goal_blocked + "usageLimited" -> R.string.goal_usagelimited + "budgetLimited" -> R.string.goal_budgetlimited + "complete" -> R.string.goal_complete + else -> R.string.goal_unknown + }) + if (current.objective != null) { + Surface(onClick = { action(ThreadGoalAction.OPEN) }, color = colors.surfaceContainerLow, contentColor = colors.onSurface, + shape = RoundedCornerShape(14.dp), modifier = Modifier.fillMaxWidth()) { + Row(Modifier.padding(horizontal = 12.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text(if (connected) status else stringResource(R.string.goal_offline), style = MaterialTheme.typography.labelSmall, color = colors.onSurfaceVariant) + Text(current.objective.orEmpty(), maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodySmall) + } + Text("›", style = MaterialTheme.typography.titleMedium, color = colors.onSurfaceVariant) + } + } + } + if (!current.visible) return + val enabled = connected && !current.busy && current.loaded && current.failure == null + Dialog(onDismissRequest = { action(ThreadGoalAction.CLOSE) }) { + Surface(shape = RoundedCornerShape(20.dp), color = colors.surface, border = BorderStroke(1.dp, colors.outlineVariant)) { + Column(Modifier.widthIn(max = 420.dp).verticalScroll(rememberScrollState()).padding(20.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(stringResource(when { clearing -> R.string.goal_cleartitle; editing -> R.string.goal_modify; current.objective == null -> R.string.goal_set; else -> R.string.goal_title }), style = MaterialTheme.typography.titleLarge, modifier = Modifier.weight(1f)) + TextButton(colors = ButtonDefaults.textButtonColors(contentColor = colors.onSurface), onClick = { action(ThreadGoalAction.CLOSE) }) { Text(stringResource(R.string.goal_close)) } + } + if (!connected) Text(stringResource(R.string.goal_offline), color = colors.onSurfaceVariant) + if (current.busy) LinearProgressIndicator(Modifier.fillMaxWidth()) + current.failure?.let { failure -> + Text(stringResource(when (failure) { + ThreadGoalFailure.UNSUPPORTED -> R.string.goal_unsupported + ThreadGoalFailure.ATTACHMENTS -> R.string.goal_attachments + else -> R.string.goal_failed + }), color = colors.onSurfaceVariant) + TextButton(colors = ButtonDefaults.textButtonColors(contentColor = colors.onSurface), enabled = connected && !current.busy, onClick = { action(ThreadGoalAction.READ) }) { Text(stringResource(R.string.goal_retry)) } + } + if (current.loaded) { + when { + clearing -> { + Text(stringResource(R.string.goal_clearhint), style = MaterialTheme.typography.bodyMedium) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton(colors = ButtonDefaults.outlinedButtonColors(contentColor = colors.onSurface), onClick = { clearing = false }, modifier = Modifier.weight(1f)) { Text(stringResource(R.string.goal_keep)) } + OutlinedButton(enabled = enabled, onClick = { saving = true; action(ThreadGoalAction.CLEAR) }, modifier = Modifier.weight(1f), colors = ButtonDefaults.outlinedButtonColors(contentColor = colors.error)) { Text(stringResource(R.string.goal_clear)) } + } + } + current.objective == null || editing -> { + Text(stringResource(R.string.goal_hint), style = MaterialTheme.typography.bodyMedium, color = colors.onSurfaceVariant) + OutlinedTextField(value = objective, onValueChange = { objective = it }, label = { Text(stringResource(R.string.goal_description)) }, enabled = connected && !saving, minLines = 4, maxLines = 8, modifier = Modifier.fillMaxWidth()) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TextButton(colors = ButtonDefaults.textButtonColors(contentColor = colors.onSurface), onClick = { if (current.objective != null) editing = false else action(ThreadGoalAction.CLOSE) }, modifier = Modifier.weight(1f)) { Text(stringResource(R.string.goal_cancel)) } + Button(colors = ButtonDefaults.buttonColors(containerColor = colors.onSurface, contentColor = colors.surface), enabled = enabled && objective.isNotBlank(), onClick = { saving = true; action(if (current.objective == null) ThreadGoalAction.START else ThreadGoalAction.EDIT, objective.trim()) }, modifier = Modifier.weight(1f)) { Text(stringResource(if (current.objective == null) R.string.goal_set else R.string.goal_save)) } + } + } + else -> { + Row(verticalAlignment = Alignment.CenterVertically) { + Surface(shape = RoundedCornerShape(50), color = colors.surfaceContainerHigh) { Text(status, style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp)) } + Spacer(Modifier.weight(1f)) + Text(stringResource(R.string.goal_usage, current.tokensUsed), style = MaterialTheme.typography.labelSmall, color = colors.onSurfaceVariant) + } + GoalContentCard(stringResource(R.string.goal_description), current.objective.orEmpty()) + GoalContentCard(stringResource(R.string.goal_workflow), if (current.status == "active") stringResource(R.string.goal_workflowhint) else if (current.status == "paused") stringResource(R.string.goal_pausedhint) else status) + HorizontalDivider(color = colors.outlineVariant) + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + OutlinedButton(colors = ButtonDefaults.outlinedButtonColors(contentColor = colors.onSurface), enabled = enabled, onClick = { objective = current.objective.orEmpty(); editing = true }, contentPadding = PaddingValues(horizontal = 8.dp), modifier = Modifier.weight(1f)) { Text(stringResource(R.string.goal_modify)) } + if (current.status == "active" || current.canResume) OutlinedButton(colors = ButtonDefaults.outlinedButtonColors(contentColor = colors.onSurface), enabled = enabled, onClick = { action(if (current.status == "active") ThreadGoalAction.PAUSE else ThreadGoalAction.RESUME) }, contentPadding = PaddingValues(horizontal = 8.dp), modifier = Modifier.weight(1f)) { Text(stringResource(if (current.status == "active") R.string.goal_pause else R.string.goal_resume)) } + OutlinedButton(enabled = enabled, onClick = { clearing = true }, contentPadding = PaddingValues(horizontal = 8.dp), modifier = Modifier.weight(1f), colors = ButtonDefaults.outlinedButtonColors(contentColor = colors.error)) { Text(stringResource(R.string.goal_clear)) } + } + } + } + } + } + } + } +} + +@Composable +private fun GoalContentCard(label: String, content: String) { + Surface(color = MaterialTheme.colorScheme.surfaceContainerLow, shape = RoundedCornerShape(10.dp), modifier = Modifier.fillMaxWidth()) { + Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text(label, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(content, style = MaterialTheme.typography.bodyMedium) + } + } +} diff --git a/src/apps/mobile/android/app/src/main/res/values-zh/strings.xml b/src/apps/mobile/android/app/src/main/res/values-zh/strings.xml index c0edb5f862..f1d8ccfda1 100644 --- a/src/apps/mobile/android/app/src/main/res/values-zh/strings.xml +++ b/src/apps/mobile/android/app/src/main/res/values-zh/strings.xml @@ -526,4 +526,40 @@ 电脑上的文件已被修改,当前草稿仍保留。请先复制草稿,再重新打开文件以读取最新内容。 加载更早消息失败,点按重试。 正在加载更早消息… + 目标 + 描述目标 + 开始 + 保存目标 + 暂停 + 恢复 + 清除目标 + 关闭 + 刷新 + 请更新所连接的宿主以管理目标。 + 无法读取或保存目标,请刷新后重试。 + 目标命令不接受附件,请先移除附件。 + 暂无目标 + 已使用 Token:%1$d + 进行中 + 已暂停 + 已阻塞 + 用量受限 + 预算已用完 + 已完成 + 未知状态 + 设置目标 + 管理目标 + 编辑 + 取消 + 保存修改 + 目标描述 + 描述期望的结果和完成标准。设置后,助手将在会话空闲时持续推进。 + 完成检查 + 每轮结束后继续推进,并依据当前证据检查目标是否完成。 + 自动推进与完成检查已暂停。 + 清除目标? + 停止按这个目标自动推进。已有对话和工作结果会保留。 + 保留目标 + 设备已离线,重新连接后可继续管理目标。 + 重试 diff --git a/src/apps/mobile/android/app/src/main/res/values/strings.xml b/src/apps/mobile/android/app/src/main/res/values/strings.xml index ad4c6fc18e..33ea4279be 100644 --- a/src/apps/mobile/android/app/src/main/res/values/strings.xml +++ b/src/apps/mobile/android/app/src/main/res/values/strings.xml @@ -541,4 +541,40 @@ File changed on the computer. Your draft is preserved. Copy it before reopening the file to load the latest version. Could not load earlier messages. Tap to retry. Loading earlier messages… + Goal + Describe the goal + Start + Save objective + Pause + Resume + Clear goal + Close + Refresh + Update the connected host to manage goals. + Could not load or save the goal. Refresh before retrying. + Goal commands do not accept attachments. Remove them first. + No goal yet + Tokens used: %1$d + Active + Paused + Blocked + Usage limited + Budget reached + Complete + Unknown status + Set goal + Manage goal + Edit + Cancel + Save changes + Objective + Describe the result and completion criteria. The assistant will keep working toward it when the session is idle. + Completion checks + Continue after each turn and check current evidence against the goal. + Automatic continuation and completion checks are paused. + Clear goal? + Stop automatic progress toward this goal. Existing messages and work will be kept. + Keep goal + The device is offline. Reconnect to manage this goal. + Retry diff --git a/src/apps/mobile/design-system/components/mobile-components.json b/src/apps/mobile/design-system/components/mobile-components.json index 3fd3ff16a8..6b1852f15d 100644 --- a/src/apps/mobile/design-system/components/mobile-components.json +++ b/src/apps/mobile/design-system/components/mobile-components.json @@ -1,6 +1,13 @@ { "version": 1, "components": { + "thread_goal": { + "purpose": "Manage the executing host's persistent goal from each native conversation.", + "anatomy": ["composer_plus_menu", "goal_context_strip", "goal_details", "objective_editor", "clear_confirmation", "status", "token_usage", "goal_actions", "retry", "close"], + "states": ["loading", "empty", "active", "paused", "blocked", "usage_limited", "budget_limited", "complete", "unsupported", "failed", "offline"], + "tokens": ["ink", "muted", "page_bg"], + "platformNotes": "Keep identical actions in compact and wide layouts. Present the multi-step objective editor in a native modal with scrollable content, keyboard handling and outside dismissal. Only a live thread_goal_v1 host enables mutations; a failed request retains the last confirmed objective and requires refresh. Create through the composer plus menu or /goal. An existing goal appears as a compact status and single-line objective strip inside the composer. Tapping it opens details; editing and clear confirmation are separate steps. Closing details keeps the strip observing the selected session; background suspends reads, foreground reloads, and session or host changes invalidate stale responses. Native controls inherit semantic appearance tokens." + }, "shell_header_alignment": { "purpose": "Keep sidebar search and conversation menu controls on one horizontal centerline.", "anatomy": ["sidebar_header", "conversation_header"], diff --git a/src/apps/mobile/design-system/preview/generated/mobile-design-data.js b/src/apps/mobile/design-system/preview/generated/mobile-design-data.js index d9080f6bb6..0d464f89f8 100644 --- a/src/apps/mobile/design-system/preview/generated/mobile-design-data.js +++ b/src/apps/mobile/design-system/preview/generated/mobile-design-data.js @@ -438,6 +438,40 @@ export const mobileTokens = { export const mobileComponents = { "version": 1, "components": { + "thread_goal": { + "purpose": "Manage the executing host's persistent goal from each native conversation.", + "anatomy": [ + "composer_plus_menu", + "goal_context_strip", + "goal_details", + "objective_editor", + "clear_confirmation", + "status", + "token_usage", + "goal_actions", + "retry", + "close" + ], + "states": [ + "loading", + "empty", + "active", + "paused", + "blocked", + "usage_limited", + "budget_limited", + "complete", + "unsupported", + "failed", + "offline" + ], + "tokens": [ + "ink", + "muted", + "page_bg" + ], + "platformNotes": "Keep identical actions in compact and wide layouts. Present the multi-step objective editor in a native modal with scrollable content, keyboard handling and outside dismissal. Only a live thread_goal_v1 host enables mutations; a failed request retains the last confirmed objective and requires refresh. Create through the composer plus menu or /goal. An existing goal appears as a compact status and single-line objective strip inside the composer. Tapping it opens details; editing and clear confirmation are separate steps. Closing details keeps the strip observing the selected session; background suspends reads, foreground reloads, and session or host changes invalidate stale responses. Native controls inherit semantic appearance tokens." + }, "shell_header_alignment": { "purpose": "Keep sidebar search and conversation menu controls on one horizontal centerline.", "anatomy": [ diff --git a/src/apps/mobile/harmonyos/AGENTS.md b/src/apps/mobile/harmonyos/AGENTS.md index 9d1ada9f07..4a705f543b 100644 --- a/src/apps/mobile/harmonyos/AGENTS.md +++ b/src/apps/mobile/harmonyos/AGENTS.md @@ -173,3 +173,9 @@ node --test src/apps/mobile/harmonyos/tools/tests/workspace-editor.test.cjs The Chrome renderer test does not replace Harmony device-level keyboard and sheet-layout checks. + +For Goal parsing, host capability gating, stale responses and failed mutations, +run `node --test tools/tests/thread-goal.test.cjs` plus `assembleHap`. Use the +`thread-goal` and `thread-goal-dark` previews to check pause/resume/clear in +compact and wide postures without sending remote commands. Restore normal +`EntryAbility` afterward. diff --git a/src/apps/mobile/harmonyos/build-profile.json5 b/src/apps/mobile/harmonyos/build-profile.json5 index b8ba85d4f3..5278a0c7b2 100644 --- a/src/apps/mobile/harmonyos/build-profile.json5 +++ b/src/apps/mobile/harmonyos/build-profile.json5 @@ -22,6 +22,21 @@ { "name": "release" } + ], + "signingConfigs": [ + { + "name": "default", + "type": "HarmonyOS", + "material": { + "certpath": "/Users/user/.ohos/config/default_harmonyos_NYLamSA5vQUl4kHZOIwligv00-oGd7bV8FupuDbgGoc=.cer", + "keyAlias": "debugKey", + "keyPassword": "0000001AEF0CD4AECE07596FBCD9189CD7F976790D8812823D867F1177768946BB7FF908FA2D50D85486", + "profile": "/Users/user/.ohos/config/default_harmonyos_NYLamSA5vQUl4kHZOIwligv00-oGd7bV8FupuDbgGoc=.p7b", + "signAlg": "SHA256withECDSA", + "storeFile": "/Users/user/.ohos/config/default_harmonyos_NYLamSA5vQUl4kHZOIwligv00-oGd7bV8FupuDbgGoc=.p12", + "storePassword": "0000001A535206CDE1A2A55FBF55D1741A4B7A021B187F91EC448383B211552F52F21E2670F408148CAE" + } + } ] }, "modules": [ diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/entryability/EntryAbility.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/entryability/EntryAbility.ets index 34ac44d932..20684458dc 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/entryability/EntryAbility.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/entryability/EntryAbility.ets @@ -13,7 +13,7 @@ export default class EntryAbility extends UIAbility { onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void { const scenarioId = want.parameters?.['openbitfunDesignPreview']; - if (scenarioId === 'history-scroll' || scenarioId === 'durable-timeline' || scenarioId === 'composer-submit' || scenarioId === 'catalog-refresh' || scenarioId === 'catalog-refresh-dark' || scenarioId === 'device-selector' || scenarioId === 'device-selector-dark' || scenarioId === 'welcome-home' || scenarioId === 'connected-conversation' || + if (scenarioId === 'thread-goal' || scenarioId === 'thread-goal-dark' || scenarioId === 'history-scroll' || scenarioId === 'durable-timeline' || scenarioId === 'composer-submit' || scenarioId === 'catalog-refresh' || scenarioId === 'catalog-refresh-dark' || scenarioId === 'device-selector' || scenarioId === 'device-selector-dark' || scenarioId === 'welcome-home' || scenarioId === 'connected-conversation' || scenarioId === 'streaming-dark' || scenarioId === 'reconnecting-wide' || scenarioId === 'narrow-multiline' || scenarioId === 'fold-context' || scenarioId === 'long-reading' || @@ -25,9 +25,9 @@ export default class EntryAbility extends UIAbility { AppStorage.setOrCreate('scenarioId', scenarioId); } try { - const colorMode = scenarioId === 'cold-start-home-dark' || scenarioId === 'catalog-refresh-dark' || scenarioId === 'device-selector-dark' || scenarioId === 'streaming-dark' || scenarioId === 'interaction-mailbox-dark' + const colorMode = scenarioId === 'thread-goal-dark' || scenarioId === 'cold-start-home-dark' || scenarioId === 'catalog-refresh-dark' || scenarioId === 'device-selector-dark' || scenarioId === 'streaming-dark' || scenarioId === 'interaction-mailbox-dark' ? ConfigurationConstant.ColorMode.COLOR_MODE_DARK - : scenarioId === 'cold-start-home' || scenarioId === 'catalog-refresh' || scenarioId === 'device-selector' || scenarioId === 'connected-conversation' || scenarioId === 'reconnecting-wide' + : scenarioId === 'thread-goal' || scenarioId === 'cold-start-home' || scenarioId === 'catalog-refresh' || scenarioId === 'device-selector' || scenarioId === 'connected-conversation' || scenarioId === 'reconnecting-wide' ? ConfigurationConstant.ColorMode.COLOR_MODE_LIGHT : ConfigurationConstant.ColorMode.COLOR_MODE_NOT_SET; this.context.getApplicationContext().setColorMode(colorMode); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/EnUsMessages.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/EnUsMessages.ets index 40fefbb267..933497b632 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/EnUsMessages.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/EnUsMessages.ets @@ -1,4 +1,40 @@ export const EN_US_MESSAGES: [string, string][] = [ + ['goal.set', 'Set goal'], + ['goal.manage', 'Manage goal'], + ['goal.modify', 'Edit'], + ['goal.cancel', 'Cancel'], + ['goal.save', 'Save changes'], + ['goal.description', 'Objective'], + ['goal.hint', 'Describe the result and completion criteria. The assistant will keep working toward it when the session is idle.'], + ['goal.workflow', 'Completion checks'], + ['goal.workflowHint', 'Continue after each turn and check current evidence against the goal.'], + ['goal.pausedHint', 'Automatic continuation and completion checks are paused.'], + ['goal.clearTitle', 'Clear goal?'], + ['goal.clearHint', 'Stop automatic progress toward this goal. Existing messages and work will be kept.'], + ['goal.keep', 'Keep goal'], + ['goal.offline', 'The device is offline. Reconnect to manage this goal.'], + ['goal.retry', 'Retry'], + ['goal.title', 'Goal'], + ['goal.objective', 'Describe the goal'], + ['goal.start', 'Start'], + ['goal.edit', 'Save objective'], + ['goal.pause', 'Pause'], + ['goal.resume', 'Resume'], + ['goal.clear', 'Clear goal'], + ['goal.close', 'Close'], + ['goal.refresh', 'Refresh'], + ['goal.unsupported', 'Update the connected host to manage goals.'], + ['goal.failed', 'Could not load or save the goal. Refresh before retrying.'], + ['goal.attachments', 'Goal commands do not accept attachments. Remove them first.'], + ['goal.empty', 'No goal yet'], + ['goal.usage', 'Tokens used: {0}'], + ['goal.active', 'Active'], + ['goal.paused', 'Paused'], + ['goal.blocked', 'Blocked'], + ['goal.usageLimited', 'Usage limited'], + ['goal.budgetLimited', 'Budget reached'], + ['goal.complete', 'Complete'], + ['goal.unknown', 'Unknown status'], ['workspaceTools.browse', 'Browse runtime folders'], ['workspaceTools.selectFolder', 'Use this folder'], ['workspaceTools.back', 'Back to files'], diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/ZhCnMessages.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/ZhCnMessages.ets index ec52d603b8..43ad4b4c5e 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/ZhCnMessages.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/ZhCnMessages.ets @@ -1,4 +1,40 @@ export const ZH_CN_MESSAGES: [string, string][] = [ + ['goal.set', '设置目标'], + ['goal.manage', '管理目标'], + ['goal.modify', '编辑'], + ['goal.cancel', '取消'], + ['goal.save', '保存修改'], + ['goal.description', '目标描述'], + ['goal.hint', '描述期望的结果和完成标准。设置后,助手将在会话空闲时持续推进。'], + ['goal.workflow', '完成检查'], + ['goal.workflowHint', '每轮结束后继续推进,并依据当前证据检查目标是否完成。'], + ['goal.pausedHint', '自动推进与完成检查已暂停。'], + ['goal.clearTitle', '清除目标?'], + ['goal.clearHint', '停止按这个目标自动推进。已有对话和工作结果会保留。'], + ['goal.keep', '保留目标'], + ['goal.offline', '设备已离线,重新连接后可继续管理目标。'], + ['goal.retry', '重试'], + ['goal.title', '目标'], + ['goal.objective', '描述目标'], + ['goal.start', '开始'], + ['goal.edit', '保存目标'], + ['goal.pause', '暂停'], + ['goal.resume', '恢复'], + ['goal.clear', '清除目标'], + ['goal.close', '关闭'], + ['goal.refresh', '刷新'], + ['goal.unsupported', '请更新所连接的宿主以管理目标。'], + ['goal.failed', '无法读取或保存目标,请刷新后重试。'], + ['goal.attachments', '目标命令不接受附件,请先移除附件。'], + ['goal.empty', '暂无目标'], + ['goal.usage', '已使用 Token:{0}'], + ['goal.active', '进行中'], + ['goal.paused', '已暂停'], + ['goal.blocked', '已阻塞'], + ['goal.usageLimited', '用量受限'], + ['goal.budgetLimited', '预算已用完'], + ['goal.complete', '已完成'], + ['goal.unknown', '未知状态'], ['workspaceTools.browse', '浏览受控设备文件夹'], ['workspaceTools.selectFolder', '选择此文件夹'], ['workspaceTools.back', '返回文件列表'], diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/model/RemoteModels.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/model/RemoteModels.ets index 6ed5dbbe03..a704159e91 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/model/RemoteModels.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/model/RemoteModels.ets @@ -128,6 +128,8 @@ export interface SavedRuntimeConnection { id: string; name: string; host: string export interface SavedRuntimeConnectionsResponse extends CommandStatusResponse { ok: boolean; value?: SavedRuntimeConnection[]; error?: string; } export interface RemoteCommand { + action?: string; + objective?: string; workspace_id?: string; remote_ssh_host?: string; command?: string; diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/model/ThreadGoal.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/model/ThreadGoal.ets new file mode 100644 index 0000000000..f9af208978 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/model/ThreadGoal.ets @@ -0,0 +1,42 @@ +import { CommandStatusResponse } from './RemoteModels'; + +export enum GoalAction { Open = 'open', Close = 'close', Read = 'read', Start = 'start', Edit = 'edit', Pause = 'pause', Resume = 'resume', Clear = 'clear' } +export interface RemoteThreadGoal { + sessionId: string; + objective: string; + status: string; + tokensUsed?: number; + tokenBudget?: number; +} +export interface ThreadGoalResponse extends CommandStatusResponse { goal?: RemoteThreadGoal; } +export class ThreadGoalState { + sessionId: string = ''; + visible: boolean = false; + busy: boolean = false; + loaded: boolean = false; + goal?: RemoteThreadGoal; + failure: string = ''; + withVisibility(visible: boolean): ThreadGoalState { + const next = new ThreadGoalState(); + next.sessionId = this.sessionId; next.visible = visible; next.busy = this.busy; + next.loaded = this.loaded; next.goal = this.goal; next.failure = this.failure; + return next; + } +} +export class GoalRequest { + action: GoalAction; + objective: string; + constructor(action: GoalAction, objective: string = '') { this.action = action; this.objective = objective; } +} +export function parseGoalCommand(text: string): GoalRequest | undefined { + const match = text.trim().match(/^\/goal(?=\s|$)\s*([\s\S]*)$/i); + if (!match) return undefined; + const objective = match[1].trim(); + switch (objective.toLowerCase()) { + case '': case 'edit': return new GoalRequest(GoalAction.Open); + case 'pause': return new GoalRequest(GoalAction.Pause); + case 'resume': return new GoalRequest(GoalAction.Resume); + case 'clear': return new GoalRequest(GoalAction.Clear); + default: return new GoalRequest(GoalAction.Start, objective); + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/ConversationIntent.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/ConversationIntent.ets index e6a5da90b1..514b169860 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/ConversationIntent.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/ConversationIntent.ets @@ -1,8 +1,10 @@ +import { GoalRequest } from '../../model/ThreadGoal'; import { InteractionMailboxAction } from '../../model/InteractionMailbox'; import { ConversationUiQuestionAnswer } from '../state/ConversationUiModels'; import { FilePreviewRequest } from '../../model/FilePreviewTarget'; export enum ConversationIntentType { + Goal = 'goal', Mailbox = 'mailbox', OpenSidebar = 'open_sidebar', Back = 'back', @@ -50,6 +52,7 @@ export class ConversationIntent { readonly filePreviewRequest?: FilePreviewRequest; readonly planBuildRequest?: PlanBuildRequest; mailboxAction?: InteractionMailboxAction; + goalRequest?: GoalRequest; constructor( type: ConversationIntentType, @@ -71,6 +74,11 @@ export class ConversationIntent { } export class ConversationIntents { + static goal(request: GoalRequest): ConversationIntent { + const intent = new ConversationIntent(ConversationIntentType.Goal); + intent.goalRequest = request; + return intent; + } static mailbox(action: InteractionMailboxAction): ConversationIntent { const intent = new ConversationIntent(ConversationIntentType.Mailbox); intent.mailboxAction = action; diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/ConversationIntentDispatcher.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/ConversationIntentDispatcher.ets index 2671a3576b..e7c9a11e41 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/ConversationIntentDispatcher.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/ConversationIntentDispatcher.ets @@ -1,3 +1,4 @@ +import { GoalRequest } from '../../model/ThreadGoal'; import { InteractionMailboxAction } from '../../model/InteractionMailbox'; import { RemoteQuestionAnswerPayload, RemoteSession } from '../../model/RemoteModels'; import { AppRoute, AppRouteContract } from '../navigation/AppRouteContract'; @@ -6,6 +7,7 @@ import { toRemoteQuestionAnswer } from '../state/ConversationUiModels'; import { FilePreviewRequest } from '../../model/FilePreviewTarget'; export interface ConversationIntentDispatcherHooks { + readonly goal?: (request: GoalRequest) => Promise; readonly mailbox: (action: InteractionMailboxAction) => Promise; readonly openSidebar: () => void; readonly back: () => void; @@ -40,6 +42,9 @@ export class ConversationIntentDispatcher { dispatch(route: AppRoute, intent: ConversationIntent): void { switch (intent.type) { + case ConversationIntentType.Goal: + if (route === AppRoute.RemoteChat && intent.goalRequest) void this.hooks.goal?.(intent.goalRequest); + return; case ConversationIntentType.Mailbox: if (intent.mailboxAction) void this.hooks.mailbox(intent.mailboxAction); return; case ConversationIntentType.OpenSidebar: this.hooks.openSidebar(); return; case ConversationIntentType.Back: this.hooks.back(); return; diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ComposerBar.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ComposerBar.ets index 5135aac2d0..14ed4e92d8 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ComposerBar.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ComposerBar.ets @@ -1,3 +1,5 @@ +import { ThreadGoalPanel } from './ThreadGoalPanel'; +import { GoalAction, GoalRequest, ThreadGoalState } from '../../model/ThreadGoal'; import { KeyboardReleaseService } from '../../services/KeyboardReleaseService'; import { RemoteI18n } from '../../i18n/RemoteI18n'; import { MobileDesignGeometry, MobileDesignMotion, MobileDesignTypography } from '../../generated/MobileDesignTokens'; @@ -49,6 +51,11 @@ const COMPOSER_IMAGE_STRIP_BLOCK: number = @ComponentV2 export struct ComposerBar { + @Param goalEnabled: boolean = false; + @Param goalState: ThreadGoalState = new ThreadGoalState(); + @Param goalSessionId: string = ''; + @Event onGoal: (request: GoalRequest) => void = () => {}; + @Local addMenuOpen: boolean = false; @Param presentation: ComposerPresentation = ComposerPresentation.Compact; @Param capabilities: ChatComposerCapabilities = REMOTE_CHAT_COMPOSER_CAPABILITIES; @Param inputId: string = 'conversation-composer-input'; @@ -130,19 +137,20 @@ export struct ComposerBar { @Builder AdaptiveComposer() { Column({ space: 2 }) { + if (this.goalEnabled) { + ThreadGoalPanel({ state: this.goalState, sessionId: this.goalSessionId, + connected: this.connectionState === 'connected', onAction: this.onGoal }) + } if (this.selectedImages.length > 0) { this.SelectedImageStrip() } Row({ space: this.isComposerExpanded() ? 0 : 5 }) { - if (this.shouldShowAddButton()) { + if (this.shouldShowAddButton() && !this.isComposerExpanded()) { Row() { this.AddButton() } - .width(this.isComposerExpanded() ? 0 : COMPOSER_ACTION_SIZE) + .width(COMPOSER_ACTION_SIZE) .height(COMPOSER_ACTION_SIZE) - .opacity(this.isComposerExpanded() ? 0 : 1) - .visibility(this.isComposerExpanded() ? Visibility.None : Visibility.Visible) - .clip(true) } this.InputField() Row() { @@ -190,7 +198,7 @@ export struct ComposerBar { .padding({ left: 8, right: 8, - top: this.isComposerExpanded() ? 4 : 0, + top: this.isComposerExpanded() || (this.goalEnabled && this.goalState.goal !== undefined) ? 4 : 0, bottom: this.isComposerExpanded() ? 2 : 0 }) // The transcript now runs underneath the pill, so the fill is translucent @@ -222,7 +230,7 @@ export struct ComposerBar { Stack({ alignContent: Alignment.Center }) { SymbolGlyph(this.isModelSelectorExpanded() ? $r('sys.symbol.chevron_up') : $r('sys.symbol.chevron_down')) - .fontSize(13) + .fontSize(MobileDesignTypography.bodySmall.size) .fontColor([INK]) .opacity(0.68) } @@ -339,10 +347,42 @@ export struct ComposerBar { if (this.isVoiceListening) { return; } - // Images are the only thing a message can carry, so a menu in front of the - // picker would be a menu of one. Straight to the album, same as local chat. - this.onPickImages(); + if (this.goalEnabled) this.addMenuOpen = !this.addMenuOpen; + else this.onPickImages(); }) + .bindPopup(this.addMenuOpen, { builder: this.AddMenu(), placement: Placement.TopLeft, + popupColor: CARD, enableArrow: false, + onStateChange: (event) => { this.addMenuOpen = event.isVisible; } }) + } + + private async openGoal(): Promise { + await this.releaseComposerKeyboard(); + this.onGoal(new GoalRequest(GoalAction.Open)); + } + + @Builder + AddMenu() { + Column({ space: 2 }) { + Button() { + Row({ space: 12 }) { + SymbolGlyph($r('sys.symbol.square_grid_2x2')).fontSize(14).fontColor([INK]) + Text(RemoteI18n.t('chat.addImage')).fontSize(MobileDesignTypography.bodySmall.size).fontColor(INK).layoutWeight(1).textAlign(TextAlign.Start) + }.width('100%').height(32) + }.type(ButtonType.Normal).width('100%').height(32).padding({ left: 10, right: 10 }) + .backgroundColor(TRANSPARENT).borderRadius(10) + .onClick(() => { this.addMenuOpen = false; this.onPickImages(); }) + Divider().color(LINE).height(1).margin({ left: 6, right: 6, top: 4, bottom: 4 }) + Button() { + Row({ space: 12 }) { + SymbolGlyph($r('sys.symbol.checkmark_circle')).fontSize(14).fontColor([INK]) + Text(RemoteI18n.t(this.goalState.goal ? 'goal.manage' : 'goal.set')).fontSize(MobileDesignTypography.bodySmall.size).fontColor(INK).layoutWeight(1).textAlign(TextAlign.Start) + Text('/goal').fontSize(MobileDesignTypography.labelSmall.size).fontColor(MUTED) + }.width('100%').height(32) + }.type(ButtonType.Normal).width('100%').height(32).padding({ left: 10, right: 10 }) + .backgroundColor(TRANSPARENT).borderRadius(10) + .enabled(this.goalSessionId.length > 0) + .onClick(() => { this.addMenuOpen = false; void this.openGoal(); }) + }.width(220).padding(8).backgroundColor(CARD).border({ width: 1, color: LINE }).borderRadius(16) } @Builder @@ -635,7 +675,7 @@ export struct ComposerBar { } private shouldShowAddButton(): boolean { - return ChatComposerPolicy.shouldShowAddButton( + return this.goalEnabled || ChatComposerPolicy.shouldShowAddButton( this.capabilities.showAddButton, this.capabilities.supportsAttachments ); @@ -654,7 +694,7 @@ export struct ComposerBar { // The pill radius only reads as a pill while the card is one row tall. private composerRadius(): number { - if (this.isComposerExpanded() || this.selectedImages.length > 0) { + if (this.isComposerExpanded() || this.selectedImages.length > 0 || (this.goalEnabled && this.goalState.goal !== undefined)) { return MobileDesignGeometry.composerExpandedRadius; } return MobileDesignGeometry.composerCollapsedRadius; diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationView.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationView.ets index 0ce830d6cc..35c108f6ad 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationView.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationView.ets @@ -1,3 +1,4 @@ +import { GoalRequest } from '../../model/ThreadGoal'; import { InteractionMailboxPanel } from './InteractionMailboxPanel'; import { InteractionMailboxAction } from '../../model/InteractionMailbox'; import { MobileDesignGeometry, MobileDesignTypography } from '../../generated/MobileDesignTokens'; @@ -366,6 +367,10 @@ export struct ConversationView { Composer() { Column() { ComposerBar({ + goalEnabled: this.surface === ChatSurface.Remote, + goalState: this.state.threadGoal, + goalSessionId: this.state.activeSession.sessionId, + onGoal: (request: GoalRequest): void => this.onIntent(ConversationIntents.goal(request)), presentation: this.composerPresentation, capabilities: this.composerCapabilities, inputId: this.composerInputId, diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ThreadGoalPanel.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ThreadGoalPanel.ets new file mode 100644 index 0000000000..0a2ee0344f --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ThreadGoalPanel.ets @@ -0,0 +1,152 @@ +import { MobileDesignTypography } from '../../generated/MobileDesignTokens'; +import { GoalAction, GoalRequest, ThreadGoalState } from '../../model/ThreadGoal'; +import { RemoteI18n } from '../../i18n/RemoteI18n'; +import { KeyboardReleaseService } from '../../services/KeyboardReleaseService'; +import { INK, MUTED, PAGE_BG, TRANSPARENT, PRIMARY_ACTION, CONTENT_ON_ACTION, STATUS_DANGER, SOFT, LINE } from './Theme'; + +/** Composer context strip and its detail/edit flow. Host state remains authoritative. */ +@ComponentV2 +export struct ThreadGoalPanel { + @Param state: ThreadGoalState = new ThreadGoalState(); + @Param sessionId: string = ''; + @Param connected: boolean = false; + @Event onAction: (request: GoalRequest) => void = () => {}; + @Local objective: string = ''; + @Local editing: boolean = false; + @Local clearing: boolean = false; + @Local saving: boolean = false; + + aboutToAppear(): void { this.load(); } + @Monitor('sessionId', 'connected') + targetChanged(): void { this.objective = ''; this.editing = false; this.clearing = false; this.saving = false; this.load(); } + private load(): void { + if (this.connected && this.sessionId.length > 0) this.onAction(new GoalRequest(GoalAction.Read)); + } + @Monitor('state') + goalChanged(): void { + if (!this.state.visible) { this.objective = ''; this.editing = false; this.clearing = false; } + if (this.saving && !this.state.busy) { + if (!this.state.failure) { this.editing = false; this.onAction(new GoalRequest(GoalAction.Close)); } + this.saving = false; + } + } + private async openGoal(): Promise { + const context = this.getUIContext().getHostContext(); + if (context) await KeyboardReleaseService.release(context); + this.getUIContext().getFocusController().clearFocus(); + this.onAction(new GoalRequest(GoalAction.Open)); + } + private canMutate(): boolean { return this.connected && this.state.loaded && !this.state.busy && !this.state.failure; } + private statusKey(): string { + const status = this.state.goal?.status || 'unknown'; + return ['active', 'paused', 'blocked', 'usageLimited', 'budgetLimited', 'complete'].indexOf(status) >= 0 ? status : 'unknown'; + } + private edit(): void { this.objective = this.state.goal?.objective || ''; this.editing = true; } + private close(): void { this.editing = false; this.clearing = false; this.onAction(new GoalRequest(GoalAction.Close)); } + + build() { + Column() { + if (this.state.sessionId === this.sessionId && this.state.goal) { + Button() { + Row({ space: 10 }) { + Column({ space: 2 }) { + Text(RemoteI18n.t(this.connected ? 'goal.' + this.statusKey() : 'goal.offline')) + .fontSize(MobileDesignTypography.labelSmall.size).fontColor(MUTED) + Text(this.state.goal.objective).fontSize(MobileDesignTypography.bodySmall.size).fontColor(INK) + .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis }) + }.layoutWeight(1).alignItems(HorizontalAlign.Start) + SymbolGlyph($r('sys.symbol.chevron_right')).fontSize(14).fontColor([MUTED]) + }.width('100%').padding({ left: 12, right: 12, top: 10, bottom: 10 }) + }.type(ButtonType.Normal).backgroundColor(SOFT).borderRadius(14).width('100%') + .accessibilityText(RemoteI18n.t('goal.manage')) + .onClick(() => { void this.openGoal(); }) + } + }.width('100%') + .bindSheet(this.state.visible && this.state.sessionId === this.sessionId, this.GoalSheet(), { + height: SheetSize.FIT_CONTENT, width: 420, + backgroundColor: PAGE_BG, showClose: false, dragBar: false, + keyboardAvoidMode: SheetKeyboardAvoidMode.TRANSLATE_AND_RESIZE, + onDisappear: () => this.close() + }) + } + @Builder + Action(label: string, action: () => void, primary: boolean = false, danger: boolean = false) { + Button(RemoteI18n.t('goal.' + label)).type(ButtonType.Normal).height(44).borderRadius(10) + .fontSize(MobileDesignTypography.bodyMedium.size) + .backgroundColor(primary ? PRIMARY_ACTION : TRANSPARENT) + .fontColor(primary ? CONTENT_ON_ACTION : danger ? STATUS_DANGER : INK) + .border({ width: primary ? 0 : 1, color: LINE }).layoutWeight(1) + .enabled(this.canMutate()).onClick(action) + } + @Builder + GoalSheet() { + Column({ space: 16 }) { + Row() { + Text(RemoteI18n.t(this.clearing ? 'goal.clearTitle' : this.editing ? 'goal.modify' : !this.state.goal ? 'goal.set' : 'goal.title')) + .fontSize(MobileDesignTypography.titleMedium.size).fontWeight(FontWeight.Medium).fontColor(INK).layoutWeight(1) + Button() { + Image($r('app.media.settings_close_x')).renderMode(ImageRenderMode.Template).fillColor(MUTED).width(16).height(16) + }.width(44).height(44).backgroundColor(TRANSPARENT).accessibilityText(RemoteI18n.t('goal.close')).onClick(() => this.close()) + }.width('100%') + if (!this.connected) { Text(RemoteI18n.t('goal.offline')).fontColor(MUTED) } + if (this.state.failure) { + Text(RemoteI18n.t('goal.' + this.state.failure)).fontColor(MUTED) + Button(RemoteI18n.t('goal.retry')).backgroundColor(SOFT).fontColor(INK) + .enabled(this.connected && !this.state.busy).onClick(() => this.load()) + } + if (this.state.busy) { LoadingProgress().width(20).height(20) } + if (this.state.loaded) { + if (this.clearing) { + Text(RemoteI18n.t('goal.clearHint')).fontSize(MobileDesignTypography.bodyMedium.size).fontColor(MUTED) + Row({ space: 8 }) { + this.Action('keep', () => { this.clearing = false; }) + this.Action('clear', () => { this.saving = true; this.onAction(new GoalRequest(GoalAction.Clear)); }, false, true) + }.width('100%') + } else if (!this.state.goal || this.editing) { + Text(RemoteI18n.t('goal.hint')).fontSize(MobileDesignTypography.bodyMedium.size).fontColor(MUTED) + TextArea({ text: this.objective, placeholder: RemoteI18n.t('goal.objective') }) + .id('goal-objective-input').height(126).fontSize(MobileDesignTypography.bodyMedium.size) + .fontColor(INK).backgroundColor(SOFT).borderRadius(10) + .enabled(this.connected && !this.saving).onChange((value: string) => { this.objective = value; }) + Row({ space: 8 }) { + Button(RemoteI18n.t('goal.cancel')).height(44).layoutWeight(1).backgroundColor(TRANSPARENT).fontColor(INK) + .onClick(() => { if (this.state.goal) this.editing = false; else this.close(); }) + Button(RemoteI18n.t(this.state.goal ? 'goal.save' : 'goal.set')).type(ButtonType.Normal) + .height(44).borderRadius(10).layoutWeight(1).backgroundColor(PRIMARY_ACTION).fontColor(CONTENT_ON_ACTION) + .enabled(this.canMutate() && this.objective.trim().length > 0) + .onClick(() => { this.saving = true; this.onAction(new GoalRequest(this.state.goal ? GoalAction.Edit : GoalAction.Start, this.objective.trim())); }) + }.width('100%') + } else { + Row() { + Text(RemoteI18n.t('goal.' + this.statusKey())).fontSize(MobileDesignTypography.labelSmall.size) + .fontColor(INK).padding({ left: 10, right: 10, top: 5, bottom: 5 }).backgroundColor(SOFT).borderRadius(16) + Blank() + Text(RemoteI18n.f('goal.usage', String(this.state.goal.tokensUsed || 0))) + .fontSize(MobileDesignTypography.labelSmall.size).fontColor(MUTED) + }.width('100%') + Scroll() { + Column({ space: 6 }) { + Text(RemoteI18n.t('goal.description')).fontSize(MobileDesignTypography.labelSmall.size).fontColor(MUTED) + Text(this.state.goal.objective).fontSize(MobileDesignTypography.bodyMedium.size).fontColor(INK).width('100%') + }.alignItems(HorizontalAlign.Start).width('100%').padding(14) + }.constraintSize({ maxHeight: 180 }).width('100%').backgroundColor(SOFT).borderRadius(10) + Column({ space: 6 }) { + Text(RemoteI18n.t('goal.workflow')).fontSize(MobileDesignTypography.labelSmall.size).fontColor(MUTED) + Text(RemoteI18n.t(this.state.goal.status === 'active' ? 'goal.workflowHint' : this.state.goal.status === 'paused' ? 'goal.pausedHint' : 'goal.' + this.statusKey())) + .fontSize(MobileDesignTypography.bodySmall.size).fontColor(MUTED) + }.width('100%').alignItems(HorizontalAlign.Start).padding(14).backgroundColor(SOFT).borderRadius(10) + Divider().color(LINE) + Row({ space: 8 }) { + this.Action('modify', () => this.edit()) + if (this.state.goal.status === 'active') { + this.Action('pause', () => this.onAction(new GoalRequest(GoalAction.Pause))) + } else if (['paused', 'blocked', 'usageLimited'].indexOf(this.state.goal.status) >= 0) { + this.Action('resume', () => this.onAction(new GoalRequest(GoalAction.Resume))) + } + this.Action('clear', () => { this.clearing = true; }, false, true) + }.width('100%') + } + } + }.alignItems(HorizontalAlign.Start).width('100%').padding({ left: 20, right: 20, top: 12, bottom: 24 }) + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/MobileDesignGallery.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/MobileDesignGallery.ets index 2047ff7c21..0c72347e7b 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/MobileDesignGallery.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/MobileDesignGallery.ets @@ -1,5 +1,6 @@ import { HistoryScrollPreview } from './HistoryScrollPreview'; import { ColdStartHomePreview } from './ColdStartHomePreview'; +import { ThreadGoalPreview } from './ThreadGoalPreview'; import { ComposerSubmissionPreview } from './ComposerSubmissionPreview'; import { DurableTimelinePreview } from './DurableTimelinePreview'; import { DeviceSelectorPreview } from './DeviceSelectorPreview'; @@ -38,7 +39,9 @@ struct MobileDesignGallery { } build() { - if ((AppStorage.get('scenarioId') || '').startsWith('cold-start-home')) { + if ((AppStorage.get('scenarioId') || '').startsWith('thread-goal')) { + ThreadGoalPreview() + } else if ((AppStorage.get('scenarioId') || '').startsWith('cold-start-home')) { ColdStartHomePreview() } else if (AppStorage.get('scenarioId') === 'history-scroll') { HistoryScrollPreview() diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/ThreadGoalPreview.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/ThreadGoalPreview.ets new file mode 100644 index 0000000000..08433849b9 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/ThreadGoalPreview.ets @@ -0,0 +1,36 @@ +import { GoalAction, GoalRequest, ThreadGoalState } from '../../model/ThreadGoal'; +import { ThreadGoalPanel } from '../components/ThreadGoalPanel'; +import { PAGE_BG } from '../components/Theme'; + +/** Isolated native fixture: all actions remain in memory, with no remote IO. */ +@ComponentV2 +export struct ThreadGoalPreview { + @Local state: ThreadGoalState = this.initialState(); + private initialState(): ThreadGoalState { + const state = new ThreadGoalState(); + state.sessionId = 'goal-preview'; + state.loaded = true; + state.goal = { sessionId: 'goal-preview', objective: 'Verify mobile goal controls', status: 'active', tokensUsed: 1200 }; + return state; + } + build() { + Column() { + ThreadGoalPanel({ state: this.state, sessionId: 'goal-preview', connected: true, + onAction: (request: GoalRequest): void => this.apply(request) }) + }.width('100%').height('100%').justifyContent(FlexAlign.Center).backgroundColor(PAGE_BG) + } + private apply(request: GoalRequest): void { + const next = this.initialState(); + next.goal = this.state.goal; + next.visible = request.action === GoalAction.Close ? false : request.action === GoalAction.Read ? this.state.visible : true; + if (request.action === GoalAction.Clear) next.goal = undefined; + if (request.action === GoalAction.Start || request.action === GoalAction.Edit) { + next.goal = { sessionId: 'goal-preview', objective: request.objective, status: 'active', tokensUsed: 0 }; + } + if (next.goal && (request.action === GoalAction.Pause || request.action === GoalAction.Resume)) { + next.goal = { sessionId: 'goal-preview', objective: next.goal.objective, + status: request.action === GoalAction.Pause ? 'paused' : 'active', tokensUsed: 1200 }; + } + this.state = next; + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntime.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntime.ets index e8544b90dc..845b06e646 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntime.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntime.ets @@ -83,6 +83,7 @@ export class AppRootRuntime extends AppRootRuntimeComposition { } onPageShow(): void { + this.conversationController.setGoalForeground(true); RemoteLogger.info(`page show state=${(this.remotePageState.connectionState as ConnectionState)} route=${this.appShellViewModel.currentRoute()}`); this.taskCompletionNotificationController.onForeground(); this.remoteActivityViewModel.resume(); @@ -93,6 +94,7 @@ export class AppRootRuntime extends AppRootRuntimeComposition { } onPageHide(): void { + this.conversationController.setGoalForeground(false); RemoteLogger.info(`page hide state=${(this.remotePageState.connectionState as ConnectionState)} route=${this.appShellViewModel.currentRoute()}`); this.settingsController.stopPresencePolling(); const activeRemoteTurn = this.remotePageState.activeTurnMessage; @@ -111,6 +113,7 @@ export class AppRootRuntime extends AppRootRuntimeComposition { } aboutToDisappear(): void { + this.conversationController.setGoalForeground(false); this.miniAppViewModel.close(); this.settingsController.stopPresencePolling(); this.watchProvisionController.stop(); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntimeComposition.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntimeComposition.ets index 7f9a8fdaa5..9828096cf8 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntimeComposition.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntimeComposition.ets @@ -1,3 +1,4 @@ +import { GoalRequest } from '../../model/ThreadGoal'; import { InteractionMailboxAction, InteractionMailboxState } from '../../model/InteractionMailbox'; import { HostCatalogChange, HostCatalogObserver } from '../../services/HostCatalogObserver'; import { WorkspaceToolsViewModel } from '../viewmodel/WorkspaceToolsViewModel'; @@ -767,6 +768,7 @@ export abstract class AppRootRuntimeComposition { ); readonly conversationIntentDispatcher: ConversationIntentDispatcher = new ConversationIntentDispatcher({ + goal: async (request: GoalRequest): Promise => { await this.conversationController.goalAction(request); }, mailbox: async (action: InteractionMailboxAction): Promise => { await this.remoteChatPollingLifecycleController.dispatchMailbox(action); }, openSidebar: (): void => this.openAppSidebar(), back: (): void => this.exitActiveChat(), diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationViewState.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationViewState.ets index 7452089d09..dd059e994c 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationViewState.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationViewState.ets @@ -1,3 +1,4 @@ +import { ThreadGoalState } from '../../model/ThreadGoal'; import { InteractionMailboxState } from '../../model/InteractionMailbox'; import { ObservableChatTimelineItem } from '../../model/ChatTimelineModels'; import { @@ -25,6 +26,7 @@ import { RemotePageState } from './RemotePageState'; /** Immutable presentation state consumed by ConversationView. */ export class ConversationViewState { + threadGoal: ThreadGoalState = new ThreadGoalState(); interactionMailbox: InteractionMailboxState = new InteractionMailboxState(); activeSession: ConversationUiSession = ConversationViewState.emptySession(); surface: ChatSurface = ChatSurface.General; @@ -86,6 +88,7 @@ export class ConversationViewState { state.downloadedFilePath = remote.downloadedFilePath; state.fileDownloadStatus = remote.fileDownloadStatus; state.interactionMailbox = remote.interactionMailbox; + state.threadGoal = remote.threadGoal; return state; } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemotePageState.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemotePageState.ets index b66b7cda5c..861dd61b78 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemotePageState.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemotePageState.ets @@ -1,3 +1,4 @@ +import { ThreadGoalState } from '../../model/ThreadGoal'; import { TimeFormat } from '../../services/TimeFormat'; import { InteractionMailboxState } from '../../model/InteractionMailbox'; import { WorkspaceToolsState } from './WorkspaceToolsState'; @@ -22,6 +23,7 @@ import { ConversationCoreState } from './ConversationCoreState'; */ @ObservedV2 export class RemotePageState { + @Trace threadGoal: ThreadGoalState = new ThreadGoalState(); @Trace interactionMailbox: InteractionMailboxState = new InteractionMailboxState(); readonly workspaceTools: WorkspaceToolsState = new WorkspaceToolsState(); @Trace conversation: ConversationCoreState = new ConversationCoreState('code'); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/ConversationController.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/ConversationController.ets index 71f8762672..0e07822689 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/ConversationController.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/ConversationController.ets @@ -1,3 +1,4 @@ +import { GoalRequest } from '../../model/ThreadGoal'; import { RemoteQuestionAnswerPayload, RemoteSession, @@ -121,6 +122,9 @@ export class ConversationController { } knownPollVersion(): number { return this.transcript.knownPollVersion(); } + setGoalForeground(active: boolean): void { this.transcript.goals.setForeground(active); } + + async goalAction(request: GoalRequest): Promise { await this.transcript.goals.dispatch(request); } resetKnownRemoteState(): void { this.transcript.resetKnownRemoteState(); } updateKnownMessageCount(pollVersion: number, knownMessageCount: number): void { this.transcript.updateKnownMessageCount(pollVersion, knownMessageCount); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteGoalController.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteGoalController.ets new file mode 100644 index 0000000000..fb031e000c --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteGoalController.ets @@ -0,0 +1,88 @@ +import { GoalAction, GoalRequest, ThreadGoalState } from '../../model/ThreadGoal'; +import { RemoteSessionManager } from '../../services/RemoteSessionManager'; +import { RemotePageState } from '../state/RemotePageState'; + +/** Goal state belongs to the executing host, never the mobile cache. */ +export class RemoteGoalController { + private foreground: boolean = true; + setForeground(active: boolean): void { + this.foreground = active; + if (active && this.remote.threadGoal.sessionId) void this.dispatch(new GoalRequest(GoalAction.Read)); + } + private timer: number = -1; + private generation: number = 0; + private readonly remote: RemotePageState; + private readonly manager?: RemoteSessionManager; + constructor(remote: RemotePageState, manager?: RemoteSessionManager) { this.remote = remote; this.manager = manager; } + + reset(): void { + this.generation += 1; + if (this.timer >= 0) clearTimeout(this.timer); + this.timer = -1; + this.remote.threadGoal = new ThreadGoalState(); + } + + async dispatch(request: GoalRequest, submittedDraft?: string): Promise { + const sessionId = this.remote.activeSession.sessionId; + if (request.action === GoalAction.Close) { + this.remote.threadGoal = this.remote.threadGoal.withVisibility(false); + return; + } + if (!sessionId || !this.manager) return; + const previous = this.remote.threadGoal.sessionId === sessionId ? this.remote.threadGoal : new ThreadGoalState(); + if (previous.busy) { + if (request.action === GoalAction.Open) this.remote.threadGoal = previous.withVisibility(true); + return; + } + if (request.action === GoalAction.Read && !this.foreground) return; + const state = new ThreadGoalState(); + state.sessionId = sessionId; + state.visible = previous.visible || request.action === GoalAction.Open; + state.loaded = previous.loaded; + state.goal = previous.goal; + if (!this.remote.supportsHostCapability('thread_goal_v1')) { + state.failure = 'unsupported'; + state.visible = state.visible || request.action !== GoalAction.Read; + this.remote.threadGoal = state; + return; + } + const composer = submittedDraft === undefined ? undefined : this.remote.conversation.prepareComposerSubmission(sessionId, submittedDraft, []); + const generation = this.generation; + const target = this.manager.goalTargetGeneration(); + state.busy = true; + this.remote.threadGoal = state; + try { + const response = await this.manager.threadGoal(sessionId, request.action, request.objective || undefined); + if (!this.current(sessionId, generation, target)) return; + const next = new ThreadGoalState(); + next.sessionId = sessionId; + next.visible = this.remote.threadGoal.visible; + next.loaded = true; + next.goal = response.goal; + this.remote.threadGoal = next; + composer?.commit(); + } catch (_) { + if (!this.current(sessionId, generation, target)) return; + const failed = new ThreadGoalState(); + failed.sessionId = sessionId; + failed.visible = this.remote.threadGoal.visible || request.action !== GoalAction.Read; + failed.goal = previous.goal; + failed.loaded = previous.loaded; + failed.failure = 'failed'; + this.remote.threadGoal = failed; + } + if (!this.current(sessionId, generation, target)) return; + if (this.timer >= 0) clearTimeout(this.timer); + this.timer = setTimeout((): void => { + this.timer = -1; + if (this.current(sessionId, generation, target)) { + void this.dispatch(new GoalRequest(GoalAction.Read)); + } + }, 5000); + } + + private current(sessionId: string, generation: number, target: number): boolean { + return this.remote.activeSession.sessionId === sessionId && this.generation === generation && + this.manager?.goalTargetGeneration() === target; + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteTranscriptController.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteTranscriptController.ets index 4eb2940da0..f2541c349d 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteTranscriptController.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteTranscriptController.ets @@ -1,3 +1,5 @@ +import { GoalRequest, parseGoalCommand, ThreadGoalState } from '../../model/ThreadGoal'; +import { RemoteGoalController } from './RemoteGoalController'; import { REMOTE_CAPABILITY_DIALOG_STEER_V1, REMOTE_CAPABILITY_PLAN_BUILD_V1, @@ -23,6 +25,7 @@ import { const REMOTE_TRANSCRIPT_CACHE_INTERVAL_MS: number = 400; export class RemoteTranscriptController { + readonly goals: RemoteGoalController; private readonly remote: RemotePageState; private readonly remoteRuntime?: RemoteConversationDependencies; private readonly notify: (message: string) => void; @@ -47,6 +50,7 @@ export class RemoteTranscriptController { this.remote = remote; this.remoteRuntime = remoteRuntime; this.notify = notify; + this.goals = new RemoteGoalController(remote, remoteRuntime?.sessionManager); } knownPollVersion(): number { @@ -54,6 +58,7 @@ export class RemoteTranscriptController { } resetKnownRemoteState(): void { + this.goals.reset(); this.knownPollVersionValue = 0; this.knownModelCatalogVersion = 0; this.knownRemoteMessageCount = 0; @@ -113,6 +118,17 @@ export class RemoteTranscriptController { !runtime.connection.ensureAvailable()) { return; } + const goalCommand = parseGoalCommand(text); + if (goalCommand) { + if (images.length > 0) { + const state = new ThreadGoalState(); + state.sessionId = sessionId; + state.visible = true; + state.failure = 'attachments'; + this.remote.threadGoal = state; + } else await this.goals.dispatch(goalCommand, this.remote.chatInput); + return; + } const queuedBehindRunningTurn = this.hasRunningRemoteTurn(); const activeTurnId = this.remoteActiveTurnId(); const canSteerRunningTurn = queuedBehindRunningTurn && activeTurnId.length > 0 && diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionManager.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionManager.ets index 2b11f882e1..5172274639 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionManager.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionManager.ets @@ -1,3 +1,4 @@ +import { GoalAction, ThreadGoalResponse } from '../model/ThreadGoal'; import { WorkspaceCatalog, projectWorkspaceCatalog } from './WorkspaceCatalog'; import { RuntimeFileLocation } from '../model/RemoteModels'; import { HostSessionCallbacks, HostSessionStream, REMOTE_CAPABILITY_HOST_STREAM_V1 } from './HostSessionStream'; @@ -52,6 +53,20 @@ export class RemoteSessionManager implements RemoteChatCommandClient, RemoteFile * the message the user can act on instead of probing it with a command it * does not know. */ + goalTargetGeneration(): number { return this.transportGeneration; } + + async threadGoal(sessionId: string, action: GoalAction, objective?: string): Promise { + if (this.hostCapabilities.indexOf('thread_goal_v1') < 0) { + throw new Error('thread_goal_v1 is unsupported'); + } + const response = await this.send({ cmd: 'thread_goal', session_id: sessionId, + action: action === GoalAction.Open ? 'read' : action, objective: objective }); + if (response.resp !== 'thread_goal' || (response.goal && response.goal.sessionId !== sessionId)) { + throw new Error('Invalid thread goal response'); + } + return response; + } + subscribeSession(logicalId: string, callbacks: HostSessionCallbacks): HostSessionStream { const transport = this.transport; if (!transport?.subscribeSession) throw new Error('Remote session streaming is unavailable'); diff --git a/src/apps/mobile/harmonyos/tools/tests/thread-goal.test.cjs b/src/apps/mobile/harmonyos/tools/tests/thread-goal.test.cjs new file mode 100644 index 0000000000..2edf2f34a0 --- /dev/null +++ b/src/apps/mobile/harmonyos/tools/tests/thread-goal.test.cjs @@ -0,0 +1,90 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const ts = require('typescript'); +function load(file, dependencies = {}, timers = {}) { + const source = fs.readFileSync(path.join(__dirname, '../../entry/src/main/ets', file), 'utf8'); + const js = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } }).outputText; + const result = {}; + new Function('require', 'exports', 'setTimeout', 'clearTimeout', js)(name => dependencies[name] || {}, result, timers.set || (() => 1), timers.clear || (() => {})); + return result; +} +const goal = load('model/ThreadGoal.ets'); +const { RemoteGoalController } = load('pages/viewmodel/RemoteGoalController.ets', { '../../model/ThreadGoal': goal }); +function fixture(supported = true) { + const commands = []; + const remote = { activeSession: { sessionId: 's' }, chatInput: '/goal ship it', threadGoal: new goal.ThreadGoalState(), + supportsHostCapability: () => supported, + conversation: { prepareComposerSubmission: () => ({ commit() { remote.chatInput = ''; } }) } }; + const manager = { generation: 1, goalTargetGeneration() { return this.generation; }, + async threadGoal(...args) { commands.push(args); return { resp: 'thread_goal', goal: { sessionId: 's', objective: 'ship it', status: 'active' } }; } }; + return { remote, manager, commands, controller: new RemoteGoalController(remote, manager) }; +} +test('goal parser distinguishes objectives, multiline instructions, and controls', () => { + assert.equal(goal.parseGoalCommand('/goalie x'), undefined); + assert.equal(goal.parseGoalCommand('hello /goal'), undefined); + assert.equal(goal.parseGoalCommand(' /GOAL ').action, 'open'); + assert.equal(goal.parseGoalCommand('/goal pause').action, 'pause'); + assert.equal(goal.parseGoalCommand('/goal pause\nthen test').action, 'start'); + assert.equal(goal.parseGoalCommand('/goal\n目标').objective, '目标'); +}); +test('unsupported host receives no command and retains draft', async () => { + const f = fixture(false); + await f.controller.dispatch(new goal.GoalRequest(goal.GoalAction.Start, 'ship it'), f.remote.chatInput); + assert.equal(f.remote.threadGoal.failure, 'unsupported'); + assert.equal(f.remote.chatInput, '/goal ship it'); + assert.equal(f.commands.length, 0); +}); +test('goal controls project the host response', async () => { + const f = fixture(); + await f.controller.dispatch(new goal.GoalRequest(goal.GoalAction.Start, 'ship it'), f.remote.chatInput); + assert.deepEqual(f.commands[0], ['s', 'start', 'ship it']); + assert.equal(f.remote.threadGoal.goal.status, 'active'); + assert.equal(f.remote.chatInput, ''); + for (const action of ['pause', 'resume', 'edit', 'clear']) { + await f.controller.dispatch(new goal.GoalRequest(action)); + assert.equal(f.commands.at(-1)[1], action); + } +}); +for (const change of ['session', 'target']) test(`late goal response cannot publish after ${change} changes`, async () => { + const f = fixture(); let resolve; + f.manager.threadGoal = () => new Promise(done => { resolve = done; }); + const pending = f.controller.dispatch(new goal.GoalRequest(goal.GoalAction.Open)); + if (change === 'session') f.remote.activeSession = { sessionId: 'other' }; + if (change === 'target') f.manager.generation++; + if (change === 'close') await f.controller.dispatch(new goal.GoalRequest(goal.GoalAction.Close)); + resolve({ goal: { sessionId: 's', objective: 'stale', status: 'active' } }); await pending; + assert.equal(f.remote.threadGoal.goal, undefined); +}); +test('failed mutation retains the last confirmed goal', async () => { + const f = fixture(); + await f.controller.dispatch(new goal.GoalRequest(goal.GoalAction.Open)); + f.manager.threadGoal = async () => { throw new Error('offline'); }; + await f.controller.dispatch(new goal.GoalRequest(goal.GoalAction.Pause)); + assert.equal(f.remote.threadGoal.goal.status, 'active'); + assert.equal(f.remote.threadGoal.failure, 'failed'); + assert.equal(f.remote.threadGoal.busy, false); +}); + +test('opening an in-flight snapshot works and closing keeps the strip without reopening', async () => { + const f = fixture(); let resolve; + f.manager.threadGoal = () => new Promise(done => { resolve = done; }); + const pending = f.controller.dispatch(new goal.GoalRequest(goal.GoalAction.Read)); + assert.equal(f.remote.threadGoal.visible, false); + await f.controller.dispatch(new goal.GoalRequest(goal.GoalAction.Open)); + assert.equal(f.remote.threadGoal.visible, true); + await f.controller.dispatch(new goal.GoalRequest(goal.GoalAction.Close)); + resolve({ goal: { sessionId: 's', objective: 'live goal', status: 'active' } }); await pending; + assert.equal(f.remote.threadGoal.visible, false); + assert.equal(f.remote.threadGoal.goal.objective, 'live goal'); +}); +test('background observation waits for foreground', async () => { + const f = fixture(); + await f.controller.dispatch(new goal.GoalRequest(goal.GoalAction.Read)); + f.controller.setForeground(false); + await f.controller.dispatch(new goal.GoalRequest(goal.GoalAction.Read)); + assert.equal(f.commands.length, 1); + f.controller.setForeground(true); + assert.equal(f.commands.length, 2); +}); diff --git a/src/apps/mobile/ios/OpenBitFun/Features/Chat/ComposerBar.swift b/src/apps/mobile/ios/OpenBitFun/Features/Chat/ComposerBar.swift index 0becc2d0af..3413d6ade2 100644 --- a/src/apps/mobile/ios/OpenBitFun/Features/Chat/ComposerBar.swift +++ b/src/apps/mobile/ios/OpenBitFun/Features/Chat/ComposerBar.swift @@ -55,8 +55,11 @@ struct ComposerBar: View { hasContent && !speech.isListening && !model.isSending } + private var hasGoal: Bool { model.surface == .remote && model.threadGoal?.sessionId == model.selectedSessionID && model.threadGoal?.objective != nil } + var body: some View { VStack(spacing: 2) { + ThreadGoalPanel(model: model) if !model.composerImages.isEmpty { attachmentStrip } @@ -69,7 +72,7 @@ struct ComposerBar: View { } } .padding(.horizontal, 8) - .padding(.top, expanded ? 4 : 0) + .padding(.top, expanded || hasGoal ? 4 : 0) .padding(.bottom, expanded ? 2 : 0) .frame(minHeight: expanded ? MobileDesignGeometry.composerExpandedHeight @@ -80,7 +83,7 @@ struct ComposerBar: View { .background(.ultraThinMaterial) .overlay( RoundedRectangle( - cornerRadius: expanded || !model.composerImages.isEmpty + cornerRadius: expanded || !model.composerImages.isEmpty || hasGoal ? MobileDesignGeometry.composerExpandedRadius : MobileDesignGeometry.composerCollapsedRadius ) @@ -88,7 +91,7 @@ struct ComposerBar: View { ) .clipShape( RoundedRectangle( - cornerRadius: expanded || !model.composerImages.isEmpty + cornerRadius: expanded || !model.composerImages.isEmpty || hasGoal ? MobileDesignGeometry.composerExpandedRadius : MobileDesignGeometry.composerCollapsedRadius ) @@ -279,14 +282,22 @@ struct ComposerBar: View { @ViewBuilder private var attachmentAction: some View { - if model.composerImages.count < 4 { - Button { photoPickerOpen = true } label: { plusGlyph } - .buttonStyle(.plain) - .accessibilityLabel(Text(model.localized("添加图片"))) + if model.surface == .remote { + Menu { + Button(model.localized("添加图片")) { photoPickerOpen = true } + .disabled(model.composerImages.count >= 4) + Divider() + Button(model.localized(hasGoal ? "goal.manage" : "goal.set")) { + focused = false + model.goalAction(.open) + }.disabled(model.selectedSessionID.isEmpty) + } label: { plusGlyph } + .accessibilityLabel(Text(model.localized("goal.manage"))) } else { - Button { model.showToast(model.localized("最多添加 4 张图片")) } label: { plusGlyph } + Button { photoPickerOpen = true } label: { plusGlyph } .buttonStyle(.plain) - .accessibilityLabel(Text(model.localized("已达到图片上限"))) + .disabled(model.composerImages.count >= 4) + .accessibilityLabel(Text(model.localized("添加图片"))) } } diff --git a/src/apps/mobile/ios/OpenBitFun/Features/Chat/ConversationHeader.swift b/src/apps/mobile/ios/OpenBitFun/Features/Chat/ConversationHeader.swift index 43b48292fb..823812a8f1 100644 --- a/src/apps/mobile/ios/OpenBitFun/Features/Chat/ConversationHeader.swift +++ b/src/apps/mobile/ios/OpenBitFun/Features/Chat/ConversationHeader.swift @@ -1,4 +1,6 @@ import SwiftUI +import UIKit +import OpenBitFunMobileCore private let conversationHeaderActionSurfaceSize: CGFloat = 42 @@ -237,3 +239,138 @@ struct ConversationActionsPopover: View { .buttonStyle(.plain) } } + + +struct ThreadGoalPanel: View { + @ObservedObject var model: MobileAppModel + @State private var objective = "" + @State private var editing = false + @State private var clearing = false + @State private var saving = false + private var current: ThreadGoalUiState? { + model.threadGoal.flatMap { $0.sessionId == model.selectedSessionID ? $0 : nil } + } + private var connected: Bool { model.remoteConnected && model.connectionPhase == .connected } + private var enabled: Bool { connected && current?.loaded == true && current?.busy == false && current?.failure == nil } + private var status: String { + let value = current?.status ?? "unknown" + return model.localized("goal.\(["active", "paused", "blocked", "usageLimited", "budgetLimited", "complete"].contains(value) ? value : "unknown")") + } + var body: some View { + if model.surface == .remote { + VStack(spacing: 0) { + if let text = current?.objective { + Button { + UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) + model.goalAction(.open) + } label: { + HStack(spacing: 10) { + Image(systemName: "target").font(.system(size: 17)) + VStack(alignment: .leading, spacing: 2) { + Text(connected ? status : model.localized("goal.offline")) + .font(.caption2).foregroundStyle(OpenBitFunTheme.muted) + Text(text).font(.subheadline).lineLimit(1) + }.frame(maxWidth: .infinity, alignment: .leading) + Image(systemName: "chevron.right").font(.caption) + }.foregroundStyle(OpenBitFunTheme.ink).padding(.horizontal, 12).padding(.vertical, 10) + .background(OpenBitFunTheme.soft, in: RoundedRectangle(cornerRadius: 14)) + }.buttonStyle(.plain).accessibilityLabel(model.localized("goal.manage")) + } + } + .onAppear { refresh() } + .onChange(of: model.selectedSessionID) { _ in objective = ""; editing = false; clearing = false; saving = false; refresh() } + .onChange(of: connected) { _ in refresh() } + .sheet(isPresented: Binding(get: { current?.visible == true }, set: { if !$0 { model.goalAction(.close) } })) { + panel + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) + .onDisappear { objective = ""; editing = false; clearing = false; saving = false } + } + } + } + private func refresh() { if connected && !model.selectedSessionID.isEmpty { model.goalAction(.read) } } + private func text(_ key: String) -> String { model.localized("goal." + key) } + private var panel: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + HStack { + Text(text(clearing ? "clearTitle" : editing ? "modify" : current?.objective == nil ? "set" : "title")) + .font(.title3.weight(.semibold)) + Spacer() + Button { model.goalAction(.close) } label: { + Image(systemName: "xmark").frame(width: 44, height: 44) + }.accessibilityLabel(text("close")) + } + if !connected { Text(text("offline")).font(.subheadline).foregroundStyle(OpenBitFunTheme.muted) } + if let goal = current { + if goal.busy { ProgressView() } + if let failure = goal.failure { + Text(text(failure == .unsupported ? "unsupported" : failure == .attachments ? "attachments" : "failed")) + .font(.subheadline).foregroundStyle(OpenBitFunTheme.muted) + Button(text("retry")) { refresh() }.disabled(!connected || goal.busy) + } + if goal.loaded { + if clearing { + Text(text("clearHint")).font(.subheadline) + HStack { + Button(text("keep")) { clearing = false }.buttonStyle(.bordered) + Spacer() + Button(text("clear"), role: .destructive) { saving = true; model.goalAction(ThreadGoalAction.clear) } + .buttonStyle(.bordered).disabled(!enabled) + } + } else if goal.objective == nil || editing { + Text(text("hint")).font(.subheadline).foregroundStyle(OpenBitFunTheme.muted) + Text(text("description")).font(.caption).foregroundStyle(OpenBitFunTheme.muted) + TextField(text("objective"), text: $objective, axis: .vertical) + .lineLimit(4...8).padding(12).background(OpenBitFunTheme.soft, in: RoundedRectangle(cornerRadius: 10)) + .disabled(!connected || saving) + HStack { + Button(text("cancel")) { if goal.objective != nil { editing = false } else { model.goalAction(.close) } } + Spacer() + Button(text(goal.objective == nil ? "set" : "save")) { + saving = true + model.goalAction(goal.objective == nil ? .start : .edit, objective: objective.trimmingCharacters(in: .whitespacesAndNewlines)) + }.buttonStyle(.borderedProminent).tint(OpenBitFunTheme.ink) + .disabled(!enabled || objective.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } else { + HStack { + Text(status).font(.caption).padding(.horizontal, 10).padding(.vertical, 6) + .background(OpenBitFunTheme.soft, in: Capsule()) + Spacer() + Text(MobileLocalization.format("goal.usage", language: model.appLanguage, goal.tokensUsed)) + .font(.caption2).foregroundStyle(OpenBitFunTheme.muted) + } + card(text("description"), goal.objective ?? "") + card(text("workflow"), goal.status == "active" ? text("workflowHint") : goal.status == "paused" ? text("pausedHint") : status) + Divider() + HStack(spacing: 8) { + Button(text("modify")) { objective = goal.objective ?? ""; editing = true } + Spacer() + if goal.status == "active" { Button(text("pause")) { model.goalAction(.pause) } } + if goal.canResume { Button(text("resume")) { model.goalAction(.resume) } } + Spacer() + Button(text("clear"), role: .destructive) { clearing = true } + }.buttonStyle(.bordered).disabled(!enabled) + } + } + } + }.padding(20).frame(maxWidth: 420).frame(maxWidth: .infinity) + }.background(OpenBitFunTheme.page).foregroundStyle(OpenBitFunTheme.ink) + .onChange(of: current?.busy) { _ in finishSavingIfReady() } + .onChange(of: saving) { _ in finishSavingIfReady() } + } + private func finishSavingIfReady() { + if saving && current?.busy == false { + if current?.failure == nil { editing = false; clearing = false; model.goalAction(.close) } + saving = false + } + } + private func card(_ label: String, _ content: String) -> some View { + VStack(alignment: .leading, spacing: 6) { + Text(label).font(.caption).foregroundStyle(OpenBitFunTheme.muted) + Text(content).font(.subheadline).fixedSize(horizontal: false, vertical: true) + }.frame(maxWidth: .infinity, alignment: .leading).padding(14) + .background(OpenBitFunTheme.soft, in: RoundedRectangle(cornerRadius: 10)) + } +} diff --git a/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+RemoteSession.swift b/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+RemoteSession.swift index e2dff7af7c..3d4062031b 100644 --- a/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+RemoteSession.swift +++ b/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+RemoteSession.swift @@ -12,6 +12,9 @@ extension MobileAppModel { func respondPermission(_ requestID: String, approve: Bool, updatedInput: String?) { coreAdapter?.respondPermission(requestID, approve: approve, updatedInput: updatedInput) } + func goalAction(_ action: ThreadGoalAction, objective: String? = nil) { + coreAdapter?.goalAction(selectedSessionID, action: action, objective: objective) + } func refreshPermissionMailbox() { coreAdapter?.refreshPermissionMailbox() } func apply(remoteTargetBound targetKey: String, epoch: UInt64, accountGeneration generation: UInt64) { @@ -77,6 +80,7 @@ extension MobileAppModel { workspaceSelectionBusy = false completionNotifier.reset() remoteHostCapabilities = [] + threadGoal = nil remoteCreateWorkspacePhase = targetKey.isEmpty ? .unavailable : .loading let clearingVisibleRemoteConversation = surface == .remote || remoteSessionSelected remoteSessionSelected = false @@ -1016,6 +1020,7 @@ extension MobileAppModel { lastApplied: remoteLastAppliedAuthority ) else { return } setPublishedIfChanged(\.remoteOpenedSessionID, to: ready.timeline?.sessionId) + threadGoal = ready.threadGoal permissionMailbox = ready.permissionMailbox completionNotifier.observe(state, target: "\(targetKey):\(epoch)") remoteLastAppliedAuthority = RemoteAuthorityGate.updatedScope( diff --git a/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel.swift b/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel.swift index f6fe7993ea..8502f3ef27 100644 --- a/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel.swift +++ b/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel.swift @@ -26,6 +26,7 @@ final class MobileAppModel: ObservableObject { /// transcript: a reopened session shows them at once, and the host has not /// answered for it yet. See `ChatTranscriptOrigin`. @Published var remoteTranscriptUnconfirmed = false + @Published var threadGoal: ThreadGoalUiState? @Published var permissionMailbox: PermissionMailboxUiState? @Published var remoteConversationLoading = false @Published var remotePermissionMode = "ASK" diff --git a/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileCoreAdapter.swift b/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileCoreAdapter.swift index 9cc9e441cf..f37ca27f1b 100644 --- a/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileCoreAdapter.swift +++ b/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileCoreAdapter.swift @@ -247,6 +247,9 @@ final class MobileCoreAdapter { func respondPermission(_ requestID: String, approve: Bool, updatedInput: String?) { remoteSession?.dispatch(intent: RemoteSessionIntentRespondPermission(requestId: requestID, approve: approve, updatedInput: updatedInput)) } + func goalAction(_ sessionID: String, action: ThreadGoalAction, objective: String?) { + remoteSession?.dispatch(intent: RemoteSessionIntentGoal(sessionId: sessionID, action: action, objective: objective)) + } func refreshPermissionMailbox() { remoteSession?.dispatch(intent: RemoteSessionIntentRefreshPermissionMailbox.shared) } diff --git a/src/apps/mobile/ios/OpenBitFun/Resources/Localizable.xcstrings b/src/apps/mobile/ios/OpenBitFun/Resources/Localizable.xcstrings index e6a01b355e..43544c5999 100644 --- a/src/apps/mobile/ios/OpenBitFun/Resources/Localizable.xcstrings +++ b/src/apps/mobile/ios/OpenBitFun/Resources/Localizable.xcstrings @@ -8968,6 +8968,582 @@ } } } + }, + "goal.title": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Goal" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "目标" + } + } + } + }, + "goal.objective": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Describe the goal" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "描述目标" + } + } + } + }, + "goal.start": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Start" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "开始" + } + } + } + }, + "goal.edit": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Save objective" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "保存目标" + } + } + } + }, + "goal.pause": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Pause" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "暂停" + } + } + } + }, + "goal.resume": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Resume" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "恢复" + } + } + } + }, + "goal.clear": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Clear goal" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "清除目标" + } + } + } + }, + "goal.close": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Close" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "关闭" + } + } + } + }, + "goal.refresh": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Refresh" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "刷新" + } + } + } + }, + "goal.unsupported": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Update the connected host to manage goals." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "请更新所连接的宿主以管理目标。" + } + } + } + }, + "goal.failed": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Could not load or save the goal. Refresh before retrying." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法读取或保存目标,请刷新后重试。" + } + } + } + }, + "goal.attachments": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Goal commands do not accept attachments. Remove them first." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "目标命令不接受附件,请先移除附件。" + } + } + } + }, + "goal.empty": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No goal yet" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "暂无目标" + } + } + } + }, + "goal.usage": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Tokens used: %lld" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已使用 Token:%lld" + } + } + } + }, + "goal.active": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Active" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "进行中" + } + } + } + }, + "goal.paused": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Paused" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已暂停" + } + } + } + }, + "goal.blocked": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Blocked" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已阻塞" + } + } + } + }, + "goal.usageLimited": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Usage limited" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "用量受限" + } + } + } + }, + "goal.budgetLimited": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Budget reached" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "预算已用完" + } + } + } + }, + "goal.complete": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Complete" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已完成" + } + } + } + }, + "goal.unknown": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Unknown status" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未知状态" + } + } + } + }, + "goal.set": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Set goal" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "设置目标" + } + } + } + }, + "goal.manage": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Manage goal" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "管理目标" + } + } + } + }, + "goal.modify": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Edit" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "编辑" + } + } + } + }, + "goal.cancel": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cancel" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "取消" + } + } + } + }, + "goal.save": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Save changes" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "保存修改" + } + } + } + }, + "goal.description": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Objective" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "目标描述" + } + } + } + }, + "goal.hint": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Describe the result and completion criteria. The assistant will keep working toward it when the session is idle." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "描述期望的结果和完成标准。设置后,助手将在会话空闲时持续推进。" + } + } + } + }, + "goal.workflow": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Completion checks" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "完成检查" + } + } + } + }, + "goal.workflowHint": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Continue after each turn and check current evidence against the goal." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "每轮结束后继续推进,并依据当前证据检查目标是否完成。" + } + } + } + }, + "goal.pausedHint": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Automatic continuation and completion checks are paused." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "自动推进与完成检查已暂停。" + } + } + } + }, + "goal.clearTitle": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Clear goal?" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "清除目标?" + } + } + } + }, + "goal.clearHint": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Stop automatic progress toward this goal. Existing messages and work will be kept." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "停止按这个目标自动推进。已有对话和工作结果会保留。" + } + } + } + }, + "goal.keep": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Keep goal" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "保留目标" + } + } + } + }, + "goal.offline": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The device is offline. Reconnect to manage this goal." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "设备已离线,重新连接后可继续管理目标。" + } + } + } + }, + "goal.retry": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Retry" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重试" + } + } + } } }, "version": "1.0" diff --git a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/RemoteSessionStore.kt b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/RemoteSessionStore.kt index 28297a511c..a3fff17b23 100644 --- a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/RemoteSessionStore.kt +++ b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/RemoteSessionStore.kt @@ -283,6 +283,7 @@ public class RemoteSessionStore internal constructor( intent.sessionId, RemoteCommand(cmd = "cancel_tool", toolId = intent.toolId, reason = intent.reason), ) + is RemoteSessionIntent.Goal -> goalAction(intent) is RemoteSessionIntent.SetPermissionMode -> setPermissionMode(intent) is RemoteSessionIntent.RefreshPermissionMode -> refreshPermissionMode() RemoteSessionIntent.RefreshModelCatalog -> refreshModelCatalog() @@ -612,6 +613,9 @@ public class RemoteSessionStore internal constructor( private fun beginWork(): Long { workGeneration += 1 + goalJob?.cancel() + val ready = _state.value as? RemoteSessionUiState.Ready + if (ready?.threadGoal?.busy == true) _state.value = ready.copy(threadGoal = ready.threadGoal.copy(busy = false)) work?.cancel() historyWork?.cancel() modelCatalogRefresh?.cancel() @@ -625,6 +629,7 @@ public class RemoteSessionStore internal constructor( catalogSubscription?.cancel(); catalogRefresh?.cancel() permissionMailbox.select(null) setForeground(false) + goalWatch?.cancel() activeCreateGeneration?.let { generation -> val requestId = (_createOperation.value as? CreateSessionOperationState.InFlight)?.requestId if (requestId != null) { @@ -1616,12 +1621,90 @@ public class RemoteSessionStore internal constructor( private var draftRevision: Long = 0 + private var goalJob: Job? = null + private var goalWatch: Job? = null + private var goalForeground: Boolean = true + + private fun goalAction(intent: RemoteSessionIntent.Goal, submittedDraft: String? = null) { + val ready = _state.value as? RemoteSessionUiState.Ready ?: return + if (ready.selectedSessionId != intent.sessionId) return + val previous = ready.threadGoal.takeIf { it.sessionId == intent.sessionId } ?: ThreadGoalUiState(intent.sessionId) + if (intent.action == ThreadGoalAction.CLOSE) { + _state.value = ready.copy(threadGoal = previous.copy(visible = false)) + return + } + if (goalJob?.isActive == true) { + if (intent.action == ThreadGoalAction.OPEN) _state.value = ready.copy(threadGoal = previous.copy(visible = true)) + return + } + val generation = workGeneration + val draftVersion = draftRevision + val read = intent.action == ThreadGoalAction.OPEN || intent.action == ThreadGoalAction.READ + _state.value = ready.copy(threadGoal = previous.copy(visible = previous.visible || intent.action == ThreadGoalAction.OPEN, busy = true, failure = null)) + goalJob = scope.launch { + try { + if (!hostCapabilitiesKnown) { + val info = transport.send(RemoteCommand(cmd = "get_workspace_info")) + if (!isCurrentWork(generation)) return@launch + recordHostCapabilities(info.capabilities) + } + if ("thread_goal_v1" !in hostCapabilities) { + val latest = _state.value as? RemoteSessionUiState.Ready ?: return@launch + if (latest.selectedSessionId == intent.sessionId && isCurrentWork(generation)) { + _state.value = latest.copy(threadGoal = previous.copy(visible = latest.threadGoal.visible || intent.action != ThreadGoalAction.READ, failure = ThreadGoalFailure.UNSUPPORTED)) + } + return@launch + } + val response = transport.send(RemoteCommand( + cmd = "thread_goal", sessionId = intent.sessionId, + action = if (read) "read" else intent.action.name.lowercase(), objective = intent.objective, + )) + check(response.resp == "thread_goal") { response.message ?: "Invalid goal response" } + val latest = _state.value as? RemoteSessionUiState.Ready ?: return@launch + if (latest.selectedSessionId != intent.sessionId || !isCurrentWork(generation)) return@launch + val goal = response.goal + check(goal == null || goal.sessionId == intent.sessionId) { "Goal session mismatch" } + _state.value = latest.copy(threadGoal = ThreadGoalUiState(intent.sessionId).copy( + sessionId = intent.sessionId, visible = latest.threadGoal.visible, loaded = true, + objective = goal?.objective, status = goal?.status ?: "unknown", + tokensUsed = goal?.tokensUsed ?: 0, tokenBudget = goal?.tokenBudget, + )) + if (submittedDraft != null && draftRevision == draftVersion && latest.draft == submittedDraft) updateDraft("") + if (goalWatch?.isActive != true) { + goalWatch = scope.launch { + while (true) { + delay(5000) + val current = _state.value as? RemoteSessionUiState.Ready ?: break + val selected = current.selectedSessionId ?: continue + if (goalForeground && _connectionPhase.value == ConnectionPhase.CONNECTED) goalAction(RemoteSessionIntent.Goal(selected, ThreadGoalAction.READ)) + } + } + } + } catch (cancelled: CancellationException) { throw cancelled } + catch (_: Throwable) { + val latest = _state.value as? RemoteSessionUiState.Ready ?: return@launch + if (latest.selectedSessionId == intent.sessionId && isCurrentWork(generation)) { + _state.value = latest.copy(threadGoal = latest.threadGoal.copy(busy = false, visible = latest.threadGoal.visible || !read, + failure = if (read) ThreadGoalFailure.LOAD else ThreadGoalFailure.SAVE)) + } + } + } + } + private fun sendMessage(intent: RemoteSessionIntent.SendMessage, plan: RemoteSessionIntent.BuildPlan? = null) { val sessionId = intent.sessionId.trim() val content = intent.content if (sessionId.isEmpty() || (content.trim().isEmpty() && intent.images.isNullOrEmpty())) return val current = _state.value as? RemoteSessionUiState.Ready ?: return if (current.busy || current.selectedSessionId != sessionId || _connectionPhase.value != ConnectionPhase.CONNECTED) return + if (plan == null) { + parseThreadGoalCommand(content)?.let { (action, objective) -> + if (!intent.images.isNullOrEmpty()) { + _state.value = current.copy(threadGoal = ThreadGoalUiState(sessionId).copy(visible = true, failure = ThreadGoalFailure.ATTACHMENTS)) + } else goalAction(RemoteSessionIntent.Goal(sessionId, action, objective), content) + return + } + } val submittedDraftRevision = draftRevision val activeTurnId = current.timeline?.activeTurn?.turnId?.takeIf { it.isNotBlank() } val steering = plan == null && activeTurnId != null && "dialog_steer_v1" in hostCapabilities @@ -2136,6 +2219,13 @@ public class RemoteSessionStore internal constructor( /** Relay replay proves log availability, not that the controlled host is online. * Keep foreground host probes for idle open conversations as well as lists. */ private fun setForeground(active: Boolean) { + goalForeground = active + if (active) { + val ready = _state.value as? RemoteSessionUiState.Ready + if (ready?.selectedSessionId != null && ready.selectedSessionId == ready.threadGoal.sessionId) { + goalAction(RemoteSessionIntent.Goal(ready.threadGoal.sessionId, ThreadGoalAction.READ)) + } + } if (active && healthWork?.isActive == true) return val generation = ++healthGeneration healthWork?.cancel() diff --git a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/RemoteSessionUiState.kt b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/RemoteSessionUiState.kt index 395d0c8740..3e827c06fb 100644 --- a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/RemoteSessionUiState.kt +++ b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/RemoteSessionUiState.kt @@ -294,7 +294,18 @@ public sealed interface RemoteSessionUiState { public val lastSentMessage: SentChatMessage?, public val permissionMailbox: PermissionMailboxUiState, public val historyLoadState: HistoryLoadState, + public val threadGoal: ThreadGoalUiState, ) : RemoteSessionUiState { + public constructor( + sessions: List, selectedSessionId: String?, timeline: ChatTimelineState?, busy: Boolean, + permissionMode: SessionPermissionMode?, permissionModeFailure: PermissionModeFailure?, query: String, + agentFilter: SessionAgentFilter, hasMore: Boolean, hasMoreMessages: Boolean, + modelCatalog: RemoteModelCatalog?, modelCatalogFailure: ModelCatalogFailure?, draft: String, + revision: Long, lastSentMessage: SentChatMessage?, permissionMailbox: PermissionMailboxUiState, + historyLoadState: HistoryLoadState, + ) : this(sessions, selectedSessionId, timeline, busy, permissionMode, permissionModeFailure, query, + agentFilter, hasMore, hasMoreMessages, modelCatalog, modelCatalogFailure, draft, revision, + lastSentMessage, permissionMailbox, historyLoadState, ThreadGoalUiState()) public constructor( sessions: List, selectedSessionId: String?, timeline: ChatTimelineState?, busy: Boolean, permissionMode: SessionPermissionMode?, permissionModeFailure: PermissionModeFailure?, query: String, @@ -396,6 +407,10 @@ public sealed interface RemoteSessionUiState { } public sealed interface RemoteSessionIntent { + public data class Goal(val sessionId: String, val action: ThreadGoalAction, val objective: String?) : RemoteSessionIntent { + public constructor(sessionId: String, action: ThreadGoalAction) : this(sessionId, action, null) + } + public data class StartQuestionInteraction(public val toolId: String) : RemoteSessionIntent public data class RespondPermission(public val requestId: String, public val approve: Boolean, public val updatedInput: String?) : RemoteSessionIntent public data object RefreshPermissionMailbox : RemoteSessionIntent diff --git a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/ThreadGoalUiState.kt b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/ThreadGoalUiState.kt new file mode 100644 index 0000000000..10f94a7703 --- /dev/null +++ b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/ThreadGoalUiState.kt @@ -0,0 +1,35 @@ +package com.openbitfun.mobile.core.feature.session + +public enum class ThreadGoalAction { OPEN, CLOSE, READ, START, EDIT, PAUSE, RESUME, CLEAR } +public enum class ThreadGoalFailure { UNSUPPORTED, LOAD, SAVE, ATTACHMENTS } + +public data class ThreadGoalUiState( + public val sessionId: String, + public val visible: Boolean, + public val busy: Boolean, + public val loaded: Boolean, + public val objective: String?, + public val status: String, + public val tokensUsed: Long, + public val tokenBudget: Long?, + public val failure: ThreadGoalFailure?, +) { + public constructor(sessionId: String) : this(sessionId, false, false, false, null, "unknown", 0, null, null) + public constructor() : this("") + public val canResume: Boolean get() = status in setOf("paused", "blocked", "usageLimited") +} + +/** Controls match whole arguments; `/goal pause\nthen verify` is an objective. */ +internal fun parseThreadGoalCommand(text: String): Pair? { + val value = text.trim() + if (!value.startsWith("/goal", ignoreCase = true) || + (value.length > 5 && !value[5].isWhitespace())) return null + val argument = value.drop(5).trim() + return when (argument.lowercase()) { + "", "edit" -> ThreadGoalAction.OPEN to null + "pause" -> ThreadGoalAction.PAUSE to null + "resume" -> ThreadGoalAction.RESUME to null + "clear" -> ThreadGoalAction.CLEAR to null + else -> ThreadGoalAction.START to argument + } +} diff --git a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/session/RemoteSessionStoreTest.kt b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/session/RemoteSessionStoreTest.kt index f4523c2874..e1543105bf 100644 --- a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/session/RemoteSessionStoreTest.kt +++ b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/session/RemoteSessionStoreTest.kt @@ -38,6 +38,111 @@ import kotlin.test.assertTrue @OptIn(ExperimentalCoroutinesApi::class) class RemoteSessionStoreTest { + @Test + fun goalUnsupportedHostNeverReceivesGoalOrChatCommand() = runTest { + val transport = FakeSessionTransport() + val store = RemoteSessionStore(this, transport) + store.dispatch(RemoteSessionIntent.Open("s-code")); runCurrent() + store.dispatch(RemoteSessionIntent.UpdateDraft("/goal ship it")) + store.dispatch(RemoteSessionIntent.SendMessage("s-code", "/goal ship it")); runCurrent() + val ready = assertIs(store.state.value) + assertEquals(ThreadGoalFailure.UNSUPPORTED, ready.threadGoal.failure) + assertEquals("/goal ship it", ready.draft) + assertFalse(transport.commands.any { it.cmd in listOf("thread_goal", "send_message", "steer_turn") }) + store.stop() + } + + @Test + fun goalCommandsUseHostManagementAndPreserveNewDraft() = runTest { + val transport = FakeSessionTransport().apply { capabilitiesJson = "[\"host_stream_v1\",\"thread_goal_v1\"]" } + val store = RemoteSessionStore(this, transport) + store.dispatch(RemoteSessionIntent.Open("s-code")); runCurrent() + val gate = CompletableDeferred() + transport.commandGates["thread_goal"] = gate + store.dispatch(RemoteSessionIntent.UpdateDraft("/goal ship it")) + store.dispatch(RemoteSessionIntent.SendMessage("s-code", "/goal ship it")); runCurrent() + store.dispatch(RemoteSessionIntent.UpdateDraft("new draft")) + gate.complete(Unit); runCurrent() + val ready = assertIs(store.state.value) + assertEquals("new draft", ready.draft) + assertTrue(ready.threadGoal.loaded) + assertEquals("ship it", ready.threadGoal.objective) + assertEquals("start", transport.commands.last { it.cmd == "thread_goal" }.action) + assertFalse(transport.commands.any { it.cmd in listOf("send_message", "steer_turn") }) + for ((text, action) in listOf("/goal pause" to "pause", "/goal resume" to "resume", "/goal clear" to "clear")) { + store.dispatch(RemoteSessionIntent.SendMessage("s-code", text)); runCurrent() + assertEquals(action, transport.commands.last { it.cmd == "thread_goal" }.action) + } + store.stop() + } + + @Test + fun goalRefreshKeepsUnknownStatusAndCloseKeepsStripFresh() = runTest { + val transport = FakeSessionTransport().apply { + capabilitiesJson = "[\"host_stream_v1\",\"thread_goal_v1\"]" + goalResponse = """{"resp":"thread_goal","goal":{"sessionId":"s-code","objective":"future","status":"future_status","tokensUsed":42}}""" + } + val store = RemoteSessionStore(this, transport) + store.dispatch(RemoteSessionIntent.Open("s-code")); runCurrent() + store.dispatch(RemoteSessionIntent.Goal("s-code", ThreadGoalAction.OPEN)); runCurrent() + assertEquals("future_status", assertIs(store.state.value).threadGoal.status) + transport.goalResponse = """{"resp":"thread_goal","goal":null}""" + advanceTimeBy(5001); runCurrent() + assertNull(assertIs(store.state.value).threadGoal.objective) + store.dispatch(RemoteSessionIntent.Goal("s-code", ThreadGoalAction.CLOSE)); runCurrent() + val count = transport.commands.count { it.cmd == "thread_goal" } + advanceTimeBy(10000); runCurrent() + assertTrue(transport.commands.count { it.cmd == "thread_goal" } > count) + assertFalse(assertIs(store.state.value).threadGoal.visible) + store.stop() + } + + @Test + fun goalObservationPausesInBackgroundAndReloadsOnReturn() = runTest { + val transport = FakeSessionTransport().apply { capabilitiesJson = "[\"host_stream_v1\",\"thread_goal_v1\"]" } + val store = RemoteSessionStore(this, transport) + store.dispatch(RemoteSessionIntent.Open("s-code")); runCurrent() + store.dispatch(RemoteSessionIntent.Goal("s-code", ThreadGoalAction.OPEN)); runCurrent() + store.dispatch(RemoteSessionIntent.SetForeground(false)); runCurrent() + val count = transport.commands.count { it.cmd == "thread_goal" } + advanceTimeBy(10001); runCurrent() + assertEquals(count, transport.commands.count { it.cmd == "thread_goal" }) + store.dispatch(RemoteSessionIntent.SetForeground(true)); runCurrent() + assertEquals(count + 1, transport.commands.count { it.cmd == "thread_goal" }) + store.stop() + } + + @Test + fun goalLateReplyCannotOverwriteAnotherSession() = runTest { + val transport = FakeSessionTransport().apply { capabilitiesJson = "[\"host_stream_v1\",\"thread_goal_v1\"]" } + val store = RemoteSessionStore(this, transport) + store.dispatch(RemoteSessionIntent.Open("s-code")); runCurrent() + transport.nonCancellableCommands += "thread_goal" + store.dispatch(RemoteSessionIntent.Goal("s-code", ThreadGoalAction.OPEN)); runCurrent() + store.dispatch(RemoteSessionIntent.Open("s-cowork")); runCurrent() + transport.lateCommandContinuations.remove("thread_goal")!!.resume(Unit); runCurrent() + val ready = assertIs(store.state.value) + assertEquals("s-cowork", ready.selectedSessionId) + assertNull(ready.threadGoal.objective) + assertFalse(ready.threadGoal.visible) + store.stop() + } + + @Test + fun goalFailureRetainsObjectiveAndDoesNotPretendSuccess() = runTest { + val transport = FakeSessionTransport().apply { capabilitiesJson = "[\"host_stream_v1\",\"thread_goal_v1\"]" } + val store = RemoteSessionStore(this, transport) + store.dispatch(RemoteSessionIntent.Open("s-code")); runCurrent() + store.dispatch(RemoteSessionIntent.Goal("s-code", ThreadGoalAction.OPEN)); runCurrent() + transport.goalResponse = """{"resp":"error","message":"offline"}""" + store.dispatch(RemoteSessionIntent.SendMessage("s-code", "/goal pause")); runCurrent() + val goal = assertIs(store.state.value).threadGoal + assertEquals(ThreadGoalFailure.SAVE, goal.failure) + assertEquals("ship it", goal.objective) + assertEquals("active", goal.status) + store.stop() + } + @Test fun firstHistoryReadDoesNotPublishAnEmptyOrPartialConversation() = runTest { val transport = FakeSessionTransport().apply { @@ -2212,6 +2317,7 @@ private class FakeSessionTransport : RemoteCommandTransport, RemoteSessionStream val commands = mutableListOf() var workspacePath: String = "/repo" /** Every fake host streams on demand; tests that model an older host override this. */ + var goalResponse: String = """{"resp":"thread_goal","goal":{"sessionId":"s-code","objective":"ship it","status":"active"}}""" var capabilitiesJson: String = "[\"host_stream_v1\"]" /** When set, the permission commands fail while everything else works. */ @@ -2306,6 +2412,7 @@ private class FakeSessionTransport : RemoteCommandTransport, RemoteSessionStream modelCatalogFailure?.let { throw RelayTransportException(it) } } val json = when (command.cmd) { + "thread_goal" -> goalResponse "get_workspace_info" -> """{"resp":"ok","has_workspace":${workspacePath.isNotEmpty()},"path":"$workspacePath","capabilities":$capabilitiesJson}""" "get_model_catalog" -> """{ diff --git a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/session/ThreadGoalCommandTest.kt b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/session/ThreadGoalCommandTest.kt new file mode 100644 index 0000000000..c4a0b0b96e --- /dev/null +++ b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/session/ThreadGoalCommandTest.kt @@ -0,0 +1,14 @@ +package com.openbitfun.mobile.core.feature.session + +import kotlin.test.* + +class ThreadGoalCommandTest { + @Test fun wholeCommandAndControlBoundaries() { + assertNull(parseThreadGoalCommand("/goalie hi")) + assertNull(parseThreadGoalCommand("hello /goal")) + assertEquals(ThreadGoalAction.OPEN to null, parseThreadGoalCommand(" /GOAL ")) + assertEquals(ThreadGoalAction.PAUSE to null, parseThreadGoalCommand("/goal pause")) + assertEquals(ThreadGoalAction.START to "pause\nthen verify", parseThreadGoalCommand("/goal pause\nthen verify")) + assertEquals(ThreadGoalAction.START to "objective", parseThreadGoalCommand("/goal\nobjective")) + } +} diff --git a/src/apps/mobile/shared/core-protocol/src/commonMain/kotlin/com/openbitfun/mobile/core/protocol/RemoteCommand.kt b/src/apps/mobile/shared/core-protocol/src/commonMain/kotlin/com/openbitfun/mobile/core/protocol/RemoteCommand.kt index 1bc276b4fc..808915a012 100644 --- a/src/apps/mobile/shared/core-protocol/src/commonMain/kotlin/com/openbitfun/mobile/core/protocol/RemoteCommand.kt +++ b/src/apps/mobile/shared/core-protocol/src/commonMain/kotlin/com/openbitfun/mobile/core/protocol/RemoteCommand.kt @@ -56,6 +56,8 @@ public object RemotePermissionModeSerializer : KSerializer @Serializable public data class RemoteCommand( @SerialName("cmd") val cmd: String, + @SerialName("action") val action: String? = null, + @SerialName("objective") val objective: String? = null, @SerialName("command") val command: String? = null, @SerialName("args") val args: JsonElement? = null, @SerialName("_request_id") val requestId: String? = null, diff --git a/src/apps/mobile/shared/core-protocol/src/commonMain/kotlin/com/openbitfun/mobile/core/protocol/RemoteGoal.kt b/src/apps/mobile/shared/core-protocol/src/commonMain/kotlin/com/openbitfun/mobile/core/protocol/RemoteGoal.kt new file mode 100644 index 0000000000..2d1357e069 --- /dev/null +++ b/src/apps/mobile/shared/core-protocol/src/commonMain/kotlin/com/openbitfun/mobile/core/protocol/RemoteGoal.kt @@ -0,0 +1,21 @@ +package com.openbitfun.mobile.core.protocol + +import kotlinx.serialization.Serializable + +@Serializable +public data class RemoteGoal( + val goalId: String = "", + val sessionId: String = "", + val objective: String = "", + val status: String = "unknown", + val tokensUsed: Long = 0, + val timeUsedSeconds: Long = 0, + val tokenBudget: Long? = null, +) + +@Serializable +public data class RemoteGoalResponse( + override val resp: String? = null, + val goal: RemoteGoal? = null, + override val message: String? = null, +) : CommandStatus diff --git a/src/crates/assembly/core/src/agentic/coordination/scheduler.rs b/src/crates/assembly/core/src/agentic/coordination/scheduler.rs index 490c3d3189..3600302c06 100644 --- a/src/crates/assembly/core/src/agentic/coordination/scheduler.rs +++ b/src/crates/assembly/core/src/agentic/coordination/scheduler.rs @@ -1644,6 +1644,46 @@ impl DialogScheduler { .await } + /// An explicit goal start/edit/resume supersedes interrupted-turn recovery, + /// like a new user message does; otherwise the goal's `AgentSession` + /// steering stays held behind the interrupted turn indefinitely. + pub(crate) async fn abandon_interrupted_turn_for_goal( + &self, + session_id: &str, + ) -> OpenBitFunResult> { + let _operation_guard = self.lock_session_operation(session_id).await; + if self.active_turns.contains(session_id) + || !self + .session_manager + .latest_dialog_turn_holds_dispatch(session_id) + .await? + { + return Ok(None); + } + let Some(turn_id) = self + .session_manager + .abandon_interrupted_dialog_turn(session_id, None) + .await? + else { + return Ok(None); + }; + self.round_injection_buffer + .drain_for_turn(session_id, &turn_id); + self.coordinator + .emit_event(AgenticEvent::DialogTurnCancelled { + session_id: session_id.to_string(), + turn_id: turn_id.clone(), + }) + .await; + if let Err(error) = self.try_start_next_queued_locked(session_id).await { + warn!( + "Failed to dispatch held queue after goal abandoned interrupted recovery: session_id={}, turn_id={}, error={}", + session_id, turn_id, error + ); + } + Ok(Some(turn_id)) + } + async fn cancel_queued_or_active_turn_with_descendant_policy( &self, session_id: &str, @@ -4169,6 +4209,63 @@ mod tests { assert_eq!(scheduler.queue_depth(session_id), 1); } + #[tokio::test] + async fn goal_activation_abandons_interrupted_turn_hold() { + let (scheduler, session_manager, _, root) = test_scheduler_with_persistence(true); + let session_id = "goal-interrupted-hold"; + let turn_id = "turn-interrupted"; + let workspace = fixture_workspace_dir(root.path().join("workspace-goal-hold")); + session_manager + .create_session_with_id( + Some(session_id.to_string()), + "Goal interrupted hold".to_string(), + "Standard".to_string(), + SessionConfig { + workspace_path: Some(workspace.to_string_lossy().into_owned()), + ..Default::default() + }, + ) + .await + .expect("create session"); + session_manager + .start_dialog_turn( + session_id, + "Standard".to_string(), + "original work".to_string(), + Some(turn_id.to_string()), + None, + None, + ) + .await + .expect("start turn"); + session_manager + .mark_dialog_turn_interrupted(session_id, turn_id) + .await + .expect("interrupt turn"); + session_manager + .update_session_state_for_turn_if_processing(session_id, turn_id, SessionState::Idle) + .await + .expect("settle idle"); + + let abandoned = scheduler + .abandon_interrupted_turn_for_goal(session_id) + .await + .expect("abandon should succeed"); + + assert_eq!(abandoned.as_deref(), Some(turn_id)); + assert!(!session_manager + .latest_dialog_turn_holds_dispatch(session_id) + .await + .expect("hold check")); + assert_eq!( + scheduler + .abandon_interrupted_turn_for_goal(session_id) + .await + .expect("second abandon should be a no-op"), + None + ); + } + #[tokio::test] async fn maintenance_does_not_release_parent_while_background_child_is_still_running() { let (scheduler, session_manager, _, root) = test_scheduler(); diff --git a/src/crates/assembly/core/src/service/remote_connect/remote_server.rs b/src/crates/assembly/core/src/service/remote_connect/remote_server.rs index b91f2b8cd3..6af4b9136c 100644 --- a/src/crates/assembly/core/src/service/remote_connect/remote_server.rs +++ b/src/crates/assembly/core/src/service/remote_connect/remote_server.rs @@ -171,6 +171,13 @@ impl<'a> CoreRemoteCommandRuntimeHost<'a> { impl RemoteCommandRuntimeHost for CoreRemoteCommandRuntimeHost<'_> { type ImageContext = crate::agentic::image_analysis::ImageContextData; + async fn handle_goal_command(&self, command: &RemoteCommand) -> RemoteResponse { + match CoreServiceAgentRuntime::remote_thread_goal(self.dispatcher, command).await { + Ok(goal) => RemoteResponse::ThreadGoal { goal }, + Err(message) => RemoteResponse::Error { message }, + } + } + async fn handle_workspace_command(&self, command: &RemoteCommand) -> RemoteResponse { let host = CoreServiceAgentRuntime::remote_workspace_host(); handle_remote_workspace_command(&host, command).await diff --git a/src/crates/assembly/core/src/service_agent_runtime.rs b/src/crates/assembly/core/src/service_agent_runtime.rs index 541686d430..c2dc39e7d7 100644 --- a/src/crates/assembly/core/src/service_agent_runtime.rs +++ b/src/crates/assembly/core/src/service_agent_runtime.rs @@ -2013,6 +2013,124 @@ impl CoreServiceAgentRuntime { CoreRemotePollRuntimeHost::new(dispatcher) } + #[cfg(feature = "remote-connect")] + pub(crate) async fn remote_thread_goal( + dispatcher: &RemoteExecutionDispatcher, + command: &openbitfun_services_integrations::remote_connect::RemoteCommand, + ) -> Result, String> { + use openbitfun_runtime_ports::ThreadGoalStatus; + use openbitfun_services_integrations::remote_connect::{RemoteCommand, RemoteGoalAction}; + let RemoteCommand::ThreadGoal { + session_id, + action, + objective, + } = command + else { + return Err("expected thread_goal".into()); + }; + openbitfun_core_types::validate_session_id(session_id)?; + let host = Self::remote_dialog_host(dispatcher)?; + // Resolve only the session's authoritative host binding, never the + // controller's current directory or the host's selected workspace. + if !host.remote_session_exists(session_id).await? { + let binding = host + .resolve_binding_workspace(session_id) + .await + .ok_or_else(|| "Goal session workspace binding is unavailable".to_string())?; + host.restore_remote_session(session_id, binding).await?; + } + let coordinator = get_global_coordinator().ok_or("Runtime is unavailable")?; + let storage = coordinator + .get_session_manager() + .effective_session_storage_path(session_id) + .await + .ok_or_else(|| "Goal session storage is unavailable".to_string())?; + if *action != RemoteGoalAction::Read { + coordinator + .ensure_session_runtime_ownership(session_id, None) + .map_err(|e| e.to_string())?; + } + // Starting, editing or resuming a goal supersedes a pending interrupted + // turn; otherwise the goal's steering turn stays held behind it. + let abandon_interrupted_turn = || async { + let scheduler = get_global_scheduler().ok_or("Scheduler is unavailable")?; + scheduler + .abandon_interrupted_turn_for_goal(session_id) + .await + .map(|_| ()) + .map_err(|e| e.to_string()) + }; + let result = match action { + RemoteGoalAction::Read => coordinator.get_thread_goal(session_id, &storage).await, + RemoteGoalAction::Start => { + let value = objective.as_deref().unwrap_or("").trim(); + openbitfun_runtime_ports::validate_thread_goal_objective(value)?; + abandon_interrupted_turn().await?; + let existing = coordinator + .get_thread_goal(session_id, &storage) + .await + .map_err(|e| e.to_string())?; + // Re-delivery of the same active objective must not reset usage. + if existing + .as_ref() + .is_some_and(|goal| goal.is_active() && goal.objective == value) + { + return Ok(existing); + } + coordinator + .activate_session_goal(session_id.clone(), Some(value.to_string())) + .await + .map(Some) + } + RemoteGoalAction::Edit => { + let objective = objective.as_deref().unwrap_or("").trim(); + openbitfun_runtime_ports::validate_thread_goal_objective(objective)?; + abandon_interrupted_turn().await?; + coordinator + .update_thread_goal_objective(session_id, &storage, objective.to_string()) + .await + .map(Some) + } + RemoteGoalAction::Pause | RemoteGoalAction::Resume => { + if *action == RemoteGoalAction::Resume { + let existing = coordinator + .get_thread_goal(session_id, &storage) + .await + .map_err(|e| e.to_string())? + .ok_or("No goal to resume")?; + abandon_interrupted_turn().await?; + if existing.status == ThreadGoalStatus::Active { + return Ok(Some(existing)); + } + if !matches!( + existing.status, + ThreadGoalStatus::Paused + | ThreadGoalStatus::Blocked + | ThreadGoalStatus::UsageLimited + ) { + return Err( + "This goal cannot be resumed; edit its objective to start again".into(), + ); + } + } + let status = if *action == RemoteGoalAction::Pause { + ThreadGoalStatus::Paused + } else { + ThreadGoalStatus::Active + }; + coordinator + .set_thread_goal_status(session_id, &storage, status) + .await + .map(Some) + } + RemoteGoalAction::Clear => coordinator + .clear_thread_goal(session_id, &storage) + .await + .map(|_| None), + }; + result.map_err(|e| e.to_string()) + } + #[cfg(feature = "remote-connect")] pub(crate) fn remote_interaction_host() -> CoreRemoteInteractionRuntimeHost { CoreRemoteInteractionRuntimeHost::new() diff --git a/src/crates/services/services-integrations/AGENTS.md b/src/crates/services/services-integrations/AGENTS.md index f57f00b4e4..9aa5d8b0d4 100644 --- a/src/crates/services/services-integrations/AGENTS.md +++ b/src/crates/services/services-integrations/AGENTS.md @@ -187,3 +187,9 @@ cargo test --locked -p openbitfun-services-integrations --no-default-features -- These loopback protocol fixtures exercise the serving host's HTTP and filesystem behavior; they do not establish real SSH, relay, peer or dispatch support. + +For negotiated mobile Goal commands and wire compatibility: + +```bash +cargo test --locked -p openbitfun-services-integrations --no-default-features --features remote-connect --lib thread_goal_wire +``` diff --git a/src/crates/services/services-integrations/src/remote_connect.rs b/src/crates/services/services-integrations/src/remote_connect.rs index 735034f45d..4ac4f47a99 100644 --- a/src/crates/services/services-integrations/src/remote_connect.rs +++ b/src/crates/services/services-integrations/src/remote_connect.rs @@ -554,6 +554,7 @@ where pub const REMOTE_FILE_MAX_READ_BYTES: u64 = 30 * 1024 * 1024; pub const REMOTE_FILE_MAX_CHUNK_BYTES: u64 = 3 * 1024 * 1024; +pub const REMOTE_CAPABILITY_THREAD_GOAL_V1: &str = "thread_goal_v1"; pub const REMOTE_CAPABILITY_HARNESS_PROFILES_V1: &str = "harness_profiles_v1"; pub const REMOTE_CAPABILITY_DIALOG_STEER_V1: &str = "dialog_steer_v1"; pub const REMOTE_CAPABILITY_USER_QUESTION_INTERACTION_V1: &str = "user_question_interaction_v1"; @@ -565,6 +566,7 @@ fn remote_host_capabilities() -> Vec { vec![ "workspace_id_references_v1".to_string(), REMOTE_CAPABILITY_HARNESS_PROFILES_V1.to_string(), + REMOTE_CAPABILITY_THREAD_GOAL_V1.to_string(), REMOTE_CAPABILITY_DIALOG_STEER_V1.to_string(), "dialog_queue_v1".to_string(), REMOTE_CAPABILITY_PLAN_BUILD_V1.to_string(), @@ -2636,6 +2638,12 @@ pub struct RemoteControlClient { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(tag = "cmd", rename_all = "snake_case")] pub enum RemoteCommand { + ThreadGoal { + session_id: String, + action: RemoteGoalAction, + #[serde(default)] + objective: Option, + }, DialogQueue { request: openbitfun_runtime_ports::DialogQueueRequest, }, @@ -2887,10 +2895,25 @@ pub enum RemoteCommand { }, } +/// Explicit goal operations; clients must negotiate `thread_goal_v1` first. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RemoteGoalAction { + Read, + Start, + Edit, + Pause, + Resume, + Clear, +} + /// Responses sent from desktop back to remote clients. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(tag = "resp", rename_all = "snake_case")] pub enum RemoteResponse { + ThreadGoal { + goal: Option, + }, DialogQueue { snapshot: openbitfun_runtime_ports::DialogQueueSnapshot, }, @@ -3145,6 +3168,11 @@ pub enum RemoteResponse { pub trait RemoteCommandRuntimeHost: Send + Sync { type ImageContext: Send + Sync + 'static; + async fn handle_goal_command(&self, _command: &RemoteCommand) -> RemoteResponse { + RemoteResponse::Error { + message: "thread_goal_v1 is unsupported by this host".into(), + } + } async fn handle_workspace_command(&self, command: &RemoteCommand) -> RemoteResponse; async fn handle_session_command(&self, command: &RemoteCommand) -> RemoteResponse; async fn handle_poll_command(&self, command: &RemoteCommand) -> RemoteResponse; @@ -3194,6 +3222,7 @@ where H: RemoteCommandRuntimeHost + ?Sized, { match command { + RemoteCommand::ThreadGoal { .. } => host.handle_goal_command(command).await, RemoteCommand::DialogQueue { request } => { match host.manage_dialog_queue(request.clone()).await { Ok(snapshot) => RemoteResponse::DialogQueue { snapshot }, @@ -4488,6 +4517,33 @@ mod tests { use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Mutex; + #[test] + fn thread_goal_wire_round_trip_and_capability() { + for action in ["read", "start", "edit", "pause", "resume", "clear"] { + let wire = serde_json::json!({"cmd":"thread_goal", "session_id":"s1", "action":action}); + let parsed: RemoteCommand = serde_json::from_value(wire).unwrap(); + let round_trip: RemoteCommand = + serde_json::from_value(serde_json::to_value(&parsed).unwrap()).unwrap(); + assert_eq!(parsed, round_trip); + } + assert!(remote_host_capabilities().contains(&REMOTE_CAPABILITY_THREAD_GOAL_V1.to_string())); + let empty: RemoteResponse = + serde_json::from_str(r#"{"resp":"thread_goal","goal":null}"#).unwrap(); + assert_eq!(empty, RemoteResponse::ThreadGoal { goal: None }); + // An older host's workspace response still has no new requirements. + let old: RemoteResponse = serde_json::from_str( + r#"{"resp":"workspace_info","has_workspace":false,"path":null,"project_name":null}"#, + ) + .unwrap(); + let round_trip: RemoteResponse = + serde_json::from_value(serde_json::to_value(&old).unwrap()).unwrap(); + assert_eq!(old, round_trip); + assert!(serde_json::from_str::( + r#"{"cmd":"thread_goal","session_id":"s1","action":"execute"}"# + ) + .is_err()); + } + struct FakeWorkspaceHost; #[async_trait::async_trait] From ca1b1087ec7a8e70989c8a9c375f783164eb3ff7 Mon Sep 17 00:00:00 2001 From: wgqqqqq Date: Thu, 24 Sep 2026 17:02:53 +0800 Subject: [PATCH 2/5] fix(session): keep generation order when merging rounds missing from a checkpoint When a controller attaches to a Turn mid-run, its projected checkpoint only contains the rounds it observed. At completion the generation journal merge appended the earlier, unmatched rounds after the final answer, so every surface (desktop by array order, mobile by roundIndex) rendered the final reply before the Turn's first rounds. Insert unmatched rounds before the next matched round in generation order and renumber roundIndex accordingly. --- .../src/agentic/session/session_manager.rs | 72 +++++++++++++++++-- 1 file changed, 68 insertions(+), 4 deletions(-) diff --git a/src/crates/assembly/core/src/agentic/session/session_manager.rs b/src/crates/assembly/core/src/agentic/session/session_manager.rs index 9fb4d7df91..fd875cfe60 100644 --- a/src/crates/assembly/core/src/agentic/session/session_manager.rs +++ b/src/crates/assembly/core/src/agentic/session/session_manager.rs @@ -7978,8 +7978,14 @@ impl SessionManager { let generated_rounds = Self::build_model_rounds_from_messages(new_messages, turn_id, timestamp); let generated_count = generated_rounds.len(); + // Rounds missing from the checkpoint (e.g. it was projected only after + // the Turn had already run for a while) must keep their generation + // order: hold them until the next matched round and insert them before + // it instead of appending them after the final answer. + let mut pending_rounds: Vec = Vec::new(); + let mut inserted_before_existing = false; for mut round in generated_rounds { - if let Some(existing_index) = turn + if let Some(mut existing_index) = turn .model_rounds .iter() .position(|existing| existing.id == round.id) @@ -8089,12 +8095,28 @@ impl SessionManager { if existing.status != "streaming" { round.status = existing.status.clone(); } + if !pending_rounds.is_empty() { + let pending_count = pending_rounds.len(); + turn.model_rounds + .splice(existing_index..existing_index, pending_rounds.drain(..)); + existing_index += pending_count; + inserted_before_existing = true; + } turn.model_rounds[existing_index] = round; } else { - round.round_index = next_round_index; - next_round_index = next_round_index.saturating_add(1); - turn.model_rounds.push(round); + pending_rounds.push(round); + } + } + if inserted_before_existing { + for (index, round) in turn.model_rounds.iter_mut().enumerate() { + round.round_index = index; } + next_round_index = turn.model_rounds.len(); + } + for mut round in pending_rounds { + round.round_index = next_round_index; + next_round_index = next_round_index.saturating_add(1); + turn.model_rounds.push(round); } generated_count } @@ -9931,6 +9953,48 @@ mod tests { assert_eq!(result.result["error_detail"]["code"], "edit_no_change"); } + #[test] + fn generation_rounds_missing_from_checkpoint_keep_generation_order() { + let round = |round_id: &str, text: &str| { + let mut message = Message::assistant(text.to_string()); + message.metadata.round_id = Some(round_id.to_string()); + message + }; + let early = [round("early-1", "explore"), round("early-2", "commit")]; + let late = [round("late-1", "verify"), round("final", "done")]; + let mut turn = DialogTurnData::new( + "turn-1".to_string(), + 1, + "session-1".to_string(), + UserMessageData { + id: "user-1".to_string(), + content: "ship it".to_string(), + timestamp: 1, + metadata: None, + }, + ); + // A controller that attached mid-Turn only checkpointed the later rounds. + turn.model_rounds = SessionManager::build_model_rounds_from_messages(&late, "turn-1", 2); + + let generation = [ + early[0].clone(), + early[1].clone(), + late[0].clone(), + late[1].clone(), + ]; + SessionManager::append_generation_rounds(&mut turn, "turn-1", &generation, 3); + + let order = turn + .model_rounds + .iter() + .map(|round| (round.id.as_str(), round.round_index)) + .collect::>(); + assert_eq!( + order, + vec![("early-1", 0), ("early-2", 1), ("late-1", 2), ("final", 3)] + ); + } + #[tokio::test] async fn runtime_model_is_visible_to_turn_admission_config() { let dir = tempfile::tempdir().expect("temporary config directory"); From a60df876b692b53e5650dd92fce1e6200394b09f Mon Sep 17 00:00:00 2001 From: wgqqqqq Date: Thu, 24 Sep 2026 17:30:42 +0800 Subject: [PATCH 3/5] fix(goal): supersede interrupted turns only after goal changes; keep mobile goal actions from being dropped - move interrupted-turn abandon from remote_thread_goal into the coordinator, running only after a goal change commits and will steer the session - KMP/HarmonyOS: background goal refresh no longer shows busy; mutations queue behind in-flight mutations and supersede in-flight reads - add THREAD_GOAL_V1 to remote connect contract tests - tests for abandon ordering and mobile goal gating; drop unreachable close branch --- .../pages/viewmodel/RemoteGoalController.ets | 76 ++++++++++--- .../tools/tests/thread-goal.test.cjs | 20 +++- .../feature/session/RemoteSessionStore.kt | 23 +++- .../feature/session/RemoteSessionStoreTest.kt | 20 ++++ .../src/agentic/coordination/coordinator.rs | 55 ++++++++++ .../src/agentic/coordination/scheduler.rs | 100 ++++++++++++++++++ .../core/src/service_agent_runtime.rs | 13 --- .../tests/remote_connect_contracts.rs | 4 +- 8 files changed, 278 insertions(+), 33 deletions(-) diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteGoalController.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteGoalController.ets index fb031e000c..d0ede49d32 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteGoalController.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteGoalController.ets @@ -1,3 +1,4 @@ +import { ComposerSubmission } from '../../model/ComposerSubmission'; import { GoalAction, GoalRequest, ThreadGoalState } from '../../model/ThreadGoal'; import { RemoteSessionManager } from '../../services/RemoteSessionManager'; import { RemotePageState } from '../state/RemotePageState'; @@ -15,10 +16,16 @@ export class RemoteGoalController { private readonly manager?: RemoteSessionManager; constructor(remote: RemotePageState, manager?: RemoteSessionManager) { this.remote = remote; this.manager = manager; } + private inFlight?: Promise; + private inFlightMutates: boolean = false; + private requestSeq: number = 0; + reset(): void { this.generation += 1; if (this.timer >= 0) clearTimeout(this.timer); this.timer = -1; + this.inFlight = undefined; + this.inFlightMutates = false; this.remote.threadGoal = new ThreadGoalState(); } @@ -29,31 +36,66 @@ export class RemoteGoalController { return; } if (!sessionId || !this.manager) return; - const previous = this.remote.threadGoal.sessionId === sessionId ? this.remote.threadGoal : new ThreadGoalState(); - if (previous.busy) { - if (request.action === GoalAction.Open) this.remote.threadGoal = previous.withVisibility(true); + const read = request.action === GoalAction.Open || request.action === GoalAction.Read; + // Any in-flight request already refreshes the goal; a user change never + // waits behind a refresh, and runs after an earlier change instead of + // being dropped. + if (read && this.inFlight) { + if (request.action === GoalAction.Open) this.remote.threadGoal = this.snapshot(sessionId).withVisibility(true); return; } if (request.action === GoalAction.Read && !this.foreground) return; - const state = new ThreadGoalState(); - state.sessionId = sessionId; - state.visible = previous.visible || request.action === GoalAction.Open; - state.loaded = previous.loaded; - state.goal = previous.goal; if (!this.remote.supportsHostCapability('thread_goal_v1')) { + const previous = this.snapshot(sessionId); + const state = new ThreadGoalState(); + state.sessionId = sessionId; + state.visible = previous.visible || request.action !== GoalAction.Read; + state.loaded = previous.loaded; + state.goal = previous.goal; state.failure = 'unsupported'; - state.visible = state.visible || request.action !== GoalAction.Read; this.remote.threadGoal = state; return; } const composer = submittedDraft === undefined ? undefined : this.remote.conversation.prepareComposerSubmission(sessionId, submittedDraft, []); + const priorChange = this.inFlightMutates ? this.inFlight : undefined; + this.requestSeq += 1; + const run = this.run(request, sessionId, read, this.requestSeq, priorChange, composer); + this.inFlight = run; + this.inFlightMutates = !read; + try { + await run; + } finally { + if (this.inFlight === run) { + this.inFlight = undefined; + this.inFlightMutates = false; + } + } + } + + private async run(request: GoalRequest, sessionId: string, read: boolean, seq: number, + priorChange: Promise | undefined, composer: ComposerSubmission | undefined): Promise { const generation = this.generation; - const target = this.manager.goalTargetGeneration(); - state.busy = true; + const target = this.manager!.goalTargetGeneration(); + if (priorChange) { + await priorChange; + if (!this.current(sessionId, generation, target)) return; + } + const previous = this.snapshot(sessionId); + // Background refreshes of a loaded goal keep the panel interactive. + const showsProgress = !read || !previous.loaded; + const state = new ThreadGoalState(); + state.sessionId = sessionId; + state.visible = previous.visible || request.action === GoalAction.Open; + state.loaded = previous.loaded; + state.goal = previous.goal; + state.busy = showsProgress; + state.failure = showsProgress ? '' : previous.failure; this.remote.threadGoal = state; + // A refresh superseded by a later change must not publish its snapshot. + const superseded = (): boolean => read && seq !== this.requestSeq; try { - const response = await this.manager.threadGoal(sessionId, request.action, request.objective || undefined); - if (!this.current(sessionId, generation, target)) return; + const response = await this.manager!.threadGoal(sessionId, request.action, request.objective || undefined); + if (!this.current(sessionId, generation, target) || superseded()) return; const next = new ThreadGoalState(); next.sessionId = sessionId; next.visible = this.remote.threadGoal.visible; @@ -62,10 +104,10 @@ export class RemoteGoalController { this.remote.threadGoal = next; composer?.commit(); } catch (_) { - if (!this.current(sessionId, generation, target)) return; + if (!this.current(sessionId, generation, target) || superseded()) return; const failed = new ThreadGoalState(); failed.sessionId = sessionId; - failed.visible = this.remote.threadGoal.visible || request.action !== GoalAction.Read; + failed.visible = this.remote.threadGoal.visible || !read; failed.goal = previous.goal; failed.loaded = previous.loaded; failed.failure = 'failed'; @@ -81,6 +123,10 @@ export class RemoteGoalController { }, 5000); } + private snapshot(sessionId: string): ThreadGoalState { + return this.remote.threadGoal.sessionId === sessionId ? this.remote.threadGoal : new ThreadGoalState(); + } + private current(sessionId: string, generation: number, target: number): boolean { return this.remote.activeSession.sessionId === sessionId && this.generation === generation && this.manager?.goalTargetGeneration() === target; diff --git a/src/apps/mobile/harmonyos/tools/tests/thread-goal.test.cjs b/src/apps/mobile/harmonyos/tools/tests/thread-goal.test.cjs index 2edf2f34a0..39068ad4c7 100644 --- a/src/apps/mobile/harmonyos/tools/tests/thread-goal.test.cjs +++ b/src/apps/mobile/harmonyos/tools/tests/thread-goal.test.cjs @@ -53,7 +53,6 @@ for (const change of ['session', 'target']) test(`late goal response cannot publ const pending = f.controller.dispatch(new goal.GoalRequest(goal.GoalAction.Open)); if (change === 'session') f.remote.activeSession = { sessionId: 'other' }; if (change === 'target') f.manager.generation++; - if (change === 'close') await f.controller.dispatch(new goal.GoalRequest(goal.GoalAction.Close)); resolve({ goal: { sessionId: 's', objective: 'stale', status: 'active' } }); await pending; assert.equal(f.remote.threadGoal.goal, undefined); }); @@ -88,3 +87,22 @@ test('background observation waits for foreground', async () => { f.controller.setForeground(true); assert.equal(f.commands.length, 2); }); +test('background refresh stays interactive and changes are never dropped', async () => { + const f = fixture(); + await f.controller.dispatch(new goal.GoalRequest(goal.GoalAction.Open)); + const pending = []; + f.manager.threadGoal = (...args) => { f.commands.push(args); return new Promise(done => pending.push(done)); }; + const refresh = f.controller.dispatch(new goal.GoalRequest(goal.GoalAction.Read)); + assert.equal(f.remote.threadGoal.busy, false); + const pause = f.controller.dispatch(new goal.GoalRequest(goal.GoalAction.Pause)); + assert.equal(f.remote.threadGoal.busy, true); + const resume = f.controller.dispatch(new goal.GoalRequest(goal.GoalAction.Resume)); + pending.shift()({ goal: { sessionId: 's', objective: 'stale refresh', status: 'active' } }); await refresh; + assert.equal(f.remote.threadGoal.goal.objective, 'ship it'); + pending.shift()({ goal: { sessionId: 's', objective: 'ship it', status: 'paused' } }); + await new Promise(done => setImmediate(done)); + pending.shift()({ goal: { sessionId: 's', objective: 'ship it', status: 'active' } }); await Promise.all([pause, resume]); + assert.deepEqual(f.commands.slice(-3).map(command => command[1]), ['read', 'pause', 'resume']); + assert.equal(f.remote.threadGoal.goal.status, 'active'); + assert.equal(f.remote.threadGoal.busy, false); +}); diff --git a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/RemoteSessionStore.kt b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/RemoteSessionStore.kt index a3fff17b23..3b73cf791f 100644 --- a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/RemoteSessionStore.kt +++ b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/RemoteSessionStore.kt @@ -1622,6 +1622,7 @@ public class RemoteSessionStore internal constructor( private var draftRevision: Long = 0 private var goalJob: Job? = null + private var goalJobMutates: Boolean = false private var goalWatch: Job? = null private var goalForeground: Boolean = true @@ -1633,16 +1634,32 @@ public class RemoteSessionStore internal constructor( _state.value = ready.copy(threadGoal = previous.copy(visible = false)) return } - if (goalJob?.isActive == true) { + val read = intent.action == ThreadGoalAction.OPEN || intent.action == ThreadGoalAction.READ + val inFlight = goalJob?.takeIf { it.isActive } + // Any in-flight request already refreshes the goal; a user change never + // waits behind a refresh, and runs after an earlier change instead of + // being dropped. + if (read && inFlight != null) { if (intent.action == ThreadGoalAction.OPEN) _state.value = ready.copy(threadGoal = previous.copy(visible = true)) return } + val priorChange = inFlight?.takeIf { goalJobMutates } + if (inFlight != null && priorChange == null) inFlight.cancel() val generation = workGeneration val draftVersion = draftRevision - val read = intent.action == ThreadGoalAction.OPEN || intent.action == ThreadGoalAction.READ - _state.value = ready.copy(threadGoal = previous.copy(visible = previous.visible || intent.action == ThreadGoalAction.OPEN, busy = true, failure = null)) + // Background refreshes of a loaded goal keep the panel interactive. + val showsProgress = !read || !previous.loaded + val visible = previous.visible || intent.action == ThreadGoalAction.OPEN + _state.value = ready.copy(threadGoal = if (showsProgress) previous.copy(visible = visible, busy = true, failure = null) else previous.copy(visible = visible)) + goalJobMutates = !read goalJob = scope.launch { try { + if (priorChange != null) { + priorChange.join() + val latest = _state.value as? RemoteSessionUiState.Ready ?: return@launch + if (latest.selectedSessionId != intent.sessionId || !isCurrentWork(generation)) return@launch + _state.value = latest.copy(threadGoal = latest.threadGoal.copy(busy = true, failure = null)) + } if (!hostCapabilitiesKnown) { val info = transport.send(RemoteCommand(cmd = "get_workspace_info")) if (!isCurrentWork(generation)) return@launch diff --git a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/session/RemoteSessionStoreTest.kt b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/session/RemoteSessionStoreTest.kt index e1543105bf..aca34f5d27 100644 --- a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/session/RemoteSessionStoreTest.kt +++ b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/session/RemoteSessionStoreTest.kt @@ -76,6 +76,26 @@ class RemoteSessionStoreTest { store.stop() } + @Test + fun goalRefreshStaysInteractiveAndChangesAreNeverDropped() = runTest { + val transport = FakeSessionTransport().apply { capabilitiesJson = "[\"host_stream_v1\",\"thread_goal_v1\"]" } + val store = RemoteSessionStore(this, transport) + store.dispatch(RemoteSessionIntent.Open("s-code")); runCurrent() + store.dispatch(RemoteSessionIntent.Goal("s-code", ThreadGoalAction.OPEN)); runCurrent() + val gate = CompletableDeferred() + transport.commandGates["thread_goal"] = gate + advanceTimeBy(5001); runCurrent() + assertEquals("read", transport.commands.last { it.cmd == "thread_goal" }.action) + assertFalse(assertIs(store.state.value).threadGoal.busy) + store.dispatch(RemoteSessionIntent.SendMessage("s-code", "/goal pause")); runCurrent() + assertTrue(assertIs(store.state.value).threadGoal.busy) + store.dispatch(RemoteSessionIntent.SendMessage("s-code", "/goal resume")); runCurrent() + gate.complete(Unit); runCurrent() + assertEquals(listOf("read", "pause", "resume"), transport.commands.filter { it.cmd == "thread_goal" }.takeLast(3).map { it.action }) + assertFalse(assertIs(store.state.value).threadGoal.busy) + store.stop() + } + @Test fun goalRefreshKeepsUnknownStatusAndCloseKeepsStripFresh() = runTest { val transport = FakeSessionTransport().apply { diff --git a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs index fba95e8b9e..a86ee30750 100644 --- a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs +++ b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs @@ -5016,6 +5016,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet .await; drop(goal_guard); if objective_changed && result.goal.is_active() { + self.supersede_interrupted_turn_for_goal(session_id).await; self.apply_objective_updated_steering(session_id, &result.goal) .await; } @@ -5062,12 +5063,44 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet .await; drop(goal_guard); if objective_changed && result.goal.is_active() { + self.supersede_interrupted_turn_for_goal(session_id).await; self.apply_objective_updated_steering(session_id, &result.goal) .await; } Ok(result.goal) } + /// A goal change that is about to steer the session supersedes a pending + /// interrupted turn, like a new user message does; otherwise the steering + /// stays held behind it. Runs only after the goal change has been + /// committed, so a rejected or no-op goal command keeps the recovery point. + /// + /// Returns a boxed future because abandoning re-enters the scheduler, whose + /// futures already await this coordinator's goal methods. + fn supersede_interrupted_turn_for_goal<'a>( + &'a self, + session_id: &'a str, + ) -> std::pin::Pin + Send + 'a>> { + #[cfg(test)] + let scheduler = test_goal_scheduler::current().or_else(get_global_scheduler); + #[cfg(not(test))] + let scheduler = get_global_scheduler(); + Box::pin(async move { + let Some(scheduler) = scheduler else { + return; + }; + if let Err(error) = scheduler + .abandon_interrupted_turn_for_goal(session_id) + .await + { + warn!( + "Failed to abandon interrupted turn for goal steering: session_id={}, error={}", + session_id, error + ); + } + }) + } + async fn apply_objective_updated_steering(&self, session_id: &str, goal: &ThreadGoal) { if !goal.is_active() { return; @@ -5200,6 +5233,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet .await; drop(goal_guard); if resuming && result.goal.is_active() { + self.supersede_interrupted_turn_for_goal(session_id).await; clear_thread_goal_continuation_abort(session_id); self.schedule_thread_goal_resumed_steering(session_id, &result.goal); } @@ -15438,6 +15472,27 @@ async fn new_btw_session_memory_mode_from_global_config() -> SessionMemoryMode { } // Global coordinator singleton +/// Lets scheduler tests route goal-driven interrupted-turn abandonment to +/// their own scheduler without touching the process-wide one. +#[cfg(test)] +pub(crate) mod test_goal_scheduler { + use super::super::scheduler::DialogScheduler; + use std::cell::RefCell; + use std::sync::Arc; + + thread_local! { + static SCHEDULER: RefCell>> = const { RefCell::new(None) }; + } + + pub(crate) fn set(scheduler: Option>) { + SCHEDULER.with(|slot| *slot.borrow_mut() = scheduler); + } + + pub(super) fn current() -> Option> { + SCHEDULER.with(|slot| slot.borrow().clone()) + } +} + static GLOBAL_COORDINATOR: OnceLock> = OnceLock::new(); /// Get global coordinator diff --git a/src/crates/assembly/core/src/agentic/coordination/scheduler.rs b/src/crates/assembly/core/src/agentic/coordination/scheduler.rs index 3600302c06..24aff3a4fe 100644 --- a/src/crates/assembly/core/src/agentic/coordination/scheduler.rs +++ b/src/crates/assembly/core/src/agentic/coordination/scheduler.rs @@ -4266,6 +4266,106 @@ mod tests { ); } + async fn interrupt_new_turn(session_manager: &SessionManager, session_id: &str, turn_id: &str) { + session_manager + .start_dialog_turn( + session_id, + "Standard".to_string(), + "original work".to_string(), + Some(turn_id.to_string()), + None, + None, + ) + .await + .expect("start turn"); + session_manager + .mark_dialog_turn_interrupted(session_id, turn_id) + .await + .expect("interrupt turn"); + session_manager + .update_session_state_for_turn_if_processing(session_id, turn_id, SessionState::Idle) + .await + .expect("settle idle"); + } + + #[tokio::test] + async fn goal_commands_abandon_interrupted_turn_only_after_changing_the_goal() { + let (scheduler, session_manager, _, root) = test_scheduler_with_persistence(true); + crate::agentic::coordination::coordinator::test_goal_scheduler::set(Some( + scheduler.clone(), + )); + let session_id = "goal-supersedes-interrupted"; + let workspace = fixture_workspace_dir(root.path().join("workspace-goal-supersede")); + session_manager + .create_session_with_id( + Some(session_id.to_string()), + "Goal supersedes interrupted".to_string(), + "Standard".to_string(), + SessionConfig { + workspace_path: Some(workspace.to_string_lossy().into_owned()), + ..Default::default() + }, + ) + .await + .expect("create session"); + let holds = || async { + session_manager + .latest_dialog_turn_holds_dispatch(session_id) + .await + .expect("hold check") + }; + interrupt_new_turn(&session_manager, session_id, "turn-1").await; + + // A rejected command must keep the recovery point. + scheduler + .coordinator + .update_thread_goal_objective(session_id, &workspace, "edit nothing".into()) + .await + .expect_err("editing without a goal fails"); + assert!(holds().await); + + scheduler + .coordinator + .activate_session_goal(session_id.to_string(), Some("ship it".into())) + .await + .expect("activate goal"); + assert!(!holds().await, "a new goal supersedes the interrupted turn"); + + // Pausing, and re-activating a goal that is not resumable, change no + // steering and keep the recovery point. + scheduler + .coordinator + .set_thread_goal_status(session_id, &workspace, ThreadGoalStatus::Paused) + .await + .expect("pause goal"); + interrupt_new_turn(&session_manager, session_id, "turn-2").await; + scheduler + .coordinator + .set_thread_goal_status(session_id, &workspace, ThreadGoalStatus::Complete) + .await + .expect("complete goal"); + scheduler + .coordinator + .set_thread_goal_status(session_id, &workspace, ThreadGoalStatus::Active) + .await + .expect("reactivate completed goal"); + assert!(holds().await); + + scheduler + .coordinator + .set_thread_goal_status(session_id, &workspace, ThreadGoalStatus::Paused) + .await + .expect("pause goal again"); + scheduler + .coordinator + .set_thread_goal_status(session_id, &workspace, ThreadGoalStatus::Active) + .await + .expect("resume goal"); + assert!(!holds().await, "resuming supersedes the interrupted turn"); + + crate::agentic::coordination::coordinator::test_goal_scheduler::set(None); + } + #[tokio::test] async fn maintenance_does_not_release_parent_while_background_child_is_still_running() { let (scheduler, session_manager, _, root) = test_scheduler(); diff --git a/src/crates/assembly/core/src/service_agent_runtime.rs b/src/crates/assembly/core/src/service_agent_runtime.rs index c2dc39e7d7..a4cb1b1b73 100644 --- a/src/crates/assembly/core/src/service_agent_runtime.rs +++ b/src/crates/assembly/core/src/service_agent_runtime.rs @@ -2050,22 +2050,11 @@ impl CoreServiceAgentRuntime { .ensure_session_runtime_ownership(session_id, None) .map_err(|e| e.to_string())?; } - // Starting, editing or resuming a goal supersedes a pending interrupted - // turn; otherwise the goal's steering turn stays held behind it. - let abandon_interrupted_turn = || async { - let scheduler = get_global_scheduler().ok_or("Scheduler is unavailable")?; - scheduler - .abandon_interrupted_turn_for_goal(session_id) - .await - .map(|_| ()) - .map_err(|e| e.to_string()) - }; let result = match action { RemoteGoalAction::Read => coordinator.get_thread_goal(session_id, &storage).await, RemoteGoalAction::Start => { let value = objective.as_deref().unwrap_or("").trim(); openbitfun_runtime_ports::validate_thread_goal_objective(value)?; - abandon_interrupted_turn().await?; let existing = coordinator .get_thread_goal(session_id, &storage) .await @@ -2085,7 +2074,6 @@ impl CoreServiceAgentRuntime { RemoteGoalAction::Edit => { let objective = objective.as_deref().unwrap_or("").trim(); openbitfun_runtime_ports::validate_thread_goal_objective(objective)?; - abandon_interrupted_turn().await?; coordinator .update_thread_goal_objective(session_id, &storage, objective.to_string()) .await @@ -2098,7 +2086,6 @@ impl CoreServiceAgentRuntime { .await .map_err(|e| e.to_string())? .ok_or("No goal to resume")?; - abandon_interrupted_turn().await?; if existing.status == ThreadGoalStatus::Active { return Ok(Some(existing)); } diff --git a/src/crates/services/services-integrations/tests/remote_connect_contracts.rs b/src/crates/services/services-integrations/tests/remote_connect_contracts.rs index fc947d54d6..0c28382b98 100644 --- a/src/crates/services/services-integrations/tests/remote_connect_contracts.rs +++ b/src/crates/services/services-integrations/tests/remote_connect_contracts.rs @@ -50,7 +50,7 @@ use openbitfun_services_integrations::remote_connect::{ RemoteWorkspaceKind, RemoteWorkspaceUpdate, TrackerEvent, REMOTE_CAPABILITY_DIALOG_STEER_V1, REMOTE_CAPABILITY_HARNESS_PROFILES_V1, REMOTE_CAPABILITY_HOST_STREAM_V1, REMOTE_CAPABILITY_PLAN_BUILD_V1, REMOTE_CAPABILITY_SESSION_ROLLBACK_V1, - REMOTE_FILE_MAX_CHUNK_BYTES, REMOTE_FILE_MAX_READ_BYTES, + REMOTE_CAPABILITY_THREAD_GOAL_V1, REMOTE_FILE_MAX_CHUNK_BYTES, REMOTE_FILE_MAX_READ_BYTES, }; use std::path::PathBuf; use std::sync::{Arc, Mutex}; @@ -2222,6 +2222,7 @@ fn remote_connect_workspace_response_helpers_own_wire_shape() { serde_json::json!([ "workspace_id_references_v1", REMOTE_CAPABILITY_HARNESS_PROFILES_V1, + REMOTE_CAPABILITY_THREAD_GOAL_V1, REMOTE_CAPABILITY_DIALOG_STEER_V1, "dialog_queue_v1", REMOTE_CAPABILITY_PLAN_BUILD_V1, @@ -2469,6 +2470,7 @@ fn remote_connect_session_response_helpers_own_pagination_and_timestamps() { serde_json::json!([ "workspace_id_references_v1", REMOTE_CAPABILITY_HARNESS_PROFILES_V1, + REMOTE_CAPABILITY_THREAD_GOAL_V1, REMOTE_CAPABILITY_DIALOG_STEER_V1, "dialog_queue_v1", REMOTE_CAPABILITY_PLAN_BUILD_V1, From e75a3649d44c5fc97081a064234ede26370a9e8c Mon Sep 17 00:00:00 2001 From: wgqqqqq Date: Thu, 24 Sep 2026 17:37:25 +0800 Subject: [PATCH 4/5] fix(session): do not inherit pending interrupted turns when branching a session Copied turns kept the source runtime's recovery point and execution generation, so a branch whose last turn was interrupted held dispatch and could resume with the source context. Treat them as abandoned history. --- .../src/agentic/persistence/session_branch.rs | 76 ++++++++++++++++++- 1 file changed, 75 insertions(+), 1 deletion(-) diff --git a/src/crates/assembly/core/src/agentic/persistence/session_branch.rs b/src/crates/assembly/core/src/agentic/persistence/session_branch.rs index 6f95dc6ac9..4a57c93915 100644 --- a/src/crates/assembly/core/src/agentic/persistence/session_branch.rs +++ b/src/crates/assembly/core/src/agentic/persistence/session_branch.rs @@ -1,11 +1,11 @@ use super::manager::PersistenceManager; use crate::agentic::core::{MessageContent, Session, SessionKind}; use crate::util::errors::{OpenBitFunError, OpenBitFunResult}; -use openbitfun_services_core::session::SessionBranchBoundary; use openbitfun_services_core::session::{ build_branched_session_metadata, format_branch_session_name, resolve_branch_session_lineage, BranchSessionMetadataFacts, }; +use openbitfun_services_core::session::{SessionBranchBoundary, TurnStatus}; pub use openbitfun_services_core::session::{SessionBranchRequest, SessionBranchResult}; use std::path::Path; use std::time::{SystemTime, UNIX_EPOCH}; @@ -112,6 +112,17 @@ impl PersistenceManager { let mut branched_turn = turn.clone(); branched_turn.session_id = target_session_id.clone(); branched_turn.turn_index = new_index; + // Recovery points and execution generations belong to the + // source Session's runtime. A copied interrupted turn is + // history in the branch, like an abandoned one; otherwise + // it would hold dispatch there and resume in the wrong + // context. + if branched_turn.recovery.take().is_some() + && branched_turn.status == TurnStatus::Cancelled + { + branched_turn.finish_reason = Some("cancelled".to_string()); + } + branched_turn.recovery_epoch = None; for tool in branched_turn .model_rounds .iter_mut() @@ -819,6 +830,69 @@ mod tests { ); } + #[tokio::test] + async fn branch_session_does_not_inherit_a_pending_interrupted_turn() { + use openbitfun_services_core::session::{ + DialogTurnRecoveryData, DialogTurnRecoveryStatus, TurnStatus, + }; + + let workspace = TestWorkspace::new(); + let manager = + PersistenceManager::new(workspace.path_manager()).expect("persistence manager"); + let source_session = Session::new( + "Source Title".to_string(), + "Standard".to_string(), + Default::default(), + ); + manager + .save_session(workspace.path(), &source_session) + .await + .expect("source session should save"); + let mut interrupted = build_turn(&source_session.session_id, "turn-0", 0, "prompt"); + interrupted.status = TurnStatus::Cancelled; + interrupted.finish_reason = Some("interrupted".to_string()); + interrupted.recovery = Some(DialogTurnRecoveryData { + status: DialogTurnRecoveryStatus::Interrupted, + execution_generation: 2, + resume_count: 1, + interrupted_at: Some(1), + model_id: Some("model-a".to_string()), + }); + interrupted.recovery_epoch = Some(2); + manager + .save_dialog_turn(workspace.path(), &interrupted) + .await + .expect("source turn should save"); + + let result = manager + .branch_session( + workspace.path(), + &SessionBranchRequest { + source_session_id: source_session.session_id.clone(), + source_turn_id: "turn-0".to_string(), + boundary: SessionBranchBoundary::ThroughTurn, + }, + ) + .await + .expect("branch should succeed"); + + let turns = manager + .load_session_turns(workspace.path(), &result.session_id) + .await + .expect("branched turns should load"); + assert_eq!(turns.len(), 1); + assert_eq!(turns[0].status, TurnStatus::Cancelled); + assert_eq!(turns[0].recovery, None); + assert_eq!(turns[0].recovery_epoch, None); + assert_eq!(turns[0].finish_reason.as_deref(), Some("cancelled")); + + let source_turns = manager + .load_session_turns(workspace.path(), &source_session.session_id) + .await + .expect("source turns should load"); + assert!(source_turns[0].recovery.is_some()); + } + #[tokio::test] async fn branch_session_before_middle_turn_copies_only_earlier_turn_state() { let workspace = TestWorkspace::new(); From 9179d8da1718f7073721c7e4dffcf19956618c88 Mon Sep 17 00:00:00 2001 From: wgqqqqq Date: Thu, 24 Sep 2026 18:10:16 +0800 Subject: [PATCH 5/5] fix(goal): keep mobile goal changes alive across unrelated work; only pause active goals - KMP: goal requests use their own generation instead of the session work generation, so loadMore/permission/model/stop-turn no longer cancel an in-flight goal change; they are dropped only on disconnect or session switch - host: remote pause requires an active goal (paused is idempotent), so a goal that completed meanwhile cannot be paused and revived through resume - test: unrelated session work does not drop an in-flight goal change --- .../feature/session/RemoteSessionStore.kt | 36 ++++++++++++++----- .../feature/session/RemoteSessionStoreTest.kt | 19 ++++++++++ .../core/src/service_agent_runtime.rs | 27 ++++++++------ 3 files changed, 63 insertions(+), 19 deletions(-) diff --git a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/RemoteSessionStore.kt b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/RemoteSessionStore.kt index 3b73cf791f..03d0ddef47 100644 --- a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/RemoteSessionStore.kt +++ b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/RemoteSessionStore.kt @@ -613,9 +613,6 @@ public class RemoteSessionStore internal constructor( private fun beginWork(): Long { workGeneration += 1 - goalJob?.cancel() - val ready = _state.value as? RemoteSessionUiState.Ready - if (ready?.threadGoal?.busy == true) _state.value = ready.copy(threadGoal = ready.threadGoal.copy(busy = false)) work?.cancel() historyWork?.cancel() modelCatalogRefresh?.cancel() @@ -630,6 +627,7 @@ public class RemoteSessionStore internal constructor( permissionMailbox.select(null) setForeground(false) goalWatch?.cancel() + cancelGoalWork() activeCreateGeneration?.let { generation -> val requestId = (_createOperation.value as? CreateSessionOperationState.InFlight)?.requestId if (requestId != null) { @@ -1623,9 +1621,25 @@ public class RemoteSessionStore internal constructor( private var goalJob: Job? = null private var goalJobMutates: Boolean = false + private var goalJobSessionId: String? = null + // Goal requests are owned by their session, not by the list/timeline work + // generation: scrolling or stopping a turn must not drop a change the host + // may already have applied. + private var goalGeneration: Long = 0 private var goalWatch: Job? = null private var goalForeground: Boolean = true + private fun isCurrentGoal(token: Long): Boolean = token == goalGeneration + + private fun cancelGoalWork() { + goalGeneration += 1 + goalJob?.cancel() + goalJob = null + goalJobSessionId = null + val ready = _state.value as? RemoteSessionUiState.Ready + if (ready?.threadGoal?.busy == true) _state.value = ready.copy(threadGoal = ready.threadGoal.copy(busy = false)) + } + private fun goalAction(intent: RemoteSessionIntent.Goal, submittedDraft: String? = null) { val ready = _state.value as? RemoteSessionUiState.Ready ?: return if (ready.selectedSessionId != intent.sessionId) return @@ -1635,6 +1649,9 @@ public class RemoteSessionStore internal constructor( return } val read = intent.action == ThreadGoalAction.OPEN || intent.action == ThreadGoalAction.READ + // A request for a session that is no longer open cannot publish; it + // must not hold back the newly opened session's goal either. + if (goalJob?.isActive == true && goalJobSessionId != intent.sessionId) cancelGoalWork() val inFlight = goalJob?.takeIf { it.isActive } // Any in-flight request already refreshes the goal; a user change never // waits behind a refresh, and runs after an earlier change instead of @@ -1645,29 +1662,30 @@ public class RemoteSessionStore internal constructor( } val priorChange = inFlight?.takeIf { goalJobMutates } if (inFlight != null && priorChange == null) inFlight.cancel() - val generation = workGeneration + val generation = goalGeneration val draftVersion = draftRevision // Background refreshes of a loaded goal keep the panel interactive. val showsProgress = !read || !previous.loaded val visible = previous.visible || intent.action == ThreadGoalAction.OPEN _state.value = ready.copy(threadGoal = if (showsProgress) previous.copy(visible = visible, busy = true, failure = null) else previous.copy(visible = visible)) goalJobMutates = !read + goalJobSessionId = intent.sessionId goalJob = scope.launch { try { if (priorChange != null) { priorChange.join() val latest = _state.value as? RemoteSessionUiState.Ready ?: return@launch - if (latest.selectedSessionId != intent.sessionId || !isCurrentWork(generation)) return@launch + if (latest.selectedSessionId != intent.sessionId || !isCurrentGoal(generation)) return@launch _state.value = latest.copy(threadGoal = latest.threadGoal.copy(busy = true, failure = null)) } if (!hostCapabilitiesKnown) { val info = transport.send(RemoteCommand(cmd = "get_workspace_info")) - if (!isCurrentWork(generation)) return@launch + if (!isCurrentGoal(generation)) return@launch recordHostCapabilities(info.capabilities) } if ("thread_goal_v1" !in hostCapabilities) { val latest = _state.value as? RemoteSessionUiState.Ready ?: return@launch - if (latest.selectedSessionId == intent.sessionId && isCurrentWork(generation)) { + if (latest.selectedSessionId == intent.sessionId && isCurrentGoal(generation)) { _state.value = latest.copy(threadGoal = previous.copy(visible = latest.threadGoal.visible || intent.action != ThreadGoalAction.READ, failure = ThreadGoalFailure.UNSUPPORTED)) } return@launch @@ -1678,7 +1696,7 @@ public class RemoteSessionStore internal constructor( )) check(response.resp == "thread_goal") { response.message ?: "Invalid goal response" } val latest = _state.value as? RemoteSessionUiState.Ready ?: return@launch - if (latest.selectedSessionId != intent.sessionId || !isCurrentWork(generation)) return@launch + if (latest.selectedSessionId != intent.sessionId || !isCurrentGoal(generation)) return@launch val goal = response.goal check(goal == null || goal.sessionId == intent.sessionId) { "Goal session mismatch" } _state.value = latest.copy(threadGoal = ThreadGoalUiState(intent.sessionId).copy( @@ -1700,7 +1718,7 @@ public class RemoteSessionStore internal constructor( } catch (cancelled: CancellationException) { throw cancelled } catch (_: Throwable) { val latest = _state.value as? RemoteSessionUiState.Ready ?: return@launch - if (latest.selectedSessionId == intent.sessionId && isCurrentWork(generation)) { + if (latest.selectedSessionId == intent.sessionId && isCurrentGoal(generation)) { _state.value = latest.copy(threadGoal = latest.threadGoal.copy(busy = false, visible = latest.threadGoal.visible || !read, failure = if (read) ThreadGoalFailure.LOAD else ThreadGoalFailure.SAVE)) } diff --git a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/session/RemoteSessionStoreTest.kt b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/session/RemoteSessionStoreTest.kt index aca34f5d27..bff718f20f 100644 --- a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/session/RemoteSessionStoreTest.kt +++ b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/session/RemoteSessionStoreTest.kt @@ -96,6 +96,25 @@ class RemoteSessionStoreTest { store.stop() } + @Test + fun unrelatedSessionWorkDoesNotDropInFlightGoalChange() = runTest { + val transport = FakeSessionTransport().apply { capabilitiesJson = "[\"host_stream_v1\",\"thread_goal_v1\"]" } + val store = RemoteSessionStore(this, transport) + store.dispatch(RemoteSessionIntent.Open("s-code")); runCurrent() + val gate = CompletableDeferred() + transport.commandGates["thread_goal"] = gate + store.dispatch(RemoteSessionIntent.UpdateDraft("/goal ship it")) + store.dispatch(RemoteSessionIntent.SendMessage("s-code", "/goal ship it")); runCurrent() + store.dispatch(RemoteSessionIntent.SetPermissionMode(SessionPermissionMode.FULL_ACCESS)); runCurrent() + assertTrue(assertIs(store.state.value).threadGoal.busy) + gate.complete(Unit); runCurrent() + val ready = assertIs(store.state.value) + assertFalse(ready.threadGoal.busy) + assertEquals("ship it", ready.threadGoal.objective) + assertEquals("", ready.draft) + store.stop() + } + @Test fun goalRefreshKeepsUnknownStatusAndCloseKeepsStripFresh() = runTest { val transport = FakeSessionTransport().apply { diff --git a/src/crates/assembly/core/src/service_agent_runtime.rs b/src/crates/assembly/core/src/service_agent_runtime.rs index a4cb1b1b73..44a67973ec 100644 --- a/src/crates/assembly/core/src/service_agent_runtime.rs +++ b/src/crates/assembly/core/src/service_agent_runtime.rs @@ -2080,12 +2080,23 @@ impl CoreServiceAgentRuntime { .map(Some) } RemoteGoalAction::Pause | RemoteGoalAction::Resume => { - if *action == RemoteGoalAction::Resume { - let existing = coordinator - .get_thread_goal(session_id, &storage) - .await - .map_err(|e| e.to_string())? - .ok_or("No goal to resume")?; + let existing = coordinator + .get_thread_goal(session_id, &storage) + .await + .map_err(|e| e.to_string())?; + // Clients act on a polled snapshot: a goal that finished in the + // meantime must not be paused and then revived through resume. + let status = if *action == RemoteGoalAction::Pause { + let existing = existing.ok_or("No goal to pause")?; + if existing.status == ThreadGoalStatus::Paused { + return Ok(Some(existing)); + } + if existing.status != ThreadGoalStatus::Active { + return Err("Only an active goal can be paused".into()); + } + ThreadGoalStatus::Paused + } else { + let existing = existing.ok_or("No goal to resume")?; if existing.status == ThreadGoalStatus::Active { return Ok(Some(existing)); } @@ -2099,10 +2110,6 @@ impl CoreServiceAgentRuntime { "This goal cannot be resumed; edit its objective to start again".into(), ); } - } - let status = if *action == RemoteGoalAction::Pause { - ThreadGoalStatus::Paused - } else { ThreadGoalStatus::Active }; coordinator