feat(ui): Enhanced agent thinking detection for Ollama models

- Added semantic detection module (agent-status-detection.ts) that analyzes
  message content for keywords like 'standby', 'processing', 'analyzing'
- Updated isAgentThinking() in multi-task-chat to use semantic detection
  when streaming has technically ended but agent is conceptually working
- Added dynamic status messages (PROCESSING, AGENT PROCESSING, etc.)
  instead of static THINKING/STREAMING labels
- Enhanced session-status.ts to check semantic content before returning idle
- Fixes issue where Ollama models output status messages and pause,
  causing UI to incorrectly show ready-to-send state
This commit is contained in:
Gemini AI
2025-12-30 02:53:21 +04:00
Unverified
parent eb863bdde7
commit 942582e981
3 changed files with 292 additions and 3 deletions

View File

@@ -10,6 +10,7 @@ import { addToTaskQueue, getSoloState, setActiveTaskId, toggleAutonomous, toggle
import { getLogger } from "@/lib/logger";
import { clearCompactionSuggestion, getCompactionSuggestion } from "@/stores/session-compaction";
import { emitSessionSidebarRequest } from "@/lib/session-sidebar-events";
import { detectAgentWorkingState, getAgentStatusMessage } from "@/lib/agent-status-detection";
import {
Command,
Plus,
@@ -216,7 +217,36 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
const store = messageStore();
const lastMsg = store.getMessage(ids[ids.length - 1]);
return lastMsg?.role === "assistant" && (lastMsg.status === "streaming" || lastMsg.status === "sending");
// Basic check: streaming or sending status
if (lastMsg?.role === "assistant" && (lastMsg.status === "streaming" || lastMsg.status === "sending")) {
return true;
}
// Enhanced check: semantic detection for "standby", "processing" messages
// This catches Ollama models that output status messages and pause
if (lastMsg?.role === "assistant") {
const workingState = detectAgentWorkingState(lastMsg);
return workingState.isWorking;
}
return false;
});
// Get dynamic status message for display
const agentStatusMessage = createMemo(() => {
const ids = filteredMessageIds();
if (ids.length === 0) return "THINKING";
const store = messageStore();
const lastMsg = store.getMessage(ids[ids.length - 1]);
if (!lastMsg || lastMsg.role !== "assistant") {
return "THINKING";
}
const statusMsg = getAgentStatusMessage(lastMsg);
return statusMsg?.toUpperCase() || "THINKING";
});
// Auto-scroll during streaming - DISABLED for performance testing
@@ -539,7 +569,7 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
<Show when={isAgentThinking()}>
<div class="flex items-center space-x-2 px-3 py-1.5 bg-violet-500/15 border border-violet-500/30 rounded-lg animate-pulse shadow-[0_0_20px_rgba(139,92,246,0.2)]">
<Sparkles size={12} class="text-violet-400 animate-spin" style={{ "animation-duration": "3s" }} />
<span class="text-[10px] font-black text-violet-400 uppercase tracking-tight">Streaming</span>
<span class="text-[10px] font-black text-violet-400 uppercase tracking-tight">{agentStatusMessage()}</span>
<span class="text-[10px] font-bold text-violet-300">{formatTokenTotal(tokenStats().used)}</span>
</div>
</Show>
@@ -846,7 +876,7 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
<div class="w-1 h-1 bg-indigo-400 rounded-full animate-bounce" style={{ "animation-delay": "150ms" }} />
<div class="w-1 h-1 bg-indigo-400 rounded-full animate-bounce" style={{ "animation-delay": "300ms" }} />
</div>
<span class="text-[9px] font-bold text-indigo-400">{isAgentThinking() ? "THINKING" : "SENDING"}</span>
<span class="text-[9px] font-bold text-indigo-400">{isSending() ? "SENDING" : agentStatusMessage()}</span>
</div>
</Show>
</div>

View File

