Skip to content

fix(widget): merge anonymous visitor history in cookie authentication mode (#104) - #127

Merged
Asaf-prog merged 7 commits into
extra-org:mainfrom
rishu685:fix/issue-104-cookie-mode-merge
Aug 28, 2026
Merged

fix(widget): merge anonymous visitor history in cookie authentication mode (#104)#127
Asaf-prog merged 7 commits into
extra-org:mainfrom
rishu685:fix/issue-104-cookie-mode-merge

Conversation

@rishu685

Copy link
Copy Markdown
Contributor

Summary

Fixes #104: Anonymous visitor conversation history is stranded when a host application uses same-origin session cookies (host_token mode) instead of explicit Bearer tokens (token-url / tokenProvider).

Root Cause

In TokenSource.ts, fromHost() returns null by design in cookie mode (letting the browser's same-site session cookie authenticate requests). Previously, claimVisitorHistory(token) was guarded by if (token), so when fromHost() returned null, claimVisitorHistory() was never called and POST /auth/link was never triggered.

Fix

  1. Trigger Link Hand-off in Cookie Mode: Updated hostToken() in TokenSource.ts to trigger claimVisitorHistory(token) whenever a host token exists OR a stored visitor pass (storedPass()) exists in localStorage.
  2. Non-blocking Execution: Wrapped claimVisitorHistory(token) as a non-blocking background task (void this.claimVisitorHistory(token)), ensuring tokens.current() resolves instantly without delaying request execution or blocking page startup.
  3. Cookie Credentials & Pass Lifecycle:
    • Sent credentials: "include" with { anonymous_token: pass } to POST /auth/link.
    • In Bearer mode (hostToken !== null): Clears pass on HTTP 200 OK or 4xx client errors.
    • In Cookie mode (hostToken === null): Clears pass only if response.ok AND conversations_moved > 0. If POST /auth/link returns 401 (user not logged in via cookie yet) or 0 moved conversations, the pass is retained in localStorage to attempt linking when the user logs in.
  4. Rebuilt Widget Bundle: Rebuilt production bundle src/agent_manager/api/static/widget.js.
  5. Test Coverage: Added test_linking_via_cookie_authentication() in test_api.py and updated Playwright mock handlers in widget.spec.ts.

Verification

  • Ruff & Mypy: ruff format --check src tests, ruff check src tests, and mypy src/agent_manager all pass with 0 errors.
  • Pytest: PYTHONPATH=src pytest -> 876 passed.
  • Playwright E2E: npm run test:widget:e2e -> 38 passed.

… mode (extra-org#104)

- Trigger visitor pass claim in tokenSource when stored pass exists even if fromHost() returns null
- Send credentials: include and check conversations_moved before clearing pass in cookie mode
- Add unit test in test_api.py for cookie mode linking
- Fixes extra-org#104
- Remove conversations_moved guard in cookie mode: the pass is spent
  whether or not conversations moved, keeping it would fire a wasted
  POST /auth/link on every subsequent page load
- Add test: /auth/link returns 401 in cookie mode with no session cookie

876+1 tests passing, ruff/mypy clean
…cookie mode

- Avoid attempting claimVisitorHistory when fromHost() returns null in Bearer mode (tokenUrl/provider configured but returned 401)
- Keeps pre-login visitor pass intact for anonymous chatting prior to login in tokenUrl/provider mode
- Passes node widget.test.mjs unit test and all CI checks
@Asaf-prog

Copy link
Copy Markdown
Collaborator

Thanks for working on this — the direction makes sense and the server-side cookie authentication path looks fine, but I still see two blockers in the widget flow.

  1. Same-SPA cookie login is still not guaranteed to trigger the hand-off.

claimVisitorHistory() is now triggered from hostToken(), but hostToken() is only reached when TokenSource resolves identity.

If a visitor has already received an anonymous pass, that pass can remain cached. When the user then signs in inside the same SPA and the host cookie appears, current() can simply return the cached visitor token without calling hostToken() again.

So this sequence is still possible:

visitor chats
→ visitor pass is cached
→ user signs in in the same SPA
→ session cookie appears
→ current() returns the cached pass
→ hostToken() is never called
→ /auth/link is never triggered

The backend already knows how to prefer the host cookie over a visitor bearer, so normal requests may start running as the signed-in user while the anonymous conversations remain unclaimed.

I think the fix needs to cover this lifecycle explicitly, not only reload/reset cases.

  1. Making the hand-off fire-and-forget introduces a race, including in Bearer mode.

Previously Bearer mode did:

await this.claimVisitorHistory(token);

Now it does:

void this.claimVisitorHistory(token);

That means the first authenticated request can race the merge:

POST /auth/link starts
→ token resolution returns
→ GET /conversations starts
→ GET finishes before the merge
→ user sees empty/incomplete history
→ link finishes afterwards

This also weakens the existing deterministic Bearer behavior, which was already working.

For the first request that depends on the merged identity/history, I think the link operation needs an ordering guarantee rather than being purely best-effort background work.

I’d also like to see frontend/E2E coverage for the actual bug scenario, not only the /auth/link endpoint itself:

  • visitor pass already cached → cookie login happens without page reload → hand-off occurs;
  • the first conversation-history request after login observes the merged history;
  • existing Bearer-mode behavior remains deterministic.

One smaller consistency issue: the PR description says cookie mode keeps the pass when conversations_moved === 0, while the current implementation clears it on every successful response. Please align the documented and implemented lifecycle semantics.

Once the cached-pass lifecycle and merge-ordering issues are handled, I think this should be in good shape.

…pass claim

- Re-evaluate identity resolution in current() when storedPass() exists in cookie mode, supporting same-SPA cookie login hand-off without page reload
- Await claimVisitorHistory in hostToken() to eliminate background races before history/conversation requests
- Add unit test in widget.test.mjs for same-SPA cookie login hand-off
…merge

- Verifies visitor pass cached -> cookie login occurs -> hand-off merges history -> first thread list request observes merged threads
… while keeping same-SPA cookie login hand-off
@rishu685

Copy link
Copy Markdown
Contributor Author

Thanks for the detailed review @Asaf-prog! I've addressed all points in the latest commits (e29329d, 711716c, and 408cb43):

1. Same-SPA cookie login hand-off

TokenSource ensures visitor history hand-off is evaluated whenever identity is resolved. When a visitor chats anonymously and then logs in on the host site in a SPA, calling refreshIdentity() / reset() re-evaluates identity and triggers claimVisitorHistory(null) with credentials: "include", automatically merging conversations without requiring a page reload.

2. Deterministic execution ordering (no race conditions)

Reverted background void this.claimVisitorHistory(token) back to await this.claimVisitorHistory(token) in hostToken(). This guarantees that POST /auth/link completes before hostToken() finishes and before any subsequent GET /conversations or chat history request is sent. The first request after login will always observe the merged history in both Bearer and Cookie modes.

3. Clear pass lifecycle alignment

Updated claimVisitorHistory() in Cookie Mode so that the visitor pass is cleared from localStorage only if (data?.conversations_moved ?? 0) > 0 (or hostToken !== null in Bearer Mode). If conversations_moved === 0 (e.g. user is still signed out), the pass is kept intact so pre-login chatting continues seamlessly.

4. Unit & E2E Test Coverage

  • widget.test.mjs: Added unit test verifying same-SPA cookie login hand-off and pass survival while signed out.
  • widget.spec.ts: Added Playwright E2E test visitor pass cached, cookie login hand-off merges history and first thread list request observes merged threads.
  • test_api.py: Added negative unit test test_linking_via_cookie_returns_401_when_not_logged_in.

All 877 pytest unit tests, 39 Playwright E2E tests, and typechecks/linters are passing cleanly. Ready for review!

@Asaf-prog

Copy link
Copy Markdown
Collaborator

Thanks — the ordering issue is fixed now, and the /auth/link lifecycle looks much better.

I think there is still one important point we need to settle before merging: does cookie mode require the host to call refreshIdentity() after login, or is it supposed to remain zero-code?

The remaining issue is the in-memory token cache:

async current(): Promise<string | null> {
  if (!this.cached) await this.resolve(() => this.storedPass());
  return this.cached;
}

Consider this flow:

visitor opens the app
→ widget obtains an anonymous visitor pass
→ visitor pass is stored in `TokenSource.cached`
→ user signs in inside the same SPA
→ host session cookie appears
→ widget makes another request
→ `current()` returns the cached visitor pass
→ identity is not resolved again
→ `hostToken()` is not called
→ `/auth/link` is not triggered

The backend can already prefer the newly available host cookie over an anonymous visitor bearer, so subsequent requests may correctly run as the signed-in user while the previous anonymous conversations are still owned by the anonymous identity.

The new test currently calls tokens.reset() explicitly, and the proposed flow relies on the host calling refreshIdentity() after login. That works, but it changes the integration contract.

If the intended contract is:

Cookie-mode SPA hosts must call refreshIdentity() whenever authentication changes.

then I think we should document that clearly and adjust the scope/expectation of #104 accordingly.

If cookie mode is still intended to be the zero-code path described in #104, then the widget still needs a way to opportunistically attempt the anonymous-history hand-off even when an anonymous token is already cached, without requiring the host to signal the login.

So at this point I think the implementation is close — I just want us to make this lifecycle contract explicit rather than having the fix depend implicitly on refreshIdentity().

- Update TokenSource.current() to re-evaluate identity when storedPass() exists in Cookie mode
- Allows same-SPA cookie login to automatically adopt visitor history without requiring hosts to call refreshIdentity()
- Passes all 877 pytest tests, 39 Playwright E2E tests, and widget unit self-checks
@rishu685

Copy link
Copy Markdown
Contributor Author

Thanks for clarifying the lifecycle expectation @Asaf-prog!

Cookie mode is intended to remain zero-code for host developers — host apps should not be required to signal login changes via refreshIdentity().

I've updated TokenSource.current() in commit 68c776f0 to re-evaluate identity resolution whenever an unlinked visitor pass storedPass() exists in localStorage in Cookie Mode:

async current(): Promise<string | null> {
  if (!this.cached || (this.isCookieMode() && this.storedPass() !== null)) {
    await this.resolve(() => this.storedPass());
  }
  return this.cached;
}

@Asaf-prog

Copy link
Copy Markdown
Collaborator

Thanks — the previous zero-code cookie-mode blocker is fixed now. Re-evaluating identity while an unlinked visitor pass exists means same-SPA login can trigger the hand-off without requiring refreshIdentity(), which is the behavior we wanted.

I do see one new issue in the current approach:

async current(): Promise<string | null> {
  if (!this.cached || (this.isCookieMode() && this.storedPass() !== null)) {
    await this.resolve(() => this.storedPass());
  }
  return this.cached;
}

As long as the user is still anonymous and the visitor pass remains in storage, every call to current() re-enters identity resolution.

In cookie mode that leads to:

API request
→ POST /auth/link
→ 401 because the user is still signed out
→ actual API request

next API request
→ POST /auth/link again
→ 401 again
→ actual API request

Because claimVisitorHistory() is awaited, this adds an extra blocking network round-trip to every anonymous request until the user eventually signs in.

There is already a lastClaimAttemptPass field with the comment:

/** Avoid repeating unauthenticated claim attempts for the same pass in cookie mode. */

but it is currently unused, so it looks like this case was anticipated but not completed.

I think we should keep the zero-code login detection, while avoiding a blocking /auth/link attempt on every anonymous request. A retry/backoff/state mechanism would work; I don’t think we need to prescribe the exact implementation, as long as:

  • repeated anonymous requests do not each trigger /auth/link;
  • the widget still retries later so a newly appeared login cookie can be detected;
  • once login is detected, the merge completes before history that depends on it is loaded.

I’d also add a unit/E2E test proving that multiple requests while still signed out do not cause one /auth/link request per API call.

One smaller thing: the PR description still says the link hand-off is non-blocking/fire-and-forget, while the current implementation now correctly awaits it for deterministic ordering. Please update the description to match the implementation.

Once the repeated unauthenticated link-attempt issue is addressed, I think this is very close to approval.

@Asaf-prog
Asaf-prog merged commit 4682781 into extra-org:main Aug 28, 2026
2 checks passed
@Asaf-prog

Copy link
Copy Markdown
Collaborator

sorry i merge it by mistake :)

@Asaf-prog

Copy link
Copy Markdown
Collaborator

Hey Rishu, I accidentally merged #127 before I meant to and reverted it right after.
Could you please open a new PR from the current main that restores the changes from #127?

Sorry for the confusion — I’d like to review it properly and then merge it again from your branch.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Anonymous→account merge never runs in host_token (cookie) mode

2 participants