Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/main/app/composition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1263,6 +1263,9 @@ export async function createMainProcessControl(dependencies: {
projection: sessionQuery,
desktop: {
openSession: (sessionId) => openRemoteSession(sessionId)
},
notifications: {
showNotification: (options) => notificationService.showNotification(options)
}
})
cronJobs = new SchedulerService({
Expand Down
6 changes: 6 additions & 0 deletions src/main/remote/channels/feishu/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,15 @@ type FeishuAdapterDeps = {
bindingStore: RemoteBindingStore
createConversationRunner: () => RemoteConversationRunner
onFatalError?: (message: string) => Promise<void> | void
onDeliveryError?: (message: string) => void
configSignature?: string
}

export class FeishuAdapter extends ChannelAdapter {
private readonly bindingStore: RemoteBindingStore
private readonly createConversationRunner: () => RemoteConversationRunner
private readonly onFatalError?: (message: string) => Promise<void> | void
private readonly onDeliveryError?: (message: string) => void
private readonly credentials: {
brand: FeishuBrand
appId: string
Expand All @@ -47,6 +49,7 @@ export class FeishuAdapter extends ChannelAdapter {
this.bindingStore = deps.bindingStore
this.createConversationRunner = deps.createConversationRunner
this.onFatalError = deps.onFatalError
this.onDeliveryError = deps.onDeliveryError
this.credentials = {
brand: config.channelConfig.brand === 'lark' ? 'lark' : 'feishu',
appId: String(config.channelConfig.appId ?? '').trim(),
Expand Down Expand Up @@ -80,6 +83,9 @@ export class FeishuAdapter extends ChannelAdapter {
},
onFatalError: (message) => {
void this.onFatalError?.(message)
},
onDeliveryError: (message) => {
this.onDeliveryError?.(message)
}
})

Expand Down
18 changes: 18 additions & 0 deletions src/main/remote/channels/feishu/feishuRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ const safeErrorMessage = (error: unknown): string =>
error instanceof Error ? error.message : String(error)

const FEISHU_INTERNAL_ERROR_REPLY = 'An internal error occurred while processing your request.'
const FEISHU_INBOUND_MESSAGE_ERROR = 'Failed to handle inbound message.'
const FEISHU_REPLY_DELIVERY_ERROR = 'Failed to deliver reply to Feishu.'
const FEISHU_STREAMING_CARD_FALLBACK_NOTICE =
'Feishu CardKit streaming failed. Falling back to normal message updates. Check that the app has im:message and cardkit:card:write permissions.'

Expand All @@ -45,6 +47,7 @@ type FeishuRuntimeDeps = {
}
onStatusChange?: (snapshot: FeishuRuntimeStatusSnapshot) => void
onFatalError?: (message: string) => void
onDeliveryError?: (message: string) => void
}

type FeishuProcessedInboundEntry = {
Expand Down Expand Up @@ -321,6 +324,10 @@ export class FeishuRuntime {
}
}
}

if (this.isCurrentRun(runId) && this.statusSnapshot.lastError) {
this.setStatus({ lastError: null })
}
} catch (error) {
const diagnostics = {
runId,
Expand All @@ -345,6 +352,10 @@ export class FeishuRuntime {
return
}

// Keep the runtime running but surface the failure in the status snapshot
// (settings page and /status), since the user otherwise sees nothing.
this.setStatus({ lastError: FEISHU_INBOUND_MESSAGE_ERROR })

try {
if (!this.isCurrentRun(runId)) {
return
Expand All @@ -358,6 +369,13 @@ export class FeishuRuntime {
eventId: message.eventId,
error: sendError
})
if (this.isCurrentRun(runId)) {
const deliveryError = FEISHU_REPLY_DELIVERY_ERROR
this.setStatus({
lastError: deliveryError
})
this.deps.onDeliveryError?.(deliveryError)
}
}
}
}
Expand Down
46 changes: 46 additions & 0 deletions src/main/remote/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ import {

const DEFAULT_CHANNEL_ID = 'default'
const WEIXIN_TRACE_LOG_ENABLED = process.env.DEEPCHAT_WEIXIN_TRACE === '1'
const REMOTE_DELIVERY_ERROR_NOTIFY_INTERVAL_MS = 60 * 1000
const FEISHU_AUTH_SESSION_TTL_MS = 5 * 60 * 1000
const FEISHU_AUTH_DEFAULT_WAIT_TIMEOUT_MS = 5 * 60 * 1000
const FEISHU_INSTALL_DEFAULT_WAIT_TIMEOUT_MS = 5 * 60 * 1000
Expand Down Expand Up @@ -222,6 +223,8 @@ export class RemoteService {
private weixinIlinkLoginWindow: BrowserWindow | null = null
private weixinIlinkLoginWindowUrl: string | null = null
private readonly weixinIlinkLoginWaits = new Map<string, Promise<WeixinIlinkLoginResult>>()
private readonly deliveryErrorNotifiedAt = new Map<RemoteChannel, number>()
private readonly deliveryErrorNotificationInFlight = new Set<RemoteChannel>()

constructor(private readonly deps: RemoteServiceDeps) {
this.bindingStore = new RemoteBindingStore(this.deps.settings)
Expand Down Expand Up @@ -1255,6 +1258,9 @@ export class RemoteService {
await this.disableFeishuRuntimeForFatalError(config.configSignature ?? '', message)
})
},
onDeliveryError: (message) => {
this.notifyChannelDeliveryError('feishu', 'Feishu', message)
},
configSignature: config.configSignature
})
})
Expand Down Expand Up @@ -2560,6 +2566,46 @@ export class RemoteService {
this.weixinIlinkLoginWindowUrl = null
}

