Skip to content

Commit b620047

Browse files
committed
Fix AI chat session job scoping to prevent wrong sessions when switching jobs
1 parent 83d6a6c commit b620047

File tree

7 files changed

+586
-20
lines changed

7 files changed

+586
-20
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@ and this project adheres to
4242

4343
### Fixed
4444

45+
- Fixed AI chat session persistence when switching between jobs in workflow
46+
editor [#3745](https://github.com/OpenFn/lightning/issues/3745)
47+
4548
## [2.14.12] - 2025-10-21
4649

4750
## [2.14.12-pre1] - 2025-10-21

lib/lightning_web/live/ai_assistant/component.ex

Lines changed: 73 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -390,6 +390,8 @@ defmodule LightningWeb.AiAssistant.Component do
390390
:new ->
391391
socket
392392
|> delegate_to_handler(:on_session_close)
393+
|> assign(:action, :new)
394+
|> assign(:session, nil)
393395
|> assign_async([:all_sessions, :pagination_meta], fn ->
394396
case handler.list_sessions(assigns, sort_direction,
395397
limit: @default_page_size
@@ -400,27 +402,80 @@ defmodule LightningWeb.AiAssistant.Component do
400402
end)
401403

402404
:show ->
403-
session = handler.get_session!(assigns)
404-
405-
message_loading =
406-
Enum.any?(session.messages, fn msg ->
407-
msg.role == :user && msg.status in [:pending, :processing]
408-
end)
409-
410-
socket
411-
|> assign(:session, session)
412-
|> assign(
413-
:pending_message,
414-
if message_loading do
415-
AsyncResult.loading()
416-
else
417-
AsyncResult.ok(nil)
418-
end
419-
)
420-
|> delegate_to_handler(:on_session_open, [session])
405+
try do
406+
session = handler.get_session!(assigns)
407+
408+
message_loading =
409+
Enum.any?(session.messages, fn msg ->
410+
msg.role == :user && msg.status in [:pending, :processing]
411+
end)
412+
413+
socket
414+
|> assign(:session, session)
415+
|> assign(:action, :show)
416+
|> assign(
417+
:pending_message,
418+
if message_loading do
419+
AsyncResult.loading()
420+
else
421+
AsyncResult.ok(nil)
422+
end
423+
)
424+
|> delegate_to_handler(:on_session_open, [session])
425+
rescue
426+
Ecto.NoResultsError ->
427+
# Session doesn't exist or doesn't belong to this job
428+
# Fall back to showing the session list
429+
socket
430+
|> apply_action(:new)
431+
|> assign(:action, :new)
432+
|> assign(:session, nil)
433+
end
421434
end
422435
end
423436

437+
defp load_and_show_session_list(socket, handler, assigns, sort_direction) do
438+
socket
439+
|> delegate_to_handler(:on_session_close)
440+
|> assign(:action, :new)
441+
|> assign(:session, nil)
442+
|> assign_async([:all_sessions, :pagination_meta], fn ->
443+
case handler.list_sessions(assigns, sort_direction,
444+
limit: @default_page_size
445+
) do
446+
%{sessions: sessions, pagination: pagination} ->
447+
{:ok, %{all_sessions: sessions, pagination_meta: pagination}}
448+
end
449+
end)
450+
end
451+
452+
defp load_and_show_session(socket, handler, assigns) do
453+
session = handler.get_session!(assigns)
454+
455+
message_loading =
456+
Enum.any?(session.messages, fn msg ->
457+
msg.role == :user && msg.status in [:pending, :processing]
458+
end)
459+
460+
socket
461+
|> assign(:session, session)
462+
|> assign(:action, :show)
463+
|> assign(
464+
:pending_message,
465+
if message_loading do
466+
AsyncResult.loading()
467+
else
468+
AsyncResult.ok(nil)
469+
end
470+
)
471+
|> delegate_to_handler(:on_session_open, [session])
472+
rescue
473+
Ecto.NoResultsError ->
474+
socket
475+
|> apply_action(:new)
476+
|> assign(:action, :new)
477+
|> assign(:session, nil)
478+
end
424479
defp save_message(socket, action, content) do
425480
result =
426481
case action do

lib/lightning_web/live/ai_assistant/modes/job_code.ex

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,16 @@ defmodule LightningWeb.Live.AiAssistant.Modes.JobCode do
100100
@impl true
101101
@spec get_session!(assigns()) :: session()
102102
def get_session!(%{chat_session_id: session_id, selected_job: job} = assigns) do
103-
AiAssistant.get_session!(session_id)
103+
session = AiAssistant.get_session!(session_id)
104+
105+
# Validate that the session belongs to the selected job
106+
if session.job_id != job.id do
107+
raise Ecto.NoResultsError,
108+
queryable: Lightning.AiAssistant.ChatSession,
109+
message: "Chat session #{session_id} does not belong to job #{job.id}"
110+
end
111+
112+
session
104113
|> AiAssistant.put_expression_and_adaptor(job.body, job.adaptor)
105114
|> maybe_add_run_logs(job, assigns[:follow_run])
106115
end

lib/lightning_web/live/workflow_live/edit.ex

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1421,6 +1421,7 @@ defmodule LightningWeb.WorkflowLive.Edit do
14211421
page_title: "",
14221422
selected_edge: nil,
14231423
selected_job: nil,
1424+
last_selected_job: nil,
14241425
selected_run: nil,
14251426
selected_trigger: nil,
14261427
selection_mode: nil,
@@ -3584,10 +3585,19 @@ defmodule LightningWeb.WorkflowLive.Edit do
35843585
end
35853586

35863587
defp assign_chat_session_id(socket, params) do
3588+
job_chat_session_id =
3589+
if changed?(socket, :selected_job) &&
3590+
not is_nil(socket.assigns[:last_selected_job]) do
3591+
nil
3592+
else
3593+
params["j-chat"]
3594+
end
3595+
35873596
socket
35883597
|> assign(
35893598
workflow_chat_session_id: params["w-chat"],
3590-
job_chat_session_id: params["j-chat"]
3599+
job_chat_session_id: job_chat_session_id,
3600+
last_selected_job: socket.assigns[:selected_job]
35913601
)
35923602
end
35933603

test/lightning/ai_assistant/ai_assistant_test.exs

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2050,4 +2050,124 @@ defmodule Lightning.AiAssistantTest do
20502050
assert AiAssistant.title_max_length() == 40
20512051
end
20522052
end
2053+
2054+
describe "list_sessions/2 - job scoping" do
2055+
test "only shows sessions for the currently selected job" do
2056+
user = insert(:user)
2057+
project = insert(:project)
2058+
workflow = insert(:workflow, project: project)
2059+
job_a = insert(:job, workflow: workflow, name: "Job A")
2060+
job_b = insert(:job, workflow: workflow, name: "Job B")
2061+
2062+
# Create sessions directly in the database (bypass AI processing)
2063+
session_a1 =
2064+
insert(:chat_session,
2065+
job: job_a,
2066+
user: user,
2067+
title: "Help with Job A - 1",
2068+
session_type: "job_code"
2069+
)
2070+
2071+
session_a2 =
2072+
insert(:chat_session,
2073+
job: job_a,
2074+
user: user,
2075+
title: "Help with Job A - 2",
2076+
session_type: "job_code"
2077+
)
2078+
2079+
_session_b =
2080+
insert(:chat_session,
2081+
job: job_b,
2082+
user: user,
2083+
title: "Help with Job B",
2084+
session_type: "job_code"
2085+
)
2086+
2087+
# List sessions for job_a
2088+
result = AiAssistant.list_sessions(job_a, :desc)
2089+
2090+
# Should only return job_a sessions
2091+
session_ids = Enum.map(result.sessions, & &1.id)
2092+
assert session_a1.id in session_ids
2093+
assert session_a2.id in session_ids
2094+
assert length(result.sessions) == 2
2095+
end
2096+
2097+
test "list_sessions for job_b doesn't include job_a sessions" do
2098+
user = insert(:user)
2099+
project = insert(:project)
2100+
workflow = insert(:workflow, project: project)
2101+
job_a = insert(:job, workflow: workflow, name: "Job A")
2102+
job_b = insert(:job, workflow: workflow, name: "Job B")
2103+
2104+
# Create sessions directly in the database
2105+
_session_a =
2106+
insert(:chat_session,
2107+
job: job_a,
2108+
user: user,
2109+
title: "Help with Job A",
2110+
session_type: "job_code"
2111+
)
2112+
2113+
session_b =
2114+
insert(:chat_session,
2115+
job: job_b,
2116+
user: user,
2117+
title: "Help with Job B",
2118+
session_type: "job_code"
2119+
)
2120+
2121+
# List sessions for job_b
2122+
result = AiAssistant.list_sessions(job_b, :desc)
2123+
2124+
# Should only return job_b session
2125+
session_ids = Enum.map(result.sessions, & &1.id)
2126+
assert session_b.id in session_ids
2127+
assert length(result.sessions) == 1
2128+
end
2129+
2130+
test "sessions from multiple jobs don't cross-contaminate" do
2131+
user = insert(:user)
2132+
project = insert(:project)
2133+
workflow = insert(:workflow, project: project)
2134+
job_a = insert(:job, workflow: workflow, name: "Job A")
2135+
job_b = insert(:job, workflow: workflow, name: "Job B")
2136+
job_c = insert(:job, workflow: workflow, name: "Job C")
2137+
2138+
# Create sessions for all jobs
2139+
session_a =
2140+
insert(:chat_session,
2141+
job: job_a,
2142+
user: user,
2143+
title: "Session A",
2144+
session_type: "job_code"
2145+
)
2146+
2147+
session_b =
2148+
insert(:chat_session,
2149+
job: job_b,
2150+
user: user,
2151+
title: "Session B",
2152+
session_type: "job_code"
2153+
)
2154+
2155+
session_c =
2156+
insert(:chat_session,
2157+
job: job_c,
2158+
user: user,
2159+
title: "Session C",
2160+
session_type: "job_code"
2161+
)
2162+
2163+
# Each job should only see its own session
2164+
result_a = AiAssistant.list_sessions(job_a, :desc)
2165+
result_b = AiAssistant.list_sessions(job_b, :desc)
2166+
result_c = AiAssistant.list_sessions(job_c, :desc)
2167+
2168+
assert [session_a.id] == Enum.map(result_a.sessions, & &1.id)
2169+
assert [session_b.id] == Enum.map(result_b.sessions, & &1.id)
2170+
assert [session_c.id] == Enum.map(result_c.sessions, & &1.id)
2171+
end
2172+
end
20532173
end

test/lightning_web/ai_assistant/job_code_test.exs

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
defmodule LightningWeb.AiAssistant.Modes.JobCodeTest do
22
use Lightning.DataCase, async: true
33

4+
import Lightning.Factories
5+
46
alias LightningWeb.Live.AiAssistant.Modes.JobCode
57

68
describe "chat_input_disabled?/1" do
@@ -239,4 +241,91 @@ defmodule LightningWeb.AiAssistant.Modes.JobCodeTest do
239241
assert JobCode.chat_input_disabled?(assigns_ok) == false
240242
end
241243
end
244+
245+
describe "get_session!/1" do
246+
test "successfully retrieves session when it belongs to the selected job" do
247+
user = insert(:user)
248+
project = insert(:project)
249+
workflow = insert(:workflow, project: project)
250+
job = insert(:job, workflow: workflow)
251+
252+
# Create session directly (bypass AI processing)
253+
session =
254+
insert(:chat_session,
255+
job: job,
256+
user: user,
257+
session_type: "job_code"
258+
)
259+
260+
assigns = %{
261+
chat_session_id: session.id,
262+
selected_job: job,
263+
follow_run: nil
264+
}
265+
266+
result = JobCode.get_session!(assigns)
267+
268+
assert result.id == session.id
269+
assert result.job_id == job.id
270+
end
271+
272+
test "raises Ecto.NoResultsError when session belongs to a different job" do
273+
user = insert(:user)
274+
project = insert(:project)
275+
workflow = insert(:workflow, project: project)
276+
job_a = insert(:job, workflow: workflow)
277+
job_b = insert(:job, workflow: workflow)
278+
279+
# Create session for job_a
280+
session =
281+
insert(:chat_session,
282+
job: job_a,
283+
user: user,
284+
session_type: "job_code"
285+
)
286+
287+
# Try to get session with job_b selected
288+
assigns = %{
289+
chat_session_id: session.id,
290+
selected_job: job_b,
291+
follow_run: nil
292+
}
293+
294+
assert_raise Ecto.NoResultsError, fn ->
295+
JobCode.get_session!(assigns)
296+
end
297+
end
298+
299+
test "includes adaptor and expression from selected job" do
300+
user = insert(:user)
301+
project = insert(:project)
302+
workflow = insert(:workflow, project: project)
303+
304+
job =
305+
insert(:job,
306+
workflow: workflow,
307+
body: "fn(state => state)",
308+
adaptor: "@openfn/language-http@1.0.0"
309+
)
310+
311+
# Create session directly (bypass AI processing)
312+
session =
313+
insert(:chat_session,
314+
job: job,
315+
user: user,
316+
session_type: "job_code"
317+
)
318+
319+
assigns = %{
320+
chat_session_id: session.id,
321+
selected_job: job,
322+
follow_run: nil
323+
}
324+
325+
result = JobCode.get_session!(assigns)
326+
327+
assert result.expression == "fn(state => state)"
328+
assert result.adaptor == "@openfn/language-http@1.0.0"
329+
end
330+
end
242331
end

0 commit comments

Comments
 (0)