fix(chat): 任务暂停不再拦截主 Agent 对话 + 移除乐观回显消除消息错乱

- 后端 chat 处理器去掉 IsPaused 的 409 拒绝(保留 IsDeleting):主 Agent
  编排会话独立于任务暂停,暂停中仍可继续对话;暂停只终止当前进行的那一轮。
- 前端 sessions-tab 删除 chatExtra 乐观回显:主 Agent 控制台改为纯服务端
  数据渲染。根治伪造 seq 撞全局 DB id 导致的 React key 冲突/跨会话内容渗漏,
  以及发送失败不回滚的幽灵气泡。发送失败改为回滚输入框内容 + toast 报错。

Claude-Session: https://claude.ai/code/session_01RVrPtpngE9T9DRJHosf6MB
This commit is contained in:
Autumn-27 committed 2026-08-20 13:09:29 -04:00
1 parent 28c1be735b
commit 0fc63ad94e
2 files changed
+20 -43

No files matched your search

+7 -7
View File
@@ -1004,7 +1004,9 @@ func (s *Server) control(w http.ResponseWriter, r *http.Request) {
case "pause":
s.engine.Pause(t.ID, agent.AbortPausedByUser)
// Main Agent turns run outside Engine's planner/worker context so they need
// their own cancellation. New turns are rejected while the task is paused.
// their own cancellation: pausing terminates the CURRENTLY running turn. New
// turns are still allowed while paused — the main-agent console is independent
// of task pause (the chat handler no longer rejects on IsPaused).
s.cancelTaskChat(t.ID, agent.AbortChatPausedWithTask)
case "resume":
s.engine.Run(s.ctx, t) // ensure loops are alive, then un-pause + nudge
@@ -3117,10 +3119,8 @@ func (s *Server) chat(w http.ResponseWriter, r *http.Request) {
writeErr(w, 409, "任务正在删除,无法发送新消息")
return
}
if s.engine.IsPaused(t.ID) {
writeErr(w, 409, "任务已暂停,无法发送新消息")
return
}
// 注意:任务暂停(paused)不拦截主 Agent 对话。主 Agent 编排会话独立于 planner/
// worker 的暂停,暂停中仍可继续对话(暂停只终止其正在进行的那一轮,见 control())。
var req struct {
Message string `json:"message"`
Attachments []chatAttachment `json:"attachments,omitempty"` // 方式1 上传的文件(路径相对任务工作目录)
@@ -3133,9 +3133,9 @@ func (s *Server) chat(w http.ResponseWriter, r *http.Request) {
// turn busy before its first activity/file write, including rule-mode turns.
// If deletion wins the race, the second barrier check rejects this request.
s.chatMu.Lock()
if s.engine.IsDeleting(t.ID) || s.engine.IsPaused(t.ID) {
if s.engine.IsDeleting(t.ID) {
s.chatMu.Unlock()
writeErr(w, 409, "任务正在删除或已暂停,无法发送新消息")
writeErr(w, 409, "任务正在删除,无法发送新消息")
return
}
if s.chatBusy[t.ID] {
@@ -359,8 +359,6 @@ export function SessionsTab({ taskId }: { taskId: string }) {
const [olderIntents, setOlderIntents] = React.useState<TaskNode[]>([]);
const [intentsHasMore, setIntentsHasMore] = React.useState(false);
const [loadingOlderIntents, setLoadingOlderIntents] = React.useState(false);
// Local-only chat messages overlaid onto the main-agent transcript.
const [chatExtra, setChatExtra] = React.useState<Activity[]>([]);
const [input, setInput] = React.useState("");
const [sending, setSending] = React.useState(false);
const [stopping, setStopping] = React.useState(false);
@@ -613,7 +611,6 @@ export function SessionsTab({ taskId }: { taskId: string }) {
// ID) resumes from the DB, so a dropped realtime link self-heals; seq-merge dedups.
React.useEffect(() => {
setStore({});
setChatExtra([]);
setSseLive(false);
setActiveId(MAIN_ID); // a stale worker id from the previous task must not leak in
snapshotRef.current = 0;
@@ -892,13 +889,11 @@ export function SessionsTab({ taskId }: { taskId: string }) {
// worker session shows only its intent's activity, led by the intent objective.
const activity = React.useMemo(() => {
const items = activeState?.items ?? [];
if (isMain) {
// chatExtra is an optimistic local echo — drop any entry the persisted (SSE)
// copy already covers so messages don't show twice after the backend round-trips.
const seen = new Set(items.map((a) => `${a.kind} ${a.summary}`));
const pending = chatExtra.filter((a) => !seen.has(`${a.kind} ${a.summary}`));
return [...items, ...pending].sort((a, b) => a.seq - b.seq);
}
// The main-agent console renders purely from server data: the human turn is
// persisted+broadcast by the backend BEFORE it returns, so it arrives over the
// same SSE stream (worker="mainagent") as every agent step — no client-side
// optimistic echo, hence no fabricated seq that could collide with real DB ids.
if (isMain) return items;
if (isPlanner || isSystem) return items;
// Worker session: the intent leads the transcript as a right-aligned "user"-style
// message (the task handed to this worker), followed by its execution steps.
@@ -918,7 +913,6 @@ export function SessionsTab({ taskId }: { taskId: string }) {
isPlanner,
isSystem,
activeState,
chatExtra,
active.id,
active.title,
active.last_activity,
@@ -1021,36 +1015,19 @@ export function SessionsTab({ taskId }: { taskId: string }) {
const text = input.trim();
const atts = attachments;
if ((!text && atts.length === 0) || sending) return;
const base = [...(store.main?.items ?? []), ...chatExtra];
const nextSeq = Math.max(0, ...base.map((a) => a.seq)) + 1;
const userMsg: Activity = {
seq: nextSeq,
worker: "mainagent",
ts: new Date().toISOString(),
kind: "user",
summary: text,
// 有附件时把 {text, attachments} 塞进 detail,乐观气泡直接渲染卡片,无需再拉取。
detail: atts.length > 0 ? JSON.stringify({ text, attachments: atts }) : undefined,
};
// optimistic echo of the human turn only; the agent's steps + final answer
// stream back live via SSE (worker="mainagent"), so we don't append the reply.
setChatExtra((prev) => [...prev, userMsg]);
// No optimistic echo: the backend persists+broadcasts the human turn before it
// returns, so it streams back over SSE (worker="mainagent") with its real DB
// seq — the transcript renders it from server data like every other step. Clear
// the composer eagerly for responsiveness; restore it if the send fails.
setInput("");
setAttachments([]);
setSending(true);
api
.chat(text, taskId, atts.length > 0 ? atts : undefined)
.catch(() => {
setChatExtra((prev) => [
...prev,
{
seq: nextSeq + 1,
worker: "mainagent",
ts: new Date().toISOString(),
kind: "text",
summary: "(发送失败,请稍后重试)",
},
]);
.catch((e) => {
setInput(text); // restore so the user doesn't lose their text / attachments
setAttachments(atts);
toast.error("发送失败:" + ((e as Error).message || "请稍后重试"));
})
.finally(() => setSending(false));
}