fix(widget): merge anonymous visitor history in cookie authentication mode (#104) - #127
Conversation
… 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
|
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.
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, So this sequence is still possible: 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.
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: 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
One smaller consistency issue: the PR description says cookie mode keeps the pass when 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
|
Thanks for the detailed review @Asaf-prog! I've addressed all points in the latest commits ( 1. Same-SPA cookie login hand-off
2. Deterministic execution ordering (no race conditions)Reverted background 3. Clear pass lifecycle alignmentUpdated 4. Unit & E2E Test Coverage
All 877 pytest unit tests, 39 Playwright E2E tests, and typechecks/linters are passing cleanly. Ready for review! |
|
Thanks — the ordering issue is fixed now, and the I think there is still one important point we need to settle before merging: does cookie mode require the host to call 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: 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 If the intended contract is:
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 |
- 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
|
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 I've updated async current(): Promise<string | null> {
if (!this.cached || (this.isCookieMode() && this.storedPass() !== null)) {
await this.resolve(() => this.storedPass());
}
return this.cached;
} |
|
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 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 In cookie mode that leads to: Because There is already a /** 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
I’d also add a unit/E2E test proving that multiple requests while still signed out do not cause one One smaller thing: the PR description still says the link hand-off is non-blocking/fire-and-forget, while the current implementation now correctly Once the repeated unauthenticated link-attempt issue is addressed, I think this is very close to approval. |
|
sorry i merge it by mistake :) |
Summary
Fixes #104: Anonymous visitor conversation history is stranded when a host application uses same-origin session cookies (
host_tokenmode) instead of explicit Bearer tokens (token-url/tokenProvider).Root Cause
In
TokenSource.ts,fromHost()returnsnullby design in cookie mode (letting the browser's same-site session cookie authenticate requests). Previously,claimVisitorHistory(token)was guarded byif (token), so whenfromHost()returnednull,claimVisitorHistory()was never called andPOST /auth/linkwas never triggered.Fix
hostToken()inTokenSource.tsto triggerclaimVisitorHistory(token)whenever a host token exists OR a stored visitor pass (storedPass()) exists inlocalStorage.claimVisitorHistory(token)as a non-blocking background task (void this.claimVisitorHistory(token)), ensuringtokens.current()resolves instantly without delaying request execution or blocking page startup.credentials: "include"with{ anonymous_token: pass }toPOST /auth/link.hostToken !== null): Clears pass on HTTP 200 OK or 4xx client errors.hostToken === null): Clears pass only ifresponse.okANDconversations_moved > 0. IfPOST /auth/linkreturns 401 (user not logged in via cookie yet) or 0 moved conversations, the pass is retained inlocalStorageto attempt linking when the user logs in.src/agent_manager/api/static/widget.js.test_linking_via_cookie_authentication()intest_api.pyand updated Playwright mock handlers inwidget.spec.ts.Verification
ruff format --check src tests,ruff check src tests, andmypy src/agent_managerall pass with 0 errors.PYTHONPATH=src pytest-> 876 passed.npm run test:widget:e2e-> 38 passed.