Skip to content
Open
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
5 changes: 5 additions & 0 deletions forge-core/runtime/loop.go
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,8 @@ func (e *LLMExecutor) Execute(ctx context.Context, task *a2a.Task, msg *a2a.Mess
mem.Append(newMsg)
}

e.persistSession(task.ID, mem)

// Build tool definitions
var toolDefs []llm.ToolDefinition
if e.tools != nil {
Expand Down Expand Up @@ -382,6 +384,7 @@ func (e *LLMExecutor) Execute(ctx context.Context, task *a2a.Task, msg *a2a.Mess
// runner, which maps it to TaskStateCanceled +
// invocation_cancelled audit. See issue #88 / FWS-4.
if err := ctx.Err(); err != nil {
e.persistSession(task.ID, mem)
return nil, err
}

Expand Down Expand Up @@ -457,6 +460,7 @@ func (e *LLMExecutor) Execute(ctx context.Context, task *a2a.Task, msg *a2a.Mess
// route to invocation_cancelled instead of state=failed.
// See issue #88 / FWS-4.
if cerr := ctx.Err(); cerr != nil {
e.persistSession(task.ID, mem)
return nil, cerr
}
_ = e.hooks.Fire(ctx, OnError, &HookContext{
Expand Down Expand Up @@ -702,6 +706,7 @@ func (e *LLMExecutor) Execute(ctx context.Context, task *a2a.Task, msg *a2a.Mess
// orchestrators that cancel mid-iteration get fast exit
// without burning more LLM/tool spend. See issue #88 / FWS-4.
if err := ctx.Err(); err != nil {
e.persistSession(task.ID, mem)
return nil, err
}
toolsUsed = append(toolsUsed, tc.Function.Name)
Expand Down
183 changes: 183 additions & 0 deletions forge-core/runtime/loop_refresh_persistence_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
package runtime

import (
"context"
"errors"
"path/filepath"
"testing"

"github.com/initializ/forge/forge-core/a2a"
"github.com/initializ/forge/forge-core/llm"
)

// Regression test for the "browser refresh mid-response" bug: a client
// disconnect (e.g. a page reload) cancels the context passed to Execute
// before the LLM call ever completes. Before the fix, persistSession was
// only called on the successful-completion paths, so the user's own
// message — and any tool-call progress already made — was never written
// to disk if that cancellation raced ahead of completion. This test
// drives Execute with a context that's cancelled synchronously from
// inside the (mocked) LLM call, using a REAL file-backed MemoryStore, and
// asserts the persisted session file on disk contains the user's message
// even though Execute returns context.Canceled.
func TestLLMExecutor_RefreshMidResponse_UserMessagePersisted(t *testing.T) {
store, err := NewMemoryStore(filepath.Join(t.TempDir(), "sessions"))
if err != nil {
t.Fatalf("NewMemoryStore: %v", err)
}

var cancel context.CancelFunc
chatCalls := 0

exec := NewLLMExecutor(LLMExecutorConfig{
Client: &mockLLMClient{
chatFunc: func(_ context.Context, _ *llm.ChatRequest) (*llm.ChatResponse, error) {
chatCalls++
// Simulate the browser tab reloading right as the agent
// starts processing the turn: the client disconnects,
// forge-ui's proxy context cancels, which (pre-fix) would
// propagate all the way here before any persist happened.
cancel()
return nil, context.Canceled
},
},
Tools: &mockToolExecutor{toolDefs: []llm.ToolDefinition{}},
Store: store,
ModelName: "test",
Provider: "test",
})

var ctx context.Context
ctx, cancel = context.WithCancel(context.Background())
defer cancel()

const taskID = "refresh-test-task"
_, err = exec.Execute(ctx,
&a2a.Task{ID: taskID},
&a2a.Message{Role: a2a.MessageRoleUser, Parts: []a2a.Part{a2a.NewTextPart("hello, are you there?")}},
)
if !errors.Is(err, context.Canceled) {
t.Fatalf("Execute should return context.Canceled (simulating the refresh-aborted turn), got %v", err)
}
if chatCalls != 1 {
t.Fatalf("expected exactly 1 LLM call before cancellation, got %d", chatCalls)
}

// The critical assertion: even though the turn never completed, the
// user's message must already be on disk — durability of input must
// not depend on the response finishing.
saved, loadErr := store.Load(taskID)
if loadErr != nil {
t.Fatalf("store.Load: %v", loadErr)
}
if saved == nil {
t.Fatal("session was never persisted — the user's message is lost on refresh, exactly the bug this test guards against")
}

found := false
for _, m := range saved.Messages {
if m.Role == llm.RoleUser && m.Content == "hello, are you there?" {
found = true
break
}
}
if !found {
t.Fatalf("persisted session does not contain the user's message; got messages: %+v", saved.Messages)
}
}

// Companion test: once the turn eventually DOES complete (the agent kept
// working in the background per the detached-context fix in
// forge-ui/chat.go, even though the original HTTP client vanished), a
// second call to Execute against the SAME task ID must not need the
// original message again — it should already be recoverable from disk,
// and the session file must accumulate rather than reset.
func TestLLMExecutor_RefreshThenRetry_SessionAccumulates(t *testing.T) {
store, err := NewMemoryStore(filepath.Join(t.TempDir(), "sessions"))
if err != nil {
t.Fatalf("NewMemoryStore: %v", err)
}

const taskID = "refresh-retry-task"

// First turn: cancelled immediately (the "refresh").
var cancel context.CancelFunc
cancelExec := NewLLMExecutor(LLMExecutorConfig{
Client: &mockLLMClient{
chatFunc: func(_ context.Context, _ *llm.ChatRequest) (*llm.ChatResponse, error) {
cancel()
return nil, context.Canceled
},
},
Tools: &mockToolExecutor{toolDefs: []llm.ToolDefinition{}},
Store: store,
ModelName: "test",
Provider: "test",
})
var ctx context.Context
ctx, cancel = context.WithCancel(context.Background())
defer cancel()
_, err = cancelExec.Execute(ctx,
&a2a.Task{ID: taskID},
&a2a.Message{Role: a2a.MessageRoleUser, Parts: []a2a.Part{a2a.NewTextPart("first message")}},
)
if !errors.Is(err, context.Canceled) {
t.Fatalf("first Execute should be cancelled, got %v", err)
}

// Second turn: a fresh, un-cancelled context (simulating the agent
// process having kept running, or the user reconnecting and the
// session recovering from disk) completes normally.
okExec := NewLLMExecutor(LLMExecutorConfig{
Client: &mockLLMClient{
chatFunc: func(_ context.Context, req *llm.ChatRequest) (*llm.ChatResponse, error) {
return &llm.ChatResponse{
ID: "r2",
Message: llm.ChatMessage{Role: llm.RoleAssistant, Content: "all done"},
}, nil
},
},
Tools: &mockToolExecutor{toolDefs: []llm.ToolDefinition{}},
Store: store,
ModelName: "test",
Provider: "test",
})

_, err = okExec.Execute(context.Background(),
&a2a.Task{ID: taskID, History: nil},
&a2a.Message{Role: a2a.MessageRoleUser, Parts: []a2a.Part{a2a.NewTextPart("second message")}},
)
if err != nil {
t.Fatalf("second Execute should succeed, got %v", err)
}

saved, loadErr := store.Load(taskID)
if loadErr != nil {
t.Fatalf("store.Load: %v", loadErr)
}
if saved == nil {
t.Fatal("session missing after second turn")
}

var userMsgs []string
for _, m := range saved.Messages {
if m.Role == llm.RoleUser {
userMsgs = append(userMsgs, m.Content)
}
}
if len(userMsgs) != 2 {
t.Fatalf("expected both turns' user messages preserved in one session file (accumulated, not overwritten-and-lost), got %v", userMsgs)
}
if userMsgs[0] != "first message" || userMsgs[1] != "second message" {
t.Fatalf("unexpected message order/content: %v", userMsgs)
}

// Same task ID -> same file on disk, not a forked/orphaned session.
ids, listErr := store.List()
if listErr != nil {
t.Fatalf("store.List: %v", listErr)
}
if len(ids) != 1 {
t.Fatalf("expected exactly one session file on disk for this task ID, got %v", ids)
}
}
100 changes: 87 additions & 13 deletions forge-ui/chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package forgeui
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
Expand All @@ -16,6 +17,8 @@ import (
"github.com/initializ/forge/forge-core/auth"
)

const agentCallTimeout = 10 * time.Minute

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor (bound): with the detach, this 10-min timeout becomes the sole upper bound on a UI-initiated turn (a client disconnect no longer ends it), and the handler holds the agent connection/goroutine draining SSE until the agent finishes or this fires. That's bounded and intentional (same trade-off as #402), just worth being a conscious default — if agents legitimately run long tool chains past 10 min, the turn is cut (the persist-on-cancel you added does save the partial state, which is the right mitigation).


// handleChat proxies a chat message to a running agent via A2A JSON-RPC
// and streams the SSE response back to the browser.
func (s *UIServer) handleChat(w http.ResponseWriter, r *http.Request) {
Expand Down Expand Up @@ -73,10 +76,12 @@ func (s *UIServer) handleChat(w http.ResponseWriter, r *http.Request) {
return
}

// POST to the agent's A2A endpoint.
agentCtx, cancel := context.WithTimeout(context.WithoutCancel(r.Context()), agentCallTimeout)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MED–LOW — concurrent turns on the same session can clobber (last-write-wins). Because this detaches the agent call from the request, a refreshed-but-still-running turn keeps going. The resume flow polls status but doesn't lock out sending, so if the user sends again before the old turn settles, two Execute goroutines run on the same task ID and both persistSession to the same file. MemoryStore.Save is mutexed + atomic so the file won't corrupt — but the later Save overwrites the earlier turn's messages (logical loss), since each Execute persists its own mem.

Worth confirming the agent already serializes/rejects a second tasks/send for a task that's still working — if it doesn't, this is reachable. Cheapest UI-side guard: disable send while fetchSessionStatus reports working/submitted (you already expose exactly that signal via the new status endpoint).

defer cancel()

client := &http.Client{Timeout: 0}
agentURL := fmt.Sprintf("http://127.0.0.1:%d/", port)
agentReq, err := http.NewRequestWithContext(r.Context(), http.MethodPost, agentURL, bytes.NewReader(rpcBody))
agentReq, err := http.NewRequestWithContext(agentCtx, http.MethodPost, agentURL, bytes.NewReader(rpcBody))
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to create agent request")
return
Expand Down Expand Up @@ -115,17 +120,18 @@ func (s *UIServer) handleChat(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Connection", "keep-alive")
flusher.Flush()

// Parse agent SSE and re-emit to browser.
scanner := bufio.NewScanner(agentResp.Body)
var eventType string
var dataLines []string
clientGone := false

for scanner.Scan() {
// Check if client disconnected.
select {
case <-r.Context().Done():
return
default:
if !clientGone {
select {
case <-r.Context().Done():
clientGone = true
default:
}
}

line := scanner.Text()
Expand All @@ -135,11 +141,13 @@ func (s *UIServer) handleChat(w http.ResponseWriter, r *http.Request) {
} else if after, found := strings.CutPrefix(line, "data:"); found {
dataLines = append(dataLines, after)
} else if line == "" && eventType != "" {
// Blank line = end of SSE frame. Re-emit to browser.
data := strings.TrimSpace(strings.Join(dataLines, "\n"))
if data != "" {
_, _ = fmt.Fprintf(w, "event: %s\ndata: %s\n\n", eventType, data)
flusher.Flush()
// Blank line = end of SSE frame. Re-emit to browser (if still connected).
if !clientGone {
data := strings.TrimSpace(strings.Join(dataLines, "\n"))
if data != "" {
_, _ = fmt.Fprintf(w, "event: %s\ndata: %s\n\n", eventType, data)
flusher.Flush()
}
}
eventType = ""
dataLines = nil
Expand All @@ -152,6 +160,72 @@ func (s *UIServer) handleChat(w http.ResponseWriter, r *http.Request) {
flusher.Flush()
}

func (s *UIServer) handleSessionStatus(w http.ResponseWriter, r *http.Request) {
agentID := r.PathValue("id")
sid := r.PathValue("sid")
if agentID == "" || sid == "" {
writeError(w, http.StatusBadRequest, "agent id and session id are required")
return
}

agents, err := s.scanner.Scan()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
agent := agents[agentID]
if agent == nil || agent.Port == 0 {
writeJSON(w, http.StatusOK, map[string]string{"state": "unknown"})
return
}

rpcBody, err := json.Marshal(map[string]any{
"jsonrpc": "2.0",
"id": 1,
"method": "tasks/get",
"params": map[string]any{"id": sid},
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to build request")
return
}

ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()

agentURL := fmt.Sprintf("http://127.0.0.1:%d/", agent.Port)
agentReq, err := http.NewRequestWithContext(ctx, http.MethodPost, agentURL, bytes.NewReader(rpcBody))
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to create agent request")
return
}
agentReq.Header.Set("Content-Type", "application/json")
if token := s.loadAgentToken(agentID); token != "" {
agentReq.Header.Set("Authorization", "Bearer "+token)
}

client := &http.Client{Timeout: 6 * time.Second}
agentResp, err := client.Do(agentReq)
if err != nil {
writeJSON(w, http.StatusOK, map[string]string{"state": "unknown"})
return
}
defer func() { _ = agentResp.Body.Close() }()

var rpcResp struct {
Result *struct {
Status struct {
State string `json:"state"`
} `json:"status"`
} `json:"result"`
}
if err := json.NewDecoder(agentResp.Body).Decode(&rpcResp); err != nil || rpcResp.Result == nil {
writeJSON(w, http.StatusOK, map[string]string{"state": "unknown"})
return
}
writeJSON(w, http.StatusOK, map[string]string{"state": rpcResp.Result.Status.State})
}

// handleListSessions returns stored chat sessions for an agent.
func (s *UIServer) handleListSessions(w http.ResponseWriter, r *http.Request) {
agentID := r.PathValue("id")
Expand Down
1 change: 1 addition & 0 deletions forge-ui/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ func (s *UIServer) Start(ctx context.Context) error {
mux.HandleFunc("POST /api/agents/{id}/chat", s.handleChat)
mux.HandleFunc("GET /api/agents/{id}/sessions", s.handleListSessions)
mux.HandleFunc("GET /api/agents/{id}/sessions/{sid}", s.handleGetSession)
mux.HandleFunc("GET /api/agents/{id}/sessions/{sid}/status", s.handleSessionStatus)

// Phase 3: Create & Configure routes
mux.HandleFunc("GET /api/wizard/meta", s.handleGetWizardMeta)
Expand Down
Loading
Loading