8000 feat: session 模型/推理强度对持久化(#879) (#1139) · felinics/Memoh@25c6447 · GitHub
[go: up one dir, main page]

Skip to content

Commit 25c6447

Browse files
authored
feat: session 模型/推理强度对持久化(#879) (#1139)
* docs(spark): session 模型/强度对持久化设计(#879) 设计定稿(v2,2026-09-02):(模型,effort)成对单值;显式选择即记忆——来源为 用户/记忆的对才随消息携带并持久化,默认来源不携带不记忆(没选过的人继续跟随 bot 默认);welcome=草稿+种子链;多 tab 同浏览器共享 view 同步、跨浏览器最后发送者赢; 渠道 /model//reasoning 清空 session 对;ACP 对存 runtime_metadata。 附录 A 含 v1→v2 修订记录与代码证据。 * feat(agent): session 模型/强度对持久化后端(#879) - 迁移 0146:bot_sessions 加 preferred_chat_model_id/preferred_reasoning_effort (成对写、成对清,NULL=无记忆;写不碰 updated_at) - 解析链:请求 > session 记忆 > subagent pin > bot 默认 > 历史;schedule 轮跳过记忆; effort 记忆 shadow stored(不进 ReasoningRequestedEffort,不串子代理) - 写点(全经 reconcile,DB 不存非法对):picker PATCH;首发 createSession body 随 INSERT; 每轮 resolve() 内请求携带且与记忆不同才写(门=数据,渠道结构不携带); 渠道 /model//reasoning 成功后将 session 对清空(P11') - welcome 种子端点:该 bot 该用户最近一条有记忆的 native session - ACP:对存 runtime_metadata(acp_model_id/acp_reasoning_effort),PATCH 双写 agent 自报值,spawn 后回放覆盖 profile 默认(治 effort 回退) - fork 继承源对;sqlc/swagger/sdk 重生;机制测试:解析链顺序/记忆级/写回门/渠道清空 * feat(web): session 模型/强度对持久化前端(#879) - composer 对搬入 ChatViewEntry(pairModelId/pairEffort/pairSource): 同 session 多 tab 共享同步;ref 改 computed 桥接;promoteDraft 迁移(首发不闪回) - 来源状态机:user/session 才随 send/retry/edit/createSession 携带; default/unset 省略——服务端据此区分'没选过'(跟随 bot 默认)与'选过'(记忆) - 播种:repoint 重播种不覆盖 user;welcome=本机草稿>种子端点>bot 默认; pin/运行时切换按转移表标来源;换模型落新模型默认档(P6') - picker 纯乐观,PATCH best-effort 失败静默;删 store 层 override 死代码 未经人工 QA:spec §1.4 九幕走查待做 * fix(web): composer 加载 spinner 改为 3 秒后才显示 + spec P8′ 措辞 - 触发器 spinner 延迟:加载 3 秒内不出 spinner(用户反馈:快速加载不该闪); popover 菜单内等待行保持即时(用户开着菜单主动等) - spec:P8′ 同步范围明确为 dockview 面板级;两个浏览器标签页=跨设备语义 (最后发送者赢,刷新收敛),消除'同一浏览器内'歧义 * fix(db): 0146 外键改为团队期复合形式,修 lint CI - 0146 迁移的 preferred_chat_model_id 外键改用团队期后的复合写法: (team_id, col) REFERENCES models(team_id, id) ON DELETE SET NULL (col)。 裸 SET NULL 会让 confdelsetcols 为 NULL,被 TestTeamChainReversible 守卫判为"可清 team_id 的不安全 FK"(CI Migrations 红)。0001 无需改: 其团队期通用块会把单列 FK 重写成同一形状,全新/增量两条路径收敛。 - golangci 三处:contract.go 与 service_model_preference_test.go 的 gofmt 对齐(rebase 合入遗留),service_model_selection_test.go 的 unused-receiver。 - 另两个 CI 失败与本分支无关(分支不碰 native/docker): TestSpawnAdapterGenerateWithWatchdog* 为预存竞态(本地 -race x20 全过), server-entrypoint 的 alpine:3.23 拉取失败为 runner 网络问题。 * fix(web): rebase 后修复 chat-pane 编译——可空 botId 守卫、收窄绕过、补导入 rebase 到 94d468e 后四处适配(main 侧变化引入,非行为变更): - currentBotId 变为 string|null(#1131),persistComposerPair/seedWelcomePair 补 botId 空守卫; - vue-tsc 对 pairSource.value 链收窄到新不含 'user',post-await 重查改走 paneView 链(该重查是防种子竞速的必要逻辑,不是冗余); - useDelayedTrue 补 type Ref 导入,onUnmounted 改用文件已导入的 onBeforeUnmount。 chat-pane 剩余两处 TS6133(textareaEl/composerEl)为 main 自带基线, 与本分支无关。 * fix(agent): #879 review 修复——半对读侧守卫、WS 首发 reconcile、preference SQL 补 team_id - sessionModelPreference:模型列为 NULL 时 effort 一并置空(半对只可能由 ON DELETE SET NULL 单列清出;否则强度会被贴到回落模型上,spec §3.7 禁止) - createWSChatSession:携带的对先经 ReconcileSessionModelPreference 再 INSERT, 与 REST 首发一致(此前已删模型 UUID=FK 违反 500、slug 退化成半对、非法 effort 原样入库) - UpdateSessionModelPreference/GetLatestSessionModelPreference 补 team_id = memoh_current_team_id()(sessions.sql 全文惯例,FORCE RLS 之下的 纵深防御),Update 加 deleted_at IS NULL 守卫 * fix(web): #879 review 修复——草稿仅首发成功时清除、种子跨 bot 守卫、对携带门入测试 - clearPairDraft 从通用 repoint watcher 挪到 handleSend 成功路径: 此前 welcome 未发草稿仅因点开历史会话/硬刷新就被清掉,直接违反 spec P2′;现在草稿只在 welcome 首发成功(对已落库)时消费,发送失败保留 - seedWelcomePair await 后补 bot/view 同一性守卫:切 bot 后迟到的种子 不再写进另一个 bot 的 welcome(此前会跨 bot 串染并随首发携带) - 携带/省略门与草稿助手提取进 chat-pane-send.ts(carriedPairForSource/ *ComposerPairDraft/welcomeSendConsumedDraft),补 6 组机制测试——此前 该状态机零测试 * fix: 区分命令成功与消息提交并隔离跨模型推理强度 * fix(chat): 捕获发送快照时立即保护偏好读取 * fix(claudecode): 保留完整模型名与别名的兼容性 * refactor(web): #879 composer pair 状态收拢到 useComposerPair - 5 个分散的重置/刷新 watcher 合并为一个:以 runtime 身份为键,同 view 身份变化即重置并重载,view 切换不重置 - 修复已有空会话切换 Agent/runtime 后残留旧模型对(旧 watcher 遇 sessionId 直接返回) - 修复 welcome seed 在默认外部 Agent stage 期间返回,把 native UUID 塞进 direct 草稿的竞态 - pair-sync 新增 invalidate(),重置时作废在途读写 - chat-pane.vue 净删约 190 行;新增 5 个状态转换测试 * perf(agent): #879 direct runtime 重复发送相同模型对时跳过目录探测 Claude Code 的 ModelCatalog 每次新起 CLI 等 initialize,Codex 调 model/list; 已记忆的 Web 会话每次发送都携带同一对,只在与已存对不同时才重新校验。 revision 仍每次推进,旧 picker PATCH 依旧被拦。 * test(db): #879 integration 测试改走真实迁移链;PATCH revision 必填 - 删除手写假表 + 字符串切 sqlc 源码的夹具,改用 freshMigratedDB/migrateTo/ applyCanonicalInitOnly:链可逆、0146 幂等、fence/换 runtime 清空/FK SET NULL 在真 schema 上验证 - PatchSessionModelPreference 的 expected revision 改为必填,删掉无调用方的 无条件写分支;picker 写永远是 CAS - 0146 up 注释精简并指向 0141,down 合并为一条 ALTER * fix(web): 保护发送准备期间的模型选择与 welcome seed * fix(chat): 偏好写入结束后立即保存后续模型选择 * fix(chat): #879 review 收尾——发送失败不再卡死 pair 重校验等四处 - composer-pair-sync: dirty 布尔改为 pending 在途计数 + unsaved 未保存 标记。任何失败(发送或写入)都归还计数,普通消息发送失败不再永久 关闭该 view 的服务器重校验;离线失败的 pick 仍受保护到下一次确认 发送;invalidate 只清 unsaved,计数由 settle 自然归还(避免双重计数)。 - picker PATCH 409 冲突不再被静默吞掉:write 新增 onError 出口,冲突时 dropUnsavedChoice + 立即刷新采纳服务端值,pane 显示已有三语言文案。 - retry/edit 接通 model_preference_settled:RetryLatestMessageInput / EditLatestMessageInput 透传回调,local_channel 两个 WS 处理器在拿到 runRef 处发事件,前端 send.ts 透传、pane 接 finish(false)。生成期间 的改选不再等整条回复结束才落库。 - direct runtime 写回失败降级为 warn(与 native best-effort 契约一致), 不再让一次 DB 抖动杀死用户消息。 - persist 的 PATCH 响应回写按 usesDirectRuntime 取列,与 applySessionPair 对称,native 会话不会吃到残留 external id。 测试:sync 原语 +2(失败发送恢复/冲突出口)、useComposerPair +3(失败 发送恢复/未保存保护/冲突回退/direct 双列回写)、chat-list +2(retry/edit settled 透传与错配不放行)、Go direct 写回失败回归 +1。
1 parent 8d5dfae commit 25c6447

71 files changed

Lines changed: 4921 additions & 481 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎apps/web/src/composables/api/useChat.chat-api.ts‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
patchBotsByBotIdAcpRuntimesByRuntimeIdMode,
1414
patchBotsByBotIdAcpRuntimesByRuntimeIdReasoning,
1515
patchBotsByBotIdSessionsBySessionId,
16+
getBotsByBotIdSessionsModelPreferenceSeed,
1617
patchBotsByBotIdSessionsBySessionIdAcpRuntimeMode,
1718
patchBotsByBotIdSessionsBySessionIdAcpRuntimeModel,
1819
patchBotsByBotIdSessionsBySessionIdAcpRuntimeReasoning,
@@ -35,6 +36,14 @@ export interface CreateSessionOptions {
3536
* workdir pins the session's workspace target and working directory.
3637
*/
3738
workdirId?: string
39+
/**
40+
* First-send picker pair (issue #879). Carried only when the pair has an
41+
* explicit source (user pick / remembered session); when omitted the
42+
* session is born with NULL preference columns and follows the bot default.
43+
* The server reconciles both before the INSERT.
44+
*/
45+
preferredChatModelId?: string
46+
preferredReasoningEffort?: string
3847
}
3948

4049
export interface CreateACPRuntimeOptions {
@@ -117,6 +126,8 @@ export async function createSession(botId: string, options?: string | CreateSess
117126
runtime_metadata: options?.runtimeMetadata,
118127
acp_runtime_id: options?.acpRuntimeId?.trim() || undefined,
119128
workdir_id: options?.workdirId?.trim() || undefined,
129+
preferred_chat_model_id: options?.preferredChatModelId?.trim() || undefined,
130+
preferred_reasoning_effort: options?.preferredReasoningEffort?.trim() || undefined,
120131
}
121132
const { data } = await postBotsByBotIdSessions({
122133
path: { bot_id: id },
@@ -158,6 +169,38 @@ export async function updateSessionTitle(botId: string, sessionId: string, title
158169
return data as SessionSummary
159170
}
160171

172+
// Picker pair persistence (issue #879): best-effort PATCH from the composer.
173+
// The caller treats failures as silent — the next sent message writes the
174+
// resolved pair back server-side, so a dropped PATCH only loses the
175+
// pick-until-send window.
176+
export async function updateSessionModelPreference(botId: string, sessionId: string, modelId: string, reasoningEffort: string, expectedRevision: string): Promise<SessionSummary> {
177+
const { data } = await patchBotsByBotIdSessionsBySessionId({
178+
path: { bot_id: botId.trim(), session_id: sessionId.trim() },
179+
body: {
180+
expected_model_preference_revision: expectedRevision,
181+
preferred_chat_model_id: modelId,
182+
preferred_reasoning_effort: reasoningEffort,
183+
},
184+
throwOnError: true,
185+
})
186+
return data as SessionSummary
187+
}
188+
189+
export interface ModelPreferenceSeed {
190+
model_id?: string
191+
reasoning_effort?: string
192+
}
193+
194+
// Welcome composer seed (issue #879): the pair of the bot's most recent
195+
// native session. Empty fields mean "no seed" — fall back to the bot default.
196+
export async function fetchModelPreferenceSeed(botId: string): Promise<ModelPreferenceSeed> {
197+
const { data } = await getBotsByBotIdSessionsModelPreferenceSeed({
198+
path: { bot_id: botId.trim() },
199+
throwOnError: true,
200+
})
201+
return data as ModelPreferenceSeed
202+
}
203+
161204
export interface UpdateSessionAgentOptions {
162205
botAgentId?: string
163206
type?: string

‎apps/web/src/composables/api/useChat.types.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@ export interface SessionSummary {
2121
updated_at?: string
2222
route_metadata?: Record<string, unknown>
2323
route_conversation_type?: string
24+
/** Session's persisted (model, effort) pair (issue #879); empty = no memory. */
25+
preferred_external_model_id?: string
26+
model_preference_revision?: string
27+
preferred_chat_model_id?: string
28+
preferred_reasoning_effort?: string
2429
}
2530

2631
// Bot-wide activity SSE: `/bots/{bot_id}/sessions/events`. Carries identifier
@@ -520,7 +525,15 @@ export type UIRuntimeEvent =
520525
| UIRuntimeDeltaEvent
521526
| UIRuntimeDroppedEvent
522527

528+
export interface UIStreamModelPreferenceSettledEvent {
529+
type: 'model_preference_settled'
530+
invocation_id: string
531+
run_id: string
532+
session_id: string
533+
}
534+
523535
export type UIStreamEvent =
536+
| UIStreamModelPreferenceSettledEvent
524537
| UIStreamRunAcceptedEvent
525538
| UIStreamRunRejectedEvent
526539
| UIStreamErrorEvent

‎apps/web/src/composables/api/useChat.ws.test.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,16 @@ describe('useChat.ws', () => {
222222
expect(third.sent).toEqual([])
223223
})
224224

225+
it('forwards preference settlement before the response completes', () => {
226+
const onStreamEvent = vi.fn()
227+
connectWebSocket('bot-1', onStreamEvent)
228+
const socket = MockWebSocket.instances[0]!
229+
socket.open()
230+
const event = { type: 'model_preference_settled', invocation_id: 'inv', run_id: 'run', session_id: 'session' }
231+
socket.emit(event)
232+
expect(onStreamEvent).toHaveBeenCalledWith(event)
233+
})
234+
225235
it('uses the configured absolute API base URL', () => {
226236
client.setConfig({ baseUrl: 'http://127.0.0.1:18080' })
227237
vi.stubGlobal('localStorage', {

‎apps/web/src/composables/api/useChat.ws.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,7 @@ export function connectWebSocket(
234234
const eventType = String(parsed.type ?? '').trim()
235235
if (
236236
eventType !== 'run_accepted'
237+
&& eventType !== 'model_preference_settled'
237238
&& eventType !== 'run_rejected'
238239
&& eventType !== 'error'
239240
&& eventType !== 'session_created'

‎apps/web/src/i18n/locales/en.json‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,9 @@
230230
"runtime_busy": "Wait for the current response to finish before switching Agent accounts.",
231231
"materialization_failed": "The Agent account could not be prepared. Please retry."
232232
},
233+
"session": {
234+
"model_preference_conflict": "The model selection changed elsewhere. Refresh or send your message to apply your selection."
235+
},
233236
"session_runtime": {
234237
"session_busy": "This conversation is still working on your previous message. Please try again in a moment.",
235238
"invocation_conflict": "This message was already sent with different content. Reload the conversation to see what was saved.",

‎apps/web/src/i18n/locales/ja.json‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,9 @@
227227
"runtime_busy": "現在の応答が完了してから Agent アカウントを切り替えてください。",
228228
"materialization_failed": "Agent アカウントを準備できませんでした。もう一度お試しください。"
229229
},
230+
"session": {
231+
"model_preference_conflict": "モデルの選択が別の場所で更新されました。再読み込みするか、メッセージを送信して現在の選択を適用してください。"
232+
},
230233
"session_runtime": {
231234
"session_busy": "このセッションはまだ前のメッセージを処理しています。しばらくしてからもう一度お試しください。",
232235
"invocation_conflict": "このメッセージは別の内容で既に送信されています。セッションを再読み込みして保存済みの内容を確認してください。",

‎apps/web/src/i18n/locales/zh.json‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,9 @@
230230
"runtime_busy": "请等待当前回复结束后再切换 Agent 账号。",
231231
"materialization_failed": "Agent 账号准备失败,请重试。"
232232
},
233+
"session": {
234+
"model_preference_conflict": "模型选择已在其他位置更新,请刷新,或发送消息以使用当前选择。"
235+
},
233236
"session_runtime": {
234237
"session_busy": "这个会话还在处理上一条消息,请稍后再试。",
235238
"invocation_conflict": "这条消息已用不同内容提交过,请刷新会话查看已保存的内容。",

‎apps/web/src/pages/home/components/chat-pane-send.test.ts‎

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,28 @@
1+
// jsdom's localStorage is unavailable under this runner (node shadows it),
2+
// so the pair-draft tests run against a Map-backed stub, matching the
3+
// markdown test's precedent.
4+
const pairDraftStorage = new Map<string, string>()
5+
Object.defineProperty(globalThis, 'localStorage', {
6+
value: {
7+
getItem: (k: string) => pairDraftStorage.get(k) ?? null,
8+
setItem: (k: string, v: string) => void pairDraftStorage.set(k, String(v)),
9+
removeItem: (k: string) => void pairDraftStorage.delete(k),
10+
clear: () => pairDraftStorage.clear(),
11+
},
12+
})
113
import { describe, expect, it } from 'vitest'
214
import {
315
captureChatPaneSendContext,
16+
carriedPairForSource,
17+
clearComposerPairDraft,
418
composerHasNoModel,
19+
composerPairDraftKey,
520
matchesChatPaneSendContext,
621
pinnedSubagentModelId,
22+
readComposerPairDraft,
723
shouldRefreshACPComposerConfig,
24+
welcomeSendConsumedDraft,
25+
writeComposerPairDraft,
826
} from './chat-pane-send'
927

1028
describe('chat pane send context', () => {
@@ -108,3 +126,45 @@ describe('pinned subagent model', () => {
108126
expect(pinnedSubagentModelId('subagent', { model_uuid: 'model-gone' }, models)).toBe('')
109127
})
110128
})
129+
130+
describe('carried pair gate (issue #879)', () => {
131+
it.each([
132+
{ source: 'user', want: { modelId: 'm-1', reasoningEffort: 'high' } },
133+
{ source: 'session', want: { modelId: 'm-1', reasoningEffort: 'high' } },
134+
{ source: 'default', want: { modelId: '', reasoningEffort: '' } },
135+
{ source: 'unset', want: { modelId: '', reasoningEffort: '' } },
136+
])('source $source carries $want', ({ source, want }) => {
137+
expect(carriedPairForSource(source, ' m-1 ', ' high ')).toEqual(want)
138+
})
139+
})
140+
141+
describe('composer pair draft', () => {
142+
it('round-trips and clears per bot', () => {
143+
writeComposerPairDraft('bot-1', { model_id: 'm-1', reasoning_effort: 'high' })
144+
writeComposerPairDraft('bot-2', { model_id: 'm-2', reasoning_effort: 'low' })
145+
expect(readComposerPairDraft('bot-1')).toEqual({ model_id: 'm-1', reasoning_effort: 'high' })
146+
clearComposerPairDraft('bot-1')
147+
expect(readComposerPairDraft('bot-1')).toBeNull()
148+
expect(readComposerPairDraft('bot-2')).toEqual({ model_id: 'm-2', reasoning_effort: 'low' })
149+
})
150+
151+
it('treats a model-less payload as no draft', () => {
152+
localStorage.setItem(composerPairDraftKey('bot-1'), JSON.stringify({ reasoning_effort: 'high' }))
153+
expect(readComposerPairDraft('bot-1')).toBeNull()
154+
})
155+
})
156+
157+
describe('welcome send consumes the draft', () => {
158+
it('keeps the draft when a command succeeds without sending a message', () => {
159+
expect(welcomeSendConsumedDraft({}, { ok: true })).toBe(false)
160+
})
161+
162+
it('clears only on a successful welcome send', () => {
163+
expect(welcomeSendConsumedDraft({ sessionId: '' }, { ok: true, messageSent: true })).toBe(true)
164+
expect(welcomeSendConsumedDraft({}, { ok: true, messageSent: true })).toBe(true)
165+
// An existing-session send never touches the draft...
166+
expect(welcomeSendConsumedDraft({ sessionId: 's-1' }, { ok: true, messageSent: true })).toBe(false)
167+
// ...and neither does a failed welcome send (the pick was never persisted).
168+
expect(welcomeSendConsumedDraft({ sessionId: '' }, { ok: false })).toBe(false)
169+
})
170+
})

‎apps/web/src/pages/home/components/chat-pane-send.ts‎

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,3 +77,70 @@ export function shouldRefreshACPComposerConfig(
7777
&& typeof result.errorCode === 'string'
7878
&& ACP_STALE_CONFIG_CODES.has(result.errorCode)
7979
}
80+
81+
// ── Composer (model, effort) pair draft (issue #879) ─────────────────────
82+
// The welcome composer keeps its picked pair in a per-bot localStorage draft
83+
// until the pair is durably handed to the server. The draft is consumed ONLY
84+
// by a successful welcome send (REST createSession or the WS first message —
85+
// both persist the pair with the session); it must survive everything else:
86+
// opening a historical session, a hard refresh, a failed send (spec P2′).
87+
88+
// carriedPairForSource is the wire gate (spec §3.4): only explicit sources
89+
// (user pick, remembered session) are carried on send/retry/edit/
90+
// createSession; default-sourced and unset pairs are omitted so the server
91+
// can tell "never picked" apart from "picked the default", and the session
92+
// keeps following the bot default live.
93+
export function carriedPairForSource(
94+
source: string,
95+
modelId: string,
96+
reasoningEffort: string,
97+
): { modelId: string, reasoningEffort: string } {
98+
if (source !== 'user' && source !== 'session') return { modelId: '', reasoningEffort: '' }
99+
return { modelId: modelId.trim(), reasoningEffort: reasoningEffort.trim() }
100+
}
101+
102+
export function composerPairDraftKey(botId: string): string {
103+
return `memoh:composer-pair:${botId.trim()}`
104+
}
105+
106+
export interface ComposerPairDraft {
107+
model_id: string
108+
reasoning_effort: string
109+
}
110+
111+
// All three draft operations swallow storage failures (private mode, quota):
112+
// the draft is a persistence scaffold, never the state of record.
113+
export function readComposerPairDraft(botId: string): ComposerPairDraft | null {
114+
try {
115+
const raw = localStorage.getItem(composerPairDraftKey(botId))
116+
if (!raw) return null
117+
const parsed = JSON.parse(raw) as { model_id?: string, reasoning_effort?: string }
118+
return parsed.model_id ? { model_id: parsed.model_id, reasoning_effort: parsed.reasoning_effort ?? '' } : null
119+
} catch {
120+
return null
121+
}
122+
}
123+
124+
export function writeComposerPairDraft(botId: string, draft: ComposerPairDraft): void {
125+
try {
126+
localStorage.setItem(composerPairDraftKey(botId), JSON.stringify(draft))
127+
} catch { /* private mode / quota: the draft simply doesn't persist */ }
128+
}
129+
130+
export function clearComposerPairDraft(botId: string): void {
131+
try {
132+
localStorage.removeItem(composerPairDraftKey(botId))
133+
} catch { /* ignore */ }
134+
}
135+
136+
// welcomeSendConsumedDraft is the ONLY moment the draft may be cleared: the
137+
// welcome send succeeded, so the pair now lives server-side. Clearing any
138+
// earlier — e.g. on a welcome→session repoint — would wipe an unsent pick
139+
// when the user merely opens a historical session (spec P2′). A failed send
140+
// keeps the draft: the pick was never persisted.
141+
export function welcomeSendConsumedDraft(
142+
target: { sessionId?: string | null },
143+
result: { ok: boolean, messageSent?: boolean },
144+
): boolean {
145+
return result.ok && result.messageSent === true && !(target.sessionId ?? '').trim()
146+
}

0 commit comments

Comments
 (0)
0