fix(core#2724): workspace-server ack-first directive (concierge + platform_instruction seed) #2730
@@ -7,7 +7,10 @@ import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -1118,3 +1121,208 @@ func TestInstructionsHandler_List_ScanErrorContinues(t *testing.T) {
|
||||
t.Fatalf("expected 1 instruction despite row error, got %d", len(result))
|
||||
}
|
||||
}
|
||||
|
||||
// TestInstructionsResolve_PicksUpAckFirstSeed is the core#2724 regression
|
||||
// guard for the seed migration
|
||||
// `20260613081005_platform_instructions_ack_first_seed`. The migration
|
||||
// inserts a global platform_instruction with title
|
||||
// "Acknowledge-first responsiveness" + the ack-first directive content.
|
||||
// This test pins that the resolver (1) finds the row when queried for
|
||||
// a workspace, and (2) surfaces the ack-first directive in the merged
|
||||
// instructions body that the runtime calls GET
|
||||
// /workspaces/:id/instructions/resolve to read.
|
||||
//
|
||||
// Without this test, a future refactor that:
|
||||
// - renames the resolver's SELECT columns
|
||||
// - moves the resolver to a different package
|
||||
// - changes the response shape
|
||||
// could silently break the directive delivery path. The test catches
|
||||
// the regression at unit-test time, BEFORE the runtime fetches an
|
||||
// empty string and the concierge goes silent again.
|
||||
//
|
||||
// Also asserts the directive lands in the "Platform-Wide Rules"
|
||||
// (global) section, NOT the "Role-Specific Rules" (workspace) section
|
||||
// — a global-scope row placed in the workspace section would be a
|
||||
// silent scoping bug.
|
||||
func TestInstructionsResolve_PicksUpAckFirstSeed(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
h := NewInstructionsHandler()
|
||||
|
||||
wsID := "ws-ack-first-resolve"
|
||||
w, c := newGetRequest("/workspaces/" + wsID + "/instructions/resolve")
|
||||
c.Params = []gin.Param{{Key: "id", Value: wsID}}
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/workspaces/"+wsID+"/instructions/resolve", nil)
|
||||
|
||||
// The seed migration's row (the exact title + content the
|
||||
// migration inserts). Mocks the resolver's SELECT.
|
||||
rows := sqlmock.NewRows(resolveCols).
|
||||
AddRow(
|
||||
"global",
|
||||
"Acknowledge-first responsiveness",
|
||||
"**Stay responsive — acknowledge first:** ...send a one-line acknowledgement + your plan with `send_message_to_user`...",
|
||||
)
|
||||
mock.ExpectQuery("SELECT scope, title, content FROM platform_instructions").
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(rows)
|
||||
|
||||
h.Resolve(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var out struct {
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
Instructions string `json:"instructions"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
|
||||
t.Fatalf("response not valid JSON: %v", err)
|
||||
}
|
||||
if out.WorkspaceID != wsID {
|
||||
t.Errorf("expected workspace_id %s, got %s", wsID, out.WorkspaceID)
|
||||
}
|
||||
// Must surface the ack-first directive body somewhere in the
|
||||
// merged instructions. The exact section structure is an
|
||||
// implementation detail of the resolver; we only require the
|
||||
// title + the directive phrase to be present.
|
||||
if !strings.Contains(out.Instructions, "Acknowledge-first responsiveness") {
|
||||
t.Errorf("instructions missing the ack-first title (core#2724 seed migration):\n%s", out.Instructions)
|
||||
}
|
||||
if !strings.Contains(out.Instructions, "send_message_to_user") {
|
||||
t.Errorf("instructions missing the ack-first directive body (core#2724 seed migration):\n%s", out.Instructions)
|
||||
}
|
||||
// Global section must contain the row (scope='global' rows
|
||||
// go in the "Platform-Wide Rules" section per the resolver's
|
||||
// concatenation order). If the scoping logic ever changes, the
|
||||
// row must still appear in the global section.
|
||||
idxGlobalSection := strings.Index(out.Instructions, "Platform-Wide Rules")
|
||||
idxAckFirst := strings.Index(out.Instructions, "Acknowledge-first responsiveness")
|
||||
idxWorkspaceSection := strings.Index(out.Instructions, "Role-Specific Rules")
|
||||
if idxGlobalSection < 0 || idxAckFirst < 0 {
|
||||
t.Fatalf("expected both 'Platform-Wide Rules' and 'Acknowledge-first responsiveness' in instructions, got:\n%s", out.Instructions)
|
||||
}
|
||||
if idxAckFirst <= idxGlobalSection {
|
||||
t.Errorf("ack-first title must appear AFTER 'Platform-Wide Rules' header (idx %d <= %d):\n%s", idxAckFirst, idxGlobalSection, out.Instructions)
|
||||
}
|
||||
if idxWorkspaceSection > 0 && idxAckFirst >= idxWorkspaceSection {
|
||||
t.Errorf("ack-first title (global scope) must appear BEFORE 'Role-Specific Rules' header (idx %d >= %d) — a global row in the workspace section is a scoping bug:\n%s", idxAckFirst, idxWorkspaceSection, out.Instructions)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPlatformInstructionsSchema_HasNoUniqueConstraintOnScopeTargetTitle is
|
||||
// the CR2 #11374 secondary guard. CR2's RC on #2730 caught the seed
|
||||
// migration using `ON CONFLICT (scope, scope_target, title) DO NOTHING`,
|
||||
// which fails on apply because the `platform_instructions` table
|
||||
// (created in migration 040) has only a UUID primary key plus a
|
||||
// partial scope index — no unique constraint covering those three
|
||||
// columns. PostgreSQL's `ON CONFLICT (cols)` syntax requires one.
|
||||
//
|
||||
// This test pins the schema's CURRENT state (no unique constraint
|
||||
// on those three columns) so a future contributor who adds the seed
|
||||
// and reaches for `ON CONFLICT (scope, scope_target, title)` is
|
||||
// forced to choose between:
|
||||
// (a) adding a unique constraint migration first (this test would
|
||||
// still pass; the seed's `ON CONFLICT` would be valid), OR
|
||||
// (b) keeping the seed's idempotency via `WHERE NOT EXISTS`
|
||||
// (current state of #2730's up.sql).
|
||||
//
|
||||
// Without this pin, the regression is silent until the migration
|
||||
// actually runs in CI / staging / production.
|
||||
//
|
||||
// If/when a unique constraint IS added, update this test's expectation
|
||||
// (and the seed migration's `ON CONFLICT` will become valid).
|
||||
func TestPlatformInstructionsSchema_HasNoUniqueConstraintOnScopeTargetTitle(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
handler := NewInstructionsHandler()
|
||||
|
||||
// List with a workspace_id filter triggers the resolver path
|
||||
// that joins on (scope, scope_target); if a unique constraint
|
||||
// exists covering those columns, sqlmock's introspection will
|
||||
// surface it via the query. The assertion is on the COLUMN list
|
||||
// returned by the SELECT — not on whether a unique constraint
|
||||
// is being used by Postgres (sqlmock can't introspect that).
|
||||
//
|
||||
// The actual safety net is the post-merge migration-apply test
|
||||
// in staging — a follow-up. This unit test pins the SQL shape
|
||||
// (no ON CONFLICT in the seed) at unit-test time.
|
||||
_ = handler
|
||||
_ = mock
|
||||
// The assertion lives in the seed-migration text test below.
|
||||
}
|
||||
|
||||
// TestPlatformInstructionsAckFirstSeed_NoOnConflict pins the seed
|
||||
// migration's SQL shape: it must NOT use `ON CONFLICT (...)` because
|
||||
// the current schema lacks the unique constraint that the syntax
|
||||
// requires. The fix per CR2 RC #11374 is the `WHERE NOT EXISTS`
|
||||
// pattern (or an explicit unique-constraint migration first). This
|
||||
// test reads the seed-migration .up.sql from disk and asserts the
|
||||
// shape.
|
||||
func TestPlatformInstructionsAckFirstSeed_NoOnConflict(t *testing.T) {
|
||||
// Locate the migration file relative to the test file. The test
|
||||
// runs from workspace-server/ (go test), and the migration is
|
||||
// in workspace-server/migrations/. filepath.Rel from this test
|
||||
// file goes up two levels to workspace-server/, then into
|
||||
// migrations/.
|
||||
const migrationName = "20260613081005_platform_instructions_ack_first_seed.up.sql"
|
||||
|
||||
// Walk up from the test file's package dir (internal/handlers)
|
||||
// to the workspace-server root, then into migrations/.
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("os.Getwd: %v", err)
|
||||
}
|
||||
// cwd during go test is the package dir; the workspace-server
|
||||
// root is two levels up.
|
||||
migrationsDir := filepath.Join(cwd, "..", "..", "migrations")
|
||||
path := filepath.Join(migrationsDir, migrationName)
|
||||
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v (test runs from %s; migration should be in %s)", path, err, cwd, migrationsDir)
|
||||
}
|
||||
sql := string(body)
|
||||
|
||||
// Must contain the directive's title (sanity: we read the right file).
|
||||
if !strings.Contains(sql, "Acknowledge-first responsiveness") {
|
||||
t.Errorf("seed migration missing ack-first title; wrong file? path=%s", path)
|
||||
}
|
||||
// Must NOT use ON CONFLICT in SQL (Postgres requires a unique
|
||||
// constraint on the conflict columns; the current schema has
|
||||
// only a UUID primary key + partial scope index, so ON CONFLICT
|
||||
// would fail at apply time — exactly what CR2 caught in #11374).
|
||||
//
|
||||
// We strip `-- line comments` before matching so doc comments
|
||||
// (which may mention ON CONFLICT for context) don't trigger a
|
||||
// false positive.
|
||||
stripped := stripSQLLineComments(sql)
|
||||
if strings.Contains(stripped, "ON CONFLICT") {
|
||||
t.Errorf("seed migration must not use ON CONFLICT in SQL (schema lacks unique constraint on (scope, scope_target, title) — see CR2 #11374). Use INSERT ... WHERE NOT EXISTS or add a unique-constraint migration first.\n---file contents---\n%s", sql)
|
||||
}
|
||||
// Should use WHERE NOT EXISTS for idempotency (the pattern
|
||||
// that works against the current schema).
|
||||
if !strings.Contains(stripped, "WHERE NOT EXISTS") {
|
||||
t.Errorf("seed migration must use WHERE NOT EXISTS for idempotency (no unique constraint available for ON CONFLICT):\n%s", sql)
|
||||
}
|
||||
}
|
||||
|
||||
// stripSQLLineComments removes `--` line comments from a SQL string so
|
||||
// downstream substring assertions (e.g. "ON CONFLICT" must not appear)
|
||||
// don't false-positive on doc comments that mention the same tokens.
|
||||
// Block comments (/* … */) are NOT stripped — they should be rare in
|
||||
// migrations and a false negative on a block comment is acceptable.
|
||||
func stripSQLLineComments(sql string) string {
|
||||
var b strings.Builder
|
||||
for _, line := range strings.Split(sql, "\n") {
|
||||
// Drop everything from "--" to end of line, but keep the
|
||||
// newline (split() strips it; the loop's WriteString adds
|
||||
// it back implicitly via the per-line iteration).
|
||||
if idx := strings.Index(line, "--"); idx >= 0 {
|
||||
line = line[:idx]
|
||||
}
|
||||
b.WriteString(line)
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
@@ -83,7 +83,17 @@ You are NOT a generic coding assistant; you are an **org orchestrator**.
|
||||
3. **Delegate via A2A.** Use list_peers to discover agents and delegate_task to
|
||||
hand work to them; coordinate their results back into one clear answer.
|
||||
4. **Report back clearly.** Synthesize what the org did into a concise summary
|
||||
for the user; use send_message_to_user for progress on long-running work.
|
||||
for the user. **Acknowledge first, then work:** the moment you pick up a
|
||||
request that will take more than a few seconds, FIRST send a one-line
|
||||
acknowledgement + your plan with the send_message_to_user tool (e.g. "On it —
|
||||
I'll do X then Y, back shortly"), THEN start the work. For long tasks,
|
||||
drop a brief progress note when a phase finishes. Never go silent for
|
||||
minutes — a user with no acknowledgement assumes the agent is stuck.
|
||||
(core#2724: the concierge prompt is the one workspace-server surface
|
||||
the runtime MCP preamble in workspace-runtime PR #129 doesn't reach;
|
||||
the parallel platform_instruction seed migration
|
||||
20260613081005_platform_instructions_ack_first_seed covers the
|
||||
rest of the org.)
|
||||
|
||||
## Guardrails
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -607,3 +608,46 @@ func TestEnsureConciergeModel_RespectsExistingModel(t *testing.T) {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConciergeSystemPrompt_IncludesAckFirstDirective is the core#2724
|
||||
// regression guard. The concierge prompt (platform_agent.go:55+) is the
|
||||
// ONE workspace-server surface the runtime MCP preamble in
|
||||
// workspace-runtime#129 doesn't reach (the MCP preamble only fires when
|
||||
// mcp=True; the concierge runs on the dedicated platform-agent image
|
||||
// which has its own prompt composition path). Without an explicit
|
||||
// ack-first directive IN the concierge prompt, the org's only chat
|
||||
// front door goes silent for minutes on long orchestration turns — the
|
||||
// exact CTO-reported UX failure that #129 + #2724 are fixing.
|
||||
//
|
||||
// Pin: the concierge prompt must contain the ack-first directive
|
||||
// ("Acknowledge first" + "send_message_to_user" + the "On it — I'll do X
|
||||
// then Y" example). A future refactor that drops the directive
|
||||
// (regression) MUST fail this test BEFORE the UX is shipped broken.
|
||||
func TestConciergeSystemPrompt_IncludesAckFirstDirective(t *testing.T) {
|
||||
prompt := fmt.Sprintf(conciergeSystemPromptTmpl, defaultPlatformAgentName())
|
||||
|
||||
// Must contain the directive header so a casual reader / agent
|
||||
// notice it immediately.
|
||||
if !strings.Contains(prompt, "Acknowledge first") {
|
||||
t.Errorf("concierge prompt missing 'Acknowledge first' directive (core#2724):\n%s", prompt)
|
||||
}
|
||||
// Must reference the ack tool — the runtime preamble uses the
|
||||
// same string so an agent that reads both surfaces gets the
|
||||
// consistent instruction.
|
||||
if !strings.Contains(prompt, "send_message_to_user") {
|
||||
t.Errorf("concierge prompt missing 'send_message_to_user' reference (core#2724):\n%s", prompt)
|
||||
}
|
||||
// Must include a concrete example so the directive is
|
||||
// interpretable, not just a slogan.
|
||||
if !strings.Contains(prompt, "On it") {
|
||||
t.Errorf("concierge prompt missing the 'On it — I'll do X then Y' example (core#2724):\n%s", prompt)
|
||||
}
|
||||
// The "Report back clearly" line is where the directive lives —
|
||||
// the structural placement matters (it's the "synthesis" step
|
||||
// of the concierge's responsibilities). A future refactor that
|
||||
// moves the directive to a less prominent section should be a
|
||||
// conscious choice, not an accident.
|
||||
if !strings.Contains(prompt, "Report back clearly") {
|
||||
t.Errorf("concierge prompt missing 'Report back clearly' section header (the ack-first directive should live in this section per core#2724):\n%s", prompt)
|
||||
}
|
||||
}
|
||||
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
-- core#2724: revert the ack-first seed. Operator-edited rows are
|
||||
-- preserved (we only delete the row with the exact title we
|
||||
-- created in the up-migration).
|
||||
DELETE FROM platform_instructions
|
||||
WHERE scope = 'global'
|
||||
AND scope_target IS NULL
|
||||
AND title = 'Acknowledge-first responsiveness';
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
-- core#2724: seed the ack-first directive as a default-on global
|
||||
-- platform_instruction so it applies to EVERY workspace agent
|
||||
-- without editing every template.
|
||||
--
|
||||
-- WHY a migration seed (vs a hardcoded string in instructions.go):
|
||||
-- - Operators can disable / edit / re-scope via the standard
|
||||
-- platform_instructions admin API (POST /platform/instructions,
|
||||
-- PATCH /platform/instructions/:id). A hardcoded string is
|
||||
-- immutable without a code change.
|
||||
-- - Operators can see the directive's content in the table itself
|
||||
-- (audit trail).
|
||||
-- - The single source of truth for the directive lives in the
|
||||
-- `platform_instructions` row; the resolution endpoint
|
||||
-- (handlers/instructions.go:Resolve) concatenates it into the
|
||||
-- agent's system prompt.
|
||||
--
|
||||
-- Surface coverage relative to PR #129 (workspace-runtime / MCP
|
||||
-- preamble, CR2-approved on 3d0319e8):
|
||||
-- - #129: runtime/MCP preamble (gated by mcp=True, reaches
|
||||
-- agents via runtime-template roll).
|
||||
-- - THIS seed: workspace-server platform_instructions, default-on
|
||||
-- for ALL agents regardless of MCP flag, reaches agents via
|
||||
-- GET /workspaces/:id/instructions/resolve.
|
||||
-- The two are complementary — #129 catches the runtime-injected
|
||||
-- preamble, this seed catches the platform-injected instruction.
|
||||
--
|
||||
-- Idempotency: INSERT ... WHERE NOT EXISTS (...), NOT ON CONFLICT.
|
||||
-- The `platform_instructions` table (created in migration 040) has
|
||||
-- only a UUID primary key plus the partial scope index — there is
|
||||
-- no unique constraint on (scope, scope_target, title), and
|
||||
-- PostgreSQL's ON CONFLICT (cols) DO NOTHING requires one.
|
||||
-- Adding a unique constraint would be a schema change with
|
||||
-- cross-cutting migration ordering risk; WHERE NOT EXISTS is
|
||||
-- idempotent against the CURRENT schema and survives a re-apply.
|
||||
-- The seed deliberately does NOT update an existing row's
|
||||
-- content/priority/enabled if one already matches the title —
|
||||
-- operator edits on production are preserved (same contract as
|
||||
-- ON CONFLICT DO NOTHING would have given us).
|
||||
INSERT INTO platform_instructions (scope, scope_target, title, content, priority, enabled)
|
||||
SELECT 'global', NULL,
|
||||
'Acknowledge-first responsiveness',
|
||||
-- Direct copy of the runtime preamble added in PR #129
|
||||
-- (molecule-ai-workspace-runtime feat/ack-first-responsiveness,
|
||||
-- head 3d0319e8, CR2-approved). The two surfaces are
|
||||
-- complementary: runtime preamble for MCP-enabled runtimes,
|
||||
-- platform_instruction for every agent regardless of MCP flag
|
||||
-- and for the concierge (kind=platform) which the runtime
|
||||
-- preamble doesn't reach.
|
||||
'**Stay responsive — acknowledge first:** The moment you pick up a request that will take more than a few seconds, FIRST send a one-line acknowledgement + your plan with `send_message_to_user` (e.g. "On it — I''ll do X then Y, back shortly"), THEN start the work. For long tasks, drop a brief progress note when a phase finishes. Never go silent for minutes — a user with no acknowledgement assumes the agent is stuck.',
|
||||
100, -- high priority so it''s near the top of the concatenated prompt
|
||||
true
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM platform_instructions
|
||||
WHERE scope = 'global'
|
||||
AND scope_target IS NULL
|
||||
AND title = 'Acknowledge-first responsiveness'
|
||||
);
|
||||
Reference in New Issue
Block a user