fix(ai-sessions): keep the refused prompt, the deleted chat and the mid-load artifact list

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
hugocasa
2026-08-31 19:32:22 +02:00
co-authored by Claude Opus 5
parent fac221c1bb
commit 0107207bc3
5 changed files with 67 additions and 5 deletions
@@ -1962,8 +1962,13 @@ export class AIChatManager {
// A synthetic send carries none of the user's text; its caller owns the
// instructions it set and unwinds them itself.
if (options.synthetic || options.queued) return
// The same fallback sendRequestImpl applies: the programmatic senders (an
// editor's AI Fix, Ask AI) leave the prompt on `this.instructions` and pass
// no `instructions` option, so reading the option alone would hand back an
// empty prompt and lose what they were about to send.
const instructions = options.instructions ?? this.instructions
const restored = this.aiChatInput?.restoreInstructions(
options.instructions ?? '',
instructions,
options.pastes ?? [],
options.images ?? [],
options.files ?? []
@@ -1972,9 +1977,9 @@ export class AIChatManager {
// queue so it is still the user's to send rather than silently gone. The
// queue holds plain text, so the pastes are expanded into it — parked as
// bare tokens they would point at blobs nothing holds any more.
if (restored !== true && options.instructions) {
if (restored !== true && instructions) {
this.restoreToInput(
expanded(chatDraft(options.instructions, options.pastes ?? [])),
expanded(chatDraft(instructions, options.pastes ?? [])),
options.images,
options.files
)
@@ -618,7 +618,14 @@ export default class HistoryManager {
if (!db) return 'unavailable'
try {
const chat = await db.get('chats', id)
if (!chat) return 'missing'
if (!chat) {
// Drop the mirror too. `loadPastChat` reads from it and never from the
// store, so a copy left behind here is a deleted chat that comes back
// on the next rotation onto this id.
const { [id]: _gone, ...rest } = this.savedChats
this.savedChats = rest
return 'missing'
}
this.savedChats = { ...this.savedChats, [id]: chat }
return 'loaded'
} catch (err) {
@@ -810,6 +810,25 @@ describe('HistoryManager.reloadChat', () => {
expect(await hm.reloadChat('no-such-chat')).toBe('missing')
})
it('evicts the mirrored copy of a chat the driver deleted', async () => {
const hm = new HistoryManager()
await hm.init()
const chatId = hm.getCurrentChatId()
await hm.saveChat(
[{ role: 'user', content: 'deleted by the driving tab' }] as DisplayMessage[],
[] as ChatCompletionMessageParam[]
)
const db = await openDB('copilot-chat-history::admin@test')
await db.delete('chats' as never, chatId)
db.close()
expect(await hm.reloadChat(chatId)).toBe('missing')
// loadPastChat serves the mirror, so a copy left behind would resurrect the
// deleted transcript the next time this id came round again.
expect(await hm.loadPastChat(chatId)).toBeUndefined()
})
it('reports a store it cannot open as unavailable, never as missing', async () => {
;(globalThis as any).indexedDB = {
open: () => {
@@ -154,6 +154,11 @@ export class SessionArtifactsStore {
// previous one's row placed into its list, nor one of its own rows dropped.
if (this.#sessionId !== loaded) return
if (read.state === 'unavailable') return
// Sampled after the awaits, once a load racing us may already have landed.
// A write below both derives from a list that load has not filled yet and
// bumps the token it checks on arrival, so it would leave this session
// holding only what this notification carried until the next write.
const loadInFlight = this.loading
if (read.state === 'loaded') {
// Weighed against what is held, not placed over it: an ordinary artifact
// whose persist the store refused lives only in memory here, and taking
@@ -161,10 +166,13 @@ export class SessionArtifactsStore {
// a different one.
const held = this.artifacts.find((a) => a.id === artifactId)
this.#place(furtherAlong(read.artifact, held) ?? read.artifact)
if (loadInFlight) await this.#load()
return
}
const next = this.artifacts.filter((a) => a.id !== artifactId)
if (next.length !== this.artifacts.length) this.#applyWrite(next)
if (next.length === this.artifacts.length) return
this.#applyWrite(next)
if (loadInFlight) await this.#load()
}
async get(id: string): Promise<PersistedArtifact | undefined> {
@@ -588,6 +588,29 @@ describe('cross-tab artifact sync', () => {
expect(broadcasts).toEqual([])
})
// A tab opening the session reads the whole list; a notification landing inside that read
// writes from the list it has so far — empty — and invalidates the read on its way out.
it('keeps the rest of the session when a notification lands mid-load', async () => {
const kept = await store.create('s1', { name: 'Plan', content: 'p' })
const notified = await store.create('s1', { name: 'Notes', content: 'n' })
const opening = new stateMod.SessionArtifactsStore()
let release: () => void = () => {}
const held = new Promise<void>((r) => (release = r))
const listForSession = dbMod.listArtifactsForSession
vi.spyOn(dbMod, 'listArtifactsForSession').mockImplementationOnce(async (id: string) => {
await held
return listForSession(id)
})
const loading = opening.setSession('s1')
await opening.applyRemoteArtifact(notified.id)
release()
await loading
expect(opening.artifacts.map((a) => a.id).sort()).toEqual([kept.id, notified.id].sort())
})
it('drops an artifact the other tab removed', async () => {
const created = await store.create('s1', { name: 'Notes', content: 'x' })
await watcher.applyRemoteArtifact(created.id)