mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: add last_injected_context column to chats table (#23798)
Adds a nullable JSONB column `last_injected_context` to the `chats` table that stores the most recently persisted injected context parts (AGENTS.md context-file and skill message parts). The column is updated only when `persistInstructionFiles()` runs — on first workspace attach or when the agent changes — so there are no redundant writes on subsequent turns. Internal fields (`ContextFileContent`, `ContextFileOS`, `ContextFileDirectory`, `SkillDir`) are stripped at write time so the column only holds small metadata. No stripping needed on the read path. <details> <summary>Implementation notes</summary> - New migration `000456` adds nullable `last_injected_context JSONB` column. - New SQL query `UpdateChatLastInjectedContext` writes the column without touching `updated_at`. - `persistInstructionFiles()` strips internal fields from parts via `StripInternal()` before persisting. - Sentinel path (no AGENTS.md) persists skill-only parts when skills exist. - `codersdk.Chat` exposes `LastInjectedContext []ChatMessagePart` (omitempty). - `db2sdk.Chat()` passes through the already-clean data. </details>
This commit is contained in:
+55
-1
@@ -4970,9 +4970,25 @@ func (p *Server) persistInstructionFiles(
|
||||
chatprompt.CurrentContentVersion,
|
||||
))
|
||||
_, _ = p.db.InsertChatMessages(ctx, msgParams)
|
||||
// Update the cache column: persist skills if any
|
||||
// exist, or clear to NULL so stale data from a
|
||||
// previous agent doesn't linger.
|
||||
if len(discoveredSkills) > 0 {
|
||||
skillParts := make([]codersdk.ChatMessagePart, 0, len(discoveredSkills))
|
||||
for _, s := range discoveredSkills {
|
||||
skillParts = append(skillParts, codersdk.ChatMessagePart{
|
||||
Type: codersdk.ChatMessagePartTypeSkill,
|
||||
SkillName: s.Name,
|
||||
SkillDescription: s.Description,
|
||||
ContextFileAgentID: uuid.NullUUID{UUID: agent.ID, Valid: true},
|
||||
})
|
||||
}
|
||||
p.updateLastInjectedContext(ctx, chat.ID, skillParts)
|
||||
} else {
|
||||
p.updateLastInjectedContext(ctx, chat.ID, nil)
|
||||
}
|
||||
return "", discoveredSkills, nil
|
||||
}
|
||||
|
||||
// Build context-file parts (one per instruction file) and
|
||||
// skill parts (one per discovered skill).
|
||||
parts := make([]codersdk.ChatMessagePart, 0, len(sections)+len(discoveredSkills))
|
||||
@@ -5015,6 +5031,15 @@ func (p *Server) persistInstructionFiles(
|
||||
if _, err := p.db.InsertChatMessages(ctx, msgParams); err != nil {
|
||||
return "", nil, xerrors.Errorf("persist instruction files: %w", err)
|
||||
}
|
||||
// Build stripped copies for the cache column so internal
|
||||
// fields (full file content, OS, directory, skill paths)
|
||||
// are never persisted or returned to API clients.
|
||||
stripped := make([]codersdk.ChatMessagePart, len(parts))
|
||||
copy(stripped, parts)
|
||||
for i := range stripped {
|
||||
stripped[i].StripInternal()
|
||||
}
|
||||
p.updateLastInjectedContext(ctx, chat.ID, stripped)
|
||||
|
||||
// Return the formatted instruction text and discovered skills
|
||||
// so the caller can inject them into this turn's prompt (since
|
||||
@@ -5022,6 +5047,35 @@ func (p *Server) persistInstructionFiles(
|
||||
return formatSystemInstructions(agent.OperatingSystem, directory, sections), discoveredSkills, nil
|
||||
}
|
||||
|
||||
// updateLastInjectedContext persists the injected context
|
||||
// parts (AGENTS.md files and skills) on the chat row so they
|
||||
// are directly queryable without scanning messages. This is
|
||||
// best-effort — a failure here is logged but does not block
|
||||
// the turn.
|
||||
func (p *Server) updateLastInjectedContext(ctx context.Context, chatID uuid.UUID, parts []codersdk.ChatMessagePart) {
|
||||
param := pqtype.NullRawMessage{Valid: false}
|
||||
if parts != nil {
|
||||
raw, err := json.Marshal(parts)
|
||||
if err != nil {
|
||||
p.logger.Warn(ctx, "failed to marshal injected context",
|
||||
slog.F("chat_id", chatID),
|
||||
slog.Error(err),
|
||||
)
|
||||
return
|
||||
}
|
||||
param = pqtype.NullRawMessage{RawMessage: raw, Valid: true}
|
||||
}
|
||||
if _, err := p.db.UpdateChatLastInjectedContext(ctx, database.UpdateChatLastInjectedContextParams{
|
||||
ID: chatID,
|
||||
LastInjectedContext: param,
|
||||
}); err != nil {
|
||||
p.logger.Warn(ctx, "failed to update injected context",
|
||||
slog.F("chat_id", chatID),
|
||||
slog.Error(err),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// resolveUserCompactionThreshold looks up the user's per-model
|
||||
// compaction threshold override. Returns the override value and
|
||||
// true if one exists and is valid, or 0 and false otherwise.
|
||||
|
||||
@@ -484,6 +484,32 @@ func TestPersistInstructionFilesIncludesAgentMetadata(t *testing.T) {
|
||||
agentID,
|
||||
).Return(workspaceAgent, nil).Times(1)
|
||||
db.EXPECT().InsertChatMessages(gomock.Any(), gomock.Any()).Return(nil, nil).AnyTimes()
|
||||
db.EXPECT().UpdateChatLastInjectedContext(gomock.Any(),
|
||||
gomock.Cond(func(x any) bool {
|
||||
arg, ok := x.(database.UpdateChatLastInjectedContextParams)
|
||||
if !ok || arg.ID != chat.ID {
|
||||
return false
|
||||
}
|
||||
if !arg.LastInjectedContext.Valid {
|
||||
return false
|
||||
}
|
||||
var parts []codersdk.ChatMessagePart
|
||||
if err := json.Unmarshal(arg.LastInjectedContext.RawMessage, &parts); err != nil {
|
||||
return false
|
||||
}
|
||||
// Expect at least one context-file part for the
|
||||
// working-directory AGENTS.md, with internal fields
|
||||
// stripped (no content, OS, or directory).
|
||||
for _, p := range parts {
|
||||
if p.Type == codersdk.ChatMessagePartTypeContextFile && p.ContextFilePath != "" {
|
||||
return p.ContextFileContent == "" &&
|
||||
p.ContextFileOS == "" &&
|
||||
p.ContextFileDirectory == ""
|
||||
}
|
||||
}
|
||||
return false
|
||||
}),
|
||||
).Return(database.Chat{}, nil).Times(1)
|
||||
|
||||
conn := agentconnmock.NewMockAgentConn(ctrl)
|
||||
conn.EXPECT().SetExtraHeaders(gomock.Any()).Times(1)
|
||||
@@ -569,6 +595,247 @@ func TestPersistInstructionFilesSkipsSentinelWhenWorkspaceUnavailable(t *testing
|
||||
require.Empty(t, instruction)
|
||||
}
|
||||
|
||||
func TestPersistInstructionFilesSentinelWithSkills(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
ctrl := gomock.NewController(t)
|
||||
db := dbmock.NewMockStore(ctrl)
|
||||
|
||||
workspaceID := uuid.New()
|
||||
agentID := uuid.New()
|
||||
chat := database.Chat{
|
||||
ID: uuid.New(),
|
||||
WorkspaceID: uuid.NullUUID{
|
||||
UUID: workspaceID,
|
||||
Valid: true,
|
||||
},
|
||||
AgentID: uuid.NullUUID{
|
||||
UUID: agentID,
|
||||
Valid: true,
|
||||
},
|
||||
}
|
||||
workspaceAgent := database.WorkspaceAgent{
|
||||
ID: agentID,
|
||||
OperatingSystem: "linux",
|
||||
Directory: "/home/coder/project",
|
||||
ExpandedDirectory: "/home/coder/project",
|
||||
}
|
||||
|
||||
db.EXPECT().GetWorkspaceAgentByID(
|
||||
gomock.Any(),
|
||||
agentID,
|
||||
).Return(workspaceAgent, nil).Times(1)
|
||||
db.EXPECT().InsertChatMessages(gomock.Any(), gomock.Any()).Return(nil, nil).AnyTimes()
|
||||
db.EXPECT().UpdateChatLastInjectedContext(gomock.Any(),
|
||||
gomock.Cond(func(x any) bool {
|
||||
arg, ok := x.(database.UpdateChatLastInjectedContextParams)
|
||||
if !ok || arg.ID != chat.ID {
|
||||
return false
|
||||
}
|
||||
if !arg.LastInjectedContext.Valid {
|
||||
return false
|
||||
}
|
||||
var parts []codersdk.ChatMessagePart
|
||||
if err := json.Unmarshal(arg.LastInjectedContext.RawMessage, &parts); err != nil {
|
||||
return false
|
||||
}
|
||||
// The sentinel path should persist only skill parts
|
||||
// with ContextFileAgentID set.
|
||||
for _, p := range parts {
|
||||
if p.Type == codersdk.ChatMessagePartTypeSkill &&
|
||||
p.SkillName == "my-skill" &&
|
||||
p.ContextFileAgentID == (uuid.NullUUID{UUID: agentID, Valid: true}) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}),
|
||||
).Return(database.Chat{}, nil).Times(1)
|
||||
|
||||
conn := agentconnmock.NewMockAgentConn(ctrl)
|
||||
conn.EXPECT().SetExtraHeaders(gomock.Any()).Times(1)
|
||||
|
||||
// Home LS (.coder directory): return 404 so no home
|
||||
// instruction file is found.
|
||||
conn.EXPECT().LS(gomock.Any(), "",
|
||||
gomock.Cond(func(x any) bool {
|
||||
req, ok := x.(workspacesdk.LSRequest)
|
||||
return ok && req.Relativity == workspacesdk.LSRelativityHome
|
||||
}),
|
||||
).Return(
|
||||
workspacesdk.LSResponse{},
|
||||
codersdk.NewTestError(404, "POST", "/api/v0/list-directory"),
|
||||
).Times(1)
|
||||
|
||||
// Pwd AGENTS.md: return 404 so no working-directory
|
||||
// instruction file is found either.
|
||||
conn.EXPECT().ReadFile(gomock.Any(),
|
||||
"/home/coder/project/AGENTS.md",
|
||||
int64(0),
|
||||
int64(maxInstructionFileBytes+1),
|
||||
).Return(
|
||||
nil, "",
|
||||
codersdk.NewTestError(404, "GET", "/api/v0/read-file"),
|
||||
).Times(1)
|
||||
|
||||
// Skills LS (.agents/skills directory): return one skill
|
||||
// directory so DiscoverSkills finds it.
|
||||
conn.EXPECT().LS(gomock.Any(), "",
|
||||
gomock.Cond(func(x any) bool {
|
||||
req, ok := x.(workspacesdk.LSRequest)
|
||||
return ok && req.Relativity == workspacesdk.LSRelativityRoot
|
||||
}),
|
||||
).Return(workspacesdk.LSResponse{
|
||||
Contents: []workspacesdk.LSFile{{
|
||||
Name: "my-skill",
|
||||
AbsolutePathString: "/home/coder/project/.agents/skills/my-skill",
|
||||
IsDir: true,
|
||||
}},
|
||||
}, nil).Times(1)
|
||||
|
||||
// Skills SKILL.md ReadFile: return valid frontmatter.
|
||||
skillContent := "---\nname: my-skill\ndescription: A test skill\n---\nSkill body"
|
||||
conn.EXPECT().ReadFile(gomock.Any(),
|
||||
"/home/coder/project/.agents/skills/my-skill/SKILL.md",
|
||||
int64(0),
|
||||
int64(64*1024+1),
|
||||
).Return(
|
||||
io.NopCloser(strings.NewReader(skillContent)),
|
||||
"",
|
||||
nil,
|
||||
).Times(1)
|
||||
|
||||
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
|
||||
server := &Server{
|
||||
db: db,
|
||||
logger: logger,
|
||||
agentConnFn: func(context.Context, uuid.UUID) (workspacesdk.AgentConn, func(), error) {
|
||||
return conn, func() {}, nil
|
||||
},
|
||||
}
|
||||
|
||||
chatStateMu := &sync.Mutex{}
|
||||
currentChat := chat
|
||||
workspaceCtx := turnWorkspaceContext{
|
||||
server: server,
|
||||
chatStateMu: chatStateMu,
|
||||
currentChat: ¤tChat,
|
||||
loadChatSnapshot: func(context.Context, uuid.UUID) (database.Chat, error) { return database.Chat{}, nil },
|
||||
}
|
||||
t.Cleanup(workspaceCtx.close)
|
||||
|
||||
instruction, skills, err := server.persistInstructionFiles(
|
||||
ctx,
|
||||
chat,
|
||||
uuid.New(),
|
||||
workspaceCtx.getWorkspaceAgent,
|
||||
workspaceCtx.getWorkspaceConn,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
// Sentinel path returns empty instruction string.
|
||||
require.Empty(t, instruction)
|
||||
// Skills are still discovered and returned.
|
||||
require.Len(t, skills, 1)
|
||||
require.Equal(t, "my-skill", skills[0].Name)
|
||||
}
|
||||
|
||||
func TestPersistInstructionFilesSentinelNoSkillsClearsColumn(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
ctrl := gomock.NewController(t)
|
||||
db := dbmock.NewMockStore(ctrl)
|
||||
|
||||
workspaceID := uuid.New()
|
||||
agentID := uuid.New()
|
||||
chat := database.Chat{
|
||||
ID: uuid.New(),
|
||||
WorkspaceID: uuid.NullUUID{
|
||||
UUID: workspaceID,
|
||||
Valid: true,
|
||||
},
|
||||
AgentID: uuid.NullUUID{
|
||||
UUID: agentID,
|
||||
Valid: true,
|
||||
},
|
||||
}
|
||||
workspaceAgent := database.WorkspaceAgent{
|
||||
ID: agentID,
|
||||
OperatingSystem: "linux",
|
||||
Directory: "/home/coder/project",
|
||||
ExpandedDirectory: "/home/coder/project",
|
||||
}
|
||||
|
||||
db.EXPECT().GetWorkspaceAgentByID(
|
||||
gomock.Any(),
|
||||
agentID,
|
||||
).Return(workspaceAgent, nil).Times(1)
|
||||
db.EXPECT().InsertChatMessages(gomock.Any(), gomock.Any()).Return(nil, nil).AnyTimes()
|
||||
db.EXPECT().UpdateChatLastInjectedContext(gomock.Any(),
|
||||
gomock.Cond(func(x any) bool {
|
||||
arg, ok := x.(database.UpdateChatLastInjectedContextParams)
|
||||
if !ok || arg.ID != chat.ID {
|
||||
return false
|
||||
}
|
||||
// No skills discovered, so the column should be
|
||||
// cleared to NULL.
|
||||
return !arg.LastInjectedContext.Valid
|
||||
}),
|
||||
).Return(database.Chat{}, nil).Times(1)
|
||||
|
||||
conn := agentconnmock.NewMockAgentConn(ctrl)
|
||||
conn.EXPECT().SetExtraHeaders(gomock.Any()).Times(1)
|
||||
|
||||
// All LS calls return 404: no home .coder directory and no
|
||||
// .agents/skills directory.
|
||||
conn.EXPECT().LS(gomock.Any(), "", gomock.Any()).Return(
|
||||
workspacesdk.LSResponse{},
|
||||
codersdk.NewTestError(404, "POST", "/api/v0/list-directory"),
|
||||
).AnyTimes()
|
||||
|
||||
// Pwd AGENTS.md: return 404.
|
||||
conn.EXPECT().ReadFile(gomock.Any(),
|
||||
"/home/coder/project/AGENTS.md",
|
||||
int64(0),
|
||||
int64(maxInstructionFileBytes+1),
|
||||
).Return(
|
||||
nil, "",
|
||||
codersdk.NewTestError(404, "GET", "/api/v0/read-file"),
|
||||
).Times(1)
|
||||
|
||||
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
|
||||
server := &Server{
|
||||
db: db,
|
||||
logger: logger,
|
||||
agentConnFn: func(context.Context, uuid.UUID) (workspacesdk.AgentConn, func(), error) {
|
||||
return conn, func() {}, nil
|
||||
},
|
||||
}
|
||||
|
||||
chatStateMu := &sync.Mutex{}
|
||||
currentChat := chat
|
||||
workspaceCtx := turnWorkspaceContext{
|
||||
server: server,
|
||||
chatStateMu: chatStateMu,
|
||||
currentChat: ¤tChat,
|
||||
loadChatSnapshot: func(context.Context, uuid.UUID) (database.Chat, error) { return database.Chat{}, nil },
|
||||
}
|
||||
t.Cleanup(workspaceCtx.close)
|
||||
|
||||
instruction, skills, err := server.persistInstructionFiles(
|
||||
ctx,
|
||||
chat,
|
||||
uuid.New(),
|
||||
workspaceCtx.getWorkspaceAgent,
|
||||
workspaceCtx.getWorkspaceConn,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
// Sentinel path: empty instruction, no skills.
|
||||
require.Empty(t, instruction)
|
||||
require.Empty(t, skills)
|
||||
}
|
||||
|
||||
func TestTurnWorkspaceContext_BindingFirstPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user