Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 77 additions & 7 deletions frontend/src/core/ai/__tests__/staged-cells.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -419,7 +419,7 @@ describe("onStream", () => {
expect(mockUpdateCellEditor).not.toHaveBeenCalled();
});

it("should create cells when text-delta is received and a stream has been created", () => {
it("should buffer text without fences and create cell on stop", () => {
const { result } = renderHook(() => useStagedCells(store));
result.current.onStream({ type: "text-start", id: "test-id" });

Expand All @@ -433,6 +433,12 @@ describe("onStream", () => {
delta: "some code",
});

// Cell should NOT be created yet — waiting for a fence or stream end
expect(mockCreateNewCell).not.toHaveBeenCalled();

// When the stream ends, the buffered code is flushed as a cell
result.current.onStream({ type: "text-end", id: "test-id" });

expect(mockCreateNewCell).toHaveBeenCalledWith({
cellId: "__end__",
code: "some code",
Expand All @@ -441,42 +447,106 @@ describe("onStream", () => {
});
});

it("should handle delta chunks", () => {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test covered the case where the first chunk is a partial fence, which got replaced. Would we add that scenario back?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added! The new test (should buffer partial fence and create cell when fence completes) covers the split-fence scenario: first chunk has just ``````, second chunk completes the fence — no premature cell creation.

it("should not create cell from preamble when fence appears later", () => {
const { result } = renderHook(() => useStagedCells(store));
result.current.onStream({ type: "text-start", id: "test-id" });

const mockCellId = cellId("mock-cell-id");
vi.mocked(CellId.create).mockReturnValue(mockCellId);

// Preamble text without fence — should be buffered
result.current.onStream({
type: "text-delta",
id: "test-id",
delta: "``",
delta: "I'll create a fibonacci function.\n\n",
});

expect(mockCreateNewCell).not.toHaveBeenCalled();

// Now fence arrives — cell should be created with only the code
result.current.onStream({
type: "text-delta",
id: "test-id",
delta: "```python\nsome code",
});

expect(mockCreateNewCell).toHaveBeenCalledWith({
cellId: "__end__",
code: "``",
code: "some code",
before: false,
newCellId: "mock-cell-id",
});

// More code arrives — cell should be updated
result.current.onStream({
type: "text-delta",
id: "test-id",
delta: "```python\nsome code",
delta: "\nmore code\n```",
});

// Now the cell is recognized and only some code is seen
expect(vi.mocked(updateEditorCodeFromPython)).toHaveBeenCalledWith(
mockCellHandle.current.editorViewOrNull,
"some code",
"some code\nmore code",
);
});

it("should handle delta chunks with fences", () => {
const { result } = renderHook(() => useStagedCells(store));
result.current.onStream({ type: "text-start", id: "test-id" });

const mockCellId = cellId("mock-cell-id");
vi.mocked(CellId.create).mockReturnValue(mockCellId);

result.current.onStream({
type: "text-delta",
id: "test-id",
delta: "```python\nsome code",
});

expect(mockCreateNewCell).toHaveBeenCalledWith({
cellId: "__end__",
code: "some code",
before: false,
newCellId: "mock-cell-id",
});

result.current.onStream({
type: "text-delta",
id: "test-id",
delta: "\n```",
});
});

it("should buffer partial fence and create cell when fence completes", () => {
const { result } = renderHook(() => useStagedCells(store));
result.current.onStream({ type: "text-start", id: "test-id" });

const mockCellId = cellId("mock-cell-id");
vi.mocked(CellId.create).mockReturnValue(mockCellId);

// Chunk 1: partial fence (just two backticks)
result.current.onStream({
type: "text-delta",
id: "test-id",
delta: "``",
});

// Cell should NOT be created — fence is incomplete
expect(mockCreateNewCell).not.toHaveBeenCalled();

// Chunk 2: fence completes + code
result.current.onStream({
type: "text-delta",
id: "test-id",
delta: "`python\nsome code",
});

// NOW cell is created with actual code
expect(mockCreateNewCell).toHaveBeenCalledWith({
cellId: "__end__",
code: "some code",
before: false,
newCellId: "mock-cell-id",
});
});
});
20 changes: 20 additions & 0 deletions frontend/src/core/ai/staged-cells.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,15 @@ class CellCreationStream {
stream(chunk: TextDeltaChunk) {
const delta = chunk.delta;
this.buffer += delta;

// If no code fence has appeared yet and no cells have been created,
// buffer the content and wait. This prevents conversational preamble
// (e.g. "I'll create a cell that...") from becoming a Python cell.
// Once a fence appears, codeToCells will correctly extract only the code.
if (!this.buffer.includes("```") && this.createdCells.length === 0) {
return;
}

const completionCells = codeToCells(this.buffer);

// As incoming chunks are appended to the buffer,
Expand Down Expand Up @@ -282,8 +291,19 @@ class CellCreationStream {
}

stop() {
// If the stream ended with buffered content but no cells were created
// (model returned code without fences), create a cell from the buffer.
if (this.buffer.trim() && this.createdCells.length === 0) {
const completionCells = codeToCells(this.buffer);
for (const cell of completionCells) {
const newCellId = this.onCreateCell(cell.code);
this.createdCells.push({ cellId: newCellId, cell });
}
}
// Clear all state
this.buffer = "";
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

stop() says it clears all state, but it only resets buffer. createdCells (and hasMarimoImport) remain populated, which can leak state if any additional text-delta chunks arrive after text-end/finish, and also makes the comment inaccurate. Consider resetting createdCells/hasMarimoImport here (and/or nulling the stream ref after stop) so a completed stream cannot update previous cells.

Suggested change
this.buffer = "";
this.buffer = "";
this.createdCells = [];
this.hasMarimoImport = false;

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — applied in 2d784fc. CellCreationStream is single-use (new instance per text-start), so this is purely defensive, but it makes the comment accurate.

this.createdCells = [];
this.hasMarimoImport = false;
}
}

Expand Down
Loading