Compare commits

...

14 Commits

Author SHA1 Message Date
Bohan Jiang
87f9d0fdd3 refactor(autopilots): open access management as a popover from the edit modal (MUL-3893) (#4765)
The standalone 'Manage access' button on the autopilot detail header was
redundant — anyone who cannot open Edit also cannot manage access. The
first attempt folded it into the edit dialog's sidebar, which read as
cluttered. This instead surfaces it as a compact 'Manage access' button in
the edit modal header that opens a popover with the grant/revoke list.

- Extract the access UI into a reusable AutopilotAccessManager (no Dialog)
- Render it inside a header Popover in edit mode, gated on canManageAccess
- Drop the detail-page button, ManageAccessDialog, and the now-dead
  detail.manage_access i18n key (access.* keys are reused by the popover)

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-30 23:11:29 +08:00
Bohan Jiang
1c010d25c0 Revert "refactor(autopilots): fold access management into the edit dialog (MU…" (#4763)
This reverts commit 48f49d8abc.
2026-06-30 20:16:32 +08:00
Bohan Jiang
48f49d8abc refactor(autopilots): fold access management into the edit dialog (MUL-3893) (#4761)
Remove the standalone 'Manage access' button from the autopilot detail
header and surface the grant/revoke list as an 'Access' section inside
the Edit dialog's configuration sidebar. Anyone who cannot open Edit
already cannot manage access, so the separate affordance was redundant.

- Extract the dialog body into a reusable AutopilotAccessManager
- Render it in edit mode only, gated on canManageAccess
- Drop ManageAccessDialog and its now-dead i18n keys

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-30 20:15:47 +08:00
Jiayuan Zhang
c4209ec7c0 fix(issues): count active issues, not agents, in working chip (#4750)
The Issues board header 'x working' chip derived its count from the set
of distinct running agent_ids, so two agents on the same issue read as
'2 working'. Count distinct issue_ids instead so the number reflects how
many issues agents are working on — matching the filter the chip toggles,
which already narrows the list to those issues. The avatar stack still
shows the distinct agents behind that work.

Adds workspace-agent-working-chip.test.tsx covering the multi-agent /
single-issue case, multi-issue counting, scopedIssueIds filtering, and
the empty state.

Fixes MUL-3875

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-30 18:58:19 +08:00
Bohan Jiang
e57288ba60 feat(usage): log per-run prompt-cache hit ratio (MUL-3887) (#4759)
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-30 18:49:41 +08:00
Multica Eve
f88544da63 docs(changelog): add v0.3.33 entry for the 2026-06-30 release (MUL-3889) (#4756)
* docs(changelog): add v0.3.33 entry for the 2026-06-30 release (MUL-3889)

Adds the v0.3.33 release entry to the /changelog page in all four
landing locales (en, zh-Hans, ja, ko), covering the 16 user-visible
changes that landed on main since v0.3.32 (2026-06-29 release).

The entry groups changes into Features / Improvements / Bug Fixes,
using product-language phrasing per the team convention (no
"modified function X" style notes). The Chinese version follows the
team's localization convention: `agent` → `智能体`, `Squad` → `小队`,
while `Issue` stays as-is as the canonical product term.

Release highlights:
- feat(autopilot): View/Write permission layer + Manage Access (MUL-3807)
- feat(slack): unified chat history backfill (MUL-3871) and typing
  reaction on inbound messages (MUL-3874)
- feat(skills): import skills from a .skill/.zip archive (MUL-3865)
- feat(cli)!: drop short UUID prefix resolution for `multica issue`
  (MUL-3838)
- feat(views): Agents page mobile friendly (MUL-3873)
- improvement: rewrite of the comment routing cascade
  (MUL-3794 + MUL-3879 follow-up)
- improvement: docs swap removed Gemini for CodeBuddy (MUL-3861) and
  remove 117 dead _one i18n keys (MUL-3877)
- improvement: self-host preflight allows newer Docker Compose
- fix(daemon): reconcile in-flight task and workspace state on WS
  reconnect (community contribution, closes #4665)
- fix(agent): recover Antigravity reply from transcript when stdout
  is empty (MUL-3726)
- fix(server): skip CLIENT SETNAME for managed Redis compatibility
  (MUL-3848, community contribution)
- fix(views): count tasks, not agents, in activity hover header
  (MUL-3872)

Verified via the existing `apps/web typecheck`, vitest landing
suite (changelog-page-client.test.ts among them), and eslint on the
i18n directory; all green.

Co-authored-by: multica-agent <github@multica.ai>

* docs(changelog): tighten v0.3.33 entry wording (MUL-3889)

Per feedback that the first draft read too verbose for the public
changelog, trim every bullet of the v0.3.33 entry to one short
sentence and drop the supporting clauses that were rehashing
implementation detail (contributor handles, issue numbers, "30s
ticker" specifics, byline of what the rewrite incidentally fixed).

The net effect is a tighter list that matches the cadence of the
v0.3.32 / v0.3.31 entries already on the page.

Applied identically across en.ts / zh.ts / ja.ts / ko.ts.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-30 18:27:46 +08:00
Bohan Jiang
a961d63611 feat(slack): make the chat agent explicitly channel-aware (MUL-3871) (#4755)
Before this, the chat prompt only carried a generic, always-on hint ('if this
came from a chat channel...'), and the task carried no channel signal — so the
agent never definitively knew it was inside Slack. For an ambiguous ask like
'what did you just talk about', it could read Multica instead of the Slack
conversation.

- Thread a chat_channel_type ('slack') signal: the server sets it on the chat
  task response when the session has a Slack binding
  (GetChannelChatSessionBindingBySession); the daemon Task carries it.
- buildChatPrompt now emits an EXPLICIT block only when channel-backed: 'You are
  operating inside a Slack conversation … this conversation and its history live
  in Slack, NOT in Multica … read it with multica chat history, do NOT look in
  Multica.' Web-only chat sessions get no such block (their history is the
  Multica chat_session the agent already resumes).

Tests: slack-backed prompt asserts the explicit Slack/“NOT in Multica”/command
copy; web-only prompt asserts the block is absent.

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-30 17:24:46 +08:00
Bohan Jiang
50a48cef1e feat(slack): unified multica chat history pull for channel backfill (MUL-3871) (#4747)
* feat(slack): add unified `multica chat history` pull for channel backfill (MUL-3871)

Agents @mentioned in a Slack thread/channel only saw the triggering message,
never the prior conversation (GitHub #4717). Instead of force-assembling a
recent-context block on every inbound (the Feishu approach), expose a single
channel-agnostic pull command the agent runs on demand.

- channel: normalized HistoryMessage/HistoryPage/HistoryOptions vocab so the
  agent sees one shape regardless of platform.
- slack.History: resolves session -> binding -> installation -> bot token and
  reads conversations.replies (real thread) or conversations.history (DM /
  top-level channel, capturing sibling messages). thread_ts is recorded on the
  binding config at session creation to pick the right call.
- handler GET /api/chat/history: authorized purely by the task-scoped token
  (stamped X-Task-ID -> the task's own chat session), so an agent can only read
  the conversation it is currently running for.
- multica chat history CLI command (no args; same for every channel).
- buildChatPrompt nudge so the agent discovers the command.

Feishu is intentionally untouched. Adding a platform = implement the reader.

Co-authored-by: multica-agent <github@multica.ai>

* fix(slack): require task-token actor source on chat history endpoint

Niko's review caught a privilege-boundary hole: the endpoint trusted
X-Task-ID, but it is mounted under the general Auth group where a normal
JWT / mul_ PAT request does NOT strip a client-forged X-Task-ID — only the
mat_ task-token branch stamps it. A workspace member who knew a chat task id
could forge the header and read that task's Slack channel/DM/thread history.

Gate on the server-set X-Actor-Source == "task_token" (the Auth middleware
deletes any client-supplied value and re-stamps it only on the mat_ branch),
then trust X-Task-ID. Adds a regression test: a forged X-Task-ID without the
task-token actor source is rejected with 403 and never reaches the reader.

Co-authored-by: multica-agent <github@multica.ai>

* fix(slack): thread-first history for follow-ups, channel for first turn (MUL-3871)

A Slack conversation has two nested histories: the surrounding channel and the
agent's own thread (the bot's first reply opens a thread on the @mention). The
first version picked replies-vs-history from a thread_ts fixed at session
creation, so a session started by a top-level @mention always read CHANNEL
history — even on follow-ups inside the bot's thread, which should read THREAD
history first.

- Add a HistoryScope (auto|thread|channel). The handler resolves auto:
  first turn (no prior bot reply) -> channel; follow-up -> thread. The agent can
  override with --scope channel|thread, and the response reports the scope read.
- The thread root is derived from the binding (last_thread_id / composite-key
  suffix), available for every engaged group session, instead of the
  creation-time thread_ts (now removed from the binding config).
- A DM degrades a thread request to channel history (DMs have no threads).
- Prompt guidance + CLI help updated to explain the policy.

Tests: scope selection (thread/channel/DM-fallback/no-root), root derivation,
and handler auto-resolution (first->channel, follow-up->thread, explicit
override).

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-30 16:48:13 +08:00
MeloMei
ff286dcfac MUL-3848: fix(server): skip CLIENT SETNAME for managed Redis compatibility
Closes #4627
2026-06-30 15:51:45 +08:00
LinYushen
630feff1af MUL-3879: restore agent-authored squad-leader fallback in comment cascade (#4748)
After MUL-3794 rewrote the comment routing cascade, computeCommentAgentTriggers
returned early for every non-member author, so worker-agent result comments on a
squad-assigned issue no longer woke the assigned squad leader, breaking the
leader->worker->leader coordination loop.

Restore a narrow agent-authored fallback: when the issue is squad-assigned and
the author is not a member, route to routeAssignedSquadLeaderFallback. Member/
thread routing and explicit @agent/@squad mention routing are untouched, and the
lastTaskWasLeader self-trigger suppression is preserved (it lives inside
routeAssignedSquadLeaderFallback). Explicit mentions are handled before this
branch, so a mentioned target is never double-enqueued alongside the leader.

Co-authored-by: multica-agent <github@multica.ai>
2026-06-30 14:56:49 +08:00
Bohan Jiang
9ee2bd4c34 fix(agent): recover Antigravity reply from transcript when agy stdout is empty (#4744)
agy 1.0.14 print mode can complete a turn (tools executed, final reply produced) while writing zero bytes to stdout, so the daemon recorded a blank but "completed" run and the user saw no answer (MUL-3726, #4595).

When an otherwise-completed turn returns empty stdout, recover the assistant text agy durably wrote to its per-conversation transcript, bounded to the current turn (reset on each USER_INPUT, status=DONE only) so a resumed conversation never re-emits prior turns' answers. App data dir is read from the daemon-owned --log-file rather than guessing $HOME. All paths fail soft to "" so genuine no-text completions and other statuses are unchanged.

Verified against real agy 1.0.14 output plus unit + end-to-end + resume-boundary tests.
2026-06-30 14:47:16 +08:00
Bohan Jiang
b90816264e feat(skills): import skills from a .skill/.zip archive (#4735)
Import a skill from a local .skill/.zip archive: POST /api/skills/import now accepts a multipart upload (file + on_conflict) alongside the JSON URL body, and the CLI gains `multica skill import --file <path>`. Reuses the existing create + on_conflict contract, per-file/bundle/count caps, reserved-SKILL.md rule, and a zip-slip guard.

Closes #4730
MUL-3865
2026-06-30 14:46:46 +08:00
Bohan Jiang
424b02e79a chore(views): remove dead i18n _one keys in other-only locales (#4746)
ja/ko/zh-Hans resolve only the CLDR `other` plural category, so every
`_one` key in those locales is dead weight that i18next never renders.
Remove 117 such orphan keys across 25 namespaces. Each already has its
`_other` sibling, so this is behavior-preserving.

Also add a parity-test guard that fails if a locale whose CLDR plural
rules lack a `one` category ships any `_one` key, so these can't silently
accumulate again (gated on Intl.PluralRules, the same source i18next uses).

Follow-up to #4740 (MUL-3877).

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-30 14:44:35 +08:00
Bohan Jiang
3b45f7fdf6 docs(slack): drop reinstall callout from Slack integration docs (MUL-3874) (#4745)
Slack is not officially launched yet, so the 'already created your app
with an older manifest — add reactions:write and reinstall' guidance is
unnecessary; nobody is running a pre-launch manifest in production. Remove
the warning callout from all four locales (en/zh/ja/ko).

The reactions:write scope in the manifest and the scope table stay, since
the typing indicator still depends on it.

Co-authored-by: J <agent-j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-30 14:29:56 +08:00
74 changed files with 3116 additions and 393 deletions

View File

@@ -198,6 +198,11 @@ CORS_ALLOWED_ORIGINS=
# startup. The same REDIS_URL is reused by the realtime fan-out hub,
# the PAT cache, and the daemon-token cache.
# REDIS_URL=redis://localhost:6379/0
# Set to "true" to skip the CLIENT SETNAME handshake on every Redis
# connection. Required for managed Redis providers that block the CLIENT
# command (e.g. GCP Memorystore, AWS ElastiCache with restricted ACLs).
# Default is false (client naming enabled for connection observability).
# REDIS_DISABLE_CLIENT_NAME=true
# Max requests per IP per minute. Defaults are 5 for send-code/google
# and 20 for verify-code.
# RATE_LIMIT_AUTH=5

View File

@@ -157,6 +157,7 @@ S3 の前段に CloudFront を置く場合、3 つの変数が適用されます
| 変数 | デフォルト | 説明 |
|---|---|---|
| `REDIS_URL` | 空 | Redis 接続 URL例: `redis://localhost:6379/0`)。設定しないと auth エンドポイントのレート制限が無効になります。同じ Redis はリアルタイムハブの fan-out、PAT キャッシュ、デーモントークンキャッシュでも使われます — 設定しない場合はすべてインメモリ / 直接 DB モードにフォールバックします |
| `REDIS_DISABLE_CLIENT_NAME` | `false` | `true` に設定すると、すべての Redis 接続で `CLIENT SETNAME` ハンドシェイクをスキップします。`CLIENT` コマンドをブロックするマネージド Redis プロバイダーGCP Memorystore や ACL 制限付きの AWS ElastiCache など)を使用する場合に**必須**です。有効にすると `CLIENT LIST` 出力で接続の説明的な名前が失われますが、制限付きプロバイダーとの互換性が得られます |
| `RATE_LIMIT_AUTH` | `5` | `/auth/send-code` および `/auth/google` に対する IP あたり毎分の最大リクエスト数 |
| `RATE_LIMIT_AUTH_VERIFY` | `20` | `/auth/verify-code` に対する IP あたり毎分の最大リクエスト数 |
| `RATE_LIMIT_TRUSTED_PROXIES` | 空 | リミッターがその `X-Forwarded-For` ヘッダーを信頼することを許可する、カンマ区切りの CIDR。空デフォルトは **XFF を決して信頼しない**ことを意味します — リミッターは直接接続の `RemoteAddr` のみを使用します |

View File

@@ -157,6 +157,7 @@ S3 앞에 CloudFront를 두는 경우 세 가지 변수가 적용됩니다: `CLO
| 변수 | 기본값 | 설명 |
|---|---|---|
| `REDIS_URL` | 비어 있음 | Redis 연결 URL (예: `redis://localhost:6379/0`). 설정하지 않으면 auth 엔드포인트의 속도 제한이 비활성화됩니다. 동일한 Redis는 실시간 허브 fan-out, PAT 캐시, 데몬 토큰 캐시에서도 사용됩니다 — 설정하지 않으면 모두 인메모리 / 직접 DB 모드로 폴백합니다 |
| `REDIS_DISABLE_CLIENT_NAME` | `false` | `true`로 설정하면 모든 Redis 연결에서 `CLIENT SETNAME` 핸드셰이크를 건너뜁니다. `CLIENT` 명령을 차단하는 관리형 Redis 제공자(GCP Memorystore 또는 ACL이 제한된 AWS ElastiCache 등)를 사용할 때 **필수**입니다. 활성화하면 `CLIENT LIST` 출력에서 연결의 설명 이름이 사라지지만, 제한된 제공자와의 호환성을 얻을 수 있습니다 |
| `RATE_LIMIT_AUTH` | `5` | `/auth/send-code` 및 `/auth/google`에 대한 IP당 분당 최대 요청 수 |
| `RATE_LIMIT_AUTH_VERIFY` | `20` | `/auth/verify-code`에 대한 IP당 분당 최대 요청 수 |
| `RATE_LIMIT_TRUSTED_PROXIES` | 비어 있음 | 리미터가 그 `X-Forwarded-For` 헤더를 신뢰하도록 허용하는, 쉼표로 구분된 CIDR. 비어 있음(기본값)은 **XFF를 절대 신뢰하지 않음**을 의미합니다 — 리미터는 직접 연결의 `RemoteAddr`만 사용합니다 |

View File

@@ -157,6 +157,7 @@ Public auth endpoints — `/auth/send-code`, `/auth/verify-code`, `/auth/google`
| Variable | Default | Description |
|---|---|---|
| `REDIS_URL` | empty | Redis connection URL (for example `redis://localhost:6379/0`). When unset, rate limiting on auth endpoints is disabled. The same Redis is also used by the realtime hub fan-out, the PAT cache, and the daemon-token cache — they all fall back to in-memory / direct-DB mode when unset |
| `REDIS_DISABLE_CLIENT_NAME` | `false` | Set to `true` to skip the `CLIENT SETNAME` handshake on every Redis connection. **Required** for managed Redis providers that block the `CLIENT` command, such as GCP Memorystore or AWS ElastiCache with restricted ACLs. When enabled, connections lose their descriptive name in `CLIENT LIST` output but gain compatibility with restricted providers |
| `RATE_LIMIT_AUTH` | `5` | Max requests per IP per minute against `/auth/send-code` and `/auth/google` |
| `RATE_LIMIT_AUTH_VERIFY` | `20` | Max requests per IP per minute against `/auth/verify-code` |
| `RATE_LIMIT_TRUSTED_PROXIES` | empty | Comma-separated CIDRs whose `X-Forwarded-For` header the limiter is allowed to trust. Empty (the default) means **never trust XFF** — the limiter only uses the direct connection's `RemoteAddr` |

View File

@@ -157,6 +157,7 @@ API 返回的 `download_url` 在未配置 CloudFront 签名时会指向 `GET /ap
| 环境变量 | 默认值 | 说明 |
|---|---|---|
| `REDIS_URL` | 空 | Redis 连接 URL例如 `redis://localhost:6379/0`)。不设时认证端点的限流功能直接关闭。同一个 Redis 也被实时事件 fan-out、PAT 缓存、守护进程 token 缓存复用;不设时这些组件分别回落到内存模式 / 直查 DB |
| `REDIS_DISABLE_CLIENT_NAME` | `false` | 设为 `true` 可跳过每次 Redis 连接时的 `CLIENT SETNAME` 握手。使用托管 Redis如 GCP Memorystore 或限制了 ACL 的 AWS ElastiCache等封禁 `CLIENT` 命令的服务时**必须开启**。启用后连接在 `CLIENT LIST` 输出中会失去描述性名称,但能兼容受限的托管服务 |
| `RATE_LIMIT_AUTH` | `5` | 单 IP 每分钟对 `/auth/send-code` 和 `/auth/google` 的最大请求数 |
| `RATE_LIMIT_AUTH_VERIFY` | `20` | 单 IP 每分钟对 `/auth/verify-code` 的最大请求数 |
| `RATE_LIMIT_TRUSTED_PROXIES` | 空 | 逗号分隔的 CIDR 列表,列在内的来源 IP 才允许通过 `X-Forwarded-For` 标识客户端。默认空 = **永不信任 XFF**,限流器只看直连的 `RemoteAddr` |

View File

@@ -77,10 +77,6 @@ settings:
**OAuth リダイレクト URL はありません**。BYO は OAuth を使わないからです。
<Callout type="warning">
以前のマニフェストでアプリを作成済みですか?**OAuth & Permissions → Bot Token Scopes** で **`reactions:write`** スコープを追加し、新しいスコープを反映させるために**アプリをワークスペースに再インストール**してください。それまではエージェントは通常どおり返信します——👀「処理中」リアクションがスキップされるだけです。
</Callout>
<Callout type="info">
Slack で特定の名前を表示したいですか? 作成前に `display_information.name` と `features.bot_user.display_name`(たとえばエージェントの名前に)を変更するか、あとで **App Home** で編集してください。Slack は Bot をその **bot display name** で表示しますが、これはアプリ名と異なる場合があります。
</Callout>

View File

@@ -77,10 +77,6 @@ settings:
**OAuth redirect URL은 없습니다.** BYO는 OAuth를 사용하지 않기 때문입니다.
<Callout type="warning">
이전 매니페스트로 이미 앱을 만들었나요? **OAuth & Permissions → Bot Token Scopes**에서 **`reactions:write`** 스코프를 추가한 뒤, 새 스코프가 적용되도록 **앱을 워크스페이스에 다시 설치**하세요. 그 전까지 에이전트는 정상적으로 답변하며 — 👀 "처리 중" 반응만 건너뜁니다.
</Callout>
<Callout type="info">
Slack에서 특정 이름을 쓰고 싶나요? 생성하기 전에 `display_information.name`과 `features.bot_user.display_name`을 (예: 에이전트 이름으로) 변경하거나, 나중에 **App Home**에서 편집하세요. Slack은 봇을 **bot display name**으로 표시하며, 이는 앱 이름과 다를 수 있습니다.
</Callout>

View File

@@ -77,10 +77,6 @@ This manifest configures everything Multica needs, so you don't set anything by
There is **no OAuth redirect URL**, because BYO doesn't use OAuth.
<Callout type="warning">
Already created your app with an earlier manifest? Add the **`reactions:write`** scope under **OAuth & Permissions → Bot Token Scopes**, then **reinstall the app to your workspace** so the new scope takes effect. Until you do, the agent still replies normally — only the 👀 "processing" reaction is skipped.
</Callout>
<Callout type="info">
Want a specific name in Slack? Change `display_information.name` and `features.bot_user.display_name` (e.g. to your agent's name) before creating, or edit it later under **App Home**. Slack shows the bot by its **bot display name**, which can differ from the app name.
</Callout>

View File

@@ -77,10 +77,6 @@ settings:
这里**没有 OAuth 重定向 URL**,因为 BYO 不使用 OAuth。
<Callout type="warning">
已经用旧版 manifest 创建过 app在 **OAuth & Permissions → Bot Token Scopes** 里加上 **`reactions:write`** 权限,然后**把 app 重新安装到工作区**让新权限生效。在此之前智能体仍然正常回复——只是会跳过 👀「处理中」表情。
</Callout>
<Callout type="info">
想在 Slack 里用一个特定的名字?在创建之前改 `display_information.name` 和 `features.bot_user.display_name`(比如改成你智能体的名字),或者之后在 **App Home** 里编辑。Slack 是按 Bot 的**显示名bot display name**来展示它的,这个名字可以和 app 名不一样。
</Callout>

View File

@@ -293,6 +293,32 @@ export function createEnDict(allowSignup: boolean): LandingDict {
fixes: "Bug Fixes",
},
entries: [
{
version: "0.3.33",
date: "2026-06-30",
title: "Autopilot access controls, Slack history backfill, and skill-archive imports",
changes: [],
features: [
"Autopilots now have a clear write-permission layer, plus a Manage Access dialog that lets the creator grant write access to specific workspace members.",
"Slack channels can backfill their conversation history into Multica, so an agent has the prior context the moment it joins.",
"Slack messages show a 👀 reaction while an agent is preparing its reply, and the reaction is always cleared on the way out.",
"Skill bundles can be installed from a local .skill or .zip archive.",
"multica issue commands no longer accept short UUID prefixes — use the issue key (MUL-123) or the full UUID.",
"The Agents page is now usable on mobile.",
],
improvements: [
"Comment routing was rewritten end-to-end so parent-chain mentions, agent-authored replies, and squad-leader fallback all flow through one well-tested cascade.",
"Locale bundles dropped 117 dead `_one` plural keys, with a parity test guarding against regressions.",
"The built-in runtime list now points at CodeBuddy instead of the removed Gemini runtime.",
"Self-host preflight accepts newer Docker Compose CLI plugin versions while still rejecting Docker Compose v1.",
],
fixes: [
"After a WebSocket reconnect, the daemon now reconciles in-flight tasks and workspace state immediately. (Community contribution.)",
"Antigravity replies that the agent produces silently now show up reliably instead of recording a blank but completed run.",
"Servers backed by managed Redis providers that reject CLIENT SETNAME now start up cleanly. (Community contribution.)",
"The agent-activity hover header now reads in terms of tasks instead of agents, so it agrees with the workspace chip.",
],
},
{
version: "0.3.32",
date: "2026-06-29",

View File

@@ -269,6 +269,32 @@ export function createJaDict(allowSignup: boolean): LandingDict {
fixes: "バグ修正",
},
entries: [
{
version: "0.3.33",
date: "2026-06-30",
title: "Autopilot のアクセス管理、Slack の履歴バックフィル、スキル パッケージのアーカイブ取り込み",
changes: [],
features: [
"Autopilot に明確な書き込み権限レイヤーが入り、詳細ページの「アクセス管理」から特定メンバーに 1 つの Autopilot の書き込み権限だけを委譲できます。",
"Slack チャンネルの過去のやり取りを Multica にバックフィルでき、エージェントが加わった時点で会話の流れをそのまま把握できます。",
"Slack でエージェントが返信を準備している間、ユーザーのメッセージに 👀 のリアクションが付き、終了時に確実に外れます。",
"スキル パッケージをローカルの .skill / .zip アーカイブから取り込めるようになりました。",
"multica issue 系のコマンドは短い UUID プレフィックスを受け付けなくなりました。Issue KeyMUL-123または完全な UUID を指定してください。",
"Agents ページがモバイルに最適化されました。",
],
improvements: [
"コメントのルーティング カスケードを全面的に書き直し、親リンクからの @ メンション、エージェント署名の返信、スクワッド リーダーへのフォールバックを十分にテストされた 1 本の経路にまとめました。",
"ロケール バンドルから、実際にはレンダリングされない `_one` 複数形キーを 117 件削除し、再発を防ぐパリティ チェックを追加しました。",
"組み込みランタイム一覧の Gemini を、実稼働している CodeBuddy に差し替えました。",
"セルフホストの事前チェックは新しい Docker Compose CLI プラグインを許容し、Docker Compose v1 は引き続き弾きます。",
],
fixes: [
"WebSocket 再接続後、デーモンが進行中のタスクとワークスペース状態を即座にサーバーと突き合わせるようになりました。(コミュニティ コントリビューション)",
"Antigravity が「ターンを完了しても何も出力しない」ケースでも、実行ログから返信内容を回収し、会話が空白になりません。",
"CLIENT SETNAME を拒否するマネージド Redis でサーバーが起動できなかった問題を修正しました。(コミュニティ コントリビューション)",
"エージェント活動のホバー カードのヘッダーが「実行中 N タスク」と表記され、ワークスペースの表示と一致するようになりました。",
],
},
{
version: "0.3.32",
date: "2026-06-29",

View File

@@ -268,6 +268,32 @@ export function createKoDict(allowSignup: boolean): LandingDict {
fixes: "버그 수정",
},
entries: [
{
version: "0.3.33",
date: "2026-06-30",
title: "Autopilot 액세스 관리, Slack 히스토리 백필, 스킬 번들 아카이브 가져오기",
changes: [],
features: [
"Autopilot에 명확한 쓰기 권한 계층이 도입되었고, 상세 페이지의 '액세스 관리'를 통해 특정 멤버에게 단일 Autopilot의 쓰기 권한만 위임할 수 있습니다.",
"Slack 채널의 과거 대화를 Multica로 백필할 수 있어, 에이전트가 채널에 합류한 순간부터 이전 맥락을 알 수 있습니다.",
"Slack에서 에이전트가 응답을 준비하는 동안 사용자 메시지에 👀 반응이 표시되고, 종료 시 안정적으로 제거됩니다.",
"스킬 번들을 로컬 .skill / .zip 아카이브에서 가져올 수 있습니다.",
"multica issue 계열 명령은 더 이상 짧은 UUID 접두사를 받지 않습니다. Issue Key(MUL-123) 또는 전체 UUID를 사용하세요.",
"Agents 페이지가 모바일에 맞게 다듬어졌습니다.",
],
improvements: [
"댓글 라우팅 캐스케이드를 전면 다시 작성해, 부모 체인 @멘션·에이전트 서명 답글·스쿼드 리더 폴백이 모두 충분히 테스트된 하나의 경로로 모입니다.",
"실제로 렌더링되지 않는 `_one` 복수 키 117개를 로케일 번들에서 제거하고, 재발을 막는 패리티 검증을 추가했습니다.",
"내장 런타임 목록의 Gemini를 실제 사용되는 CodeBuddy로 교체했습니다.",
"셀프호스트 사전 검사는 최신 Docker Compose CLI 플러그인을 허용하고, Docker Compose v1은 계속 거부합니다.",
],
fixes: [
"WebSocket 재연결 후 로컬 데몬이 진행 중인 작업과 워크스페이스 상태를 서버와 즉시 동기화합니다. (커뮤니티 기여)",
"Antigravity가 '턴을 마쳤지만 아무 것도 출력하지 않는' 경우에도 데몬이 실행 기록에서 응답을 복원해, 대화가 비어 보이지 않습니다.",
"CLIENT SETNAME을 거부하는 매니지드 Redis에서 서버가 기동되지 않던 문제를 수정했습니다. (커뮤니티 기여)",
"에이전트 활동 호버 카드의 헤더가 '실행 중 N 작업'으로 표시되어 워크스페이스 표시와 일치합니다.",
],
},
{
version: "0.3.32",
date: "2026-06-29",

View File

@@ -293,6 +293,32 @@ export function createZhDict(allowSignup: boolean): LandingDict {
fixes: "问题修复",
},
entries: [
{
version: "0.3.33",
date: "2026-06-30",
title: "Autopilot 协作权限、Slack 历史回灌、技能包归档导入",
changes: [],
features: [
"Autopilot 新增清晰的写权限分层,详情页提供「管理协作者」入口,可把单个 Autopilot 的写权限授予指定成员。",
"Slack 频道可以把过往对话回灌到 Multica智能体一进入频道即拥有完整上下文。",
"Slack 智能体处理消息期间会在用户消息上加 👀 反应表情,处理结束后稳定清除,不再出现卡死。",
"技能包支持从本地 .skill / .zip 归档导入。",
"multica issue 命令不再接受短 UUID 前缀,请使用 Issue KeyMUL-123或完整 UUID。",
"Agents 页面适配移动端。",
],
improvements: [
"重写了评论路由级联:父链 @ 提及、智能体署名回复、小队 Leader 兜底,三条路径汇入同一条经过充分测试的流程。",
"语言包清理了 117 个事实上不渲染的 _one 复数键,并新增校验防止再次回归。",
"内置运行时清单中失效的 Gemini 替换为实际使用的 CodeBuddy。",
"自托管预检允许更新版的 Docker Compose CLI 插件,同时继续拦截 Docker Compose v1。",
],
fixes: [
"WebSocket 断线重连后,守护进程会立即与服务端对账正在执行的任务和工作区状态。(社区贡献)",
"Antigravity 智能体「完成回合但未输出任何内容」时,回复会被从运行记录中补回,对话不再空白。",
"在拒绝 CLIENT SETNAME 的托管 Redis 上,服务端启动不再失败。(社区贡献)",
"智能体活动悬浮卡片头部计数改为「N 个任务正在执行」,与工作区显示保持一致。",
],
},
{
version: "0.3.32",
date: "2026-06-29",

View File

@@ -0,0 +1,162 @@
"use client";
import { useMemo, useState } from "react";
import { Plus, X } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { useWorkspaceId } from "@multica/core/hooks";
import { memberListOptions } from "@multica/core/workspace/queries";
import { useActorName } from "@multica/core/workspace/hooks";
import {
useGrantAutopilotAccess,
useRevokeAutopilotAccess,
} from "@multica/core/autopilots/mutations";
import type { AutopilotCollaborator } from "@multica/core/types";
import { toast } from "sonner";
import { ActorAvatar } from "../../common/actor-avatar";
import {
PropertyPicker,
PickerItem,
PickerEmpty,
} from "../../issues/components/pickers/property-picker";
import { matchesPinyin } from "../../editor/extensions/pinyin-match";
import { useT } from "../../i18n";
// Grant / revoke explicit write access to an autopilot. Members-only, mirroring
// the subscriber picker. Creators and workspace admins always have access and
// are not listed here — this manages the additional, explicitly-granted set.
// Rendered inside the edit dialog's "Manage access" popover; access changes
// commit immediately via their own mutations and are independent of the form's
// Save action.
export function AutopilotAccessManager({
autopilotId,
collaborators,
}: {
autopilotId: string;
collaborators: AutopilotCollaborator[];
}) {
const { t } = useT("autopilots");
const wsId = useWorkspaceId();
const { data: members = [] } = useQuery(memberListOptions(wsId));
const { getActorName } = useActorName();
const grant = useGrantAutopilotAccess();
const revoke = useRevokeAutopilotAccess();
const [pickerOpen, setPickerOpen] = useState(false);
const [filter, setFilter] = useState("");
const grantedIds = useMemo(
() => new Set(collaborators.map((c) => c.user_id)),
[collaborators],
);
const query = filter.trim().toLowerCase();
const candidates = useMemo(
() =>
members.filter(
(m) =>
!grantedIds.has(m.user_id) &&
(query === "" ||
m.name.toLowerCase().includes(query) ||
matchesPinyin(m.name, query)),
),
[members, grantedIds, query],
);
const handleGrant = async (userId: string) => {
try {
await grant.mutateAsync({ autopilotId, userId });
toast.success(t(($) => $.access.toast_granted));
} catch (e: any) {
toast.error(e?.message || t(($) => $.access.toast_failed));
}
};
const handleRevoke = async (userId: string) => {
try {
await revoke.mutateAsync({ autopilotId, userId });
toast.success(t(($) => $.access.toast_revoked));
} catch (e: any) {
toast.error(e?.message || t(($) => $.access.toast_failed));
}
};
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
{t(($) => $.access.current_label)}
</span>
<PropertyPicker
open={pickerOpen}
onOpenChange={(v) => {
setPickerOpen(v);
if (!v) setFilter("");
}}
width="w-64"
align="end"
searchable
searchPlaceholder={t(($) => $.access.search_placeholder)}
onSearchChange={setFilter}
trigger={
<span className="inline-flex cursor-pointer items-center gap-1 rounded-md border border-dashed px-2 py-1 text-xs text-muted-foreground transition-colors hover:border-primary/40 hover:text-foreground">
<Plus className="size-3" />
{t(($) => $.access.add)}
</span>
}
>
{candidates.length === 0 ? (
<PickerEmpty />
) : (
candidates.map((m) => (
<PickerItem
key={m.user_id}
selected={false}
onClick={() => {
void handleGrant(m.user_id);
setPickerOpen(false);
}}
>
<ActorAvatar actorType="member" actorId={m.user_id} size={18} />
<span className="truncate">{m.name}</span>
</PickerItem>
))
)}
</PropertyPicker>
</div>
{collaborators.length === 0 ? (
<p className="rounded-md border border-dashed px-3 py-4 text-center text-sm text-muted-foreground">
{t(($) => $.access.empty)}
</p>
) : (
<ul className="space-y-1">
{collaborators.map((c) => (
<li
key={c.user_id}
className="flex items-center justify-between rounded-md px-2 py-1.5 hover:bg-muted/50"
>
<span className="flex min-w-0 items-center gap-2">
<ActorAvatar actorType="member" actorId={c.user_id} size={20} />
<span className="truncate text-sm">
{getActorName("member", c.user_id)}
</span>
</span>
<button
type="button"
onClick={() => void handleRevoke(c.user_id)}
disabled={revoke.isPending}
className="cursor-pointer text-muted-foreground transition-colors hover:text-foreground disabled:opacity-50"
aria-label={t(($) => $.access.remove_tooltip)}
>
<X className="size-3.5" />
</button>
</li>
))}
</ul>
)}
<p className="text-xs text-muted-foreground">
{t(($) => $.access.owner_note)}
</p>
</div>
);
}

View File

@@ -4,7 +4,7 @@ import { useState } from "react";
import {
Zap, Play, Clock, Plus, Trash2, CheckCircle2, XCircle, Loader2, Pencil,
Ban, ChevronDown, ChevronRight,
Webhook, Copy, Check, RotateCw, Users,
Webhook, Copy, Check, RotateCw,
} from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { autopilotDetailOptions, autopilotRunsOptions, autopilotRunOptions } from "@multica/core/autopilots/queries";
@@ -62,7 +62,6 @@ import type { AgentTask } from "@multica/core/types/agent";
import { ReadonlyContent } from "../../editor";
import { TranscriptButton } from "../../common/task-transcript";
import { AutopilotDialog } from "./autopilot-dialog";
import { ManageAccessDialog } from "./manage-access-dialog";
import { WebhookPayloadPreview } from "./webhook-payload-preview";
import { WebhookDeliveriesSection } from "./webhook-deliveries-section";
import { ProjectIcon } from "../../projects/components/project-icon";
@@ -635,7 +634,6 @@ export function AutopilotDetailPage({ autopilotId }: { autopilotId: string }) {
const [triggerDialogOpen, setTriggerDialogOpen] = useState(false);
const [editDialogOpen, setEditDialogOpen] = useState(false);
const [accessDialogOpen, setAccessDialogOpen] = useState(false);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [deleting, setDeleting] = useState(false);
@@ -760,12 +758,6 @@ export function AutopilotDetailPage({ autopilotId }: { autopilotId: string }) {
actions={
canWrite ? (
<>
{canManageAccess && (
<Button size="sm" variant="outline" onClick={() => setAccessDialogOpen(true)} className="px-2 sm:px-2.5" aria-label={t(($) => $.detail.manage_access)}>
<Users className="h-3.5 w-3.5 sm:mr-1" />
<span className="hidden sm:inline">{t(($) => $.detail.manage_access)}</span>
</Button>
)}
<Button size="sm" variant="outline" onClick={() => setEditDialogOpen(true)} className="px-2 sm:px-2.5" aria-label={t(($) => $.detail.edit)}>
<Pencil className="h-3.5 w-3.5 sm:mr-1" />
<span className="hidden sm:inline">{t(($) => $.detail.edit)}</span>
@@ -980,14 +972,8 @@ export function AutopilotDetailPage({ autopilotId }: { autopilotId: string }) {
.map((s) => s.user_id) ?? [],
}}
triggers={triggers}
/>
)}
{accessDialogOpen && (
<ManageAccessDialog
open={accessDialogOpen}
onOpenChange={setAccessDialogOpen}
autopilotId={autopilot.id}
collaborators={collaborators}
canManageAccess={canManageAccess}
/>
)}
<AlertDialog

View File

@@ -15,6 +15,7 @@ import {
Minimize2,
Play,
Rocket,
Users,
Webhook,
X as XIcon,
Zap,
@@ -27,6 +28,14 @@ import {
DialogTitle,
} from "@multica/ui/components/ui/dialog";
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
import {
Popover,
PopoverTrigger,
PopoverContent,
PopoverHeader,
PopoverTitle,
PopoverDescription,
} from "@multica/ui/components/ui/popover";
import { Button } from "@multica/ui/components/ui/button";
import {
Select,
@@ -51,6 +60,7 @@ import { buildAutopilotWebhookUrl } from "@multica/core/autopilots";
import { api } from "@multica/core/api";
import type {
AutopilotAssigneeType,
AutopilotCollaborator,
AutopilotExecutionMode,
AutopilotTrigger,
} from "@multica/core/types";
@@ -60,6 +70,7 @@ import { ProjectPicker } from "../../projects/components/project-picker";
import { ProjectIcon } from "../../projects/components/project-icon";
import { AgentPicker, type AssigneeSelection } from "./pickers/agent-picker";
import { SubscriberMultiSelect } from "./subscriber-multi-select";
import { AutopilotAccessManager } from "./autopilot-access-manager";
import {
getDefaultTriggerConfig,
getLocalTimezone,
@@ -102,6 +113,8 @@ export type AutopilotDialogProps =
autopilotId: string;
initial: AutopilotInitial;
triggers: AutopilotTrigger[];
collaborators: AutopilotCollaborator[];
canManageAccess: boolean;
};
// ---------------------------------------------------------------------------
@@ -555,6 +568,29 @@ export function AutopilotDialog(props: AutopilotDialogProps) {
)}
</div>
<div className="flex items-center gap-1">
{!isCreate && props.canManageAccess && (
<>
<Popover>
<PopoverTrigger className="flex items-center gap-1.5 rounded-sm px-2 py-1 text-xs text-muted-foreground opacity-90 transition-all hover:bg-accent/60 hover:text-foreground hover:opacity-100 cursor-pointer">
<Users className="size-3.5" />
<span>{t(($) => $.access.title)}</span>
</PopoverTrigger>
<PopoverContent align="end" sideOffset={6} keepMounted className="w-80">
<PopoverHeader>
<PopoverTitle>{t(($) => $.access.title)}</PopoverTitle>
<PopoverDescription className="text-xs">
{t(($) => $.access.description)}
</PopoverDescription>
</PopoverHeader>
<AutopilotAccessManager
autopilotId={props.autopilotId}
collaborators={props.collaborators}
/>
</PopoverContent>
</Popover>
<span className="mx-0.5 h-4 w-px bg-border" />
</>
)}
<Tooltip>
<TooltipTrigger
render={

View File

@@ -1,177 +0,0 @@
"use client";
import { useMemo, useState } from "react";
import { Plus, X } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { useWorkspaceId } from "@multica/core/hooks";
import { memberListOptions } from "@multica/core/workspace/queries";
import { useActorName } from "@multica/core/workspace/hooks";
import {
useGrantAutopilotAccess,
useRevokeAutopilotAccess,
} from "@multica/core/autopilots/mutations";
import type { AutopilotCollaborator } from "@multica/core/types";
import {
Dialog,
DialogContent,
DialogTitle,
} from "@multica/ui/components/ui/dialog";
import { toast } from "sonner";
import { ActorAvatar } from "../../common/actor-avatar";
import {
PropertyPicker,
PickerItem,
PickerEmpty,
} from "../../issues/components/pickers/property-picker";
import { matchesPinyin } from "../../editor/extensions/pinyin-match";
import { useT } from "../../i18n";
// Grant / revoke explicit write access to an autopilot. Members-only, mirroring
// the subscriber picker. Creators and workspace admins always have access and
// are not listed here — this manages the additional, explicitly-granted set.
export function ManageAccessDialog({
open,
onOpenChange,
autopilotId,
collaborators,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
autopilotId: string;
collaborators: AutopilotCollaborator[];
}) {
const { t } = useT("autopilots");
const wsId = useWorkspaceId();
const { data: members = [] } = useQuery(memberListOptions(wsId));
const { getActorName } = useActorName();
const grant = useGrantAutopilotAccess();
const revoke = useRevokeAutopilotAccess();
const [pickerOpen, setPickerOpen] = useState(false);
const [filter, setFilter] = useState("");
const grantedIds = useMemo(
() => new Set(collaborators.map((c) => c.user_id)),
[collaborators],
);
const query = filter.trim().toLowerCase();
const candidates = useMemo(
() =>
members.filter(
(m) =>
!grantedIds.has(m.user_id) &&
(query === "" ||
m.name.toLowerCase().includes(query) ||
matchesPinyin(m.name, query)),
),
[members, grantedIds, query],
);
const handleGrant = async (userId: string) => {
try {
await grant.mutateAsync({ autopilotId, userId });
toast.success(t(($) => $.access.toast_granted));
} catch (e: any) {
toast.error(e?.message || t(($) => $.access.toast_failed));
}
};
const handleRevoke = async (userId: string) => {
try {
await revoke.mutateAsync({ autopilotId, userId });
toast.success(t(($) => $.access.toast_revoked));
} catch (e: any) {
toast.error(e?.message || t(($) => $.access.toast_failed));
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogTitle>{t(($) => $.access.title)}</DialogTitle>
<p className="text-sm text-muted-foreground">
{t(($) => $.access.description)}
</p>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
{t(($) => $.access.current_label)}
</span>
<PropertyPicker
open={pickerOpen}
onOpenChange={(v) => {
setPickerOpen(v);
if (!v) setFilter("");
}}
width="w-64"
align="start"
searchable
searchPlaceholder={t(($) => $.access.search_placeholder)}
onSearchChange={setFilter}
trigger={
<span className="inline-flex cursor-pointer items-center gap-1 rounded-md border border-dashed px-2 py-1 text-xs text-muted-foreground transition-colors hover:border-primary/40 hover:text-foreground">
<Plus className="size-3" />
{t(($) => $.access.add)}
</span>
}
>
{candidates.length === 0 ? (
<PickerEmpty />
) : (
candidates.map((m) => (
<PickerItem
key={m.user_id}
selected={false}
onClick={() => {
void handleGrant(m.user_id);
setPickerOpen(false);
}}
>
<ActorAvatar actorType="member" actorId={m.user_id} size={18} />
<span className="truncate">{m.name}</span>
</PickerItem>
))
)}
</PropertyPicker>
</div>
{collaborators.length === 0 ? (
<p className="rounded-md border border-dashed px-3 py-4 text-center text-sm text-muted-foreground">
{t(($) => $.access.empty)}
</p>
) : (
<ul className="space-y-1">
{collaborators.map((c) => (
<li
key={c.user_id}
className="flex items-center justify-between rounded-md px-2 py-1.5 hover:bg-muted/50"
>
<span className="flex min-w-0 items-center gap-2">
<ActorAvatar actorType="member" actorId={c.user_id} size={20} />
<span className="truncate text-sm">
{getActorName("member", c.user_id)}
</span>
</span>
<button
type="button"
onClick={() => void handleRevoke(c.user_id)}
disabled={revoke.isPending}
className="cursor-pointer text-muted-foreground transition-colors hover:text-foreground disabled:opacity-50"
aria-label={t(($) => $.access.remove_tooltip)}
>
<X className="size-3.5" />
</button>
</li>
))}
</ul>
)}
</div>
<p className="text-xs text-muted-foreground">
{t(($) => $.access.owner_note)}
</p>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,149 @@
// @vitest-environment jsdom
import { cleanup, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { AgentTask } from "@multica/core/types";
import { renderWithI18n } from "../../test/i18n";
const mockState = vi.hoisted(() => ({
snapshot: [] as unknown[],
// Captures the agent ids handed to the avatar stack so a test can assert
// the stack still reflects distinct agents even when the count counts issues.
avatarAgentIds: undefined as string[] | undefined,
}));
vi.mock("@multica/core/hooks", () => ({
useWorkspaceId: () => "ws-1",
}));
vi.mock("@multica/core/agents", () => ({
agentTaskSnapshotOptions: (wsId: string) => ({
queryKey: ["agents", "task-snapshot", wsId],
}),
}));
vi.mock("../../agents/components/agent-avatar-stack", () => ({
AgentAvatarStack: ({ agentIds }: { agentIds: string[] }) => {
mockState.avatarAgentIds = agentIds;
return <div data-testid="agent-avatar-stack">{agentIds.length}</div>;
},
}));
vi.mock("../../agents/components/agent-activity-hover-content", () => ({
AgentActivityHoverContent: ({ tasks }: { tasks: AgentTask[] }) => (
<div data-testid="activity-hover">{tasks.length}</div>
),
}));
vi.mock("@tanstack/react-query", async () => {
const actual =
await vi.importActual<typeof import("@tanstack/react-query")>(
"@tanstack/react-query",
);
return {
...actual,
useQuery: (opts: { queryKey?: readonly unknown[] }) => {
if (opts.queryKey?.[1] === "task-snapshot") {
return { data: mockState.snapshot };
}
return { data: undefined };
},
};
});
import { WorkspaceAgentWorkingChip } from "./workspace-agent-working-chip";
function makeTask(overrides: Partial<AgentTask>): AgentTask {
return {
id: "task-1",
agent_id: "agent-1",
runtime_id: "runtime-1",
issue_id: "issue-1",
status: "running",
priority: 0,
dispatched_at: null,
started_at: "2026-06-08T08:00:00Z",
completed_at: null,
result: null,
error: null,
created_at: "2026-06-08T08:00:00Z",
...overrides,
};
}
beforeEach(() => {
cleanup();
vi.clearAllMocks();
mockState.snapshot = [];
mockState.avatarAgentIds = undefined;
});
describe("WorkspaceAgentWorkingChip", () => {
it("counts distinct active issues, not running agents", () => {
// Two agents working the SAME issue: the count is about issues, so it
// must read "1", not "2" (the old unique-agent behavior). MUL-3875.
mockState.snapshot = [
makeTask({ id: "t-1", agent_id: "agent-1", issue_id: "issue-1" }),
makeTask({ id: "t-2", agent_id: "agent-2", issue_id: "issue-1" }),
];
renderWithI18n(
<WorkspaceAgentWorkingChip value={false} onToggle={() => {}} />,
);
expect(
screen.getByRole("button", { name: /working/i }),
).toHaveTextContent("1");
// The avatar stack still shows both distinct agents behind that work.
expect(mockState.avatarAgentIds).toEqual(["agent-1", "agent-2"]);
});
it("counts each distinct issue once when agents span several issues", () => {
mockState.snapshot = [
makeTask({ id: "t-1", agent_id: "agent-1", issue_id: "issue-1" }),
makeTask({ id: "t-2", agent_id: "agent-2", issue_id: "issue-2" }),
makeTask({ id: "t-3", agent_id: "agent-1", issue_id: "issue-3" }),
];
renderWithI18n(
<WorkspaceAgentWorkingChip value={false} onToggle={() => {}} />,
);
expect(
screen.getByRole("button", { name: /working/i }),
).toHaveTextContent("3");
});
it("ignores non-running tasks and respects scopedIssueIds", () => {
mockState.snapshot = [
makeTask({ id: "t-1", issue_id: "issue-1", status: "running" }),
makeTask({ id: "t-2", issue_id: "issue-2", status: "queued" }),
makeTask({ id: "t-3", issue_id: "issue-3", status: "running" }),
];
renderWithI18n(
<WorkspaceAgentWorkingChip
value={false}
onToggle={() => {}}
scopedIssueIds={new Set(["issue-1"])}
/>,
);
// Only the running task within scope counts → "1".
expect(
screen.getByRole("button", { name: /working/i }),
).toHaveTextContent("1");
});
it("shows 0 when no agents are running", () => {
mockState.snapshot = [];
renderWithI18n(
<WorkspaceAgentWorkingChip value={false} onToggle={() => {}} />,
);
expect(
screen.getByRole("button", { name: /working/i }),
).toHaveTextContent("0");
});
});

View File

@@ -65,7 +65,7 @@ export function WorkspaceAgentWorkingChip({
const wsId = useWorkspaceId();
const { data: snapshot = [] } = useQuery(agentTaskSnapshotOptions(wsId));
const { runningTasks, agentIds } = useMemo(() => {
const { runningTasks, agentIds, issueIds } = useMemo(() => {
const running: AgentTask[] = [];
for (const task of snapshot) {
if (task.status !== "running") continue;
@@ -75,11 +75,21 @@ export function WorkspaceAgentWorkingChip({
if (scopedIssueIds && !scopedIssueIds.has(task.issue_id)) continue;
running.push(task);
}
const unique = [...new Set(running.map((tk) => tk.agent_id))];
return { runningTasks: running, agentIds: unique };
// The count tracks active *issues*, not active agents: several agents
// can work the same issue at once, and the chip answers "how many
// issues are agents working on right now?" (its filter narrows the
// list to exactly those issues). The avatar stack still shows the
// distinct agents behind that work.
const uniqueIssues = [...new Set(running.map((tk) => tk.issue_id))];
const uniqueAgents = [...new Set(running.map((tk) => tk.agent_id))];
return {
runningTasks: running,
agentIds: uniqueAgents,
issueIds: uniqueIssues,
};
}, [snapshot, scopedIssueIds]);
const hasAgents = agentIds.length > 0;
const hasAgents = issueIds.length > 0;
// Active (brand-filled) class — must explicitly re-pin text and bg in
// every interactive state. Button's `outline` variant ships
// `hover:text-foreground` + `aria-expanded:bg-muted aria-expanded:text-foreground`,
@@ -140,7 +150,7 @@ export function WorkspaceAgentWorkingChip({
max={3}
opacity="full"
/>
<span className="tabular-nums">{agentIds.length}</span>
<span className="tabular-nums">{issueIds.length}</span>
<span className="hidden md:inline">{label}</span>
</Button>
}

View File

@@ -70,7 +70,6 @@
"pause_aria": "Pause autopilot",
"activate_aria": "Activate autopilot",
"edit": "Edit",
"manage_access": "Manage access",
"run_now": "Run now",
"running": "Running...",
"toast_triggered": "Autopilot triggered",

View File

@@ -71,7 +71,6 @@
"fallback_runtime_cloud": "クラウド",
"fallback_runtime_local": "ローカル",
"actions_aria": "行の操作",
"task_count_one": "{{count}} 件のタスク",
"task_count_other": "{{count}} 件のタスク"
},
"activity_tooltip": {
@@ -463,14 +462,12 @@
},
"last_active": {
"today": "今日",
"days_ago_one": "{{count}} 日前",
"days_ago_other": "{{count}} 日前",
"none": "30 日間アクティビティなし"
},
"toolbar": {
"result_count_title": "該当エージェント / スコープ内全体",
"filter_label": "フィルター",
"filter_active_count_one": "{{count}} 件のフィルター",
"filter_active_count_other": "{{count}} 件のフィルター",
"clear_filters": "フィルターをクリア",
"section_availability": "可用性",
@@ -484,7 +481,6 @@
"section_model": "モデル"
},
"actions": {
"selected_one": "{{count}} 件選択中",
"selected_other": "{{count}} 件選択中",
"clear_selection": "選択を解除"
}

View File

@@ -70,7 +70,6 @@
"pause_aria": "オートパイロットを一時停止",
"activate_aria": "オートパイロットを有効化",
"edit": "編集",
"manage_access": "アクセス管理",
"run_now": "今すぐ実行",
"running": "実行中...",
"toast_triggered": "オートパイロットを実行しました",
@@ -382,7 +381,6 @@
"toolbar": {
"result_count_title": "該当する自動化 / すべての自動化",
"filter_label": "フィルター",
"filter_active_count_one": "{{count}} 件のフィルター",
"filter_active_count_other": "{{count}} 件のフィルター",
"clear_filters": "フィルターをクリア",
"section_assignee": "担当",
@@ -400,12 +398,10 @@
"pause": "一時停止",
"resume": "再開",
"delete": "削除",
"selected_one": "{{count}} 件選択中",
"selected_other": "{{count}} 件選択中",
"clear_selection": "選択を解除",
"delete_dialog": {
"title": "自動化を削除しますか?",
"description_one": "「{{name}}」とその実行履歴を完全に削除します。",
"description_other": "{{count}} 件の自動化とその実行履歴を完全に削除します。",
"warning": "この操作は取り消せません。",
"cancel": "キャンセル",

View File

@@ -15,7 +15,6 @@
"unpin": "ピン留め解除",
"delete": "削除",
"no_matches": "該当するプロジェクトはありません",
"selected_one": "{{count}} 件選択中",
"selected_other": "{{count}} 件選択中",
"clear_selection": "選択を解除"
},
@@ -132,7 +131,6 @@
"toolbar": {
"result_count_title": "該当プロジェクト / すべてのプロジェクト",
"filter_label": "フィルター",
"filter_active_count_one": "{{count}} 件のフィルター",
"filter_active_count_other": "{{count}} 件のフィルター",
"clear_filters": "フィルターをクリア",
"section_status": "ステータス",

View File

@@ -65,24 +65,19 @@
"selected": "{{count}} 件選択中",
"clear_selection": "選択を解除",
"delete_no_permission": "作成者または管理者のみ削除できるスキルが含まれています",
"delete_dialog_title_one": "{{count}} 件のスキルを削除しますか?",
"delete_dialog_title_other": "{{count}} 件のスキルを削除しますか?",
"delete_dialog_desc_one": "選択したスキルを完全に削除し、すべてのエージェントから取り外します。",
"delete_dialog_desc_other": "選択した {{count}} 件のスキルを完全に削除し、すべてのエージェントから取り外します。",
"deleted_toast_one": "{{count}} 件のスキルを削除しました",
"deleted_toast_other": "{{count}} 件のスキルを削除しました",
"delete_failed_toast": "スキルの削除に失敗しました",
"add_dialog_description": "選択したスキルを追加するエージェントを選んでください。",
"cancel": "キャンセル",
"add_confirm": "追加({{num}}",
"adding": "追加中...",
"added_multi_toast_one": "{{count}} 件のエージェントに追加しました",
"added_multi_toast_other": "{{count}} 件のエージェントに追加しました"
},
"toolbar": {
"result_count_title": "該当スキル / 全スキル",
"filter_label": "フィルター",
"filter_active_count_one": "{{count}} 件のフィルター",
"filter_active_count_other": "{{count}} 件のフィルター",
"clear_filters": "フィルターを解除",
"section_usage": "使用状況",

View File

@@ -93,7 +93,6 @@
"section_columns": "列",
"result_count_title": "該当スカッド / すべてのスカッド",
"filter_label": "フィルター",
"filter_active_count_one": "{{count}} 件のフィルター",
"filter_active_count_other": "{{count}} 件のフィルター",
"clear_filters": "フィルターをクリア"
}

View File

@@ -57,7 +57,6 @@
"section_local": "로컬",
"section_remote": "원격",
"section_cloud": "클라우드",
"agent_count_one": "에이전트 {{count}}개",
"agent_count_other": "에이전트 {{count}}개",
"empty": "아직 기기가 없습니다"
},
@@ -80,16 +79,13 @@
"fallback_runtime_cloud": "클라우드",
"fallback_runtime_local": "로컬",
"actions_aria": "행 작업",
"task_count_one": "태스크 {{count}}개",
"task_count_other": "태스크 {{count}}개"
},
"activity_tooltip": {
"created_today": "오늘 생성됨",
"created_days_ago_one": "{{count}}일 전에 생성됨",
"created_days_ago_other": "{{count}}일 전에 생성됨",
"last_7_days": "최근 7일",
"no_activity": "활동 없음",
"runs_one": "실행 {{count}}회",
"runs_other": "실행 {{count}}회",
"failed_suffix": " · 실패 {{count}}회({{percent}}%)"
},
@@ -103,14 +99,12 @@
"agent_restored_toast": "에이전트를 복원했습니다",
"restore_failed_toast": "에이전트를 복원하지 못했습니다",
"no_tasks_to_cancel_toast": "취소할 활성 작업이 없습니다",
"cancelled_tasks_toast_one": "작업 {{count}}개를 취소했습니다",
"cancelled_tasks_toast_other": "작업 {{count}}개를 취소했습니다",
"cancel_failed_toast": "작업을 취소하지 못했습니다",
"cancel_dialog_title": "\"{{name}}\"의 모든 작업을 취소할까요?",
"cancel_dialog_no_tasks": "취소할 활성 작업이 없습니다.",
"cancel_dialog_running_other": "실행 중 {{count}}개",
"cancel_dialog_queued_other": "대기 중 {{count}}개",
"cancel_dialog_impact_one": "{{summary}} 작업이 취소됩니다.",
"cancel_dialog_impact_other": "{{summary}} 작업이 취소됩니다.",
"cancel_dialog_running_note": " 실행 중인 작업이 완전히 멈추기까지 최대 5초가 걸릴 수 있습니다.",
"cancel_dialog_irreversible": " 취소한 작업은 재개할 수 없습니다.",
@@ -258,7 +252,6 @@
"skills_section": {
"label": "스킬",
"placeholder": "워크스페이스에서 스킬 추가",
"selected_one": "{{count}}개 선택됨 - 클릭해서 수정",
"selected_other": "{{count}}개 선택됨 - 클릭해서 수정",
"collapse": "접기",
"list_empty_multi": "아직 이 워크스페이스에 스킬이 없습니다. 먼저 만들거나 가져오세요.",
@@ -299,7 +292,6 @@
"duplicate_keys_toast": "중복된 환경 변수 키가 있습니다",
"saved_toast": "환경 변수를 저장했습니다",
"save_failed_toast": "환경 변수를 저장하지 못했습니다",
"not_revealed_title_one": "변수 {{count}}개 설정됨",
"not_revealed_title_other": "변수 {{count}}개 설정됨",
"not_revealed_empty": "설정된 환경 변수가 없습니다.",
"not_revealed_hint": "값은 공개하기 전까지 마스킹됩니다. 모든 공개와 수정은 워크스페이스 감사 로그에 기록됩니다.",
@@ -363,7 +355,6 @@
"add_dialog_empty_partial": "더 추가할 스킬이 없습니다. 이 에이전트에 모든 스킬이 이미 할당되어 있습니다.",
"add_dialog_saving": "추가하는 중...",
"add_dialog_confirm_default": "추가",
"add_dialog_confirm_one": "스킬 {{count}}개 추가",
"add_dialog_confirm_other": "스킬 {{count}}개 추가",
"add_dialog_cancel": "취소",
"add_failed_toast": "스킬을 추가하지 못했습니다"
@@ -377,7 +368,6 @@
"section_last_30d": "최근 30일",
"section_recent": "최근 작업",
"subtitle_no_active": "활성 작업 없음",
"subtitle_active_one": "활성 작업 {{count}}개",
"subtitle_active_other": "활성 작업 {{count}}개",
"subtitle_performance": "성과",
"subtitle_no_recent": "아직 완료된 작업 없음",
@@ -387,7 +377,6 @@
"empty_30d": "최근 30일 동안 완료된 작업이 없습니다.",
"empty_recent": "이 에이전트는 아직 완료한 작업이 없습니다.",
"show_more": "더 보기 →",
"runs_one": "회 실행",
"runs_other": "회 실행",
"success_pct": "성공률 {{percent}}%",
"avg_duration": "평균 {{value}}",
@@ -452,9 +441,7 @@
"status_failed": "실패함",
"filter": "필터",
"clear_filters": "필터 지우기",
"tool_calls_one": "도구 호출 {{count}}회",
"tool_calls_other": "도구 호출 {{count}}회",
"events_one": "이벤트 {{count}}개",
"events_other": "이벤트 {{count}}개",
"events_filtered": "이벤트 {{total}}개 중 {{shown}}개",
"copy_all": "전체 복사",
@@ -475,14 +462,12 @@
},
"last_active": {
"today": "오늘",
"days_ago_one": "{{count}}일 전",
"days_ago_other": "{{count}}일 전",
"none": "30일간 활동 없음"
},
"toolbar": {
"result_count_title": "일치하는 에이전트 / 범위 내 전체",
"filter_label": "필터",
"filter_active_count_one": "필터 {{count}}개",
"filter_active_count_other": "필터 {{count}}개",
"clear_filters": "필터 지우기",
"section_availability": "가용성",
@@ -496,7 +481,6 @@
"section_model": "모델"
},
"actions": {
"selected_one": "{{count}}개 선택됨",
"selected_other": "{{count}}개 선택됨",
"clear_selection": "선택 해제"
}

View File

@@ -70,7 +70,6 @@
"pause_aria": "오토파일럿 일시 중지",
"activate_aria": "오토파일럿 활성화",
"edit": "수정",
"manage_access": "접근 권한 관리",
"run_now": "지금 실행",
"running": "실행 중...",
"toast_triggered": "오토파일럿을 실행했습니다",
@@ -382,7 +381,6 @@
"toolbar": {
"result_count_title": "일치하는 자동화 / 전체 자동화",
"filter_label": "필터",
"filter_active_count_one": "필터 {{count}}개",
"filter_active_count_other": "필터 {{count}}개",
"clear_filters": "필터 지우기",
"section_assignee": "담당",
@@ -400,12 +398,10 @@
"pause": "일시중지",
"resume": "재개",
"delete": "삭제",
"selected_one": "{{count}}개 선택됨",
"selected_other": "{{count}}개 선택됨",
"clear_selection": "선택 해제",
"delete_dialog": {
"title": "자동화를 삭제할까요?",
"description_one": "\"{{name}}\" 및 실행 기록이 영구 삭제됩니다.",
"description_other": "자동화 {{count}}개와 실행 기록이 영구 삭제됩니다.",
"warning": "이 작업은 되돌릴 수 없습니다.",
"cancel": "취소",

View File

@@ -1,7 +1,6 @@
{
"fab": {
"running": "Multica가 작업 중입니다...",
"unread_one": "읽지 않은 채팅 {{count}}개",
"unread_other": "읽지 않은 채팅 {{count}}개",
"default": "Multica에 묻기"
},
@@ -20,11 +19,9 @@
"replied_in": "{{elapsed}} 만에 답변",
"failed_after": "{{elapsed}} 후 실패",
"task_failed_fallback": "작업 실패",
"tools_one": "도구 {{count}}개",
"tools_other": "도구 {{count}}개",
"tool_result_named": "{{tool}} 결과: ",
"tool_result_unnamed": "결과: ",
"process_steps_one": "단계 {{count}}개",
"process_steps_other": "단계 {{count}}개",
"copy_action": "복사",
"copied_toast": "복사했습니다",

View File

@@ -42,7 +42,6 @@
"title": "초대를 받았습니다",
"subtitle": "참가할 워크스페이스를 선택하세요. 나머지는 나중에 사이드바에서 처리할 수 있습니다.",
"submit_skip": "건너뛰고 내 워크스페이스 설정",
"submit_join_one": "워크스페이스 1개 참가",
"submit_join_other": "워크스페이스 {{count}}개 참가",
"joining": "참가하는 중...",
"error_generic": "초대를 처리하지 못했습니다. 다시 시도하세요.",

View File

@@ -60,10 +60,8 @@
"members_group": "멤버",
"agents_group": "에이전트",
"squads_group": "스쿼드",
"issue_count_one": "이슈 {{count}}개",
"issue_count_other": "이슈 {{count}}개",
"reset": "필터 모두 초기화",
"active_count_one": "필터 {{count}}개",
"active_count_other": "필터 {{count}}개"
},
"display": {
@@ -194,7 +192,6 @@
"pull_request_card_status_ready": "병합 가능",
"pull_request_card_status_unknown": "아직 검사 결과가 없습니다",
"pull_request_card_draft_prefix": "초안 · {{status}}",
"pull_request_card_files_count_one": "파일 {{count}}개",
"pull_request_card_files_count_other": "파일 {{count}}개",
"pull_request_card_show_more": "{{count}}개 더 보기",
"pull_request_card_show_less": "간단히 보기",
@@ -253,9 +250,7 @@
"due_date_removed": "마감일을 제거했습니다",
"title_renamed": "이슈 제목을 \"{{from}}\"에서 \"{{to}}\"로 바꿨습니다",
"description_updated": "설명을 업데이트했습니다",
"task_completed_one": "작업을 완료했습니다",
"task_completed_other": "작업을 완료했습니다({{count}}회)",
"task_failed_one": "작업이 실패했습니다",
"task_failed_other": "작업이 실패했습니다({{count}}회)",
"squad_leader_evaluated": "스쿼드 트리거를 평가했습니다",
"squad_leader_action": "평가 후 작업을 수행했습니다",
@@ -265,9 +260,7 @@
"squad_leader_failed": "평가 실패",
"squad_leader_failed_reason": "평가 실패: {{reason}}",
"coalesced_badge": "×{{count}}",
"activity_count_one": "활동 {{count}}개",
"activity_count_other": "활동 {{count}}개",
"show_more_activities_one": "활동 {{count}}개 더 보기",
"show_more_activities_other": "활동 {{count}}개 더 보기"
},
"comment": {
@@ -286,7 +279,6 @@
"send_failed": "댓글을 보내지 못했습니다",
"send_reply_failed": "답글을 보내지 못했습니다",
"delete_failed": "댓글을 삭제하지 못했습니다",
"reply_count_one": "답글 {{count}}개",
"reply_count_other": "답글 {{count}}개",
"leave_comment_placeholder": "댓글 남기기...",
"send_tooltip": "보내기",
@@ -315,11 +307,8 @@
"unresolve_action": "해결 취소",
"resolution_badge": "해결",
"collapse": "접기",
"bar_one": "{{authors}}님의 해결된 댓글 {{count}}개",
"bar_other": "{{authors}}님의 해결된 댓글 {{count}}개",
"fold_one": "{{authors}}님의 댓글 {{count}}개",
"fold_other": "{{authors}}님의 댓글 {{count}}개",
"bar_authors_more_one": "{{names}} 외 {{count}}명",
"bar_authors_more_other": "{{names}} 외 {{count}}명",
"resolve_failed": "스레드를 해결하지 못했습니다",
"unresolve_failed": "스레드 해결을 취소하지 못했습니다"
@@ -344,7 +333,6 @@
"is_waiting_local_directory": "{{name}} 로컬 디렉터리 대기 중",
"queued_elapsed_prefix": "{{elapsed}} 동안 대기 중",
"fallback_name": "에이전트",
"tool_count_one": "도구 {{count}}개",
"tool_count_other": "도구 {{count}}개",
"transcript_button": "트랜스크립트 보기",
"stop_button": "중지",
@@ -391,15 +379,11 @@
"priority": "우선순위",
"assignee": "담당자",
"delete": "삭제",
"update_success_one": "이슈 {{count}}개를 업데이트했습니다",
"update_success_other": "이슈 {{count}}개를 업데이트했습니다",
"update_failed": "이슈를 업데이트하지 못했습니다",
"delete_success_one": "이슈 {{count}}개를 삭제했습니다",
"delete_success_other": "이슈 {{count}}개를 삭제했습니다",
"delete_failed": "이슈를 삭제하지 못했습니다",
"delete_dialog_title_one": "이슈 {{count}}개를 삭제할까요?",
"delete_dialog_title_other": "이슈 {{count}}개를 삭제할까요?",
"delete_dialog_desc_one": "이 작업은 되돌릴 수 없습니다. 선택한 이슈와 관련 데이터가 영구 삭제됩니다.",
"delete_dialog_desc_other": "이 작업은 되돌릴 수 없습니다. 선택한 이슈와 관련 데이터가 영구 삭제됩니다.",
"delete_dialog_warning": "워크스페이스 멤버라면 누구나 이슈를 삭제할 수 있습니다.",
"cancel": "취소"

View File

@@ -8,7 +8,6 @@
"unavailable": "멤버를 사용할 수 없습니다",
"agents_section": "에이전트 ({{count}})",
"detail_link": "자세히 →",
"more_agents_one": "외 에이전트 {{count}}개",
"more_agents_other": "외 에이전트 {{count}}개"
},
"detail": {

View File

@@ -85,7 +85,6 @@
"members_optional": "(선택 사항)",
"members_hint": "리더가 하위 작업을 위임할 수 있는 멤버입니다. 나중에 추가할 수 있습니다.",
"members_placeholder": "에이전트 또는 워크스페이스 멤버 추가",
"members_selected_count_one": "{{count}}개 선택됨",
"members_selected_count_other": "{{count}}개 선택됨",
"members_more_count": "+{{count}}",
"members_remove_aria": "{{name}} 제거",
@@ -120,7 +119,6 @@
"toast_created": "프로젝트를 만들었습니다",
"toast_failed": "프로젝트를 만들지 못했습니다",
"repos_pill": "저장소",
"repos_pill_count_one": "저장소 {{count}}개",
"repos_pill_count_other": "저장소 {{count}}개",
"repos_heading": "이 프로젝트에 GitHub 저장소 연결",
"repos_empty": "아직 워크스페이스 수준 저장소가 없습니다. 아래에 URL을 붙여 넣어 임시로 연결하세요.",

View File

@@ -19,7 +19,6 @@
"filter_button": "필터",
"filter_status": "상태",
"filter_priority": "우선순위",
"issue_count_one": "이슈 {{count}}개",
"issue_count_other": "이슈 {{count}}개",
"reset_filters": "필터 모두 초기화",
"display_settings": "보기 설정",

View File

@@ -319,7 +319,6 @@
"scanning_lede_suffix": " 등. 이 컴퓨터에 설치된 도구를 확인하는 중입니다.",
"found_headline": "이 컴퓨터가 연결되었습니다. 에이전트 런타임을 선택하세요.",
"found_lede": "이 컴퓨터에서 찾은 에이전트 런타임입니다. 첫 에이전트에 사용할 런타임을 선택하세요. 나중에 바꾸거나 더 추가할 수 있습니다.",
"runtime_count_one": "에이전트 런타임 {{count}}개",
"runtime_count_other": "에이전트 런타임 {{count}}개",
"status_all_online": "모두 온라인",
"status_none_online": "온라인 없음",
@@ -365,7 +364,6 @@
"cli_dialog_description": "데스크톱과 같은 연결을 터미널에서 설정합니다. 서버, 원격 개발 환경, headless 컴퓨터에 사용하세요.",
"cli_dialog_pick_hint": "위에서 컴퓨터를 선택하세요.",
"cli_dialog_connect": "연결하고 계속",
"runtimes_connected_one": "컴퓨터 {{count}}대 연결됨",
"runtimes_connected_other": "컴퓨터 {{count}}대 연결됨",
"live_listening": "실시간 · 컴퓨터를 기다리는 중",
"stage_normal_prefix": "위 명령을 실행하세요. ",

View File

@@ -15,7 +15,6 @@
"unpin": "고정 해제",
"delete": "삭제",
"no_matches": "일치하는 프로젝트가 없습니다",
"selected_one": "{{count}}개 선택됨",
"selected_other": "{{count}}개 선택됨",
"clear_selection": "선택 해제"
},
@@ -132,7 +131,6 @@
"toolbar": {
"result_count_title": "일치하는 프로젝트 / 전체 프로젝트",
"filter_label": "필터",
"filter_active_count_one": "필터 {{count}}개",
"filter_active_count_other": "필터 {{count}}개",
"clear_filters": "필터 지우기",
"section_status": "상태",

View File

@@ -40,20 +40,16 @@
"this_machine": "이 Mac",
"local_badge": "로컬 · 이 기기",
"pending_custom_runtimes": "등록 대기 중인 사용자 지정 런타임",
"runtime_count_one": "런타임 {{count}}개",
"runtime_count_other": "런타임 {{count}}개",
"busy_count_one": "사용 중 {{count}}개",
"busy_count_other": "사용 중 {{count}}개",
"no_matches_title": "기기 없음",
"no_matches_hint": "현재 검색 또는 필터와 일치하는 기기가 없습니다.",
"select_machine": "기기를 선택해 런타임을 확인하세요.",
"metrics": {
"runtimes": "런타임",
"runtimes_hint_one": "온라인 {{count}}개",
"runtimes_hint_other": "온라인 {{count}}개",
"health": "상태",
"health_clear": "문제 없음",
"health_issues_one": "이슈 {{count}}개",
"health_issues_other": "이슈 {{count}}개",
"workload": "작업량",
"workload_value_idle": "유휴",
@@ -94,7 +90,6 @@
"fact_daemon_cli": "데몬 CLI",
"fact_daemon_id": "데몬 ID",
"serving_title": "제공 중",
"serving_count_one": "에이전트 {{count}}개",
"serving_count_other": "에이전트 {{count}}개",
"no_agents": "아직 이 런타임에 연결된 에이전트가 없습니다.",
"diagnostics_title": "진단",
@@ -121,16 +116,13 @@
"submitting": "삭제하는 중..."
},
"cascade": {
"title_one": "에이전트 {{count}}개를 보관하고 이 런타임을 삭제할까요?",
"title_other": "에이전트 {{count}}개를 보관하고 이 런타임을 삭제할까요?",
"description": "\"{{name}}\"을(를) 삭제합니다. 아래 에이전트는 보관되어 활성 워크플로에서 제거되고, 대기 중이거나 실행 중인 작업은 취소된 뒤 런타임이 삭제됩니다.",
"warning": "파괴적인 작업입니다. 보관된 에이전트는 활성 워크플로에서 제거되고 대기 중이거나 실행 중인 작업은 취소됩니다.",
"notice_runtime_has_active_agents": "이 대화상자를 연 뒤 활성 에이전트가 추가되었습니다. 새 계획을 확인한 뒤 승인하세요.",
"notice_runtime_delete_plan_changed": "대화상자가 열린 동안 활성 에이전트 목록이 바뀌었습니다. 새 계획을 확인한 뒤 승인하세요.",
"checkbox_one": "에이전트 {{count}}개가 보관되고 대기 중이거나 실행 중인 작업이 취소된다는 점을 이해했습니다.",
"checkbox_other": "에이전트 {{count}}개가 보관되고 대기 중이거나 실행 중인 작업이 취소된다는 점을 이해했습니다.",
"cancel": "취소",
"confirm_one": "에이전트 {{count}}개 보관 및 런타임 삭제",
"confirm_other": "에이전트 {{count}}개 보관 및 런타임 삭제",
"submitting": "보관하고 삭제하는 중...",
"delete_failed_toast": "런타임을 삭제하지 못했습니다",
@@ -154,18 +146,14 @@
},
"toast_deleted": "런타임을 삭제했습니다",
"toast_delete_failed": "런타임을 삭제하지 못했습니다",
"running_chip_one": "· 실행 중 {{count}}개",
"running_chip_other": "· 실행 중 {{count}}개",
"queued_chip_one": "· 대기 중 {{count}}개",
"queued_chip_other": "· 대기 중 {{count}}개"
},
"detail_page": {
"not_found_title": "런타임을 찾을 수 없습니다",
"not_found_hint": "삭제되었거나 접근 권한이 없을 수 있습니다."
},
"running_one": "실행 중 {{count}}개",
"running_other": "실행 중 {{count}}개",
"queued_one": "대기 중 {{count}}개",
"queued_other": "대기 중 {{count}}개",
"connect": {
"title": "컴퓨터 추가",
@@ -377,7 +365,6 @@
"empty_pricing_missing": "토큰은 기록됐지만 가격 정보가 없습니다:",
"empty_pricing_hint": "비용 합계에 포함하려면 사용자 지정 단가를 설정하세요.",
"empty_zero_cost": "토큰은 기록됐지만 비용 계산 결과가 $0입니다.",
"unmapped_notice_one": "기본 가격이 등록되지 않은 모델 {{count}}개가 있어 해당 토큰은 비용 합계에서 제외됩니다.",
"unmapped_notice_other": "기본 가격이 등록되지 않은 모델 {{count}}개가 있어 해당 토큰은 비용 합계에서 제외됩니다.",
"custom_pricing": {
"open_button": "사용자 지정 가격 설정",
@@ -397,9 +384,7 @@
"cost_by_title_model": "모델별 비용",
"cost_by_tab_agent": "에이전트별",
"cost_by_tab_model": "모델별",
"cost_by_caption_agent_one": "이 런타임의 에이전트 {{count}}개",
"cost_by_caption_agent_other": "이 런타임의 에이전트 {{count}}개",
"cost_by_caption_model_one": "사용된 모델 {{count}}개",
"cost_by_caption_model_other": "사용된 모델 {{count}}개",
"daily_breakdown_toggle": "일별 상세 테이블",
"table_date": "날짜",

View File

@@ -65,24 +65,19 @@
"selected": "{{count}}개 선택됨",
"clear_selection": "선택 해제",
"delete_no_permission": "작성자 또는 관리자만 삭제할 수 있는 스킬이 포함되어 있습니다",
"delete_dialog_title_one": "스킬 {{count}}개를 삭제할까요?",
"delete_dialog_title_other": "스킬 {{count}}개를 삭제할까요?",
"delete_dialog_desc_one": "선택한 스킬을 영구 삭제하고 모든 에이전트에서 제거합니다.",
"delete_dialog_desc_other": "선택한 스킬 {{count}}개를 영구 삭제하고 모든 에이전트에서 제거합니다.",
"deleted_toast_one": "스킬 {{count}}개를 삭제했습니다",
"deleted_toast_other": "스킬 {{count}}개를 삭제했습니다",
"delete_failed_toast": "스킬 삭제에 실패했습니다",
"add_dialog_description": "선택한 스킬을 추가할 에이전트를 선택하세요.",
"cancel": "취소",
"add_confirm": "추가 ({{num}})",
"adding": "추가 중...",
"added_multi_toast_one": "에이전트 {{count}}개에 추가했습니다",
"added_multi_toast_other": "에이전트 {{count}}개에 추가했습니다"
},
"toolbar": {
"result_count_title": "일치하는 스킬 / 전체 스킬",
"filter_label": "필터",
"filter_active_count_one": "필터 {{count}}개",
"filter_active_count_other": "필터 {{count}}개",
"clear_filters": "필터 해제",
"section_usage": "사용 상태",
@@ -135,7 +130,6 @@
"files": "파일",
"id": "ID",
"origin": "출처",
"used_by_one": "에이전트 {{count}}개가 사용 중",
"used_by_other": "에이전트 {{count}}개가 사용 중",
"permissions": "권한",
"permissions_owner": "이 스킬을 수정하고 삭제할 수 있습니다. 변경사항은 다음 에이전트 실행부터 적용됩니다.",
@@ -161,7 +155,6 @@
"toast_delete_failed": "스킬을 삭제하지 못했습니다",
"delete_dialog": {
"title": "스킬을 삭제할까요?",
"description_with_agents_one": "\"{{name}}\"을(를) 영구 삭제하고 현재 사용 중인 에이전트 {{count}}개에서 제거합니다.",
"description_with_agents_other": "\"{{name}}\"을(를) 영구 삭제하고 현재 사용 중인 에이전트 {{count}}개에서 제거합니다.",
"description_no_agents": "\"{{name}}\"을(를) 영구 삭제하고 모든 에이전트에서 제거합니다.",
"warning": "이 작업은 되돌릴 수 없습니다.",
@@ -257,7 +250,6 @@
"select_skill": "계속하려면 스킬을 선택하세요.",
"import_button": "워크스페이스로 가져오기",
"importing": "가져오는 중...",
"skill_files_one": "파일 {{count}}개",
"skill_files_other": "파일 {{count}}개",
"skill_name_label": "워크스페이스 스킬 이름",
"skill_description_label": "설명",

View File

@@ -52,7 +52,6 @@
},
"members_tab": {
"section_title": "멤버",
"section_count_one": "이 스쿼드의 멤버 {{count}}명",
"section_count_other": "이 스쿼드의 멤버 {{count}}명",
"add_member_button": "멤버 추가",
"create_agent_button": "에이전트 만들기",
@@ -73,10 +72,8 @@
"unavailable": "스쿼드를 사용할 수 없습니다",
"detail_link": "자세히 →",
"archived": "보관됨",
"member_count_one": "멤버 {{count}}명",
"member_count_other": "멤버 {{count}}명",
"members_section": "멤버",
"more_members_one": "+{{count}}명 더 보기",
"more_members_other": "+{{count}}명 더 보기"
},
"instructions_tab": {
@@ -96,7 +93,6 @@
"section_columns": "열",
"result_count_title": "일치하는 스쿼드 / 전체 스쿼드",
"filter_label": "필터",
"filter_active_count_one": "필터 {{count}}개",
"filter_active_count_other": "필터 {{count}}개",
"clear_filters": "필터 지우기"
}

View File

@@ -85,3 +85,27 @@ describe("locale bundle parity", () => {
}
}
});
// Dead plural-key guard: a locale whose CLDR plural rules have no `one`
// category (e.g. ja/ko/zh-Hans) resolves only `_other`, so any `_one` key in
// it is dead weight i18next never renders. Left unchecked these accumulate and
// hide bugs — a missing `_other` silently falls back while the orphan `_one`
// looks like coverage. i18next resolves plurals via Intl.PluralRules, so we
// gate on the same source of truth.
describe("dead plural-key guard", () => {
for (const locale of translatedLocales) {
const categories = new Intl.PluralRules(locale).resolvedOptions()
.pluralCategories;
if (categories.includes("one")) continue;
const bundle = RESOURCES[locale as keyof typeof RESOURCES];
it(`${locale} ships no dead _one keys (plural categories: ${categories.join("/")})`, () => {
const offenders = Object.keys(bundle).flatMap((ns) =>
flattenKeys(bundle[ns])
.filter((key) => key.endsWith("_one"))
.map((key) => `${ns}:${key}`),
);
expect(offenders).toEqual([]);
});
}
});

View File

@@ -79,7 +79,6 @@
"fallback_runtime_cloud": "云端",
"fallback_runtime_local": "本地",
"actions_aria": "行操作",
"task_count_one": "{{count}} 个任务",
"task_count_other": "{{count}} 个任务"
},
"activity_tooltip": {
@@ -463,14 +462,12 @@
},
"last_active": {
"today": "今天",
"days_ago_one": "{{count}} 天前",
"days_ago_other": "{{count}} 天前",
"none": "30 天内无活动"
},
"toolbar": {
"result_count_title": "当前结果 / 范围内全部",
"filter_label": "筛选",
"filter_active_count_one": "{{count}} 项筛选",
"filter_active_count_other": "{{count}} 项筛选",
"clear_filters": "清除筛选",
"section_availability": "可用性",
@@ -484,7 +481,6 @@
"section_model": "模型"
},
"actions": {
"selected_one": "已选 {{count}} 项",
"selected_other": "已选 {{count}} 项",
"clear_selection": "清除选择"
}

View File

@@ -70,7 +70,6 @@
"pause_aria": "暂停自动化",
"activate_aria": "启用自动化",
"edit": "编辑",
"manage_access": "管理访问",
"run_now": "立即运行",
"running": "运行中...",
"toast_triggered": "已触发自动化",
@@ -382,7 +381,6 @@
"toolbar": {
"result_count_title": "当前结果 / 全部自动化",
"filter_label": "筛选",
"filter_active_count_one": "{{count}} 项筛选",
"filter_active_count_other": "{{count}} 项筛选",
"clear_filters": "清除筛选",
"section_assignee": "执行者",
@@ -400,12 +398,10 @@
"pause": "暂停",
"resume": "恢复",
"delete": "删除",
"selected_one": "已选 {{count}} 项",
"selected_other": "已选 {{count}} 项",
"clear_selection": "清除选择",
"delete_dialog": {
"title": "删除自动化?",
"description_one": "将永久删除“{{name}}”及其运行历史。",
"description_other": "将永久删除 {{count}} 个自动化及其运行历史。",
"warning": "此操作无法撤销。",
"cancel": "取消",

View File

@@ -192,7 +192,6 @@
"pull_request_card_status_ready": "可以合入",
"pull_request_card_status_unknown": "暂无检查信息",
"pull_request_card_draft_prefix": "Draft · {{status}}",
"pull_request_card_files_count_one": "{{count}} 个文件",
"pull_request_card_files_count_other": "{{count}} 个文件",
"pull_request_card_show_more": "展开剩余 {{count}} 个",
"pull_request_card_show_less": "收起",
@@ -251,9 +250,7 @@
"due_date_removed": "移除了截止日期",
"title_renamed": "把这个 issue 从\"{{from}}\"重命名为\"{{to}}\"",
"description_updated": "更新了描述",
"task_completed_one": "完成了 task",
"task_completed_other": "完成了 task{{count}} 次)",
"task_failed_one": "task 失败",
"task_failed_other": "task 失败({{count}} 次)",
"squad_leader_evaluated": "评估了小队触发",
"squad_leader_action": "已评估并采取了操作",
@@ -263,9 +260,7 @@
"squad_leader_failed": "评估失败",
"squad_leader_failed_reason": "评估失败:{{reason}}",
"coalesced_badge": "×{{count}}",
"activity_count_one": "{{count}} 条动态",
"activity_count_other": "{{count}} 条动态",
"show_more_activities_one": "展开更早 {{count}} 条动态",
"show_more_activities_other": "展开更早 {{count}} 条动态"
},
"comment": {

View File

@@ -85,7 +85,6 @@
"members_optional": "(可选)",
"members_hint": "Leader 可以委派子任务的成员。也可稍后再加。",
"members_placeholder": "添加 Agent 或工作区成员",
"members_selected_count_one": "已选 {{count}} 人",
"members_selected_count_other": "已选 {{count}} 人",
"members_more_count": "+{{count}}",
"members_remove_aria": "移除 {{name}}",
@@ -120,7 +119,6 @@
"toast_created": "已创建项目",
"toast_failed": "创建项目失败",
"repos_pill": "代码仓库",
"repos_pill_count_one": "{{count}} 个仓库",
"repos_pill_count_other": "{{count}} 个仓库",
"repos_heading": "为此项目关联 GitHub 仓库",
"repos_empty": "还没有工作区级别的仓库。可以在下方粘贴 URL 临时关联一个。",

View File

@@ -15,7 +15,6 @@
"unpin": "取消钉选",
"delete": "删除",
"no_matches": "没有匹配的项目",
"selected_one": "已选 {{count}} 项",
"selected_other": "已选 {{count}} 项",
"clear_selection": "清除选择"
},
@@ -132,7 +131,6 @@
"toolbar": {
"result_count_title": "当前结果 / 全部项目",
"filter_label": "筛选",
"filter_active_count_one": "{{count}} 项筛选",
"filter_active_count_other": "{{count}} 项筛选",
"clear_filters": "清除筛选",
"section_status": "状态",

View File

@@ -77,24 +77,19 @@
"selected": "已选 {{count}} 项",
"clear_selection": "清除选择",
"delete_no_permission": "选中项包含仅创建者或管理员可删除的 skill",
"delete_dialog_title_one": "删除 {{count}} 个 skill",
"delete_dialog_title_other": "删除 {{count}} 个 skill",
"delete_dialog_desc_one": "将永久删除选中的 skill并从所有智能体上移除。",
"delete_dialog_desc_other": "将永久删除选中的 {{count}} 个 skill并从所有智能体上移除。",
"deleted_toast_one": "已删除 {{count}} 个 skill",
"deleted_toast_other": "已删除 {{count}} 个 skill",
"delete_failed_toast": "删除 skill 失败",
"add_dialog_description": "选择要获得所选 skill 的智能体。",
"cancel": "取消",
"add_confirm": "添加({{num}}",
"adding": "添加中...",
"added_multi_toast_one": "已添加到 {{count}} 个智能体",
"added_multi_toast_other": "已添加到 {{count}} 个智能体"
},
"toolbar": {
"result_count_title": "当前结果 / 全部 skill",
"filter_label": "筛选",
"filter_active_count_one": "{{count}} 项筛选",
"filter_active_count_other": "{{count}} 项筛选",
"clear_filters": "清除筛选",
"section_usage": "使用状态",
@@ -147,7 +142,6 @@
"files": "文件",
"id": "ID",
"origin": "来源",
"used_by_one": "被 {{count}} 个智能体使用",
"used_by_other": "被 {{count}} 个智能体使用",
"permissions": "权限",
"permissions_owner": "你可以编辑和删除这个 skill。修改在智能体下次运行时生效。",
@@ -173,7 +167,6 @@
"toast_delete_failed": "删除 skill 失败",
"delete_dialog": {
"title": "删除这个 skill",
"description_with_agents_one": "将永久删除\"{{name}}\",并从当前正在使用它的 {{count}} 个智能体上移除。",
"description_with_agents_other": "将永久删除\"{{name}}\",并从当前正在使用它的 {{count}} 个智能体上移除。",
"description_no_agents": "将永久删除\"{{name}}\",并从所有智能体上移除。",
"warning": "此操作不可撤销。",
@@ -269,7 +262,6 @@
"select_skill": "请选择一个 skill 继续。",
"import_button": "导入到工作区",
"importing": "导入中...",
"skill_files_one": "{{count}} 个文件",
"skill_files_other": "{{count}} 个文件",
"skill_name_label": "工作区里的 skill 名称",
"skill_description_label": "描述",

View File

@@ -52,7 +52,6 @@
},
"members_tab": {
"section_title": "成员",
"section_count_one": "该小队有 {{count}} 名成员",
"section_count_other": "该小队有 {{count}} 名成员",
"add_member_button": "添加成员",
"create_agent_button": "创建智能体",
@@ -73,10 +72,8 @@
"unavailable": "小队不可用",
"detail_link": "详情 →",
"archived": "已归档",
"member_count_one": "{{count}} 名成员",
"member_count_other": "{{count}} 名成员",
"members_section": "成员",
"more_members_one": "还有 {{count}} 人",
"more_members_other": "还有 {{count}} 人"
},
"instructions_tab": {
@@ -96,7 +93,6 @@
"section_columns": "列",
"result_count_title": "当前结果 / 全部小队",
"filter_label": "筛选",
"filter_active_count_one": "{{count}} 项筛选",
"filter_active_count_other": "{{count}} 项筛选",
"clear_filters": "清除筛选"
}

View File

@@ -0,0 +1,109 @@
package main
import (
"context"
"fmt"
"net/url"
"os"
"strconv"
"github.com/spf13/cobra"
"github.com/multica-ai/multica/server/internal/cli"
)
var chatCmd = &cobra.Command{
Use: "chat",
Short: "Work with the current chat conversation",
}
var chatHistoryCmd = &cobra.Command{
Use: "history",
Short: "Read prior messages from the chat channel this conversation came from",
Long: `Read the earlier messages of the chat channel (e.g. a Slack thread, channel,
or DM) that this conversation is connected to.
When you are @mentioned in a Slack thread or channel you only receive the one
triggering message — not what was said before it. Run this to pull the
surrounding conversation so you understand the full context.
A conversation has two nested histories: the surrounding CHANNEL and your own
THREAD within it (your first reply opens a thread on the @mention). By default
(--scope auto) the server reads the channel on your first reply — where the
prior context lives — and your thread on follow-ups. Use --scope channel to pull
the wider channel during a follow-up when the thread alone is not enough, or
--scope thread to force the thread.
It is the SAME command regardless of which channel the conversation came from;
the server hides the per-platform differences. It reads only the conversation
you are currently running for — it cannot read any other session or channel.`,
Args: cobra.NoArgs,
RunE: runChatHistory,
}
func init() {
chatHistoryCmd.Flags().String("scope", "auto", "Which history to read: auto, thread, or channel")
chatHistoryCmd.Flags().Int("limit", 0, "Maximum number of messages to return (the server clamps the range)")
chatHistoryCmd.Flags().String("before", "", "Opaque cursor (a next_cursor from a prior page) to read older messages")
chatHistoryCmd.Flags().String("output", "json", "Output format: table or json")
chatCmd.AddCommand(chatHistoryCmd)
}
func runChatHistory(cmd *cobra.Command, _ []string) error {
client, err := newAPIClient(cmd)
if err != nil {
return err
}
ctx, cancel := cli.APIContext(context.Background())
defer cancel()
scope, _ := cmd.Flags().GetString("scope")
limit, _ := cmd.Flags().GetInt("limit")
before, _ := cmd.Flags().GetString("before")
q := url.Values{}
if scope != "" && scope != "auto" {
q.Set("scope", scope)
}
if limit > 0 {
q.Set("limit", strconv.Itoa(limit))
}
if before != "" {
q.Set("before", before)
}
path := "/api/chat/history"
if encoded := q.Encode(); encoded != "" {
path += "?" + encoded
}
var resp map[string]any
if err := client.GetJSON(ctx, path, &resp); err != nil {
return fmt.Errorf("read chat history: %w", err)
}
output, _ := cmd.Flags().GetString("output")
if output == "table" {
if note := strVal(resp, "note"); note != "" {
fmt.Fprintln(os.Stdout, note)
return nil
}
if s := strVal(resp, "scope"); s != "" {
fmt.Fprintf(os.Stdout, "scope: %s\n", s)
}
msgs, _ := resp["messages"].([]any)
headers := []string{"TS", "ROLE", "AUTHOR", "TEXT"}
rows := make([][]string, 0, len(msgs))
for _, mi := range msgs {
m, ok := mi.(map[string]any)
if !ok {
continue
}
rows = append(rows, []string{strVal(m, "ts"), strVal(m, "role"), strVal(m, "author"), strVal(m, "text")})
}
cli.PrintTable(os.Stdout, headers, rows)
return nil
}
return cli.PrintJSON(os.Stdout, resp)
}

View File

@@ -9,6 +9,7 @@ import (
"io"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"unicode/utf8"
@@ -58,7 +59,7 @@ var skillDeleteCmd = &cobra.Command{
var skillImportCmd = &cobra.Command{
Use: "import",
Short: "Import a skill from a URL (clawhub.ai, skills.sh, or github.com)",
Short: "Import a skill from a URL (clawhub.ai, skills.sh, github.com) or a local .skill/.zip archive",
RunE: runSkillImport,
}
@@ -139,7 +140,8 @@ func init() {
skillDeleteCmd.Flags().Bool("yes", false, "Skip confirmation prompt")
// skill import
skillImportCmd.Flags().String("url", "", "URL to import from (required)")
skillImportCmd.Flags().String("url", "", "URL to import from (clawhub.ai, skills.sh, or github.com). Mutually exclusive with --file.")
skillImportCmd.Flags().String("file", "", "Path to a local skill archive (.skill or .zip) to import. Mutually exclusive with --url.")
skillImportCmd.Flags().String("on-conflict", "fail", "Conflict strategy when a skill with the same name exists: fail, overwrite, rename, or skip")
skillImportCmd.Flags().String("output", "json", "Output format: table or json")
@@ -416,23 +418,40 @@ func runSkillImport(cmd *cobra.Command, _ []string) error {
}
importURL, _ := cmd.Flags().GetString("url")
if importURL == "" {
return fmt.Errorf("--url is required")
importFile, _ := cmd.Flags().GetString("file")
switch {
case importURL == "" && importFile == "":
return fmt.Errorf("either --url or --file is required")
case importURL != "" && importFile != "":
return fmt.Errorf("--url and --file are mutually exclusive")
}
onConflict, _ := cmd.Flags().GetString("on-conflict")
if !validSkillImportConflictStrategy(onConflict) {
return fmt.Errorf("--on-conflict must be one of: fail, overwrite, rename, skip")
}
body := map[string]any{
"url": importURL,
"on_conflict": onConflict,
}
ctx, cancel := context.WithTimeout(context.Background(), cli.AtLeastAPITimeout(60*time.Second))
defer cancel()
var result map[string]any
if importFile != "" {
fileData, readErr := os.ReadFile(importFile)
if readErr != nil {
return fmt.Errorf("read skill archive: %w", readErr)
}
if err := client.ImportSkillFile(ctx, fileData, filepath.Base(importFile), onConflict, &result); err != nil {
if handledErr := handleSkillImportError(cmd, err); handledErr != nil {
return handledErr
}
return fmt.Errorf("import skill: %w", err)
}
return printSkillImportResult(cmd, result)
}
body := map[string]any{
"url": importURL,
"on_conflict": onConflict,
}
if err := client.PostJSON(ctx, "/api/skills/import", body, &result); err != nil {
if handledErr := handleSkillImportError(cmd, err); handledErr != nil {
return handledErr

View File

@@ -52,6 +52,7 @@ func init() {
repoCmd.GroupID = groupCore
skillCmd.GroupID = groupCore
squadCmd.GroupID = groupCore
chatCmd.GroupID = groupCore
// Runtime commands
daemonCmd.GroupID = groupRuntime
@@ -76,6 +77,7 @@ func init() {
rootCmd.AddCommand(repoCmd)
rootCmd.AddCommand(skillCmd)
rootCmd.AddCommand(squadCmd)
rootCmd.AddCommand(chatCmd)
rootCmd.AddCommand(daemonCmd)
rootCmd.AddCommand(runtimeCmd)
rootCmd.AddCommand(authCmd)

View File

@@ -34,7 +34,11 @@ var (
func newNamedRedisClient(base *redis.Options, suffix string) *redis.Client {
opts := *base
opts.ClientName = redisClientName(opts.ClientName, suffix)
if envBool("REDIS_DISABLE_CLIENT_NAME", false) {
opts.ClientName = ""
} else {
opts.ClientName = redisClientName(opts.ClientName, suffix)
}
return redis.NewClient(&opts)
}
@@ -120,6 +124,19 @@ func envDuration(name string, def time.Duration) time.Duration {
return v
}
func envBool(name string, def bool) bool {
raw := os.Getenv(name)
if raw == "" {
return def
}
v, err := strconv.ParseBool(raw)
if err != nil {
slog.Warn("invalid env var, using default", "name", name, "value", raw, "default", def, "error", err)
return def
}
return v
}
func main() {
logger.Init()
@@ -224,6 +241,9 @@ func main() {
if err != nil {
slog.Error("invalid REDIS_URL — falling back to in-memory hub", "error", err)
} else {
if envBool("REDIS_DISABLE_CLIENT_NAME", false) {
slog.Info("redis: CLIENT SETNAME disabled (REDIS_DISABLE_CLIENT_NAME=true) for managed Redis compatibility")
}
storeRedis = newNamedRedisClient(opts, "store")
relayWriteRedis = newNamedRedisClient(opts, "realtime-write")

View File

@@ -0,0 +1,112 @@
package main
import (
"os"
"testing"
"github.com/redis/go-redis/v9"
)
func TestRedisClientName(t *testing.T) {
tests := []struct {
name string
existing string
suffix string
want string
}{
{"empty_suffix_returns_existing", "multica-api:store", "", "multica-api:store"},
{"empty_existing_uses_default_prefix", "", "store", "multica-api:store"},
{"both_set_joins_with_colon", "custom", "store", "custom:store"},
{"empty_both_returns_empty", "", "", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := redisClientName(tt.existing, tt.suffix)
if got != tt.want {
t.Errorf("redisClientName(%q, %q) = %q, want %q", tt.existing, tt.suffix, got, tt.want)
}
})
}
}
func TestNewNamedRedisClient_SetsClientName(t *testing.T) {
t.Setenv("REDIS_DISABLE_CLIENT_NAME", "")
base := &redis.Options{Addr: "localhost:6379"}
client := newNamedRedisClient(base, "store")
defer client.Close()
opts := client.Options()
if opts.ClientName != "multica-api:store" {
t.Errorf("ClientName = %q, want %q", opts.ClientName, "multica-api:store")
}
}
func TestNewNamedRedisClient_DisableClientName(t *testing.T) {
t.Setenv("REDIS_DISABLE_CLIENT_NAME", "true")
base := &redis.Options{Addr: "localhost:6379"}
client := newNamedRedisClient(base, "store")
defer client.Close()
opts := client.Options()
if opts.ClientName != "" {
t.Errorf("ClientName = %q, want empty when REDIS_DISABLE_CLIENT_NAME=true", opts.ClientName)
}
}
func TestNewNamedRedisClient_DisableClientName_ClearsPreExistingName(t *testing.T) {
t.Setenv("REDIS_DISABLE_CLIENT_NAME", "true")
// Simulate REDIS_URL with ?client_name=foo — ParseURL sets ClientName.
base := &redis.Options{Addr: "localhost:6379", ClientName: "foo"}
client := newNamedRedisClient(base, "store")
defer client.Close()
opts := client.Options()
if opts.ClientName != "" {
t.Errorf("ClientName = %q, want empty: REDIS_DISABLE_CLIENT_NAME must clear pre-existing name from URL", opts.ClientName)
}
}
func TestNewNamedRedisClient_DisableClientName_InvalidValue(t *testing.T) {
t.Setenv("REDIS_DISABLE_CLIENT_NAME", "not-a-bool")
base := &redis.Options{Addr: "localhost:6379"}
client := newNamedRedisClient(base, "store")
defer client.Close()
opts := client.Options()
// Invalid value falls back to default (false), so ClientName IS set
if opts.ClientName != "multica-api:store" {
t.Errorf("ClientName = %q, want %q (invalid env should fall back to naming enabled)", opts.ClientName, "multica-api:store")
}
}
func TestEnvBool(t *testing.T) {
tests := []struct {
name string
key string
value string
def bool
want bool
}{
{"empty_returns_default_false", "TEST_ENV_BOOL_1", "", false, false},
{"empty_returns_default_true", "TEST_ENV_BOOL_2", "", true, true},
{"true_string", "TEST_ENV_BOOL_3", "true", false, true},
{"false_string", "TEST_ENV_BOOL_4", "false", true, false},
{"one_is_true", "TEST_ENV_BOOL_5", "1", false, true},
{"zero_is_false", "TEST_ENV_BOOL_6", "0", true, false},
{"invalid_returns_default", "TEST_ENV_BOOL_7", "maybe", false, false},
{"invalid_returns_default_true", "TEST_ENV_BOOL_8", "maybe", true, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.value != "" {
t.Setenv(tt.key, tt.value)
} else {
os.Unsetenv(tt.key)
}
got := envBool(tt.key, tt.def)
if got != tt.want {
t.Errorf("envBool(%q, %v) = %v, want %v", tt.key, tt.def, got, tt.want)
}
})
}
}

View File

@@ -464,6 +464,11 @@ func NewRouterWithOptions(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus
channelRouter.Register(slack.TypeSlack, slack.NewSlackResolverSet(queries, pool, slackReplier, slackTyping))
slack.NewOutbound(queries, box.Open, slog.Default()).Register(bus)
// On-demand history reader behind the unified `multica chat history`
// command (MUL-3871): pull the session's Slack conversation when the
// agent asks, instead of force-assembling it on every inbound.
h.SlackHistory = slack.NewHistory(queries, box.Open, slog.Default())
// Per-installation inbound: the Supervisor builds + supervises one
// Socket Mode connection per active Slack installation, authenticated
// with that installation's OWN app-level token (xapp-, pasted at BYO
@@ -1139,6 +1144,13 @@ func NewRouterWithOptions(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus
})
r.Get("/api/chat/pending-tasks", h.ListPendingChatTasks)
// Agent-facing unified history read: `multica chat history` resolves
// the caller's task-scoped token to its own chat session and returns
// the bound channel's prior messages (MUL-3871). No session id is
// passed — the token IS the scope, so an agent can only read its own
// conversation.
r.Get("/api/chat/history", h.GetChatChannelHistory)
// Inbox
r.Route("/api/inbox", func(r chi.Router) {
r.Get("/", h.ListInbox)

View File

@@ -529,6 +529,64 @@ func (c *APIClient) UploadFileWithURL(ctx context.Context, fileData []byte, file
return result.ID, result.URL, nil
}
// ImportSkillFile imports a skill from a local archive (.skill / .zip) by
// POSTing it as multipart/form-data to /api/skills/import, alongside the
// on_conflict strategy. The structured import result is decoded into out.
func (c *APIClient) ImportSkillFile(ctx context.Context, fileData []byte, filename, onConflict string, out any) error {
var body bytes.Buffer
writer := multipart.NewWriter(&body)
part, err := writer.CreateFormFile("file", filepath.Base(filename))
if err != nil {
return fmt.Errorf("create form file: %w", err)
}
if _, err := part.Write(fileData); err != nil {
return fmt.Errorf("write file data: %w", err)
}
if onConflict != "" {
if err := writer.WriteField("on_conflict", onConflict); err != nil {
return fmt.Errorf("write on_conflict field: %w", err)
}
}
if err := writer.Close(); err != nil {
return fmt.Errorf("close multipart writer: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+"/api/skills/import", &body)
if err != nil {
return err
}
req.Header.Set("Content-Type", writer.FormDataContentType())
c.setHeaders(req)
// Respect a longer context deadline for slow uploads, mirroring
// UploadFileWithURL: the default client timeout would otherwise shadow it.
httpClient := c.HTTPClient
if deadline, ok := ctx.Deadline(); ok {
remaining := time.Until(deadline)
if remaining > httpClient.Timeout {
clientCopy := *httpClient
clientCopy.Timeout = remaining
httpClient = &clientCopy
}
}
resp, err := httpClient.Do(req)
err = wrapTransport(req, err)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return newHTTPError(http.MethodPost, "/api/skills/import", resp)
}
if out == nil {
return nil
}
return json.NewDecoder(resp.Body).Decode(out)
}
// DownloadFile downloads a file from the given URL and returns the response body.
// This is used for downloading attachments via their signed download_url.
// Downloads are limited to 100 MB to match the upload size limit.

View File

@@ -190,6 +190,16 @@ func buildChatPrompt(task Task) string {
var b strings.Builder
b.WriteString("You are running as a chat assistant for a Multica workspace.\n")
b.WriteString("A user is chatting with you directly. Respond to their message.\n\n")
// Channel awareness (MUL-3871). When the session is backed by an IM channel,
// the agent must KNOW it is operating inside that channel — otherwise an ask
// like "what did you just talk about" sends it to read Multica instead of the
// Slack conversation. State it explicitly and point history reads at the
// channel, not Multica. A web-only chat session gets no such line — its
// history is the Multica chat_session the agent already resumes.
if task.ChatChannelType != "" {
platform := channelDisplayName(task.ChatChannelType)
fmt.Fprintf(&b, "You are operating inside a %s conversation (a channel, thread, or DM) — not the Multica web app. This conversation and its history live in %s, NOT in Multica. When the user asks about earlier messages, what was discussed, or who said what here, read it with `multica chat history --output json`; do NOT look in Multica issues or comments for this conversation's history. The message below may be only what triggered you — `multica chat history` auto-selects the right scope (the surrounding channel on your first reply, your own thread on follow-ups; add `--scope channel` to pull the wider channel when needed).\n\n", platform, platform)
}
if task.Agent != nil && len(task.Agent.Skills) > 0 {
refs := ExtractSlashSkills(task.ChatMessage)
if len(refs) > 0 {
@@ -243,6 +253,16 @@ func buildChatPrompt(task Task) string {
return b.String()
}
// channelDisplayName renders a chat_channel_type for prompt copy.
func channelDisplayName(channelType string) string {
switch channelType {
case "slack":
return "Slack"
default:
return channelType
}
}
// buildAutopilotPrompt constructs a prompt for run_only autopilot tasks.
func buildAutopilotPrompt(task Task) string {
var b strings.Builder

View File

@@ -270,6 +270,31 @@ func TestBuildChatPromptAttachmentIDsCanBeBoundToCreatedIssues(t *testing.T) {
}
}
func TestBuildChatPromptChannelAwareness(t *testing.T) {
t.Run("slack-backed session tells the agent it is in slack", func(t *testing.T) {
out := buildChatPrompt(Task{
ChatSessionID: "sess-1",
ChatChannelType: "slack",
ChatMessage: "你刚刚和 xxx 聊了什么",
})
for _, want := range []string{"Slack", "multica chat history", "NOT in Multica"} {
if !strings.Contains(out, want) {
t.Fatalf("slack-backed prompt missing %q\n--- output ---\n%s", want, out)
}
}
})
t.Run("web-only session has no channel block", func(t *testing.T) {
out := buildChatPrompt(Task{
ChatSessionID: "sess-1",
ChatMessage: "hi",
})
if strings.Contains(out, "multica chat history") {
t.Fatalf("web-only chat prompt should not mention channel history, got:\n%s", out)
}
})
}
func TestBuildChatPromptSlashSkills(t *testing.T) {
t.Run("injects selected skills block", func(t *testing.T) {
task := Task{

View File

@@ -68,6 +68,7 @@ type Task struct {
NewCommentCount int `json:"new_comment_count,omitempty"` // issue-wide comments since this agent's last run (excludes its own and the injected trigger); 0/omitted for old daemons or cold start
NewCommentsSince string `json:"new_comments_since,omitempty"` // RFC3339 anchor (last run's started_at) the count is measured from; empty on cold start
ChatSessionID string `json:"chat_session_id,omitempty"` // non-empty for chat tasks
ChatChannelType string `json:"chat_channel_type,omitempty"` // "slack" when the chat session is backed by an IM channel; empty for a web-only chat. Drives the channel-awareness block in the prompt
ChatMessage string `json:"chat_message,omitempty"` // user message content for chat tasks
ChatMessageAttachments []ChatAttachmentMeta `json:"chat_message_attachments,omitempty"` // attachments linked to the chat message; agent uses these to `multica attachment download <id>`
AutopilotRunID string `json:"autopilot_run_id,omitempty"` // non-empty for autopilot run_only tasks

View File

@@ -277,6 +277,7 @@ type AgentTaskResponse struct {
NewCommentCount int `json:"new_comment_count,omitempty"` // trigger-thread comments since last run; excludes injected trigger + own comments; omitempty so old daemons ignore it
NewCommentsSince string `json:"new_comments_since,omitempty"` // RFC3339 anchor (last run's started_at) the count is measured from; omitempty so old daemons ignore it
ChatSessionID string `json:"chat_session_id,omitempty"` // non-empty for chat tasks
ChatChannelType string `json:"chat_channel_type,omitempty"` // "slack" when the chat session is backed by an IM channel; empty for a web-only chat. Makes the agent channel-aware (read history from the channel, not Multica)
ChatMessage string `json:"chat_message,omitempty"` // user message for chat tasks
ChatMessageAttachments []ChatAttachmentMeta `json:"chat_message_attachments,omitempty"` // attachments on the user message — agent calls `multica attachment download <id>` per entry
AutopilotRunID string `json:"autopilot_run_id,omitempty"` // non-empty for autopilot-spawned tasks

View File

@@ -0,0 +1,183 @@
package handler
import (
"context"
"errors"
"log/slog"
"net/http"
"strconv"
"github.com/jackc/pgx/v5/pgtype"
"github.com/multica-ai/multica/server/internal/integrations/channel"
"github.com/multica-ai/multica/server/internal/integrations/slack"
"github.com/multica-ai/multica/server/internal/logger"
"github.com/multica-ai/multica/server/internal/util"
)
// ChatChannelHistoryReader reads a chat session's bound IM-channel history. The
// Slack reader (slack.History) satisfies it; a future platform registers its
// own. Defined here as a narrow interface so the handler stays testable and so
// the channel-agnostic contract — one shape regardless of platform — is enforced
// at the boundary (MUL-3871).
type ChatChannelHistoryReader interface {
Fetch(ctx context.Context, chatSessionID pgtype.UUID, opts channel.HistoryOptions) (channel.HistoryPage, error)
}
// ChatChannelHistoryResponse is the unified `multica chat history` payload. It
// is the SAME shape no matter which channel backs the session — the agent never
// sees a per-platform API.
type ChatChannelHistoryResponse struct {
ChannelType string `json:"channel_type"`
Scope channel.HistoryScope `json:"scope,omitempty"`
Messages []channel.HistoryMessage `json:"messages"`
NextCursor string `json:"next_cursor,omitempty"`
// Note carries a human-readable explanation when there is no history to
// read (e.g. the session is not connected to a chat channel), so the agent
// gets a clear answer instead of a bare empty list.
Note string `json:"note,omitempty"`
}
// GetChatChannelHistory serves the agent-facing `multica chat history` command.
// It is authorized by the task-scoped token alone: middleware stamps the token's
// task into X-Task-ID (the client cannot forge it), and the endpoint reads the
// history of THAT task's chat session — so an agent can only ever read the
// conversation it is currently running for, never an arbitrary session/channel.
func (h *Handler) GetChatChannelHistory(w http.ResponseWriter, r *http.Request) {
// X-Actor-Source is server-set only: the Auth middleware deletes any
// client-supplied value and re-stamps "task_token" ONLY on the mat_ task
// token branch (along with the authoritative X-Task-ID). A normal JWT / mul_
// PAT request leaves it empty and does NOT strip a client-forged X-Task-ID,
// so this gate is load-bearing: without it a member could forge X-Task-ID and
// read another session's channel history. Require the task-token actor here,
// THEN trust X-Task-ID.
if r.Header.Get("X-Actor-Source") != "task_token" {
writeError(w, http.StatusForbidden, "chat history is only available from within an agent task")
return
}
taskIDHeader := r.Header.Get("X-Task-ID")
if taskIDHeader == "" {
writeError(w, http.StatusBadRequest, "missing task context")
return
}
taskUUID, err := util.ParseUUID(taskIDHeader)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid task id")
return
}
task, err := h.Queries.GetAgentTask(r.Context(), taskUUID)
if err != nil {
writeError(w, http.StatusNotFound, "task not found")
return
}
if !task.ChatSessionID.Valid {
writeError(w, http.StatusBadRequest, "this task is not a chat task")
return
}
// Defense in depth: load the session and confirm it lives in the token's
// stamped workspace. The token→task binding already guarantees the agent can
// only reach its own task here; this makes a future wiring regression fail
// closed instead of leaking another workspace's conversation.
session, err := h.Queries.GetChatSession(r.Context(), task.ChatSessionID)
if err != nil {
writeError(w, http.StatusNotFound, "chat session not found")
return
}
if ws := ctxWorkspaceID(r.Context()); ws != "" && uuidToString(session.WorkspaceID) != ws {
writeError(w, http.StatusForbidden, "chat session does not belong to this workspace")
return
}
scope := parseHistoryScope(r.URL.Query().Get("scope"))
if scope == channel.HistoryScopeAuto {
// First turn — the bot has not replied yet, so no thread exists — reads
// the surrounding channel (where the prior context lives). A follow-up
// reads the agent's own thread. The agent can override with
// ?scope=channel|thread.
if h.chatSessionHasBotReply(r.Context(), task.ChatSessionID) {
scope = channel.HistoryScopeThread
} else {
scope = channel.HistoryScopeChannel
}
}
opts := channel.HistoryOptions{
Scope: scope,
Limit: parseHistoryLimit(r.URL.Query().Get("limit")),
Before: r.URL.Query().Get("before"),
}
empty := ChatChannelHistoryResponse{Messages: []channel.HistoryMessage{}}
if h.SlackHistory == nil {
empty.Note = "No chat channel integration is configured on this server."
writeJSON(w, http.StatusOK, empty)
return
}
page, err := h.SlackHistory.Fetch(r.Context(), task.ChatSessionID, opts)
if err != nil {
if errors.Is(err, slack.ErrNoSlackSession) {
empty.Note = "This conversation is not connected to a chat channel, so there is no prior channel history to read."
writeJSON(w, http.StatusOK, empty)
return
}
slog.Error("chat channel history fetch failed", append(logger.RequestAttrs(r),
"error", err, "chat_session_id", uuidToString(task.ChatSessionID))...)
writeError(w, http.StatusBadGateway, "failed to read channel history")
return
}
messages := page.Messages
if messages == nil {
messages = []channel.HistoryMessage{}
}
writeJSON(w, http.StatusOK, ChatChannelHistoryResponse{
ChannelType: page.ChannelType,
Scope: page.Scope,
Messages: messages,
NextCursor: page.NextCursor,
})
}
// parseHistoryScope maps the ?scope query value to a HistoryScope, defaulting to
// auto for empty / unknown values.
func parseHistoryScope(raw string) channel.HistoryScope {
switch channel.HistoryScope(raw) {
case channel.HistoryScopeThread:
return channel.HistoryScopeThread
case channel.HistoryScopeChannel:
return channel.HistoryScopeChannel
default:
return channel.HistoryScopeAuto
}
}
// chatSessionHasBotReply reports whether the bot has already replied in this
// session — i.e. this is a follow-up, not the first turn. On Slack the bot's
// first reply opens the thread, so an existing assistant message is the signal
// that a thread worth reading exists. Best-effort: a query error defaults to
// false (treat as first turn → channel), the safe, context-rich choice.
func (h *Handler) chatSessionHasBotReply(ctx context.Context, sessionID pgtype.UUID) bool {
msgs, err := h.Queries.ListChatMessages(ctx, sessionID)
if err != nil {
return false
}
for _, m := range msgs {
if m.Role == "assistant" {
return true
}
}
return false
}
// parseHistoryLimit reads the ?limit query param, ignoring junk (the reader
// clamps the range). 0 means "use the reader's default".
func parseHistoryLimit(raw string) int {
if raw == "" {
return 0
}
n, err := strconv.Atoi(raw)
if err != nil || n < 0 {
return 0
}
return n
}

View File

@@ -0,0 +1,275 @@
package handler
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/jackc/pgx/v5/pgtype"
"github.com/multica-ai/multica/server/internal/integrations/channel"
"github.com/multica-ai/multica/server/internal/integrations/slack"
)
type fakeChatHistoryReader struct {
page channel.HistoryPage
err error
gotSession pgtype.UUID
gotOpts channel.HistoryOptions
}
func (f *fakeChatHistoryReader) Fetch(_ context.Context, sid pgtype.UUID, opts channel.HistoryOptions) (channel.HistoryPage, error) {
f.gotSession = sid
f.gotOpts = opts
return f.page, f.err
}
// newChatHistoryTask inserts a chat task bound to a fresh chat session and
// returns the task id and (for chat tasks) the session id. With
// chatSession=false it inserts a non-chat task and an empty session id.
func newChatHistoryTask(t *testing.T, chatSession bool) (taskID, sessionID string) {
t.Helper()
agentID := createHandlerTestAgent(t, "ChatHistoryAgent", []byte("[]"))
runtimeID := handlerTestRuntimeID(t)
var sessionArg any
if chatSession {
sessionID = createHandlerTestChatSession(t, agentID)
sessionArg = sessionID
}
if err := testPool.QueryRow(context.Background(), `
INSERT INTO agent_task_queue (agent_id, runtime_id, status, priority, chat_session_id)
VALUES ($1, $2, 'completed', 0, $3)
RETURNING id
`, agentID, runtimeID, sessionArg).Scan(&taskID); err != nil {
t.Fatalf("insert chat history task: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM agent_task_queue WHERE id = $1`, taskID)
})
return taskID, sessionID
}
// addAssistantMessage records a prior bot reply in the session, so the endpoint
// classifies the next read as a follow-up. The chat_session cleanup cascades to
// chat_message, so no separate cleanup is needed.
func addAssistantMessage(t *testing.T, sessionID string) {
t.Helper()
if _, err := testPool.Exec(context.Background(),
`INSERT INTO chat_message (chat_session_id, role, content) VALUES ($1, 'assistant', 'prior reply')`,
sessionID); err != nil {
t.Fatalf("insert assistant message: %v", err)
}
}
// taskActorRequest builds a /api/chat/history request as the Auth middleware
// would leave it for a mat_ task token: the server-set X-Actor-Source=task_token
// plus the authoritative X-Task-ID.
func taskActorRequest(taskID string) *http.Request {
req := newRequest("GET", "/api/chat/history", nil)
req.Header.Set("X-Actor-Source", "task_token")
req.Header.Set("X-Task-ID", taskID)
return req
}
func withSlackHistory(t *testing.T, r ChatChannelHistoryReader) {
t.Helper()
orig := testHandler.SlackHistory
testHandler.SlackHistory = r
t.Cleanup(func() { testHandler.SlackHistory = orig })
}
func TestGetChatChannelHistory_Success(t *testing.T) {
if testHandler == nil {
t.Skip("requires test database")
}
taskID, _ := newChatHistoryTask(t, true)
fake := &fakeChatHistoryReader{page: channel.HistoryPage{
ChannelType: "slack",
Messages: []channel.HistoryMessage{
{ID: "100", Author: "Alice", Role: channel.HistoryRoleUser, Text: "alert", TS: "100"},
{ID: "101", Author: "Bot", Role: channel.HistoryRoleAssistant, Text: "on it", TS: "101"},
},
NextCursor: "100",
}}
withSlackHistory(t, fake)
req := taskActorRequest(taskID)
req.URL.RawQuery = "limit=10"
w := httptest.NewRecorder()
testHandler.GetChatChannelHistory(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200: %s", w.Code, w.Body.String())
}
var resp ChatChannelHistoryResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.ChannelType != "slack" || len(resp.Messages) != 2 || resp.NextCursor != "100" {
t.Fatalf("unexpected response: %+v", resp)
}
if !fake.gotSession.Valid {
t.Errorf("reader was not called with a session id")
}
}
func TestGetChatChannelHistory_NoBindingReturnsNote(t *testing.T) {
if testHandler == nil {
t.Skip("requires test database")
}
taskID, _ := newChatHistoryTask(t, true)
withSlackHistory(t, &fakeChatHistoryReader{err: slack.ErrNoSlackSession})
w := httptest.NewRecorder()
testHandler.GetChatChannelHistory(w, taskActorRequest(taskID))
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200: %s", w.Code, w.Body.String())
}
var resp ChatChannelHistoryResponse
_ = json.Unmarshal(w.Body.Bytes(), &resp)
if resp.Note == "" || len(resp.Messages) != 0 {
t.Fatalf("expected empty messages + a note, got %+v", resp)
}
}
func TestGetChatChannelHistory_NilReaderReturnsNote(t *testing.T) {
if testHandler == nil {
t.Skip("requires test database")
}
taskID, _ := newChatHistoryTask(t, true)
withSlackHistory(t, nil)
w := httptest.NewRecorder()
testHandler.GetChatChannelHistory(w, taskActorRequest(taskID))
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200: %s", w.Code, w.Body.String())
}
var resp ChatChannelHistoryResponse
_ = json.Unmarshal(w.Body.Bytes(), &resp)
if resp.Note == "" {
t.Fatalf("expected a note when no reader configured, got %+v", resp)
}
}
// TestGetChatChannelHistory_RejectsForgedTaskID is the security regression test
// for Niko's must-fix: a normal request (no server-set X-Actor-Source) that
// forges X-Task-ID — exactly what a workspace member could do with a JWT / mul_
// PAT, since the Auth middleware does NOT strip a client-sent X-Task-ID — must be
// rejected, never served another session's history.
func TestGetChatChannelHistory_RejectsForgedTaskID(t *testing.T) {
if testHandler == nil {
t.Skip("requires test database")
}
taskID, _ := newChatHistoryTask(t, true)
fake := &fakeChatHistoryReader{page: channel.HistoryPage{ChannelType: "slack"}}
withSlackHistory(t, fake)
req := newRequest("GET", "/api/chat/history", nil)
req.Header.Set("X-Task-ID", taskID) // forged: no X-Actor-Source=task_token
w := httptest.NewRecorder()
testHandler.GetChatChannelHistory(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403", w.Code)
}
if fake.gotSession.Valid {
t.Fatalf("reader must not be called for a forged X-Task-ID")
}
}
func TestGetChatChannelHistory_MissingTaskHeader(t *testing.T) {
if testHandler == nil {
t.Skip("requires test database")
}
// Task-token actor source but no X-Task-ID: a defensive 400 (the mat_ branch
// always stamps both, so this should not happen in practice).
req := newRequest("GET", "/api/chat/history", nil)
req.Header.Set("X-Actor-Source", "task_token")
w := httptest.NewRecorder()
testHandler.GetChatChannelHistory(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", w.Code)
}
}
func TestGetChatChannelHistory_NonChatTask(t *testing.T) {
if testHandler == nil {
t.Skip("requires test database")
}
taskID, _ := newChatHistoryTask(t, false) // task with no chat_session_id
withSlackHistory(t, &fakeChatHistoryReader{})
w := httptest.NewRecorder()
testHandler.GetChatChannelHistory(w, taskActorRequest(taskID))
if w.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400: %s", w.Code, w.Body.String())
}
}
// TestGetChatChannelHistory_AutoFirstTurnReadsChannel: with no prior bot reply,
// scope=auto resolves to channel (the surrounding context before the thread).
func TestGetChatChannelHistory_AutoFirstTurnReadsChannel(t *testing.T) {
if testHandler == nil {
t.Skip("requires test database")
}
taskID, _ := newChatHistoryTask(t, true) // no assistant message => first turn
fake := &fakeChatHistoryReader{page: channel.HistoryPage{ChannelType: "slack", Scope: channel.HistoryScopeChannel}}
withSlackHistory(t, fake)
w := httptest.NewRecorder()
testHandler.GetChatChannelHistory(w, taskActorRequest(taskID)) // no ?scope => auto
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200: %s", w.Code, w.Body.String())
}
if fake.gotOpts.Scope != channel.HistoryScopeChannel {
t.Fatalf("auto first-turn scope = %q, want channel", fake.gotOpts.Scope)
}
}
// TestGetChatChannelHistory_AutoFollowUpReadsThread: once the bot has replied,
// scope=auto resolves to thread.
func TestGetChatChannelHistory_AutoFollowUpReadsThread(t *testing.T) {
if testHandler == nil {
t.Skip("requires test database")
}
taskID, sessionID := newChatHistoryTask(t, true)
addAssistantMessage(t, sessionID) // bot already replied => follow-up
fake := &fakeChatHistoryReader{page: channel.HistoryPage{ChannelType: "slack", Scope: channel.HistoryScopeThread}}
withSlackHistory(t, fake)
w := httptest.NewRecorder()
testHandler.GetChatChannelHistory(w, taskActorRequest(taskID))
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200: %s", w.Code, w.Body.String())
}
if fake.gotOpts.Scope != channel.HistoryScopeThread {
t.Fatalf("auto follow-up scope = %q, want thread", fake.gotOpts.Scope)
}
}
// TestGetChatChannelHistory_ExplicitChannelScope: ?scope=channel overrides the
// auto default even on a follow-up.
func TestGetChatChannelHistory_ExplicitChannelScope(t *testing.T) {
if testHandler == nil {
t.Skip("requires test database")
}
taskID, sessionID := newChatHistoryTask(t, true)
addAssistantMessage(t, sessionID) // follow-up, but explicit override below
fake := &fakeChatHistoryReader{page: channel.HistoryPage{ChannelType: "slack", Scope: channel.HistoryScopeChannel}}
withSlackHistory(t, fake)
req := taskActorRequest(taskID)
req.URL.RawQuery = "scope=channel"
w := httptest.NewRecorder()
testHandler.GetChatChannelHistory(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200: %s", w.Code, w.Body.String())
}
if fake.gotOpts.Scope != channel.HistoryScopeChannel {
t.Fatalf("explicit scope = %q, want channel", fake.gotOpts.Scope)
}
}

View File

@@ -1455,6 +1455,21 @@ func (h *Handler) computeCommentAgentTriggers(ctx context.Context, issue db.Issu
}
if actorType != "member" {
// Agent-authored comments do not participate in the member-driven
// conversation routing (parent-author / thread-root continuation) or
// the member assignee fallback. They retain one narrow path restored
// after MUL-3794 (MUL-3879): a worker-agent result comment on a
// squad-assigned issue can still wake the assigned squad leader, so
// the leader→worker→leader coordination loop stays closed. The leader
// self-trigger guard (lastTaskWasLeader) lives in
// routeAssignedSquadLeaderFallback. Explicit @agent / @squad mentions
// are already handled above, so this never double-enqueues a mentioned
// target alongside the assigned leader.
if issue.AssigneeType.Valid && issue.AssigneeType.String == "squad" {
if trigger, ok := h.routeAssignedSquadLeaderFallback(ctx, issue, actorType, actorID, opts); ok {
return []commentAgentTrigger{trigger}
}
}
return nil
}

View File

@@ -20,6 +20,7 @@ import (
"github.com/multica-ai/multica/server/internal/analytics"
"github.com/multica-ai/multica/server/internal/auth"
"github.com/multica-ai/multica/server/internal/daemonws"
"github.com/multica-ai/multica/server/internal/integrations/slack"
obsmetrics "github.com/multica-ai/multica/server/internal/metrics"
"github.com/multica-ai/multica/server/internal/middleware"
"github.com/multica-ai/multica/server/internal/service"
@@ -1594,6 +1595,16 @@ func (h *Handler) ClaimTaskByRuntime(w http.ResponseWriter, r *http.Request) {
resp.WorkspaceID = uuidToString(cs.WorkspaceID)
resp.ChatSessionID = uuidToString(cs.ID)
resp.ThreadName = cs.Title
// Flag a channel-backed session so the daemon makes the agent aware
// it is operating inside Slack — read this conversation's history
// from the channel via `multica chat history`, not from Multica
// (MUL-3871). Empty for a web-only chat session.
if _, berr := h.Queries.GetChannelChatSessionBindingBySession(r.Context(), db.GetChannelChatSessionBindingBySessionParams{
ChatSessionID: cs.ID,
ChannelType: string(slack.TypeSlack),
}); berr == nil {
resp.ChatChannelType = string(slack.TypeSlack)
}
if ws, err := h.Queries.GetWorkspace(r.Context(), cs.WorkspaceID); err == nil && ws.Repos != nil {
var repos []RepoData
if json.Unmarshal(ws.Repos, &repos) == nil && len(repos) > 0 {
@@ -2331,6 +2342,24 @@ func (h *Handler) ReportTaskUsage(w http.ResponseWriter, r *http.Request) {
continue
}
h.TaskService.CaptureTaskUsage(r.Context(), task, provider, u.Model, u.InputTokens, u.OutputTokens, u.CacheReadTokens, u.CacheWriteTokens)
// Surface prompt-cache effectiveness per run so cache hit rates are
// observable in logs, not just queryable from runtime_usage. The ratio
// is cached input over total input-side tokens; a persistently low
// value flags a prompt prefix that is not being reused across runs
// (e.g. volatile values poisoning the cacheable prefix). MUL-3887.
if totalInput := u.InputTokens + u.CacheReadTokens + u.CacheWriteTokens; totalInput > 0 {
slog.Info("task prompt-cache usage",
"task_id", taskID,
"provider", provider,
"model", u.Model,
"input_tokens", u.InputTokens,
"output_tokens", u.OutputTokens,
"cache_read_tokens", u.CacheReadTokens,
"cache_write_tokens", u.CacheWriteTokens,
"cache_read_ratio", float64(u.CacheReadTokens)/float64(totalInput),
)
}
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})

View File

@@ -185,7 +185,12 @@ type Handler struct {
// "link your Slack account" prompt (MUL-3666). Nil unless Slack is
// configured (MULTICA_SLACK_SECRET_KEY set).
SlackBindingTokens *slack.BindingTokenService
cfg Config
// SlackHistory backs the agent-facing `multica chat history` command: it
// reads a chat session's bound Slack conversation on demand (MUL-3871). Nil
// unless Slack is configured; GetChatChannelHistory then reports "no channel
// integration". A future platform satisfies the same reader interface.
SlackHistory ChatChannelHistoryReader
cfg Config
}
func New(queries *db.Queries, txStarter txStarter, hub *realtime.Hub, bus *events.Bus, emailService *service.EmailService, store storage.Storage, cfSigner *auth.CloudFrontSigner, analyticsClient analytics.Client, cfg Config, daemonHubs ...*daemonws.Hub) *Handler {

View File

@@ -1918,6 +1918,14 @@ func (h *Handler) ImportSkill(w http.ResponseWriter, r *http.Request) {
}
creatorUUID := parseUUID(creatorID)
// An uploaded skill archive (.skill / .zip) arrives as multipart/form-data;
// a hosted-URL import arrives as JSON. Both converge on the same create +
// conflict tail via finishSkillImport.
if isMultipartForm(r) {
h.importSkillFromArchive(w, r, workspaceID, workspaceUUID, creatorUUID, creatorID)
return
}
var req ImportSkillRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
@@ -1955,6 +1963,15 @@ func (h *Handler) ImportSkill(w http.ResponseWriter, r *http.Request) {
return
}
h.finishSkillImport(w, r, workspaceID, workspaceUUID, creatorUUID, creatorID, strategy, structuredResult, imported)
}
// finishSkillImport runs the shared tail of every skill import — whether the
// bundle came from a hosted URL or an uploaded archive (.skill / .zip). It maps
// the extracted files onto CreateSkillFileRequest, records provenance into
// config.origin, and creates the skill, routing same-name collisions through
// the on_conflict strategy.
func (h *Handler) finishSkillImport(w http.ResponseWriter, r *http.Request, workspaceID string, workspaceUUID, creatorUUID pgtype.UUID, creatorID, strategy string, structuredResult bool, imported *importedSkill) {
files := make([]CreateSkillFileRequest, 0, len(imported.files))
for _, f := range imported.files {
if !validateFilePath(f.path) {

View File

@@ -0,0 +1,255 @@
package handler
import (
"archive/zip"
"bytes"
"fmt"
"io"
"net/http"
"path"
"sort"
"strings"
"github.com/jackc/pgx/v5/pgtype"
skillpkg "github.com/multica-ai/multica/server/internal/skill"
)
// maxImportArchiveUploadSize bounds the compressed upload accepted by the
// archive import path. The decompressed bundle is still held to the existing
// per-file / total / file-count caps (maxImportFileSize, maxImportTotalSize,
// maxImportFileCount); this outer cap just stops a client from streaming an
// unbounded compressed body before those decompression limits can apply.
const maxImportArchiveUploadSize = 16 << 20 // 16 MiB
// isMultipartForm reports whether the request carries a multipart/form-data
// body (an uploaded skill archive) rather than the JSON URL-import body.
func isMultipartForm(r *http.Request) bool {
return strings.HasPrefix(strings.ToLower(r.Header.Get("Content-Type")), "multipart/form-data")
}
// importSkillFromArchive handles POST /api/skills/import when the body is an
// uploaded skill archive (.skill / .zip). It reads the file plus the optional
// on_conflict form field, decompresses the archive into an importedSkill, and
// hands off to the shared finishSkillImport tail. The archive path always
// produces structured (status / skill / existing_skill) results — there is no
// legacy pre-on_conflict client for it to stay compatible with.
func (h *Handler) importSkillFromArchive(w http.ResponseWriter, r *http.Request, workspaceID string, workspaceUUID, creatorUUID pgtype.UUID, creatorID string) {
r.Body = http.MaxBytesReader(w, r.Body, maxImportArchiveUploadSize)
if err := r.ParseMultipartForm(maxImportArchiveUploadSize); err != nil {
writeError(w, http.StatusBadRequest, "invalid multipart upload or file exceeds the size limit")
return
}
defer func() {
if r.MultipartForm != nil {
_ = r.MultipartForm.RemoveAll()
}
}()
onConflict := r.FormValue("on_conflict")
if !validImportOnConflict(onConflict) {
writeError(w, http.StatusBadRequest, "on_conflict must be one of: fail, overwrite, rename, skip")
return
}
strategy := onConflict
if strategy == "" {
strategy = importOnConflictFail
}
file, header, err := r.FormFile("file")
if err != nil {
writeError(w, http.StatusBadRequest, `a skill archive file is required (form field "file")`)
return
}
defer file.Close()
data, err := io.ReadAll(file)
if err != nil {
writeError(w, http.StatusBadRequest, "failed to read uploaded file")
return
}
filename := ""
if header != nil {
filename = header.Filename
}
imported, err := parseSkillArchive(data, filename)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
h.finishSkillImport(w, r, workspaceID, workspaceUUID, creatorUUID, creatorID, strategy, true, imported)
}
// parseSkillArchive decompresses an uploaded skill archive (.skill / .zip) into
// an importedSkill. A .skill file is a standard zip whose entries sit either at
// the archive root (SKILL.md, scripts/...) or nested under a single top-level
// directory (my-skill/SKILL.md, my-skill/scripts/...) — the layout produced by
// Anthropic's package_skill. Both are accepted by rooting on the shallowest
// SKILL.md found.
//
// Safety: every entry is validated against traversal / absolute paths
// (zip-slip), the reserved SKILL.md supporting path is dropped, per-file size is
// bounded while reading (so a lying zip header can't blow up memory), and the
// shared addFile enforces the per-bundle byte and file-count caps.
func parseSkillArchive(data []byte, filename string) (*importedSkill, error) {
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
return nil, fmt.Errorf("uploaded file is not a valid .skill/.zip archive")
}
// Locate the skill root: the directory of the shallowest SKILL.md. This
// accepts both a root-level SKILL.md and the common single-wrapper layout.
// The candidate path is validated up front (absolute / traversal entries are
// rejected) so a malicious archive cannot smuggle an unsafe path in as the
// primary content — keeping every accepted entry zip-slip-safe.
var skillMd *zip.File
rootPrefix := ""
for _, f := range zr.File {
if f.FileInfo().IsDir() {
continue
}
clean := path.Clean(f.Name)
if !strings.EqualFold(path.Base(clean), skillpkg.ContentFilename) {
continue
}
if !validateFilePath(clean) {
continue
}
prefix := archiveEntryPrefix(clean)
if skillMd == nil || len(prefix) < len(rootPrefix) {
skillMd = f
rootPrefix = prefix
}
}
if skillMd == nil {
return nil, fmt.Errorf("archive does not contain a SKILL.md")
}
content, err := readZipFile(skillMd, maxImportFileSize)
if err != nil {
return nil, fmt.Errorf("read SKILL.md: %w", err)
}
name, description := skillpkg.ParseSkillFrontmatter(content)
if name == "" {
name = skillNameFromArchive(rootPrefix, filename)
}
if name == "" {
return nil, fmt.Errorf("could not determine the skill name: SKILL.md has no name field and the archive is unnamed")
}
imported := &importedSkill{
name: name,
description: description,
content: content,
}
for _, f := range zr.File {
if f.FileInfo().IsDir() {
continue
}
clean := path.Clean(f.Name)
// Only files under the resolved skill root belong to this skill.
if rootPrefix != "" && !strings.HasPrefix(clean, rootPrefix) {
continue
}
rel := strings.TrimPrefix(clean, rootPrefix)
if rel == "" {
continue
}
// A SKILL.md at any depth is never a supporting file: the top-level one
// is the primary content, and a nested one would collide with the
// reserved primary-content name. Mirrors the daemon's local-skill rule.
if strings.EqualFold(path.Base(rel), skillpkg.ContentFilename) {
continue
}
if isIgnoredArchiveEntry(rel) {
continue
}
// zip-slip / absolute-path guard.
if !validateFilePath(rel) {
continue
}
fileContent, ferr := readZipFile(f, maxImportFileSize)
if ferr != nil {
// An oversize or unreadable individual asset is skipped rather than
// failing the whole import, matching the local-runtime importer.
continue
}
// addFile enforces the per-bundle caps and drops binary assets; a cap
// breach aborts the import instead of silently truncating it.
if err := imported.addFile(rel, fileContent); err != nil {
return nil, err
}
}
sort.Slice(imported.files, func(i, j int) bool {
return imported.files[i].path < imported.files[j].path
})
return imported, nil
}
// archiveEntryPrefix returns the directory prefix (with trailing slash) of a
// cleaned, slash-delimited archive entry: "" for a root entry, "my-skill/" for
// "my-skill/SKILL.md".
func archiveEntryPrefix(cleanName string) string {
dir := path.Dir(cleanName)
if dir == "." || dir == "/" {
return ""
}
return dir + "/"
}
// skillNameFromArchive derives a fallback skill name when SKILL.md carries no
// name field: the wrapper directory name if the skill is nested, else the
// uploaded filename without its extension.
func skillNameFromArchive(rootPrefix, filename string) string {
if rootPrefix != "" {
base := path.Base(strings.TrimSuffix(rootPrefix, "/"))
if base != "." && base != "/" && base != ".." {
return base
}
}
clean := strings.ReplaceAll(filename, "\\", "/")
base := path.Base(clean)
if ext := path.Ext(base); ext != "" {
base = strings.TrimSuffix(base, ext)
}
return strings.TrimSpace(base)
}
// isIgnoredArchiveEntry filters editor/OS noise and license files out of the
// supporting bundle, mirroring the daemon's local-skill discovery rules.
func isIgnoredArchiveEntry(rel string) bool {
for _, seg := range strings.Split(rel, "/") {
if seg == "" || seg == "__MACOSX" || strings.HasPrefix(seg, ".") {
return true
}
}
switch strings.ToLower(path.Base(rel)) {
case "license", "license.md", "license.txt":
return true
}
return false
}
// readZipFile reads a single zip entry, capping the read at maxSize+1 bytes so a
// header that under-reports its uncompressed size cannot force an unbounded
// allocation. Entries larger than maxSize are rejected.
func readZipFile(f *zip.File, maxSize int64) (string, error) {
rc, err := f.Open()
if err != nil {
return "", err
}
defer rc.Close()
data, err := io.ReadAll(io.LimitReader(rc, maxSize+1))
if err != nil {
return "", err
}
if int64(len(data)) > maxSize {
return "", fmt.Errorf("file %q exceeds %d bytes", f.Name, maxSize)
}
return string(data), nil
}

View File

@@ -0,0 +1,312 @@
package handler
import (
"archive/zip"
"bytes"
"context"
"encoding/json"
"mime/multipart"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// buildTestZip packs the given path->content map into an in-memory zip and
// returns its bytes. A path ending in "/" is written as a directory entry.
func buildTestZip(t *testing.T, entries map[string]string) []byte {
t.Helper()
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
for name, content := range entries {
if strings.HasSuffix(name, "/") {
if _, err := zw.Create(name); err != nil {
t.Fatalf("create dir entry %q: %v", name, err)
}
continue
}
w, err := zw.Create(name)
if err != nil {
t.Fatalf("create entry %q: %v", name, err)
}
if _, err := w.Write([]byte(content)); err != nil {
t.Fatalf("write entry %q: %v", name, err)
}
}
if err := zw.Close(); err != nil {
t.Fatalf("close zip: %v", err)
}
return buf.Bytes()
}
func filePaths(imported *importedSkill) []string {
paths := make([]string, 0, len(imported.files))
for _, f := range imported.files {
paths = append(paths, f.path)
}
return paths
}
func fileContent(imported *importedSkill, path string) (string, bool) {
for _, f := range imported.files {
if f.path == path {
return f.content, true
}
}
return "", false
}
const testSkillMd = `---
name: review-helper
description: Reviews code changes
---
# Review Helper
Do the review.
`
func TestParseSkillArchive_NestedWrapper(t *testing.T) {
data := buildTestZip(t, map[string]string{
"review-helper/": "",
"review-helper/SKILL.md": testSkillMd,
"review-helper/scripts/run.sh": "echo hi",
"review-helper/references/g.md": "guide",
})
imported, err := parseSkillArchive(data, "review-helper.skill")
if err != nil {
t.Fatalf("parseSkillArchive: %v", err)
}
if imported.name != "review-helper" {
t.Errorf("name = %q, want review-helper", imported.name)
}
if imported.description != "Reviews code changes" {
t.Errorf("description = %q", imported.description)
}
if !strings.Contains(imported.content, "# Review Helper") {
t.Errorf("content missing SKILL.md body: %q", imported.content)
}
got := filePaths(imported)
want := map[string]bool{"scripts/run.sh": true, "references/g.md": true}
if len(got) != len(want) {
t.Fatalf("files = %v, want keys %v", got, want)
}
for _, p := range got {
if !want[p] {
t.Errorf("unexpected file %q (SKILL.md must not be a supporting file)", p)
}
}
if c, ok := fileContent(imported, "scripts/run.sh"); !ok || c != "echo hi" {
t.Errorf("scripts/run.sh content = %q, ok=%v", c, ok)
}
}
func TestParseSkillArchive_RootLayout(t *testing.T) {
data := buildTestZip(t, map[string]string{
"SKILL.md": testSkillMd,
"references/doc.md": "doc",
})
imported, err := parseSkillArchive(data, "anything.zip")
if err != nil {
t.Fatalf("parseSkillArchive: %v", err)
}
if imported.name != "review-helper" {
t.Errorf("name = %q, want review-helper", imported.name)
}
if got := filePaths(imported); len(got) != 1 || got[0] != "references/doc.md" {
t.Errorf("files = %v, want [references/doc.md]", got)
}
}
func TestParseSkillArchive_NoSkillMd(t *testing.T) {
data := buildTestZip(t, map[string]string{
"my-skill/notes.md": "hello",
})
if _, err := parseSkillArchive(data, "x.skill"); err == nil {
t.Fatal("expected error when archive has no SKILL.md")
}
}
func TestParseSkillArchive_InvalidZip(t *testing.T) {
if _, err := parseSkillArchive([]byte("not a zip"), "x.skill"); err == nil {
t.Fatal("expected error for non-zip data")
}
}
func TestParseSkillArchive_RejectsUnsafeSkillMdPath(t *testing.T) {
// A SKILL.md whose only candidate path is absolute or traversal must not be
// accepted as the primary content; the archive is treated as having none.
for _, name := range []string{"../escape/SKILL.md", "/abs/SKILL.md"} {
data := buildTestZip(t, map[string]string{name: testSkillMd})
if _, err := parseSkillArchive(data, "x.skill"); err == nil {
t.Errorf("expected rejection for unsafe SKILL.md path %q", name)
}
}
}
func TestParseSkillArchive_DropsTraversalAndJunk(t *testing.T) {
data := buildTestZip(t, map[string]string{
"s/SKILL.md": testSkillMd,
"s/../evil.sh": "pwn", // zip-slip out of the skill root
"s/.git/config": "secret", // dotfile dir
"s/.DS_Store": "junk", // dotfile
"__MACOSX/s/._x": "applemeta", // mac noise (outside root anyway)
"s/LICENSE": "MIT", // license excluded
"s/keep.md": "real", // legitimate asset
})
imported, err := parseSkillArchive(data, "s.skill")
if err != nil {
t.Fatalf("parseSkillArchive: %v", err)
}
got := filePaths(imported)
if len(got) != 1 || got[0] != "keep.md" {
t.Fatalf("files = %v, want only [keep.md]", got)
}
}
func TestParseSkillArchive_SkipsBinaryAssets(t *testing.T) {
data := buildTestZip(t, map[string]string{
"s/SKILL.md": testSkillMd,
"s/logo.png": "\x89PNG\x00binary",
"s/note.txt": "text",
})
imported, err := parseSkillArchive(data, "s.skill")
if err != nil {
t.Fatalf("parseSkillArchive: %v", err)
}
if got := filePaths(imported); len(got) != 1 || got[0] != "note.txt" {
t.Errorf("files = %v, want [note.txt] (binary png dropped)", got)
}
}
func TestParseSkillArchive_NameFallbackToWrapperDir(t *testing.T) {
noName := "# Title only\n\nNo frontmatter name here.\n"
data := buildTestZip(t, map[string]string{
"cool-skill/SKILL.md": noName,
})
imported, err := parseSkillArchive(data, "ignored.skill")
if err != nil {
t.Fatalf("parseSkillArchive: %v", err)
}
if imported.name != "cool-skill" {
t.Errorf("name = %q, want cool-skill (wrapper dir fallback)", imported.name)
}
}
func TestParseSkillArchive_NameFallbackToFilename(t *testing.T) {
noName := "# Title only\n"
data := buildTestZip(t, map[string]string{
"SKILL.md": noName,
})
imported, err := parseSkillArchive(data, "My-Thing.skill")
if err != nil {
t.Fatalf("parseSkillArchive: %v", err)
}
if imported.name != "My-Thing" {
t.Errorf("name = %q, want My-Thing (filename fallback)", imported.name)
}
}
// --- Handler-level tests: the multipart /api/skills/import archive path ---
func skillMdWithName(name, desc string) string {
return "---\nname: " + name + "\ndescription: " + desc + "\n---\n\n# " + name + "\n\nBody.\n"
}
func newSkillArchiveImportRequest(userID string, archive []byte, filename, onConflict string) *http.Request {
var buf bytes.Buffer
writer := multipart.NewWriter(&buf)
part, _ := writer.CreateFormFile("file", filename)
_, _ = part.Write(archive)
if onConflict != "" {
_ = writer.WriteField("on_conflict", onConflict)
}
_ = writer.Close()
req := httptest.NewRequest(http.MethodPost, "/api/skills/import", &buf)
req.Header.Set("Content-Type", writer.FormDataContentType())
req.Header.Set("X-User-ID", userID)
req.Header.Set("X-Workspace-ID", testWorkspaceID)
return req
}
func TestImportSkill_ArchiveUploadCreatesSkill(t *testing.T) {
if testHandler == nil || testPool == nil {
t.Skip("handler test DB not configured")
}
name := "archive-create-" + t.Name()
archive := buildTestZip(t, map[string]string{
name + "/SKILL.md": skillMdWithName(name, "From archive"),
name + "/scripts/run.sh": "echo hi",
})
t.Cleanup(func() {
_, _ = testPool.Exec(context.Background(), `DELETE FROM skill WHERE workspace_id = $1 AND name = $2`, testWorkspaceID, name)
})
w := httptest.NewRecorder()
testHandler.ImportSkill(w, newSkillArchiveImportRequest(testUserID, archive, name+".skill", "fail"))
if w.Code != http.StatusCreated {
t.Fatalf("status = %d, want 201: %s", w.Code, w.Body.String())
}
var body SkillImportResult
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatalf("decode: %v", err)
}
if body.Status != "created" || body.Skill == nil {
t.Fatalf("body = %#v", body)
}
if body.Skill.Name != name {
t.Errorf("name = %q, want %q", body.Skill.Name, name)
}
found := false
for _, f := range body.Skill.Files {
if f.Path == "scripts/run.sh" {
found = true
}
}
if !found {
t.Errorf("scripts/run.sh missing from imported files: %#v", body.Skill.Files)
}
}
func TestImportSkill_ArchiveUploadConflictSkip(t *testing.T) {
if testHandler == nil || testPool == nil {
t.Skip("handler test DB not configured")
}
namePrefix := "archive-skip"
skillName := namePrefix + "-" + t.Name()
existingID := insertHandlerTestSkill(t, namePrefix, "# Existing")
archive := buildTestZip(t, map[string]string{
skillName + "/SKILL.md": skillMdWithName(skillName, "From archive"),
})
w := httptest.NewRecorder()
testHandler.ImportSkill(w, newSkillArchiveImportRequest(testUserID, archive, skillName+".skill", "skip"))
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200: %s", w.Code, w.Body.String())
}
var body SkillImportResult
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatalf("decode: %v", err)
}
if body.Status != "skipped" {
t.Fatalf("status = %q, want skipped", body.Status)
}
if body.ExistingSkill == nil || body.ExistingSkill.ID != existingID {
t.Fatalf("existing_skill = %#v", body.ExistingSkill)
}
}
func TestImportSkill_ArchiveUploadRejectsNonZip(t *testing.T) {
if testHandler == nil || testPool == nil {
t.Skip("handler test DB not configured")
}
w := httptest.NewRecorder()
testHandler.ImportSkill(w, newSkillArchiveImportRequest(testUserID, []byte("not a zip at all"), "bad.skill", "fail"))
if w.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400: %s", w.Code, w.Body.String())
}
}

View File

@@ -200,12 +200,12 @@ func TestShouldEnqueueSquadLeaderOnComment_SkipsWhenMemberMentionsAnyone(t *test
description: "@squad routes the issue to that squad's leader — current leader stays out",
},
{
name: "agent comment with @agent does not implicitly trigger leader",
name: "agent comment with @agent does not also trigger leader",
content: "delegating to [@Other](mention://agent/" + fx.OtherID + ")",
authorType: "agent",
authorID: fx.OtherID,
want: false,
description: "agent→agent routing now requires explicit mentions only; assignee fallback is member-authored only",
description: "explicit @agent routes only to the mentioned target; the assigned squad-leader fallback must not also fire (no double-enqueue)",
},
}
@@ -220,10 +220,13 @@ func TestShouldEnqueueSquadLeaderOnComment_SkipsWhenMemberMentionsAnyone(t *test
}
}
// TestShouldEnqueueSquadLeaderOnComment_AgentAuthoredCommentsDoNotFallback
// pins the cascade loop guard: agent-authored comments never reach the assignee
// fallback branch. Agent→agent work handoff must be explicit @mention.
func TestShouldEnqueueSquadLeaderOnComment_AgentAuthoredCommentsDoNotFallback(t *testing.T) {
// TestShouldEnqueueSquadLeaderOnComment_AgentAuthoredWorkerCommentsWakeLeader
// pins the MUL-3879 restored behavior in the new MUL-3794 cascade: an
// agent-authored worker-result comment on a squad-assigned issue wakes the
// assigned squad leader so the leader→worker→leader coordination loop stays
// closed, while the leader's own self-trigger loop stays suppressed via
// lastTaskWasLeader.
func TestShouldEnqueueSquadLeaderOnComment_AgentAuthoredWorkerCommentsWakeLeader(t *testing.T) {
if testHandler == nil || testPool == nil {
t.Skip("database not available")
}
@@ -240,7 +243,11 @@ func TestShouldEnqueueSquadLeaderOnComment_AgentAuthoredCommentsDoNotFallback(t
t.Fatalf("clear tasks: %v", err)
}
}
insertTask := func(isLeader bool, status string) {
// insertLeaderTask seeds a task for the leader agent so the
// lastTaskWasLeader guard can read the agent's most recent role on the
// issue. Separate Exec calls get distinct created_at values, so the last
// inserted row is the "latest" task.
insertLeaderTask := func(isLeader bool, status string) {
t.Helper()
var runtimeID string
if err := testPool.QueryRow(ctx, `SELECT runtime_id FROM agent WHERE id = $1`, fx.LeaderID).Scan(&runtimeID); err != nil {
@@ -254,35 +261,47 @@ func TestShouldEnqueueSquadLeaderOnComment_AgentAuthoredCommentsDoNotFallback(t
}
}
t.Run("no prior task still does not fallback", func(t *testing.T) {
// Case 1: a worker agent (not the leader) posts a result comment on the
// squad-assigned issue — the assigned leader must wake to coordinate.
t.Run("worker agent comment wakes squad leader", func(t *testing.T) {
clearTasks()
if got := shouldEnqueueSquadLeaderOnCommentForTest(ctx, fx.Issue, "noted", "agent", fx.LeaderID); got {
t.Fatalf("no prior task: expected no implicit leader fallback, got enqueue")
if got := shouldEnqueueSquadLeaderOnCommentForTest(ctx, fx.Issue, "pushed the fix, PR is up", "agent", fx.OtherID); !got {
t.Fatalf("worker agent comment: expected leader to wake, got skip")
}
})
t.Run("prior leader task still does not fallback", func(t *testing.T) {
// Case 2: a dual-role agent (leader of the squad, also runs worker tasks)
// posts while its latest task on the issue was a worker task — the leader
// role must still wake because the comment is a worker result, not a
// leader self-trigger.
t.Run("dual-role worker comment wakes leader when latest task is worker", func(t *testing.T) {
clearTasks()
insertTask(true, "completed")
if got := shouldEnqueueSquadLeaderOnCommentForTest(ctx, fx.Issue, "noted", "agent", fx.LeaderID); got {
t.Fatalf("after leader task: expected no implicit leader fallback, got enqueue")
insertLeaderTask(true, "completed") // older leader task
insertLeaderTask(false, "completed") // newer worker task → latest role is worker
if got := shouldEnqueueSquadLeaderOnCommentForTest(ctx, fx.Issue, "done with my worker slice", "agent", fx.LeaderID); !got {
t.Fatalf("dual-role worker comment: expected leader to wake, got skip")
}
})
t.Run("prior worker task still does not fallback", func(t *testing.T) {
// Case 3: the leader posts while its latest task was a leader task — this
// is a self-trigger loop and must stay suppressed.
t.Run("leader comment from latest leader task does not self-trigger", func(t *testing.T) {
clearTasks()
insertTask(false, "completed")
if got := shouldEnqueueSquadLeaderOnCommentForTest(ctx, fx.Issue, "result", "agent", fx.LeaderID); got {
t.Fatalf("after worker task: expected no implicit leader fallback, got enqueue")
insertLeaderTask(false, "completed") // older worker task
insertLeaderTask(true, "completed") // newer leader task → latest role is leader
if got := shouldEnqueueSquadLeaderOnCommentForTest(ctx, fx.Issue, "coordinating next steps", "agent", fx.LeaderID); got {
t.Fatalf("leader self-trigger: expected skip, got wake")
}
})
t.Run("most recent task does not change agent-authored fallback guard", func(t *testing.T) {
// Case 4: an agent-authored comment carrying an explicit @agent mention
// routes only to the mentioned target — the assigned squad leader must NOT
// also be enqueued via the fallback path (no double-enqueue).
t.Run("explicit mention does not double-enqueue assigned leader", func(t *testing.T) {
clearTasks()
insertTask(true, "completed") // older leader task
insertTask(false, "completed") // newer worker task
if got := shouldEnqueueSquadLeaderOnCommentForTest(ctx, fx.Issue, "result", "agent", fx.LeaderID); got {
t.Fatalf("latest task is worker: expected no implicit leader fallback, got enqueue")
content := "handing to [@Other](mention://agent/" + fx.OtherID + ")"
if got := shouldEnqueueSquadLeaderOnCommentForTest(ctx, fx.Issue, content, "agent", fx.LeaderID); got {
t.Fatalf("explicit mention: expected no assigned-leader fallback, got wake")
}
})
}
@@ -370,15 +389,16 @@ func TestCreateComment_SquadPlainReplyToMemberParentKeepsRootMentionOwner(t *tes
}
}
// TestCreateComment_DualRoleAgentWorkerCommentDoesNotImplicitlyWakeLeader pins
// the cascade's agent-authored loop guard. Scenario:
// TestCreateComment_DualRoleAgentWorkerCommentWakesLeader pins the MUL-3879
// restored coordination loop at the full-handler level. Scenario:
//
// - Agent L is the leader of squad S and also a worker assigned tasks on
// issues belonging to S.
// - L is woken in its worker role (is_leader_task=false) and posts a comment.
// - No leader fallback is enqueued; agent→agent handoff requires explicit
// mention under the routing cascade.
func TestCreateComment_DualRoleAgentWorkerCommentDoesNotImplicitlyWakeLeader(t *testing.T) {
// - Agent L is the leader of squad S and also runs worker tasks on issues
// belonging to S.
// - L is woken in its worker role (is_leader_task=false) and posts a result
// comment.
// - A leader-role task IS enqueued so the squad leader can coordinate the
// next step — the worker result must not silently strand the issue.
func TestCreateComment_DualRoleAgentWorkerCommentWakesLeader(t *testing.T) {
if testHandler == nil || testPool == nil {
t.Skip("database not available")
}
@@ -423,7 +443,7 @@ func TestCreateComment_DualRoleAgentWorkerCommentDoesNotImplicitlyWakeLeader(t *
t.Fatalf("CreateComment: expected 201, got %d: %s", w.Code, w.Body.String())
}
// No new leader-role task is enqueued implicitly.
// A new leader-role task is enqueued so the leader coordinates next steps.
var leaderTasks int
if err := testPool.QueryRow(ctx, `
SELECT count(*) FROM agent_task_queue
@@ -431,8 +451,8 @@ func TestCreateComment_DualRoleAgentWorkerCommentDoesNotImplicitlyWakeLeader(t *
`, issueID, fx.LeaderID).Scan(&leaderTasks); err != nil {
t.Fatalf("count leader tasks: %v", err)
}
if leaderTasks != 0 {
t.Fatalf("after worker comment from dual-role agent: expected 0 queued leader tasks, got %d", leaderTasks)
if leaderTasks != 1 {
t.Fatalf("after worker comment from dual-role agent: expected 1 queued leader task, got %d", leaderTasks)
}
}

View File

@@ -0,0 +1,100 @@
package channel
// This file defines the channel-agnostic vocabulary for ON-DEMAND history
// reads. Unlike the inbound push path (InboundMessage), history is PULLED by
// the agent through a single unified CLI (`multica chat history`): the agent
// asks for "the history of the conversation I'm in" and never sees a
// per-platform API. The server resolves the session's binding to a channel
// type and dispatches to that platform's reader, which returns these
// normalized shapes — so adding a platform is "implement a reader", and the
// agent-facing contract never changes (MUL-3871).
// HistoryRole is the normalized author kind of a fetched message, mirroring the
// chat_message.role domain the agent already reasons about.
type HistoryRole string
const (
// HistoryRoleUser is a human (or a third-party bot, e.g. an alerting bot)
// message — context the agent should read.
HistoryRoleUser HistoryRole = "user"
// HistoryRoleAssistant is one of THIS bot's own prior messages in the
// conversation.
HistoryRoleAssistant HistoryRole = "assistant"
)
// HistoryScope selects which slice of a conversation to read. A chat platform
// has two nested histories: the surrounding CHANNEL and the agent's own THREAD
// within it (on Slack the bot's first reply opens a thread on the @mention, so
// every engaged conversation has one). The agent's primary read on a follow-up
// is its thread; the wider channel is pulled only when needed. On the first
// turn there is no thread yet, so the channel is the relevant context.
type HistoryScope string
const (
// HistoryScopeAuto lets the server pick: the channel on the first turn (no
// thread exists yet), the thread on follow-ups. This is the default.
HistoryScopeAuto HistoryScope = "auto"
// HistoryScopeThread reads the agent's own thread (Slack
// conversations.replies). Falls back to the channel where the platform /
// conversation has no threads (e.g. a DM).
HistoryScopeThread HistoryScope = "thread"
// HistoryScopeChannel reads the surrounding channel (Slack
// conversations.history).
HistoryScopeChannel HistoryScope = "channel"
)
// HistoryMessage is one normalized message from a conversation's history. It is
// the same shape regardless of platform so the agent reads a uniform list,
// exactly like `multica issue comment list --output json`.
type HistoryMessage struct {
// ID is the platform message identifier (Slack ts, Feishu message_id).
ID string `json:"id"`
// Author is a human-readable display label for the sender ("Alice",
// "Bot", or a positional "User 2" fallback when the name is unresolved).
Author string `json:"author"`
// AuthorID is the platform-native sender id, when available. Empty for
// some platform/bot messages.
AuthorID string `json:"author_id,omitempty"`
// Role distinguishes the bot's own turns from everyone else's.
Role HistoryRole `json:"role"`
// Text is the message body, flattened to plain text by the adapter.
Text string `json:"text"`
// TS is the platform timestamp string, sortable lexicographically within a
// platform (Slack "1700000000.000100"). It doubles as the paging cursor.
TS string `json:"ts"`
}
// HistoryPage is one normalized page of history plus a cursor for paging
// further back. Messages are ordered OLDEST-FIRST so the transcript reads
// top-to-bottom like the chat does.
type HistoryPage struct {
// ChannelType is the platform the history came from ("slack"). Empty when
// the session is not bound to any channel (a web-only chat session).
ChannelType string `json:"channel_type,omitempty"`
// Scope is the scope actually read ("thread" or "channel") after resolving
// "auto" and any platform fallback (e.g. a DM has no thread). It lets the
// agent know what it got and decide whether to also pull the other scope.
Scope HistoryScope `json:"scope,omitempty"`
// Messages are the fetched messages, oldest-first.
Messages []HistoryMessage `json:"messages"`
// NextCursor, when non-empty, is an opaque cursor to pass as Before to
// page to OLDER messages. Empty means no older messages were available.
NextCursor string `json:"next_cursor,omitempty"`
}
// HistoryOptions tune a history read. They are platform-neutral; each reader
// maps them onto its own API's paging primitives.
type HistoryOptions struct {
// Scope selects thread vs channel. The handler resolves "auto" to a
// concrete scope before calling the reader (it knows whether this is a
// first turn or a follow-up); the reader still degrades "thread" to channel
// where the conversation has no thread. An empty value reads the channel.
Scope HistoryScope
// Limit caps how many messages to return. A reader clamps it to its
// platform's per-page maximum and applies a sane default for <= 0.
Limit int
// Before is an opaque cursor (a NextCursor from a prior page); the reader
// returns only messages strictly older than it. Empty starts at the most
// recent messages.
Before string
}

View File

@@ -0,0 +1,334 @@
package slack
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"sort"
"strconv"
"strings"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/slack-go/slack"
"github.com/multica-ai/multica/server/internal/integrations/channel"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
// ErrNoSlackSession reports that the chat session has no Slack channel binding —
// it is a Feishu or web-only session. Callers surface it as an empty (not
// failed) history read so the unified `multica chat history` command answers
// gracefully on a non-Slack conversation.
var ErrNoSlackSession = errors.New("slack: session has no slack channel binding")
const (
// defaultHistoryLimit is the page size used when the caller asks for none.
defaultHistoryLimit = 20
// maxHistoryLimit caps a single page. Slack's own conversations.* limit is
// far higher; we self-cap so a pull can't dump an unbounded transcript into
// the agent's context (mirrors the Feishu recent-context clamp).
maxHistoryLimit = 50
)
// historyQueries is the slice of generated queries the history reader needs.
// *db.Queries satisfies it. It mirrors outboundQueries: resolve the session's
// Slack binding, then load the installation that owns the bot token.
type historyQueries interface {
GetChannelChatSessionBindingBySession(ctx context.Context, arg db.GetChannelChatSessionBindingBySessionParams) (db.ChannelChatSessionBinding, error)
GetChannelInstallation(ctx context.Context, arg db.GetChannelInstallationParams) (db.ChannelInstallation, error)
}
// historyClient is the slice of the slack-go Web API the reader calls. The real
// *slack.Client satisfies it; tests inject a fake so the fetch/labeling logic is
// exercised without a live Slack.
type historyClient interface {
GetConversationHistoryContext(ctx context.Context, params *slack.GetConversationHistoryParameters) (*slack.GetConversationHistoryResponse, error)
GetConversationRepliesContext(ctx context.Context, params *slack.GetConversationRepliesParameters) ([]slack.Message, bool, string, error)
GetUsersInfoContext(ctx context.Context, users ...string) (*[]slack.User, error)
}
// History reads a Slack conversation's prior messages on demand — the pull half
// of the unified `multica chat history` tool (MUL-3871). It mirrors Outbound:
// given a chat_session it finds the Slack binding, decrypts the installation's
// bot token, and calls conversations.replies (a real thread) or
// conversations.history (DM / top-level channel context). Sessions with no
// Slack binding return ErrNoSlackSession, so it coexists with Feishu sessions on
// the shared endpoint.
type History struct {
q historyQueries
decrypt Decrypter
logger *slog.Logger
newClient func(botToken string) historyClient
}
// NewHistory builds the reader over the generated queries and the bot-token
// decrypter (box.Open at wiring time).
func NewHistory(q historyQueries, decrypt Decrypter, logger *slog.Logger) *History {
if logger == nil {
logger = slog.Default()
}
h := &History{q: q, decrypt: decrypt, logger: logger}
h.newClient = func(botToken string) historyClient {
// Only the bot token is needed to read history; the app-level token is
// for the inbound Socket Mode connection (slack_channel.go).
return slack.New(botToken)
}
return h
}
// Fetch returns one normalized, oldest-first page of the session's Slack
// conversation. It returns ErrNoSlackSession when the session is not Slack-bound
// or its installation is inactive.
func (h *History) Fetch(ctx context.Context, chatSessionID pgtype.UUID, opts channel.HistoryOptions) (channel.HistoryPage, error) {
binding, err := h.q.GetChannelChatSessionBindingBySession(ctx, db.GetChannelChatSessionBindingBySessionParams{
ChatSessionID: chatSessionID,
ChannelType: string(TypeSlack),
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return channel.HistoryPage{}, ErrNoSlackSession
}
return channel.HistoryPage{}, fmt.Errorf("lookup slack chat binding: %w", err)
}
inst, err := h.q.GetChannelInstallation(ctx, db.GetChannelInstallationParams{
ID: binding.InstallationID,
ChannelType: string(TypeSlack),
})
if err != nil {
return channel.HistoryPage{}, fmt.Errorf("load slack installation: %w", err)
}
if inst.Status != "active" {
return channel.HistoryPage{}, ErrNoSlackSession // revoked install: nothing to read
}
creds, err := decodeCredentials(inst.Config, h.decrypt)
if err != nil {
return channel.HistoryPage{}, fmt.Errorf("decode slack credentials: %w", err)
}
channelID, threadRoot := historyTarget(binding)
// Resolve the concrete scope to read. The handler resolves "auto" to
// thread/channel (it knows first-turn vs follow-up); here we additionally
// degrade "thread" to "channel" when there is no thread to read — a DM, or a
// group whose root could not be recovered.
scope := channel.HistoryScopeChannel
if opts.Scope == channel.HistoryScopeThread &&
binding.ChatType == string(channel.ChatTypeGroup) && threadRoot != "" {
scope = channel.HistoryScopeThread
}
limit := opts.Limit
if limit <= 0 {
limit = defaultHistoryLimit
}
if limit > maxHistoryLimit {
limit = maxHistoryLimit
}
fetchThreadTS := ""
if scope == channel.HistoryScopeThread {
fetchThreadTS = threadRoot
}
client := h.newClient(creds.BotToken)
raw, err := fetchRaw(ctx, client, channelID, fetchThreadTS, opts.Before, limit)
if err != nil {
return channel.HistoryPage{}, fmt.Errorf("read slack history: %w", err)
}
page := normalizeHistory(ctx, client, h.logger, raw, creds.BotUserID, limit)
page.ChannelType = string(TypeSlack)
page.Scope = scope
return page, nil
}
// historyTarget recovers the real channel id and the thread root from the
// binding. The channel_chat_id may be a composite "channel:threadRoot"
// isolation key, so the real channel id is read from the binding config
// (slackBindingConfig). The thread root — present for every engaged group
// session, since the bot's first reply opens a thread on the @mention — is the
// recorded reply thread (last_thread_id), falling back to the composite-key
// suffix. It is empty for a DM (no threads).
func historyTarget(b db.ChannelChatSessionBinding) (channelID, threadRoot string) {
channelID = b.ChannelChatID
if len(b.Config) > 0 {
var cfg slackBindingConfig
if err := json.Unmarshal(b.Config, &cfg); err == nil && cfg.ChannelID != "" {
channelID = cfg.ChannelID
}
}
if b.LastThreadID.Valid && b.LastThreadID.String != "" {
threadRoot = b.LastThreadID.String
} else if i := strings.IndexByte(b.ChannelChatID, ':'); i >= 0 {
threadRoot = b.ChannelChatID[i+1:]
}
return channelID, threadRoot
}
// fetchRaw pulls the most recent `limit` messages older than `before` (exclusive
// when set). A thread read uses conversations.replies anchored on the thread
// root; a channel read uses conversations.history. Both return newest-first;
// ordering is normalized downstream.
func fetchRaw(ctx context.Context, client historyClient, channelID, threadTS, before string, limit int) ([]slack.Message, error) {
if threadTS != "" {
msgs, _, _, err := client.GetConversationRepliesContext(ctx, &slack.GetConversationRepliesParameters{
ChannelID: channelID,
Timestamp: threadTS,
Latest: before,
Inclusive: false,
Limit: limit,
})
return msgs, err
}
resp, err := client.GetConversationHistoryContext(ctx, &slack.GetConversationHistoryParameters{
ChannelID: channelID,
Latest: before,
Inclusive: false,
Limit: limit,
})
if err != nil {
return nil, err
}
return resp.Messages, nil
}
// normalizeHistory turns raw Slack messages into a normalized, oldest-first
// page: it resolves human display names in one batch, labels each sender, maps
// the role, and computes the back-paging cursor.
func normalizeHistory(ctx context.Context, client historyClient, logger *slog.Logger, raw []slack.Message, botUserID string, limit int) channel.HistoryPage {
// Oldest-first so the transcript reads top-to-bottom like the chat does.
sort.SliceStable(raw, func(i, j int) bool { return slackTSLess(raw[i].Timestamp, raw[j].Timestamp) })
names := resolveUserNames(ctx, client, logger, raw, botUserID)
labeler := newHistoryLabeler(names)
out := make([]channel.HistoryMessage, 0, len(raw))
for i := range raw {
m := raw[i]
text := m.Text
if text == "" {
continue // join/system/edit markers carry no readable body
}
own := m.User != "" && m.User == botUserID
role := channel.HistoryRoleUser
if own {
role = channel.HistoryRoleAssistant
}
out = append(out, channel.HistoryMessage{
ID: m.Timestamp,
Author: labeler.label(m, own),
AuthorID: m.User,
Role: role,
Text: text,
TS: m.Timestamp,
})
}
page := channel.HistoryPage{Messages: out}
// Only advertise a cursor when the platform returned a full page (more may
// exist older than the oldest message we just returned).
if len(raw) >= limit && len(out) > 0 {
page.NextCursor = out[0].TS
}
return page
}
// resolveUserNames batch-resolves the human senders' display names, best-effort.
// A failure (missing users:read scope, transport error) yields a nil map so the
// labeler falls back to positional "User N" rather than blocking the read.
func resolveUserNames(ctx context.Context, client historyClient, logger *slog.Logger, msgs []slack.Message, botUserID string) map[string]string {
seen := make(map[string]bool)
ids := make([]string, 0, len(msgs))
for i := range msgs {
u := msgs[i].User
if u == "" || u == botUserID || seen[u] {
continue
}
seen[u] = true
ids = append(ids, u)
}
if len(ids) == 0 {
return nil
}
users, err := client.GetUsersInfoContext(ctx, ids...)
if err != nil || users == nil {
if err != nil {
logger.WarnContext(ctx, "slack history: user name resolution failed", "ids", len(ids), "error", err)
}
return nil
}
names := make(map[string]string, len(*users))
for _, u := range *users {
if name := slackDisplayName(u); name != "" {
names[u.ID] = name
}
}
return names
}
// slackDisplayName picks the friendliest available name for a Slack user.
func slackDisplayName(u slack.User) string {
switch {
case u.Profile.DisplayName != "":
return u.Profile.DisplayName
case u.RealName != "":
return u.RealName
default:
return u.Name
}
}
// historyLabeler assigns stable, human-readable labels within one page, mirroring
// the Feishu speakerLabeler: this bot is "Bot"; a resolved human gets their real
// name; an unresolved human falls back to positional "User N"; a third-party bot
// uses its posted username.
type historyLabeler struct {
names map[string]string
seen map[string]string
n int
}
func newHistoryLabeler(names map[string]string) *historyLabeler {
return &historyLabeler{names: names, seen: make(map[string]string)}
}
func (l *historyLabeler) label(m slack.Message, own bool) string {
if own {
return "Bot"
}
key := m.User
if key == "" {
// A third-party bot (alerting app, …) posts with a bot_id and often a
// username but no user id; label it by that username when present.
if m.Username != "" {
return m.Username
}
key = "bot:" + m.BotID
}
if lbl, ok := l.seen[key]; ok {
return lbl
}
var lbl string
if name := l.names[m.User]; name != "" {
lbl = name
} else if m.Username != "" {
lbl = m.Username
} else {
l.n++
lbl = fmt.Sprintf("User %d", l.n)
}
l.seen[key] = lbl
return lbl
}
// slackTSLess orders two Slack timestamps ("secs.micros") chronologically. Slack
// ts strings are not safely comparable lexicographically across widths, so parse
// them; an unparseable value sorts as 0 (oldest).
func slackTSLess(a, b string) bool {
return parseSlackTS(a) < parseSlackTS(b)
}
func parseSlackTS(ts string) float64 {
f, _ := strconv.ParseFloat(ts, 64)
return f
}

View File

@@ -0,0 +1,253 @@
package slack
import (
"context"
"errors"
"testing"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/slack-go/slack"
"github.com/multica-ai/multica/server/internal/integrations/channel"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
type fakeHistoryQueries struct {
binding db.ChannelChatSessionBinding
bindingErr error
inst db.ChannelInstallation
instErr error
}
func (f *fakeHistoryQueries) GetChannelChatSessionBindingBySession(context.Context, db.GetChannelChatSessionBindingBySessionParams) (db.ChannelChatSessionBinding, error) {
return f.binding, f.bindingErr
}
func (f *fakeHistoryQueries) GetChannelInstallation(context.Context, db.GetChannelInstallationParams) (db.ChannelInstallation, error) {
return f.inst, f.instErr
}
type fakeHistoryClient struct {
historyMsgs []slack.Message
repliesMsgs []slack.Message
users []slack.User
historyCalls int
repliesCalls int
lastHistory *slack.GetConversationHistoryParameters
lastReplies *slack.GetConversationRepliesParameters
}
func (f *fakeHistoryClient) GetConversationHistoryContext(_ context.Context, p *slack.GetConversationHistoryParameters) (*slack.GetConversationHistoryResponse, error) {
f.historyCalls++
f.lastHistory = p
return &slack.GetConversationHistoryResponse{Messages: f.historyMsgs}, nil
}
func (f *fakeHistoryClient) GetConversationRepliesContext(_ context.Context, p *slack.GetConversationRepliesParameters) ([]slack.Message, bool, string, error) {
f.repliesCalls++
f.lastReplies = p
return f.repliesMsgs, false, "", nil
}
func (f *fakeHistoryClient) GetUsersInfoContext(_ context.Context, _ ...string) (*[]slack.User, error) {
return &f.users, nil
}
func msg(user, text, ts string) slack.Message {
return slack.Message{Msg: slack.Msg{User: user, Text: text, Timestamp: ts}}
}
func activeSlackInstall() db.ChannelInstallation {
return db.ChannelInstallation{Status: "active", Config: slackInstallConfigJSON()}
}
// groupBinding builds a group session binding rooted at threadRoot (the thread
// the bot's reply opened on the @mention).
func groupBinding(threadRoot string) db.ChannelChatSessionBinding {
b := db.ChannelChatSessionBinding{
InstallationID: uid(2),
ChannelChatID: "C1:" + threadRoot,
ChatType: string(channel.ChatTypeGroup),
Config: []byte(`{"channel_id":"C1"}`),
}
if threadRoot != "" {
b.LastThreadID = pgtype.Text{String: threadRoot, Valid: true}
}
return b
}
func dmBinding() db.ChannelChatSessionBinding {
return db.ChannelChatSessionBinding{
InstallationID: uid(2),
ChannelChatID: "D1",
ChatType: string(channel.ChatTypeP2P),
Config: []byte(`{"channel_id":"D1"}`),
}
}
func newTestHistory(q historyQueries, fc historyClient) *History {
h := NewHistory(q, nil, nil) // nil decrypter => stored bytes treated as plaintext
h.newClient = func(string) historyClient { return fc }
return h
}
// TestHistoryFetchChannelScope verifies a channel-scope read uses
// conversations.history and normalizes oldest-first with roles + labels.
func TestHistoryFetchChannelScope(t *testing.T) {
q := &fakeHistoryQueries{binding: groupBinding("50.000000"), inst: activeSlackInstall()}
fc := &fakeHistoryClient{
// Slack returns newest-first; the bot (UBOT) replied last.
historyMsgs: []slack.Message{
msg("UBOT", "on it", "102.000000"),
msg("U1", "@bot look into this", "101.000000"),
msg("U2", "alert: 5xx spiking", "100.000000"),
},
users: []slack.User{{ID: "U1", RealName: "Alice"}}, // U2 unresolved -> positional
}
h := newTestHistory(q, fc)
page, err := h.Fetch(context.Background(), uid(9), channel.HistoryOptions{Scope: channel.HistoryScopeChannel})
if err != nil {
t.Fatalf("Fetch: %v", err)
}
if fc.historyCalls != 1 || fc.repliesCalls != 0 {
t.Fatalf("expected conversations.history, got history=%d replies=%d", fc.historyCalls, fc.repliesCalls)
}
if fc.lastHistory.ChannelID != "C1" {
t.Errorf("channel id = %q, want C1", fc.lastHistory.ChannelID)
}
if page.ChannelType != "slack" || page.Scope != channel.HistoryScopeChannel {
t.Errorf("channel_type/scope = %q/%q, want slack/channel", page.ChannelType, page.Scope)
}
if len(page.Messages) != 3 || page.Messages[0].TS != "100.000000" || page.Messages[2].TS != "102.000000" {
t.Fatalf("expected 3 msgs oldest-first, got %+v", page.Messages)
}
if got := page.Messages[0]; got.Author != "User 1" || got.Role != channel.HistoryRoleUser {
t.Errorf("msg0 author/role = %q/%q, want User 1/user", got.Author, got.Role)
}
if got := page.Messages[1]; got.Author != "Alice" {
t.Errorf("msg1 author = %q, want Alice", got.Author)
}
if got := page.Messages[2]; got.Author != "Bot" || got.Role != channel.HistoryRoleAssistant {
t.Errorf("msg2 author/role = %q/%q, want Bot/assistant", got.Author, got.Role)
}
}
// TestHistoryFetchThreadScope verifies a thread-scope read uses
// conversations.replies anchored on the session's thread root (from the binding).
func TestHistoryFetchThreadScope(t *testing.T) {
q := &fakeHistoryQueries{binding: groupBinding("50.000000"), inst: activeSlackInstall()}
fc := &fakeHistoryClient{repliesMsgs: []slack.Message{
msg("U1", "second", "52.000000"),
msg("U1", "root", "50.000000"),
}}
h := newTestHistory(q, fc)
page, err := h.Fetch(context.Background(), uid(9), channel.HistoryOptions{Scope: channel.HistoryScopeThread, Limit: 10})
if err != nil {
t.Fatalf("Fetch: %v", err)
}
if fc.repliesCalls != 1 || fc.historyCalls != 0 {
t.Fatalf("expected conversations.replies, got history=%d replies=%d", fc.historyCalls, fc.repliesCalls)
}
if fc.lastReplies.Timestamp != "50.000000" || fc.lastReplies.ChannelID != "C1" {
t.Errorf("replies anchored at %q/%q, want C1/50.000000", fc.lastReplies.ChannelID, fc.lastReplies.Timestamp)
}
if page.Scope != channel.HistoryScopeThread {
t.Errorf("scope = %q, want thread", page.Scope)
}
if len(page.Messages) != 2 || page.Messages[0].TS != "50.000000" {
t.Fatalf("expected 2 msgs oldest-first, got %+v", page.Messages)
}
}
// TestHistoryFetchDMIgnoresThreadScope confirms a DM (no threads) degrades a
// thread request to channel history.
func TestHistoryFetchDMIgnoresThreadScope(t *testing.T) {
q := &fakeHistoryQueries{binding: dmBinding(), inst: activeSlackInstall()}
fc := &fakeHistoryClient{historyMsgs: []slack.Message{msg("U1", "hi", "100.000000")}}
h := newTestHistory(q, fc)
page, err := h.Fetch(context.Background(), uid(9), channel.HistoryOptions{Scope: channel.HistoryScopeThread})
if err != nil {
t.Fatalf("Fetch: %v", err)
}
if fc.historyCalls != 1 || fc.repliesCalls != 0 {
t.Fatalf("DM must use conversations.history, got history=%d replies=%d", fc.historyCalls, fc.repliesCalls)
}
if page.Scope != channel.HistoryScopeChannel {
t.Errorf("scope = %q, want channel (DM has no thread)", page.Scope)
}
}
// TestHistoryFetchThreadFallsBackWithoutRoot: a group binding with no recoverable
// thread root degrades a thread request to channel history.
func TestHistoryFetchThreadFallsBackWithoutRoot(t *testing.T) {
q := &fakeHistoryQueries{
binding: db.ChannelChatSessionBinding{InstallationID: uid(2), ChannelChatID: "C1", ChatType: string(channel.ChatTypeGroup), Config: []byte(`{"channel_id":"C1"}`)},
inst: activeSlackInstall(),
}
fc := &fakeHistoryClient{historyMsgs: []slack.Message{msg("U1", "x", "100.000000")}}
h := newTestHistory(q, fc)
page, err := h.Fetch(context.Background(), uid(9), channel.HistoryOptions{Scope: channel.HistoryScopeThread})
if err != nil {
t.Fatalf("Fetch: %v", err)
}
if fc.historyCalls != 1 || fc.repliesCalls != 0 {
t.Fatalf("expected fallback to history, got history=%d replies=%d", fc.historyCalls, fc.repliesCalls)
}
if page.Scope != channel.HistoryScopeChannel {
t.Errorf("scope = %q, want channel", page.Scope)
}
}
// TestHistoryTargetDerivesRoot pins the channel + thread-root recovery from a
// binding: last_thread_id first, then the composite-key suffix, empty for a DM.
func TestHistoryTargetDerivesRoot(t *testing.T) {
if ch, root := historyTarget(groupBinding("50.0")); ch != "C1" || root != "50.0" {
t.Errorf("from last_thread_id: got %q/%q, want C1/50.0", ch, root)
}
keyOnly := db.ChannelChatSessionBinding{ChannelChatID: "C9:77.7", Config: []byte(`{"channel_id":"C9"}`)}
if ch, root := historyTarget(keyOnly); ch != "C9" || root != "77.7" {
t.Errorf("from key suffix: got %q/%q, want C9/77.7", ch, root)
}
if ch, root := historyTarget(dmBinding()); ch != "D1" || root != "" {
t.Errorf("dm: got %q/%q, want D1/<empty>", ch, root)
}
}
// TestHistoryFetchNoBinding maps a missing Slack binding to ErrNoSlackSession.
func TestHistoryFetchNoBinding(t *testing.T) {
q := &fakeHistoryQueries{bindingErr: pgx.ErrNoRows}
h := newTestHistory(q, &fakeHistoryClient{})
if _, err := h.Fetch(context.Background(), uid(9), channel.HistoryOptions{}); !errors.Is(err, ErrNoSlackSession) {
t.Fatalf("err = %v, want ErrNoSlackSession", err)
}
}
// TestHistoryFetchInactiveInstall treats a revoked installation as empty.
func TestHistoryFetchInactiveInstall(t *testing.T) {
q := &fakeHistoryQueries{
binding: groupBinding("50.0"),
inst: db.ChannelInstallation{Status: "revoked", Config: slackInstallConfigJSON()},
}
h := newTestHistory(q, &fakeHistoryClient{})
if _, err := h.Fetch(context.Background(), uid(9), channel.HistoryOptions{}); !errors.Is(err, ErrNoSlackSession) {
t.Fatalf("err = %v, want ErrNoSlackSession", err)
}
}
// TestHistoryLimitClamp confirms an over-large limit is clamped before the call.
func TestHistoryLimitClamp(t *testing.T) {
q := &fakeHistoryQueries{binding: groupBinding("50.0"), inst: activeSlackInstall()}
fc := &fakeHistoryClient{}
h := newTestHistory(q, fc)
if _, err := h.Fetch(context.Background(), uid(9), channel.HistoryOptions{Scope: channel.HistoryScopeChannel, Limit: 5000}); err != nil {
t.Fatalf("Fetch: %v", err)
}
if fc.lastHistory.Limit != maxHistoryLimit {
t.Errorf("limit = %d, want clamp to %d", fc.lastHistory.Limit, maxHistoryLimit)
}
}

View File

@@ -90,6 +90,11 @@ func slackSessionRouting(msg channel.InboundMessage) (bindingKey string, config
if msg.Source.ChatType == channel.ChatTypeP2P {
return chatID, cfg, msg.Source.ThreadID
}
// The thread root is the inbound thread_ts when the @mention is a reply
// inside an existing thread, else the message's own ts (a top-level mention
// becomes the root the bot threads its reply under). Either way the root is
// recoverable later from the binding (channel_chat_id suffix / last_thread_id),
// which is what the history reader uses to read the thread.
threadRoot := msg.Source.ThreadID
if threadRoot == "" {
threadRoot = msg.MessageID

View File

@@ -22,19 +22,26 @@ Every claim below is traced to source in
A skill is installed for Multica only when it exists in the current workspace's
skill database. The single supported path that puts it there is the workspace
import endpoint, driven by this CLI:
import endpoint. It accepts either a hosted URL or an uploaded local archive
(`.skill` / `.zip`), driven by this CLI:
```bash
multica skill import --url <url> --output json
multica skill import --url <url> --output json # hosted source
multica skill import --file <path-to.skill> --output json # local archive
```
The CLI defaults to `--on-conflict fail`. Current CLIs send:
The CLI defaults to `--on-conflict fail`. A URL import sends:
```text
POST /api/skills/import
Content-Type: application/json
body: { "url": "<url>", "on_conflict": "fail" }
```
A `--file` import hits the same route as `multipart/form-data` with a `file`
part (the `.skill` / `.zip` bytes) and an `on_conflict` field. `--url` and
`--file` are mutually exclusive; exactly one is required.
Do not finish with `npx skills add`. That installs into an external/local skill
environment, not the Multica workspace DB, so Multica cannot manage or bind it.
@@ -57,6 +64,29 @@ multica skill import --url github.com/owner/repo/blob/main/path/to/SKILL.md --ou
- A bare ClawHub slug (no host) is accepted and routed to ClawHub.
- Any other host is rejected with a 400 naming the supported sources.
## Local archive import (`.skill` / `.zip`)
`multica skill import --file <path> --output json` imports a skill from a local
archive instead of a hosted URL. A `.skill` file is a standard zip — the format
Anthropic's skill-creator `package_skill` produces — and a plain `.zip` of a
skill folder works too. The server:
- accepts the upload as `multipart/form-data` (a `file` part plus an optional
`on_conflict` field) on the same `POST /api/skills/import` route;
- decompresses it and roots on the shallowest `SKILL.md`, so both a root-level
`SKILL.md` and the nested `my-skill/SKILL.md` wrapper layout are accepted;
- takes the name/description from `SKILL.md` frontmatter, falling back to the
wrapper directory name and then the uploaded filename;
- carries the supporting files — dropping any `SKILL.md`, dotfiles, `__MACOSX`,
license files, and binary assets — under the same per-file (1 MiB),
per-bundle (8 MiB), and file-count (128) caps as URL imports, and rejects path
traversal (zip-slip);
- returns the same structured result envelope and honors the same
`--on-conflict` strategies as URL imports.
The upload itself is capped at 16 MiB (compressed). Any source that is not a
local archive still goes through `--url`.
## Direct URL flow
1. When the request contains a concrete URL, the import endpoint can be called

View File

@@ -28,19 +28,40 @@ grep -n "func IsReservedContentPath" server/internal/skill/reserved.go
| Legacy success: `201 Created` with bare `SkillWithFilesResponse` when `on_conflict` was omitted | `server/internal/handler/skill.go:1990` |
| Route registration `r.Post("/import", h.ImportSkill)` | `server/cmd/server/router.go:874` |
## CLI: `multica skill import --url`
Note: `ImportSkill` now branches on content type. A multipart body routes to the
archive path (below); a JSON body keeps the URL flow. Both converge on the shared
`finishSkillImport` tail. Line numbers in this table predate that split — re-grep
`func (h *Handler) ImportSkill` / `finishSkillImport` to re-derive.
## Local archive import (`.skill` / `.zip`)
| Behavior | File:line |
|---|---|
| `skill import` command def | `server/cmd/multica/cmd_skill.go:60-64` |
| `--url` flag | `server/cmd/multica/cmd_skill.go:142` |
| `--on-conflict` flag (default `fail`) | `server/cmd/multica/cmd_skill.go:143` |
| `--output` flag (default `json`) | `server/cmd/multica/cmd_skill.go:144` |
| `ImportSkill` branches to the archive path on multipart bodies | `server/internal/handler/skill.go:1924` (`if isMultipartForm(r)`) |
| Shared create + conflict tail `finishSkillImport` (URL and archive) | `server/internal/handler/skill.go:1974` |
| `isMultipartForm` content-type check | `server/internal/handler/skill_import_archive.go:26` |
| `importSkillFromArchive` (multipart parse + `MaxBytesReader` + `on_conflict` + `file`) | `server/internal/handler/skill_import_archive.go:36` |
| Upload cap `maxImportArchiveUploadSize` (16 MiB compressed) | `server/internal/handler/skill_import_archive.go:22` |
| `parseSkillArchive` (zip decode, shallowest-`SKILL.md` root, frontmatter name, zip-slip + reserved + ignore filters) | `server/internal/handler/skill_import_archive.go:95` |
| Reuses per-file / per-bundle / count caps via `importedSkill.addFile` | `server/internal/handler/skill.go:618` (`maxImportFileSize`/`maxImportTotalSize`/`maxImportFileCount` at `:579-583`) |
| Name fallback (wrapper dir, then filename) | `server/internal/handler/skill_import_archive.go:201` |
| Ignore filter (dotfiles, `__MACOSX`, license) | `server/internal/handler/skill_import_archive.go:218` |
| Per-entry size-capped read | `server/internal/handler/skill_import_archive.go:234` |
| Tests (parser units + handler multipart create/skip/reject) | `server/internal/handler/skill_import_archive_test.go` |
## CLI: `multica skill import --url` / `--file`
| Behavior | File:line |
|---|---|
| `skill import` command def | `server/cmd/multica/cmd_skill.go:59-63` |
| `--url` flag | `server/cmd/multica/cmd_skill.go:143` |
| `--file` flag (local `.skill` / `.zip`; mutually exclusive with `--url`) | `server/cmd/multica/cmd_skill.go:144` |
| `--on-conflict` flag (default `fail`) | `server/cmd/multica/cmd_skill.go:145` |
| `--output` flag (default `json`) | `server/cmd/multica/cmd_skill.go:146` |
| `runSkillImport` | `server/cmd/multica/cmd_skill.go:412` |
| Requires `--url` | `server/cmd/multica/cmd_skill.go:418-421` |
| Reads and validates `--on-conflict` | `server/cmd/multica/cmd_skill.go:422-425` |
| Sends `on_conflict` in the request body | `server/cmd/multica/cmd_skill.go:428-431` |
| `POST /api/skills/import` | `server/cmd/multica/cmd_skill.go:436` |
| Requires exactly one of `--url` / `--file` | `server/cmd/multica/cmd_skill.go:420-427` |
| `--file` reads the archive and posts multipart via `ImportSkillFile` | `server/cmd/multica/cmd_skill.go:436-447`, client method `server/internal/cli/client.go:535` |
| `POST /api/skills/import` (URL, JSON body) | `server/cmd/multica/cmd_skill.go:455` |
| Structured HTTP error body handling | `server/cmd/multica/cmd_skill.go:437-440`, `handleSkillImportError` at `:454` |
| Prints structured result (`json` or table) | `server/cmd/multica/cmd_skill.go:443`, helper at `:497` |

View File

@@ -3,6 +3,7 @@ package agent
import (
"bufio"
"context"
"encoding/json"
"fmt"
"log/slog"
"os"
@@ -23,6 +24,15 @@ import (
// therefore streams stdout line-by-line as `MessageText` events and accumulates
// the same text as the final `Result.Output`.
//
// agy 1.0.14's print mode regressed this stdout contract: a turn can run tools
// and produce a final reply while emitting ZERO bytes to stdout (the log shows
// "PlannerResponse without ModifiedResponse encountered"). Exit code is 0 and
// no error is logged, so a blank-but-"completed" run reaches the daemon and the
// user sees an empty result even though the work happened (MUL-3726, #4595).
// When stdout comes back empty on an otherwise-completed turn, the backend
// therefore recovers the assistant text agy durably wrote to its per-
// conversation transcript (see readAntigravityTranscriptOutput).
//
// Session resumption uses `--conversation <id>`. The conversation id is not
// emitted on stdout; we capture it by routing `--log-file` to a temp file and
// scanning its glog-formatted lines for the `conversation=<uuid>` token that
@@ -170,11 +180,24 @@ func (b *antigravityBackend) Execute(ctx context.Context, prompt string, opts Ex
finalError = withAgentStderr(finalError, "agy", stderrBuf.Tail())
}
finalOutput := output.String()
if finalStatus == "completed" && strings.TrimSpace(finalOutput) == "" {
// agy 1.0.14 print mode can finish a turn (tools executed, reply
// produced) without writing anything to stdout, leaving a blank but
// "completed" run none of the guards above catch (MUL-3726). Recover
// the assistant text agy persisted to its conversation transcript so
// the user sees the actual answer instead of an empty result.
if recovered := readAntigravityTranscriptOutput(logPath, sessionID); recovered != "" {
finalOutput = recovered
b.cfg.Logger.Info("agy recovered empty stdout from transcript", "bytes", len(recovered))
}
}
b.cfg.Logger.Info("agy finished", "pid", cmd.Process.Pid, "status", finalStatus, "duration", duration.Round(time.Millisecond).String())
resCh <- Result{
Status: finalStatus,
Output: output.String(),
Output: finalOutput,
Error: finalError,
DurationMs: duration.Milliseconds(),
SessionID: sessionID,
@@ -265,6 +288,118 @@ func readAntigravityConversationID(logPath string) string {
return string(matches[len(matches)-1][1])
}
// antigravityAppDataDirRe matches the glog line agy writes at startup naming its
// CLI app data directory — the root under which per-conversation transcripts
// live. Reading the path from the log (which the daemon owns via --log-file)
// is more robust than guessing $HOME, and follows agy through a custom data dir.
//
// Example: `I0630 14:19:40.582492 88197 common.go:156] CLI app data directory:
// /Users/me/.gemini/antigravity-cli`
var antigravityAppDataDirRe = regexp.MustCompile(`CLI app data directory:\s*(.+)`)
// antigravityTranscriptRecord is the minimal shape of one line in agy's
// per-conversation transcript.jsonl. A turn opens with a USER_INPUT record; the
// assistant's replies are PLANNER_RESPONSE records with source=MODEL and (once
// settled) status=DONE. Content holds the text, or JSON null for a tool-only
// step — it is RawMessage so a null or non-string value is skipped rather than
// failing the whole line.
type antigravityTranscriptRecord struct {
Type string `json:"type"`
Source string `json:"source"`
Status string `json:"status"`
Content json.RawMessage `json:"content"`
}
// readAntigravityTranscriptOutput recovers the assistant's text from agy's
// per-conversation transcript when stdout carried nothing. agy 1.0.14's print
// mode can finish a turn (tools executed, final reply produced) while emitting
// zero bytes to stdout, leaving the daemon with a blank but "completed" run
// (MUL-3726, #4595). The full reply is still durably written to:
//
// <appDataDir>/brain/<conversation-id>/.system_generated/logs/transcript.jsonl
//
// as PLANNER_RESPONSE / source=MODEL records.
//
// The transcript is per-conversation and ACCUMULATES across resumed turns
// (daemon reuses the conversation via --conversation / ResumeSessionID), so we
// must return only the CURRENT turn's reply — otherwise a later empty-stdout
// turn would re-emit prior turns' answers. Each turn opens with a USER_INPUT
// record, so we reset on every USER_INPUT and keep only the model text that
// follows the last one. We also require status=DONE to skip any future
// streaming/partial planner records. The remaining text is joined in order
// (intermediate narration + final reply), mirroring what stdout would have
// streamed for this turn. Best-effort: returns "" if the app data dir or
// conversation id is unknown, the transcript is missing, or it holds no model
// text for the current turn.
func readAntigravityTranscriptOutput(logPath, conversationID string) string {
if logPath == "" || conversationID == "" {
return ""
}
appDataDir := readAntigravityAppDataDir(logPath)
if appDataDir == "" {
return ""
}
transcriptPath := filepath.Join(
appDataDir, "brain", conversationID, ".system_generated", "logs", "transcript.jsonl",
)
f, err := os.Open(transcriptPath)
if err != nil {
return ""
}
defer f.Close()
var parts []string
scanner := bufio.NewScanner(f)
scanner.Buffer(make([]byte, 0, 1024*1024), 10*1024*1024)
for scanner.Scan() {
line := scanner.Bytes()
if len(line) == 0 {
continue
}
var rec antigravityTranscriptRecord
if err := json.Unmarshal(line, &rec); err != nil {
continue
}
if rec.Type == "USER_INPUT" {
// New turn boundary: drop anything collected for prior turns so a
// resumed conversation yields only the current turn's reply.
parts = parts[:0]
continue
}
if rec.Type != "PLANNER_RESPONSE" || rec.Source != "MODEL" || rec.Status != "DONE" {
continue
}
var text string
// Content is JSON null for tool-only steps; unmarshal leaves text "".
// A non-string value (object) errors and is skipped.
if err := json.Unmarshal(rec.Content, &text); err != nil {
continue
}
if strings.TrimSpace(text) != "" {
parts = append(parts, text)
}
}
return strings.Join(parts, "\n\n")
}
// readAntigravityAppDataDir extracts agy's CLI app data directory from the
// per-run log. Best-effort: returns "" if the log is missing or the marker
// format changes upstream.
func readAntigravityAppDataDir(logPath string) string {
if logPath == "" {
return ""
}
data, err := os.ReadFile(logPath)
if err != nil {
return ""
}
m := antigravityAppDataDirRe.FindSubmatch(data)
if m == nil {
return ""
}
return strings.TrimSpace(string(m[1]))
}
// antigravityBlockedArgs are flags hardcoded by the daemon that must not be
// overridden by user-configured custom_args. Overriding these would break
// non-interactive operation or the daemon's session-resume bookkeeping.

View File

@@ -503,3 +503,192 @@ func TestAntigravityModelError(t *testing.T) {
t.Error("near-miss model (dropped suffix) should be rejected")
}
}
// seedAntigravityTranscript writes a transcript.jsonl under appDataDir for the
// given conversation id, at the real path agy uses
// (<appDataDir>/brain/<cid>/.system_generated/logs/transcript.jsonl).
func seedAntigravityTranscript(t *testing.T, appDataDir, conversationID string, records []string) {
t.Helper()
dir := filepath.Join(appDataDir, "brain", conversationID, ".system_generated", "logs")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
body := strings.Join(records, "\n") + "\n"
if err := os.WriteFile(filepath.Join(dir, "transcript.jsonl"), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
func TestReadAntigravityTranscriptOutput(t *testing.T) {
t.Parallel()
appDataDir := t.TempDir()
cid := "d1637a93-20c7-4d90-8edb-7395e71280d2"
// The app data dir + conversation id both come from the per-run log, the
// same source the daemon already owns via --log-file.
logPath := filepath.Join(t.TempDir(), "agy.log")
if err := os.WriteFile(logPath, []byte(strings.Join([]string{
`I0630 14:19:40.582492 1 common.go:156] CLI app data directory: ` + appDataDir,
`I0630 14:19:46.755801 1 printmode.go:179] Print mode: conversation=` + cid + `, sending message`,
}, "\n")), 0o644); err != nil {
t.Fatal(err)
}
seedAntigravityTranscript(t, appDataDir, cid, []string{
// USER_INPUT (source=USER_EXPLICIT in practice) opens the turn.
`{"type":"USER_INPUT","source":"USER_EXPLICIT","status":"DONE","step_index":0,"content":"the user's prompt — must be ignored"}`,
// Tool-only model steps carry content=null and must be skipped.
`{"type":"PLANNER_RESPONSE","source":"MODEL","status":"DONE","step_index":2,"content":null}`,
`{"type":"PLANNER_RESPONSE","source":"MODEL","status":"DONE","step_index":3,"content":"First I will read the file."}`,
// Tool records can also be source=MODEL — excluded by the type check.
`{"type":"VIEW_FILE","source":"MODEL","status":"DONE","step_index":4}`,
// Non-MODEL planner text must be excluded.
`{"type":"PLANNER_RESPONSE","source":"SYSTEM","status":"DONE","step_index":5,"content":"non-model planner text — must be ignored"}`,
// A non-DONE (streaming/partial) model record must be excluded.
`{"type":"PLANNER_RESPONSE","source":"MODEL","status":"IN_PROGRESS","step_index":6,"content":"partial streaming text — must be ignored"}`,
`{"type":"CODE_ACTION","source":"MODEL","status":"DONE","step_index":7}`,
`{"type":"PLANNER_RESPONSE","source":"MODEL","status":"DONE","step_index":8,"content":"Done: created result.txt and verified it."}`,
})
// MODEL/DONE text is joined in order; null/tool/non-model/non-DONE records
// are all dropped.
got := readAntigravityTranscriptOutput(logPath, cid)
want := "First I will read the file.\n\nDone: created result.txt and verified it."
if got != want {
t.Fatalf("readAntigravityTranscriptOutput mismatch\n got: %q\nwant: %q", got, want)
}
// Every soft-failure path must yield "" rather than erroring.
if got := readAntigravityTranscriptOutput(logPath, "ffffffff-0000-0000-0000-000000000000"); got != "" {
t.Errorf("unknown conversation id should yield empty, got %q", got)
}
if got := readAntigravityTranscriptOutput("/nonexistent/agy.log", cid); got != "" {
t.Errorf("missing log (no app data dir) should yield empty, got %q", got)
}
if got := readAntigravityTranscriptOutput(logPath, ""); got != "" {
t.Errorf("empty conversation id should yield empty, got %q", got)
}
if got := readAntigravityTranscriptOutput("", cid); got != "" {
t.Errorf("empty log path should yield empty, got %q", got)
}
}
// TestReadAntigravityTranscriptOutputResumeReturnsCurrentTurnOnly is the
// regression guard for the PR #4744 review must-fix: the transcript accumulates
// across resumed turns (daemon reuses the conversation via --conversation), so
// recovery must return ONLY the current turn's reply. Without the USER_INPUT
// turn boundary, a second empty-stdout turn would re-emit the previous turn's
// answer alongside the new one.
func TestReadAntigravityTranscriptOutputResumeReturnsCurrentTurnOnly(t *testing.T) {
t.Parallel()
appDataDir := t.TempDir()
cid := "9e18418b-a431-4523-9616-75a94904554e"
logPath := filepath.Join(t.TempDir(), "agy.log")
if err := os.WriteFile(logPath, []byte(strings.Join([]string{
`I0630 14:19:40.582492 1 common.go:156] CLI app data directory: ` + appDataDir,
`I0630 14:19:46.755801 1 printmode.go:179] Print mode: conversation=` + cid + `, sending message`,
}, "\n")), 0o644); err != nil {
t.Fatal(err)
}
// Two turns in one accumulated transcript, matching real agy resume output:
// each turn opens with its own USER_INPUT.
seedAntigravityTranscript(t, appDataDir, cid, []string{
// --- turn 1 ---
`{"type":"USER_INPUT","source":"USER_EXPLICIT","status":"DONE","step_index":0,"content":"read marker.txt"}`,
`{"type":"VIEW_FILE","source":"MODEL","status":"DONE","step_index":1}`,
`{"type":"PLANNER_RESPONSE","source":"MODEL","status":"DONE","step_index":2,"content":"The two values are alpha-1 and beta-2."}`,
// --- turn 2 (resumed) ---
`{"type":"USER_INPUT","source":"USER_EXPLICIT","status":"DONE","step_index":3,"content":"what is 7 times 8?"}`,
`{"type":"PLANNER_RESPONSE","source":"MODEL","status":"DONE","step_index":4,"content":"56"}`,
})
got := readAntigravityTranscriptOutput(logPath, cid)
if got != "56" {
t.Fatalf("expected only the current turn's reply %q, got %q (prior turn leaked?)", "56", got)
}
}
// fakeAgyEmptyStdoutScript reproduces agy 1.0.14's regressed print mode: the
// process logs its CLI app data directory and the conversation id, writes
// NOTHING to stdout, and exits 0 — the "PlannerResponse without ModifiedResponse"
// case (MUL-3726). The real reply lives only in the conversation transcript,
// which the test seeds under appDataDir.
func fakeAgyEmptyStdoutScript(appDataDir, conversationID string) string {
return `#!/bin/sh
log=""
while [ $# -gt 0 ]; do
case "$1" in
--log-file) log="$2"; shift 2 ;;
*) shift ;;
esac
done
if [ -n "$log" ]; then
printf 'I0630 14:19:40.582492 1 common.go:156] CLI app data directory: ` + appDataDir + `\n' >> "$log"
printf 'I0630 14:19:46.755801 1 printmode.go:179] Print mode: conversation=` + conversationID + `, sending message\n' >> "$log"
fi
exit 0
`
}
// TestAntigravityBackendRecoversEmptyStdoutFromTranscript is the end-to-end
// guard for MUL-3726: agy 1.0.14 can complete a turn with empty stdout while the
// real reply lives only in the conversation transcript. The backend must recover
// that text into Result.Output instead of returning a blank "completed" run.
func TestAntigravityBackendRecoversEmptyStdoutFromTranscript(t *testing.T) {
t.Parallel()
appDataDir := t.TempDir()
cid := "44a57718-801c-41e7-9691-3225be2b1cb8"
seedAntigravityTranscript(t, appDataDir, cid, []string{
`{"type":"USER_INPUT","source":"USER_EXPLICIT","status":"DONE","step_index":0,"content":"create result.txt"}`,
`{"type":"PLANNER_RESPONSE","source":"MODEL","status":"DONE","step_index":2,"content":null}`,
`{"type":"VIEW_FILE","source":"MODEL","status":"DONE","step_index":3}`,
`{"type":"PLANNER_RESPONSE","source":"MODEL","status":"DONE","step_index":4,"content":"I read marker.txt and created result.txt with VERIFIED=yes."}`,
})
fakePath := filepath.Join(t.TempDir(), "agy")
writeTestExecutable(t, fakePath, []byte(fakeAgyEmptyStdoutScript(appDataDir, cid)))
backend, err := New("antigravity", Config{ExecutablePath: fakePath, Logger: quietAntigravityLogger()})
if err != nil {
t.Fatalf("new antigravity backend: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
session, err := backend.Execute(ctx, "prompt-ignored", ExecOptions{})
if err != nil {
t.Fatalf("execute: %v", err)
}
go func() {
for range session.Messages {
}
}()
select {
case result, ok := <-session.Result:
if !ok {
t.Fatal("result channel closed without a value")
}
if result.Status != "completed" {
t.Fatalf("expected status=completed, got %q (error=%q)", result.Status, result.Error)
}
if !strings.Contains(result.Output, "created result.txt with VERIFIED=yes") {
t.Fatalf("expected transcript reply recovered into output, got %q", result.Output)
}
// content=null tool steps must not leak the literal "null" into output.
if strings.Contains(result.Output, "null") {
t.Errorf("null tool-step content leaked into output: %q", result.Output)
}
if result.SessionID != cid {
t.Errorf("expected session id %q recovered from log, got %q", cid, result.SessionID)
}
case <-time.After(10 * time.Second):
t.Fatal("timeout waiting for result")
}
}