@@ -0,0 +1,249 @@
/**
* Agent Status Detection Module
*
* Provides intelligent detection of when an agent is still "working" even after
* streaming has technically completed. This handles cases where:
* 1. Agent outputs "standby", "processing", "working" messages
* 2. Agent is in multi-step reasoning mode
* 3. Ollama models pause between thinking and output phases
*/
import type { MessageRecord } from "../stores/message-v2/types"
// Keywords that indicate the agent is still processing
const WORKING_KEYWORDS = [
"standby",
"stand by",
"processing",
"please wait",
"working on",
"analyzing",
"thinking",
"computing",
"calculating",
"evaluating",
"generating",
"preparing",
"loading",
"fetching",
"retrieving",
"in progress",
"one moment",
"hold on",
"just a sec",
"give me a moment",
"let me",
"i'll",
"i will",
"checking",
"scanning",
"searching",
"looking",
"finding"
] as const
// Keywords that indicate the agent has finished
const COMPLETION_KEYWORDS = [
"here is",
"here's",
"here are",
"done",
"complete",
"finished",
"result",
"solution",
"answer",
"output",
"summary",
"conclusion",
"final",
"successfully",
"implemented",
"fixed",
"resolved",
"created",
"updated"
] as const
// Patterns that strongly indicate agent is still working
const WORKING_PATTERNS = [
/stand\s*by/i,
/processing\s*(complete\s*)?data/i,
/please\s+wait/i,
/working\s+on/i,
/analyzing/i,
/\bwait\b/i,
/\bone\s+moment\b/i,
/\bhold\s+on\b/i,
/\.\.\.\s*$/, // Ends with ellipsis
/…\s*$/, // Ends with unicode ellipsis
] as const
/**
* Extracts text content from a message's parts
*/
function extractMessageText(message: MessageRecord): string {
const textParts: string[] = []
for (const partId of message.partIds) {
const part = message.parts[partId]
if (part?.data) {
const data = part.data as Record<string, unknown>
if (data.type === "text" && typeof data.text === "string") {
textParts.push(data.text)
}
}
}
return textParts.join("\n")
}
/**
* Gets the last N characters of a message for keyword detection
*/
function getRecentContent(message: MessageRecord, charLimit = 500): string {
const fullText = extractMessageText(message)
if (fullText.length <= charLimit) {
return fullText.toLowerCase()
}
return fullText.slice(-charLimit).toLowerCase()
}
/**
* Checks if the message content indicates the agent is still working
*/
export function detectAgentWorkingState(message: MessageRecord | null | undefined): {
isWorking: boolean
reason?: string
confidence: "high" | "medium" | "low"
} {
if (!message) {
return { isWorking: false, confidence: "high" }
}
// If message status is streaming or sending, definitely working
if (message.status === "streaming" || message.status === "sending") {
return { isWorking: true, reason: "Active streaming", confidence: "high" }
}
// Get recent content to analyze
const recentContent = getRecentContent(message)
if (!recentContent) {
return { isWorking: false, confidence: "high" }
}
// Check for working patterns with high confidence
for (const pattern of WORKING_PATTERNS) {
if (pattern.test(recentContent)) {
return {
isWorking: true,
reason: `Pattern match: ${pattern.source}`,
confidence: "high"
}
}
}
// Check if recent content ends with working keywords
const lastLine = recentContent.split("\n").pop()?.trim() || ""
for (const keyword of WORKING_KEYWORDS) {
if (lastLine.includes(keyword)) {
// Check if there's also a completion keyword nearby
const hasCompletionNearby = COMPLETION_KEYWORDS.some(ck =>
recentContent.slice(-200).includes(ck)
)
if (!hasCompletionNearby) {
return {
isWorking: true,
reason: `Working keyword: "${keyword}"`,
confidence: "medium"
}
}
}
}
// Check message age - if very recent and short, might still be working
const now = Date.now()
const messageAge = now - message.updatedAt
const contentLength = extractMessageText(message).length
// If message was updated very recently (< 2s) and content is short
if (messageAge < 2000 && contentLength < 100) {
return {
isWorking: true,
reason: "Recently updated with short content",
confidence: "low"
}
}
return { isWorking: false, confidence: "high" }
}
/**
* Check if the last assistant message indicates agent is still conceptually working
*/
export function isAgentConceptuallyThinking(
messages: MessageRecord[],
lastAssistantMessage: MessageRecord | null | undefined
): boolean {
if (!lastAssistantMessage) {
return false
}
// Check if message status indicates active work
if (lastAssistantMessage.status === "streaming" ||
lastAssistantMessage.status === "sending") {
return true
}
// Use semantic detection
const workingState = detectAgentWorkingState(lastAssistantMessage)
return workingState.isWorking
}
/**
* Get a user-friendly status message for the current agent state
*/
export function getAgentStatusMessage(
message: MessageRecord | null | undefined
): string | null {
if (!message) {
return null
}
const workingState = detectAgentWorkingState(message)
if (!workingState.isWorking) {
return null
}
if (message.status === "streaming") {
return "Streaming..."
}
if (message.status === "sending") {
return "Sending..."
}
// Based on reason
if (workingState.reason?.includes("standby") ||
workingState.reason?.includes("stand by")) {
return "Agent processing..."
}
if (workingState.reason?.includes("processing")) {
return "Processing..."
}
if (workingState.reason?.includes("analyzing")) {
return "Analyzing..."
}
if (workingState.reason?.includes("ellipsis")) {
return "Thinking..."
}
return "Working..."
}

View File

@@ -4,6 +4,7 @@ import type { MessageRecord } from "./message-v2/types"
import { sessions } from "./sessions"
import { getSessionCompactionState } from "./session-compaction"
import { messageStoreBus } from "./message-v2/bus"
import { detectAgentWorkingState } from "../lib/agent-status-detection"
function getSession(instanceId: string, sessionId: string): Session | null {
const instanceSessions = sessions().get(instanceId)
@@ -159,6 +160,15 @@ export function getSessionStatus(instanceId: string, sessionId: string): Session
return "working"
}
// Enhanced: Check if last assistant message content suggests agent is still working
// This catches Ollama models that output "standby", "processing" messages and pause
if (lastRecord && lastRecord.role === "assistant") {
const workingState = detectAgentWorkingState(lastRecord)
if (workingState.isWorking && workingState.confidence !== "low") {
return "working"
}
}
return "idle"
}