Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions internal/handler/web.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"sync"
Expand Down Expand Up @@ -446,9 +447,11 @@ type WebSubagentProgressData struct {

// WebDoneData signals agent completion. Error is a short user-facing summary;
// Detail carries the full raw error text for a collapsible "details" view.
// Stopped marks a user-initiated stop — the UI shows a calm notice, not an error.
type WebDoneData struct {
Error string `json:"error,omitempty"`
Detail string `json:"detail,omitempty"`
Error string `json:"error,omitempty"`
Detail string `json:"detail,omitempty"`
Stopped bool `json:"stopped,omitempty"`
}

// WebApprovalRequestData carries an approval request. ToolCallID (when known)
Expand Down Expand Up @@ -626,6 +629,12 @@ func (h *WebHandler) OnAgentDone(err error) {
h.emit("agent_done", WebDoneData{})
return
}
// User-initiated stop (the runner reports the clean context error): show a
// calm "stopped" notice, not a red error card.
if errors.Is(err, context.Canceled) {
h.emit("agent_done", WebDoneData{Stopped: true})
return
}
// Raw run errors (eino NodeRunError wrapping go-openai API errors) are too
// noisy for the timeline — send a one-line summary plus the raw detail.
summary, detail := internalmodel.SummarizeRunError(err)
Expand Down
201 changes: 201 additions & 0 deletions internal/runner/cancel_backfill_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
package runner

import (
"context"
"errors"
"sync"
"testing"
"time"

"github.com/cloudwego/eino/adk"
einomodel "github.com/cloudwego/eino/components/model"
"github.com/cloudwego/eino/components/tool"
"github.com/cloudwego/eino/compose"
"github.com/cloudwego/eino/schema"

"github.com/cnjack/jcode/internal/handler"
"github.com/cnjack/jcode/internal/session"
)

// blockingTool simulates a long-running tool: it blocks until its context is
// canceled (user stop) and then returns the context error.
type blockingTool struct {
info *schema.ToolInfo
}

func newBlockingTool() *blockingTool {
return &blockingTool{info: &schema.ToolInfo{
Name: "block",
Desc: "blocks until the run is cancelled",
ParamsOneOf: schema.NewParamsOneOfByParams(map[string]*schema.ParameterInfo{
"note": {Type: schema.String, Desc: "ignored"},
}),
}}
}

func (bt *blockingTool) Info(context.Context) (*schema.ToolInfo, error) { return bt.info, nil }

func (bt *blockingTool) InvokableRun(ctx context.Context, _ string, _ ...tool.Option) (string, error) {
<-ctx.Done()
return "", ctx.Err()
}

// cancelModel streams one assistant message carrying a call to the blocking
// tool; if the run ever continues past the tool it answers with plain text.
type cancelModel struct{}

func (m *cancelModel) WithTools([]*schema.ToolInfo) (einomodel.ToolCallingChatModel, error) {
return m, nil
}

func (m *cancelModel) Generate(context.Context, []*schema.Message, ...einomodel.Option) (*schema.Message, error) {
return nil, errors.New("Generate is not used: streaming is enabled")
}

func (m *cancelModel) Stream(_ context.Context, input []*schema.Message, _ ...einomodel.Option) (*schema.StreamReader[*schema.Message], error) {
if last := input[len(input)-1]; last.Role == schema.Tool {
return schema.StreamReaderFromArray([]*schema.Message{
{Role: schema.Assistant, Content: "done"},
}), nil
}
return schema.StreamReaderFromArray([]*schema.Message{{
Role: schema.Assistant,
ToolCalls: []schema.ToolCall{{
ID: "call-block-1",
Function: schema.FunctionCall{Name: "block", Arguments: "{}"},
}},
}}), nil
}

// cancelRecordingHandler cancels the run as soon as the tool call is
// announced (the user hitting Stop) and records every tool result.
type cancelRecordingHandler struct {
stubHandler
cancel context.CancelFunc
done chan struct{}
mu sync.Mutex
results []handler.ToolResultEvent
doneErr error
}

func (h *cancelRecordingHandler) OnToolCall(handler.ToolCallEvent) { h.cancel() }

func (h *cancelRecordingHandler) OnToolResult(ev handler.ToolResultEvent) {
h.mu.Lock()
defer h.mu.Unlock()
h.results = append(h.results, ev)
}

func (h *cancelRecordingHandler) OnAgentDone(err error) {
h.mu.Lock()
h.doneErr = err
h.mu.Unlock()
close(h.done)
}

// TestRunInnerCancellationBackfillsToolResult stops the run while a tool is
// still executing. The announced tool call must not be left dangling: exactly
// one result reaches the handler, and the persisted session reconstructs into
// a history that satisfies the model API's tool-call/tool-message invariant
// (previously a resume of such a session failed with "assistant message with
// 'tool_calls' must be followed by tool messages...").
func TestRunInnerCancellationBackfillsToolResult(t *testing.T) {
t.Setenv("HOME", t.TempDir())

ctx, cancel := context.WithCancel(context.Background())
defer cancel()

ag, err := adk.NewChatModelAgent(ctx, &adk.ChatModelAgentConfig{
Name: "cancel-test",
Description: "cancel-test",
Instruction: "test",
Model: &cancelModel{},
ToolsConfig: adk.ToolsConfig{
ToolsNodeConfig: compose.ToolsNodeConfig{
Tools: []tool.BaseTool{newBlockingTool()},
},
},
MaxIterations: 5,
})
if err != nil {
t.Fatalf("create agent: %v", err)
}

rec, err := session.NewRecorder("cancel-test", "test", "test")
if err != nil {
t.Fatalf("create recorder: %v", err)
}
h := &cancelRecordingHandler{cancel: cancel, done: make(chan struct{})}

finished := make(chan bool, 1)
go func() {
_, done := runInner(ctx, ag, []adk.Message{schema.UserMessage("go")}, h, rec)
finished <- done
}()

select {
case done := <-finished:
if !done {
t.Errorf("runInner done = false, want true after cancellation")
}
case <-time.After(10 * time.Second):
t.Fatal("runInner did not return after cancellation")
}

select {
case <-h.done:
case <-time.After(time.Second):
t.Fatal("OnAgentDone was not called")
}
h.mu.Lock()
defer h.mu.Unlock()
if !errors.Is(h.doneErr, context.Canceled) {
t.Errorf("OnAgentDone err = %v, want context.Canceled", h.doneErr)
}

// The announced call got exactly one result (drain backfill or a folded
// framework result — either satisfies the invariant).
if len(h.results) != 1 {
t.Fatalf("tool results = %d, want exactly 1", len(h.results))
}
if h.results[0].ToolCallID != "call-block-1" {
t.Errorf("result ToolCallID = %q, want call-block-1", h.results[0].ToolCallID)
}

// The session on disk pairs the recorded call with a recorded result.
entries, err := session.LoadSession(rec.UUID())
if err != nil {
t.Fatalf("load session: %v", err)
}
var calls, results int
for _, e := range entries {
switch e.Type {
case session.EntryToolCall:
calls++
case session.EntryToolResult:
results++
if e.ToolCallID != "call-block-1" {
t.Errorf("recorded result for %q, want call-block-1", e.ToolCallID)
}
}
}
if calls != 1 || results != 1 {
t.Errorf("recorded calls=%d results=%d, want 1/1", calls, results)
}

state := session.ReconstructState(entries)
for i, m := range state.History {
if m.Role != schema.Assistant || len(m.ToolCalls) == 0 {
continue
}
answered := map[string]bool{}
for j := i + 1; j < len(state.History) && state.History[j].Role == schema.Tool; j++ {
answered[state.History[j].ToolCallID] = true
}
for _, tc := range m.ToolCalls {
if !answered[tc.ID] {
t.Errorf("reconstructed history: tool_call %s has no answering tool message", tc.ID)
}
}
}
}
28 changes: 24 additions & 4 deletions internal/runner/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,11 @@ func runInner(
// toolStarts records when each tool call was announced so results can
// carry a call→result latency. The event loop below is the only reader
// and writer (single goroutine), so no locking is needed.
toolStarts := make(map[string]time.Time)
type toolStart struct {
at time.Time
name string
}
toolStarts := make(map[string]toolStart)
// meter carries approval wait/denied outcomes from the approval path (see
// Run) so the emitted Duration is pure execution time and denied calls are
// flagged. emitToolResult also owns session recording so the persisted
Expand All @@ -247,7 +251,7 @@ func runInner(
emitToolResult := func(name, output, toolCallID string, err error) {
ev := handler.ToolResultEvent{Name: name, Output: output, ToolCallID: toolCallID, Err: err}
if started, ok := toolStarts[toolCallID]; ok {
ev.Duration = time.Since(started)
ev.Duration = time.Since(started.at)
delete(toolStarts, toolCallID)
}
applyApprovalOutcome(&ev, meter)
Expand All @@ -256,6 +260,19 @@ func runInner(
rec.RecordToolResult(name, output, toolCallID, err, ev.Denied, ev.Duration)
}
}
// drainDanglingToolResults backfills a result for every announced tool
// call that never produced one (user stop, fatal tool-node failure). The
// session must never persist a tool_call without its tool_result: the next
// resume would reconstruct a history the model API rejects ("assistant
// message with 'tool_calls' must be followed by tool messages..."). The
// backfill carries no error: an interrupted call is not a failed call, and
// a non-nil Err would paint every front-end's tool row red (raw
// "context.Canceled" text) right next to the calm stop notice.
drainDanglingToolResults := func() {
for id, started := range toolStarts {
emitToolResult(started.name, session.InterruptedToolOutput, id, nil)
}
}

config.Logger().Printf("[runner] runInner start, messages=%d", len(messages))
iterator := ag.Run(ctx, input)
Expand All @@ -270,6 +287,7 @@ func runInner(
// calm "Stopped". runInner owns this OnAgentDone (returns done=true),
// so Run does not emit a second one.
config.Logger().Printf("[runner] context cancelled, stopping iteration")
drainDanglingToolResults()
h.OnAgentDone(ctx.Err())
return assistantText.String(), true
default:
Expand All @@ -290,6 +308,7 @@ func runInner(
// "[NodeRunError] context canceled"); report the clean context
// error instead of the noisy wrapped one.
config.Logger().Printf("[runner] event error during cancellation: %v", event.Err)
drainDanglingToolResults()
h.OnAgentDone(ctx.Err())
return assistantText.String(), true
}
Expand All @@ -298,6 +317,7 @@ func runInner(
// so wrapping here fixes the display in the TUI, the web UI and ACP
// at once — and stops the next frontend from having to remember.
config.Logger().Printf("[runner] event error: %v", event.Err)
drainDanglingToolResults()
h.OnAgentDone(internalmodel.WrapFriendly(event.Err, "", ""))
return assistantText.String(), true
}
Expand Down Expand Up @@ -401,7 +421,7 @@ func runInner(
startedAt := time.Now()
for i, idx := range indices {
p := pending[idx]
toolStarts[p.id] = startedAt
toolStarts[p.id] = toolStart{at: startedAt, name: p.name}
h.OnToolCall(handler.ToolCallEvent{
Name: p.name,
Args: p.args.String(),
Expand All @@ -422,7 +442,7 @@ func runInner(
startedAt := time.Now()
size := len(mo.Message.ToolCalls)
for i, tc := range mo.Message.ToolCalls {
toolStarts[tc.ID] = startedAt
toolStarts[tc.ID] = toolStart{at: startedAt, name: tc.Function.Name}
h.OnToolCall(handler.ToolCallEvent{
Name: tc.Function.Name,
Args: tc.Function.Arguments,
Expand Down
Loading
Loading