From bfd4c8e519f96ca5bdc6cdd9f7a635b9345dbf11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=93=E8=89=AF?= <1204183885@qq.com> Date: Tue, 28 Jul 2026 23:32:56 +0800 Subject: [PATCH 1/7] fix(scripts): slim release-note model prompts and log request timing (#7941) --- scripts/generate-release-notes.js | 25 ++++-------- scripts/tests/generate-release-notes.test.js | 43 +++++++++++++++----- 2 files changed, 40 insertions(+), 28 deletions(-) diff --git a/scripts/generate-release-notes.js b/scripts/generate-release-notes.js index 9efb09d443..0d6227a624 100644 --- a/scripts/generate-release-notes.js +++ b/scripts/generate-release-notes.js @@ -34,11 +34,7 @@ export function buildPullRequestQuery(numbers) { pr${index}: pullRequest(number: ${number}) { number body - additions - deletions - changedFiles labels(first: 20) { nodes { name } } - files(first: 40) { nodes { path } } }`, ) .join('\n'); @@ -262,14 +258,7 @@ function compactEntry(entry) { return { number: entry.number, title: entry.title, - body: (entry.body || '').slice(0, 3000), - labels: (entry.labels || []).map((label) => - typeof label === 'string' ? label : label.name, - ), - files: (entry.files || []).slice(0, 40), - additions: entry.additions, - deletions: entry.deletions, - changedFiles: entry.changedFiles, + body: (entry.body || '').slice(0, 700), category: classifyChange(entry), }; } @@ -375,15 +364,10 @@ export function enrichEntries(entries, metadata) { const byNumber = new Map(metadata.map((item) => [item.number, item])); return entries.map((entry) => { const details = byNumber.get(entry.number) || {}; - const files = details.files?.nodes || details.files || []; return { ...entry, body: details.body || '', labels: details.labels?.nodes || details.labels || [], - files: files.map((file) => (typeof file === 'string' ? file : file.path)), - additions: details.additions || 0, - deletions: details.deletions || 0, - changedFiles: details.changedFiles || files.length, }; }); } @@ -468,6 +452,7 @@ export function createOpenAiCompleter({ if (remainingMs <= 0) { throw deadlineError(); } + const attemptStartedAt = Date.now(); try { const response = await fetchImpl(endpoint, { method: 'POST', @@ -495,10 +480,16 @@ export function createOpenAiCompleter({ if (typeof content !== 'string' || !content.trim()) { throw new Error(CONTENT_VALIDATION_ERROR_MESSAGE); } + console.error( + `Model ${request.kind} request succeeded in ${Date.now() - attemptStartedAt}ms (prompt ${prompt.user.length} chars).`, + ); return content; } catch (error) { lastError = error; attempt += 1; + console.error( + `Model ${request.kind} request failed after ${Date.now() - attemptStartedAt}ms (prompt ${prompt.user.length} chars): ${escapeWorkflowCommand(error.message)}`, + ); if (Date.now() >= deadline) { throw deadlineError(); } diff --git a/scripts/tests/generate-release-notes.test.js b/scripts/tests/generate-release-notes.test.js index 9d3edb0973..eb19fb88d0 100644 --- a/scripts/tests/generate-release-notes.test.js +++ b/scripts/tests/generate-release-notes.test.js @@ -39,10 +39,6 @@ const entry = (number, title, labels = []) => ({ author: 'alice', labels, body: '', - files: [], - additions: 1, - deletions: 0, - changedFiles: 1, }); describe('parseGeneratedEntries', () => { @@ -240,6 +236,34 @@ describe('generateAiContent', () => { ]); }); + it('sends only title, a bounded body excerpt, and category to the model', async () => { + const long = { ...entry(1, 'feat: long body'), body: 'x'.repeat(5000) }; + const calls = []; + const complete = async (request) => { + calls.push(request); + if (request.kind === 'summaries') { + return JSON.stringify({ + summaries: request.entries.map((item) => ({ + pr: item.number, + summary: 'Summary.', + })), + }); + } + return JSON.stringify({ highlights: [] }); + }; + + await generateAiContent([long], complete); + + const [payload] = calls[0].entries; + expect(Object.keys(payload).sort()).toEqual([ + 'body', + 'category', + 'number', + 'title', + ]); + expect(payload.body).toHaveLength(700); + }); + it('falls back to original titles for an invalid summary batch', async () => { const entries = [entry(1, 'feat: original'), entry(2, 'fix: original')]; const complete = async (request) => { @@ -359,17 +383,13 @@ describe('enrichEntries', () => { number: 1, body: 'Why it matters.', labels: [{ name: 'type/bug' }], - files: [{ path: 'packages/core/a.ts' }], - additions: 3, - deletions: 2, - changedFiles: 1, }, ]); expect(enriched.map((item) => item.number)).toEqual([2, 1]); expect(enriched[0].body).toBe(''); expect(enriched[1].body).toBe('Why it matters.'); - expect(enriched[1].files).toEqual(['packages/core/a.ts']); + expect(enriched[1].labels).toEqual([{ name: 'type/bug' }]); }); }); @@ -379,7 +399,8 @@ describe('buildPullRequestQuery', () => { expect(query).toContain('pr0: pullRequest(number: 12)'); expect(query).toContain('pr1: pullRequest(number: 8)'); - expect(query).toContain('files(first: 40)'); + expect(query).toContain('labels(first: 20)'); + expect(query).not.toContain('files(first: 40)'); expect(query).not.toContain('pullRequest(number: undefined)'); }); }); @@ -480,7 +501,7 @@ describe('generateReleaseNotes', () => { ' process.exit(0);', '}', "if (args[0] === 'api' && args[1] === 'graphql') {", - " process.stdout.write(JSON.stringify({ data: { repository: { pr0: { number: 1, body: 'Body.', additions: 1, deletions: 0, changedFiles: 1, labels: { nodes: [] }, files: { nodes: [] } } } } }));", + " process.stdout.write(JSON.stringify({ data: { repository: { pr0: { number: 1, body: 'Body.', labels: { nodes: [] } } } } }));", ' process.exit(0);', '}', 'process.exit(1);', From 54d3997add6fad300c168f309b741e0a608fc7d1 Mon Sep 17 00:00:00 2001 From: carffuca Date: Tue, 28 Jul 2026 23:55:09 +0800 Subject: [PATCH 2/7] feat(web-shell): suggest BTW for side questions (#7935) * test(web-shell): define composer intent suggestions * feat(web-shell): suggest btw for side questions * test(web-shell): cover pasted image suggestion gating * fix(web-shell): block btw suggestions for inline tags * test(web-shell): verify inline attachment snapshots * feat(web-shell): refine BTW intent suggestions --- ...8-web-shell-composer-intent-suggestions.md | 71 ++++++ packages/web-shell/client/App.test.tsx | 235 +++++++++++++++++- packages/web-shell/client/App.tsx | 64 ++++- .../client/components/ChatEditor.test.tsx | 46 +++- .../client/components/ChatEditor.tsx | 6 + .../client/hooks/useComposerCore.dom.test.tsx | 22 ++ .../web-shell/client/hooks/useComposerCore.ts | 19 ++ .../hooks/useNewSessionSuggestion.test.tsx | 122 ++++++++- .../client/hooks/useNewSessionSuggestion.ts | 86 +++++-- packages/web-shell/client/i18n.tsx | 4 + 10 files changed, 623 insertions(+), 52 deletions(-) create mode 100644 docs/design/2026-07-28-web-shell-composer-intent-suggestions.md diff --git a/docs/design/2026-07-28-web-shell-composer-intent-suggestions.md b/docs/design/2026-07-28-web-shell-composer-intent-suggestions.md new file mode 100644 index 0000000000..06b052d14e --- /dev/null +++ b/docs/design/2026-07-28-web-shell-composer-intent-suggestions.md @@ -0,0 +1,71 @@ +# Web Shell Composer Intent Suggestions + +## Summary + +Extend Web Shell's existing new-topic suggestion so one conservative +classification can recommend either asking a side question with `/btw` or +sending a substantial new topic in a fresh session. + +The composer continues to show at most one non-blocking action. A valid +`none` decision renders nothing. Invalid, failed, or cancelled classifications +also render nothing. + +## Decision contract + +```ts +type SuggestionKind = 'btw' | 'new_session' | 'none'; + +interface SuggestionDecision { + suggestion: SuggestionKind; + confidence: number; +} +``` + +Only `btw` and `new_session` decisions at or above the existing confidence +threshold become actionable. The actionable state records the exact classified +draft and source session so both can be checked again when the user clicks. + +## Behavior + +- `btw` is for a quick, self-contained side question that should not disturb + the main task. +- `new_session` is for a clearly different, substantial task or topic. +- `none` covers continuations, uncertainty, and drafts that fit neither action. +- BTW classification starts after one prior user/assistant exchange. New-session + suggestions keep their stricter existing context thresholds. +- Follow-up-like wording may be classified for BTW, but can never use the + relaxed BTW threshold to surface a new-session action. +- Clicking a `btw` suggestion submits `/btw ` through the existing + editor path, which preserves the command's current history and composer-clear + semantics. +- A draft with an image or composer tag is never eligible for `btw`. +- `new_session` retains the existing clear, detach, create, and auto-submit + sequence, including image preservation and session-race cancellation. + +## Safety + +The classifier remains conservative and fail-closed: + +- malformed output, unknown actions, invalid confidence, errors, and + cancellation produce no action; +- a session change aborts pending classification and invalidates a visible + suggestion; +- a draft or attachment change invalidates a visible suggestion; +- click handling checks the current draft, source session, and attachment state + immediately before executing; +- attachments are treated as present until ChatEditor reports otherwise, so a + transient unknown state cannot expose a `/btw` action. + +## Scope + +The change stays inside Web Shell. It reuses existing daemon session generation, +editor submission, and `/btw` behavior. It does not add daemon or SDK routes, +change styling, or introduce a general-purpose suggestion framework. + +## Test strategy + +- Hook tests cover the three decision values, strict parsing, confidence, + attachment gating, and stale-session results. +- App tests cover `/btw` execution and composer clearing, stale draft/session + rejection, attachment rejection, and the existing new-session races. +- ChatEditor tests cover attachment-presence reporting. diff --git a/packages/web-shell/client/App.test.tsx b/packages/web-shell/client/App.test.tsx index 24c8a1b42a..59a43f342f 100644 --- a/packages/web-shell/client/App.test.tsx +++ b/packages/web-shell/client/App.test.tsx @@ -51,6 +51,7 @@ type ChatEditorTestProps = { ) => boolean | void; onCancel?: () => void; onInputTextChange?: (text: string) => void; + onAttachmentsChange?: (hasAttachments: boolean) => void; onStartNewSessionSuggestion?: () => void; newSessionSuggestion?: { isVisible: boolean; classifiedInput: string } | null; skills?: Array<{ name: string; description: string }>; @@ -170,6 +171,7 @@ const { mockConnection: connection, mockSessionActions: { sendPrompt: vi.fn().mockResolvedValue(undefined), + btwSession: vi.fn().mockResolvedValue({ answer: 'side answer' }), generateSessionContent: vi.fn(async function* () {}), createSession: vi.fn().mockResolvedValue({ sessionId: 'session-1' }), attachSession: vi.fn().mockResolvedValue(undefined), @@ -400,12 +402,23 @@ vi.mock('./components/ChatEditor', async () => { props: ChatEditorTestProps, ref: React.ForwardedRef<{ clear: () => void; + hasAttachments: () => boolean; hasInput: () => boolean; insertText: (text: string) => void; + submit: (input?: { text?: string }) => void; focus: () => void; }>, ) { testState.latestChatEditorProps = props; + const { onAttachmentsChange } = props; + React.useEffect(() => { + onAttachmentsChange?.( + Boolean( + testState.promptImages?.length || + testState.inputAnnotations?.length, + ), + ); + }, [onAttachmentsChange]); React.useImperativeHandle(ref, () => ({ clear: () => { testState.prompt = ''; @@ -413,17 +426,23 @@ vi.mock('./components/ChatEditor', async () => { props.onInputTextChange?.(''); editorClear(); }, + hasAttachments: () => + Boolean( + testState.promptImages?.length || + testState.inputAnnotations?.length, + ), hasInput: () => testState.prompt.trim().length > 0, insertText: editorInsertText, - submit: () => { - props.onSubmit( - testState.prompt, + submit: (input) => { + const accepted = props.onSubmit( + input?.text ?? testState.prompt, testState.promptImages, editorCommit, testState.inputAnnotations ? { inputAnnotations: testState.inputAnnotations } : undefined, ); + if (accepted) editorCommit(); }, // The panel focus effect calls editorRef.current?.focus() when a panel // closes with no pending approval (e.g. resuming a session). @@ -1587,6 +1606,7 @@ beforeEach(() => { if (typeof value === 'function' && 'mockClear' in value) value.mockClear(); } mockSessionActions.sendPrompt.mockResolvedValue(undefined); + mockSessionActions.btwSession.mockResolvedValue({ answer: 'side answer' }); mockSessionActions.createSession.mockResolvedValue({ sessionId: 'session-1', }); @@ -5477,7 +5497,7 @@ describe('App session callbacks', () => { requestId: 'req-1', seq: 0, text: JSON.stringify({ - shouldSuggestNewSession: true, + suggestion: 'new_session', confidence: 0.91, }), }; @@ -5534,6 +5554,207 @@ describe('App session callbacks', () => { expect(editorInsertText).not.toHaveBeenCalled(); }); + it('suggests sending a side question with BTW and clears the accepted draft', async () => { + vi.useFakeTimers(); + mockConnection.capabilities.features = ['session_generation']; + ( + mockConnection as typeof mockConnection & { + tokenCount?: number; + contextWindow?: number; + } + ).tokenCount = 600; + ( + mockConnection as typeof mockConnection & { + tokenCount?: number; + contextWindow?: number; + } + ).contextWindow = 1000; + testState.messages = Array.from({ length: 8 }, (_, index) => ({ + id: `m-btw-${index}`, + role: index % 2 === 0 ? 'user' : 'assistant', + content: `existing session topic ${index} about daemon generation review work`, + timestamp: index, + })); + const sideQuestion = '这里的 confidence 阈值为什么是 0.75?'; + testState.prompt = sideQuestion; + mockSessionActions.generateSessionContent.mockImplementation( + async function* () { + yield { + type: 'delta', + requestId: 'req-btw', + seq: 0, + text: JSON.stringify({ + suggestion: 'btw', + confidence: 0.92, + }), + }; + yield { + type: 'done', + requestId: 'req-btw', + model: 'fast-model', + modelSource: 'fast', + }; + }, + ); + + const { container } = renderApp(); + await flush(); + + act(() => { + testState.latestChatEditorProps?.onInputTextChange?.(testState.prompt); + }); + await flush(); + act(() => { + vi.advanceTimersByTime(121); + }); + await flush(); + act(() => { + vi.advanceTimersByTime(701); + }); + await flush(); + + expect( + container.querySelector('[data-testid="btw-suggestion"]')?.textContent, + ).toContain('side question'); + + await act(async () => { + container + .querySelector('[data-testid="btw-suggestion-send"]') + ?.click(); + await Promise.resolve(); + }); + + expect(mockSessionActions.btwSession).toHaveBeenCalledWith( + sideQuestion, + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + expect(editorCommit).toHaveBeenCalledTimes(1); + + const newTask = '帮我写一篇新的设计文档,主题是 Web Shell 新功能方案'; + mockSessionActions.generateSessionContent.mockImplementation( + async function* () { + yield { + type: 'delta', + requestId: 'req-new-session-after-btw', + seq: 0, + text: JSON.stringify({ + suggestion: 'new_session', + confidence: 0.94, + }), + }; + yield { + type: 'done', + requestId: 'req-new-session-after-btw', + model: 'fast-model', + modelSource: 'fast', + }; + }, + ); + testState.prompt = newTask; + act(() => { + testState.latestChatEditorProps?.onInputTextChange?.(newTask); + }); + await flush(); + act(() => { + vi.advanceTimersByTime(121); + }); + await flush(); + act(() => { + vi.advanceTimersByTime(701); + }); + await flush(); + + expect( + container.querySelector('[data-testid="new-session-suggestion"]'), + ).not.toBeNull(); + }); + + it('refuses a visible BTW suggestion when an inline tag is added before acceptance', async () => { + vi.useFakeTimers(); + mockConnection.capabilities.features = ['session_generation']; + ( + mockConnection as typeof mockConnection & { + tokenCount?: number; + contextWindow?: number; + } + ).tokenCount = 600; + ( + mockConnection as typeof mockConnection & { + tokenCount?: number; + contextWindow?: number; + } + ).contextWindow = 1000; + testState.messages = Array.from({ length: 8 }, (_, index) => ({ + id: `m-btw-attachment-${index}`, + role: index % 2 === 0 ? 'user' : 'assistant', + content: `existing session topic ${index} about daemon generation review work`, + timestamp: index, + })); + testState.prompt = '顺便看看这里为什么会报错?'; + mockSessionActions.generateSessionContent.mockImplementation( + async function* () { + yield { + type: 'delta', + requestId: 'req-btw-attachment', + seq: 0, + text: JSON.stringify({ + suggestion: 'btw', + confidence: 0.95, + }), + }; + yield { + type: 'done', + requestId: 'req-btw-attachment', + model: 'fast-model', + modelSource: 'fast', + }; + }, + ); + + const { container } = renderApp(); + await flush(); + + act(() => { + testState.latestChatEditorProps?.onInputTextChange?.(testState.prompt); + }); + await flush(); + act(() => { + vi.advanceTimersByTime(121); + }); + await flush(); + act(() => { + vi.advanceTimersByTime(701); + }); + await flush(); + + expect( + container.querySelector('[data-testid="btw-suggestion"]'), + ).not.toBeNull(); + + testState.inputAnnotations = [ + { + type: 'reference', + text: '@src/App.tsx', + start: 0, + end: 12, + reference: { + id: 'src/App.tsx', + value: 'src/App.tsx', + serialized: '@src/App.tsx', + }, + }, + ]; + await act(async () => { + container + .querySelector('[data-testid="btw-suggestion-send"]') + ?.click(); + await Promise.resolve(); + }); + + expect(mockSessionActions.btwSession).not.toHaveBeenCalled(); + expect(editorCommit).not.toHaveBeenCalled(); + }); + it('waits for the current session to detach before auto-submitting the suggested new-session draft', async () => { vi.useFakeTimers(); const clear = deferred(); @@ -5570,7 +5791,7 @@ describe('App session callbacks', () => { requestId: 'req-2', seq: 0, text: JSON.stringify({ - shouldSuggestNewSession: true, + suggestion: 'new_session', confidence: 0.91, }), }; @@ -5659,7 +5880,7 @@ describe('App session callbacks', () => { requestId: 'req-stale', seq: 0, text: JSON.stringify({ - shouldSuggestNewSession: true, + suggestion: 'new_session', confidence: 0.91, }), }; @@ -5747,7 +5968,7 @@ describe('App session callbacks', () => { requestId: 'req-switch', seq: 0, text: JSON.stringify({ - shouldSuggestNewSession: true, + suggestion: 'new_session', confidence: 0.91, }), }; diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index 66c0c7b1be..8b8d00d8b8 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -2618,6 +2618,9 @@ export function App({ null, ); const [composerText, setComposerText] = useState(''); + const [hasComposerAttachments, setHasComposerAttachments] = useState< + boolean | null + >(null); const [isStartingNewSessionSuggestion, setIsStartingNewSessionSuggestion] = useState(false); const streamingState = useStreamingState(); @@ -5184,6 +5187,13 @@ export function App({ }, 120); }, []); + const handleComposerAttachmentsChange = useCallback( + (hasAttachments: boolean) => { + setHasComposerAttachments(hasAttachments); + }, + [], + ); + const { suggestion: newSessionSuggestion, dismiss: dismissNewSessionSuggestion, @@ -5200,6 +5210,7 @@ export function App({ : 0, isRunning: streamingState !== 'idle', dialogOpen: interactionBlocked || approvalOverlayActive, + hasAttachments: hasComposerAttachments, generateContent: sessionActions.generateSessionContent, }); @@ -5263,7 +5274,11 @@ export function App({ const handleAcceptNewSessionSuggestion = useCallback(() => { const draft = composerTextRef.current.trim(); if (!draft || isStartingNewSessionSuggestion) return; - if (newSessionSuggestion?.classifiedInput !== draft) { + if ( + newSessionSuggestion?.suggestion !== 'new_session' || + newSessionSuggestion.classifiedInput !== draft || + newSessionSuggestion.sourceSessionId !== connectionRef.current.sessionId + ) { dismissNewSessionSuggestion(); return; } @@ -5303,6 +5318,24 @@ export function App({ suppressNewSessionSuggestion, ]); + const handleAcceptBtwSuggestion = useCallback(() => { + const draft = composerTextRef.current.trim(); + if ( + !draft || + newSessionSuggestion?.suggestion !== 'btw' || + newSessionSuggestion.classifiedInput !== draft || + newSessionSuggestion.sourceSessionId !== + connectionRef.current.sessionId || + editorRef.current?.hasAttachments() !== false + ) { + dismissNewSessionSuggestion(); + return; + } + dismissNewSessionSuggestion(); + editorRef.current?.submit({ text: `/btw ${draft}` }); + editorRef.current?.focus(); + }, [dismissNewSessionSuggestion, newSessionSuggestion]); + const shellApi = useMemo( () => ({ openSplitView: () => { @@ -8653,7 +8686,11 @@ export function App({
- {t('editor.newSessionSuggestionTitle')} + {newSessionSuggestion.suggestion === 'btw' + ? t('editor.btwSuggestionTitle') + : t('editor.newSessionSuggestionTitle')}
@@ -8703,6 +8752,9 @@ export function App({ ref={setEditorHandle} onSubmit={handleEditorSubmit} onInputTextChange={handleComposerTextChange} + onAttachmentsChange={ + handleComposerAttachmentsChange + } onCycleMode={handleCycleMode} onToggleShortcuts={handleToggleShortcuts} onCancel={handleCancel} diff --git a/packages/web-shell/client/components/ChatEditor.test.tsx b/packages/web-shell/client/components/ChatEditor.test.tsx index d54ad3a5c0..efc68ac079 100644 --- a/packages/web-shell/client/components/ChatEditor.test.tsx +++ b/packages/web-shell/client/components/ChatEditor.test.tsx @@ -24,6 +24,7 @@ Element.prototype.scrollIntoView = vi.fn(); const mockComposerCoreState = vi.hoisted(() => ({ composerTags: [] as WebShellComposerTag[], + pastedImages: [] as Array<{ data: string; media_type: string }>, removeTopTag: vi.fn(), })); @@ -129,6 +130,9 @@ vi.mock('../hooks/useComposerCore', async (importOriginal) => { clearText: vi.fn(), getText: vi.fn(() => ''), hasInput: vi.fn(() => false), + hasAttachments: + mockComposerCoreState.pastedImages.length > 0 || + mockComposerCoreState.composerTags.length > 0, hasContent: false, handle: { focus: vi.fn(), @@ -139,8 +143,11 @@ vi.mock('../hooks/useComposerCore', async (importOriginal) => { addTags: vi.fn(), removeInlineTags: vi.fn(), submit: vi.fn(), + hasAttachments: () => + mockComposerCoreState.pastedImages.length > 0 || + mockComposerCoreState.composerTags.length > 0, }, - pastedImages: [], + pastedImages: mockComposerCoreState.pastedImages, removeImage: vi.fn(), composerTags: mockComposerCoreState.composerTags, removeTopTag: mockComposerCoreState.removeTopTag, @@ -224,11 +231,13 @@ afterEach(() => { portalRoot.remove(); } mockComposerCoreState.composerTags = []; + mockComposerCoreState.pastedImages = []; mockComposerCoreState.removeTopTag.mockReset(); }); function renderChatEditor(props: { composerTags?: WebShellComposerTag[]; + pastedImages?: Array<{ data: string; media_type: string }>; gitBranch?: string; workspaceName?: string; workspaceTitle?: string; @@ -240,10 +249,12 @@ function renderChatEditor(props: { availableModels?: Array<{ id: string; label?: string }>; onSelectMode?: (mode: string) => void; onSelectModel?: (model: string) => void; + onAttachmentsChange?: (hasAttachments: boolean) => void; customization?: WebShellCustomization; }) { const { composerTags, + pastedImages, customization, renderComposerTagTooltip, onComposerTagClick, @@ -252,6 +263,9 @@ function renderChatEditor(props: { if (composerTags) { mockComposerCoreState.composerTags = composerTags; } + if (pastedImages) { + mockComposerCoreState.pastedImages = pastedImages; + } const container = document.createElement('div'); container.dataset.webShellRoot = ''; const portalRoot = document.createElement('div'); @@ -307,6 +321,36 @@ describe('ChatEditor voice toolbar integration', () => { }); }); +describe('ChatEditor attachment reporting', () => { + it('reports whether the composer has tags or pasted images', () => { + const onEmptyAttachmentsChange = vi.fn(); + renderChatEditor({ + onAttachmentsChange: onEmptyAttachmentsChange, + }); + expect(onEmptyAttachmentsChange).toHaveBeenLastCalledWith(false); + + const onTaggedAttachmentsChange = vi.fn(); + renderChatEditor({ + composerTags: [ + { + id: 'file:reference', + kind: 'file', + value: 'reference', + }, + ], + onAttachmentsChange: onTaggedAttachmentsChange, + }); + expect(onTaggedAttachmentsChange).toHaveBeenLastCalledWith(true); + + const onImageAttachmentsChange = vi.fn(); + renderChatEditor({ + pastedImages: [{ data: 'abc', media_type: 'image/png' }], + onAttachmentsChange: onImageAttachmentsChange, + }); + expect(onImageAttachmentsChange).toHaveBeenLastCalledWith(true); + }); +}); + describe('ChatEditor composer tag icons', () => { it('renders built-in icons for top composer tags', () => { const kinds = ['extension', 'file', 'mcp', 'skill'] as const; diff --git a/packages/web-shell/client/components/ChatEditor.tsx b/packages/web-shell/client/components/ChatEditor.tsx index b8f5c1cf25..c335a28c87 100644 --- a/packages/web-shell/client/components/ChatEditor.tsx +++ b/packages/web-shell/client/components/ChatEditor.tsx @@ -116,6 +116,7 @@ interface ChatEditorProps { metadata?: ComposerSubmitMetadata, ) => boolean | void; onInputTextChange?: (text: string) => void; + onAttachmentsChange?: (hasAttachments: boolean) => void; onCycleMode?: () => void; onToggleShortcuts?: () => void; onCancel?: () => void; @@ -1161,6 +1162,7 @@ export const ChatEditor = memo( const { onSubmit, onInputTextChange, + onAttachmentsChange, onCycleMode, onToggleShortcuts, onCancel, @@ -1268,6 +1270,10 @@ export const ChatEditor = memo( useImperativeHandle(ref, () => core.handle, [core.handle]); + useEffect(() => { + onAttachmentsChange?.(core.hasAttachments); + }, [core.hasAttachments, onAttachmentsChange]); + const [modeDropdownOpen, setModeDropdownOpen] = useState(false); const [modelDropdownOpen, setModelDropdownOpen] = useState(false); const [quickActionsOpen, setQuickActionsOpen] = useState(false); diff --git a/packages/web-shell/client/hooks/useComposerCore.dom.test.tsx b/packages/web-shell/client/hooks/useComposerCore.dom.test.tsx index 38fd24b157..4f451c2128 100644 --- a/packages/web-shell/client/hooks/useComposerCore.dom.test.tsx +++ b/packages/web-shell/client/hooks/useComposerCore.dom.test.tsx @@ -880,6 +880,28 @@ describe('useComposerCore tags', () => { ).toHaveLength(kinds.length); }); + it('reports inline composer tags as attachments', async () => { + await mount(); + + expect(latest!.handle.hasAttachments()).toBe(false); + expect(latest!.hasAttachments).toBe(false); + + act(() => { + latest!.addTags( + [{ id: 'orders', value: 'orders', serialized: '@orders' }], + { placement: 'inline' }, + ); + }); + expect(latest!.handle.hasAttachments()).toBe(true); + expect(latest!.hasAttachments).toBe(true); + + act(() => { + latest!.removeInlineTags(); + }); + expect(latest!.handle.hasAttachments()).toBe(false); + expect(latest!.hasAttachments).toBe(false); + }); + it('keeps inline tags after trimming leading whitespace on submit', async () => { const { onSubmit } = await mount(); diff --git a/packages/web-shell/client/hooks/useComposerCore.ts b/packages/web-shell/client/hooks/useComposerCore.ts index 7ab0668eff..19fdde3802 100644 --- a/packages/web-shell/client/hooks/useComposerCore.ts +++ b/packages/web-shell/client/hooks/useComposerCore.ts @@ -961,6 +961,7 @@ export interface EditorHandle extends WebShellComposerApi { clearText(): void; focus(): void; getText(): string; + hasAttachments(): boolean; hasInput(): boolean; retryLast(): void; restoreImages(images: readonly PromptImage[]): void; @@ -1268,6 +1269,7 @@ export interface UseComposerCoreReturn { clearText: () => void; getText: () => string; hasInput: () => boolean; + hasAttachments: boolean; hasContent: boolean; handle: EditorHandle; pastedImages: PromptImage[]; @@ -1607,6 +1609,7 @@ export function useComposerCore( const [composerTags, setComposerTags] = useState([]); const composerTagsRef = useRef([]); composerTagsRef.current = composerTags; + const [hasInlineTags, setHasInlineTags] = useState(false); const historyDraftComposerTagsRef = useRef( null, ); @@ -2825,6 +2828,7 @@ export function useComposerCore( triggerCleanupListener, // Update hasContent state when document changes EditorView.updateListener.of((update) => { + setHasInlineTags(getInlineComposerTags(update.view).length > 0); if (update.docChanged) { const text = getDocText(update.state); if (draftIdentityRef.current.storageKey === undefined) { @@ -3553,6 +3557,17 @@ export function useComposerCore( ); }, [isTouchComposer]); + const hasAttachments = useCallback(() => { + const inlineTags = viewRef.current + ? getInlineComposerTags(viewRef.current) + : []; + return ( + inlineTags.length > 0 || + composerTagsRef.current.length > 0 || + pastedImagesRef.current.length > 0 + ); + }, []); + const submit = useCallback( (input?: WebShellComposerInput) => { const view = viewRef.current; @@ -3856,6 +3871,7 @@ export function useComposerCore( clear, focus, getText, + hasAttachments, hasInput, setText, addTags, @@ -3871,6 +3887,7 @@ export function useComposerCore( clearText, focus, getText, + hasAttachments, hasInput, insertText, removeTopTag, @@ -3902,6 +3919,8 @@ export function useComposerCore( clearText, getText, hasInput, + hasAttachments: + hasInlineTags || composerTags.length > 0 || pastedImages.length > 0, hasContent, handle, pastedImages, diff --git a/packages/web-shell/client/hooks/useNewSessionSuggestion.test.tsx b/packages/web-shell/client/hooks/useNewSessionSuggestion.test.tsx index 44bcb4b5df..7e5f192786 100644 --- a/packages/web-shell/client/hooks/useNewSessionSuggestion.test.tsx +++ b/packages/web-shell/client/hooks/useNewSessionSuggestion.test.tsx @@ -29,6 +29,7 @@ const testState = { contextUsageRatio: 0, isRunning: false, dialogOpen: false, + hasAttachments: false as boolean | null, generateContent: vi.fn(async function* () {}), }; @@ -78,6 +79,7 @@ afterEach(async () => { testState.contextUsageRatio = 0; testState.isRunning = false; testState.dialogOpen = false; + testState.hasAttachments = false; testState.generateContent.mockReset(); vi.useRealTimers(); }); @@ -129,7 +131,7 @@ describe('useNewSessionSuggestion', () => { requestId: 'req-1', seq: 0, text: JSON.stringify({ - shouldSuggestNewSession: true, + suggestion: 'new_session', confidence: 0.9, }), }; @@ -149,8 +151,9 @@ describe('useNewSessionSuggestion', () => { expect(testState.generateContent).toHaveBeenCalledOnce(); expect(latestSuggestion).toEqual({ - isVisible: true, + suggestion: 'new_session', classifiedInput: '帮我写一篇新的设计文档,主题是 Web Shell 新功能方案', + sourceSessionId: 'session-1', }); testState.inputText = '顺手补个测试'; @@ -182,10 +185,14 @@ describe('useNewSessionSuggestion', () => { }, ] as Message[]; - async function classify(decisionText: string) { + async function classify( + decisionText: string, + inputText = NEW_TASK_DRAFT, + messages = CONTEXT_MESSAGES, + ) { vi.useFakeTimers(); - testState.inputText = NEW_TASK_DRAFT; - testState.messages = CONTEXT_MESSAGES; + testState.inputText = inputText; + testState.messages = messages; testState.generateContent.mockImplementation(async function* () { yield { type: 'delta', @@ -208,44 +215,137 @@ describe('useNewSessionSuggestion', () => { await flush(3); } + it('classifies a side question after only one prior exchange', async () => { + const sideQuestion = '这里的 confidence 阈值为什么是 0.75?'; + await classify( + JSON.stringify({ suggestion: 'btw', confidence: 0.92 }), + sideQuestion, + ); + + expect(testState.generateContent).toHaveBeenCalledOnce(); + expect(latestSuggestion).toEqual({ + suggestion: 'btw', + classifiedInput: sideQuestion, + sourceSessionId: 'session-1', + }); + }); + + it('lets common side-question wording reach the classifier', async () => { + const sideQuestion = '顺手问下,这里的 confidence 阈值为什么是 0.75?'; + await classify( + JSON.stringify({ suggestion: 'btw', confidence: 0.9 }), + sideQuestion, + ); + + expect(testState.generateContent).toHaveBeenCalledOnce(); + expect(latestSuggestion?.suggestion).toBe('btw'); + }); + + it('does not surface new_session from the relaxed BTW context floor', async () => { + await classify( + JSON.stringify({ suggestion: 'new_session', confidence: 0.96 }), + '这里的 confidence 阈值为什么是 0.75?', + ); + + expect(testState.generateContent).toHaveBeenCalledOnce(); + expect(latestSuggestion).toBeNull(); + }); + + it('does not classify BTW with less than one prior exchange', async () => { + await classify( + JSON.stringify({ suggestion: 'btw', confidence: 0.96 }), + '这里的 confidence 阈值为什么是 0.75?', + CONTEXT_MESSAGES.slice(0, 1), + ); + + expect(testState.generateContent).not.toHaveBeenCalled(); + expect(latestSuggestion).toBeNull(); + }); + + it.each([true, null])( + 'does not classify a low-context side question when attachment presence is %s', + async (hasAttachments) => { + testState.hasAttachments = hasAttachments; + await classify( + JSON.stringify({ suggestion: 'btw', confidence: 0.96 }), + '这里的 confidence 阈值为什么是 0.75?', + ); + + expect(testState.generateContent).not.toHaveBeenCalled(); + expect(latestSuggestion).toBeNull(); + }, + ); + it('recovers a positive decision wrapped in prose (observed live)', async () => { // Verbatim shape from a live run: prose preamble + bare JSON. await classify( 'The user is explicitly switching to a completely new task, which is ' + 'unrelated to the previous discussion. This is a clear topic change.\n\n' + - JSON.stringify({ shouldSuggestNewSession: true, confidence: 0.98 }), + JSON.stringify({ suggestion: 'new_session', confidence: 0.98 }), ); expect(testState.generateContent).toHaveBeenCalledOnce(); expect(latestSuggestion).toEqual({ - isVisible: true, + suggestion: 'new_session', classifiedInput: NEW_TASK_DRAFT, + sourceSessionId: 'session-1', }); }); it('recovers a positive decision inside a code fence', async () => { await classify( '```json\n' + - JSON.stringify({ shouldSuggestNewSession: true, confidence: 0.95 }) + + JSON.stringify({ suggestion: 'new_session', confidence: 0.95 }) + '\n```', ); expect(latestSuggestion).toEqual({ - isVisible: true, + suggestion: 'new_session', classifiedInput: NEW_TASK_DRAFT, + sourceSessionId: 'session-1', }); }); - it('keeps the banner hidden for a prose-wrapped negative decision', async () => { + it('keeps the banner hidden for a valid none decision', async () => { await classify( 'This is a follow-up on the same topic.\n\n' + - JSON.stringify({ shouldSuggestNewSession: false, confidence: 0.97 }), + JSON.stringify({ suggestion: 'none', confidence: 0.97 }), ); expect(testState.generateContent).toHaveBeenCalledOnce(); expect(latestSuggestion).toBeNull(); }); + it('suggests BTW for a side question without attachments', async () => { + await classify(JSON.stringify({ suggestion: 'btw', confidence: 0.92 })); + + expect(latestSuggestion).toEqual({ + suggestion: 'btw', + classifiedInput: NEW_TASK_DRAFT, + sourceSessionId: 'session-1', + }); + }); + + it.each([true, null])( + 'does not suggest BTW when attachment presence is %s', + async (hasAttachments) => { + testState.hasAttachments = hasAttachments; + await classify(JSON.stringify({ suggestion: 'btw', confidence: 0.92 })); + + expect(latestSuggestion).toBeNull(); + }, + ); + + it.each([ + JSON.stringify({ shouldSuggestNewSession: true, confidence: 0.98 }), + JSON.stringify({ suggestion: 'later', confidence: 0.98 }), + JSON.stringify({ suggestion: 'btw', confidence: 1.1 }), + ])('stays fail-closed for an invalid decision: %s', async (decision) => { + await classify(decision); + + expect(latestSuggestion).toBeNull(); + }); + it('stays fail-closed on prose with no recoverable JSON object', async () => { await classify( 'I think {this draft} switches topics, but here is no JSON to parse.', diff --git a/packages/web-shell/client/hooks/useNewSessionSuggestion.ts b/packages/web-shell/client/hooks/useNewSessionSuggestion.ts index 5e538b8cff..f7344b48e4 100644 --- a/packages/web-shell/client/hooks/useNewSessionSuggestion.ts +++ b/packages/web-shell/client/hooks/useNewSessionSuggestion.ts @@ -3,6 +3,7 @@ import type { Message } from '../adapters/types'; import type { DaemonSessionActions } from '@qwen-code/webui/daemon-react-sdk'; const MIN_PROMPT_LENGTH = 12; +const MIN_BTW_MESSAGE_COUNT = 2; const MIN_MESSAGE_COUNT = 8; const MIN_CONTEXT_USAGE_RATIO = 0.35; const MIN_EXPLICIT_CUE_MESSAGE_COUNT = 2; @@ -50,14 +51,17 @@ const EXPLICIT_NEW_TASK_PATTERNS = [ /brainstorm/i, ]; -interface TopicShiftDecision { - shouldSuggestNewSession: boolean; +type SuggestionKind = 'btw' | 'new_session' | 'none'; + +interface ComposerSuggestionDecision { + suggestion: SuggestionKind; confidence: number; } export interface NewSessionSuggestionState { - isVisible: boolean; + suggestion: Exclude; classifiedInput: string; + sourceSessionId: string; } export interface UseNewSessionSuggestionOptions { @@ -68,6 +72,7 @@ export interface UseNewSessionSuggestionOptions { contextUsageRatio: number; isRunning: boolean; dialogOpen: boolean; + hasAttachments: boolean | null; generateContent?: DaemonSessionActions['generateSessionContent']; } @@ -114,16 +119,20 @@ function buildPrompt(params: { currentInput: string; contextUsageRatio: number; messageCount: number; + allowBtw: boolean; + allowNewSession: boolean; }): string { const recent = params.recentMessages .map((message, index) => `${index + 1}. ${message.role}: ${message.text}`) .join('\n'); return [ - "You are deciding whether a user's new message still belongs in the current coding session.", - 'Suggest starting a new session only when the new message is clearly a different task or topic, and continuing in the current session would likely add context noise or wasted token usage.', - 'Be conservative. When in doubt, keep the current session.', - 'Do NOT suggest a new session for follow-up questions, implementation continuations, debugging iterations, review follow-ups, or adjacent design discussion about the same repo, PR, bug, or feature.', - 'Return JSON only with keys: shouldSuggestNewSession (boolean) and confidence (0-1 number).', + "You are deciding how a user's new message should be handled in the current coding session.", + 'Choose "new_session" only when the message is clearly a different task or topic and continuing here would add context noise.', + 'Choose "btw" only for a brief side question that can be answered without changing the main task or adding its answer to the main conversation context.', + 'Choose "none" for follow-ups, implementation continuations, debugging iterations, review follow-ups, and adjacent discussion about the same repo, PR, bug, or feature.', + 'Be conservative. When in doubt, choose "none".', + `Allowed actions: btw=${params.allowBtw ? 'yes' : 'no'}, new_session=${params.allowNewSession ? 'yes' : 'no'}. Never choose an action marked no.`, + 'Return JSON only with keys: suggestion ("btw", "new_session", or "none") and confidence (0-1 number).', '', `Context usage ratio: ${params.contextUsageRatio.toFixed(2)}`, `Visible message count: ${params.messageCount}`, @@ -136,13 +145,26 @@ function buildPrompt(params: { ].join('\n'); } -function tryParseDecision(text: string): TopicShiftDecision | null { +function tryParseDecision(text: string): ComposerSuggestionDecision | null { try { - const parsed = JSON.parse(text) as Partial; - if (typeof parsed.shouldSuggestNewSession !== 'boolean') return null; - if (typeof parsed.confidence !== 'number') return null; + const parsed = JSON.parse(text) as Partial; + if ( + parsed.suggestion !== 'btw' && + parsed.suggestion !== 'new_session' && + parsed.suggestion !== 'none' + ) { + return null; + } + if ( + typeof parsed.confidence !== 'number' || + !Number.isFinite(parsed.confidence) || + parsed.confidence < 0 || + parsed.confidence > 1 + ) { + return null; + } return { - shouldSuggestNewSession: parsed.shouldSuggestNewSession, + suggestion: parsed.suggestion, confidence: parsed.confidence, }; } catch { @@ -150,7 +172,7 @@ function tryParseDecision(text: string): TopicShiftDecision | null { } } -function parseDecision(text: string): TopicShiftDecision | null { +function parseDecision(text: string): ComposerSuggestionDecision | null { const direct = tryParseDecision(text); if (direct) return direct; // Despite the JSON-only instruction, the model sometimes wraps a perfectly @@ -176,6 +198,7 @@ export function useNewSessionSuggestion({ contextUsageRatio, isRunning, dialogOpen, + hasAttachments, generateContent, }: UseNewSessionSuggestionOptions): UseNewSessionSuggestionReturn { const [suggestion, setSuggestion] = @@ -226,22 +249,22 @@ export function useNewSessionSuggestion({ setSuggestion(null); return; } - if (isFollowupLike(trimmed)) { - setSuggestion(null); - return; - } const explicitNewTaskCue = hasExplicitNewTaskCue(trimmed); if (isRunning || dialogOpen) { setSuggestion(null); return; } - if ( - explicitNewTaskCue - ? recentMessages.length < MIN_EXPLICIT_CUE_MESSAGE_COUNT && - contextUsageRatio < MIN_EXPLICIT_CUE_CONTEXT_USAGE_RATIO - : recentMessages.length < MIN_MESSAGE_COUNT && - contextUsageRatio < MIN_CONTEXT_USAGE_RATIO - ) { + const allowBtw = + hasAttachments === false && + recentMessages.length >= MIN_BTW_MESSAGE_COUNT; + const allowNewSession = + !isFollowupLike(trimmed) && + (explicitNewTaskCue + ? recentMessages.length >= MIN_EXPLICIT_CUE_MESSAGE_COUNT || + contextUsageRatio >= MIN_EXPLICIT_CUE_CONTEXT_USAGE_RATIO + : recentMessages.length >= MIN_MESSAGE_COUNT || + contextUsageRatio >= MIN_CONTEXT_USAGE_RATIO); + if (!allowBtw && !allowNewSession) { setSuggestion(null); return; } @@ -259,6 +282,8 @@ export function useNewSessionSuggestion({ currentInput: trimmed, contextUsageRatio, messageCount: recentMessages.length, + allowBtw, + allowNewSession, }); void (async () => { let text = ''; @@ -280,10 +305,16 @@ export function useNewSessionSuggestion({ const decision = parseDecision(text.trim()); if ( decision && - decision.shouldSuggestNewSession && + decision.suggestion !== 'none' && + ((decision.suggestion === 'btw' && allowBtw) || + (decision.suggestion === 'new_session' && allowNewSession)) && decision.confidence >= MIN_CONFIDENCE ) { - setSuggestion({ isVisible: true, classifiedInput: trimmed }); + setSuggestion({ + suggestion: decision.suggestion, + classifiedInput: trimmed, + sourceSessionId: sessionId, + }); return; } setSuggestion(null); @@ -308,6 +339,7 @@ export function useNewSessionSuggestion({ dialogOpen, enabled, generateContent, + hasAttachments, inputText, isRunning, recentMessages, diff --git a/packages/web-shell/client/i18n.tsx b/packages/web-shell/client/i18n.tsx index 2294fac0c3..17542b25e8 100644 --- a/packages/web-shell/client/i18n.tsx +++ b/packages/web-shell/client/i18n.tsx @@ -881,6 +881,8 @@ const EN: Messages = { 'editor.searchPlaceholder': 'type to search...', 'editor.newSessionSuggestionTitle': 'This looks like a new topic', 'editor.newSessionSuggestionStart': 'Send in new session', + 'editor.btwSuggestionTitle': 'This looks like a side question', + 'editor.btwSuggestionSend': 'Ask with BTW', 'quickActions.open': 'more actions', 'quickActions.title': 'more actions', 'quickActions.mcp': 'MCP', @@ -3353,6 +3355,8 @@ const ZH: Messages = { 'editor.searchPlaceholder': '输入以搜索…', 'editor.newSessionSuggestionTitle': '这条消息看起来像新话题', 'editor.newSessionSuggestionStart': '在新会话发送', + 'editor.btwSuggestionTitle': '这条消息看起来像顺带一问', + 'editor.btwSuggestionSend': '用 BTW 提问', 'quickActions.open': '更多操作', 'quickActions.title': '更多操作', 'quickActions.mcp': 'MCP', From bbaf13d4563cd6f2cedf91481a6a230e907fc1be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=93=E8=89=AF?= <1204183885@qq.com> Date: Wed, 29 Jul 2026 00:58:28 +0800 Subject: [PATCH 3/7] fix(release): pin channel-base dep to exact version during release bump (#7953) The channel adapters depend on @qwen-code/channel-base via a caret range (^0.21.0). A prerelease bump such as 0.21.1-preview.0 does not satisfy that range, so the per-workspace npm version reifies replaced the workspace link with the stale registry package, and the release build compiled the adapters against outdated types (missing PollingChannelBase), failing the publish job. Pin the adapters' channel-base dependency to the exact new version during scripts/version.js, refresh the install, and drop the stale nested copies npm leaves under the adapters. --- scripts/version.js | 48 ++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 42 insertions(+), 6 deletions(-) diff --git a/scripts/version.js b/scripts/version.js index f19f4670c4..964157b20c 100644 --- a/scripts/version.js +++ b/scripts/version.js @@ -5,8 +5,14 @@ */ import { execSync } from 'node:child_process'; -import { readFileSync, writeFileSync } from 'node:fs'; -import { resolve } from 'node:path'; +import { + existsSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { join, resolve } from 'node:path'; // A script to handle versioning and ensure all related changes are in a single, atomic commit. @@ -104,12 +110,42 @@ if (cliPackageJson.config?.sandboxImageUri) { writeJson(cliPackageJsonPath, cliPackageJson); } -// 7. Run `npm install` to update package-lock.json. +// 7. Pin channel adapters' semver dependency on @qwen-code/channel-base to +// the exact new version. A caret range like ^0.21.0 does not match a +// prerelease bump (e.g. 0.21.1-preview.0), so npm would replace the workspace +// link with the stale registry package and the release build would compile +// against outdated types. +const channelsDir = resolve(process.cwd(), 'packages/channels'); +for (const entry of readdirSync(channelsDir)) { + const pkgPath = join(channelsDir, entry, 'package.json'); + if (!existsSync(pkgPath)) continue; + const pkg = readJson(pkgPath); + const dep = pkg.dependencies?.['@qwen-code/channel-base']; + if (dep && !dep.startsWith('file:')) { + pkg.dependencies['@qwen-code/channel-base'] = newVersion; + writeJson(pkgPath, pkg); + console.log( + `Pinned @qwen-code/channel-base to ${newVersion} in ${pkg.name}`, + ); + } +} + +// 8. Refresh node_modules and package-lock.json against the pinned exact +// versions so the adapters resolve channel-base to the workspace link again. // --ignore-scripts prevents the root `prepare` lifecycle from triggering a // redundant full build that fails with TS5055 when dist/ already exists from // the initial `npm ci` install. -run( - 'npm install --workspace packages/cli --workspace packages/core --workspace packages/channels/base --workspace packages/channels/plugin-example --package-lock-only --ignore-scripts', -); +run('npm install --ignore-scripts'); + +// 9. The per-workspace `npm version` reifies above nested a stale registry +// copy of channel-base under each adapter while ranges briefly mismatched. +// The install above cleans both lockfiles but can leave that directory on +// disk, where it shadows the workspace link during tsc. Remove it. +for (const entry of readdirSync(channelsDir)) { + rmSync(join(channelsDir, entry, 'node_modules', '@qwen-code'), { + recursive: true, + force: true, + }); +} console.log(`Successfully bumped versions to v${newVersion}.`); From 25e357a2794be0b281d4716d3772a8fa6bbd933b Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Wed, 29 Jul 2026 01:13:23 +0800 Subject: [PATCH 4/7] feat(triage): make the verify report readable in Chinese (#7918) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(triage): make the verify report readable in Chinese The scope disclaimer under the verify headline has been bilingual since the lane shipped. The verdict above it was not, and neither was the assertion count — so a Chinese reader of the unfolded comment got the caveat ("advisory evidence, not a review") and not the conclusion. Three changes, all on the parts a reader reaches first: - every headline carries a Chinese twin, across all eight arms — merge-ready / findings / blocked / inconclusive from the agent, and completed / fail / timeout / infra-error from the process outcome; - the assertion count renders in both languages, off the same validated object, so an inconsistent assertions.json still suppresses both and the comment cannot grow a number no gate checked; - in the report itself, 中文摘要 moves from last item to second, right after the verdict. The whole report is already inside a
on the PR, so leaving the Chinese summary at the bottom meant expanding that fold and scrolling the entire English report — about 90 lines on a real one — to reach the one section written for that reader. It stays collapsed, so it costs everyone else exactly one line. The headline test pins the pairing as a bijection, not as containment: asserting only that each arm renders some Chinese leaves a single hardcoded string — or one that echoes the English — passing every arm. Mutation-verified 4/4: dropping the Chinese headline, collapsing all arms onto one Chinese string, dropping the Chinese assertion sentence, and moving 中文摘要 back to the end each turn one test red. * fix(ci): correct cross-reference direction and cover all verdict branches in bilingual headline test (#7918) * fix(ci): narrow bilingual headline comment to match actual scope (#7918) Co-authored-by: Qwen-Coder --------- Co-authored-by: wenshao Co-authored-by: qwen-code-dev-bot Co-authored-by: qwen-code-ci-bot Co-authored-by: Qwen-Coder --- .github/workflows/qwen-triage.yml | 28 +++-- .qwen/skills/verify-pr/SKILL.md | 20 ++-- scripts/tests/qwen-triage-workflow.test.js | 113 +++++++++++++++++++++ 3 files changed, 143 insertions(+), 18 deletions(-) diff --git a/.github/workflows/qwen-triage.yml b/.github/workflows/qwen-triage.yml index 8b29da3ed8..1aae5f8614 100644 --- a/.github/workflows/qwen-triage.yml +++ b/.github/workflows/qwen-triage.yml @@ -3194,7 +3194,7 @@ jobs: and ([.pass, .fail, .total] | all(type == "number" and . >= 0 and . == floor)) and (.total > 0) and (.total == .pass + .fail) - then "Scripted assertions: \(.pass) passed · \(.fail) failed · \(.total) total" + then "Scripted assertions: \(.pass) passed · \(.fail) failed · \(.total) total\n\n脚本断言:\(.pass) 通过 · \(.fail) 失败 · \(.total) 总计" else empty end' "$ASSERTIONS_FILE" 2>/dev/null || true )" if [ -z "$ASSERT_LINE" ]; then @@ -3216,20 +3216,25 @@ jobs: findings|blocked|inconclusive) TRUST_AGENT_VERDICT=true ;; esac fi + # The verdict headline carries its Chinese twin. The verdict is the one + # line a reader acts on, and until now it was the only part of + # the unfolded comment that was English-only — the scope + # disclaimer below it has been bilingual all along, so a Chinese + # reader got the caveat and not the conclusion. if [ "$TRUST_AGENT_VERDICT" = true ]; then case "$AGENT_VERDICT" in - merge-ready) HEADLINE='merge-ready (agent verdict)' ;; - findings) HEADLINE='findings reported (agent verdict)' ;; - blocked) HEADLINE='blocked (agent verdict)' ;; - inconclusive) HEADLINE='inconclusive (agent verdict)' ;; + merge-ready) HEADLINE='merge-ready (agent verdict)'; HEADLINE_ZH='可合入(agent 判定)' ;; + findings) HEADLINE='findings reported (agent verdict)'; HEADLINE_ZH='报告了发现(agent 判定)' ;; + blocked) HEADLINE='blocked (agent verdict)'; HEADLINE_ZH='阻塞(agent 判定)' ;; + inconclusive) HEADLINE='inconclusive (agent verdict)'; HEADLINE_ZH='结论不足(agent 判定)' ;; esac else case "${VERDICT:-}" in - pass) HEADLINE='completed (no usable structured verdict)' ;; - fail) HEADLINE='agent run failed' ;; - timeout) HEADLINE='timeout — partial evidence' ;; - infra-error) HEADLINE='infra-error (crash, OOM, or unwritable results)' ;; - *) HEADLINE='unknown' ;; + pass) HEADLINE='completed (no usable structured verdict)'; HEADLINE_ZH='已完成(无可用的结构化判定)' ;; + fail) HEADLINE='agent run failed'; HEADLINE_ZH='agent 运行失败' ;; + timeout) HEADLINE='timeout — partial evidence'; HEADLINE_ZH='超时——证据不完整' ;; + infra-error) HEADLINE='infra-error (crash, OOM, or unwritable results)'; HEADLINE_ZH='基础设施故障(崩溃、OOM 或结果不可写)' ;; + *) HEADLINE='unknown'; HEADLINE_ZH='未知' ;; esac if [ -n "${AGENT_VERDICT:-}" ]; then echo "::warning::Agent wrote verdict '${AGENT_VERDICT}' but the run did not complete cleanly (process verdict '${VERDICT:-}'); reporting the process outcome instead." @@ -3255,7 +3260,8 @@ jobs: printf '%s\n' '' fi printf '\n' - printf '**Sandboxed verification: %s** - [workflow run](%s)\n\n' "$HEADLINE" "$RUN_URL" + printf '**Sandboxed verification: %s** - [workflow run](%s)\n' "$HEADLINE" "$RUN_URL" + printf '**沙箱验证:%s**\n\n' "${HEADLINE_ZH:-$HEADLINE}" if [ "${VERDICT:-}" = 'pass' ]; then printf '%s\n\n' "$SCOPE_EN" printf '%s\n\n' "$SCOPE_ZH" diff --git a/.qwen/skills/verify-pr/SKILL.md b/.qwen/skills/verify-pr/SKILL.md index 93503cc1d5..e5c4ac42ee 100644 --- a/.qwen/skills/verify-pr/SKILL.md +++ b/.qwen/skills/verify-pr/SKILL.md @@ -347,25 +347,31 @@ central claim from being tested — say why. 1. **Verdict line first**, with assertion totals and the verified head OID (`git rev-parse HEAD^2` — not the snapshot's, which may have drifted). -2. **Central claim + A/B table** (cells, oracles, head vs control counts). -3. **Corrections**, when an earlier review round or bot comment described +2. **中文摘要** in a collapsed `
` block, **immediately after the + verdict**: verdict, A/B 结论, findings, 未覆盖范围. Collapsed, so it costs a + reader who does not want it exactly one line; placed here rather than at + the end, because the whole report is already inside a `
` on the + PR — burying the Chinese summary under it made a Chinese reader expand a + fold and scroll the entire report to reach the one section written for + them. Cite the tables below by name instead of restating their numbers in + prose: a number written twice is a number that can disagree with itself. +3. **Central claim + A/B table** (cells, oracles, head vs control counts). +4. **Corrections**, when an earlier review round or bot comment described the code inaccurately (a wrong ARIA role, a wrong mechanism, a misattributed cause). State the correct fact with its evidence and label it explicitly as a correction to the description — not as a request to change the code. Leaving a wrong description standing costs the next reader more than the original finding did. -4. **Findings**, ordered by severity, each with the exact reproducing +5. **Findings**, ordered by severity, each with the exact reproducing command; for a blocker, enumerate the blast radius (the affected call sites, not just the one you hit), demonstrate the sharpest consequence end-to-end when budget allows, and where the cause is clear add a collapsed minimal suggested fix that preserves the original commit's intent. -5. **Not covered** — every claim, surface, or gate you skipped. A silent cap +6. **Not covered** — every claim, surface, or gate you skipped. A silent cap reads as "covered everything"; never allow that. -6. **Methodology** — one paragraph: environment, how each harness drove the +7. **Methodology** — one paragraph: environment, how each harness drove the code, where the raw logs live. -7. **中文摘要** in a collapsed `
` block: verdict, A/B 结论, findings, - 未覆盖范围. ## Hard rules diff --git a/scripts/tests/qwen-triage-workflow.test.js b/scripts/tests/qwen-triage-workflow.test.js index ba7cd32efe..4732d98fce 100644 --- a/scripts/tests/qwen-triage-workflow.test.js +++ b/scripts/tests/qwen-triage-workflow.test.js @@ -21,6 +21,7 @@ const prSkill = readFileSync( '.qwen/skills/triage/references/pr-workflow.md', 'utf8', ); +const verifySkill = readFileSync('.qwen/skills/verify-pr/SKILL.md', 'utf8'); function escapeRegExp(value) { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); @@ -1535,6 +1536,24 @@ describe('qwen-triage verify hardening round 2', () => { } }); + // The whole report is already inside a
on the PR. With the + // Chinese summary as the last item, reaching it meant expanding that fold + // and scrolling the entire English report — ~90 lines on a real one. + it('puts the report Chinese summary next to the verdict, not last', () => { + const struct = verifySkill.slice( + verifySkill.indexOf('### report.md structure'), + verifySkill.indexOf('## Hard rules'), + ); + expect(struct).toBeTruthy(); + const zh = struct.indexOf('中文摘要'); + expect(zh).toBeGreaterThan(-1); + expect(zh).toBeLessThan(struct.indexOf('Central claim + A/B table')); + expect(zh).toBeLessThan(struct.indexOf('**Not covered**')); + expect(zh).toBeLessThan(struct.indexOf('**Methodology**')); + // Moved, not duplicated — two summaries would drift apart. + expect(struct.match(/中文摘要/g)?.length).toBe(1); + }); + // Only a validated assertions object counts as evidence. it('rejects inconsistent assertions objects', () => { const publishStep = step('Post verification report comment'); @@ -1695,6 +1714,100 @@ describe('qwen-triage verify publish fidelity', () => { // Only bodies carrying findings are substantive; weak notices must not be // snapshotted as the previous round's report. + // The scope disclaimer under the headline has been bilingual since the + // lane shipped, but the verdict itself — the one line a reader acts on — + // was English-only. A Chinese reader got the caveat and not the + // conclusion. + it('renders every verdict headline in both languages', () => { + const dir = fixture(); + try { + const ARMS = [ + [ + { VERDICT: 'pass', AGENT_VERDICT: 'merge-ready' }, + 'merge-ready (agent verdict)', + '可合入(agent 判定)', + ], + [ + { VERDICT: 'pass', AGENT_VERDICT: 'findings' }, + 'findings reported (agent verdict)', + '报告了发现(agent 判定)', + ], + [ + { VERDICT: 'pass', AGENT_VERDICT: 'blocked' }, + 'blocked (agent verdict)', + '阻塞(agent 判定)', + ], + [ + { VERDICT: 'pass', AGENT_VERDICT: 'inconclusive' }, + 'inconclusive (agent verdict)', + '结论不足(agent 判定)', + ], + [ + { VERDICT: 'pass' }, + 'completed (no usable structured verdict)', + '已完成(无可用的结构化判定)', + ], + [{ VERDICT: 'fail' }, 'agent run failed', 'agent 运行失败'], + [ + { VERDICT: 'timeout' }, + 'timeout — partial evidence', + '超时——证据不完整', + ], + [ + { VERDICT: 'infra-error' }, + 'infra-error (crash, OOM, or unwritable results)', + '基础设施故障(崩溃、OOM 或结果不可写)', + ], + [{ VERDICT: 'bogus' }, 'unknown', '未知'], + ]; + const seenZh = new Set(); + ARMS.forEach(([env, en, zh], i) => { + const body = render(dir, { NAME: `hl${i}`, AGENT_VERDICT: '', ...env }); + expect(body).toContain(`**Sandboxed verification: ${en}**`); + expect(body).toContain(`**沙箱验证:${zh}**`); + seenZh.add(zh); + }); + // Distinct per arm. A single hardcoded Chinese string — or one that + // renders the English text twice — satisfies a per-arm containment + // check, so the pairing has to be pinned as a bijection. + expect(seenZh.size).toBe(ARMS.length); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('renders the assertion count in both languages', () => { + const dir = fixture(); + try { + const body = render(dir, { + NAME: 'assertzh', + VERDICT: 'pass', + AGENT_VERDICT: 'merge-ready', + }); + expect(body).toContain( + 'Scripted assertions: 10 passed · 0 failed · 10 total', + ); + expect(body).toContain('脚本断言:10 通过 · 0 失败 · 10 总计'); + + // The Chinese line rides the same validated object as the English one: + // an inconsistent assertions.json must suppress BOTH, or the comment + // grows a number that no gate checked. + writeFileSync( + join(dir, 'work', 'verify-results', 'prA-verify-1', 'assertions.json'), + '{"pass":1,"fail":0,"total":0}', + ); + const bad = render(dir, { + NAME: 'assertbad', + VERDICT: 'pass', + AGENT_VERDICT: 'merge-ready', + }); + expect(bad).not.toContain('Scripted assertions:'); + expect(bad).not.toContain('脚本断言'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + it('marks only finding-bearing bodies as substantive', () => { const dir = fixture(); const M = 'qwen-triage:verify-substantive'; From ddf4b8875d7e64e35bb0e7516bc27515ed120c53 Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Wed, 29 Jul 2026 01:13:40 +0800 Subject: [PATCH 5/7] fix(triage): make the build-process guard diagnosable and zombie-aware (#7858) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(triage): make the build-process guard diagnosable and zombie-aware The first real /verify run to reach the agent step (job 30267953352) was stopped by the guard I added to stop detached lifecycle children from outliving their step: ::error::Processes owned by the build user survived; refusing to start the agent. That is all it printed. No pid, no state, no command line — so a genuine threat and a harmless leftover were indistinguishable, including to the person who wrote it. The control had no observability of its own. Two changes, both in the verify and tmux lanes: - name the survivors. The error now lists each one as pid, state and command line, so the next occurrence can actually be diagnosed. - disregard zombies. A zombie has already exited and released everything except its exit status: it cannot re-plant an artifact or touch the agent's inputs, and it cannot be killed either — so counting one means the check can never clear, no matter how many times SIGKILL is sent. The container runs no init to reap orphans, so they are expected here. The platform difference is the reason the old form could hang: Linux pgrep reports defunct processes ("Defunct processes are reported." — pgrep(1), procps-ng), while macOS pgrep does not list them at all (verified directly: ps shows our zombie, pgrep does not). So this failure mode was unreachable in local replay and only appears on the runner. That same difference shapes the tests. The behavioural arm constructs a real zombie, confirms it survives SIGKILL, and shows the state filter drops it — but it cannot discriminate the old implementation from the new one on macOS, because pgrep never saw the zombie there in the first place. So the portable guarantee is asserted structurally: the filter must read process state and exclude Z, and must not be a bare pgrep. Mutation- verified 3/3 — removing the state filter, dropping the survivor names, or reverting the message each turn one test red. Also gives both proxy-watchdog tests an explicit timeout. They stream 20 chunks at 200 ms — 4 s before the stall arm even starts — so they cannot fit vitest's 5 s default and were timing out on main. * fix(triage): tolerate zero-process exit in build guard (#7858) --------- Co-authored-by: wenshao Co-authored-by: Qwen Code Autofix Co-authored-by: qwen-code-dev-bot --- .github/workflows/qwen-triage.yml | 44 ++++++- scripts/tests/qwen-triage-workflow.test.js | 134 ++++++++++++++++++++- 2 files changed, 167 insertions(+), 11 deletions(-) diff --git a/.github/workflows/qwen-triage.yml b/.github/workflows/qwen-triage.yml index 1aae5f8614..3e2b5a69dc 100644 --- a/.github/workflows/qwen-triage.yml +++ b/.github/workflows/qwen-triage.yml @@ -1112,14 +1112,30 @@ jobs: # not racing a live process. This also removes the localhost blind # scan surface the bearer-gated proxy defends against. The proxy # started further below runs as root, so this cannot touch it. + # Zombies do not count. A zombie has already exited and released + # everything except its exit status, so it cannot re-plant an + # artifact or touch the agent's inputs — and it cannot be killed + # either, so counting it means this check can never clear. The + # container runs no init to reap orphans, so they are expected. + live_build_processes() { + ps -o pid=,stat=,args= -u node 2>/dev/null | awk '$2 !~ /^Z/' + } pkill -KILL -u node 2>/dev/null || true for _ in 1 2 3; do - pgrep -u node >/dev/null 2>&1 || break + [ -n "$(live_build_processes)" ] || break sleep 1 pkill -KILL -u node 2>/dev/null || true done - if pgrep -u node >/dev/null 2>&1; then - echo "::error::Processes owned by the build user survived; refusing to start the agent." + survivors="$(live_build_processes)" || true + if [ -n "$survivors" ]; then + # Name them. The first version of this guard failed the job with + # nothing but "processes survived", so a real threat and a + # harmless leftover were indistinguishable — including to the + # person who wrote it. + echo "::error::Processes owned by the build user survived SIGKILL; refusing to start the agent." + printf '%s\n' "$survivors" | while IFS= read -r proc; do + echo "::error:: surviving process: ${proc}" + done exit 1 fi @@ -2329,14 +2345,30 @@ jobs: # child can outlive its step, wait for the sweeps below, and then # re-plant artifacts or tamper with the agent's inputs. Without # this, every one-shot cleanup here is racing a live process. + # Zombies do not count. A zombie has already exited and released + # everything except its exit status, so it cannot re-plant an + # artifact or touch the agent's inputs — and it cannot be killed + # either, so counting it means this check can never clear. The + # container runs no init to reap orphans, so they are expected. + live_build_processes() { + ps -o pid=,stat=,args= -u node 2>/dev/null | awk '$2 !~ /^Z/' + } pkill -KILL -u node 2>/dev/null || true for _ in 1 2 3; do - pgrep -u node >/dev/null 2>&1 || break + [ -n "$(live_build_processes)" ] || break sleep 1 pkill -KILL -u node 2>/dev/null || true done - if pgrep -u node >/dev/null 2>&1; then - echo "::error::Processes owned by the build user survived; refusing to start the agent." + survivors="$(live_build_processes)" || true + if [ -n "$survivors" ]; then + # Name them. The first version of this guard failed the job with + # nothing but "processes survived", so a real threat and a + # harmless leftover were indistinguishable — including to the + # person who wrote it. + echo "::error::Processes owned by the build user survived SIGKILL; refusing to start the agent." + printf '%s\n' "$survivors" | while IFS= read -r proc; do + echo "::error:: surviving process: ${proc}" + done exit 1 fi diff --git a/scripts/tests/qwen-triage-workflow.test.js b/scripts/tests/qwen-triage-workflow.test.js index 4732d98fce..10f63b1469 100644 --- a/scripts/tests/qwen-triage-workflow.test.js +++ b/scripts/tests/qwen-triage-workflow.test.js @@ -1257,8 +1257,9 @@ describe('qwen-triage verify hardening round 2', () => { expect(chown).toBeGreaterThan(repin); expect(launch).toBeGreaterThan(home); // Killing is not enough on its own: surviving build processes must - // fail the step rather than race the sweeps that follow. - expect(runStep).toContain('pgrep -u node'); + // fail the step rather than race the sweeps that follow. The check + // disregards zombies — see the build-process-guard suite for why. + expect(runStep).toContain('live_build_processes'); expect(runStep).toContain('refusing to start the agent'); expect(runStep).toContain('"HOME=$AGENT_HOME"'); // The proxy must require this run's bearer, not just a fixed dummy key. @@ -2555,7 +2556,9 @@ describe('qwen-triage verify maintainer-review round', () => { // A mid-body stall closes the response (curl 18), not a hang until the // client's own timeout (curl 28). expect(out).toContain('stall_exit=18'); - }); + // 20 chunks x 200 ms is 4 s before the stall arm even starts, so this + // cannot fit vitest's 5 s default. It was timing out on main. + }, 30000); // GitHub cancels the OLDER pending run in a concurrency group, so the // requester's own /verify proceeds — the earlier "queued behind other @@ -2828,7 +2831,9 @@ describe('qwen-triage tmux lane parity', () => { const out = runProxyWatchdogTest(proxy); expect(out).toContain('chunks=20'); expect(out).toContain('stall_exit=18'); - }); + // Same reason as its verify-lane twin: the stream alone outlasts the + // 5 s default. + }, 30000); // PR lifecycle scripts run before the agent and can plant a // tmp/-tmux-/ directory whose report.md and transcript the @@ -2934,7 +2939,7 @@ describe('qwen-triage tmux lane parity', () => { const runStep = stepIn('tmux-testing', 'Run tmux real-user testing'); expect(runStep).toContain('pkill -KILL -u node'); expect(runStep).toContain( - 'Processes owned by the build user survived; refusing to start the agent.', + 'Processes owned by the build user survived SIGKILL; refusing to start the agent.', ); // Before the sweep and the proxy: the cleanup must not race a live // process, and no leftover child may be alive when the proxy binds. @@ -3039,3 +3044,122 @@ describe('qwen-triage tmux lane parity', () => { expect(reportCap + transcriptCap + envelope).toBeLessThan(65536); }); }); + +describe('qwen-triage build-process guard', () => { + // The guard fired on a real run (job 30267953352) and failed the job with + // nothing but "processes survived" — no pid, no state, no command line. + // Nobody could tell a genuine leftover from a harmless one, including the + // person who wrote it. Both lanes now name what survived. + it('names the surviving processes instead of just refusing', () => { + for (const lane of ['verify', 'tmux-testing']) { + const runStep = stepIn( + lane, + lane === 'verify' + ? 'Run verification agent' + : 'Run tmux real-user testing', + ); + expect(runStep, `${lane} lost the guard`).toContain( + 'live_build_processes', + ); + expect(runStep).toContain('surviving process:'); + expect(runStep).toContain( + 'Processes owned by the build user survived SIGKILL; refusing to start the agent.', + ); + } + }); + + // `ps -u node` exits 1 when the user owns zero processes. Under + // `set -euo pipefail` the bare assignment would die silently on the + // success path — the `|| true` absorbs the no-match status. + it('"survivors" assignment tolerates zero processes under pipefail', () => { + for (const lane of ['verify', 'tmux-testing']) { + const runStep = stepIn( + lane, + lane === 'verify' + ? 'Run verification agent' + : 'Run tmux real-user testing', + ); + expect( + runStep, + `${lane}: survivors assignment must survive ps exit 1`, + ).toContain('survivors="$(live_build_processes)" || true'); + } + }); + + // A zombie cannot be killed and cannot execute anything, so counting one + // means this check can never clear. + // + // PLATFORM NOTE, and the reason this test is split in two: Linux pgrep + // reports defunct processes ("Defunct processes are reported." — pgrep(1), + // procps-ng), which is why the original `pgrep -u node` guard could hang + // on a zombie in CI. macOS pgrep does NOT list them, so the behavioural + // arm below cannot discriminate the old implementation from the new one + // here — verified directly: ps lists our zombie, pgrep does not. The + // structural assertion is therefore the one that holds on every platform. + it('excludes zombies from the surviving-process check', () => { + for (const lane of ['verify', 'tmux-testing']) { + const runStep = stepIn( + lane, + lane === 'verify' + ? 'Run verification agent' + : 'Run tmux real-user testing', + ); + const body = runStep + .match(/live_build_processes\(\) \{\n([\s\S]*?)\n\s*\}/)?.[1] + ?.trim(); + expect(body, `${lane}: no live_build_processes body`).toBeTruthy(); + // It must read process STATE and drop zombies. `pgrep` alone cannot: + // on Linux it reports defunct processes and offers no default filter. + expect(body, `${lane}: the filter must inspect process state`).toMatch( + /stat=/, + ); + expect(body, `${lane}: the filter must exclude zombies`).toMatch( + /\/\^Z\//, + ); + expect(body).not.toMatch(/^pgrep\b/); + } + }); + + // The OS property the exclusion rests on: a zombie survives SIGKILL and + // ps still lists it, so an unfiltered check would never clear. + it('confirms a zombie survives SIGKILL and stays visible to ps', () => { + const dir = mkdtempSync(join(tmpdir(), 'zombie-')); + try { + writeFileSync( + join(dir, 'mkzombie.py'), + [ + 'import os, time', + 'pid = os.fork()', + 'if pid == 0:', + ' os._exit(0)', + 'print(pid, flush=True)', + 'time.sleep(8)', + ].join('\n'), + ); + const driver = [ + 'set -u', + `python3 "$1/mkzombie.py" > "$1/zpid" &`, + 'PP=$!', + 'sleep 1', + 'Z="$(tr -d " \n" < "$1/zpid")"', + '[ -n "$Z" ] || { echo "no-zombie"; kill $PP 2>/dev/null; exit 0; }', + 'kill -9 "$Z" 2>/dev/null', + 'sleep 0.5', + 'echo "state=$(ps -o stat= -p "$Z" 2>/dev/null | tr -d " ")"', + 'echo "unfiltered=$(ps -o pid= -p "$Z" 2>/dev/null | wc -l | tr -d " ")"', + `echo "filtered=$(ps -o pid=,stat=,args= -p "$Z" 2>/dev/null | awk '$2 !~ /^Z/' | wc -l | tr -d ' ')"`, + 'kill $PP 2>/dev/null', + ].join('\n'); + const out = spawnSync('bash', ['-c', driver, '_', dir], { + encoding: 'utf8', + timeout: 30000, + }).stdout; + if (out.includes('no-zombie')) return; + expect(out).toMatch(/state=Z/); + expect(out).toContain('unfiltered=1'); + expect(out).toContain('filtered=0'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }, 30000); +}); From 5b6714b9fbef40d11d176588b4fe10278739c10d Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Wed, 29 Jul 2026 01:22:35 +0800 Subject: [PATCH 6/7] fix(ci): give each job its own proxy wrapper directory (#7951) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PR review job and the containerised triage jobs all built their gh/git proxy wrappers in one fixed directory under RUNNER_TEMP. On the shared self-hosted runner that path outlives a job, and the triage containers write it as root through the RUNNER_TEMP bind mount. Once that happened, the review job — running as the unprivileged runner user — could neither overwrite the wrapper nor remove the root-owned directory holding it, so every review scheduled on that runner died with EACCES while writing the wrapper, before the review itself had started. Each run now creates a private wrapper directory and removes it on exit, and the container jobs keep theirs on the container's own disk so they stop leaving root-owned state on the host. --- .github/workflows/qwen-code-pr-review.yml | 16 +++++++++++++--- .github/workflows/qwen-triage.yml | 18 ++++++++++++++---- 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/.github/workflows/qwen-code-pr-review.yml b/.github/workflows/qwen-code-pr-review.yml index 5adefcac6a..2d0950834b 100644 --- a/.github/workflows/qwen-code-pr-review.yml +++ b/.github/workflows/qwen-code-pr-review.yml @@ -500,7 +500,9 @@ jobs: REPO="${GITHUB_REPOSITORY}" REVIEW_URL="${GITHUB_SERVER_URL}/${REPO}/pull/${PR_NUMBER}" LOG_PATH="${RUNNER_TEMP:-/tmp}/qwen-review-pr-${PR_NUMBER}.jsonl" - trap 'rm -f "$LOG_PATH"' EXIT + # Set by configure_qwen_network once the wrapper dir exists. + PROXY_BIN="" + trap 'rm -f "$LOG_PATH"; [ -z "$PROXY_BIN" ] || rm -rf "$PROXY_BIN"' EXIT if [ -z "${GH_TOKEN:-}" ]; then fail "CI_BOT_PAT secret is required for Qwen PR review." @@ -535,8 +537,16 @@ jobs: export QWEN_CI_https_proxy="${https_proxy:-}" export QWEN_CI_HTTP_PROXY="${HTTP_PROXY:-}" export QWEN_CI_http_proxy="${http_proxy:-}" - proxy_bin="${RUNNER_TEMP:-/tmp}/qwen-network-bin" - mkdir -p "$proxy_bin" + # A fixed path is a landmine on the shared self-hosted runner: + # RUNNER_TEMP survives across jobs, and the triage workflow's + # containerised jobs write this same path as root through the + # RUNNER_TEMP bind mount. This job runs as the unprivileged runner + # user, so once that happens it can neither overwrite the wrapper + # nor remove the root-owned directory holding it, and every review + # landing on that runner dies here with EACCES. Use a private + # directory per run, cleaned up by the EXIT trap. + proxy_bin="$(mktemp -d "${RUNNER_TEMP:-/tmp}/qwen-network-bin.XXXXXX")" + PROXY_BIN="$proxy_bin" if command -v gh >/dev/null 2>&1; then local real_gh diff --git a/.github/workflows/qwen-triage.yml b/.github/workflows/qwen-triage.yml index 3e2b5a69dc..d98e3a60de 100644 --- a/.github/workflows/qwen-triage.yml +++ b/.github/workflows/qwen-triage.yml @@ -1178,8 +1178,13 @@ jobs: export QWEN_CI_https_proxy="${https_proxy:-}" export QWEN_CI_HTTP_PROXY="${HTTP_PROXY:-}" export QWEN_CI_http_proxy="${http_proxy:-}" - proxy_bin="${RUNNER_TEMP:-/tmp}/qwen-network-bin" - mkdir -p "$proxy_bin" + # This job runs in a container, and RUNNER_TEMP is bind-mounted + # from the self-hosted runner's host filesystem. Writing the + # wrappers there leaves root-owned files on the host that later + # non-container jobs (the PR review) can neither overwrite nor + # delete, breaking every review scheduled on that runner. Keep + # them on the container's own disk, under a per-run directory. + proxy_bin="$(mktemp -d /tmp/qwen-network-bin.XXXXXX)" if command -v gh >/dev/null 2>&1; then local real_gh @@ -2447,8 +2452,13 @@ jobs: export QWEN_CI_https_proxy="${https_proxy:-}" export QWEN_CI_HTTP_PROXY="${HTTP_PROXY:-}" export QWEN_CI_http_proxy="${http_proxy:-}" - proxy_bin="${RUNNER_TEMP:-/tmp}/qwen-network-bin" - mkdir -p "$proxy_bin" + # This job runs in a container, and RUNNER_TEMP is bind-mounted + # from the self-hosted runner's host filesystem. Writing the + # wrappers there leaves root-owned files on the host that later + # non-container jobs (the PR review) can neither overwrite nor + # delete, breaking every review scheduled on that runner. Keep + # them on the container's own disk, under a per-run directory. + proxy_bin="$(mktemp -d /tmp/qwen-network-bin.XXXXXX)" if command -v gh >/dev/null 2>&1; then local real_gh From 0c0ca5fed0e287b98d9be9e51d364d01be3d2041 Mon Sep 17 00:00:00 2001 From: qqqys Date: Wed, 29 Jul 2026 01:41:49 +0800 Subject: [PATCH 7/7] feat(autofix): defer suggestions after five change rounds (#7913) * feat(autofix): defer suggestions after five change rounds * test(autofix): execute deferred jq queries against fixture data (#7913) * fix(autofix): scrub control markers from deferred feedback reports (#7913) * test(autofix): execute actionable reviews and issue-level filters against fixtures (#7913) --------- Co-authored-by: qwen-code-dev-bot <269191875+qwen-code-dev-bot@users.noreply.github.com> Co-authored-by: qwen-code-ci-bot Co-authored-by: qwen-code-dev-bot --- .github/workflows/qwen-autofix.yml | 133 +++++- .qwen/skills/autofix/SKILL.md | 7 + scripts/tests/qwen-autofix-workflow.test.js | 448 ++++++++++++++++++-- 3 files changed, 545 insertions(+), 43 deletions(-) diff --git a/.github/workflows/qwen-autofix.yml b/.github/workflows/qwen-autofix.yml index 1a98a2e222..cc65d6dbb2 100644 --- a/.github/workflows/qwen-autofix.yml +++ b/.github/workflows/qwen-autofix.yml @@ -113,6 +113,12 @@ env: # exists to stop an unproductive LOOP, not to ration ordinary iteration — # a genuinely stuck PR still stops, just later. MAX_ROUNDS: '10' + # Suggestions may improve a young PR, but continuing to implement them after + # five change-producing rounds expands the diff and creates fresh review + # churn. From round 6 onward, only Critical findings, formally requested + # changes, failed checks, and base conflicts may drive code changes; + # lower-severity feedback is recorded and left open. + CRITICAL_ONLY_AFTER_ROUND: '5' # An auth/access model error (401/402/403, "no access"/"does not exist") # never self-heals - only a maintainer can fix the key - and every retry # costs an agent run AND a PR comment. Cap those attempts far below @@ -2259,15 +2265,11 @@ jobs: | select(((.author_association // "") | IN($trust[])) or (.user.login // "") == $rb) | select((.state // "") | IN("CHANGES_REQUESTED", "COMMENTED")) ] | length' \ "${WORKDIR}/rv.json")" - # Per AGENTS.md's review policy, Suggestion-level findings ARE - # addressed during a PR's first ~5 review rounds; only past that are - # they deferred (with a recorded reason). The loop's MAX_ROUNDS cap is - # that same boundary — every round the loop actually runs is within - # the address-Suggestions window — so /review `**[Suggestion]**` - # inline comments count as actionable feedback here. The agent's - # triage still decides implement-vs-defer per finding and records the - # decision in its summary comment, and the MAX_ROUNDS handoff is the - # defer-to-a-human boundary. + # Per AGENTS.md's review policy, Suggestion-level findings are + # actionable during the first five change-producing rounds. The + # scan still selects later feedback so prepare can record and + # watermark its deferral; only the address job filters what may + # drive code changes. N_COMMENTS="$(jq --arg wm "${EFF_WM}" --arg rb "${REVIEW_BOT}" --arg ab "${AUTOFIX_BOT}" \ --argjson trust "${TRUSTED_ASSOC}" ' [ .[] @@ -2817,9 +2819,76 @@ jobs: STALE='true' echo "⛔ live round ${ROUND} already at MAX_ROUNDS (${MAX_ROUNDS}) — discarding without action or marker" fi + CRITICAL_ONLY='false' + if [[ "${ROUND}" -ge "${CRITICAL_ONLY_AFTER_ROUND}" ]]; then + CRITICAL_ONLY='true' + fi echo "stale=${STALE}" >> "${GITHUB_OUTPUT}" echo "effective_round=${ROUND}" >> "${GITHUB_OUTPUT}" + rm -f "${WORKDIR}/deferred-feedback.md" + if [[ "${CRITICAL_ONLY}" == "true" ]]; then + PR_URL="https://github.com/${REPO}/pull/${PR}" + { + echo '## Deferred non-Critical feedback' + echo + echo "Critical-only mode is active after ${CRITICAL_ONLY_AFTER_ROUND} change-producing rounds. Any items listed below stay open for human follow-up; do not modify code, resolve threads, or reply on their behalf." + echo + jq -r --arg wm "${WATERMARK}" --arg rb "${REVIEW_BOT}" --arg ab "${AUTOFIX_BOT}" \ + --argjson trust "${TRUSTED_ASSOC}" --arg pr_url "${PR_URL}" ' + .[] + | select((.submitted_at // "") > $wm) + | select((.user.login // "") != $ab) + | select(((.author_association // "") | IN($trust[])) or (.user.login // "") == $rb) + | select((.state // "") == "COMMENTED") + | select(((.body // "") | contains("**[Critical]**")) | not) + | "- Review by @\(.user.login): \(.html_url // $pr_url)"' \ + "${WORKDIR}/rv.json" + jq -rs --arg wm "${WATERMARK}" --arg rb "${REVIEW_BOT}" --arg ab "${AUTOFIX_BOT}" \ + --argjson trust "${TRUSTED_ASSOC}" --arg pr_url "${PR_URL}" \ + --slurpfile reviews "${WORKDIR}/rv.json" ' + add as $comments + | ($reviews | add) as $reviews + | $comments[] + | select((.created_at // "") > $wm) + | select((.user.login // "") != $ab) + | select(((.author_association // "") | IN($trust[])) or (.user.login // "") == $rb) + | select(( + ((.body // "") | contains("**[Critical]**")) + or ((.in_reply_to_id // null) as $root + | $root != null + and any($comments[]; + .id == $root + and ((.body // "") | contains("**[Critical]**")))) + or ((.pull_request_review_id // null) as $review + | $review != null + and any($reviews[]; + .id == $review + and ((.state // "") == "CHANGES_REQUESTED"))) + ) | not) + | "- Inline rc:\(.id) \(.path // "?"):\(.line // "?"): \(.html_url // $pr_url)"' \ + "${WORKDIR}/rc.json" + jq -r --arg wm "${WATERMARK}" --arg rb "${REVIEW_BOT}" --arg ab "${AUTOFIX_BOT}" \ + --argjson trust "${TRUSTED_ASSOC}" --arg pr_url "${PR_URL}" ' + .[] + | select((.created_at // "") > $wm) + | select((.user.login // "") != $ab) + | select(((.author_association // "") | IN($trust[])) or (.user.login // "") == $rb) + | select((.body // "") | test(" sits on another line, while jq scan() matches across newlines. // Proven end-to-end on a split forged marker. @@ -4776,7 +5174,7 @@ describe('qwen-autofix workflow', () => { // backslashes — a NO-OP on both GNU and BSD sed, verified) left the count // at four and this test green, shipping an unescaped publish site. const escapeSites = workflow.match(/sed 's\/