diff --git a/coderd/x/chatd/chatd_test.go b/coderd/x/chatd/chatd_test.go index 03ff68b34a..fabbada49f 100644 --- a/coderd/x/chatd/chatd_test.go +++ b/coderd/x/chatd/chatd_test.go @@ -12693,8 +12693,8 @@ func TestActiveServer_WorkspaceContextAndDynamicToolInjection(t *testing.T) { require.Equal(t, database.ChatStatusWaiting, chatResult.Status) parts := persistedChatParts(ctx, t, db, chat.ID) - require.Len(t, contextFilePartsForAgent(parts, dbAgent.ID), 1) - contextPart := contextFilePartsForAgent(parts, dbAgent.ID)[0] + require.Len(t, allContextFilePartsForAgent(parts, dbAgent.ID), 1) + contextPart := allContextFilePartsForAgent(parts, dbAgent.ID)[0] require.Equal(t, "/home/coder/project/AGENTS.md", contextPart.ContextFilePath) require.Equal(t, contextText, contextPart.ContextFileContent) require.Equal(t, "linux", contextPart.ContextFileOS) @@ -12785,7 +12785,7 @@ func TestActiveServer_WorkspaceContextAndDynamicToolInjection(t *testing.T) { require.Equal(t, database.ChatStatusWaiting, secondResult.Status) parts := persistedChatParts(ctx, t, db, chat.ID) - require.Len(t, contextFilePartsForAgent(parts, dbAgent.ID), 1) + require.Len(t, allContextFilePartsForAgent(parts, dbAgent.ID), 1) require.Equal(t, int32(1), contextConfigCalls.Load()) requestsMu.Lock() @@ -12796,6 +12796,72 @@ func TestActiveServer_WorkspaceContextAndDynamicToolInjection(t *testing.T) { require.True(t, requestHasSystemSubstring(recorded[len(recorded)-1], contextText)) }) + t.Run("commits marker when selected agent is unreachable", func(t *testing.T) { + t.Parallel() + + db, ps := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitLong) + + var ( + requestsMu sync.Mutex + requests []recordedOpenAIRequest + ) + openAIURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse { + if !req.Stream { + return chattest.OpenAINonStreamingResponse("title") + } + + requestsMu.Lock() + requests = append(requests, recordOpenAIRequest(req)) + requestsMu.Unlock() + + return chattest.OpenAIStreamingResponse( + chattest.OpenAITextChunks("done")..., + ) + }) + + user, org, model := seedChatDependenciesWithProvider(t, db, "openai-compat", openAIURL) + ws, dbAgent := seedWorkspaceWithAgent(t, db, user.ID) + require.NoError(t, db.SoftDeleteWorkspaceAgentsByWorkspaceID(ctx, ws.ID)) + + var agentDialCalls atomic.Int32 + server := newActiveTestServer(t, db, ps, func(cfg *chatd.Config) { + cfg.AgentConn = func(context.Context, uuid.UUID) (workspacesdk.AgentConn, func(), error) { + agentDialCalls.Add(1) + return nil, nil, xerrors.New("unexpected workspace agent dial") + } + }) + + chat, err := server.CreateChat(ctx, chatd.CreateOptions{ + OrganizationID: org.ID, + OwnerID: user.ID, + APIKeyID: testAPIKeyID(t, db, user.ID), + Title: "workspace-context-agent-unreachable", + ModelConfigID: model.ID, + WorkspaceID: uuid.NullUUID{UUID: ws.ID, Valid: true}, + AgentID: uuid.NullUUID{UUID: dbAgent.ID, Valid: true}, + InitialUserContent: []codersdk.ChatMessagePart{ + codersdk.ChatMessageText("Continue without workspace context."), + }, + }) + require.NoError(t, err) + + chatResult := waitForTerminalChat(ctx, t, db, chat.ID) + require.Equal(t, database.ChatStatusWaiting, chatResult.Status) + + parts := persistedChatParts(ctx, t, db, chat.ID) + markers := contextFileMarkersForAgent(parts, dbAgent.ID) + require.Len(t, markers, 1) + require.Empty(t, markers[0].ContextFileContent) + + requestsMu.Lock() + recorded := append([]recordedOpenAIRequest(nil), requests...) + requestsMu.Unlock() + require.Len(t, recorded, 1, "expected model call after marker commit") + require.False(t, requestHasSystemSubstring(recorded[0], "Source: ")) + require.Zero(t, agentDialCalls.Load()) + }) + t.Run("repersists workspace context after agent changes", func(t *testing.T) { t.Parallel() @@ -12892,8 +12958,8 @@ func TestActiveServer_WorkspaceContextAndDynamicToolInjection(t *testing.T) { require.Equal(t, database.ChatStatusWaiting, secondResult.Status) parts := persistedChatParts(ctx, t, db, chat.ID) - require.Len(t, contextFilePartsForAgent(parts, firstAgent.ID), 1) - require.Len(t, contextFilePartsForAgent(parts, secondAgent.ID), 1) + require.Len(t, allContextFilePartsForAgent(parts, firstAgent.ID), 1) + require.Len(t, allContextFilePartsForAgent(parts, secondAgent.ID), 1) requestsMu.Lock() recorded := append([]recordedOpenAIRequest(nil), requests...) @@ -12979,7 +13045,7 @@ func persistedChatMessages( return messages } -func contextFilePartsForAgent( +func allContextFilePartsForAgent( parts []codersdk.ChatMessagePart, agentID uuid.UUID, ) []codersdk.ChatMessagePart { @@ -12996,6 +13062,22 @@ func contextFilePartsForAgent( return matched } +func contextFileMarkersForAgent( + parts []codersdk.ChatMessagePart, + agentID uuid.UUID, +) []codersdk.ChatMessagePart { + var matched []codersdk.ChatMessagePart + for _, part := range parts { + if part.Type != codersdk.ChatMessagePartTypeContextFile || + !part.ContextFileAgentID.Valid || + part.ContextFileAgentID.UUID != agentID { + continue + } + matched = append(matched, part) + } + return matched +} + func requireChatToolPart( t *testing.T, messages []database.ChatMessage, diff --git a/coderd/x/chatd/generation.go b/coderd/x/chatd/generation.go index c3afe31faa..103ddb301b 100644 --- a/coderd/x/chatd/generation.go +++ b/coderd/x/chatd/generation.go @@ -800,9 +800,9 @@ func compactionModel(opts chatloop.GenerateCompactionOptions) string { // workspace context messages (e.g. AGENTS.md, workspace skills) into // chat history. It records a generation attempt, calls the injected // workspace context builder without holding the DB lock, then commits -// the returned messages fenced to the attempt. If the builder returns -// no messages, the action exits as expected and the next worker task -// re-reads the chat. +// the returned messages fenced to the attempt. If context cannot be +// fetched, it commits a marker for the selected agent so the generation +// loop can continue. func (s *taskStarter) persistWorkspaceContext( ctx context.Context, machine *chatstate.ChatMachine, @@ -831,13 +831,16 @@ func (s *taskStarter) persistWorkspaceContext( ActiveAPIKeyID: modelOpts.ActiveAPIKeyID, }) if err != nil { - if errors.Is(err, errWorkspaceContextUnavailable) { - // Builder reported nothing durable to commit (workspace or - // agent missing, unreachable, etc.). Exit the action without - // committing so the next worker task can re-read the chat. - return errTaskExpectedExit + s.opts.Logger.Warn(ctx, "failed to build workspace context, committing marker", + slog.F("chat_id", input.ChatID), + slog.F("worker_id", input.WorkerID), + slogError(err), + ) + marker, err := workspaceContextMarkerMessage(locked, modelOpts.ActiveAPIKeyID) + if err != nil { + return xerrors.Errorf("build workspace context marker: %w", err) } - return err + result.Messages = []chatstate.Message{marker} } return s.commitGenerationStep(ctx, machine, input, attempt, generationActionPersistWorkspaceContext, stepMessagesForCommit{ Messages: result.Messages, @@ -845,6 +848,28 @@ func (s *taskStarter) persistWorkspaceContext( }) } +// workspaceContextMarkerMessage builds an empty context-file sentinel +// for the chat's selected agent. Committing this marker lets the +// generation loop proceed when the agent is unreachable. +func workspaceContextMarkerMessage(chat database.Chat, activeAPIKeyID string) (chatstate.Message, error) { + content, err := chatprompt.MarshalParts([]codersdk.ChatMessagePart{{ + Type: codersdk.ChatMessagePartTypeContextFile, + ContextFileAgentID: chat.AgentID, + }}) + if err != nil { + return chatstate.Message{}, xerrors.Errorf("marshal workspace context marker: %w", err) + } + modelConfigID := chat.LastModelConfigID + return chatstate.Message{ + Role: database.ChatMessageRoleUser, + Content: content, + Visibility: database.ChatMessageVisibilityBoth, + ModelConfigID: uuid.NullUUID{UUID: modelConfigID, Valid: modelConfigID != uuid.Nil}, + ContentVersion: chatprompt.CurrentContentVersion, + APIKeyID: sql.NullString{String: activeAPIKeyID, Valid: activeAPIKeyID != ""}, + }, nil +} + func (s *taskStarter) beginGenerationAttempt( ctx context.Context, machine *chatstate.ChatMachine, diff --git a/coderd/x/chatd/workspace_context_builder.go b/coderd/x/chatd/workspace_context_builder.go index 9f2aac93a5..d27da4d78e 100644 --- a/coderd/x/chatd/workspace_context_builder.go +++ b/coderd/x/chatd/workspace_context_builder.go @@ -19,7 +19,7 @@ import ( // errWorkspaceContextUnavailable is returned by buildWorkspaceContext // when there is nothing safe to persist for the current committed // metadata, e.g. the chat has no bound workspace agent or the agent is -// no longer resolvable. Callers treat it as an expected exit. +// no longer resolvable. var errWorkspaceContextUnavailable = xerrors.New("workspace context unavailable") // buildWorkspaceContext fetches workspace context for the chat's @@ -54,8 +54,7 @@ func (server *Server) buildWorkspaceContext( defer wsCtx.close() parts, expectedAgentID := server.fetchContextForBuild(ctx, chat, &wsCtx, logger) - // If the workspace or agent is gone, fall back to no-op so the - // generation action exits without committing stale context. + // If the workspace or agent is gone, report unavailable. if expectedAgentID == uuid.Nil { return workspaceContextBuildResult{}, errWorkspaceContextUnavailable }