mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-21 00:02:30 +00:00
fix(ai-sessions): keep the final mirror frame inside its turn, sync plan mode
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
3b9322d947
commit
61eaabba93
@@ -501,10 +501,15 @@
|
||||
)
|
||||
// Fall back to ask-permission when the persisted mode isn't applicable in the
|
||||
// current AI mode (e.g. auto-accept edits while in a mode without edits).
|
||||
// A session planning in another tab overrides both: that posture governs the
|
||||
// run this tab is showing, and this tab's own mode governs nothing until it
|
||||
// drives a turn itself.
|
||||
const effectiveAutonomyMode = $derived(
|
||||
availableAutonomyModeOptions.some((option) => option.mode === aiChatManager.autonomyMode)
|
||||
? aiChatManager.autonomyMode
|
||||
: AIAutonomyMode.DEFAULT
|
||||
aiChatManager.mirroredPlanMode
|
||||
? AIAutonomyMode.PLAN
|
||||
: availableAutonomyModeOptions.some((option) => option.mode === aiChatManager.autonomyMode)
|
||||
? aiChatManager.autonomyMode
|
||||
: AIAutonomyMode.DEFAULT
|
||||
)
|
||||
const showAutonomyModeSelector = $derived(!disabled && availableAutonomyModeOptions.length > 1)
|
||||
const effectiveAutonomyModeOption = $derived(autonomyModeOption(effectiveAutonomyMode))
|
||||
@@ -955,7 +960,25 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
|
||||
onchange={onFolderInputChange}
|
||||
/>
|
||||
{/if}
|
||||
{#if showAutonomyModeSelector}
|
||||
{#if aiChatManager.mirroredPlanMode}
|
||||
<!-- The posture belongs to the tab running the turn, so it reads here
|
||||
rather than offering a choice that would only move this tab's own
|
||||
preference while the label stayed put. -->
|
||||
<Button
|
||||
nonCaptureEvent
|
||||
unifiedSize="2xs"
|
||||
variant="default"
|
||||
disabled
|
||||
title="This session is planning in another tab"
|
||||
btnClasses={effectiveAutonomyModeOption.triggerClass ?? ''}
|
||||
startIcon={{
|
||||
icon: effectiveAutonomyModeOption.icon,
|
||||
classes: effectiveAutonomyModeOption.iconColor
|
||||
}}
|
||||
>
|
||||
{autonomyModeLabel(effectiveAutonomyMode)}
|
||||
</Button>
|
||||
{:else if showAutonomyModeSelector}
|
||||
<DropdownV2
|
||||
items={() =>
|
||||
availableAutonomyModeOptions.map((option) => ({
|
||||
|
||||
@@ -701,6 +701,19 @@ export class AIChatManager {
|
||||
* session runtime. */
|
||||
mirroringRemoteRun = $state(false)
|
||||
|
||||
/** Whether the tab driving this session was last seen in plan mode.
|
||||
*
|
||||
* Plan mode is the one autonomy state that is deliberately never persisted
|
||||
* (see `persistAutonomyMode`): a model entered it for this session, so it
|
||||
* lives only in the memory of the tab running the turn. Every other mode is
|
||||
* a stored preference each tab is entitled to its own copy of.
|
||||
*
|
||||
* Kept after the turn ends rather than cleared with `mirroringRemoteRun`,
|
||||
* because the driver stays in plan mode between turns and stops sending
|
||||
* frames that could say so. The next turn's first frame corrects it; this
|
||||
* tab driving one of its own clears it outright. */
|
||||
mirroredPlanMode = $state(false)
|
||||
|
||||
/** Whether the turn that just finished is one a follow-up should be sent
|
||||
* after: it committed, or the user deliberately stopped it. False through a
|
||||
* provider error, an empty-response rollback, or a programmatic cancel — the
|
||||
@@ -3724,7 +3737,10 @@ export class AIChatManager {
|
||||
// saveChat no-ops on an empty transcript; the chat persisted earlier
|
||||
// this turn would linger in history and resurface the rolled-back
|
||||
// user message on reload. Remove it instead.
|
||||
this.historyManager.deletePastChat(this.historyManager.getCurrentChatId())
|
||||
// Awaited: the turn-end this rollback is about to announce sends the
|
||||
// other tabs back to the store for this chat, and a delete still in
|
||||
// flight leaves them the transcript being rolled back.
|
||||
await this.historyManager.deletePastChat(this.historyManager.getCurrentChatId())
|
||||
} else {
|
||||
await this.historyManager.saveChat(
|
||||
this.displayMessages,
|
||||
|
||||
@@ -588,11 +588,16 @@ export default class HistoryManager {
|
||||
this.pruneImageIds(this.currentChatId)
|
||||
}
|
||||
|
||||
deletePastChat(id: string) {
|
||||
/** Returns once the row is actually gone, for the one caller that has to know:
|
||||
* a rolled-back turn announces its end to the other tabs, and they re-read
|
||||
* this chat from the store. Dropped in flight, that read still finds the
|
||||
* transcript the rollback exists to remove. Everywhere else the removal is
|
||||
* visible from `savedChats` at once and the promise can be ignored. */
|
||||
deletePastChat(id: string): Promise<void> {
|
||||
this.savedChats = Object.fromEntries(
|
||||
Object.entries(this.savedChats).filter(([key]) => key !== id)
|
||||
)
|
||||
void this.enqueueDbWrite(async (db) => {
|
||||
return this.enqueueDbWrite(async (db) => {
|
||||
await db.delete('chats', id)
|
||||
const keys = await imageKeysForChat(db, id)
|
||||
await Promise.all(keys.map((key) => db.delete('images', key)))
|
||||
|
||||
@@ -387,6 +387,31 @@ describe('HistoryManager legacy chat-history migration', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves deletePastChat behind a checkpoint still in flight', async () => {
|
||||
const hm = new HistoryManager()
|
||||
await hm.init()
|
||||
const chatId = hm.getCurrentChatId()
|
||||
|
||||
// The shape a rolled-back turn actually ends in: a mid-turn checkpoint is
|
||||
// still queued when the rollback removes the chat.
|
||||
const checkpoint = hm.saveChat(
|
||||
[{ role: 'user', content: 'rolled back' }] as DisplayMessage[],
|
||||
[] as ChatCompletionMessageParam[]
|
||||
)
|
||||
const removed = hm.deletePastChat(chatId)
|
||||
await removed
|
||||
|
||||
// Read with no waitFor on purpose: the rollback announces its turn-end to the
|
||||
// other tabs as soon as this resolves, and they go straight to the store for
|
||||
// this chat. Resolving ahead of the queue hands them the very transcript the
|
||||
// rollback exists to remove.
|
||||
const db = await openDB('copilot-chat-history::admin@test')
|
||||
const stored = await db.get('chats' as never, chatId)
|
||||
db.close()
|
||||
expect(stored).toBeUndefined()
|
||||
await checkpoint
|
||||
})
|
||||
|
||||
it('loads pre-blob-store records with inline data URLs untouched', async () => {
|
||||
const png = 'data:image/png;base64,LEGACYINLINE'
|
||||
const hm = new HistoryManager()
|
||||
|
||||
@@ -936,6 +936,9 @@ async function initRuntime(runtime: SessionRuntime, session: Session) {
|
||||
// turns interleave into one chat id.
|
||||
manager.runGuard = async (body) => {
|
||||
const outcome = await withSessionRunLock(session.id, async () => {
|
||||
// Driving under this tab's own posture from here on, so whatever the
|
||||
// last driver was in stops describing the session.
|
||||
manager.mirroredPlanMode = false
|
||||
// The first frame doubles as the "a run started here" signal: it is
|
||||
// posted immediately and carries the chat id the watchers need.
|
||||
startMirroring(session.id)
|
||||
@@ -1054,7 +1057,8 @@ function mirrorSnapshotOf(sessionId: string, full: boolean): MirrorSnapshot | un
|
||||
currentReasoning: m.currentReasoning,
|
||||
currentReasoningActive: m.currentReasoningActive,
|
||||
loadingLabel: m.loadingLabel,
|
||||
compacting: m.compacting
|
||||
compacting: m.compacting,
|
||||
planModeActive: m.planModeActive
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1080,11 +1084,15 @@ function stopMirroring(sessionId: string): void {
|
||||
if (!timer) return
|
||||
clearInterval(timer)
|
||||
mirrorTimers.delete(sessionId)
|
||||
lastSentTotals.delete(sessionId)
|
||||
turnStarts.delete(sessionId)
|
||||
// One last frame: the closing tokens of a turn usually land between ticks,
|
||||
// and this is what the passive tabs render until their re-read completes.
|
||||
// Posted while the turn's bookkeeping still stands — it is a frame like any
|
||||
// other, and one sent without `turnStart` reaches below the turn and replaces
|
||||
// complete messages with payload-stripped copies, while one sent without
|
||||
// `lastSentTotals` cannot notice a compaction landing on this very tick.
|
||||
postMirror(sessionId)
|
||||
lastSentTotals.delete(sessionId)
|
||||
turnStarts.delete(sessionId)
|
||||
}
|
||||
|
||||
/** Adopt a frame from the tab driving this session. */
|
||||
@@ -1133,6 +1141,7 @@ function applyMirror(msg: MirrorMsg): void {
|
||||
m.currentReasoningActive = msg.currentReasoningActive
|
||||
m.loadingLabel = msg.loadingLabel
|
||||
m.compacting = msg.compacting
|
||||
m.mirroredPlanMode = msg.planModeActive
|
||||
}
|
||||
|
||||
/** The driver answers a resync with its whole transcript. */
|
||||
@@ -1187,7 +1196,16 @@ async function applyTurnEnd(sessionId: string, chatId: string, committed: boolea
|
||||
// than sent. Send it only after a turn that landed, which is the rule a turn
|
||||
// follows locally: firing it into a failed turn, or into the gap left by a tab
|
||||
// that vanished, is how a follow-up ends up answering nothing.
|
||||
if (committed) await m.flushQueuedMessage()
|
||||
if (!committed) return
|
||||
if (m.mirroredPlanMode) {
|
||||
// Plan mode belongs to the tab that entered it, and a turn sent from here
|
||||
// would run under this tab's own autonomy instead — unblocking the very
|
||||
// workspace tools the posture exists to hold back. Leave the message where
|
||||
// the user put it and say why, rather than quietly sending it out of mode.
|
||||
sendUserToast('This session is planning in another tab. Your message stays queued.')
|
||||
return
|
||||
}
|
||||
await m.flushQueuedMessage()
|
||||
}
|
||||
|
||||
/** A Stop pressed in a watching tab reaches the run here. */
|
||||
|
||||
@@ -66,6 +66,11 @@ type MirrorMsg = {
|
||||
currentReasoningActive: boolean
|
||||
loadingLabel: string | undefined
|
||||
compacting: boolean
|
||||
/** The driver's plan-mode posture. The only autonomy state worth carrying:
|
||||
* every other one is a stored preference each tab keeps its own copy of,
|
||||
* while plan mode is never persisted and so exists nowhere but the driving
|
||||
* tab's memory. */
|
||||
planModeActive: boolean
|
||||
}
|
||||
/** Sent by a tab whose local prefix can't host the tail it just received (it
|
||||
* joined mid-run, or is on a different chat). The driver answers with a full
|
||||
|
||||
Reference in New Issue
Block a user