Skip to content

feat: /session command to switch to any existing session (issue #8) - #9

Closed
HarveyZed wants to merge 8 commits into
omdsh-dev:mainfrom
HarveyZed:session-switch
Closed

feat: /session command to switch to any existing session (issue #8)#9
HarveyZed wants to merge 8 commits into
omdsh-dev:mainfrom
HarveyZed:session-switch

Conversation

@HarveyZed

@HarveyZed HarveyZed commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds a /session command that lets a Feishu conversation switch to any existing session in its workspace — including sessions created in the Web UI (dsh web). This closes the gap where a chat is permanently bound to one auto-derived session, and hardens the feature against the session-lifecycle edges the switch opened up.

Companion request: issue #8 (dsh持久化会话,应支持切换已有会话列表).

What it does

Command Effect
/session Card listing switchable sessions in the current workspace: title (label) + id (value) per row, so a phone user can copy the id
/session <id> Bind this conversation to an existing persisted session (agents.resume()), continuing its context from the next message; an id that is not a session is refused instead of silently creating one
/session <title> Bind by the title of a switchable session, mirroring how /cd accepts a directory's basename; an ambiguous title lists the candidate ids
/session reset Clear the binding, back to automatic derivation

Fixes in this series

  • Live-reuse composition: a chat reusing a live agent another owner keeps running (typically a Web UI session) never ran the create/resume setup, so the shadow registrations for ask_user_question / plan review were missing and questions fell through to the Web UI's provider. Reuse now composes the chat-only parts idempotently (WeakSet-keyed), skipping the preset join, with a try/catch so reuse never costs the message.
  • /new and /cd were shadowed by an override: once bound, sessionIdForKey always prefers the override, so /new's fresh epoch (and /cd's new directory) never took effect — the next message resumed the old bound session. Both commands now clear the binding on success (/new unconditionally; /cd only when the directory actually changed, via the onSwitched callback), and /new's reply copy mentions the unbind.

Implementation notes

  • Symmetric to ChatWorkspaces (/cd): a per-chat override map (ChatSessionOverrides) persisted through the settings service (chatSessions field). sessionIdForKey / releaseFor / statusFieldsFor honor the override.
  • Session titles are folded via sessionQuery.readTitleSnapshots() (handles the settled-wrapper return shape). Shown in the /session card and in /status.
  • Archived sessions (Web UI workspaceRegistry.archivedSessionIds) are filtered out of the list; they can still be resumed directly via /session <id>. The id-existence check runs against the FULL persisted list, so cross-directory and archived sessions stay switchable.
  • Session resolution tries the argument as an id first (exact, unique), then as a title in the current workspace's switchable sessions; resolution runs before the id-format check so titles may contain any characters.
  • /status gains a switched activity state ("已切换到指定会话,下一条消息接续") instead of the misleading "尚未创建".
  • Card copy is bilingual (zh/en), following the existing card vocabulary.
  • README command tables updated for the /session family and /new's unbind behavior (zh/en).

Verification

  • tsc -b: 0 errors
  • vitest run: 490 passed, 2 skipped (19 files)
  • Manually tested against a live dsh 0.1.0-rc.6 + Feishu deployment: question cards from a reused live session, /new / /cd unbinding, phantom-id refusal, and title switching incl. ambiguous-title refusal.

…-dev#8)

Add /session to switch a Feishu conversation to any persisted session in
its workspace, including ones created in the Web UI.

- /session: card listing switchable sessions (title + id per row, copyable)
- /session <id>: bind the conversation to an existing session
- /session reset: clear the binding, back to automatic derivation
- session titles folded via sessionQuery.readTitleSnapshots
- archived sessions (workspaceRegistry.archivedSessionIds) filtered out
- /status shows the session title and a 'switched' activity state

Symmetric to the existing ChatWorkspaces (/cd) mechanism: a per-chat
override map persisted through the settings service.
Web UI 创建的会话(或任何其他 owner 保持 live 的会话)被飞书聊天复用时,
ladder 的 lookup rung 直接返回已 live 的 agent,不会跑 create/resume 的
setup——于是 ask_user_question、plan review 等 shadow 工具从未注册。
模型在飞书里提问时,工具调用落到宿主全局的 ask_user_question,而
ctx.userQuestions 的单 provider 已被 Web 界面占据:飞书用户看不到问题、
答不了,超时后整个 turn 以 ASK_ABORTED 失败。

修复是让 lookup 命中 live agent 时幂等补跑 chat 专属合成(ensureChatComposed:
shadow 工具 + deny 守卫 + presence 提示段),用 WeakSet 按 agent 对象去重——
同一 live 实例只补一次,resume 重建的新实例(新对象)会正确重新注册。
preset join 故意跳过:presets.mount 的契约限定在 agent factory 的 setup
上下文,且 live agent 已加入其 owner 的 preset。副作用可逆:注册都挂在
agent 自身 scope 上,随其 owner 销毁而清理,不改变"谁拥有这个 agent"
(reach() 对 lookup 命中返回 owned: false)的生命周期语义。补合成整体
try/catch:失败只 notify 打日志,绝不影响消息收发。

测试:fake agent 增加可记录工具注册的 scope(declareLiveWithScope),
覆盖 shadow 注册、跨消息幂等、合成失败时消息仍送达。
…mments

对 live 复用修复的 review 加固,不改变行为:
- 双重类型断言提取为命名函数 agentOf,并把宿主构造的 invariant 写清楚
  (dsh-agent-loop 生成 agent.ctx = scope.ctx.extend({ agent }));
- ensureChatComposed 的 JSDoc 补充所有权论证:注册是可逆副作用、scope
  无法 shadow 时 deny 而非放任工具落到无人看的界面,与 create/resume
  的既有策略一致;
- fake 增加带工具记录能力的 scope,新增 3 个测试断言复用后 shadow 注册、
  幂等、合成失败降级。
/session <id> 把聊天绑定到指定会话后,sessionIdForKey 里这个 override
永远优先于派生 id。于是 /new 只递增派生 epoch 时,下一条消息仍会
resume 回被绑定的旧会话——回复说"已开新会话",上下文却原封不动;
大会话下切视觉模型时还会直接 400 "Prompt exceeds max length"。

修复是让 /new 先走 ChatSessionOverrides.set(key, undefined)(与
/session reset 同一条路径,写 __reset__ 标记)再开新 epoch:对没有
绑定的聊天是幂等 no-op,持久化失败照旧降级为内存态并 report。
绑定是显式选择,/new 是重新开始,语义冲突时以 /new 为准——这正是
命令文案承诺的行为。同源的 /cd 遮蔽问题由本系列下一个 commit 处理。

测试:plugin.spec 断言 /new 在存在 override 时把 chatSessions
持久化为 __reset__。
与 /new 同源的遮蔽问题:/cd 承诺"下一条消息在该目录继续",但 override
仍让 sessionIdForKey 路由到绑定会话——它住在旧目录。修复沿用 /new 的
思路:把传给 runWorkspaceCommand 的 onSwitched 包装成 cdRelease,在
"目录真正切换成功"时才清 override(runWorkspaceCommand 恰好只在
changed 时调用 onSwitched)——未切换的 /cd 和纯 /ws 列表保留绑定。

/new 的回复文案同步补上"已解除会话绑定,回到自动路由",对齐上一
commit 的行为。

测试:plugin.spec 断言 /cd 成功切换后 chatSessions 持久化为 __reset__。
/session <id> 只校验 id 格式就绑定。绑定的 id 若不存在,下一条消息
走 ladder 的 resume→create 兜底——resume 失败就 create,用这个不存在的
id 悄悄建一个空会话。操作者输错一个前缀(如漏掉 session-)就会在空壳里
聊半天才发现不对。

修复:绑定前用 sessionQuery.listSessions() 的 FULL 列表验证 id 存在——
不是 /session 列表那个按工作区过滤、剔归档的视图,所以跨目录和已归档
会话只要有 ID 仍可切换(既有边界保留),只有"根本不是会话"的 id 被拒。
查询服务缺失或查询失败时降级为信任 id(与列表的降级一致),不阻塞切换。

测试:createFakeSessionQuery 提供固定会话列表;断言不存在的 id 被拒且
override 未被动过。
/session 的 argument 现在先按 id 解析(精确、唯一,查全量持久化列表,
跨目录/归档可切),再按当前工作区可切换会话的标题匹配——即 /cd 给
目录 basename 的那个简写。标题唯一命中即绑定(回复显示「标题」(id));
多个会话同名则拒绝并列出候选 id;无命中拒绝并提示。解析放在 id 格式
校验之前,所以标题可以含中文、空格等任意字符;id 路径行为不变,
"找不到"的拒绝文案统一由解析器给出。

测试:新增唯一标题切换、歧义标题拒绝(列出候选)两个用例;原 id
拒绝用例的断言文案同步更新。
/session 命令族(列出、按 id/标题切换、reset)以及 /new 会清除会话
绑定的行为,之前只存在于代码里,README 命令表完全没有——按贡献者
约定,行为变化要同步文档。两份 README 一起补。
@Roy-oss1

Copy link
Copy Markdown
Collaborator

Thank you for this, @HarveyZed — it shipped in v0.0.7.

Your commit is in main unchanged and under your name (918b770), as the first of the two commits in #14, and the release notes credit you at the top: https://github.com/omdsh-dev/dsh-lark/releases/tag/v0.0.7

Two parts of your work were kept as they are:

  • the persisted shape — chatSessions in src/config.ts, wired through the interface, the schema and resolveConfig, symmetric to ChatWorkspaces down to the single-key patch;
  • the /status surface for a switched conversation.

What changed on top, and why, in case it is useful for the next one:

  • /session <id> became /sessions, a list you press. A session id is a machine identifier, and typing one is hard on the phone this channel is mostly driven from. It also opens a second way in: with a typed id, something has to decide which ids a chat may name. With a list, nothing does — the rows are derived per render, and the click handler derives the same list again rather than trusting the id in its payload.
  • The switch now releases the conversation's agent. SessionStore.acquireKey hands back an already-bound agent without re-deriving the id, so without a release the switch had no effect until the agent went away on its own.
  • Delegated and archived sessions are filtered out, the latter via workspaceRegistry.archivedSessionIds — your idea, and one the new picker kept.
  • Rows are labelled by the last thing a person said, because a title is folded from a session's first prompt and half of them read "Hello".

The full reasoning is in #14 and in src/sessions.ts. Closing this one as superseded — the feature and your commit are both in main. Thanks again, and please do send more.

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