feat: /session command to switch to any existing session (issue #8) - #9
Closed
HarveyZed wants to merge 8 commits into
Closed
feat: /session command to switch to any existing session (issue #8)#9HarveyZed wants to merge 8 commits into
HarveyZed wants to merge 8 commits into
Conversation
HarveyZed
force-pushed
the
session-switch
branch
from
August 17, 2026 23:26
9881b17 to
2d7806c
Compare
…-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 一起补。
HarveyZed
force-pushed
the
session-switch
branch
from
August 18, 2026 12:11
b462a45 to
ee42172
Compare
Collaborator
|
Thank you for this, @HarveyZed — it shipped in v0.0.7. Your commit is in Two parts of your work were kept as they are:
What changed on top, and why, in case it is useful for the next one:
The full reasoning is in #14 and in |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds a
/sessioncommand 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
/session/session <id>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>/cdaccepts a directory's basename; an ambiguous title lists the candidate ids/session resetFixes in this series
setup, so the shadow registrations forask_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./newand/cdwere shadowed by an override: once bound,sessionIdForKeyalways 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 (/newunconditionally;/cdonly when the directory actually changed, via theonSwitchedcallback), and/new's reply copy mentions the unbind.Implementation notes
ChatWorkspaces(/cd): a per-chat override map (ChatSessionOverrides) persisted through the settings service (chatSessionsfield).sessionIdForKey/releaseFor/statusFieldsForhonor the override.sessionQuery.readTitleSnapshots()(handles the settled-wrapper return shape). Shown in the/sessioncard and in/status.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./statusgains a switched activity state ("已切换到指定会话,下一条消息接续") instead of the misleading "尚未创建"./sessionfamily and/new's unbind behavior (zh/en).Verification
tsc -b: 0 errorsvitest run: 490 passed, 2 skipped (19 files)/new//cdunbinding, phantom-id refusal, and title switching incl. ambiguous-title refusal.