Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55a838cb78 | ||
|
|
5bd0b2acaf | ||
|
|
8865a31013 | ||
|
|
a199c35377 | ||
|
|
6a495b6e06 | ||
|
|
1617f75f1a | ||
|
|
c519502551 | ||
|
|
dafa6f3a18 | ||
|
|
246d9f1010 | ||
|
|
e9f32673be |
@@ -3,23 +3,14 @@ name: Android On-Demand
|
||||
run-name: Android ${{ inputs.preset }} · ${{ inputs.head_sha }}
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
inputs:
|
||||
head_sha:
|
||||
description: Exact pushed commit SHA to verify
|
||||
required: true
|
||||
type: string
|
||||
preset:
|
||||
description: Android verification lane
|
||||
required: true
|
||||
default: focused
|
||||
type: choice
|
||||
options:
|
||||
- focused
|
||||
- lint
|
||||
- assemble-debug
|
||||
- release-smoke
|
||||
- all-final
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -38,11 +29,19 @@ jobs:
|
||||
shell: bash
|
||||
env:
|
||||
REQUESTED_SHA: ${{ inputs.head_sha }}
|
||||
REQUESTED_PRESET: ${{ inputs.preset }}
|
||||
run: |
|
||||
if [[ ! "$REQUESTED_SHA" =~ ^[0-9a-f]{40}$ ]]; then
|
||||
echo "head_sha must be a full lowercase 40-character commit SHA" >&2
|
||||
exit 2
|
||||
fi
|
||||
case "$REQUESTED_PRESET" in
|
||||
focused|lint|assemble-debug|release-smoke|all-final) ;;
|
||||
*)
|
||||
echo "unsupported Android preset: $REQUESTED_PRESET" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Checkout exact commit
|
||||
uses: actions/checkout@v7
|
||||
|
||||
@@ -20,13 +20,25 @@ on:
|
||||
description: "Exact candidate commit to check"
|
||||
required: true
|
||||
type: string
|
||||
android_preset:
|
||||
description: "Optional Android-only compute lane"
|
||||
required: false
|
||||
default: auto
|
||||
type: choice
|
||||
options:
|
||||
- auto
|
||||
- focused
|
||||
- lint
|
||||
- assemble-debug
|
||||
- release-smoke
|
||||
- all-final
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
concurrency:
|
||||
group: ci-required-${{ github.ref }}
|
||||
group: ci-required-${{ github.event_name == 'workflow_dispatch' && format('{0}-{1}', inputs.head_sha, inputs.android_preset) || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
@@ -113,33 +125,41 @@ jobs:
|
||||
|
||||
android:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.android == 'true'
|
||||
if: needs.changes.outputs.android == 'true' && (github.event_name != 'workflow_dispatch' || inputs.android_preset == 'auto')
|
||||
uses: ./.github/workflows/ci-android.yml
|
||||
|
||||
android_on_demand:
|
||||
needs: changes
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.android_preset != 'auto'
|
||||
uses: ./.github/workflows/android-on-demand.yml
|
||||
with:
|
||||
head_sha: ${{ inputs.head_sha }}
|
||||
preset: ${{ inputs.android_preset }}
|
||||
|
||||
desktop:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.desktop == 'true'
|
||||
if: needs.changes.outputs.desktop == 'true' && (github.event_name != 'workflow_dispatch' || inputs.android_preset == 'auto')
|
||||
uses: ./.github/workflows/ci-desktop.yml
|
||||
|
||||
plugin:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.plugin == 'true'
|
||||
if: needs.changes.outputs.plugin == 'true' && (github.event_name != 'workflow_dispatch' || inputs.android_preset == 'auto')
|
||||
uses: ./.github/workflows/ci-plugin.yml
|
||||
|
||||
dashboard:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.dashboard == 'true'
|
||||
if: needs.changes.outputs.dashboard == 'true' && (github.event_name != 'workflow_dispatch' || inputs.android_preset == 'auto')
|
||||
uses: ./.github/workflows/ci-dashboard.yml
|
||||
|
||||
contract:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.contract == 'true'
|
||||
if: needs.changes.outputs.contract == 'true' && (github.event_name != 'workflow_dispatch' || inputs.android_preset == 'auto')
|
||||
uses: ./.github/workflows/ci-contract.yml
|
||||
|
||||
docs:
|
||||
name: Build public docs
|
||||
needs: changes
|
||||
if: needs.changes.outputs.docs == 'true'
|
||||
if: needs.changes.outputs.docs == 'true' && (github.event_name != 'workflow_dispatch' || inputs.android_preset == 'auto')
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
@@ -161,11 +181,12 @@ jobs:
|
||||
guard:
|
||||
name: Required checks
|
||||
if: always()
|
||||
needs: [changes, android, desktop, plugin, dashboard, contract, docs]
|
||||
needs: [changes, android, android_on_demand, desktop, plugin, dashboard, contract, docs]
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
CHANGES_RESULT: ${{ needs.changes.result }}
|
||||
ANDROID_RESULT: ${{ needs.android.result }}
|
||||
ANDROID_ON_DEMAND_RESULT: ${{ needs.android_on_demand.result }}
|
||||
DESKTOP_RESULT: ${{ needs.desktop.result }}
|
||||
PLUGIN_RESULT: ${{ needs.plugin.result }}
|
||||
DASHBOARD_RESULT: ${{ needs.dashboard.result }}
|
||||
@@ -176,7 +197,7 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
failed=0
|
||||
for check in CHANGES ANDROID DESKTOP PLUGIN DASHBOARD CONTRACT DOCS; do
|
||||
for check in CHANGES ANDROID ANDROID_ON_DEMAND DESKTOP PLUGIN DASHBOARD CONTRACT DOCS; do
|
||||
result_var="${check}_RESULT"
|
||||
result="${!result_var}"
|
||||
echo "$check: $result"
|
||||
|
||||
+5
-4
@@ -270,9 +270,10 @@ Release notes (`RELEASE_NOTES.md`, `app/src/main/assets/whats_new.txt`, `docs/pl
|
||||
|
||||
## Testing
|
||||
|
||||
- **Android cloud verification (preferred for pushed work):** dispatch
|
||||
`.github/workflows/android-on-demand.yml` against the exact pushed SHA with
|
||||
`focused`, `lint`, `assemble-debug`, `release-smoke`, or `all-final`. Check for
|
||||
- **Android cloud verification (preferred for pushed work):** dispatch the
|
||||
registered `Required checks` workflow with an exact base/head SHA pair and
|
||||
`android_preset` set to `focused`, `lint`, `assemble-debug`, `release-smoke`,
|
||||
or `all-final`. It calls the reusable Android workflow from `dev`. Check for
|
||||
an existing run before dispatching the same SHA/preset again. The four
|
||||
`all-final` compute jobs use isolated runners and may execute concurrently.
|
||||
- **Full local Android gate (optional):** `scripts\dev.bat prepush` on Windows
|
||||
@@ -287,7 +288,7 @@ Release notes (`RELEASE_NOTES.md`, `app/src/main/assets/whats_new.txt`, `docs/pl
|
||||
device lane is scheduled automatically.
|
||||
- **Python tests:** `python -m unittest plugin.tests.test_<name>` from the repo root with the hermes-agent venv active. `pytest` works too but the pre-existing `conftest.py` imports a module that isn't always installed — `unittest` avoids that entirely.
|
||||
|
||||
CI is split into path-filtered workflows: `.github/workflows/ci-android.yml` (lint + build + test on app/Gradle changes), `.github/workflows/ci-server.yml` (syntax check + focused server tests on plugin/Python changes), and `.github/workflows/ci-desktop.yml` (desktop type/build/smoke checks). They run on pushes to `main` and `dev` and on PRs targeting either when their paths are touched. `android-on-demand.yml` is the trusted manual compute lane for an exact pushed commit; it does not replace required PR checks.
|
||||
CI is split into path-filtered workflows: `.github/workflows/ci-android.yml` (lint + build + test on app/Gradle changes), `.github/workflows/ci-server.yml` (syntax check + focused server tests on plugin/Python changes), and `.github/workflows/ci-desktop.yml` (desktop type/build/smoke checks). They run on pushes to `main` and `dev` and on PRs targeting either when their paths are touched. The registered `ci-required.yml` dispatcher calls `android-on-demand.yml` as the trusted manual compute lane for an exact pushed commit; it does not replace required PR checks.
|
||||
Superseded Android runs on `dev` and PR refs are canceled automatically; `main`
|
||||
runs are never canceled because each release-branch commit must complete its
|
||||
independent validation.
|
||||
|
||||
@@ -2436,14 +2436,7 @@ internal fun Throwable.isDashboardSignInRequiredFailure(): Boolean {
|
||||
java.util.IdentityHashMap<Throwable, Boolean>(),
|
||||
)
|
||||
while (current != null && seen.add(current)) {
|
||||
if (
|
||||
current is DashboardHttpException &&
|
||||
current.statusCode == 401 &&
|
||||
(
|
||||
current.message.orEmpty().contains("no_cookie", ignoreCase = true) ||
|
||||
current.message.orEmpty().contains("unauthenticated", ignoreCase = true)
|
||||
)
|
||||
) {
|
||||
if (current is DashboardHttpException && current.statusCode == 401) {
|
||||
return true
|
||||
}
|
||||
current = current.cause
|
||||
|
||||
@@ -209,6 +209,7 @@ internal class HermesRuntimeBinder(
|
||||
chat.setProfileMessageLoaderWithMode { profileName, sessionId, mode ->
|
||||
connection.loadProfileScopedMessages(profileName, sessionId, mode)
|
||||
}
|
||||
chat.setDashboardSignInRequiredHandler(connection::probeNow)
|
||||
chat.setDashboardConfigLoader { connection.loadActiveDashboardConfig() }
|
||||
chat.profileSessionDeleter = connection::deleteSession
|
||||
chat.profileSessionRenamer = connection::renameSession
|
||||
|
||||
@@ -3849,6 +3849,12 @@ class ChatViewModel : ViewModel() {
|
||||
profileSessionPageLister = lister
|
||||
}
|
||||
|
||||
private var dashboardSignInRequiredHandler: (() -> Unit)? = null
|
||||
|
||||
fun setDashboardSignInRequiredHandler(handler: () -> Unit) {
|
||||
dashboardSignInRequiredHandler = handler
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a session scoped to the active profile on gateway connections
|
||||
* (dashboard `DELETE /api/sessions/{id}?profile=`). The write twin of
|
||||
@@ -4237,7 +4243,19 @@ class ChatViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
private fun publishHistoryLoadFailure(sessionId: String, error: Throwable) {
|
||||
if (error.isDashboardSignInRequiredFailure()) return
|
||||
if (error.isDashboardSignInRequiredFailure()) {
|
||||
dashboardSignInRequiredHandler?.invoke()
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Auth,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = "Dashboard sign-in required for chat history",
|
||||
detail = "stored_session=$sessionId; dashboard_auth=required",
|
||||
operation = "load chat history",
|
||||
endpointRole = "gateway",
|
||||
suggestion = "Sign in to Dashboard on the active route, then retry this conversation.",
|
||||
)
|
||||
return
|
||||
}
|
||||
val rawError = error.message?.takeIf { it.isNotBlank() }
|
||||
?: "The active profile's conversation history could not be reached."
|
||||
_chatFailure.value = ChatFailureNotice(
|
||||
@@ -7824,13 +7842,28 @@ class ChatViewModel : ViewModel() {
|
||||
if (!queuedSuccessorPending.get()) {
|
||||
val expectedSessionId = checkpoint.sessionId
|
||||
viewModelScope.launch {
|
||||
val history = loadSessionHistory(expectedSessionId)
|
||||
if (handler.currentSessionId.value == expectedSessionId && history.isNotEmpty()) {
|
||||
handler.loadMessageHistory(history)
|
||||
refreshSessions()
|
||||
scheduleTitleReconcile(expectedSessionId)
|
||||
try {
|
||||
val history = loadSessionHistory(expectedSessionId)
|
||||
if (
|
||||
handler.currentSessionId.value == expectedSessionId &&
|
||||
history.isNotEmpty()
|
||||
) {
|
||||
handler.loadMessageHistory(history)
|
||||
clearMatchingHistoryLoadFailure(expectedSessionId)
|
||||
}
|
||||
} catch (e: kotlinx.coroutines.CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
if (handler.currentSessionId.value == expectedSessionId) {
|
||||
publishHistoryLoadFailure(expectedSessionId, e)
|
||||
}
|
||||
} finally {
|
||||
if (handler.currentSessionId.value == expectedSessionId) {
|
||||
refreshSessions()
|
||||
scheduleTitleReconcile(expectedSessionId)
|
||||
}
|
||||
drainQueue()
|
||||
}
|
||||
drainQueue()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9712,34 +9745,44 @@ class ChatViewModel : ViewModel() {
|
||||
)
|
||||
}
|
||||
viewModelScope.launch {
|
||||
if (!turnErrored && !gatewayHistoryReconcileRequired) {
|
||||
// Profile-aware read: a gateway turn on a non-default profile
|
||||
// persists into THAT profile's own state.db, so the bare
|
||||
// api_server `/api/sessions/{id}/messages` 404s → emptyList()
|
||||
// → a silent wipe of the just-finished turn. loadSessionHistory
|
||||
// prefers the `?profile=` dashboard loader on gateway connections.
|
||||
val serverMessages = loadSessionHistory(sid)
|
||||
val missingPersistedToolActivity =
|
||||
completedTransport == "gateway" &&
|
||||
handler.hasMissingPersistedToolActivity(serverMessages)
|
||||
if (shouldReloadHistoryAfterSuccessfulTurn(
|
||||
actualTransport = completedTransport,
|
||||
gatewayReconcileRequired = gatewayHistoryReconcileRequired,
|
||||
missingPersistedToolActivity = missingPersistedToolActivity,
|
||||
)
|
||||
) {
|
||||
handler.loadMessageHistory(serverMessages)
|
||||
try {
|
||||
if (!turnErrored && !gatewayHistoryReconcileRequired) {
|
||||
// Profile-aware read: a gateway turn on a non-default profile
|
||||
// persists into THAT profile's own state.db, so the bare
|
||||
// api_server `/api/sessions/{id}/messages` 404s → emptyList()
|
||||
// → a silent wipe of the just-finished turn. loadSessionHistory
|
||||
// prefers the `?profile=` dashboard loader on gateway connections.
|
||||
val serverMessages = loadSessionHistory(sid)
|
||||
val missingPersistedToolActivity =
|
||||
completedTransport == "gateway" &&
|
||||
handler.hasMissingPersistedToolActivity(serverMessages)
|
||||
if (shouldReloadHistoryAfterSuccessfulTurn(
|
||||
actualTransport = completedTransport,
|
||||
gatewayReconcileRequired = gatewayHistoryReconcileRequired,
|
||||
missingPersistedToolActivity = missingPersistedToolActivity,
|
||||
)
|
||||
) {
|
||||
handler.loadMessageHistory(serverMessages)
|
||||
clearMatchingHistoryLoadFailure(sid)
|
||||
}
|
||||
}
|
||||
} catch (e: kotlinx.coroutines.CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
if (handler.currentSessionId.value == sid) {
|
||||
publishHistoryLoadFailure(sid, e)
|
||||
}
|
||||
} finally {
|
||||
// Re-sync the drawer now that the turn is persisted server-side.
|
||||
// The only other auto-refresh fires ~160ms after session creation
|
||||
// (RelayApp) — mid-stream, BEFORE the new session's first message
|
||||
// is persisted, so a brand-new chat would otherwise stay missing
|
||||
// from the drawer (carried only by the optimistic row) until a
|
||||
// manual reload. By message.complete the dashboard list includes it.
|
||||
refreshSessions()
|
||||
scheduleTitleReconcile(sid)
|
||||
drainQueue()
|
||||
}
|
||||
// Re-sync the drawer now that the turn is persisted server-side.
|
||||
// The only other auto-refresh fires ~160ms after session creation
|
||||
// (RelayApp) — mid-stream, BEFORE the new session's first message
|
||||
// is persisted, so a brand-new chat would otherwise stay missing
|
||||
// from the drawer (carried only by the optimistic row) until a
|
||||
// manual reload. By message.complete the dashboard list includes it.
|
||||
refreshSessions()
|
||||
scheduleTitleReconcile(sid)
|
||||
drainQueue()
|
||||
}
|
||||
Unit
|
||||
} else {
|
||||
|
||||
+8
-1
@@ -562,17 +562,24 @@ class DashboardApiClientTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun signInRequiredClassifierAcceptsNoCookieButRejectsForbidden() {
|
||||
fun signInRequiredClassifierAcceptsEveryUnauthorizedShapeButRejectsForbidden() {
|
||||
val noCookie = DashboardHttpException(
|
||||
401,
|
||||
"Session failed - HTTP 401: {\"reason\":\"no_cookie\",\"detail\":\"Unauthorized\"}",
|
||||
)
|
||||
val expired = DashboardHttpException(
|
||||
401,
|
||||
"Session failed - HTTP 401: {\"reason\":\"session_expired\",\"detail\":\"invalid_or_expired_session\"}",
|
||||
)
|
||||
val generic = DashboardHttpException(401, "Session failed - HTTP 401: Unauthorized")
|
||||
val forbidden = DashboardHttpException(
|
||||
403,
|
||||
"Session failed - HTTP 403: forbidden",
|
||||
)
|
||||
|
||||
assertTrue(noCookie.isDashboardSignInRequiredFailure())
|
||||
assertTrue(expired.isDashboardSignInRequiredFailure())
|
||||
assertTrue(generic.isDashboardSignInRequiredFailure())
|
||||
assertFalse(forbidden.isDashboardSignInRequiredFailure())
|
||||
}
|
||||
|
||||
|
||||
+143
@@ -238,6 +238,63 @@ class ChatViewModelGatewayInboundTurnTest {
|
||||
assertEquals("gateway", diagnostic.endpointRole)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun normalCompletionUnauthorizedHistoryPreservesTranscriptAndRunsCleanup() {
|
||||
DiagnosticsLog.clear()
|
||||
apiMessageRequestCount.set(0)
|
||||
val owner = Profile(name = "owner", model = "model-a", description = "Owner")
|
||||
val requestedProfiles = mutableListOf<String?>()
|
||||
val sessionRefreshes = AtomicInteger(0)
|
||||
val signInRequests = AtomicInteger(0)
|
||||
viewModel.setSelectedProfileProvider { owner }
|
||||
viewModel.setSessionProfileNameProvider { owner.name }
|
||||
viewModel.setProfileMessageLoaderWithMode { profileName, sessionId, _ ->
|
||||
requestedProfiles += profileName
|
||||
assertEquals(STORED_SESSION_ID, sessionId)
|
||||
Result.failure(
|
||||
DashboardHttpException(401, "Session failed - HTTP 401: Unauthorized"),
|
||||
)
|
||||
}
|
||||
viewModel.setProfileSessionLister { profileName ->
|
||||
assertEquals(owner.name, profileName)
|
||||
sessionRefreshes.incrementAndGet()
|
||||
Result.success(emptyList())
|
||||
}
|
||||
viewModel.setDashboardSignInRequiredHandler { signInRequests.incrementAndGet() }
|
||||
|
||||
viewModel.sendMessage("Keep this local prompt")
|
||||
gatewayHarness.awaitRpc("prompt.submit")
|
||||
serverWs.send(
|
||||
gatewayHarness.eventFrame(
|
||||
"message.delta",
|
||||
buildJsonObject { put("text", "Keep this local answer") },
|
||||
"live-resumed",
|
||||
),
|
||||
)
|
||||
serverWs.send(
|
||||
gatewayHarness.eventFrame(
|
||||
"message.complete",
|
||||
buildJsonObject { put("text", "Keep this local answer") },
|
||||
"live-resumed",
|
||||
),
|
||||
)
|
||||
|
||||
awaitCondition {
|
||||
!handler.isStreaming.value &&
|
||||
signInRequests.get() == 1 &&
|
||||
sessionRefreshes.get() >= 1
|
||||
}
|
||||
assertTrue(handler.messages.value.any { it.content == "Keep this local prompt" })
|
||||
assertTrue(handler.messages.value.any { it.content == "Keep this local answer" })
|
||||
assertEquals(listOf(owner.name), requestedProfiles)
|
||||
assertEquals(0, apiMessageRequestCount.get())
|
||||
assertFalse(viewModel.steerableTurn.value)
|
||||
assertNull(viewModel.chatFailure.value)
|
||||
val diagnostic = DiagnosticsLog.recent(setOf(DiagnosticCategory.Auth), 1).single()
|
||||
assertEquals("Dashboard sign-in required for chat history", diagnostic.title)
|
||||
assertTrue(diagnostic.suggestion.orEmpty().contains("Sign in to Dashboard"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun missingRequiredProfileHistoryLoaderFailsClosedWithoutApiRead() {
|
||||
DiagnosticsLog.clear()
|
||||
@@ -2746,6 +2803,92 @@ class ChatViewModelGatewayInboundTurnTest {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun recoveredCompletionUnauthorizedHistoryPreservesTranscriptAndRunsCleanup() {
|
||||
DiagnosticsLog.clear()
|
||||
apiMessageRequestCount.set(0)
|
||||
val checkpointStore = MemoryCheckpointStore(
|
||||
ChatTurnCheckpoint(
|
||||
contextKey = PROFILE_CONTEXT,
|
||||
sessionId = STORED_SESSION_ID,
|
||||
liveSessionId = "live-resumed",
|
||||
transport = "gateway",
|
||||
user = ChatTurnUserCheckpoint("prior-user", "Recovered prompt", 1L),
|
||||
assistant = ChatTurnAssistantCheckpoint(
|
||||
id = "prior-assistant",
|
||||
content = "Recovered partial",
|
||||
timestamp = 2L,
|
||||
),
|
||||
priorUserMessageCount = 0,
|
||||
baselineAssistantCount = 0,
|
||||
startedAt = 2L,
|
||||
updatedAt = System.currentTimeMillis(),
|
||||
),
|
||||
)
|
||||
val requestedProfiles = mutableListOf<String?>()
|
||||
val sessionRefreshes = AtomicInteger(0)
|
||||
val signInRequests = AtomicInteger(0)
|
||||
var failHistory = false
|
||||
viewModel.setProfileMessageLoaderWithMode { profileName, sessionId, _ ->
|
||||
requestedProfiles += profileName
|
||||
assertEquals(STORED_SESSION_ID, sessionId)
|
||||
if (failHistory) {
|
||||
Result.failure(
|
||||
DashboardHttpException(
|
||||
401,
|
||||
"Session failed - HTTP 401: {\"reason\":\"session_expired\"}",
|
||||
),
|
||||
)
|
||||
} else {
|
||||
Result.success(emptyList())
|
||||
}
|
||||
}
|
||||
viewModel.setProfileSessionLister { profileName ->
|
||||
assertNull(profileName)
|
||||
sessionRefreshes.incrementAndGet()
|
||||
Result.success(emptyList())
|
||||
}
|
||||
viewModel.setDashboardSignInRequiredHandler { signInRequests.incrementAndGet() }
|
||||
gatewayHarness.recoveryRunning = true
|
||||
gatewayHarness.recoveryAssistant = "Recovered partial"
|
||||
viewModel.setChatTurnCheckpointStore(checkpointStore)
|
||||
handler.setSessionId(null)
|
||||
viewModel.switchProfileContext(PROFILE_CONTEXT, STORED_SESSION_ID)
|
||||
|
||||
viewModel.prewarmGateway()
|
||||
gatewayHarness.awaitRpc("session.activate")
|
||||
awaitCondition {
|
||||
handler.messages.value.any { it.id == "prior-assistant" && it.isStreaming }
|
||||
}
|
||||
failHistory = true
|
||||
serverWs.send(
|
||||
gatewayHarness.eventFrame(
|
||||
"message.complete",
|
||||
buildJsonObject { put("text", "Recovered answer") },
|
||||
"live-resumed",
|
||||
),
|
||||
)
|
||||
|
||||
awaitCondition {
|
||||
!handler.isStreaming.value &&
|
||||
signInRequests.get() == 1 &&
|
||||
sessionRefreshes.get() >= 1
|
||||
}
|
||||
assertTrue(handler.messages.value.any {
|
||||
it.id == "prior-assistant" &&
|
||||
it.content.contains("Recovered answer") &&
|
||||
!it.isStreaming
|
||||
})
|
||||
assertTrue(requestedProfiles.isNotEmpty())
|
||||
assertTrue(requestedProfiles.all { it == null })
|
||||
assertEquals(0, apiMessageRequestCount.get())
|
||||
assertFalse(viewModel.steerableTurn.value)
|
||||
awaitCondition { checkpointStore.checkpoint == null }
|
||||
assertNull(viewModel.chatFailure.value)
|
||||
val diagnostic = DiagnosticsLog.recent(setOf(DiagnosticCategory.Auth), 1).single()
|
||||
assertEquals("Dashboard sign-in required for chat history", diagnostic.title)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun lateCanceledCompletionDrainsBeforeImmediateNextTurn() {
|
||||
serverWs.send(gatewayHarness.eventFrame("message.start", null, "live-resumed"))
|
||||
|
||||
@@ -26,17 +26,23 @@ Check recent runs before dispatching so another task does not duplicate the
|
||||
same SHA and preset:
|
||||
|
||||
```powershell
|
||||
gh run list --workflow android-on-demand.yml --event workflow_dispatch --limit 20
|
||||
$base = git merge-base origin/dev HEAD
|
||||
$sha = git rev-parse HEAD
|
||||
gh workflow run android-on-demand.yml --ref dev -f head_sha=$sha -f preset=focused
|
||||
gh run list --workflow android-on-demand.yml --event workflow_dispatch --limit 5
|
||||
gh run list --workflow ci-required.yml --event workflow_dispatch --limit 20
|
||||
gh workflow run ci-required.yml --ref dev `
|
||||
-f base_sha=$base `
|
||||
-f head_sha=$sha `
|
||||
-f android_preset=focused
|
||||
gh run list --workflow ci-required.yml --event workflow_dispatch --limit 5
|
||||
```
|
||||
|
||||
The SHA must already exist on GitHub. Do not push solely to obtain cloud compute
|
||||
without push authorization. On-demand jobs read shared Gradle cache state but
|
||||
do not write it, so task commits cannot replace the cache populated by trusted
|
||||
`dev`/`main` CI. The on-demand result supplements rather than replaces required
|
||||
PR checks.
|
||||
PR checks. `Required checks` is the registered dispatcher because GitHub only
|
||||
registers manual workflow entry points from the default branch; it calls the
|
||||
Android workflow from the selected `dev` ref.
|
||||
|
||||
## Local use
|
||||
|
||||
|
||||
Reference in New Issue
Block a user