Skip to content
Open
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ dmypy.json

# Claude Code
.claude/settings.local.json
.claude/worktrees/

# mkdocs
/site
Expand Down
8 changes: 6 additions & 2 deletions components/backend/handlers/sessions.go
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,10 @@ func parseStatus(status map[string]interface{}) *types.AgenticSessionStatus {
result.LastActivityTime = types.StringPtr(lastActivityTime)
}

if agentStatus, ok := status["agentStatus"].(string); ok && agentStatus != "" {
result.AgentStatus = types.StringPtr(agentStatus)
}

if stoppedReason, ok := status["stoppedReason"].(string); ok && stoppedReason != "" {
result.StoppedReason = types.StringPtr(stoppedReason)
}
Expand Down Expand Up @@ -560,9 +564,9 @@ func CreateSession(c *gin.Context) {
timeout = *req.Timeout
}

// Generate unique name (timestamp-based)
// Generate unique name (millisecond timestamp for burst-creation safety)
// Note: Runner will create branch as "ambient/{session-name}"
timestamp := time.Now().Unix()
timestamp := time.Now().UnixMilli()
name := fmt.Sprintf("session-%d", timestamp)

// Create the custom resource
Expand Down
1 change: 1 addition & 0 deletions components/backend/types/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ type AgenticSessionStatus struct {
StartTime *string `json:"startTime,omitempty"`
CompletionTime *string `json:"completionTime,omitempty"`
LastActivityTime *string `json:"lastActivityTime,omitempty"`
AgentStatus *string `json:"agentStatus,omitempty"`
StoppedReason *string `json:"stoppedReason,omitempty"`
ReconciledRepos []ReconciledRepo `json:"reconciledRepos,omitempty"`
ReconciledWorkflow *ReconciledWorkflow `json:"reconciledWorkflow,omitempty"`
Expand Down
109 changes: 107 additions & 2 deletions components/backend/websocket/agui_proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/util/retry"
)

const (
Expand Down Expand Up @@ -379,15 +380,33 @@ func persistStreamedEvent(sessionID, runID, threadID, jsonData string) {

persistEvent(sessionID, event)

// Update lastActivityTime on CR for activity events (debounced).
// Extract event type to check; projectName is derived from the
// Extract event type; projectName is derived from the
// sessionID-to-project mapping populated by HandleAGUIRunProxy.
eventType, _ := event["type"].(string)

// Update lastActivityTime on CR for activity events (debounced).
if isActivityEvent(eventType) {
if projectName, ok := sessionProjectMap.Load(sessionID); ok {
updateLastActivityTime(projectName.(string), sessionID, eventType == types.EventTypeRunStarted)
}
}

// Update agentStatus on CR for status-changing events (not debounced).
if projectName, ok := sessionProjectMap.Load(sessionID); ok {
proj := projectName.(string)
switch eventType {
case types.EventTypeRunStarted:
updateAgentStatus(proj, sessionID, "working")
case types.EventTypeRunFinished:
updateAgentStatus(proj, sessionID, "idle")
case types.EventTypeRunError:
updateAgentStatus(proj, sessionID, "idle")
case types.EventTypeToolCallStart:
if toolName, _ := event["toolCallName"].(string); isAskUserQuestionToolCall(toolName) {
updateAgentStatus(proj, sessionID, "waiting_input")
}
}
}
}

// ─── POST /agui/interrupt ────────────────────────────────────────────
Expand Down Expand Up @@ -843,3 +862,89 @@ func updateLastActivityTime(projectName, sessionName string, immediate bool) {
}
}()
}

// isAskUserQuestionToolCall checks if a tool call name is the AskUserQuestion HITL tool.
// Uses case-insensitive comparison after stripping non-alpha characters,
// matching the frontend pattern in use-agent-status.ts.
func isAskUserQuestionToolCall(name string) bool {
var clean strings.Builder
for _, r := range strings.ToLower(name) {
if r >= 'a' && r <= 'z' {
clean.WriteRune(r)
}
}
return clean.String() == "askuserquestion"
}

