From 35c18f71f003553c51f73d4e780ca9e0fec9cff0 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Wed, 9 Sep 2026 20:09:24 +0100 Subject: [PATCH 1/2] Detect entity-encoded placeholder anchors --- .../src/tests/guards/deadAnchors.spec.ts | 33 +++++++++++++++++-- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/frontend/taskdeck-web/src/tests/guards/deadAnchors.spec.ts b/frontend/taskdeck-web/src/tests/guards/deadAnchors.spec.ts index 1cb218dc9..217b031a3 100644 --- a/frontend/taskdeck-web/src/tests/guards/deadAnchors.spec.ts +++ b/frontend/taskdeck-web/src/tests/guards/deadAnchors.spec.ts @@ -19,8 +19,8 @@ import { baseParse, ElementTypes, NodeTypes, parserOptions } from '@vue/compiler * WHAT THIS DOES NOT MECHANIZE. The dogfooding pass that produced GH-1932 and * GH-1934 found dead affordances of several other shapes; this guard does not * cover them, and passing it is not evidence that they are gone: - * - HTML-entity or control-character obfuscation of a scheme - * (`href="javascript:..."`) is not decoded, so it is not detected. + * - Control characters inside a scheme are not normalized. Static href HTML + * entities are decoded once by Vue's parser; bound expressions remain raw. * - Runtime-assembled hrefs and dynamic event names are out of reach: this * guard reads SFC source, not Vue's rendered event table. * - Button action hidden behind a runtime-bound `:type` is not inferred. @@ -337,7 +337,8 @@ function markupOnly(source: string): string { /** True when any href on `tag` is the bare `#` placeholder, statically or inside a bound expression. */ function hasPlaceholderHref(tag: string): boolean { - for (const [, binding, , value] of tag.matchAll(HREF_ATTR)) { + for (const [, binding, quote, rawValue] of tag.matchAll(HREF_ATTR)) { + let value = rawValue // A bound href's value is a JS expression: a bare `#` literal anywhere in // it is the placeholder, however it is reached (`dead ?? '#'`). if (binding) { @@ -350,6 +351,14 @@ function hasPlaceholderHref(tag: string): boolean { if (JAVASCRIPT_SCHEME_LITERAL.test(value)) return true continue } + // Use the same attribute decoding as Vue, preserving the source delimiter. + // Parsing the isolated attribute also avoids interpreting decoded text as + // markup or recursively decoding a literal entity such as s. + const element = baseParse(``, parserOptions).children[0] + if (element?.type === NodeTypes.ELEMENT) { + const href = element.props[0] + if (href?.type === NodeTypes.ATTRIBUTE && href.value) value = href.value.content + } // A static href's value IS the URL. `#` alone is the placeholder; // `#section-id` is a real in-page target and must survive. if (value.trim() === '#') return true @@ -780,6 +789,24 @@ describe('dead affordances', () => { expect(findDeadAnchors('')).toHaveLength(1) }) + it('detects entity-encoded static placeholder hrefs without decoding twice', () => { + for (const href of [ + 'javascript:void(0)', + 'javascript:void(0)', + 'javascript:void(0)', + '#', + ]) { + const source = `` + expect(findDeadAnchors(source), href).toHaveLength(1) + } + for (const href of ['/search?q=a&b=c', '#details', 'javascript:void(0)']) { + expect(findDeadAnchors(``), href).toEqual([]) + } + expect( + findDeadAnchors(''), + ).toEqual([]) + }) + it('detects empty and javascript: placeholder hrefs', () => { // GH-1949 residuals. Both shapes were named as uncovered in this guard's // own header from 2026-08-23 until this slice; neither exists in src/. From a257f45248a64f7e1a2c88cbec33c2e399456efe Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Wed, 9 Sep 2026 21:07:14 +0100 Subject: [PATCH 2/2] Fail fast when reconciling a successful chat send --- frontend/taskdeck-web/src/api/chatApi.ts | 4 ++-- .../taskdeck-web/src/composables/useAutomationChat.ts | 4 +++- frontend/taskdeck-web/src/tests/api/chatApi.spec.ts | 8 ++++++++ .../src/tests/composables/useAutomationChat.spec.ts | 3 +++ .../src/tests/store/chatApi.integration.spec.ts | 4 ++-- 5 files changed, 18 insertions(+), 5 deletions(-) diff --git a/frontend/taskdeck-web/src/api/chatApi.ts b/frontend/taskdeck-web/src/api/chatApi.ts index f8399b387..0639acaa3 100644 --- a/frontend/taskdeck-web/src/api/chatApi.ts +++ b/frontend/taskdeck-web/src/api/chatApi.ts @@ -13,8 +13,8 @@ export const chatApi = { return data }, - async getSession(sessionId: string): Promise { - const { data } = await http.get(`/llm/chat/sessions/${encodeURIComponent(sessionId)}`) + async getSession(sessionId: string, options?: { skipRetry?: boolean }): Promise { + const { data } = await http.get(`/llm/chat/sessions/${encodeURIComponent(sessionId)}`, options) return data }, diff --git a/frontend/taskdeck-web/src/composables/useAutomationChat.ts b/frontend/taskdeck-web/src/composables/useAutomationChat.ts index e59762208..eb9dacc82 100644 --- a/frontend/taskdeck-web/src/composables/useAutomationChat.ts +++ b/frontend/taskdeck-web/src/composables/useAutomationChat.ts @@ -284,7 +284,9 @@ export function useAutomationChat() { async function refreshSelectedSession(sessionId: string) { try { - const result = await chatApi.getSession(sessionId) + // The send already succeeded and its messages are retained locally. A + // failed reconciliation must not hold continuation behind read retries. + const result = await chatApi.getSession(sessionId, { skipRetry: true }) if (isDisposed || requestedSessionId !== sessionId || selectedSession.value?.id !== sessionId) return localMessagesBySession.delete(sessionId) sessionWriteGenerations.set(sessionId, (sessionWriteGenerations.get(sessionId) ?? 0) + 1) diff --git a/frontend/taskdeck-web/src/tests/api/chatApi.spec.ts b/frontend/taskdeck-web/src/tests/api/chatApi.spec.ts index 534393454..e19556b4c 100644 --- a/frontend/taskdeck-web/src/tests/api/chatApi.spec.ts +++ b/frontend/taskdeck-web/src/tests/api/chatApi.spec.ts @@ -43,6 +43,14 @@ describe('chatApi', () => { }) }) + it('can fail fast for a post-send reconciliation read', async () => { + const failure = new Error('Refresh unavailable') + vi.mocked(http.get).mockRejectedValue(failure) + + await expect(chatApi.getSession('session/1', { skipRetry: true })).rejects.toBe(failure) + expect(http.get).toHaveBeenCalledWith('/llm/chat/sessions/session%2F1', { skipRetry: true }) + }) + it('loads provider health', async () => { const healthPayload = { isAvailable: true, diff --git a/frontend/taskdeck-web/src/tests/composables/useAutomationChat.spec.ts b/frontend/taskdeck-web/src/tests/composables/useAutomationChat.spec.ts index 0dd1fe9a3..32dc75da4 100644 --- a/frontend/taskdeck-web/src/tests/composables/useAutomationChat.spec.ts +++ b/frontend/taskdeck-web/src/tests/composables/useAutomationChat.spec.ts @@ -544,6 +544,9 @@ describe('useAutomationChat', () => { chat.messageContent.value = 'new instruction' await chat.handleSendMessage() + expect(chatApiMocks.getSession).toHaveBeenNthCalledWith(1, 's1') + expect(chatApiMocks.getSession).toHaveBeenLastCalledWith('s1', { skipRetry: true }) + expect(chat.sendingMessage.value).toBe(false) expect(chat.selectedSession.value?.recentMessages.map((message) => message.content)).toEqual([ 'older instruction', 'No board linked', diff --git a/frontend/taskdeck-web/src/tests/store/chatApi.integration.spec.ts b/frontend/taskdeck-web/src/tests/store/chatApi.integration.spec.ts index 0f2713a9b..b433f297f 100644 --- a/frontend/taskdeck-web/src/tests/store/chatApi.integration.spec.ts +++ b/frontend/taskdeck-web/src/tests/store/chatApi.integration.spec.ts @@ -158,7 +158,7 @@ describe('chatApi — integration (mocked HTTP)', () => { expect(result.recentMessages).toHaveLength(2) expect(result.recentMessages[0].role).toBe('User') expect(result.recentMessages[1].role).toBe('Assistant') - expect(http.get).toHaveBeenCalledWith('/llm/chat/sessions/session-1') + expect(http.get).toHaveBeenCalledWith('/llm/chat/sessions/session-1', undefined) }) it('URL-encodes special characters in the session ID', async () => { @@ -166,7 +166,7 @@ describe('chatApi — integration (mocked HTTP)', () => { await chatApi.getSession('session/special') - expect(http.get).toHaveBeenCalledWith('/llm/chat/sessions/session%2Fspecial') + expect(http.get).toHaveBeenCalledWith('/llm/chat/sessions/session%2Fspecial', undefined) }) it('propagates 404 when session does not exist', async () => {