mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-21 00:02:30 +00:00
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:
co-authored by
Claude Opus 5
parent
fac221c1bb
commit
0107207bc3
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user