// updateAgentStatus updates the agentStatus field on the AgenticSession CR status.
// Unlike updateLastActivityTime, this is NOT debounced because status changes are
// infrequent (2-4 per run) and should be reflected immediately.
// It also updates lastActivityTime in the same CR write to avoid double API calls.
func updateAgentStatus(projectName, sessionName, newStatus string) {
if handlers.DynamicClient == nil {
log.Printf("Agent status: DynamicClient is nil, skipping update for %s/%s", projectName, sessionName)
return
}

// Bound concurrency: reuse the activity semaphore.
select {
case activityUpdateSem <- struct{}{}:
default:
log.Printf("Agent status: semaphore full, dropping update %s for %s/%s", newStatus, projectName, sessionName)
return
}

go func() {
defer func() { <-activityUpdateSem }()

gvr := handlers.GetAgenticSessionV1Alpha1Resource()
ctx, cancel := context.WithTimeout(context.Background(), activityUpdateTimeout)
defer cancel()

var now time.Time
err := retry.RetryOnConflict(retry.DefaultRetry, func() error {
obj, err := handlers.DynamicClient.Resource(gvr).Namespace(projectName).Get(ctx, sessionName, metav1.GetOptions{})
if err != nil {
return err
}

status, found, err := unstructured.NestedMap(obj.Object, "status")
if err != nil {
log.Printf("Agent status: failed to read nested status for %s/%s: %v", projectName, sessionName, err)
return nil // non-retryable
}
if !found || status == nil {
status = make(map[string]any)
}

// Skip RUN_FINISHED → "idle" if current status is "waiting_input".
// waiting_input is more informative and should persist until the next RUN_STARTED.
if newStatus == "idle" {
if current, _ := status["agentStatus"].(string); current == "waiting_input" {
return nil
}
}

status["agentStatus"] = newStatus
// Also update lastActivityTime to avoid a separate API call.
now = time.Now()
status["lastActivityTime"] = now.UTC().Format(time.RFC3339)

if err := unstructured.SetNestedField(obj.Object, status, "status"); err != nil {
log.Printf("Agent status: failed to set status for %s/%s: %v", projectName, sessionName, err)
return nil // non-retryable
}

_, err = handlers.DynamicClient.Resource(gvr).Namespace(projectName).UpdateStatus(ctx, obj, metav1.UpdateOptions{})
return err
})

if err != nil {
log.Printf("Agent status: failed to update agentStatus to %s for %s/%s: %v", newStatus, projectName, sessionName, err)
} else {
// Update lastActivity timestamp in the debounce map to avoid redundant activity updates.
key := projectName + "/" + sessionName
lastActivityUpdateTimes.Store(key, now)
}
}()
}
33 changes: 33 additions & 0 deletions components/frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion components/frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.1.3",
Expand All @@ -26,7 +27,6 @@
"@tanstack/react-query": "^5.90.2",
"@tanstack/react-query-devtools": "^5.90.2",
"@unleash/proxy-client-react": "^5.0.1",
"unleash-proxy-client": "^3.6.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
Expand All @@ -47,6 +47,7 @@
"tailwind-merge": "^3.3.1",
"tailwindcss-animate": "^1.0.7",
"tw-animate-css": "^1.4.0",
"unleash-proxy-client": "^3.6.1",
"zod": "^3.25.0"
},
"devDependencies": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,9 @@ import {
import { Label } from "@/components/ui/label";
import { Breadcrumbs } from "@/components/breadcrumbs";
import { SessionHeader } from "./session-header";
import { getPhaseColor } from "@/utils/session-helpers";
import { SessionStatusDot } from "@/components/session-status-dot";
import { AgentStatusIndicator } from "@/components/agent-status-indicator";
import { useAgentStatus } from "@/hooks/use-agent-status";

// Extracted components
import { AddContextModal } from "./components/modals/add-context-modal";
Expand Down Expand Up @@ -736,6 +738,13 @@ export default function ProjectSessionDetailPage({
handleWorkflowChange(workflowId);
};

// Derive agent-level status from session data and messages
const agentStatus = useAgentStatus(
session?.status?.phase || "Pending",
isRunActive,
aguiStream.state.messages as unknown as Array<MessageObject | ToolUseMessages>,
);

// Convert AG-UI messages to display format with hierarchical tool call rendering
const streamMessages: Array<MessageObject | ToolUseMessages | HierarchicalToolMessage> = useMemo(() => {

Expand Down Expand Up @@ -887,6 +896,12 @@ export default function ProjectSessionDetailPage({

// Handle text content by role
if (msg.role === "user") {
// Hide AskUserQuestion response messages from the chat
const msgMeta = msg.metadata as Record<string, unknown> | undefined;
if (msgMeta?.type === "ask_user_question_response") {
continue;
}

result.push({
type: "user_message",
id: msg.id, // Preserve message ID for feedback association
Expand Down Expand Up @@ -1331,6 +1346,17 @@ export default function ProjectSessionDetailPage({
}
};

// Send an AskUserQuestion response (hidden from chat, properly formatted)
const sendToolAnswer = async (formattedAnswer: string) => {
try {
await aguiSendMessage(formattedAnswer, {
type: "ask_user_question_response",
});
} catch (err) {
errorToast(err instanceof Error ? err.message : "Failed to send answer");
}
};

const handleCommandClick = async (slashCommand: string) => {
try {
await aguiSendMessage(slashCommand);
Expand Down Expand Up @@ -1414,13 +1440,8 @@ export default function ProjectSessionDetailPage({
<span className="text-sm font-medium truncate max-w-[150px]">
{session.spec.displayName || session.metadata.name}
</span>
<Badge
className={getPhaseColor(
session.status?.phase || "Pending",
)}
>
{session.status?.phase || "Pending"}
</Badge>
<SessionStatusDot phase={session.status?.phase || "Pending"} />
<AgentStatusIndicator status={agentStatus} compact />
</div>
</div>

Expand All @@ -1437,13 +1458,10 @@ export default function ProjectSessionDetailPage({
{
label: session.spec.displayName || session.metadata.name,
rightIcon: (
<Badge
className={getPhaseColor(
session.status?.phase || "Pending",
)}
>
{session.status?.phase || "Pending"}
</Badge>
<div className="flex items-center gap-2">
<SessionStatusDot phase={session.status?.phase || "Pending"} />
<AgentStatusIndicator status={agentStatus} />
</div>
),
},
]}
Expand Down Expand Up @@ -2529,6 +2547,7 @@ export default function ProjectSessionDetailPage({
chatInput={chatInput}
setChatInput={setChatInput}
onSendChat={() => Promise.resolve(sendChat())}
onSendToolAnswer={sendToolAnswer}
onInterrupt={aguiInterrupt}
onGoToResults={() => {}}
onContinue={handleContinue}
Expand Down Expand Up @@ -2606,6 +2625,7 @@ export default function ProjectSessionDetailPage({
chatInput={chatInput}
setChatInput={setChatInput}
onSendChat={() => Promise.resolve(sendChat())}
onSendToolAnswer={sendToolAnswer}
onInterrupt={aguiInterrupt}
onGoToResults={() => {}}
onContinue={handleContinue}
Expand Down
Loading
Loading