feat: version history for session artifacts (#10574)

* fix: never replace an in-flight indexeddb open, only a settled one

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat: keep a version history for session artifacts

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat: let the assistant browse an artifact's earlier versions

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat: pick an older artifact version from the preview panel

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(ai_evals): cover the change note the assistant writes on each edit

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: bound every indexeddb open, not only one told it is blocked

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
AlexRV12
2026-08-10 19:51:29 +02:00
committed by GitHub
parent 4a69cd616e
commit 77adf85ccd
21 changed files with 1286 additions and 131 deletions
@@ -0,0 +1,22 @@
import { describe, expect, it } from "bun:test";
import { createEvalArtifactHelpers } from "./evalArtifactStore";
// A hand-written stand-in for SessionArtifactsStore (bun has no IndexedDB), so nothing
// makes it follow that class. A method missing from it surfaces as a tool throwing
// part-way through an eval run, which reads as a model failure rather than a harness one.
describe("eval artifact store", () => {
it("exposes every method the artifact tools call", () => {
const { helpers } = createEvalArtifactHelpers();
for (const method of [
"create",
"get",
"update",
"remove",
"listForSession",
"listVersions",
"getVersion",
]) {
expect(typeof (helpers.artifacts as any)[method]).toBe("function");
}
});
});
@@ -0,0 +1,93 @@
// SessionArtifactsStore can't run here (bun has no IndexedDB, nor the compiled $state runes),
// so mirror only the shape the artifact tools call, not its scoping or race handling.
export const EVAL_SESSION_ID = "eval-session";
export function createEvalArtifactHelpers() {
const items = new Map<string, Record<string, any>>();
// Snapshots per artifact id, oldest first — the version tools read history from here.
const history = new Map<string, Array<Record<string, any>>>();
let seq = 0;
const snapshotOf = (
artifact: Record<string, any>,
version: number,
note?: string,
) => ({
key: `${artifact.id}:${version}`,
artifactId: artifact.id,
version,
name: artifact.name,
content: artifact.content,
savedAt: artifact.updatedAt,
note,
});
const store = {
create: async (sessionId: string, input: Record<string, any>) => {
const now = seq++;
const artifact = {
id: `eval-artifact-${now}`,
sessionId,
chatId: input.chatId,
kind: input.kind ?? "md",
name: input.name,
content: input.content,
createdAt: now,
updatedAt: now,
version: 1,
};
items.set(artifact.id, artifact);
history.set(artifact.id, [snapshotOf(artifact, 1)]);
return artifact;
},
get: async (id: string) => items.get(id),
update: async (
id: string,
input: Record<string, any>,
opts?: { sessionId?: string },
) => {
const existing = items.get(id);
if (!existing) return undefined;
if (
opts?.sessionId !== undefined &&
existing.sessionId !== opts.sessionId
)
return undefined;
// Only a content change earns a version, as in SessionArtifactsStore.
const contentChanged =
input.content !== undefined && input.content !== existing.content;
const version = (existing.version ?? 1) + (contentChanged ? 1 : 0);
const updated = {
...existing,
name: input.name ?? existing.name,
content: input.content ?? existing.content,
updatedAt: seq++,
version,
};
items.set(id, updated);
if (contentChanged) {
history.set(id, [
...(history.get(id) ?? []),
snapshotOf(updated, version, input.note),
]);
}
return updated;
},
remove: async (id: string) => {
items.delete(id);
history.delete(id);
},
listForSession: async (sessionId: string) =>
[...items.values()].filter((a) => a.sessionId === sessionId),
listVersions: async (id: string) =>
[...(history.get(id) ?? [])].sort((a, b) => b.version - a.version),
getVersion: async (id: string, version: number) =>
(history.get(id) ?? []).find((v) => v.version === version),
};
return {
helpers: {
artifacts: store,
sessionId: EVAL_SESSION_ID,
getChatId: () => "eval-chat",
openArtifact: () => {},
},
snapshot: () => [...items.values()],
};
}
@@ -14,6 +14,7 @@ import {
} from "../../../../../frontend/src/lib/components/copilot/chat/global/userDraftAdapter";
import type { Tool as ProductionTool } from "../../../../../frontend/src/lib/components/copilot/chat/shared";
import { UserDraft } from "../../../../../frontend/src/lib/userDraft.svelte";
import { createEvalArtifactHelpers } from "./evalArtifactStore";
import type { ModeRunContext } from "../../../../core/types";
import type { GlobalDraftState } from "../../../../core/validators";
import type { WindmillBackendSettings } from "../../../../core/windmillBackendSettings";
@@ -43,67 +44,6 @@ const LIVE_EDITOR_ITEM_KINDS = {
app: "raw_app",
} as const;
// SessionArtifactsStore can't run here (bun has no IndexedDB, nor the compiled $state runes),
// so mirror only its tool-facing shape; its own logic (scoping, race guard) is unit-tested.
const EVAL_SESSION_ID = "eval-session";
function createEvalArtifactHelpers() {
const items = new Map<string, Record<string, unknown>>();
let seq = 0;
const store = {
create: async (sessionId: string, input: Record<string, any>) => {
const now = seq++;
const artifact = {
id: `eval-artifact-${now}`,
sessionId,
chatId: input.chatId,
kind: input.kind ?? "md",
name: input.name,
content: input.content,
createdAt: now,
updatedAt: now,
};
items.set(artifact.id, artifact);
return artifact;
},
get: async (id: string) => items.get(id),
update: async (
id: string,
input: Record<string, any>,
opts?: { sessionId?: string },
) => {
const existing = items.get(id);
if (!existing) return undefined;
if (
opts?.sessionId !== undefined &&
existing.sessionId !== opts.sessionId
)
return undefined;
const updated = {
...existing,
name: input.name ?? existing.name,
content: input.content ?? existing.content,
updatedAt: seq++,
};
items.set(id, updated);
return updated;
},
remove: async (id: string) => {
items.delete(id);
},
listForSession: async (sessionId: string) =>
[...items.values()].filter((a) => a.sessionId === sessionId),
};
return {
helpers: {
artifacts: store,
sessionId: EVAL_SESSION_ID,
getChatId: () => "eval-chat",
openArtifact: () => {},
},
snapshot: () => [...items.values()],
};
}
export interface GlobalLiveEditorDraftFixture {
type: keyof typeof LIVE_EDITOR_ITEM_KINDS;
storagePath?: string;
+32
View File
@@ -1140,6 +1140,38 @@
- closes the runs preview tab in the side panel
- does not write, deploy, or delete anything
# --- Artifact version history ---
# Every content change to an artifact is snapshotted, and update_artifact requires a
# change_note that the user reads in the version picker. A blank note makes the history
# unreadable, so pin that the model fills it on every edit.
- id: global-artifact-note-on-each-edit
prompt: |-
Write up a short rollout plan for me as a doc I can come back to, covering a staged
rollout in three phases. Then add a rollback section to it, and after that tighten
the wording of phase 2.
runtime:
maxTurns: 12
sessionChat: true
validate:
draftCountExactly: 0
toolExpect:
requiredToolsUsed:
- create_artifact
- update_artifact
forbiddenToolsUsed:
- deploy_workspace_item
- delete_workspace_item
toolCallArgs:
# The note is what the version picker shows; a blank one makes history unreadable.
- tool: update_artifact
field: change_note
nonEmpty: true
skipJudge: true
judgeChecklist:
- creates one artifact and revises it rather than creating a second artifact
- each revision carries a short description of what changed
# --- Documentation search (search_docs) ---
# Pure product-knowledge questions: the assistant should consult the docs via
# search_docs and answer conversationally, not draft or mutate anything. No
+6
View File
@@ -166,6 +166,12 @@ export interface ToolCallArgumentRule {
* tool — e.g. SQL where a mutation is mixed with verification SELECTs.
*/
stringIncludesAnyOf?: string[];
/**
* Universal over calls: every recorded call to `tool` must carry `field` as a
* non-blank string. Use for a required argument whose value is free text, where
* the point is that the model filled it in at all rather than what it said.
*/
nonEmpty?: boolean;
}
export interface ToolValidationSpec {
+46
View File
@@ -174,6 +174,52 @@ describe("validateToolExpectations", () => {
expect(checks.every((check) => check.passed)).toBe(true);
});
it("fails nonEmpty when any call left the field blank", () => {
const checks = validateToolExpectations({
run: {
success: true,
actual: {},
assistantMessageCount: 1,
toolCallCount: 2,
toolsUsed: ["update_artifact"],
toolCallDetails: [
{ name: "update_artifact", arguments: { change_note: "Added a rollback section" } },
// A whitespace-only note is as unreadable in the picker as a missing one.
{ name: "update_artifact", arguments: { change_note: " " } },
],
skillsInvoked: [],
},
toolExpect: {
toolCallArgs: [{ tool: "update_artifact", field: "change_note", nonEmpty: true }],
},
});
const nonEmptyCheck = checks.find((c) => c.name.includes("is filled in on every call"));
expect(nonEmptyCheck?.passed).toBe(false);
expect(nonEmptyCheck?.details).toContain("blank on 1 of 2");
});
it("passes nonEmpty when every call filled the field", () => {
const checks = validateToolExpectations({
run: {
success: true,
actual: {},
assistantMessageCount: 1,
toolCallCount: 1,
toolsUsed: ["update_artifact"],
toolCallDetails: [
{ name: "update_artifact", arguments: { change_note: "Tightened phase 2" } },
],
skillsInvoked: [],
},
toolExpect: {
toolCallArgs: [{ tool: "update_artifact", field: "change_note", nonEmpty: true }],
},
});
expect(checks.every((check) => check.passed)).toBe(true);
});
it("accepts a stringIncludesAnyOf substring inside an array-valued field", () => {
const checks = validateToolExpectations({
run: {
+13
View File
@@ -233,6 +233,19 @@ export function validateToolExpectations(input: {
);
}
if (rule.nonEmpty) {
const blankValues = values.filter(
(value) => typeof value !== "string" || value.trim().length === 0
);
checks.push(
check(
`${rule.tool}.${rule.field} is filled in on every call`,
blankValues.length === 0,
`blank on ${blankValues.length} of ${values.length} call(s); values: ${summarizeToolValues(values)}`
)
);
}
if (rule.stringIncludesAnyOf && rule.stringIncludesAnyOf.length > 0) {
// Existential: at least one call must contain one of the substrings.
// Other calls to the same tool may do anything — this suits SQL, where a
+53 -2
View File
@@ -9,6 +9,8 @@
noDate?: boolean
isRecent?: boolean
noSeconds?: boolean
/** Single-unit form ("2m", "3h", "5d") for rows too narrow for "2 mins ago". */
compact?: boolean
}
let {
@@ -16,7 +18,8 @@
agoOnlyIfRecent = false,
noDate = false,
isRecent = $bindable(true),
noSeconds = false
noSeconds = false,
compact = false
}: Props = $props()
let computedTimeAgo: string | undefined = $state(undefined)
@@ -24,7 +27,10 @@
let interval
onMount(() => {
// Update every minute for noSeconds mode, every second for regular mode
// compact schedules itself below; it needs no fixed rate.
if (compact) return
// Update every minute for noSeconds mode, every second otherwise.
const intervalMs = noSeconds ? 60000 : 1000
interval = setInterval(() => {
computeDate()
@@ -40,6 +46,36 @@
}
})
// Waking on the boundary of the unit on screen, rather than at a fixed rate: `2h` only
// changes on the hour, and a row that reads `5d` must not hold a 1s timer to find that
// out. Re-armed when `date` changes, so an item edited to now leaves its day-long wait.
$effect(() => {
if (!compact) return
const at = date
let handle: ReturnType<typeof setTimeout> | undefined
const tick = () => {
computeDate()
handle = setTimeout(tick, compactDelayMs(at))
}
handle = setTimeout(tick, compactDelayMs(at))
return () => {
handle && clearTimeout(handle)
}
})
function compactDelayMs(dateString: string): number {
const secs = secondsAgo(new Date(dateString))
const left =
secs < 60
? 1
: secs < 3600
? 60 - (secs % 60)
: secs < 86_400
? 3600 - (secs % 3600)
: 86_400 - (secs % 86_400)
return left * 1000
}
async function computeDate() {
computedTimeAgo = await displayDaysAgo(date)
}
@@ -80,6 +116,21 @@
async function displayDaysAgo(dateString: string): Promise<string> {
const date = new Date(dateString)
if (compact) {
// The s/m/h/d ladder the hand-rolled `ago()` helpers around the codebase use
// (PipelineEventLog, PipelineActivityPanel, IndexerMemorySettings): seconds stay
// meaningful right up to the minute mark.
const secs = secondsAgo(date)
if (secs < 60) return `${secs}s`
const mins = minutesAgo(date)
if (mins < 60) return `${mins}m`
const hours = hoursAgo(date)
if (hours < 24) return `${hours}h`
// Days all the way up, never an absolute date: callers read as "<slot> ago", and
// the hand-rolled ago() helpers this mirrors have no date fallback either.
return `${daysAgo(date)}d`
}
// New noSeconds mode
if (noSeconds) {
const mins = minutesAgo(date)
@@ -0,0 +1,109 @@
<script lang="ts">
import Popover from '$lib/components/meltComponents/Popover.svelte'
import TimeAgo from '$lib/components/TimeAgo.svelte'
import { ChevronDown } from 'lucide-svelte'
import { displayDate } from '$lib/utils'
import { currentVersion, type ArtifactVersion, type PersistedArtifact } from './artifactsDB'
import type { SessionArtifactsStore } from './artifactsState.svelte'
interface Props {
artifact: PersistedArtifact
store: SessionArtifactsStore
/** Version being shown, or undefined for the current one. */
selected: number | undefined
onSelect: (version: number | undefined) => void
}
let { artifact, store, selected, onSelect }: Props = $props()
const latest = $derived(currentVersion(artifact))
const shown = $derived(selected ?? latest)
let open = $state(false)
let versions = $state<ArtifactVersion[]>([])
// Loaded on open, not on mount: a snapshot row carries its whole content, so listing
// twenty of them is far more than the trigger needs to know that history exists.
// Reloaded on every later version too, since the assistant can write one while the menu
// is open — the rows would then contradict the count in the header.
$effect(() => {
// Reads `latest` only while open, which is exactly when a reload is worth doing.
if (open && latest) void load()
})
// Reopening the menu while the assistant is writing puts two loads in flight. Only the
// last one asked for may win: an earlier list landing last would be rendered under a
// header counted against the newer `latest`, claiming history had been pruned when it
// had not.
let loadSeq = 0
async function load() {
const seq = ++loadSeq
const loaded = await store.listVersions(artifact.id)
if (seq === loadSeq) versions = loaded
}
// v1 was created; anything later was edited — a missing note must not claim otherwise.
function noteLabel(v: ArtifactVersion): string {
return v.note ?? (v.version === 1 ? 'Created' : 'Edited')
}
function pick(version: number) {
open = false
onSelect(version === latest ? undefined : version)
}
</script>
<Popover
bind:isOpen={open}
placement="bottom-start"
closeOnOtherPopoverOpen
contentClasses="!bg-surface"
triggerAttrs={{ 'aria-label': `Version ${shown} of ${latest}`, 'aria-haspopup': 'listbox' }}
class="shrink-0"
>
{#snippet trigger()}
<span
class="flex items-center gap-1 rounded px-1.5 py-0.5 text-2xs font-normal tabular-nums
{selected !== undefined
? 'bg-orange-100 text-orange-800 dark:bg-orange-950 dark:text-orange-300'
: 'bg-surface-secondary text-secondary hover:text-emphasis'}"
>
v{shown}
<ChevronDown size={11} />
</span>
{/snippet}
{#snippet content()}
<div class="flex w-80 flex-col text-xs">
<!-- Version numbers run 1..latest, so a count says nothing the rows don't — until
pruning drops the oldest, which is the one thing the list cannot show. -->
<div class="px-3 pt-2 pb-1 text-2xs text-hint">
{#if versions.length > 0 && versions.length < latest}
{versions.length} most recent of {latest} versions
{:else}
Versions
{/if}
</div>
<div class="max-h-[min(20rem,60vh)] overflow-y-auto py-1" role="listbox" tabindex="-1">
{#each versions as v (v.version)}
<button
type="button"
role="option"
aria-selected={v.version === shown}
title={noteLabel(v)}
class="flex w-full flex-col gap-0.5 px-3 py-1.5 text-left font-normal
{v.version === shown ? 'bg-surface-accent-selected' : 'hover:bg-surface-hover'}"
onclick={() => pick(v.version)}
>
<span class="line-clamp-2 w-full text-primary">
{noteLabel(v)}
</span>
<span class="text-2xs tabular-nums text-hint" title={displayDate(new Date(v.savedAt))}>
v{v.version} · <TimeAgo date={new Date(v.savedAt).toISOString()} compact /> ago
</span>
</button>
{/each}
</div>
</div>
{/snippet}
</Popover>
@@ -9,14 +9,68 @@
import { copyToClipboard, download } from '$lib/utils'
import CodeDisplay from '../script/CodeDisplay.svelte'
import LinkRenderer from '../LinkRenderer.svelte'
import { artifactFilename, artifactMimeType, type PersistedArtifact } from './artifactsDB'
import {
artifactFilename,
artifactMimeType,
currentVersion,
type ArtifactVersion,
type PersistedArtifact
} from './artifactsDB'
import { markdownProse } from '$lib/components/markdownProse'
import ArtifactVersionPicker from './ArtifactVersionPicker.svelte'
import type { SessionArtifactsStore } from './artifactsState.svelte'
import { History } from 'lucide-svelte'
import TimeAgo from '$lib/components/TimeAgo.svelte'
interface Props {
artifact: PersistedArtifact
store: SessionArtifactsStore
}
let { artifact }: Props = $props()
let { artifact, store }: Props = $props()
const latest = $derived(currentVersion(artifact))
// Nothing to pick between until a second version exists.
const hasHistory = $derived(latest > 1)
// undefined = following the current version. An explicit pick survives later edits, so
// the AI writing v8 does not yank the reader out of the v3 they chose to read.
let pinned = $state<number | undefined>(undefined)
let pinnedContent = $state<ArtifactVersion | undefined>(undefined)
// The store hands us a fresh object on every edit, so this effect reruns constantly.
// Compare the id against the last one seen: clearing on every rerun would drop the
// reader's pin the moment the assistant writes a new version.
let pinnedFor: string | undefined
$effect(() => {
if (artifact.id === pinnedFor) return
pinnedFor = artifact.id
pinned = undefined
})
$effect(() => {
const version = pinned
if (version === undefined) {
pinnedContent = undefined
return
}
const id = artifact.id
void store.getVersion(id, version).then((snapshot) => {
if (pinned !== version || artifact.id !== id) return
// Pruned out from under the pin (history is capped): fall back to current rather
// than showing an empty document.
if (!snapshot) {
pinned = undefined
return
}
pinnedContent = snapshot
})
})
const shown = $derived(pinnedContent ?? artifact)
// Label from the snapshot that is rendered, not from the one just requested: the read is
// async, so `pinned` names a version the body has not swapped to yet.
const shownVersion = $derived(pinnedContent?.version)
// Markdown is the only rendered kind in v1; anything else shows source only.
const canPreview = $derived(artifact.kind === 'md')
@@ -25,12 +79,16 @@
let copied = $state(false)
async function copyRaw() {
if (!(await copyToClipboard(artifact.content))) return
if (!(await copyToClipboard(shown.content))) return
copied = true
setTimeout(() => (copied = false), 1500)
}
function downloadFile() {
download(artifactFilename(artifact), artifact.content, artifactMimeType(artifact.kind))
download(
artifactFilename({ name: shown.name, kind: artifact.kind }),
shown.content,
artifactMimeType(artifact.kind)
)
}
const plugins = [gfmPlugin(), { renderer: { pre: CodeDisplay, a: LinkRenderer } }]
@@ -40,9 +98,17 @@
<div class="flex items-center justify-between gap-2 px-8 py-2">
<div class="flex items-center gap-1.5 min-w-0 flex-1">
<FileText size={14} class="shrink-0 text-secondary" />
<span class="truncate text-xs font-normal text-emphasis" title={artifact.name}>
{artifact.name}
<span class="truncate text-xs font-normal text-emphasis" title={shown.name}>
{shown.name}
</span>
{#if hasHistory}
<ArtifactVersionPicker
{artifact}
{store}
selected={shownVersion}
onSelect={(v) => (pinned = v)}
/>
{/if}
</div>
<div class="flex items-center gap-2 shrink-0">
<!-- Copy raw markdown, with a dropdown for the download-as-file variant. -->
@@ -78,11 +144,31 @@
</div>
</div>
{#if pinnedContent}
<!-- Everything below is stale text; say so where it cannot be scrolled past unnoticed. -->
<div
class="flex items-center gap-2 px-8 py-1 text-2xs font-normal
bg-orange-100 text-orange-800 dark:bg-orange-950 dark:text-orange-300"
>
<History size={12} class="shrink-0" />
<span class="truncate">
<!-- compact renders the magnitude only ("8m"); the phrasing belongs to the sentence. -->
Viewing v{shownVersion} of {latest} · saved
<TimeAgo date={new Date(pinnedContent.savedAt).toISOString()} compact /> ago
</span>
<div class="ml-auto shrink-0">
<Button unifiedSize="xs" variant="default" onClick={() => (pinned = undefined)}>
Back to latest
</Button>
</div>
</div>
{/if}
<div class="flex-1 min-h-0 overflow-auto px-8">
{#if source}
<!-- key: SimpleEditor reads `code` only on init; remount on id or content change. -->
{#key `${artifact.id}:${artifact.updatedAt}`}
<SimpleEditor lang="markdown" code={artifact.content} readOnly class="h-full" />
{#key `${artifact.id}:${pinnedContent ? `v${pinnedContent.version}` : artifact.updatedAt}`}
<SimpleEditor lang="markdown" code={shown.content} readOnly class="h-full" />
{/key}
{:else}
<!-- Pinned under the header, fades scrolled-under content instead of hard-clipping it.
@@ -90,7 +176,7 @@
<div class="sticky top-0 z-10 h-4 -mb-4 bg-gradient-to-b from-surface-tertiary to-transparent"
></div>
<div class="pb-4 pt-2 {markdownProse.doc}">
<Markdown md={artifact.content} {plugins} />
<Markdown md={shown.content} {plugins} />
</div>
{/if}
</div>
@@ -5,7 +5,12 @@
import { Download, Trash2 } from 'lucide-svelte'
import { download, displayDate } from '$lib/utils'
import { getAiChatManager } from '../aiChatManagerContext'
import { artifactFilename, artifactMimeType, type PersistedArtifact } from './artifactsDB'
import {
artifactFilename,
artifactMimeType,
currentVersion,
type PersistedArtifact
} from './artifactsDB'
const aiChatManager = getAiChatManager()
const artifacts = $derived(aiChatManager.artifacts.artifacts)
@@ -36,7 +41,8 @@
class="min-w-[4.5rem] shrink-0 text-right text-2xs font-normal text-hint"
title={displayDate(new Date(a.updatedAt))}
>
<TimeAgo date={new Date(a.updatedAt).toISOString()} noSeconds />
{#if currentVersion(a) > 1}<span class="tabular-nums">v{currentVersion(a)}</span> ·{/if}
<TimeAgo date={new Date(a.updatedAt).toISOString()} compact />
</span>
{/snippet}
{#snippet actions(a)}
@@ -107,13 +107,21 @@ describe('artifact tools', () => {
it('update_artifact overwrites content and persists', async () => {
const a = JSON.parse(await ctx.call('create_artifact', { name: 'A', content: 'v1' }))
const res = JSON.parse(await ctx.call('update_artifact', { id: a.id, content: 'v2' }))
const res = JSON.parse(
await ctx.call('update_artifact', {
id: a.id,
content: 'v2',
change_note: 'Reworded the intro'
})
)
expect(res.success).toBe(true)
expect((await ctx.dbMod.getArtifact(a.id))?.content).toBe('v2')
})
it('update_artifact reports a missing id', async () => {
const res = JSON.parse(await ctx.call('update_artifact', { id: 'nope', content: 'x' }))
const res = JSON.parse(
await ctx.call('update_artifact', { id: 'nope', content: 'x', change_note: 'n/a' })
)
expect(res.success).toBe(false)
expect(res.error).toMatch(/No artifact/)
})
@@ -123,6 +131,55 @@ describe('artifact tools', () => {
expect(res.success).toBe(false)
})
it('exposes the version history and reads an earlier version by number', async () => {
const a = JSON.parse(await ctx.call('create_artifact', { name: 'A', content: 'v1' }))
await ctx.call('update_artifact', {
id: a.id,
content: 'v2',
change_note: 'Reworded the intro'
})
const versions = JSON.parse(await ctx.call('list_artifact_versions', { id: a.id }))
expect(versions.map((v: any) => [v.version, v.current, v.note])).toEqual([
[2, true, 'Reworded the intro'],
// A first version has no note — the picker labels it itself.
[1, false, undefined]
])
expect(JSON.parse(await ctx.call('read_artifact', { id: a.id, version: 1 })).content).toBe('v1')
// Omitting the version, and naming the current one, both read the live content.
expect(JSON.parse(await ctx.call('read_artifact', { id: a.id })).content).toBe('v2')
expect(JSON.parse(await ctx.call('read_artifact', { id: a.id, version: 2 })).content).toBe('v2')
})
it('stores an overlong change note truncated rather than failing the update', async () => {
const a = JSON.parse(await ctx.call('create_artifact', { name: 'A', content: 'v1' }))
const res = JSON.parse(
await ctx.call('update_artifact', { id: a.id, content: 'v2', change_note: 'x'.repeat(300) })
)
// The content edit must land: the note is a label, not a reason to reject the write.
expect(res.success).toBe(true)
expect((await ctx.dbMod.getArtifact(a.id))?.content).toBe('v2')
const [latest] = await ctx.store.listVersions(a.id)
expect(latest.note).toHaveLength(120)
})
it('stores a blank change note as absent so the picker can label it', async () => {
const a = JSON.parse(await ctx.call('create_artifact', { name: 'A', content: 'v1' }))
// Whitespace-only survives zod's `z.string()`; "" is not nullish, so it would slip
// past the picker's fallback and render a row with no label at all.
await ctx.call('update_artifact', { id: a.id, content: 'v2', change_note: ' ' })
const [latest] = await ctx.store.listVersions(a.id)
expect(latest.note).toBeUndefined()
})
it('read_artifact reports a version that was never saved or has been pruned', async () => {
const a = JSON.parse(await ctx.call('create_artifact', { name: 'A', content: 'v1' }))
const res = JSON.parse(await ctx.call('read_artifact', { id: a.id, version: 7 }))
expect(res.success).toBe(false)
expect(res.error).toMatch(/list_artifact_versions/)
})
it('rejects content over the size cap without persisting', async () => {
const huge = 'x'.repeat(256 * 1024 + 1)
const res = JSON.parse(await ctx.call('create_artifact', { name: 'Big', content: huge }))
@@ -133,8 +190,16 @@ describe('artifact tools', () => {
it('reports unavailable when there is no session', async () => {
const noSession = await fresh(undefined)
for (const name of ['create_artifact', 'list_artifacts', 'update_artifact', 'read_artifact']) {
const res = JSON.parse(await noSession.call(name, { id: 'x', name: 'A', content: 'a' }))
for (const name of [
'create_artifact',
'list_artifacts',
'update_artifact',
'read_artifact',
'list_artifact_versions'
]) {
const res = JSON.parse(
await noSession.call(name, { id: 'x', name: 'A', content: 'a', change_note: 'n/a' })
)
expect(res.success).toBe(false)
expect(res.error).toMatch(/inside an AI session/)
}
@@ -154,7 +219,9 @@ describe('artifact tools', () => {
})
const read = JSON.parse(await ctx.call('read_artifact', { id: 'other' }))
expect(read.success).toBe(false)
const updated = JSON.parse(await ctx.call('update_artifact', { id: 'other', content: 'x' }))
const updated = JSON.parse(
await ctx.call('update_artifact', { id: 'other', content: 'x', change_note: 'n/a' })
)
expect(updated.success).toBe(false)
// The other session's content is untouched.
expect((await ctx.dbMod.getArtifact('other'))?.content).toBe('secret')
@@ -1,5 +1,6 @@
import { z } from 'zod'
import { createToolDef, type Tool } from '../shared'
import { currentVersion } from './artifactsDB'
import type { SessionArtifactsStore } from './artifactsState.svelte'
// The subset of GlobalToolHelpers these tools read. Kept local (not imported from
@@ -14,6 +15,11 @@ type ArtifactToolHelpers = {
const MAX_ARTIFACT_BYTES = 256 * 1024
// Bounds what a snapshot stores and replays to the model. Enforced by truncation rather
// than by the schema: rejecting the call would throw away a real content update over a
// cosmetic label, and the model would have to resend the whole document to recover.
const MAX_NOTE_CHARS = 120
const createArtifactSchema = z.object({
name: z.string().describe('Short display title for the artifact.'),
content: z.string().describe('Full markdown content of the artifact.')
@@ -22,13 +28,26 @@ const createArtifactSchema = z.object({
const updateArtifactSchema = z.object({
id: z.string().describe('Id of the artifact to update, from create_artifact or list_artifacts.'),
content: z.string().describe('New full markdown content, replacing the previous content.'),
name: z.string().optional().describe('New display title. Omit to keep the current one.')
name: z.string().optional().describe('New display title. Omit to keep the current one.'),
change_note: z
.string()
.describe(
'What this edit changes, as a short label the user will read in the version picker: under 60 characters, no trailing period, starting with a verb — "Added rollback section", "Tightened the phase 2 wording".'
)
})
const listArtifactsSchema = z.object({})
const readArtifactSchema = z.object({
id: z.string().describe('Id of the artifact to read.')
id: z.string().describe('Id of the artifact to read.'),
version: z
.number()
.optional()
.describe('Version to read, from list_artifact_versions. Omit for the current content.')
})
const listArtifactVersionsSchema = z.object({
id: z.string().describe('Id of the artifact whose history to list.')
})
function tooLarge(content: string): string | undefined {
@@ -93,7 +112,13 @@ export const artifactTools: Tool<{}>[] = [
}
const updated = await h.artifacts.update(
parsed.id,
{ content: parsed.content, name: parsed.name },
{
content: parsed.content,
name: parsed.name,
// Blank collapses to undefined, not "": an empty string is not nullish, so it
// would slip past the picker's fallback and render a row with no label.
note: parsed.change_note.trim().slice(0, MAX_NOTE_CHARS) || undefined
},
{ sessionId }
)
if (!updated) {
@@ -134,7 +159,7 @@ export const artifactTools: Tool<{}>[] = [
def: createToolDef(
readArtifactSchema,
'read_artifact',
"Read an artifact's full markdown content by id."
"Read an artifact's full markdown content by id, at its current or an earlier version."
),
fn: async ({ args, toolId, toolCallbacks, helpers }) => {
const parsed = readArtifactSchema.parse(args)
@@ -151,13 +176,70 @@ export const artifactTools: Tool<{}>[] = [
toolCallbacks.setToolStatus(toolId, { content: error, error })
return JSON.stringify({ success: false, error })
}
if (parsed.version !== undefined && parsed.version !== currentVersion(artifact)) {
const snapshot = await h.artifacts.getVersion(parsed.id, parsed.version, { sessionId })
if (!snapshot) {
// Pruned or never existed; either way the model should re-list rather than retry.
const error = `Artifact "${artifact.name}" has no version ${parsed.version}. Call list_artifact_versions for the versions still kept.`
toolCallbacks.setToolStatus(toolId, { content: error, error })
return JSON.stringify({ success: false, error })
}
toolCallbacks.setToolStatus(toolId, {
content: `Read artifact "${artifact.name}" (v${snapshot.version})`
})
return JSON.stringify({
id: artifact.id,
name: snapshot.name,
kind: artifact.kind,
version: snapshot.version,
savedAt: new Date(snapshot.savedAt).toISOString(),
content: snapshot.content
})
}
toolCallbacks.setToolStatus(toolId, { content: `Read artifact "${artifact.name}"` })
return JSON.stringify({
id: artifact.id,
name: artifact.name,
kind: artifact.kind,
version: currentVersion(artifact),
content: artifact.content
})
}
},
{
def: createToolDef(
listArtifactVersionsSchema,
'list_artifact_versions',
"List an artifact's saved versions, newest first. Read one with read_artifact's version argument."
),
fn: async ({ args, toolId, toolCallbacks, helpers }) => {
const parsed = listArtifactVersionsSchema.parse(args)
const h = helpers as ArtifactToolHelpers
const sessionId = h.sessionId
if (!h.artifacts || !sessionId) {
toolCallbacks.setToolStatus(toolId, { content: UNAVAILABLE, error: UNAVAILABLE })
return JSON.stringify({ success: false, error: UNAVAILABLE })
}
const artifact = await h.artifacts.get(parsed.id)
if (!artifact || artifact.sessionId !== sessionId) {
const error = `No artifact found with id "${parsed.id}".`
toolCallbacks.setToolStatus(toolId, { content: error, error })
return JSON.stringify({ success: false, error })
}
const versions = await h.artifacts.listVersions(parsed.id, { sessionId })
const current = currentVersion(artifact)
toolCallbacks.setToolStatus(toolId, {
content: `Listed ${versions.length} version${versions.length === 1 ? '' : 's'} of "${artifact.name}"`
})
return JSON.stringify(
versions.map((v) => ({
version: v.version,
current: v.version === current,
name: v.name,
savedAt: new Date(v.savedAt).toISOString(),
...(v.note ? { note: v.note } : {})
}))
)
}
}
]
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { IDBFactory } from 'fake-indexeddb'
import type { PersistedArtifact } from './artifactsDB'
import type { ArtifactVersion, PersistedArtifact } from './artifactsDB'
// The user-scoping subscription is BROWSER-gated; the node test env reports false.
vi.mock('esm-env', async (orig) => ({
@@ -47,6 +47,17 @@ beforeEach(async () => {
db = await freshDb()
})
function version(v: number, artifactId = 'a1'): ArtifactVersion {
return {
key: `${artifactId}:${v}`,
artifactId,
version: v,
name: 'Doc',
content: `body ${v}`,
savedAt: v
}
}
describe('artifactsDB', () => {
it('derives filename and mime type from the artifact kind', () => {
expect(db.artifactFilename({ name: 'Plan', kind: 'md' })).toBe('Plan.md')
@@ -94,6 +105,81 @@ describe('artifactsDB', () => {
expect((await db.listArtifactsForSession('s2')).map((a) => a.id)).toEqual(['c'])
})
it('upgrades a version-1 database in place, keeping the artifacts already in it', async () => {
vi.resetModules()
;(globalThis as any).indexedDB = new IDBFactory()
const store = (await import('$lib/stores')).userStore as unknown as {
set: (v: unknown) => void
}
store.set({ email: 'a@x.com' })
// The schema exactly as it shipped before version history: `items` and nothing else.
// Every existing user's database looks like this, and the upgrade runs over it — the
// path no other test reaches, because they all start from an empty IDBFactory.
const { openDB } = await import('idb')
const v1 = (await openDB('copilot-artifacts::a@x.com', 1, {
upgrade(database) {
const items = (database as any).createObjectStore('items', { keyPath: 'id' })
items.createIndex('by-session', 'sessionId')
}
})) as any
await v1.put('items', artifact({ id: 'old', sessionId: 's1', content: 'written at v1' }))
v1.close()
const upgraded = await import('./artifactsDB')
// A ConstraintError here would reject the open, and a rejected open degrades silently
// — every pre-existing artifact would just quietly stop existing.
expect((await upgraded.getArtifact('old'))?.content).toBe('written at v1')
expect((await upgraded.listArtifactsForSession('s1')).map((a) => a.id)).toEqual(['old'])
// The store the upgrade added works on the upgraded database, not just a fresh one.
await upgraded.putArtifactWithVersions(artifact({ id: 'old' }), [version(1, 'old')])
expect((await upgraded.listArtifactVersions('old')).map((v) => v.version)).toEqual([1])
})
it('keeps only the most recent versions of an artifact', async () => {
const total = db.MAX_VERSIONS_PER_ARTIFACT + 5
for (let v = 1; v <= total; v++)
await db.putArtifactWithVersions(artifact({ id: 'a1' }), [version(v)])
const kept = await db.listArtifactVersions('a1')
expect(kept).toHaveLength(db.MAX_VERSIONS_PER_ARTIFACT)
// Newest first, and the pruned tail is the numerically — not lexicographically —
// oldest, which is what separates v9 from v10 surviving.
expect(kept[0].version).toBe(total)
expect(kept.at(-1)?.version).toBe(total - db.MAX_VERSIONS_PER_ARTIFACT + 1)
})
it('keeps fewer versions of a large artifact, but never fewer than the minimum', async () => {
// Big enough that the char budget, not the count, decides — a plain count cap would
// let one document's history run to several MB.
const big = 'x'.repeat(db.MAX_VERSION_CHARS_PER_ARTIFACT / 4)
for (let v = 1; v <= 8; v++)
await db.putArtifactWithVersions(artifact({ id: 'a1' }), [{ ...version(v), content: big }])
const kept = await db.listArtifactVersions('a1')
expect(kept).toHaveLength(4)
expect(kept[0].version).toBe(8)
// A single snapshot larger than the whole budget still leaves a usable history.
const huge = 'x'.repeat(db.MAX_VERSION_CHARS_PER_ARTIFACT * 2)
await db.putArtifactWithVersions(artifact({ id: 'a1' }), [{ ...version(9), content: huge }])
expect(await db.listArtifactVersions('a1')).toHaveLength(db.MIN_VERSIONS_PER_ARTIFACT)
})
it('deleting an artifact, or a whole session, drops the versions with it', async () => {
await db.putArtifact(artifact({ id: 'a1', sessionId: 's1' }))
await db.putArtifact(artifact({ id: 'a2', sessionId: 's1' }))
await db.putArtifactWithVersions(artifact({ id: 'a1', sessionId: 's1' }), [version(1)])
await db.putArtifactWithVersions(artifact({ id: 'a2', sessionId: 's1' }), [version(1, 'a2')])
await db.deleteArtifact('a1')
expect(await db.listArtifactVersions('a1')).toEqual([])
expect(await db.listArtifactVersions('a2')).toHaveLength(1)
await db.deleteArtifactsForSession('s1')
expect(await db.listArtifactVersions('a2')).toEqual([])
})
it('isolates artifacts between users on the same browser', async () => {
await db.putArtifact(artifact({ id: 'a', sessionId: 's1' }))
// A different user sees an empty, separate database...
@@ -1,6 +1,6 @@
// Scoped by sessionId (fixed for the session's life), not chatId: a session follows its
// active chat's rotation, so chatId-keying would drop artifacts on each new conversation.
import { type DBSchema as IDBSchema } from 'idb'
import { type DBSchema as IDBSchema, type IDBPObjectStore } from 'idb'
import { userScopedDb } from '$lib/userScopedDb'
export type ArtifactKind = 'md' | 'html'
@@ -14,6 +14,48 @@ export interface PersistedArtifact {
content: string
createdAt: number
updatedAt: number
/** Absent on artifacts written before history existed — read it through currentVersion(). */
version?: number
}
/** A content snapshot taken every time an artifact's content changes. */
export interface ArtifactVersion {
/** versionKey(artifactId, version) — the store's keyPath. */
key: string
artifactId: string
version: number
name: string
content: string
savedAt: number
/** What this edit changed, in the editor's words. Absent on a first version. */
note?: string
}
/** Oldest snapshots past this are dropped: history is bounded, IndexedDB quota is not. */
export const MAX_VERSIONS_PER_ARTIFACT = 20
/**
* A count alone does not bound storage — twenty snapshots of a max-size artifact would be
* ~5 MB of history for one document — so a large artifact keeps proportionally fewer.
* Sized from the incoming snapshot rather than from the whole history, which would mean
* deserializing every stored version's content on each write just to total it up.
*/
export const MAX_VERSION_CHARS_PER_ARTIFACT = 1024 * 1024
/** However large the artifact, keep enough history for the picker to be worth opening. */
export const MIN_VERSIONS_PER_ARTIFACT = 3
function versionsToKeep(chars: number): number {
const affordable = Math.floor(MAX_VERSION_CHARS_PER_ARTIFACT / Math.max(1, chars))
return Math.min(MAX_VERSIONS_PER_ARTIFACT, Math.max(MIN_VERSIONS_PER_ARTIFACT, affordable))
}
export function currentVersion(a: Pick<PersistedArtifact, 'version'>): number {
return a.version ?? 1
}
export function versionKey(artifactId: string, version: number): string {
return `${artifactId}:${version}`
}
export function artifactFilename(a: Pick<PersistedArtifact, 'name' | 'kind'>): string {
@@ -30,15 +72,28 @@ interface ArtifactsSchema extends IDBSchema {
value: PersistedArtifact
indexes: { 'by-session': string }
}
versions: {
key: string
value: ArtifactVersion
indexes: { 'by-artifact': string }
}
}
// User-scoped like the chat-history store these are keyed against: no cross-user
// co-residency on a shared browser.
const dbh = userScopedDb<ArtifactsSchema>('copilot-artifacts', {
version: 1,
version: 2,
// Runs for a fresh database and for the v1 upgrade alike, so create each store only
// when it is missing.
upgrade(db) {
const store = db.createObjectStore('items', { keyPath: 'id' })
store.createIndex('by-session', 'sessionId')
if (!db.objectStoreNames.contains('items')) {
const store = db.createObjectStore('items', { keyPath: 'id' })
store.createIndex('by-session', 'sessionId')
}
if (!db.objectStoreNames.contains('versions')) {
const store = db.createObjectStore('versions', { keyPath: 'key' })
store.createIndex('by-artifact', 'artifactId')
}
}
})
@@ -80,11 +135,88 @@ export async function listArtifactsForSession(sessionId: string): Promise<Persis
}
}
/**
* Write an artifact and the snapshots that edit produced in one transaction.
*
* Never as two writes: a row stamped version N whose snapshot is missing still *reads*
* as complete, because listVersions synthesizes N from the row itself — until the next
* edit overwrites that row, at which point N's content is gone and the history has a
* hole nothing can back-fill.
*/
export async function putArtifactWithVersions(
artifact: PersistedArtifact,
snapshots: ArtifactVersion[]
): Promise<void> {
const db = await getDB()
if (!db) return
try {
const tx = db.transaction(['items', 'versions'], 'readwrite')
const versions = tx.objectStore('versions')
await tx.objectStore('items').put(artifact)
for (const entry of snapshots) await versions.put(entry)
const newest = snapshots.at(-1)
if (newest) await pruneVersionsIn(versions, artifact.id, newest.content.length)
await tx.done
} catch (err) {
// A rejected write (most likely QuotaExceededError) leaves the artifact usable for the
// session but unpersisted — degrade like the reads rather than throwing at the caller.
console.error('Could not persist artifact', err)
}
}
async function pruneVersionsIn(
store: VersionsStore,
artifactId: string,
newestChars: number
): Promise<void> {
const keys = await store.index('by-artifact').getAllKeys(artifactId)
const keep = versionsToKeep(newestChars)
if (keys.length <= keep) return
// Keys sort lexicographically, which puts ":10" before ":2" — order by the parsed
// number so pruning drops the genuinely oldest snapshots.
const oldest = keys.sort((a, b) => versionOf(a) - versionOf(b)).slice(0, keys.length - keep)
for (const key of oldest) await store.delete(key)
}
function versionOf(key: string): number {
return Number(key.slice(key.lastIndexOf(':') + 1))
}
/** The artifact's snapshots, newest first. */
export async function listArtifactVersions(artifactId: string): Promise<ArtifactVersion[]> {
const db = await getDB()
if (!db) return []
try {
const items = await db.getAllFromIndex('versions', 'by-artifact', artifactId)
return items.sort((a, b) => b.version - a.version)
} catch (err) {
console.error('Could not read artifact versions', err)
return []
}
}
export async function getArtifactVersion(
artifactId: string,
version: number
): Promise<ArtifactVersion | undefined> {
const db = await getDB()
if (!db) return undefined
try {
return await db.get('versions', versionKey(artifactId, version))
} catch (err) {
console.error('Could not read artifact version', err)
return undefined
}
}
export async function deleteArtifact(id: string): Promise<void> {
const db = await getDB()
if (!db) return
try {
await db.delete('items', id)
const tx = db.transaction(['items', 'versions'], 'readwrite')
await tx.objectStore('items').delete(id)
await deleteVersionsIn(tx.objectStore('versions'), id)
await tx.done
} catch (err) {
console.error('Could not delete artifact', err)
}
@@ -94,15 +226,31 @@ export async function deleteArtifactsForSession(sessionId: string): Promise<void
const db = await getDB()
if (!db) return
try {
const tx = db.transaction('items', 'readwrite')
const index = tx.store.index('by-session')
let cursor = await index.openCursor(sessionId)
while (cursor) {
await cursor.delete()
cursor = await cursor.continue()
const tx = db.transaction(['items', 'versions'], 'readwrite')
const items = tx.objectStore('items')
const versions = tx.objectStore('versions')
// Collect the ids up front rather than deleting from a live cursor: interleaving
// another store's requests between continue() calls is what breaks a cursor walk.
const ids = await items.index('by-session').getAllKeys(sessionId)
for (const id of ids) {
await items.delete(id)
await deleteVersionsIn(versions, id)
}
await tx.done
} catch (err) {
console.error('Could not delete artifacts for session', err)
}
}
type VersionsStore = IDBPObjectStore<
ArtifactsSchema,
('items' | 'versions')[],
'versions',
'readwrite'
>
async function deleteVersionsIn(store: VersionsStore, artifactId: string): Promise<void> {
for (const key of await store.index('by-artifact').getAllKeys(artifactId)) {
await store.delete(key)
}
}
@@ -1,10 +1,15 @@
import { randomUUID } from '$lib/utils/uuid'
import {
currentVersion,
deleteArtifact,
getArtifact,
getArtifactVersion,
listArtifactVersions,
listArtifactsForSession,
putArtifact,
putArtifactWithVersions,
versionKey,
type ArtifactKind,
type ArtifactVersion,
type PersistedArtifact
} from './artifactsDB'
@@ -18,6 +23,8 @@ export interface CreateArtifactInput {
export interface UpdateArtifactInput {
name?: string
content?: string
/** Recorded on the snapshot this update produces; ignored if content is unchanged. */
note?: string
}
/**
@@ -87,9 +94,10 @@ export class SessionArtifactsStore {
name: input.name,
content: input.content,
createdAt: now,
updatedAt: now
updatedAt: now,
version: 1
}
await putArtifact(artifact)
await putArtifactWithVersions(artifact, [snapshotOf(artifact, 1)])
if (sessionId === this.#sessionId) {
this.#applyWrite(sortByUpdatedDesc([artifact, ...this.artifacts]))
}
@@ -108,19 +116,69 @@ export class SessionArtifactsStore {
const existing = this.artifacts.find((a) => a.id === id) ?? (await getArtifact(id))
if (!existing) return undefined
if (opts?.sessionId !== undefined && existing.sessionId !== opts.sessionId) return undefined
// Only a content change earns a version: a rename or an identical rewrite would
// otherwise fill the picker with entries the user cannot tell apart.
const contentChanged = input.content !== undefined && input.content !== existing.content
const version = currentVersion(existing) + (contentChanged ? 1 : 0)
const updated: PersistedArtifact = {
...existing,
name: input.name ?? existing.name,
content: input.content ?? existing.content,
updatedAt: Date.now()
updatedAt: Date.now(),
version
}
await putArtifact(updated)
const snapshots: ArtifactVersion[] = []
// An artifact written before history existed has no snapshot of its current content,
// so capture one on *any* update, not just a content change: this write stamps
// `version`, and nothing afterwards would recognise it as pre-history.
if (existing.version === undefined) {
snapshots.push(snapshotOf(existing, currentVersion(existing)))
}
if (contentChanged) {
snapshots.push(snapshotOf(updated, version, input.note))
}
await putArtifactWithVersions(updated, snapshots)
if (updated.sessionId === this.#sessionId) {
this.#applyWrite(sortByUpdatedDesc(this.artifacts.map((a) => (a.id === id ? updated : a))))
}
return updated
}
/**
* Every snapshot of an artifact, newest first. Empty if `id` is unknown, or if
* `opts.sessionId` is given and the artifact belongs to a different session — snapshots
* carry the document's full text, so this scopes like update() rather than trusting
* every caller to check first.
*/
async listVersions(id: string, opts?: { sessionId?: string }): Promise<ArtifactVersion[]> {
const artifact = await this.get(id)
if (opts?.sessionId !== undefined && artifact?.sessionId !== opts.sessionId) return []
const stored = await listArtifactVersions(id)
if (!artifact) return stored
const version = currentVersion(artifact)
// An artifact written before history existed has no snapshot of its current
// content, so stand one in — the picker must never show a document as absent
// from its own history.
if (!stored.some((v) => v.version === version)) {
return [snapshotOf(artifact, version), ...stored]
}
return stored
}
/** One snapshot; scoped by `opts.sessionId` like listVersions when it is given. */
async getVersion(
id: string,
version: number,
opts?: { sessionId?: string }
): Promise<ArtifactVersion | undefined> {
const artifact = await this.get(id)
if (opts?.sessionId !== undefined && artifact?.sessionId !== opts.sessionId) return undefined
const stored = await getArtifactVersion(id, version)
if (stored) return stored
if (artifact && currentVersion(artifact) === version) return snapshotOf(artifact, version)
return undefined
}
async remove(id: string): Promise<void> {
await deleteArtifact(id)
// Guard on presence: a no-op remove must not invalidate an in-flight load.
@@ -129,6 +187,18 @@ export class SessionArtifactsStore {
}
}
function snapshotOf(a: PersistedArtifact, version: number, note?: string): ArtifactVersion {
return {
key: versionKey(a.id, version),
artifactId: a.id,
version,
name: a.name,
content: a.content,
savedAt: a.updatedAt,
note
}
}
function sortByUpdatedDesc(items: PersistedArtifact[]): PersistedArtifact[] {
return [...items].sort((a, b) => b.updatedAt - a.updatedAt)
}
@@ -191,6 +191,70 @@ describe('SessionArtifactsStore', () => {
expect((await store.update(created.id, { content: '' }))?.content).toBe('')
})
it('snapshots a version per content change, and none for a rename', async () => {
await store.setSession('s1')
const created = await store.create('s1', { name: 'Plan', content: 'v1' })
await store.update(created.id, { content: 'v2', note: 'Added a rollback step' })
// Neither a rename nor a rewrite to the identical content is a new version.
await store.update(created.id, { name: 'Renamed', note: 'ignored' })
await store.update(created.id, { content: 'v2', note: 'ignored' })
const versions = await store.listVersions(created.id)
expect(versions.map((v) => [v.version, v.content, v.note])).toEqual([
[2, 'v2', 'Added a rollback step'],
[1, 'v1', undefined]
])
expect((await store.get(created.id))?.version).toBe(2)
expect((await store.getVersion(created.id, 1))?.content).toBe('v1')
})
it('persists a row and the snapshots it produced in one write', async () => {
await store.setSession('s1')
const created = await store.create('s1', { name: 'Doc', content: 'c1' })
const spy = vi.spyOn(dbMod, 'putArtifactWithVersions')
await store.update(created.id, { content: 'c2', note: 'second' })
// Split into two writes, a stamped version can outlive the snapshot that failed to
// land beside it. That state still reads as complete, because listVersions
// synthesizes the current version from the row — right up until the next edit
// overwrites the row, which is the only copy of that content left.
expect(spy).toHaveBeenCalledTimes(1)
expect(spy.mock.calls[0][1].map((v) => v.version)).toEqual([2])
spy.mockRestore()
const row = await dbMod.getArtifact(created.id)
const stored = await dbMod.listArtifactVersions(created.id)
expect(stored.some((v) => v.version === row!.version)).toBe(true)
})
it('keeps a legacy v1 when a rename lands before the first content edit', async () => {
await dbMod.putArtifact(mk({ id: 'legacy', content: 'original' }))
await store.setSession('s1')
// The rename stamps `version`, after which nothing else would recognise this as a
// pre-history artifact — so its v1 has to be captured here or it is lost for good.
await store.update('legacy', { name: 'Renamed' })
await store.update('legacy', { content: 'edited', note: 'Rewrote it' })
expect((await store.listVersions('legacy')).map((v) => [v.version, v.content])).toEqual([
[2, 'edited'],
[1, 'original']
])
})
it('reads an artifact stored before history existed as its own version 1', async () => {
await dbMod.putArtifact(mk({ id: 'legacy', content: 'only' }))
await store.setSession('s1')
expect(await store.listVersions('legacy')).toMatchObject([{ version: 1, content: 'only' }])
expect((await store.getVersion('legacy', 1))?.content).toBe('only')
// Its first edit still lands as v2, so version numbers stay monotonic.
await store.update('legacy', { content: 'edited' })
expect((await store.listVersions('legacy')).map((v) => v.version)).toEqual([2, 1])
})
it('remove deletes from the DB and the loaded list', async () => {
await store.setSession('s1')
const created = await store.create('s1', { name: 'Plan', content: 'x' })
@@ -1229,7 +1229,7 @@ ${
: `- When the user raises how a raw app looks (something is off, or they want the design or layout improved) and their description alone isn't specific enough to pinpoint the problem, ask them to paste or drop a screenshot of it into the chat before changing anything.`
}
- open_page opens its page as a tab in the side-panel preview next to the chat the only way to show one of these pages there (open_preview only handles editable items). Changing filters on a page already open updates that same tab; only pass new_tab when the user explicitly asks for a separate tab.
- create_artifact saves a persistent markdown document (a planning doc, design write-up, spec, or other longer structured output) shown in the session preview panel. Prefer it over a long inline reply for content the user will revisit; keep brief answers inline. To revise one, call list_artifacts then read_artifact for the current content, then update_artifact to overwrite it never create a second artifact for the same document.`
- create_artifact saves a persistent markdown document (a planning doc, design write-up, spec, or other longer structured output) shown in the session preview panel. Prefer it over a long inline reply for content the user will revisit; keep brief answers inline. To revise one, call list_artifacts then read_artifact for the current content, then update_artifact to overwrite it never create a second artifact for the same document. Each content change is saved as a version, keeping the most recent ones: use list_artifact_versions and read_artifact's version argument to recover earlier wording the user asks to go back to, rather than rewriting it from memory. list_artifact_versions is the source of truth for what is still available do not assume a version that is not listed.`
: ''
}
@@ -2954,9 +2954,7 @@ export const globalTools: Tool<{}>[] = [
const parsed = writeScheduleSchema.parse(merged)
const dropped = droppedOptionKeys(merged, parsed)
if (dropped.length) {
throw new Error(
describeDroppedScheduleOptions(dropped)
)
throw new Error(describeDroppedScheduleOptions(dropped))
}
return writeScheduleDraft(parsed, ctx)
}
@@ -3649,7 +3647,8 @@ export const SESSION_PREVIEW_TOOL_NAMES = new Set([
'create_artifact',
'update_artifact',
'list_artifacts',
'read_artifact'
'read_artifact',
'list_artifact_versions'
])
/**
@@ -245,8 +245,8 @@
class="absolute inset-0 flex flex-col min-h-0 bg-surface {visibility}"
aria-hidden={!active}
>
{#if artifact}
<ArtifactViewer {artifact} />
{#if artifact && runtime}
<ArtifactViewer {artifact} store={runtime.manager.artifacts} />
{:else if !runtime?.manager.artifacts.loading}
<div class="p-4 text-sm text-tertiary">This artifact is no longer available.</div>
{/if}
+108
View File
@@ -57,6 +57,114 @@ describe('userScopedDb', () => {
expect((await dbA2!.getAll('items')).map((x) => x.id)).toEqual(['i1'])
})
it('yields its connection so another tab can upgrade the schema', async () => {
userStore.set(asUser('a@x.com'))
const held = userScopedDb<TestSchema>('t', { version: 1, upgrade })
expect(await held.whenReady()).toBeDefined()
// Second tab, higher version. Without the blocking handler the open never settles
// and this await hangs rather than failing.
const upgrading = userScopedDb<TestSchema>('t', { version: 2, upgrade })
expect((await upgrading.whenReady())?.version).toBe(2)
// The tab that yielded is still on the old schema, so its reopen cannot succeed —
// it degrades to in-memory like any failed open, rather than hanging or throwing.
expect(await held.whenReady()).toBeUndefined()
})
it('gives up on an upgrade an uncooperative connection is blocking', async () => {
userStore.set(asUser('a@x.com'))
// A tab running a build older than the blocking handler: it holds v1 open and never
// hears versionchange, so nothing this side can do will make it let go.
const legacy = await openDB<TestSchema>('t::a@x.com', 1, { upgrade })
const dbh = userScopedDb<TestSchema>('t', { version: 2, upgrade, openGraceMs: 20 })
// Bounded, so callers degrade to in-memory instead of awaiting it forever.
expect(await dbh.whenReady()).toBeUndefined()
// Every later call gives up too, rather than reopening behind the parked request.
expect(await dbh.whenReady()).toBeUndefined()
expect(await dbh.whenReady()).toBeUndefined()
// Giving up is not permanent: once the blocker goes, the next call gets the DB.
legacy.close()
await vi.waitFor(async () => expect((await dbh.whenReady())?.version).toBe(2))
})
it('gives up on an open that is queued behind another and so hears nothing', async () => {
userStore.set(asUser('a@x.com'))
// What a second upgrading opener sees while a first one sits blocked: the browser
// processes a database's opens in order, so this request waits its turn without
// reaching `blocked` — or any other callback — of its own.
let arrive!: (db: IDBPDatabase<TestSchema>) => void
const queuedOpen = (() =>
new Promise((resolve) => (arrive = resolve as never))) as unknown as typeof openDB
const dbh = userScopedDb<TestSchema>('t', {
version: 2,
upgrade,
openGraceMs: 20,
openDB: queuedOpen
})
expect(await dbh.whenReady()).toBeUndefined()
// Giving up did not cancel it — nothing can. When its turn comes it is adopted.
const real = await openDB<TestSchema>('t::a@x.com', 2, { upgrade })
arrive(real)
await vi.waitFor(async () => expect(await dbh.whenReady()).toBe(real))
real.close()
})
it('keeps degrading, not hanging, across a user switch away and back', async () => {
userStore.set(asUser('a@x.com'))
const legacy = await openDB<TestSchema>('t::a@x.com', 1, { upgrade })
const dbh = userScopedDb<TestSchema>('t', { version: 2, upgrade, openGraceMs: 20 })
expect(await dbh.whenReady()).toBeUndefined()
// B is a different physical database, so it opens normally.
userStore.set(asUser('b@y.com'))
expect(await dbh.whenReady()).toBeDefined()
// Back to A, whose open is still parked: reopening here would hang, not degrade.
userStore.set(asUser('a@x.com'))
expect(await dbh.whenReady()).toBeUndefined()
legacy.close()
await vi.waitFor(async () => expect((await dbh.whenReady())?.version).toBe(2))
})
it('issues one open per database however often callers give up on it', async () => {
userStore.set(asUser('a@x.com'))
const legacy = await openDB<TestSchema>('t::a@x.com', 1, { upgrade })
const opens: string[] = []
const countingOpen = ((name: string, version: number, cbs: unknown) => {
opens.push(name)
return openDB(name as never, version, cbs as never)
}) as unknown as typeof openDB
const dbh = userScopedDb<TestSchema>('t', {
version: 2,
upgrade,
openGraceMs: 20,
openDB: countingOpen
})
// Give up, drop the handle, switch away and back — every path that used to start over.
expect(await dbh.whenReady()).toBeUndefined()
expect(await dbh.whenReady()).toBeUndefined()
dbh.close()
expect(await dbh.whenReady()).toBeUndefined()
userStore.set(asUser('b@y.com'))
await dbh.whenReady()
userStore.set(asUser('a@x.com'))
expect(await dbh.whenReady()).toBeUndefined()
expect(opens.filter((n) => n === 't::a@x.com')).toHaveLength(1)
legacy.close()
})
it('runs migrate once per scoped name and claims+deletes the legacy DB', async () => {
// Seed a legacy (un-namespaced) DB, mirroring the chat-history pattern.
const legacy = await openDB<TestSchema>('t', 1, { upgrade })
+151 -24
View File
@@ -10,7 +10,9 @@ import { scopedKey } from '$lib/userScopedStorage'
// and transparently closes + reopens when the email changes, so the handle
// self-heals on user switch WITHOUT subscribing to onUserChange. That matters
// because there is one handle per HistoryManager instance (singleton + one per
// session runtime) — a per-instance subscription would leak callbacks.
// session runtime) — a per-instance subscription would leak callbacks. The email
// can change back and forth within one page: logout is a client-side goto() that
// clears userStore, not a reload.
export interface UserScopedDbMigrateDeps {
openDB: typeof idbOpenDB
@@ -27,40 +29,104 @@ export interface UserScopedDbOptions<Schema extends DBSchema> {
// Injectable for tests (defaults to the real idb implementations).
openDB?: typeof idbOpenDB
deleteDB?: typeof idbDeleteDB
// How long an open waits to settle before giving up. Injectable for tests, which
// cannot afford the real grace period.
openGraceMs?: number
}
export interface UserScopedDb<Schema extends DBSchema> {
// Resolves to the open DB for the current user, or undefined when no user is
// logged in yet or the open failed (degrade to in-memory; never rejects).
// Resolves to the open DB for the current user, or undefined when no user is logged
// in yet, the open failed, or another connection is holding up a schema upgrade —
// directly, or by sitting ahead of this open in the browser's queue for the database
// (degrade to in-memory; never rejects, never hangs).
whenReady(): Promise<IDBPDatabase<Schema> | undefined>
close(): void
}
// How long an open waits to settle before the opener gives up and degrades to in-memory.
// Only a connection that ignores `versionchange`, or an open queued behind one, takes this long.
const OPEN_GRACE_MS = 5000
export function userScopedDb<Schema extends DBSchema>(
baseName: string,
opts: UserScopedDbOptions<Schema>
): UserScopedDb<Schema> {
const openDB = opts.openDB ?? idbOpenDB
const deleteDB = opts.deleteDB ?? idbDeleteDB
const openGraceMs = opts.openGraceMs ?? OPEN_GRACE_MS
const migratedNames = new Set<string>()
let openName: string | undefined
let openPromise: Promise<IDBPDatabase<Schema> | undefined> | undefined
function closeCurrent() {
const prev = openPromise
if (prev) void prev.then((db) => db?.close()).catch(() => {})
openPromise = undefined
openName = undefined
/**
* One open request for one scoped database, plus everything learned about it since.
*
* An open cannot be cancelled and the browser processes a database's opens in order, so
* a replacement issued while one is pending waits behind it, never reaching its own
* `blocked` callback. An attempt is therefore replaced only once it has settled; losing
* interest in it is recorded on these fields instead.
*/
interface Attempt {
/**
* The open with the keep-or-discard decision already applied, so a handle we drop is
* reported as undefined rather than handed over and closed behind the caller's back.
*/
outcome: Promise<IDBPDatabase<Schema> | undefined>
/** Set when `outcome` settles. Its presence, not its `db`, is what "settled" means. */
settled?: { db: IDBPDatabase<Schema> | undefined }
/** Resolves undefined when the open has taken too long to settle. */
gaveUp: Promise<undefined>
timedOut: boolean
/** Handle yielded to another tab's upgrade, or force-closed by the browser. */
dead: boolean
/** Released before it arrived: close it on arrival rather than hand it out. */
unwanted: boolean
}
async function open(name: string): Promise<IDBPDatabase<Schema> | undefined> {
const attempts = new Map<string, Attempt>()
let currentName: string | undefined
async function open(
name: string,
attempt: Attempt,
onTooLong: () => void
): Promise<IDBPDatabase<Schema> | undefined> {
// Armed before the request is issued rather than from `blocked`, because an open
// queued behind another one is told nothing at all: it waits its turn in the
// browser's per-database queue, and a callback that never fires cannot bound it.
let graceTimer: ReturnType<typeof setTimeout> | undefined = setTimeout(onTooLong, openGraceMs)
const stopWaiting = () => {
if (graceTimer) clearTimeout(graceTimer)
graceTimer = undefined
}
try {
let handle: IDBPDatabase<Schema> | undefined
const db = await openDB<Schema>(name, opts.version, {
upgrade(database) {
// The version-change transaction is ours: nothing is queued ahead of this
// open any more, and what remains is our own upgrade running.
stopWaiting()
opts.upgrade(database)
},
// Another tab is opening this database at a higher version, which our open
// connection would block indefinitely. Let go so their upgrade lands; this
// attempt is replaced by a fresh open once it has settled.
blocking() {
handle?.close()
attempt.dead = true
},
blocked(currentVersion, blockedVersion) {
console.warn(
`userScopedDb(${baseName}): upgrade ${currentVersion}${blockedVersion} waiting on another connection`
)
},
// The browser force-closed the connection (site data cleared, database
// dropped from devtools). Every request on this handle would now throw.
terminated() {
attempt.dead = true
}
})
handle = db
// The handle is in hand; a slow migrate is not a hung open.
stopWaiting()
if (opts.migrate && !migratedNames.has(name)) {
migratedNames.add(name)
try {
@@ -74,30 +140,91 @@ export function userScopedDb<Schema extends DBSchema>(
}
return db
} catch (e) {
// Failed open (blocked / corrupt / private-browsing): degrade to
// in-memory by resolving undefined (callers no-op their writes). The
// undefined is cached for this name so we don't hammer the open.
// Failed open (corrupt / private-browsing / a newer version already stored):
// degrade to in-memory by resolving undefined (callers no-op their writes).
console.error(`userScopedDb(${baseName}): could not open database`, e)
return undefined
} finally {
stopWaiting()
}
}
function start(name: string): Attempt {
let giveUp!: () => void
const attempt: Attempt = {
// Assigned immediately below; open() needs the attempt to report back onto.
outcome: undefined as unknown as Promise<IDBPDatabase<Schema> | undefined>,
gaveUp: new Promise<undefined>((resolve) => (giveUp = () => resolve(undefined))),
timedOut: false,
dead: false,
unwanted: false
}
// The timer is owned by this attempt, so it can only ever time out its own request.
attempt.outcome = open(name, attempt, () => {
attempt.timedOut = true
giveUp()
}).then((db) => {
// Arrived after we stopped wanting it, or after we yielded the handle: holding it
// open would block the next tab's upgrade for no one's benefit, and by now it may
// belong to a user who is no longer logged in.
const discard = attempt.unwanted || attempt.dead
if (discard) {
db?.close()
attempt.dead = true
}
attempt.settled = { db: discard ? undefined : db }
attempt.timedOut = false
return attempt.settled.db
})
attempts.set(name, attempt)
return attempt
}
/** Stop serving `name`; a still-pending attempt is only marked, per the rule on Attempt. */
function release(name: string | undefined) {
const attempt = name ? attempts.get(name) : undefined
if (!attempt) return
if (attempt.settled) {
const { db } = attempt.settled
attempts.delete(name!)
// A microtask later, never synchronously: callers hold this handle across several
// awaits (hydrate, write-behind), and closing under them turns a routine user
// switch into InvalidStateError. close() then waits on what they started.
if (db) void Promise.resolve().then(() => db.close())
return
}
attempt.unwanted = true
}
return {
whenReady() {
const name = scopedKey(baseName)
if (!name) {
closeCurrent()
return Promise.resolve(undefined)
if (name !== currentName) {
release(currentName)
currentName = name
}
if (name !== openName) {
closeCurrent()
openName = name
openPromise = open(name)
if (!name) return Promise.resolve(undefined)
let attempt = attempts.get(name)
// The one place an attempt is replaced, and only once settled — see Attempt.
if (attempt?.settled && attempt.dead) {
attempts.delete(name)
attempt = undefined
}
return openPromise!
attempt ??= start(name)
// Wanting it again cancels a release that has not landed yet.
attempt.unwanted = false
if (attempt.settled) return Promise.resolve(attempt.settled.db)
// Still in flight. `dead` means the handle is spoken for and `timedOut` that we
// stopped waiting — either way, degrade now rather than block the caller; both
// resolve themselves when the request finally settles.
if (attempt.dead || attempt.timedOut) return Promise.resolve(undefined)
return Promise.race([attempt.outcome, attempt.gaveUp])
},
close() {
closeCurrent()
release(currentName)
currentName = undefined
}
}
}