fix: SSE filter no longer drops content events for a different session
Bug: after switching to a new session via the SessionSelector, the
next /edit would not get a reply. Root cause: applyEvent dropped
ANY event whose sessionID didn't match the prompt's _sessionId,
including text deltas / reasoning / tool / idle. OpenCode's sessionID
extraction from event payloads is not perfectly consistent across
event types, so the prompt could end up with a stale _sessionId
that doesn't match the events coming in, and EVERY event got
filtered out -> no reply.
Fix: the cross-session filter is now strict ONLY for
permission.asked / question.asked (so the user can't accidentally
reply to another session's prompt). Content events always pass
through. The server's /events handler is the single source of
truth for session filtering (it has the URL ?session= param); the
client's filter would only ever mask the user's interaction with
their own active session.
Also: drop the cell-context auto-injection. The OpenCodeRequest
now carries only {notebookPath}. The user explicitly attaches
whatever they want via the new '📋 插入单元格内容' button
(inserts the cell source as a markdown code block into the input).
This makes the LLM context match user intent and stops the
OpenCode prompt from being polluted with stale previousCode /
traceback snapshots.
Backend
-------
- _build_request_body: now takes only the prompt, returns
parts=[{text: prompt}]. No more <previous_cell>/<traceback>/<cell>
tag wrapping.
Frontend
-------
- types.ts: CellContext collapsed to {notebookPath}. ErrorOutput /
cellId / source / previousCode / error / cellIndex / totalCells
/ language all removed.
- opencode_cell_actions.ts: extractCellContextFromCell() replaced
with extractNotebookPathFromCell(). _context field renamed to
_notebookPath. NotebookPanel / extractCellContext / context/
cell_context imports all removed.
- src/context/cell_context.ts + src/__tests__/cell_context.spec.ts:
deleted.
New feature: '📋 插入单元格内容' button
----------------------------------------
The button sits at the start of the actions row (visually
left-aligned via margin-right:auto) and appends the current cell's
source as a markdown code fence to the textarea. Caret is moved
to the end so the user can keep typing. The fence info string is
the cell's model type (falls back to a plain fence if the type
isn't a clean language identifier). No-op for empty cells.
Tests
-----
- 72 jest (was 78: -8 cell-context tests + 1 SSE filter test +
3 insert-cell tests + rewrites). 73 pytest (unchanged). Build
green.
This commit is contained in:
@@ -766,7 +766,7 @@ describe('OpenCodeCellActions', () => {
|
||||
});
|
||||
|
||||
describe('OpenCodeInlinePrompt', () => {
|
||||
it('renders a textarea, a send and a cancel button (no selector when providers null)', () => {
|
||||
it('renders a textarea, a send, a cancel, and an insert-cell-source button', () => {
|
||||
const cell = makeFakeCell('x = 1');
|
||||
const prompt = new OpenCodeInlinePrompt(cell, {
|
||||
onSubmit: jest.fn(),
|
||||
@@ -775,9 +775,76 @@ describe('OpenCodeInlinePrompt', () => {
|
||||
providers: null
|
||||
});
|
||||
expect(prompt.node.querySelector('textarea')).not.toBeNull();
|
||||
// 2 buttons: 发送 and 取消 (no more output-area close button; the
|
||||
// scrollable history area is the display).
|
||||
expect(prompt.node.querySelectorAll('button').length).toBe(2);
|
||||
// 3 buttons: 发送, 取消, 📋 插入单元格内容
|
||||
expect(prompt.node.querySelectorAll('button').length).toBe(3);
|
||||
const insertBtn = prompt.node.querySelector(
|
||||
'.opencode-btn-insert-cell'
|
||||
) as HTMLButtonElement;
|
||||
expect(insertBtn).not.toBeNull();
|
||||
expect(insertBtn.textContent).toContain('插入单元格内容');
|
||||
});
|
||||
|
||||
it('"📋 插入单元格内容" appends the cell source as a markdown code block to the textarea', () => {
|
||||
const cell = makeFakeCell('print("hello")\nprint("world")');
|
||||
const prompt = new OpenCodeInlinePrompt(cell, {
|
||||
onSubmit: jest.fn(),
|
||||
onCancel: jest.fn(),
|
||||
disabled: false,
|
||||
providers: null
|
||||
});
|
||||
const insertBtn = prompt.node.querySelector(
|
||||
'.opencode-btn-insert-cell'
|
||||
) as HTMLButtonElement;
|
||||
insertBtn.click();
|
||||
const textarea = prompt.node.querySelector('textarea') as HTMLTextAreaElement;
|
||||
// Inserted as a fenced code block (the fake cell's type is 'code'
|
||||
// so the fence info string is whatever the cell reports — we just
|
||||
// assert a fence + the source content, not a specific language).
|
||||
expect(textarea.value).toMatch(/```\w*\n/);
|
||||
expect(textarea.value).toContain('print("hello")');
|
||||
expect(textarea.value).toContain('print("world")');
|
||||
expect(textarea.value.trim().endsWith('```')).toBe(true);
|
||||
});
|
||||
|
||||
it('"📋 插入单元格内容" appends to existing textarea content (with a leading newline if needed)', () => {
|
||||
const cell = makeFakeCell('x = 1');
|
||||
const prompt = new OpenCodeInlinePrompt(cell, {
|
||||
onSubmit: jest.fn(),
|
||||
onCancel: jest.fn(),
|
||||
disabled: false,
|
||||
providers: null
|
||||
});
|
||||
const textarea = prompt.node.querySelector('textarea') as HTMLTextAreaElement;
|
||||
textarea.value = 'Explain:'; // no trailing newline
|
||||
const insertBtn = prompt.node.querySelector(
|
||||
'.opencode-btn-insert-cell'
|
||||
) as HTMLButtonElement;
|
||||
insertBtn.click();
|
||||
// The original prefix is preserved at the start.
|
||||
expect(textarea.value.startsWith('Explain:')).toBe(true);
|
||||
// The code block was inserted; the user prompt + the code block
|
||||
// are now both present.
|
||||
expect(textarea.value).toContain('x = 1');
|
||||
expect(textarea.value).toMatch(/```\w*[\s\S]*x = 1/);
|
||||
// The cursor was moved to the end.
|
||||
expect(textarea.selectionStart).toBe(textarea.value.length);
|
||||
});
|
||||
|
||||
it('"📋 插入单元格内容" is a no-op when the cell is empty', () => {
|
||||
const cell = makeFakeCell('');
|
||||
const prompt = new OpenCodeInlinePrompt(cell, {
|
||||
onSubmit: jest.fn(),
|
||||
onCancel: jest.fn(),
|
||||
disabled: false,
|
||||
providers: null
|
||||
});
|
||||
const insertBtn = prompt.node.querySelector(
|
||||
'.opencode-btn-insert-cell'
|
||||
) as HTMLButtonElement;
|
||||
const textarea = prompt.node.querySelector('textarea') as HTMLTextAreaElement;
|
||||
insertBtn.click();
|
||||
// Textarea is still empty.
|
||||
expect(textarea.value).toBe('');
|
||||
});
|
||||
|
||||
it('renders a history area; setMessages renders user text + assistant markdown and scrolls to bottom', () => {
|
||||
@@ -1382,7 +1449,14 @@ describe('OpenCodeInlinePrompt', () => {
|
||||
expect(asstBlocks[1].textContent).toBe('turn2');
|
||||
});
|
||||
|
||||
it('applyEvent: drops events for a different session (defensive filter)', () => {
|
||||
it('applyEvent: text deltas pass through regardless of sessionID match (no client-side filter for content events)', () => {
|
||||
// Regression: the SSE filter used to drop content events whose
|
||||
// sessionID didn't match the prompt's _sessionId. After a session
|
||||
// switch, this caused "no reply" because events for the new
|
||||
// session came in with a sessionID the prompt wasn't tracking.
|
||||
// Fix: only permission/question events are strict (so the user
|
||||
// can't reply to a different session's prompt); content events
|
||||
// (text, reasoning, tool, idle) always pass through.
|
||||
const cell = makeFakeCell('x = 1');
|
||||
const prompt = new OpenCodeInlinePrompt(cell, {
|
||||
onSubmit: jest.fn(),
|
||||
@@ -1393,12 +1467,35 @@ describe('OpenCodeInlinePrompt', () => {
|
||||
prompt.setSessionId('s1');
|
||||
prompt.applyEvent({
|
||||
type: 'session.next.text.delta',
|
||||
properties: { sessionID: 's2', delta: 'should be ignored' }
|
||||
properties: { sessionID: 's2', delta: 'still rendered' }
|
||||
});
|
||||
const asst = prompt.node.querySelector(
|
||||
'.opencode-inline-prompt .opencode-msg-assistant'
|
||||
'.opencode-msg-assistant'
|
||||
) as HTMLElement;
|
||||
expect(asst).not.toBeNull();
|
||||
expect(asst.textContent).toBe('still rendered');
|
||||
});
|
||||
|
||||
it('applyEvent: permission.asked IS still dropped for a different session', () => {
|
||||
// The cross-session filter is strict ONLY for permission.asked /
|
||||
// question.asked so the user can't accidentally reply to another
|
||||
// session's prompt.
|
||||
const cell = makeFakeCell('x = 1');
|
||||
const prompt = new OpenCodeInlinePrompt(cell, {
|
||||
onSubmit: jest.fn(),
|
||||
onCancel: jest.fn(),
|
||||
disabled: false,
|
||||
providers: null
|
||||
});
|
||||
prompt.setSessionId('s1');
|
||||
prompt.applyEvent({
|
||||
type: 'permission.asked',
|
||||
properties: { sessionID: 's2', id: 'perm-X', description: 'rm -rf' }
|
||||
});
|
||||
const block = prompt.node.querySelector(
|
||||
'.opencode-msg-block-interaction'
|
||||
);
|
||||
expect(asst).toBeNull();
|
||||
expect(block).toBeNull();
|
||||
});
|
||||
|
||||
it('applyEvent: permission.asked creates a permission block with 3 buttons', () => {
|
||||
|
||||
Reference in New Issue
Block a user