8 Commits
Author SHA1 Message Date
tao.chenandClaude fcd015ac0e fix: stop running marked.parse on every streaming text delta
CI / CI (push) Successful in 17m54s
The streaming path used to call _renderAssistantMarkdown on every
incoming text delta, which re-ran marked.parse on the WHOLE
accumulated markdown source and re-post-processed (added the
Copy/Insert/Replace toolbar to) every <pre> block.

This is a partial-render problem: a markdown source mid-stream (e.g.
an opening ```py fence with no closing fence yet, or a partial
heading) classifies DIFFERENTLY than the complete message. Each
new delta could re-classify the same text into a different DOM
shape, causing visible flicker - <pre>s appearing and disappearing,
the toolbar popping in and out, paragraphs re-grouping - while the
model is still typing. The bug surfaced because the user could see
the glitchy view during a stream but everything looked correct
after closing+reopening the panel (which goes through the static
setMessages -> marked.parse path on the full text).

Fix: keep the streaming path dumb. _appendToAssistantText now just
appends the raw delta to the assistant element's textContent. The
full markdown render happens exactly once at the end of the turn,
inside _resetStreamPointers (which the prompt invokes when
session.idle arrives, before calling onStreamEnd). Result:

- The user still sees real-time text growth (textContent += delta).
- No more intermediate-state flicker.
- The rendered view at turn end is byte-for-byte equivalent to what
  setMessages would produce from the stored history (they share
  _renderAssistantMarkdown).

The corresponding regression test is updated: it now asserts the
DURING-stream state is raw text (no <pre>, no toolbar) and the
POST-idle state is the rendered DOM with the toolbar attached.

Tests: 72 jest (unchanged count, the one streaming test was
rewritten), 52 pytest, build green.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-27 17:52:53 +08:00
tao.chen d867b2f6d6 Merge remote-tracking branch 'origin/main' 2026-07-27 17:40:49 +08:00
tao.chen b4060d3b2f Merge pull request 'Feat/async sse streaming' (#1) from feat/async-sse-streaming into main
Reviewed-on: #1
2026-07-27 17:38:53 +08:00
dd5667cea4 feat: persist user (provider, model) selection per notebook in localStorage
When the user picks a provider+model in the inline prompt, remember
that choice across panel close/reopen AND across JupyterLab restarts,
keyed by notebookPath. If the stored provider (or the model under it)
is no longer present in the current providers payload, fall back to
the default (first provider / default[pid] model) - never silently
apply a stale value that would result in an empty or disabled select.

- New module src/components/model_selection.ts with
  loadModelSelection / saveModelSelection / clearModelSelection.
  Best-effort: localStorage may be disabled (private mode), quota
  may be exhausted, value may be corrupt - all failure modes return
  null / no-op rather than throw.
- IOpenCodeInlinePromptOptions gains notebookPath?: string.
- Constructor resolves the initial selection: prefer stored+valid,
  else first provider / default[pid]. providerSelect.change and
  modelSelect.change listeners call _persistSelection() to keep
  storage in sync.
- _rebuildModelSelect now accepts a preferredModelId; if it exists
  in the current provider's models it's used, else the default.
- OpenCodeCellActions._showPrompt passes notebookPath =
  this._context?.notebookPath so the prompt can key the storage.

Tests
-----
- 10 unit tests in model_selection.spec.ts (load/save/clear +
  malformed JSON + per-notebook independence).
- 4 prompt behavior tests in opencode_cell_actions.spec.ts
  (valid stored applied, provider removed -> default, model removed
  -> default, change -> saved).
- 72 frontend jest (was 58), 52 backend pytest (unchanged),
  build green.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-27 17:38:53 +08:00
b7089519fd feat: async + SSE message flow with interactive permission/question UI
Replace the synchronous /edit (wait for full markdown response) with an
async + SSE flow per demo.html so the user sees text stream in real-time
and can interact with permission/question events the agent raises.

Backend
-------
- EditHandler now calls OpenCode /session/:id/prompt_async and returns
  immediately with {ok, sessionId, notebookPath}; the LLM reply is no
  longer embedded in this response.
- New GlobalEventHandler proxies OpenCode /global/event as
  text/event-stream. Server forwards ALL events; the client filters.
  A too-eager server-side ?session= filter was silently dropping events
  the client would have accepted, so it was removed.
- New PermissionReplyHandler + QuestionReplyHandler forward user
  replies (once/always/reject and freeform answer) back to OpenCode
  Serve at /session/:sid/permissions/:permId and
  /session/:sid/question/:qId/reply.
- OpenCodeClient gains send_message_async(), stream_global_events()
  (async generator over the SSE feed via tornado streaming_callback),
  reply_permission() and reply_question(). Legacy send_message_sync
  removed.

Frontend
--------
- subscribeOpenCodeEvents() opens a fetch+reader SSE client (XSRF
  token injected from serverSettings); AbortController-backed close()
  is idempotent.
- OpenCodeInlinePrompt.applyEvent() routes events into the streaming
  UI: text delta -> assistant message (re-rendered as markdown on
  every delta so the user sees formatted <pre><code> blocks in
  real-time, not raw fence source); reasoning/tool/permission/question
  get collapsible details blocks. session.idle resets stream pointers
  and fires onStreamEnd.
- Permission/question blocks render real interactive UI: three
  buttons (once/always/reject) for permission, a text input + submit
  for question. Click handlers post through the new API routes and
  show success/failure status in place.
- Centralized idle detection (4 shapes: top-level + payload-nested
  x {session.idle, session.status/idle}) inside the prompt; cell
  action reacts via onStreamEnd callback rather than re-parsing
  event types.
- System / workspace / pty / lsp / mcp / installation events AND
  session-level control events (agent.switched, model.switched,
  file.edited) are not rendered in the frontend.

Tests
-----
- 52 backend pytest (was 43)
- 58 frontend jest (was 46)

design.md section 3 updated for the new async /edit response shape
and the new GET /events SSE endpoint.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-27 17:38:53 +08:00
tao.chenandtao.chen a43174e5bc update: optimize config.py 2026-07-27 17:38:53 +08:00
tao.chen 380e4a3150 更新 .github/workflows/build.yml 2026-07-24 11:02:27 +08:00
tao.chen b0abeaf329 更新 .github/scripts/create-release.sh 2026-07-24 10:59:47 +08:00
4 changed files with 74 additions and 37 deletions
+1 -1
View File
@@ -38,7 +38,7 @@ if [ -z "${RELEASE_ID}" ]; then
fi
echo "Release created with ID ${RELEASE_ID}"
for asset in dist/snapshot-*.whl dist/snapshot-*.tar.gz; do
for asset in dist/*.whl dist/*.tar.gz; do
if [ -f "${asset}" ]; then
echo "Uploading ${asset}..."
curl -fsS -X POST "${API_URL}/repos/${REPOSITORY}/releases/${RELEASE_ID}/assets" \
+2
View File
@@ -7,6 +7,8 @@ on:
tags:
- 'v*'
pull_request:
workflow_dispatch:
jobs:
ci:
+46 -27
View File
@@ -1005,13 +1005,17 @@ describe('OpenCodeInlinePrompt', () => {
warnSpy.mockRestore();
});
it('applyEvent: streaming markdown is rendered as HTML in real-time (not raw ```fence``` source)', () => {
// Regression: previously the streaming path appended deltas to
// .textContent, so the user saw raw ```py\n...\n``` source while
// the model was still typing. Only after closing+reopening the
// panel (which goes through setMessages + marked.parse) did the
// code block render as <pre><code>. The fix: re-render via
// marked.parse on every delta.
it('applyEvent: streaming shows raw text in real-time, then renders to HTML on session.idle', () => {
// Regression: a previous version ran marked.parse on every text
// delta. That produced glitchy partial-render states (e.g. an
// opening ```py fence with no closing fence yet is a different
// DOM shape than the complete code block, so the toolbar
// post-processing flickered in and out between deltas). The fix
// is to keep the streaming path dumb (append raw text only) and
// run the markdown render exactly once at the end of the turn
// (session.idle). The user sees real-time text grow during the
// stream, and a clean rendered view the moment the turn ends —
// matching what setMessages would produce from the stored history.
const cell = makeFakeCell('x = 1');
const prompt = new OpenCodeInlinePrompt(cell, {
onSubmit: jest.fn(),
@@ -1020,43 +1024,58 @@ describe('OpenCodeInlinePrompt', () => {
providers: null
});
prompt.setSessionId('s1');
// Simulate a streaming reply that contains a fenced code block.
const deltas = [
'Here is the fix:\n\n',
'```python\n',
'import pandas as pd\n',
'import numpy as np\n',
'```\n'
];
for (const d of deltas) {
const fullSource =
'Here is the fix:\n\n```python\nimport pandas as pd\nimport numpy as np\n```\n';
// Stream every delta of the source.
for (const d of fullSource.split(/(?=H|```|\n)/)) {
if (!d) continue;
prompt.applyEvent({
type: 'session.next.text.delta',
properties: { sessionID: 's1', delta: d }
});
}
// The <pre><code> block is rendered DURING streaming, not just at
// the end. The raw ```fence``` characters must NOT appear as visible
// text content in the message body.
// During streaming: raw text, NO markdown render yet.
const asstDuring = prompt.node.querySelector(
'.opencode-msg-assistant'
) as HTMLElement;
expect(asstDuring.textContent).toBe(fullSource);
// The ```fence``` characters are present as literal text — the
// user is looking at the source, not the rendered view.
expect(asstDuring.textContent).toContain('```python');
// No <pre> element yet (no markdown render was attempted).
expect(asstDuring.querySelector('pre')).toBeNull();
// No toolbar yet either.
expect(asstDuring.querySelector('.opencode-code-toolbar')).toBeNull();
// Still a single assistant element (no forks per delta).
expect(
prompt.node.querySelectorAll(
'.opencode-msg-assistant'
).length
).toBe(1);
// Turn end -> session.idle triggers the final markdown render
// (via _resetStreamPointers, which the prompt invokes before
// calling onStreamEnd).
prompt.applyEvent({
type: 'session.idle',
properties: { sessionID: 's1' }
});
// After idle: the assistant message is now a fully rendered
// markdown DOM, identical to what setMessages would produce.
const pre = prompt.node.querySelector(
'.opencode-msg-assistant .opencode-msg-content pre'
) as HTMLElement;
expect(pre).not.toBeNull();
const code = pre.querySelector('code') as HTMLElement;
expect(code).not.toBeNull();
expect(code.textContent).toBe('import pandas as pd\nimport numpy as np');
// The Copy / Insert / Replace toolbar is attached to the <pre>
// block during streaming, same as in setMessages.
// block on the final render, same as in setMessages.
const toolbar = pre.parentElement!.querySelector(
'.opencode-code-toolbar'
) as HTMLElement;
expect(toolbar).not.toBeNull();
expect(toolbar.querySelectorAll('button').length).toBe(3);
// The wrapper still has a single assistant element (not one per delta).
expect(
prompt.node.querySelectorAll(
'.opencode-inline-prompt .opencode-msg-assistant'
).length
).toBe(1);
});
it('applyEvent: message.part.delta with field=text routes to the assistant message', () => {
+25 -9
View File
@@ -596,16 +596,19 @@ export class OpenCodeInlinePrompt extends Widget {
if (!this._currentAssistantEl) {
this._currentAssistantEl = this._createMessageElement('assistant', '');
}
// Accumulate the raw markdown source, then re-render the whole
// message through marked.parse. This way the user sees properly
// formatted output (code blocks as <pre><code>, headings, lists)
// in real-time as text deltas arrive — NOT the raw ```fence```
// source that an append-only .textContent would show.
// During streaming, append the raw delta to the assistant element's
// textContent. We do NOT run marked.parse on every delta: the
// partial source (e.g. an opening ```py fence with no closing
// fence yet) is structurally different from the complete message
// and the toolbar post-processing creates visible flicker —
// <pre>s appear and disappear as the renderer re-classifies the
// incomplete markdown between deltas. The user gets stable
// real-time text here, and the full markdown render happens once
// at the end of the turn (see _resetStreamPointers) so the result
// is identical to what setMessages would produce from the stored
// history.
this._currentAssistantText += text;
this._renderAssistantMarkdown(
this._currentAssistantEl,
this._currentAssistantText
);
this._currentAssistantEl.textContent = this._currentAssistantText;
this._scrollToBottom();
}
@@ -807,6 +810,19 @@ export class OpenCodeInlinePrompt extends Widget {
}
private _resetStreamPointers(): void {
// Finalize the in-progress assistant message by running the
// markdown render ONCE on the accumulated raw text. This is the
// moment where the streaming path converges to the same DOM
// structure that setMessages would produce from the stored
// history (i.e. identical to "reopen" behavior). Without this
// final pass the user would see raw ```fence``` source
// permanently until they close+reopen the panel.
if (this._currentAssistantEl && this._currentAssistantText) {
this._renderAssistantMarkdown(
this._currentAssistantEl,
this._currentAssistantText
);
}
this._currentAssistantEl = null;
this._currentAssistantText = '';
this._activeBlocks = {};