From ad0615a08fe9058acd5640ee8b830338ec551965 Mon Sep 17 00:00:00 2001 From: Jiang Bohan Date: Fri, 3 Apr 2026 15:38:38 +0800 Subject: [PATCH 01/16] docs(web): add v0.1.5 changelog entry for 2026-04-02 --- apps/web/features/landing/i18n/en.ts | 17 +++++++++++++++++ apps/web/features/landing/i18n/zh.ts | 17 +++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/apps/web/features/landing/i18n/en.ts b/apps/web/features/landing/i18n/en.ts index 7ae26655..99249e1f 100644 --- a/apps/web/features/landing/i18n/en.ts +++ b/apps/web/features/landing/i18n/en.ts @@ -272,6 +272,23 @@ export const en: LandingDict = { title: "Changelog", subtitle: "New updates and improvements to Multica.", entries: [ + { + version: "0.1.5", + date: "2026-04-02", + title: "Mentions & Permissions", + changes: [ + "@mention issues in comments with server-side auto-expansion", + "@all mention to notify every workspace member", + "Inbox auto-scrolls to the referenced comment from a notification", + "Repositories extracted into a standalone settings tab", + "CLI update support from the web runtime page and direct download for non-Homebrew installs", + "CLI commands for viewing issue execution runs and run messages", + "Agent permission model — owners and admins manage agents, members manage skills on their own agents", + "Per-issue serial execution to prevent concurrent task collisions", + "File upload now supports all file types", + "README redesign with quickstart guide", + ], + }, { version: "0.1.4", date: "2026-04-01", diff --git a/apps/web/features/landing/i18n/zh.ts b/apps/web/features/landing/i18n/zh.ts index 9f87b9f6..7c2554eb 100644 --- a/apps/web/features/landing/i18n/zh.ts +++ b/apps/web/features/landing/i18n/zh.ts @@ -272,6 +272,23 @@ export const zh: LandingDict = { title: "\u66f4\u65b0\u65e5\u5fd7", subtitle: "Multica \u7684\u6700\u65b0\u66f4\u65b0\u548c\u6539\u8fdb\u3002", entries: [ + { + version: "0.1.5", + date: "2026-04-02", + title: "提及与权限", + changes: [ + "评论中支持 @提及 Issue,服务端自动展开", + "支持 @all 提及工作区所有成员", + "收件箱通知点击后自动滚动到对应评论", + "仓库管理独立为设置页单独标签页", + "支持从网页端运行时页面更新 CLI,非 Homebrew 安装支持直接下载更新", + "新增 CLI 命令查看 Issue 执行记录和运行消息", + "Agent 权限模型优化——所有者和管理员管理 Agent,成员可管理自己 Agent 的技能", + "每个 Issue 串行执行,防止并发任务冲突", + "文件上传支持所有文件类型", + "README 重新设计,新增快速入门指南", + ], + }, { version: "0.1.4", date: "2026-04-01", From 2787bd60bed01f82278571cc5187102a686e3f56 Mon Sep 17 00:00:00 2001 From: Jiang Bohan Date: Fri, 3 Apr 2026 16:38:04 +0800 Subject: [PATCH 02/16] docs(web): add v0.1.6 changelog entry for 2026-04-03 --- apps/web/features/landing/i18n/en.ts | 16 ++++++++++++++++ apps/web/features/landing/i18n/zh.ts | 16 ++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/apps/web/features/landing/i18n/en.ts b/apps/web/features/landing/i18n/en.ts index 99249e1f..5ca5c6a2 100644 --- a/apps/web/features/landing/i18n/en.ts +++ b/apps/web/features/landing/i18n/en.ts @@ -272,6 +272,22 @@ export const en: LandingDict = { title: "Changelog", subtitle: "New updates and improvements to Multica.", entries: [ + { + version: "0.1.6", + date: "2026-04-03", + title: "Editor Overhaul & Agent Lifecycle", + changes: [ + "Unified Tiptap editor with a single Markdown pipeline for editing and display", + "Reliable Markdown paste, inline code spacing, and link styling", + "Agent archive and restore — soft delete replaces hard delete", + "Archived agents hidden from default agent list", + "Skeleton loading states, error toasts, and confirmation dialogs across the app", + "OpenCode added as a supported agent provider", + "Reply-triggered agent tasks now inherit thread-root @mentions", + "Granular real-time event handling for issues and inbox — no more full refetches", + "Unified image upload flow for paste and button in the editor", + ], + }, { version: "0.1.5", date: "2026-04-02", diff --git a/apps/web/features/landing/i18n/zh.ts b/apps/web/features/landing/i18n/zh.ts index 7c2554eb..9b463178 100644 --- a/apps/web/features/landing/i18n/zh.ts +++ b/apps/web/features/landing/i18n/zh.ts @@ -272,6 +272,22 @@ export const zh: LandingDict = { title: "\u66f4\u65b0\u65e5\u5fd7", subtitle: "Multica \u7684\u6700\u65b0\u66f4\u65b0\u548c\u6539\u8fdb\u3002", entries: [ + { + version: "0.1.6", + date: "2026-04-03", + title: "编辑器重构与 Agent 生命周期", + changes: [ + "统一 Tiptap 编辑器,编辑和展示共用单一 Markdown 渲染管线", + "Markdown 粘贴、行内代码间距和链接样式修复", + "Agent 支持归档和恢复——软删除替代硬删除", + "默认列表隐藏已归档的 Agent", + "全应用新增骨架屏加载态、错误提示和确认对话框", + "新增 OpenCode 作为支持的 Agent 提供商", + "回复触发的 Agent 任务自动继承主线程 @提及", + "Issue 和收件箱实时事件细粒度处理,不再全量刷新", + "编辑器中统一图片上传流程,支持粘贴和按钮上传", + ], + }, { version: "0.1.5", date: "2026-04-02", From 9d9e0317c08f1c040a93133b630e29098d5628cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=91=A8=E9=98=B3?= Date: Sat, 4 Apr 2026 22:12:47 +0800 Subject: [PATCH 03/16] fix(web): handle null trigger config in agents page --- apps/web/app/(dashboard)/agents/page.tsx | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/apps/web/app/(dashboard)/agents/page.tsx b/apps/web/app/(dashboard)/agents/page.tsx index 8a9680c9..ab4aaa82 100644 --- a/apps/web/app/(dashboard)/agents/page.tsx +++ b/apps/web/app/(dashboard)/agents/page.tsx @@ -919,7 +919,13 @@ function TriggersTab({
- {triggers.map((trigger) => ( + {triggers.map((trigger) => { + const scheduledConfig = (trigger.config ?? {}) as { + cron?: string; + timezone?: string; + }; + + return (
@@ -982,10 +988,10 @@ function TriggersTab({ updateTriggerConfig(trigger.id, { - ...trigger.config, + ...scheduledConfig, cron: e.target.value, }) } @@ -999,10 +1005,10 @@ function TriggersTab({ updateTriggerConfig(trigger.id, { - ...trigger.config, + ...scheduledConfig, timezone: e.target.value, }) } @@ -1013,7 +1019,8 @@ function TriggersTab({
)}
- ))} + ); + })}
From 3bf094ebf77469188d16758577bebe007c4d83f1 Mon Sep 17 00:00:00 2001 From: sunjie21 Date: Mon, 6 Apr 2026 21:48:31 +0800 Subject: [PATCH 04/16] fix(auth): extend JWT and CloudFront cookie expiration from 72h to 30 days Reduces login frequency for users by increasing token lifetime. Co-Authored-By: Claude Opus 4.6 (1M context) --- server/internal/handler/auth.go | 4 ++-- server/internal/middleware/cloudfront.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/server/internal/handler/auth.go b/server/internal/handler/auth.go index 5339190c..61807b81 100644 --- a/server/internal/handler/auth.go +++ b/server/internal/handler/auth.go @@ -175,7 +175,7 @@ func (h *Handler) issueJWT(user db.User) (string, error) { "sub": uuidToString(user.ID), "email": user.Email, "name": user.Name, - "exp": time.Now().Add(72 * time.Hour).Unix(), + "exp": time.Now().Add(30 * 24 * time.Hour).Unix(), "iat": time.Now().Unix(), }) return token.SignedString(auth.JWTSecret()) @@ -302,7 +302,7 @@ func (h *Handler) VerifyCode(w http.ResponseWriter, r *http.Request) { // Set CloudFront signed cookies for CDN access. if h.CFSigner != nil { - for _, cookie := range h.CFSigner.SignedCookies(time.Now().Add(72 * time.Hour)) { + for _, cookie := range h.CFSigner.SignedCookies(time.Now().Add(30 * 24 * time.Hour)) { http.SetCookie(w, cookie) } } diff --git a/server/internal/middleware/cloudfront.go b/server/internal/middleware/cloudfront.go index ab749998..b6a27d75 100644 --- a/server/internal/middleware/cloudfront.go +++ b/server/internal/middleware/cloudfront.go @@ -18,7 +18,7 @@ func RefreshCloudFrontCookies(signer *auth.CloudFrontSigner) func(http.Handler) } return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if _, err := r.Cookie("CloudFront-Policy"); err != nil { - for _, cookie := range signer.SignedCookies(time.Now().Add(72 * time.Hour)) { + for _, cookie := range signer.SignedCookies(time.Now().Add(30 * 24 * time.Hour)) { http.SetCookie(w, cookie) } } From 02a759890648c6b8342d67eec76cc9dc313116e2 Mon Sep 17 00:00:00 2001 From: Jiang Bohan Date: Tue, 7 Apr 2026 14:13:26 +0800 Subject: [PATCH 05/16] fix(daemon): add missing CLI commands to agent instructions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add 5 missing commands to buildMetaSkillContent() so agents can discover them: Read: - workspace members — query member IDs for mentions - repo checkout — listed in command reference, not just prose Write: - issue create — create sub-issues and new tasks - issue assign — assign/unassign issues - issue comment delete — remove erroneous comments --- server/internal/daemon/execenv/runtime_config.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/server/internal/daemon/execenv/runtime_config.go b/server/internal/daemon/execenv/runtime_config.go index 553b1e01..99e17837 100644 --- a/server/internal/daemon/execenv/runtime_config.go +++ b/server/internal/daemon/execenv/runtime_config.go @@ -49,13 +49,18 @@ func buildMetaSkillContent(provider string, ctx TaskContextForEnv) string { b.WriteString("- `multica issue list [--status X] [--priority X] [--assignee X] --output json` — List issues in workspace\n") b.WriteString("- `multica issue comment list --output json` — List all comments on an issue (includes id, parent_id for threading)\n") b.WriteString("- `multica workspace get --output json` — Get workspace details and context\n") + b.WriteString("- `multica workspace members [workspace-id] --output json` — List workspace members (user IDs, names, roles)\n") b.WriteString("- `multica agent list --output json` — List agents in workspace\n") + b.WriteString("- `multica repo checkout ` — Check out a repository into the working directory (creates a git worktree with a dedicated branch)\n") b.WriteString("- `multica issue runs --output json` — List all execution runs for an issue (status, timestamps, errors)\n") b.WriteString("- `multica issue run-messages [--since ] --output json` — List messages for a specific execution run (supports incremental fetch)\n") b.WriteString("- `multica attachment download [-o ]` — Download an attachment file locally by ID\n\n") b.WriteString("### Write\n") + b.WriteString("- `multica issue create --title \"...\" [--description \"...\"] [--priority X] [--assignee X] [--parent ] [--status X]` — Create a new issue\n") + b.WriteString("- `multica issue assign --to ` — Assign an issue to a member or agent by name (use --unassign to remove assignee)\n") b.WriteString("- `multica issue comment add --content \"...\" [--parent ]` — Post a comment (use --parent to reply to a specific comment)\n") + b.WriteString("- `multica issue comment delete ` — Delete a comment\n") b.WriteString("- `multica issue status ` — Update issue status (todo, in_progress, in_review, done, blocked)\n") b.WriteString("- `multica issue update [--title X] [--description X] [--priority X]` — Update issue fields\n\n") From cfb0365cb31a208ea469d1ea9e880d7cc9426949 Mon Sep 17 00:00:00 2001 From: Jiang Bohan Date: Tue, 7 Apr 2026 14:36:08 +0800 Subject: [PATCH 06/16] fix(triggers): remove assignee skip in enqueueMentionedAgentTasks The assignee check in enqueueMentionedAgentTasks silently skipped explicit @mentions when the target agent was the issue assignee in a non-terminal status. This broke the review-rejection-retry loop: when a reviewer rejected a PR and @mentioned the developer agent, the mention was skipped because the developer was the assignee. The downstream HasPendingTaskForIssueAndAgent check already prevents duplicate queued tasks, making the assignee skip redundant. Removing it ensures explicit @mentions always fire regardless of assignee status. Closes #431 --- server/internal/handler/comment.go | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/server/internal/handler/comment.go b/server/internal/handler/comment.go index 4902f646..e2f2f7fb 100644 --- a/server/internal/handler/comment.go +++ b/server/internal/handler/comment.go @@ -357,9 +357,8 @@ func (h *Handler) isReplyToMemberThread(parent *db.Comment, content string, issu // re-triggered by subsequent replies in the same thread — unless the reply // explicitly @mentions only non-agent entities (members, issues), which // signals the user is talking to other people and not the agent. -// Skips self-mentions, agents that are already the issue's assignee (handled -// by on_comment), agents with on_mention trigger disabled, and private agents -// mentioned by non-owner members (only the agent owner or workspace +// Skips self-mentions, agents with on_mention trigger disabled, and private +// agents mentioned by non-owner members (only the agent owner or workspace // admin/owner can mention a private agent). // Note: no status gate here — @mention is an explicit action and should work // even on done/cancelled issues (the agent can reopen the issue if needed). @@ -404,17 +403,6 @@ func (h *Handler) enqueueMentionedAgentTasks(ctx context.Context, issue db.Issue continue } agentUUID := parseUUID(m.ID) - // Prevent duplicate: skip if this agent is the issue's assignee - // (already handled by the on_comment trigger above) — but only - // when the issue is in a non-terminal status where on_comment - // will actually fire. For done/cancelled issues on_comment is - // suppressed, so an explicit @mention must still go through. - isAssignee := issue.AssigneeType.Valid && issue.AssigneeType.String == "agent" && - issue.AssigneeID.Valid && uuidToString(issue.AssigneeID) == m.ID - isTerminal := issue.Status == "done" || issue.Status == "cancelled" - if isAssignee && !isTerminal { - continue - } // Load the agent to check visibility, archive status, and trigger config. agent, err := h.Queries.GetAgent(ctx, agentUUID) if err != nil || !agent.RuntimeID.Valid || agent.ArchivedAt.Valid { From 5cf4ba803dbb9591374421850cd923bd9cdfea78 Mon Sep 17 00:00:00 2001 From: Jiang Bohan Date: Tue, 7 Apr 2026 14:40:51 +0800 Subject: [PATCH 07/16] feat(agent): add OpenClaw runtime support Add OpenClaw as a fourth supported agent runtime alongside Claude Code, Codex, and OpenCode. OpenClaw CLI (`openclaw agent -p ... --output-format stream-json`) is integrated via the same Backend interface pattern. Changes: - Add openclawBackend in server/pkg/agent/openclaw.go with NDJSON event stream parsing (text, thinking, tool_call, error, step, result) - Register "openclaw" in the agent factory (agent.go) - Add MULTICA_OPENCLAW_PATH / MULTICA_OPENCLAW_MODEL env var detection in daemon config - Include "openclaw" in AGENTS.md config injection alongside codex/opencode - Add comprehensive unit tests for all event handlers and processEvents --- server/internal/daemon/config.go | 11 +- .../internal/daemon/execenv/runtime_config.go | 7 +- server/pkg/agent/agent.go | 10 +- server/pkg/agent/openclaw.go | 316 +++++++++ server/pkg/agent/openclaw_test.go | 599 ++++++++++++++++++ 5 files changed, 934 insertions(+), 9 deletions(-) create mode 100644 server/pkg/agent/openclaw.go create mode 100644 server/pkg/agent/openclaw_test.go diff --git a/server/internal/daemon/config.go b/server/internal/daemon/config.go index bb41e30e..896c7f17 100644 --- a/server/internal/daemon/config.go +++ b/server/internal/daemon/config.go @@ -30,7 +30,7 @@ type Config struct { RuntimeName string CLIVersion string // multica CLI version (e.g. "0.1.13") Profile string // profile name (empty = default) - Agents map[string]AgentEntry // "claude" -> entry, "codex" -> entry, "opencode" -> entry + Agents map[string]AgentEntry // "claude" -> entry, "codex" -> entry, "opencode" -> entry, "openclaw" -> entry WorkspacesRoot string // base path for execution envs (default: ~/multica_workspaces) KeepEnvAfterTask bool // preserve env after task for debugging HealthPort int // local HTTP port for health checks (default: 19514) @@ -92,8 +92,15 @@ func LoadConfig(overrides Overrides) (Config, error) { Model: strings.TrimSpace(os.Getenv("MULTICA_OPENCODE_MODEL")), } } + openclawPath := envOrDefault("MULTICA_OPENCLAW_PATH", "openclaw") + if _, err := exec.LookPath(openclawPath); err == nil { + agents["openclaw"] = AgentEntry{ + Path: openclawPath, + Model: strings.TrimSpace(os.Getenv("MULTICA_OPENCLAW_MODEL")), + } + } if len(agents) == 0 { - return Config{}, fmt.Errorf("no agent CLI found: install claude, codex, or opencode and ensure it is on PATH") + return Config{}, fmt.Errorf("no agent CLI found: install claude, codex, opencode, or openclaw and ensure it is on PATH") } // Host info diff --git a/server/internal/daemon/execenv/runtime_config.go b/server/internal/daemon/execenv/runtime_config.go index e9e8f9ce..8f695ba2 100644 --- a/server/internal/daemon/execenv/runtime_config.go +++ b/server/internal/daemon/execenv/runtime_config.go @@ -13,13 +13,14 @@ import ( // For Claude: writes {workDir}/CLAUDE.md (skills discovered natively from .claude/skills/) // For Codex: writes {workDir}/AGENTS.md (skills discovered natively via CODEX_HOME) // For OpenCode: writes {workDir}/AGENTS.md (skills discovered natively from .config/opencode/skills/) +// For OpenClaw: writes {workDir}/AGENTS.md (skills discovered natively from .openclaw/skills/) func InjectRuntimeConfig(workDir, provider string, ctx TaskContextForEnv) error { content := buildMetaSkillContent(provider, ctx) switch provider { case "claude": return os.WriteFile(filepath.Join(workDir, "CLAUDE.md"), []byte(content), 0o644) - case "codex", "opencode": + case "codex", "opencode", "openclaw": return os.WriteFile(filepath.Join(workDir, "AGENTS.md"), []byte(content), 0o644) default: // Unknown provider — skip config injection, prompt-only mode. @@ -117,8 +118,8 @@ func buildMetaSkillContent(provider string, ctx TaskContextForEnv) string { case "claude": // Claude discovers skills natively from .claude/skills/ — just list names. b.WriteString("You have the following skills installed (discovered automatically):\n\n") - case "codex", "opencode": - // Codex and OpenCode discover skills natively from their respective paths — just list names. + case "codex", "opencode", "openclaw": + // Codex, OpenCode, and OpenClaw discover skills natively from their respective paths — just list names. b.WriteString("You have the following skills installed (discovered automatically):\n\n") default: b.WriteString("Detailed skill instructions are in `.agent_context/skills/`. Each subdirectory contains a `SKILL.md`.\n\n") diff --git a/server/pkg/agent/agent.go b/server/pkg/agent/agent.go index 383d6fe1..5636617d 100644 --- a/server/pkg/agent/agent.go +++ b/server/pkg/agent/agent.go @@ -1,5 +1,5 @@ // Package agent provides a unified interface for executing prompts via -// coding agents (Claude Code, Codex, OpenCode). It mirrors the happy-cli AgentBackend +// coding agents (Claude Code, Codex, OpenCode, OpenClaw). It mirrors the happy-cli AgentBackend // pattern, translated to idiomatic Go. package agent @@ -73,13 +73,13 @@ type Result struct { // Config configures a Backend instance. type Config struct { - ExecutablePath string // path to CLI binary (claude, codex, or opencode) + ExecutablePath string // path to CLI binary (claude, codex, opencode, or openclaw) Env map[string]string // extra environment variables Logger *slog.Logger } // New creates a Backend for the given agent type. -// Supported types: "claude", "codex", "opencode". +// Supported types: "claude", "codex", "opencode", "openclaw". func New(agentType string, cfg Config) (Backend, error) { if cfg.Logger == nil { cfg.Logger = slog.Default() @@ -92,8 +92,10 @@ func New(agentType string, cfg Config) (Backend, error) { return &codexBackend{cfg: cfg}, nil case "opencode": return &opencodeBackend{cfg: cfg}, nil + case "openclaw": + return &openclawBackend{cfg: cfg}, nil default: - return nil, fmt.Errorf("unknown agent type: %q (supported: claude, codex, opencode)", agentType) + return nil, fmt.Errorf("unknown agent type: %q (supported: claude, codex, opencode, openclaw)", agentType) } } diff --git a/server/pkg/agent/openclaw.go b/server/pkg/agent/openclaw.go new file mode 100644 index 00000000..7a704119 --- /dev/null +++ b/server/pkg/agent/openclaw.go @@ -0,0 +1,316 @@ +package agent + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" + "os/exec" + "strings" + "time" +) + +// openclawBackend implements Backend by spawning `openclaw agent -p +// --output-format stream-json --yes` and reading streaming NDJSON events from +// stdout — similar to the opencode backend. +type openclawBackend struct { + cfg Config +} + +func (b *openclawBackend) Execute(ctx context.Context, prompt string, opts ExecOptions) (*Session, error) { + execPath := b.cfg.ExecutablePath + if execPath == "" { + execPath = "openclaw" + } + if _, err := exec.LookPath(execPath); err != nil { + return nil, fmt.Errorf("openclaw executable not found at %q: %w", execPath, err) + } + + timeout := opts.Timeout + if timeout == 0 { + timeout = 20 * time.Minute + } + runCtx, cancel := context.WithTimeout(ctx, timeout) + + args := []string{"agent", "--output-format", "stream-json", "--yes"} + if opts.Model != "" { + args = append(args, "--model", opts.Model) + } + if opts.SystemPrompt != "" { + args = append(args, "--system-prompt", opts.SystemPrompt) + } + if opts.MaxTurns > 0 { + args = append(args, "--max-turns", fmt.Sprintf("%d", opts.MaxTurns)) + } + if opts.ResumeSessionID != "" { + args = append(args, "--session", opts.ResumeSessionID) + } + args = append(args, "-p", prompt) + + cmd := exec.CommandContext(runCtx, execPath, args...) + if opts.Cwd != "" { + cmd.Dir = opts.Cwd + } + cmd.Env = buildEnv(b.cfg.Env) + + stdout, err := cmd.StdoutPipe() + if err != nil { + cancel() + return nil, fmt.Errorf("openclaw stdout pipe: %w", err) + } + cmd.Stderr = newLogWriter(b.cfg.Logger, "[openclaw:stderr] ") + + if err := cmd.Start(); err != nil { + cancel() + return nil, fmt.Errorf("start openclaw: %w", err) + } + + b.cfg.Logger.Info("openclaw started", "pid", cmd.Process.Pid, "cwd", opts.Cwd, "model", opts.Model) + + msgCh := make(chan Message, 256) + resCh := make(chan Result, 1) + + go func() { + defer cancel() + defer close(msgCh) + defer close(resCh) + + startTime := time.Now() + scanResult := b.processEvents(stdout, msgCh) + + // Wait for process exit. + exitErr := cmd.Wait() + duration := time.Since(startTime) + + if runCtx.Err() == context.DeadlineExceeded { + scanResult.status = "timeout" + scanResult.errMsg = fmt.Sprintf("openclaw timed out after %s", timeout) + } else if runCtx.Err() == context.Canceled { + scanResult.status = "aborted" + scanResult.errMsg = "execution cancelled" + } else if exitErr != nil && scanResult.status == "completed" { + scanResult.status = "failed" + scanResult.errMsg = fmt.Sprintf("openclaw exited with error: %v", exitErr) + } + + b.cfg.Logger.Info("openclaw finished", "pid", cmd.Process.Pid, "status", scanResult.status, "duration", duration.Round(time.Millisecond).String()) + + resCh <- Result{ + Status: scanResult.status, + Output: scanResult.output, + Error: scanResult.errMsg, + DurationMs: duration.Milliseconds(), + SessionID: scanResult.sessionID, + } + }() + + return &Session{Messages: msgCh, Result: resCh}, nil +} + +// ── Event handlers ── + +// openclawEventResult holds accumulated state from processing the event stream. +type openclawEventResult struct { + status string + errMsg string + output string + sessionID string +} + +// processEvents reads NDJSON lines from r, dispatches events to ch, and returns +// the accumulated result. +func (b *openclawBackend) processEvents(r io.Reader, ch chan<- Message) openclawEventResult { + var output strings.Builder + var sessionID string + finalStatus := "completed" + var finalError string + + scanner := bufio.NewScanner(r) + scanner.Buffer(make([]byte, 0, 1024*1024), 10*1024*1024) + + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + + var event openclawEvent + if err := json.Unmarshal([]byte(line), &event); err != nil { + continue + } + + if event.SessionID != "" { + sessionID = event.SessionID + } + + switch event.Type { + case "text": + b.handleOCTextEvent(event, ch, &output) + case "thinking": + b.handleOCThinkingEvent(event, ch) + case "tool_call": + b.handleOCToolCallEvent(event, ch) + case "error": + b.handleOCErrorEvent(event, ch, &finalStatus, &finalError) + case "step_start": + trySend(ch, Message{Type: MessageStatus, Status: "running"}) + case "step_end": + // Captures final session ID from step_end if present. + case "result": + if event.Data != nil { + if s, ok := event.Data["status"].(string); ok && s != "" { + if s == "error" || s == "failed" { + finalStatus = "failed" + if msg, ok := event.Data["error"].(string); ok { + finalError = msg + } + } + } + } + } + } + + // Check for scanner errors (e.g. broken pipe, read errors). + if scanErr := scanner.Err(); scanErr != nil { + b.cfg.Logger.Warn("openclaw stdout scanner error", "error", scanErr) + if finalStatus == "completed" { + finalStatus = "failed" + finalError = fmt.Sprintf("stdout read error: %v", scanErr) + } + } + + return openclawEventResult{ + status: finalStatus, + errMsg: finalError, + output: output.String(), + sessionID: sessionID, + } +} + +func (b *openclawBackend) handleOCTextEvent(event openclawEvent, ch chan<- Message, output *strings.Builder) { + text := extractEventText(event.Data) + if text != "" { + output.WriteString(text) + trySend(ch, Message{Type: MessageText, Content: text}) + } +} + +func (b *openclawBackend) handleOCThinkingEvent(event openclawEvent, ch chan<- Message) { + text := extractEventText(event.Data) + if text != "" { + trySend(ch, Message{Type: MessageThinking, Content: text}) + } +} + +// handleOCToolCallEvent processes "tool_call" events from OpenClaw. A single +// tool_call event may contain both the call and result when the tool has +// completed (status == "completed"). +func (b *openclawBackend) handleOCToolCallEvent(event openclawEvent, ch chan<- Message) { + if event.Data == nil { + return + } + + name, _ := event.Data["name"].(string) + callID, _ := event.Data["callId"].(string) + + // Extract input. + var input map[string]any + if raw, ok := event.Data["input"]; ok { + if m, ok := raw.(map[string]any); ok { + input = m + } + } + + // Emit the tool-use message. + trySend(ch, Message{ + Type: MessageToolUse, + Tool: name, + CallID: callID, + Input: input, + }) + + // If the tool has completed, also emit a tool-result message. + status, _ := event.Data["status"].(string) + if status == "completed" { + outputStr := extractOCToolOutput(event.Data["output"]) + trySend(ch, Message{ + Type: MessageToolResult, + Tool: name, + CallID: callID, + Output: outputStr, + }) + } +} + +func (b *openclawBackend) handleOCErrorEvent(event openclawEvent, ch chan<- Message, finalStatus, finalError *string) { + errMsg := "" + if event.Data != nil { + if msg, ok := event.Data["message"].(string); ok { + errMsg = msg + } + if errMsg == "" { + if code, ok := event.Data["code"].(string); ok { + errMsg = code + } + } + } + if errMsg == "" { + errMsg = "unknown openclaw error" + } + + b.cfg.Logger.Warn("openclaw error event", "error", errMsg) + trySend(ch, Message{Type: MessageError, Content: errMsg}) + + *finalStatus = "failed" + *finalError = errMsg +} + +// extractEventText extracts text content from an event data map. +func extractEventText(data map[string]any) string { + if data == nil { + return "" + } + // Try "text" field directly. + if text, ok := data["text"].(string); ok { + return text + } + // Try nested "content.text". + if content, ok := data["content"].(map[string]any); ok { + if text, ok := content["text"].(string); ok { + return text + } + } + return "" +} + +// extractOCToolOutput converts tool output (string or structured) into a string. +func extractOCToolOutput(output any) string { + if output == nil { + return "" + } + if s, ok := output.(string); ok { + return s + } + data, _ := json.Marshal(output) + return string(data) +} + +// ── JSON types for `openclaw agent --output-format stream-json` stdout events ── + +// openclawEvent represents a single NDJSON line from OpenClaw's stream-json output. +// +// Event types: +// +// "step_start" — agent step begins +// "text" — text output from agent +// "thinking" — model reasoning/thinking +// "tool_call" — tool invocation with call and result +// "error" — error from openclaw +// "step_end" — agent step completes +// "result" — final result with status +type openclawEvent struct { + Type string `json:"type"` + SessionID string `json:"sessionId,omitempty"` + Data map[string]any `json:"data,omitempty"` +} diff --git a/server/pkg/agent/openclaw_test.go b/server/pkg/agent/openclaw_test.go new file mode 100644 index 00000000..c55c7abe --- /dev/null +++ b/server/pkg/agent/openclaw_test.go @@ -0,0 +1,599 @@ +package agent + +import ( + "log/slog" + "strings" + "testing" +) + +func TestNewReturnsOpenclawBackend(t *testing.T) { + t.Parallel() + b, err := New("openclaw", Config{ExecutablePath: "/nonexistent/openclaw"}) + if err != nil { + t.Fatalf("New(openclaw) error: %v", err) + } + if _, ok := b.(*openclawBackend); !ok { + t.Fatalf("expected *openclawBackend, got %T", b) + } +} + +// ── Text event tests ── + +func TestOpenclawHandleTextEvent(t *testing.T) { + t.Parallel() + + b := &openclawBackend{} + ch := make(chan Message, 10) + var output strings.Builder + + event := openclawEvent{ + Type: "text", + SessionID: "ses_abc", + Data: map[string]any{"text": "Hello from openclaw"}, + } + + b.handleOCTextEvent(event, ch, &output) + + if output.String() != "Hello from openclaw" { + t.Errorf("output: got %q, want %q", output.String(), "Hello from openclaw") + } + msg := <-ch + if msg.Type != MessageText { + t.Errorf("type: got %v, want MessageText", msg.Type) + } + if msg.Content != "Hello from openclaw" { + t.Errorf("content: got %q, want %q", msg.Content, "Hello from openclaw") + } +} + +func TestOpenclawHandleTextEventEmpty(t *testing.T) { + t.Parallel() + + b := &openclawBackend{} + ch := make(chan Message, 10) + var output strings.Builder + + event := openclawEvent{ + Type: "text", + Data: map[string]any{"text": ""}, + } + + b.handleOCTextEvent(event, ch, &output) + + if output.String() != "" { + t.Errorf("expected empty output, got %q", output.String()) + } + if len(ch) != 0 { + t.Errorf("expected no messages, got %d", len(ch)) + } +} + +func TestOpenclawHandleTextEventNilData(t *testing.T) { + t.Parallel() + + b := &openclawBackend{} + ch := make(chan Message, 10) + var output strings.Builder + + event := openclawEvent{Type: "text"} + + b.handleOCTextEvent(event, ch, &output) + + if output.String() != "" { + t.Errorf("expected empty output, got %q", output.String()) + } + if len(ch) != 0 { + t.Errorf("expected no messages, got %d", len(ch)) + } +} + +// ── Thinking event tests ── + +func TestOpenclawHandleThinkingEvent(t *testing.T) { + t.Parallel() + + b := &openclawBackend{} + ch := make(chan Message, 10) + + event := openclawEvent{ + Type: "thinking", + Data: map[string]any{"text": "Let me think about this..."}, + } + + b.handleOCThinkingEvent(event, ch) + + if len(ch) != 1 { + t.Fatalf("expected 1 message, got %d", len(ch)) + } + msg := <-ch + if msg.Type != MessageThinking { + t.Errorf("type: got %v, want MessageThinking", msg.Type) + } + if msg.Content != "Let me think about this..." { + t.Errorf("content: got %q", msg.Content) + } +} + +// ── Tool call event tests ── + +func TestOpenclawHandleToolCallCompleted(t *testing.T) { + t.Parallel() + + b := &openclawBackend{} + ch := make(chan Message, 10) + + event := openclawEvent{ + Type: "tool_call", + Data: map[string]any{ + "name": "bash", + "callId": "call_123", + "input": map[string]any{"command": "pwd"}, + "status": "completed", + "output": "/tmp/project\n", + }, + } + + b.handleOCToolCallEvent(event, ch) + + // Should emit both tool-use and tool-result. + if len(ch) != 2 { + t.Fatalf("expected 2 messages, got %d", len(ch)) + } + + // First: tool-use + msg := <-ch + if msg.Type != MessageToolUse { + t.Errorf("type: got %v, want MessageToolUse", msg.Type) + } + if msg.Tool != "bash" { + t.Errorf("tool: got %q, want %q", msg.Tool, "bash") + } + if msg.CallID != "call_123" { + t.Errorf("callID: got %q, want %q", msg.CallID, "call_123") + } + if cmd, ok := msg.Input["command"].(string); !ok || cmd != "pwd" { + t.Errorf("input.command: got %v", msg.Input["command"]) + } + + // Second: tool-result + msg = <-ch + if msg.Type != MessageToolResult { + t.Errorf("type: got %v, want MessageToolResult", msg.Type) + } + if msg.Output != "/tmp/project\n" { + t.Errorf("output: got %q", msg.Output) + } +} + +func TestOpenclawHandleToolCallPending(t *testing.T) { + t.Parallel() + + b := &openclawBackend{} + ch := make(chan Message, 10) + + event := openclawEvent{ + Type: "tool_call", + Data: map[string]any{ + "name": "read", + "callId": "call_456", + "input": map[string]any{"filePath": "/tmp/test.go"}, + "status": "pending", + }, + } + + b.handleOCToolCallEvent(event, ch) + + if len(ch) != 1 { + t.Fatalf("expected 1 message for pending tool, got %d", len(ch)) + } + msg := <-ch + if msg.Type != MessageToolUse { + t.Errorf("type: got %v, want MessageToolUse", msg.Type) + } +} + +func TestOpenclawHandleToolCallNilData(t *testing.T) { + t.Parallel() + + b := &openclawBackend{} + ch := make(chan Message, 10) + + event := openclawEvent{Type: "tool_call"} + + b.handleOCToolCallEvent(event, ch) + + if len(ch) != 0 { + t.Errorf("expected no messages for nil data, got %d", len(ch)) + } +} + +// ── Error event tests ── + +func TestOpenclawHandleErrorEvent(t *testing.T) { + t.Parallel() + + b := &openclawBackend{cfg: Config{Logger: slog.Default()}} + ch := make(chan Message, 10) + status := "completed" + errMsg := "" + + event := openclawEvent{ + Type: "error", + SessionID: "ses_abc", + Data: map[string]any{"message": "Model not found: bad/model"}, + } + + b.handleOCErrorEvent(event, ch, &status, &errMsg) + + if status != "failed" { + t.Errorf("status: got %q, want %q", status, "failed") + } + if errMsg != "Model not found: bad/model" { + t.Errorf("error: got %q", errMsg) + } + msg := <-ch + if msg.Type != MessageError { + t.Errorf("type: got %v, want MessageError", msg.Type) + } +} + +func TestOpenclawHandleErrorEventCodeOnly(t *testing.T) { + t.Parallel() + + b := &openclawBackend{cfg: Config{Logger: slog.Default()}} + ch := make(chan Message, 10) + status := "completed" + errMsg := "" + + event := openclawEvent{ + Type: "error", + Data: map[string]any{"code": "RateLimitError"}, + } + + b.handleOCErrorEvent(event, ch, &status, &errMsg) + + if errMsg != "RateLimitError" { + t.Errorf("error: got %q, want %q", errMsg, "RateLimitError") + } +} + +func TestOpenclawHandleErrorEventNilData(t *testing.T) { + t.Parallel() + + b := &openclawBackend{cfg: Config{Logger: slog.Default()}} + ch := make(chan Message, 10) + status := "completed" + errMsg := "" + + event := openclawEvent{Type: "error"} + + b.handleOCErrorEvent(event, ch, &status, &errMsg) + + if errMsg != "unknown openclaw error" { + t.Errorf("error: got %q, want %q", errMsg, "unknown openclaw error") + } +} + +// ── Integration-level tests: processEvents ── + +func TestOpenclawProcessEventsHappyPath(t *testing.T) { + t.Parallel() + + b := &openclawBackend{cfg: Config{Logger: slog.Default()}} + ch := make(chan Message, 256) + + // Simulate a successful run: step_start → text → tool_call → text → step_end + lines := strings.Join([]string{ + `{"type":"step_start","sessionId":"ses_happy"}`, + `{"type":"text","sessionId":"ses_happy","data":{"text":"Analyzing..."}}`, + `{"type":"tool_call","sessionId":"ses_happy","data":{"name":"bash","callId":"call_1","input":{"command":"ls"},"status":"completed","output":"file.go\n"}}`, + `{"type":"text","sessionId":"ses_happy","data":{"text":" Done."}}`, + `{"type":"step_end","sessionId":"ses_happy"}`, + }, "\n") + + result := b.processEvents(strings.NewReader(lines), ch) + + if result.status != "completed" { + t.Errorf("status: got %q, want %q", result.status, "completed") + } + if result.sessionID != "ses_happy" { + t.Errorf("sessionID: got %q, want %q", result.sessionID, "ses_happy") + } + if result.output != "Analyzing... Done." { + t.Errorf("output: got %q, want %q", result.output, "Analyzing... Done.") + } + if result.errMsg != "" { + t.Errorf("errMsg: got %q, want empty", result.errMsg) + } + + // Drain and verify messages. + close(ch) + var msgs []Message + for m := range ch { + msgs = append(msgs, m) + } + + // Expected: status(running), text, tool-use, tool-result, text = 5 messages + if len(msgs) != 5 { + t.Fatalf("expected 5 messages, got %d: %+v", len(msgs), msgs) + } + if msgs[0].Type != MessageStatus || msgs[0].Status != "running" { + t.Errorf("msg[0]: got %+v, want status=running", msgs[0]) + } + if msgs[1].Type != MessageText || msgs[1].Content != "Analyzing..." { + t.Errorf("msg[1]: got %+v", msgs[1]) + } + if msgs[2].Type != MessageToolUse || msgs[2].Tool != "bash" { + t.Errorf("msg[2]: got %+v, want tool-use(bash)", msgs[2]) + } + if msgs[3].Type != MessageToolResult || msgs[3].Output != "file.go\n" { + t.Errorf("msg[3]: got %+v, want tool-result", msgs[3]) + } + if msgs[4].Type != MessageText || msgs[4].Content != " Done." { + t.Errorf("msg[4]: got %+v", msgs[4]) + } +} + +func TestOpenclawProcessEventsErrorCausesFailedStatus(t *testing.T) { + t.Parallel() + + b := &openclawBackend{cfg: Config{Logger: slog.Default()}} + ch := make(chan Message, 256) + + lines := strings.Join([]string{ + `{"type":"step_start","sessionId":"ses_err"}`, + `{"type":"error","sessionId":"ses_err","data":{"message":"Model not found: bad/model"}}`, + `{"type":"step_end","sessionId":"ses_err"}`, + }, "\n") + + result := b.processEvents(strings.NewReader(lines), ch) + + if result.status != "failed" { + t.Errorf("status: got %q, want %q", result.status, "failed") + } + if result.errMsg != "Model not found: bad/model" { + t.Errorf("errMsg: got %q", result.errMsg) + } + if result.sessionID != "ses_err" { + t.Errorf("sessionID: got %q, want %q", result.sessionID, "ses_err") + } + + close(ch) + var errorMsgs int + for m := range ch { + if m.Type == MessageError { + errorMsgs++ + } + } + if errorMsgs != 1 { + t.Errorf("expected 1 error message, got %d", errorMsgs) + } +} + +func TestOpenclawProcessEventsSessionIDExtracted(t *testing.T) { + t.Parallel() + + b := &openclawBackend{cfg: Config{Logger: slog.Default()}} + ch := make(chan Message, 256) + + lines := strings.Join([]string{ + `{"type":"step_start","sessionId":"ses_first"}`, + `{"type":"text","sessionId":"ses_updated","data":{"text":"hi"}}`, + }, "\n") + + result := b.processEvents(strings.NewReader(lines), ch) + + if result.sessionID != "ses_updated" { + t.Errorf("sessionID: got %q, want %q (should use last seen)", result.sessionID, "ses_updated") + } + + close(ch) +} + +func TestOpenclawProcessEventsScannerError(t *testing.T) { + t.Parallel() + + b := &openclawBackend{cfg: Config{Logger: slog.Default()}} + ch := make(chan Message, 256) + + result := b.processEvents(&ioErrReader{ + data: `{"type":"text","sessionId":"ses_scan","data":{"text":"before error"}}` + "\n", + }, ch) + + if result.status != "failed" { + t.Errorf("status: got %q, want %q", result.status, "failed") + } + if !strings.Contains(result.errMsg, "stdout read error") { + t.Errorf("errMsg: got %q, want it to contain 'stdout read error'", result.errMsg) + } + if result.output != "before error" { + t.Errorf("output: got %q, want %q", result.output, "before error") + } + + close(ch) +} + +func TestOpenclawProcessEventsEmptyLines(t *testing.T) { + t.Parallel() + + b := &openclawBackend{cfg: Config{Logger: slog.Default()}} + ch := make(chan Message, 256) + + lines := strings.Join([]string{ + "", + " ", + "not json at all", + `{"type":"text","sessionId":"ses_ok","data":{"text":"valid"}}`, + "", + }, "\n") + + result := b.processEvents(strings.NewReader(lines), ch) + + if result.status != "completed" { + t.Errorf("status: got %q, want %q", result.status, "completed") + } + if result.output != "valid" { + t.Errorf("output: got %q, want %q", result.output, "valid") + } + if result.sessionID != "ses_ok" { + t.Errorf("sessionID: got %q, want %q", result.sessionID, "ses_ok") + } + + close(ch) + var msgs []Message + for m := range ch { + msgs = append(msgs, m) + } + if len(msgs) != 1 || msgs[0].Type != MessageText { + t.Errorf("expected 1 text message, got %d: %+v", len(msgs), msgs) + } +} + +func TestOpenclawProcessEventsErrorDoesNotRevertToCompleted(t *testing.T) { + t.Parallel() + + b := &openclawBackend{cfg: Config{Logger: slog.Default()}} + ch := make(chan Message, 256) + + lines := strings.Join([]string{ + `{"type":"error","sessionId":"ses_x","data":{"message":"RateLimitError"}}`, + `{"type":"text","sessionId":"ses_x","data":{"text":"recovered?"}}`, + }, "\n") + + result := b.processEvents(strings.NewReader(lines), ch) + + if result.status != "failed" { + t.Errorf("status: got %q, want %q (error should stick)", result.status, "failed") + } + if result.errMsg != "RateLimitError" { + t.Errorf("errMsg: got %q, want %q", result.errMsg, "RateLimitError") + } + + close(ch) +} + +func TestOpenclawProcessEventsResultEvent(t *testing.T) { + t.Parallel() + + b := &openclawBackend{cfg: Config{Logger: slog.Default()}} + ch := make(chan Message, 256) + + lines := strings.Join([]string{ + `{"type":"text","sessionId":"ses_r","data":{"text":"Done"}}`, + `{"type":"result","sessionId":"ses_r","data":{"status":"completed"}}`, + }, "\n") + + result := b.processEvents(strings.NewReader(lines), ch) + + if result.status != "completed" { + t.Errorf("status: got %q, want %q", result.status, "completed") + } + if result.output != "Done" { + t.Errorf("output: got %q, want %q", result.output, "Done") + } + + close(ch) +} + +func TestOpenclawProcessEventsResultErrorStatus(t *testing.T) { + t.Parallel() + + b := &openclawBackend{cfg: Config{Logger: slog.Default()}} + ch := make(chan Message, 256) + + lines := strings.Join([]string{ + `{"type":"result","sessionId":"ses_rf","data":{"status":"error","error":"out of tokens"}}`, + }, "\n") + + result := b.processEvents(strings.NewReader(lines), ch) + + if result.status != "failed" { + t.Errorf("status: got %q, want %q", result.status, "failed") + } + if result.errMsg != "out of tokens" { + t.Errorf("errMsg: got %q, want %q", result.errMsg, "out of tokens") + } + + close(ch) +} + +// ── extractEventText tests ── + +func TestExtractEventTextDirect(t *testing.T) { + t.Parallel() + data := map[string]any{"text": "hello"} + if got := extractEventText(data); got != "hello" { + t.Errorf("got %q, want %q", got, "hello") + } +} + +func TestExtractEventTextNested(t *testing.T) { + t.Parallel() + data := map[string]any{ + "content": map[string]any{"text": "nested hello"}, + } + if got := extractEventText(data); got != "nested hello" { + t.Errorf("got %q, want %q", got, "nested hello") + } +} + +func TestExtractEventTextNil(t *testing.T) { + t.Parallel() + if got := extractEventText(nil); got != "" { + t.Errorf("got %q, want empty", got) + } +} + +// ── extractOCToolOutput tests ── + +func TestExtractOCToolOutputString(t *testing.T) { + t.Parallel() + if got := extractOCToolOutput("hello\n"); got != "hello\n" { + t.Errorf("got %q, want %q", got, "hello\n") + } +} + +func TestExtractOCToolOutputNil(t *testing.T) { + t.Parallel() + if got := extractOCToolOutput(nil); got != "" { + t.Errorf("got %q, want empty", got) + } +} + +func TestExtractOCToolOutputStructured(t *testing.T) { + t.Parallel() + obj := map[string]any{"key": "value"} + got := extractOCToolOutput(obj) + if !strings.Contains(got, `"key"`) || !strings.Contains(got, `"value"`) { + t.Errorf("got %q, expected JSON containing key/value", got) + } +} + +// ── Thinking event with nested content ── + +func TestOpenclawHandleThinkingEventNestedContent(t *testing.T) { + t.Parallel() + + b := &openclawBackend{} + ch := make(chan Message, 10) + + event := openclawEvent{ + Type: "thinking", + Data: map[string]any{ + "content": map[string]any{"text": "Nested thinking"}, + }, + } + + b.handleOCThinkingEvent(event, ch) + + if len(ch) != 1 { + t.Fatalf("expected 1 message, got %d", len(ch)) + } + msg := <-ch + if msg.Type != MessageThinking { + t.Errorf("type: got %v, want MessageThinking", msg.Type) + } + if msg.Content != "Nested thinking" { + t.Errorf("content: got %q, want %q", msg.Content, "Nested thinking") + } +} From dd2ce90b1dbc2892fc1ee207ee687e6986f5a7a4 Mon Sep 17 00:00:00 2001 From: Jiang Bohan Date: Tue, 7 Apr 2026 14:52:54 +0800 Subject: [PATCH 08/16] fix(agent): address openclaw review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove duplicate extractOCToolOutput, reuse extractToolOutput from opencode.go - Rename extractEventText → openclawExtractText to avoid package-level name collisions - Add clarifying comments for error status stickiness and result event behavior - Remove redundant extractOCToolOutput tests (already covered by opencode tests) --- server/pkg/agent/openclaw.go | 31 +++++++++++++---------------- server/pkg/agent/openclaw_test.go | 33 ++++--------------------------- 2 files changed, 18 insertions(+), 46 deletions(-) diff --git a/server/pkg/agent/openclaw.go b/server/pkg/agent/openclaw.go index 7a704119..96c240d8 100644 --- a/server/pkg/agent/openclaw.go +++ b/server/pkg/agent/openclaw.go @@ -152,12 +152,20 @@ func (b *openclawBackend) processEvents(r io.Reader, ch chan<- Message) openclaw case "tool_call": b.handleOCToolCallEvent(event, ch) case "error": + // NOTE: error events unconditionally set finalStatus to "failed" and + // it stays sticky — subsequent text or result events won't revert it. + // This is intentional: once an error fires, the session is considered + // failed regardless of later events. b.handleOCErrorEvent(event, ch, &finalStatus, &finalError) case "step_start": trySend(ch, Message{Type: MessageStatus, Status: "running"}) case "step_end": // Captures final session ID from step_end if present. case "result": + // The result event only updates status on explicit failure. A + // "completed" result is a no-op because finalStatus defaults to + // "completed". Any unrecognized status (e.g. "partial") is also + // treated as success — update this if OpenClaw adds new statuses. if event.Data != nil { if s, ok := event.Data["status"].(string); ok && s != "" { if s == "error" || s == "failed" { @@ -189,7 +197,7 @@ func (b *openclawBackend) processEvents(r io.Reader, ch chan<- Message) openclaw } func (b *openclawBackend) handleOCTextEvent(event openclawEvent, ch chan<- Message, output *strings.Builder) { - text := extractEventText(event.Data) + text := openclawExtractText(event.Data) if text != "" { output.WriteString(text) trySend(ch, Message{Type: MessageText, Content: text}) @@ -197,7 +205,7 @@ func (b *openclawBackend) handleOCTextEvent(event openclawEvent, ch chan<- Messa } func (b *openclawBackend) handleOCThinkingEvent(event openclawEvent, ch chan<- Message) { - text := extractEventText(event.Data) + text := openclawExtractText(event.Data) if text != "" { trySend(ch, Message{Type: MessageThinking, Content: text}) } @@ -233,7 +241,7 @@ func (b *openclawBackend) handleOCToolCallEvent(event openclawEvent, ch chan<- M // If the tool has completed, also emit a tool-result message. status, _ := event.Data["status"].(string) if status == "completed" { - outputStr := extractOCToolOutput(event.Data["output"]) + outputStr := extractToolOutput(event.Data["output"]) trySend(ch, Message{ Type: MessageToolResult, Tool: name, @@ -266,8 +274,9 @@ func (b *openclawBackend) handleOCErrorEvent(event openclawEvent, ch chan<- Mess *finalError = errMsg } -// extractEventText extracts text content from an event data map. -func extractEventText(data map[string]any) string { +// openclawExtractText extracts text content from an openclaw event data map. +// Supports both flat {"text": "..."} and nested {"content": {"text": "..."}} layouts. +func openclawExtractText(data map[string]any) string { if data == nil { return "" } @@ -284,18 +293,6 @@ func extractEventText(data map[string]any) string { return "" } -// extractOCToolOutput converts tool output (string or structured) into a string. -func extractOCToolOutput(output any) string { - if output == nil { - return "" - } - if s, ok := output.(string); ok { - return s - } - data, _ := json.Marshal(output) - return string(data) -} - // ── JSON types for `openclaw agent --output-format stream-json` stdout events ── // openclawEvent represents a single NDJSON line from OpenClaw's stream-json output. diff --git a/server/pkg/agent/openclaw_test.go b/server/pkg/agent/openclaw_test.go index c55c7abe..3e3a6c38 100644 --- a/server/pkg/agent/openclaw_test.go +++ b/server/pkg/agent/openclaw_test.go @@ -517,12 +517,12 @@ func TestOpenclawProcessEventsResultErrorStatus(t *testing.T) { close(ch) } -// ── extractEventText tests ── +// ── openclawExtractText tests ── func TestExtractEventTextDirect(t *testing.T) { t.Parallel() data := map[string]any{"text": "hello"} - if got := extractEventText(data); got != "hello" { + if got := openclawExtractText(data); got != "hello" { t.Errorf("got %q, want %q", got, "hello") } } @@ -532,43 +532,18 @@ func TestExtractEventTextNested(t *testing.T) { data := map[string]any{ "content": map[string]any{"text": "nested hello"}, } - if got := extractEventText(data); got != "nested hello" { + if got := openclawExtractText(data); got != "nested hello" { t.Errorf("got %q, want %q", got, "nested hello") } } func TestExtractEventTextNil(t *testing.T) { t.Parallel() - if got := extractEventText(nil); got != "" { + if got := openclawExtractText(nil); got != "" { t.Errorf("got %q, want empty", got) } } -// ── extractOCToolOutput tests ── - -func TestExtractOCToolOutputString(t *testing.T) { - t.Parallel() - if got := extractOCToolOutput("hello\n"); got != "hello\n" { - t.Errorf("got %q, want %q", got, "hello\n") - } -} - -func TestExtractOCToolOutputNil(t *testing.T) { - t.Parallel() - if got := extractOCToolOutput(nil); got != "" { - t.Errorf("got %q, want empty", got) - } -} - -func TestExtractOCToolOutputStructured(t *testing.T) { - t.Parallel() - obj := map[string]any{"key": "value"} - got := extractOCToolOutput(obj) - if !strings.Contains(got, `"key"`) || !strings.Contains(got, `"value"`) { - t.Errorf("got %q, expected JSON containing key/value", got) - } -} - // ── Thinking event with nested content ── func TestOpenclawHandleThinkingEventNestedContent(t *testing.T) { From 9495179923aef341d1d9b7bbfd6ef4423872789e Mon Sep 17 00:00:00 2001 From: Jiang Bohan Date: Tue, 7 Apr 2026 15:01:48 +0800 Subject: [PATCH 09/16] fix(daemon): ensure multica CLI is on PATH in agent task environment Prepend the directory of the running multica binary to PATH in the agent's environment variables. This fixes the issue where isolated runtimes (e.g. Codex sandbox) cannot find the multica CLI, causing agent tasks to fail immediately with "command not found: multica". Closes #451 --- server/internal/daemon/daemon.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/server/internal/daemon/daemon.go b/server/internal/daemon/daemon.go index 9d1b494a..b264d04c 100644 --- a/server/internal/daemon/daemon.go +++ b/server/internal/daemon/daemon.go @@ -921,6 +921,14 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string, taskLo "MULTICA_AGENT_ID": task.AgentID, "MULTICA_TASK_ID": task.ID, } + // Ensure the multica CLI is on PATH inside the agent's environment. + // Some runtimes (e.g. Codex) run in an isolated sandbox that may not + // inherit the daemon's PATH. Prepend the directory of the running + // multica binary so that `multica` commands in the agent always resolve. + if selfBin, err := os.Executable(); err == nil { + binDir := filepath.Dir(selfBin) + agentEnv["PATH"] = binDir + string(os.PathListSeparator) + os.Getenv("PATH") + } // Point Codex to the per-task CODEX_HOME so it discovers skills natively // without polluting the system ~/.codex/skills/. if env.CodexHome != "" { From 47917825d1aba2035d27ecf24d9ac79362839d00 Mon Sep 17 00:00:00 2001 From: Jiang Bohan Date: Tue, 7 Apr 2026 15:22:02 +0800 Subject: [PATCH 10/16] fix(daemon): correct duplicate sub-step lettering in workflow instructions When repos are present, sub-steps c/d/e/f are now distinct instead of having two 'c' steps. Each branch (with/without repos) now has its own complete set of correctly lettered sub-steps. --- server/internal/daemon/execenv/runtime_config.go | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/server/internal/daemon/execenv/runtime_config.go b/server/internal/daemon/execenv/runtime_config.go index e9e8f9ce..80166e27 100644 --- a/server/internal/daemon/execenv/runtime_config.go +++ b/server/internal/daemon/execenv/runtime_config.go @@ -99,13 +99,16 @@ func buildMetaSkillContent(provider string, ctx TaskContextForEnv) string { b.WriteString(" a. Run `multica repo checkout ` to check out the appropriate repository\n") b.WriteString(" b. `cd` into the checked-out directory\n") b.WriteString(" c. Implement the changes and commit\n") + b.WriteString(" d. Push the branch to the remote\n") + b.WriteString(" e. Create a pull request (decide the target branch based on the repo's conventions)\n") + fmt.Fprintf(&b, " f. Post the PR link as a comment: `multica issue comment add %s --content \"PR: \"`\n", ctx.IssueID) } else { b.WriteString(" a. Create a new branch\n") b.WriteString(" b. Implement the changes and commit\n") + b.WriteString(" c. Push the branch to the remote\n") + b.WriteString(" d. Create a pull request (decide the target branch based on the repo's conventions)\n") + fmt.Fprintf(&b, " e. Post the PR link as a comment: `multica issue comment add %s --content \"PR: \"`\n", ctx.IssueID) } - b.WriteString(" c. Push the branch to the remote\n") - b.WriteString(" d. Create a pull request (decide the target branch based on the repo's conventions)\n") - fmt.Fprintf(&b, " e. Post the PR link as a comment: `multica issue comment add %s --content \"PR: \"`\n", ctx.IssueID) b.WriteString("5. If the task does not require code (e.g. research, documentation), post your findings as a comment\n") fmt.Fprintf(&b, "6. Run `multica issue status %s in_review`\n", ctx.IssueID) fmt.Fprintf(&b, "7. If blocked, run `multica issue status %s blocked` and post a comment explaining why\n\n", ctx.IssueID) From 14fe8e9df9f03d1f7c809e0d6f6b2269a20c261d Mon Sep 17 00:00:00 2001 From: Jiang Bohan Date: Tue, 7 Apr 2026 15:25:26 +0800 Subject: [PATCH 11/16] feat(auth): add Google OAuth login Support Google login that links to existing accounts by email. When a user who registered via email OTP signs in with Google using the same email, they are linked to the same account. Backend: - Add POST /auth/google endpoint that exchanges Google auth code for tokens, fetches user profile, and calls findOrCreateUser() - Updates user name and avatar from Google profile on first Google login Frontend: - Add "Continue with Google" button on login page (shown when NEXT_PUBLIC_GOOGLE_CLIENT_ID is configured) - Add /auth/callback page to handle Google OAuth redirect - Add loginWithGoogle to auth store and API client --- .env.example | 1 + apps/web/app/(auth)/callback/page.tsx | 90 +++++++++++++++ apps/web/app/(auth)/login/page.tsx | 58 +++++++++- apps/web/features/auth/store.ts | 10 ++ apps/web/shared/api/client.ts | 7 ++ server/cmd/server/router.go | 1 + server/internal/handler/auth.go | 158 ++++++++++++++++++++++++++ 7 files changed, 324 insertions(+), 1 deletion(-) create mode 100644 apps/web/app/(auth)/callback/page.tsx diff --git a/.env.example b/.env.example index bfa38ae7..8a98d2d8 100644 --- a/.env.example +++ b/.env.example @@ -29,6 +29,7 @@ RESEND_FROM_EMAIL=noreply@multica.ai GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= GOOGLE_REDIRECT_URI=http://localhost:3000/auth/callback +NEXT_PUBLIC_GOOGLE_CLIENT_ID= # S3 / CloudFront S3_BUCKET= diff --git a/apps/web/app/(auth)/callback/page.tsx b/apps/web/app/(auth)/callback/page.tsx new file mode 100644 index 00000000..660f9fd6 --- /dev/null +++ b/apps/web/app/(auth)/callback/page.tsx @@ -0,0 +1,90 @@ +"use client"; + +import { Suspense, useEffect, useState } from "react"; +import { useSearchParams, useRouter } from "next/navigation"; +import { useAuthStore } from "@/features/auth"; +import { useWorkspaceStore } from "@/features/workspace"; +import { api } from "@/shared/api"; +import { + Card, + CardHeader, + CardTitle, + CardDescription, + CardContent, +} from "@/components/ui/card"; +import { Loader2 } from "lucide-react"; + +function CallbackContent() { + const router = useRouter(); + const searchParams = useSearchParams(); + const loginWithGoogle = useAuthStore((s) => s.loginWithGoogle); + const hydrateWorkspace = useWorkspaceStore((s) => s.hydrateWorkspace); + const [error, setError] = useState(""); + + useEffect(() => { + const code = searchParams.get("code"); + if (!code) { + setError("Missing authorization code"); + return; + } + + const errorParam = searchParams.get("error"); + if (errorParam) { + setError(errorParam === "access_denied" ? "Access denied" : errorParam); + return; + } + + const redirectUri = `${window.location.origin}/auth/callback`; + + loginWithGoogle(code, redirectUri) + .then(async () => { + const wsList = await api.listWorkspaces(); + const lastWsId = localStorage.getItem("multica_workspace_id"); + await hydrateWorkspace(wsList, lastWsId); + router.push("/issues"); + }) + .catch((err) => { + setError(err instanceof Error ? err.message : "Login failed"); + }); + }, [searchParams, loginWithGoogle, hydrateWorkspace, router]); + + if (error) { + return ( +
+ + + Login Failed + {error} + + + + Back to login + + + +
+ ); + } + + return ( +
+ + + Signing in... + Please wait while we complete your login + + + + + +
+ ); +} + +export default function CallbackPage() { + return ( + + + + ); +} diff --git a/apps/web/app/(auth)/login/page.tsx b/apps/web/app/(auth)/login/page.tsx index 74d60858..34194933 100644 --- a/apps/web/app/(auth)/login/page.tsx +++ b/apps/web/app/(auth)/login/page.tsx @@ -282,6 +282,22 @@ function LoginPageContent() { ); } + const googleClientId = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID; + + const handleGoogleLogin = () => { + if (!googleClientId) return; + const redirectUri = `${window.location.origin}/auth/callback`; + const params = new URLSearchParams({ + client_id: googleClientId, + redirect_uri: redirectUri, + response_type: "code", + scope: "openid email profile", + access_type: "offline", + prompt: "select_account", + }); + window.location.href = `https://accounts.google.com/o/oauth2/v2/auth?${params}`; + }; + return (
@@ -307,7 +323,7 @@ function LoginPageContent() { )} - + + {googleClientId && ( + <> +
+
+ +
+
+ or +
+
+ + + )}
diff --git a/apps/web/features/auth/store.ts b/apps/web/features/auth/store.ts index 0f6ce7be..12f85513 100644 --- a/apps/web/features/auth/store.ts +++ b/apps/web/features/auth/store.ts @@ -12,6 +12,7 @@ interface AuthState { initialize: () => Promise; sendCode: (email: string) => Promise; verifyCode: (email: string, code: string) => Promise; + loginWithGoogle: (code: string, redirectUri: string) => Promise; logout: () => void; setUser: (user: User) => void; } @@ -53,6 +54,15 @@ export const useAuthStore = create((set) => ({ return user; }, + loginWithGoogle: async (code: string, redirectUri: string) => { + const { token, user } = await api.googleLogin(code, redirectUri); + localStorage.setItem("multica_token", token); + api.setToken(token); + setLoggedInCookie(); + set({ user }); + return user; + }, + logout: () => { localStorage.removeItem("multica_token"); api.setToken(null); diff --git a/apps/web/shared/api/client.ts b/apps/web/shared/api/client.ts index f7323a16..5d7d2e79 100644 --- a/apps/web/shared/api/client.ts +++ b/apps/web/shared/api/client.ts @@ -144,6 +144,13 @@ export class ApiClient { }); } + async googleLogin(code: string, redirectUri: string): Promise { + return this.fetch("/auth/google", { + method: "POST", + body: JSON.stringify({ code, redirect_uri: redirectUri }), + }); + } + async getMe(): Promise { return this.fetch("/api/me"); } diff --git a/server/cmd/server/router.go b/server/cmd/server/router.go index 477b9ae0..a7500007 100644 --- a/server/cmd/server/router.go +++ b/server/cmd/server/router.go @@ -82,6 +82,7 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route // Auth (public) r.Post("/auth/send-code", h.SendCode) r.Post("/auth/verify-code", h.VerifyCode) + r.Post("/auth/google", h.GoogleLogin) // Daemon API routes (all require a valid token) r.Route("/api/daemon", func(r chi.Router) { diff --git a/server/internal/handler/auth.go b/server/internal/handler/auth.go index 5339190c..2edb7f60 100644 --- a/server/internal/handler/auth.go +++ b/server/internal/handler/auth.go @@ -7,8 +7,10 @@ import ( "encoding/binary" "encoding/json" "fmt" + "io" "log/slog" "net/http" + "net/url" "os" "strings" "time" @@ -334,6 +336,162 @@ type UpdateMeRequest struct { AvatarURL *string `json:"avatar_url"` } +type GoogleLoginRequest struct { + Code string `json:"code"` + RedirectURI string `json:"redirect_uri"` +} + +type googleTokenResponse struct { + AccessToken string `json:"access_token"` + IDToken string `json:"id_token"` + TokenType string `json:"token_type"` +} + +type googleUserInfo struct { + Email string `json:"email"` + Name string `json:"name"` + Picture string `json:"picture"` +} + +func (h *Handler) GoogleLogin(w http.ResponseWriter, r *http.Request) { + var req GoogleLoginRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + + if req.Code == "" { + writeError(w, http.StatusBadRequest, "code is required") + return + } + + clientID := os.Getenv("GOOGLE_CLIENT_ID") + clientSecret := os.Getenv("GOOGLE_CLIENT_SECRET") + if clientID == "" || clientSecret == "" { + writeError(w, http.StatusServiceUnavailable, "Google login is not configured") + return + } + + redirectURI := req.RedirectURI + if redirectURI == "" { + redirectURI = os.Getenv("GOOGLE_REDIRECT_URI") + } + + // Exchange authorization code for tokens. + tokenResp, err := http.PostForm("https://oauth2.googleapis.com/token", url.Values{ + "code": {req.Code}, + "client_id": {clientID}, + "client_secret": {clientSecret}, + "redirect_uri": {redirectURI}, + "grant_type": {"authorization_code"}, + }) + if err != nil { + slog.Error("google oauth token exchange failed", "error", err) + writeError(w, http.StatusBadGateway, "failed to exchange code with Google") + return + } + defer tokenResp.Body.Close() + + tokenBody, err := io.ReadAll(tokenResp.Body) + if err != nil { + writeError(w, http.StatusBadGateway, "failed to read Google token response") + return + } + + if tokenResp.StatusCode != http.StatusOK { + slog.Error("google oauth token exchange returned error", "status", tokenResp.StatusCode, "body", string(tokenBody)) + writeError(w, http.StatusBadRequest, "failed to exchange code with Google") + return + } + + var gToken googleTokenResponse + if err := json.Unmarshal(tokenBody, &gToken); err != nil { + writeError(w, http.StatusBadGateway, "failed to parse Google token response") + return + } + + // Fetch user info from Google. + userInfoReq, _ := http.NewRequestWithContext(r.Context(), http.MethodGet, "https://www.googleapis.com/oauth2/v2/userinfo", nil) + userInfoReq.Header.Set("Authorization", "Bearer "+gToken.AccessToken) + + userInfoResp, err := http.DefaultClient.Do(userInfoReq) + if err != nil { + slog.Error("google userinfo fetch failed", "error", err) + writeError(w, http.StatusBadGateway, "failed to fetch user info from Google") + return + } + defer userInfoResp.Body.Close() + + var gUser googleUserInfo + if err := json.NewDecoder(userInfoResp.Body).Decode(&gUser); err != nil { + writeError(w, http.StatusBadGateway, "failed to parse Google user info") + return + } + + if gUser.Email == "" { + writeError(w, http.StatusBadRequest, "Google account has no email") + return + } + + email := strings.ToLower(strings.TrimSpace(gUser.Email)) + + user, err := h.findOrCreateUser(r.Context(), email) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to create user") + return + } + + // Update name and avatar from Google profile if the user was just created + // (default name is email prefix) or has no avatar yet. + needsUpdate := false + newName := user.Name + newAvatar := user.AvatarUrl + + if gUser.Name != "" && user.Name == strings.Split(email, "@")[0] { + newName = gUser.Name + needsUpdate = true + } + if gUser.Picture != "" && !user.AvatarUrl.Valid { + newAvatar = pgtype.Text{String: gUser.Picture, Valid: true} + needsUpdate = true + } + + if needsUpdate { + updated, err := h.Queries.UpdateUser(r.Context(), db.UpdateUserParams{ + ID: user.ID, + Name: newName, + AvatarUrl: newAvatar, + }) + if err == nil { + user = updated + } + } + + if err := h.ensureUserWorkspace(r.Context(), user); err != nil { + writeError(w, http.StatusInternalServerError, "failed to provision workspace") + return + } + + tokenString, err := h.issueJWT(user) + if err != nil { + slog.Warn("google login failed", append(logger.RequestAttrs(r), "error", err, "email", email)...) + writeError(w, http.StatusInternalServerError, "failed to generate token") + return + } + + if h.CFSigner != nil { + for _, cookie := range h.CFSigner.SignedCookies(time.Now().Add(72 * time.Hour)) { + http.SetCookie(w, cookie) + } + } + + slog.Info("user logged in via google", append(logger.RequestAttrs(r), "user_id", uuidToString(user.ID), "email", user.Email)...) + writeJSON(w, http.StatusOK, LoginResponse{ + Token: tokenString, + User: userToResponse(user), + }) +} + func (h *Handler) UpdateMe(w http.ResponseWriter, r *http.Request) { userID, ok := requireUserID(w, r) if !ok { From 0c92fb267477ea418302fc558ee9dd3c59a97159 Mon Sep 17 00:00:00 2001 From: Jiang Bohan Date: Tue, 7 Apr 2026 15:25:29 +0800 Subject: [PATCH 12/16] fix(types): make AgentTrigger.config nullable to match API reality The API can return `config: null` for non-scheduled triggers, but the type was `Record` which doesn't reflect this. Update to `Record | null` so TypeScript catches unsafe access at compile time. Follow-up to #415. --- apps/web/shared/types/agent.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/shared/types/agent.ts b/apps/web/shared/types/agent.ts index 9f596ab4..b478d101 100644 --- a/apps/web/shared/types/agent.ts +++ b/apps/web/shared/types/agent.ts @@ -36,7 +36,7 @@ export interface AgentTrigger { id: string; type: AgentTriggerType; enabled: boolean; - config: Record; + config: Record | null; } export interface AgentTask { From 30cda933bc2a00cec3d15cb10fab49660e95ce14 Mon Sep 17 00:00:00 2001 From: Jiang Bohan Date: Tue, 7 Apr 2026 15:36:32 +0800 Subject: [PATCH 13/16] docs(web): add v0.1.7 changelog entry for 2026-04-05 --- apps/web/features/landing/i18n/en.ts | 14 ++++++++++++++ apps/web/features/landing/i18n/zh.ts | 14 ++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/apps/web/features/landing/i18n/en.ts b/apps/web/features/landing/i18n/en.ts index 5ca5c6a2..d761a690 100644 --- a/apps/web/features/landing/i18n/en.ts +++ b/apps/web/features/landing/i18n/en.ts @@ -272,6 +272,20 @@ export const en: LandingDict = { title: "Changelog", subtitle: "New updates and improvements to Multica.", entries: [ + { + version: "0.1.7", + date: "2026-04-05", + title: "Comment Pagination & CLI Polish", + changes: [ + "Comment list pagination in both the API and CLI", + "Inbox archive now dismisses all items for the same issue at once", + "CLI help output overhauled to match gh CLI style with examples", + "Attachments use UUIDv7 as S3 key and auto-link on issue/comment creation", + "@mention assigned agents on done or cancelled issues", + "Reply @mention inheritance skips when the reply only mentions members", + "Worktree setup preserves existing .env.worktree variables", + ], + }, { version: "0.1.6", date: "2026-04-03", diff --git a/apps/web/features/landing/i18n/zh.ts b/apps/web/features/landing/i18n/zh.ts index 9b463178..e6b18832 100644 --- a/apps/web/features/landing/i18n/zh.ts +++ b/apps/web/features/landing/i18n/zh.ts @@ -272,6 +272,20 @@ export const zh: LandingDict = { title: "\u66f4\u65b0\u65e5\u5fd7", subtitle: "Multica \u7684\u6700\u65b0\u66f4\u65b0\u548c\u6539\u8fdb\u3002", entries: [ + { + version: "0.1.7", + date: "2026-04-05", + title: "评论分页与 CLI 优化", + changes: [ + "评论列表支持分页,API 和 CLI 均已适配", + "收件箱归档操作现在一次性归档同一 Issue 的所有通知", + "CLI 帮助输出重新设计,匹配 gh CLI 风格并增加示例", + "附件使用 UUIDv7 作为 S3 key,创建 Issue/评论时自动关联附件", + "支持在已完成或已取消的 Issue 上 @提及已分配的 Agent", + "回复仅 @提及成员时跳过父级提及继承逻辑", + "Worktree 环境配置保留已有的 .env.worktree 变量", + ], + }, { version: "0.1.6", date: "2026-04-03", From 7f2ea9857dea51e538ceb2d9a4fb46a3e9902c96 Mon Sep 17 00:00:00 2001 From: Jiang Bohan Date: Tue, 7 Apr 2026 15:47:44 +0800 Subject: [PATCH 14/16] fix(auth): move Google callback page to correct route path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The callback page was placed under app/(auth)/callback/ — a Next.js route group — which mapped to /callback instead of /auth/callback. Move it to app/auth/callback/ so the URL matches the Google OAuth redirect URI. --- apps/web/app/{(auth) => auth}/callback/page.tsx | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename apps/web/app/{(auth) => auth}/callback/page.tsx (100%) diff --git a/apps/web/app/(auth)/callback/page.tsx b/apps/web/app/auth/callback/page.tsx similarity index 100% rename from apps/web/app/(auth)/callback/page.tsx rename to apps/web/app/auth/callback/page.tsx From abcc7bf3cd08a6560c002cb7dcad64e4eb4aef80 Mon Sep 17 00:00:00 2001 From: devv-eve Date: Tue, 7 Apr 2026 00:59:03 -0700 Subject: [PATCH 15/16] feat(issues): load all open issues without limit, paginate closed (#459) - Add ListOpenIssues SQL query (excludes done/cancelled, no LIMIT) - Add CountIssues SQL query for true total count - Backend: support open_only=true param, fix total to return real count - Frontend: two-phase fetch in issue store (all open + first 50 closed) - Add fetchMoreClosed action for paginated closed issue loading - Replace all hardcoded limit:200 with store.fetch() calls Resolves MUL-369 Co-authored-by: Devv Co-authored-by: Claude Opus 4.6 (1M context) --- .../components/batch-action-toolbar.tsx | 8 +- .../issues/components/issues-page.tsx | 4 +- apps/web/features/issues/store.ts | 46 ++++++++++- apps/web/shared/api/client.ts | 1 + apps/web/shared/types/api.ts | 1 + server/internal/handler/issue.go | 62 +++++++++++--- server/pkg/db/generated/issue.sql.go | 81 +++++++++++++++++++ server/pkg/db/queries/issue.sql | 15 ++++ 8 files changed, 194 insertions(+), 24 deletions(-) diff --git a/apps/web/features/issues/components/batch-action-toolbar.tsx b/apps/web/features/issues/components/batch-action-toolbar.tsx index 9b259e3e..7ac9905f 100644 --- a/apps/web/features/issues/components/batch-action-toolbar.tsx +++ b/apps/web/features/issues/components/batch-action-toolbar.tsx @@ -53,9 +53,7 @@ export function BatchActionToolbar() { toast.success(`Updated ${count} issue${count > 1 ? "s" : ""}`); } catch { toast.error("Failed to update issues"); - api.listIssues({ limit: 200 }).then((res) => { - useIssueStore.getState().setIssues(res.issues); - }).catch(console.error); + useIssueStore.getState().fetch().catch(console.error); } finally { setLoading(false); } @@ -72,9 +70,7 @@ export function BatchActionToolbar() { toast.success(`Deleted ${count} issue${count > 1 ? "s" : ""}`); } catch { toast.error("Failed to delete issues"); - api.listIssues({ limit: 200 }).then((res) => { - useIssueStore.getState().setIssues(res.issues); - }).catch(console.error); + useIssueStore.getState().fetch().catch(console.error); } finally { setLoading(false); setDeleteOpen(false); diff --git a/apps/web/features/issues/components/issues-page.tsx b/apps/web/features/issues/components/issues-page.tsx index aa070940..56a33580 100644 --- a/apps/web/features/issues/components/issues-page.tsx +++ b/apps/web/features/issues/components/issues-page.tsx @@ -82,9 +82,7 @@ export function IssuesPage() { api.updateIssue(issueId, updates).catch(() => { toast.error("Failed to move issue"); - api.listIssues({ limit: 200 }).then((res) => { - useIssueStore.getState().setIssues(res.issues); - }).catch(console.error); + useIssueStore.getState().fetch().catch(console.error); }); }, [] diff --git a/apps/web/features/issues/store.ts b/apps/web/features/issues/store.ts index 1e47b7d7..05add4f7 100644 --- a/apps/web/features/issues/store.ts +++ b/apps/web/features/issues/store.ts @@ -8,11 +8,16 @@ import { createLogger } from "@/shared/logger"; const logger = createLogger("issue-store"); +const CLOSED_PAGE_SIZE = 50; + interface IssueState { issues: Issue[]; loading: boolean; activeIssueId: string | null; + hasMoreClosed: boolean; + closedOffset: number; fetch: () => Promise; + fetchMoreClosed: () => Promise; setIssues: (issues: Issue[]) => void; addIssue: (issue: Issue) => void; updateIssue: (id: string, updates: Partial) => void; @@ -24,15 +29,28 @@ export const useIssueStore = create((set, get) => ({ issues: [], loading: true, activeIssueId: null, + hasMoreClosed: false, + closedOffset: 0, fetch: async () => { logger.debug("fetch start"); const isInitialLoad = get().issues.length === 0; if (isInitialLoad) set({ loading: true }); try { - const res = await api.listIssues({ limit: 200 }); - logger.info("fetched", res.issues.length, "issues"); - set({ issues: res.issues, loading: false }); + // Phase 1: fetch ALL open issues (no limit) + // Phase 2: fetch first page of closed issues + const [openRes, closedRes] = await Promise.all([ + api.listIssues({ open_only: true }), + api.listIssues({ status: "done", limit: CLOSED_PAGE_SIZE, offset: 0 }), + ]); + const allIssues = [...openRes.issues, ...closedRes.issues]; + logger.info("fetched", openRes.issues.length, "open +", closedRes.issues.length, "closed issues"); + set({ + issues: allIssues, + loading: false, + hasMoreClosed: closedRes.issues.length >= CLOSED_PAGE_SIZE, + closedOffset: CLOSED_PAGE_SIZE, + }); } catch (err) { logger.error("fetch failed", err); toast.error("Failed to load issues"); @@ -40,6 +58,28 @@ export const useIssueStore = create((set, get) => ({ } }, + fetchMoreClosed: async () => { + const { closedOffset } = get(); + try { + const res = await api.listIssues({ + status: "done", + limit: CLOSED_PAGE_SIZE, + offset: closedOffset, + }); + set((s) => ({ + issues: [ + ...s.issues, + ...res.issues.filter((ni) => !s.issues.some((ei) => ei.id === ni.id)), + ], + closedOffset: closedOffset + CLOSED_PAGE_SIZE, + hasMoreClosed: res.issues.length >= CLOSED_PAGE_SIZE, + })); + } catch (err) { + logger.error("fetchMoreClosed failed", err); + toast.error("Failed to load more issues"); + } + }, + setIssues: (issues) => set({ issues }), addIssue: (issue) => set((s) => ({ diff --git a/apps/web/shared/api/client.ts b/apps/web/shared/api/client.ts index 5d7d2e79..2c3a4207 100644 --- a/apps/web/shared/api/client.ts +++ b/apps/web/shared/api/client.ts @@ -172,6 +172,7 @@ export class ApiClient { if (params?.status) search.set("status", params.status); if (params?.priority) search.set("priority", params.priority); if (params?.assignee_id) search.set("assignee_id", params.assignee_id); + if (params?.open_only) search.set("open_only", "true"); return this.fetch(`/api/issues?${search}`); } diff --git a/apps/web/shared/types/api.ts b/apps/web/shared/types/api.ts index 882750bc..39e4d712 100644 --- a/apps/web/shared/types/api.ts +++ b/apps/web/shared/types/api.ts @@ -32,6 +32,7 @@ export interface ListIssuesParams { status?: IssueStatus; priority?: IssuePriority; assignee_id?: string; + open_only?: boolean; } export interface ListIssuesResponse { diff --git a/server/internal/handler/issue.go b/server/internal/handler/issue.go index 0259bb21..389bbede 100644 --- a/server/internal/handler/issue.go +++ b/server/internal/handler/issue.go @@ -83,6 +83,42 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) { ctx := r.Context() workspaceID := resolveWorkspaceID(r) + wsUUID := parseUUID(workspaceID) + + // Parse optional filter params + var priorityFilter pgtype.Text + if p := r.URL.Query().Get("priority"); p != "" { + priorityFilter = pgtype.Text{String: p, Valid: true} + } + var assigneeFilter pgtype.UUID + if a := r.URL.Query().Get("assignee_id"); a != "" { + assigneeFilter = parseUUID(a) + } + + // open_only=true returns all non-done/cancelled issues (no limit). + if r.URL.Query().Get("open_only") == "true" { + issues, err := h.Queries.ListOpenIssues(ctx, db.ListOpenIssuesParams{ + WorkspaceID: wsUUID, + Priority: priorityFilter, + AssigneeID: assigneeFilter, + }) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to list issues") + return + } + + prefix := h.getIssuePrefix(ctx, wsUUID) + resp := make([]IssueResponse, len(issues)) + for i, issue := range issues { + resp[i] = issueToResponse(issue, prefix) + } + + writeJSON(w, http.StatusOK, map[string]any{ + "issues": resp, + "total": len(resp), + }) + return + } limit := 100 offset := 0 @@ -97,22 +133,13 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) { } } - // Parse optional filter params var statusFilter pgtype.Text if s := r.URL.Query().Get("status"); s != "" { statusFilter = pgtype.Text{String: s, Valid: true} } - var priorityFilter pgtype.Text - if p := r.URL.Query().Get("priority"); p != "" { - priorityFilter = pgtype.Text{String: p, Valid: true} - } - var assigneeFilter pgtype.UUID - if a := r.URL.Query().Get("assignee_id"); a != "" { - assigneeFilter = parseUUID(a) - } issues, err := h.Queries.ListIssues(ctx, db.ListIssuesParams{ - WorkspaceID: parseUUID(workspaceID), + WorkspaceID: wsUUID, Limit: int32(limit), Offset: int32(offset), Status: statusFilter, @@ -124,7 +151,18 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) { return } - prefix := h.getIssuePrefix(ctx, parseUUID(workspaceID)) + // Get the true total count for pagination awareness. + total, err := h.Queries.CountIssues(ctx, db.CountIssuesParams{ + WorkspaceID: wsUUID, + Status: statusFilter, + Priority: priorityFilter, + AssigneeID: assigneeFilter, + }) + if err != nil { + total = int64(len(issues)) + } + + prefix := h.getIssuePrefix(ctx, wsUUID) resp := make([]IssueResponse, len(issues)) for i, issue := range issues { resp[i] = issueToResponse(issue, prefix) @@ -132,7 +170,7 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{ "issues": resp, - "total": len(resp), + "total": total, }) } diff --git a/server/pkg/db/generated/issue.sql.go b/server/pkg/db/generated/issue.sql.go index f899eb6e..97ec6788 100644 --- a/server/pkg/db/generated/issue.sql.go +++ b/server/pkg/db/generated/issue.sql.go @@ -11,6 +11,33 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +const countIssues = `-- name: CountIssues :one +SELECT count(*) FROM issue +WHERE workspace_id = $1 + AND ($2::text IS NULL OR status = $2) + AND ($3::text IS NULL OR priority = $3) + AND ($4::uuid IS NULL OR assignee_id = $4) +` + +type CountIssuesParams struct { + WorkspaceID pgtype.UUID `json:"workspace_id"` + Status pgtype.Text `json:"status"` + Priority pgtype.Text `json:"priority"` + AssigneeID pgtype.UUID `json:"assignee_id"` +} + +func (q *Queries) CountIssues(ctx context.Context, arg CountIssuesParams) (int64, error) { + row := q.db.QueryRow(ctx, countIssues, + arg.WorkspaceID, + arg.Status, + arg.Priority, + arg.AssigneeID, + ) + var count int64 + err := row.Scan(&count) + return count, err +} + const createIssue = `-- name: CreateIssue :one INSERT INTO issue ( workspace_id, title, description, status, priority, @@ -254,6 +281,60 @@ func (q *Queries) ListIssues(ctx context.Context, arg ListIssuesParams) ([]Issue return items, nil } +const listOpenIssues = `-- name: ListOpenIssues :many +SELECT id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number FROM issue +WHERE workspace_id = $1 + AND status NOT IN ('done', 'cancelled') + AND ($2::text IS NULL OR priority = $2) + AND ($3::uuid IS NULL OR assignee_id = $3) +ORDER BY position ASC, created_at DESC +` + +type ListOpenIssuesParams struct { + WorkspaceID pgtype.UUID `json:"workspace_id"` + Priority pgtype.Text `json:"priority"` + AssigneeID pgtype.UUID `json:"assignee_id"` +} + +func (q *Queries) ListOpenIssues(ctx context.Context, arg ListOpenIssuesParams) ([]Issue, error) { + rows, err := q.db.Query(ctx, listOpenIssues, arg.WorkspaceID, arg.Priority, arg.AssigneeID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Issue{} + for rows.Next() { + var i Issue + if err := rows.Scan( + &i.ID, + &i.WorkspaceID, + &i.Title, + &i.Description, + &i.Status, + &i.Priority, + &i.AssigneeType, + &i.AssigneeID, + &i.CreatorType, + &i.CreatorID, + &i.ParentIssueID, + &i.AcceptanceCriteria, + &i.ContextRefs, + &i.Position, + &i.DueDate, + &i.CreatedAt, + &i.UpdatedAt, + &i.Number, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const updateIssue = `-- name: UpdateIssue :one UPDATE issue SET title = COALESCE($2, title), diff --git a/server/pkg/db/queries/issue.sql b/server/pkg/db/queries/issue.sql index edc229c3..c8821ffb 100644 --- a/server/pkg/db/queries/issue.sql +++ b/server/pkg/db/queries/issue.sql @@ -51,3 +51,18 @@ RETURNING *; -- name: DeleteIssue :exec DELETE FROM issue WHERE id = $1; + +-- name: ListOpenIssues :many +SELECT * FROM issue +WHERE workspace_id = $1 + AND status NOT IN ('done', 'cancelled') + AND (sqlc.narg('priority')::text IS NULL OR priority = sqlc.narg('priority')) + AND (sqlc.narg('assignee_id')::uuid IS NULL OR assignee_id = sqlc.narg('assignee_id')) +ORDER BY position ASC, created_at DESC; + +-- name: CountIssues :one +SELECT count(*) FROM issue +WHERE workspace_id = $1 + AND (sqlc.narg('status')::text IS NULL OR status = sqlc.narg('status')) + AND (sqlc.narg('priority')::text IS NULL OR priority = sqlc.narg('priority')) + AND (sqlc.narg('assignee_id')::uuid IS NULL OR assignee_id = sqlc.narg('assignee_id')); From d6b59aade69e418cc59bfb29802417ea6b44f828 Mon Sep 17 00:00:00 2001 From: Jiang Bohan Date: Tue, 7 Apr 2026 17:03:41 +0800 Subject: [PATCH 16/16] docs(web): add v0.1.8 changelog entry for 2026-04-07 --- apps/web/features/landing/i18n/en.ts | 15 +++++++++++++++ apps/web/features/landing/i18n/zh.ts | 15 +++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/apps/web/features/landing/i18n/en.ts b/apps/web/features/landing/i18n/en.ts index d761a690..233edf6a 100644 --- a/apps/web/features/landing/i18n/en.ts +++ b/apps/web/features/landing/i18n/en.ts @@ -272,6 +272,21 @@ export const en: LandingDict = { title: "Changelog", subtitle: "New updates and improvements to Multica.", entries: [ + { + version: "0.1.8", + date: "2026-04-07", + title: "OAuth, OpenClaw & Issue Loading", + changes: [ + "Google OAuth login", + "OpenClaw runtime support for running agents on OpenClaw infrastructure", + "Redesigned agent live card — always sticky with manual expand/collapse toggle", + "Load all open issues without pagination limit; closed issues paginate on scroll", + "JWT and CloudFront cookie expiration extended from 72 hours to 30 days", + "Remember last selected workspace after re-login", + "Daemon ensures multica CLI is on PATH in agent task environment", + "PR template and CLI install guide for agent-driven setup", + ], + }, { version: "0.1.7", date: "2026-04-05", diff --git a/apps/web/features/landing/i18n/zh.ts b/apps/web/features/landing/i18n/zh.ts index e6b18832..d7ec3d01 100644 --- a/apps/web/features/landing/i18n/zh.ts +++ b/apps/web/features/landing/i18n/zh.ts @@ -272,6 +272,21 @@ export const zh: LandingDict = { title: "\u66f4\u65b0\u65e5\u5fd7", subtitle: "Multica \u7684\u6700\u65b0\u66f4\u65b0\u548c\u6539\u8fdb\u3002", entries: [ + { + version: "0.1.8", + date: "2026-04-07", + title: "OAuth、OpenClaw 与 Issue 加载优化", + changes: [ + "支持 Google OAuth 登录", + "新增 OpenClaw 运行时,支持在 OpenClaw 基础设施上运行 Agent", + "Agent 实时卡片重新设计——始终吸顶,支持手动展开/收起", + "打开的 Issue 不再分页限制全量加载,已关闭的 Issue 滚动分页", + "JWT 和 CloudFront Cookie 有效期从 72 小时延长至 30 天", + "重新登录后记住上次选择的工作区", + "守护进程确保 Agent 任务环境中 multica CLI 在 PATH 上", + "新增 PR 模板和面向 Agent 的 CLI 安装指南", + ], + }, { version: "0.1.7", date: "2026-04-05",