mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-05 13:29:44 +02:00
Merge remote-tracking branch 'origin/main' into agent/matt/ed345a53
This commit is contained in:
@@ -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)를 참고하세요.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)。
|
||||
|
||||
@@ -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 "..."` | 리더 에이전트가 매 턴 종료 시 기록 |
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
@@ -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 "..."` | 队长每次结束前由它自己调用 |
|
||||
|
||||
|
||||
@@ -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 (~50–200ms 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();
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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}}\"과(와) 메시지가 영구 삭제됩니다. 이 작업은 되돌릴 수 없습니다.",
|
||||
|
||||
@@ -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}}\" 及其消息会被永久删除,无法撤销。",
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
98
server/cmd/multica/cmd_skill_test.go
Normal file
98
server/cmd/multica/cmd_skill_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
107
server/cmd/multica/cmd_squad_test.go
Normal file
107
server/cmd/multica/cmd_squad_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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())
|
||||
|
||||
48
server/internal/handler/skill_import_duplicate_test.go
Normal file
48
server/internal/handler/skill_import_duplicate_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
113
server/pkg/agent/claude_deadlock_test.go
Normal file
113
server/pkg/agent/claude_deadlock_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user