Skip to content

[Bug]: Chat session lost/forked when browser is refreshed mid-response in forge-ui #375

Description

@utkarshkatiyar1

Component

Other

Description

When using forge ui, refreshing the browser tab while an agent response is
still streaming (in-flight) can cause the in-progress turn — and sometimes
the entire visible chat session — to disappear. This is not actually data
corruption; it's three compounding, independent bugs:

  1. forge-ui/chat.go:79 builds the outbound A2A request to the agent process
    using r.Context() (the browser's own request context). When the browser
    tab reloads, the underlying fetch is aborted, cancelling this context,
    which propagates all the way into forge-core/runtime/loop.go's agent
    loop. The loop checks ctx.Err() at multiple points (loop.go ~377/452/697)
    and returns early on cancellation — but persistSession (loop.go
    ~674/683/848) is only called on the successful-completion path. So a
    cancelled turn is never written to disk, including the user's own
    just-sent message.

  2. If the cancelled turn was the FIRST turn of a brand-new session, no
    session file has ever been created (forge-core/runtime/memory_store.go
    Save() only creates the file when called), so the whole session appears
    to have never existed — GET /api/agents/{id}/sessions returns an empty
    list.

  3. Separately (and this is what actually caused my "successful chat vanished"
    report): forge-ui/static/app.js keeps sessionId in pure in-memory
    React state (useState(null)), with no persistence to the URL, a
    cookie, or localStorage. Any page reload resets it to null. If the user
    then sends another message from this blank-looking state, chat.go:51-54
    mints a brand-new session ID (fmt.Sprintf("%s-%d", agentID, time.Now().UnixNano())) rather than resuming the old one. This forks a
    second, disconnected session file — the original conversation's JSON
    file is still fully intact on disk, but it's orphaned from the UI and
    looks like it "vanished."

I verified this directly: on my machine, .forge/sessions/ for one agent
contains a 20KB file with a full multi-turn conversation, alongside two
~300-byte orphaned single-exchange session files created after refreshes —
confirming Save() does a full-file overwrite per task ID (atomic
temp+fsync+rename, forge-core/runtime/memory_store.go Save()), never an
append-across-files, and that no existing successful session data was
actually destroyed — it was silently orphaned instead.

A fourth, related gap: forge-core/a2a/taskstore.go is a pure in-memory
map with no disk backing, and forge-ui/static/app.js never calls
tasks/get or any resubscribe endpoint to reattach to a still-in-flight
task after reload — so even though the A2A spec supports resubscription,
the frontend has no reconnect logic at all.

Steps to reproduce

  1. Run forge init to scaffold an agent, then forge run/forge serve to
    start it, then forge ui to open the web dashboard.
  2. Open the agent's chat page, send a message, and while the response is
    still streaming/loading, refresh the browser tab (F5 / Ctrl+R).
  3. Observe the chat pane is now empty (no session auto-loaded).
  4. Type a new message without clicking anything in the "Sessions" sidebar.
  5. Check .forge/sessions/ on disk — a new session JSON file has been
    created alongside the original, and the original's conversation is no
    longer reachable from the UI without manually finding it in the sidebar.
  6. For a brand-new agent with no prior turns, repeat steps 1-2 on the very
    first message — the sessions list (sidebar) returns empty entirely,
    since no file was ever written before the refresh interrupted the turn.

Expected behavior

  • Refreshing the page should resume the same session automatically (session
    id persisted in the URL, e.g. #/agent/{id}/session/{sid}), not silently
    fork a new one.
  • A user's sent message should be durably persisted as soon as it's
    received by the agent, independent of whether the LLM response completes,
    so a refresh never loses the user's own input.
  • Ideally, reloading mid-response should reattach to the still-in-flight
    task and continue streaming the answer in, rather than leaving the user
    unsure whether their message was received.
  • The server-side proxy call to the agent process should not be cancelled
    just because the browser's own HTTP connection was dropped (a reload is
    not a deliberate "stop generating" action).

Actual behavior

  • The outbound proxy request in forge-ui/chat.go inherits the incoming
    browser request's context, so a page reload cancels agent processing
    outright instead of merely disconnecting the display.
  • persistSession (forge-core/runtime/loop.go) only saves on successful
    completion, so any cancelled turn — including the user's own message —
    is never written to disk.
  • The frontend (forge-ui/static/app.js) keeps sessionId only in React
    state, with no URL/cookie/localStorage persistence, so every reload
    drops the active session and risks forking a new one on next send.
  • There is no resubscribe/reconnect path from the frontend to an
    in-flight A2A task, so a reload during streaming looks like data loss
    even when the agent is still working server-side.

Operating system

Windows 11

Go version

go1.25.1 windows/amd64

forge.yaml (if relevant)

agent_id: assistant
version: 0.1.0
framework: forge

model:
  provider: openai
  name: gpt-5.4
  version: "latest"

compression:
  enabled: true

egress:
  mode: allowlist
  allowed_domains:
    - api.openai.com
    - api.openweathermap.org
    - cdn.jsdelivr.net
    - cdn.tailwindcss.co
    - registry.npmjs.org

secrets:
  providers:
    - env

scheduler:
  backend: file

Logs / error output

No error is thrown client- or server-side — this is a silent behavioral
gap, not a crash. Confirmed via direct filesystem inspection of
.forge/sessions/*.json: one 20KB file (real multi-turn conversation,
untouched) alongside orphaned ~300-byte single-exchange files created
after refreshes, e.g.:

{
  "task_id": "assistant-1785251106286801900",
  "messages": [
    {"role": "user", "content": "hi mate"},
    {"role": "assistant", "content": "Hi mate — how can I help?"}
  ],
  "created_at": "2026-07-28T15:05:10.0788456Z",
  "updated_at": "2026-07-28T15:05:10.0788456Z"
}

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions