Files
multica/server/internal/handler/onboarding_test.go
Naiyuan Qing fbd965e5bf feat(onboarding): v3 — thin server, frontend-orchestrated welcome (#3008)
* feat(onboarding): Multica Helper as general workspace assistant + blocking modal

Reshape Multica Helper from an onboarding-only guide into the workspace's
general-purpose AI assistant. The agent's permanent identity (injected as
`## Agent Identity` into every task's CLAUDE.md / AGENTS.md / GEMINI.md
via execenv.InjectRuntimeConfig) is rewritten to three sections that don't
overlap with what the brief already provides:

  - Who I am (built-in workspace assistant, not onboarding-only)
  - What Multica is + docs/source/issues URLs as knowledge sources
  - What I can do (CLI = manifest, `multica --help` is the source of truth)
  - Tone (concise, like a colleague, match user's language)

Bootstrap moves out of the in-flow Step 4. Runtime step now exits the
onboarding shell with no bootstrap call; a blocking OnboardingHelperModal
mounts inside the workspace layout (web + desktop) and gates purely on
`me.onboarded_at == null`. The user picks one of three starter prompts
(intro / assign / second_agent) and the modal calls
BootstrapOnboardingRuntime with a new optional `starter_prompt` field that
becomes the seeded onboarding issue's description.

Side effects required to make `onboarded_at == null` an honest signal:

  - CreateWorkspace no longer marks onboarded (was atomic with CreateMember).
    The "member exists ⟹ onboarded_at != null" invariant is intentionally
    broken; guards (useDashboardGuard / desktop App.tsx) already tolerate
    this — comments updated to reflect the new contract.
  - AcceptInvitation still marks (invitee skips the modal in someone
    else's workspace). Code comment added warning future removers.
  - resolvePostAuthDestination flips to workspace-presence-first: a user
    with a workspace lands in it regardless of `onboarded_at`, so the
    modal can pick up an interrupted setup on relogin.

Other backend changes:
  - `onboardingAssistantDescription` rewritten ("Built-in workspace assistant…")
  - `onboardingAssistantInstructions` rewritten to the 3-section identity
  - `bootstrapOnboardingRuntimeRequest.StarterPrompt` (optional, 2 KiB rune
    cap, empty-falls-back-to onboardingIssueDescription)

Frontend changes:
  - Delete `packages/views/onboarding/steps/step-teammate.tsx` (no longer a
    persisted step)
  - `ONBOARDING_STEP_ORDER` and `OnboardingStep` type drop `"teammate"`
  - `handleRuntimeNext` exits via `onComplete(workspace, undefined)` — no
    bootstrap, `onboarded_at` stays NULL so the modal fires
  - Runtime step next-button copy → "Start exploring" / "开始探索"
  - New `packages/views/workspace/onboarding-helper-modal.tsx`:
    Base UI Dialog, dismissible=false, three localized cards, mutation
    invalidates agents + issues queries then navigates to the seeded issue
  - Mounted in both `apps/web/app/[workspaceSlug]/layout.tsx` and
    `apps/desktop/src/renderer/src/components/workspace-route-layout.tsx`

Tests:
  - Backend: TestBootstrapOnboardingRuntime_{With,No}StarterPrompt and
    TestCreateWorkspace_DoesNotMarkOnboarded
  - Frontend: onboarding-helper-modal.test.tsx covers all four gating
    conditions, three-card behavior, mutation pending state, and the
    "no close button" invariant

Compatibility:
  - Already-onboarded users: zero impact (modal can't fire)
  - Invitees: AcceptInvitation still marks → modal can't fire
  - Skip-runtime path: BootstrapOnboardingNoRuntime still marks → modal can't fire
  - Old desktop / web clients: legacy teammate-step path keeps working
    (bootstrap accepts missing starter_prompt) — the new modal only fires
    on the new frontend bundle
  - Avatar SVG kept (asterisk variant) — no migration of existing Helper
    agents, only newly-created Helpers pick up the new instructions/description

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(desktop): suppress OnboardingHelperModal while a WindowOverlay is open

On desktop, App.tsx auto-creates a tab pointing at the user's first
workspace as soon as workspaces.length flips from 0 → 1 (during onboarding
Step 2). The new tab mounts WorkspaceRouteLayout under the overlay,
which mounts OnboardingHelperModal. The modal's Portal renders to
document.body — appearing AFTER the WindowOverlay in DOM order, so its
z-50 wins and the modal floats in front of the still-active onboarding
Step 3 (runtime).

Suppress the modal whenever any WindowOverlay is active. When the overlay
closes (onComplete fires after the user finishes onboarding), the modal
re-evaluates `me.onboarded_at == null` and pops on its own.

Web is unaffected (onboarding flow lives at /onboarding, not under
/[workspaceSlug]/, so WorkspaceRouteLayout never mounts during the
onboarding flow).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(onboarding): add v2 refactor plan

Captures the design + 8-step implementation order for collapsing the
onboarding state machine: single mark-onboarded entry point, persisted
Step 3 user choice, dumb Modal, single install-runtime seed call site.
Includes old-user compatibility analysis (4 existing gates) and per-PR
risk/rollback.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(db): persist Step 3 runtime choice on user record (MUL-onboarding-v2)

Adds onboarding_runtime_id UUID NULL + onboarding_runtime_skipped BOOLEAN
columns to "user" and the CHECK constraint enforcing the 3-state machine
(unset / picked-runtime / explicit-skip; the fourth combination is
forbidden). ON DELETE SET NULL on the FK so a deleted runtime degrades
to "unset" rather than dangling.

PatchUserOnboarding gains the two narg fields plus CASE expressions that
collapse the runtime/skipped pair atomically — a follow-up PATCH that
flips one side now clears the other in the same statement, instead of
preserving it via per-field COALESCE and tripping the CHECK constraint.

Backwards compatible for existing users: both new fields default to
(NULL, false), which is the "unset" leaf of the state machine, and four
upstream gates on me.onboarded_at != null already short-circuit the
new fields' readers for everyone who's already onboarded.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(server): collapse onboarding side effects to service layer

Introduces OnboardingService.MarkComplete and
WorkspaceContentService.{Ensure,Seed}InstallRuntimeIssue as the single
authorities for the two onboarding side effects that used to be
duplicated across four handlers:

  - MarkUserOnboarded + claim starter_content_state +
    optional install-runtime fallback seed: was inline in
    BootstrapOnboardingRuntime, BootstrapOnboardingNoRuntime,
    AcceptInvitation, and CompleteOnboarding.
  - install-runtime issue seeding: was inline in CreateWorkspace and
    AcceptInvitation as a "no runtime yet" fallback.

After this refactor:
  - MarkUserOnboarded is called from exactly one place (the service).
  - install-runtime issue is seeded from exactly one place (the service).
  - CreateWorkspace deliberately does not seed — the new
    /ensure-onboarding-content endpoint (also added here) lets the
    workspace-entry init component request the seed on first mount, so
    workspaces created but never opened don't accumulate stale issues.
  - The PatchOnboarding handler now accepts the new runtime_id /
    runtime_skipped fields and rejects (uuid, skipped=true) up front.
  - UserResponse exposes the two new persisted fields so the frontend
    can read them off `me` without an extra round-trip.

Handler-side tests added: TestPatchOnboarding_RuntimeChoiceSwitch (the
explicit cross-request switch path that the original COALESCE design
would have 500'd on) + TestPatchOnboarding_PreserveUntouched.

Old handler-local file no_runtime_issue.go is deleted; its content
moved to service/workspace_content.go with the helpers exported.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(core): API + types for persisted onboarding runtime choice

User type / Zod schema gain onboarding_runtime_id (string | null) and
onboarding_runtime_skipped (boolean); EMPTY_USER + test fixture updated
to match. api.patchOnboarding accepts the new optional fields and the
new api.ensureOnboardingContent endpoint is wired so the workspace
shell can request the fallback seed.

Two new store helpers — recordOnboardingRuntimeChoice(runtimeId) and
recordOnboardingRuntimeSkipped() — replace the prior pattern of
Step 3 calling bootstrap directly. They PATCH the user's choice, sync
the auth store, and return. Mutually exclusive on the server side via
the CHECK constraint; the client just ships one intent at a time.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(workspace): WorkspaceOnboardingInit single decision point + dumb Modal

Replaces OnboardingHelperModal's self-gating render path with a 4-branch
dispatcher that runs once on workspace-shell mount:

  branch 0  me.onboarded_at != null         → ensure install-runtime issue
                                              fallback, render nothing
  branch 1  me.onboarding_runtime_skipped   → SkipBootstrapping component:
                                              loading veil → bootstrap →
                                              navigate. On failure shows
                                              a Retry UI instead of
                                              silently freezing the veil
  branch 2  me.onboarding_runtime_id        → render Modal with the
                                              runtime id from `me` (no
                                              internal list query)
  branch 3  (none of the above)             → useEffect navigate back to
                                              /onboarding so the user
                                              walks Step 3 again

The Modal itself is now a dumb component — receives `workspace` and
`runtimeId` as props, no internal gates, no runtimeListOptions query.
Tests rewritten to cover the props-driven render + pick-card paths;
the prior gating tests move into the new
workspace-onboarding-init.test.tsx alongside the M2 retry-on-failure
behaviour.

Mounted in both apps/web/app/[workspaceSlug]/layout.tsx and the desktop
workspace-route-layout. Desktop keeps its `!overlayActive` suppression
guard so the init doesn't portal-jump in front of an active
WindowOverlay.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(onboarding): Step 3 records user choice instead of calling bootstrap

handleRuntimeNext now PATCHes the user's pick (recordOnboardingRuntime
{Choice,Skipped}) and navigates straight into the workspace shell. The
workspace-entry WorkspaceOnboardingInit reads the persisted choice off
`me` and runs the appropriate branch — Step 3 is pure intent capture
with zero side effects on its own.

PATCH must succeed before navigation: if it fails the user stays on
Step 3 with a toast, because navigating with no persisted intent would
land them in WorkspaceOnboardingInit's branch 3 "no decision yet" rescue
and trigger a redirect loop back to /onboarding.

The prior asymmetry (Connect deferred bootstrap to the workspace, Skip
ran bootstrap inline) is gone — both paths defer to the workspace
shell now.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(onboarding): v3 — thin server, frontend-orchestrated welcome

Collapse v2's persisted runtime-choice fields + 4-branch dispatcher +
OnboardingService/WorkspaceContentService stack down to a single rule:
`onboarded_at` is the only state field, layout hard-gates on it, and the
welcome experience after Step 3 is owned entirely by the frontend.

V3 flow
- Step 3 button: await POST /api/me/onboarding/complete (mark only) +
  park a transient signal in `useWelcomeStore` + navigate
- Workspace layout: hard gate `onboarded_at == null` -> /onboarding
- `<WelcomeAfterOnboarding />` reads the welcome-store signal:
  - runtime path: find-or-create Multica Helper via generic createAgent
    with bilingual instructions from `templates/helper-instructions.ts`,
    blocking modal with 3 starter cards, pick -> createIssue + navigate
  - skip path: provision install-runtime (in_progress) -> agent-guide
    (todo, body embeds install-runtime mention chip) -> follow-up comment
    on install-runtime mentioning agent-guide; then pop celebration
    modal with 🎉 emoji pop animation, 2 read-only preview cards, single
    [Got it] CTA that navigates to install-runtime

Server cleanup
- Drop OnboardingService, WorkspaceContentService, v2 runtime-choice
  columns/CHECK on user, EnsureOnboardingContent endpoint
- CompleteOnboarding/AcceptInvitation call qtx.MarkUserOnboarded
  directly (no service indirection)
- BootstrapOnboardingRuntime / BootstrapOnboardingNoRuntime kept as a
  deprecation shim in onboarding_shim.go for desktop < v3 during the
  rollout window — handlers inlined to qtx.* calls, no service layer

Localization
- Persisted strings (issue titles/bodies, Helper instructions/
  description, comment prefix) live as TS const `{en, zh}` maps in
  `packages/views/onboarding/templates/` — i18n bundle staleness can no
  longer write raw key paths into DB
- UI-rendered strings (modal copy, status chips, buttons) stay in
  `packages/views/locales/{en,zh-Hans}/onboarding.json`
- Language picked from live `i18n.language` (not `me.language`, which is
  null for new users until they pick a preference)

Race protection
- Module-level promise dedupe (`findOrCreateHelper`, `seedIssueDeduped`,
  `postCommentDeduped`) so React StrictMode double-mount can't fire two
  parallel API calls that the server would then 409

Cross-references between the two skip-path issues render via Multica's
mention-chip protocol `[<identifier>](mention://issue/<uuid>)` so they
match the styled IssueChip pills used elsewhere.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(onboarding): welcome-after-onboarding modal redesign + cross-user safety

Welcome modal polish (the post-Step-3 surface this branch already
introduced):

Runtime path
- Helper avatar replaces the bouncy 🎉 hero; tone-down animation to
  fade. New copy: "Hi, welcome to Multica / I'm your first Agent
  assistant" + capability hint sentence so users discover assignment +
  chat from the first screen.
- Cards changed from "click = submit" to multi-select with the existing
  border-primary + ring selection pattern used by compact-runtime-row;
  bottom CTA "Assign N tasks to me →" appears only with N>0.
- New starter cards: intro / tour / welcome_page (the last one tells
  Helper to paste an HTML welcome page into the issue comment — works
  on any runtime regardless of fs access).
- Success state added between createIssue and navigation: 🎉 +
  "All set!" + "Sit tight  — your {agentName} is on it" + inbox/chat
  hints, single [Got it] button.
- Title/prompt for starter cards now live in TS const
  HELPER_STARTER_PROMPTS (persisted to DB — must not depend on i18n
  bundle being loaded); subtitle stays in onboarding.json.

Skip path
- Body restructured into three independent ```md blocks (Name /
  Description / Instructions) so each picks up the markdown renderer's
  per-block copy button — no manual extraction.
- ZH body now embeds the ZH Helper Description + Instructions (was
  Chinese-around-English-block).
- Follow-up comment uses Multica's mention-chip protocol
  [identifier](mention://issue/uuid) so it renders as the styled
  IssueChip pill.
- Issue titles bilingual with "Step 1 / Step 2" prefix.

Cross-user / cross-workspace safety (code review feedback)
- web onLogout + desktop handleDaemonLogout now call
  useWelcomeStore.reset() so user B logging into the same browser
  doesn't inherit user A's signal.
- WelcomeAfterOnboarding gates on
  currentWorkspace.id === signal.workspaceId — prevents firing the
  modal in workspace B when the signal was parked for workspace A
  (desktop multi-tab, back/forward, deep-link).
- Module-level promise dedupes (pendingHelperSetup,
  pendingIssueSeed, pendingCommentSeed) for the three API calls so
  React 18+ StrictMode dev double-mount can't race-create duplicates.

Other small fixes carried in this commit
- Helper instructions / agent description / starter card titles all
  read i18n.language (not me.language, which is null for new users
  who haven't picked a UI language preference yet).
- Reverted welcome-emoji-pop animation to a small fade for the runtime
  avatar (kept the bouncy variant for the skip 🎉 hero where the
  celebration is the whole point).
- Removed the duplicate 🎉 from the skip modal title (kept the hero
  one only).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(views): i18n hardcoded "Close" in welcome FullScreenError

CI lint (i18next/no-literal-string) blocked on a literal "Close" string
inside `FullScreenError` — surfaced as a nit in the original code
review but missed in the merge. Add `error_close` to onboarding.json
(EN: "Close" / ZH: "关闭") and thread it through as a `closeLabel`
prop, matching the existing `retryLabel` plumbing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 19:00:26 +08:00

665 lines
21 KiB
Go

package handler
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// newWaitlistTestUser inserts a fresh user row, returns its id, and
// registers a cleanup. Uses the test pool directly so we don't depend
// on the handler-under-test for fixture setup.
func newWaitlistTestUser(t *testing.T, email string) string {
t.Helper()
ctx := context.Background()
var userID string
if err := testPool.QueryRow(ctx,
`INSERT INTO "user" (name, email) VALUES ($1, $2) RETURNING id`,
"Waitlist Test", email,
).Scan(&userID); err != nil {
t.Fatalf("insert test user: %v", err)
}
t.Cleanup(func() {
testPool.Exec(ctx, `DELETE FROM "user" WHERE id = $1`, userID)
})
return userID
}
func newWaitlistRequest(userID string, body map[string]string) *http.Request {
var buf bytes.Buffer
if body != nil {
json.NewEncoder(&buf).Encode(body)
}
req := httptest.NewRequest(
"POST", "/api/me/onboarding/cloud-waitlist", &buf,
)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-User-ID", userID)
return req
}
func TestJoinCloudWaitlistRecordsEmailAndReason(t *testing.T) {
userID := newWaitlistTestUser(t, "waitlist-ok@multica.ai")
w := httptest.NewRecorder()
req := newWaitlistRequest(userID, map[string]string{
"email": "Someone@Example.COM",
"reason": "evaluating for our team",
})
testHandler.JoinCloudWaitlist(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var (
waitlistEmail *string
waitlistReason *string
onboardedAt *time.Time
)
if err := testPool.QueryRow(context.Background(), `
SELECT cloud_waitlist_email, cloud_waitlist_reason, onboarded_at
FROM "user" WHERE id = $1
`, userID).Scan(&waitlistEmail, &waitlistReason, &onboardedAt); err != nil {
t.Fatalf("lookup user: %v", err)
}
if waitlistEmail == nil || *waitlistEmail != "someone@example.com" {
t.Fatalf("email not normalized/stored: %v", waitlistEmail)
}
if waitlistReason == nil || *waitlistReason != "evaluating for our team" {
t.Fatalf("reason not stored: %v", waitlistReason)
}
// Waitlist is a pure side effect — onboarding is NOT marked
// complete here. The user still has to pick a real Step 3
// path (CLI / Skip) before onboarded_at gets set.
if onboardedAt != nil {
t.Fatalf("onboarded_at should stay NULL, got %v", *onboardedAt)
}
}
func TestJoinCloudWaitlistAllowsEmptyReason(t *testing.T) {
userID := newWaitlistTestUser(t, "waitlist-noreason@multica.ai")
w := httptest.NewRecorder()
req := newWaitlistRequest(userID, map[string]string{
"email": "noreason@example.com",
})
testHandler.JoinCloudWaitlist(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var waitlistReason *string
if err := testPool.QueryRow(context.Background(),
`SELECT cloud_waitlist_reason FROM "user" WHERE id = $1`, userID,
).Scan(&waitlistReason); err != nil {
t.Fatalf("lookup user: %v", err)
}
if waitlistReason != nil {
t.Fatalf("expected NULL reason, got %q", *waitlistReason)
}
}
func TestJoinCloudWaitlistMissingEmailReturns400(t *testing.T) {
userID := newWaitlistTestUser(t, "waitlist-missing@multica.ai")
cases := []map[string]string{
{}, // empty body
{"email": ""}, // blank
{"email": " "}, // whitespace only
{"reason": "no email here"},
}
for i, body := range cases {
w := httptest.NewRecorder()
req := newWaitlistRequest(userID, body)
testHandler.JoinCloudWaitlist(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("case %d: expected 400, got %d: %s", i, w.Code, w.Body.String())
}
}
}
func TestJoinCloudWaitlistRejectsOverlongReason(t *testing.T) {
userID := newWaitlistTestUser(t, "waitlist-long@multica.ai")
w := httptest.NewRecorder()
req := newWaitlistRequest(userID, map[string]string{
"email": "long@example.com",
"reason": strings.Repeat("x", cloudWaitlistReasonMaxLen+1),
})
testHandler.JoinCloudWaitlist(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for overlong reason, got %d", w.Code)
}
}
func TestJoinCloudWaitlistSecondCallOverwrites(t *testing.T) {
userID := newWaitlistTestUser(t, "waitlist-overwrite@multica.ai")
// First submission.
w := httptest.NewRecorder()
req := newWaitlistRequest(userID, map[string]string{
"email": "first@example.com",
"reason": "first",
})
testHandler.JoinCloudWaitlist(w, req)
if w.Code != http.StatusOK {
t.Fatalf("first call: expected 200, got %d", w.Code)
}
// Second submission with different values.
w = httptest.NewRecorder()
req = newWaitlistRequest(userID, map[string]string{
"email": "second@example.com",
"reason": "changed my mind",
})
testHandler.JoinCloudWaitlist(w, req)
if w.Code != http.StatusOK {
t.Fatalf("second call: expected 200, got %d", w.Code)
}
var (
waitlistEmail string
waitlistReason string
onboardedAt *time.Time
)
if err := testPool.QueryRow(context.Background(), `
SELECT cloud_waitlist_email, cloud_waitlist_reason, onboarded_at
FROM "user" WHERE id = $1
`, userID).Scan(&waitlistEmail, &waitlistReason, &onboardedAt); err != nil {
t.Fatalf("lookup user: %v", err)
}
if waitlistEmail != "second@example.com" {
t.Fatalf("second email should overwrite; got %q", waitlistEmail)
}
if waitlistReason != "changed my mind" {
t.Fatalf("second reason should overwrite; got %q", waitlistReason)
}
// onboarded_at is never touched by the waitlist path — stays NULL
// across any number of submissions.
if onboardedAt != nil {
t.Fatalf("onboarded_at should stay NULL, got %v", *onboardedAt)
}
}
// ---------------------------------------------------------------------------
// Shim endpoint tests — guard the BootstrapOnboarding* handlers that were
// restored for desktop < v3 compatibility. Once telemetry confirms no
// pre-v3 desktops remain, delete both these tests AND the handlers in
// onboarding_shim.go in the same commit.
// ---------------------------------------------------------------------------
func TestBootstrapOnboardingRuntimeCreatesSingleGuideIssue(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
t.Cleanup(func() {
testPool.Exec(ctx, `
DELETE FROM agent_task_queue
WHERE agent_id IN (
SELECT id FROM agent
WHERE workspace_id = $1 AND name = $2
)
`, testWorkspaceID, onboardingAssistantName)
testPool.Exec(ctx,
`DELETE FROM issue WHERE workspace_id = $1 AND title = $2`,
testWorkspaceID, onboardingIssueTitle,
)
testPool.Exec(ctx,
`DELETE FROM agent WHERE workspace_id = $1 AND name = $2`,
testWorkspaceID, onboardingAssistantName,
)
testPool.Exec(ctx,
`UPDATE "user" SET onboarded_at = NULL, starter_content_state = NULL WHERE id = $1`,
testUserID,
)
})
testPool.Exec(ctx,
`DELETE FROM issue WHERE workspace_id = $1 AND title = $2`,
testWorkspaceID, onboardingIssueTitle,
)
testPool.Exec(ctx,
`DELETE FROM agent WHERE workspace_id = $1 AND name = $2`,
testWorkspaceID, onboardingAssistantName,
)
testPool.Exec(ctx,
`UPDATE "user" SET onboarded_at = NULL, starter_content_state = NULL WHERE id = $1`,
testUserID,
)
body := map[string]string{
"workspace_id": testWorkspaceID,
"runtime_id": testRuntimeID,
}
w := httptest.NewRecorder()
testHandler.BootstrapOnboardingRuntime(w, newRequest(http.MethodPost, "/api/me/onboarding/runtime-bootstrap", body))
if w.Code != http.StatusOK {
t.Fatalf("BootstrapOnboardingRuntime: expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp bootstrapOnboardingRuntimeResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if resp.WorkspaceID != testWorkspaceID || resp.AgentID == "" || resp.IssueID == "" {
t.Fatalf("unexpected response: %+v", resp)
}
var (
agentName string
agentRuntime string
instructions string
avatarURL *string
)
if err := testPool.QueryRow(ctx, `
SELECT name, runtime_id, instructions, avatar_url
FROM agent
WHERE id = $1
`, resp.AgentID).Scan(&agentName, &agentRuntime, &instructions, &avatarURL); err != nil {
t.Fatalf("lookup assistant: %v", err)
}
if agentName != onboardingAssistantName {
t.Fatalf("agent name = %q, want %q", agentName, onboardingAssistantName)
}
if agentRuntime != testRuntimeID {
t.Fatalf("agent runtime = %q, want %q", agentRuntime, testRuntimeID)
}
if !strings.Contains(instructions, "built-in AI assistant") {
t.Fatalf("assistant instructions were not seeded with the new identity: %q", instructions)
}
if avatarURL == nil || *avatarURL != onboardingAssistantAvatarURL {
t.Fatalf("agent avatar_url = %v, want seeded Multica Helper avatar", avatarURL)
}
var (
issueTitle string
assigneeType string
assigneeID string
issueStatus string
issuePriority string
)
if err := testPool.QueryRow(ctx, `
SELECT title, assignee_type, assignee_id, status, priority
FROM issue
WHERE id = $1
`, resp.IssueID).Scan(&issueTitle, &assigneeType, &assigneeID, &issueStatus, &issuePriority); err != nil {
t.Fatalf("lookup onboarding issue: %v", err)
}
if issueTitle != onboardingIssueTitle {
t.Fatalf("issue title = %q, want %q", issueTitle, onboardingIssueTitle)
}
if assigneeType != "agent" || assigneeID != resp.AgentID {
t.Fatalf("issue assignee = %s/%s, want agent/%s", assigneeType, assigneeID, resp.AgentID)
}
if issueStatus != "todo" || issuePriority != "high" {
t.Fatalf("issue status/priority = %s/%s, want todo/high", issueStatus, issuePriority)
}
var (
onboardedAt *time.Time
starterContentState *string
)
if err := testPool.QueryRow(ctx, `
SELECT onboarded_at, starter_content_state
FROM "user"
WHERE id = $1
`, testUserID).Scan(&onboardedAt, &starterContentState); err != nil {
t.Fatalf("lookup user onboarding state: %v", err)
}
if onboardedAt == nil {
t.Fatal("expected onboarded_at to be set")
}
if starterContentState == nil || *starterContentState != "imported" {
t.Fatalf("starter_content_state = %v, want imported", starterContentState)
}
var taskCount int
if err := testPool.QueryRow(ctx, `
SELECT count(*)
FROM agent_task_queue
WHERE issue_id = $1 AND agent_id = $2
`, resp.IssueID, resp.AgentID).Scan(&taskCount); err != nil {
t.Fatalf("count queued tasks: %v", err)
}
if taskCount == 0 {
t.Fatal("expected onboarding issue to enqueue an agent task")
}
w2 := httptest.NewRecorder()
testHandler.BootstrapOnboardingRuntime(w2, newRequest(http.MethodPost, "/api/me/onboarding/runtime-bootstrap", body))
if w2.Code != http.StatusOK {
t.Fatalf("second BootstrapOnboardingRuntime: expected 200, got %d: %s", w2.Code, w2.Body.String())
}
var resp2 bootstrapOnboardingRuntimeResponse
if err := json.NewDecoder(w2.Body).Decode(&resp2); err != nil {
t.Fatalf("decode second response: %v", err)
}
if resp2.AgentID != resp.AgentID || resp2.IssueID != resp.IssueID {
t.Fatalf("bootstrap should be idempotent: first=%+v second=%+v", resp, resp2)
}
}
func TestBootstrapOnboardingRuntime_WithStarterPrompt(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
t.Cleanup(func() {
testPool.Exec(ctx, `
DELETE FROM agent_task_queue
WHERE agent_id IN (
SELECT id FROM agent
WHERE workspace_id = $1 AND name = $2
)
`, testWorkspaceID, onboardingAssistantName)
testPool.Exec(ctx,
`DELETE FROM issue WHERE workspace_id = $1 AND title = $2`,
testWorkspaceID, onboardingIssueTitle,
)
testPool.Exec(ctx,
`DELETE FROM agent WHERE workspace_id = $1 AND name = $2`,
testWorkspaceID, onboardingAssistantName,
)
testPool.Exec(ctx,
`UPDATE "user" SET onboarded_at = NULL, starter_content_state = NULL WHERE id = $1`,
testUserID,
)
})
testPool.Exec(ctx,
`DELETE FROM issue WHERE workspace_id = $1 AND title = $2`,
testWorkspaceID, onboardingIssueTitle,
)
testPool.Exec(ctx,
`DELETE FROM agent WHERE workspace_id = $1 AND name = $2`,
testWorkspaceID, onboardingAssistantName,
)
testPool.Exec(ctx,
`UPDATE "user" SET onboarded_at = NULL, starter_content_state = NULL WHERE id = $1`,
testUserID,
)
const wantPrompt = "Introduce Multica to me, please."
body := map[string]string{
"workspace_id": testWorkspaceID,
"runtime_id": testRuntimeID,
"starter_prompt": wantPrompt,
}
w := httptest.NewRecorder()
testHandler.BootstrapOnboardingRuntime(w, newRequest(http.MethodPost, "/api/me/onboarding/runtime-bootstrap", body))
if w.Code != http.StatusOK {
t.Fatalf("BootstrapOnboardingRuntime: expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp bootstrapOnboardingRuntimeResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
var description *string
if err := testPool.QueryRow(ctx, `
SELECT description FROM issue WHERE id = $1
`, resp.IssueID).Scan(&description); err != nil {
t.Fatalf("lookup issue description: %v", err)
}
if description == nil || *description != wantPrompt {
t.Fatalf("issue description = %v, want %q", description, wantPrompt)
}
}
func TestBootstrapOnboardingRuntime_NoStarterPrompt(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
t.Cleanup(func() {
testPool.Exec(ctx, `
DELETE FROM agent_task_queue
WHERE agent_id IN (
SELECT id FROM agent
WHERE workspace_id = $1 AND name = $2
)
`, testWorkspaceID, onboardingAssistantName)
testPool.Exec(ctx,
`DELETE FROM issue WHERE workspace_id = $1 AND title = $2`,
testWorkspaceID, onboardingIssueTitle,
)
testPool.Exec(ctx,
`DELETE FROM agent WHERE workspace_id = $1 AND name = $2`,
testWorkspaceID, onboardingAssistantName,
)
testPool.Exec(ctx,
`UPDATE "user" SET onboarded_at = NULL, starter_content_state = NULL WHERE id = $1`,
testUserID,
)
})
testPool.Exec(ctx,
`DELETE FROM issue WHERE workspace_id = $1 AND title = $2`,
testWorkspaceID, onboardingIssueTitle,
)
testPool.Exec(ctx,
`DELETE FROM agent WHERE workspace_id = $1 AND name = $2`,
testWorkspaceID, onboardingAssistantName,
)
testPool.Exec(ctx,
`UPDATE "user" SET onboarded_at = NULL, starter_content_state = NULL WHERE id = $1`,
testUserID,
)
body := map[string]string{
"workspace_id": testWorkspaceID,
"runtime_id": testRuntimeID,
}
w := httptest.NewRecorder()
testHandler.BootstrapOnboardingRuntime(w, newRequest(http.MethodPost, "/api/me/onboarding/runtime-bootstrap", body))
if w.Code != http.StatusOK {
t.Fatalf("BootstrapOnboardingRuntime: expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp bootstrapOnboardingRuntimeResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
var description *string
if err := testPool.QueryRow(ctx, `
SELECT description FROM issue WHERE id = $1
`, resp.IssueID).Scan(&description); err != nil {
t.Fatalf("lookup issue description: %v", err)
}
if description == nil || *description != onboardingIssueDescription {
t.Fatalf("issue description = %v, want fallback onboardingIssueDescription", description)
}
}
func TestBootstrapOnboardingNoRuntimeCreatesSingleGuideIssue(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
t.Cleanup(func() {
testPool.Exec(ctx,
`DELETE FROM issue WHERE workspace_id = $1 AND title = $2`,
testWorkspaceID, noRuntimeIssueTitle,
)
testPool.Exec(ctx,
`UPDATE "user" SET onboarded_at = NULL, starter_content_state = NULL, language = NULL WHERE id = $1`,
testUserID,
)
})
testPool.Exec(ctx,
`DELETE FROM issue WHERE workspace_id = $1 AND title = $2`,
testWorkspaceID, noRuntimeIssueTitle,
)
testPool.Exec(ctx,
`UPDATE "user" SET onboarded_at = NULL, starter_content_state = NULL, language = 'en' WHERE id = $1`,
testUserID,
)
body := map[string]string{
"workspace_id": testWorkspaceID,
}
w := httptest.NewRecorder()
testHandler.BootstrapOnboardingNoRuntime(w, newRequest(http.MethodPost, "/api/me/onboarding/no-runtime-bootstrap", body))
if w.Code != http.StatusOK {
t.Fatalf("BootstrapOnboardingNoRuntime: expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp bootstrapOnboardingNoRuntimeResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if resp.WorkspaceID != testWorkspaceID || resp.IssueID == "" {
t.Fatalf("unexpected response: %+v", resp)
}
var (
issueTitle string
assigneeType string
assigneeID string
issueStatus string
issuePriority string
description string
)
if err := testPool.QueryRow(ctx, `
SELECT title, assignee_type, assignee_id, status, priority, description
FROM issue
WHERE id = $1
`, resp.IssueID).Scan(&issueTitle, &assigneeType, &assigneeID, &issueStatus, &issuePriority, &description); err != nil {
t.Fatalf("lookup no-runtime onboarding issue: %v", err)
}
if issueTitle != noRuntimeIssueTitle {
t.Fatalf("issue title = %q, want %q", issueTitle, noRuntimeIssueTitle)
}
if assigneeType != "member" || assigneeID != testUserID {
t.Fatalf("issue assignee = %s/%s, want member/%s", assigneeType, assigneeID, testUserID)
}
if issueStatus != "todo" || issuePriority != "high" {
t.Fatalf("issue status/priority = %s/%s, want todo/high", issueStatus, issuePriority)
}
for _, want := range []string{
"Try Multica first",
"https://multica.ai/docs/install-agent-runtime",
"npm i -g @openai/codex",
} {
if !strings.Contains(description, want) {
t.Fatalf("issue description missing %q: %q", want, description)
}
}
if !strings.Contains(description, "Agents need a runtime before they can execute work") {
t.Fatalf("issue description was not seeded: %q", description)
}
var (
onboardedAt *time.Time
starterContentState *string
)
if err := testPool.QueryRow(ctx, `
SELECT onboarded_at, starter_content_state
FROM "user"
WHERE id = $1
`, testUserID).Scan(&onboardedAt, &starterContentState); err != nil {
t.Fatalf("lookup user onboarding state: %v", err)
}
if onboardedAt == nil {
t.Fatal("expected onboarded_at to be set")
}
if starterContentState == nil || *starterContentState != "imported" {
t.Fatalf("starter_content_state = %v, want imported", starterContentState)
}
var taskCount int
if err := testPool.QueryRow(ctx, `
SELECT count(*)
FROM agent_task_queue
WHERE issue_id = $1
`, resp.IssueID).Scan(&taskCount); err != nil {
t.Fatalf("count queued tasks: %v", err)
}
if taskCount != 0 {
t.Fatalf("expected no agent tasks for no-runtime issue, got %d", taskCount)
}
w2 := httptest.NewRecorder()
testHandler.BootstrapOnboardingNoRuntime(w2, newRequest(http.MethodPost, "/api/me/onboarding/no-runtime-bootstrap", body))
if w2.Code != http.StatusOK {
t.Fatalf("second BootstrapOnboardingNoRuntime: expected 200, got %d: %s", w2.Code, w2.Body.String())
}
var resp2 bootstrapOnboardingNoRuntimeResponse
if err := json.NewDecoder(w2.Body).Decode(&resp2); err != nil {
t.Fatalf("decode second response: %v", err)
}
if resp2.IssueID != resp.IssueID {
t.Fatalf("bootstrap should be idempotent: first=%+v second=%+v", resp, resp2)
}
}
func TestBootstrapOnboardingNoRuntimeUsesChineseGuideForChineseUsers(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
t.Cleanup(func() {
testPool.Exec(ctx,
`DELETE FROM issue WHERE workspace_id = $1 AND title = $2`,
testWorkspaceID, noRuntimeIssueTitle,
)
testPool.Exec(ctx,
`UPDATE "user" SET onboarded_at = NULL, starter_content_state = NULL, language = NULL WHERE id = $1`,
testUserID,
)
})
testPool.Exec(ctx,
`DELETE FROM issue WHERE workspace_id = $1 AND title = $2`,
testWorkspaceID, noRuntimeIssueTitle,
)
testPool.Exec(ctx,
`UPDATE "user" SET onboarded_at = NULL, starter_content_state = NULL, language = 'zh-Hans' WHERE id = $1`,
testUserID,
)
body := map[string]string{
"workspace_id": testWorkspaceID,
}
w := httptest.NewRecorder()
testHandler.BootstrapOnboardingNoRuntime(w, newRequest(http.MethodPost, "/api/me/onboarding/no-runtime-bootstrap", body))
if w.Code != http.StatusOK {
t.Fatalf("BootstrapOnboardingNoRuntime: expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp bootstrapOnboardingNoRuntimeResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
var description string
if err := testPool.QueryRow(ctx, `
SELECT description
FROM issue
WHERE id = $1
`, resp.IssueID).Scan(&description); err != nil {
t.Fatalf("lookup no-runtime onboarding issue: %v", err)
}
for _, want := range []string{
"先体验项目管理功能",
"https://multica.ai/docs/install-agent-runtime",
"中文用户建议先装 Kimi CLI",
"kimi --version",
} {
if !strings.Contains(description, want) {
t.Fatalf("Chinese issue description missing %q: %q", want, description)
}
}
}