mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-30 19:09:27 +02:00
Compare commits
14 Commits
agent/j/6c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87f9d0fdd3 | ||
|
|
1c010d25c0 | ||
|
|
48f49d8abc | ||
|
|
c4209ec7c0 | ||
|
|
e57288ba60 | ||
|
|
f88544da63 | ||
|
|
a961d63611 | ||
|
|
50a48cef1e | ||
|
|
ff286dcfac | ||
|
|
630feff1af | ||
|
|
9ee2bd4c34 | ||
|
|
b90816264e | ||
|
|
424b02e79a | ||
|
|
3b45f7fdf6 |
@@ -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
|
||||
|
||||
@@ -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` のみを使用します |
|
||||
|
||||
@@ -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`만 사용합니다 |
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 Key(MUL-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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 Key(MUL-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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "選択を解除"
|
||||
}
|
||||
|
||||
@@ -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": "キャンセル",
|
||||
|
||||
@@ -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": "ステータス",
|
||||
|
||||
@@ -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": "使用状況",
|
||||
|
||||
@@ -93,7 +93,6 @@
|
||||
"section_columns": "列",
|
||||
"result_count_title": "該当スカッド / すべてのスカッド",
|
||||
"filter_label": "フィルター",
|
||||
"filter_active_count_one": "{{count}} 件のフィルター",
|
||||
"filter_active_count_other": "{{count}} 件のフィルター",
|
||||
"clear_filters": "フィルターをクリア"
|
||||
}
|
||||
|
||||
@@ -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": "선택 해제"
|
||||
}
|
||||
|
||||
@@ -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": "취소",
|
||||
|
||||
@@ -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": "복사했습니다",
|
||||
|
||||
@@ -42,7 +42,6 @@
|
||||
"title": "초대를 받았습니다",
|
||||
"subtitle": "참가할 워크스페이스를 선택하세요. 나머지는 나중에 사이드바에서 처리할 수 있습니다.",
|
||||
"submit_skip": "건너뛰고 내 워크스페이스 설정",
|
||||
"submit_join_one": "워크스페이스 1개 참가",
|
||||
"submit_join_other": "워크스페이스 {{count}}개 참가",
|
||||
"joining": "참가하는 중...",
|
||||
"error_generic": "초대를 처리하지 못했습니다. 다시 시도하세요.",
|
||||
|
||||
@@ -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": "취소"
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
"unavailable": "멤버를 사용할 수 없습니다",
|
||||
"agents_section": "에이전트 ({{count}})",
|
||||
"detail_link": "자세히 →",
|
||||
"more_agents_one": "외 에이전트 {{count}}개",
|
||||
"more_agents_other": "외 에이전트 {{count}}개"
|
||||
},
|
||||
"detail": {
|
||||
|
||||
@@ -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을 붙여 넣어 임시로 연결하세요.",
|
||||
|
||||
@@ -19,7 +19,6 @@
|
||||
"filter_button": "필터",
|
||||
"filter_status": "상태",
|
||||
"filter_priority": "우선순위",
|
||||
"issue_count_one": "이슈 {{count}}개",
|
||||
"issue_count_other": "이슈 {{count}}개",
|
||||
"reset_filters": "필터 모두 초기화",
|
||||
"display_settings": "보기 설정",
|
||||
|
||||
@@ -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": "위 명령을 실행하세요. ",
|
||||
|
||||
@@ -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": "상태",
|
||||
|
||||
@@ -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": "날짜",
|
||||
|
||||
@@ -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": "설명",
|
||||
|
||||
@@ -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": "필터 지우기"
|
||||
}
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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": "清除选择"
|
||||
}
|
||||
|
||||
@@ -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": "取消",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 临时关联一个。",
|
||||
|
||||
@@ -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": "状态",
|
||||
|
||||
@@ -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": "描述",
|
||||
|
||||
@@ -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": "清除筛选"
|
||||
}
|
||||
|
||||
109
server/cmd/multica/cmd_chat.go
Normal file
109
server/cmd/multica/cmd_chat.go
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
112
server/cmd/server/main_test.go
Normal file
112
server/cmd/server/main_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
183
server/internal/handler/chat_history.go
Normal file
183
server/internal/handler/chat_history.go
Normal 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
|
||||
}
|
||||
275
server/internal/handler/chat_history_test.go
Normal file
275
server/internal/handler/chat_history_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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"})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
255
server/internal/handler/skill_import_archive.go
Normal file
255
server/internal/handler/skill_import_archive.go
Normal 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
|
||||
}
|
||||
312
server/internal/handler/skill_import_archive_test.go
Normal file
312
server/internal/handler/skill_import_archive_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
100
server/internal/integrations/channel/history.go
Normal file
100
server/internal/integrations/channel/history.go
Normal 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
|
||||
}
|
||||
334
server/internal/integrations/slack/history.go
Normal file
334
server/internal/integrations/slack/history.go
Normal 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
|
||||
}
|
||||
253
server/internal/integrations/slack/history_test.go
Normal file
253
server/internal/integrations/slack/history_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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` |
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user