From e146521a7954a4cf45ddc9c1f1612e8509924d8e Mon Sep 17 00:00:00 2001 From: Pedro Nauck Date: Thu, 14 May 2026 19:12:32 -0300 Subject: [PATCH 1/2] fix: qa round --- .gitignore | 1 + .skeeper.yml | 1 + config.toml | 1 - internal/api/contract/settings.go | 1 - internal/api/core/agent_spawn.go | 2 +- internal/api/core/authored_context.go | 15 +- internal/api/core/authored_context_test.go | 138 +++++++++++ internal/api/core/conversions.go | 21 +- internal/api/core/network.go | 12 +- internal/api/core/network_conversations.go | 18 +- internal/api/core/network_details.go | 44 ++-- internal/api/core/network_test.go | 141 ++++++++++- internal/api/core/session_workspace.go | 3 +- .../core/session_workspace_internal_test.go | 27 ++- internal/api/core/settings.go | 1 - internal/api/core/settings_internal_test.go | 2 +- internal/api/core/settings_test.go | 7 +- internal/api/core/workspace_scope.go | 14 +- internal/api/httpapi/handlers_test.go | 2 +- internal/api/httpapi/server_test.go | 2 +- internal/api/httpapi/stream_helpers_test.go | 4 +- internal/api/udsapi/handlers_test.go | 4 +- internal/cli/agent.go | 222 ++++++++++++++++++ internal/cli/agent_commands_test.go | 85 +++++++ internal/cli/cli_integration_test.go | 9 +- internal/cli/client.go | 29 +++ internal/cli/config.go | 1 - internal/cli/helpers_test.go | 12 + internal/cli/network.go | 115 ++++++++- internal/cli/network_client_test.go | 35 +++ internal/cli/network_test.go | 52 ++++ internal/cli/task.go | 42 ++++ internal/cli/task_test.go | 10 +- internal/config/config.go | 4 - internal/config/config_test.go | 3 +- internal/config/merge.go | 4 - internal/config/tool_surface.go | 1 - internal/config/tool_surface_test.go | 4 +- internal/coordinator/coordinator.go | 3 + internal/coordinator/coordinator_test.go | 2 + internal/session/additional_test.go | 4 - internal/session/manager.go | 30 +-- internal/session/manager_helpers.go | 10 - internal/session/manager_start.go | 2 +- internal/session/manager_test.go | 32 ++- internal/session/prompt_activity.go | 47 ++++ internal/session/prompt_activity_test.go | 48 +++- internal/settings/sections.go | 4 - internal/settings/service_test.go | 6 +- internal/transcript/transcript_test.go | 73 ++++++ internal/transcript/ui_messages.go | 12 +- openapi/agh.json | 16 +- .../core/configuration/config-toml.mdx | 12 +- .../runtime/core/operations/production.mdx | 3 +- .../core/workspaces/config-overlays.mdx | 18 +- .../agh/references/tasks-and-orchestration.md | 12 +- skills/agh/references/tools-and-skills.md | 4 +- web/src/generated/agh-openapi.d.ts | 2 - .../routes/__tests__/use-app-layout.test.tsx | 5 +- .../use-settings-general-page.test.tsx | 4 +- .../routes/__tests__/use-tasks-page.test.tsx | 8 + .../use-create-provider-focus-restore.ts | 28 +++ web/src/hooks/routes/use-tasks-page.ts | 14 +- web/src/routes/__tests__/-_app.test.tsx | 23 +- web/src/routes/_app.tsx | 6 +- .../_app/settings/__tests__/-general.test.tsx | 2 +- .../settings/__tests__/-providers.test.tsx | 16 +- web/src/routes/_app/settings/general.tsx | 5 +- web/src/routes/_app/settings/providers.tsx | 6 +- .../network-create-channel-dialog.test.tsx | 7 + .../network-create-channel-dialog.tsx | 4 +- .../use-network-route-shell.test.tsx | 104 ++++++++ .../network/hooks/use-network-route-shell.ts | 14 +- .../runtime-activity-notice.test.tsx | 41 ++++ .../session-chat-runtime-provider.test.tsx | 41 ++++ .../__tests__/session-create-dialog.test.tsx | 17 ++ .../components/runtime-activity-notice.tsx | 75 +++++- .../components/session-create-dialog.tsx | 7 +- .../session-command-controls.stories.tsx | 16 +- .../adapters/__tests__/settings-api.test.ts | 4 +- .../__tests__/use-settings-mutations.test.tsx | 2 +- web/src/systems/settings/mocks/fixtures.ts | 2 +- .../__tests__/task-editor-modal.test.tsx | 46 +++- .../__tests__/tasks-detail-header.test.tsx | 15 ++ .../tasks/components/task-editor-modal.tsx | 82 ++++++- .../tasks/components/tasks-detail-header.tsx | 7 +- .../workspace/hooks/use-active-workspace.ts | 1 + 87 files changed, 1784 insertions(+), 247 deletions(-) create mode 100644 web/src/hooks/routes/use-create-provider-focus-restore.ts create mode 100644 web/src/systems/network/hooks/__tests__/use-network-route-shell.test.tsx diff --git a/.gitignore b/.gitignore index cdcdda9de..4c4d385fe 100644 --- a/.gitignore +++ b/.gitignore @@ -98,4 +98,5 @@ docs/proposals/** .compozy/tasks/**/.gitkeep .compozy/tasks/**/.empty .codex/loop/** +.codex/qa/** # <<< skeeper ignored specs <<< diff --git a/.skeeper.yml b/.skeeper.yml index 90ceced45..c92bbb5b2 100644 --- a/.skeeper.yml +++ b/.skeeper.yml @@ -27,3 +27,4 @@ namespaces: - .compozy/tasks/**/.gitkeep - .compozy/tasks/**/.empty - .codex/loop/** + - .codex/qa/** diff --git a/config.toml b/config.toml index c5218d6b8..c743364ab 100644 --- a/config.toml +++ b/config.toml @@ -10,7 +10,6 @@ agent = "general" provider = "claude" [limits] -max_sessions = 10 max_concurrent_agents = 20 [permissions] diff --git a/internal/api/contract/settings.go b/internal/api/contract/settings.go index eee014726..394299c56 100644 --- a/internal/api/contract/settings.go +++ b/internal/api/contract/settings.go @@ -175,7 +175,6 @@ type SettingsDefaultsPayload struct { } type SettingsLimitsPayload struct { - MaxSessions int `json:"max_sessions"` MaxConcurrentAgents int `json:"max_concurrent_agents"` } diff --git a/internal/api/core/agent_spawn.go b/internal/api/core/agent_spawn.go index f65801016..cb86ec48f 100644 --- a/internal/api/core/agent_spawn.go +++ b/internal/api/core/agent_spawn.go @@ -125,7 +125,7 @@ func statusForAgentSpawnError(err error) int { return http.StatusCreated case errors.Is(err, session.ErrSpawnPermissionDenied): return http.StatusForbidden - case errors.Is(err, session.ErrSpawnLimitExceeded), errors.Is(err, session.ErrMaxSessionsReached): + case errors.Is(err, session.ErrSpawnLimitExceeded): return http.StatusConflict case errors.Is(err, session.ErrSpawnValidation): return http.StatusUnprocessableEntity diff --git a/internal/api/core/authored_context.go b/internal/api/core/authored_context.go index 781bf2322..d26a22dc2 100644 --- a/internal/api/core/authored_context.go +++ b/internal/api/core/authored_context.go @@ -733,7 +733,7 @@ func (h *BaseHandlers) WakeAgentHeartbeat(c *gin.Context) { } } decision, err := h.HeartbeatWake.Wake(c.Request.Context(), heartbeat.WakeRequest{ - WorkspaceID: target.workspaceID, + WorkspaceID: target.storageWorkspaceID(), AgentName: target.agentName, SessionID: sessionID, Source: source, @@ -1059,7 +1059,7 @@ func (t authoredAgentTarget) withAgentArtifacts( func (t authoredAgentTarget) soulAuthoringTarget() soul.AuthoringTarget { return soul.AuthoringTarget{ - WorkspaceID: t.workspaceID, + WorkspaceID: t.storageWorkspaceID(), WorkspaceRoot: authoredContextSourceRoot(t.workspaceRoot, t.agentPath), AgentName: t.agentName, AgentPath: t.agentPath, @@ -1070,7 +1070,7 @@ func (t authoredAgentTarget) soulAuthoringTarget() soul.AuthoringTarget { func (t authoredAgentTarget) heartbeatAuthoringTarget() heartbeat.AuthoringTarget { return heartbeat.AuthoringTarget{ - WorkspaceID: t.workspaceID, + WorkspaceID: t.storageWorkspaceID(), WorkspaceRoot: authoredContextSourceRoot(t.workspaceRoot, t.agentPath), AgentName: t.agentName, AgentPath: t.agentPath, @@ -1078,6 +1078,13 @@ func (t authoredAgentTarget) heartbeatAuthoringTarget() heartbeat.AuthoringTarge } } +func (t authoredAgentTarget) storageWorkspaceID() string { + if id := strings.TrimSpace(t.sessionWorkspaceID); id != "" { + return id + } + return strings.TrimSpace(t.workspaceID) +} + func authoredContextSourceRoot(workspaceRoot string, agentPath string) string { root := strings.TrimSpace(workspaceRoot) source := strings.TrimSpace(agentPath) @@ -1376,7 +1383,7 @@ func (h *BaseHandlers) heartbeatWakeEvents( return nil, nil } events, err := h.HeartbeatWakeEvents.ListHeartbeatWakeEvents(ctx, heartbeat.WakeEventListQuery{ - WorkspaceID: target.workspaceID, + WorkspaceID: target.storageWorkspaceID(), AgentName: target.agentName, SessionID: strings.TrimSpace(sessionID), Limit: defaultWakeEventInspectLimit, diff --git a/internal/api/core/authored_context_test.go b/internal/api/core/authored_context_test.go index d535a5ec0..0745bfca0 100644 --- a/internal/api/core/authored_context_test.go +++ b/internal/api/core/authored_context_test.go @@ -4,8 +4,11 @@ import ( "bytes" "context" "encoding/json" + "errors" "net/http" "net/http/httptest" + "os" + "path/filepath" "strings" "testing" @@ -164,6 +167,141 @@ func (s *heartbeatWakeSpy) Wake( }, nil } +type workspaceIDCaptureSoulAuthoring struct { + putCalls int + last soul.PutRequest +} + +func (s *workspaceIDCaptureSoulAuthoring) Validate(context.Context, soul.ValidateRequest) (soul.ValidateResult, error) { + return soul.ValidateResult{}, nil +} + +func (s *workspaceIDCaptureSoulAuthoring) Put( + _ context.Context, + req soul.PutRequest, +) (soul.MutationResult, error) { + s.putCalls++ + s.last = req + return soul.MutationResult{}, errors.New("captured soul put request") +} + +func (s *workspaceIDCaptureSoulAuthoring) Delete(context.Context, soul.DeleteRequest) (soul.MutationResult, error) { + return soul.MutationResult{}, nil +} + +func (s *workspaceIDCaptureSoulAuthoring) History(context.Context, soul.HistoryRequest) (soul.HistoryResult, error) { + return soul.HistoryResult{}, nil +} + +func (s *workspaceIDCaptureSoulAuthoring) Rollback(context.Context, soul.RollbackRequest) (soul.MutationResult, error) { + return soul.MutationResult{}, nil +} + +func TestAuthoredContextUsesRegistryWorkspaceIDForStorageBackedOperations(t *testing.T) { + t.Parallel() + + workspaceRoot := t.TempDir() + agentDir := filepath.Join(workspaceRoot, aghconfig.DirName, aghconfig.AgentsDirName, "coder") + if err := os.MkdirAll(agentDir, 0o755); err != nil { + t.Fatalf("MkdirAll(agent dir) error = %v", err) + } + agentBody := []byte("---\nname: coder\nprovider: claude\n---\nReview startup launch work.\n") + if err := os.WriteFile(filepath.Join(agentDir, "AGENT.md"), agentBody, 0o644); err != nil { + t.Fatalf("WriteFile(AGENT.md) error = %v", err) + } + + workspaces := testutil.StubWorkspaceService{ + ResolveFn: func(ctx context.Context, ref string) (workspacepkg.ResolvedWorkspace, error) { + if err := ctx.Err(); err != nil { + return workspacepkg.ResolvedWorkspace{}, err + } + if strings.TrimSpace(ref) != "ws-stable" { + return workspacepkg.ResolvedWorkspace{}, workspacepkg.ErrWorkspaceNotFound + } + return workspacepkg.ResolvedWorkspace{ + Workspace: workspacepkg.Workspace{ID: "ws-registry", RootDir: workspaceRoot, Name: "Ad8 QA"}, + WorkspaceID: "ws-stable", + Config: aghconfig.Config{ + Agents: aghconfig.AgentsConfig{ + Soul: aghconfig.DefaultSoulConfig(), + Heartbeat: aghconfig.DefaultHeartbeatConfig(), + }, + }, + }, nil + }, + } + fixture := newHandlerFixture(t, testutil.StubSessionManager{}, testutil.StubObserver{}, workspaces, nil, nil) + soulAuthoring := &workspaceIDCaptureSoulAuthoring{} + statusSpy := &heartbeatStatusSpy{} + wakeSpy := &heartbeatWakeSpy{} + fixture.Handlers.SoulAuthoring = soulAuthoring + fixture.Handlers.HeartbeatStatus = statusSpy + fixture.Handlers.HeartbeatWake = wakeSpy + fixture.Engine.PUT("/agents/:agent_name/soul", fixture.Handlers.PutAgentSoul) + fixture.Engine.GET("/agents/:name/heartbeat/status", fixture.Handlers.GetAgentHeartbeatStatus) + fixture.Engine.POST("/agents/:name/heartbeat/wake", fixture.Handlers.WakeAgentHeartbeat) + + t.Run("Should pass registry workspace id to Soul authoring", func(t *testing.T) { + body := []byte("{\"workspace_id\":\"ws-stable\",\"agent_name\":\"coder\",\"body\":\"# Soul\"}") + req := httptest.NewRequestWithContext( + context.Background(), + http.MethodPut, + "/agents/coder/soul", + bytes.NewReader(body), + ) + req.Header.Set("Content-Type", "application/json") + recorder := httptest.NewRecorder() + fixture.Engine.ServeHTTP(recorder, req) + + if soulAuthoring.putCalls != 1 { + t.Fatalf("soul put calls = %d, want 1", soulAuthoring.putCalls) + } + if got, want := soulAuthoring.last.Target.WorkspaceID, "ws-registry"; got != want { + t.Fatalf("Soul target WorkspaceID = %q, want %q", got, want) + } + }) + + t.Run("Should pass registry workspace id to Heartbeat status", func(t *testing.T) { + req := httptest.NewRequestWithContext( + context.Background(), + http.MethodGet, + "/agents/coder/heartbeat/status?workspace_id=ws-stable", + nil, + ) + recorder := httptest.NewRecorder() + fixture.Engine.ServeHTTP(recorder, req) + + if statusSpy.calls != 1 { + t.Fatalf("heartbeat status calls = %d, want 1", statusSpy.calls) + } + if got, want := statusSpy.last.Target.WorkspaceID, "ws-registry"; got != want { + t.Fatalf("Heartbeat status target WorkspaceID = %q, want %q", got, want) + } + }) + + t.Run("Should pass registry workspace id to Heartbeat wake", func(t *testing.T) { + body := []byte( + "{\"workspace_id\":\"ws-stable\",\"agent_name\":\"coder\",\"source\":\"manual\",\"dry_run\":true}", + ) + req := httptest.NewRequestWithContext( + context.Background(), + http.MethodPost, + "/agents/coder/heartbeat/wake", + bytes.NewReader(body), + ) + req.Header.Set("Content-Type", "application/json") + recorder := httptest.NewRecorder() + fixture.Engine.ServeHTTP(recorder, req) + + if wakeSpy.calls != 1 { + t.Fatalf("heartbeat wake calls = %d, want 1", wakeSpy.calls) + } + if got, want := wakeSpy.last.WorkspaceID, "ws-registry"; got != want { + t.Fatalf("Heartbeat wake WorkspaceID = %q, want %q", got, want) + } + }) +} + func TestAuthoredContextHeartbeatStatusAndWakeRejectForeignSessionWorkspace(t *testing.T) { t.Parallel() diff --git a/internal/api/core/conversions.go b/internal/api/core/conversions.go index 519a725f5..1cb1dd752 100644 --- a/internal/api/core/conversions.go +++ b/internal/api/core/conversions.go @@ -1047,7 +1047,11 @@ func SessionProviderOptionPayloadsFromConfig(cfg *aghconfig.Config) []contract.S } } - return sortSessionProviderOptionPayloads(payloadsByName) + defaultProvider := "" + if cfg != nil { + defaultProvider = aghconfig.CanonicalProviderName(cfg.Defaults.Provider) + } + return sortSessionProviderOptionPayloads(payloadsByName, defaultProvider) } func sessionProviderOptionPayloadFromConfig( @@ -1089,17 +1093,29 @@ func sessionProviderOptionPayloads(names []string) []contract.SessionProviderOpt } values[trimmed] = contract.SessionProviderOptionPayload{Name: trimmed} } - return sortSessionProviderOptionPayloads(values) + return sortSessionProviderOptionPayloads(values, "") } func sortSessionProviderOptionPayloads( values map[string]contract.SessionProviderOptionPayload, + defaultProvider string, ) []contract.SessionProviderOptionPayload { names := make([]string, 0, len(values)) for name := range values { names = append(names, name) } sort.Strings(names) + defaultProvider = aghconfig.CanonicalProviderName(defaultProvider) + if defaultProvider != "" { + for i, name := range names { + if name != defaultProvider { + continue + } + copy(names[1:i+1], names[:i]) + names[0] = defaultProvider + break + } + } payloads := make([]contract.SessionProviderOptionPayload, 0, len(names)) for _, name := range names { payloads = append(payloads, values[name]) @@ -1568,7 +1584,6 @@ func settingsGeneralConfigPayload(value settingspkg.GeneralSettings) contract.Se Sandbox: strings.TrimSpace(value.Defaults.Sandbox), }, Limits: contract.SettingsLimitsPayload{ - MaxSessions: value.Limits.MaxSessions, MaxConcurrentAgents: value.Limits.MaxConcurrentAgents, }, Permissions: contract.SettingsPermissionsPayload{ diff --git a/internal/api/core/network.go b/internal/api/core/network.go index 945ae2814..ad829d34b 100644 --- a/internal/api/core/network.go +++ b/internal/api/core/network.go @@ -55,7 +55,11 @@ func (h *BaseHandlers) NetworkPeers(c *gin.Context) { return } - peers, err := service.ListPeers(c.Request.Context(), scope.ID, strings.TrimSpace(c.Query("channel"))) + peers, err := service.ListPeers( + c.Request.Context(), + scope.NetworkWorkspaceID(), + strings.TrimSpace(c.Query("channel")), + ) if err != nil { h.respondError(c, StatusForNetworkError(err), err) return @@ -133,7 +137,7 @@ func (h *BaseHandlers) NetworkChannels(c *gin.Context) { return } - channels, err := h.networkChannelPayloads(c.Request.Context(), service, scope.ID) + channels, err := h.networkChannelPayloads(c.Request.Context(), service, scope.NetworkWorkspaceID()) if err != nil { h.respondError(c, StatusForNetworkError(err), err) return @@ -162,7 +166,7 @@ func (h *BaseHandlers) NetworkSend(c *gin.Context) { ) return } - if bodyWorkspaceID := strings.TrimSpace(req.WorkspaceID); bodyWorkspaceID != "" && bodyWorkspaceID != scope.ID { + if !scope.BodyWorkspaceIDMatches(req.WorkspaceID) { h.respondError( c, http.StatusBadRequest, @@ -170,7 +174,7 @@ func (h *BaseHandlers) NetworkSend(c *gin.Context) { ) return } - req.WorkspaceID = scope.ID + req.WorkspaceID = scope.NetworkWorkspaceID() if strings.TrimSpace(req.SessionID) == "" { h.respondError(c, http.StatusBadRequest, NewNetworkValidationError(errors.New("session_id is required"))) return diff --git a/internal/api/core/network_conversations.go b/internal/api/core/network_conversations.go index c30d860ad..2a6b8b0c9 100644 --- a/internal/api/core/network_conversations.go +++ b/internal/api/core/network_conversations.go @@ -90,7 +90,7 @@ func (h *BaseHandlers) NetworkThreadMessages(c *gin.Context) { } threadID := strings.TrimSpace(c.Param("thread_id")) ref := store.NetworkConversationRef{ - WorkspaceID: scope.ID, + WorkspaceID: scope.NetworkWorkspaceID(), Channel: channel, Surface: store.NetworkSurfaceThread, ThreadID: threadID, @@ -167,7 +167,7 @@ func (h *BaseHandlers) ResolveNetworkDirectRoom(c *gin.Context) { localPeer, remotePeer, err := h.resolveDirectRoomPeers( c.Request.Context(), service, - scope.ID, + scope.NetworkWorkspaceID(), channel, sessionID, peerID, @@ -176,14 +176,20 @@ func (h *BaseHandlers) ResolveNetworkDirectRoom(c *gin.Context) { h.respondError(c, StatusForNetworkError(err), err) return } - directID, peerA, peerB, err := network.DirectRoomIdentity(scope.ID, channel, localPeer.PeerID, remotePeer.PeerID) + networkWorkspaceID := scope.NetworkWorkspaceID() + directID, peerA, peerB, err := network.DirectRoomIdentity( + networkWorkspaceID, + channel, + localPeer.PeerID, + remotePeer.PeerID, + ) if err != nil { h.respondError(c, StatusForNetworkError(err), err) return } now := h.nowUTC() direct, err := h.NetworkStore.ResolveDirectRoom(c.Request.Context(), store.NetworkDirectRoomEntry{ - WorkspaceID: scope.ID, + WorkspaceID: networkWorkspaceID, Channel: channel, DirectID: directID, PeerA: peerA, @@ -246,7 +252,7 @@ func (h *BaseHandlers) NetworkDirectRoomMessages(c *gin.Context) { } directID := strings.TrimSpace(c.Param("direct_id")) ref := store.NetworkConversationRef{ - WorkspaceID: scope.ID, + WorkspaceID: scope.NetworkWorkspaceID(), Channel: channel, Surface: store.NetworkSurfaceDirect, DirectID: directID, @@ -268,7 +274,7 @@ func (h *BaseHandlers) NetworkWork(c *gin.Context) { if !ok { return } - work, err := h.NetworkStore.GetWork(c.Request.Context(), scope.ID, workID) + work, err := h.NetworkStore.GetWork(c.Request.Context(), scope.NetworkWorkspaceID(), workID) if err != nil { h.respondError(c, StatusForNetworkError(err), err) return diff --git a/internal/api/core/network_details.go b/internal/api/core/network_details.go index 589116708..f1e22adc4 100644 --- a/internal/api/core/network_details.go +++ b/internal/api/core/network_details.go @@ -105,7 +105,7 @@ func (h *BaseHandlers) CreateNetworkChannel(c *gin.Context) { if !ok { return } - if bodyWorkspaceID := strings.TrimSpace(req.WorkspaceID); bodyWorkspaceID != "" && bodyWorkspaceID != scope.ID { + if !scope.BodyWorkspaceIDMatches(req.WorkspaceID) { h.respondError( c, http.StatusBadRequest, @@ -113,8 +113,10 @@ func (h *BaseHandlers) CreateNetworkChannel(c *gin.Context) { ) return } + networkWorkspaceID := scope.NetworkWorkspaceID() + req.WorkspaceID = networkWorkspaceID - channel, purpose, resolved, agentNames, err := h.resolveCreateNetworkChannelRequest( + channel, purpose, agentNames, err := h.resolveCreateNetworkChannelRequest( c.Request.Context(), req, &scope.Resolved, @@ -127,7 +129,7 @@ func (h *BaseHandlers) CreateNetworkChannel(c *gin.Context) { createdIDs, err := h.createNetworkChannelSessions( c.Request.Context(), channel, - resolved.WorkspaceID, + networkWorkspaceID, agentNames, ) if err != nil { @@ -141,7 +143,7 @@ func (h *BaseHandlers) CreateNetworkChannel(c *gin.Context) { networkStore, store.NetworkChannelEntry{ Channel: channel, - WorkspaceID: resolved.WorkspaceID, + WorkspaceID: networkWorkspaceID, Purpose: purpose, CreatedBy: agentNames[0], }, @@ -173,7 +175,7 @@ func (h *BaseHandlers) NetworkChannel(c *gin.Context) { return } - detail, err := h.networkChannelDetailPayload(c.Request.Context(), service, scope.ID, channel) + detail, err := h.networkChannelDetailPayload(c.Request.Context(), service, scope.NetworkWorkspaceID(), channel) if err != nil { if isNetworkChannelNotFound(err) { h.respondError(c, http.StatusNotFound, err) @@ -219,13 +221,14 @@ func (h *BaseHandlers) NetworkChannelMessages(c *gin.Context) { h.respondError(c, http.StatusInternalServerError, err) return } - peers, err := service.ListPeers(c.Request.Context(), scope.ID, channel) + networkWorkspaceID := scope.NetworkWorkspaceID() + peers, err := service.ListPeers(c.Request.Context(), networkWorkspaceID, channel) if err != nil { h.respondError(c, StatusForNetworkError(err), err) return } - query.WorkspaceID = scope.ID + query.WorkspaceID = networkWorkspaceID query.Channel = channel if err := query.Validate(); err != nil { h.respondError(c, http.StatusBadRequest, NewNetworkValidationError(err)) @@ -242,7 +245,7 @@ func (h *BaseHandlers) NetworkChannelMessages(c *gin.Context) { h.respondError(c, http.StatusInternalServerError, err) return } - if len(rawMessages) == 0 && !networkChannelExists(sessions, peers, metadata, scope.ID, channel) { + if len(rawMessages) == 0 && !networkChannelExists(sessions, peers, metadata, networkWorkspaceID, channel) { notFoundErr := fmt.Errorf("%w: %s", errNetworkChannelNotFound, channel) h.respondError(c, http.StatusNotFound, notFoundErr) return @@ -294,7 +297,8 @@ func (h *BaseHandlers) NetworkPeerMessages(c *gin.Context) { if !ok { return } - peers, err := service.ListPeers(c.Request.Context(), scope.ID, "") + networkWorkspaceID := scope.NetworkWorkspaceID() + peers, err := service.ListPeers(c.Request.Context(), networkWorkspaceID, "") if err != nil { h.respondError(c, StatusForNetworkError(err), err) return @@ -310,7 +314,7 @@ func (h *BaseHandlers) NetworkPeerMessages(c *gin.Context) { return } - query.WorkspaceID = scope.ID + query.WorkspaceID = networkWorkspaceID query.PeerID = peerID query.DirectedOnly = !query.IncludePresence if err := query.Validate(); err != nil { @@ -364,7 +368,7 @@ func (h *BaseHandlers) NetworkPeer(c *gin.Context) { if !ok { return } - peers, err := service.ListPeers(c.Request.Context(), scope.ID, "") + peers, err := service.ListPeers(c.Request.Context(), scope.NetworkWorkspaceID(), "") if err != nil { h.respondError(c, StatusForNetworkError(err), err) return @@ -399,32 +403,32 @@ func (h *BaseHandlers) resolveCreateNetworkChannelRequest( ctx context.Context, req contract.CreateNetworkChannelRequest, resolved *workspacepkg.ResolvedWorkspace, -) (string, string, workspacepkg.ResolvedWorkspace, []string, error) { +) (string, string, []string, error) { _ = ctx if resolved == nil { - return "", "", workspacepkg.ResolvedWorkspace{}, nil, NewNetworkValidationError( + return "", "", nil, NewNetworkValidationError( errors.New("workspace is required"), ) } channel, err := normalizeNetworkChannel(req.Channel) if err != nil { - return "", "", workspacepkg.ResolvedWorkspace{}, nil, err + return "", "", nil, err } purpose, err := normalizeNetworkChannelPurpose(req.Purpose) if err != nil { - return "", "", workspacepkg.ResolvedWorkspace{}, nil, err + return "", "", nil, err } - workspaceID := strings.TrimSpace(resolved.WorkspaceID) + workspaceID := strings.TrimSpace(resolved.ID) if workspaceID == "" { - return "", "", workspacepkg.ResolvedWorkspace{}, nil, NewNetworkValidationError( + return "", "", nil, NewNetworkValidationError( errors.New("workspace_id is required"), ) } agentNames, err := normalizeNetworkAgentNames(req.AgentNames) if err != nil { - return "", "", workspacepkg.ResolvedWorkspace{}, nil, err + return "", "", nil, err } available := make(map[string]struct{}, len(resolved.Agents)) for _, agent := range resolved.Agents { @@ -434,14 +438,14 @@ func (h *BaseHandlers) resolveCreateNetworkChannelRequest( if _, ok := available[agentName]; ok { continue } - return "", "", workspacepkg.ResolvedWorkspace{}, nil, fmt.Errorf( + return "", "", nil, fmt.Errorf( "%w: %s", workspacepkg.ErrAgentNotAvailable, agentName, ) } - return channel, purpose, *resolved, agentNames, nil + return channel, purpose, agentNames, nil } func normalizeNetworkChannel(channel string) (string, error) { diff --git a/internal/api/core/network_test.go b/internal/api/core/network_test.go index fcd9a2e5a..8827f91f8 100644 --- a/internal/api/core/network_test.go +++ b/internal/api/core/network_test.go @@ -4314,7 +4314,8 @@ func TestBaseHandlersCreateNetworkChannelCreatesSessionsPerAgent(t *testing.T) { t.Fatalf("Resolve() ref = %q, want ws-workspace", ref) } return workspacepkg.ResolvedWorkspace{ - Workspace: workspacepkg.Workspace{ID: "ws-1", Name: "Workspace"}, + Workspace: workspacepkg.Workspace{ID: "ws-workspace", Name: "Workspace"}, + WorkspaceID: "ws-stable", Agents: []aghconfig.AgentDef{ {Name: "coder"}, {Name: "reviewer"}, @@ -4325,7 +4326,10 @@ func TestBaseHandlersCreateNetworkChannelCreatesSessionsPerAgent(t *testing.T) { fixture := newHandlerFixture(t, manager, testutil.StubObserver{}, workspaces, nil, nil) fixture.Handlers.Config.Network.Enabled = true fixture.Handlers.Network = testutil.StubNetworkService{ - ListPeersFn: func(_ context.Context, _ string, channel string) ([]network.PeerInfo, error) { + ListPeersFn: func(_ context.Context, workspaceID string, channel string) ([]network.PeerInfo, error) { + if workspaceID != "ws-workspace" { + t.Fatalf("ListPeers() workspaceID = %q, want ws-workspace", workspaceID) + } if channel != "builders" { return nil, nil } @@ -4350,6 +4354,12 @@ func TestBaseHandlersCreateNetworkChannelCreatesSessionsPerAgent(t *testing.T) { }, } fixture.Handlers.NetworkStore = testutil.StubNetworkStore{ + WriteNetworkChannelFn: func(_ context.Context, entry store.NetworkChannelEntry) error { + if got, want := entry.WorkspaceID, "ws-workspace"; got != want { + t.Fatalf("WriteNetworkChannel() workspace_id = %q, want %q", got, want) + } + return nil + }, ListNetworkMessagesFn: func(_ context.Context, query store.NetworkMessageQuery) ([]store.NetworkMessageEntry, error) { if query.Channel != "builders" { return nil, nil @@ -4425,6 +4435,133 @@ func TestBaseHandlersCreateNetworkChannelCreatesSessionsPerAgent(t *testing.T) { ) } +func TestBaseHandlersNetworkUsesRegistryWorkspaceIdentity(t *testing.T) { + t.Parallel() + + t.Run("Should list persisted channels with the registry workspace id", func(t *testing.T) { + t.Parallel() + + workspaces := testutil.StubWorkspaceService{ + ResolveFn: func(_ context.Context, ref string) (workspacepkg.ResolvedWorkspace, error) { + if ref != "ws-workspace" { + t.Fatalf("Resolve() ref = %q, want ws-workspace", ref) + } + return workspacepkg.ResolvedWorkspace{ + Workspace: workspacepkg.Workspace{ID: "ws-workspace", Name: "Workspace"}, + WorkspaceID: "ws-stable", + }, nil + }, + } + fixture := newHandlerFixture( + t, + networkTestSessionManager("ws-workspace", "sess-a"), + testutil.StubObserver{}, + workspaces, + nil, + nil, + ) + fixture.Handlers.Config.Network.Enabled = true + fixture.Handlers.Network = testutil.StubNetworkService{ + ListPeersFn: func(_ context.Context, workspaceID string, channel string) ([]network.PeerInfo, error) { + if workspaceID != "ws-workspace" { + t.Fatalf("ListPeers() workspaceID = %q, want ws-workspace", workspaceID) + } + if channel != "" { + t.Fatalf("ListPeers() channel = %q, want empty list filter", channel) + } + return nil, nil + }, + } + fixture.Handlers.NetworkStore = testutil.StubNetworkStore{ + ListNetworkChannelsFn: func(_ context.Context, query store.NetworkChannelQuery) ([]store.NetworkChannelEntry, error) { + if query.WorkspaceID != "ws-workspace" { + t.Fatalf("ListNetworkChannels() workspace_id = %q, want ws-workspace", query.WorkspaceID) + } + return []store.NetworkChannelEntry{{ + WorkspaceID: "ws-workspace", + Channel: "builders", + Purpose: "Coordinate builders", + CreatedBy: "general", + CreatedAt: time.Date(2026, 4, 11, 18, 0, 0, 0, time.UTC), + UpdatedAt: time.Date(2026, 4, 11, 18, 0, 0, 0, time.UTC), + }}, nil + }, + ListNetworkMessagesFn: func(_ context.Context, query store.NetworkMessageQuery) ([]store.NetworkMessageEntry, error) { + if query.WorkspaceID != "ws-workspace" { + t.Fatalf("ListNetworkMessages() workspace_id = %q, want ws-workspace", query.WorkspaceID) + } + return nil, nil + }, + } + + resp := performRequest( + t, + fixture.Engine, + http.MethodGet, + "/workspaces/ws-workspace/network/channels", + nil, + ) + if resp.Code != http.StatusOK { + t.Fatalf("channels code = %d, want %d; body=%s", resp.Code, http.StatusOK, resp.Body.String()) + } + + var payload contract.NetworkChannelsResponse + testutil.DecodeJSONResponse(t, resp, &payload) + if len(payload.Channels) != 1 || payload.Channels[0].Channel != "builders" { + t.Fatalf("channels payload = %#v, want builders channel", payload.Channels) + } + if got, want := payload.Channels[0].WorkspaceID, "ws-workspace"; got != want { + t.Fatalf("channel workspace_id = %q, want %q", got, want) + } + }) + + t.Run("Should send messages with the registry workspace id", func(t *testing.T) { + t.Parallel() + + workspaces := testutil.StubWorkspaceService{ + ResolveFn: func(_ context.Context, ref string) (workspacepkg.ResolvedWorkspace, error) { + if ref != "ws-workspace" { + t.Fatalf("Resolve() ref = %q, want ws-workspace", ref) + } + return workspacepkg.ResolvedWorkspace{ + Workspace: workspacepkg.Workspace{ID: "ws-workspace", Name: "Workspace"}, + WorkspaceID: "ws-stable", + }, nil + }, + } + fixture := newHandlerFixture( + t, + networkTestSessionManager("ws-workspace", "sess-a"), + testutil.StubObserver{}, + workspaces, + nil, + nil, + ) + fixture.Handlers.Config.Network.Enabled = true + fixture.Handlers.Network = testutil.StubNetworkService{ + SendFn: func(_ context.Context, req network.SendRequest) (string, error) { + if req.WorkspaceID != "ws-workspace" { + t.Fatalf("Send() workspace_id = %q, want ws-workspace", req.WorkspaceID) + } + return "msg-1", nil + }, + } + + resp := performRequest( + t, + fixture.Engine, + http.MethodPost, + "/workspaces/ws-workspace/network/send", + []byte( + "{\"workspace_id\":\"ws-workspace\",\"session_id\":\"sess-a\",\"channel\":\"builders\",\"surface\":\"thread\",\"thread_id\":\"thread_launch_db\",\"kind\":\"say\",\"body\":{\"text\":\"hello\"}}", + ), + ) + if resp.Code != http.StatusOK { + t.Fatalf("send code = %d, want %d; body=%s", resp.Code, http.StatusOK, resp.Body.String()) + } + }) +} + func TestBaseHandlersNetworkPeerDetailUsesAuditMetrics(t *testing.T) { t.Parallel() diff --git a/internal/api/core/session_workspace.go b/internal/api/core/session_workspace.go index be4b84df9..0e7786f7f 100644 --- a/internal/api/core/session_workspace.go +++ b/internal/api/core/session_workspace.go @@ -157,8 +157,7 @@ func statusForSessionError(err error) int { return http.StatusBadRequest case errors.Is(err, session.ErrSessionNotActive): return http.StatusBadRequest - case errors.Is(err, session.ErrMaxSessionsReached), - errors.Is(err, session.ErrPromptInProgress), + case errors.Is(err, session.ErrPromptInProgress), errors.Is(err, session.ErrPendingPermissionNotFound), errors.Is(err, session.ErrPendingPermissionConflict), errors.Is(err, workspacepkg.ErrWorkspaceNameTaken), diff --git a/internal/api/core/session_workspace_internal_test.go b/internal/api/core/session_workspace_internal_test.go index 3574b827c..9eb96baf4 100644 --- a/internal/api/core/session_workspace_internal_test.go +++ b/internal/api/core/session_workspace_internal_test.go @@ -230,6 +230,7 @@ func TestSessionProviderOptionPayloadsFromConfig(t *testing.T) { t.Parallel() cfg := &aghconfig.Config{ + Defaults: aghconfig.DefaultsConfig{Provider: "codex"}, Providers: map[string]aghconfig.ProviderConfig{ "alpha": {Command: "alpha --acp"}, "claude": {Command: "claude-overlay --acp"}, @@ -253,9 +254,29 @@ func TestSessionProviderOptionPayloadsFromConfig(t *testing.T) { if got, want := len(payloads), len(expected); got != want { t.Fatalf("len(payloads) = %d, want %d (%#v)", got, want, payloads) } - for i, want := range expected { - if got := payloads[i].Name; got != want.Name { - t.Fatalf("payloads[%d].Name = %q, want %q (%#v)", i, got, want.Name, payloads) + if got, want := payloads[0].Name, "codex"; got != want { + t.Fatalf("payloads[0].Name = %q, want default provider %q first (%#v)", got, want, payloads) + } + seen := make(map[string]bool, len(payloads)) + for _, payload := range payloads { + seen[payload.Name] = true + } + for _, want := range expected { + if !seen[want.Name] { + t.Fatalf("payloads missing provider %q (%#v)", want.Name, payloads) + } + } + remainder := make([]string, 0, len(expected)-1) + for _, want := range expected { + if want.Name == "codex" { + continue + } + remainder = append(remainder, want.Name) + } + for i, want := range remainder { + payload := payloads[i+1] + if payload.Name != want { + t.Fatalf("payloads[%d].Name = %q, want %q after default (%#v)", i+1, payload.Name, want, payloads) } } for _, payload := range payloads { diff --git a/internal/api/core/settings.go b/internal/api/core/settings.go index 193f2e811..acfb804e7 100644 --- a/internal/api/core/settings.go +++ b/internal/api/core/settings.go @@ -1134,7 +1134,6 @@ func generalSettingsFromPayload(payload contract.SettingsGeneralConfigPayload) ( Sandbox: strings.TrimSpace(payload.Defaults.Sandbox), }, Limits: aghconfig.LimitsConfig{ - MaxSessions: payload.Limits.MaxSessions, MaxConcurrentAgents: payload.Limits.MaxConcurrentAgents, }, Permissions: aghconfig.PermissionsConfig{ diff --git a/internal/api/core/settings_internal_test.go b/internal/api/core/settings_internal_test.go index bc93a238a..7b46ad7c7 100644 --- a/internal/api/core/settings_internal_test.go +++ b/internal/api/core/settings_internal_test.go @@ -182,7 +182,7 @@ func TestSettingsPayloadHelpersRejectInvalidInputs(t *testing.T) { if _, err := generalSettingsFromPayload(contract.SettingsGeneralConfigPayload{ Defaults: contract.SettingsDefaultsPayload{Agent: "coder"}, - Limits: contract.SettingsLimitsPayload{MaxSessions: 4, MaxConcurrentAgents: 2}, + Limits: contract.SettingsLimitsPayload{MaxConcurrentAgents: 2}, Permissions: contract.SettingsPermissionsPayload{Mode: contract.SettingsPermissionModeApproveReads}, SessionTimeout: "bad", HTTP: contract.SettingsHTTPPayload{Host: "127.0.0.1", Port: 2123}, diff --git a/internal/api/core/settings_test.go b/internal/api/core/settings_test.go index 4bf8b704b..a4bbb6f6c 100644 --- a/internal/api/core/settings_test.go +++ b/internal/api/core/settings_test.go @@ -392,7 +392,7 @@ func TestSettingsSectionAndCollectionConversions(t *testing.T) { }, Settings: settingspkg.GeneralSettings{ Defaults: aghconfig.DefaultsConfig{Agent: "coder", Provider: "openai", Sandbox: "local"}, - Limits: aghconfig.LimitsConfig{MaxSessions: 4, MaxConcurrentAgents: 2}, + Limits: aghconfig.LimitsConfig{MaxConcurrentAgents: 2}, Permissions: aghconfig.PermissionsConfig{ Mode: aghconfig.PermissionModeApproveReads, }, @@ -772,7 +772,6 @@ func TestUpdateSettingsGeneralRejectsInvalidPayload(t *testing.T) { "agent": "coder", }, "limits": map[string]any{ - "max_sessions": 4, "max_concurrent_agents": 2, }, "permissions": map[string]any{ @@ -871,7 +870,7 @@ func TestUpdateSettingsSectionHandlersDelegateValidPayloads(t *testing.T) { Provider: "openai", Sandbox: "local", }, - Limits: contract.SettingsLimitsPayload{MaxSessions: 4, MaxConcurrentAgents: 2}, + Limits: contract.SettingsLimitsPayload{MaxConcurrentAgents: 2}, Permissions: contract.SettingsPermissionsPayload{ Mode: contract.SettingsPermissionModeApproveReads, }, @@ -1768,7 +1767,7 @@ func TestSettingsHandlersReturnServiceUnavailableWithoutInjectedDependencies(t * body: mustJSON(t, contract.UpdateSettingsGeneralRequest{ Config: contract.SettingsGeneralConfigPayload{ Defaults: contract.SettingsDefaultsPayload{Agent: "coder"}, - Limits: contract.SettingsLimitsPayload{MaxSessions: 4, MaxConcurrentAgents: 2}, + Limits: contract.SettingsLimitsPayload{MaxConcurrentAgents: 2}, Permissions: contract.SettingsPermissionsPayload{ Mode: contract.SettingsPermissionModeApproveReads, }, diff --git a/internal/api/core/workspace_scope.go b/internal/api/core/workspace_scope.go index 824d45af8..6a4d3857e 100644 --- a/internal/api/core/workspace_scope.go +++ b/internal/api/core/workspace_scope.go @@ -23,15 +23,27 @@ type workspaceScope struct { func (s *workspaceScope) NetworkChannelRef(channel string) store.NetworkChannelRef { return store.NetworkChannelRef{ - WorkspaceID: strings.TrimSpace(s.ID), + WorkspaceID: s.NetworkWorkspaceID(), Channel: strings.TrimSpace(channel), } } +func (s *workspaceScope) NetworkWorkspaceID() string { + return strings.TrimSpace(s.RegistryID) +} + func (s *workspaceScope) SessionWorkspaceID() string { return strings.TrimSpace(s.RegistryID) } +func (s *workspaceScope) BodyWorkspaceIDMatches(workspaceID string) bool { + trimmed := strings.TrimSpace(workspaceID) + if trimmed == "" { + return true + } + return trimmed == strings.TrimSpace(s.ID) || trimmed == strings.TrimSpace(s.RegistryID) +} + func (h *BaseHandlers) resolveWorkspaceScope(c *gin.Context) (workspaceScope, bool) { if c == nil { return workspaceScope{}, false diff --git a/internal/api/httpapi/handlers_test.go b/internal/api/httpapi/handlers_test.go index c784c3dc4..4247f4703 100644 --- a/internal/api/httpapi/handlers_test.go +++ b/internal/api/httpapi/handlers_test.go @@ -618,7 +618,7 @@ func TestSettingsAndExtensionMutationsReachHandlersOnLoopbackHost(t *testing.T) body: mustJSONBody(t, contract.UpdateSettingsGeneralRequest{ Config: contract.SettingsGeneralConfigPayload{ Defaults: contract.SettingsDefaultsPayload{Agent: "coder"}, - Limits: contract.SettingsLimitsPayload{MaxSessions: 4, MaxConcurrentAgents: 2}, + Limits: contract.SettingsLimitsPayload{MaxConcurrentAgents: 2}, Permissions: contract.SettingsPermissionsPayload{ Mode: contract.SettingsPermissionModeApproveReads, }, diff --git a/internal/api/httpapi/server_test.go b/internal/api/httpapi/server_test.go index 69f3c4c83..37a5e9db9 100644 --- a/internal/api/httpapi/server_test.go +++ b/internal/api/httpapi/server_test.go @@ -344,7 +344,7 @@ func TestLoopbackServerAllowsSettingsAndExtensionMutations(t *testing.T) { body: mustJSONBody(t, contract.UpdateSettingsGeneralRequest{ Config: contract.SettingsGeneralConfigPayload{ Defaults: contract.SettingsDefaultsPayload{Agent: "coder"}, - Limits: contract.SettingsLimitsPayload{MaxSessions: 4, MaxConcurrentAgents: 2}, + Limits: contract.SettingsLimitsPayload{MaxConcurrentAgents: 2}, Permissions: contract.SettingsPermissionsPayload{ Mode: contract.SettingsPermissionModeApproveReads, }, diff --git a/internal/api/httpapi/stream_helpers_test.go b/internal/api/httpapi/stream_helpers_test.go index e2194b35c..2718dbd80 100644 --- a/internal/api/httpapi/stream_helpers_test.go +++ b/internal/api/httpapi/stream_helpers_test.go @@ -423,8 +423,8 @@ func TestPayloadAndStatusHelpersCoverRemainingBranches(t *testing.T) { if status := core.StatusForSessionError(os.ErrNotExist); status != http.StatusNotFound { t.Fatalf("statusForSessionError(os.ErrNotExist) = %d, want %d", status, http.StatusNotFound) } - if status := core.StatusForSessionError(session.ErrMaxSessionsReached); status != http.StatusConflict { - t.Fatalf("statusForSessionError(ErrMaxSessionsReached) = %d, want %d", status, http.StatusConflict) + if status := core.StatusForSessionError(session.ErrPromptInProgress); status != http.StatusConflict { + t.Fatalf("statusForSessionError(ErrPromptInProgress) = %d, want %d", status, http.StatusConflict) } if status := core.StatusForSessionError(workspacepkg.ErrWorkspaceNotFound); status != http.StatusNotFound { t.Fatalf("statusForSessionError(ErrWorkspaceNotFound) = %d, want %d", status, http.StatusNotFound) diff --git a/internal/api/udsapi/handlers_test.go b/internal/api/udsapi/handlers_test.go index 6fd6ed5d4..250375595 100644 --- a/internal/api/udsapi/handlers_test.go +++ b/internal/api/udsapi/handlers_test.go @@ -434,7 +434,7 @@ func TestSettingsRoutesUseSharedCoreHandlers(t *testing.T) { body: mustJSONBody(t, contract.UpdateSettingsGeneralRequest{ Config: contract.SettingsGeneralConfigPayload{ Defaults: contract.SettingsDefaultsPayload{Agent: "coder"}, - Limits: contract.SettingsLimitsPayload{MaxSessions: 4, MaxConcurrentAgents: 2}, + Limits: contract.SettingsLimitsPayload{MaxConcurrentAgents: 2}, Permissions: contract.SettingsPermissionsPayload{ Mode: contract.SettingsPermissionModeApproveReads, }, @@ -2035,7 +2035,7 @@ func TestSessionErrorMappingUsesNotFoundAndConflict(t *testing.T) { return nil, session.ErrSessionNotFound }, CreateFn: func(context.Context, session.CreateOpts) (*session.Session, error) { - return nil, session.ErrMaxSessionsReached + return nil, session.ErrPromptInProgress }, } handlers := newTestHandlers(t, manager, stubObserver{}, homePaths) diff --git a/internal/cli/agent.go b/internal/cli/agent.go index fc5c13cb6..f9773984d 100644 --- a/internal/cli/agent.go +++ b/internal/cli/agent.go @@ -1,18 +1,27 @@ package cli import ( + "errors" + "fmt" + "os" + "path/filepath" "strconv" "strings" + aghconfig "github.com/pedronauck/agh/internal/config" "github.com/spf13/cobra" + "gopkg.in/yaml.v3" ) +const cliAgentDefinitionFileName = "AGENT.md" + func newAgentCommand(deps commandDeps) *cobra.Command { cmd := &cobra.Command{ Use: "agent", Short: "Inspect AGH agent definitions", } + cmd.AddCommand(newAgentCreateCommand(deps)) cmd.AddCommand(newAgentListCommand(deps)) cmd.AddCommand(newAgentInfoCommand(deps)) cmd.AddCommand(newAgentSoulCommand(deps)) @@ -20,6 +29,219 @@ func newAgentCommand(deps commandDeps) *cobra.Command { return cmd } +type agentCreateFlags struct { + workspace string + provider string + command string + model string + prompt string + promptFile string + tools []string + toolsets []string + denyTools []string + permissions string + categoryPath []string + force bool +} + +type agentDefinitionFrontmatter struct { + Name string `yaml:"name"` + Provider string `yaml:"provider,omitempty"` + Command string `yaml:"command,omitempty"` + Model string `yaml:"model,omitempty"` + Tools []string `yaml:"tools,omitempty"` + Toolsets []string `yaml:"toolsets,omitempty"` + DenyTools []string `yaml:"deny_tools,omitempty"` + Permissions string `yaml:"permissions,omitempty"` + CategoryPath []string `yaml:"category_path,omitempty"` +} + +func newAgentCreateCommand(deps commandDeps) *cobra.Command { + var flags agentCreateFlags + cmd := &cobra.Command{ + Use: "create ", + Short: "Create a global or workspace-local AGENT.md definition", + Example: ` # Create a workspace-local agent definition + agh agent create pricing_strategist \ + --workspace ~/dev/ad8 \ + --provider claude \ + --model claude-sonnet-4-6 \ + --prompt "You own pricing strategy." \ + -o json`, + Args: exactOneNonBlankArg(), + RunE: func(cmd *cobra.Command, args []string) error { + workspace, err := commandWorkspaceFlag(cmd) + if err != nil { + return err + } + flags.workspace = workspace + agent, err := createAgentDefinition(cmd, deps, args[0], flags) + if err != nil { + return err + } + return writeCommandOutput(cmd, agentBundle(agentRecordFromDefinition(agent))) + }, + } + cmd.Flags().String("workspace", "", "Workspace id, name, or path to create the agent under") + cmd.Flags().StringVar(&flags.provider, "provider", "", "Provider name for sessions using this agent") + cmd.Flags().StringVar(&flags.command, "command", "", "Optional provider command override") + cmd.Flags().StringVar(&flags.model, "model", "", "Optional provider model") + cmd.Flags().StringVar(&flags.prompt, "prompt", "", "Agent system prompt body") + cmd.Flags().StringVar(&flags.promptFile, "prompt-file", "", "Read the agent system prompt body from a file") + cmd.Flags().StringArrayVar(&flags.tools, "tool", nil, "Allowed tool pattern (repeatable)") + cmd.Flags().StringArrayVar(&flags.toolsets, "toolset", nil, "Allowed toolset reference (repeatable)") + cmd.Flags().StringArrayVar(&flags.denyTools, "deny-tool", nil, "Denied tool pattern (repeatable)") + cmd.Flags().StringVar(&flags.permissions, "permissions", "", "Optional permission mode") + cmd.Flags().StringArrayVar(&flags.categoryPath, "category", nil, "Agent category path segment (repeatable)") + cmd.Flags().BoolVar(&flags.force, "force", false, "Overwrite an existing AGENT.md definition") + return cmd +} + +func createAgentDefinition( + cmd *cobra.Command, + deps commandDeps, + name string, + flags agentCreateFlags, +) (aghconfig.AgentDef, error) { + agentName := aghconfig.NormalizeAgentName(name) + if err := aghconfig.ValidateAgentName(agentName); err != nil { + return aghconfig.AgentDef{}, err + } + prompt, err := agentCreatePrompt(flags) + if err != nil { + return aghconfig.AgentDef{}, err + } + contents, agent, err := renderAgentDefinition(agentName, prompt, flags) + if err != nil { + return aghconfig.AgentDef{}, err + } + agentsDir, err := resolveAgentCreateDirectory(cmd, deps, flags.workspace) + if err != nil { + return aghconfig.AgentDef{}, err + } + path := filepath.Join(agentsDir, agentName, cliAgentDefinitionFileName) + if err := writeAgentDefinitionFile(path, contents, flags.force); err != nil { + return aghconfig.AgentDef{}, err + } + agent.SourcePath = filepath.Clean(path) + return agent, nil +} + +func agentCreatePrompt(flags agentCreateFlags) (string, error) { + prompt := strings.TrimSpace(flags.prompt) + promptFile := strings.TrimSpace(flags.promptFile) + if prompt != "" && promptFile != "" { + return "", errors.New("cli: use either --prompt or --prompt-file, not both") + } + if promptFile != "" { + contents, err := os.ReadFile(promptFile) + if err != nil { + return "", fmt.Errorf("cli: read prompt file %q: %w", promptFile, err) + } + prompt = strings.TrimSpace(string(contents)) + } + if prompt == "" { + return "", errors.New("cli: --prompt or --prompt-file is required") + } + return prompt, nil +} + +func renderAgentDefinition( + agentName string, + prompt string, + flags agentCreateFlags, +) (string, aghconfig.AgentDef, error) { + metadata := agentDefinitionFrontmatter{ + Name: agentName, + Provider: strings.TrimSpace(flags.provider), + Command: strings.TrimSpace(flags.command), + Model: strings.TrimSpace(flags.model), + Tools: trimSpawnAtoms(flags.tools), + Toolsets: trimSpawnAtoms(flags.toolsets), + DenyTools: trimSpawnAtoms(flags.denyTools), + Permissions: strings.TrimSpace(flags.permissions), + CategoryPath: trimSpawnAtoms(flags.categoryPath), + } + frontmatter, err := yaml.Marshal(metadata) + if err != nil { + return "", aghconfig.AgentDef{}, fmt.Errorf("cli: render agent frontmatter: %w", err) + } + contents := fmt.Sprintf("---\n%s---\n\n%s\n", string(frontmatter), prompt) + agent, err := aghconfig.ParseAgentDef([]byte(contents)) + if err != nil { + return "", aghconfig.AgentDef{}, fmt.Errorf("cli: validate generated agent definition: %w", err) + } + if agent.Name != agentName { + return "", aghconfig.AgentDef{}, fmt.Errorf( + "cli: generated agent name %q does not match %q", + agent.Name, + agentName, + ) + } + return contents, agent, nil +} + +func resolveAgentCreateDirectory(cmd *cobra.Command, deps commandDeps, workspaceRef string) (string, error) { + if strings.TrimSpace(workspaceRef) == "" { + homePaths, err := deps.resolveHome() + if err != nil { + return "", err + } + if deps.ensureHome != nil { + if err := deps.ensureHome(homePaths); err != nil { + return "", err + } + } + return homePaths.AgentsDir, nil + } + + client, err := clientFromDeps(deps) + if err != nil { + return "", err + } + detail, err := client.GetWorkspace(cmd.Context(), workspaceRef) + if err != nil { + return "", err + } + rootDir := strings.TrimSpace(detail.Workspace.RootDir) + if rootDir == "" { + return "", errors.New("cli: resolved workspace root_dir is empty") + } + return filepath.Join(rootDir, aghconfig.DirName, aghconfig.AgentsDirName), nil +} + +func writeAgentDefinitionFile(path string, contents string, force bool) error { + if !force { + if _, err := os.Stat(path); err == nil { + return fmt.Errorf("cli: agent definition already exists at %s (use --force to overwrite)", path) + } else if !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("cli: inspect agent definition %q: %w", path, err) + } + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return fmt.Errorf("cli: create agent directory %q: %w", filepath.Dir(path), err) + } + if err := os.WriteFile(path, []byte(contents), 0o600); err != nil { + return fmt.Errorf("cli: write agent definition %q: %w", path, err) + } + return nil +} + +func agentRecordFromDefinition(agent aghconfig.AgentDef) AgentRecord { + return AgentRecord{ + Name: agent.Name, + Provider: agent.Provider, + Command: agent.Command, + Model: agent.Model, + Tools: agent.Tools, + Toolsets: agent.Toolsets, + DenyTools: agent.DenyTools, + Permissions: agent.Permissions, + CategoryPath: agent.CategoryPath, + Prompt: agent.Prompt, + } +} + func newAgentListCommand(deps commandDeps) *cobra.Command { cmd := &cobra.Command{ Use: "list", diff --git a/internal/cli/agent_commands_test.go b/internal/cli/agent_commands_test.go index 4cce498ed..e3d39227e 100644 --- a/internal/cli/agent_commands_test.go +++ b/internal/cli/agent_commands_test.go @@ -3,8 +3,12 @@ package cli import ( "context" "encoding/json" + "os" + "path/filepath" "strings" "testing" + + aghconfig "github.com/pedronauck/agh/internal/config" ) func TestAgentListAndInfoCommands(t *testing.T) { @@ -155,6 +159,87 @@ func TestAgentCommandsPassWorkspaceQuery(t *testing.T) { }) } +func TestAgentCreateCommand(t *testing.T) { + t.Parallel() + + t.Run("Should create a workspace-local agent definition", func(t *testing.T) { + t.Parallel() + + workspaceRoot := t.TempDir() + const workspaceRef = "ws-alpha" + deps := newTestDeps(t, &stubClient{ + getWorkspaceFn: func(_ context.Context, ref string) (WorkspaceDetailRecord, error) { + if ref != workspaceRef { + t.Fatalf("GetWorkspace() ref = %q, want %q", ref, workspaceRef) + } + return WorkspaceDetailRecord{ + Workspace: WorkspaceRecord{ + ID: workspaceRef, + RootDir: workspaceRoot, + Name: "Alpha", + }, + }, nil + }, + }) + + stdout, _, err := executeRootCommand( + t, + deps, + "agent", + "create", + "pricing_strategist", + "--workspace", + workspaceRef, + "--provider", + "claude", + "--model", + "claude-sonnet-4-6", + "--prompt", + "You own Ad8 pricing strategy.", + "--tool", + "builtin__shell", + "--category", + "Strategy", + "-o", + "json", + ) + if err != nil { + t.Fatalf("agent create error = %v", err) + } + var created AgentRecord + if err := json.Unmarshal([]byte(stdout), &created); err != nil { + t.Fatalf("json.Unmarshal(agent create) error = %v", err) + } + if created.Name != "pricing_strategist" || created.Provider != "claude" || + created.Model != "claude-sonnet-4-6" || created.Prompt != "You own Ad8 pricing strategy." { + t.Fatalf("created agent = %#v, want pricing strategist metadata", created) + } + + path := filepath.Join( + workspaceRoot, + aghconfig.DirName, + aghconfig.AgentsDirName, + "pricing_strategist", + "AGENT.md", + ) + fileInfo, err := os.Stat(path) + if err != nil { + t.Fatalf("os.Stat(created AGENT.md) error = %v", err) + } + if fileInfo.Mode().Perm() != 0o600 { + t.Fatalf("created AGENT.md mode = %v, want 0600", fileInfo.Mode().Perm()) + } + loaded, err := aghconfig.LoadAgentDefFile(path) + if err != nil { + t.Fatalf("LoadAgentDefFile(created AGENT.md) error = %v", err) + } + if len(loaded.Tools) != 1 || loaded.Name != created.Name || loaded.Provider != created.Provider || + loaded.Tools[0] != "builtin__shell" { + t.Fatalf("loaded agent = %#v, want created agent definition", loaded) + } + }) +} + func TestAgentWorkspaceFlagRejectsEmptyExplicitValue(t *testing.T) { t.Parallel() diff --git a/internal/cli/cli_integration_test.go b/internal/cli/cli_integration_test.go index 82ba9a5ef..2ff43a060 100644 --- a/internal/cli/cli_integration_test.go +++ b/internal/cli/cli_integration_test.go @@ -1608,9 +1608,10 @@ func TestCLITaskCreateListGetIntegration(t *testing.T) { "--channel", "builders", "--title", "Investigate flaky task runs", "--description", "Capture root cause", + "--priority", "high", "--owner-kind", "pool", "--owner-ref", "triage", - "--metadata", `{"priority":"high"}`, + "--metadata", `{"source":"qa"}`, "-o", "json", ) if err != nil { @@ -1621,7 +1622,11 @@ func TestCLITaskCreateListGetIntegration(t *testing.T) { if err := json.Unmarshal([]byte(createOut), &created); err != nil { t.Fatalf("json.Unmarshal(task create) error = %v", err) } - if created.ID == "" || created.Scope != taskpkg.ScopeWorkspace || created.WorkspaceID == "" || created.NetworkChannel != "builders" { + if created.ID == "" || + created.Scope != taskpkg.ScopeWorkspace || + created.WorkspaceID == "" || + created.NetworkChannel != "builders" || + created.Priority != taskpkg.PriorityHigh { t.Fatalf("created task = %#v, want workspace task with id/channel", created) } diff --git a/internal/cli/client.go b/internal/cli/client.go index d061b9cc6..51c664e4f 100644 --- a/internal/cli/client.go +++ b/internal/cli/client.go @@ -53,6 +53,11 @@ type DaemonClient interface { NetworkStatus(ctx context.Context) (NetworkStatusRecord, error) NetworkPeers(ctx context.Context, query NetworkPeersQuery) ([]NetworkPeerRecord, error) NetworkChannels(ctx context.Context, workspaceRef string) ([]NetworkChannelRecord, error) + CreateNetworkChannel( + ctx context.Context, + workspaceRef string, + request CreateNetworkChannelRequest, + ) (NetworkChannelDetailRecord, error) NetworkThreads(ctx context.Context, query NetworkThreadsQuery) ([]NetworkThreadRecord, error) NetworkThread( ctx context.Context, @@ -1030,6 +1035,12 @@ type NetworkPeerCardRecord = contract.NetworkPeerCardPayload // NetworkChannelRecord is the shared active-channel payload. type NetworkChannelRecord = contract.NetworkChannelPayload +// NetworkChannelDetailRecord is the shared detailed channel payload. +type NetworkChannelDetailRecord = contract.NetworkChannelDetailPayload + +// CreateNetworkChannelRequest captures one network channel creation payload. +type CreateNetworkChannelRequest = contract.CreateNetworkChannelRequest + // NetworkThreadRecord is the shared public-thread summary payload. type NetworkThreadRecord = contract.NetworkThreadSummaryPayload @@ -1382,6 +1393,24 @@ func (c *unixSocketClient) NetworkChannels(ctx context.Context, workspaceRef str return response.Channels, nil } +func (c *unixSocketClient) CreateNetworkChannel( + ctx context.Context, + workspaceRef string, + request CreateNetworkChannelRequest, +) (NetworkChannelDetailRecord, error) { + var response contract.CreateNetworkChannelResponse + path, err := networkBasePath(workspaceRef) + if err != nil { + return NetworkChannelDetailRecord{}, err + } + body := request + body.WorkspaceID = "" + if err := c.doJSON(ctx, http.MethodPost, path+"/channels", nil, body, &response); err != nil { + return NetworkChannelDetailRecord{}, err + } + return response.Channel, nil +} + func (c *unixSocketClient) NetworkThreads( ctx context.Context, query NetworkThreadsQuery, diff --git a/internal/cli/config.go b/internal/cli/config.go index c987f85c6..4507d6ba0 100644 --- a/internal/cli/config.go +++ b/internal/cli/config.go @@ -142,7 +142,6 @@ var ( "defaults.agent": configSetString, "defaults.provider": configSetString, "defaults.sandbox": configSetString, - "limits.max_sessions": configSetInt, "limits.max_concurrent_agents": configSetInt, "session.limits.timeout": configSetDuration, "session.supervision.activity_heartbeat_interval": configSetDuration, diff --git a/internal/cli/helpers_test.go b/internal/cli/helpers_test.go index 4c18f40a1..b8c4e4ef1 100644 --- a/internal/cli/helpers_test.go +++ b/internal/cli/helpers_test.go @@ -32,6 +32,7 @@ type stubClient struct { networkStatusFn func(context.Context) (NetworkStatusRecord, error) networkPeersFn func(context.Context, NetworkPeersQuery) ([]NetworkPeerRecord, error) networkChannelsFn func(context.Context, string) ([]NetworkChannelRecord, error) + createNetworkChannelFn func(context.Context, string, CreateNetworkChannelRequest) (NetworkChannelDetailRecord, error) networkThreadsFn func(context.Context, NetworkThreadsQuery) ([]NetworkThreadRecord, error) networkThreadFn func(context.Context, string, string, string) (NetworkThreadRecord, error) networkThreadMessagesFn func(context.Context, NetworkConversationMessagesQuery) ([]NetworkConversationMessageRecord, error) @@ -372,6 +373,17 @@ func (s *stubClient) NetworkChannels(ctx context.Context, workspaceRef string) ( return nil, errors.New("unexpected NetworkChannels call") } +func (s *stubClient) CreateNetworkChannel( + ctx context.Context, + workspaceRef string, + request CreateNetworkChannelRequest, +) (NetworkChannelDetailRecord, error) { + if s.createNetworkChannelFn != nil { + return s.createNetworkChannelFn(ctx, workspaceRef, request) + } + return NetworkChannelDetailRecord{}, errors.New("unexpected CreateNetworkChannel call") +} + func (s *stubClient) NetworkThreads( ctx context.Context, query NetworkThreadsQuery, diff --git a/internal/cli/network.go b/internal/cli/network.go index 048be0a09..7a2cb2c2d 100644 --- a/internal/cli/network.go +++ b/internal/cli/network.go @@ -105,7 +105,7 @@ func newNetworkPeersCommand(deps commandDeps, workspaceRef *string) *cobra.Comma } func newNetworkChannelsCommand(deps commandDeps, workspaceRef *string) *cobra.Command { - return &cobra.Command{ + cmd := &cobra.Command{ Use: "channels", Short: "List active runtime channels", RunE: func(cmd *cobra.Command, _ []string) error { @@ -125,6 +125,85 @@ func newNetworkChannelsCommand(deps commandDeps, workspaceRef *string) *cobra.Co return writeCommandOutput(cmd, networkChannelsBundle(channels)) }, } + cmd.AddCommand(newNetworkChannelsCreateCommand(deps, workspaceRef)) + return cmd +} + +type networkCreateChannelFlags struct { + channel string + purpose string + agentNames []string +} + +func newNetworkChannelsCreateCommand(deps commandDeps, workspaceRef *string) *cobra.Command { + var flags networkCreateChannelFlags + cmd := &cobra.Command{ + Use: "create [channel]", + Short: "Create a runtime channel and start one session per selected agent", + Args: cobra.MaximumNArgs(1), + Example: ` # Create a launch channel with two local agents + agh network --workspace ~/dev/ad8 channels create ad8_launch \ + --purpose "Coordinate launch work" \ + --agent site_copywriter \ + --agent growth_marketer \ + -o json`, + RunE: func(cmd *cobra.Command, args []string) error { + client, err := clientFromDeps(deps) + if err != nil { + return err + } + workspace, err := resolveNetworkWorkspaceRef(cmd, deps, client, workspaceRef) + if err != nil { + return err + } + channel, err := resolveNetworkCreateChannelName(args, flags.channel) + if err != nil { + return err + } + agentNames := trimSpawnAtoms(flags.agentNames) + if len(agentNames) == 0 { + return errors.New("cli: at least one --agent is required") + } + created, err := client.CreateNetworkChannel(cmd.Context(), workspace, CreateNetworkChannelRequest{ + Channel: channel, + Purpose: strings.TrimSpace(flags.purpose), + AgentNames: agentNames, + }) + if err != nil { + return err + } + return writeCommandOutput(cmd, networkChannelBundle(created)) + }, + } + cmd.Flags().StringVar(&flags.channel, "channel", "", "Channel name when not passed as an argument") + cmd.Flags().StringVar(&flags.purpose, "purpose", "", "Human-readable channel purpose") + cmd.Flags().StringArrayVar( + &flags.agentNames, + "agent", + nil, + "Agent definition name to launch in the channel (repeatable)", + ) + mustMarkFlagRequired(cmd, "purpose") + mustMarkFlagRequired(cmd, "agent") + return cmd +} + +func resolveNetworkCreateChannelName(args []string, channelFlag string) (string, error) { + fromArg := "" + if len(args) == 1 { + fromArg = strings.TrimSpace(args[0]) + } + fromFlag := strings.TrimSpace(channelFlag) + switch { + case fromArg == "" && fromFlag == "": + return "", errors.New("cli: channel is required") + case fromArg != "" && fromFlag != "" && fromArg != fromFlag: + return "", errors.New("cli: channel argument and --channel must match") + case fromArg != "": + return fromArg, nil + default: + return fromFlag, nil + } } type networkThreadsFlags struct { @@ -754,6 +833,40 @@ func networkChannelsBundle(channels []NetworkChannelRecord) outputBundle { ) } +func networkChannelBundle(channel NetworkChannelDetailRecord) outputBundle { + fields := []string{ + "channel", "workspace_id", "purpose", "created_by", "peer_count", "session_count", "message_count", + } + values := []string{ + channel.Channel, + channel.WorkspaceID, + channel.Purpose, + channel.CreatedBy, + strconv.Itoa(channel.PeerCount), + strconv.Itoa(channel.SessionCount), + strconv.Itoa(channel.MessageCount), + } + return outputBundle{ + jsonValue: channel, + human: func() (string, error) { + return renderHumanBlocks( + renderHumanSection("Network Channel", []keyValue{ + {Label: "Channel", Value: stringOrDash(channel.Channel)}, + {Label: "Workspace", Value: stringOrDash(channel.WorkspaceID)}, + {Label: "Purpose", Value: stringOrDash(channel.Purpose)}, + {Label: "Created By", Value: stringOrDash(channel.CreatedBy)}, + {Label: "Peers", Value: strconv.Itoa(channel.PeerCount)}, + {Label: "Sessions", Value: strconv.Itoa(channel.SessionCount)}, + {Label: "Messages", Value: strconv.Itoa(channel.MessageCount)}, + }), + ), nil + }, + toon: func() (string, error) { + return renderToonObject("network_channel", fields, values), nil + }, + } +} + func networkThreadsBundle(threads []NetworkThreadRecord) outputBundle { return listBundle( contract.NetworkThreadsResponse{Threads: threads}, diff --git a/internal/cli/network_client_test.go b/internal/cli/network_client_test.go index b312e0814..b0645a48e 100644 --- a/internal/cli/network_client_test.go +++ b/internal/cli/network_client_test.go @@ -42,6 +42,21 @@ func TestUnixSocketClientNetworkMethods(t *testing.T) { http.StatusOK, `{"channels":[{"channel":"builders","peer_count":2}]}`, ), nil + case req.Method == http.MethodPost && req.URL.Path == "/api/workspaces/ws-alpha/network/channels": + body, err := io.ReadAll(req.Body) + if err != nil { + t.Fatalf("io.ReadAll(network channels create body) error = %v", err) + } + if strings.Contains(string(body), `"workspace_id":"ws-alpha"`) || + !strings.Contains(string(body), `"channel":"launch_ops"`) || + !strings.Contains(string(body), `"purpose":"Coordinate launch work"`) || + !strings.Contains(string(body), `"agent_names":["site_copywriter","growth_marketer"]`) { + t.Fatalf("network channels create body = %s, want route-scoped create payload", body) + } + return newHTTPResponse( + http.StatusCreated, + `{"channel":{"channel":"launch_ops","workspace_id":"ws-alpha","purpose":"Coordinate launch work","created_by":"site_copywriter","peer_count":2,"session_count":2}}`, + ), nil case req.Method == http.MethodGet && req.URL.Path == "/api/workspaces/ws-alpha/network/channels/builders/threads": if got := req.URL.Query().Get("limit"); got != "2" { t.Fatalf("network threads limit query = %q, want 2", got) @@ -174,6 +189,16 @@ func TestUnixSocketClientNetworkMethods(t *testing.T) { t.Fatalf("NetworkChannels() = %#v, %v", channels, err) } + createdChannel, err := client.CreateNetworkChannel(ctx, "ws-alpha", CreateNetworkChannelRequest{ + Channel: "launch_ops", + WorkspaceID: "ws-alpha", + Purpose: "Coordinate launch work", + AgentNames: []string{"site_copywriter", "growth_marketer"}, + }) + if err != nil || createdChannel.Channel != "launch_ops" || createdChannel.SessionCount != 2 { + t.Fatalf("CreateNetworkChannel() = %#v, %v", createdChannel, err) + } + threads, err := client.NetworkThreads(ctx, NetworkThreadsQuery{ WorkspaceRef: "ws-alpha", Channel: "builders", @@ -323,6 +348,16 @@ func TestNetworkClientHelpersAndAliases(t *testing.T) { {name: "NetworkSendRecord", cliType: NetworkSendRecord{}, want: contract.NetworkSendPayload{}}, {name: "NetworkPeerRecord", cliType: NetworkPeerRecord{}, want: contract.NetworkPeerPayload{}}, {name: "NetworkChannelRecord", cliType: NetworkChannelRecord{}, want: contract.NetworkChannelPayload{}}, + { + name: "NetworkChannelDetailRecord", + cliType: NetworkChannelDetailRecord{}, + want: contract.NetworkChannelDetailPayload{}, + }, + { + name: "CreateNetworkChannelRequest", + cliType: CreateNetworkChannelRequest{}, + want: contract.CreateNetworkChannelRequest{}, + }, { name: "NetworkThreadRecord", cliType: NetworkThreadRecord{}, diff --git a/internal/cli/network_test.go b/internal/cli/network_test.go index 188f9e297..af33f6ab5 100644 --- a/internal/cli/network_test.go +++ b/internal/cli/network_test.go @@ -21,6 +21,7 @@ func TestNetworkCommandsAndFormatting(t *testing.T) { directID := "direct_99401d24bee62651d189e5a561785466" workID := "work_1" var seenPeersQuery NetworkPeersQuery + var seenCreateRequest CreateNetworkChannelRequest var seenSendRequest NetworkSendRequest client := &stubClient{ @@ -84,6 +85,24 @@ func TestNetworkCommandsAndFormatting(t *testing.T) { } return []NetworkChannelRecord{{Channel: "builders", PeerCount: 2}}, nil }, + createNetworkChannelFn: func( + _ context.Context, + workspaceRef string, + request CreateNetworkChannelRequest, + ) (NetworkChannelDetailRecord, error) { + if workspaceRef != "ws-alpha" { + t.Fatalf("CreateNetworkChannel() workspace = %q, want ws-alpha", workspaceRef) + } + seenCreateRequest = request + return NetworkChannelDetailRecord{ + Channel: request.Channel, + WorkspaceID: workspaceRef, + Purpose: request.Purpose, + CreatedBy: request.AgentNames[0], + PeerCount: len(request.AgentNames), + SessionCount: len(request.AgentNames), + }, nil + }, networkSendFn: func(_ context.Context, request NetworkSendRequest) (NetworkSendRecord, error) { seenSendRequest = request return NetworkSendRecord{ @@ -183,6 +202,39 @@ func TestNetworkCommandsAndFormatting(t *testing.T) { t.Fatalf("network channels toon = %q, want TOON list", channelsOut) } + createOut, _, err := executeRootCommand( + t, + deps, + "network", + "--workspace", + "ws-alpha", + "channels", + "create", + "launch_ops", + "--purpose", + "Coordinate launch work", + "--agent", + "site_copywriter", + "--agent", + "growth_marketer", + "-o", + "json", + ) + if err != nil { + t.Fatalf("network channels create error = %v", err) + } + if seenCreateRequest.Channel != "launch_ops" || seenCreateRequest.Purpose != "Coordinate launch work" || + len(seenCreateRequest.AgentNames) != 2 || seenCreateRequest.AgentNames[1] != "growth_marketer" { + t.Fatalf("seenCreateRequest = %#v, want launch channel with two agents", seenCreateRequest) + } + var created NetworkChannelDetailRecord + if err := json.Unmarshal([]byte(createOut), &created); err != nil { + t.Fatalf("json.Unmarshal(network channels create) error = %v", err) + } + if created.Channel != "launch_ops" || created.SessionCount != 2 { + t.Fatalf("created channel = %#v, want launch_ops with two sessions", created) + } + sendOut, _, err := executeRootCommand(t, deps, "network", "--workspace", "ws-alpha", "send", "--session", "sess-a", diff --git a/internal/cli/task.go b/internal/cli/task.go index 9372e95c3..6aeeb4ffe 100644 --- a/internal/cli/task.go +++ b/internal/cli/task.go @@ -23,6 +23,7 @@ type taskCreateInput struct { NetworkRaw string Title string Description string + PriorityRaw string OwnerKindRaw string OwnerRef string MetadataRaw string @@ -31,6 +32,7 @@ type taskCreateInput struct { type taskUpdateInput struct { Title string Description string + PriorityRaw string MetadataRaw string NetworkRaw string OwnerKindRaw string @@ -174,6 +176,7 @@ func newTaskCreateCommand(deps commandDeps) *cobra.Command { ownerKindRaw string ownerRef string metadataRaw string + priorityRaw string ) cmd := &cobra.Command{ @@ -194,6 +197,7 @@ func newTaskCreateCommand(deps commandDeps) *cobra.Command { NetworkRaw: networkRaw, Title: title, Description: description, + PriorityRaw: priorityRaw, OwnerKindRaw: ownerKindRaw, OwnerRef: ownerRef, MetadataRaw: metadataRaw, @@ -217,6 +221,7 @@ func newTaskCreateCommand(deps commandDeps) *cobra.Command { cmd.Flags().StringVar(&networkRaw, "channel", "", "Optional network channel binding") cmd.Flags().StringVar(&title, "title", "", "Task title") cmd.Flags().StringVar(&description, "description", "", "Task description") + cmd.Flags().StringVar(&priorityRaw, "priority", "", "Task priority: low, medium, high, or urgent") cmd.Flags().StringVar(&ownerKindRaw, "owner-kind", "", "Optional owner kind") cmd.Flags().StringVar(&ownerRef, "owner-ref", "", "Optional owner reference") cmd.Flags().StringVar(&metadataRaw, "metadata", "", "Optional metadata JSON") @@ -251,6 +256,7 @@ func newTaskUpdateCommand(deps commandDeps) *cobra.Command { description string metadataRaw string networkRaw string + priorityRaw string ownerKindRaw string ownerRef string clearOwner bool @@ -269,6 +275,7 @@ func newTaskUpdateCommand(deps commandDeps) *cobra.Command { request, err := buildTaskUpdateRequest(cmd, taskUpdateInput{ Title: title, Description: description, + PriorityRaw: priorityRaw, MetadataRaw: metadataRaw, NetworkRaw: networkRaw, OwnerKindRaw: ownerKindRaw, @@ -291,6 +298,7 @@ func newTaskUpdateCommand(deps commandDeps) *cobra.Command { } cmd.Flags().StringVar(&title, "title", "", "Update the task title") cmd.Flags().StringVar(&description, "description", "", "Update the task description") + cmd.Flags().StringVar(&priorityRaw, "priority", "", "Update the task priority: low, medium, high, or urgent") cmd.Flags().StringVar(&metadataRaw, "metadata", "", "Update metadata JSON") cmd.Flags(). StringVar(&networkRaw, "channel", "", "Update the network channel; pass an empty value to clear it") @@ -312,6 +320,13 @@ func buildTaskUpdateRequest(cmd *cobra.Command, input taskUpdateInput) (UpdateTa if cmd.Flags().Changed("description") { request.Description = stringPointer(strings.TrimSpace(input.Description)) } + if cmd.Flags().Changed("priority") { + priority, err := parseOptionalTaskPriority(input.PriorityRaw) + if err != nil { + return UpdateTaskRequest{}, err + } + request.Priority = &priority + } if cmd.Flags().Changed("metadata") { metadata, err := parseJSONFlag("metadata", input.MetadataRaw) if err != nil { @@ -1308,6 +1323,7 @@ func newTaskChildCreateCommand(deps commandDeps) *cobra.Command { networkRaw string title string description string + priorityRaw string ownerKindRaw string ownerRef string metadataRaw string @@ -1331,6 +1347,7 @@ func newTaskChildCreateCommand(deps commandDeps) *cobra.Command { NetworkRaw: networkRaw, Title: title, Description: description, + PriorityRaw: priorityRaw, OwnerKindRaw: ownerKindRaw, OwnerRef: ownerRef, MetadataRaw: metadataRaw, @@ -1355,6 +1372,7 @@ func newTaskChildCreateCommand(deps commandDeps) *cobra.Command { cmd.Flags().StringVar(&networkRaw, "channel", "", "Optional network channel binding") cmd.Flags().StringVar(&title, "title", "", "Child task title") cmd.Flags().StringVar(&description, "description", "", "Child task description") + cmd.Flags().StringVar(&priorityRaw, "priority", "", "Child task priority: low, medium, high, or urgent") cmd.Flags().StringVar(&ownerKindRaw, "owner-kind", "", "Optional child owner kind") cmd.Flags().StringVar(&ownerRef, "owner-ref", "", "Optional child owner reference") cmd.Flags().StringVar(&metadataRaw, "metadata", "", "Optional child metadata JSON") @@ -2044,6 +2062,10 @@ func buildTaskCreateRequest(cmd *cobra.Command, input taskCreateInput) (CreateTa if err != nil { return CreateTaskRequest{}, err } + priority, err := parseOptionalChangedTaskPriority(cmd, input.PriorityRaw) + if err != nil { + return CreateTaskRequest{}, err + } request := CreateTaskRequest{ ID: strings.TrimSpace(input.ID), @@ -2053,6 +2075,7 @@ func buildTaskCreateRequest(cmd *cobra.Command, input taskCreateInput) (CreateTa NetworkChannel: strings.TrimSpace(input.NetworkRaw), Title: strings.TrimSpace(input.Title), Description: strings.TrimSpace(input.Description), + Priority: priority, Owner: owner, Metadata: metadata, } @@ -2080,6 +2103,25 @@ func parseOptionalTaskMetadata(cmd *cobra.Command, metadataRaw string) (json.Raw return parseJSONFlag("metadata", metadataRaw) } +func parseOptionalChangedTaskPriority(cmd *cobra.Command, raw string) (taskpkg.Priority, error) { + if !cmd.Flags().Changed("priority") { + return "", nil + } + return parseOptionalTaskPriority(raw) +} + +func parseOptionalTaskPriority(raw string) (taskpkg.Priority, error) { + trimmed := strings.ToLower(strings.TrimSpace(raw)) + if trimmed == "" { + return "", errors.New("cli: --priority cannot be blank") + } + priority := taskpkg.Priority(trimmed) + if err := priority.Validate("priority"); err != nil { + return "", fmt.Errorf("cli: %w", err) + } + return priority, nil +} + func validateTaskChannelFlag(channel string) error { trimmed := strings.TrimSpace(channel) if trimmed == "" { diff --git a/internal/cli/task_test.go b/internal/cli/task_test.go index 6df1382e0..133d29e51 100644 --- a/internal/cli/task_test.go +++ b/internal/cli/task_test.go @@ -145,9 +145,10 @@ func TestTaskCreateAndListCommandsParseTaskFields(t *testing.T) { "--channel", "builders", "--title", "Investigate flaky task runs", "--description", "Capture root cause", + "--priority", "high", "--owner-kind", "pool", "--owner-ref", "triage", - "--metadata", `{"priority":"high"}`, + "--metadata", `{"source":"qa"}`, "-o", "json", ) if err != nil { @@ -158,10 +159,11 @@ func TestTaskCreateAndListCommandsParseTaskFields(t *testing.T) { createRequest.Workspace != "alpha" || createRequest.NetworkChannel != "builders" || createRequest.Title != "Investigate flaky task runs" || + createRequest.Priority != taskpkg.PriorityHigh || createRequest.Owner == nil || createRequest.Owner.Kind != taskpkg.OwnerKindPool || createRequest.Owner.Ref != "triage" || - string(createRequest.Metadata) != `{"priority":"high"}` { + string(createRequest.Metadata) != `{"source":"qa"}` { t.Fatalf("createRequest = %#v, want parsed workspace/channel/owner/metadata", createRequest) } @@ -981,6 +983,7 @@ func TestTaskMutationCommandsMapRequests(t *testing.T) { "task", "update", "task-1", "--title", "Retitle triage task", "--description", "Refined scope", + "--priority", "urgent", "--channel", "builders", "--owner-kind", "pool", "--owner-ref", "triage", @@ -992,6 +995,7 @@ func TestTaskMutationCommandsMapRequests(t *testing.T) { if updateTaskID != "task-1" || updateRequest.Title == nil || *updateRequest.Title != "Retitle triage task" || updateRequest.Description == nil || *updateRequest.Description != "Refined scope" || + updateRequest.Priority == nil || *updateRequest.Priority != taskpkg.PriorityUrgent || updateRequest.NetworkChannel == nil || *updateRequest.NetworkChannel != "builders" || updateRequest.Owner == nil || updateRequest.Owner.Kind != taskpkg.OwnerKindPool || updateRequest.Owner.Ref != "triage" || updateRequest.ClearOwner || @@ -1066,6 +1070,7 @@ func TestTaskMutationCommandsMapRequests(t *testing.T) { "--channel", "builders", "--title", "Check runtime logs", "--description", "Focus on worker output", + "--priority", "urgent", "--owner-kind", "human", "--owner-ref", "alice", "--metadata", `{"phase":"two"}`, @@ -1081,6 +1086,7 @@ func TestTaskMutationCommandsMapRequests(t *testing.T) { childCreateRequest.NetworkChannel != "builders" || childCreateRequest.Title != "Check runtime logs" || childCreateRequest.Description != "Focus on worker output" || + childCreateRequest.Priority != taskpkg.PriorityUrgent || childCreateRequest.Owner == nil || childCreateRequest.Owner.Kind != taskpkg.OwnerKindHuman || childCreateRequest.Owner.Ref != "alice" || string(childCreateRequest.Metadata) != `{"phase":"two"}` { t.Fatalf("childCreateRequest = %#v, want parsed child task payload", childCreateRequest) diff --git a/internal/config/config.go b/internal/config/config.go index 75a4e70a2..6647e8e66 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -104,7 +104,6 @@ type HeartbeatConfig struct { // LimitsConfig defines runtime safety bounds. type LimitsConfig struct { - MaxSessions int `toml:"max_sessions"` MaxConcurrentAgents int `toml:"max_concurrent_agents"` } @@ -622,7 +621,6 @@ func DefaultWithHome(homePaths HomePaths) Config { Heartbeat: DefaultHeartbeatConfig(), }, Limits: LimitsConfig{ - MaxSessions: 10, MaxConcurrentAgents: 20, }, Session: SessionConfig{ @@ -1247,8 +1245,6 @@ func (c HeartbeatConfig) Validate() error { // Validate ensures the configured limits are positive. func (c LimitsConfig) Validate() error { switch { - case c.MaxSessions <= 0: - return fmt.Errorf("limits.max_sessions must be positive: %d", c.MaxSessions) case c.MaxConcurrentAgents <= 0: return fmt.Errorf("limits.max_concurrent_agents must be positive: %d", c.MaxConcurrentAgents) default: diff --git a/internal/config/config_test.go b/internal/config/config_test.go index ca73b388e..8a4c9a2f6 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -61,7 +61,6 @@ session_health_stale_after = "3m" session_health_hook_min_interval = "90s" [limits] -max_sessions = 11 max_concurrent_agents = 22 [session.limits] @@ -215,7 +214,7 @@ max_queue_depth = 250 if got, want := cfg.Agents.Heartbeat.SessionHealthHookMinInterval, 90*time.Second; got != want { t.Fatalf("Load() Agents.Heartbeat.SessionHealthHookMinInterval = %s, want %s", got, want) } - if cfg.Limits.MaxSessions != 11 || cfg.Limits.MaxConcurrentAgents != 22 { + if cfg.Limits.MaxConcurrentAgents != 22 { t.Fatalf("Load() Limits = %#v", cfg.Limits) } if got, want := cfg.Session.Limits.Timeout, 30*time.Minute; got != want { diff --git a/internal/config/merge.go b/internal/config/merge.go index 1b4507d3d..3e1cef7fa 100644 --- a/internal/config/merge.go +++ b/internal/config/merge.go @@ -97,7 +97,6 @@ type heartbeatOverlay struct { } type limitsOverlay struct { - MaxSessions *int `toml:"max_sessions"` MaxConcurrentAgents *int `toml:"max_concurrent_agents"` } @@ -713,9 +712,6 @@ func (o heartbeatOverlay) Apply(dst *HeartbeatConfig) { } func (o limitsOverlay) Apply(dst *LimitsConfig) { - if o.MaxSessions != nil { - dst.MaxSessions = *o.MaxSessions - } if o.MaxConcurrentAgents != nil { dst.MaxConcurrentAgents = *o.MaxConcurrentAgents } diff --git a/internal/config/tool_surface.go b/internal/config/tool_surface.go index 26e8001fb..c4b44ee00 100644 --- a/internal/config/tool_surface.go +++ b/internal/config/tool_surface.go @@ -83,7 +83,6 @@ var ( "agents.heartbeat.wake_event_retention": ConfigValueDuration, "agents.heartbeat.session_health_stale_after": ConfigValueDuration, "agents.heartbeat.session_health_hook_min_interval": ConfigValueDuration, - "limits.max_sessions": ConfigValueInt, "limits.max_concurrent_agents": ConfigValueInt, "session.limits.timeout": ConfigValueDuration, "session.supervision.activity_heartbeat_interval": ConfigValueDuration, diff --git a/internal/config/tool_surface_test.go b/internal/config/tool_surface_test.go index 9ff55d724..78aeb7040 100644 --- a/internal/config/tool_surface_test.go +++ b/internal/config/tool_surface_test.go @@ -22,8 +22,8 @@ func TestToolConfigPathPolicy(t *testing.T) { kind: ConfigValueString, }, { - name: "Should allow runtime limit mutation", - path: "limits.max_sessions", + name: "Should allow concurrent agent limit mutation", + path: "limits.max_concurrent_agents", kind: ConfigValueInt, }, { diff --git a/internal/coordinator/coordinator.go b/internal/coordinator/coordinator.go index ff1648b68..a386db840 100644 --- a/internal/coordinator/coordinator.go +++ b/internal/coordinator/coordinator.go @@ -242,9 +242,12 @@ func PromptOverlay(input PromptInput) string { writePromptLine(&b, "coordination_channel_id", input.CoordinationChannelID) b.WriteString("\nUse public AGH agent APIs only:\n") b.WriteString("- `agh me context` for the Situation Surface.\n") + b.WriteString("- `agh task create|start` to persist task intent and enqueue active work.\n") b.WriteString("- `agh task next|heartbeat|complete|fail|release` for task ownership and terminal status.\n") b.WriteString("- `agh ch list|recv|send|reply` for operational worker communication.\n") b.WriteString("- `agh spawn` for bounded worker delegation.\n") + b.WriteString("\nCreating a task only records intent. When the objective asks for active execution, ") + b.WriteString("start the executable task so AGH enqueues a run and can route worker agents.\n") b.WriteString("\nChannel communication is operational only. Use the run coordination channel for ") b.WriteString(strings.Join(operationalMessageKinds[:], ", ")) b.WriteString(" messages when conversation is useful. Do not use channel messages as task ownership state.\n") diff --git a/internal/coordinator/coordinator_test.go b/internal/coordinator/coordinator_test.go index 0c67906cb..0912dca65 100644 --- a/internal/coordinator/coordinator_test.go +++ b/internal/coordinator/coordinator_test.go @@ -270,9 +270,11 @@ func TestPromptOverlayUsesPublicAPIsAndRunChannel(t *testing.T) { }) for _, required := range []string{ "agh me context", + "agh task create|start", "agh task next|heartbeat|complete|fail|release", "agh ch list|recv|send|reply", "agh spawn", + "Creating a task only records intent", "coordination_channel_id: ch-run-1", "Never spawn another coordinator", } { diff --git a/internal/session/additional_test.go b/internal/session/additional_test.go index 7c50ebff8..33a69cac7 100644 --- a/internal/session/additional_test.go +++ b/internal/session/additional_test.go @@ -555,10 +555,6 @@ func TestHelperFunctionsAndUtilities(t *testing.T) { if got := newID(""); got == "" { t.Fatal("newID(\"\") = empty, want non-empty") } - - if got := (maxSessionsReachedError{active: 1, limit: 2}).Error(); !strings.Contains(got, "1/2") { - t.Fatalf("maxSessionsReachedError.Error() = %q", got) - } } func TestCreateWithBlankWorkspaceReturnsValidationError(t *testing.T) { diff --git a/internal/session/manager.go b/internal/session/manager.go index 5101f0568..175c23a7f 100644 --- a/internal/session/manager.go +++ b/internal/session/manager.go @@ -29,8 +29,6 @@ var ( ErrSessionNotFound = errors.New("session: session not found") // ErrSessionNotActive reports that a known session cannot accept live approvals or prompts. ErrSessionNotActive = errors.New("session: session is not active") - // ErrMaxSessionsReached reports that the active plus pending session count hit the configured limit. - ErrMaxSessionsReached = errors.New("session: max sessions reached") // ErrPendingPermissionNotFound reports that no waiting permission matched the approval request. ErrPendingPermissionNotFound = errors.New("session: pending permission not found") // ErrPendingPermissionConflict reports that the approval request matched multiple pending permissions. @@ -131,7 +129,6 @@ type Manager struct { newSessionID IDGenerator newSandboxID IDGenerator newTurnID IDGenerator - maxSessions int promptBufSize int soulRefreshTimeout time.Duration sessionHealthHookMinInterval time.Duration @@ -321,13 +318,6 @@ func WithTurnIDGenerator(generator IDGenerator) Option { } } -// WithMaxSessions overrides the config-derived max session limit. -func WithMaxSessions(limit int) Option { - return func(manager *Manager) { - manager.maxSessions = limit - } -} - // WithPromptBufferSize overrides the size of the returned prompt event buffer. func WithPromptBufferSize(size int) Option { return func(manager *Manager) { @@ -635,7 +625,7 @@ func (m *Manager) lookup(id string) (*Session, error) { return session, nil } -func (m *Manager) reserve(id string, maxSessions int) error { +func (m *Manager) reserve(id string) error { m.mu.Lock() defer m.mu.Unlock() @@ -646,11 +636,6 @@ func (m *Manager) reserve(id string, maxSessions int) error { return fmt.Errorf("session: session %q is already pending", id) } - active := len(m.sessions) + len(m.pending) - if active >= maxSessions { - return maxSessionsReachedError{active: active, limit: maxSessions} - } - m.pending[id] = struct{}{} return nil } @@ -799,16 +784,3 @@ func (m *Manager) WaitForFinalizations(ctx context.Context) error { } } } - -type maxSessionsReachedError struct { - active int - limit int -} - -func (e maxSessionsReachedError) Error() string { - return fmt.Sprintf("session: max sessions reached (%d/%d)", e.active, e.limit) -} - -func (e maxSessionsReachedError) Is(target error) bool { - return target == ErrMaxSessionsReached -} diff --git a/internal/session/manager_helpers.go b/internal/session/manager_helpers.go index 253941dd6..63a71e6c5 100644 --- a/internal/session/manager_helpers.go +++ b/internal/session/manager_helpers.go @@ -64,16 +64,6 @@ func (m *Manager) startPermissions(sessionType Type, configured string) aghconfi return mode } -func (m *Manager) effectiveMaxSessions(cfg *aghconfig.Config) int { - if m.maxSessions > 0 { - return m.maxSessions - } - if cfg != nil && cfg.Limits.MaxSessions > 0 { - return cfg.Limits.MaxSessions - } - return aghconfig.DefaultWithHome(m.homePaths).Limits.MaxSessions -} - func (m *Manager) writeMeta(session *Session) error { if session == nil { return errors.New("session: session is required") diff --git a/internal/session/manager_start.go b/internal/session/manager_start.go index e3e778c98..52756dd9b 100644 --- a/internal/session/manager_start.go +++ b/internal/session/manager_start.go @@ -172,7 +172,7 @@ func (m *Manager) startSession(ctx context.Context, spec *sessionStartSpec) (_ * } }() - if err := m.reserve(spec.sessionID, m.effectiveMaxSessions(&spec.workspace.Config)); err != nil { + if err := m.reserve(spec.sessionID); err != nil { return nil, err } defer func() { diff --git a/internal/session/manager_test.go b/internal/session/manager_test.go index 00cf2323b..b958c6cb0 100644 --- a/internal/session/manager_test.go +++ b/internal/session/manager_test.go @@ -898,7 +898,7 @@ func TestActivateAndWatchUpdatesStateAndStartsWatcher(t *testing.T) { recorder: recorder, } - if err := h.manager.reserve(session.ID, h.cfg.Limits.MaxSessions); err != nil { + if err := h.manager.reserve(session.ID); err != nil { t.Fatalf("reserve() error = %v", err) } @@ -2744,7 +2744,7 @@ func TestListAndGet(t *testing.T) { } func TestConcurrentCreateStopGet(t *testing.T) { - h := newHarness(t, WithMaxSessions(32)) + h := newHarness(t) done := make(chan struct{}) var readers sync.WaitGroup @@ -2796,24 +2796,22 @@ func TestConcurrentCreateStopGet(t *testing.T) { } } -func TestCreateEnforcesMaxSessions(t *testing.T) { +func TestCreateDoesNotEnforceSessionCap(t *testing.T) { t.Parallel() - h := newHarness(t, WithMaxSessions(1)) - first := createSession(t, h) - t.Cleanup(func() { - _ = h.manager.Stop(testutil.Context(t), first.ID) - }) - - _, err := h.manager.Create(testutil.Context(t), CreateOpts{ - AgentName: "coder", - Workspace: h.workspaceID, - }) - if err == nil { - t.Fatal("Create(second) error = nil, want non-nil") + h := newHarness(t) + const total = 12 + for range total { + session := createSession(t, h) + t.Cleanup(func() { + if err := h.manager.Stop(testutil.Context(t), session.ID); err != nil && + !errors.Is(err, ErrSessionNotFound) { + t.Errorf("Stop(%q) error = %v", session.ID, err) + } + }) } - if !errors.Is(err, ErrMaxSessionsReached) { - t.Fatalf("Create(second) error = %v, want ErrMaxSessionsReached", err) + if list := h.manager.List(); len(list) != total { + t.Fatalf("List() = %d sessions, want %d", len(list), total) } } diff --git a/internal/session/prompt_activity.go b/internal/session/prompt_activity.go index 6dd1e612c..45c02410a 100644 --- a/internal/session/prompt_activity.go +++ b/internal/session/prompt_activity.go @@ -133,6 +133,10 @@ func (s *promptActivitySupervisor) report(report acp.PromptActivityReport) { if kind == "" { kind = runtimeActivityKindAgentWaiting } + if kind == runtimeActivityKindAgentWaiting { + s.recordWaitingHeartbeat(ts, report.Detail) + return + } s.touch(ts, kind, report.Detail) } @@ -396,6 +400,49 @@ func (s *promptActivitySupervisor) touch(now time.Time, kind string, detail stri s.touchWithTool(now, kind, detail, "", "", false) } +func (s *promptActivitySupervisor) recordWaitingHeartbeat(now time.Time, detail string) { + if s == nil || s.manager == nil || s.session == nil { + return + } + if now.IsZero() { + now = s.now() + } + processUnhealthy := s.handleUnhealthyProcess(now, true) + if processUnhealthy { + return + } + + s.mu.Lock() + s.unhealthy = false + s.unhealthyWarned = false + s.activity.TurnID = s.turnID + s.activity.TurnSource = string(s.turnSource) + s.activity.TurnStartedAt = timePtr(s.startedAt) + if s.activity.LastActivityAt == nil || s.activity.LastActivityAt.IsZero() { + startedAt := s.startedAt.UTC() + s.activity.LastActivityAt = &startedAt + } + s.activity.LastActivityKind = runtimeActivityKindAgentWaiting + s.activity.LastActivityDetail = strings.TrimSpace(detail) + s.activity.IdleSeconds = store.SessionActivityIdleSeconds(&s.activity, now) + activity := *store.CloneSessionActivityMeta(&s.activity) + lastActivityAt := time.Time{} + if s.activity.LastActivityAt != nil { + lastActivityAt = s.activity.LastActivityAt.UTC() + } + s.mu.Unlock() + + s.session.observeRuntimeActivity(activity, now) + if err := s.manager.writeMeta(s.session); err != nil { + s.manager.sessionLogger(s.session). + Warn("session: persist runtime heartbeat failed", "turn_id", s.turnID, "error", err) + } + if _, err := s.manager.persistSessionPromptActivity(s.ctx, s.session, lastActivityAt); err != nil { + s.manager.sessionLogger(s.session). + Warn("session: persist runtime heartbeat health failed", "turn_id", s.turnID, "error", err) + } +} + func (s *promptActivitySupervisor) touchWithTool( now time.Time, kind string, diff --git a/internal/session/prompt_activity_test.go b/internal/session/prompt_activity_test.go index ad8ab4548..1c669f4ec 100644 --- a/internal/session/prompt_activity_test.go +++ b/internal/session/prompt_activity_test.go @@ -32,6 +32,7 @@ func TestPromptActivitySupervisorReportPersistsHeartbeatWithoutEvent(t *testing. newPromptTurnDispatchState(session, "turn-activity", TurnSourceUser, "hello"), testSupervisionConfig(), ) + supervisor.touch(now, runtimeActivityKindPromptStarted, "prompt started") supervisor.report(acp.PromptActivityReport{ Timestamp: now.Add(5 * time.Second), Kind: "agent_waiting", @@ -49,8 +50,14 @@ func TestPromptActivitySupervisorReportPersistsHeartbeatWithoutEvent(t *testing. t.Fatalf("activity detail = %q, want %q", got, want) } if meta.Liveness.Activity.LastActivityAt == nil || - !meta.Liveness.Activity.LastActivityAt.Equal(now.Add(5*time.Second)) { - t.Fatalf("activity LastActivityAt = %#v, want heartbeat timestamp", meta.Liveness.Activity.LastActivityAt) + !meta.Liveness.Activity.LastActivityAt.Equal(now) { + t.Fatalf( + "activity LastActivityAt = %#v, want last real activity timestamp", + meta.Liveness.Activity.LastActivityAt, + ) + } + if got, want := meta.Liveness.Activity.IdleSeconds, int64(5); got != want { + t.Fatalf("activity IdleSeconds = %d, want %d", got, want) } select { @@ -60,6 +67,43 @@ func TestPromptActivitySupervisorReportPersistsHeartbeatWithoutEvent(t *testing. } } +func TestPromptActivitySupervisorWaitingHeartbeatDoesNotPreventTimeout(t *testing.T) { + now := time.Date(2026, 4, 24, 12, 0, 0, 0, time.UTC) + h := newHarness(t, WithNow(func() time.Time { return now })) + session := createSession(t, h) + session.setCurrentTurnSource(TurnSourceUser) + session.setCurrentPromptMeta(acp.PromptMeta{TurnSource: acp.PromptTurnSourceUser}) + + config := testSupervisionConfig() + config.InactivityTimeout = time.Second + config.TimeoutCancelGrace = 200 * time.Millisecond + supervisor := newPromptActivitySupervisor( + testutil.Context(t), + h.manager, + session, + newPromptTurnDispatchState(session, "turn-heartbeat-timeout", TurnSourceUser, "hello"), + config, + ) + supervisor.touch(now, runtimeActivityKindPromptStarted, "prompt started") + supervisor.report(acp.PromptActivityReport{ + Timestamp: now.Add(500 * time.Millisecond), + Kind: runtimeActivityKindAgentWaiting, + Detail: "waiting for provider", + }) + supervisor.evaluate(now.Add(2 * time.Second)) + + if got := h.driver.cancelCalls; got != 1 { + t.Fatalf("driver cancel calls = %d, want 1", got) + } + if got := h.driver.stopCalls; got != 1 { + t.Fatalf("driver stop calls = %d, want 1", got) + } + meta := readMeta(t, session.MetaPath()) + if meta.StopReason == nil || *meta.StopReason != store.StopTimeout { + t.Fatalf("meta.StopReason = %#v, want %q", meta.StopReason, store.StopTimeout) + } +} + func TestPromptActivitySupervisorProgressIsPersistedThroughPromptPump(t *testing.T) { h := newHarness(t, WithSessionSupervision(aghconfig.SessionSupervisionConfig{ diff --git a/internal/settings/sections.go b/internal/settings/sections.go index 693ff7e41..0dfd69193 100644 --- a/internal/settings/sections.go +++ b/internal/settings/sections.go @@ -749,9 +749,6 @@ func diffGeneralSettings(cfg *aghconfig.Config, desired GeneralSettings) []strin if cfg.Defaults.Sandbox != desired.Defaults.Sandbox { changed = append(changed, "defaults.sandbox") } - if cfg.Limits.MaxSessions != desired.Limits.MaxSessions { - changed = append(changed, "limits.max_sessions") - } if cfg.Limits.MaxConcurrentAgents != desired.Limits.MaxConcurrentAgents { changed = append(changed, "limits.max_concurrent_agents") } @@ -921,7 +918,6 @@ func applyGeneralSettings(editor *aghconfig.OverlayEditor, settings GeneralSetti {path: []string{"defaults", "agent"}, value: settings.Defaults.Agent}, {path: []string{"defaults", "provider"}, value: settings.Defaults.Provider}, {path: []string{"defaults", "sandbox"}, value: settings.Defaults.Sandbox}, - {path: []string{"limits", "max_sessions"}, value: settings.Limits.MaxSessions}, {path: []string{"limits", "max_concurrent_agents"}, value: settings.Limits.MaxConcurrentAgents}, {path: []string{"session", "limits", "timeout"}, value: settings.SessionTimeout.String()}, {path: []string{"permissions", "mode"}, value: string(settings.Permissions.Mode)}, diff --git a/internal/settings/service_test.go b/internal/settings/service_test.go index 106ba3730..79d3936a2 100644 --- a/internal/settings/service_test.go +++ b/internal/settings/service_test.go @@ -435,7 +435,6 @@ func TestUpdateSectionGeneralReturnsRestartRequired(t *testing.T) { Sandbox: "dev", }, Limits: aghconfig.LimitsConfig{ - MaxSessions: 7, MaxConcurrentAgents: 11, }, Permissions: aghconfig.PermissionsConfig{Mode: aghconfig.PermissionModeApproveReads}, @@ -1602,7 +1601,6 @@ func TestUpdateSectionNoChangesReturnsWarning(t *testing.T) { Sandbox: "dev", }, Limits: aghconfig.LimitsConfig{ - MaxSessions: 7, MaxConcurrentAgents: 11, }, Permissions: aghconfig.PermissionsConfig{Mode: aghconfig.PermissionModeApproveReads}, @@ -1965,8 +1963,7 @@ func TestSettingsMutationsEmitObserveEvents(t *testing.T) { General: &GeneralSettings{ Defaults: cfg.Defaults, Limits: aghconfig.LimitsConfig{ - MaxSessions: cfg.Limits.MaxSessions + 1, - MaxConcurrentAgents: cfg.Limits.MaxConcurrentAgents, + MaxConcurrentAgents: cfg.Limits.MaxConcurrentAgents + 1, }, Permissions: cfg.Permissions, SessionTimeout: cfg.Session.Limits.Timeout, @@ -2066,7 +2063,6 @@ provider = "codex" sandbox = "dev" [limits] -max_sessions = 7 max_concurrent_agents = 11 [session.limits] diff --git a/internal/transcript/transcript_test.go b/internal/transcript/transcript_test.go index 969323cf9..0ef0b5137 100644 --- a/internal/transcript/transcript_test.go +++ b/internal/transcript/transcript_test.go @@ -803,6 +803,79 @@ func TestToUIMessagesPermissionDataParts(t *testing.T) { } func TestToUIMessagesOrderedAssistantParts(t *testing.T) { + t.Run("ShouldPreserveFatalPromptErrorAsDataPart", func(t *testing.T) { + t.Parallel() + + timestamp := time.Date(2026, 5, 14, 15, 32, 0, 0, time.UTC) + errorText := `{"code":-32603,"message":"Internal error","data":{"error":"peer disconnected before response"}}` + events := []store.SessionEvent{ + mustUIAgentSessionEvent(t, "ev-text", 1, timestamp, acp.AgentEvent{ + Type: acp.EventTypeAgentMessage, + SessionID: "sess-failed", + TurnID: "turn-failed", + Timestamp: timestamp, + Text: "partial response", + }), + mustUIAgentSessionEvent(t, "ev-error", 2, timestamp.Add(time.Second), acp.AgentEvent{ + Type: acp.EventTypeError, + SessionID: "sess-failed", + TurnID: "turn-failed", + Timestamp: timestamp.Add(time.Second), + Error: errorText, + Failure: &store.SessionFailure{ + Kind: store.FailureProcess, + Summary: "peer disconnected before response", + }, + }), + } + + messages, err := ToUIMessages(events) + if err != nil { + t.Fatalf("ToUIMessages() error = %v", err) + } + if got, want := len(messages), 2; got != want { + t.Fatalf("len(messages) = %d, want %d; messages=%#v", got, want, messages) + } + if got, want := len(messages[0].Parts), 1; got != want { + t.Fatalf("len(messages[0].Parts) = %d, want %d; parts=%#v", got, want, messages[0].Parts) + } + if got, want := messages[0].Parts[0].Type, uiPartText; got != want { + t.Fatalf("parts[0].Type = %q, want %q", got, want) + } + if got, want := messages[0].Parts[0].State, uiPartStateDone; got != want { + t.Fatalf("parts[0].State = %q, want %q", got, want) + } + + if got, want := messages[1].ID, "ev-error"; got != want { + t.Fatalf("messages[1].ID = %q, want %q", got, want) + } + if got, want := len(messages[1].Parts), 1; got != want { + t.Fatalf("len(messages[1].Parts) = %d, want %d; parts=%#v", got, want, messages[1].Parts) + } + + errorPart := messages[1].Parts[0] + if got, want := errorPart.Type, uiPartDataEvent; got != want { + t.Fatalf("parts[1].Type = %q, want %q", got, want) + } + + var payload UIAgentEventPayload + if err := json.Unmarshal(errorPart.Data, &payload); err != nil { + t.Fatalf("json.Unmarshal(errorPart.Data) error = %v", err) + } + if got, want := payload.Type, acp.EventTypeError; got != want { + t.Fatalf("payload.Type = %q, want %q", got, want) + } + if got, want := payload.Error, errorText; got != want { + t.Fatalf("payload.Error = %q, want %q", got, want) + } + if payload.Failure == nil { + t.Fatal("payload.Failure = nil, want process failure") + } + if got, want := payload.Failure.Kind, store.FailureProcess; got != want { + t.Fatalf("payload.Failure.Kind = %q, want %q", got, want) + } + }) + t.Run("ShouldPreserveTextToolTextOrderInsideOneAssistantMessage", func(t *testing.T) { t.Parallel() diff --git a/internal/transcript/ui_messages.go b/internal/transcript/ui_messages.go index d5c9a3d51..1b248dda9 100644 --- a/internal/transcript/ui_messages.go +++ b/internal/transcript/ui_messages.go @@ -275,7 +275,10 @@ func applyDecodedEvent(builder *uiMessageBuilder, decoded *decodedStoredEvent) { builder.applyToolResult(decoded) case acp.EventTypePermission: builder.appendDataPart(uiPartDataPermission, uiPermissionDataPartID(decoded.agent), decoded.dataPayload()) - case acp.EventTypeDone, acp.EventTypeError: + case acp.EventTypeError: + builder.appendDataPart(uiPartDataEvent, "", decoded.dataPayload()) + builder.finished = true + case acp.EventTypeDone: builder.finished = true default: builder.appendDataPart(uiPartDataEvent, "", decoded.dataPayload()) @@ -634,6 +637,13 @@ func inputMessageID(decoded *decodedStoredEvent, role string) string { } func assistantMessageID(decoded *decodedStoredEvent) string { + if decoded != nil && decoded.parsed.Type == acp.EventTypeError { + return fallbackMessageID( + strings.TrimSpace(decoded.stored.ID), + strings.TrimSpace(decoded.parsed.TurnID), + "assistant-error", + ) + } return fallbackMessageID( strings.TrimSpace(decoded.parsed.TurnID), strings.TrimSpace(decoded.stored.ID), diff --git a/openapi/agh.json b/openapi/agh.json index 25852a945..d791a710f 100644 --- a/openapi/agh.json +++ b/openapi/agh.json @@ -40680,15 +40680,9 @@ "properties": { "max_concurrent_agents": { "type": "integer" - }, - "max_sessions": { - "type": "integer" } }, - "required": [ - "max_concurrent_agents", - "max_sessions" - ], + "required": ["max_concurrent_agents"], "type": "object" }, "permissions": { @@ -40902,15 +40896,9 @@ "properties": { "max_concurrent_agents": { "type": "integer" - }, - "max_sessions": { - "type": "integer" } }, - "required": [ - "max_concurrent_agents", - "max_sessions" - ], + "required": ["max_concurrent_agents"], "type": "object" }, "permissions": { diff --git a/packages/site/content/runtime/core/configuration/config-toml.mdx b/packages/site/content/runtime/core/configuration/config-toml.mdx index be695352c..7e5c9e8b6 100644 --- a/packages/site/content/runtime/core/configuration/config-toml.mdx +++ b/packages/site/content/runtime/core/configuration/config-toml.mdx @@ -29,7 +29,7 @@ Use only `[sandboxes.]` for session execution boundaries. | `[daemon]` | Unix domain socket path for CLI and UDS API traffic. | `socket = "$AGH_HOME/daemon.sock"` | | `[http]` | HTTP and SSE bind address. | `host = "localhost"`, `port = 2123` | | `[defaults]` | Default agent, provider, and sandbox resolution. | `agent = "general"`, `provider = ""`, `sandbox = ""` | -| `[limits]` | Daemon-level session and agent caps. | `max_sessions = 10`, `max_concurrent_agents = 20` | +| `[limits]` | Daemon-level concurrently running agent cap. | `max_concurrent_agents = 20` | | `[session.limits]` | Session-scoped wall-clock timeout. | `timeout = "0s"` | | `[session.supervision]` | Runtime activity heartbeat, progress, warning, and inactivity timeout controls. | heartbeat 30 seconds, progress 10 minutes, warning 15 minutes, timeout 30 minutes | | `[agents.soul]` | Optional `SOUL.md` parsing, body limits, and compact projection budget. | enabled, 32 KiB body, 2 KiB compact projection | @@ -112,8 +112,7 @@ provider = "claude" sandbox = "local" [limits] -# Positive daemon-wide limits. -max_sessions = 10 +# Positive daemon-wide agent concurrency bound. max_concurrent_agents = 20 [session.limits] @@ -550,10 +549,9 @@ max_queue_depth = 100 ## `[limits]` -| Field | Type | Default | Valid values | Description | -| ----------------------- | ------- | ------- | ----------------- | -------------------------------------------------------------- | -| `max_sessions` | integer | `10` | Positive integer. | Daemon-wide cap for sessions retained in active runtime state. | -| `max_concurrent_agents` | integer | `20` | Positive integer. | Daemon-wide cap for concurrently running agent subprocesses. | +| Field | Type | Default | Valid values | Description | +| ----------------------- | ------- | ------- | ----------------- | ------------------------------------------------------------ | +| `max_concurrent_agents` | integer | `20` | Positive integer. | Daemon-wide cap for concurrently running agent subprocesses. | ## `[session.limits]` diff --git a/packages/site/content/runtime/core/operations/production.mdx b/packages/site/content/runtime/core/operations/production.mdx index 48cc13fde..2dfcbc8dc 100644 --- a/packages/site/content/runtime/core/operations/production.mdx +++ b/packages/site/content/runtime/core/operations/production.mdx @@ -56,7 +56,6 @@ port = 2123 level = "info" [limits] -max_sessions = 10 max_concurrent_agents = 20 ``` @@ -65,7 +64,7 @@ max_concurrent_agents = 20 | HTTP bind | `[http].host` is `localhost` unless AGH is intentionally protected by a reverse proxy or host firewall. | | UDS path | `[daemon].socket` is inside a directory owned by the daemon user. | | Log level | `[log].level` is `info` or `warn` for unattended operation; use `debug` only for short investigations. | -| Limits | Session and agent concurrency limits match the host capacity. | +| Limits | Agent concurrency limits and host resource expectations match the host capacity. | | Provider auth | Native CLI providers are logged in for the service user, and `bound_secret` providers have resolvable `env:` or `vault:` credentials. | ## 3. Run under a service manager diff --git a/packages/site/content/runtime/core/workspaces/config-overlays.mdx b/packages/site/content/runtime/core/workspaces/config-overlays.mdx index 0f0708789..90651c7fa 100644 --- a/packages/site/content/runtime/core/workspaces/config-overlays.mdx +++ b/packages/site/content/runtime/core/workspaces/config-overlays.mdx @@ -51,7 +51,7 @@ provider = "claude" mode = "approve-reads" [limits] -max_sessions = 4 +max_concurrent_agents = 12 [skills] disabled_skills = ["public-cloud-deploy"] @@ -178,7 +178,7 @@ provider = "codex" mode = "approve-all" [limits] -max_sessions = 10 +max_concurrent_agents = 20 [skills] disabled_skills = ["legacy-notes"] @@ -200,13 +200,13 @@ disabled_skills = ["deploy-production", "legacy-notes"] Effective result for the workspace: -| Setting | Final value | Why | -| ------------------------ | --------------------------------------- | ----------------------------------------------- | -| `defaults.agent` | `reviewer` | Workspace scalar replaces global scalar. | -| `defaults.provider` | `codex` | Workspace omitted it, so global remains. | -| `permissions.mode` | `approve-reads` | Workspace scalar replaces global scalar. | -| `limits.max_sessions` | `10` | Workspace omitted the table, so global remains. | -| `skills.disabled_skills` | `["deploy-production", "legacy-notes"]` | Workspace slice replaces the global slice. | +| Setting | Final value | Why | +| ------------------------------ | --------------------------------------- | ----------------------------------------------- | +| `defaults.agent` | `reviewer` | Workspace scalar replaces global scalar. | +| `defaults.provider` | `codex` | Workspace omitted it, so global remains. | +| `permissions.mode` | `approve-reads` | Workspace scalar replaces global scalar. | +| `limits.max_concurrent_agents` | `20` | Workspace omitted the table, so global remains. | +| `skills.disabled_skills` | `["deploy-production", "legacy-notes"]` | Workspace slice replaces the global slice. | ## `.env` And `AGH_HOME` diff --git a/skills/agh/references/tasks-and-orchestration.md b/skills/agh/references/tasks-and-orchestration.md index 0d3e835c3..374e9f8dd 100644 --- a/skills/agh/references/tasks-and-orchestration.md +++ b/skills/agh/references/tasks-and-orchestration.md @@ -23,10 +23,14 @@ Use this guidance only inside a daemon-managed coordinator session. 1. Read agh me context or the provided task context bundle first. 2. Identify task id, run id, workflow id, execution profile, review policy, coordination channel, and latest events. 3. Break the objective into bounded worker prompts with acceptance criteria. -4. Spawn or route only within daemon permissions and configured execution profile. -5. Watch persisted task/run state rather than chat activity. -6. Request or route reviews through the daemon review path. -7. On rejection, continue from persisted missing_work and next_round_guidance. +4. Create child tasks only when durable task intent is needed. Creation alone is not execution. +5. When the objective requires work to begin now, start each executable task through the task start path so AGH enqueues a run and can route matching worker agents. +6. Spawn or route only within daemon permissions and configured execution profile. +7. Watch persisted task/run state rather than chat activity. +8. Request or route reviews through the daemon review path. +9. On rejection, continue from persisted missing_work and next_round_guidance. + +Do not leave ready tasks idle after telling the operator that work has been orchestrated. Either start the task runs or report that the tasks were created but not started. Never spawn another coordinator unless the runtime explicitly supports that delegation. Never use channel messages as task ownership state. diff --git a/skills/agh/references/tools-and-skills.md b/skills/agh/references/tools-and-skills.md index 0dc7850e4..d0105384a 100644 --- a/skills/agh/references/tools-and-skills.md +++ b/skills/agh/references/tools-and-skills.md @@ -33,9 +33,11 @@ For skills, search with agh**skill_search and load full instructions with agh**s The prompt catalog lists skill names and descriptions, not full bodies. Load the full body on demand: agh skill view agh - agh skill view agh --file references/network.md Inside a tool-capable session, use the equivalent skill search/view tools. +For resource files inside daemon-managed AGH sessions, use the native skill view tool with the resource path instead of the CLI `--file` fallback. The CLI resource form is for local operator mode where skill resolution reads directly from the filesystem: + + agh skill view agh --file references/network.md ## Bundled Skill Resources diff --git a/web/src/generated/agh-openapi.d.ts b/web/src/generated/agh-openapi.d.ts index de0afd00f..8932f7cb5 100644 --- a/web/src/generated/agh-openapi.d.ts +++ b/web/src/generated/agh-openapi.d.ts @@ -21933,7 +21933,6 @@ export interface operations { }; limits: { max_concurrent_agents: number; - max_sessions: number; }; permissions: { /** @enum {string} */ @@ -22023,7 +22022,6 @@ export interface operations { }; limits: { max_concurrent_agents: number; - max_sessions: number; }; permissions: { /** @enum {string} */ diff --git a/web/src/hooks/routes/__tests__/use-app-layout.test.tsx b/web/src/hooks/routes/__tests__/use-app-layout.test.tsx index dab883487..c305af919 100644 --- a/web/src/hooks/routes/__tests__/use-app-layout.test.tsx +++ b/web/src/hooks/routes/__tests__/use-app-layout.test.tsx @@ -4,12 +4,14 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const { mockNavigate, mockMutateAsync, + mockSetActiveWorkspaceId, mockToastError, mockWorkspaceQuery, mockUseCreateSessionPending, } = vi.hoisted(() => ({ mockNavigate: vi.fn<(input: unknown) => Promise>(), mockMutateAsync: vi.fn<(input: unknown) => Promise<{ id: string; agent_name: string }>>(), + mockSetActiveWorkspaceId: vi.fn<(workspaceId: string | null) => void>(), mockToastError: vi.fn(), mockWorkspaceQuery: vi.fn(), mockUseCreateSessionPending: { current: false as boolean }, @@ -129,7 +131,7 @@ vi.mock("@/systems/workspace", () => ({ updated_at: "2026-04-20T10:00:00Z", }, activeWorkspaceId: mockActiveWorkspaceId, - setActiveWorkspaceId: vi.fn(), + setActiveWorkspaceId: mockSetActiveWorkspaceId, isLoading: false, isError: false, }), @@ -150,6 +152,7 @@ describe("useAppLayout", () => { mockAgentsError = false; mockNavigate.mockReset(); mockMutateAsync.mockReset(); + mockSetActiveWorkspaceId.mockReset(); mockToastError.mockReset(); mockWorkspaceQuery.mockReset(); mockUseCreateSessionPending.current = false; diff --git a/web/src/hooks/routes/__tests__/use-settings-general-page.test.tsx b/web/src/hooks/routes/__tests__/use-settings-general-page.test.tsx index 0e68ffd1b..af224b253 100644 --- a/web/src/hooks/routes/__tests__/use-settings-general-page.test.tsx +++ b/web/src/hooks/routes/__tests__/use-settings-general-page.test.tsx @@ -39,7 +39,7 @@ const envelope: SettingsGeneralSection = { daemon: { socket: "/tmp/agh.sock" }, defaults: { agent: "general", provider: "claude" }, http: { host: "127.0.0.1", port: 2123 }, - limits: { max_sessions: 10, max_concurrent_agents: 20 }, + limits: { max_concurrent_agents: 20 }, permissions: { mode: "approve-all" }, session_timeout: "0s", }, @@ -125,7 +125,7 @@ describe("useSettingsGeneralPage", () => { act(() => { result.current.setDraft({ ...envelope.config, - limits: { ...envelope.config.limits, max_sessions: 50 }, + limits: { ...envelope.config.limits, max_concurrent_agents: 50 }, }); result.current.handleSave(); }); diff --git a/web/src/hooks/routes/__tests__/use-tasks-page.test.tsx b/web/src/hooks/routes/__tests__/use-tasks-page.test.tsx index 7e2a8926c..b84158347 100644 --- a/web/src/hooks/routes/__tests__/use-tasks-page.test.tsx +++ b/web/src/hooks/routes/__tests__/use-tasks-page.test.tsx @@ -150,6 +150,10 @@ describe("useTasksPage", () => { }); expect(result.current.mode).toBe("dashboard"); + expect(getTaskDashboard).toHaveBeenLastCalledWith( + expect.objectContaining({ scope: "workspace", workspace: "ws_alpha" }), + expect.any(AbortSignal) + ); expect(listTasks).not.toHaveBeenCalled(); expect(getTaskInbox).not.toHaveBeenCalled(); }); @@ -166,6 +170,10 @@ describe("useTasksPage", () => { }); expect(result.current.mode).toBe("inbox"); + expect(getTaskInbox).toHaveBeenLastCalledWith( + expect.objectContaining({ scope: "workspace", workspace: "ws_alpha" }), + expect.any(AbortSignal) + ); }); it("maps inbox unread + search state into the backend query (lane stays client-side)", async () => { diff --git a/web/src/hooks/routes/use-create-provider-focus-restore.ts b/web/src/hooks/routes/use-create-provider-focus-restore.ts new file mode 100644 index 000000000..9a4feefd1 --- /dev/null +++ b/web/src/hooks/routes/use-create-provider-focus-restore.ts @@ -0,0 +1,28 @@ +import { useEffect, useRef } from "react"; + +import type { ProviderInspectorState } from "./use-settings-providers-page"; + +type ProviderInspectorMode = ProviderInspectorState["mode"]; + +export function useCreateProviderFocusRestore(inspectorMode: ProviderInspectorMode) { + const createProviderButtonRef = useRef(null); + const inspectorOpen = inspectorMode !== "closed"; + const previousInspectorOpenRef = useRef(inspectorOpen); + const lastInspectorModeRef = useRef(inspectorMode); + + useEffect(() => { + if (inspectorOpen) { + lastInspectorModeRef.current = inspectorMode; + } + if ( + previousInspectorOpenRef.current && + !inspectorOpen && + lastInspectorModeRef.current === "create" + ) { + createProviderButtonRef.current?.focus(); + } + previousInspectorOpenRef.current = inspectorOpen; + }, [inspectorMode, inspectorOpen]); + + return createProviderButtonRef; +} diff --git a/web/src/hooks/routes/use-tasks-page.ts b/web/src/hooks/routes/use-tasks-page.ts index 42feb983f..9d4e8ef22 100644 --- a/web/src/hooks/routes/use-tasks-page.ts +++ b/web/src/hooks/routes/use-tasks-page.ts @@ -98,36 +98,38 @@ function useTasksPage(options: UseTasksPageOptions = {}) { const deferredSearchQuery = useDeferredValue(searchQuery); const deferredInboxQuery = useDeferredValue(inboxSearchQuery); const scopedWorkspace = workspaceFilterForActiveScope(scopeFilter, activeWorkspaceId); + const backendScope = + scopeFilter === "all" ? (scopedWorkspace ? "workspace" : undefined) : scopeFilter; const listFilters: TaskListFilter = useMemo( () => ({ - scope: scopeFilter === "all" ? undefined : scopeFilter, + scope: backendScope, workspace: scopedWorkspace, status: statusFilter ?? undefined, include_drafts: includeDrafts, owner_ref: ownerFilter ?? undefined, limit: 100, }), - [includeDrafts, ownerFilter, scopeFilter, scopedWorkspace, statusFilter] + [backendScope, includeDrafts, ownerFilter, scopedWorkspace, statusFilter] ); const dashboardFilters: TaskDashboardFilter = useMemo( () => ({ - scope: scopeFilter === "all" ? undefined : scopeFilter, + scope: backendScope, workspace: scopedWorkspace, }), - [scopeFilter, scopedWorkspace] + [backendScope, scopedWorkspace] ); const inboxFilters: TaskInboxFilter = useMemo( () => ({ - scope: scopeFilter === "all" ? undefined : scopeFilter, + scope: backendScope, workspace: scopedWorkspace, unread: inboxUnreadOnly ? true : undefined, query: deferredInboxQuery.trim() ? deferredInboxQuery.trim() : undefined, limit: 100, }), - [deferredInboxQuery, inboxUnreadOnly, scopeFilter, scopedWorkspace] + [backendScope, deferredInboxQuery, inboxUnreadOnly, scopedWorkspace] ); const isListTab = mode === "list" || mode === "kanban" || options.forceListData === true; diff --git a/web/src/routes/__tests__/-_app.test.tsx b/web/src/routes/__tests__/-_app.test.tsx index 2f4b8573d..b5981b082 100644 --- a/web/src/routes/__tests__/-_app.test.tsx +++ b/web/src/routes/__tests__/-_app.test.tsx @@ -59,8 +59,14 @@ vi.mock("@tanstack/react-router", () => ({ })); vi.mock("@/systems/runtime", () => ({ - AppSidebar: ({ onAddWorkspace }: { onAddWorkspace: () => void }) => ( - ), @@ -215,6 +221,19 @@ describe("AppLayout", () => { expect(screen.queryByTestId("app-route-motion")).not.toBeInTheDocument(); }); + it("uses the drawer shell column map before the sidebar drawer breakpoint", () => { + render(); + + expect(screen.getByTestId("app-grid")).toHaveClass("grid-cols-[56px_minmax(0,1fr)]"); + expect(screen.getByTestId("app-grid")).toHaveClass( + "min-[880px]:grid-cols-[56px_220px_minmax(0,1fr)]" + ); + expect(screen.getByTestId("app-sidebar")).toHaveClass("col-span-1"); + expect(screen.getByTestId("app-sidebar")).toHaveClass("min-[880px]:col-span-2"); + expect(screen.getByTestId("app-content")).toHaveClass("col-start-2"); + expect(screen.getByTestId("app-content")).toHaveClass("min-[880px]:col-start-3"); + }); + it("renders onboarding instead of the shell when no workspaces exist", () => { mockHasWorkspaces = false; mockActiveWorkspaceId = null; diff --git a/web/src/routes/_app.tsx b/web/src/routes/_app.tsx index 4155dd1fd..2d5aff000 100644 --- a/web/src/routes/_app.tsx +++ b/web/src/routes/_app.tsx @@ -42,10 +42,10 @@ function AppLayout() { >
diff --git a/web/src/routes/_app/settings/__tests__/-general.test.tsx b/web/src/routes/_app/settings/__tests__/-general.test.tsx index dd1100890..8c888f7ab 100644 --- a/web/src/routes/_app/settings/__tests__/-general.test.tsx +++ b/web/src/routes/_app/settings/__tests__/-general.test.tsx @@ -52,7 +52,7 @@ const envelope = { daemon: { socket: "/tmp/agh.sock" }, defaults: { agent: "general", provider: "claude", sandbox: "local" }, http: { host: "127.0.0.1", port: 2123 }, - limits: { max_sessions: 10, max_concurrent_agents: 20 }, + limits: { max_concurrent_agents: 20 }, permissions: { mode: "approve-all" as const }, session_timeout: "0s", }, diff --git a/web/src/routes/_app/settings/__tests__/-providers.test.tsx b/web/src/routes/_app/settings/__tests__/-providers.test.tsx index b99e5b592..0e63ee769 100644 --- a/web/src/routes/_app/settings/__tests__/-providers.test.tsx +++ b/web/src/routes/_app/settings/__tests__/-providers.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, screen } from "@testing-library/react"; +import { fireEvent, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import type { ReactNode } from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; @@ -328,6 +328,20 @@ describe("ProvidersSettingsPage", () => { expect(pageState.openCreate).toHaveBeenCalled(); }); + it("restores focus to the new-provider action after the create sheet closes", async () => { + pageState = makeState({ inspector: { mode: "create", draft: draftFor(builtinEntry) } }); + const { rerender } = render(); + const trigger = screen.getByTestId("settings-page-providers-create"); + + screen.getByTestId("provider-inspector-cancel").focus(); + expect(screen.getByTestId("provider-inspector-cancel")).toHaveFocus(); + + pageState = makeState(); + rerender(); + + await waitFor(() => expect(trigger).toHaveFocus()); + }); + it("renders each provider card with identity, summary, and state tone", () => { render(); expect(screen.getByTestId("settings-page-providers-card-claude")).toBeInTheDocument(); diff --git a/web/src/routes/_app/settings/general.tsx b/web/src/routes/_app/settings/general.tsx index cfc077f42..2ccb0dbce 100644 --- a/web/src/routes/_app/settings/general.tsx +++ b/web/src/routes/_app/settings/general.tsx @@ -185,10 +185,7 @@ function RuntimeSection({ envelope }: { envelope: SettingsGeneralSection }) { : `${envelope.config.http.host}:${envelope.config.http.port}` } /> - + { /> ); + expect( + screen.getByText("Use lowercase letters, numbers, underscores, or hyphens; e.g. coord_core.") + ).toBeInTheDocument(); + expect(screen.getByTestId("network-channel-name-input")).toHaveAttribute( + "placeholder", + "e.g. website_copy" + ); fireEvent.change(screen.getByTestId("network-channel-name-input"), { target: { value: "deployments" }, }); diff --git a/web/src/systems/network/components/network-create-channel-dialog.tsx b/web/src/systems/network/components/network-create-channel-dialog.tsx index ae47d88f2..009506ec8 100644 --- a/web/src/systems/network/components/network-create-channel-dialog.tsx +++ b/web/src/systems/network/components/network-create-channel-dialog.tsx @@ -76,14 +76,14 @@ export function NetworkCreateChannelDialog({ Channel name - Dot-notation encouraged; e.g. coord.core, ops.alerts. + Use lowercase letters, numbers, underscores, or hyphens; e.g. coord_core. onChannelNameChange(event.target.value)} - placeholder="e.g. deployments" + placeholder="e.g. website_copy" value={draft.channelName} /> diff --git a/web/src/systems/network/hooks/__tests__/use-network-route-shell.test.tsx b/web/src/systems/network/hooks/__tests__/use-network-route-shell.test.tsx new file mode 100644 index 000000000..d78b9a9da --- /dev/null +++ b/web/src/systems/network/hooks/__tests__/use-network-route-shell.test.tsx @@ -0,0 +1,104 @@ +// Suite: Network route shell workspace synchronization +// Invariant: A stale Network detail URL must not overwrite an explicit sidebar workspace switch. +// Boundary IN: useNetworkRouteShell workspace/route synchronization behavior. +// Boundary OUT: Sidebar click rendering and browser integration, covered by app layout tests and QA Playwright evidence. + +import { renderHook, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { mockNavigate, mockSetActiveWorkspaceId } = vi.hoisted(() => ({ + mockNavigate: vi.fn<(input: unknown) => Promise>(), + mockSetActiveWorkspaceId: vi.fn<(workspaceId: string | null) => void>(), +})); + +let mockActiveWorkspaceId: string | null = "ws_alpha"; +let mockSelectedWorkspaceId: string | null = "ws_alpha"; +let mockChildParams: { + workspaceId?: string; + channel?: string; + threadId?: string; + directId?: string; +} = { + workspaceId: "ws_alpha", + channel: "copy", +}; +let mockChildPathname = "/network/ws_alpha/copy/threads"; + +vi.mock("@tanstack/react-router", () => ({ + useChildMatches: () => [{ pathname: mockChildPathname }], + useNavigate: () => mockNavigate, + useParams: () => mockChildParams, +})); + +vi.mock("@/systems/workspace", () => ({ + useActiveWorkspace: () => ({ + activeWorkspaceId: mockActiveWorkspaceId, + selectedWorkspaceId: mockSelectedWorkspaceId, + setActiveWorkspaceId: mockSetActiveWorkspaceId, + }), +})); + +vi.mock("../use-last-read", () => ({ + useLastRead: () => ({ + lastReadAt: vi.fn(() => null), + }), +})); + +vi.mock("../use-network-page", () => ({ + useNetworkPage: () => ({ + channels: [ + { + channel: "copy", + last_activity_at: "2026-05-14T02:00:00Z", + }, + ], + firstVisibleChannel: { channel: "copy" }, + recents: [], + }), +})); + +import { useNetworkRouteShell } from "../use-network-route-shell"; + +describe("useNetworkRouteShell", () => { + beforeEach(() => { + mockActiveWorkspaceId = "ws_alpha"; + mockSelectedWorkspaceId = "ws_alpha"; + mockChildParams = { + workspaceId: "ws_alpha", + channel: "copy", + }; + mockChildPathname = "/network/ws_alpha/copy/threads"; + mockNavigate.mockReset(); + mockSetActiveWorkspaceId.mockReset(); + mockNavigate.mockResolvedValue(undefined); + }); + + it("uses the route workspace when opening a deep Network URL directly", async () => { + mockActiveWorkspaceId = "ws_beta"; + mockSelectedWorkspaceId = null; + mockChildParams = { + workspaceId: "ws_alpha", + channel: "copy", + }; + + renderHook(() => useNetworkRouteShell()); + + await waitFor(() => { + expect(mockSetActiveWorkspaceId).toHaveBeenCalledWith("ws_alpha"); + }); + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + it("does not let a stale detail route overwrite an explicit workspace switch", async () => { + const { rerender } = renderHook(() => useNetworkRouteShell()); + + mockActiveWorkspaceId = "ws_beta"; + mockSelectedWorkspaceId = "ws_beta"; + rerender(); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith({ to: "/network" }); + }); + expect(mockSetActiveWorkspaceId).not.toHaveBeenCalledWith("ws_alpha"); + }); +}); diff --git a/web/src/systems/network/hooks/use-network-route-shell.ts b/web/src/systems/network/hooks/use-network-route-shell.ts index d08eb497c..bb509faf9 100644 --- a/web/src/systems/network/hooks/use-network-route-shell.ts +++ b/web/src/systems/network/hooks/use-network-route-shell.ts @@ -36,7 +36,7 @@ export interface NetworkRouteShellResult { export function useNetworkRouteShell(): NetworkRouteShellResult { const page = useNetworkPage(); - const { activeWorkspaceId, setActiveWorkspaceId } = useActiveWorkspace(); + const { activeWorkspaceId, selectedWorkspaceId, setActiveWorkspaceId } = useActiveWorkspace(); const { lastReadAt } = useLastRead(); const navigate = useNavigate(); const childMatches = useChildMatches(); @@ -52,8 +52,18 @@ export function useNetworkRouteShell(): NetworkRouteShellResult { if (!childParams.workspaceId || childParams.workspaceId === activeWorkspaceId) { return; } + if (selectedWorkspaceId !== null && selectedWorkspaceId !== childParams.workspaceId) { + void navigate({ to: "/network" }); + return; + } setActiveWorkspaceId(childParams.workspaceId); - }, [activeWorkspaceId, childParams.workspaceId, setActiveWorkspaceId]); + }, [ + activeWorkspaceId, + childParams.workspaceId, + navigate, + selectedWorkspaceId, + setActiveWorkspaceId, + ]); useEffect(() => { if (childParams.workspaceId != null && childParams.channel != null) { diff --git a/web/src/systems/session/components/__tests__/runtime-activity-notice.test.tsx b/web/src/systems/session/components/__tests__/runtime-activity-notice.test.tsx index f0d6bfeba..c8b5dd603 100644 --- a/web/src/systems/session/components/__tests__/runtime-activity-notice.test.tsx +++ b/web/src/systems/session/components/__tests__/runtime-activity-notice.test.tsx @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import type { AgentEventPayload, RuntimeActivityPayload } from "../../types"; import { + isSessionErrorEvent, isRuntimeActivityEvent, RuntimeActivityNotice, SessionActivityInline, @@ -27,6 +28,23 @@ describe("RuntimeActivityNotice", () => { expect(isRuntimeActivityEvent({ type: "agent_message", runtime })).toBe(false); }); + it("recognizes fatal session errors when error or failure details exist", () => { + expect( + isSessionErrorEvent({ + type: "error", + error: '{"code":-32603,"message":"Internal error"}', + }) + ).toBe(true); + expect(isSessionErrorEvent({ type: "error", failure: { kind: "process_exit" } })).toBe(false); + expect( + isSessionErrorEvent({ + type: "error", + failure: { kind: "process_exit", summary: "peer disconnected before response" }, + }) + ).toBe(true); + expect(isSessionErrorEvent({ type: "runtime_warning", error: "failed" })).toBe(false); + }); + it("renders progress as a separate status notice", () => { const event: AgentEventPayload = { type: "runtime_progress", @@ -61,6 +79,29 @@ describe("RuntimeActivityNotice", () => { ); }); + it("renders session errors with alert semantics and failure detail", () => { + render( + + ); + + expect(screen.getByRole("alert")).toHaveAttribute("data-tone", "danger"); + expect(screen.getByTestId("session-error-notice")).toHaveTextContent("Session failed"); + expect(screen.getByTestId("session-error-meta")).toHaveTextContent("process_exit"); + expect(screen.getByTestId("session-error-detail")).toHaveTextContent( + "peer disconnected before response" + ); + }); + it("does not render non-runtime events", () => { render(); diff --git a/web/src/systems/session/components/__tests__/session-chat-runtime-provider.test.tsx b/web/src/systems/session/components/__tests__/session-chat-runtime-provider.test.tsx index cecc1584a..19d7dcef3 100644 --- a/web/src/systems/session/components/__tests__/session-chat-runtime-provider.test.tsx +++ b/web/src/systems/session/components/__tests__/session-chat-runtime-provider.test.tsx @@ -163,6 +163,47 @@ describe("SessionChatRuntimeProvider", () => { expect(screen.getByTestId("runtime-activity-detail")).toHaveTextContent("Using Bash"); }, 10_000); + it("renders persisted session error events as failure notices", async () => { + transcriptMessages = [ + ...sessionTranscriptFixture.slice(0, 1), + { + id: "transcript_error_001", + role: "assistant", + parts: [ + { + type: "text", + text: "Partial response before failure.", + state: "done", + }, + { + type: "data-agh-event", + data: { + type: "error", + error: + '{"code":-32603,"message":"Internal error","data":{"error":"peer disconnected before response"}}', + failure: { + kind: "process_exit", + summary: "peer disconnected before response", + }, + }, + }, + ], + }, + ]; + + renderSessionThread(); + + await waitFor(() => { + expect(screen.getByTestId("session-error-notice")).toBeInTheDocument(); + }); + + expect(screen.getByText("Partial response before failure.")).toBeInTheDocument(); + expect(screen.getByTestId("session-error-notice")).toHaveTextContent("Session failed"); + expect(screen.getByTestId("session-error-detail")).toHaveTextContent( + "peer disconnected before response" + ); + }, 10_000); + it("renders only unresolved permission events as interactive prompts", async () => { transcriptMessages = [ ...sessionTranscriptFixture.slice(0, 1), diff --git a/web/src/systems/session/components/__tests__/session-create-dialog.test.tsx b/web/src/systems/session/components/__tests__/session-create-dialog.test.tsx index 401ffd41b..944ae9c44 100644 --- a/web/src/systems/session/components/__tests__/session-create-dialog.test.tsx +++ b/web/src/systems/session/components/__tests__/session-create-dialog.test.tsx @@ -431,6 +431,23 @@ describe("SessionCreateDialog", () => { expect(screen.queryByTestId("session-create-providers-empty")).not.toBeInTheDocument(); }); + it("Should not render blank agent provider metadata for inherited providers", () => { + render( + + + + ); + + expect(screen.queryByTestId("session-create-agent-default")).not.toBeInTheDocument(); + expect(screen.getByTestId("session-create-provider-select")).toHaveTextContent("Codex"); + }); + it("Should block backdrop dismissal while submit is in flight", () => { const onOpenChange = vi.fn(); render(); diff --git a/web/src/systems/session/components/runtime-activity-notice.tsx b/web/src/systems/session/components/runtime-activity-notice.tsx index b1cec228e..87ba267ea 100644 --- a/web/src/systems/session/components/runtime-activity-notice.tsx +++ b/web/src/systems/session/components/runtime-activity-notice.tsx @@ -1,4 +1,4 @@ -import { Activity, AlertTriangle, Clock, Wrench } from "lucide-react"; +import { Activity, AlertCircle, AlertTriangle, Clock, Wrench } from "lucide-react"; import { Alert, AlertDescription, AlertMeta, AlertTitle, Pill, cn } from "@agh/ui"; @@ -10,6 +10,14 @@ export function isRuntimeActivityEvent(event: AgentEventPayload): boolean { return RUNTIME_EVENT_TYPES.has(event.type) && event.runtime !== undefined; } +export function isSessionErrorEvent(event: AgentEventPayload): boolean { + return event.type === "error" && (hasText(event.error) || hasText(event.failure?.summary)); +} + +function hasText(value: string | undefined): value is string { + return typeof value === "string" && value.trim().length > 0; +} + function formatDuration(seconds: number | undefined): string | null { if (typeof seconds !== "number" || !Number.isFinite(seconds) || seconds < 0) { return null; @@ -69,7 +77,72 @@ function activityMeta(activity: RuntimeActivityPayload | undefined): string | nu return null; } +function normalizeErrorText(error: string | undefined): string | null { + if (!hasText(error)) { + return null; + } + + const trimmed = error.trim(); + try { + const parsed: unknown = JSON.parse(trimmed); + if (typeof parsed === "object" && parsed !== null && "data" in parsed) { + const data = (parsed as { data?: unknown }).data; + if (typeof data === "object" && data !== null && "error" in data) { + const nested = (data as { error?: unknown }).error; + if (typeof nested === "string" && nested.trim().length > 0) { + return nested.trim(); + } + } + } + if (typeof parsed === "object" && parsed !== null && "message" in parsed) { + const message = (parsed as { message?: unknown }).message; + if (typeof message === "string" && message.trim().length > 0) { + return message.trim(); + } + } + } catch { + return trimmed; + } + + return trimmed; +} + +function sessionErrorDescription(event: AgentEventPayload): string { + return ( + normalizeErrorText(event.error) || + normalizeErrorText(event.failure?.summary) || + "The session stopped before completing this turn." + ); +} + export function RuntimeActivityNotice({ event }: { event: AgentEventPayload }) { + if (isSessionErrorEvent(event)) { + const failureKind = event.failure?.kind?.trim(); + + return ( + + + ); + } + if (!isRuntimeActivityEvent(event)) { return null; } diff --git a/web/src/systems/session/components/session-create-dialog.tsx b/web/src/systems/session/components/session-create-dialog.tsx index 81409b227..87f243f13 100644 --- a/web/src/systems/session/components/session-create-dialog.tsx +++ b/web/src/systems/session/components/session-create-dialog.tsx @@ -95,6 +95,7 @@ function SessionCreateDialog({ const activeAgent = workspaceSelected ? agents.find(agent => agent.name === trimmedSelectedAgentName) : undefined; + const activeAgentProvider = activeAgent?.provider.trim() ?? ""; const hasAgents = agents.length > 0; const hasProviderOptions = providerOptions.length > 0; const hasSelectedAgent = agents.some(agent => agent.name === trimmedSelectedAgentName); @@ -172,13 +173,13 @@ function SessionCreateDialog({ triggerTestId="session-create-agent-select" placeholder={agentPlaceholder} /> - {activeAgent ? ( + {activeAgent && activeAgentProvider.length > 0 ? (
- - Agent default provider: {activeAgent.provider} + + Agent default provider: {activeAgentProvider}
) : null} diff --git a/web/src/systems/session/components/stories/session-command-controls.stories.tsx b/web/src/systems/session/components/stories/session-command-controls.stories.tsx index e033a0716..0d6188c6f 100644 --- a/web/src/systems/session/components/stories/session-command-controls.stories.tsx +++ b/web/src/systems/session/components/stories/session-command-controls.stories.tsx @@ -61,6 +61,17 @@ const warningEvent: AgentEventPayload = { runtime: { ...runtimeActivity, current_tool: undefined, idle_seconds: 74 }, }; +const errorEvent: AgentEventPayload = { + type: "error", + error: + '{"code":-32603,"message":"Internal error","data":{"error":"peer disconnected before response"}}', + failure: { + kind: "process_exit", + summary: "peer disconnected before response", + }, + timestamp: "2026-05-14T15:32:02Z", +}; + const meta: Meta = { title: "systems/session/SessionCommandControls", component: ModelCommandSelect, @@ -130,8 +141,8 @@ export const ReasoningSelect: StoryObj = { }; /** - * RuntimeActivityNotice and SessionActivityInline expose progress and warning - * state without opening a live session. + * RuntimeActivityNotice and SessionActivityInline expose progress, warning, + * and failure states without opening a live session. */ export const RuntimeActivity: StoryObj = { args: {}, @@ -139,6 +150,7 @@ export const RuntimeActivity: StoryObj = {
+
), diff --git a/web/src/systems/settings/adapters/__tests__/settings-api.test.ts b/web/src/systems/settings/adapters/__tests__/settings-api.test.ts index bbcfb8287..40f155c84 100644 --- a/web/src/systems/settings/adapters/__tests__/settings-api.test.ts +++ b/web/src/systems/settings/adapters/__tests__/settings-api.test.ts @@ -42,7 +42,7 @@ const generalSectionFixture = { daemon: { socket: "/tmp/agh.sock" }, defaults: { agent: "claude-code" }, http: { host: "127.0.0.1", port: 2123 }, - limits: { max_concurrent_agents: 4, max_sessions: 16 }, + limits: { max_concurrent_agents: 4 }, permissions: { mode: "approve-reads" as const }, session_timeout: "30m", }, @@ -147,7 +147,7 @@ describe("section reads and updates", () => { daemon: { socket: "/tmp/next.sock" }, defaults: { agent: "claude-code" }, http: { host: "127.0.0.1", port: 2123 }, - limits: { max_concurrent_agents: 4, max_sessions: 16 }, + limits: { max_concurrent_agents: 4 }, permissions: { mode: "approve-reads" as const }, session_timeout: "45m", }, diff --git a/web/src/systems/settings/hooks/__tests__/use-settings-mutations.test.tsx b/web/src/systems/settings/hooks/__tests__/use-settings-mutations.test.tsx index f676ca5d0..760cc3dba 100644 --- a/web/src/systems/settings/hooks/__tests__/use-settings-mutations.test.tsx +++ b/web/src/systems/settings/hooks/__tests__/use-settings-mutations.test.tsx @@ -97,7 +97,7 @@ describe("useUpdateSettingsGeneral", () => { daemon: { socket: "/tmp/a.sock" }, defaults: { agent: "claude-code" }, http: { host: "127.0.0.1", port: 2123 }, - limits: { max_concurrent_agents: 4, max_sessions: 16 }, + limits: { max_concurrent_agents: 4 }, permissions: { mode: "approve-reads" as const }, session_timeout: "30m", }, diff --git a/web/src/systems/settings/mocks/fixtures.ts b/web/src/systems/settings/mocks/fixtures.ts index 9bb7febe9..90f133b83 100644 --- a/web/src/systems/settings/mocks/fixtures.ts +++ b/web/src/systems/settings/mocks/fixtures.ts @@ -33,7 +33,7 @@ export const settingsGeneralSectionFixture: SettingsGeneralSection = { daemon: { socket: "/tmp/agh.sock" }, defaults: { agent: storyAgentNames.product, provider: "claude", sandbox: "local" }, http: { host: "127.0.0.1", port: 2123 }, - limits: { max_sessions: 10, max_concurrent_agents: 20 }, + limits: { max_concurrent_agents: 20 }, permissions: { mode: "approve-all" }, session_timeout: "0s", }, diff --git a/web/src/systems/tasks/components/__tests__/task-editor-modal.test.tsx b/web/src/systems/tasks/components/__tests__/task-editor-modal.test.tsx index 448fe2e4e..d5bf0123c 100644 --- a/web/src/systems/tasks/components/__tests__/task-editor-modal.test.tsx +++ b/web/src/systems/tasks/components/__tests__/task-editor-modal.test.tsx @@ -138,21 +138,49 @@ describe("TaskEditorModal", () => { expect(draft.maxAttempts).toBe(1); }); - it("Should render the 6-kind owner enum matching the backend OwnerKind", () => { + it("Should render user-facing owner options while preserving backend values", () => { renderNewModal(); const select = screen.getByTestId("task-editor-owner-kind"); - const options = Array.from(select.querySelectorAll("option")).map(option => option.value); + const options = Array.from(select.querySelectorAll("option")).map(option => ({ + label: option.textContent, + value: option.value, + })); expect(options).toEqual([ - "", - "agent_session", - "human", - "automation", - "extension", - "network_peer", - "pool", + { label: "Unassigned", value: "" }, + { label: "Agent / pool", value: "pool" }, + { label: "Exact session", value: "agent_session" }, + { label: "Human", value: "human" }, + { label: "Automation", value: "automation" }, + { label: "Extension", value: "extension" }, + { label: "Network peer", value: "network_peer" }, ]); }); + it("Should explain that agent names use pool ownership instead of session ownership", () => { + const draft: TaskEditorDraft = { + ...createTaskEditorDraft("one_shot", "ws_alpha"), + ownerKind: "agent_session", + }; + + renderNewModal({ draft }); + + expect(screen.getByTestId("task-editor-owner-ref")).toHaveAttribute( + "placeholder", + "Session id (e.g. sess-...)" + ); + expect(screen.getByTestId("task-editor-owner-help")).toHaveTextContent( + "Use the exact session id. Agent names belong under Agent / pool." + ); + }); + + it("Should disable owner reference input until an owner kind is selected", () => { + renderNewModal(); + expect(screen.getByTestId("task-editor-owner-ref")).toBeDisabled(); + expect(screen.getByTestId("task-editor-owner-help")).toHaveTextContent( + "Leave ownership empty unless a specific agent, session, human, automation, extension, or peer owns the work." + ); + }); + it("Should invoke onOpenChange when Cancel is clicked", () => { const { onOpenChange } = renderNewModal(); fireEvent.click(screen.getByTestId("task-editor-modal-cancel")); diff --git a/web/src/systems/tasks/components/__tests__/tasks-detail-header.test.tsx b/web/src/systems/tasks/components/__tests__/tasks-detail-header.test.tsx index 4cd09e0d9..919e0c462 100644 --- a/web/src/systems/tasks/components/__tests__/tasks-detail-header.test.tsx +++ b/web/src/systems/tasks/components/__tests__/tasks-detail-header.test.tsx @@ -153,6 +153,21 @@ describe("TasksDetailHeader", () => { ); }); + it("hides the start-run action while an active run is open", () => { + const activeRun = { + id: "run_42", + task_id: "task_001", + attempt: 1, + status: "queued", + queued_at: "2026-04-11T09:30:00Z", + } as TaskListItem["active_run"]; + const detail = buildDetail({ status: "ready" }, { active_run: activeRun }); + + render( {}} />); + + expect(screen.queryByTestId("tasks-detail-enqueue")).not.toBeInTheDocument(); + }); + it("surfaces the coordination channel chip when the active run is bound to a channel", () => { const activeRun = { id: "run_42", diff --git a/web/src/systems/tasks/components/task-editor-modal.tsx b/web/src/systems/tasks/components/task-editor-modal.tsx index 478318d82..a82a90d1f 100644 --- a/web/src/systems/tasks/components/task-editor-modal.tsx +++ b/web/src/systems/tasks/components/task-editor-modal.tsx @@ -80,13 +80,54 @@ const PRIORITY_OPTIONS: PillGroupItem[] = [ { value: "urgent", label: "Urgent", testId: "task-editor-priority-urgent" }, ]; -const OWNER_KIND_OPTIONS: TaskOwnerKind[] = [ - "agent_session", - "human", - "automation", - "extension", - "network_peer", - "pool", +interface OwnerKindOption { + value: TaskOwnerKind; + label: string; + placeholder: string; + description: string; +} + +const UNASSIGNED_OWNER_DESCRIPTION = + "Leave ownership empty unless a specific agent, session, human, automation, extension, or peer owns the work."; + +const OWNER_KIND_OPTIONS: OwnerKindOption[] = [ + { + value: "pool", + label: "Agent / pool", + placeholder: "Agent name or pool id (e.g. landing_builder)", + description: + "Use an agent name or worker-pool id. Matching agent sessions can claim queued runs.", + }, + { + value: "agent_session", + label: "Exact session", + placeholder: "Session id (e.g. sess-...)", + description: "Use the exact session id. Agent names belong under Agent / pool.", + }, + { + value: "human", + label: "Human", + placeholder: "Human id or handle (e.g. pedro)", + description: "Use this when a human operator owns the task.", + }, + { + value: "automation", + label: "Automation", + placeholder: "Automation id", + description: "Use this when a daemon automation owns the task.", + }, + { + value: "extension", + label: "Extension", + placeholder: "Extension id", + description: "Use this when an installed extension owns the task.", + }, + { + value: "network_peer", + label: "Network peer", + placeholder: "Peer id", + description: "Use this when a Network peer owns the task.", + }, ]; const APPROVAL_OPTIONS: PillGroupItem<"none" | "manual">[] = [ @@ -149,6 +190,13 @@ function resolveSubmitLabel(mode: TaskEditorModalMode, template?: TaskTemplate): return "Save draft"; } +function resolveOwnerKindOption(kind: TaskOwnerKind | ""): OwnerKindOption | null { + if (!kind) { + return null; + } + return OWNER_KIND_OPTIONS.find(option => option.value === kind) ?? null; +} + /** * Task authoring modal — 720 px overlay covering `/tasks`. Switches anatomy * via `mode: "new" | "edit"`: @@ -300,6 +348,12 @@ function TaskEditorFormBody({ templateId, workspaceName, }: TaskEditorFormBodyProps) { + const ownerHelpId = useId(); + const ownerKindOption = resolveOwnerKindOption(draft.ownerKind); + const ownerDescription = ownerKindOption?.description ?? UNASSIGNED_OWNER_DESCRIPTION; + const ownerRefPlaceholder = ownerKindOption?.placeholder ?? "Select an owner kind first"; + const ownerRefDisabled = draft.ownerKind === ""; + return (
Unassigned - {OWNER_KIND_OPTIONS.map(kind => ( - - {kind} + {OWNER_KIND_OPTIONS.map(option => ( + + {option.label} ))} + + {ownerDescription} + diff --git a/web/src/systems/tasks/components/tasks-detail-header.tsx b/web/src/systems/tasks/components/tasks-detail-header.tsx index 35979386c..5d3f513de 100644 --- a/web/src/systems/tasks/components/tasks-detail-header.tsx +++ b/web/src/systems/tasks/components/tasks-detail-header.tsx @@ -71,6 +71,11 @@ export function TasksDetailHeader({ record.status === "ready" || record.status === "in_progress" || record.status === "blocked"; const signal = taskStatusSignal(record.status); const activeRun = detail.summary?.active_run ?? null; + const hasOpenRun = + activeRun?.status === "queued" || + activeRun?.status === "claimed" || + activeRun?.status === "starting" || + activeRun?.status === "running"; const lifecyclePhase = taskLifecyclePhase({ status: record.status, approval_state: record.approval_state, @@ -215,7 +220,7 @@ export function TasksDetailHeader({ {publishCopy.label} ) : null} - {!isDraft && onEnqueueRun ? ( + {!isDraft && !hasOpenRun && onEnqueueRun ? (