Merge remote-tracking branch 'origin/main' into agent/matt/ed345a53

This commit is contained in:
Naiyuan Qing
2026-06-01 14:52:47 +08:00
20 changed files with 959 additions and 217 deletions

View File

@@ -88,7 +88,7 @@ CI나 headless 환경에서는 브라우저 플로우를 건너뛰세요. 웹
| `multica squad create --name "..." --leader <agent>` | 스쿼드 생성(owner / admin) |
| `multica squad update <id> ...` | 이름, 설명, 지침, 리더, 또는 아바타 업데이트 |
| `multica squad delete <id>` | 보관(소프트 삭제) — 할당된 이슈를 리더에게 이관 |
| `multica squad member list/add/remove <squad-id>` | 스쿼드 멤버 관리 |
| `multica squad member list/add/remove/set-role <squad-id>` | 스쿼드 멤버 관리 및 역할 직접 업데이트 |
| `multica squad activity <issue-id> <action\|no_action\|failed> --reason "..."` | 스쿼드 리더 에이전트가 매 턴마다 평가를 기록할 때 사용 |
전체 모델은 [스쿼드](/squads)를 참고하세요.

View File

@@ -88,7 +88,7 @@ For the difference between token types, see [Authentication and tokens](/auth-to
| `multica squad create --name "..." --leader <agent>` | Create a squad (owner / admin) |
| `multica squad update <id> ...` | Update name, description, instructions, leader, or avatar |
| `multica squad delete <id>` | Archive (soft-delete) — transfers assigned issues to the leader |
| `multica squad member list/add/remove <squad-id>` | Manage squad members |
| `multica squad member list/add/remove/set-role <squad-id>` | Manage squad members and update roles in place |
| `multica squad activity <issue-id> <action\|no_action\|failed> --reason "..."` | Used by squad leader agents to record an evaluation per turn |
See [Squads](/squads) for the full model.

View File

@@ -88,7 +88,7 @@ Token 类型的详细区分见 [认证与令牌](/auth-tokens)。
| `multica squad create --name "..." --leader <agent>` | 创建小队owner / admin|
| `multica squad update <id> ...` | 修改名字、描述、instructions、队长、头像 |
| `multica squad delete <id>` | 归档(软删除)—— 同时把分配给小队的 issue 转给队长 |
| `multica squad member list/add/remove <squad-id>` | 管理小队成员 |
| `multica squad member list/add/remove/set-role <squad-id>` | 管理小队成员并原地更新 role |
| `multica squad activity <issue-id> <action\|no_action\|failed> --reason "..."` | 队长智能体每轮结束时调用,记录 evaluation |
完整模型见 [小队](/squads)。

View File

@@ -123,6 +123,7 @@ multica squad member add <squad-id> --member-id <agent-or-user-uuid> --type agen
| `multica squad delete <id>` | 보관(소프트 삭제) — 할당된 이슈를 리더에게 이전 |
| `multica squad member list <id>` | 스쿼드의 멤버 목록 표시 |
| `multica squad member add <id> --member-id <uuid> --type agent\|member [--role "..."]` | 멤버 추가(owner / admin) |
| `multica squad member set-role <id> --member-id <uuid> --member-type agent\|member --role "..."` | 멤버를 제거하지 않고 역할 변경 |
| `multica squad member remove <id> --member-id <uuid> --type agent\|member` | 멤버 제거(리더는 제거할 수 없습니다 — 먼저 리더를 변경하세요) |
| `multica squad activity <issue-id> <action\|no_action\|failed> --reason "..."` | 리더 에이전트가 매 턴 종료 시 기록 |

View File

@@ -123,6 +123,7 @@ There is currently no unarchive command; create a new squad if you need the rout
| `multica squad delete <id>` | Archive (soft-delete) — transfers assigned issues to the leader |
| `multica squad member list <id>` | List a squad's members |
| `multica squad member add <id> --member-id <uuid> --type agent\|member [--role "..."]` | Add a member (owner / admin) |
| `multica squad member set-role <id> --member-id <uuid> --member-type agent\|member --role "..."` | Change a member's role without removing it |
| `multica squad member remove <id> --member-id <uuid> --type agent\|member` | Remove a member (the leader cannot be removed — change leader first) |
| `multica squad activity <issue-id> <action\|no_action\|failed> --reason "..."` | Recorded by the leader agent at the end of every turn |

View File

@@ -123,6 +123,7 @@ multica squad member add <squad-id> --member-id <agent-or-user-uuid> --type agen
| `multica squad delete <id>` | 归档(软删除)——同时把当前分配给小队的 issue 转给队长 |
| `multica squad member list <id>` | 列出小队成员 |
| `multica squad member add <id> --member-id <uuid> --type agent\|member [--role "..."]` | 加成员owner / admin|
| `multica squad member set-role <id> --member-id <uuid> --member-type agent\|member --role "..."` | 不移除成员,直接修改 role |
| `multica squad member remove <id> --member-id <uuid> --type agent\|member` | 移除成员(**不能移除队长**——先换队长)|
| `multica squad activity <issue-id> <action\|no_action\|failed> --reason "..."` | 队长每次结束前由它自己调用 |

View File

@@ -3,8 +3,9 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { motion } from "motion/react";
import { Minus, Maximize2, Minimize2, ChevronDown, ChevronRight, Plus, Check, Trash2, Pencil } from "lucide-react";
import { Minus, Maximize2, Minimize2, ChevronDown, ChevronRight, Plus, Check, Trash2, Pencil, Loader2, Square } from "lucide-react";
import { Button } from "@multica/ui/components/ui/button";
import { cn } from "@multica/ui/lib/utils";
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
import {
DropdownMenu,
@@ -16,15 +17,10 @@ import {
DropdownMenuTrigger,
} from "@multica/ui/components/ui/dropdown-menu";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@multica/ui/components/ui/alert-dialog";
Popover,
PopoverContent,
PopoverTrigger,
} from "@multica/ui/components/ui/popover";
import { useWorkspaceId } from "@multica/core/hooks";
import { useAuthStore } from "@multica/core/auth";
import { agentListOptions, memberListOptions } from "@multica/core/workspace/queries";
@@ -60,7 +56,7 @@ import {
import { ChatResizeHandles } from "./chat-resize-handles";
import { useChatResize } from "./use-chat-resize";
import { createLogger } from "@multica/core/logger";
import type { Agent, ChatMessage, ChatPendingTask, ChatSession } from "@multica/core/types";
import type { Agent, ChatMessage, ChatPendingTask, ChatSession, PendingChatTasksResponse } from "@multica/core/types";
import { useT } from "../../i18n";
const uiLogger = createLogger("chat.ui");
@@ -730,8 +726,14 @@ function SessionDropdown({
return { active, archived };
}, [sessions]);
const [isHistoryOpen, setIsHistoryOpen] = useState(false);
const [showArchived, setShowArchived] = useState(false);
const [pendingDelete, setPendingDelete] = useState<ChatSession | null>(null);
const [confirmingDeleteId, setConfirmingDeleteId] = useState<string | null>(null);
const [confirmingStopId, setConfirmingStopId] = useState<string | null>(null);
const [stoppingTaskId, setStoppingTaskId] = useState<string | null>(null);
const [completedFlashIds, setCompletedFlashIds] = useState<Set<string>>(() => new Set());
const previousInFlightRef = useRef<Set<string>>(new Set());
const completedFlashTimersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
// Inline rename: only one row can be in edit mode at a time. We track the
// session id (not the full session) so a stale closure can't overwrite a
// newer rename pulled in via WS.
@@ -739,40 +741,88 @@ function SessionDropdown({
const deleteSession = useDeleteChatSession();
const updateSession = useUpdateChatSession();
const setActiveSession = useChatStore((s) => s.setActiveSession);
const queryClient = useQueryClient();
const formatTimeAgo = useFormatTimeAgo();
// Aggregate "which sessions have an in-flight task right now". Reuses
// the same workspace-scoped query the FAB consumes, so toggling the chat
// window doesn't fire a second request — TanStack dedupes by key.
const { data: pending } = useQuery(pendingChatTasksOptions(wsId));
const inFlightSessionIds = useMemo(
() => new Set((pending?.tasks ?? []).map((t) => t.chat_session_id)),
const pendingTaskBySessionId = useMemo(
() => new Map((pending?.tasks ?? []).map((task) => [task.chat_session_id, task])),
[pending],
);
const inFlightSessionIds = useMemo(
() => new Set(pendingTaskBySessionId.keys()),
[pendingTaskBySessionId],
);
// Cross-session aggregate signal for the closed-dropdown trigger.
// "Active" here means there's something interesting happening in a
// session OTHER than the one the user is currently looking at — the
// user already sees their own session's state via the StatusPill /
// unread auto-mark, so highlighting it on the trigger would be noise.
// Same priority rule as the row pips: running > unread.
const otherSessionRunning = sessions.some(
useEffect(() => {
const previous = previousInFlightRef.current;
const unreadSessionIds = new Set(sessions.filter((s) => s.has_unread).map((s) => s.id));
for (const sessionId of previous) {
if (inFlightSessionIds.has(sessionId) || !unreadSessionIds.has(sessionId)) continue;
setCompletedFlashIds((current) => {
if (current.has(sessionId)) return current;
return new Set(current).add(sessionId);
});
const existingTimer = completedFlashTimersRef.current.get(sessionId);
if (existingTimer) clearTimeout(existingTimer);
const timer = setTimeout(() => {
setCompletedFlashIds((current) => {
if (!current.has(sessionId)) return current;
const next = new Set(current);
next.delete(sessionId);
return next;
});
completedFlashTimersRef.current.delete(sessionId);
}, 1600);
completedFlashTimersRef.current.set(sessionId, timer);
}
previousInFlightRef.current = inFlightSessionIds;
}, [inFlightSessionIds, sessions]);
useEffect(() => {
const timers = completedFlashTimersRef.current;
return () => {
for (const timer of timers.values()) clearTimeout(timer);
timers.clear();
};
}, []);
useEffect(() => {
if (!confirmingStopId || pendingTaskBySessionId.has(confirmingStopId)) return;
setConfirmingStopId(null);
}, [confirmingStopId, pendingTaskBySessionId]);
// Header state split:
// - inside the trigger: the current chat's own live state
// - beside the trigger: aggregate activity from other chats
const currentSessionRunning = activeSessionId ? inFlightSessionIds.has(activeSessionId) : false;
const otherRunningCount = sessions.filter(
(s) => s.id !== activeSessionId && inFlightSessionIds.has(s.id),
);
const otherSessionUnread = sessions.some(
).length;
const otherUnreadCount = sessions.filter(
(s) => s.id !== activeSessionId && s.has_unread,
);
).length;
const handleConfirmDelete = () => {
if (!pendingDelete) return;
const sessionId = pendingDelete.id;
const handleConfirmDelete = (session: ChatSession) => {
const sessionId = session.id;
const isDeletingCurrent = activeSessionId === sessionId;
// Eager local clear when the user is deleting the session they're
// currently looking at — otherwise messages / pendingTask queries
// keep rendering the now-deleted session until chat:session_deleted
// arrives over WS (~50200ms gap).
if (activeSessionId === sessionId) setActiveSession(null);
if (isDeletingCurrent) {
setActiveSession(null);
}
deleteSession.mutate(sessionId, {
onSettled: () => setPendingDelete(null),
onSettled: () => setConfirmingDeleteId(null),
});
};
@@ -787,26 +837,91 @@ function SessionDropdown({
updateSession.mutate({ sessionId, title: trimmed });
};
const handleSelectSession = (session: ChatSession) => {
onSelectSession(session);
setIsHistoryOpen(false);
};
const handleConfirmStop = (session: ChatSession, task: PendingChatTasksResponse["tasks"][number]) => {
setStoppingTaskId(task.task_id);
previousInFlightRef.current = new Set(
[...previousInFlightRef.current].filter((sessionId) => sessionId !== session.id),
);
// Same optimistic behavior as the active chat Stop button: remove the
// running affordance immediately, then let task:cancelled / refetches
// converge every open surface on the server truth.
queryClient.setQueryData<PendingChatTasksResponse>(chatKeys.pendingTasks(wsId), (current) => {
if (!current) return current;
return {
...current,
tasks: current.tasks.filter((item) => item.task_id !== task.task_id),
};
});
queryClient.setQueryData(chatKeys.pendingTask(session.id), {});
queryClient.invalidateQueries({ queryKey: chatKeys.messages(session.id) });
api.cancelTaskById(task.task_id).then(
() => apiLogger.info("cancelTask.success (history row)", { taskId: task.task_id, sessionId: session.id }),
(err) =>
apiLogger.warn("cancelTask.error (history row; task may have already finished)", {
taskId: task.task_id,
sessionId: session.id,
err,
}),
).finally(() => {
queryClient.invalidateQueries({ queryKey: chatKeys.pendingTasks(wsId) });
queryClient.invalidateQueries({ queryKey: chatKeys.pendingTask(session.id) });
setStoppingTaskId(null);
setConfirmingStopId(null);
});
};
const renderRow = (session: ChatSession) => {
const isCurrent = session.id === activeSessionId;
const agent = agentById.get(session.agent_id) ?? null;
const isRunning = inFlightSessionIds.has(session.id);
const pendingTask = pendingTaskBySessionId.get(session.id);
const isRunning = !!pendingTask;
const showCompleted = completedFlashIds.has(session.id) && !isCurrent;
const showUnread = session.has_unread && !isCurrent;
const isRenaming = renamingId === session.id;
const isConfirmingDelete = confirmingDeleteId === session.id;
const isConfirmingStop = confirmingStopId === session.id && !!pendingTask;
const isConfirmingAction = isConfirmingDelete || isConfirmingStop;
const titleText = session.title?.trim() || t(($) => $.window.untitled);
const trailingStatus = isRunning
? t(($) => $.session_history.row_subtitle.working)
: showCompleted
? t(($) => $.session_history.row_subtitle.completed)
: showUnread
? t(($) => $.session_history.row_subtitle.new_reply)
: session.status === "archived"
? t(($) => $.session_history.row_subtitle.archived_label)
: formatTimeAgo(session.updated_at);
return (
<DropdownMenuItem
<div
key={session.id}
// While renaming we don't want a row click to select the session
// OR close the menu — the user is editing text, not navigating.
// closeOnClick=false keeps the dropdown open across input clicks
// / button clicks inside the row; the normal "click row → switch
// session → close menu" flow is unchanged when isRenaming=false.
closeOnClick={!isRenaming}
aria-current={isCurrent ? "true" : undefined}
tabIndex={0}
onClick={() => {
if (isRenaming) return;
onSelectSession(session);
if (isRenaming || isConfirmingAction) return;
handleSelectSession(session);
}}
className="group flex min-w-0 items-center gap-2"
onKeyDown={(e) => {
if (isRenaming || isConfirmingAction) return;
if (e.key !== "Enter" && e.key !== " ") return;
e.preventDefault();
handleSelectSession(session);
}}
className={cn(
"group/history-row relative flex min-h-11 min-w-0 cursor-default items-center gap-2 overflow-hidden rounded-md py-1.5 pl-2 pr-2 outline-none transition-colors hover:bg-accent/60 focus-visible:bg-accent/60 focus-visible:ring-1 focus-visible:ring-ring",
isCurrent && "bg-accent/70",
isConfirmingAction && "bg-destructive/5 hover:bg-destructive/5",
session.status === "archived" && "opacity-75",
)}
>
{isCurrent && <span className="absolute left-0 top-1.5 bottom-1.5 w-0.5 rounded-full bg-brand" />}
{agent ? (
<ActorAvatar
actorType="agent"
@@ -818,121 +933,236 @@ function SessionDropdown({
) : (
<span className="size-6 shrink-0" />
)}
<div className="min-w-0 flex-1">
<div className={cn("min-w-0 flex-1", !isRenaming && !isConfirmingAction && "pr-28")}>
{isRenaming ? (
<SessionRenameInput
initialValue={session.title ?? ""}
onSubmit={(value) => handleSubmitRename(session.id, value)}
onCancel={() => setRenamingId(null)}
/>
) : isConfirmingDelete ? (
<div className="truncate text-sm font-medium text-destructive">
{t(($) => $.session_history.delete_dialog.title)}
</div>
) : isConfirmingStop ? (
<div className="truncate text-sm font-medium text-destructive">
{t(($) => $.session_history.stop_dialog.title)}
</div>
) : (
<>
<div className="truncate text-sm">
{session.title?.trim() || t(($) => $.window.untitled)}
</div>
<div className="truncate text-xs text-muted-foreground/70">
{formatTimeAgo(session.updated_at)}
</div>
</>
<div
className={cn("truncate text-sm", (showUnread || showCompleted) && !isRunning && "font-medium")}
style={{
maskImage: "linear-gradient(to right, black calc(100% - 18px), transparent)",
WebkitMaskImage: "linear-gradient(to right, black calc(100% - 18px), transparent)",
}}
>
{titleText}
</div>
)}
</div>
{/* Right-edge status pip: in-flight wins over unread because
* "still working" is more actionable than "has reply" — and
* the two rarely coexist in practice (the unread flag fires
* on chat_message write, by which point the task has just
* finished). Same pip shape as unread for visual rhythm,
* amber + pulse to read as activity.
*
* Hidden while renaming so the inline input has room to
* breathe and trailing pips don't visually trail off-screen
* next to the editor caret. */}
{!isRenaming && isRunning ? (
<span
aria-label={t(($) => $.window.running)}
title={t(($) => $.window.running)}
className="size-1.5 shrink-0 rounded-full bg-amber-500 animate-pulse"
/>
) : !isRenaming && session.has_unread ? (
<span
aria-label={t(($) => $.window.unread)}
title={t(($) => $.window.unread)}
className="size-1.5 shrink-0 rounded-full bg-brand"
/>
) : null}
{!isRenaming && isCurrent && (
<Check className="size-3.5 text-muted-foreground shrink-0" />
)}
{!isRenaming && (
<>
<button
type="button"
// preventDefault is what tells Base UI's Menu.Item to skip
// its close-on-click; stopPropagation prevents the row's
// onClick from also firing (which would switch sessions).
// onPointerDown is stopped too so the menu's typeahead /
// focus tracking doesn't pre-empt the click.
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
setRenamingId(session.id);
}}
className="shrink-0 rounded p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-accent hover:text-foreground focus-visible:opacity-100 group-hover:opacity-100"
aria-label={t(($) => $.session_history.row_rename_aria)}
title={t(($) => $.session_history.row_rename_aria)}
>
<Pencil className="size-3.5" />
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
setPendingDelete(session);
}}
className="shrink-0 rounded p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-destructive/10 hover:text-destructive focus-visible:opacity-100 group-hover:opacity-100"
aria-label={t(($) => $.session_history.row_delete_aria)}
>
<Trash2 className="size-3.5" />
</button>
</>
isConfirmingDelete ? (
<div className="flex shrink-0 items-center gap-1">
<button
type="button"
onPointerDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
setConfirmingDeleteId(null);
}}
disabled={deleteSession.isPending}
className="inline-flex h-7 items-center rounded px-2 text-[11px] font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-50"
>
{t(($) => $.session_history.delete_dialog.cancel)}
</button>
<button
type="button"
onPointerDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
handleConfirmDelete(session);
}}
disabled={deleteSession.isPending}
className="inline-flex h-7 items-center rounded px-2 text-[11px] font-medium text-destructive transition-colors hover:bg-destructive/10 disabled:opacity-50"
>
{deleteSession.isPending
? t(($) => $.session_history.delete_dialog.confirming)
: t(($) => $.session_history.delete_dialog.confirm)}
</button>
</div>
) : isConfirmingStop && pendingTask ? (
<div className="flex shrink-0 items-center gap-1">
<button
type="button"
onPointerDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
setConfirmingStopId(null);
}}
disabled={stoppingTaskId === pendingTask.task_id}
className="inline-flex h-7 items-center rounded px-2 text-[11px] font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-50"
>
{t(($) => $.session_history.stop_dialog.cancel)}
</button>
<button
type="button"
onPointerDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
handleConfirmStop(session, pendingTask);
}}
disabled={stoppingTaskId === pendingTask.task_id}
className="inline-flex h-7 items-center rounded px-2 text-[11px] font-medium text-destructive transition-colors hover:bg-destructive/10 disabled:opacity-50"
>
{stoppingTaskId === pendingTask.task_id
? t(($) => $.session_history.stop_dialog.confirming)
: t(($) => $.session_history.stop_dialog.confirm)}
</button>
</div>
) : (
<div className="absolute right-2 top-1/2 flex -translate-y-1/2 items-center">
<div className="flex h-7 min-w-16 items-center justify-end gap-1.5 text-xs text-muted-foreground transition-opacity group-hover/history-row:opacity-0">
{isRunning && <Loader2 className="size-3 animate-spin" />}
{showCompleted && !isRunning && <Check className="size-3 text-emerald-500" />}
{showUnread && !isRunning && !showCompleted && (
<span
aria-label={t(($) => $.window.unread)}
title={t(($) => $.window.unread)}
className="size-1.5 rounded-full bg-brand"
/>
)}
<span className={cn("truncate", (showUnread || showCompleted || isRunning) && "font-medium text-foreground")}>{trailingStatus}</span>
</div>
<div className="absolute right-0 top-1/2 flex -translate-y-1/2 items-center gap-0.5 opacity-0 transition-opacity group-hover/history-row:opacity-100">
{isRunning && pendingTask && (
<button
type="button"
onPointerDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
setConfirmingStopId(session.id);
}}
className="inline-flex h-7 items-center gap-1 rounded px-1.5 text-[11px] font-medium text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive focus-visible:bg-destructive/10 focus-visible:text-destructive focus-visible:outline-none"
aria-label={t(($) => $.session_history.row_stop_aria)}
title={t(($) => $.session_history.row_stop_aria)}
>
<Square className="size-2.5 fill-current" />
{t(($) => $.session_history.stop_action)}
</button>
)}
{!isRunning && (
<>
<button
type="button"
onPointerDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
setRenamingId(session.id);
}}
className="inline-flex size-7 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:bg-accent focus-visible:text-foreground focus-visible:outline-none"
aria-label={t(($) => $.session_history.row_rename_aria)}
title={t(($) => $.session_history.row_rename_aria)}
>
<Pencil className="size-3.5" />
</button>
<button
type="button"
onPointerDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
setConfirmingDeleteId(session.id);
}}
className="inline-flex size-7 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive focus-visible:bg-destructive/10 focus-visible:text-destructive focus-visible:outline-none"
aria-label={t(($) => $.session_history.row_delete_aria)}
title={t(($) => $.session_history.row_delete_aria)}
>
<Trash2 className="size-3.5" />
</button>
</>
)}
</div>
</div>
)
)}
</DropdownMenuItem>
</div>
);
};
return (
<>
<DropdownMenu>
<DropdownMenuTrigger className="flex max-w-96 min-w-0 items-center gap-1.5 rounded-md px-1.5 py-1 transition-colors hover:bg-accent aria-expanded:bg-accent">
{triggerAgent && (
<ActorAvatar
actorType="agent"
actorId={triggerAgent.id}
size={24}
enableHoverCard
showStatusDot
/>
)}
<span className="min-w-0 truncate text-sm font-medium">{title}</span>
{otherSessionRunning ? (
<Popover open={isHistoryOpen} onOpenChange={setIsHistoryOpen}>
<div className="flex min-w-0 items-center gap-1">
<PopoverTrigger className="flex max-w-96 min-w-0 items-center gap-1.5 rounded-md px-1.5 py-1 transition-colors hover:bg-accent data-[popup-open]:bg-accent data-open:bg-accent">
{triggerAgent && (
<ActorAvatar
actorType="agent"
actorId={triggerAgent.id}
size={24}
enableHoverCard
showStatusDot
/>
)}
<span className="min-w-0 truncate text-sm font-medium">{title}</span>
{currentSessionRunning && (
<Loader2
aria-label={t(($) => $.session_history.row_subtitle.working)}
className="size-3 shrink-0 animate-spin text-muted-foreground"
/>
)}
<ChevronDown className="size-3 text-muted-foreground shrink-0" />
</PopoverTrigger>
{otherRunningCount > 0 ? (
<span
aria-label={t(($) => $.window.another_running)}
title={t(($) => $.window.another_running)}
className="size-1.5 shrink-0 rounded-full bg-amber-500 animate-pulse"
/>
) : otherSessionUnread ? (
className="inline-flex h-6 shrink-0 items-center gap-1 rounded-md px-1.5 text-xs font-medium text-muted-foreground"
>
<Loader2 className="size-3 animate-spin" />
{otherRunningCount > 1 && <span>{otherRunningCount}</span>}
</span>
) : otherUnreadCount > 0 ? (
<span
aria-label={t(($) => $.window.another_unread)}
title={t(($) => $.window.another_unread)}
className="size-1.5 shrink-0 rounded-full bg-brand"
/>
className="inline-flex h-6 shrink-0 items-center gap-1 rounded-md px-1.5 text-xs font-medium text-muted-foreground"
>
<span className="size-1.5 rounded-full bg-brand" />
{otherUnreadCount > 1 && <span>{otherUnreadCount}</span>}
</span>
) : null}
<ChevronDown className="size-3 text-muted-foreground shrink-0" />
</DropdownMenuTrigger>
<DropdownMenuContent
</div>
<PopoverContent
align="start"
className="max-h-96 w-auto min-w-[max(16rem,var(--anchor-width,16rem))] max-w-96 overflow-y-auto"
className="max-h-96 w-auto min-w-[max(16rem,var(--anchor-width,16rem))] max-w-96 gap-0 overflow-y-auto p-1"
onClick={(e) => e.stopPropagation()}
>
{sessions.length === 0 ? (
<div className="px-2 py-1.5 text-xs text-muted-foreground">
@@ -941,20 +1171,24 @@ function SessionDropdown({
) : (
<>
{active.length > 0 && (
<DropdownMenuGroup>
<DropdownMenuLabel>{t(($) => $.window.active_group)}</DropdownMenuLabel>
<div role="group" aria-label={t(($) => $.window.active_group)}>
<div className="px-1.5 py-1 text-xs font-medium text-muted-foreground">
{t(($) => $.window.active_group)}
</div>
{active.map(renderRow)}
</DropdownMenuGroup>
</div>
)}
{archived.length > 0 && (
<>
{active.length > 0 && <DropdownMenuSeparator />}
<DropdownMenuItem
{active.length > 0 && <div className="-mx-1 my-1 h-px bg-border" />}
<button
type="button"
onClick={(e) => {
e.preventDefault();
setShowArchived((v) => !v);
}}
className="flex items-center gap-1.5 text-xs text-muted-foreground"
className="flex w-full items-center gap-1.5 rounded-md px-1.5 py-1 text-left text-xs text-muted-foreground outline-none transition-colors hover:bg-accent/60 focus-visible:bg-accent/60 focus-visible:ring-1 focus-visible:ring-ring"
aria-expanded={showArchived}
>
{showArchived ? (
<ChevronDown className="size-3" />
@@ -964,54 +1198,18 @@ function SessionDropdown({
<span>
{t(($) => $.window.archived_group, { count: archived.length })}
</span>
</DropdownMenuItem>
</button>
{showArchived && (
<DropdownMenuGroup>
<div role="group" aria-label={t(($) => $.window.archived_group, { count: archived.length })}>
{archived.map(renderRow)}
</DropdownMenuGroup>
</div>
)}
</>
)}
</>
)}
</DropdownMenuContent>
</DropdownMenu>
<AlertDialog
open={!!pendingDelete}
onOpenChange={(open) => {
if (!open && !deleteSession.isPending) setPendingDelete(null);
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t(($) => $.session_history.delete_dialog.title)}
</AlertDialogTitle>
<AlertDialogDescription>
{pendingDelete?.title
? t(($) => $.session_history.delete_dialog.description_with_title, {
title: pendingDelete.title,
})
: t(($) => $.session_history.delete_dialog.description_default)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteSession.isPending}>
{t(($) => $.session_history.delete_dialog.cancel)}
</AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmDelete}
disabled={deleteSession.isPending}
className="bg-destructive text-white hover:bg-destructive/90"
>
{deleteSession.isPending
? t(($) => $.session_history.delete_dialog.confirming)
: t(($) => $.session_history.delete_dialog.confirm)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</PopoverContent>
</Popover>
</>
);
}
@@ -1022,13 +1220,10 @@ function SessionDropdown({
* into the existing text. Enter commits, Escape cancels, a real click
* outside the input also commits.
*
* We do NOT commit on the input's `blur` event: Base UI's Menu uses
* focus-follows-cursor (hovering a sibling row drags DOM focus there),
* so a blur handler would fire on every mouse-move and "save" the user's
* half-typed title without them clicking anywhere. Instead a document-
* level `pointerdown` listener — registered in capture phase so it runs
* before Base UI's outside-click close handler — commits when the user
* actually clicks outside the input.
* We do NOT commit on the input's `blur` event: the history popover can
* move focus to sibling rows and nested actions while the user is still
* interacting with the panel. Instead a document-level `pointerdown`
* listener commits only when the user actually clicks outside the input.
*/
function SessionRenameInput({
initialValue,
@@ -1061,9 +1256,8 @@ function SessionRenameInput({
if (input.contains(e.target as Node)) return;
onSubmitRef.current(valueRef.current);
};
// Capture phase — Base UI registers its own outside-click handler in
// bubble; running first lets us commit before the menu starts to
// close (and unmount this component).
// Capture phase — commit before outside-click handling can close the
// popover and unmount this component.
document.addEventListener("pointerdown", handlePointerDown, true);
return () => {
document.removeEventListener("pointerdown", handlePointerDown, true);
@@ -1081,7 +1275,8 @@ function SessionRenameInput({
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
onKeyDown={(e) => {
// Stop the menu from stealing arrow / typeahead / space input.
// Keep editing keys inside the input instead of letting the row
// selection keyboard handler consume them.
e.stopPropagation();
if (e.key === "Enter") {
e.preventDefault();

View File

@@ -38,6 +38,22 @@
},
"row_delete_aria": "Delete chat session",
"row_rename_aria": "Rename chat session",
"row_stop_aria": "Stop current run",
"stop_action": "Stop",
"row_subtitle": {
"working": "Working",
"completed": "Completed",
"new_reply": "New reply",
"archived_label": "Archived"
},
"stop_dialog": {
"title": "Stop this run?",
"description_with_title": "This will cancel the current agent task in \"{{title}}\". Partial output will stay in the chat.",
"description_default": "This will cancel the current agent task. Partial output will stay in the chat.",
"cancel": "Keep running",
"confirm": "Stop run",
"confirming": "Stopping..."
},
"delete_dialog": {
"title": "Delete chat session",
"description_with_title": "\"{{title}}\" and its messages will be permanently removed. This action cannot be undone.",

View File

@@ -38,6 +38,22 @@
},
"row_delete_aria": "채팅 세션 삭제",
"row_rename_aria": "채팅 세션 이름 변경",
"row_stop_aria": "현재 실행 중지",
"stop_action": "중지",
"row_subtitle": {
"working": "작업 중",
"completed": "완료됨",
"new_reply": "새 답변",
"archived_label": "보관됨"
},
"stop_dialog": {
"title": "이 실행을 중지할까요?",
"description_with_title": "\"{{title}}\"의 현재 에이전트 작업을 취소합니다. 이미 생성된 내용은 채팅에 남습니다.",
"description_default": "현재 에이전트 작업을 취소합니다. 이미 생성된 내용은 채팅에 남습니다.",
"cancel": "계속 실행",
"confirm": "실행 중지",
"confirming": "중지하는 중..."
},
"delete_dialog": {
"title": "채팅 세션 삭제",
"description_with_title": "\"{{title}}\"과(와) 메시지가 영구 삭제됩니다. 이 작업은 되돌릴 수 없습니다.",

View File

@@ -35,6 +35,22 @@
},
"row_delete_aria": "删除对话",
"row_rename_aria": "重命名对话",
"row_stop_aria": "停止当前运行",
"stop_action": "停止",
"row_subtitle": {
"working": "运行中",
"completed": "已完成",
"new_reply": "新回复",
"archived_label": "已归档"
},
"stop_dialog": {
"title": "停止这次运行?",
"description_with_title": "这会取消 \"{{title}}\" 里的当前智能体任务。已产生的内容会保留在对话里。",
"description_default": "这会取消当前智能体任务。已产生的内容会保留在对话里。",
"cancel": "继续运行",
"confirm": "停止运行",
"confirming": "停止中..."
},
"delete_dialog": {
"title": "删除对话",
"description_with_title": "\"{{title}}\" 及其消息会被永久删除,无法撤销。",

View File

@@ -4,7 +4,9 @@ import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"strings"
"time"
@@ -346,6 +348,9 @@ func runSkillImport(cmd *cobra.Command, _ []string) error {
var result map[string]any
if err := client.PostJSON(ctx, "/api/skills/import", body, &result); err != nil {
if handleSkillImportConflict(cmd, err) {
return nil
}
return fmt.Errorf("import skill: %w", err)
}
@@ -358,6 +363,31 @@ func runSkillImport(cmd *cobra.Command, _ []string) error {
return nil
}
func handleSkillImportConflict(cmd *cobra.Command, err error) bool {
var httpErr *cli.HTTPError
if !errors.As(err, &httpErr) || httpErr.StatusCode != http.StatusConflict || strings.TrimSpace(httpErr.Body) == "" {
return false
}
var body map[string]any
if json.Unmarshal([]byte(httpErr.Body), &body) != nil {
return false
}
if _, ok := body["existing_skill"]; !ok {
return false
}
output, _ := cmd.Flags().GetString("output")
if output == "json" {
_ = cli.PrintJSON(os.Stdout, body)
return true
}
existing, _ := body["existing_skill"].(map[string]any)
fmt.Printf("Skill already exists: %s (%s)\n", strVal(existing, "name"), strVal(existing, "id"))
return true
}
// ---------------------------------------------------------------------------
// Skill file subcommands
// ---------------------------------------------------------------------------

View File

@@ -0,0 +1,98 @@
package main
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"testing"
"github.com/spf13/cobra"
)
func newSkillImportTestCmd() *cobra.Command {
cmd := &cobra.Command{Use: "import"}
cmd.Flags().String("server-url", "", "")
cmd.Flags().String("workspace-id", "", "")
cmd.Flags().String("profile", "", "")
cmd.Flags().String("url", "", "")
cmd.Flags().String("output", "json", "")
return cmd
}
func captureStdout(t *testing.T, fn func() error) (string, error) {
t.Helper()
old := os.Stdout
r, w, err := os.Pipe()
if err != nil {
t.Fatalf("pipe stdout: %v", err)
}
os.Stdout = w
defer func() { os.Stdout = old }()
runErr := fn()
if err := w.Close(); err != nil {
t.Fatalf("close stdout writer: %v", err)
}
out, err := io.ReadAll(r)
if err != nil {
t.Fatalf("read stdout: %v", err)
}
return string(out), runErr
}
func TestRunSkillImportJsonTreatsDuplicateAsStructuredResult(t *testing.T) {
t.Setenv("HOME", t.TempDir())
t.Setenv("MULTICA_TOKEN", "test-token")
t.Setenv("MULTICA_WORKSPACE_ID", "workspace-123")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Fatalf("method = %s, want POST", r.Method)
}
if r.URL.Path != "/api/skills/import" {
t.Fatalf("path = %q, want /api/skills/import", r.URL.Path)
}
if r.Header.Get("X-Workspace-ID") != "workspace-123" {
t.Fatalf("X-Workspace-ID = %q, want workspace-123", r.Header.Get("X-Workspace-ID"))
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusConflict)
_ = json.NewEncoder(w).Encode(map[string]any{
"error": "a skill with this name already exists",
"existing_skill": map[string]any{
"id": "skill-123",
"name": "review-helper",
},
})
}))
defer srv.Close()
t.Setenv("MULTICA_SERVER_URL", srv.URL)
cmd := newSkillImportTestCmd()
_ = cmd.Flags().Set("url", "https://skills.sh/acme/review-helper")
_ = cmd.Flags().Set("output", "json")
out, err := captureStdout(t, func() error {
return runSkillImport(cmd, nil)
})
if err != nil {
t.Fatalf("runSkillImport returned error for duplicate import: %v", err)
}
var got map[string]any
if err := json.Unmarshal([]byte(out), &got); err != nil {
t.Fatalf("decode stdout JSON %q: %v", out, err)
}
if got["error"] != "a skill with this name already exists" {
t.Fatalf("error = %v", got["error"])
}
existing, ok := got["existing_skill"].(map[string]any)
if !ok {
t.Fatalf("existing_skill missing or wrong type: %#v", got["existing_skill"])
}
if existing["id"] != "skill-123" || existing["name"] != "review-helper" {
t.Fatalf("existing_skill = %#v", existing)
}
}

View File

@@ -344,6 +344,56 @@ func runSquadMemberAdd(cmd *cobra.Command, args []string) error {
return nil
}
// ── Member Set Role ─────────────────────────────────────────────────────────
var squadMemberSetRoleCmd = &cobra.Command{
Use: "set-role <squad-id>",
Short: "Change a squad member's role",
Args: exactArgs(1),
RunE: runSquadMemberSetRole,
}
func runSquadMemberSetRole(cmd *cobra.Command, args []string) error {
memberID, _ := cmd.Flags().GetString("member-id")
memberType, _ := cmd.Flags().GetString("member-type")
role, _ := cmd.Flags().GetString("role")
if memberID == "" {
return fmt.Errorf("--member-id is required")
}
if memberType != "agent" && memberType != "member" {
return fmt.Errorf("--member-type must be 'agent' or 'member'")
}
if role == "" {
return fmt.Errorf("--role is required")
}
client, err := newAPIClient(cmd)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
body := map[string]any{
"member_type": memberType,
"member_id": memberID,
"role": role,
}
var result map[string]any
if err := client.PatchJSON(ctx, "/api/squads/"+args[0]+"/members/role", body, &result); err != nil {
return fmt.Errorf("set member role: %w", err)
}
output, _ := cmd.Flags().GetString("output")
if output == "json" {
return cli.PrintJSON(os.Stdout, result)
}
fmt.Fprintf(os.Stderr, "Member %s role updated to %s.\n", memberID, role)
return nil
}
// ── Member Remove ───────────────────────────────────────────────────────────
var squadMemberRemoveCmd = &cobra.Command{
@@ -487,6 +537,12 @@ func init() {
squadMemberRemoveCmd.Flags().String("type", "agent", "Member type: agent or member")
squadMemberRemoveCmd.Flags().String("output", "table", "Output format: table or json")
// member set-role
squadMemberSetRoleCmd.Flags().String("member-id", "", "Member or agent ID (required)")
squadMemberSetRoleCmd.Flags().String("member-type", "agent", "Member type: agent or member")
squadMemberSetRoleCmd.Flags().String("role", "", "New role in the squad (required)")
squadMemberSetRoleCmd.Flags().String("output", "json", "Output format: table or json")
// activity
squadActivityCmd.Flags().String("reason", "", "Short explanation of the decision")
squadActivityCmd.Flags().String("output", "table", "Output format: table or json")
@@ -494,6 +550,7 @@ func init() {
squadMemberCmd.AddCommand(squadMemberListCmd)
squadMemberCmd.AddCommand(squadMemberAddCmd)
squadMemberCmd.AddCommand(squadMemberRemoveCmd)
squadMemberCmd.AddCommand(squadMemberSetRoleCmd)
squadCmd.AddCommand(squadListCmd)
squadCmd.AddCommand(squadGetCmd)

View File

@@ -0,0 +1,107 @@
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/spf13/cobra"
)
func newSquadMemberSetRoleTestCmd() *cobra.Command {
cmd := &cobra.Command{Use: "set-role"}
cmd.Flags().String("server-url", "", "")
cmd.Flags().String("workspace-id", "", "")
cmd.Flags().String("profile", "", "")
cmd.Flags().String("member-id", "", "")
cmd.Flags().String("member-type", "agent", "")
cmd.Flags().String("role", "", "")
cmd.Flags().String("output", "json", "")
return cmd
}
func TestSquadMemberSetRoleCommandIsRegistered(t *testing.T) {
cmd, _, err := squadMemberCmd.Find([]string{"set-role", "squad-123"})
if err != nil {
t.Fatalf("find set-role command: %v", err)
}
if cmd == nil || cmd.Name() != "set-role" {
t.Fatalf("set-role command not registered; got %#v", cmd)
}
for _, flag := range []string{"member-id", "member-type", "role", "output"} {
if cmd.Flags().Lookup(flag) == nil {
t.Fatalf("set-role command missing --%s flag", flag)
}
}
}
func TestRunSquadMemberSetRolePatchesRole(t *testing.T) {
t.Setenv("HOME", t.TempDir())
t.Setenv("MULTICA_TOKEN", "test-token")
t.Setenv("MULTICA_WORKSPACE_ID", "workspace-123")
var gotMethod, gotPath string
var gotBody map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotMethod = r.Method
gotPath = r.URL.Path
if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
t.Fatalf("decode request body: %v", err)
}
if r.Header.Get("X-Workspace-ID") != "workspace-123" {
t.Fatalf("X-Workspace-ID = %q, want workspace-123", r.Header.Get("X-Workspace-ID"))
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"squad_id": "squad-123",
"member_id": "member-456",
"member_type": "agent",
"role": "reviewer",
})
}))
defer srv.Close()
t.Setenv("MULTICA_SERVER_URL", srv.URL)
cmd := newSquadMemberSetRoleTestCmd()
_ = cmd.Flags().Set("member-id", "member-456")
_ = cmd.Flags().Set("member-type", "agent")
_ = cmd.Flags().Set("role", "reviewer")
_ = cmd.Flags().Set("output", "json")
if err := runSquadMemberSetRole(cmd, []string{"squad-123"}); err != nil {
t.Fatalf("runSquadMemberSetRole: %v", err)
}
if gotMethod != http.MethodPatch {
t.Fatalf("method = %s, want PATCH", gotMethod)
}
if gotPath != "/api/squads/squad-123/members/role" {
t.Fatalf("path = %q, want /api/squads/squad-123/members/role", gotPath)
}
wantBody := map[string]any{"member_id": "member-456", "member_type": "agent", "role": "reviewer"}
for k, want := range wantBody {
if gotBody[k] != want {
t.Fatalf("body[%s] = %v, want %v (full body: %#v)", k, gotBody[k], want, gotBody)
}
}
}
func TestRunSquadMemberSetRoleValidatesRequiredFlags(t *testing.T) {
cmd := newSquadMemberSetRoleTestCmd()
if err := runSquadMemberSetRole(cmd, []string{"squad-123"}); err == nil {
t.Fatal("expected missing --member-id error")
}
cmd = newSquadMemberSetRoleTestCmd()
_ = cmd.Flags().Set("member-id", "member-456")
_ = cmd.Flags().Set("member-type", "invalid")
if err := runSquadMemberSetRole(cmd, []string{"squad-123"}); err == nil {
t.Fatal("expected invalid --member-type error")
}
cmd = newSquadMemberSetRoleTestCmd()
_ = cmd.Flags().Set("member-id", "member-456")
if err := runSquadMemberSetRole(cmd, []string{"squad-123"}); err == nil {
t.Fatal("expected missing --role error")
}
}

View File

@@ -613,6 +613,7 @@ func TestInjectRuntimeConfigAvailableCommandsCoreOnly(t *testing.T) {
"multica issue status <id> <status>",
"multica issue comment add <issue-id>",
"multica issue comment add --help",
"multica squad member set-role <squad-id>",
} {
if !strings.Contains(s, want) {
t.Errorf("AGENTS.md missing core command/help text %q\n---\n%s", want, s)

View File

@@ -452,6 +452,8 @@ func buildMetaSkillContent(provider string, ctx TaskContextForEnv) string {
b.WriteString("- `multica issue metadata list <issue-id> [--output json]` — List every metadata key pinned to an issue. Empty `{}` is normal.\n")
b.WriteString("- `multica issue metadata set <issue-id> --key <k> --value <v> [--type string|number|bool]` — Pin (or overwrite) a single metadata key. The CLI auto-infers JSON primitives, so URLs and plain text are stored as strings — pass `--type number` or `--type bool` only when the semantic type matters.\n")
b.WriteString("- `multica issue metadata delete <issue-id> --key <k>` — Remove a metadata key.\n\n")
b.WriteString("### Squad maintenance\n")
b.WriteString("- `multica squad member set-role <squad-id> --member-id <id> --member-type <agent|member> --role <role> [--output json]` — Change a squad member role in place; use this instead of remove+add when only the role changes.\n\n")
if provider == "codex" {
b.WriteString("## Codex-Specific Comment Formatting\n\n")

View File

@@ -1,6 +1,7 @@
package handler
import (
"context"
"encoding/json"
"errors"
"fmt"
@@ -14,6 +15,7 @@ import (
"time"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
db "github.com/multica-ai/multica/server/pkg/db/generated"
"github.com/multica-ai/multica/server/pkg/protocol"
@@ -88,6 +90,18 @@ type SkillWithFilesResponse struct {
Files []SkillFileResponse `json:"files"`
}
type ExistingSkillIdentity struct {
ID string `json:"id"`
Name string `json:"name"`
}
func writeSkillImportDuplicateConflict(w http.ResponseWriter, existing ExistingSkillIdentity) {
writeJSON(w, http.StatusConflict, map[string]any{
"error": "a skill with this name already exists",
"existing_skill": existing,
})
}
func skillToResponse(s db.Skill) SkillResponse {
return SkillResponse{
ID: uuidToString(s.ID),
@@ -102,6 +116,20 @@ func skillToResponse(s db.Skill) SkillResponse {
}
}
func (h *Handler) existingSkillIdentityByName(ctx context.Context, workspaceID pgtype.UUID, name string) (ExistingSkillIdentity, bool, error) {
skill, err := h.Queries.GetSkillByWorkspaceAndName(ctx, db.GetSkillByWorkspaceAndNameParams{
WorkspaceID: workspaceID,
Name: name,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ExistingSkillIdentity{}, false, nil
}
return ExistingSkillIdentity{}, false, err
}
return ExistingSkillIdentity{ID: uuidToString(skill.ID), Name: skill.Name}, true, nil
}
// decodeSkillConfig decodes a JSONB skill.config blob, defaulting to {} when
// missing or unparseable so the API surface always returns a JSON object.
func decodeSkillConfig(raw []byte) any {
@@ -1657,7 +1685,11 @@ func (h *Handler) ImportSkill(w http.ResponseWriter, r *http.Request) {
})
if err != nil {
if isUniqueViolation(err) {
writeError(w, http.StatusConflict, "a skill with this name already exists")
if existing, found, findErr := h.existingSkillIdentityByName(r.Context(), workspaceUUID, imported.name); findErr == nil && found {
writeSkillImportDuplicateConflict(w, existing)
} else {
writeError(w, http.StatusConflict, "a skill with this name already exists")
}
return
}
writeError(w, http.StatusInternalServerError, "failed to create skill: "+err.Error())

View File

@@ -0,0 +1,48 @@
package handler
import (
"context"
"encoding/json"
"net/http/httptest"
"testing"
)
func TestExistingSkillIdentityByNameReturnsIDAndName(t *testing.T) {
namePrefix := "duplicate-import-identity"
name := namePrefix + "-" + t.Name()
skillID := insertHandlerTestSkill(t, namePrefix, "# Duplicate import identity")
existing, ok, err := testHandler.existingSkillIdentityByName(context.Background(), parseUUID(testWorkspaceID), name)
if err != nil {
t.Fatalf("existingSkillIdentityByName: %v", err)
}
if !ok {
t.Fatal("expected existing skill identity to be found")
}
if existing.ID != skillID || existing.Name != name {
t.Fatalf("existing skill = %#v, want id %s name %s", existing, skillID, name)
}
}
func TestWriteSkillImportDuplicateConflictIncludesExistingSkill(t *testing.T) {
w := httptest.NewRecorder()
writeSkillImportDuplicateConflict(w, ExistingSkillIdentity{ID: "skill-123", Name: "review-helper"})
if w.Code != 409 {
t.Fatalf("status = %d, want 409: %s", w.Code, w.Body.String())
}
var body map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatalf("decode body: %v", err)
}
if body["error"] != "a skill with this name already exists" {
t.Fatalf("error = %v", body["error"])
}
existing, ok := body["existing_skill"].(map[string]any)
if !ok {
t.Fatalf("existing_skill missing or wrong type: %#v", body["existing_skill"])
}
if existing["id"] != "skill-123" || existing["name"] != "review-helper" {
t.Fatalf("existing_skill = %#v", existing)
}
}

View File

@@ -4,13 +4,13 @@ import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"os"
"os/exec"
"strings"
"sync"
"time"
)
@@ -78,12 +78,8 @@ func (b *claudeBackend) Execute(ctx context.Context, prompt string, opts ExecOpt
cancel()
return nil, fmt.Errorf("claude stdin pipe: %w", err)
}
closeStdin := func() {
if stdin != nil {
_ = stdin.Close()
stdin = nil
}
}
var closeStdinOnce sync.Once
closeStdin := func() { closeStdinOnce.Do(func() { _ = stdin.Close() }) }
// Capture stderr into both the daemon log (as before) and a bounded tail
// buffer so we can include the last few KB in Result.Error when claude
// exits unexpectedly. Without the tail, an exit-code-only failure looks
@@ -97,19 +93,6 @@ func (b *claudeBackend) Execute(ctx context.Context, prompt string, opts ExecOpt
cancel()
return nil, fmt.Errorf("start claude: %w", err)
}
if err := writeClaudeInput(stdin, prompt); err != nil {
// claude almost certainly died during startup (broken pipe). The
// real reason is sitting in stderrBuf — surface it the same way the
// post-handshake error path does, otherwise the daemon log is the
// only place that knows whether it was a V8 abort, a missing native
// module, or anything else. cmd.Wait() flushes os/exec's stderr
// copy goroutine, so stderrBuf.Tail() is safe to read.
closeStdin()
cancel()
_ = cmd.Wait()
return nil, errors.New(withAgentStderr(fmt.Sprintf("write claude input: %v", err), "claude", stderrBuf.Tail()))
}
closeStdin()
b.cfg.Logger.Info("claude started", "pid", cmd.Process.Pid, "cwd", opts.Cwd, "model", opts.Model)
@@ -119,6 +102,21 @@ func (b *claudeBackend) Execute(ctx context.Context, prompt string, opts ExecOpt
msgCh := make(chan Message, 256)
resCh := make(chan Result, 1)
// writeClaudeInput runs in its own goroutine so it cannot deadlock
// against the stdout reader. With --verbose --output-format stream-json
// the CLI emits a startup banner before reading its first stdin frame;
// if nothing is draining stdout while we write the prompt, claude blocks
// writing stdout, never reads stdin, and our Write blocks until runCtx
// fires. The field symptom is "write |1: The pipe has been ended."
// surfacing exactly at the per-task timeout when the kill invalidates
// the still-blocked pipe.
writeDone := make(chan error, 1)
go func() {
err := writeClaudeInput(stdin, prompt)
closeStdin()
writeDone <- err
}()
go func() {
defer cancel()
defer close(msgCh)
@@ -165,7 +163,6 @@ func (b *claudeBackend) Execute(ctx context.Context, prompt string, opts ExecOpt
}
trySend(msgCh, Message{Type: MessageStatus, Status: "running", SessionID: sessionID})
case "result":
closeStdin()
sessionID = msg.SessionID
if msg.ResultText != "" {
output.Reset()
@@ -192,14 +189,25 @@ func (b *claudeBackend) Execute(ctx context.Context, prompt string, opts ExecOpt
// Wait for process exit
exitErr := cmd.Wait()
duration := time.Since(startTime)
// writeDone is buffered (cap 1) and the writer always sends — by the
// time cmd has exited, the prompt write has either succeeded, hit a
// broken pipe, or been unblocked by the kill that ended cmd.
writeErr := <-writeDone
if runCtx.Err() == context.DeadlineExceeded {
switch {
case runCtx.Err() == context.DeadlineExceeded:
finalStatus = "timeout"
finalError = fmt.Sprintf("claude timed out after %s", timeout)
} else if runCtx.Err() == context.Canceled {
case runCtx.Err() == context.Canceled:
finalStatus = "aborted"
finalError = "execution cancelled"
} else if exitErr != nil && finalStatus == "completed" {
case writeErr != nil && finalStatus == "completed" && sessionID == "":
// No result event landed and the prompt write failed — claude
// died before reading the prompt. Surface the write error; the
// stderr tail attached below carries the real reason.
finalStatus = "failed"
finalError = fmt.Sprintf("write claude input: %v", writeErr)
case exitErr != nil && finalStatus == "completed":
finalStatus = "failed"
finalError = fmt.Sprintf("claude exited with error: %v", exitErr)
}

View File

@@ -0,0 +1,113 @@
package agent
import (
"bufio"
"context"
"fmt"
"io"
"log/slog"
"os"
"strings"
"testing"
"time"
)
// TestMain intercepts when the test binary is re-executed as a fake
// child process by the agent backend. The fake's behavior is selected via
// CLAUDE_FAKE_MODE; absent that env var, this is a normal `go test` run.
func TestMain(m *testing.M) {
switch mode := os.Getenv("CLAUDE_FAKE_MODE"); mode {
case "":
os.Exit(m.Run())
case "startup_stdout_burst":
runFakeClaudeStartupStdoutBurst()
os.Exit(0)
default:
fmt.Fprintf(os.Stderr, "unknown CLAUDE_FAKE_MODE: %q\n", mode)
os.Exit(2)
}
}
// runFakeClaudeStartupStdoutBurst writes ~256 KiB to stdout BEFORE
// reading any byte from stdin, then drains stdin and emits a stream-json
// result. Reproduces the stdio deadlock: if the daemon writes the prompt
// to stdin before a stdout reader is running, the child blocks writing
// stdout and the daemon blocks writing stdin — neither side can progress
// until the per-task context times out and the child is killed.
func runFakeClaudeStartupStdoutBurst() {
line := strings.Repeat("x", 1020)
bw := bufio.NewWriter(os.Stdout)
for i := 0; i < 256; i++ {
if _, err := fmt.Fprintf(bw, `{"type":"log","log":{"level":"info","message":"%s"}}`+"\n", line); err != nil {
os.Exit(11)
}
}
if err := bw.Flush(); err != nil {
os.Exit(12)
}
if _, err := io.Copy(io.Discard, os.Stdin); err != nil {
os.Exit(13)
}
fmt.Println(`{"type":"result","subtype":"success","is_error":false,"session_id":"sess-deadlock","result":"done"}`)
}
// TestClaudeExecuteDoesNotDeadlockOnStartupStdoutBurst verifies that the
// claude backend drains stdout concurrently with writing the prompt to
// stdin. The buggy path serialises the two: writeClaudeInput runs before
// the reader goroutine starts, so a child that emits startup output
// before its first stdin read deadlocks both directions. Field evidence
// in the daemon log shows tasks failing exactly at the 2 h per-task
// timeout with "write |1: The pipe has been ended.", produced when
// runCtx fires, the child is killed, and the blocked stdin Write
// finally unwinds.
//
// The fake child writes 256 KiB to stdout then 128 KiB of prompt is
// pushed at stdin — both well past any plausible OS pipe buffer
// (Linux ~64 KiB, Windows 4-64 KiB) — so a regression here hangs until
// the test deadline rather than passing slowly.
func TestClaudeExecuteDoesNotDeadlockOnStartupStdoutBurst(t *testing.T) {
t.Parallel()
self, err := os.Executable()
if err != nil {
t.Fatalf("os.Executable: %v", err)
}
backend, err := New("claude", Config{
ExecutablePath: self,
Env: map[string]string{"CLAUDE_FAKE_MODE": "startup_stdout_burst"},
Logger: slog.Default(),
})
if err != nil {
t.Fatalf("new claude backend: %v", err)
}
// 128 KiB prompt forces writeClaudeInput to block until the child
// drains stdin, which the buggy code cannot reach because the reader
// goroutine hasn't started yet.
prompt := strings.Repeat("p", 128*1024)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
session, err := backend.Execute(ctx, prompt, ExecOptions{Timeout: 20 * time.Second})
if err != nil {
t.Fatalf("execute returned error: %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)
}
case <-time.After(15 * time.Second):
t.Fatal("timeout waiting for result — claude backend is deadlocked on writeClaudeInput because stdout is not being drained concurrently")
}
}