Skip to content

Commit 56e76ff

Browse files
Gkrumbach07claude
andcommitted
fix: derive agentStatus from event log and fix AskUserQuestion UI
- Derive agentStatus at query time from persisted event log instead of caching on the CR (eliminates goroutine race condition) - Fix useAgentStatus hook to read raw AG-UI PlatformMessage format instead of processed ToolUseMessages (fixes detail page status) - Emit TOOL_CALL_RESULT on next run for halted AskUserQuestion tool calls so frontend marks questions as answered - Remove agentStatus field from CRD and CR update logic - Remove animate-pulse from Running dot and waiting_input badge - Add AgentStatus constants to types/agui.go Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 354cc56 commit 56e76ff

12 files changed

Lines changed: 140 additions & 121 deletions

File tree

Makefile

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -734,9 +734,9 @@ kind-port-forward: check-kubectl check-local-context ## Port-forward kind servic
734734
@echo ""
735735
@echo "$(COLOR_YELLOW)Press Ctrl+C to stop$(COLOR_RESET)"
736736
@echo ""
737-
@trap 'echo ""; echo "$(COLOR_GREEN)✓$(COLOR_RESET) Port forwarding stopped"; exit 0' INT; \
738-
(kubectl port-forward -n ambient-code svc/frontend-service $(KIND_FWD_FRONTEND_PORT):3000 >/dev/null 2>&1 &); \
739-
(kubectl port-forward -n ambient-code svc/backend-service $(KIND_FWD_BACKEND_PORT):8080 >/dev/null 2>&1 &); \
737+
@trap 'kill 0; echo ""; echo "$(COLOR_GREEN)✓$(COLOR_RESET) Port forwarding stopped"; exit 0' INT; \
738+
kubectl port-forward -n ambient-code svc/frontend-service $(KIND_FWD_FRONTEND_PORT):3000 >/dev/null 2>&1 & \
739+
kubectl port-forward -n ambient-code svc/backend-service $(KIND_FWD_BACKEND_PORT):8080 >/dev/null 2>&1 & \
740740
wait
741741

742742
dev-bootstrap: check-kubectl check-local-context ## Bootstrap developer workspace with API key and integrations

components/backend/handlers/sessions.go

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@ var (
4141
GetGitHubToken func(context.Context, kubernetes.Interface, dynamic.Interface, string, string) (string, error)
4242
GetGitLabToken func(context.Context, kubernetes.Interface, string, string) (string, error)
4343
DeriveRepoFolderFromURL func(string) string
44+
// DeriveAgentStatusFromEvents derives agentStatus from the persisted event log.
45+
// Set by the websocket package at init to avoid circular imports.
46+
DeriveAgentStatusFromEvents func(sessionID string) string
4447
// LEGACY: SendMessageToSession removed - AG-UI server uses HTTP/SSE instead of WebSocket
4548
)
4649

@@ -245,10 +248,6 @@ func parseStatus(status map[string]interface{}) *types.AgenticSessionStatus {
245248
result.LastActivityTime = types.StringPtr(lastActivityTime)
246249
}
247250

