|
|
@@ -8,6 +8,9 @@
|
|
|
*/
|
|
|
|
|
|
import { describe, expect, it, vi } from 'vitest'
|
|
|
+import { mkdtemp, readFile, rm } from 'node:fs/promises'
|
|
|
+import { tmpdir } from 'node:os'
|
|
|
+import { join } from 'node:path'
|
|
|
import { Context } from '@deepseek-ai/cordis'
|
|
|
import { z } from 'zod'
|
|
|
import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
|
|
|
@@ -19,6 +22,10 @@ import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
|
|
import type { Session } from '@deepseek-ai/dsh-session'
|
|
|
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
|
|
|
import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
|
|
|
+import SessionProjectionCache, { projectionCacheDomainSpec } from '@deepseek-ai/dsh-session-projection-cache'
|
|
|
+import Storage from '@deepseek-ai/dsh-storage'
|
|
|
+import * as StorageDomain from '@deepseek-ai/dsh-storage-domain'
|
|
|
+import * as StorageJson from '@deepseek-ai/dsh-storage-json'
|
|
|
import { SessionControlController } from '@deepseek-ai/dsh-api-session-controller/src/control.ts'
|
|
|
import type { SessionControlFrame, SessionFollowFrame } from '@deepseek-ai/dsh-api-session-controller/types'
|
|
|
import { createSessionTestRemote, type TestSessionRemote } from './test-remote.ts'
|
|
|
@@ -27,6 +34,7 @@ declare module '@deepseek-ai/dsh-session-projection/types' {
|
|
|
interface SessionProjectionStateMap {
|
|
|
'test/last-user': LastUserState
|
|
|
'test/internal-count': number
|
|
|
+ 'test/private-prompt': string | null
|
|
|
}
|
|
|
interface SessionProjectionMap {
|
|
|
'test/last-user': { text: string } | null
|
|
|
@@ -91,6 +99,16 @@ const internalCountUnit = () => ({
|
|
|
stateVersion: 1,
|
|
|
}) satisfies ProjectionDefinition<'test/internal-count', number>
|
|
|
|
|
|
+const privatePromptUnit = () => ({
|
|
|
+ key: 'test/private-prompt',
|
|
|
+ stateSchema: z.string().nullable(),
|
|
|
+ init: () => null,
|
|
|
+ apply: (state, event) => (event.type === 'user/message'
|
|
|
+ ? (event.data.content[0] as { text?: string }).text ?? ''
|
|
|
+ : state),
|
|
|
+ stateVersion: 1,
|
|
|
+}) satisfies ProjectionDefinition<'test/private-prompt', string | null>
|
|
|
+
|
|
|
async function harness(withRegistry: boolean): Promise<{ ctx: Context; session: Session }> {
|
|
|
const ctx = new Context()
|
|
|
await ctx.plugin(SessionStore)
|
|
|
@@ -420,6 +438,60 @@ describe('session.list projections column', () => {
|
|
|
})
|
|
|
})
|
|
|
|
|
|
+ it('keeps persisted host-only state out of a cold session.list response', async () => {
|
|
|
+ const root = await mkdtemp(join(tmpdir(), 'dsh-api-projcache-'))
|
|
|
+ const ctx = new Context()
|
|
|
+ try {
|
|
|
+ await ctx.plugin(Storage)
|
|
|
+ await ctx.plugin(StorageJson, { root })
|
|
|
+ await ctx.plugin(StorageDomain, { backend: 'json' })
|
|
|
+ await ctx.plugin(SessionStore)
|
|
|
+ await ctx.plugin(AgentRegistry)
|
|
|
+ await ctx.plugin(SessionProjectionRegistry)
|
|
|
+ ctx.sessionProjections.register(privatePromptUnit())
|
|
|
+ await ctx.plugin(SessionProjectionCache, { writeEveryEvents: 100, writeIntervalMs: 60_000 })
|
|
|
+ const gateway = remote(ctx)
|
|
|
+ await new Promise(resolve => setTimeout(resolve, 0))
|
|
|
+
|
|
|
+ const id = SessionId('session-cold-host-state')
|
|
|
+ const secret = 'private prompt text from the cache'
|
|
|
+ let session: Session | undefined
|
|
|
+ const owner = await ctx.plugin(Object.assign((sessionCtx: Context) => {
|
|
|
+ session = sessionCtx.sessions.create(id, { meta: { createdAt: 5, cwd: '/workspace' } })
|
|
|
+ }, { inject: ['sessions'] }))
|
|
|
+ if (session === undefined) throw new Error('session was not created')
|
|
|
+ session.append('turn/start', { turn: 1 })
|
|
|
+ session.append('user/message', createUserMessage({
|
|
|
+ content: [{ type: 'text', text: secret }],
|
|
|
+ source: { kind: 'user' },
|
|
|
+ }), { surfaceOp: 'append' })
|
|
|
+ await ctx.sessionProjectionCache.write(session)
|
|
|
+ const stored = await readFile(
|
|
|
+ join(root, projectionCacheDomainSpec.name, 'sessions', `${id}.json`),
|
|
|
+ 'utf8',
|
|
|
+ )
|
|
|
+ expect(stored).toContain(secret)
|
|
|
+
|
|
|
+ const header = session.header
|
|
|
+ await owner.dispose()
|
|
|
+ expect(ctx.sessions.get(id)).toBeUndefined()
|
|
|
+ ctx.provide('sessionPersistence', {
|
|
|
+ list: async () => [header],
|
|
|
+ locate: () => undefined,
|
|
|
+ } as never)
|
|
|
+
|
|
|
+ const response = await gateway.list(request({}))
|
|
|
+ if (!response.ok) throw new Error('unreachable')
|
|
|
+ const row = response.value.items.find(item => item.sessionId === id)
|
|
|
+ expect(row?.projections?.values.sessionListMetadata).toMatchObject({ blank: false })
|
|
|
+ expect('test/private-prompt' in (row?.projections?.values ?? {})).toBe(false)
|
|
|
+ expect(JSON.stringify(row)).not.toContain(secret)
|
|
|
+ } finally {
|
|
|
+ await ctx.fiber.dispose()
|
|
|
+ await rm(root, { recursive: true, force: true })
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
it('cold rows without a cache plugin (or without a stored row) just lack the column', async () => {
|
|
|
const { ctx } = await harness(true)
|
|
|
const coldId = SessionId('session-cold-uncached')
|