mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-26 20:45:37 +02:00
* feat(chat): add project context Co-authored-by: multica-agent <github@multica.ai> * fix(chat): resolve MUL-5150 review blockers - Renumber project-context migrations to unique prefixes after current main: 206_chat_session_project -> 212 (column), 207_chat_session_project_index -> 213 (concurrent index). 206/207 collided with 206_agent_disabled_runtime_skills and main's 207-211 client_usage_daily set. - Add the 4 missing chat input.project_context keys to ja/ko locales so the locale parity test passes (en/zh-Hans already had them). - Lock the project-context control while a send is in flight (isSubmitting), not just while the agent is running. A brand-new chat creates its session lazily during send bound to the project at click time; switching project mid-send would create the session against the stale project and clear the editor as if the send landed on the new selection. Add a regression test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> * fix(chat): complete project context handling * fix(chat): pin fresh chat to open session's agent on project switch Switching an existing session to a different project opens a fresh chat but only cleared the active session, dropping selection back to the stored `selectedAgentId`. When that preference was stale (open session belongs to agent B while the persisted pick is still agent A), the lazily-created session and its first send bound to the wrong agent (agent A). Extract the project-switch decision into a shared `planProjectContextChange` pure helper in use-chat-controller.ts and route both chat surfaces (the chat tab controller and the floating ChatWindow) through it, so the fresh chat is pinned to the open session's agent and the rule cannot drift between the two copies. Add a dual-entry regression test (pure-fn guard + controller integration) covering the stale selectedAgentId case. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> * chore(ci): re-trigger required checks on latest head The prior push updated the branch ref but GitHub did not emit a pull_request synchronize for it (PR head-sync lag), so CI/Mobile Verify never ran on the commit carrying the stale-agent project-switch fix. Empty commit to force a fresh synchronize on a head that includes it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> * fix(chat): renumber project migrations to 213/214 after main added 212 Current main added 212_agent_service_tier; the PR's 212/213 chat migrations collided with it on the merge ref, failing TestMigrationNumericPrefixesStay UniqueAfterLegacySet. Merge current main and move the chat column migration to 213 and the concurrent index migration to 214 (column before index preserved). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> * fix(chat): lock ProjectPicker clear control during send (keyboard path) The send-pending lock only put pointer-events-none on the wrapper, which blocks the mouse but leaves ProjectPicker's inline clear button in the tab order — a keyboard user could Tab to "Remove from project" and press Enter mid-send, detaching the project after the lazily-created session already went out with the old one (reopens the mid-send retarget path via keyboard). Add an explicit `disabled` capability to the shared ProjectPicker that locks the trigger, the menu (forced closed), and the inline clear button (disabled + out of the tab order). Defaults to false, so issue/create/autopilot callers keep their hover/keyboard clear. ChatInput passes disabled while the project selection is locked. Tests: real-ProjectPicker regression (keyboard activation of the clear control is inert when disabled; still works when enabled) + ChatInput wiring assertion that the picker is disabled mid-send. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Lambda <lambda@multica.ai> Co-authored-by: multica-agent <github@multica.ai> Co-authored-by: Walt <walt@multica.ai> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Co-authored-by: NevilleQingNY <nevilleqing@gmail.com>
373 lines
13 KiB
Go
373 lines
13 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
func createChatProjectTestProject(t *testing.T, workspaceID, title, description string) string {
|
|
t.Helper()
|
|
|
|
var projectID string
|
|
if err := testPool.QueryRow(context.Background(), `
|
|
INSERT INTO project (workspace_id, title, description)
|
|
VALUES ($1, $2, NULLIF($3, ''))
|
|
RETURNING id
|
|
`, workspaceID, title, description).Scan(&projectID); err != nil {
|
|
t.Fatalf("create project: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
testPool.Exec(context.Background(), `DELETE FROM project WHERE id = $1`, projectID)
|
|
})
|
|
return projectID
|
|
}
|
|
|
|
func createChatSessionWithProjectForTest(t *testing.T, agentID, projectID string) string {
|
|
t.Helper()
|
|
|
|
var sessionID string
|
|
if err := testPool.QueryRow(context.Background(), `
|
|
INSERT INTO chat_session (workspace_id, agent_id, creator_id, title, status, project_id)
|
|
VALUES ($1, $2, $3, 'Project context chat', 'active', $4)
|
|
RETURNING id
|
|
`, testWorkspaceID, agentID, testUserID, projectID).Scan(&sessionID); err != nil {
|
|
t.Fatalf("create chat session: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
testPool.Exec(context.Background(), `DELETE FROM chat_session WHERE id = $1`, sessionID)
|
|
})
|
|
return sessionID
|
|
}
|
|
|
|
func TestCreateChatSession_ProjectContext(t *testing.T) {
|
|
if testHandler == nil || testPool == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
agentID := createHandlerTestAgent(t, "ChatProjectContextAgent", []byte("[]"))
|
|
projectID := createChatProjectTestProject(t, testWorkspaceID, "Chat project", "Durable context")
|
|
|
|
t.Run("persists a workspace project", func(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
req := withChatTestWorkspaceCtx(t, newRequest(http.MethodPost, "/api/chat/sessions", map[string]any{
|
|
"agent_id": agentID,
|
|
"title": "with project",
|
|
"project_id": projectID,
|
|
}))
|
|
testHandler.CreateChatSession(w, req)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("CreateChatSession: expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var response ChatSessionResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
testPool.Exec(context.Background(), `DELETE FROM chat_session WHERE id = $1`, response.ID)
|
|
})
|
|
if response.ProjectID == nil || *response.ProjectID != projectID {
|
|
t.Fatalf("project_id = %v, want %s", response.ProjectID, projectID)
|
|
}
|
|
|
|
var storedProjectID *string
|
|
if err := testPool.QueryRow(context.Background(), `
|
|
SELECT project_id::text FROM chat_session WHERE id = $1
|
|
`, response.ID).Scan(&storedProjectID); err != nil {
|
|
t.Fatalf("load persisted project_id: %v", err)
|
|
}
|
|
if storedProjectID == nil || *storedProjectID != projectID {
|
|
t.Fatalf("stored project_id = %v, want %s", storedProjectID, projectID)
|
|
}
|
|
|
|
listW := httptest.NewRecorder()
|
|
listReq := withChatTestWorkspaceCtx(t, newRequest(http.MethodGet, "/api/chat/sessions", nil))
|
|
testHandler.ListChatSessions(listW, listReq)
|
|
if listW.Code != http.StatusOK {
|
|
t.Fatalf("ListChatSessions: expected 200, got %d: %s", listW.Code, listW.Body.String())
|
|
}
|
|
var sessions []ChatSessionResponse
|
|
if err := json.NewDecoder(listW.Body).Decode(&sessions); err != nil {
|
|
t.Fatalf("decode session list: %v", err)
|
|
}
|
|
found := false
|
|
for _, session := range sessions {
|
|
if session.ID != response.ID {
|
|
continue
|
|
}
|
|
found = true
|
|
if session.ProjectID == nil || *session.ProjectID != projectID {
|
|
t.Fatalf("listed project_id = %v, want %s", session.ProjectID, projectID)
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatalf("created session %s missing from list", response.ID)
|
|
}
|
|
})
|
|
|
|
t.Run("rejects malformed project id", func(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
req := withChatTestWorkspaceCtx(t, newRequest(http.MethodPost, "/api/chat/sessions", map[string]any{
|
|
"agent_id": agentID,
|
|
"project_id": "not-a-uuid",
|
|
}))
|
|
testHandler.CreateChatSession(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
})
|
|
|
|
t.Run("rejects a project from another workspace", func(t *testing.T) {
|
|
var foreignWorkspaceID string
|
|
if err := testPool.QueryRow(context.Background(), `
|
|
INSERT INTO workspace (name, slug) VALUES ('Foreign chat context', $1) RETURNING id
|
|
`, "chat-context-"+uuid.NewString()).Scan(&foreignWorkspaceID); err != nil {
|
|
t.Fatalf("create foreign workspace: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
testPool.Exec(context.Background(), `DELETE FROM workspace WHERE id = $1`, foreignWorkspaceID)
|
|
})
|
|
foreignProjectID := createChatProjectTestProject(t, foreignWorkspaceID, "Foreign project", "")
|
|
|
|
w := httptest.NewRecorder()
|
|
req := withChatTestWorkspaceCtx(t, newRequest(http.MethodPost, "/api/chat/sessions", map[string]any{
|
|
"agent_id": agentID,
|
|
"project_id": foreignProjectID,
|
|
}))
|
|
testHandler.CreateChatSession(w, req)
|
|
if w.Code != http.StatusNotFound {
|
|
t.Fatalf("expected 404, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
})
|
|
|
|
t.Run("keeps project optional", func(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
req := withChatTestWorkspaceCtx(t, newRequest(http.MethodPost, "/api/chat/sessions", map[string]any{
|
|
"agent_id": agentID,
|
|
"title": "workspace context only",
|
|
}))
|
|
testHandler.CreateChatSession(w, req)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("CreateChatSession: expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var response ChatSessionResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
testPool.Exec(context.Background(), `DELETE FROM chat_session WHERE id = $1`, response.ID)
|
|
})
|
|
if response.ProjectID != nil {
|
|
t.Fatalf("project_id = %v, want null", response.ProjectID)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestUpdateChatSession_UpdatesProjectContext(t *testing.T) {
|
|
if testHandler == nil || testPool == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
projectID := createChatProjectTestProject(t, testWorkspaceID, "Removable chat project", "")
|
|
replacementProjectID := createChatProjectTestProject(t, testWorkspaceID, "Replacement chat project", "")
|
|
agentID := createHandlerTestAgent(t, "RemoveChatProjectAgent", []byte("[]"))
|
|
sessionID := createChatSessionWithProjectForTest(t, agentID, projectID)
|
|
|
|
updateProject := func(projectID any) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
w := httptest.NewRecorder()
|
|
req := withURLParam(
|
|
withChatTestWorkspaceCtx(t, newRequest(http.MethodPatch, "/api/chat/sessions/"+sessionID, map[string]any{
|
|
"project_id": projectID,
|
|
})),
|
|
"sessionId",
|
|
sessionID,
|
|
)
|
|
testHandler.UpdateChatSession(w, req)
|
|
return w
|
|
}
|
|
|
|
w := updateProject(replacementProjectID)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("replace project: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var response ChatSessionResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if response.ID != sessionID {
|
|
t.Fatalf("session id = %q, want %q", response.ID, sessionID)
|
|
}
|
|
if response.ProjectID == nil || *response.ProjectID != replacementProjectID {
|
|
t.Fatalf("response project_id = %v, want %s", response.ProjectID, replacementProjectID)
|
|
}
|
|
|
|
var foreignWorkspaceID string
|
|
if err := testPool.QueryRow(context.Background(), `
|
|
INSERT INTO workspace (name, slug) VALUES ('Foreign chat update', $1) RETURNING id
|
|
`, "chat-update-"+uuid.NewString()).Scan(&foreignWorkspaceID); err != nil {
|
|
t.Fatalf("create foreign workspace: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
testPool.Exec(context.Background(), `DELETE FROM workspace WHERE id = $1`, foreignWorkspaceID)
|
|
})
|
|
foreignProjectID := createChatProjectTestProject(t, foreignWorkspaceID, "Foreign replacement", "")
|
|
foreignW := updateProject(foreignProjectID)
|
|
if foreignW.Code != http.StatusNotFound {
|
|
t.Fatalf("foreign project: expected 404, got %d: %s", foreignW.Code, foreignW.Body.String())
|
|
}
|
|
|
|
w = updateProject(nil)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("remove project: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
response = ChatSessionResponse{}
|
|
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
|
t.Fatalf("decode remove response: %v", err)
|
|
}
|
|
if response.ProjectID != nil {
|
|
t.Fatalf("response project_id = %v, want null", response.ProjectID)
|
|
}
|
|
|
|
var storedProjectID *string
|
|
if err := testPool.QueryRow(context.Background(), `
|
|
SELECT project_id::text FROM chat_session WHERE id = $1
|
|
`, sessionID).Scan(&storedProjectID); err != nil {
|
|
t.Fatalf("load updated chat session: %v", err)
|
|
}
|
|
if storedProjectID != nil {
|
|
t.Fatalf("stored project_id = %v, want null", storedProjectID)
|
|
}
|
|
}
|
|
|
|
func TestDeleteProject_ClearsChatSessionContext(t *testing.T) {
|
|
if testHandler == nil || testPool == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
projectID := createChatProjectTestProject(t, testWorkspaceID, "Deleted chat project", "")
|
|
agentID := createHandlerTestAgent(t, "DeletedChatProjectAgent", []byte("[]"))
|
|
sessionID := createChatSessionWithProjectForTest(t, agentID, projectID)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := withURLParam(
|
|
newRequest(http.MethodDelete, "/api/projects/"+projectID, nil),
|
|
"id",
|
|
projectID,
|
|
)
|
|
testHandler.DeleteProject(w, req)
|
|
if w.Code != http.StatusNoContent {
|
|
t.Fatalf("DeleteProject: expected 204, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var storedProjectID *string
|
|
if err := testPool.QueryRow(context.Background(), `
|
|
SELECT project_id::text FROM chat_session WHERE id = $1
|
|
`, sessionID).Scan(&storedProjectID); err != nil {
|
|
t.Fatalf("load chat session after project delete: %v", err)
|
|
}
|
|
if storedProjectID != nil {
|
|
t.Fatalf("project_id = %v after project delete, want null", storedProjectID)
|
|
}
|
|
}
|
|
|
|
func TestClaimTaskByRuntime_ChatProjectContext(t *testing.T) {
|
|
if testHandler == nil || testPool == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
ctx := context.Background()
|
|
setHandlerTestWorkspaceRepos(t, []map[string]string{
|
|
{"url": "https://github.com/example/workspace-fallback"},
|
|
})
|
|
projectID := createChatProjectTestProject(
|
|
t,
|
|
testWorkspaceID,
|
|
"Chat claim project",
|
|
"Use the project design system and release branch.",
|
|
)
|
|
const projectRepoURL = "https://github.com/example/chat-project-repo"
|
|
const projectRepoRef = "release/chat-context"
|
|
if _, err := testPool.Exec(ctx, `
|
|
INSERT INTO project_resource (
|
|
project_id, workspace_id, resource_type, resource_ref, label, position
|
|
) VALUES ($1, $2, 'github_repo', $3::jsonb, 'Chat repository', 0)
|
|
`, projectID, testWorkspaceID, `{"url":"`+projectRepoURL+`","ref":"`+projectRepoRef+`"}`); err != nil {
|
|
t.Fatalf("create project resource: %v", err)
|
|
}
|
|
|
|
agentID := createHandlerTestAgent(t, "ChatProjectClaimAgent", []byte("[]"))
|
|
runtimeID := handlerTestRuntimeID(t)
|
|
sessionID := createChatSessionWithProjectForTest(t, agentID, projectID)
|
|
if _, err := testPool.Exec(ctx, `
|
|
INSERT INTO chat_message (chat_session_id, role, content)
|
|
VALUES ($1, 'user', 'Plan the project release')
|
|
`, sessionID); err != nil {
|
|
t.Fatalf("create chat message: %v", err)
|
|
}
|
|
|
|
var taskID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO agent_task_queue (
|
|
agent_id, runtime_id, status, priority, chat_session_id
|
|
) VALUES ($1, $2, 'queued', 1000, $3)
|
|
RETURNING id
|
|
`, agentID, runtimeID, sessionID).Scan(&taskID); err != nil {
|
|
t.Fatalf("create chat task: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
testPool.Exec(context.Background(), `DELETE FROM agent_task_queue WHERE id = $1`, taskID)
|
|
})
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newDaemonTokenRequest(
|
|
http.MethodPost,
|
|
"/api/daemon/runtimes/"+runtimeID+"/claim",
|
|
nil,
|
|
testWorkspaceID,
|
|
"chat-project-context-test",
|
|
)
|
|
req = withURLParam(req, "runtimeId", runtimeID)
|
|
testHandler.ClaimTaskByRuntime(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("ClaimTaskByRuntime: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var response struct {
|
|
Task *struct {
|
|
ID string `json:"id"`
|
|
ProjectID string `json:"project_id"`
|
|
ProjectTitle string `json:"project_title"`
|
|
ProjectDescription string `json:"project_description"`
|
|
ProjectResources []ProjectResourceData `json:"project_resources"`
|
|
Repos []RepoData `json:"repos"`
|
|
} `json:"task"`
|
|
}
|
|
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if response.Task == nil || response.Task.ID != taskID {
|
|
t.Fatalf("claimed task = %+v, want %s", response.Task, taskID)
|
|
}
|
|
if response.Task.ProjectID != projectID {
|
|
t.Fatalf("project_id = %q, want %q", response.Task.ProjectID, projectID)
|
|
}
|
|
if response.Task.ProjectTitle != "Chat claim project" {
|
|
t.Fatalf("project_title = %q", response.Task.ProjectTitle)
|
|
}
|
|
if response.Task.ProjectDescription != "Use the project design system and release branch." {
|
|
t.Fatalf("project_description = %q", response.Task.ProjectDescription)
|
|
}
|
|
if len(response.Task.ProjectResources) != 1 || response.Task.ProjectResources[0].Label != "Chat repository" {
|
|
t.Fatalf("project_resources = %+v", response.Task.ProjectResources)
|
|
}
|
|
if len(response.Task.Repos) != 1 || response.Task.Repos[0].URL != projectRepoURL || response.Task.Repos[0].Ref != projectRepoRef {
|
|
t.Fatalf("repos = %+v, want project repo only", response.Task.Repos)
|
|
}
|
|
}
|