Skip to content

fix(example): fix/data-agent-multi-session-inbox-routing - #3095

Open
xy-ygz wants to merge 1 commit into
agentscope-ai:mainfrom
xy-ygz:fix/data-agent-multi-session-inbox-routing
Open

fix(example): fix/data-agent-multi-session-inbox-routing#3095
xy-ygz wants to merge 1 commit into
agentscope-ai:mainfrom
xy-ygz:fix/data-agent-multi-session-inbox-routing

Conversation

@xy-ygz

@xy-ygz xy-ygz commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

AgentScope-Java Version

2.0.3-SNAPSHOT

Description

Background

Fixes #1626
After the data-agent is launched locally, creating a second chat session causes the new session to replace the previous one in the left history sidebar, so only one session ever appears. Expected behavior: multiple independent sessions for the same user and agent should coexist in the inbox.
New conversations keep overwriting old ones, leaving only a single session visible on the left sidebar:
da修复前
da修复前2

Root causes

Every new conversation created on the frontend generates a distinct conversationId. However, the backend route does not incorporate this value into the session identifier. As a result, all conversations are mapped to the same backend session, which explains why the inbox only returns a single entry.

  • Frontend: works correctly, each new conversation gets a unique ID
  • Backend: the wrong field is passed for conversationId, so the routing layer cannot distinguish individual conversations
  • Result: the gateway reuses the same MAIN session → only one entry appears in the history list

Repair ideas

  • Use Thread peer to inject conversationId into the routing key (|t:xxx), so that each new conversation maps to a dedicated backend session.
  • As a side improvement: instead of throwing an obscure NPE when no LLM is configured, return a prompt asking for the DASHSCOPE_API_KEY configuration.

Changes

File Change
ChatController Consistently use buildConversationInbound() to construct inbound messages, and perform routing based on conversation‑ID
ChatController Fail fast when no model is configured, with a hint to set the API Key
ChatControllerConversationRoutingTest Unit test: verify distinct conversationId resolves to different gateKey

after fix:
da修复后

How to test

  1. Run unit tests:

    mvn -pl agentscope-examples/agents/agentscope-dataagent -am \
      test -Dtest=ChatControllerConversationRoutingTest \
      -Dfrontend-maven-plugin.skip=true

    Expected: BUILD SUCCESS.

  2. Manual verification
    ‑ Configure DASHSCOPE_API_KEY and start the Data Agent
    ‑ Send messages in Session A → one entry appears in the sidebar
    ‑ Click "New Conversation", send messages in Session B → two entries show up in the sidebar
    ‑ Switch back to Session A and refresh the page → both entries remain, conversation history works as expected

Checklist

  • mvn spotless:apply(如需要)
  • 单测通过(ChatControllerConversationRoutingTest,BUILD SUCCESS)
  • 设计文档已补充

@codecov

codecov Bot commented Sep 10, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@oss-maintainer oss-maintainer left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Summary

Per-conversationId gate keys via a thread peer is the right fix for multiple chats collapsing into one session, and funneling both the currentSession probe and the real dispatch through buildConversationInbound removes the duplicated key construction that caused the drift. COMMENT because the DM-to-thread key change needs an explicit migration answer and one new message is provider-specific.

Findings

  • [Critical] ChatController.java:582 — no-agent path changes the canonical key shape; pre-existing sessions will not resolve (silent context reset).
  • [Warning] ChatController.java:565DASHSCOPE_API_KEY text hardcoded in a generic controller.
  • [Info] ChatController.java:391 — probe/dispatch equality is asserted nowhere.
  • [Info] test — blank-conversationId case missing.

Suggestions

If pre-change sessions must keep working, fall back to the DM shape when conversationId is blank or resolve both keys on lookup; otherwise record the intentional break in the PR description.


Automated review by github-manager-bot

