chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
import { redisConfigMock, redisConfigMockFns } from '@sim/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
MothershipStreamV1EventType,
|
||||
MothershipStreamV1TextChannel,
|
||||
} from '@/lib/copilot/generated/mothership-stream-v1'
|
||||
import { createEvent } from '@/lib/copilot/request/session/event'
|
||||
|
||||
type StoredEnvelope = {
|
||||
score: number
|
||||
value: string
|
||||
}
|
||||
|
||||
const createRedisStub = () => {
|
||||
const counters = new Map<string, number>()
|
||||
const values = new Map<string, string>()
|
||||
const sortedSets = new Map<string, StoredEnvelope[]>()
|
||||
|
||||
const api = {
|
||||
incr: vi.fn().mockImplementation((key: string) => {
|
||||
const next = (counters.get(key) ?? 0) + 1
|
||||
counters.set(key, next)
|
||||
return next
|
||||
}),
|
||||
expire: vi.fn().mockResolvedValue(1),
|
||||
del: vi.fn().mockImplementation((...keys: string[]) => {
|
||||
for (const key of keys) {
|
||||
values.delete(key)
|
||||
sortedSets.delete(key)
|
||||
counters.delete(key)
|
||||
}
|
||||
return Promise.resolve(keys.length)
|
||||
}),
|
||||
zadd: vi.fn().mockImplementation((key: string, score: number, value: string) => {
|
||||
const entries = sortedSets.get(key) ?? []
|
||||
entries.push({ score, value })
|
||||
sortedSets.set(key, entries)
|
||||
return Promise.resolve(1)
|
||||
}),
|
||||
zremrangebyrank: vi.fn().mockImplementation((key: string, start: number, stop: number) => {
|
||||
const entries = [...(sortedSets.get(key) ?? [])].sort((a, b) => a.score - b.score)
|
||||
const normalizedStart = start < 0 ? Math.max(entries.length + start, 0) : start
|
||||
const normalizedStop = stop < 0 ? entries.length + stop : stop
|
||||
const next = entries.filter(
|
||||
(_entry, index) => index < normalizedStart || index > normalizedStop
|
||||
)
|
||||
sortedSets.set(key, next)
|
||||
return Promise.resolve(1)
|
||||
}),
|
||||
zrangebyscore: vi.fn().mockImplementation((key: string, min: number, max: string) => {
|
||||
const upperBound = max === '+inf' ? Number.POSITIVE_INFINITY : Number(max)
|
||||
const entries = [...(sortedSets.get(key) ?? [])]
|
||||
.filter((entry) => entry.score >= min && entry.score <= upperBound)
|
||||
.sort((a, b) => a.score - b.score)
|
||||
.map((entry) => entry.value)
|
||||
return Promise.resolve(entries)
|
||||
}),
|
||||
set: vi.fn().mockImplementation((key: string, value: string) => {
|
||||
values.set(key, value)
|
||||
return Promise.resolve('OK')
|
||||
}),
|
||||
get: vi.fn().mockImplementation((key: string) => Promise.resolve(values.get(key) ?? null)),
|
||||
pipeline: vi.fn().mockImplementation(() => {
|
||||
const operations: Array<() => Promise<unknown>> = []
|
||||
const pipeline = {
|
||||
zadd: (...args: [string, number, string]) => {
|
||||
operations.push(() => api.zadd(...args))
|
||||
return pipeline
|
||||
},
|
||||
expire: (...args: [string, number]) => {
|
||||
operations.push(() => api.expire(...args))
|
||||
return pipeline
|
||||
},
|
||||
set: (...args: [string, string, 'EX', number]) => {
|
||||
operations.push(() => api.set(args[0], args[1]))
|
||||
return pipeline
|
||||
},
|
||||
zremrangebyrank: (...args: [string, number, number]) => {
|
||||
operations.push(() => api.zremrangebyrank(...args))
|
||||
return pipeline
|
||||
},
|
||||
exec: vi.fn().mockImplementation(async () => {
|
||||
const results: Array<[null, unknown]> = []
|
||||
for (const operation of operations) {
|
||||
results.push([null, await operation()])
|
||||
}
|
||||
return results
|
||||
}),
|
||||
}
|
||||
return pipeline
|
||||
}),
|
||||
}
|
||||
|
||||
return api
|
||||
}
|
||||
|
||||
let mockRedis: ReturnType<typeof createRedisStub>
|
||||
|
||||
vi.mock('@/lib/core/config/redis', () => redisConfigMock)
|
||||
|
||||
import {
|
||||
allocateCursor,
|
||||
appendEvent,
|
||||
clearBuffer,
|
||||
readEvents,
|
||||
scheduleBufferCleanup,
|
||||
} from '@/lib/copilot/request/session/buffer'
|
||||
|
||||
describe('mothership-stream-outbox', () => {
|
||||
beforeEach(() => {
|
||||
mockRedis = createRedisStub()
|
||||
vi.clearAllMocks()
|
||||
redisConfigMockFns.mockGetRedisClient.mockImplementation(() => mockRedis)
|
||||
})
|
||||
|
||||
it('replays envelopes after a given cursor', async () => {
|
||||
const firstCursor = await allocateCursor('stream-1')
|
||||
const secondCursor = await allocateCursor('stream-1')
|
||||
|
||||
await appendEvent(
|
||||
createEvent({
|
||||
streamId: 'stream-1',
|
||||
cursor: firstCursor.cursor,
|
||||
seq: firstCursor.seq,
|
||||
requestId: 'req-1',
|
||||
type: MothershipStreamV1EventType.text,
|
||||
payload: { channel: MothershipStreamV1TextChannel.assistant, text: 'hello' },
|
||||
})
|
||||
)
|
||||
await appendEvent(
|
||||
createEvent({
|
||||
streamId: 'stream-1',
|
||||
cursor: secondCursor.cursor,
|
||||
seq: secondCursor.seq,
|
||||
requestId: 'req-1',
|
||||
type: MothershipStreamV1EventType.text,
|
||||
payload: { channel: MothershipStreamV1TextChannel.assistant, text: 'world' },
|
||||
})
|
||||
)
|
||||
|
||||
const allEvents = await readEvents('stream-1', '0')
|
||||
expect(allEvents.map((entry) => entry.payload.text)).toEqual(['hello', 'world'])
|
||||
|
||||
const replayed = await readEvents('stream-1', '1')
|
||||
expect(replayed.map((entry) => entry.payload.text)).toEqual(['world'])
|
||||
})
|
||||
|
||||
it('trims active stream history to eventLimit on every append', async () => {
|
||||
const cursor = await allocateCursor('stream-1')
|
||||
|
||||
await appendEvent(
|
||||
createEvent({
|
||||
streamId: 'stream-1',
|
||||
cursor: cursor.cursor,
|
||||
seq: cursor.seq,
|
||||
requestId: 'req-1',
|
||||
type: MothershipStreamV1EventType.text,
|
||||
payload: { channel: MothershipStreamV1TextChannel.assistant, text: 'hello' },
|
||||
})
|
||||
)
|
||||
|
||||
expect(mockRedis.zremrangebyrank).toHaveBeenCalledWith(
|
||||
'mothership_stream:stream-1:events',
|
||||
0,
|
||||
-100_001
|
||||
)
|
||||
})
|
||||
|
||||
it('clears persisted stream state during teardown cleanup', async () => {
|
||||
const cursor = await allocateCursor('stream-1')
|
||||
|
||||
await appendEvent(
|
||||
createEvent({
|
||||
streamId: 'stream-1',
|
||||
cursor: cursor.cursor,
|
||||
seq: cursor.seq,
|
||||
requestId: 'req-1',
|
||||
type: MothershipStreamV1EventType.text,
|
||||
payload: { channel: MothershipStreamV1TextChannel.assistant, text: 'hello' },
|
||||
})
|
||||
)
|
||||
|
||||
expect((await readEvents('stream-1', '0')).length).toBe(1)
|
||||
|
||||
await clearBuffer('stream-1')
|
||||
|
||||
expect(await readEvents('stream-1', '0')).toEqual([])
|
||||
})
|
||||
|
||||
it('shortens completed stream retention without deleting replay data immediately', async () => {
|
||||
const cursor = await allocateCursor('stream-1')
|
||||
|
||||
await appendEvent(
|
||||
createEvent({
|
||||
streamId: 'stream-1',
|
||||
cursor: cursor.cursor,
|
||||
seq: cursor.seq,
|
||||
requestId: 'req-1',
|
||||
type: MothershipStreamV1EventType.text,
|
||||
payload: { channel: MothershipStreamV1TextChannel.assistant, text: 'hello' },
|
||||
})
|
||||
)
|
||||
|
||||
await scheduleBufferCleanup('stream-1', 30)
|
||||
|
||||
expect(mockRedis.expire).toHaveBeenCalledWith('mothership_stream:stream-1:events', 30)
|
||||
expect(mockRedis.expire).toHaveBeenCalledWith('mothership_stream:stream-1:seq', 30)
|
||||
expect(mockRedis.expire).toHaveBeenCalledWith('mothership_stream:stream-1:abort', 30)
|
||||
expect((await readEvents('stream-1', '0')).map((entry) => entry.payload.text)).toEqual([
|
||||
'hello',
|
||||
])
|
||||
})
|
||||
|
||||
it('skips corrupt replay entries that fail stream validation', async () => {
|
||||
const cursor = await allocateCursor('stream-1')
|
||||
|
||||
await appendEvent(
|
||||
createEvent({
|
||||
streamId: 'stream-1',
|
||||
cursor: cursor.cursor,
|
||||
seq: cursor.seq,
|
||||
requestId: 'req-1',
|
||||
type: MothershipStreamV1EventType.text,
|
||||
payload: { channel: MothershipStreamV1TextChannel.assistant, text: 'hello' },
|
||||
})
|
||||
)
|
||||
|
||||
await mockRedis.zadd(
|
||||
'mothership_stream:stream-1:events',
|
||||
cursor.seq + 1,
|
||||
JSON.stringify({
|
||||
v: 1,
|
||||
type: 'tool',
|
||||
seq: cursor.seq + 1,
|
||||
ts: '2026-04-11T00:00:00.000Z',
|
||||
stream: { streamId: 'stream-1' },
|
||||
payload: { toolCallId: 'broken-tool' },
|
||||
})
|
||||
)
|
||||
|
||||
const replayed = await readEvents('stream-1', '0')
|
||||
|
||||
expect(replayed).toHaveLength(1)
|
||||
expect(replayed[0]?.payload.text).toBe('hello')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user