Compare commits

...
5 Commits
Author SHA1 Message Date
a24702a178 1 (#267)
* fix: 为 Eino agentic 路径补充 tool_call/tool_result 配对防御中间件

agentic 路径(单代理模式)缺少 toolPairReconciler 和 orphanToolPruner,
当 summarization 截断历史破坏配对后,序列化到 OpenAI API 触发 400
"insufficient tool messages following tool_calls message"。

新增 AgenticMessage 版本的 reconciler 和 pruner,与 classic 路径对齐。

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: 为 Eino agentic 路径补充 tool_call/tool_result 配对防御中间件 (#265) (#266)

agentic 路径(单代理模式)缺少 toolPairReconciler 和 orphanToolPruner,
当 summarization 截断历史破坏配对后,序列化到 OpenAI API 触发 400
"insufficient tool messages following tool_calls message"。

新增 AgenticMessage 版本的 reconciler 和 pruner,与 classic 路径对齐。

Co-authored-by: temp <temp@tempdeMacBook-Pro.local>
Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: temp <temp@tempdeMacBook-Pro.local>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 17:31:39 +08:00
8b3dc0e4d2 fix: 为 Eino agentic 路径补充 tool_call/tool_result 配对防御中间件 (#265)
agentic 路径(单代理模式)缺少 toolPairReconciler 和 orphanToolPruner,
当 summarization 截断历史破坏配对后,序列化到 OpenAI API 触发 400
"insufficient tool messages following tool_calls message"。

新增 AgenticMessage 版本的 reconciler 和 pruner,与 classic 路径对齐。

Co-authored-by: temp <temp@tempdeMacBook-Pro.local>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 17:28:29 +08:00
RuoJi6 bec2d2faf1 修复多会话任务栏抖动、会话跳回与停止失效 (#264)
* fix(web): stabilize active task ordering

* fix(chat): preserve navigation during conversation startup

* fix(tasks): guarantee hard cancellation

* fix(chat): bind navigation and hard stop targets

* fix(chat): prevent replay from reclaiming navigation
2026-08-19 13:54:30 +08:00
RuoJi6 c7cc0bc9da 修复人工审批长内容遮挡及刷新审批人状态异常 (#263)
* docs(hitl): remove stale asm_list_resources references

* fix(hitl): constrain approval layout and preserve reviewer
2026-08-19 13:31:37 +08:00
公明andCursor 24d06c5220 Fix missing results for parallel Eino tool calls.
Merge streaming tool outputs by CallID with ConcatMessages, pair same-name historical results, and FIFO-match duplicate IDs so concurrent nmap 1/2 and 2/2 stay distinct.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 00:29:12 +08:00
31 changed files with 1573 additions and 162 deletions
+93 -51
View File
@@ -1548,12 +1548,12 @@ LIMIT 1`, messageID).Scan(&terminalEvent, &terminalCreatedAt)
seenExecIDs := make(map[string]bool)
// A provider may reuse a fallback toolCallId across streaming rounds. Keep a
// FIFO per ID instead of a single index so every persisted call gets at most
// one result. Results without a stable ID are kept separate instead of being
// guessed by order; showing no link is safer than linking to the wrong tool.
// one result. ID-less results still attach to an unmatched call with the same
// tool name (parallel nmap 1/2, 2/2 often lose one ID); different tools stay
// unlinked so a leftover preview cannot steal another call's slot.
toolIndexesByCallID := make(map[string][]int)
lastMatchedToolIndexByCallID := make(map[string]int)
matchedToolIndexes := make([]bool, 0)
nextUnmatchedToolIdx := 0
for execRows.Next() {
var detailID string
var eventType string
@@ -1569,24 +1569,10 @@ LIMIT 1`, messageID).Scan(&terminalEvent, &terminalCreatedAt)
if err := json.Unmarshal([]byte(dataJSON), &payload); err != nil {
continue
}
toolName, _ := payload["toolName"].(string)
toolName = strings.TrimSpace(toolName)
toolCallID, _ := payload["toolCallId"].(string)
toolCallID = strings.TrimSpace(toolCallID)
execID, _ := payload["executionId"].(string)
execID = strings.TrimSpace(execID)
status := ""
if eventType == "tool_result" {
if success, ok := payload["success"].(bool); ok {
if success {
status = "completed"
} else {
status = "failed"
}
} else if isErr, ok := payload["isError"].(bool); ok && isErr {
status = "failed"
}
}
toolName := processDetailString(payload, "toolName")
toolCallID := processDetailString(payload, "toolCallId")
execID := processDetailString(payload, "executionId")
status := toolResultStatusFromPayload(payload, eventType)
if eventType == "tool_call" {
summary.ToolExecutions = append(summary.ToolExecutions, ProcessDetailsToolExecution{
ProcessDetailID: strings.TrimSpace(detailID),
@@ -1603,36 +1589,14 @@ LIMIT 1`, messageID).Scan(&terminalEvent, &terminalCreatedAt)
}
}
if eventType == "tool_result" {
idx := -1
if toolCallID != "" {
queue := toolIndexesByCallID[toolCallID]
for len(queue) > 0 {
candidate := queue[0]
queue = queue[1:]
if candidate >= 0 && candidate < len(matchedToolIndexes) && !matchedToolIndexes[candidate] {
idx = candidate
break
}
}
toolIndexesByCallID[toolCallID] = queue
if idx < 0 {
// Multiple persisted result events for one call (for example an
// agent-facing reduced result replacing an earlier preview) update
// that call instead of consuming an unrelated FIFO entry.
if previous, ok := lastMatchedToolIndexByCallID[toolCallID]; ok {
idx = previous
}
}
}
if idx < 0 && toolCallID != "" {
for nextUnmatchedToolIdx < len(matchedToolIndexes) && matchedToolIndexes[nextUnmatchedToolIdx] {
nextUnmatchedToolIdx++
}
if nextUnmatchedToolIdx < len(matchedToolIndexes) {
idx = nextUnmatchedToolIdx
nextUnmatchedToolIdx++
}
}
idx := matchToolExecutionIndex(
summary.ToolExecutions,
matchedToolIndexes,
toolCallID,
toolName,
toolIndexesByCallID,
lastMatchedToolIndexByCallID,
)
if idx >= 0 && idx < len(summary.ToolExecutions) {
matchedToolIndexes[idx] = true
if toolCallID != "" {
@@ -1648,6 +1612,8 @@ LIMIT 1`, messageID).Scan(&terminalEvent, &terminalCreatedAt)
summary.ToolExecutions[idx].ExecutionID = execID
if status != "" {
summary.ToolExecutions[idx].Status = status
} else {
summary.ToolExecutions[idx].Status = "completed"
}
} else {
summary.ToolExecutions = append(summary.ToolExecutions, ProcessDetailsToolExecution{
@@ -1704,6 +1670,82 @@ LIMIT 1`, messageID).Scan(&terminalEvent, &terminalCreatedAt)
return summary, nil
}
func processDetailString(payload map[string]interface{}, key string) string {
if payload == nil {
return ""
}
v, ok := payload[key]
if !ok || v == nil {
return ""
}
s := strings.TrimSpace(fmt.Sprint(v))
if s == "" || s == "<nil>" {
return ""
}
return s
}
func toolResultStatusFromPayload(payload map[string]interface{}, eventType string) string {
if eventType != "tool_result" {
return ""
}
if status := processDetailString(payload, "status"); strings.EqualFold(status, "background_running") {
return "background_running"
}
if success, ok := payload["success"].(bool); ok {
if success {
return "completed"
}
return "failed"
}
if isErr, ok := payload["isError"].(bool); ok && isErr {
return "failed"
}
return "completed"
}
func matchToolExecutionIndex(
executions []ProcessDetailsToolExecution,
matched []bool,
toolCallID, toolName string,
toolIndexesByCallID map[string][]int,
lastMatchedToolIndexByCallID map[string]int,
) int {
if toolCallID != "" {
queue := toolIndexesByCallID[toolCallID]
for len(queue) > 0 {
candidate := queue[0]
queue = queue[1:]
if candidate >= 0 && candidate < len(matched) && !matched[candidate] {
toolIndexesByCallID[toolCallID] = queue
return candidate
}
}
toolIndexesByCallID[toolCallID] = queue
if previous, ok := lastMatchedToolIndexByCallID[toolCallID]; ok {
return previous
}
}
if toolName != "" {
for i := range matched {
if matched[i] {
continue
}
if strings.EqualFold(strings.TrimSpace(executions[i].ToolName), toolName) {
return i
}
}
}
if toolCallID != "" {
for i := range matched {
if !matched[i] {
return i
}
}
}
return -1
}
// GetProcessDetailsPage 分页获取消息的过程详情(按时间升序)。
func (db *DB) GetProcessDetailsPage(messageID string, limit, offset int) ([]ProcessDetail, int, error) {
var total int
@@ -8,7 +8,7 @@ import (
"go.uber.org/zap"
)
func TestProcessDetailsSummaryDoesNotGuessIDLessResultsByOrder(t *testing.T) {
func TestProcessDetailsSummaryDoesNotGuessIDLessResultsOntoDifferentTool(t *testing.T) {
db, conversationID, messageID := setupProcessDetailsSummaryTest(t)
for _, id := range []string{"call-1", "call-2", "call-3", "call-4"} {
if err := db.AddProcessDetail(messageID, conversationID, "tool_call", "call", map[string]interface{}{
@@ -20,8 +20,8 @@ func TestProcessDetailsSummaryDoesNotGuessIDLessResultsByOrder(t *testing.T) {
results := []map[string]interface{}{
{"toolName": "http-framework-test", "toolCallId": "call-1", "success": true},
{"toolName": "http-framework-test", "toolCallId": "call-2", "success": true},
{"toolName": "http-framework-test", "success": true},
{"toolName": "http-framework-test", "success": true},
{"toolName": "other-tool", "success": true},
{"toolName": "other-tool", "success": true},
}
var resultIDs []string
for _, result := range results {
@@ -53,12 +53,71 @@ func TestProcessDetailsSummaryDoesNotGuessIDLessResultsByOrder(t *testing.T) {
}
}
for i, execution := range summary.ToolExecutions[4:] {
if execution.Status != "completed" || execution.ToolCallID != "" {
if execution.Status != "completed" || execution.ToolCallID != "" || execution.ToolName != "other-tool" {
t.Fatalf("idless result %d = %#v, want separate completed result without toolCallId", i, execution)
}
}
}
func TestProcessDetailsSummaryPairsIDLessResultsWithSameToolName(t *testing.T) {
db, conversationID, messageID := setupProcessDetailsSummaryTest(t)
for i, id := range []string{"call-1", "call-2"} {
if err := db.AddProcessDetail(messageID, conversationID, "tool_call", "call", map[string]interface{}{
"toolName": "nmap", "toolCallId": id, "index": i + 1, "total": 2,
}); err != nil {
t.Fatalf("AddProcessDetail(tool_call): %v", err)
}
}
var resultIDs []string
for i := 0; i < 2; i++ {
resultID, err := db.AddProcessDetailWithID(messageID, conversationID, "tool_result", "result", map[string]interface{}{
"toolName": "nmap", "success": true,
})
if err != nil {
t.Fatalf("AddProcessDetail(tool_result): %v", err)
}
resultIDs = append(resultIDs, resultID)
}
summary, err := db.GetProcessDetailsSummary(messageID)
if err != nil {
t.Fatalf("GetProcessDetailsSummary: %v", err)
}
if len(summary.ToolExecutions) != 2 {
t.Fatalf("tool executions = %d, want 2", len(summary.ToolExecutions))
}
for i, execution := range summary.ToolExecutions {
if execution.Status != "completed" {
t.Fatalf("execution %d status = %q, want completed", i, execution.Status)
}
if execution.ResultDetailID != resultIDs[i] {
t.Fatalf("execution %d result detail id = %q, want %q", i, execution.ResultDetailID, resultIDs[i])
}
}
}
func TestProcessDetailsSummaryPairedResultWithoutSuccessIsCompleted(t *testing.T) {
db, conversationID, messageID := setupProcessDetailsSummaryTest(t)
if err := db.AddProcessDetail(messageID, conversationID, "tool_call", "call", map[string]interface{}{
"toolName": "nmap", "toolCallId": "call-1",
}); err != nil {
t.Fatalf("AddProcessDetail(tool_call): %v", err)
}
if err := db.AddProcessDetail(messageID, conversationID, "tool_result", "result", map[string]interface{}{
"toolName": "nmap", "toolCallId": "call-1", "resultPreview": "open 22",
}); err != nil {
t.Fatalf("AddProcessDetail(tool_result): %v", err)
}
summary, err := db.GetProcessDetailsSummary(messageID)
if err != nil {
t.Fatalf("GetProcessDetailsSummary: %v", err)
}
if len(summary.ToolExecutions) != 1 || summary.ToolExecutions[0].Status != "completed" {
t.Fatalf("tool executions = %#v, want completed", summary.ToolExecutions)
}
}
func TestProcessDetailsSummaryPairsRepeatedToolCallIDsFIFO(t *testing.T) {
db, conversationID, messageID := setupProcessDetailsSummaryTest(t)
for i := 0; i < 2; i++ {
+11 -1
View File
@@ -3,6 +3,7 @@ package handler
import (
"context"
"errors"
"sort"
"strings"
"sync"
"time"
@@ -484,7 +485,10 @@ func (m *AgentTaskManager) CancelTask(conversationID string, cause error) (bool,
if runtimeCancel != nil {
runtimeHandled = runtimeCancel(cause)
}
if cancel != nil && !runtimeHandled {
// 「彻底停止」必须同时取消宿主 context:原生 Agent Cancel 即使已受理,
// 也可能只在安全点返回或报告超时,不能据此让整条任务继续存活。
// 中断并继续仍保留原语义:原生取消已处理时由运行时负责恢复。
if cancel != nil && (!runtimeHandled || errors.Is(cause, ErrTaskCancelled)) {
cancel(cause)
}
if toolCanceler != nil {
@@ -591,6 +595,12 @@ func (m *AgentTaskManager) GetActiveTasks() []*AgentTask {
Status: task.Status,
})
}
sort.Slice(result, func(i, j int) bool {
if result[i].StartedAt.Equal(result[j].StartedAt) {
return result[i].ConversationID < result[j].ConversationID
}
return result[i].StartedAt.Before(result[j].StartedAt)
})
return result
}
@@ -0,0 +1,31 @@
package handler
import (
"testing"
"time"
)
func TestGetActiveTasksUsesStableCreationOrder(t *testing.T) {
m := NewAgentTaskManager()
started := time.Date(2026, 8, 19, 10, 0, 0, 0, time.UTC)
m.mu.Lock()
m.tasks = map[string]*AgentTask{
"conversation-z": {ConversationID: "conversation-z", StartedAt: started, Status: "running"},
"conversation-late": {ConversationID: "conversation-late", StartedAt: started.Add(time.Minute), Status: "running"},
"conversation-a": {ConversationID: "conversation-a", StartedAt: started, Status: "running"},
}
m.mu.Unlock()
want := []string{"conversation-a", "conversation-z", "conversation-late"}
for attempt := 0; attempt < 20; attempt++ {
gotTasks := m.GetActiveTasks()
if len(gotTasks) != len(want) {
t.Fatalf("GetActiveTasks() length = %d, want %d", len(gotTasks), len(want))
}
for i, task := range gotTasks {
if task.ConversationID != want[i] {
t.Fatalf("attempt %d order[%d] = %q, want %q", attempt, i, task.ConversationID, want[i])
}
}
}
}
@@ -32,7 +32,7 @@ func TestCancelTaskInvokesToolCancelerOnFullStop(t *testing.T) {
}
}
func TestCancelTaskUsesAgentRuntimeCancelAsPrimaryPath(t *testing.T) {
func TestCancelTaskFullStopCancelsRuntimeAndParentContext(t *testing.T) {
tm := NewAgentTaskManager()
var order []string
tm.SetToolCanceler(func(conversationID string) {
@@ -61,7 +61,7 @@ func TestCancelTaskUsesAgentRuntimeCancelAsPrimaryPath(t *testing.T) {
if err != nil || !ok {
t.Fatalf("CancelTask: ok=%v err=%v", ok, err)
}
want := []string{"runtime", "tool"}
want := []string{"runtime", "context", "tool"}
if len(order) != len(want) {
t.Fatalf("order length got %d want %d: %#v", len(order), len(want), order)
}
@@ -72,6 +72,29 @@ func TestCancelTaskUsesAgentRuntimeCancelAsPrimaryPath(t *testing.T) {
}
}
func TestCancelTaskInterruptContinueKeepsParentWhenRuntimeHandlesIt(t *testing.T) {
tm := NewAgentTaskManager()
ctx, cancel := context.WithCancelCause(context.Background())
if _, err := tm.StartTask("conv-interrupt-native", "hello", cancel); err != nil {
t.Fatalf("StartTask: %v", err)
}
unregister := tm.BindAgentRuntimeCancel("conv-interrupt-native", func(err error) bool {
if !errors.Is(err, multiagent.ErrInterruptContinue) {
t.Fatalf("runtime cancel got %v", err)
}
return true
})
defer unregister()
ok, err := tm.CancelTask("conv-interrupt-native", multiagent.ErrInterruptContinue)
if err != nil || !ok {
t.Fatalf("CancelTask: ok=%v err=%v", ok, err)
}
if cause := context.Cause(ctx); cause != nil {
t.Fatalf("interrupt-continue parent context cause = %v, want nil", cause)
}
}
func TestCancelTaskFallsBackToContextWhenAgentRuntimeCancelMisses(t *testing.T) {
tm := NewAgentTaskManager()
var order []string
@@ -0,0 +1,126 @@
package multiagent
import (
"context"
"github.com/cloudwego/eino/adk"
"github.com/cloudwego/eino/schema"
"go.uber.org/zap"
)
// agenticOrphanToolPrunerMiddleware is the AgenticMessage equivalent of
// orphanToolPrunerMiddleware. It removes user-role messages whose content
// blocks are exclusively FunctionToolResult entries with CallIDs that do not
// match any FunctionToolCall in the history.
//
// This is a defense-in-depth layer after agenticToolPairReconcilerMiddleware;
// the reconciler handles the common case (assistant followed by its results)
// while this pruner catches stray results that appear before their assistant
// or in non-adjacent positions (e.g. after summarization rewriting).
type agenticOrphanToolPrunerMiddleware struct {
*adk.TypedBaseChatModelAgentMiddleware[*schema.AgenticMessage]
logger *zap.Logger
phase string
}
func newAgenticOrphanToolPrunerMiddleware(logger *zap.Logger, phase string) adk.TypedChatModelAgentMiddleware[*schema.AgenticMessage] {
return &agenticOrphanToolPrunerMiddleware{
TypedBaseChatModelAgentMiddleware: &adk.TypedBaseChatModelAgentMiddleware[*schema.AgenticMessage]{},
logger: logger,
phase: phase,
}
}
func (m *agenticOrphanToolPrunerMiddleware) BeforeModelRewriteState(
ctx context.Context,
state *adk.TypedChatModelAgentState[*schema.AgenticMessage],
mc *adk.TypedModelContext[*schema.AgenticMessage],
) (context.Context, *adk.TypedChatModelAgentState[*schema.AgenticMessage], error) {
_ = mc
if m == nil || state == nil || len(state.Messages) == 0 {
return ctx, state, nil
}
// Pass 1: collect all provided CallIDs from assistant FunctionToolCall blocks.
provided := make(map[string]struct{}, 8)
for _, msg := range state.Messages {
if msg == nil || msg.Role != schema.AgenticRoleTypeAssistant {
continue
}
for _, block := range msg.ContentBlocks {
if block != nil && block.FunctionToolCall != nil && block.FunctionToolCall.CallID != "" {
provided[block.FunctionToolCall.CallID] = struct{}{}
}
}
}
// Fast path: check if any orphan exists.
hasOrphan := false
for _, msg := range state.Messages {
if msg == nil || !isPureAgenticToolResult(msg) {
continue
}
for _, id := range agenticToolResultCallIDs(msg) {
if _, ok := provided[id]; !ok {
hasOrphan = true
break
}
}
if hasOrphan {
break
}
}
if !hasOrphan {
return ctx, state, nil
}
// Pass 2: build pruned list.
pruned := make([]*schema.AgenticMessage, 0, len(state.Messages))
var droppedIDs []string
var droppedNames []string
for _, msg := range state.Messages {
if msg == nil {
continue
}
if !isPureAgenticToolResult(msg) {
pruned = append(pruned, msg)
continue
}
// Check if ALL result call IDs are orphans. If any is matched, keep the
// message (the reconciler already handled partial mismatches).
allOrphan := true
for _, id := range agenticToolResultCallIDs(msg) {
if _, ok := provided[id]; ok {
allOrphan = false
break
}
}
if allOrphan {
for _, block := range msg.ContentBlocks {
if block != nil && block.FunctionToolResult != nil {
droppedIDs = append(droppedIDs, block.FunctionToolResult.CallID)
droppedNames = append(droppedNames, block.FunctionToolResult.Name)
}
}
continue
}
pruned = append(pruned, msg)
}
if len(droppedIDs) == 0 {
return ctx, state, nil
}
if m.logger != nil {
m.logger.Warn("agentic orphan tool messages pruned before model call",
zap.String("phase", m.phase),
zap.Int("dropped_count", len(droppedIDs)),
zap.Strings("dropped_tool_call_ids", droppedIDs),
zap.Strings("dropped_tool_names", droppedNames),
zap.Int("messages_before", len(state.Messages)),
zap.Int("messages_after", len(pruned)),
)
}
ns := *state
ns.Messages = pruned
return ctx, &ns, nil
}
@@ -0,0 +1,247 @@
package multiagent
import (
"context"
"fmt"
"github.com/cloudwego/eino/adk"
"github.com/cloudwego/eino/schema"
"go.uber.org/zap"
)
// agenticToolPairReconcilerMiddleware is the AgenticMessage equivalent of
// toolPairReconcilerMiddleware. It ensures every assistant FunctionToolCall
// block is followed by a matching FunctionToolResult message, patching or
// dropping as needed so the downstream model never receives an unpaired
// tool-call history.
//
// In the AgenticMessage protocol:
// - Assistant tool calls: Role=AgenticRoleTypeAssistant with FunctionToolCall content blocks.
// - Tool results: Role=AgenticRoleTypeUser with FunctionToolResult content blocks.
//
// This middleware runs after summarization which may truncate history and
// break pairings.
type agenticToolPairReconcilerMiddleware struct {
*adk.TypedBaseChatModelAgentMiddleware[*schema.AgenticMessage]
logger *zap.Logger
phase string
}
func newAgenticToolPairReconcilerMiddleware(logger *zap.Logger, phase string) adk.TypedChatModelAgentMiddleware[*schema.AgenticMessage] {
return &agenticToolPairReconcilerMiddleware{
TypedBaseChatModelAgentMiddleware: &adk.TypedBaseChatModelAgentMiddleware[*schema.AgenticMessage]{},
logger: logger,
phase: phase,
}
}
func (m *agenticToolPairReconcilerMiddleware) BeforeModelRewriteState(
ctx context.Context,
state *adk.TypedChatModelAgentState[*schema.AgenticMessage],
mc *adk.TypedModelContext[*schema.AgenticMessage],
) (context.Context, *adk.TypedChatModelAgentState[*schema.AgenticMessage], error) {
_ = mc
if m == nil || state == nil || len(state.Messages) == 0 {
return ctx, state, nil
}
usedIDs := make(map[string]struct{}, 16)
changed := false
patched := 0
dropped := 0
out := make([]*schema.AgenticMessage, 0, len(state.Messages))
for i := 0; i < len(state.Messages); {
msg := state.Messages[i]
if msg == nil {
changed = true
i++
continue
}
calls := agenticFunctionToolCalls(msg)
// Non-assistant or assistant without tool calls — but check for orphan
// tool-result messages (user role with only FunctionToolResult blocks).
if len(calls) == 0 {
if isPureAgenticToolResult(msg) {
// Orphan tool result not preceded by its assistant; drop it.
changed = true
dropped++
i++
continue
}
out = append(out, msg)
i++
continue
}
// Deduplicate / fix empty call IDs.
idsChanged := false
for ci := range calls {
id := calls[ci].CallID
_, duplicate := usedIDs[id]
if id == "" || duplicate {
base := fmt.Sprintf("patched_agentic_call_%d_%d", i, ci)
id = base
for suffix := 1; ; suffix++ {
if _, exists := usedIDs[id]; !exists {
break
}
id = fmt.Sprintf("%s_%d", base, suffix)
}
calls[ci].CallID = id
idsChanged = true
changed = true
}
usedIDs[id] = struct{}{}
}
assistant := msg
if idsChanged {
assistant = cloneAgenticMessageWithCalls(msg, calls)
}
out = append(out, assistant)
// Build expected set.
expected := make(map[string]*schema.FunctionToolCall, len(calls))
for ci := range calls {
expected[calls[ci].CallID] = calls[ci]
}
// Consume following tool-result messages.
results := make(map[string]*schema.AgenticMessage, len(calls))
j := i + 1
for j < len(state.Messages) {
next := state.Messages[j]
if next == nil {
changed = true
j++
continue
}
if !isPureAgenticToolResult(next) {
break
}
resultCallIDs := agenticToolResultCallIDs(next)
consumed := false
for _, rid := range resultCallIDs {
if _, wanted := expected[rid]; !wanted {
continue
}
if _, dup := results[rid]; dup {
continue
}
results[rid] = next
consumed = true
}
if !consumed {
changed = true
dropped++
}
j++
}
// Emit results in call order, patching missing ones.
for _, tc := range calls {
if result, ok := results[tc.CallID]; ok {
out = append(out, result)
continue
}
out = append(out, makeAgenticPatchedToolResult(tc.CallID, tc.Name))
changed = true
patched++
}
i = j
}
if !changed {
return ctx, state, nil
}
if m.logger != nil {
m.logger.Warn("agentic tool-call/result pairs reconciled before model call",
zap.String("phase", m.phase),
zap.Int("patched_results", patched),
zap.Int("dropped_results", dropped),
zap.Int("messages_before", len(state.Messages)),
zap.Int("messages_after", len(out)),
)
}
ns := *state
ns.Messages = out
return ctx, &ns, nil
}
// agenticFunctionToolCalls extracts FunctionToolCall pointers from an
// assistant message's content blocks. Returns nil for non-assistant messages.
func agenticFunctionToolCalls(msg *schema.AgenticMessage) []*schema.FunctionToolCall {
if msg == nil || msg.Role != schema.AgenticRoleTypeAssistant {
return nil
}
var out []*schema.FunctionToolCall
for _, block := range msg.ContentBlocks {
if block != nil && block.FunctionToolCall != nil {
out = append(out, block.FunctionToolCall)
}
}
return out
}
// isPureAgenticToolResult returns true when the message is a user-role
// message whose content blocks are exclusively FunctionToolResult entries.
func isPureAgenticToolResult(msg *schema.AgenticMessage) bool {
if msg == nil || msg.Role != schema.AgenticRoleTypeUser || len(msg.ContentBlocks) == 0 {
return false
}
for _, block := range msg.ContentBlocks {
if block == nil {
continue
}
if block.FunctionToolResult == nil {
return false
}
}
return true
}
// agenticToolResultCallIDs extracts all CallIDs from FunctionToolResult blocks.
func agenticToolResultCallIDs(msg *schema.AgenticMessage) []string {
if msg == nil {
return nil
}
var ids []string
for _, block := range msg.ContentBlocks {
if block != nil && block.FunctionToolResult != nil && block.FunctionToolResult.CallID != "" {
ids = append(ids, block.FunctionToolResult.CallID)
}
}
return ids
}
func cloneAgenticMessageWithCalls(msg *schema.AgenticMessage, calls []*schema.FunctionToolCall) *schema.AgenticMessage {
cloned := *msg
cloned.ContentBlocks = make([]*schema.ContentBlock, 0, len(msg.ContentBlocks))
callIdx := 0
for _, block := range msg.ContentBlocks {
if block != nil && block.FunctionToolCall != nil && callIdx < len(calls) {
cloned.ContentBlocks = append(cloned.ContentBlocks, schema.NewContentBlock(calls[callIdx]))
callIdx++
} else {
cloned.ContentBlocks = append(cloned.ContentBlocks, block)
}
}
return &cloned
}
func makeAgenticPatchedToolResult(callID, name string) *schema.AgenticMessage {
return &schema.AgenticMessage{
Role: schema.AgenticRoleTypeUser,
ContentBlocks: []*schema.ContentBlock{schema.NewContentBlock(&schema.FunctionToolResult{
CallID: callID,
Name: name,
Content: []*schema.FunctionToolResultContentBlock{{
Type: schema.FunctionToolResultContentBlockTypeText,
Text: &schema.UserInputText{Text: patchedMissingToolResult},
}},
})},
}
}
@@ -0,0 +1,157 @@
package multiagent
import (
"context"
"testing"
"github.com/cloudwego/eino/adk"
"github.com/cloudwego/eino/schema"
)
func TestAgenticToolPairReconcilerPatchesMissing(t *testing.T) {
t.Parallel()
mw := newAgenticToolPairReconcilerMiddleware(nil, "test")
state := &adk.TypedChatModelAgentState[*schema.AgenticMessage]{
Messages: []*schema.AgenticMessage{
agenticAssistantToolCall("c1", "search", `{"q":"x"}`),
agenticAssistantToolCall("c2", "execute", `{"cmd":"ls"}`),
// c1 result present, c2 missing
agenticToolResult("c1", "search", "found it"),
},
}
_, out, err := mw.BeforeModelRewriteState(context.Background(), state, nil)
if err != nil {
t.Fatal(err)
}
// Expected: assistant(c1) -> result(c1) -> assistant(c2) -> patched_result(c2)
if len(out.Messages) != 4 {
t.Fatalf("messages = %d, want 4", len(out.Messages))
}
// c1 assistant
if calls := agenticFunctionToolCalls(out.Messages[0]); len(calls) != 1 || calls[0].CallID != "c1" {
t.Fatal("msg[0] should be assistant(c1)")
}
// c1 result
if ids := agenticToolResultCallIDs(out.Messages[1]); len(ids) != 1 || ids[0] != "c1" {
t.Fatal("msg[1] should be result(c1)")
}
// c2 assistant
if calls := agenticFunctionToolCalls(out.Messages[2]); len(calls) != 1 || calls[0].CallID != "c2" {
t.Fatal("msg[2] should be assistant(c2)")
}
// c2 patched result
if ids := agenticToolResultCallIDs(out.Messages[3]); len(ids) != 1 || ids[0] != "c2" {
t.Fatal("msg[3] should be patched result(c2)")
}
resultText := out.Messages[3].ContentBlocks[0].FunctionToolResult.Content[0].Text.Text
if resultText != patchedMissingToolResult {
t.Fatalf("patched text = %q", resultText)
}
}
func TestAgenticToolPairReconcilerDropsOrphan(t *testing.T) {
t.Parallel()
mw := newAgenticToolPairReconcilerMiddleware(nil, "test")
state := &adk.TypedChatModelAgentState[*schema.AgenticMessage]{
Messages: []*schema.AgenticMessage{
// Orphan tool result with no preceding assistant
agenticToolResult("orphan", "deleted_tool", "stale data"),
{Role: schema.AgenticRoleTypeUser, ContentBlocks: []*schema.ContentBlock{
schema.NewContentBlock(&schema.UserInputText{Text: "hello"}),
}},
},
}
_, out, err := mw.BeforeModelRewriteState(context.Background(), state, nil)
if err != nil {
t.Fatal(err)
}
if len(out.Messages) != 1 {
t.Fatalf("messages = %d, want 1 (orphan dropped)", len(out.Messages))
}
if out.Messages[0].ContentBlocks[0].UserInputText == nil {
t.Fatal("remaining message should be the user text")
}
}
func TestAgenticToolPairReconcilerNoopWhenPaired(t *testing.T) {
t.Parallel()
mw := newAgenticToolPairReconcilerMiddleware(nil, "test")
state := &adk.TypedChatModelAgentState[*schema.AgenticMessage]{
Messages: []*schema.AgenticMessage{
agenticAssistantToolCall("c1", "search", `{}`),
agenticToolResult("c1", "search", "ok"),
},
}
_, out, err := mw.BeforeModelRewriteState(context.Background(), state, nil)
if err != nil {
t.Fatal(err)
}
// Should return original state unchanged
if &out.Messages[0] == &state.Messages[0] {
// pointer equality on slice — state not cloned
}
if len(out.Messages) != 2 {
t.Fatalf("messages = %d, want 2", len(out.Messages))
}
}
func TestAgenticToolPairReconcilerFixesEmptyCallID(t *testing.T) {
t.Parallel()
mw := newAgenticToolPairReconcilerMiddleware(nil, "test")
state := &adk.TypedChatModelAgentState[*schema.AgenticMessage]{
Messages: []*schema.AgenticMessage{
{
Role: schema.AgenticRoleTypeAssistant,
ContentBlocks: []*schema.ContentBlock{schema.NewContentBlock(&schema.FunctionToolCall{
CallID: "", Name: "search", Arguments: `{}`,
})},
},
},
}
_, out, err := mw.BeforeModelRewriteState(context.Background(), state, nil)
if err != nil {
t.Fatal(err)
}
calls := agenticFunctionToolCalls(out.Messages[0])
if len(calls) != 1 || calls[0].CallID == "" {
t.Fatalf("empty call ID should be patched, got %q", calls[0].CallID)
}
}
func TestAgenticOrphanToolPrunerRemovesOrphan(t *testing.T) {
t.Parallel()
mw := newAgenticOrphanToolPrunerMiddleware(nil, "test")
state := &adk.TypedChatModelAgentState[*schema.AgenticMessage]{
Messages: []*schema.AgenticMessage{
agenticAssistantToolCall("c1", "search", `{}`),
agenticToolResult("c1", "search", "ok"),
// Orphan: no assistant has call_id "c_orphan"
agenticToolResult("c_orphan", "deleted", "stale"),
},
}
_, out, err := mw.BeforeModelRewriteState(context.Background(), state, nil)
if err != nil {
t.Fatal(err)
}
if len(out.Messages) != 2 {
t.Fatalf("messages = %d, want 2 (orphan pruned)", len(out.Messages))
}
}
func TestAgenticOrphanToolPrunerNoopWhenClean(t *testing.T) {
t.Parallel()
mw := newAgenticOrphanToolPrunerMiddleware(nil, "test")
state := &adk.TypedChatModelAgentState[*schema.AgenticMessage]{
Messages: []*schema.AgenticMessage{
agenticAssistantToolCall("c1", "search", `{}`),
agenticToolResult("c1", "search", "ok"),
},
}
_, out, err := mw.BeforeModelRewriteState(context.Background(), state, nil)
if err != nil {
t.Fatal(err)
}
if len(out.Messages) != 2 {
t.Fatalf("messages = %d, want 2", len(out.Messages))
}
}
+29 -10
View File
@@ -443,22 +443,41 @@ func nextAgentEventWithContext(ctx context.Context, iter *adk.AsyncIterator[*adk
// recvSchemaMessageStream 消费 ADK Tool 流式结果;ctx 取消时立即返回,避免 amass 等无输出时永久阻塞。
func recvSchemaMessageStream(ctx context.Context, stream *schema.StreamReader[*schema.Message]) (content, toolCallID, toolName string, recvErr error) {
if stream == nil {
return "", "", "", nil
msgs, recvErr := recvSchemaToolResultMessages(ctx, stream)
if len(msgs) == 0 {
return "", "", "", recvErr
}
var buf strings.Builder
recvErr = recvEinoSchemaMessageStreamWithContext(ctx, stream, 8, func(chunk *schema.Message) {
if chunk.Content != "" {
buf.WriteString(chunk.Content)
parts := make([]string, 0, len(msgs))
for _, msg := range msgs {
if msg == nil {
continue
}
if tid := strings.TrimSpace(chunk.ToolCallID); tid != "" {
toolCallID = tid
parts = append(parts, msg.Content)
if id := strings.TrimSpace(msg.ToolCallID); id != "" {
toolCallID = id
}
if name := strings.TrimSpace(chunk.ToolName); name != "" {
if name := strings.TrimSpace(msg.ToolName); name != "" {
toolName = name
}
}
return strings.Join(parts, ""), toolCallID, toolName, recvErr
}
// recvSchemaToolResultMessages 先收齐 Tool 流,再用 Eino ConcatMessages 合并。
// EventSender 一 call 一条流时走 ConcatMessages;并行结果被摊平进同一条流时按 CallID 分列再合并。
func recvSchemaToolResultMessages(ctx context.Context, stream *schema.StreamReader[*schema.Message]) (msgs []*schema.Message, recvErr error) {
if stream == nil {
return nil, nil
}
var chunks []*schema.Message
recvErr = recvEinoSchemaMessageStreamWithContext(ctx, stream, 8, func(chunk *schema.Message) {
chunks = append(chunks, chunk)
})
return buf.String(), toolCallID, toolName, recvErr
msgs, concatErr := concatToolResultChunks(chunks)
if concatErr != nil && recvErr == nil {
return nil, concatErr
}
return msgs, recvErr
}
func buildEinoCheckpointID(orchMode string) string {
@@ -30,6 +30,29 @@ func TestRecvSchemaMessageStream_EOF(t *testing.T) {
}
}
func TestRecvSchemaToolResultMessages_SplitsParallelIDs(t *testing.T) {
sr, sw := schema.Pipe[*schema.Message](8)
_ = sw.Send(schema.ToolMessage("one-", "tc-1", schema.WithToolName("nmap")), nil)
_ = sw.Send(schema.ToolMessage("two-", "tc-2", schema.WithToolName("nmap")), nil)
_ = sw.Send(schema.ToolMessage("a", "tc-1", schema.WithToolName("nmap")), nil)
_ = sw.Send(schema.ToolMessage("b", "tc-2", schema.WithToolName("nmap")), nil)
sw.Close()
msgs, err := recvSchemaToolResultMessages(context.Background(), sr)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if len(msgs) != 2 {
t.Fatalf("msgs = %#v, want 2", msgs)
}
if msgs[0].ToolCallID != "tc-1" || msgs[0].Content != "one-a" {
t.Fatalf("msg 0 = %#v", msgs[0])
}
if msgs[1].ToolCallID != "tc-2" || msgs[1].Content != "two-b" {
t.Fatalf("msg 1 = %#v", msgs[1])
}
}
func TestRecvSchemaMessageStream_CapturesToolName(t *testing.T) {
sr, sw := schema.Pipe[*schema.Message](4)
_ = sw.Send(schema.ToolMessage("hello", "tc-1", schema.WithToolName("execute")), nil)
@@ -20,8 +20,13 @@ func appendEinoAgenticChatModelTailMiddlewares(
handlers = append(handlers, newAgenticSystemMessageNormalizerMiddleware(cfg.logger, cfg.phase))
handlers = append(handlers, newAgenticContinuationUserDedupMiddleware(cfg.logger, cfg.phase))
if cfg.agenticSummarization != nil {
handlers = append(handlers, newAgenticToolPairReconcilerMiddleware(cfg.logger, cfg.phase+"_pre_summarization"))
handlers = append(handlers, cfg.agenticSummarization)
}
handlers = append(handlers, newAgenticToolPairReconcilerMiddleware(cfg.logger, cfg.phase))
if !cfg.skipOrphanPruner {
handlers = append(handlers, newAgenticOrphanToolPrunerMiddleware(cfg.logger, cfg.phase))
}
if !cfg.skipTrace && cfg.trace != nil {
if capMw := newAgenticModelFacingTraceMiddleware(cfg.trace); capMw != nil {
handlers = append(handlers, capMw)
@@ -106,7 +106,8 @@ func TestAppendEinoAgenticChatModelTailMiddlewares(t *testing.T) {
phase: "agentic",
trace: holder,
})
if len(handlers) != 3 {
t.Fatalf("handlers = %d, want system + continuation + trace", len(handlers))
// system + continuation + reconciler + orphan_pruner + trace
if len(handlers) != 5 {
t.Fatalf("handlers = %d, want system + continuation + reconciler + orphan_pruner + trace", len(handlers))
}
}
@@ -31,6 +31,11 @@ func adaptAgenticEventToEinoEvents(ev *adk.TypedAgentEvent[*schema.AgenticMessag
return []*adk.AgentEvent{base(&adk.AgentOutput{CustomizedOutput: customized})}
}
if mv.IsStreaming {
// Tool 流保持 1 event ↔ 1 MessageStream,对齐 ADK EventSenderToolWrapper
// 每个 CallID 在工具包装层就已经是独立事件。这里不能再按 CallID 现场拆成
// 多条 live pipe——drain 会阻塞读完当前流,交错的并行 chunk 会把另一列写满后死锁。
// 若上游仍把 ToolsNode 的 MergeStreamReaders 摊成一条流,由
// concatToolResultChunks 按列 ConcatMessages 恢复。
return []*adk.AgentEvent{base(&adk.AgentOutput{
MessageOutput: &adk.MessageVariant{
IsStreaming: true,
@@ -98,6 +98,39 @@ func TestEinoRunProgressTrackerDedupesToolCalls(t *testing.T) {
}
}
func TestEinoRunProgressTrackerDedupesSameToolCallIDsWithDifferentArgs(t *testing.T) {
var toolCalls int
progress := func(eventType, _ string, _ interface{}) {
if eventType == "tool_call" {
toolCalls++
}
}
tracker := newEinoRunProgressTracker("deep", "lead", "conv-1", progress, nil, nil)
first := &schema.Message{ToolCalls: []schema.ToolCall{{
ID: "call-1",
Type: "function",
Function: schema.FunctionCall{
Name: "nmap",
Arguments: `{"host":"10.0.0.1"}`,
},
}}}
second := &schema.Message{ToolCalls: []schema.ToolCall{{
ID: "call-1",
Type: "function",
Function: schema.FunctionCall{
Name: "nmap",
Arguments: `{"host":"10.0.0.1","ports":"1-1024"}`,
},
}}}
tracker.EmitToolCalls(first, "lead", nil)
tracker.EmitToolCalls(second, "lead", nil)
if toolCalls != 1 {
t.Fatalf("tool call events = %d, want 1", toolCalls)
}
}
func TestEinoRunProgressTrackerHidesModelOutputRecoveryToolCalls(t *testing.T) {
var eventTypes []string
var marked []toolCallPendingInfo
@@ -0,0 +1,91 @@
package multiagent
import (
"fmt"
"strings"
"github.com/cloudwego/eino/schema"
)
// concatToolResultChunks 按 Eino 原生语义合并工具结果流:
// - 同一 CallIDEventSender 一 call 一 event):schema.ConcatMessages
// - 并行工具被摊进同一条流(ToolsNode MergeStreamReaders 扁平化后):
// 按 CallID 分列后再 ConcatMessages,等价于 schema.ConcatMessageArray
func concatToolResultChunks(chunks []*schema.Message) ([]*schema.Message, error) {
if len(chunks) == 0 {
return nil, nil
}
if toolResultChunksShareCallID(chunks) {
merged, err := schema.ConcatMessages(chunks)
if err != nil {
return nil, err
}
return []*schema.Message{merged}, nil
}
return concatToolResultChunksByCallID(chunks)
}
func toolResultChunksShareCallID(chunks []*schema.Message) bool {
id := ""
for _, chunk := range chunks {
if chunk == nil {
continue
}
got := strings.TrimSpace(chunk.ToolCallID)
if got == "" {
continue
}
if id == "" {
id = got
continue
}
if got != id {
return false
}
}
return true
}
func concatToolResultChunksByCallID(chunks []*schema.Message) ([]*schema.Message, error) {
type column struct {
key string
chunks []*schema.Message
}
var ordered []column
index := make(map[string]int)
lastKey := ""
anon := 0
for _, chunk := range chunks {
if chunk == nil {
continue
}
key := strings.TrimSpace(chunk.ToolCallID)
if key == "" {
if lastKey != "" {
key = lastKey
} else {
key = fmt.Sprintf("\x00anon-%d", anon)
anon++
}
}
if idx, ok := index[key]; ok {
ordered[idx].chunks = append(ordered[idx].chunks, chunk)
} else {
index[key] = len(ordered)
ordered = append(ordered, column{key: key, chunks: []*schema.Message{chunk}})
}
lastKey = key
}
out := make([]*schema.Message, 0, len(ordered))
for _, col := range ordered {
merged, err := schema.ConcatMessages(col.chunks)
if err != nil {
return nil, err
}
if strings.HasPrefix(col.key, "\x00anon-") {
merged.ToolCallID = ""
}
out = append(out, merged)
}
return out, nil
}
@@ -0,0 +1,41 @@
package multiagent
import (
"testing"
"github.com/cloudwego/eino/schema"
)
func TestConcatToolResultChunksUsesEinoConcatForSingleCall(t *testing.T) {
got, err := concatToolResultChunks([]*schema.Message{
schema.ToolMessage("hel", "call-1", schema.WithToolName("execute")),
schema.ToolMessage("lo", "call-1", schema.WithToolName("execute")),
})
if err != nil {
t.Fatalf("concat: %v", err)
}
if len(got) != 1 || got[0].ToolCallID != "call-1" || got[0].Content != "hello" || got[0].ToolName != "execute" {
t.Fatalf("got = %#v, want one ConcatMessages result", got)
}
}
func TestConcatToolResultChunksSplitsParallelCalls(t *testing.T) {
got, err := concatToolResultChunks([]*schema.Message{
schema.ToolMessage("nmap 1/2 ", "call-1", schema.WithToolName("nmap")),
schema.ToolMessage("nmap 2/2 ", "call-2", schema.WithToolName("nmap")),
schema.ToolMessage("22/tcp", "call-1", schema.WithToolName("nmap")),
schema.ToolMessage("80/tcp", "call-2", schema.WithToolName("nmap")),
})
if err != nil {
t.Fatalf("concat: %v", err)
}
if len(got) != 2 {
t.Fatalf("got = %#v, want two calls", got)
}
if got[0].ToolCallID != "call-1" || got[0].Content != "nmap 1/2 22/tcp" {
t.Fatalf("call-1 = %#v", got[0])
}
if got[1].ToolCallID != "call-2" || got[1].Content != "nmap 2/2 80/tcp" {
t.Fatalf("call-2 = %#v", got[1])
}
}
@@ -42,27 +42,42 @@ func (h *einoToolResultEventHandler) HandleStreaming(mv *adk.MessageVariant, age
if h == nil || mv == nil || !mv.IsStreaming || mv.MessageStream == nil || mv.Role != schema.Tool {
return false
}
toolName := strings.TrimSpace(mv.ToolName)
content, streamToolCallID, streamToolName, recvErr := recvSchemaMessageStream(h.ctx, mv.MessageStream)
if toolName == "" {
toolName = streamToolName
defaultName := strings.TrimSpace(mv.ToolName)
msgs, recvErr := recvSchemaToolResultMessages(h.ctx, mv.MessageStream)
if isEinoVoluntaryCancelErr(recvErr) && len(msgs) == 0 {
msgs = []*schema.Message{schema.ToolMessage("已中断并继续,当前工具调用已停止。", "", schema.WithToolName(defaultName))}
}
if isEinoVoluntaryCancelErr(recvErr) && strings.TrimSpace(content) == "" {
content = "已中断并继续,当前工具调用已停止。"
if len(msgs) == 0 {
msgs = []*schema.Message{schema.ToolMessage("", "", schema.WithToolName(defaultName))}
}
isErr := einoToolResultIsError(toolName, content) || isEinoVoluntaryCancelErr(recvErr)
content = einoToolResultBody(content)
if streamToolCallID != "" && h.runMessages != nil {
h.runMessages.AppendToolMessage(content, streamToolCallID, schema.WithToolName(toolName))
}
if h.emitter != nil {
h.emitter.Emit(h.ctx, toolName, content, streamToolCallID, isErr, agentName)
}
if recvErr != nil && !isEinoVoluntaryCancelErr(recvErr) && h.logger != nil {
h.logger.Warn("eino tool result stream recv error",
zap.Error(recvErr),
zap.String("agent", agentName),
zap.String("tool", toolName))
for _, msg := range msgs {
if msg == nil {
continue
}
toolName := strings.TrimSpace(msg.ToolName)
if toolName == "" {
toolName = defaultName
}
content := msg.Content
if isEinoVoluntaryCancelErr(recvErr) && strings.TrimSpace(content) == "" {
content = "已中断并继续,当前工具调用已停止。"
}
isErr := einoToolResultIsError(toolName, content) || isEinoVoluntaryCancelErr(recvErr)
content = einoToolResultBody(content)
toolCallID := strings.TrimSpace(msg.ToolCallID)
if toolCallID != "" && h.runMessages != nil {
h.runMessages.AppendToolMessage(content, toolCallID, schema.WithToolName(toolName))
}
if h.emitter != nil {
h.emitter.Emit(h.ctx, toolName, content, toolCallID, isErr, agentName)
}
if recvErr != nil && !isEinoVoluntaryCancelErr(recvErr) && h.logger != nil {
h.logger.Warn("eino tool result stream recv error",
zap.Error(recvErr),
zap.String("agent", agentName),
zap.String("tool", toolName),
zap.String("toolCallId", toolCallID))
}
}
if recvErr == nil && h.confirmRecovery != nil {
h.confirmRecovery()
@@ -59,6 +59,54 @@ func TestEinoToolResultEventHandlerHandlesStreamingToolResult(t *testing.T) {
}
}
func TestEinoToolResultEventHandlerSplitsParallelStreamingResults(t *testing.T) {
var events []map[string]interface{}
runMessages := newEinoRunMessageAccumulator(nil)
emitter := newEinoToolResultProgressEmitter(einoToolResultProgressEmitterConfig{
ConversationID: "conv-1",
Progress: func(eventType, _ string, data interface{}) {
if eventType != "tool_result" {
return
}
m, _ := data.(map[string]interface{})
events = append(events, m)
},
})
handler := newEinoToolResultEventHandler(einoToolResultEventHandlerConfig{
RunMessages: runMessages,
Emitter: emitter,
})
stream := schema.StreamReaderFromArray([]*schema.Message{
{Role: schema.Tool, Content: "nmap 1/2 start ", ToolCallID: "call-1", ToolName: "nmap"},
{Role: schema.Tool, Content: "nmap 2/2 start ", ToolCallID: "call-2", ToolName: "nmap"},
{Role: schema.Tool, Content: "22/tcp open", ToolCallID: "call-1", ToolName: "nmap"},
{Role: schema.Tool, Content: "80/tcp open", ToolCallID: "call-2", ToolName: "nmap"},
})
mv := &adk.MessageVariant{
IsStreaming: true,
Role: schema.Tool,
ToolName: "nmap",
MessageStream: stream,
}
if !handler.HandleStreaming(mv, "worker") {
t.Fatal("streaming tool result was not handled")
}
if len(events) != 2 {
t.Fatalf("events = %#v, want two tool_result", events)
}
if events[0]["toolCallId"] != "call-1" || events[0]["result"] != "nmap 1/2 start 22/tcp open" {
t.Fatalf("first event = %#v", events[0])
}
if events[1]["toolCallId"] != "call-2" || events[1]["result"] != "nmap 2/2 start 80/tcp open" {
t.Fatalf("second event = %#v", events[1])
}
msgs := runMessages.Messages()
if len(msgs) != 2 || msgs[0].ToolCallID != "call-1" || msgs[1].ToolCallID != "call-2" {
t.Fatalf("run messages = %#v", msgs)
}
}
func TestEinoToolResultEventHandlerHandlesMaterializedToolResult(t *testing.T) {
var event map[string]interface{}
emitter := newEinoToolResultProgressEmitter(einoToolResultProgressEmitterConfig{
+27
View File
@@ -908,10 +908,37 @@ func tryEmitToolCallsOnce(
if _, ok := seen[sig]; ok {
return
}
if idSig := toolCallsStableIDSignature(msg); idSig != "" {
idKey := agentName + "\x1eids\x1e" + idSig
if _, ok := seen[idKey]; ok {
return
}
seen[idKey] = struct{}{}
}
seen[sig] = struct{}{}
emitToolCallsFromMessage(msg, agentName, orchestratorName, conversationID, orchMode, progress, subAgentToolStep, mainAgentToolStep, markPending)
}
func toolCallsStableIDSignature(msg *schema.Message) string {
if msg == nil || len(msg.ToolCalls) == 0 {
return ""
}
visible := filterVisibleToolCallsForProgress(msg.ToolCalls)
ids := make([]string, 0, len(visible))
for _, tc := range visible {
id := strings.TrimSpace(tc.ID)
if id == "" {
continue
}
ids = append(ids, id)
}
if len(ids) == 0 {
return ""
}
sort.Strings(ids)
return strings.Join(ids, ";")
}
func emitToolCallsFromMessage(
msg *schema.Message,
agentName, orchestratorName, conversationID, orchMode string,
+50 -7
View File
@@ -46811,6 +46811,7 @@ html[data-theme="dark"] .chat-composer-surface > .chat-session-settings-popover
line-height: 1.5;
white-space: pre-wrap;
overflow-wrap: anywhere;
scrollbar-gutter: stable;
}
.hitl-approval-countdown {
@@ -46936,7 +46937,11 @@ html[data-theme="dark"] .chat-composer-surface > .chat-session-settings-popover
display: block;
width: 100%;
min-height: 158px;
padding: 18px 20px 16px;
max-height: min(62vh, 560px);
max-height: min(62dvh, 560px);
padding: 18px 20px 74px;
overflow: hidden;
overscroll-behavior: contain;
border: 1px solid rgba(15, 23, 42, 0.14);
border-radius: 24px;
background: rgba(255, 255, 255, 0.985);
@@ -46945,6 +46950,16 @@ html[data-theme="dark"] .chat-composer-surface > .chat-session-settings-popover
outline: none;
}
.chat-hitl-approval-scroll-region {
min-height: 0;
max-height: max(76px, calc(min(62vh, 560px) - 94px));
max-height: max(76px, calc(min(62dvh, 560px) - 94px));
overflow-y: auto;
overscroll-behavior: contain;
scroll-padding-bottom: 12px;
scrollbar-gutter: stable;
}
.chat-hitl-approval-dock .hitl-codex-tool-row {
font-size: 0.92rem;
}
@@ -46958,8 +46973,12 @@ html[data-theme="dark"] .chat-composer-surface > .chat-session-settings-popover
}
.chat-hitl-approval-dock .hitl-approval-heading h3 {
display: -webkit-box;
max-width: 900px;
overflow: hidden;
font-size: 1rem;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
}
.chat-hitl-approval-dock .hitl-inline-body {
@@ -46968,13 +46987,28 @@ html[data-theme="dark"] .chat-composer-surface > .chat-session-settings-popover
padding: 0;
}
.chat-hitl-approval-dock .hitl-edit-args {
max-height: min(28vh, 220px);
max-height: min(28dvh, 220px);
overflow: auto;
resize: vertical;
}
.chat-hitl-approval-dock .hitl-inline-actions {
position: absolute;
right: 20px;
bottom: 16px;
left: 20px;
z-index: 2;
min-height: 48px;
justify-content: flex-end;
gap: 8px;
margin-top: 12px;
padding: 0;
margin-top: 0;
padding: 10px 0 0;
border: 0;
background: transparent;
border-top: 1px solid color-mix(in srgb, var(--border-color) 70%, transparent);
background: rgba(255, 255, 255, 0.985);
box-shadow: none;
}
.chat-hitl-approval-dock .hitl-inline-status {
@@ -47010,8 +47044,9 @@ html[data-theme="dark"] .chat-hitl-approval-dock {
}
html[data-theme="dark"] .chat-hitl-approval-dock .hitl-inline-actions {
border-color: transparent !important;
background: transparent !important;
border-color: color-mix(in srgb, var(--border-color) 70%, transparent) !important;
background: color-mix(in srgb, var(--bg-primary) 97%, transparent) !important;
box-shadow: none;
}
html[data-theme="dark"] .hitl-approval-primary code,
@@ -47136,10 +47171,15 @@ html[data-theme="dark"] .hitl-inline-approval.hitl-inline-approval--merged {
}
.chat-hitl-approval-dock {
padding: 16px;
padding: 16px 16px 116px;
border-radius: 18px;
}
.chat-hitl-approval-scroll-region {
max-height: max(76px, calc(min(62vh, 560px) - 134px));
max-height: max(76px, calc(min(62dvh, 560px) - 134px));
}
.chat-hitl-approval-dock .hitl-approval-heading,
.chat-hitl-approval-dock .hitl-approval-primary,
.chat-hitl-approval-dock .hitl-approval-countdown,
@@ -47149,6 +47189,9 @@ html[data-theme="dark"] .hitl-inline-approval.hitl-inline-approval--merged {
}
.chat-hitl-approval-dock .hitl-inline-actions {
right: 16px;
bottom: 16px;
left: 16px;
flex-wrap: wrap;
}
+2
View File
@@ -878,6 +878,8 @@
"approvalUrgencyWithinOne": "Earliest approval expires within 1 minute",
"requestGeneric": "Allow CyberStrikeAI to call {{tool}}?",
"requestVisitUrl": "Allow CyberStrikeAI to visit {{url}}?",
"requestVisitLongUrl": "Allow CyberStrikeAI to visit this address?",
"requestModifyLongPath": "Allow CyberStrikeAI to modify this file?",
"requestBrowser": "Allow CyberStrikeAI to use the browser?",
"requestCommand": "Allow CyberStrikeAI to run this command?",
"requestFile": "Allow CyberStrikeAI to modify {{path}}?",
+2
View File
@@ -866,6 +866,8 @@
"approvalUrgencyWithinOne": "最早审批将在 1 分钟内到期",
"requestGeneric": "允许 CyberStrikeAI 调用 {{tool}}",
"requestVisitUrl": "允许 CyberStrikeAI 访问 {{url}}",
"requestVisitLongUrl": "允许 CyberStrikeAI 访问此地址?",
"requestModifyLongPath": "允许 CyberStrikeAI 修改此文件?",
"requestBrowser": "允许 CyberStrikeAI 使用浏览器?",
"requestCommand": "允许 CyberStrikeAI 执行这条命令?",
"requestFile": "允许 CyberStrikeAI 修改 {{path}}",
+75 -4
View File
@@ -6,6 +6,7 @@ const vm = require('node:vm');
const scroll = fs.readFileSync('web/static/js/chat-scroll.js', 'utf8');
const monitor = fs.readFileSync('web/static/js/monitor.js', 'utf8');
const chat = fs.readFileSync('web/static/js/chat.js', 'utf8');
const projects = fs.readFileSync('web/static/js/projects.js', 'utf8');
const router = fs.readFileSync('web/static/js/router.js', 'utf8');
const auth = fs.readFileSync('web/static/js/auth.js', 'utf8');
const webshell = fs.readFileSync('web/static/js/webshell.js', 'utf8');
@@ -340,7 +341,7 @@ test('消息气泡内部流式增高时仅在跟随模式继续粘底', () => {
test('页面在任务补流脚本之前加载智能滚动控制器', () => {
const scrollIndex = html.indexOf('/static/js/chat-scroll.js?v=20260815-1');
const monitorIndex = html.indexOf('/static/js/monitor.js?v=20260815-2');
const monitorIndex = html.indexOf('/static/js/monitor.js?v=20260819-3');
assert.notEqual(scrollIndex, -1);
assert.notEqual(monitorIndex, -1);
@@ -383,6 +384,76 @@ test('任务计划进度事件在活跃任务列表变化和新任务开始时
assert.match(renderSource, /detail: \{ tasks: normalizedTasks \}/);
});
test('活跃任务按启动时间稳定排列且无变化刷新不重建停止按钮', () => {
const sortSource = functionSource(monitor, 'stableActiveTasksForDisplay', 'activeTasksRenderSignature');
const sortTasks = vm.runInNewContext(`(${sortSource.trim()})`);
const tasks = [
{ conversationId: 'conversation-z', startedAt: '2026-08-19T10:00:00Z' },
{ conversationId: 'conversation-late', startedAt: '2026-08-19T10:01:00Z' },
{ conversationId: 'conversation-a', startedAt: '2026-08-19T10:00:00Z' }
];
assert.deepEqual(
Array.from(sortTasks(tasks), task => task.conversationId),
['conversation-a', 'conversation-z', 'conversation-late']
);
const renderSource = functionSource(monitor, 'renderActiveTasks', 'reconcileHitlApprovalStateWithActiveTasks');
assert.match(renderSource, /nextVisualSignature === activeTasksVisualSignature/);
assert.match(renderSource, /bar\.querySelectorAll\('\.active-task-item'\)\.length === normalizedTasks\.length/);
assert.match(renderSource, /const previousScrollLeft = bar\.scrollLeft/);
assert.match(renderSource, /bar\.scrollLeft = previousScrollLeft/);
});
test('新对话初始化期间切换会话后旧流事件不能把页面拉回', () => {
const guardSource = functionSource(chat, 'shouldIgnoreLiveChatStreamEvent', 'clearLiveChatStreamIfOwned');
const shouldIgnore = vm.runInNewContext(`(${guardSource.trim()})`);
const activeStream = { active: true, detached: false, navigationSeq: 7 };
assert.equal(shouldIgnore(activeStream, activeStream, 7), false);
activeStream.detached = true;
assert.equal(shouldIgnore(activeStream, activeStream, 7), true);
activeStream.detached = false;
activeStream.active = false;
assert.equal(shouldIgnore(activeStream, activeStream, 7), true);
activeStream.active = true;
assert.equal(shouldIgnore(activeStream, activeStream, 8), true);
assert.equal(shouldIgnore({ active: true, detached: false, navigationSeq: 7 }, activeStream, 7), true);
const sendSource = functionSource(chat, 'sendMessage', 'renderChatFileChips');
const guardIndex = sendSource.indexOf('shouldIgnoreLiveChatStreamEvent(liveStreamState)');
const handlerIndex = sendSource.indexOf('handleStreamEvent(eventData');
assert.notEqual(guardIndex, -1);
assert.notEqual(handlerIndex, -1);
assert.ok(guardIndex < handlerIndex);
assert.match(sendSource, /const requestNavigationSeq = chatConversationNavigationSeq;[\s\S]*?await loadActiveTasks\(\)/);
assert.match(sendSource, /if \(requestNavigationSeq !== chatConversationNavigationSeq\) \{[\s\S]{0,80}return;/);
assert.match(sendSource, /navigationSeq: requestNavigationSeq/);
assert.match(sendSource, /if \(!streamConversationId\) \{[\s\S]{0,180}liveStreamState\.conversationId = eventConvId/);
assert.match(sendSource, /if \(eventConvId\) updateProgressConversation\(progressId, eventConvId\);[\s\S]{0,80}return;/);
const loadSource = functionSource(chat, 'loadConversation', 'attachDeleteTurnButton');
const newConversationSource = functionSource(chat, 'startNewConversation', 'loadConversations');
assert.match(loadSource, /markChatConversationNavigation\(conversationId\)/);
assert.match(loadSource, /window\.cancelScheduledChatConversationFromHash\(\)/);
assert.match(newConversationSource, /markChatConversationNavigation\('', true\)/);
assert.match(newConversationSource, /clearChatConversationHash\(\)/);
assert.match(router, /function cancelScheduledChatConversationFromHash\(\)[\s\S]{0,160}chatConversationFromHashSeq\+\+/);
assert.match(chat, /function abandonChatConversationForPageNavigation\(\)[\s\S]{0,260}markChatConversationNavigation\('', true\)/);
assert.match(chat, /abandonChatConversationForPageNavigation\(\)[\s\S]{0,420}detachLiveChatStreamForNavigation\('', true\)/);
assert.match(router, /currentPage === 'chat'[\s\S]{0,140}window\.abandonChatConversationForPageNavigation\(\)/);
assert.match(chat, /const targetConversationId = String\(item\.dataset\.conversationId \|\| ''\)\.trim\(\);[\s\S]{0,80}loadConversation\(targetConversationId\)/);
assert.match(projects, /const targetConversationId = String\(event\.currentTarget && event\.currentTarget\.dataset\.conversationId \|\| ''\)\.trim\(\)/);
assert.match(projects, /window\.loadConversation\(targetConversationId\)/);
assert.match(chat, /let loadConversationPendingId = ''/);
assert.match(chat, /window\.isChatConversationLoadPending = isChatConversationLoadPending/);
const immediateSelectionIndex = loadSource.indexOf('currentConversationId = conversationId;');
const conversationFetchIndex = loadSource.indexOf('await apiFetch(`/api/conversations/${conversationId}?include_process_details=0`');
assert.notEqual(immediateSelectionIndex, -1);
assert.notEqual(conversationFetchIndex, -1);
assert.ok(immediateSelectionIndex < conversationFetchIndex);
assert.match(monitor, /String\(window\.currentConversationId \|\| ''\) !== conversationId[\s\S]{0,300}window\.isChatConversationLoadPending\(conversationId\)/);
});
test('刷新指定对话时立即恢复且加载完成前不闪出无项目状态', () => {
const scheduleSource = functionSource(router, 'scheduleChatConversationFromHash', 'navigateToConversation');
const restoreStateSource = functionSource(router, 'setChatConversationRestorePending', 'finishChatConversationRestore');
@@ -397,8 +468,8 @@ test('刷新指定对话时立即恢复且加载完成前不闪出无项目状
assert.match(loadSource, /finally \{[\s\S]*?finishChatConversationRestore\(conversationId\)/);
assert.match(css, /\.chat-container\.is-conversation-restoring #chat-messages/);
assert.match(css, /\.chat-container\.is-conversation-restoring #chat-input-container/);
assert.match(html, /router\.js\?v=20260813-2/);
assert.match(html, /chat\.js\?v=20260818-3/);
assert.match(html, /router\.js\?v=20260819-3/);
assert.match(html, /chat\.js\?v=20260819-5/);
});
test('刷新运行中回复会复用已持久化 planning 并继续追加未来增量', () => {
@@ -462,5 +533,5 @@ test('暗色模式对话三点悬浮不会触发浅色父行背景', () => {
const css = fs.readFileSync('web/static/css/style.css', 'utf8');
assert.match(css, /html\[data-theme="dark"\] \.project-conversation-row:hover \.project-conversation-item/);
assert.match(css, /html\[data-theme="dark"\] \.project-folder-action:hover,[\s\S]*?background: rgba\(71, 85, 105, 0\.28\);[\s\S]*?box-shadow: none;/);
assert.match(html, /style\.css\?v=20260818-3/);
assert.match(html, /style\.css\?v=20260819-4/);
});
+141 -14
View File
@@ -10,8 +10,46 @@ function syncChatConversationHash(conversationId) {
}
}
window.syncChatConversationHash = syncChatConversationHash;
function clearChatConversationHash() {
if (window.location.hash.split('?')[0] !== '#chat' || window.location.hash === '#chat') return;
window.history.replaceState(null, '', '#chat');
}
window.clearChatConversationHash = clearChatConversationHash;
let loadConversationRequestSeq = 0;
let loadConversationAbortController = null;
let loadConversationPendingId = '';
let chatConversationNavigationSeq = 0;
function isChatConversationLoadPending(conversationId) {
const id = String(conversationId || '').trim();
return !!id && loadConversationPendingId === id;
}
window.isChatConversationLoadPending = isChatConversationLoadPending;
function markChatConversationNavigation(nextConversationId, force = false) {
const nextId = String(nextConversationId || '').trim();
const visibleId = String(currentConversationId || '').trim();
if (force || nextId !== visibleId) {
chatConversationNavigationSeq++;
}
return chatConversationNavigationSeq;
}
/**
* 离开聊天页时立即让尚在初始化的发送请求失去页面所有权
* 后端任务仍会继续执行这里只中止浏览器前台流避免首个 conversation
* 事件在用户已经切到其他页面后再次抢占当前会话
*/
function abandonChatConversationForPageNavigation() {
markChatConversationNavigation('', true);
if (typeof window.cancelScheduledChatConversationFromHash === 'function') {
window.cancelScheduledChatConversationFromHash();
}
cancelPendingConversationLoad();
detachLiveChatStreamForNavigation('', true);
}
window.abandonChatConversationForPageNavigation = abandonChatConversationForPageNavigation;
/**
* 轻量会话 LRU 缓存
@@ -130,6 +168,8 @@ const DEFAULT_HITL_TIMEOUT_SECONDS = 300;
const DEFAULT_HITL_SESSION_TOOL_WHITELIST = 'tool_search, skill, task, write_todos, transfer_to_agent, exit, TaskCreate, TaskGet, TaskUpdate, TaskList, upsert_project_fact, get_project_fact';
let hitlApplyFeedbackTimer = null;
let hitlAutoSaveTimer = null;
let hitlConfigSyncConversationId = '';
let hitlConfigSyncPromise = Promise.resolve();
const sessionSettingsSelects = new Map();
let sessionSettingsSelectDocBound = false;
@@ -706,6 +746,18 @@ function refreshHitlConfigByCurrentConversation() {
applyHitlConfigToUI(cfg);
}
async function waitForHitlConfigReady(conversationId) {
const cid = String(conversationId || '').trim();
if (cid && hitlConfigSyncConversationId === cid) {
await hitlConfigSyncPromise;
return;
}
if (!cid && window.csaiHitlDefaultReviewerReady && typeof window.csaiHitlDefaultReviewerReady.then === 'function') {
await window.csaiHitlDefaultReviewerReady.catch(function () {});
if (!currentConversationId) refreshHitlConfigByCurrentConversation();
}
}
function showHitlApplyFeedback(text, isError, partial) {
const el = document.getElementById('hitl-apply-feedback');
if (hitlApplyFeedbackTimer) {
@@ -1754,6 +1806,18 @@ function ownsLiveChatStream(liveStream) {
return !!liveStream && window.__csAgentLiveStream === liveStream;
}
function shouldIgnoreLiveChatStreamEvent(
liveStream,
activeLiveStream = window.__csAgentLiveStream,
navigationSeq = chatConversationNavigationSeq
) {
return !liveStream ||
activeLiveStream !== liveStream ||
liveStream.active !== true ||
liveStream.detached === true ||
liveStream.navigationSeq !== navigationSeq;
}
function clearLiveChatStreamIfOwned(liveStream) {
if (!ownsLiveChatStream(liveStream)) return false;
liveStream.active = false;
@@ -2194,11 +2258,22 @@ async function sendMessage() {
const input = document.getElementById('chat-input');
let message = input.value.trim();
const hasAttachments = chatAttachments && chatAttachments.length > 0;
const requestConversationId = currentConversationId;
const requestNavigationSeq = chatConversationNavigationSeq;
if (!message && !hasAttachments) {
return;
}
// A restored conversation renders from the local cache first, while its
// authoritative HITL config is fetched separately. Do not let a fast send
// reuse the temporary/default reviewer (historically "human") before that
// fetch completes, otherwise refreshing could turn Audit Agent review into
// a human approval for the next tool call.
const hitlConversationAtSendStart = String(currentConversationId || '').trim();
await waitForHitlConfigReady(hitlConversationAtSendStart);
if (String(currentConversationId || '').trim() !== hitlConversationAtSendStart) return;
// Enter 会直接调用 sendMessage;同一会话在其他标签页已启动任务时,
// 必须在渲染用户气泡和发起 POST 前做一次权威状态同步,避免生成一轮“已有任务执行中”伪对话。
if (currentConversationId && typeof loadActiveTasks === 'function') {
@@ -2238,6 +2313,12 @@ async function sendMessage() {
message = CHAT_FILE_DEFAULT_PROMPT;
}
// 发送前的任务状态/附件检查可能包含异步等待。若用户已主动切换会话,
// 保留当前页面,不再把这次尚未发出的请求写入新的可见对话。
if (requestNavigationSeq !== chatConversationNavigationSeq) {
return;
}
// 显示用户消息(含附件名,便于用户确认)
const displayMessage = hasAttachments
? message + '\n' + chatAttachments.map(a => '📎 ' + a.fileName).join('\n')
@@ -2273,7 +2354,7 @@ async function sendMessage() {
// 构建请求体(含附件)
const body = {
message: message,
conversationId: currentConversationId,
conversationId: requestConversationId,
role: typeof getCurrentRole === 'function' ? getCurrentRole() : ''
};
if (window.__csNextChatFinalizationPolicy && typeof window.__csNextChatFinalizationPolicy === 'object') {
@@ -2334,7 +2415,8 @@ async function sendMessage() {
conversationId: streamConversationId || null,
progressId: progressId,
abortController: requestAbortController,
detached: false
detached: false,
navigationSeq: requestNavigationSeq
};
window.__csAgentLiveStream = liveStreamState;
if (streamConversationId && typeof window.notifyConversationTaskStarted === 'function') {
@@ -2385,18 +2467,18 @@ async function sendMessage() {
if (streamConversationId && streamConversationId !== eventConvId) {
return;
}
if (!streamConversationId && eventData.type === 'conversation') {
if (!streamConversationId) {
streamConversationId = eventConvId;
liveStreamState.conversationId = eventConvId;
justBoundConversation = true;
// 旧请求可能在用户切换对话后才收到 conversation 事件。
// 只完成本地任务绑定,不允许它重新抢占当前对话或新的主流状态。
if (!ownsLiveChatStream(liveStreamState) || liveStreamState.detached) {
updateProgressConversation(progressId, eventConvId);
return;
}
}
}
// 切换对话后仍可能收到旧响应流中已缓冲的 conversation、response_start
// 或 response 事件。它们只能补齐后台任务归属,不能重新抢占当前对话。
if (shouldIgnoreLiveChatStreamEvent(liveStreamState)) {
if (eventConvId) updateProgressConversation(progressId, eventConvId);
return;
}
if (!justBoundConversation && !isStreamStillVisibleForRequest()) {
return;
}
@@ -5629,6 +5711,11 @@ async function startNewConversation(options = {}) {
const requestedProjectId = hasExplicitProjectId
? String(options.projectId || '').trim()
: String(inheritedProjectId || '').trim();
markChatConversationNavigation('', true);
if (typeof window.cancelScheduledChatConversationFromHash === 'function') {
window.cancelScheduledChatConversationFromHash();
}
clearChatConversationHash();
cancelPendingConversationLoad();
detachLiveChatStreamForNavigation('', true);
if (typeof window.cancelRunningTaskEventStream === 'function') {
@@ -5753,7 +5840,8 @@ function createConversationListItem(conversation) {
item.onclick = (e) => {
e.preventDefault();
e.stopPropagation();
loadConversation(conversation.id);
const targetConversationId = String(item.dataset.conversationId || '').trim();
if (targetConversationId) loadConversation(targetConversationId);
};
return item;
}
@@ -6069,16 +6157,30 @@ async function prefetchLastAssistantProcessDetails() {
}
async function loadConversation(conversationId) {
conversationId = String(conversationId || '').trim();
if (!conversationId) return;
// Keep the visible conversation addressable across a full page refresh.
// Sidebar/project entries call loadConversation directly (rather than the
// router helper), so without this synchronization #chat loses the active
// conversation and reload falls back to the welcome screen instead of
// reconnecting the running task event stream.
markChatConversationNavigation(conversationId);
if (typeof window.cancelScheduledChatConversationFromHash === 'function') {
window.cancelScheduledChatConversationFromHash();
}
syncChatConversationHash(conversationId);
const seq = ++loadConversationRequestSeq;
const previousConversationId = currentConversationId;
cancelPendingConversationLoad();
detachLiveChatStreamForNavigation(conversationId);
// 用户单击即代表新的可见会话。必须在任何网络等待之前提交该选择,
// 否则每 2 秒的活跃任务刷新仍会把旧会话识别为可见,并排队重载旧补流,
// 反过来取消这次切换。
currentConversationId = conversationId;
try {
window.currentConversationId = conversationId;
} catch (e) { /* ignore */ }
loadConversationPendingId = conversationId;
const conversationLoadController = new AbortController();
loadConversationAbortController = conversationLoadController;
if (typeof window.selectChatProjectConversationItem === 'function') {
@@ -6109,6 +6211,14 @@ async function loadConversation(conversationId) {
return;
}
if (response && !response.ok) {
if (seq === loadConversationRequestSeq) {
currentConversationId = previousConversationId;
try {
window.currentConversationId = previousConversationId || '';
} catch (e) { /* ignore */ }
if (previousConversationId) syncChatConversationHash(previousConversationId);
else clearChatConversationHash();
}
showChatToast('加载对话失败: ' + (conversation.error || '未知错误'), 'error');
return;
}
@@ -6178,7 +6288,12 @@ async function loadConversation(conversationId) {
}
}).catch(() => {})
: Promise.resolve();
void hitlSyncPromise;
hitlConfigSyncConversationId = conversationId;
hitlConfigSyncPromise = Promise.resolve(hitlSyncPromise);
await hitlConfigSyncPromise;
if (seq !== loadConversationRequestSeq || currentConversationId !== conversationId) {
return;
}
updateActiveConversation();
// 如果攻击链模态框打开且显示的不是当前对话,关闭它
@@ -6390,8 +6505,16 @@ async function loadConversation(conversationId) {
}
} catch (error) {
if (error && error.name === 'AbortError') return;
if (seq === loadConversationRequestSeq && typeof window.selectChatProjectConversationItem === 'function') {
window.selectChatProjectConversationItem(previousConversationId);
if (seq === loadConversationRequestSeq) {
currentConversationId = previousConversationId;
try {
window.currentConversationId = previousConversationId || '';
} catch (e) { /* ignore */ }
if (previousConversationId) syncChatConversationHash(previousConversationId);
else clearChatConversationHash();
if (typeof window.selectChatProjectConversationItem === 'function') {
window.selectChatProjectConversationItem(previousConversationId);
}
}
console.error('加载对话失败:', error);
showChatToast('加载对话失败: ' + (error && error.message ? error.message : String(error)), 'error');
@@ -6402,6 +6525,9 @@ async function loadConversation(conversationId) {
if (loadConversationAbortController === conversationLoadController) {
loadConversationAbortController = null;
}
if (seq === loadConversationRequestSeq && loadConversationPendingId === conversationId) {
loadConversationPendingId = '';
}
}
}
@@ -10065,7 +10191,8 @@ function createConversationListItemWithMenu(conversation, isPinned) {
if (currentGroupId) {
exitGroupDetail();
}
loadConversation(conversation.id);
const targetConversationId = String(item.dataset.conversationId || '').trim();
if (targetConversationId) loadConversation(targetConversationId);
};
return item;
+47 -6
View File
@@ -22,6 +22,35 @@ test('输入区提供独立审批入口并暴露可配置等待时限', () => {
assert.match(chat, /body\.hitl = \{[\s\S]*?timeoutSeconds: normalizeHitlTimeoutForChat\(hitlCfg\.timeoutSeconds/);
});
test('超长人工审批内容在限高区域内滚动且操作按钮始终可见', () => {
assert.match(styles, /\.chat-hitl-approval-dock \{[\s\S]*?max-height: min\(62dvh, 560px\);[\s\S]*?padding: 18px 20px 74px;[\s\S]*?overflow: hidden;/);
assert.match(styles, /\.chat-hitl-approval-scroll-region \{[\s\S]*?max-height: max\(76px, calc\(min\(62dvh, 560px\) - 94px\)\);[\s\S]*?overflow-y: auto;[\s\S]*?overscroll-behavior: contain;/);
assert.match(styles, /\.chat-hitl-approval-dock \.hitl-edit-args \{[\s\S]*?max-height: min\(28dvh, 220px\);[\s\S]*?overflow: auto;/);
assert.match(styles, /\.chat-hitl-approval-dock \.hitl-inline-actions \{[\s\S]*?position: absolute;[\s\S]*?bottom: 16px;[\s\S]*?box-shadow: none;/);
assert.match(styles, /\.chat-hitl-approval-dock \.hitl-approval-heading h3 \{[\s\S]*?-webkit-line-clamp: 3;/);
assert.match(monitor, /function wrapChatHitlApprovalScrollRegion\(dock\)/);
assert.match(monitor, /while \(dock\.firstChild && dock\.firstChild !== actions\)/);
assert.match(monitor, /wrapChatHitlApprovalScrollRegion\(dock\);/);
assert.match(monitor, /url\.length > 160[\s\S]*?requestVisitLongUrl/);
assert.equal(zh.hitl.requestVisitLongUrl, '允许 CyberStrikeAI 访问此地址?');
assert.equal(en.hitl.requestVisitLongUrl, 'Allow CyberStrikeAI to visit this address?');
});
test('刷新恢复会话时先完成权威审批配置同步再允许发送', () => {
assert.match(chat, /function waitForHitlConfigReady\(conversationId\)/);
assert.match(chat, /await waitForHitlConfigReady\(hitlConversationAtSendStart\)/);
assert.match(chat, /hitlConfigSyncConversationId = conversationId;[\s\S]{0,240}await hitlConfigSyncPromise;/);
assert.match(chat, /await hitlConfigSyncPromise;[\s\S]{0,220}seq !== loadConversationRequestSeq/);
assert.match(fs.readFileSync('web/static/js/hitl.js', 'utf8'), /window\.csaiHitlDefaultReviewerReady = initHitlDefaultReviewerFromServer\(\)/);
});
test('同一会话的审批配置写入串行化以防止旧请求后到覆盖新选择', () => {
const hitlPage = fs.readFileSync('web/static/js/hitl.js', 'utf8');
assert.match(hitlPage, /const hitlConversationConfigSaveQueues = new Map\(\)/);
assert.match(hitlPage, /const previous = hitlConversationConfigSaveQueues\.get\(normalizedConversationId\) \|\| Promise\.resolve\(\)/);
assert.match(hitlPage, /const queued = previous\.catch\(function \(\) \{\}\)\.then\(async function \(\)/);
});
test('输入框可按会话通道获取模型并双向同步会话推理且审批模型只出现在审计 Agent 入口', () => {
assert.match(chat, /function currentSystemModelLabel\(\)/);
assert.match(chat, /chatDefaultAIChannel \? chatAIChannels\[chatDefaultAIChannel\]/);
@@ -251,7 +280,7 @@ test('多对话并发时释放隐藏主流且旧请求不能覆盖新对话状
assert.match(chat, /liveStream\.detached = true;[\s\S]{0,240}controller\.abort\(\)/);
assert.match(chat, /const requestAbortController = new AbortController\(\)/);
assert.match(chat, /signal: requestAbortController\.signal/);
assert.match(chat, /if \(!ownsLiveChatStream\(liveStreamState\) \|\| liveStreamState\.detached\)/);
assert.match(chat, /shouldIgnoreLiveChatStreamEvent\(liveStreamState\)/);
assert.match(chat, /const clearedOwnedStream = clearLiveChatStreamIfOwned\(liveStreamState\)/);
assert.match(chat, /detachLiveChatStreamForNavigation\(conversationId\)/);
assert.match(chat, /detachLiveChatStreamForNavigation\('', true\)/);
@@ -260,12 +289,24 @@ test('多对话并发时释放隐藏主流且旧请求不能覆盖新对话状
assert.match(monitor, /function scrollProcessDetailsToLatest\(assistantMessageId, smooth = true\)/);
assert.match(monitor, /timeline\.scrollTop = targetTop/);
assert.match(chat, /let loadConversationAbortController = null/);
assert.match(chat, /cancelPendingConversationLoad\(\);[\s\S]{0,220}const conversationLoadController = new AbortController\(\)/);
assert.match(chat, /cancelPendingConversationLoad\(\);[\s\S]{0,900}const conversationLoadController = new AbortController\(\)/);
assert.match(chat, /signal: conversationLoadController\.signal/);
assert.match(template, /monitor\.js\?v=20260815-2/);
assert.match(template, /monitor\.js\?v=20260819-3/);
assert.match(template, /chat-scroll\.js\?v=20260815-1/);
assert.match(template, /chat\.js\?v=20260818-3/);
assert.match(template, /style\.css\?v=20260818-3/);
assert.match(template, /chat\.js\?v=20260819-5/);
assert.match(template, /style\.css\?v=20260819-4/);
});
test('彻底停止始终使用弹窗锁定的会话且状态刷新后仍会取消', () => {
const start = monitor.indexOf("async function performHardCancelProgressTask(progressId, conversationId = '')");
const end = monitor.indexOf('function progressElapsedText(', start);
assert.notEqual(start, -1);
assert.notEqual(end, -1);
const hardCancelSource = monitor.slice(start, end);
assert.match(monitor, /performHardCancelProgressTask\(progressId, conversationId\)/);
assert.match(hardCancelSource, /const targetConversationId = String\(conversationId \|\| \(state && state\.conversationId\) \|\| ''\)\.trim\(\)/);
assert.match(hardCancelSource, /await requestCancel\(targetConversationId\)/);
assert.doesNotMatch(hardCancelSource, /if \(!state \|\| !state\.conversationId\)/);
});
test('输入区 Agent 审查文字保留足够行高且不会裁切字形', () => {
@@ -310,7 +351,7 @@ test('审批状态主动轮询并在服务不可用时立即关闭旧审批', ()
assert.match(monitor, /renderActiveTasks\(\[\]\);[\s\S]{0,260}hitlPendingInterruptTracker\.update\(\[\]\)/);
assert.match(projects, /function syncProjectConversationApprovalStatuses\(items\)/);
assert.match(projects, /window\.syncProjectConversationApprovalStatuses/);
assert.match(template, /projects\.js\?v=20260812-6/);
assert.match(template, /projects\.js\?v=20260819-1/);
});
test('旧会话首次升级到五分钟默认审批时限,仍允许用户之后主动选择不限时', () => {
+29 -18
View File
@@ -100,6 +100,7 @@ const HITL_LOGS_PAGE_SIZE_KEY = 'cyberstrike_hitl_logs_page_size';
const HITL_PENDING_PAGE_SIZE_KEY = 'cyberstrike_hitl_pending_page_size';
const HITL_TIMEOUT_DEFAULT_MIGRATION_PREFIX = 'cyberstrike-hitl-timeout-default-v1:';
const HITL_PAGE_SIZE_OPTIONS = [10, 20, 50, 100];
const hitlConversationConfigSaveQueues = new Map();
function hitlPaginationT(key, opts, fallback) {
if (typeof window.t === 'function') {
@@ -495,29 +496,39 @@ async function saveHitlPageWhitelist() {
async function saveHitlConversationConfig(conversationId, config) {
if (!conversationId || !config) return false;
const normalizedConversationId = String(conversationId).trim();
const mode = hitlModeNormalize(config.mode || 'off');
const enabled = typeof config.enabled === 'boolean' ? config.enabled : (mode !== 'off');
const sensitiveTools = hitlSensitiveToolsToArray(config);
const timeoutSeconds = normalizeHitlTimeoutSeconds(config.timeoutSeconds, 0);
const reviewer = hitlReviewerNormalize(config.reviewer || 'human');
const resp = await hitlApiFetch('/api/hitl/config', {
method: 'PUT',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
conversationId: conversationId,
enabled: enabled,
mode: mode,
reviewer: reviewer,
sensitiveTools: sensitiveTools,
timeoutSeconds: timeoutSeconds
})
const previous = hitlConversationConfigSaveQueues.get(normalizedConversationId) || Promise.resolve();
const queued = previous.catch(function () {}).then(async function () {
const resp = await hitlApiFetch('/api/hitl/config', {
method: 'PUT',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
conversationId: normalizedConversationId,
enabled: enabled,
mode: mode,
reviewer: reviewer,
sensitiveTools: sensitiveTools,
timeoutSeconds: timeoutSeconds
})
});
if (!resp.ok) {
const msg = await readHitlApiError(resp);
throw new Error(msg || ('HTTP ' + resp.status));
}
return true;
});
hitlConversationConfigSaveQueues.set(normalizedConversationId, queued);
return queued.finally(function () {
if (hitlConversationConfigSaveQueues.get(normalizedConversationId) === queued) {
hitlConversationConfigSaveQueues.delete(normalizedConversationId);
}
});
if (!resp.ok) {
const msg = await readHitlApiError(resp);
throw new Error(msg || ('HTTP ' + resp.status));
}
return true;
}
async function syncHitlConfigFromServer(conversationId) {
@@ -1809,7 +1820,7 @@ document.addEventListener('DOMContentLoaded', function () {
if (typeof window.bindHitlReviewerToggleListeners === 'function') {
window.bindHitlReviewerToggleListeners();
}
initHitlDefaultReviewerFromServer();
window.csaiHitlDefaultReviewerReady = initHitlDefaultReviewerFromServer();
setTimeout(reconcileHitlUiState, 0);
});
+106 -13
View File
@@ -6,6 +6,7 @@ const ACTIVE_TASK_REFRESH_INTERVAL = 2000; // 运行态与审批态需要及时
const TASK_FINAL_STATUSES = new Set(['failed', 'timeout', 'cancelled', 'completed']);
const hitlInterruptToolItemMap = new Map();
let activeTasksLoadPromise = null;
let activeTasksVisualSignature = '';
const CHAT_TASK_SYNC_CHANNEL_NAME = 'cyberstrike-chat-task-sync-v1';
let chatTaskSyncChannel = null;
let visibleConversationReplaySyncPromise = null;
@@ -1432,7 +1433,7 @@ async function submitUserInterruptHardCancel() {
const { progressId, conversationId } = userInterruptModalPending;
closeUserInterruptModal();
if (progressId) {
await performHardCancelProgressTask(progressId);
await performHardCancelProgressTask(progressId, conversationId);
return;
}
if (!conversationId) {
@@ -1448,11 +1449,12 @@ async function submitUserInterruptHardCancel() {
}
/** 彻底停止任务(原「停止任务」行为) */
async function performHardCancelProgressTask(progressId) {
async function performHardCancelProgressTask(progressId, conversationId = '') {
const state = progressTaskState.get(progressId);
const stopBtn = document.getElementById(`${progressId}-stop-btn`);
const targetConversationId = String(conversationId || (state && state.conversationId) || '').trim();
if (!state || !state.conversationId) {
if (!targetConversationId) {
if (stopBtn) {
stopBtn.disabled = true;
setTimeout(() => {
@@ -1463,7 +1465,7 @@ async function performHardCancelProgressTask(progressId) {
return;
}
if (state.cancelling) {
if (state && state.cancelling) {
return;
}
@@ -1474,7 +1476,7 @@ async function performHardCancelProgressTask(progressId) {
}
try {
await requestCancel(state.conversationId);
await requestCancel(targetConversationId);
loadActiveTasks();
} catch (error) {
console.error('取消任务失败:', error);
@@ -4282,7 +4284,9 @@ function describeHitlApprovalRequest(data) {
if (isBrowser) {
kind = 'browser';
question = url
? hitlApprovalTemplate('hitl.requestVisitUrl', '允许 CyberStrikeAI 访问 {{url}}', { url: url })
? (url.length > 160
? hitlApprovalTranslate('hitl.requestVisitLongUrl', '允许 CyberStrikeAI 访问此地址?')
: hitlApprovalTemplate('hitl.requestVisitUrl', '允许 CyberStrikeAI 访问 {{url}}', { url: url }))
: hitlApprovalTranslate('hitl.requestBrowser', '允许 CyberStrikeAI 使用浏览器?');
primary = url;
} else if (isCommand) {
@@ -4292,7 +4296,9 @@ function describeHitlApprovalRequest(data) {
} else if (isFile) {
kind = 'file';
question = path
? hitlApprovalTemplate('hitl.requestFile', '允许 CyberStrikeAI 修改 {{path}}', { path: path })
? (path.length > 160
? hitlApprovalTranslate('hitl.requestModifyLongPath', '允许 CyberStrikeAI 修改此文件?')
: hitlApprovalTemplate('hitl.requestFile', '允许 CyberStrikeAI 修改 {{path}}', { path: path }))
: hitlApprovalTranslate('hitl.requestFiles', '允许 CyberStrikeAI 修改文件?');
primary = path;
}
@@ -4835,6 +4841,20 @@ function clearChatHitlApprovalDock(interruptId) {
if (container) container.classList.remove('has-hitl-approval');
}
function wrapChatHitlApprovalScrollRegion(dock) {
if (!dock) return;
const actions = Array.prototype.find.call(dock.children, function (child) {
return child.classList && child.classList.contains('hitl-inline-actions');
});
if (!actions) return;
const scrollRegion = document.createElement('div');
scrollRegion.className = 'chat-hitl-approval-scroll-region';
while (dock.firstChild && dock.firstChild !== actions) {
scrollRegion.appendChild(dock.firstChild);
}
dock.insertBefore(scrollRegion, actions);
}
function renderChatHitlApprovalDock(data) {
const dock = document.getElementById('chat-hitl-approval-dock');
if (!dock || !data || !data.interruptId) return false;
@@ -4855,6 +4875,7 @@ function renderChatHitlApprovalDock(data) {
allowEdit: allowEdit,
argsJSON: JSON.stringify(hitlApprovalArguments(data), null, 2)
});
wrapChatHitlApprovalScrollRegion(dock);
dock.hidden = false;
const container = dock.closest('.chat-input-container');
if (container) container.classList.add('has-hitl-approval');
@@ -6255,21 +6276,44 @@ function coalesceProcessDetailsToolPairs(details) {
createdAt: detail.createdAt,
data: Object.assign({}, data)
};
if (id) callsById.set(id, copy);
if (id) {
let list = callsById.get(id);
if (!list) {
list = [];
callsById.set(id, list);
}
list.push(copy);
}
fifoCalls.push(copy);
out.push(copy);
} else if (et === 'tool_result') {
} else if (et === 'tool_result') {
let target = null;
if (id && callsById.has(id)) {
target = callsById.get(id);
} else {
const list = callsById.get(id);
while (list.length) {
const candidate = list.shift();
if (candidate && candidate.data && !candidate.data._mergedResult) {
target = candidate;
break;
}
}
}
if (!target) {
const resultName = String(data.toolName || '').trim().toLowerCase();
let anyUnmatched = null;
for (let j = 0; j < fifoCalls.length; j++) {
const c = fifoCalls[j];
if (c && c.data && !c.data._mergedResult) {
if (!c || !c.data || c.data._mergedResult) continue;
if (!anyUnmatched) anyUnmatched = c;
const callName = String(c.data.toolName || '').trim().toLowerCase();
if (!resultName || !callName || callName === resultName) {
target = c;
break;
}
}
if (!target && id) {
target = anyUnmatched;
}
}
if (target) {
// agentFacing 或较新的 tool_result 覆盖旧合并(历史数据可能含 reduction 前全量正文)
@@ -6816,6 +6860,17 @@ function syncVisibleConversationTaskReplay(tasks) {
visibleConversationReplaySyncId = conversationId;
visibleConversationReplaySyncPromise = Promise.resolve()
.then(async function () {
// 用户可能在任务刷新排队后、此微任务执行前切换了会话。
// 不允许旧会话补流取消或覆盖用户刚发起的目标会话加载。
if (String(window.currentConversationId || '') !== conversationId) {
return false;
}
if (
typeof window.isChatConversationLoadPending === 'function' &&
window.isChatConversationLoadPending(conversationId)
) {
return false;
}
// 另一标签页已新增用户消息和运行中助手轮次;先重载轻量历史,避免把补流挂到旧助手消息上。
if (typeof window.loadConversation === 'function') {
await window.loadConversation(conversationId);
@@ -6851,6 +6906,33 @@ function getActiveTaskDisplayName(task) {
return message || unnamedTaskText;
}
function stableActiveTasksForDisplay(tasks) {
return (Array.isArray(tasks) ? tasks : []).slice().sort(function (a, b) {
const aStartedAt = Date.parse(a && a.startedAt ? a.startedAt : '');
const bStartedAt = Date.parse(b && b.startedAt ? b.startedAt : '');
const aTime = Number.isFinite(aStartedAt) ? aStartedAt : Number.MAX_SAFE_INTEGER;
const bTime = Number.isFinite(bStartedAt) ? bStartedAt : Number.MAX_SAFE_INTEGER;
if (aTime !== bTime) return aTime - bTime;
return String(a && a.conversationId || '').localeCompare(String(b && b.conversationId || ''));
});
}
function activeTasksRenderSignature(tasks) {
const language = typeof i18next !== 'undefined' && i18next.language ? i18next.language : getCurrentTimeLocale();
return JSON.stringify({
language: language,
tasks: (Array.isArray(tasks) ? tasks : []).map(function (task) {
return {
conversationId: task && task.conversationId || '',
title: task && task.title || '',
message: task && task.message || '',
startedAt: task && task.startedAt || '',
status: task && task.status || ''
};
})
});
}
function updateActiveTaskConversationTitle(conversationId, newTitle) {
const bar = document.getElementById('active-tasks-bar');
if (!bar || !conversationId) return;
@@ -6867,7 +6949,7 @@ function renderActiveTasks(tasks) {
const bar = document.getElementById('active-tasks-bar');
if (!bar) return;
const normalizedTasks = Array.isArray(tasks) ? tasks : [];
const normalizedTasks = stableActiveTasksForDisplay(tasks);
conversationExecutionTracker.update(normalizedTasks);
window.dispatchEvent(new CustomEvent('conversation-task-state-changed', {
detail: { tasks: normalizedTasks }
@@ -6888,10 +6970,20 @@ function renderActiveTasks(tasks) {
if (normalizedTasks.length === 0) {
bar.style.display = 'none';
bar.innerHTML = '';
activeTasksVisualSignature = '';
return;
}
bar.style.display = 'flex';
const nextVisualSignature = activeTasksRenderSignature(normalizedTasks);
if (
nextVisualSignature === activeTasksVisualSignature &&
bar.querySelectorAll('.active-task-item').length === normalizedTasks.length
) {
return;
}
const previousScrollLeft = bar.scrollLeft;
activeTasksVisualSignature = nextVisualSignature;
bar.innerHTML = '';
function openActiveTaskConversation(conversationId) {
@@ -6970,6 +7062,7 @@ function renderActiveTasks(tasks) {
bar.appendChild(item);
});
bar.scrollLeft = previousScrollLeft;
}
function reconcileHitlApprovalStateWithActiveTasks(tasks) {
+7 -5
View File
@@ -3352,15 +3352,17 @@ function appendChatProjectConversationItem(list, conversation, project) {
});
button.appendChild(label);
button.addEventListener('click', async () => {
button.addEventListener('click', async (event) => {
const targetConversationId = String(event.currentTarget && event.currentTarget.dataset.conversationId || '').trim();
if (!targetConversationId) return;
projectConversationPreviewSuppressedUntil = Date.now() + 700;
hideProjectConversationPreview(true);
selectChatProjectConversationItem(conversation.id);
selectChatProjectConversationItem(targetConversationId);
if (typeof window.loadConversation === 'function') {
await window.loadConversation(conversation.id);
await window.loadConversation(targetConversationId);
}
if (window.currentConversationId === conversation.id && completed) {
markProjectConversationViewed(conversation.id, completed.completedAt);
if (window.currentConversationId === targetConversationId && completed) {
markProjectConversationViewed(targetConversationId, completed.completedAt);
renderChatProjectFolders(projectsCacheAll);
}
});
+9
View File
@@ -18,6 +18,12 @@ function buildHashForPage(pageId) {
let chatConversationFromHashSeq = 0;
function cancelScheduledChatConversationFromHash() {
chatConversationFromHashSeq++;
setChatConversationRestorePending('', false);
}
window.cancelScheduledChatConversationFromHash = cancelScheduledChatConversationFromHash;
function setChatConversationRestorePending(conversationId, pending) {
const container = document.querySelector('.chat-container');
if (!container) return;
@@ -123,6 +129,9 @@ function switchPage(pageId) {
if (!targetPage) return;
if (pageId !== 'chat') {
setChatConversationRestorePending('', false);
if (currentPage === 'chat' && typeof window.abandonChatConversationForPageNavigation === 'function') {
window.abandonChatConversationForPageNavigation();
}
}
// 导航点击会修改 hash,随后浏览器还会触发 hashchange。
@@ -16,3 +16,10 @@ test('历史 process_details 合并时也会从 tool_result 补齐 tool_call 参
assert.match(source, /targetDetail\.data\.argumentsObj = resultArgs;/);
assert.match(source, /targetDetail\.data\.arguments = JSON\.stringify\(resultArgs\);/);
});
test('同一 toolCallId 的多次调用按 FIFO 合并结果,避免后一次覆盖导致结果记录缺失', () => {
const source = fs.readFileSync('web/static/js/monitor.js', 'utf8');
assert.match(source, /list\.push\(copy\)/);
assert.match(source, /const candidate = list\.shift\(\)/);
assert.match(source, /callName === resultName/);
});
+6 -6
View File
@@ -31,11 +31,11 @@
}
})();
</script>
<link rel="stylesheet" href="/static/css/style.css?v=20260818-3">
<link rel="stylesheet" href="/static/css/style.css?v=20260819-4">
<link rel="stylesheet" href="/static/css/chat-plan-progress.css?v=20260813-4">
<link rel="stylesheet" href="/static/css/c2.css">
<link rel="stylesheet" href="/static/vendor/xterm.css">
<script src="/static/js/router.js?v=20260813-2"></script>
<script src="/static/js/router.js?v=20260819-3"></script>
</head>
<body>
<div id="login-overlay" class="login-overlay" style="display: none;">
@@ -6844,10 +6844,10 @@
<script src="/static/js/agents.js"></script>
<script src="/static/js/dashboard.js"></script>
<script src="/static/js/chat-scroll.js?v=20260815-1"></script>
<script src="/static/js/monitor.js?v=20260815-2"></script>
<script src="/static/js/chat.js?v=20260818-3"></script>
<script src="/static/js/monitor.js?v=20260819-3"></script>
<script src="/static/js/chat.js?v=20260819-5"></script>
<script src="/static/js/chat-plan-progress.js?v=20260815-1"></script>
<script src="/static/js/hitl.js?v=20260811-4"></script>
<script src="/static/js/hitl.js?v=20260819-1"></script>
<script src="/static/js/settings.js?v=20260717-1"></script>
<script src="/static/js/audit-datetime-picker.js"></script>
<script src="/static/js/audit.js"></script>
@@ -6858,7 +6858,7 @@
<script src="/static/js/knowledge.js"></script>
<script src="/static/js/skills.js"></script>
<script src="/static/js/fact-graph.js"></script>
<script src="/static/js/projects.js?v=20260812-6"></script>
<script src="/static/js/projects.js?v=20260819-1"></script>
<script src="/static/js/vulnerability.js?v=14"></script>
<script src="/static/js/webshell.js"></script>
<script src="/static/js/chat-files.js"></script>