• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

stacklok / toolhive-studio / 29571051485

17 Jul 2026 09:44AM UTC coverage: 71.713% (+0.5%) from 71.204%
29571051485

Pull #2475

github

samuv
fix(chat): harden Effect runtime adapters after external review

Make readiness and runtime retrieval atomic, type adapter programs against
ChatServices, and tighten domain-error narrowing plus agent/settings edge cases.

Co-authored-by: Cursor <cursoragent@cursor.com>
Pull Request #2475: refactor(chat): migrate main-process chat to Effect domain services

5476 of 8187 branches covered (66.89%)

871 of 1363 new or added lines in 42 files covered. (63.9%)

1 existing line in 1 file now uncovered.

8153 of 11369 relevant lines covered (71.71%)

137.65 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

48.05
/main/src/chat/threads/threads-service.ts
1
import { Effect } from 'effect'
2
import {
3
  ThreadAlreadyExistsError,
4
  ThreadNotFoundError,
5
} from '../runtime/errors'
6
import { ThreadsRepository } from './threads-repository'
7
import type { ChatSettingsThread, ThreadMessage, ThreadUpdates } from './types'
8

9
function generateThreadId(): string {
10
  return `thread_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`
2✔
11
}
12

13
export class ThreadsService extends Effect.Service<ThreadsService>()(
14
  'chat/ThreadsService',
15
  {
16
    accessors: true,
17
    effect: Effect.gen(function* () {
18
      const repo = yield* ThreadsRepository
121✔
19

20
      const getThreadOrFail = (threadId: string) =>
121✔
21
        Effect.gen(function* () {
10✔
22
          const thread = yield* repo.readThread(threadId)
10✔
23
          if (!thread) {
10!
NEW
24
            return yield* Effect.fail(
×
25
              new ThreadNotFoundError({
26
                threadId,
27
                userMessage: 'Thread not found',
28
              })
29
            )
30
          }
31
          return thread
10✔
32
        })
33

34
      return {
121✔
35
        createThread: (
36
          title?: string,
37
          initialMessages: ThreadMessage[] = [],
3✔
38
          explicitId?: string
39
        ) =>
40
          Effect.gen(function* () {
3✔
41
            const threadId = explicitId ?? generateThreadId()
3✔
42
            if (explicitId) {
3✔
43
              const existing = yield* repo.readThread(explicitId)
2✔
44
              if (existing) {
2✔
45
                return yield* Effect.fail(
1✔
46
                  new ThreadAlreadyExistsError({
47
                    threadId: explicitId,
48
                    userMessage: `Thread ${explicitId} already exists`,
49
                  })
50
                )
51
              }
52
            }
53

54
            const now = Date.now()
2✔
55
            const newThread: ChatSettingsThread = {
2✔
56
              id: threadId,
57
              title,
58
              messages: initialMessages,
59
              lastEditTimestamp: now,
60
              createdAt: now,
61
            }
62

63
            yield* repo.writeThread(newThread)
2✔
64
            yield* repo.writeActiveThread(threadId)
2✔
65
            return { threadId }
2✔
66
          }),
67

68
        getThread: (threadId: string) =>
69
          repo
28✔
70
            .readThread(threadId)
NEW
71
            .pipe(Effect.catchTag('StorageError', () => Effect.succeed(null))),
×
72

73
        getAllThreads: () =>
NEW
74
          repo
×
75
            .readAllThreads()
NEW
76
            .pipe(Effect.catchTag('StorageError', () => Effect.succeed([]))),
×
77

78
        updateThread: (threadId: string, updates: ThreadUpdates) =>
79
          Effect.gen(function* () {
10✔
80
            const existing = yield* getThreadOrFail(threadId)
10✔
81
            const updatedThread: ChatSettingsThread = {
10✔
82
              ...existing,
83
              ...updates,
84
              lastEditTimestamp: Date.now(),
85
            }
86
            yield* repo.writeThread(updatedThread)
10✔
87
          }),
88

89
        addMessageToThread: (threadId: string, message: ThreadMessage) =>
NEW
90
          Effect.gen(function* () {
×
NEW
91
            const existing = yield* getThreadOrFail(threadId)
×
NEW
92
            const updatedThread: ChatSettingsThread = {
×
93
              ...existing,
94
              messages: [...existing.messages, message],
95
              lastEditTimestamp: Date.now(),
96
            }
NEW
97
            yield* repo.writeThread(updatedThread)
×
98
          }),
99

100
        updateThreadMessages: (threadId: string, messages: ThreadMessage[]) =>
101
          repo.updateMessages(threadId, messages).pipe(
13✔
102
            Effect.mapError((error) =>
103
              error.userMessage === 'Thread not found'
2!
104
                ? new ThreadNotFoundError({
105
                    threadId,
106
                    userMessage: 'Thread not found',
107
                  })
108
                : error
109
            )
110
          ),
111

112
        deleteThread: (threadId: string) =>
NEW
113
          Effect.gen(function* () {
×
NEW
114
            yield* getThreadOrFail(threadId)
×
NEW
115
            yield* repo.deleteThread(threadId)
×
NEW
116
            const activeThreadId = yield* repo.readActiveThreadId()
×
NEW
117
            if (activeThreadId === threadId) {
×
NEW
118
              yield* repo.writeActiveThread(undefined)
×
119
            }
120
          }),
121

122
        getActiveThreadId: () =>
NEW
123
          repo
×
124
            .readActiveThreadId()
125
            .pipe(
NEW
126
              Effect.catchTag('StorageError', () => Effect.succeed(undefined))
×
127
            ),
128

129
        setActiveThreadId: (threadId: string | undefined) =>
NEW
130
          Effect.gen(function* () {
×
NEW
131
            if (threadId) {
×
NEW
132
              yield* getThreadOrFail(threadId)
×
133
            }
NEW
134
            yield* repo.writeActiveThread(threadId)
×
135
          }),
136

NEW
137
        clearAllThreads: () => repo.clearAllThreads(),
×
138

139
        getThreadCount: () =>
NEW
140
          repo
×
141
            .readThreadCount()
NEW
142
            .pipe(Effect.catchTag('StorageError', () => Effect.succeed(0))),
×
143

144
        ensureThreadExists: (threadId?: string, title?: string) =>
145
          Effect.gen(function* () {
4✔
146
            if (threadId) {
4✔
147
              const existing = yield* repo.readThread(threadId)
3✔
148
              if (existing) {
3✔
149
                yield* repo.writeActiveThread(threadId)
1✔
150
                return { threadId, isNew: false as const }
1✔
151
              }
152
            }
153

154
            const id = threadId ?? generateThreadId()
3✔
155
            const now = Date.now()
4✔
156
            yield* repo.writeThread({
4✔
157
              id,
158
              title,
159
              messages: [],
160
              lastEditTimestamp: now,
161
              createdAt: now,
162
            })
163
            yield* repo.writeActiveThread(id)
2✔
164
            return { threadId: id, isNew: true as const }
2✔
165
          }),
166

167
        getThreadInfo: (threadId: string) =>
NEW
168
          Effect.gen(function* () {
×
NEW
169
            const thread = yield* repo.readThread(threadId)
×
NEW
170
            if (!thread) {
×
NEW
171
              return {
×
172
                thread: null,
173
                messageCount: 0,
174
                lastActivity: null,
175
                hasUserMessages: false,
176
                hasAssistantMessages: false,
177
              }
178
            }
179

NEW
180
            const messageCount = thread.messages.length
×
NEW
181
            const lastActivity = new Date(thread.lastEditTimestamp)
×
NEW
182
            const hasUserMessages = thread.messages.some(
×
NEW
183
              (msg) => msg.role === 'user'
×
184
            )
NEW
185
            const hasAssistantMessages = thread.messages.some(
×
NEW
186
              (msg) => msg.role === 'assistant'
×
187
            )
188

NEW
189
            return {
×
190
              thread,
191
              messageCount,
192
              lastActivity,
193
              hasUserMessages,
194
              hasAssistantMessages,
195
            }
196
          }).pipe(
197
            Effect.catchTag('StorageError', () =>
NEW
198
              Effect.succeed({
×
199
                thread: null,
200
                messageCount: 0,
201
                lastActivity: null,
202
                hasUserMessages: false,
203
                hasAssistantMessages: false,
204
              })
205
            )
206
          ),
207

208
        getThreadMessagesForTransport: (threadId: string) =>
NEW
209
          Effect.gen(function* () {
×
NEW
210
            const thread = yield* repo.readThread(threadId)
×
NEW
211
            if (!thread?.messages?.length) return []
×
NEW
212
            return thread.messages
×
NEW
213
          }).pipe(Effect.catchTag('StorageError', () => Effect.succeed([]))),
×
214
      }
215
    }),
216
    dependencies: [ThreadsRepository.Default],
217
  }
218
) {}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc