From 9a1a5541f21aa61fa6677c1f3f0a75294565fdcb Mon Sep 17 00:00:00 2001 From: Jonathan Payne Date: Mon, 12 Oct 2026 14:22:18 -0400 Subject: [PATCH 1/2] closes OpenConceptLab/ocl_issues#2887 | AI log entries go on the row the AI analysed fetchRecommendation logged three AI outcomes without a row: a quota or preview-limit stop, from the response or a thrown error, and a failed analysis (the AI Assistant answering with detail). log() then put each on the row open in the side panel, or under null when none was open. So during AutoMatch, or a retry from the grid, the entry showed up on another row, and the row the AI was called for showed nothing. The three calls now pass __index, as the other three AI entries do. A call whose run was superseded still logs on its own row. isSuperseded() guards the run's bookkeeping (the stage and the failure count), not the row's history, which already records such a call's result and its failed attempts. The new test reads fetchRecommendation's source, since the tests can't mount MapProject, and checks that every AIRecommendation* log() call passes __index. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AEzMedE6q8ivk6uh6KeHQz --- src/components/map-projects/MapProject.jsx | 13 +++-- .../__tests__/aiRecommendationLogs.test.js | 48 +++++++++++++++++++ 2 files changed, 57 insertions(+), 4 deletions(-) create mode 100644 src/components/map-projects/__tests__/aiRecommendationLogs.test.js diff --git a/src/components/map-projects/MapProject.jsx b/src/components/map-projects/MapProject.jsx index 70cd93eb..747a79ac 100644 --- a/src/components/map-projects/MapProject.jsx +++ b/src/components/map-projects/MapProject.jsx @@ -1715,6 +1715,9 @@ const MapProject = () => { // event would arrive where the options object is expected. const onManualSave = () => onSave({source: 'manual'}) + // index is the row the entry is about. Without it the entry goes on the row + // open in the side panel, so work on another row (a run's, a grid button's) + // must pass it (OpenConceptLab/ocl_issues#2887). const log = (data, index) => { let idx = index === undefined ? rowIndex : index const nextLogs = {...logsRef.current, [idx]: [{created_at: moment().toDate(), user: user.username || user.id, ...data}, ...(logsRef.current[idx] || [])]} @@ -5721,7 +5724,9 @@ const MapProject = () => { // Returns the row's AI outcome as a stage (1 analysed, -2 failed, -3 not // run), or null when the row was skipped or the run stopped. A run's call // that ends after a newer run started leaves that run's stages and failure - // count alone (ocl_issues#2872). + // count alone (ocl_issues#2872), but still logs on its own row, like every + // AI entry here: the row's history records each call made for it + // (OpenConceptLab/ocl_issues#2887). const runTicket = autoMatchRunTicketRef.current const isSuperseded = () => isBulk && runTicket !== autoMatchRunTicketRef.current const markRecommend = stage => { @@ -5842,14 +5847,14 @@ const MapProject = () => { let timestamp = moment().toDate() if(handlePreviewLimitError(response, isSuperseded() ? null : __index, 'recommend', {isRun: isBulk, stopPhase: 'ai'})) { markRecommend(-3) - log({created_at: timestamp, action: 'AIRecommendationLimitReached', extras: {model: requestedModel, prompt_template: promptTemplateRef, prompt_template_uri: promptTemplateRef?.uri}}) + log({created_at: timestamp, action: 'AIRecommendationLimitReached', extras: {model: requestedModel, prompt_template: promptTemplateRef, prompt_template_uri: promptTemplateRef?.uri}}, __index) return -3 } if(response?.detail) { markRecommend(-2) if(!isSuperseded()) aiFailuresInARowRef.current += 1 - log({created_at: timestamp, action: 'AIRecommendation', description: response.detail, extras: {error: response.detail, model: requestedModel, prompt_template: promptTemplateRef, prompt_template_uri: promptTemplateRef?.uri}}) + log({created_at: timestamp, action: 'AIRecommendation', description: response.detail, extras: {error: response.detail, model: requestedModel, prompt_template: promptTemplateRef, prompt_template_uri: promptTemplateRef?.uri}}, __index) setAlert({message: response.detail, severity: 'error'}) return -2 } @@ -5882,7 +5887,7 @@ const MapProject = () => { } catch (err) { if(handlePreviewLimitError(err, isSuperseded() ? null : __index, 'recommend', {isRun: isBulk, stopPhase: 'ai'})) { markRecommend(-3) - log({created_at: moment().toDate(), action: 'AIRecommendationLimitReached', extras: {model: requestedModel, prompt_template: promptTemplateRef, prompt_template_uri: promptTemplateRef?.uri}}) + log({created_at: moment().toDate(), action: 'AIRecommendationLimitReached', extras: {model: requestedModel, prompt_template: promptTemplateRef, prompt_template_uri: promptTemplateRef?.uri}}, __index) return -3 } markRecommend(-2) diff --git a/src/components/map-projects/__tests__/aiRecommendationLogs.test.js b/src/components/map-projects/__tests__/aiRecommendationLogs.test.js new file mode 100644 index 00000000..7bac9a32 --- /dev/null +++ b/src/components/map-projects/__tests__/aiRecommendationLogs.test.js @@ -0,0 +1,48 @@ +/** + * Every row log entry an AI Assistant call writes goes on the row the call + * analysed (OpenConceptLab/ocl_issues#2887). log() puts an entry passed + * without a row on the row open in the side panel, or under null when none is + * open, so during Auto Match a failure or quota stop showed up on another row. + * + * fetchRecommendation is a closure inside MapProject, which these tests can't + * mount (they run without a JSX transform), so this reads its source: each + * log() call with an AIRecommendation* action must pass __index, the row + * fetchRecommendation was called for. In a bulk run that's the run's row. + */ + +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import babel from '@babel/core' + +const source = readFileSync(new URL('../MapProject.jsx', import.meta.url), 'utf8') + +// The AIRecommendation* log() calls in fetchRecommendation, and the row each passes. +const getAILogCalls = () => { + const ast = babel.parseSync(source, {babelrc: false, configFile: false, parserOpts: {plugins: ['jsx']}}) + const calls = [] + babel.traverse(ast, { + VariableDeclarator(path) { + if(path.node.id.name !== 'fetchRecommendation') + return + path.traverse({ + CallExpression({node}) { + const [entry, row] = node.arguments + if(node.callee.name !== 'log' || entry?.type !== 'ObjectExpression') + return + const action = entry.properties.find(property => property.key?.name === 'action')?.value?.value + if(String(action).startsWith('AIRecommendation')) + calls.push({line: node.loc.start.line, action, row: row?.name || null}) + } + }) + } + }) + return calls +} + +test('fetchRecommendation: every AIRecommendation* entry goes on the row being analysed, superseded or not', () => { + const calls = getAILogCalls() + + assert.ok(calls.length > 0, 'no AIRecommendation* log() calls found in fetchRecommendation') + assert.deepEqual(calls.filter(call => call.row !== '__index'), []) +}) From 885c64be6b4896a44a20a18533c7752281cceb19 Mon Sep 17 00:00:00 2001 From: Jonathan Payne Date: Mon, 12 Oct 2026 14:35:06 -0400 Subject: [PATCH 2/2] closes OpenConceptLab/ocl_issues#2887 | Simplification review: a shorter test and log() comment - S1: the test checks every log() call in fetchRecommendation, not only the AIRecommendation* ones. All six are AI entries, and anything it logs is about the row it analyses. - S2: the one-caller helper is inlined into the test. - S3: the test's header no longer restates log()'s fallback or the bug. - S4: the comment above log() keeps only the rule for callers. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AEzMedE6q8ivk6uh6KeHQz --- src/components/map-projects/MapProject.jsx | 5 +-- .../__tests__/aiRecommendationLogs.test.js | 44 ++++++------------- 2 files changed, 16 insertions(+), 33 deletions(-) diff --git a/src/components/map-projects/MapProject.jsx b/src/components/map-projects/MapProject.jsx index 747a79ac..d385ea38 100644 --- a/src/components/map-projects/MapProject.jsx +++ b/src/components/map-projects/MapProject.jsx @@ -1715,9 +1715,8 @@ const MapProject = () => { // event would arrive where the options object is expected. const onManualSave = () => onSave({source: 'manual'}) - // index is the row the entry is about. Without it the entry goes on the row - // open in the side panel, so work on another row (a run's, a grid button's) - // must pass it (OpenConceptLab/ocl_issues#2887). + // Work on a row other than the one open in the side panel (a run's, a grid + // button's) must pass its index (OpenConceptLab/ocl_issues#2887). const log = (data, index) => { let idx = index === undefined ? rowIndex : index const nextLogs = {...logsRef.current, [idx]: [{created_at: moment().toDate(), user: user.username || user.id, ...data}, ...(logsRef.current[idx] || [])]} diff --git a/src/components/map-projects/__tests__/aiRecommendationLogs.test.js b/src/components/map-projects/__tests__/aiRecommendationLogs.test.js index 7bac9a32..720059e3 100644 --- a/src/components/map-projects/__tests__/aiRecommendationLogs.test.js +++ b/src/components/map-projects/__tests__/aiRecommendationLogs.test.js @@ -1,13 +1,10 @@ /** - * Every row log entry an AI Assistant call writes goes on the row the call - * analysed (OpenConceptLab/ocl_issues#2887). log() puts an entry passed - * without a row on the row open in the side panel, or under null when none is - * open, so during Auto Match a failure or quota stop showed up on another row. + * Every entry fetchRecommendation logs goes on the row it analysed, not the + * row open in the side panel (OpenConceptLab/ocl_issues#2887). * * fetchRecommendation is a closure inside MapProject, which these tests can't * mount (they run without a JSX transform), so this reads its source: each - * log() call with an AIRecommendation* action must pass __index, the row - * fetchRecommendation was called for. In a bulk run that's the run's row. + * log() call in it must pass __index, the row it was called for. */ import test from 'node:test' @@ -15,34 +12,21 @@ import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import babel from '@babel/core' -const source = readFileSync(new URL('../MapProject.jsx', import.meta.url), 'utf8') - -// The AIRecommendation* log() calls in fetchRecommendation, and the row each passes. -const getAILogCalls = () => { - const ast = babel.parseSync(source, {babelrc: false, configFile: false, parserOpts: {plugins: ['jsx']}}) +test('fetchRecommendation: every AI entry it logs goes on the row being analysed, superseded or not', () => { + const source = readFileSync(new URL('../MapProject.jsx', import.meta.url), 'utf8') const calls = [] - babel.traverse(ast, { + babel.traverse(babel.parseSync(source, {babelrc: false, configFile: false, parserOpts: {plugins: ['jsx']}}), { VariableDeclarator(path) { - if(path.node.id.name !== 'fetchRecommendation') - return - path.traverse({ - CallExpression({node}) { - const [entry, row] = node.arguments - if(node.callee.name !== 'log' || entry?.type !== 'ObjectExpression') - return - const action = entry.properties.find(property => property.key?.name === 'action')?.value?.value - if(String(action).startsWith('AIRecommendation')) - calls.push({line: node.loc.start.line, action, row: row?.name || null}) - } - }) + if(path.node.id.name === 'fetchRecommendation') + path.traverse({ + CallExpression({node}) { + if(node.callee.name === 'log') + calls.push({line: node.loc.start.line, row: node.arguments[1]?.name}) + } + }) } }) - return calls -} - -test('fetchRecommendation: every AIRecommendation* entry goes on the row being analysed, superseded or not', () => { - const calls = getAILogCalls() - assert.ok(calls.length > 0, 'no AIRecommendation* log() calls found in fetchRecommendation') + assert.ok(calls.length > 0, 'no log() calls found in fetchRecommendation') assert.deepEqual(calls.filter(call => call.row !== '__index'), []) })