-
Notifications
You must be signed in to change notification settings - Fork 12
Feature/session persistence #407
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
5e2504b
d677274
34b52bd
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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) | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,6 +3,7 @@ package forgeui | |
| import ( | ||
| "bufio" | ||
| "bytes" | ||
| "context" | ||
| "encoding/json" | ||
| "fmt" | ||
| "io" | ||
|
|
@@ -16,6 +17,8 @@ import ( | |
| "github.com/initializ/forge/forge-core/auth" | ||
| ) | ||
|
|
||
| const agentCallTimeout = 10 * time.Minute | ||
|
|
||
| // 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) { | ||
|
|
@@ -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) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Worth confirming the agent already serializes/rejects a second |
||
| 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 | ||
|
|
@@ -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() | ||
|
|
@@ -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 | ||
|
|
@@ -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") | ||
|
|
||
There was a problem hiding this comment.
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).