From 0485f51cb2928802402bbdb0bd6ac0515a08d3b6 Mon Sep 17 00:00:00 2001 From: Masayoshi Mizutani Date: Tue, 16 Jun 2026 16:23:37 +0900 Subject: [PATCH 1/2] feat: persist conversation history for ticketless threads to continue conversations --- doc/strategy/README.md | 8 +++ pkg/usecase/chat.go | 12 ++-- pkg/usecase/chat/aster/aster.go | 19 +++-- pkg/usecase/chat/aster/aster_test.go | 37 ++++++++++ pkg/usecase/chat/bluebell/bluebell.go | 19 +++-- pkg/usecase/chat/bluebell/bluebell_test.go | 46 ++++++++++++ pkg/usecase/slack_session_lock_test.go | 83 +++++++++++++++++++--- 7 files changed, 190 insertions(+), 34 deletions(-) diff --git a/doc/strategy/README.md b/doc/strategy/README.md index ad7fa676..e019af59 100644 --- a/doc/strategy/README.md +++ b/doc/strategy/README.md @@ -32,6 +32,14 @@ All strategies share these components from `pkg/usecase/chat/`: Each strategy is a separate package under `pkg/usecase/chat//` with its own prompt templates, execution logic, and configuration options. +## Conversation Memory + +The agent's working memory (`gollem.History`) is persisted per **Session**, keyed by `SessionID` (not by ticket). A Slack thread maps to a deterministic Session via `ResolveSlackSession`, so the same thread reuses the same Session across mentions and Warren instances. + +This applies to **ticketless threads** as well: a thread that has not been escalated into a ticket gets a deterministic `slack_ticketless_*` Session, and its working memory is saved and restored across mentions just like a ticketed thread. Consequently, conversation context carries over between turns even when no ticket exists. + +> The ticket-scoped `latest.json` snapshot (written via `saveLatestHistory`) is a crash-recovery artifact and is only produced for ticketed threads; it is not used for cross-turn conversation continuation, which always reads the `SessionID`-keyed history. + ## Execution Flow Overview Both current strategies follow a common high-level flow: diff --git a/pkg/usecase/chat.go b/pkg/usecase/chat.go index 772a854e..71a97132 100644 --- a/pkg/usecase/chat.go +++ b/pkg/usecase/chat.go @@ -408,10 +408,14 @@ func (x *UseCases) buildChatContext(ctx context.Context, t *ticket.Ticket, slack chatCtx.Tools = allTools - // Load history (skip for ticketless and when no Session was - // resolved — the latter leaves working memory empty so the next - // turn starts fresh rather than resurrecting pre-redesign data). - if !ticketless && sess != nil { + // Load history whenever a Session was resolved, regardless of + // ticket association. Session history is keyed by SessionID (not + // TicketID), and ticketless Slack threads get a deterministic + // SessionID, so loading here lets ticketless threads continue a + // conversation across turns just like ticketed ones. When no + // Session was resolved we leave working memory empty so the next + // turn starts fresh rather than resurrecting pre-redesign data. + if sess != nil { storageSvc := storage.New(x.storageClient, storage.WithPrefix(x.storagePrefix)) history, err := chatpkg.LoadSessionHistory(ctx, sess.ID, storageSvc) if err != nil { diff --git a/pkg/usecase/chat/aster/aster.go b/pkg/usecase/chat/aster/aster.go index 9d5270e5..d66bb4a0 100644 --- a/pkg/usecase/chat/aster/aster.go +++ b/pkg/usecase/chat/aster/aster.go @@ -247,12 +247,11 @@ func (c *AsterChat) executeAster(ctx context.Context, ssn *session.Session, mess msg.Notify(ctx, "💬 %s", planResult.Message) } - // Direct response (no tasks) + // Direct response (no tasks). saveSessionHistory is keyed by + // SessionID, so it persists working memory for ticketless threads + // too (no-op when no Session was resolved). if len(planResult.Tasks) == 0 { - if !ticketless { - return c.saveSessionHistory(ctx, planSession, *chatCtx, storageSvc) - } - return nil + return c.saveSessionHistory(ctx, planSession, *chatCtx, storageSvc) } // Execute phases @@ -392,11 +391,11 @@ func (c *AsterChat) executeAster(ctx context.Context, ssn *session.Session, mess // Trigger fact knowledge reflection in background c.triggerFactReflection(ctx, buildReflectionSummary(allResults), chatCtx) - if !ticketless { - logger.Debug("aster executeAster: completed, saving history") - return c.saveSessionHistory(ctx, planSession, *chatCtx, storageSvc) - } - return nil + // Persist working memory keyed by SessionID for both ticketed and + // ticketless threads so the next turn on this thread continues the + // conversation (no-op when no Session was resolved). + logger.Debug("aster executeAster: completed, saving history") + return c.saveSessionHistory(ctx, planSession, *chatCtx, storageSvc) } // buildReflectionSummary aggregates all task results into a text summary for reflection. diff --git a/pkg/usecase/chat/aster/aster_test.go b/pkg/usecase/chat/aster/aster_test.go index 8577b987..8d52efbe 100644 --- a/pkg/usecase/chat/aster/aster_test.go +++ b/pkg/usecase/chat/aster/aster_test.go @@ -560,6 +560,43 @@ func TestAsterChat_LatestHistorySavedOnDirectResponse(t *testing.T) { gt.V(t, latest).NotNil() } +func TestAsterChat_TicketlessSavesSessionHistory(t *testing.T) { + ctx := setupTestContext(t) + repo := repository.NewMemory() + mockStorage := adapter.NewMock() + + mockLLM := &mock.LLMClientMock{ + NewSessionFunc: func(ctx context.Context, opts ...gollem.SessionOption) (gollem.Session, error) { + ssn := newMockSession() + ssn.GenerateFunc = func(ctx context.Context, input []gollem.Input, opts ...gollem.GenerateOption) (*gollem.Response, error) { + return &gollem.Response{ + Texts: []string{`{"message": "Direct response.", "tasks": []}`}, + }, nil + } + return ssn, nil + }, + } + + chatUC := aster.New(repo, mockLLM, + aster.WithStorageClient(mockStorage), + ) + + // Ticketless thread with a resolved Session. Working memory must be + // persisted under the SessionID so the next mention continues the + // conversation, even without a Ticket. + sess := newDummySession("") + gt.NoError(t, chatUC.Execute(ctx, &chat.RunContext{ + Session: sess, + Message: "Hello", + ChatCtx: &chatModel.ChatContext{Ticket: &ticket.Ticket{}, Session: sess}, + })) + + storageSvc := storage_svc.New(mockStorage) + saved, err := storageSvc.GetSessionHistory(ctx, sess.ID) + gt.NoError(t, err) + gt.V(t, saved).NotNil() +} + func TestAsterChat_LatestHistorySavedAfterReplan(t *testing.T) { ctx := setupTestContext(t) repo := repository.NewMemory() diff --git a/pkg/usecase/chat/bluebell/bluebell.go b/pkg/usecase/chat/bluebell/bluebell.go index a929bfc8..fb5b4142 100644 --- a/pkg/usecase/chat/bluebell/bluebell.go +++ b/pkg/usecase/chat/bluebell/bluebell.go @@ -274,12 +274,11 @@ func (c *BluebellChat) executeBluebell(ctx context.Context, ssn *session.Session msg.Notify(ctx, "💬 %s", planResult.Message) } - // Direct response (no tasks) + // Direct response (no tasks). saveSessionHistory is keyed by + // SessionID, so it persists working memory for ticketless threads + // too (no-op when no Session was resolved). if len(planResult.Tasks) == 0 { - if !ticketless { - return c.saveSessionHistory(ctx, planSession, *chatCtx, storageSvc) - } - return nil + return c.saveSessionHistory(ctx, planSession, *chatCtx, storageSvc) } // Execute phases @@ -406,11 +405,11 @@ func (c *BluebellChat) executeBluebell(ctx context.Context, ssn *session.Session c.triggerFactReflection(ctx, buildReflectionSummary(allResults), chatCtx) - if !ticketless { - logger.Debug("bluebell executeBluebell: completed, saving history") - return c.saveSessionHistory(ctx, planSession, *chatCtx, storageSvc) - } - return nil + // Persist working memory keyed by SessionID for both ticketed and + // ticketless threads so the next turn on this thread continues the + // conversation (no-op when no Session was resolved). + logger.Debug("bluebell executeBluebell: completed, saving history") + return c.saveSessionHistory(ctx, planSession, *chatCtx, storageSvc) } // buildReflectionSummary aggregates all task results into a text summary for reflection. diff --git a/pkg/usecase/chat/bluebell/bluebell_test.go b/pkg/usecase/chat/bluebell/bluebell_test.go index 46ed061c..613f01d7 100644 --- a/pkg/usecase/chat/bluebell/bluebell_test.go +++ b/pkg/usecase/chat/bluebell/bluebell_test.go @@ -11,6 +11,7 @@ import ( "github.com/gollem-dev/gollem" "github.com/m-mizutani/goerr/v2" "github.com/m-mizutani/gt" + adapter "github.com/secmon-lab/warren/pkg/adapter/storage" "github.com/secmon-lab/warren/pkg/cli/config" "github.com/secmon-lab/warren/pkg/domain/mock" "github.com/secmon-lab/warren/pkg/domain/model/alert" @@ -22,6 +23,7 @@ import ( "github.com/secmon-lab/warren/pkg/repository" svcknowledge "github.com/secmon-lab/warren/pkg/service/knowledge" slackService "github.com/secmon-lab/warren/pkg/service/slack" + storage_svc "github.com/secmon-lab/warren/pkg/service/storage" "github.com/secmon-lab/warren/pkg/usecase/chat" "github.com/secmon-lab/warren/pkg/usecase/chat/bluebell" "github.com/secmon-lab/warren/pkg/utils/msg" @@ -646,3 +648,47 @@ func TestBluebellChat_Ticketless(t *testing.T) { }) gt.NoError(t, err) } + +func TestBluebellChat_TicketlessSavesSessionHistory(t *testing.T) { + ctx := setupTestContext(t) + repo := repository.NewMemory() + knowledgeSvc := svcknowledge.New(repo, newMockEmbeddingClient()) + mockStorage := adapter.NewMock() + + mockLLM := &mock.LLMClientMock{ + NewSessionFunc: func(ctx context.Context, opts ...gollem.SessionOption) (gollem.Session, error) { + ssn := newMockSession() + ssn.GenerateFunc = func(ctx context.Context, input []gollem.Input, opts ...gollem.GenerateOption) (*gollem.Response, error) { + return &gollem.Response{ + Texts: []string{`{"message": "Here is the answer.", "tasks": []}`}, + }, nil + } + return ssn, nil + }, + } + + chatUC, err := bluebell.New(repo, mockLLM, + bluebell.WithKnowledgeService(knowledgeSvc), + bluebell.WithStorageClient(mockStorage), + ) + gt.NoError(t, err) + + // Ticketless thread, but a Session is resolved (the deterministic + // slack_ticketless_* Session). Working memory must be persisted under the + // SessionID so the next mention on this thread continues the conversation. + sess := newDummySession("") + err = chatUC.Execute(ctx, &chat.RunContext{ + Session: sess, + Message: "General question", + ChatCtx: &chatModel.ChatContext{ + Ticket: &ticket.Ticket{}, + Session: sess, + }, + }) + gt.NoError(t, err) + + storageSvc := storage_svc.New(mockStorage) + saved, err := storageSvc.GetSessionHistory(ctx, sess.ID) + gt.NoError(t, err) + gt.V(t, saved).NotNil() +} diff --git a/pkg/usecase/slack_session_lock_test.go b/pkg/usecase/slack_session_lock_test.go index 2d08d749..eb82d16f 100644 --- a/pkg/usecase/slack_session_lock_test.go +++ b/pkg/usecase/slack_session_lock_test.go @@ -13,7 +13,9 @@ import ( "testing" "time" + "github.com/gollem-dev/gollem" "github.com/m-mizutani/gt" + storageAdapter "github.com/secmon-lab/warren/pkg/adapter/storage" sessSvcDomain "github.com/secmon-lab/warren/pkg/domain/interfaces" chatModel "github.com/secmon-lab/warren/pkg/domain/model/chat" sessModel "github.com/secmon-lab/warren/pkg/domain/model/session" @@ -23,6 +25,7 @@ import ( "github.com/secmon-lab/warren/pkg/repository" slackSvc "github.com/secmon-lab/warren/pkg/service/slack" "github.com/secmon-lab/warren/pkg/service/slack/testutil" + storageSvc "github.com/secmon-lab/warren/pkg/service/storage" "github.com/secmon-lab/warren/pkg/usecase" ) @@ -30,16 +33,18 @@ import ( // without pulling in the full LLM stack. It also lets a test pause the // Execute call so the lock can be inspected while a run is in flight. type stubChatUC struct { - mu sync.Mutex - runs int - release chan struct{} - err error - onExec func(ctx context.Context) + mu sync.Mutex + runs int + release chan struct{} + err error + onExec func(ctx context.Context) + lastChatCtx chatModel.ChatContext } func (s *stubChatUC) Execute(ctx context.Context, message string, chatCtx chatModel.ChatContext) error { s.mu.Lock() s.runs++ + s.lastChatCtx = chatCtx release := s.release onExec := s.onExec s.mu.Unlock() @@ -52,10 +57,19 @@ func (s *stubChatUC) Execute(ctx context.Context, message string, chatCtx chatMo return s.err } +// LastChatCtx returns the ChatContext captured by the most recent Execute +// call so tests can assert on what ChatFromSlack assembled (e.g. whether +// session history was restored). +func (s *stubChatUC) LastChatCtx() chatModel.ChatContext { + s.mu.Lock() + defer s.mu.Unlock() + return s.lastChatCtx +} + // ensure stubChatUC implements the interface expected by usecase.UseCases. var _ sessSvcDomain.ChatUseCase = (*stubChatUC)(nil) -func setupUseCases(t *testing.T, stub *stubChatUC) (*usecase.UseCases, *testutil.Recorder, *repository.Memory) { +func setupUseCases(t *testing.T, stub *stubChatUC) (*usecase.UseCases, *testutil.Recorder, *repository.Memory, sessSvcDomain.StorageClient) { t.Helper() repo := repository.NewMemory() @@ -64,18 +78,21 @@ func setupUseCases(t *testing.T, stub *stubChatUC) (*usecase.UseCases, *testutil slack, err := slackSvc.New(client, "C_DEFAULT") gt.NoError(t, err).Required() + storageClient := storageAdapter.NewMock() + uc := usecase.New( usecase.WithRepository(repo), usecase.WithSlackService(slack), usecase.WithChatUseCase(stub), + usecase.WithStorageClient(storageClient), ) - return uc, rec, repo + return uc, rec, repo, storageClient } func TestChatFromSlack_AcquiresLockAndCreatesTurn(t *testing.T) { ctx := context.Background() stub := &stubChatUC{} - uc, _, repo := setupUseCases(t, stub) + uc, _, repo, _ := setupUseCases(t, stub) // Seed a Ticket so ChatFromSlack takes the "with ticket" path. thread := slackModel.Thread{ChannelID: "C1", ThreadID: "t1", TeamID: "T1"} @@ -112,7 +129,7 @@ func TestChatFromSlack_AcquiresLockAndCreatesTurn(t *testing.T) { func TestChatFromSlack_DoubleMention_BlocksSecondAndPostsBusyNotice(t *testing.T) { ctx := context.Background() stub := &stubChatUC{release: make(chan struct{})} - uc, rec, repo := setupUseCases(t, stub) + uc, rec, repo, _ := setupUseCases(t, stub) thread := slackModel.Thread{ChannelID: "C1", ThreadID: "t1", TeamID: "T1"} tk := ticket.Ticket{ @@ -175,7 +192,7 @@ func TestChatFromSlack_ConcurrentMentions_OneWinsRestBusy(t *testing.T) { var execStarted atomic.Int32 stub := &stubChatUC{release: make(chan struct{})} stub.onExec = func(ctx context.Context) { execStarted.Add(1) } - uc, _, repo := setupUseCases(t, stub) + uc, _, repo, _ := setupUseCases(t, stub) thread := slackModel.Thread{ChannelID: "C1", ThreadID: "t1", TeamID: "T1"} tk := ticket.Ticket{ @@ -223,6 +240,52 @@ func TestChatFromSlack_ConcurrentMentions_OneWinsRestBusy(t *testing.T) { } } +// TestChatFromSlack_TicketlessContinuesConversation verifies that a Slack +// thread with no associated Ticket still restores its gollem working memory +// across mentions: the deterministic ticketless Session is reused, and +// history saved under its SessionID is loaded back into chatCtx.History on +// the next mention. +func TestChatFromSlack_TicketlessContinuesConversation(t *testing.T) { + ctx := context.Background() + stub := &stubChatUC{} + uc, _, _, storageClient := setupUseCases(t, stub) + + // No Ticket is seeded for this thread, so ChatFromSlack takes the + // ticketless path and resolves a deterministic slack_ticketless_* Session. + thread := slackModel.Thread{ChannelID: "C9", ThreadID: "t9", TeamID: "T9"} + msg := slackModel.NewTestMessage(thread.ChannelID, thread.ThreadID, thread.TeamID, "m1", "U1", "@warren question") + + // First mention: nothing has been persisted yet, so working memory + // starts empty. + gt.NoError(t, uc.ChatFromSlack(ctx, &msg, "question")) + first := stub.LastChatCtx() + gt.V(t, first.Session != nil).Equal(true) + gt.V(t, first.IsTicketless()).Equal(true) + gt.V(t, first.History == nil).Equal(true) + + sessID := first.Session.ID + + // Simulate the chat strategy persisting working memory for this + // ticketless Session (keyed by SessionID, not TicketID). + svc := storageSvc.New(storageClient) + want := &gollem.History{ + Version: gollem.HistoryVersion, + Messages: []gollem.Message{{Role: gollem.RoleUser}}, + } + gt.NoError(t, svc.PutSessionHistory(ctx, sessID, want)) + + // Second mention on the same thread reuses the deterministic Session and + // restores its working memory into chatCtx.History. + msg2 := slackModel.NewTestMessage(thread.ChannelID, thread.ThreadID, thread.TeamID, "m2", "U1", "@warren follow up") + gt.NoError(t, uc.ChatFromSlack(ctx, &msg2, "follow up")) + second := stub.LastChatCtx() + gt.V(t, second.Session != nil).Equal(true) + gt.V(t, second.Session.ID).Equal(sessID) + gt.V(t, second.History != nil).Equal(true) + gt.A(t, second.History.Messages).Length(1) + gt.V(t, second.History.Messages[0].Role).Equal(gollem.RoleUser) +} + // waitUntil polls cond for up to ~2 seconds and fails the test otherwise. func waitUntil(t *testing.T, cond func() bool) { t.Helper() From 941799199e46980dccd538204837930b388ab0f3 Mon Sep 17 00:00:00 2001 From: Masayoshi Mizutani Date: Wed, 17 Jun 2026 09:59:20 +0900 Subject: [PATCH 2/2] fix(deps): upgrade dompurify to 3.4.10 to clear trivy CVEs --- frontend/package.json | 2 +- frontend/pnpm-lock.yaml | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/frontend/package.json b/frontend/package.json index 85e53b55..69e28190 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -38,7 +38,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", - "dompurify": "^3.4.0", + "dompurify": "^3.4.9", "graphql": "^16.13.2", "lucide-react": "^0.511.0", "react": "^19.2.4", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index c29e336b..bf93014f 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -75,8 +75,8 @@ importers: specifier: ^4.1.0 version: 4.1.0 dompurify: - specifier: ^3.4.0 - version: 3.4.0 + specifier: ^3.4.9 + version: 3.4.10 graphql: specifier: ^16.13.2 version: 16.13.2 @@ -2093,8 +2093,8 @@ packages: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} - dompurify@3.4.0: - resolution: {integrity: sha512-nolgK9JcaUXMSmW+j1yaSvaEaoXYHwWyGJlkoCTghc97KgGDDSnpoU/PlEnw63Ah+TGKFOyY+X5LnxaWbCSfXg==} + dompurify@3.4.10: + resolution: {integrity: sha512-0xzNv0e7oYC6yyuOGZIABPM4qtg3QxLFniDNPP4ZP90wR8Yq3zgwpRbrNiT4N3IKqDbbYFEJLV+JWEs19aZ//w==} dot-case@3.0.4: resolution: {integrity: sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==} @@ -6084,7 +6084,7 @@ snapshots: dependencies: esutils: 2.0.3 - dompurify@3.4.0: + dompurify@3.4.10: optionalDependencies: '@types/trusted-types': 2.0.7