Skip to content

fix(workspace): key the transcript snapshot guard by the stream directory - #825

Merged
Ishaan Gangwani (ishaan1124) merged 2 commits into
synthetic-sciences:mainfrom
aniruddhaadak80:fix/transcript-hydration-key
Sep 29, 2026
Merged

Ishaan Gangwani (ishaan1124) merged 2 commits into
synthetic-sciences:mainfrom
aniruddhaadak80:fix/transcript-hydration-key

Conversation

@aniruddhaadak80

@aniruddhaadak80 ANIRUDDHA ADAK (aniruddhaadak80) commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Entering a session could rewind a running conversation's text and resurrect messages the event stream had already deleted. The guard meant to prevent that read its change ledger under the wrong key, so it was permanently inert for any session opened with a projectID. This threads the event stream's physical directory through to the ledger lookup.

Root cause

loadMessages in frontend/workspace/src/context/sync.tsx guards against an in-flight HTTP snapshot overwriting newer streamed state:

const startedAt = globalSync.transcript.revision(input.directory, input.sessionID)
await retry(() => input.client.session.messages({ ... }))
  .then((messages) => {
    ...
    const changes = globalSync.transcript.changesSince(input.directory, input.sessionID, startedAt)

input.directory is the route scope. Both call sites set it from sdk.scope, and sdk.tsx:17 defines that as:

const scope = createMemo(() => props.scope ?? props.projectID ?? props.directory)

But the ledger is written under the physical directory the event stream publishes. global-sync.tsx:1065 takes const directory = e.name off the SSE event and markTranscriptMutation(directory, ...) keys the ledger with it, and transcriptKey is a raw join with no folding:

const transcriptKey = (directory: string, sessionID: string) => `${directory}\0${sessionID}`

So the read key and the write key live in different namespaces whenever a projectID is present — the normal way a project session is opened. revision is permanently 0 and changesSince permanently returns empty sets, so the guard never sees a change. With empty preferCached / removed, mergeHydratedMessages then lets the older HTTP snapshot overwrite the newer streamed state and resurrect messages SSE deleted.

The reconnect path in the same file (global-sync.tsx:994,997) already uses the same physical key as markTranscriptMutation and is correct — only the hydration call site was mis-keyed. sync.tsx:23 uses globalSync.child(sdk.directory, …), so events do reach the store correctly; it was purely the ledger lookup.

Changes

  • frontend/workspace/src/context/sync.tsx
    • loadMessages takes an additional streamDirectory input and uses it for both revision(...) and changesSince(...), leaving input.directory to key the inflight map and the store as before.
    • Both call sites (initial hydration and the incremental load path) pass streamDirectory: sdk.directory.
  • frontend/workspace/src/context/transcript-hydration.test.ts (new)
    • Mounts the real provider stack against a fake server that holds GET /session/{id}/message open, pushes a message.part.updated frame while the request is in flight, then releases an older snapshot. Asserts the streamed text survives.
  • CHANGELOG.md
    • Entry under Unreleased → Fixed, as the repo requires for user-visible behaviour changes.

Testing

Confirmed the regression test fails without the fix. Restoring the pre-fix sync.tsx and keeping the new test:

$ git checkout HEAD~1 -- frontend/workspace/src/context/sync.tsx
$ bun test --timeout 30000 src/context/transcript-hydration.test.ts
src\context\transcript-hydration.test.ts:
227 |
228 |   fake.release([{ info: { id: "msg_1", sessionID, role: "assistant" }, parts: [text("the older snapshot text")] }])
229 |   await entering
230 |
231 |   const parts = (view.store()?.part.msg_1 ?? []) as { text?: string }[]
232 |   expect(parts.map((part) => part.text)).toEqual(["the streamed, newer text"])
error: expect(received).toEqual(expected)
  [
-   "the streamed, newer text",
+   "the older snapshot text",
  ]
(fail) a snapshot that predates a streamed part does not roll the transcript back [437.23ms]
 0 pass
 1 fail

With the fix restored:

$ git checkout HEAD -- frontend/workspace/src/context/sync.tsx
$ bun test --timeout 30000 src/context/transcript-hydration.test.ts
src\context\transcript-hydration.test.ts:
(pass) a snapshot that predates a streamed part does not roll the transcript back [121.42ms]
 1 pass
 0 fail

The neighbouring suite in the touched area passes in isolation:

$ bun test --timeout 15000 ./src/context/global-sync-bootstrap.test.ts
(pass) project bootstrap > a reconnect clears the working state of a session that finished while the stream was down [95.11ms]
(pass) project bootstrap > transcript mutations arriving during a snapshot request are reported for the merge [123.11ms]
 9 pass
 0 fail

Full workspace suite (bun run test:workspace, 188 files):

 1163 pass
 8 fail
 7 errors
Ran 1171 tests across 188 files. [866.79s]

The 8 failures are 5 s per-test timeouts that appear only under full-suite load, not regressions from this change. Four are named above (files pane ×2, project bootstrap, local notebook documents); running the bootstrap file alone gives 9 pass / 0 fail, including the two tests closest to this change. The rest are in files pane / local notebook documents / volume areas untouched by this diff, and were not investigated further — I could not get a clean full-suite baseline on this machine because the working tree also carries an unrelated in-progress rebase on another branch.

Fixes #824

@vercel

vercel Bot commented Sep 29, 2026

Copy link
Copy Markdown

ANIRUDDHA ADAK (@aniruddhaadak80) is attempting to deploy a commit to the InkVell Team on Vercel.

A member of the Team first needs to authorize it.

…tory

loadMessages read the SSE change ledger with the route scope, but the
ledger is written under the physical directory the event stream
publishes. A project is opened with a projectID, so the two differ and
the guard never saw a change, letting a pre-stream snapshot overwrite
live text. Thread the stream directory alongside the store key.
Prettier requires a blank line between a markdown heading and the list that follows it, and the entry landed directly under `### Fixed`. The Format job failed on this file; the source change and its test are unaffected.

Refs synthetic-sciences#824
@ishaan1124
Ishaan Gangwani (ishaan1124) merged commit b5dec20 into synthetic-sciences:main Sep 29, 2026
6 of 7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Entering a session can rewind a running conversation's text and resurrect deleted messages

2 participants