Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,29 @@ class AgentForegroundService : Service() {
true
} catch (t: Throwable) {
Log.w(TAG, "applyForeground failed: ${t.message}")
// The service was launched with startForegroundService(), so the
// system has a hard ~5s deadline for a successful startForeground().
// That deadline is queued the moment the START call lands — it is
// NOT lifted by stopSelf(), which only tears down the component.
// If startForeground() threw (a forked SystemUI rejecting the
// notification, a missing channel after restore, a transient
// DeadObject), swallowing the error and just returning false leaves
// the deadline armed against a service that will never anchor: the
// caller's stopSelf() destroys it, and the system then fires
// ForegroundServiceDidNotStartInTimeException and kills the app.
// Dropping out of the foreground state explicitly is the only
// call that retracts that pending requirement — it tells the
// ActivityManager "this service is no longer expected to go
// foreground", so the already-destroyed component has nothing left
// to answer for.
try {
androidx.core.app.ServiceCompat.stopForeground(
this,
androidx.core.app.ServiceCompat.STOP_FOREGROUND_REMOVE,
)
} catch (t2: Throwable) {
Log.w(TAG, "stopForeground after applyForeground failure also failed: ${t2.message}")
}
false
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,33 +43,42 @@ fun ChatViewModel.resume() {
// behind the input bar.
_forceScrollToBottom.tryEmit(Unit)

// If history ends with assistant (Case 2: text-cancel committed a
// partial assistant turn), append a continue reminder as a user
// message. If it ends with user tool_result (Case 1), it's already
// a valid starting point for the next API call — no reminder needed.
val historyEndsWithAssistant =
agentHistory.lastOrNull()?.role == LLMMessage.Role.ASSISTANT
if (historyEndsWithAssistant) {
val reminder =
"<system-reminder>The user stopped the previous response but now wants to continue. Pick up exactly where you left off.</system-reminder>"
val parts = listOf<AgentContentPart>(AgentContentPart.Text(reminder))
appendBoundedHistory(
LLMMessage(
role = LLMMessage.Role.USER,
content = reminder,
contentParts = parts,
viewModelScope.launch {
// Rebuild agentHistory from the DB before continuing — a fresh
// sendMessage() does exactly this (rebuildAgentHistoryFromDatabase),
// which is why a normal send never carries stale context while a
// Resume could. The in-memory agentHistory lags the DB whenever the
// process was killed mid-turn and the ViewModel was restored with a
// partial in-memory tail, or any path committed rows the in-memory
// list missed. The DB is the authoritative store; trusting memory
// here is how a resume picks up an old, pre-interruption context.
rebuildAgentHistoryFromDatabase(activeSessionId)
// If history ends with assistant (Case 2: text-cancel committed a
// partial assistant turn), append a continue reminder as a user
// message. If it ends with user tool_result (Case 1), it's already
// a valid starting point for the next API call — no reminder needed.
val historyEndsWithAssistant =
agentHistory.lastOrNull()?.role == LLMMessage.Role.ASSISTANT
if (historyEndsWithAssistant) {
val reminder =
"<system-reminder>The user stopped the previous response but now wants to continue. Pick up exactly where you left off.</system-reminder>"
val parts = listOf<AgentContentPart>(AgentContentPart.Text(reminder))
appendBoundedHistory(
LLMMessage(
role = LLMMessage.Role.USER,
content = reminder,
contentParts = parts,
)
)
)
viewModelScope.launch(Dispatchers.IO) {
val partsJson = """[{"type":"text","value":${escapeJson(reminder)}}]"""
val persisted = chatRepository.appendMessage(activeSessionId, "user", partsJson)
withContext(Dispatchers.Main.immediate) {
notePersistedUiRow(null, persisted.id)
launch(Dispatchers.IO) {
val partsJson = """[{"type":"text","value":${escapeJson(reminder)}}]"""
val persisted = chatRepository.appendMessage(activeSessionId, "user", partsJson)
withContext(Dispatchers.Main.immediate) {
notePersistedUiRow(null, persisted.id)
}
}
}
}

viewModelScope.launch {
val baseSystemPrompt = buildSystemPrompt()
val systemPrompt =
if ((provider as? com.openminis.app.provider.anthropic.AnthropicProvider)?.isOAuth == true) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,33 +72,6 @@ fun ChatViewModel.retryLast() {
// Case A too — the row is deleted moments later regardless.
clearPersistedLastAssistantError()

// 2. Pop ONLY a trailing assistant entry from agentHistory (mirrors
// iOS retry() :2107-2109). If the tail is already user(tool_result),
// the next-turn LLM call errored — leave history alone.
val poppedAssistant = if (agentHistory.lastOrNull()?.role == LLMMessage.Role.ASSISTANT) {
val last = agentHistory.removeAt(agentHistory.size - 1)
last
} else null

// 3. GC orphaned tool_result parts whose tool_use is gone (mirrors
// iOS retry() :2114-2128). Walks backward so removeAt is safe.
val liveToolUseIds = agentHistory.flatMap { m ->
m.contentParts.filterIsInstance<AgentContentPart.ToolUse>().map { it.id }
}.toSet()
for (i in agentHistory.indices.reversed()) {
val m = agentHistory[i]
if (m.role != LLMMessage.Role.USER) continue
val cleanedParts = m.contentParts.filter { p ->
p !is AgentContentPart.ToolResult || p.id in liveToolUseIds
}
when {
cleanedParts.isEmpty() && m.contentParts.isNotEmpty() ->
agentHistory.removeAt(i)
cleanedParts.size < m.contentParts.size ->
agentHistory[i] = m.copy(contentParts = cleanedParts)
}
}

val initialProvider = currentProvider ?: return
var provider: LLMProvider = initialProvider
_error.value = null
Expand All @@ -112,6 +85,41 @@ fun ChatViewModel.retryLast() {
try {
val sid = realSessionId.takeIf { it.isNotEmpty() } ?: sessionId

// Rebuild agentHistory from the DB before deciding what to pop —
// a fresh sendMessage() does the same (rebuildAgentHistoryFromDatabase),
// so a normal send never carries stale context while a retry that
// trusted memory could. In-memory agentHistory lags the DB after a
// mid-turn process kill or any commit path memory missed; the DB is
// authoritative. Rebuild FIRST, then run the pop/GC steps below on
// the rebuilt (authoritative) history.
rebuildAgentHistoryFromDatabase(sid)

// 2. Pop ONLY a trailing assistant entry from agentHistory (mirrors
// iOS retry() :2107-2109). If the tail is already user(tool_result),
// the next-turn LLM call errored — leave history alone.
val poppedAssistant = if (agentHistory.lastOrNull()?.role == LLMMessage.Role.ASSISTANT) {
agentHistory.removeAt(agentHistory.size - 1)
} else null

// 3. GC orphaned tool_result parts whose tool_use is gone (mirrors
// iOS retry() :2114-2128). Walks backward so removeAt is safe.
val liveToolUseIds = agentHistory.flatMap { m ->
m.contentParts.filterIsInstance<AgentContentPart.ToolUse>().map { it.id }
}.toSet()
for (i in agentHistory.indices.reversed()) {
val m = agentHistory[i]
if (m.role != LLMMessage.Role.USER) continue
val cleanedParts = m.contentParts.filter { p ->
p !is AgentContentPart.ToolResult || p.id in liveToolUseIds
}
when {
cleanedParts.isEmpty() && m.contentParts.isNotEmpty() ->
agentHistory.removeAt(i)
cleanedParts.size < m.contentParts.size ->
agentHistory[i] = m.copy(contentParts = cleanedParts)
}
}

// T258: only sync the DB when step 2 popped a trailing assistant
// entry from agentHistory. In that case the persisted partial-
// assistant row would resurrect the failed turn on next session
Expand Down
Loading