private notifyChannelDeliveryError(
channel: RemoteChannel,
channelLabel: string,
message: string
): void {
const notifications = this.deps.notifications
if (!notifications || this.deliveryErrorNotificationInFlight.has(channel)) {
return
}

// Throttled: a dead proxy fails every message; one notification per minute is enough.
const now = Date.now()
const lastNotifiedAt = this.deliveryErrorNotifiedAt.get(channel) ?? 0
if (now - lastNotifiedAt < REMOTE_DELIVERY_ERROR_NOTIFY_INTERVAL_MS) {
return
}

this.deliveryErrorNotificationInFlight.add(channel)
void notifications
.showNotification({
id: `remote-delivery-error:${channel}`,
title: `DeepChat ${channelLabel} Remote`,
body: message
})
.then((notificationId) => {
if (notificationId) {
this.deliveryErrorNotifiedAt.set(channel, now)
}
Comment on lines +2593 to +2596

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Record the throttle timestamp when notification delivery succeeds.

A notification lasting over one minute stores an already-expired now, allowing another alert immediately after completion.

  • src/main/remote/index.ts#L2593-L2596: set the value with Date.now() inside the success handler.
  • test/main/remote/remoteService.test.ts#L222-L240: wait for in-flight completion before the second call so the test exercises time throttling rather than only in-flight deduplication.
Proposed fix
       .then((notificationId) => {
         if (notificationId) {
-          this.deliveryErrorNotifiedAt.set(channel, now)
+          this.deliveryErrorNotifiedAt.set(channel, Date.now())
         }
       })
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
.then((notificationId) => {
if (notificationId) {
this.deliveryErrorNotifiedAt.set(channel, now)
}
.then((notificationId) => {
if (notificationId) {
this.deliveryErrorNotifiedAt.set(channel, Date.now())
}
})
📍 Affects 2 files
  • src/main/remote/index.ts#L2593-L2596 (this comment)
  • test/main/remote/remoteService.test.ts#L222-L240
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/remote/index.ts` around lines 2593 - 2596, Update the success
handler in the notification delivery promise in src/main/remote/index.ts (lines
2593-2596) to store the current Date.now() value in deliveryErrorNotifiedAt when
notification delivery succeeds, rather than the earlier now timestamp. In
test/main/remote/remoteService.test.ts (lines 222-240), await in-flight
completion before issuing the second call so the test validates time-based
throttling after delivery completes.

})
.catch((error) => {
logger.warn('[RemoteService] Failed to show delivery error notification.', {
channel,
error
})
})
.finally(() => {
this.deliveryErrorNotificationInFlight.delete(channel)
})
}

private createConversationRunner(channel: RemoteChannel): RemoteConversationRunner {
return new RemoteConversationRunner(
{
Expand Down
10 changes: 10 additions & 0 deletions src/main/remote/ports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,15 @@ export interface RemoteDesktopPort {
openSession(sessionId: string): Promise<boolean>
}

export interface RemoteNotificationPort {
showNotification(options: {
id: string
title: string
body: string
silent?: boolean
}): Promise<string | undefined>
}

export interface RemoteServiceDeps {
settings: Pick<SettingsStore, 'get' | 'set'>
catalog: RemoteCatalogPort
Expand All @@ -73,6 +82,7 @@ export interface RemoteServiceDeps {
assignment: RemoteSessionAssignmentPort
projection: RemoteSessionProjectionPort
desktop: RemoteDesktopPort
notifications?: RemoteNotificationPort
}

export interface RemoteRuntimeLifecycle {
Expand Down
29 changes: 29 additions & 0 deletions test/main/remote/feishuAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { FeishuRuntimeStatusSnapshot } from '@/remote/types'
type MockRuntimeDeps = {
onStatusChange?: (snapshot: FeishuRuntimeStatusSnapshot) => void
onFatalError?: (message: string) => void
onDeliveryError?: (message: string) => void
}

const runtimeInstances: Array<{
Expand Down Expand Up @@ -121,6 +122,34 @@ describe('FeishuAdapter', () => {
expect(onFatalError).toHaveBeenCalledWith('fatal feishu error')
})

it('forwards delivery errors from the wrapped runtime', async () => {
const onDeliveryError = vi.fn()
const adapter = new FeishuAdapter(
{
channelId: 'default',
channelType: 'feishu',
agentId: 'deepchat',
channelConfig: {
appId: 'cli_a',
appSecret: 'secret',
verificationToken: 'verify',
encryptKey: 'encrypt'
},
configSignature: 'feishu:test'
},
{
bindingStore: {} as any,
createConversationRunner: () => ({}) as any,
onDeliveryError
}
)

await adapter.connect()
runtimeInstances[0].deps.onDeliveryError?.('Failed to deliver reply to Feishu.')

expect(onDeliveryError).toHaveBeenCalledWith('Failed to deliver reply to Feishu.')
})

it('returns Feishu image message ids from sendImage', async () => {
const adapter = new FeishuAdapter(
{
Expand Down
94 changes: 93 additions & 1 deletion test/main/remote/feishuRuntime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ const createDeferred = <T>() => {
const createHarness = async (options?: {
logger?: { error: (...params: unknown[]) => void }
enableStreamingCards?: boolean
onDeliveryError?: (message: string) => void
}) => {
let onMessage: ((event: unknown) => Promise<void>) | null = null
const streamHandlers: Array<(event: unknown) => Promise<void>> = []
Expand Down Expand Up @@ -109,7 +110,8 @@ const createHarness = async (options?: {
router: router as any,
bindingStore: bindingStore as any,
enableStreamingCards: options?.enableStreamingCards,
logger: options?.logger
logger: options?.logger,
onDeliveryError: options?.onDeliveryError
})
await runtime.start()

Expand Down Expand Up @@ -1285,6 +1287,96 @@ describe('FeishuRuntime', () => {
messageId: 'om-error',
eventId: 'evt-error'
})
expect(harness.runtime.getStatusSnapshot().lastError).toBe('Failed to handle inbound message.')

await harness.runtime.stop()
})

it('records handling failures in the status snapshot without stopping the runtime', async () => {
const harness = await createHarness()
harness.router.handleMessage.mockRejectedValueOnce(new Error('Insufficient Balance'))

await harness.emitMessage({
parsed: createParsedMessage({
eventId: 'evt-error',
messageId: 'om-error'
})
})

await vi.waitFor(() => {
expect(harness.runtime.getStatusSnapshot()).toMatchObject({
state: 'running',
lastError: 'Failed to handle inbound message.'
})
})

harness.router.handleMessage.mockResolvedValueOnce({
replies: ['ok']
})
await harness.emitMessage({
parsed: createParsedMessage({
eventId: 'evt-recovered',
messageId: 'om-recovered'
})
})

await vi.waitFor(() => {
expect(harness.runtime.getStatusSnapshot()).toMatchObject({
state: 'running',
lastError: null
})
})

await harness.runtime.stop()
})

it('records reply delivery failures when even the error reply cannot be sent', async () => {
const onDeliveryError = vi.fn()
const harness = await createHarness({ onDeliveryError })
harness.router.handleMessage.mockRejectedValueOnce(new Error('Insufficient Balance'))
harness.client.sendText.mockRejectedValueOnce(
new Error('Connect Timeout Error (attempted address: 127.0.0.1:7890)')
)

await harness.emitMessage({
parsed: createParsedMessage({
eventId: 'evt-error',
messageId: 'om-error'
})
})

const expectedDeliveryError = 'Failed to deliver reply to Feishu.'
await vi.waitFor(() => {
expect(harness.runtime.getStatusSnapshot()).toMatchObject({
state: 'running',
lastError: expectedDeliveryError
})
})
expect(onDeliveryError).toHaveBeenCalledWith(expectedDeliveryError)

await harness.runtime.stop()
})

it('does not raise the delivery fallback when the error reply is sent successfully', async () => {
const onDeliveryError = vi.fn()
const harness = await createHarness({ onDeliveryError })
harness.router.handleMessage.mockRejectedValueOnce(new Error('Insufficient Balance'))

await harness.emitMessage({
parsed: createParsedMessage({
eventId: 'evt-error',
messageId: 'om-error'
})
})

await vi.waitFor(() => {
expect(harness.client.sendText).toHaveBeenCalled()
expect(harness.runtime.getStatusSnapshot()).toMatchObject({
state: 'running',
lastError: 'Failed to handle inbound message.'
})
})
expect(onDeliveryError).not.toHaveBeenCalled()

await harness.runtime.stop()
})
Expand Down
Loading
Loading