248-
if agentStatus, ok := status["agentStatus"].(string); ok && agentStatus != "" {
249-
result.AgentStatus = types.StringPtr(agentStatus)
250-
}
251-
252251
if stoppedReason, ok := status["stoppedReason"].(string); ok && stoppedReason != "" {
253252
result.StoppedReason = types.StringPtr(stoppedReason)
254253
}
@@ -365,6 +364,25 @@ func parseStatus(status map[string]interface{}) *types.AgenticSessionStatus {
365364

366365
// V2 API Handlers - Multi-tenant session management
367366

367+
// enrichAgentStatus derives agentStatus from the persisted event log for
368+
// Running sessions. This is the source of truth — it replaces the stale
369+
// CR-cached value which was subject to goroutine race conditions.
370+
func enrichAgentStatus(session *types.AgenticSession) {
371+
if session.Status == nil || session.Status.Phase != "Running" {
372+
return
373+
}
374+
if DeriveAgentStatusFromEvents == nil {
375+
return
376+
}
377+
name, _ := session.Metadata["name"].(string)
378+
if name == "" {
379+
return
380+
}
381+
if derived := DeriveAgentStatusFromEvents(name); derived != "" {
382+
session.Status.AgentStatus = types.StringPtr(derived)
383+
}
384+
}
385+
368386
func ListSessions(c *gin.Context) {
369387
project := c.GetString("project")
370388

@@ -418,6 +436,9 @@ func ListSessions(c *gin.Context) {
418436
session.Status = parseStatus(status)
419437
}
420438

439+
// Derive agentStatus from event log (source of truth) for running sessions
440+
enrichAgentStatus(&session)
441+
421442
session.AutoBranch = ComputeAutoBranch(item.GetName())
422443

423444
sessions = append(sessions, session)
@@ -907,6 +928,9 @@ func GetSession(c *gin.Context) {
907928
session.Status = parseStatus(status)
908929
}
909930

931+
// Derive agentStatus from event log (source of truth) for running sessions
932+
enrichAgentStatus(&session)
933+
910934
session.AutoBranch = ComputeAutoBranch(sessionName)
911935

912936
c.JSON(http.StatusOK, session)

components/backend/main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,7 @@ func main() {
163163

164164
// Initialize websocket package
165165
websocket.StateBaseDir = server.StateBaseDir
166+
handlers.DeriveAgentStatusFromEvents = websocket.DeriveAgentStatus
166167

167168
// Normal server mode
168169
if err := server.Run(registerRoutes); err != nil {

components/backend/types/agui.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,13 @@ const (
6969
EventTypeMeta = "META"
7070
)
7171

72+
// Agent status values derived from the AG-UI event stream.
73+
const (
74+
AgentStatusWorking = "working"
75+
AgentStatusIdle = "idle"
76+
AgentStatusWaitingInput = "waiting_input"
77+
)
78+
7279
// AG-UI Message Roles
7380
// See: https://docs.ag-ui.com/concepts/messages
7481
const (

components/backend/websocket/agui_proxy.go

Lines changed: 2 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,6 @@ import (
3232
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
3333
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
3434
"k8s.io/client-go/kubernetes"
35-
"k8s.io/client-go/util/retry"
3635
)
3736

3837
const (
@@ -448,22 +447,8 @@ func persistStreamedEvent(sessionID, runID, threadID, jsonData string) {
448447
}
449448
}
450449

451-
// Update agentStatus on CR for status-changing events (not debounced).
452-
if projectName, ok := sessionProjectMap.Load(sessionID); ok {
453-
proj := projectName.(string)
454-
switch eventType {
455-
case types.EventTypeRunStarted:
456-
updateAgentStatus(proj, sessionID, "working")
457-
case types.EventTypeRunFinished:
458-
updateAgentStatus(proj, sessionID, "idle")
459-
case types.EventTypeRunError:
460-
updateAgentStatus(proj, sessionID, "idle")
461-
case types.EventTypeToolCallStart:
462-
if toolName, _ := event["toolCallName"].(string); isAskUserQuestionToolCall(toolName) {
463-
updateAgentStatus(proj, sessionID, "waiting_input")
464-
}
465-
}
466-
}
450+
// agentStatus is derived at query time from the event log (DeriveAgentStatus).
451+
// No CR updates needed here — the persisted events ARE the source of truth.
467452
}
468453

469454
// ─── POST /agui/interrupt ────────────────────────────────────────────
@@ -978,75 +963,3 @@ func isAskUserQuestionToolCall(name string) bool {
978963
return clean.String() == "askuserquestion"
979964
}
980965

981-
// updateAgentStatus updates the agentStatus field on the AgenticSession CR status.
982-
// Unlike updateLastActivityTime, this is NOT debounced because status changes are
983-
// infrequent (2-4 per run) and should be reflected immediately.
984-
// It also updates lastActivityTime in the same CR write to avoid double API calls.
985-
func updateAgentStatus(projectName, sessionName, newStatus string) {
986-
if handlers.DynamicClient == nil {
987-
log.Printf("Agent status: DynamicClient is nil, skipping update for %s/%s", projectName, sessionName)
988-
return
989-
}
990-
991-
// Bound concurrency: reuse the activity semaphore.
992-
select {
993-
case activityUpdateSem <- struct{}{}:
994-
default:
995-
log.Printf("Agent status: semaphore full, dropping update %s for %s/%s", newStatus, projectName, sessionName)
996-
return
997-
}
998-
999-
go func() {
1000-
defer func() { <-activityUpdateSem }()
1001-
1002-
gvr := handlers.GetAgenticSessionV1Alpha1Resource()
1003-
ctx, cancel := context.WithTimeout(context.Background(), activityUpdateTimeout)
1004-
defer cancel()
1005-
1006-
var now time.Time
1007-
err := retry.RetryOnConflict(retry.DefaultRetry, func() error {
1008-
obj, err := handlers.DynamicClient.Resource(gvr).Namespace(projectName).Get(ctx, sessionName, metav1.GetOptions{})
1009-
if err != nil {
1010-
return err
1011-
}
1012-
1013-
status, found, err := unstructured.NestedMap(obj.Object, "status")
1014-
if err != nil {
1015-
log.Printf("Agent status: failed to read nested status for %s/%s: %v", projectName, sessionName, err)
1016-
return nil // non-retryable
1017-
}
1018-
if !found || status == nil {
1019-
status = make(map[string]any)
1020-
}
1021-
1022-
// Skip RUN_FINISHED → "idle" if current status is "waiting_input".
1023-
// waiting_input is more informative and should persist until the next RUN_STARTED.
1024-
if newStatus == "idle" {
1025-
if current, _ := status["agentStatus"].(string); current == "waiting_input" {
1026-
return nil
1027-
}
1028-
}
1029-
1030-
status["agentStatus"] = newStatus
1031-
// Also update lastActivityTime to avoid a separate API call.
1032-
now = time.Now()
1033-
status["lastActivityTime"] = now.UTC().Format(time.RFC3339)
1034-
1035-
if err := unstructured.SetNestedField(obj.Object, status, "status"); err != nil {
1036-
log.Printf("Agent status: failed to set status for %s/%s: %v", projectName, sessionName, err)
1037-
return nil // non-retryable
1038-
}
1039-
1040-
_, err = handlers.DynamicClient.Resource(gvr).Namespace(projectName).UpdateStatus(ctx, obj, metav1.UpdateOptions{})
1041-
return err
1042-
})
1043-
1044-
if err != nil {
1045-
log.Printf("Agent status: failed to update agentStatus to %s for %s/%s: %v", newStatus, projectName, sessionName, err)
1046-
} else {
1047-
// Update lastActivity timestamp in the debounce map to avoid redundant activity updates.
1048-
key := projectName + "/" + sessionName
1049-
lastActivityUpdateTimes.Store(key, now)
1050-
}
1051-
}()
1052-
}

components/backend/websocket/agui_store.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,68 @@ func loadEvents(sessionID string) []map[string]interface{} {
194194
return events
195195
}
196196

197+
// DeriveAgentStatus reads a session's event log and returns the agent
198+
// status derived from the last significant events.
199+
//
200+
// Returns "" if the status cannot be determined (no events, file missing, etc.).
201+
func DeriveAgentStatus(sessionID string) string {
202+
path := fmt.Sprintf("%s/sessions/%s/agui-events.jsonl", StateBaseDir, sessionID)
203+
204+
data, err := os.ReadFile(path)
205+
if err != nil {
206+
return ""
207+
}
208+
209+
lines := splitLines(data)
210+
211+
// Scan backwards. We only care about lifecycle and AskUserQuestion events.
212+
// RUN_STARTED → "working"
213+
// RUN_FINISHED / RUN_ERROR → "idle", unless same run had AskUserQuestion
214+
// TOOL_CALL_START (AskUserQuestion) → "waiting_input"
215+
var runEndRunID string // set when we hit RUN_FINISHED/RUN_ERROR and need to look deeper
216+
for i := len(lines) - 1; i >= 0; i-- {
217+
if len(lines[i]) == 0 {
218+
continue
219+
}
220+
var evt map[string]interface{}
221+
if err := json.Unmarshal(lines[i], &evt); err != nil {
222+
continue
223+
}
224+
evtType, _ := evt["type"].(string)
225+
226+
switch evtType {
227+
case types.EventTypeRunStarted:
228+
if runEndRunID != "" {
229+
// We were scanning for an AskUserQuestion but hit RUN_STARTED first → idle
230+
return types.AgentStatusIdle
231+
}
232+
return types.AgentStatusWorking
233+
234+
case types.EventTypeRunFinished, types.EventTypeRunError:
235+
if runEndRunID == "" {
236+
// First run-end seen; scan deeper within this run for AskUserQuestion
237+
runEndRunID, _ = evt["runId"].(string)
238+
}
239+
240+
case types.EventTypeToolCallStart:
241+
if runEndRunID != "" {
242+
// Only relevant if we're scanning within the ended run
243+
if evtRunID, _ := evt["runId"].(string); evtRunID != "" && evtRunID != runEndRunID {
244+
return types.AgentStatusIdle
245+
}
246+
}
247+
if toolName, _ := evt["toolCallName"].(string); isAskUserQuestionToolCall(toolName) {
248+
return types.AgentStatusWaitingInput
249+
}
250+
}
251+
}
252+
253+
if runEndRunID != "" {
254+
return types.AgentStatusIdle
255+
}
256+
return ""
257+
}
258+
197259
// ─── Compaction ──────────────────────────────────────────────────────
198260
//
199261
// Go port of @ag-ui/client compactEvents. Concatenates streaming deltas

components/frontend/src/app/projects/[name]/sessions/[sessionName]/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -781,7 +781,7 @@ export default function ProjectSessionDetailPage({
781781
const agentStatus = useAgentStatus(
782782
session?.status?.phase || "Pending",
783783
isRunActive,
784-
aguiStream.state.messages as unknown as Array<MessageObject | ToolUseMessages>,
784+
aguiStream.state.messages,
785785
);
786786

787787
// Convert AG-UI messages to display format with hierarchical tool call rendering

components/frontend/src/components/agent-status-indicator.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ export function AgentStatusIndicator({
4444
return (
4545
<Badge
4646
className={cn(
47-
"bg-amber-500 hover:bg-amber-500 text-white animate-pulse gap-1",
47+
"bg-amber-500 hover:bg-amber-500 text-white gap-1",
4848
compact && "px-1.5 py-0.5 text-[10px]",
4949
className
5050
)}

components/frontend/src/components/session-status-dot.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@ const DOT_COLORS: Record<string, string> = {
2525
};
2626

2727
const DOT_ANIMATIONS: Record<string, string> = {
28-
Running: "animate-pulse",
2928
Creating: "animate-pulse",
3029
Stopping: "animate-pulse",
3130
};

components/frontend/src/hooks/use-agent-status.ts

Lines changed: 13 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,25 +2,24 @@ import { useMemo } from "react";
22
import type {
33
AgenticSessionPhase,
44
AgentStatus,
5-
MessageObject,
6-
ToolUseMessages,
75
} from "@/types/agentic-session";
6+
import type { PlatformMessage } from "@/types/agui";
87

98
function isAskUserQuestionTool(name: string): boolean {
109
const normalized = name.toLowerCase().replace(/[^a-z]/g, "");
1110
return normalized === "askuserquestion";
1211
}
1312

1413
/**
15-
* Derive agent status from session data and message stream.
14+
* Derive agent status from session data and the raw AG-UI message stream.
1615
*
1716
* For the session detail page where the full message stream is available,
1817
* this provides accurate status including `waiting_input` detection.
1918
*/
2019
export function useAgentStatus(
2120
phase: AgenticSessionPhase | string,
2221
isRunActive: boolean,
23-
messages: Array<MessageObject | ToolUseMessages>,
22+
messages: PlatformMessage[],
2423
): AgentStatus {
2524
return useMemo(() => {
2625
// Terminal states from session phase
@@ -31,26 +30,25 @@ export function useAgentStatus(
3130
// Non-running phases
3231
if (phase !== "Running") return "idle";
3332

34-
// Check if the last tool call is an unanswered AskUserQuestion
33+
// Scan backwards for the last tool call to check for unanswered AskUserQuestion.
34+
// Raw AG-UI messages store tool calls in msg.toolCalls[] (PlatformToolCall[]).
3535
for (let i = messages.length - 1; i >= 0; i--) {
3636
const msg = messages[i];
37+
if (!msg.toolCalls || msg.toolCalls.length === 0) continue;
3738

38-
// Skip non-tool messages
39-
if (!("toolUseBlock" in msg)) continue;
40-
41-
const toolMsg = msg as ToolUseMessages;
42-
if (isAskUserQuestionTool(toolMsg.toolUseBlock.name)) {
43-
// Check if it has a result (answered)
39+
// Check the last tool call on this message
40+
const lastTc = msg.toolCalls[msg.toolCalls.length - 1];
41+
if (isAskUserQuestionTool(lastTc.function.name)) {
4442
const hasResult =
45-
toolMsg.resultBlock?.content !== undefined &&
46-
toolMsg.resultBlock?.content !== null &&
47-
toolMsg.resultBlock?.content !== "";
43+
lastTc.result !== undefined &&
44+
lastTc.result !== null &&
45+
lastTc.result !== "";
4846
if (!hasResult) {
4947
return "waiting_input";
5048
}
5149
}
5250

53-
// Only check the most recent tool call
51+
// Only check the most recent message with tool calls
5452
break;
5553
}
5654

0 commit comments

Comments
 (0)