if (agentId == null || agentId.isBlank()) {
// No agent override and no conversation scoping — pure binding-driven routing.
inbound = InboundMessage.dm(ChatUiChannel.CHANNEL_ID, userId, List.copyOf(msgs));
inbound = buildConversationInbound(userId, null, conversationId, msgs);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This changes session identity for the no-agentId path, not just routing. Previously that branch built InboundMessage.dm(...), whose canonical key has no |t: segment; now the same user gets a thread-scoped key. Sessions registered under the old DM-shaped key stop matching, so an existing chat silently starts a fresh context after upgrade — and resolveGateKey now returns null when conversationId is blank, so currentSession reports exists=false. Is there a migration/back-compat path (keep the DM key when conversationId is blank, or match both keys on lookup)? If the dataagent example has no persisted users to migrate, worth stating in the PR description so reviewers can accept the break.

List<Msg> payload = messages != null ? List.copyOf(messages) : List.of();
InboundMessage.Builder builder =
InboundMessage.builder(
ChatUiChannel.CHANNEL_ID, Peer.thread(conversationId), payload)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Peer.thread(conversationId) + senderId(userId) + parentPeer(Peer.direct(userId)) is the kind of triple that breaks quietly if a router rule later keys off parentPeer. Routing both branches through buildConversationInbound is the right shape, but the test only asserts the key format, not that the dispatched inbound in executeChat yields the same canonicalKey as the resolveGateKey probe. One assertion tying probe and dispatch together would prevent exactly the drift that caused this bug.

String userId, String agentId, String message, String conversationId) {
long startMs = System.currentTimeMillis();

if (agentId != null && !agentId.isBlank()) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The new ha.getModel() == null pre-check hardcodes provider-specific guidance (DASHSCOPE_API_KEY, dataagent.dashscope.api-key) into a generic controller, while HarnessAgent is model-agnostic across the OpenAI/Anthropic/Gemini/Ollama extensions — this message will mislead users on another provider. Consider a provider-neutral wording, and/or surfacing it where the model is actually resolved so all entry points benefit, not only executeChat.

class ChatControllerConversationRoutingTest {

private static final ChatUiChannel CHANNEL =
ChatUiChannel.create(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Solid focused test. Two additions would lock the fix down: (1) blank/null conversationId on the no-agent path — asserting the resulting key documents the migration decision above; (2) a regression that two turns with the same conversationId map to the same key while a different one does not, which is the user-visible invariant.

@xy-ygz
xy-ygz force-pushed the fix/data-agent-multi-session-inbox-routing branch from 00eddcd to e3f8c90 Compare September 11, 2026 14:29
@xy-ygz

xy-ygz commented Sep 11, 2026

Copy link
Copy Markdown
Contributor Author

Summary

Per-conversationId gate keys via a thread peer is the right fix for multiple chats collapsing into one session, and funneling both the currentSession probe and the real dispatch through buildConversationInbound removes the duplicated key construction that caused the drift. COMMENT because the DM-to-thread key change needs an explicit migration answer and one new message is provider-specific.

Findings

  • [Critical] ChatController.java:582 — no-agent path changes the canonical key shape; pre-existing sessions will not resolve (silent context reset).
  • [Warning] ChatController.java:565DASHSCOPE_API_KEY text hardcoded in a generic controller.
  • [Info] ChatController.java:391 — probe/dispatch equality is asserted nowhere.
  • [Info] test — blank-conversationId case missing.

Suggestions

If pre-change sessions must keep working, fall back to the DM shape when conversationId is blank or resolve both keys on lookup; otherwise record the intentional break in the PR description.

Automated review by github-manager-bot

Thanks for the thorough review. I have addressed all feedback in the latest push.

[Critical] DM‑to‑thread key change & migration
This point deserves explicit clarification. The key‑shape change for pinned conversations is intentional. Prior to this fix, the routing logic never populated conversationId into gateKey, which meant multi‑session support was already non‑functional; existing persisted entries do not contain the |t: segment.
For the data‑agent example module, we accept that upgraded instances will start with fresh sessions. This breaking behaviour is documented in the PR description.

For the defensive fallback path: when conversationId is blank, executeChat now falls back to InboundMessage.dm(...), restoring the pre‑change behaviour. The stream / send code paths always mint a UUID before dispatch, so regular UI flows consistently use thread‑scoped keys. A new test blankConversationIdUsesDmShapedGateKeyWithoutThreadSegment is added to lock in this behaviour.

[Warning] Hard‑coded DASHSCOPE message
Replaced with provider‑neutral wording: configure a Model Spring bean or set dataagent.* properties in application.yml.

[Info] probe / dispatch canonical‑key equality
Added test probeAndDispatchInboundShareCanonicalKey. Empty‑message probe and message‑bearing dispatch now produce the identical canonicalKey by reusing the shared buildConversationInbound() helper.

[Info] same‑conversation‑id invariant & blank‑id handling
Added sameConversationIdMapsToSameGateKey, complemented by the aforementioned blank‑id test case.

For this example module, I do not think strict backward‑compatibility for pre‑fix persisted sessions (e.g. implementing dual‑key lookup within findSessionKeyByGate) is necessary. Documenting this breaking change should suffice

@oss-maintainer oss-maintainer left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Summary

Adds per-conversation (|t: thread) session scoping to the data-agent chat controller via a shared buildConversationInbound(...) helper, plus a routing test that pins distinct gate keys per conversation and stable keys for the same conversation. The refactor is a clear improvement: probe and dispatch now go through one builder, so the canonical key they compare cannot drift apart the way the previous hand-rolled accountId(conversationId) probe allowed, and the new tests assert exactly that invariant.

One consistency gap is worth resolving before merge: the blank-conversation fallback in executeChat and the new early return in resolveGateKey no longer agree.

Findings

  • [Warning] ChatController.java:368resolveGateKey now returns null for a blank conversation id, while executeChat (line 581) still dispatches for a blank id using the pre-thread DM shape. On that path a session is created but nothing can resolve its key: /reset reports "No active session to reset.", currentSession returns exists=false, and recordRunSession drops its RUN_SESSION activity event. Before this change the two sides agreed, because the old Peer.direct + accountId(...) probe produced the same DM-shaped key. See the inline comment for the two ways to close it.
  • [Info] ChatController.java:392 — the senderId requirement for thread peers is well documented, but only indirectly protected; an assertion on the built InboundMessage's senderId/parentPeer would pin the constraint itself rather than just the resulting key.

Good bits

  • Sharing one builder between the probe and the dispatch path removes the class of bug this file has had repeatedly, and probeAndDispatchInboundShareCanonicalKey locks that in.
  • sameUserDifferentConversationsShareRoomButNotThread is the right assertion to have for a multi-session inbox: it documents that |r:<user> stays shared while the thread segment separates conversations.
  • The fail-fast when a running agent has no configured Model turns an opaque NPE deep in a ReAct loop into an actionable message naming application.yml.

Tests

ChatControllerConversationRoutingTest covers the non-blank matrix well (distinct keys, stable keys, room/thread separation, back-compat DM shape). The uncovered case is the one flagged above: probe/dispatch agreement for a blank conversation id.


Automated review by github-manager-bot

*/
private String resolveGateKey(String userId, String agentId, String conversationId) {
if (agentId == null || agentId.isBlank()) return null;
if (conversationId == null || conversationId.isBlank()) return null;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Warning] This new guard makes resolveGateKey return null for a blank conversation id, but executeChat (line 581) still dispatches for a blank id by falling back to the pre-thread DM shape. The two sides now disagree about which session a request belongs to.

Consequence on that path: the message is dispatched and a session is created with a |r:<user>-style gate key, but nothing can resolve that key any more — /reset answers "No active session to reset.", the currentSession probe reports exists=false, and recordRunSession's dedupeKey is null so the RUN_SESSION activity event is silently dropped.

Before this change the two sides agreed, because resolveGateKey built a Peer.direct + accountId(conversationId) probe and produced exactly the same DM-shaped key that InboundMessage.dm(...) does.

The HTTP entry points mint a UUID (lines 164-166, 277-279), so I could not confirm a reachable caller that arrives here with a blank id — but the comment on the fallback branch says it exists for back-compat, which implies it is expected to be live. Please pick one:

  1. mirror the fallback here — build the DM probe when the conversation id is blank instead of returning null; or
  2. if a blank id really cannot reach executeChat, delete the branch at line 581 and make the invariant explicit, so the two paths cannot drift again.

ChatControllerConversationRoutingTest.probeAndDispatchInboundShareCanonicalKey asserts probe/dispatch agreement only for a non-blank conv-a; a blank-id case would pin whichever option you choose.

InboundMessage.Builder builder =
InboundMessage.builder(
ChatUiChannel.CHANNEL_ID, Peer.thread(conversationId), payload)
.senderId(userId)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Info] Setting senderId here because thread peers are not DM peers is a genuinely subtle routing constraint, and the javadoc explains it well.

It is only protected by the canonical-key assertions, though: if a future edit dropped .senderId(userId), gateKey would likely stay stable and these tests would keep passing while the gateway silently lost the authenticated user id on the thread path. An assertion on the built InboundMessage itself (getSenderId(), getParentPeer()) would pin the constraint the comment describes.

@xy-ygz

xy-ygz commented Sep 12, 2026

Copy link
Copy Markdown
Contributor Author

Summary

Adds per-conversation (|t: thread) session scoping to the data-agent chat controller via a shared buildConversationInbound(...) helper, plus a routing test that pins distinct gate keys per conversation and stable keys for the same conversation. The refactor is a clear improvement: probe and dispatch now go through one builder, so the canonical key they compare cannot drift apart the way the previous hand-rolled accountId(conversationId) probe allowed, and the new tests assert exactly that invariant.通过一个共享的 buildConversationInbound(...) 辅助函数,该重构为数据代理聊天控制器添加了“每条对话独立处理”的功能。同时,还增加了路由测试,确保每条对话都有唯一的标识符,而同一条对话的标识符则保持不变。这一重构带来了明显的改进:现在,所有的探测和调度操作都通过同一个处理单元来完成,因此,用于比较的标识符不会像之前那样出现偏差。新的测试也正好验证了这一特性。

One consistency gap is worth resolving before merge: the blank-conversation fallback in executeChat and the new early return in resolveGateKey no longer agree.在合并之前,有一个需要解决的不一致性问题: executeChat 中的“空白对话”处理方式与 resolveGateKey 中新引入的“提前返回”处理方式不再一致。

Findings

  • [Warning] ChatController.java:368resolveGateKey now returns null for a blank conversation id, while executeChat (line 581) still dispatches for a blank id using the pre-thread DM shape. On that path a session is created but nothing can resolve its key: /reset reports "No active session to reset.", currentSession returns exists=false, and recordRunSession drops its RUN_SESSION activity event. Before this change the two sides agreed, because the old Peer.direct + accountId(...) probe produced the same DM-shaped key. See the inline comment for the two ways to close it.[警告] ChatController.java:368resolveGateKey 在对话 ID 为空的情况下会返回 null ;而 executeChat (第 581 行)仍然会使用预置的 DM 格式来处理空 ID 的情况。在这种情况下,虽然会创建一个会话,但无法确定该会话的密钥: /reset 报告“没有可重置的活跃会话”, currentSession 返回 exists=falserecordRunSession 则放弃其 RUN_SESSION 相关的操作。在本次更改之前,双方都能达成一致,因为原来的 Peer.direct + accountId(...) 处理方式也能生成相同的 DM 格式密钥。关于如何结束该会话,详见相关注释。
  • [Info] ChatController.java:392 — the senderId requirement for thread peers is well documented, but only indirectly protected; an assertion on the built InboundMessage's senderId/parentPeer would pin the constraint itself rather than just the resulting key.[信息] ChatController.java:392 —— 关于线程伙伴的 senderId 要求有明确的文档记载,但实际上只是通过间接方式来加以保障的;如果对已构建的 InboundMessage 中的 senderId / parentPeer 进行验证,那么被约束的将是该约束条件本身,而不仅仅是由此产生的键值对。

Good bits

  • Sharing one builder between the probe and the dispatch path removes the class of bug this file has had repeatedly, and probeAndDispatchInboundShareCanonicalKey locks that in.在探测路径和调度路径之间共享同一个构建器,可以避免该文件中反复出现的错误。同时, probeAndDispatchInboundShareCanonicalKey 也会确保这一机制的稳定性。
  • sameUserDifferentConversationsShareRoomButNotThread is the right assertion to have for a multi-session inbox: it documents that |r:<user> stays shared while the thread segment separates conversations. sameUserDifferentConversationsShareRoomButNotThread 正是多会话收件箱应有的正确处理方式:它确保了 |r:<user> 的内容能够被共享,同时又能将不同的对话区分开来。
  • The fail-fast when a running agent has no configured Model turns an opaque NPE deep in a ReAct loop into an actionable message naming application.yml.当正在运行的代理没有配置 Model 时,该机制会自动失效。这样一来,ReAct 循环中那些难以理解的 NPE 错误,就能被转化为包含 application.yml 信息的、便于处理的消息。

Tests

ChatControllerConversationRoutingTest covers the non-blank matrix well (distinct keys, stable keys, room/thread separation, back-compat DM shape). The uncovered case is the one flagged above: probe/dispatch agreement for a blank conversation id. ChatControllerConversationRoutingTest 能够很好地处理那些非空矩阵的情况(即具有唯一键值、键值稳定的情况,以及能够区分不同房间/线程的情况)。而无法处理的情况就是上面所提到的:当对话 ID 为空时,如何处理探针与调度机制之间的协调问题。

Automated review by github-manager-bot由 github-manager-bot 自动完成审核流程

As the HTTP entry point already emits a UUID automatically, removed the compatibility logic that permits message sending without an ID. Treat messages missing an ID as invalid / unexpected events for a cleaner codebase.

@xy-ygz
xy-ygz force-pushed the fix/data-agent-multi-session-inbox-routing branch from e3f8c90 to d074b3a Compare September 12, 2026 04:49

@oss-maintainer oss-maintainer left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Summary

Re-review after the update (new head d074b3a5). Root-cause fix is correct: conversations are now routed as thread peers (|t:<conversationId>) instead of a DM-shaped key with accountId, so multiple chats per user/agent no longer overwrite each other in the inbox. Probe (resolveGateKey) and dispatch now share one builder, eliminating the key-divergence class of bugs, and blank ids are rejected up front — I verified both stream() and send() mint a UUID before executeChat, and every resolveGateKey caller already handles a null gateKey, so there is no regression for existing callers. New routing tests cover key distinctness, stability, and sender/parent-peer shape. CLA signed.

Verdict: COMMENT — leaving this non-approving pending maintainer confirmation of the one gateKey-shape migration consideration inline; everything else looks good to go.


Automated review by github-manager-bot

// No agent override and no conversation scoping — pure binding-driven routing.
inbound = InboundMessage.dm(ChatUiChannel.CHANNEL_ID, userId, List.copyOf(msgs));
inbound = buildConversationInbound(userId, null, pinnedConversationId, msgs);
} else {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Warning] The dispatch shape change from Peer.direct(userId) + accountId(conversationId) to Peer.thread(conversationId) + parentPeer(direct) changes the canonical gateKey (|t: segment replaces the DM-shaped key). For the example's own new-session flow this is exactly the fix (distinct conversations no longer collapse), but for a deployed DataAgent with pre-existing MAIN sessions keyed under the old DM-shaped gateKey, the first message after upgrade will resolve to a new gateKey — history/listen-by-gate lookups (findSessionKeyByGate, /reset, tool-event bus subscription at the first turn) will miss the old session until it ages out. If that migration blind-spot matters for users running the example against persisted state, consider either (a) a fallback lookup on the legacy key shape during resolveGateKey, or (b) an explicit note in the PR description / release note that existing chat sessions start fresh after upgrade.

long startMs = System.currentTimeMillis();

if (agentId != null && !agentId.isBlank()) {
HarnessAgent ha = catalogService.getRunningAgent(userId, agentId);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Info] Good UX improvement turning the missing-model condition into an actionable error instead of a downstream failure. Minor: this is a check-then-act on catalogService.getRunningAgent — the agent can be stopped or reconfigured between here and chatUiChannel.dispatch, so the guard is best-effort only (dispatch still needs its own error path, which it has). Also consider emitting the same IllegalStateException text from the stream() SSE error frame so the frontend shows the hint rather than a generic stream error — quick check whether the existing error mapping already covers it.

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.

如何加载历史对话?

2 participants