mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-29 10:32:36 +02:00
Compare commits
2 Commits
agent/lamb
...
agent/j/ed
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc250f9a17 | ||
|
|
0f32615f91 |
@@ -2239,27 +2239,28 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string, slot i
|
||||
// Repos are passed as metadata only — the agent checks them out on demand
|
||||
// via `multica repo checkout <url>`.
|
||||
taskCtx := execenv.TaskContextForEnv{
|
||||
IssueID: task.IssueID,
|
||||
TriggerCommentID: task.TriggerCommentID,
|
||||
AgentID: agentID,
|
||||
AgentName: agentName,
|
||||
AgentInstructions: instructions,
|
||||
AgentSkills: convertSkillsForEnv(skills),
|
||||
Repos: convertReposForEnv(task.Repos),
|
||||
ProjectID: task.ProjectID,
|
||||
ProjectTitle: task.ProjectTitle,
|
||||
ProjectResources: convertProjectResourcesForEnv(task.ProjectResources),
|
||||
ChatSessionID: task.ChatSessionID,
|
||||
AutopilotRunID: task.AutopilotRunID,
|
||||
AutopilotID: task.AutopilotID,
|
||||
AutopilotTitle: task.AutopilotTitle,
|
||||
AutopilotDescription: task.AutopilotDescription,
|
||||
AutopilotSource: task.AutopilotSource,
|
||||
AutopilotTriggerPayload: strings.TrimSpace(string(task.AutopilotTriggerPayload)),
|
||||
QuickCreatePrompt: task.QuickCreatePrompt,
|
||||
IsSquadLeader: strings.Contains(instructions, "## Squad Operating Protocol"),
|
||||
IssueID: task.IssueID,
|
||||
TriggerCommentID: task.TriggerCommentID,
|
||||
AgentID: agentID,
|
||||
AgentName: agentName,
|
||||
AgentInstructions: instructions,
|
||||
AgentSkills: convertSkillsForEnv(skills),
|
||||
Repos: convertReposForEnv(task.Repos),
|
||||
ProjectID: task.ProjectID,
|
||||
ProjectTitle: task.ProjectTitle,
|
||||
ProjectResources: convertProjectResourcesForEnv(task.ProjectResources),
|
||||
ChatSessionID: task.ChatSessionID,
|
||||
AutopilotRunID: task.AutopilotRunID,
|
||||
AutopilotID: task.AutopilotID,
|
||||
AutopilotTitle: task.AutopilotTitle,
|
||||
AutopilotDescription: task.AutopilotDescription,
|
||||
AutopilotSource: task.AutopilotSource,
|
||||
AutopilotTriggerPayload: strings.TrimSpace(string(task.AutopilotTriggerPayload)),
|
||||
QuickCreatePrompt: task.QuickCreatePrompt,
|
||||
IsSquadLeader: strings.Contains(instructions, "## Squad Operating Protocol"),
|
||||
RequestingUserName: task.RequestingUserName,
|
||||
RequestingUserProfileDescription: task.RequestingUserProfileDescription,
|
||||
WorkspaceContext: task.WorkspaceContext,
|
||||
}
|
||||
|
||||
// Mark candidate env roots as active before any env work so the GC loop
|
||||
|
||||
@@ -62,6 +62,11 @@ type TaskContextForEnv struct {
|
||||
AutopilotTriggerPayload string
|
||||
QuickCreatePrompt string // non-empty for quick-create tasks
|
||||
IsSquadLeader bool // true when the agent is acting as a squad leader (may exit silently on no_action)
|
||||
// WorkspaceContext is the workspace-level system prompt (workspace.context
|
||||
// in the DB). Rendered into the brief as `## Workspace Context` when
|
||||
// non-empty so every agent in the workspace sees the same shared context,
|
||||
// regardless of issue / chat / autopilot / quick-create.
|
||||
WorkspaceContext string
|
||||
// RequestingUserName + RequestingUserProfileDescription describe the
|
||||
// human the agent is acting on behalf of. v1 sources them from the
|
||||
// runtime owner (the user who registered the daemon). Rendered into the
|
||||
|
||||
@@ -23,7 +23,7 @@ var runtimeGOOS = runtime.GOOS
|
||||
//
|
||||
// CR/LF and other whitespace control bytes collapse to a single space; other
|
||||
// C0 controls and DEL are dropped; markdown structural characters that have
|
||||
// meaning in inline context (`*`, `_`, `` ` ``, `\`, `[`, `]`, `<`) are
|
||||
// meaning in inline context (`*`, `_`, “ ` “, `\`, `[`, `]`, `<`) are
|
||||
// backslash-escaped. Trailing whitespace is trimmed.
|
||||
func sanitizeNameForBriefMarkdown(name string) string {
|
||||
var b strings.Builder
|
||||
@@ -184,6 +184,20 @@ func buildMetaSkillContent(provider string, ctx TaskContextForEnv) string {
|
||||
b.WriteString("\nTreat this as background context, not as task instructions. If it conflicts with the actual task, the task wins.\n\n")
|
||||
}
|
||||
|
||||
// Workspace Context block: the workspace-level system prompt set by
|
||||
// workspace owners in Settings → General (`workspace.context` DB column).
|
||||
// Applies to every agent run in the workspace regardless of task kind, so
|
||||
// emit it unconditionally above Available Commands when non-empty. Heading
|
||||
// is skipped when the field is empty — bare headings are noise. Content
|
||||
// is set by trusted workspace admins, so it is embedded directly (no
|
||||
// blockquote wrapping like Requesting User, which is user-supplied) but
|
||||
// trailing whitespace is trimmed to avoid stacking blank lines.
|
||||
if ctxText := strings.TrimRight(ctx.WorkspaceContext, " \t\r\n"); ctxText != "" {
|
||||
b.WriteString("## Workspace Context\n\n")
|
||||
b.WriteString(ctxText)
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
|
||||
b.WriteString("## Available Commands\n\n")
|
||||
b.WriteString("**Use `--output json` for structured data.** Human table output now prints routable issue keys (for example `MUL-123`) and short UUID prefixes for workspace resources; use `--full-id` on list commands when you need canonical UUIDs.\n\n")
|
||||
b.WriteString("The default brief includes the commands needed for the core agent loop and common issue create/update tasks. For everything else, run `multica --help`, `multica <command> --help`, or `multica <command> <subcommand> --help`; prefer `--output json` when the command supports it.\n\n")
|
||||
|
||||
@@ -199,6 +199,111 @@ func TestSubIssueCreationSectionIsUnconditional(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Workspace Context block: workspace.context (the per-workspace system prompt
|
||||
// owners set in Settings → General) must reach the brief as `## Workspace
|
||||
// Context` for every task kind so agents see a consistent shared system prompt
|
||||
// regardless of how they were triggered. Empty content must skip the heading
|
||||
// entirely — bare headings would just add noise.
|
||||
func TestWorkspaceContextRenderedAcrossTaskKinds(t *testing.T) {
|
||||
t.Parallel()
|
||||
const wsContext = "All comments must be in English. Prefer concise PR descriptions."
|
||||
cases := []struct {
|
||||
name string
|
||||
ctx TaskContextForEnv
|
||||
}{
|
||||
{
|
||||
name: "assignment-triggered",
|
||||
ctx: TaskContextForEnv{
|
||||
IssueID: "11111111-2222-3333-4444-555555555555",
|
||||
WorkspaceContext: wsContext,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "comment-triggered",
|
||||
ctx: TaskContextForEnv{
|
||||
IssueID: "22222222-3333-4444-5555-666666666666",
|
||||
TriggerCommentID: "33333333-4444-5555-6666-777777777777",
|
||||
WorkspaceContext: wsContext,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "chat",
|
||||
ctx: TaskContextForEnv{
|
||||
ChatSessionID: "chat-1",
|
||||
WorkspaceContext: wsContext,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "quick-create",
|
||||
ctx: TaskContextForEnv{
|
||||
QuickCreatePrompt: "create me an issue",
|
||||
WorkspaceContext: wsContext,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "autopilot run-only",
|
||||
ctx: TaskContextForEnv{
|
||||
AutopilotRunID: "run-1",
|
||||
WorkspaceContext: wsContext,
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
out := buildMetaSkillContent("claude", tc.ctx)
|
||||
|
||||
if !strings.Contains(out, "## Workspace Context") {
|
||||
t.Fatalf("[%s] expected `## Workspace Context` heading", tc.name)
|
||||
}
|
||||
if !strings.Contains(out, wsContext) {
|
||||
t.Errorf("[%s] brief missing workspace context body %q", tc.name, wsContext)
|
||||
}
|
||||
// The block must precede Available Commands so it acts as
|
||||
// background framing, not a footer hidden below CLI usage.
|
||||
ctxIdx := strings.Index(out, "## Workspace Context")
|
||||
cmdsIdx := strings.Index(out, "## Available Commands")
|
||||
if ctxIdx == -1 || cmdsIdx == -1 || ctxIdx > cmdsIdx {
|
||||
t.Errorf("[%s] `## Workspace Context` must appear above `## Available Commands` (ctx=%d, cmds=%d)", tc.name, ctxIdx, cmdsIdx)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkspaceContextHeadingSkippedWhenEmpty(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
ctx TaskContextForEnv
|
||||
}{
|
||||
{
|
||||
name: "empty string",
|
||||
ctx: TaskContextForEnv{
|
||||
IssueID: "11111111-2222-3333-4444-555555555555",
|
||||
WorkspaceContext: "",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "whitespace only",
|
||||
ctx: TaskContextForEnv{
|
||||
IssueID: "11111111-2222-3333-4444-555555555555",
|
||||
WorkspaceContext: " \n\t \r\n",
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
out := buildMetaSkillContent("claude", tc.ctx)
|
||||
if strings.Contains(out, "## Workspace Context") {
|
||||
t.Errorf("[%s] empty workspace context must NOT emit the heading", tc.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubIssueCreationSectionSkippedForNonIssueModes(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
|
||||
@@ -33,34 +33,39 @@ type ProjectResourceData struct {
|
||||
// Task represents a claimed task from the server.
|
||||
// Agent data (name, skills) is populated by the claim endpoint.
|
||||
type Task struct {
|
||||
ID string `json:"id"`
|
||||
AgentID string `json:"agent_id"`
|
||||
RuntimeID string `json:"runtime_id"`
|
||||
IssueID string `json:"issue_id"`
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
Agent *AgentData `json:"agent,omitempty"`
|
||||
ID string `json:"id"`
|
||||
AgentID string `json:"agent_id"`
|
||||
RuntimeID string `json:"runtime_id"`
|
||||
IssueID string `json:"issue_id"`
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
// WorkspaceContext mirrors workspace.context (the per-workspace system
|
||||
// prompt set in Settings → General). Server populates this on every claim
|
||||
// regardless of task kind so the daemon can inject `## Workspace Context`
|
||||
// into the brief. Empty when the owner hasn't set one.
|
||||
WorkspaceContext string `json:"workspace_context,omitempty"`
|
||||
Agent *AgentData `json:"agent,omitempty"`
|
||||
Repos []RepoData `json:"repos,omitempty"`
|
||||
ProjectID string `json:"project_id,omitempty"` // issue's project, when present
|
||||
ProjectTitle string `json:"project_title,omitempty"` // human-readable project title for context injection
|
||||
ProjectResources []ProjectResourceData `json:"project_resources,omitempty"` // project-scoped resources to expose to the agent
|
||||
PriorSessionID string `json:"prior_session_id,omitempty"` // Claude session ID from a previous task on this issue
|
||||
PriorWorkDir string `json:"prior_work_dir,omitempty"` // work_dir from a previous task on this issue
|
||||
TriggerCommentID string `json:"trigger_comment_id,omitempty"` // comment that triggered this task
|
||||
TriggerCommentContent string `json:"trigger_comment_content,omitempty"` // content of the triggering comment
|
||||
TriggerAuthorType string `json:"trigger_author_type,omitempty"` // "agent" or "member" — author kind for the triggering comment
|
||||
TriggerAuthorName string `json:"trigger_author_name,omitempty"` // display name of the triggering comment author
|
||||
ChatSessionID string `json:"chat_session_id,omitempty"` // non-empty for chat tasks
|
||||
ChatMessage string `json:"chat_message,omitempty"` // user message content for chat tasks
|
||||
ChatMessageAttachments []ChatAttachmentMeta `json:"chat_message_attachments,omitempty"` // attachments linked to the chat message; agent uses these to `multica attachment download <id>`
|
||||
AutopilotRunID string `json:"autopilot_run_id,omitempty"` // non-empty for autopilot run_only tasks
|
||||
AutopilotID string `json:"autopilot_id,omitempty"` // autopilot that spawned this run
|
||||
AutopilotTitle string `json:"autopilot_title,omitempty"` // autopilot title used as task context
|
||||
AutopilotDescription string `json:"autopilot_description,omitempty"` // autopilot description used as task prompt
|
||||
AutopilotSource string `json:"autopilot_source,omitempty"` // manual, schedule, webhook, or api
|
||||
AutopilotTriggerPayload json.RawMessage `json:"autopilot_trigger_payload,omitempty"` // optional trigger payload for webhook/api runs
|
||||
QuickCreatePrompt string `json:"quick_create_prompt,omitempty"` // user's natural-language input for quick-create tasks
|
||||
SquadID string `json:"squad_id,omitempty"` // when the picker was a squad, the squad's UUID; Agent is still the resolved leader
|
||||
SquadName string `json:"squad_name,omitempty"` // display name for the picker squad, used in prompt text
|
||||
ProjectID string `json:"project_id,omitempty"` // issue's project, when present
|
||||
ProjectTitle string `json:"project_title,omitempty"` // human-readable project title for context injection
|
||||
ProjectResources []ProjectResourceData `json:"project_resources,omitempty"` // project-scoped resources to expose to the agent
|
||||
PriorSessionID string `json:"prior_session_id,omitempty"` // Claude session ID from a previous task on this issue
|
||||
PriorWorkDir string `json:"prior_work_dir,omitempty"` // work_dir from a previous task on this issue
|
||||
TriggerCommentID string `json:"trigger_comment_id,omitempty"` // comment that triggered this task
|
||||
TriggerCommentContent string `json:"trigger_comment_content,omitempty"` // content of the triggering comment
|
||||
TriggerAuthorType string `json:"trigger_author_type,omitempty"` // "agent" or "member" — author kind for the triggering comment
|
||||
TriggerAuthorName string `json:"trigger_author_name,omitempty"` // display name of the triggering comment author
|
||||
ChatSessionID string `json:"chat_session_id,omitempty"` // non-empty for chat tasks
|
||||
ChatMessage string `json:"chat_message,omitempty"` // user message content for chat tasks
|
||||
ChatMessageAttachments []ChatAttachmentMeta `json:"chat_message_attachments,omitempty"` // attachments linked to the chat message; agent uses these to `multica attachment download <id>`
|
||||
AutopilotRunID string `json:"autopilot_run_id,omitempty"` // non-empty for autopilot run_only tasks
|
||||
AutopilotID string `json:"autopilot_id,omitempty"` // autopilot that spawned this run
|
||||
AutopilotTitle string `json:"autopilot_title,omitempty"` // autopilot title used as task context
|
||||
AutopilotDescription string `json:"autopilot_description,omitempty"` // autopilot description used as task prompt
|
||||
AutopilotSource string `json:"autopilot_source,omitempty"` // manual, schedule, webhook, or api
|
||||
AutopilotTriggerPayload json.RawMessage `json:"autopilot_trigger_payload,omitempty"` // optional trigger payload for webhook/api runs
|
||||
QuickCreatePrompt string `json:"quick_create_prompt,omitempty"` // user's natural-language input for quick-create tasks
|
||||
SquadID string `json:"squad_id,omitempty"` // when the picker was a squad, the squad's UUID; Agent is still the resolved leader
|
||||
SquadName string `json:"squad_name,omitempty"` // display name for the picker squad, used in prompt text
|
||||
// RequestingUserName + RequestingUserProfileDescription describe the human
|
||||
// the agent is working on behalf of. v1 sources them from the runtime
|
||||
// owner (the user who registered the daemon). Empty when the runtime has
|
||||
|
||||
@@ -141,11 +141,17 @@ type ProjectResourceData struct {
|
||||
}
|
||||
|
||||
type AgentTaskResponse struct {
|
||||
ID string `json:"id"`
|
||||
AgentID string `json:"agent_id"`
|
||||
RuntimeID string `json:"runtime_id"`
|
||||
IssueID string `json:"issue_id"`
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
ID string `json:"id"`
|
||||
AgentID string `json:"agent_id"`
|
||||
RuntimeID string `json:"runtime_id"`
|
||||
IssueID string `json:"issue_id"`
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
// WorkspaceContext is the workspace-level system prompt set in workspace
|
||||
// settings (`workspace.context` DB column). Injected into the agent brief
|
||||
// as `## Workspace Context` so every agent running in this workspace —
|
||||
// regardless of issue / chat / autopilot / quick-create — sees the same
|
||||
// shared context. Empty when the workspace owner hasn't set it.
|
||||
WorkspaceContext string `json:"workspace_context,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Priority int32 `json:"priority"`
|
||||
DispatchedAt *string `json:"dispatched_at"`
|
||||
|
||||
@@ -161,7 +161,7 @@ type DaemonRegisterRequest struct {
|
||||
DeviceName string `json:"device_name"`
|
||||
CLIVersion string `json:"cli_version"` // multica CLI version
|
||||
LaunchedBy string `json:"launched_by"` // "desktop" when spawned by the Electron app
|
||||
Runtimes []struct {
|
||||
Runtimes []struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Version string `json:"version"` // agent CLI version (claude/codex)
|
||||
@@ -1509,6 +1509,24 @@ func (h *Handler) ClaimTaskByRuntime(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Workspace-level Context (workspace.context DB column) — the per-workspace
|
||||
// system prompt that workspace owners set in Settings → General. Inject it
|
||||
// into the brief regardless of task kind (issue / chat / autopilot /
|
||||
// quick-create) so every agent running in the workspace sees the same
|
||||
// shared context. Empty string when the owner hasn't set one; the daemon
|
||||
// skips rendering the heading in that case.
|
||||
if ws, err := h.Queries.GetWorkspace(r.Context(), parseUUID(resp.WorkspaceID)); err == nil {
|
||||
if ws.Context.Valid {
|
||||
resp.WorkspaceContext = ws.Context.String
|
||||
}
|
||||
} else {
|
||||
slog.Warn("task claim: failed to load workspace for context injection",
|
||||
"task_id", uuidToString(task.ID),
|
||||
"workspace_id", resp.WorkspaceID,
|
||||
"error", err,
|
||||
)
|
||||
}
|
||||
|
||||
slog.Info("task claimed by runtime", "task_id", uuidToString(task.ID), "runtime_id", runtimeID, "agent_id", uuidToString(task.AgentID), "prior_session", resp.PriorSessionID)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"task": resp})
|
||||
}
|
||||
|
||||
@@ -307,6 +307,122 @@ func TestClaimTaskByRuntime_DoesNotReclaimDifferentRuntimeTask(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestClaimTaskByRuntime_PopulatesWorkspaceContext verifies the claim
|
||||
// response carries workspace.context so the daemon can inject the
|
||||
// workspace-level system prompt into every agent brief. Regression coverage
|
||||
// for MUL-2542: before this fix the field was never plumbed through, so
|
||||
// even workspaces that had set a context got an empty brief.
|
||||
func TestClaimTaskByRuntime_PopulatesWorkspaceContext(t *testing.T) {
|
||||
if testHandler == nil || testPool == nil {
|
||||
t.Skip("database not available")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
const wsContext = "All comments must be in English. Prefer concise PR descriptions."
|
||||
var prior string
|
||||
if err := testPool.QueryRow(ctx, `SELECT COALESCE(context, '') FROM workspace WHERE id = $1`, testWorkspaceID).Scan(&prior); err != nil {
|
||||
t.Fatalf("read workspace.context: %v", err)
|
||||
}
|
||||
if _, err := testPool.Exec(ctx, `UPDATE workspace SET context = $1 WHERE id = $2`, wsContext, testWorkspaceID); err != nil {
|
||||
t.Fatalf("set workspace.context: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if prior == "" {
|
||||
testPool.Exec(ctx, `UPDATE workspace SET context = NULL WHERE id = $1`, testWorkspaceID)
|
||||
} else {
|
||||
testPool.Exec(ctx, `UPDATE workspace SET context = $1 WHERE id = $2`, prior, testWorkspaceID)
|
||||
}
|
||||
})
|
||||
|
||||
runtimeID := createClaimReclaimRuntime(t, ctx, "Workspace context claim runtime")
|
||||
agentID, issueID := createClaimReclaimAgentAndIssue(t, ctx, runtimeID, "Workspace context claim agent")
|
||||
taskID := createDispatchedClaimFixtureTask(t, ctx, agentID, runtimeID, issueID, "120 seconds", false)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := newDaemonTokenRequest("POST", "/api/daemon/runtimes/"+runtimeID+"/tasks/claim", nil,
|
||||
testWorkspaceID, "workspace-context-claim")
|
||||
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 resp struct {
|
||||
Task *struct {
|
||||
ID string `json:"id"`
|
||||
WorkspaceContext string `json:"workspace_context"`
|
||||
} `json:"task"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode claim response: %v", err)
|
||||
}
|
||||
if resp.Task == nil {
|
||||
t.Fatalf("expected dispatched task %s to be claimed, got nil response: %s", taskID, w.Body.String())
|
||||
}
|
||||
if resp.Task.ID != taskID {
|
||||
t.Fatalf("claimed task id = %s, want %s", resp.Task.ID, taskID)
|
||||
}
|
||||
if resp.Task.WorkspaceContext != wsContext {
|
||||
t.Errorf("workspace_context = %q, want %q", resp.Task.WorkspaceContext, wsContext)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClaimTaskByRuntime_WorkspaceContextEmptyWhenUnset verifies the field
|
||||
// is omitted (empty string after JSON decode) when the workspace owner has
|
||||
// not set a context. Important because the daemon's brief skips the heading
|
||||
// only on empty input — a stray "context: null" coming back as the string
|
||||
// "null" would render as a bogus paragraph.
|
||||
func TestClaimTaskByRuntime_WorkspaceContextEmptyWhenUnset(t *testing.T) {
|
||||
if testHandler == nil || testPool == nil {
|
||||
t.Skip("database not available")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
var prior string
|
||||
if err := testPool.QueryRow(ctx, `SELECT COALESCE(context, '') FROM workspace WHERE id = $1`, testWorkspaceID).Scan(&prior); err != nil {
|
||||
t.Fatalf("read workspace.context: %v", err)
|
||||
}
|
||||
if _, err := testPool.Exec(ctx, `UPDATE workspace SET context = NULL WHERE id = $1`, testWorkspaceID); err != nil {
|
||||
t.Fatalf("clear workspace.context: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if prior == "" {
|
||||
testPool.Exec(ctx, `UPDATE workspace SET context = NULL WHERE id = $1`, testWorkspaceID)
|
||||
} else {
|
||||
testPool.Exec(ctx, `UPDATE workspace SET context = $1 WHERE id = $2`, prior, testWorkspaceID)
|
||||
}
|
||||
})
|
||||
|
||||
runtimeID := createClaimReclaimRuntime(t, ctx, "Workspace context empty claim runtime")
|
||||
agentID, issueID := createClaimReclaimAgentAndIssue(t, ctx, runtimeID, "Workspace context empty claim agent")
|
||||
taskID := createDispatchedClaimFixtureTask(t, ctx, agentID, runtimeID, issueID, "120 seconds", false)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := newDaemonTokenRequest("POST", "/api/daemon/runtimes/"+runtimeID+"/tasks/claim", nil,
|
||||
testWorkspaceID, "workspace-context-empty-claim")
|
||||
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 resp struct {
|
||||
Task *struct {
|
||||
ID string `json:"id"`
|
||||
WorkspaceContext string `json:"workspace_context"`
|
||||
} `json:"task"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode claim response: %v", err)
|
||||
}
|
||||
if resp.Task == nil {
|
||||
t.Fatalf("expected dispatched task %s to be claimed, got nil: %s", taskID, w.Body.String())
|
||||
}
|
||||
if resp.Task.WorkspaceContext != "" {
|
||||
t.Errorf("workspace_context = %q, want empty string when workspace.context is NULL", resp.Task.WorkspaceContext)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaemonRegister_WithDaemonToken(t *testing.T) {
|
||||
if testHandler == nil {
|
||||
t.Skip("database not available")
|
||||
|
||||
Reference in New Issue
Block a user