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
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
55 changes: 55 additions & 0 deletions apps/backend/internal/agent/runtime/lifecycle/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -519,6 +519,9 @@ func (sm *SessionManager) waitForPromptDone(ctx context.Context, execution *Agen
sm.logger.Error("prompt completed with error",
zap.String("execution_id", execution.ID),
zap.String("error", signal.Error))
if isAgentReportedSessionLoadMissingErr(signal.Error) {
return nil, fmt.Errorf("%w: %s: %w", ErrAgentReported, signal.Error, ErrExecutionNotFound)
}
// Wrap cancel-release sentinels so PromptTask can identify them and
// skip the REVIEW task-state transition — the user is cancelling, not
// hitting a real agent failure.
Expand Down Expand Up @@ -782,6 +785,58 @@ func isSessionUnknownErr(err error) bool {
return strings.Contains(err.Error(), "Resource not found")
}

// PromptCompletionSignal currently crosses the agentctl updates stream as JSON
// with only the rendered error string. Keep this fallback narrow until the
// stream carries structured ACP error codes alongside the message.
func isAgentReportedSessionLoadMissingErr(message string) bool {
Comment thread
carlosflorencio marked this conversation as resolved.
Comment thread
carlosflorencio marked this conversation as resolved.
Comment thread
carlosflorencio marked this conversation as resolved.
Comment thread
carlosflorencio marked this conversation as resolved.
Comment thread
carlosflorencio marked this conversation as resolved.
Comment thread
carlosflorencio marked this conversation as resolved.
Comment thread
carlosflorencio marked this conversation as resolved.
lower := strings.ToLower(message)
trimmed := strings.TrimSpace(lower)
if isStandaloneMissingSessionMessage(trimmed) {
return true
}
if !hasSessionLoadContext(lower) {
return false
}
return isMissingSessionMessage(lower)
}

func hasSessionLoadContext(message string) bool {
return strings.Contains(message, "failed to load session") ||
strings.Contains(message, "session/load") ||
strings.Contains(message, "load session") ||
hasBroadSessionLoadContext(message)
}

func hasBroadSessionLoadContext(message string) bool {
hasLoadWord := strings.Contains(message, "load") || strings.Contains(message, "loading")
if !hasLoadWord || !strings.Contains(message, "session") {
return false
}
return !strings.Contains(message, "cache") &&
!strings.Contains(message, "config") &&
!strings.Contains(message, "profile")
}

func isMissingSessionMessage(message string) bool {
return strings.Contains(message, "session not found") ||
strings.Contains(message, "no such session") ||
strings.Contains(message, "resource not found") ||
strings.Contains(message, "could not find session") ||
strings.Contains(message, "session id not recognized") ||
strings.Contains(message, "unknown session") ||
strings.Contains(message, "session does not exist") ||
(strings.Contains(message, "session") && strings.Contains(message, "not found"))
}

func isStandaloneMissingSessionMessage(message string) bool {
switch message {
case "session not found", "no such session", "resource not found":
return true
default:
return false
}
}

func isAgentStreamNotConnectedErr(err error) bool {
if err == nil {
return false
Expand Down
69 changes: 69 additions & 0 deletions apps/backend/internal/agent/runtime/lifecycle/session_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,75 @@ func containsAction(items []string, want string) bool {
return false
}

func TestWaitForPromptDoneWrapsMissingSessionLoadAsExecutionNotFound(t *testing.T) {
tests := []string{
"failed to load session: session not found",
"session/load failed: no such session",
"failed to load session id session-1: Resource not found",
"session/load failed: could not find session",
"failed to load session: session id not recognized",
"failed to load session: unknown session",
"failed to load session: session does not exist",
"session not found",
"no such session",
"Resource not found",
"Session abc not found while loading",
}
for _, message := range tests {
t.Run(message, func(t *testing.T) {
sm := NewSessionManager(newSessionTestLogger(), newTestStopCh(t))
execution := &AgentExecution{
ID: "exec-1",
promptDoneCh: make(chan PromptCompletionSignal, 1),
}
execution.promptDoneCh <- PromptCompletionSignal{
IsError: true,
Error: message,
}

_, err := sm.waitForPromptDone(context.Background(), execution)
if !errors.Is(err, ErrAgentReported) {
t.Fatalf("expected ErrAgentReported, got %v", err)
}
if !errors.Is(err, ErrExecutionNotFound) {
t.Fatalf("expected ErrExecutionNotFound, got %v", err)
}
})
}
}

func TestWaitForPromptDoneDoesNotWrapUnrelatedNotFoundAgentErrors(t *testing.T) {
tests := []string{
"permission denied: resource not found",
"tool failed: no such session",
"tool failed: session id abc: no such session in cache",
"loading agent config: session profile not found in vault",
"loading cache: session id not found",
"permission denied: session not found",
}
for _, message := range tests {
t.Run(message, func(t *testing.T) {
sm := NewSessionManager(newSessionTestLogger(), newTestStopCh(t))
execution := &AgentExecution{
ID: "exec-1",
promptDoneCh: make(chan PromptCompletionSignal, 1),
}
execution.promptDoneCh <- PromptCompletionSignal{
IsError: true,
Error: message,
}

_, err := sm.waitForPromptDone(context.Background(), execution)
if !errors.Is(err, ErrAgentReported) {
t.Fatalf("expected ErrAgentReported, got %v", err)
}
if errors.Is(err, ErrExecutionNotFound) {
t.Fatalf("expected unrelated agent error, got ErrExecutionNotFound: %v", err)
}
})
}
}

func TestInitializeAndPrompt_StreamTimeout(t *testing.T) {
// This test verifies that InitializeAndPrompt returns an error if
// the stream fails to connect within the timeout.
Expand Down
24 changes: 15 additions & 9 deletions apps/backend/internal/backendapp/boot_state_routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -461,15 +461,21 @@ func mapUserSettingsState(response userdto.UserSettingsResponse, workspaceID str
"lspServerConfigs": mapStringMap(settings.LspServerConfigs),
"savedLayouts": settings.SavedLayouts,
"sidebarViews": mapSidebarViews(settings.SidebarViews),
"defaultUtilityAgentId": nullString(settings.DefaultUtilityAgentID),
"keyboardShortcuts": mapStringAny(settings.KeyboardShortcuts),
"terminalLinkBehavior": terminalLinkBehavior(settings.TerminalLinkBehavior),
"terminalFontFamily": nullString(settings.TerminalFontFamily),
"terminalFontSize": nullInt(settings.TerminalFontSize),
"changesPanelLayout": changesPanelLayout(settings.ChangesPanelLayout),
"systemMetricsDisplay": map[string]any{"showInTopbar": settings.SystemMetricsDisplay.ShowInTopbar},
"voiceMode": mapVoiceMode(settings.VoiceMode),
"loaded": true,
"taskCreateLastUsed": map[string]any{
"repositoryId": nullString(settings.TaskCreateLastUsed.RepositoryID),
"branch": nullString(settings.TaskCreateLastUsed.Branch),
"agentProfileId": nullString(settings.TaskCreateLastUsed.AgentProfileID),
"executorProfileId": nullString(settings.TaskCreateLastUsed.ExecutorProfileID),
},
"defaultUtilityAgentId": nullString(settings.DefaultUtilityAgentID),
"keyboardShortcuts": mapStringAny(settings.KeyboardShortcuts),
"terminalLinkBehavior": terminalLinkBehavior(settings.TerminalLinkBehavior),
"terminalFontFamily": nullString(settings.TerminalFontFamily),
"terminalFontSize": nullInt(settings.TerminalFontSize),
"changesPanelLayout": changesPanelLayout(settings.ChangesPanelLayout),
"systemMetricsDisplay": map[string]any{"showInTopbar": settings.SystemMetricsDisplay.ShowInTopbar},
"voiceMode": mapVoiceMode(settings.VoiceMode),
"loaded": true,
}
}

Expand Down
10 changes: 10 additions & 0 deletions apps/backend/internal/backendapp/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,8 @@ func appendAgentctlStatusMessage(
// Includes task_environment_id when present so late-subscribing clients
// (page reload, task switch, WS reconnect) can seed environmentIdBySessionId
// — without it, env-routed shell terminals stall on "Connecting terminal...".
// Includes is_passthrough as an explicit boolean, including false, so snapshot
// events can clear stale passthrough UI state after a task switch or reload.
func appendSessionStateMessage(sessionID string, session *models.TaskSession, result []*ws.Message) []*ws.Message {
Comment thread
carlosflorencio marked this conversation as resolved.
payload := map[string]interface{}{
sessionIDPayloadKey: sessionID,
Expand All @@ -191,6 +193,13 @@ func appendSessionStateMessage(sessionID string, session *models.TaskSession, re
if session.TaskEnvironmentID != "" {
payload["task_environment_id"] = session.TaskEnvironmentID
}
if session.AgentProfileID != "" {
payload["agent_profile_id"] = session.AgentProfileID
}
if session.AgentProfileSnapshot != nil {
payload["agent_profile_snapshot"] = session.AgentProfileSnapshot
}
payload["is_passthrough"] = session.IsPassthrough
Comment thread
carlosflorencio marked this conversation as resolved.
Comment thread
carlosflorencio marked this conversation as resolved.
Comment thread
carlosflorencio marked this conversation as resolved.
notification, err := ws.NewNotification(ws.ActionSessionStateChanged, payload)
if err == nil {
result = append(result, notification)
Expand Down Expand Up @@ -971,6 +980,7 @@ func registerSecondaryRoutes(
p.eventBus,
p.log,
)
registerWsSentTestRoute(p.router, p.gateway.Hub, p.log)
p.log.Info("E2E mock routes enabled at /api/v1/_test/* — DO NOT enable in production")
}

Expand Down
139 changes: 139 additions & 0 deletions apps/backend/internal/backendapp/helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import (
"net/http"
"net/http/httptest"
"path/filepath"
"reflect"
"sort"
"strings"
"testing"
"time"
Expand All @@ -25,6 +27,8 @@ import (
sqlitetaskrepo "github.com/kandev/kandev/internal/task/repository/sqlite"
taskservice "github.com/kandev/kandev/internal/task/service"
usercontroller "github.com/kandev/kandev/internal/user/controller"
userdto "github.com/kandev/kandev/internal/user/dto"
usermodels "github.com/kandev/kandev/internal/user/models"
userservice "github.com/kandev/kandev/internal/user/service"
userstore "github.com/kandev/kandev/internal/user/store"
"github.com/kandev/kandev/internal/webapp"
Expand All @@ -44,6 +48,83 @@ func decodePayload(t *testing.T, raw json.RawMessage) map[string]interface{} {
return payload
}

func TestMapUserSettingsStateKeys(t *testing.T) {
state := mapUserSettingsState(userdto.UserSettingsResponse{}, "")

got := make([]string, 0, len(state))
for key := range state {
got = append(got, key)
}
sort.Strings(got)

want := []string{
"changesPanelLayout",
"chatSubmitKey",
"defaultEditorId",
"defaultUtilityAgentId",
"enablePreviewOnClick",
"kanbanViewMode",
"keyboardShortcuts",
"loaded",
"lspAutoInstallLanguages",
"lspAutoStartLanguages",
"lspServerConfigs",
"preferredShell",
"releaseNotesLastSeenVersion",
"repositoryIds",
"reviewAutoMarkOnScroll",
"savedLayouts",
"shellOptions",
"showReleaseNotification",
"sidebarViews",
"systemMetricsDisplay",
"taskCreateLastUsed",
"tasksListGroup",
"tasksListSort",
"terminalFontFamily",
"terminalFontSize",
"terminalLinkBehavior",
"voiceMode",
"workflowId",
"workspaceId",
}
sort.Strings(want)

if !reflect.DeepEqual(got, want) {
t.Fatalf("mapUserSettingsState keys = %#v, want %#v", got, want)
}
}

func TestMapUserSettingsStateIncludesTaskCreateLastUsed(t *testing.T) {
Comment thread
carlosflorencio marked this conversation as resolved.
state := mapUserSettingsState(userdto.UserSettingsResponse{
Settings: userdto.UserSettingsDTO{
TaskCreateLastUsed: usermodels.TaskCreateLastUsed{
RepositoryID: "repo-1",
Branch: "main",
AgentProfileID: "agent-profile-1",
ExecutorProfileID: "executor-profile-1",
},
},
}, "")

lastUsed, ok := state["taskCreateLastUsed"].(map[string]any)
if !ok {
t.Fatalf("taskCreateLastUsed missing or wrong type: %#v", state["taskCreateLastUsed"])
}
if lastUsed["repositoryId"] != "repo-1" {
t.Fatalf("repositoryId = %#v, want repo-1", lastUsed["repositoryId"])
}
if lastUsed["branch"] != "main" {
t.Fatalf("branch = %#v, want main", lastUsed["branch"])
}
if lastUsed["agentProfileId"] != "agent-profile-1" {
t.Fatalf("agentProfileId = %#v, want agent-profile-1", lastUsed["agentProfileId"])
}
if lastUsed["executorProfileId"] != "executor-profile-1" {
t.Fatalf("executorProfileId = %#v, want executor-profile-1", lastUsed["executorProfileId"])
}
}

// TestAppendSessionStateMessage_IncludesTaskEnvironmentID asserts the snapshot
// the WS hub sends on `session.subscribe` carries `task_environment_id`.
//
Expand Down Expand Up @@ -79,6 +160,64 @@ func TestAppendSessionStateMessage_IncludesTaskEnvironmentID(t *testing.T) {
}
}

func TestAppendSessionStateMessage_IncludesRoutingMetadata(t *testing.T) {
session := &models.TaskSession{
ID: "sess-1",
TaskID: "task-1",
State: models.TaskSessionStateRunning,
AgentProfileID: "profile-1",
AgentProfileSnapshot: map[string]interface{}{
"name": "Codex",
"model": "gpt-5",
},
IsPassthrough: true,
}

msgs := appendSessionStateMessage(session.ID, session, nil)
if len(msgs) != 1 {
t.Fatalf("expected 1 message, got %d", len(msgs))
}

payload := decodePayload(t, msgs[0].Payload)
if got := payload["agent_profile_id"]; got != "profile-1" {
t.Fatalf("expected agent_profile_id=profile-1, got %v", got)
}
snapshot, ok := payload["agent_profile_snapshot"].(map[string]interface{})
if !ok {
t.Fatalf("payload missing agent_profile_snapshot: %#v", payload["agent_profile_snapshot"])
}
if snapshot["name"] != "Codex" || snapshot["model"] != "gpt-5" {
t.Fatalf("unexpected agent_profile_snapshot: %#v", snapshot)
}
if got := payload["is_passthrough"]; got != true {
t.Fatalf("expected is_passthrough=true, got %v", got)
}
}

func TestAppendSessionStateMessage_EmitsFalsePassthroughAndOmitsEmptyRoutingMetadata(t *testing.T) {
session := &models.TaskSession{
ID: "sess-1",
TaskID: "task-1",
State: models.TaskSessionStateRunning,
}

msgs := appendSessionStateMessage(session.ID, session, nil)
if len(msgs) != 1 {
t.Fatalf("expected 1 message, got %d", len(msgs))
}

payload := decodePayload(t, msgs[0].Payload)
got, present := payload["is_passthrough"]
if !present || got != false {
t.Fatalf("expected is_passthrough=false, got present=%v value=%#v", present, got)
}
for _, key := range []string{"agent_profile_id", "agent_profile_snapshot", "task_environment_id"} {
if _, present := payload[key]; present {
t.Fatalf("expected %s to be omitted, payload=%#v", key, payload)
}
}
}

func TestAppendAgentctlStatusMessage_IncludesWorkspacePathForReload(t *testing.T) {
log, err := logger.NewLogger(logger.LoggingConfig{
Level: "error",
Expand Down
Loading
Loading