mirror of
https://github.com/coder/coder.git
synced 2026-10-25 09:42:55 +08:00
feat: integrate agent context snapshots into chats (#26389)
Makes the chat context foundation from #26385 live. That PR added the storage columns, writer queries, and a dormant `agentapi.ContextDirtyMarker` trigger with no production callers; this PR wires them together end to end. When a workspace agent pushes a context snapshot, bound chats now hydrate to that snapshot's hash, and a later push with a different hash flips already-pinned chats to dirty (emitting a `context_dirty` watch event after the transaction commits). Chat creation pins the agent's latest snapshot when one already exists. The experimental chat API exposes this as `Chat.Context` (`*ChatContext` with `dirty`, `dirty_since`, `error`), and a new `PUT /api/experimental/chats/{chat}/context` endpoint re-pins the agent's latest snapshot and clears the dirty marker. `context_dirty_resources` stays NULL (the resource-level diff is deferred to the UI phase) and the live per-turn context pull is unchanged. The end-to-end test provisions a workspace agent via the echo provisioner, connects it over the Agent API v2.10, and exercises the full path: an initial push hydrates a bound chat (clean), a second push with a different hash marks it dirty, the API reports the dirty state, and the refresh endpoint clears it. <details> <summary>Decision log</summary> - **API shape — sub-struct.** Dirty state is surfaced as `codersdk.Chat.Context *ChatContext { Dirty bool; DirtySince *time.Time; Error string }` rather than flat fields, matching the RFC's named `ChatContext` type and leaving room for future fields (resource diff, sources). `db2sdk.Chat` populates it when the chat is context-tracked (`len(ContextAggregateHash) > 0`), dirty, or carries a snapshot error, and leaves it nil (`omitempty`) otherwise. `Dirty` mirrors `context_dirty_since` being set. - **Marker wiring.** The chat daemon is injected directly as the `agentapi.ContextDirtyMarker`. It is unconditionally constructed (only its background worker is gated), so the marker is always non-nil and the wiring matches every other `api.chatDaemon` call site. `agentapi` still treats a nil marker as "chatd absent", so `PushContextState` stays a pure write path for any future caller that does not wire chatd in. - **Refresh is atomic.** `RefreshChatContext` reads the agent's latest snapshot and re-pins the chat in one repeatable-read transaction, so a concurrent push cannot land between the read and the write and leave the chat pinned to a stale hash with the dirty marker cleared. - **Hydrate + dirty run inside the push transaction.** The fan-out shares the push's transaction so a concurrent refresh cannot interleave with the version gate; `context_dirty` watch events publish only after commit. The pinned hash on dirtied chats is intentionally left unchanged — the refresh endpoint re-pins it. - **Dirtied chats keep their pinned hash.** Drift is advisory: a dirty chat stays usable, and refreshing is the only path that advances the pinned hash. - **Test binds `chats.agent_id` directly.** In production the binding is set lazily during a chat turn (`chatd.persistBuildAgentBinding`); the test sets it via `dbgen` so it exercises the context flow rather than turn resolution. Plan: `coderd/x/chatd` context integration + E2E (sub-struct API, create-time + push-time hydration, refresh endpoint; `context_dirty_resources` and the per-turn pull untouched). </details> 🤖 Generated by Coder Agents on behalf of @kylecarbs
This commit is contained in:
@@ -80,7 +80,7 @@ type ContextDirtyMarker interface {
|
||||
// already-pinned chats whose hash differs from aggregateHash. It
|
||||
// returns a callback that publishes the resulting dirty watch events;
|
||||
// the caller invokes it only after the transaction commits. The
|
||||
// callback is nil when nothing transitioned to dirty.
|
||||
// callback is a no-op when nothing transitioned to dirty.
|
||||
HydrateAndMarkChatsDirty(ctx context.Context, tx database.Store, agentID uuid.UUID, aggregateHash []byte, snapshotError string, now time.Time) (publishDirty func(), err error)
|
||||
}
|
||||
|
||||
|
||||
Generated
+66
-2
@@ -549,6 +549,42 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/experimental/chats/{chat}/context": {
|
||||
"put": {
|
||||
"description": "Experimental: this endpoint is subject to change.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Chats"
|
||||
],
|
||||
"summary": "Refresh chat context",
|
||||
"operationId": "refresh-chat-context",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"description": "Chat ID",
|
||||
"name": "chat",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.Chat"
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/experimental/chats/{chat}/diff": {
|
||||
"get": {
|
||||
"description": "Experimental: this endpoint is subject to change.",
|
||||
@@ -16450,6 +16486,14 @@ const docTemplate = `{
|
||||
"client_type": {
|
||||
"$ref": "#/definitions/codersdk.ChatClientType"
|
||||
},
|
||||
"context": {
|
||||
"description": "Context reports the chat's pinned workspace-context state and\nwhether it has drifted from the agent's latest pushed snapshot.\nNil when the chat has no pinned context yet.",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/codersdk.ChatContext"
|
||||
}
|
||||
]
|
||||
},
|
||||
"created_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
@@ -16605,6 +16649,24 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.ChatContext": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"dirty": {
|
||||
"description": "Dirty is true when the agent's latest snapshot hash differs from the\nchat's pinned hash.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"dirty_since": {
|
||||
"description": "DirtySince is when drift was first detected; nil when not dirty.",
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"error": {
|
||||
"description": "Error is the snapshot-level error copied from the pinned snapshot\n(empty when healthy).",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.ChatDiffContents": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -17544,7 +17606,8 @@ const docTemplate = `{
|
||||
"created",
|
||||
"deleted",
|
||||
"diff_status_change",
|
||||
"action_required"
|
||||
"action_required",
|
||||
"context_dirty"
|
||||
],
|
||||
"x-enum-varnames": [
|
||||
"ChatWatchEventKindStatusChange",
|
||||
@@ -17553,7 +17616,8 @@ const docTemplate = `{
|
||||
"ChatWatchEventKindCreated",
|
||||
"ChatWatchEventKindDeleted",
|
||||
"ChatWatchEventKindDiffStatusChange",
|
||||
"ChatWatchEventKindActionRequired"
|
||||
"ChatWatchEventKindActionRequired",
|
||||
"ChatWatchEventKindContextDirty"
|
||||
]
|
||||
},
|
||||
"codersdk.ConnectionLatency": {
|
||||
|
||||
Generated
+62
-2
@@ -482,6 +482,38 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/experimental/chats/{chat}/context": {
|
||||
"put": {
|
||||
"description": "Experimental: this endpoint is subject to change.",
|
||||
"produces": ["application/json"],
|
||||
"tags": ["Chats"],
|
||||
"summary": "Refresh chat context",
|
||||
"operationId": "refresh-chat-context",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"description": "Chat ID",
|
||||
"name": "chat",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.Chat"
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/experimental/chats/{chat}/diff": {
|
||||
"get": {
|
||||
"description": "Experimental: this endpoint is subject to change.",
|
||||
@@ -14764,6 +14796,14 @@
|
||||
"client_type": {
|
||||
"$ref": "#/definitions/codersdk.ChatClientType"
|
||||
},
|
||||
"context": {
|
||||
"description": "Context reports the chat's pinned workspace-context state and\nwhether it has drifted from the agent's latest pushed snapshot.\nNil when the chat has no pinned context yet.",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/codersdk.ChatContext"
|
||||
}
|
||||
]
|
||||
},
|
||||
"created_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
@@ -14907,6 +14947,24 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.ChatContext": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"dirty": {
|
||||
"description": "Dirty is true when the agent's latest snapshot hash differs from the\nchat's pinned hash.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"dirty_since": {
|
||||
"description": "DirtySince is when drift was first detected; nil when not dirty.",
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"error": {
|
||||
"description": "Error is the snapshot-level error copied from the pinned snapshot\n(empty when healthy).",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.ChatDiffContents": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -15816,7 +15874,8 @@
|
||||
"created",
|
||||
"deleted",
|
||||
"diff_status_change",
|
||||
"action_required"
|
||||
"action_required",
|
||||
"context_dirty"
|
||||
],
|
||||
"x-enum-varnames": [
|
||||
"ChatWatchEventKindStatusChange",
|
||||
@@ -15825,7 +15884,8 @@
|
||||
"ChatWatchEventKindCreated",
|
||||
"ChatWatchEventKindDeleted",
|
||||
"ChatWatchEventKindDiffStatusChange",
|
||||
"ChatWatchEventKindActionRequired"
|
||||
"ChatWatchEventKindActionRequired",
|
||||
"ChatWatchEventKindContextDirty"
|
||||
]
|
||||
},
|
||||
"codersdk.ConnectionLatency": {
|
||||
|
||||
@@ -1360,6 +1360,7 @@ func New(options *Options) *API {
|
||||
r.Post("/title/regenerate", api.regenerateChatTitle)
|
||||
r.Post("/title/propose", api.proposeChatTitle)
|
||||
r.Get("/diff", api.getChatDiffContents)
|
||||
r.Put("/context", api.refreshChatContext)
|
||||
r.Route("/queue/{queuedMessage}", func(r chi.Router) {
|
||||
r.Delete("/", api.deleteChatQueuedMessage)
|
||||
r.Post("/promote", api.promoteChatQueuedMessage)
|
||||
|
||||
@@ -1755,6 +1755,19 @@ func Chat(c database.Chat, diffStatus *database.ChatDiffStatus, files []database
|
||||
chat.LastInjectedContext = parts
|
||||
}
|
||||
}
|
||||
// Report pinned-context state when the chat is context-tracked
|
||||
// (has a pinned hash), dirty, or carries a snapshot error.
|
||||
if len(c.ContextAggregateHash) > 0 || c.ContextDirtySince.Valid || c.ContextError != "" {
|
||||
chatContext := &codersdk.ChatContext{
|
||||
Dirty: c.ContextDirtySince.Valid,
|
||||
Error: c.ContextError,
|
||||
}
|
||||
if c.ContextDirtySince.Valid {
|
||||
dirtySince := c.ContextDirtySince.Time
|
||||
chatContext.DirtySince = &dirtySince
|
||||
}
|
||||
chat.Context = chatContext
|
||||
}
|
||||
return chat
|
||||
}
|
||||
|
||||
|
||||
@@ -731,6 +731,11 @@ func TestChat_AllFieldsPopulated(t *testing.T) {
|
||||
RawMessage: json.RawMessage(`[{"name":"tool1","description":"test tool","inputSchema":{"type":"object"}}]`),
|
||||
Valid: true,
|
||||
},
|
||||
// Pinned-context columns drive codersdk.Chat.Context. Set all of
|
||||
// them so the converted sub-struct's fields are non-zero too.
|
||||
ContextAggregateHash: []byte{0x01, 0x02, 0x03},
|
||||
ContextDirtySince: sql.NullTime{Time: now, Valid: true},
|
||||
ContextError: "context boom",
|
||||
}
|
||||
// Only ChatID is needed here. This test checks that
|
||||
// Chat.DiffStatus is non-nil, not that every DiffStatus
|
||||
|
||||
@@ -2618,6 +2618,39 @@ func (api *API) applyChatTitleUpdate(
|
||||
return updatedChat, false
|
||||
}
|
||||
|
||||
// refreshChatContext re-pins a chat to its agent's latest context snapshot
|
||||
// and clears the dirty marker.
|
||||
//
|
||||
// @Summary Refresh chat context
|
||||
// @ID refresh-chat-context
|
||||
// @Security CoderSessionToken
|
||||
// @Tags Chats
|
||||
// @Produce json
|
||||
// @Param chat path string true "Chat ID" format(uuid)
|
||||
// @Success 200 {object} codersdk.Chat
|
||||
// @Router /api/experimental/chats/{chat}/context [put]
|
||||
// @Description Experimental: this endpoint is subject to change.
|
||||
func (api *API) refreshChatContext(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
chat := httpmw.ChatParam(r)
|
||||
|
||||
if !api.Authorize(r, policy.ActionUpdate, chat.RBACObject()) {
|
||||
httpapi.ResourceNotFound(rw)
|
||||
return
|
||||
}
|
||||
|
||||
updated, err := api.chatDaemon.RefreshChatContext(ctx, chat)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Internal error refreshing chat context.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
httpapi.Write(ctx, rw, http.StatusOK, db2sdk.Chat(updated, nil, nil))
|
||||
}
|
||||
|
||||
// patchChat updates a chat resource. Supports updating labels,
|
||||
// workspace binding, archiving, pinning, and pinned-chat ordering.
|
||||
//
|
||||
|
||||
@@ -180,6 +180,9 @@ func (api *API) workspaceAgentRPC(rw http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// Optional:
|
||||
UpdateAgentMetricsFn: api.UpdateAgentMetrics,
|
||||
// chatDaemon is always constructed (only its worker is gated), so
|
||||
// this is non-nil; agentapi treats a nil marker as "chatd absent".
|
||||
ContextDirtyMarker: api.chatDaemon,
|
||||
}, workspace, workspaceAgent)
|
||||
|
||||
streamID := tailnet.StreamID{
|
||||
|
||||
@@ -1457,6 +1457,11 @@ func (p *Server) CreateChat(ctx context.Context, opts CreateOptions) (database.C
|
||||
// committed and emitted its own state-machine notifications. The
|
||||
// watch endpoint is maintained separately from chatstate notifications.
|
||||
p.publishChatPubsubEvent(chat, codersdk.ChatWatchEventKindCreated, nil)
|
||||
|
||||
// Pin the chat to the agent's latest context snapshot if one exists.
|
||||
// Best-effort: a chat created before its agent has pushed is hydrated
|
||||
// by that agent's next push.
|
||||
p.hydrateChatContextOnCreate(ctx, chat)
|
||||
return chat, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
package chatd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog/v3"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
)
|
||||
|
||||
// latestAgentSnapshot looks up an agent's pinned context snapshot; ok is false
|
||||
// (with a nil error) when the agent has not pushed one yet.
|
||||
func latestAgentSnapshot(ctx context.Context, db database.Store, agentID uuid.UUID) (aggregateHash []byte, snapshotError string, ok bool, err error) {
|
||||
snapshot, err := db.GetLatestWorkspaceAgentContextSnapshot(ctx, agentID)
|
||||
switch {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
return nil, "", false, nil
|
||||
case err != nil:
|
||||
return nil, "", false, xerrors.Errorf("get latest snapshot: %w", err)
|
||||
default:
|
||||
return snapshot.AggregateHash, snapshot.SnapshotError, true, nil
|
||||
}
|
||||
}
|
||||
|
||||
// HydrateAndMarkChatsDirty implements agentapi.ContextDirtyMarker. It runs
|
||||
// inside the PushContextState transaction: it stamps the pushed snapshot hash
|
||||
// on chats for the agent that have not been hydrated yet (no dirty event),
|
||||
// then flips already-pinned chats whose hash differs to dirty. It returns a
|
||||
// callback that publishes the dirty watch events; the caller invokes it only
|
||||
// after the transaction commits, and the callback is a no-op when nothing
|
||||
// transitioned to dirty.
|
||||
//
|
||||
// The pinned hash on dirtied chats is intentionally left unchanged; the
|
||||
// refresh endpoint re-pins it.
|
||||
func (p *Server) HydrateAndMarkChatsDirty(ctx context.Context, tx database.Store, agentID uuid.UUID, aggregateHash []byte, snapshotError string, now time.Time) (func(), error) {
|
||||
//nolint:gocritic // An agent does not own the chats bound to it.
|
||||
ctx = dbauthz.AsChatd(ctx)
|
||||
|
||||
// Chats created before the agent's first push land with a NULL pinned
|
||||
// hash. Stamp them now so they start clean; this is their first
|
||||
// hydration, so no dirty event is emitted.
|
||||
if err := tx.HydrateAgentChatsContext(ctx, database.HydrateAgentChatsContextParams{
|
||||
AgentID: agentID,
|
||||
AggregateHash: aggregateHash,
|
||||
ContextError: snapshotError,
|
||||
}); err != nil {
|
||||
return nil, xerrors.Errorf("hydrate agent chats context: %w", err)
|
||||
}
|
||||
|
||||
dirtied, err := tx.MarkChatsContextDirtyByAgent(ctx, database.MarkChatsContextDirtyByAgentParams{
|
||||
AgentID: agentID,
|
||||
AggregateHash: aggregateHash,
|
||||
DirtySince: sql.NullTime{Time: now, Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("mark chats context dirty: %w", err)
|
||||
}
|
||||
if len(dirtied) == 0 {
|
||||
return func() {}, nil
|
||||
}
|
||||
|
||||
// Read the dirtied chats inside the transaction and capture their rows so
|
||||
// the post-commit callback needs no database access: the published payload
|
||||
// reflects the just-committed dirty state (no re-read a concurrent refresh
|
||||
// could race), and the callback does not depend on the request-scoped
|
||||
// context surviving past commit. Only the transitioned chats are read.
|
||||
dirtyChats := make([]database.Chat, 0, len(dirtied))
|
||||
for _, d := range dirtied {
|
||||
chat, err := tx.GetChatByID(ctx, d.ID)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("get dirtied chat %s: %w", d.ID, err)
|
||||
}
|
||||
dirtyChats = append(dirtyChats, chat)
|
||||
}
|
||||
|
||||
return func() {
|
||||
p.publishChatPubsubEvents(dirtyChats, codersdk.ChatWatchEventKindContextDirty)
|
||||
}, nil
|
||||
}
|
||||
|
||||
// hydrateChatContextOnCreate pins a newly created chat to its agent's latest
|
||||
// context snapshot when one already exists. Best-effort: a chat whose agent
|
||||
// has not pushed yet is hydrated later by that agent's next push. Failures
|
||||
// are logged and swallowed so they never block chat creation.
|
||||
//
|
||||
// A concurrent push that already hydrated the chat is not clobbered with a
|
||||
// stale hash.
|
||||
func (p *Server) hydrateChatContextOnCreate(ctx context.Context, chat database.Chat) {
|
||||
if !chat.AgentID.Valid {
|
||||
return
|
||||
}
|
||||
//nolint:gocritic // Chatd stamps chats it does not own as the daemon subject.
|
||||
ctx = dbauthz.AsChatd(ctx)
|
||||
|
||||
aggregateHash, snapshotError, ok, err := latestAgentSnapshot(ctx, p.db, chat.AgentID.UUID)
|
||||
if err != nil {
|
||||
p.logger.Warn(ctx, "hydrate chat context on create: get latest snapshot",
|
||||
slog.F("chat_id", chat.ID), slog.Error(err))
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := p.db.HydrateAgentChatsContext(ctx, database.HydrateAgentChatsContextParams{
|
||||
AgentID: chat.AgentID.UUID,
|
||||
AggregateHash: aggregateHash,
|
||||
ContextError: snapshotError,
|
||||
}); err != nil {
|
||||
p.logger.Warn(ctx, "hydrate chat context on create: stamp chats",
|
||||
slog.F("chat_id", chat.ID), slog.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// RefreshChatContext re-pins a chat to its agent's latest context snapshot and
|
||||
// clears the dirty marker. It backs PUT /chats/{chat}/context (no body). A
|
||||
// chat with no bound agent, or whose agent has no snapshot, simply has its
|
||||
// pinned hash and dirty marker cleared.
|
||||
//
|
||||
// The snapshot read and the re-pin run in one repeatable-read transaction so a
|
||||
// concurrent push cannot land between them and leave the chat pinned to a
|
||||
// stale hash with the dirty marker cleared.
|
||||
func (p *Server) RefreshChatContext(ctx context.Context, chat database.Chat) (database.Chat, error) {
|
||||
//nolint:gocritic // Chatd re-pins the chat as the daemon subject.
|
||||
ctx = dbauthz.AsChatd(ctx)
|
||||
|
||||
var updated database.Chat
|
||||
err := database.ReadModifyUpdate(p.db, func(tx database.Store) error {
|
||||
var (
|
||||
aggregateHash []byte
|
||||
snapshotError string
|
||||
)
|
||||
if chat.AgentID.Valid {
|
||||
hash, snapErr, ok, err := latestAgentSnapshot(ctx, tx, chat.AgentID.UUID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ok {
|
||||
aggregateHash = hash
|
||||
snapshotError = snapErr
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.SetChatContextSnapshot(ctx, database.SetChatContextSnapshotParams{
|
||||
ID: chat.ID,
|
||||
AggregateHash: aggregateHash,
|
||||
ContextError: snapshotError,
|
||||
}); err != nil {
|
||||
return xerrors.Errorf("set chat context snapshot: %w", err)
|
||||
}
|
||||
|
||||
got, err := tx.GetChatByID(ctx, chat.ID)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get chat after refresh: %w", err)
|
||||
}
|
||||
updated = got
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return database.Chat{}, err
|
||||
}
|
||||
return updated, nil
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package chatd
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/mock/gomock"
|
||||
|
||||
"cdr.dev/slog/v3/sloggers/slogtest"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/dbmock"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
|
||||
// TestHydrateChatContextOnCreate covers the create-time pinning path, which the
|
||||
// end-to-end test cannot reach: chats there are inserted directly, bypassing
|
||||
// CreateChat. It pins to the agent's latest snapshot via the NULL-guarded
|
||||
// HydrateAgentChatsContext so a concurrent push is never clobbered, and is a
|
||||
// best-effort no-op when there is no agent or no snapshot.
|
||||
func TestHydrateChatContextOnCreate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("PinsWhenSnapshotExists", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
ctrl := gomock.NewController(t)
|
||||
db := dbmock.NewMockStore(ctrl)
|
||||
server := &Server{db: db, logger: slogtest.Make(t, nil)}
|
||||
|
||||
agentID := uuid.New()
|
||||
chat := database.Chat{ID: uuid.New(), AgentID: uuid.NullUUID{UUID: agentID, Valid: true}}
|
||||
snapshot := database.WorkspaceAgentContextSnapshot{
|
||||
WorkspaceAgentID: agentID,
|
||||
AggregateHash: []byte{0x0a, 0x0b},
|
||||
SnapshotError: "one source failed",
|
||||
}
|
||||
|
||||
db.EXPECT().GetLatestWorkspaceAgentContextSnapshot(gomock.Any(), agentID).
|
||||
Return(snapshot, nil)
|
||||
// The guarded agent-scoped stamp, not an unconditional SetChatContextSnapshot,
|
||||
// so a concurrent push that already hydrated the chat wins.
|
||||
db.EXPECT().HydrateAgentChatsContext(gomock.Any(), database.HydrateAgentChatsContextParams{
|
||||
AgentID: agentID,
|
||||
AggregateHash: snapshot.AggregateHash,
|
||||
ContextError: snapshot.SnapshotError,
|
||||
}).Return(nil)
|
||||
|
||||
server.hydrateChatContextOnCreate(ctx, chat)
|
||||
})
|
||||
|
||||
t.Run("SkipsWhenAgentless", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
ctrl := gomock.NewController(t)
|
||||
// No EXPECT calls: a chat with no agent must touch the database zero times.
|
||||
db := dbmock.NewMockStore(ctrl)
|
||||
server := &Server{db: db, logger: slogtest.Make(t, nil)}
|
||||
|
||||
server.hydrateChatContextOnCreate(ctx, database.Chat{ID: uuid.New()})
|
||||
})
|
||||
|
||||
t.Run("SkipsWhenNoSnapshot", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
ctrl := gomock.NewController(t)
|
||||
db := dbmock.NewMockStore(ctrl)
|
||||
server := &Server{db: db, logger: slogtest.Make(t, nil)}
|
||||
|
||||
agentID := uuid.New()
|
||||
// ErrNoRows means the agent has not pushed yet; no stamp is written
|
||||
// (HydrateAgentChatsContext has no EXPECT, so a call would fail the test).
|
||||
db.EXPECT().GetLatestWorkspaceAgentContextSnapshot(gomock.Any(), agentID).
|
||||
Return(database.WorkspaceAgentContextSnapshot{}, sql.ErrNoRows)
|
||||
|
||||
server.hydrateChatContextOnCreate(ctx, database.Chat{
|
||||
ID: uuid.New(),
|
||||
AgentID: uuid.NullUUID{UUID: agentID, Valid: true},
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package chatd_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
agentproto "github.com/coder/coder/v2/agent/proto"
|
||||
"github.com/coder/coder/v2/coderd/coderdtest"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/dbgen"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/codersdk/agentsdk"
|
||||
"github.com/coder/coder/v2/provisioner/echo"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
|
||||
// TestChatContextDirtyFromAgentPush is an end-to-end check of the chat
|
||||
// context integration. An echo-provisioned workspace agent pushes a context
|
||||
// snapshot that hydrates a bound chat; a later push with a different hash
|
||||
// marks the chat dirty; the experimental API reports the dirty state and the
|
||||
// snapshot error; the refresh endpoint re-pins the latest snapshot and clears
|
||||
// it; and a re-push of the now-pinned hash stays clean. A second chat bound to
|
||||
// no agent stays untouched throughout, guarding the agent-scoped queries.
|
||||
func TestChatContextDirtyFromAgentPush(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{
|
||||
DeploymentValues: directChatRoutingDeploymentValues(t),
|
||||
IncludeProvisionerDaemon: true,
|
||||
})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
expClient := codersdk.NewExperimentalClient(client)
|
||||
|
||||
// Build a workspace with an agent via the echo provisioner.
|
||||
agentToken := uuid.NewString()
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
|
||||
Parse: echo.ParseComplete,
|
||||
ProvisionPlan: echo.PlanComplete,
|
||||
ProvisionApply: echo.ApplyComplete,
|
||||
ProvisionGraph: echo.ProvisionGraphWithAgent(agentToken),
|
||||
})
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
workspace := coderdtest.CreateWorkspace(t, client, template.ID)
|
||||
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
|
||||
|
||||
ws, err := client.Workspace(ctx, workspace.ID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, ws.LatestBuild.Resources, 1)
|
||||
require.Len(t, ws.LatestBuild.Resources[0].Agents, 1)
|
||||
agentID := ws.LatestBuild.Resources[0].Agents[0].ID
|
||||
|
||||
// A chat bound to the agent. In production agent_id is set lazily during
|
||||
// a workspace turn (chatd.persistBuildAgentBinding); bind it directly here
|
||||
// so the test exercises the context flow rather than turn resolution.
|
||||
// dbgen.ChatModelConfig provisions an AI provider as needed so the chat's
|
||||
// last_model_config_id foreign key is satisfied.
|
||||
model := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{})
|
||||
chat := dbgen.Chat(t, db, database.Chat{
|
||||
OrganizationID: user.OrganizationID,
|
||||
OwnerID: user.UserID,
|
||||
WorkspaceID: uuid.NullUUID{UUID: workspace.ID, Valid: true},
|
||||
AgentID: uuid.NullUUID{UUID: agentID, Valid: true},
|
||||
LastModelConfigID: model.ID,
|
||||
Status: database.ChatStatusWaiting,
|
||||
})
|
||||
|
||||
// An unrelated chat bound to no agent. The hydrate and dirty queries
|
||||
// scope by agent_id, so this chat must stay untouched by every push
|
||||
// below; it guards against the scoping clause silently breaking.
|
||||
otherChat := dbgen.Chat(t, db, database.Chat{
|
||||
OrganizationID: user.OrganizationID,
|
||||
OwnerID: user.UserID,
|
||||
LastModelConfigID: model.ID,
|
||||
Status: database.ChatStatusWaiting,
|
||||
})
|
||||
|
||||
// Before any push there is no pinned context.
|
||||
got, err := expClient.GetChat(ctx, chat.ID)
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, got.Context, "no pinned context before the first push")
|
||||
|
||||
requireChatContextNil := func(id uuid.UUID, msg string) {
|
||||
t.Helper()
|
||||
unrelated, err := expClient.GetChat(ctx, id)
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, unrelated.Context, msg)
|
||||
}
|
||||
requireChatContextNil(otherChat.ID, "agent-less chat has no pinned context")
|
||||
|
||||
// Connect as the agent and push the initial snapshot. The push runs the
|
||||
// hydrate/dirty fan-out synchronously inside its transaction, so the chat
|
||||
// reflects the change by the time the RPC returns.
|
||||
agentClient := agentsdk.New(client.URL, agentsdk.WithFixedToken(agentToken))
|
||||
aAPI, _, err := agentClient.ConnectRPC210(ctx)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = aAPI.DRPCConn().Close() }()
|
||||
|
||||
hashA := []byte{0x01, 0x02, 0x03}
|
||||
resp, err := aAPI.PushContextState(ctx, &agentproto.PushContextStateRequest{
|
||||
Version: 1,
|
||||
Initial: true,
|
||||
AggregateHash: hashA,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.True(t, resp.GetAccepted())
|
||||
|
||||
// The initial push hydrates the chat to a clean (not dirty) context.
|
||||
got, err = expClient.GetChat(ctx, chat.ID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, got.Context, "chat should be hydrated after the initial push")
|
||||
require.False(t, got.Context.Dirty, "initial hydration is clean")
|
||||
require.Nil(t, got.Context.DirtySince)
|
||||
|
||||
// The agent refreshes its context and pushes a different hash carrying a
|
||||
// snapshot-level error, which drifts from the pinned hash and marks the
|
||||
// chat dirty.
|
||||
hashB := []byte{0x04, 0x05, 0x06}
|
||||
const snapshotError = "two sources failed to resolve"
|
||||
resp, err = aAPI.PushContextState(ctx, &agentproto.PushContextStateRequest{
|
||||
Version: 2,
|
||||
AggregateHash: hashB,
|
||||
SnapshotError: snapshotError,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.True(t, resp.GetAccepted())
|
||||
|
||||
got, err = expClient.GetChat(ctx, chat.ID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, got.Context)
|
||||
require.True(t, got.Context.Dirty, "drift should mark the chat dirty")
|
||||
require.NotNil(t, got.Context.DirtySince)
|
||||
require.Empty(t, got.Context.Error, "dirty marking leaves the pinned hash and error unchanged")
|
||||
requireChatContextNil(otherChat.ID, "agent-less chat unaffected by the dirty fan-out")
|
||||
|
||||
// Refreshing re-pins the latest snapshot (hash and error) and clears the
|
||||
// dirty marker.
|
||||
refreshed, err := expClient.RefreshChatContext(ctx, chat.ID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, refreshed.Context)
|
||||
require.False(t, refreshed.Context.Dirty, "refresh clears the dirty marker")
|
||||
require.Equal(t, snapshotError, refreshed.Context.Error, "refresh re-pins the snapshot error")
|
||||
|
||||
got, err = expClient.GetChat(ctx, chat.ID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, got.Context)
|
||||
require.False(t, got.Context.Dirty)
|
||||
|
||||
// Re-pushing the now-pinned hash proves the refresh advanced the pin to
|
||||
// hashB: a matching hash must not re-dirty the chat.
|
||||
resp, err = aAPI.PushContextState(ctx, &agentproto.PushContextStateRequest{
|
||||
Version: 3,
|
||||
AggregateHash: hashB,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.True(t, resp.GetAccepted())
|
||||
|
||||
got, err = expClient.GetChat(ctx, chat.ID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, got.Context)
|
||||
require.False(t, got.Context.Dirty, "re-push of the pinned hash stays clean")
|
||||
}
|
||||
+39
-2
@@ -142,8 +142,12 @@ type Chat struct {
|
||||
// is updated only when context changes, on first workspace
|
||||
// attach or agent change.
|
||||
LastInjectedContext []ChatMessagePart `json:"last_injected_context,omitempty"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
ClientType ChatClientType `json:"client_type"`
|
||||
// Context reports the chat's pinned workspace-context state and
|
||||
// whether it has drifted from the agent's latest pushed snapshot.
|
||||
// Nil when the chat has no pinned context yet.
|
||||
Context *ChatContext `json:"context,omitempty"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
ClientType ChatClientType `json:"client_type"`
|
||||
// Children holds child (subagent) chats nested under this root
|
||||
// chat. Always initialized to an empty slice so the JSON field
|
||||
// is present as []. Child chats cannot create their own
|
||||
@@ -152,6 +156,20 @@ type Chat struct {
|
||||
Children []Chat `json:"children"`
|
||||
}
|
||||
|
||||
// ChatContext reports a chat's pinned workspace context and whether it has
|
||||
// drifted from the agent's latest pushed snapshot. The chat stays usable
|
||||
// when dirty; refreshing re-pins it to the latest snapshot.
|
||||
type ChatContext struct {
|
||||
// Dirty is true when the agent's latest snapshot hash differs from the
|
||||
// chat's pinned hash.
|
||||
Dirty bool `json:"dirty"`
|
||||
// DirtySince is when drift was first detected; nil when not dirty.
|
||||
DirtySince *time.Time `json:"dirty_since,omitempty" format:"date-time"`
|
||||
// Error is the snapshot-level error copied from the pinned snapshot
|
||||
// (empty when healthy).
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// ChatFileMetadata contains lightweight metadata about a file
|
||||
// associated with a chat, excluding the file content itself.
|
||||
type ChatFileMetadata struct {
|
||||
@@ -1692,6 +1710,10 @@ const (
|
||||
ChatWatchEventKindDeleted ChatWatchEventKind = "deleted"
|
||||
ChatWatchEventKindDiffStatusChange ChatWatchEventKind = "diff_status_change"
|
||||
ChatWatchEventKindActionRequired ChatWatchEventKind = "action_required"
|
||||
// ChatWatchEventKindContextDirty signals that the chat's pinned
|
||||
// workspace context drifted from the agent's latest pushed snapshot.
|
||||
// The chat stays usable; a refresh re-pins it to the latest snapshot.
|
||||
ChatWatchEventKindContextDirty ChatWatchEventKind = "context_dirty"
|
||||
)
|
||||
|
||||
// ChatWatchEvent represents an event from the global chat watch stream.
|
||||
@@ -3086,6 +3108,21 @@ func (c *ExperimentalClient) GetChat(ctx context.Context, chatID uuid.UUID) (Cha
|
||||
return chat, json.NewDecoder(res.Body).Decode(&chat)
|
||||
}
|
||||
|
||||
// RefreshChatContext re-pins the chat to its agent's latest context snapshot
|
||||
// and clears the dirty marker. The request takes no body.
|
||||
func (c *ExperimentalClient) RefreshChatContext(ctx context.Context, chatID uuid.UUID) (Chat, error) {
|
||||
res, err := c.Request(ctx, http.MethodPut, fmt.Sprintf("/api/experimental/chats/%s/context", chatID), nil)
|
||||
if err != nil {
|
||||
return Chat{}, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return Chat{}, ReadBodyAsError(res)
|
||||
}
|
||||
var chat Chat
|
||||
return chat, json.NewDecoder(res.Body).Decode(&chat)
|
||||
}
|
||||
|
||||
func (c *ExperimentalClient) GetChatACL(ctx context.Context, chatID uuid.UUID) (ChatACL, error) {
|
||||
res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/experimental/chats/%s/acl", chatID), nil)
|
||||
if err != nil {
|
||||
|
||||
Generated
+434
-47
@@ -36,6 +36,11 @@ Experimental: this endpoint is subject to change.
|
||||
{}
|
||||
],
|
||||
"client_type": "ui",
|
||||
"context": {
|
||||
"dirty": true,
|
||||
"dirty_since": "2019-08-24T14:15:22Z",
|
||||
"error": "string"
|
||||
},
|
||||
"created_at": "2019-08-24T14:15:22Z",
|
||||
"diff_status": {
|
||||
"additions": 0,
|
||||
@@ -189,6 +194,10 @@ Status Code **200**
|
||||
| `» build_id` | string(uuid) | false | | |
|
||||
| `» children` | [codersdk.Chat](schemas.md#codersdkchat) | false | | Children holds child (subagent) chats nested under this root chat. Always initialized to an empty slice so the JSON field is present as []. Child chats cannot create their own subagents, so nesting depth is capped at 1 and this slice is always empty for child chats. |
|
||||
| `» client_type` | [codersdk.ChatClientType](schemas.md#codersdkchatclienttype) | false | | |
|
||||
| `» context` | [codersdk.ChatContext](schemas.md#codersdkchatcontext) | false | | Context reports the chat's pinned workspace-context state and whether it has drifted from the agent's latest pushed snapshot. Nil when the chat has no pinned context yet. |
|
||||
| `»» dirty` | boolean | false | | Dirty is true when the agent's latest snapshot hash differs from the chat's pinned hash. |
|
||||
| `»» dirty_since` | string(date-time) | false | | Dirty since is when drift was first detected; nil when not dirty. |
|
||||
| `»» error` | string | false | | Error is the snapshot-level error copied from the pinned snapshot (empty when healthy). |
|
||||
| `» created_at` | string(date-time) | false | | |
|
||||
| `» diff_status` | [codersdk.ChatDiffStatus](schemas.md#codersdkchatdiffstatus) | false | | |
|
||||
| `»» additions` | integer | false | | |
|
||||
@@ -382,6 +391,11 @@ Experimental: this endpoint is subject to change.
|
||||
"build_id": "bfb1f3fa-bf7b-43a5-9e0b-26cc050e44cb",
|
||||
"children": [],
|
||||
"client_type": "ui",
|
||||
"context": {
|
||||
"dirty": true,
|
||||
"dirty_since": "2019-08-24T14:15:22Z",
|
||||
"error": "string"
|
||||
},
|
||||
"created_at": "2019-08-24T14:15:22Z",
|
||||
"diff_status": {
|
||||
"additions": 0,
|
||||
@@ -516,6 +530,11 @@ Experimental: this endpoint is subject to change.
|
||||
}
|
||||
],
|
||||
"client_type": "ui",
|
||||
"context": {
|
||||
"dirty": true,
|
||||
"dirty_since": "2019-08-24T14:15:22Z",
|
||||
"error": "string"
|
||||
},
|
||||
"created_at": "2019-08-24T14:15:22Z",
|
||||
"diff_status": {
|
||||
"additions": 0,
|
||||
@@ -801,6 +820,11 @@ Experimental: this endpoint is subject to change.
|
||||
{}
|
||||
],
|
||||
"client_type": "ui",
|
||||
"context": {
|
||||
"dirty": true,
|
||||
"dirty_since": "2019-08-24T14:15:22Z",
|
||||
"error": "string"
|
||||
},
|
||||
"created_at": "2019-08-24T14:15:22Z",
|
||||
"diff_status": {
|
||||
"additions": 0,
|
||||
@@ -989,6 +1013,11 @@ Experimental: this endpoint is subject to change.
|
||||
"build_id": "bfb1f3fa-bf7b-43a5-9e0b-26cc050e44cb",
|
||||
"children": [],
|
||||
"client_type": "ui",
|
||||
"context": {
|
||||
"dirty": true,
|
||||
"dirty_since": "2019-08-24T14:15:22Z",
|
||||
"error": "string"
|
||||
},
|
||||
"created_at": "2019-08-24T14:15:22Z",
|
||||
"diff_status": {
|
||||
"additions": 0,
|
||||
@@ -1123,6 +1152,11 @@ Experimental: this endpoint is subject to change.
|
||||
}
|
||||
],
|
||||
"client_type": "ui",
|
||||
"context": {
|
||||
"dirty": true,
|
||||
"dirty_since": "2019-08-24T14:15:22Z",
|
||||
"error": "string"
|
||||
},
|
||||
"created_at": "2019-08-24T14:15:22Z",
|
||||
"diff_status": {
|
||||
"additions": 0,
|
||||
@@ -1311,62 +1345,18 @@ Experimental: this endpoint is subject to change.
|
||||
|
||||
To perform this operation, you must be authenticated. [Learn more](authentication.md).
|
||||
|
||||
## Get chat diff contents
|
||||
## Refresh chat context
|
||||
|
||||
### Code samples
|
||||
|
||||
```shell
|
||||
# Example request using curl
|
||||
curl -X GET http://coder-server:8080/api/experimental/chats/{chat}/diff \
|
||||
curl -X PUT http://coder-server:8080/api/experimental/chats/{chat}/context \
|
||||
-H 'Accept: application/json' \
|
||||
-H 'Coder-Session-Token: API_KEY'
|
||||
```
|
||||
|
||||
`GET /api/experimental/chats/{chat}/diff`
|
||||
|
||||
Experimental: this endpoint is subject to change.
|
||||
|
||||
### Parameters
|
||||
|
||||
| Name | In | Type | Required | Description |
|
||||
|--------|------|--------------|----------|-------------|
|
||||
| `chat` | path | string(uuid) | true | Chat ID |
|
||||
|
||||
### Example responses
|
||||
|
||||
> 200 Response
|
||||
|
||||
```json
|
||||
{
|
||||
"branch": "string",
|
||||
"chat_id": "efc9fe20-a1e5-4a8c-9c48-f1b30c1e4f86",
|
||||
"diff": "string",
|
||||
"provider": "string",
|
||||
"pull_request_url": "string",
|
||||
"remote_origin": "string"
|
||||
}
|
||||
```
|
||||
|
||||
### Responses
|
||||
|
||||
| Status | Meaning | Description | Schema |
|
||||
|--------|---------------------------------------------------------|-------------|------------------------------------------------------------------|
|
||||
| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.ChatDiffContents](schemas.md#codersdkchatdiffcontents) |
|
||||
|
||||
To perform this operation, you must be authenticated. [Learn more](authentication.md).
|
||||
|
||||
## Interrupt chat
|
||||
|
||||
### Code samples
|
||||
|
||||
```shell
|
||||
# Example request using curl
|
||||
curl -X POST http://coder-server:8080/api/experimental/chats/{chat}/interrupt \
|
||||
-H 'Accept: application/json' \
|
||||
-H 'Coder-Session-Token: API_KEY'
|
||||
```
|
||||
|
||||
`POST /api/experimental/chats/{chat}/interrupt`
|
||||
`PUT /api/experimental/chats/{chat}/context`
|
||||
|
||||
Experimental: this endpoint is subject to change.
|
||||
|
||||
@@ -1392,6 +1382,11 @@ Experimental: this endpoint is subject to change.
|
||||
"build_id": "bfb1f3fa-bf7b-43a5-9e0b-26cc050e44cb",
|
||||
"children": [],
|
||||
"client_type": "ui",
|
||||
"context": {
|
||||
"dirty": true,
|
||||
"dirty_since": "2019-08-24T14:15:22Z",
|
||||
"error": "string"
|
||||
},
|
||||
"created_at": "2019-08-24T14:15:22Z",
|
||||
"diff_status": {
|
||||
"additions": 0,
|
||||
@@ -1526,6 +1521,378 @@ Experimental: this endpoint is subject to change.
|
||||
}
|
||||
],
|
||||
"client_type": "ui",
|
||||
"context": {
|
||||
"dirty": true,
|
||||
"dirty_since": "2019-08-24T14:15:22Z",
|
||||
"error": "string"
|
||||
},
|
||||
"created_at": "2019-08-24T14:15:22Z",
|
||||
"diff_status": {
|
||||
"additions": 0,
|
||||
"approved": true,
|
||||
"author_avatar_url": "string",
|
||||
"author_login": "string",
|
||||
"base_branch": "string",
|
||||
"changed_files": 0,
|
||||
"changes_requested": true,
|
||||
"chat_id": "efc9fe20-a1e5-4a8c-9c48-f1b30c1e4f86",
|
||||
"commits": 0,
|
||||
"deletions": 0,
|
||||
"head_branch": "string",
|
||||
"pr_number": 0,
|
||||
"pull_request_draft": true,
|
||||
"pull_request_state": "string",
|
||||
"pull_request_title": "string",
|
||||
"refreshed_at": "2019-08-24T14:15:22Z",
|
||||
"reviewer_count": 0,
|
||||
"stale_at": "2019-08-24T14:15:22Z",
|
||||
"url": "string"
|
||||
},
|
||||
"files": [
|
||||
{
|
||||
"created_at": "2019-08-24T14:15:22Z",
|
||||
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
|
||||
"mime_type": "string",
|
||||
"name": "string",
|
||||
"organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6",
|
||||
"owner_id": "8826ee2e-7933-4665-aef2-2393f84a0d05"
|
||||
}
|
||||
],
|
||||
"has_unread": true,
|
||||
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
|
||||
"labels": {
|
||||
"property1": "string",
|
||||
"property2": "string"
|
||||
},
|
||||
"last_error": {
|
||||
"detail": "string",
|
||||
"kind": "generic",
|
||||
"message": "string",
|
||||
"provider": "string",
|
||||
"retryable": true,
|
||||
"status_code": 0
|
||||
},
|
||||
"last_injected_context": [
|
||||
{
|
||||
"args": [
|
||||
0
|
||||
],
|
||||
"args_delta": "string",
|
||||
"completed_at": "2019-08-24T14:15:22Z",
|
||||
"content": "string",
|
||||
"context_file_agent_id": {
|
||||
"uuid": "string",
|
||||
"valid": true
|
||||
},
|
||||
"context_file_content": "string",
|
||||
"context_file_directory": "string",
|
||||
"context_file_os": "string",
|
||||
"context_file_path": "string",
|
||||
"context_file_skill_meta_file": "string",
|
||||
"context_file_truncated": true,
|
||||
"created_at": "2019-08-24T14:15:22Z",
|
||||
"data": [
|
||||
0
|
||||
],
|
||||
"end_line": 0,
|
||||
"file_id": {
|
||||
"uuid": "string",
|
||||
"valid": true
|
||||
},
|
||||
"file_name": "string",
|
||||
"is_error": true,
|
||||
"is_media": true,
|
||||
"mcp_server_config_id": {
|
||||
"uuid": "string",
|
||||
"valid": true
|
||||
},
|
||||
"media_type": "string",
|
||||
"name": "string",
|
||||
"parsed_commands": [
|
||||
[
|
||||
"string"
|
||||
]
|
||||
],
|
||||
"provider_executed": true,
|
||||
"provider_metadata": [
|
||||
0
|
||||
],
|
||||
"result": [
|
||||
0
|
||||
],
|
||||
"result_delta": "string",
|
||||
"result_reset": true,
|
||||
"signature": "string",
|
||||
"skill_description": "string",
|
||||
"skill_dir": "string",
|
||||
"skill_name": "string",
|
||||
"source_id": "string",
|
||||
"start_line": 0,
|
||||
"text": "string",
|
||||
"title": "string",
|
||||
"tool_call_id": "string",
|
||||
"tool_name": "string",
|
||||
"type": "text",
|
||||
"url": "string"
|
||||
}
|
||||
],
|
||||
"last_model_config_id": "30ebb95f-c255-4759-9429-89aa4ec1554c",
|
||||
"last_turn_summary": "string",
|
||||
"mcp_server_ids": [
|
||||
"497f6eca-6276-4993-bfeb-53cbbbba6f08"
|
||||
],
|
||||
"organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6",
|
||||
"owner_id": "8826ee2e-7933-4665-aef2-2393f84a0d05",
|
||||
"owner_name": "string",
|
||||
"owner_username": "string",
|
||||
"parent_chat_id": "c3609ee6-3b11-4a93-b9ae-e4fabcc99359",
|
||||
"pin_order": 0,
|
||||
"plan_mode": "plan",
|
||||
"root_chat_id": "2898031c-fdce-4e3e-8c53-4481dd42fcd7",
|
||||
"shared": true,
|
||||
"status": "waiting",
|
||||
"title": "string",
|
||||
"updated_at": "2019-08-24T14:15:22Z",
|
||||
"warnings": [
|
||||
"string"
|
||||
],
|
||||
"workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9"
|
||||
}
|
||||
```
|
||||
|
||||
### Responses
|
||||
|
||||
| Status | Meaning | Description | Schema |
|
||||
|--------|---------------------------------------------------------|-------------|------------------------------------------|
|
||||
| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.Chat](schemas.md#codersdkchat) |
|
||||
|
||||
To perform this operation, you must be authenticated. [Learn more](authentication.md).
|
||||
|
||||
## Get chat diff contents
|
||||
|
||||
### Code samples
|
||||
|
||||
```shell
|
||||
# Example request using curl
|
||||
curl -X GET http://coder-server:8080/api/experimental/chats/{chat}/diff \
|
||||
-H 'Accept: application/json' \
|
||||
-H 'Coder-Session-Token: API_KEY'
|
||||
```
|
||||
|
||||
`GET /api/experimental/chats/{chat}/diff`
|
||||
|
||||
Experimental: this endpoint is subject to change.
|
||||
|
||||
### Parameters
|
||||
|
||||
| Name | In | Type | Required | Description |
|
||||
|--------|------|--------------|----------|-------------|
|
||||
| `chat` | path | string(uuid) | true | Chat ID |
|
||||
|
||||
### Example responses
|
||||
|
||||
> 200 Response
|
||||
|
||||
```json
|
||||
{
|
||||
"branch": "string",
|
||||
"chat_id": "efc9fe20-a1e5-4a8c-9c48-f1b30c1e4f86",
|
||||
"diff": "string",
|
||||
"provider": "string",
|
||||
"pull_request_url": "string",
|
||||
"remote_origin": "string"
|
||||
}
|
||||
```
|
||||
|
||||
### Responses
|
||||
|
||||
| Status | Meaning | Description | Schema |
|
||||
|--------|---------------------------------------------------------|-------------|------------------------------------------------------------------|
|
||||
| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.ChatDiffContents](schemas.md#codersdkchatdiffcontents) |
|
||||
|
||||
To perform this operation, you must be authenticated. [Learn more](authentication.md).
|
||||
|
||||
## Interrupt chat
|
||||
|
||||
### Code samples
|
||||
|
||||
```shell
|
||||
# Example request using curl
|
||||
curl -X POST http://coder-server:8080/api/experimental/chats/{chat}/interrupt \
|
||||
-H 'Accept: application/json' \
|
||||
-H 'Coder-Session-Token: API_KEY'
|
||||
```
|
||||
|
||||
`POST /api/experimental/chats/{chat}/interrupt`
|
||||
|
||||
Experimental: this endpoint is subject to change.
|
||||
|
||||
### Parameters
|
||||
|
||||
| Name | In | Type | Required | Description |
|
||||
|--------|------|--------------|----------|-------------|
|
||||
| `chat` | path | string(uuid) | true | Chat ID |
|
||||
|
||||
### Example responses
|
||||
|
||||
> 200 Response
|
||||
|
||||
```json
|
||||
{
|
||||
"agent_id": "2b1e3b65-2c04-4fa2-a2d7-467901e98978",
|
||||
"archived": true,
|
||||
"build_id": "bfb1f3fa-bf7b-43a5-9e0b-26cc050e44cb",
|
||||
"children": [
|
||||
{
|
||||
"agent_id": "2b1e3b65-2c04-4fa2-a2d7-467901e98978",
|
||||
"archived": true,
|
||||
"build_id": "bfb1f3fa-bf7b-43a5-9e0b-26cc050e44cb",
|
||||
"children": [],
|
||||
"client_type": "ui",
|
||||
"context": {
|
||||
"dirty": true,
|
||||
"dirty_since": "2019-08-24T14:15:22Z",
|
||||
"error": "string"
|
||||
},
|
||||
"created_at": "2019-08-24T14:15:22Z",
|
||||
"diff_status": {
|
||||
"additions": 0,
|
||||
"approved": true,
|
||||
"author_avatar_url": "string",
|
||||
"author_login": "string",
|
||||
"base_branch": "string",
|
||||
"changed_files": 0,
|
||||
"changes_requested": true,
|
||||
"chat_id": "efc9fe20-a1e5-4a8c-9c48-f1b30c1e4f86",
|
||||
"commits": 0,
|
||||
"deletions": 0,
|
||||
"head_branch": "string",
|
||||
"pr_number": 0,
|
||||
"pull_request_draft": true,
|
||||
"pull_request_state": "string",
|
||||
"pull_request_title": "string",
|
||||
"refreshed_at": "2019-08-24T14:15:22Z",
|
||||
"reviewer_count": 0,
|
||||
"stale_at": "2019-08-24T14:15:22Z",
|
||||
"url": "string"
|
||||
},
|
||||
"files": [
|
||||
{
|
||||
"created_at": "2019-08-24T14:15:22Z",
|
||||
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
|
||||
"mime_type": "string",
|
||||
"name": "string",
|
||||
"organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6",
|
||||
"owner_id": "8826ee2e-7933-4665-aef2-2393f84a0d05"
|
||||
}
|
||||
],
|
||||
"has_unread": true,
|
||||
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
|
||||
"labels": {
|
||||
"property1": "string",
|
||||
"property2": "string"
|
||||
},
|
||||
"last_error": {
|
||||
"detail": "string",
|
||||
"kind": "generic",
|
||||
"message": "string",
|
||||
"provider": "string",
|
||||
"retryable": true,
|
||||
"status_code": 0
|
||||
},
|
||||
"last_injected_context": [
|
||||
{
|
||||
"args": [
|
||||
0
|
||||
],
|
||||
"args_delta": "string",
|
||||
"completed_at": "2019-08-24T14:15:22Z",
|
||||
"content": "string",
|
||||
"context_file_agent_id": {
|
||||
"uuid": "string",
|
||||
"valid": true
|
||||
},
|
||||
"context_file_content": "string",
|
||||
"context_file_directory": "string",
|
||||
"context_file_os": "string",
|
||||
"context_file_path": "string",
|
||||
"context_file_skill_meta_file": "string",
|
||||
"context_file_truncated": true,
|
||||
"created_at": "2019-08-24T14:15:22Z",
|
||||
"data": [
|
||||
0
|
||||
],
|
||||
"end_line": 0,
|
||||
"file_id": {
|
||||
"uuid": "string",
|
||||
"valid": true
|
||||
},
|
||||
"file_name": "string",
|
||||
"is_error": true,
|
||||
"is_media": true,
|
||||
"mcp_server_config_id": {
|
||||
"uuid": "string",
|
||||
"valid": true
|
||||
},
|
||||
"media_type": "string",
|
||||
"name": "string",
|
||||
"parsed_commands": [
|
||||
[
|
||||
"string"
|
||||
]
|
||||
],
|
||||
"provider_executed": true,
|
||||
"provider_metadata": [
|
||||
0
|
||||
],
|
||||
"result": [
|
||||
0
|
||||
],
|
||||
"result_delta": "string",
|
||||
"result_reset": true,
|
||||
"signature": "string",
|
||||
"skill_description": "string",
|
||||
"skill_dir": "string",
|
||||
"skill_name": "string",
|
||||
"source_id": "string",
|
||||
"start_line": 0,
|
||||
"text": "string",
|
||||
"title": "string",
|
||||
"tool_call_id": "string",
|
||||
"tool_name": "string",
|
||||
"type": "text",
|
||||
"url": "string"
|
||||
}
|
||||
],
|
||||
"last_model_config_id": "30ebb95f-c255-4759-9429-89aa4ec1554c",
|
||||
"last_turn_summary": "string",
|
||||
"mcp_server_ids": [
|
||||
"497f6eca-6276-4993-bfeb-53cbbbba6f08"
|
||||
],
|
||||
"organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6",
|
||||
"owner_id": "8826ee2e-7933-4665-aef2-2393f84a0d05",
|
||||
"owner_name": "string",
|
||||
"owner_username": "string",
|
||||
"parent_chat_id": "c3609ee6-3b11-4a93-b9ae-e4fabcc99359",
|
||||
"pin_order": 0,
|
||||
"plan_mode": "plan",
|
||||
"root_chat_id": "2898031c-fdce-4e3e-8c53-4481dd42fcd7",
|
||||
"shared": true,
|
||||
"status": "waiting",
|
||||
"title": "string",
|
||||
"updated_at": "2019-08-24T14:15:22Z",
|
||||
"warnings": [
|
||||
"string"
|
||||
],
|
||||
"workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9"
|
||||
}
|
||||
],
|
||||
"client_type": "ui",
|
||||
"context": {
|
||||
"dirty": true,
|
||||
"dirty_since": "2019-08-24T14:15:22Z",
|
||||
"error": "string"
|
||||
},
|
||||
"created_at": "2019-08-24T14:15:22Z",
|
||||
"diff_status": {
|
||||
"additions": 0,
|
||||
@@ -2316,6 +2683,11 @@ Experimental: this endpoint is subject to change.
|
||||
"build_id": "bfb1f3fa-bf7b-43a5-9e0b-26cc050e44cb",
|
||||
"children": [],
|
||||
"client_type": "ui",
|
||||
"context": {
|
||||
"dirty": true,
|
||||
"dirty_since": "2019-08-24T14:15:22Z",
|
||||
"error": "string"
|
||||
},
|
||||
"created_at": "2019-08-24T14:15:22Z",
|
||||
"diff_status": {
|
||||
"additions": 0,
|
||||
@@ -2450,6 +2822,11 @@ Experimental: this endpoint is subject to change.
|
||||
}
|
||||
],
|
||||
"client_type": "ui",
|
||||
"context": {
|
||||
"dirty": true,
|
||||
"dirty_since": "2019-08-24T14:15:22Z",
|
||||
"error": "string"
|
||||
},
|
||||
"created_at": "2019-08-24T14:15:22Z",
|
||||
"diff_status": {
|
||||
"additions": 0,
|
||||
@@ -2998,6 +3375,11 @@ Experimental: this endpoint is subject to change.
|
||||
"build_id": "bfb1f3fa-bf7b-43a5-9e0b-26cc050e44cb",
|
||||
"children": [],
|
||||
"client_type": "ui",
|
||||
"context": {
|
||||
"dirty": true,
|
||||
"dirty_since": "2019-08-24T14:15:22Z",
|
||||
"error": "string"
|
||||
},
|
||||
"created_at": "2019-08-24T14:15:22Z",
|
||||
"diff_status": {
|
||||
"additions": 0,
|
||||
@@ -3132,6 +3514,11 @@ Experimental: this endpoint is subject to change.
|
||||
}
|
||||
],
|
||||
"client_type": "ui",
|
||||
"context": {
|
||||
"dirty": true,
|
||||
"dirty_since": "2019-08-24T14:15:22Z",
|
||||
"error": "string"
|
||||
},
|
||||
"created_at": "2019-08-24T14:15:22Z",
|
||||
"diff_status": {
|
||||
"additions": 0,
|
||||
|
||||
Generated
+37
-3
@@ -1925,6 +1925,11 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
|
||||
"build_id": "bfb1f3fa-bf7b-43a5-9e0b-26cc050e44cb",
|
||||
"children": [],
|
||||
"client_type": "ui",
|
||||
"context": {
|
||||
"dirty": true,
|
||||
"dirty_since": "2019-08-24T14:15:22Z",
|
||||
"error": "string"
|
||||
},
|
||||
"created_at": "2019-08-24T14:15:22Z",
|
||||
"diff_status": {
|
||||
"additions": 0,
|
||||
@@ -2059,6 +2064,11 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
|
||||
}
|
||||
],
|
||||
"client_type": "ui",
|
||||
"context": {
|
||||
"dirty": true,
|
||||
"dirty_since": "2019-08-24T14:15:22Z",
|
||||
"error": "string"
|
||||
},
|
||||
"created_at": "2019-08-24T14:15:22Z",
|
||||
"diff_status": {
|
||||
"additions": 0,
|
||||
@@ -2202,6 +2212,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
|
||||
| `build_id` | string | false | | |
|
||||
| `children` | array of [codersdk.Chat](#codersdkchat) | false | | Children holds child (subagent) chats nested under this root chat. Always initialized to an empty slice so the JSON field is present as []. Child chats cannot create their own subagents, so nesting depth is capped at 1 and this slice is always empty for child chats. |
|
||||
| `client_type` | [codersdk.ChatClientType](#codersdkchatclienttype) | false | | |
|
||||
| `context` | [codersdk.ChatContext](#codersdkchatcontext) | false | | Context reports the chat's pinned workspace-context state and whether it has drifted from the agent's latest pushed snapshot. Nil when the chat has no pinned context yet. |
|
||||
| `created_at` | string | false | | |
|
||||
| `diff_status` | [codersdk.ChatDiffStatus](#codersdkchatdiffstatus) | false | | |
|
||||
| `files` | array of [codersdk.ChatFileMetadata](#codersdkchatfilemetadata) | false | | |
|
||||
@@ -2327,6 +2338,24 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
|
||||
| `acquire_batch_size` | integer | false | | |
|
||||
| `debug_logging_enabled` | boolean | false | | |
|
||||
|
||||
## codersdk.ChatContext
|
||||
|
||||
```json
|
||||
{
|
||||
"dirty": true,
|
||||
"dirty_since": "2019-08-24T14:15:22Z",
|
||||
"error": "string"
|
||||
}
|
||||
```
|
||||
|
||||
### Properties
|
||||
|
||||
| Name | Type | Required | Restrictions | Description |
|
||||
|---------------|---------|----------|--------------|------------------------------------------------------------------------------------------|
|
||||
| `dirty` | boolean | false | | Dirty is true when the agent's latest snapshot hash differs from the chat's pinned hash. |
|
||||
| `dirty_since` | string | false | | Dirty since is when drift was first detected; nil when not dirty. |
|
||||
| `error` | string | false | | Error is the snapshot-level error copied from the pinned snapshot (empty when healthy). |
|
||||
|
||||
## codersdk.ChatDiffContents
|
||||
|
||||
```json
|
||||
@@ -3748,6 +3777,11 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
|
||||
{}
|
||||
],
|
||||
"client_type": "ui",
|
||||
"context": {
|
||||
"dirty": true,
|
||||
"dirty_since": "2019-08-24T14:15:22Z",
|
||||
"error": "string"
|
||||
},
|
||||
"created_at": "2019-08-24T14:15:22Z",
|
||||
"diff_status": {
|
||||
"additions": 0,
|
||||
@@ -3909,9 +3943,9 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
|
||||
|
||||
#### Enumerated Values
|
||||
|
||||
| Value(s) |
|
||||
|------------------------------------------------------------------------------------------------------------------|
|
||||
| `action_required`, `created`, `deleted`, `diff_status_change`, `status_change`, `summary_change`, `title_change` |
|
||||
| Value(s) |
|
||||
|-----------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `action_required`, `context_dirty`, `created`, `deleted`, `diff_status_change`, `status_change`, `summary_change`, `title_change` |
|
||||
|
||||
## codersdk.ConnectionLatency
|
||||
|
||||
|
||||
Generated
+31
@@ -1503,6 +1503,12 @@ export interface Chat {
|
||||
* attach or agent change.
|
||||
*/
|
||||
readonly last_injected_context?: readonly ChatMessagePart[];
|
||||
/**
|
||||
* Context reports the chat's pinned workspace-context state and
|
||||
* whether it has drifted from the agent's latest pushed snapshot.
|
||||
* Nil when the chat has no pinned context yet.
|
||||
*/
|
||||
readonly context?: ChatContext;
|
||||
readonly warnings?: readonly string[];
|
||||
readonly client_type: ChatClientType;
|
||||
/**
|
||||
@@ -1587,6 +1593,29 @@ export interface ChatConfig {
|
||||
readonly ai_gateway_routing_enabled: boolean;
|
||||
}
|
||||
|
||||
// From codersdk/chats.go
|
||||
/**
|
||||
* ChatContext reports a chat's pinned workspace context and whether it has
|
||||
* drifted from the agent's latest pushed snapshot. The chat stays usable
|
||||
* when dirty; refreshing re-pins it to the latest snapshot.
|
||||
*/
|
||||
export interface ChatContext {
|
||||
/**
|
||||
* Dirty is true when the agent's latest snapshot hash differs from the
|
||||
* chat's pinned hash.
|
||||
*/
|
||||
readonly dirty: boolean;
|
||||
/**
|
||||
* DirtySince is when drift was first detected; nil when not dirty.
|
||||
*/
|
||||
readonly dirty_since?: string;
|
||||
/**
|
||||
* Error is the snapshot-level error copied from the pinned snapshot
|
||||
* (empty when healthy).
|
||||
*/
|
||||
readonly error?: string;
|
||||
}
|
||||
|
||||
// From codersdk/chats.go
|
||||
export interface ChatContextFilePart {
|
||||
readonly type: "context-file";
|
||||
@@ -3037,6 +3066,7 @@ export interface ChatWatchEvent {
|
||||
// From codersdk/chats.go
|
||||
export type ChatWatchEventKind =
|
||||
| "action_required"
|
||||
| "context_dirty"
|
||||
| "created"
|
||||
| "deleted"
|
||||
| "diff_status_change"
|
||||
@@ -3046,6 +3076,7 @@ export type ChatWatchEventKind =
|
||||
|
||||
export const ChatWatchEventKinds: ChatWatchEventKind[] = [
|
||||
"action_required",
|
||||
"context_dirty",
|
||||
"created",
|
||||
"deleted",
|
||||
"diff_status_change",
|
||||
|
||||
Reference in New Issue
Block a user